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:
@@ -153,12 +153,13 @@ export interface GradientMask {
|
||||
exposure: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
// The spec's section 4 knobs, the frame-wide ones the mask can now restrict to
|
||||
// The spec's section 4 knobs, the frame-wide ones the mask can restrict to
|
||||
// its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE
|
||||
// and BLACK at the two ends of the histogram, and the two spatial moves —
|
||||
// CLARITY against the frame's own blurred reference and DEHAZE through the dark
|
||||
// channel. Same -10..+10 as the app's own rulers; absent on a mask stored
|
||||
// before they existed, and then the shader reads 0 for it.
|
||||
// channel, both signed. The same -10..+10 as the app's own rulers, and the same
|
||||
// moves; absent on a mask stored before they existed, and then the shader reads
|
||||
// 0 for it.
|
||||
highlights?: number;
|
||||
shadows?: number;
|
||||
whites?: number;
|
||||
@@ -182,8 +183,9 @@ export interface ColorAdjustments {
|
||||
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
|
||||
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
|
||||
// FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2):
|
||||
// 0 to 10, the Dark Channel Prior pass — the scattered light the haze puts in
|
||||
// front of the subject, taken back out. Absent on an older recipe = 0.
|
||||
// -10 to +10, the Dark Channel Prior pass — the scattered light the haze puts
|
||||
// in front of the subject, taken back out above zero and put back below it.
|
||||
// Absent on an older recipe = 0.
|
||||
dehaze?: number;
|
||||
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
|
||||
grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { GradientMask } from '../types';
|
||||
import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader';
|
||||
import {
|
||||
CLARITY_GAIN,
|
||||
DEHAZE_FLOOR_T,
|
||||
DEHAZE_MAX_OMEGA,
|
||||
DEHAZE_PATCH_STEP,
|
||||
DEHAZE_PATCH_TAPS,
|
||||
} from './toneShader';
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
|
||||
// adjustments that are a SHAPE rather than a whole-frame knob.
|
||||
@@ -27,7 +33,7 @@ import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader';
|
||||
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
|
||||
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
|
||||
// DEHAZE through the dark channel) that the caller hands in the blurred
|
||||
// reference and the atmospheric light for. Every one of them rides the same
|
||||
// reference for and the atmospheric light for. Every one of them rides the same
|
||||
// alpha the shape produces and lands through the same `mix`, so a mask at half
|
||||
// strength is half of the move.
|
||||
export const MASK_KIND = { linear: 0, radial: 1 } as const;
|
||||
@@ -101,11 +107,12 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
||||
}
|
||||
|
||||
// The two spatial knobs need the frame's own blurred reference (and DEHAZE the
|
||||
// atmospheric light) handed to the shader as a second child; the four tonal ones
|
||||
// need nothing. One question, asked in one place, so the renderer builds the
|
||||
// blur for exactly the masks that read it and the effect is cached for the same
|
||||
// answer.
|
||||
// The two spatial knobs read what the pass cannot build on its own: CLARITY the
|
||||
// frame's own blurred reference, handed in as a second child, and both of them
|
||||
// the frame's atmospheric light. One question, asked in one place, so the
|
||||
// renderer builds the blur for exactly the masks with a spatial knob on them and
|
||||
// the effect is cached for the same answer, and so the shader is built with the
|
||||
// spatial block (DEHAZE's own dark channel lives in it).
|
||||
export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
||||
return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0);
|
||||
}
|
||||
@@ -159,13 +166,14 @@ export function maskUniforms(
|
||||
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
|
||||
// half of the move rather than the whole of it.
|
||||
//
|
||||
// `blur` is the frame's bilateral reference (the same one CLARITY and DEHAZE use
|
||||
// `blur` is the frame's bilateral reference (the same one CLARITY uses
|
||||
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
|
||||
// shader was built without the second child, and then the two spatial knobs are
|
||||
// left out of the pass — the caller only builds that child for masks that ask
|
||||
// for them (masksHaveSpatial).
|
||||
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
|
||||
// read in the block below (the frame's own pixels are only in reach there).
|
||||
const adjustFn = (spatial: boolean) => `
|
||||
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
c = c * half(pow(2.0, a.x));
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
@@ -193,10 +201,15 @@ half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 b
|
||||
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
|
||||
// sharpening haze only makes it read as detail.
|
||||
if (fx.y != 0.0) {
|
||||
// DEHAZE (doc section 3.2): the dark channel of the patch is the haze.
|
||||
// DEHAZE (doc section 3.2), DEHAZE_SKSL's own formula on the mask's pixels:
|
||||
// dark is the patch's dark channel, the MINIMUM of min(r,g,b)/A over a
|
||||
// neighbourhood — read by the block below, which is where the frame's pixels
|
||||
// are — so a patch with anything genuinely dark in it is not haze and is left
|
||||
// alone (the patch average this used to read called every patch hazy). Signed
|
||||
// like the frame-wide knob: below zero t rises above 1 and the same
|
||||
// expression puts the scattered light back.
|
||||
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
|
||||
half dark = min(min(blur.r / aa.r, blur.g / aa.g), blur.b / aa.b);
|
||||
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(1.0));
|
||||
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(${1 + DEHAZE_MAX_OMEGA}));
|
||||
c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0));
|
||||
}
|
||||
// CLARITY (doc section 3.1): the pixel against its own blurred surroundings.
|
||||
@@ -204,9 +217,10 @@ ${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-
|
||||
// CLARITY -10 is a soften of the mask's own detail, which is the property the
|
||||
// knob is named for, and not the inverted unsharp it used to be (that only
|
||||
// sank the mask's whites and left its contrast where it was).
|
||||
// ponytail: the reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the
|
||||
// frame), not the frame-wide mist blur — the mask pass is not handed a second
|
||||
// blurred child. Add one when a negative CLARITY wants a wider soften.
|
||||
// The reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the frame) and
|
||||
// the frame-wide negative CLARITY mixes toward that same reference with the
|
||||
// same factor (CLARITY_BLEND_SKSL), so the two readings of the knob are one
|
||||
// move on two different areas.
|
||||
if (fx.x > 0.0) {
|
||||
c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0));
|
||||
} else if (fx.x < 0.0) {
|
||||
@@ -240,7 +254,23 @@ const maskBlock = (i: number, spatial: boolean) => `
|
||||
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
|
||||
}
|
||||
if (a > 0.0) {
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}]${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||
${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PATCH_STEP of
|
||||
// the frame's width — the same fraction the frame-wide pass reads, so the
|
||||
// mask's DEHAZE and the frame's are the same neighbourhood on any size of
|
||||
// render. Skipped when the mask has no DEHAZE, so a CLARITY-only mask
|
||||
// pays nothing for it.
|
||||
half dark = half(1.0);
|
||||
if (fx[${i}].y != 0.0) {
|
||||
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
|
||||
float stp = max(1.0, size.x * ${DEHAZE_PATCH_STEP});
|
||||
for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) {
|
||||
for (int k = -${DEHAZE_PATCH_TAPS}; k <= ${DEHAZE_PATCH_TAPS}; k++) {
|
||||
half3 p = clamp(img.eval(pos + float2(float(k), float(j)) * stp).rgb, half3(0.0), half3(1.0));
|
||||
dark = min(dark, min(min(p.r / aa.r, p.g / aa.g), p.b / aa.b));
|
||||
}
|
||||
}
|
||||
}` : ' half dark = half(0.0);'}
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -181,16 +181,17 @@ export const PARAM_DEFS: {
|
||||
},
|
||||
{
|
||||
// The haze the frame's own pixels carry (dark channel prior, toneShader's
|
||||
// DEHAZE_SKSL): no negative side, because there is no way to put scattered
|
||||
// light back that is honest about where it went. Its chip sits with the
|
||||
// other spatial knob it shares a blurred reference with.
|
||||
// DEHAZE_SKSL): positive takes the scattered light out, negative puts it
|
||||
// back — the same expression, with the transmission pushed above 1, which
|
||||
// is the knob a photo shot through mist wants from this side. Its chip sits
|
||||
// with the other spatial knob, CLARITY.
|
||||
key: 'dehaze',
|
||||
label: 'DEHAZE',
|
||||
min: 0,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
display: sign,
|
||||
get: (a) => a.dehaze ?? 0,
|
||||
set: (v) => ({ dehaze: v }),
|
||||
},
|
||||
|
||||
@@ -362,12 +362,17 @@ vec4 main(vec2 xy) {
|
||||
}
|
||||
`;
|
||||
|
||||
// CLARITY (positive), pass 3 of the doc's architecture: the frame against its
|
||||
// own blurred reference. `orig + (orig - B) * strength` — the local contrast the
|
||||
// doc asks for, clamped because a file cannot hold more than white. Runs on the
|
||||
// ENCODED pixels like every other grade here (only EXPOSURE_SKSL is linear
|
||||
// light, see colorUtils.exposureStops) — the doc's formula is written for linear
|
||||
// light, and moving the whole renderer there is a bigger change than this pass.
|
||||
// CLARITY, pass 3 of the doc's architecture: the frame against its own blurred
|
||||
// reference — `orig + (orig - B) * strength` above zero, the mix back toward B
|
||||
// below it. One reference, one pass, both directions of one knob: a NEGATIVE
|
||||
// CLARITY is the positive one's soften, not a second kind of blur picked for the
|
||||
// sign (that mist had another radius than the reference the positive side reads,
|
||||
// so -10 and +10 were two different neighbourhoods and a MASK's CLARITY could
|
||||
// not be the frame's own move). Clamped because a file cannot hold more than
|
||||
// white. Runs on the ENCODED pixels like every other grade here (only
|
||||
// EXPOSURE_SKSL is linear light, see colorUtils.exposureStops) — the doc's
|
||||
// formula is written for linear light, and moving the whole renderer there is a
|
||||
// bigger change than this pass.
|
||||
export const CLARITY_BLEND_SKSL = `
|
||||
uniform shader original;
|
||||
uniform shader blurred;
|
||||
@@ -375,48 +380,87 @@ uniform float strength;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = original.eval(xy);
|
||||
vec3 b = blurred.eval(xy).rgb;
|
||||
return vec4(clamp(c.rgb + (c.rgb - b) * strength, 0.0, 1.0), c.a);
|
||||
vec3 d = c.rgb - b;
|
||||
vec3 out_rgb = strength >= 0.0 ? c.rgb + d * strength : mix(c.rgb, b, clamp(-strength, 0.0, 1.0));
|
||||
return vec4(clamp(out_rgb, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Strength that keeps CLARITY 10 where the 3x3 kernel had it: that kernel was
|
||||
// `c*(1+4a) - a*sum` with a = 0.8, i.e. `c + 3.2*(c - mean4)`, so the same 3.2
|
||||
// lands the same local contrast through the wider bilateral reference.
|
||||
// lands the same local contrast through the wider bilateral reference. The gain
|
||||
// is for the POSITIVE side only: below zero the knob reads as its own fraction
|
||||
// of the reference (0..1, the same units MASK's CLARITY uses on it).
|
||||
export const CLARITY_GAIN = 3.2;
|
||||
|
||||
// DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2.
|
||||
// Haze is scattered light: it lifts the DARKEST channel of every patch, which is
|
||||
// the Dark Channel Prior. `haze` is the frame's own bilateral reference (the
|
||||
// patch average the blur already computes), `air` the atmospheric light the
|
||||
// caller estimated from the frame, and the transmission is what is left of the
|
||||
// dark channel once the haze is taken out of it, floored so a flat sky cannot
|
||||
// divide by zero. Then `J = (I - A)/t + A` removes the scattered light and the
|
||||
// division is also the contrast stretch the doc asks for afterwards.
|
||||
export const DEHAZE_SKSL = `
|
||||
uniform shader img;
|
||||
uniform shader haze;
|
||||
uniform float3 air;
|
||||
uniform float amount;
|
||||
uniform float floorT;
|
||||
vec4 main(vec2 xy) {
|
||||
vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0);
|
||||
vec3 b = clamp(haze.eval(xy).rgb, 0.0, 1.0);
|
||||
vec3 a = max(air, vec3(0.05));
|
||||
float dark = min(min(b.r / a.r, b.g / a.g), b.b / a.b);
|
||||
float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0);
|
||||
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// the Dark Channel Prior. The dark channel is the MINIMUM of min(r,g,b)/A over
|
||||
// the patch, and that minimum is the whole prior: a patch holding anything
|
||||
// genuinely dark — a shadow, a black frame line — reads 0 and is left alone,
|
||||
// while only a patch with no dark pixel in it at all is haze and gets corrected.
|
||||
// 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).
|
||||
//
|
||||
// `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).
|
||||
export const DEHAZE_FLOOR_T = 0.1;
|
||||
export const DEHAZE_MAX_OMEGA = 0.95;
|
||||
|
||||
export function dehazeUniformArray(air: [number, number, number], amount: number): number[] {
|
||||
// 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
|
||||
// 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.
|
||||
export const DEHAZE_PATCH_TAPS = 2;
|
||||
export const DEHAZE_PATCH_STEP = 0.00625;
|
||||
|
||||
export const DEHAZE_SKSL = `
|
||||
uniform shader img;
|
||||
uniform float3 air;
|
||||
uniform float amount;
|
||||
uniform float floorT;
|
||||
uniform float stepPx;
|
||||
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});
|
||||
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function dehazeUniformArray(
|
||||
air: [number, number, number],
|
||||
amount: number,
|
||||
stepPx: number
|
||||
): number[] {
|
||||
'worklet';
|
||||
return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T];
|
||||
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);
|
||||
}
|
||||
|
||||
export interface ToneUniforms {
|
||||
|
||||
@@ -2406,8 +2406,7 @@ export function Workspace() {
|
||||
// shoulder, not a gain on the whole pixel), and the two spatial ones — CLARITY
|
||||
// against the frame's blurred reference, DEHAZE through the dark channel. Each
|
||||
// one means inside the mask what the row of the same name means on the whole
|
||||
// frame. All six are the same -10..+10 row, so one factory builds them; DEHAZE
|
||||
// is the one knob with nothing to do on the negative side.
|
||||
// frame. All six are the same -10..+10 row, so one factory builds them.
|
||||
const maskKnobRow = (
|
||||
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze',
|
||||
label: string
|
||||
@@ -2417,7 +2416,7 @@ export function Workspace() {
|
||||
key: `mask-${key}`,
|
||||
label,
|
||||
value: v,
|
||||
min: key === 'dehaze' ? 0 : -10,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
display: v > 0 ? `+${v}` : String(v),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user