Commit Graph
3 Commits
Author SHA1 Message Date
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
dtourolleandClaude Opus 5 e17b909d41 Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper
and test suite without an entry in `ops/`, so it was never in `chain()`.
It reached no photograph and no panel, and `Attribute::Optics` was an empty
category in consequence — filtered out of the tab strip for having no rows,
by a chain that had never been given its only member.

Declaring it needs the one thing the operation was written against and
which did not exist. `wgsl_body` reads `radius`, and the module claimed
"the composer publishes `radius` in the shader prologue for exactly this
reason". It did not. `sample_source` now does, in both sampling branches,
beside the `source_px` it already published for the same class of caller.

It is corner-normalised there, which is the part that is easy to leave out.
`p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) —
about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against
a corner radius of 1, so passing `length(p)` straight in evaluates every one
of them short of where it was measured, by a factor that changes with the
aspect ratio. It would have read as a correction that is simply too weak,
which is indistinguishable from a bad profile. Both `lens.rs` and
`framing.rs` asserted the normalisation `p` does not have; corrected.

`order: 5` puts the correction ahead of the tonal stages, and the ordering
is load-bearing rather than tidy. Recovering a corner means dividing by an
attenuation below one — about two stops for a fast prime wide open — so run
after the highlights have been rolled off and clipped, the lift has nowhere
to go and the corners posterise instead of brightening.

`layer_chain` now drops `Optics` as well as the neighbourhood operations.
A local vignetting slider would have worked, which is what makes it worth
excluding: `radius` measures from the centre of the whole photograph and a
mask cannot move the optical axis, so it would lay a frame-centred radial
ramp across the picture and multiply it by the mask. The existing exclusion
covers operations that move and do nothing; this one covers an operation
that moves and does something its name does not promise. The rule both
share is now written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:24 +02:00
dtourolleandClaude Opus 5 0cd3ef1b3f Run a node declaration without compiling it
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
declarative nodes landed — it was simply resolved at build time. Nothing about
a declaration requires the compiler: everything it produces is data plus a
WGSL string, and the composer already assembles WGSL at run time from whatever
operations are active. So this is not a new mechanism. It is the existing one,
loaded later (FR-PLG-2).

`DeclaredOp` implements `Operation` from an owned `Declaration` — one
interpreter over many declarations, where `build.rs` emits generated code per
node. The generated path stays, as FR-PLG-2 says it should: a generated
`match` is faster than an interpreted one, the built-ins' declared `tests:`
have to run under `cargo test`, and generated source is inspectable in a way
an interpreter's state is not.

**The reader is now one file, read by both.** `src/declared/decl.rs` and
`src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being
modules of the crate, and they produce a neutral `Declaration` that names no
Rust type. The build script's job is reduced to *rendering* that declaration
as Rust; `DeclaredOp` converts the same declaration into descriptors and
`Expr::eval` walks the same tree the renderer writes out. There is one
grammar, one set of validations and one set of error messages, so "a plugin is
the same kind of thing as a built-in" is structural rather than aspirational.

What remains genuinely written twice is the pair of backends — an arithmetic
node rendered as Rust here and evaluated there — and that is what the parity
test stands between. `tests/declared_parity.rs` parses every built-in
declaration at run time and asserts the composed WGSL is byte-for-byte what
the generated implementation produces, with the uniform block bit-for-bit
identical, at both ends of every parameter's range and at four interior
points; then again over the whole develop chain with the declared nodes
swapped in, which is what covers uniform slot ordering and helper
de-duplication between operations. A third test asserts the declared and
hand-written nodes partition `ops/` between them, so coverage cannot shrink
silently.

Bit-for-bit rather than within a tolerance, because a tolerance is where a
real divergence hides. The one thing that had to be got right for that to hold
is number literals: `expr::as_f32` rounds a decimal exactly once, through the
same shortest-round-trip text the compiler is handed, rather than rounding an
`f64` a second time.

Not in scope, and deliberately untagged: load-time WGSL validation
(FR-PLG-11), id namespacing, a plugin directory read at startup, and pass
nodes (FR-PLG-2a). Those are separate work, and tagging them from here would
be the overstatement the spec's own §7 warns about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:08:34 +02:00