<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: whitetirocket</title>
    <description>The latest articles on DEV Community by whitetirocket (@whitetirocket).</description>
    <link>https://dev.to/whitetirocket</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900468%2Fc5aec947-7d12-4b5b-8307-eca83bab9a36.png</url>
      <title>DEV Community: whitetirocket</title>
      <link>https://dev.to/whitetirocket</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/whitetirocket"/>
    <language>en</language>
    <item>
      <title>I open-sourced passport photo specs for 100 countries (MIT, JSON, public API)</title>
      <dc:creator>whitetirocket</dc:creator>
      <pubDate>Wed, 13 May 2026 19:05:42 +0000</pubDate>
      <link>https://dev.to/whitetirocket/i-open-sourced-passport-photo-specs-for-100-countries-mit-json-public-api-50d2</link>
      <guid>https://dev.to/whitetirocket/i-open-sourced-passport-photo-specs-for-100-countries-mit-json-public-api-50d2</guid>
      <description>&lt;h2&gt;
  
  
  I open-sourced passport photo specs for 100 countries (MIT, JSON, public API)
&lt;/h2&gt;

&lt;p&gt;If you have ever built anything that touches passport or visa photos, you know the boring part is not the face detection or the background segmentation. It is the country specifications. Every country has slightly different size. Different background color. Different file size cap on the upload portal. A different head-height ratio. The documentation is scattered across consulate websites in 12 languages, half of which 404 every six months.&lt;/p&gt;

&lt;p&gt;I have been maintaining this dataset for a year while building &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;IDPhotoSnap&lt;/a&gt;, a free browser-only passport photo tool. Last week I published the whole thing as an open repository at &lt;a href="https://github.com/whitetirocket/passport-photo-specs" rel="noopener noreferrer"&gt;github.com/whitetirocket/passport-photo-specs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MIT licensed. JSON, TypeScript, and Python bindings. 100 countries. 248 document formats. Public HTTP API at &lt;code&gt;idphotosnap.com/api/specs&lt;/code&gt; with no auth and CORS open.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is for the next person building anything in this category - you can skip the country-spec research entirely and focus on the actual product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in it
&lt;/h2&gt;

&lt;p&gt;Each country entry has documents (passport, visa, ID card, driving licence, residence permit). Each document has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DocumentSpec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;             &lt;span class="c1"&gt;// 'italy-visa'&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;           &lt;span class="c1"&gt;// 'Visa'&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;           &lt;span class="c1"&gt;// 'italy-visa-photo'&lt;/span&gt;
  &lt;span class="nx"&gt;widthMm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;        &lt;span class="c1"&gt;// 35&lt;/span&gt;
  &lt;span class="nx"&gt;heightMm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;       &lt;span class="c1"&gt;// 45&lt;/span&gt;
  &lt;span class="nx"&gt;widthPx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;        &lt;span class="c1"&gt;// 413 (at given DPI)&lt;/span&gt;
  &lt;span class="nx"&gt;heightPx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;       &lt;span class="c1"&gt;// 531&lt;/span&gt;
  &lt;span class="nx"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;            &lt;span class="c1"&gt;// 300&lt;/span&gt;
  &lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;     &lt;span class="c1"&gt;// 'Plain light grey'&lt;/span&gt;
  &lt;span class="nx"&gt;bgColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;        &lt;span class="c1"&gt;// '#eeeeee' (hex)&lt;/span&gt;
  &lt;span class="nx"&gt;bgColorLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;   &lt;span class="c1"&gt;// 'Light grey'&lt;/span&gt;
  &lt;span class="nx"&gt;requirements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="c1"&gt;// ['Plain light grey background', ...]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CountrySpec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocumentSpec&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three ways to use it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. HTTP API (no clone)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Full dataset (Schema.org Dataset JSON-LD wrapped)&lt;/span&gt;
curl https://idphotosnap.com/api/specs

&lt;span class="c"&gt;# Filter to one country&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://idphotosnap.com/api/specs?country=china&amp;amp;format=raw"&lt;/span&gt;

