From c26e91620dbaf76365ebc37f7036d43fd5f3f834 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:00:24 +0700 Subject: [PATCH] feat(pas-foto): guided camera capture with live framing help and auto-align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users without a photo can now shoot one in the tool. The camera opens front-facing with the existing passport guide drawn over the exact crop the photo will use, and on-device face detection (the model Face Blur already ships) gives live feedback — move closer / further back / centre your head — plus an optional 3-2-1 auto-capture that only fires while the framing stays good. After capture *and* after an upload, the head is auto-aligned: a single detection sets the existing zoom/offset controls so the crown and chin land on the guide lines. Manual sliders and a re-align button remain. New pure lib foto-align.lib (framingFeedback, alignTransform, coverCropRect — 18 tests verifying the head lands on the guide to sub-pixel accuracy across aspect ratios and source orientations). useCamera.start() takes an optional facing mode (defaults unchanged). Preview is mirrored for selfie comfort but the captured file is not. Detector failure degrades to manual framing, never blocking capture. EN + ID SEO updated. --- src/hooks/useCamera.ts | 3 +- src/islands/image/PasFoto.tsx | 109 +++++++++- src/islands/image/PasFotoCamera.tsx | 266 +++++++++++++++++++++++++ src/islands/media/VideoPlayer.tsx | 3 +- src/registry/tool-seo.ts | 26 ++- src/tools/image/foto-align.lib.test.ts | 167 ++++++++++++++++ src/tools/image/foto-align.lib.ts | 129 ++++++++++++ 7 files changed, 685 insertions(+), 18 deletions(-) create mode 100644 src/islands/image/PasFotoCamera.tsx create mode 100644 src/tools/image/foto-align.lib.test.ts create mode 100644 src/tools/image/foto-align.lib.ts diff --git a/src/hooks/useCamera.ts b/src/hooks/useCamera.ts index 9556174..05fb34f 100644 --- a/src/hooks/useCamera.ts +++ b/src/hooks/useCamera.ts @@ -67,7 +67,8 @@ export function useCamera() { } }, []); - const start = useCallback(async () => { await open('environment'); }, [open]); + // Defaults to the rear camera; pass 'user' for selfie-style capture. + const start = useCallback(async (mode: 'environment' | 'user' = 'environment') => { await open(mode); }, [open]); // Try the other camera; if it can't be opened, fall back to the current one so // the user is never stranded on an error screen with no working camera. diff --git a/src/islands/image/PasFoto.tsx b/src/islands/image/PasFoto.tsx index 0cfaf4f..a720bd5 100644 --- a/src/islands/image/PasFoto.tsx +++ b/src/islands/image/PasFoto.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { Camera, Upload, Crosshair } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; +import PasFotoCamera from './PasFotoCamera'; +import { alignTransform, type FaceBox } from '@/tools/image/foto-align.lib'; import { Alert } from '@/components/ui/Alert'; import { ProgressBar } from '@/components/ui/ProgressBar'; import { ResultActions } from '@/components/ui/ResultActions'; @@ -50,6 +53,11 @@ const TR: Record = { en: { intro: 'Make a print-ready ID photo (pas foto): remove the background, pick a color and size, and download a PDF that tiles copies onto a photo sheet — ready to print. Everything runs in your browser.', @@ -72,6 +80,11 @@ const TR: Record(null); + const [mode, setMode] = useState<'upload' | 'camera'>('upload'); + const [aligning, setAligning] = useState(false); + const [aligned, setAligned] = useState(false); + const imgRef = useRef(null); const [imgReady, setImgReady] = useState(0); const previewRef = useRef(null); + const sizeRef = useRef(size); + sizeRef.current = size; // Revoke the subject object URL when it changes/unmounts. useEffect(() => () => { if (subjectUrl) URL.revokeObjectURL(subjectUrl); }, [subjectUrl]); @@ -165,11 +189,59 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { } }; + /** + * Detect the face in a source image and set zoom/offset so the head lands on + * the passport guide. Best-effort: if detection fails the manual sliders are + * untouched and the user carries on as before. + */ + const autoAlign = async (file: File) => { + setAligning(true); + try { + const bitmap = await createImageBitmap(file); + try { + const { FilesetResolver, FaceDetector } = await import('@mediapipe/tasks-vision'); + const vision = await FilesetResolver.forVisionTasks(new URL('/models/mediapipe/wasm', location.origin).href); + const detector = await FaceDetector.createFromOptions(vision, { + baseOptions: { modelAssetPath: new URL('/models/mediapipe/blaze_face_short_range.tflite', location.origin).href }, + runningMode: 'IMAGE', + minDetectionConfidence: 0.4, + }); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + const box = detector.detect(canvas).detections?.[0]?.boundingBox; + detector.close(); + if (box) { + const face: FaceBox = { x: box.originX, y: box.originY, w: box.width, h: box.height }; + const s = sizeRef.current; + const { zoom: z, offsetY: oy } = alignTransform(face, bitmap.width, bitmap.height, s.w * 100, s.h * 100); + setZoom(z); + setOffsetY(oy); + setAligned(true); + } + } finally { + bitmap.close?.(); + } + } catch { + // No detector / no face — keep the manual controls as they are. + } finally { + setAligning(false); + } + }; + const onDrop = (files: File[]) => { const file = files.find(f => f.type.startsWith('image/')); if (!file) return; + setAligned(false); setSrcFile(file); prepare(file, removeBg); + void autoAlign(file); + }; + + const onCameraCapture = (file: File) => { + setMode('upload'); + onDrop([file]); }; usePasteImage(f => onDrop([f])); @@ -271,12 +343,31 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) {

