Files
RecipesCam/docker/frontend/shared/utils/paramDefs.ts
T
3dtours 9164bf3228 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).
2026-09-26 20:16:13 +07:00

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 }),
},
],
};