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).
261 lines
6.2 KiB
TypeScript
261 lines
6.2 KiB
TypeScript
import { ColorAdjustments } from '../types';
|
|
|
|
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
|
|
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
|
|
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
|
|
// /set accessors are stateless so one table serves every render.
|
|
export interface ContinuousParamDef {
|
|
key: string;
|
|
label: string;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
defaultValue: number;
|
|
display: (v: number) => string;
|
|
get: (a: ColorAdjustments) => number;
|
|
set: (v: number) => Partial<ColorAdjustments>;
|
|
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
|
|
// cannot open its row, so the value stays at its default there.
|
|
pro?: boolean;
|
|
}
|
|
|
|
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
|
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
|
|
|
export const PARAM_DEFS: {
|
|
iq: ContinuousParamDef[];
|
|
wb: ContinuousParamDef[];
|
|
filters: ContinuousParamDef[];
|
|
} = {
|
|
iq: [
|
|
{
|
|
key: 'exposure',
|
|
label: 'EXPOSURE',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.exposure,
|
|
set: (v) => ({ exposure: v }),
|
|
},
|
|
{
|
|
key: 'ev',
|
|
label: 'EV',
|
|
min: -3,
|
|
max: 3,
|
|
step: 0.5,
|
|
defaultValue: 0,
|
|
display: oneEv,
|
|
get: (a) => a.exposureCompensation ?? 0,
|
|
set: (v) => ({ exposureCompensation: v }),
|
|
},
|
|
{
|
|
key: 'contrast',
|
|
label: 'CONTRAST',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.contrast,
|
|
set: (v) => ({ contrast: v }),
|
|
},
|
|
{
|
|
key: 'color',
|
|
label: 'COLOR',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.saturation,
|
|
set: (v) => ({ saturation: v }),
|
|
},
|
|
{
|
|
key: 'vibrance',
|
|
label: 'VIBRANCE',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.vibrance ?? 0,
|
|
set: (v) => ({ vibrance: v }),
|
|
},
|
|
{
|
|
key: 'highlight',
|
|
label: 'HIGHLIGHT',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.highlight ?? 0,
|
|
set: (v) => ({ highlight: v }),
|
|
},
|
|
{
|
|
key: 'shadow',
|
|
label: 'SHADOW',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.shadow ?? 0,
|
|
set: (v) => ({ shadow: v }),
|
|
},
|
|
],
|
|
wb: [
|
|
{
|
|
key: 'temperature',
|
|
label: 'COLOR TEMP',
|
|
min: 2500,
|
|
max: 10000,
|
|
step: 100,
|
|
defaultValue: 5500,
|
|
display: (v) => `${v}K`,
|
|
get: (a) => a.temperature,
|
|
set: (v) => ({ temperature: v }),
|
|
},
|
|
{
|
|
key: 'tint',
|
|
label: 'TINT',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.tint ?? 0,
|
|
set: (v) => ({ tint: v }),
|
|
},
|
|
{
|
|
// The two points live on WB, not on LIGHT: they shift each channel's own
|
|
// end of the ramp, which balances a cast at the toe and the shoulder
|
|
// rather than adding another tone knob. Same range as the CREATE form's
|
|
// rows, so a look round-trips.
|
|
key: 'whites',
|
|
label: 'WHITE',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.whites ?? 0,
|
|
set: (v) => ({ whites: v }),
|
|
},
|
|
{
|
|
key: 'blacks',
|
|
label: 'BLACK',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.blacks ?? 0,
|
|
set: (v) => ({ blacks: v }),
|
|
},
|
|
],
|
|
filters: [
|
|
{
|
|
key: 'denoise',
|
|
label: 'NOISE REDUCTION',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: String,
|
|
get: (a) => a.denoise,
|
|
set: (v) => ({ denoise: v }),
|
|
},
|
|
{
|
|
key: 'clarity',
|
|
label: 'CLARITY',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.clarity,
|
|
set: (v) => ({ clarity: v }),
|
|
},
|
|
{
|
|
// The haze the frame's own pixels carry (dark channel prior, toneShader's
|
|
// 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: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: sign,
|
|
get: (a) => a.dehaze ?? 0,
|
|
set: (v) => ({ dehaze: v }),
|
|
},
|
|
{
|
|
key: 'sharpening',
|
|
label: 'SHARPENING',
|
|
min: -10,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: String,
|
|
get: (a) => a.sharpening ?? 0,
|
|
set: (v) => ({ sharpening: v }),
|
|
},
|
|
{
|
|
key: 'grain',
|
|
label: 'MONOCHROME GRAIN',
|
|
min: 0,
|
|
max: 10,
|
|
// Half a step: the grain is a look, not a switch, and the AMOUNT chip in
|
|
// the GRAIN strip is what spends the half.
|
|
step: 0.5,
|
|
defaultValue: 0,
|
|
display: String,
|
|
get: (a) => a.grain,
|
|
set: (v) => ({ grain: v }),
|
|
},
|
|
{
|
|
// The SIZE knob: percent of the stock's own cell (grainShader.ts), so the
|
|
// same number means the same texture RELATIVE to the picture on both
|
|
// platforms and the readout beside it is the stock's, scaled.
|
|
key: 'grainSize',
|
|
label: 'GRAIN SIZE',
|
|
min: 50,
|
|
max: 200,
|
|
step: 5,
|
|
defaultValue: 100,
|
|
display: (v) => `${v}%`,
|
|
get: (a) => a.grainSize ?? 100,
|
|
set: (v) => ({ grainSize: v }),
|
|
},
|
|
{
|
|
key: 'hdf',
|
|
label: 'HDF EFFECT',
|
|
pro: true,
|
|
min: 0,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: String,
|
|
get: (a) => a.hdf ?? 0,
|
|
set: (v) => ({ hdf: v }),
|
|
},
|
|
{
|
|
key: 'vignette',
|
|
label: 'VIGNETTING',
|
|
min: 0,
|
|
max: 10,
|
|
step: 1,
|
|
defaultValue: 0,
|
|
display: String,
|
|
get: (a) => a.vignette ?? 0,
|
|
set: (v) => ({ vignette: v }),
|
|
},
|
|
],
|
|
};
|