Skip to content

Instantly share code, notes, and snippets.

@Ceare258
Created September 1, 2026 16:47
Show Gist options
  • Select an option

  • Save Ceare258/7d5624a3e4181562cecf4bb0e763ed56 to your computer and use it in GitHub Desktop.

Select an option

Save Ceare258/7d5624a3e4181562cecf4bb0e763ed56 to your computer and use it in GitHub Desktop.
ONI RESEARCH
#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