<?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: Fox</title>
    <description>The latest articles on DEV Community by Fox (@deepfox).</description>
    <link>https://dev.to/deepfox</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961377%2Fc098f7d3-30f1-453b-ae8b-5de7b02f5238.jpg</url>
      <title>DEV Community: Fox</title>
      <link>https://dev.to/deepfox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deepfox"/>
    <language>en</language>
    <item>
      <title>The hard part of national ID OCR isn't the OCR</title>
      <dc:creator>Fox</dc:creator>
      <pubDate>Fri, 19 Jun 2026 03:32:18 +0000</pubDate>
      <link>https://dev.to/deepfox/the-hard-part-of-national-id-ocr-isnt-the-ocr-1oon</link>
      <guid>https://dev.to/deepfox/the-hard-part-of-national-id-ocr-isnt-the-ocr-1oon</guid>
      <description>&lt;p&gt;You wire up OCR for your KYC flow, point it at a national ID card, and get back a clean &lt;code&gt;{ name, idNumber, dateOfBirth }&lt;/code&gt;. Ship it. Then you onboard your second country — and it falls apart. Fields you mapped don't exist. The name comes back as garbled Latin. The date of birth says the year 2567.&lt;/p&gt;

&lt;p&gt;Here's the thing nobody tells you when you start: &lt;strong&gt;the hard part of national ID OCR isn't the OCR. It's that every country's ID is a different document.&lt;/strong&gt; A model that reads text off a card is table stakes. Turning 30 countries' cards into data your system can actually use is where the work is.&lt;/p&gt;

&lt;p&gt;Let me show you the three axes of variation that will bite you, then how to architect so they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 1: the fields are different
&lt;/h2&gt;

&lt;p&gt;There is no universal "national ID" schema, because the cards themselves don't agree on what to print.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Thai&lt;/strong&gt; ID card prints the holder's &lt;strong&gt;religion&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;German&lt;/strong&gt; ID card prints &lt;strong&gt;height&lt;/strong&gt; and &lt;strong&gt;eye color&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Chinese&lt;/strong&gt; ID card prints &lt;strong&gt;ethnicity&lt;/strong&gt; and the issuing authority.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are edge cases — they're core fields on those documents. So the instinct to define one &lt;code&gt;IdCard&lt;/code&gt; type with a fixed set of columns is wrong from day one. Either you drop information that some countries consider essential, or you end up with a sparse table full of &lt;code&gt;null&lt;/code&gt;s and country-specific special-casing.&lt;/p&gt;

&lt;p&gt;And it's not just &lt;em&gt;which&lt;/em&gt; fields exist — it's what they're called and how they're split. The same "name" concept might come back as a single full-name string on one card and as separate given/family fields on another, sometimes in two scripts at once. Your data model has to treat "the field set depends on the country" as a first-class fact, not an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 2: the script is different
&lt;/h2&gt;

&lt;p&gt;If your users are global, a lot of their names are not in the Latin alphabet — Chinese, Thai, Arabic, and more.&lt;/p&gt;

&lt;p&gt;The naive move is to transliterate everything to Latin "so it's consistent." Don't. Transliteration is lossy and ambiguous: multiple native spellings collapse to the same Latin form, diacritics get dropped, and you can no longer match the name back against the source document or a government database. For KYC specifically, mangling the name defeats the purpose.&lt;/p&gt;

&lt;p&gt;The correct approach is to &lt;strong&gt;keep the native-script value as printed, and carry a Latin form alongside it when the card itself prints one&lt;/strong&gt; (many do — they show both). That way you have a local handle for matching against local records and a Latin handle for systems that need ASCII, without throwing away the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 3: the format is different — dates and ID numbers
&lt;/h2&gt;

