web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages
This commit is contained in:
@@ -4,18 +4,16 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
// Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic
|
||||
// Range). SkSL runtime effect over a child image shader.
|
||||
//
|
||||
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the
|
||||
// four zones of the tone mapping doc, and no two of them own the same part of
|
||||
// the ramp:
|
||||
//
|
||||
// BLACKS peak at 0.00, gone by 0.25
|
||||
// SHADOWS peak at 0.25, gone by 0.50
|
||||
// HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00
|
||||
// WHITES peak at 1.00, gone by 0.75
|
||||
//
|
||||
// The four masks below are those tents — the doc's smoothsteps, one per quarter
|
||||
// of the ramp — and the 0.50 midpoint is in none of them: it is the one value
|
||||
// every one of the four leaves where it was.
|
||||
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. Each knob is the MOVE its
|
||||
// own Lightroom export made, measured off the nine reference files in the repo
|
||||
// root and stored as a polyline on the encoded luma (TONE_KNOBS below): WHITE
|
||||
// +100 takes everything above luma 0.50 to 1.0, BLACK -100 takes everything
|
||||
// below 0.21 to 0.0, HIGHLIGHT +100 is a +0.24 lift peaking around luma 0.18 and
|
||||
// dead by 0.72. Read the code values as `out = L + amt * curve(L)`, clamped, amt
|
||||
// = knob / 10. The four are NOT the four zones of the tone mapping doc any more,
|
||||
// a tent per quarter of the ramp with a quarter of travel each: that is the
|
||||
// layout the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi" report
|
||||
// came back on, WHITE ±100 a literal no-op among them.
|
||||
//
|
||||
// Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the
|
||||
// pixel lands on its new luma with the chroma it had, so the hue is untouched
|
||||
@@ -23,30 +21,23 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
// (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and
|
||||
// moves the hue the moment a channel clips; see the note on `k` below.
|
||||
//
|
||||
// These four masks are ADDED in the doc's own pseudo-shader, and measured that
|
||||
// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
|
||||
// unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp
|
||||
// should still be climbing. Read here instead as the four ANCHORS of one ramp —
|
||||
// knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each
|
||||
// held inside the knot before it, drawn straight in between — the same
|
||||
// measurement is monotone for every combination of the four at full deflection.
|
||||
// A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe
|
||||
// on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range
|
||||
// slider has in the program this layout copies, without the inversion. SHADOW is
|
||||
// the one knob held to half of that, because the knot it moves is the HEAD of
|
||||
// the quarter above it and not an end of the ramp: a band cannot be lifted at
|
||||
// its head and keep its slope at the same time, so the knob's travel is what has
|
||||
// to give — see the measured note on a1.
|
||||
// The four moves are applied ONE AFTER THE OTHER — BLACK, SHADOW, HIGHLIGHT,
|
||||
// WHITE — each clamped on the way to the next. The doc ADDED its four masks, and
|
||||
// measured that way the ramp inverts: BLACK +10 against SHADOW -10 falls to a
|
||||
// slope of -5 per unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band
|
||||
// where the ramp should still be climbing. A composition of non-decreasing maps
|
||||
// is non-decreasing by construction, so the ramp cannot fold however the four
|
||||
// sliders are set, and the knot tables are cut so that L + move is monotone on
|
||||
// every segment; highlight-knee-check.mjs sweeps both.
|
||||
//
|
||||
// WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
|
||||
// tab any more. The doc puts the two points on the ends of the SAME ramp as the
|
||||
// other two, so they are the ends this ramp is drawn through, and nothing else
|
||||
// in the shader reads them.
|
||||
// tab any more. They are the two ENDS of the same ramp the other two move, and
|
||||
// nothing else in the shader reads them.
|
||||
//
|
||||
// The ramp is drawn through the BASE LAYER, not through the pixel. The pixel's
|
||||
// own luma, run through a knot move, is a GLOBAL curve: every pixel at luma t
|
||||
// lands on the same o whatever is around it, so a knot lifted onto the band above
|
||||
// it (SHADOW's a1) is a band whose whole spread is squashed to the slope left
|
||||
// lands on the same o whatever is around it, so a knob that lifts a band onto
|
||||
// the one above it is a band whose whole spread is squashed to the slope left
|
||||
// over — at SHADOW +100 a quarter of the ramp carries half its contrast, and on a
|
||||
// real frame 0.50 of it survived: the grey sheet the knob was reported for. What
|
||||
// the eye is reading there is LOCAL contrast, and a curve drawn through the pixel
|
||||
@@ -82,29 +73,34 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
// to six code values in the quarter-tones even with all four knobs on zero — and
|
||||
// this pass still runs for the stock split tones and for DR alone, where nothing
|
||||
// the user set asked for a contrast move. Straight segments keep a neutral
|
||||
// setting the exact identity. The smoothsteps are the four ZONE masks above,
|
||||
// which is where the doc's shape belongs: they weight DR and the split tones,
|
||||
// and nothing but their peak positions has to be smooth.
|
||||
// setting the exact identity. What smoothsteps are left in toneRamp weight the
|
||||
// chroma boost, the noise floor and the highlight desaturation — gates on the
|
||||
// result, none of them on the ramp itself.
|
||||
//
|
||||
// The -HL highlight recovery that used to run in LINEAR light ahead of all this
|
||||
// is gone with it: HIGHLIGHT is one zone move now, in both directions. There is
|
||||
// is gone with it: HIGHLIGHT is one move now, in both directions. There is
|
||||
// still detail at the top to move — the develop's own knee compresses the two
|
||||
// stops the sensor holds above its white level into the frame (see
|
||||
// highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather
|
||||
// than onto a flat 1.0.
|
||||
// highlight-knee-check.mjs), so -WHITES pulls a true white 0.10 down under it
|
||||
// rather than onto a flat plateau.
|
||||
//
|
||||
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji
|
||||
// extended DR); 0/auto/DR100 = no extra curve. It moves the same four
|
||||
// knots the knobs move, so DR and a knob cannot fight over the middle and
|
||||
// DR cannot invert the ramp either — added as its own masked terms on top
|
||||
// it could, and did: see the fold noted on the knots below.
|
||||
// hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down.
|
||||
// sh - shadow -1..1: moves the 0.25 anchor, + up, - down.
|
||||
// wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that
|
||||
// is the move that clips a highlight toward white — and - pulls the head
|
||||
// of the ramp down under it.
|
||||
// bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the
|
||||
// floor (a faded black), - has nothing left to crush at 0.
|
||||
// extended DR); 0/auto/DR100 = no extra curve. It is added to the same
|
||||
// four amounts the sliders set (bl +0.12, sh +0.06, hl -0.09, wh -0.18),
|
||||
// so DR rides the measured curves with them and cannot invert the ramp
|
||||
// the way a set of masked terms of its own did.
|
||||
// hl - highlight -1..1: the measured HIGHLIGHT move — ±0.24 at full, on the
|
||||
// DEEP tones, and gone by luma 0.73. The slider's name and what it moves
|
||||
// are two different things, and the measurement is what is shipped.
|
||||
// sh - shadow -1..1: the measured SHADOW move. + is a broad lift that is
|
||||
// hardest at the top of the ramp (+0.17 by luma 0.71); - is a bell
|
||||
// deepest on the deep tones (-0.16 at 0.22) and dead by 0.73.
|
||||
// wh - white point -1..1: the measured WHITE move. + drives everything above
|
||||
// luma 0.50 to 1.0 — the move that clips a highlight toward white — and -
|
||||
// pulls a true white 0.10 down under it.
|
||||
// bl - black point -1..1: the measured BLACK move. + lifts the toe off the
|
||||
// floor (+0.115 by luma 0.19, so a faded black); - clips everything below
|
||||
// ~0.23 to 0.0 rather than only the very floor.
|
||||
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
@@ -139,63 +135,109 @@ const BAND_BLOCK = hslBandGaps()
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The four tonal-range knobs, as the four bands the ramp in TONE_MATH_SKSL owns.
|
||||
// Each band is compactly supported — a knob is exactly the identity outside its
|
||||
// own — which is what makes the four independent without a guard, and a guard is
|
||||
// what they used to share: one ceiling over two amplitudes, so a film stock that
|
||||
// already sits on SHADOW took the BLACK knob's travel down with it (0.663 of it
|
||||
// on the monochrome stock). See the head of the ramp for the whole argument.
|
||||
//
|
||||
// The edges are the doc's (§2.1 and §2.4): BLACKS dies on 0.18, the middle grey
|
||||
// the doc anchors the toe to, and WHITES starts on 0.80, its shoulder.
|
||||
// How far down the ramp the BLACK knob's Hunt-effect chroma mask reaches. The
|
||||
// knob's own curve is measured (see TONE_KNOBS); this is the floor the chroma
|
||||
// boost reads, and it is the doc's 0.18 — the middle grey it anchors the toe to.
|
||||
export const TONE_BLACK_EDGE = 0.18;
|
||||
export const TONE_WHITE_EDGE = 0.80;
|
||||
|
||||
// How much of its band a knob is worth at full deflection — 100 on the slider.
|
||||
// ONE TONAL KNOB, as the curve Lightroom actually draws. Measured, not derived.
|
||||
//
|
||||
// Sized by what the EYE can see, not by what the doc's own rates happen to be. At
|
||||
// the doc's numbers a full +100 was worth 0.060 of luma on BLACKS, 0.082 on
|
||||
// HIGHLIGHTS and 0.042 on WHITES (the arithmetic sweep on the scratchpad, and the
|
||||
// probe that ran the frame through the pass: BLACKS moved its mean by 0.0001 in
|
||||
// either direction), which is the "giá trị thay đổi quá nhỏ, không thể hiện trên
|
||||
// thị giác" report. Each rate below is now the largest its own move allows under
|
||||
// the contracts hold in highlight-knee-check: monotone at any amount on the
|
||||
// slider, both anchors fixed exactly, and a knob still exactly the identity off
|
||||
// its own band.
|
||||
// The four knobs were the doc's four compact windows (thay_doi_thong_so_giong_
|
||||
// lightroom.md §2), and they did not survive contact with the exports the app is
|
||||
// judged against. The doc puts every move inside a quarter of the ramp — BLACKS
|
||||
// dead past 0.18, SHADOWS down on the floor, HIGHLIGHTS up on the shoulder — and
|
||||
// at full deflection each was worth a few hundredths of luma on that quarter and
|
||||
// literally nothing outside it: the "sau khi chỉnh sửa 4 thông số này không đúng,
|
||||
// không thay đổi" report. The reference exports say something else entirely: on
|
||||
// Main.jpg, WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43),
|
||||
// BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), and HIGHLIGHT +100
|
||||
// is a +0.24 lift peaking around luma 0.18 that has died by 0.72. Whatever the
|
||||
// slider is called, the MOVE is what a knob has to reproduce, so the move is what
|
||||
// is stored here.
|
||||
//
|
||||
// BLACK's lift is the doc's square root at the rate monotonicity allows — 0.93,
|
||||
// the last rate whose own toe stays monotone on the check's grid (0.94 folds
|
||||
// 3.4e-6 of luma back on itself, 0.9387 is the exact ceiling, and the travel
|
||||
// between 0.93 and 0.94 is 1e-4, so the round number costs nothing). Its crush
|
||||
// cannot be the doc's multiplicative rate at all: that form
|
||||
// is bounded by its own window and is worth 0.019 of luma at full -100, five code
|
||||
// values on a black patch, invisible. The crush is the toe's own exponent instead
|
||||
// (see toneBlack), which is monotone for ANY rate, and worth 0.054.
|
||||
// Every curve below is the per-pixel luma the Lightroom export landed on MINUS
|
||||
// the base luma, binned by base luma and averaged, at ±100 — measured off the
|
||||
// nine reference files in the repo root (Main.jpg, Main-highlight±100.jpg,
|
||||
// Main-shadow±100.jpg, Main-white±100.jpg, Main-black±100.jpg), all genuine
|
||||
// Lightroom Classic 14.2 exports of the same frame. Read the code values as:
|
||||
// out = L + amt * curve(L), clamped, with amt = knob/10.
|
||||
//
|
||||
// HIGHLIGHT and WHITE are measured rather than inherited — the doc draws the
|
||||
// shapes and leaves the rates off, and its own sample (a raw pow(L, 1.5) with no
|
||||
// headroom term, and a WHITE worth 0.5) either blows the frame or does nothing on
|
||||
// one. Both head terms are only monotone while their own rate stays under a
|
||||
// ceiling, and the ceiling is a FOLD, not a slope: the move overshoots 1.0, the
|
||||
// clamp draws a plateau, and the ramp then falls back under it — measured under
|
||||
// the knee check as the drop from the running maximum, at 1/32768 of the ramp.
|
||||
// The head term is squared (see the note above toneCurve) because that is what
|
||||
// buys the room: the move dies out before it can reach the ceiling.
|
||||
// They are drawn STRAIGHT between the knots, which is the same choice the ramp
|
||||
// has always made (see the head of TONE_MATH_SKSL) and the right one here: the
|
||||
// measurement is a polyline, the eye cannot see a 2-code-value corner, and a
|
||||
// smoothstep through the knots would put an S-curve in the darks no export asked
|
||||
// for. Knots are placed where the curve bends, ~0.008 of luma is the worst error
|
||||
// anywhere against the measured grid, and the last knot is forced to 0.0 on 1.0
|
||||
// so a bright frame (this reference has nothing above luma 0.73) cannot inherit a
|
||||
// constant lift at the white end. WHITES -100 is the exception that proves it is
|
||||
// the shoulder anchor: its tail is -0.10, not 0.
|
||||
//
|
||||
// HIGHLIGHT at 14.0 stretches the quarter it owns to 1.44x of the 1.5x the check
|
||||
// allows and lands +0.113 on the band, 29 code values, with no fold in either
|
||||
// direction. WHITE at 3.0 is worth +0.080 — and it SATURATES the top of the ramp
|
||||
// from 0.92: 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, it is what the knob is for, and the head
|
||||
// still lands on 1.0 exactly. The one fold left in the ramp is not from these
|
||||
// rates: SHADOWS' crush is the doc's own multiplicative form and dips 0.018 of
|
||||
// luma (4.6 code values) over the 0.06..0.11 band at full -100, measured and
|
||||
// pinned in the knee check, whatever rate it is given.
|
||||
export const TONE_BLACK_LIFT = 0.93;
|
||||
export const TONE_BLACK_CRUSH = 8.0;
|
||||
export const TONE_HIGH_GAIN = 14.0;
|
||||
export const TONE_WHITE_GAIN = 3.0;
|
||||
// Only ±100 was sampled, so a knob at 40 is 0.4 of the same curve. That is
|
||||
// Lightroom's own read (its sliders are linear in the move) and it is the only
|
||||
// thing the nine files can support. Re-measure with the harness in the checks:
|
||||
// render the frame through TONE_SKSL at each knob's ±100 and diff.
|
||||
export type TONE_KNOB = 'bl' | 'sh' | 'hl' | 'wh';
|
||||
export interface ToneKnobCurve {
|
||||
p: number[]; // knot positions, encoded luma, ascending, p[0] = 0 and p[last] = 1
|
||||
v: number[]; // the move at +100, in luma, at each knot
|
||||
}
|
||||
export const TONE_KNOBS: Record<TONE_KNOB, { plus: ToneKnobCurve; minus: ToneKnobCurve }> = {
|
||||
// BLACKS. + lifts from the very floor (0.004 at black, so a true 0 does not
|
||||
// move) and peaks at 0.115 by luma 0.19. - is the clip: everything below 0.23
|
||||
// lands on 0 (the first segment's slope is -0.97 against the ramp's 1.0), then
|
||||
// the frame comes back up to meet the prints by 0.51.
|
||||
bl: {
|
||||
plus: { p: [0, 0.01, 0.025, 0.045, 0.185, 0.645, 0.71, 0.73, 1], v: [0.0042, 0.0131, 0.0649, 0.0876, 0.1155, 0.0442, 0.0301, 0.0138, 0] },
|
||||
minus: { p: [0, 0.23, 0.34, 0.37, 0.51, 0.73, 1], v: [-0.0017, -0.2245, -0.253, -0.2502, -0.1518, -0.0707, 0] },
|
||||
},
|
||||
// SHADOWS. The one knob that reads the way its name does: + lifts everywhere
|
||||
// and hardest at the top (+0.173 at 0.71) — a fill that never lets the bright
|
||||
// end go; - is a bell deepest on the deep tones (-0.161 at 0.22, dead by 0.73).
|
||||
sh: {
|
||||
plus: { p: [0, 0.055, 0.185, 0.42, 0.57, 0.705, 0.73, 1], v: [0.0004, 0.0306, 0.063, 0.0941, 0.16, 0.1734, 0.1534, 0] },
|
||||
minus: { p: [0, 0.155, 0.22, 0.29, 0.355, 0.395, 0.46, 0.73, 1], v: [-0.0017, -0.1324, -0.1613, -0.1365, -0.1313, -0.0926, -0.0879, 0.0001, 0] },
|
||||
},
|
||||
// HIGHLIGHTS. Not the shoulder at all: + is a broad lift of the DEEP tones
|
||||
// (+0.084 on a true black, +0.237 at 0.19) and - is a band that takes the
|
||||
// bright end down (-0.169 at 0.63) and dies on 1.0.
|
||||
hl: {
|
||||
plus: { p: [0, 0.005, 0.015, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0.084, 0.0955, 0.2133, 0.2372, 0.1632, 0.1424, 0.0987, 0.0828, 0.0001, 0] },
|
||||
minus: { p: [0, 0.22, 0.4, 0.5, 0.625, 0.695, 0.73, 1], v: [-0.0002, -0.0634, -0.0808, -0.1298, -0.1689, -0.1505, -0.0978, 0] },
|
||||
},
|
||||
// WHITES. + is the clipping point and nothing else: the move lands on 1.0
|
||||
// exactly from luma 0.50 (the tail is 1 - L, drawn by the knots 0.465 -> 0.73
|
||||
// -> 1.0) and lifts the deep tones to get there. - is the white anchor, moved
|
||||
// down: -0.10 on 1.0, tapering to nothing at the floor.
|
||||
wh: {
|
||||
plus: { p: [0, 0.01, 0.025, 0.035, 0.05, 0.175, 0.22, 0.35, 0.385, 0.465, 0.73, 1], v: [0.0023, 0.0169, 0.1217, 0.1634, 0.2036, 0.4103, 0.464, 0.5295, 0.5647, 0.534, 0.27, 0] },
|
||||
minus: { p: [0, 0.495, 0.7, 0.73, 1], v: [0, -0.0294, -0.0609, -0.0831, -0.1] },
|
||||
},
|
||||
};
|
||||
|
||||
// One knot table as a polyline: the first value, plus one hinge per segment.
|
||||
// Written as a SUM of clamps rather than a loop because SkSL indexes arrays by
|
||||
// constant only (see the mixer at the foot of TONE_SKSL), and generated from the
|
||||
// table so the source and the shader cannot drift.
|
||||
const knobChain = (c: ToneKnobCurve): string =>
|
||||
c.v
|
||||
.map((v, i) =>
|
||||
i === 0
|
||||
? v.toFixed(4)
|
||||
: ` + (${(v - c.v[i - 1]).toFixed(4)}) * clamp((L - ${c.p[i - 1].toFixed(3)}) / ${(c.p[i] - c.p[i - 1]).toFixed(3)}, 0.0, 1.0)`
|
||||
)
|
||||
.join('');
|
||||
|
||||
// One knob, both directions, as the shader reads it: the move is the knot table
|
||||
// on its own side of zero scaled by the amount, and the ramp is the move added
|
||||
// to L and clamped. The clamp is what keeps a -- knob's own clip a plateau
|
||||
// instead of a fold.
|
||||
const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const)
|
||||
.map(
|
||||
(k) => `float tone${k}(float L, float amt) {
|
||||
return clamp(L + (amt >= 0.0 ? amt * (${knobChain(TONE_KNOBS[k].plus)}) : -amt * (${knobChain(TONE_KNOBS[k].minus)})), 0.0, 1.0);
|
||||
}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
// How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's
|
||||
// own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width).
|
||||
@@ -235,119 +277,24 @@ export const TONE_BASE_SIGMA = 0.35;
|
||||
// has no such knob and hands in 0, which is what DR's terms are worth when it is
|
||||
// off on the frame too.
|
||||
export const TONE_MATH_SKSL = `
|
||||
// THE FOUR TONAL KNOBS, as thay_doi_thong_so_giong_lightroom.md §2 asks for them:
|
||||
// each one owns a COMPACT band of the ramp and is exactly ZERO outside it, and the
|
||||
// four moves are applied ONE AFTER THE OTHER instead of summed.
|
||||
//
|
||||
// The sum came with a guard, and the guard is where the knobs touched. Two bumps
|
||||
// of a half share a slope, so past a total of 1 the sum carries the curve
|
||||
// backwards — a fold — and the fix was one ceiling (holdLo / holdHi) shared by
|
||||
// the amplitudes of a half. A stock that already sits on SHADOW therefore took
|
||||
// BLACK's lift down with it: on the monochrome stock (sh = -0.24) BLACK -100 came
|
||||
// back with 0.663 of the travel the knob has on its own, which is the "kéo theo
|
||||
// sự thay đổi của thông số khác" report exactly. A composition of monotone maps
|
||||
// is monotone by construction, so it needs no guard, and the same knob then
|
||||
// measures 1.00 of its travel on every stock. (tone-curve, the arithmetic in
|
||||
// plain JS on the scratchpad, and the grid sweep in highlight-knee-check.)
|
||||
//
|
||||
// The windows are the doc's own, in the encoded luma this whole file works in —
|
||||
// the boundaries land where the doc's diagram draws them, BLACKS on the toe up to
|
||||
// the doc's 0.18, SHADOWS as a bell over the deep tones, HIGHLIGHTS as a bell over
|
||||
// the bright ones, WHITES on the shoulder from 0.80.
|
||||
float toneBlackW(float L) {
|
||||
float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0);
|
||||
// Squared once more than the doc's square for the same reason the sum's kernel
|
||||
// was (1-u^2)^2: the join with the identity at L = 0.18 is a slope, not an
|
||||
// angle, and an angle on a tone curve is a Mach band.
|
||||
return u * u * u;
|
||||
}
|
||||
// BLACKS, both directions, one window. §2.1's TOE SLOPE, and the two moves it
|
||||
// names are not the same kind of move:
|
||||
//
|
||||
// - the lift is the doc's own (L + amount * W * (sqrt(L) - L)), at the largest
|
||||
// rate monotonicity allows. The square root's slope at the floor is what makes
|
||||
// a bigger rate fold the toe back on itself — past 0.93 the ramp carries whole
|
||||
// hundredths of luma backwards and a gradient wears the fold as a band.
|
||||
// - the crush cannot be the doc's (L * (1 + amount * W * 0.85)) at any rate that
|
||||
// can be seen. The window is 1 only AT the floor and this form multiplies what
|
||||
// the window leaves, so its whole travel is 0.019 of luma at full -100 however
|
||||
// hard the rate bites (0.85 or 1.0, measured), 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 is the slope §2.1 asks for: x -> x^(1 + rate * W) is
|
||||
// monotone for every rate, so it deepens what the doc's form cannot reach —
|
||||
// 0.054 at full -100 — while x = 0 stays on 0 and x = 1 (the 0.18 edge) stays
|
||||
// on 1, which is the compact support and the two anchors both kept.
|
||||
float toneBlack(float L, float bl) {
|
||||
const float W = ${TONE_BLACK_EDGE};
|
||||
float q = toneBlackW(L);
|
||||
if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L);
|
||||
if (L >= W) return L;
|
||||
return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q);
|
||||
}
|
||||
float toneShadowW(float L) {
|
||||
return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L));
|
||||
}
|
||||
float toneHighW(float L) {
|
||||
return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L));
|
||||
}
|
||||
float toneWhiteW(float L) {
|
||||
float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0);
|
||||
return u * u;
|
||||
}
|
||||
// The ramp the pixel is rebuilt through. Read at the BASE, so the move is the
|
||||
// neighbourhood's and the pixel keeps its own difference from it (fix_shadow.md's
|
||||
// Base' + Detail): the ratio Base' * (Input / Base) was the first cut and it is
|
||||
// what broke BLACK — it scales the detail by the neighbourhood's gain, so the
|
||||
// knob that takes the base toward zero takes the picture's texture with it (the
|
||||
// blur the report named on a monochrome frame, where every channel IS the luma).
|
||||
//
|
||||
// Every move below is monotone for any amount in [-1, 1] and lands on L = 0 and
|
||||
// L = 1 without moving either, so the composition is monotone, the black point is
|
||||
// the black point, and the white point is the white point whatever the four
|
||||
// sliders say. The one deliberate departure from the doc's own arithmetic is the
|
||||
// (1.0 - L)^2 on HIGHLIGHTS: the doc's raw soft-knee ADDS pow(L - 0.5, 1.5) to L,
|
||||
// and above 0.94 that overshoots the cube — a measured 17% of the ramp driven to
|
||||
// flat white at +100 before the clamp, the "cháy vùng Whites" the doc's own §1
|
||||
// opens by calling a defect. The knee reads the headroom that is left instead, so
|
||||
// the move is zero at L = 1 by construction and the head rolls instead of
|
||||
// clipping — and squared rather than linear, because one power of the headroom
|
||||
// dies too slowly to keep the rate's own ceiling off the clamp: at a linear head
|
||||
// a rate worth 0.113 of luma already overshoots 1.0 near 0.94 and the ramp falls
|
||||
// back over the plateau by 0.018 (the fold the knee check measures), so the rate
|
||||
// that shape allows is the 0.082 this whole pass is replacing.
|
||||
// THE FOUR TONAL KNOBS, as the curves the reference exports measure (TONE_KNOBS
|
||||
// above says where the numbers come from and why they are not the doc's four
|
||||
// compact windows any more): each is a polyline on the encoded luma, each is
|
||||
// added to the luma it reads and clamped, and the four are applied ONE AFTER THE
|
||||
// OTHER instead of summed. A composition of non-decreasing maps is non-decreasing
|
||||
// by construction, so the ramp needs no guard whatever the four sliders say — and
|
||||
// the knot tables are cut so that L + move is monotone on every segment, which is
|
||||
// the property highlight-knee-check sweeps for.
|
||||
${KNOB_SKSL}
|
||||
// The four moves in the order the exports were read: the toe, then the bell that
|
||||
// reads the deep tones, then the one the slider calls HIGHLIGHT, then the white
|
||||
// anchor. Each clamps on the way to the next (tone* does it), so a knob that has
|
||||
// driven the luma to an end leaves the ones after it nothing to fold.
|
||||
float toneCurve(float L, float bl, float sh, float hl, float wh) {
|
||||
float q;
|
||||
// BLACKS, the toe — see toneBlack. Neither direction touches the anchor: the
|
||||
// lift is 0 at L = 0 (sqrt(0) - 0), the crush is 0^(anything) = 0, so the black
|
||||
// point is exactly where it was. The offset that lifted (0,0,0) to a grey
|
||||
// pedestal was a SUM adding its bump's height at L = 0, which is the doc's
|
||||
// "Milky / Foggy" failure. Past 0.18 the window is 0 and the move is exactly
|
||||
// the identity, slope and all.
|
||||
L = toneBlack(L, bl);
|
||||
L = clamp(L, 0.0, 1.0);
|
||||
// SHADOWS, a gain on the light with the floor still on 0: the multiplier is
|
||||
// 1 + amount * bell * (1 - L)^1.8, so the window's own bell already keeps it
|
||||
// off the midtones the doc names and the exponent keeps it off the white end.
|
||||
q = toneShadowW(L);
|
||||
L *= 1.0 + sh * q * pow(1.0 - L, 1.8);
|
||||
L = clamp(L, 0.0, 1.0);
|
||||
// HIGHLIGHTS, the doc's soft-knee against the headroom that is left — see the
|
||||
// note above the function. max(L - 0.5, 0) because the pow is undefined under
|
||||
// the knee and the window is only wide where it is not, and the headroom enters
|
||||
// SQUARED so the move has died out by the time the ramp reaches the clamp.
|
||||
q = toneHighW(L);
|
||||
L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L) * (1.0 - L);
|
||||
L = clamp(L, 0.0, 1.0);
|
||||
// WHITES, the doc's Hermite on the shoulder: (1 - L) * L is zero on both ends,
|
||||
// so the white point is fixed and the move is spent inside the top of the ramp.
|
||||
// At the rate the knob is worth, +100 takes the top of the ramp TO the ceiling
|
||||
// and the clamp flattens what is left of it: from 0.92 up a pixel is white.
|
||||
// That is §2.4's clipping point ("giới hạn cháy sáng") raised, and it is the
|
||||
// only move in this curve that reaches 1.0 short of the white point itself —
|
||||
// the head is still 1.0 on 1.0, and the ramp is still non-decreasing through
|
||||
// the flat part, so nothing folds.
|
||||
q = toneWhiteW(L);
|
||||
L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L;
|
||||
L = tonebl(L, bl);
|
||||
L = tonesh(L, sh);
|
||||
L = tonehl(L, hl);
|
||||
L = tonewh(L, wh);
|
||||
return clamp(L, 0.0, 1.0);
|
||||
}
|
||||
// Below t = 0.0004 there is no ratio worth the name: dividing by what is left of
|
||||
@@ -364,27 +311,56 @@ float toneCurve(float L, float bl, float sh, float hl, float wh) {
|
||||
// a channel reaches the ceiling, where the three clip by different amounts and
|
||||
// the hue goes with them.
|
||||
vec3 lightMove(vec3 c, float t, float o) {
|
||||
float k = t > 0.0004 ? o / t : 1.0;
|
||||
float safeT = max(t, 0.005);
|
||||
float k = t > 0.0004 ? min(o / safeT, 3.5) : 1.0;
|
||||
float hiC = max(max(c.r, c.g), c.b);
|
||||
float loC = min(min(c.r, c.g), c.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
if (hiC > t) k = min(k, (1.0 - o) / max(hiC - t, 0.0001));
|
||||
if (loC < t) k = min(k, o / max(t - loC, 0.0001));
|
||||
return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0);
|
||||
}
|
||||
vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) {
|
||||
// DR is the whole frame's DYNAMIC RANGE, and it rides the same four moves the
|
||||
// sliders do rather than adding masked terms of its own: a recovery that lifts
|
||||
// the toe and rolls the head is a BLACK and a WHITE, and after the windows a
|
||||
// tenth of a unit lands inside the band the knob owns instead of on the whole
|
||||
// frame. A mask has no such knob and hands in 0, which is what these terms are
|
||||
// worth when DR is off on the frame too.
|
||||
float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),
|
||||
// Edge-aware range falloff: when |t - base| is small (flat/fine texture), edgeW is 1.0.
|
||||
// When |t - base| is large (hard step edge, especially in high-DR RAW photos), edgeW falls smoothly to 0.0,
|
||||
// preventing base from leaking across high-contrast edges and eliminating halos.
|
||||
float diff = abs(t - base);
|
||||
float edgeW = 1.0 - smoothstep(0.08, 0.35, diff);
|
||||
float effBase = mix(t, base, edgeW);
|
||||
|
||||
// DR is the whole frame's DYNAMIC RANGE, and it is added to the same four moves
|
||||
// the sliders make rather than running masked terms of its own: a recovery that
|
||||
// lifts the toe and rolls the head IS a BLACK and a WHITE, so it rides their
|
||||
// measured curves (see the legend at the head of the file) instead of reaching
|
||||
// into the middle of the ramp. A mask has no such knob and hands in 0, which is
|
||||
// what these terms are worth when DR is off on the frame too.
|
||||
float o = toneCurve(effBase, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),
|
||||
clamp(hl - dr * 0.09, -1.0, 1.0), clamp(wh - dr * 0.18, -1.0, 1.0));
|
||||
// A caller with no neighbourhood of its own (a mask) hands in the pixel as its
|
||||
// base, and the difference is then exactly zero: the target is the ramp at t,
|
||||
// the global move, which is what the ratio gave it too.
|
||||
float target = o + (t - base);
|
||||
return lightMove(c, t, clamp(target, 0.0, 1.0));
|
||||
float target = clamp(o + (t - effBase), 0.0, 1.0);
|
||||
vec3 res = lightMove(c, t, target);
|
||||
|
||||
// Chroma Boost for lifted shadows & blacks (Hunt effect) to prevent washed-out darks
|
||||
float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase));
|
||||
float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);
|
||||
float blMask = uBl * uBl * uBl;
|
||||
float boost = 1.0 + (shMask * max(sh, 0.0) * 0.35) + (blMask * max(bl, 0.0) * 0.25);
|
||||
if (boost > 1.001) {
|
||||
res = mix(vec3(target), res, boost);
|
||||
}
|
||||
|
||||
// Desaturate noise floor in extreme darks (< 3% luminance) to prevent noise specks
|
||||
float noiseFloor = smoothstep(0.0, 0.03, target);
|
||||
res = mix(vec3(target), res, noiseFloor);
|
||||
|
||||
// Soft roll-off and chroma desaturation for highlights reduction (hl < 0) to avoid dirty gray clouds (100_lightroom.md §6)
|
||||
if (hl < 0.0 && target > 0.70) {
|
||||
float hlDesat = smoothstep(0.70, 0.98, target) * (-hl) * 0.35;
|
||||
res = mix(res, vec3(target), hlDesat);
|
||||
}
|
||||
|
||||
return clamp(res, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
|
||||
@@ -846,8 +822,13 @@ vec4 main(vec2 xy) {
|
||||
float lb = dot(b, CLARITY_LUM);
|
||||
float mid = clamp(4.0 * lb * (1.0 - lb), 0.0, 1.0);
|
||||
float nl = clamp(lb + (lo - lb) * (1.0 + strength * CLARITY_DETAIL_GAIN * mid), 0.0, 1.0);
|
||||
float scale = (nl + CLARITY_EPS) / (lo + CLARITY_EPS);
|
||||
return vec4(clamp(c.rgb * scale, 0.0, 1.0), c.a);
|
||||
float safeLo = max(lo, 0.005);
|
||||
float scale = (nl + CLARITY_EPS) / (safeLo + CLARITY_EPS);
|
||||
float clampedScale = min(scale, 1.0 + max(strength, 0.0) * 2.5);
|
||||
vec3 res = c.rgb * clampedScale;
|
||||
float noiseFloor = smoothstep(0.0, 0.03, nl);
|
||||
res = mix(vec3(nl), res, noiseFloor);
|
||||
return vec4(clamp(res, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -955,7 +936,14 @@ vec4 main(vec2 xy) {
|
||||
vec3 a = max(air, vec3(0.05));
|
||||
float d = clamp(dark.eval(xy).r, 0.0, 1.0);
|
||||
float t = clamp(1.0 - amount * ${DEHAZE_MAX_OMEGA} * d, floorT, maxT);
|
||||
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
|
||||
vec3 res = (c - a) / t + a;
|
||||
float origLum = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
float newLum = dot(clamp(res, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
|
||||
if (amount > 0.0 && origLum > 0.001) {
|
||||
float satAdj = clamp(1.0 - (amount * 0.15 * (1.0 - t)), 0.75, 1.25);
|
||||
res = mix(vec3(newLum), res, satAdj);
|
||||
}
|
||||
return vec4(clamp(res, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -994,45 +982,54 @@ export interface ToneUniforms {
|
||||
// here instead of in the 4x5 matrix, which cannot move one end of the curve
|
||||
// without also moving the other.
|
||||
//
|
||||
// The `sh` and `hl` here are written in the KNOB's unit, not the look's: a stock
|
||||
// that wants its toe on 0.18 asks the SHADOW knob for whatever the window is
|
||||
// worth there (1.0 at 0.25) times (1 - 0.25)^1.8, which is -0.47 on this ramp.
|
||||
// The numbers moved when the ramp did — the four knobs are windows now, not
|
||||
// summed bumps, and the doc's own rates (§2) replaced the quarter-anchor ones —
|
||||
// so the knots below are re-solved against the new curve rather than tuned by
|
||||
// eye: 0.18 / 0.22 / 0.17 on the toe and 0.7375 / 0.815 on the head, the values
|
||||
// the stocks were written against, land where they always did, which is what
|
||||
// highlight-knee-check pins.
|
||||
// The `sh`, `hl` and `wh` here are written in the KNOB's unit, not the look's: a
|
||||
// stock that wants its toe on 0.18 asks the SHADOW knob for a move of -0.07 at
|
||||
// luma 0.25, which is -0.47 on this ramp. They are solved against the MEASURED
|
||||
// tables rather than tuned by eye. The stocks were written against 0.18 / 0.22 /
|
||||
// 0.17 on the toe and 0.7375 / 0.815 on the head, and the ramp reads them at
|
||||
// 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151 — the numbers
|
||||
// highlight-knee-check pins. The Acros toe lands 0.0074 under its target because
|
||||
// the HIGHLIGHT move reaches down to luma 0.22, so its shoulder lands on the toe
|
||||
// as well.
|
||||
//
|
||||
// A LIFTED head is asked of WHITES and a ROLLED one of HIGHLIGHT, because that
|
||||
// is where the measured tables have anything left: the HIGHLIGHT curve is dead
|
||||
// by luma 0.73 on the + side (a lift has nothing up there to lift), while its -
|
||||
// side still reaches a true white.
|
||||
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
'classic-chrome': { sh: -0.47 },
|
||||
// Classic Vivid is Classic Chrome's sibling — the shadow crush belongs to the
|
||||
// stock, not to the matrix rows, so it comes along.
|
||||
'classic-vivid': { sh: -0.47 },
|
||||
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
|
||||
// Acros. A black-and-white stock IS its grey ramp, so this entry only shapes
|
||||
// the two ENDS and leaves the middle an identity: a smooth shadow toe that
|
||||
// reaches a true black (no film-base lift, no flat grey wash) and a highlight
|
||||
// shoulder that stops just short of white instead of clipping a cloud to
|
||||
// paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep
|
||||
// every step the matrix handed over — which is what 'deep black' costs in a
|
||||
// colour stock and does not have to cost here.
|
||||
// The values move the two end bands of the ramp: -0.20 puts the toe on 0.22
|
||||
// and -0.1143 rolls the head to 0.7375 (both solved against the window's own
|
||||
// height at 0.25 and 0.75, see the note above FILM_TONE — the head move is
|
||||
// -0.1143 rather than the -0.16 it used to be because the head term is squared
|
||||
// now, and a squared headroom is worth less at the 0.75 knot for the same rate).
|
||||
// Acros. A black-and-white stock IS its grey ramp, so this entry names only the
|
||||
// two ENDS: a shadow toe that reaches a true black (no film-base lift, no flat
|
||||
// grey wash) and a highlight shoulder that stops just short of white instead of
|
||||
// clipping a cloud to paper. Neither move is a compact window any more — they
|
||||
// are the measured curves, so the -0.1143 shoulder does reach down into the
|
||||
// midtones as well — and nothing else is asked of the stock: the strength the
|
||||
// greys need is its matrix slope (SIM_CONTRAST_BIAS in colorUtils), which is
|
||||
// what 'deep black' costs in a colour stock and does not have to cost here.
|
||||
// The values move the two end bands of the ramp: -0.20 asks the toe for 0.22
|
||||
// (it reads 0.2126, the -0.1143 shoulder reaching down onto it) and -0.1143
|
||||
// rolls the head to 0.7397, both read off the measured tables at their knots
|
||||
// (see the note above FILM_TONE).
|
||||
monochrome: { sh: -0.20, hl: -0.1143 },
|
||||
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
|
||||
// (-0.54 against Acros' -0.20, so 0.17 against 0.22) so the darks reach true
|
||||
// black, and a shoulder that LIFTS instead of rolling (-0.16 → +0.5929, the
|
||||
// head going to 0.815 — re-solved from +0.83 for the squared head, which is
|
||||
// worth less at the 0.75 knot for the same rate), which is the whites step of
|
||||
// the brief. The stretch
|
||||
// (0.17 against Acros' 0.22) so the darks reach true black, and a shoulder
|
||||
// that LIFTS instead of rolling (+0.26 of WHITES, the head going to 0.815
|
||||
// against Acros' 0.7397), which is the whites step of the brief. The stretch
|
||||
// between the two inner knots (0.25 and 0.75) is still the identity, so the
|
||||
// long smooth stretch of the greys survives — that is what keeps a hard push
|
||||
// off the posterised look, and the strength the stock needs on the greys is
|
||||
// its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here.
|
||||
'mono-high-contrast': { sh: -0.54, hl: 0.5929 },
|
||||
//
|
||||
// SHADOW is at FULL deflection here, and that is not a taste: the WHITES move
|
||||
// this stock asks for is measured to lift the darks as well (+0.074 at luma
|
||||
// 0.25 on this ramp — a WHITE anchor is the top of a curve, not a window), so
|
||||
// the toe only lands on its 0.17 if the crush under it is everything the knob
|
||||
// has. It reads 0.1735, which is the floor.
|
||||
'mono-high-contrast': { sh: -1.0, wh: 0.26 },
|
||||
};
|
||||
|
||||
// The base layer's neighbourhood is the caller's business, not this function's:
|
||||
@@ -1046,13 +1043,13 @@ export function getToneUniforms(
|
||||
): ToneUniforms {
|
||||
const drRaw = adj.dynamicRange ?? 'auto';
|
||||
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
|
||||
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
|
||||
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
|
||||
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? (adj as any).highlights ?? 0) / 10));
|
||||
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? (adj as any).shadows ?? 0) / 10));
|
||||
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
|
||||
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
|
||||
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
|
||||
// WHITE and BLACK ride this pass with the other two, each as the end knot of
|
||||
// the same ramp (see TONE_SKSL). They are no longer a white-balance move and
|
||||
// WHITE and BLACK ride this pass with the other two, as two of the four moves
|
||||
// on the same luma (see TONE_SKSL). They are no longer a white-balance move and
|
||||
// are read by nothing else in the pipeline.
|
||||
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
|
||||
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
|
||||
|
||||
Reference in New Issue
Block a user