Files
RecipesCam/docker/frontend/scripts/tone-base-check.mjs
T
3dtours acbb2bba4b web: the four tonal knobs are sized by what the eye can see, and the highlight head is squared so a bigger rate fits inside the cube
The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.

  - `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
    0.9387 the doc's own square root carries luma backwards inside its window
    (0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
    0.93 is the last round rate under it — monotone on the check's 1/32768
    grid, and the 1e-4 of travel between it and 0.94 is not a code value.
  - `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
    * 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
    its whole visible travel at full -100 is 0.019 of luma: five code values on
    a black patch, and a rate past 1 drives the product negative and clips the
    toe to a flat black instead of deepening it. The toe's own EXPONENT,
    `L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
    while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
    compact support kept.
  - `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
    `(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
    ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
    clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
    the knee check now measures as a drawdown from the running maximum. Squared,
    the move has died out by the time the ramp reaches the clamp: 14.0 is
    fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
    owns is allowed.
  - `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
    ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
    the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
    cháy sáng") and not a Hermite that cannot move the head; it is felt only
    above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.

`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.

The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.

Two folds are pinned rather than removed, both named in the check:

  - SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
    accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
    — where the window rises faster than the light, and it PREDATES this change.
    The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
    out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
    its crush.
  - WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
    of the knob and the ramp is non-decreasing through it, so it is not a fold.

`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.

Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
2026-10-02 10:54:54 +07:00

194 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The tone ramp is drawn through a BASE layer, and that base is now a blurred
// CHILD of the pass rather than a ring of point samples inside it — see
// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things
// can go wrong with that and neither is a crash: the shader stops compiling once
// it takes a second child (SkSL is only checked at runtime, and no other check
// here compiles TONE_SKSL as a whole), or the second child is wired to the wrong
// slot and the pass quietly reads the sharp image as its own base — which is
// exactly the identity the default (bx = 0) used to give, so a regression would
// look like nothing happening. So the pass is compiled and rendered for real:
// the frame and the base are held at two different flat values, and the pixel
// has to land on the somewhere-between value the ramp over THAT base predicts.
//
// node scripts/tone-base-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const transpile = (path) =>
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'tone-base-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const {
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
// The shader has to NAME a base child — a ring of taps would not need one.
assert.match(TONE_SKSL, /uniform shader base;/);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const SIZE = 8;
function flat(value) {
const surf = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(value, value, value));
surf.getCanvas().drawPaint(paint);
return surf.makeImageSnapshot();
}
const asChild = (image) =>
image.makeShaderOptions(
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
);
// SHADOW full, everything else off — the knob the mottle was reported on.
const adjustments = { shadow: 10 };
const uniforms = getToneUniforms(adjustments);
assert.ok(toneIsActive(uniforms), 'SHADOW +100 no longer turns the pass on');
function render(srcValue, baseValue) {
const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL);
assert.ok(effect, 'TONE_SKSL does not compile');
const src = flat(srcValue);
const base = flat(baseValue);
const shader = effect.makeShaderWithChildren(uniforms ? toneUniformArray(uniforms) : [], [
asChild(src),
asChild(base),
]);
assert.ok(shader, 'the pass did not take two children — the base is not wired in');
const out = CanvasKit.MakeSurface(SIZE, SIZE);
const paint = new CanvasKit.Paint();
paint.setShader(shader);
out.getCanvas().drawPaint(paint);
const pixels = out.makeImageSnapshot().readPixels(0, 0, {
width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888,
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB,
});
return pixels[0];
}
// The ramp, in the same arithmetic the shader runs: four COMPACT windows of the
// encoded luma — BLACK's toe dying on 0.18, SHADOW's bell over the deep tones,
// HIGHLIGHT against the headroom that is left, WHITE's Hermite on the shoulder —
// applied ONE AFTER THE OTHER and clamped on the way to the next, so the four
// moves compose instead of summing and the ramp needs no guard. SHADOW carries
// the whole knob at its own window, so SHADOW +100 reads toneCurve(b, 0, 1, 0, 0)
// (b and a1 are gone with the bumps; see the note at the head of toneRamp).
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
const toneBlackW = (L) => {
const u = clamp01(1 - L / TONE_BLACK_EDGE);
return u * u * u;
};
const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L));
const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L));
const toneWhiteW = (L) => {
const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE));
return u * u;
};
// toneBlack, as the shader emits it: the lift is the doc's own, the crush is the
// toe's EXPONENT (monotone for any rate, and the only form that moves a visible
// amount inside a window that is 1 only at the floor).
const toneBlack = (L, bl) => {
const q = toneBlackW(L);
if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L);
return L >= TONE_BLACK_EDGE
? L
: TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q);
};
// toneCurve, move for move, with the clamp the shader puts after each one.
// `afterBlack` is the L the SHADOW move reads.
function toneCurve(L, bl, sh, hl, wh) {
let q = toneBlackW(L);
L = toneBlack(L, bl);
L = clamp01(L);
const afterBlack = L;
q = toneShadowW(L);
L *= 1 + sh * q * (1 - L) ** 1.8;
L = clamp01(L);
q = toneHighW(L);
L += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) * (1 - L);
L = clamp01(L);
q = toneWhiteW(L);
L += TONE_WHITE_GAIN * wh * q * (1 - L) * L;
return { o: clamp01(L), afterBlack };
}
const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0);
const srcValue = 128; // the pixel: 0.501961 encoded
const baseValue = 76; // its neighbourhood, darker: 0.298039
const t = srcValue / 255;
const b = baseValue / 255;
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
// Base' + Detail, the reconstruction the shader emits (see the note in
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
// it is what took the texture out of the frame at BLACK -100.
const expected = Math.round(255 * clamp01(ramp(b, { sh: 1 }).o + (t - b)));
const got = render(srcValue, baseValue);
assert.ok(
Math.abs(got - expected) <= 2,
`a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp over that base predicts ${expected}`,
);
// And the two are NOT the same value: if the pass had quietly read the sharp
// image as its base the answer would be the pixel itself, unchanged.
assert.ok(
Math.abs(got - srcValue) > 8,
`the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`,
);
// With the base handed in as the sharp image the difference is exactly zero and
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
// the shadow window spans the middle, so the same knobs move a mid-grey.
const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
const selfBase = render(srcValue, srcValue);
assert.ok(
Math.abs(selfBase - globalMove) <= 2,
`a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`,
);
assert.ok(
Math.abs(selfBase - srcValue) > 2,
`the pass returned the pixel (${selfBase}) — the base is the sharp image again`,
);
// There is no guard any more, and nothing to hold under one: the four moves are
// each monotone for any amount in [-1, 1] and clamped on the way to the next, so
// BLACK and SHADOW both at +100 compose into a ramp that still rises — measured
// on a 1/400 grid, where the two used to sum past a slope of 1 and carry the
// curve backwards — and BLACK's own travel no longer depends on the stock's
// SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever
// SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report.
const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean);
assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
assert.equal(
ramp(0.05, { bl: -1 }).afterBlack,
ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack,
'SHADOW drags BLACK’s own travel with it again',
);
console.log(
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
);