Stop describing a base curve the pipeline no longer has

D19 retired the per-body base curve, moved the matrix ahead of the
edits and the film into the view transform's place, but a dozen doc
comments still listed the curve among what a pixel passes through, or
said the film skipped it. The detail stage's module doc still drew the
matrix after the edits and the last detail pass encoding, which the
view pass took over. The film crate's README gave the base curves as
its reason for being data, and the ops README's list of hand-written
nodes had neither the view transform nor three of the five kernels.

FR-MRG-2 gave the base curve as why the merge cuts below the profile;
the view transform is why now. The decision table still said colour
defaults were a per-body curve, and FR-DEV-3j said only the default
view transform skips a JPEG, where the node skips one whatever its
sliders say. frame-budget.md records the view pass as unmeasured.
This commit is contained in:
2026-09-27 19:42:41 -04:00
parent 31bcc3a462
commit affdaecaee
19 changed files with 107 additions and 61 deletions
+2 -2
View File
@@ -67,8 +67,8 @@
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
//! than as a pipeline reordering. They would: both are lookups applied to a
//! colour after this matrix and before, or alongside, the base curve, so they
//! extend [`CameraProfile`] with more calibration data and extend the shader's
//! colour at this matrix, before any edit reaches it, so they extend
//! [`CameraProfile`] with more calibration data and extend the shader's
//! camera-profile stage with more work. Nothing above would move.
use crate::{cam_to_srgb_from, invert3};
+9 -3
View File
@@ -1,9 +1,8 @@
# Film stocks
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
file — no code change, no shader, no new operation — for the same reason
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
contributable without a release.
file — no code change, no shader, no new operation — because under the GPLv3
a stock should be contributable without a release.
## What a profile is
@@ -51,6 +50,13 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
log exposure through the negative, the enlarger's exposure is added there,
and the paper's own curve row and cube take it to linear sRGB.
The stock is the last thing that happens to the picture. It runs in the view
transform's place (D19): handed linear sRGB, scene-referred, after every other
adjustment and after sharpening and noise reduction, and handing back the
rendering the output transform encodes. So every other slider decides the
exposure the negative receives, and the default tone mapping is not applied
on top.
Per pixel that is a matrix multiply, a handful of curve taps and one texture
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
rather than assumed: the curve carries all the sharp shape and the dye mixing
+3 -3
View File
@@ -1520,9 +1520,9 @@ impl AdjustPass {
/// one: the storage format is in the layout. The profile uniforms are
/// filled neutral here rather than from the source, which is the whole
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
/// identity matrix, base curve off. The non-linear flag is kept, so a
/// JPEG source is still linearised — camera space for a JPEG is the
/// decoded values made linear, which is the best that exists.
/// identity matrix, and no view transform composed. The non-linear flag
/// is kept, so a JPEG source is still linearised — camera space for a
/// JPEG is the decoded values made linear, which is the best that exists.
///
/// The texture stays on the device for a merge's warp to sample; see
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
+6 -5
View File
@@ -320,10 +320,11 @@ impl DemosaicedImage {
color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already had its
// camera's base curve baked in by the camera; applying one again
// would render the rendering, crushing the shadows and flattening
// the highlights of an image that was already finished.
// rather than resolved further down.** A JPEG has already been
// rendered by the camera; the view transform skips a source
// flagged non-linear, since rendering the rendering would crush
// the shadows and flatten the highlights of an image that was
// already finished.
non_linear: true,
id: next_image_id(),
frame: (width, height),
@@ -338,7 +339,7 @@ impl DemosaicedImage {
/// what a merge writes. No demosaic; the samples are normalised by the
/// file's black and white levels exactly as the demosaic kernel would
/// normalise a photosite, and everything else — the matrix, the
/// balance, the body's base curve — is carried through as for a CFA
/// balance, the view transform — is carried through as for a CFA
/// file, because the composite is developed as one photograph from the
/// body that took its sources.
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
+2 -2
View File
@@ -28,8 +28,8 @@
//! than leaving the specification and the code silently disagreeing.
//!
//! That texture is the right one on the merits. It is camera-native: no white
//! balance has been applied, no camera matrix, no base curve, no tone curve,
//! no output transform. It is normalised by the sensor's own black and white
//! balance has been applied, no camera matrix, no tone curve, no view
//! transform, no output transform. It is normalised by the sensor's own black and white
//! levels, so 1.0 is saturation by construction and the distribution below it
//! *is* the headroom question, with no calibration to carry and no origin to
//! choose.
+1 -1
View File
@@ -3,7 +3,7 @@
//
// The shader beside this one, `histogram.wgsl`, counts the frame the display
// is about to show: an 8-bit code value, after white balance, the camera
// matrix, the base curve, the tone curve and the output transform. This one
// matrix, the tone curve, the view transform and the output transform. This one
// counts the texture the demosaic wrote, before any of that. The two differ in
// exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any
+7 -4
View File
@@ -228,9 +228,11 @@ interpolated points — master, red, green, blue — each reaching the shader on
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
not parameters, and the one node that declares `Operation::renders` — see
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a
kernel, and one that decides how many dispatches to emit at each resolution) —
the last two for the reason the next section gives. `vignetting` is
below), `view_transform` (composed at its defaults, which a declaration cannot
say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
kernels — `capture_sharpen` (a separable convolution), `noise_reduction` (one
that decides how many dispatches to emit at each resolution), `clarity`,
`texture` and `dehaze` — for the reason the next section gives. `vignetting` is
hand-written too but is not in the develop chain — it carries lens-profile
coefficients that are not parameters.
@@ -262,7 +264,8 @@ clarity, texture, dehaze and spot removal are all defined by what the
of `c` at any price.
They go in the **detail stage**, which runs after the fused pass, in linear
light, at render resolution, before the output transform — see
light, at render resolution, before the view transform and the output
transform — see
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
rather than a convenience. A node of this kind:
+2 -2
View File
@@ -11,8 +11,8 @@ why_rust: |
characteristic curves and a density lookup — which are not parameters and
which no `uniforms:` expression could produce. Its neutral is "no stock
loaded" rather than a set of values, and it is the one node that declares
`Operation::renders`, so the composer omits the camera profile's base curve
and the conversion out of camera space on its behalf.
`Operation::renders`, so while a stock is loaded the composer emits it in
the view transform's place instead of the default sigmoid.
placement: |
Last, in the view transform's place (D19, FR-DEV-3j), after every other
+4 -4
View File
@@ -659,10 +659,10 @@ impl Attribute {
///
/// `Effect` after `Colour` is a look laid over a settled picture — and is
/// the one arguable slot. A spectral film simulation declares
/// [`crate::Operation::renders`] and replaces the base curve, which is an
/// argument for treating it as foundational rather than final; an array of
/// six cannot say "last, except when it is first". The tension is recorded
/// here rather than settled.
/// [`crate::Operation::renders`] and takes the view transform's place at
/// the very end of the chain (D19), which is an argument for treating it as
/// the rendering rather than one effect among others; an array of six
/// cannot say that. The tension is recorded here rather than settled.
///
/// Both ends were wrong for as long as this list only fed a row of chips
/// nobody reads in order. It stopped being harmless when the same list
+21 -13
View File
@@ -24,9 +24,10 @@
//! v
//! +------------------------------------------+
//! | the fused point-operation pass | one dispatch
//! | white balance, exposure, tone, colour |
//! | the mask layers |
//! | white balance (camera RGB) |
//! | camera RGB -> linear sRGB |
//! | exposure, tone, colour |
//! | the mask layers |
//! +------------------------------------------+
//! | rgba16float, linear, **unclipped**, at render resolution
//! v
@@ -34,7 +35,13 @@
//! | the detail stage - this module | one dispatch per pass
//! | sharpen, NR, clarity, texture, spots |
//! +------------------------------------------+
//! | the last pass applies the output transform
//! | rgba16float, still scene-linear and unclipped
//! v
//! +------------------------------------------+
//! | the view pass | one dispatch
//! | view transform, or the film stock |
//! | output transform, mask reveal |
//! +------------------------------------------+
//! v
//! rgba8unorm display or export texture
//! ```
@@ -52,14 +59,13 @@
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
//! the colour mixer.
//!
//! **In linear light, after the camera matrix.** The fused pass works in
//! *camera* space, because white balance and exposure are physically
//! meaningful there and nowhere else. A detail pass is the opposite case: it
//! wants a luminance, and camera RGB has no luminance — the three channels are
//! **In linear light, after the camera matrix.** A detail pass wants a
//! luminance, and camera RGB has no luminance — the three channels are
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
//! would be numerology. So the split is taken *after* the `cam_to_srgb`
//! multiply, where the working space is linear sRGB and a luminance is a
//! luminance.
//! would be numerology. Since D19 only white balance runs in camera RGB; the
//! `cam_to_srgb` multiply follows it, so every point operation, and every
//! detail pass after them, works in linear sRGB primaries, where a luminance
//! is a luminance.
//!
//! **Before the output transform, and before the clip.** FR-DEV-2 allows
//! exactly one quantisation, at the display or export stage. A detail pass
@@ -70,9 +76,11 @@
//! therefore `rgba16float` and holds linear values that have **not** been
//! clamped to `0..=1`: a recovered highlight is still above one at this point,
//! and clipping it before the sharpener sees it would put a hard edge exactly
//! where the sharpener is most visible. The last detail pass performs the
//! primaries conversion, the clip and the encode, so the single quantisation
//! stays single.
//! where the sharpener is most visible. Every detail pass writes such an
//! intermediate, the last one included, and the view pass after them — the
//! view transform, then the output transform's primaries, clip and encode —
//! is the one place the scene is fitted to a display (D19, ARCH §6.14), so
//! the single quantisation stays single.
//!
//! **After framing, at render resolution.** The alternative — running detail
//! on the demosaiced source before the framing prologue — is superficially
+2 -2
View File
@@ -2068,8 +2068,8 @@ pub(crate) struct LayerShader {
///
/// Kept apart from the rest because it belongs at the other end of the
/// shader. Everything else runs on scene-referred colour in the working
/// space, where a flat tint would then be pushed through the base curve
/// and the camera matrix and arrive as some other colour, and a
/// space, where a flat tint would then be pushed through the view
/// transform and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen.
pub reveal: String,
+2 -2
View File
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
/// Move the graph so that `sample` renders neutral.
///
/// `sample` is the linear triple the operation's own gains multiply — camera
/// RGB with the camera's as-shot balance on, *before* the body's base curve
/// and matrix, and with the sampling operation at its defaults. Not the
/// RGB with the camera's as-shot balance on, *before* the camera matrix and
/// the view transform, and with the sampling operation at its defaults. Not the
/// pixel on the screen: the matrix mixes the channels on the way there, so
/// a colour read after it does not answer to these gains, and a solve over
/// one lands somewhere no sample asked for. Returns whether the graph was
+1 -1
View File
@@ -518,7 +518,7 @@ pub enum OutputMode {
///
/// What a merge stitches (FR-MRG-2). The shader is the linear tail with
/// no operations, and the caller fills the reserved uniforms neutral —
/// unit white balance, identity matrix, base curve off — so what is
/// unit white balance, identity matrix, no view transform — so what is
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
/// [`compose_camera_probe`] produces it — for a merge through
/// [`compose_camera_linear`], and for the white balance picker under the
+4 -4
View File
@@ -706,10 +706,10 @@ mod tests {
#[test]
fn it_declares_itself_a_rendering_transform() {
// The whole reason the composer skips the base curve and the camera
// matrix. If this ever returned false the picture would be rendered
// twice and converted twice, which looks like a colour management bug
// a long way from here.
// The whole reason the composer emits the stock in the view
// transform's place rather than beside it. If this ever returned
// false the picture would be rendered twice, which looks like a
// colour management bug a long way from here.
assert!(FilmSim::new().renders());
}
+23
View File
@@ -515,3 +515,26 @@ texture and dehaze; the scenes without dehaze moved within ±2%. The
picture is the same bits: a minimum is exact in any order, the window is
the one the split passes covered, and the rgba8 output hashed identically
before and after in all 64 scene, view and size combinations measured.
## The view pass after the detail stage — 2026-09-27
**Status:** Not measured. Every figure above predates it.
**A chain with a detail stage is now one dispatch longer** (`c07f81e`,
D19). The fused pass used to end in the rendering — the base curve, then
the output transform — before it stored, so every detail pass convolved
display-referred values, and the last detail pass encoded. Now the fused
pass stops before the view transform, every detail pass writes a
scene-linear `rgba16float` intermediate, the last one included, and a view
pass composed from the same inputs reads the result and runs the view
transform (or the film stock), the output transform and the mask reveal.
What that adds, per frame with a detail stage: one render-sized read and
write, and a third intermediate for a one-pass chain. By the dehaze
section's own figure that is about 4 ms at 2560 × 1600 on the laptop
RTX 3050 under its power cap. What it removed: a capture sharpening too
fine to draw at the current scale no longer emits a pass-through, and
there is no resolve pass for an active kernel with nothing to draw. A
chain with no detail operation is unchanged, one fused dispatch with the
view transform at its tail. The rows above that name a detail operation
should be re-run before they are quoted.
+5 -3
View File
@@ -143,7 +143,9 @@ composer already makes that a matter of uniforms rather than structure: the
white balance, the matrix and the curve's active flag are all in the reserved
uniform block, and a fused pass with no operations, `as_shot_wb = 1`,
`cam_to_srgb = I` and `base_curve_last.z = 0` stores exactly camera-linear
RGB after the warp. So the tap is:
RGB after the warp. *(Since D19 there is no curve flag: the base curve is
gone, and the tap composes no view transform, so the white balance and the
matrix are the only uniforms it fills neutral.)* So the tap is:
- `EditGraph::compose_camera_linear()` — the `LinearWorking` tail with an
empty operation list and identity framing, paired by name with
@@ -158,8 +160,8 @@ be re-developed deserves the sensor's precision. The cost is 2× on buffers
FR-MRG-11 already bounds.
**What the DNG carries as a consequence:** the first source's `Make`,
`Model` and `UniqueCameraModel` — so `base_curve::for_body` finds the 6D's
curve (retired with the base curves, D19) — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
`Model` and `UniqueCameraModel` — so `base_curve::for_body` found the 6D's
curve, until D19 retired the per-body curves — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
composite then develops through the same profile as its sources, applied
once. The spike's 64 × 48 file (§8) already carries the matrix and neutral;
the body name is a string.
+10 -7
View File
@@ -565,8 +565,9 @@ display 0.18. A photograph with the view transform at its defaults is **unedited
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.
An already-rendered source — a JPEG — is not rendered again: the view transform is skipped for it,
as the base curve was, so its two sliders do not move a JPEG. A film stock is not skipped, 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
@@ -1911,7 +1912,7 @@ with no depth to recover — so it is where the shared machinery is built.
**FR-MRG-2 — What is stitched.** Each source enters the merge in **camera space**: after black
and white levels, demosaic and lens distortion correction, and before everything else — no white
balance, no base curve, no camera matrix, no edit. The composite carries the first source's body,
balance, no camera matrix, no edit, no view transform. The composite carries the first source's body,
colour matrix and as-shot neutral, so that it is developed afterwards exactly as one of its
sources would be: the camera profile, the white balance and every operation in §3.3 are applied
once, to the composite, in its own develop.
@@ -1920,9 +1921,11 @@ This is the clause that decides what the output *is*. Stitching the rendered edi
stitcher does; the result cannot be re-developed, and any difference between the frames' edits
becomes a seam. Stitching camera-space pixels produces a photograph the camera could have taken,
and nothing is applied twice. The cut sits *below* the profile, not above it, for a reason S15.3
found in the pipeline: the base curve is part of the profile (FR-DEV-3e) and is applied to every
frame of a known body, so a composite that baked it in and then developed as one would render the
curve twice. Lens correction alone sits above the cut, because a distorted frame does not align.
found in the pipeline: the profile's rendering was applied to every frame of a known body, so a
composite that baked it in and then developed as one would render it twice. *(Amended 2026-09-27,
D19: that rendering was the per-body base curve, retired; the view transform that replaces it is
applied to every develop, so the reason stands.)* Lens correction alone sits above the cut, because
a distorted frame does not align.
White balance sits below it because the sensor saw the same light in every frame: un-balanced
camera RGB agrees across the overlaps whether or not the camera's auto white balance drifted, and
the balanced values would not.
@@ -2429,7 +2432,7 @@ Settled by requirements calibration, 2026-08-08.
| Culling | **The core differentiator** (§3.9) |
| Focus checking | Peaking *and* zoom |
| Ingest | Full workflow — template rename, checksum verify, dual-destination |
| Colour defaults | Good, not obsessive — matrices plus per-body base curve |
| Colour defaults | Good, not obsessive — matrices plus one scene-referred view transform for every body (D19; the per-body base curves are retired) |
| Film simulation | Fujifilm explicitly targeted |
| AI | Denoise in v1; masking deferred. Per-face eye state and head pose are in v1 **as culling evidence, not AI** (FR-CULL-8a, FR-CULL-13); gaze deferred (§7) |
| Local adjustments | Full masking, GPU-rasterised |
+1 -1
View File
@@ -416,7 +416,7 @@ impl DevelopSession {
/// **This is the other histogram, not a variant of the one above**, and
/// the two answer questions that a culling decision needs kept apart.
/// [`Self::histogram`] counts the frame on the canvas, after white
/// balance, the camera matrix, the base curve, the tone curve and the
/// balance, the camera matrix, the tone curve, the view transform and the
/// output transform: a clipped bin there is a highlight that is gone as
/// the image currently stands. This counts the demosaiced scene-linear
/// texture, before any of that, on an axis of stops below sensor
+2 -2
View File
@@ -49,8 +49,8 @@ impl DevelopSession {
///
/// **Measured where the operation acts, not where the photographer
/// looks.** The white balance node runs first in the chain, on camera
/// RGB, before the body's base curve and its matrix; the canvas shows
/// the pixel after all three. The probe used to be read off a display
/// RGB, before the camera matrix and the view transform; the canvas
/// shows the pixel after all three. The probe used to be read off a display
/// render with the adjustments stripped, and the solve then treated an
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
/// spaces coincide, so it worked; on a raw file from any real body the