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:
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user