Specify a scene-referred pipeline (D19)

The spec missed Ansel, and with it Aurélien Pierre's argument that a
display-referred curve early in the pipeline throws away what every
later stage needs. Read against that argument, the code broke it four
ways: the base curve was flat past its last point and so clipped every
recovered highlight; the detail stage was handed curved, clipped values
while its comments promised linear ones; the edits ran in camera RGB
because the matrix had drifted to after them, against ARCH §5.2; and the
tone curve clamped to [0, 1] around a 2.2 gamma mid-chain.

D19 records the decision: unbounded scene-linear colour from the matrix
to one view transform, last, after the detail stage. FR-DEV-3j specifies
that view transform as an adjustable operation (contrast, white) with a
default fitted to where the retired default curve put middle grey.
FR-DEV-3e retires the per-body base curves, whose own file called them
hand-tuned shapes of unknown provenance. FR-DEV-3f makes a film stock
the view transform when one is chosen, instead of a rendering at order
25 that the later edits acted on. FR-DEV-2 gains the acceptance test
that holds the rule, and ARCH gains §6.14 and the corrected §5.2 order.
This commit is contained in:
2026-09-27 16:52:52 -04:00
parent 621a6b8313
commit e8898a5c38
3 changed files with 154 additions and 17 deletions
+26 -4
View File
@@ -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) |
---
+123 -9
View File
@@ -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
+5 -4
View File
@@ -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.
<details><summary>Show untagged requirements</summary>
@@ -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