Most "passport photo online" sites ask you to upload a selfie and then charge to remove the watermark. I wanted to see how far a browser-only version could go: crop to the official spec, replace the background and export print-ready files without the image ever leaving the phone. Here is what turned out to matter.
1. Specs are about head height, not zoom
Official rules describe the head height (chin to top of head) and the margin above it:
| Photo | Size | Head | Source |
|---|---|---|---|
| Brazil 3x4 | 30 x 40 mm | ≈ 30 mm (70–80%) | ICAO guidance published by ANAC (gov.br) |
| US visa | 2 x 2 in; DS-160 file 600x600 px, ≤ 240 KB | 25–35 mm (50–69%) | travel.state.gov |
| Canada visa | 35 x 45 mm | 31–36 mm | canada.ca (IRCC) |
| Schengen | 35 x 45 mm | 32–36 mm | EU Visa Code / ICAO 9303 |
Once you know the chin and the crown in pixels, the crop is just arithmetic:
function computeCrop({ chinY, crownY, centerX }, spec) {
const pxPerMm = (chinY - crownY) / spec.headTargetMm;
const w = spec.wMm * pxPerMm, h = spec.hMm * pxPerMm;
return { x: centerX - w / 2, y: crownY - spec.topMm * pxPerMm, w, h };
}
2. The chin is easy, the crown is not
MediaPipe's FaceLandmarker gives 478 points. Point 152 is the chin, but the highest point (10) sits mid-forehead. What worked for me:
- De-rotate the image using the eye line first.
- Run a person segmenter on a window around the head and take the first row, from the top, where the mask covers a central band of the face. That row is the top of the hair.
- For specs that measure to the skull (Canada, ICAO), clamp that value. Otherwise big curly hair shrinks the face below the allowed range.
3. Segmentation: download size vs. quality
selfie_segmenter is 250 KB and good enough for a flat white background. selfie_multiclass_256x256 is 16 MB and handles curly hair much better. I load the small model first, show a result, and then fetch the big one in the background unless navigator.connection reports 2g/3g or saveData. Running the segmenter on the crop region instead of the full photo makes the 256-px mask far sharper.
4. Getting the print size right: write DPI into the JPEG
canvas.toBlob('image/jpeg') doesn't set a useful density, so print shops print the file at the wrong size. Patch the JFIF header bytes directly:
b[13] = 1; b[14] = dpi >> 8; b[15] = dpi & 255; b[16] = dpi >> 8; b[17] = dpi & 255;
5. A4 PDF without a library
A one-page PDF with the JPEG embedded as /DCTDecode and each copy placed with a cm operator (mm × 72 / 25.4) takes about 40 lines of JS. An A4 sheet with 12 mm margins and 3 mm gaps fits 30 copies of a 3x4 photo.
6. Feedback before printing
MediaPipe blendshapes (eyeBlink*, mouthSmile*, jawOpen), plus two cheap measurements (nose offset from the eye midpoint for a turned head, cheek luminance difference for side lighting), are enough to warn people about closed eyes, smiling, a turned head or one-sided light before they print.
Code and data
The specs (each with its official link) and the engine are open: https://github.com/MMMystery/foto-documento-specs (MIT code, CC BY 4.0 data). The live tool built on it, in Portuguese for Brazil, is free and has no watermark: https://fotoapta.com/
Disclosure: I built FotoApta. This post was drafted with AI assistance and reviewed; all numbers come from the official sources linked above.
Top comments (0)