From bb11e086f92ba4f873802cb9ad31255b5ca19634 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Tue, 14 Apr 2026 15:41:31 +0800 Subject: [PATCH] fix(passport-photo): replace circular compliance checks with source photo validation Old checks validated head height and eye position against the auto-computed crop, so they always passed. New checks validate the source photo itself: face centered, head level (eyes same height), looking straight (nose between eyes), and face size (large enough for quality crop). Adds detail text on failed checks and updates the canvas overlay to show eye-level and center indicators instead of the old crown/chin/eye lines. --- .../tools/passport-photo-settings.tsx | 121 +++++++++--------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/apps/web/src/components/tools/passport-photo-settings.tsx b/apps/web/src/components/tools/passport-photo-settings.tsx index 31d43000..18134dbb 100644 --- a/apps/web/src/components/tools/passport-photo-settings.tsx +++ b/apps/web/src/components/tools/passport-photo-settings.tsx @@ -55,6 +55,7 @@ interface GenerateResult { interface ComplianceCheck { label: string; pass: boolean; + detail: string; } // ── Zustand store ───────────────────────────────────────────────── @@ -261,35 +262,44 @@ function computeCropRegion( return { leftX, topY, photoWidthPx, photoHeightPx }; } -function runComplianceChecks( - doc: PassportDocumentSpec, - landmarks: FaceLandmarks, - imageHeight: number, - adjustY: number, -): ComplianceCheck[] { - const crownYPx = (landmarks.crown.y + adjustY) * imageHeight; - const chinYPx = (landmarks.chin.y + adjustY) * imageHeight; - const headHeightPx = chinYPx - crownYPx; - const targetHeadRatio = (doc.headHeightMin + doc.headHeightMax) / 2; - const photoHeightPx = headHeightPx / targetHeadRatio; - - const headFraction = headHeightPx / photoHeightPx; - const headOk = headFraction >= doc.headHeightMin && headFraction <= doc.headHeightMax; - - const eyeYPx = (landmarks.eyeCenter.y + adjustY) * imageHeight; - const topY = eyeYPx - photoHeightPx * (1 - doc.eyeLineFromBottom); - const eyeFromBottom = 1 - (eyeYPx - topY) / photoHeightPx; - const eyeTolerance = 0.05; - const eyeOk = - eyeFromBottom >= doc.eyeLineFromBottom - eyeTolerance && - eyeFromBottom <= doc.eyeLineFromBottom + eyeTolerance; - +function runComplianceChecks(landmarks: FaceLandmarks): ComplianceCheck[] { + // 1. Face centered - is the face horizontally centered in the source photo? const centerOk = Math.abs(landmarks.faceCenterX - 0.5) < 0.08; + // 2. Head level - are the eyes at the same height (no tilt)? + const eyeTilt = Math.abs(landmarks.leftEye.y - landmarks.rightEye.y); + const levelOk = eyeTilt < 0.02; + + // 3. Looking straight - is the nose centered between the eyes (not turned)? + const eyeMidX = (landmarks.leftEye.x + landmarks.rightEye.x) / 2; + const noseOffset = Math.abs(landmarks.nose.x - eyeMidX); + const straightOk = noseOffset < 0.03; + + // 4. Face size - is the face large enough for a quality crop? + const faceHeight = landmarks.chin.y - landmarks.crown.y; + const sizeOk = faceHeight > 0.15; + return [ - { label: "Head height", pass: headOk }, - { label: "Eye position", pass: eyeOk }, - { label: "Face centered", pass: centerOk }, + { + label: "Face centered", + pass: centerOk, + detail: "Face is off-center. Crop or reposition your photo.", + }, + { + label: "Head level", + pass: levelOk, + detail: "Head is tilted. Straighten your head or rotate the photo.", + }, + { + label: "Looking straight", + pass: straightOk, + detail: "Face is turned sideways. Look directly at the camera.", + }, + { + label: "Face size", + pass: sizeOk, + detail: "Face is too small. Get closer to the camera or crop tighter.", + }, ]; } @@ -893,9 +903,7 @@ export function PassportPhotoPreview() { const pxDims = getPixelDimensions(docSpec); // Compliance checks - const complianceChecks = analyzeResult - ? runComplianceChecks(docSpec, analyzeResult.landmarks, analyzeResult.imageHeight, adjustY) - : []; + const complianceChecks = analyzeResult ? runComplianceChecks(analyzeResult.landmarks) : []; // Render canvas const renderCanvas = useCallback(() => { @@ -958,43 +966,18 @@ export function PassportPhotoPreview() { ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, canvasDisplayWidth, canvasDisplayHeight); // Compliance overlay - const checks = runComplianceChecks(docSpec, landmarks, imageHeight, adjustY); - const headOk = checks[0].pass; - const eyeOk = checks[1].pass; - const centerOk = checks[2].pass; + const checks = runComplianceChecks(landmarks); + const centerOk = checks[0].pass; + const levelOk = checks[1].pass; ctx.setLineDash([6, 4]); ctx.lineWidth = 1.5; - // Helper: convert original-image Y coord to canvas Y coord (zoom-aware) + // Helper: convert original-image coords to canvas coords (zoom-aware) const toCanvasY = (origY: number) => ((origY - zoomedTop) / zoomedH) * canvasDisplayHeight; const toCanvasX = (origX: number) => ((origX - zoomedLeft) / zoomedW) * canvasDisplayWidth; - // Crown line - const crownYCanvas = toCanvasY((landmarks.crown.y + adjustY) * imageHeight); - ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; - ctx.beginPath(); - ctx.moveTo(0, crownYCanvas); - ctx.lineTo(canvasDisplayWidth, crownYCanvas); - ctx.stroke(); - - // Chin line - const chinYCanvas = toCanvasY((landmarks.chin.y + adjustY) * imageHeight); - ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; - ctx.beginPath(); - ctx.moveTo(0, chinYCanvas); - ctx.lineTo(canvasDisplayWidth, chinYCanvas); - ctx.stroke(); - - // Eye line - const eyeYCanvas = toCanvasY((landmarks.eyeCenter.y + adjustY) * imageHeight); - ctx.strokeStyle = eyeOk ? "#3b82f6" : "#ef4444"; - ctx.beginPath(); - ctx.moveTo(0, eyeYCanvas); - ctx.lineTo(canvasDisplayWidth, eyeYCanvas); - ctx.stroke(); - - // Center line (vertical) + // Center line (vertical) - face centered check const centerXCanvas = toCanvasX((landmarks.faceCenterX + adjustX) * imageWidth); ctx.strokeStyle = centerOk ? "#f59e0b" : "#ef4444"; ctx.beginPath(); @@ -1002,6 +985,17 @@ export function PassportPhotoPreview() { ctx.lineTo(centerXCanvas, canvasDisplayHeight); ctx.stroke(); + // Eye-level indicator - line connecting left eye to right eye + const leftEyeX = toCanvasX((landmarks.leftEye.x + adjustX) * imageWidth); + const leftEyeY = toCanvasY((landmarks.leftEye.y + adjustY) * imageHeight); + const rightEyeX = toCanvasX((landmarks.rightEye.x + adjustX) * imageWidth); + const rightEyeY = toCanvasY((landmarks.rightEye.y + adjustY) * imageHeight); + ctx.strokeStyle = levelOk ? "#22c55e" : "#ef4444"; + ctx.beginPath(); + ctx.moveTo(leftEyeX, leftEyeY); + ctx.lineTo(rightEyeX, rightEyeY); + ctx.stroke(); + ctx.setLineDash([]); }, [analyzeResult, docSpec, bgColor, adjustX, adjustY, zoom]); @@ -1167,15 +1161,18 @@ export function PassportPhotoPreview() { {/* Compliance checklist */} {complianceChecks.length > 0 && ( -
+
{complianceChecks.map((check) => (
{check.pass ? ( - + ) : ( - + )} {check.label} + {!check.pass && ( + - {check.detail} + )}
))}