diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index f298d44..f66150d 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -355,11 +355,12 @@ RawImage (sensor data, CPU) ├─────────────────────┤ │ AI denoise │ optional; raw-domain, joint with demosaic where possible ├─────────────────────┤ -│ camera profile │ matrices + per-body base curve (FR-DEV-3e) +│ white balance │ camera RGB: as-shot, then the operation ├─────────────────────┤ -│ → working space │ linear, wide-gamut, f16 +│ camera profile │ the matrix (FR-DEV-3e) — no curve (D19) +├─────────────────────┤ +│ → working space │ linear, unbounded, f16 ├─────────────────────┤ -│ white balance │ │ exposure/contrast │ │ highlights/shadows │ ← masks apply per-op from here down │ tone curve │ @@ -368,10 +369,11 @@ RawImage (sensor data, CPU) │ spot removal │ │ sharpen / NR │ │ lens corrections │ -│ look (HaldCLUT) │ FR-DEV-3f ├─────────────────────┤ │ geometry │ crop, straighten, rotate ├─────────────────────┤ +│ view transform │ sigmoid, or the film stock (FR-DEV-3j) +├─────────────────────┤ │ output transform │ → display or export profile └─────────────────────┘ │ @@ -381,6 +383,14 @@ RawImage (sensor data, CPU) Working precision is f16 in a linear wide-gamut space, quantising once at the output transform. +**Scene-referred until the view transform (D19, §6.14).** Everything between the matrix and the +view transform is linear and unbounded. The view transform is the one stage allowed to compress +the scene into a display range. With a detail stage it runs as a dispatch of its own after the +detail passes, generated by the same composer as the fused pass so that it gets the mask layers, +the film tables and the grain's source position. Without one it is the fused pass's tail. Both +the view transform and the output transform (primaries, gamut clip, encode) come after +everything that reads a neighbourhood. + **A hot or dead photosite is repaired before the demosaic, not after.** Past it, one photosite of nonsense is a coloured cross three pixels wide that no later stage can tell from detail. The pass (`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that @@ -1255,6 +1265,17 @@ differently, f16 rounding varies. Cache keys and graph hashes are computed over state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance (R1), not a checksum. +### 6.14 Scene-referred until the view transform + +Added 2026-09-27 (D19). Between the camera matrix and the view transform, values are scene-linear +and unbounded, and no operation clamps above 1.0, applies a transfer function or maps to a display +gamut. The view transform (FR-DEV-3j) is the one stage that does, and the output transform after +it clips and encodes. This is the constraint the base curve broke and ARCH §5.2 had drawn all +along: a display-referred curve in the middle of the chain throws away what every later stage, +the neighbourhood ones above all, needs. A test (`scene_referred_until_the_view`) runs every point +operation over a ramp to 16.0 so that a fragment that clips fails the build rather than the +photograph. + --- ## 13. Decisions @@ -1278,6 +1299,7 @@ Full rationale in [requirements.md §8](requirements.md). Summary: | D13 | Face inference runtime and model licensing | **Runtime answered**, reopened for per-device backends (docs/inference.md); licensing open | | D14 | Segmentation source for local masking | Decided — arm C (docs/segmentation.md §14) | | D15 | Target devices — 12-inch tablet and desktop, no phone | Decided (requirements D15) | +| D19 | Scene-referred pipeline, one view transform last | Decided (requirements D19) | --- diff --git a/docs/dev/requirements.md b/docs/dev/requirements.md index 49a3e20..4693872 100644 --- a/docs/dev/requirements.md +++ b/docs/dev/requirements.md @@ -306,6 +306,19 @@ from source + graph. per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens once, at the final export or display stage. +*Amended 2026-09-27 (D19):* quantisation is one of three things deferred to the end, not the only +one. Until the view transform (FR-DEV-3j), values are **scene-linear and unbounded**: nothing +clamps above 1.0, nothing applies a transfer function, and nothing maps to a display gamut. Every +operation between the camera matrix and the view transform receives and returns that. The +working space's primaries are linear Rec.709, carried unbounded, so a colour outside sRGB is a +negative component rather than a clipped one. That is wide-gamut in range, not in the primaries +the operations measure hue against; moving the primaries to Rec.2020 is deferred (D19). + +*Acceptance:* every point operation at non-neutral settings, handed a ramp to 16.0, returns values +that are still monotone in the ramp and still above 1.0 where the ramp is, before the view +transform (`scene_referred_until_the_view` in `dr-pipeline`). The view transform itself and film +simulation are excluded, because clipping into a display range is their job. + **FR-DEV-3 — Adjustment set (v1).** - White balance (temperature/tint, and picker) @@ -408,21 +421,31 @@ demosaic and the working-space conversion. **v1 scope** (per D11 — good defaults rather than exhaustive colour science): 1. Embedded DNG `ColorMatrix1/2` and `ForwardMatrix1/2` tags -2. A hand-tuned base curve per launch camera body, shipped with the app +2. ~~A hand-tuned base curve per launch camera body, shipped with the app~~ — **retired + 2026-09-27 (D19).** The tone half of "the camera's look" is the view transform's (FR-DEV-3j), + one for every body and adjustable. The colour half stays here, in the matrix and later the DCP. 3. HaldCLUT import (FR-DEV-3f) +The camera profile ends at the matrix, and the matrix runs **first**: white balance is applied in +camera RGB, where its multipliers are defined, and every other operation receives working-space +colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the +colour mixer and the weights in `luminance()` meant something different on every body. + **Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and dual-illuminant interpolation. The stage shall be structured so these are additions rather than a pipeline reordering. Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering characteristic of dcraw defaults, which is the documented reason people abandon darktable in the -first hour. A per-body base curve fixes most of that at a fraction of the cost of a full DCP -implementation. The profile database ships **versioned independently of the app binary** so bodies -and curves can be added without a release — and, under D8's GPLv3, contributed by users. +first hour. ~~A per-body base curve fixes most of that at a fraction of the cost of a full DCP +implementation.~~ The flat render is a missing *view transform*, not a missing per-body curve: +darktable's own answer to the first-hour complaint was a scene-referred default, and Ansel's is +the same. The per-body curves this clause shipped described themselves as hand-tuned shapes, not +measurements, and their provenance was not known well enough to keep them as defaults (D19). -*Acceptance:* for each launch body, the default render is subjectively comparable to the camera's -own JPEG. ΔE2000 validation against ColorChecker references applies once DCP support lands. +*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through +FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies +once DCP support lands. **FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag @@ -439,9 +462,13 @@ the picture along the film's own curve, shoulder and all, rather than scaling a exposure. And the **data cost inverts**: a stock is ~17 kB of published measurements where one HaldCLUT is ~800 kB of one person's grade. -A film simulation is a *rendering*, not an adjustment, so it replaces the camera profile's base -curve and the conversion out of camera space (`Operation::renders`) — applying both would render -the scene twice. +A film simulation is a *rendering*, not an adjustment, so it **is** the view transform when a +stock is chosen (FR-DEV-3j): it runs last, after every adjustment and after the detail stage, in +place of the default sigmoid, and never in addition to it. *Amended 2026-09-27 (D19):* it ran at +order 25 before this, after exposure and before everything else, so the edits below it acted on +the film's output. They now act on the scene the film is shown: an edit is a decision about the +exposure the negative receives, and the film is the last thing that happens to the picture. +Existing edits that combine a stock with tone or colour operations render differently. *Acceptance:* a neutral scene printed through a colour negative's own paper renders neutral to within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code @@ -518,6 +545,33 @@ also written to the sidecar, run-length coded beside the layer, because a stored that never runs a model. It is a materialisation of the identity, not the edit: it takes no part in equality or merge, and the identity remains what the part means. +**FR-DEV-3j — View transform.** The last stage of the develop pipeline maps scene-linear +colour to a display range, and it is the only stage that may. By default it is a log-logistic +sigmoid applied per channel, with the middle channel's position between the other two restored +afterwards so a hue survives the shoulder, and the result clipped only by the output transform. A +stock chosen under FR-DEV-3f replaces it. + +It is an operation with two parameters, persisted in the sidecar, adjustable in the develop panel, +and held per mask layer like any other: + +- **Contrast** — the sigmoid's slope. Default 1.4. +- **White** — how far above middle grey, in stops, the scene reaches display white. Default 4.0, + so a highlight a stop past sensor saturation still rolls into white rather than clipping at it. + +Scene middle grey is 0.13, where the retired default curve placed it (FR-DEV-3e), and it maps to +display 0.18. A photograph with the view transform at its defaults is **unedited**: the operation +is always composed, and "active" keeps meaning "moved from the defaults", so an untouched image +writes no parameters and every other operation's neutral is still the image. + +An already-rendered source — a JPEG — is not rendered again: the default view transform is skipped +for it, as the base curve was. A film stock is not, because choosing one is an edit. + +*Acceptance:* monotone in each channel; a neutral stays neutral; middle grey lands within 0.01 of +0.18; between scene 0.03 and 1.0, the default is within 0.3 EV of the retired default curve; the +scene value `0.13 · 2^white` reaches 1.0; and the shader agrees with the CPU reference. + +*Added 2026-09-27 (D19).* + **FR-DEV-4 — Ordered, GPU-resident execution.** The pipeline executes as a sequence of GPU compute stages. Intermediate results remain in GPU memory between stages. **Processed pixels shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint — @@ -2346,6 +2400,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in | D11 | Product positioning | Culling-first differentiator; see below | | D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date | | D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version | +| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired | ### D11 — product positioning @@ -2592,6 +2647,65 @@ file, not an edit to the old one; and the composite occupies disk — a five-fra where the output is still frame A, and inherits nothing from this decision but the provenance rule. +### D19 — scene-referred pipeline · **DECIDED 2026-09-27** + +**Edits operate on scene-linear, unbounded colour in the working space, and one view transform, +last, maps it to a display range.** Range, encoding and gamut are all deferred to that point, as +quantisation already was (FR-DEV-2). + +*Why now.* The spec missed [Ansel](https://ansel.photos/), Aurélien Pierre's fork of darktable 4.0, +and with it the argument he spent years making in darktable: a display-referred curve early in the +pipeline throws away what every later stage needs. Reading the code against that argument found +four places it applied: + +1. **The base curve clipped.** It was a five-point spline on the unit square, flat past its last + point, so every value above 1.0 — every recovered highlight — left it at the same number, per + channel. +2. **The detail stage was handed non-linear data.** The fused pass stops at "linear working + values" when a sharpener or a blur follows, but it stopped *after* the base curve, so the + neighbourhood operations convolved curved, clipped values while their comments promised the + opposite. +3. **The edits ran in camera RGB.** The matrix came after them, so `luminance()`'s Rec.709 weights + were applied to camera primaries and a hue in the colour mixer was a different hue on each + body. ARCH §5.2 had always drawn the matrix first; the code had drifted. +4. **The tone curve clamped** to [0, 1] and applied a 2.2 gamma around its spline, mid-chain. + +*What changes.* The order becomes: demosaic → as-shot white balance and the white balance +operation, in camera RGB → the camera matrix → every other point operation and every mask layer → +the detail stage → the view transform (FR-DEV-3j), or the film stock (FR-DEV-3f) when one is chosen +→ the output transform. With a detail stage the view transform is a dispatch of its own after it, +composed by the same generator as the fused pass. Nothing before the view transform clamps above +1.0 or display-encodes, and a test says so (FR-DEV-2). + +*What is retired.* The per-body base curves and their database (FR-DEV-3e). Their own file called +them hand-tuned shapes rather than measurements, and not enough was known about where the shapes +came from to keep them as defaults behind sliders. Body character is the matrix's, and the DCP's +when it lands. + +*What it costs.* + +- **Every photograph renders differently.** The default view transform was fitted so middle grey + lands where the retired default curve put it and midtones stay within 0.3 EV of it, but the + upper midtones are darker and the highlights roll off over two more stops. Previews rendered + before the change keep the old look until they are rendered again. +- **Film edits change meaning.** A tone or colour operation beside a stock used to act on the + film's output; it now acts on the scene the film receives. +- **Tablet and desktop must be released together.** No schema changes and the sidecar gains only + ordinary parameters, but two peers on different builds render the same edit differently. +- **One more dispatch with a detail stage**, for the view transform after it. + +*Rejected.* Keeping the per-body curves as the view transform's per-body defaults, for the +provenance reason above. Leaving the film at order 25 and having it suppress the view transform: +simpler, and it kept existing film edits' meaning, but it left a display-referred rendering in the +middle of the chain, which is the thing this decision removes. A fixed view transform with no +controls: it would have been smaller, but a scene-referred pipeline whose white point cannot be +moved hands the photographer a shoulder they cannot place. + +*Deferred.* Working-space primaries of Rec.2020 rather than Rec.709. The range is already +unbounded, but several fragments floor at zero, which clips a colour outside sRGB, and the colour +mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression +beyond the output transform's clip goes with it. + ### D16 — plugin licensing · **OPEN, post-v1** > Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 4439b4f..453e0c1 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -11,16 +11,16 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n |---|---| | Source files scanned | 470 | | TRACES tags found | 1911 | -| Requirements defined | 192 | +| Requirements defined | 193 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 163 | -| **Coverage** | **84.9%** (163/192) | +| **Coverage** | **84.5%** (163/193) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 123 | 134 | +| FR | 123 | 135 | | NFR | 35 | 51 | | R | 5 | 7 | @@ -229,7 +229,7 @@ Defined in `requirements.md` and marked `(post-v1)` on the defining line. Not in ## Not yet tagged -29 of 192 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +30 of 193 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -237,6 +237,7 @@ Defined in `requirements.md` and marked `(post-v1)` on the defining line. Not in - FR-CULL-6 - FR-CULL-7 - FR-DEV-3g +- FR-DEV-3j - FR-DSP-2 - FR-INF-2 - FR-INF-3