An ID photo looks like the simplest image task there is: one face, plain background, fixed size. In practice it is one of the least forgiving. A portrait for social media can be stylised freely. An ID photo has to be geometrically correct, visually clean and, above all, still the same person.
Our ID-photo tool at CoolMe AI turns a phone selfie into a 3×4, 4×6, 2×3 or 1×1 photo with a clean background and an outfit template (suit, office shirt, áo dài and so on). Below is the general shape of an ID-photo pipeline like this one, with simplified Python to illustrate the deterministic parts, followed by the places where generative AI should be kept out.
The code and thresholds below are illustrative and simplified for the article. They are not production code; the point is the constraints they encode.
The pipeline at a glance
selfie ──► quality gate ──► face detection + landmarks ──► alignment & crop
│
export (print / web sizes) ◄── background ◄── light correction
│
outfit template (generative, constrained)
Two kinds of steps live in this pipeline:
- Deterministic steps – quality checks, alignment, cropping, sizing. These must give the same output for the same input, and they must be explainable.
- Generative steps – background clean-up, light correction, outfit template. These are where models help most and where they can do the most damage.
Keeping the two separate is the main design decision. Generative models should never decide where the face goes or how big it is.
Step 1: a quality gate before any model runs
Most bad ID photos are caused by bad input, not bad models. Rejecting early saves the user money and avoids "fixing" what should simply be retaken.
import cv2
import numpy as np
def quality_gate(img_bgr: np.ndarray) -> list[str]:
"""Return a list of human-readable problems; empty list means OK."""
problems = []
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
# Blur: variance of the Laplacian is a cheap sharpness proxy.
if cv2.Laplacian(gray, cv2.CV_64F).var() < 60:
problems.append("Photo looks blurry. Hold the phone steady or use the rear camera.")
# Exposure: too dark or blown out.
mean = gray.mean()
if mean < 70:
problems.append("Photo is too dark. Face a window or a light source.")
elif mean > 200:
problems.append("Photo is overexposed. Move away from direct light.")
# Resolution: ID prints need real pixels.
h, w = gray.shape
if min(h, w) < 600:
problems.append("Resolution is too low for printing.")
return problems
The thresholds are placeholders; you tune them on your own data. The important part is that the messages tell the user what to do, not just that something failed.
Step 2: alignment and crop are pure geometry
Once you have facial landmarks (any decent landmark model works), the crop is arithmetic: level the eyes, then place the face so that the head occupies a fixed share of the frame height.
import math
def align_and_crop(img, left_eye, right_eye, chin, crown, ratio=(3, 4), head_share=0.62):
"""
left_eye, right_eye, chin, crown: (x, y) pixel coordinates.
ratio: output aspect ratio (w, h), e.g. (3, 4) or (2, 3).
head_share: crown-to-chin height as a fraction of output height.
"""
# 1) Rotate so the eyes are level.
dx, dy = right_eye[0] - left_eye[0], right_eye[1] - left_eye[1]
angle = math.degrees(math.atan2(dy, dx))
cx, cy = (left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
rotated = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]), flags=cv2.INTER_CUBIC)
def rot(p):
x, y = p
return (M[0, 0] * x + M[0, 1] * y + M[0, 2], M[1, 0] * x + M[1, 1] * y + M[1, 2])
chin_r, crown_r = rot(chin), rot(crown)
# 2) Size the frame from the head height.
head_h = chin_r[1] - crown_r[1]
out_h = head_h / head_share
out_w = out_h * ratio[0] / ratio[1]
# 3) Centre horizontally on the eyes, leave a small margin above the crown.
top = crown_r[1] - out_h * 0.10
left = cx - out_w / 2
x0, y0 = int(round(left)), int(round(top))
x1, y1 = int(round(left + out_w)), int(round(top + out_h))
if x0 < 0 or y0 < 0 or x1 > rotated.shape[1] or y1 > rotated.shape[0]:
raise ValueError("Face is too close to the edge. Ask the user to step back.")
return rotated[y0:y1, x0:x1]
Notice what this function refuses to do: it does not pad the image with generated content when the head is cut off. If the shoulders or the top of the head are missing, the honest answer is "retake the photo", not "let a model invent them".
Step 3: generative steps, with a short leash
Background clean-up, light balancing and outfit templates are where generative models earn their place. A selfie taken in a bedroom becomes a flat white or light-blue background; harsh shadows are softened; a T-shirt becomes the suit template the user picked.
These steps need a short leash. Three constraints are worth building in:
- Face region is protected. Generative edits are masked so they apply to background, clothing and lighting, not to facial geometry.
- Identity check after generation. Compare a face embedding of the output with the input. If similarity drops below a threshold, discard the result instead of showing it.
- Mild by default. Skin smoothing and light correction start at a low strength. The goal is "photographed on a good day", not "a different person".
A minimal version of the identity check looks like this:
def same_person(emb_in: np.ndarray, emb_out: np.ndarray, threshold: float = 0.6) -> bool:
# Cosine similarity between L2-normalised face embeddings.
a = emb_in / np.linalg.norm(emb_in)
b = emb_out / np.linalg.norm(emb_out)
return float(a @ b) >= threshold
The threshold depends on the embedding model; calibrate it on pairs you know are the same person and pairs you know are not.
Step 4: export for paper and for screens
Print sizes are physical (3×4 cm, 4×6 cm), while screens care about aspect ratio. Export by DPI for print and by pixels for the web:
def cm_to_px(cm: float, dpi: int = 300) -> int:
return round(cm / 2.54 * dpi)
PRINT_SIZES = {"3x4": (cm_to_px(3), cm_to_px(4)), "4x6": (cm_to_px(4), cm_to_px(6))}
# 3x4 at 300 dpi -> 354 x 472 px; 4x6 -> 472 x 709 px
A common user mistake is printing with "fit to page" enabled, which silently rescales the photo. It is worth warning about this near the download step, because no amount of AI fixes a printer setting.
Where AI must not be used
This is the part that matters most, and it applies to anyone building in this space.
- Do not change identity. No face slimming, eye enlarging, nose reshaping or age changes on an ID photo. A recruiter or an officer has to recognise the person in front of them.
- Do not invent missing anatomy. If the crown or shoulders are out of frame, ask for a retake instead of outpainting.
- Do not promise official acceptance. Passport and visa offices publish their own rules, and many forbid digitally altered photos. For those documents, the product should tell users to check the official guideline, and in doubt, to use a photo studio.
- Do not add things that are not true. Uniforms, badges or insignia of organisations the user does not belong to are off-limits.
- Do not keep photos longer than needed. Face images are sensitive personal data. Process for the user's request, let them download, and set a clear retention policy.
Takeaways
- Split the pipeline into deterministic geometry and constrained generation.
- Reject bad input early with actionable messages.
- Protect the face region and verify identity after every generative step.
- Be explicit about which documents your tool is not for.
If you read Vietnamese, there is a longer overview of how the studio is organised and how its credit model works in this introduction to CoolMe AI Studio.
How do you handle identity preservation in your own image pipelines? I would be glad to compare notes in the comments.
Top comments (0)