d7ec00c08ebbfc86d09afd81a56dc792331022c1
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80416a51b8 | fix(tone): correct tone knobs curves and eliminate spatial halo bleed around dark silhouettes | ||
|
|
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. |