{t.intro}

- -
-

{t.dropTitle}

-

{t.dropSub}

-
-
+
+ + +
+ + {mode === 'camera' ? ( + setMode('upload')} + /> + ) : ( + +
+

{t.dropTitle}

+

{t.dropSub}

+
+
+ )} {error && {error}} {busy && } @@ -324,6 +415,12 @@ export default function PasFoto({ lang = 'en' }: { lang?: Lang }) { setOffsetY(Number(e.target.value))} className="w-full accent-accent" /> +
+ + {aligned && !aligning &&

{t.alignedHint}

} +
{/* Controls */} diff --git a/src/islands/image/PasFotoCamera.tsx b/src/islands/image/PasFotoCamera.tsx new file mode 100644 index 0000000..4b65dbd --- /dev/null +++ b/src/islands/image/PasFotoCamera.tsx @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Camera, RefreshCw, X } from 'lucide-react'; +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 { 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; + +const TR: Record; + capture: string; switchCam: string; cancel: string; auto: string; useDevice: string; + hint: string; privacy: string; detectFailed: string; +}> = { + en: { + status: { + loading: 'Starting camera…', + 'no-face': 'Look at the camera — no face detected yet', + 'too-far': 'Move a little closer', + 'too-close': 'Move a little further back', + 'off-center': 'Center your head in the oval', + 'too-high': 'Lower the camera slightly', + 'too-low': 'Raise the camera slightly', + ok: '✓ Framing looks good — hold still', + manual: 'Line your head up with the oval, then capture', + }, + capture: 'Capture', switchCam: 'Switch camera', cancel: 'Cancel', auto: 'Auto-capture when framed', + useDevice: 'Use device camera', + hint: 'Put your crown at the top line and your chin at the bottom line.', + privacy: 'The camera preview and face detection run entirely on your device — no frames are uploaded.', + detectFailed: 'Live framing help is unavailable, but you can still line up with the guide and capture.', + }, + id: { + status: { + loading: 'Menyalakan kamera…', + 'no-face': 'Lihat ke kamera — wajah belum terdeteksi', + 'too-far': 'Sedikit lebih dekat', + 'too-close': 'Sedikit lebih menjauh', + 'off-center': 'Posisikan kepala di tengah oval', + 'too-high': 'Turunkan kamera sedikit', + 'too-low': 'Naikkan kamera sedikit', + ok: '✓ Bingkai sudah pas — tahan sebentar', + manual: 'Sejajarkan kepala dengan oval, lalu ambil foto', + }, + capture: 'Ambil foto', switchCam: 'Ganti kamera', cancel: 'Batal', auto: 'Ambil otomatis saat pas', + useDevice: 'Gunakan kamera perangkat', + hint: 'Posisikan puncak kepala di garis atas dan dagu di garis bawah.', + privacy: 'Pratinjau kamera dan deteksi wajah berjalan sepenuhnya di perangkat Anda — tidak ada frame yang diunggah.', + detectFailed: 'Bantuan bingkai langsung tidak tersedia, tapi Anda tetap bisa menyejajarkan dengan panduan lalu mengambil foto.', + }, +}; + +interface MpDetection { boundingBox?: { originX: number; originY: number; width: number; height: number } } +interface MpDetector { detectForVideo(v: HTMLVideoElement, ts: number): { detections: MpDetection[] }; close(): void } + +export default function PasFotoCamera({ + photoW, + photoH, + lang = 'en', + onCapture, + onCancel, +}: { + photoW: number; + photoH: number; + lang?: Lang; + onCapture: (file: File) => void; + onCancel: () => void; +}) { + const t = TR[lang] ?? TR.en; + const { videoRef, stream, error, hasMultiple, facingMode, start, stop, switchCamera } = useCamera(); + const fileInputRef = useRef(null); + const detectorRef = useRef(null); + const rafRef = useRef(null); + const lastDetect = useRef(0); + const busyRef = useRef(false); + + const [status, setStatus] = useState('loading'); + const [detectFailed, setDetectFailed] = useState(false); + const [auto, setAuto] = useState(true); + const [countdown, setCountdown] = useState(null); + + // Selfie-first: open the front camera for a self-taken ID photo. + useEffect(() => { void start('user'); return () => stop(); }, [start, stop]); + + useEffect(() => { + if (stream && videoRef.current) videoRef.current.play().catch(() => {}); + }, [stream, videoRef]); + + const capture = useCallback(async () => { + const video = videoRef.current; + if (!video || busyRef.current) return; + busyRef.current = true; + try { + // Draws the raw frame — a mirrored preview still saves an unmirrored photo. + const file = await frameToFile(video, 'pas-foto-camera.jpg'); + stop(); + onCapture(file); + } catch { + busyRef.current = false; + } + }, [videoRef, stop, onCapture]); + + // Load the face detector (same model the Face Blur tool already ships). + useEffect(() => { + let alive = true; + (async () => { + const { FilesetResolver, FaceDetector } = await import('@mediapipe/tasks-vision'); + const vision = await FilesetResolver.forVisionTasks(new URL('/models/mediapipe/wasm', location.origin).href); + const detector = await FaceDetector.createFromOptions(vision, { + baseOptions: { modelAssetPath: new URL('/models/mediapipe/blaze_face_short_range.tflite', location.origin).href }, + runningMode: 'VIDEO', + minDetectionConfidence: 0.4, + }); + if (!alive) { detector.close(); return; } + detectorRef.current = detector as unknown as MpDetector; + })().catch(() => { if (alive) { setDetectFailed(true); setStatus('manual'); } }); + return () => { alive = false; detectorRef.current?.close(); detectorRef.current = null; }; + }, []); + + // Live framing feedback against the crop the photo will actually use. + useEffect(() => { + const tick = () => { + rafRef.current = requestAnimationFrame(tick); + const video = videoRef.current; + const detector = detectorRef.current; + if (!video || !detector || video.readyState < 2 || !video.videoWidth) return; + const now = performance.now(); + if (now - lastDetect.current < DETECT_MS) return; + lastDetect.current = now; + try { + const res = detector.detectForVideo(video, now); + const box = res.detections?.[0]?.boundingBox; + const crop = coverCropRect(video.videoWidth, video.videoHeight, photoW, photoH); + const face: FaceBox | null = box + ? { x: box.originX - crop.x, y: box.originY - crop.y, w: box.width, h: box.height } + : null; + setStatus(framingFeedback(face, crop.w, crop.h)); + } catch { + // A transient detector error shouldn't kill the preview. + } + }; + rafRef.current = requestAnimationFrame(tick); + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; + }, [videoRef, photoW, photoH]); + + // Auto-capture: count down only while the framing stays good. + useEffect(() => { + if (!auto || detectFailed || status !== 'ok') { setCountdown(null); return; } + setCountdown(COUNTDOWN_FROM); + let n = COUNTDOWN_FROM; + const id = setInterval(() => { + n -= 1; + if (n <= 0) { clearInterval(id); setCountdown(null); void capture(); } + else setCountdown(n); + }, 1000); + return () => clearInterval(id); + }, [auto, status, detectFailed, capture]); + + const onFallbackFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { stop(); onCapture(file); } + }; + + const cancel = () => { stop(); onCancel(); }; + + // Crop region as percentages so the guide sits exactly where the photo crops. + const video = videoRef.current; + const crop = video?.videoWidth + ? coverCropRect(video.videoWidth, video.videoHeight, photoW, photoH) + : null; + const cropStyle = crop && video + ? { + left: `${(crop.x / video.videoWidth) * 100}%`, + top: `${(crop.y / video.videoHeight) * 100}%`, + width: `${(crop.w / video.videoWidth) * 100}%`, + height: `${(crop.h / video.videoHeight) * 100}%`, + } + : { left: '0%', top: '0%', width: '100%', height: '100%' }; + + const good = status === 'ok'; + + if (error) { + return ( +
+ {error.message} +
+ + +
+ +
+ ); + } + + return ( +
+
+
+ +

+ {t.status[status]} +

+ + {detectFailed && {t.detectFailed}} + +
+ + {hasMultiple && } + + +
+ + {!detectFailed && ( + + )} + +

{t.hint}

+

{t.privacy}

+ + +
+ ); +} diff --git a/src/islands/media/VideoPlayer.tsx b/src/islands/media/VideoPlayer.tsx index 0b734d1..0479f39 100644 --- a/src/islands/media/VideoPlayer.tsx +++ b/src/islands/media/VideoPlayer.tsx @@ -72,7 +72,8 @@ export default function VideoPlayer({ lang = 'en' }: { lang?: Lang }) { applySubs(raw, 0); }; - useEffect(() => { if (subText) applySubs(subText, subOffset); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [subOffset]); + // Re-time the loaded subtitles whenever the sync offset changes. + useEffect(() => { if (subText) applySubs(subText, subOffset); }, [subOffset]); useEffect(() => () => { if (subUrl.current) URL.revokeObjectURL(subUrl.current); }, []); const play = async () => { try { await videoRef.current?.play(); } catch { /* blocked */ } }; diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 789bded..3ed5ada 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -1976,16 +1976,19 @@ const en: Record = { ], }, 'pas-foto': { - title: 'Free Pas Foto Maker — 2x3, 3x4 & 4x6 ID Photos to Print', - description: 'Make a print-ready pas foto (ID photo) online: remove the background, set a red, blue or white color, choose 2x3, 3x4 or 4x6 cm, and download a PDF tiled for printing. Runs in your browser — nothing is uploaded.', - intro: 'This free pas foto maker turns a normal portrait into a print-ready ID photo. It removes the background on your device, replaces it with the red, blue or white you need, frames the photo to a standard 2x3, 3x4 or 4x6 cm size, and lays out multiple copies on a 4R photo sheet or A4 page as a PDF you can print at a photo shop or at home. Your photo never leaves your browser.', + title: 'Free Pas Foto Maker — Take or Upload, 2x3, 3x4 & 4x6 to Print', + description: 'Make a print-ready pas foto (ID photo) online: shoot with your camera or upload, remove the background, set red, blue or white, pick 2x3, 3x4 or 4x6 cm, and download a printable PDF. Nothing is uploaded.', + intro: 'This free pas foto maker turns a portrait into a print-ready ID photo — and if you don’t have a photo yet, you can take one right here. The camera shows a live passport framing guide and tells you to move closer, further back or centre your head, then captures and automatically aligns your head to the guide. It removes the background on your device, replaces it with the red, blue or white you need, frames the photo to a standard 2x3, 3x4 or 4x6 cm size, and lays out multiple copies on a 4R photo sheet or A4 page as a PDF you can print at a photo shop or at home. Your photo and camera never leave your browser.', howTo: [ - 'Drop or paste a clear, front-facing portrait photo.', + 'Choose “Take a photo” to use your camera, or drop/paste a front-facing portrait.', + 'With the camera, line your head up with the oval — it counts down and shoots when the framing is right.', 'Keep “Remove background” on and pick a background color — red, blue, white or custom.', - 'Choose the size (2x3, 3x4 or 4x6 cm) and use zoom and position to frame the face.', + 'Choose the size (2x3, 3x4 or 4x6 cm); the head is aligned automatically, and zoom/position fine-tune it.', 'Pick a print sheet (4R or A4) and click Generate — download the PDF and print it.', ], faqs: [ + { q: 'Can I take the photo with my phone camera?', a: 'Yes. Choose “Take a photo” and the front camera opens with a live passport framing guide. On-device face detection tells you to move closer, back or centre yourself, and can auto-capture with a 3-2-1 countdown once you are framed correctly.' }, + { q: 'Does it crop my head correctly by itself?', a: 'Yes. After you take or upload a photo, the head is detected and the zoom and vertical position are set automatically so your crown and chin land on the passport guide lines. You can still adjust both sliders by hand.' }, { q: 'What are the standard pas foto sizes?', a: 'The common Indonesian sizes are 2x3, 3x4 and 4x6 centimetres. This tool renders each at 300 DPI so the print is sharp, and tiles as many copies as fit on the sheet.' }, { q: 'Can I set a red or blue background?', a: 'Yes. The background is removed automatically and replaced with red, blue or white presets, or any custom color you pick — the usual requirement for ID and document photos.' }, { q: 'Are my photos uploaded anywhere?', a: 'No. Background removal and layout run entirely in your browser using on-device models, so your photo stays private and is never uploaded.' }, @@ -4913,16 +4916,19 @@ const id: Record = { ], }, 'pas-foto': { - title: 'Tool Pas Foto Gratis — Buat Pas Foto 2x3, 3x4 & 4x6 Siap Cetak', - description: 'Buat pas foto siap cetak online: hapus latar belakang, atur warna merah, biru, atau putih, pilih ukuran 2x3, 3x4, atau 4x6 cm, dan unduh PDF yang sudah disusun untuk dicetak. Berjalan di browser Anda — tidak ada yang diunggah.', - intro: 'Tool pas foto gratis ini mengubah foto potret biasa menjadi pas foto siap cetak. Latar belakang dihapus di perangkat Anda, diganti dengan warna merah, biru, atau putih sesuai kebutuhan, foto dibingkai ke ukuran standar 2x3, 3x4, atau 4x6 cm, lalu beberapa salinan disusun dalam satu lembar 4R atau A4 sebagai PDF yang bisa Anda cetak di studio foto atau di rumah. Foto Anda tidak pernah meninggalkan browser.', + title: 'Tool Pas Foto Gratis — Foto Langsung atau Unggah, 2x3, 3x4 & 4x6', + description: 'Buat pas foto siap cetak online: ambil langsung dengan kamera atau unggah, hapus latar belakang, atur merah/biru/putih, pilih 2x3, 3x4, atau 4x6 cm, dan unduh PDF siap cetak. Tidak ada yang diunggah.', + intro: 'Tool pas foto gratis ini mengubah foto potret menjadi pas foto siap cetak — dan jika Anda belum punya fotonya, Anda bisa mengambilnya langsung di sini. Kamera menampilkan panduan bingkai paspor secara langsung dan memberi tahu Anda untuk mendekat, menjauh, atau menengahkan kepala, lalu mengambil foto dan menyejajarkan kepala Anda ke panduan secara otomatis. Latar belakang dihapus di perangkat Anda, diganti dengan warna merah, biru, atau putih sesuai kebutuhan, foto dibingkai ke ukuran standar 2x3, 3x4, atau 4x6 cm, lalu beberapa salinan disusun dalam satu lembar 4R atau A4 sebagai PDF yang bisa Anda cetak di studio foto atau di rumah. Foto dan kamera Anda tidak pernah meninggalkan browser.', howTo: [ - 'Letakkan atau tempel foto potret menghadap depan yang jelas.', + 'Pilih “Ambil foto” untuk memakai kamera, atau letakkan/tempel foto potret menghadap depan.', + 'Dengan kamera, sejajarkan kepala dengan oval — hitung mundur berjalan dan foto diambil saat bingkainya pas.', 'Biarkan “Hapus latar belakang” aktif dan pilih warna latar — merah, biru, putih, atau kustom.', - 'Pilih ukuran (2x3, 3x4, atau 4x6 cm) lalu gunakan zoom dan posisi untuk membingkai wajah.', + 'Pilih ukuran (2x3, 3x4, atau 4x6 cm); kepala disejajarkan otomatis, zoom dan posisi untuk penyesuaian halus.', 'Pilih lembar cetak (4R atau A4) dan klik Buat — unduh PDF-nya lalu cetak.', ], faqs: [ + { q: 'Bisakah mengambil foto dengan kamera ponsel?', a: 'Bisa. Pilih “Ambil foto” dan kamera depan terbuka dengan panduan bingkai paspor langsung. Deteksi wajah di perangkat memberi tahu Anda untuk mendekat, menjauh, atau menengahkan diri, dan bisa memotret otomatis dengan hitungan mundur 3-2-1 saat posisi sudah pas.' }, + { q: 'Apakah kepala saya dipotong dengan benar otomatis?', a: 'Ya. Setelah Anda mengambil atau mengunggah foto, wajah dideteksi lalu zoom dan posisi vertikal diatur otomatis agar puncak kepala dan dagu jatuh tepat pada garis panduan paspor. Kedua slider tetap bisa Anda sesuaikan manual.' }, { q: 'Apa saja ukuran pas foto standar?', a: 'Ukuran umum di Indonesia adalah 2x3, 3x4, dan 4x6 sentimeter. Tool ini merender setiap ukuran pada 300 DPI agar hasil cetak tajam, dan menyusun sebanyak mungkin salinan yang muat dalam lembar.' }, { q: 'Bisakah mengatur latar merah atau biru?', a: 'Ya. Latar belakang dihapus otomatis dan diganti dengan preset merah, biru, atau putih, atau warna kustom pilihan Anda — sesuai kebutuhan umum foto identitas dan dokumen.' }, { q: 'Apakah foto saya diunggah ke suatu tempat?', a: 'Tidak. Penghapusan latar belakang dan penyusunan berjalan sepenuhnya di browser Anda menggunakan model di perangkat, jadi foto Anda tetap privat dan tidak pernah diunggah.' }, diff --git a/src/tools/image/foto-align.lib.test.ts b/src/tools/image/foto-align.lib.test.ts new file mode 100644 index 0000000..3392a2e --- /dev/null +++ b/src/tools/image/foto-align.lib.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { framingFeedback, alignTransform, headFromFace, coverCropRect, HEAD_TO_FACE, type FaceBox } from './foto-align.lib'; +import { HEAD_GUIDE } from './pas-foto.lib'; + +const FRAME_W = 600; +const FRAME_H = 800; + +/** A face box that sits exactly on the guide for a FRAME_W×FRAME_H frame. */ +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 }; +} + +describe('headFromFace', () => { + it('extends the detector box upward to the crown', () => { + const face: FaceBox = { x: 100, y: 200, w: 80, 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); + }); +}); + +describe('framingFeedback', () => { + it('accepts a correctly framed face', () => { + expect(framingFeedback(perfectFace(), FRAME_W, FRAME_H)).toBe('ok'); + }); + + it('reports no face when nothing is detected', () => { + expect(framingFeedback(null, FRAME_W, FRAME_H)).toBe('no-face'); + expect(framingFeedback({ x: 0, y: 0, w: 0, h: 0 }, FRAME_W, FRAME_H)).toBe('no-face'); + }); + + it('detects too close and too far', () => { + const f = perfectFace(); + const big = { ...f, h: f.h * 1.5, w: f.w * 1.5 }; + const small = { ...f, h: f.h * 0.5, w: f.w * 0.5 }; + expect(framingFeedback(big, FRAME_W, FRAME_H)).toBe('too-close'); + expect(framingFeedback(small, FRAME_W, FRAME_H)).toBe('too-far'); + }); + + it('tolerates a small size difference', () => { + const f = perfectFace(); + expect(framingFeedback({ ...f, h: f.h * 1.05 }, FRAME_W, FRAME_H)).toBe('ok'); + expect(framingFeedback({ ...f, h: f.h * 0.95 }, FRAME_W, FRAME_H)).toBe('ok'); + }); + + it('detects an off-centre face', () => { + const f = perfectFace(); + expect(framingFeedback({ ...f, x: f.x + FRAME_W * 0.2 }, FRAME_W, FRAME_H)).toBe('off-center'); + expect(framingFeedback({ ...f, x: f.x - FRAME_W * 0.2 }, FRAME_W, FRAME_H)).toBe('off-center'); + }); + + it('detects a head that is too high or too low in the frame', () => { + const f = perfectFace(); + expect(framingFeedback({ ...f, y: f.y - FRAME_H * 0.2 }, FRAME_W, FRAME_H)).toBe('too-high'); + expect(framingFeedback({ ...f, y: f.y + FRAME_H * 0.2 }, FRAME_W, FRAME_H)).toBe('too-low'); + }); + + it('checks size before position (size is the more useful hint)', () => { + const f = perfectFace(); + const bigAndOff = { ...f, h: f.h * 2, w: f.w * 2, x: 0 }; + expect(framingFeedback(bigAndOff, FRAME_W, FRAME_H)).toBe('too-close'); + }); + + it('honours custom tolerances', () => { + const f = perfectFace(); + const slightlyBig = { ...f, h: f.h * 1.1 }; + expect(framingFeedback(slightlyBig, FRAME_W, FRAME_H)).toBe('ok'); + expect(framingFeedback(slightlyBig, FRAME_W, FRAME_H, { sizeTolerance: 0.05 })).toBe('too-close'); + }); +}); + +describe('alignTransform', () => { + /** Reproduce the island's compositor to verify where the head lands. */ + function composeCrown(face: FaceBox, imgW: number, imgH: number, photoW: number, photoH: number) { + const { zoom, offsetY } = alignTransform(face, imgW, imgH, photoW, photoH); + const cover = Math.max(photoW / imgW, photoH / imgH); + const s = cover * zoom; + const dh = imgH * s; + const y = (photoH - dh) / 2 + offsetY * photoH; + const head = headFromFace(face); + return { + crown: y + head.crownY * s, + chin: y + head.chinY * s, + zoom, + offsetY, + }; + } + + // zoom/offset are rounded to 3dp for the sliders, so allow sub-pixel drift. + const within1px = (actual: number, expected: number) => + expect(Math.abs(actual - expected)).toBeLessThan(1); + + it('puts the crown and chin on the guide lines', () => { + const imgW = 900, imgH = 1200; + const face: FaceBox = { x: 350, y: 300, w: 200, h: 260 }; + const photoW = 300, photoH = 400; // 3×4 ratio + const r = composeCrown(face, imgW, imgH, photoW, photoH); + within1px(r.crown, HEAD_GUIDE.crown * photoH); + within1px(r.chin, HEAD_GUIDE.chin * photoH); + }); + + it('zooms in for a small (distant) face', () => { + const small: FaceBox = { x: 430, y: 500, w: 60, h: 80 }; + const big: FaceBox = { x: 300, y: 300, w: 300, h: 400 }; + const zSmall = alignTransform(small, 900, 1200, 300, 400).zoom; + const zBig = alignTransform(big, 900, 1200, 300, 400).zoom; + expect(zSmall).toBeGreaterThan(zBig); + }); + + it('clamps zoom and offset to the slider ranges', () => { + const tiny: FaceBox = { x: 440, y: 580, w: 10, h: 12 }; + 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); + }); + + it('works for a landscape source photo', () => { + const imgW = 1600, imgH = 900; + const face: FaceBox = { x: 700, y: 200, w: 180, h: 240 }; + const r = composeCrown(face, imgW, imgH, 300, 400); + within1px(r.crown, HEAD_GUIDE.crown * 400); + }); + + it('adapts to a different photo aspect (4×6 vs 3×4)', () => { + const face: FaceBox = { x: 350, y: 300, w: 200, h: 260 }; + const a = composeCrown(face, 900, 1200, 400, 600); + within1px(a.crown, HEAD_GUIDE.crown * 600); + within1px(a.chin, HEAD_GUIDE.chin * 600); + }); + + it('returns rounded, slider-friendly values', () => { + const r = alignTransform({ x: 350, y: 300, w: 200, h: 260 }, 900, 1200, 300, 400); + expect(String(r.zoom).split('.')[1]?.length ?? 0).toBeLessThanOrEqual(3); + expect(String(r.offsetY).split('.')[1]?.length ?? 0).toBeLessThanOrEqual(3); + }); +}); + +describe('coverCropRect', () => { + it('crops the sides of a wide frame for a portrait photo', () => { + // 4:3 video, 3:4 photo → keeps full height, crops width to 720. + const r = coverCropRect(1280, 960, 300, 400); + expect(r.h).toBeCloseTo(960, 5); + expect(r.w).toBeCloseTo(720, 5); + expect(r.x).toBeCloseTo(280, 5); + expect(r.y).toBeCloseTo(0, 5); + }); + + it('crops top and bottom when the source is narrower than the photo', () => { + const r = coverCropRect(600, 1200, 400, 300); // landscape photo from a tall frame + expect(r.w).toBeCloseTo(600, 5); + expect(r.h).toBeCloseTo(450, 5); + expect(r.y).toBeCloseTo(375, 5); + }); + + it('returns the whole frame when aspects match', () => { + const r = coverCropRect(300, 400, 300, 400); + expect(r).toMatchObject({ x: 0, y: 0, w: 300, h: 400 }); + }); +}); diff --git a/src/tools/image/foto-align.lib.ts b/src/tools/image/foto-align.lib.ts new file mode 100644 index 0000000..49e25b8 --- /dev/null +++ b/src/tools/image/foto-align.lib.ts @@ -0,0 +1,129 @@ +/** + * Framing feedback and automatic head alignment for the ID-photo (pas foto) + * camera flow. Pure and framework-free: the island feeds in a detected face + * box, this decides what to tell the user and what zoom/offset makes the head + * land on the passport guide. + * + * Coordinates are pixels in the source frame (video frame or still image). + */ +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. + */ +export const HEAD_TO_FACE = 1.4; + +/** 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 }; +} + +export type FramingStatus = 'ok' | 'no-face' | 'too-close' | 'too-far' | 'off-center' | 'too-high' | 'too-low'; + +export interface FramingOptions { + /** Tolerance on head height as a fraction of the target (default ±18%). */ + sizeTolerance?: number; + /** Horizontal tolerance as a fraction of frame width (default 8%). */ + centerTolerance?: number; + /** Vertical tolerance on the crown as a fraction of frame height (default 10%). */ + verticalTolerance?: number; +} + +/** + * Compare a detected face against the passport guide for a frame of the given + * size, and say what the user should change. Checks size first (the most + * common problem), then centring, then height. + */ +export function framingFeedback( + face: FaceBox | null, + frameW: number, + frameH: number, + opts: FramingOptions = {}, +): FramingStatus { + if (!face || face.w <= 0 || face.h <= 0) return 'no-face'; + const { sizeTolerance = 0.18, centerTolerance = 0.08, verticalTolerance = 0.1 } = opts; + + const { crownY, headH, cx } = headFromFace(face); + const targetHeadH = (HEAD_GUIDE.chin - HEAD_GUIDE.crown) * frameH; + + const sizeRatio = headH / targetHeadH; + if (sizeRatio > 1 + sizeTolerance) return 'too-close'; + if (sizeRatio < 1 - sizeTolerance) return 'too-far'; + + if (Math.abs(cx - frameW / 2) > centerTolerance * frameW) return 'off-center'; + + const targetCrownY = HEAD_GUIDE.crown * frameH; + const dy = crownY - targetCrownY; + if (dy < -verticalTolerance * frameH) return 'too-high'; + if (dy > verticalTolerance * frameH) return 'too-low'; + + return 'ok'; +} + +/** + * The centred region of a source frame that a "cover" fit keeps for a photo of + * the given aspect — i.e. exactly what the preview shows at zoom 1, offset 0. + * The live camera guide is drawn over this rect so what you frame is what you get. + */ +export function coverCropRect(srcW: number, srcH: number, photoW: number, photoH: number): { x: number; y: number; w: number; h: number } { + const cover = Math.max(photoW / srcW, photoH / srcH); + const w = photoW / cover; + const h = photoH / cover; + return { x: (srcW - w) / 2, y: (srcH - h) / 2, w, h }; +} + +export interface AlignTransform { zoom: number; offsetY: number } + +/** + * Compute the {zoom, offsetY} that the preview compositor needs so the + * detected head lands on the passport guide. + * + * The compositor scales the image to *cover* a W×H photo frame, multiplies by + * `zoom`, centres it, then shifts it by `offsetY * H`. This inverts that: + * pick the zoom that makes the head the target height, then the offset that + * puts the crown on the guide line. + */ +export function alignTransform( + face: FaceBox, + imgW: number, + imgH: number, + photoW: number, + photoH: number, + limits: { minZoom?: number; maxZoom?: number } = {}, +): AlignTransform { + const { minZoom = 0.5, maxZoom = 3 } = limits; + // Work in the photo frame's own pixels; only ratios matter. + const W = photoW; + const H = photoH; + const cover = Math.max(W / imgW, H / imgH); + + const { crownY, headH, cx } = headFromFace(face); + const targetHeadH = (HEAD_GUIDE.chin - HEAD_GUIDE.crown) * H; + + // headH scales with (cover * zoom), so solve for the zoom that matches. + const rawZoom = targetHeadH / (headH * cover); + const zoom = Math.min(maxZoom, Math.max(minZoom, rawZoom)); + + const s = cover * zoom; + const dh = imgH * s; + // 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. + const targetCrown = HEAD_GUIDE.crown * H; + const offsetY = Math.max(-0.5, Math.min(0.5, (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. + void cx; + + return { zoom: Number(zoom.toFixed(3)), offsetY: Number(offsetY.toFixed(3)) }; +}