mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
co-authored by
stirling-image
parent
43821a955c
commit
2f11b9e101
@@ -27,6 +27,7 @@ import { registerImageToPdf } from "./image-to-pdf.js";
|
|||||||
import { registerInfo } from "./info.js";
|
import { registerInfo } from "./info.js";
|
||||||
import { registerNoiseRemoval } from "./noise-removal.js";
|
import { registerNoiseRemoval } from "./noise-removal.js";
|
||||||
import { registerOcr } from "./ocr.js";
|
import { registerOcr } from "./ocr.js";
|
||||||
|
import { registerPassportPhoto } from "./passport-photo.js";
|
||||||
import { registerPdfToImage } from "./pdf-to-image.js";
|
import { registerPdfToImage } from "./pdf-to-image.js";
|
||||||
import { registerQrGenerate } from "./qr-generate.js";
|
import { registerQrGenerate } from "./qr-generate.js";
|
||||||
import { registerRedEyeRemoval } from "./red-eye-removal.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: "colorize", register: registerColorize },
|
||||||
{ id: "enhance-faces", register: registerEnhanceFaces },
|
{ id: "enhance-faces", register: registerEnhanceFaces },
|
||||||
{ id: "noise-removal", register: registerNoiseRemoval },
|
{ id: "noise-removal", register: registerNoiseRemoval },
|
||||||
|
{ id: "passport-photo", register: registerPassportPhoto },
|
||||||
{ id: "red-eye-removal", register: registerRedEyeRemoval },
|
{ id: "red-eye-removal", register: registerRedEyeRemoval },
|
||||||
{ id: "restore-photo", register: registerRestorePhoto },
|
{ id: "restore-photo", register: registerRestorePhoto },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -259,6 +259,11 @@ const NoiseRemovalSettings = lazy(() =>
|
|||||||
default: m.NoiseRemovalSettings,
|
default: m.NoiseRemovalSettings,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const PassportPhotoSettings = lazy(() =>
|
||||||
|
import("@/components/tools/passport-photo-settings").then((m) => ({
|
||||||
|
default: m.PassportPhotoSettings,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const RedEyeRemovalSettings = lazy(() =>
|
const RedEyeRemovalSettings = lazy(() =>
|
||||||
import("@/components/tools/red-eye-removal-settings").then((m) => ({
|
import("@/components/tools/red-eye-removal-settings").then((m) => ({
|
||||||
default: m.RedEyeRemovalSettings,
|
default: m.RedEyeRemovalSettings,
|
||||||
@@ -400,6 +405,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
|||||||
],
|
],
|
||||||
["colorize", { displayMode: "before-after", Settings: ColorizeSettings }],
|
["colorize", { displayMode: "before-after", Settings: ColorizeSettings }],
|
||||||
["noise-removal", { displayMode: "before-after", Settings: NoiseRemovalSettings }],
|
["noise-removal", { displayMode: "before-after", Settings: NoiseRemovalSettings }],
|
||||||
|
["passport-photo", { displayMode: "no-comparison", Settings: PassportPhotoSettings }],
|
||||||
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
|
["red-eye-removal", { displayMode: "before-after", Settings: RedEyeRemovalSettings }],
|
||||||
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
|
["restore-photo", { displayMode: "before-after", Settings: RestorePhotoSettings }],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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<FaceLandmarksResult> {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export { colorize } from "./colorization.js";
|
|||||||
export type { DetectFacesResult, FaceRegion } from "./face-detection.js";
|
export type { DetectFacesResult, FaceRegion } from "./face-detection.js";
|
||||||
export { blurFaces, detectFaces } from "./face-detection.js";
|
export { blurFaces, detectFaces } from "./face-detection.js";
|
||||||
export { enhanceFaces } from "./face-enhancement.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 { inpaint } from "./inpainting.js";
|
||||||
export { noiseRemoval } from "./noise-removal.js";
|
export { noiseRemoval } from "./noise-removal.js";
|
||||||
export { extractText } from "./ocr.js";
|
export { extractText } from "./ocr.js";
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ export const TOOLS: Tool[] = [
|
|||||||
icon: "Undo2",
|
icon: "Undo2",
|
||||||
route: "/restore-photo",
|
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
|
// Watermark & Overlay
|
||||||
{
|
{
|
||||||
id: "watermark-text",
|
id: "watermark-text",
|
||||||
@@ -424,6 +432,455 @@ export const SMART_CROP_FACE_PRESETS: SmartCropFacePreset[] = [
|
|||||||
{ id: "half-body", label: "Half Body", multiplier: 7.0 },
|
{ 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";
|
export const APP_VERSION = "1.14.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -441,4 +898,5 @@ export const PYTHON_SIDECAR_TOOLS = [
|
|||||||
"noise-removal",
|
"noise-removal",
|
||||||
"red-eye-removal",
|
"red-eye-removal",
|
||||||
"restore-photo",
|
"restore-photo",
|
||||||
|
"passport-photo",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export const en = {
|
|||||||
name: "AI Colorization",
|
name: "AI Colorization",
|
||||||
description: "Convert black & white photos to full color using AI deep learning models",
|
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-text": { name: "Text Watermark", description: "Add text watermark overlay" },
|
||||||
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
"watermark-image": { name: "Image Watermark", description: "Overlay a logo as watermark" },
|
||||||
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
"text-overlay": { name: "Text Overlay", description: "Add styled text to images" },
|
||||||
|
|||||||
Reference in New Issue
Block a user