Files
DarkRoom/core/dr-pipeline/ops
dtourolle 81b1ae8c42 Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.

Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.

The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.

The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.

Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.

It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.

What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.

FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
2026-09-06 19:01:47 +02:00
..

Develop nodes

One file per operation. Adding a node to the pipeline is adding a file to this directory — there is no list to extend, no shader to edit, and no UI change.

../build.rs compiles each declaration into Rust implementing Operation, generated into OUT_DIR. The result is indistinguishable downstream from a hand-written operation: the same Arc<OpDescriptor>, the same fused-shader composition, the same sidecar round-trip.

The same declaration also runs without being compiled (FR-PLG-2). DeclaredOp reads this format at load time and implements Operation from it directly — one interpreter over many declarations, where build.rs emits generated code per node. Both paths exist on purpose: the built-ins stay compiled, because a generated match is faster than an interpreted one and because the tests: blocks below have to run under cargo test.

The reading half is one file, src/declared/decl.rs, shared by both — so everything documented here means exactly one thing, and tests/declared_parity.rs asserts the two backends compose byte-identical WGSL for every node in this directory. Nothing in this document is specific to the build-time path.

attributes: — what the operation is about

Required, one or more of tone, colour, detail, optics, geometry, effect. The build fails without it, and that is deliberate: an operation with no attribute is invisible to an interface that groups by them, and a control that silently does not exist is a worse failure than a build that stops.

Say what the operation is, not where you want it drawn. The core declares capabilities; the frontend composes (ARCH §4.3a). tone means "this is about lightness", not "put this in the second tab" — the interface is free to render attributes as tabs, as headings, or not at all, and that choice may differ between a tablet and a desktop without this file changing.

Plural is normal. The tone curve is [tone, colour] because an RGB curve is tonal and the per-channel curves are chromatic; it appears wherever the frontend decides that means. Reach for a second attribute when an operation genuinely answers two questions, not to make it easier to find.

The vocabulary is closed. A typo is a build error rather than a new category containing exactly one operation — which looks identical to a deliberate new category until somebody notices the group with one control in it.

What you get for free

A node dropped in here arrives with:

  • Controls. The develop panel builds them from the parameters' declared kinds (FR-DEV-3c). It never learns the node's name.
  • A place in the chain, from order:.
  • A place in the panel, from attributes:. The interface groups by these and names no operation, so a node arrives in the right group by saying what it is rather than by anyone editing a list in ui/.
  • Persistence. Parameters are ordinary scalars, so the sidecar writes them with everything else.
  • Its tests, compiled from tests: and run with cargo test.
  • Neutral-means-absent. At its defaults the node contributes no code, no uniform, and no branch to the generated shader.

The shape of a file

id: exposure                 # must match the filename
label: op.exposure           # a localisation key, never a display string
order: 20                    # where it sits in the chain
attributes: [tone]           # what it is about; one or more, and required

doc: |                       # becomes the generated module's documentation
  Exposure — a linear gain, expressed in stops.
placement: |                 # why it sits where it does; appears above the
  A correction to capture.   # entry in the generated chain

params:
  exposure:
    label: param.exposure
    kind: stops
    min: -5
    max: 5
    doc: |                   # optional prose, kept with the descriptor
      ±5 stops.

uniforms:
  gain: exp2(exposure)       # shorthand, or a mapping with `value:` and `doc:`

helpers: [luminance]         # names from _helpers.yaml
define:                      # helpers this node alone needs
  my_curve: |
    fn my_curve(x: f32) -> f32 { return x; }

wgsl: |                      # `c` is linear RGB in and out
  c = c * gain;

presentation:                # optional; omit for one control per parameter
  widgets: [tone_curve]      # a hint, most preferred first
  params: [exposure]         # what that widget owns

tests:
  - name: one_stop_is_a_doubling
    why: The definition of a stop.
    set: { exposure: 1 }
    expect: { gain: 2.0 }

Parameter kinds

