From 98bff4b25053ff07d0caee6525311ec37067471b Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:07:40 +0700 Subject: [PATCH] fix(pas-foto): correct head guide proportions and calibrate face detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from real-device feedback: 1. The oval was far too narrow. Its width came from the FRAME width while its height came from the crown/chin band, so in a 3x4 frame it rendered at a width/height of ~0.51 — nothing like a head, and a different shape in every photo size. The width is now DERIVED from the head height with an anatomical ratio (0.68), so the guide is head-shaped in 2x3, 3x4 and 4x6 alike. The plain ellipse is replaced by headOutlinePath(), an egg shape with a rounded cranium tapering to the chin, and the SVG viewBox now matches the photo aspect instead of stretching a square one. 2. Framing feedback said 'looks good' while the subject was still too far away. HEAD_TO_FACE was a guessed 1.4; measuring a real detection (436px box vs a true 521px crown-to-chin, chin sitting above the box bottom) gives 1.20, split into CHIN_AT/CROWN_ABOVE so the chin is placed correctly too. A test pins the model to that measurement. 3. Background removal could fail with 'Failed to fetch' because the background-removal and face-detection models were downloaded at the same time; they now run in sequence. Also widens the vertical-position range to +/-1 frame height: a test showed a small (distant) face needs more travel than +/-0.5 allowed, which left such photos misaligned. --- src/islands/image/PasFoto.tsx | 40 +++++++++++------- src/islands/image/PasFotoCamera.tsx | 30 +++++++------ src/tools/image/foto-align.lib.test.ts | 32 ++++++++------ src/tools/image/foto-align.lib.ts | 28 ++++++++----- src/tools/image/pas-foto.lib.test.ts | 58 +++++++++++++++++++++++++- src/tools/image/pas-foto.lib.ts | 47 +++++++++++++++++++-- 6 files changed, 179 insertions(+), 56 deletions(-) diff --git a/src/islands/image/PasFoto.tsx b/src/islands/image/PasFoto.tsx index a720bd5..c0f88d5 100644 --- a/src/islands/image/PasFoto.tsx +++ b/src/islands/image/PasFoto.tsx @@ -17,6 +17,7 @@ import { photoPx, sheetLayout, headGuideBox, + headOutlinePath, type PhotoSize, type Sheet, } from '@/tools/image/pas-foto.lib'; @@ -25,7 +26,9 @@ import type { Lang } from '@/i18n/config'; const GAP_CM = 0.2; const MARGIN_CM = 0.3; // Framing-guide geometry in a 0–100 viewBox (drawn only on the preview). -const GUIDE = headGuideBox(100, 100); +// Guide geometry is computed per photo size so the head outline keeps real +// proportions in 2x3, 3x4 and 4x6 alike (see headGuideBox). +const GUIDE_UNITS = 100; const BG_PRESETS = [ { color: '#e02424', label: 'Merah' }, { color: '#1e50e0', label: 'Biru' }, @@ -124,7 +127,7 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { const [size, setSize] = useState(PHOTO_SIZES[1]); // 3x4 const [sheet, setSheet] = useState(SHEETS[0]); // 4R const [zoom, setZoom] = useState(1); - const [offsetY, setOffsetY] = useState(0); // -0.5 .. 0.5 of frame height + const [offsetY, setOffsetY] = useState(0); // -1 .. 1 of frame height const [showGuide, setShowGuide] = useState(true); const [busy, setBusy] = useState(false); const [stage, setStage] = useState(''); @@ -151,6 +154,13 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { [size, sheet], ); + // Guide drawn in the photo's own proportions (e.g. 300x400 for 3x4 cm). + const guideW = size.w * GUIDE_UNITS; + const guideH = size.h * GUIDE_UNITS; + const guide = useMemo(() => headGuideBox(guideW, guideH), [guideW, guideH]); + const outline = useMemo(() => headOutlinePath(guideW, guideH), [guideW, guideH]); + const guideStroke = guideH / 220; + const setSubject = (blob: Blob) => { setSubjectUrl(prev => { if (prev) URL.revokeObjectURL(prev); @@ -235,8 +245,10 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { if (!file) return; setAligned(false); setSrcFile(file); - prepare(file, removeBg); - void autoAlign(file); + // Run the two on-device models one after the other: kicking off the + // background-removal and face-detection downloads at the same time made + // the first one fail with "Failed to fetch" on mobile connections. + void prepare(file, removeBg).then(() => autoAlign(file)); }; const onCameraCapture = (file: File) => { @@ -381,20 +393,20 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { {showGuide && ( {/* Dark halo behind, light line on top → visible on any background. */} - - - - + + + + - - - - + + + + )} @@ -412,7 +424,7 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
diff --git a/src/islands/image/PasFotoCamera.tsx b/src/islands/image/PasFotoCamera.tsx index 4b65dbd..34049d8 100644 --- a/src/islands/image/PasFotoCamera.tsx +++ b/src/islands/image/PasFotoCamera.tsx @@ -4,11 +4,9 @@ import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { useCamera } from '@/hooks/useCamera'; import { frameToFile } from '@/tools/image/camera.lib'; -import { headGuideBox } from '@/tools/image/pas-foto.lib'; +import { headGuideBox, headOutlinePath } from '@/tools/image/pas-foto.lib'; import { framingFeedback, coverCropRect, type FaceBox, type FramingStatus } from '@/tools/image/foto-align.lib'; import type { Lang } from '@/i18n/config'; - -const GUIDE = headGuideBox(100, 100); /** Detection cadence — faster than this wastes CPU without helping the user. */ const DETECT_MS = 120; const COUNTDOWN_FROM = 3; @@ -184,6 +182,10 @@ export default function PasFotoCamera({ : { left: '0%', top: '0%', width: '100%', height: '100%' }; const good = status === 'ok'; + const guide = headGuideBox(photoW, photoH); + const outline = headOutlinePath(photoW, photoH); + // Keep the stroke visually constant whatever units the photo frame uses. + const guideStroke = photoH / 220; if (error) { return ( @@ -211,21 +213,23 @@ export default function PasFotoCamera({ {/* Crop window: dims everything outside the photo area. */}
- - - - - + {/* viewBox matches the photo aspect so the head outline keeps real + proportions instead of being stretched by preserveAspectRatio. */} + + + + + - - - + + +
diff --git a/src/tools/image/foto-align.lib.test.ts b/src/tools/image/foto-align.lib.test.ts index 3392a2e..465ca1e 100644 --- a/src/tools/image/foto-align.lib.test.ts +++ b/src/tools/image/foto-align.lib.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { framingFeedback, alignTransform, headFromFace, coverCropRect, HEAD_TO_FACE, type FaceBox } from './foto-align.lib'; +import { framingFeedback, alignTransform, headFromFace, coverCropRect, HEAD_TO_FACE, CROWN_ABOVE, type FaceBox } from './foto-align.lib'; import { HEAD_GUIDE } from './pas-foto.lib'; const FRAME_W = 600; @@ -9,19 +9,27 @@ const FRAME_H = 800; function perfectFace(w = FRAME_W, h = FRAME_H): FaceBox { const headH = (HEAD_GUIDE.chin - HEAD_GUIDE.crown) * h; const faceH = headH / HEAD_TO_FACE; - const chinY = HEAD_GUIDE.chin * h; - const faceW = faceH * 0.75; - return { x: w / 2 - faceW / 2, y: chinY - faceH, w: faceW, h: faceH }; + const crownY = HEAD_GUIDE.crown * h; + const faceW = faceH; // MediaPipe returns a square box + // crownY = y − h*CROWN_ABOVE → y = crownY + h*CROWN_ABOVE + return { x: w / 2 - faceW / 2, y: crownY + faceH * CROWN_ABOVE, w: faceW, h: faceH }; } describe('headFromFace', () => { - it('extends the detector box upward to the crown', () => { - const face: FaceBox = { x: 100, y: 200, w: 80, h: 100 }; + it('places the crown above the box and the chin just inside its bottom', () => { + const face: FaceBox = { x: 100, y: 200, w: 100, h: 100 }; const head = headFromFace(face); - expect(head.chinY).toBe(300); // bottom of the box - expect(head.headH).toBeCloseTo(140, 5); // 100 × 1.4 - expect(head.crownY).toBeCloseTo(160, 5); // chin − headH - expect(head.cx).toBe(140); + expect(head.chinY).toBeCloseTo(288, 5); // 200 + 100×0.88 — above the box bottom + expect(head.crownY).toBeCloseTo(168, 5); // 200 − 100×0.32 + expect(head.headH).toBeCloseTo(120, 5); // 100 × 1.20 + expect(head.cx).toBe(150); + }); + + it('matches the real detection it was calibrated from', () => { + // Measured sample: 436px box at y=1021; true crown ≈883, chin ≈1404. + const head = headFromFace({ x: 323, y: 1021, w: 436, h: 436 }); + expect(Math.abs(head.crownY - 883)).toBeLessThan(25); + expect(Math.abs(head.chinY - 1404)).toBeLessThan(25); }); }); @@ -118,8 +126,8 @@ describe('alignTransform', () => { const r = alignTransform(tiny, 900, 1200, 300, 400); expect(r.zoom).toBeLessThanOrEqual(3); expect(r.zoom).toBeGreaterThanOrEqual(0.5); - expect(r.offsetY).toBeGreaterThanOrEqual(-0.5); - expect(r.offsetY).toBeLessThanOrEqual(0.5); + expect(r.offsetY).toBeGreaterThanOrEqual(-1); + expect(r.offsetY).toBeLessThanOrEqual(1); }); it('works for a landscape source photo', () => { diff --git a/src/tools/image/foto-align.lib.ts b/src/tools/image/foto-align.lib.ts index 49e25b8..bfec0b2 100644 --- a/src/tools/image/foto-align.lib.ts +++ b/src/tools/image/foto-align.lib.ts @@ -11,20 +11,23 @@ import { HEAD_GUIDE } from './pas-foto.lib'; export interface FaceBox { x: number; y: number; w: number; h: number } /** - * MediaPipe's short-range detector returns a box tight around the face - * (roughly brow line to chin). A passport crop measures crown-to-chin, which - * is taller — this factor converts one to the other, and the crown sits above - * the box top by the difference. + * Where the head actually sits relative to MediaPipe's (square) face box. + * + * Calibrated against a real detection rather than guessed: on a sample photo + * the detector returned a 436px box while the true crown-to-chin height was + * ~521px, with the chin sitting slightly ABOVE the box bottom. The earlier + * 1.4 factor overestimated the head by ~17%, which made the tool report + * "framing looks good" while the subject was still too far from the camera. */ -export const HEAD_TO_FACE = 1.4; +export const CHIN_AT = 0.88; // chin, as a fraction down the box +export const CROWN_ABOVE = 0.32; // crown, as a fraction of box height above its top +export const HEAD_TO_FACE = CHIN_AT + CROWN_ABOVE; // 1.20 /** Estimated crown/chin/centre of the head from a detected face box. */ export function headFromFace(face: FaceBox): { crownY: number; chinY: number; cx: number; headH: number } { - const headH = face.h * HEAD_TO_FACE; - // The chin sits near the bottom of the detector's box; the crown is above it. - const chinY = face.y + face.h; - const crownY = chinY - headH; - return { crownY, chinY, cx: face.x + face.w / 2, headH }; + const chinY = face.y + face.h * CHIN_AT; + const crownY = face.y - face.h * CROWN_ABOVE; + return { crownY, chinY, cx: face.x + face.w / 2, headH: chinY - crownY }; } export type FramingStatus = 'ok' | 'no-face' | 'too-close' | 'too-far' | 'off-center' | 'too-high' | 'too-low'; @@ -118,8 +121,11 @@ export function alignTransform( // Where the crown lands with offsetY = 0 … const baseCrown = (H - dh) / 2 + crownY * s; // … and how far it must move to reach the guide line. + // ±1 frame-height of travel: a small (distant) face needs a large zoom, and + // a large zoom needs a correspondingly large shift to bring the crown up. + // ±0.5 was too tight and left such photos visibly misaligned. const targetCrown = HEAD_GUIDE.crown * H; - const offsetY = Math.max(-0.5, Math.min(0.5, (targetCrown - baseCrown) / H)); + const offsetY = Math.max(-1, Math.min(1, (targetCrown - baseCrown) / H)); // cx is unused for now (the compositor centres horizontally), but a face far // off-centre is reported by framingFeedback so the user can recentre. diff --git a/src/tools/image/pas-foto.lib.test.ts b/src/tools/image/pas-foto.lib.test.ts index 584ee1d..c7df49d 100644 --- a/src/tools/image/pas-foto.lib.test.ts +++ b/src/tools/image/pas-foto.lib.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { cmToPt, photoPx, sheetLayout, headGuideBox, PHOTO_SIZES, SHEETS } from './pas-foto.lib'; +import { cmToPt, photoPx, sheetLayout, headGuideBox, headOutlinePath, HEAD_GUIDE, PHOTO_SIZES, SHEETS } from './pas-foto.lib'; describe('cmToPt', () => { it('converts cm to PDF points', () => { @@ -56,9 +56,9 @@ describe('headGuideBox', () => { expect(g.crownY).toBeCloseTo(8, 5); expect(g.chinY).toBeCloseTo(85, 5); expect(g.cx).toBeCloseTo(50, 5); - expect(g.rx).toBeCloseTo(26, 5); // widthRatio 0.52 → diameter 52 → radius 26 expect(g.ry).toBeCloseTo(38.5, 5); // (85-8)/2 expect(g.cy).toBeCloseTo(46.5, 5); // (8+85)/2 + expect(g.rx).toBeCloseTo(38.5 * HEAD_GUIDE.widthToHeight, 5); }); it('scales with the frame size', () => { @@ -66,6 +66,60 @@ describe('headGuideBox', () => { expect(g.crownY).toBeCloseTo(32, 5); // 0.08 * 400 expect(g.cx).toBeCloseTo(150, 5); }); + + it('keeps a head-shaped oval in every photo aspect', () => { + // The oval's width must follow its HEIGHT (anatomy), not the frame width — + // deriving it from the frame made it far too narrow in a 3x4 frame. + for (const [w, h] of [[200, 300], [300, 400], [400, 600]]) { + const g = headGuideBox(w, h); + expect((g.rx * 2) / (g.ry * 2)).toBeCloseTo(HEAD_GUIDE.widthToHeight, 5); + } + }); + + it('the oval is wider than the old frame-derived version in a 3x4 frame', () => { + const g = headGuideBox(300, 400); + expect(g.rx * 2).toBeGreaterThan(0.52 * 300); // old widthRatio behaviour + }); +}); + +describe('headOutlinePath', () => { + const path = headOutlinePath(300, 400); + + it('produces a closed SVG path', () => { + expect(path.startsWith('M ')).toBe(true); + expect(path.trim().endsWith('Z')).toBe(true); + expect(path.match(/C /g)).toHaveLength(4); // four bezier quadrants + }); + + it('stays inside the guide box', () => { + const g = headGuideBox(300, 400); + const nums = path.match(/-?\d+(\.\d+)?/g)!.map(Number); + const xs = nums.filter((_, i) => i % 2 === 0); + const ys = nums.filter((_, i) => i % 2 === 1); + expect(Math.min(...xs)).toBeGreaterThanOrEqual(g.cx - g.rx - 0.01); + expect(Math.max(...xs)).toBeLessThanOrEqual(g.cx + g.rx + 0.01); + expect(Math.min(...ys)).toBeGreaterThanOrEqual(g.crownY - 0.01); + expect(Math.max(...ys)).toBeLessThanOrEqual(g.chinY + 0.01); + }); + + it('starts at the crown and reaches the chin', () => { + const g = headGuideBox(300, 400); + const nums = path.match(/-?\d+(\.\d+)?/g)!.map(Number); + // First point is the crown (centre-top); the chin is the lowest y. + expect(nums[0]).toBeCloseTo(g.cx, 1); + expect(nums[1]).toBeCloseTo(g.crownY, 1); + const ys = nums.filter((_, i) => i % 2 === 1); + expect(Math.max(...ys)).toBeCloseTo(g.chinY, 1); + }); + + it('is narrower at the chin than at the temples (head-shaped, not an ellipse)', () => { + // Sample the path's widest control points vs the chin end. + const g = headGuideBox(300, 400); + const nums = path.match(/-?\d+(\.\d+)?/g)!.map(Number); + const xs = nums.filter((_, i) => i % 2 === 0); + const widest = Math.max(...xs) - Math.min(...xs); + expect(widest).toBeCloseTo(g.rx * 2, 1); + }); }); describe('constants', () => { diff --git a/src/tools/image/pas-foto.lib.ts b/src/tools/image/pas-foto.lib.ts index 029a83d..6442cc2 100644 --- a/src/tools/image/pas-foto.lib.ts +++ b/src/tools/image/pas-foto.lib.ts @@ -54,9 +54,24 @@ export function cmToPt(cm: number): number { * passport convention: the head fills most of the frame, with a little * headroom above the crown. Guides are advisory — shown on the preview only. */ -export const HEAD_GUIDE = { crown: 0.08, chin: 0.85, widthRatio: 0.52 }; +export const HEAD_GUIDE = { + crown: 0.08, + chin: 0.85, + /** + * Head breadth ÷ crown-to-chin height for an adult (~15.5cm ÷ 23cm). The + * oval's width is DERIVED from its height with this ratio so the guide keeps + * a head shape in every photo aspect — deriving it from the frame width + * instead made the oval far too narrow in a 3×4 frame. + */ + widthToHeight: 0.68, +}; -/** Guide geometry (crown/chin lines + centered head oval) for a W×H frame. */ +/** + * Guide geometry (crown/chin lines + head oval) for a W×H frame. + * Pass the frame's REAL proportions (e.g. 300×400) — `rx` is in x-units and + * `ry` in y-units, so a square viewBox stretched with preserveAspectRatio + * would distort the oval. + */ export function headGuideBox(w: number, h: number): { crownY: number; chinY: number; @@ -67,16 +82,40 @@ export function headGuideBox(w: number, h: number): { } { const crownY = HEAD_GUIDE.crown * h; const chinY = HEAD_GUIDE.chin * h; + const ry = (chinY - crownY) / 2; return { crownY, chinY, cx: w / 2, cy: (crownY + chinY) / 2, - rx: (HEAD_GUIDE.widthRatio * w) / 2, - ry: (chinY - crownY) / 2, + rx: ry * HEAD_GUIDE.widthToHeight, + ry, }; } +/** + * SVG path for a head-shaped outline (an egg: widest at the temples, tapering + * to the chin) inside the guide box — closer to a real head than a plain + * ellipse, so it is easier to line yourself up with. + */ +export function headOutlinePath(w: number, h: number): string { + const g = headGuideBox(w, h); + const headH = g.chinY - g.crownY; + const hw = g.rx; // half-width at the widest point + // Control points in normalised head space (0 = crown, 1 = chin). + const x = (u: number) => g.cx + u * hw; + const y = (v: number) => g.crownY + v * headH; + const n = (v: number) => Number(v.toFixed(2)); + return [ + `M ${n(x(0))} ${n(y(0))}`, + `C ${n(x(0.72))} ${n(y(0))} ${n(x(1))} ${n(y(0.16))} ${n(x(1))} ${n(y(0.40))}`, + `C ${n(x(1))} ${n(y(0.64))} ${n(x(0.66))} ${n(y(0.88))} ${n(x(0))} ${n(y(1))}`, + `C ${n(x(-0.66))} ${n(y(0.88))} ${n(x(-1))} ${n(y(0.64))} ${n(x(-1))} ${n(y(0.40))}`, + `C ${n(x(-1))} ${n(y(0.16))} ${n(x(-0.72))} ${n(y(0))} ${n(x(0))} ${n(y(0))}`, + 'Z', + ].join(' '); +} + /** Pixel dimensions of one photo at the given print DPI. */ export function photoPx(wCm: number, hCm: number, dpi: number = DPI): { w: number; h: number } { return {