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 <stirling-image@users.noreply.github.com>
This commit is contained in:
stirling-image
2026-04-14 09:59:48 +08:00
committed by GitHub
co-authored by stirling-image
parent 43821a955c
commit 2f11b9e101
9 changed files with 1988 additions and 0 deletions
+2
View File
@@ -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<void> {
{ 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 },
];
+538
View File
@@ -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<Buffer | null> {
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<typeof generateSettingsSchema>;
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<string, unknown> = {
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<typeof pipelineSettingsSchema>;
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" };
},
});
}
@@ -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<PassportRegion, string> = {
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<PassportRegion, PassportSpec[]> {
const groups = new Map<PassportRegion, PassportSpec[]>();
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 (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
{children}
</p>
);
}
// ── 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<HTMLDivElement>(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<AnalyzeResult | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
// Generate result
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null);
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
// Canvas
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewImgRef = useRef<HTMLImageElement | null>(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<string | null>(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<HTMLCanvasElement>) => {
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 (
<div className="space-y-4">
{/* Country selector */}
<SectionLabel>Country</SectionLabel>
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground hover:border-primary/50 transition-colors"
>
<span>{selectedSpec.flag}</span>
<span className="flex-1 text-left truncate">{selectedSpec.name}</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${dropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{dropdownOpen && (
<div className="absolute z-50 mt-1 w-full max-h-64 overflow-auto rounded-lg border border-border bg-popover shadow-lg">
{/* Search input */}
<div className="sticky top-0 bg-popover p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={(el) => 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"
/>
</div>
</div>
{/* Country list */}
<div className="py-1">
{filteredSpecs ? (
// Search results (flat list)
filteredSpecs.length > 0 ? (
filteredSpecs.map((spec) => (
<CountryOption
key={spec.code}
spec={spec}
selected={spec.code === countryCode}
onSelect={() => {
setCountryCode(spec.code);
setDropdownOpen(false);
setSearchQuery("");
}}
/>
))
) : (
<p className="px-3 py-2 text-xs text-muted-foreground">No countries found</p>
)
) : (
// Grouped by region
REGION_ORDER.map((region) => {
const specs = regionGroups.get(region);
if (!specs || specs.length === 0) return null;
return (
<div key={region}>
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
{REGION_LABELS[region]}
</p>
{specs.map((spec) => (
<CountryOption
key={spec.code}
spec={spec}
selected={spec.code === countryCode}
onSelect={() => {
setCountryCode(spec.code);
setDropdownOpen(false);
setSearchQuery("");
}}
/>
))}
</div>
);
})
)}
</div>
</div>
)}
</div>
{/* Document type toggle */}
{uniqueDocTypes.length > 1 && (
<>
<SectionLabel>Document Type</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{uniqueDocTypes.map((type) => (
<button
key={type}
type="button"
onClick={() => setDocumentType(type)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors capitalize ${
documentType === type
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{type}
</button>
))}
</div>
</>
)}
{/* Background color */}
<SectionLabel>Background Color</SectionLabel>
<div className="space-y-2">
<div className="flex gap-1.5 flex-wrap">
{docSpec.bgColors.map((color) => (
<button
key={color}
type="button"
onClick={() => setBgColor(color)}
className={`w-7 h-7 rounded border-2 transition-all ${
bgColor === color ? "border-primary scale-110" : "border-border"
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
<div className="flex items-center gap-2">
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="w-7 h-7 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
placeholder="#FFFFFF"
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground"
/>
</div>
</div>
{/* Print layout */}
<SectionLabel>Print Layout</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{PRINT_LAYOUTS.map((layout) => (
<button
key={layout.id}
type="button"
onClick={() => setPrintLayout(layout.id)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
printLayout === layout.id
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{layout.label}
</button>
))}
</div>
{/* Spec info */}
<div className="text-[11px] text-muted-foreground space-y-0.5 bg-muted/50 rounded-lg px-3 py-2">
<p>
{docSpec.label}: {docSpec.width}x{docSpec.height}mm at {docSpec.dpi} DPI
</p>
</div>
{/* Canvas preview */}
{analyzeResult && (
<div className="space-y-2">
<SectionLabel>Preview</SectionLabel>
<div className="flex justify-center">
<div className="relative">
<canvas
ref={canvasRef}
className={`rounded-lg border border-border shadow-sm ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
onMouseDown={handleMouseDown}
/>
{/* Drag hint */}
<div className="absolute bottom-1 right-1 flex items-center gap-1 bg-black/50 text-white text-[9px] px-1.5 py-0.5 rounded">
<Move className="h-2.5 w-2.5" />
Drag to adjust
</div>
</div>
</div>
{/* Compliance checklist */}
<div className="space-y-1">
<SectionLabel>Compliance</SectionLabel>
{complianceChecks.map((check) => (
<div key={check.label} className="flex items-center gap-2 text-xs">
{check.pass ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<X className="h-3.5 w-3.5 text-red-500" />
)}
<span className={check.pass ? "text-foreground" : "text-red-400"}>
{check.label}
</span>
</div>
))}
</div>
</div>
)}
{/* Errors */}
{analyzeError && <p className="text-xs text-red-500">{analyzeError}</p>}
{generateError && <p className="text-xs text-red-500">{generateError}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Analyze progress */}
{analyzing && (
<ProgressCard
active
phase="processing"
label="Analyzing face"
stage="Detecting landmarks..."
percent={50}
elapsed={0}
/>
)}
{/* Generate button */}
{analyzeResult && !generating && !generateResult && (
<button
type="button"
onClick={handleGenerate}
disabled={generating}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<UserCheck className="h-4 w-4" />
Generate Passport Photo
</button>
)}
{/* Generating progress */}
{generating && (
<div className="flex items-center justify-center gap-2 py-2.5 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Generating...
</div>
)}
{/* Download buttons */}
{generateResult && (
<div className="space-y-2">
<a
href={generateResult.downloadUrl}
download
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90"
>
<Download className="h-4 w-4" />
Download Photo
</a>
{generateResult.printDownloadUrl && printLayout !== "none" && (
<a
href={generateResult.printDownloadUrl}
download
className="w-full py-2 rounded-lg border border-border text-foreground font-medium text-sm flex items-center justify-center gap-2 hover:bg-muted/50"
>
<Printer className="h-4 w-4" />
Download Print Sheet
</a>
)}
</div>
)}
</div>
);
}
// ── Country option item ────────────────────────────────────────────
function CountryOption({
spec,
selected,
onSelect,
}: {
spec: PassportSpec;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors ${
selected ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted"
}`}
>
<span>{spec.flag}</span>
<span className="flex-1 text-left">{spec.name}</span>
<span className="text-muted-foreground">{spec.code}</span>
{selected && <Check className="h-3 w-3 text-primary" />}
</button>
);
}
+6
View File
@@ -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<string, ToolRegistryEntry>([
],
["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 }],
]);