From 2f11b9e101d4d5d594818b36c45653bdf9af75f7 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Tue, 14 Apr 2026 09:59:48 +0800 Subject: [PATCH] feat(passport-photo): SOTA passport photo maker with compliance validation (#64) * feat(passport-photo): add passport specs database and tool constants * feat(passport-photo): add MediaPipe FaceMesh landmark detection script * feat(passport-photo): add TypeScript bridge for face landmark detection * feat(passport-photo): add API routes with analyze and generate endpoints * fix(passport-photo): accept landmarks from request body and fix pixel coordinate conversion - Generate endpoint now accepts landmarks + imageWidth/imageHeight in request body instead of re-running AI face detection (makes generate phase instant) - Fixed bug where normalized landmark coordinates (0-1) were used directly as pixel values in crop computation - now properly multiplied by imgW/imgH - Fixed same bug in pipeline process function * feat(passport-photo): add UI component with live preview and compliance overlay --------- Co-authored-by: stirling-image --- apps/api/src/routes/tools/index.ts | 2 + apps/api/src/routes/tools/passport-photo.ts | 538 ++++++++++++ .../tools/passport-photo-settings.tsx | 790 ++++++++++++++++++ apps/web/src/lib/tool-registry.tsx | 6 + packages/ai/python/face_landmarks.py | 130 +++ packages/ai/src/face-landmarks.ts | 57 ++ packages/ai/src/index.ts | 2 + packages/shared/src/constants.ts | 458 ++++++++++ packages/shared/src/i18n/en.ts | 5 + 9 files changed, 1988 insertions(+) create mode 100644 apps/api/src/routes/tools/passport-photo.ts create mode 100644 apps/web/src/components/tools/passport-photo-settings.tsx create mode 100644 packages/ai/python/face_landmarks.py create mode 100644 packages/ai/src/face-landmarks.ts diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index a3651a34..f4de90da 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -27,6 +27,7 @@ import { registerImageToPdf } from "./image-to-pdf.js"; import { registerInfo } from "./info.js"; import { registerNoiseRemoval } from "./noise-removal.js"; import { registerOcr } from "./ocr.js"; +import { registerPassportPhoto } from "./passport-photo.js"; import { registerPdfToImage } from "./pdf-to-image.js"; import { registerQrGenerate } from "./qr-generate.js"; import { registerRedEyeRemoval } from "./red-eye-removal.js"; @@ -138,6 +139,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "colorize", register: registerColorize }, { id: "enhance-faces", register: registerEnhanceFaces }, { id: "noise-removal", register: registerNoiseRemoval }, + { id: "passport-photo", register: registerPassportPhoto }, { id: "red-eye-removal", register: registerRedEyeRemoval }, { id: "restore-photo", register: registerRestorePhoto }, ]; diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts new file mode 100644 index 00000000..46f9dc03 --- /dev/null +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -0,0 +1,538 @@ +import { randomUUID } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { detectFaceLandmarks, removeBackground } from "@stirling-image/ai"; +import { PASSPORT_SPECS, PRINT_LAYOUTS } from "@stirling-image/shared"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { autoOrient } from "../../lib/auto-orient.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; +import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js"; +import { updateSingleFileProgress } from "../progress.js"; +import { registerToolProcessFn } from "../tool-factory.js"; + +const landmarkPointSchema = z.object({ x: z.number(), y: z.number() }); + +const landmarksSchema = z.object({ + leftEye: landmarkPointSchema, + rightEye: landmarkPointSchema, + eyeCenter: landmarkPointSchema, + chin: landmarkPointSchema, + forehead: landmarkPointSchema, + crown: landmarkPointSchema, + nose: landmarkPointSchema, + faceCenterX: z.number(), +}); + +const generateSettingsSchema = z.object({ + jobId: z.string(), + filename: z.string(), + countryCode: z.string(), + documentType: z.string().default("passport"), + bgColor: z.string().default("#FFFFFF"), + printLayout: z.string().default("none"), + adjustX: z.number().default(0), + adjustY: z.number().default(0), + landmarks: landmarksSchema, + imageWidth: z.number(), + imageHeight: z.number(), +}); + +/** + * Generate a print sheet that tiles passport photos onto standard paper. + * Returns JPEG buffer or null if layout is "none". + */ +async function generatePrintSheet( + photoBuffer: Buffer, + photoWidthMm: number, + photoHeightMm: number, + layoutId: string, +): Promise { + const layout = PRINT_LAYOUTS.find((l) => l.id === layoutId); + if (!layout || layout.id === "none") return null; + + const DPI = 300; + const MM_PER_INCH = 25.4; + const GUTTER_MM = 2; + + const paperWidthPx = Math.round((layout.width / MM_PER_INCH) * DPI); + const paperHeightPx = Math.round((layout.height / MM_PER_INCH) * DPI); + const photoWidthPx = Math.round((photoWidthMm / MM_PER_INCH) * DPI); + const photoHeightPx = Math.round((photoHeightMm / MM_PER_INCH) * DPI); + const gutterPx = Math.round((GUTTER_MM / MM_PER_INCH) * DPI); + + const cols = Math.floor((paperWidthPx + gutterPx) / (photoWidthPx + gutterPx)); + const rows = Math.floor((paperHeightPx + gutterPx) / (photoHeightPx + gutterPx)); + + if (cols < 1 || rows < 1) return null; + + // Center the grid on the paper + const gridWidth = cols * photoWidthPx + (cols - 1) * gutterPx; + const gridHeight = rows * photoHeightPx + (rows - 1) * gutterPx; + const offsetX = Math.round((paperWidthPx - gridWidth) / 2); + const offsetY = Math.round((paperHeightPx - gridHeight) / 2); + + // Resize photo to exact pixel dimensions + const resizedPhoto = await sharp(photoBuffer) + .resize(photoWidthPx, photoHeightPx, { fit: "fill" }) + .toBuffer(); + + // Build composite inputs + const composites: sharp.OverlayOptions[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + composites.push({ + input: resizedPhoto, + left: offsetX + col * (photoWidthPx + gutterPx), + top: offsetY + row * (photoHeightPx + gutterPx), + }); + } + } + + return sharp({ + create: { + width: paperWidthPx, + height: paperHeightPx, + channels: 3, + background: { r: 255, g: 255, b: 255 }, + }, + }) + .composite(composites) + .jpeg({ quality: 95 }) + .toBuffer(); +} + +/** + * Passport photo tool with two-phase flow: + * + * Phase 1 (POST /passport-photo/analyze): AI face detection + bg removal. + * Returns landmarks, preview, and caches images for generate phase. + * + * Phase 2 (POST /passport-photo/generate): Sharp crop/resize/tile. + * Uses cached images. No AI re-run. Fast response. + */ +export function registerPassportPhoto(app: FastifyInstance) { + // ── Phase 1: Analyze (face landmarks + bg removal) ──────────────── + app.post( + "/api/v1/tools/passport-photo/analyze", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + let clientJobId: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) chunks.push(chunk); + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "clientJobId") { + clientJobId = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + + try { + // Decode HEIC/HEIF before processing + if (validation.format === "heif") { + fileBuffer = await decodeHeic(fileBuffer); + const ext = filename.match(/\.[^.]+$/)?.[0]; + if (ext) filename = filename.slice(0, -ext.length) + ".png"; + } + + // Auto-orient to fix EXIF rotation + fileBuffer = await autoOrient(fileBuffer); + + request.log.info( + { toolId: "passport-photo", imageSize: fileBuffer.length }, + "Starting passport photo analysis", + ); + + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save original to workspace for generate phase + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Progress callback + const jobIdForProgress = clientJobId; + const onProgress = jobIdForProgress + ? (percent: number, stage: string) => { + updateSingleFileProgress({ + jobId: jobIdForProgress, + phase: "processing", + stage, + percent: Math.min(percent, 95), + }); + } + : undefined; + + // Step 1: Detect face landmarks (0-30% of progress) + const landmarkProgress = onProgress + ? (percent: number, stage: string) => { + onProgress(Math.round(percent * 0.3), stage); + } + : undefined; + + const landmarksResult = await detectFaceLandmarks(fileBuffer, landmarkProgress); + + if (!landmarksResult.faceDetected || !landmarksResult.landmarks) { + if (clientJobId) { + updateSingleFileProgress({ + jobId: clientJobId, + phase: "complete", + percent: 100, + }); + } + return reply.status(422).send({ + error: "No face detected", + details: + "Could not detect a face in the uploaded image. Please upload a clear, front-facing photo with good lighting.", + }); + } + + // Step 2: Remove background with birefnet-portrait (30-95%) + const bgProgress = onProgress + ? (percent: number, stage: string) => { + onProgress(30 + Math.round(percent * 0.65), stage); + } + : undefined; + + const bgRemovedBuffer = await removeBackground( + fileBuffer, + join(workspacePath, "output"), + { model: "birefnet-portrait" }, + bgProgress, + ); + + // Save bg-removed image to workspace + const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`; + await writeFile(join(workspacePath, "output", bgRemovedFilename), bgRemovedBuffer); + + // Create a smaller preview for fast transfer (max 800px wide) + const meta = await sharp(bgRemovedBuffer).metadata(); + const previewWidth = Math.min(meta.width ?? 800, 800); + const previewBuffer = await sharp(bgRemovedBuffer) + .resize({ width: previewWidth, withoutEnlargement: true }) + .png() + .toBuffer({ resolveWithObject: true }); + + const preview = previewBuffer.data.toString("base64"); + + if (clientJobId) { + updateSingleFileProgress({ + jobId: clientJobId, + phase: "complete", + percent: 100, + }); + } + + return reply.send({ + jobId, + filename, + preview, + previewWidth: previewBuffer.info.width, + previewHeight: previewBuffer.info.height, + landmarks: landmarksResult.landmarks, + imageWidth: landmarksResult.imageWidth, + imageHeight: landmarksResult.imageHeight, + }); + } catch (err) { + request.log.error({ err, toolId: "passport-photo" }, "Passport photo analysis failed"); + return reply.status(422).send({ + error: "Passport photo analysis failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); + + // ── Phase 2: Generate (crop + resize + tile) ───────────────────── + app.post( + "/api/v1/tools/passport-photo/generate", + async (request: FastifyRequest, reply: FastifyReply) => { + let parsed: z.infer; + try { + parsed = generateSettingsSchema.parse(request.body); + } catch (err) { + return reply.status(400).send({ + error: "Invalid settings", + details: err instanceof Error ? err.message : String(err), + }); + } + + const { + jobId, + filename, + countryCode, + documentType, + bgColor, + printLayout, + adjustX, + adjustY, + landmarks: rawLandmarks, + imageWidth: imgW, + imageHeight: imgH, + } = parsed; + + // Look up country spec + const countrySpec = PASSPORT_SPECS.find((s) => s.code === countryCode); + if (!countrySpec) { + return reply.status(400).send({ error: `Unknown country code: ${countryCode}` }); + } + + const docSpec = countrySpec.documents.find((d) => d.type === documentType); + if (!docSpec) { + return reply.status(400).send({ + error: `No ${documentType} spec found for ${countryCode}`, + }); + } + + try { + const workspacePath = getWorkspacePath(jobId); + const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`; + + const [bgRemovedBuffer, originalBuffer] = await Promise.all([ + readFile(join(workspacePath, "output", bgRemovedFilename)), + readFile(join(workspacePath, "input", filename)), + ]); + + // Convert normalized landmarks (0-1) to pixel coordinates + const crownYPx = (rawLandmarks.crown.y + adjustY) * imgH; + const chinYPx = (rawLandmarks.chin.y + adjustY) * imgH; + const eyeYPx = (rawLandmarks.eyeCenter.y + adjustY) * imgH; + const faceCenterXPx = (rawLandmarks.faceCenterX + adjustX) * imgW; + + // Compute crop region from landmarks + const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2; + const headHeightPx = chinYPx - crownYPx; + const photoHeightPx = headHeightPx / targetHeadRatio; + const aspectRatio = docSpec.width / docSpec.height; + const photoWidthPx = photoHeightPx * aspectRatio; + + // Position: eye line should be at eyeLineFromBottom from photo bottom + const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom); + const leftX = faceCenterXPx - photoWidthPx / 2; + + // Clamp to image bounds + const cropW = Math.min(Math.round(photoWidthPx), imgW); + const cropH = Math.min(Math.round(photoHeightPx), imgH); + let cropLeft = Math.max(0, Math.round(leftX)); + let cropTop = Math.max(0, Math.round(topY)); + if (cropLeft + cropW > imgW) cropLeft = imgW - cropW; + if (cropTop + cropH > imgH) cropTop = imgH - cropH; + cropLeft = Math.max(0, cropLeft); + cropTop = Math.max(0, cropTop); + + // Parse background color + const hex = bgColor.replace("#", ""); + const bgR = Number.parseInt(hex.slice(0, 2), 16); + const bgG = Number.parseInt(hex.slice(2, 4), 16); + const bgB = Number.parseInt(hex.slice(4, 6), 16); + + // Composite bg-removed onto colored background + const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata(); + const bgLayer = await sharp({ + create: { + width: bgRemovedMeta.width ?? imgW, + height: bgRemovedMeta.height ?? imgH, + channels: 4, + background: { r: bgR, g: bgG, b: bgB, alpha: 1 }, + }, + }) + .composite([{ input: bgRemovedBuffer, blend: "over" }]) + .png() + .toBuffer(); + + // Target pixel dimensions at 300 DPI + const MM_PER_INCH = 25.4; + const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi); + const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi); + + // Extract crop region and resize to target dimensions + const cropped = await sharp(bgLayer) + .extract({ + left: cropLeft, + top: cropTop, + width: cropW, + height: cropH, + }) + .resize(targetWidthPx, targetHeightPx, { fit: "fill" }) + .jpeg({ quality: 95 }) + .toBuffer(); + + // Save output + const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_passport.jpg`; + const outputPath = join(workspacePath, "output", outputFilename); + await writeFile(outputPath, cropped); + + const response: Record = { + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, + dimensions: { + widthMm: docSpec.width, + heightMm: docSpec.height, + widthPx: targetWidthPx, + heightPx: targetHeightPx, + dpi: docSpec.dpi, + }, + spec: { + country: countrySpec.name, + countryCode: countrySpec.code, + documentType: docSpec.type, + documentLabel: docSpec.label, + }, + }; + + // Generate print sheet if requested + if (printLayout !== "none") { + const printBuffer = await generatePrintSheet( + cropped, + docSpec.width, + docSpec.height, + printLayout, + ); + + if (printBuffer) { + const printFilename = `${filename.replace(/\.[^.]+$/, "")}_passport_print_${printLayout}.jpg`; + await writeFile(join(workspacePath, "output", printFilename), printBuffer); + response.printDownloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(printFilename)}`; + } + } + + return reply.send(response); + } catch (err) { + request.log.error({ err, toolId: "passport-photo" }, "Passport photo generation failed"); + return reply.status(422).send({ + error: "Passport photo generation failed", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); + + // ── Pipeline/batch registry ────────────────────────────────────── + const pipelineSettingsSchema = z.object({ + countryCode: z.string(), + documentType: z.string().default("passport"), + bgColor: z.string().default("#FFFFFF"), + printLayout: z.string().default("none"), + adjustX: z.number().default(0), + adjustY: z.number().default(0), + }); + + registerToolProcessFn({ + toolId: "passport-photo", + settingsSchema: pipelineSettingsSchema, + process: async (inputBuffer, settings, filename) => { + const s = settings as z.infer; + const orientedBuffer = await autoOrient(inputBuffer); + + // Step 1: Detect face landmarks + const landmarksResult = await detectFaceLandmarks(orientedBuffer); + if (!landmarksResult.faceDetected || !landmarksResult.landmarks) { + throw new Error( + "No face detected. Please upload a clear, front-facing photo with good lighting.", + ); + } + + const landmarks = landmarksResult.landmarks; + const imgW = landmarksResult.imageWidth; + const imgH = landmarksResult.imageHeight; + + // Step 2: Remove background + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + const bgRemovedBuffer = await removeBackground( + orientedBuffer, + join(workspacePath, "output"), + { + model: "birefnet-portrait", + }, + ); + + // Step 3: Look up spec and compute crop + const countrySpec = PASSPORT_SPECS.find((sp) => sp.code === s.countryCode); + if (!countrySpec) throw new Error(`Unknown country code: ${s.countryCode}`); + + const docSpec = countrySpec.documents.find((d) => d.type === s.documentType); + if (!docSpec) throw new Error(`No ${s.documentType} spec for ${s.countryCode}`); + + // Convert normalized landmarks (0-1) to pixel coordinates + const crownYPx = (landmarks.crown.y + s.adjustY) * imgH; + const chinYPx = (landmarks.chin.y + s.adjustY) * imgH; + const eyeYPx = (landmarks.eyeCenter.y + s.adjustY) * imgH; + const faceCenterXPx = (landmarks.faceCenterX + s.adjustX) * imgW; + + const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2; + const headHeightPx = chinYPx - crownYPx; + const photoHeightPx = headHeightPx / targetHeadRatio; + const aspectRatio = docSpec.width / docSpec.height; + const photoWidthPx = photoHeightPx * aspectRatio; + + const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom); + const leftX = faceCenterXPx - photoWidthPx / 2; + + const cropW = Math.min(Math.round(photoWidthPx), imgW); + const cropH = Math.min(Math.round(photoHeightPx), imgH); + let cropLeft = Math.max(0, Math.round(leftX)); + let cropTop = Math.max(0, Math.round(topY)); + if (cropLeft + cropW > imgW) cropLeft = imgW - cropW; + if (cropTop + cropH > imgH) cropTop = imgH - cropH; + cropLeft = Math.max(0, cropLeft); + cropTop = Math.max(0, cropTop); + + // Composite onto background + const hex = s.bgColor.replace("#", ""); + const bgR = Number.parseInt(hex.slice(0, 2), 16); + const bgG = Number.parseInt(hex.slice(2, 4), 16); + const bgB = Number.parseInt(hex.slice(4, 6), 16); + + const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata(); + const bgLayer = await sharp({ + create: { + width: bgRemovedMeta.width ?? imgW, + height: bgRemovedMeta.height ?? imgH, + channels: 4, + background: { r: bgR, g: bgG, b: bgB, alpha: 1 }, + }, + }) + .composite([{ input: bgRemovedBuffer, blend: "over" }]) + .png() + .toBuffer(); + + const MM_PER_INCH = 25.4; + const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi); + const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi); + + const result = await sharp(bgLayer) + .extract({ left: cropLeft, top: cropTop, width: cropW, height: cropH }) + .resize(targetWidthPx, targetHeightPx, { fit: "fill" }) + .jpeg({ quality: 95 }) + .toBuffer(); + + const stem = filename.replace(/\.[^.]+$/, ""); + return { buffer: result, filename: `${stem}_passport.jpg`, contentType: "image/jpeg" }; + }, + }); +} diff --git a/apps/web/src/components/tools/passport-photo-settings.tsx b/apps/web/src/components/tools/passport-photo-settings.tsx new file mode 100644 index 00000000..130467ef --- /dev/null +++ b/apps/web/src/components/tools/passport-photo-settings.tsx @@ -0,0 +1,790 @@ +import { + PASSPORT_SPECS, + type PassportDocumentSpec, + type PassportRegion, + type PassportSpec, + PRINT_LAYOUTS, +} from "@stirling-image/shared"; +import { + Check, + ChevronDown, + Download, + Loader2, + Move, + Printer, + Search, + UserCheck, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { formatHeaders } from "@/lib/api"; +import { useFileStore } from "@/stores/file-store"; + +// ── Types ────────────────────────────────────────────────────────── + +interface FaceLandmarks { + leftEye: { x: number; y: number }; + rightEye: { x: number; y: number }; + eyeCenter: { x: number; y: number }; + chin: { x: number; y: number }; + forehead: { x: number; y: number }; + crown: { x: number; y: number }; + nose: { x: number; y: number }; + faceCenterX: number; +} + +interface AnalyzeResult { + preview: string; // base64 PNG + landmarks: FaceLandmarks; + imageWidth: number; + imageHeight: number; + jobId: string; + filename: string; +} + +interface GenerateResult { + downloadUrl: string; + printDownloadUrl?: string; + dimensions: { width: number; height: number }; + spec: { country: string; document: string }; +} + +interface ComplianceCheck { + label: string; + pass: boolean; +} + +// ── Region groups ────────────────────────────────────────────────── + +const REGION_LABELS: Record = { + americas: "Americas", + europe: "Europe", + asia: "Asia", + "middle-east": "Middle East", + africa: "Africa", + oceania: "Oceania", +}; + +const REGION_ORDER: PassportRegion[] = [ + "americas", + "europe", + "asia", + "middle-east", + "africa", + "oceania", +]; + +function groupByRegion(): Map { + const groups = new Map(); + for (const r of REGION_ORDER) groups.set(r, []); + for (const spec of PASSPORT_SPECS) { + const list = groups.get(spec.region); + if (list) list.push(spec); + } + return groups; +} + +// ── Section label (matches codebase pattern) ─────────────────────── + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// ── Canvas helpers ───────────────────────────────────────────────── + +function computeCropRegion( + doc: PassportDocumentSpec, + landmarks: FaceLandmarks, + imageWidth: number, + imageHeight: number, + adjustX: number, + adjustY: number, +) { + const targetHeadRatio = (doc.headHeightMin + doc.headHeightMax) / 2; + + const crownYPx = (landmarks.crown.y + adjustY) * imageHeight; + const chinYPx = (landmarks.chin.y + adjustY) * imageHeight; + const eyeYPx = (landmarks.eyeCenter.y + adjustY) * imageHeight; + const faceCenterXPx = (landmarks.faceCenterX + adjustX) * imageWidth; + + const headHeightPx = chinYPx - crownYPx; + const photoHeightPx = headHeightPx / targetHeadRatio; + const photoWidthPx = photoHeightPx * (doc.width / doc.height); + + const topY = eyeYPx - photoHeightPx * (1 - doc.eyeLineFromBottom); + const leftX = faceCenterXPx - photoWidthPx / 2; + + 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; + + const centerOk = Math.abs(landmarks.faceCenterX - 0.5) < 0.08; + + return [ + { label: "Head height", pass: headOk }, + { label: "Eye position", pass: eyeOk }, + { label: "Face centered", pass: centerOk }, + ]; +} + +// ── Main component ───────────────────────────────────────────────── + +export function PassportPhotoSettings() { + const { files } = useFileStore(); + const { error } = useToolProcessor("passport-photo"); + + // Settings + const [countryCode, setCountryCode] = useState("US"); + const [documentType, setDocumentType] = useState("passport"); + const [bgColor, setBgColor] = useState("#FFFFFF"); + const [printLayout, setPrintLayout] = useState("4x6"); + + // Country search + const [searchQuery, setSearchQuery] = useState(""); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + // Drag adjustment + const [adjustX, setAdjustX] = useState(0); + const [adjustY, setAdjustY] = useState(0); + const [dragging, setDragging] = useState(false); + const dragStartRef = useRef<{ x: number; y: number; ax: number; ay: number } | null>(null); + + // Analysis result + const [analyzeResult, setAnalyzeResult] = useState(null); + const [analyzing, setAnalyzing] = useState(false); + const [analyzeError, setAnalyzeError] = useState(null); + + // Generate result + const [generateResult, setGenerateResult] = useState(null); + const [generating, setGenerating] = useState(false); + const [generateError, setGenerateError] = useState(null); + + // Canvas + const canvasRef = useRef(null); + const previewImgRef = useRef(null); + + // ── Derived state ───────────────────────────────────────────── + + const selectedSpec = PASSPORT_SPECS.find((s) => s.code === countryCode) ?? PASSPORT_SPECS[0]; + const docSpec = + selectedSpec.documents.find((d) => d.type === documentType) ?? selectedSpec.documents[0]; + const hasFile = files.length > 0; + + // Available doc types for selected country + const docTypes = selectedSpec.documents.map((d) => d.type); + const uniqueDocTypes = [...new Set(docTypes)]; + + // ── Close dropdown on outside click ──────────────────────────── + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + } + if (dropdownOpen) { + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + } + }, [dropdownOpen]); + + // ── Auto-select bg color when country changes ────────────────── + + useEffect(() => { + setBgColor(docSpec.bgColor); + }, [docSpec.bgColor]); + + // ── Ensure valid documentType when country changes ───────────── + + useEffect(() => { + if (!selectedSpec.documents.some((d) => d.type === documentType)) { + setDocumentType(selectedSpec.documents[0].type); + } + }, [selectedSpec, documentType]); + + // ── Analyze ──────────────────────────────────────────────────── + + const runAnalyze = useCallback(async (file: File) => { + setAnalyzing(true); + setAnalyzeError(null); + setAnalyzeResult(null); + setGenerateResult(null); + setAdjustX(0); + setAdjustY(0); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("settings", JSON.stringify({})); + + const headers = formatHeaders(); + const response = await fetch("/api/v1/tools/passport-photo/analyze", { + method: "POST", + headers, + body: formData, + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.details || body?.error || `Analysis failed: ${response.status}`); + } + + const result = await response.json(); + setAnalyzeResult(result); + } catch (err) { + setAnalyzeError(err instanceof Error ? err.message : "Face analysis failed"); + } finally { + setAnalyzing(false); + } + }, []); + + // ── Auto-analyze when files change ───────────────────────────── + + const analyzeRef = useRef(null); + + useEffect(() => { + if (!hasFile || analyzeResult || analyzing) return; + const file = files[0]; + const fileKey = `${file.name}-${file.size}-${file.lastModified}`; + if (analyzeRef.current === fileKey) return; + analyzeRef.current = fileKey; + runAnalyze(file); + }, [hasFile, files, analyzeResult, analyzing, runAnalyze]); + + // ── Render canvas ────────────────────────────────────────────── + + const renderCanvas = useCallback(() => { + const canvas = canvasRef.current; + const img = previewImgRef.current; + if (!canvas || !img || !analyzeResult) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const { landmarks, imageWidth, imageHeight } = analyzeResult; + + // Canvas display size - use doc spec aspect ratio + const canvasDisplayWidth = 280; + const canvasDisplayHeight = canvasDisplayWidth * (docSpec.height / docSpec.width); + canvas.width = canvasDisplayWidth; + canvas.height = canvasDisplayHeight; + + // Fill background + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, canvasDisplayWidth, canvasDisplayHeight); + + // Compute crop region in original image coords + const crop = computeCropRegion(docSpec, landmarks, imageWidth, imageHeight, adjustX, adjustY); + + // Map crop region from original image coords to preview image coords + const scaleX = img.naturalWidth / imageWidth; + const scaleY = img.naturalHeight / imageHeight; + const srcX = crop.leftX * scaleX; + const srcY = crop.topY * scaleY; + const srcW = crop.photoWidthPx * scaleX; + const srcH = crop.photoHeightPx * scaleY; + + // Draw preview image, cropped, onto full canvas + 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; + + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1.5; + + // Crown line (top of head) + const crownYCanvas = + ((landmarks.crown.y + adjustY) * imageHeight - crop.topY) * + (canvasDisplayHeight / crop.photoHeightPx); + ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; + ctx.beginPath(); + ctx.moveTo(0, crownYCanvas); + ctx.lineTo(canvasDisplayWidth, crownYCanvas); + ctx.stroke(); + + // Chin line + const chinYCanvas = + ((landmarks.chin.y + adjustY) * imageHeight - crop.topY) * + (canvasDisplayHeight / crop.photoHeightPx); + ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; + ctx.beginPath(); + ctx.moveTo(0, chinYCanvas); + ctx.lineTo(canvasDisplayWidth, chinYCanvas); + ctx.stroke(); + + // Eye line + const eyeYCanvas = + ((landmarks.eyeCenter.y + adjustY) * imageHeight - crop.topY) * + (canvasDisplayHeight / crop.photoHeightPx); + ctx.strokeStyle = eyeOk ? "#3b82f6" : "#ef4444"; + ctx.beginPath(); + ctx.moveTo(0, eyeYCanvas); + ctx.lineTo(canvasDisplayWidth, eyeYCanvas); + ctx.stroke(); + + // Center line (vertical) + const centerXCanvas = + ((landmarks.faceCenterX + adjustX) * imageWidth - crop.leftX) * + (canvasDisplayWidth / crop.photoWidthPx); + ctx.strokeStyle = centerOk ? "#f59e0b" : "#ef4444"; + ctx.beginPath(); + ctx.moveTo(centerXCanvas, 0); + ctx.lineTo(centerXCanvas, canvasDisplayHeight); + ctx.stroke(); + + ctx.setLineDash([]); + }, [analyzeResult, docSpec, bgColor, adjustX, adjustY]); + + // ── Load preview image when analyzeResult changes ────────────── + + useEffect(() => { + if (!analyzeResult?.preview) { + previewImgRef.current = null; + return; + } + const img = new Image(); + img.onload = () => { + previewImgRef.current = img; + renderCanvas(); + }; + img.src = `data:image/png;base64,${analyzeResult.preview}`; + }, [analyzeResult?.preview, renderCanvas]); + + // Re-render canvas when settings change + useEffect(() => { + renderCanvas(); + }, [renderCanvas]); + + // ── Drag to adjust ───────────────────────────────────────────── + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!analyzeResult) return; + setDragging(true); + dragStartRef.current = { x: e.clientX, y: e.clientY, ax: adjustX, ay: adjustY }; + }, + [analyzeResult, adjustX, adjustY], + ); + + useEffect(() => { + if (!dragging) return; + + function handleMouseMove(e: MouseEvent) { + if (!dragStartRef.current) return; + const dx = (e.clientX - dragStartRef.current.x) * 0.001; + const dy = (e.clientY - dragStartRef.current.y) * 0.001; + setAdjustX(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ax - dx))); + setAdjustY(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ay - dy))); + } + + function handleMouseUp() { + setDragging(false); + dragStartRef.current = null; + } + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [dragging]); + + // ── Generate ─────────────────────────────────────────────────── + + const handleGenerate = useCallback(async () => { + if (!analyzeResult) return; + + setGenerating(true); + setGenerateError(null); + setGenerateResult(null); + + try { + const headers = formatHeaders({ "Content-Type": "application/json" }); + const body = { + jobId: analyzeResult.jobId, + filename: analyzeResult.filename, + countryCode, + documentType, + bgColor, + printLayout, + adjustX, + adjustY, + landmarks: analyzeResult.landmarks, + imageWidth: analyzeResult.imageWidth, + imageHeight: analyzeResult.imageHeight, + }; + + const response = await fetch("/api/v1/tools/passport-photo/generate", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => null); + throw new Error( + errBody?.details || errBody?.error || `Generation failed: ${response.status}`, + ); + } + + const result: GenerateResult = await response.json(); + setGenerateResult(result); + } catch (err) { + setGenerateError(err instanceof Error ? err.message : "Photo generation failed"); + } finally { + setGenerating(false); + } + }, [analyzeResult, countryCode, documentType, bgColor, printLayout, adjustX, adjustY]); + + // ── Compliance checks ────────────────────────────────────────── + + const complianceChecks = analyzeResult + ? runComplianceChecks(docSpec, analyzeResult.landmarks, analyzeResult.imageHeight, adjustY) + : []; + + // ── Filtered countries ───────────────────────────────────────── + + const filteredSpecs = searchQuery + ? PASSPORT_SPECS.filter( + (s) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) || + s.code.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : null; + + const regionGroups = groupByRegion(); + + // ── Render ───────────────────────────────────────────────────── + + return ( +
+ {/* Country selector */} + Country +
+ + + {dropdownOpen && ( +
+ {/* Search input */} +
+
+ + el?.focus()} + type="text" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder="Search countries..." + className="w-full pl-7 pr-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + /> +
+
+ + {/* Country list */} +
+ {filteredSpecs ? ( + // Search results (flat list) + filteredSpecs.length > 0 ? ( + filteredSpecs.map((spec) => ( + { + setCountryCode(spec.code); + setDropdownOpen(false); + setSearchQuery(""); + }} + /> + )) + ) : ( +

No countries found

+ ) + ) : ( + // Grouped by region + REGION_ORDER.map((region) => { + const specs = regionGroups.get(region); + if (!specs || specs.length === 0) return null; + return ( +
+

+ {REGION_LABELS[region]} +

+ {specs.map((spec) => ( + { + setCountryCode(spec.code); + setDropdownOpen(false); + setSearchQuery(""); + }} + /> + ))} +
+ ); + }) + )} +
+
+ )} +
+ + {/* Document type toggle */} + {uniqueDocTypes.length > 1 && ( + <> + Document Type +
+ {uniqueDocTypes.map((type) => ( + + ))} +
+ + )} + + {/* Background color */} + Background Color +
+
+ {docSpec.bgColors.map((color) => ( +
+
+ setBgColor(e.target.value)} + className="w-7 h-7 rounded border border-border cursor-pointer" + /> + setBgColor(e.target.value)} + placeholder="#FFFFFF" + className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground" + /> +
+
+ + {/* Print layout */} + Print Layout +
+ {PRINT_LAYOUTS.map((layout) => ( + + ))} +
+ + {/* Spec info */} +
+

+ {docSpec.label}: {docSpec.width}x{docSpec.height}mm at {docSpec.dpi} DPI +

+
+ + {/* Canvas preview */} + {analyzeResult && ( +
+ Preview +
+
+ + {/* Drag hint */} +
+ + Drag to adjust +
+
+
+ + {/* Compliance checklist */} +
+ Compliance + {complianceChecks.map((check) => ( +
+ {check.pass ? ( + + ) : ( + + )} + + {check.label} + +
+ ))} +
+
+ )} + + {/* Errors */} + {analyzeError &&

{analyzeError}

} + {generateError &&

{generateError}

} + {error &&

{error}

} + + {/* Analyze progress */} + {analyzing && ( + + )} + + {/* Generate button */} + {analyzeResult && !generating && !generateResult && ( + + )} + + {/* Generating progress */} + {generating && ( +
+ + Generating... +
+ )} + + {/* Download buttons */} + {generateResult && ( +
+ + + Download Photo + + {generateResult.printDownloadUrl && printLayout !== "none" && ( + + + Download Print Sheet + + )} +
+ )} +
+ ); +} + +// ── Country option item ──────────────────────────────────────────── + +function CountryOption({ + spec, + selected, + onSelect, +}: { + spec: PassportSpec; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 31aebe64..a8433057 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -259,6 +259,11 @@ const NoiseRemovalSettings = lazy(() => default: m.NoiseRemovalSettings, })), ); +const PassportPhotoSettings = lazy(() => + import("@/components/tools/passport-photo-settings").then((m) => ({ + default: m.PassportPhotoSettings, + })), +); const RedEyeRemovalSettings = lazy(() => import("@/components/tools/red-eye-removal-settings").then((m) => ({ default: m.RedEyeRemovalSettings, @@ -400,6 +405,7 @@ export const toolRegistry = new Map([ ], ["colorize", { displayMode: "before-after", Settings: ColorizeSettings }], ["noise-removal", { displayMode: "before-after", Settings: NoiseRemovalSettings }], + ["passport-photo", { displayMode: "no-comparison", Settings: PassportPhotoSettings }], ["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }], ["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }], ]); diff --git a/packages/ai/python/face_landmarks.py b/packages/ai/python/face_landmarks.py new file mode 100644 index 00000000..e677fc08 --- /dev/null +++ b/packages/ai/python/face_landmarks.py @@ -0,0 +1,130 @@ +"""Face landmark detection using MediaPipe FaceMesh for passport photo positioning.""" +import sys +import json + + +def emit_progress(percent, stage): + """Emit structured progress to stderr for bridge.ts to capture.""" + print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True) + + +def main(): + input_path = sys.argv[1] + output_path = sys.argv[2] # unused but kept for bridge.ts compatibility + settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} + + try: + emit_progress(10, "Loading image") + from PIL import Image + + img = Image.open(input_path).convert("RGB") + iw, ih = img.size + + try: + import mediapipe as mp + import numpy as np + + emit_progress(20, "Initializing face mesh") + + img_array = np.array(img) + + mp_face_mesh = mp.solutions.face_mesh + face_mesh = mp_face_mesh.FaceMesh( + static_image_mode=True, + max_num_faces=1, + refine_landmarks=True, + min_detection_confidence=0.5, + ) + + emit_progress(30, "Detecting face landmarks") + results = face_mesh.process(img_array) + face_mesh.close() + + if not results.multi_face_landmarks: + print(json.dumps({ + "success": True, + "faceDetected": False, + "landmarks": None, + })) + return + + emit_progress(60, "Extracting key points") + face_lm = results.multi_face_landmarks[0] + lms = face_lm.landmark + + # Left eye center (average of key eye landmarks) + left_eye_indices = [33, 133, 159, 145, 160, 144, 158, 153] + left_eye_x = sum(lms[i].x for i in left_eye_indices) / len(left_eye_indices) + left_eye_y = sum(lms[i].y for i in left_eye_indices) / len(left_eye_indices) + + # Right eye center + right_eye_indices = [362, 263, 386, 374, 385, 373, 387, 380] + right_eye_x = sum(lms[i].x for i in right_eye_indices) / len(right_eye_indices) + right_eye_y = sum(lms[i].y for i in right_eye_indices) / len(right_eye_indices) + + # Eye center (midpoint between both eyes) + eye_center_x = (left_eye_x + right_eye_x) / 2 + eye_center_y = (left_eye_y + right_eye_y) / 2 + + # Chin bottom (landmark 152) + chin_x = lms[152].x + chin_y = lms[152].y + + # Forehead top (landmark 10) + forehead_x = lms[10].x + forehead_y = lms[10].y + + # Nose tip (landmark 1) + nose_x = lms[1].x + nose_y = lms[1].y + + # Estimate crown position + # The crown is above the forehead. Using anthropometric data: + # forehead-to-chin distance is roughly 85-90% of crown-to-chin. + # So crown is about 12-15% above forehead relative to chin-forehead distance. + forehead_chin_dist = chin_y - forehead_y + crown_y = forehead_y - (forehead_chin_dist * 0.15) + crown_x = forehead_x + + # Face center X (average of nose, eye center) + face_center_x = (nose_x + eye_center_x) / 2 + + emit_progress(90, "Done") + + print(json.dumps({ + "success": True, + "faceDetected": True, + "landmarks": { + "leftEye": {"x": round(left_eye_x, 6), "y": round(left_eye_y, 6)}, + "rightEye": {"x": round(right_eye_x, 6), "y": round(right_eye_y, 6)}, + "eyeCenter": {"x": round(eye_center_x, 6), "y": round(eye_center_y, 6)}, + "chin": {"x": round(chin_x, 6), "y": round(chin_y, 6)}, + "forehead": {"x": round(forehead_x, 6), "y": round(forehead_y, 6)}, + "crown": {"x": round(crown_x, 6), "y": round(crown_y, 6)}, + "nose": {"x": round(nose_x, 6), "y": round(nose_y, 6)}, + "faceCenterX": round(face_center_x, 6), + }, + "imageWidth": iw, + "imageHeight": ih, + })) + + except ImportError: + print(json.dumps({ + "success": False, + "error": "Face landmark detection requires MediaPipe. Install with: pip install mediapipe", + })) + sys.exit(1) + + except ImportError: + print(json.dumps({ + "success": False, + "error": "Pillow is not installed. Install with: pip install Pillow", + })) + sys.exit(1) + except Exception as e: + print(json.dumps({"success": False, "error": str(e)})) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/ai/src/face-landmarks.ts b/packages/ai/src/face-landmarks.ts new file mode 100644 index 00000000..944ecbd3 --- /dev/null +++ b/packages/ai/src/face-landmarks.ts @@ -0,0 +1,57 @@ +import { unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type ProgressCallback, runPythonWithProgress } from "./bridge.js"; + +export interface FaceLandmarkPoint { + x: number; + y: number; +} + +export interface FaceLandmarks { + leftEye: FaceLandmarkPoint; + rightEye: FaceLandmarkPoint; + eyeCenter: FaceLandmarkPoint; + chin: FaceLandmarkPoint; + forehead: FaceLandmarkPoint; + crown: FaceLandmarkPoint; + nose: FaceLandmarkPoint; + faceCenterX: number; +} + +export interface FaceLandmarksResult { + faceDetected: boolean; + landmarks: FaceLandmarks | null; + imageWidth: number; + imageHeight: number; +} + +export async function detectFaceLandmarks( + inputBuffer: Buffer, + onProgress?: ProgressCallback, +): Promise { + const inputPath = join(tmpdir(), `face_landmarks_${Date.now()}.png`); + + try { + await writeFile(inputPath, inputBuffer); + const { stdout } = await runPythonWithProgress( + "face_landmarks.py", + [inputPath, "unused", "{}"], + { onProgress }, + ); + + const result = JSON.parse(stdout); + if (!result.success) { + throw new Error(result.error || "Face landmark detection failed"); + } + + return { + faceDetected: result.faceDetected, + landmarks: result.landmarks ?? null, + imageWidth: result.imageWidth ?? 0, + imageHeight: result.imageHeight ?? 0, + }; + } finally { + await unlink(inputPath).catch(() => {}); + } +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a5174788..9abd17ce 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -4,6 +4,8 @@ export { colorize } from "./colorization.js"; export type { DetectFacesResult, FaceRegion } from "./face-detection.js"; export { blurFaces, detectFaces } from "./face-detection.js"; export { enhanceFaces } from "./face-enhancement.js"; +export type { FaceLandmarkPoint, FaceLandmarks, FaceLandmarksResult } from "./face-landmarks.js"; +export { detectFaceLandmarks } from "./face-landmarks.js"; export { inpaint } from "./inpainting.js"; export { noiseRemoval } from "./noise-removal.js"; export { extractText } from "./ocr.js"; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 63b303e3..980e2633 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -217,6 +217,14 @@ export const TOOLS: Tool[] = [ icon: "Undo2", route: "/restore-photo", }, + { + id: "passport-photo", + name: "Passport Photo", + description: "AI-powered passport and ID photo generator", + category: "ai", + icon: "UserCheck", + route: "/passport-photo", + }, // Watermark & Overlay { id: "watermark-text", @@ -424,6 +432,455 @@ export const SMART_CROP_FACE_PRESETS: SmartCropFacePreset[] = [ { id: "half-body", label: "Half Body", multiplier: 7.0 }, ]; +// --------------------------------------------------------------------------- +// Passport / ID Photo Specs +// --------------------------------------------------------------------------- + +export type PassportRegion = "americas" | "europe" | "asia" | "africa" | "oceania" | "middle-east"; + +export interface PassportDocumentSpec { + type: string; + label: string; + width: number; // mm + height: number; // mm + dpi: number; + headHeightMin: number; // fraction of photo height + headHeightMax: number; // fraction of photo height + eyeLineFromBottom: number; // fraction of photo height + bgColor: string; // default background hex + bgColors: string[]; // allowed background colors +} + +export interface PassportSpec { + code: string; + name: string; + flag: string; + region: PassportRegion; + documents: PassportDocumentSpec[]; +} + +export const PASSPORT_SPECS: PassportSpec[] = [ + // Americas + { + code: "US", + name: "United States", + flag: "🇺🇸", + region: "americas", + documents: [ + { + type: "passport", + label: "US Passport", + width: 51, + height: 51, + dpi: 300, + headHeightMin: 0.5, + headHeightMax: 0.69, + eyeLineFromBottom: 0.56, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "CA", + name: "Canada", + flag: "🇨🇦", + region: "americas", + documents: [ + { + type: "passport", + label: "Canadian Passport", + width: 50, + height: 70, + dpi: 300, + headHeightMin: 0.44, + headHeightMax: 0.51, + eyeLineFromBottom: 0.55, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "BR", + name: "Brazil", + flag: "🇧🇷", + region: "americas", + documents: [ + { + type: "passport", + label: "Brazilian Passport", + width: 50, + height: 70, + dpi: 300, + headHeightMin: 0.5, + headHeightMax: 0.69, + eyeLineFromBottom: 0.56, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "MX", + name: "Mexico", + flag: "🇲🇽", + region: "americas", + documents: [ + { + type: "passport", + label: "Mexican Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + // Europe + { + code: "GB", + name: "United Kingdom", + flag: "🇬🇧", + region: "europe", + documents: [ + { + type: "passport", + label: "UK Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#D4D4D4", + bgColors: ["#D4D4D4"], + }, + ], + }, + { + code: "EU", + name: "European Union", + flag: "🇪🇺", + region: "europe", + documents: [ + { + type: "passport", + label: "EU Passport (ICAO)", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF", "#D4D4D4"], + }, + ], + }, + { + code: "DE", + name: "Germany", + flag: "🇩🇪", + region: "europe", + documents: [ + { + type: "passport", + label: "German Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#D4D4D4", + bgColors: ["#D4D4D4"], + }, + ], + }, + { + code: "FR", + name: "France", + flag: "🇫🇷", + region: "europe", + documents: [ + { + type: "passport", + label: "French Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#D4D4D4", + bgColors: ["#D4D4D4", "#BFDBFE"], + }, + ], + }, + { + code: "RU", + name: "Russia", + flag: "🇷🇺", + region: "europe", + documents: [ + { + type: "passport", + label: "Russian Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "TR", + name: "Turkey", + flag: "🇹🇷", + region: "europe", + documents: [ + { + type: "passport", + label: "Turkish Passport", + width: 50, + height: 60, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + // Asia + { + code: "IN", + name: "India", + flag: "🇮🇳", + region: "asia", + documents: [ + { + type: "passport", + label: "Indian Passport", + width: 51, + height: 51, + dpi: 300, + headHeightMin: 0.5, + headHeightMax: 0.69, + eyeLineFromBottom: 0.56, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "CN", + name: "China", + flag: "🇨🇳", + region: "asia", + documents: [ + { + type: "passport", + label: "Chinese Passport", + width: 33, + height: 48, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "JP", + name: "Japan", + flag: "🇯🇵", + region: "asia", + documents: [ + { + type: "passport", + label: "Japanese Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "KR", + name: "South Korea", + flag: "🇰🇷", + region: "asia", + documents: [ + { + type: "passport", + label: "Korean Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "TH", + name: "Thailand", + flag: "🇹🇭", + region: "asia", + documents: [ + { + type: "passport", + label: "Thai Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "PH", + name: "Philippines", + flag: "🇵🇭", + region: "asia", + documents: [ + { + type: "passport", + label: "Philippine Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + { + code: "ID", + name: "Indonesia", + flag: "🇮🇩", + region: "asia", + documents: [ + { + type: "passport", + label: "Indonesian Passport", + width: 30, + height: 40, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#EF4444", + bgColors: ["#EF4444"], + }, + ], + }, + // Oceania + { + code: "AU", + name: "Australia", + flag: "🇦🇺", + region: "oceania", + documents: [ + { + type: "passport", + label: "Australian Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + // Middle East + { + code: "SA", + name: "Saudi Arabia", + flag: "🇸🇦", + region: "middle-east", + documents: [ + { + type: "passport", + label: "Saudi Passport", + width: 40, + height: 60, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, + // Africa + { + code: "NG", + name: "Nigeria", + flag: "🇳🇬", + region: "africa", + documents: [ + { + type: "passport", + label: "Nigerian Passport", + width: 35, + height: 45, + dpi: 300, + headHeightMin: 0.7, + headHeightMax: 0.8, + eyeLineFromBottom: 0.63, + bgColor: "#FFFFFF", + bgColors: ["#FFFFFF"], + }, + ], + }, +]; + +export interface PrintLayout { + id: string; + label: string; + width: number; // mm + height: number; // mm +} + +export const PRINT_LAYOUTS: PrintLayout[] = [ + { id: "4x6", label: "4x6 inch", width: 102, height: 152 }, + { id: "a4", label: "A4", width: 210, height: 297 }, + { id: "none", label: "None", width: 0, height: 0 }, +]; + export const APP_VERSION = "1.14.0"; /** @@ -441,4 +898,5 @@ export const PYTHON_SIDECAR_TOOLS = [ "noise-removal", "red-eye-removal", "restore-photo", + "passport-photo", ] as const; diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 7327dcd1..f7649373 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -104,6 +104,11 @@ export const en = { name: "AI Colorization", description: "Convert black & white photos to full color using AI deep learning models", }, + "passport-photo": { + name: "Passport Photo", + description: + "Create government-compliant passport, visa, and ID photos with auto face detection", + }, "watermark-text": { name: "Text Watermark", description: "Add text watermark overlay" }, "watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" }, "text-overlay": { name: "Text Overlay", description: "Add styled text to images" },