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