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
+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 {