web: make AUTO write the tone and the colour it reads off the frame

The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.

  EV          the mean luma, unchanged
  HIGHLIGHT   the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
  SHADOW      the bottom 1% (p01 < 0.02 opens up), to +5
  TEMPERATURE/TINT  the gain that puts the three channel means on each other,
              green as the anchor: gray-world on linearised means, then the
              closest of 76 temperatures x 21 tints under the renderer's own
              kelvinToRGB, so the pair cannot drift from what the ruler applies.

The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.

Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.

scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
This commit is contained in:
2026-09-28 15:24:50 +07:00
parent 224ff0b935
commit e6c050581f
3 changed files with 233 additions and 11 deletions
+119
View File
@@ -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');
+18 -11
View File
@@ -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
+96
View File
@@ -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 {