Commit Graph
1 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