kind Shape Extra keys
amount −100…100, neutral at 0 — the familiar photographic control —
stops exposure-like, in stops min, max
fraction 0…1 default
switch a toggle, neutral off —
enum one of a short, fixed list variants
scalar anything else min, max, default, unit, scale, precision

Reach for amount first. Writing its range out by hand in each node is how one of them comes to disagree with the rest.

An enum names its choices as localisation keys, most-neutral first:

params:
  method:
    label: param.method
    kind: enum
    variants: [param.method.fast, param.method.exact]

The value is the chosen index, carried as an f32 like every other parameter — so a uniform expression can use it (method is 0 or 1), the sidecar stores it unchanged, and nothing along the way needed a second kind of value. The default is always the first variant, so reset means what it means everywhere else; if your neutral choice is not first, reorder the list rather than reaching for a default:.

Two variants is the minimum. A one-entry list is a control the user cannot change, and the build rejects it.

Uniform expressions

Arithmetic (+ - * /), parentheses, numbers, this node's parameters, and: exp2 log2 exp sqrt abs floor ceil round pow min max clamp mix.

Compiled to Rust, so an unknown name or a wrong arity is a build error naming the file and the key. Deliberately small: a node is a description, and letting it name arbitrary Rust would make this a second, worse place to write code.

Activity

By default a node is active exactly when some parameter has moved off its default, which is the honest rule and the right one for nearly everything. A node whose neutral is something else declares active: as an expression — non-zero means active.

Presentation

Optional, and absent from nearly every node: one control per parameter is the right answer for a list of unrelated sliders, which is what most operations are. Declare it when several parameters form one conceptual control.

presentation:
  widgets: [colour_wheel]        # most preferred first
  demand:
    two_dimensional: true        # dragged in x and y at once
    precise_pointing: false      # needs accuracy finer than a fingertip
  params: [hue, strength]        # what the widget owns, in its order

Widgets: tone_curve, colour_wheel, crop_overlay, gradient_handle, brush_mask, white_point.

It is a hint and only a hint. The frontend walks widgets and takes the first it both implements and can afford; if it implements none of them, or cannot meet the demand, it renders the parameters as ordinary sliders and the edit still works. So it is safe to name a widget nothing draws yet — the node degrades to sliders rather than breaking. That fallback is also why every parameter must stay an ordinary scalar: it is what there is to fall back to.

demand says what the widget inherently needs, never what the screen has. There is deliberately no way to write a pixel width, a breakpoint or a platform name here — those are the frontend's to decide, and a node that reasoned about them would eventually be wrong about a display it never saw (ARCH §4.3a).

Parameters named in params: are claimed by the widget and not drawn separately, so a typo would take a parameter out of both. The build checks each name against the node's own params: and fails if it does not match.

Tests

Key Asserts
set parameter values to apply first; build fails if out of range
expect uniform values, within 1e-5
expect_range a uniform lies in [low, high]
expect_active whether the node reaches the shader
expect_wgsl substrings the fragment must contain
expect_helper_wgsl substrings a named helper must contain

why: becomes a comment in the generated test. Use it — a test named for a property should say what breaks without it.

Hand-written nodes

Some operations are not four facts, and forcing them into this schema would produce a worse language aimed at one caller. Those stay in ../src/ops/ and declare only their position here:

id: tone_curve
order: 60
rust: ToneCurve
why_rust: |
  Its neutral is a relationship between five points, not a set of values.

They still belong in this directory, because the pipeline's order is the one thing a reader comes here to learn, and an order written half in YAML and half in Rust would be worse than either alone.

