← Blog · 2026-11-05 · AI products · ~17 min read

Making ID photos with AI in the browser: how it was built, what was measured, what broke

A tool that removes the background, straightens the head, crops to a standard size and swaps the background colour for ID photos, running entirely in JavaScript and WebAssembly with no server. This post records each step: choosing models by licence, testing the model outside the browser, the geometry of the crop, the edge-cleaning filters, the bugs hit along the way and the results on real photos.

Free to try: AI ID photo maker. The photo is processed on your own device and is not uploaded to any server.

1. The problem and the constraints

An ID photo has fairly rigid requirements: a plain background (white, blue or red depending on the receiving office), a face looking straight ahead, an upright head, the head filling a given share of the photo height, and a standard print size and resolution. Doing it by hand in an image editor means cutting the person out, rotating, cropping to size, filling the background and sharpening. The goal was to automate that chain under these constraints.

2. Choosing the technology

The candidate list was MediaPipe, YuNet and SCRFD for face detection; MODNet, BiRefNet, IS-Net (packaged in IMG.LY’s library), PICORN and RMBG for background removal; Real-ESRGAN, SwinIR and OpenCV.js for sharpening; and ONNX Runtime Web, Transformers.js and WebGPU as runtime. I judged them with four questions: does it give enough geometry, does the licence allow commercial use, how heavy is it, and does it work on single-thread WebAssembly.

StageChosenWhyLeft out
Face detection and alignmentMediaPipe Face Landmarker (478 landmarks, Apache-2.0, 3.76 MB)Gives both iris centres, the chin and the face edges at once: enough to compute tilt and head height from a single modelYuNet, SCRFD: a box and a few points, not enough to measure head proportions
Background removalMODNet (Apache-2.0, ONNX fp16, 12.98 MB)Portrait-specific, no trimap, soft alpha output (including hair)IMG.LY is AGPL. RMBG is non-commercial. BiRefNet and PICORN: not tried here, given the time budget on single-thread WebAssembly
Model runtimeONNX Runtime Web 1.20.1, WebAssembly, in a Web WorkerMIT, stable, loaded from a CDN so the repo stays smallWebGPU: not widespread enough to be the main path
SharpeningHand-written unsharp mask on canvasAn ID photo needs only mild sharpening; super-resolution invents detail and is heavy in a browserReal-ESRGAN, SwinIR (listed under future work)
Edge cleaningGuided filter + Blur-FusionTwo short algorithms, easy to implement, no need for OpenCV.js (several MB)OpenCV.js

Two points. First, the authoritative licence text lives in each model’s own repository, not in a roundup article, so I re-read the licences of MODNet, MediaPipe and ONNX Runtime before committing. Second, “lightest” is not “best for this job”: a detector that returns only a face box cannot tell you how many degrees the head is tilted.

3. Testing the model outside the browser first

Before writing any JavaScript I used Python and onnxruntime to check the pre- and post-processing and to compare three variants of MODNet on a public-domain portrait. Input convention: RGB normalised as (x − 0.5) / 0.5, NCHW layout, shorter side 512, both sides divisible by 32; the output is a single alpha channel in [0, 1].

VariantSizeMean abs. difference from fp3299th percentileRemark
fp3225.9 MB——reference
fp16 (fp16 weights, float32 in/out)13.0 MB0.00010.0010indistinguishable from fp32 in practice
int8 (quantized)6.6 MB0.1610.9998broken: mean alpha 0.377 vs 0.532

The result is surprising if you are used to int8 working well on small classifiers (see the int8 quantization post): a dense matting network, where every pixel needs an accurate continuous value, is far more sensitive to coarse weights and activations. I used fp16: half the download for practically the same output. One caveat on timing, measured once on native CPU for a single image: fp32 2.88 s, fp16 4.23 s, int8 4.18 s. fp16 is not faster on CPU because of the type conversion; its benefit is download size, not speed.

