Files
RecipesCam/docker/frontend/shared
3dtours 899921ef8a A dark detail stops being copied beside itself: DEHAZE reads its prior off a copy of the frame
Raising DEHAZE drew bright copies of every dark detail in the photo, stacked
alongside it. The prior was the reason, and the prior was read wrongly.

The 5x5 patch of DEHAZE_SKSL was sampled inline, five taps out at 0.625% of the
frame's width each — one tap every 6.67 pixels of the 1067-pixel preview, an
average spacing over a 26-pixel patch that is meant to be the minimum over it. A
detail thinner than that spacing therefore sat between two taps on one row and
under a tap on the next, and the transmission swung between "this patch holds a
shadow, leave it alone" and "this patch is all haze, divide hard" with a
7-pixel period around every dark thing on the frame. A rising DEHAZE drew that
period: `t` is a per-pixel divisor, so the rows of the detail that were left
alone stayed put while the rows read as haze came up bright, and the 13.3-pixel
column spacing made the next copy and the copy after that. That is what was
stacking.

Measured on a synthetic frame of sloped haze with four one-pixel dark lines: the
old shader departs from the clean correction by +58 to +102 codes (8-bit) at
exactly +/-6.67 and +/-13.3 pixels around each line — the two spacings, in both
directions, which is the whole signature of the bug. That frame is otherwise
flat, so those deviations are the copies.

Three things had to be got right, and each is the smallest fix that removes one
of them:

- The patch is no longer sampled. The caller builds the dark channel as an image
  — a 64x64 copy of the frame, smallest channel over a cell-wide neighbourhood,
  then two box passes — and hands it to the pass as its second child, so the
  shader's own `dark.eval` IS the patch: one cell covers a whole neighbourhood
  rather than sampling it, and the bilinear upscale interpolates it back up with
  no period left in it. The box passes are the doc's soft matting in the one form
  free here — the map is smoothed, not the pixels. The copy is made with
  drawImageRect, rect to rect: a paint shader drawing a 64x64 rect reads only the
  source's 4x4 corner, and a one-pixel line in such a copy lands at 166, i.e.
  pure haze, because the cell covering it is mostly sky.

- What travels as that image is the dark channel and not the transmission. t is
  1 + 0.95 at the negative end of the knob, more than a channel can carry, so a
  copy of t would arrive here clipped to 1 and "put the scattered light back"
  would become a pass that returns its input. The dark channel is 0..1 by
  construction and the signed amount stays a uniform, where it costs no range —
  the knob keeps both of its directions. Swept on the real photo, DEHAZE -100
  moves 779,237 pixels brighter and 703,114 darker (worst 110 codes) while the
  same frame at 0 either side of it moves exactly none, and +100 moves the frame
  the other way at atmosphericLight [0.93155, 0.90980, 0.93084].

- The pass was reading a shader that does not exist yet. `effects()` is what it
  asks now, not the module variable: nothing above DEHAZE has asked for the
  effect, so on the first render the variable is still null and the knob stayed
  dead until some later render happened to fill it in.

A map this small only covers the frame if it is told to, and the matrix that does
it is the last thing that had to be right: CanvasKit reads a shader's local
matrix as the map's own pixels to the frame's, so the 64x64 copy needs frame over
map, `[W/64, 0, 0, 0, H/64, 0, 0, 0, 1]`. Without it the pass covers only the
top-left 64 pixels and clamps every pixel past them onto the map's last texel —
one constant t over the whole photo, a global inversion and not a dehaze.
Measured against the ideal ramp, `scaled(n/w)` clamps the same way; the
reciprocal lands on it.

The knob is left to over-correct at the top of its range, and that is deliberate.
A hazy sky still goes white and a saturated colour beside a dark edge still
deepens: `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, which is
the arithmetic the doc asks for. It is smooth on the frame — 6x zoom panels of
the hazy frame at DEHAZE 100 show one wide gradient and no band repeating at any
period — so no knee is added to soften a correction that is no longer producing
the symptom. If that side ever needs taming, DEHAZE_MAX_OMEGA is the one number.

The MASK's DEHAZE still samples the old 5x5 patch inline (gradientMask.ts,
unchanged): the frame-wide pass is what the report was about and what is fixed
here.

Verified: the synthetic harness over four patch configurations puts the new
shader at zero deviation from the clean correction at N=64 — the 16.7-pixel cell
swallows the test line, which is why the real photo is the judge. The real photo
through the running app, with the slider swept 0, 100, -100, 0, is exact at both
zero points and moves the frame at both ends, and its zoomed panels at DEHAZE 100
— roof, floor and a wooden rail at 3x and 6x — show the remaining change as one
smooth region, the blue of a tarp and the green of a floor stain deepening where
the haze was hiding them, with nothing repeated around the dark detail that used
to copy itself. npx tsc --noEmit clean, npm run build clean,
scripts/mask-wb-check.mjs and scripts/highlight-knee-check.mjs both pass.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:02:35 +07:00
..