Currently hand-written: tone_curve (one widget over four curves of five interpolated points — master, red, green, blue — each reaching the shader only 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 hand-written too but is not in the develop chain — it carries lens-profile coefficients that are not parameters.

The node that renders

film_sim is the only operation that returns true from Operation::renders, and it is worth knowing why before writing a second one.

Every other node adjusts a picture. That one makes it: a film stock's characteristic curve does the camera profile's base curve's job, from measurements rather than from a curve somebody drew. Running both renders the scene twice — the camera's rendering, and then a film's rendering of that — which looks like neither and reads as a colour-management bug with no colour-management bug to find.

So a node declaring renders takes camera RGB and hands back linear sRGB, and in exchange the composer emits neither the base curve nor the conversion out of camera space. Both halves move to the node, together: the base curve is defined in camera RGB and the matrix is what leaves it, so a node replacing one has necessarily replaced the other. compose_full keeps them as a single string for exactly that reason — it is what makes getting half of it right impossible. distortion and aberration are Warps rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it.

Nodes that read their neighbours

wgsl: above is handed c, a colour, and no coordinate. That is what makes the fused dispatch possible and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are all defined by what the neighbouring pixels are doing, and none of them can be written as a function 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 ../src/detail.rs for why each of those is a decision rather than a convenience. A node of this kind:

  • is declared here with rust:, like any other hand-written node, because a kernel is not four facts and stretching this schema to cover one would produce a worse language than Rust;
  • implements Operation as usual — descriptor, parameters, is_active — so the panel, the sidecar, the history and the presets all work unchanged;
  • returns Affects::Detail from affects() and Some(self) from detail();
  • implements DetailStage::passes, returning one DetailPass per dispatch, each with a WGSL body, its uniforms, and its kernel radius in render pixels, which the tile scheduler needs and nothing can infer.

capture_sharpen is the worked example: two passes, one per axis, and a radius converted from source pixels once per render.

The order: still belongs here, and still orders the node — among the other detail nodes. Detail runs as a group after every point operation, so an order: that interleaves one with exposure would be a lie the chain cannot tell.

The one thing to get right is the unit of a radius. Never store pixels: a length is either a fraction of the frame's shorter edge (RenderScale:: frame_fraction — clarity, texture, the unit a mask feather already uses) or a count of source pixels (RenderScale::source_pixels — capture sharpening, luminance NR). passes() is given the scale and converts on the CPU. A radius in raw pixels is a different photograph on screen and in the exported file.

What is not a node, and why

Three things act on every pixel and are deliberately not in this directory: the as-shot white balance, the camera matrix, and the base curve (FR-DEV-3e). They are emitted by ../src/operation.rs into the composed shader's fixed preamble, around the block of nodes.

The test is not "does it transform a colour" — all three do. It is whose decision is it. A node is something a photographer chose: it has parameters, it moves off a neutral, it lands in the sidecar, it can be undone. These three are properties of the file, at the same standing as the masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's green sensitivity or the body's rendering; they are what reading the file correctly means.

Making the base curve a node would have said the opposite in four places at once. It would have appeared in the develop panel as a control, so an unprofiled body would show a slider that does nothing. Its values would have gone into the sidecar, and sidecars are shared between devices and bodies (FR-NC-9) — one camera's rendering would follow an edit onto another camera's file. Its neutral would have had to be "the identity", so a profiled body would open reporting itself modified. And there is no seam through which a node could learn which camera took the frame: the profile arrives on the decoded image, travels through DemosaicedImage beside the matrix it belongs with, and is written into the uniform block by the same three lines in dr-gpu — which is exactly the path the matrix already took, because it is exactly the same kind of thing.

What it does share with the tone curve node is the spline. The composer asks ToneCurve for its curve_span/curve_eval helpers rather than emitting a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it.

The order still reads correctly from this directory: the base curve runs after every node in the chain and before the conversion out of camera space. That is the same reasoning exposure records under placement: — corrections to capture are only meaningful on linear values, so the rendering goes last.

Errors

The build script reports failures by naming the key you got wrong, and exits rather than panicking, so the message is the message and not a backtrace. It refuses: a duplicate order:, a filename disagreeing with its id:, a default outside its own range, a test value the graph would clamp before the node saw it, a helper that does not define the function it names, an expression naming something that is not a parameter, and a declared node whose name collides with a file in ../src/ops/.