{"slug": "building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal", "title": "Building a Browser-Based ID Photo Maker: Face Detection, Head Estimation & Background Removal", "summary": "A developer built a fully client-side ID photo maker using a three-tier face detection chain (MediaPipe, Browser FaceDetector, BlazeFace) with anthropometric head measurement and background removal. The solution runs entirely in the browser, avoiding server costs and enabling instant preview.", "body_md": "The browser has become a surprisingly capable platform for computer vision. In this post, I'll walk through building a fully client-side ID photo maker — covering multi-tier face detection, anthropometric head measurement, segmentation-refined cropping, and background removal with color decontamination.\n\nID photos have strict requirements: head height ratios, eye-line positions, margin rules. Tools like [HivisionIDPhotos](https://github.com/Zeyi-Lin/HivisionIDPhotos), [dpar39/ppp](https://github.com/dpar39/ppp), and commercial services like [dreamega.ai](https://dreamega.ai) and [photoaid.com](https://photoaid.com) all need to solve the same core problem: given an uploaded portrait, detect the face accurately enough to auto-crop to spec.\n\nServer-side tools can afford heavyweight models (HivisionIDPhotos uses ONNX runtime with dedicated face parsing networks). But if you want to run everything **client-side** — no upload, no server cost, instant preview — the browser environment is unpredictable. Not every user has Chrome's experimental `FaceDetector`\n\nAPI, and WebGL support varies.\n\nOur solution: a **three-tier detection chain** that tries the best option first and gracefully falls back.\n\n```\n// Detection chain: MediaPipe (478 landmarks) → Browser FaceDetector → BlazeFace\nexport async function detectFace(image: HTMLImageElement): Promise<FaceDetectResult> {\n  if (typeof window !== \"undefined\" && !window.isSecureContext) {\n    return { supported: false, face: null, reason: \"insecure-context\" };\n  }\n\n  // 1. MediaPipe Face Landmarker – best quality, 478 landmarks\n  try {\n    const mpResult = await detectWithMediaPipe(image);\n    if (mpResult.face || mpResult.reason === \"no-face-found\") {\n      return mpResult;\n    }\n  } catch { /* Fall through */ }\n\n  // 2. Browser FaceDetector API (Chrome)\n  const detector = await getDetector();\n  if (detector) {\n    try {\n      const nativeResult = await detectWithNative(detector, image);\n      if (nativeResult.face || nativeResult.reason === \"no-face-found\") {\n        return nativeResult;\n      }\n    } catch { /* Fall through */ }\n  }\n\n  // 3. BlazeFace TensorFlow model\n  try {\n    const fallbackResult = await detectWithBlazeFace(image);\n    if (fallbackResult.supported || fallbackResult.reason === \"no-face-found\") {\n      return fallbackResult;\n    }\n  } catch { /* Fall through */ }\n\n  return { supported: false, face: null, reason: \"api-missing\" };\n}\n```\n\nEach tier returns a `FaceBox`\n\nwith bounding coordinates and optional landmarks (eyes, forehead, chin). The key insight: **each detector gives you different landmark richness**, so the geometry estimation adapts.\n\nAccurate head measurement is crucial — a few pixels off and the photo fails compliance. The approach here borrows from [dpar39/ppp](https://github.com/dpar39/ppp)'s `CrownChinEstimator`\n\nand extends it with a MediaPipe-first path:\n\n```\nexport function estimateHeadHeight(face: FaceBox): number {\n  // Best: MediaPipe forehead-to-chin + hair offset (1.35x multiplier)\n  // HivisionIDPhotos avoids this problem by using alpha-channel contour\n  // detection after background removal — a neat trick if you have a server.\n  if (face.foreheadTop && face.chinBottom) {\n    const chinY = Math.max(face.chinBottom.y, face.y + face.height);\n    const foreheadToChin = chinY - face.foreheadTop.y;\n    return foreheadToChin * 1.35;\n  }\n\n  // Good: ppp formula (chinCrownCoeff = 1.7699)\n  if (face.leftEye && face.rightEye) {\n    const ipd = Math.hypot(\n      face.rightEye.x - face.leftEye.x,\n      face.rightEye.y - face.leftEye.y\n    );\n    const eyeMidY = (face.leftEye.y + face.rightEye.y) / 2;\n    const chinY = face.y + face.height;\n    const frownToChin = chinY - eyeMidY;\n    return 1.77 * (ipd + frownToChin);\n  }\n\n  // Fallback: anthropometric multiplier (Farkas LG reference)\n  return face.height * 1.35;\n}\n```\n\nThe 1.35x multiplier accounts for hair volume above the forehead — MediaPipe landmarks don't detect hair, so this is intentionally an overestimate to prevent head cropping. The IPD formula comes from anthropometric research (Farkas LG, \"Anthropometry of the Head and Face\").\n\nLandmarks estimate where the head top *should be*, but segmentation gives you pixel-accurate results. This is where combining both outperforms services like [passport-photo.online](https://passport-photo.online/) that rely purely on face detection — especially for people with voluminous hair:\n\n```\nexport function refineWithSegmentation(\n  geom: HeadGeometry,\n  segBounds: { topY: number; bottomY: number } | null\n): HeadGeometry {\n  if (!segBounds) return geom;\n\n  // Only use segmentation topY — bottomY detects body silhouette, not chin\n  const refinedTopY = Math.min(segBounds.topY, geom.topY);\n\n  return {\n    ...geom,\n    topY: refinedTopY,\n    headHeight: geom.bottomY - refinedTopY,\n  };\n}\n```\n\nAn important gotcha: segmentation `bottomY`\n\nis the *body* bottom (shoulders, torso), not the chin. Using it would over-extend the head measurement. We only take `topY`\n\nfrom segmentation (hair boundary) and keep the chin position from face landmarks.\n\nWith accurate head geometry, auto-cropping becomes a three-step process:\n\n```\nexport function computeAutoFitCropState({ imageWidth, imageHeight, face, spec, canvasWidth, canvasHeight, segBounds }) {\n  const rules = getNormalizedHeadRules(spec);\n  const head = refineWithSegmentation(estimateHeadGeometry(face), segBounds);\n\n  // 1. SCALE — head matches target height ratio\n  const targetH = guide.headBottom - guide.headTop;\n  const scale = clampedTarget / Math.max(1, head.headHeight);\n\n  // 2. PLACE — two anchor strategies\n  if (rules.hasEyeLineRule && hasEyes) {\n    // Strategy A: eyes at target Y (used by ICAO/US passport specs)\n    crop = cropFromAnchor(eyeImg, { x: cw/2, y: eyeTargetY }, ...);\n  } else {\n    // Strategy B: head top at target margin (common in Asian passport specs)\n    crop = cropFromAnchor({ x: head.centerX, y: head.topY }, ...);\n  }\n\n  // 3. GUARD — prevent head from being cropped\n  crop = guard(crop, head, face, ...);\n  return crop;\n}\n```\n\nThe guard pass handles edge cases: crown too close to the top, chin below frame, or both overflowing (in which case it scales down to fit).\n\nWhen replacing backgrounds, edge pixels bleed the original background color. We use `@imgly/background-removal`\n\n(ISNet model) — the same engine behind [remove.bg](https://remove.bg)'s open-source alternative — then apply color decontamination:\n\n``` js\n// Decontaminate semi-transparent edge pixels\nfor (let i = 0; i < rPx.length; i += 4) {\n  let a = rPx[i + 3] / 255;\n\n  // Erode very low alpha pixels — eliminates faint halo\n  if (a < 0.15) { rPx[i + 3] = 0; continue; }\n\n  // Squeeze alpha: remap [0.15, 1] → [0, 1]\n  a = Math.min(1, (a - 0.15) / 0.85);\n  rPx[i + 3] = Math.round(a * 255);\n\n  // Color decontamination for edge pixels\n  if (a > 0.01 && a < 0.95) {\n    const inv = 1 - a;\n    rPx[i]     = clamp255((rPx[i]     - inv * bgR) / a);\n    rPx[i + 1] = clamp255((rPx[i + 1] - inv * bgG) / a);\n    rPx[i + 2] = clamp255((rPx[i + 2] - inv * bgB) / a);\n  }\n}\n```\n\nThe math reverses the alpha compositing formula: if the composited color is `C = α·F + (1-α)·B`\n\n, then the true foreground is `F = (C - (1-α)·B) / α`\n\n. The alpha squeeze (`[0.15, 1] → [0, 1]`\n\n) acts as an erosion filter that kills the soft halo around hair.\n\n**Progressive enhancement works for ML**: The three-tier face detection chain means every user gets the best experience their browser supports, with no manual feature-flag management.\n\n**Separate detection from estimation**: Face detection gives you coordinates; head geometry estimation interprets them. Keeping these separate lets you swap in better models without touching the layout logic.\n\n**Segmentation complements, doesn't replace landmarks**: Use segmentation for what it's good at (hair boundaries, background separation) and landmarks for what they're good at (facial feature positions). Mixing their strengths produces better results than either alone.\n\n**Color decontamination matters**: Without the alpha-unpremultiply step, every background replacement shows a visible color fringe. The three lines of math are worth it.\n\n**Browser ML is production-ready**: Between MediaPipe, TensorFlow.js, and [@imgly](https://dev.to/imgly)/background-removal, you can build a full ID photo pipeline that runs entirely on the client — no server round-trips, no privacy concerns.\n\nHope this helps if you're building similar browser-based CV tools. Happy to answer questions in the comments.", "url": "https://wpnews.pro/news/building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal", "canonical_source": "https://dev.to/luckinprime/browser-based-face-detection-for-id-photos-multi-provider-ai-task-orchestration-1p6o", "published_at": "2026-07-28 16:10:09+00:00", "updated_at": "2026-07-28 16:36:34.413713+00:00", "lang": "en", "topics": ["computer-vision", "developer-tools"], "entities": ["MediaPipe", "BlazeFace", "HivisionIDPhotos", "dpar39/ppp", "dreamega.ai", "photoaid.com"], "alternates": {"html": "https://wpnews.pro/news/building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal", "markdown": "https://wpnews.pro/news/building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal.md", "text": "https://wpnews.pro/news/building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal.txt", "jsonld": "https://wpnews.pro/news/building-a-browser-based-id-photo-maker-face-detection-head-estimation-removal.jsonld"}}