web: read DEHAZE off the dark channel, and let it run both ways

DEHAZE read its haze estimate out of the frame's own bilateral reference — the
patch AVERAGE — where the Dark Channel Prior asks for the patch MINIMUM. That
one word is the whole prior: `dark = min(min(r,g,b)/A)` over a neighbourhood
reads 0 for any patch that holds a shadow or a black frame line, so the
transmission stays at 1 and the patch is left alone, while the average of a
patch that holds a dark pixel is still bright, so every patch looked hazy. The
positive end therefore ground the frame down instead of taking haze out of it:
at +9 the mask moved its own middle band -0.2127 and the frame-wide row moved
the whole frame -0.2311, and the local contrast went the WRONG way (dhp -0.0060
on the mask, -0.0056 frame-wide) — a haze remover that lowers contrast is a haze
remover that is lowering everything.

The pass reads the dark channel from the image it is correcting, five by five
taps at DEHAZE_PATCH_STEP (0.625% of the frame's width per tap, a 2.5%-wide
patch — the DCP's own 15 pixels on a 600px frame, and the same fraction of a
4000px export) in DEHAZE_SKSL and in gradientMask's block, so the mask and the
frame-wide row are the same neighbourhood at every render size. Five by five
rather than fifteen by fifteen because 225 child reads per pixel is what
CLARITY_BLUR_SKSL already refused for a reference the prior does not need to be
that wide. The bilateral reference is now only what CLARITY compares against, so
DEHAZE no longer takes a second child at all.

DEHAZE is signed, which it was not: the knob was 0..10 and the export engine
skipped the pass unless the amount was above zero, so a negative value was a
slider the UI would not even offer. It is -10..+10 now, and the transmission
carries the sign — positive pushes t below 1 and `J = (I - A)/t + A` takes the
scattered light out, negative pushes it above 1 and the same expression scatters
light back in. That is the direction a photo shot through mist wants, and it
needs no second formula: one expression, both signs, the ceiling at
1 + DEHAZE_MAX_OMEGA.

CLARITY's negative side was the last place where a knob meant two different
things depending on where it was read: the frame-wide row softened with a mist
blur of its own radius (MakeBlur, sigma |c|/10*4) while a mask mixed toward the
bilateral reference the positive side reads — two neighbourhoods, two strengths,
one name. CLARITY_BLEND_SKSL now carries both directions of the one move (above
zero the doc's unsharp, below it the mix back toward the same reference, gain
1), so the frame-wide row and a mask's CLARITY are the same reference at the
same strength, and the frame-wide mist blur is gone.

Measured in one harness, one photo, one session, knob at +-9, before -> after,
mask phase and frame phase in the same run (the box is the mask's own middle
box for the mask, the stage's own box for the frame-wide row):

  - FRAME DEHAZE +9: dmean -0.1680 -> -0.0751, dhp -0.0056 -> +0.0036, white
    band -0.2156 -> -0.0522 — it darkens the haze and raises the contrast
    instead of lowering both.
  - FRAME DEHAZE -9: dmean +0.0469 (was not offered), dhp -0.0010 — the same
    knob on the other side, and the frame gets hazier.
  - MASK DEHAZE +9: dmean -0.1490 -> -0.0513, dhp -0.0060 -> +0.0039, white band
    -0.1234 -> -0.0274, dark band -0.0595 -> -0.0075 — a mask's DEHAZE is now
    the frame-wide move on the mask's own pixels (dhp +0.0039 against the
    frame's +0.0036).
  - MASK DEHAZE -9: dmean +0.0319, dhp -0.0013.
  - FRAME CLARITY -9: dhp -0.0200 -> -0.0094, white band -0.1112 -> -0.0203, so
    the frame-wide row no longer pays for its soften by flattening every white
    in the frame; MASK CLARITY -9 is the same move (dhp -0.0150, white band
    -0.0103) and the two now agree in direction, sign and rough magnitude at
    -9. CLARITY +9 is untouched on both sides (+0.0335 mask, +0.0307 frame) and
    every other knob's numbers are unchanged to within +-0.0005, which is the
    run-to-run noise of the same harness.

`step` was the uniform's first name and SkSL refused the shader with it (a
builtin), which is how a whole DEHAZE row came back with all-zero deltas in the
first measurement after the change; `stepPx` is what compiles. `npm run
typecheck` and `npm run build` are clean, and the stage draws with no page error
(the only console error is the dev server's own `/api/events` 404).

Not ported: nothing. The phone's renderer has no gradient mask and no
atmospheric-light estimate to mirror; `shared/utils/toneShader.ts` and
`shared/utils/gradientMask.ts` are the web engine's own files.

Probes: measure-parity (both phases in one run, one photo, before and after —
the same harness the previous commit was scored with), measure-dehaze2 (the same
script with only DEHAZE in both phases, plus a console listener, which is how
the `step` uniform was caught), sim-dehaze-dcp (the offline simulation that
picked the min-patch over the average: clear frame +9, contrast 0.0248 -> 0.0292
against the average's 0.0248 -> 0.0235).
This commit is contained in:
2026-09-26 20:16:13 +07:00
parent 5f3257a4d8
commit 9164bf3228
6 changed files with 158 additions and 75 deletions
+20 -13
View File
@@ -33,6 +33,7 @@ import {
CLARITY_GAIN,
DEHAZE_SKSL,
dehazeUniformArray,
dehazePatchStep,
getToneUniforms,
toneIsActive,
toneUniformArray,
@@ -776,36 +777,42 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
return air;
};
// DEHAZE is signed: positive takes scattered light out, negative puts it
// back (DEHAZE_SKSL's own t moves either side of 1). Either way it reads the
// dark channel from the frame itself — a minimum over a patch, not the
// bilateral reference — so the pass needs no second child and the reference
// is built only for CLARITY and the masks that ask for it.
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
if (dehazeAmount > 0 && dehazeEffect) {
if (dehazeAmount !== 0 && dehazeEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const a = airOf();
const reference = spatialReference(surface, width, height);
if (!a || !reference) return null;
own(reference);
const shader = dehazeEffect.makeShaderWithChildren(dehazeUniformArray(a, dehazeAmount), [
own(imageShaderChild(snap)),
own(imageShaderChild(reference)),
]);
if (!a) return null;
const shader = dehazeEffect.makeShaderWithChildren(
dehazeUniformArray(a, dehazeAmount, dehazePatchStep(width)),
[own(imageShaderChild(snap))]
);
return shader ? own(shader) : null;
});
}
if (adjustments.clarity > 0 && clarityBlendEffect) {
// CLARITY is one move in two directions: the frame against its own blurred
// reference above zero, the mix back toward that same reference below it —
// which is exactly what a mask's CLARITY does with the same child, so the
// frame-wide knob and the masked one are the same neighbourhood and the same
// strength (the negative side used to be a mist blur of its own radius).
const clarityKnob = adjustments.clarity ?? 0;
if (clarityKnob !== 0 && clarityBlendEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const reference = spatialReference(surface, width, height);
if (!reference) return null;
own(reference);
const shader = clarityBlendEffect.makeShaderWithChildren(
[(adjustments.clarity / 10) * CLARITY_GAIN],
[(clarityKnob / 10) * (clarityKnob > 0 ? CLARITY_GAIN : 1)],
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
);
return shader ? own(shader) : null;
});
} else if (adjustments.clarity < 0) {
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
drawBlurred(canvas, surface, width, height, mistSigma);
}
// Negative SHARPENING takes edge enhancement back out — after the conv, so
// it cannot blur away what the CLARITY pass just added.