<?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: Tedy Mezaguer</title>
    <description>The latest articles on DEV Community by Tedy Mezaguer (@tedy_yomi).</description>
    <link>https://dev.to/tedy_yomi</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%2F4238934%2F4e018726-38fa-4b74-8e1b-b91c3390de01.png</url>
      <title>DEV Community: Tedy Mezaguer</title>
      <link>https://dev.to/tedy_yomi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tedy_yomi"/>
    <language>en</language>
    <item>
      <title>Vertical Japanese broke my OCR: how I group manga text into speech bubbles on iOS</title>
      <dc:creator>Tedy Mezaguer</dc:creator>
      <pubDate>Wed, 11 Nov 2026 11:15:49 +0000</pubDate>
      <link>https://dev.to/tedy_yomi/vertical-japanese-broke-my-ocr-how-i-group-manga-text-into-speech-bubbles-on-ios-2h9f</link>
      <guid>https://dev.to/tedy_yomi/vertical-japanese-broke-my-ocr-how-i-group-manga-text-into-speech-bubbles-on-ios-2h9f</guid>
      <description>&lt;p&gt;Point a general camera translator at a page of raw manga and you get one of two results. A single paragraph of English in an order that makes no sense, or one tiny box per column of text, each translated on its own, so nothing reads like a sentence.&lt;/p&gt;

&lt;p&gt;I build Yomi, an iOS app for reading manga that only exists in Japanese, and most of the work turned out not to be translation at all. It was geometry: deciding which pieces of text belong to the same speech bubble, in what order to read them, and where to put the English afterwards. This post is about that part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why manga pages are hard for OCR
&lt;/h2&gt;

&lt;p&gt;Three things a sign or a menu never does to you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vertical text.&lt;/strong&gt; Japanese bubbles are usually written in columns, top to bottom, and the columns read right to left. OCR engines tend to return each column as its own line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many separate blocks.&lt;/strong&gt; A page has eight to fifteen bubbles. A translator that treats the page as one block of text merges dialogue from different characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small text at page scale.&lt;/strong&gt; A whole page photographed by a phone leaves each glyph a few pixels wide. Recognition quality collapses on small bubbles and sound effects.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: three OCR passes, because none of them is right alone
&lt;/h2&gt;

&lt;p&gt;On a page I run three passes concurrently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cloudPass&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ocr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognizeLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;visionPass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VisionTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognizeLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidateLanguages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;detectPass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VisionTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectTextRegions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;cloud OCR&lt;/strong&gt; reads Japanese best, but its overlay geometry is sometimes shifted as a whole on real pages, so a box can land beside the text it read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple Vision recognition&lt;/strong&gt; gives extra boxes on device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision text rectangle detection&lt;/strong&gt; reads nothing at all, but its geometry is trustworthy. It anchors regions where text really sits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is the answer. Together they are a set of proposals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: read each region again, bigger
&lt;/h2&gt;

&lt;p&gt;Each proposed region is cropped from the full resolution page, upscaled, flattened, and read again. This single change did more for small bubbles and vertical columns than anything else: the OCR finally sees glyphs at a size it was trained on.&lt;/p&gt;

&lt;p&gt;Then the results are merged. Refined lines win wherever a region produced text. Regions the refine pass could not read fall back to the page level lines, so recall never drops below the naive approach. One trap: the same text read twice, once by the refine pass and once through the shifted page level geometry, creates a phantom bubble at the wrong position. So a page level line whose text was already read elsewhere is dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: grouping lines into bubbles with union find
&lt;/h2&gt;

&lt;p&gt;Now there is a list of lines with rectangles. Two lines belong to the same bubble if they are close. The classic way to do that is to inflate every rectangle a little and union any two that intersect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;inflated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inflated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&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;My first version inflated every line by a fraction of its own size, and vertical bubbles shattered: one "bubble" per column. A vertical column is tall and narrow, so a fraction of its width never bridges the gap to the next column, which is about one column wide.&lt;/p&gt;

&lt;p&gt;The fix is to make inflation direction aware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;inflated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isVertical&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inflated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;insetX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;insetY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inflated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;insetX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;insetY&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;Vertical lines reach a full column width sideways and stay tight vertically, so two bubbles stacked above each other do not merge. Horizontal lines (Korean webtoons, Chinese manhua) keep the gentle default. A bubble counts as vertical when at least half of its lines are.&lt;/p&gt;

&lt;p&gt;Two small filters made a surprising difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lines that are only whitespace never join a bubble. An empty bubble downstream is an invitation for the translation step to invent something.&lt;/li&gt;
&lt;li&gt;Tiny bubbles made only of digits or punctuation are dropped. They are page numbers. Real shouts like "18!!" are far larger than the cutoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: reading order
&lt;/h2&gt;

&lt;p&gt;Inside a vertical bubble, columns are read right to left and each column top to bottom. Inside a horizontal one, top to bottom and left to right. Across the page, bubbles are sorted top to bottom, then right to left when they sit on roughly the same row. That last sort is mostly for a stable UI, so bubbles do not reshuffle when you rescan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: putting English back on the page
&lt;/h2&gt;

&lt;p&gt;This is where "translate in place" gets honest. English does not fit in the shape Japanese leaves behind.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;wide bubbles&lt;/strong&gt;, the original text is erased and the translation typeset inside. The erase is not machine learning inpainting, it is a color matched fill. To match the paper, I sample the pixels around each block on a downscaled copy of the frame and split them into two luminance clusters: the dominant one is paper, the minority is ink. It costs under 5 ms for a handful of blocks, and it is why a patch on a yellowed scan does not glow white.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;tall, narrow bubbles&lt;/strong&gt;, typesetting inside fails. A horizontal sentence in a box two glyphs wide wraps every word in half. So those bubbles get a detached card beside them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;isNarrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVertical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isVertical&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both conditions require the bubble to be at least roughly as tall as it is wide, because the OCR's vertical flag alone was too noisy to decide the look. The card is at least half the page wide, picks the side with room (right, left, below, above), stays inside the page, and draws a short connector back to its bubble so the pairing is never ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still does not work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sound effects.&lt;/strong&gt; Hand drawn, stylised, often overlapping the art. Recognition is hit and miss, mine included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It needs a connection.&lt;/strong&gt; The best Japanese OCR I found is in the cloud, and so is the translation. On device Vision is a helper, not the reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory in extensions.&lt;/strong&gt; The iOS Share Extension has a tight memory budget, and a full page erase gets it killed. The extension asks for text only and skips the cleaned page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The app is &lt;a href="https://yomi-translate.online/manga-translator" rel="noopener noreferrer"&gt;Yomi&lt;/a&gt;, on iPhone and iPad, with a free trial and a small library of free, legal manga and comics you can test it on. If you are curious why I bothered, I measured how much manga never reaches English: &lt;a href="https://yomi-translate.online/blog/manga-never-translated-into-english" rel="noopener noreferrer"&gt;about 86% of series from 2010 to 2023&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have fought vertical CJK OCR yourself, I would love to hear what worked for you.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>computervision</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
