9164bf3228
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).
305 lines
16 KiB
TypeScript
305 lines
16 KiB
TypeScript
import type { GradientMask } from '../types';
|
|
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.
|
|
//
|
|
// HEAL and MOSAIC repair and hide; these two grade. A shape is dragged on the
|
|
// photo, and inside it (through the alpha the shape's own maths produces) the
|
|
// three knobs below move the pixels and nothing outside them does. A linear
|
|
// gradient is the shape whose alpha is a straight ramp across the drag; a radial
|
|
// one is an ellipse whose alpha holds full until its feather and dies at its
|
|
// rim. Two shapes, one shader: the kind is a number in the mask's own uniform
|
|
// and the block that reads it branches, because that is the whole difference
|
|
// between them.
|
|
//
|
|
// The maths is the GLSL spec these were asked for (its scratchpad md, sections
|
|
// 1 and 2 for the masks and the blend shader for the colour): the linear one
|
|
// projects the pixel onto the drag with a dot product and smoothsteps the
|
|
// result; the radial one moves the pixel into the ellipse's own frame, rotates
|
|
// it back by -angle, divides by the semi-axes and smoothsteps inward from the
|
|
// rim. Written in SkSL because that is what this renderer is (Canvaskit, see
|
|
// skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in
|
|
// the order the user drew them, each reading what the one before it left.
|
|
//
|
|
// The spec's section 4 — "the system needs to restrict all the above effects to
|
|
// operate only within the mask's area" — is the rest of the block below: the
|
|
// 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 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;
|
|
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
|
|
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
|
|
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
|
|
// into the spec's -1..+1 on the way to the shader.
|
|
export const MASK_EXPOSURE_MAX = 5;
|
|
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
|
|
// enough to be a gradient mask rather than a cut-out, and every pixel of the
|
|
// ellipse is still on the shape's own side of the rim.
|
|
export const MASK_DEFAULT_FEATHER = 0.5;
|
|
// The smallest drag that is a shape rather than a press: a hundredth of the
|
|
// width. Below this the mask is dropped instead of laid down — the same floor
|
|
// readMasks refuses a stored one at.
|
|
export const MASK_MIN = 0.01;
|
|
|
|
const num = (v: unknown, fallback: number) => {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
};
|
|
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
|
|
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
|
|
|
|
// The stored masks, made readable: numbers, inside the frame, one kind — the
|
|
// same guard HEAL's and MOSAIC's lists get, so a hand-written or older file
|
|
// cannot produce a shape the overlay and the renderer disagree about. A mask
|
|
// with no shape (a linear drag of no length, an ellipse of no radius) is
|
|
// dropped: it would paint nothing and could never be taken hold of on the photo.
|
|
// What readMasks hands back: the stored mask with every knob resolved to a
|
|
// number. The two spatial ones and the four tonal ones are optional in the
|
|
// stored type (a mask saved before they existed has none), so the reader's
|
|
// return type is the one that says they are there.
|
|
export type ReadMask = GradientMask & {
|
|
highlights: number;
|
|
shadows: number;
|
|
whites: number;
|
|
blacks: number;
|
|
clarity: number;
|
|
dehaze: number;
|
|
};
|
|
|
|
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
|
if (!Array.isArray(masks)) return [];
|
|
return masks
|
|
.filter((m) => m?.kind === 'linear' || m?.kind === 'radial')
|
|
.map((m) => ({
|
|
kind: m.kind,
|
|
x: clamp01(num(m.x, 0.5)),
|
|
y: clamp01(num(m.y, 0.5)),
|
|
ex: clamp01(num(m.ex, 0.5)),
|
|
ey: clamp01(num(m.ey, 0.5)),
|
|
rx: Math.max(0, num(m.rx, 0)),
|
|
ry: Math.max(0, num(m.ry, 0)),
|
|
angle: num(m.angle, 0),
|
|
feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)),
|
|
exposure: clampEV(num(m.exposure, 0)),
|
|
contrast: clampA(num(m.contrast, 0)),
|
|
saturation: clampA(num(m.saturation, 0)),
|
|
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
|
|
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
|
|
// a mask stored before they existed.
|
|
highlights: clampA(num(m.highlights, 0)),
|
|
shadows: clampA(num(m.shadows, 0)),
|
|
whites: clampA(num(m.whites, 0)),
|
|
blacks: clampA(num(m.blacks, 0)),
|
|
clarity: clampA(num(m.clarity, 0)),
|
|
dehaze: clampA(num(m.dehaze, 0)),
|
|
}))
|
|
.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 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);
|
|
}
|
|
|
|
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
|
|
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
|
|
// then the frame the fractions are of — and, when the caller has one, the
|
|
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
|
|
// buffer is one upload per render, the same shape healUniforms and
|
|
// mosaicUniforms use. Its length is a function of the list, not a fixed
|
|
// capacity, because the shader carries exactly the masks the recipe holds.
|
|
export function maskUniforms(
|
|
masks: GradientMask[],
|
|
width: number,
|
|
height: number,
|
|
air?: [number, number, number] | null
|
|
): Float32Array {
|
|
const list = readMasks(masks);
|
|
const n = list.length;
|
|
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
|
|
for (let i = 0; i < n; i++) {
|
|
const m = list[i];
|
|
u.set([m.x, m.y, m.ex, m.ey], i * 4);
|
|
u.set([m.rx, m.ry, m.angle, m.feather], (n + i) * 4);
|
|
// The knobs are handed over in the renderer's own units: EV for exposure,
|
|
// -1..1 for the other two, which is what the spec's blend shader reads.
|
|
u.set(
|
|
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
|
|
(2 * n + i) * 4
|
|
);
|
|
// The tone soft masks and the two ends, then the spatial pair.
|
|
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
|
|
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
|
|
}
|
|
u.set([width, height, 0, 0], 5 * n * 4);
|
|
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
|
|
return u;
|
|
}
|
|
|
|
// The colour inside a mask, in the spec's own order and, for the knobs the app
|
|
// also has frame-wide, in the app's own formulas: exposure first (a power of
|
|
// two, so a stop is a stop), then contrast about the middle, then saturation as
|
|
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
|
|
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
|
|
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
|
|
// makes, and the two spatial ones against the blurred reference the caller hands
|
|
// in. Every one of them means inside the mask what it means on the whole frame
|
|
// (toneShader.ts is the reference the four tonal ones are written from), because
|
|
// the same name on the same knob should not be two different moves. The result
|
|
// is clamped to the range a file can hold — the spec's own guard, and it is
|
|
// `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 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). `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, 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));
|
|
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
|
|
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
|
|
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
|
|
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
|
|
// cannot drag a blown white to grey, and the colour difference rides along at
|
|
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
|
|
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
|
|
// own distance from the end it owns: the toe and the shoulder move, the
|
|
// midtones do not, and a white that is lowered stays white.
|
|
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
|
|
// one more type the shader does not have to guess at.
|
|
float lf = clamp(float(l), 0.0, 1.0);
|
|
float mh = smoothstep(0.50, 1.00, lf);
|
|
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
|
|
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
|
|
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
|
|
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
|
|
half3 dk = half3(1.0) - c;
|
|
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
|
|
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
|
c = mix(half3(nl), c, half(1.0 + a.z));
|
|
${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), 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 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.
|
|
// Positive sharpens. Negative SOFTENS toward that same reference — a masked
|
|
// 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).
|
|
// 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) {
|
|
c = mix(c, blur, half(clamp(-fx.x, 0.0, 1.0)));
|
|
}
|
|
` : ''} return clamp(c, half3(0.0), half3(1.0));
|
|
}
|
|
`;
|
|
|
|
// One unrolled block per mask, for the reason heal.ts unrolls its own: SkSL
|
|
// indexes a uniform array by constant only, so the shader is built for the count
|
|
// it is handed rather than for a capacity, and the renderer caches them by count
|
|
// (exportEngine's maskEffectFor). A linear mask's alpha is the spec's dot
|
|
// product, and its zero-length guard is the filter in readMasks rather than a
|
|
// branch here: a mask with no length is not a mask. A radial mask's alpha is
|
|
// 1 inside the feather and 0 at the rim.
|
|
const maskBlock = (i: number, spatial: boolean) => `
|
|
{
|
|
float a = 0.0;
|
|
if (adj[${i}].w < 0.5) {
|
|
float2 d = (rects[${i}].zw - rects[${i}].xy) * size.xy;
|
|
float len2 = dot(d, d);
|
|
float t = dot(pos - rects[${i}].xy * size.xy, d) / len2;
|
|
a = smoothstep(0.0, 1.0, clamp(t, 0.0, 1.0));
|
|
} else {
|
|
float2 st = (pos - rects[${i}].xy * size.xy) / size.x;
|
|
float ca = cos(-rads[${i}].z);
|
|
float sa = sin(-rads[${i}].z);
|
|
float2 r = float2(st.x * ca - st.y * sa, st.x * sa + st.y * ca);
|
|
float d = length(r / max(rads[${i}].xy, float2(1e-5)));
|
|
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
|
|
}
|
|
if (a > 0.0) {
|
|
${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));
|
|
}
|
|
}
|
|
`;
|
|
|
|
// The pass. It reads the pixels the pipeline has already built — the grade, the
|
|
// grain, the vignette — and writes the local adjustments back over them, one
|
|
// mask at a time, so a mask over another mask reads the first one's result
|
|
// exactly as a stack of local adjustments does. It runs BEFORE HEAL on purpose:
|
|
// a repair laid inside a mask then borrows pixels that already carry the mask's
|
|
// light, which is what makes it match its surroundings, and a MOSAIC over a mask
|
|
// hides what the mask left. The frame the masks are of comes in as `size`, the
|
|
// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY
|
|
// and DEHAZE read: the frame's own blurred reference as a second child, whose
|
|
// image is in the same coordinates the pass runs in, and the atmospheric light.
|
|
export function gradientMaskSkSL(count: number, spatial = false): string {
|
|
return `
|
|
uniform shader img;
|
|
uniform float4 rects[${count}];
|
|
uniform float4 rads[${count}];
|
|
uniform float4 adj[${count}];
|
|
uniform float4 tone[${count}];
|
|
uniform float4 fx[${count}];
|
|
uniform float4 size;
|
|
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
|
${adjustFn(spatial)}
|
|
half4 main(float2 pos) {
|
|
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
|
|
return c;
|
|
}
|
|
`;
|
|
}
|