Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions src/islands/image/PasFoto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
photoPx,
sheetLayout,
headGuideBox,
headOutlinePath,
type PhotoSize,
type Sheet,
} from '@/tools/image/pas-foto.lib';
Expand All @@ -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' },
Expand Down Expand Up @@ -124,7 +127,7 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
const [size, setSize] = useState<PhotoSize>(PHOTO_SIZES[1]); // 3x4
const [sheet, setSheet] = useState<Sheet>(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('');
Expand All @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -381,20 +393,20 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
<canvas ref={previewRef} className="block h-auto max-h-[60vh] w-auto" />
{showGuide && (
<svg
viewBox="0 0 100 100"
viewBox={`0 0 ${guideW} ${guideH}`}
preserveAspectRatio="none"
className="pointer-events-none absolute inset-0 h-full w-full"
>
{/* Dark halo behind, light line on top → visible on any background. */}
<g fill="none" stroke="rgba(0,0,0,0.45)" strokeWidth={1.4}>
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
<g fill="none" stroke="rgba(0,0,0,0.45)" strokeWidth={guideStroke * 2.4}>
<line x1={0} y1={guide.crownY} x2={guideW} y2={guide.crownY} />
<line x1={0} y1={guide.chinY} x2={guideW} y2={guide.chinY} />
<path d={outline} />
</g>
<g fill="none" stroke="rgba(255,255,255,0.95)" strokeWidth={0.5} strokeDasharray="2 2">
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
<g fill="none" stroke="rgba(255,255,255,0.95)" strokeWidth={guideStroke} strokeDasharray={`${guideStroke * 4} ${guideStroke * 4}`}>
<line x1={0} y1={guide.crownY} x2={guideW} y2={guide.crownY} />
<line x1={0} y1={guide.chinY} x2={guideW} y2={guide.chinY} />
<path d={outline} />
</g>
</svg>
)}
Expand All @@ -412,7 +424,7 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {
</label>
<label className="block space-y-1 text-sm">
<span className="block font-semibold">{t.position}</span>
<input type="range" min={-0.5} max={0.5} step={0.01} value={offsetY}
<input type="range" min={-1} max={1} step={0.01} value={offsetY}
onChange={e => setOffsetY(Number(e.target.value))} className="w-full accent-accent" />
</label>
<div className="space-y-1">
Expand Down
30 changes: 17 additions & 13 deletions src/islands/image/PasFotoCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -211,21 +213,23 @@ export default function PasFotoCamera({

{/* Crop window: dims everything outside the photo area. */}
<div className="pointer-events-none absolute" style={{ ...cropStyle, boxShadow: '0 0 0 9999px rgba(0,0,0,0.45)' }}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full">
<g fill="none" stroke="rgba(0,0,0,0.5)" strokeWidth={1.4}>
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
{/* viewBox matches the photo aspect so the head outline keeps real
proportions instead of being stretched by preserveAspectRatio. */}
<svg viewBox={`0 0 ${photoW} ${photoH}`} preserveAspectRatio="none" className="h-full w-full">
<g fill="none" stroke="rgba(0,0,0,0.5)" strokeWidth={guideStroke * 2.4}>
<line x1={0} y1={guide.crownY} x2={photoW} y2={guide.crownY} />
<line x1={0} y1={guide.chinY} x2={photoW} y2={guide.chinY} />
<path d={outline} />
</g>
<g
fill="none"
stroke={good ? 'rgba(74,222,128,0.95)' : 'rgba(255,255,255,0.95)'}
strokeWidth={good ? 0.9 : 0.5}
strokeDasharray="2 2"
strokeWidth={guideStroke}
strokeDasharray={`${guideStroke * 4} ${guideStroke * 4}`}
>
<line x1={0} y1={GUIDE.crownY} x2={100} y2={GUIDE.crownY} />
<line x1={0} y1={GUIDE.chinY} x2={100} y2={GUIDE.chinY} />
<ellipse cx={GUIDE.cx} cy={GUIDE.cy} rx={GUIDE.rx} ry={GUIDE.ry} />
<line x1={0} y1={guide.crownY} x2={photoW} y2={guide.crownY} />
<line x1={0} y1={guide.chinY} x2={photoW} y2={guide.chinY} />
<path d={outline} />
</g>
</svg>
</div>
Expand Down
32 changes: 20 additions & 12 deletions src/tools/image/foto-align.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 17 additions & 11 deletions src/tools/image/foto-align.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 56 additions & 2 deletions src/tools/image/pas-foto.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -56,16 +56,70 @@ 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', () => {
const g = headGuideBox(300, 400);
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', () => {
Expand Down
Loading
Loading