diff --git a/docker/frontend/scripts/auto-tone-check.mjs b/docker/frontend/scripts/auto-tone-check.mjs new file mode 100644 index 0000000..7254b93 --- /dev/null +++ b/docker/frontend/scripts/auto-tone-check.mjs @@ -0,0 +1,119 @@ +// AUTO's three readings are pure arithmetic on the binned ramp, so they can be +// checked here rather than in a browser: hand the functions a histogram whose +// answer is known by construction and read it back. The module is TypeScript +// (and a .tsx), so it is transpiled on the fly out of the installed compiler and +// the React/i18n imports — which only the overlay component needs — are dropped +// before it is evaluated (same convention as preview-match-check.mjs). +// +// node scripts/auto-tone-check.mjs +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ts from 'typescript'; + +const transpile = (path) => + ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + }, + }).outputText; + +const dir = mkdtempSync(join(tmpdir(), 'auto-check-')); +// colorUtils only imports types, so the compiler drops that line on its own. +writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts')); +writeFileSync( + join(dir, 'histogram.mjs'), + transpile('../src/ui/Histogram.tsx') + .replace( + /^import .*from ['"]\.\.\/\.\.\/shared\/utils\/colorUtils['"];$/m, + 'import { kelvinToRGB } from "./colorUtils.mjs";', + ) + // The overlay's own imports: the component is never rendered here, so React + // and the i18n provider are dead weight node cannot resolve. + .replace(/^import .*from ['"]react[^'"]*['"];$/gm, '') + .replace(/^import .*from ['"]\.\.\/i18n\/I18nProvider['"];$/m, ''), +); +const { autoExposureStops, autoTone, autoWhiteBalance, AUTO_EV_MAX } = await import( + pathToFileURL(join(dir, 'histogram.mjs')).href +); + +const BINS = 256; +// One pixel per bin: the whole 0..255 ramp, evenly, so every quantile of it is +// known in closed form (quantile q of a flat ramp is q). +const flat = () => new Array(BINS).fill(1); +// Every pixel at one level: a frame with a single tone in it, and nothing else. +const spike = (v, n = 1000) => { + const bins = new Array(BINS).fill(0); + bins[v] = n; + return bins; +}; + +// --- Exposure: the mean luma against the 0.48 a grey card lands on, in stops. +const stops = (lum) => autoExposureStops(lum); +assert.equal(stops(spike(255)), Math.log2(0.48 / 1), 'white is pulled back by log2(0.48)'); +assert.equal(stops(spike(0)), AUTO_EV_MAX, "black is pushed to the knob own ceiling"); +// Half a stop down from the target is 0.48 / 2^0.5 = 0.339 -> bin 87. +assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked for +0.5 stop'); +// The flat ramp averages exactly mid-grey, which is 0.0589 stop under target. +assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey'); +assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity'); + +// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5. +assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights'); +assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows'); +// The flat ramp is the interesting one: its own top 1% really is clipped and its +// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992 +// (5 units), p01 = 2/255 = 0.008 (3 units). +assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play'); +// 1% of the frame at white over an otherwise mid ramp: the spec's small blown +// window, and the shadow side stays put because the ramp's floor is not black. +const blown = flat(); +blown[255] += 3; +assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top'); +// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts +// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded. +const shoulder = spike(100, 989); +shoulder[243] = 11; +assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way'); +// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves — +// but only 3 units, because the tail is barely under the 0.02 threshold. +const crushed = spike(128, 980); +crushed[2] = 20; +assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way'); +// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid +// ramp, so the threshold is not crossed and the knob stays put. +const speck = spike(128, 996); +speck[0] = 4; +assert.deepEqual(autoTone(speck), { highlight: 0, shadow: 0 }, 'a speck of black is not a crushed frame'); +assert.deepEqual(autoTone(spike(0, 0)), { highlight: 0, shadow: 0 }, 'an empty histogram is left alone'); + +// --- White Balance: gray-world with G as the anchor, matched against the +// engine's own kelvinToRGB, so a neutral frame must come back on 5500K / 0. +assert.deepEqual(autoWhiteBalance(flat(), flat(), flat()), { temperature: 5500, tint: 0 }, 'neutral stays put'); +assert.deepEqual( + autoWhiteBalance(spike(0), spike(0), spike(0)), + { temperature: 5500, tint: 0 }, + 'a frame with no light in it has no cast to read', +); +// A mildly warm frame (R above G above B) is a frame lit too warmly, so the +// ruler is asked to cool it — below 5500K. The cast sits on the planckian +// locus, so no green/magenta trim is needed to reach it. +const warm = autoWhiteBalance(spike(160), spike(150), spike(140)); +assert.ok(warm.temperature < 5500, `a warm frame cools: got ${warm.temperature}K`); +assert.equal(warm.tint, 0, `and stays on the locus: got tint ${warm.tint}`); +// A cast far warmer than the Kelvin ruler's travel cannot be cancelled, and the +// two axes overlap once the temperature pins at its end — so this only asks for +// the direction and for the trim staying small. +const veryWarm = autoWhiteBalance(spike(180), spike(150), spike(120)); +assert.ok(veryWarm.temperature < 3000, `a harder warm cast cools further: got ${veryWarm.temperature}K`); +assert.ok(Math.abs(veryWarm.tint) <= 5, `without a wild trim: got ${veryWarm.tint}`); +// A frame that is only green is the one the TINT axis exists for: green is +// trimmed toward magenta (positive), which no Kelvin move can do. +const green = autoWhiteBalance(spike(120), spike(160), spike(120)); +assert.ok(green.tint > 5, `a green frame is trimmed toward magenta: got ${green.tint}`); + +console.log('auto-tone-check ok'); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index e4cfbf4..9182cf5 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -47,7 +47,7 @@ import { curveIsActive } from '../shared/utils/toneCurve'; import { HEAL_DEFAULT_R } from '../shared/utils/heal'; import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic'; import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask'; -import { readHistogram, autoExposureStops } from './ui/Histogram'; +import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -2587,20 +2587,27 @@ export function Workspace() { // for the mono stock and swaps right back. Adjustments are never touched, so // a knob moved while the switch is on survives the trip back. const monoOn = isMonochromeBase(recipe.baseFilter); - // AUTO (LIGHT tab): the one chip that WRITES a value instead of naming one. It - // measures the LOADED PHOTO — not the preview — so the number does not depend - // on the knobs already in play: pressing it twice lands on the same EV, the way - // Lightroom's Auto does. readHistogram gives the average luminance, - // autoExposureStops turns it into stops, and the EV knob takes it, rounded to - // the 0.1 that knob's own readout prints. - const autoExposure = async () => { + // AUTO (LIGHT tab): the one chip that WRITES values instead of naming one. It + // measures the LOADED PHOTO — not the preview — so the numbers do not depend + // on the knobs already in play: pressing it twice lands on the same recipe, the + // way Lightroom's Auto does. readHistogram gives the binned ramp, which the + // three readings in ui/Histogram take their answer from: the mean luma for EV, + // the top and bottom 1% for HIGHLIGHT/SHADOW, the channel means for the WB + // ruler. AUTO is on LIGHT but it does write WB — the colour cast is part of + // what the frame is asking for, and the TEMPERATURE/TINT knobs report it. + const autoTune = async () => { if (!previewBytes) return; const url = URL.createObjectURL(new Blob([previewBytes as BlobPart], { type: 'image/jpeg' })); try { const hist = await readHistogram(url, () => true); if (hist) { remember(); - setAdjustment({ exposureCompensation: Math.round(autoExposureStops(hist.lum) * 10) / 10 }); + setAdjustment({ + // EV is the one knob printed to a tenth; the others are whole units. + exposureCompensation: Math.round(autoExposureStops(hist.lum) * 10) / 10, + ...autoTone(hist.lum), + ...autoWhiteBalance(hist.r, hist.g, hist.b), + }); } } catch { // A photo that will not decode leaves the recipe alone — a broken preview @@ -2683,8 +2690,8 @@ export function Workspace() { case 'light': return [ // AUTO rides at the head of the strip because it is the one chip here - // that is an ACTION rather than a knob or a look (see autoExposure). - { key: 'auto', label: 'AUTO', onClick: () => void autoExposure() }, + // that is an ACTION rather than a knob or a look (see autoTune). + { key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, ...paramChips(PARAM_DEFS.iq), groupChip('dr'), // TONE CURVE is not a row of sliders: it opens the graph on the photo diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx index 71306f6..e957082 100644 --- a/docker/frontend/src/ui/Histogram.tsx +++ b/docker/frontend/src/ui/Histogram.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { kelvinToRGB } from '../../shared/utils/colorUtils'; import { useI18n } from '../i18n/I18nProvider'; // The histogram overlay: a draggable frame over the photo, reading the render @@ -80,6 +81,101 @@ export function autoExposureStops(lum: number[]): number { return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops)); } +// How far AUTO may push HIGHLIGHT and SHADOW, in the sliders' own units: half +// of the ±10 ruler, so the frame is corrected but a hand can still finish the +// move. The knob then reports the number AUTO chose, the way the EV knob does. +const AUTO_TONE_MAX = 5; + +// The value `p` of the way up the binned ramp (0.99 for the top 1% of pixels): +// walk the cumulative count to the first bin that passes `p * total`, and report +// where that bin sits. Bin i holds every pixel worth exactly i, so it sits at +// i/(len-1) — the same 0..1 the exposure math uses. Nearest rank, not +// interpolated: a bin is 1/255 wide and the thresholds here are 0.1 apart. +export function lumaPercentile(lum: number[], p: number): number { + const total = lum.reduce((a, n) => a + n, 0); + if (total <= 0) return 0; + const target = p * total; + let seen = 0; + for (let i = 0; i < lum.length; i++) { + seen += lum[i]; + if (lum[i] > 0 && seen >= target) return i / (lum.length - 1); + } + return 1; +} + +// AUTO's Highlight/Shadow, as Snapseed decides them: the ends are read at the +// top and bottom 1% rather than at the average, so a small blown window pulls +// the highlights down while the rest of the frame stays put. Only crossed +// thresholds move a knob — p99 above 0.9 asks for negative HIGHLIGHT (recover), +// p01 below 0.02 for positive SHADOW (open up) — and the ramp reaches +// AUTO_TONE_MAX at a frame that is entirely clipped or entirely black. +// +// ponytail: one linear ramp per end, no scene analysis. Add a curve (or weight +// by how much of the frame is clipped) when AUTO starts overshooting on scenes +// with a genuine specular. +export function autoTone(lum: number[]): { highlight: number; shadow: number } { + // Nothing sampled at all (an empty canvas) reads as a frame on the floor, + // which would open the shadows the whole way; leave the knobs where they are. + if (!lum.some((n) => n > 0)) return { highlight: 0, shadow: 0 }; + const p99 = lumaPercentile(lum, 0.99); + const p01 = lumaPercentile(lum, 0.01); + const highlight = p99 > 0.9 ? -Math.round(((p99 - 0.9) / 0.1) * AUTO_TONE_MAX) : 0; + const shadow = p01 < 0.02 ? Math.round(((0.02 - p01) / 0.02) * AUTO_TONE_MAX) : 0; + return { highlight, shadow }; +} + +// AUTO's White Balance, gray-world with green as the anchor: the gain that puts +// the three channel means on top of each other is G/avgR and G/avgB. Those are +// multipliers on LINEAR light (shared/utils/colorUtils takes its ratios there), +// so the sRGB means off the bins are linearised first. +export function autoWhiteBalance( + r: number[], + g: number[], + b: number[], +): { temperature: number; tint: number } { + const mean = (bins: number[]) => { + let sum = 0; + let weighted = 0; + for (let i = 0; i < bins.length; i++) { + sum += bins[i]; + weighted += (i / (bins.length - 1)) * bins[i]; + } + const srgb = sum > 0 ? weighted / sum : 0; + return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4); + }; + const R = mean(r); + const G = mean(g); + const B = mean(b); + // A frame with a dead channel has no cast to read — leave the ruler alone. + if (R <= 0 || G <= 0 || B <= 0) return { temperature: 5500, tint: 0 }; + + // The pair of gains the frame is asking for, against G. + const wantR = G / R; + const wantB = G / B; + // TEMPERATURE and TINT are the two knobs that BE this gain: scanning what the + // engine would apply (kelvinToRGB, tint's ±0.08 on the green↔magenta axis) + // and keeping the closest pair needs no inverse — and cannot drift from the + // render, because it asks the renderer's own function. 76 x 21 pairs is a + // tenth of a millisecond. Luma normalisation is skipped: it scales all three + // channels alike, so it cancels in the ratios being matched. + let best = { temperature: 5500, tint: 0 }; + let bestErr = Infinity; + for (let k = 2500; k <= 10000; k += 100) { + const gain = kelvinToRGB(k); + for (let tint = -10; tint <= 10; tint++) { + const magenta = (tint / 10) * 0.08; + const gr = (gain.r * (1 + magenta)) / (gain.g * (1 - magenta)); + const gb = (gain.b * (1 + magenta)) / (gain.g * (1 - magenta)); + const err = (gr - wantR) ** 2 + (gb - wantB) ** 2; + if (err < bestErr) { + bestErr = err; + best = { temperature: k, tint }; + } + } + } + return best; +} + // One channel across the full width of the ramp. `close` also draws the floor, // which is only wanted for the filled luminance curve. function curve(bins: number[], max: number, close: boolean): string {