&lt;span class="c"&gt;# Plain JSON (no JSON-LD wrapper)&lt;/span&gt;
curl https://idphotosnap.com/api/specs?format&lt;span class="o"&gt;=&lt;/span&gt;raw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No auth. CORS open. Edge-cached 1 hour. Use in production without permission.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. NPM-style import
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// npm install github:whitetirocket/passport-photo-specs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;findDocument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;findCountry&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;passport-photo-specs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chinaVisa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;china-visa-photo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// { widthMm: 33, heightMm: 48, ... }&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;india&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findCountry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;india&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// All India docs: passport, visa, OCI, PAN card, PCC, driving licence, voter ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Plain JSON in any language
&lt;/h3&gt;

&lt;p&gt;The complete dataset is at &lt;a href="https://github.com/whitetirocket/passport-photo-specs/blob/main/specs/specs.json" rel="noopener noreferrer"&gt;&lt;code&gt;specs/specs.json&lt;/code&gt;&lt;/a&gt; (174 KB). Examples for Python, Go, and Rust are in &lt;a href="https://github.com/whitetirocket/passport-photo-specs/tree/main/examples" rel="noopener noreferrer"&gt;&lt;code&gt;examples/&lt;/code&gt;&lt;/a&gt; - they show the three most common patterns: filter to one country, lookup by slug, validate spec consistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I bothered
&lt;/h2&gt;

&lt;p&gt;Most passport photo tools claim "200+ countries supported" or "900+ document types". When you check what that means, two patterns appear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;They have one default spec (35x45mm, white background, ICAO 9303) and apply it as a fallback for every country that has not been independently validated. So "200 countries" is really "30 verified plus 170 ICAO defaults".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;They scrape specs from each other rather than from the original government source. When a country updates its requirements (which happens every few years), the change takes 6-12 months to propagate.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a real passport application this matters. A wrong photo gets rejected at the consulate, application delayed by days or weeks. The user paid $15 for the photo at a drugstore plus $200 for the visa, and the photo is the part that fails.&lt;/p&gt;

&lt;p&gt;I wanted a single source of truth I could verify against government documentation directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notable specs that catch tool builders
&lt;/h2&gt;

&lt;p&gt;If you are building something in this category, these formats are commonly mis-specified across the ecosystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chinese visa is 33x48mm&lt;/strong&gt;, not 35x45 like Schengen, not square like US. Unique format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chinese visa COVA portal&lt;/strong&gt; wants file size 40 KB to 1 MB, JPG only, 354x472 px minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US DS-160 visa upload caps files at 240 KB&lt;/strong&gt;. Square 600x600 minimum, 1200x1200 maximum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indian Sarathi driving licence requires 20-50 KB&lt;/strong&gt; file size &lt;em&gt;window&lt;/em&gt; (under or over both fail silently).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indian PAN card is 25x35mm&lt;/strong&gt; at 200x230 px maximum, 10-300 KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UK passport accepts light grey background&lt;/strong&gt; in addition to white. One of few that does. Glasses banned since 2018.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;German Personalausweis&lt;/strong&gt; has a May 2025 rule requiring digital-only photos via Buergeramt for German citizens. Separate from German Schengen visa for foreign applicants, which still accepts standard 35x45mm digital photos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the dataset's biggest non-obvious wins. If your tool gets these seven right, you cover the meaningfully different cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation methodology
&lt;/h2&gt;

