// 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');