<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Frank-Yomik | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/frank-yomik/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/frank-yomik/index.xml" rel="self" type="application/rss+xml"/><item><title>Complete Manga Solution: Frank Manga+, Frank Yomik, and the Prettify-Manga Extension</title><link>https://akitaonrails.github.io/en/2026/05/30/manga-plus-shueisha-on-the-desktop-frank-manga-plus/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/30/manga-plus-shueisha-on-the-desktop-frank-manga-plus/</guid><pubDate>Sat, 30 May 2026 09:00:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Frank Manga&amp;#43; logo: the name stylized in red and white over a black background." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m a subscriber to the &lt;strong&gt;MANGA Plus by Shueisha&lt;/strong&gt; app on Android&amp;rsquo;s Google Play Store. For those who don&amp;rsquo;t know, this is Shueisha&amp;rsquo;s official channel (the Japanese publisher behind Shonen Jump) for reading their manga legally, with chapters releasing weekly nearly the same day they&amp;rsquo;re published in Japan. Two dollars a month gets you the entire catalog with an official English translation.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Frank Manga&#43; logo: the name stylized in red and white over a black background."  loading="lazy" /></p>
<p>I&rsquo;m a subscriber to the <strong>MANGA Plus by Shueisha</strong> app on Android&rsquo;s Google Play Store. For those who don&rsquo;t know, this is Shueisha&rsquo;s official channel (the Japanese publisher behind Shonen Jump) for reading their manga legally, with chapters releasing weekly nearly the same day they&rsquo;re published in Japan. Two dollars a month gets you the entire catalog with an official English translation.</p>
<p>I own a Samsung ZFold 7, one of the few phones that&rsquo;s actually good for reading manga and Kindle (I talked about this and about <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">Frank Yomik</a> in the AI marathon final ranking). The Fold opened gives you a bigger screen than a normal phone, which helps a lot. But there are days when my eyes are already tired of tiny screens, and what I really wanted was to read on a 32-inch OLED monitor that sits right in front of me, perfect for long sessions.</p>
<p>The problem is that <strong>MANGA Plus has no official website for reading</strong>. The subscription is locked to Android. There&rsquo;s an iOS version too, but no web. A tablet helps, but nothing beats the comfort of a big, static screen when you want to read a whole One Piece arc in one go.</p>
<p>For context, I caught up on a lot recently: I finally finished Chainsaw Man, Dan Da Dan, Jujutsu Kaisen, Kagurabachi, Akane-banashi, Sakamoto Days and, of course, One Piece. There&rsquo;s a lot more I want to follow, and the comfort of the big monitor became a real problem worth solving.</p>
<p>The main act here is <strong>Frank Manga+</strong>, which fixes Shueisha&rsquo;s desktop gap. At the end I also cover two pieces of my setup: <a href="#prettify-manga">Prettify Manga Reader</a>, for turning fansub sites and Kindle Web into tolerable readers, and <a href="#frank-yomik">Frank Yomik in the browser</a>, for furigana/translation on Kindle Japan and Naver.</p>
<h2>Decompiling the app<span class="hx:absolute hx:-mt-20" id="decompiling-the-app"></span>
    <a href="#decompiling-the-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I decided to dig in. I sat down with Claude Code and we downloaded the official MANGA Plus xapk. We decompiled it to Java source with <a href="https://github.com/skylot/jadx"target="_blank" rel="noopener">jadx</a> and navigated around until we understood how the app authenticates with Shueisha&rsquo;s API.</p>
<p>The conclusion is straightforward: the user&rsquo;s &ldquo;subscription,&rdquo; from the app&rsquo;s point of view, is a 32-character hexadecimal <strong>deviceSecret</strong> stored in a plain XML file inside Android&rsquo;s shared preferences. The app generates this secret once when you log in, and from then on every API call (<code>jumpg-api.tokyo-cdn.com</code>) is signed with it. No elaborate DRM, no rotating token, none of that. A single static secret per device.</p>
<p>The catch is that <strong>extracting that secret from the app is a hassle</strong>, because by default you don&rsquo;t have access to <code>/data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml</code> on a non-rooted Android. Three paths exist:</p>
<ol>
<li><strong>Rooted phone</strong>: run <code>adb shell su -c &quot;cat /data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml&quot;</code> and look for <code>&lt;string name=&quot;secret&quot;&gt;...&lt;/string&gt;</code>.</li>
<li><strong>Rooted emulator</strong>: install Android Studio, create an AVD with Google Play x86_64, apply <a href="https://gitlab.com/newbit/rootAVD"target="_blank" rel="noopener">rootAVD</a> for Magisk root, log into the Google account that holds your subscription, install MANGA Plus from the Play Store, then run the same <code>adb shell</code> above.</li>
<li><strong>Network capture with mitmproxy</strong> (more advanced): run the rooted emulator, inject the mitmproxy CA via Conscrypt APEX, and intercept the secret on the app&rsquo;s first authenticated call.</li>
</ol>
<p>All three paths are documented in detail in the repo&rsquo;s <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/install.md"target="_blank" rel="noopener">docs/install.md</a> and <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/debugging.md"target="_blank" rel="noopener">docs/debugging.md</a>. I won&rsquo;t repeat them step by step here because it&rsquo;s more about tooling than technique, and anyone willing to put in the work will want to read the original. The important thing is that it <strong>works</strong>, and you only have to do it <strong>once</strong>: once extracted, the secret keeps the desktop app working for as long as your subscription lasts.</p>
<p>Yes, it&rsquo;s annoying. But it&rsquo;s the price for having a single configuration point that unlocks the rest.</p>
<h2>The desktop app<span class="hx:absolute hx:-mt-20" id="the-desktop-app"></span>
    <a href="#the-desktop-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With the secret in hand, the rest was easy. I built <strong>Frank Manga+</strong>, a native app in <strong>Rust + Tauri</strong>, with a SvelteKit WebView on the frontend and a Rust client using <code>reqwest</code> on the backend talking directly to Shueisha&rsquo;s API. To load chapter images without leaking URLs or fighting CORS, I created a custom <code>mpimg://</code> scheme intercepted by Tauri, which downloads, decrypts (the API serves pages with a simple XOR), and hands the result back to the WebView.</p>
<p>The app opens on a library view with search:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/library.png" alt="Frank Manga&#43; open on the Library/Search tab showing a grid of manga covers: One Piece, Sakamoto Days, Kagurabachi, Jujutsu Kaisen, Akane-banashi, Chainsaw Man, Dan Da Dan, among others."  loading="lazy" /></p>
<p>Clicking a title takes you to a detail page with the full chapter list and a &ldquo;Continue&rdquo; button that picks up from the last chapter you read:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/title-detail.png" alt="Kagurabachi detail page in Frank Manga&#43;: large cover on the left, synopsis, and a virtualized list of numbered chapters on the right with a Continue button at the top."  loading="lazy" /></p>
<p>The reader opens in single-page, with snap-per-page and manga-style click zones: click the <strong>left half</strong> of the page to advance and the <strong>right half</strong> to go back (Japanese right-to-left reading direction). If you&rsquo;d rather use the keyboard, arrows, Space, <code>j</code>/<code>k</code>, and PageUp/PageDown all work. When you hit the end of a chapter, the next one prefetches and gets appended to the scroll, so you don&rsquo;t have to bounce back to the chapter list every time. And resume is per-chapter: leave mid-read and the next time you open that chapter you land on the exact page you stopped at. For huge series like One Piece (1100+ chapters), the list is virtualized, so it opens instantly:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader.png" alt="Frank Manga&#43; reader in single-page showing page 12 of 19 of a Kagurabachi chapter: dramatic black-and-white scene of Chihiro wielding his sword, with a page indicator at the top and a progress bar at the bottom."  loading="lazy" /></p>
<p>On a 32-inch monitor, single-page wastes half the screen. That&rsquo;s why there&rsquo;s <strong>double-page mode</strong>: hit <code>D</code> (or click the layout icon in the header) and the reader starts pairing facing pages, so the whole spread fills the screen the way the author drew it. It&rsquo;s how manga was meant to be read.</p>
<p>You can see why that matters when you compare it with cheap binding. Bad paperbacks swallow the middle of the fold, exactly where the art is supposed to connect. In digital, when the reader joins it properly, the scene gets room to breathe:</p>
<table>
  <thead>
      <tr>
          <th>Frank Manga+ joining the spread</th>
          <th>Cheap paperback swallowing the fold</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-double-page.png" alt="Frank Manga&#43; in double-page mode on a widescreen monitor: a dramatic black-and-white scene fills the entire screen, with a pigtailed character crying and the speech bubbles “I….” and “…WANT TO GET STRONGER.”."  loading="lazy" /></td>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/kikoru-paperback-spread.jpeg" alt="A Kaiju No. 8 paperback opened on a Kikoru two-page spread, with the art swallowed and distorted by the spine in the center."  loading="lazy" /></td>
      </tr>
  </tbody>
</table>
<p><code>D</code> cycles through three layouts: <strong>single</strong> (one page at a time), <strong>double</strong> (sequential pairs starting from page 1), and <strong>double-cover</strong> (the first page of each chapter solo, the rest in pairs, the way printed manga binds the cover singly before the first spread). The choice persists across chapters and sessions, so you set it once and forget it.</p>
<p>There&rsquo;s also a night-reading filter. Hit <code>F</code> (or click the crescent-moon icon in the header) and the page whites warm toward sepia. LCD white is harsh on the eyes in the dark, and this softens it without flattening the contrast:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-eye-protection.png" alt="Frank Manga&#43; with the night-reading filter active: a black-and-white double-page spread takes on a warm amber tone, with several panels and English dialogue bubbles."  loading="lazy" /></p>
<p><code>F</code> cycles through four levels: <strong>off → low → med → high → off</strong>. The button tints amber and shows one to three dots for the active level, and the choice is saved in <code>localStorage</code> like the reader&rsquo;s other prefs. Under the hood it&rsquo;s a CSS <code>sepia + brightness + saturate</code> filter on the whole page-stack: the sepia pushes the hue toward amber but preserves the luminance range, so blacks stay black and the artwork&rsquo;s contrast is intact.</p>
<p>Reading state (last chapter, current page) lives in the WebView&rsquo;s <code>localStorage</code>. Image cache at <code>~/.cache/mangaplus-reader/</code>. The extracted <code>deviceSecret</code> lives in <code>~/.config/mangaplus-reader/secret</code> (Linux/macOS) or <code>%APPDATA%\mangaplus-reader\secret</code> (Windows). You can override it with the <code>MANGAPLUS_SECRET</code> environment variable if you prefer.</p>
<h2>Install<span class="hx:absolute hx:-mt-20" id="install"></span>
    <a href="#install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s a build ready for all the main platforms:</p>
<ul>
<li><strong>Linux</strong>: AppImage, <code>.deb</code>, <code>.rpm</code>, or via AUR (<code>yay -S mangaplus-reader-bin</code>).</li>
<li><strong>macOS</strong>: separate <code>.dmg</code> for Apple Silicon (aarch64) and Intel (x64).</li>
<li><strong>Windows</strong>: <code>.exe</code> installer. It&rsquo;s not signed, so Windows SmartScreen will complain, click &ldquo;More info → Run anyway&rdquo;.</li>
</ul>
<p>All artifacts come out on the <a href="https://github.com/akitaonrails/frank_mangaplus/releases"target="_blank" rel="noopener">GitHub Releases</a>.</p>
<h2>Status and contributions<span class="hx:absolute hx:-mt-20" id="status-and-contributions"></span>
    <a href="#status-and-contributions" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is <strong>beta</strong>. It works fine for my use (I read several series a week with no headaches), but it&rsquo;s missing features: genre filters in search, real offline mode (today it only caches what you&rsquo;ve already read), and a smoother secret-extraction path for less technical users.</p>
<p>If you subscribe to MANGA Plus and want to test it, send an issue or PR. The code is Rust + Svelte, MIT, contributions welcome: <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">github.com/akitaonrails/frank_mangaplus</a>.</p>
<p>Now I finally read One Piece on the big monitor, without having to hold a phone or twist my neck. The work paid off.</p>
<p><a id="prettify-manga"></a></p>
<h2>Bonus: Prettify Manga Reader for fansub sites<span class="hx:absolute hx:-mt-20" id="bonus-prettify-manga-reader-for-fansub-sites"></span>
    <a href="#bonus-prettify-manga-reader-for-fansub-sites" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Besides the desktop app, I keep a simpler extension around: <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">Prettify Manga Reader</a>. It does not translate anything. Its job is to fix the reading experience.</p>
<p>Most fansub sites are ugly, packed with banners, and obviously built for phones: one page under the next, endless vertical scroll, good luck. That&rsquo;s practical on a phone, but manga was not drawn like an Instagram feed. Manga was drawn for page turns. Two pages open at once. The reading rhythm, the reveal, the pause between panels, all of it assumes that pair.</p>
<p>And many authors use that to draw two-page spreads. <strong>Kaiju No. 8</strong> does it several times. If the site stacks one page under the other, you can still read the speech bubbles, but the full composition is gone. You&rsquo;re seeing half the art at a time and thinking you saw the whole thing.</p>
<p>The usual site looks like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-before.webp" alt="A typical fansub page before enabling the extension, with the manga page surrounded by the original site layout."  loading="lazy" /></p>
<p>Prettify opens a dark overlay, fits the art to the screen, and joins pages at the center seam when <strong>Book</strong> or <strong>Double</strong> mode is active. The spread looks like a real two-page spread again:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-spread.webp" alt="Prettify Manga Reader showing a two-page spread correctly joined at the center seam, full-screen on a dark background."  loading="lazy" /></p>
<p>It has three modes: <strong>Single</strong> (one page per screen), <strong>Double</strong> (pairs from the start), and <strong>Book</strong> (first page alone, then pairs, like binding). It opens in Book by default. If an image is already horizontal, it stays as a full-width spread. It has scroll snap so every page lands in the right place, keyboard shortcuts, small mouse controls, help with <code>?</code>, and an end-of-chapter card that tries to find previous/next chapter links when the site makes them obvious.</p>
<p>It also has a three-level night filter. Hit <code>N</code> and it warms the page with sepia, brightness, and contrast without turning the art into dead gray. A white manga page on a big monitor at night is a flashlight to the face. This fixes it.</p>
<p>To install it, go to the <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">Prettify Manga Reader GitHub repo</a>, download the zip from <strong>Releases</strong>, unzip it into a permanent directory, open <code>chrome://extensions</code> or <code>brave://extensions</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the unzipped folder. If you want to build from source, clone the repo, run <code>npm test</code>, then <code>npm run package</code>, unzip the package generated under <code>dist/</code>, and load that folder.</p>
<p>On Kindle Web Reader, Prettify deliberately does less. On URLs like <code>read.amazon.com/manga/...</code>, <code>read.amazon.co.jp/manga/...</code>, <code>read.amazon.co.uk/manga/...</code>, and <code>read.kindle.*</code> domains, it doesn&rsquo;t try to rebuild the book inside its own overlay. Amazon&rsquo;s reader already handles account, pages, and DRM; the extension only adds a discreet bottom-right toolbar and keyboard shortcuts on top of the native reader.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-kindle.webp" alt="Kindle Web Reader with Prettify Manga Reader’s discreet toolbar in the bottom-right corner, showing the Night button active to soften the manga page."  loading="lazy" /></p>
<p>Space, PageDown, and arrows advance the page; Shift+Space, PageUp, and the opposite arrows go back; Home/End jump to the beginning or end when the native reader accepts them, with a scroll fallback; <code>N</code> cycles the night filter. It doesn&rsquo;t store Amazon credentials, doesn&rsquo;t touch sessions, and needs no token in the code. It&rsquo;s just visual comfort and keyboard control on top of the Kindle Web that already exists.</p>
<p><a id="frank-yomik"></a></p>
<h2>Bonus 2: Frank Yomik now on the desktop too via extension<span class="hx:absolute hx:-mt-20" id="bonus-2-frank-yomik-now-on-the-desktop-too-via-extension"></span>
    <a href="#bonus-2-frank-yomik-now-on-the-desktop-too-via-extension" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Anyone who read the <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">AI marathon recap</a> remembers <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, my self-hosted project that takes a manga or webtoon page, detects the bubbles with RT-DETR-v2, runs OCR, sends it to a local Ollama (<code>qwen3:14b</code>) for translation, and returns the page with the translated text in place. I already covered the story and the technical details in <a href="/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/">My First Vibe Code Failure and How I Fixed It | Frank Yomik</a>. The point here is the new desktop piece.</p>
<p>Originally it had only a Flutter client for Android and Linux, which opened Kindle Japan and Naver Webtoon inside a WebView. The thing is, running a Flutter app inside a WebView on the desktop just to read Kindle is using a sledgehammer to crack a nut. On the desktop you already have Chromium open. What was missing was a browser extension.</p>
<p>Over the past few weeks I added exactly that. A Manifest V3 extension for Chromium/Brave/Edge, talking to the same self-hosted Yomik server. It runs on <a href="https://read.amazon.co.jp"target="_blank" rel="noopener">read.amazon.co.jp</a>, <a href="https://read.kindle.co.jp"target="_blank" rel="noopener">read.kindle.co.jp</a>, <a href="https://comic.naver.com"target="_blank" rel="noopener">comic.naver.com</a>, and Naver&rsquo;s mobile version. The page stays visually identical to the original, with no injected buttons or HUD in the reader&rsquo;s face. All configuration lives in the popup:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/yomik-extension-popup.png" alt="Frank Yomik popup open over a Kindle Japan page showing Initial D in Japanese: fields for API base URL, auth token, checkboxes for Kindle reader and Naver Webtoon, manga pipeline dropdown set to Furigana, target language English, and a webtoon prefetch block."  loading="lazy" /></p>
<p>The content script grabs the current page image and sends it to the server. The bearer token sits in the service worker, so it never leaks to the page&rsquo;s JS. When the translation comes back, the image gets swapped in place of the original. There&rsquo;s a local cache to avoid re-translating pages you&rsquo;ve already seen, a &ldquo;force reprocess&rdquo; button for when you need to file a bug report, and autosave for the config. Details in the <a href="https://github.com/akitaonrails/FrankYomik/tree/main/extension"target="_blank" rel="noopener">extension README</a>, and the Load Unpacked zip ships with the repo&rsquo;s Releases.</p>
<p>In practice, the 32&quot; monitor is now my reading desk. Frank Manga+ opens Shueisha&rsquo;s official catalog in English (One Piece, Chainsaw Man, and whatever else Shonen Jump publishes). When it&rsquo;s fansub or Kindle Web and all I want is visual comfort, Prettify handles the reader, keyboard, and night filter. When I want to read something Japanese with no official translation, I open Kindle Japan in another tab and Yomik renders furigana or an LLM translation on top of the page. For Korean Naver webtoons, same story, same extension.</p>
<p>The nice part is that each project solves a different piece and they complement each other. Frank Manga+ exists because Shueisha has no website. Prettify makes browser readers less miserable. Yomik exists because the rest of the manga I read doesn&rsquo;t have a decent official translation in any reasonable timeframe. For a manga addict like me, it&rsquo;s the setup I&rsquo;d been wanting for years.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item><item><title>My First Vibe Code Failure and How I Fixed It | Frank Yomik</title><link>https://akitaonrails.github.io/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/05/my-first-vibe-code-failure-frank-yomik/</guid><pubDate>Thu, 05 Mar 2026 08:00:00 -0300</pubDate><description>&lt;p&gt;Before anyone asks, all the code is in &lt;a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener"&gt;this repository&lt;/a&gt;. And there are pre-built client binaries on the &lt;a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener"&gt;releases page&lt;/a&gt;. But the app alone isn&amp;rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&amp;rsquo;m not going to maintain a public server, just a personal one for my own private use.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before anyone asks, all the code is in <a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener">this repository</a>. And there are pre-built client binaries on the <a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener">releases page</a>. But the app alone isn&rsquo;t enough because it needs the server component, which runs on a machine of yours (local or cloud) with a GPU of at least 16GB of VRAM, and that you need to configure — I&rsquo;m not going to maintain a public server, just a personal one for my own private use.</p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-52-11.png" alt="kindle library"  loading="lazy" /></p>
<p>I have a huge collection of manga bought on Amazon.co.jp that I read through <a href="read.amazon.co.jp">Kindle web</a>. Shounen manga normally has furigana — that small text in hiragana next to the kanji — that helps me read, because, despite having studied Japanese, I was never formally trained. But manga aimed at adult audiences (seinen, not porn) usually comes without furigana. It&rsquo;s pure kanji and my reading speed crawls.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-53-26.png" alt="furigana"  loading="lazy" /></p>
<p>For years I&rsquo;ve wanted a tool that solves this. The idea is simple: detect the speech bubbles on a manga page, extract the text with OCR, and either add furigana to the kanji, or translate directly to English and render it back into the bubble. Sounds easy, right?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-07-43.png" alt="no-furigana"  loading="lazy" />
<em>(kanji with no caption/furigana)</em></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-11-21.png" alt="com furigana"  loading="lazy" />
<em>(with furigana injected in real time)</em></p>
<p>Yeah, it sounded easy. And because I knew it sounded easy but wouldn&rsquo;t be, I never had the patience to actually do it. I know how to build the first 80% of any project. The problem is the last 20% — that phase of experimentation, tweaking, fine-tuning, handling edge cases — that consumes more time than all the rest combined. And in a computer vision project, that 20% is especially treacherous.</p>
<p>But then the vibe coding era arrived. And I thought: maybe now the 20% is feasible. I started the project on February 24, 2026, at 23:10 at night. And it became an example of how easy it is to produce massive volumes of <strong>useless</strong> code.</p>
<h2>The original idea: OpenCV and heuristics<span class="hx:absolute hx:-mt-20" id="the-original-idea-opencv-and-heuristics"></span>
    <a href="#the-original-idea-opencv-and-heuristics" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My original plan — the concept I&rsquo;d imagined for years — was: use <a href="https://opencv.org/"target="_blank" rel="noopener">OpenCV</a> — which is a famous and old computer vision library — to detect the speech bubbles. Manga bubbles are typically white areas with a black contour inside the panels. In theory, you just threshold the image to grab white regions, find contours, filter by size and shape, and you&rsquo;re done.</p>
<p>In 24 hours I already had a working proof of concept: bubble detection, OCR with manga-ocr (a model trained specifically on Japanese manga text), furigana with MeCab for morphological analysis, translation with Ollama running Qwen3:14b locally, and rendering the text back into the bubble. The initial commit (<code>9169d73</code>) on Feb 24 already did all of that.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen6-debug.jpg" alt="shounen6 debug"  loading="lazy" /></p>
<p>The next day, Feb 25, I was already extending the pipeline with a Korean webtoon translation flow. 27 commits that day. Everything seemed to be flowing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/057-debug.jpg" alt="webtoon debug"  loading="lazy" /></p>
<p>And then the hell started.</p>
<h2>The hell of false positives<span class="hx:absolute hx:-mt-20" id="the-hell-of-false-positives"></span>
    <a href="#the-hell-of-false-positives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The problem with OpenCV bubble detection is that manga isn&rsquo;t a standardized document. Every artist has their own line, scan quality varies enormously between print eras, and color pages need completely different parameters from black-and-white pages. And the thing that looks the most like a white speech bubble on a manga page is&hellip; <strong>a face</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_09-15-17.png" alt="false positive face"  loading="lazy" /></p>
<p>Character faces are light areas, relatively rounded, with a dark contour. Exactly like speech bubbles. And no matter how many filters you stack, there&rsquo;s always a case where the face of a character with blue hair sails through every filter, or where a legitimate bubble gets rejected because it has an unusual shape.</p>
<p>Look at how my bubble detector ended up at peak complexity — 551 lines with 7 layers of false-positive filters (abridged version):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># --- False positive filters ---</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. Edge density</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">edges</span><span class="p">,</span> <span class="n">edges</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_density</span> <span class="o">=</span> <span class="n">edge_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">edge_density</span> <span class="o">&gt;</span> <span class="n">max_edge_density</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Bright pixel ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">bright_thresh</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_ratio</span> <span class="o">=</span> <span class="n">bright_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">bright_ratio</span> <span class="o">&lt;</span> <span class="n">min_bright_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Mid-tone ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_mask</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">inRange</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="mi">80</span><span class="p">,</span> <span class="mi">220</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">mid_mask</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_ratio</span> <span class="o">=</span> <span class="n">mid_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">mid_ratio</span> <span class="o">&gt;</span> <span class="n">max_mid_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. Contour circularity</span>
</span></span><span class="line"><span class="cl"><span class="n">circularity</span> <span class="o">=</span> <span class="mi">4</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">area</span> <span class="o">/</span> <span class="p">(</span><span class="n">perimeter</span> <span class="o">*</span> <span class="n">perimeter</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">circularity</span> <span class="o">&lt;</span> <span class="mf">0.10</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. Border darkness</span>
</span></span><span class="line"><span class="cl"><span class="n">border_mean</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">mean</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">border_only</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">border_mean</span> <span class="o">&gt;</span> <span class="mi">160</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 6. Background uniformity (white_std)</span>
</span></span><span class="line"><span class="cl"><span class="n">white_pixels</span> <span class="o">=</span> <span class="n">gray</span><span class="p">[(</span><span class="n">mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&gt;</span> <span class="mi">15</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 7. Dark content analysis (text strokes)</span>
</span></span><span class="line"><span class="cl"><span class="n">very_dark</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">((</span><span class="n">inner_mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">dark_ratio_60</span> <span class="o">=</span> <span class="n">very_dark</span> <span class="o">/</span> <span class="n">inner_area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">dark_ratio_60</span> <span class="o">&lt;</span> <span class="n">min_dark_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each one of those filters was added in response to a specific false positive. Edge density separated bubbles (which have sparse text strokes) from faces (which have hair, eyes, nose creating dense edges). Bright pixel ratio checked whether the region was actually white. Circularity discarded shapes that were too irregular. And so on.</p>
<p>But the worst part is that those filters interacted with each other in unpredictable ways. Look at this commit from Feb 26 (<code>70c814a</code>):</p>
<blockquote>
  <p>&ldquo;Revert rect_dark, mid_ratio, and early-split changes that caused face FPs&rdquo;</p>

</blockquote>
<p>I had tried to relax two thresholds — rect_dark from 0.10 to 0.11, mid_ratio from 0.15 to 0.16 — to recover bubbles that were being missed. Result: faces and body regions started passing as false positives in Adachi manga. I had to revert everything.</p>
<p>That&rsquo;s the pattern that repeated for days: recovering a missed bubble meant opening the door for false positives. Fixing a false positive meant losing a legitimate bubble. It was infinite whack-a-mole.</p>
<h2>The band-aids: CLAHE, edge detection, watershed<span class="hx:absolute hx:-mt-20" id="the-band-aids-clahe-edge-detection-watershed"></span>
    <a href="#the-band-aids-clahe-edge-detection-watershed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the 7 basic filters weren&rsquo;t enough, I started stacking additional passes.</p>
<p>Commit <code>294e785</code> (Feb 26): added CLAHE (<em>Contrast Limited Adaptive Histogram Equalization</em>) as a second detection pass. Bubbles with mid-range brightness, near the 200 threshold, were being missed. CLAHE equalized the contrast and revealed those borderline bubbles.</p>
<p>But CLAHE also made faces pass through the filters because it artificially inflated skin brightness. So I had to add an entire validation function against the original image:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_validate_on_original</span><span class="p">(</span><span class="n">candidate</span><span class="p">,</span> <span class="n">gray_orig</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Check if a CLAHE-detected candidate looks bubble-like on original.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">roi</span> <span class="o">=</span> <span class="n">gray_orig</span><span class="p">[</span><span class="n">y1</span><span class="p">:</span><span class="n">y2</span><span class="p">,</span> <span class="n">x1</span><span class="p">:</span><span class="n">x2</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean_brightness</span> <span class="o">=</span> <span class="n">roi</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Already bright enough for pass 1 — rejected for good reason</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">mean_brightness</span> <span class="o">&gt;</span> <span class="mi">215</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Must have text strokes (dark pixels)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dark_ratio</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">(</span><span class="n">roi</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">)</span> <span class="o">/</span> <span class="n">roi</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">dark_ratio</span> <span class="o">&lt;</span> <span class="mf">0.07</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># White pixel variance: text creates high std, face skin is uniform</span>
</span></span><span class="line"><span class="cl">    <span class="n">white_pixels</span> <span class="o">=</span> <span class="n">roi</span><span class="p">[</span><span class="n">roi</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&lt;</span> <span class="mi">9</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Commit <code>5dddb31</code> (March 1): added an entire third detection pass based on edges (<em>edge-based segmentation</em>). To catch bubbles where the white interior blended into the white background of the page. Dilated the Canny edges, inverted, did an AND with bright regions, and looked for contours in the result.</p>
<p>Commit <code>b695295</code> (Feb 26): added recovery of small bubbles via morphological gradient + OCR validation. If OCR confirmed there was valid Japanese text in the region, it was probably a real bubble.</p>
<p>Each band-aid added 50-100 lines of code and another layer of complexity. And each one had its own edge cases and false positives.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen-debug.jpg" alt="shounen debug"  loading="lazy" /></p>
<h2>The final tally for v0.1<span class="hx:absolute hx:-mt-20" id="the-final-tally-for-v01"></span>
    <a href="#the-final-tally-for-v01" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On March 1, I tagged v0.1. At that point I had:</p>
<ul>
<li><strong>90 commits</strong> in 6 days of development (Feb 24 to March 1)</li>
<li><strong>551 lines</strong> in <code>bubble_detector.py</code> alone</li>
<li>7 layers of false-positive filters, each with empirical thresholds</li>
<li>Two complete detection passes (original + CLAHE)</li>
<li>A third edge-based pass</li>
<li>Cross-validation against the original image</li>
<li>Watershed separation for overlapping bubbles</li>
<li>Separate threshold profiles for color vs black-and-white pages</li>
<li>20+ magic numbers tuned empirically against a limited sample of pages</li>
<li>Regression tests pinning each specific false positive (face of a girl with blue hair, window frame, thin horizontal strip, concrete floor&hellip;)</li>
</ul>
<p>And even with all that, it still wasn&rsquo;t reliable. Every new manga I tested revealed some little case that broke the detector. It was an extremely brittle monolith.</p>
<blockquote>
  <p><strong>SYMPTOM:</strong> you&rsquo;re patching a fix that fixed another fix that was fixing yet another fix, and when you touch one piece, you accidentally break another: that means the code is <strong>brittle</strong>, a house of cards about to collapse. That&rsquo;s the moment to give up and rethink!</p>

</blockquote>
<h2>The decision: research alternatives<span class="hx:absolute hx:-mt-20" id="the-decision-research-alternatives"></span>
    <a href="#the-decision-research-alternatives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At v0.1 I stopped and asked the question I should have asked at the start: is there someone who already trained an ML model to do exactly this?</p>
<p>I asked Claude to research available models for comic bubble detection. The research produced the document <code>docs/yolo_bubble_detection_plan.md</code>, where we analyzed the alternatives.</p>
<p>The first one that came up was a <a href="https://medium.com/@beyzaakyildiz/what-is-yolov8-how-to-use-it-b3807d13c5ce"target="_blank" rel="noopener">YOLOv8 Medium from ogkalu</a> (<code>comic-speech-bubble-detector-yolov8m</code>), trained on ~8,000 images of manga, webtoon, manhua and Western comics. It detects only one class (speech bubble). But digging deeper, we found another model from the same author: <code>ogkalu/comic-text-and-bubble-detector</code>, an <a href="https://github.com/lyuwenyu/RT-DETR"target="_blank" rel="noopener">RT-DETR-v2</a> with a ResNet-50-vd backbone (42.9M parameters), trained on <strong>~11,000 images</strong>, with three classes: <code>bubble</code>, <code>text_bubble</code> and <code>text_free</code>. Both Apache 2.0.</p>
<p>We also evaluated the comic-text-detector (DBNet + YOLOv5, ~13,000 images from Manga109), but that one detected text regions and not bubbles. And as future training data, there was Roboflow with 4,492 already-labeled images, and the Manga109 dataset with 147,918 annotations across 21,142 pages.</p>
<p>The RT-DETR-v2 with 3 classes was the most promising because it distinguished speech bubbles, text inside bubbles and free text (narration, SFX). It could replace both <code>bubble_detector.py</code> and <code>text_detector.py</code> in a single inference pass.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/026-en.jpg" alt="026 en"  loading="lazy" /></p>
<p>The conclusion of the research document was direct:</p>
<blockquote>
  <p>&ldquo;Even without fine-tuning, these models were trained on 8 to 11 thousand images of diverse comics. They should handle the artistic style diversity that our manual filters struggle with. The 7-filter heuristic cascade and its magic numbers would be completely eliminated.&rdquo;</p>

</blockquote>
<p>And if it weren&rsquo;t enough, we included a fine-tuning plan using paired data (original Japanese pages vs English fan translations) that generated labels automatically through image diff. But first we wanted to test the baseline.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen2-en.jpg" alt="shounen2 en"  loading="lazy" /></p>
<h2>Replacing everything: RT-DETR-v2<span class="hx:absolute hx:-mt-20" id="replacing-everything-rt-detr-v2"></span>
    <a href="#replacing-everything-rt-detr-v2" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On March 4, I made commit <code>0df63f2</code>: &ldquo;Replace OpenCV heuristic detection with RT-DETR-v2, add bubble shape masking&rdquo;.</p>
<p>The diff speaks for itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>16 files changed, 732 insertions(&#43;), 1112 deletions(-)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>1,112 lines deleted. More lines removed than added. The 551-line bubble detector was replaced by 262 lines — and most of those are shape mask extraction (contour mask) from the detected bbox, not the detection itself.</p>
<p>The detection core became this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MODEL_ID</span> <span class="o">=</span> <span class="s2">&#34;ogkalu/comic-text-and-bubble-detector&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">DEFAULT_CONFIDENCE</span> <span class="o">=</span> <span class="mf">0.35</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">detect_bubbles</span><span class="p">(</span><span class="n">img_cv</span><span class="p">,</span> <span class="n">confidence</span><span class="o">=</span><span class="n">DEFAULT_CONFIDENCE</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="p">,</span> <span class="n">processor</span><span class="p">,</span> <span class="n">device</span> <span class="o">=</span> <span class="n">_get_model</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">img_pil</span> <span class="o">=</span> <span class="n">Image</span><span class="o">.</span><span class="n">fromarray</span><span class="p">(</span><span class="n">img_cv</span><span class="p">[:,</span> <span class="p">:,</span> <span class="p">::</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span>  <span class="c1"># BGR→RGB</span>
</span></span><span class="line"><span class="cl">    <span class="n">inputs</span> <span class="o">=</span> <span class="n">processor</span><span class="p">(</span><span class="n">images</span><span class="o">=</span><span class="n">img_pil</span><span class="p">,</span> <span class="n">return_tensors</span><span class="o">=</span><span class="s2">&#34;pt&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">torch</span><span class="o">.</span><span class="n">no_grad</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="o">**</span><span class="n">inputs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">processor</span><span class="o">.</span><span class="n">post_process_object_detection</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span><span class="p">,</span> <span class="n">target_sizes</span><span class="o">=</span><span class="n">target_sizes</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="n">confidence</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... map classes, deduplicate, sort</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No magic thresholds. No separate profiles for color vs B&amp;W. No CLAHE. No edge detection. No watershed. No 7 filter layers. A model trained on 11,000 diverse comic images already knows how to tell a bubble from a face better than my pile of &ldquo;ifs.&rdquo;</p>
<p>What also vanished alongside it:</p>
<ul>
<li>The entire <code>text_detector.py</code> (387 lines) — replaced by RT-DETR&rsquo;s <code>text_free</code> class</li>
<li>The false-positive feedback system — RT-DETR detections are reliable enough to not need manual marking</li>
<li>Hundreds of lines of regression tests pinning specific false positives</li>
</ul>
<p>In the end, I didn&rsquo;t even need to try fine-tuning my own model. The model that already existed solved more than 99% of the cases, which for me was already excellent.</p>
<p>People who don&rsquo;t understand statistics struggle with this. With my &ldquo;manual&rdquo; OpenCV procedure I was already getting 80%, maybe more. But that&rsquo;s very little. If on every page a face gets a bubble slapped on top of it, that&rsquo;s terrible.</p>
<p>Even if with a lot of effort (another five hundred different &ldquo;ifs&rdquo;) I could get to 95%, that&rsquo;s still not enough. Reaching 80% is easy. The last 20% costs exponentially more, and the last 1% can be impossible in many cases. That&rsquo;s how things work. Everyone stops at 80%.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen10-debug.jpg" alt="shounen10 debug"  loading="lazy" /></p>
<h2>The other pains: Flutter on Linux<span class="hx:absolute hx:-mt-20" id="the-other-pains-flutter-on-linux"></span>
    <a href="#the-other-pains-flutter-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>While the Python and Go backend was relatively stable, the Flutter client on Linux was a separate chapter of suffering.</p>
<p>Flutter uses WebKitGTK for the WebView on Linux, and that component has painful particularities. Commit <code>8e5d168</code> (Feb 28) tells the story: WebKitGTK can&rsquo;t resolve Promise return values from asynchronous JavaScript, generating a PlatformException. I had to rewrite all the overlays as synchronous IIFEs with <code>decode().then()</code> and an <code>opacity: 0.999</code> nudge to force a texture refresh in the GPU compositor.</p>
<p>On my NVIDIA + Wayland setup, the WebView was unusable at full resolution. Commit <code>a36e1eb</code> (Feb 27) tried to fix it by forcing CPU rendering with <code>WEBKIT_SKIA_ENABLE_CPU_RENDERING=1</code> and disabling accelerated compositing. Then I had to revert that (commit <code>8e5d168</code>) and force the AMD iGPU&rsquo;s Mesa via <code>__EGL_VENDOR_LIBRARY_FILENAMES</code> to stop WebKitGTK from grabbing the NVIDIA dGPU.</p>
<p>Each one of those discoveries cost hours of debugging things that simply had no documentation. In the end I got the Linux version to a reasonable state, but it&rsquo;s nothing spectacular. I don&rsquo;t know if I&rsquo;m missing something obvious, but Flutter on Linux I found to be a drag, especially right after building a native app with Rust/Tauri (much better). But since I wanted an app that worked on Linux and Android, there weren&rsquo;t that many options.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/078-en.jpg" alt="078 en"  loading="lazy" /></p>
<h2>The Kindle prefetch that died<span class="hx:absolute hx:-mt-20" id="the-kindle-prefetch-that-died"></span>
    <a href="#the-kindle-prefetch-that-died" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>An idea that seemed brilliant and went wrong: create a second hidden WebView in Flutter that shared cookies/session with the main WebView and kept navigating pages ahead to pre-process them.</p>
<p>The reason is that the Kindle website only loads one page at a time. It doesn&rsquo;t load everything at once or in chunks, just one page. So you can&rsquo;t process pages forward or backward. When the server was still slow at processing pages, I wanted to keep everything pre-cached, so when the user turned the page the translation was already there.</p>
<p>Commit <code>a36e1eb</code> (Feb 27) implemented the entire KindlePrefetchManager: 406 lines of Dart, with batched prefetch (3 pages ahead), trusted GDK events for turning the page (isTrusted=true), rate limiting with human pacing, a window with sensitive=FALSE so it wouldn&rsquo;t steal focus.</p>
<p>In the following days, the fixes piled up:</p>
<ul>
<li><code>cfe08eb</code>: improve prefetch reliability and overlay matching</li>
<li><code>4461b5f</code>: harden overlay selection and Kindle recovery</li>
<li><code>f141b2e</code>: stop destroying the background webview on every page turn</li>
</ul>
<p>And in the end (commit <code>2b93e99</code>, March 4), I deleted everything. 657 lines removed, replaced by a simple spinner in the toolbar.</p>
<p>With the move to the RT-DETR model, and parallelizing the bubble translation, the translation became &ldquo;almost real-time,&rdquo; loading in less than 10 seconds, so it&rsquo;s no longer such a big deal to wait for a page to come in, and you can ask one at a time. The prefetch added too much complexity for marginal gain, and the right way to solve it was simply to process the page on demand with proper visual feedback.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult2-furigana.jpg" alt="adult2 furigana"  loading="lazy" /></p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In total, the Frank Yomik project today has:</p>
<ul>
<li><strong>111 commits</strong> across 9 days of development (with a 2-day break in the middle)</li>
<li><strong>29,428 lines</strong> of code in 181 files</li>
<li><strong>~13K lines</strong> of Python (processing pipeline + worker)</li>
<li><strong>~6K lines</strong> of Dart (Flutter client)</li>
<li><strong>~3.4K lines</strong> of Go (API server)</li>
<li><strong>345 unit tests</strong> + 34 integration tests</li>
<li>Support for Japanese manga (Kindle) and Korean webtoons (Naver/Webtoons)</li>
</ul>
<p>The pipeline today works like this:</p>
<ol>
<li>The Flutter client (Android or Linux desktop) opens the Kindle or Webtoons site in a WebView</li>
<li>Captures the page image (capturing the image blob on the Kindle page, fetching the <code>&lt;img&gt;</code> for webtoons)</li>
<li>Sends it to the Go API which queues it on Redis Streams with SHA256-based dedup</li>
<li>Python worker processes: RT-DETR-v2 detects bubbles → manga-ocr or EasyOCR extracts text → Ollama qwen3:14b translates → text_renderer renders back into the image</li>
<li>Result comes back over WebSocket, the overlay replaces the original image</li>
</ol>
<p>For furigana: fugashi (a MeCab wrapper) does morphological analysis and generates the hiragana reading for each kanji. I switched from pykakasi to fugashi because pykakasi doesn&rsquo;t consider sentence context (「人」 became にん instead of ひと).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult4-furigana.jpg" alt="adult4 furigana"  loading="lazy" /></p>
<p>If I knew then what I know now — that the RT-DETR-v2 model already existed and solved the detection problem with a confidence threshold — I would have eliminated the entire OpenCV phase. The OCR, translation, rendering and Flutter parts were already reasonably stable. Detection was the bottleneck, and it was exactly the part I could have saved myself, if I&rsquo;d given up sooner.</p>
<h2>What I learned by failing<span class="hx:absolute hx:-mt-20" id="what-i-learned-by-failing"></span>
    <a href="#what-i-learned-by-failing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I spent 5 days polishing an OpenCV bubble detector that had 20+ tuning commits, 7 filter layers, 3 detection passes, and that in the end was replaced by a 50-line wrapper around a pretrained model.</p>
<p>It was 1,112 lines deleted in a single commit. But it wasn&rsquo;t wasted time. Those 5 days taught me exactly why heuristics fail in computer vision. I understood the problem, the cascade where touching one threshold breaks another, and it was that understanding that made me recognize when to stop and research alternatives.</p>
<p>And here&rsquo;s where the real role of vibe coding in this story comes in.</p>
<blockquote>
  <p><strong>Prompting doesn&rsquo;t replace thinking.</strong></p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen8-en.jpg" alt="shounen8 en"  loading="lazy" /></p>
<p>I generated code very fast with Claude, but the problem wasn&rsquo;t writing speed, it was the approach. No prompt in the world turns 7 layers of heuristic filters into a robust solution. The right solution was to change the approach completely.</p>
<p>But vibe coding made the failure cheap. In the pre-AI days, those 5 days of OpenCV would have been maybe 2-3 weeks. The cost of being wrong would have been high enough to make the decision to throw it out really hard. With vibe coding, 5 days were discarded without pain because I knew I could rebuild fast. And in fact, the RT-DETR-v2 integration and the restructuring of the entire project were done in <strong>a single day</strong> (March 4, 15 commits).</p>
<p>The question that remains: if I had done the <code>yolo_bubble_detection_plan.md</code> research on day 1 instead of day 5, I&rsquo;d probably have reached the current state in 2 days. The difference between weeks of work and a weekend was a HuggingFace search. Researching before implementing seems obvious in hindsight, but in the heat of the moment the temptation to solve it by hand is strong.</p>
<p>The project is now open source. I initially didn&rsquo;t know if I wanted to open the code, there was a lot of kludge and that 551-line detector that I was embarrassed about. But after the refactoring, the code became clean enough to share. It&rsquo;s the version I would have liked to have built from the start, but that I could only build because I screwed up first.</p>
<p>The bigger headache was the bubble detection and replacement model, but there are several other points I didn&rsquo;t detail: did you notice that webtoons are colored and the bubbles themselves have art? I had to use an image model to do <strong>in-painting</strong> and have the AI redraw the bubble before placing the text on top.</p>
<p>Another headache I don&rsquo;t think I&rsquo;ll solve: <strong>translation coherence</strong>. Today it translates each bubble in isolation, with no context of the story before or after. In Japanese, there&rsquo;s no gender distinction in words. So the captain was talking about Nami, but the bubble says &ldquo;He&rdquo; instead of &ldquo;She.&rdquo; There&rsquo;s no way to know without reading the previous text. For this to work better, like in a GPT chat, you have to add part of the previous text to know when to use the correct gender or, worse, when there are puns that appeared volumes ago and are referenced in the future (something an Oda loves to do). All those subtleties get lost if you only translate one bubble at a time.</p>
<p>I imagine that&rsquo;s why nobody has done something like this yet, translating in near real time, because for it to really be good, the translation work would be exponential for each chapter further along in the story.</p>
<p>Anyway, the name <strong>Frank Yomik</strong> comes from &ldquo;yomi&rdquo; (読み, reading in Japanese) and &ldquo;ik-da&rdquo; (읽다, to read in Korean). Frank is a nod to a frank, direct translation. The app reads in both languages.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen9-en.jpg" alt="shounen9 en"  loading="lazy" /></p>
<p>For anyone who wants to try it: the <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">repository is on GitHub</a>. The server needs a GPU with at least 8GB of VRAM for the detection model + OCR + translation. The Flutter client runs on Android and Linux desktop. And if you, like me, have a stack of Japanese manga you&rsquo;d like to read more fluently — now you can.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>artificial-intelligence</category></item></channel></rss>