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>
This commit is contained in:
@@ -544,65 +544,62 @@ export const CLARITY_GAIN = 3.2;
|
||||
// The patch AVERAGE this pass used to read instead (the bilateral reference)
|
||||
// called every patch hazy, so the positive end ground the frame down instead of
|
||||
// taking haze out. `air` is the atmospheric light the caller estimated from the
|
||||
// frame, `step` one tap of the patch in the caller's own pixels — a fraction of
|
||||
// the frame's width, so the preview and the file look at the same neighbourhood
|
||||
// (DEHAZE_PATCH_STEP).
|
||||
// frame — brightest dark-channel pixel of a copy of it, the doc's 0.1% answer in
|
||||
// one readback (exportEngine's atmosphericLight).
|
||||
//
|
||||
// `amount` is signed. Positive pushes the transmission 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, which is what a negative
|
||||
// DEHAZE is for. The floor keeps a flat sky from dividing by zero, and the
|
||||
// ceiling is the largest amount the knob can ask for either way.
|
||||
// Ray marching the doc's A estimate would need the histogram; the caller reads a
|
||||
// 32x32 copy of the frame instead and takes its brightest dark-channel pixel —
|
||||
// the same 0.1% answer, in one readback (see exportEngine's atmosphericLight).
|
||||
// The pass itself is now only the doc's last line, `J = (I - A)/t + A`, on a
|
||||
// transmission the caller has already solved for. `dark` is what the caller
|
||||
// hands in as an image: the dark channel itself, read off a small copy of the
|
||||
// frame and interpolated back up, which is the smoothing the prior wants — see
|
||||
// the caller's dehazeDarkChannel for why it cannot be had from a patch read out
|
||||
// per pixel. Its cell is the patch, so a value per cell is a value per patch.
|
||||
//
|
||||
// What travels as an image is the dark channel and not t on purpose. A channel is
|
||||
// eight bits, so it can only carry 0..1 — and t is 1 + 0.95 at the negative end
|
||||
// of the knob, which would arrive here clipped to 1 and turn "put the scattered
|
||||
// light back" into a pass that does nothing. The dark channel is 0..1 by
|
||||
// construction, and the signed amount stays a uniform where it costs no range.
|
||||
//
|
||||
// Signedness is then in the expression. Positive folds t below 1 and takes the
|
||||
// scattered light out; negative folds it above 1 and the same expression scatters
|
||||
// it back in, which is what a negative DEHAZE is for. The floor keeps a flat sky
|
||||
// from dividing by zero, and the ceiling is the largest amount the knob can ask
|
||||
// for either way.
|
||||
export const DEHAZE_FLOOR_T = 0.1;
|
||||
export const DEHAZE_MAX_OMEGA = 0.95;
|
||||
|
||||
// The dark channel's patch, and the two ends of the transmission t. The patch is
|
||||
// `taps` samples out at `step` each — two taps at 0.625% of the frame's width is
|
||||
// a 2.5%-wide neighbourhood, the DCP's own 15-pixel patch on a 600-pixel frame
|
||||
// and the same fraction of a 4000-pixel export. Five by five samples rather than
|
||||
// The patch the MASK's DEHAZE reads out of its own frame (gradientMask.ts) —
|
||||
// `taps` samples out at `step` each, two taps at 0.625% of the frame's width, a
|
||||
// 2.5%-wide neighbourhood: the DCP's own 15-pixel patch on a 600-pixel frame and
|
||||
// the same fraction of a 4000-pixel export. Five by five samples rather than
|
||||
// fifteen by fifteen because the doc's 225 reads per pixel is what
|
||||
// CLARITY_BLUR_SKSL above already refused, and the prior only needs a patch the
|
||||
// haze is flat over.
|
||||
// haze is flat over. The frame-wide pass reads no patch at all any more.
|
||||
export const DEHAZE_PATCH_TAPS = 2;
|
||||
export const DEHAZE_PATCH_STEP = 0.00625;
|
||||
|
||||
export const DEHAZE_SKSL = `
|
||||
uniform shader img;
|
||||
uniform shader dark;
|
||||
uniform float3 air;
|
||||
uniform float amount;
|
||||
uniform float floorT;
|
||||
uniform float stepPx;
|
||||
uniform float maxT;
|
||||
uniform float amount;
|
||||
vec4 main(vec2 xy) {
|
||||
vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0);
|
||||
vec3 a = max(air, vec3(0.05));
|
||||
float dark = 1.0;
|
||||
for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) {
|
||||
for (int i = -${DEHAZE_PATCH_TAPS}; i <= ${DEHAZE_PATCH_TAPS}; i++) {
|
||||
vec3 p = clamp(img.eval(xy + vec2(float(i), float(j)) * stepPx).rgb, 0.0, 1.0);
|
||||
dark = min(dark, min(min(p.r / a.r, p.g / a.g), p.b / a.b));
|
||||
}
|
||||
}
|
||||
float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0 + ${DEHAZE_MAX_OMEGA});
|
||||
float d = clamp(dark.eval(xy).r, 0.0, 1.0);
|
||||
float t = clamp(1.0 - amount * ${DEHAZE_MAX_OMEGA} * d, floorT, maxT);
|
||||
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function dehazeUniformArray(
|
||||
air: [number, number, number],
|
||||
amount: number,
|
||||
stepPx: number
|
||||
amount: number
|
||||
): number[] {
|
||||
'worklet';
|
||||
return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T, stepPx];
|
||||
}
|
||||
|
||||
// One tap of that patch in the pixels of a frame this wide.
|
||||
export function dehazePatchStep(width: number): number {
|
||||
'worklet';
|
||||
return Math.max(1, width * DEHAZE_PATCH_STEP);
|
||||
return [air[0], air[1], air[2], DEHAZE_FLOOR_T, 1 + DEHAZE_MAX_OMEGA, amount];
|
||||
}
|
||||
|
||||
export interface ToneUniforms {
|
||||
|
||||
Reference in New Issue
Block a user