4. Architecture: two phases

The heavy work (face detection, matting) runs once per photo. The parameters people tweak (size, background colour, head ratio, sharpness, edge softness) only affect the render stage, so it is kept separate and dragging a slider shows the result at once.

Phase 1 - analysis (once per photo, ~15-25 s)
  photo -> decode (respecting EXIF) -> MediaPipe: 478 landmarks -> tilt, eyes, chin
        -> MODNet (ONNX Runtime Web, worker): coarse alpha at 512 resolution
        -> upscale alpha to the photo size -> guided filter snapping to source edges
        -> geometry: eye centre, angle theta, head height (crown -> chin)

Phase 2 - render (instant, re-run whenever a setting changes)
  rotate + scale + centre -> ID-photo frame
  -> foreground colour estimate (Blur-Fusion) -> composite on background
  -> optional levels -> unsharp mask -> PNG/JPG at 300 DPI + print sheet

All the code is one JavaScript file (about 520 lines) with no libraries other than ONNX Runtime Web and MediaPipe, both loaded from a CDN. The two models (modnet-fp16.onnx, 12.98 MB, and face_landmarker.task, 3.76 MB) live in the website repo. The first visit downloads about 37 MB in total, including both libraries’ WebAssembly; later visits use the browser cache.

5. Geometry: rotation, head ratio and placement

5.1 Tilt from the eyes

Face Landmarker returns 478 points; points 468 and 473 are the iris centres. With \(\mathbf e_A\) and \(\mathbf e_B\) the two eye centres (ordered left to right in the image), the tilt angle and the inter-eye distance are

$$\theta=\operatorname{atan2}(e_{B,y}-e_{A,y},\;e_{B,x}-e_{A,x}),\qquad E=\lVert\mathbf e_B-\mathbf e_A\rVert.$$

Every point \(\mathbf p\) is expressed in an “upright” frame with its origin at the eye midpoint \(\mathbf c_0\):

$$\begin{pmatrix}x'\\y'\end{pmatrix}=\begin{pmatrix}\cos\theta&\sin\theta\\-\sin\theta&\cos\theta\end{pmatrix}\begin{pmatrix}p_x-c_{0,x}\\p_y-c_{0,y}\end{pmatrix}.$$