&lt;p&gt;For each country, I checked at least one of these sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US Department of State (travel.state.gov)&lt;/li&gt;
&lt;li&gt;UK HM Passport Office (gov.uk/photos-for-passports)&lt;/li&gt;
&lt;li&gt;German Bundesdruckerei + Auswaertiges Amt&lt;/li&gt;
&lt;li&gt;Italian Polizia di Stato + Questura&lt;/li&gt;
&lt;li&gt;French ANTS (ants.gouv.fr)&lt;/li&gt;
&lt;li&gt;Spanish Ministerio de Asuntos Exteriores&lt;/li&gt;
&lt;li&gt;Canadian IRCC (Immigration, Refugees and Citizenship Canada)&lt;/li&gt;
&lt;li&gt;Australian Department of Foreign Affairs and Trade&lt;/li&gt;
&lt;li&gt;Indian Passport Seva Kendra (passportindia.gov.in)&lt;/li&gt;
&lt;li&gt;Indian RTO / Sarathi / Parivahan portals&lt;/li&gt;
&lt;li&gt;Chinese Ministry of Foreign Affairs (cova.cs.mfa.gov.cn)&lt;/li&gt;
&lt;li&gt;Japanese Ministry of Foreign Affairs (mofa.go.jp)&lt;/li&gt;
&lt;li&gt;Schengen visa code Annex 11 (ICAO 9303)&lt;/li&gt;
&lt;li&gt;EU Entry/Exit System (EES) specifications&lt;/li&gt;
&lt;li&gt;New Zealand DIA (passports.govt.nz)&lt;/li&gt;
&lt;li&gt;Brazilian Polícia Federal&lt;/li&gt;
&lt;li&gt;Mexican SRE (gob.mx)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond top-20 countries, I follow the ICAO 9303 default unless an official source documents a country-specific deviation. I do not pad the dataset with synthetic country variants. If a country's photo spec is identical to ICAO 9303 default, it is marked as such rather than counted as "unique".&lt;/p&gt;

&lt;p&gt;This means the 100 country count is honest. I could have called it 193 (all ICAO member states) and claimed parity with competitors. I chose not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture (in case you are building something similar)
&lt;/h2&gt;