&lt;p&gt;Two formats will surprise you specifically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates aren't all Gregorian.&lt;/strong&gt; A Thai card prints dates in the &lt;strong&gt;Buddhist calendar (BE)&lt;/strong&gt;, which runs &lt;strong&gt;543 years ahead&lt;/strong&gt; of the Gregorian year and, on top of that, uses &lt;strong&gt;Thai numerals&lt;/strong&gt; rather than Arabic digits. So two things break a naive parser: the digits won't &lt;code&gt;int()&lt;/code&gt;-parse at all, and even once you read the number, treating it as a Gregorian year leaves every age check and expiry comparison off by 543 years. The fix is to convert the numerals, subtract 543, and normalize to a single representation (ISO 8601 is the sane choice) — while keeping the original string around for display. (You'll see exactly this — the raw Thai date next to the normalized one — in the response further down.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ID numbers have structure.&lt;/strong&gt; A Thai national ID is 13 digits; a Chinese one is 18. Many encode a checksum digit, region codes, or a date. That structure is genuinely useful — you &lt;em&gt;can&lt;/em&gt; validate the checksum to catch a misread early — but note the responsibility split: an OCR step gives you the number &lt;strong&gt;as printed&lt;/strong&gt;; validating it against each country's rule is logic &lt;em&gt;you&lt;/em&gt; own, per country. Don't assume the extraction layer does it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to deal with this
&lt;/h2&gt;

&lt;p&gt;Once you accept that ID OCR is per-country, you have two implementation paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the per-country layer yourself.&lt;/strong&gt; Run a generic OCR/vision model, then write, per country, the field mapping, the script handling, the date-calendar conversion, and the number parsing — plus tests with real sample cards for each. This is doable, but the cost is &lt;em&gt;linear in the number of countries you support&lt;/em&gt;, and it never really ends: layouts get redesigned, new document versions ship, a new market means a new adapter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use an API that already encodes the per-country knowledge.&lt;/strong&gt; You hand it an image and tell it which country/document you're reading; it gives back the fields that document actually has, in the right scripts, with dates normalized. You've outsourced the heterogeneity instead of maintaining it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're supporting more than two or three countries, the second path is usually the honest economic call. &lt;a href="https://pictotext.io/products/id-card-ocr" rel="noopener noreferrer"&gt;PicToText's ID Card OCR API&lt;/a&gt; is one option built around exactly this per-country model, so the rest of this post shows what that looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;You send one image and a country-specific &lt;code&gt;documentType&lt;/code&gt; — the &lt;code&gt;documentType&lt;/code&gt; is how you select which national format to read.  With &lt;strong&gt;cURL&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://pictotext.io/api/v1/ocr"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"image=@id_card.jpg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"documentType=th_id_card"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the same call in &lt;strong&gt;Python&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id_card.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://pictotext.io/api/v1/ocr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer sk_live_YOUR_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;th_id_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. th_id_card, de_id_card, cn_id_card
&lt;/span&gt;        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nameEn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nameTh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;birthEn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now look at the two things that make this a &lt;em&gt;per-country&lt;/em&gt; response. A &lt;strong&gt;Thai&lt;/strong&gt; card comes back with native + Latin names, a normalized date next to the original, and &lt;code&gt;religion&lt;/code&gt;:&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;"idNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nameTh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"nameEn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SOMCHAI JAIDEE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"birthTh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"birthEn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1990-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiryDateEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2030-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"religion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123/456 หมู่ 1 ถ.สุขุมวิท เขตคลองเตย กรุงเทพฯ 10110"&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;A &lt;strong&gt;German&lt;/strong&gt; card, same endpoint, returns a different field set entirely — &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;eyeColor&lt;/code&gt;, &lt;code&gt;placeOfBirth&lt;/code&gt;:&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;"firstName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERIKA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lastName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MÜLLER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"documentNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L01X00T28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dateOfBirth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1990-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nationality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEUTSCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"placeOfBirth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BERLIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eyeColor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BLAU"&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"170"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLATZ DER REPUBLIK 1, 11011 BERLIN"&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;Notice what axes 1–3 look like when they're handled: native script preserved (&lt;code&gt;nameTh&lt;/code&gt;), Latin alongside (&lt;code&gt;nameEn&lt;/code&gt;), the Buddhist-calendar birthdate normalized (&lt;code&gt;birthEn&lt;/code&gt;), and each country's own fields returned instead of being flattened away. The exact keys per country are in the &lt;a href="https://pictotext.io/docs/reference/asia/thailand/id-card" rel="noopener noreferrer"&gt;field reference&lt;/a&gt; — e.g. the Thai ID card page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you remember one thing: &lt;strong&gt;treat "every country is different" as a first-class design constraint, not something you patch after OCR.&lt;/strong&gt; Your data model should expect a per-country field set, your name handling should preserve native script, and your dates should be normalized but reversible. Whether you build that layer yourself or use an API that already has it, the teams that get global identity verification right are the ones that designed for the heterogeneity up front.&lt;/p&gt;

&lt;p&gt;If you'd rather not maintain a per-country adapter for every market, the &lt;a href="https://github.com/focusto/ptt-docs" rel="noopener noreferrer"&gt;docs are open on GitHub&lt;/a&gt; and there's a &lt;a href="https://pictotext.io/docs/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; to try it on your own sample cards.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The problem: too many image models, which one do I use?</title>
      <dc:creator>Fox</dc:creator>
      <pubDate>Mon, 01 Jun 2026 09:19:46 +0000</pubDate>
      <link>https://dev.to/deepfox/the-problem-too-many-image-models-which-one-do-i-use-5ko</link>
      <guid>https://dev.to/deepfox/the-problem-too-many-image-models-which-one-do-i-use-5ko</guid>
      <description>&lt;p&gt;New image-generation models keep landing — Nano Banana, Nano Banana Pro, GPT Image 2, ByteDance's Seedream — and each claims to be the best. But when you actually need &lt;em&gt;one&lt;/em&gt; good image, the real questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Same request, different model — how much does the output actually differ?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Re-tuning the prompt for every model is exhausting.&lt;/li&gt;
&lt;li&gt;Comparing them means hopping between platforms and signing up over and over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I ran a small, reproducible test: &lt;strong&gt;fix one prompt, feed it to several models, look at the differences, and boil it down to a selection cheat sheet.&lt;/strong&gt; To avoid the multi-platform shuffle I did the comparison on &lt;a href="https://cvy.ai" rel="noopener noreferrer"&gt;cvy.ai&lt;/a&gt;, where you switch models from a dropdown on the same prompt — no re-registering, no rewriting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: make the prompt reproducible with a template
&lt;/h2&gt;

&lt;p&gt;A fair comparison needs a &lt;strong&gt;stable, reusable prompt&lt;/strong&gt; — otherwise the differences you see are just &lt;em&gt;you&lt;/em&gt; writing it differently each time. I break prompts into fixed slots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[subject] + [style/medium] + [composition/lens] + [light/mood] + [details] + [aspect ratio]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A portrait example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subject: a young woman in a casual wool coat, half body, glancing toward camera
Style: cinematic realism, warm film tone
Composition: 85mm telephoto, shallow depth of field
Light: golden-hour, a glowing storefront neon sign reading "CAFE" in the blurred background, rim light on hair
Details: natural skin, windswept hair strands, no over-smoothing
Aspect ratio: 3:4 vertical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note the neon sign reading &lt;strong&gt;"CAFE"&lt;/strong&gt; — it's deliberate. Text inside an image is one of the clearest ways models differ, so keeping a short word in the scene makes the text-rendering comparison below much more telling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Flatten that into one continuous prompt and &lt;strong&gt;every model gets identical input&lt;/strong&gt; — that's what makes the comparison mean something.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Tip: instead of staring at an empty prompt box, keep a few reusable templates (portrait / product / scene / social cover) and just swap the subject. cvy.ai ships a set of editable templates I use as a starting point — faster than writing from scratch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2: same prompt, four models side by side
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft52x44div322gkmpv1z4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft52x44div322gkmpv1z4.png" alt="Same portrait prompt rendered by four AI image models — GPT Image 2, Seedream 4.5, Nano Banana Pro, and Nano Banana — each showing a woman in a wool coat with a neon " width="800" height="1050"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPT Image 2 — the winner.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt Adherence:&lt;/strong&gt; Excellent. The "windswept hair" is dynamic and natural. The pose of the subject glancing over her shoulder adds superb narrative depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Rendering:&lt;/strong&gt; The spelling of "CAFE" is perfect. The neon glow and bokeh effect integrate flawlessly with the optical physics of an 85mm lens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighting &amp;amp; Vibe:&lt;/strong&gt; Perfectly captures the "golden-hour" backlight. The rim light on the hair is spot-on, and the warm film tone is rich and cinematic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Details &amp;amp; Textures:&lt;/strong&gt; The skin retains authentic texture without feeling over-smoothed. The wool coat texture is slightly soft but generally solid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review:&lt;/strong&gt; &lt;strong&gt;The absolute winner of this test.&lt;/strong&gt; It completely nails the "cinematic realism" requirement with a perfect balance of atmosphere and accuracy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seedream 4.5 — biggest visual impact.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt Adherence:&lt;/strong&gt; Follows the composition well, though the "windswept hair" feels a bit forced and slightly clumpy rather than naturally blown by the wind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Rendering:&lt;/strong&gt; "CAFE" is perfectly legible, featuring a very strong and bright neon glow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighting &amp;amp; Vibe:&lt;/strong&gt; Takes a highly aggressive approach to the "golden-hour" and "rim light" prompts with intense backlighting. This creates massive visual impact, though it sacrifices a bit of the soft film vibe requested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Details &amp;amp; Textures:&lt;/strong&gt; The wool coat texture is well-rendered. However, while freckles are present, there is still a faint hint of "AI smoothing" on the skin, making it feel slightly less than 100% natural.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review:&lt;/strong&gt; &lt;strong&gt;The strongest visual impact.&lt;/strong&gt; While slightly over-rendered, it is incredibly polished and ready for direct commercial use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Nano Banana Pro — texture king, lopsided.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Details &amp;amp; Textures:&lt;/strong&gt; &lt;strong&gt;The undisputed king of textures.&lt;/strong&gt; The coarse, pillowy grain of the wool coat, the natural facial imperfections, and the ultra-realistic skin pores demonstrate terrifying microscopic rendering capabilities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Rendering:&lt;/strong&gt; "CAFE" is clear, but the neon tube structure looks somewhat stiff and doesn't blend seamlessly into the background lighting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighting &amp;amp; Vibe (Major Deduction):&lt;/strong&gt; It completely missed the "golden-hour" and "warm film tone" instructions. The lighting is incredibly flat (resembling an overcast afternoon), and the requested rim light on the hair is almost non-existent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt Adherence:&lt;/strong&gt; The hair is messy, but it lacks the dynamic motion implied by "windswept."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review:&lt;/strong&gt; &lt;strong&gt;A hyper-realistic but lopsided specialist.&lt;/strong&gt; It is visually flawless if you only care about micro-textures, but it completely failed to follow the core lighting and atmospheric instructions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Nano Banana — baseline.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt Adherence (Major Deduction):&lt;/strong&gt; The hair is perfectly neat and tucked away, entirely ignoring the "windswept hair" prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Rendering:&lt;/strong&gt; Suffers from AI hallucination; an extra glowing accent mark appeared above the 'E', spelling "CAFÈ" instead of "CAFE".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighting &amp;amp; Vibe:&lt;/strong&gt; The lighting is dull with only a very faint hint of a sunset glow. It lacks cinematic tension, and the background blur feels rigid and artificial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Details &amp;amp; Textures:&lt;/strong&gt; The coat feels more like flat felt than coarse wool, and the skin details are the flattest among the four candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review:&lt;/strong&gt; &lt;strong&gt;Baseline performance.&lt;/strong&gt; It missed multiple instructions and falls significantly behind the other models in this test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: the cheat sheet
&lt;/h2&gt;

&lt;p&gt;Same prompt across the board, distilled (★ = relative strength from this test):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Prompt Adherence&lt;/th&gt;
&lt;th&gt;Text Rendering&lt;/th&gt;
&lt;th&gt;Lighting &amp;amp; Vibe&lt;/th&gt;
&lt;th&gt;Details &amp;amp; Textures&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nano Banana&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;Quick flat drafts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nano Banana Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;Hyper-realistic textures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT Image 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;Cinematic storytelling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Seedream 4.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐&lt;/td&gt;
&lt;td&gt;High-impact commercial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;no single model wins on every axis.&lt;/strong&gt; The skill is &lt;em&gt;matching the model to the job&lt;/em&gt; — cinematic storytelling from one, raw texture from another, high-impact commercial polish from a third. That's exactly why I didn't want to juggle separate platforms: compare once, and you know which job goes where.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reusable generation workflow
&lt;/h2&gt;

&lt;p&gt;What the experiment settled into as my default:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start from a template&lt;/strong&gt; — pick the closest prompt template, swap the subject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the model by task&lt;/strong&gt; — use the cheat sheet; don't default to the same one every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a reference image when you need direction&lt;/strong&gt; — when text alone won't land it, upload a reference so the result follows an existing look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iterate in small steps&lt;/strong&gt; — keep prompts, styles, model choices, and good samples together so each render informs the next.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I run the whole flow on &lt;a href="https://cvy.ai" rel="noopener noreferrer"&gt;cvy.ai&lt;/a&gt; — templates, multiple models, and text-to-image / image-to-image in one workspace — which suits a "compare fast, iterate often" habit. The method itself is platform-agnostic, though; any multi-model tool works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't pick a model by vibes — &lt;strong&gt;run one identical prompt across all of them.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Keep prompts &lt;strong&gt;structured and templated&lt;/strong&gt; so comparison is fair and reuse is cheap.&lt;/li&gt;
&lt;li&gt;Remember: &lt;strong&gt;match the model to the task&lt;/strong&gt;, don't worship one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If "which image model should I use?" keeps tripping you up, spend ten minutes running your own same-prompt comparison — the conclusion beats any review. The portrait template and cheat sheet above are yours to copy.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nanobanana</category>
    </item>
  </channel>
</rss>