In this frame both eyes sit on one horizontal row, and the eye-to-chin distance (landmark 152) is \(H_{ec}=y'_{\text{chin}}\).

5.2 Crown: measured from the matte, clamped by body proportions

ID-photo head height runs from crown to chin, but Face Landmarker has no reliable crown point (hair gets in the way). I take the crown from the matte: rotate the alpha into the upright frame and scan upward from the eye row inside a column band around the face centre; the first row with at least 3 pixels of alpha above 0.5 is the crown. With big hair, a hat or a segmentation error that can be far off, so there are two safeguards. If the matte gives nothing, use the estimate \(y'_{\text{crown}}=-1.17\,H_{ec}\). If the matte value falls outside \([-1.6\,H_{ec},\,-0.95\,H_{ec}]\), clamp it to that range. The 1.17 factor and the two bounds are empirical coefficients of mine, derived from the ratio of eye-to-chin distance to head height; they are not a regulation.

5.3 Mapping into the ID-photo frame

Let \(\rho\) be the head ratio (head height over photo height) and \(h_{out}\) the output height in pixels. The scale is \(\sigma=\rho\,h_{out}/H_{head}\) with \(H_{head}=H_{ec}-y'_{\text{crown}}\). The transform is implemented as a canvas matrix:

$$a=\sigma\cos\theta,\quad b=-\sigma\sin\theta,\quad c=\sigma\sin\theta,\quad d=\sigma\cos\theta,$$

translated so that the point on the crown line directly above the face centre lands at \((w_{out}/2,\;m)\) with top margin \(m=h_{out}(1-\rho)\cdot 0.36\). The 0.36 splits the non-head remainder of the photo roughly 36% above the head and 64% below (neck and shoulders). Sizes and default head ratios:

SizemmPixels at 300 DPIDefault head ratio \(\rho\)
3×430×40354×4720.70
4×640×60472×7090.72
2×320×30236×3540.70
35×4535×45413×5310.75
2×2 inch50.8×50.8600×6000.60

These are common defaults that the user can adjust with a slider; exact rules depend on the receiving authority, so check them before submitting.

var scale = ratio * outH / g.headPx;
var topMargin = outH * (1 - ratio) * 0.36;
var a = scale * cs, b = -scale * sn, c = scale * sn, d = scale * cs;
var e = outW / 2 - (a * P0x + c * P0y), f = topMargin - (b * P0x + d * P0y);
rctx.setTransform(a, b, c, d, e, f);   // draw the source into the ID-photo frame
rctx.drawImage(S.src, 0, 0);

6. Cleaning the edge: from a coarse alpha to a composite

6.1 Guided filter

MODNet runs with the shorter side at 512 pixels, so its alpha is coarse and staircased at the hair edge once scaled up to the source. The guided filter (He, Sun, Tang, 2013) filters the alpha \(p\) using a guide image \(I\) (here the grayscale of the source), so the alpha’s edges snap to real image edges. In every window the output is a linear function of the guide:

$$q_i=\bar a_i I_i+\bar b_i,\qquad a_k=\frac{\operatorname{cov}_k(I,p)}{\operatorname{var}_k(I)+\varepsilon},\qquad b_k=\bar p_k-a_k\bar I_k,$$

where \(\bar a_i,\bar b_i\) are the means of \(a_k,b_k\) over the windows containing pixel \(i\). I implemented it with separable box filters computed by running sums, so the cost does not depend on the radius, using \(r=\max(3,\operatorname{round}(\max(W,H)/260))\) and \(\varepsilon=10^{-4}\).

6.2 Foreground colour estimation (Blur-Fusion)

Even with a correct alpha, compositing \(C=\alpha F+(1-\alpha)B'\) over a new background \(B'\) still shows a fringe if you reuse the source colour \(I\) as \(F\): a pixel on the hair edge is a mix of hair and the old background, so the fringe carries the old background colour (a white halo around hair when switching to a blue background). You need an estimate of the foreground colour \(F\) with the old background removed from semi-transparent pixels. Blur-Fusion (Forte, 2021) does this with a few blurs:

$$\hat F=\frac{\overline{F\alpha}}{\bar\alpha}+\alpha\Bigl(I-\alpha\frac{\overline{F\alpha}}{\bar\alpha}-(1-\alpha)\frac{\overline{B(1-\alpha)}}{1-\bar\alpha}\Bigr),$$

where an overbar is the mean over a window. I run two passes like the author’s reference code: the first uses a large window (radius \(\max(9,\,h_{out}/14)\) pixels), the second a small window (radius 3) on the first pass’s result.

6.3 Sharpening and levels

Sharpening is an unsharp mask, \(I'=I+k\,(I-G_\sigma*I)\), with the Gaussian approximated by three box passes, \(\sigma=\max(0.8,\,h_{out}/520)\), and differences under 2 grey levels ignored so that noise is not amplified. The amount \(k\) is suggested automatically from the sharpness of the face region, measured as the variance of the Laplacian \(\mathrm{Var}(\nabla^2 I)\): under 25 gives \(k=1.4\), under 60 gives 1.1, under 140 gives 0.75, otherwise 0.45. A photo that is already sharp is therefore not over-processed, and a blurry one gets more. The optional levels step stretches the range between the 1st and 99th percentile of subject brightness and blends 60% of that with the original so it stays natural.

7. Running the model in the browser

GitHub Pages has no SharedArrayBuffer, so ONNX Runtime Web runs on one thread. The first version ran inference on the main thread and froze the whole page during matting; the test harness even timed out because the page stopped responding. Setting ort.env.wasm.proxy = true moves inference into a Web Worker and fixes it: sliders still drag and the language still switches while you wait. Initialisation:

libs.ort.env.wasm.wasmPaths = ORT_BASE;      // jsDelivr, onnxruntime-web 1.20.1
libs.ort.env.wasm.proxy = true;               // inference runs in a worker
libs.ort.env.wasm.numThreads = crossOriginIsolated ? Math.min(4, hw) : 1;
libs.session = await libs.ort.InferenceSession.create(modelBuf,
  { executionProviders: ['wasm'], graphOptimizationLevel: 'all' });

If the site later moves to hosting that can send the two isolation headers, this code will use up to 4 threads automatically.

8. Results on real photos

I tested in a real Chrome (driven over CDP), loading photos into the file input as a user would and timing from load to result. The demo photo in this post is a portrait of the Vietnamese swimmer Nguyễn Thị Ánh Viên, a front-facing shot chosen because its licence allows editing and republishing. Below are the original and three results for the same photo, each produced entirely in the browser:

Original: portrait of Nguyễn Thị Ánh Viên against green trees, wearing a blue swim-club top
Original
Result: 3x4 ID photo on a blue background
3×4, blue
Result: 35x45 ID photo on a white background
35×45, white
Result: 3x4 ID photo on a red background
3×4, red

Source of the original: “Nguyen Thi Anh Vien 2023”, HITA TV, taken from a YouTube video, published on Wikimedia Commons under CC BY 3.0. The three results are modified versions by Shopsmon (background removal, rotation, crop, background swap, sharpening), shown with attribution under CC BY 3.0.

For this photo the tool needed no rotation (already upright), kept even the loose strands of hair at the crown, and the head fills about 70% of the height at 3×4 (judged by eye on the output). The same analysis can also output a 10×15 cm print sheet with nine 3×4 photos:

10x15 cm print sheet with nine 3x4 ID photos on blue, each with a thin grey outline
10×15 cm sheet: 3×3 photos of 3×4, thin grey outline for cutting. Exported at 300 DPI.

A tilted-head case

The photo below is a selfie with the head tilted and slightly turned. The tool measured a tilt of \(-18.5^\circ\) and straightened it; it illustrates the alignment step, not a valid ID photo (a turned pose does not meet the front-facing requirement of a real ID photo).

Selfie with a tilted head, pink hair, sitting in front of a monitor
Original
Result after straightening and background removal, 3x4 on blue
After rotating \(-18.5^\circ\), blue

Source of the original: “Hinh-anh-chan-dung-cua-nha-phat-trien-quan-tri-vien-nguyen-thi-thu-huong.jpg”, by Nguyen T Thu Huong (self-published), Wikimedia Commons, CC0.

Timing table and other cases

Test photoSize / backgroundTimeResult
NASA portrait 700×9003×4 / blue17.5 sauto-rotated \(-5.4^\circ\), clean edge; a “low face resolution” warning because the photo is small
Same photo, rotated a further \(14^\circ\), 1600 px35×45 / red20.1 smeasured \(-19.3^\circ\) (\(14^\circ\) plus the original \(5.4^\circ\) tilt), upright result
Astronaut in a white suit2×2 inch / white27.3 shead separated well; the white suit stays because it is foreground, its edge blends slightly into the white
Tilted selfie (above)3×4 / blue21.6 smeasured \(-18.5^\circ\), straightened
Ánh Viên (above)3×4 / blue18.0 sgood, no rotation needed
Ánh Viên35×45 / white20.0 sgood; a very faint light halo remains around the ears and hair
Ánh Viên3×4 / red104.8 sresult fine; the time is far outside the other runs, cause not identified
Cat photo (no face)3×46.8 scorrect error: no face found

The six runs on photos with a face (excluding the 104.8 s one) fall between 17.5 and 27.3 s, median about 20 s, on one test machine with single-thread WebAssembly. These are one machine’s numbers, not a benchmark; another machine will differ. After deploying I re-ran one photo on shopsmon.com itself (4×6, white background): the model and libraries loaded from the CDN and the sheet laid out four photos. No run produced a console error.

9. Bugs hit and how they were fixed

SymptomCauseFix
Page freezes during matting, the test harness times outInference on the main threadort.env.wasm.proxy = true (Web Worker)
Head fills about 58% instead of 70%Wrong crown clamp range: \([-2.1,\,-1.45]\cdot H_{ec}\)Recomputed as \([-1.6,\,-0.95]\cdot H_{ec}\) with the estimate \(-1.17\,H_{ec}\); afterwards the head fills about 72% on the test photo
Completely wrong alpha with int8 MODNetQuantization breaks the matting networkUse the fp16 variant
Elements with the hidden attribute still showCSS display:grid overrides the attributeAdd [hidden]{display:none!important}
The page generator sometimes fails with OSError 22 writing a file on WindowsTransient error while another process scans the fileRetry loop, up to 8 attempts
Wrong author list on the Blur-Fusion citationAn extra author added that is not on the paperChecked the DOI, fixed to a single author (M. Forte)

The second bug is the most instructive: it showed up in no automated check, because the program ran smoothly and the photo looked “reasonable”. I only caught it by measuring the head ratio on the output and comparing it with the set value (70%). The lesson for image products: open the output image and measure it, do not just check that nothing threw an error.

10. Limitations

11. Future work

  1. WebGPU for ONNX Runtime Web, or hosting that allows SharedArrayBuffer so multiple threads can be used.
  2. A “high quality” option with a larger matting model (such as BiRefNet), downloaded only when selected.
  3. A small super-resolution model (for example a light Real-ESRGAN) only when the source photo is too small.
  4. Quality warnings before generating: closed eyes, strong head tilt, glasses, hats.
  5. More country-specific sizes and print-sheet layouts.

12. The reusable process

  1. Fix the constraints (hosting, licence, time budget) before comparing models.
  2. Rule out components with unsuitable licences early.
  3. Try the model in Python, settle pre-processing and numeric variant (fp32, fp16, int8), then write the JavaScript.
  4. Separate the heavy phase from the light one; run the heavy phase in a worker.
  5. Test end to end in a real browser with photos whose source and licence are clear; open the output and measure it.
  6. Test the bad cases: no face, rotated photo, background matching clothing, small photo.
  7. Deploy, wait for the CDN, and re-run a test against the live site.
Need an AI tool that runs right in the browser, or a similar product for your data?
I take this on as a freelancer. Describe the idea in a few sentences and I will reply with questions and a fixed-scope quote.

Try the tool Get a quote

References

  1. Z. Ke, J. Sun, K. Li, Q. Yan and R. W. H. Lau. “MODNet: Real-Time Trimap-Free Portrait Matting via Objective Decomposition.” Proceedings of the AAAI Conference on Artificial Intelligence, 36(1), 2022. doi:10.1609/aaai.v36i1.19999.
  2. K. He, J. Sun and X. Tang. “Guided Image Filtering.” IEEE Transactions on Pattern Analysis and Machine Intelligence, 35(6), 2013, pp. 1397–1409.
  3. M. Forte. “Approximate Fast Foreground Colour Estimation.” IEEE International Conference on Image Processing (ICIP), 2021. doi:10.1109/ICIP42928.2021.9506164.
  4. Google. “MediaPipe Face Landmarker.” Google AI Edge documentation, ai.google.dev/edge/mediapipe.
  5. Microsoft. “ONNX Runtime Web.” onnxruntime.ai/docs/tutorials/web.

The tool runs on the product page (assets/js/idphoto.js). Every measurement in Sections 3 and 8 comes from running the tests described in this post.