&lt;p&gt;The companion tool, IDPhotoSnap, runs the whole pipeline in the browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Face landmark detection&lt;/strong&gt; via &lt;code&gt;face-api.js&lt;/code&gt; (TF.js + WASM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background segmentation&lt;/strong&gt; via BRIA RMBG-1.4 (ONNX Runtime Web)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geometric cropping + JPEG export&lt;/strong&gt; via Canvas API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print-ready PDF&lt;/strong&gt; via jsPDF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The photo never reaches a server. Verifiable in browser DevTools Network tab during the workflow - zero photo uploads, only static assets and ML model weights.&lt;/p&gt;

&lt;p&gt;This matters because passport photos are biometric data under GDPR Article 9, Illinois BIPA, Texas CUBI, India DPDP Act 2023. Server-based architecture inherits compliance obligations; browser-only sidesteps the whole chain.&lt;/p&gt;

&lt;p&gt;If you want a deeper writeup on the browser-only architecture and how to verify the claim, &lt;a href="https://idphotosnap.com/blog/privacy-first-passport-photo-maker-2026" rel="noopener noreferrer"&gt;my privacy-first explainer covers the DevTools test&lt;/a&gt; in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want from this
&lt;/h2&gt;

&lt;p&gt;If you find a spec that is wrong, open an issue with a link to the official government source. If you want to add a country I do not have, open a PR with the spec plus the citation.&lt;/p&gt;

&lt;p&gt;I will not accept specs copied from other photo tool aggregators. Government sources only - this is the data quality discipline that keeps the dataset from drifting into the same marketing inflation as the rest of the category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Used in production
&lt;/h2&gt;

&lt;p&gt;The dataset powers &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;IDPhotoSnap&lt;/a&gt;. If you build something else with it, open a PR adding your project to the "Used by" section of the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/whitetirocket/passport-photo-specs" rel="noopener noreferrer"&gt;github.com/whitetirocket/passport-photo-specs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw JSON&lt;/strong&gt;: &lt;a href="https://github.com/whitetirocket/passport-photo-specs/blob/main/specs/specs.json" rel="noopener noreferrer"&gt;specs/specs.json&lt;/a&gt; (174 KB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live HTTP API&lt;/strong&gt;: &lt;a href="https://idphotosnap.com/api/specs" rel="noopener noreferrer"&gt;idphotosnap.com/api/specs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer docs&lt;/strong&gt;: &lt;a href="https://idphotosnap.com/developers" rel="noopener noreferrer"&gt;idphotosnap.com/developers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examples (Python / Go / Rust)&lt;/strong&gt;: &lt;a href="https://github.com/whitetirocket/passport-photo-specs/tree/main/examples" rel="noopener noreferrer"&gt;&lt;code&gt;examples/&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License&lt;/strong&gt;: MIT (attribution appreciated, not required)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comments and corrections are welcome.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>webdev</category>
      <category>api</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Validating Passport Photos for 3 of the Strictest Government Portals (India, China, US)</title>
      <dc:creator>whitetirocket</dc:creator>
      <pubDate>Mon, 11 May 2026 06:24:16 +0000</pubDate>
      <link>https://dev.to/whitetirocket/validating-passport-photos-for-3-of-the-strictest-government-portals-india-china-us-5bbp</link>
      <guid>https://dev.to/whitetirocket/validating-passport-photos-for-3-of-the-strictest-government-portals-india-china-us-5bbp</guid>
      <description>&lt;h2&gt;
  
  
  Validating Passport Photos for 3 of the Strictest Government Portals (India, China, US)
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Passport photo validation looks like a solved problem. Crop to the country spec, slap on a white background, save as JPG. Done.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then you start submitting to actual government portals and discover that each one has its own undocumented edge cases that reject otherwise correct photos. The crop is right. The background is right. The portal still rejects.&lt;/p&gt;

&lt;p&gt;I run a free browser-based passport photo tool covering 85+ countries. Building country-specific validation has surfaced some genuinely strange constraints. Here are the three that taught me the most: India's Sarathi/Parivahan, China's COVA, and the US DS-160.&lt;/p&gt;

&lt;h2&gt;
  
  
  India: Sarathi and Parivahan portals
&lt;/h2&gt;

&lt;p&gt;The Indian Ministry of Road Transport and Highways runs two portals for driving licenses and vehicle registration: Sarathi (for citizens) and Parivahan (the umbrella service). Both accept passport-style photos for license applications.&lt;/p&gt;

&lt;p&gt;Documented spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;35 x 45 mm&lt;/li&gt;
&lt;li&gt;Plain white background&lt;/li&gt;
&lt;li&gt;JPG only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Undocumented constraint that catches every tool I have tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File size must be &lt;strong&gt;20 to 50 KB&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not 50 KB max. A 20 KB minimum too. A clean photo from a modern phone, properly cropped to 35 x 45 mm at 300 DPI, lands at 80-150 KB. The portal silently rejects anything above 50 KB with a generic error like "image format not accepted".&lt;/p&gt;

&lt;p&gt;The actual fix is JPEG quality compression, but you cannot just lower the quality slider blindly. At quality 60, the file size hits the range but Sarathi's automated face detection rejects "low image quality". At quality 75, the file is 60-70 KB and still rejected. The narrow window is quality 70 with subsampling 4:2:0 and progressive encoding off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Browser-side compression for Sarathi/Parivahan&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressForSarathi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;
    &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not hit Sarathi 20-50 KB window&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PAN card portal is similar: 25 x 35 mm size, but with a different range (10 to 300 KB) and no minimum on dimensions, only a 200 x 230 px maximum.&lt;/p&gt;

&lt;p&gt;For the validated DIY workflow see the &lt;a href="https://idphotosnap.com/india-driving-license-photo" rel="noopener noreferrer"&gt;Indian driving license photo tool&lt;/a&gt; which auto-fits the 20-50 KB window before output.&lt;/p&gt;

&lt;h2&gt;
  
  
  China: COVA online visa portal
&lt;/h2&gt;

&lt;p&gt;The China Online Visa Application portal (cova.cs.mfa.gov.cn) is the modern alternative to the 2 printed photos at a CVASC visa center. Foreigners applying for a Chinese visa from anywhere in the world upload through the same system.&lt;/p&gt;

&lt;p&gt;Documented spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;33 x 48 mm (note: not 35 x 45)&lt;/li&gt;
&lt;li&gt;Pure white background&lt;/li&gt;
&lt;li&gt;JPG only&lt;/li&gt;
&lt;li&gt;354 x 472 px minimum&lt;/li&gt;
&lt;li&gt;40 KB to 1 MB file size&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Undocumented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The portal validates background uniformity by sampling pixels in 4 corners. If any corner pixel is not in the range R[245-255] G[245-255] B[245-255], it returns error E002.&lt;/li&gt;
&lt;li&gt;Eyes must be detected as fully open. Even slightly squinted photos return E003.&lt;/li&gt;
&lt;li&gt;The face must occupy 60-70% of frame height. Anything below 55% or above 75% returns E001 even though the file dimensions are correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The corner pixel check matters because most "white" backgrounds in real photos are slightly off-white from ambient light. A photo taken against a true-white wall in your living room often has corners reading R[238-242] - looks white to a human, fails the COVA validator.&lt;/p&gt;

&lt;p&gt;The workaround is post-processing the background to pure #FFFFFF after segmentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flattenBackgroundToPureWhite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// pixel is background&lt;/span&gt;
      &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;  &lt;span class="c1"&gt;// R&lt;/span&gt;
      &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;  &lt;span class="c1"&gt;// G&lt;/span&gt;
      &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;  &lt;span class="c1"&gt;// B&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;imageData&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Background segmentation runs on BRIA RMBG-1.4 in WebAssembly. Once the mask is computed, flattening to pure white is cheap. The visa-specific validator is at the &lt;a href="https://idphotosnap.com/china-visa-photo" rel="noopener noreferrer"&gt;China visa photo tool&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  US DS-160: the 240 KB cap
&lt;/h2&gt;

&lt;p&gt;The US State Department's DS-160 form for nonimmigrant visa applications has the strictest documented file size cap of any major portal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;240 KB maximum&lt;/strong&gt; for the photo upload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is below the natural file size of a properly cropped 600 x 600 px JPG at any reasonable quality setting. The portal also enforces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Square aspect ratio (1:1)&lt;/li&gt;
&lt;li&gt;600 x 600 px minimum, 1200 x 1200 px maximum&lt;/li&gt;
&lt;li&gt;JPEG only&lt;/li&gt;
&lt;li&gt;White background, head 50-69% of frame&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 240 KB cap means JPEG quality must be in the 60-70 range AND dimensions cannot exceed about 800 x 800 px. Smaller dimensions actually help here - a 600 x 600 px photo at quality 80 fits comfortably.&lt;/p&gt;

&lt;p&gt;The same cap does not apply to the paper passport photo (which is 51 x 51 mm at 300 DPI, no file size limit). DS-160 is digital-only, separate constraint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DS-160-compliant output (square + 240 KB cap)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportForDS160&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Force square 600x600&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Tune quality to fit 240 KB&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not fit 240 KB cap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validated US flow is at the &lt;a href="https://idphotosnap.com/us-passport-photo" rel="noopener noreferrer"&gt;US passport photo tool&lt;/a&gt; which produces the DS-160-compatible square JPG plus a print-ready 51 x 51 mm PDF in the same export.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture summary
&lt;/h2&gt;

&lt;p&gt;Three portals, three different constraints, all of which the browser can solve without uploading anything.&lt;/p&gt;

&lt;p&gt;The architecture I settled on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture or upload&lt;/strong&gt; in the browser. No server, no upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Face detection&lt;/strong&gt; with face-api.js (WebAssembly + tiny TF model). Returns landmarks for cropping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background segmentation&lt;/strong&gt; with BRIA RMBG-1.4 (WebAssembly inference). Returns a per-pixel mask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Country-specific cropping&lt;/strong&gt; based on each spec (head height, dimensions, ratio).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background replacement&lt;/strong&gt; to pure white or country-specific (light grey for German, white for most).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG export&lt;/strong&gt; with country-specific quality tuning to hit file-size constraints.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Privacy-wise the win is that biometric data (face, segmentation mask) never leaves the device. Practically, the win is that you can run validation against the actual portal constraints before the user even tries to upload, saving them the rejected-application loop.&lt;/p&gt;

&lt;p&gt;The full tool is at &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;idphotosnap.com&lt;/a&gt; - free, no signup, no watermark, 85+ countries.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Why not just use a server for image processing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For passport photos specifically, the photo contains biometric data (face, expression, distinguishing marks). Many users of these tools are visa applicants from one country applying to another - exactly the scenario where regulators (and users) prefer the photo never reach a third-party server. WebAssembly inference makes this practical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How accurate is browser-side background segmentation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BRIA RMBG-1.4 is good enough for hairline accuracy in most lighting conditions. The failure mode is high-contrast hair against a light background - here the segmentation occasionally clips fine strands. We mitigate by running a guided filter post-processing pass on the mask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What about countries with light grey backgrounds (UK, Germany)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same architecture, different output color. The mask defines the background region; replacement is just &lt;code&gt;fillStyle = '#eeeeee'&lt;/code&gt; instead of &lt;code&gt;#ffffff&lt;/code&gt; for those cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why do countries diverge so much on file size limits?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most portals were built when JPEG was the only practical format and bandwidth was tight. The 240 KB DS-160 cap predates HTTPS being the default - it is a 2010-era constraint that no one updated as cameras got better. The 20 KB minimum on Sarathi is anti-malware (rejects very small files that might be exploit payloads). Combined, these heuristics are the legacy you have to code around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Open source?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The validators are not, but the architecture pattern is in the article. BRIA RMBG-1.4 and face-api.js are both open weights. WebAssembly is just WebAssembly.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your passport photo keeps getting rejected (it's the file, not the picture)</title>
      <dc:creator>whitetirocket</dc:creator>
      <pubDate>Mon, 04 May 2026 04:49:35 +0000</pubDate>
      <link>https://dev.to/whitetirocket/why-your-passport-photo-keeps-getting-rejected-its-the-file-not-the-picture-23cn</link>
      <guid>https://dev.to/whitetirocket/why-your-passport-photo-keeps-getting-rejected-its-the-file-not-the-picture-23cn</guid>
      <description>&lt;p&gt;If you've ever uploaded a passport photo to a government portal and gotten a vague "photo rejected" error, the problem usually isn't the photo. It's the file.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;IDPhotoSnap&lt;/a&gt;, a free browser-based passport photo tool, for a few months now. The single most common support question is some flavor of "my photo looks fine, why does the portal say it's wrong?"&lt;/p&gt;

&lt;p&gt;The answer almost always lives in the file's metadata, not the visible image. Here's the breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8 file-level rejection reasons
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. File size out of range
&lt;/h3&gt;

&lt;p&gt;Most embassy portals enforce strict caps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US State Department DS-160: 240 KB max&lt;/li&gt;
&lt;li&gt;UK passport portal: 50 KB - 10 MB&lt;/li&gt;
&lt;li&gt;Schengen visa portals: 240 KB - 6 MB depending on country&lt;/li&gt;
&lt;li&gt;India passport seva: 20 KB - 300 KB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A modern phone shoots 4-8 MB by default. The portal rejects before any human sees the picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Wrong DPI
&lt;/h3&gt;

&lt;p&gt;DPI is metadata. It doesn't change pixel data — it just labels the image as "intended for printing at this density". Phone cameras tag photos at 72 DPI. Embassy print pipelines require 300.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a JPEG, DPI lives in the JFIF header (bytes 13-18) or EXIF tag 0x011A.&lt;/span&gt;
&lt;span class="c1"&gt;// Changing it does NOT recompress or resize - just rewrites those bytes.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can verify in any terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;identify &lt;span class="nt"&gt;-format&lt;/span&gt; &lt;span class="s2"&gt;"%x x %y&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; photo.jpg  &lt;span class="c"&gt;# ImageMagick&lt;/span&gt;
&lt;span class="c"&gt;# Output: 72x72  ← needs to be 300x300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pixel content is identical. The metadata tag is what trips the validator.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Wrong dimensions
&lt;/h3&gt;

&lt;p&gt;Every country uses different size requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;td&gt;600×600 px (2×2 inches)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schengen&lt;/td&gt;
&lt;td&gt;35×45 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK&lt;/td&gt;
&lt;td&gt;35×45 mm at 600×750 px minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;India&lt;/td&gt;
&lt;td&gt;51×51 mm at 600×600 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japan&lt;/td&gt;
&lt;td&gt;35×45 mm at 413×531 px&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A photo that passes for one country fails for another. There's no universal size.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Wrong format (HEIC, WebP, PNG)
&lt;/h3&gt;

&lt;p&gt;iPhones save HEIC by default. Android sometimes saves WebP. Most government portals only accept JPG. About half also reject PNG.&lt;/p&gt;

&lt;p&gt;The HEIC → JPG conversion can be done client-side with libheif compiled to WASM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libheif-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;heicToJpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="c1"&gt;// ... draw onto canvas, export as JPEG with quality 0.92&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Background isn't pure white
&lt;/h3&gt;

&lt;p&gt;Background validators look for RGB(255,255,255) ± a small delta. Common failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Off-white walls (255, 250, 245)&lt;/li&gt;
&lt;li&gt;Window light gradient across the wall&lt;/li&gt;
&lt;li&gt;Soft shadow behind the head&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For true compliance, replace the background entirely. ML segmentation models like MODNet (~25 MB ONNX) run in-browser via onnxruntime-web.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Compression artifacts
&lt;/h3&gt;

&lt;p&gt;Quality 60% JPEG produces visible block artifacts. Validators sometimes flag low SSIM. Recompress at quality 90-95%, target the size limit by re-trying with smaller pixel dimensions if needed — never below the size threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Color profile mismatch
&lt;/h3&gt;

&lt;p&gt;Display-P3 photos from iPhones can fail validators that expect sRGB. Convert before export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageSmoothingEnabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// canvas defaults to sRGB - the act of drawing converts it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8. Embedded thumbnail mismatch
&lt;/h3&gt;

&lt;p&gt;Obscure but real: some portals compare the EXIF thumbnail to the main image. If they differ (e.g., you cropped the main but the thumbnail is the original), it's flagged as edited. Strip EXIF entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Re-encoding via canvas removes all EXIF/XMP/IPTC metadata&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a browser-only tool makes sense here
&lt;/h2&gt;

&lt;p&gt;All the operations above are pure pixel manipulation. None of them require server compute. None of them require AI in the cloud. Even background replacement runs locally with onnxruntime-web at ~2-5 seconds per image on a mid-range laptop.&lt;/p&gt;

&lt;p&gt;Uploading a photo of your face to a third-party service to do work that runs fine in WebAssembly is bad architecture and worse privacy.&lt;/p&gt;

&lt;p&gt;If you want to see this approach in action, &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;IDPhotoSnap&lt;/a&gt; handles all 8 of these issues for 85+ countries with zero uploads. There's a separate &lt;a href="https://idphotosnap.com/fix/photo-rejected-passport" rel="noopener noreferrer"&gt;Photo Rejected hub&lt;/a&gt; that diagnoses an existing rejected photo and fixes the specific issue, also entirely client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson
&lt;/h2&gt;

&lt;p&gt;When a government portal rejects "a fine-looking photo," 90% of the time it's reading the file's metadata, not the picture. Engineering for this is mostly about being deliberate about what you write into the JPEG header — DPI tag, dimensions, color profile, embedded thumbnails — not about the pixels themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Why do passport portals not give specific error messages?&lt;/strong&gt;&lt;br&gt;
A: They run a chain of validators (size → format → DPI → dimensions → background) and abort on the first failure. Some surface only the last failure code. Many surface nothing useful at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Will printing the photo fix DPI?&lt;/strong&gt;&lt;br&gt;
A: Yes for in-person submission. No for online portals — they read the file metadata, not the print.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I just convert HEIC to JPG and call it done?&lt;/strong&gt;&lt;br&gt;
A: Often yes for size and format checks. But the converter often loses the DPI tag (defaults to 72) and the dimensions stay phone-default, so 50% of the time you also need a resize and a DPI rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is server-side processing ever needed for this?&lt;/strong&gt;&lt;br&gt;
A: Not for 99% of cases. Background removal is the only borderline case (large model file). Everything else fits comfortably in Canvas + a few KB of code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Building a privacy-first passport photo tool that runs entirely in the browser</title>
      <dc:creator>whitetirocket</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:03:10 +0000</pubDate>
      <link>https://dev.to/whitetirocket/building-a-privacy-first-passport-photo-tool-that-runs-entirely-in-the-browser-i94</link>
      <guid>https://dev.to/whitetirocket/building-a-privacy-first-passport-photo-tool-that-runs-entirely-in-the-browser-i94</guid>
      <description>&lt;p&gt;I launched IDPhotoSnap on Product Hunt today. It's a free passport, visa, and ID photo maker for 85+ countries. Here's the technical writeup of why it runs 100% in the browser, what that bought me, and where the tradeoffs were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client-side
&lt;/h2&gt;

&lt;p&gt;The straightforward way to build this product would have been:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User uploads photo → server&lt;/li&gt;
&lt;li&gt;Server runs image processing (crop, resize, background)&lt;/li&gt;
&lt;li&gt;Server sends back result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the architecture every paid competitor uses. It also costs money to run, requires user accounts to manage abuse, and creates a privacy concern: somewhere on a server is a database of passport photos.&lt;/p&gt;

&lt;p&gt;Client-side processing flips all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; $0 compute. The user's phone does the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuse model:&lt;/strong&gt; there's nothing to abuse. There's no server to overload, no API to rate-limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy:&lt;/strong&gt; the photo never leaves the device. This is verifiable — open DevTools and watch the network tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's actually running in the browser
&lt;/h2&gt;

&lt;p&gt;The core processing pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;faceBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectFace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cropBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;faceBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cropBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three steps that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Face detection&lt;/strong&gt; uses the FaceDetector API where available (Chrome on Android, recent Safari) and falls back to a small TensorFlow.js model on browsers that don't support it. The fallback adds about 4MB to the initial load but only loads on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Crop computation&lt;/strong&gt; is country-specific. Each country has documented requirements like "face must occupy 70-80% of the frame, vertically centered, eyes at 60% from the bottom." These are encoded as JSON specs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width_mm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height_mm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dpi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"face"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"min_height_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_height_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"vertical_center_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#FFFFFF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"head_position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"centered"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting these specs right was the actual hard work. Some governments publish them well. Most don't. I ended up cross-referencing consulate PDFs in the local language for about half the countries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Canvas rendering&lt;/strong&gt; is just &lt;code&gt;drawImage&lt;/code&gt; with the computed bounding box, then &lt;code&gt;toDataURL&lt;/code&gt; for export. No magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background removal
&lt;/h2&gt;

&lt;p&gt;This was the part where I almost gave up on client-side. Background removal historically meant a U-Net or similar segmentation model — too heavy for the browser.&lt;/p&gt;

&lt;p&gt;The answer was the &lt;a href="https://developers.google.com/mediapipe" rel="noopener noreferrer"&gt;MediaPipe Selfie Segmentation&lt;/a&gt; model. It's about 256KB, runs at 30fps on a mid-range phone, and produces a soft alpha mask good enough for passport photo backgrounds. After segmentation, I composite over a white canvas. Done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I lost by going client-side
&lt;/h2&gt;

&lt;p&gt;Three real tradeoffs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No analytics on uploaded photos.&lt;/strong&gt; Useful for debugging but obviously not viable here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initial load is heavier.&lt;/strong&gt; First visit fetches face detection fallback + segmentation model. Total: ~5MB. After cache, instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No batch processing.&lt;/strong&gt; Can't queue 1000 photos through. But this is a passport photo tool — one photo at a time is the use case.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I gained
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hosting cost is $0.&lt;/strong&gt; Just a static site on Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GDPR exposure.&lt;/strong&gt; No user data is collected because none is transmitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Genuinely free.&lt;/strong&gt; Because there's no compute cost, the product can stay free forever without ads, subscriptions, or tier-locking.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  On Product Hunt
&lt;/h2&gt;

&lt;p&gt;If you want to see the result, the launch is here: &lt;a href="https://www.producthunt.com/products/idphotosnap" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/idphotosnap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The site itself is at &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;https://idphotosnap.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: How does this make money?&lt;/strong&gt;&lt;br&gt;
It doesn't yet. If traffic grows, I'll add unobtrusive ads. No subscription tier planned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why not WebAssembly for face detection?&lt;/strong&gt;&lt;br&gt;
The FaceDetector API is fast enough on modern phones and the TF.js fallback handles older browsers. WASM would be a reasonable optimization later but isn't blocking anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What if I want to verify the photo doesn't leave my device?&lt;/strong&gt;&lt;br&gt;
Open DevTools → Network tab → upload a photo. You'll see no requests carrying image data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I self-host?&lt;/strong&gt;&lt;br&gt;
Not open source yet. Possibly in the future after the codebase stabilizes.&lt;/p&gt;

&lt;p&gt;Feedback welcome. The hardest thing right now isn't the code, it's getting the country specs right — if you've used the tool for a country and the result was rejected, I want to know.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
