Created
September 1, 2026 16:47
-
-
Save Ceare258/7d5624a3e4181562cecf4bb0e763ed56 to your computer and use it in GitHub Desktop.
ONI RESEARCH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #include <CoreImage/CoreImage.h> | |
| using namespace metal; | |
| // Rec. 2020 luminance weights, identical to those of the other kernels. | |
| constant float3 LumaWeights = float3(0.2627f, 0.6780f, 0.0593f); | |
| // Chroma denoise recombination: mixes original and blurred chroma, keeping the luma | |
| // bit-identical so film grain is preserved by construction rather than adjusted. | |
| extern "C" float4 chromaDenoise(coreimage::sample_t src, coreimage::sample_t blurred, float force) | |
| { | |
| float y = dot(src.rgb, LumaWeights); | |
| float3 chroma = src.rgb - y; | |
| float3 mixed = mix(chroma, blurred.rgb, force); | |
| // Reproject to zero luminance: otherwise the blur leaves a luminance residue that would | |
| // smooth the grain. | |
| mixed -= dot(mixed, LumaWeights); | |
| return float4(y + mixed, src.a); | |
| } | |
| // Saturation by luminance zone: shadow and highlight weights sum to 1 everywhere, split by a | |
| // smoothstep with no hard boundary. Preserves luminance so saturating never brightens. | |
| extern "C" float4 zoneSaturation(coreimage::sample_t s, float shadows, float highlights) | |
| { | |
| float y = dot(s.rgb, LumaWeights); | |
| // Split on perceived (gamma-corrected) luminance, not linear: a linear split puts the | |
| // boundary far from mid grey, so each slider would not get its intended half of the image. | |
| float perceived = pow(max(y, 0.0f), 1.0f / 2.2f); | |
| float t = smoothstep(0.0f, 1.0f, saturate(perceived)); | |
| // At t = 0 only the shadows amount acts, at t = 1 only the highlights one. | |
| float factor = max(1.0f + shadows * (1.0f - t) + highlights * t, 0.0f); | |
| float3 chroma = (s.rgb - y) * factor; | |
| chroma -= dot(chroma, LumaWeights); | |
| return float4(y + chroma, s.a); | |
| } | |
| // First moment and second, packed so a single blur carries both: the guided filter needs the local | |
| // mean and the local mean of squares over the same window. | |
| extern "C" float4 textureMoments(coreimage::sample_t luma) | |
| { | |
| float v = luma.r; | |
| return float4(v, v * v, 0.0f, 1.0f); | |
| } | |
| // The self-guided filter's two coefficients, packed so a single blur smooths both. `a` is the share | |
| // of local detail the base is allowed to follow, so an edge lands in the base instead of the band. | |
| extern "C" float4 textureCoeff(coreimage::sample_t moments, float relative) | |
| { | |
| float mean = moments.r; | |
| float variance = max(moments.g - mean * mean, 0.0f); | |
| // The threshold follows the local level, because a scene is multiplicative: pinned to mid grey | |
| // it would part the shadows from the highlights on one and the same relative texture. | |
| float edge = relative * max(mean, 0.0f); | |
| // Guards the ratio where a flat field makes both terms zero; below it the base is the mean. | |
| float eps = max(edge * edge, 1e-8f); | |
| float a = variance / (variance + eps); | |
| return float4(a, (1.0f - a) * mean, 0.0f, 1.0f); | |
| } | |
| // Mid-frequency local contrast on the luma alone, above an edge-aware base: the gain is linear, so | |
| // the supplement follows the band instead of being reshaped by it. Strong structure is in the base. | |
| extern "C" float4 texture(coreimage::sample_t src, coreimage::sample_t narrowLuma, | |
| coreimage::sample_t coeff, float gain) | |
| { | |
| float y = dot(src.rgb, LumaWeights); | |
| float3 chroma = src.rgb - y; | |
| float base = coeff.r * narrowLuma.r + coeff.g; | |
| float band = narrowLuma.r - base; | |
| float shaped = y + band * gain; | |
| return float4(max(shaped, 0.0f) + chroma, src.a); | |
| } | |
| // Unsharp mask on the luma alone; chroma is carried over untouched to avoid coloured fringes | |
| // on edges. The small radius is what keeps grain from being lifted ahead of real edges. | |
| extern "C" float4 sharpen(coreimage::sample_t src, coreimage::sample_t blurredLuma, float amount) | |
| { | |
| float y = dot(src.rgb, LumaWeights); | |
| float3 chroma = src.rgb - y; | |
| float sharpened = y + (y - blurredLuma.r) * amount; | |
| return float4(max(sharpened, 0.0f) + chroma, src.a); | |
| } | |
| // The colour mixer's eight band centres, in degrees: red, orange, yellow, green, aqua, blue, | |
| // purple, magenta. FIXED — moving a band's effect never changes which pixels that band catches. | |
| constant float BandCentre[8] = {0.0f, 30.0f, 60.0f, 120.0f, 180.0f, 240.0f, 270.0f, 300.0f}; | |
| // How far past its neighbour a band still owns a hue. Reaching only to the neighbour's centre puts | |
| // 1.5 / 30 of weight in a degree, so a firm hue boundary becomes a firm LUMINANCE one; spreading | |
| // flattens that slope, at the price of a band reaching further into its neighbours' colours. | |
| constant float BandSpread = 1.8f; | |
| // What a full pull is worth, in stops. The luma is a GAIN, never `1 + x`: that form reaches zero at | |
| // the bottom of the travel and prints a black pixel wherever a grain lands in the band, which is | |
| // speckle rather than a darker colour. An exponential cannot reach zero and needs no clamp. | |
| constant float LumaStops = 2.0f; | |
| // How much a band owns a hue, before the eight are normalised. Smoothstepped rather than a bare | |
| // ramp: a slope that jumps is a Mach band, which on luminance the eye reads as an edge. | |
| static inline float bandWeight(float hue, int i) | |
| { | |
| float centre = BandCentre[i]; | |
| float delta = hue - centre; | |
| if (delta > 180.0f) delta -= 360.0f; | |
| if (delta < -180.0f) delta += 360.0f; | |
| int neighbour = delta >= 0.0f ? ((i + 1) & 7) : ((i + 7) & 7); | |
| float span = fabs(BandCentre[neighbour] - centre); | |
| if (span > 180.0f) span = 360.0f - span; | |
| return smoothstep(0.0f, 1.0f, max(0.0f, 1.0f - fabs(delta) / (span * BandSpread))); | |
| } | |
| // Per-band hue and saturation, the colour mixer drawn as a wheel. Sits between the curves and the | |
| // finishing balance, so the panel's order is the chain's. | |
| extern "C" float4 spectrogram(coreimage::sample_t src, float4 hueLow, float4 hueHigh, | |
| float4 satLow, float4 satHigh, float4 lumaLow, float4 lumaHigh) | |
| { | |
| float y = dot(src.rgb, LumaWeights); | |
| float3 chroma = src.rgb - y; | |
| // The hue is the chroma's own angle, read in the plane every grey projects to a point of. | |
| float a = dot(chroma, float3(1.0f, -0.5f, -0.5f)); | |
| float b = dot(chroma, float3(0.0f, 0.8660254f, -0.8660254f)); | |
| // A near-neutral pixel has no meaningful angle, so the effect fades in rather than rotating | |
| // noise. The floor follows the level, a scene being multiplicative. | |
| // Wide on purpose: a hue turn is invisible on a near-neutral pixel, but a luminance change is | |
| // not, so the band the gate opens over has to be long enough not to draw itself. | |
| float presence = smoothstep(0.0f, 0.10f + 0.25f * max(y, 0.0f), length(float2(a, b))); | |
| if (presence <= 0.0f) { return src; } | |
| float hue = atan2(b, a) * 57.29577951f; | |
| if (hue < 0.0f) { hue += 360.0f; } | |
| float4 wLow = float4(bandWeight(hue, 0), bandWeight(hue, 1), | |
| bandWeight(hue, 2), bandWeight(hue, 3)); | |
| float4 wHigh = float4(bandWeight(hue, 4), bandWeight(hue, 5), | |
| bandWeight(hue, 6), bandWeight(hue, 7)); | |
| // Normalised rather than relied upon: spreading the bands makes more than two overlap, and a | |
| // sum drifting off one would show as a ring of its own. | |
| float total = dot(wLow, 1.0f) + dot(wHigh, 1.0f); | |
| if (total > 0.0f) { wLow /= total; wHigh /= total; } | |
| float shift = (dot(wLow, hueLow) + dot(wHigh, hueHigh)) * presence; | |
| float boost = (dot(wLow, satLow) + dot(wHigh, satHigh)) * presence; | |
| float lift = (dot(wLow, lumaLow) + dot(wHigh, lumaHigh)) * presence; | |
| // Rodrigues about the axis every grey sits on, then the arriving luminance re-imposed: the | |
| // rotation is only exact on that axis, and a colour does not sit on it. | |
| const float3 axis = float3(0.5773502692f); | |
| float angle = shift * 0.01745329252f; | |
| float c = cos(angle), s = sin(angle); | |
| float3 turned = src.rgb * c + cross(axis, src.rgb) * s + axis * dot(axis, src.rgb) * (1.0f - c); | |
| turned += y - dot(turned, LumaWeights); | |
| // The lift multiplies the whole colour, which in a linear space is an exposure on that band | |
| // alone: the hue and the saturation ratio come through it untouched. | |
| float3 mixed = (y + (turned - y) * max(1.0f + boost, 0.0f)) * exp2(lift * LumaStops); | |
| return float4(mixed, src.a); | |
| } | |
| #include <CoreImage/CoreImage.h> | |
| using namespace metal; | |
| // One output texel per candidate offset: the SSD, plus the offset itself, so the caller never | |
| // has to guess this kernel's own coordinate convention when reading the cost map back. | |
| extern "C" float4 correctionCost(coreimage::sampler src, | |
| float2 destCentre, | |
| float2 halfSize, | |
| float2 windowOrigin, | |
| float step, | |
| float gridN, | |
| float minRadius, | |
| float2 extentMin, | |
| float2 extentMax, | |
| float4 hole, | |
| coreimage::destination dest) | |
| { | |
| float2 offset = windowOrigin + dest.coord() * step; | |
| float2 candCentre = destCentre + offset; | |
| // Excludes a candidate overlapping its own destination, and one whose ring would sample | |
| // past the photo's own bounds — both would otherwise score a spuriously perfect match. | |
| if (length(offset) < minRadius | |
| || candCentre.x - halfSize.x < extentMin.x || candCentre.x + halfSize.x > extentMax.x | |
| || candCentre.y - halfSize.y < extentMin.y || candCentre.y + halfSize.y > extentMax.y) { | |
| return float4(INFINITY, offset.x, offset.y, 1.0); | |
| } | |
| int n = int(gridN); | |
| float3 accum = float3(0.0); | |
| int counted = 0; | |
| for (int j = 0; j < n; j++) { | |
| for (int i = 0; i < n; i++) { | |
| float2 t = (float2(float(i), float(j)) + 0.5) / float(n) * 2.0 - 1.0; | |
| float2 cell = t * halfSize; | |
| float2 destPos = destCentre + cell; | |
| float2 srcPos = candCentre + cell; | |
| // A cell sampling INSIDE the hole, on either side, compares the defect against itself | |
| // (or a candidate's own unrelated content against it) rather than real structure. | |
| bool destInHole = destPos.x >= hole.x && destPos.x <= hole.z | |
| && destPos.y >= hole.y && destPos.y <= hole.w; | |
| bool srcInHole = srcPos.x >= hole.x && srcPos.x <= hole.z | |
| && srcPos.y >= hole.y && srcPos.y <= hole.w; | |
| if (destInHole || srcInHole) { continue; } | |
| float3 d = coreimage::sample(src, coreimage::samplerTransform(src, destPos)).rgb; | |
| float3 s = coreimage::sample(src, coreimage::samplerTransform(src, srcPos)).rgb; | |
| float3 diff = d - s; | |
| // Divides each channel's squared error by the destination's OWN squared amplitude at | |
| // that cell: an absolute error, not a relative one, would let whichever sub-region of | |
| // the ring reads brightest decide the whole offset — a mismatch worth 0.3/channel next | |
| // to one worth 0.01/channel outweighs it 900 to 1 with no normalisation at all, even | |
| // when the darker mismatch has its own exact match sitting a few pixels away. A cap on | |
| // this weight was tried and measured to help nothing: the search is exactly as unstable | |
| // at 1:1 signal-to-noise near the pedestal with NO weighting at all (`scale = 1`), so the | |
| // instability is the dense correlation search's own, not this normalisation's to fix — | |
| // see the "Addendum" in docs/specs/2026-08-24-correcteur-taches-durcissement.md. | |
| float3 scale = d * d + float3(0.001); | |
| accum += (diff * diff) / scale; | |
| counted++; | |
| } | |
| } | |
| // No cell survived the hole: this offset cannot be scored, never a free pass to a tied zero. | |
| if (counted == 0) { | |
| return float4(INFINITY, offset.x, offset.y, 1.0); | |
| } | |
| float ssd = (accum.x + accum.y + accum.z) / float(counted); | |
| return float4(ssd, offset.x, offset.y, 1.0); | |
| } | |
| // Adds a bias that varies LINEARLY across the patch instead of one flat value — a cheap stand-in | |
| // for a full gradient-domain blend, built from a handful of extra mean-colour samples around the | |
| // ring rather than a per-pixel solve. Reads the patch at the SAME position it writes, so the ROI | |
| // is the identity and no second sampler is needed. | |
| extern "C" float4 correctionBiasField(coreimage::sampler patch, | |
| float3 base, | |
| float3 gradX, | |
| float3 gradY, | |
| float2 origin, | |
| coreimage::destination dest) | |
| { | |
| float2 pos = dest.coord(); | |
| float3 bias = base + gradX * (pos.x - origin.x) + gradY * (pos.y - origin.y); | |
| float4 p = coreimage::sample(patch, coreimage::samplerTransform(patch, pos)); | |
| return float4(p.rgb + bias, p.a); | |
| } | |
| #include <CoreImage/CoreImage.h> | |
| using namespace metal; | |
| // Rec. 2020 luminance weights, identical to those of pipeline.metal. | |
| constant float3 LumaWeights = float3(0.2627f, 0.6780f, 0.0593f); | |
| // Clamped to avoid the y2/y ratio blowing up on an almost black pixel. | |
| constant float LumaScaleMax = 8.0f; | |
| // Reads a 1D table at position v, sampling the vertical centre and insetting by half a pixel so | |
| // the read never falls past either end. | |
| static inline float4 rawLUT(coreimage::sampler lut, float v) | |
| { | |
| float size = coreimage::samplerSize(lut).x; | |
| float x = mix(0.5f, size - 0.5f, saturate(v)); | |
| return coreimage::sample(lut, coreimage::samplerTransform(lut, float2(x, 0.5f))); | |
| } | |
| // Applies the table while preserving values above white: past 1 the curve extends as a straight | |
| // line of slope 1 instead of clipping to the table's last entry. | |
| static inline float applyCurve(coreimage::sampler lut, float v, int component) | |
| { | |
| if (v <= 1.0f) { | |
| return rawLUT(lut, v)[component]; | |
| } | |
| return rawLUT(lut, 1.0f)[component] + (v - 1.0f); | |
| } | |
| // Stage 7, second half: the curves. A general CIKernel is required because a CIColorKernel | |
| // cannot read a texture; rgbLUT packs R = linked, G/B/A = red/green/blue, lumaLUT carries luma. | |
| extern "C" float4 curves(coreimage::sampler src, | |
| coreimage::sampler rgbLUT, | |
| coreimage::sampler lumaLUT) | |
| { | |
| float4 s = coreimage::sample(src, coreimage::samplerCoord(src)); | |
| float3 e = s.rgb; | |
| // Each channel its own table: component G for red, B for green, A for blue. | |
| e = float3(applyCurve(rgbLUT, e.r, 1), | |
| applyCurve(rgbLUT, e.g, 2), | |
| applyCurve(rgbLUT, e.b, 3)); | |
| // The linked table applies identically to all three channels. | |
| e = float3(applyCurve(rgbLUT, e.r, 0), | |
| applyCurve(rgbLUT, e.g, 0), | |
| applyCurve(rgbLUT, e.b, 0)); | |
| // Scales luminance without touching chroma, so shadows keep their hue and saturation. | |
| float y = dot(e, LumaWeights); | |
| if (y > 1e-4f) { | |
| e *= clamp(applyCurve(lumaLUT, y, 0) / y, 0.0f, LumaScaleMax); | |
| } | |
| return float4(e, s.a); | |
| } | |
| #include <CoreImage/CoreImage.h> | |
| using namespace metal; | |
| // **Rec. 2020** luminance weights, matching the working space. Must stay in agreement with | |
| // `Pipeline.lumaWeights`, which feeds the histogram's luma trace. | |
| constant float3 LumaWeights = float3(0.2627f, 0.6780f, 0.0593f); | |
| // Ceiling on the luma factor, so a near-black pixel cannot be multiplied without bound. | |
| constant float LumaScaleMax = 8.0f; | |
| /// One set of levels: (black, white, gamma). No upper clamp: five apply in sequence and clipping | |
| /// one at 1 makes a later stretch unrecoverable. `max(…, 0)` floors a negative density, or pow NaNs. | |
| static inline float applyLevels(float e, float3 lv) | |
| { | |
| float n = max((e - lv.x) / max(lv.y - lv.x, 1e-4f), 0.0f); | |
| return pow(n, lv.z); | |
| } | |
| /// A levels window: the three-point pass, then a degree-4 Bézier whose two inner ordinates | |
| /// `c1` and `c3` are handles. Skipped on the base pair, so a resting frame renders bit for bit. | |
| static inline float applyWindow(float e, float3 lv, float c1, float c3) | |
| { | |
| float t = applyLevels(e, lv); | |
| if (c1 == 0.25f && c3 == 0.75f) { return t; } | |
| // The Bézier is defined on 0…1 only; what a stretch pushes above is carried through as an | |
| // offset, keeping the window unbounded above so a later pass can still recover it. | |
| float u = min(t, 1.0f); | |
| float v = 1.0f - u; | |
| float curve = c1 * (4.0f * u * v * v * v) + 0.5f * (6.0f * u * u * v * v) | |
| + c3 * (4.0f * u * u * u * v) + u * u * u * u; | |
| return curve + (t - u); | |
| } | |
| static inline float3 applyWindow3(float3 e, float3 lv, float c1, float c3) | |
| { | |
| return float3(applyWindow(e.r, lv, c1, c3), applyWindow(e.g, lv, c1, c3), | |
| applyWindow(e.b, lv, c1, c3)); | |
| } | |
| /// Stage 11 — output clipping, **hard and constant**: a shoulder rolls channels unevenly and | |
| /// drifts hue. The only bound above in the chain, the levels' `max(…, 0)` being the only one below. | |
| extern "C" float4 outputClip(coreimage::sample_t s) | |
| { | |
| return float4(clamp(s.rgb, 0.0f, 1.0f), s.a); | |
| } | |
| // Pipeline stages 4 → 7, in a single pass, in floating point. The axis — `pedestal`, `logGuard`, | |
| // `window` — arrives computed: `pow(10, −x)` on the GPU is not the CPU's value at every ceiling. | |
| extern "C" float4 pipeline(coreimage::sample_t s, | |
| float3 gain, | |
| float3 linked, float3 red, float3 green, float3 blue, float3 luma, | |
| float invert, float density, | |
| float pedestal, float logGuard, float window, | |
| // Appended, never inserted: the five vectors above permute silently, and | |
| // a new argument at the tail cannot move one of them. | |
| float3 lowPoints, float3 highPoints, | |
| // The same two ordinates for the global sets, `x` linked and `y` luma. | |
| float2 globalLow, float2 globalHigh) | |
| { | |
| // The pedestal goes in **before** the gain, where the decoder's offset physically is. No | |
| // ceiling: a transmittance over 1 must reach the levels as a density under zero. | |
| float3 T = (s.rgb + pedestal) * gain; // 4 — gains, multiplicative, linear | |
| float3 D = -log10(max(T, logGuard)); // 5 — density, the guard keeping the log defined | |
| // 6 — inversion, or not. D is already the inverse of T, so the density **is** the negative | |
| // inversion; positive re-inverts on `window`, the top of the axis the levels are graduated on. | |
| float3 E = (invert > 0.5f) ? D : (window - D); | |
| // 7 — levels. Channels first, linked second: placing black and white points on the corrected | |
| // image, rather than upstream of the channel correction, is the only order that stays steerable. | |
| // Five points everywhere, on one Bézier: the three windows read a density, the global sets | |
| // read their dimensionless output, and the two added ordinates are normalised in both. | |
| E = float3(applyWindow(E.r, red, lowPoints.r, highPoints.r), | |
| applyWindow(E.g, green, lowPoints.g, highPoints.g), | |
| applyWindow(E.b, blue, lowPoints.b, highPoints.b)); | |
| E = applyWindow3(E, linked, globalLow.x, globalHigh.x); | |
| // Colour density: every channel raised to a power, undone on the luminance alone. Grey is the | |
| // power's fixed point, so the channels separate while a neutral lands exactly where it was. | |
| if (density != 1.0f) { | |
| float3 P = pow(max(E, 0.0f), density); | |
| float p = dot(P, LumaWeights); | |
| // No clamp here, unlike the luma set below: the two halves are exact inverses on a neutral, | |
| // so nothing can run away — a bound would only break the invariance it is meant to protect. | |
| if (p > 1e-12f) { E = P * pow(p, (1.0f / density) - 1.0f); } | |
| } | |
| // Luma acts on luminance alone, chroma preserved: a scaling, not an offset, so shadows keep | |
| // hue and saturation. The factor is clamped, otherwise an almost-black pixel multiplies unbounded. | |
| float y = dot(E, LumaWeights); | |
| if (y > 1e-4f) { | |
| E *= clamp(applyWindow(y, luma, globalLow.y, globalHigh.y) / y, 0.0f, LumaScaleMax); | |
| } | |
| // Deliberately unbounded on output: the compression is done by `outputClip`, at the very end of | |
| // the chain. Do not add a saturate here. | |
| return float4(E, s.a); | |
| } | |
| // Stage 8 — the finishing balance: a degree-4 Bézier transfer curve, per channel. Five ordinates | |
| // arrive already computed and non-decreasing by Swift; evaluated on the value clamped into 0…1, with anything outside carried through as an offset. | |
| extern "C" float4 toneBalance(coreimage::sample_t s, | |
| float3 c0, float3 c1, float3 c2, float3 c3, float3 c4) | |
| { | |
| float3 t = clamp(s.rgb, 0.0f, 1.0f); | |
| float3 u = 1.0f - t; | |
| float3 curve = c0 * (u * u * u * u) | |
| + c1 * (4.0f * t * u * u * u) | |
| + c2 * (6.0f * t * t * u * u) | |
| + c3 * (4.0f * t * t * t * u) | |
| + c4 * (t * t * t * t); | |
| return float4(curve + (s.rgb - t), s.a); | |
| } | |
| // Flat-field correction — a division, applied on the **linear negative**. A `CIColorKernel` with | |
| // two samples read at the same destination point; `amount` mixes the mask toward 1, so 0 is the identity exactly. | |
| extern "C" float4 flatField(coreimage::sample_t s, coreimage::sample_t f, float amount) | |
| { | |
| // Floored above zero: a black corner would otherwise divide by nothing and send the whole | |
| // neighbourhood to infinity. | |
| float m = max(f.g, 0.05f); | |
| float d = mix(1.0f, m, amount); | |
| return float4(s.rgb / d, s.a); | |
| } | |
| import Foundation | |
| /// Where the information in a channel begins and ends — the black and white points a hand would | |
| /// place, computed from a measured histogram. Runs only on request, previews before it applies. | |
| enum AutoLevels { | |
| /// Where the slider starts. A fraction of the channel's own total, not of the image, so a | |
| /// weak channel is not judged against a strong one. | |
| static let defaultThreshold: Float = 0.001 | |
| /// The upper end of what the slider offers. | |
| static let maximumThreshold: Float = 0.05 | |
| /// The narrowest span a suggestion may propose, as a share of the measured window. Below it the | |
| /// channel is flat and the honest answer is no suggestion, not a violent one. | |
| static let minimumSpan: Float = 0.02 | |
| /// The three positions, as fractions of the graduation window — what a bin index names, and | |
| /// deliberately not the density the handles are stored in, which `placed` converts to. | |
| struct Suggestion: Equatable, Sendable { | |
| var black: Float | |
| var white: Float | |
| /// The median handle, stored normalised between black and white; 0.5 is neutral. | |
| /// Not optional: every mode writes all five handles, or the result depends on click order. | |
| var mid: Float = 0.5 | |
| /// The two added handles, in that same normalised frame and not optional for that same | |
| /// reason. No mode promises anything at the quarters, so every mode leaves them at rest. | |
| var shadows: Float = 0.25 | |
| var highlights: Float = 0.75 | |
| /// The five handles averaged across several proposals. Exactly representable, and ordered | |
| /// by construction: the ends share one window and the inner three are normalised on it. | |
| static func mean(of proposals: [Suggestion]) -> Suggestion? { | |
| guard !proposals.isEmpty else { return nil } | |
| let count = Float(proposals.count) | |
| func over(_ handle: (Suggestion) -> Float) -> Float { | |
| proposals.reduce(0) { $0 + handle($1) } / count | |
| } | |
| return Suggestion(black: over { $0.black }, white: over { $0.white }, | |
| mid: over { $0.mid }, shadows: over { $0.shadows }, | |
| highlights: over { $0.highlights }) | |
| } | |
| } | |
| /// Reads one channel of a measured histogram: `counts` one bin per entry, `threshold` the | |
| /// share of pixels allowed outside at each end. `nil` when there is nothing to say. | |
| static func suggest(counts: [Float], threshold: Float) -> Suggestion? { | |
| guard counts.count > 1 else { return nil } | |
| let total = counts.reduce(0, +) | |
| guard total > 0 else { return nil } | |
| let allowed = total * max(threshold, 0) | |
| // First bin where the running total passes the allowance. ">" rather than ">=" so a | |
| // threshold of 0 lands on the first bin holding any pixel at all. | |
| var running: Float = 0 | |
| var low = counts.count - 1 | |
| for (bin, count) in counts.enumerated() { | |
| running += count | |
| if running > allowed { low = bin; break } | |
| } | |
| running = 0 | |
| var high = 0 | |
| for (offset, count) in counts.reversed().enumerated() { | |
| running += count | |
| if running > allowed { high = counts.count - 1 - offset; break } | |
| } | |
| guard high > low else { return nil } | |
| let scale = Float(counts.count - 1) | |
| let suggestion = Suggestion(black: Float(low) / scale, white: Float(high) / scale) | |
| guard suggestion.white - suggestion.black >= minimumSpan else { return nil } | |
| return suggestion | |
| } | |
| /// A suggestion read as handles: the ends leave the window's fractions for the density the | |
| /// levels are stored in, and the three inner points, normalised between them, convert to nothing. | |
| static func placed(_ suggestion: Suggestion, in mode: ConversionMode) -> Levels { | |
| Levels(black: Graduation.value(atFraction: suggestion.black, in: mode), | |
| white: Graduation.value(atFraction: suggestion.white, in: mode), | |
| mid: suggestion.mid, shadows: suggestion.shadows, | |
| highlights: suggestion.highlights) | |
| } | |
| /// The same, taken from a measured histogram's channel. | |
| static func suggest(_ histogram: Histogram, channel: Int, threshold: Float) -> Suggestion? { | |
| guard !histogram.isEmpty, (0..<3).contains(channel) else { return nil } | |
| return suggest(counts: histogram.rgb.map { $0[channel] }, threshold: threshold) | |
| } | |
| // MARK: - The placements | |
| /// The channel the other two are brought onto. | |
| static let reference = 1 | |
| /// How the three channels are brought together, once their black points are placed. Several | |
| /// modes because each is right about a different scene — matching medians assumes grey. | |
| enum Method: String, CaseIterable, Identifiable, Hashable { | |
| /// Each channel between its own ends, nothing else touched. | |
| case classic | |
| /// …plus the midpoint moved so the medians agree. | |
| case mids | |
| /// …plus the white point moved so the highlights agree. | |
| case highlights | |
| /// …plus the white point moved so the top twentieth agrees. | |
| case whites | |
| /// …plus both points moved so the tenth and the ninetieth agree. | |
| case body | |
| /// Every other mode's answer, averaged handle by handle. | |
| case average | |
| var id: String { rawValue } | |
| var label: String { | |
| switch self { | |
| case .classic: "Classic" | |
| case .mids: "Mids" | |
| case .highlights: "Highs" | |
| case .whites: "Whites" | |
| case .body: "Body" | |
| case .average: "AVG" | |
| } | |
| } | |
| /// True when the mode names a statistic of red and blue to land on green's, hence assumes | |
| /// it is neutral. The plate tints the two that name none; the switch is exhaustive. | |
| var alignsChannels: Bool { | |
| switch self { | |
| case .classic, .average: false | |
| case .mids, .highlights, .whites, .body: true | |
| } | |
| } | |
| /// The modes `average` averages: every other one. Derived rather than listed, so a new | |
| /// button joins the mean without a second place to remember. | |
| static var averaged: [Method] { allCases.filter { $0 != .average } } | |
| /// The buttons a mode offers, and what they are called there. Aligning medians and | |
| /// highlights across channels shapes a colour cast, which a monochrome render then projects | |
| /// away — only placing the ends survives, and it is named for what it does. | |
| static func offered(in mode: ConversionMode) -> [Method] { | |
| mode.isMonochrome ? [.classic] : allCases | |
| } | |
| func label(in mode: ConversionMode) -> String { | |
| mode.isMonochrome && self == .classic ? "Equalise" : label | |
| } | |
| /// The mode a remembered name asks for, held to what this frame really offers: a retired | |
| /// button leaves its name in the preferences, and nothing may describe a key not on screen. | |
| static func stored(_ name: String, in mode: ConversionMode) -> Method { | |
| let offered = offered(in: mode) | |
| return Method(rawValue: name).flatMap { offered.contains($0) ? $0 : nil } ?? .classic | |
| } | |
| /// The share of the mass the channels are made to agree on. `classic` and `average` align | |
| /// nothing, `body` reads `bodyEnds` instead. | |
| var quantile: Float { | |
| switch self { | |
| case .classic, .mids, .body, .average: 0.5 | |
| case .highlights: 0.9 | |
| case .whites: 0.95 | |
| } | |
| } | |
| /// The statistic this mode anchors on, as a fraction of the measured window. | |
| func anchor(of counts: [Float]) -> Float? { | |
| AutoLevels.quantile(of: counts, at: quantile) | |
| .map { $0 / Float(counts.count - 1) } | |
| } | |
| /// What the button does, in one sentence, for the tooltip. | |
| var summary: String { | |
| switch self { | |
| case .classic: | |
| "returns the three inner points to neutral, and nothing else" | |
| case .mids: | |
| "then moves red and blue's midpoints, so their median lands on green's" | |
| case .highlights: | |
| "then moves red and blue's white points, so their brightest tenth lands on green's" | |
| case .whites: | |
| "then moves red and blue's white points, so their brightest twentieth lands on " | |
| + "green's — a tighter grip on the very top than Highs" | |
| case .body: | |
| "then moves red and blue's black and white points together, so the middle of each " | |
| + "channel — its tenth to its ninetieth — lands on green's, the extremes falling " | |
| + "where they may" | |
| case .average: | |
| "then places every handle at the average of what the five buttons above propose " | |
| + "— a starting point on a frame where no single one of them is obviously right" | |
| } | |
| } | |
| /// The whole sentence, first step included. | |
| var explanation: String { | |
| "Recentres the source balance so the three channels sit on green, re-reads the " | |
| + "distribution, places each channel's own black and white point where its data starts " | |
| + "and stops — \(summary)." | |
| } | |
| } | |
| /// Where the three channels' handles should go: black/white are each channel's own; the | |
| /// alignment modes additionally land a statistic of red and blue on green's. | |
| static func placement(_ histogram: Histogram, threshold: Float, | |
| method: Method) -> [Suggestion]? { | |
| guard !histogram.isEmpty else { return nil } | |
| let channels = (0..<3).map { channel in histogram.rgb.map { $0[channel] } } | |
| var out: [Suggestion] = [] | |
| for counts in channels { | |
| guard let ends = suggest(counts: counts, threshold: threshold) else { return nil } | |
| out.append(ends) | |
| } | |
| guard method != .classic else { return out } | |
| // The mean of every other mode, handle by handle. Sound because all five are read on one | |
| // frame: step 1 is mode-independent, so the six answers share their frame of reference. | |
| if method == .average { | |
| let others = Method.averaged.compactMap { | |
| placement(histogram, threshold: threshold, method: $0) | |
| } | |
| let mean = (0..<3).compactMap { channel in | |
| Suggestion.mean(of: others.map { $0[channel] }) | |
| } | |
| return mean.count == 3 ? mean : out | |
| } | |
| // Both ends solved so two quantiles land on green's: the window becomes an affine map of | |
| // the reference's, over the band that holds the picture rather than at its extremes. | |
| if method == .body { | |
| let g = out[reference] | |
| let span = max(g.white - g.black, 1e-4) | |
| guard let lowBin = quantile(of: channels[reference], at: bodyEnds.low), | |
| let highBin = quantile(of: channels[reference], at: bodyEnds.high) | |
| else { return out } | |
| let scale = Float(channels[reference].count - 1) | |
| let targetLow = (lowBin / scale - g.black) / span | |
| let targetHigh = (highBin / scale - g.black) / span | |
| guard targetHigh - targetLow > 0.02, targetLow > 0, targetHigh < 1.5 else { return out } | |
| for channel in 0..<3 where channel != reference { | |
| guard let eL = quantile(of: channels[channel], at: bodyEnds.low), | |
| let eH = quantile(of: channels[channel], at: bodyEnds.high), | |
| eH > eL else { continue } | |
| let width = (eH - eL) / scale / (targetHigh - targetLow) | |
| guard width >= minimumSpan, width.isFinite else { continue } | |
| out[channel].black = min(max(eL / scale - targetLow * width, 0), 1 - minimumSpan) | |
| out[channel].white = min(max(out[channel].black + width, | |
| out[channel].black + minimumSpan), 1) | |
| } | |
| return out | |
| } | |
| /// Where this channel's anchor lands once its own ends are applied. | |
| func placed(_ channel: Int) -> Float? { | |
| guard let e = method.anchor(of: channels[channel]) else { return nil } | |
| let span = max(out[channel].white - out[channel].black, 1e-4) | |
| return (e - out[channel].black) / span | |
| } | |
| guard let target = placed(reference), target > 0.02, target < 1.5 else { return out } | |
| for channel in 0..<3 where channel != reference { | |
| guard let n = placed(channel), n > 0 else { continue } | |
| switch method { | |
| case .classic, .average, .body: | |
| continue | |
| case .mids: | |
| // Shared with the neutral pipette, which solves the same handle off a designated | |
| // point rather than a guessed quantile. | |
| guard let solved = Levels.mid(placing: n, at: target) else { continue } | |
| out[channel].mid = solved | |
| case .highlights, .whites: | |
| // n = (value − black)/(white − black) has to equal the target: solve for white. | |
| let e = out[channel].black + n * (out[channel].white - out[channel].black) | |
| let solved = out[channel].black + (e - out[channel].black) / target | |
| guard solved.isFinite else { continue } | |
| // Held apart by the span `suggest` itself refuses to go under, a share of the | |
| // window: `Levels.epsilon` is a density and would mean nothing here. | |
| out[channel].white = min(max(solved, out[channel].black + minimumSpan), 1) | |
| } | |
| } | |
| return out | |
| } | |
| /// The band `body` aligns at both ends, wide enough to straddle the picture and narrow | |
| /// enough that the ends the threshold trims stay outside it. | |
| static let bodyEnds: (low: Float, high: Float) = (0.1, 0.9) | |
| /// The bin a given share of the mass falls below, interpolated inside that bin. | |
| static func quantile(of counts: [Float], at fraction: Float) -> Float? { | |
| let total = counts.reduce(0, +) | |
| guard total > 0 else { return nil } | |
| let mark = total * min(max(fraction, 0), 1) | |
| var running: Float = 0 | |
| for (bin, count) in counts.enumerated() { | |
| let next = running + count | |
| if next >= mark { | |
| guard count > 0 else { return Float(bin) } | |
| return Float(bin) + (mark - running) / count | |
| } | |
| running = next | |
| } | |
| return Float(counts.count - 1) | |
| } | |
| static func median(of counts: [Float]) -> Float? { quantile(of: counts, at: 0.5) } | |
| // MARK: - Two steps: the gains first, then the points | |
| /// How many stops of stage-4 gain the whole measured window is worth: its span in density, | |
| /// times the stops a decade of transmittance holds. Read by everything that maps the two. | |
| static var stopsPerUnitE: Float { Graduation.span * log2(Float(10)) } | |
| /// What a stage-4 gain does to the measurement: a translation of the density axis, since the | |
| /// pedestal sits upstream of the gain. The only place the mode's sign appears in this file. | |
| static func shift(ofStops stops: Float, mode: ConversionMode) -> Float { | |
| (mode.invertsGainSense ? -stops : stops) / stopsPerUnitE | |
| } | |
| /// The exact inverse of `shift(ofStops:mode:)`. | |
| static func stops(forShift shift: Float, mode: ConversionMode) -> Float { | |
| (mode.invertsGainSense ? -shift : shift) * stopsPerUnitE | |
| } | |
| /// **Step one**: the stage-4 gains bringing each channel's median density onto green's, bounded | |
| /// by the graduation window and by `Gains.range` (±3 stops). | |
| static func recentred(_ histogram: Histogram, from stops: SIMD3<Float>, | |
| mode: ConversionMode, threshold: Float) -> SIMD3<Float>? { | |
| guard !histogram.isEmpty else { return nil } | |
| let scale = Float(histogram.rgb.count - 1) | |
| let channels = (0..<3).map { channel in histogram.rgb.map { $0[channel] } } | |
| guard let anchor = median(of: channels[reference]) else { return nil } | |
| let target = anchor / scale | |
| var out = stops | |
| for channel in 0..<3 where channel != reference { | |
| guard let bin = median(of: channels[channel]) else { continue } | |
| var travel = target - bin / scale | |
| // Never past the window's ends, where an edge bin swallows the data — and a | |
| // channel too flat for `suggest` to speak of still moves, being the one needing it. | |
| if let ends = suggest(counts: channels[channel], threshold: threshold) { | |
| travel = min(max(travel, -ends.black), 1 - ends.white) | |
| } | |
| let asked = stops[channel] + Self.stops(forShift: travel, mode: mode) | |
| out[channel] = min(max(asked, Gains.range.lowerBound), Gains.range.upperBound) | |
| } | |
| return out | |
| } | |
| /// The whole gesture: the gains of stage 4, then the three sets of levels read off what those | |
| /// gains leave behind. | |
| struct Balance: Equatable, Sendable { | |
| /// Absolute stops for stage 4 — what step one decided. | |
| var stops: SIMD3<Float> | |
| /// One suggestion per channel, on the window the frame after step one was measured on. | |
| var levels: [Suggestion] | |
| /// How far step one moved each channel, as a share of that window. Kept so the panel can | |
| /// draw the proposal on the histogram it already has, rather than one nobody has measured. | |
| var shift: SIMD3<Float> | |
| /// The same proposal as handles, in the frame of reference of the histogram measured before | |
| /// step one — the one on screen, and the one the preview must draw against. | |
| func shown(_ channel: Int, in mode: ConversionMode) -> Levels { | |
| var out = levels[channel] | |
| let travel = shift[channel] | |
| // Held inside the measured window: a mark past one of its ends would describe a handle | |
| // where the plot shows nothing. | |
| out.black = min(max(out.black - travel, 0), 1 - minimumSpan) | |
| out.white = min(max(out.white - travel, out.black + minimumSpan), 1) | |
| return placed(out, in: mode) | |
| } | |
| } | |
| /// How much wider a contact sheet's window is left, as a share of the SPAN the measurement | |
| /// found on that channel — never a step on the levels' own scale, which would open a narrow | |
| /// window far more than a wide one. | |
| static let sheetMargin: Float = 0.02 | |
| /// The margin a frame is owed. Read from one place, or the pane and the batch widen by | |
| /// different amounts and the mark drawn on hover stops describing what the click does. | |
| nonisolated static func margin(on settings: PipelineSettings) -> Float { | |
| settings.sheetMode ? sheetMargin : 0 | |
| } | |
| /// The window opened by a share of its own width, AFTER the points are placed, so what was | |
| /// measured stays measured. The inner three keep the density they were given, not their share. | |
| nonisolated static func widened(_ suggestion: Suggestion, by share: Float) -> Suggestion { | |
| let span = suggestion.white - suggestion.black | |
| guard share > 0, span > 0 else { return suggestion } | |
| let step = span * share / 2 | |
| var out = suggestion | |
| out.black = suggestion.black - step | |
| out.white = suggestion.white + step | |
| let opened = out.white - out.black | |
| // Re-expressed on the wider window so each inner handle names the same density it did. | |
| func kept(_ normalised: Float) -> Float { | |
| (suggestion.black + normalised * span - out.black) / opened | |
| } | |
| (out.mid, out.shadows, out.highlights) = (kept(suggestion.mid), kept(suggestion.shadows), | |
| kept(suggestion.highlights)) | |
| return out | |
| } | |
| /// The two steps, in order, as one value. The histogram is re-measured after the gains: a gain | |
| /// translates the density axis exactly, but not the mass it carries over the window's ends. | |
| static func balance(_ histogram: Histogram, from stops: SIMD3<Float>, mode: ConversionMode, | |
| threshold: Float, method: Method, margin: Float = 0, | |
| remeasuring: (SIMD3<Float>) -> Histogram) -> Balance? { | |
| guard let moved = recentred(histogram, from: stops, mode: mode, threshold: threshold) | |
| else { return nil } | |
| let travel = SIMD3<Float>(shift(ofStops: moved[0] - stops[0], mode: mode), | |
| shift(ofStops: moved[1] - stops[1], mode: mode), | |
| shift(ofStops: moved[2] - stops[2], mode: mode)) | |
| let after = moved == stops ? histogram : remeasuring(moved) | |
| guard let levels = placement(after, threshold: threshold, method: method) else { return nil } | |
| // AFTER the placement and never before: what was measured stays measured, and the margin | |
| // only opens what the ends clip. | |
| return Balance(stops: moved, levels: levels.map { widened($0, by: margin) }, shift: travel) | |
| } | |
| } | |
| // MARK: - More than one frame at a time | |
| extension AutoLevels { | |
| /// A balance written into a frame's settings — the single place that says what an automatic | |
| /// balance writes, so a future fourth handle has one line to change, not several. | |
| static func applied(_ balance: Balance, to settings: PipelineSettings) -> PipelineSettings { | |
| var out = settings | |
| out.gains.stops = balance.stops | |
| // The list the pane draws, read rather than spelled out. The fractions land on the window | |
| // of the frame being written, so a balance copied onto another mode reads on its own axis. | |
| for tab in LevelsChannel.perChannel { | |
| guard let index = tab.histogramChannel else { continue } | |
| out.levels[tab] = placed(balance.levels[index], in: settings.mode) | |
| } | |
| return out | |
| } | |
| /// The two ways of balancing more than one frame: `each` corrects every frame for itself; | |
| /// `fromFirst` measures once and writes the same values everywhere. | |
| enum Strategy: String, CaseIterable, Identifiable, Sendable { | |
| case each | |
| case fromFirst | |
| var id: String { rawValue } | |
| /// The title of the button that chooses it. The count is in the label on both options, to | |
| /// protect against balancing a forgotten selection. | |
| func label(count: Int) -> String { | |
| switch self { | |
| case .each: "Balance Each of the \(count)" | |
| case .fromFirst: "Balance the First, Copy to \(count)" | |
| } | |
| } | |
| /// What that button will do, in one sentence — a selection cannot show thirty-six | |
| /// previews, so the wording carries the promise instead. | |
| func explanation(count: Int) -> String { | |
| switch self { | |
| case .each: | |
| "Balance Each measures all \(count) photos and corrects each one for itself. " | |
| + "Right when the light changed between frames; it decodes every file, so it takes " | |
| + "a while." | |
| case .fromFirst: | |
| "Balance the First measures the first photo only and writes its values onto all " | |
| + "\(count). Right on a series shot in one light, where the whole batch should " | |
| + "match rather than each frame being individually right." | |
| } | |
| } | |
| /// What the panel says while it runs. | |
| var progressVerb: String { | |
| switch self { | |
| case .each: "Balancing" | |
| case .fromFirst: "Copying the balance to" | |
| } | |
| } | |
| } | |
| /// A run under way, for the panel to show. | |
| struct Batch: Equatable, Sendable { | |
| var strategy: Strategy | |
| var done: Int | |
| var total: Int | |
| var label: String { "\(strategy.progressVerb) \(done) of \(total)…" } | |
| } | |
| /// One photograph of a run: where it is, and the settings it starts from — read from its own | |
| /// sidecar, never from the screen, or it would be balanced against pixels not its own. | |
| struct Frame: Equatable, Sendable { | |
| var path: String | |
| var settings: PipelineSettings | |
| } | |
| /// What can stop a run. | |
| enum Failure: Error, LocalizedError { | |
| /// Unreadable file, or nothing to propose in it. | |
| case unmeasurable(String) | |
| var errorDescription: String? { | |
| switch self { | |
| case .unmeasurable(let path): | |
| "\(URL(fileURLWithPath: path).lastPathComponent): nothing to balance. The file " | |
| + "could not be read, or one of its channels is too flat to place points in." | |
| } | |
| } | |
| } | |
| /// Walks a run, frame by frame, in order. Stops at the first failure, matching the export's | |
| /// behaviour on a missing volume. `measuring` is called once under `fromFirst`. | |
| static func run(_ frames: [Frame], strategy: Strategy, | |
| measuring: (Frame) -> Balance?, | |
| applying: (Frame, Balance) throws -> Void) throws { | |
| var carried: Balance? | |
| for frame in frames { | |
| let balance: Balance | |
| if strategy == .fromFirst, let carried { | |
| balance = carried | |
| } else if let fresh = measuring(frame) { | |
| balance = fresh | |
| } else { | |
| throw Failure.unmeasurable(frame.path) | |
| } | |
| if strategy == .fromFirst, carried == nil { carried = balance } | |
| try applying(frame, balance) | |
| } | |
| } | |
| /// The balance a frame's own data asks for, measured from its file. `nonisolated`: it decodes | |
| /// a RAW and drives the GPU twice, hundreds of milliseconds a frame — never on the draw thread. | |
| nonisolated static func measured(_ url: URL, settings: PipelineSettings, threshold: Float, | |
| method: Method, | |
| at side: CGFloat = Negative.measureSide) -> Balance? { | |
| guard let source = RawDecode.linear(url, longestSide: side) else { | |
| return nil | |
| } | |
| // Without it the crop frame quantises on the reduced copy's own grid, so the frame | |
| // measured is not quite the frame exported. | |
| let fullWidth = RawDecode.pixelSize(of: url)?.width | |
| func measure(_ stops: SIMD3<Float>) -> Histogram { | |
| var probe = settings | |
| probe.gains.stops = stops | |
| return Pipeline.histogram(of: source, settings: probe, before: .levels, | |
| fullWidth: fullWidth) | |
| } | |
| return balance(measure(settings.gains.stops), from: settings.gains.stops, | |
| mode: settings.mode, threshold: threshold, method: method, | |
| margin: margin(on: settings), remeasuring: measure) | |
| } | |
| } | |
| extension AutoLevels { | |
| /// Far finer than anything the app measures at, so the drift line below has a direction to | |
| /// state rather than a second reading of the same grid. Never a size a gesture runs on. | |
| static let referenceSide: CGFloat = 2600 | |
| /// The placement on a real frame: what it proposes, printed to be read. Measures and does not | |
| /// arbitrate — ordering and range are checked, the rest is a number to look at. | |
| static func selfCheck(source: URL) -> (Bool, String) { | |
| // Read at the shipped side, so what is printed is what a button would place on this frame. | |
| guard let decoded = RawDecode.linear(source, longestSide: Negative.measureSide) else { | |
| return (false, " FAIL \(source.lastPathComponent) undecodable") | |
| } | |
| let measured = Pipeline.histogram(of: decoded, settings: PipelineSettings(), | |
| before: .levels) | |
| guard let placed = placement(measured, threshold: defaultThreshold, method: .mids) else { | |
| return (false, " FAIL no placement on \(source.lastPathComponent)") | |
| } | |
| // What the windows change on this frame, printed side by side. The grid is invented from | |
| // its own extent: what is shown is the DIFFERENCE they make, not a claim about the file. | |
| var sheeted = PipelineSettings() | |
| sheeted.sheetMode = true | |
| // Built from THIS source's own extent, or it fits nothing and the line below prints a | |
| // difference of zero for ever — a diagnostic that cannot inform. | |
| sheeted.sheet = ContactSheet.Grid(columns: 1, rows: 1, frames: 1, | |
| tile: decoded.extent.size, margin: 0) | |
| let withFlag = Pipeline.histogram(of: decoded, settings: sheeted, before: .levels) | |
| let sheetSays = placement(withFlag, threshold: defaultThreshold, method: .mids) | |
| let mass = { (h: Histogram) in h.rgb.reduce(Float(0)) { $0 + $1.x } } | |
| var sheetLines = [String(format: " ---- contact sheet flag: %.0f px of %.0f left in the " | |
| + "count, %.1f %% left outside the windows", mass(withFlag), | |
| mass(measured), | |
| 100 * (1 - mass(withFlag) / max(mass(measured), 1)))] | |
| // TWO PRESSES, the second starting from what the first wrote. A balance re-measures between | |
| // its steps so a second press finds nothing; where it does, one pass did not converge. | |
| var pressStop: Float = 0 | |
| var pressPoint: Float = 0 | |
| for method in Method.allCases { | |
| guard let first = Self.measured(source, settings: sheeted, | |
| threshold: defaultThreshold, method: method), | |
| let twice = Self.measured(source, settings: applied(first, to: sheeted), | |
| threshold: defaultThreshold, method: method) | |
| else { continue } | |
| let stopGap = max(abs(twice.stops.x - first.stops.x), | |
| max(abs(twice.stops.y - first.stops.y), | |
| abs(twice.stops.z - first.stops.z))) | |
| let pointGap = (0..<3).map { abs(twice.levels[$0].black - first.levels[$0].black) } | |
| .max() ?? 0 | |
| let whiteGap = (0..<3).map { abs(twice.levels[$0].white - first.levels[$0].white) } | |
| .max() ?? 0 | |
| pressStop = max(pressStop, stopGap) | |
| pressPoint = max(pressPoint, max(pointGap, whiteGap)) | |
| sheetLines.append(String(format: " ---- %@ · second press: gains %.4f, black %.4f, " | |
| + "white %.4f", method.rawValue, stopGap, pointGap, whiteGap)) | |
| } | |
| if let sheetSays { | |
| sheetLines.append(String(format: " ---- red black %.4f → %.4f, white %.4f → %.4f", | |
| placed[0].black, sheetSays[0].black, | |
| placed[0].white, sheetSays[0].white)) | |
| } | |
| // What each mode promises, verified on the real frame: the proposed levels are applied | |
| // and the targeted quantile is read back in each channel. | |
| var promises: [String] = sheetLines | |
| var kept = true | |
| // The second press bounded, not only printed: the defect this loop was built on was gains | |
| // DOUBLING on a sheet, three orders of magnitude past these bounds. | |
| kept = kept && pressStop < 0.005 && pressPoint < 0.002 | |
| promises.append(String(format: "second press %.4f st / %.4f", pressStop, pressPoint)) | |
| /// Where a channel's anchor renders through a proposal, the kernel's window replicated. | |
| func landing(_ e: Float, _ s: Suggestion) -> Float { | |
| let n = max((e - s.black) / max(s.white - s.black, 1e-4), 0) | |
| return pow(n, Levels(black: s.black, white: s.white, mid: s.mid).gamma) | |
| } | |
| for m in Method.allCases { | |
| // A switch rather than a list, so a new method is a compile error here instead of a | |
| // silent exemption: the three below are checked further down, each on its own contract. | |
| switch m { | |
| case .classic, .average, .body: continue | |
| case .mids, .highlights, .whites: break | |
| } | |
| guard let ps = placement(measured, threshold: defaultThreshold, method: m), | |
| let g = m.anchor(of: measured.rgb.map { $0[reference] }) else { continue } | |
| // The gamma modes solve on anchors held inside `Levels.anchorClamp`; the check reads | |
| // through the same clamp, or a rebate spike at a window's edge fails right arithmetic. | |
| let clamps = m == .mids | |
| func read(_ e: Float, _ s: Suggestion) -> Float { | |
| let n = (e - s.black) / max(s.white - s.black, 1e-4) | |
| let bounded = clamps ? min(max(n, Levels.anchorClamp.lowerBound), | |
| Levels.anchorClamp.upperBound) : max(n, 0) | |
| return pow(bounded, Levels(black: s.black, white: s.white, mid: s.mid).gamma) | |
| } | |
| let aim = read(g, ps[reference]) | |
| var worst: Float = 0 | |
| var pins = 0 | |
| var signs = true | |
| for c in 0..<3 where c != reference { | |
| let raw = measured.rgb.map { $0[c] } | |
| guard let e = m.anchor(of: raw) else { continue } | |
| let gap = read(e, ps[c]) - aim | |
| // A stop owes a sign, never exactness: the low mid stop is the small gamma, so a | |
| // mid held there lands short of the aim; the high stop and a capped white land past. | |
| if ps[c].mid == Levels.midRange.upperBound { | |
| pins += 1; signs = signs && gap > -1e-3 | |
| } else if ps[c].mid == Levels.midRange.lowerBound { | |
| pins += 1; signs = signs && gap < 1e-3 | |
| } else if (m == .highlights || m == .whites) && ps[c].white == 1 { | |
| pins += 1; signs = signs && gap > -1e-3 | |
| } else { | |
| worst = max(worst, abs(gap)) | |
| } | |
| } | |
| kept = kept && worst < 1e-3 && signs | |
| promises.append(String(format: "%@ %.4f%@", m.label, worst, | |
| pins > 0 ? " (\(pins) at a stop)" : "")) | |
| } | |
| // Body's own promise: both band ends land on green's, and a channel whose black or white | |
| // sits on a stop misses on the side that stop forces. | |
| if let ps = placement(measured, threshold: defaultThreshold, method: .body) { | |
| var worst: Float = 0 | |
| var pins = 0 | |
| var signs = true | |
| for q in [bodyEnds.low, bodyEnds.high] { | |
| let greens = measured.rgb.map { $0[reference] } | |
| guard let bin = quantile(of: greens, at: q) else { continue } | |
| let aim = landing(bin / Float(greens.count - 1), ps[reference]) | |
| for c in 0..<3 where c != reference { | |
| let raw = measured.rgb.map { $0[c] } | |
| guard let b = quantile(of: raw, at: q) else { continue } | |
| let gap = landing(b / Float(raw.count - 1), ps[c]) - aim | |
| if ps[c].black == 0, ps[c].white < 1 { | |
| pins += 1; signs = signs && gap < 1e-3 | |
| } else if ps[c].white == 1, ps[c].black > 0 { | |
| pins += 1; signs = signs && gap > -1e-3 | |
| } else if ps[c].black == 0, ps[c].white == 1 { | |
| pins += 1 | |
| } else { | |
| worst = max(worst, abs(gap)) | |
| } | |
| } | |
| } | |
| kept = kept && worst < 1e-3 && signs | |
| promises.append(String(format: "Body %.4f%@", worst, | |
| pins > 0 ? " (\(pins) at a stop)" : "")) | |
| } else { | |
| kept = false | |
| promises.append("Body returned nothing") | |
| } | |
| // Average's own promise, on this frame's real distribution: every one of the five handles | |
| // is the mean of what the other buttons propose, and every one of them contributes. | |
| if let ps = placement(measured, threshold: defaultThreshold, method: .average) { | |
| let others = Method.averaged.compactMap { | |
| placement(measured, threshold: defaultThreshold, method: $0) | |
| } | |
| let gap = (0..<3).compactMap { channel -> Float? in | |
| guard let mean = Suggestion.mean(of: others.map { $0[channel] }) else { return nil } | |
| return max(abs(ps[channel].black - mean.black), abs(ps[channel].white - mean.white), | |
| abs(ps[channel].mid - mean.mid), abs(ps[channel].shadows - mean.shadows), | |
| abs(ps[channel].highlights - mean.highlights)) | |
| }.max() ?? -1 | |
| kept = kept && gap == 0 && others.count == Method.averaged.count | |
| promises.append(String(format: "Average %.4f over %d", gap, others.count)) | |
| } else { | |
| kept = false | |
| promises.append("Average returned nothing") | |
| } | |
| // The twin: Classic promises nothing, so its spread must be much larger, or the check | |
| // above would say nothing. | |
| var classicSpread: Float = 0 | |
| if let ps = placement(measured, threshold: defaultThreshold, method: .classic) { | |
| var landed: [Float] = [] | |
| for c in 0..<3 { | |
| let raw = measured.rgb.map { $0[c] } | |
| guard let bin = quantile(of: raw, at: 0.5) else { continue } | |
| let e = bin / Float(raw.count - 1) | |
| let n = max((e - ps[c].black) / max(ps[c].white - ps[c].black, 1e-4), 0) | |
| landed.append(pow(n, Levels(black: ps[c].black, white: ps[c].white, | |
| mid: ps[c].mid).gamma)) | |
| } | |
| classicSpread = (landed.max() ?? 0) - (landed.min() ?? 0) | |
| } | |
| kept = kept && classicSpread > 1e-2 | |
| let valid = placed.allSatisfy { | |
| $0.white > $0.black && Levels.midRange.contains($0.mid) | |
| } | |
| // MARK: - The two steps, on the real path | |
| // The only place the re-measurement really goes through the GPU; the pure checks below | |
| // simulate the kernel in Swift and cannot catch a wiring mistake by themselves. | |
| let remeasuring: (SIMD3<Float>) -> Histogram = { stops in | |
| var probe = PipelineSettings() | |
| probe.gains.stops = stops | |
| return Pipeline.histogram(of: decoded, settings: probe, before: .levels) | |
| } | |
| /// Where each channel's median lands once these levels are applied to this measurement. | |
| func residual(_ frame: Histogram, _ points: [Suggestion]) -> Float { | |
| var landed: [Float] = [] | |
| for c in 0..<3 { | |
| let raw = frame.rgb.map { $0[c] } | |
| guard let bin = median(of: raw) else { continue } | |
| let set = Levels(black: points[c].black, white: points[c].white, mid: points[c].mid) | |
| let n = max((bin / Float(raw.count - 1) - set.black) | |
| / max(set.white - set.black, 1e-4), 0) | |
| landed.append(pow(n, set.gamma)) | |
| } | |
| return (landed.max() ?? 0) - (landed.min() ?? 0) | |
| } | |
| var twoStep = "two steps: nothing to propose" | |
| var twoStepOK = true | |
| if let one = placement(measured, threshold: defaultThreshold, method: .classic), | |
| let two = balance(measured, from: .zero, mode: .negative, threshold: defaultThreshold, | |
| method: .classic, remeasuring: remeasuring) { | |
| // Required: ordered levels and gains inside their range. How much the residual spread | |
| // moves is a number to look at. | |
| twoStepOK = two.levels.allSatisfy { $0.white > $0.black } | |
| && (0..<3).allSatisfy { Gains.range.contains(two.stops[$0]) } | |
| && two.stops.y == 0 | |
| twoStep = String(format: "two steps: gains %+.2f / %+.2f / %+.2f st, median gap " | |
| + "%.4f → %.4f", two.stops.x, two.stops.y, two.stops.z, | |
| residual(measured, one), residual(remeasuring(two.stops), two.levels)) | |
| } | |
| // MARK: - One step 1 for every mode, which is what lets Average take a mean | |
| // Average means five coordinates read on ONE frame. A step 1 depending on the mode would | |
| // have it averaging positions read on different histograms, in silence. | |
| var sharedStep = "shared step 1: nothing to propose" | |
| var sharedOK = true | |
| // Cached on the stops asked for, so the six modes cost one re-measurement, not six — | |
| // and the keys are themselves part of the claim: one frame is measured, not several. | |
| var frames: [SIMD3<Float>: Histogram] = [:] | |
| var asked: [SIMD3<Float>] = [] | |
| func shared(_ stops: SIMD3<Float>) -> Histogram { | |
| asked.append(stops) | |
| if let held = frames[stops] { return held } | |
| let fresh = remeasuring(stops) | |
| frames[stops] = fresh | |
| return fresh | |
| } | |
| let landed = Method.allCases.compactMap { | |
| balance(measured, from: .zero, mode: .negative, threshold: defaultThreshold, | |
| method: $0, remeasuring: shared)?.stops | |
| } | |
| if let first = landed.first { | |
| /// The largest disagreement between two sets of gains, in stops. | |
| func apart(_ these: [SIMD3<Float>]) -> Float { | |
| these.map { set in (0..<3).map { abs(set[$0] - first[$0]) }.max() ?? 9 }.max() ?? 9 | |
| } | |
| let drift = apart(landed) | |
| // A non-zero constant on one mode's gains, which is what a mode-dependent step 1 | |
| // produces: adverse by construction, since the offset cannot be zero. | |
| let injected: Float = 0.25 | |
| let skewed = apart(landed.enumerated().map { | |
| $0.offset == 0 ? $0.element : $0.element + SIMD3(injected, 0, 0) | |
| }) | |
| sharedOK = landed.count == Method.allCases.count && drift == 0 | |
| && asked.allSatisfy { $0 == first } && skewed > 0 | |
| sharedStep = String(format: "shared step 1: the %d modes ask for the same gains " | |
| + "(%+.2f / %+.2f / %+.2f st, apart by %.4f), read on the %d frame " | |
| + "the re-measurement produced — and the check discriminates, one " | |
| + "mode offset by %.2f st reads %.4f apart", landed.count, | |
| first.x, first.y, first.z, drift, frames.count, injected, skewed) | |
| } else { | |
| sharedOK = false | |
| sharedStep = "shared step 1: no mode returned a balance" | |
| } | |
| // MARK: - What the third point does not reach, and what the two added ones can | |
| /// Where each channel's quantile lands once the balance's own handles are applied. Read in | |
| /// the render, the only surface on which two grades with different gains compare. | |
| func rendered(_ frame: Histogram, _ points: [Suggestion], at fraction: Float) -> [Float] { | |
| let scale = Float(frame.rgb.count - 1) | |
| return (0..<3).compactMap { channel -> Float? in | |
| let counts = frame.rgb.map { $0[channel] } | |
| guard let bin = quantile(of: counts, at: fraction) else { return nil } | |
| let e = Graduation.value(atFraction: bin / scale, in: .negative) | |
| // Explicit `Self.`: `placed` also names the local placement above, and Swift would | |
| // find that one without the qualification. | |
| return DensityMigration.applyWindow(e, Self.placed(points[channel], in: .negative)) | |
| } | |
| } | |
| func spread(_ values: [Float]) -> Float { (values.max() ?? 0) - (values.min() ?? 0) } | |
| /// The two added handles solved onto the reference's tenth and ninetieth, and the largest | |
| /// ask in travels: truncated at the stop, a solve may move one of the two the wrong way. | |
| func steered(_ points: [Suggestion], | |
| _ frame: Histogram) -> (points: [Suggestion], asked: Float) { | |
| let at = rendered(frame, points, at: 0.1), to = rendered(frame, points, at: 0.9) | |
| guard at.count == 3, to.count == 3 else { return (points, 0) } | |
| func basis(_ t: Float) -> (low: Float, high: Float) { | |
| let u = min(max(t, 0), 1), v = 1 - u | |
| return (4 * u * v * v * v, 4 * u * u * u * v) | |
| } | |
| let travel = 0.25 - Levels.shadowRange.lowerBound | |
| var out = points | |
| var asked: Float = 0 | |
| for channel in 0..<3 where channel != reference { | |
| let low = basis(at[channel]), high = basis(to[channel]) | |
| let determinant = low.low * high.high - low.high * high.low | |
| guard abs(determinant) > 1e-6 else { continue } | |
| let wantedLow = at[reference] - at[channel] | |
| let wantedHigh = to[reference] - to[channel] | |
| let s = (wantedLow * high.high - low.high * wantedHigh) / determinant | |
| let h = (low.low * wantedHigh - wantedLow * high.low) / determinant | |
| asked = max(asked, max(abs(s), abs(h)) / travel) | |
| out[channel].shadows = min(max(0.25 - s, Levels.shadowRange.lowerBound), | |
| Levels.shadowRange.upperBound) | |
| out[channel].highlights = min(max(0.75 - h, Levels.highlightRange.lowerBound), | |
| Levels.highlightRange.upperBound) | |
| } | |
| return (out, asked) | |
| } | |
| var residual = "five points: nothing to propose" | |
| if let aligned = balance(measured, from: .zero, mode: .negative, threshold: defaultThreshold, | |
| method: .mids, remeasuring: remeasuring) { | |
| let after = remeasuring(aligned.stops) | |
| let reached = steered(aligned.levels, after) | |
| residual = String(format: "five points: after Mids the tenth and ninetieth still " | |
| + "disagree by %.4f and %.4f — the two added points, solved on that " | |
| + "same frame, bring them to %.4f and %.4f, for %.2f× the travel " | |
| + "they have", | |
| spread(rendered(after, aligned.levels, at: 0.1)), | |
| spread(rendered(after, aligned.levels, at: 0.9)), | |
| spread(rendered(after, reached.points, at: 0.1)), | |
| spread(rendered(after, reached.points, at: 0.9)), reached.asked) | |
| } | |
| // MARK: - The real path of a balance over a selection | |
| // `measured` is the only part no synthetic check can see, since it decodes the file itself. | |
| // Its counterpart here is the open frame's own copy, the other path one click can take. | |
| var pathReport = "selection's path: nothing to measure" | |
| var pathOK = true | |
| let started = Date() | |
| // Explicit `Self.`: `measured` also names the local histogram above, and Swift would find | |
| // that one without the qualification. | |
| let onFile = Self.measured(source, settings: PipelineSettings(), | |
| threshold: defaultThreshold, method: .classic) | |
| let cost = Date().timeIntervalSince(started) | |
| /// The two ends of one channel, and the gains, as the largest disagreement of each. | |
| func gaps(_ a: Balance, _ b: Balance) -> (gain: Float, point: Float) { | |
| ((0..<3).map { abs(a.stops[$0] - b.stops[$0]) }.max() ?? 9, | |
| (0..<3).map { max(abs(a.levels[$0].black - b.levels[$0].black), | |
| abs(a.levels[$0].white - b.levels[$0].white)) }.max() ?? 9) | |
| } | |
| // The bin is the finest thing either reading can name — a point IS a bin index over the | |
| // count, and the gains are read off medians in those same bins. | |
| let oneBin = 1 / Float(measured.rgb.count - 1) | |
| // HALF a bin, the widest interval that cannot hold two of them: a bar at the bin itself | |
| // puts a one-bin drift on float luck, passing or failing by the last ulp of a subtraction. | |
| let resolution = oneBin / 2 | |
| let resolutionInStops = resolution * stopsPerUnitE | |
| let open = Negative(url: source) | |
| if let onFile, let reduced = open.measure { | |
| let fullWidth = open.fullWidth | |
| func arm(_ stage: PipelineSettings.Stage) -> Balance? { | |
| func frame(_ stops: SIMD3<Float>) -> Histogram { | |
| var probe = PipelineSettings() | |
| probe.gains.stops = stops | |
| return Pipeline.histogram(of: reduced, settings: probe, before: stage, | |
| fullWidth: fullWidth) | |
| } | |
| return balance(frame(.zero), from: .zero, mode: .negative, | |
| threshold: defaultThreshold, method: .classic, remeasuring: frame) | |
| } | |
| // The twin: the same path reading the distribution one stage too late, after the levels | |
| // instead of before. Its bar is the claim's own, negated — never the reading it guards. | |
| if let replayed = arm(.levels), let late = arm(.curves).map({ gaps(onFile, $0) }) { | |
| let same = gaps(onFile, replayed) | |
| pathOK = same.gain < resolutionInStops && same.point < resolution | |
| && late.gain > resolutionInStops && late.point > resolution | |
| // Measured beside the claim and deliberately not bounded: where a channel's bright | |
| // speckle straddles the threshold, the black point crosses a plateau size moves. | |
| let drift = Self.measured(source, settings: PipelineSettings(), | |
| threshold: defaultThreshold, method: .classic, | |
| at: referenceSide).map { gaps(onFile, $0) } | |
| pathReport = String(format: "selection's path: the real path replays on its own " | |
| + "source to the same bin (%.4f st, %.4f, against half of a " | |
| + "%.4f bin) — and the check discriminates, one stage too late " | |
| + "moves it %.0f× and %.0f× that bar. Measured, unbounded: the " | |
| + "shipped %.0f px stands %.3f st and %.4f off a %.0f px " | |
| + "reading. %.2f s the view", | |
| same.gain, same.point, oneBin, | |
| late.gain / resolutionInStops, late.point / resolution, | |
| Negative.measureSide, drift?.gain ?? -1, drift?.point ?? -1, | |
| referenceSide, cost) | |
| } else { | |
| pathOK = false | |
| pathReport = "selection's path: no replay of `measured` on a real file" | |
| } | |
| } else { | |
| pathOK = false | |
| pathReport = "selection's path: `measured` returned nothing on a real file" | |
| } | |
| // Each report line carries its own verdict, not the group's — otherwise breaking one | |
| // block would mark a still-true line as failed. | |
| let modesOK = valid && kept | |
| let ok = modesOK && twoStepOK && sharedOK && pathOK | |
| return (ok, | |
| String(format: " %@ %@ : each mode keeps its promise (%@), and Classic makes " | |
| + "none (%.4f)\n %@ %@\n %@ %@\n ---- %@\n %@ %@", | |
| modesOK ? "OK " : "FAIL", | |
| source.lastPathComponent, promises.joined(separator: " · "), classicSpread, | |
| twoStepOK ? "OK " : "FAIL", twoStep, | |
| sharedOK ? "OK " : "FAIL", sharedStep, residual, | |
| pathOK ? "OK " : "FAIL", pathReport)) | |
| } | |
| /// What the placement owes. A pure function of a measurement, so all of it is checkable. | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| /// A channel whose pixels all sit between two bins, plus one stray pixel at each far end. | |
| func block(from: Int, to: Int, bins: Int = 1024, strays: Float = 1) -> [Float] { | |
| var counts = [Float](repeating: 0, count: bins) | |
| for bin in from...to { counts[bin] = 100 } | |
| counts[0] += strays | |
| counts[bins - 1] += strays | |
| return counts | |
| } | |
| // With no threshold, the very first and last pixels are found — "where the information | |
| // begins and ends" in the dumbest sense. | |
| let raw = suggest(counts: block(from: 300, to: 700), threshold: 0) | |
| report(raw?.black == 0 && raw?.white == 1, | |
| "zero threshold: the points reach the isolated pixels at both ends") | |
| // The check that justifies the threshold's existence: two stray pixels in 40,002 are | |
| // enough to send the points to the ends. | |
| let cleaned = suggest(counts: block(from: 300, to: 700), threshold: defaultThreshold) | |
| let expectedBlack = Float(300) / 1023, expectedWhite = Float(700) / 1023 | |
| report(abs((cleaned?.black ?? -1) - expectedBlack) < 0.01 | |
| && abs((cleaned?.white ?? -1) - expectedWhite) < 0.01, | |
| String(format: "threshold at %.1f %%: the isolated pixels are ignored, points at " | |
| + "%.4f and %.4f (the block is at %.4f and %.4f)", defaultThreshold * 100, | |
| cleaned?.black ?? -1, cleaned?.white ?? -1, expectedBlack, expectedWhite)) | |
| // Its twin: the threshold has to bite when raised, or a function ignoring it would pass. | |
| let biting = suggest(counts: block(from: 300, to: 700), threshold: 0.05) | |
| report((biting?.black ?? 0) > expectedBlack && (biting?.white ?? 1) < expectedWhite, | |
| String(format: "and the check discriminates: at 5 %% the threshold bites into the " | |
| + "block (%.4f → %.4f)", biting?.black ?? -1, biting?.white ?? -1)) | |
| // The threshold is a fraction of the channel, not of the image: two channels of very | |
| // different masses carrying the same shape must give the same suggestion. | |
| let weak = block(from: 300, to: 700).map { $0 * 0.01 } | |
| report(suggest(counts: weak, threshold: defaultThreshold) == cleaned, | |
| "a channel ten times less populated gives the same suggestion") | |
| // Nothing to say, and it must be said rather than invented. | |
| report(suggest(counts: [], threshold: 0) == nil, "an empty measurement suggests nothing") | |
| report(suggest(counts: [Float](repeating: 0, count: 1024), threshold: 0) == nil, | |
| "a channel with no pixel at all suggests nothing") | |
| var spike = [Float](repeating: 0, count: 1024) | |
| spike[512] = 1000 | |
| report(suggest(counts: spike, threshold: 0) == nil, | |
| "an entirely flat channel does not suggest a violent stretch") | |
| // A threshold so high it devours everything must not return a black above the white. | |
| let devoured = suggest(counts: block(from: 300, to: 700), threshold: 0.9) | |
| report(devoured == nil || (devoured!.white > devoured!.black), | |
| "a devouring threshold never returns a black above the white") | |
| // Ordering holds over a full sweep of the threshold, not just at one point. | |
| let monotone = stride(from: Float(0), through: maximumThreshold, by: 0.002).allSatisfy { | |
| guard let s = suggest(counts: block(from: 200, to: 800), threshold: $0) else { return true } | |
| return s.white > s.black && s.black >= 0 && s.white <= 1 | |
| } | |
| report(monotone, "over the whole domain of the threshold, the points stay ordered and in [0,1]") | |
| // MARK: - The alignment | |
| /// A bell-shaped distribution, placed at a given abscissa, plus an optional tail. | |
| func lobe(at centre: Int, spread: Int = 60, bins: Int = 1024, | |
| tail: ClosedRange<Int>? = nil, tailMass: Float = 0) -> [Float] { | |
| var counts = [Float](repeating: 0, count: bins) | |
| for bin in max(0, centre - spread)...min(bins - 1, centre + spread) { | |
| let d = Float(bin - centre) / Float(spread) | |
| counts[bin] = max(0, 1 - d * d) * 1000 | |
| } | |
| // A second, smaller peak gives the correlation something to grip beyond a plain | |
| // average. | |
| for bin in max(0, centre - spread * 3)...max(0, centre - spread * 2) { | |
| counts[bin] += 300 | |
| } | |
| if let tail { for bin in tail { counts[bin] += tailMass } } | |
| return counts | |
| } | |
| /// A three-channel histogram, each one shifted by a known number of bins. | |
| func scene(_ shifts: [Int], tails: [ClosedRange<Int>?] = [nil, nil, nil], | |
| tailMass: Float = 0) -> Histogram { | |
| let channels = (0..<3).map { | |
| lobe(at: 500 - shifts[$0], tail: tails[$0], tailMass: tailMass) | |
| } | |
| return Histogram(rgb: (0..<1024).map { | |
| SIMD3(channels[0][$0], channels[1][$0], channels[2][$0]) | |
| }, luma: (0..<1024).map { channels[1][$0] }) | |
| } | |
| // The central check: a known shift has to come back with its sign, or a sign inversion | |
| // would double the cast instead of removing it. | |
| let known = scene([80, 0, -50]) | |
| if let placed = placement(known, threshold: defaultThreshold, method: .mids), | |
| let anchor = suggest(known, channel: reference, threshold: defaultThreshold) { | |
| let expectedRed = anchor.black - 80 / Float(1023) | |
| let expectedBlue = anchor.black + 50 / Float(1023) | |
| report(abs(placed[0].black - expectedRed) < 2e-3 | |
| && abs(placed[1].black - anchor.black) < 1e-6 | |
| && abs(placed[2].black - expectedBlue) < 2e-3, | |
| String(format: "a known shift comes back: red %.4f (expected %.4f), " | |
| + "blue %.4f (expected %.4f)", placed[0].black, expectedRed, | |
| placed[2].black, expectedBlue)) | |
| // The three widths are equal: what is undone is a translation, not three independent | |
| // stretches. | |
| let spans = placed.map { $0.white - $0.black } | |
| report((spans.max() ?? 0) - (spans.min() ?? 0) < 1e-6, | |
| String(format: "the three channels keep the same width (%.4f)", spans[0])) | |
| } else { | |
| report(false, "the alignment returned nothing on a synthetic scene") | |
| } | |
| // The contract of the modes: they share their ends and differ in what else each writes. | |
| // Uses a scene with a shape difference between channels. | |
| let cast = scene([40, 0, -20], tails: [nil, nil, 700...1000], tailMass: 12) | |
| for method in Method.allCases { | |
| guard let placed = placement(cast, threshold: defaultThreshold, method: method) else { | |
| report(false, "\(method.label) returned nothing"); continue | |
| } | |
| let movedMids = placed.enumerated().contains { $0.offset != reference && $0.element.mid != 0.5 } | |
| let ends = placement(cast, threshold: defaultThreshold, method: .classic)! | |
| let movedWhites = placed.enumerated().contains { | |
| $0.offset != reference && abs($0.element.white - ends[$0.offset].white) > 1e-4 | |
| } | |
| let expected: (mids: Bool, whites: Bool) | |
| switch method { | |
| case .classic: expected = (false, false) | |
| case .mids: expected = (true, false) | |
| case .highlights, .whites, .body: expected = (false, true) | |
| // The mean of the five moves whatever any of them moved, which on this scene is both. | |
| case .average: expected = (true, true) | |
| } | |
| report((movedMids, movedWhites) == expected, | |
| "\(method.label): medians moved \(movedMids), whites moved \(movedWhites)") | |
| // No mode places the two added points, the mean of five rests included — a complete | |
| // state either way, which is what keeps two buttons distinct after the first press. | |
| report(placed.allSatisfy { $0.shadows == 0.25 && $0.highlights == 0.75 }, | |
| "\(method.label): the two added points are written at rest, the identity") | |
| report(placed[reference].mid == 0.5 | |
| && placed[reference].white == ends[reference].white, | |
| "\(method.label): green, the reference, is untouched") | |
| } | |
| // The sequence: pressing Mids then Classic must not leave the median where Mids put it. | |
| // A placement must lay down a complete state, or the result depends on click order. | |
| if let byMids = placement(cast, threshold: defaultThreshold, method: .mids), | |
| let byClassic = placement(cast, threshold: defaultThreshold, method: .classic), | |
| let moved = (0..<3).first(where: { abs(byMids[$0].mid - 0.5) > 0.02 }) { | |
| var levels = placed(byMids[moved], in: .negative) | |
| let afterMids = levels.mid | |
| levels = placed(byClassic[moved], in: .negative) | |
| report(abs(afterMids - 0.5) > 0.02 && levels.mid == 0.5, | |
| String(format: "Mids then Classic on channel %d: the median goes to %.3f then " | |
| + "returns to neutral (%.3f) — the two buttons stay distinct regardless " | |
| + "of click order", moved, afterMids, levels.mid)) | |
| } else { | |
| report(false, "no channel is moved by Mids: the sequence tests nothing") | |
| } | |
| // MARK: - Average: the mean of the others, handle by handle | |
| /// The five handles of one channel, so a comparison reads every one of them and not the | |
| /// two ends alone. | |
| func allHandles(_ s: Suggestion) -> [Float] { | |
| [s.black, s.white, s.mid, s.shadows, s.highlights] | |
| } | |
| let contributors = Method.averaged.compactMap { | |
| placement(cast, threshold: defaultThreshold, method: $0) | |
| } | |
| if let byAverage = placement(cast, threshold: defaultThreshold, method: .average), | |
| contributors.count == Method.averaged.count { | |
| /// How far a proposal stands from the mean of the contributors, over all five handles. | |
| func offMean(_ candidate: [Suggestion]) -> Float { | |
| (0..<3).compactMap { channel -> Float? in | |
| guard let mean = Suggestion.mean(of: contributors.map { $0[channel] }) | |
| else { return nil } | |
| return zip(allHandles(candidate[channel]), allHandles(mean)) | |
| .map { abs($0 - $1) }.max() | |
| }.max() ?? 9 | |
| } | |
| report(offMean(byAverage) == 0, | |
| "average: every handle is the mean of the \(contributors.count) other modes, " | |
| + "exactly") | |
| // The twin: one handle displaced by a stated amount, so the comparison above is shown | |
| // to bite. Adverse by construction — the displacement cannot be zero. | |
| let nudge: Float = 0.01 | |
| var wrong = byAverage | |
| wrong[0].mid += nudge | |
| report(abs(offMean(wrong) - nudge) < 1e-6, | |
| String(format: "and the check discriminates: one handle moved by %.3f reads " | |
| + "%.3f off the mean", nudge, offMean(wrong))) | |
| // The mean of ordered windows is an ordered window, and the inner three stay on their | |
| // own tracks: no clamp is applied afterwards, so this has to hold by arithmetic. | |
| report(byAverage.allSatisfy { | |
| $0.white - $0.black >= minimumSpan && Levels.midRange.contains($0.mid) | |
| && Levels.shadowRange.contains($0.shadows) | |
| && Levels.highlightRange.contains($0.highlights) | |
| }, | |
| String(format: "and it lands on the track unadjusted: narrowest window %.4f " | |
| + "against a floor of %.4f", | |
| byAverage.map { $0.white - $0.black }.min() ?? -1, minimumSpan)) | |
| // Measured, not arbitrated: how far the mean stands from each button it averages. | |
| let apart = zip(Method.averaged, contributors).map { method, points in | |
| String(format: "%@ %.4f", method.label, | |
| (0..<3).map { channel in | |
| zip(allHandles(byAverage[channel]), allHandles(points[channel])) | |
| .map { abs($0 - $1) }.max() ?? 9 | |
| }.max() ?? 9) | |
| } | |
| report(true, "average stands from each button it averages: " | |
| + apart.joined(separator: " · ")) | |
| } else { | |
| report(false, "average returned nothing, or a mode it averages did") | |
| } | |
| // MARK: - The anchored family: whites and body, each apart from its neighbour by design | |
| /// Where each channel's statistic renders once a placement is applied, the kernel's | |
| /// stage 7 replicated in Swift over the suggestion's own window. | |
| func landings(_ points: [Suggestion], _ h: Histogram, | |
| at value: ([Float]) -> Float?) -> [Float] { | |
| (0..<3).compactMap { c -> Float? in | |
| let counts = h.rgb.map { $0[c] } | |
| guard let e = value(counts) else { return nil } | |
| let f = e / Float(counts.count - 1) | |
| let n = max((f - points[c].black) / max(points[c].white - points[c].black, 1e-4), 0) | |
| return pow(n, Levels(black: points[c].black, white: points[c].white, | |
| mid: points[c].mid).gamma) | |
| } | |
| } | |
| func spreadOf(_ values: [Float]) -> Float { (values.max() ?? 9) - (values.min() ?? 0) } | |
| func packed(_ channels: [[Float]]) -> Histogram { | |
| Histogram(rgb: (0..<channels[0].count).map { | |
| SIMD3(channels[0][$0], channels[1][$0], channels[2][$0]) | |
| }, luma: channels[1]) | |
| } | |
| /// A bright ledge inserted between two quantiles of red only, so `whites` and `highlights` | |
| /// read two different anchors on this scene whatever the numbers. | |
| let ledged = packed([lobe(at: 480, tail: 700...900, tailMass: 40), | |
| lobe(at: 500), lobe(at: 500)]) | |
| if let byWhites = placement(ledged, threshold: defaultThreshold, method: .whites), | |
| let byHighs = placement(ledged, threshold: defaultThreshold, method: .highlights) { | |
| let q95: ([Float]) -> Float? = { quantile(of: $0, at: 0.95) } | |
| let q90: ([Float]) -> Float? = { quantile(of: $0, at: 0.90) } | |
| let kept = spreadOf(landings(byWhites, ledged, at: q95)) | |
| report(kept < 1e-3, | |
| String(format: "whites: the brightest twentieth renders together (spread %.5f)", | |
| kept)) | |
| let highsOnQ95 = spreadOf(landings(byHighs, ledged, at: q95)) | |
| let whitesOnQ90 = spreadOf(landings(byWhites, ledged, at: q90)) | |
| report(highsOnQ95 > 10e-3 && whitesOnQ90 > 10e-3, | |
| String(format: "and the two buttons stay distinct both ways: Highs leaves the " | |
| + "twentieth %.4f apart, Whites leaves the tenth %.4f apart", highsOnQ95, | |
| whitesOnQ90)) | |
| } else { | |
| report(false, "whites or highs returned nothing on the ledged scene") | |
| } | |
| /// A heavy dark tail on red only: its share below the tenth is several times green's, so | |
| /// classic's data-edge blacks and body's aligned band are two different answers. | |
| let dragged = packed([lobe(at: 500, tail: 100...260, tailMass: 90), | |
| lobe(at: 500), lobe(at: 480)]) | |
| if let byBody = placement(dragged, threshold: defaultThreshold, method: .body), | |
| let byEnds = placement(dragged, threshold: defaultThreshold, method: .classic) { | |
| let low: ([Float]) -> Float? = { quantile(of: $0, at: bodyEnds.low) } | |
| let high: ([Float]) -> Float? = { quantile(of: $0, at: bodyEnds.high) } | |
| let keptLow = spreadOf(landings(byBody, dragged, at: low)) | |
| let keptHigh = spreadOf(landings(byBody, dragged, at: high)) | |
| report(keptLow < 1e-3 && keptHigh < 1e-3 | |
| && byBody.allSatisfy { $0.mid == 0.5 }, | |
| String(format: "body: both band ends render together (spreads %.5f and %.5f), " | |
| + "midpoints at rest", keptLow, keptHigh)) | |
| report(byBody[0].black > byEnds[0].black + 0.01, | |
| String(format: "body is not classic: red's black leaves the data edge " | |
| + "(%.4f against %.4f) to hold the band", byBody[0].black, | |
| byEnds[0].black)) | |
| let classicOnLow = spreadOf(landings(byEnds, dragged, at: low)) | |
| report(classicOnLow > 10e-3, | |
| String(format: "and the check discriminates: classic leaves the tenth %.4f " | |
| + "apart on this scene, by the tail it carries", classicOnLow)) | |
| } else { | |
| report(false, "body or classic returned nothing on the tailed scene") | |
| } | |
| // Nothing to say rather than inventing, on the degenerate cases. | |
| report(placement(Histogram.empty, threshold: 0, method: .mids) == nil, "an empty measurement aligns nothing") | |
| report(median(of: [Float](repeating: 0, count: 1024)) == nil, | |
| "a channel with no pixel has no median") | |
| // The write boundary: a suggestion spanning the whole measurement lands on the window's own | |
| // two ends — a black under zero included, which is what the density axis buys. | |
| let whole = placed(Suggestion(black: 0, white: 1, mid: 0.62), in: .negative) | |
| let window = Graduation.window(for: .negative) | |
| report(whole.black == window.lowerBound && whole.white == window.upperBound | |
| && whole.mid == 0.62, | |
| String(format: "placing the points writes all three handles, on the window " | |
| + "(%.4f, %.2f, %.4f)", whole.black, whole.mid, whole.white)) | |
| // The twin: the fractions written straight in, the mistake the conversion exists against. | |
| report(whole.black != 0 && whole.white != 1, | |
| String(format: "the twin: the same suggestion written as measured would put the " | |
| + "black at 0.0000 and the white at 1.0000, %.4f of density too high and " | |
| + "%.4f too low", -window.lowerBound, window.upperBound - 1)) | |
| // The twin that protects Mids: a placement without its own median resets to neutral, and | |
| // the two added points with it — a button writes five handles or none. | |
| let reset = placed(Suggestion(black: 0.05, white: 0.9), in: .negative) | |
| report(reset.mid == 0.5 && reset.shadows == 0.25 && reset.highlights == 0.75, | |
| String(format: "a placement without its own inner points resets all three to " | |
| + "neutral (%.2f, %.2f, %.2f)", reset.shadows, reset.mid, reset.highlights)) | |
| // And it really carries them when it has them, or the reset above would be a field nothing | |
| // ever writes. | |
| let carried = placed(Suggestion(black: 0.05, white: 0.9, mid: 0.62, shadows: 0.37, | |
| highlights: 0.58), in: .negative) | |
| report(carried.shadows == 0.37 && carried.highlights == 0.58, | |
| String(format: "and a placement carrying them writes them through (%.2f, %.2f)", | |
| carried.shadows, carried.highlights)) | |
| // Both modes read their own window, or a positive frame would take the negative's axis. | |
| let positive = placed(Suggestion(black: 0, white: 1), in: .positive) | |
| report(positive.black == Graduation.window(for: .positive).lowerBound | |
| && positive.white == Graduation.window(for: .positive).upperBound | |
| && positive.black != whole.black, | |
| String(format: "and a positive frame lands on its own window (%.4f…%.4f against " | |
| + "%.4f…%.4f)", positive.black, positive.white, whole.black, whole.white)) | |
| // MARK: - The two steps | |
| // The scene is given as densities the frame really holds, not as already-measured bins: the | |
| // window's ends are what a gain carries mass across, and only a real source shows it. | |
| func simulate(_ densities: [[Float]], stops: SIMD3<Float>, | |
| mode: ConversionMode = .negative, bins: Int = 1024) -> Histogram { | |
| let graduated = Graduation.levels(for: mode) | |
| // The measuring path's own settings, so the sweep goes through the live kernel's closed | |
| // form rather than a second replica of it, which would model a kernel nobody runs. | |
| let measuring = LevelsSet(luma: .neutral, linked: .neutral, | |
| red: graduated, green: graduated, blue: graduated) | |
| var counts = [SIMD3<Float>](repeating: .zero, count: bins) | |
| for channel in 0..<3 { | |
| for density in densities[channel] { | |
| let source = SIMD3<Float>(repeating: pow(10, -density) - Pipeline.tpedestal) | |
| let measured = DensityMigration.new(source, | |
| stops: SIMD3(repeating: stops[channel]), | |
| levels: measuring, mode: mode, | |
| pedestal: Pipeline.tpedestal, | |
| guardFloor: Pipeline.tmin) | |
| // Bounded above as `Pipeline.histogram(of:)` bounds it: that is the top end the | |
| // mass piles against, the floor at zero being the kernel's own. | |
| let bin = Int((min(measured.x, 1) * Float(bins - 1)).rounded()) | |
| counts[min(max(bin, 0), bins - 1)][channel] += 1 | |
| } | |
| } | |
| return Histogram(rgb: counts, luma: counts.map { $0.y }) | |
| } | |
| /// One channel: `count` densities spread uniformly over `spread`, so its median is exactly | |
| /// `centre` and every quantile of it translates by the same amount. | |
| func cloud(centre: Float, spread: Float, count: Int = 4000) -> [Float] { | |
| (0..<count).map { centre + ((Float($0) + 0.5) / Float(count) - 0.5) * spread } | |
| } | |
| /// The same channel a gain of `stops` away: on the density axis that is a translation of | |
| /// 0.301 per stop and nothing else, the pedestal sitting upstream of the gain. | |
| func shifted(_ base: [Float], byStops stops: Float) -> [Float] { | |
| base.map { $0 - stops * log10(Float(2)) } | |
| } | |
| /// A body plus the bright tail a scan holds where it transmits more than 1: `share` of the | |
| /// channel, against the window's bottom, which is where a recentring would push it out. | |
| func tailed(_ body: [Float], from: Float, to: Float, share: Float) -> [Float] { | |
| let count = max(Int((Float(body.count) * share / (1 - share)).rounded()), 1) | |
| return body + (0..<count).map { from + (Float($0) + 0.5) / Float(count) * (to - from) } | |
| } | |
| /// The share of a channel piled onto the two end bins — what the window's ends swallow. | |
| func piled(_ h: Histogram, _ channel: Int) -> Float { | |
| let counts = h.rgb.map { $0[channel] } | |
| let total = counts.reduce(0, +) | |
| guard total > 0, let last = counts.last else { return 0 } | |
| return (counts[0] + last) / total | |
| } | |
| /// Where each channel's median lands once the gains and the levels — read as handles, in | |
| /// density — are applied together. | |
| func outcomes(_ densities: [[Float]], stops: SIMD3<Float>, levels: [Levels]) -> [Float] { | |
| let frame = simulate(densities, stops: stops) | |
| let scale = Float(frame.rgb.count - 1) | |
| return (0..<3).compactMap { channel -> Float? in | |
| let counts = frame.rgb.map { $0[channel] } | |
| guard let bin = median(of: counts) else { return nil } | |
| let set = levels[channel] | |
| let e = Graduation.value(atFraction: bin / scale, in: .negative) | |
| let n = max((e - set.black) / max(set.white - set.black, 1e-4), 0) | |
| return pow(n, set.gamma) | |
| } | |
| } | |
| func disagreement(_ values: [Float]) -> Float { (values.max() ?? 0) - (values.min() ?? 0) } | |
| /// The same suggestions the panel would write, so the checks read the handles and not the | |
| /// fractions they came from. | |
| func handles(_ points: [Suggestion]) -> [Levels] { points.map { placed($0, in: .negative) } } | |
| // The base of both scenes: a green well inside the window, and a red identical to it, so | |
| // its recentring must be zero. | |
| let spread: Float = 1.2 | |
| let green = cloud(centre: 1.2, spread: spread) | |
| // Step 1's central check: a known shift has to come back with its sign. | |
| let sane = [green, green, shifted(green, byStops: -1.5)] | |
| let saneFrame = simulate(sane, stops: .zero) | |
| if let recentring = recentred(saneFrame, from: .zero, mode: .negative, | |
| threshold: defaultThreshold) { | |
| report(abs(recentring.z - 1.5) < 0.03 && abs(recentring.x) < 0.03 && recentring.y == 0, | |
| String(format: "step 1: a shift of 1.5 stops comes back (blue %+.3f st, red " | |
| + "%+.3f st, green %+.3f st — green is never touched)", | |
| recentring.z, recentring.x, recentring.y)) | |
| } else { | |
| report(false, "step 1: no recentring on a sane scene") | |
| } | |
| // The sign's twin: the two conversion directions are exactly inverse. | |
| let roundTrip = stops(forShift: shift(ofStops: 1.5, mode: .negative), mode: .negative) | |
| report(abs(roundTrip - 1.5) < 1e-4 | |
| && abs(shift(ofStops: 1.5, mode: .positive) + shift(ofStops: 1.5, mode: .negative)) | |
| < 1e-6 | |
| && shift(ofStops: 1.5, mode: .negative) < 0, | |
| String(format: "step 1: a gain lowers the measurement in negative (%.4f of the " | |
| + "window) and raises it in positive (%.4f), and the round trip is exact " | |
| + "(%.4f)", shift(ofStops: 1.5, mode: .negative), | |
| shift(ofStops: 1.5, mode: .positive), roundTrip)) | |
| // The broken scene: blue is five stops denser than green, so the quarter of it past the | |
| // window's top — the guard's own density — piles on the last bin. | |
| let broken = [green, green, shifted(green, byStops: -5)] | |
| let brokenFrame = simulate(broken, stops: .zero) | |
| let brokenPile = piled(brokenFrame, 2) | |
| // MARK: - Why the second measurement survives the density axis | |
| /// One bin of the graduation, as the readers divide it: the resolution every reading here | |
| /// is held to. | |
| let oneBin = 1 / Float(brokenFrame.rgb.count - 1) | |
| /// Three quantiles of one channel, spread so a clipped end cannot hide behind the median. | |
| func quantiles(_ densities: [[Float]], _ channel: Int, stops: SIMD3<Float>) -> [Float] { | |
| let counts = simulate(densities, stops: stops).rgb.map { $0[channel] } | |
| let scale = Float(counts.count - 1) | |
| return [0.1, 0.5, 0.9].compactMap { quantile(of: counts, at: $0).map { $0 / scale } } | |
| } | |
| /// How far a gain moves a channel's quantiles against the translation it owes. | |
| func travelled(_ densities: [[Float]], _ channel: Int, byStops asked: Float) -> Float { | |
| var moved = SIMD3<Float>.zero | |
| moved[channel] = asked | |
| let owed = shift(ofStops: asked, mode: .negative) | |
| return zip(quantiles(densities, channel, stops: .zero), | |
| quantiles(densities, channel, stops: moved)) | |
| .map { abs($1 - $0 - owed) }.max() ?? 9 | |
| } | |
| // The physics motive for measuring twice is gone: the pedestal sits upstream of the gain, | |
| // so on the density axis a gain translates a channel exactly, unpiling nothing. | |
| let exact = travelled(sane, 2, byStops: 1.5) | |
| report(exact < 2 * oneBin, | |
| String(format: "a gain translates a channel inside the window exactly: its tenth, " | |
| + "half and ninetieth all move by %.4f of the window, within %.1f bin of the " | |
| + "%.4f owed", shift(ofStops: 1.5, mode: .negative), exact / oneBin, | |
| shift(ofStops: 1.5, mode: .negative))) | |
| // And what keeps the second measurement anyway, adverse by construction: the same | |
| // reading on a channel crossing the window's top, which no translation of bins reaches. | |
| let crossing = travelled(broken, 2, byStops: 3) | |
| report(crossing > 20 * oneBin, | |
| String(format: "and the check discriminates: on the channel crossing the window's " | |
| + "top the same gain moves them by up to %.4f off that translation, %.0f bins " | |
| + "— the mass a gain carries over an end is not in the bins to be translated", | |
| crossing, crossing / oneBin)) | |
| for (name, raws, frame) in [("sane", sane, saneFrame), ("broken", broken, brokenFrame)] { | |
| guard let oneStep = placement(frame, threshold: defaultThreshold, method: .classic), | |
| let twoStep = balance(frame, from: .zero, mode: .negative, | |
| threshold: defaultThreshold, method: .classic, | |
| remeasuring: { simulate(raws, stops: $0) }) | |
| else { report(false, "the two steps returned nothing on the \(name) scene"); continue } | |
| let before = disagreement(outcomes(raws, stops: .zero, levels: handles(oneStep))) | |
| let after = disagreement(outcomes(raws, stops: twoStep.stops, | |
| levels: handles(twoStep.levels))) | |
| if name == "broken" { | |
| // Two steps must land both medians at the middle of their own window, so what is | |
| // left is the binning, read through a window as narrow as the cloud's spread. | |
| let noise = 3 * oneBin * Graduation.span / spread | |
| report(after < noise && after < before / 10, | |
| String(format: "broken scene: the residual gap goes from %.4f in one step to " | |
| + "%.4f in two steps (%.0f× better, for %.4f of binning)", before, | |
| after, after > 0 ? before / after : 999, noise)) | |
| // What one step reads instead: a white point pinned on the pile, hence a window | |
| // three quarters of the channel wide describing a whole one. | |
| report(oneStep[2].white == 1, | |
| String(format: "broken scene: one step reads blue's white point off the " | |
| + "pile, at the very top of the window (%.4f)", oneStep[2].white)) | |
| // And on the cause, not only the effect: the share of the channel past the window's | |
| // top is what piles, and the gain brings it back. | |
| let expected = Float(raws[2].filter { $0 > Graduation.top }.count) | |
| / Float(raws[2].count) | |
| let recovered = piled(simulate(raws, stops: twoStep.stops), 2) | |
| report(abs(brokenPile - expected) < 0.01 && recovered < brokenPile / 10, | |
| String(format: "broken scene: the blue pile-up at the extremes drops from " | |
| + "%.1f %% to %.2f %% — the %.1f %% of it that sits past D = %.3f", | |
| brokenPile * 100, recovered * 100, expected * 100, Graduation.top)) | |
| // Truncated, not refused: five stops asked for, three written, step 2 doing the rest. | |
| let ownMedian = median(of: frame.rgb.map { $0[2] }) ?? 0 | |
| let anchor = median(of: frame.rgb.map { $0[1] }) ?? 0 | |
| let asked = stops(forShift: (anchor - ownMedian) * oneBin, mode: .negative) | |
| report(abs(asked - 5) < 0.03 && twoStep.stops.z == Gains.range.upperBound, | |
| String(format: "broken scene: the %.2f stops the two medians ask for are " | |
| + "TRUNCATED to %+.1f, not refused", asked, twoStep.stops.z)) | |
| } else { | |
| // The opposite twin: on a sane scene step 1 is a pure translation, so two steps | |
| // must give exactly the same result as one. | |
| report(abs(after - before) < 5e-3, | |
| String(format: "sane scene: step 1 changes nothing about the result " | |
| + "(%.4f versus %.4f)", after, before)) | |
| // And it creates no clipping where there was none. | |
| let sanePile = (0..<3).map { piled(simulate(raws, stops: twoStep.stops), $0) } | |
| let wasPiled = (0..<3).map { piled(frame, $0) } | |
| report(zip(sanePile, wasPiled).allSatisfy { $0 <= $1 + 1e-3 }, | |
| String(format: "sane scene: no channel is pushed into clipping " | |
| + "(%.3f %% at worst, versus %.3f %% before)", | |
| (sanePile.max() ?? 0) * 100, (wasPiled.max() ?? 0) * 100)) | |
| // The markers mapped back into the displayed histogram's frame must land on those | |
| // of a single step — as handles, since that is what the panel draws them beside. | |
| let single = handles(oneStep) | |
| let mapped = (0..<3).map { twoStep.shown($0, in: .negative) } | |
| let density = oneBin * Graduation.span | |
| let drift = (0..<3).map { max(abs(mapped[$0].black - single[$0].black), | |
| abs(mapped[$0].white - single[$0].white)) } | |
| report((drift.max() ?? 9) < 2 * density, | |
| String(format: "sane scene: the markers mapped back land on those of a " | |
| + "single step, within %.1f bin (%.4f of density)", | |
| (drift.max() ?? 9) / density, drift.max() ?? 9)) | |
| // The twin: without the mapping they would be out by step 1's own translation, | |
| // which this scene fixes at 1.5 stops. | |
| let unread = handles(twoStep.levels) | |
| let unmapped = (0..<3).map { max(abs(unread[$0].black - single[$0].black), | |
| abs(unread[$0].white - single[$0].white)) } | |
| let owed = abs(shift(ofStops: 1.5, mode: .negative)) * Graduation.span | |
| report(abs((unmapped.max() ?? 0) - owed) < 2 * density, | |
| String(format: "and the check discriminates: without the mapping the gap is " | |
| + "%.4f of density, the 1.5 stops step 1 applied (%.4f)", | |
| unmapped.max() ?? 0, owed)) | |
| } | |
| } | |
| // The window bound, on a scene built to exercise it: a channel denser than green, whose | |
| // bright tail transmits more than 1 and so already lies against the window's bottom. | |
| let tail: (from: Float, to: Float, share: Float) = (-0.30, -0.15, 0.08) | |
| let brink = [tailed(cloud(centre: 2.0, spread: 0.8), from: tail.from, to: tail.to, | |
| share: tail.share), green, green] | |
| let brinkFrame = simulate(brink, stops: .zero) | |
| let brinkScale = Float(brinkFrame.rgb.count - 1) | |
| if let held = recentred(brinkFrame, from: .zero, mode: .negative, | |
| threshold: defaultThreshold), | |
| let ends = suggest(brinkFrame, channel: 0, threshold: defaultThreshold), | |
| let own = median(of: brinkFrame.rgb.map { $0[0] }), | |
| let anchor = median(of: brinkFrame.rgb.map { $0[1] }) { | |
| /// The same computation, minus the bound: what step 1 would ask for looking only at | |
| /// the medians. | |
| let unbounded = SIMD3<Float>(stops(forShift: (anchor - own) / brinkScale, | |
| mode: .negative), 0, 0) | |
| let before = piled(brinkFrame, 0) | |
| let after = piled(simulate(brink, stops: held), 0) | |
| let without = piled(simulate(brink, stops: unbounded), 0) | |
| // The bound is the room itself, not a margin chosen for it: exactly the distance from | |
| // the channel's bright end to the window's bottom. | |
| let room = stops(forShift: -ends.black, mode: .negative) | |
| report(abs(held.x - room) < 1e-4 && held.x < unbounded.x, | |
| String(format: "scene at the brink: the bound holds recentring to %+.3f st, the " | |
| + "room between the tail and the window's bottom, instead of the %+.2f " | |
| + "the medians ask for — %.2f %% of the channel outside for a %.2f %% " | |
| + "threshold (%.2f %% before)", held.x, unbounded.x, after * 100, | |
| defaultThreshold * 100, before * 100)) | |
| report(without > after + tail.share / 2 && after < tail.share / 10, | |
| String(format: "and the check discriminates: without the bound the whole tail, " | |
| + "%.1f %% of the channel, leaves the window (%.1f %% against %.2f %%)", | |
| tail.share * 100, without * 100, after * 100)) | |
| } else { | |
| report(false, "scene at the brink: no recentring, the bound is not exercised") | |
| } | |
| // MARK: - A whole selection at once | |
| // Two views in different light — the only scene that separates the two strategies. | |
| let firstScene = [green, green, shifted(green, byStops: -1.5)] | |
| let secondScene = [shifted(green, byStops: -1), green, green] | |
| let scenes = ["A": firstScene, "B": secondScene] | |
| /// A view's measurement, without disk or GPU. | |
| func synthetic(_ frame: Frame) -> Balance? { | |
| guard let raws = scenes[frame.path] else { return nil } | |
| let stops = frame.settings.gains.stops | |
| return balance(simulate(raws, stops: stops), from: stops, mode: .negative, | |
| threshold: defaultThreshold, method: .classic, | |
| remeasuring: { simulate(raws, stops: $0) }) | |
| } | |
| /// The residual spread between a view's three channels once its own settings are applied — | |
| /// read from the handles those settings carry, which are already in density. | |
| func residual(_ raws: [[Float]], _ settings: PipelineSettings) -> Float { | |
| disagreement(outcomes(raws, stops: settings.gains.stops, | |
| levels: LevelsChannel.perChannel.map { settings.levels[$0] })) | |
| } | |
| // The second view carries its own crop and curve, so the check can see they stay put. | |
| var second = PipelineSettings() | |
| second.geometry.crop = CGRect(x: 0.05, y: 0.1, width: 0.8, height: 0.7) | |
| second.curves.linked.points = [CGPoint(x: 0, y: 0), CGPoint(x: 0.3, y: 0.5), | |
| CGPoint(x: 1, y: 1)] | |
| let roll = [Frame(path: "A", settings: PipelineSettings()), | |
| Frame(path: "B", settings: second)] | |
| func walk(_ strategy: Strategy) -> [String: PipelineSettings] { | |
| var landed: [String: PipelineSettings] = [:] | |
| do { | |
| try run(roll, strategy: strategy, measuring: synthetic) { frame, balance in | |
| landed[frame.path] = applied(balance, to: frame.settings) | |
| } | |
| } catch { return [:] } | |
| return landed | |
| } | |
| let each = walk(.each), copied = walk(.fromFirst) | |
| guard let eachA = each["A"], let eachB = each["B"], | |
| let copiedA = copied["A"], let copiedB = copied["B"] else { | |
| report(false, "selection: one of the two strategies returned nothing") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| // The heart of the ticket: one-per-image gives different gains, propagating the first | |
| // makes them identical. | |
| report(eachA.gains.stops != eachB.gains.stops, | |
| String(format: "one per image: each view has its own gains (%+.2f/%+.2f/%+.2f versus " | |
| + "%+.2f/%+.2f/%+.2f)", eachA.gains.stops.x, eachA.gains.stops.y, | |
| eachA.gains.stops.z, eachB.gains.stops.x, eachB.gains.stops.y, | |
| eachB.gains.stops.z)) | |
| let sameLevels = LevelsChannel.perChannel.allSatisfy { copiedA.levels[$0] == copiedB.levels[$0] } | |
| report(copiedA.gains == copiedB.gains && sameLevels, | |
| String(format: "propagating the first: both views carry its gains " | |
| + "(%+.2f/%+.2f/%+.2f) and its three sets of points", | |
| copiedB.gains.stops.x, copiedB.gains.stops.y, copiedB.gains.stops.z)) | |
| // And they are the first's own answer, not a third value. | |
| report(copiedA == eachA, "and these are the ones the first would have had on its own") | |
| // The choice matters in the image, not only in numbers: corrected for itself the second | |
| // view aligns; corrected by the first, it does not. | |
| let own = residual(secondScene, eachB), borrowed = residual(secondScene, copiedB) | |
| report(own < borrowed / 3, | |
| String(format: "and the two answers differ in the image: median gap of the second " | |
| + "view %.4f for itself, %.4f with the first's", | |
| own, borrowed)) | |
| // The exclusion, on the real path of propagation. | |
| report(copiedB.geometry == second.geometry && eachB.geometry == second.geometry | |
| && copiedB.curves == second.curves && eachB.curves == second.curves, | |
| "neither the second view's crop nor its curves move, whichever strategy") | |
| // Stops at the first failure, like the export, rather than reporting once at the end. | |
| let withHole = [Frame(path: "A", settings: PipelineSettings()), | |
| Frame(path: "introuvable", settings: PipelineSettings()), | |
| Frame(path: "B", settings: PipelineSettings())] | |
| var reached: [String] = [] | |
| var stopped = false | |
| do { | |
| try run(withHole, strategy: .each, measuring: synthetic) { frame, _ in | |
| reached.append(frame.path) | |
| } | |
| } catch { stopped = true } | |
| report(stopped && reached == ["A"], | |
| "an unmeasurable view stops the loop, and only the one before was written " | |
| + "(\(reached.joined(separator: " · ")))") | |
| // The twin: propagating the first never decodes the following views, so an unreadable one | |
| // cannot fail the batch. | |
| var copiedReach: [String] = [] | |
| let survived: Bool = { | |
| do { | |
| try run(withHole, strategy: .fromFirst, measuring: synthetic) { frame, _ in | |
| copiedReach.append(frame.path) | |
| } | |
| return true | |
| } catch { return false } | |
| }() | |
| report(survived && copiedReach.count == withHole.count, | |
| "propagating the first, the following ones are not measured: all three pass") | |
| // The count is in the label on both options — the only protection against a forgotten | |
| // selection. | |
| report(Strategy.allCases.allSatisfy { $0.label(count: 36).contains("36") } | |
| && Strategy.each.label(count: 36) != Strategy.fromFirst.label(count: 36), | |
| "both options carry the count and do not say the same thing " | |
| + "(\(Strategy.allCases.map { $0.label(count: 36) }.joined(separator: " / ")))") | |
| let running = Batch(strategy: .each, done: 7, total: 36).label | |
| report(running.contains("7") && running.contains("36"), | |
| "the progress says where it stands and out of how many (\(running))") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// `--lab <files>`: research harness for the anchored balance family. Measures every candidate | |
| /// mode against the tester's own grade, in the render, and prints one table per question. | |
| enum BalanceLab { | |
| /// The render surface both arms are compared on, as the spec of the balance modes fixes it. | |
| static let scoredQuantiles: [Float] = [0.02, 0.1, 0.25, 0.5, 0.75, 0.9, 0.98] | |
| // MARK: - Peak definitions under test | |
| /// A peak estimator: a window fraction, or nothing when the channel holds no interior mass. | |
| /// The two end bins are excluded everywhere — they hold what the counted range piles, not data. | |
| enum PeakDef: String, CaseIterable { | |
| case argmax | |
| case slide9, slide33, slide65 | |
| case com33 | |
| case half33 | |
| func read(_ counts: [Float]) -> Float? { | |
| let bins = counts.count | |
| guard bins > 4 else { return nil } | |
| let interior = Array(counts[1..<(bins - 1)]) | |
| guard interior.max() ?? 0 > 0 else { return nil } | |
| func smoothed(_ window: Int) -> [Float] { | |
| let h = window / 2 | |
| return interior.indices.map { i in | |
| (max(0, i - h)...min(interior.count - 1, i + h)).reduce(Float(0)) { | |
| $0 + interior[$1] | |
| } | |
| } | |
| } | |
| func argmax(_ values: [Float]) -> Int { | |
| values.indices.max(by: { values[$0] < values[$1] }) ?? 0 | |
| } | |
| let scale = Float(bins - 1) | |
| switch self { | |
| case .argmax: | |
| return (Float(argmax(interior)) + 1) / scale | |
| case .slide9, .slide33, .slide65: | |
| let window = self == .slide9 ? 9 : self == .slide33 ? 33 : 65 | |
| return (Float(argmax(smoothed(window))) + 1) / scale | |
| case .com33: | |
| let h = 16 | |
| let p = argmax(smoothed(33)) | |
| let range = max(0, p - h)...min(interior.count - 1, p + h) | |
| let mass = range.reduce(Float(0)) { $0 + interior[$1] } | |
| guard mass > 0 else { return nil } | |
| let com = range.reduce(Float(0)) { $0 + Float($1) * interior[$1] } / mass | |
| return (com + 1) / scale | |
| case .half33: | |
| let s = smoothed(33) | |
| let p = argmax(s) | |
| let bar = s[p] / 2 | |
| var lo = p, hi = p | |
| while lo > 0, s[lo - 1] >= bar { lo -= 1 } | |
| while hi < s.count - 1, s[hi + 1] >= bar { hi += 1 } | |
| let mass = (lo...hi).reduce(Float(0)) { $0 + interior[$1] } | |
| guard mass > 0 else { return nil } | |
| let com = (lo...hi).reduce(Float(0)) { $0 + Float($1) * interior[$1] } / mass | |
| return (com + 1) / scale | |
| } | |
| } | |
| } | |
| /// The definition the bench arms anchor on; `agreesWithShipped` holds it against the estimator | |
| /// a button reads, since the bench's other windows are comparison points and must not move. | |
| static let retainedPeak: PeakDef = .slide33 | |
| /// The bench reads its own peak and the kernel through the GPU, so a stale `.metallib` or a | |
| /// moved `peakWindow` would price a shape the retired `Peak` arm never had. Both are refused. | |
| static func agreesWithShipped(_ counts: [Float]) -> Bool { | |
| let shipped = AutoLevels.peakBin(of: counts) | |
| guard let mine = retainedPeak.read(counts), let bin = shipped else { return shipped == nil } | |
| // `peakBin` names a bin, `read` a window fraction: the scale is what makes them comparable. | |
| return abs(mine - bin / Float(counts.count - 1)) < 1e-6 | |
| } | |
| // MARK: - Candidate placements | |
| /// A statistic a channel is anchored on, as a window fraction. | |
| enum Anchor { | |
| case quantile(Float) | |
| case peak(PeakDef) | |
| func read(_ counts: [Float]) -> Float? { | |
| switch self { | |
| case .quantile(let q): | |
| guard let bin = AutoLevels.quantile(of: counts, at: q) else { return nil } | |
| return bin / Float(counts.count - 1) | |
| case .peak(let def): | |
| return def.read(counts) | |
| } | |
| } | |
| } | |
| /// One candidate mode: a name and a placement over a measured histogram. | |
| struct Arm { | |
| let name: String | |
| let place: (Histogram, Float) -> [AutoLevels.Suggestion]? | |
| /// Gains recentring anchor; nil keeps the shared median step 1. | |
| var step1: Anchor? = nil | |
| } | |
| static let reference = AutoLevels.reference | |
| /// Classic's ends, the base every anchored placement starts from. | |
| static func classicEnds(_ hist: Histogram, _ t: Float) -> [AutoLevels.Suggestion]? { | |
| AutoLevels.placement(hist, threshold: t, method: .classic) | |
| } | |
| static func channelCounts(_ hist: Histogram) -> [[Float]] { | |
| (0..<3).map { c in hist.rgb.map { $0[c] } } | |
| } | |
| /// Green's render of its own anchor through its classic window, gamma 1 — the value the other | |
| /// two channels are made to land on. | |
| static func target(_ anchor: Anchor, _ hist: Histogram, _ ends: [AutoLevels.Suggestion]) | |
| -> Float? { | |
| let counts = channelCounts(hist) | |
| guard let e = anchor.read(counts[reference]) else { return nil } | |
| let g = ends[reference] | |
| let span = max(g.white - g.black, 1e-4) | |
| return (e - g.black) / span | |
| } | |
| /// Gamma alignment of one interior anchor, Mids' own shape with the statistic swapped. | |
| static func gammaAligned(_ anchor: Anchor) -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let raw = target(anchor, hist, out) else { return nil } | |
| guard raw > 0.02, raw < 1.5 else { return out } | |
| let tau = min(max(raw, 0.02), 0.98) | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let e = anchor.read(counts[c]) else { continue } | |
| let span = max(out[c].white - out[c].black, 1e-4) | |
| let n = min(max((e - out[c].black) / span, 0.02), 0.98) | |
| let gamma = log(tau) / log(n) | |
| guard gamma.isFinite, gamma > 0 else { continue } | |
| out[c].mid = min(max(pow(0.5, 1 / gamma), Levels.midRange.lowerBound), | |
| Levels.midRange.upperBound) | |
| } | |
| return out | |
| } | |
| } | |
| /// White alignment of one anchor, Highs' own shape with the statistic swapped. | |
| static func whiteAligned(_ anchor: Anchor) -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let raw = target(anchor, hist, out) else { return nil } | |
| guard raw > 0.02, raw < 1.5 else { return out } | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let e = anchor.read(counts[c]) else { continue } | |
| let solved = out[c].black + (e - out[c].black) / raw | |
| guard solved.isFinite else { continue } | |
| out[c].white = min(max(solved, out[c].black + AutoLevels.minimumSpan), 1) | |
| } | |
| return out | |
| } | |
| } | |
| /// Black alignment of one low anchor: the black moves so that anchor renders where green's | |
| /// does, the white staying Classic's. | |
| static func blackAligned(_ anchor: Anchor) -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let raw = target(anchor, hist, out) else { return nil } | |
| guard raw > 0.02, raw < 0.98 else { return out } | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let e = anchor.read(counts[c]) else { continue } | |
| let solved = (e - raw * out[c].white) / (1 - raw) | |
| guard solved.isFinite else { continue } | |
| out[c].black = min(max(solved, 0), out[c].white - AutoLevels.minimumSpan) | |
| } | |
| return out | |
| } | |
| } | |
| /// Two anchors at once through gamma and white, closed form: the ratio of the two landings | |
| /// fixes the normalised position, the position fixes the white, the target fixes the gamma. | |
| static func gammaWhiteAligned(_ mid: Anchor, _ high: Anchor) | |
| -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let rawM = target(mid, hist, out), let rawH = target(high, hist, out) | |
| else { return nil } | |
| guard rawM > 0.02, rawM < 0.98, rawH > rawM, rawH < 1.5 else { return out } | |
| let tauM = min(max(rawM, 0.02), 0.98) | |
| let tauH = min(max(rawH, 0.02), 0.98) | |
| let r = log(tauH) / log(tauM) | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let eM = mid.read(counts[c]), let eH = high.read(counts[c]), | |
| eH > eM, eM > out[c].black else { continue } | |
| let k = (eH - out[c].black) / (eM - out[c].black) | |
| guard abs(r - 1) > 1e-4, k > 1 else { continue } | |
| let nM = pow(k, 1 / (r - 1)) | |
| guard nM > 0.02, nM < 0.98 else { continue } | |
| let white = out[c].black + (eM - out[c].black) / nM | |
| guard white.isFinite else { continue } | |
| out[c].white = min(max(white, out[c].black + AutoLevels.minimumSpan), 1) | |
| let gamma = log(tauM) / log(nM) | |
| guard gamma.isFinite, gamma > 0 else { continue } | |
| out[c].mid = min(max(pow(0.5, 1 / gamma), Levels.midRange.lowerBound), | |
| Levels.midRange.upperBound) | |
| } | |
| return out | |
| } | |
| } | |
| /// Both ends on anchors, gamma untouched: a linear solve of black and white. | |
| static func endsAligned(_ low: Anchor, _ high: Anchor) | |
| -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let rawL = target(low, hist, out), let rawH = target(high, hist, out) | |
| else { return nil } | |
| guard rawH > rawL, rawL > 0, rawH < 1.5 else { return out } | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let eL = low.read(counts[c]), let eH = high.read(counts[c]), eH > eL | |
| else { continue } | |
| let span = (eH - eL) / (rawH - rawL) | |
| guard span >= AutoLevels.minimumSpan, span.isFinite else { continue } | |
| let black = eL - rawL * span | |
| out[c].black = min(max(black, 0), 1 - AutoLevels.minimumSpan) | |
| out[c].white = min(max(out[c].black + span, out[c].black + AutoLevels.minimumSpan), 1) | |
| } | |
| return out | |
| } | |
| } | |
| /// Three anchors through black, white and gamma: ends solved linearly at a swept gamma, the | |
| /// middle landing closed by bisection. | |
| static func tripleAligned(_ low: Anchor, _ mid: Anchor, _ high: Anchor) | |
| -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t), | |
| let rawL = target(low, hist, out), let rawM = target(mid, hist, out), | |
| let rawH = target(high, hist, out) else { return nil } | |
| guard rawH > rawM, rawM > rawL, rawL > 0, rawH < 1.5 else { return out } | |
| let tauL = min(max(rawL, 0.02), 0.98) | |
| let tauM = min(max(rawM, 0.02), 0.98) | |
| let tauH = min(max(rawH, 0.02), 0.98) | |
| let counts = channelCounts(hist) | |
| for c in 0..<3 where c != reference { | |
| guard let eL = low.read(counts[c]), let eM = mid.read(counts[c]), | |
| let eH = high.read(counts[c]), eH > eM, eM > eL else { continue } | |
| /// The middle landing minus its target at one gamma, ends solved for that gamma. | |
| func residual(_ gamma: Float) -> Float? { | |
| let nL = pow(tauL, 1 / gamma), nH = pow(tauH, 1 / gamma) | |
| guard nH > nL else { return nil } | |
| let span = (eH - eL) / (nH - nL) | |
| let black = eL - nL * span | |
| guard span >= AutoLevels.minimumSpan else { return nil } | |
| let n = (eM - black) / span | |
| guard n > 0 else { return nil } | |
| return pow(n, gamma) - tauM | |
| } | |
| let gammas = stride(from: Float(0.37), through: 4.2, by: 0.01) | |
| var best: (gamma: Float, r: Float)? | |
| for g in gammas { | |
| guard let r = residual(g) else { continue } | |
| if best == nil || abs(r) < abs(best!.r) { best = (g, r) } | |
| } | |
| guard let solved = best, abs(solved.r) < 0.02 else { continue } | |
| let nL = pow(tauL, 1 / solved.gamma), nH = pow(tauH, 1 / solved.gamma) | |
| let span = (eH - eL) / (nH - nL) | |
| let black = eL - nL * span | |
| out[c].black = min(max(black, 0), 1 - AutoLevels.minimumSpan) | |
| out[c].white = min(max(black + span, out[c].black + AutoLevels.minimumSpan), 1) | |
| out[c].mid = min(max(pow(0.5, 1 / solved.gamma), Levels.midRange.lowerBound), | |
| Levels.midRange.upperBound) | |
| } | |
| return out | |
| } | |
| } | |
| /// One added control point solved so its own quantile renders on green's, the ask clamped into | |
| /// the handle's range as a button would clamp it. The Bernstein basis is the window's own. | |
| static func quarterAligned(low: Bool) -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard var out = classicEnds(hist, t) else { return nil } | |
| let q: Float = low ? 0.25 : 0.75 | |
| let counts = channelCounts(hist) | |
| func landed(_ c: Int) -> Float? { | |
| guard let bin = AutoLevels.quantile(of: counts[c], at: q) else { return nil } | |
| let e = bin / Float(counts[c].count - 1) | |
| let span = max(out[c].white - out[c].black, 1e-4) | |
| return (e - out[c].black) / span | |
| } | |
| guard let aim = landed(reference), aim > 0.02, aim < 0.98 else { return out } | |
| for c in 0..<3 where c != reference { | |
| guard let t0 = landed(c), t0 > 0.02, t0 < 0.98 else { continue } | |
| let u = t0, v = 1 - u | |
| let basis = low ? 4 * u * v * v * v : 4 * u * u * u * v | |
| guard basis > 1e-4 else { continue } | |
| let delta = (aim - t0) / basis | |
| if low { | |
| out[c].shadows = min(max(0.25 - delta, Levels.shadowRange.lowerBound), | |
| Levels.shadowRange.upperBound) | |
| } else { | |
| out[c].highlights = min(max(0.75 - delta, Levels.highlightRange.lowerBound), | |
| Levels.highlightRange.upperBound) | |
| } | |
| } | |
| return out | |
| } | |
| } | |
| /// Both added points solved jointly so the tenth and ninetieth render on green's — the exact | |
| /// resolution the five-point residual check runs, here scored as a candidate mode. | |
| static func steered1090(_ hist: Histogram, _ t: Float) -> [AutoLevels.Suggestion]? { | |
| guard var out = classicEnds(hist, t) else { return nil } | |
| let counts = channelCounts(hist) | |
| func landed(_ c: Int, _ q: Float) -> Float? { | |
| guard let bin = AutoLevels.quantile(of: counts[c], at: q) else { return nil } | |
| let e = bin / Float(counts[c].count - 1) | |
| let span = max(out[c].white - out[c].black, 1e-4) | |
| return (e - out[c].black) / span | |
| } | |
| guard let aimL = landed(reference, 0.1), let aimH = landed(reference, 0.9) | |
| else { return out } | |
| func basis(_ t: Float) -> (low: Float, high: Float) { | |
| let u = min(max(t, 0), 1), v = 1 - u | |
| return (4 * u * v * v * v, 4 * u * u * u * v) | |
| } | |
| for c in 0..<3 where c != reference { | |
| guard let tL = landed(c, 0.1), let tH = landed(c, 0.9) else { continue } | |
| let bl = basis(tL), bh = basis(tH) | |
| let det = bl.low * bh.high - bl.high * bh.low | |
| guard abs(det) > 1e-6 else { continue } | |
| let wl = aimL - tL, wh = aimH - tH | |
| let s = (wl * bh.high - bl.high * wh) / det | |
| let h = (bl.low * wh - wl * bh.low) / det | |
| out[c].shadows = min(max(0.25 - s, Levels.shadowRange.lowerBound), | |
| Levels.shadowRange.upperBound) | |
| out[c].highlights = min(max(0.75 - h, Levels.highlightRange.lowerBound), | |
| Levels.highlightRange.upperBound) | |
| } | |
| return out | |
| } | |
| /// The match placement with one knob moved, for the ablation table: criterion, floor, | |
| /// tolerance, zone count and the darkest-peak black each priced apart. | |
| static func matchVariant(floor: Int = AutoLevels.matchZoneFloor, | |
| tolerance: Float = AutoLevels.matchTolerance, | |
| criterion: AutoLevels.MatchCriterion | |
| = AutoLevels.retainedMatchCriterion, | |
| multiZone: Bool = true, peakBlacks: Bool = false) | |
| -> (Histogram, Float) -> [AutoLevels.Suggestion]? { | |
| { hist, t in | |
| guard let ends = classicEnds(hist, t) else { return nil } | |
| return AutoLevels.matchSolved(channelCounts(hist), ends: ends, floor: floor, | |
| tolerance: tolerance, criterion: criterion, | |
| multiZone: multiZone, peakBlacks: peakBlacks).points | |
| } | |
| } | |
| /// Every arm on trial: the shipped methods through their real path first, so the table reads | |
| /// against what a button does, then the shapes still on the bench. | |
| static var arms: [Arm] { | |
| let shipped = AutoLevels.Method.allCases.map { m in | |
| Arm(name: m.label) { h, t in AutoLevels.placement(h, threshold: t, method: m) } | |
| } | |
| return shipped + [ | |
| // Shipped once, then retired at the till: kept as arms so what they were worth stays | |
| // measurable and nobody reinvents either direction from scratch. | |
| Arm(name: "Peak", place: gammaAligned(.peak(retainedPeak))), | |
| Arm(name: "Match", place: matchVariant()), | |
| Arm(name: "Peak/raw", place: gammaAligned(.peak(.argmax))), | |
| Arm(name: "Peak/com", place: gammaAligned(.peak(.com33))), | |
| Arm(name: "Peak/half", place: gammaAligned(.peak(.half33))), | |
| Arm(name: "GammaQ25", place: gammaAligned(.quantile(0.25))), | |
| Arm(name: "GammaQ75", place: gammaAligned(.quantile(0.75))), | |
| Arm(name: "BlackQ10", place: blackAligned(.quantile(0.10))), | |
| Arm(name: "BlackQ02", place: blackAligned(.quantile(0.02))), | |
| Arm(name: "Mid+High", place: gammaWhiteAligned(.quantile(0.5), .quantile(0.9))), | |
| Arm(name: "Peak+High", place: gammaWhiteAligned(.peak(retainedPeak), .quantile(0.9))), | |
| Arm(name: "Ends1090", place: endsAligned(.quantile(0.10), .quantile(0.90))), | |
| Arm(name: "Blk+Mids", place: { h, t in | |
| guard var out = blackAligned(.quantile(0.10))(h, t) else { return nil } | |
| // Gamma re-read over the moved window, so the two anchors hold together. | |
| let counts = channelCounts(h) | |
| guard let ends = classicEnds(h, t), | |
| let raw = target(.quantile(0.5), h, ends), raw > 0.02, raw < 0.98 | |
| else { return out } | |
| for c in 0..<3 where c != reference { | |
| guard let e = Anchor.quantile(0.5).read(counts[c]) else { continue } | |
| let span = max(out[c].white - out[c].black, 1e-4) | |
| let n = min(max((e - out[c].black) / span, 0.02), 0.98) | |
| let gamma = log(min(max(raw, 0.02), 0.98)) / log(n) | |
| guard gamma.isFinite, gamma > 0 else { continue } | |
| out[c].mid = min(max(pow(0.5, 1 / gamma), Levels.midRange.lowerBound), | |
| Levels.midRange.upperBound) | |
| } | |
| return out | |
| }), | |
| Arm(name: "Triple", place: tripleAligned(.quantile(0.1), .quantile(0.5), | |
| .quantile(0.9))), | |
| Arm(name: "Peak*2", place: gammaAligned(.peak(retainedPeak)), | |
| step1: .peak(retainedPeak)), | |
| Arm(name: "ShadPt25", place: quarterAligned(low: true)), | |
| Arm(name: "HighPt75", place: quarterAligned(low: false)), | |
| Arm(name: "Steer1090", place: steered1090), | |
| // The match family's ablations: each knob priced apart, and the criterion rivals — | |
| // crossings in three readings — beside the retained covered-mass formula. | |
| Arm(name: "Match1Z", place: matchVariant(multiZone: false)), | |
| Arm(name: "MatchXn", place: matchVariant(criterion: .crossings, multiZone: false)), | |
| Arm(name: "MatchXd", place: matchVariant(criterion: .crossingDensity, | |
| multiZone: false)), | |
| Arm(name: "MatchXm", place: matchVariant(criterion: .agreedCrossings, | |
| multiZone: false)), | |
| Arm(name: "MatchFree", place: matchVariant(floor: 2)), | |
| Arm(name: "MatchMinE", place: matchVariant(floor: 2, criterion: .leastError, | |
| multiZone: false)), | |
| Arm(name: "MatchPB", place: matchVariant(peakBlacks: true)), | |
| Arm(name: "MatchE1", place: matchVariant(tolerance: 0.01)), | |
| Arm(name: "MatchE4", place: matchVariant(tolerance: 0.04)), | |
| Arm(name: "MatchF8", place: matchVariant(floor: 8)), | |
| Arm(name: "MatchF16", place: matchVariant(floor: 16)), | |
| ] | |
| } | |
| // MARK: - The measurement | |
| /// Renders a density through one window, the kernel's five-point stage 7 replicated, clipped | |
| /// to the output. | |
| static func render(_ density: Float, _ levels: Levels) -> Float { | |
| min(max(DensityMigration.applyWindow(density, levels), 0), 1) | |
| } | |
| /// The rendered quantiles as one row per channel, aligned so a column is one quantile of the | |
| /// three channels — the shape the luma/chroma split reads. `nil` when a channel has no data. | |
| static func renderedGrid(_ hist: Histogram, _ levels: [Levels], | |
| mode: ConversionMode) -> [[Float]]? { | |
| let counts = channelCounts(hist) | |
| var out: [[Float]] = [] | |
| for c in 0..<3 { | |
| var row: [Float] = [] | |
| for q in scoredQuantiles { | |
| guard let bin = AutoLevels.quantile(of: counts[c], at: q) else { return nil } | |
| let d = Graduation.value(atFraction: bin / Float(counts[c].count - 1), in: mode) | |
| row.append(render(d, levels[c])) | |
| } | |
| out.append(row) | |
| } | |
| return out | |
| } | |
| // MARK: - The score split and the produced chroma | |
| /// The score split along the chain's own `Y + chroma` axis: per quantile, the part of the gap | |
| /// the three channels share (a clarity error) against the part that separates them (a tint one). | |
| static func splitGaps(arm: [[Float]], hand: [[Float]]) -> (luma: Float, chroma: Float)? { | |
| guard arm.count == 3, hand.count == 3, !arm[0].isEmpty, | |
| arm.allSatisfy({ $0.count == arm[0].count }), | |
| hand.allSatisfy({ $0.count == arm[0].count }) else { return nil } | |
| let w = Pipeline.lumaWeights | |
| var luma: Float = 0 | |
| var chroma: Float = 0 | |
| for q in arm[0].indices { | |
| let d = SIMD3(arm[0][q], arm[1][q], arm[2][q]) | |
| - SIMD3(hand[0][q], hand[1][q], hand[2][q]) | |
| let y = (d * w).sum() | |
| luma += abs(y) | |
| let c = d - SIMD3(repeating: y) | |
| chroma += (abs(c.x) + abs(c.y) + abs(c.z)) / 3 | |
| } | |
| let n = Float(arm[0].count) | |
| return (luma / n, chroma / n) | |
| } | |
| /// Per-pixel chroma of a finished render — the saturation an arm actually produces, not its | |
| /// distance to anything: mean and ninetieth percentile of `mean |rgb − Y|` over the pixels. | |
| static func chromaStats(_ pixels: [SIMD3<Float>]) -> (mean: Float, p90: Float)? { | |
| guard !pixels.isEmpty else { return nil } | |
| let w = Pipeline.lumaWeights | |
| var magnitudes = pixels.map { p -> Float in | |
| let c = p - SIMD3(repeating: (p * w).sum()) | |
| return (abs(c.x) + abs(c.y) + abs(c.z)) / 3 | |
| } | |
| let mean = magnitudes.reduce(0, +) / Float(magnitudes.count) | |
| magnitudes.sort() | |
| return (mean, magnitudes[Int(Float(magnitudes.count - 1) * 0.9)]) | |
| } | |
| /// One context for every bitmap read of a run; creating one per arm costs seconds for nothing. | |
| static let readContext = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| /// Saturation is judged where it is looked at, so every produced-chroma read shares one | |
| /// display encoding rather than the linear working space, which starves the shadows. | |
| static let displaySpace = CGColorSpace(name: CGColorSpace.sRGB)! | |
| /// A render's pixels in a display space, row order irrelevant to every reader. | |
| static func displayPixels(_ image: CIImage, space: CGColorSpace) -> [SIMD3<Float>]? { | |
| let w = Int(image.extent.width), h = Int(image.extent.height) | |
| guard w > 1, h > 1 else { return nil } | |
| var px = [Float](repeating: 0, count: w * h * 4) | |
| px.withUnsafeMutableBytes { p in | |
| readContext.render(image, toBitmap: p.baseAddress!, rowBytes: w * 16, | |
| bounds: CGRect(x: image.extent.origin.x, y: image.extent.origin.y, | |
| width: CGFloat(w), height: CGFloat(h)), | |
| format: .RGBAf, colorSpace: space) | |
| } | |
| var out = [SIMD3<Float>]() | |
| out.reserveCapacity(w * h) | |
| for i in stride(from: 0, to: px.count, by: 4) { | |
| out.append(SIMD3(px[i], px[i + 1], px[i + 2])) | |
| } | |
| return out | |
| } | |
| /// A hand grade's window read back as window fractions, the frame a mode's placement lives | |
| /// in, so the two compare handle by handle. | |
| static func handSuggestion(_ levels: Levels, mode: ConversionMode) -> AutoLevels.Suggestion { | |
| AutoLevels.Suggestion(black: Graduation.fraction(of: levels.black, in: mode), | |
| white: Graduation.fraction(of: levels.white, in: mode), | |
| mid: levels.mid, shadows: levels.shadows, | |
| highlights: levels.highlights) | |
| } | |
| /// Step 1 anchored on a statistic other than the median — the peak variant's own recentring. | |
| static func recentredOn(_ anchor: Anchor, _ hist: Histogram, mode: ConversionMode, | |
| threshold: Float) -> SIMD3<Float>? { | |
| guard !hist.isEmpty else { return nil } | |
| let counts = channelCounts(hist) | |
| guard let a = anchor.read(counts[reference]) else { return nil } | |
| var out = SIMD3<Float>.zero | |
| for c in 0..<3 where c != reference { | |
| guard let e = anchor.read(counts[c]) else { continue } | |
| var travel = a - e | |
| if let ends = AutoLevels.suggest(counts: counts[c], threshold: threshold) { | |
| travel = min(max(travel, -ends.black), 1 - ends.white) | |
| } | |
| let asked = AutoLevels.stops(forShift: travel, mode: mode) | |
| out[c] = min(max(asked, Gains.range.lowerBound), Gains.range.upperBound) | |
| } | |
| return out | |
| } | |
| static func run(sources: [URL]) -> Bool { | |
| let threshold = AutoLevels.defaultThreshold | |
| // Overriding the grid sizes the score's own drift, which is the bar a candidate's margin | |
| // must clear before a win is called. | |
| let side = ProcessInfo.processInfo.environment["ONI_LAB_SIDE"] | |
| .flatMap { Double($0) }.map { CGFloat($0) } ?? Negative.measureSide | |
| print("balance lab — \(Int(side)) px grid, threshold " | |
| + String(format: "%.3f %%", threshold * 100)) | |
| var scoreRows: [String] = [] | |
| var stabilityRows: [String] = [] | |
| for source in sources { | |
| guard let loaded = try? Sidecar.read(for: source) else { | |
| print("FAIL \(source.lastPathComponent): unreadable sidecar"); return false | |
| } | |
| let hand = loaded.settings | |
| var base = hand | |
| base.gains = Gains() | |
| base.levels = .neutral(for: hand.mode) | |
| let mode = hand.mode | |
| guard let decoded = RawDecode.linear(source, longestSide: side) else { | |
| print("FAIL \(source.lastPathComponent): undecodable"); return false | |
| } | |
| let fullWidth = RawDecode.pixelSize(of: source)?.width | |
| func measure(_ image: CIImage, _ stops: SIMD3<Float>) -> Histogram { | |
| var probe = base | |
| probe.gains.stops = stops | |
| return Pipeline.histogram(of: image, settings: probe, before: .levels, | |
| fullWidth: fullWidth) | |
| } | |
| let hist0 = measure(decoded, .zero) | |
| // A bench measuring through a stale kernel prints wrong numbers in silence, which is | |
| // worse than no bench. Every channel of the first reading is held against the shipped | |
| // estimator before anything is scored. | |
| for channel in 0..<3 where !agreesWithShipped(hist0.rgb.map { $0[channel] }) { | |
| print("FAIL \(source.lastPathComponent): the bench's peak disagrees with " | |
| + "`peakBin` — rebuild the metallib, or `peakWindow` has moved") | |
| return false | |
| } | |
| let handHist = measure(decoded, hand.gains.stops) | |
| let handLevels = [hand.levels.red, hand.levels.green, hand.levels.blue] | |
| guard let handGrid = renderedGrid(handHist, handLevels, mode: mode) else { | |
| print("FAIL \(source.lastPathComponent): the hand grade cannot be rendered") | |
| return false | |
| } | |
| let handRender = handGrid.flatMap { $0 } | |
| let graded = handLevels.contains { $0 != LevelsSet()[.red] } | |
| || hand.gains.stops != .zero | |
| // Peak stability: the same frame at three sizes, every definition, worst channel. | |
| var drifts: [String] = [] | |
| if let wide = RawDecode.linear(source, longestSide: AutoLevels.referenceSide), | |
| let narrow = RawDecode.linear(source, longestSide: 700) { | |
| let histWide = measure(wide, .zero) | |
| let histNarrow = measure(narrow, .zero) | |
| for def in PeakDef.allCases { | |
| var worstRef: Float = 0 | |
| var worstNarrow: Float = 0 | |
| for c in 0..<3 { | |
| let here = def.read(hist0.rgb.map { $0[c] }) | |
| let there = def.read(histWide.rgb.map { $0[c] }) | |
| let below = def.read(histNarrow.rgb.map { $0[c] }) | |
| guard let here, let there, let below else { worstRef = 9; continue } | |
| worstRef = max(worstRef, abs(here - there)) | |
| worstNarrow = max(worstNarrow, abs(here - below)) | |
| } | |
| drifts.append(String(format: "%@ %.4f/%.4f", def.rawValue, worstRef, | |
| worstNarrow)) | |
| stabilityRows.append(String(format: "STAB\t%@\t%@\t%.5f\t%.5f", | |
| source.lastPathComponent, def.rawValue, | |
| worstRef, worstNarrow)) | |
| } | |
| } | |
| print("\n\(source.lastPathComponent) — graded: \(graded ? "yes" : "no, rest")") | |
| print(" peak drift vs \(Int(AutoLevels.referenceSide))px / vs 700px: " | |
| + drifts.joined(separator: " · ")) | |
| // Shared step 1, then every arm on the histogram it leaves behind. | |
| guard let moved = AutoLevels.recentred(hist0, from: .zero, mode: mode, | |
| threshold: threshold) else { | |
| print("FAIL \(source.lastPathComponent): no recentring"); return false | |
| } | |
| let after = moved == .zero ? hist0 : measure(decoded, moved) | |
| // The signature a button leaves: step 1 is shared, so gains on the shared recentring | |
| // mark a balance that started from a button, and the nearest placement names it. | |
| let gainsOff = hand.gains.stops - moved | |
| let gainsGap = max(abs(gainsOff.x), abs(gainsOff.y), abs(gainsOff.z)) | |
| var nearest: (name: String, gap: Float)? | |
| for m in AutoLevels.Method.allCases { | |
| guard let ps = AutoLevels.placement(after, threshold: threshold, method: m) | |
| else { continue } | |
| var worstGap: Float = 0 | |
| for c in 0..<3 { | |
| let hs = handSuggestion(handLevels[c], mode: mode) | |
| worstGap = max(worstGap, abs(ps[c].black - hs.black), | |
| abs(ps[c].white - hs.white), abs(ps[c].mid - hs.mid), | |
| abs(ps[c].shadows - hs.shadows), | |
| abs(ps[c].highlights - hs.highlights)) | |
| } | |
| if nearest == nil || worstGap < nearest!.gap { nearest = (m.label, worstGap) } | |
| } | |
| print(String(format: " signature: gains %.3f stops off the shared step 1 · nearest " | |
| + "placement %@ at %.4f of window", gainsGap, | |
| nearest?.name ?? "none", nearest?.gap ?? -1)) | |
| scoreRows.append(String(format: "SIG\t%@\t%.4f\t%@\t%.4f", source.lastPathComponent, | |
| gainsGap, nearest?.name ?? "none", nearest?.gap ?? -1)) | |
| // The match anatomy: zones retained per channel, their covered mass, the union's | |
| // error, and whether the grid holds enough crossings for a rank criterion to read. | |
| if let fits = AutoLevels.matchFits(after, threshold: threshold) { | |
| var notes: [String] = [] | |
| for (c, fit) in fits.enumerated() { | |
| guard let fit else { continue } | |
| let spans = fit.zones.map { "\($0.lowerBound)-\($0.upperBound)" } | |
| .joined(separator: "+") | |
| let covered = fit.zones.reduce(0) { $0 + $1.count } | |
| notes.append(String(format: "ch%d [%@] %d/%d rms %.4f%@ ×%d", c, spans, | |
| covered, AutoLevels.matchGridCount, fit.rms, | |
| fit.met ? "" : " floor", fit.gridCrossings)) | |
| scoreRows.append(String(format: "MATCH\t%@\tch%d\t%@\t%d\t%.4f\t%@\t%d", | |
| source.lastPathComponent, c, spans, covered, | |
| fit.rms, fit.met ? "met" : "floor", | |
| fit.gridCrossings)) | |
| } | |
| print(" match zones: " + notes.joined(separator: " · ")) | |
| } | |
| // The degeneration probe, stated per file: the same criterion free of the floor, and | |
| // the fit-alone criterion — the first must keep wide zones, the second may not. | |
| if let ends = classicEnds(after, threshold) { | |
| for (name, floor2, crit) in [("free", 2, AutoLevels.retainedMatchCriterion), | |
| ("fitAlone", 2, .leastError)] { | |
| let probe = AutoLevels.matchSolved(channelCounts(after), ends: ends, | |
| floor: floor2, criterion: crit) | |
| let sizes = probe.fits.compactMap { $0 } | |
| .map { fit in fit.zones.map { "\($0.count)" }.joined(separator: "+") } | |
| print(" match \(name) zone sizes: " + sizes.joined(separator: " · ")) | |
| scoreRows.append("MFREE\t\(source.lastPathComponent)\t\(name)\t" | |
| + sizes.joined(separator: " · ")) | |
| } | |
| } | |
| let handSat = displayPixels(Pipeline.apply(decoded, settings: hand, | |
| fullWidth: fullWidth), | |
| space: displaySpace).flatMap(chromaStats) | |
| if let handSat { | |
| print(String(format: " hand render sat %.4f p90 %.4f", handSat.mean, | |
| handSat.p90)) | |
| scoreRows.append(String(format: "CHROMA\t%@\tHand\t%.4f\t%.4f", | |
| source.lastPathComponent, handSat.mean, handSat.p90)) | |
| } | |
| var proposals: [(name: String, stops: SIMD3<Float>, | |
| points: [AutoLevels.Suggestion])] = [] | |
| for arm in arms { | |
| var stops = moved | |
| var placedOn = after | |
| if let anchor = arm.step1 { | |
| guard let own = recentredOn(anchor, hist0, mode: mode, | |
| threshold: threshold) else { continue } | |
| stops = own | |
| placedOn = own == .zero ? hist0 : measure(decoded, own) | |
| } | |
| guard let suggestions = arm.place(placedOn, threshold) else { | |
| print(String(format: " %-9@ no placement", arm.name as NSString)); continue | |
| } | |
| proposals.append((arm.name, stops, suggestions)) | |
| let levels = suggestions.map { AutoLevels.placed($0, in: mode) } | |
| let armHist = measure(decoded, stops) | |
| guard let armGrid = renderedGrid(armHist, levels, mode: mode) else { | |
| print(String(format: " %-9@ no render", arm.name as NSString)); continue | |
| } | |
| let armRender = armGrid.flatMap { $0 } | |
| let gaps = zip(armRender, handRender).map { abs($0 - $1) } | |
| let mean = gaps.reduce(0, +) / Float(max(gaps.count, 1)) | |
| let worst = gaps.max() ?? 9 | |
| let split = splitGaps(arm: armGrid, hand: handGrid) | |
| let armSettings = AutoLevels.applied( | |
| AutoLevels.Balance(stops: stops, levels: suggestions, shift: .zero), to: hand) | |
| let sat = displayPixels(Pipeline.apply(decoded, settings: armSettings, | |
| fullWidth: fullWidth), | |
| space: displaySpace).flatMap(chromaStats) | |
| print(String(format: " %-9@ mean %.4f worst %.4f luma %.4f chroma %.4f " | |
| + "sat %.4f p90 %.4f", arm.name as NSString, mean, worst, | |
| split?.luma ?? -1, split?.chroma ?? -1, sat?.mean ?? -1, | |
| sat?.p90 ?? -1)) | |
| if ProcessInfo.processInfo.environment["ONI_LAB_DEBUG"] != nil { | |
| for c in 0..<3 { | |
| print(String(format: " ch%d b %.4f w %.4f m %.3f | hand b %.4f w " | |
| + "%.4f m %.3f", c, suggestions[c].black, | |
| suggestions[c].white, suggestions[c].mid, | |
| handLevels[c].black, handLevels[c].white, | |
| handLevels[c].mid)) | |
| } | |
| print(" arm render " + armRender.map { String(format: "%.3f", $0) } | |
| .joined(separator: " ")) | |
| print(" hand render " + handRender.map { String(format: "%.3f", $0) } | |
| .joined(separator: " ")) | |
| } | |
| scoreRows.append(String(format: "SCORE\t%@\t%@\t%.4f\t%.4f\t%.4f\t%.4f", | |
| source.lastPathComponent, arm.name, mean, worst, | |
| split?.luma ?? -1, split?.chroma ?? -1)) | |
| if let sat { | |
| scoreRows.append(String(format: "CHROMA\t%@\t%@\t%.4f\t%.4f", | |
| source.lastPathComponent, arm.name, sat.mean, | |
| sat.p90)) | |
| } | |
| } | |
| scoreRows += judged(source: source, decoded: decoded, fullWidth: fullWidth, | |
| hand: hand, proposals: proposals) | |
| } | |
| print("\n==== machine-readable ====") | |
| (stabilityRows + scoreRows).forEach { print($0) } | |
| return true | |
| } | |
| // MARK: - The render judge: a surviving export scores a mode where the settings are lost | |
| /// Exact quantiles of an image in a display space, per channel — sorted, never binned, so the | |
| /// two sides of a comparison cannot disagree by a counter's grid. | |
| static func displayQuantiles(_ image: CIImage, space: CGColorSpace) -> [[Float]]? { | |
| guard let pixels = displayPixels(image, space: space) else { return nil } | |
| return (0..<3).map { c in | |
| var values = pixels.map { $0[c] } | |
| values.sort() | |
| return scoredQuantiles.map { | |
| values[min(values.count - 1, max(0, Int(Float(values.count - 1) * $0)))] | |
| } | |
| } | |
| } | |
| /// Scores every proposal against a surviving export: both sides rendered to the export's own | |
| /// profile and read as quantiles, so a grade whose settings are lost still judges in the render. | |
| static func judged(source: URL, decoded: CIImage, fullWidth: CGFloat?, | |
| hand: PipelineSettings, | |
| proposals: [(name: String, stops: SIMD3<Float>, | |
| points: [AutoLevels.Suggestion])]) -> [String] { | |
| let stem = source.deletingPathExtension().lastPathComponent | |
| let folder = source.deletingLastPathComponent().appendingPathComponent("exp") | |
| guard let ref = ["tiff", "jpg", "jpeg"] | |
| .map({ folder.appendingPathComponent("\(stem).\($0)") }) | |
| .first(where: { FileManager.default.fileExists(atPath: $0.path) }) | |
| else { return [] } | |
| guard let cgSource = CGImageSourceCreateWithURL(ref as CFURL, nil), | |
| let cg = CGImageSourceCreateImageAtIndex(cgSource, 0, nil) else { | |
| print(" render judge: \(ref.lastPathComponent) unreadable") | |
| return [] | |
| } | |
| let space = cg.colorSpace ?? CGColorSpaceCreateDeviceRGB() | |
| print(" render judge — \(ref.lastPathComponent), " | |
| + (space.name.map { String($0 as NSString) } ?? "no profile")) | |
| /// One proposal's balance written over the sidecar's other settings — the assumption the | |
| /// judge rests on: nothing but the balance moved between the export and now. | |
| func settings(_ stops: SIMD3<Float>, _ points: [AutoLevels.Suggestion]) -> PipelineSettings { | |
| AutoLevels.applied(AutoLevels.Balance(stops: stops, levels: points, shift: .zero), | |
| to: hand) | |
| } | |
| let handRender = Pipeline.apply(decoded, settings: hand, fullWidth: fullWidth) | |
| guard let handQ = displayQuantiles(handRender, space: space) else { return [] } | |
| // The export is brought onto the render's own grid: quantile reads share their resolution | |
| // or the tails drift apart by resampling alone. | |
| let side = max(handRender.extent.width, handRender.extent.height) | |
| let refImage = CIImage(cgImage: cg) | |
| let scale = side / max(refImage.extent.width, refImage.extent.height) | |
| guard let refQ = displayQuantiles(refImage.applyingFilter("CILanczosScaleTransform", | |
| parameters: ["inputScale": scale]), | |
| space: space) else { return [] } | |
| // The protocol's own noise floor: the same settings rendered at full resolution and brought | |
| // back down the ref's path, against the measuring render. A gap under it means nothing. | |
| if let full = RawDecode.linear(source) { | |
| let fullRender = Pipeline.apply(full, settings: hand) | |
| let down = side / max(fullRender.extent.width, fullRender.extent.height) | |
| if let protoQ = displayQuantiles(fullRender.applyingFilter("CILanczosScaleTransform", | |
| parameters: ["inputScale": down]), | |
| space: space) { | |
| let gaps = zip(handQ.flatMap { $0 }, protoQ.flatMap { $0 }).map { abs($0 - $1) } | |
| print(String(format: " RJ protocol noise floor: mean %.4f worst %.4f", | |
| gaps.reduce(0, +) / Float(max(gaps.count, 1)), gaps.max() ?? 9)) | |
| } | |
| } | |
| var scored: [(String, [[Float]])] = [("Current", handQ)] | |
| for p in proposals { | |
| guard let q = displayQuantiles(Pipeline.apply(decoded, | |
| settings: settings(p.stops, p.points), | |
| fullWidth: fullWidth), | |
| space: space) else { continue } | |
| scored.append((p.name, q)) | |
| } | |
| var rows: [String] = [] | |
| for (name, q) in scored { | |
| let gaps = zip(q.flatMap { $0 }, refQ.flatMap { $0 }).map { abs($0 - $1) } | |
| let mean = gaps.reduce(0, +) / Float(max(gaps.count, 1)) | |
| let worst = gaps.max() ?? 9 | |
| print(String(format: " RJ %-9@ mean %.4f worst %.4f", name as NSString, mean, worst)) | |
| rows.append(String(format: "RJUDGE\t%@\t%@\t%.4f\t%.4f", source.lastPathComponent, | |
| name, mean, worst)) | |
| } | |
| return rows | |
| } | |
| // MARK: - The split's own controls | |
| /// Holds the luma/chroma split to its definition: exact decomposition, blindness to a pure | |
| /// luma shift, and a twin weighting that must misread — so the chain's weights carry the split. | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let w = Pipeline.lumaWeights | |
| let zeros = [[Float]](repeating: [Float](repeating: 0, count: 7), count: 3) | |
| // A gap shared by the three channels is clarity alone: the chroma part reads zero. | |
| let lift: Float = 0.2 | |
| let lifted = zeros.map { $0.map { _ in lift } } | |
| if let s = splitGaps(arm: lifted, hand: zeros) { | |
| report(abs(s.luma - lift) < 1e-5 && s.chroma < 1e-5, String(format: | |
| "a pure luma shift of %.2f reads luma %.5f, chroma %.5f", lift, s.luma, s.chroma)) | |
| } else { report(false, "the split refused a valid grid") } | |
| // A gap the weights cancel is tint alone — and the twin, adverse by construction: uniform | |
| // weights on that same gap misread it as clarity, so the weighting is what discriminates. | |
| let v = SIMD3<Float>(w.y, -w.x, 0) | |
| let tinted = (0..<3).map { c in (0..<7).map { _ in v[c] } } | |
| if let s = splitGaps(arm: tinted, hand: zeros) { | |
| report(s.luma < 1e-6 && s.chroma > 0.05, String(format: | |
| "a weight-cancelled tint reads luma %.6f, chroma %.4f", s.luma, s.chroma)) | |
| let uniform = abs((v.x + v.y + v.z) / 3) | |
| report(uniform > 0.1, String(format: | |
| "the twin: uniform weights would read %.4f of that tint as luma", uniform)) | |
| } else { report(false, "the split refused a valid grid") } | |
| // The decomposition is exact on any gap: luma·1 + chroma re-adds, weighted chroma is zero. | |
| let arm: [[Float]] = [[0.1, 0.9, 0.4, 0.55, 0.2, 0.7, 0.33], | |
| [0.3, 0.1, 0.8, 0.55, 0.9, 0.2, 0.66], | |
| [0.5, 0.5, 0.1, 0.95, 0.4, 0.6, 0.05]] | |
| var reconstructed = true | |
| var weighted: Float = 0 | |
| for q in 0..<7 { | |
| let d = SIMD3(arm[0][q], arm[1][q], arm[2][q]) | |
| let y = (d * w).sum() | |
| let c = d - SIMD3(repeating: y) | |
| weighted = max(weighted, abs((c * w).sum())) | |
| let back = c + SIMD3(repeating: y) | |
| reconstructed = reconstructed && abs(back.x - d.x) < 1e-6 | |
| && abs(back.y - d.y) < 1e-6 && abs(back.z - d.z) < 1e-6 | |
| } | |
| report(reconstructed && weighted < 1e-6, String(format: | |
| "luma + chroma re-adds to the gap, and the weighted chroma stays at %.1e", weighted)) | |
| // The produced-chroma metric: zero on grey, and linear in the tint at fixed luma. | |
| let greys = (0..<50).map { SIMD3<Float>(repeating: Float($0) / 50) } | |
| if let g = chromaStats(greys) { | |
| report(g.mean < 1e-6 && g.p90 < 1e-6, String(format: | |
| "a grey render carries %.1e of chroma", g.mean)) | |
| } else { report(false, "the metric refused a grey render") } | |
| let once = greys.map { $0 + 0.05 * v } | |
| let twice = greys.map { $0 + 0.10 * v } | |
| if let a = chromaStats(once), let b = chromaStats(twice), a.mean > 0 { | |
| report(abs(b.mean / a.mean - 2) < 1e-3, String(format: | |
| "doubling the tint moves the metric by ×%.4f", b.mean / a.mean)) | |
| } else { report(false, "the metric refused a tinted render") } | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| // MARK: - Retired from the shipped path, kept for measurement | |
| /// The estimators and the zone sweep the retired `Peak` and `Match` buttons ran on. No button | |
| /// reads them: they live here so the bench can still price those two directions. | |
| extension AutoLevels { | |
| /// How many bins the peak's neighbourhood sums. Wider drowns a narrow summit in its | |
| /// surroundings; narrower lets grain elect the winner. | |
| static let peakWindow = 33 | |
| /// The bin whose `peakWindow` neighbourhood holds the most mass, end bins excluded — they pile | |
| /// the window's overflow. A raw argmax drifts 0.011 of window across grids; this stays at 0.002. | |
| static func peakBin(of counts: [Float]) -> Float? { | |
| let bins = counts.count | |
| guard bins > 4 else { return nil } | |
| let interior = counts[1..<(bins - 1)] | |
| guard interior.contains(where: { $0 > 0 }) else { return nil } | |
| var prefix: [Float] = [0] | |
| prefix.reserveCapacity(interior.count + 1) | |
| for count in interior { prefix.append(prefix[prefix.count - 1] + count) } | |
| let half = peakWindow / 2 | |
| var best = 0 | |
| var bestMass = -Float.infinity | |
| for i in 0..<interior.count { | |
| let mass = prefix[min(i + half + 1, interior.count)] - prefix[max(i - half, 0)] | |
| if mass > bestMass { bestMass = mass; best = i } | |
| } | |
| return Float(best + 1) | |
| } | |
| // MARK: - Match: zones discovered where two channels' curves agree | |
| /// The share of the dominant summit's neighbourhood mass a darker summit must hold to count — | |
| /// under it a wisp of grain would become the black point. | |
| static let matchPeakShare: Float = 0.2 | |
| /// The lowest interior bin that is the maximum of its own `peakWindow` neighbourhood and | |
| /// carries `matchPeakShare` of the dominant summit's mass — the darkest summit, not the tallest. | |
| static func darkestPeakBin(of counts: [Float]) -> Float? { | |
| let bins = counts.count | |
| guard bins > 4 else { return nil } | |
| let interior = Array(counts[1..<(bins - 1)]) | |
| guard interior.contains(where: { $0 > 0 }) else { return nil } | |
| var prefix: [Float] = [0] | |
| prefix.reserveCapacity(interior.count + 1) | |
| for count in interior { prefix.append(prefix[prefix.count - 1] + count) } | |
| let half = peakWindow / 2 | |
| func mass(_ i: Int) -> Float { | |
| prefix[min(i + half + 1, interior.count)] - prefix[max(i - half, 0)] | |
| } | |
| let dominant = (0..<interior.count).map(mass).max() ?? 0 | |
| guard dominant > 0 else { return nil } | |
| for i in 0..<interior.count where mass(i) >= matchPeakShare * dominant { | |
| let m = mass(i) | |
| if (max(i - half, 0)...min(i + half, interior.count - 1)) | |
| .allSatisfy({ mass($0) <= m }) { return Float(i + 1) } | |
| } | |
| return nil | |
| } | |
| /// The zones' fixed frame: this many quantiles over the counted mass. Fixing the count is what | |
| /// keeps every zone figure — size, error, crossings — one meaning across measure grids. | |
| static let matchGridCount = 49 | |
| /// The mass the grid spans; the outer hundredths hold the ends' piles, not curve. | |
| static let matchQuantileSpan: ClosedRange<Float> = 0.02...0.98 | |
| /// The narrowest zone the sweep may retain, in grid steps — a quarter of the counted mass. | |
| /// A constant: a smaller portion always fits better, so this is what the criterion pushes on. | |
| static let matchZoneFloor = 12 | |
| /// The agreement bar, in render units: a zone is a portion whose fit holds under it. | |
| static let matchTolerance: Float = 0.02 | |
| /// How the sweep ranks candidate zones. One is retained; the rest stay callable so the choice | |
| /// stays a measurement. | |
| enum MatchCriterion: Equatable, Sendable { | |
| /// The widest portion whose fit holds under the tolerance. Degeneration loses by | |
| /// construction: the criterion maximises covered mass, never fit alone. | |
| case coveredMass | |
| /// The portion where the residual changes sign most — a rank statistic, scale-free. | |
| case crossings | |
| /// Sign changes per grid step: the density rather than the count. | |
| case crossingDensity | |
| /// Sign changes counted only between two samples both inside the tolerance. | |
| case agreedCrossings | |
| /// The best fit wherever it is — the degenerate probe a floorless sweep collapses to. | |
| case leastError | |
| } | |
| static let retainedMatchCriterion: MatchCriterion = .coveredMass | |
| /// One channel's fit: the zones retained and the window they solve, plus the figures the | |
| /// checks and the bench read back. | |
| struct MatchFit: Equatable, Sendable { | |
| var zones: [ClosedRange<Int>] | |
| var white: Float | |
| var shadows: Float | |
| var highlights: Float | |
| /// Error of the render against green's over the union of zones; `met` marks the tolerance | |
| /// branch — the floor fallback still answers, and says so here. | |
| var rms: Float | |
| var met: Bool | |
| /// Sign changes of the residual over the whole grid: where this is a handful, the | |
| /// crossing criteria have nothing left to count. | |
| var gridCrossings: Int | |
| } | |
| /// The share of a channel's mass at or under a window fraction, interpolated inside its bin. | |
| static func massBelow(_ counts: [Float], fraction: Float) -> Float { | |
| let total = counts.reduce(0, +) | |
| guard total > 0, fraction > 0 else { return 0 } | |
| let position = fraction * Float(counts.count - 1) | |
| let bin = min(Int(position.rounded(.down)), counts.count - 1) | |
| var running: Float = 0 | |
| for i in 0..<bin { running += counts[i] } | |
| running += counts[bin] * min(max(position - Float(bin), 0), 1) | |
| return running / total | |
| } | |
| /// Grid quantiles of the mass past `above`, so each curve is read from its own black: pinned | |
| /// summits leave unequal shares under them, and a global grid pairs across the blacks. | |
| static func matchGrid(_ counts: [Float], above: Float = 0) -> [Float]? { | |
| let scale = Float(counts.count - 1) | |
| var out: [Float] = [] | |
| out.reserveCapacity(matchGridCount) | |
| for i in 0..<matchGridCount { | |
| let g = matchQuantileSpan.lowerBound | |
| + (matchQuantileSpan.upperBound - matchQuantileSpan.lowerBound) | |
| * Float(i) / Float(matchGridCount - 1) | |
| guard let bin = quantile(of: counts, at: above + (1 - above) * g) else { return nil } | |
| out.append(bin / scale) | |
| } | |
| return out | |
| } | |
| /// The exhaustive sweep: a scale solved over the best portion of the grid, then further zones | |
| /// taken by the two added points while the union still holds under the tolerance. | |
| static func matchFit(x: [Float], y: [Float], black: Float, | |
| floor: Int = matchZoneFloor, | |
| tolerance: Float = matchTolerance, | |
| criterion: MatchCriterion = retainedMatchCriterion, | |
| multiZone: Bool = true) -> MatchFit? { | |
| let m = x.count | |
| guard m > 2, y.count == m, floor >= 2, floor <= m else { return nil } | |
| var sxx: [Float] = [0], sxy: [Float] = [0], syy: [Float] = [0] | |
| for i in 0..<m { | |
| sxx.append(sxx[i] + x[i] * x[i]) | |
| sxy.append(sxy[i] + x[i] * y[i]) | |
| syy.append(syy[i] + y[i] * y[i]) | |
| } | |
| /// The least-squares scale through the origin, and its residual, over one portion. | |
| func fitted(_ lo: Int, _ hi: Int) -> (scale: Float, rms: Float)? { | |
| let xx = sxx[hi + 1] - sxx[lo], xy = sxy[hi + 1] - sxy[lo] | |
| let yy = syy[hi + 1] - syy[lo] | |
| guard xx > 1e-8 else { return nil } | |
| let s = xy / xx | |
| guard s > 0, s.isFinite else { return nil } | |
| return (s, sqrt(max(yy - xy * xy / xx, 0) / Float(hi - lo + 1))) | |
| } | |
| func crossings(_ lo: Int, _ hi: Int, scale: Float, agreedOnly: Bool) -> Int { | |
| var count = 0 | |
| var previous: Float = 0 | |
| for i in lo...hi { | |
| let d = y[i] - scale * x[i] | |
| guard d != 0 else { continue } | |
| if previous != 0, (d < 0) != (previous < 0), | |
| !agreedOnly || (abs(d) <= tolerance && abs(previous) <= tolerance) { | |
| count += 1 | |
| } | |
| previous = d | |
| } | |
| return count | |
| } | |
| var best: (lo: Int, hi: Int, scale: Float, key: SIMD3<Float>)? | |
| for lo in 0...(m - floor) { | |
| for hi in (lo + floor - 1)..<m { | |
| guard let fit = fitted(lo, hi) else { continue } | |
| let size = Float(hi - lo + 1) | |
| let key: SIMD3<Float> | |
| switch criterion { | |
| case .coveredMass: | |
| let met: Float = fit.rms <= tolerance ? 1 : 0 | |
| key = SIMD3(met, met > 0 ? size : -fit.rms, met > 0 ? -fit.rms : size) | |
| case .crossings: | |
| key = SIMD3(Float(crossings(lo, hi, scale: fit.scale, agreedOnly: false)), | |
| -fit.rms, size) | |
| case .crossingDensity: | |
| key = SIMD3(Float(crossings(lo, hi, scale: fit.scale, agreedOnly: false)) | |
| / (size - 1), -fit.rms, size) | |
| case .agreedCrossings: | |
| key = SIMD3(Float(crossings(lo, hi, scale: fit.scale, agreedOnly: true)), | |
| -fit.rms, size) | |
| case .leastError: | |
| key = SIMD3(-fit.rms, size, 0) | |
| } | |
| func beats(_ a: SIMD3<Float>, _ b: SIMD3<Float>) -> Bool { | |
| a.x != b.x ? a.x > b.x : a.y != b.y ? a.y > b.y : a.z > b.z | |
| } | |
| if best == nil || beats(key, best!.key) { | |
| best = (lo, hi, fit.scale, key) | |
| } | |
| } | |
| } | |
| guard let zone = best else { return nil } | |
| // The white the scale means, held at the window's top like any button's ask; the scale is | |
| // re-read off the held white so the zones that follow judge what will really be written. | |
| let white = min(max(black + 1 / zone.scale, black + minimumSpan), 1) | |
| let scale = 1 / (white - black) | |
| /// The window's render replicated at rest gamma: the affine part clamped at zero, the two | |
| /// added ordinates as the kernel adds them. | |
| func residual(_ i: Int, _ deltas: SIMD2<Float>) -> Float { | |
| let t = max(scale * x[i], 0) | |
| let u = min(t, 1), v = 1 - u | |
| return y[i] - (t + deltas.x * 4 * u * v * v * v + deltas.y * 4 * u * u * u * v) | |
| } | |
| func rms(over indices: [Int], _ deltas: SIMD2<Float>) -> Float { | |
| guard !indices.isEmpty else { return 0 } | |
| let sse = indices.reduce(Float(0)) { | |
| let r = residual($1, deltas) | |
| return $0 + r * r | |
| } | |
| return sqrt(sse / Float(indices.count)) | |
| } | |
| var zones: [ClosedRange<Int>] = [zone.lo...zone.hi] | |
| var deltas = SIMD2<Float>.zero | |
| var unionRMS = rms(over: Array(zone.lo...zone.hi), .zero) | |
| // Further zones, greedily: the widest disjoint portion the two added points can bring — | |
| // union and all — under the tolerance. With one zone they stay at rest: nothing to fix. | |
| if multiZone { | |
| let travel = 0.25 - Levels.shadowRange.lowerBound | |
| while true { | |
| var accepted: (zone: ClosedRange<Int>, deltas: SIMD2<Float>, rms: Float)? | |
| for lo in 0...(m - floor) { | |
| inner: for hi in (lo + floor - 1)..<m { | |
| for held in zones where hi >= held.lowerBound && lo <= held.upperBound { | |
| continue inner | |
| } | |
| let union = (zones + [lo...hi]).flatMap { Array($0) } | |
| var b11: Float = 0, b13: Float = 0, b33: Float = 0 | |
| var r1: Float = 0, r3: Float = 0 | |
| for i in union { | |
| let t = max(scale * x[i], 0) | |
| let u = min(t, 1), v = 1 - u | |
| let f1 = 4 * u * v * v * v, f3 = 4 * u * u * u * v | |
| let r = y[i] - t | |
| b11 += f1 * f1; b13 += f1 * f3; b33 += f3 * f3 | |
| r1 += f1 * r; r3 += f3 * r | |
| } | |
| let det = b11 * b33 - b13 * b13 | |
| var solved = SIMD2<Float>.zero | |
| if abs(det) > 1e-8 { | |
| solved = SIMD2((r1 * b33 - b13 * r3) / det, | |
| (b11 * r3 - r1 * b13) / det) | |
| } else if max(b11, b33) > 1e-8 { | |
| solved = b11 > b33 ? SIMD2(r1 / b11, 0) : SIMD2(0, r3 / b33) | |
| } else { continue } | |
| solved = SIMD2(min(max(solved.x, -travel), travel), | |
| min(max(solved.y, -travel), travel)) | |
| let e = rms(over: union, solved) | |
| guard e <= tolerance else { continue } | |
| let size = hi - lo + 1 | |
| if accepted == nil || size > accepted!.zone.count | |
| || (size == accepted!.zone.count && e < accepted!.rms) { | |
| accepted = (lo...hi, solved, e) | |
| } | |
| } | |
| } | |
| guard let take = accepted else { break } | |
| zones.append(take.zone) | |
| deltas = take.deltas | |
| unionRMS = take.rms | |
| } | |
| zones.sort { $0.lowerBound < $1.lowerBound } | |
| } | |
| return MatchFit(zones: zones, white: white, | |
| shadows: min(max(0.25 - deltas.x, Levels.shadowRange.lowerBound), | |
| Levels.shadowRange.upperBound), | |
| highlights: min(max(0.75 - deltas.y, Levels.highlightRange.lowerBound), | |
| Levels.highlightRange.upperBound), | |
| rms: unionRMS, met: unionRMS <= tolerance, | |
| gridCrossings: crossings(0, m - 1, scale: scale, agreedOnly: false)) | |
| } | |
| /// Classic's blacks, green's white on its data edge, each other channel's top and added | |
| /// points solved over its zones. Green is untouched past its black, as everywhere. | |
| static func matchSolved(_ channels: [[Float]], ends: [Suggestion], | |
| floor: Int = matchZoneFloor, | |
| tolerance: Float = matchTolerance, | |
| criterion: MatchCriterion = retainedMatchCriterion, | |
| multiZone: Bool = true, | |
| peakBlacks: Bool = false) | |
| -> (points: [Suggestion], fits: [MatchFit?]) { | |
| var out = ends | |
| var fits: [MatchFit?] = [nil, nil, nil] | |
| let scale = Float(channels[reference].count - 1) | |
| if peakBlacks { | |
| for channel in 0..<3 { | |
| guard let peak = darkestPeakBin(of: channels[channel]) else { continue } | |
| out[channel].black = min(max(peak / scale, 0), | |
| out[channel].white - minimumSpan) | |
| } | |
| } | |
| let g = out[reference] | |
| guard let greenGrid = matchGrid(channels[reference], | |
| above: massBelow(channels[reference], | |
| fraction: g.black)) | |
| else { return (out, fits) } | |
| let span = max(g.white - g.black, 1e-4) | |
| let y = greenGrid.map { max(($0 - g.black) / span, 0) } | |
| for channel in 0..<3 where channel != reference { | |
| guard let grid = matchGrid(channels[channel], | |
| above: massBelow(channels[channel], | |
| fraction: out[channel].black)) | |
| else { continue } | |
| let x = grid.map { max($0 - out[channel].black, 0) } | |
| guard let fit = matchFit(x: x, y: y, black: out[channel].black, floor: floor, | |
| tolerance: tolerance, criterion: criterion, | |
| multiZone: multiZone) else { continue } | |
| out[channel].white = fit.white | |
| out[channel].shadows = fit.shadows | |
| out[channel].highlights = fit.highlights | |
| fits[channel] = fit | |
| } | |
| return (out, fits) | |
| } | |
| static func matchPlacement(_ channels: [[Float]], ends: [Suggestion]) -> [Suggestion] { | |
| matchSolved(channels, ends: ends).points | |
| } | |
| /// The fits behind a match placement, for the bench: zones, errors, crossings. | |
| static func matchFits(_ histogram: Histogram, threshold: Float) -> [MatchFit?]? { | |
| guard !histogram.isEmpty else { return nil } | |
| let channels = (0..<3).map { channel in histogram.rgb.map { $0[channel] } } | |
| var ends: [Suggestion] = [] | |
| for counts in channels { | |
| guard let e = suggest(counts: counts, threshold: threshold) else { return nil } | |
| ends.append(e) | |
| } | |
| return matchSolved(channels, ends: ends).fits | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Stage 3: chroma-only denoise, placed before the inversion so it acts on the scan noise before | |
| /// it is stretched. Guarantees a bit-identical luma, so the silver grain survives any dose. | |
| struct ChromaDenoise: Codable, Equatable, Hashable, Sendable { | |
| /// Blur radius in FULL-RESOLUTION pixels, so a slider reading 3 px puts 3 px in the export. | |
| /// Rests at zero: the stage tints edges on a quantised source, so it is dosed by hand. | |
| var radius: Float = 0 | |
| /// Dose, from 0 to 1. Rests at zero for the same reason as the radius. | |
| var force: Float = 0 | |
| /// The top clears the widest dose the eight reference films still pay for, 4.48 px. Past it at | |
| /// most 16 % of the denoising is left against up to 63 % of the smearing. | |
| static let radiusRange: ClosedRange<Float> = 0...6 | |
| /// Either slider at zero switches the stage off. | |
| var isNeutral: Bool { force <= 0 || radius <= 0 } | |
| /// The resting state is genuinely off, unlike `Sharpen.neutral`: a double click, the block's | |
| /// reset and a fresh import all land on the same nothing. | |
| static let neutral = ChromaDenoise() | |
| /// The pairs a sidecar carries when nobody ever touched the block, in the fraction of the | |
| /// largest side they were written in. Recognised exactly, so a hand-set dose keeps its value. | |
| static let formerRests: [(radius: Float, force: Float)] = [(0.0001, 0.33), (0.00133, 0.33)] | |
| /// A dose nobody chose stops being applied; anything else is a decision and survives untouched. | |
| /// Reads the stored fraction, hence the raw document, since the field now carries pixels. | |
| static func retired(radius: Float, force: Float) -> Bool { | |
| formerRests.contains { $0.radius == radius && $0.force == force } | |
| } | |
| /// The dose follows the scale between a reduced copy and the export, and neither the crop, the | |
| /// rotation nor the frame's shape: none of the three enters `scale`. | |
| func pixelRadius(scale: Double) -> Double { | |
| Double(radius) * scale | |
| } | |
| } | |
| extension Pipeline { | |
| /// Extracts the chroma via `chroma = rgb − luma(rgb)`, a linear transformation expressible | |
| /// directly as a `CIColorMatrix` (`I − 1·wᵀ`). | |
| private static func chromaOnly(_ image: CIImage) -> CIImage { | |
| let w = lumaWeights | |
| return image.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputRVector": CIVector(x: CGFloat(1 - w.x), y: CGFloat(-w.y), z: CGFloat(-w.z), w: 0), | |
| "inputGVector": CIVector(x: CGFloat(-w.x), y: CGFloat(1 - w.y), z: CGFloat(-w.z), w: 0), | |
| "inputBVector": CIVector(x: CGFloat(-w.x), y: CGFloat(-w.y), z: CGFloat(1 - w.z), w: 0), | |
| "inputAVector": CIVector(x: 0, y: 0, z: 0, w: 1), | |
| ]) | |
| } | |
| /// Stage 3, skipped entirely when the dose is zero. | |
| /// - Parameter scale: current side over full-resolution side, 1 on a full-resolution frame. | |
| static func applyChromaDenoise(_ image: CIImage, _ settings: ChromaDenoise, | |
| scale: Double) -> CIImage { | |
| guard !settings.isNeutral, let chromaKernel else { return image } | |
| let radius = settings.pixelRadius(scale: scale) | |
| guard radius > blurFloor else { return image } | |
| // `CIGaussianBlur` grows the extent and softens the edges: we bring it back to that of the | |
| // input, otherwise the image would gain a translucent edge strip at every application. | |
| let blurred = chromaOnly(image) | |
| .clampedToExtent() | |
| .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: radius]) | |
| .cropped(to: image.extent) | |
| return chromaKernel.apply(extent: image.extent, | |
| arguments: [image, blurred, settings.force]) ?? image | |
| } | |
| } | |
| extension ChromaDenoise { | |
| /// Where the stage stops denoising and starts smearing, measured on a real film at full | |
| /// resolution: the range's top is that turn, and this is what says the track is not wasted. | |
| static func selfCheck(source: URL) -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| guard Pipeline.chromaKernel != nil else { | |
| return (false, " FAIL chroma kernel not found") | |
| } | |
| guard let region = NeighbourhoodProbe.region(of: source) else { | |
| return (false, " FAIL \(source.lastPathComponent): no full-resolution region read") | |
| } | |
| let ctx = NeighbourhoodProbe.context | |
| let before = NeighbourhoodProbe.planes(ctx, region.image, region.side) | |
| // The luma is bit-identical through this stage, so a mask cut on its gradient names the | |
| // same pixels at every radius: flat where noise is all there is, edges where colour lives. | |
| let mask = NeighbourhoodProbe.split(before.luma, region.side) | |
| /// Chroma noise left in the flat half, and colour displaced at the edges, at one radius. | |
| func measure(_ radius: Float) -> (noise: Float, bleed: Float) { | |
| let dosed = ChromaDenoise(radius: radius, force: 1) | |
| let out = Pipeline.applyChromaDenoise(region.image, dosed, scale: 1) | |
| let after = NeighbourhoodProbe.planes(ctx, out, region.side) | |
| return (NeighbourhoodProbe.neighbourSpread(after.chroma, region.side, over: mask.flat), | |
| NeighbourhoodProbe.displacement(before.chroma, after.chroma, over: mask.edges)) | |
| } | |
| // Four times the track, so the question « does a wider radius still remove noise » is asked | |
| // well past any answer the slider can give. The range is read against this, never into it. | |
| let sweep = NeighbourhoodProbe.sweep(upTo: 48) | |
| let rest = measure(0) | |
| var noise: [Float] = [], bleed: [Float] = [] | |
| for radius in sweep { | |
| let point = measure(Float(radius)) | |
| noise.append(rest.noise - point.noise) | |
| bleed.append(point.bleed) | |
| } | |
| lines.append(" ---- \(source.lastPathComponent), " | |
| + "\(region.side) × \(region.side) px of the full frame at " | |
| + String(format: "%.0f px of long side", region.longSide)) | |
| lines.append(" ---- radius px noise removed colour displaced at edges") | |
| for (k, radius) in sweep.enumerated() { | |
| lines.append(String(format: " ---- %8.2f %13.6f %24.6f", | |
| radius, noise[k], bleed[k])) | |
| } | |
| // Where the stage stops paying for radius, read on the rate so no end of sweep divides it. | |
| let removed = NeighbourhoodProbe.exhaustion(sweep, noise) | |
| report(removed.reach > 0, String(format: | |
| "denoising is spent at %.2f px: a pixel of radius there removes a tenth of the noise " | |
| + "the best pixel of radius removed (%.2e per px at its peak)", | |
| removed.reach, removed.peak)) | |
| // The track has to reach that dose: short of it a film cannot be cleaned. | |
| let top = Double(radiusRange.upperBound) | |
| report(top >= removed.reach, String(format: | |
| "the track runs to %.0f px, so this film reaches its own dose and spends %.0f %% of " | |
| + "the travel getting there", top, 100 * removed.reach / top)) | |
| // What the stop leaves behind, over the same span: the asymmetry that makes it a stop and | |
| // not an amputation. Read as a share of each whole rise, never as a rate on a widening grid. | |
| let noiseLeft = NeighbourhoodProbe.beyond(sweep, noise, past: top) | |
| let smearLeft = NeighbourhoodProbe.beyond(sweep, bleed, past: top) | |
| report(noiseLeft < 0.2 && smearLeft >= noiseLeft, String(format: | |
| "and past that stop, out to %.0f px, only %.1f %% of this film's noise is left to " | |
| + "remove, against %.1f %% of its smear left to buy — the track holds the stage, and " | |
| + "going further is never a bargain", sweep[sweep.count - 1], | |
| noiseLeft * 100, smearLeft * 100)) | |
| // The twin, adverse by construction: the bottom of the track sits an order under every dose | |
| // measured above, so a stop there always leaves most of the noise out of reach. | |
| let atFloor = NeighbourhoodProbe.beyond(sweep, noise, past: Pipeline.previewVisibleRadius) | |
| report(atFloor > noiseLeft * 2, String(format: | |
| "the twin: stopping at %.4f px instead would leave %.1f %% of it unreachable against " | |
| + "the %.1f %% above, so that reading belongs to where the track ends, not to the sweep", | |
| Pipeline.previewVisibleRadius, atFloor * 100, noiseLeft * 100)) | |
| // The twin, adverse by construction: the fraction scale ran to 0.01 of the largest side, a | |
| // fixed multiple of this frame whatever the sweep measures. | |
| let former = 0.01 * region.longSide | |
| report(removed.reach / former < 0.1, String(format: | |
| "and the check discriminates: on the scale this replaces that dose sat at %.1f %% of a " | |
| + "track running to %.0f px, i.e. inside its first tenth", | |
| removed.reach / former * 100, former)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import Foundation | |
| /// One travel raising every channel to a power and undoing it on the luminance alone. Grey is the | |
| /// power's fixed point, so the three channels separate from one another and a neutral stays put. | |
| enum ColorDensity { | |
| /// Slider travel, bipolar. Zero raises to the first power, which is the identity. | |
| static let range: ClosedRange<Float> = -1...1 | |
| static let base: Float = 0 | |
| /// Power at full travel. Symmetric in the logarithm, since powers compose by multiplication: | |
| /// the two halves of the travel are exact inverses of one another. Set where ordinary colours | |
| /// first reach the top of the scale, so leaving it is a gesture rather than the resting state. | |
| static let fullPower: Float = 2 | |
| /// What the travel raises each channel to. | |
| static func factor(_ travel: Float) -> Float { pow(fullPower, travel) } | |
| } | |
| extension ColorDensity { | |
| /// Swift replica of the density block in `pipeline.metal`, on values that already carry the | |
| /// linked levels. Lets a check state the law without going through the whole GPU chain. | |
| static func rendered(_ rgb: SIMD3<Float>, travel: Float) -> SIMD3<Float> { | |
| let m = factor(travel) | |
| guard m != 1 else { return rgb } | |
| let p = SIMD3<Float>(pow(max(rgb.x, 0), m), pow(max(rgb.y, 0), m), pow(max(rgb.z, 0), m)) | |
| let y = (p * Pipeline.lumaWeights).sum() | |
| guard y > 1e-12 else { return rgb } | |
| return p * pow(y, (1 / m) - 1) | |
| } | |
| /// The three properties the operation rests on: a neutral is left exactly where it was, every | |
| /// channel ratio is raised to the power, and a colour gains on the grey it started level with. | |
| static func selfCheck() -> [(Bool, String)] { | |
| var out: [(Bool, String)] = [] | |
| let travel: Float = 1 | |
| // A neutral is the whole point: this must hold at any grey, not merely away from the ends. | |
| var worst: Float = 0 | |
| var worstAt: Float = 0 | |
| for step in 1...1000 { | |
| let g = Float(step) / 1000 | |
| let gap = rendered(SIMD3(repeating: g), travel: travel) - SIMD3(repeating: g) | |
| let drift = max(gap.max(), -gap.min()) / g | |
| if drift > worst { worst = drift; worstAt = g } | |
| } | |
| out.append((worst < 1e-4, String(format: | |
| "a neutral is left where it was at ×%.1f, worst relative drift %.2e at grey %.3f, over " | |
| + "1000 greys", fullPower, worst, worstAt))) | |
| // The law itself: the luminance step scales all three channels together, so it moves no | |
| // ratio. This is what "the channels separate" means, stated as an equality. | |
| let colour = SIMD3<Float>(0.55, 0.22, 0.16) | |
| let done = rendered(colour, travel: travel) | |
| let wanted = pow(colour.x / colour.z, factor(travel)) | |
| out.append((abs(done.x / done.z - wanted) < 1e-3 * wanted, String(format: | |
| "and a channel ratio is raised to the power exactly: %.4f against %.4f wanted", | |
| done.x / done.z, wanted))) | |
| // What the eye reads as the colour standing out, and it is not saturation: at equal | |
| // starting luminance a colour comes back brighter than the grey, by Jensen's inequality. | |
| let y0 = (colour * Pipeline.lumaWeights).sum() | |
| let grey = rendered(SIMD3(repeating: y0), travel: travel) | |
| let gain = ((done * Pipeline.lumaWeights).sum() / (grey * Pipeline.lumaWeights).sum()) - 1 | |
| out.append((gain > 0.05, String(format: | |
| "so a colour comes back %+.1f %% brighter than the grey it started level with", | |
| gain * 100))) | |
| // The twin: the other half must close that gap, or the line above would pass on anything | |
| // that merely brightens the picture. | |
| let back = rendered(colour, travel: -travel) | |
| let greyBack = rendered(SIMD3(repeating: y0), travel: -travel) | |
| let closed = ((back * Pipeline.lumaWeights).sum() | |
| / (greyBack * Pipeline.lumaWeights).sum()) - 1 | |
| out.append((closed < 0, String(format: | |
| "while the opposite travel closes it to %+.1f %%, so the check discriminates", | |
| closed * 100))) | |
| // What bounds the travel: ordinary colours must still fit under 1 at full travel, or the | |
| // range would rest on a clip. A uniform cube is not the measure — it is mostly primaries. | |
| let ordinary: [SIMD3<Float>] = [ | |
| SIMD3(0.45, 0.45, 0.45), SIMD3(0.62, 0.46, 0.38), SIMD3(0.55, 0.22, 0.16), | |
| SIMD3(0.24, 0.42, 0.18), SIMD3(0.30, 0.44, 0.68), SIMD3(0.66, 0.58, 0.30), | |
| SIMD3(0.82, 0.74, 0.66), SIMD3(0.14, 0.16, 0.20), SIMD3(0.71, 0.35, 0.28), | |
| SIMD3(0.38, 0.52, 0.55), SIMD3(0.90, 0.86, 0.80), SIMD3(0.20, 0.24, 0.16), | |
| ] | |
| let peak = ordinary.map { rendered($0, travel: travel).max() }.max() ?? 0 | |
| out.append((peak <= 1.1, String(format: | |
| "and %d ordinary colours still peak at %.3f at ×%.1f, so the range does not rest on a " | |
| + "clip", ordinary.count, peak, fullPower))) | |
| // The twin: a primary must leave, or the line above would pass on a travel doing nothing. | |
| let primary = rendered(SIMD3(0.95, 0.05, 0.05), travel: travel).max() | |
| out.append((primary > 1.5, String(format: | |
| "while a near-primary reaches %.3f, so the check discriminates", primary))) | |
| return out | |
| } | |
| } | |
| import Foundation | |
| /// The contrast slider's transfer function: a normalised logistic, and its exact inverse when the | |
| /// slider runs the other way. Monotone and endpoint-pinned by construction, so it cannot clip. | |
| enum Contrast { | |
| /// Slider travel, bipolar. Zero is the identity. | |
| static let range: ClosedRange<Float> = -1...1 | |
| static let base: Float = 0 | |
| /// Steepness at full travel. Set so the midtone slope reaches 3.80, the strongest of the three | |
| /// reference curves the family was fitted to. | |
| static let fullSteepness: Float = 15.2 | |
| /// Below this the logistic is the identity to within a table step, and the formula divides by a | |
| /// vanishing denominator. | |
| private static let dead: Float = 1e-4 | |
| /// The value the slider gives to `x`. Positive travel steepens the middle, negative flattens it | |
| /// by exactly the inverse curve, so the two halves undo one another. | |
| static func apply(_ x: Float, travel: Float) -> Float { | |
| let b = abs(travel) * fullSteepness | |
| guard b > dead else { return x } | |
| let low = logistic(0, b), high = logistic(1, b) | |
| if travel > 0 { | |
| return (logistic(x, b) - low) / (high - low) | |
| } | |
| // Inverse: solve the same expression for its argument. `g` stays clear of 0 and 1 because | |
| // `low` and `high` are strictly inside them, so the logarithm never diverges. | |
| let g = low + min(max(x, 0), 1) * (high - low) | |
| return min(max(0.5 - log(1 / g - 1) / b, 0), 1) | |
| } | |
| private static func logistic(_ x: Float, _ b: Float) -> Float { | |
| 1 / (1 + exp(-b * (x - 0.5))) | |
| } | |
| /// Midtone slope at a given travel, in closed form: 0.25b / tanh(b/4) for the steepening half | |
| /// and its reciprocal for the other. Lets a check state the strength without sampling. | |
| static func midSlope(at travel: Float) -> Float { | |
| let b = abs(travel) * fullSteepness | |
| guard b > dead else { return 1 } | |
| let steep = 0.25 * b / tanh(b / 4) | |
| return travel > 0 ? steep : 1 / steep | |
| } | |
| } | |
| extension Contrast { | |
| /// What must hold at every travel: increasing, ends untouched, and the two halves exactly | |
| /// inverse. None of it is a bound to respect — the family cannot fold. | |
| static func selfCheck() -> [(Bool, String)] { | |
| var out: [(Bool, String)] = [] | |
| let steps = 512 | |
| var everMonotone = true, everPinned = true, worst: Float = .greatestFiniteMagnitude | |
| for tenth in -10...10 { | |
| let travel = Float(tenth) / 10 | |
| var previous = apply(0, travel: travel) | |
| everPinned = everPinned && abs(previous) < 1e-5 | |
| && abs(apply(1, travel: travel) - 1) < 1e-5 | |
| for i in 1...steps { | |
| let value = apply(Float(i) / Float(steps), travel: travel) | |
| worst = min(worst, (value - previous) * Float(steps)) | |
| everMonotone = everMonotone && value >= previous - 1e-6 | |
| previous = value | |
| } | |
| } | |
| out.append((everMonotone, String(format: | |
| "increasing at every travel, lowest slope anywhere %.4f", worst))) | |
| out.append((everPinned, "and the ends are never written, so this slider cannot clip")) | |
| // The two halves are one operation and its reverse, which is what makes the control | |
| // symmetric in the hand rather than by a second set of tuned constants. | |
| var drift: Float = 0 | |
| for i in 0...steps { | |
| let x = Float(i) / Float(steps) | |
| drift = max(drift, abs(apply(apply(x, travel: 0.7), travel: -0.7) - x)) | |
| } | |
| out.append((drift < 1e-4, String(format: | |
| "flattening undoes steepening exactly (worst round trip %.2e)", drift))) | |
| // Where the three hand-drawn reference curves fall on the travel, by their midtone slope. | |
| // Pinned because reproducing them is what chose this family over a spline. | |
| let references: [(String, Float, Float)] = | |
| [("soft", 0.3750, 1.60), ("intense", 0.5689, 2.22), ("extreme", 0.9982, 3.80)] | |
| var matched = true | |
| var read = "" | |
| for (name, travel, wanted) in references { | |
| let got = midSlope(at: travel) | |
| matched = matched && abs(got - wanted) < 0.01 | |
| read += String(format: "%@ %.0f%%->%.2f ", name, travel * 100, got) | |
| } | |
| out.append((matched, "the three reference curves land on their own slopes: " + read)) | |
| // A slope that only ever rose would satisfy the line above by accident. | |
| out.append((midSlope(at: -1) < 0.3 && midSlope(at: 0) == 1, | |
| String(format: "and the check discriminates: full flattening reads %.3f and " | |
| + "rest reads %.3f", midSlope(at: -1), midSlope(at: 0)))) | |
| return out | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// What the source **is**. Set by hand only — never guessed from pixel, EXIF, filename or history. | |
| /// A slide and a digital frame are both already positive, so those two share one state. | |
| enum ConversionMode: String, Codable, CaseIterable, Hashable, Identifiable, Sendable { | |
| /// A colour negative: the density inversion runs. | |
| case negative | |
| /// Anything already positive — a slide, a scan, a digital frame. The logarithm stays, only the | |
| /// inversion is given up. | |
| case positive | |
| /// A negative rendered grey. The channels still carry colour through the whole chain, so the | |
| /// per-channel curves act as coloured filters; only the last stage projects. | |
| case monochrome | |
| var id: String { rawValue } | |
| /// An unknown value reads as a negative rather than throwing: a sidecar written by a newer | |
| /// version must open read-only, and a failed enum would fail the whole settings decode. | |
| init(from decoder: Decoder) throws { | |
| let raw = try decoder.singleValueContainer().decode(String.self) | |
| self = ConversionMode(rawValue: raw) ?? .negative | |
| } | |
| var label: String { | |
| switch self { | |
| case .negative: "Negative" | |
| case .positive: "Positive" | |
| case .monochrome: "B&W" | |
| } | |
| } | |
| /// Stage 6's switch, as the kernel wants it. Last argument of `pipeline`. | |
| var invertFlag: Float { self == .positive ? 0 : 1 } | |
| /// True when the density inversion reverses the felt direction of the stage 4 gains: in | |
| /// negative a gain lowers the output, in positive it raises it, like stage 8. | |
| var invertsGainSense: Bool { self != .positive } | |
| /// The same question for the colour filters, and the answer is never: they ride on the tone | |
| /// curves, downstream of stage 6, where every mode is already positive. Measured, not assumed. | |
| var invertsColourSense: Bool { false } | |
| /// True where the chain ends on a projection to grey, which is also what empties the grade. | |
| var isMonochrome: Bool { self == .monochrome } | |
| } | |
| extension ConversionMode { | |
| /// What the third state has to keep saying: it inverts like a negative, it lands grey, and it | |
| /// empties the controls that only shape colour. | |
| @MainActor | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| report(ConversionMode.monochrome.invertFlag == 1 | |
| && ConversionMode.monochrome.invertsGainSense | |
| && Levels.restingMid(for: .monochrome) == Levels.restingMid(for: .negative), | |
| "monochrome inverts and rests exactly like a negative: it is one, printed grey") | |
| report(ConversionMode.positive.invertFlag == 0 && !ConversionMode.positive.invertsGainSense, | |
| "and the check discriminates: a positive still neither inverts nor flips the gains") | |
| // Frozen rather than derived: only a positive carries a rest of its own, and a change | |
| // reaching the other two has to be deliberate instead of arriving as a side effect. | |
| report(LevelsSet.neutral(for: .negative) == LevelsSet() | |
| && LevelsSet.neutral(for: .monochrome) == LevelsSet() | |
| && Levels.restingMid(for: .negative) == 0.67 | |
| && LevelsSet.resting(.red, for: .negative).black == 0, | |
| "the negative and monochrome rests are the stored defaults bit for bit: median 0.67 " | |
| + "on the linked set, black on zero on the three windows") | |
| report(LevelsSet.neutral(for: .positive) != LevelsSet(), | |
| String(format: "and the check discriminates: a positive rests on its own pair, " | |
| + "black %.5f and median %.4f", LevelsSet.resting(.red, for: .positive).black, | |
| LevelsSet.resting(.linked, for: .positive).mid)) | |
| // A handle a hand moved must not be dragged along by a change of mode, or switching to | |
| // compare and back would quietly undo a placement. | |
| var placed = LevelsSet.neutral(for: .negative) | |
| placed.red.setBlack(0.42) | |
| let moved = placed.carried(from: .negative, to: .positive) | |
| report(moved.red.black == 0.42 | |
| && moved.green.black == Levels.restingBlack(for: .positive) | |
| && moved.linked.mid == Levels.restingMid(for: .positive) | |
| && LevelsSet.neutral(for: .positive).carried(from: .positive, to: .negative) | |
| == LevelsSet.neutral(for: .negative), | |
| "changing mode carries the handles still at rest and leaves a placed black at 0.420, " | |
| + "and the round trip lands back on the departing mode's rest") | |
| // A colourful target, so landing grey is a real measurement rather than an identity. | |
| let side = 8 | |
| var pixels = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| pixels[i * 4] = 0.40; pixels[i * 4 + 1] = 0.12; pixels[i * 4 + 2] = 0.05 | |
| pixels[i * 4 + 3] = 1 | |
| } | |
| guard let target = pixels.withUnsafeBufferPointer({ buffer -> CIImage? in | |
| guard let base = buffer.baseAddress else { return nil } | |
| return CIImage(bitmapData: Data(bytes: base, count: pixels.count * 4), | |
| bytesPerRow: side * 16, size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| }) else { return (false, " FAIL colour target not built") } | |
| let context = CIContext(options: [.workingColorSpace: NSNull()]) | |
| /// The widest gap between two channels of the rendered pixel. | |
| func spread(_ mode: ConversionMode) -> Float { | |
| var settings = PipelineSettings() | |
| settings.mode = mode | |
| var px = [Float](repeating: 0, count: 4) | |
| context.render(Pipeline.apply(target, settings: settings), toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 4, y: 4, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return max(px[0], px[1], px[2]) - min(px[0], px[1], px[2]) | |
| } | |
| let grey = spread(.monochrome), coloured = spread(.negative) | |
| report(grey < 1e-6, String(format: | |
| "a monochrome render lands grey: %.2e between its widest and narrowest channel", grey)) | |
| report(coloured > 0.05, String(format: | |
| "and the check discriminates: the same frame as a negative keeps %.4f of spread", | |
| coloured)) | |
| // Leaving the frame never lands back on it, and never on a pane the mode withdrew. | |
| report(PanelTab.leaving(toward: .frame, in: .negative) == .color | |
| && PanelTab.leaving(toward: .grade, in: .negative) == .grade | |
| && PanelTab.leaving(toward: .grade, in: .monochrome) == .color, | |
| "leaving the frame lands on the last panel, on Color when there is none, and never " | |
| + "on a pane the mode withdrew") | |
| // The frame is a PANE and not a tab: the toolbar's key is the way in and the way out, so a | |
| // tab beside the others would be a second way in that never comes back. | |
| report(!PanelTab.tabs(for: .negative).contains(.frame) | |
| && PanelTab.panes(for: .negative).contains(.frame) | |
| && PanelTab.frame.available(in: .negative) == .frame, | |
| "the frame is a pane the app can be in, and no tab on the bar") | |
| // A block may state a width; it may not state one the pane cannot hold, or it overflows by | |
| // exactly the scroll bar and cuts it off. | |
| report(DS.Control.paneContentWidth <= DS.Control.panelWidth - 2 * DS.Space.md | |
| && DS.Control.paneContentWidth > 0, | |
| String(format: "a block's widest is %.0f pt, the pane's %.0f less the bar's %.0f", | |
| DS.Control.paneContentWidth, DS.Control.panelWidth - 2 * DS.Space.md, | |
| DS.Control.scrollerReserve)) | |
| report(!PanelTab.tabs(for: .monochrome).contains(.grade) | |
| && PanelTab.tabs(for: .negative).contains(.grade), | |
| "the grade pane leaves the bar in monochrome and stays everywhere else") | |
| report(PanelTab.grade.available(in: .monochrome) == .color | |
| && PanelTab.grade.available(in: .negative) == .grade, | |
| "and a selection standing on it lands on colour rather than on an empty pane") | |
| report(AutoLevels.Method.offered(in: .monochrome) == [.classic] | |
| && AutoLevels.Method.offered(in: .negative) == AutoLevels.Method.allCases, | |
| "the automatic balance keeps one button in monochrome and all of them elsewhere") | |
| report(AutoLevels.Method.classic.label(in: .monochrome) == "Equalise" | |
| && AutoLevels.Method.classic.label(in: .negative) == "Classic", | |
| "and it is named for what it does there: Equalise against Classic") | |
| // The preference outlives the button: a remembered name for a retired mode, or for one | |
| // this frame does not offer, must resolve to a key really on the plate. | |
| report(["peak", "match", ""].allSatisfy { | |
| AutoLevels.Method.stored($0, in: .negative) == .classic | |
| } && AutoLevels.Method.stored("mids", in: .monochrome) == .classic, | |
| "a remembered name for a retired or unoffered mode falls back on the one live key") | |
| report(AutoLevels.Method.stored("mids", in: .negative) == .mids, | |
| "and the check discriminates: a live name is kept rather than reset") | |
| // Tolerant decoding: a sidecar from a version that knows a fourth state must still open. | |
| let unknown = Data("\"sepia\"".utf8) | |
| let read = try? JSONDecoder().decode(ConversionMode.self, from: unknown) | |
| report(read == .negative, | |
| "an unknown mode reads as a negative instead of failing the whole settings decode") | |
| let known = try? JSONDecoder().decode(ConversionMode.self, from: Data("\"positive\"".utf8)) | |
| report(known == .positive, | |
| "and the check discriminates: a known one still reads as itself") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreGraphics | |
| import CoreImage | |
| import Foundation | |
| /// A dust or scratch correction: a painted stroke, and nothing else. The generated patch is a | |
| /// disposable cache keyed on this id, never stored here — the same split the sidecar and the | |
| /// thumbnail cache already draw between a photo's truth and a rebuildable bitmap. | |
| struct Correction: Equatable, Hashable, Sendable, Identifiable, Codable { | |
| var id: UUID | |
| /// The brush's path, as fractions of the CROPPED, ORIENTED frame (0...1, origin top-left, | |
| /// `onPick`'s own convention) — the frame a hand actually paints on. Spliced after geometry | |
| /// for exactly this reason: a stroke needs no transform of its own to land where it was drawn. | |
| /// A crop widened or rotated afterward is the one thing this does not survive. | |
| var stroke: [CGPoint] | |
| /// The brush width at the time of the stroke, in full-resolution pixels — the unit the two | |
| /// existing dosed radii (chroma denoise, sharpen) already use. | |
| var radius: CGFloat | |
| static let defaultRadius: CGFloat = 12 | |
| /// What the brush slider may dial. Matches the model's own tile (800 px): a radius near the | |
| /// top would leave the tile mostly stroke and no context to reconstruct from. | |
| static let radiusRange: ClosedRange<CGFloat> = 4...120 | |
| init(id: UUID = UUID(), stroke: [CGPoint] = [], radius: CGFloat = Correction.defaultRadius) { | |
| self.id = id | |
| self.stroke = stroke | |
| self.radius = radius | |
| } | |
| private enum CodingKeys: String, CodingKey { case id, stroke, radius } | |
| /// Decoded field by field, so a Correction can gain one the way every other stage already can: | |
| /// an old sidecar reads the new field neutral instead of failing the whole document. | |
| init(from decoder: Decoder) throws { | |
| let container = try decoder.container(keyedBy: CodingKeys.self) | |
| id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| stroke = try container.decodeIfPresent([CGPoint].self, forKey: .stroke) ?? [] | |
| radius = try container.decodeIfPresent(CGFloat.self, forKey: .radius) ?? Self.defaultRadius | |
| } | |
| } | |
| extension Correction { | |
| /// What the data model must guarantee before a brush stroke ever reaches the pipeline: the | |
| /// round trip, the tolerance to a field it does not carry yet, and one undo step per stroke. | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| let id = UUID(uuidString: "11111111-2222-3333-4444-555555555555") ?? UUID() | |
| let stroke = [CGPoint(x: 0.12, y: 0.34), CGPoint(x: 0.56, y: 0.78)] | |
| let full = Correction(id: id, stroke: stroke, radius: 9.5) | |
| guard let data = try? JSONEncoder().encode(full), | |
| let back = try? JSONDecoder().decode(Correction.self, from: data) else { | |
| return (false, " FAIL a correction does not round-trip through JSON") | |
| } | |
| report(back == full, "a correction round-trips exactly (id, stroke and radius)") | |
| // A field this version does not write yet: the decoder must not know its name, and the | |
| // reader must still take the neutral default rather than fail the document. | |
| guard var object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { | |
| return (false, " FAIL the encoded correction is not a JSON object") | |
| } | |
| object.removeValue(forKey: "radius") | |
| if let holed = try? JSONSerialization.data(withJSONObject: object), | |
| let read = try? JSONDecoder().decode(Correction.self, from: holed) { | |
| report(read.radius == Correction.defaultRadius && read.id == full.id | |
| && read.stroke == full.stroke, | |
| "a correction missing `radius` reads the default (\(Correction.defaultRadius) px), " | |
| + "its stroke and id intact") | |
| } else { | |
| report(false, "a correction missing `radius` should still decode") | |
| } | |
| // The twin: the same document with the field present carries what it says, so "the | |
| // default" above is a value read, not one the decoder always returns. | |
| report(full.radius == 9.5 && full.radius != Correction.defaultRadius, | |
| "and the check discriminates: a radius actually written reads back as written") | |
| // The whole point of a Correction: it survives inside PipelineSettings and the sidecar | |
| // that already tolerates every other stage's absent keys. | |
| var settings = PipelineSettings() | |
| settings.corrections = [full] | |
| guard let sidecarData = try? SettingsCodec.encode(settings), | |
| let decoded = try? SettingsCodec.decode(sidecarData) else { | |
| return (false, " FAIL a settings document carrying a correction does not encode or decode") | |
| } | |
| report(decoded.settings.corrections == [full], | |
| "a correction survives a full sidecar round trip") | |
| // A sidecar written before this field existed carries no `corrections` key at all. | |
| guard var bare = try? JSONSerialization.jsonObject(with: try SettingsCodec.encode(PipelineSettings())) | |
| as? [String: Any], var bareSettings = bare["settings"] as? [String: Any] else { | |
| return (false, " FAIL a neutral settings document is not readable as raw JSON") | |
| } | |
| bareSettings.removeValue(forKey: "corrections") | |
| bare["settings"] = bareSettings | |
| if let bareData = try? JSONSerialization.data(withJSONObject: bare), | |
| let read = try? SettingsCodec.decode(bareData) { | |
| report(read.settings.corrections.isEmpty, | |
| "a sidecar with no `corrections` key opens with none, not a failure") | |
| } else { | |
| report(false, "a sidecar missing `corrections` entirely should still decode") | |
| } | |
| // Never propagated: a paste must not stamp one photo's dust onto another's negative. | |
| report(Parameters.excluded.contains { $0.field == "corrections" }, | |
| "corrections are excluded from propagation, by name") | |
| // One stroke, drawn as several points arriving inside the coalescing window, is one undo | |
| // step — the same posture a slider drag already takes. | |
| var history = History() | |
| let now: TimeInterval = 1000 | |
| for count in 1...3 { | |
| var next = history.current | |
| next.corrections = [Correction(id: id, stroke: Array(stroke.prefix(count)), radius: 9.5)] | |
| history.record(next, at: now + Double(count) * 0.05) | |
| } | |
| report(history.entries.count == 2, | |
| "a stroke drawn as several points coalesces into one History step " | |
| + "(\(history.entries.count) entries)") | |
| let undone = history.undo() | |
| report(undone?.corrections.isEmpty == true, | |
| "undoing that step restores the photo with no corrections") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| // MARK: - The generated patch cache | |
| /// One file per correction, disposable and rebuildable from the `Correction` alone — the split | |
| /// `Thumbnails` already draws between a photo's truth and a rebuildable bitmap, applied to a patch | |
| /// instead of a whole frame. Keyed on the correction's OWN id, never a photo's fingerprint: a | |
| /// `Correction`'s id is already globally unique, and `Pipeline.framed` — which reads this cache on | |
| /// every render — has no reason to be handed a photo's identity just to look one file up. | |
| enum CorrectionCache { | |
| nonisolated static var directory: URL { | |
| AppFolders.caches.appendingPathComponent("Corrections", isDirectory: true) | |
| } | |
| nonisolated static func key(_ correctionID: UUID) -> String { correctionID.uuidString } | |
| nonisolated static func load(_ correctionID: UUID) -> Data? { | |
| try? Data(contentsOf: directory.appendingPathComponent(key(correctionID) + ".tiff")) | |
| } | |
| nonisolated static func persist(_ data: Data, correctionID: UUID) { | |
| try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | |
| try? data.write(to: directory.appendingPathComponent(key(correctionID) + ".tiff"), | |
| options: .atomic) | |
| } | |
| /// Removes exactly the named corrections' patches — called with a photo's own ids at deletion, | |
| /// which the caller already read off its sidecar before the photo left the library. | |
| nonisolated static func discard(_ correctionIDs: [UUID]) { | |
| for id in correctionIDs { | |
| try? FileManager.default.removeItem(at: directory.appendingPathComponent(key(id) + ".tiff")) | |
| } | |
| } | |
| /// Empties the whole cache at quit — a patch is cheap enough to regenerate that nothing durable | |
| /// needs to hold it. Never mid-session: a photo still open needs its cached patches. | |
| nonisolated static func purgeAll() { | |
| try? FileManager.default.removeItem(at: directory) | |
| } | |
| /// Cache files no photo's own corrections claim any more. The live set needs every reachable | |
| /// photo's actual correction ids, read off its sidecar — so, unlike `Thumbnails.deadFiles`, | |
| /// there is no fingerprint prefix left to reserve for an original a reading failed to obtain. | |
| /// A single unreachable original therefore withholds the WHOLE sweep, not just its own share: | |
| /// the safe direction, since a correction's cache carries nothing that names its photo back. | |
| nonisolated static func deadFiles(claimedBy rows: [LibraryIndex.Entry], | |
| members: [String]) -> [String] { | |
| guard Set(rows.map(\.path)).isSuperset(of: members) else { return [] } | |
| var live: Set<String> = [] | |
| for row in rows { | |
| guard FileManager.default.fileExists(atPath: row.path) else { return [] } | |
| let corrections = (try? Sidecar.read(for: URL(fileURLWithPath: row.path)))? | |
| .settings.corrections ?? [] | |
| for correction in corrections { live.insert(key(correction.id) + ".tiff") } | |
| } | |
| let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] | |
| return names.filter { !live.contains($0) } | |
| } | |
| /// Deletes one file named by `deadFiles`, so the pass around it can show its progress. | |
| nonisolated static func removeDead(_ name: String) { | |
| try? FileManager.default.removeItem(at: directory.appendingPathComponent(name)) | |
| } | |
| } | |
| extension CorrectionCache { | |
| /// That the sweep deletes only what it can prove unclaimed, and that a photo's own corrections | |
| /// go with it on deletion — the exact defect `Thumbnails` once shipped (#590), not repeated. | |
| nonisolated static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| guard let sandbox = AppFolders.sandbox("correction-sweep"), | |
| let cache = AppFolders.ensure(directory) else { | |
| return (false, " FAIL correction sweep check folders not creatable") | |
| } | |
| let manager = FileManager.default | |
| defer { try? manager.removeItem(at: sandbox) } | |
| let count = 128 * 1024 | |
| let here = sandbox.appendingPathComponent("ici.dng") | |
| let away = sandbox.appendingPathComponent("loin.dng") | |
| try? Data((0..<count).map { UInt8(($0 &* 7) % 251) }).write(to: here) | |
| try? Data((0..<count).map { UInt8(($0 &* 13) % 251) }).write(to: away) | |
| // `here` keeps one live correction; the one it once had and lost is what the sweep must | |
| // still catch. `away` keeps its own so an unreachable original withholds the sweep. | |
| let liveID = UUID(), goneID = UUID(), awayID = UUID() | |
| try? SettingsCodec.encode(PipelineSettings(corrections: [Correction(id: liveID)])) | |
| .write(to: Sidecar.url(for: here)) | |
| try? SettingsCodec.encode(PipelineSettings(corrections: [Correction(id: awayID)])) | |
| .write(to: Sidecar.url(for: away)) | |
| guard let hereMark = Relink.fingerprint(of: here), | |
| let awayMark = Relink.fingerprint(of: away) else { | |
| return (false, " FAIL sweep check originals not fingerprintable") | |
| } | |
| let liveName = key(liveID) + ".tiff" | |
| let staleName = key(goneID) + ".tiff" | |
| let awayName = key(awayID) + ".tiff" | |
| let orphanName = key(UUID()) + ".tiff" | |
| let written = [liveName, staleName, awayName, orphanName] | |
| for name in written { try? Data("patch".utf8).write(to: cache.appendingPathComponent(name)) } | |
| defer { for name in written { try? manager.removeItem(at: cache.appendingPathComponent(name)) } } | |
| let now = Date(timeIntervalSinceReferenceDate: 800_000_000) | |
| let rows = [ | |
| LibraryIndex.Entry(path: here.path, importedAt: now, capturedAt: now, fingerprint: hereMark), | |
| LibraryIndex.Entry(path: away.path, importedAt: now, capturedAt: now, fingerprint: awayMark), | |
| ] | |
| let members = [here.path, away.path] | |
| // Fully reachable: the sweep bites on exactly what it can prove unclaimed. | |
| let allReachable = Set(deadFiles(claimedBy: [rows[0]], members: [here.path])) | |
| report(allReachable.contains(staleName) && allReachable.contains(orphanName) | |
| && !allReachable.contains(liveName), | |
| "with every original reachable, the sweep takes a superseded correction and an " | |
| + "orphan, and leaves the one still on its photo") | |
| // An unplugged disk takes the sidecar with the original — now `away`'s corrections cannot | |
| // be read, and the whole sweep must withhold rather than guess. | |
| try? manager.removeItem(at: away) | |
| try? manager.removeItem(at: Sidecar.url(for: away)) | |
| let withUnreachable = deadFiles(claimedBy: rows, members: members) | |
| report(withUnreachable.isEmpty, | |
| "and a SINGLE unreachable original withholds the whole sweep — nothing is provably " | |
| + "unclaimed when one photo's corrections cannot be read") | |
| let partial = Set(deadFiles(claimedBy: [rows[0]], members: members)) | |
| report(partial.isEmpty, "an index that misses a member sweeps NOTHING at all, orphan included") | |
| // Deletion: a photo's own corrections go by id, whatever else the cache holds. | |
| discard([liveID, goneID]) | |
| let remaining = (try? manager.contentsOfDirectory(atPath: cache.path)) ?? [] | |
| report(!remaining.contains(liveName) && !remaining.contains(staleName), | |
| "discarding a photo's correction ids removes exactly those patches") | |
| report(remaining.contains(awayName) && remaining.contains(orphanName), | |
| "and leaves every other cached patch untouched") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| // MARK: - Rendering one correction | |
| enum CorrectionRenderer { | |
| /// The duplicated tile's own side, in full-resolution pixels — a leftover of the retired | |
| /// model's fixed input size, kept as the context window a stroke still gets clamped into. | |
| static let tileSide = 800 | |
| /// The square of context around a stroke, directly in Core Image's own coordinate space | |
| /// (bottom-left origin) — the same space `extent`, every mask and every patch already live in. | |
| static func contextRect(for correction: Correction, in extent: CGRect) -> CGRect { | |
| let side = CGFloat(tileSide) | |
| let width = min(side, extent.width), height = min(side, extent.height) | |
| guard let centre = centroid(of: correction, in: extent) else { | |
| return CGRect(x: extent.minX, y: extent.minY, width: width, height: height) | |
| } | |
| let x = min(max(centre.x - width / 2, extent.minX), max(extent.maxX - width, extent.minX)) | |
| let y = min(max(centre.y - height / 2, extent.minY), max(extent.maxY - height, extent.minY)) | |
| return CGRect(x: x, y: y, width: width, height: height) | |
| } | |
| /// `stroke`'s own midpoint in Core Image's space — top-left, y-down fractions (matching | |
| /// CropOverlay) to bottom-left, y-up pixels, the one flip every point in this file crosses once. | |
| private static func centroid(of correction: Correction, in extent: CGRect) -> CGPoint? { | |
| guard !correction.stroke.isEmpty else { return nil } | |
| let xs = correction.stroke.map { extent.minX + $0.x * extent.width } | |
| let ys = correction.stroke.map { extent.minY + (1 - $0.y) * extent.height } | |
| return CGPoint(x: ((xs.min() ?? 0) + (xs.max() ?? 0)) / 2, | |
| y: ((ys.min() ?? 0) + (ys.max() ?? 0)) / 2) | |
| } | |
| /// The stroke rasterised at `tileRect`'s own resolution and placed exactly there: white where | |
| /// a correction paints, black elsewhere — inpainting's standard convention, hole = white. | |
| /// `scale` converts `correction.radius`, always stored in full-resolution pixels, into | |
| /// `tileRect`'s own units — 1 on a full-resolution render, `apply`'s own reduced-preview ratio | |
| /// otherwise. Left at 1, a stroke drawn on a preview would keep its full-resolution width | |
| /// relative to a tile already shrunk, ballooning to cover far more than the brushed area. | |
| static func strokeMask(_ correction: Correction, imageExtent: CGRect, tileRect: CGRect, | |
| scale: CGFloat = 1) -> CIImage { | |
| let width = max(Int(tileRect.width.rounded()), 1) | |
| let height = max(Int(tileRect.height.rounded()), 1) | |
| let colorSpace = CGColorSpaceCreateDeviceGray() | |
| let blank = CIImage(color: .black).cropped(to: tileRect) | |
| guard let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, | |
| bytesPerRow: 0, space: colorSpace, | |
| bitmapInfo: CGImageAlphaInfo.none.rawValue) else { return blank } | |
| ctx.setFillColor(gray: 0, alpha: 1) | |
| ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) | |
| // Flips the CONTEXT to top-left, y-down — `correction.stroke`'s own convention — so the | |
| // path below is drawn in the same frame it was recorded in. A CGImage's rows stay stored | |
| // top-to-bottom regardless, so this affects only how the path is drawn, not how the | |
| // finished bitmap reads back. | |
| ctx.translateBy(x: 0, y: CGFloat(height)) | |
| ctx.scaleBy(x: 1, y: -1) | |
| // A stroke point, from a full-image top-left fraction to a pixel local to this tile: the | |
| // tile's own top-left corner, in that SAME top-left frame, is what `tileRect` is once its | |
| // bottom-left (Core Image) origin is flipped against the whole image's height. | |
| let tileTopLeftX = tileRect.minX - imageExtent.minX | |
| let tileTopLeftY = imageExtent.height - (tileRect.minY - imageExtent.minY) - tileRect.height | |
| func local(_ p: CGPoint) -> CGPoint { | |
| CGPoint(x: p.x * imageExtent.width - tileTopLeftX, y: p.y * imageExtent.height - tileTopLeftY) | |
| } | |
| let radius = correction.radius * scale | |
| ctx.setFillColor(gray: 1, alpha: 1) | |
| ctx.setStrokeColor(gray: 1, alpha: 1) | |
| ctx.setLineCap(.round) | |
| ctx.setLineJoin(.round) | |
| ctx.setLineWidth(radius * 2) | |
| if correction.stroke.count == 1, let point = correction.stroke.first { | |
| let c = local(point) | |
| ctx.fillEllipse(in: CGRect(x: c.x - radius, y: c.y - radius, | |
| width: radius * 2, height: radius * 2)) | |
| } else if correction.stroke.count > 1 { | |
| let path = CGMutablePath() | |
| path.addLines(between: correction.stroke.map(local)) | |
| ctx.addPath(path) | |
| ctx.strokePath() | |
| } | |
| guard let cgImage = ctx.makeImage() else { return blank } | |
| return CIImage(cgImage: cgImage).transformed(by: CGAffineTransform(translationX: tileRect.minX, | |
| y: tileRect.minY)) | |
| } | |
| /// The COMPOSITING mask: `strokeMask`'s hard edge, softened, so the seam between the duplicated | |
| /// patch and the original fades rather than cuts. | |
| static func blendMask(for correction: Correction, imageExtent: CGRect, tileRect: CGRect, | |
| scale: CGFloat = 1) -> CIImage { | |
| let hard = strokeMask(correction, imageExtent: imageExtent, tileRect: tileRect, scale: scale) | |
| let feather = max(correction.radius * scale * 0.2, 2) | |
| return hard.applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: feather]) | |
| .cropped(to: hard.extent) | |
| } | |
| /// `rect`, in Core Image's own space, expressed as the top-left, y-down FRACTION of `extent` | |
| /// `Correction.stroke` already uses — so the UI can draw it with no second frame of reference. | |
| static func fraction(of rect: CGRect, in extent: CGRect) -> CGRect { | |
| CGRect(x: (rect.minX - extent.minX) / extent.width, | |
| y: 1 - (rect.maxY - extent.minY) / extent.height, | |
| width: rect.width / extent.width, height: rect.height / extent.height) | |
| } | |
| private static func meanColor(of image: CIImage, in rect: CGRect, context: CIContext) -> SIMD3<Float>? { | |
| guard let averaged = CIFilter(name: "CIAreaAverage", parameters: [ | |
| kCIInputImageKey: image, kCIInputExtentKey: CIVector(cgRect: rect), | |
| ])?.outputImage else { return nil } | |
| var px = [Float](repeating: 0, count: 4) | |
| context.render(averaged, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBAf, colorSpace: nil) | |
| return SIMD3(px[0], px[1], px[2]) | |
| } | |
| /// The largest half-extent, per axis, that keeps `centre ± half` inside `extent` — the search | |
| /// kernel always samples symmetrically around a fixed centre (never repositioned, unlike | |
| /// `contextRect`'s tile), so shrinking is the only option near an edge: reading past `extent` | |
| /// samples fabricated content on the very side every candidate is compared against. | |
| private static func edgeClampedHalf(_ desired: CGSize, centre: CGPoint, extent: CGRect) -> CGSize { | |
| let maxHalfWidth = max(0, min(centre.x - extent.minX, extent.maxX - centre.x)) | |
| let maxHalfHeight = max(0, min(centre.y - extent.minY, extent.maxY - centre.y)) | |
| return CGSize(width: min(desired.width, maxHalfWidth), height: min(desired.height, maxHalfHeight)) | |
| } | |
| /// The template shared by the search and the tone match: sized on the PAINTED SELECTION | |
| /// itself, not a constant multiple of the radius — a dab and a dragged stroke of the same | |
| /// radius leave very different holes, and the template must read the actual one, in full, | |
| /// plus a margin generous enough to hold real surrounding structure to compare against. | |
| /// The radius already reaches this rect through `hole`, padding the stroke's own bounding box — | |
| /// it plays no further part here, so it cannot end up smaller than what was actually painted. | |
| private static func contextRing(centre: CGPoint, hole: CGRect, extent: CGRect) -> CGRect { | |
| let contextPad: CGFloat = 3 | |
| let desired = min(max(hole.width, hole.height) * contextPad, min(extent.width, extent.height)) | |
| let half = edgeClampedHalf(CGSize(width: desired / 2, height: desired / 2), | |
| centre: centre, extent: extent) | |
| let side = min(half.width, half.height) * 2 | |
| return CGRect(x: centre.x - side / 2, y: centre.y - side / 2, width: side, height: side) | |
| } | |
| private static let metallib: Data? = { | |
| guard let url = Bundle.main.url(forResource: "OpenNegative", withExtension: "metallib") | |
| else { return nil } | |
| return try? Data(contentsOf: url) | |
| }() | |
| /// Reads (dest − candidate) over an N×N point grid, GPU-side: one texel per candidate offset, | |
| /// so a dense search costs one dispatch instead of one GPU round trip per position tried. | |
| private static let costKernel: CIKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIKernel(functionName: "correctionCost", fromMetalLibraryData: metallib) | |
| }() | |
| /// One evaluated candidate: its offset from the destination, and how well its ring matched. | |
| private struct Candidate { let offset: CGVector; let cost: Float } | |
| /// Dispatches `correctionCost` over a `gridSize`×`gridSize` block of candidate offsets and | |
| /// reads every texel back; each carries its OWN offset, so no assumption about row order, | |
| /// or about which axis the GPU laid out first, ever has to be made on the Swift side. | |
| private static func costMap(source: CIImage, destCentre: CGPoint, halfSize: CGSize, | |
| originOffset: CGVector, step: CGFloat, gridN: Int, gridSize: Int, | |
| minRadius: CGFloat, hole: CGRect, context: CIContext) -> [Candidate]? { | |
| guard let costKernel else { return nil } | |
| let reach = CGFloat(gridSize - 1) * step | |
| let searchMin = CGPoint(x: destCentre.x + originOffset.dx - halfSize.width, | |
| y: destCentre.y + originOffset.dy - halfSize.height) | |
| let searchMax = CGPoint(x: destCentre.x + originOffset.dx + reach + halfSize.width, | |
| y: destCentre.y + originOffset.dy + reach + halfSize.height) | |
| let destMin = CGPoint(x: destCentre.x - halfSize.width, y: destCentre.y - halfSize.height) | |
| let destMax = CGPoint(x: destCentre.x + halfSize.width, y: destCentre.y + halfSize.height) | |
| // The union of the destination ring and every ring the window could ever place: what a | |
| // fixed `roiCallback` must return, since Core Image never asks per-texel for this kernel. | |
| let needed = CGRect(x: min(destMin.x, searchMin.x), y: min(destMin.y, searchMin.y), | |
| width: max(destMax.x, searchMax.x) - min(destMin.x, searchMin.x), | |
| height: max(destMax.y, searchMax.y) - min(destMin.y, searchMin.y)) | |
| .intersection(source.extent) | |
| let roi: @Sendable (Int32, CGRect) -> CGRect = { _, _ in needed } | |
| let outputExtent = CGRect(x: 0, y: 0, width: gridSize, height: gridSize) | |
| guard let cost = costKernel.apply(extent: outputExtent, roiCallback: roi, arguments: [ | |
| source, CIVector(cgPoint: destCentre), CIVector(x: halfSize.width, y: halfSize.height), | |
| CIVector(x: originOffset.dx, y: originOffset.dy), step, CGFloat(gridN), minRadius, | |
| CIVector(x: source.extent.minX, y: source.extent.minY), | |
| CIVector(x: source.extent.maxX, y: source.extent.maxY), | |
| CIVector(x: hole.minX, y: hole.minY, z: hole.maxX, w: hole.maxY), | |
| ]) else { return nil } | |
| let count = gridSize * gridSize | |
| var raw = [Float](repeating: 0, count: count * 4) | |
| // The throwing task API, not `render(_:toBitmap:...)`: a dispatch big enough to trip the | |
| // GPU watchdog used to come back as an untouched, all-zero buffer from the non-throwing | |
| // call — every candidate tied at a "perfect" 0 cost, so the search committed to whichever | |
| // one iteration order landed on, silently. Catching the failure and returning nil sends the | |
| // caller to its own fallback instead. | |
| do { | |
| try raw.withUnsafeMutableBytes { buffer in | |
| guard let base = buffer.baseAddress else { throw CocoaError(.fileReadUnknown) } | |
| let destination = CIRenderDestination(bitmapData: base, width: gridSize, | |
| height: gridSize, bytesPerRow: gridSize * 16, | |
| format: .RGBAf) | |
| // A cost map is not colour: matching it to a working space would corrupt the raw | |
| // SSD and offset values it carries, the same trap a table `CIImage` falls into. | |
| destination.colorSpace = nil | |
| let task = try context.startTask(toRender: cost, to: destination) | |
| _ = try task.waitUntilCompleted() | |
| } | |
| } catch { | |
| return nil | |
| } | |
| return (0..<count).map { i in | |
| Candidate(offset: CGVector(dx: CGFloat(raw[i * 4 + 1]), dy: CGFloat(raw[i * 4 + 2])), | |
| cost: raw[i * 4]) | |
| } | |
| } | |
| /// A coarse pass over a wide window finds the right neighbourhood; a fine pass around its | |
| /// winner, at a finer grid and a one-pixel step, resolves exactly where in it — the fix for a | |
| /// search that used to find AN edge but land it a few pixels off the one already in the photo. | |
| static func bestSourceRect(for correction: Correction, in source: CIImage, | |
| context: CIContext) -> CGRect { | |
| let extent = source.extent | |
| let tileRect = contextRect(for: correction, in: extent) | |
| let fallback = tileRect.offsetBy(dx: tileRect.width, dy: 0).maxX > extent.maxX | |
| ? tileRect.offsetBy(dx: -tileRect.width, dy: 0) : tileRect.offsetBy(dx: tileRect.width, dy: 0) | |
| guard let centre = centroid(of: correction, in: extent) else { return fallback } | |
| // Fallback to a point-sized hole rather than an unbounded one: `contextRing`'s `hole.map` | |
| // treats an absent stroke as no constraint, which an empty stroke never actually reaches. | |
| let hole = strokeBoundingRect(of: correction, in: extent) | |
| ?? CGRect(x: centre.x, y: centre.y, width: 0, height: 0) | |
| let ring = contextRing(centre: centre, hole: hole, extent: extent) | |
| let halfSize = CGSize(width: ring.width / 2, height: ring.height / 2) | |
| let minRadius = max(halfSize.width, halfSize.height) * 1.2 | |
| // A grid of N samples across a ring is BLIND inside halfSize/N of its own centre — no | |
| // sample ever lands closer than that, so every offset inside it reads identically and | |
| // scores a tied zero. The coarse pass's own blind spot is what the fine pass must outreach. | |
| // `coarseRadius` is ALSO the one thing that must always outgrow `minRadius`: on a long or | |
| // wide enough stroke, `minRadius` (which scales with the ring, hence with the hole) used to | |
| // exceed the fixed, tile-only `coarseRadius`, excluding every coarse candidate at once and | |
| // returning the content-blind `fallback` with nothing to distinguish it from a real match. | |
| let coarseRadius = max(max(tileRect.width, tileRect.height) * 1.6, minRadius * 1.5) | |
| let coarseStep: CGFloat = 8 | |
| let coarseGridN = 8 | |
| let coarseSize = Int((coarseRadius * 2 / coarseStep).rounded()) + 1 | |
| guard let coarse = costMap(source: source, destCentre: centre, halfSize: halfSize, | |
| originOffset: CGVector(dx: -coarseRadius, dy: -coarseRadius), | |
| step: coarseStep, gridN: coarseGridN, gridSize: coarseSize, | |
| minRadius: minRadius, hole: hole, context: context), | |
| let coarseBest = coarse.min(by: { $0.cost < $1.cost }), coarseBest.cost.isFinite | |
| else { return fallback } | |
| // The fine pass reads a MUCH SMALLER ring — precision near an already-plausible position, | |
| // not context — but it must still clear a wide dragged hole, or most of its own cells get | |
| // excluded below. `fineHalfSize` grows with the hole so the template still covers it; the | |
| // SAMPLE COUNT does not follow it past a fixed ceiling — a longer stroke used to multiply | |
| // both the number of candidates tried AND the samples per candidate at once, an unbounded | |
| // GPU dispatch that could run for minutes or trip the OS's own GPU watchdog outright on an | |
| // entirely ordinary wide brush dragged a few hundred pixels. Past the ceiling, a bigger hole | |
| // buys a wider template at the SAME density, never a denser one. | |
| // Clamped the same way `contextRing` is: the kernel samples this window symmetrically | |
| // around `centre`, so a defect near a frame edge would otherwise read fabricated content | |
| // past the photo's own border on the destination side, with nothing there to reject it | |
| // the way the candidate side's own bounds check already does. | |
| let fineHalfSize = edgeClampedHalf(CGSize(width: max(30, hole.width * 0.75), | |
| height: max(30, hole.height * 0.75)), | |
| centre: centre, extent: extent) | |
| let fineGridN = min(64, max(24, Int((max(fineHalfSize.width, fineHalfSize.height) / 1.25) | |
| .rounded(.up)))) | |
| let coarseBlindSpot = max(halfSize.width, halfSize.height) / CGFloat(coarseGridN) | |
| let fineRadius = coarseBlindSpot * 2 + coarseStep | |
| let fineStep: CGFloat = 1 | |
| let fineSize = Int((fineRadius * 2 / fineStep).rounded()) + 1 | |
| // Its own exclusion radius, not the coarse pass's: reusing `minRadius` (sized on the much | |
| // bigger coarse ring) could reject part of the fine grid for overlapping a ring far larger | |
| // than the one actually being compared here, right where the true match sits just past the | |
| // coarse ring's own boundary — an asymmetric, avoidable loss of sub-pixel refinement. | |
| let fineMinRadius = max(fineHalfSize.width, fineHalfSize.height) * 1.2 | |
| guard let fine = costMap(source: source, destCentre: centre, halfSize: fineHalfSize, | |
| originOffset: CGVector(dx: coarseBest.offset.dx - fineRadius, | |
| dy: coarseBest.offset.dy - fineRadius), | |
| step: fineStep, gridN: fineGridN, gridSize: fineSize, | |
| minRadius: fineMinRadius, hole: hole, context: context), | |
| let bestIndex = fine.indices.min(by: { fine[$0].cost < fine[$1].cost }), | |
| fine[bestIndex].cost.isFinite | |
| else { | |
| return tileRect.offsetBy(dx: coarseBest.offset.dx, dy: coarseBest.offset.dy) | |
| } | |
| var best = fine[bestIndex].offset | |
| // Parabolic peak interpolation, done on whatever offset each neighbour actually reports — | |
| // never on an assumed row/column direction, which a flipped axis would silently mis-sign. | |
| let row = bestIndex / fineSize, col = bestIndex % fineSize | |
| if row > 0, row < fineSize - 1, col > 0, col < fineSize - 1 { | |
| func refine(_ centreCost: Float, _ minus: Candidate, _ plus: Candidate, | |
| _ axis: KeyPath<CGVector, CGFloat>) -> CGFloat { | |
| let denom = minus.cost - 2 * centreCost + plus.cost | |
| guard denom > 0, minus.cost.isFinite, plus.cost.isFinite else { return best[keyPath: axis] } | |
| // Clamped to half a step: a near-flat neighbourhood makes `denom` tiny, and without | |
| // this a noisy fit would extrapolate the "peak" far outside the sampled window. | |
| let delta = max(-0.5, min(0.5, 0.5 * (minus.cost - plus.cost) / denom)) | |
| let span = plus.offset[keyPath: axis] - minus.offset[keyPath: axis] | |
| return best[keyPath: axis] + CGFloat(delta) * span / 2 | |
| } | |
| let centreCost = fine[bestIndex].cost | |
| best.dx = refine(centreCost, fine[row * fineSize + col - 1], fine[row * fineSize + col + 1], | |
| \.dx) | |
| best.dy = refine(centreCost, fine[(row - 1) * fineSize + col], fine[(row + 1) * fineSize + col], | |
| \.dy) | |
| } | |
| return tileRect.offsetBy(dx: best.dx, dy: best.dy) | |
| } | |
| /// The stroke's own bounding box, padded by its radius — what a destination-side mean must | |
| /// exclude, or the defect's own damaged pixels bias the very tone the patch is matched to. | |
| private static func strokeBoundingRect(of correction: Correction, in extent: CGRect) -> CGRect? { | |
| guard !correction.stroke.isEmpty else { return nil } | |
| let xs = correction.stroke.map { extent.minX + $0.x * extent.width } | |
| let ys = correction.stroke.map { extent.minY + (1 - $0.y) * extent.height } | |
| let minX = (xs.min() ?? 0) - correction.radius, maxX = (xs.max() ?? 0) + correction.radius | |
| let minY = (ys.min() ?? 0) - correction.radius, maxY = (ys.max() ?? 0) + correction.radius | |
| return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) | |
| } | |
| /// `rect`'s mean with `hole` subtracted out — by area, from two plain averages, since | |
| /// `CIAreaAverage` has no notion of an annulus and the arithmetic needs nothing fancier. | |
| private static func maskedMeanColor(of image: CIImage, in rect: CGRect, excludingHole hole: CGRect?, | |
| context: CIContext) -> SIMD3<Float>? { | |
| guard let bigMean = meanColor(of: image, in: rect, context: context) else { return nil } | |
| guard let hole else { return bigMean } | |
| let holeInRect = hole.intersection(rect) | |
| guard !holeInRect.isEmpty, let holeMean = meanColor(of: image, in: holeInRect, context: context) | |
| else { return bigMean } | |
| let bigArea = Float(rect.width * rect.height), holeArea = Float(holeInRect.width * holeInRect.height) | |
| let remainingArea = bigArea - holeArea | |
| // A hole this close to the whole ring makes the subtraction catastrophic cancellation: | |
| // two near-equal large terms, divided by a sliver — any measurement noise is amplified | |
| // into a wild mean. A wide brush on a long stroke reaches this; the plain, contaminated | |
| // mean this replaces is the safer of the two known-bad answers. | |
| guard remainingArea > bigArea * 0.3 else { return bigMean } | |
| return (bigMean * bigArea - holeMean * holeArea) / remainingArea | |
| } | |
| /// The tone bias's own spatial variation: a base value at the ring's centre, plus how fast it | |
| /// changes per pixel along each axis — sampled at four half-rects rather than the one overall | |
| /// mean, so a soft light gradient across the destination is followed instead of averaged away. | |
| /// `gradX`/`gradY` are exactly zero when the two halves of an axis agree, which is what keeps | |
| /// this an exact generalisation of the old flat bias rather than a different measurement of it. | |
| private struct ToneField { let base: SIMD3<Float>; let gradX: SIMD3<Float>; let gradY: SIMD3<Float> } | |
| private static func toneGradient(destinationRing: CGRect, sourceRing: CGRect, source: CIImage, | |
| excludingHole hole: CGRect?, context: CIContext) -> ToneField { | |
| guard let destinationMean = maskedMeanColor(of: source, in: destinationRing, excludingHole: hole, | |
| context: context), | |
| let sourceMean = meanColor(of: source, in: sourceRing, context: context) | |
| else { return ToneField(base: .zero, gradX: .zero, gradY: .zero) } | |
| let base = destinationMean - sourceMean | |
| func leftHalf(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: r.minY, width: r.width / 2, height: r.height) } | |
| func rightHalf(_ r: CGRect) -> CGRect { CGRect(x: r.midX, y: r.minY, width: r.width / 2, height: r.height) } | |
| func bottomHalf(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: r.minY, width: r.width, height: r.height / 2) } | |
| func topHalf(_ r: CGRect) -> CGRect { CGRect(x: r.minX, y: r.midY, width: r.width, height: r.height / 2) } | |
| guard let dl = maskedMeanColor(of: source, in: leftHalf(destinationRing), excludingHole: hole, context: context), | |
| let dr = maskedMeanColor(of: source, in: rightHalf(destinationRing), excludingHole: hole, context: context), | |
| let db = maskedMeanColor(of: source, in: bottomHalf(destinationRing), excludingHole: hole, context: context), | |
| let dt = maskedMeanColor(of: source, in: topHalf(destinationRing), excludingHole: hole, context: context), | |
| let sl = meanColor(of: source, in: leftHalf(sourceRing), context: context), | |
| let sr = meanColor(of: source, in: rightHalf(sourceRing), context: context), | |
| let sb = meanColor(of: source, in: bottomHalf(sourceRing), context: context), | |
| let st = meanColor(of: source, in: topHalf(sourceRing), context: context) | |
| else { return ToneField(base: base, gradX: .zero, gradY: .zero) } | |
| // Divides by the distance between the two half-rects' own centres (a quarter of the ring's | |
| // side each), not the ring's full side — the slope a straight line through those two points | |
| // actually has, not one scaled by an unrelated span. | |
| let halfSpan = Float(max(destinationRing.width, 1) / 4 + max(destinationRing.width, 1) / 4) | |
| let halfSpanY = Float(max(destinationRing.height, 1) / 4 + max(destinationRing.height, 1) / 4) | |
| let gradX = ((dr - sr) - (dl - sl)) / max(halfSpan, 1e-3) | |
| let gradY = ((dt - st) - (db - sb)) / max(halfSpanY, 1e-3) | |
| return ToneField(base: base, gradX: gradX, gradY: gradY) | |
| } | |
| private static let biasFieldKernel: CIKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIKernel(functionName: "correctionBiasField", fromMetalLibraryData: metallib) | |
| }() | |
| /// Adds `field`'s bias to `image`, varying linearly across it — through the dedicated kernel | |
| /// when there is a real gradient to follow, or the plain `CIColorMatrix` bias otherwise (exact, | |
| /// and cheaper, when `gradX`/`gradY` are zero — the common case on an even-lit patch). | |
| private static func applyToneField(_ image: CIImage, field: ToneField, origin: CGPoint) -> CIImage { | |
| guard field.base != .zero || field.gradX != .zero || field.gradY != .zero else { return image } | |
| guard field.gradX != .zero || field.gradY != .zero, let biasFieldKernel else { | |
| guard field.base != .zero else { return image } | |
| return image.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputBiasVector": CIVector(x: CGFloat(field.base.x), y: CGFloat(field.base.y), | |
| z: CGFloat(field.base.z), w: 0), | |
| ]) | |
| } | |
| let roi: @Sendable (Int32, CGRect) -> CGRect = { _, rect in rect } | |
| return biasFieldKernel.apply(extent: image.extent, roiCallback: roi, arguments: [ | |
| image, | |
| CIVector(x: CGFloat(field.base.x), y: CGFloat(field.base.y), z: CGFloat(field.base.z)), | |
| CIVector(x: CGFloat(field.gradX.x), y: CGFloat(field.gradX.y), z: CGFloat(field.gradX.z)), | |
| CIVector(x: CGFloat(field.gradY.x), y: CGFloat(field.gradY.y), z: CGFloat(field.gradY.z)), | |
| CIVector(x: origin.x, y: origin.y), | |
| ]) ?? image | |
| } | |
| /// Generates and caches one correction's patch. `source` must be `Pipeline.correctionSource` | |
| /// at full resolution — the same frame the stroke was painted on. Async, off the main actor. | |
| /// Returns the source tile actually used, as a fraction of `source`, so the caller can draw it. | |
| @discardableResult | |
| static func generate(_ correction: Correction, source: CIImage, | |
| settings: PipelineSettings) async -> CGRect? { | |
| let extent = source.extent | |
| let tileRect = contextRect(for: correction, in: extent) | |
| let context = Pipeline.measureContext | |
| // The search costs a handful of GPU reductions — the "small loading" traded for a patch | |
| // that actually matches its surroundings, real pixels and real grain either way. | |
| let rect = bestSourceRect(for: correction, in: source, context: context) | |
| var duplicated = source.cropped(to: rect) | |
| .transformed(by: CGAffineTransform(translationX: tileRect.minX - rect.minX, | |
| y: tileRect.minY - rect.minY)) | |
| // A bias that can vary across the patch, not just a single number: still cheap enough to | |
| // run on every stroke, and it is what removes both a flat tone step AND a soft light | |
| // gradient a structurally good match can still leave at the seam. | |
| if let centre = centroid(of: correction, in: extent), | |
| let hole = strokeBoundingRect(of: correction, in: extent) { | |
| let destinationRing = contextRing(centre: centre, hole: hole, extent: extent) | |
| let sourceRing = destinationRing.offsetBy(dx: rect.minX - tileRect.minX, | |
| dy: rect.minY - tileRect.minY) | |
| let field = toneGradient(destinationRing: destinationRing, sourceRing: sourceRing, | |
| source: source, excludingHole: hole, context: context) | |
| duplicated = applyToneField(duplicated, field: field, origin: centre) | |
| } | |
| guard let data = Pipeline.encodedPatch(duplicated) else { return nil } | |
| // A correction deleted while its own generation was still running has nothing left to name | |
| // this write: without this guard it persists anyway, resurrecting a `.tiff` for an id that | |
| // no longer exists in `settings.corrections`, permanently orphaned on disk. | |
| guard !Task.isCancelled else { return nil } | |
| CorrectionCache.persist(data, correctionID: correction.id) | |
| return fraction(of: rect, in: source.extent) | |
| } | |
| } | |
| extension CorrectionRenderer { | |
| /// The geometry a stroke depends on, without the model: a point at a known fraction lands at | |
| /// the pixel it names, in both `contextRect`'s framing and the mask `strokeMask` rasterises. | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| // Large enough that the 800 px tile never needs clamping against an edge. | |
| let extent = CGRect(x: 0, y: 0, width: 2000, height: 1600) | |
| let side = CGFloat(CorrectionRenderer.tileSide) | |
| // Dead centre: the tile must be centred on it, symmetrically. | |
| let centre = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 20) | |
| let centred = CorrectionRenderer.contextRect(for: centre, in: extent) | |
| let expectedX = extent.midX - side / 2, expectedY = extent.midY - side / 2 | |
| report(abs(centred.minX - expectedX) < 1 && abs(centred.minY - expectedY) < 1 | |
| && centred.width == side && centred.height == side, | |
| String(format: "a stroke at the frame's centre gets an %.0f px tile centred on it " | |
| + "(got x=%.1f y=%.1f, wanted x=%.1f y=%.1f)", side, centred.minX, centred.minY, | |
| expectedX, expectedY)) | |
| // A corner: the tile clamps inside the frame rather than running off it. | |
| let corner = Correction(stroke: [CGPoint(x: 0.0, y: 0.0)], radius: 20) | |
| let cornered = CorrectionRenderer.contextRect(for: corner, in: extent) | |
| report(cornered.minX >= extent.minX && cornered.minY >= extent.minY | |
| && cornered.maxX <= extent.maxX && cornered.maxY <= extent.maxY, | |
| "a stroke at a corner keeps its tile fully inside the frame") | |
| // The mask: a single dab at a known fraction must land white at that exact pixel and | |
| // black far from it, in the ORIENTATION `stroke`'s own doc promises — top-left fractions. | |
| let dabFraction = CGPoint(x: 0.25, y: 0.75) // left half, lower half, in image terms | |
| let dab = Correction(stroke: [dabFraction], radius: 8) | |
| let tileRect = CorrectionRenderer.contextRect(for: dab, in: extent) | |
| let mask = CorrectionRenderer.strokeMask(dab, imageExtent: extent, tileRect: tileRect) | |
| let ctx = CIContext() | |
| func sample(_ p: CGPoint) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(mask, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: p.x, y: p.y, width: 1, height: 1), format: .RGBAf, | |
| colorSpace: nil) | |
| return px[0] | |
| } | |
| // The dab's own Core Image point: top-left fraction -> bottom-left CI space, the one flip | |
| // this whole file crosses exactly once. | |
| let dabCI = CGPoint(x: extent.minX + dabFraction.x * extent.width, | |
| y: extent.minY + (1 - dabFraction.y) * extent.height) | |
| let onDab = sample(CGPoint(x: dabCI.x.rounded(), y: dabCI.y.rounded())) | |
| let farFromDab = sample(CGPoint(x: tileRect.minX + 2, y: tileRect.minY + 2)) | |
| report(onDab > 0.9, String(format: "the mask is white exactly on a single dab (%.2f)", onDab)) | |
| report(farFromDab < 0.1, | |
| String(format: "and the check discriminates: a far corner of the same tile stays " | |
| + "black (%.2f)", farFromDab)) | |
| // Feathering: a point just OUTSIDE the hard circle must still catch some of the blur, where | |
| // strokeMask itself would already have reported a clean 0 — the seam this exists to soften. | |
| let hard = CorrectionRenderer.strokeMask(dab, imageExtent: extent, tileRect: tileRect) | |
| let soft = CorrectionRenderer.blendMask(for: dab, imageExtent: extent, tileRect: tileRect) | |
| func sample(_ image: CIImage, _ p: CGPoint) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: p.x, y: p.y, width: 1, height: 1), format: .RGBAf, | |
| colorSpace: nil) | |
| return px[0] | |
| } | |
| let justOutside = CGPoint(x: dabCI.x.rounded() + dab.radius + 3, y: dabCI.y.rounded()) | |
| report(sample(hard, justOutside) < 0.05, | |
| String(format: "fixture: strokeMask itself is already black just past its edge (%.2f)", | |
| sample(hard, justOutside))) | |
| report(sample(soft, justOutside) > 0.01, | |
| String(format: "and blendMask reaches past that same edge (%.3f), the softened seam", | |
| sample(soft, justOutside))) | |
| let wellInside = CGPoint(x: dabCI.x.rounded(), y: dabCI.y.rounded()) | |
| report(sample(soft, wellInside) > 0.9, | |
| String(format: "well inside the dab, blendMask is still effectively opaque (%.3f)", | |
| sample(soft, wellInside))) | |
| // The dense search, against a scene with exactly ONE other place a corner like the | |
| // defect's own could come from — a flat scene cannot tell a wrong offset from a right one. | |
| let bigExtent = CGRect(x: 0, y: 0, width: 4000, height: 4000) | |
| func flatBlock(_ c: SIMD3<Float>, _ rect: CGRect) -> CIImage { | |
| CIImage(color: CIColor(red: CGFloat(c.x), green: CGFloat(c.y), blue: CGFloat(c.z))) | |
| .cropped(to: rect) | |
| } | |
| let background = flatBlock(SIMD3(0.55, 0.10, 0.10), bigExtent) | |
| let blockColor = SIMD3<Float>(0.10, 0.15, 0.60) | |
| // Two identical corners, nowhere else on the canvas: the only candidate a structural | |
| // search should ever prefer is the other one, at exactly the offset between them. | |
| let trueSource = flatBlock(blockColor, CGRect(x: 1300, y: 1200, width: 500, height: 500)) | |
| let defectSite = flatBlock(blockColor, CGRect(x: 2200, y: 1800, width: 500, height: 500)) | |
| let corners = defectSite.composited(over: trueSource.composited(over: background)) | |
| // (0.55, 0.55): the fraction landing centroid() on the defect corner, (2200, 1800). | |
| let cornerDefect = Correction(stroke: [CGPoint(x: 0.55, y: 0.55)], radius: 60) | |
| let cornerTile = CorrectionRenderer.contextRect(for: cornerDefect, in: corners.extent) | |
| let cornerFound = CorrectionRenderer.bestSourceRect(for: cornerDefect, in: corners, | |
| context: Pipeline.measureContext) | |
| let foundOffset = CGVector(dx: cornerFound.minX - cornerTile.minX, | |
| dy: cornerFound.minY - cornerTile.minY) | |
| let expectedOffset = CGVector(dx: -900, dy: -600) | |
| let offsetError = hypot(foundOffset.dx - expectedOffset.dx, foundOffset.dy - expectedOffset.dy) | |
| report(offsetError < 2, | |
| String(format: "a corner duplicated at a known (-900, -600) offset is found within " | |
| + "%.2f px of it (found dx=%.1f dy=%.1f)", offsetError, foundOffset.dx, | |
| foundOffset.dy)) | |
| // The real regression: a SMALL radius (8 px) DRAGGED ~100 px along the corner, the way a | |
| // hair or a scratch is actually painted — not a single dab. Old `radius * 6` gave a 48 px | |
| // ring, dwarfed by a hole over twice as wide, so the "template" was mostly the defect itself. | |
| let draggedDefect = Correction(stroke: [CGPoint(x: 0.5375, y: 0.55), CGPoint(x: 0.5625, y: 0.55)], | |
| radius: 8) | |
| let draggedTile = CorrectionRenderer.contextRect(for: draggedDefect, in: corners.extent) | |
| let draggedFound = CorrectionRenderer.bestSourceRect(for: draggedDefect, in: corners, | |
| context: Pipeline.measureContext) | |
| let draggedOffset = CGVector(dx: draggedFound.minX - draggedTile.minX, | |
| dy: draggedFound.minY - draggedTile.minY) | |
| let draggedError = hypot(draggedOffset.dx - expectedOffset.dx, draggedOffset.dy - expectedOffset.dy) | |
| report(draggedError < 4, | |
| String(format: "a small-radius (8 px) stroke dragged 100 px across the same corner " | |
| + "still finds it within %.2f px (found dx=%.1f dy=%.1f)", draggedError, | |
| draggedOffset.dx, draggedOffset.dy)) | |
| // A long, clean vertical boundary, defect dead centre on it: EVERY dx≈0 offset (any dy) is | |
| // an exact tie for best — nothing here should ever prefer a sideways offset over one along | |
| // the line itself. A repeatable sideways bias would show here with no texture to blame. | |
| let longBoundary = flatBlock(blockColor, CGRect(x: 2000, y: 0, width: 2000, height: 4000)) | |
| .composited(over: flatBlock(SIMD3(0.55, 0.10, 0.10), bigExtent)) | |
| let boundaryDefect = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 60) | |
| let boundaryTile = CorrectionRenderer.contextRect(for: boundaryDefect, in: longBoundary.extent) | |
| let boundaryFound = CorrectionRenderer.bestSourceRect(for: boundaryDefect, in: longBoundary, | |
| context: Pipeline.measureContext) | |
| let boundaryOffset = CGVector(dx: boundaryFound.minX - boundaryTile.minX, | |
| dy: boundaryFound.minY - boundaryTile.minY) | |
| report(abs(boundaryOffset.dx) < 5, | |
| String(format: "on a long clean vertical boundary, the found offset is not biased " | |
| + "sideways: dx=%.2f (dy=%.1f)", boundaryOffset.dx, boundaryOffset.dy)) | |
| // The tone match's destination-side mean must exclude the defect's own damaged pixels. | |
| // Measured against a CLEAN version of the same ring, never a hand-computed value — `CIColor` | |
| // does not necessarily land in the working space `meanColor` reads, so only a comparison | |
| // taken through the same pipeline on both sides is meaningful. | |
| let toneRingRect = CGRect(x: 0, y: 0, width: 360, height: 360) | |
| let toneBackground = SIMD3<Float>(0.10, 0.10, 0.10) | |
| let toneDefectColor = SIMD3<Float>(0.95, 0.95, 0.95) | |
| let toneDefectRect = CGRect(x: 130, y: 130, width: 100, height: 100) | |
| let toneScene = flatBlock(toneDefectColor, toneDefectRect) | |
| .composited(over: flatBlock(toneBackground, toneRingRect)) | |
| let cleanTone = meanColor(of: flatBlock(toneBackground, toneRingRect), in: toneRingRect, | |
| context: Pipeline.measureContext) ?? .zero | |
| let unmaskedTone = meanColor(of: toneScene, in: toneRingRect, context: Pipeline.measureContext) | |
| ?? .zero | |
| let maskedTone = maskedMeanColor(of: toneScene, in: toneRingRect, excludingHole: toneDefectRect, | |
| context: Pipeline.measureContext) ?? .zero | |
| report(abs(unmaskedTone.x - cleanTone.x) > 0.02, | |
| String(format: "fixture: an unmasked destination mean is measurably biased by a bright " | |
| + "defect left inside it (Δ%.4f against the clean ring)", | |
| unmaskedTone.x - cleanTone.x)) | |
| report(abs(maskedTone.x - cleanTone.x) < 0.005, | |
| String(format: "and the check discriminates: excluding the defect's own bounding box " | |
| + "recovers the clean ring's own mean (Δ%.4f)", maskedTone.x - cleanTone.x)) | |
| // Grain-like noise, hash-based rather than random, so the same fixture reads the same way | |
| // on every run. `row` counts from the FIRST byte written, which lands at the TOP of the | |
| // image — measured, not assumed: CI's own y grows upward, opposite the buffer's own order. | |
| func noisyScene(side: Int, amplitude: Float, colorAt: (Int, Int) -> SIMD3<Float>) -> CIImage { | |
| var buffer = [Float](repeating: 0, count: side * side * 4) | |
| for row in 0..<side { | |
| for x in 0..<side { | |
| var h = UInt32(truncatingIfNeeded: x &* 374_761_393 &+ row &* 668_265_263) | |
| h = (h ^ (h >> 13)) &* 1_274_126_177 | |
| h ^= h >> 16 | |
| let n = (Float(h % 2000) / 1000 - 1) * amplitude | |
| let c = colorAt(x, row) | |
| let idx = (row * side + x) * 4 | |
| buffer[idx] = c.x + n | |
| buffer[idx + 1] = c.y + n | |
| buffer[idx + 2] = c.z + n | |
| buffer[idx + 3] = 1 | |
| } | |
| } | |
| let data = buffer.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: data, bytesPerRow: side * 16, | |
| size: CGSize(width: side, height: side), format: .RGBAf, colorSpace: nil) | |
| } | |
| // The same two corners as above, this time under grain: a real scan never hands the search | |
| // two bit-identical patches, only two statistically similar ones. | |
| let noisySide = 4000 | |
| func cornerColor(_ x: Int, _ row: Int) -> SIMD3<Float> { | |
| let ciY = noisySide - 1 - row | |
| let inTrueSource = x >= 1300 && x < 1800 && ciY >= 1200 && ciY < 1700 | |
| let inDefectSite = x >= 2200 && x < 2700 && ciY >= 1800 && ciY < 2300 | |
| return (inTrueSource || inDefectSite) ? blockColor : SIMD3(0.55, 0.10, 0.10) | |
| } | |
| let noisyCorners = noisyScene(side: noisySide, amplitude: 0.03, colorAt: cornerColor) | |
| let noisyTile = CorrectionRenderer.contextRect(for: cornerDefect, in: noisyCorners.extent) | |
| let noisyFound = CorrectionRenderer.bestSourceRect(for: cornerDefect, in: noisyCorners, | |
| context: Pipeline.measureContext) | |
| let noisyOffset = CGVector(dx: noisyFound.minX - noisyTile.minX, | |
| dy: noisyFound.minY - noisyTile.minY) | |
| let noisyError = hypot(noisyOffset.dx - expectedOffset.dx, noisyOffset.dy - expectedOffset.dy) | |
| report(noisyError < 4, | |
| String(format: "the same corner, under grain-like noise, is still found within %.2f px " | |
| + "of it (found dx=%.1f dy=%.1f)", noisyError, noisyOffset.dx, noisyOffset.dy)) | |
| // A boundary tilted 1.5° from vertical, under the same noise, with NO second landmark: every | |
| // offset along the tilt is an equally exact match, so the found one must lie ON that line — | |
| // a residual PERPENDICULAR to it is what a translation-only search cannot correct by chance. | |
| let tilt = 1.5 * CGFloat.pi / 180 | |
| let tanTilt = tan(tilt) | |
| let ridgeCentre: CGFloat = 2000 | |
| func ridgeColor(_ x: Int, _ row: Int) -> SIMD3<Float> { | |
| let ciY = CGFloat(noisySide - 1 - row) | |
| let boundaryX = ridgeCentre + tanTilt * (ciY - ridgeCentre) | |
| return CGFloat(x) >= boundaryX ? blockColor : SIMD3(0.55, 0.10, 0.10) | |
| } | |
| let ridgeScene = noisyScene(side: noisySide, amplitude: 0.03, colorAt: ridgeColor) | |
| let ridgeDefect = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 60) | |
| let ridgeTile = CorrectionRenderer.contextRect(for: ridgeDefect, in: ridgeScene.extent) | |
| let ridgeFound = CorrectionRenderer.bestSourceRect(for: ridgeDefect, in: ridgeScene, | |
| context: Pipeline.measureContext) | |
| let ridgeOffset = CGVector(dx: ridgeFound.minX - ridgeTile.minX, | |
| dy: ridgeFound.minY - ridgeTile.minY) | |
| let ridgeMagnitude = hypot(ridgeOffset.dx, ridgeOffset.dy) | |
| let ridgePerp = abs(ridgeOffset.dx - tanTilt * ridgeOffset.dy) / (1 + tanTilt * tanTilt).squareRoot() | |
| report(ridgeMagnitude > 50, | |
| String(format: "a tilted-edge match is not degenerate: offset magnitude %.1f px", | |
| ridgeMagnitude)) | |
| report(ridgePerp < 2, | |
| String(format: "and a 1.5° tilt, under noise, drifts only %.2f px perpendicular to the " | |
| + "edge's own direction (found dx=%.1f dy=%.1f)", ridgePerp, ridgeOffset.dx, | |
| ridgeOffset.dy)) | |
| // A thin scratch's own brush radius (4 px, the slider's minimum) shrinks the CONTEXT ring | |
| // to 24 px — well under the fine pass's fixed 30 px window, which the coarse ring no longer | |
| // bounds: the fine pass could then be pulled by structure the coarse pass never considered. | |
| let thinDefect = Correction(stroke: [CGPoint(x: 0.55, y: 0.55)], radius: 4) | |
| let thinTile = CorrectionRenderer.contextRect(for: thinDefect, in: corners.extent) | |
| let thinFound = CorrectionRenderer.bestSourceRect(for: thinDefect, in: corners, | |
| context: Pipeline.measureContext) | |
| let thinOffset = CGVector(dx: thinFound.minX - thinTile.minX, dy: thinFound.minY - thinTile.minY) | |
| let thinError = hypot(thinOffset.dx - expectedOffset.dx, thinOffset.dy - expectedOffset.dy) | |
| report(thinError < 4, | |
| String(format: "a thin scratch's 4 px brush radius still finds the corner within %.2f " | |
| + "px (found dx=%.1f dy=%.1f)", thinError, thinOffset.dx, thinOffset.dy)) | |
| // A wide brush (100 px) dragged 900 px: `hole` alone already exceeds 1005 px, the point | |
| // past which the OLD `minRadius` (scaling with the ring) outgrew the OLD fixed-tile | |
| // `coarseRadius`, excluding every coarse candidate and returning the naive, content-blind | |
| // fallback with nothing to tell it apart from a real match. A dedicated, WIDER scene: the | |
| // existing corner pair sits only 1082 px apart, inside `minRadius` at this hole size, which | |
| // would defeat the self-overlap exclusion on its own — nothing to do with this fix. | |
| // Both corners must sit far enough from EVERY edge that their own 3300 px-wide context ring | |
| // (halfSize 1650) never samples out of bounds — the reason this cannot reuse the existing | |
| // 500 px-apart `corners` scene, whose separation is itself inside `minRadius` at this hole | |
| // size, defeating the self-overlap exclusion regardless of this fix. | |
| let wideExtent = CGRect(x: 0, y: 0, width: 6000, height: 6000) | |
| let wideBackground = flatBlock(SIMD3(0.55, 0.10, 0.10), wideExtent) | |
| let wideDefectCorner = CGPoint(x: 4300, y: 4300) | |
| let wideTrueCorner = CGPoint(x: 1700, y: 1700) | |
| let wideDefectSite = flatBlock(blockColor, CGRect(x: wideDefectCorner.x, y: wideDefectCorner.y, | |
| width: 500, height: 500)) | |
| let wideTrueSource = flatBlock(blockColor, CGRect(x: wideTrueCorner.x, y: wideTrueCorner.y, | |
| width: 500, height: 500)) | |
| let wideScene = wideDefectSite.composited(over: wideTrueSource.composited(over: wideBackground)) | |
| let wideExpectedOffset = CGVector(dx: wideTrueCorner.x - wideDefectCorner.x, | |
| dy: wideTrueCorner.y - wideDefectCorner.y) | |
| let wideStroke = [ | |
| CGPoint(x: (wideDefectCorner.x - 450) / wideExtent.width, | |
| y: 1 - wideDefectCorner.y / wideExtent.height), | |
| CGPoint(x: (wideDefectCorner.x + 450) / wideExtent.width, | |
| y: 1 - wideDefectCorner.y / wideExtent.height), | |
| ] | |
| let wideDefect = Correction(stroke: wideStroke, radius: 100) | |
| let wideTile = CorrectionRenderer.contextRect(for: wideDefect, in: wideScene.extent) | |
| let wideSearchStart = ProcessInfo.processInfo.systemUptime | |
| let wideFound = CorrectionRenderer.bestSourceRect(for: wideDefect, in: wideScene, | |
| context: Pipeline.measureContext) | |
| let wideElapsed = ProcessInfo.processInfo.systemUptime - wideSearchStart | |
| let wideOffset = CGVector(dx: wideFound.minX - wideTile.minX, dy: wideFound.minY - wideTile.minY) | |
| let wideIsFallback = wideFound == wideTile.offsetBy(dx: wideTile.width, dy: 0) | |
| || wideFound == wideTile.offsetBy(dx: -wideTile.width, dy: 0) | |
| report(!wideIsFallback, | |
| "a 100 px brush dragged 900 px (hole past the old fallback threshold) still runs a " | |
| + "real search rather than the content-blind one-tile-width fallback") | |
| // A purely horizontal stroke leaves `hole` — hence the fine pass's own vertical half-size — | |
| // only as tall as twice the radius: real horizontal precision, loose vertical constraint, | |
| // by construction. The point here is landing in the right neighbourhood at all, not the | |
| // sub-pixel precision the other, better-shaped fixtures already cover. | |
| let wideError = hypot(wideOffset.dx - wideExpectedOffset.dx, wideOffset.dy - wideExpectedOffset.dy) | |
| report(wideError < 150, | |
| String(format: "and it lands within %.1f px of the true offset (found dx=%.1f dy=%.1f)", | |
| wideError, wideOffset.dx, wideOffset.dy)) | |
| report(wideElapsed < 3, | |
| String(format: "and it does so in bounded time: %.2f s, nowhere near a GPU watchdog " | |
| + "abort or a multi-minute stall", wideElapsed)) | |
| // Two landmarks in one ring, each with its OWN exact duplicate at a different, nearby | |
| // offset — but this time the SMALL, low-absolute-value landmark carries the BIGGER relative | |
| // error when placed wrong (20 % of its own value) and the large, bright one the SMALLER | |
| // relative error (10 %). An unweighted SSD (absolute error squared) still favours the bright | |
| // one 25:1 despite its mismatch mattering LESS proportionally — exactly the amplitude-only | |
| // domination this fix targets, isolated from "which landmark simply looks more prominent." | |
| // Split the canvas by absolute Y alone, so a PURELY HORIZONTAL offset (dy = 0 for both true | |
| // duplicates) keeps each landmark's own local background context — dark below, bright above | |
| // — identical at every position translated to, with no extra bookkeeping needed. | |
| let contrastSplitY: CGFloat = 1500 | |
| func contrastGround(_ x: Int, _ row: Int) -> SIMD3<Float> { | |
| let ciY = CGFloat(3000 - 1 - row) | |
| return SIMD3(repeating: ciY < contrastSplitY ? 0.04 : 0.45) | |
| } | |
| let contrastBase = noisyScene(side: 3000, amplitude: 0, colorAt: contrastGround) | |
| let dimMarker = flatBlock(SIMD3(0.05, 0.05, 0.05), CGRect(x: 1470, y: 1450, width: 30, height: 30)) | |
| let brightMarker = flatBlock(SIMD3(0.50, 0.50, 0.50), CGRect(x: 1470, y: 1520, width: 30, height: 30)) | |
| let brightTrueOffset = CGVector(dx: -600, dy: 0) | |
| let dimTrueOffset = CGVector(dx: -560, dy: 0) | |
| let brightTrue = flatBlock(SIMD3(0.50, 0.50, 0.50), | |
| CGRect(x: 1470 + brightTrueOffset.dx, y: 1520 + brightTrueOffset.dy, | |
| width: 30, height: 30)) | |
| let dimTrue = flatBlock(SIMD3(0.05, 0.05, 0.05), | |
| CGRect(x: 1470 + dimTrueOffset.dx, y: 1450 + dimTrueOffset.dy, | |
| width: 30, height: 30)) | |
| let contrastScene = dimMarker.composited(over: brightMarker.composited( | |
| over: dimTrue.composited(over: brightTrue.composited(over: contrastBase)))) | |
| let contrastDefect = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 20) | |
| let contrastTile = CorrectionRenderer.contextRect(for: contrastDefect, in: contrastScene.extent) | |
| let contrastFound = CorrectionRenderer.bestSourceRect(for: contrastDefect, in: contrastScene, | |
| context: Pipeline.measureContext) | |
| let contrastOffset = CGVector(dx: contrastFound.minX - contrastTile.minX, | |
| dy: contrastFound.minY - contrastTile.minY) | |
| // The dim landmark's own true offset is 24 px from the bright one's. An unweighted search | |
| // lands on the bright landmark's offset almost exactly — weighted, the chosen offset should | |
| // sit measurably closer to the dim landmark's own match than that, not just repeat it. | |
| let distanceToDim = hypot(contrastOffset.dx - dimTrueOffset.dx, contrastOffset.dy - dimTrueOffset.dy) | |
| report(distanceToDim < 20, | |
| String(format: "weighted by local contrast, a dim landmark's own match (24 px from the " | |
| + "bright one's) is no longer ignored: %.1f px away (found dx=%.1f dy=%.1f)", | |
| distanceToDim, contrastOffset.dx, contrastOffset.dy)) | |
| // The mechanism `ensureGenerated`'s own fix (waiting for every prior correction to persist | |
| // before capturing a new one's search source, in OpenNegativeApp.swift) relies on: | |
| // `Pipeline.correctionSource` shows whatever is ACTUALLY ON DISK for another correction | |
| // right now — its raw defect if that correction has not finished generating, its real fix | |
| // once it has. Not itself the View's own queuing (private, unreachable from here), but the | |
| // exact fact that queuing is what closes: without a wait, a second correction's search | |
| // would read the first one's still-unfixed defect; with it, the first is already composited. | |
| let raceExtent = CGRect(x: 0, y: 0, width: 2000, height: 2000) | |
| let raceBackground = SIMD3<Float>(0.30, 0.30, 0.30) | |
| let raceDefectColor = SIMD3<Float>(0.90, 0.90, 0.90) | |
| let raceDefectRect = CGRect(x: 900, y: 900, width: 80, height: 80) | |
| let raceScene = flatBlock(raceDefectColor, raceDefectRect) | |
| .composited(over: flatBlock(raceBackground, raceExtent)) | |
| let correctionA = Correction(stroke: [CGPoint(x: 0.47, y: 0.53), CGPoint(x: 0.49, y: 0.53)], | |
| radius: 40) | |
| var raceSettings = PipelineSettings() | |
| raceSettings.corrections = [correctionA] | |
| let anotherCorrectionID = UUID() | |
| func sampleAtDefect(_ image: CIImage) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| Pipeline.measureContext.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: raceDefectRect.midX, y: raceDefectRect.midY, | |
| width: 1, height: 1), format: .RGBAf, | |
| colorSpace: nil) | |
| return px[0] | |
| } | |
| // Measured through the SAME render path rather than compared to the raw constructor | |
| // arguments: `CIColor(red:green:blue:)` is not necessarily read back in the linear working | |
| // space `sampleAtDefect` renders through, and only a value taken through that same pipeline | |
| // on both sides is meaningful — the exact reason the tone-match fixture above does the same. | |
| let raceDefectReference = sampleAtDefect(flatBlock(raceDefectColor, raceExtent)) | |
| let raceBackgroundReference = sampleAtDefect(flatBlock(raceBackground, raceExtent)) | |
| let sourceBeforeGeneration = Pipeline.correctionSource(raceScene, settings: raceSettings, | |
| excluding: anotherCorrectionID) | |
| let stillRaw = sampleAtDefect(sourceBeforeGeneration) | |
| report(abs(stillRaw - raceDefectReference) < 0.02, | |
| String(format: "fixture: before A finishes generating, the source a second correction " | |
| + "would read still carries A's own raw defect (%.3f, wanted ~%.3f)", | |
| stillRaw, raceDefectReference)) | |
| // A blocking bridge for a synchronous check: the semaphore is the real synchronisation. | |
| struct UncheckedBox<T>: @unchecked Sendable { let value: T } | |
| let boxed = UncheckedBox(value: (correctionA, raceScene, raceSettings)) | |
| let semaphore = DispatchSemaphore(value: 0) | |
| Task { | |
| let (correction, source, settings) = boxed.value | |
| await CorrectionRenderer.generate(correction, source: source, settings: settings) | |
| semaphore.signal() | |
| } | |
| semaphore.wait() | |
| defer { CorrectionCache.discard([correctionA.id]) } | |
| let sourceAfterGeneration = Pipeline.correctionSource(raceScene, settings: raceSettings, | |
| excluding: anotherCorrectionID) | |
| let afterFix = sampleAtDefect(sourceAfterGeneration) | |
| report(abs(afterFix - raceDefectReference) > 0.05, | |
| String(format: "and the check discriminates: once A has actually persisted, the same " | |
| + "read no longer carries the raw defect (%.3f)", afterFix)) | |
| report(abs(afterFix - raceBackgroundReference) < 0.05, | |
| String(format: "— it reads close to the clean background instead (%.3f, wanted ~%.3f): " | |
| + "exactly what waiting for A before capturing a source buys a correction " | |
| + "painted moments later", afterFix, raceBackgroundReference)) | |
| // A correction deleted while its own generation is still running must never resurrect a | |
| // patch on disk afterwards — `generate` checks cancellation immediately before persisting, | |
| // so cancelling before it gets there (a plain, non-throwing `Task`, cancelled the instant | |
| // after creation — the GPU search itself costs far more than that gap) is enough to prove it. | |
| let cancelDefect = Correction(stroke: [CGPoint(x: 0.47, y: 0.53), CGPoint(x: 0.49, y: 0.53)], | |
| radius: 40) | |
| struct UncheckedRaceBox<T>: @unchecked Sendable { let value: T } | |
| let cancelBoxed = UncheckedRaceBox(value: (cancelDefect, raceScene)) | |
| let cancelSemaphore = DispatchSemaphore(value: 0) | |
| let cancelTask = Task { | |
| let (correction, source) = cancelBoxed.value | |
| _ = await CorrectionRenderer.generate(correction, source: source, settings: PipelineSettings()) | |
| cancelSemaphore.signal() | |
| } | |
| cancelTask.cancel() | |
| cancelSemaphore.wait() | |
| let survivedCancellation = CorrectionCache.load(cancelDefect.id) != nil | |
| defer { CorrectionCache.discard([cancelDefect.id]) } | |
| report(!survivedCancellation, | |
| "a correction cancelled before its own generation finishes never persists a patch — " | |
| + "the guard that stops a delete-during-generation resurrecting one on disk afterwards") | |
| // `contextRing` must never reach past the frame: unlike `contextRect` (already clamped), | |
| // nothing stopped it sampling fabricated content near an edge, on the destination side of | |
| // both the search kernel and the tone match — a routine place for scan dust to sit. | |
| let edgeExtent = CGRect(x: 0, y: 0, width: 2000, height: 2000) | |
| let edgeCentre = CGPoint(x: 40, y: 1000) | |
| let edgeHole = CGRect(x: 0, y: 960, width: 200, height: 80) | |
| let edgeRing = CorrectionRenderer.contextRing(centre: edgeCentre, hole: edgeHole, extent: edgeExtent) | |
| report(edgeRing.minX >= edgeExtent.minX && edgeRing.maxX <= edgeExtent.maxX | |
| && edgeRing.minY >= edgeExtent.minY && edgeRing.maxY <= edgeExtent.maxY, | |
| String(format: "a context ring near the frame's edge stays fully inside it " | |
| + "(minX=%.1f maxX=%.1f, frame 0...%.0f)", edgeRing.minX, edgeRing.maxX, | |
| edgeExtent.width)) | |
| let unclampedSide = min(max(edgeHole.width, edgeHole.height) * 3, | |
| min(edgeExtent.width, edgeExtent.height)) | |
| report(edgeCentre.x - unclampedSide / 2 < edgeExtent.minX && edgeRing.width < unclampedSide, | |
| String(format: "and the check discriminates: the unclamped formula this replaces " | |
| + "would have reached past that same edge (side %.1f shrunk to %.1f)", | |
| unclampedSide, edgeRing.width)) | |
| // The stroke mask's own radius must scale with the preview, or a stroke drawn at | |
| // full-resolution width on a shrunk tile balloons far past the brushed area — invisible at | |
| // 100%/export, where scale is always 1, which is exactly why this stayed unnoticed. | |
| let maskCorrection = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 30) | |
| let fullTile = CorrectionRenderer.contextRect(for: maskCorrection, in: extent) | |
| let fullMask = CorrectionRenderer.blendMask(for: maskCorrection, imageExtent: extent, tileRect: fullTile) | |
| func whiteFraction(_ mask: CIImage, in rect: CGRect) -> Float { | |
| meanColor(of: mask, in: rect, context: Pipeline.measureContext)?.x ?? -1 | |
| } | |
| let fullFraction = whiteFraction(fullMask, in: fullTile) | |
| let previewScale: CGFloat = 0.25 | |
| let previewExtent = CGRect(x: 0, y: 0, width: extent.width * previewScale, | |
| height: extent.height * previewScale) | |
| let previewTile = CGRect(x: fullTile.minX * previewScale, y: fullTile.minY * previewScale, | |
| width: fullTile.width * previewScale, height: fullTile.height * previewScale) | |
| let previewMask = CorrectionRenderer.blendMask(for: maskCorrection, imageExtent: previewExtent, | |
| tileRect: previewTile, scale: previewScale) | |
| let previewFraction = whiteFraction(previewMask, in: previewTile) | |
| report(abs(fullFraction - previewFraction) < 0.05, | |
| String(format: "the stroke mask covers the same fraction of its tile at full " | |
| + "resolution and at a 0.25 reduced preview (%.3f vs %.3f)", fullFraction, | |
| previewFraction)) | |
| let unscaledPreviewMask = CorrectionRenderer.blendMask(for: maskCorrection, | |
| imageExtent: previewExtent, | |
| tileRect: previewTile) | |
| let unscaledPreviewFraction = whiteFraction(unscaledPreviewMask, in: previewTile) | |
| report(unscaledPreviewFraction > fullFraction * 1.5, | |
| String(format: "and the check discriminates: without scaling the radius, the same " | |
| + "reduced tile reads a far larger white fraction (%.3f) — the bug this fixes", | |
| unscaledPreviewFraction)) | |
| // The tone bias now follows a real gradient across the ring instead of averaging it into | |
| // one flat number: a destination ring shading linearly left to right, against a flat source. | |
| func gradientImage(_ rect: CGRect, from: SIMD3<Float>, to: SIMD3<Float>) -> CIImage { | |
| let w = max(Int(rect.width.rounded()), 1), h = max(Int(rect.height.rounded()), 1) | |
| var raw = [Float](repeating: 0, count: w * h * 4) | |
| for y in 0..<h { | |
| for x in 0..<w { | |
| let t = Float(x) / Float(max(w - 1, 1)) | |
| let c = from + (to - from) * t | |
| let i = (y * w + x) * 4 | |
| raw[i] = c.x; raw[i + 1] = c.y; raw[i + 2] = c.z; raw[i + 3] = 1 | |
| } | |
| } | |
| return raw.withUnsafeBytes { | |
| CIImage(bitmapData: Data($0), bytesPerRow: w * 16, size: rect.size, format: .RGBAf, | |
| colorSpace: nil) | |
| }.transformed(by: CGAffineTransform(translationX: rect.minX, y: rect.minY)) | |
| } | |
| let gradRing = CGRect(x: 0, y: 0, width: 400, height: 400) | |
| let gradientSourceRing = CGRect(x: 1000, y: 0, width: 400, height: 400) | |
| let darkSide = SIMD3<Float>(0.10, 0.10, 0.10), lightSide = SIMD3<Float>(0.30, 0.30, 0.30) | |
| let gradientScene = gradientImage(gradRing, from: darkSide, to: lightSide) | |
| .composited(over: flatBlock(darkSide, gradientSourceRing) | |
| .composited(over: flatBlock(.zero, CGRect(x: 0, y: 0, width: 1400, height: 400)))) | |
| let field = CorrectionRenderer.toneGradient(destinationRing: gradRing, sourceRing: gradientSourceRing, | |
| source: gradientScene, excludingHole: nil, | |
| context: Pipeline.measureContext) | |
| let expectedSlope: Float = (lightSide.x - darkSide.x) / Float(gradRing.width) | |
| report(abs(field.gradX.x - expectedSlope) < 0.0001, | |
| String(format: "the tone field's horizontal gradient tracks a real light gradient " | |
| + "across the ring (measured %.5f/px, expected ~%.5f/px)", field.gradX.x, | |
| expectedSlope)) | |
| report(abs(field.gradY.x) < 0.0001, | |
| String(format: "and the check discriminates on axis: no vertical gradient exists in " | |
| + "this scene, and none is measured (%.5f/px)", field.gradY.x)) | |
| let testPatch = flatBlock(.zero, gradRing) | |
| let biasedPatch = CorrectionRenderer.applyToneField(testPatch, field: field, | |
| origin: CGPoint(x: gradRing.midX, y: gradRing.midY)) | |
| func sampleAt(_ image: CIImage, _ p: CGPoint) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| Pipeline.measureContext.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: p.x, y: p.y, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| let leftEdgeBias = sampleAt(biasedPatch, CGPoint(x: gradRing.minX + 5, y: gradRing.midY)) | |
| let rightEdgeBias = sampleAt(biasedPatch, CGPoint(x: gradRing.maxX - 5, y: gradRing.midY)) | |
| report(rightEdgeBias - leftEdgeBias > 0.15, | |
| String(format: "and applying the field to a patch varies the bias spatially — left " | |
| + "edge %.3f vs right edge %.3f — not a single number averaged across it", | |
| leftEdgeBias, rightEdgeBias)) | |
| let flatBiased = testPatch.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputBiasVector": CIVector(x: CGFloat(field.base.x), y: CGFloat(field.base.y), | |
| z: CGFloat(field.base.z), w: 0), | |
| ]) | |
| let flatLeft = sampleAt(flatBiased, CGPoint(x: gradRing.minX + 5, y: gradRing.midY)) | |
| let flatRight = sampleAt(flatBiased, CGPoint(x: gradRing.maxX - 5, y: gradRing.midY)) | |
| report(abs(flatRight - flatLeft) < 0.001, | |
| "and the check discriminates: the flat bias this replaces would read the same value " | |
| + "at both edges, blind to the gradient the field-aware version follows") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// A tone curve: control points and the interpolation that joins them. | |
| /// Natural cubic spline (C² curvature continuity), not monotone: curvature breaks cause visible micro-contrast artifacts, so occasional overshoot is the accepted trade-off. | |
| struct Curve: Codable, Equatable, Hashable, Sendable { | |
| /// Endpoints are editable points like any other, so black can be lifted and white lowered. | |
| var points: [CGPoint] = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1)] | |
| static let neutral = Curve() | |
| var isNeutral: Bool { self == .neutral } | |
| // MARK: - Editing | |
| mutating func add(_ point: CGPoint) { | |
| points.append(clamped(point)) | |
| points.sort { $0.x < $1.x } | |
| } | |
| /// Prevents a point from crossing its neighbours; abscissa ordering is a spline invariant. | |
| mutating func move(_ index: Int, to point: CGPoint) { | |
| guard points.indices.contains(index) else { return } | |
| let margin: CGFloat = 0.001 | |
| let lower = index > 0 ? points[index - 1].x + margin : 0 | |
| let upper = index < points.count - 1 ? points[index + 1].x - margin : 1 | |
| var p = clamped(point) | |
| p.x = min(max(p.x, lower), upper) | |
| points[index] = p | |
| } | |
| mutating func remove(_ index: Int) { | |
| guard points.count > 2, index > 0, index < points.count - 1 else { return } | |
| points.remove(at: index) | |
| } | |
| private func clamped(_ p: CGPoint) -> CGPoint { | |
| CGPoint(x: min(max(p.x, 0), 1), y: min(max(p.y, 0), 1)) | |
| } | |
| // MARK: - Evaluation | |
| /// Second derivatives must be precomputed once here rather than inside `value(at:)`: with | |
| /// `lut()` sampling 1024 points per table, recomputing per call wastes several ms per frame. | |
| struct Evaluated { | |
| let points: [CGPoint] | |
| /// Second derivatives at the nodes, zero at the endpoints: that is what "natural" means. | |
| let m: [CGFloat] | |
| func value(at x: CGFloat) -> CGFloat { | |
| guard points.count > 1 else { return x } | |
| if x <= points[0].x { return points[0].y } | |
| if x >= points[points.count - 1].x { return points[points.count - 1].y } | |
| guard let i = (0..<(points.count - 1)).last(where: { points[$0].x <= x }) else { | |
| return points[0].y | |
| } | |
| let p0 = points[i], p1 = points[i + 1] | |
| let h = p1.x - p0.x | |
| guard h > 0 else { return p0.y } | |
| // Classic form: y_i + b·t + M_i/2·t² + (M_{i+1} − M_i)/(6h)·t³ | |
| let t = x - p0.x | |
| let b = (p1.y - p0.y) / h - h * (2 * m[i] + m[i + 1]) / 6 | |
| return p0.y + b * t + m[i] / 2 * t * t + (m[i + 1] - m[i]) / (6 * h) * t * t * t | |
| } | |
| /// Baking into a table: this is what goes to the GPU. | |
| func lut(size: Int = Curve.lutSize) -> [Float] { | |
| (0..<size).map { Float(value(at: CGFloat($0) / CGFloat(size - 1))) } | |
| } | |
| } | |
| /// Call once before a series of evaluations. | |
| func evaluated() -> Evaluated { | |
| Evaluated(points: points, m: secondDerivatives) | |
| } | |
| /// Solved by a Thomas sweep over the tridiagonal system; endpoints stay zero (the "natural" | |
| /// condition), so the curve extends as a straight line at the edges. | |
| var secondDerivatives: [CGFloat] { | |
| let n = points.count | |
| guard n > 2 else { return [CGFloat](repeating: 0, count: max(n, 1)) } | |
| let h = (0..<(n - 1)).map { points[$0 + 1].x - points[$0].x } | |
| var diagonal = [CGFloat](repeating: 0, count: n) | |
| var rhs = [CGFloat](repeating: 0, count: n) | |
| var upper = [CGFloat](repeating: 0, count: n) | |
| for i in 1..<(n - 1) { | |
| guard h[i - 1] > 0, h[i] > 0 else { continue } | |
| let slopeAfter = (points[i + 1].y - points[i].y) / h[i] | |
| let slopeBefore = (points[i].y - points[i - 1].y) / h[i - 1] | |
| diagonal[i] = 2 * (h[i - 1] + h[i]) | |
| upper[i] = h[i] | |
| rhs[i] = 6 * (slopeAfter - slopeBefore) | |
| } | |
| // Forward sweep: elimination of the sub-diagonal term. | |
| for i in 2..<(n - 1) where diagonal[i - 1] != 0 { | |
| let factor = h[i - 1] / diagonal[i - 1] | |
| diagonal[i] -= factor * upper[i - 1] | |
| rhs[i] -= factor * rhs[i - 1] | |
| } | |
| // Back substitution. | |
| var m = [CGFloat](repeating: 0, count: n) | |
| for i in stride(from: n - 2, through: 1, by: -1) where diagonal[i] != 0 { | |
| m[i] = (rhs[i] - upper[i] * m[i + 1]) / diagonal[i] | |
| } | |
| return m | |
| } | |
| /// One-off evaluation for drawing; use `evaluated()` for a series so tangents are shared. | |
| func value(at x: CGFloat) -> CGFloat { evaluated().value(at: x) } | |
| /// Sampled on the Swift side since an arbitrary-point spline has no finite `float` form the | |
| /// kernel could evaluate directly; the kernel reads this as a texture instead. | |
| func lut(size: Int = Curve.lutSize) -> [Float] { evaluated().lut(size: size) } | |
| static let lutSize = 1024 | |
| } | |
| /// The colour surface onto a curve: one control point at mid-scale, the print's filtration. Acts | |
| /// after the inversion, so it tints an already-toned image and never moves the conversion. | |
| extension Curve { | |
| /// Abscissa of the single point the colour sliders own. | |
| static let midAbscissa: CGFloat = 0.5 | |
| /// The room the mid point has: the spline folds at exactly 1/3, and the shoulder approaches | |
| /// this asymptote without ever reaching it, so monotonicity holds by construction. | |
| static let midLimit: Float = 0.30 | |
| /// What one unit of travel is worth before the shoulder. Two rooms, so full travel spends | |
| /// 86.47 % of what is available and the last stretch of the track still moves. | |
| static let midScale: Float = 2 * midLimit | |
| /// Maps the travel into `(-midLimit, midLimit)`: identity to first order near zero, an | |
| /// asymptote never reached. Compresses a setting, never a pixel. | |
| static func soften(_ travel: Float) -> Float { | |
| let magnitude = midLimit * (1 - exp(-abs(travel) * midScale / midLimit)) | |
| return travel < 0 ? -magnitude : magnitude | |
| } | |
| /// The shoulder's inverse, held at the track's stop past the asymptote: an offset from outside | |
| /// the track still has to have a position a handle can be drawn at. | |
| static func harden(_ offset: Float) -> Float { | |
| let share = min(abs(offset) / midLimit, 1) | |
| guard share < 1 else { return offset < 0 ? -1 : 1 } | |
| let travel = min(-log(1 - share) * midLimit / midScale, 1) | |
| return offset < 0 ? -travel : travel | |
| } | |
| private func index(at x: CGFloat) -> Int? { | |
| points.firstIndex { abs($0.x - x) < 0.001 } | |
| } | |
| /// The slider's travel, -1 to 1, through the shoulder that keeps the spline increasing. | |
| /// Normalised because an ordinate of 0.26 means nothing to a hand; a share of the travel does. | |
| var midOffset: Float { | |
| get { | |
| let y = index(at: Self.midAbscissa).map { Float(points[$0].y - Self.midAbscissa) } ?? 0 | |
| return Self.harden(y) | |
| } | |
| set { write(CGFloat(Self.soften(newValue)), at: Self.midAbscissa) } | |
| } | |
| /// Places, moves or removes the one point at `x`. Zero removes it, so a neutral slider leaves a | |
| /// curve indistinguishable from one never touched. | |
| private mutating func write(_ offset: CGFloat, at x: CGFloat) { | |
| let existing = index(at: x) | |
| guard abs(offset) > 1e-6 else { | |
| if let existing { points.remove(at: existing) } | |
| return | |
| } | |
| let point = CGPoint(x: x, y: min(max(x + offset, 0), 1)) | |
| if let existing { points[existing] = point } else { add(point) } | |
| } | |
| } | |
| struct CurveSet: Codable, Equatable, Hashable, Sendable { | |
| /// Travel of the contrast slider, composed into the linked table when it is baked. A closed | |
| /// family rather than control points: see `Contrast`. | |
| var contrast: Float = 0 | |
| var luma = Curve() | |
| var linked = Curve() | |
| var red = Curve() | |
| var green = Curve() | |
| var blue = Curve() | |
| subscript(channel: LevelsChannel) -> Curve { | |
| get { | |
| switch channel { | |
| case .luma: luma | |
| case .linked: linked | |
| case .red: red | |
| case .green: green | |
| case .blue: blue | |
| } | |
| } | |
| set { | |
| switch channel { | |
| case .luma: luma = newValue | |
| case .linked: linked = newValue | |
| case .red: red = newValue | |
| case .green: green = newValue | |
| case .blue: blue = newValue | |
| } | |
| } | |
| } | |
| var touched: Set<LevelsChannel> { | |
| Set(LevelsChannel.allCases.filter { !self[$0].isNeutral }) | |
| } | |
| /// Lets the kernel skip a GPU pass and table sampling when every curve is the identity. | |
| var isNeutral: Bool { touched.isEmpty && contrast == 0 } | |
| static let neutral = CurveSet() | |
| // MARK: - Tables for the GPU | |
| /// Packs the four tables into one pixel's components (R = linked, G = red, B = green, A = blue). | |
| /// `colorSpace: nil` is required, or Core Image would convert the table values as colours. | |
| func rgbLUT() -> CIImage? { | |
| let size = Curve.lutSize | |
| var tables = [linked, red, green, blue].map { $0.evaluated().lut() } | |
| // Contrast rides on the linked table: one transfer function per channel reaches the GPU, | |
| // and the two settings compose without either owning the other's control points. | |
| if contrast != 0 { | |
| tables[0] = tables[0].map { Contrast.apply($0, travel: contrast) } | |
| } | |
| var pixels = [Float](repeating: 0, count: size * 4) | |
| for i in 0..<size { | |
| for c in 0..<4 { pixels[i * 4 + c] = tables[c][i] } | |
| } | |
| return image(from: pixels, size: size) | |
| } | |
| /// A separate table rather than a second image row, which would risk silent vertical | |
| /// interpolation between rows. | |
| func lumaLUT() -> CIImage? { | |
| let size = Curve.lutSize | |
| let table = luma.evaluated().lut() | |
| var pixels = [Float](repeating: 0, count: size * 4) | |
| for i in 0..<size { | |
| pixels[i * 4] = table[i] | |
| pixels[i * 4 + 3] = 1 | |
| } | |
| return image(from: pixels, size: size) | |
| } | |
| // MARK: - Checks | |
| /// The two slider surfaces at full travel. Monotonicity is what must hold — neither gesture may | |
| /// fold the curve — and the ends must stay put, which is what makes clipping impossible. | |
| static func sliderChecks() -> [(Bool, String)] { | |
| func increases(_ curve: Curve) -> Bool { | |
| let table = curve.lut() | |
| return zip(table, table.dropFirst()).allSatisfy { $1 >= $0 - 1e-6 } | |
| } | |
| func endsHeld(_ curve: Curve) -> Bool { | |
| let table = curve.lut() | |
| return abs(table[0]) < 1e-6 && abs(table[table.count - 1] - 1) < 1e-6 | |
| } | |
| var out: [(Bool, String)] = [] | |
| for travel in [Float(-1), 1] { | |
| var mid = Curve(); mid.midOffset = travel | |
| out.append((increases(mid) && endsHeld(mid), String(format: | |
| "a colour slider at %+.0f %% keeps its channel curve increasing, ends untouched", | |
| travel * 100))) | |
| } | |
| out.append(contentsOf: Contrast.selfCheck()) | |
| // The twin: past the measured limits both fold, so it is the bounds that hold the lines | |
| // above and not the shape of the point set. | |
| let over = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 0.5, y: 0.90), | |
| CGPoint(x: 1, y: 1)]) | |
| out.append((!increases(over), | |
| "and the check discriminates: past that offset the colour curve folds")) | |
| // A slider must read back what it wrote, or its handle jumps the next time the pane opens. | |
| var trip = Curve(); trip.midOffset = 0.42 | |
| out.append((abs(trip.midOffset - 0.42) < 1e-5, | |
| "a slider reads back exactly what it wrote")) | |
| // Zero must leave no point behind, or a reset would look done while the curve stayed bent. | |
| var cleared = Curve(); cleared.midOffset = 0.5; cleared.midOffset = 0 | |
| out.append((cleared.isNeutral, | |
| "and returning to zero leaves the curve neutral, not merely flat")) | |
| return out | |
| } | |
| /// Verifies curvature continuity, the property that justifies the natural spline. Excludes a | |
| /// monotonicity check on purpose: overshoot is an accepted trade-off, not a defect. | |
| static func splineChecks() -> (ok: Bool, report: String) { | |
| var lines: [String] = [] | |
| var ok = true | |
| for (passed, text) in sliderChecks() { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| // A fast rise, a near-plateau, then a pick-up. | |
| var tricky = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 0.40, y: 0.80), | |
| CGPoint(x: 0.45, y: 0.81), CGPoint(x: 1, y: 1)]) | |
| let evaluated = tricky.evaluated() | |
| // Checks the tridiagonal system directly rather than measuring curvature numerically, | |
| // which would also capture its normal variation inside an interval. | |
| let m = tricky.secondDerivatives | |
| let pts = tricky.points | |
| let h = (0..<(pts.count - 1)).map { pts[$0 + 1].x - pts[$0].x } | |
| var worstResidual: CGFloat = 0 | |
| for i in 1..<(pts.count - 1) { | |
| let slopeAfter = (pts[i + 1].y - pts[i].y) / h[i] | |
| let slopeBefore = (pts[i].y - pts[i - 1].y) / h[i - 1] | |
| let residual = h[i - 1] * m[i - 1] + 2 * (h[i - 1] + h[i]) * m[i] + h[i] * m[i + 1] | |
| - 6 * (slopeAfter - slopeBefore) | |
| worstResidual = max(worstResidual, abs(residual)) | |
| } | |
| // Zero curvature at the endpoints gives a straight-line extension. | |
| let naturalEnds = abs(m[0]) < 1e-12 && abs(m[m.count - 1]) < 1e-12 | |
| let smooth = worstResidual < 1e-9 && naturalEnds | |
| ok = ok && smooth | |
| lines.append(String(format: " %@ spline C² : system satisfied (residual %.2e), zero curvature at the edges", | |
| smooth ? "OK " : "FAIL", worstResidual)) | |
| // Continuous first derivative at the nodes reads as a curve without a break. | |
| let step: CGFloat = 1e-4 | |
| var worstSlopeJump: CGFloat = 0 | |
| for node in pts.dropFirst().dropLast() { | |
| let before = (evaluated.value(at: node.x) - evaluated.value(at: node.x - step)) / step | |
| let after = (evaluated.value(at: node.x + step) - evaluated.value(at: node.x)) / step | |
| worstSlopeJump = max(worstSlopeJump, abs(after - before)) | |
| } | |
| let continuous = worstSlopeJump < 0.01 | |
| ok = ok && continuous | |
| lines.append(String(format: " %@ continuous slope at the nodes (worst gap %.2e)", | |
| continuous ? "OK " : "FAIL", worstSlopeJump)) | |
| // A spline that smoothed past the clicked points would make editing unpredictable. | |
| let onPoints = tricky.points.allSatisfy { abs(evaluated.value(at: $0.x) - $0.y) < 1e-9 } | |
| ok = ok && onPoints | |
| lines.append(" \(onPoints ? "OK " : "FAIL") the curve passes through every clicked point") | |
| // A neutral curve must remain the identity after baking. | |
| let lut = Curve.neutral.lut() | |
| let identity = lut.indices.allSatisfy { | |
| abs(lut[$0] - Float($0) / Float(Curve.lutSize - 1)) < 1e-6 | |
| } | |
| ok = ok && identity | |
| lines.append(" \(identity ? "OK " : "FAIL") neutral curve = identity after baking") | |
| // Aligned points must give exactly a straight line, or adding a point mid-curve would | |
| // distort a neutral image. | |
| let aligned = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 0.5, y: 0.5), | |
| CGPoint(x: 1, y: 1)]).evaluated() | |
| let straight = stride(from: CGFloat(0), through: 1, by: 0.05) | |
| .allSatisfy { abs(aligned.value(at: $0) - $0) < 1e-9 } | |
| ok = ok && straight | |
| lines.append(" \(straight ? "OK " : "FAIL") three aligned points give an exact straight line") | |
| // Abscissa ordering is a spline invariant: a point must not cross its neighbours. | |
| tricky.move(1, to: CGPoint(x: 0.99, y: 0.5)) | |
| let ordered = zip(tricky.points, tricky.points.dropFirst()).allSatisfy { $0.x < $1.x } | |
| ok = ok && ordered | |
| lines.append(" \(ordered ? "OK " : "FAIL") a moved point does not cross its neighbours") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// What `Colour` is: its own mechanism, shouldered so it cannot fold a curve, and reaching no | |
| /// field the conversion owns. The shoulder, the decoupling and what an old sidecar still draws. | |
| static func colourFilterChecks() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let context = CIContext(options: [.workingColorSpace: NSNull()]) | |
| /// A colour patch through the whole per-pixel chain, so the three tables really separate. | |
| func render(_ settings: PipelineSettings) -> SIMD3<Float> { | |
| let source = CIImage(color: CIColor(red: 0.30, green: 0.12, blue: 0.05)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| context.render(Pipeline.apply(source, settings: settings.perPixelOnly()), | |
| toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return SIMD3(px[0], px[1], px[2]) | |
| } | |
| // MARK: The shoulder | |
| // The fold is at exactly 1/3, so an asymptote under it makes monotonicity structural | |
| // rather than a bound someone has to keep checking. | |
| let fold: Float = 1.0 / 3 | |
| let full = Curve.soften(1) | |
| report(full < Curve.midLimit && Curve.midLimit < fold, String(format: | |
| "full travel reaches %.4f of the %.2f available (%.2f %%), and the asymptote itself " | |
| + "stands %.4f short of the fold at %.4f", | |
| full, Curve.midLimit, 100 * full / Curve.midLimit, fold - Curve.midLimit, fold)) | |
| // Its twin: without the shoulder the same travel leaves the room altogether, so it is the | |
| // mapping that holds the line above and not the size of the track. | |
| let unsoftened = Float(1) * Curve.midScale | |
| report(unsoftened > Curve.midLimit && Curve.soften(1) < Curve.midLimit, | |
| String(format: "and the check discriminates: without the shoulder full travel would " | |
| + "be %.2f, past the %.2f available", unsoftened, Curve.midLimit)) | |
| /// Where on the track a share of what full travel gives is reached. | |
| func travel(forShare share: Float) -> Float { | |
| let wanted = share * full | |
| return Curve.harden(wanted) | |
| } | |
| report(travel(forShare: 0.5) < 0.5 && travel(forShare: 0.9) < 0.9, String(format: | |
| "the gesture front-loads: half the effect at %.3f of the track against 0.500 straight, " | |
| + "nine tenths at %.3f against 0.900", travel(forShare: 0.5), travel(forShare: 0.9))) | |
| // Identity to first order, or the first half of the gesture would not behave as it reads. | |
| let small = Float(0.02) | |
| report(abs(Curve.soften(small) - small * Curve.midScale) < small * Curve.midScale * 0.1, | |
| String(format: "near zero the shoulder is imperceptible (%.5f for %.5f)", | |
| Curve.soften(small), small * Curve.midScale)) | |
| report(Curve.soften(-0.4) == -Curve.soften(0.4) && Curve.soften(0.5) > Curve.soften(0.4), | |
| "the shoulder is odd and strictly increasing") | |
| // A slider must read back what it wrote, or its handle jumps the next time the pane opens. | |
| var trip = Curve(); trip.midOffset = 0.42 | |
| report(abs(trip.midOffset - 0.42) < 1e-5, | |
| String(format: "and a slider reads back exactly what it wrote (%.6f)", trip.midOffset)) | |
| // MARK: Colour reaches no gain | |
| // The central claim, taken on a frame already converted: both filters at full travel move | |
| // the picture and leave what a balance button wrote exactly where it stands. | |
| var converted = PipelineSettings() | |
| converted.gains.stops = SIMD3(0.83, 0, -1.42) | |
| let written = converted.gains | |
| var filtered = converted | |
| filtered.curves.green.midOffset = 1 | |
| filtered.curves.blue.midOffset = -1 | |
| let tinted = render(filtered) | |
| let bare = render(converted) | |
| let moved = ((tinted - bare) * (tinted - bare)).max().squareRoot() | |
| report(filtered.gains == written && moved > 0.01, String(format: | |
| "both filters at full travel move the render by %.4f and leave gains.stops on " | |
| + "(%.2f, %.2f, %.2f), not one bit off what the balance wrote", moved, | |
| filtered.gains.stops.x, filtered.gains.stops.y, filtered.gains.stops.z)) | |
| // Its twin: what a recoupled row would write into that same state, so the line can go red. | |
| var recoupled = converted | |
| recoupled.gains.stops.y = 1 | |
| report(recoupled.gains != written, String(format: | |
| "and the check discriminates: wired onto the conversion the same gesture would move " | |
| + "the green gain from %.2f to %.2f stop", written.stops.y, recoupled.gains.stops.y)) | |
| for (passed, text) in paneChecks() { report(passed, text) } | |
| // MARK: What an old sidecar still draws | |
| // The state the sliders write, through the very property they write it with. | |
| var graded = PipelineSettings() | |
| graded.curves.red.midOffset = 0.6 | |
| graded.curves.green.midOffset = -0.4 | |
| graded.curves.blue.midOffset = 0.25 | |
| guard let written = try? SettingsCodec.encode(graded), | |
| let read = try? SettingsCodec.decode(written) else { | |
| return (false, " FAIL a sidecar carrying the colour mid-point is not writable") | |
| } | |
| report(read.version == SettingsCodec.version && read.settings.curves == graded.curves, | |
| "a sidecar carrying the colour mid-point reads back unchanged, at version " | |
| + "\(read.version): no rename, no migration") | |
| // A file's own spelling against the same state built in memory: two constructions that | |
| // share no code, so equal bits say the decoder reaches the stage by the names on disk. | |
| let mid: [CGFloat] = [0.7, 0.35, 0.55] | |
| let literal = """ | |
| {"version": \(SettingsCodec.version), "settings": {"curves": {\ | |
| "red": {"points": [[0, 0], [0.5, \(mid[0])], [1, 1]]}, \ | |
| "green": {"points": [[0, 0], [0.5, \(mid[1])], [1, 1]]}, \ | |
| "blue": {"points": [[0, 0], [0.5, \(mid[2])], [1, 1]]}}}} | |
| """ | |
| var built = PipelineSettings() | |
| for (channel, y) in zip([LevelsChannel.red, .green, .blue], mid) { | |
| built.curves[channel].points = [CGPoint(x: 0, y: 0), CGPoint(x: 0.5, y: y), | |
| CGPoint(x: 1, y: 1)] | |
| } | |
| guard let typed = try? SettingsCodec.decode(Data(literal.utf8)) else { | |
| report(false, "a hand-written sidecar fails to decode") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| let after = render(typed.settings) | |
| report(after == render(built), String(format: | |
| "a hand-written sidecar renders to the same bits as the same state built in memory " | |
| + "(R %.7f G %.7f B %.7f)", after.x, after.y, after.z)) | |
| // The twin: the same document with the three keys taken out. The curve pass is skipped when | |
| // every table is the identity, so a stage that stopped running would land exactly here. | |
| guard var object = try? JSONSerialization.jsonObject(with: Data(literal.utf8)) | |
| as? [String: Any], | |
| var settingsObject = object["settings"] as? [String: Any], | |
| var curvesObject = settingsObject["curves"] as? [String: Any] else { | |
| report(false, "the written document carries no curves to strip") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| for key in ["red", "green", "blue"] { curvesObject.removeValue(forKey: key) } | |
| settingsObject["curves"] = curvesObject | |
| object["settings"] = settingsObject | |
| guard let stripped = try? JSONSerialization.data(withJSONObject: object), | |
| let bare = try? SettingsCodec.decode(stripped) else { | |
| report(false, "a document with the three keys taken out fails to decode") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| let neutral = render(bare.settings) | |
| let gap = ((after - neutral) * (after - neutral)).max().squareRoot() | |
| report(bare.settings.curves.touched.isEmpty && gap > 0.01, String(format: | |
| "and the check discriminates: dropping the three keys neutralises the pass and moves " | |
| + "the same pixel by %.4f", gap)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// The decoupling read as text, because what must hold is a wiring and not a value: a block | |
| /// rewired onto the conversion renders plausibly and no number above would say so. | |
| private static func paneChecks() -> [(Bool, String)] { | |
| let path = "Sources/OpenNegative/UI/ColorPane.swift" | |
| guard let pane = SourceFile.text(path) else { | |
| return [(true, "SKIPPED sources absent, the pane's wiring is not readable here")] | |
| } | |
| /// One block of the pane, from its heading to the next one. | |
| func block(_ title: String) -> String { | |
| guard let start = pane.range(of: "DSSection(\"\(title)\"") else { return "" } | |
| let rest = pane[start.upperBound...] | |
| let end = rest.range(of: "DSSection(")?.lowerBound ?? rest.endIndex | |
| return String(rest[..<end]).lowercased() | |
| } | |
| let mount = "gainpanel(channels:" | |
| // The field, never the bare word: a note reading "dialled against" carries the letters of | |
| // `gains` and would decide a wiring check by its prose. | |
| let conversionField = ".gains" | |
| /// What the colour block must be: a mounted panel, on the curves, and nowhere near the | |
| /// conversion's own field. The mount is required so the twin below always bites. | |
| func decoupled(_ text: String) -> Bool { | |
| text.contains(mount) && text.contains("curves") && !text.contains(conversionField) | |
| } | |
| let colour = block("Colour") | |
| return [ | |
| (decoupled(colour), | |
| "the pane's Colour block draws the curves and names no gain, so a finishing gesture " | |
| + "cannot reach the conversion"), | |
| // Adverse by construction: the same mount handed the one binding it must never take. | |
| // A reader that only looked for "curves" would stay green on it. | |
| (!decoupled(colour.replacingOccurrences( | |
| of: mount, with: mount + " $settings.gains.stops, //")), | |
| "and the check discriminates: the same block wired onto the conversion is refused"), | |
| // The extractor really slices, or "no gain here" would be true of an empty string. | |
| (block("Source balance").contains(conversionField), | |
| "and it reads the block asked for: Source balance, next door, does name them"), | |
| ] | |
| } | |
| private func image(from pixels: [Float], size: Int) -> CIImage? { | |
| pixels.withUnsafeBufferPointer { buffer in | |
| guard let base = buffer.baseAddress else { return nil } | |
| return CIImage(bitmapData: Data(bytes: base, count: pixels.count * 4), | |
| bytesPerRow: size * 16, | |
| size: CGSize(width: size, height: 1), | |
| format: .RGBAf, | |
| colorSpace: nil) | |
| } | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Top of the density axis, dialled per photograph. One number gives both constants stage 5 needs: | |
| /// the `pedestal` lifting a decode off zero, where the logarithm has no value, and the guard under it. | |
| enum DensityAxis { | |
| /// How far under the ceiling the guard sits, in density: `log10(8)`, the relation the two | |
| /// constants carry as `0.008 = 8 × 1e-3`, and what places the range's floor. | |
| static let guardOffset = Float(log10(8.0)) | |
| static func pedestal(at ceiling: Float) -> Float { pow(10, -ceiling) } | |
| /// An eighth of the pedestal, exact in binary so −3 stops of gain reach it and never a notch | |
| /// before. Not a floor of its own: one number doing both piles a channel onto a single value. | |
| static func logGuard(at ceiling: Float) -> Float { pedestal(at: ceiling) / 8 } | |
| /// Where the ceiling is dialled. Its floor is where the guard's density meets `Pipeline.dmax`, | |
| /// the per-channel resting white — under it the guard's pile lands inside the picture as grey. | |
| static var range: ClosedRange<Float> { (Pipeline.dmax - guardOffset)...highest } | |
| /// A constant until a use proves it badly calibrated. At it a decade of transmittance spreads | |
| /// over 0.552 of density against 0.301 at rest, and five of six test films pile 16 % to 85 %. | |
| private static let highest: Float = 2.6 | |
| /// The resting ceiling, on the range's floor. Measured at full resolution on eight films: | |
| /// raising it 0.05 multiplies the piled population by 2.6 while widening a decade by 8 %. | |
| static var base: Float { range.lowerBound } | |
| /// True where raising the ceiling BRIGHTENS the render — the opposite of stage 4's sense, the | |
| /// pedestal adding to transmittance where a gain multiplies it. `selfCheck` measures it. | |
| static func brightens(in mode: ConversionMode) -> Bool { mode.invertFlag > 0.5 } | |
| /// Three decimals of density, the unit the per-channel handles read in: the ceiling is a point | |
| /// on that same axis, and stating it otherwise would name a second ruler over one drawing. | |
| static func readout(_ value: Float) -> String { String(format: "%.3f", value) } | |
| } | |
| // MARK: - Checks | |
| extension DensityAxis { | |
| /// A flat target of three chosen transmittances, none of which clips in either mode. Green and | |
| /// blue carry the pair whose separation is being priced; red only keeps the frame plausible. | |
| private static func target(_ values: SIMD3<Float>, side: Int = 4) -> CIImage? { | |
| var px = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| for channel in 0..<3 { px[i * 4 + channel] = values[channel] } | |
| px[i * 4 + 3] = 1 | |
| } | |
| let bytes = px.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: side * 16, | |
| size: CGSize(width: side, height: side), format: .RGBAf, colorSpace: nil) | |
| } | |
| /// The stretch the pedestal compresses hardest, in green and blue. | |
| private static var decadePair: SIMD3<Float> { | |
| SIMD3(0.20, FilmProbe.decade.upperBound, FilmProbe.decade.lowerBound) | |
| } | |
| /// Adverse by construction: the widest pedestal is a sixtieth of 0.5, so no ceiling of the | |
| /// range can open this gap by half — which is what the reading it twins claims for the decade. | |
| private static let brightPair = SIMD3<Float>(0.20, 0.50, 0.20) | |
| /// Stages 4→7 alone at a given ceiling: everything past them is common to every ceiling and | |
| /// would only add its own arithmetic to the direction being read. | |
| private static func rendered(_ ctx: CIContext, _ image: CIImage, | |
| mode: ConversionMode, at ceiling: Float) -> SIMD3<Float> { | |
| var settings = PipelineSettings() | |
| settings.mode = mode | |
| // On the bare axis, not on the mode's rest: a resting black point clips what the ceiling | |
| // pushes under it, and the direction being read would be the black point's, not the axis's. | |
| settings.levels = LevelsSet(luma: .neutral, linked: .neutral, red: .onAxis, green: .onAxis, | |
| blue: .onAxis) | |
| settings.densityCeiling = ceiling | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(Pipeline.apply(image, settings: settings.truncated(before: .curves)), | |
| toBitmap: &px, rowBytes: 16, bounds: CGRect(x: 1, y: 1, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return SIMD3(px[0], px[1], px[2]) | |
| } | |
| /// The signed step that disproves the mode's own direction first: the least where raising | |
| /// brightens, the GREATEST where it darkens, so a fold back shows as the wrong sign either way. | |
| private static func slope(_ ctx: CIContext, _ image: CIImage, mode: ConversionMode) -> Float { | |
| let steps = 16 | |
| let up = brightens(in: mode) | |
| var worst = up ? Float.greatestFiniteMagnitude : -Float.greatestFiniteMagnitude | |
| var previous = rendered(ctx, image, mode: mode, at: range.lowerBound) | |
| for step in 1...steps { | |
| let span = range.upperBound - range.lowerBound | |
| let next = rendered(ctx, image, mode: mode, | |
| at: range.lowerBound + span * Float(step) / Float(steps)) | |
| for channel in 0..<3 { | |
| let delta = next[channel] - previous[channel] | |
| worst = up ? min(worst, delta) : max(worst, delta) | |
| } | |
| previous = next | |
| } | |
| return worst | |
| } | |
| static func selfCheck() -> (ok: Bool, report: String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // The resting pedestal must be the literal the frozen form carries, or the migration's | |
| // identity is measured against a number the live chain does not run. | |
| report(pedestal(at: base) == DensityMigration.frozenPedestal, | |
| String(format: "the resting ceiling %@ gives the pedestal bit for bit: %a against " | |
| + "the frozen %a", readout(base), pedestal(at: base), | |
| DensityMigration.frozenPedestal)) | |
| // The twin, adverse by construction: the same ceiling quoted to three decimals, which is | |
| // what a literal written by hand would carry. | |
| let quoted = pedestal(at: 2.097) | |
| report(quoted != DensityMigration.frozenPedestal, | |
| String(format: "the twin: quoted to three decimals it lands %.2e relative away and " | |
| + "refuses", abs(quoted - DensityMigration.frozenPedestal) / quoted)) | |
| // The range's floor is not chosen: it is where the guard's density reaches the per-channel | |
| // resting white, which is the only ceiling at which the axis and its window end together. | |
| report(abs(base + guardOffset - Pipeline.dmax) < 1e-6, | |
| String(format: "the range starts at %@, where the guard's density (%.5f) is the " | |
| + "resting white (%.3f) exactly", readout(base), | |
| -log10(logGuard(at: base)), Pipeline.dmax)) | |
| // The twin: anywhere else on the range the two part, so the floor is a meeting point and | |
| // not a property every ceiling would satisfy. | |
| report(-log10(logGuard(at: range.upperBound)) - Pipeline.dmax > 0.1, | |
| String(format: "the twin: at the range's top the guard's density stands %.3f past " | |
| + "that white, and the reading refuses", -log10(logGuard(at: range.upperBound)) | |
| - Pipeline.dmax)) | |
| let sampled = 11 | |
| /// The one reading: at how many ceilings of the range that many stops of gain take the | |
| /// pedestal exactly onto the guard. The twin is the same code at another gain. | |
| func landing(_ stops: Float) -> Int { | |
| (0..<sampled).filter { step in | |
| let ceiling = range.lowerBound + (range.upperBound - range.lowerBound) | |
| * Float(step) / Float(sampled - 1) | |
| return pedestal(at: ceiling) * exp2(stops) == logGuard(at: ceiling) | |
| }.count | |
| } | |
| report(landing(-3) == sampled, | |
| "at all \(sampled) ceilings of the range, −3 stops of gain takes the pedestal exactly " | |
| + "onto the guard, the eighth being exact in binary") | |
| // The twin, adverse by construction: the same equality a tenth of a stop away, which a | |
| // comparison loosened into a tolerance would start accepting. | |
| report(landing(-2.9) == 0, | |
| "the twin: −2.9 stops lands on it at \(landing(-2.9)) of them and refuses, so the " | |
| + "identity names one gain rather than a neighbourhood") | |
| guard let image = target(decadePair), let bright = target(brightPair) else { | |
| return (ok, lines.joined(separator: "\n") + "\n FAIL the axis targets were not built") | |
| } | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| // The direction is MEASURED, in every mode, over the whole range: stated instead, a slider | |
| // could be drawn against the kernel while a check still agreed with what it was told. | |
| var measured: [ConversionMode: Float] = [:] | |
| for mode in ConversionMode.allCases { | |
| let step = slope(ctx, image, mode: mode) | |
| let up = brightens(in: mode) | |
| measured[mode] = step | |
| report(up ? step > 0 : step < 0, | |
| String(format: "[%@] raising the ceiling %@ the render at every step of the " | |
| + "range: %@ signed step of the sweep %+.6f", mode.rawValue, | |
| up ? "brightens" : "darkens", up ? "least" : "greatest", step)) | |
| } | |
| // The twin, adverse by construction: one sense for both modes, which is the slider a | |
| // gradient not following the mode would draw — the two measurements have to disagree. | |
| let together = (measured[.negative] ?? 0) * (measured[.positive] ?? 0) | |
| report(together < 0, | |
| String(format: "the twin: one sense for both modes refuses — the same gesture moves " | |
| + "the negative %+.6f and the positive %+.6f", measured[.negative] ?? 0, | |
| measured[.positive] ?? 0)) | |
| /// The one reading, so the twin is the same code on a pair chosen to be out of reach: how | |
| /// far the range opens two transmittances the per-channel window then renders. | |
| func opening(_ image: CIImage) -> (rest: Float, top: Float) { | |
| let at = { (ceiling: Float) -> Float in | |
| let px = rendered(ctx, image, mode: .negative, at: ceiling) | |
| return abs(px[1] - px[2]) | |
| } | |
| return (at(range.lowerBound), at(range.upperBound)) | |
| } | |
| // The trade's other side, through the kernel rather than off the formula: what the range | |
| // buys the two ends of a decade of transmittance, which is a burnt patch's whole contrast. | |
| let decade = opening(image) | |
| report(decade.top > 1.5 * decade.rest, | |
| String(format: "the decade %.3f…%.3f comes out %.4f apart at rest and %.4f at the " | |
| + "range's top, %.2f× the separation", FilmProbe.decade.lowerBound, | |
| FilmProbe.decade.upperBound, decade.rest, decade.top, | |
| decade.top / decade.rest)) | |
| // The twin, adverse by construction: a pair sitting decades above the pedestal, where no | |
| // ceiling has purchase — so the reading above belongs to the compression, not to the range. | |
| let untouched = opening(bright) | |
| report(untouched.top <= 1.5 * untouched.rest, | |
| String(format: "the twin: %.2f…%.2f, which the pedestal barely reaches, opens only " | |
| + "%.2f× and refuses", brightPair[2], brightPair[1], | |
| untouched.top / untouched.rest)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Stages 4→7 in two closed forms — the arithmetic the kernel runs today, and the density-native | |
| /// one — so the migration between them is proved against a known-good oracle rather than asserted. | |
| enum DensityMigration { | |
| // MARK: - The axis | |
| /// The frozen form's pedestal, a literal so the oracle cannot follow a moving resting ceiling: | |
| /// what it replicates has to stand still, or the comparison measures the chain against itself. | |
| static let frozenPedestal: Float = 0.008 | |
| /// Density ceiling that pedestal encodes, derived and never quoted: rounding it to three | |
| /// decimals moves the pedestal by 2·10⁻⁴ relative and the render by 3·10⁻², measured below. | |
| static var referenceCeiling: Float { -log10(frozenPedestal) } | |
| /// Top of the density axis, and the pivot the positive branch re-inverts on: one name for two | |
| /// readers, or the positive mode would need a migration of its own. | |
| static var window: Float { Pipeline.dmax } | |
| /// Post-clip agreement the migration has to hold, over the whole grid. | |
| static let agreement: Float = 2e-4 | |
| // MARK: - The two closed forms | |
| /// Replica of the kernel's `applyLevels`. Its span floor is the one place the identity is | |
| /// inexact, and `Levels.epsilon` keeps every reachable setting two orders clear of it. | |
| static func applyLevels(_ e: Float, _ lv: Levels) -> Float { | |
| pow(max((e - lv.black) / max(lv.white - lv.black, 1e-4), 0), lv.gamma) | |
| } | |
| /// Replica of the kernel's `applyWindow`, run by all five sets: the three-point pass, then the | |
| /// degree-4 Bézier whose two inner ordinates are handles. Skipped on the base pair, bit for bit. | |
| static func applyWindow(_ e: Float, _ lv: Levels) -> Float { | |
| let t = applyLevels(e, lv) | |
| guard !lv.isWindowNeutral else { return t } | |
| let u = min(t, 1) | |
| let v = 1 - u | |
| let curve = lv.shadowOrdinate * 4 * u * v * v * v + 0.5 * 6 * u * u * v * v | |
| + lv.highlightOrdinate * 4 * u * u * u * v + u * u * u * u | |
| return curve + (t - u) | |
| } | |
| /// The per-channel window: the only pass whose input changes unit, hence the only one the | |
| /// migration touches. | |
| static func perChannelWindow(_ e: SIMD3<Float>, _ lv: LevelsSet) -> SIMD3<Float> { | |
| SIMD3(applyWindow(e.x, lv.red), applyWindow(e.y, lv.green), applyWindow(e.z, lv.blue)) | |
| } | |
| /// The same window as the frozen kernel ran it, three points and no Bézier: the oracle has to | |
| /// stand still, or the comparison measures the chain against itself. | |
| static func frozenWindow(_ e: SIMD3<Float>, _ lv: LevelsSet) -> SIMD3<Float> { | |
| SIMD3(applyLevels(e.x, lv.red), applyLevels(e.y, lv.green), applyLevels(e.z, lv.blue)) | |
| } | |
| /// Linked then luma, both reading the window's output — a dimensionless signal, identical in | |
| /// both worlds. The luma factor is clamped as the kernel clamps it. | |
| static func globalPasses(_ e0: SIMD3<Float>, _ lv: LevelsSet) -> SIMD3<Float> { | |
| var e = SIMD3(applyWindow(e0.x, lv.linked), applyWindow(e0.y, lv.linked), | |
| applyWindow(e0.z, lv.linked)) | |
| let y = (e * Pipeline.lumaWeights).sum() | |
| if y > 1e-4 { e *= min(max(applyWindow(y, lv.luma) / y, 0), Pipeline.lumaScaleMax) } | |
| return e | |
| } | |
| /// The frozen kernel: transmittance clamped into `[Tmin, 1]`, density divided by `Dmax`, then | |
| /// the three levels passes. The oracle the new form is judged against. | |
| static func old(_ source: SIMD3<Float>, stops: SIMD3<Float>, levels: LevelsSet, | |
| mode: ConversionMode) -> SIMD3<Float> { | |
| let gain = SIMD3(exp2(stops.x), exp2(stops.y), exp2(stops.z)) | |
| let t = ((source + SIMD3(repeating: frozenPedestal)) * gain) | |
| .clamped(lowerBound: SIMD3(repeating: frozenPedestal / 8), | |
| upperBound: SIMD3(repeating: 1)) | |
| let d = SIMD3(-log10(t.x), -log10(t.y), -log10(t.z)) | |
| let e = mode.invertFlag > 0.5 ? d / Pipeline.dmax | |
| : (SIMD3(repeating: Pipeline.dmax) - d) / Pipeline.dmax | |
| return globalPasses(frozenWindow(e, levels), levels) | |
| } | |
| /// The density-native form: no clamp, a guard under the log, no division. `pedestal` and | |
| /// `guardFloor` are arguments because the GPU's `pow(10, −x)` is not the CPU's at every ceiling. | |
| static func new(_ source: SIMD3<Float>, stops: SIMD3<Float>, levels: LevelsSet, | |
| mode: ConversionMode, pedestal: Float, guardFloor: Float) -> SIMD3<Float> { | |
| let gain = SIMD3(exp2(stops.x), exp2(stops.y), exp2(stops.z)) | |
| let t = (source + SIMD3(repeating: pedestal)) * gain | |
| let d = SIMD3(-log10(max(t.x, guardFloor)), -log10(max(t.y, guardFloor)), | |
| -log10(max(t.z, guardFloor))) | |
| let e = mode.invertFlag > 0.5 ? d : (SIMD3(repeating: window) - d) | |
| return globalPasses(perChannelWindow(e, levels), levels) | |
| } | |
| // MARK: - The migration | |
| /// The per-channel sets alone change unit: linked and luma read the window's output, a | |
| /// dimensionless signal, and a median stored normalised between black and white follows on its own. | |
| static func migrate(_ levels: LevelsSet) -> LevelsSet { | |
| var out = levels | |
| for channel in LevelsChannel.perChannel { | |
| out[channel].black *= Pipeline.dmax | |
| out[channel].white *= Pipeline.dmax | |
| } | |
| return out | |
| } | |
| // MARK: - The grid | |
| /// Samples per scene. Dense enough that a black point is approached within a thousandth of a | |
| /// density, which is where the two forms disagree most. | |
| private static let samples = 4000 | |
| /// Swept past both ends of the window and off any round value, so no sample lands on a handle | |
| /// by luck. The offsets decorrelate the three channels, which the luma pass then mixes. | |
| private static func density(_ index: Int, offset: Int) -> Float { | |
| let k = (index + offset) % samples | |
| return -0.6 + 4.2 * (Float(k) + 0.5) / Float(samples) | |
| } | |
| /// One grid pixel: three decorrelated densities and the source producing them under the scene's | |
| /// gain, so the sweep is exact on the new form and the old one receives the very same pixel. | |
| private static func gridSource(_ index: Int, gain: SIMD3<Float>, pedestal: Float) | |
| -> (d: SIMD3<Float>, source: SIMD3<Float>) { | |
| let d = SIMD3(density(index, offset: 0), density(index, offset: 1373), | |
| density(index, offset: 2711)) | |
| return (d, SIMD3(pow(10, -d.x) / gain.x - pedestal, | |
| pow(10, -d.y) / gain.y - pedestal, | |
| pow(10, -d.z) / gain.z - pedestal)) | |
| } | |
| private struct Scene { | |
| var name: String | |
| var levels: LevelsSet | |
| var mode: ConversionMode | |
| var stops: SIMD3<Float> | |
| } | |
| /// The frozen kernel's rest, stated rather than read from the live default: the per-channel | |
| /// resting white migrates too, so `LevelsSet()` is this comparison's output, not its input. | |
| private static func frozenRest(for mode: ConversionMode) -> LevelsSet { | |
| LevelsSet(luma: .neutral, linked: Levels(mid: Levels.restingMid(for: mode)), | |
| red: .neutral, green: .neutral, blue: .neutral) | |
| } | |
| /// Placed points, gammas on both sides of neutral, windows narrow enough to amplify any | |
| /// residue, gains at the ends of their range, and every branch of the inversion. | |
| private static var scenes: [Scene] { | |
| var placed = frozenRest(for: .negative) | |
| placed.red = Levels(black: 0.0731, white: 0.8137) | |
| placed.green = Levels(black: 0.1279, white: 0.7411) | |
| placed.blue = Levels(black: 0.2113, white: 0.9337) | |
| var gammas = placed | |
| gammas.red.mid = 0.3117 | |
| gammas.green.mid = 0.7213 | |
| gammas.blue.mid = 0.4409 | |
| gammas.linked = Levels(black: 0.0511, white: 0.9137, | |
| mid: Levels.restingMid(for: .negative)) | |
| var lumaMoved = gammas | |
| lumaMoved.luma = Levels(black: 0.0313, white: 0.8711, mid: 0.4137) | |
| var narrow = frozenRest(for: .negative) | |
| narrow.red = Levels(black: 0.4133, white: 0.4933, mid: 0.2311) | |
| narrow.green = Levels(black: 0.0117, white: 0.1017, mid: 0.7911) | |
| narrow.blue = Levels(black: 0.8813, white: 0.9613) | |
| var positive = placed | |
| positive.linked = Levels(mid: Levels.restingMid(for: .positive)) | |
| var positiveLuma = positive | |
| positiveLuma.luma = Levels(black: 0.0413, white: 0.9311, mid: 0.6137) | |
| return [ | |
| Scene(name: "rest", levels: frozenRest(for: .negative), mode: .negative, stops: .zero), | |
| Scene(name: "rest positive", levels: frozenRest(for: .positive), mode: .positive, | |
| stops: .zero), | |
| Scene(name: "placed", levels: placed, mode: .negative, | |
| stops: SIMD3(1.5137, 0, -2.2531)), | |
| Scene(name: "gammas", levels: gammas, mode: .negative, | |
| stops: SIMD3(-0.7331, 0.4111, 2.9137)), | |
| Scene(name: "luma moved", levels: lumaMoved, mode: .negative, | |
| stops: SIMD3(2.9971, -2.9971, 0.5137)), | |
| Scene(name: "narrow", levels: narrow, mode: .negative, stops: SIMD3(0.1337, -1.7711, 3)), | |
| Scene(name: "positive placed", levels: positive, mode: .positive, | |
| stops: SIMD3(-3, 0.9131, 1.4477)), | |
| Scene(name: "positive luma", levels: positiveLuma, mode: .positive, | |
| stops: SIMD3(0.7331, -1.4111, 2.1137)), | |
| Scene(name: "monochrome", levels: gammas, mode: .monochrome, | |
| stops: SIMD3(-2.1137, 1.3311, 0)), | |
| ] | |
| } | |
| /// One arm of the comparison: the worst gap, clipped as the output stage clips and bare, plus | |
| /// where it fell and how much of the grid it covers. | |
| private struct Deviation { | |
| var clipped: Float = 0 | |
| var bare: Float = 0 | |
| var worst = "nothing measured" | |
| var pixels = 0 | |
| mutating func note(_ before: Float, _ after: Float, _ label: @autoclosure () -> String) { | |
| let gap = abs(Pipeline.clip(before) - Pipeline.clip(after)) | |
| if gap > clipped { | |
| clipped = gap | |
| worst = label() | |
| } | |
| let raw = abs(before - after) | |
| if raw.isFinite && raw > bare { bare = raw } | |
| } | |
| } | |
| /// Splits the grid per pixel — the luma pass mixes the channels — between what the old `Tmax` | |
| /// clamp pinned in positive and the rest. `capping` restores that clamp, so the split is provable. | |
| private static func deviation(preparing: (LevelsSet) -> LevelsSet, | |
| pedestal: Float, guardFloor: Float, capping: Bool = false) | |
| -> (identity: Deviation, pinned: Deviation) { | |
| var identity = Deviation() | |
| var pinned = Deviation() | |
| for scene in scenes { | |
| let gain = SIMD3(exp2(scene.stops.x), exp2(scene.stops.y), exp2(scene.stops.z)) | |
| let migrated = preparing(scene.levels) | |
| let positive = scene.mode.invertFlag < 0.5 | |
| for i in 0..<samples { | |
| let (d, swept) = gridSource(i, gain: gain, pedestal: pedestal) | |
| var source = swept | |
| if capping, positive { | |
| source = source.replacing(with: 1 / gain - pedestal, | |
| where: (source + pedestal) * gain .> 1) | |
| } | |
| let before = old(source, stops: scene.stops, levels: scene.levels, mode: scene.mode) | |
| let after = new(source, stops: scene.stops, levels: migrated, mode: scene.mode, | |
| pedestal: pedestal, guardFloor: guardFloor) | |
| let clamped = positive && d.min() < 0 | |
| for c in 0..<3 { | |
| // The label is an autoclosure, so only a new worst pays for its formatting. | |
| if clamped { | |
| pinned.note(before[c], after[c], | |
| String(format: "%@ ch%d at D=%.4f, %.6f against %.6f", | |
| scene.name, c, d[c], before[c], after[c])) | |
| } else { | |
| identity.note(before[c], after[c], | |
| String(format: "%@ ch%d at D=%.4f, %.6f against %.6f", | |
| scene.name, c, d[c], before[c], after[c])) | |
| } | |
| } | |
| if clamped { pinned.pixels += 1 } else { identity.pixels += 1 } | |
| } | |
| } | |
| return (identity, pinned) | |
| } | |
| // MARK: - Checks | |
| /// The migration against the frozen kernel, the two wrong migrations that must diverge, and | |
| /// the histogram's edge law this build actually has. | |
| static func selfCheck() -> (ok: Bool, report: String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let ceiling = referenceCeiling | |
| let ped = DensityAxis.pedestal(at: ceiling) | |
| let floor = DensityAxis.logGuard(at: ceiling) | |
| lines.append(String(format: " ---- density axis: ceiling %.5f, pedestal %.7f, " | |
| + "guard %.7f, window %.3f", ceiling, ped, floor, window)) | |
| let measured = deviation(preparing: migrate, pedestal: ped, guardFloor: floor) | |
| let identity = measured.identity | |
| report(identity.clipped <= agreement, | |
| String(format: "the migrated settings render the frozen kernel over %d pixels: " | |
| + "post-clip %.3e (≤ %.0e) — worst %@. Unclipped %.3e, hence entirely on " | |
| + "pixels both forms put outside the range", identity.pixels, | |
| identity.clipped, agreement, identity.worst, identity.bare)) | |
| // What dropping `Tmax` costs, pinned rather than left to be discovered: in negative a | |
| // density under zero is floored to black either way, in positive it lands past the window. | |
| let pinned = measured.pinned | |
| let share = Float(pinned.pixels) / Float(pinned.pixels + identity.pixels) * 100 | |
| report(pinned.clipped > 0.1, | |
| String(format: "and it is NOT an identity on the one population the `Tmax` clamp " | |
| + "pinned — a transmittance over 1 in positive mode, %.1f %% of the grid — " | |
| + "which moves by up to %.4f post-clip: worst %@", | |
| share, pinned.clipped, pinned.worst)) | |
| // Capped, the population the clamp used to pin has to close on the oracle too — checking | |
| // identity alone would miss a recapping that leaves that very population untouched. | |
| let capped = deviation(preparing: migrate, pedestal: ped, guardFloor: floor, capping: true) | |
| let whole = scenes.count * samples | |
| report(capped.identity.clipped <= agreement && capped.pinned.clipped <= agreement, | |
| String(format: "and the exclusion names exactly that population: capping the " | |
| + "transmittance at 1 as `Tmax` did brings its %d of the grid's %d pixels " | |
| + "back to the oracle too, at %.3e post-clip (%.3e uncapped)", | |
| capped.pinned.pixels, whole, capped.pinned.clipped, pinned.clipped)) | |
| // The twin: that very reading, taken over the same whole grid with the cap removed, must | |
| // refuse — the agreement above belongs to the cap and not to a grid the clamp never touched. | |
| report(max(identity.clipped, pinned.clipped) > agreement, | |
| String(format: "the twin: the same whole-grid reading without the cap refuses at " | |
| + "%.4f post-clip", max(identity.clipped, pinned.clipped))) | |
| // The grid's own discrimination: a pedestal moved by 2·10⁻⁴ relative is a different kernel, | |
| // and a comparison blind to that would be measuring nothing. | |
| let rounded = deviation(preparing: migrate, pedestal: pow(10, -2.097), | |
| guardFloor: pow(10, -(2.097 + 0.903))).identity | |
| report(rounded.clipped > 50 * agreement, | |
| String(format: "the twin: quoting the ceiling and the guard offset to three " | |
| + "decimals instead of deriving them costs %.3e post-clip — the constants " | |
| + "are computed, never written down", rounded.clipped)) | |
| let linkedToo = deviation(preparing: { | |
| var out = migrate($0) | |
| out.linked.black *= Pipeline.dmax | |
| out.linked.white *= Pipeline.dmax | |
| return out | |
| }, pedestal: ped, guardFloor: floor).identity | |
| report(linkedToo.clipped > 0.5, | |
| String(format: "the twin: migrating the linked set as well diverges to %.4f " | |
| + "post-clip, a full-scale wrong render", linkedToo.clipped)) | |
| let unmigrated = deviation(preparing: { $0 }, pedestal: ped, guardFloor: floor).identity | |
| report(unmigrated.clipped > 0.5, | |
| String(format: "the twin: leaving the per-channel sets unmigrated diverges to %.4f " | |
| + "post-clip (%.3e before the clip)", unmigrated.clipped, unmigrated.bare)) | |
| // The unmigrated per-channel gap, unclipped, at the lowest density a test film reaches: | |
| // the positive branch re-inverts, so a density under zero lands past the window's top. | |
| let windowOnly = frozenRest(for: .positive).perChannelOnly | |
| let extreme = SIMD3<Float>(repeating: pow(10, 0.243) - ped) | |
| let keptRest = old(extreme, stops: .zero, levels: windowOnly, mode: .positive) | |
| let unitLost = new(extreme, stops: .zero, levels: windowOnly, mode: .positive, | |
| pedestal: ped, guardFloor: floor) | |
| let gap = unitLost.x - keptRest.x | |
| report(gap > 2, | |
| String(format: "the same twin, quantified: at D = −0.243 in positive, a resting " | |
| + "white left at 1 instead of %.0f puts the window's output at %.4f instead " | |
| + "of %.4f, a gap of %.4f", window, unitLost.x, keptRest.x, gap)) | |
| // The migration moves the per-channel handles and nothing else: three sets scaled, two | |
| // untouched, and five medians that stay where they were. | |
| var hand = LevelsSet() | |
| hand.red = Levels(black: 0.1373, white: 0.7911, mid: 0.3117) | |
| hand.green = Levels(black: 0.0211, white: 0.9137, mid: 0.6413) | |
| hand.blue = Levels(black: 0.3111, white: 0.8813, mid: 0.5) | |
| hand.linked = Levels(black: 0.0413, white: 0.9711, mid: 0.67) | |
| hand.luma = Levels(black: 0.1111, white: 0.8137, mid: 0.4311) | |
| func reads(_ out: LevelsSet) -> Bool { | |
| let scaled = LevelsChannel.perChannel.allSatisfy { | |
| abs(out[$0].black - hand[$0].black * window) < 1e-6 | |
| && abs(out[$0].white - hand[$0].white * window) < 1e-6 | |
| } | |
| let held = out.linked == hand.linked && out.luma == hand.luma | |
| return scaled && held && LevelsChannel.allCases.allSatisfy { out[$0].mid == hand[$0].mid } | |
| } | |
| let after = migrate(hand) | |
| report(reads(after), | |
| String(format: "migrate scales the three per-channel windows by %.0f (red white " | |
| + "%.4f → %.4f), leaves linked and luma alone, and moves no median", | |
| window, hand.red.white, after.red.white)) | |
| // The twin: the very same reading, run on a migration that reaches one handle too far. | |
| var reachingFurther = after | |
| reachingFurther.linked.white *= window | |
| report(!reads(reachingFurther), | |
| "the twin: that same reading refuses a migration reaching the linked set") | |
| lines.append(contentsOf: gridAgainstGPU(&ok)) | |
| lines.append(contentsOf: histogramEdgeLaw(&ok)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| // MARK: - The grid, against the kernel that actually runs | |
| /// Post-clip agreement between the closed form and the GPU, a decade over the worst the grid | |
| /// reaches: a kernel compiled with fast math owes no ulp, but no arm of it may drift a bin. | |
| static let gpuAgreement: Float = 2e-5 | |
| /// The grid as one image per scene, alpha at 1 so no premultiplication touches a source swept | |
| /// deliberately past both ends of the window. | |
| private static func gridImage(_ px: [Float]) -> CIImage { | |
| let bytes = px.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: samples * 16, | |
| size: CGSize(width: samples, height: 1), format: .RGBAf, colorSpace: nil) | |
| } | |
| /// The closed form against the real kernel over every scene of the grid. `ceiling` drives the | |
| /// GPU alone: leaving the form's constants free is what lets a twin quote them differently. | |
| private static func againstGPU(_ ctx: CIContext, at ceiling: Float, | |
| pedestal: Float, guardFloor: Float) | |
| -> (gap: Deviation, pixels: Int) { | |
| var gap = Deviation() | |
| var counted = 0 | |
| for scene in scenes { | |
| let gain = SIMD3(exp2(scene.stops.x), exp2(scene.stops.y), exp2(scene.stops.z)) | |
| let migrated = migrate(scene.levels) | |
| var px = [Float](repeating: 0, count: samples * 4) | |
| var swept = [SIMD3<Float>](repeating: .zero, count: samples) | |
| for i in 0..<samples { | |
| let grid = gridSource(i, gain: gain, pedestal: DensityAxis.pedestal(at: ceiling)) | |
| swept[i] = grid.d | |
| px[i * 4] = grid.source.x | |
| px[i * 4 + 1] = grid.source.y | |
| px[i * 4 + 2] = grid.source.z | |
| px[i * 4 + 3] = 1 | |
| } | |
| // Truncated before the curves: the migration touches stages 4→7 alone, and the graph | |
| // past them is common to both worlds. `measuring` drops the clip, which hides a gap. | |
| let settings = PipelineSettings(mode: scene.mode, gains: Gains(stops: scene.stops), | |
| densityCeiling: ceiling, | |
| levels: migrated).truncated(before: .curves) | |
| let out = Pipeline.apply(gridImage(px), settings: settings, measuring: true) | |
| let read = Pipeline.samples(ctx, out, width: samples, height: 1) | |
| for i in 0..<samples { | |
| let source = SIMD3(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]) | |
| var closed = new(source, stops: scene.stops, levels: migrated, mode: scene.mode, | |
| pedestal: pedestal, guardFloor: guardFloor) | |
| // Monochrome ends on a projection to grey; the closed form stops at stage 7, so the | |
| // last matrix is replayed rather than the scene being dropped from the grid. | |
| if scene.mode.isMonochrome { | |
| closed = SIMD3(repeating: (closed * Pipeline.lumaWeights).sum()) | |
| } | |
| for c in 0..<3 { | |
| gap.note(closed[c], read[i * 4 + c], | |
| String(format: "%@ ch%d at D=%.4f, closed %.6f against GPU %.6f", | |
| scene.name, c, swept[i][c], closed[c], read[i * 4 + c])) | |
| } | |
| counted += 3 | |
| } | |
| } | |
| return (gap, counted) | |
| } | |
| /// The oracle anchored to the GPU at every density of the grid rather than at a handful of | |
| /// points, where a fast-math `log10` diverging elsewhere would pass unseen. | |
| private static func gridAgainstGPU(_ ok: inout Bool) -> [String] { | |
| guard Pipeline.kernel != nil else { | |
| ok = false | |
| return [" FAIL the grid against the GPU: kernel not found"] | |
| } | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| // Both ends of the ceiling's range, so the setting is anchored to the kernel at every | |
| // density rather than only where it rests — a dialled axis reaching neither would show here. | |
| for ceiling in [DensityAxis.base, DensityAxis.range.upperBound] { | |
| let measured = againstGPU(ctx, at: ceiling, | |
| pedestal: DensityAxis.pedestal(at: ceiling), | |
| guardFloor: DensityAxis.logGuard(at: ceiling)) | |
| report(measured.gap.clipped <= gpuAgreement, | |
| String(format: "at a ceiling of %@ the closed form is the kernel over %d samples " | |
| + "of the grid: post-clip %.3e (≤ %.0e) — worst %@. Unclipped %.3e, on " | |
| + "values both put outside the range", DensityAxis.readout(ceiling), | |
| measured.pixels, measured.gap.clipped, gpuAgreement, | |
| measured.gap.worst, measured.gap.bare)) | |
| } | |
| // The twin: quoting the axis to three decimals is a different kernel, and a comparison | |
| // blind to that would be reading the GPU against nothing in particular. | |
| let rounded = againstGPU(ctx, at: DensityAxis.base, | |
| pedestal: pow(10, -2.097), guardFloor: pow(10, -3)) | |
| report(rounded.gap.clipped > 20 * gpuAgreement, | |
| String(format: "the twin: the same reading against a form quoting the ceiling to " | |
| + "three decimals refuses at %.3e post-clip", rounded.gap.clipped)) | |
| // The twin, adverse by construction: the GPU dialled to one ceiling read against the closed | |
| // form of the other, which is what a setting the kernel never received would look like. | |
| let crossed = againstGPU(ctx, at: DensityAxis.range.upperBound, | |
| pedestal: DensityAxis.pedestal(at: DensityAxis.base), | |
| guardFloor: DensityAxis.logGuard(at: DensityAxis.base)) | |
| report(crossed.gap.clipped > 20 * gpuAgreement, | |
| String(format: "the twin: the kernel at the range's top against the resting form " | |
| + "refuses at %.3e post-clip", crossed.gap.clipped)) | |
| return lines | |
| } | |
| // MARK: - The frames the migration will actually meet | |
| /// Half an 8-bit step. Under it a thumbnail cached by the previous version is still the picture | |
| /// this one draws, which is the whole question `Thumbnails.renderVersion` answers. | |
| static let halfStep: Float = 0.5 / 255 | |
| /// What a real frame has to hold, twenty times under `halfStep`: a render drifting this far is | |
| /// worth looking at long before a cached 8-bit thumbnail would show it. | |
| static let frameAgreement: Float = 1e-4 | |
| /// The worst post-clip gap over a frame, and where it fell. Post-clip because a cached bitmap | |
| /// holds the clipped pixel, and that is what an eye compares. | |
| private struct FrameGap { | |
| var worst: Float = 0 | |
| var at = "nothing measured" | |
| var pixels = 0 | |
| mutating func note(_ before: Float, _ after: Float, _ label: @autoclosure () -> String) { | |
| let gap = abs(Pipeline.clip(before) - Pipeline.clip(after)) | |
| if gap > worst { | |
| worst = gap | |
| at = label() | |
| } | |
| } | |
| } | |
| /// One arm of a real-frame comparison: what the live chain renders, and the frozen settings | |
| /// the closed form is fed. `migrating` off replays a sidecar the ×3 never reached. | |
| private struct FrameArm { | |
| var name: String | |
| var frozen: LevelsSet | |
| var stops: SIMD3<Float> | |
| var migrating = true | |
| /// Adds the new closed form, naming the GPU's own share of the gap instead of assuming it. | |
| var alsoNewForm = false | |
| } | |
| /// Rest is what an untouched import renders and the graded set amplifies any residue. The twin | |
| /// and the second closed form ride the reduced frame: each costs a whole form per pixel. | |
| private static func frameArms(reduced: Bool) -> [FrameArm] { | |
| var graded = frozenRest(for: .negative) | |
| graded.red = Levels(black: 0.0731, white: 0.8137, mid: 0.4411) | |
| graded.green = Levels(black: 0.1279, white: 0.7411, mid: 0.5237) | |
| graded.blue = Levels(black: 0.2113, white: 0.9337, mid: 0.6113) | |
| let stops = SIMD3<Float>(0.7331, 0, -0.4111) | |
| var arms = [FrameArm(name: "at rest", frozen: frozenRest(for: .negative), stops: .zero, | |
| alsoNewForm: reduced), | |
| FrameArm(name: "graded", frozen: graded, stops: stops, alsoNewForm: reduced)] | |
| if reduced { | |
| arms.append(FrameArm(name: "the twin, left unmigrated", frozen: graded, stops: stops, | |
| migrating: false)) | |
| } | |
| return arms | |
| } | |
| /// Stages 4→7 alone: the sharpening a resting `PipelineSettings` carries would drown the gap. | |
| /// The ceiling is pinned on the oracle's own, or a moved resting default is what gets measured. | |
| private static func liveSettings(_ arm: FrameArm) -> PipelineSettings { | |
| PipelineSettings(gains: Gains(stops: arm.stops), densityCeiling: referenceCeiling, | |
| levels: arm.migrating ? migrate(arm.frozen) : arm.frozen) | |
| .truncated(before: .curves) | |
| } | |
| /// One band as an image the kernel can be handed. Alpha is forced opaque: the comparison is on | |
| /// the three channels, and a premultiplied read would scale them by whatever the decode left. | |
| private static func bandImage(_ px: [Float], width: Int, rows: Int) -> CIImage { | |
| var opaque = px | |
| for i in stride(from: 3, to: opaque.count, by: 4) { opaque[i] = 1 } | |
| let bytes = opaque.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: width * 16, | |
| size: CGSize(width: width, height: rows), format: .RGBAf, colorSpace: nil) | |
| } | |
| /// The migration on the frames it will meet, at the thumbnail's size and at full resolution. | |
| /// This is what decides `Thumbnails.renderVersion`, the spec's estimate being an estimate. | |
| static func selfCheck(source: URL) -> (ok: Bool, report: String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // Two contexts: the decode is read in the working space it is produced in, and the kernel | |
| // then runs unmanaged, so no colour transform sits between the two arms being compared. | |
| let readContext = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| let kernelContext = CIContext(options: [.workingColorSpace: NSNull(), | |
| .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| var worst: Float = 0 | |
| for (sizeName, side) in [("thumbnail", Thumbnails.side), ("full", CGFloat?.none)] { | |
| guard let decoded = RawDecode.linear(source, longestSide: side) else { | |
| report(false, "\(sizeName): \(source.lastPathComponent) could not be decoded") | |
| continue | |
| } | |
| let arms = frameArms(reduced: side != nil) | |
| var against = [FrameGap](repeating: FrameGap(), count: arms.count) | |
| var residue = [FrameGap](repeating: FrameGap(), count: arms.count) | |
| var bandsRead = 0 | |
| let start = Date() | |
| Pipeline.bands(readContext, [decoded], over: decoded.extent, | |
| colorSpace: RawDecode.workingSpace) { read, width, rows in | |
| // The kernel is handed the very pixels the closed form reads. Decoded a second time | |
| // for the other arm, the same frame already differs from itself by 1.7e-4. | |
| let band = bandImage(read[0], width: width, rows: rows) | |
| let px = read[0] | |
| for (k, arm) in arms.enumerated() { | |
| let live = Pipeline.samples(kernelContext, | |
| Pipeline.apply(band, settings: liveSettings(arm)), | |
| width: width, height: rows) | |
| let migrated = migrate(arm.frozen) | |
| for i in 0..<(width * rows) { | |
| let s = SIMD3(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]) | |
| let frozen = old(s, stops: arm.stops, levels: arm.frozen, mode: .negative) | |
| let closed = arm.alsoNewForm | |
| ? new(s, stops: arm.stops, levels: migrated, mode: .negative, | |
| pedestal: DensityAxis.pedestal(at: referenceCeiling), | |
| guardFloor: DensityAxis.logGuard(at: referenceCeiling)) | |
| : SIMD3<Float>.zero | |
| for c in 0..<3 { | |
| against[k].note(frozen[c], live[i * 4 + c], | |
| String(format: "T=%.5f, frozen %.6f against live %.6f", | |
| s[c], frozen[c], live[i * 4 + c])) | |
| guard arm.alsoNewForm else { continue } | |
| residue[k].note(closed[c], live[i * 4 + c], | |
| String(format: "T=%.5f, closed %.6f against live %.6f", | |
| s[c], closed[c], live[i * 4 + c])) | |
| } | |
| } | |
| against[k].pixels += width * rows | |
| } | |
| bandsRead += 1 | |
| } | |
| let shape = String(format: "%.0f × %.0f", decoded.extent.width, decoded.extent.height) | |
| for (k, arm) in arms.enumerated() where arm.migrating { | |
| worst = max(worst, against[k].worst) | |
| report(against[k].worst <= frameAgreement && against[k].pixels > 0, | |
| String(format: "%@, %@ px, %@: the frozen kernel and the live chain agree to " | |
| + "%.3e post-clip over %d px (≤ %.0e, half an 8-bit step being %.3e) " | |
| + "— worst %@", sizeName, shape, arm.name, against[k].worst, | |
| against[k].pixels, frameAgreement, halfStep, against[k].at)) | |
| guard arm.alsoNewForm else { continue } | |
| // Names the GPU's own share instead of leaving the whole gap on the migration: the | |
| // closed form of the chain that runs, judged on the very same pixels. | |
| report(residue[k].worst <= frameAgreement, | |
| String(format: " of which the kernel's own float arithmetic, the same " | |
| + "pixels against the closed form of the chain that runs: %.3e", | |
| residue[k].worst)) | |
| } | |
| // The twin, adverse by construction: the same reading, on the same pixels, with three | |
| // windows left three times too narrow — which is a sidecar the ×3 never reached. | |
| for (k, arm) in arms.enumerated() where !arm.migrating { | |
| report(against[k].worst > 0.5, | |
| String(format: "%@: %@ refuses at %.4f post-clip, a full-scale wrong render", | |
| sizeName, arm.name, against[k].worst)) | |
| } | |
| lines.append(String(format: " ---- %@ read in %.1f s over %d bands", sizeName, | |
| -start.timeIntervalSinceNow, bandsRead)) | |
| } | |
| lines.append(String(format: " ---- worst over every size and arm: %.3e. Under %.3e a " | |
| + "thumbnail cached by the previous version stays the picture, so " | |
| + "`Thumbnails.renderVersion` (%d) holds", worst, halfStep, | |
| Thumbnails.renderVersion)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// `CIAreaHistogram`'s behaviour outside 0…1, measured on this build: it decides whether a | |
| /// density mapped past the graduation window shows as a spike or vanishes. | |
| private static func histogramEdgeLaw(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let side = 8 | |
| let population = Float(side * side) | |
| func flat(_ value: Float) -> CIImage { | |
| var data = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| data[i * 4] = value | |
| data[i * 4 + 1] = value | |
| data[i * 4 + 2] = value | |
| data[i * 4 + 3] = 1 | |
| } | |
| let bytes = data.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: side * 16, | |
| size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| let bins = 1024 | |
| /// Where a uniform population lands, and how much of it survived the count. | |
| func landing(_ value: Float) -> (bin: Int, kept: Float) { | |
| let counts = Pipeline.histogram(flat(value), bins: bins).rgb.map { $0.x } | |
| let total = counts.reduce(0, +) | |
| let bin = counts.indices.max(by: { counts[$0] < counts[$1] }) ?? -1 | |
| return (bin, total / population) | |
| } | |
| let under = landing(-0.5) | |
| let exact = landing(1) | |
| let over = landing(1.5) | |
| let inside = landing(0.5) | |
| report(under.bin == 0 && abs(under.kept - 1) < 1e-3, | |
| String(format: "below zero clamps into bin 0 (%.0f %% of the pixels kept)", | |
| under.kept * 100)) | |
| report(exact.bin == bins - 1 && abs(exact.kept - 1) < 1e-3, | |
| String(format: "exactly 1.0 lands in the top bin, %d of %d", exact.bin, bins)) | |
| report(over.bin == bins - 1 && abs(over.kept - 1) < 1e-3, | |
| String(format: "above 1.0 clamps into the top bin too, %.0f %% kept — measured, " | |
| + "against a specification that announced it dropped", over.kept * 100)) | |
| report(inside.bin == bins / 2 && abs(inside.kept - 1) < 1e-3, | |
| String(format: "and a value inside the range still lands where it belongs, bin %d", | |
| inside.bin)) | |
| // The twin: without this, a source silently clamped on the way in would produce exactly | |
| // the same four readings, and the measurement would be of nothing. | |
| let ctx = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| var probe = [Float](repeating: 0, count: 4) | |
| ctx.render(flat(1.5), toBitmap: &probe, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| report(abs(probe[0] - 1.5) < 1e-6, | |
| String(format: "the twin: the measured source really carries its out-of-range " | |
| + "value (%.4f), so the clamping seen above is the histogram's", probe[0])) | |
| return lines | |
| } | |
| } | |
| import Foundation | |
| /// A roll: the frames a hand put together. Membership is carried by each frame's own sidecar, never | |
| /// by a table — an index is a disposable cache, and a roll living only there dies with it. | |
| struct Film: Codable, Equatable, Hashable, Sendable, Identifiable { | |
| /// Identity, so renaming a roll cannot scatter its frames. The name rides along in every | |
| /// member, which is what lets the whole library be regrouped without opening a second file. | |
| var id: UUID | |
| var name: String | |
| /// A roll's own place among its ROOT siblings — nil until dragged, one level up from a | |
| /// photo's own `manualOrder`. A day has no `Film` to carry one, hence no override for it. | |
| var manualOrder: Double? | |
| /// `FilmStockCatalog`'s own key, `nil` until a hand picks one — never the display name, so | |
| /// the catalogue can rename a stock without stranding every roll already labelled with it. | |
| var stock: String? | |
| /// When the roll was actually shot, purely organisational: never `capturedAt` on a photo, so | |
| /// setting it moves nothing in `Film.grouped`, `BoxTree` or the feed's own order. | |
| var shotDate: Date? | |
| /// The camera the roll was shot on, hand-entered — unlike a scan's own EXIF, which this app | |
| /// never trusts, a declared body is real and may reach an exported file's metadata one day. | |
| var cameraBody: String? | |
| init(id: UUID = UUID(), name: String, manualOrder: Double? = nil, stock: String? = nil, | |
| shotDate: Date? = nil, cameraBody: String? = nil) { | |
| self.id = id | |
| self.name = name | |
| self.manualOrder = manualOrder | |
| self.stock = stock | |
| self.shotDate = shotDate | |
| self.cameraBody = cameraBody | |
| } | |
| } | |
| /// What a gallery draws a heading for. Every group is a roll: the ones a hand formed carry an `id`, | |
| /// the rest are the capture day standing in for one, so there is a single kind of thing on screen. | |
| struct FilmGroup: Equatable, Sendable, Identifiable { | |
| /// What the frames were bucketed on, so a surface can name a row without a second lookup. | |
| let id: Film.Key | |
| /// `nil` on a day standing in for a roll — a day has no name of its own to rename. | |
| let film: Film? | |
| /// Earliest capture in the group — where it sits in the feed only absent a hand-dragged | |
| /// `rootOrder`. A roll spanning two days still starts on its first frame. | |
| let start: Date | |
| let entries: [LibraryIndex.Entry] | |
| var isExplicit: Bool { film != nil } | |
| /// A named roll heads with its name, a nameless one or a bare day with the day itself — the | |
| /// `GalleryEngine`'s own `GalleryGroup.displayTitle` contract, so a Library index needs no adapter. | |
| var displayTitle: String { | |
| if let name = film?.name, !name.trimmingCharacters(in: .whitespaces).isEmpty { return name } | |
| return Film.dayLabel(Calendar.current.startOfDay(for: start)) | |
| } | |
| /// The hand-set name alone, `nil` on a day — `GalleryGroup`'s own rename contract, so opening | |
| /// the field starts empty there rather than pre-filled with `displayTitle`'s day fallback. | |
| var explicitName: String? { film?.name } | |
| } | |
| extension FilmGroup: GalleryGroup {} | |
| extension Film { | |
| /// The bucket a frame falls in: its roll if it has one, its capture day otherwise. One field | |
| /// decides both, so ungrouping is clearing it and the day takes its frames back on its own. | |
| enum Key: Hashable, Sendable { | |
| case roll(UUID) | |
| case day(Date) | |
| } | |
| /// A case rather than a formatted string: the whole library is rebucketed whenever the list | |
| /// moves, and spelling a date costs more per frame than the day itself does to compute. | |
| nonisolated static func key(of entry: LibraryIndex.Entry) -> Key { | |
| entry.film.map { .roll($0.id) } ?? .day(dayStart(of: entry)) | |
| } | |
| /// `capturedAt`, so a roll stays one group across however many sessions it is imported in; no | |
| /// EXIF date falls back to `importedAt`, its own stable day. | |
| nonisolated static func dayStart(of entry: LibraryIndex.Entry) -> Date { | |
| Calendar.current.startOfDay(for: entry.capturedAt ?? entry.importedAt) | |
| } | |
| nonisolated static func capture(of entry: LibraryIndex.Entry) -> Date { | |
| entry.capturedAt ?? entry.importedAt | |
| } | |
| /// The one place a day is spelled — a heading standing in for an unnamed roll, and the name a | |
| /// freshly promoted one takes at import or on a rebuild. | |
| nonisolated static func dayLabel(_ day: Date) -> String { dayFormatter.string(from: day) } | |
| private static let dayFormatter: DateFormatter = { | |
| let formatter = DateFormatter() | |
| formatter.dateStyle = .full | |
| formatter.timeStyle = .none | |
| formatter.locale = Locale(identifier: "en_US") | |
| return formatter | |
| }() | |
| /// A frame's position among its own siblings: hand-placed if it was ever dragged, its capture | |
| /// time otherwise — one scale, so a moved frame and an untouched one sort on the same line. | |
| nonisolated static func order(of entry: LibraryIndex.Entry) -> Double { | |
| entry.manualOrder ?? capture(of: entry).timeIntervalSinceReferenceDate | |
| } | |
| /// A roll's place among its OWN root siblings — hand-placed if dragged, its earliest capture | |
| /// otherwise. A day always reads its own start, so the two compare on one scale. | |
| nonisolated static func rootOrder(of group: FilmGroup) -> Double { | |
| group.film?.manualOrder ?? group.start.timeIntervalSinceReferenceDate | |
| } | |
| /// The floor a drop's new orders are spread above `ahead`, defending against a neighbour whose | |
| /// own order ties or reverses it — a scanned roll often stamps every frame the same capture time. | |
| nonisolated static func behind(ahead: Double, priorOrder: Double?) -> Double { | |
| priorOrder.flatMap { $0 < ahead ? $0 : nil } ?? ahead - 86400 | |
| } | |
| /// A whole scanned roll can share one capture time, not just one neighbour: this spreads the | |
| /// tied run up to `index` across the real gap below it, so any position in it becomes insertable. | |
| nonisolated static func detied(_ entries: [LibraryIndex.Entry], | |
| through index: Int) -> [(path: String, order: Double)] { | |
| let ahead = order(of: entries[index]) | |
| var start = index | |
| while start > 0, order(of: entries[start - 1]) >= ahead { start -= 1 } | |
| guard start < index else { return [] } | |
| let floor = start > 0 ? order(of: entries[start - 1]) : ahead - 86400 | |
| let span = ahead - floor | |
| let count = index - start + 1 | |
| return (start...index).enumerated().map { offset, i in | |
| (entries[i].path, floor + span * Double(offset + 1) / Double(count + 1)) | |
| } | |
| } | |
| /// What a roll is called until a hand names it: the lowest free number, so forming one after | |
| /// another never proposes a name already on screen. Identity is the id, so reuse is harmless. | |
| static let untitledStem = "Roll " | |
| nonisolated static func untitled(among taken: [Film]) -> String { | |
| let numbers = Set(taken.compactMap { film -> Int? in | |
| guard film.name.hasPrefix(untitledStem) else { return nil } | |
| return Int(film.name.dropFirst(untitledStem.count)) | |
| }) | |
| var number = 1 | |
| while numbers.contains(number) { number += 1 } | |
| return "\(untitledStem)\(number)" | |
| } | |
| /// The library cut into rolls, each ordered by capture and the rolls by `rootOrder` — capture | |
| /// order exactly when nothing is dragged. Membership is read off the frame, never a table. | |
| nonisolated static func grouped(_ entries: [LibraryIndex.Entry]) -> [FilmGroup] { | |
| var buckets: [Key: [LibraryIndex.Entry]] = [:] | |
| var films: [Key: Film] = [:] | |
| for entry in entries { | |
| let key = key(of: entry) | |
| buckets[key, default: []].append(entry) | |
| if let film = entry.film { films[key] = film } | |
| } | |
| return buckets.map { key, members in | |
| let ordered = members.sorted { order(of: $0) < order(of: $1) } | |
| // The group's own place in the feed stays on capture, never a member's hand-placed | |
| // position — reordering photos inside a roll must not walk the roll itself. | |
| let byCapture = members.map(capture).min() ?? .distantPast | |
| return FilmGroup(id: key, film: films[key], start: byCapture, entries: ordered) | |
| } | |
| // `rootOrder`, not `start`: the gallery and the sidebar tree are one organisation, and a | |
| // roll dragged in either place must move in both — the tie-break still falls back to it. | |
| .sorted { a, b in | |
| let (orderA, orderB) = (rootOrder(of: a), rootOrder(of: b)) | |
| return orderA == orderB | |
| ? (a.entries.first?.path ?? "") < (b.entries.first?.path ?? "") | |
| : orderA < orderB | |
| } | |
| } | |
| } | |
| // MARK: - Checks | |
| extension Film { | |
| /// What the grouping promises: forming a roll moves nothing else, clearing it gives the frames | |
| /// back to their day, and a roll sits where its earliest frame does. | |
| nonisolated static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var report = "" | |
| func check(_ passed: Bool, _ label: String) { | |
| ok = ok && passed | |
| report += " \(passed ? "OK " : "FAIL") \(label)\n" | |
| } | |
| let base = Date(timeIntervalSinceReferenceDate: 8e8) | |
| func at(_ hours: Double, _ path: String, _ film: Film? = nil) -> LibraryIndex.Entry { | |
| LibraryIndex.Entry(path: path, importedAt: base, | |
| capturedAt: base.addingTimeInterval(hours * 3600), | |
| fingerprint: path, film: film) | |
| } | |
| /// The same list with a roll placed on the named frames, since membership rides on the | |
| /// frame itself: forming a roll is rewriting those frames and nothing else. | |
| func placing(_ film: Film?, on paths: Set<String>, | |
| in entries: [LibraryIndex.Entry]) -> [LibraryIndex.Entry] { | |
| entries.map { entry in | |
| var copy = entry | |
| if paths.contains(entry.path) { copy.film = film } | |
| return copy | |
| } | |
| } | |
| // Two days: three frames on the first, two on the second. | |
| let all = [at(1, "a"), at(2, "b"), at(3, "c"), at(30, "d"), at(31, "e")] | |
| let byDay = grouped(all) | |
| check(byDay.count == 2 && byDay[0].entries.map(\.path) == ["a", "b", "c"] | |
| && byDay[1].entries.map(\.path) == ["d", "e"], | |
| String(format: "with nothing grouped the days stand as rolls — %d of them", | |
| byDay.count)) | |
| check(byDay.allSatisfy { !$0.isExplicit }, | |
| "and none of them claims a name it does not have") | |
| // A roll formed across the two days takes its frames out of both. | |
| let roll = Film(name: "Portra 400") | |
| let crossed = grouped(placing(roll, on: ["b", "d"], in: all)) | |
| check(crossed.count == 3, | |
| String(format: "forming a roll leaves what it did not take — %d groups", crossed.count)) | |
| let made = crossed.first { $0.isExplicit } | |
| check(made?.entries.map(\.path) == ["b", "d"], | |
| "and holds exactly the frames it was given, in capture order") | |
| // It sits on its EARLIEST frame, so forming one never jumps it ahead of a roll before it. | |
| check(crossed.map(\.start) == crossed.map(\.start).sorted(), | |
| "the feed stays in capture order, each roll on its earliest frame") | |
| // Where it lands is decided by its own earliest frame and nothing else: the day it starts | |
| // inside still holds a frame before it, so the roll follows that day rather than opening. | |
| check(crossed.firstIndex(where: { $0.isExplicit }) == 1 | |
| && crossed[0].entries.map(\.path) == ["a", "c"] | |
| && crossed[2].entries.map(\.path) == ["e"], | |
| String(format: "a roll follows what still starts before it — at rank %d of %d", | |
| (crossed.firstIndex(where: { $0.isExplicit }) ?? -1) + 1, crossed.count)) | |
| // Clearing the field is the whole of ungrouping: nothing recreates a day. | |
| let cleared = grouped(placing(nil, on: ["b", "d"], | |
| in: placing(roll, on: ["b", "d"], in: all))) | |
| check(cleared == byDay, "clearing the roll gives every frame back to its day, unchanged") | |
| // Twin, adverse by construction: two rolls of the same NAME are two rolls, since identity | |
| // is the id — otherwise renaming one would silently swallow the other. | |
| let twin = Film(name: "Portra 400") | |
| let named = grouped(placing(twin, on: ["e"], | |
| in: placing(roll, on: ["a"], in: all))) | |
| check(named.filter(\.isExplicit).count == 2, | |
| String(format: "and the check discriminates: two rolls sharing a name stay two — " | |
| + "%d explicit groups", named.filter(\.isExplicit).count)) | |
| // A roll and the day it left can open on the same capture, and the equal starts asserted | |
| // below are what make this adverse: undecided, the sort shuffles the feed between passes. | |
| let tied = grouped([at(1, "a", roll), at(1, "b"), at(2, "c")]) | |
| check(tied.count == 2 && tied[0].start == tied[1].start | |
| && tied[0].entries.first?.path == "a", | |
| "two groups opening on the same capture are ordered by their first frame") | |
| // MARK: What a roll is called before a hand names it | |
| check(untitled(among: []) == "Roll 1", "the first roll is offered the first number") | |
| check(untitled(among: [Film(name: "Roll 1"), Film(name: "Portra 400")]) == "Roll 2", | |
| "and the next takes the lowest free one, a named roll standing in the way of none") | |
| check(untitled(among: [Film(name: "Roll 1"), Film(name: "Roll 3")]) == "Roll 2", | |
| "a freed number is offered again rather than counting ever upward") | |
| // Twin, adverse by construction: a count of rolls would answer 3 here and clash outright. | |
| check(untitled(among: [Film(name: "Roll 2"), Film(name: "Roll 3")]) == "Roll 1", | |
| "and the check discriminates: counting the rolls would propose a name already taken") | |
| // MARK: The day is computed once per frame | |
| let many = (0..<2000).map { i in | |
| LibraryIndex.Entry(path: "/perf/\(i).RW2", | |
| importedAt: base.addingTimeInterval(Double(i)), | |
| capturedAt: nil, fingerprint: "p\(i)") | |
| } | |
| // Twin, adverse by construction: the same buckets, keyed twice per frame, which is the one | |
| // cost this cut must not pay — `startOfDay` dwarfs the hashing around it. | |
| func twice(_ entries: [LibraryIndex.Entry]) -> [Key: [LibraryIndex.Entry]] { | |
| var buckets: [Key: [LibraryIndex.Entry]] = [:] | |
| for entry in entries { | |
| let first = key(of: entry) | |
| guard first == key(of: entry) else { continue } | |
| buckets[first, default: []].append(entry) | |
| } | |
| return buckets | |
| } | |
| func ms(_ body: () -> Void) -> Double { | |
| let start = ProcessInfo.processInfo.systemUptime | |
| body() | |
| return (ProcessInfo.processInfo.systemUptime - start) * 1000 | |
| } | |
| // The MINIMUM of the runs, never the median: this measures work, and the least-contended | |
| // run is the one least polluted by whatever else the machine is doing. | |
| func best(_ values: [Double]) -> Double { values.min() ?? 0 } | |
| let once = best((1...9).map { _ in ms { _ = grouped(many) } }) | |
| let doubled = best((1...9).map { _ in ms { _ = twice(many) } }) | |
| // Floor 1.3, well under the ratio typically measured: `startOfDay`'s cost varies by | |
| // machine, and a floor close to what is observed flickers under load. | |
| check(doubled / max(once, 1e-6) >= 1.3, | |
| String(format: "cutting %d frames into rolls keys each once: %.2f ms against the " | |
| + "%.2f ms of keying twice, %.2f× (floor 1.3×)", | |
| many.count, once, doubled, doubled / max(once, 1e-6))) | |
| // MARK: manualOrder | |
| // A hand-placed frame reorders inside its own roll, without moving where the roll itself | |
| // sits — reordering photographs must not walk the roll past ones that came before it. | |
| var reordered = [at(1, "x"), at(2, "y"), at(3, "z")] | |
| reordered[2].manualOrder = order(of: reordered[0]) - 1000 | |
| let regrouped = grouped(reordered) | |
| check(regrouped.count == 1 && regrouped[0].entries.map(\.path) == ["z", "x", "y"], | |
| String(format: "a hand-placed frame moves ahead of its capture order — %@", | |
| regrouped.first?.entries.map(\.path).description ?? "none")) | |
| check(regrouped[0].start == capture(of: reordered[0]), | |
| "and the check discriminates: the roll's own place in the feed still reads the " | |
| + "earliest CAPTURE, not the frame a hand moved ahead of it") | |
| // MARK: behind | |
| // A scanned roll often stamps every frame the same capture time: the floor a drop's new | |
| // orders spread above must still separate from `ahead`, tie or not. | |
| check(behind(ahead: 100, priorOrder: 50) == 50, "a genuinely lower neighbour is used as-is") | |
| check(behind(ahead: 100, priorOrder: 100) == 100 - 86400, | |
| "a neighbour tied with `ahead` falls back to a real gap instead of a zero one") | |
| check(behind(ahead: 100, priorOrder: 150) == 100 - 86400, | |
| "and a neighbour reading AFTER `ahead` falls back the same way, never reversing the gap") | |
| check(behind(ahead: 100, priorOrder: nil) == 100 - 86400, | |
| "no neighbour at all is the same fallback, unchanged from before") | |
| // MARK: detied | |
| // A whole scanned roll, five frames sharing one capture time — the shape `behind` alone | |
| // cannot fix, since there is no single tied neighbour to fall back past. | |
| let sameStamp = [at(0, "a"), at(0, "b"), at(0, "c"), at(0, "d"), at(0, "e")] | |
| let untied = detied(sameStamp, through: 1) | |
| check(untied.map(\.path) == ["a", "b"], "de-ties exactly the run up to and including the target") | |
| check(untied[0].order < untied[1].order && untied[1].order < order(of: sameStamp[1]), | |
| "in increasing order, the target itself ending strictly below its own original value") | |
| check(detied(sameStamp, through: 0).isEmpty, | |
| "nothing precedes the first frame, so there is no run to spread") | |
| let staggered = [at(-1, "a"), at(0, "b"), at(1, "c")] | |
| check(detied(staggered, through: 2).isEmpty, | |
| "an already-distinct predecessor costs nothing — the common case is untouched") | |
| // MARK: rootOrder | |
| var laterRoll = Film(name: "Later") | |
| let earlier = FilmGroup(id: .roll(UUID()), film: Film(name: "Earlier"), | |
| start: base.addingTimeInterval(3600), entries: []) | |
| let later = FilmGroup(id: .roll(laterRoll.id), film: laterRoll, | |
| start: base.addingTimeInterval(7200), entries: []) | |
| check(rootOrder(of: earlier) < rootOrder(of: later), | |
| "two untouched rolls sort by their own start, exactly like their groups already do") | |
| laterRoll.manualOrder = rootOrder(of: earlier) - 1 | |
| let laterMoved = FilmGroup(id: later.id, film: laterRoll, start: later.start, | |
| entries: later.entries) | |
| check(rootOrder(of: laterMoved) < rootOrder(of: earlier), | |
| "a hand-placed roll reorders ahead of one that started earlier") | |
| let day = FilmGroup(id: .day(base), film: nil, start: base.addingTimeInterval(1), | |
| entries: []) | |
| check(rootOrder(of: day) == day.start.timeIntervalSinceReferenceDate, | |
| "and the check discriminates: a day has no Film to carry an override, so it always " | |
| + "reads its own start") | |
| // `grouped` itself, not just `rootOrder` in isolation — the gallery calls this function, | |
| // and it must read the same field the sidebar tree already sorts its own roots on. | |
| var pulledAhead = Film(name: "Pulled ahead") | |
| let untouchedFilm = Film(name: "Untouched") | |
| let before = grouped([at(1, "p", untouchedFilm), at(2, "q", pulledAhead)]) | |
| check(before[0].entries.first?.path == "p", "captures alone: the earlier frame's roll leads") | |
| pulledAhead.manualOrder = rootOrder(of: before[0]) - 1 | |
| let after = grouped([at(1, "p", untouchedFilm), at(2, "q", pulledAhead)]) | |
| check(after[0].entries.first?.path == "q", | |
| "and dragged ahead of it, that roll leads `grouped`'s own output too") | |
| // MARK: The three inspector fields round-trip and each moves equality on its own | |
| let bare = Film(name: "Bare") | |
| var withStock = bare; withStock.stock = "kodak-gold-200" | |
| var withDate = bare; withDate.shotDate = base | |
| var withBody = bare; withBody.cameraBody = "Leica M6" | |
| check(withStock != bare && withDate != bare && withBody != bare, | |
| "setting any one of stock, shot date or camera body changes equality") | |
| check(withStock != withDate && withDate != withBody && withStock != withBody, | |
| "and the check discriminates: the three fields are independent, not one flag") | |
| let data = try? JSONEncoder().encode(withStock) | |
| let decoded = data.flatMap { try? JSONDecoder().decode(Film.self, from: $0) } | |
| check(decoded == withStock, "a roll carrying a stock round-trips through JSON unchanged") | |
| return (ok, report) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// `--film <file>`: what a scan actually holds, channel by channel, before anything decides. | |
| /// Exists because "the red clips" has three possible causes and only numbers tell them apart. | |
| enum FilmProbe { | |
| /// The share of a channel sitting on each end of the scale. Piled pixels are the question: | |
| /// a strictly increasing chain cannot unpile what was already flat when it was scanned. | |
| struct Channel { | |
| let name: String | |
| let low: Float | |
| let high: Float | |
| let quantiles: [Float] | |
| let onFloor: Double | |
| let onCeiling: Double | |
| /// At or below zero, hence at or past the density ceiling: the band the pedestal folds into | |
| /// the 0.903 between the ceiling and the guard. Who is in it does not move with the ceiling. | |
| let dark: Double | |
| /// At or below `−pedestal`, where the sum a gain multiplies is non-positive: piled on the | |
| /// guard, one value, and the only one of the three bands the ceiling moves. | |
| let underPedestal: Double | |
| /// At or over 1. On a decode that is a density at or under zero, which a black point can | |
| /// reach; on a rendered frame it is the output clip's own pile. | |
| let overUnit: Double | |
| } | |
| /// Where the auto balance reads, plus the two ends: the same fractions it uses, so a figure | |
| /// here can be compared against what a button will do rather than to a different measurement. | |
| static let fractions: [Float] = [0.001, 0.1, 0.5, 0.9, 0.999] | |
| /// Below this a pile at a channel's own extreme is grain rather than a scanner that ran out of | |
| /// range, and naming every frame would bury the ones that matter. | |
| static let notable: Double = 0.01 | |
| static func run(source: URL) -> Bool { | |
| guard let decoded = RawDecode.linear(source, longestSide: Negative.measureSide) else { | |
| print("FAIL: \(source.lastPathComponent) could not be decoded") | |
| return false | |
| } | |
| guard let read = pixels(decoded) else { | |
| print("FAIL: the decoded frame could not be read back") | |
| return false | |
| } | |
| // The same three-way sort `RawDecode.linear` makes, and never a guess from the extension: | |
| // reporting the wrong branch would mislead exactly where a diagnostic must not. | |
| let route: String | |
| switch ContainerRead.probe(source) { | |
| case .linearRGB: route = "ContainerRead (linear RGB, read strip by strip)" | |
| case .mosaic: route = "CIRAWFilter, at \(Int(RawDecode.temperature)) K" | |
| case .unsupportedTIFF, nil: | |
| route = RawDecode.isRaw(source) | |
| ? "CIRAWFilter, at \(Int(RawDecode.temperature)) K" | |
| : "CIImage (declared profile, sRGB otherwise)" | |
| } | |
| print("\(source.lastPathComponent) — \(read.w) × \(read.h), scene-linear, before any stage") | |
| print(" decoded through: \(route)") | |
| print("") | |
| print(" channel min max " + fractions.map { | |
| String(format: "%7.3f", $0) | |
| }.joined() + " over 1 under 0 on guard") | |
| // The central half as well as the whole: a scan carries the holder's black rebate around | |
| // the frame, and counting it would report every scan as half dead. | |
| let middle = centre(read) | |
| var rows: [Channel] = [] | |
| var centreRows: [Channel] = [] | |
| for (index, name) in ["red", "green", "blue"].enumerated() { | |
| let channel = measure(read.px, offset: index, name: name) | |
| rows.append(channel) | |
| centreRows.append(measure(middle, offset: index, name: name)) | |
| print(" " + channel.name.padding(toLength: 8, withPad: " ", startingAt: 0) | |
| + String(format: "%9.4f %9.4f", channel.low, channel.high) | |
| + channel.quantiles.map { String(format: "%7.3f", $0) }.joined() + shares(channel)) | |
| } | |
| // A pile at a channel's own extreme is the one thing quantiles cannot show, and it is what | |
| // the chain can never undo: the scanner ran out of range before any stage saw the pixel. | |
| for channel in rows where max(channel.onFloor, channel.onCeiling) >= notable { | |
| print(String(format: " %@ was already flat when it was scanned: %.2f %% on its own " | |
| + "minimum, %.2f %% on its own maximum", channel.name, | |
| channel.onFloor * 100, channel.onCeiling * 100)) | |
| } | |
| print("") | |
| // The same shape read inside the frame alone, which is what an offset would have to lift. | |
| print("") | |
| print(" centre only min max " + fractions.map { | |
| String(format: "%7.3f", $0) | |
| }.joined() + " over 1 under 0 on guard") | |
| for channel in centreRows { | |
| // The absolute span and the share astride zero. Never a ratio in stops: a channel | |
| // whose minimum sits at zero makes any ratio measure the epsilon, not the data. | |
| print(" " + channel.name.padding(toLength: 8, withPad: " ", startingAt: 0) | |
| + String(format: "%9.4f %9.4f", channel.low, channel.high) | |
| + channel.quantiles.map { String(format: "%7.3f", $0) }.joined() + shares(channel)) | |
| } | |
| print("") | |
| print(axisTable(rows)) | |
| output(decoded, source: source) | |
| sweep(source) | |
| // The ceiling's own trade, read at full resolution: the reduced frame above cannot price a | |
| // rare tail, and the slider that moves the ceiling is dialled on a full-resolution scan. | |
| print("") | |
| let grain = ceilingGrain(source: source) | |
| print(grain.report) | |
| let column = axisChecks() | |
| print(column.report) | |
| print("") | |
| print(" on guard is the share no gain reaches: it is at or under −pedestal, so the sum a") | |
| print(" gain multiplies stays non-positive and the pixel lands on the top of the axis") | |
| print(" however far the slider is pushed. A difficult film is not a failure here.") | |
| // A piled channel is what this probe exists to show, so it is never the exit code. Only a | |
| // claim the probe makes about the axis is, or a difficult film would read as a broken build. | |
| return grain.ok && column.ok | |
| } | |
| /// The three shares every table carries, in one place: a column that changed meaning would | |
| /// otherwise change in one table and stay stale in the other. | |
| static func shares(_ channel: Channel) -> String { | |
| String(format: " %8.3f %% %8.3f %% %8.3f %%", channel.overUnit * 100, | |
| channel.dark * 100, channel.underPedestal * 100) | |
| } | |
| /// What the WHOLE pipeline makes of the frame, which is what the eye judges: the pedestal, the | |
| /// log, the levels and the output clip all sit downstream of everything measured above. | |
| static func output(_ decoded: CIImage, source: URL) { | |
| // The whole of `Gains.range` and nothing past it: what lands on the guard is gain-invariant | |
| // by arithmetic, `(v + pedestal)·g ≤ 0` at any positive gain, so a stronger arm prices nothing. | |
| // The positive arm is what says whether a scan opens on a picture or on a log-style veil. | |
| let arms: [(String, ConversionMode, SIMD3<Float>)] = [ | |
| ("negative, red gain \(Int(Gains.range.lowerBound)) st", .negative, | |
| SIMD3(Gains.range.lowerBound, 0, 0)), | |
| ("negative, rest", .negative, SIMD3(0, 0, 0)), | |
| ("negative, red gain +\(Int(Gains.range.upperBound)) st", .negative, | |
| SIMD3(Gains.range.upperBound, 0, 0)), | |
| ("positive, rest", .positive, SIMD3(0, 0, 0)), | |
| ] | |
| for (label, mode, gain) in arms { | |
| var block = PipelineSettings() | |
| block.mode = mode | |
| block.levels = .neutral(for: mode) | |
| block.gains.stops = gain | |
| let rendered = Pipeline.apply(decoded, settings: block) | |
| guard let read = pixels(rendered) else { continue } | |
| // The WHOLE frame and the centre. The centre alone is blind by construction to a | |
| // corner or an edge that burns, which is exactly where a scan's brightest areas sit. | |
| let whole = (0...2).map { measure(read.px, offset: $0, name: "") } | |
| let middle = centre(read) | |
| let inner = (0...2).map { measure(middle, offset: $0, name: "") } | |
| print("") | |
| print(" after the whole pipeline, \(label) — quantiles of the rendered frame") | |
| print(" channel " + fractions.map { String(format: "%7.3f", $0) }.joined() | |
| + " min max at 0 whole at 1 whole at 1 centre") | |
| for (index, name) in ["red", "green", "blue"].enumerated() { | |
| let c = whole[index] | |
| print(" " + name.padding(toLength: 8, withPad: " ", startingAt: 0) | |
| + c.quantiles.map { String(format: "%7.3f", $0) }.joined() | |
| + String(format: " %8.4f %8.4f %9.2f %% %10.2f %% %10.2f %%", | |
| c.low, c.high, c.dark * 100, c.overUnit * 100, | |
| inner[index].overUnit * 100)) | |
| } | |
| } | |
| } | |
| /// The central half of the frame, away from whatever the holder leaves around the edges. | |
| static func centre(_ read: (w: Int, h: Int, px: [Float])) -> [Float] { | |
| let x0 = read.w / 4, x1 = read.w * 3 / 4, y0 = read.h / 4, y1 = read.h * 3 / 4 | |
| var out = [Float]() | |
| out.reserveCapacity((x1 - x0) * (y1 - y0) * 4) | |
| for y in y0..<y1 { | |
| let row = y * read.w * 4 | |
| out.append(contentsOf: read.px[(row + x0 * 4)..<(row + x1 * 4)]) | |
| } | |
| return out | |
| } | |
| /// What the decode temperature costs this frame. No candidate here assumes a film's mask | |
| /// or base colour — AutoLevels' per-channel gains realign whatever a decode leaves behind. | |
| static func sweep(_ source: URL) { | |
| guard ContainerRead.probe(source) == .mosaic || RawDecode.isRaw(source) else { return } | |
| print("") | |
| print(" decode temperature red <= 0 c. green <= 0 c. blue <= 0 c. red max") | |
| for kelvin in [Float(2500), 3500, 5000, 6500] { | |
| guard let image = RawDecode.rawLinear(source, longestSide: Negative.measureSide, | |
| at: kelvin), | |
| let read = pixels(image) else { continue } | |
| let middle = centre(read) | |
| let channels = (0...2).map { measure(middle, offset: $0, name: "") } | |
| print(String(format: " %6.0f K %7.3f %% %7.3f %% %7.3f %% %8.4f", | |
| kelvin, channels[0].dark * 100, channels[1].dark * 100, | |
| channels[2].dark * 100, channels[0].high)) | |
| } | |
| print("") | |
| } | |
| /// One channel's shape. Sorted once: the quantiles and the ends then cost nothing more. | |
| static func measure(_ px: [Float], offset: Int, name: String) -> Channel { | |
| var values = [Float]() | |
| values.reserveCapacity(px.count / 4) | |
| for i in stride(from: offset, to: px.count, by: 4) { values.append(px[i]) } | |
| values.sort() | |
| let count = values.count | |
| let quantiles = fractions.map { fraction -> Float in | |
| values[min(count - 1, max(0, Int(Float(count - 1) * fraction)))] | |
| } | |
| // Piled means many pixels at the SAME value, never "below a threshold": a channel whose | |
| // low end is a spread of small numbers still carries detail, and counting those as piled | |
| // would report every scan as clipped. | |
| let ceiling = values.last ?? 0 | |
| let low = values.first ?? 0 | |
| let epsilon = max((ceiling - low) * 1e-4, 1e-7) | |
| let piledHigh = values.reduce(into: 0) { total, v in if v >= ceiling - epsilon { total += 1 } } | |
| let piledLow = values.reduce(into: 0) { total, v in if v <= low + epsilon { total += 1 } } | |
| // At or under zero, hence at or past the ceiling: the band the pedestal folds into the | |
| // 0.903 above it. Ceiling-invariant, since the pedestal is added to every one of them. | |
| let dark = values.reduce(into: 0) { total, v in if v <= 0 { total += 1 } } | |
| // Transmittance at or over 1, hence a density at or under zero. Nothing clamps it: it | |
| // reaches the levels, and a black point placed under zero is what addresses it. | |
| let over = values.reduce(into: 0) { total, v in if v >= 1 { total += 1 } } | |
| // Below minus the pedestal: the share that lands on the guard whatever the gain, since a | |
| // scalar cannot make a non-positive sum positive. | |
| let underPedestal = values.reduce(into: 0) { total, v in | |
| if v <= -Pipeline.tpedestal { total += 1 } | |
| } | |
| return Channel(name: name, low: values.first ?? 0, high: ceiling, quantiles: quantiles, | |
| onFloor: Double(piledLow) / Double(count), | |
| onCeiling: Double(piledHigh) / Double(count), | |
| dark: Double(dark) / Double(count), | |
| underPedestal: Double(underPedestal) / Double(count), | |
| overUnit: Double(over) / Double(count)) | |
| } | |
| // MARK: - Where the film falls on the axis it is graded on | |
| /// The density a decode value carries at a ceiling: the kernel's stages 4 and 5 at unit gain. | |
| /// One frame of reference for the quantiles and for the landmarks the shares are counted at. | |
| static func density(of value: Float, at ceiling: Float) -> Float { | |
| -log10(max(value + DensityAxis.pedestal(at: ceiling), DensityAxis.logGuard(at: ceiling))) | |
| } | |
| /// The values the density column is read at: real signal, the neighbourhood of zero the pedestal | |
| /// compresses, and both sides of `−pedestal`, which is where the guard takes the reading over. | |
| static let axisSweep: [Float] = [0.99, 0.5, 0.18, 0.05, 0.01, 0.002, 0, -0.004, -0.008, -0.02] | |
| /// What the table's third decimal needs. The sweep reaches values sitting on both guards, where | |
| /// the range's two ends stand exactly its own width apart, 0.503 — five hundred times this. | |
| static let axisAgreement: Float = 1e-3 | |
| /// The same quantiles in density, with the axis's own landmarks. The transmittance tables say | |
| /// what the scan holds; this one says where that lands on the ruler the handles are graduated on. | |
| static func axisTable(_ rows: [Channel]) -> String { | |
| let ceiling = PipelineSettings().densityCeiling | |
| var lines = [String(format: " the axis at ceiling %@ — its guard piles at %.3f, and the " | |
| + "four bands partition each channel", DensityAxis.readout(ceiling), | |
| density(of: -DensityAxis.pedestal(at: ceiling), at: ceiling))] | |
| lines.append(" channel " + fractions.map { String(format: "%8.3f", $0) }.joined() | |
| + " under 0 on the axis past ceiling on guard") | |
| for channel in rows { | |
| lines.append(" " + channel.name.padding(toLength: 8, withPad: " ", startingAt: 0) | |
| + channel.quantiles.map { | |
| String(format: "%8.3f", density(of: $0, at: ceiling)) | |
| }.joined() | |
| + String(format: " %8.3f %% %10.3f %% %11.3f %% %8.3f %%", | |
| channel.overUnit * 100, | |
| (1 - channel.overUnit - channel.dark) * 100, | |
| (channel.dark - channel.underPedestal) * 100, | |
| channel.underPedestal * 100)) | |
| } | |
| return lines.joined(separator: "\n") | |
| } | |
| /// One pixel per swept value, negatives included: stages 4 to 7 are per-pixel, so a whole row | |
| /// costs one graph where a patch each would cost one per value. | |
| private static func ramp(_ values: [Float]) -> CIImage? { | |
| var px = [Float](repeating: 0, count: values.count * 4) | |
| for (i, value) in values.enumerated() { | |
| for channel in 0..<3 { px[i * 4 + channel] = value } | |
| px[i * 4 + 3] = 1 | |
| } | |
| let bytes = px.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: values.count * 16, | |
| size: CGSize(width: values.count, height: 1), format: .RGBAf, colorSpace: nil) | |
| } | |
| /// The axis read back off the kernel that runs it: every level set at the identity and the clip | |
| /// skipped, so what leaves stage 7 is the density itself rather than a window's fraction of it. | |
| private static func kernelDensities(_ ctx: CIContext, at ceiling: Float) -> [Float] { | |
| var settings = PipelineSettings() | |
| settings.densityCeiling = ceiling | |
| settings.levels = LevelsSet(luma: .neutral, linked: .neutral, red: .neutral, | |
| green: .neutral, blue: .neutral) | |
| guard let row = ramp(axisSweep) else { return [] } | |
| var px = [Float](repeating: 0, count: axisSweep.count * 4) | |
| ctx.render(Pipeline.apply(row, settings: settings.truncated(before: .curves), | |
| measuring: true), | |
| toBitmap: &px, rowBytes: axisSweep.count * 16, | |
| bounds: CGRect(x: 0, y: 0, width: axisSweep.count, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return axisSweep.indices.map { px[$0 * 4] } | |
| } | |
| /// That the density column names the kernel's own axis, at both ends of what a hand can dial. | |
| static func axisChecks() -> (ok: Bool, report: String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| let ends = [DensityAxis.range.lowerBound, DensityAxis.range.upperBound] | |
| let rendered = ends.map { kernelDensities(ctx, at: $0) } | |
| /// The one reading, so the twin is the same code with the two ceilings crossed: how far the | |
| /// printed column stands from what the kernel renders, worst value of the sweep. | |
| func apart(table: Int, kernel: Int) -> Float { | |
| guard rendered[kernel].count == axisSweep.count else { return .infinity } | |
| return axisSweep.indices.map { | |
| abs(density(of: axisSweep[$0], at: ends[table]) - rendered[kernel][$0]) | |
| }.max() ?? .infinity | |
| } | |
| let worst = ends.indices.map { apart(table: $0, kernel: $0) }.max() ?? .infinity | |
| report(worst < axisAgreement, | |
| String(format: "the density column is the kernel's own axis at both ends of the " | |
| + "range: %d values, worst gap %.2e", axisSweep.count, worst)) | |
| // The twin, adverse by construction: the sweep holds values on both guards, where the range's | |
| // ends differ by its own width — so a column drawn at one ceiling cannot pass at the other. | |
| let crossed = apart(table: 0, kernel: 1) | |
| report(crossed > axisAgreement, | |
| String(format: "the twin: that column drawn at %@ and rendered at %@ stands %.4f " | |
| + "away and refuses", DensityAxis.readout(ends[0]), | |
| DensityAxis.readout(ends[1]), crossed)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| // MARK: - What raising the ceiling costs, at the size the eye judges | |
| /// The ceilings the price is read at: both ends of the slider's own range and the three raises | |
| /// the knee falls between, so the table prices exactly what a hand can dial and nothing else. | |
| static var ceilings: [Float] { [DensityAxis.base, 2.2, 2.3, 2.4, DensityAxis.range.upperBound] } | |
| /// A decade of real film transmittance, the stretch the pedestal compresses hardest. Fixed | |
| /// rather than read off quantiles, so one film's figure is comparable with another's. | |
| static let decade: ClosedRange<Float> = 0.001...0.01 | |
| /// Density that decade is rendered across, against the 1.000 it would occupy with no pedestal | |
| /// at all: what a burnt patch gets to spread over, which is the contrast a ceiling buys. | |
| static func spread(at ceiling: Float) -> Float { | |
| let pedestal = DensityAxis.pedestal(at: ceiling) | |
| return log10((decade.upperBound + pedestal) / (decade.lowerBound + pedestal)) | |
| } | |
| /// Both sides of the trade in one pass: the share at or under each `−pedestal`, which no gain | |
| /// reaches, and the share inside `decade`, which is who receives the contrast bought. | |
| static func census(_ ctx: CIContext, _ image: CIImage, at pedestals: [Float]) | |
| -> (under: [[Double]], inDecade: [Double]) { | |
| var counts = Array(repeating: [Int](repeating: 0, count: 3), count: pedestals.count) | |
| var inside = [Int](repeating: 0, count: 3) | |
| var total = 0 | |
| // The widest threshold gates the rest: nearly every pixel is positive, so the common case | |
| // costs one comparison per channel instead of one per ceiling. | |
| let widest = -(pedestals.min() ?? 0) | |
| Pipeline.bands(ctx, [image], over: image.extent, | |
| colorSpace: RawDecode.workingSpace) { read, width, rows in | |
| let px = read[0] | |
| for i in 0..<(width * rows) { | |
| for c in 0..<3 { | |
| let v = px[i * 4 + c] | |
| if v >= decade.lowerBound, v <= decade.upperBound { inside[c] += 1 } | |
| guard v <= widest else { continue } | |
| for k in pedestals.indices where v <= -pedestals[k] { counts[k][c] += 1 } | |
| } | |
| } | |
| total += width * rows | |
| } | |
| let scale = Double(max(total, 1)) | |
| return (counts.map { row in row.map { Double($0) / scale } }, | |
| inside.map { Double($0) / scale }) | |
| } | |
| /// The price of a raised ceiling at both sizes at once: a reduced frame averages hundreds of | |
| /// source pixels into one, so its figure is a different measurement and not a floor on the price. | |
| static func ceilingGrain(source: URL) -> (ok: Bool, report: String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let ctx = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| guard let reduced = RawDecode.linear(source, longestSide: Negative.measureSide), | |
| let full = RawDecode.linear(source), let read = pixels(reduced) else { | |
| return (false, " FAIL \(source.lastPathComponent) could not be decoded") | |
| } | |
| let pedestals = ceilings.map { DensityAxis.pedestal(at: $0) } | |
| let small = census(ctx, reduced, at: pedestals) | |
| let large = census(ctx, full, at: pedestals) | |
| lines.append(String(format: " ---- %@ — %.0f × %.0f against %.0f × %.0f, share at or " | |
| + "under −pedestal", source.lastPathComponent, | |
| reduced.extent.width, reduced.extent.height, | |
| full.extent.width, full.extent.height)) | |
| lines.append(String(format: " ---- the decade %.3f…%.3f holds %.2f / %.2f / %.2f %% of " | |
| + "red / green / blue at full resolution", decade.lowerBound, | |
| decade.upperBound, large.inDecade[0] * 100, large.inDecade[1] * 100, | |
| large.inDecade[2] * 100)) | |
| let side = Int(Negative.measureSide) | |
| lines.append(" ---- ceiling pedestal decade red \(side) / full " | |
| + "green \(side) / full blue \(side) / full") | |
| for (k, ceiling) in ceilings.enumerated() { | |
| lines.append(String(format: " ---- %6.3f %8.6f %5.3f " + String(repeating: | |
| "%8.4f %% /%8.4f %% ", count: 3), ceiling, pedestals[k], | |
| spread(at: ceiling), | |
| small.under[k][0] * 100, large.under[k][0] * 100, | |
| small.under[k][1] * 100, large.under[k][1] * 100, | |
| small.under[k][2] * 100, large.under[k][2] * 100)) | |
| } | |
| /// The one reading, so a twin is the same code on swapped inputs: how far the second | |
| /// measurement stands from the first, worst channel over every ceiling. | |
| func apart(_ a: [[Double]], _ b: [[Double]]) -> Double { | |
| ceilings.indices.flatMap { k in (0..<3).map { abs(b[k][$0] - a[k][$0]) } }.max() ?? 0 | |
| } | |
| let top = ceilings.count - 1 | |
| // A thousand pixels of the full frame, never a share: at 47 Mpx a population invisible as a | |
| // percentage is a thousand grains of salt, which is exactly what this measurement is for. | |
| let counted = Double(full.extent.width * full.extent.height) | |
| let deepest = ((0..<3).map { large.under[top][$0] }.max() ?? 0) * counted | |
| let measurable = deepest >= 1000 | |
| // A reduced frame prices no ceiling, and it errs both ways: on a rare tail resampling | |
| // averages the speckle away, on a bulk population it concentrates it on its mean. | |
| report(!measurable || apart(small.under, large.under) > 0, | |
| String(format: "no figure measured at %d px prices the ceiling: the two " | |
| + "resolutions stand %.4f %% of the frame apart at worst, %.0f px under the " | |
| + "pedestal at the top ceiling%@", side, | |
| apart(small.under, large.under) * 100, deepest, | |
| measurable ? "" : " — too few to read a gap on this frame")) | |
| // The twin, adverse by construction: that same reading with one resolution in BOTH slots. | |
| // A measurement cannot stand apart from itself, so the sentence above must refuse it. | |
| report(!measurable || apart(large.under, large.under) <= 0, | |
| String(format: "the twin: that reading run on one resolution twice finds %.5f %% " | |
| + "and refuses", apart(large.under, large.under) * 100)) | |
| // The table's baseline against the counter `--film` prints, which sorts the channel instead | |
| // of sweeping thresholds: two independent counts, so a drift between them cannot hide. | |
| let probe = (0..<3).map { measure(read.px, offset: $0, name: "").underPedestal } | |
| let drift = (0..<3).map { abs(probe[$0] - small.under[0][$0]) }.max() ?? 0 | |
| report(drift < 1e-6, | |
| String(format: "and the 500 px baseline is the share the probe reports, counted " | |
| + "another way: %.4f %% against %.4f %%, apart by %.2e", | |
| probe.max().map { $0 * 100 } ?? 0, | |
| ((0..<3).map { small.under[0][$0] }.max() ?? 0) * 100, drift)) | |
| // The twin: the same comparison against the full-resolution column, which is a different | |
| // population — so the agreement above belongs to the frame read, not to the counter. | |
| let atReference = ((0..<3).map { large.under[0][$0] }.max() ?? 0) * counted | |
| let elsewhere = (0..<3).map { abs(probe[$0] - large.under[0][$0]) }.max() ?? 0 | |
| report(atReference < 1000 || elsewhere > drift, | |
| String(format: "the twin: the same reading against the full-resolution column " | |
| + "stands %.2e away and refuses", elsewhere)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// The whole image as interleaved RGBA floats in the working space. | |
| private static func pixels(_ image: CIImage) -> (w: Int, h: Int, px: [Float])? { | |
| let ctx = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| let w = Int(image.extent.width), h = Int(image.extent.height) | |
| guard w >= 1, h >= 1 else { return nil } | |
| var buf = [Float](repeating: 0, count: w * h * 4) | |
| buf.withUnsafeMutableBytes { p in | |
| ctx.render(image, toBitmap: p.baseAddress!, rowBytes: w * 16, | |
| bounds: CGRect(x: 0, y: 0, width: w, height: h), | |
| format: .RGBAf, colorSpace: RawDecode.workingSpace) | |
| } | |
| return (w, h, buf) | |
| } | |
| } | |
| import Foundation | |
| /// One entry in the closed, hand-curated list of film stocks a roll can be labelled with — data, | |
| /// not an enum, since the list grows by adding a row and an icon, never by touching a switch. | |
| struct FilmStockKind: Identifiable, Equatable, Hashable, Sendable { | |
| /// Stable key, stored on `Film.stock` — never the display name, so renaming a stock in the | |
| /// picker cannot orphan every roll already carrying the old string. | |
| let id: String | |
| let displayName: String | |
| /// The vendored SVG's own file name in `Resources/Icons`, without extension. Absent from the | |
| /// bundle is an expected state here, unlike `DSIcon`'s own set — `FilmStockIcon` falls back. | |
| let iconName: String | |
| /// What the full picker groups and heads its sections by — its own field, not parsed out of | |
| /// `id`, since a hyphenated id like `lomography-lomo-turquoise` does not split cleanly. | |
| let brand: String | |
| } | |
| /// The list itself, one row per vendored icon — growing it is adding a row and an SVG, never | |
| /// touching this file's own shape. `id`/`iconName` read the same today, kept separate for a redraw. | |
| enum FilmStockCatalog { | |
| static let all: [FilmStockKind] = [ | |
| FilmStockKind(id: "generic-azure", displayName: "Generic Azure", iconName: "generic-azure", | |
| brand: "Generic"), | |
| FilmStockKind(id: "generic-black-and-white", displayName: "Generic Black & White", | |
| iconName: "generic-black-and-white", brand: "Generic"), | |
| FilmStockKind(id: "generic-color-negative", displayName: "Generic Color Negative", | |
| iconName: "generic-color-negative", brand: "Generic"), | |
| FilmStockKind(id: "generic-color-positive", displayName: "Generic Color Positive", | |
| iconName: "generic-color-positive", brand: "Generic"), | |
| FilmStockKind(id: "generic-redscale", displayName: "Generic Redscale", | |
| iconName: "generic-redscale", brand: "Generic"), | |
| FilmStockKind(id: "agfaphoto-apx", displayName: "AgfaPhoto APX", iconName: "agfaphoto-apx", | |
| brand: "AgfaPhoto"), | |
| FilmStockKind(id: "cinestill-400d", displayName: "CineStill 400D", iconName: "cinestill-400d", | |
| brand: "CineStill"), | |
| FilmStockKind(id: "cinestill-50d", displayName: "CineStill 50D", iconName: "cinestill-50d", | |
| brand: "CineStill"), | |
| FilmStockKind(id: "cinestill-800t", displayName: "CineStill 800T", iconName: "cinestill-800t", | |
| brand: "CineStill"), | |
| FilmStockKind(id: "foma-fomapan200", displayName: "Foma Fomapan 200", | |
| iconName: "foma-fomapan200", brand: "Foma"), | |
| FilmStockKind(id: "foma-fomapan400", displayName: "Foma Fomapan 400", | |
| iconName: "foma-fomapan400", brand: "Foma"), | |
| FilmStockKind(id: "foma-fomapan401", displayName: "Foma Fomapan 401", | |
| iconName: "foma-fomapan401", brand: "Foma"), | |
| FilmStockKind(id: "fujifilm-acros", displayName: "Fujifilm Acros", iconName: "fujifilm-acros", | |
| brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-fujicolor", displayName: "Fujifilm Fujicolor", | |
| iconName: "fujifilm-fujicolor", brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-fujicolor-pro", displayName: "Fujifilm Fujicolor Pro", | |
| iconName: "fujifilm-fujicolor-pro", brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-provia100", displayName: "Fujifilm Provia 100", | |
| iconName: "fujifilm-provia100", brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-superia", displayName: "Fujifilm Superia", | |
| iconName: "fujifilm-superia", brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-velvia100", displayName: "Fujifilm Velvia 100", | |
| iconName: "fujifilm-velvia100", brand: "Fujifilm"), | |
| FilmStockKind(id: "fujifilm-velvia50", displayName: "Fujifilm Velvia 50", | |
| iconName: "fujifilm-velvia50", brand: "Fujifilm"), | |
| FilmStockKind(id: "harman-phoenix", displayName: "Harman Phoenix", iconName: "harman-phoenix", | |
| brand: "Harman"), | |
| FilmStockKind(id: "harman-phoenixII", displayName: "Harman Phoenix II", | |
| iconName: "harman-phoenixII", brand: "Harman"), | |
| FilmStockKind(id: "harman-red", displayName: "Harman Red", iconName: "harman-red", | |
| brand: "Harman"), | |
| FilmStockKind(id: "ilford-delta100", displayName: "Ilford Delta 100", | |
| iconName: "ilford-delta100", brand: "Ilford"), | |
| FilmStockKind(id: "ilford-delta3200", displayName: "Ilford Delta 3200", | |
| iconName: "ilford-delta3200", brand: "Ilford"), | |
| FilmStockKind(id: "ilford-delta400", displayName: "Ilford Delta 400", | |
| iconName: "ilford-delta400", brand: "Ilford"), | |
| FilmStockKind(id: "ilford-fp4", displayName: "Ilford FP4", iconName: "ilford-fp4", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "ilford-hp5", displayName: "Ilford HP5", iconName: "ilford-hp5", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "ilford-ortho", displayName: "Ilford Ortho", iconName: "ilford-ortho", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "ilford-panf", displayName: "Ilford Pan F", iconName: "ilford-panf", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "ilford-sfx", displayName: "Ilford SFX", iconName: "ilford-sfx", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "ilford-xp2", displayName: "Ilford XP2", iconName: "ilford-xp2", | |
| brand: "Ilford"), | |
| FilmStockKind(id: "kodak-400tx", displayName: "Kodak Tri-X 400", iconName: "kodak-400tx", | |
| brand: "Kodak"), | |
| FilmStockKind(id: "kodak-colorplus", displayName: "Kodak ColorPlus", | |
| iconName: "kodak-colorplus", brand: "Kodak"), | |
| FilmStockKind(id: "kodak-ektachrome", displayName: "Kodak Ektachrome", | |
| iconName: "kodak-ektachrome", brand: "Kodak"), | |
| FilmStockKind(id: "kodak-gold", displayName: "Kodak Gold", iconName: "kodak-gold", | |
| brand: "Kodak"), | |
| FilmStockKind(id: "kodak-portra", displayName: "Kodak Portra", iconName: "kodak-portra", | |
| brand: "Kodak"), | |
| FilmStockKind(id: "kodak-ultramax", displayName: "Kodak UltraMax", iconName: "kodak-ultramax", | |
| brand: "Kodak"), | |
| FilmStockKind(id: "lomography-lomo-turquoise", displayName: "Lomography LomoChrome Turquoise", | |
| iconName: "lomography-lomo-turquoise", brand: "Lomography"), | |
| FilmStockKind(id: "lucky-color200", displayName: "Lucky Color 200", | |
| iconName: "lucky-color200", brand: "Lucky"), | |
| FilmStockKind(id: "mira-color400", displayName: "Mira Color 400", | |
| iconName: "mira-color400", brand: "Mira"), | |
| FilmStockKind(id: "mira-color800", displayName: "Mira Color 800", | |
| iconName: "mira-color800", brand: "Mira"), | |
| ] | |
| static func kind(for id: String?) -> FilmStockKind? { | |
| guard let id else { return nil } | |
| return all.first { $0.id == id } | |
| } | |
| /// A roll's generic stock while nobody has picked a real one — read off the mode a hand | |
| /// already set on its first photo, never guessed from the image itself. | |
| static func defaultID(for mode: ConversionMode) -> String { | |
| switch mode { | |
| case .negative: "generic-color-negative" | |
| case .positive: "generic-color-positive" | |
| case .monochrome: "generic-black-and-white" | |
| } | |
| } | |
| /// The full picker's own shape: one section per brand, `Generic` always first so an unnamed | |
| /// roll's fallback options are the first thing a hand reaches, the rest alphabetical after it. | |
| static var byBrand: [(brand: String, kinds: [FilmStockKind])] { | |
| var order: [String] = [] | |
| var buckets: [String: [FilmStockKind]] = [:] | |
| for kind in all { | |
| if buckets[kind.brand] == nil { order.append(kind.brand) } | |
| buckets[kind.brand, default: []].append(kind) | |
| } | |
| let rest = order.filter { $0 != "Generic" }.sorted() | |
| let brands = (order.contains("Generic") ? ["Generic"] : []) + rest | |
| return brands.map { ($0, buckets[$0] ?? []) } | |
| } | |
| } | |
| extension FilmStockCatalog { | |
| nonisolated static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var report = "" | |
| func check(_ passed: Bool, _ label: String) { | |
| ok = ok && passed | |
| report += " \(passed ? "OK " : "FAIL") \(label)\n" | |
| } | |
| check(!all.isEmpty, "the catalogue is not empty") | |
| check(Set(all.map(\.id)).count == all.count, | |
| "every stock's id is unique — a duplicate would silently swallow one on lookup") | |
| check(all.allSatisfy { !$0.id.isEmpty && !$0.displayName.isEmpty && !$0.iconName.isEmpty | |
| && !$0.brand.isEmpty }, | |
| "no stock carries a blank id, name, icon reference or brand") | |
| check(kind(for: "kodak-gold")?.displayName == "Kodak Gold", | |
| "a known id resolves to its own kind") | |
| check(kind(for: "not-a-real-stock") == nil, | |
| "and the check discriminates: an unknown id resolves to nothing rather than the first") | |
| check(kind(for: nil) == nil, "a nil id resolves to nothing, the picker's own \"unset\" state") | |
| check(kind(for: defaultID(for: .negative))?.displayName == "Generic Color Negative" | |
| && kind(for: defaultID(for: .positive))?.displayName == "Generic Color Positive" | |
| && kind(for: defaultID(for: .monochrome))?.displayName == "Generic Black & White", | |
| "every mode's default resolves to a real, distinct catalogue entry") | |
| check(Set(ConversionMode.allCases.map(defaultID(for:))).count | |
| == ConversionMode.allCases.count, | |
| "and the check discriminates: no two modes share one default") | |
| let grouped = byBrand | |
| check(grouped.first?.brand == "Generic", "the generic fallbacks head the grouped picker") | |
| check(grouped.dropFirst().map(\.brand) == grouped.dropFirst().map(\.brand).sorted(), | |
| "every brand after Generic is alphabetical") | |
| check(Set(grouped.flatMap(\.kinds)) == Set(all) && grouped.flatMap(\.kinds).count == all.count, | |
| "grouping neither drops a stock nor duplicates one") | |
| // Twin, adverse by construction: a grouping that merged two brands or split one in two | |
| // would still pass the set-equality check above, since it only tests membership. | |
| check(grouped.allSatisfy { section in section.kinds.allSatisfy { $0.brand == section.brand } }, | |
| "and the check discriminates: every kind in a section actually carries that section's own brand") | |
| return (ok, report) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Divides the frame by the light the panel put on it, run before stage 0 so the mask stays in | |
| /// the scan's own frame and commutes exactly with the per-channel gains regardless of order. | |
| struct FlatField: Codable, Equatable, Hashable, Sendable { | |
| /// Which mask, from a closed list shipped inside the app. | |
| enum Mask: String, Codable, CaseIterable, Identifiable, Sendable { | |
| case none = "None" | |
| case valoiEasy35V1 = "Valoi Easy35 V1" | |
| var id: String { rawValue } | |
| /// The file in `Contents/Resources/FlatFields`, or `nil` for `none`. | |
| var fileName: String? { | |
| switch self { | |
| case .none: nil | |
| case .valoiEasy35V1: "valoi-easy35-v1" | |
| } | |
| } | |
| } | |
| var mask: Mask = .none | |
| /// How much of the mask to apply, 0 → 1. At 0 the stage is the identity, exactly. | |
| var amount: Float = 0.2 | |
| /// Off when no mask is chosen or the amount is zero, since the UI can only reach both together. | |
| var isNeutral: Bool { mask == .none || amount <= 0 } | |
| static let amountRange: ClosedRange<Float> = 0...1 | |
| /// The mask image, decoded once per mask and kept; missing file returns `nil` rather than | |
| /// crashing, read with `colorSpace: nil` so it isn't treated as a color and gamma-corrected. | |
| static func image(for mask: Mask) -> CIImage? { images[mask] } | |
| private static let images: [Mask: CIImage] = | |
| Dictionary(uniqueKeysWithValues: Mask.allCases.compactMap { mask in | |
| decode(mask).map { (mask, $0) } | |
| }) | |
| private static func decode(_ mask: Mask) -> CIImage? { | |
| guard let name = mask.fileName, | |
| let url = Bundle.main.url(forResource: name, withExtension: "png", | |
| subdirectory: "FlatFields") | |
| ?? Bundle.main.url(forResource: name, withExtension: "png") | |
| else { return nil } | |
| return CIImage(contentsOf: url, options: [.colorSpace: NSNull()]) | |
| } | |
| /// The mask, stretched onto `extent` and normalised so its brightest point is 1 — keeping a | |
| /// 100% amount a correction rather than a global darkening, since the source PNG's peak isn't 1. | |
| static func fitted(_ mask: Mask, to extent: CGRect, peak: CGFloat) -> CIImage? { | |
| guard let source = image(for: mask), source.extent.width > 0, source.extent.height > 0 | |
| else { return nil } | |
| let scaled = source.transformed(by: CGAffineTransform( | |
| scaleX: extent.width / source.extent.width, | |
| y: extent.height / source.extent.height)) | |
| let placed = scaled.transformed(by: CGAffineTransform( | |
| translationX: extent.minX - scaled.extent.minX, | |
| y: extent.minY - scaled.extent.minY)) | |
| guard peak > 0 else { return placed } | |
| return placed.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputRVector": CIVector(x: 1 / peak, y: 0, z: 0, w: 0), | |
| "inputGVector": CIVector(x: 0, y: 1 / peak, z: 0, w: 0), | |
| "inputBVector": CIVector(x: 0, y: 0, z: 1 / peak, w: 0), | |
| ]) | |
| } | |
| /// The brightest value in a mask, measured once and memoised as a thread-safe `static let`. | |
| static func peak(of mask: Mask) -> CGFloat { peaks[mask] ?? 0 } | |
| private static let peaks: [Mask: CGFloat] = | |
| Dictionary(uniqueKeysWithValues: Mask.allCases.map { ($0, measurePeak($0)) }) | |
| private static func measurePeak(_ mask: Mask) -> CGFloat { | |
| guard let source = image(for: mask) else { return 0 } | |
| let maxFilter = source.applyingFilter("CIAreaMaximum", parameters: [ | |
| kCIInputExtentKey: CIVector(cgRect: source.extent), | |
| ]) | |
| var px = [Float](repeating: 0, count: 4) | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| ctx.render(maxFilter, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return CGFloat(px[1]) | |
| } | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var report = "" | |
| func check(_ passed: Bool, _ label: String) { | |
| ok = ok && passed | |
| report += " \(passed ? "OK " : "FAIL") \(label)\n" | |
| } | |
| check(FlatField().isNeutral, "at rest, the stage is neutral") | |
| check(FlatField(mask: .valoiEasy35V1, amount: 0).isNeutral, | |
| "and a zero amount also turns it off, mask chosen or not") | |
| check(!FlatField(mask: .valoiEasy35V1, amount: 1).isNeutral, | |
| "and the check discriminates: mask chosen and full amount, the stage runs") | |
| // Catches a build that forgot to bundle the mask file, which would otherwise fail silently. | |
| let loaded = image(for: .valoiEasy35V1) | |
| check(loaded != nil, "the Valoi Easy35 V1 mask is present in the bundle") | |
| // Verifies the cache reuses the same object identity, not just equal pixels, since Core | |
| // Image keys its intermediates on identity. | |
| check(image(for: .valoiEasy35V1) === image(for: .valoiEasy35V1), | |
| "and two reads render the SAME object, so the stretched intermediate is reused") | |
| // The twin: a fresh decode, on the other hand, must give a DIFFERENT object — without which | |
| // the check above would pass on anything at all, the code of before included. | |
| check(decode(.valoiEasy35V1) !== decode(.valoiEasy35V1), | |
| "and the check discriminates: two fresh decodes give two distinct objects") | |
| check(peak(of: .none) == 0, | |
| "the neutral mask renders a maximum of 0 without touching disk") | |
| if let loaded { | |
| check(loaded.extent.width > 100 && loaded.extent.height > 100, String(format: | |
| "and it has plausible dimensions (%.0f × %.0f)", | |
| loaded.extent.width, loaded.extent.height)) | |
| let top = peak(of: .valoiEasy35V1) | |
| check(top > 0.5 && top <= 1.0001, String(format: | |
| "its maximum is %.4f, so the normalisation makes sense", top)) | |
| // Normalised, the brightest point must land on 1: that is what makes an amount of 100 % | |
| // a correction rather than a global darkening. | |
| if let fitted = fitted(.valoiEasy35V1, to: CGRect(x: 0, y: 0, width: 64, height: 64), | |
| peak: top) { | |
| check(fitted.extent.width == 64 && fitted.extent.height == 64, | |
| "and it stretches exactly onto the image it is given") | |
| } else { | |
| check(false, "the mask could not be stretched") | |
| } | |
| } | |
| // Verifies the correction darkens transmittance in the corner, not brightens it — a check | |
| // that would stay green even if the division were mistakenly written as a multiplication. | |
| let side = 240 | |
| let flat = CIImage(color: CIColor(red: 0.5, green: 0.5, blue: 0.5)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: side, height: side)) | |
| var settings = PipelineSettings() | |
| settings.flatField = FlatField(mask: .valoiEasy35V1, amount: 1) | |
| let corrected = Pipeline.apply(flat, settings: settings, measuring: true) | |
| var neutralSettings = PipelineSettings() | |
| neutralSettings.flatField = FlatField() | |
| let untouched = Pipeline.apply(flat, settings: neutralSettings, measuring: true) | |
| let ctx = CIContext(options: [.workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf]) | |
| func read(_ image: CIImage, x: Int, y: Int) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: x, y: y, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: RawDecode.workingSpace) | |
| return px[1] | |
| } | |
| // The corner is where the panel is dimmest, so it is where the correction is strongest. | |
| let cornerOn = read(corrected, x: 3, y: 3), cornerOff = read(untouched, x: 3, y: 3) | |
| let midOn = read(corrected, x: side / 2, y: side / 2) | |
| let midOff = read(untouched, x: side / 2, y: side / 2) | |
| // In negative mode the pipeline inverts, so more transmittance comes out darker: dividing | |
| // by a mask below 1 raises T in the corner, hence lowers E there. | |
| check(cornerOn < cornerOff - 0.01, String(format: | |
| "the corner does receive the correction (E %.4f vs %.4f without mask)", | |
| cornerOn, cornerOff)) | |
| check(abs(midOn - midOff) < abs(cornerOn - cornerOff) / 2, String(format: | |
| "and the centre is indeed much less affected than the corner (Δ %.4f vs %.4f)", | |
| abs(midOn - midOff), abs(cornerOn - cornerOff))) | |
| // Identity at zero amount, exactly — the condition for "None" and a zero slider to be the | |
| // same picture, which the interface promises by disabling the slider. | |
| var offSettings = PipelineSettings() | |
| offSettings.flatField = FlatField(mask: .valoiEasy35V1, amount: 0) | |
| let off = Pipeline.apply(flat, settings: offSettings, measuring: true) | |
| check(read(off, x: 3, y: 3) == cornerOff, | |
| "at zero amount, the stage is the identity down to the bit") | |
| return (ok, report) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Verifies that no stage quantizes its output, including between two nodes of the graph. | |
| enum FloatInvariant { | |
| /// One more node in the graph: an identity matrix, to prove nothing quantizes between nodes. | |
| private static func neutralNode(_ image: CIImage) -> CIImage { | |
| image.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputRVector": CIVector(x: 1, y: 0, z: 0, w: 0), | |
| "inputGVector": CIVector(x: 0, y: 1, z: 0, w: 0), | |
| "inputBVector": CIVector(x: 0, y: 0, z: 1, w: 0), | |
| "inputAVector": CIVector(x: 0, y: 0, z: 0, w: 1), | |
| ]) | |
| } | |
| static func check() -> (ok: Bool, report: String) { | |
| // Colour management disabled: measures the chain, not a space conversion. | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), | |
| .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| // Non-trivial levels: an arbitrary float output, neither 0 nor 1, so quantization | |
| // would not go unnoticed. | |
| let levels = Levels(black: 0.05, white: 0.9, mid: 0.4) | |
| var lines: [String] = [] | |
| var ok = true | |
| // Covers both conversion modes: the same kernel with one more uniform, but an | |
| // invariant checked on one branch only describes half the real graph. | |
| for mode in ConversionMode.allCases { | |
| // A wide extent is required: neighbourhood radii are indexed on it, and a source too | |
| // small drops several stages below their activation guard, leaving them untested. | |
| let side = CGSize(width: 2048, height: 1365) | |
| // An interior pixel: a blur reads its neighbours, and a corner would mostly read the | |
| // clampedToExtent smear. | |
| let probe = CGRect(x: side.width / 2, y: side.height / 2, width: 1, height: 1) | |
| func render(_ t: Float, nodes: Int, context: CIContext = ctx) -> Float { | |
| var img = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(origin: .zero, size: side)) | |
| // Zone saturation and the flat-field are set non-neutral on purpose: at rest their | |
| // kernels are absent from the graph, hence untested. | |
| img = Pipeline.apply(img, settings: PipelineSettings( | |
| mode: mode, | |
| flatField: FlatField(mask: .valoiEasy35V1, amount: 0.5), | |
| levels: LevelsSet(linked: levels), | |
| saturation: ZoneSaturation(shadows: 0.2, highlights: -0.2)), | |
| // measuring: true skips the shoulder, a deliberate compressor; testing through | |
| // it would mistake a requested plateau for quantization. | |
| measuring: true) | |
| for _ in 0..<nodes { img = neutralNode(img) } | |
| var px = [Float](repeating: 0, count: 4) | |
| context.render(img, toBitmap: &px, rowBytes: 16, | |
| bounds: probe, | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| // 1 — ten neutral nodes stacked: bit-for-bit equality, not a tolerance, matching how | |
| // the invariant is stated. | |
| let bare = render(0.42, nodes: 0) | |
| let stacked = render(0.42, nodes: 10) | |
| let identical = bare.bitPattern == stacked.bitPattern | |
| ok = ok && identical | |
| lines.append(String(format: " %@ [\(mode.rawValue)] 10 stacked neutral nodes: %.9f (0x%08X) vs %.9f (0x%08X)", | |
| identical ? "OK " : "FAIL ", | |
| bare, bare.bitPattern, stacked, stacked.bitPattern)) | |
| // 2 — two inputs 5e-6 apart, three times below a 16-bit step: an integer buffer, | |
| // even at 16 bits, would conflate them. | |
| let a = render(0.42, nodes: 10) | |
| let b = render(0.42 + 5e-6, nodes: 10) | |
| let resolved = a.bitPattern != b.bitPattern | |
| ok = ok && resolved | |
| lines.append(String(format: " %@ [\(mode.rawValue)] 5e-6 gap preserved across 10 nodes: %.9f vs %.9f (Δ %.3e)", | |
| resolved ? "OK " : "FAIL ", a, b, abs(b - a))) | |
| // 2 bis — the twin: the same graph rendered through an RGBA8 context must lose the | |
| // 5e-6 gap, proving the check can actually detect quantization. | |
| let quantising = CIContext(options: [.workingColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBA8]) | |
| let qa = render(0.42, nodes: 10, context: quantising) | |
| let qb = render(0.42 + 5e-6, nodes: 10, context: quantising) | |
| let conflated = qa.bitPattern == qb.bitPattern | |
| ok = ok && conflated | |
| lines.append(String(format: " %@ [\(mode.rawValue)] and the check DISCRIMINATES: in RGBA8 the same gap is lost (%.9f vs %.9f)", | |
| conflated ? "OK " : "FAIL ", qa, qb)) | |
| // The curves pass samples a 1024-entry table, so it isn't bit-exact even for the identity — | |
| // a sampling error, not quantization. What must hold: neighbouring inputs stay distinct. | |
| let flat = CurveSet(linked: Curve(points: [CGPoint(x: 0, y: 0), | |
| CGPoint(x: 0.5, y: 0.5), | |
| CGPoint(x: 1, y: 1)])) | |
| // Same settings as render above, isolating the curves stage — but with a small extent: | |
| // at the wide extent the 5e-6 gap would slip under the sampler's sub-texel precision. | |
| func through(_ t: Float, curves: CurveSet) -> Float { | |
| let img = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| let out = Pipeline.apply(img, settings: PipelineSettings( | |
| mode: mode, | |
| levels: LevelsSet(linked: levels), | |
| curves: curves)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(out, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| func throughCurves(_ t: Float) -> Float { through(t, curves: flat) } | |
| /// This block's reference: the same graph without curves. | |
| let plain = through(0.42, curves: CurveSet()) | |
| let curved = throughCurves(0.42) | |
| let close = abs(curved - plain) < 1e-5 | |
| ok = ok && close | |
| lines.append(String(format: " %@ [\(mode.rawValue)] identity curve via LUT: %.9f vs %.9f (Δ %.2e, interpolated table so not bit-exact)", | |
| close ? "OK " : "FAIL ", curved, plain, abs(curved - plain))) | |
| let ca = throughCurves(0.42) | |
| let cb = throughCurves(0.42 + 5e-6) | |
| let curveResolves = ca.bitPattern != cb.bitPattern | |
| ok = ok && curveResolves | |
| lines.append(String(format: " %@ [\(mode.rawValue)] 5e-6 gap preserved across the sampler kernel: %.9f vs %.9f", | |
| curveResolves ? "OK " : "FAIL ", ca, cb)) | |
| } | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| /// Per-channel gains, in stops: `gain = 2^s`. A multiplication, unlike the finishing balance, | |
| /// which needs a different shape to move highlights independently of shadows. | |
| struct Gains: Codable, Equatable, Hashable, Sendable { | |
| var stops = SIMD3<Float>(repeating: 0) | |
| static let base: Float = 0 | |
| /// Keeps a stray drag from doubling a channel's exposure over a short slider travel. | |
| static let range: ClosedRange<Float> = -3...3 | |
| var linear: SIMD3<Float> { SIMD3(exp2(stops.x), exp2(stops.y), exp2(stops.z)) } | |
| var vector: CIVector { | |
| CIVector(x: Double(exp2(stops.x)), y: Double(exp2(stops.y)), z: Double(exp2(stops.z))) | |
| } | |
| } | |
| import Foundation | |
| /// Keeps the measurement as raw pixel counts; scaling for display is a presentation concern. | |
| struct Histogram: Equatable { | |
| var rgb: [SIMD3<Float>] = [] | |
| var luma: [Float] = [] | |
| var isEmpty: Bool { rgb.count < 2 } | |
| /// Reports where the image falls relative to the fixed zone-saturation boundary. | |
| var zoneBalance: (shadows: Int, highlights: Int)? { | |
| guard !luma.isEmpty else { return nil } | |
| let total = luma.reduce(0, +) | |
| guard total > 0 else { return nil } | |
| // Bin corresponding to the split point, expressed in linear luminance. | |
| let boundary = Int(Double(ZoneSaturation.splitLuminance) * Double(luma.count - 1)) | |
| let below = luma.prefix(max(boundary, 0)).reduce(0, +) | |
| let share = Int((below / total * 100).rounded()) | |
| return (share, 100 - share) | |
| } | |
| static let empty = Histogram() | |
| } | |
| /// The window of the density axis a measurement is counted on, mapped onto 0…1 so a bin index and | |
| /// a handle position name the same point on it. | |
| enum Graduation { | |
| /// How far under zero the window reaches. A constant and not a bound — a gain drives the | |
| /// density arbitrarily low — chosen to clear the −0.243 a test film holds by 44 %. | |
| static let overshoot: Float = 0.35 | |
| /// The densest value the axis can express, tied to the guard at the RESTING ceiling so one | |
| /// window graduates every frame: a raised ceiling reaches past it, into the same last bin. | |
| static var top: Float { -log10(Pipeline.tmin) } | |
| /// The same in both modes: stage 6 mirrors the axis, it does not stretch it. One bin is | |
| /// `span / bins` of density, which is what the window costs in resolution. | |
| static var span: Float { top + overshoot } | |
| /// Read off `invertFlag`, the kernel's own switch: the positive branch re-inverts on | |
| /// `Pipeline.dmax`, so the window mirrors with it, and monochrome inverts like a negative. | |
| static func window(for mode: ConversionMode) -> ClosedRange<Float> { | |
| mode.invertFlag > 0.5 ? (-overshoot)...top | |
| : (Pipeline.dmax - top)...(Pipeline.dmax + overshoot) | |
| } | |
| /// The window as the affine map the kernel already applies, so it costs no pass — and the floor | |
| /// at the window's black is the kernel's own `max(n, 0)`. | |
| static func levels(for mode: ConversionMode) -> Levels { | |
| let window = window(for: mode) | |
| return Levels(black: window.lowerBound, white: window.upperBound) | |
| } | |
| /// Where a stage-6 value falls on the counted range. Deliberately unbounded: what lands outside | |
| /// is the measuring path's to bound, and it is the one place that does. | |
| static func fraction(of value: Float, in mode: ConversionMode) -> Float { | |
| (value - window(for: mode).lowerBound) / span | |
| } | |
| /// The exact inverse, which is how a bin index or a handle position reads back onto the axis. | |
| static func value(atFraction fraction: Float, in mode: ConversionMode) -> Float { | |
| window(for: mode).lowerBound + fraction * span | |
| } | |
| } | |
| /// A throttle, not a debounce: the first measurement fires immediately and later ones are spaced by | |
| /// `interval`, so a continuous drag still tracks the hand instead of only updating once it stops. | |
| @MainActor | |
| enum HistogramThrottle { | |
| /// Caps measurements near screen refresh rate; bursts above it would just fight the GPU for no | |
| /// visible gain. | |
| static let interval: TimeInterval = 0.012 | |
| /// The cadence under a held hand, aimed at sixty a second: the measure runs on a quarter of | |
| /// the pixels there, so one fits under a frame — and it stays detached, costing main nothing. | |
| static let heldInterval: TimeInterval = 0.016 | |
| /// One place decides the cadence, so a check can hold both branches without waiting a clock. | |
| static func cadence(held: Bool) -> TimeInterval { held ? heldInterval : interval } | |
| private static var lastRun: [String: TimeInterval] = [:] | |
| /// Waits only long enough to hold the cadence; returns `false` if cancelled while waiting. | |
| /// The last write's task outlives its cancelled predecessors, so the trailing edge lands. | |
| static func wait(_ key: String, held: Bool = false) async -> Bool { | |
| let now = Date.timeIntervalSinceReferenceDate | |
| let since = now - (lastRun[key] ?? 0) | |
| let interval = cadence(held: held) | |
| if since < interval { | |
| let remaining = interval - since | |
| try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) | |
| if Task.isCancelled { return false } | |
| } | |
| lastRun[key] = Date.timeIntervalSinceReferenceDate | |
| return true | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| import SwiftUI | |
| /// The five levels tabs, parameterised by a single channel type to avoid duplicated views. | |
| enum LevelsChannel: String, CaseIterable, Identifiable, Sendable { | |
| case luma = "Luma" | |
| case linked = "RGB" | |
| case red = "R" | |
| case green = "G" | |
| case blue = "B" | |
| var id: String { rawValue } | |
| /// The channels of the first effect: correcting the cast, channel by channel. | |
| static let perChannel: [LevelsChannel] = [.red, .green, .blue] | |
| /// The channels of the second effect, applied after the first, shaping overall tonality. | |
| static let global: [LevelsChannel] = [.linked, .luma] | |
| /// What the two global sets are FOR is a distinction between linked channels and perceived | |
| /// luminance, which a monochrome render has already collapsed: the richer window is kept. | |
| static func global(in mode: ConversionMode) -> [LevelsChannel] { | |
| mode.isMonochrome ? [.linked] : global | |
| } | |
| /// The channel's tint. Linked and luma have no colour of their own. | |
| var tint: Color { | |
| switch self { | |
| case .luma, .linked: .primary | |
| case .red: .red | |
| case .green: .green | |
| case .blue: .blue | |
| } | |
| } | |
| /// The channel whose histogram is brought forward. `nil` = all three. | |
| var histogramChannel: Int? { | |
| switch self { | |
| case .red: 0 | |
| case .green: 1 | |
| case .blue: 2 | |
| case .luma, .linked: nil | |
| } | |
| } | |
| } | |
| // MARK: - The unit a set's handles carry, and the track that expresses it | |
| extension LevelsChannel { | |
| /// True where the handles carry a density: the three per-channel windows, whose input is the | |
| /// density axis. The global ones read that window's dimensionless output instead. | |
| var isDensity: Bool { Self.perChannel.contains(self) } | |
| /// The domain the track expresses. On the per-channel sets it is the graduation window, which | |
| /// begins **under zero** — that is what lets a black point reach a channel transmitting past 1. | |
| func domain(for mode: ConversionMode) -> ClosedRange<Float> { | |
| isDensity ? Graduation.window(for: mode) : 0...1 | |
| } | |
| /// The same domain with room OUTSIDE the signal, for the shape drawing these as plain sliders. | |
| /// The handle track is graduated ON DATA — its five rest a quarter apart — so it cannot take it. | |
| func reachable(for mode: ConversionMode) -> ClosedRange<Float> { | |
| guard !isDensity else { return domain(for: mode) } | |
| return -Levels.globalHeadroom...(1 + Levels.globalHeadroom) | |
| } | |
| /// Where a value sits on the track, 0 to 1 — never the value itself once the domain starts under | |
| /// zero. It is the graduation's own fraction, so a handle and a histogram bin name one point. | |
| func position(ofValue value: Float, in mode: ConversionMode) -> Float { | |
| let domain = domain(for: mode) | |
| return (value - domain.lowerBound) / (domain.upperBound - domain.lowerBound) | |
| } | |
| /// The exact inverse: the one path a drag or a scroll writes a value through. | |
| func value(atPosition position: Float, in mode: ConversionMode) -> Float { | |
| let domain = domain(for: mode) | |
| return domain.lowerBound + position * (domain.upperBound - domain.lowerBound) | |
| } | |
| /// How a handle reads: three decimals of density where that is the unit values are stored in, | |
| /// a share of the window's output where there is no unit to state. | |
| func readout(_ value: Float) -> String { | |
| isDensity ? String(format: "%.3f", value) : String(format: "%.1f%%", value * 100) | |
| } | |
| } | |
| /// The five sets of levels of a layer, applied channels first, linked second, then luma — | |
| /// mirroring the gesture of correcting the cast per channel before steering the linked set. | |
| struct LevelsSet: Codable, Equatable, Hashable, Sendable { | |
| var luma = Levels() | |
| /// Starts at negative mode's resting point since that is `PipelineSettings()`'s default. | |
| /// A visible, movable, undoable handle value, not a preset applied on import. | |
| var linked = Levels(mid: Levels.restingMid(for: .negative)) | |
| /// The three windows rest on the top of the density axis, the unit their handles carry. | |
| /// `Levels()` stays (0, 1, 0.5), the mathematical identity in any unit. | |
| var red = Levels(black: 0, white: Pipeline.dmax) | |
| var green = Levels(black: 0, white: Pipeline.dmax) | |
| var blue = Levels(black: 0, white: Pipeline.dmax) | |
| subscript(channel: LevelsChannel) -> Levels { | |
| get { | |
| switch channel { | |
| case .luma: luma | |
| case .linked: linked | |
| case .red: red | |
| case .green: green | |
| case .blue: blue | |
| } | |
| } | |
| set { | |
| switch channel { | |
| case .luma: luma = newValue | |
| case .linked: linked = newValue | |
| case .red: red = newValue | |
| case .green: green = newValue | |
| case .blue: blue = newValue | |
| } | |
| } | |
| } | |
| /// Channels no longer neutral, so a setting changed in a hidden tab stays marked. | |
| func touched(for mode: ConversionMode) -> Set<LevelsChannel> { | |
| Set(LevelsChannel.allCases.filter { self[$0] != Self.resting($0, for: mode) }) | |
| } | |
| /// Only luma rests on the mathematical identity; read from the defaults rather than restated, or | |
| /// reset and the modified-dot indicators contradict each other on the per-channel windows. | |
| static func resting(_ channel: LevelsChannel, for mode: ConversionMode) -> Levels { | |
| var out = LevelsSet()[channel] | |
| switch channel { | |
| case .linked: out.mid = Levels.restingMid(for: mode) | |
| case .red, .green, .blue: out.black = Levels.restingBlack(for: mode) | |
| case .luma: break | |
| } | |
| return out | |
| } | |
| /// The complete set at rest, for a given mode: what the reset buttons aim at. | |
| static func neutral(for mode: ConversionMode) -> LevelsSet { | |
| var out = LevelsSet() | |
| for channel in LevelsChannel.allCases { out[channel] = resting(channel, for: mode) } | |
| return out | |
| } | |
| /// Moves the handles still sitting on one mode's rest onto the other's and leaves anything a | |
| /// hand placed where it is, so changing mode never undoes a placement. | |
| func carried(from previous: ConversionMode, to next: ConversionMode) -> LevelsSet { | |
| guard previous != next else { return self } | |
| var out = self | |
| if linked.mid == Levels.restingMid(for: previous) { | |
| out.linked.mid = Levels.restingMid(for: next) | |
| } | |
| for channel in LevelsChannel.perChannel | |
| where self[channel].black == Levels.restingBlack(for: previous) { | |
| out[channel].setBlack(Levels.restingBlack(for: next)) | |
| } | |
| return out | |
| } | |
| /// Per-channel levels alone, global ones neutral, to measure what the second effect receives — | |
| /// the linked set must stay flatly neutral, never at its resting gamma. | |
| var perChannelOnly: LevelsSet { | |
| LevelsSet(luma: .neutral, linked: .neutral, red: red, green: green, blue: blue) | |
| } | |
| /// The added ordinates of the three windows, one vector per control point — the shape the | |
| /// kernel takes them in. | |
| var shadowOrdinates: CIVector { | |
| CIVector(x: Double(red.shadowOrdinate), y: Double(green.shadowOrdinate), | |
| z: Double(blue.shadowOrdinate)) | |
| } | |
| var highlightOrdinates: CIVector { | |
| CIVector(x: Double(red.highlightOrdinate), y: Double(green.highlightOrdinate), | |
| z: Double(blue.highlightOrdinate)) | |
| } | |
| /// The same pair for the two global sets, `x` linked and `y` luma — one vector each, so the | |
| /// kernel's tail grows by two arguments rather than four. | |
| var globalShadowOrdinates: CIVector { | |
| CIVector(x: Double(linked.shadowOrdinate), y: Double(luma.shadowOrdinate)) | |
| } | |
| var globalHighlightOrdinates: CIVector { | |
| CIVector(x: Double(linked.highlightOrdinate), y: Double(luma.highlightOrdinate)) | |
| } | |
| static let neutral = LevelsSet() | |
| } | |
| /// One set of levels: five handles, the same five on all five sets. The two added points are | |
| /// stored normalised, so they read alike whether the ends carry a density or a share of a window. | |
| struct Levels: Codable, Equatable, Hashable, Sendable { | |
| var black: Float = 0 | |
| var white: Float = 1 | |
| /// Stored normalised between black and white so it always follows when those two move, | |
| /// keeping its ratio constant. | |
| var mid: Float = 0.5 | |
| /// Stored normalised between black and white exactly as `mid` is, resting a quarter and three | |
| /// quarters along: each steers the window's output at its own quarter, and follows the ends. | |
| var shadows: Float = 0.25 | |
| var highlights: Float = 0.75 | |
| /// A negative is exposed at gamma ~0.6 and the paper renders the contrast, so it rests at 0.67; | |
| /// raising that to 0.75 crushes a thin negative to 3 codes of 256. | |
| static func restingMid(for mode: ConversionMode) -> Float { | |
| mode == .positive ? positiveMid : 0.67 | |
| } | |
| /// Undoes stage 5's logarithm, which a positive carries too. Shares a number with the negative's | |
| /// paper gamma and no reason with it — neither has cause to follow the other. | |
| static let positiveMid: Float = 0.67 | |
| /// Where the pedestal's veil lands once the positive branch re-inverts, so a physical black | |
| /// opens on 0 rather than 0.301. A negative reads its density straight and rests on zero. | |
| static func restingBlack(for mode: ConversionMode) -> Float { | |
| mode == .positive ? DensityAxis.guardOffset : 0 | |
| } | |
| /// Room the two global sliders have outside the 0…1 they read: pinned to it they rest on their | |
| /// own stops and can only tighten. A tenth each side, the share the window's overshoot is. | |
| static let globalHeadroom: Float = 0.1 | |
| /// Minimum width of an interval, in density: prevents a zero range and the log's divergence. | |
| /// One percent of the axis, which is what bounds the steepest stretch a hand can set. | |
| static let epsilon: Float = 0.03 | |
| /// Usable travel of the median handle, narrower than 0...1: gamma is hyperbolic in `mid` | |
| /// near the edges, making small handle movements swing contrast unmanageably. | |
| static let midRange: ClosedRange<Float> = 0.15...0.85 | |
| /// A fifth of the window either side of each added handle's own quarter. That bound is what | |
| /// keeps the five Bézier ordinates strictly ordered, hence the curve increasing, at any setting. | |
| static let shadowRange: ClosedRange<Float> = 0.05...0.45 | |
| static let highlightRange: ClosedRange<Float> = 0.55...0.95 | |
| /// The value that renders as mid-grey, hence what the median reads as — not where its handle is | |
| /// drawn, since that travels on `midRange` mapped across the whole track. | |
| var displayMid: Float { black + mid * (white - black) } | |
| /// The densities the two added handles are steered from, in the unit the ends carry. | |
| var displayShadows: Float { black + shadows * (white - black) } | |
| var displayHighlights: Float { black + highlights * (white - black) } | |
| /// `gamma = log(0.5) / log(normalised median)`; `mid = 0.5` is neutral, `mid < 0.5` brightens. | |
| var gamma: Float { | |
| log(0.5) / log(min(max(mid, Self.midRange.lowerBound), Self.midRange.upperBound)) | |
| } | |
| /// Where a normalised position is held before the logarithm, which diverges at both ends and | |
| /// would let a rebate spike at a window's edge decide a gamma. | |
| static let anchorClamp: ClosedRange<Float> = 0.02...0.98 | |
| /// The median that renders a normalised position `n` at `target`: the exact inverse of `gamma`. | |
| /// One formula for the anchor a mode guesses and the point a pipette is told. | |
| static func mid(placing n: Float, at target: Float) -> Float? { | |
| func held(_ v: Float) -> Float { | |
| min(max(v, anchorClamp.lowerBound), anchorClamp.upperBound) | |
| } | |
| let gamma = log(held(target)) / log(held(n)) | |
| guard gamma.isFinite, gamma > 0 else { return nil } | |
| return min(max(pow(0.5, 1 / gamma), midRange.lowerBound), midRange.upperBound) | |
| } | |
| var vector: CIVector { | |
| CIVector(x: Double(black), y: Double(white), z: Double(gamma)) | |
| } | |
| // MARK: - The two added control points of the window's Bézier | |
| /// The ordinate the quarter-tone control point takes, mirrored so moving its handle right | |
| /// darkens, as the median's does. Bounded into `0…0.5` by `shadowRange`, hence never out of turn. | |
| var shadowOrdinate: Float { | |
| 0.5 - min(max(shadows, Self.shadowRange.lowerBound), Self.shadowRange.upperBound) | |
| } | |
| /// The same at three quarters, bounded into `0.5…1`, so the five ordinates | |
| /// `0 < c₁ < ½ < c₃ < 1` are ordered whatever the handles carry and the curve cannot fold. | |
| var highlightOrdinate: Float { | |
| 1.5 - min(max(highlights, Self.highlightRange.lowerBound), Self.highlightRange.upperBound) | |
| } | |
| /// True where the two added points sit on their base ordinates, which is the exact identity: | |
| /// `Σ (i/4)·Bᵢ(x) = x`. The kernel and its replica both skip the pass on it, hence bit for bit. | |
| var isWindowNeutral: Bool { shadowOrdinate == 0.25 && highlightOrdinate == 0.75 } | |
| static let neutral = Levels() | |
| /// The window that reads the density axis without placing anything on it. What a check of the | |
| /// kernel's own arithmetic runs through, a mode's resting black deciding the result otherwise. | |
| static let onAxis = Levels(black: 0, white: Pipeline.dmax) | |
| // MARK: - Constraints between handles | |
| /// Black does not cross white: on contact the range would be zero. | |
| mutating func setBlack(_ v: Float) { black = min(v, white - Self.epsilon) } | |
| mutating func setWhite(_ v: Float) { white = max(v, black + Self.epsilon) } | |
| /// Takes an absolute position and stores it normalised between black and white. | |
| mutating func setDisplayMid(_ v: Float) { | |
| let span = white - black | |
| guard span > 0 else { return } | |
| mid = min(max((v - black) / span, Self.midRange.lowerBound), Self.midRange.upperBound) | |
| } | |
| /// The same for the two added points. Their travels overlap the median's on the track, but their | |
| /// ordinates are clamped either side of the half, so the curve stays increasing whatever is set. | |
| mutating func setDisplayShadows(_ v: Float) { | |
| let span = white - black | |
| guard span > 0 else { return } | |
| shadows = min(max((v - black) / span, Self.shadowRange.lowerBound), | |
| Self.shadowRange.upperBound) | |
| } | |
| mutating func setDisplayHighlights(_ v: Float) { | |
| let span = white - black | |
| guard span > 0 else { return } | |
| highlights = min(max((v - black) / span, Self.highlightRange.lowerBound), | |
| Self.highlightRange.upperBound) | |
| } | |
| /// Puts the median back halfway between the other two, not at its original spectrum position. | |
| mutating func centreMid() { mid = 0.5 } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// The open frame: its source file and its reduced linear decoding. | |
| /// The linear preview is not inverted and carries no setting, so a slider never re-decodes the RAW. | |
| struct Negative { | |
| let url: URL | |
| let linear: CIImage? | |
| /// Full resolution on first demand. A recipe for a camera RAW; for a container scan the decode | |
| /// materialises the whole frame, so building it here would bill every open ~1.3 GB it may never show. | |
| var full: CIImage? { fullBox?.image } | |
| /// One build shared by every copy of the frame: without the reference, each copy of a value | |
| /// type would decode the full frame again. | |
| private let fullBox: FullBox? | |
| /// Exact full-resolution size, known without building `full` — from the container's directory | |
| /// for a scan. The crop frame quantises on it, so an approximation would reframe the export. | |
| let fullSize: CGSize? | |
| /// True while still decoding; `init(url:)` always leaves the flag down, so a frame that went | |
| /// through the decoder never looks pending again. | |
| var isPending: Bool { linear == nil && fullBox == nil && isPendingFlag } | |
| /// Set only by `init(pending:)`. A stored flag rather than an inference, since `init(url:)` | |
| /// also leaves everything `nil` for an unreadable file. | |
| private var isPendingFlag = false | |
| /// Full-resolution width, passed to `Pipeline.apply` whenever it is given `linear` or `measure`: | |
| /// prevents the crop frame from quantizing onto the reduced copy's grid. | |
| var fullWidth: CGFloat? { fullSize?.width } | |
| /// Reduction ratio of the preview: beyond that scale, the preview would be stretched and one | |
| /// has to switch to full resolution. | |
| var previewRatio: CGFloat { | |
| guard let fullSize, let linear, fullSize.width > 0 else { return 1 } | |
| return linear.extent.width / fullSize.width | |
| } | |
| /// Reduced copy every measurement reads: the two live histograms, and through them the | |
| /// automatic balance's own two steps on the open frame. | |
| let measure: CIImage? | |
| /// A third of `measure`'s side, built ONCE: the drag-time histograms read it, and a fresh | |
| /// scale graph per measure filed unique intermediates the context cached and never re-served. | |
| let measureSmall: CIImage? | |
| /// Long side of the preview cache. Full size is only decoded at export. | |
| static let previewSide: CGFloat = 2000 | |
| /// The side every distribution is read on. Below it a black point crosses a plateau of bright | |
| /// salt the grid averaged away, and the decode does not scale with it: 0.200 s at any of these. | |
| static let measureSide: CGFloat = 1400 | |
| /// A frame that is known but not yet decoded, so switching frames stays instant while | |
| /// `init(url:)`'s RAW decodes run; `linear == nil` still means "not decodable" too. | |
| init(pending url: URL) { | |
| self.url = url | |
| self.linear = nil | |
| self.fullBox = nil | |
| self.fullSize = nil | |
| self.measure = nil | |
| self.measureSmall = nil | |
| self.isPendingFlag = true | |
| } | |
| /// The decode came back without dynamic range, measured once here so a view does not re-ask on | |
| /// every redraw; not an error state, the pixels are there, they simply say nothing. | |
| private(set) var looksFlat = false | |
| /// True when the file only carries 256 values per channel; retained at open time since | |
| /// `RawDecode.isLowPrecision` touches disk. Not a defect: density inversion just widens the gaps. | |
| private(set) var isLowPrecision = false | |
| init(url: URL) { | |
| self.url = url | |
| guard let linear = RawDecode.linear(url, longestSide: Self.previewSide) else { | |
| self.linear = nil | |
| self.fullBox = nil | |
| self.fullSize = nil | |
| self.measure = nil | |
| self.measureSmall = nil | |
| return | |
| } | |
| self.linear = linear | |
| if ContainerRead.probe(url) == .linearRGB, let size = ContainerRead.pixelSize(of: url) { | |
| self.fullSize = size | |
| self.fullBox = FullBox { RawDecode.linear(url) } | |
| } else { | |
| // The other branches decode nothing here — `full` stays a recipe — so building it now | |
| // costs a header read and keeps its extent exact for free. | |
| let full = RawDecode.linear(url) | |
| self.fullSize = full?.extent.size | |
| self.fullBox = full.map { FullBox(image: $0) } | |
| } | |
| self.measure = RawDecode.linear(url, longestSide: Self.measureSide) | |
| self.measureSmall = self.measure?.applyingFilter("CILanczosScaleTransform", | |
| parameters: [kCIInputScaleKey: 0.35, | |
| kCIInputAspectRatioKey: 1.0]) | |
| self.looksFlat = RawDecode.looksFlat(linear) | |
| self.isLowPrecision = RawDecode.isLowPrecision(of: url) | |
| } | |
| } | |
| /// Builds once, then serves the same instance: the canvas's texture cache keys on content, and a | |
| /// fresh `CIImage` per access would re-decode and re-materialise on every frame at 100 %. | |
| private final class FullBox: @unchecked Sendable { | |
| private let make: () -> CIImage? | |
| private var made: CIImage? | |
| private var built = false | |
| // Guards `made` because the box crosses from the decode task to the main thread. | |
| private let lock = NSLock() | |
| init(image: CIImage) { | |
| make = { image } | |
| made = image | |
| built = true | |
| } | |
| init(_ make: @escaping () -> CIImage?) { | |
| self.make = make | |
| } | |
| var image: CIImage? { | |
| lock.lock() | |
| defer { lock.unlock() } | |
| if built { return made } | |
| made = make() | |
| built = true | |
| return made | |
| } | |
| } | |
| import CoreGraphics | |
| import Foundation | |
| /// What a preset and a paste are chosen from: one row per serialisable concept, each unfolding | |
| /// into its own sliders. A row taken whole serialises as its name, taken in part as its sliders'. | |
| enum Parameters { | |
| /// Carries one value from a source onto a destination. A closure rather than the key path | |
| /// itself, so rows holding different value types sit in one list. | |
| struct Slider { | |
| let id: String | |
| let carry: (PipelineSettings, inout PipelineSettings) -> Void | |
| } | |
| struct Row: Identifiable { | |
| let id: String | |
| /// The `PipelineSettings` fields this row answers for. The completeness check reads it, | |
| /// which is what stops a new stage from becoming an orphan in silence. | |
| let covers: [String] | |
| let sliders: [Slider] | |
| let carry: (PipelineSettings, inout PipelineSettings) -> Void | |
| } | |
| /// Fields deliberately outside every row, each with the reason it is out. **Absent** rather | |
| /// than unticked: a row that does not exist cannot be ticked by mistake. | |
| static let excluded: [(field: String, why: String)] = [ | |
| ("geometry", "depends on this frame's content — pasting one destroys hand-made cropping"), | |
| ("mode", "negative or positive is what the frame IS, not a look one applies"), | |
| ("film", "which roll a frame belongs to — pasting a look would move photographs about"), | |
| ("manualOrder", "a frame's own hand-placed position among its siblings"), | |
| ("sheet", "a contact sheet's own grid — the windows its measurements read through"), | |
| ("sheetMode", "whether those windows are used — what this scan IS, " | |
| + "not a look one applies"), | |
| ("corrections", "a specific defect on THIS negative — pasting one paints someone " | |
| + "else's dust onto a different photograph"), | |
| ] | |
| private static func carrying<V>(_ path: WritableKeyPath<PipelineSettings, V>) | |
| -> (PipelineSettings, inout PipelineSettings) -> Void { | |
| { from, to in to[keyPath: path] = from[keyPath: path] } | |
| } | |
| private static func slider<V>(_ id: String, | |
| _ path: WritableKeyPath<PipelineSettings, V>) -> Slider { | |
| Slider(id: id, carry: carrying(path)) | |
| } | |
| /// Every handle of one levels channel, named for the row that holds them. The global sets do | |
| /// not draw the two added points but still carry them, so a paste cannot lose one. | |
| private static func handles(_ row: String, _ channel: String, | |
| _ path: WritableKeyPath<PipelineSettings, Levels>) -> [Slider] { | |
| [slider("\(row).\(channel).black", path.appending(path: \.black)), | |
| slider("\(row).\(channel).shadows", path.appending(path: \.shadows)), | |
| slider("\(row).\(channel).mid", path.appending(path: \.mid)), | |
| slider("\(row).\(channel).highlights", path.appending(path: \.highlights)), | |
| slider("\(row).\(channel).white", path.appending(path: \.white))] | |
| } | |
| /// One band of the finishing balance, three channels wide. | |
| private static func band(_ name: String, | |
| _ path: WritableKeyPath<PipelineSettings, ToneBalance.Band>) | |
| -> [Slider] { | |
| [slider("toneBalance.\(name).red", path.appending(path: \.colour.x)), | |
| slider("toneBalance.\(name).green", path.appending(path: \.colour.y)), | |
| slider("toneBalance.\(name).blue", path.appending(path: \.colour.z))] | |
| } | |
| /// Every row, in the order a form shows them. `mode` and `geometry` are absent by design; see | |
| /// `excluded`, and the check that refuses a field named in neither place. | |
| /// Main-actor because a row holds key paths, which Swift 6 does not call `Sendable`, and every | |
| /// caller — the form, a paste, the checks — already runs there. | |
| @MainActor | |
| static let all: [Row] = [ | |
| Row(id: "gains", covers: ["gains"], | |
| sliders: [slider("gains.red", \.gains.stops.x), | |
| slider("gains.green", \.gains.stops.y), | |
| slider("gains.blue", \.gains.stops.z)], | |
| carry: carrying(\.gains)), | |
| // Between the gains and the levels, where it acts: it decides the axis the three windows | |
| // below are then placed on. One value, hence no slider of its own to name. | |
| Row(id: "densityCeiling", covers: ["densityCeiling"], sliders: [], | |
| carry: carrying(\.densityCeiling)), | |
| Row(id: "levelsPerChannel", covers: ["levels"], | |
| sliders: handles("levelsPerChannel", "red", \.levels.red) | |
| + handles("levelsPerChannel", "green", \.levels.green) | |
| + handles("levelsPerChannel", "blue", \.levels.blue), | |
| carry: { from, to in | |
| to.levels.red = from.levels.red | |
| to.levels.green = from.levels.green | |
| to.levels.blue = from.levels.blue | |
| }), | |
| Row(id: "levelsGlobal", covers: ["levels"], | |
| sliders: handles("levelsGlobal", "linked", \.levels.linked) | |
| + handles("levelsGlobal", "luma", \.levels.luma), | |
| carry: { from, to in | |
| to.levels.linked = from.levels.linked | |
| to.levels.luma = from.levels.luma | |
| }), | |
| // Split from the curves it rides on: the slider is composed into the linked table when it | |
| // is baked, but it is dialled on its own and travels on its own. | |
| Row(id: "contrast", covers: ["curves"], sliders: [], | |
| carry: carrying(\.curves.contrast)), | |
| Row(id: "curves", covers: ["curves"], | |
| sliders: [slider("curves.luma", \.curves.luma), | |
| slider("curves.linked", \.curves.linked), | |
| slider("curves.red", \.curves.red), | |
| slider("curves.green", \.curves.green), | |
| slider("curves.blue", \.curves.blue)], | |
| // Every table but not `contrast`, which is the row above and would travel twice. | |
| carry: { from, to in | |
| to.curves.luma = from.curves.luma | |
| to.curves.linked = from.curves.linked | |
| to.curves.red = from.curves.red | |
| to.curves.green = from.curves.green | |
| to.curves.blue = from.curves.blue | |
| }), | |
| // The whole other `CurveSet`, `curves`'s own two rows mirrored on `manualCurves` — never | |
| // sharing a row with it, or a paste would carry the wrong stage's tables into the other's. | |
| Row(id: "manualContrast", covers: ["manualCurves"], sliders: [], | |
| carry: carrying(\.manualCurves.contrast)), | |
| Row(id: "manualCurves", covers: ["manualCurves"], | |
| sliders: [slider("manualCurves.luma", \.manualCurves.luma), | |
| slider("manualCurves.linked", \.manualCurves.linked), | |
| slider("manualCurves.red", \.manualCurves.red), | |
| slider("manualCurves.green", \.manualCurves.green), | |
| slider("manualCurves.blue", \.manualCurves.blue)], | |
| carry: { from, to in | |
| to.manualCurves.luma = from.manualCurves.luma | |
| to.manualCurves.linked = from.manualCurves.linked | |
| to.manualCurves.red = from.manualCurves.red | |
| to.manualCurves.green = from.manualCurves.green | |
| to.manualCurves.blue = from.manualCurves.blue | |
| }), | |
| Row(id: "density", covers: ["density"], sliders: [], | |
| carry: carrying(\.density)), | |
| // No sliders: eight bands on two axes is sixteen rows, and what a hand sets on the wheel is | |
| // one gesture. The whole mixer travels or none of it does. | |
| Row(id: "spectrogram", covers: ["spectrogram"], sliders: [], | |
| carry: carrying(\.spectrogram)), | |
| Row(id: "toneBalance", covers: ["toneBalance"], | |
| sliders: band("black", \.toneBalance.black) + band("shadows", \.toneBalance.shadows) | |
| + band("midtones", \.toneBalance.midtones) | |
| + band("highlights", \.toneBalance.highlights) | |
| + band("white", \.toneBalance.white), | |
| carry: carrying(\.toneBalance)), | |
| Row(id: "saturation", covers: ["saturation"], | |
| sliders: [slider("saturation.shadows", \.saturation.shadows), | |
| slider("saturation.highlights", \.saturation.highlights)], | |
| carry: carrying(\.saturation)), | |
| Row(id: "chroma", covers: ["chroma"], | |
| sliders: [slider("chroma.force", \.chroma.force), | |
| slider("chroma.radius", \.chroma.radius)], | |
| carry: carrying(\.chroma)), | |
| Row(id: "sharpen", covers: ["sharpen"], | |
| sliders: [slider("sharpen.amount", \.sharpen.amount), | |
| slider("sharpen.radius", \.sharpen.radius)], | |
| carry: carrying(\.sharpen)), | |
| Row(id: "texture", covers: ["texture"], | |
| sliders: [slider("texture.amount", \.texture.amount)], | |
| carry: carrying(\.texture)), | |
| Row(id: "flatField", covers: ["flatField"], | |
| sliders: [slider("flatField.mask", \.flatField.mask), | |
| slider("flatField.amount", \.flatField.amount)], | |
| carry: carrying(\.flatField)), | |
| ] | |
| /// Carries the chosen parameters onto a destination and leaves everything else standing. A row | |
| /// named whole wins over its own sliders, so the two states can never both apply. | |
| @MainActor | |
| static func apply(_ chosen: Set<String>, from source: PipelineSettings, | |
| to destination: PipelineSettings) -> PipelineSettings { | |
| var result = destination | |
| for row in all { | |
| if chosen.contains(row.id) { | |
| row.carry(source, &result) | |
| continue | |
| } | |
| for slider in row.sliders where chosen.contains(slider.id) { | |
| slider.carry(source, &result) | |
| } | |
| } | |
| return result | |
| } | |
| // MARK: - The white balance, measured rather than copied | |
| /// An automatic balance is named, not valued: the numbers depend on the frame it lands on, so | |
| /// the preset carries the METHOD and the target is measured when it is applied. | |
| static let autoBalancePrefix = "autoBalance." | |
| static func autoName(for method: AutoLevels.Method) -> String { | |
| autoBalancePrefix + method.rawValue | |
| } | |
| /// The method a selection asks for, or `nil`. It writes the same two rows a manual balance | |
| /// does, so a selection naming both is a state the form cannot make and `apply` must survive. | |
| static func autoMethod(in chosen: Set<String>) -> AutoLevels.Method? { | |
| for method in AutoLevels.Method.allCases where chosen.contains(autoName(for: method)) { | |
| return method | |
| } | |
| return nil | |
| } | |
| /// A selection stripped of the methods that no longer exist. A retired button leaves its name | |
| /// in presets already written; dropping it there keeps the file loading, minus that one line. | |
| static func withoutRetiredMethods(_ chosen: Set<String>) -> Set<String> { | |
| let live = Set(AutoLevels.Method.allCases.map(autoName(for:))) | |
| return chosen.filter { !$0.hasPrefix(autoBalancePrefix) || live.contains($0) } | |
| } | |
| /// The rows an automatic balance writes, which are exactly the manual ones — that is why the | |
| /// two are one choice on screen rather than two boxes that could both be ticked. | |
| static let whiteBalanceRows = ["gains", "levelsPerChannel"] | |
| // MARK: - What a photograph has actually moved | |
| /// The state a photograph is imported in. Not one block for both modes: the median rests at | |
| /// 0.67 in negative, where the paper renders the contrast, and at the true neutral otherwise. | |
| static func rest(for mode: ConversionMode) -> PipelineSettings { | |
| var block = PipelineSettings() | |
| block.mode = mode | |
| block.levels = .neutral(for: mode) | |
| return block | |
| } | |
| /// What a reset lands on, and the ONE answer every reset path asks: `rest` in the frame's own | |
| /// mode, plus the facts about the FILE, which no look replaces and no reset may drop. | |
| static func imported(from settings: PipelineSettings) -> PipelineSettings { | |
| var block = rest(for: settings.mode) | |
| block.sheetMode = settings.sheetMode | |
| block.sheet = settings.sheet | |
| // A roll says where a photograph came FROM, which no look replaces: resetting one must | |
| // not take it out of the film it was shot on. | |
| block.film = settings.film | |
| return block | |
| } | |
| /// The file's own facts a candidate reset drops. Takes the reset as a closure, so a shape that | |
| /// keeps nothing runs through the same reading instead of the check agreeing with itself. | |
| static func fileFactsDropped(by reset: (PipelineSettings) -> PipelineSettings, | |
| from settings: PipelineSettings) -> [String] { | |
| let after = reset(settings) | |
| var lost: [String] = [] | |
| if after.mode != settings.mode { lost.append("mode") } | |
| if after.sheetMode != settings.sheetMode { lost.append("sheetMode") } | |
| if after.sheet != settings.sheet { lost.append("sheet") } | |
| if after.film != settings.film { lost.append("film") } | |
| return lost | |
| } | |
| /// The rows a photograph has moved off rest, which is what a form opens ticked. | |
| /// Read through each row's own `carry`, so a row cannot disagree with itself about what it | |
| /// holds, and never through `isNeutral` — sharpening rests ON, and would tick untouched frames. | |
| @MainActor | |
| static func changed(in settings: PipelineSettings) -> Set<String> { | |
| let resting = rest(for: settings.mode) | |
| return Set(all.filter { row in | |
| var carried = resting | |
| row.carry(settings, &carried) | |
| return carried != resting | |
| }.map(\.id)) | |
| } | |
| // MARK: - Nothing may be silently absent | |
| /// The fields `PipelineSettings` declares, read from the source. Bounded to that structure, or | |
| /// every other `var` in the file would be counted as a setting. | |
| nonisolated static func fields(in text: String) -> [String] { | |
| var found: [String] = [] | |
| var inside = false | |
| for line in text.split(separator: "\n", omittingEmptySubsequences: false) { | |
| if line.hasPrefix("struct PipelineSettings") { inside = true; continue } | |
| if inside, line == "}" { break } | |
| guard inside, line.hasPrefix(" var ") else { continue } | |
| found.append(String(line.dropFirst(8).prefix { $0.isLetter || $0.isNumber })) | |
| } | |
| return found | |
| } | |
| /// The fields nothing answers for. A separate function so the twin can run the same comparison | |
| /// with a real field withheld, rather than proving only that the parser reads. | |
| nonisolated static func unanswered(fields: [String], answered: Set<String>) -> [String] { | |
| fields.filter { !answered.contains($0) }.sorted() | |
| } | |
| /// The keys a `Levels` really writes, taken from the encoder and not from a list: a handle | |
| /// added to the struct and forgotten here would ride into sidecars no preset could carry. | |
| nonisolated static func handleKeys() -> [String] { | |
| guard let data = try? JSONEncoder().encode(Levels()), | |
| let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] | |
| else { return [] } | |
| return object.keys.sorted() | |
| } | |
| @MainActor | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var report = "" | |
| func check(_ passed: Bool, _ label: String) { | |
| ok = ok && passed | |
| report += " \(passed ? "OK " : "FAIL") \(label)\n" | |
| } | |
| check(all.map(\.id).count == Set(all.map(\.id)).count, | |
| "the \(all.count) rows carry distinct names") | |
| let sliderIDs = all.flatMap { $0.sliders.map(\.id) } | |
| check(sliderIDs.count == Set(sliderIDs).count && !sliderIDs.isEmpty, | |
| "and their \(sliderIDs.count) sliders too, across every row") | |
| check(Set(sliderIDs).isDisjoint(with: Set(all.map(\.id))), | |
| "and no slider bears a row's name, which would make one selection mean two things") | |
| guard let text = SourceFile.text("Sources/OpenNegative/Pipeline/Pipeline.swift") else { | |
| return (ok, report + " SKIPPED sources absent, completeness is not checkable here\n") | |
| } | |
| let declared = fields(in: text) | |
| let answered = Set(all.flatMap(\.covers)).union(excluded.map(\.field)) | |
| let missing = unanswered(fields: declared, answered: answered) | |
| check(declared.count >= 10, | |
| "\(declared.count) fields read from PipelineSettings, so the parser found the struct") | |
| check(missing.isEmpty, | |
| "and every one of them is either carried by a row or excluded with a reason" | |
| + (missing.isEmpty ? "" : " — orphaned: \(missing.joined(separator: ", "))")) | |
| // Twin: adverse by construction, since it withholds a field that really is answered for. | |
| let withheld = unanswered(fields: declared, answered: answered.subtracting(["sharpen"])) | |
| check(withheld == ["sharpen"], | |
| "and the check discriminates: withholding a REAL field names it back, so the sweep " | |
| + "reads the source rather than agreeing with itself") | |
| // The same sweep one level down: the fields of `Levels`, whose rows carry handle by handle. | |
| // Bounded to the levels rows, or `saturation.shadows` would answer for a levels handle. | |
| let handles = handleKeys() | |
| let levelsSliders = all.filter { $0.id.hasPrefix("levels") }.flatMap { $0.sliders.map(\.id) } | |
| let named = Set(levelsSliders.compactMap { $0.split(separator: ".").last.map(String.init) }) | |
| check(handles.count >= 5 && unanswered(fields: handles, answered: named).isEmpty, | |
| "the \(handles.count) handles a levels set serialises are each carried by a slider " | |
| + "(\(handles.joined(separator: ", ")))") | |
| check(unanswered(fields: handles, answered: named.subtracting(["mid"])) == ["mid"], | |
| "and the check discriminates: withholding a REAL handle names it back") | |
| for mode in ConversionMode.allCases { | |
| check(changed(in: rest(for: mode)).isEmpty, | |
| "an untouched \(mode) frame ticks nothing") | |
| } | |
| // The trap this rule exists for: sharpening's rest is not an off state, so reading | |
| // `isNeutral` would tick it on every photograph nobody has touched. | |
| check(!Sharpen.neutral.isNeutral, | |
| "and the check discriminates: sharpening rests ON (isNeutral is false at rest), so " | |
| + "an isNeutral reading would have ticked every frame above") | |
| var moved = rest(for: .negative) | |
| moved.sharpen.amount += 1 | |
| check(changed(in: moved) == ["sharpen"], | |
| "one slider moved ticks its row and nothing else") | |
| // Adverse by construction: the two modes rest on different per-channel windows, a positive's | |
| // black sitting on the pedestal's veil, so a crossed reading must report the levels moved. | |
| var crossed = rest(for: .positive) | |
| crossed.mode = .negative | |
| check(changed(in: crossed).contains("levelsPerChannel"), | |
| "and rest follows the MODE: a positive's levels read against a negative's rest are " | |
| + "reported moved") | |
| var lender = rest(for: .negative) | |
| lender.gains.stops.x = 1 | |
| let untouched = rest(for: .negative) | |
| check(apply([autoName(for: .mids)], from: lender, to: untouched) == untouched, | |
| "a method's name carries no VALUE: it is measured on the frame it lands on, and " | |
| + "applying it alone moves nothing here") | |
| check(apply(Set(whiteBalanceRows), from: lender, to: untouched) != untouched, | |
| "and the check discriminates: the same source, named by its rows, does move them") | |
| for name in excluded.map(\.field) { | |
| check(!all.contains { $0.covers.contains(name) } && !all.contains { $0.id == name }, | |
| "\(name) is absent from every row rather than merely unticked") | |
| } | |
| return (ok, report) | |
| } | |
| // MARK: - What a reset keeps | |
| /// The scene's reset members, name and body. Read as text because what is guarded is a WIRING | |
| /// — that no path builds its own fresh state — which no value can report on. | |
| nonisolated static func resetMembers(in lines: [String]) -> [(name: String, body: String)] { | |
| var found: [(name: String, body: String)] = [] | |
| var open: String? | |
| var body = "" | |
| var depth = 0 | |
| for line in lines { | |
| let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| if open == nil { | |
| guard !trimmed.hasPrefix("//"), let declared = resetDeclaration(trimmed) | |
| else { continue } | |
| open = declared | |
| body = "" | |
| depth = 0 | |
| } | |
| body += line + "\n" | |
| depth += line.filter { $0 == "{" }.count - line.filter { $0 == "}" }.count | |
| if depth <= 0, body.contains("{"), let declared = open { | |
| found.append((declared, body)) | |
| open = nil | |
| } | |
| } | |
| return found | |
| } | |
| /// The name a line declares, when what it declares is a reset. `canReset` counts: it decides | |
| /// whether the gesture is offered, so a rest of its own greys the button on a live frame. | |
| private nonisolated static func resetDeclaration(_ trimmed: String) -> String? { | |
| for keyword in ["func ", "var "] { | |
| guard let word = trimmed.range(of: keyword) else { continue } | |
| let name = String(trimmed[word.upperBound...].prefix { $0.isLetter || $0.isNumber }) | |
| if name.hasPrefix("reset") || name.hasPrefix("canReset") { return name } | |
| } | |
| return nil | |
| } | |
| /// A reset as it must not be written: the frame's mode carried across and nothing else, which | |
| /// takes a contact sheet's own grid out along with the look. | |
| private nonisolated static var adverseReset: [String] { | |
| [" private func resetAll() { settings = Parameters.rest(for: settings.mode) }"] | |
| } | |
| /// What a reset keeps, and that every path asks one place for it. A sidecar wiped of the two | |
| /// sheet fields stops the app recognising the file, with no gesture left to say so. | |
| @MainActor | |
| static func resetChecks() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // Adverse on all three facts at once: a POSITIVE contact sheet, so a reset blind to any | |
| // one of them names it rather than landing on the value it would have kept anyway. | |
| var sheet = rest(for: .positive) | |
| // In a roll too, so the sample is adverse on every fact at once: a reset that kept the | |
| // sheet's grid but scattered its frames would still be wrong. | |
| sheet.film = Film(name: "Portra 400") | |
| sheet.sheetMode = true | |
| sheet.sheet = ContactSheet.Grid(columns: 3, rows: 2, frames: 6, | |
| tile: CGSize(width: 400, height: 300), margin: 12) | |
| sheet.gains.stops.x = 1.5 | |
| sheet.sharpen.amount += 0.4 | |
| sheet.geometry.angle = 3 | |
| let moved = changed(in: sheet) | |
| let after = imported(from: sheet) | |
| report(fileFactsDropped(by: imported(from:), from: sheet).isEmpty, | |
| "a reset keeps mode, sheetMode, sheet and film: what the FILE is outlives the look") | |
| report(changed(in: after).isEmpty && after != sheet, | |
| "and everything else goes back to rest — \(moved.count) rows moved before it, " | |
| + "none after") | |
| report(after.geometry == Geometry(), | |
| "framing included, since tightening at an angle is not undone by a handle") | |
| // Twin, adverse by construction: a reset reading the MODE alone cannot see the two sheet | |
| // fields, whatever they hold. | |
| report(fileFactsDropped(by: { rest(for: $0.mode) }, from: sheet) | |
| == ["sheetMode", "sheet", "film"], | |
| "and the check discriminates: a reset built on the mode alone drops both sheet " | |
| + "fields — the shape that stopped a sheet being one") | |
| report(fileFactsDropped(by: { _ in PipelineSettings() }, from: sheet) | |
| == ["mode", "sheetMode", "sheet", "film"], | |
| "and a bare block drops the mode with them") | |
| guard let text = SourceFile.text("Sources/OpenNegative/OpenNegativeApp.swift") else { | |
| return (ok, (lines + [" SKIPPED scene absent, the reset paths cannot be read here"]) | |
| .joined(separator: "\n")) | |
| } | |
| let members = resetMembers(in: text.split(separator: "\n", omittingEmptySubsequences: false) | |
| .map(String.init)) | |
| report(members.count >= 3, | |
| "the scene declares \(members.count) reset members " | |
| + "(\(members.map(\.name).joined(separator: ", ")))") | |
| // Delegating to the batch counts: it is the same single place one step further, and | |
| // demanding the literal name would push every member to inline what it should call. | |
| // The rule is that no reset path BUILDS its own state: it asks the single place, or it | |
| // delegates to something that does. A member building none — a label — has nothing to ask, | |
| // so it is judged on what it builds rather than on being named reset-something. | |
| let builds = { (body: String) in | |
| body.contains("PipelineSettings(") || body.contains("Parameters.rest(") | |
| } | |
| let silent = members.filter { builds($0.body) }.filter { | |
| !$0.body.contains("Parameters.imported(") && !$0.body.contains("resetSettings(") | |
| } | |
| report(silent.isEmpty, | |
| "and every one of them asks the single place what a reset keeps" | |
| + (silent.isEmpty ? "" : " — mute: \(silent.map(\.name).joined(separator: ", "))")) | |
| let ownState = members.filter { | |
| $0.body.contains("PipelineSettings()") || $0.body.contains("Parameters.rest(") | |
| } | |
| report(ownState.isEmpty, | |
| "and none of them builds a state of its own" | |
| + (ownState.isEmpty ? "" : " — does: \(ownState.map(\.name).joined(separator: ", "))")) | |
| // Twin, adverse by construction: the retired shape is READ, then refused on the rule, so a | |
| // green line above cannot come from an extractor that finds nothing. | |
| let wrong = resetMembers(in: adverseReset) | |
| report(wrong.count == 1 && !wrong[0].body.contains("Parameters.imported(") | |
| && wrong[0].body.contains("Parameters.rest("), | |
| "and the check discriminates: a reset written on the mode alone is found, then " | |
| + "refused on the rule rather than on a failure to read it") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| import Metal | |
| import UniformTypeIdentifiers | |
| /// Aggregates every setting the graph consumes, so a new stage only touches one call site instead | |
| /// of `apply`'s seven. | |
| struct PipelineSettings: Codable, Equatable, Hashable, Sendable { | |
| /// What the source is — stage 6's only switch, placed first since everything below describes | |
| /// the treatment. Missing sidecars inherit this default, so changing it silently reflips every frame already graded. | |
| var mode: ConversionMode = .negative | |
| /// The roll this frame belongs to, `nil` when its capture day stands in for one. Carried here | |
| /// because the sidecar is the truth, and EXCLUDED from propagation: pasting settings must not | |
| /// move a photograph between rolls. See `Parameters.excluded`. | |
| var film: Film? | |
| /// A hand-placed position among its siblings, overriding capture order — `nil` for every frame | |
| /// nobody has ever dragged. EXCLUDED from propagation: pasting a look must not reorder a roll. | |
| var manualOrder: Double? | |
| /// Reads a contact sheet through its own windows, leaving the gaps and the frame borders out | |
| /// of every measurement. Off on a photograph, which has neither. | |
| var sheetMode = false | |
| /// A contact sheet's own grid, set when one is generated. With `sheetMode` on it is what a | |
| /// measurement reads through: one window per frame, held clear of its edges. | |
| var sheet: ContactSheet.Grid? | |
| /// Stage 0, at the head: straightening and cropping. | |
| var geometry = Geometry() | |
| /// Stage 3: chroma denoise, luma untouched. | |
| var chroma = ChromaDenoise() | |
| /// Stage 4, before density: the heavy lifting, killing the orange cast. | |
| var gains = Gains() | |
| /// Stage 5's axis: how far the density is read before the pedestal compresses what is left. | |
| /// See `DensityAxis` for the trade raising it makes and why it rests on its range's floor. | |
| var densityCeiling: Float = DensityAxis.base | |
| /// Flat-field correction, applied before stage 0 rather than where its UI sits — see | |
| /// `FlatField` for why the two positions differ. | |
| var flatField = FlatField() | |
| /// Dust and scratch corrections, spliced right after decode and the flat-field, before every | |
| /// other stage — see `Correction` for why the model needs pixels no setting has touched yet. | |
| var corrections: [Correction] = [] | |
| var levels = LevelsSet() | |
| /// Stage 7, riding on the two global gammas rather than on a stage of its own: see | |
| /// `ColorDensity` for why the pair is what separates the channels without moving a neutral. | |
| var density: Float = 0 | |
| /// Between Lighting and Colour: a second, independent curve set the user draws by hand. | |
| /// Shares `curves`'s own machinery but never its state — see docs/specs/2026-08-24-courbes-manuelles.md. | |
| var manualCurves = CurveSet() | |
| var curves = CurveSet() | |
| /// Between the curves and the finishing balance: the colour mixer, per band. See `Spectrogram` | |
| /// for why the eight centres are fixed and only their effect moves. | |
| var spectrogram = Spectrogram() | |
| /// Stage 8, after the whole tonal shaping: the finishing balance. See `ToneBalance` for why it | |
| /// is a curve family rather than a multiplication. | |
| var toneBalance = ToneBalance() | |
| /// Stage 9: saturation by luminance zone. | |
| var saturation = ZoneSaturation() | |
| /// Stage 10: unsharp mask on the luma alone. | |
| var texture = Texture() | |
| var sharpen = Sharpen() | |
| static let neutral = PipelineSettings() | |
| /// The stage a histogram is measured in front of. | |
| enum Stage: Hashable, Sendable { | |
| /// Before the per-channel levels. | |
| case levels | |
| /// After the per-channel levels, before the global ones. | |
| case globalLevels | |
| /// After all the levels, before the manual curves — what those curves are about to receive. | |
| case manualCurves | |
| /// After all the levels, before the curves. | |
| case curves | |
| } | |
| /// Every neighbourhood stage off — the three most expensive, and invisible at 640 px, hence | |
| /// what a **thumbnail** renders through as well as a check reading the kernel's own arithmetic. | |
| func perPixelOnly() -> PipelineSettings { | |
| var out = self | |
| out.sharpen.amount = 0 | |
| out.chroma = .neutral | |
| out.texture = Texture() | |
| return out | |
| } | |
| /// The current settings with everything downstream of `stage` set back to neutral: copied then | |
| /// neutralised, so a future field travels along instead of being silently dropped. | |
| func truncated(before stage: Stage) -> PipelineSettings { | |
| var out = self | |
| // Colour's own points, in every case: they run last of the three curve-related passes, | |
| // after contrast and after manualCurves, so no truncation point may ever carry them. | |
| out.curves.luma = Curve() | |
| out.curves.linked = Curve() | |
| out.curves.red = Curve() | |
| out.curves.green = Curve() | |
| out.curves.blue = Curve() | |
| // Chroma denoise runs downstream of the levels, so a measurement of what ENTERS them must | |
| // not carry it. Dormant while the stage rests at zero, and a lie the day it does not. | |
| out.chroma = .neutral | |
| out.spectrogram = Spectrogram() | |
| out.toneBalance = ToneBalance() | |
| out.saturation = ZoneSaturation() | |
| out.texture = Texture() | |
| // Zeroes `amount` rather than using `Sharpen()`: its resting defaults carry a real dose, so | |
| // the initialiser would sharpen the very measurement meant to show what enters the levels. | |
| out.sharpen.amount = 0 | |
| switch stage { | |
| // The three windows carry the graduation, not their rest: a black at zero floors every | |
| // density under it onto bin 0, where a handle can no longer be aimed at those pixels. | |
| case .levels: | |
| let graduated = Graduation.levels(for: mode) | |
| // Flatly neutral globals, never `LevelsSet()`, whose linked median would darken the very | |
| // measurement the placement reads; the density rides inside stage 7 and leaves with them. | |
| out.levels = LevelsSet(luma: .neutral, linked: .neutral, | |
| red: graduated, green: graduated, blue: graduated) | |
| out.density = 0 | |
| out.manualCurves = CurveSet() | |
| out.curves.contrast = 0 | |
| case .globalLevels: | |
| out.levels = levels.perChannelOnly | |
| out.density = 0 | |
| out.manualCurves = CurveSet() | |
| out.curves.contrast = 0 | |
| // Contrast runs ahead of manualCurves now, so it stays — only manualCurves itself, not | |
| // yet run at this point, is neutralised. | |
| case .manualCurves: out.manualCurves = CurveSet() | |
| // Contrast and manualCurves have both already run by here in the real render, so only | |
| // Colour's own points (zeroed above, unconditionally) are missing from this measurement. | |
| case .curves: break | |
| } | |
| return out | |
| } | |
| } | |
| /// The kernel carrying stages 4 to 7: per-channel gains, density, inversion, levels — fused into a | |
| /// single Metal function since all four are per-pixel operators with no neighbourhood read. | |
| enum Pipeline { | |
| private static let metallib: Data? = { | |
| guard let url = Bundle.main.url(forResource: "OpenNegative", withExtension: "metallib") | |
| else { return nil } | |
| return try? Data(contentsOf: url) | |
| }() | |
| static let kernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "pipeline", fromMetalLibraryData: metallib) | |
| }() | |
| /// The curves have their own kernel because they must **read a texture**: a `CIColorKernel` | |
| /// cannot sample, whatever the number of arguments. | |
| static let curvesKernel: CIKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIKernel(functionName: "curves", fromMetalLibraryData: metallib) | |
| }() | |
| /// Stage 8: a degree-4 Bézier per channel, hence a `CIColorKernel` rather than a `CIColorMatrix` | |
| /// — the operation is no longer linear since it moves the end points too. | |
| static let balanceKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "toneBalance", fromMetalLibraryData: metallib) | |
| }() | |
| /// Unsharp mask: two aligned inputs, the blur being done upstream by a stock filter. | |
| static let sharpenKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "sharpen", fromMetalLibraryData: metallib) | |
| }() | |
| /// Mid-frequency band: three aligned inputs, the blurs being done upstream by stock filters. | |
| static let textureKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "texture", fromMetalLibraryData: metallib) | |
| }() | |
| /// The band's guided base needs local statistics, and all of them come from stock blurs: the | |
| /// whole neighbourhood stays outside the kernels, so both remain colour kernels. | |
| static let textureMomentsKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "textureMoments", fromMetalLibraryData: metallib) | |
| }() | |
| static let textureCoeffKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "textureCoeff", fromMetalLibraryData: metallib) | |
| }() | |
| /// Saturation by zone: per pixel and with no neighbourhood, hence a `CIColorKernel`. | |
| static let saturationKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "zoneSaturation", fromMetalLibraryData: metallib) | |
| }() | |
| /// The colour mixer: eight bands carried as four vectors, so it stays a colour kernel rather | |
| /// than needing a baked table and the sampler it would come with. | |
| static let spectrogramKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "spectrogram", fromMetalLibraryData: metallib) | |
| }() | |
| /// Recombination of the chroma denoise: two aligned inputs, so a `CIColorKernel` is enough — | |
| /// the blur is done upstream by a stock filter. | |
| static let chromaKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "chromaDenoise", fromMetalLibraryData: metallib) | |
| }() | |
| /// The flat-field division: two aligned inputs, so a `CIColorKernel` suffices — the mask is | |
| /// resized on the Swift side and sampled at the same destination point, with no offset read. | |
| static let flatFieldKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "flatField", fromMetalLibraryData: metallib) | |
| }() | |
| /// The gentle highlight compression, in its own pass so as to be **always the last** in the | |
| /// chain. It is the only place in the pipeline where information is bounded. | |
| static let outputKernel: CIColorKernel? = { | |
| guard let metallib else { return nil } | |
| return try? CIColorKernel(functionName: "outputClip", fromMetalLibraryData: metallib) | |
| }() | |
| /// Top of the density axis, in two roles the checks hold to one number: the pivot the positive | |
| /// branch re-inverts on, and the per-channel levels' resting white. | |
| static let dmax: Float = 3 | |
| /// The axis at rest: what every check of the kernel's bare arithmetic renders through, and what | |
| /// the graduation window is drawn on. A photograph dials its own with `densityCeiling`. | |
| static var tpedestal: Float { DensityAxis.pedestal(at: DensityAxis.base) } | |
| static var tmin: Float { DensityAxis.logGuard(at: DensityAxis.base) } | |
| /// The gamma the linked levels apply at rest in negative mode. Checks of the kernel's bare | |
| /// arithmetic must start neutral; checks of the app's default rendering must carry it. | |
| static var restingGamma: Float { Levels(mid: Levels.restingMid(for: .negative)).gamma } | |
| /// Swift replica of `outputClip`: a hard clip, deliberately duplicated so the checks can compare | |
| /// this function against the kernel and fail on any divergence. | |
| static func clip(_ e: Float) -> Float { min(max(e, 0), 1) } | |
| /// Largest radius at which `CIGaussianBlur` still returns its input bit for bit, the blur waking | |
| /// up at 0.16141. Under it a neighbourhood stage costs a pass and changes nothing. | |
| static let blurFloor: Double = 0.1614 | |
| /// Largest full-resolution radius a fitted preview leaves INERT, as `blurFloor` is: at it the | |
| /// reduced copy asks for exactly the floor and gets its input back, while the export is dosed. | |
| static var previewVisibleRadius: Double { | |
| blurFloor * SettingsCodec.radiusReferenceSide / Double(Negative.previewSide) | |
| } | |
| /// One single context for the whole session. Recreating one per call costs a compilation of | |
| /// the graph on every slider movement. | |
| static let measureContext = CIContext(options: [ | |
| .workingColorSpace: RawDecode.workingSpace, | |
| .workingFormat: CIFormat.RGBAf, | |
| ]) | |
| /// The head of the chain, which a contact sheet runs per frame before tiling and `apply` runs | |
| /// inline: one expression, so a tile and a photograph cannot be framed by two different rules. | |
| static func framed(_ image: CIImage, settings: PipelineSettings, | |
| fullWidth: CGFloat? = nil) -> CIImage { | |
| // Flat-field first, so the mask covers the whole scan rather than whatever the crop left — | |
| // after stage 0 it would slide the correction across the picture as the crop tightens. | |
| let flattened = applyFlatField(image, settings.flatField) | |
| // Stage 0 next: the discarded pixels are not computed, and the histogram only counts the | |
| // pixels that are kept. | |
| let framed = applyGeometry(flattened, settings.geometry, fullWidth: fullWidth) | |
| // Corrections last, in the CROPPED frame a stroke is actually painted on — every stage | |
| // below sees a corrected pixel exactly like any other, with no special case of its own. | |
| return applyCorrections(framed, settings.corrections, fullWidth: fullWidth) | |
| } | |
| /// Per-correction, in memory: the disk read and TIFF decode `CorrectionCache.load` costs, and | |
| /// the CGContext rasterisation `blendMask` costs, neither lazy — measured, both ran again on | |
| /// EVERY render of EVERY correction, the actual reason performance fell off with each one added. | |
| /// `NSCache` for its built-in thread safety: a render can run off the main actor (export, thumbnails). | |
| // `NSCache` is documented thread-safe internally; the compiler's blanket Sendable objection | |
| // is a false positive here, the same one already granted to the retired model's own cache. | |
| nonisolated(unsafe) private static let patchCache = NSCache<NSString, CIImage>() | |
| nonisolated(unsafe) private static let maskCache = NSCache<NSString, CIImage>() | |
| /// Which mask keys belong to which correction — `NSCache` cannot enumerate or remove by | |
| /// prefix, so eviction needs this to find only the deleted correction's own entries. | |
| nonisolated(unsafe) private static var maskKeysByCorrection: [UUID: Set<NSString>] = [:] | |
| private static let maskKeysLock = NSLock() | |
| /// Drops one correction's cached patch and every resolution's cached mask it was rendered at — | |
| /// called once, on deletion, so a stale entry never outlives the disk file it was read from. | |
| /// Never every OTHER correction's masks: a photo with many corrections re-rasterises all of | |
| /// them if this reaches for `removeAllObjects()` instead of its own keys alone. | |
| static func evictCorrectionCache(_ correctionID: UUID) { | |
| patchCache.removeObject(forKey: correctionID.uuidString as NSString) | |
| maskKeysLock.lock() | |
| let keys = maskKeysByCorrection.removeValue(forKey: correctionID) ?? [] | |
| maskKeysLock.unlock() | |
| for key in keys { maskCache.removeObject(forKey: key) } | |
| } | |
| /// Composites every correction's already-generated patch — cheap once cached, and run on every | |
| /// render. Producing a patch is the expensive part and never happens here: an absent one leaves | |
| /// the original pixels untouched rather than blocking. | |
| private static func applyCorrections(_ image: CIImage, _ corrections: [Correction], | |
| fullWidth: CGFloat?) -> CIImage { | |
| guard !corrections.isEmpty else { return image } | |
| // The patch was cached at full resolution; a reduced preview scales both its content and | |
| // its position by the same factor `apply`'s two dosed radii already scale by. | |
| let scale = fullWidth.flatMap { $0 > 0 ? image.extent.width / $0 : nil } ?? 1 | |
| // `contextRect`'s tile is a full-resolution constant (`tileSide`): read directly off a | |
| // reduced extent it becomes 800 REDUCED pixels, wildly oversized — computed at full | |
| // resolution first and scaled down, it stays the same tile at every zoom. | |
| let fullExtent = scale == 1 ? image.extent | |
| : CGRect(x: image.extent.minX / scale, y: image.extent.minY / scale, | |
| width: image.extent.width / scale, height: image.extent.height / scale) | |
| var result = image | |
| for correction in corrections { | |
| let patchKey = correction.id.uuidString as NSString | |
| let patchAtFull: CIImage | |
| if let cached = patchCache.object(forKey: patchKey) { | |
| patchAtFull = cached | |
| } else { | |
| guard let data = CorrectionCache.load(correction.id), | |
| let decoded = CIImage(data: data) else { continue } | |
| patchCache.setObject(decoded, forKey: patchKey) | |
| patchAtFull = decoded | |
| } | |
| let fullTileRect = CorrectionRenderer.contextRect(for: correction, in: fullExtent) | |
| let tileRect = scale == 1 ? fullTileRect | |
| : CGRect(x: fullTileRect.minX * scale, y: fullTileRect.minY * scale, | |
| width: fullTileRect.width * scale, height: fullTileRect.height * scale) | |
| let scaled = scale == 1 ? patchAtFull | |
| : patchAtFull.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) | |
| // A CGImage carries no position: the TIFF round trip always decodes back at the | |
| // origin, so the patch must be moved to the tile it belongs at, same as the mask. | |
| let patch = scaled.transformed(by: CGAffineTransform(translationX: tileRect.minX, | |
| y: tileRect.minY)) | |
| let maskKey = "\(correction.id.uuidString)-\(tileRect)" as NSString | |
| let mask: CIImage | |
| if let cached = maskCache.object(forKey: maskKey) { | |
| mask = cached | |
| } else { | |
| let built = CorrectionRenderer.blendMask(for: correction, imageExtent: image.extent, | |
| tileRect: tileRect, scale: scale) | |
| maskCache.setObject(built, forKey: maskKey) | |
| maskKeysLock.lock() | |
| maskKeysByCorrection[correction.id, default: []].insert(maskKey) | |
| maskKeysLock.unlock() | |
| mask = built | |
| } | |
| result = patch.applyingFilter("CIBlendWithMask", parameters: [ | |
| kCIInputBackgroundImageKey: result, | |
| kCIInputMaskImageKey: mask, | |
| ]) | |
| } | |
| return result.cropped(to: image.extent) | |
| } | |
| /// 16-bit-per-channel FLOAT TIFF, never `.RGBA16`: decoded-source pixels routinely fall | |
| /// outside 0…1, and an integer format clamps that silently down to solid black. | |
| static func encodedPatch(_ image: CIImage) -> Data? { | |
| guard let buffer = measureContext.createCGImage(image, from: image.extent, format: .RGBAh, | |
| colorSpace: RawDecode.workingSpace), | |
| let data = CFDataCreateMutable(nil, 0), | |
| let destination = CGImageDestinationCreateWithData(data, UTType.tiff.identifier as CFString, | |
| 1, nil) | |
| else { return nil } | |
| CGImageDestinationAddImage(destination, buffer, [ | |
| kCGImagePropertyTIFFDictionary: [kCGImagePropertyTIFFCompression: 8], | |
| ] as CFDictionary) | |
| guard CGImageDestinationFinalize(destination) else { return nil } | |
| return data as Data | |
| } | |
| /// - Parameter fullWidth: full-resolution width, required when `image` is a reduced copy. | |
| /// - Parameter measuring: skips output compression, for the histogram paths only. | |
| static func apply(_ image: CIImage, settings: PipelineSettings, | |
| fullWidth: CGFloat? = nil, measuring: Bool = false) -> CIImage { | |
| guard let kernel else { return image } | |
| let framed = framed(image, settings: settings, fullWidth: fullWidth) | |
| // Both taken **before** stage 0, so neither the crop nor the frame's shape reaches a radius. | |
| // The band is a fraction of the picture; the two dosed radii are pixels of the export. | |
| let reference = Double(max(image.extent.width, image.extent.height)) | |
| let scale = fullWidth.flatMap { $0 > 0 ? Double(image.extent.width / $0) : nil } ?? 1 | |
| let lv = settings.levels | |
| let out = kernel.apply(extent: framed.extent, arguments: [ | |
| framed, settings.gains.vector, | |
| lv.linked.vector, lv.red.vector, lv.green.vector, lv.blue.vector, lv.luma.vector, | |
| // Must stay after `luma`: five `CIVector`s in a row can permute silently, and keeping | |
| // the scalars in the tail limits an ordering mistake to them alone. | |
| // The density separates channels a monochrome render then projects away, so it is | |
| // skipped there rather than hidden while still moving the luminance it feeds. | |
| settings.mode.invertFlag, | |
| settings.mode.isMonochrome ? 1 : ColorDensity.factor(settings.density), | |
| // The axis travels as arguments, never as kernel constants: it is dialled per frame, | |
| // and `pow(10, −x)` differs by an ulp or two on the GPU at most of its values. | |
| DensityAxis.pedestal(at: settings.densityCeiling), | |
| DensityAxis.logGuard(at: settings.densityCeiling), dmax, | |
| // At the tail, after the scalars: appending cannot displace the five vectors above, | |
| // which is the ordering mistake that would compile and render. | |
| lv.shadowOrdinates, lv.highlightOrdinates, | |
| lv.globalShadowOrdinates, lv.globalHighlightOrdinates, | |
| ]) ?? image | |
| // Contrast lives on `curves` but reads as part of Lighting, so it runs on its own, first — | |
| // before manualCurves and before Colour's own points, never composed into either's table. | |
| let contrasted = applyCurves(out, CurveSet(contrast: settings.curves.contrast), | |
| cache: contrastLutCache) | |
| let graded = applyCurves(contrasted, settings.manualCurves, cache: manualLutCache) | |
| var colour = settings.curves | |
| colour.contrast = 0 | |
| let mixed = applySpectrogram(applyCurves(graded, colour, cache: lutCache), | |
| settings.spectrogram) | |
| let toned = applyToneBalance(mixed, settings.toneBalance) | |
| // The chroma denoise heads the finishing group, after the inversion rather than before it. | |
| // Upstream, a channel it pushes negative meets `−log10` and comes back saturated. | |
| let cleaned = applyChromaDenoise(applyZoneSaturation(toned, settings.saturation), | |
| settings.chroma, scale: scale) | |
| let finished = applySharpen(applyTexture(cleaned, settings.texture, | |
| referenceWidth: reference), | |
| settings.sharpen, scale: scale) | |
| // Monochrome projects last: everything upstream keeps its colour, so the per-channel curves | |
| // act as coloured filters instead of a mix nobody chose. | |
| let shown = settings.mode.isMonochrome ? lumaOnly(finished) : finished | |
| return measuring ? shown : onGround(applyOutputClip(shown), settings) | |
| } | |
| /// A sheet's gutters are the one transparency the app lays in a picture on purpose. Compositing | |
| /// alone leaves them at 0.538: the chain carries a value in the colour whatever the alpha says, | |
| /// so it is premultiplied first — which is what makes the alpha decide, and the ground black. | |
| private static func onGround(_ image: CIImage, _ settings: PipelineSettings) -> CIImage { | |
| guard settings.sheetMode else { return image } | |
| return image.premultiplyingAlpha() | |
| .composited(over: CIImage(color: .black).cropped(to: image.extent)) | |
| } | |
| /// The sliding shutter: split in the destination's coordinate space so moving the line touches | |
| /// no computed pixel, and cropped disjoint so Core Image's cost stays that of one image. | |
| static func splitView(after: CIImage, before: CIImage, in rect: CGRect, | |
| at split: CGFloat) -> CIImage { | |
| let x = rect.minX + rect.width * min(max(split, 0), 1) | |
| let left = CGRect(x: rect.minX, y: rect.minY, width: x - rect.minX, height: rect.height) | |
| let right = CGRect(x: x, y: rect.minY, width: rect.maxX - x, height: rect.height) | |
| return before.cropped(to: left).composited(over: after.cropped(to: right)) | |
| } | |
| /// What `CorrectionRenderer.generate` crops its tile from: the frame a stroke was actually | |
| /// painted on, carrying every OTHER already-cached correction — so its coordinates need no transform. | |
| static func correctionSource(_ image: CIImage, settings: PipelineSettings, | |
| excluding: UUID) -> CIImage { | |
| var upstream = settings | |
| upstream.corrections = settings.corrections.filter { $0.id != excluding } | |
| return framed(image, settings: upstream) | |
| } | |
| /// The flat-field division: returns the image untouched when the stage is off or the mask is | |
| /// missing, since an uncorrected picture beats a crash. | |
| private static func applyFlatField(_ image: CIImage, _ settings: FlatField) -> CIImage { | |
| guard !settings.isNeutral, let kernel = flatFieldKernel, | |
| let mask = FlatField.fitted(settings.mask, to: image.extent, | |
| peak: FlatField.peak(of: settings.mask)) | |
| else { return image } | |
| return kernel.apply(extent: image.extent, | |
| arguments: [image, mask, settings.amount]) ?? image | |
| } | |
| private static func applyOutputClip(_ image: CIImage) -> CIImage { | |
| guard let outputKernel else { return image } | |
| return outputKernel.apply(extent: image.extent, arguments: [image]) ?? image | |
| } | |
| /// Stage 8, placed after the whole tonal shaping so it acts non-linearly and is a genuinely | |
| /// different control from stage 4, rather than duplicating it up to an offset. | |
| private static func applyToneBalance(_ image: CIImage, _ balance: ToneBalance) -> CIImage { | |
| guard !balance.isNeutral, let balanceKernel else { return image } | |
| return balanceKernel.apply(extent: image.extent, | |
| arguments: [image] + balance.vectors()) ?? image | |
| } | |
| /// Caches the tables baked for the last curve set seen: `apply` runs several times per frame, | |
| /// and without this the tables would be rebaked identically on every call. | |
| private static let lutCache = LUTCache() | |
| /// A second slot, never shared with `lutCache`: `manualCurves` and `curves` alternate on every | |
| /// single `apply`, so one shared cache would thrash and rebake both, every frame, always. | |
| private static let manualLutCache = LUTCache() | |
| /// A third slot, same reason: the contrast-only pass bakes its own tiny table every call too. | |
| private static let contrastLutCache = LUTCache() | |
| /// A lock rather than an actor: `apply` is called from the main thread **and** from detached | |
| /// tasks, and must stay synchronous. | |
| private final class LUTCache: @unchecked Sendable { | |
| private let lock = NSLock() | |
| private var key: CurveSet? | |
| private var tables: (rgb: CIImage, luma: CIImage)? | |
| func tables(for curves: CurveSet) -> (rgb: CIImage, luma: CIImage)? { | |
| lock.lock() | |
| defer { lock.unlock() } | |
| if key == curves, let tables { return tables } | |
| guard let rgb = curves.rgbLUT(), let luma = curves.lumaLUT() else { return nil } | |
| key = curves | |
| tables = (rgb, luma) | |
| return tables | |
| } | |
| } | |
| /// Each input has its own region of interest: the image follows the destination, but the tables | |
| /// must be provided **whole**, otherwise the kernel samples out of bounds. | |
| /// A stored constant, not a closure per evaluation: the context retains every callback it is | |
| /// handed until `clearCaches()`, and both tables are invariant. | |
| private static let curvesROI: @Sendable (Int32, CGRect) -> CGRect = { index, rect in | |
| index == 0 ? rect : CGRect(x: 0, y: 0, width: CGFloat(Curve.lutSize), height: 1) | |
| } | |
| /// The curves pass, skipped entirely when all five are neutral: no point going through a kernel | |
| /// and two tables for an identity. `cache` is the caller's own slot — see `manualLutCache`. | |
| private static func applyCurves(_ image: CIImage, _ curves: CurveSet, | |
| cache: LUTCache) -> CIImage { | |
| guard !curves.isNeutral, let curvesKernel, | |
| let (rgb, luma) = cache.tables(for: curves) else { return image } | |
| return curvesKernel.apply(extent: image.extent, roiCallback: curvesROI, | |
| arguments: [image, rgb, luma]) ?? image | |
| } | |
| /// Rec. 2020 luminance weights, matching the working space; must stay in agreement with | |
| /// `LumaWeights` in both `.metal` files. | |
| static let lumaWeights = SIMD3<Float>(0.2627, 0.6780, 0.0593) | |
| /// Ceiling on the luma factor, mirroring the kernel's `LumaScaleMax`: an almost-black pixel | |
| /// would otherwise be multiplied without bound. | |
| static let lumaScaleMax: Float = 8 | |
| /// GPU histogram, 1024 bins for the three channels plus luminance, returning pixel counts | |
| /// rather than normalised fractions. Blocks on GPU→CPU reads: call off the main thread. | |
| static func histogram(_ image: CIImage, bins: Int = 1024) -> Histogram { | |
| let pixels = Float(image.extent.width * image.extent.height) | |
| guard pixels > 0 else { return .empty } | |
| let rgb = counts(image, bins: bins).map { $0 * pixels } | |
| let luma = counts(greyTrace(image), bins: bins).map { $0.x * pixels } | |
| return Histogram(rgb: rgb, luma: luma) | |
| } | |
| /// Luminance cannot be derived from the three per-channel histograms — the joint distribution | |
| /// is lost once counted channel by channel — hence this second image for a second pass. | |
| static func greyTrace(_ image: CIImage) -> CIImage { | |
| let w = lumaWeights | |
| return image.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputRVector": CIVector(x: CGFloat(w.x), y: CGFloat(w.y), z: CGFloat(w.z), w: 0), | |
| "inputGVector": CIVector(x: CGFloat(w.x), y: CGFloat(w.y), z: CGFloat(w.z), w: 0), | |
| "inputBVector": CIVector(x: CGFloat(w.x), y: CGFloat(w.y), z: CGFloat(w.z), w: 0), | |
| "inputAVector": CIVector(x: 0, y: 0, z: 0, w: 1), | |
| ]) | |
| } | |
| /// The distribution in front of a stage, everything downstream neutral and the result bounded | |
| /// onto the counted range. One place, so no caller can forget the graduation's ends. | |
| static func histogram(of image: CIImage, settings: PipelineSettings, | |
| before stage: PipelineSettings.Stage, fullWidth: CGFloat? = nil, | |
| bins: Int = 1024) -> Histogram { | |
| let read = counted(measured(of: image, settings: settings, before: stage, | |
| fullWidth: fullWidth)) | |
| return histogram(read, settings: settings, bins: bins) | |
| } | |
| /// EVERY distribution a sheet is shown or placed on goes through here, so no second reader can | |
| /// forget the windows. What lies outside them no test on the VALUE tells from a real shadow. | |
| static func histogram(_ image: CIImage, settings: PipelineSettings, | |
| bins: Int = 1024) -> Histogram { | |
| guard settings.sheetMode, let grid = settings.sheet, | |
| // The grid names pixels of the frame it was laid on: handed another shape it would | |
| // name the wrong ones, so the whole picture is read rather than the wrong part. | |
| grid.fits(image.extent.size) else { | |
| return histogram(image, bins: bins) | |
| } | |
| let windows = grid.windows(in: image.extent.size) | |
| guard !windows.isEmpty else { return histogram(image, bins: bins) } | |
| return sum(windows.map { histogram(image.cropped(to: $0), bins: bins) }) | |
| } | |
| /// Histograms added term by term. A `Histogram` holds absolute pixel counts, so the sum is | |
| /// the sum — weighted by nothing, and exact. | |
| static func sum(_ parts: [Histogram]) -> Histogram { | |
| let counted = parts.filter { !$0.isEmpty } | |
| guard let bins = counted.first?.rgb.count else { return .empty } | |
| var rgb = [SIMD3<Float>](repeating: .zero, count: bins) | |
| var luma = [Float](repeating: 0, count: bins) | |
| for part in counted where part.rgb.count == bins { | |
| for bin in 0..<bins { rgb[bin] += part.rgb[bin] } | |
| guard part.luma.count == bins else { continue } | |
| for bin in 0..<bins { luma[bin] += part.luma[bin] } | |
| } | |
| return Histogram(rgb: rgb, luma: luma) | |
| } | |
| /// The image a distribution is read off, output compression skipped. One recipe, so a value | |
| /// picked by hand and a plotted bin cannot describe two different pictures. | |
| static func measured(of image: CIImage, settings: PipelineSettings, | |
| before stage: PipelineSettings.Stage, | |
| fullWidth: CGFloat? = nil) -> CIImage { | |
| apply(image, settings: settings.truncated(before: stage), fullWidth: fullWidth, | |
| measuring: true) | |
| } | |
| /// Bounds a measurement onto the counted range, so the top bin's spike and the luma trace read | |
| /// off the graduation window rather than off the counter. Never on a path that shows a pixel. | |
| private static func counted(_ image: CIImage) -> CIImage { | |
| image.applyingFilter("CIColorClamp", parameters: [ | |
| "inputMinComponents": CIVector(x: 0, y: 0, z: 0, w: 0), | |
| "inputMaxComponents": CIVector(x: 1, y: 1, z: 1, w: 1), | |
| ]) | |
| } | |
| /// Fractions per bin, as `CIAreaHistogram` returns them. | |
| private static func counts(_ image: CIImage, bins: Int) -> [SIMD3<Float>] { | |
| guard let hist = CIFilter(name: "CIAreaHistogram", parameters: [ | |
| kCIInputImageKey: image, | |
| kCIInputExtentKey: CIVector(cgRect: image.extent), | |
| "inputCount": bins, | |
| ])?.outputImage else { return [] } | |
| var raw = [Float](repeating: 0, count: bins * 4) | |
| measureContext.render(hist, toBitmap: &raw, rowBytes: bins * 16, | |
| bounds: CGRect(x: 0, y: 0, width: bins, height: 1), | |
| format: .RGBAf, colorSpace: RawDecode.workingSpace) | |
| return (0..<bins).map { SIMD3(raw[$0 * 4], raw[$0 * 4 + 1], raw[$0 * 4 + 2]) } | |
| } | |
| // MARK: - Checks | |
| /// The kernel's stages 4 → 6, replicated: a pedestal before the gain, a guard under the log, and | |
| /// no division — what reaches the levels is a density, which the positive branch re-inverts. | |
| private static func density(ofT t: Float, stops: Float = 0, | |
| mode: ConversionMode = .negative) -> Float { | |
| let d = -log10(max((t + tpedestal) * pow(2, stops), tmin)) | |
| return mode.invertFlag > 0.5 ? d : dmax - d | |
| } | |
| /// The same, then the per-channel window at rest, which is what normalises the density now | |
| /// lives. A check reading the screen goes through it, or it models a kernel with no handles. | |
| private static func atRest(_ t: Float, stops: Float = 0, | |
| mode: ConversionMode = .negative) -> Float { | |
| DensityMigration.applyWindow(density(ofT: t, stops: stops, mode: mode), | |
| LevelsSet.resting(.red, for: mode)) | |
| } | |
| /// The same with the LINKED window on top, which is where a positive carries the gamma undoing | |
| /// stage 5. An oracle stopping at the first window models half of what the screen shows. | |
| private static func atModeRest(_ t: Float, stops: Float = 0, mode: ConversionMode) -> Float { | |
| DensityMigration.applyWindow(atRest(t, stops: stops, mode: mode), | |
| LevelsSet.resting(.linked, for: mode)) | |
| } | |
| /// Checks the kernel against hand-computed values with colour management disabled. The | |
| /// inversion check matters most: `E` must decrease as `T` increases, which isolated-value comparisons alone would not catch. | |
| static func selfCheck() -> (ok: Bool, report: String) { | |
| guard kernel != nil else { return (false, "kernel not found") } | |
| let ctx = CIContext(options: [.workingColorSpace: NSNull(), | |
| .outputColorSpace: NSNull(), | |
| .workingFormat: CIFormat.RGBAf]) | |
| /// `channel` defaults to the linked one. | |
| func run(_ t: Float, _ stops: Float, _ levels: Levels = .neutral, | |
| _ channel: LevelsChannel = .linked) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var set = LevelsSet() | |
| set[channel] = levels | |
| let settings = PipelineSettings(gains: Gains(stops: .init(repeating: stops)), | |
| levels: set) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: settings.perPixelOnly()), toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| /// A whole set at once, for the two windows that have to agree on one pixel. | |
| func through(_ t: Float, _ set: LevelsSet) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(levels: set).perPixelOnly()), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| // The guard, no longer a ceiling, is what caps the density: under it the axis stops at its | |
| // top, and the per-channel window at rest maps that top to exactly 1. | |
| let floored = run(0, -6) | |
| report(abs(floored - Self.clip(atRest(0, stops: -6))) < 1e-4, | |
| String(format: "under the guard the density stops at %.5f, and the resting window " | |
| + "puts it at %.5f (expected %.5f)", density(ofT: 0, stops: -6), | |
| floored, Self.clip(atRest(0, stops: -6)))) | |
| // A black pixel must not pile on the axis's top: the pedestal caps the readable density at | |
| // 2.097, which lands mid-axis where the levels can still catch it. | |
| let black = run(0, 0) | |
| report(abs(black - Self.clip(atRest(0))) < 1e-4, | |
| String(format: "a BLACK pixel lands mid-axis, at a density of %.4f: %.5f (expected " | |
| + "%.5f, against %.5f on the top)", density(ofT: 0), black, | |
| Self.clip(atRest(0)), Self.clip(atRest(0, stops: -6)))) | |
| // A transmittance over 1 is no longer clamped: it arrives as a density under zero, and the | |
| // window's own `max(n, 0)` is what returns black there, no ceiling being involved. | |
| let ceiled = run(1, 3) | |
| report(abs(ceiled - Self.clip(atRest(1, stops: 3))) < 1e-4, | |
| String(format: "T·g > 1 → the window floors it to %.5f (expected %.5f)", | |
| ceiled, Self.clip(atRest(1, stops: 3)))) | |
| // The twin: that pixel really does carry a density under zero, so the black above is the | |
| // window's floor and not a ceiling the kernel no longer has. | |
| report(density(ofT: 1, stops: 3) < 0, | |
| String(format: "the twin: that same pixel carries a density of %.5f, under zero — " | |
| + "there is no ceiling left in the kernel to hide it", | |
| density(ofT: 1, stops: 3))) | |
| // The closed form is the whole chain, not stage 6 alone: `atRest` folds in the per-channel | |
| // window, whose resting white carries the division the kernel no longer does. | |
| for (t, stops) in [(Float(0.1), Float(0)), (0.5, 0), (0.1, 1), (1.0, 0)] { | |
| let got = run(t, stops) | |
| let want = Self.clip(atRest(t, stops: stops)) | |
| report(abs(got - want) < 1e-4, | |
| String(format: "T=%.1f %+.0f stop → D %.5f, E %.5f (expected %.5f)", | |
| t, stops, density(ofT: t, stops: stops), got, want)) | |
| } | |
| let dark = run(0.05, 0) // dense film → highlights of the scene | |
| let light = run(0.5, 0) // clear film → shadows of the scene | |
| report(dark > light, String(format: "inversion: T=0.05 → %.5f > T=0.50 → %.5f", | |
| dark, light)) | |
| // These three drive the LINKED set, which reads the per-channel window's output and is | |
| // dimensionless in both worlds — hence not multiplied. n = (E − 0.1) / 0.4. | |
| let n: Float = (dark - 0.1) / 0.4 | |
| for (lv, expected, label) in [ | |
| (Levels(black: 0.1, white: 0.5, mid: 0.5), n, "black/white, neutral γ"), | |
| (Levels(black: 0.1, white: 0.5, mid: 0.25), Self.clip(pow(n, 0.5)), | |
| "γ brightens"), | |
| (Levels(black: 0.9, white: 1.0, mid: 0.5), 0, "black above → clipped"), | |
| ] { | |
| let got = run(0.05, 0, lv) | |
| report(abs(got - expected) < 1e-4, | |
| String(format: "%@ → %.5f (expected %.5f)", label, got, expected)) | |
| } | |
| // The migration's ×3, measured on the kernel: the same window written in density on a | |
| // per-channel set renders what it renders on the linked one, which reads that window's output. | |
| let onLinked = LevelsSet(linked: Levels(black: 0.1, white: 0.5, mid: 0.5)) | |
| var inDensity = LevelsSet(linked: .neutral) | |
| inDensity.red = Levels(black: 0.1 * dmax, white: 0.5 * dmax, mid: 0.5) | |
| let asLinked = through(0.05, onLinked) | |
| let asDensity = through(0.05, inDensity) | |
| report(abs(asDensity - asLinked) < 1e-4, | |
| String(format: "the migration's ×%.0f holds through the GPU: (%.2f, %.2f) in density " | |
| + "on red renders %.5f, the same window on the linked set %.5f", dmax, | |
| inDensity.red.black, inDensity.red.white, asDensity, asLinked)) | |
| // The twin, adverse by construction: unmigrated, that window is three times too narrow, so | |
| // it over-stretches instead of agreeing. | |
| var unmigrated = LevelsSet(linked: .neutral) | |
| unmigrated.red = Levels(black: 0.1, white: 0.5, mid: 0.5) | |
| let asNarrow = through(0.05, unmigrated) | |
| report(abs(asNarrow - asLinked) > 0.1, | |
| String(format: "the twin: left unmigrated on red, the same numbers render %.5f", | |
| asNarrow)) | |
| // One number in two roles, or the positive branch's pivot and the window the per-channel | |
| // levels rest on could drift apart with nothing measuring it. | |
| report(abs(LevelsSet.resting(.red, for: .negative).white - dmax) < 1e-6, | |
| String(format: "the per-channel resting white and the positive branch's pivot are " | |
| + "one number (%.3f)", dmax)) | |
| report(abs(Levels.neutral.white - dmax) > 1e-6, | |
| String(format: "the twin: that reading refuses the mathematical identity's white " | |
| + "(%.3f), which `Levels()` keeps whatever the unit", Levels.neutral.white)) | |
| lines.append(contentsOf: channelChecks(ctx, &ok)) | |
| lines.append(contentsOf: curveChannelChecks(ctx, &ok)) | |
| lines.append(contentsOf: chromaChecks(ctx, &ok)) | |
| lines.append(contentsOf: blurFloorChecks(ctx, &ok)) | |
| lines.append(contentsOf: saturationChecks(ctx, &ok)) | |
| let (sharpenOK, sharpenReport) = Sharpen.selfCheck(ctx) | |
| ok = ok && sharpenOK | |
| lines.append(sharpenReport) | |
| lines.append(contentsOf: radiusReferenceChecks(ctx, &ok)) | |
| let (textureOK, textureReport) = MainActor.assumeIsolated { Texture.selfCheck() } | |
| ok = ok && textureOK | |
| lines.append(textureReport) | |
| lines.append(contentsOf: geometryChecks(&ok)) | |
| lines.append(contentsOf: viewportChecks(&ok)) | |
| lines.append(contentsOf: splitViewChecks(&ok)) | |
| lines.append(contentsOf: positiveGainChecks(ctx, &ok)) | |
| lines.append(contentsOf: headroomChecks(ctx, &ok)) | |
| lines.append(contentsOf: outputClipChecks(ctx, &ok)) | |
| lines.append(contentsOf: graduationChecks(ctx, &ok)) | |
| lines.append(contentsOf: modeChecks(ctx, &ok)) | |
| lines.append(contentsOf: windowChecks(ctx, &ok)) | |
| lines.append(contentsOf: globalWindowChecks(ctx, &ok)) | |
| lines.append(contentsOf: handleChecks(&ok)) | |
| lines.append(contentsOf: patchEncodingChecks(&ok)) | |
| lines.append(contentsOf: applyCorrectionsPositionChecks(&ok)) | |
| lines.append(contentsOf: manualCurvesChecks(ctx, &ok)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// `.RGBA16` once shipped here and clamped decoded-source pixels — routinely outside 0…1 — to | |
| /// black. Raw bytes, never `CIColor`, which tags sRGB and gets gamma-decoded on the way in. | |
| private static func patchEncodingChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| func constant(_ value: Float) -> CIImage { | |
| var raw = [Float](repeating: 0, count: 4 * 4 * 4) | |
| for i in 0..<16 { raw[i * 4] = value; raw[i * 4 + 1] = value | |
| raw[i * 4 + 2] = value; raw[i * 4 + 3] = 1 } | |
| return raw.withUnsafeBytes { | |
| CIImage(bitmapData: Data($0), bytesPerRow: 4 * 16, size: CGSize(width: 4, height: 4), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| } | |
| for value: Float in [-0.5, -0.01, 0.04, 1.5] { | |
| guard let data = encodedPatch(constant(value)), let decoded = CIImage(data: data) else { | |
| report(false, "value \(value): encode/decode failed") | |
| continue | |
| } | |
| var px = [Float](repeating: 0, count: 4) | |
| measureContext.render(decoded, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBAf, colorSpace: nil) | |
| report(abs(px[0] - value) < 1e-3, | |
| String(format: "out-of-range patch value %.2f round-trips to %.4f through the real " | |
| + "TIFF bytes", value, px[0])) | |
| } | |
| return lines | |
| } | |
| /// The one check that goes through the REAL `applyCorrections`, cache included: every other | |
| /// correction check samples near the origin, which is exactly where a decoded patch (a CGImage | |
| /// carries no position) lands whether or not it was ever moved to its tile — a bug here can | |
| /// pass every other check and still show as a stroke-shaped hole anywhere off-origin. | |
| private static func applyCorrectionsPositionChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL ") \(text)") | |
| } | |
| func constant(_ value: Float, _ size: CGSize) -> CIImage { | |
| let w = Int(size.width), h = Int(size.height) | |
| var raw = [Float](repeating: 0, count: w * h * 4) | |
| for i in 0..<(w * h) { raw[i * 4] = value; raw[i * 4 + 1] = value | |
| raw[i * 4 + 2] = value; raw[i * 4 + 3] = 1 } | |
| return raw.withUnsafeBytes { | |
| CIImage(bitmapData: Data($0), bytesPerRow: w * 16, size: size, format: .RGBAf, | |
| colorSpace: nil) | |
| } | |
| } | |
| let extent = CGRect(x: 0, y: 0, width: 4000, height: 3000) | |
| let background = constant(0.10, extent.size).cropped(to: extent) | |
| let correction = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 40) | |
| let tileRect = CorrectionRenderer.contextRect(for: correction, in: extent) | |
| report(tileRect.minX > 100 && tileRect.minY > 100, | |
| String(format: "fixture's own tile sits well off the origin (%.0f, %.0f), the case " | |
| + "every other correction check misses", tileRect.minX, tileRect.minY)) | |
| // Mirrors what `generate()` actually caches: a patch positioned at its tile before encoding. | |
| let patchValue: Float = 0.80 | |
| let positioned = constant(patchValue, tileRect.size) | |
| .transformed(by: CGAffineTransform(translationX: tileRect.minX, y: tileRect.minY)) | |
| guard let data = encodedPatch(positioned) else { | |
| return [" FAIL applyCorrections position check: fixture patch failed to encode"] | |
| } | |
| CorrectionCache.persist(data, correctionID: correction.id) | |
| defer { CorrectionCache.discard([correction.id]) } | |
| var settings = PipelineSettings() | |
| settings.corrections = [correction] | |
| let composited = framed(background, settings: settings) | |
| var px = [Float](repeating: 0, count: 4) | |
| let strokeX = extent.minX + 0.5 * extent.width | |
| let strokeY = extent.minY + (1 - 0.5) * extent.height | |
| measureContext.render(composited, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: strokeX, y: strokeY, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| report(abs(px[0] - patchValue) < 0.05, | |
| String(format: "sampling the painted stroke reads %.4f, the patch's own value " | |
| + "(%.2f) — not the background (0.10) and not a transparent 0.0000", px[0], | |
| patchValue)) | |
| // A SPLIT patch — left half one value, right half another, split exactly at the tile's | |
| // own centre — rendered on a REDUCED preview with `fullWidth` pointing at the real | |
| // resolution. A flat patch cannot see this bug: `contextRect`'s tile stays CENTRED on the | |
| // stroke whatever its (wrong) size, so a uniform fill reads the same regardless. Only a | |
| // patch with real content either side of centre shows a tile that is the wrong size — and | |
| // therefore pasted at the wrong offset from that same centre — landing the wrong half. | |
| let splitLeft: Float = 0.30, splitRight: Float = 0.90 | |
| func splitPatch(_ size: CGSize) -> CIImage { | |
| let half = size.width / 2 | |
| let right = constant(splitRight, CGSize(width: half, height: size.height)) | |
| .transformed(by: CGAffineTransform(translationX: half, y: 0)) | |
| return right.composited(over: constant(splitLeft, CGSize(width: half, height: size.height))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: size.width, height: size.height)) | |
| } | |
| let splitCorrection = Correction(stroke: [CGPoint(x: 0.5, y: 0.5)], radius: 40) | |
| let splitPositioned = splitPatch(tileRect.size) | |
| .transformed(by: CGAffineTransform(translationX: tileRect.minX, y: tileRect.minY)) | |
| guard let splitData = encodedPatch(splitPositioned) else { | |
| return lines + [" FAIL reduced-scale check: split fixture patch failed to encode"] | |
| } | |
| CorrectionCache.persist(splitData, correctionID: splitCorrection.id) | |
| defer { CorrectionCache.discard([splitCorrection.id]) } | |
| var splitSettings = PipelineSettings() | |
| splitSettings.corrections = [splitCorrection] | |
| let reducedScale: CGFloat = 0.5 | |
| let reducedExtent = CGRect(x: 0, y: 0, width: extent.width * reducedScale, | |
| height: extent.height * reducedScale) | |
| let reducedBackground = constant(0.10, reducedExtent.size).cropped(to: reducedExtent) | |
| let reducedComposited = framed(reducedBackground, settings: splitSettings, fullWidth: extent.width) | |
| let reducedCentreX = reducedExtent.minX + 0.5 * reducedExtent.width | |
| let reducedCentreY = reducedExtent.minY + (1 - 0.5) * reducedExtent.height | |
| func sample(_ x: CGFloat, _ y: CGFloat) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| measureContext.render(reducedComposited, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: x, y: y, width: 1, height: 1), format: .RGBAf, | |
| colorSpace: nil) | |
| return px[0] | |
| } | |
| // 8, not 15: the mask's own radius is now correctly scaled too (40 px full-resolution | |
| // becomes 20 at this 0.5 preview) — a sample this close to its edge would cross into the | |
| // feather and blend toward the background, which is a second, later fix, not this one. | |
| let leftSample = sample(reducedCentreX - 8, reducedCentreY) | |
| let rightSample = sample(reducedCentreX + 8, reducedCentreY) | |
| report(abs(leftSample - splitLeft) < 0.1 && abs(rightSample - splitRight) < 0.1, | |
| String(format: "on a reduced preview (scale %.2f) with `fullWidth` given, the " | |
| + "patch's own split — its tile's exact centre — still lands on the stroke's " | |
| + "own centre: left %.3f (wanted %.2f), right %.3f (wanted %.2f)", reducedScale, | |
| leftSample, splitLeft, rightSample, splitRight)) | |
| report(abs(leftSample - rightSample) > 0.3, | |
| String(format: "and the check discriminates: an oversized, mispositioned tile would " | |
| + "show the SAME half on both samples, not this %.3f gap", | |
| abs(leftSample - rightSample))) | |
| return lines | |
| } | |
| /// The stage between Levels and Colour: a neutral sidecar round trip, its position proven | |
| /// against `truncated(before:)` rather than assumed, and its cost when at rest. | |
| private static func manualCurvesChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // Sidecar round trip, the same tolerant-decoding contract every other field keeps. | |
| var withCurve = PipelineSettings.neutral | |
| withCurve.manualCurves.linked = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 0.5, y: 0.9), | |
| CGPoint(x: 1, y: 1)]) | |
| guard let data = try? SettingsCodec.encode(withCurve), | |
| let decoded = try? SettingsCodec.decode(data) else { | |
| return [" FAIL manualCurves: a settings document carrying it does not encode or decode"] | |
| } | |
| report(decoded.settings.manualCurves == withCurve.manualCurves, | |
| "manualCurves round-trips through the sidecar exactly") | |
| guard var object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| var settingsObject = object["settings"] as? [String: Any] else { | |
| return lines + [" FAIL manualCurves: the encoded sidecar is not a JSON object"] | |
| } | |
| settingsObject.removeValue(forKey: "manualCurves") | |
| object["settings"] = settingsObject | |
| if let holedData = try? JSONSerialization.data(withJSONObject: object), | |
| let holedDecoded = try? SettingsCodec.decode(holedData) { | |
| report(holedDecoded.settings.manualCurves.isNeutral, | |
| "a sidecar written before this field existed reads it back neutral") | |
| } else { | |
| report(false, "a sidecar missing manualCurves should still decode") | |
| } | |
| // Order, proven by contrast against `truncated(before:)`. Only two points each — a | |
| // straight line end to end — so it moves the real value wherever it lands, not just near one point. | |
| let bump = Curve(points: [CGPoint(x: 0, y: 0.3), CGPoint(x: 1, y: 1)]) | |
| let dip = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0.7)]) | |
| var probe = PipelineSettings.neutral | |
| probe.manualCurves.linked = bump | |
| probe.curves.linked = dip | |
| let flat = CIImage(color: CIColor(red: 0.5, green: 0.5, blue: 0.5)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| func sample(_ settings: PipelineSettings, before stage: PipelineSettings.Stage) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(flat, settings: settings.truncated(before: stage), measuring: true), | |
| toBitmap: &px, rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var curvesNeutralOnly = probe | |
| curvesNeutralOnly.curves = CurveSet() | |
| let beforeCurvesWithBoth = sample(probe, before: .curves) | |
| let beforeCurvesCurvesNeutral = sample(curvesNeutralOnly, before: .curves) | |
| report(abs(beforeCurvesWithBoth - beforeCurvesCurvesNeutral) < 0.001, | |
| String(format: "the input measured for `curves` (%.4f) does not depend on curves' " | |
| + "OWN setting (%.4f neutral) — it has not run yet, only reflects what came before", | |
| beforeCurvesWithBoth, beforeCurvesCurvesNeutral)) | |
| var neitherCurve = probe | |
| neitherCurve.manualCurves = CurveSet() | |
| neitherCurve.curves = CurveSet() | |
| let beforeManualNeither = sample(neitherCurve, before: .manualCurves) | |
| let beforeCurvesNeither = sample(neitherCurve, before: .curves) | |
| report(abs(beforeManualNeither - beforeCurvesNeither) < 0.001, | |
| String(format: "with both curve sets neutral, the input reported before manualCurves " | |
| + "(%.4f) and before curves (%.4f) agree — nothing ran between them", | |
| beforeManualNeither, beforeCurvesNeither)) | |
| report(abs(beforeCurvesWithBoth - beforeCurvesNeither) > 0.05, | |
| String(format: "and the check discriminates: manualCurves' own bump moves what " | |
| + "enters curves by %.4f — it really did run in between", | |
| abs(beforeCurvesWithBoth - beforeCurvesNeither))) | |
| // Contrast now runs ahead of manualCurves — proven the same way, but never on the flat | |
| // 0.5 `flat` above: a contrast curve pivots on middle grey, so 0.5 never moves. | |
| let dark = CIImage(color: CIColor(red: 0.2, green: 0.2, blue: 0.2)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| func sampleDark(_ settings: PipelineSettings, before stage: PipelineSettings.Stage) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(dark, settings: settings.truncated(before: stage), measuring: true), | |
| toBitmap: &px, rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var contrastProbe = PipelineSettings.neutral | |
| contrastProbe.curves.contrast = 0.5 | |
| let beforeManualWithContrast = sampleDark(contrastProbe, before: .manualCurves) | |
| let beforeManualNoContrast = sampleDark(.neutral, before: .manualCurves) | |
| report(abs(beforeManualWithContrast - beforeManualNoContrast) > 0.05, | |
| String(format: "contrast moves what enters manualCurves by %.4f — it runs ahead of " | |
| + "it, not composed into Colour's own table any more", | |
| abs(beforeManualWithContrast - beforeManualNoContrast))) | |
| let beforeLevelsWithContrast = sampleDark(contrastProbe, before: .levels) | |
| let beforeLevelsNoContrast = sampleDark(.neutral, before: .levels) | |
| report(abs(beforeLevelsWithContrast - beforeLevelsNoContrast) < 0.001, | |
| String(format: "and the check discriminates: contrast has NOT run yet before levels " | |
| + "(%.4f vs %.4f) — it sits between levels and manualCurves, not ahead of " | |
| + "levels itself", beforeLevelsWithContrast, beforeLevelsNoContrast)) | |
| // Cost at rest: `applyCurves`'s own short-circuit for a neutral set means a resting | |
| // manualCurves must cost the same as if this second stage were not there at all. | |
| let t0 = ProcessInfo.processInfo.systemUptime | |
| for _ in 0..<20 { _ = apply(flat, settings: .neutral, measuring: true) } | |
| let perCall = (ProcessInfo.processInfo.systemUptime - t0) / 20 * 1000 | |
| report(perCall < 5, | |
| String(format: "a resting manualCurves adds no measurable dispatch: %.3f ms per " | |
| + "apply(), averaged over 20", perCall)) | |
| return lines | |
| } | |
| /// Guards against cascading clipping: once a channel is stretched to 1, `pow(1, γ) = 1` makes | |
| /// every later correction on it useless. | |
| private static func headroomChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| func run(_ t: Float, _ set: LevelsSet) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(levels: set).perPixelOnly()), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var lines: [String] = [] | |
| // An aggressive stretch pushes the value above 1; a later channel setting must be able to | |
| // recover it, proving the information survived rather than being clipped. | |
| let hard = Levels(black: 0.1, white: 0.25, mid: 0.5) // T=0.05 → E/3 = 0.41 → n ≈ 2.1 | |
| let stretched = run(0.05, LevelsSet(linked: hard)) | |
| var recovered = LevelsSet(linked: hard) | |
| // Three times the resting window, since the resting one is what the stretch already reads: | |
| // a window brings the range down only by how far it opens PAST its rest. | |
| recovered.red = Levels(black: 0, white: 3 * dmax, mid: 0.5) | |
| let brought = run(0.05, recovered) | |
| let survives = brought < stretched - 0.05 | |
| ok = ok && survives | |
| lines.append(String(format: " %@ the overshoot survives the next stage: %.5f recovered to %.5f", | |
| survives ? "OK " : "FAIL", stretched, brought)) | |
| // The twin, adverse by construction: at the resting white the per-channel window is the | |
| // migration's own identity, so it recovers nothing and the reading above must refuse it. | |
| var atItsRest = LevelsSet(linked: hard) | |
| atItsRest.red = Levels(black: 0, white: dmax, mid: 0.5) | |
| let unrecovered = run(0.05, atItsRest) | |
| let refuses = !(unrecovered < stretched - 0.05) | |
| ok = ok && refuses | |
| lines.append(String(format: " %@ and the check discriminates: red left on the resting white " | |
| + "recovers nothing (%.5f)", refuses ? "OK " : "FAIL", unrecovered)) | |
| // The clip bounds, touches nothing below 1, and must not shave: two distinct values below 1 | |
| // must stay distinct, or the float invariant would be violated by quantisation. | |
| func clipRisesStrictly(_ f: (Float) -> Float) -> Bool { | |
| let steps = (0...64).map { Float($0) / 64 } | |
| return zip(steps, steps.dropFirst()).allSatisfy { f($0) < f($1) } | |
| } | |
| let ramp: [Float] = [0.5, 0.85, 0.9, 1.0, 1.3, 2.0] | |
| let clipped = ramp.map { Self.clip($0) } | |
| let bounded = (clipped + [Self.clip(10), Self.clip(-5)]).allSatisfy { $0 <= 1 && $0 >= 0 } | |
| let rises = zip(clipped, clipped.dropFirst()).allSatisfy { $0 <= $1 } | |
| let untouched = abs(Self.clip(0.5) - 0.5) < 1e-9 && abs(Self.clip(1) - 1) < 1e-9 | |
| let strictBelow = clipRisesStrictly(clip) | |
| let flatRejected = !clipRisesStrictly { _ in 0.5 } | |
| let clipOK = bounded && rises && untouched && strictBelow && flatRejected | |
| ok = ok && clipOK | |
| lines.append(String(format: " %@ hard clip: bounded, increasing, nothing touched below 1, " | |
| + "and the check discriminates a constant function (−5 → %.1f, " | |
| + "10 → %.1f)", clipOK ? "OK " : "FAIL", Self.clip(-5), Self.clip(10))) | |
| // Caps the luma factor so an extreme shadow lift stays in the range the compression can | |
| // handle, rather than overflowing. | |
| var shadowLift = LevelsSet() | |
| shadowLift.luma = Levels(black: 0, white: 0.01, mid: 0.5) | |
| let lifted = run(0.9, shadowLift) // T=0.9 → very low E, the case that blew up | |
| let contained = lifted <= 1 && lifted.isFinite | |
| ok = ok && contained | |
| lines.append(String(format: " %@ extreme shadow lift contained: %.5f (the scaling is bounded)", | |
| contained ? "OK " : "FAIL", lifted)) | |
| return lines | |
| } | |
| /// What the conversion mode has to guarantee, mode by mode, measured through the GPU. | |
| private static func modeChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| func run(_ t: Float, _ s: PipelineSettings) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: s.perPixelOnly()), toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| func settings(_ mode: ConversionMode, stops: SIMD3<Float> = .zero, | |
| finish: SIMD3<Float> = .zero) -> PipelineSettings { | |
| var out = PipelineSettings(mode: mode, gains: Gains(stops: stops)) | |
| // The linked median's resting point depends on the mode: without setting it explicitly, | |
| // a hand-built positive setting would inherit the negative's resting gamma. | |
| out.levels = .neutral(for: mode) | |
| out.toneBalance.shadows.colour = finish | |
| return out | |
| } | |
| // C1 — monotonicity in both directions: dense film must come out lighter in negative and | |
| // darker in positive, so no constant `invertFlag` can satisfy both cases. | |
| let negRises = run(0.05, settings(.negative)) > run(0.50, settings(.negative)) | |
| let posRises = run(0.05, settings(.positive)) > run(0.50, settings(.positive)) | |
| report(negRises && !posRises, | |
| "opposite monotonicity between the modes: in negative T=0.05 comes out lighter " | |
| + "than T=0.50, in positive darker") | |
| // C2 — closed-form values for the positive branch, the only way to catch a mismatch on the | |
| // `window` the re-inversion pivots on: it is unread by the negative branch. | |
| for t in [Float(1.0), 0.5, 0.18, 0.05] { | |
| let got = run(t, settings(.positive)) | |
| let want = clip(atModeRest(t, mode: .positive)) | |
| report(abs(got - want) < 1e-4, | |
| String(format: "positive: T=%.2f → E %.5f (expected %.5f)", t, got, want)) | |
| } | |
| // C2b — what the whole positive rest is FOR: stage 5 log-encodes a positive as well, and the | |
| // resting pair is what undoes it. Frozen, so moving the pair is a decision and not a drift. | |
| let grey = run(0.18, settings(.positive)) | |
| report(abs(grey - 0.47933) < 2e-3, | |
| String(format: "the positive rest undoes stage 5: an 18 %% grey renders %.5f, on its " | |
| + "way down from the 0.758 no gamma leaves it at", grey)) | |
| // The twin, adverse by construction: the rest earlier versions carried, which is the same | |
| // two windows with the median at the identity and the black left under the pedestal's veil. | |
| var former = settings(.positive) | |
| former.levels.linked.mid = SettingsCodec.formerPositiveMid | |
| for channel in LevelsChannel.perChannel { former.levels[channel].black = 0 } | |
| let flat = run(0.18, former) | |
| // Frozen against its own figure rather than against the reading above, or a rest that broke | |
| // both would move the two together and the pair would agree all the way down. | |
| report(abs(flat - 0.75805) < 2e-3, | |
| String(format: "the twin: on the former rest the same grey stays at %.5f, %.0f %% of " | |
| + "the way to white, and refuses", flat, flat * 100)) | |
| // C3 — the direction of stage 4's gains, measured and compared against | |
| // `mode.invertsGainSense`, the property the UI's gradients actually consume. | |
| for mode in ConversionMode.allCases { | |
| let base = run(0.2, settings(mode)) | |
| let pushed = run(0.2, settings(mode, stops: SIMD3<Float>(1, 0, 0))) | |
| let lowers = pushed < base | |
| report(lowers == mode.invertsGainSense, | |
| String(format: "stage 4 in %@: +1 stop of red %@ the output (%.5f → %.5f), " | |
| + "matches invertsGainSense = %@", mode.rawValue, | |
| lowers ? "lowers" : "raises", base, pushed, | |
| mode.invertsGainSense ? "true" : "false")) | |
| } | |
| // C4 — stage 8 is insensitive to the mode: it acts after the whole tone mapping in both | |
| // cases, and must not be wired to it too. | |
| for mode in ConversionMode.allCases { | |
| let base = run(0.2, settings(mode)) | |
| let pushed = run(0.2, settings(mode, finish: SIMD3<Float>(1, 0, 0))) | |
| report(pushed > base, | |
| String(format: "stage 8 in %@: +1 stop of red raises the output (%.5f → %.5f)", | |
| mode.rawValue, base, pushed)) | |
| } | |
| // C9 — the truncated settings feeding the histograms must carry the mode; the naive | |
| // member-by-member construction below is required to diverge from it. | |
| var live = PipelineSettings(mode: .positive, gains: Gains(stops: SIMD3<Float>(1, -0.5, 0.25))) | |
| live.levels.red = Levels(black: 0.1, white: 0.8, mid: 0.4) | |
| live.curves.luma.points = [CGPoint(x: 0, y: 0), CGPoint(x: 0.4, y: 0.6), CGPoint(x: 1, y: 1)] | |
| live.sharpen = Sharpen(radius: 0.001, amount: 1) | |
| live.texture.amount = 1.5 | |
| live.density = 0.8 | |
| let cut = live.truncated(before: .levels) | |
| // Compared against a whole value rather than field by field: a field added downstream and | |
| // forgotten in the truncation shows up here, where a hand-written list would not name it. | |
| var expected = PipelineSettings() | |
| expected.mode = live.mode | |
| expected.geometry = live.geometry | |
| expected.flatField = live.flatField | |
| expected.chroma = live.chroma | |
| expected.gains = live.gains | |
| let graduated = Graduation.levels(for: live.mode) | |
| expected.levels = LevelsSet(luma: .neutral, linked: .neutral, | |
| red: graduated, green: graduated, blue: graduated) | |
| // The one exception: only the dose is dropped, the radius rides along, since a resting | |
| // `Sharpen()` carries a real amount and would sharpen the measurement. | |
| expected.sharpen = live.sharpen | |
| expected.sharpen.amount = 0 | |
| report(cut == expected, | |
| "the truncated settings keep the mode and the gains, graduate the three windows and " | |
| + "neutralise every downstream field") | |
| // The twin: the graduation is the mode's own, so a truncation taking the negative window in | |
| // positive mode — the mistake a mode-blind constant would make — is refused here. | |
| var wrongWindow = expected | |
| let mirrored = Graduation.levels(for: .negative) | |
| wrongWindow.levels = LevelsSet(luma: .neutral, linked: .neutral, | |
| red: mirrored, green: mirrored, blue: mirrored) | |
| report(cut != wrongWindow, | |
| String(format: "and the check discriminates: in %@ the window runs %.3f…%.3f, not " | |
| + "the negative branch's %.3f…%.3f", live.mode.rawValue, graduated.black, | |
| graduated.white, mirrored.black, mirrored.white)) | |
| // Checked via `isNeutral`, never against `Sharpen()`: the resting default now carries a | |
| // real dose, so comparing to it would go green again the next time the default moves. | |
| report(cut.sharpen.isNeutral && cut.texture.isNeutral && cut.density == 0, | |
| String(format: "including the three that measure their own dose: sharpening, " | |
| + "texture and a density of %.1f all leave the measurement", live.density)) | |
| report(!Sharpen().isNeutral, | |
| "and the check discriminates: writing `Sharpen()` in the truncation would leave " | |
| + String(format: "%.2f× of sharpening on the measurement", Sharpen().amount)) | |
| // Downstream is flatly neutralised: `LevelsSet()` carries the linked set's resting median, | |
| // which would leave a gamma in a measurement meant to show what enters the levels. | |
| report(cut.levels.linked.mid == 0.5 && LevelsSet().linked.mid != 0.5, | |
| String(format: "the input measurement carries no gamma (%.2f) where the resting " | |
| + "point carries one (%.2f)", cut.levels.linked.mid, LevelsSet().linked.mid)) | |
| let naive = PipelineSettings(geometry: live.geometry, gains: live.gains) | |
| report(naive.mode != live.mode, | |
| "and the check discriminates: the member-by-member construction, on the other " | |
| + "hand, loses the mode (\(naive.mode.rawValue) instead of \(live.mode.rawValue))") | |
| report(live.truncated(before: .globalLevels).levels == live.levels.perChannelOnly | |
| && live.truncated(before: .curves).levels == live.levels, | |
| "each step only neutralises what follows it") | |
| // C10 — no NaN or infinity anywhere the density goes negative: a transmittance over 1 | |
| // now carries a density under zero, in EVERY mode, which only the levels and the clip bound. | |
| var worst: Float = 0 | |
| var lowest: Float = 0 | |
| var finite = true | |
| var combinations = 0 | |
| for mode in ConversionMode.allCases { | |
| var levelled = settings(mode) | |
| levelled.levels.linked = Levels(black: 0.15, white: 0.7, mid: 0.3) | |
| for t in [Float(1e-6), 1e-4, 1e-2, 0.18, 0.9, 1, 10, 100] { | |
| for stops in [Float(-3), 0, 3] { | |
| var s = levelled | |
| s.gains = Gains(stops: SIMD3<Float>(repeating: stops)) | |
| let got = run(t, s) | |
| finite = finite && got.isFinite && got >= 0 && got <= 1 | |
| worst = max(worst, got.isFinite ? got : .infinity) | |
| lowest = min(lowest, density(ofT: t, stops: stops)) | |
| combinations += 1 | |
| } | |
| } | |
| } | |
| report(finite, String(format: "%d combinations of mode, T and gains, all finite and in " | |
| + "[0,1] (max %.5f)", combinations, worst)) | |
| // The twin, adverse by construction: T = 100 at +3 stops is a transmittance of 800, whose | |
| // logarithm is positive, so the grid really reaches the over-unit population it claims to. | |
| report(lowest < 0, | |
| String(format: "the twin: the grid carries a density of %.5f at its lowest, under " | |
| + "zero — without it the finiteness above would be measured on bounded pixels", | |
| lowest)) | |
| // C11 — the guard sits an eighth of the way under the pedestal, so the floor is reached at | |
| // exactly −3 stops of gain and not before. Read on the kernel's own arithmetic: the positive | |
| // rest places a black point, which would decide this floor instead of the guard. | |
| var sunk = settings(.positive, stops: SIMD3<Float>(repeating: -3)) | |
| sunk.levels = LevelsSet(luma: .neutral, linked: .neutral, red: .onAxis, green: .onAxis, | |
| blue: .onAxis) | |
| let floorHit = run(0, sunk) | |
| let justAbove = run(0.0004, sunk) | |
| report(abs(floorHit) < 1e-4 && justAbove > 1e-3, | |
| String(format: "positive floor at 3 decades: T=0 comes out at %.5f, and the check " | |
| + "discriminates — just above, %.5f > 0", floorHit, justAbove)) | |
| let veil = run(0, settings(.positive)) | |
| report(abs(veil - clip(atModeRest(0, mode: .positive))) < 1e-4 && veil < 1e-4, | |
| String(format: "and at neutral gain the resting black sits on the pedestal's veil, " | |
| + "so a physical black opens on %.5f", veil)) | |
| // The twin, adverse by construction: the same frame with the resting black put back to zero, | |
| // the one place the veil is subtracted, so a physical black stops reaching black. | |
| var unveiled = settings(.positive) | |
| for channel in LevelsChannel.perChannel { unveiled.levels[channel].black = 0 } | |
| let carried = run(0, unveiled) | |
| report(carried > 1e-3, | |
| String(format: "the twin: with that black at zero the veil stays in the picture " | |
| + "(%.5f) and refuses", carried)) | |
| return lines | |
| } | |
| /// What the output clip has to guarantee, measured through the GPU: a hard clip, no longer an | |
| /// adjustable shoulder. | |
| private static func outputClipChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // Checks that the kernel itself clips, not just the Swift replica — a divergence between | |
| // the two would fail silently otherwise. | |
| func run(_ t: Float, _ settings: PipelineSettings, measuring: Bool = false) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: settings.perPixelOnly(), measuring: measuring), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| let sunk = PipelineSettings(gains: Gains(stops: .init(repeating: -6))) | |
| // The one population where the two forms part: in positive a transmittance over 1 gives | |
| // a density under zero, hence a value past the window's top that only this clip brings back. | |
| var over = PipelineSettings(mode: .positive, gains: Gains(stops: .init(repeating: 3))) | |
| over.levels = .neutral(for: .positive) | |
| let overshoot = atModeRest(1, stops: 3, mode: .positive) | |
| let measured = run(1, over, measuring: true) | |
| let onScreen = run(1, over) | |
| report(abs(measured - overshoot) < 1e-4 && abs(onScreen - 1) < 1e-4, | |
| String(format: "the kernel hard-clips what now passes 1: %.5f measured, %.5f on " | |
| + "screen (closed form %.5f)", measured, onScreen, overshoot)) | |
| // The twin: a value well below the ceiling must pass through intact, or a kernel returning | |
| // 1 everywhere would pass the check above. | |
| let mid = run(0.2, .neutral) | |
| // Carries the resting gamma: `.neutral` is a negative-mode setting, so this checks the | |
| // app's default rendering, not the kernel's bare arithmetic. | |
| let midExpected = clip(pow(atRest(0.2), restingGamma)) | |
| report(abs(mid - midExpected) < 1e-4, | |
| String(format: "and the check discriminates: 0.2 passes through intact (%.5f, " | |
| + "expected %.5f)", mid, midExpected)) | |
| // The measuring path skips the clip, keeping the histogram trace in the same frame of | |
| // reference as the levels handles. | |
| let flat = CIImage(color: .black).cropped(to: CGRect(x: 0, y: 0, width: 16, height: 16)) | |
| func peakBin(_ measuring: Bool) -> Int { | |
| let counts = histogram(apply(flat, settings: sunk, measuring: measuring)).rgb | |
| return counts.indices.max(by: { counts[$0].x < counts[$1].x }) ?? -1 | |
| } | |
| let measuringBin = peakBin(true), screenBin = peakBin(false) | |
| report(measuringBin >= screenBin, | |
| "the measuring path does not shave before the trace " | |
| + "(measurement \(measuringBin), screen \(screenBin))") | |
| // A sheet's gutter: alpha zero, carried as such by every stage, so what it lands on is the | |
| // whole of what it renders. | |
| func gutter(_ sheet: Bool) -> (Float, Float) { | |
| var settings = PipelineSettings() | |
| settings.sheetMode = sheet | |
| // A colour with an extent, not `.empty()`, which has none and renders no pixel at all. | |
| let clear = CIImage(color: CIColor(red: 0, green: 0, blue: 0, alpha: 0)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: -1, count: 4) | |
| ctx.render(apply(clear, settings: settings.perPixelOnly()), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return (px[0], px[3]) | |
| } | |
| let laid = gutter(true), bare = gutter(false) | |
| report(laid.0 == 0 && laid.1 == 1, | |
| String(format: "a sheet's gutter renders black and opaque — %.4f at alpha %.4f — " | |
| + "so an export shows the ground the canvas does", laid.0, laid.1)) | |
| // The twin, and it is what the first attempt measured: composited without premultiplying, | |
| // the same gutter keeps the colour the chain gave it and lands light. | |
| report(bare.0 > 0.5, | |
| String(format: "and the check discriminates: that gutter carries %.4f in its " | |
| + "colour whatever the alpha says, so compositing alone would not black it", | |
| bare.0)) | |
| report(bare.1 == 0, | |
| String(format: "off a sheet nothing is laid: the same pixel keeps its alpha " | |
| + "%.4f, so a rotated frame's corners stay as they were", bare.1)) | |
| return lines | |
| } | |
| /// The lowest density a test film holds, a transmittance of 1.75 through its blue channel. What | |
| /// the window has to reach past for those pixels to be countable rather than piled on an edge. | |
| private static let filmExtreme: Float = -0.243 | |
| /// The window the density is counted on: what a bin index reads back as, both ends included, | |
| /// and what bounding the counted range is worth against leaving it to the counter. | |
| private static func graduationChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let bins = 1024 | |
| /// One bin of density: the tolerance every reading below is held to, and the resolution the | |
| /// window costs. | |
| let step = Graduation.span / Float(bins) | |
| lines.append(String(format: " ---- graduation: %.4f…%.4f in negative, %.4f…%.4f in " | |
| + "positive, span %.4f, %.6f of density per bin (%.5f st)", | |
| Graduation.window(for: .negative).lowerBound, | |
| Graduation.window(for: .negative).upperBound, | |
| Graduation.window(for: .positive).lowerBound, | |
| Graduation.window(for: .positive).upperBound, | |
| Graduation.span, step, step * log2(Float(10)))) | |
| report(abs(Graduation.top - density(ofT: 0, stops: -6)) < 1e-6, | |
| String(format: "the window's top is the guard's own density (%.5f), so the pixels the " | |
| + "guard piles land on the last bin and nowhere inside", Graduation.top)) | |
| report(abs(Graduation.window(for: .negative).upperBound | |
| - Graduation.window(for: .negative).lowerBound - Graduation.span) < 1e-6 | |
| && abs(Graduation.window(for: .positive).upperBound | |
| - Graduation.window(for: .positive).lowerBound - Graduation.span) < 1e-6, | |
| String(format: "both modes span the same %.4f — stage 6 mirrors the axis, it does " | |
| + "not stretch it", Graduation.span)) | |
| report(Graduation.overshoot > -filmExtreme, | |
| String(format: "and it reaches %.3f under zero, clearing the %.3f a test film holds " | |
| + "by %.0f %%, at a cost of %.1f %% of the counted range", | |
| Graduation.overshoot, filmExtreme, | |
| (Graduation.overshoot + filmExtreme) / -filmExtreme * 100, | |
| Graduation.overshoot / Graduation.span * 100)) | |
| // The pair a bin index and a handle position are read through. Swept off any round value, so | |
| // no sample lands on a window's end by luck. | |
| var worstTrip: Float = 0 | |
| for mode in ConversionMode.allCases { | |
| for i in 0..<997 { | |
| let d = -0.7 + 4.3 * (Float(i) + 0.5) / 997 | |
| let back = Graduation.value(atFraction: Graduation.fraction(of: d, in: mode), | |
| in: mode) | |
| worstTrip = max(worstTrip, abs(back - d)) | |
| } | |
| } | |
| report(worstTrip < 1e-5, | |
| String(format: "fraction and value are inverses over the whole axis and past both " | |
| + "ends, worst %.3e", worstTrip)) | |
| // The twin: normalising on the window's top instead of its span is the mistake that reads as | |
| // right at one end, and the round trip above is what refuses it. | |
| let onTop = Graduation.window(for: .negative).lowerBound | |
| + Graduation.fraction(of: filmExtreme, in: .negative) * Graduation.top | |
| report(abs(onTop - filmExtreme) > step, | |
| String(format: "the twin: normalised on the top rather than the span, %.4f comes " | |
| + "back as %.4f", filmExtreme, onTop)) | |
| /// A flat frame of three channel values, unmanaged so an out-of-range value survives. | |
| func flat(_ rgb: SIMD3<Float>) -> CIImage { | |
| let side = 8 | |
| var data = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| data[i * 4] = rgb.x; data[i * 4 + 1] = rgb.y; data[i * 4 + 2] = rgb.z | |
| data[i * 4 + 3] = 1 | |
| } | |
| let bytes = data.withUnsafeBufferPointer { Data(buffer: $0) } | |
| return CIImage(bitmapData: bytes, bytesPerRow: side * 16, | |
| size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| /// The source value a wanted density needs, minus the pedestal the kernel adds back. | |
| func source(atDensity d: Float) -> Float { pow(10, -d) - tpedestal } | |
| func frame(atDensity d: Float) -> CIImage { | |
| flat(SIMD3(repeating: source(atDensity: d))) | |
| } | |
| /// The bin a flat frame's whole population lands on. | |
| func peakBin(_ counts: [Float]) -> Int { | |
| counts.indices.max(by: { counts[$0] < counts[$1] }) ?? -1 | |
| } | |
| /// The density a measurement's peak bin reads back as, through the published pair. | |
| func readBack(_ h: Histogram, _ mode: ConversionMode) -> Float { | |
| let bin = peakBin(h.rgb.map { $0.x }) | |
| let e = Graduation.value(atFraction: Float(bin) / Float(bins - 1), in: mode) | |
| return mode.invertFlag > 0.5 ? e : dmax - e | |
| } | |
| // One bin is the structural bound on this reading and not slack: the counter floors where | |
| // the readers divide by `bins − 1`, so a landmark can never come back more than a bin out. | |
| let landmarks: [Float] = [filmExtreme, filmExtreme / 2, 0, 1, -log10(tpedestal), | |
| Graduation.top] | |
| for mode in ConversionMode.allCases { | |
| var worst: Float = 0 | |
| var worstAt: Float = 0 | |
| for d in landmarks { | |
| let got = readBack(histogram(of: frame(atDensity: d), | |
| settings: PipelineSettings(mode: mode), | |
| before: .levels, bins: bins), mode) | |
| if abs(got - d) > worst { worst = abs(got - d); worstAt = d } | |
| } | |
| report(worst < step, | |
| String(format: "[%@] the six landmarks come back within one bin, worst %.5f at " | |
| + "D = %.4f (one bin is %.5f)", mode.rawValue, worst, worstAt, step)) | |
| } | |
| // The twin, adverse by construction: the resting window's black sits at zero, so the two | |
| // densities under it arrive on the same bin and no reading can tell them apart. | |
| var rest = PipelineSettings() | |
| rest.levels = LevelsSet(linked: .neutral) | |
| let underZero = [filmExtreme, filmExtreme / 2] | |
| let piled = underZero.map { d in | |
| peakBin(histogram(counted(apply(frame(atDensity: d), settings: rest, measuring: true)), | |
| bins: bins).rgb.map { $0.x }) | |
| } | |
| let separated = underZero.map { d in | |
| peakBin(histogram(of: frame(atDensity: d), settings: PipelineSettings(), | |
| before: .levels, bins: bins).rgb.map { $0.x }) | |
| } | |
| report(piled[0] == piled[1] && separated[0] != separated[1], | |
| String(format: "the twin: measured on the resting window instead, %.3f and %.3f both " | |
| + "land on bin %d, where the graduation separates them into %d and %d", | |
| underZero[0], underZero[1], piled[0], separated[0], separated[1])) | |
| // The bound on the counted range is the graduation's, not the counter's: in positive mode a | |
| // value past the window's top is reachable, and it is this path that holds it at 1. | |
| let past = -1.5 * Graduation.overshoot // follows the constant, never a literal | |
| let mixed = SIMD3(source(atDensity: past), source(atDensity: 1.5), source(atDensity: 1.5)) | |
| let bare = apply(flat(mixed), settings: PipelineSettings(mode: .positive) | |
| .truncated(before: .levels), measuring: true) | |
| var raw = [Float](repeating: 0, count: 4) | |
| ctx.render(counted(bare), toBitmap: &raw, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBAf, colorSpace: nil) | |
| var unbounded = [Float](repeating: 0, count: 4) | |
| ctx.render(bare, toBitmap: &unbounded, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBAf, colorSpace: nil) | |
| report(abs(raw[0] - 1) < 1e-6, | |
| String(format: "[positive] a density of %.3f lands at %.4f of the window and the " | |
| + "measuring path holds it at %.4f", past, unbounded[0], raw[0])) | |
| report(unbounded[0] > 1 + step, | |
| String(format: "the twin: the same pixel unbounded really carries %.4f, so the " | |
| + "bound above is this path's and not the counter's", unbounded[0])) | |
| // And it is the luma trace that pays for the difference: an out-of-window channel would drag | |
| // the joint distribution the two global tabs read, which no per-channel count can show. | |
| let boundLuma = peakBin(histogram(counted(bare), bins: bins).luma) | |
| let bareLuma = peakBin(histogram(bare, bins: bins).luma) | |
| report(boundLuma != bareLuma && boundLuma >= 0, | |
| String(format: "the luma trace follows the bound, bin %d against %d — the one place " | |
| + "the clamp changes a count on this build", boundLuma, bareLuma)) | |
| return lines | |
| } | |
| /// The guarantee of the chroma denoise: the luma channel does not move, which is what preserves | |
| /// film grain. Checked on a synthetic noisy image, since a flat would prove nothing. | |
| private static func chromaChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| guard chromaKernel != nil else { | |
| return [" FAIL chroma denoise kernel not found"] | |
| } | |
| // Coloured checkerboard: chroma that varies from one pixel to the next, hence that the blur | |
| // will really modify, and a luminance that varies too so that preserving it is a real test. | |
| let side = 64 | |
| var pixels = [Float](repeating: 0, count: side * side * 4) | |
| for y in 0..<side { | |
| for x in 0..<side { | |
| let i = (y * side + x) * 4 | |
| pixels[i] = (x + y) % 2 == 0 ? 0.6 : 0.2 | |
| pixels[i + 1] = x % 3 == 0 ? 0.5 : 0.3 | |
| pixels[i + 2] = y % 2 == 0 ? 0.15 : 0.45 | |
| pixels[i + 3] = 1 | |
| } | |
| } | |
| let source = pixels.withUnsafeBufferPointer { buffer -> CIImage? in | |
| guard let base = buffer.baseAddress else { return nil } | |
| return CIImage(bitmapData: Data(bytes: base, count: pixels.count * 4), | |
| bytesPerRow: side * 16, | |
| size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| guard let source else { return [" FAIL check image not built"] } | |
| let settings = ChromaDenoise(radius: 3.2, force: 1) | |
| let denoised = applyChromaDenoise(source, settings, scale: 1) | |
| func lumaPlane(_ image: CIImage) -> [Float] { | |
| var raw = [Float](repeating: 0, count: side * side * 4) | |
| ctx.render(image, toBitmap: &raw, rowBytes: side * 16, | |
| bounds: CGRect(x: 0, y: 0, width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| let w = lumaWeights | |
| return (0..<(side * side)).map { | |
| raw[$0 * 4] * w.x + raw[$0 * 4 + 1] * w.y + raw[$0 * 4 + 2] * w.z | |
| } | |
| } | |
| let before = lumaPlane(source) | |
| let after = lumaPlane(denoised) | |
| let worst = zip(before, after).map { abs($0 - $1) }.max() ?? 0 | |
| // Tolerance at single-precision floating point: the kernel feeds the luma back in as is, | |
| // but it goes through a round trip in a 32-bit texture. | |
| let preserved = worst < 1e-6 | |
| ok = ok && preserved | |
| lines.append(String(format: " %@ luma preserved by the denoise (worst gap %.2e)", | |
| preserved ? "OK " : "FAIL", worst)) | |
| // And the chroma must really have moved, otherwise we would only be proving an identity. | |
| func chromaSpread(_ image: CIImage) -> Float { | |
| var raw = [Float](repeating: 0, count: side * side * 4) | |
| ctx.render(image, toBitmap: &raw, rowBytes: side * 16, | |
| bounds: CGRect(x: 0, y: 0, width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| let w = lumaWeights | |
| // Standard deviation of the departure from grey, blue channel: what the denoise must | |
| // reduce. | |
| let values = (0..<(side * side)).map { | |
| raw[$0 * 4 + 2] - (raw[$0 * 4] * w.x + raw[$0 * 4 + 1] * w.y + raw[$0 * 4 + 2] * w.z) | |
| } | |
| let mean = values.reduce(0, +) / Float(values.count) | |
| return (values.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / Float(values.count)) | |
| .squareRoot() | |
| } | |
| let spreadBefore = chromaSpread(source) | |
| let spreadAfter = chromaSpread(denoised) | |
| let smoothed = spreadAfter < spreadBefore * 0.7 | |
| ok = ok && smoothed | |
| lines.append(String(format: " %@ chroma actually smoothed (std dev %.5f → %.5f)", | |
| smoothed ? "OK " : "FAIL", spreadBefore, spreadAfter)) | |
| lines.append(contentsOf: chromaPlacementChecks(ctx, &ok)) | |
| lines.append(contentsOf: chromaCost()) | |
| return lines | |
| } | |
| /// Where the stage belongs, measured **after the inversion** — the space the eye reads. Judged | |
| /// on the transmittance instead, the old placement scored well while tripling the visible noise. | |
| private static func chromaPlacementChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // A negative whose base favours red over blue by an order of magnitude — common on amber | |
| // stock — puts blue within reach of the transmittance floor. | |
| let side = 96 | |
| var seed: UInt32 = 12345 | |
| func noise() -> Float { | |
| seed = seed &* 1664525 &+ 1013904223 | |
| return Float(seed >> 8) / Float(1 << 24) - 0.5 | |
| } | |
| var pixels = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| let shade = 0.5 + 0.4 * Float(i % side) / Float(side) | |
| pixels[i * 4] = 0.45 * shade * (1 + 0.10 * noise()) | |
| pixels[i * 4 + 1] = 0.12 * shade * (1 + 0.25 * noise()) | |
| // Deliberately within reach of the floor: this is where a transplanted absolute chroma | |
| // takes more from the channel than it holds, and the measured fault lives. | |
| pixels[i * 4 + 2] = 0.024 * shade * (1 + 0.80 * noise()) | |
| pixels[i * 4 + 3] = 1 | |
| } | |
| guard let target = pixels.withUnsafeBufferPointer({ buffer -> CIImage? in | |
| guard let base = buffer.baseAddress else { return nil } | |
| return CIImage(bitmapData: Data(bytes: base, count: pixels.count * 4), | |
| bytesPerRow: side * 16, size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| }) else { return [" FAIL negative target not built"] } | |
| /// Departure from grey, differentiated across x: the noise the stage exists to remove, read | |
| /// where it is seen. The peak blue says how far the stage pushes a channel up the scale. | |
| func read(_ image: CIImage) -> (noise: Float, peakBlue: Float) { | |
| var raw = [Float](repeating: 0, count: side * side * 4) | |
| ctx.render(image, toBitmap: &raw, rowBytes: side * 16, | |
| bounds: CGRect(x: 0, y: 0, width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| let w = lumaWeights | |
| var sum: Float = 0, count = 0, peak: Float = 0 | |
| for y in 0..<side { | |
| for x in 0..<(side - 1) { | |
| func blue(_ k: Int) -> Float { | |
| raw[k * 4 + 2] - (raw[k * 4] * w.x + raw[k * 4 + 1] * w.y + raw[k * 4 + 2] * w.z) | |
| } | |
| let i = y * side + x | |
| let step = blue(i + 1) - blue(i) | |
| sum += step * step | |
| count += 1 | |
| peak = max(peak, raw[i * 4 + 2]) | |
| } | |
| } | |
| return ((sum / Float(count)).squareRoot(), peak) | |
| } | |
| var on = PipelineSettings() | |
| on.chroma = ChromaDenoise(radius: 1.92, force: 1) | |
| var off = PipelineSettings() | |
| off.chroma = .neutral | |
| let shipped = read(apply(target, settings: on)) | |
| let bare = read(apply(target, settings: off)) | |
| // The old order, rebuilt exactly: the stage upstream of the kernel, none inside it. | |
| let former = read(apply(applyChromaDenoise(target, on.chroma, scale: 1), settings: off)) | |
| report(shipped.noise < bare.noise * 0.9, String(format: | |
| "the denoise removes visible chroma noise after the inversion: %.5f against %.5f " | |
| + "with the stage off", shipped.noise, bare.noise)) | |
| // The twin, adverse by construction: it is the previous pipeline, unchanged, measured in | |
| // the space that judges it. Upstream of the logarithm the same dose adds noise. | |
| report(former.noise > bare.noise, String(format: | |
| "and the check discriminates: applied before the kernel, the same dose raises it to " | |
| + "%.5f instead", former.noise)) | |
| report(shipped.peakBlue <= bare.peakBlue && former.peakBlue > shipped.peakBlue, | |
| String(format: "and it drives no blue further up the scale (%.4f against %.4f " | |
| + "before the kernel, %.4f with the stage off)", | |
| shipped.peakBlue, former.peakBlue, bare.peakBlue)) | |
| return lines | |
| } | |
| /// What the resting denoise costs where it is largest, on a full-resolution frame. Measured | |
| /// rather than extrapolated from the preview, whose blur is four times narrower. | |
| private static func chromaCost() -> [String] { | |
| let frame = CGRect(x: 0, y: 0, width: 8368, height: 5584) | |
| guard let device = MTLCreateSystemDefaultDevice(), | |
| let queue = device.makeCommandQueue(), | |
| let noise = CIFilter(name: "CIRandomGenerator")?.outputImage?.cropped(to: frame) | |
| else { return [" ---- no Metal device, the denoise's cost is not measurable"] } | |
| let descriptor = MTLTextureDescriptor() | |
| descriptor.pixelFormat = .rgba16Float | |
| descriptor.width = Int(frame.width) | |
| descriptor.height = Int(frame.height) | |
| descriptor.usage = [.shaderRead, .shaderWrite] | |
| descriptor.storageMode = .private | |
| guard let texture = device.makeTexture(descriptor: descriptor) else { | |
| return [" ---- no destination texture, the denoise's cost is not measurable"] | |
| } | |
| let gpu = CIContext(mtlCommandQueue: queue, | |
| options: [.workingColorSpace: RawDecode.workingSpace]) | |
| func timings(_ settings: PipelineSettings) -> (median: Double, spread: Double) { | |
| let graph = apply(noise, settings: settings) | |
| var times: [Double] = [] | |
| // Two frames go uncounted: the first compiles the graph, which is the price of opening | |
| // the export sheet and not of a rendered file. | |
| for run in 0..<11 { | |
| guard let buffer = queue.makeCommandBuffer() else { return (.infinity, .infinity) } | |
| let destination = CIRenderDestination(width: descriptor.width, | |
| height: descriptor.height, | |
| pixelFormat: descriptor.pixelFormat, | |
| commandBuffer: buffer, | |
| mtlTextureProvider: { texture }) | |
| let start = ProcessInfo.processInfo.systemUptime | |
| guard (try? gpu.startTask(toRender: graph, to: destination)) != nil else { | |
| buffer.commit() | |
| return (.infinity, .infinity) | |
| } | |
| buffer.commit() | |
| buffer.waitUntilCompleted() | |
| if run >= 2 { times.append((ProcessInfo.processInfo.systemUptime - start) * 1000) } | |
| } | |
| times.sort() | |
| return (times[times.count / 2], (times.last ?? 0) - (times.first ?? 0)) | |
| } | |
| let resting = PipelineSettings() | |
| var off = resting | |
| off.chroma = ChromaDenoise(radius: 0, force: 0) | |
| var narrow = resting | |
| narrow.chroma.radius = ChromaDenoise.neutral.radius / 10 | |
| let bare = timings(off) | |
| let (wide, thin) = (timings(resting).median, timings(narrow).median) | |
| return [String(format: " ---- a %.0f × %.0f frame takes %.2f ms with the resting denoise " | |
| + "(%.2f px), %.2f ms at a tenth of that radius (%.2f px) and %.2f ms with " | |
| + "the stage off, over a run-to-run spread of %.2f ms: the blur downsamples, " | |
| + "so on a full frame the width of the dose is lost in the noise", | |
| frame.width, frame.height, wide, | |
| Double(resting.chroma.radius) * frame.width, thin, | |
| Double(narrow.chroma.radius) * frame.width, bare.median, bare.spread)] | |
| } | |
| /// The shutter's guarantee: it truly shows two different images, each on its own side, and | |
| /// values are compared to the source images rather than to fixed constants. | |
| private static func splitViewChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| let rect = CGRect(x: 0, y: 0, width: 100, height: 40) | |
| let after = CIImage(color: CIColor(red: 1, green: 0, blue: 0)).cropped(to: rect) | |
| let before = CIImage(color: CIColor(red: 0, green: 0, blue: 1)).cropped(to: rect) | |
| func sample(_ image: CIImage, _ x: CGFloat) -> [Float] { | |
| var px = [Float](repeating: 0, count: 4) | |
| measureContext.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: x, y: 20, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px | |
| } | |
| func same(_ a: [Float], _ b: [Float]) -> Bool { | |
| zip(a, b).allSatisfy { abs($0 - $1) < 1e-5 } | |
| } | |
| let afterRef = sample(after, 50) | |
| let beforeRef = sample(before, 50) | |
| // Without which everything else would be true for the wrong reasons. | |
| guard !same(afterRef, beforeRef) else { | |
| ok = false | |
| return [" FAIL shutter: the two check images are identical"] | |
| } | |
| let composed = splitView(after: after, before: before, in: rect, at: 0.5) | |
| // The reference on the left, the current state on the right. The direction matters: | |
| // reversed, the shutter would lie about what one is looking at. | |
| let correct = same(sample(composed, 10), beforeRef) | |
| && same(sample(composed, 90), afterRef) | |
| ok = ok && correct | |
| lines.append(" \(correct ? "OK " : "FAIL") shutter: before on the left, after on the right") | |
| // Both halves are opaque either side of the line: a transparent band at the join would show | |
| // up as a black tear across the image. | |
| let opaque = [10, 49, 50, 90].allSatisfy { sample(composed, CGFloat($0))[3] > 0.99 } | |
| ok = ok && opaque | |
| lines.append(String(format: " %@ shutter: no transparent seam (alpha %.2f at the line)", | |
| opaque ? "OK " : "FAIL", sample(composed, 50)[3])) | |
| // The extent covers the whole requested rectangle, no more and no less. | |
| let exact = composed.extent == rect | |
| ok = ok && exact | |
| lines.append(String(format: " %@ shutter: exact extent (%.0f × %.0f)", | |
| exact ? "OK " : "FAIL", composed.extent.width, composed.extent.height)) | |
| // Line at 0: all "after". Line at 1: all "before". Both ends must be clean, otherwise the | |
| // shutter would keep a strip of the other image when pushed all the way. | |
| let allAfter = splitView(after: after, before: before, in: rect, at: 0) | |
| let allBefore = splitView(after: after, before: before, in: rect, at: 1) | |
| let ends = [1, 50, 99].allSatisfy { | |
| same(sample(allAfter, CGFloat($0)), afterRef) && same(sample(allBefore, CGFloat($0)), beforeRef) | |
| } | |
| ok = ok && ends | |
| lines.append(" \(ends ? "OK " : "FAIL") shutter: at the stop, a single image occupies the whole thing") | |
| return lines | |
| } | |
| private static func viewportChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| let full = CGSize(width: 8368, height: 5584) | |
| let view = CGSize(width: 3456, height: 2032) | |
| // Zoomed in, any edge may reach the edge of the view but never beyond: the bound is the | |
| // real overhang, not an approximation that would leave black showing. | |
| var zoomed = Viewport() | |
| zoomed.scale = 1 | |
| zoomed.pan(by: CGSize(width: 1e6, height: 0), full: full, view: view) | |
| let expected = (full.width * 1 - view.width) / 2 | |
| let bounded = abs(zoomed.offset.width - expected) < 1 | |
| ok = ok && bounded | |
| lines.append(String(format: " %@ offset bounded to the real overhang (%.0f, expected %.0f)", | |
| bounded ? "OK " : "FAIL", zoomed.offset.width, expected)) | |
| // In fit mode, the image fits inside the view and no panning must be possible: the canvas | |
| // breathing room is layout padding, never mechanical play. | |
| var fitted = Viewport() | |
| fitted.pan(by: CGSize(width: 1e6, height: 1e6), full: full, view: view) | |
| let locked = abs(fitted.offset.width) < 1 && abs(fitted.offset.height) < 1 | |
| ok = ok && locked | |
| lines.append(String(format: " %@ in fit mode the image cannot escape (%.0f, %.0f)", | |
| locked ? "OK " : "FAIL", fitted.offset.width, fitted.offset.height)) | |
| // No scale may switch to full resolution without the regional cache being able to cover it, | |
| // or panning turns expensive again. | |
| let previewRatio: CGFloat = 2000 / full.width | |
| var deadBand: [Int] = [] | |
| for percent in stride(from: 20, through: 400, by: 5) { | |
| var v = Viewport() | |
| v.scale = CGFloat(percent) / 100 | |
| guard v.needsFullResolution(previewRatio: previewRatio, full: full, view: view) else { | |
| continue | |
| } | |
| let visible = (view.width / v.scale!) * (view.height / v.scale!) | |
| // What the cache will actually have to materialise, margin included. | |
| if min(visible, full.width * full.height) * Viewport.cacheMarginFactor | |
| > Viewport.cacheBudget { | |
| deadBand.append(percent) | |
| } | |
| } | |
| let noDeadBand = deadBand.isEmpty | |
| ok = ok && noDeadBand | |
| lines.append(" \(noDeadBand ? "OK " : "FAIL") no dead band between switch and cache" | |
| + (noDeadBand ? "" : " — scales at fault: \(deadBand.prefix(6))")) | |
| // And pixel-exact zoom must still switch to full resolution — that is where grain is | |
| // judged, and a dead-band fix that forbade it would be a regression. | |
| var pixelExact = Viewport() | |
| pixelExact.scale = 1 | |
| let usesFull = pixelExact.needsFullResolution(previewRatio: previewRatio, | |
| full: full, view: view) | |
| ok = ok && usesFull | |
| lines.append(" \(usesFull ? "OK " : "FAIL") 100% zoom switches to full resolution") | |
| return lines | |
| } | |
| /// The guarantees of stage 0: the frame is in fractions, hence identical whatever the | |
| /// resolution, and the ratio does impose the requested shape. | |
| private static func geometryChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| // Two resolutions of the same frame must give exactly the same proportions — that is what | |
| // guarantees the preview and the export crop the same way. | |
| var geometry = Geometry() | |
| geometry.crop = CGRect(x: 0.1, y: 0.15, width: 0.7, height: 0.6) | |
| let sizes: [CGSize] = [CGSize(width: 2000, height: 1335), CGSize(width: 8368, height: 5584)] | |
| let shapes = sizes.map { size -> CGFloat in | |
| let source = CIImage(color: .white).cropped(to: CGRect(origin: .zero, size: size)) | |
| let out = applyGeometry(source, geometry).extent | |
| return out.width / out.height | |
| } | |
| // Compares proportions only to within 0.01: too coarse to catch a pixel-level framing | |
| // drift, but it does verify that the shape does not flip. | |
| let consistent = abs(shapes[0] - shapes[1]) < 0.01 | |
| ok = ok && consistent | |
| lines.append(String(format: " %@ identical frame in preview and full resolution (%.4f vs %.4f)", | |
| consistent ? "OK " : "FAIL", shapes[0], shapes[1])) | |
| // The strict check: the same rectangle of the photo to within one full-resolution pixel, | |
| // measured in fractions of the source so two scales can be compared directly. | |
| let fullSize = CGSize(width: 8368, height: 5584) | |
| let previewSize = CGSize(width: 2000, height: 1335) | |
| // An adversarial frame, not a round one: a round fraction falls on the grid of both scales | |
| // and reveals nothing, so these fall just above a whole preview pixel instead. | |
| var adverse = Geometry() | |
| adverse.crop = CGRect(x: 0.1000005, y: 0.1500005, width: 0.6999995, height: 0.5999995) | |
| func framed(_ size: CGSize, fullWidth: CGFloat?) -> (CGFloat, CGFloat) { | |
| let src = CIImage(color: .white).cropped(to: CGRect(origin: .zero, size: size)) | |
| let e = applyGeometry(src, adverse, fullWidth: fullWidth).extent | |
| return (e.minX / size.width, e.width / size.width) | |
| } | |
| let atFull = framed(fullSize, fullWidth: nil) | |
| let atPreview = framed(previewSize, fullWidth: fullSize.width) | |
| // One full-resolution pixel, expressed as a fraction. It is the pitch of the grid on which | |
| // both scales now quantise: they cannot differ by more than that. | |
| let tolerance = 1 / fullSize.width | |
| let originGap = abs(atFull.0 - atPreview.0) * fullSize.width | |
| let widthGap = abs(atFull.1 - atPreview.1) * fullSize.width | |
| let aligned = abs(atFull.0 - atPreview.0) <= tolerance | |
| && abs(atFull.1 - atPreview.1) <= tolerance | |
| ok = ok && aligned | |
| lines.append(String(format: | |
| " %@ preview and export frame the same rectangle (gap %.2f px of origin, %.2f px of width)", | |
| aligned ? "OK " : "FAIL", originGap, widthGap)) | |
| // Proves the previous check discriminates: without `fullWidth` the preview quantises on its | |
| // own grid and the gap must reappear. | |
| let naive = framed(previewSize, fullWidth: nil) | |
| let naiveGap = max(abs(atFull.0 - naive.0), abs(atFull.1 - naive.1)) * fullSize.width | |
| let drifts = naiveGap > 1 | |
| ok = ok && drifts | |
| lines.append(String(format: | |
| " %@ and the check discriminates: without fullWidth the gap comes back (%.1f px)", | |
| drifts ? "OK " : "FAIL", naiveGap)) | |
| // A requested ratio must be obtained in pixels, not in fractions: a frame of 0.5 × 0.5 is | |
| // only square on a square image. | |
| var squared = Geometry() | |
| squared.ratio = .square | |
| squared.crop = CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.8) | |
| let imageSize = CGSize(width: 8368, height: 5584) | |
| squared.applyRatio(imageSize: imageSize) | |
| let source = CIImage(color: .white).cropped(to: CGRect(origin: .zero, size: imageSize)) | |
| let out = applyGeometry(source, squared).extent | |
| let isSquare = abs(out.width / out.height - 1) < 0.01 | |
| ok = ok && isSquare | |
| lines.append(String(format: " %@ 1:1 ratio obtained in pixels (%.0f × %.0f)", | |
| isSquare ? "OK " : "FAIL", out.width, out.height)) | |
| // A test of position, not just shape: a frame stuck in the bottom-left corner must land in | |
| // the bottom-right after one counter-clockwise quarter turn. | |
| var tracked = Geometry() | |
| tracked.crop = CGRect(x: 0, y: 0, width: 0.3, height: 0.2) | |
| tracked.rotate(quarters: 1, imageSize: imageSize) | |
| let landedBottomRight = abs(tracked.crop.maxX - 1) < 1e-9 && abs(tracked.crop.minY) < 1e-9 | |
| ok = ok && landedBottomRight | |
| lines.append(String(format: " %@ counter-clockwise quarter: bottom-left corner → bottom-right (x %.3f…%.3f, y %.3f)", | |
| landedBottomRight ? "OK " : "FAIL", | |
| tracked.crop.minX, tracked.crop.maxX, tracked.crop.minY)) | |
| // Composability: three individual quarter turns must give exactly the same frame as one | |
| // direct call with three quarters. | |
| var stepwise = Geometry() | |
| stepwise.crop = CGRect(x: 0.1, y: 0.2, width: 0.6, height: 0.3) | |
| var direct = stepwise | |
| for _ in 0..<3 { stepwise.rotate(quarters: 1, imageSize: imageSize) } | |
| direct.rotate(quarters: 3, imageSize: imageSize) | |
| let composes = abs(stepwise.crop.minX - direct.crop.minX) < 1e-9 | |
| && abs(stepwise.crop.minY - direct.crop.minY) < 1e-9 | |
| && stepwise.quarterTurns == direct.quarterTurns | |
| ok = ok && composes | |
| lines.append(" \(composes ? "OK " : "FAIL") three quarter-turns step by step = one direct call with three quarters") | |
| // And the two directions must cancel out. | |
| var reversible = Geometry() | |
| reversible.crop = CGRect(x: 0.15, y: 0.25, width: 0.5, height: 0.35) | |
| let start = reversible.crop | |
| reversible.rotate(quarters: 1, imageSize: imageSize) | |
| reversible.rotate(quarters: -1, imageSize: imageSize) | |
| let cancels = abs(reversible.crop.minX - start.minX) < 1e-9 | |
| && abs(reversible.crop.minY - start.minY) < 1e-9 | |
| && reversible.quarterTurns == 0 | |
| ok = ok && cancels | |
| lines.append(" \(cancels ? "OK " : "FAIL") one quarter turn right then left returns to identity") | |
| // Portrait mode must really invert the resulting shape. | |
| var landscape = Geometry() | |
| landscape.ratio = .threeTwo | |
| landscape.crop = CGRect(x: 0, y: 0, width: 1, height: 1) | |
| landscape.applyRatio(imageSize: imageSize) | |
| var upright = landscape | |
| upright.portrait = true | |
| upright.applyRatio(imageSize: imageSize) | |
| let landscapeShape = (landscape.crop.width * imageSize.width) | |
| / (landscape.crop.height * imageSize.height) | |
| let portraitShape = (upright.crop.width * imageSize.width) | |
| / (upright.crop.height * imageSize.height) | |
| let flips = abs(landscapeShape - 1.5) < 0.02 && abs(portraitShape - 1 / 1.5) < 0.02 | |
| ok = ok && flips | |
| lines.append(String(format: " %@ portrait flips the ratio (%.3f → %.3f)", | |
| flips ? "OK " : "FAIL", landscapeShape, portraitShape)) | |
| // The ratio must stay correct after a quarter turn: the frame lives in the rotated image, | |
| // so the dimensions used for it must be swapped too. | |
| var turnedRatio = Geometry() | |
| turnedRatio.quarterTurns = 1 | |
| turnedRatio.ratio = .threeTwo | |
| turnedRatio.crop = CGRect(x: 0, y: 0, width: 1, height: 1) | |
| turnedRatio.applyRatio(imageSize: imageSize) | |
| let oriented = turnedRatio.orientedSize(imageSize) | |
| let turnedShape = (turnedRatio.crop.width * oriented.width) | |
| / (turnedRatio.crop.height * oriented.height) | |
| let ratioHolds = abs(turnedShape - 1.5) < 0.02 | |
| ok = ok && ratioHolds | |
| lines.append(String(format: " %@ 3:2 ratio held after a quarter turn (%.3f)", | |
| ratioHolds ? "OK " : "FAIL", turnedShape)) | |
| // Rotating with a locked ratio reapplies the ratio immediately, so the resulting frame | |
| // already matches what touching a handle would produce. | |
| var turned = Geometry() | |
| turned.ratio = .threeTwo | |
| turned.applyRatio(imageSize: imageSize) | |
| turned.rotate(quarters: 1, imageSize: imageSize) | |
| let afterTurn = turned.crop | |
| var touched = turned | |
| touched.applyRatio(imageSize: imageSize) // what the first handle grabbed does | |
| let stable = abs(touched.crop.width - afterTurn.width) < 1e-9 | |
| && abs(touched.crop.height - afterTurn.height) < 1e-9 | |
| let turnedSize = turned.orientedSize(imageSize) | |
| let turnedRatioShape = (afterTurn.width * turnedSize.width) | |
| / (afterTurn.height * turnedSize.height) | |
| let reapplied = stable && abs(turnedRatioShape - 1.5) < 0.02 | |
| ok = ok && reapplied | |
| lines.append(String(format: " %@ rotation reapplies the ratio (%.3f) and the frame no longer jumps on the first gesture", | |
| reapplied ? "OK " : "FAIL", turnedRatioShape)) | |
| // The anchor: resizing while holding a corner must not move that corner. | |
| var anchored = Geometry() | |
| anchored.ratio = .threeTwo | |
| anchored.crop = CGRect(x: 0.1, y: 0.1, width: 0.5, height: 0.5) | |
| // The bottom-left corner is the one held. | |
| let held = CGPoint(x: anchored.crop.minX, y: anchored.crop.minY) | |
| anchored.applyRatio(imageSize: imageSize, anchor: held) | |
| let heldStill = abs(anchored.crop.minX - held.x) < 1e-6 | |
| && abs(anchored.crop.minY - held.y) < 1e-6 | |
| ok = ok && heldStill | |
| lines.append(String(format: " %@ the held corner does not move when the ratio applies (%.4f, %.4f)", | |
| heldStill ? "OK " : "FAIL", anchored.crop.minX, anchored.crop.minY)) | |
| // And with no anchor, the ratio recentres on the frame's centre. | |
| var centred = Geometry() | |
| centred.ratio = .threeTwo | |
| centred.crop = CGRect(x: 0.1, y: 0.1, width: 0.5, height: 0.5) | |
| let middle = CGPoint(x: centred.crop.midX, y: centred.crop.midY) | |
| centred.applyRatio(imageSize: imageSize) | |
| let stillCentred = abs(centred.crop.midX - middle.x) < 1e-6 | |
| ok = ok && stillCentred | |
| lines.append(" \(stillCentred ? "OK " : "FAIL") with no anchor, the ratio recentres as before") | |
| // Locked ratio: a top or bottom edge handle must change something — without a driving axis, | |
| // `applyRatio` always derived height from width and four of eight handles went dead. | |
| var edgeDriven = Geometry() | |
| edgeDriven.ratio = .square | |
| edgeDriven.crop = CGRect(x: 0.3, y: 0.2, width: 0.4, height: 0.5995) | |
| let beforeEdge = edgeDriven.crop.height | |
| edgeDriven.crop.size.height = 0.3995 // the top handle comes back down | |
| edgeDriven.applyRatio(imageSize: imageSize, | |
| anchor: CGPoint(x: 0.5, y: 0.2), driving: .height) | |
| let edgeMoved = abs(edgeDriven.crop.height - beforeEdge) > 0.01 | |
| let edgeShape = (edgeDriven.crop.width * imageSize.width) | |
| / (edgeDriven.crop.height * imageSize.height) | |
| let edgeHolds = edgeMoved && abs(edgeShape - 1) < 0.02 | |
| ok = ok && edgeHolds | |
| lines.append(String(format: " %@ edge handle driven by height: %.4f → %.4f, square held (%.3f)", | |
| edgeHolds ? "OK " : "FAIL", beforeEdge, | |
| edgeDriven.crop.height, edgeShape)) | |
| // The floor survives `applyRatio`: it was `applyRatio` that slipped under it, not the UI. | |
| var floored = Geometry() | |
| floored.ratio = .sixSeven | |
| floored.crop = CGRect(x: 0, y: 0.95, width: 1, height: Geometry.minCrop) | |
| floored.applyRatio(imageSize: imageSize) | |
| let aboveFloor = floored.crop.width >= Geometry.minCrop - 1e-9 | |
| && floored.crop.height >= Geometry.minCrop - 1e-9 | |
| ok = ok && aboveFloor | |
| lines.append(String(format: " %@ floor held when a ratio crushes a flat frame (%.4f × %.4f)", | |
| aboveFloor ? "OK " : "FAIL", floored.crop.width, floored.crop.height)) | |
| // The clamping catches any frame at all, including a negative coordinate that export and | |
| // display would otherwise read differently. | |
| var wild = Geometry() | |
| wild.crop = CGRect(x: -0.3, y: 0.9, width: 0.01, height: 2) | |
| wild.normaliseCrop() | |
| let tamed = wild.crop.minX >= -1e-9 && wild.crop.minY >= -1e-9 | |
| && wild.crop.maxX <= 1 + 1e-9 && wild.crop.maxY <= 1 + 1e-9 | |
| && wild.crop.width >= Geometry.minCrop - 1e-9 | |
| && wild.crop.height >= Geometry.minCrop - 1e-9 | |
| ok = ok && tamed | |
| lines.append(String(format: " %@ degenerate frame brought back inside the image (%.3f, %.3f, %.3f × %.3f)", | |
| tamed ? "OK " : "FAIL", wild.crop.minX, wild.crop.minY, | |
| wild.crop.width, wild.crop.height)) | |
| // The frame must never contain any void, measured on the alpha of its four corners — a | |
| // dimension check would miss a photo with corners sliced off or thrown away. | |
| func worstCornerAlpha(_ g: Geometry) -> Float { | |
| let out = applyGeometry(source, g) | |
| let box = out.extent | |
| let inset: CGFloat = 1 | |
| let points = [ | |
| CGPoint(x: box.minX + inset, y: box.minY + inset), | |
| CGPoint(x: box.maxX - inset - 1, y: box.minY + inset), | |
| CGPoint(x: box.minX + inset, y: box.maxY - inset - 1), | |
| CGPoint(x: box.maxX - inset - 1, y: box.maxY - inset - 1), | |
| ] | |
| return points.map { at -> Float in | |
| var px = [Float](repeating: 0, count: 4) | |
| measureContext.render(out, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: at.x, y: at.y, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[3] | |
| }.min() ?? 0 | |
| } | |
| // A full frame at 5°, constrained: its corners must carry image. | |
| var tilted = Geometry() | |
| tilted.angle = 5 | |
| tilted.fitCropInsideImage(imageSize) | |
| let tiltedAlpha = worstCornerAlpha(tilted) | |
| let noVoid = tiltedAlpha > 0.99 | |
| ok = ok && noVoid | |
| lines.append(String(format: " %@ full frame constrained at 5°: no void inside (alpha min %.4f)", | |
| noVoid ? "OK " : "FAIL", tiltedAlpha)) | |
| // Without the constraint the same frame does leak void, proving the check above measures | |
| // something real. | |
| var unconstrained = Geometry() | |
| unconstrained.angle = 5 | |
| let leaks = worstCornerAlpha(unconstrained) < 0.5 | |
| ok = ok && leaks | |
| lines.append(String(format: " %@ the check discriminates: without the constraint the full frame leaks (alpha %.4f)", | |
| leaks ? "OK " : "FAIL", worstCornerAlpha(unconstrained))) | |
| // The image, for its part, is **not** boxed in: its extent is the bounding box, larger than | |
| // the upright box. That is what makes it possible to crop into the crooked corners. | |
| let visible = applyGeometry(source, Geometry(angle: 5).withoutCrop).extent | |
| let expectedBox = Geometry(angle: 5).rotatedBoundingSize(imageSize) | |
| let notBoxed = visible.width > imageSize.width && visible.height > imageSize.height | |
| && abs(visible.width - expectedBox.width) <= 2 | |
| && abs(visible.height - expectedBox.height) <= 2 | |
| ok = ok && notBoxed | |
| lines.append(String(format: " %@ image not boxed in: extent %.0f × %.0f > %.0f × %.0f", | |
| notBoxed ? "OK " : "FAIL", visible.width, visible.height, | |
| imageSize.width, imageSize.height)) | |
| // Cross-check of the two derivations: on a centred full frame, the general constraint must | |
| // land exactly on the closed form of the inscribed rectangle. | |
| let closedForm = Geometry.straightenScale(angle: 5, in: imageSize) | |
| let agree = abs(tilted.crop.width - closedForm) < 1e-9 | |
| && abs(tilted.crop.height - closedForm) < 1e-9 | |
| ok = ok && agree | |
| lines.append(String(format: " %@ constraint and closed form agree (%.9f vs %.9f)", | |
| agree ? "OK " : "FAIL", tilted.crop.width, closedForm)) | |
| // The square at 45° gives 1/√2: the only value verifiable by hand, hence the one that | |
| // protects the trigonometry from a swapped cos/sin. | |
| let square = Geometry.straightenScale(angle: 45, in: CGSize(width: 100, height: 100)) | |
| let exact = abs(square - 1 / 2.0.squareRoot()) < 1e-9 | |
| ok = ok && exact | |
| lines.append(String(format: " %@ inscribed factor of a square at 45° = 1/√2 (%.9f)", | |
| exact ? "OK " : "FAIL", square)) | |
| // Pushing the frame against a crooked edge **blocks** it without shrinking it: the gesture | |
| // must stay reversible, otherwise the frame would melt away at every stop. | |
| var pushed = Geometry() | |
| pushed.angle = 5 | |
| pushed.crop = CGRect(x: 0.2, y: 0.2, width: 0.5, height: 0.5) | |
| pushed.fitCropInsideImage(imageSize) | |
| let sizeBefore = pushed.crop.size | |
| pushed.crop.origin = CGPoint(x: 0.5, y: 0.5) // pushed towards the corner | |
| pushed.fitCropInsideImage(imageSize) | |
| let keptSize = abs(pushed.crop.width - sizeBefore.width) < 1e-9 | |
| && abs(pushed.crop.height - sizeBefore.height) < 1e-9 | |
| ok = ok && keptSize | |
| lines.append(String(format: " %@ a stop moves the frame without shrinking it (%.4f × %.4f)", | |
| keptSize ? "OK " : "FAIL", pushed.crop.width, pushed.crop.height)) | |
| // One straightening gesture fits from its own starting frame, so a sweep out and back | |
| // restores it; refit per event on the live crop, every intermediate shrink compounds. | |
| let base = Geometry() | |
| var swept = base | |
| var ratcheted = base | |
| for step in 0...200 { | |
| let angle = Float(step <= 100 ? step : 200 - step) * 0.08 | |
| swept = base.straightened(to: angle, imageSize: imageSize) | |
| ratcheted.angle = angle | |
| ratcheted.fitCropInsideImage(imageSize) | |
| } | |
| let restored = abs(swept.crop.width - 1) < 1e-9 && abs(swept.crop.height - 1) < 1e-9 | |
| ok = ok && restored | |
| lines.append(String(format: " %@ a straightening sweep out to 8° and back restores the " | |
| + "frame it set out from (%.6f × %.6f)", | |
| restored ? "OK " : "FAIL", swept.crop.width, swept.crop.height)) | |
| let lost = ratcheted.crop.width | |
| ok = ok && lost < 0.9 | |
| lines.append(String(format: " %@ and the check discriminates: refit per event on the live " | |
| + "crop, the same sweep keeps %.4f of the side at an angle back to zero", | |
| lost < 0.9 ? "OK " : "FAIL", lost)) | |
| // Held at one angle the fit is exactly idempotent: the ratchet came from the sweep, never | |
| // from the fit's own arithmetic. | |
| var fixed = base.straightened(to: 5, imageSize: imageSize) | |
| let once = fixed.crop | |
| for _ in 0..<200 { fixed.fitCropInsideImage(imageSize) } | |
| let idempotent = abs(fixed.crop.minX - once.minX) < 1e-12 | |
| && abs(fixed.crop.minY - once.minY) < 1e-12 | |
| && abs(fixed.crop.width - once.width) < 1e-12 | |
| && abs(fixed.crop.height - once.height) < 1e-12 | |
| ok = ok && idempotent | |
| lines.append(String(format: " %@ re-fitting at a held angle changes nothing over two " | |
| + "hundred passes (worst gap %.2e)", | |
| idempotent ? "OK " : "FAIL", | |
| max(abs(fixed.crop.width - once.width), | |
| abs(fixed.crop.minX - once.minX)))) | |
| // The binding must fit through the gesture law: a refit written back per event is the | |
| // retired shape, and only a read of the pane's own text can see it return. | |
| if let text = SourceFile.text("Sources/OpenNegative/UI/GeometryPanel.swift") { | |
| let block = text.components(separatedBy: "private var angleBinding").last? | |
| .components(separatedBy: "private var").first ?? "" | |
| let lawful = block.contains("straightened(") && !block.contains("fitCropInsideImage") | |
| ok = ok && lawful | |
| lines.append(" \(lawful ? "OK " : "FAIL") the angle binding fits through the " | |
| + "gesture law, never per event on the live crop") | |
| let elsewhere = text.contains("fitCropInsideImage") | |
| ok = ok && elsewhere | |
| lines.append(" \(elsewhere ? "OK " : "FAIL") and the check discriminates: the " | |
| + "per-event spelling still exists in the pane's other bindings, so its " | |
| + "absence above is a choice and not a rename") | |
| } | |
| return lines | |
| } | |
| /// A hard step across the LONG side, carrying a colour step on the same line: both | |
| /// neighbourhood stages bite on it, and turning the target turns its edge with it. | |
| static func edgeTarget(width: Int, height: Int) -> CIImage? { | |
| var pixels = [Float](repeating: 0, count: width * height * 4) | |
| let long = max(width, height) | |
| for y in 0..<height { | |
| for x in 0..<width { | |
| let i = (y * width + x) * 4 | |
| let past = (width >= height ? x : y) >= long / 2 | |
| let edge: Float = past ? 0.7 : 0.25 | |
| pixels[i] = edge * 1.15 | |
| pixels[i + 1] = edge | |
| pixels[i + 2] = edge * (past ? 1.3 : 0.6) | |
| pixels[i + 3] = 1 | |
| } | |
| } | |
| return pixels.withUnsafeBufferPointer { buffer in | |
| buffer.baseAddress.flatMap { | |
| CIImage(bitmapData: Data(bytes: $0, count: pixels.count * 4), | |
| bytesPerRow: width * 16, | |
| size: CGSize(width: width, height: height), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| } | |
| } | |
| /// The pixels of a render, straight out and through no colour conversion. | |
| static func samples(_ ctx: CIContext, _ image: CIImage, width: Int, height: Int) -> [Float] { | |
| var raw = [Float](repeating: 0, count: width * height * 4) | |
| ctx.render(image, toBitmap: &raw, rowBytes: width * 16, | |
| bounds: CGRect(x: 0, y: 0, width: width, height: height), | |
| format: .RGBAf, colorSpace: nil) | |
| return raw | |
| } | |
| /// Reads several aligned renders band by band, handing each band to `body`. A full-resolution | |
| /// RGBAf frame is 750 MB, so a measurement holding one whole would trade a check for a swap. | |
| static func bands(_ ctx: CIContext, _ images: [CIImage], over extent: CGRect, | |
| colorSpace: CGColorSpace?, budget: Int = 96_000_000, | |
| _ body: (_ read: [[Float]], _ width: Int, _ rows: Int) -> Void) { | |
| let width = Int(extent.width), total = Int(extent.height) | |
| guard width > 0, total > 0 else { return } | |
| let step = max(1, budget / (width * 16)) | |
| var y = 0 | |
| while y < total { | |
| let rows = min(step, total - y) | |
| let read = images.map { image -> [Float] in | |
| var buffer = [Float](repeating: 0, count: width * rows * 4) | |
| buffer.withUnsafeMutableBytes { raw in | |
| ctx.render(image, toBitmap: raw.baseAddress!, rowBytes: width * 16, | |
| bounds: CGRect(x: extent.minX, y: extent.minY + CGFloat(y), | |
| width: CGFloat(width), height: CGFloat(rows)), | |
| format: .RGBAf, colorSpace: colorSpace) | |
| } | |
| return buffer | |
| } | |
| body(read, width, rows) | |
| y += rows | |
| } | |
| } | |
| /// `blurFloor` is what both neighbourhood guards compare against, so it has to be the LARGEST | |
| /// inert radius: taken for the smallest live one, each guard is wrong by one notch. | |
| private static func blurFloorChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| guard let source = edgeTarget(width: 64, height: 64) else { | |
| return [" FAIL blur floor: check image not built"] | |
| } | |
| let base = samples(ctx, source, width: 64, height: 64) | |
| func gap(_ radius: Double) -> Float { | |
| let blurred = source.clampedToExtent() | |
| .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: radius]) | |
| .cropped(to: source.extent) | |
| return zip(base, samples(ctx, blurred, width: 64, height: 64)) | |
| .map { abs($0 - $1) }.max() ?? .infinity | |
| } | |
| let atFloor = gap(blurFloor) | |
| let inert = atFloor == 0 | |
| ok = ok && inert | |
| lines.append(String(format: " %@ at `blurFloor` (%.4f) the blur hands its input back bit " | |
| + "for bit, so a guard that skips it skips nothing (worst gap %.2e)", | |
| inert ? "OK " : "FAIL", blurFloor, atFloor)) | |
| // Adverse by construction: a Gaussian's support grows with its radius, so inert at r means | |
| // inert below r. Bisection therefore brackets the wake-up point instead of sampling for it. | |
| var asleep = blurFloor, awake = 1.0 | |
| for _ in 0..<40 { | |
| let mid = (asleep + awake) / 2 | |
| if gap(mid) > 0 { awake = mid } else { asleep = mid } | |
| } | |
| let above = awake > blurFloor | |
| ok = ok && above | |
| lines.append(String(format: " %@ and the check discriminates: the blur wakes up at %.6f, " | |
| + "ABOVE the floor by %.2e — the floor is the largest INERT radius, " | |
| + "not the smallest live one", | |
| above ? "OK " : "FAIL", awake, awake - blurFloor)) | |
| return lines | |
| } | |
| /// The neighbourhood radii index on the decoded source's LARGEST side, so a frame scanned across | |
| /// is dosed like the same frame scanned along. Measured through `apply`, that line's only caller. | |
| private static func radiusReferenceChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| // 3:1, so the width and the largest side differ by a factor three whatever the pixels hold — | |
| // which is what makes the twin below adverse by construction rather than by measurement. | |
| let (long, short) = (600, 200) | |
| guard let landscape = edgeTarget(width: long, height: short), | |
| let portrait = edgeTarget(width: short, height: long) else { | |
| return [" FAIL radius reference: check images not built"] | |
| } | |
| let w = lumaWeights | |
| /// Read along the long side in both orientations, so the two profiles carry the same | |
| /// neighbourhoods in the same order and only the radius can separate them. | |
| func profile(_ image: CIImage, width: Int, height: Int) -> (luma: [Float], chroma: [Float]) { | |
| let raw = samples(ctx, image, width: width, height: height) | |
| var luma: [Float] = [], chroma: [Float] = [] | |
| for k in 0..<max(width, height) { | |
| let i = (width >= height ? (height / 2) * width + k : k * width + width / 2) * 4 | |
| let y = raw[i] * w.x + raw[i + 1] * w.y + raw[i + 2] * w.z | |
| luma.append(y) | |
| chroma.append(raw[i + 2] - y) | |
| } | |
| return (luma, chroma) | |
| } | |
| func worst(_ a: [Float], _ b: [Float]) -> Float { | |
| guard a.count == b.count, !a.isEmpty else { return .infinity } | |
| return zip(a, b).map { abs($0 - $1) }.max() ?? .infinity | |
| } | |
| // One stage at a time: a profile carrying both could have one of them agreeing for the wrong | |
| // reason. The white point opens the inverted output mid-range, where a halo has room. | |
| var opened = PipelineSettings() | |
| opened.levels.linked = Levels(black: 0, white: 0.2, mid: 0.5) | |
| var sharp = opened | |
| sharp.chroma = ChromaDenoise(radius: 0, force: 0) | |
| sharp.sharpen = Sharpen(radius: 1.2, amount: 1) | |
| var soft = opened | |
| soft.chroma = ChromaDenoise(radius: 1.2, force: 1) | |
| soft.sharpen = Sharpen(radius: 0, amount: 0) | |
| // The reverted line reproduced exactly: on a 3:1 frame the width IS a third of the largest | |
| // side, so dividing the setting by three is the same arithmetic reaching the same kernel. | |
| var thirdSharp = sharp | |
| thirdSharp.sharpen.radius /= 3 | |
| var thirdSoft = soft | |
| thirdSoft.chroma.radius /= 3 | |
| func turned(_ settings: PipelineSettings, _ other: PipelineSettings, | |
| _ plane: ((luma: [Float], chroma: [Float])) -> [Float]) -> Float { | |
| worst(plane(profile(apply(landscape, settings: settings), width: long, height: short)), | |
| plane(profile(apply(portrait, settings: other), width: short, height: long))) | |
| } | |
| let luma: ((luma: [Float], chroma: [Float])) -> [Float] = { $0.luma } | |
| let chroma: ((luma: [Float], chroma: [Float])) -> [Float] = { $0.chroma } | |
| // The floor the two rows below are read against: with both neighbourhood stages off every | |
| // remaining stage is per pixel, so a turn moves nothing at all and any residual is the blur's. | |
| var idle = opened | |
| idle.chroma = ChromaDenoise(radius: 0, force: 0) | |
| idle.sharpen = Sharpen(radius: 0, amount: 0) | |
| let bare = max(turned(idle, idle, luma), turned(idle, idle, chroma)) | |
| ok = ok && bare == 0 | |
| lines.append(String(format: " %@ with both neighbourhood stages off, turning the frame " | |
| + "moves nothing at all (worst gap %.2e): what follows measures the " | |
| + "radius and nothing else", bare == 0 ? "OK " : "FAIL", bare)) | |
| for (label, settings, thirded, plane) in [ | |
| ("sharpening", sharp, thirdSharp, luma), | |
| ("chroma denoise", soft, thirdSoft, chroma), | |
| ] { | |
| let kept = turned(settings, settings, plane) | |
| let reverted = turned(settings, thirded, plane) | |
| // Agreement is stated against the reverted line, not against a chosen epsilon: the | |
| // residual left is `CIGaussianBlur` reordering its tiles under a turn. | |
| let same = kept * 100 < reverted | |
| ok = ok && same | |
| lines.append(String(format: " %@ %@: through `apply`, a 600×200 frame and the same " | |
| + "frame scanned across agree to %.2e, %.0f× under what the width " | |
| + "as reference produces", same ? "OK " : "FAIL", label, kept, | |
| Double(reverted / max(kept, .leastNormalMagnitude)))) | |
| let apart = reverted > 0.01 | |
| ok = ok && apart | |
| lines.append(String(format: " %@ and the check discriminates: indexed on the WIDTH — a " | |
| + "third of the largest side on a 3:1 frame — the two land %.4f " | |
| + "apart, a full percent of range and then some", | |
| apart ? "OK " : "FAIL", reverted)) | |
| } | |
| // The other half of the law: a radius counted in pixels of the export has to shrink with a | |
| // reduced copy, which is the only thing `fullWidth` carries into these two stages. | |
| for (label, settings, plane) in [("sharpening", sharp, luma), | |
| ("chroma denoise", soft, chroma)] { | |
| var halved = settings | |
| halved.sharpen.radius /= 2 | |
| halved.chroma.radius /= 2 | |
| // Declaring twice the width makes this frame a half-size copy, which must dose exactly | |
| // as the same frame at full size carrying half the radius. | |
| let asCopy = plane(profile(apply(landscape, settings: settings, | |
| fullWidth: CGFloat(long * 2)), | |
| width: long, height: short)) | |
| let atFull = plane(profile(apply(landscape, settings: halved), width: long, height: short)) | |
| let follows = worst(asCopy, atFull) | |
| let unscaled = plane(profile(apply(landscape, settings: settings), | |
| width: long, height: short)) | |
| let holds = follows * 100 < worst(asCopy, unscaled) | |
| ok = ok && holds | |
| lines.append(String(format: " %@ %@: a copy reduced by two takes half the radius, " | |
| + "agreeing %@ with the same frame dosed at half, against %.4f " | |
| + "when the scale is left at 1", holds ? "OK " : "FAIL", label, | |
| follows == 0 ? "bit for bit" | |
| : String(format: "to %.2e", follows), | |
| worst(asCopy, unscaled))) | |
| // Adverse by construction: the two radii differ by two whatever the pixels hold, so a | |
| // scale silently left at 1 cannot land on the reading above. | |
| let separates = worst(asCopy, unscaled) > 0.01 | |
| ok = ok && separates | |
| lines.append(String(format: " %@ and the check discriminates: omitting `fullWidth` " | |
| + "leaves the scale at 1 and the two land %.4f apart", | |
| separates ? "OK " : "FAIL", worst(asCopy, unscaled))) | |
| } | |
| return lines | |
| } | |
| /// The two guarantees of the saturation by zone: the weights cover the whole spectrum, and the | |
| /// luminance does not move. | |
| private static func saturationChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| guard saturationKernel != nil else { | |
| return [" FAIL saturation kernel not found"] | |
| } | |
| /// Applies the saturation alone to a colour, outside the rest of the pipeline. | |
| func run(_ rgb: (Float, Float, Float), _ sat: ZoneSaturation) -> (Float, Float, Float) { | |
| let src = CIImage(color: CIColor(red: CGFloat(rgb.0), green: CGFloat(rgb.1), | |
| blue: CGFloat(rgb.2))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(applyZoneSaturation(src, sat), toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return (px[0], px[1], px[2]) | |
| } | |
| let w = lumaWeights | |
| func luma(_ c: (Float, Float, Float)) -> Float { c.0 * w.x + c.1 * w.y + c.2 * w.z } | |
| /// Distance from grey: what the saturation must make vary. | |
| func spread(_ c: (Float, Float, Float)) -> Float { | |
| let y = luma(c) | |
| return max(abs(c.0 - y), abs(c.1 - y), abs(c.2 - y)) | |
| } | |
| // Two clearly saturated colours, at the two ends of the spectrum. | |
| let dark: (Float, Float, Float) = (0.05, 0.018, 0.012) | |
| let light: (Float, Float, Float) = (0.95, 0.80, 0.68) | |
| // Verifies not an absolute value — the split point is a rendering decision — but that each | |
| // slider acts far more on its own zone than the other. | |
| func selectivity(_ setting: ZoneSaturation, | |
| target: (Float, Float, Float), | |
| other: (Float, Float, Float)) -> (onTarget: Float, onOther: Float) { | |
| (1 - spread(run(target, setting)) / max(spread(target), 1e-6), | |
| 1 - spread(run(other, setting)) / max(spread(other), 1e-6)) | |
| } | |
| let onShadows = selectivity(ZoneSaturation(shadows: -1, highlights: 0), | |
| target: dark, other: light) | |
| let shadowsOK = onShadows.onTarget > 3 * max(onShadows.onOther, 0.01) | |
| ok = ok && shadowsOK | |
| lines.append(String(format: " %@ shadows slider: %.0f %% of effect on a dark pixel against %.0f %% on a light one", | |
| shadowsOK ? "OK " : "FAIL", | |
| onShadows.onTarget * 100, onShadows.onOther * 100)) | |
| let onHighlights = selectivity(ZoneSaturation(shadows: 0, highlights: -1), | |
| target: light, other: dark) | |
| let highlightsOK = onHighlights.onTarget > 3 * max(onHighlights.onOther, 0.01) | |
| ok = ok && highlightsOK | |
| lines.append(String(format: " %@ highlights slider: %.0f %% of effect on a light pixel against %.0f %% on a dark one", | |
| highlightsOK ? "OK " : "FAIL", | |
| onHighlights.onTarget * 100, onHighlights.onOther * 100)) | |
| // The two weights sum to 1: desaturating both zones all the way must empty everything, | |
| // whatever the luminance. A zone left out would show up here. | |
| let killBoth = ZoneSaturation(shadows: -1, highlights: -1) | |
| let worstResidual = [dark, light, (0.5, 0.4, 0.3), (0.02, 0.008, 0.005), (0.98, 0.93, 0.88)] | |
| .map { spread(run($0, killBoth)) } | |
| .max() ?? 0 | |
| let covers = worstResidual < 1e-5 | |
| ok = ok && covers | |
| lines.append(String(format: " %@ the two zones cover the whole spectrum (worst residual %.2e)", | |
| covers ? "OK " : "FAIL", worstResidual)) | |
| // The luminance does not move, whether saturating or desaturating. | |
| let worstLumaShift = [ZoneSaturation(shadows: -1, highlights: 1), | |
| ZoneSaturation(shadows: 1, highlights: -1), | |
| ZoneSaturation(shadows: 1, highlights: 1)] | |
| .flatMap { sat in [dark, light].map { abs(luma(run($0, sat)) - luma($0)) } } | |
| .max() ?? 0 | |
| let lumaKept = worstLumaShift < 1e-6 | |
| ok = ok && lumaKept | |
| lines.append(String(format: " %@ luminance preserved by the saturation (worst gap %.2e)", | |
| lumaKept ? "OK " : "FAIL", worstLumaShift)) | |
| return lines | |
| } | |
| /// What justifies stage 8 existing separately from stage 4: the same gain applied upstream or | |
| /// downstream must give different results once a tonal shaping separates them. | |
| private static func positiveGainChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| func run(_ settings: PipelineSettings) -> Float { | |
| let src = CIImage(color: CIColor(red: 0.05, green: 0.05, blue: 0.05)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: settings.perPixelOnly()), toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var lines: [String] = [] | |
| /// The finishing balance with one zone pushed all the way, on every channel. | |
| func balanced(_ zone: ToneBalance.Zone, _ amount: Float) -> PipelineSettings { | |
| var settings = PipelineSettings() | |
| settings.toneBalance[zone].colour = SIMD3<Float>(repeating: amount) | |
| return settings | |
| } | |
| // The kernel and the Swift replica, set against each other through the GPU: the degree-4 | |
| // Bézier evaluation is duplicated in Metal and Swift, and nothing else keeps them in agreement. | |
| let plain = run(.neutral) | |
| var worstGap: Float = 0 | |
| for zone in ToneBalance.Zone.allCases { | |
| for amount in [Float(-1), 1] { | |
| let measured = run(balanced(zone, amount)) | |
| var replica = ToneBalance() | |
| replica[zone].colour = SIMD3<Float>(repeating: amount) | |
| worstGap = max(worstGap, abs(measured - clip(replica.applied(plain)))) | |
| } | |
| } | |
| let agrees = worstGap < 1e-4 | |
| ok = ok && agrees | |
| lines.append(String(format: " %@ stage 8: the kernel and the Swift replica agree on " | |
| + "the 6 extremes (max gap %.2e)", agrees ? "OK " : "FAIL", worstGap)) | |
| // The twin: the stage must really act on this input, or the check above would compare the | |
| // identity with itself. | |
| let lifted = run(balanced(.shadows, 1)) | |
| ok = ok && lifted > plain + 0.02 | |
| lines.append(String(format: " %@ and the check discriminates: shadows pushed all the way " | |
| + "do move the output (%.5f → %.5f)", | |
| lifted > plain + 0.02 ? "OK " : "FAIL", plain, lifted)) | |
| // The stage's promise: both ends do not move, measured through the GPU with levels pushed | |
| // to 0 and 1 since the signal alone reaches neither. | |
| var atWhite = balanced(.midtones, 1) | |
| atWhite.levels.linked = Levels(black: 0, white: 0.1, mid: 0.5) | |
| var atBlack = balanced(.midtones, 1) | |
| atBlack.levels.linked = Levels(black: 0.9, white: 1, mid: 0.5) | |
| let endsHeld = abs(run(atWhite) - 1) < 1e-4 && abs(run(atBlack)) < 1e-4 | |
| ok = ok && endsHeld | |
| lines.append(String(format: " %@ stage 8: the ends do not move, through the GPU " | |
| + "(white %.5f, black %.5f)", endsHeld ? "OK " : "FAIL", | |
| run(atWhite), run(atBlack))) | |
| // The direction each stage's slider announces: a gain lowers the output in negative mode | |
| // and raises it in finishing, since the density inversion reverses the sense. | |
| let pushedNegative = run(PipelineSettings(gains: Gains(stops: SIMD3(1, 0, 0)))) | |
| var finish = PipelineSettings() | |
| finish.toneBalance.shadows.colour = SIMD3(1, 0, 0) | |
| let pushedFinish = run(finish) | |
| let sensesOK = pushedNegative < plain && pushedFinish > plain | |
| ok = ok && sensesOK | |
| lines.append(String(format: " %@ sliders' sense: negative %.5f < %.5f < finish %.5f", | |
| sensesOK ? "OK " : "FAIL", pushedNegative, plain, pushedFinish)) | |
| // The same gain, before or after a non-linear tonal shaping. | |
| let tone = LevelsSet(linked: Levels(black: 0.05, white: 0.9, mid: 0.3)) | |
| let upstream = run(PipelineSettings(gains: Gains(stops: .init(repeating: 1)), levels: tone)) | |
| var after = PipelineSettings(levels: tone) | |
| after.toneBalance.midtones.colour = SIMD3(repeating: 1) | |
| let downstream = run(after) | |
| let differs = abs(upstream - downstream) > 1e-3 | |
| ok = ok && differs | |
| lines.append(String(format: " %@ stage 8 ≠ stage 4 through a tonal shaping (%.5f vs %.5f)", | |
| differs ? "OK " : "FAIL", upstream, downstream)) | |
| return lines | |
| } | |
| /// The curves kernel reads four tables from one pixel's four components; a permutation between | |
| /// them would produce no error, only a wrong image — hence this per-channel check. | |
| private static func curveChannelChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| /// A curve that brightens clearly, while staying monotone. | |
| let lift = Curve(points: [CGPoint(x: 0, y: 0), CGPoint(x: 0.5, y: 0.8), CGPoint(x: 1, y: 1)]) | |
| func run3(_ curves: CurveSet) -> (r: Float, g: Float, b: Float) { | |
| let src = CIImage(color: CIColor(red: 0.2, green: 0.1, blue: 0.05)) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(curves: curves).perPixelOnly()), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return (px[0], px[1], px[2]) | |
| } | |
| var lines: [String] = [] | |
| let neutral = run3(.neutral) | |
| let ref = [neutral.r, neutral.g, neutral.b] | |
| for (channel, index, name) in [(LevelsChannel.red, 0, "R"), | |
| (.green, 1, "G"), | |
| (.blue, 2, "B")] { | |
| var set = CurveSet() | |
| set[channel] = lift | |
| let out = run3(set) | |
| let got = [out.r, out.g, out.b] | |
| let lifted = got[index] > ref[index] + 1e-4 | |
| let othersIntact = (0..<3).allSatisfy { $0 == index || abs(got[$0] - ref[$0]) < 1e-5 } | |
| let pass = lifted && othersIntact | |
| ok = ok && pass | |
| lines.append(" \(pass ? "OK " : "FAIL") \(name) curve: brightens its channel, leaves the others") | |
| } | |
| // The linked one acts on all three at once. | |
| let linked = run3(CurveSet(linked: lift)) | |
| let allLifted = linked.r > neutral.r + 1e-4 && linked.g > neutral.g + 1e-4 | |
| && linked.b > neutral.b + 1e-4 | |
| ok = ok && allLifted | |
| lines.append(" \(allLifted ? "OK " : "FAIL") linked RGB curve: brightens the three channels") | |
| // Luma brightens without touching the ratios between channels. | |
| let lumaOut = run3(CurveSet(luma: lift)) | |
| let hueKept = abs(lumaOut.r / lumaOut.g - neutral.r / neutral.g) < 1e-3 | |
| let brighter = lumaOut.r > neutral.r + 1e-4 | |
| let lumaOK = hueKept && brighter | |
| ok = ok && lumaOK | |
| lines.append(String(format: " %@ Luma curve: brightens, chroma preserved (R/G %.5f → %.5f)", | |
| lumaOK ? "OK " : "FAIL", | |
| neutral.r / neutral.g, lumaOut.r / lumaOut.g)) | |
| return lines | |
| } | |
| /// What each tab must do, and above all must not touch: five `CIVector`s in a row can swap in | |
| /// the kernel call's argument order without anything breaking visibly. | |
| private static func channelChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| func run3(_ rgb: (Float, Float, Float), _ set: LevelsSet) -> (r: Float, g: Float, b: Float) { | |
| let src = CIImage(color: CIColor(red: CGFloat(rgb.0), green: CGFloat(rgb.1), | |
| blue: CGFloat(rgb.2))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(levels: set).perPixelOnly()), toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return (px[0], px[1], px[2]) | |
| } | |
| var lines: [String] = [] | |
| let source: (Float, Float, Float) = (0.2, 0.1, 0.05) | |
| let neutral = run3(source, .neutral) | |
| // A channel tab only touches its own channel. | |
| for (channel, index, name) in [(LevelsChannel.red, 0, "R"), | |
| (.green, 1, "G"), | |
| (.blue, 2, "B")] { | |
| var set = LevelsSet() | |
| set[channel] = Levels(black: 0.05, white: 0.6, mid: 0.5) | |
| let out = run3(source, set) | |
| let got = [out.r, out.g, out.b] | |
| let ref = [neutral.r, neutral.g, neutral.b] | |
| let moved = abs(got[index] - ref[index]) > 1e-4 | |
| let othersIntact = (0..<3).allSatisfy { $0 == index || abs(got[$0] - ref[$0]) < 1e-6 } | |
| let pass = moved && othersIntact | |
| ok = ok && pass | |
| lines.append(" \(pass ? "OK " : "FAIL") \(name) tab only acts on its own channel") | |
| } | |
| // Luma acts on luminance while preserving the chroma: the ratios between channels must not | |
| // move. That is the guarantee that makes the tab usable on skin. | |
| var lumaSet = LevelsSet() | |
| lumaSet.luma = Levels(black: 0.02, white: 0.7, mid: 0.4) | |
| let lumaOut = run3(source, lumaSet) | |
| let refRatio = neutral.r / neutral.g | |
| let outRatio = lumaOut.r / lumaOut.g | |
| let brightnessChanged = abs(lumaOut.r - neutral.r) > 1e-4 | |
| let hueKept = abs(refRatio - outRatio) < 1e-3 | |
| let lumaOK = brightnessChanged && hueKept | |
| ok = ok && lumaOK | |
| lines.append(String(format: " %@ Luma tab: luminance changed, chroma preserved (R/G %.5f → %.5f)", | |
| lumaOK ? "OK " : "FAIL", refRatio, outRatio)) | |
| return lines | |
| } | |
| /// The handle behaviours specified by name, plus the crossing constraint, tested in pure Swift | |
| /// so they actually run — the kind of regression an eye does not see. | |
| private static func handleChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| var lv = Levels(black: 0, white: 1, mid: 0.5) | |
| let before = lv.displayMid | |
| lv.setBlack(0.2) | |
| let follows = abs(before - 0.5) < 1e-6 && abs(lv.displayMid - 0.6) < 1e-6 | |
| ok = ok && follows | |
| lines.append(String(format: " %@ the median follows black: %.3f → %.3f (expected 0.500 → 0.600)", | |
| follows ? "OK " : "FAIL", before, lv.displayMid)) | |
| var off = Levels(black: 0.2, white: 0.8, mid: 0.9) | |
| off.centreMid() | |
| let middle = (off.black + off.white) / 2 | |
| let centred = abs(off.displayMid - middle) < 1e-6 | |
| ok = ok && centred | |
| lines.append(String(format: " %@ median recentred between 20 %% and 80 %% → %.1f %% (expected %.1f %%)", | |
| centred ? "OK " : "FAIL", off.displayMid * 100, middle * 100)) | |
| var crossing = Levels(black: 0, white: 0.4, mid: 0.5) | |
| crossing.setBlack(0.9) | |
| let blocked = crossing.black < crossing.white | |
| ok = ok && blocked | |
| lines.append(String(format: " %@ black blocked below white: %.3f < %.3f", | |
| blocked ? "OK " : "FAIL", crossing.black, crossing.white)) | |
| // The two added points follow the ends by the very same arithmetic, which is what a | |
| // convention shared with the median buys. | |
| var five = Levels(black: 0, white: 1) | |
| let quarters = (five.displayShadows, five.displayHighlights) | |
| five.setBlack(0.2) | |
| let carried = abs(quarters.0 - 0.25) < 1e-6 && abs(quarters.1 - 0.75) < 1e-6 | |
| && abs(five.displayShadows - 0.4) < 1e-6 && abs(five.displayHighlights - 0.8) < 1e-6 | |
| ok = ok && carried | |
| lines.append(String(format: " %@ the two added points follow black too: %.3f / %.3f → " | |
| + "%.3f / %.3f (expected 0.400 / 0.800)", carried ? "OK " : "FAIL", | |
| quarters.0, quarters.1, five.displayShadows, five.displayHighlights)) | |
| // Ordering by construction: swept over the whole travel of both handles, the five ordinates | |
| // stay `0 < c₁ < ½ < c₃ < 1`, so no scale factor carries the monotonicity. | |
| var worstLow: Float = 0, worstHigh: Float = 1 | |
| for step in 0...200 { | |
| let v = Float(step) / 200 | |
| worstLow = max(worstLow, Levels(shadows: v).shadowOrdinate) | |
| worstHigh = min(worstHigh, Levels(highlights: v).highlightOrdinate) | |
| } | |
| let ordered = worstLow < 0.5 && worstHigh > 0.5 && Levels(shadows: 0).shadowOrdinate > 0 | |
| && Levels(highlights: 1).highlightOrdinate < 1 | |
| ok = ok && ordered | |
| lines.append(String(format: " %@ the five ordinates stay ordered over the whole travel: " | |
| + "c₁ reaches %.3f at most, c₃ %.3f at least, either side of ½", | |
| ordered ? "OK " : "FAIL", worstLow, worstHigh)) | |
| // The twin: the bound is what does it. A fifth of the window past that floor — an ask a | |
| // pointer makes freely, the track reaching under the black point — crosses ½. | |
| let past = Levels.shadowRange.lowerBound - 0.2 | |
| let unbounded = 0.5 - past | |
| ok = ok && past < 0 && unbounded > 0.5 | |
| lines.append(String(format: " %@ and the check discriminates: that same handle a fifth " | |
| + "past its floor would put c₁ at %.3f, on the median's far side", | |
| past < 0 && unbounded > 0.5 ? "OK " : "FAIL", unbounded)) | |
| // Each added point acts where it says. Measured by sweeping the window's output, not | |
| // asserted from the formula, on a window whose three-point pass is the identity. | |
| func peak(of window: Levels) -> Float { | |
| stride(from: Float(0), through: 1, by: 0.001).max { | |
| abs(DensityMigration.applyWindow($0, window) - $0) | |
| < abs(DensityMigration.applyWindow($1, window) - $1) | |
| } ?? -1 | |
| } | |
| let lowPeak = peak(of: Levels(shadows: Levels.shadowRange.lowerBound)) | |
| let highPeak = peak(of: Levels(highlights: Levels.highlightRange.upperBound)) | |
| let placed = abs(lowPeak - 0.25) < 0.02 && abs(highPeak - 0.75) < 0.02 | |
| ok = ok && placed | |
| lines.append(String(format: " %@ each added point peaks where the Bernstein basis puts it: " | |
| + "%.3f and %.3f (expected 0.250 and 0.750)", | |
| placed ? "OK " : "FAIL", lowPeak, highPeak)) | |
| // The ends are pinned: only the three inner points can move, so the black and white a hand | |
| // placed still render 0 and 1 whatever the two added handles carry. | |
| let extreme = Levels(shadows: Levels.shadowRange.lowerBound, | |
| highlights: Levels.highlightRange.upperBound) | |
| let pinned = DensityMigration.applyWindow(0, extreme) == 0 | |
| && abs(DensityMigration.applyWindow(1, extreme) - 1) < 1e-6 | |
| ok = ok && pinned | |
| lines.append(String(format: " %@ and they never move the ends: 0 → %.7f, 1 → %.7f", | |
| pinned ? "OK " : "FAIL", DensityMigration.applyWindow(0, extreme), | |
| DensityMigration.applyWindow(1, extreme))) | |
| // Monotone at both corners of the two travels together, since one handle alone would not | |
| // exercise the pair that comes closest to folding. | |
| func slope(_ window: Levels) -> Float { | |
| var worst = Float.greatestFiniteMagnitude | |
| var previous = DensityMigration.applyWindow(0, window) | |
| for step in 1...500 { | |
| let value = DensityMigration.applyWindow(Float(step) / 500, window) | |
| worst = min(worst, (value - previous) * 500) | |
| previous = value | |
| } | |
| return worst | |
| } | |
| let corners = [Levels(shadows: 0.05, highlights: 0.55), Levels(shadows: 0.45, highlights: 0.95), | |
| Levels(shadows: 0.05, highlights: 0.95), Levels(shadows: 0.45, highlights: 0.55)] | |
| let flattest = corners.map(slope).min() ?? -1 | |
| ok = ok && flattest > 0 | |
| lines.append(String(format: " %@ none of the four extreme pairs folds the curve (minimum " | |
| + "slope %.4f)", flattest > 0 ? "OK " : "FAIL", flattest)) | |
| // Handle positioning geometry is checked by `DSTrack.selfCheck()`, since it is a property | |
| // of the layout convention rather than of the pipeline. | |
| return lines | |
| } | |
| /// The window's two added points through the GPU: the kernel has to run the arithmetic the | |
| /// replica states, and to be the identity on the base pair. | |
| private static func windowChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| func run(_ t: Float, _ window: Levels) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| let set = LevelsSet(luma: .neutral, linked: .neutral, | |
| red: window, green: window, blue: window) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(levels: set).perPixelOnly()), | |
| toBitmap: &px, | |
| rowBytes: 16, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| var lines: [String] = [] | |
| let rest = LevelsSet.resting(.red, for: .negative) | |
| var pushed = rest | |
| pushed.shadows = 0.40 | |
| pushed.highlights = 0.62 | |
| // The kernel against its replica, off rest, where the two disagree if either drifts. | |
| var worst: Float = 0 | |
| var farthest: Float = 0 | |
| for t in [Float(0.02), 0.05, 0.1, 0.25, 0.5] { | |
| let want = clip(DensityMigration.applyWindow(density(ofT: t), pushed)) | |
| worst = max(worst, abs(run(t, pushed) - want)) | |
| // The same pixel with the added points at rest: how far the pass really moves it. | |
| farthest = max(farthest, abs(want - clip(DensityMigration.applyWindow(density(ofT: t), | |
| rest)))) | |
| } | |
| ok = ok && worst < 1e-4 | |
| lines.append(String(format: " %@ the window's two added points run on the GPU as the " | |
| + "replica states them (%.6f off over five transmittances)", | |
| worst < 1e-4 ? "OK " : "FAIL", worst)) | |
| // The twin, adverse by construction: at rest the same handles are the exact identity, so a | |
| // kernel ignoring them would agree with the reading above by doing nothing. | |
| ok = ok && farthest > 0.05 | |
| lines.append(String(format: " %@ and the check discriminates: those same pixels move %.4f " | |
| + "off the resting pair, which a kernel ignoring the pass would not", | |
| farthest > 0.05 ? "OK " : "FAIL", farthest)) | |
| // At rest the pass is skipped, hence the three-point window bit for bit in closed form — | |
| // an already-graded frame must not move because a stage grew two handles. | |
| var exact = true | |
| for step in 0...1000 { | |
| let e = -0.35 + Float(step) / 1000 * Graduation.span | |
| exact = exact && DensityMigration.applyWindow(e, rest) | |
| == DensityMigration.applyLevels(e, rest) | |
| } | |
| // And through the GPU, where the neutral global passes cost their own rounding: read to the | |
| // tolerance the rest of the kernel's checks hold, not to the bit. | |
| var drifted: Float = 0 | |
| for t in [Float(0.02), 0.05, 0.1, 0.25, 0.5, 1] { | |
| drifted = max(drifted, abs(run(t, rest) | |
| - clip(DensityMigration.applyLevels(density(ofT: t), rest)))) | |
| } | |
| ok = ok && exact && drifted < 1e-4 | |
| lines.append(String(format: " %@ and at rest the window is the three-point pass, bit for " | |
| + "bit over the whole axis and %.1e off through the GPU", | |
| exact && drifted < 1e-4 ? "OK " : "FAIL", drifted)) | |
| // The sense, measured rather than asserted: moving a handle right darkens its zone, as the | |
| // median's does, or the UI could drift while the test still agreed with the kernel. | |
| var darker = rest | |
| darker.shadows = Levels.shadowRange.upperBound | |
| var brighter = rest | |
| brighter.shadows = Levels.shadowRange.lowerBound | |
| // A transmittance whose window output lands near the quarter, where the point acts most. | |
| let probe: Float = 0.12 | |
| let sense = run(probe, darker) < run(probe, rest) && run(probe, brighter) > run(probe, rest) | |
| ok = ok && sense | |
| lines.append(String(format: " %@ the sense: at T=%.2f the shadows handle renders %.5f right, " | |
| + "%.5f at rest, %.5f left — right darkens, as the median does", | |
| sense ? "OK " : "FAIL", probe, run(probe, darker), run(probe, rest), | |
| run(probe, brighter))) | |
| return lines | |
| } | |
| /// The same two points on the global sets, which read the windows' dimensionless output rather | |
| /// than a density: one Bézier, two scales, and a luma pass that multiplies instead of applying. | |
| private static func globalWindowChecks(_ ctx: CIContext, _ ok: inout Bool) -> [String] { | |
| func run(_ t: Float, _ set: LevelsSet) -> Float { | |
| let src = CIImage(color: CIColor(red: CGFloat(t), green: CGFloat(t), blue: CGFloat(t))) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 2, height: 2)) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(apply(src, settings: PipelineSettings(levels: set).perPixelOnly()), | |
| toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] | |
| } | |
| /// Stages 4→7 in closed form, through the very replica the migration is proved against. | |
| func closed(_ t: Float, _ set: LevelsSet) -> Float { | |
| let d = SIMD3<Float>(repeating: density(ofT: t)) | |
| return clip(DensityMigration.globalPasses(DensityMigration.perChannelWindow(d, set), | |
| set).x) | |
| } | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let rest = LevelsSet.neutral(for: .negative) | |
| let transmittances: [Float] = [0.02, 0.05, 0.1, 0.25, 0.5] | |
| // The kernel against its replica on each global set in turn, off rest, where the two | |
| // disagree if either drifts — and the twin says the pass really moves those same pixels. | |
| for channel in LevelsChannel.global { | |
| var pushed = rest | |
| pushed[channel].shadows = 0.40 | |
| pushed[channel].highlights = 0.62 | |
| var worst: Float = 0 | |
| var farthest: Float = 0 | |
| for t in transmittances { | |
| worst = max(worst, abs(run(t, pushed) - closed(t, pushed))) | |
| farthest = max(farthest, abs(closed(t, pushed) - closed(t, rest))) | |
| } | |
| report(worst < 1e-4, | |
| String(format: "the %@ set's two added points run on the GPU as the replica " | |
| + "states them (%.6f off over five transmittances)", | |
| channel.rawValue, worst)) | |
| report(farthest > 0.02, | |
| String(format: "and the check discriminates: those same pixels move %.4f off " | |
| + "the resting pair, which a kernel ignoring the pass would not", | |
| farthest)) | |
| } | |
| // At rest both global sets skip the pass, so a sidecar written before they grew handles | |
| // renders the three-point chain it was graded on — bit for bit in closed form. | |
| var exact = true | |
| for step in 0...1000 { | |
| let e = Float(step) / 1000 | |
| for channel in LevelsChannel.global { | |
| exact = exact && DensityMigration.applyWindow(e, rest[channel]) | |
| == DensityMigration.applyLevels(e, rest[channel]) | |
| } | |
| } | |
| var drifted: Float = 0 | |
| for t in transmittances + [1] { | |
| let three = DensityMigration.applyLevels( | |
| DensityMigration.applyLevels(density(ofT: t), rest.red), rest.linked) | |
| drifted = max(drifted, abs(run(t, rest) - clip(three))) | |
| } | |
| report(exact && drifted < 1e-4, | |
| String(format: "and at rest they are the three-point pass, bit for bit over the " | |
| + "whole output and %.1e off through the GPU", drifted)) | |
| lines.append(contentsOf: globalMonotonicity(&ok)) | |
| lines.append(contentsOf: lumaCeilingChecks(&ok)) | |
| return lines | |
| } | |
| /// Monotonicity where the global sets differ from the windows: a median resting at 0.67 and a | |
| /// gamma reaching both ends of its travel, on a domain that starts at zero. | |
| private static func globalMonotonicity(_ ok: inout Bool) -> [String] { | |
| /// The Bézier with its two ordinates handed in, so the twin can be given the pair the | |
| /// clamps refuse. Same expression order as the replica, hence the same bits on the same pair. | |
| func window(_ e: Float, _ lv: Levels, _ c1: Float, _ c3: Float) -> Float { | |
| let t = DensityMigration.applyLevels(e, lv) | |
| let u = min(t, 1) | |
| let v = 1 - u | |
| let curve = c1 * 4 * u * v * v * v + 0.5 * 6 * u * u * v * v | |
| + c3 * 4 * u * u * u * v + u * u * u * u | |
| return curve + (t - u) | |
| } | |
| func flattest(_ windows: [Levels], _ ordinates: (Levels) -> (Float, Float)) -> Float { | |
| var worst = Float.greatestFiniteMagnitude | |
| for lv in windows { | |
| let (c1, c3) = ordinates(lv) | |
| var previous = window(0, lv, c1, c3) | |
| for step in 1...500 { | |
| let value = window(Float(step) / 500, lv, c1, c3) | |
| worst = min(worst, (value - previous) * 500) | |
| previous = value | |
| } | |
| } | |
| return worst | |
| } | |
| // Every corner of the two travels, at the linked rest and at both ends of the gamma's own | |
| // travel — the median a global set rests on is 0.67, not the windows' ½. | |
| let mids = [Levels.midRange.lowerBound, Levels.restingMid(for: .negative), | |
| Levels.midRange.upperBound] | |
| let pairs = [(Float(0.05), Float(0.55)), (0.45, 0.95), (0.05, 0.95), (0.45, 0.55)] | |
| let corners = mids.flatMap { mid in | |
| pairs.map { Levels(mid: mid, shadows: $0.0, highlights: $0.1) } | |
| } | |
| let held = flattest(corners) { ($0.shadowOrdinate, $0.highlightOrdinate) } | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| report(held >= 0, | |
| String(format: "none of the %d extreme pairs folds a global window, gamma swept " | |
| + "from %.2f to %.2f (minimum slope %.3e)", corners.count, | |
| Levels(mid: mids[0]).gamma, Levels(mid: mids[2]).gamma, held)) | |
| // The twin, adverse by construction: the ordinates the two handles take dragged to the | |
| // window's opposite ends, which is the ask `shadowRange` and `highlightRange` refuse. | |
| let swapped = (Float(0.5) - 1, Float(1.5) - 0) | |
| let crossed = flattest(corners) { _ in swapped } | |
| report(crossed < 0, | |
| String(format: "the twin: handed the pair those bounds refuse — c₁ %.3f, c₃ %.3f, " | |
| + "each handle dragged to the other's end — that same sweep folds at %.4f", | |
| swapped.0, swapped.1, crossed)) | |
| return lines | |
| } | |
| /// What the luma set's ceiling does to the two added points. The pass is a clamped factor, not | |
| /// a window applied to the pixel, so a lifting setting can pin it flat and make them inert. | |
| private static func lumaCeilingChecks(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| func factor(_ y: Float, _ luma: Levels) -> Float { | |
| min(max(DensityMigration.applyWindow(y, luma) / y, 0), lumaScaleMax) | |
| } | |
| /// The factor the steepest pair reaches on a given window, and the output gap it opens | |
| /// against that same window's resting pair. | |
| func moved(white: Float) -> (peak: Float, gap: Float) { | |
| let atRest = Levels(black: 0, white: white) | |
| let pushed = Levels(black: 0, white: white, shadows: Levels.shadowRange.lowerBound, | |
| highlights: Levels.highlightRange.lowerBound) | |
| var peak: Float = 0 | |
| var gap: Float = 0 | |
| for step in 1...2000 { | |
| let y = Float(step) / 2000 | |
| peak = max(peak, factor(y, pushed)) | |
| gap = max(gap, abs(factor(y, pushed) - factor(y, atRest)) * y) | |
| } | |
| return (peak, gap) | |
| } | |
| // On the resting luma window the two points are always live: the steepest pair they reach | |
| // multiplies the factor by under two, far from the ceiling. | |
| let live = moved(white: 1) | |
| report(live.peak < lumaScaleMax && live.gap > 0.01, | |
| String(format: "on the resting luma window the steepest pair reaches a factor of " | |
| + "%.4f, under the ceiling of %.0f, and moves the output by %.4f", | |
| live.peak, lumaScaleMax, live.gap)) | |
| // Where the ceiling swallows them, measured rather than assumed. It lands on the ceiling's | |
| // own reciprocal: under that white the three-point pass alone already asks past it. | |
| var widestInert: Float = 0 | |
| var narrowestLive: Float = 0 | |
| for step in 1...400 { | |
| let white = Float(step) / 400 | |
| if moved(white: white).gap == 0 { | |
| widestInert = max(widestInert, white) | |
| } else if narrowestLive == 0 { | |
| narrowestLive = white | |
| } | |
| } | |
| let threshold = 1 / lumaScaleMax | |
| report(widestInert > 0 && narrowestLive > widestInert | |
| && abs(widestInert - threshold) < 0.003, | |
| String(format: "and the ceiling does make them inert: at a luma white of %.4f or " | |
| + "under they move the output by exactly nothing — the ceiling's own " | |
| + "reciprocal, %.4f — while %.4f still carries them", | |
| widestInert, threshold, narrowestLive)) | |
| return lines | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// A designated reading rather than a guess: the pointer names a black or a white, every pixel of | |
| /// the disc under it is aggregated, and that one end of the three windows takes the answer. | |
| enum Pipette { | |
| /// Which handle of the three per-channel windows a reading is written onto. A pipette answers | |
| /// one question and writes nothing else, unlike a placement button proposing a whole state. | |
| enum Target: String, CaseIterable, Identifiable, Hashable, Sendable { | |
| case black | |
| case neutral | |
| case white | |
| var id: String { rawValue } | |
| /// The section title carries the verb, so the three keys share one plate without wrapping. | |
| var label: String { | |
| switch self { | |
| case .black: "Black" | |
| case .neutral: "Neutral" | |
| case .white: "White" | |
| } | |
| } | |
| /// Said before the click, since the disc shows where but not what the gesture writes. | |
| var explanation: String { | |
| switch self { | |
| case .black: | |
| "Puts each channel's black point a margin BELOW the disc under the pointer, so " | |
| + "what is darker than the darkest detail is kept rather than clipped." | |
| case .neutral: | |
| "Aims any surface meant to be achromatic — a wall, a chart, matte black. Moves red " | |
| + "and blue's medians onto green at that point, at the lightness it already has." | |
| case .white: | |
| "Puts each channel's white point a margin ABOVE the disc under the pointer, so " | |
| + "what is brighter than the brightest detail is kept rather than clipped." | |
| } | |
| } | |
| /// What a key leaves untouched, said in the pane: the whole difference with a placement | |
| /// button, and the reason a second pick cannot destroy the first. | |
| var leaves: String { | |
| switch self { | |
| case .black: "The white points, the medians and the two added handles are left alone." | |
| case .neutral: "Both ends and the two added handles are left alone." | |
| case .white: "The black points, the medians and the two added handles are left alone." | |
| } | |
| } | |
| } | |
| /// How far past the picked point a handle is set, as a share of the graduation window — the | |
| /// track, not the gap between two picks, so one pick alone still leaves its margin. | |
| static let margin: Float = 0.05 | |
| /// The same in density, which is the unit the three windows carry. | |
| static var marginDensity: Float { margin * Graduation.span } | |
| /// Resting radius of the disc, in pixels of `Negative.measureSide` — the grid every measurement | |
| /// already shares, so the reading follows neither the zoom nor the crop. | |
| static let defaultRadius: CGFloat = 4 | |
| /// What the slider may dial. Two films read cheapest at the floor and none above the ceiling, | |
| /// so the track brackets what a photograph asks for rather than what one of them does. | |
| static let radiusRange: ClosedRange<CGFloat> = 2...24 | |
| /// Holds a stored preference to the track: a value left by another version must not draw a ring | |
| /// the slider has no position for, nor a disc too small to hold a pixel. | |
| static func radius(_ stored: Double) -> CGFloat { | |
| stored.isFinite ? min(max(CGFloat(stored), radiusRange.lowerBound), | |
| radiusRange.upperBound) : defaultRadius | |
| } | |
| /// The disc's diameter as a share of the width, which is how the zone is judged: a ring wider | |
| /// than the shadow it sits in cannot be aimed, whatever the grain it averages. | |
| static func widthShare(ofRadius radius: CGFloat) -> Float { | |
| Float(2 * radius / Negative.measureSide) | |
| } | |
| /// Where the samples are averaged. The two differ on the mean alone: a monotone map commutes | |
| /// with a median, so the retained statistic settles this question rather than inheriting it. | |
| enum Space: Equatable, Sendable { | |
| /// The axis the chain works on, hence the geometric mean of the transmittances. | |
| case density | |
| /// Their arithmetic mean, which is what a sensor integrates. | |
| case transmittance | |
| } | |
| /// Silver grain is log-normal, so the axis the sliders are already linear in stops on is also | |
| /// the one whose mean is not dragged by the brightest grains. `selfCheck` prices the gap. | |
| static let retainedSpace: Space = .density | |
| /// How the samples are summarised. The two part company on foreign matter — dust, scratches, | |
| /// specks — which is what makes this a question at all. | |
| enum Statistic: Equatable, Sendable { | |
| case mean | |
| case median | |
| } | |
| /// The disc is drawn under the pointer before the click, so the zone is chosen with a speck in | |
| /// plain sight; that makes the mean's one weakness visible, and it keeps every pixel counted. | |
| static let retainedStatistic: Statistic = .mean | |
| /// The three fractions of the graduation window the disc holds, and how many pixels said so — | |
| /// the count is what says a reading near an edge still rests on a real sample. | |
| struct Reading: Equatable, Sendable { | |
| var fractions: SIMD3<Float> | |
| var pixels: Int | |
| } | |
| /// The image a reading is taken off: the recipe the levels histogram is measured through, which | |
| /// is what keeps the value picked independent of the handles it writes. | |
| static func graduated(_ image: CIImage, settings: PipelineSettings, | |
| fullWidth: CGFloat?) -> CIImage { | |
| Pipeline.measured(of: image, settings: settings, before: .levels, fullWidth: fullWidth) | |
| } | |
| /// The disc's diameter as a share of the graduated image's width, the one form a view can draw | |
| /// without knowing which copy was measured. Never defaulted, so drawn and read cannot differ. | |
| static func discWidth(on graduated: CGRect, radius: CGFloat) -> CGFloat { | |
| graduated.width > 0 ? min(2 * radius / graduated.width, 1) : 0 | |
| } | |
| /// Reads the disc centred on `unit`, a point of the rendered image with y growing downward as | |
| /// a view reports it. `nil` when the disc falls outside the frame or holds no pixel. | |
| static func read(_ graduated: CIImage, at unit: CGPoint, in mode: ConversionMode, | |
| radius: CGFloat, space: Space = retainedSpace, | |
| statistic: Statistic = retainedStatistic) -> Reading? { | |
| let extent = graduated.extent | |
| guard extent.width >= 1, extent.height >= 1, radius >= 0.5, | |
| (0...1).contains(unit.x), (0...1).contains(unit.y) else { return nil } | |
| // Core Image counts from the bottom while a pointer is reported from the top. | |
| let centre = CGPoint(x: extent.minX + unit.x * extent.width, | |
| y: extent.maxY - unit.y * extent.height) | |
| let box = CGRect(x: (centre.x - radius).rounded(.down), | |
| y: (centre.y - radius).rounded(.down), | |
| width: (2 * radius).rounded(.up) + 1, | |
| height: (2 * radius).rounded(.up) + 1).intersection(extent) | |
| guard box.width >= 1, box.height >= 1 else { return nil } | |
| let w = Int(box.width), h = Int(box.height) | |
| var pixels = [Float](repeating: 0, count: w * h * 4) | |
| pixels.withUnsafeMutableBytes { raw in | |
| guard let base = raw.baseAddress else { return } | |
| Pipeline.measureContext.render(graduated, toBitmap: base, rowBytes: w * 16, | |
| bounds: box, format: .RGBAf, colorSpace: nil) | |
| } | |
| // Row 0 of the bitmap is the TOP of `box`, so the vertical centre is measured from `maxY`. | |
| // Off `minY` the disc slides one pixel down whenever the box's height is even. | |
| let cx = Float(centre.x - box.minX) - 0.5, cy = Float(box.maxY - centre.y) - 0.5 | |
| let r2 = Float(radius * radius) | |
| var samples: [SIMD3<Float>] = [] | |
| samples.reserveCapacity(w * h) | |
| for row in 0..<h { | |
| let dy = Float(row) - cy | |
| for column in 0..<w { | |
| let dx = Float(column) - cx | |
| guard dx * dx + dy * dy <= r2 else { continue } | |
| let i = (row * w + column) * 4 | |
| samples.append(SIMD3(pixels[i], pixels[i + 1], pixels[i + 2])) | |
| } | |
| } | |
| guard let aggregated = aggregate(samples, in: mode, space: space, statistic: statistic) | |
| else { return nil } | |
| return Reading(fractions: aggregated, pixels: samples.count) | |
| } | |
| /// Summarises a disc's samples. Only the ANSWER is bounded onto the window — bounding each | |
| /// sample, as the counter must, would drag the mean up out of a channel reading under zero. | |
| static func aggregate(_ samples: [SIMD3<Float>], in mode: ConversionMode, | |
| space: Space = retainedSpace, | |
| statistic: Statistic = retainedStatistic) -> SIMD3<Float>? { | |
| guard !samples.isEmpty else { return nil } | |
| var out = SIMD3<Float>.zero | |
| for channel in 0..<3 { | |
| let column = samples.map { $0[channel] } | |
| let summarised: Float | |
| switch (statistic, space) { | |
| case (.median, _): | |
| // A monotone map commutes with a median, so the space cannot reach this branch. | |
| summarised = middle(of: column) | |
| case (.mean, .density): | |
| // The graduated signal is affine in density, so its mean IS the density's. | |
| summarised = column.reduce(0, +) / Float(column.count) | |
| case (.mean, .transmittance): | |
| let mean = column.reduce(Float(0)) { $0 + pow(10, -density(atFraction: $1, in: mode)) } | |
| / Float(column.count) | |
| summarised = fraction(ofDensity: -log10(max(mean, .leastNormalMagnitude)), in: mode) | |
| } | |
| out[channel] = min(max(summarised, 0), 1) | |
| } | |
| return out | |
| } | |
| /// The lower of the two middles on an even count, so the answer is a value the film really | |
| /// holds rather than one interpolated between two grains. | |
| static func middle(of column: [Float]) -> Float { | |
| var sorted = column | |
| sorted.sort() | |
| return sorted[(sorted.count - 1) / 2] | |
| } | |
| /// The density behind a fraction of the window: the positive branch re-inverts on `Pipeline.dmax` | |
| /// while the negative one emits the density itself, read off `invertFlag`, the kernel's switch. | |
| static func density(atFraction fraction: Float, in mode: ConversionMode) -> Float { | |
| let value = Graduation.value(atFraction: fraction, in: mode) | |
| return mode.invertFlag > 0.5 ? value : Pipeline.dmax - value | |
| } | |
| static func fraction(ofDensity density: Float, in mode: ConversionMode) -> Float { | |
| Graduation.fraction(of: mode.invertFlag > 0.5 ? density : Pipeline.dmax - density, | |
| in: mode) | |
| } | |
| /// The density a handle takes for a pick, margin included and held to the track: past the end | |
| /// of the window a handle has no position to be drawn at, so it stops rather than folds. | |
| static func placed(_ value: Float, for target: Target, in mode: ConversionMode) -> Float { | |
| let window = Graduation.window(for: mode) | |
| let shifted = target == .black ? value - marginDensity : value + marginDensity | |
| return min(max(shifted, window.lowerBound), window.upperBound) | |
| } | |
| /// The channel the other two are aligned onto, as everywhere else in the balance. Its own | |
| /// median cannot move, so the neutral key skips it rather than rewriting it to itself. | |
| static let reference = 1 | |
| /// Green's own rendering of the pick, which is what the neutral key brings the other two onto: | |
| /// the surface is achromatic at the lightness it already has, and no target value is imposed. | |
| static func neutralTarget(_ reading: Reading, in settings: PipelineSettings) -> Float? { | |
| let set = LevelsChannel.perChannel[reference] | |
| let levels = settings.levels[set] | |
| let span = levels.white - levels.black | |
| guard span > 0 else { return nil } | |
| let value = Graduation.value(atFraction: reading.fractions[reference], in: settings.mode) | |
| return pow(min(max((value - levels.black) / span, Levels.anchorClamp.lowerBound), | |
| Levels.anchorClamp.upperBound), levels.gamma) | |
| } | |
| /// Writes the one handle the pipette answers for, on the three windows, and nothing else. | |
| /// Placing a black must not send the white back to rest, or the gesture destroys the previous. | |
| static func applied(_ reading: Reading, to target: Target, | |
| in settings: PipelineSettings) -> PipelineSettings { | |
| var out = settings | |
| let aim = target == .neutral ? neutralTarget(reading, in: settings) : nil | |
| for (channel, set) in LevelsChannel.perChannel.enumerated() { | |
| let value = Graduation.value(atFraction: reading.fractions[channel], in: settings.mode) | |
| switch target { | |
| // `setBlack` and `setWhite` carry the crossing guard, so a black picked past the white | |
| // is held one `epsilon` short of it instead of collapsing the window. | |
| case .black: out.levels[set].setBlack(placed(value, for: target, in: settings.mode)) | |
| case .white: out.levels[set].setWhite(placed(value, for: target, in: settings.mode)) | |
| case .neutral where channel != reference: | |
| let levels = out.levels[set] | |
| let span = max(levels.white - levels.black, 1e-4) | |
| guard let aim, | |
| let solved = Levels.mid(placing: (value - levels.black) / span, at: aim) | |
| else { continue } | |
| out.levels[set].mid = solved | |
| case .neutral: continue | |
| } | |
| } | |
| return out | |
| } | |
| } | |
| // MARK: - What the gesture is worth, measured on real grain | |
| extension Pipette { | |
| /// Radii the sweep prices, in pixels of the shared measuring grid. Dense at the bottom, where | |
| /// the track is, and stopping at 32: a lattice of nine discs past it asks for 400 flat pixels. | |
| static let sweptRadii: [CGFloat] = [1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32] | |
| /// The quantile of green standing for the frame's own ends. A hundredth of a percent leaves the | |
| /// hot pixels out while still naming the darkest matter a hand would aim at. | |
| static let endQuantile: Float = 0.0001 | |
| /// A mote of dust too small to notice, in pixels of the measuring grid: 50 µm on a 36 mm frame | |
| /// is 0.14 % of the long side, hence two pixels across at 1400. | |
| static let speckPixels = 3 | |
| /// How far the pointer is nudged to price the grain, in pixels of the grid. Fixed across the | |
| /// sweep, since it stands for a hand that does not hold still and not for the disc's own size. | |
| static let tremor = 2 | |
| /// How far past its best a radius may cost and still belong on the track. Twice is the widest | |
| /// bracket that still names a track rather than the whole sweep. | |
| static let trackCeiling: Float = 2 | |
| /// Half a bin of the graduation window: the finest distinction the placement machinery can | |
| /// express, hence what "the reading stopped moving" has to mean. | |
| static var halfBin: Float { 1 / 2048 } | |
| /// The share of a disc a speck of dust is worth on a film scan, and what the two statistics | |
| /// each pay for it. | |
| static let dustShares: [Float] = [0.01, 0.05, 0.10] | |
| /// Arithmetic the gesture rests on, with no file: what a pipette writes, and what it leaves. | |
| @MainActor | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let mode = ConversionMode.negative | |
| var settings = PipelineSettings() | |
| // A graded state, so "the other handles are left alone" has something to leave alone. | |
| for set in LevelsChannel.perChannel { | |
| settings.levels[set] = Levels(black: 0.31, white: 2.40, mid: 0.62, | |
| shadows: 0.30, highlights: 0.71) | |
| } | |
| let black = Reading(fractions: SIMD3(0.12, 0.15, 0.18), pixels: 1809) | |
| let white = Reading(fractions: SIMD3(0.81, 0.84, 0.88), pixels: 1809) | |
| let afterBlack = applied(black, to: .black, in: settings) | |
| let afterWhite = applied(white, to: .white, in: settings) | |
| // The whole difference with a placement button, and the one that survives a second click. | |
| let keptWhites = LevelsChannel.perChannel.allSatisfy { | |
| afterBlack.levels[$0].white == settings.levels[$0].white | |
| } | |
| let keptInner = LevelsChannel.perChannel.allSatisfy { | |
| afterBlack.levels[$0].mid == settings.levels[$0].mid | |
| && afterBlack.levels[$0].shadows == settings.levels[$0].shadows | |
| && afterBlack.levels[$0].highlights == settings.levels[$0].highlights | |
| } | |
| report(keptWhites && keptInner, | |
| "picking a black writes the three black points and nothing else: the whites stay at " | |
| + String(format: "%.3f", settings.levels.red.white) | |
| + " and the three inner handles keep their normalised places") | |
| report(LevelsChannel.perChannel.allSatisfy { | |
| afterWhite.levels[$0].black == settings.levels[$0].black | |
| }, | |
| "and picking a white writes the three white points alone, symmetrically") | |
| // The third key, and the only one reaching the handle no pipette touched: the medians move | |
| // and both ends must not, or a neutral pick undoes the two before it. | |
| let neutral = Reading(fractions: SIMD3(0.44, 0.50, 0.57), pixels: 49) | |
| let afterNeutral = applied(neutral, to: .neutral, in: settings) | |
| let keptEnds = LevelsChannel.perChannel.allSatisfy { | |
| afterNeutral.levels[$0].black == settings.levels[$0].black | |
| && afterNeutral.levels[$0].white == settings.levels[$0].white | |
| && afterNeutral.levels[$0].shadows == settings.levels[$0].shadows | |
| && afterNeutral.levels[$0].highlights == settings.levels[$0].highlights | |
| } | |
| report(keptEnds && afterNeutral.levels.red.mid != settings.levels.red.mid, | |
| String(format: "a neutral pick writes the three medians alone: red goes %.4f → " | |
| + "%.4f while both ends and the two added handles stand still", | |
| settings.levels.red.mid, afterNeutral.levels.red.mid)) | |
| // Green is the reference the other two are brought onto, so its own median cannot move — | |
| // and the three then render the picked pixel at one value, which is what neutral means. | |
| func rendered(_ reading: Reading, _ state: PipelineSettings, _ channel: Int) -> Float { | |
| let set = LevelsChannel.perChannel[channel] | |
| let value = Graduation.value(atFraction: reading.fractions[channel], in: state.mode) | |
| return DensityMigration.applyLevels(value, state.levels[set]) | |
| } | |
| let spread = (0..<3).map { rendered(neutral, afterNeutral, $0) } | |
| let agreement = (spread.max() ?? 0) - (spread.min() ?? 0) | |
| report(afterNeutral.levels.green.mid == settings.levels.green.mid && agreement < 1e-3, | |
| String(format: "green's median is untouched and the three channels then render the " | |
| + "picked pixel within %.6f of one another, at R %.4f G %.4f B %.4f", | |
| agreement, spread[0], spread[1], spread[2])) | |
| // Adverse by construction: the same three renderings BEFORE the pick, which is the | |
| // disagreement the key exists to close. | |
| let before = (0..<3).map { rendered(neutral, settings, $0) } | |
| report((before.max() ?? 0) - (before.min() ?? 0) > agreement, | |
| String(format: "and the check discriminates: before the pick they stood %.6f apart", | |
| (before.max() ?? 0) - (before.min() ?? 0))) | |
| // Adverse by construction: the complete state a placement button writes, on the same | |
| // reading — it must be refused by the very rule above. | |
| var complete = settings | |
| for (channel, set) in LevelsChannel.perChannel.enumerated() { | |
| complete.levels[set] = AutoLevels.placed( | |
| AutoLevels.Suggestion(black: black.fractions[channel], | |
| white: white.fractions[channel]), in: mode) | |
| } | |
| report(!LevelsChannel.perChannel.allSatisfy { | |
| complete.levels[$0].white == settings.levels[$0].white | |
| && complete.levels[$0].mid == settings.levels[$0].mid | |
| }, | |
| "and the check discriminates: a complete state written on the same reading moves " | |
| + "the whites and the medians, which is exactly what a pipette must not do") | |
| // The margin, in the unit the track is graduated in: a handle set ON the darkest detail | |
| // clips all under it, so it goes past it, by a share of the window and not of a gap. | |
| let picked = Graduation.value(atFraction: black.fractions[0], in: mode) | |
| report(abs(afterBlack.levels.red.black - (picked - marginDensity)) < 1e-6, | |
| String(format: "a fraction of %.3f reads as a density of %.4f and the black lands " | |
| + "at %.4f, %.0f %% of the %.2f-wide track below it", black.fractions[0], | |
| picked, afterBlack.levels.red.black, margin * 100, Graduation.span)) | |
| let pickedWhite = Graduation.value(atFraction: white.fractions[0], in: mode) | |
| report(abs(afterWhite.levels.red.white - (pickedWhite + marginDensity)) < 1e-6, | |
| String(format: "and a white lands at %.4f, the same margin ABOVE the %.4f picked", | |
| afterWhite.levels.red.white, pickedWhite)) | |
| // Adverse by construction: the margin taken on the gap between the two picks instead of on | |
| // the track. It can never invert a window, and it is not what a lone pick can offer. | |
| let byGap = margin * (pickedWhite - picked) | |
| report(abs(byGap - marginDensity) > 1e-4, | |
| String(format: "and the check discriminates: 5 %% of the gap between the two picks " | |
| + "would be %.4f of density against the track's %.4f", byGap, marginDensity)) | |
| // Past the end of the track a handle has no position to be drawn at, so it stops there. | |
| let top = Graduation.window(for: mode).upperBound | |
| let atTop = applied(Reading(fractions: SIMD3(repeating: 1), pixels: 49), to: .white, | |
| in: settings) | |
| report(abs(atTop.levels.red.white - top) < 1e-6, | |
| String(format: "a white picked at the very top plus its margin is held at %.4f, the " | |
| + "window's own end, rather than folded past it", atTop.levels.red.white)) | |
| // The crossing guard, since a black picked on a fogged leader can sit above the white. | |
| let crossing = applied(Reading(fractions: SIMD3(0.99, 0.99, 0.99), pixels: 1809), | |
| to: .black, in: settings) | |
| report(crossing.levels.red.black <= settings.levels.red.white - Levels.epsilon, | |
| String(format: "a black picked past the white is held %.2f short of it rather than " | |
| + "collapsing the window", Levels.epsilon)) | |
| // A window already narrower than two margins: the guard, not the arithmetic, is what holds. | |
| var narrow = settings | |
| for set in LevelsChannel.perChannel { | |
| narrow.levels[set] = Levels(black: 1.40, white: 1.40 + 2 * Levels.epsilon, mid: 0.5) | |
| } | |
| let squeezed = applied(Reading(fractions: SIMD3(repeating: 0.53), pixels: 49), to: .black, | |
| in: narrow) | |
| let width = squeezed.levels.red.white - squeezed.levels.red.black | |
| report(width >= Levels.epsilon - 1e-6, | |
| String(format: "on a window %.2f wide, a third of the margin, the pick leaves %.4f " | |
| + "of density rather than inverting it", 2 * Levels.epsilon, width)) | |
| // The track's floor cannot let a disc hold nothing, whatever a stored preference carries. | |
| report(radius(0) == radiusRange.lowerBound && radius(1e6) == radiusRange.upperBound | |
| && radius(.nan) == defaultRadius, | |
| String(format: "a stored radius is held to %.0f…%.0f px of the measuring grid, and " | |
| + "an unreadable one reads back as the resting %.0f", radiusRange.lowerBound, | |
| radiusRange.upperBound, defaultRadius)) | |
| report(Frame.discOffsets(radius: radiusRange.lowerBound).count >= 5, | |
| "and the smallest disc on the track holds " | |
| + "\(Frame.discOffsets(radius: radiusRange.lowerBound).count) px, never none") | |
| // The retained statistic makes the space a live question: a median commutes with the axis, | |
| // a mean does not, and the ramp below is what that costs. | |
| let samples: [SIMD3<Float>] = (0..<101).map { | |
| SIMD3(repeating: 0.1 + Float($0) * 0.008) | |
| } | |
| let byDensity = aggregate(samples, in: mode, space: .density, statistic: .median) | |
| let byTransmittance = aggregate(samples, in: mode, space: .transmittance, | |
| statistic: .median) | |
| report(byDensity == byTransmittance, | |
| "a median reads the same in both spaces, exactly — which is why the space is only " | |
| + "a question under the mean the pipette ships") | |
| let meanD = aggregate(samples, in: mode, space: .density, statistic: .mean)?.x ?? 0 | |
| let meanT = aggregate(samples, in: mode, space: .transmittance, statistic: .mean)?.x ?? 0 | |
| report(abs(meanD - meanT) > halfBin, | |
| String(format: "and under the mean the two spaces part by %.4f of window on that " | |
| + "same ramp, %.0f× half a bin — a choice with a price", abs(meanD - meanT), | |
| abs(meanD - meanT) / halfBin)) | |
| lines.append(contentsOf: rimReport(&ok)) | |
| lines.append(contentsOf: wiringReport(&ok)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| /// Radii the rim is measured at: both ends, the resting point, the middle, and a HALF-integer | |
| /// one, whose sampling box is even and where a centre read off the wrong edge slides the disc. | |
| static var rimRadii: [CGFloat] { | |
| [radiusRange.lowerBound, defaultRadius, defaultRadius + 0.5, | |
| (radiusRange.lowerBound + radiusRange.upperBound) / 2, radiusRange.upperBound] | |
| } | |
| /// The last integer offset inside the rim and the first outside, found rather than tabulated so | |
| /// the pair straddles at every radius. `dx` is positive, giving the twin's slip its leverage. | |
| static func rimStraddle(radius: CGFloat) | |
| -> (inside: (x: Int, y: Int), outside: (x: Int, y: Int))? { | |
| let reach = Int(radius.rounded(.up)) + 1, r2 = Float(radius * radius) | |
| var inside: (offset: (x: Int, y: Int), d2: Int)? | |
| var outside: (offset: (x: Int, y: Int), d2: Int)? | |
| for y in 0...reach { | |
| for x in 1...reach { | |
| let d2 = x * x + y * y | |
| if Float(d2) <= r2 { | |
| if inside == nil || d2 > inside!.d2 { inside = ((x, y), d2) } | |
| } else if outside == nil || d2 < outside!.d2 { | |
| outside = ((x, y), d2) | |
| } | |
| } | |
| } | |
| guard let inside, let outside else { return nil } | |
| return (inside.offset, outside.offset) | |
| } | |
| /// The whole protection the mean rests on: the ring is what a hand aims with, so a speck seen | |
| /// outside it must be outside the sample. Measured on the rim itself, at every radius. | |
| @MainActor | |
| private static func rimReport(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let mode = ConversionMode.negative | |
| let extent = CGRect(x: 0, y: 0, width: Negative.measureSide, height: 933) | |
| for radius in rimRadii { | |
| // The path a `Circle` strokes has the radius of half its frame, and the frame is the | |
| // aggregated diameter — one identity, so the rim drawn IS the rim read. | |
| let drawn = discWidth(on: extent, radius: radius) * extent.width / 2 | |
| report(abs(drawn - radius) < 1e-9, | |
| String(format: "r=%.1f: the ring's path radius is the sampled radius, %.4f px " | |
| + "of the measuring grid, whatever the zoom lays it out at", | |
| radius, drawn)) | |
| // Adverse by construction: `strokeBorder` insets the path by half the line, which is | |
| // the shape of gap this check exists for — and it bites hardest on the smallest disc. | |
| let inset = drawn - Double(DSGuide.width(true)) / 2 | |
| report(abs(inset - radius) > 1e-9, | |
| String(format: " and the check discriminates: an inset rim would fall %.1f %% " | |
| + "short of the pixels it bounds", 100 * (radius - inset) / radius)) | |
| guard let plain = speckled(offset: nil), | |
| let flat = read(plain, at: rimCentre, in: mode, radius: radius), | |
| let straddle = rimStraddle(radius: radius) else { | |
| report(false, String(format: "r=%.1f: the rim is not measurable here", radius)) | |
| continue | |
| } | |
| // Exact, unlike πr²: a box a pixel short, or a centre off by half a one, changes the | |
| // count — which is what a disc of five pixels would let an area tolerance hide. | |
| let held = Frame.discOffsets(radius: radius).count | |
| report(flat.pixels == held, | |
| String(format: " the disc holds %d px, the membership test's own count, where " | |
| + "πr² only asks for %.0f", flat.pixels, Double.pi * radius * radius)) | |
| func moved(_ offset: (x: Int, y: Int), from centre: CGPoint = rimCentre) -> Float? { | |
| guard let image = speckled(offset: offset), | |
| let soiled = read(image, at: centre, in: mode, radius: radius) | |
| else { return nil } | |
| return abs(soiled.fractions.x - flat.fractions.x) | |
| } | |
| let dIn = Double(straddle.inside.x * straddle.inside.x | |
| + straddle.inside.y * straddle.inside.y).squareRoot() | |
| let dOut = Double(straddle.outside.x * straddle.outside.x | |
| + straddle.outside.y * straddle.outside.y).squareRoot() | |
| guard let inside = moved(straddle.inside), let outside = moved(straddle.outside) else { | |
| report(false, String(format: "r=%.1f: the rim probes rendered nothing", radius)) | |
| continue | |
| } | |
| report(inside > 0, | |
| String(format: " a speck %.4f px INSIDE the rim moves the reading by %.6f of " | |
| + "window", radius - dIn, inside)) | |
| report(outside == 0, | |
| String(format: " and one %.4f px OUTSIDE it moves it by %.6f — the ring the " | |
| + "hand aims with is the sample", dOut - radius, outside)) | |
| // The twin: the same inside speck against a centre displaced half a pixel. It must fall | |
| // out, so a half-pixel error between what is drawn and what is read cannot pass unseen. | |
| let halfOff = CGPoint(x: (rimCentre.x * extent.width - 0.5) / extent.width, | |
| y: rimCentre.y) | |
| let slipInside = moved(straddle.inside, from: halfOff) | |
| report(slipInside == 0, | |
| String(format: " and the check discriminates: half a pixel of slip in the " | |
| + "centre drops that speck out of the sample (%.6f)", slipInside ?? -1)) | |
| } | |
| return lines | |
| } | |
| /// Centre of the synthetic frame's disc, on a pixel centre so the probe offsets are exact | |
| /// integers and the rim's arithmetic is the membership test's own. | |
| static let rimCentre = CGPoint(x: 700.5 / 1400, y: 466.5 / 933) | |
| /// A flat frame carrying at most one aberrant pixel, at a given offset from the disc's centre. | |
| /// A disc being symmetric, which end of the bitmap holds the top cannot change the distance. | |
| private static func speckled(offset: (x: Int, y: Int)?) -> CIImage? { | |
| let width = 1400, height = 933 | |
| let centre = (x: 700, y: 466) | |
| var pixels = [Float](repeating: 0, count: width * height * 4) | |
| for i in 0..<(width * height) { | |
| pixels[i * 4] = 0.5; pixels[i * 4 + 1] = 0.5 | |
| pixels[i * 4 + 2] = 0.5; pixels[i * 4 + 3] = 1 | |
| } | |
| if let offset { | |
| let i = ((centre.y + offset.y) * width + centre.x + offset.x) * 4 | |
| pixels[i] = 1; pixels[i + 1] = 1; pixels[i + 2] = 1 | |
| } | |
| return CIImage(bitmapData: Data(bytes: pixels, count: pixels.count * 4), | |
| bytesPerRow: width * 16, | |
| size: CGSize(width: width, height: height), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| /// Where the canvas layer sits, read as text: nothing rendered denounces a catcher mounted | |
| /// above the navigation, and the cost is a locked zoom rather than a wrong pixel. | |
| private static func wiringReport(_ ok: inout Bool) -> [String] { | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| guard let text = SourceFile.text("Sources/OpenNegative/UI/NegativeView.swift") else { | |
| return [" SKIPPED source absent, the canvas layer's order is not readable here"] | |
| } | |
| let real = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| let order = layering(in: real) | |
| report(order.picker && order.catcher && order.pickerFirst, | |
| "the picking layer is mounted BEFORE the navigation catcher, which is what leaves " | |
| + "the wheel and the pinch reaching a view that answers them") | |
| let wrong = layering(in: adverseLayering) | |
| report(wrong.picker && wrong.catcher && !wrong.pickerFirst, | |
| "and the check discriminates: the same two layers in the other order are refused") | |
| guard let scene = SourceFile.text("Sources/OpenNegative/OpenNegativeApp.swift") else { | |
| return lines + [" SKIPPED the scene is not readable here"] | |
| } | |
| let sizes = radiusArguments(in: scene) | |
| report(sizes.drawn != nil && sizes.drawn == sizes.read, | |
| "the ring's radius and the reading's are one expression, \(sizes.drawn ?? "absent") " | |
| + "— an adjustable radius drawing one disc and sampling another shows nothing") | |
| let adverse = radiusArguments(in: adverseRadii) | |
| report(adverse.drawn != nil && adverse.read != nil && adverse.drawn != adverse.read, | |
| "and the check discriminates: a scene passing the preference to one and the resting " | |
| + "value to the other is refused") | |
| return lines | |
| } | |
| /// What the two calls are handed as a radius: one draws the ring, the other aggregates the | |
| /// disc, and nothing rendered denounces two different sizes. | |
| private static func radiusArguments(in text: String) -> (drawn: String?, read: String?) { | |
| func argument(after call: String) -> String? { | |
| guard let start = text.range(of: call) else { return nil } | |
| let tail = text[start.upperBound...] | |
| guard let label = tail.range(of: "radius:") else { return nil } | |
| let rest = tail[label.upperBound...] | |
| guard let end = rest.firstIndex(where: { $0 == ")" || $0 == "," || $0 == "\n" }) | |
| else { return nil } | |
| return rest[..<end].trimmingCharacters(in: .whitespaces) | |
| } | |
| return (argument(after: "disc" + "Width(on:"), argument(after: "Pipette." + "read(")) | |
| } | |
| /// The scene as it must not be: the preference reaching the reading while the ring keeps the | |
| /// resting size, which draws a disc the click does not sample. | |
| private static var adverseRadii: String { | |
| "disc" + "Width(on: extent, radius: Pipette.defaultRadius)\n" | |
| + "Pipette." + "read(source, at: unit, in: mode, radius: Pipette.radius(pickRadius))" | |
| } | |
| /// True positions of the two canvas layers among the overlays, comments excluded so a mention | |
| /// in prose cannot stand in for the wiring. | |
| private static func layering(in lines: [String]) -> (picker: Bool, catcher: Bool, | |
| pickerFirst: Bool) { | |
| let over = "over" + "lay" | |
| let picker = "pipette" + "Layer" | |
| let catcher = "Navigation" + "Catcher(" | |
| let code = lines.compactMap { line -> String? in | |
| let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| return trimmed.hasPrefix("//") ? nil : trimmed | |
| } | |
| let atPicker = code.firstIndex { $0.contains(over) && $0.contains(picker) } | |
| let atCatcher = code.firstIndex { $0.contains(catcher) } | |
| guard let atPicker, let atCatcher else { | |
| return (atPicker != nil, atCatcher != nil, false) | |
| } | |
| return (true, true, atPicker < atCatcher) | |
| } | |
| /// The file as it must not be: the picking layer laid over the catcher, which routes the wheel | |
| /// into a view that ignores it. | |
| private static var adverseLayering: [String] { | |
| [".over" + "lay { Navigation" + "Catcher(onPan: {}, onZoom: { _, _ in }) }", | |
| ".over" + "lay { pipette" + "Layer }"] | |
| } | |
| } | |
| // MARK: - The three questions the gesture cannot answer without a film | |
| extension Pipette { | |
| /// The placement on a real frame, against what a button proposes at the same spot. Measures the | |
| /// three open questions and holds each answer against the figure that dictates it. | |
| static func selfCheck(source: URL) -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| let mode = ConversionMode.negative | |
| var settings = PipelineSettings() | |
| settings.mode = mode | |
| guard let decoded = RawDecode.linear(source, longestSide: Negative.measureSide) else { | |
| return (false, " FAIL \(source.lastPathComponent) undecodable") | |
| } | |
| let fullWidth = RawDecode.pixelSize(of: source)?.width | |
| let image = graduated(decoded, settings: settings, fullWidth: fullWidth) | |
| guard let frame = Frame(image) else { | |
| return (false, " FAIL \(source.lastPathComponent) rendered no measurable frame") | |
| } | |
| lines.append(" ---- \(source.lastPathComponent), \(frame.width)×\(frame.height) " | |
| + "measuring grid") | |
| // MARK: The reading is taken before the levels, so the gesture is not circular | |
| var moved = settings | |
| for set in LevelsChannel.perChannel { | |
| moved.levels[set] = Levels(black: 0.42, white: 2.31, mid: 0.61) | |
| } | |
| let unit = CGPoint(x: 0.5, y: 0.5) | |
| let atRest = read(image, at: unit, in: mode, radius: defaultRadius) | |
| let afterHandles = read(graduated(decoded, settings: moved, fullWidth: fullWidth), | |
| at: unit, in: mode, radius: defaultRadius) | |
| if let atRest, let afterHandles { | |
| let drift = (0..<3).map { abs(atRest.fractions[$0] - afterHandles.fractions[$0]) }.max() ?? 1 | |
| report(drift < 1e-6, | |
| String(format: "moving all three windows leaves the reading where it was " | |
| + "(%.7f of window), so a second pick cannot answer the first", | |
| Double(drift))) | |
| // Adverse by construction: the same disc read AFTER the levels, which is the circular | |
| // wiring this constraint exists to refuse. | |
| let downstream = Pipeline.measured(of: decoded, settings: moved, before: .curves, | |
| fullWidth: fullWidth) | |
| let restDownstream = Pipeline.measured(of: decoded, settings: settings, before: .curves, | |
| fullWidth: fullWidth) | |
| if let a = read(downstream, at: unit, in: mode, radius: defaultRadius), | |
| let b = read(restDownstream, at: unit, in: mode, radius: defaultRadius) { | |
| let circular = (0..<3).map { abs(a.fractions[$0] - b.fractions[$0]) }.max() ?? 0 | |
| report(circular > 1e-3, | |
| String(format: "and the check discriminates: read after the levels the same " | |
| + "disc moves %.4f of window under those handles", Double(circular))) | |
| } | |
| } else { | |
| report(false, "the centre of the frame yielded no reading") | |
| } | |
| // MARK: 1 — the space, measured on grain rather than argued from Jensen | |
| // The arbiter needs a well-estimated MEDIAN, so it reads the widest disc the TRACK offers. | |
| // The choice it settles is the population's, and it holds at every radius the slider dials. | |
| let arbiterRadius = radiusRange.upperBound | |
| let probes = frame.probes(radius: defaultRadius, count: 48) | |
| let wide = frame.probes(radius: arbiterRadius, count: 48) | |
| var gaps: [Float] = [] | |
| // The arbiter: on a log-normal population the mean of the logs sits on the median, so | |
| // whichever mean lands nearer the median is the one reading the grain's own centre. | |
| var nearer = 0, offDensity: Float = 0, offTransmittance: Float = 0 | |
| for centre in wide { | |
| let column = frame.samples(at: centre, radius: arbiterRadius) | |
| guard let d = aggregate(column, in: mode, space: .density, statistic: .mean), | |
| let t = aggregate(column, in: mode, space: .transmittance, statistic: .mean), | |
| let m = aggregate(column, in: mode, statistic: .median) | |
| else { continue } | |
| gaps.append((0..<3).map { abs(d[$0] - t[$0]) }.max() ?? 0) | |
| let dm = (0..<3).map { abs(d[$0] - m[$0]) }.max() ?? 0 | |
| let tm = (0..<3).map { abs(t[$0] - m[$0]) }.max() ?? 0 | |
| offDensity += dm | |
| offTransmittance += tm | |
| if dm < tm { nearer += 1 } | |
| } | |
| let worstGap = gaps.max() ?? 0 | |
| let meanGap = gaps.isEmpty ? 0 : gaps.reduce(0, +) / Float(gaps.count) | |
| lines.append(String(format: | |
| " ---- space: over %d discs of r=%.0f, the density mean and the transmittance mean " | |
| + "part by %.4f of window on average and %.4f at worst — %.4f and %.4f of density, " | |
| + "%.3f and %.3f stop", gaps.count, arbiterRadius, meanGap, worstGap, | |
| meanGap * Graduation.span, worstGap * Graduation.span, | |
| meanGap * Graduation.span * log2(Float(10)), | |
| worstGap * Graduation.span * log2(Float(10)))) | |
| report(worstGap > 0, | |
| "the two spaces really do part on this film's grain, so the retained one is a " | |
| + "measured choice and not a formality") | |
| // A sign test, disc by disc, and never the averaged distance: two discs straddling an edge | |
| // carry gaps ten times the rest and would decide a mean of distances on their own. | |
| let count = max(gaps.count, 1) | |
| report(nearer * 2 > gaps.count, | |
| String(format: "and the density mean is the one that lands on the grain's own " | |
| + "centre, as a log-normal population owes: nearer the median on %d discs of " | |
| + "%d, %.4f of window away on average against %.4f", | |
| nearer, gaps.count, offDensity / Float(count), | |
| offTransmittance / Float(count))) | |
| // MARK: 2 — what a speck costs the mean, stated rather than arbitrating | |
| for share in dustShares { | |
| var moved: [Float] = [] | |
| for centre in probes { | |
| let column = frame.samples(at: centre, radius: defaultRadius) | |
| guard let clean = aggregate(column, in: mode), | |
| // A mote is opaque, hence the top of the window: what a black pick fears. | |
| let dirty = aggregate(Frame.soiled(column, share: share, with: 1), in: mode) | |
| else { continue } | |
| moved.append((0..<3).map { abs(clean[$0] - dirty[$0]) }.max() ?? 0) | |
| } | |
| let m = moved.isEmpty ? 0 : moved.reduce(0, +) / Float(moved.count) | |
| lines.append(String(format: | |
| " ---- a speck over %.0f %% of the disc (%d px of %d) moves the mean %.4f of " | |
| + "window, %.3f stop", share * 100, | |
| Int((Float(frame.samples(at: probes[0], radius: defaultRadius).count) * share).rounded()), | |
| frame.samples(at: probes[0], radius: defaultRadius).count, m, | |
| m * Graduation.span * log2(Float(10)))) | |
| } | |
| // The matter the film really carries, not an injection: how far the reading moves once the | |
| // brightest and darkest hundredth of each disc is dropped. | |
| var real: [Float] = [] | |
| for centre in probes { | |
| let column = frame.samples(at: centre, radius: defaultRadius) | |
| guard let whole = aggregate(column, in: mode), | |
| let trimmed = aggregate(Frame.trimmed(column, share: 0.01), in: mode) | |
| else { continue } | |
| real.append((0..<3).map { abs(whole[$0] - trimmed[$0]) }.max() ?? 0) | |
| } | |
| let worstReal = real.max() ?? 0 | |
| lines.append(String(format: | |
| " ---- real matter: dropping the outer 1 %% of each disc moves the reading %.4f of " | |
| + "window on average and %.4f at worst, %.3f stop", | |
| real.isEmpty ? 0 : real.reduce(0, +) / Float(real.count), worstReal, | |
| worstReal * Graduation.span * log2(Float(10)))) | |
| // MARK: 3 — the radius, between the grain that rises under it and the aim that dies over it | |
| // One zone for the whole sweep, flat over the widest lattice any radius lays on it, or a | |
| // patch re-elected per radius agrees with itself and the curve says nothing. | |
| let coarsest = sweptRadii.max() ?? defaultRadius | |
| let span = CGFloat(3 * (Int(2 * coarsest) + 1)) / 2 | |
| let anchors = frame.flattest(reference: span, reach: Int(span) + 1, count: 8) | |
| // The frame's own ends, which no disc reaches exactly: the third cost, the one a grain | |
| // statistic cannot see and the only one a hand pays for by not fitting the zone. | |
| let ends = frame.ends(quantile: endQuantile) | |
| var curve: [(radius: CGFloat, cost: Float)] = [] | |
| for r in sweptRadii { | |
| guard let spread = frame.spread(radius: r, at: anchors, step: tremor, in: mode), | |
| let speck = frame.speckCost(radius: r, at: anchors, in: mode), | |
| let got = frame.reach(radius: r) else { continue } | |
| let short = max(got.darkest - ends.dark, 0) + max(ends.bright - got.brightest, 0) | |
| curve.append((r, spread.mean + speck + short)) | |
| lines.append(String(format: | |
| " ---- r=%.1f (%d px, %.2f %% of width): a %d px nudge moves the reading %.5f of " | |
| + "window, a %d px mote moves it %.5f, and the darkest and brightest discs fall " | |
| + "%.5f short of the frame's own ends — %.5f together", r, | |
| Frame.discOffsets(radius: r).count, 100 * widthShare(ofRadius: r), tremor, | |
| spread.mean, speckPixels, speck, short, spread.mean + speck + short)) | |
| } | |
| // The track is where the gesture stays within a factor of the best it can do. Under it a | |
| // mote nobody sees rules the reading; over it the disc no longer fits the zone aimed at. | |
| if let best = curve.min(by: { $0.cost < $1.cost }), | |
| let floor = curve.first(where: { $0.radius == radiusRange.lowerBound }), | |
| let ceiling = curve.first(where: { $0.radius == radiusRange.upperBound }), | |
| let resting = curve.first(where: { $0.radius == defaultRadius }) { | |
| let bar = best.cost * trackCeiling | |
| let inside = curve.filter { $0.cost <= bar } | |
| lines.append(String(format: | |
| " ---- best at r=%.1f (%.5f of window); within %.0f× of it the sweep keeps " | |
| + "r=%.1f…%.1f. The floor r=%.0f costs %.5f, the resting %.0f costs %.5f (%.2f× " | |
| + "the best), the ceiling r=%.0f costs %.5f", best.radius, best.cost, trackCeiling, | |
| inside.first?.radius ?? 0, inside.last?.radius ?? 0, | |
| floor.radius, floor.cost, resting.radius, resting.cost, | |
| resting.cost / max(best.cost, 1e-9), ceiling.radius, ceiling.cost)) | |
| // What a TRACK owes, and the resting point is not it: the slider has to be able to | |
| // reach the cheapest reading this film asks for, whichever radius that turns out to be. | |
| report(radiusRange.contains(best.radius), | |
| String(format: "the track reaches this film's own cheapest radius, r=%.1f, " | |
| + "inside %.0f…%.0f", best.radius, radiusRange.lowerBound, | |
| radiusRange.upperBound)) | |
| // Adverse by construction: the sweep runs from 1 to 32, wider than the track at both | |
| // ends, so a film asking for a radius the slider cannot dial turns this red. | |
| report((sweptRadii.first ?? 0) < radiusRange.lowerBound | |
| && (sweptRadii.last ?? 0) > radiusRange.upperBound, | |
| String(format: "and the check discriminates: the sweep runs %.0f…%.0f, outside " | |
| + "the track at both ends", sweptRadii.first ?? 0, sweptRadii.last ?? 0)) | |
| // The two ends are the two costs, each read where it rules: neither may be the cheapest | |
| // place on the sweep, or the track would be pointing away from what it is for. | |
| report(floor.cost >= best.cost && ceiling.cost >= best.cost, | |
| String(format: "and both ends cost more than that best (%.5f at %.0f and %.5f " | |
| + "at %.0f against %.5f), so the track brackets it rather than sitting " | |
| + "to one side", floor.cost, floor.radius, ceiling.cost, ceiling.radius, | |
| best.cost)) | |
| } else { | |
| lines.append(" ---- the frame hosts no lattice at the swept radii; the track is not " | |
| + "priced here") | |
| } | |
| // MARK: What a pick gives against what Classic proposes at the same spot | |
| let histogram = Pipeline.histogram(of: decoded, settings: settings, before: .levels, | |
| fullWidth: fullWidth) | |
| guard let classic = AutoLevels.placement(histogram, threshold: AutoLevels.defaultThreshold, | |
| method: .classic) else { | |
| report(false, "Classic proposed nothing on this frame") | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| let darkest = frame.extremeProbe(radius: defaultRadius, brightest: false) | |
| let brightest = frame.extremeProbe(radius: defaultRadius, brightest: true) | |
| var picks: [Target: SIMD3<Float>] = [:] | |
| for (target, centre) in [(Target.black, darkest), (Target.white, brightest)] { | |
| guard let centre, | |
| let picked = aggregate(frame.samples(at: centre, radius: defaultRadius), in: mode) | |
| else { continue } | |
| picks[target] = picked | |
| let proposed = (0..<3).map { target == .black ? classic[$0].black : classic[$0].white } | |
| let gap = (0..<3).map { abs(picked[$0] - proposed[$0]) } | |
| lines.append(String(format: | |
| " ---- %@ picked at %.2f, %.2f reads R %.4f G %.4f B %.4f, where Classic places " | |
| + "R %.4f G %.4f B %.4f — apart by %.4f / %.4f / %.4f of window", | |
| target.rawValue, centre.x / Double(frame.width), centre.y / Double(frame.height), | |
| picked[0], picked[1], picked[2], proposed[0], proposed[1], proposed[2], | |
| gap[0], gap[1], gap[2])) | |
| } | |
| report(true, "the pick and Classic are stated side by side on this film") | |
| // MARK: 4 — the margin, on the track against the gap, on this film's own two picks | |
| if let low = picks[.black], let high = picks[.white] { | |
| for channel in 0..<3 { | |
| let b = Graduation.value(atFraction: low[channel], in: mode) | |
| let w = Graduation.value(atFraction: high[channel], in: mode) | |
| let gap = margin * (w - b) | |
| lines.append(String(format: | |
| " ---- margin on channel %d: picks %.4f…%.4f. On the track %.4f…%.4f, held to " | |
| + "%.4f…%.4f; on the gap %.4f…%.4f — the track's is %.2f× the gap's", | |
| channel, b, w, b - marginDensity, w + marginDensity, | |
| placed(b, for: .black, in: mode), placed(w, for: .white, in: mode), | |
| b - gap, w + gap, gap > 0 ? marginDensity / gap : 0)) | |
| } | |
| // The one way the track's margin can hurt: two picks nearer than two margins would | |
| // cross. Stated as a number rather than assumed away. | |
| func density(_ v: SIMD3<Float>, _ c: Int) -> Float { | |
| Graduation.value(atFraction: v[c], in: mode) | |
| } | |
| let closest = (0..<3).map { density(high, $0) - density(low, $0) }.min() ?? 0 | |
| report(closest > 0, | |
| String(format: "the two picks stand %.4f of density apart at their closest, " | |
| + "against two margins of %.4f — %@", closest, 2 * marginDensity, | |
| closest > 2 * marginDensity ? "the window keeps its sense" | |
| : "the guard is what holds it open")) | |
| } | |
| // MARK: 5 — the neutral key against Mids, on the flattest mid-tone the frame carries | |
| if let aim = frame.neutralProbe(radius: defaultRadius, among: anchors), | |
| let picked = aggregate(frame.samples(at: aim, radius: defaultRadius), in: mode), | |
| let mids = AutoLevels.placement(histogram, threshold: AutoLevels.defaultThreshold, | |
| method: .mids) { | |
| // Read against the ends a button would have placed, or the two answers are compared | |
| // through different windows and the gap measures the windows. | |
| var rest = settings | |
| for (channel, set) in LevelsChannel.perChannel.enumerated() { | |
| rest.levels[set] = AutoLevels.placed(classic[channel], in: mode) | |
| } | |
| let after = applied(Reading(fractions: picked, pixels: 0), to: .neutral, in: rest) | |
| let placedMids = (0..<3).map { AutoLevels.placed(mids[$0], in: mode).mid } | |
| let gaps = (0..<3).map { | |
| abs(after.levels[LevelsChannel.perChannel[$0]].mid - placedMids[$0]) | |
| } | |
| lines.append(String(format: | |
| " ---- neutral picked at %.2f, %.2f puts the medians at R %.4f G %.4f B %.4f " | |
| + "where Mids guesses R %.4f G %.4f B %.4f — apart by %.4f / %.4f / %.4f", | |
| aim.x / Double(frame.width), aim.y / Double(frame.height), | |
| after.levels.red.mid, after.levels.green.mid, after.levels.blue.mid, | |
| placedMids[0], placedMids[1], placedMids[2], gaps[0], gaps[1], gaps[2])) | |
| report(after.levels.green.mid == rest.levels.green.mid, | |
| "and green's median stands still under it, as the reference channel must") | |
| } | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| // MARK: - The frame held in memory, so a sweep costs one render | |
| extension Pipette { | |
| /// The graduated frame as floats. A sweep asks for thousands of discs, and a render each would | |
| /// price the measurement rather than the gesture. | |
| struct Frame { | |
| let width: Int | |
| let height: Int | |
| private let pixels: [Float] | |
| init?(_ image: CIImage) { | |
| let extent = image.extent | |
| guard extent.width >= 64, extent.height >= 64 else { return nil } | |
| let w = Int(extent.width), h = Int(extent.height) | |
| width = w | |
| height = h | |
| var buffer = [Float](repeating: 0, count: w * h * 4) | |
| buffer.withUnsafeMutableBytes { raw in | |
| guard let base = raw.baseAddress else { return } | |
| Pipeline.measureContext.render(image, toBitmap: base, rowBytes: w * 16, | |
| bounds: extent, format: .RGBAf, colorSpace: nil) | |
| } | |
| pixels = buffer | |
| } | |
| /// The disc's samples, in the buffer's own coordinates — which end holds the top of the | |
| /// frame cannot matter, a disc being symmetric about its centre. | |
| func samples(at centre: CGPoint, radius: CGFloat) -> [SIMD3<Float>] { | |
| let r = Int(radius.rounded(.up)) | |
| let cx = Int(centre.x), cy = Int(centre.y) | |
| let r2 = Float(radius * radius) | |
| var out: [SIMD3<Float>] = [] | |
| out.reserveCapacity((2 * r + 1) * (2 * r + 1)) | |
| for row in max(cy - r, 0)...min(cy + r, height - 1) { | |
| let dy = Float(row - cy) | |
| for column in max(cx - r, 0)...min(cx + r, width - 1) { | |
| let dx = Float(column - cx) | |
| guard dx * dx + dy * dy <= r2 else { continue } | |
| let i = (row * width + column) * 4 | |
| out.append(SIMD3(pixels[i], pixels[i + 1], pixels[i + 2])) | |
| } | |
| } | |
| return out | |
| } | |
| /// Disc centres spread over the frame on a fixed lattice, so the figures a run prints can | |
| /// be compared against the next run's on the same film. | |
| func probes(radius: CGFloat, count: Int) -> [CGPoint] { | |
| let margin = Int(radius.rounded(.up)) + 1 | |
| guard width > 2 * margin, height > 2 * margin, count > 0 else { return [] } | |
| let columns = Int(Double(count).squareRoot().rounded(.up)) | |
| let rows = max(count / columns, 1) | |
| var out: [CGPoint] = [] | |
| for row in 0..<rows { | |
| for column in 0..<columns where out.count < count { | |
| let x = margin + (width - 2 * margin) * column / max(columns - 1, 1) | |
| let y = margin + (height - 2 * margin) * row / max(rows - 1, 1) | |
| out.append(CGPoint(x: x, y: y)) | |
| } | |
| } | |
| return out | |
| } | |
| /// The darkest or the brightest disc of the frame, which is where a hand would aim: the | |
| /// comparison against a button is only worth reading on a spot a person would pick. | |
| func extremeProbe(radius: CGFloat, brightest: Bool) -> CGPoint? { | |
| var best: (point: CGPoint, value: Float)? | |
| for centre in probes(radius: radius, count: 240) { | |
| let column = samples(at: centre, radius: radius) | |
| guard !column.isEmpty else { continue } | |
| let value = column.reduce(Float(0)) { $0 + $1.y } / Float(column.count) | |
| if best == nil || (brightest ? value > best!.value : value < best!.value) { | |
| best = (centre, value) | |
| } | |
| } | |
| return best?.point | |
| } | |
| /// The flattest mid-tone patch, which is what a wall or a chart looks like to a measure. | |
| /// A stand-in for the aim: whether a surface is MEANT to be neutral is not measurable. | |
| func neutralProbe(radius: CGFloat, among anchors: [CGPoint]) -> CGPoint? { | |
| var best: (point: CGPoint, off: Float)? | |
| for centre in anchors { | |
| let column = samples(at: centre, radius: radius) | |
| guard !column.isEmpty else { continue } | |
| let level = column.reduce(Float(0)) { $0 + $1.y } / Float(column.count) | |
| let off = abs(level - 0.5) | |
| if best == nil || off < best!.off { best = (centre, off) } | |
| } | |
| return best?.point | |
| } | |
| /// How far the reading moves when the pointer is nudged. The step is FIXED, or the lattice | |
| /// widens with the radius and the patch's own gradient is read back as grain. | |
| func spread(radius: CGFloat, at anchors: [CGPoint], step: Int, | |
| in mode: ConversionMode) -> (mean: Float, median: Float)? { | |
| var means: [Float] = [], medians: [Float] = [] | |
| for anchor in anchors { | |
| var byMean: [Float] = [], byMedian: [Float] = [] | |
| for dy in -1...1 { | |
| for dx in -1...1 { | |
| let centre = CGPoint(x: anchor.x + Double(dx * step), | |
| y: anchor.y + Double(dy * step)) | |
| guard centre.x >= radius, centre.y >= radius, | |
| centre.x < Double(width) - radius, | |
| centre.y < Double(height) - radius else { continue } | |
| let column = samples(at: centre, radius: radius) | |
| guard let m = aggregate(column, in: mode, statistic: .mean), | |
| let d = aggregate(column, in: mode, statistic: .median) | |
| else { continue } | |
| byMean.append(m.y) | |
| byMedian.append(d.y) | |
| } | |
| } | |
| guard byMean.count == 9 else { continue } | |
| means.append(deviation(byMean)) | |
| medians.append(deviation(byMedian)) | |
| } | |
| guard !means.isEmpty else { return nil } | |
| return (means.reduce(0, +) / Float(means.count), | |
| medians.reduce(0, +) / Float(medians.count)) | |
| } | |
| /// What a mote of a FIXED size costs at a given radius: the mean's one weakness, which | |
| /// falls as the disc grows exactly where the aim's cost rises. | |
| func speckCost(radius: CGFloat, at anchors: [CGPoint], | |
| in mode: ConversionMode) -> Float? { | |
| var moved: [Float] = [] | |
| for anchor in anchors { | |
| let column = samples(at: anchor, radius: radius) | |
| guard let clean = aggregate(column, in: mode), | |
| let dirty = aggregate(Self.soiled(column, count: Pipette.speckPixels, | |
| with: 1), in: mode) else { continue } | |
| moved.append((0..<3).map { abs(clean[$0] - dirty[$0]) }.max() ?? 0) | |
| } | |
| guard !moved.isEmpty else { return nil } | |
| return moved.reduce(0, +) / Float(moved.count) | |
| } | |
| /// Offsets of a disc's pixels, on the membership test `read` runs. Computed once, so a | |
| /// lattice over the whole frame costs no allocation per centre. | |
| static func discOffsets(radius: CGFloat) -> [(dx: Int, dy: Int)] { | |
| let reach = Int(radius.rounded(.down)) | |
| let r2 = Float(radius * radius) | |
| var out: [(dx: Int, dy: Int)] = [] | |
| for dy in -reach...reach { | |
| for dx in -reach...reach where Float(dx * dx + dy * dy) <= r2 { | |
| out.append((dx, dy)) | |
| } | |
| } | |
| return out | |
| } | |
| /// The frame's own ends on green: the matter a pick aims at, which a disc too wide to fit | |
| /// the zone holding it cannot reach. | |
| func ends(quantile: Float) -> (dark: Float, bright: Float) { | |
| var green = [Float](repeating: 0, count: width * height) | |
| for i in 0..<(width * height) { green[i] = pixels[i * 4 + 1] } | |
| green.sort() | |
| let low = min(Int(Float(green.count - 1) * quantile), (green.count - 1) / 2) | |
| return (green[low], green[green.count - 1 - low]) | |
| } | |
| /// The darkest and brightest a disc of this radius reads ANYWHERE on the frame — the cost | |
| /// the aim pays, which no grain statistic sees and which rises with the radius. | |
| func reach(radius: CGFloat) -> (darkest: Float, brightest: Float)? { | |
| let offsets = Self.discOffsets(radius: radius) | |
| let margin = Int(radius.rounded(.up)) | |
| guard !offsets.isEmpty, width > 2 * margin, height > 2 * margin else { return nil } | |
| // Half a radius, so a zone of the disc's own size cannot fall between two centres. | |
| let step = max(Int((radius / 2).rounded()), 1) | |
| let scale = 1 / Float(offsets.count) | |
| var darkest = Float.greatestFiniteMagnitude | |
| var brightest = -Float.greatestFiniteMagnitude | |
| var y = margin | |
| while y < height - margin { | |
| var x = margin | |
| while x < width - margin { | |
| var sum: Float = 0 | |
| // The graduated signal is affine in density, so this plain mean IS the density | |
| // mean `aggregate` returns, without its per-channel allocation. | |
| for offset in offsets { | |
| sum += pixels[((y + offset.dy) * width + x + offset.dx) * 4 + 1] | |
| } | |
| let mean = sum * scale | |
| darkest = min(darkest, mean) | |
| brightest = max(brightest, mean) | |
| x += step | |
| } | |
| y += step | |
| } | |
| return darkest <= brightest ? (darkest, brightest) : nil | |
| } | |
| /// Where a lattice of nine discs sits on matter flat at the coarsest scale swept, so the | |
| /// rise at the far end is the subject and not the choice of zone. | |
| func flattest(reference: CGFloat, reach: Int, count: Int) -> [CGPoint] { | |
| var scored: [(point: CGPoint, roughness: Float)] = [] | |
| var y = reach | |
| while y < height - reach { | |
| var x = reach | |
| while x < width - reach { | |
| let column = samples(at: CGPoint(x: x, y: y), radius: reference) | |
| let values = column.map { $0.y } | |
| let level = values.reduce(0, +) / Float(max(values.count, 1)) | |
| // Piled matter is flat for a reason no radius reaches, and electing it would | |
| // report a stable reading that says nothing about grain. | |
| if values.count > 4, level > 0.05, level < 0.95 { | |
| scored.append((CGPoint(x: x, y: y), deviation(values))) | |
| } | |
| x += Int(reference) | |
| } | |
| y += Int(reference) | |
| } | |
| return scored.sorted { $0.roughness < $1.roughness }.prefix(count).map(\.point) | |
| } | |
| /// A speck laid over a share of the disc, at the top of the window where an opaque mote | |
| /// lands. Injected rather than hunted for, so the price is the same figure on every film. | |
| static func soiled(_ column: [SIMD3<Float>], share: Float, | |
| with value: Float) -> [SIMD3<Float>] { | |
| soiled(column, count: Int((Float(column.count) * share).rounded()), with: value) | |
| } | |
| /// The same speck stated in pixels, which is what a mote of dust is: a fixed patch of the | |
| /// film, whatever the disc laid over it. | |
| static func soiled(_ column: [SIMD3<Float>], count asked: Int, | |
| with value: Float) -> [SIMD3<Float>] { | |
| var out = column | |
| let count = min(asked, column.count) | |
| guard count > 0 else { return out } | |
| // Contiguous, since a mote is one patch and not salt sprinkled across the disc. | |
| for i in 0..<count { out[i] = SIMD3(repeating: value) } | |
| return out | |
| } | |
| /// The disc with its outer share dropped at both ends, channel by channel: what the film | |
| /// really carries at the edges, against what an injected speck stands for. | |
| static func trimmed(_ column: [SIMD3<Float>], share: Float) -> [SIMD3<Float>] { | |
| let cut = Int((Float(column.count) * share).rounded()) | |
| guard cut > 0, column.count > 4 * cut else { return column } | |
| var out = [SIMD3<Float>](repeating: .zero, count: column.count - 2 * cut) | |
| for channel in 0..<3 { | |
| var sorted = column.map { $0[channel] } | |
| sorted.sort() | |
| for (i, value) in sorted[cut..<(sorted.count - cut)].enumerated() { | |
| out[i][channel] = value | |
| } | |
| } | |
| return out | |
| } | |
| } | |
| /// Standard deviation of a small sample, which is what "moves from one disc to the next" is. | |
| static func deviation(_ values: [Float]) -> Float { | |
| guard values.count > 1 else { return 0 } | |
| let mean = values.reduce(0, +) / Float(values.count) | |
| let sse = values.reduce(Float(0)) { $0 + ($1 - mean) * ($1 - mean) } | |
| return (sse / Float(values.count - 1)).squareRoot() | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| import ImageIO | |
| import UniformTypeIdentifiers | |
| /// Pipeline stage 1: linear RAW decoding, with no rendering curve. | |
| enum RawDecode { | |
| // Wide primaries: stage 4's per-channel gains are strong enough to clip on a narrow profile | |
| // before export. | |
| static let workingSpace = CGColorSpace(name: CGColorSpace.extendedLinearITUR_2020)! | |
| // Neutral, mode-agnostic, and the same for every film stock: no mask or base colour is | |
| // assumed here — AutoLevels' own per-channel gains realign whatever a decode leaves. | |
| static let temperature: Float = 5000 | |
| static let tint: Float = 0 | |
| /// True for a camera RAW, false for a TIFF, JPEG, PNG, or HEIC. | |
| static func isRaw(_ url: URL) -> Bool { | |
| UTType(filenameExtension: url.pathExtension)?.conforms(to: .rawImage) ?? false | |
| } | |
| /// True exactly where `linear` would route through `rawLinear` — the one branch reading | |
| /// `temperature`. Shared with the settings migration, so the two routings cannot drift apart. | |
| static func decodesViaMosaic(_ url: URL) -> Bool { | |
| switch ContainerRead.probe(url) { | |
| case .linearRGB: false | |
| case .mosaic: true | |
| case .unsupportedTIFF, nil: isRaw(url) | |
| } | |
| } | |
| /// Decodes to scene-linear values in the working space, whatever the source format. | |
| /// Anything not claimed as a mosaic or a linear-RGB container falls back to `isRaw`. | |
| static func linear(_ url: URL, longestSide: CGFloat? = nil) -> CIImage? { | |
| let decoded: CIImage? | |
| switch ContainerRead.probe(url) { | |
| case .linearRGB: | |
| decoded = ContainerRead.linear(url, longestSide: longestSide) | |
| case .mosaic: | |
| decoded = rawLinear(url, longestSide: longestSide) | |
| case .unsupportedTIFF, nil: | |
| decoded = isRaw(url) ? rawLinear(url, longestSide: longestSide) | |
| : plainLinear(url, longestSide: longestSide) | |
| } | |
| // Only a reduced decode has the flaw, and only it can afford the pixel: a full-resolution | |
| // frame is what the file says, edge included. | |
| return longestSide == nil ? decoded : decoded.map(rebuildingEdge) | |
| } | |
| /// A scaled decode darkens its outermost row and column, the resampler covering less than a | |
| /// source pixel there. Inverted, that hairline reads as white; rebuilt from its neighbour, it goes. | |
| private static func rebuildingEdge(_ image: CIImage) -> CIImage { | |
| let inner = image.extent.insetBy(dx: 1, dy: 1) | |
| guard inner.width > 1, inner.height > 1 else { return image } | |
| return image.cropped(to: inner).clampedToExtent().cropped(to: image.extent) | |
| } | |
| /// `at` exists for the diagnostics alone: constancy is what the constant buys, so nothing on | |
| /// a rendering path may pass anything but the default. | |
| static func rawLinear(_ url: URL, longestSide: CGFloat?, at kelvin: Float? = nil) -> CIImage? { | |
| guard let raw = CIRAWFilter(imageURL: url) else { return nil } | |
| raw.boostAmount = 0 // without this, a tone curve, different per channel | |
| raw.isGamutMappingEnabled = false | |
| raw.neutralTemperature = kelvin ?? temperature | |
| raw.neutralTint = tint | |
| if let longestSide { | |
| let native = max(raw.nativeSize.width, raw.nativeSize.height) | |
| raw.scaleFactor = Float(min(1, longestSide / native)) | |
| } | |
| return raw.outputImage | |
| } | |
| /// TIFF, JPEG, PNG, HEIC — everything ImageIO opens that is not a RAW. | |
| private static func plainLinear(_ url: URL, longestSide: CGFloat?) -> CIImage? { | |
| // A reduced copy never touches the full-resolution leaf: `CIImage(contentsOf:)` scaled | |
| // down afterwards still makes Core Image realise the source at its NATIVE size on every | |
| // render — cheap once, but paid again on every held slider notch. Measured on a 4589×3648 | |
| // contact sheet: 4.3–4.6 ms a notch against 0.7 ms for a RAW's genuinely reduced decode. | |
| if let longestSide { | |
| return plainLinearReduced(url, longestSide: longestSide) | |
| } | |
| // Core Image linearises on entry to the working context; converting here too applies it | |
| // twice, and a linear flat of 0.2000 comes back as 0.0331. | |
| guard var image = CIImage(contentsOf: url, | |
| options: [.applyOrientationProperty: true]) else { return nil } | |
| if image.colorSpace == nil { | |
| guard let tagged = CIImage(contentsOf: url, options: [ | |
| .applyOrientationProperty: true, | |
| .colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!, | |
| ]) else { return nil } | |
| image = tagged | |
| } | |
| // Origin at zero: stage 0's geometry assumes an extent starting there, as a decoded RAW does. | |
| return image.transformed(by: CGAffineTransform(translationX: -image.extent.minX, | |
| y: -image.extent.minY)) | |
| } | |
| /// `CGImageSourceCreateThumbnailAtIndex` sub-samples AT DECODE TIME, so the surface Core | |
| /// Image ends up caching is genuinely the requested size — matching what `CIRAWFilter`'s own | |
| /// `scaleFactor` already buys a RAW. `FromImageAlways` is required: an absent flag can return | |
| /// a scanner's OWN embedded low-bit-depth thumbnail instead of a fresh downsample of the real data. | |
| private static func plainLinearReduced(_ url: URL, longestSide: CGFloat) -> CIImage? { | |
| guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } | |
| let options: [CFString: Any] = [ | |
| kCGImageSourceCreateThumbnailFromImageAlways: true, | |
| kCGImageSourceThumbnailMaxPixelSize: longestSide, | |
| kCGImageSourceCreateThumbnailWithTransform: true, | |
| ] | |
| guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) | |
| else { return nil } | |
| var image = CIImage(cgImage: cgImage) | |
| if image.colorSpace == nil { | |
| image = CIImage(cgImage: cgImage, | |
| options: [.colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!]) | |
| } | |
| let normalised = image.transformed(by: CGAffineTransform(translationX: -image.extent.minX, | |
| y: -image.extent.minY)) | |
| // A CGImage-backed leaf still costs a bridge on every render that samples it — measured, | |
| // 6× a RAW's genuinely GPU-resident decode, on every held slider notch. Residency, once, | |
| // is what a RAW gets for free from `CIRAWFilter`; this earns the same for everything else. | |
| return residentTexture(normalised) ?? normalised | |
| } | |
| /// Renders a leaf into an actual GPU texture once, so every later sample reads a texture — | |
| /// exactly what `MetalImageView`'s own caches do, and why they cost nothing per frame. Stays | |
| /// in the WORKING space, never `display`: this feeds `Pipeline.apply`, not a screen. | |
| private static func residentTexture(_ image: CIImage) -> CIImage? { | |
| guard let device = MTLCreateSystemDefaultDevice(), let queue = device.makeCommandQueue() | |
| else { return nil } | |
| let extent = image.extent | |
| guard extent.width > 0, extent.height > 0 else { return nil } | |
| let descriptor = MTLTextureDescriptor() | |
| descriptor.pixelFormat = .rgba16Float | |
| descriptor.width = Int(extent.width.rounded()) | |
| descriptor.height = Int(extent.height.rounded()) | |
| descriptor.usage = [.shaderRead, .shaderWrite] | |
| descriptor.storageMode = .private | |
| guard let texture = device.makeTexture(descriptor: descriptor), | |
| let buffer = queue.makeCommandBuffer() else { return nil } | |
| let ctx = CIContext(mtlCommandQueue: queue, options: [.workingColorSpace: workingSpace]) | |
| let dest = CIRenderDestination(width: descriptor.width, height: descriptor.height, | |
| pixelFormat: descriptor.pixelFormat, | |
| commandBuffer: buffer, mtlTextureProvider: { texture }) | |
| dest.colorSpace = workingSpace | |
| guard (try? ctx.startTask(toRender: image, to: dest)) != nil else { | |
| buffer.commit() | |
| return nil | |
| } | |
| buffer.commit() | |
| buffer.waitUntilCompleted() | |
| guard let backed = CIImage(mtlTexture: texture, options: [.colorSpace: workingSpace]) | |
| else { return nil } | |
| // Flips vertically: a Metal texture's origin is at the top, a CIImage's at the bottom — | |
| // `MetalImageView.Renderer.fromTexture`'s own correction, required every time one is read back. | |
| return backed.transformed(by: CGAffineTransform(scaleX: 1, y: -1) | |
| .translatedBy(x: 0, y: -backed.extent.height)) | |
| } | |
| /// Pixel count at full resolution, without decoding the image: used to extrapolate the size of | |
| /// an export from the preview. | |
| static func pixelCount(of url: URL) -> CGFloat? { | |
| pixelSize(of: url).map { $0.width * $0.height } | |
| } | |
| /// Native, uncropped, unrotated dimensions — good for an order of magnitude, not an exact count. | |
| static func pixelSize(of url: URL?) -> CGSize? { | |
| guard let url else { return nil } | |
| if isRaw(url) { return CIRAWFilter(imageURL: url)?.nativeSize } | |
| guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), | |
| let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], | |
| let width = props[kCGImagePropertyPixelWidth] as? CGFloat, | |
| let height = props[kCGImagePropertyPixelHeight] as? CGFloat else { return nil } | |
| return CGSize(width: width, height: height) | |
| } | |
| /// True when the source carries 8 bits per channel or fewer — a warning, never a refusal to | |
| /// open, since the density inversion will band low-code shadows. | |
| static func isLowPrecision(of url: URL) -> Bool { | |
| if ContainerRead.probe(url) == .linearRGB, let bits = ContainerRead.bitsPerSample(of: url) { | |
| return bits <= 8 | |
| } | |
| guard !isRaw(url), let source = CGImageSourceCreateWithURL(url as CFURL, nil), | |
| let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], | |
| let depth = props[kCGImagePropertyDepth] as? Int else { return false } | |
| return depth <= 8 | |
| } | |
| /// Authoritative check for the non-RAW decoding branch; builds its own fixtures so it always | |
| /// runs. | |
| /// True when a temperature other than the constant is passed from a RENDERING path. Constancy | |
| /// is the whole point: a flat-field reference must decode exactly like the frames it divides. | |
| nonisolated static func temperatureIsPinned(_ sources: [(file: String, text: String)]) -> Bool { | |
| // Assembled, or THIS line matches first: a text check that reads its own file has to | |
| // stay out of its own search. | |
| let needle = "at: " + "kelvin" | |
| return sources.allSatisfy { source in | |
| source.file.hasSuffix("FilmProbe.swift") | |
| || !source.text.split(separator: "\n").contains { line in | |
| let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| return !trimmed.hasPrefix("//") && trimmed.contains(needle) | |
| } | |
| } | |
| } | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var report = "" | |
| func check(_ passed: Bool, _ label: String) { | |
| ok = ok && passed | |
| report += " \(passed ? "OK " : "FAIL") \(label)\n" | |
| } | |
| // The diagnostic door opened for `--film` must never be reachable from a render: the | |
| // flat-field reference has to decode exactly like the frames it divides. | |
| let folder = SourceFile.url("Sources/OpenNegative/Pipeline/RawDecode.swift")? | |
| .deletingLastPathComponent() | |
| let manager = FileManager.default | |
| var read: [(file: String, text: String)] = [] | |
| for root in ["Pipeline", "UI", "DesignSystem"] { | |
| let dir = folder?.deletingLastPathComponent().appendingPathComponent(root) | |
| guard let dir, let names = try? manager.contentsOfDirectory(atPath: dir.path) | |
| else { continue } | |
| for name in names where name.hasSuffix(".swift") { | |
| guard let text = try? String(contentsOf: dir.appendingPathComponent(name), | |
| encoding: .utf8) else { continue } | |
| read.append((file: name, text: text)) | |
| } | |
| } | |
| if read.isEmpty { | |
| report += " SKIPPED sources absent, the pinned temperature is not checkable here\n" | |
| } else { | |
| let pinned = temperatureIsPinned(read) | |
| ok = ok && pinned | |
| report += " \(pinned ? "OK " : "FAIL") \(read.count) file(s): only the film probe " | |
| + "passes a temperature; every render path takes the constant\n" | |
| // Adverse by construction: the probe's own file, judged as if it were a render path. | |
| let adverse = read.filter { $0.file.hasSuffix("FilmProbe.swift") } | |
| .map { (file: "Pretend.swift", text: $0.text) } | |
| let discriminates = adverse.isEmpty || !temperatureIsPinned(adverse) | |
| ok = ok && discriminates | |
| report += " \(discriminates ? "OK " : "FAIL") and the check discriminates: the same " | |
| + "call judged from any other file is refused\n" | |
| } | |
| check(isRaw(URL(fileURLWithPath: "/x/P1072392.RW2")), "a .RW2 is recognized as RAW by its UTI") | |
| check(isRaw(URL(fileURLWithPath: "/x/a.CR3")), "so is a .CR3, with no list to hold it") | |
| check(!isRaw(URL(fileURLWithPath: "/x/scan.tif")), "a .tif is not a RAW") | |
| check(!isRaw(URL(fileURLWithPath: "/x/scan.jpg")), "neither is a .jpg") | |
| check(decodesViaMosaic(URL(fileURLWithPath: "/x/P1072392.RW2")), | |
| "a .RW2 routes through the mosaic decode") | |
| check(!decodesViaMosaic(URL(fileURLWithPath: "/x/scan.jpg")), | |
| "and the check discriminates: a .jpg does not, absent bytes to probe") | |
| // The hairline a scaled decode leaves on its outermost row and column. Built here rather | |
| // than decoded, so the property holds without a RAW to hand. | |
| let side = 16 | |
| var edged = [Float](repeating: 0, count: side * side * 4) | |
| for i in 0..<(side * side) { | |
| let x = i % side, y = i / side | |
| let rim = x == 0 || y == 0 || x == side - 1 || y == side - 1 | |
| // A rim three quarters as bright: darker than its neighbour exactly as a partial | |
| // resampling leaves it, which inverted is the white hair. | |
| let v: Float = rim ? 0.3 : 0.4 | |
| edged[i * 4] = v; edged[i * 4 + 1] = v; edged[i * 4 + 2] = v; edged[i * 4 + 3] = 1 | |
| } | |
| let target = edged.withUnsafeBufferPointer { buffer -> CIImage? in | |
| guard let base = buffer.baseAddress else { return nil } | |
| return CIImage(bitmapData: Data(bytes: base, count: edged.count * 4), | |
| bytesPerRow: side * 16, size: CGSize(width: side, height: side), | |
| format: .RGBAf, colorSpace: nil) | |
| } | |
| if let target { | |
| let context = CIContext(options: [.workingColorSpace: NSNull()]) | |
| /// Worst gap between a border line and the one just inside it, on all four sides. | |
| func worstRim(_ image: CIImage) -> Float { | |
| var px = [Float](repeating: 0, count: side * side * 4) | |
| context.render(image, toBitmap: &px, rowBytes: side * 16, | |
| bounds: image.extent, format: .RGBAf, colorSpace: nil) | |
| func at(_ x: Int, _ y: Int) -> Float { px[(y * side + x) * 4] } | |
| var worst: Float = 0 | |
| for k in 1..<(side - 1) { | |
| worst = max(worst, abs(at(k, 0) - at(k, 1)), abs(at(k, side - 1) - at(k, side - 2))) | |
| worst = max(worst, abs(at(0, k) - at(1, k)), abs(at(side - 1, k) - at(side - 2, k))) | |
| } | |
| return worst | |
| } | |
| let bare = worstRim(target) | |
| let rebuilt = worstRim(rebuildingEdge(target)) | |
| check(rebuilt < 1e-6, String(format: | |
| "a scaled decode's border is rebuilt from the line inside it (worst gap %.2e)", | |
| rebuilt)) | |
| // Adverse by construction: the target is built with a border that differs, so a | |
| // no-op would report exactly that difference rather than zero. | |
| check(bare > 0.09, String(format: | |
| "and the check discriminates: left alone, that border sits %.3f off its neighbour", | |
| bare)) | |
| } else { | |
| check(false, "the border target could not be built") | |
| } | |
| guard let sandbox = AppFolders.sandbox("decode-check") | |
| else { return (false, report + " FAIL check directory could not be created\n") } | |
| defer { try? FileManager.default.removeItem(at: sandbox) } | |
| let context = CIContext(options: [.workingColorSpace: workingSpace]) | |
| let srgb = CGColorSpace(name: CGColorSpace.sRGB)! | |
| let p3 = CGColorSpace(name: CGColorSpace.displayP3)! | |
| // Writes a flat of linear `value`, encoded in `space` with that space's transfer curve. | |
| func write(_ value: CGFloat, _ space: CGColorSpace, _ name: String) -> URL? { | |
| let flat = CIImage(color: CIColor(red: value, green: value, blue: value, | |
| colorSpace: workingSpace) ?? .white) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 64, height: 64)) | |
| let url = sandbox.appendingPathComponent(name) | |
| do { | |
| try context.writeTIFFRepresentation(of: flat, to: url, format: .RGBA16, | |
| colorSpace: space) | |
| return url | |
| } catch { return nil } | |
| } | |
| // +1 EV on writing must come back ×2 after decoding, at the same tolerance as `isLinear`. | |
| guard let dim = write(0.2, srgb, "dim.tiff"), let bright = write(0.4, srgb, "bright.tiff"), | |
| let a = linear(dim).map(meanRGB), let b = linear(bright).map(meanRGB) else { | |
| return (false, report + " FAIL writing or re-reading the check TIFFs\n") | |
| } | |
| let ratio = b.g / a.g | |
| let linearEnough = abs(ratio - 2) <= 0.02 | |
| check(linearEnough, String(format: | |
| "non-RAW decoding is linear: 0.2 → 0.4 comes back ×%.4f (expected 2.0000 at 1%%)", ratio)) | |
| check(abs(a.g - 0.2) <= 0.004, String(format: | |
| "and the value itself is preserved, not just the ratio (%.4f for 0.2000)", a.g)) | |
| // Proves the ICC profile is actually read: the same linear flat written in sRGB and in | |
| // Display P3 carries different bytes, so identical output would mean no conversion happened. | |
| if let inP3 = write(0.2, p3, "p3.tiff"), let c = linear(inP3).map(meanRGB) { | |
| check(abs(c.g - a.g) <= 0.004, String(format: | |
| "the same flat written in sRGB and in Display P3 reads back to the same value (%.4f vs %.4f)", | |
| a.g, c.g)) | |
| } else { | |
| check(false, "the Display P3 check TIFF could not be written or re-read") | |
| } | |
| // The reduced branch is a DIFFERENT decoder (`CGImageSourceCreateThumbnailAtIndex`, never | |
| // `CIImage(contentsOf:)`) — nothing above exercises it, so it needs its own value check. | |
| if let reduced = linear(dim, longestSide: 32).map(meanRGB) { | |
| check(abs(reduced.g - a.g) <= 0.004, String(format: | |
| "the reduced decoder reads the same value as the full one (%.4f against %.4f)", | |
| reduced.g, a.g)) | |
| } else { | |
| check(false, "the reduced decoder could not read the check TIFF") | |
| } | |
| // Both halves matter: a check only asserting "flat is flat" would also pass on a function | |
| // that always returns true, warning on every photograph. | |
| if let flat = linear(dim) { | |
| check(looksFlat(flat), "a decode with no dynamic range is recognized as such") | |
| } else { | |
| check(false, "the check flat could not be re-read") | |
| } | |
| let dark = CIImage(color: CIColor(red: 0.05, green: 0.05, blue: 0.05, | |
| colorSpace: workingSpace) ?? .black) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 64, height: 64)) | |
| let light = CIImage(color: CIColor(red: 0.8, green: 0.8, blue: 0.8, | |
| colorSpace: workingSpace) ?? .white) | |
| .cropped(to: CGRect(x: 0, y: 32, width: 64, height: 32)) | |
| check(!looksFlat(light.composited(over: dark)), | |
| "and the check discriminates: a contrasted image is not") | |
| // The third case, which neither half covers: a render that came back empty. A memory target | |
| // too low for its source returns zeros silently, and a flat verdict there warns on a photo. | |
| let empty = CIImage(color: CIColor(red: 0, green: 0, blue: 0, | |
| colorSpace: workingSpace) ?? .black) | |
| .cropped(to: CGRect(x: 0, y: 0, width: 64, height: 64)) | |
| check(!looksFlat(empty), | |
| "and a buffer of exact zeros is a FAILED render, not a flat frame") | |
| if let size = pixelSize(of: dim) { | |
| check(size == CGSize(width: 64, height: 64), String(format: | |
| "the dimensions of a non-RAW are read without decoding (%.0f × %.0f)", | |
| size.width, size.height)) | |
| } else { | |
| check(false, "the dimensions of a non-RAW could not be read") | |
| } | |
| return (ok, report) | |
| } | |
| /// Options for the throwaway diagnostic contexts. No memory target: under a threshold that is | |
| /// non-monotone in both target and source size, a capped context silently renders zeros. | |
| static var diagnosticOptions: [CIContextOption: Any] { | |
| [.workingColorSpace: workingSpace, .workingFormat: CIFormat.RGBAf] | |
| } | |
| /// Per-channel mean, in floating point, in the working space. | |
| static func meanRGB(_ image: CIImage) -> (r: Float, g: Float, b: Float) { | |
| let avg = CIFilter(name: "CIAreaAverage", parameters: [ | |
| kCIInputImageKey: image, | |
| kCIInputExtentKey: CIVector(cgRect: image.extent), | |
| ])!.outputImage! | |
| let ctx = CIContext(options: diagnosticOptions) | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(avg, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: 0, y: 0, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: workingSpace) | |
| return (px[0], px[1], px[2]) | |
| } | |
| /// True when the decode came back with no dynamic range worth the name. A constatation only: | |
| /// nothing is corrected, re-decoded, or refused — the caller may dismiss it by looking. | |
| static func looksFlat(_ image: CIImage) -> Bool { | |
| let side: CGFloat = 200 | |
| let scale = side / max(image.extent.width, image.extent.height, 1) | |
| let small = scale < 1 ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) | |
| : image | |
| let w = max(1, Int(small.extent.width)), h = max(1, Int(small.extent.height)) | |
| guard w * h >= 64 else { return false } | |
| var buf = [Float](repeating: 0, count: w * h * 4) | |
| let ctx = CIContext(options: diagnosticOptions) | |
| buf.withUnsafeMutableBytes { p in | |
| ctx.render(small, toBitmap: p.baseAddress!, rowBytes: w * 16, | |
| bounds: CGRect(x: 0, y: 0, width: w, height: h), | |
| format: .RGBAf, colorSpace: workingSpace) | |
| } | |
| // Green, the channel every other check in this project speaks in. | |
| var green = (0..<(w * h)).map { buf[$0 * 4 + 1] } | |
| green.sort() | |
| // A buffer that came back exactly zero everywhere is a failed render, not a flat frame: a | |
| // memory target too low for the source renders zeros and reports nothing. | |
| guard let brightest = green.last, brightest > 0 else { return false } | |
| let low = green[green.count / 10], high = green[green.count * 9 / 10] | |
| return high - low < 0.02 | |
| } | |
| /// The share of samples the decoding stage pushed outside [0;1]. Clipping born here cannot be | |
| /// recovered by a black point, since the levels never see pixels the decoder already crushed. | |
| static func clippedFraction(_ image: CIImage) -> (above: Double, below: Double) { | |
| let side: CGFloat = 400 | |
| let scale = side / max(image.extent.width, image.extent.height, 1) | |
| let small = scale < 1 ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) | |
| : image | |
| let w = max(1, Int(small.extent.width)), h = max(1, Int(small.extent.height)) | |
| var buf = [Float](repeating: 0, count: w * h * 4) | |
| let ctx = CIContext(options: diagnosticOptions) | |
| buf.withUnsafeMutableBytes { p in | |
| ctx.render(small, toBitmap: p.baseAddress!, rowBytes: w * 16, | |
| bounds: CGRect(x: 0, y: 0, width: w, height: h), | |
| format: .RGBAf, colorSpace: workingSpace) | |
| } | |
| var above = 0, below = 0 | |
| let total = w * h * 3 | |
| for i in 0..<(w * h) { | |
| for c in 0..<3 { | |
| let v = buf[i * 4 + c] | |
| if v > 1 { above += 1 } else if v < 0 { below += 1 } | |
| } | |
| } | |
| return (Double(above) / Double(total) * 100, Double(below) / Double(total) * 100) | |
| } | |
| /// A linear decode doubles exactly on +1 EV, identically on all three channels; Apple's default | |
| /// rendering curve gives ~1.60/1.75/1.73 — non-linear and mismatched between channels. | |
| static func isLinear(_ url: URL, tolerance: Float = 0.01) -> (ok: Bool, ratios: (Float, Float, Float))? { | |
| func mean(_ ev: Float) -> (r: Float, g: Float, b: Float)? { | |
| guard let raw = CIRAWFilter(imageURL: url) else { return nil } | |
| raw.boostAmount = 0 | |
| raw.isGamutMappingEnabled = false | |
| raw.neutralTemperature = temperature | |
| raw.neutralTint = tint | |
| raw.scaleFactor = Float(min(1, 512 / max(raw.nativeSize.width, raw.nativeSize.height))) | |
| raw.exposure = ev | |
| return raw.outputImage.map(meanRGB) | |
| } | |
| guard let a = mean(0), let b = mean(1) else { return nil } | |
| let ratios = (b.r / a.r, b.g / a.g, b.b / a.b) | |
| let ok = [ratios.0, ratios.1, ratios.2].allSatisfy { abs($0 - 2) <= 2 * tolerance } | |
| return (ok, ratios) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Stage 10: unsharp mask on the luma alone, so edges gain definition without coloured fringes. | |
| /// The resting amount is non-zero on purpose: this is a scan's baseline, not an effect. | |
| struct Sharpen: Codable, Equatable, Hashable, Sendable { | |
| /// Blur radius in FULL-RESOLUTION pixels, so a slider reading 2 px puts 2 px in the export. | |
| /// Rests on the anchor, at mid-travel. | |
| var radius: Float = Sharpen.radiusAnchor | |
| var amount: Float = 0.67 | |
| /// From the floor a fitted preview leaves inert to past the widest dose the eight reference | |
| /// films still pay an edge for, 6.53 px. The loupe is what shows the export's dose. | |
| static let radiusRange: ClosedRange<Float> = Float(Pipeline.previewVisibleRadius)...8 | |
| /// Radius sitting at mid-travel, and the resting value: the range's upper half is reachable but | |
| /// never dosed on a scan, so an anchor buys fine control where the gesture actually happens. | |
| static let radiusAnchor: Float = 2.092 | |
| static let amountRange: ClosedRange<Float> = 0...3 | |
| /// The radius a sidecar carries when nobody ever touched the block, in the fraction of the | |
| /// largest side it was written in: the range's own floor, which no gesture ever placed. | |
| static let formerRest: Float = 0.0001 | |
| var isNeutral: Bool { amount <= 0 || radius <= 0 } | |
| /// The resting state, not an off state: `neutral.isNeutral` is `false`. A double click on amount | |
| /// is the one gesture that truly disables it, not a section reset. | |
| static let neutral = Sharpen() | |
| /// The dose follows the scale between a reduced copy and the export, and neither the crop, the | |
| /// rotation nor the frame's shape: none of the three enters `scale`. | |
| func pixelRadius(scale: Double) -> Double { | |
| Double(radius) * scale | |
| } | |
| } | |
| extension Pipeline { | |
| /// Luminance alone, carried by the three channels. | |
| static func lumaOnly(_ image: CIImage) -> CIImage { | |
| let w = lumaWeights | |
| let row = CIVector(x: CGFloat(w.x), y: CGFloat(w.y), z: CGFloat(w.z), w: 0) | |
| return image.applyingFilter("CIColorMatrix", parameters: [ | |
| "inputRVector": row, "inputGVector": row, "inputBVector": row, | |
| "inputAVector": CIVector(x: 0, y: 0, z: 0, w: 1), | |
| ]) | |
| } | |
| /// Stage 10, running on any photo since the resting amount is non-zero. `scale` is the current | |
| /// side over the full-resolution one, 1 on a frame already there. | |
| static func applySharpen(_ image: CIImage, _ settings: Sharpen, scale: Double) -> CIImage { | |
| guard !settings.isNeutral, let sharpenKernel else { return image } | |
| let radius = settings.pixelRadius(scale: scale) | |
| guard radius > blurFloor else { return image } | |
| let blurred = lumaOnly(image) | |
| .clampedToExtent() | |
| .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: radius]) | |
| .cropped(to: image.extent) | |
| return sharpenKernel.apply(extent: image.extent, | |
| arguments: [image, blurred, settings.amount]) ?? image | |
| } | |
| } | |
| extension Sharpen { | |
| /// The stage's guarantees, then the resting dose at every scale the chain renders at. A radius | |
| /// counted in pixels is one number at full resolution and a fraction of it on a reduced copy. | |
| static func selfCheck(_ ctx: CIContext) -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| guard Pipeline.sharpenKernel != nil else { | |
| return (false, " FAIL sharpening kernel not found") | |
| } | |
| let side = 64 | |
| guard let source = Pipeline.edgeTarget(width: side, height: side) else { | |
| return (false, " FAIL sharpening: check image not built") | |
| } | |
| let w = Pipeline.lumaWeights | |
| func planes(_ image: CIImage) -> (luma: [Float], chroma: [Float]) { | |
| let raw = Pipeline.samples(ctx, image, width: side, height: side) | |
| var luma: [Float] = [], chroma: [Float] = [] | |
| for i in 0..<(side * side) { | |
| let y = raw[i * 4] * w.x + raw[i * 4 + 1] * w.y + raw[i * 4 + 2] * w.z | |
| luma.append(y) | |
| // The three departures from grey: that is the chroma, which must not move. | |
| chroma.append(raw[i * 4] - y) | |
| chroma.append(raw[i * 4 + 1] - y) | |
| chroma.append(raw[i * 4 + 2] - y) | |
| } | |
| return (luma, chroma) | |
| } | |
| /// Largest gap between two neighbours of the middle row: what an unsharp mask widens. | |
| func edgeStep(_ luma: [Float]) -> Float { | |
| let row = side / 2 | |
| return (1..<side).map { abs(luma[row * side + $0] - luma[row * side + $0 - 1]) } | |
| .max() ?? 0 | |
| } | |
| let dosed = Sharpen(radius: 2, amount: 2) | |
| let before = planes(source) | |
| let after = planes(Pipeline.applySharpen(source, dosed, scale: 1)) | |
| let chromaShift = zip(before.chroma, after.chroma).map { abs($0 - $1) }.max() ?? 0 | |
| report(chromaShift < 1e-6, | |
| String(format: "chroma preserved by the sharpening (worst gap %.2e)", chromaShift)) | |
| let stepBefore = edgeStep(before.luma) | |
| let stepAfter = edgeStep(after.luma) | |
| report(stepAfter > stepBefore * 1.2, | |
| String(format: "local contrast increased (edge step %.4f → %.4f)", | |
| stepBefore, stepAfter)) | |
| // Amount is what switches the stage off, and it must switch it off completely. | |
| let off = Pipeline.applySharpen(source, Sharpen(radius: 2, amount: 0), scale: 1) | |
| let offStep = edgeStep(planes(off).luma) | |
| report(abs(offStep - stepBefore) < 1e-5, | |
| String(format: "a zero amount gives the source back (%.4f against %.4f)", | |
| offStep, stepBefore)) | |
| // Cropping around a pixel must not change its value, or tightening the frame would silently | |
| // divide the effective radius. `apply` is what decides that, hence the whole pipeline here. | |
| var whole = PipelineSettings() | |
| whole.sharpen = dosed | |
| var tight = whole | |
| tight.geometry.crop = CGRect(x: 0.25, y: 0.25, width: 0.5, height: 0.5) | |
| func lumaAt(_ image: CIImage) -> Float { | |
| var px = [Float](repeating: 0, count: 4) | |
| ctx.render(image, toBitmap: &px, rowBytes: 16, | |
| bounds: CGRect(x: side / 2 + 2, y: side / 2 + 2, width: 1, height: 1), | |
| format: .RGBAf, colorSpace: nil) | |
| return px[0] * w.x + px[1] * w.y + px[2] * w.z | |
| } | |
| let asWhole = lumaAt(Pipeline.apply(source, settings: whole)) | |
| let asTight = lumaAt(Pipeline.apply(source, settings: tight)) | |
| report(abs(asWhole - asTight) < 1e-6, | |
| String(format: "radius invariant under cropping (%.6f vs %.6f)", asWhole, asTight)) | |
| /// The resting dose at one scale. Which scale `apply` computes is a separate question, held | |
| /// by `radiusReferenceChecks`. | |
| func gain(scale: Double) -> Float { | |
| edgeStep(planes(Pipeline.applySharpen(source, .neutral, scale: scale)).luma) / stepBefore | |
| } | |
| // The scales the chain renders at, the export being the only one at 1. A file of any size | |
| // now takes the same pixel dose, which is what a radius counted in pixels buys. | |
| let reference = SettingsCodec.radiusReferenceSide | |
| let preview = Double(Negative.previewSide) / reference | |
| let sizes: [(label: String, scale: Double)] = [ | |
| ("export, and every full-resolution render", 1), | |
| ("fitted preview, 2000 px of long side", preview), | |
| ("measuring copy, 1400 px", Double(Negative.measureSide) / reference), | |
| ("thumbnail", Double(Thumbnails.side) / reference), | |
| ] | |
| for row in sizes { | |
| lines.append(String(format: " ---- scale %.4f — radius %.3f px, edge step ×%.4f (%@)", | |
| row.scale, Double(neutral.radius) * row.scale, | |
| gain(scale: row.scale), row.label)) | |
| } | |
| let floor: Float = 1.30 | |
| report(gain(scale: 1) >= floor, | |
| String(format: "the resting dose clears ×%.2f at full resolution, on every file and " | |
| + "no longer only on the large ones (×%.4f)", floor, gain(scale: 1))) | |
| // The reduced preview still cannot show it: the export's radius is scaled down with the | |
| // copy, so the information is not there. That is what the loupe renders full size for. | |
| let previewGain = gain(scale: preview) | |
| report(previewGain < floor, | |
| String(format: "and the fitted preview stays UNDER that floor (×%.4f, %.0f %% of the " | |
| + "export's ×%.4f): raising the rest until the screen matched would come out " | |
| + "of the export as a halo", previewGain, | |
| Double(previewGain / gain(scale: 1) * 100), gain(scale: 1))) | |
| // The bottom of the track is the smallest radius that still moves a preview pixel, so no | |
| // part of the gesture is inert on screen while reaching the export. | |
| let bottom = Double(radiusRange.lowerBound) | |
| report(bottom * preview > Pipeline.blurFloor | |
| && bottom * preview / Pipeline.blurFloor < 1.05, | |
| String(format: "and the track starts at %.4f px, the smallest full-resolution radius " | |
| + "a fitted preview can show at all: %.5f px there against a blur floor of " | |
| + "%.4f", bottom, bottom * preview, Pipeline.blurFloor)) | |
| // The twin, adverse by construction: a caller omitting `fullWidth` leaves the scale at 1, so | |
| // the preview would take the export's own radius — and clear the very floor it must not. | |
| report(gain(scale: 1) >= floor && previewGain < floor, | |
| String(format: "and the check discriminates: with the scale left at 1, which is what " | |
| + "omitting `fullWidth` does, that same preview clears the floor at ×%.4f — " | |
| + "the separation belongs to the scaling, not to 2000 px", gain(scale: 1))) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| extension Sharpen { | |
| /// Where the rim overtakes the edge it buys, measured on a real film at full resolution: the | |
| /// range's top is that turn, and this is what says the track is not spent on halo. | |
| static func selfCheck(source: URL) -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| guard Pipeline.sharpenKernel != nil else { | |
| return (false, " FAIL sharpening kernel not found") | |
| } | |
| guard let region = NeighbourhoodProbe.region(of: source) else { | |
| return (false, " FAIL \(source.lastPathComponent): no full-resolution region read") | |
| } | |
| let ctx = NeighbourhoodProbe.context | |
| let side = region.side | |
| let before = NeighbourhoodProbe.planes(ctx, region.image, side) | |
| let mask = NeighbourhoodProbe.split(before.luma, side) | |
| let luma = Pipeline.lumaOnly(region.image) | |
| // The band the source holds around each pixel, read once: the window is the EDGE's own | |
| // neighbourhood and never the filter's, which grown with the radius swallows the very rim. | |
| let ceiling = Pipeline.samples(ctx, NeighbourhoodProbe.morphology(luma, window: 3, | |
| maximum: true), | |
| width: side, height: side) | |
| let base = Pipeline.samples(ctx, NeighbourhoodProbe.morphology(luma, window: 3, | |
| maximum: false), | |
| width: side, height: side) | |
| /// Contour and rim at one radius: what the mask lifts on the edges, and how far the result | |
| /// leaves the band the source held around them. | |
| func measure(_ radius: Double) -> (contour: Float, rim: Float) { | |
| let dosed = Sharpen(radius: Float(radius), amount: 1) | |
| let out = Pipeline.applySharpen(region.image, dosed, scale: 1) | |
| let after = NeighbourhoodProbe.planes(ctx, out, side) | |
| var rim: Double = 0 | |
| var counted = 0 | |
| for i in 0..<(side * side) where mask.edges[i] { | |
| let over = max(0, after.luma[i] - ceiling[i * 4]) | |
| let under = max(0, base[i * 4] - after.luma[i]) | |
| rim += Double(over + under) | |
| counted += 1 | |
| } | |
| return (NeighbourhoodProbe.step(after.luma, side, over: mask.edges), | |
| counted == 0 ? 0 : Float(rim / Double(counted))) | |
| } | |
| let sweep = NeighbourhoodProbe.sweep(upTo: 32) | |
| let rest = NeighbourhoodProbe.step(before.luma, side, over: mask.edges) | |
| var contour: [Float] = [], rim: [Float] = [] | |
| for radius in sweep { | |
| let point = measure(radius) | |
| contour.append(point.contour - rest) | |
| rim.append(point.rim) | |
| } | |
| lines.append(" ---- \(source.lastPathComponent), \(side) × \(side) px of the full frame " | |
| + String(format: "at %.0f px of long side", region.longSide)) | |
| lines.append(" ---- radius px edge lifted rim outside the source's own band") | |
| for (k, radius) in sweep.enumerated() { | |
| lines.append(String(format: " ---- %8.2f %11.6f %31.6f", | |
| radius, contour[k], rim[k])) | |
| } | |
| // Where the stage stops paying for radius, read on the rate so no end of sweep divides it. | |
| let lifted = NeighbourhoodProbe.exhaustion(sweep, contour) | |
| report(lifted.reach > 0, String(format: | |
| "the edge is spent at %.2f px: a pixel of radius there lifts a tenth of what the best " | |
| + "pixel of radius lifted (%.2e per px at its peak)", lifted.reach, lifted.peak)) | |
| let top = Double(radiusRange.upperBound) | |
| report(top >= lifted.reach, String(format: | |
| "the track runs to %.0f px, so this film reaches its own dose and spends %.0f %% of " | |
| + "the travel getting there", top, 100 * lifted.reach / top)) | |
| // What the stop leaves behind, over the same span: the asymmetry that makes it a stop and | |
| // not an amputation. Read as a share of each whole rise, never as a rate on a widening grid. | |
| let edgeLeft = NeighbourhoodProbe.beyond(sweep, contour, past: top) | |
| let haloLeft = NeighbourhoodProbe.beyond(sweep, rim, past: top) | |
| report(edgeLeft < 0.2 && haloLeft >= edgeLeft, String(format: | |
| "and past that stop, out to %.0f px, only %.1f %% of this film's edge is left to lift, " | |
| + "against %.1f %% of its rim left to buy — the track holds the stage, and going " | |
| + "further is never a bargain", sweep[sweep.count - 1], | |
| edgeLeft * 100, haloLeft * 100)) | |
| // The twin, adverse by construction: the bottom of the track sits an order under every dose | |
| // measured above, so a stop there always leaves most of the edge out of reach. | |
| let atFloor = NeighbourhoodProbe.beyond(sweep, contour, past: Double(radiusRange.lowerBound)) | |
| report(atFloor > edgeLeft * 2, String(format: | |
| "the twin: stopping at %.4f px instead would leave %.1f %% of it unlifted against the " | |
| + "%.1f %% above, so that reading belongs to where the track ends, not to the sweep", | |
| radiusRange.lowerBound, atFloor * 100, edgeLeft * 100)) | |
| // The twin, adverse by construction: the fraction scale ran to 0.002 of the largest side, | |
| // a fixed multiple of this frame whatever the sweep measures. | |
| let former = 0.002 * region.longSide | |
| report(top / former < 0.75, String(format: | |
| "and the check discriminates: the scale this replaces ran to %.1f px on this frame, " | |
| + "%.1f× the track above and all of it past the edge's own answer", | |
| former, former / top)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| import Foundation | |
| /// Finishing balance: a degree-4 Bézier transfer curve per channel, five control points | |
| /// (black, shadows, midtones, highlights, white). Only the three middle bands keep black/white fixed. | |
| struct ToneBalance: Codable, Equatable, Hashable, Sendable { | |
| /// Deliberately has no shared exposure field: that tone is already reachable through the global | |
| /// RGB levels, and duplicating it here would be a control with no distinct effect. | |
| struct Band: Codable, Equatable, Hashable, Sendable { | |
| /// Per-channel offset: what tints this part of the range. | |
| var colour = SIMD3<Float>(repeating: 0) | |
| /// What each channel's ordinate is shifted by, **after the shoulder**. | |
| var offsets: SIMD3<Float> { | |
| let raw = colour * ToneBalance.scale | |
| return SIMD3(ToneBalance.soften(raw.x), ToneBalance.soften(raw.y), | |
| ToneBalance.soften(raw.z)) | |
| } | |
| var isNeutral: Bool { colour == .zero } | |
| } | |
| var black = Band() | |
| var shadows = Band() | |
| var midtones = Band() | |
| var highlights = Band() | |
| var white = Band() | |
| /// A control point of the curve, from the black end to the white end. | |
| enum Zone: String, CaseIterable, Identifiable, Hashable, Sendable { | |
| case black, shadows, midtones, highlights, white | |
| var id: String { rawValue } | |
| var label: String { | |
| switch self { | |
| case .black: "Black" | |
| case .shadows: "Shadows" | |
| case .midtones: "Mids" | |
| case .highlights: "Highs" | |
| case .white: "White" | |
| } | |
| } | |
| /// Its index in the Bernstein basis, hence where it sits and where it peaks. | |
| var index: Int { | |
| switch self { | |
| case .black: 0 | |
| case .shadows: 1 | |
| case .midtones: 2 | |
| case .highlights: 3 | |
| case .white: 4 | |
| } | |
| } | |
| /// The value this control point holds when nothing is set — and the abscissa of its greatest | |
| /// influence, the two being the same number for a Bernstein basis. | |
| var base: Float { Float(index) / 4 } | |
| } | |
| subscript(zone: Zone) -> Band { | |
| get { | |
| switch zone { | |
| case .black: black | |
| case .shadows: shadows | |
| case .midtones: midtones | |
| case .highlights: highlights | |
| case .white: white | |
| } | |
| } | |
| set { | |
| switch zone { | |
| case .black: black = newValue | |
| case .shadows: shadows = newValue | |
| case .midtones: midtones = newValue | |
| case .highlights: highlights = newValue | |
| case .white: white = newValue | |
| } | |
| } | |
| } | |
| /// What one slider unit is worth **before the shoulder**. | |
| static let scale: Float = 0.25 | |
| /// The room a control point has before it would reach its neighbour: the spacing of the five | |
| /// base ordinates. | |
| static let room: Float = 0.25 | |
| /// Maps the slider's range into `(-room, room)`, approaching but never reaching the neighbouring | |
| /// control point's gap, so no single slider can flatten the curve. | |
| static func soften(_ offset: Float) -> Float { | |
| let magnitude = room * (1 - exp(-abs(offset) / room)) | |
| return offset < 0 ? -magnitude : magnitude | |
| } | |
| /// What the sliders offer. The black zone gets a quarter of the travel: a cast in the deepest | |
| /// tones shows at an amplitude the other four zones would not even register. | |
| static let range: ClosedRange<Float> = -2...2 | |
| static let blackRange: ClosedRange<Float> = -0.5...0.5 | |
| static func range(for zone: Zone) -> ClosedRange<Float> { | |
| zone == .black ? blackRange : range | |
| } | |
| static let base: Float = 0 | |
| var isNeutral: Bool { Zone.allCases.allSatisfy { self[$0].isNeutral } } | |
| /// The zones that carry something, for the tab bar's dots. | |
| var touched: Set<Zone> { Set(Zone.allCases.filter { !self[$0].isNeutral }) } | |
| /// Forces the five ordinates non-decreasing so the curve cannot fold, flattening a stretch | |
| /// instead; a middle band that would overtake black or white is held against it, not the reverse. | |
| func ordinates(channel: Int) -> [Float] { | |
| let raw = Zone.allCases.map { $0.base + self[$0].offsets[channel] } | |
| // The white end may not fall below the black one — that would be an inverted image, which | |
| // no slider should be able to ask for by accident. | |
| let low = raw[0] | |
| let high = max(raw[4], low) | |
| var out = [low] | |
| var floor = low | |
| for point in 1..<4 { | |
| floor = min(max(raw[point], floor), high) | |
| out.append(floor) | |
| } | |
| out.append(high) | |
| return out | |
| } | |
| /// The five control ordinates of the three channels, as the kernel wants them: one vector per | |
| /// control point. | |
| func vectors() -> [CIVector] { | |
| let channels = (0..<3).map { ordinates(channel: $0) } | |
| return (0..<5).map { point in | |
| CIVector(x: Double(channels[0][point]), y: Double(channels[1][point]), | |
| z: Double(channels[2][point])) | |
| } | |
| } | |
| /// Swift replica of the kernel, for checks only. Evaluates on `x` clamped into 0...1 and carries | |
| /// anything outside through as an offset, since the Bézier is undefined past that interval. | |
| func applied(_ x: Float, channel: Int = 0) -> Float { | |
| let t = min(max(x, 0), 1) | |
| return curve(t, ordinates(channel: channel)) + (x - t) | |
| } | |
| func applied(_ x: SIMD3<Float>) -> SIMD3<Float> { | |
| SIMD3(applied(x.x, channel: 0), applied(x.y, channel: 1), applied(x.z, channel: 2)) | |
| } | |
| private func curve(_ t: Float, _ c: [Float]) -> Float { | |
| let u = 1 - t | |
| let basis = [u * u * u * u, 4 * t * u * u * u, 6 * t * t * u * u, 4 * t * t * t * u, | |
| t * t * t * t] | |
| return zip(c, basis).reduce(Float(0)) { $0 + $1.0 * $1.1 } | |
| } | |
| /// The curve with the ordering clamp optionally switched off, to show what it holds up. | |
| fileprivate func raw(_ x: Float, clamped: Bool) -> Float { | |
| let t = min(max(x, 0), 1) | |
| let c = clamped ? ordinates(channel: 0) | |
| : Zone.allCases.map { $0.base + self[$0].offsets[0] } // without the ordering | |
| return curve(t, c) | |
| } | |
| } | |
| extension ToneBalance { | |
| static func selfCheck() -> (Bool, String) { | |
| var ok = true | |
| var lines: [String] = [] | |
| func report(_ passed: Bool, _ text: String) { | |
| ok = ok && passed | |
| lines.append(" \(passed ? "OK " : "FAIL") \(text)") | |
| } | |
| // The black zone is the one place a cast shows at an amplitude the others ignore, so its | |
| // travel is a quarter. Pinned, since the ratio is the whole of what the rule says. | |
| let full = range.upperBound - range.lowerBound | |
| let black = blackRange.upperBound - blackRange.lowerBound | |
| report(abs(full / black - 4) < 1e-5, String(format: | |
| "the black zone offers a quarter of the travel (%.2f against %.2f)", black, full)) | |
| report(range(for: .black) == blackRange | |
| && Zone.allCases.filter { $0 != .black }.allSatisfy { range(for: $0) == range }, | |
| "and it is the only zone narrowed: the other four keep the full range") | |
| // Identity at the neutral setting, guaranteeing the stage can be skipped without touching | |
| // anything. | |
| let neutral = ToneBalance() | |
| let worstDrift = stride(from: Float(0), through: 1, by: 0.001) | |
| .map { abs(neutral.applied($0) - $0) }.max() ?? 1 | |
| report(neutral.isNeutral && worstDrift < 1e-6, | |
| String(format: "at neutral the curve is the identity (max deviation %.2e)", worstDrift)) | |
| // Each control acts where it says. Measured by sweeping, not asserted from the formula. | |
| for zone in Zone.allCases { | |
| var only = ToneBalance() | |
| only[zone].colour = SIMD3(repeating: 1) | |
| let peak = stride(from: Float(0), through: 1, by: 0.001) | |
| .max { abs(only.applied($0) - $0) < abs(only.applied($1) - $1) } ?? -1 | |
| report(abs(peak - zone.base) < 0.02, | |
| String(format: "%@: peak effect measured at %.3f (expected %.3f)", | |
| zone.label, peak, zone.base)) | |
| } | |
| // The three middle bands must leave the ends alone; the black and white points must move | |
| // them. Both halves are checked, since either alone would miss the opposite error. | |
| var middles = ToneBalance() | |
| middles.shadows.colour = SIMD3(repeating: 2) | |
| middles.midtones.colour = SIMD3(repeating: -2) | |
| middles.highlights.colour = SIMD3(repeating: 2) | |
| report(abs(middles.applied(0)) < 1e-6 && abs(middles.applied(1) - 1) < 1e-6, | |
| String(format: "the three middle bands do not move the endpoints " | |
| + "(0 → %.7f, 1 → %.7f)", middles.applied(0), middles.applied(1))) | |
| var ends = ToneBalance() | |
| ends.black.colour = SIMD3(repeating: 1) | |
| ends.white.colour = SIMD3(repeating: -1) | |
| report(ends.applied(0) > 0.1 && ends.applied(1) < 0.9, | |
| String(format: "and the black and white points, on the other hand, do move them " | |
| + "(0 → %.4f, 1 → %.4f)", ends.applied(0), ends.applied(1))) | |
| /// The minimum slope over the 243 extreme combinations, colour AND exposure pushed together. | |
| func sweep(_ clamped: Bool) -> Float { | |
| var worst = Float.greatestFiniteMagnitude | |
| for corner in 0..<243 { | |
| var balance = ToneBalance() | |
| var digits = corner | |
| for zone in Zone.allCases { | |
| let amount = Float(digits % 3 - 1) * 2 | |
| digits /= 3 | |
| balance[zone].colour = SIMD3(repeating: amount) | |
| } | |
| var previous = balance.raw(0, clamped: clamped) | |
| for step in 1...500 { | |
| let x = Float(step) / 500 | |
| let value = balance.raw(x, clamped: clamped) | |
| worst = min(worst, (value - previous) * 500) | |
| previous = value | |
| } | |
| } | |
| return worst | |
| } | |
| // Monotonicity holds at every extreme combination through the ordinates' running maximum, | |
| // not through scaling, with tolerance set by float finite-difference noise. | |
| let clampedSlope = sweep(true) | |
| report(clampedSlope >= -1e-3, | |
| String(format: "none of the 243 extreme combinations folds the curve " | |
| + "(minimum slope %.5f)", clampedSlope)) | |
| // Its twin: without the running maximum it must fold back, or the clamp above proves nothing. | |
| let unclampedSlope = sweep(false) | |
| report(unclampedSlope < -0.01, | |
| String(format: "and the check discriminates: without the running maximum it does fold " | |
| + "(slope %.5f)", unclampedSlope)) | |
| // One slider alone, at full travel, must never engage the clamp: as soon as it bites, a | |
| // piece of the range is flattened against a stop, which reads as clipping. | |
| var clampEngaged = false | |
| var reached: Float = 0 | |
| for zone in Zone.allCases { | |
| for amount in [Float(-2), 2] { | |
| do { | |
| var one = ToneBalance() | |
| one[zone].colour = SIMD3(repeating: amount) | |
| let raw = Zone.allCases.map { $0.base + one[$0].offsets[0] } | |
| let held = one.ordinates(channel: 0) | |
| if zip(raw, held).contains(where: { abs($0 - $1) > 1e-6 }) { clampEngaged = true } | |
| reached = max(reached, abs(one[zone].offsets[0])) | |
| } | |
| } | |
| } | |
| report(!clampEngaged, | |
| String(format: "a single slider at full travel never engages the clamp: it " | |
| + "reaches %.4f out of the %.2f available, never touching them", | |
| reached, room)) | |
| // Its twin: without the shoulder the same travel would leave the range. | |
| let unsoftened = Float(2) * scale | |
| report(unsoftened > room && soften(unsoftened) < room, | |
| String(format: "and the check discriminates: without the shoulder the travel would be %.2f, " | |
| + "beyond the %.2f available", unsoftened, room)) | |
| // The shoulder is the identity to first order: the first half of the travel behaves | |
| // exactly as before, which is what makes it painless. | |
| let small = Float(0.02) | |
| report(abs(soften(small) - small) < small * 0.1, | |
| String(format: "near zero the shoulder is imperceptible (%.5f for %.5f)", | |
| soften(small), small)) | |
| // And it is odd and strictly increasing, otherwise the slider would lie about its sign. | |
| report(soften(-0.4) == -soften(0.4) && soften(0.5) > soften(0.4), | |
| "the shoulder is odd and strictly increasing") | |
| // The sign, on all five: pushing towards the positive lightens. | |
| for zone in Zone.allCases { | |
| var up = ToneBalance() | |
| up[zone].colour = SIMD3(repeating: 2) | |
| let at = max(min(zone.base, 0.999), 0.001) | |
| report(up.applied(at) > at, | |
| String(format: "%@: a positive setting lightens (%.3f → %.3f)", | |
| zone.label, at, up.applied(at))) | |
| } | |
| // A signal outside 0…1 goes through keeping its gap: the Bézier is only defined on its | |
| // interval, and the pipeline is deliberately unbounded until the clipping. | |
| var lifted = ToneBalance() | |
| lifted.white.colour = SIMD3(repeating: 1) | |
| let above = lifted.applied(1.4) - lifted.applied(1) | |
| report(abs(above - 0.4) < 1e-5, | |
| String(format: "beyond 1, the gap is carried through as is (%.4f)", above)) | |
| return (ok, lines.joined(separator: "\n")) | |
| } | |
| } | |
| import CoreImage | |
| /// Saturation by luminance zone, applied after tone is set: "shadows" and "highlights" only | |
| /// make sense on the positive image. | |
| struct ZoneSaturation: Codable, Equatable, Hashable, Sendable { | |
| /// −1 turns the zone black and white, 0 leaves it untouched, +1 doubles its saturation. | |
| var shadows: Float = 0 | |
| var highlights: Float = 0 | |
| static let range: ClosedRange<Float> = -1...1 | |
| /// Split point between the two zones, in perceived luminance. 0.5 = mid grey. | |
| static let split: Float = 0.5 | |
| /// Split point in linear luminance, matching how the histogram measures it. | |
| static var splitLuminance: Float { pow(split, 2.2) } | |
| var isNeutral: Bool { shadows == 0 && highlights == 0 } | |
| static let neutral = ZoneSaturation() | |
| var vector: CIVector { CIVector(x: Double(shadows), y: Double(highlights)) } | |
| } | |
| extension Pipeline { | |
| /// Stage 9, skipped when both doses are zero. | |
| static func applyZoneSaturation(_ image: CIImage, _ saturation: ZoneSaturation) -> CIImage { | |
| guard !saturation.isNeutral, let saturationKernel else { return image } | |
| return saturationKernel.apply(extent: image.extent, | |
| arguments: [image, saturation.shadows, | |
| saturation.highlights]) ?? image | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment