SwiftInvert ports NegPy's negative-inversion pipeline; NegPy
(~/Documents/code/NegPy) keeps evolving. This file records the last NegPy
commit we reviewed, so "what changed since we last looked?" always has a
baseline. Keep it current: every upstream review ends by updating the marker
and appending a history entry.
commit: a09cc46 ("change: Linear RAW defaults to off (#717)")
reviewed: 2026-07-31
fixtures: Tests/Fixtures/ dumped from 0369b10 except lab_color (partially
re-dumped from a09cc46, 2026-07-31, with the pure gamut boost —
the b8c596c dump had carried the interim in-boost skin damping).
Preferred: run the /negpy-review skill (.claude/skills/negpy-review/)
— it automates the steps below, applies the port/skip judging rules, and
updates this file. The manual procedure, for reference:
cd ~/Documents/code/NegPy && git fetch origingit log --oneline <last-reviewed>..origin/main— the unreviewed range.- Focus the diff on the pipeline we track:
git diff <last-reviewed> origin/main -- negpy/features/exposure/ negpy/features/process/ negpy/kernel/image/(normalization.py, logic.py, models.py/EXPOSURE_CONSTANTS, the WGSL shaders). Also check whether the characterization goldens moved (tests/test_scene_linear_relocation.py,tests/test_characteristic_curve.py) — golden changes mean a deliberate default-look change upstream. - Decide per change: port / deliberately skip (record why) / not applicable (UI, camera capture, CPU-parallelism, stages we don't ship).
- If constants or kernel semantics were ported: pull NegPy, re-dump fixtures
(
uv run python ~/Documents/code/SwiftInvert/scripts/dump_fixtures.pyfrom the NegPy repo — check the dump script against upstream signature changes first), updateK/ kernels,make test. - Update the Last reviewed marker and append to the history below.
Headline: upstream REDESIGNED the skin protection we ported yesterday.
bfcd90a + fb94aed replace 1b900ab's in-boost damping (what our
2026-07-30 port ships) with a separate operator, after measuring the
interim design's flaws: the hue-only 52±25° mask couldn't tell a face from
a red car (~40° sits in-band), it was inert at Chroma 1.0 (a face arriving
sunburnt from the print curve got nothing), and the first replacement's
wide chroma window reined sunsets at weight 0.98. End state at tip:
- The gamut-aware knee stays exactly as we ported it — but with the skin term REMOVED from inside it (pure gamut math now).
- Skin Protection is a user-facing Lab control (
skin_protection, default 0.5):skin_chroma_rein, a one-directional soft chroma ceiling (ceiling = 22/strength, softplus knee from 0.6×ceiling, scale blended by mask weight; a*/b* together so hue and L* never move) applied AFTER the saturation scale and independent of it — it also catches skin that arrived over-chromatic from the curve, and reduce-only means saturation 0 still reaches true grey and it can never overshoot the gamut the knee fitted. - The mask is the axis-aligned CIELAB skin locus: hue Gaussian 52° σ20 (was ±25), one-sided chroma window full ≤35 zero ≥60 via smoothstep (skin C≈12–40; pure red ≈104 drops out entirely), lightness rolloff below L15/above 95, chroma-gate 2 for hue stability. Measured weights after the tightening: skin 0.95–1.00 vs sunset 0.04, terracotta 0.18, brick 0.25, autumn leaf/rust 0.00.
- Their goldens provably unmoved (no skin-band pixels in the relocation frame); their WGSL parity test was strengthened after discovering a dead GPU mirror could pass the old one.
PORTED 2026-07-31 (same day, user approved). Landed exactly as
proposed below: the boost is pure gamut math again, skinProtection
(default 0.5, keyless sidecars adopt it like upstream) + skinChromaRein
both sides, fixture parity pinned at 0 in both harnesses, lab_color
re-dumped from a09cc46 (yesterday's b8c596c dump still carried the
interim in-boost damping — the two boost cases moved again and our pure
implementation matches at 1e-4), and the dead-mirror lesson became a
parity test whose PRECONDITION asserts the rein moves a deliberately
warmed frame (mean > 2e-3) before checking GPU==CPU. Known cost, accepted:
default-on rein dispatches colorPop every frame — bench 4.6 → ~7.2 ms
preview (still ~140 fps). 185 tests green.
Original proposal, for the record: remove the skin term from gamutAwareBoost (keep the
knee), add skinProtection (default 0.5) + skinChromaRein in colorPop
after saturation, both sides; pin skinProtection = 0 in the GPU fixture
parity settings (fixtures predate the control — same pattern as
preSaturation); colorPop activation gains the new term (default-on means
the pass always dispatches — bench it); dedicated coverage that actually
exercises the rein (their dead-mirror lesson). No fixture re-dump
(apply_saturation's new kwarg defaults to 0.0, so the dump script's
existing calls are semantics-stable).
Not applicable: a09cc46 Linear RAW defaults OFF + b6d9b8e scanning
setup wizard — their onboarding defaults for uncalibrated setups (the
wizard turns it back on); our linear rawpy-style decode is the pinned
reference contract, unchanged upstream. e62aacb filmstrip scrolling,
7528f9c favourites panel, df91cb4 Filtration reset icon, d1a3b61 IR
dust fixes, 076e676 test-junk cleanup, 16b8f6d-style prefetch order
(16b8fd6 — their prefetch; we retain sessions, no prefetch).
Still open (carried over): colour ring-around (±4cc/2cc spec),
91a1b78 tunable targets (user-initiated only), the mixer band re-tune.
Kernel status: no golden moves, no constants drift — but three port candidates, one of which is a bug-class we share.
PORTED 2026-07-30 (same day, user approved) — items 1 and 2 (see the
commits for full detail): Separation Damping landed with the exact gain
formula, ref spread 0.35 as a new constants sync point, and the
population-reversal contract tested both ways; gamut-aware Chroma
landed in LabColor + colorPop with the skin band, bisection and knee —
the dump script's lab_color case was repaired at the same time (frozen
verbatim apply_vibrance for our SwiftInvert-maintained control, upstream
apply_saturation for the rest) and lab_color partially re-dumped from
b8c596c: ONLY the two boost cases changed, desat/in-gamut/vibrance
byte-identical, and our Double implementation matches upstream's float32
fixtures at the 1e-4 gate. A dedicated tight CPU/GPU test (mean < 1e-3,
max < 0.02) guards the path their transposed-matrix bug hid in.
Original proposals, for the record:
- Gamut-aware Chroma + skin-tone protection (
1b900ab) — a flat Lab a*/b* scale preserves hue, but overshooting pixels get hard-clamped PER RGB CHANNEL afterward, which shifts the visible hue (their measurement: a 1.2× push clips 6.3% of a realistic population, mean hue error 8.65° on the clipped set). Fix: bisect each pixel's true in-gamut headroom (10 iterations) and soft-knee toward it; in-gamut pixels stay byte-identical; pulls (<1.0) unchanged; skin-tone hues get a gentler push. Our colorPop has the same hard clamp (clamp(lab_to_rgb(lab), 0, 1)after saturation/vibrance/mixer) — the bug class applies verbatim. Port = LabColor.swift + colorPop MSL both sides + skin-band constants; CAUTION: changesapply_saturationsemantics for clipped pixels, so thelab_colorfixture must be re-dumped (requires the dump-script adaptation first) or its clipped cases recalibrated. Medium effort. Their war story worth keeping: a transposed RGB↔XYZ matrix inside the bisection masqueraded as float precision noise — verify our port with their tight CPU/GPU gate (~1e-4), not just the 0.04 parity bar. - Separation Damping (
d86a5aa,separation_dampingτ 0…1, default 0 = byte-exact off): makes the dye-separation k chroma-selective per pixel —h(c) = (c0−c)/(c0+c),k_eff = k^((1−τ)+τ·h(c))on the RMS dye-density spread above base — so a push lands on muted colour and REVERSES on already-extreme colour (gain crosses 1.0; no flat k reproduces it: their Ektar sample reads ×1.37 on the least-coloured decile, ×0.89 on the most). The per-pixel-targeting idea we ported and retired, reborn as a modifier of the surviving control: maps directly onto our printSaturation (identity dye matrix → k_eff around the achromatic mean). Only meaningful when printSaturation ≠ 1 (grey out at 1.0, like upstream). Kernel both sides + one constant (their c0 reference spread); no re-dump. Medium. - Proof-ladder rotation (
a2455ab) — PORTED 2026-07-30 (same day): TestStrip gained orientation 0–3 (logical grid rotated under the fixed display grid; bijection + axis-purity tested per orientation), rotate buttons/⌘[/⌘] intercepted while a strip is up or building, labels/preview badge/picks orientation-aware, orientation sticky for the session. Documented divergence: upstream assembles all four orientations from cached tiles; we re-render from the warm tower (~one strip build) instead of retaining 25 preview-size tiles.
Noted: the ring-around spec at the tip is now ±4cc in 2cc steps (was ±2cc/1cc when first proposed) — still carried as an open candidate.
Not applicable: d982c4b zoom-button alignment (Qt), b8c596c
tethered-capture deletion for bodies without a capture target (camera
scanning), 4aa1001/540cd9a/65c7667/9714664/1869f2e
changelog/screenshot/docs churn.
Still open (carried over): colour ring-around (above), 91a1b78
tunable Auto Density/Grade targets (user-initiated only), the on-scan
Color Mixer band re-tune pass (ours).
Headline: upstream DELETED the per-pixel Dye Separation — the control we
ported this same morning — after measuring it redundant with Print
Saturation. The three-commit saga (3ca2229 → 1d02071 → 3fb5ca8)
ends with only the matrix-slot density saturation surviving, renamed
dye_separation (default 1.0, slider 0.25–1.75, per-channel trims). The
range's golden-file touch is comment-only (default annotations; no numeric
move — both retired states were identity).
The saga, for the record (each step supersedes the last; only the end state matters for porting):
3ca2229measured the mask's problem: the 0.4 spread gate put the sigmoid half-point at 0.44 D spread — the p90 of real frames — so ~90% of pixels sat on the mask's flat top and the control acted as a near-uniform gain; per-pixel deltas correlated r=0.94/0.97 against Print Saturation on two real frames ("a second Print Saturation"). Fix: gate 0.4 → 0.12 (median-spread calibrated), correlation → 0.64/0.75.156252awidened the desktop slider;1d02071gave the positive branch a ×3 gain (muted pixels have tiny deviations by selection, so k−1 barely moved them) and returned the slider to ±0.5.3fb5ca8concluded "the two density-domain colour controls read the same on real frames" and deleted the per-pixel block from BOTH engines (kernel block, WGSL constant, UBO slot,dye_separation_spread_scalegone);density_saturation(+trims) renameddye_separation(+trims); their sidecar migration DROPS retired per-pixel values.
PORTED (RETIRED) 2026-07-29 (same day, user approved) — the per-pixel
dyeSeparation is gone, converging on the end state. Our port (this
morning, UPSTREAM entry below) was the ORIGINAL 0.4-gate variant —
precisely the one upstream measured as r≈0.95 redundant with our
printSaturation; porting the intermediate fixes just to land where
upstream started from would have been churn. Removed: the kernel block
from BOTH ReferenceCurve.swift and NegPipeline.metal,
K.dyeSeparationSpreadScale + the DYE_SEPARATION_SCALE MSL literal
(that constants-sync point is closed), the CurveUniforms field + pads
(stride 272 → 256, LayoutTests re-pinned), the settings field + decoder
line + history label + UI slider (tripwires 50 → 49). Mirroring
upstream's migration, a sidecar dyeSeparation key from the field's
hours-long lifetime decodes IGNORED — pinned by a new
DensityChromaTests retired-key test (the suite's dye-separation
behavioral cases dropped with the control; the sign-selects-population
contract is upstream history now). The Lab vibrance help text now points
at Print Saturation as the density-space counterpart. printSaturation
STAYS under our name — a recorded cosmetic divergence (upstream calls
the survivor Dye Separation; renaming would collide with the retired key
in same-day sidecars). UI range kept 0–2 (upstream 0.25–1.75; derive
clamp 0–3 unchanged). Per-channel trims remain not-ported (recorded
skip). NO fixture re-dump (identity in every dump config). All 167 tests
green (incl. GPU/CPU parity with printSaturation 1.4 active); bench
4.0 ms/frame steady state; app renders headlessly. CLAUDE.md updated —
including its tripwire counts, which had gone stale at 48 during the
morning port (now 49) — and the constants-sync list.
Carried port candidate updated: db37476 widens the colour
ring-around (item 3, still to port) to 2cc steps out to ±4cc — 1cc
rungs were unreadable on a preview-sized mosaic. RING_CC_STEP 0.05 →
0.1; any port should use the new spec.
Not applicable: 4f4d7a1 broken live view no longer kills the
capture session (camera scanning), 4ee5267/7dc7903 Analysis-panel
info button + guide-window centring (their Qt help UI), 111492b IR
channels from 64-bit HDRi RAW DNGs (retouch/IR loading), 35b6994
crosstalk Separation → Strength slider rename (their crosstalk UI/docs —
renamed to avoid colliding with the NEW Dye Separation; no math),
156252a desktop slider range (superseded by 3fb5ca8), 2f91a98
version bump/discord URL, 723e2c5 user-guide churn.
dump_fixtures.py: still broken ≥de79e13 (lab_color apply_vibrance
import — carried); the 3fb5ca8 apply_characteristic_curve signature
change is benign for the script (no saturation kwargs passed; new
dye_separation default 1.0 is identity).
Still open (carried over): colour ring-around (b646e23 + db37476
spec); 91a1b78 tunable Auto Density/Grade targets (user-initiated
only); the on-scan Color Mixer band re-tune pass (ours).
The chroma stack was rebuilt upstream across three commits, with TWO golden moves — the biggest pipeline reshape since 127bcd7. End state:
7bc8bdcPrint Saturation (density_saturation, default 1.0, + per-channel trims): density-space saturation — an achromatic-projection matrixk·I + (1−k)·Jcomposed into the paper dye-crosstalk matrix slot, applied to density-above-base AFTER the H&D curve, so its push scales with what the curve left in each channel (shoulder-compressed channels move less) and composes per paper.c0d6ff0(golden move) folded Dye Mute into that density-space slot as a grade-coupled damping ofdensity_saturation(default OFF, was 0.25) and renamed Lab Saturation → Chroma.de79e13(golden move) replaced Dye Mute AND Lab Vibrance with one signeddye_separation(−0.5…+0.5, default 0): a per-pixel spread-masked rescale of dye-density separation inside the print-curve kernel — positive boosts muted pixels (vibrance), negative compresses already-separated ones (true anti-vibrance, which Lab never had); the sign flips the mask's TARGET population, not just the direction. Lab Vibrance is deleted (LabUniforms shrank;apply_vibrancegone).
Bookkeeping — two records close/change:
- The Dye Mute skip is CLOSED, vindicated: the control we declined (2026-07-17, reaffirmed 2026-07-27) no longer exists upstream; its default had already been walked 0.5 → 0.25 → off before deletion.
- Our Lab vibrance now has NO upstream counterpart (their vibrance is the density-space Dye Separation). Ours stays for sidecar compat and is identity in every parity config — recorded as a SwiftInvert-maintained control unless/until we converge on Dye Separation.
PORTED 2026-07-29 (same day, user approved) — items 1 and 2:
Print Saturation (printSaturation, default 1.0, UI 0–2, derive clamp
0–3) and Dye Separation (dyeSeparation, signed ±0.5) landed through
the full control checklist: settings + decoder + history labels (tripwire
counts 48 → 50), derive passthrough, printCurve BOTH sides — after the toe
softplus, on density above paper base, Print Saturation as the uniform-k
mean-deviation reduction of NegPy's saturation matrix (identity dye matrix
here; per-channel trims NOT ported, consistent with the recorded
per-layer-trims skip), Dye Separation with the exact spread-sigmoid mask
(2·σ(spread/0.4) − 1, sign selects the population; scale =
K.dyeSeparationSpreadScale, MSL literal — new constants sync point).
CurveUniforms grew via the ex-pad slot + a tail field (stride 256 → 272,
pinned in LayoutTests). DensityChromaTests: identity at defaults, neutral
invariance, k-direction on density spread, the sign-selects-population
contract both ways, sidecar round-trip; the tone-controls GPU-parity case
now runs with both active. No fixture re-dump (identity in every dump
config; all 160 tests green incl. NegPy fixture parity). Bench steady-state
unchanged (~4 ms/frame). Lab vibrance stays visible for now (its help text
points at Dye Separation as the successor); hide/deprecate is a future UX
call.
Still to port (proposed, not yet implemented):
3. Colour ring-around (b646e23, Shift+F) — the RA4 filtration proof:
5×5 mosaic of real renders stepping ±2cc in 1cc steps on magenta and
yellow, absolute ladder centred on neutral; click a patch to keep its
filtration; shares the canvas with the test strip. Pairs naturally with
the carried test-strip port.
4. Test strip — PORTED 2026-07-29 (same day, user approved; revised
5×5 spec): TestStrip grid math + incremental mosaic assembly in
NegativeKit (tiling/hit-test/nearest-rung/assembly all closed-form
tested), ImageSession.renderTestStrip reusing the warm cache tower
(one analysis, one source upload, 25 derive+renderDisplay passes,
~150–250 ms, never HQ), canvas presentation with upstream's rules (no
gridlines, hovered-patch outline, bold axis labels, current-rung
accent), Print-group button + ⇧T (key monitor, text-field-guarded) +
Escape-clears-first. Click = one history entry setting density+grade;
auto toggles untouched (patches were rendered under them); cleared by
any edit/navigation/tool/baseline/HQ change, stale builds fenced by a
generation counter. One documented presentation divergence: columns
mirrored so the image BRIGHTENS left→right (our "right = brighter"
slider convention; upstream draws density ascending).
TestStripRenderTests pins the core invariant end-to-end on the GPU:
every mosaic patch is byte-identical to a full render at that patch's
settings. 167 tests green.
Noted, not proposed: 38aa023 step wedge (21-step transmission wedge
printed through the frame's settings under their curve chart — tied to the
H&D/Analysis panel we deliberately don't ship, same rationale as the
density_histogram skip); 64b6e79/9b04748 grain-focuser loupe (2×
unsmoothed loupe + acutance figure, preview/full-res badge — UI tool; the
acutance read-out is the interesting fragment if we ever want a sharpness
aid).
Not applicable: da9a033 contact-sheet tiles at proof scale (feature
we don't ship), 717db98 hide _IR TIFF sidecars from the contact sheet
(retouch/scan stack).
dump_fixtures.py: BROKEN ≥de79e13 — see the Last reviewed block; adapt the lab_color case before any re-dump past this range.
Still open (carried over): 91a1b78 tunable Auto Density/Grade targets
(user-initiated only); the on-scan Color Mixer band re-tune pass (ours).
Kernel status: untouched — no golden moves, no constants. The two
features/exposure|process/ hits are additive UI-feature math and capture
gating; dump_fixtures.py unaffected.
To port (proposed, not yet implemented — presentation layer, not
pipeline): e4bc450 Test strip (0.45.0's headliner, Shift+T): the
frame printed as a 6×6 grid of REAL renders — Print Density 0.4…1.9 in
steps of 0.3 left to right, Grade R55…R180 in steps of 25 top to bottom —
so the diagonals read light-to-dark and soft-to-hard like a split-filter
test strip; click a patch to commit its density+grade. Design decisions
worth keeping: ladders are ABSOLUTE (strips comparable frame to frame,
both straddle the defaults inside slider ranges), Auto Density/Grade stay
untouched on pick (the patches were rendered under them), always
preview-res, session-only state cleared by any real edit, NO gridlines
(reads as one print; only the hovered patch outlines), current-settings
rung accented on the axis labels. Their new analysis.py helpers
(strip_cells/strip_mosaic/strip_patch_rect/strip_cell_at/
strip_nearest_cell) are pure grid math — a Swift port is app-layer:
36 derive+renders at preview size is ~150–250 ms here (our slider path is
~5 ms/frame), one mosaic texture or 36 draws, hit-test + commit. No
parity surface, no fixture re-dump. Moderate effort (~half day).
Not applicable: 6073a98 sensor-profile gating when linear raw is off
(crosstalk/capture stack), 44f2a74 BEFORE-badge sync (their compare UI),
d06f45f auto-crop rebate + Rebate Trim (their camera-scan auto-crop
detection; we ship none), 9ef7732 settings-picker default-values fix
(their preset picker), acf9ca5 IR-removal mottling on noisy IR planes
(retouch).
Still open (carried over): 91a1b78 tunable Auto Density/Grade targets
(user-initiated only); the on-scan Color Mixer band re-tune pass (ours).
Kernel status: untouched — no golden moves, no constants, no parity
impact. The one features/exposure/ hit is additive measurement math for
a UI feature (below); dump_fixtures.py unaffected (its density_histogram
import is signature-stable).
PORTED 2026-07-27 (same day, user approved): ae56f8b Zone system
overlay — ZoneGrid (NegativeKit: grid + flood-fill region labels, closed-
form tests incl. stride padding, concave-anchor and densitometer-ruler
agreement), ZoneOverlay (SwiftUI Canvas: merged-region edges with dark
underlay, bold numerals, paper ends red), computed from DensitometerState's
cached bytes per adopt/toggle, gated to the plain presentation like the
probe. Toggle: "Zones" control-bar button + View menu + ⇧Z — implemented in
the window key monitor with a first-responder text guard (a bare-letter menu
equivalent would fire while typing; lesson from the profile-name field).
Deviation from upstream, documented in code: integer-boundary box means
instead of INTER_AREA (differences sit under the zone rounding step; no
parity surface). 147 tests green. Original proposal, for the record:
integer Adams zone per cell of a fixed grid (24 cells along the long edge)
computed from the display-encoded frame via area-average downsample (the
averaging damps grain — no extra smoothing); contiguous same-zone cells
merge into regions (shared edges simply not drawn), one bold Roman numeral
per region with the label anchor snapped to a cell the region actually owns
(a concave region's centroid can land outside it), paper black (0) and
white (X) flagged red, dark underlay beneath the grid lines for readability
over blown highlights; built once per render so toggling costs a repaint.
Crucially it reads the SAME zone ruler as the sidebar zone strip and spot
densitometer ("the three never disagree") — which we already ported
(Densitometry.zone(ofEncoded:) over the displayed rgba8 bitmap, cached in
DensitometerState). A Swift port is pure app-layer: grid + tiny
connected-components over a ~24×16 int grid (no OpenCV needed), a SwiftUI
canvas overlay, and a toggle; no parity surface, no fixture re-dump.
Moderate effort (~half day incl. the pinned-to-picture pan/zoom mapping).
Not applicable:
f7f3972chroma denoise edge-bloom fix (taps weighted by chroma similarity as well as distance; slider renamed Chroma Denoise) — lab denoise stage we don't ship. Divergence note updated: if denoise is ever implemented, port THIS edge-aware version.b9b98c3DNG export dropped + config-migration centralization (their export formats/config plumbing; we never offered DNG),a8027afthumbnail-cache clear in Manage Database (their DB tooling),31aea5crelease chore. (0.44.0 changelog's remaining entries — Dye Mute 0.25, glow/halation luma, paste re-meter, filed-carrier flare — were all reviewed in the two prior entries.)
Still open (carried over): 91a1b78 tunable Auto Density/Grade targets
(user-initiated only); the on-scan Color Mixer band re-tune pass (ours).
One golden move, and it's inside a recorded skip: 6fd61c5 lowers the
Dye Mute default 0.5 → 0.25 (lab/models.py chroma_damping; relocation
goldens regenerated — "less chroma damping on the default look"). This is
the divergence baseline moving TOWARD us: upstream themselves flagged the
0.5 default as tuned on the old ProPhoto gamut (2026-07-20 review) and have
now halved it, walking their canonical look halfway back to our no-damping
position. Skip stands, reinforced; reopening condition unchanged
(visible oversaturation at hard grades on real rolls) — and if Dye Mute is
ever ported, the reference default is now 0.25. Our fixtures are unaffected
(damping is derivation-side and no dump config exercises it);
dump_fixtures.py signatures unaffected.
Not applicable (each checked):
3ca00f2glow/halation luma → Adobe RGB weights (0.2974/0.6273/0.0753): fixes ONLY the glow/halation taps infeatures/lab/(stage we don't ship). Verified the core analysis luma upstream is still Rec.709 (domain/types.pyLUMA_R 0.2126 at the tip) — so ourK.lumaR/G/Bmirror remains correct for the uses we ported (bounds/meters/histogram/ densitometry). Worth remembering if we ever add a halation-class effect: luma taken on WORKING-encoded triplets must use working-space weights.5a00dd9re-meter when pasted settings change the analysis region — their paste can transfer the region; ours structurally can't (analysisRect is in thekeepingGeometrylist, never pasted), so the bug has no counterpart here.0a9d22aslider commit baseline on external sync (their Qt settings-db plumbing),f783fd00.43.1 release chore,9722a9cfiled-carrier bevel flare + paper margin + 2-D edge roughness (print finishing, out of scope).
Still open (carried over): 91a1b78 tunable Auto Density/Grade targets
(user-initiated only — see 2026-07-26 note); the on-scan Color Mixer band
re-tune pass (ours, post-b3490eb).
Kernel status: untouched — a genuine null. The path-filtered log over
features/exposure/, features/process/, kernel/image/ and the
characterization goldens is empty; no renames; no VERSION/CHANGELOG
movement. The one render-adjacent hunk was read in full:
desktop/workers/render.py (+5) adds a separate Qt signal so a batch
thumbnail overwrite can't clobber an already-rendered canvas frame — their
worker plumbing, zero kernel lines. No fixture re-dump, no constants
drift, dump_fixtures.py unaffected.
Ported: nothing (nothing required).
UI idea worth noting (not pipeline): e5f99ff slider UX — their Qt
CompactSlider now grabs on click anywhere in the groove (absolute jump,
Shift = relative fine-drag) and forwards near-miss clicks in an ~8 px band
above/below the groove into a real drag. Our SwiftUI sliders already
click-to-jump; the near-miss band + Shift-for-fine-drag are candidates if
slider precision ever comes up.
Not applicable: 9319408 camera live-view ability checks (capture),
00d01c3 RGB-triplet thumbnail rendering (their thumbnail service;
camera-RAW only here), 0e5b3f6 macOS release CI (their packaging).
Still open (carried over): 91a1b78 user-tunable Auto Density / Auto
Grade targets — the "needs a Settings surface" blocker is weakening now
that the app has the profile picker/editor windows (2026-07-25), but those
manage ADJUSTMENT profiles; tunable targets are analysis CONSTANTS
(anchor/grade calibration), a different layer. Note the adjacent history:
an anchor-meter recalibration A/B for these same constants was built and
dropped 2026-07-25 (user: don't pursue) — any tunable-targets port should
be user-initiated. Also open: the on-scan Color Mixer band re-tune pass
(ours, post-b3490eb).
Kernel status: ANALYSIS SEMANTICS MOVED UPSTREAM — goldens regenerated.
One commit, 127bcd7 ("Improve cast-removal estimators and fix chart cast
parity", 0.43.0's headliner), rewrites the two analysis estimators we ported,
with test_scene_linear_relocation.py goldens moved (small deltas ~1e-3 on
the synthetic scene — it has no strong cast; the point of the change is real
scans WITH casts). No WGSL/shader changes anywhere in the range — this is
CPU-analysis only, so a port touches NegativeKit + fixtures, not the Metal
side.
PORTED 2026-07-25 (same day, user approved and chose it as the default)
— 127bcd7, all pieces together as one semantic unit. Landed in
Meters.neutralAxis (two-pass rewrite), BoundsAnalysis.samePixelColorFloorRefs
analyzewiring,K(six constants), andStats[Double] overloads (np-matching percentile/median — the same-pixel path is float64 end-to-end like upstream, bit-comparable arithmetic). The vestigialanalyze(channelsSorted:)public overload was folded away (offset re-derives stopped re-running analysis at 2125a34; nothing external used it). Fixtures re-dumped from0369b10; all 132 tests pass, including AnalysisParityTests/GridParityTests against the new-estimator fixtures. Branch coverage verified positively: the same-pixel branch FIRES on synthetic_grid (refs ≈0.1D off the percentile floors, Swift matches) and correctly falls back on synthetic64; the two-pass axis moves synthetic64's confidence 0.984 → 0.337 (the n/(n+256) size term bites on a 32×32 grid — real previews sit near 393k grid px, where it doesn't). Real-scan A/B (three CR3s,negcli meterold vs new binary): neutral-rich frames move ≤0.004D; IMG_0365's bright probe went 0.768/0.785/0.791 → 0.789/0.784/0.777 — a slightly cyan highlight now reads neutral, the exact failure mode the same-pixel floors target. An independent line-by-line Python↔Swift audit cleared all eight semantic areas and surfaced two last-ulp fidelity gaps, both fixed:Stats.percentileOfSortednow mirrors numpy's two-sided_lerp(interpolates from the upper bound when frac ≥ 0.5 — both overloads), and the neutral axis's pass-1 luma/chroma read the ROUNDED float32 norm values (upstream computes them from the float32 normalized image). The audit's remaining note (two guards invert only under NaN) is unreachable: the grid is log10(clip(...)), finite by construction. Bench: slider path unchanged (4.6 ms/frame); prepare 157→182 ms, finalize 25→47 ms (per-image analysis, matches the added work). CLAUDE.md §2 + the constants-sync paragraph updated.
The pieces, for the record:
-
Hue-uniform chroma metric: near-neutral ranking switches from
max−minto pairwise RMSsqrt(((r−g)²+(g−b)²+(r−b)²)/3)— max−min scores an opposed R/B split double a same-side deviation. Used by both estimators below. Maps to ourfinalizeneutral-axis band selection. -
Two-pass neutral axis (
measure_neutral_axis_from_log): pass 1 selects under a LOOSE cap (neutral_axis_first_pass_cap0.55 — admits strong-but-correctable casts the old single 0.35 cap rejected outright; saturated content still fails); the affine R/B→G correction implied by pass-1 mid+shadow refs (normalized space) re-ranks chroma; pass 2 selects true neutrals under the strict cap, now 0.29 (was 0.35). -
Confidence rebuilt:
tight × size × agreement—tight = 1 − max(midChroma, shadowChroma)/capon CORRECTED chroma (was mid-only, uncorrected);size = n/(n+256)(neutral_axis_confidence_n0);agreement= 1 minus the mid↔shadow R/B deviation-difference beyond a 0.10 dead zone, rolled off over 0.20 (neutral_axis_agreement_deadzone/_scale). Drives ourstrength = confidence × sliderdirectly. -
Same-pixel colour floors (
_same_pixel_color_floor_refs+analyze_log_exposure_bounds_from_logwiring): the colour axis's dense end (print whites) now reads ONE shared chroma-gated pixel set — the luma-extreme percentile band[colorClip, colorClip+4](color_bounds_band_width4.0), chroma measured base-anchored (offsets from the thin-end refs, per-channel span as provisional gamma, refined once from band medians, same two-pass loose/strict caps) — instead of independent per-channel percentiles, so coloured highlight content stops masquerading as film cast. Falls back to the percentile pass when the band holds no trustworthy neutrals. The thin end stays percentile-based (film density is bounded below by base). Maps toBoundsAnalysis.analyze's colour axis.New
Kconstants:neutral_axis_chroma_cap0.35→0.29,neutral_axis_first_pass_cap0.55,neutral_axis_confidence_n0256,neutral_axis_agreement_deadzone0.10,neutral_axis_agreement_scale0.20,color_bounds_band_width4.0. All CPU-side (no MSL duplicates).dump_fixtures.pyverified compatible before the re-dump — every imported symbol's signature is unchanged at 0369b10.
Not applicable:
127bcd7's chart-parity half:cast_solve_inputs(single source of truth for CPU processor + chart — ourderiveRenderParamsalready IS that),CharacteristicCurvegainingcurvature(chart-only class; their render pathapply_characteristic_curvealways had it, as does ourReferenceCurve), GPU engine exporting raw cast refs (chart plumbing).ab5a6adsensor-crosstalk calibration: 3×3 CFA unmix on the linear capture for single-shot narrowband-LED camera scans, calibrated from bare- light exposures, default None, gated off for RGB-triplet composites — capture-side crosstalk stack we don't ship. Noted for the crosstalk thread: upstream now formally splits sensor crosstalk (linear domain, pre-inversion, per-setup) from dye crosstalk (density domain, Density Mixer) — the plannednegcli chart-solveis on the dye side and remains a SwiftInvert-original.8a1a3e1selectable scanner backend,6cea8a5live-view drop/output- folder guards (capture stack);d7e2502IR sidecar case-insensitive matching (retouch);59e5352DMG target-arch build flag (their packaging);6e294fa/d7ca80c/5a9cc2d/9b2345a/91cac6ddocs,76ae85f/f85877bchangelog,0369b10lint.
Still open (carried over): 91a1b78 user-tunable Auto Density / Auto
Grade targets (blocked on a Settings surface); the on-scan Color Mixer band
re-tune pass (ours, post-b3490eb).
Kernel status: untouched — a genuine null. The path-filtered log over
features/exposure/, features/process/, kernel/image/ and the
characterization goldens is empty; no renames in the range; the full
diff --stat touches only desktop/Qt, presets, contact-sheet and export-form
code. The one rendering-adjacent hunk was read in full:
services/rendering/image_processor.py (+18) is the contact-sheet tile
downsampling fix (fdf43b7) — memory management in a feature we don't ship,
zero kernel lines. No fixture re-dump, no constants drift,
dump_fixtures.py unaffected. Changelog: 0.41.0 finalized + 0.42.0 opened,
all entries UI/workflow/export-plumbing.
Ported: nothing (nothing required).
UI ideas worth copying independently (not pipeline):
ec66499per-setting copy/paste + apply-to-roll: pasting opens a picker listing exactly the settings that differ on the source frame, grouped by section with values shown; per-frame geometry never overwritten. Our Copy/Paste Adjustments (⇧⌘C/⇧⌘V) is all-or-nothing (geometry excluded) — the granular picker is the natural upgrade if multi-frame workflows grow.1dcccbepresets redesign builds on the same picker (a preset stores exactly the ticked settings, applied as overlay or replace) — relevant only if we ever grow presets.
Not applicable: fdf43b7 contact-sheet tile memory (feature we don't
ship), 4772dcd export-destination-mode restore (their export form; our
exportOptions JSON blob round-trips the destination already), 07dd965
print-border tool-coordinate compensation (we ship no print borders, and
our coordinate space is unified by baking orientation into pixels),
5e08828/650cf4b changelog/lint/skill churn.
Still open (carried over): 91a1b78 user-tunable Auto Density / Auto
Grade targets (blocked on a Settings surface); the on-scan Color Mixer band
re-tune pass (ours, post-b3490eb).
Kernel status: untouched — a genuine null. The path-filtered log over
features/exposure/, features/process/, kernel/image/ and the
characterization goldens is empty; no VERSION/CHANGELOG/PIPELINE.md movement.
No fixture re-dump, no constants drift, dump_fixtures.py unaffected.
Not applicable (all three, each diff checked):
a9e5169ICC tagging fixes across export formats — read in full because export tagging is a shared bug class, but all four defects live in features we don't ship: greyscale JPEG/WebP CMS (B&W), ACES/XYZ export targets (rawpy decode spaces; our export offers only sRGB/Adobe RGB), untagged contact-sheet JPEGs. Our ImageIO path tags from the CGImage's colorspace and can't produce the swallowed-lcms-error untagged case.40f7d2ftoolbar streamline + compare/flat-peek stabilization — Qt UI. Their fix (rotate/flip re-render INSIDE an active compare view viarerender_active_viewinstead of dropping to the plain edit) doesn't map: our baseline compare is hold-to-press (showingBaseline), so geometry ops can't interleave with it, and we have no flat-peek.1d48e01stitch-config sidecar-key whitelist warning — scan stitching, capture side.
Still open (carried over): 91a1b78 user-tunable Auto Density / Auto
Grade targets (blocked on a Settings surface); the on-scan Color Mixer band
re-tune pass (ours, post-b3490eb).
Kernel status: untouched — a genuine null. The path-filtered log over
features/exposure/, features/process/, kernel/image/ and the
characterization goldens is empty; no renames in the range; the pipeline-tree
diff is empty. No fixture re-dump, no constants drift, dump_fixtures.py
unaffected. The range is film-scanner integration (Coolscan/SANE bba487d,
RGBI + IR dust over SANE 8d6f44e), IR dust reconstruction rework
(f612a74), lab sharpen improvements (d3436c7 — stage we don't ship),
multi-part scan stitching (5e141df — capture-side composite assembly),
contact-sheet/DB/UI work, and docs churn.
Noted for the active crosstalk thread (not a pipeline change): a27f035
widens the crosstalk matrix adjustment range in their EDITOR dialog by one
line — the matrix math is untouched. Confirms the Density Mixer remains
manual upstream; the chart-based auto-solve being planned here
(negcli chart-solve, see 2026-07-20/21 conversations) stays a
SwiftInvert-original with no upstream counterpart to converge with yet.
Not applicable: 2d8e55e TIFF sRGB decode gating (their TIFF ingestion;
we're camera-RAW only), 67e2051 scan-window exposure gating, ce28dc1 DB
dialog, 9c260e2 contact-sheet labels, 2cdc569 filmstrip thumbnails,
b805578 icon/migration fixes, readme/changelog/tutorial commits.
Kernel status: untouched. Zero commits in the range touch
features/exposure/, features/process/, kernel/image/, or the
characterization goldens — the path-filtered log AND git diff --stat over
those trees are empty, and there are no renames in the range
(--diff-filter=R empty). The shared-kernel-adjacent hunks were checked by
hand: lab.wgsl + lab/logic.py/models.py changed ONLY the sharpen block
(rewritten for the 0.40.0 sharpening feature — zero lines touch vibrance/
saturation/Dye Mute or the Lab matrices our colorPop mirrors), and the
domain/models.py hunks are sidecar migrations for features we don't ship
(IR inpaint radius, filed-carrier toggle fold). No fixture re-dump, no
constants drift; dump_fixtures.py signatures unaffected (exposure
normalization.py/logic.py unchanged).
Ported: nothing (nothing required).
Divergence baseline moved (no action): d3436c7 rebuilt sharpening —
Method selector (Unsharp Mask with halo-suppression overshoot clamp +
gradient Masking, or Richardson-Lucy deconvolution on linear Y applied as a
chroma-preserving RGB ratio), Radius/Masking sliders, preview/export parity
via shared gaussian_kernel_1d taps (the old fixed 5×5 GPU kernel sharpened
the wrong band at export scale). We don't ship sharpen (recorded
divergence); if it's ever implemented, port THIS version (PIPELINE.md §5.4
documents the full math). Default sharpen stays 0.25.
Noted upstream self-doubt on Dye Mute (strengthens our skip): PIPELINE.md now carries "(The default was tuned against the old ProPhoto working gamut — it may run strong now that the working space is Adobe RGB.)" — upstream themselves flag the 0.5 default as possibly too strong post- b3490eb. Our 2026-07-17 skip stands; reopening condition unchanged (visible oversaturation at hard grades on real rolls).
UI idea worth copying independently (not pipeline): b805578's
analysis-region active-indicator — a small dot on the Freedraw Analysis
Region button whenever a custom region is overriding the buffer. We have the
same override semantics (resolve_analysis_region port) and the same
discoverability gap after the tool closes.
Not applicable: a27f035 crosstalk matrix range (stage we don't ship),
bba487d Coolscan/SANE film scanning (capture stack), f612a74/8a98326
IR dust reconstruction (retouch; runs pre-normalization on IR-carrying
scans — we're camera-RAW only, no IR plane), d3436c7's non-sharpen bulk
(their GPU engine sharpen-pass plumbing), c3e83ff tooltips, b805578's
icon fixes/carrier migration, c53aa26 USER_GUIDE rewrite, 2cdc569
filmstrip thumbnail fixes.
Still open (carried over): 91a1b78 user-tunable Auto Density / Auto
Grade targets — blocked on us growing a Settings surface; the on-scan
Color Mixer band re-tune pass (ours, post-b3490eb).
Seven pipeline-relevant commits — the deepest range since the original port, headlined by a full working-space swap.
The headline: b3490eb Adobe RGB (1998) working space. Upstream
REVERSED the 2026-07-16 ProPhoto output fix: full ProPhoto primaries at
output are now judged "unnaturally saturated, pain to correct", and the
whole pipeline boundary moved to Adobe RGB — OETF ROMM TRC (1.8 + linear
toe) → pure 563/256 gamma (no linear segment), output tagging, Lab
matrices, CLAHE/toning shaders, display/export management, ALL goldens
regenerated. Dye Mute stays at default 0.5 on top, so their canonical look
is now markedly more muted than either the pre-fix or the ProPhoto-era look.
Third output-space change in four days (stale Adobe RGB bug → ProPhoto
- Dye Mute → deliberate Adobe RGB).
Judgment (revised 2026-07-20 after researching issue #537 / PRs 518/538/544): the "third change in four days" framing was wrong — this is NOT a flip-flop. NegPy's output had been Adobe-RGB-interpreted for its whole life via a stale tag; PR #518 exposed true ProPhoto for the first time, two independent users immediately reported neon reds AND hue shifts (red→magenta, sky→cyan) with image evidence, Dye Mute could damp chroma but not hue, and #544 restored Adobe RGB coherently (primaries + pure 563/256 TRC + D65) within three days. The argument (user thetalkingdrum, adopted verbatim by the maintainer): the pipeline ASSIGNS primaries to raw uncharacterized sensor RGB at output — an interpretation, not a conversion — and ProPhoto's imaginary green/blue primaries stretch dye separations outside the spectral locus. Decision is settled upstream (both reporters confirmed the fix; follow-ups are routed to crosstalk characterization).
The argument applies to SwiftInvert verbatim — we tag sensor-native pipeline output as ROMM the same way — and one of our own divergences may be evidence: redHue +0.5 exists because "C-41 reds skew magenta out of the box", which matches the reported ProPhoto-interpretation hue shift exactly. A correct-primaries output might let redHue return toward 0. PORTED 2026-07-20 — the user chose convergence over A/B ("I want to stay close to the main project"). Full port: WorkingOETF → pure 563/256 gamma (Swift + MSL 0.45470693 literal), Lab matrices/white → Adobe RGB D65 both sides, output tagging → adobeRGB1998 (ImageConversion, ColorIO workingColorSpace, negcli TIFF), littleCMS oracles regenerated from NegPy's AdobeCompat-v4/sRGB-v4 at 96adfde, fixtures re-dumped from 96adfde (dump script adapted to the paper_black rename, manifest key stable), coupled constants ported (anchor 0.75, grade target 0.6, strength 0.5). Space-sensitive tests recalibrated with intent preserved: True Black's contract restated in the linear domain (the old encoded thresholds baked in the ROMM toe), pre-saturation chroma margin 1.05 → 1.02 (smaller-gamut Lab), mixer-band test pixel moved in-band (real- content blues land 240–265° in the new Lab — the band constants themselves survive). ExportColorSpace's wide option relabeled Adobe RGB (case name kept for sticky-JSON compat). Export-note: existing exports re-render slightly different, as upstream's changelog warns. Follow-ups: redHue +0.5 RETIRED same day (it countered the hue skew this port removes at the root; default 0 again, explicit sidecar values preserved); the on-scan mixer-band re-tune pass remains open. 132 tests green incl. GPU parity against Adobe-world fixtures; bench unchanged.
Coupled to the above — do NOT port separately: 2db0470 + 088c393
auto-constant tunings (anchor_target_density 0.74 → 0.75, auto_grade_target
0.55 → 0.6, auto_grade_strength 0.3 → 0.5, goldens moved). These were tuned
against the Adobe RGB output; applying them under our ROMM output applies
their new-space taste to our old-space look. Port together with b3490eb or
not at all.
Ported (2026-07-20) — we shared the bug: 2125a34 Cast Removal
neutral axis vs PRE-trim bounds. Their CPU measured the neutral axis
against user-trimmed (WP/BP-adjusted) bounds while their GPU measured
pre-trim; they standardized on pre-trim ("the film's inherent cast is a
source property — creative trims shouldn't perturb it"). Our finalize
had faithfully ported the now-declared-buggy CPU side. Ported as the
simplification it implies: finalize/analyze lost their offset params
(the analysis is now fully offset-independent; offsets fold into
finalBounds at derive time only), ImageSession's second cache tier
(AnalysisKey) dissolved — wp/bp handle drags re-run no analysis at all —
and ImagePipelineSeamTests pins the new semantics (axis == base-bounds
measurement; offsets still reach the render). Fixtures unchanged (all
dump configs use zero offsets); CLAUDE.md §2 updated.
To port (proposed, needs a Settings surface + the visible-improvement
bar): 91a1b78 user-tunable Auto Density / Auto Grade targets —
app-global calibration (TUNABLE_TARGETS ranges over 5 anchor/grade
constants, Set Targets dialog, TARGETS_REVISION cache-bust). The right
shape for per-scanner calibration; we have no Settings window yet (TODO).
Bookkeeping (no action): 687bcd5 True Black renamed Paper Black,
inverted — new default (off) keeps BPC on, so upstream's effective
default still matches ours; only nomenclature/polarity changed. Our
trueBlack field and UI naming stand; renaming to match upstream is a
cosmetic option, recorded here so the next review doesn't re-derive the
polarity mapping (paper_black = !bpc).
Not applicable: 81bea3c half-frame mode (their asset/session model),
4678569/96adfde camera-scanning fixes, 7e888ef dodge/burn mask
visibility, 13a7ba5/3113241 update banner, b14dc62 tutorial,
docs/changelog/roadmap churn (a11535f 18f6dd5 7a0046d 46c85c2
c27b379 5bd0c03 57948f5 0ca43de-style lint).
dump_fixtures.py: BROKEN against ≥b3490eb — _oetf_encode_flat/
_oetf_decode_flat lost their break-point parameters and the OETF
semantics changed; the closed-form working_oetf fixture case would dump
Adobe-gamma values against our ROMM oracle. Verify/adjust the script
BEFORE any re-dump past b3490eb (it still works at ≤6b841a1 checkouts).
One pipeline-relevant commit, and it's a golden move: 8bc9678
Dye Mute — grade-coupled chroma damping, ON by default upstream (0.5),
relocation goldens regenerated. Mechanism: damp = (slope_min/slope_g)^strength
(green reference slope, clamped) folded into the Lab saturation multiplier at
parameter-derivation time — per-channel print curves multiply channel
separation by the slope, so chroma inflates with grade; the damping counters
it ("mimicking paper dyes' unwanted absorptions"). No WGSL/uniform change
upstream; the identical fold works here (colorPopActive reads derived
params, so params.saturation × damp self-activates the pass). Port cost
~15 lines + tests, NO fixture re-dump (derivation-side; apply_saturation
unchanged). dump_fixtures.py signatures unaffected (new function only).
Deliberately skipped (for now) — reopening condition recorded: Dye Mute
is not ported despite being a golden move. Their motivation was a regression
fix: the ProPhoto output bug had been squeezing their gamut for releases;
fixing it (07e3f8f) suddenly exposed full-gamut chroma their users never
saw, reading oversaturated at hard grades. We never had the bug — our
color has been full-gamut ProPhoto from day one, and our deliberate
divergences (preSaturation 1.15, redHue +0.5) were A/B-tuned ON that gamut
toward MORE color, not less. Adopting their 0.5 default would desaturate our
established default look ~15% at typical grades (up to ~55% at slope 10);
adopting the slider at 0 would add exactly the kind of dormant control the
2026-07-16 removals just cleared out. Reopen if hard-grade frames ever
visibly oversaturate on real rolls — the mechanism above is the ready-made
fix, and one negcli A/B decides the default.
Also in 8bc9678: default lab sharpen 0.5 → 0.25 (we don't implement lab
sharpen — divergence note updated in CLAUDE.md).
Not applicable (this range): 5811662 crop-ratio picker consolidation
(their UI; NOTE their final ratio list — 7:5, 16:9, 16:10, US Letter — as
reference for our own TODO'd aspect presets), 611b251 Finish/geometry
sidebar cleanup + auto-narrowband-on-RGB-scan (capture-side scanning stack),
0ca43de lint, 1c07529 tooltips, 0ea27d2 changelog. Changelog-only
item: "Cast Removal strength sticks across frames" is their session-sticky
settings model — ours is per-image sidecars + explicit Copy/Paste, an
architectural divergence, not a gap.
Kernel status: untouched. Zero commits in the range touch
features/exposure/, features/process/, kernel/image/, or the
characterization goldens — the path-filtered log is empty, git diff --stat
over those trees returns nothing, and there are no renames in the range
(--diff-filter=R empty). A genuine null for the inversion pipeline: no
fixture re-dump, no constants drift, dump_fixtures.py signatures
unaffected. The one shared-kernel-adjacent hunk was checked by hand:
lab.wgsl changed only a stale comment ("Adobe RGB" → ProPhoto/D50 — the
code fix was 07e3f8f, reviewed 2026-07-15), and the lab/logic.py rewrite
is entirely the CLAHE function; vibrance/saturation (mirrored by our
colorPop) are untouched.
Ported: nothing (nothing required).
Not applicable (this range):
232f26dunify CPU/GPU CLAHE into one Lab-L algorithm (+ newtest_gpu_curve_parityCLAHE case, PIPELINE.md now documents CLAHE as its own stage before Retouching) — local-contrast lab stage we don't ship. If we ever add local contrast, port THIS version (fixed 8×8 tile grid, 256 bins, integer-count clip + even redistribution, smoothstep-bilinear CDF blend on CIELAB L*, CPU/GPU pinned to ~1e-6).c714a24Feat/finish — Finish panel: edge burn (true exposure burn in stops, radial↔rectangular roundness), filed-carrier black rebate, print mats — print-finishing stage, out of scope. The edge burn design (stops-domainI·2^(−s·m), card-burn roundness) is the note-worthy idea if a vignette tool is ever wanted.78b74efroll-aware Auto Crop All + consensus detection (~500 lines ingeometry/batch_autocrop.py+ biggeometry/logic.pygrowth) — camera-scan auto-crop detection; we have no auto-crop detection feature.16bcee8DNG/JXL export via imagecodecs 16-bit CMS — their export CMS stack; we export JPEG/TIFF through ImageIO/ColorSync.4a669edexport_fmt/color_space override in all_saved scope — their export-preset session plumbing.2682a0acamera-scanning calibration fix;69a2934status toasts / crop busy overlay;ca18ecboverflow-menu fix;86e73bachangelog — UI/capture.
Kernel status: DEFAULT LOOK MOVED UPSTREAM. Three commits (0f063cc,
67b5a8c, 6b841a1) changed exposure/models.py and regenerated both
characterization goldens — the highest-priority signal this log tracks.
Net change at the tip (0f063cc bounced auto_grade_target to 0.6, 67b5a8c
back to 0.5, 6b841a1 settled it):
paper_dmindefault True → False (paper white d_min 0.06 → 0; highlights print brighter — goldens moved 0.86 → 0.94 in the lights).true_blackdefault False → True — upstream converged on OUR recorded divergence. No code change here; the CLAUDE.md divergence entry ("trueBlack default on; NegPy ships it off") is now stale and should be dropped when the port lands.EXPOSURE_CONSTANTS["auto_grade_target"]0.5 → 0.55,["auto_grade_strength"]0.4 → 0.3 — Auto Grade targets slightly higher contrast and adapts less to scene range. Maps toK.autoGradeTarget/K.autoGradeStrength(ExposureConstants.swift:67-68), CPU-only (no MSL duplicate). Also shiftsCurveLogic.defaultGradeRange1.0 → 1.1, the denominator of the Negative-character read-out — upstream'sstats._negative_rowuses the samedefault_grade_range(), so porting the constant keeps that diagnostic in lockstep automatically.
Ported (2026-07-15, same day as the review):
K.autoGradeTarget0.5 → 0.55,K.autoGradeStrength0.3 (ExposureConstants.swift— CPU-only, no MSL duplicate). ShiftsCurveLogic.defaultGradeRange1.0 → 1.1, which the Negative-character read-out tracks automatically (verified vianegcli meteron a real CR3: "density range 0.958 default grade range 1.100 → normal").ExposureSettings.paperDmindefault true → false, including the sidecar decoder fallback (user's call: no existing edits worth preserving, so no split-default dance).negcli meterconfirms the paper floor is gone — brightest tone reads D 0.00 (was 0.06 = K.dMin).- trueBlack: no code change (already our default); the CLAUDE.md divergence bullet is retired — it's upstream's default now too.
- Fixtures re-dumped from
6b841a1.dump_fixtures.pynow recordstrue_blackin the per-config manifest (it already recordedpaper_dmin), and both parity harnesses (AnalysisParityTests.settingsFrom,GPUParityTests.settings) read it instead of hard-coding the old NegPy default — future default flips on either side can't silently skew parity. Drift-catcher mutation lists (SidecarCodecTests,HistoryLabelTests) updated for the new paperDmin default. All 132 tests pass;negcli benchunchanged (4.4 ms/frame, prepare 157 ms, finalize 25 ms).
Deliberately skipped:
- Lab sharpen default 0.25 → 0.5 + the
lab.wgslsharpen rework (0f063cc: real CIELAB L*-space unsharp mask with reflect-101 borders, sigma tracking scale_factor, smoothstep noise gate — replacing the old gamma-luma RGB-ratio scale). We don't ship sharpen (recorded divergence). Divergence baseline moved: if sharpen is ever implemented, port THIS Lab-space version, not anything older.
Not applicable (this range):
f309982cast-removal strength sticks across frames,6e55a4aTrue Black sticks across frames — their Qt session's frame-to-frame settings carry-over; we have per-image sidecars + Copy/Paste Adjustments, no carry-over model.07e3f8fcolour-manage from the working space, not Adobe RGB — theirAppState.workspace_color_spacewas left at "Adobe RGB" when the working space moved to ProPhoto, skewing preview/export/thumbnails together. SwiftInvert has no such field: display tagsrommrgbdirectly, export draws ROMM into an sRGB context;ColorIOTestsoracles were generated directly from NegPy's bundled ProPhoto ICC (not through their AppState path), so they are unaffected. Their deleteddetect_color_space_from_rawwas never ported.936ff88untagged scanner-TIFF loading (sRGB → linear scanner data),34e9efeTIFF export via imagecodecs 16-bit CMS — scanner-TIFF input and their export CMS stack; we're camera-RAW only and export through ImageIO/ColorSync.3a0175cFeat/heal — retouch (out of scope); itsrawpy_loader.pyhunk is the VueScan/Adobe SubIFD LinearRaw scanner-DNG path, also out of scope (camera DNGs go through LibRaw unaffected).
Kernel status: untouched. Zero commits in the range touch
features/exposure/, features/process/, kernel/image/, or the
characterization goldens — the path-filtered log is empty and
git diff --stat over those trees returns nothing. No renames in the range
(--diff-filter=R empty; all tracked pipeline files still present at the
tip), so this is a genuine null, not a path-filter miss. No fixture re-dump,
no constants drift, dump_fixtures.py signatures unaffected. 0.38.0 is
entirely triage/UI/workflow work.
Ported: nothing (nothing required).
Closed since the last review (were in "To port", now shipped here independently of upstream):
- Fine rotation + Straighten (
7f4b7a7) — shipped as our unified Crop & Straighten mode (CropGeometry,commitFineRotation), a Lightroom-model design rather than upstream's reference-line tool. - "Enter confirms crop" (
3961b4d) — shipped (fa5c890), plus Escape-cancel.
Correction to this entry (same day): the TIFF-compression item below was
first logged as "confirmed still absent". That was wrong. TIFF compression
has been implemented since a499904 — Exporter.swift sets
kCGImagePropertyTIFFCompression = 5 (LZW), and its comment already records
the deliberate LZW-over-Deflate decision (ImageIO's Deflate support is
undocumented). The false "confirmed" came from a verification grep truncated by
head -12, forty lines before the relevant code. Item closed, not open.
Lesson for future reviews: never head-truncate an absence check — absence is
exactly what truncation fabricates.
Closed (was carried over as "To port"; already shipped here):
- TIFF export compression (
fb4b7a7) — done ata499904(LZW). Upstream's Deflate+predictor is a deliberate skip, not a gap: ImageIO won't reliably write Deflate TIFF. Do not re-raise without new evidence.
Ported (2026-07-15, same day as the review):
77c8113spot densitometer + zone strip →NegativeKit/Densitometry.swift(zone(ofEncoded:),printDensity(ofEncoded:),read(encodedRGB:),zoneRoman),SwiftInvert/Densitometer.swift+DensitometerReadout.swift, wired to a canvas hover inDetailViewwith the read-out in the control bar. NegPy-exact semantics, including luma-on-encoded before the OETF decode. Divergence from upstream, deliberate: the probe reads the displayed rgba8 bitmap (already ROMM-encoded) instead of a GPU metric — upstream needsdensity_hist.wgslto feed their H&D chart; we have no chart, so a 120-bin GPU pass would have no consumer.density_histogramtherefore not ported; revisit only if we ever build the H&D chart. Also skipped: the ΔD-above-base per-channel figure — it needs the source linear pixel + bounds, i.e. an actor round-trip intoImageSessionper pointer move, for the negative-diagnostic half of the read-out. D + zone is the darkroom core; ΔD is a candidate if the round-trip proves cheap.- Negative character diagnostic →
Densitometry.character(densityRange:)(NegPy's 0.80/1.25 gates and wording), shown under the Grade slider — the slider it's about. Readsanalysis.baseBounds.luminanceDensityRange, verified to be the samenorm_density_rangeupstream feeds both its curve and this diagnostic. - Both are pure measurement (no render path), so the parity fixtures are
untouched and no re-dump was needed. 92 tests pass, including a new
DensitometryTestssuite of closed-form oracles. - Verified on real scans via a new
negcli metercommand: on three CR3s the brightest tone reads D 0.06 on every frame — exactlyK.dMin, the paper white the pipeline targets — and the character gates fire correctly (0.902 → normal, 0.710 / 0.494 → flat). Unverified: the hover interaction itself— verified by hand 2026-07-15: hovering the canvas meters correctly. (The original gap: synthetic mouse events can't reach the unbundled binary — no accessibility permission, app won't take focus from a shell — and the known-working histogram hover ignored them too, proving the harness was what failed. Lesson kept: GUI hover paths in this app need a human pointer to verify.)
New candidate (perf/architecture, not pipeline) — half ported, half declined:
938fe9e"halve preview-load memory; instant frame switching". Ported: multi-frame retention —AppModel.sessionLRUkeeps the 2 most recentImageSessions (upstream'spreview_cache_max_full_res_entriesbudget: active frame + the one navigated from), withreleaseHQ()stripping the full-res tier from the frame that isn't on screen. Retention alone suffices here because every cache tier is already keyed. Declined: upstream'sRenderMemo— it memoizes the last displayed render because their re-render is expensive. With the tower warm ours is derive+GPU (~5 ms), so a memo of the CGImage would add a staleness surface (their key has to track HQ flag, working space, GPU engine, soft-proof ICCs, monitor profile) to save 5 ms. Not worth it. Declined: copy elision — their win was deleting numpy defensive.copy()calls. Swift arrays are COW, so we never had that class of waste; the one place it would matter is already explicit (preview = meterPreviewat 0°, commented "COW: at 0° the render input IS the meter image, no copy"). No change made. Original analysis of both ideas, retained for context:- Multi-frame retention. Upstream gives full-res cache entries a slot
budget (default 2: active frame + the one navigated from) so navigate-back
is instant, and memoizes each frame's last displayed render in a new
RenderMemokeyed by config + every display-path input, painting from it immediately while the authoritative render refreshes underneath. We hold exactly oneImageSession(AppModel.swift:506reassigns a single optional), so navigating back drops the entire cache tower — decode, oriented preview,Prepared(~150 ms), textures — and rebuilds from scratch. A 2-slot session LRU would be the bigger win for us than the memo itself, since we discard more than they do. Wants a memory-budget check first (our HQ path already caches a full-res decode per session). - Copy elision under a read-only contract. They removed three redundant
full-size buffer copies (peak RSS 2880→1609 MB on a 56 MP load) by letting
cache and caller alias one buffer. Less directly applicable — our
RGBImagecopies are value-semantic and our contract already forbids handing out live textures — but worth a look at the HQ decode path if HQ memory ever becomes a complaint.
- Multi-frame retention. Upstream gives full-res cache entries a slot
budget (default 2: active frame + the one navigated from) so navigate-back
is instant, and memoizes each frame's last displayed render in a new
Not applicable (this range):
976851cUI refinements;ecec2bbpanel pin/reset docking;979b592adaptive canvas-toolbar overflow — their Qt dock/panel layer.13b9434heal/scratch edits never persisted — retouch, not shipped here.2079d7cdocs (their CLAUDE.md slim-down);0ca292dCI self-assign workflow.- 0.38.0 changelog headliners we don't ship: Keep/Reject contact-sheet triage, unreadable-file badges, roll-wide undo, canvas-tool Esc grammar, colour/ Filtration renaming. One idea worth noting even though the feature is out of scope: their two-stage Esc (first clears in-progress points, second puts the tool down) — our Escape already cancels Crop & Straighten wholesale, which is the right behaviour for a mode with no in-progress point list.
Kernel status: untouched. No commits in the range touch
features/exposure/, features/process/, kernel/image/, or the
characterization goldens — no fixture re-dump, no constants drift,
dump_fixtures.py signatures unaffected.
Ported: nothing (nothing required).
Not applicable (this range):
2ecaebbGPU readback-vs-destroy race fix (per-texture lock in their wgpuGPUTexture; densitometer hover on the UI thread raced engine cleanup on file switch). SwiftInvert's architecture already precludes this class of bug:RenderPipeline.renderis serialized by an internal lock and returns read-back buffers, never live textures. Note for the future: if we port the spot densitometer (flagged 2026-07-13), keep hover readouts on those read-back buffers, not on GPU probes.c728874more ASCII-encode EXIF crash fixes — their metadata writer.2a62c6acamera-scanning follow-ups (RGB-only Scanlights, live-view polish, crash logging) — feature we don't ship.3961b4dUX polish (drag-to-heal, Enter confirms crop, cursor/tooltip tweaks) — heal is out of scope; "Enter confirms crop" is a small UX idea we could copy independently of upstream.a033b92,80041ca,0500404— changelog/lint churn.
To port (proposed, not yet implemented — carried over from 2026-07-13, re-confirmed still open):
- TIFF export compression (
fb4b7a7, upstream default Deflate+predictor): ourExporter.swiftstill writes uncompressed TIFF. Best-value small item; verify ImageIO Deflate support, else LZW. 77c8113spot densitometer +density_histogrammetric + zone strip — useful darkroom tool we lack; moderate effort (new GPU metric + UI), no parity impact.- "Negative character" diagnostic (flat/contrasty vs
default_grade_range()) — cheap, blocked on us having a stats read-out surface. - Fine rotation + Straighten tool (
7f4b7a7) — geometry feature, not pipeline; still open as a feature candidate.
Kernel status: untouched. No changes to normalization/curve logic,
EXPOSURE_CONSTANTS, or the characterization goldens — no fixture re-dump
needed. Only two commits touched features/exposure/ at all:
Ported: nothing (nothing required).
Candidates flagged (not yet decided):
77c8113Analysis panel: merged H&D chart, spot densitometer, zone strip. Newdensity_histogrammetric (120 bins over −0.1…1.1 normalized-log density,analysis.py/density_hist.wgsl) computed per render; the densitometer maps hover → normalized-log coords → zone/density read-out. Presentation-layer, but a genuinely useful darkroom tool we lack.stats.pyrework (same PR): "Negative character" diagnostic — measured density range vsdefault_grade_range(), ratio <0.80 → "flat (≈N−1)",1.25 → "contrasty (≈N+1)". Cheap to add if we grow a stats read-out.
fb4b7a7default TIFF compression LZW → Adobe Deflate (ZIP) + horizontal predictor. Our export currently writes uncompressed TIFF (no compression options set inExporter.swift); worth adding compression, though ImageIO's Deflate-TIFF support needs verifying (LZW is the safe bet).7f4b7a7clockwise-positive fine rotation + Straighten reference-line tool — we only have 90° steps; geometry feature, not pipeline.
Not applicable / out of scope:
db72e9fCPU/GPU parity for B&W renders (post-curve luma collapse inexposure.wgslismode == 1only; we don't ship B&W). Its histogram sub-fix (bin from float output before quantization, drop the 4× stride) is a CPU-preview artifact fix — ourhistogram256already bins the linear float content on GPU.e56c199preserve edits when EXIF rewrites change the file hash — NegPy keys sidecars by content hash; ours are filename-keyed.1deeba6batch export path/mode bug — their session-export plumbing.- Toners (
4a3cd34), crosstalk profile editor (0bba263), retouch/heal & dust (9b04f49,05b8763), dodge-burn masks (2cf6afd), camera scanning (c558a16,15d15c7), shortcut-editor UI (4 commits), EXIF string sanitizing (8363495), changelog/screenshot churn — all stages/features we deliberately don't ship.
Ported:
- True Black (BPC):
t → (t−b)/(1−b), b referenced to physical d_max; negative toe raises the clip point. (SwiftInvert later defaulted it ON — our divergence; NegPy ships it off.) toe_height0.35 → 0.90 withtoe_grade_strength× 0.35/0.90 rescale (perceptual toe/shoulder balance; default output bit-compatible).- Cast removal: confidence scaling always on (auto toggle removed).
- flare/surround deleted (were always-off in SwiftInvert).
Deliberately skipped:
- Per-layer R/G/B trims (grade/toe/shoulder/width/Snap) — per-channel crossover correction; candidate future feature.
- Split Grade + Zone Density — upstream's convergent equivalent of our tone controls (theirs: zone centers 1.49/0.34, sharpness 4.0, sequential ordering; ours keep 1.40/0.30 @ 3.5 with documented monotone bounds).
- Midtone-gamma user slider (cheap to add if wanted).
- C41 denoise (lab stage — not ported), multi-core CPU kernels (we're GPU), camera-scanning feature work.
Known-unreviewed at time of writing: local NegPy has already pulled past
the marker (14 commits, cac6396..c558a16, mostly camera-scanning work by
the titles) — NOT yet reviewed for pipeline changes.
The 2026-07-15 "Ported" entry for 77c8113-adjacent film-base sampling is
superseded: Sample Film Base was removed after its trial, together with
the three patent-derived cast experiments built 2026-07-16 (saturation-
weighted colour metering, tempered cast removal, roll balance — see
PLAN-cast-experiments.md at 2b73c5d for the mechanisms). All were
default-off and parity-clean; all verified mechanically correct end to end.
Why removed: the trial standard was "visibly better on real rolls", and none met it. On neutral-rich frames every method converges with the existing statistical estimate BY DESIGN (film-base agreed within 0.02D; weighted metering moved bounds ~0.01D; tempered cast at shipping constants shifts pixels ~1/7 of an 8-bit step). The existing two-axis bounds + confidence- gated neutral axis already handle these rolls well; the added UI surface wasn't paying for itself. Direction judged wrong by the user 2026-07-16.
Do not re-propose without new evidence — specifically, a real roll where
casts visibly fail (dominant color, no neutrals, no usable rebate). If that
roll appears, the removed work is complete and recoverable:
fed271a (film-base sampling), fa3e095 (weighted metering),
36ae788 (tempered cast), 420f583 (roll balance), 2b73c5d (plan) —
local-only commits, never pushed, reachable via reflog until gc.
Kept: the NegPy 0.38 constants port (1c1c3cb, this entry's parent), the
spot densitometer / zone strip / negative character (shipped earlier and in
regular use), and the research findings in the 2026-07-16 conversation.
The densitometer remains the darkroom read-out surface; sidecars and the
one roll-profile dotfile were scrubbed of the removed features' keys.