e98b4d9d5c9f5b1ffbddefadb5d983b54b6322e7
14 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e98b4d9d5c | web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages | ||
|
|
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.
|
||
|
|
e4f5407c19 |
web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity, and the sum carried a guard: the two bumps of a half shared a slope, so past a total of 1 the curve folded backwards, and the ceiling that stopped it was shared by the amplitudes of a half. A stock already sitting on SHADOW therefore took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at -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. thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead: each knob owns a compact band of the ramp and is exactly zero outside it, and the four moves are applied ONE AFTER ANOTHER rather than summed. A composition of monotone maps is monotone by construction, so it needs no guard, and each knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe — u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by 0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the grey pedestal that BLACK +100 left on a black was the sum adding its bump's height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones, WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two anchors — 0.00 and 1.00 — at every setting of the four knobs. One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above 0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read against the headroom that is left, the move is zero at L = 1 by construction and the head rolls instead of clipping. The windows are read in the sRGB-encoded luma this file already works in, not in linear light as the doc's section 1 sets out: the doc's own boundaries (0.18, 0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the whole renderer to the linear domain is a bigger change than this pass. The divergence is the one the scratchpad compat doc already warns the Android port about, and it is noted at the windows themselves. Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs` and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with every knob at full negative), both anchors hold for every combination, and a knob outside its band is the exact identity. |
||
|
|
bc550569ad |
web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.
The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.
CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.
The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.
And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.
Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
|
||
|
|
166a677590 |
light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72
The ramp was a0..a4 with the pixel's base interpolated straight between them. A segment meets its neighbour at an ANGLE, and the second derivative of a tone curve is what a gradient reads as a band — so a knob left a line across the mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift inside 0.25 and the stretch from 0.25 up came back identical to the untouched frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100 folded a seam into 0.747, between the highlights it pulled and the shadow it left under them. Measured on a 1024-step luma wedge through the exported pass, second difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from 0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and 1.146 is a sign flip, not a bend. So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel (1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves without a fold at its own top; level at u = 1, so a move lands on the identity and on its neighbour without an angle; C1 everywhere between. The two bumps of a half meet on 0.50 both on zero, which is the same fixed midpoint as before, and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of each on the heads beside them). A sum of bumps can overshoot where two steep sides land on one stretch — past a slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces, and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were not (unguarded min slope -0.0141). The guard reads each pair at its own steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792, TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up together past it. A single knob never reaches it (a full BLACK is 0.77, a full SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead of folding. Guarded, the sweep over five levels of all four knobs and DR reads a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005. After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and 0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK +100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at 0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and spends it over the quarter instead of into a line. Every knob on zero is the identity to the last bit — the pass also runs for the stock split tones and for DR alone — and 0.50 is still the one value no knob moves. Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic beside the shader (with the negative control: the unguarded pair still folds, the guard is what stops it). highlight-knee-check.mjs reads the kernel and the two slope constants off the source, pins the four amplitudes and the guard, and sweeps the travel of every knob as before. All ten checks that run without a browser pass, build clean. Skipped: the guard's ceiling is a constant, not a search for the widest travel that still clears a band we cannot see. Add when a frame shows a band the deflections in hand cannot explain. |
||
|
|
097e383b86 |
light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle
The base layer shipped last commit was nine POINT SAMPLES of the child, one ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with texture at the ring's own scale it is worse than one: the nine lumas differ, the sample pattern beats against the texture, and the base field comes out aliased. The gain the pixel then rides, o(base)/base, is a function of the base with a kink at every knot — so each alias of the base becomes an alias of the gain, and the reconstruction paints it straight back over the detail it was supposed to leave standing. Reported on a waterfall: grey patches loose on a mountainside, a smear across the face of the falls, and plateaus in cloud and sky (the pass runs whole-frame, so its base is read in the bright end too). Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field (9px high-pass, where a real base can hold none): 0.0627 for the ring, 0.0048 for a blur of the same radius. Thirteen times. The fix is not more taps — a 7x7 at a third of the step is still point samples, only smaller — it is to stop sampling: the caller blurs. So the base is now a second CHILD of the tone pass, the frame blurred by Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius, so the neighbourhood is the one the radius always named and the cuts are smooth instead of hard. The shader reads it ONCE per pixel and baseLuma loses its loop and its `bx`. That is also the cheaper pass: nine child evals walked the exposure/matrix chain nine times, one eval does not. A caller with no frame to blur hands in the image it is already shading as the base. The tap then lands exactly on t and the ramp is the global move again — a mask's degenerate bx of zero, spelled as a child that IS the source, which is what gradientMask.ts's own call (base == t) already meant. Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered for real, with the frame and the base held at two flat values a little apart: the pixel has to land on the value the ramp over THAT base predicts (171 for a 128 pixel over a 76 base), and a base equal to the pixel has to be the identity. highlight-knee-check now pins the one tap, the absence of `bx`, the blur, and the two-child wiring. 9/9 pass, build clean. Skipped: no guided filter proper — the base is a plain blur, so a strong edge is no longer held out of it the way the range weight held it (a dark rock a blur's width from the water reads a lifted base and keeps its own darkness). Add when a frame shows the halo; the doc asks for a plain blur and this is one. |
||
|
|
e61dccc784 |
light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a global curve: every pixel at luma t lands on the same o whatever surrounds it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed that whole quarter to the half slope left over — measured on a real frame, 0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey sheet the knob was reported for. A curve drawn through the pixel cannot see local contrast; that is what the eye was reading. So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x Detail split — and the pixel then rides the neighbourhood's gain o/base, keeping its own difference from it. Base moves, detail stays: the same lift on the same pixels, with the texture inside the region left standing. Full deflection keeps 0.78 of the band's spread now. The base is the same maths in every caller: bx = 0 (a mask, which has no neighbourhood) reads the pixel nine times and gets the old global move back, and with every knob on zero the ramp at base IS base, so the ratio is 1 and the pass is the identity however coarse the base is. Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation (0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either: the multiply is a uniform gain on encoded values, which is the same stop exposureMove already argues for. |
||
|
|
a56581c757 | light: the HIGHLIGHT knob rides half its anchor too, so a lift stops drawing a cloud to paper and a pull stops flattening the quarter under it — the top quarter came back at 0.26x of its own contrast, 0.62x now | ||
|
|
36fb93658b | light: the SHADOW knob rides half its anchor, so a lift stops drawing the band above it flat — a waterfall's spray came back at 0.10 of its own contrast, 0.55 now | ||
|
|
851563ed73 | light: the pixel rides o/t, not a held chroma — SHADOW and BLACK drained a dark red to 0.505 of its saturation, it is 0.742 now | ||
|
|
6d60d452e0 |
One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
A gradient mask had its own tone formula and its own exposure, and both disagreed with the frame's. Before any of this was tidied, the mask ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame moved the knots of a four-zone ramp, and the mask's exposure was a stop on sRGB-encoded values while the frame's was a stop on light. Two names, four moves, and the same slider meant different things depending on whether the pixels were inside the shape you drew — the divergence §3.3 of the Android port's compat doc warns about. The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the ramp the four knots build, the hue-preserving rebuild behind it, the transfer pair, and exposureMove. A mask calls the same functions the frame calls. The rebuild carries the chroma instead of re-scaling it. Lightness takes the curve and the colour rides the difference — the channel differences move by ONE shared scale k, pulled back only where the cube has no room left. The doc's ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact only while nothing clips: a channel past 1.0 stops being scaled with its neighbours and the hue goes with it. Measured on a flat patch frame, a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The scale is the chroma's own now: over 135 knob combinations on seven colours and five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°). EXPOSURE gets the same treatment, which is what the second half of the request was: the linear domain decides where the luma is going, and the pixel is rebuilt onto it through the same lightMove. The old pass multiplied the three channels in linear light, so +1 EV clipped them by three different amounts: measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the pixel's own encoded luma rather than pointed straight at the encoded linear target, which is what makes the knob exactly the identity at 0 EV — the transfer does not commute with the luma weights, so pointing at it brightened a colour by a couple of code values even at zero. A grey is the knob it always was: 128 through +1 EV is 176, the same number the linear per-channel multiply put there, so nothing a user has dialled in moves. Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels pushed through it, agreeing with the twin to a code value on a grey at +1 EV (176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the hue, the cube, the identity at 0 EV and the black pixel that has no light to move. mask-wb-check compiles the mask pass and pushes the same stops through it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on the ceiling at 255 and holds the hue within 3°. auto-tone-check, preview-match-check, white-level-check, raw-develop-check, half-check and roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from 0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV (Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the corner outside it does not move at all (220.2 -> 220.2), with no console error. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
c0aaa67361 |
Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
WB and FX were separate tabs whose only content was the same Lightroom-style develop column LIGHT already renders, so the rail carried three doors into one room. LIGHT now owns the whole column: white balance, tone, presence, the effects group (grain, dehaze, vignette) and the filters, in that order. The `wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs. Every continuous develop value that the UI exposes as a symmetric knob now runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set and the `deepen` helper, which widens a def's range and scales its accessors by ten, so the store keeps its -10..+10 internal scale and every stored look, DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a meaningful physical scale (temperature in kelvin, exposure in EV-ish units, grain, grain size, hdf, vignette, rotate, crop) keep their own units. Highlight recovery no longer bends hue. The old knee clamped the per-channel gain into 0.55..1.35, which is a per-channel operation and therefore a hue rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black instead of toward white. The shader now caps the pixel's distance from its undersaturated knee point while preserving the direction of that offset, i.e. it scales chroma and keeps hue, then clamps into gamut. Verified: - `npx tsc --noEmit` clean; `npm run build` clean. - `node scripts/highlight-knee-check.mjs` (pins the new shader source and twin-tests 135 knob combinations) ok. - Existing checks re-run green: `auto-tone-check`, `half-check`, `white-level-check`, `preview-match-check`, `library-check`, `scan-nav-check`, `roll-walk-check`. - Live browser pass: rail shows exactly the seven expected tabs with no WB or FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report `min=-100 max=100`; everything else keeps its own range. - Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within 0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the patches the ramp intentionally drives to white arrive fully neutral. Greys stay neutral (channel spread <= 2/255) at every knob setting. Skin patch before/after: 23.94 deg -> 0.00 deg. ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each), grain, grain size, hdf, vignette, rotate and crop deliberately keep their own scales rather than the blanket -100..+100; widen them the day a user asks for more range, not before. Hue assertions live in the flat-patch check because real-photo measurements pick up 2-4 deg of resample drift from the snapshot pipeline that has nothing to do with the shader. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
34f8601c91 |
studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become shapes of the tone curve, the way the mapping spec measures them. The column. The left rail used to hand LIGHT a row of chips and nothing else; the four tone knobs were chips that opened a curve, and the rest of the develop state lived in the chip row's own vocabulary. `DevelopPanels` renders the five sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS — as an accordion, all open, and every parameter the recipe holds has a row in it with a `data-key` off the parameter name: slider, value readout, double-click to default. Sliders are always visible, so a knob is one drag away instead of two taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber, green through pink) so the direction is on the control. The PRO looks the spec marks stay in the list but locked, tagged PRO, and tapping one asks for PRO — they are shown, not hidden, and not silently dropped. WHITE and BLACK move out of the WB group. They were the temperature group's extremes, which is what a white balance control does — the toe and the shoulder of the same ramp — but the spec puts them with the tone knobs and gives them the two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights` and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives. A recipe written before this commit still reads: the keys are unchanged. The four knobs. The first pass of the mapping spec added a mask per zone onto the channel: `luma += knob * mask * intensity`, and the shader followed it. It is the wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the four masks are not a partition of the ramp. They sum to one at the ends and to zero at the midpoint, so an adjustment in the middle of a zone is applied where the mask is half and not at all where the mask has fallen to nothing, and the ramp inverts: with every knob at its stop the curve folds over itself, slope −5 at t=0.87, and the twin catches it as a non-monotone ramp. So each knob moves a knot on the curve instead, which is the reading the spec's own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50, HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a quarter of the range at that knot, so the range is 0.75..1.00 at the top and 0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`) by clamping each against its neighbour. Between knots the curve is a straight line, and 0.5 is untouched by every knob, so a knob at zero is the identity exactly rather than nearly, and any combination of the four is monotone. The mask sum survives where the spec is right about it: it hints the split between the two dark zones and the two light ones, nothing else. The hue is kept the way the spec keeps it: work in luma, then scale the chroma offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a saturated red stays the same red and only its brightness moves. The ratio is clamped to 0.55..1.35 because at luma near zero the division is the whole highlight of the picture on one code value. Verified: - `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader — four masks, four anchors, the four `mix` lines — and asserts the constructions it replaced are gone, then drives a twin of the ramp in JS: the masks do not overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162 combinations of the four knobs, every combination is monotone, the amplitude at each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded case from the additive build is in the harness as a regression. - `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`, `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check` and `white-level-check` all pass against the bundle — the catalogue, the RAW path, auto tone and the white level are untouched by the panel move. - Driven in a real browser (`tone-live-check.mjs`, Chromium against `vite preview`, a P1010256.JPG in the source control, mean luma of the preview canvas read before and after each knob): neutral 184.25, WHITE +1 187.35, BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80. Every knob moves the picture the way the spec says it should and none of them moves it much — a stop of a knob is a quarter of a zone, not a level. - The same run asserts the built DOM: five panels, the 23 `data-key` rows, `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and `dev-shadow` together in TONE, the gradient classes on the two white balance sliders, and the chip slots the panel is handed. The only failed request is `/api/events`, which is the backend this preview does not run. ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a per-channel rolloff in linear light — is gone, deleted rather than ported. The additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends, and the per-channel rolloff is a second knob for the same picture. Bring it back as its own parameter if a frame ever clips badly enough to need it. Also dropped: DR used to ride along as two additive terms. That is where the fold at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the ramp past its own end. It shifts the knots now, which is what the film sims always meant by it, and the numbers in the sims were kept and their meaning recommented (classic-chrome toe 0.22, head 0.7375, etc.). Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
224ff0b935 |
web: open a RAW at the resolution of its sensor, not at the quarter of it
LibRaw's half-size demosaic was on. The Ricoh GR's own DNG (D0004128.DNG) developed to 3010x2012 while the JPEG written beside it in the same second is 6000x4000, and the Fuji's RAF to 3008x2007 against its own 6000x4000 -- the quarter was the flag, not the file. With `halfSize: false` the same develop returns 6020x4024 and it is the sensor's frame on every body tried: D0004128.DNG 6020x4024 IMGP6916.DNG 6028x4024 DSCF1701.RAF 6016x4014 _DSC0009.ARW 6024x4024 AFXT2721.RAF 6246x4170 Nikon-D850 NEF 6216x4136 _GDN0447.NEF 4284x2844 P1010607.RW2 3472x3472 5G4A9396.CR2 2880x1920 Nine files, 27s to 155s a develop on one core. Checked through the app itself, not only through LibRaw: photo-dims 6020x4024 on the DNG against 6000x4000 on the JPEG, both err none. The colour it opens with is now fitted per file to the preview the camera wrote into it (previewMatch.ts): a 3x3 over a block grid of the develop against the same grid of that preview, then one cubic a channel for what the 3x3 leaves. The offline per-body table this replaces (cameraMatch.ts) stopped matching the moment the path under it changed -- its rows no longer summed to 1 once the highlight knee landed ahead of it -- and a body with a row opened with a cast one without did not. The file's own preview does not age. The white level the gain carries is the frame's own plateau rather than `maximum` (sensorWhite.ts), a factor of 1.89 to 2.00 out; without it every frame opened a stop bright and a body that sat lower (X-Trans, 1.892) never reached the highlight desaturation at all. The desaturation gate reads the gain-lifted levels as well as the sensor's, which is the whole of the magenta: on a body whose cam_mul lifts red and blue (the GR's [2.64, 1, 1.73]) a blown sky crosses the white level at 0.38 of the raw range in red while green crosses at 1.0, so a gate read on the sensor's levels alone stayed shut across it. Measured in the app against the camera's own JPEG, mean dRGB over a 16x16 block grid: +1.20, -5.95, -6.11 with the sensor's clip alone, +0.21, +0.24, +0.47 with both, mean |dL| 21.5 against 10.3. The same grid on the Fuji comes back balanced (+4.7, +5.0, +3.6) and best aligned at offset 0,0. -HL is recovery and +HL is a lift, so they are different moves now: recovery is the doc's soft knee in linear light over the top half, which is the only term in the tone shader that is not a shift and the only one that can put detail back into a blown sky rather than merely darken it. The four checks pin the develop down where it can only run in a browser: raw-develop-check, preview-match-check, white-level-check, highlight-knee-check. |