588fbf77d9
Measured on the 12S Ultra (2203121C), same scene, 1x, EV 0, 3 shots per arm:
- qualityPrioritization 'quality' -> 'balanced': tap->file 1013 ms vs 736 ms
(-277 ms, -27%), settled 1552 ms vs 1280 ms, in-camera 521 ms vs 286 ms.
Repeat on a second session: -228 ms. Image quality at ISO ~450 is inside the
scatter of one arm (flat sigma +2.4%, edge +0.1%, texture -1.1%), so the MFNR
pass quality would have bought is not measurable here. Also removes the
EXPO_PUBLIC_CAPTURE_MODE seam: Metro inlines EXPO_PUBLIC_* in dev only, the
release HBC build does not, so it could never gate shipped behaviour.
- Dark scenes now go through the app's own burst (ISO >= 100 gate for the probe)
and mergeBurst runs in the export queue instead of on the shutter path, so a
second shot is accepted while the frames are still being averaged: double-tap
at 1.2 s used to drop the second photo, now it lands. Output stays the merged
frame (flat sigma 0.825 vs 0.939 for a single frame).
Burst is skipped when the LED is the light source, when the shot came from the
native 0.5x session, for a held burst, for RAW, and below the devicePerf RAM
floor (deviceFloor.ts) where three raws plus the alignment rasters would be
seconds of shutter lag.
Bundles in the work this sits on: frame averaging (burstAlign/burstMerge),
the per-device floor, and the EXIF/ISO readback the burst gate needs (exifWrite,
photoMeta, exportEngine, RecipescamExportModule).
Self-checks: src/utils/{burstAlign,deviceFloor,exifWrite}.check.ts.
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
// Self-check for the burst alignment maths — the one part of the low-light
|
|
// merge that is decided by arithmetic rather than by Skia, and the part a wrong
|
|
// answer turns into a visibly doubled photo. Run it after touching
|
|
// burstAlign.ts (or the constants that feed it):
|
|
// node src/utils/burstAlign.check.ts
|
|
// Exits non-zero on the first failed expectation.
|
|
|
|
import { bestShift, nearest2D, profilesOf, sadAt, type Pixels } from './burstAlign.ts';
|
|
|
|
function check(name: string, ok: boolean, detail = ''): void {
|
|
if (!ok) {
|
|
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`ok ${name}`);
|
|
}
|
|
|
|
// Deterministic structure (a hash, not Math.random) so a failure is repeatable.
|
|
function texture(w: number, h: number, seed: number): Pixels {
|
|
const px = new Uint8Array(w * h * 4);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const n = Math.sin(x * 12.9898 + y * 78.233 + seed) * 43758.5453;
|
|
const v = 96 + Math.floor((n - Math.floor(n)) * 128);
|
|
const p = (y * w + x) * 4;
|
|
px[p] = v;
|
|
px[p + 1] = v;
|
|
px[p + 2] = v;
|
|
px[p + 3] = 255;
|
|
}
|
|
}
|
|
return px;
|
|
}
|
|
|
|
// `a` cut down to what a camera would see of `b` after a shift of (dx, dy).
|
|
function shifter(a: Pixels, w: number, h: number, dx: number, dy: number): Pixels {
|
|
const px = new Uint8Array(w * h * 4);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const sx = x - dx;
|
|
const sy = y - dy;
|
|
if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue;
|
|
const d = (y * w + x) * 4;
|
|
const s = (sy * w + sx) * 4;
|
|
px[d] = a[s];
|
|
px[d + 1] = a[s + 1];
|
|
px[d + 2] = a[s + 2];
|
|
px[d + 3] = 255;
|
|
}
|
|
}
|
|
return px;
|
|
}
|
|
|
|
const W = 192;
|
|
const H = 128;
|
|
const ref = texture(W, H, 1);
|
|
const refProfile = profilesOf(ref, W, H);
|
|
|
|
// 1) A known shift comes back out of the profiles, as the offset that draws the
|
|
// frame back onto the reference: content moved right by 7 is pulled left by 7.
|
|
{
|
|
const cur = shifter(ref, W, H, 7, -4);
|
|
const curProfile = profilesOf(cur, W, H);
|
|
const dx = bestShift(refProfile.cols, curProfile.cols, 12);
|
|
const dy = bestShift(refProfile.rows, curProfile.rows, 12);
|
|
check('bestShift undoes a (7, -4) move', dx === -7 && dy === 4, `got (${dx}, ${dy})`);
|
|
}
|
|
|
|
// 2) The 2D confirmation returns the translation, not the seed, when they agree.
|
|
{
|
|
const cur = shifter(ref, W, H, -5, 3);
|
|
const curProfile = profilesOf(cur, W, H);
|
|
const seedX = bestShift(refProfile.cols, curProfile.cols, 12);
|
|
const seedY = bestShift(refProfile.rows, curProfile.rows, 12);
|
|
const best = nearest2D(ref, cur, W, H, seedX, seedY, 2);
|
|
check('nearest2D confirms it on the pixels', best.x === 5 && best.y === -3, `got (${best.x}, ${best.y})`);
|
|
}
|
|
|
|
// 3) A featureless pair — an unreadable scene, or a flat wall in the dark —
|
|
// merges unshifted instead of being pushed somewhere by noise.
|
|
{
|
|
const flat: Pixels = new Uint8Array(W * H * 4).fill(200);
|
|
for (let i = 3; i < flat.length; i += 4) flat[i] = 255;
|
|
const best = nearest2D(flat, flat, W, H, 9, 9, 2);
|
|
check('flat pair stays at (0, 0)', best.x === 0 && best.y === 0, `got (${best.x}, ${best.y})`);
|
|
}
|
|
|
|
// 4) A candidate that keeps less than half the frame cannot win on a flat error.
|
|
{
|
|
const a: Pixels = new Uint8Array(W * H * 4).fill(10);
|
|
const b: Pixels = new Uint8Array(W * H * 4).fill(10);
|
|
check('disjoint candidate rejected', sadAt(a, b, W, H, W, 0) === Infinity);
|
|
check('full-overlap candidate scored', sadAt(a, b, W, H, 0, 0) === 0);
|
|
}
|
|
|
|
// 5) The fine stage searches around the coarse estimate: the true offset is
|
|
// found from a seed that is already close, and a seed that is wrong can only
|
|
// pull the answer as far as the window allows — never back to the truth.
|
|
{
|
|
const cur = shifter(ref, W, H, 2, 0);
|
|
const curProfile = profilesOf(cur, W, H);
|
|
check('expect-centred search finds -2 from a seed of -2', bestShift(refProfile.cols, curProfile.cols, 1, -2) === -2);
|
|
const off = bestShift(refProfile.cols, curProfile.cols, 1, 5);
|
|
check('a wrong seed stays inside its window', Math.abs(off - 5) <= 1, `got ${off}`);
|
|
}
|
|
|
|
console.log('\nburstAlign: all checks passed');
|