Author SHA1 Message Date
Poison Flower 35b7be680e feat(ocr): unify config, add manga translation pipeline and context lookahead
- Consolidate module configs into root config.example.json with ocr, vision, and epub sections

- Split LLM OCR workflows into novel_ocr.py (prose) and manga_ocr_llm.py (manga)

- Remove gemini_direct_ocr.py in favor of OpenAI-compatible API endpoints

- Support direct manga translation via --translate, --target-lang, and glossary.md

- Add bidirectional context support: past translations (--context-pages) and lookahead Japanese text (--context-pages-ahead)

- Add per-page JSON audit logging under logs/ and expose OpenAI sampling parameters
2026-09-06 15:42:29 +03:00
23 changed files with 1811 additions and 494 deletions
+39
View File
@@ -0,0 +1,39 @@
# --- Python ---
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/
# --- Secrets / personal settings ---
# The real config file contains API keys or book-specific metadata.
# Only config.example.json is meant to be committed.
/config.json
# The real glossary is book-specific content (character names, terms).
# Only glossary.example.md is meant to be committed.
/ocr/glossary.md
# --- Generated OCR output ---
out/
pages_txt/
combined.md
# --- Request/response logs (debugging only, can contain full prompts) ---
/logs/
# --- Book content: scans, sorted chapters, fonts, and the built epub ---
# This repo is tooling only — never commit someone else's copyrighted
# book pages, extracted text, embedded fonts, or the resulting epub.
epub_builder/chapters/*
!epub_builder/chapters/.gitkeep
epub_builder/font/*
!epub_builder/font/.gitkeep
*.epub
# --- OS / editor cruft ---
.DS_Store
Thumbs.db
.idea/
.vscode/
+42 -26
View File
@@ -1,10 +1,11 @@
# Light Novel Scan -> EPUB
# Scanned Light Novel / Manga -> EPUB & Translation Tooling
Turns a folder of scanned light novel page images (vertical Japanese text)
into a proper `.epub`, with furigana rendered as real `<ruby>` markup,
illustrations kept in place, an embedded font, and epub metadata — using a
multimodal LLM to do the OCR instead of a traditional column-segmentation
pipeline.
Turns a folder of scanned Japanese pages into either a proper `.epub`
(light novels) or transcribed/translated text (manga), using a multimodal
LLM to do the OCR instead of a traditional column-segmentation pipeline.
Light novel and manga pages get separate tooling, since their layouts need
genuinely different logic — dense running prose vs. scattered speech
bubbles that benefit from translation, a glossary, and cross-page context.
## Pipeline
@@ -12,48 +13,63 @@ pipeline.
scanned page images
ocr/ module → transcribes each page into pages_txt/<name>.txt
ocr/ module → transcribes (or translates) each page into
pages_txt/<name>.txt
(manual step) → sort the .txt files (and any illustration
images) into chapters/chNN_name/ folders
(manual step, → sort the .txt files (and any illustration
light novel only) images) into chapters/chNN_name/ folders
epub_builder/ module → assembles chapters/ into a finished .epub
(light novel only — manga output is meant for
your own typesetting workflow instead)
```
1. **[`ocr/`](ocr/README.md)** — batch-OCRs scanned pages into per-page
`.txt` files. Several backends are available (a multimodal LLM via any
OpenAI-compatible API is the recommended default; Google Cloud Vision
and a fully offline `manga-ocr` pipeline are also included).
2. **Manual sorting** — split the resulting pages into chapter folders
(`ch00_frontmatter/`, `ch01_chapter00/`, ...), optionally dropping in a
`cover.jpg` and illustration images alongside the `.txt` files. This
step is manual because automatically detecting chapter boundaries from
OCR'd headers turned out to be unreliable — folder structure is simple
and unambiguous instead.
`.txt` files, or translates them directly. `novel_ocr.py` (light novel)
and `manga_ocr_llm.py` (manga) are the recommended entry points, both
via any OpenAI-compatible API; `google_vision_ocr.py` (Google Cloud
Vision) and `local_mangaocr_ocr.py` (fully offline `manga-ocr`) are
pure-OCR alternatives with no translation.
2. **Manual sorting** (light novel only) — split the resulting pages into
chapter folders (`ch00_frontmatter/`, `ch01_chapter00/`, ...), optionally
dropping in a `cover.jpg` and illustration images alongside the `.txt`
files. This step is manual because automatically detecting chapter
boundaries from OCR'd headers turned out to be unreliable — folder
structure is simple and unambiguous instead.
3. **[`epub_builder/`](epub_builder/README.md)** — assembles the sorted
`chapters/` folder into a valid `.epub`: furigana notation becomes
`<ruby>` markup, images are placed inline, a font from `font/` gets
embedded, and metadata comes from `config.json`.
embedded, and metadata comes from the root `config.json`.
Both modules read their settings from a single `config.json` at the repo
root (copy `config.example.json` to get started) — see each module's own
README for the exact fields.
See each module's own README for setup and usage details.
## Quick start
```bash
# 1. OCR
# 0. One-time setup: copy the shared config and fill it in
cp config.example.json config.json
# fill in ocr.api_key / ocr.base_url / ocr.model, epub.title / epub.author / ...
# 1a. OCR a light novel
cd ocr
pip install openai pillow natsort tqdm
cp config.example.json config.json # fill in api_key / base_url / model
python openrouter_ocr.py --input /path/to/scans --output ./out
python novel_ocr.py --input /path/to/scans --output ./out
# 2. Sort ./out/pages_txt/*.txt by hand into epub_builder/chapters/chNN_name/
# 1b. ...or OCR/translate manga instead
python manga_ocr_llm.py --input /path/to/scans --output ./out
python manga_ocr_llm.py --input /path/to/scans --output ./out --translate --target-lang Russian
# 3. Build the epub
# 2. (light novel) Sort ./out/pages_txt/*.txt by hand into epub_builder/chapters/chNN_name/
# 3. (light novel) Build the epub
cd ../epub_builder
pip install natsort
cp config.example.json config.json # fill in title / author / ...
python build_epub.py
```
@@ -62,7 +78,7 @@ python build_epub.py
This repository contains only the **tooling**. It is not meant to, and
should not, be used to host or distribute:
- scanned page images,
- OCR'd text extracted from a copyrighted book,
- OCR'd or translated text extracted from a copyrighted book,
- or a resulting `.epub` file,
for any book you don't hold the rights to. The `.gitignore` in this repo
+35
View File
@@ -0,0 +1,35 @@
{
"ocr": {
"api_key": "PUT_YOUR_API_KEY_HERE",
"base_url": "https://your-provider.example.com/v1",
"model": "google/gemini-3.7-flash",
"temperature": 0,
"max_tokens": null,
"top_p": null,
"reasoning_effort": null
},
"vision": {
"credentials": ""
},
"epub": {
"title": "Your Book Title",
"author": "Author Name",
"language": "ja",
"identifier": "",
"publisher": "",
"description": "",
"date": "",
"rights": "",
"series": "",
"series_index": "",
"font_family": "",
"vertical": false,
"show_chapter_titles": false,
"output": "book.epub",
"chapter_titles": {
"ch00_frontmatter": "",
"ch01_chapter00": "Chapter 00",
"ch08_backmatter": ""
}
}
}
+32 -26
View File
@@ -3,7 +3,7 @@
Assembles a `chapters/` folder — laid out by hand into per-chapter
subfolders — into a finished, valid `.epub`, with furigana rendered as
proper `<ruby>` markup, an embedded font, a cover image, and metadata from
`config.json`.
the `epub` section of the repo's root `config.json`.
This is step 2 of the pipeline; step 1 is the [`ocr`](../ocr/README.md)
module, which produces the per-page `.txt` files you'll sort into
@@ -12,6 +12,7 @@ module, which produces the per-page `.txt` files you'll sort into
## Layout
```
config.json <- shared with the ocr module; see below for the "epub" section
epub_builder/
chapters/
cover.jpg <- cover image (optional, any "cover.*" directly in chapters/)
@@ -30,7 +31,6 @@ epub_builder/
YourFont.ttf <- font to embed (optional)
build_epub.py
furigana.py
config.json
```
## Chapter folder naming: `chNN_name`
@@ -38,8 +38,8 @@ epub_builder/
- `chNN` — sequence number, determines sort order (`ch00``ch01` → ... →
`ch10`; use leading zeros if you have more than 9 chapters so plain
alphabetical sort stays correct).
- `name` — default chapter title (override via `config.json`
`chapter_titles`).
- `name` — default chapter title (override via `config.json`'s
`epub.chapter_titles`).
- Folders with `frontmatter` or `backmatter` anywhere in their name are
**always** ignored: no title, no TOC entry — regardless of any flag or
config setting. Their content is still included in the book, just
@@ -64,26 +64,31 @@ Files inside a folder are sorted strictly by the number in the filename
## config.json
This module reads the `epub` section of the shared `config.json` at the
repo root (copy `config.example.json` there if you haven't already):
```json
{
"title": "Your Book Title",
"author": "Author Name",
"language": "ja",
"identifier": "",
"publisher": "",
"description": "",
"date": "",
"rights": "",
"series": "",
"series_index": "",
"font_family": "",
"vertical": false,
"show_chapter_titles": false,
"output": "book.epub",
"chapter_titles": {
"ch00_frontmatter": "",
"ch01_chapter00": "Chapter 00",
"ch08_backmatter": ""
"epub": {
"title": "Your Book Title",
"author": "Author Name",
"language": "ja",
"identifier": "",
"publisher": "",
"description": "",
"date": "",
"rights": "",
"series": "",
"series_index": "",
"font_family": "",
"vertical": false,
"show_chapter_titles": false,
"output": "book.epub",
"chapter_titles": {
"ch00_frontmatter": "",
"ch01_chapter00": "Chapter 00",
"ch08_backmatter": ""
}
}
}
```
@@ -131,10 +136,10 @@ rest are still embedded in the epub but need manual CSS edits to use.
The script tries to guess a shared family name from the common part of the
filenames (e.g. `NotoSerifJP-Regular.ttf` + `NotoSerifJP-Bold.ttf` ->
`NotoSerifJP`). To set it explicitly, use `config.json`:
`NotoSerifJP`). To set it explicitly, use `config.json`'s `epub` section:
```json
{ "font_family": "MyBookFont" }
{ "epub": { "font_family": "MyBookFont" } }
```
**Check the font's license** before embedding it in a file you intend to
@@ -143,7 +148,8 @@ redistribution terms vary by font.
## Running
Simplest case — no arguments, if everything is where it's expected:
Simplest case — no arguments, if everything is where it's expected
(`config.json` one level up, at the repo root):
```bash
python build_epub.py
@@ -155,7 +161,7 @@ Or with explicit paths/overrides:
python build_epub.py \
--pages-dir ./chapters \
--font-dir ./font \
--config ./config.json \
--config ../config.json \
--output ./my_book.epub \
--show-chapter-titles \
--vertical
+18 -5
View File
@@ -23,7 +23,9 @@ Expected layout (next to this script):
YourFont.ttf <- font to embed (optional)
build_epub.py
furigana.py
config.json <- metadata and build settings
config.json (at the repo root, shared with the ocr module — has an
"epub" section with metadata and build settings)
Chapter folder naming: chNN_name
- chNN determines sort order.
@@ -53,11 +55,12 @@ start of each chapter.
Usage:
python build_epub.py
# (all settings are read from config.json / chapters/ / font/ next to this script)
# (chapters/ and font/ are read next to this script; config.json is
# read from the repo root, one level up)
# or with explicit paths/overrides:
python build_epub.py --pages-dir ./chapters --font-dir ./font \\
--config ./config.json --output ./book.epub --show-chapter-titles
--config ../config.json --output ./book.epub --show-chapter-titles
Requirements:
pip install natsort
@@ -79,6 +82,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from furigana import parse_page, page_to_paragraphs_html # noqa: E402
SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent
TEXT_EXT = {".txt"}
IMAGE_EXT = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
@@ -592,16 +596,25 @@ def build_epub(docs, all_images, cover_path, fonts: list[Path], output_path: Pat
# ---------------------------------------------------------------------------
def load_config(config_path: Path) -> dict:
"""Loads the shared config.json and returns its "epub" section.
Falls back to treating the whole file as the epub config if there's no
"epub" key, so a bare {"title": ...} style file still works.
"""
if not config_path.exists():
return {}
return json.loads(config_path.read_text(encoding="utf-8"))
data = json.loads(config_path.read_text(encoding="utf-8"))
return data.get("epub", data) if isinstance(data, dict) else {}
def main():
parser = argparse.ArgumentParser(description="Build an EPUB from a chapters/ folder, with metadata from config.json")
parser.add_argument("--pages-dir", default=str(SCRIPT_DIR / "chapters"), help="Folder with chapter subfolders")
parser.add_argument("--font-dir", default=str(SCRIPT_DIR / "font"), help="Folder with the font to embed")
parser.add_argument("--config", default=str(SCRIPT_DIR / "config.json"), help="Path to config.json")
parser.add_argument(
"--config", default=str(ROOT_DIR / "config.json"),
help="Path to config.json with an \"epub\" section. Defaults to config.json at the repo root."
)
parser.add_argument("--output", default=None, help="Path to the resulting .epub (defaults to config.json's value)")
parser.add_argument("--cover", default=None, help="Explicit cover image path (overrides auto-detection and config.json)")
parser.add_argument("--title", default=None, help="Override title from config.json")
View File
-21
View File
@@ -1,21 +0,0 @@
{
"title": "Your Book Title",
"author": "Author Name",
"language": "ja",
"identifier": "",
"publisher": "",
"description": "",
"date": "",
"rights": "",
"series": "",
"series_index": "",
"font_family": "",
"vertical": false,
"show_chapter_titles": false,
"output": "book.epub",
"chapter_titles": {
"ch00_frontmatter": "",
"ch01_chapter00": "Chapter 00",
"ch08_backmatter": ""
}
}
View File
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Shared logic for turning OCR page text (see ocr/prompt.txt for the expected
Shared logic for turning OCR page text (see ocr/prompt_novel.txt for the expected
format) into HTML paragraphs, including furigana rendered as proper <ruby>
markup. Used by build_epub.py, and also runnable standalone as a quick
preview/debugging tool for a single page.
+257 -30
View File
@@ -1,37 +1,49 @@
# OCR module
Batch-transcribes scanned light novel pages (vertical Japanese text) into
per-page `.txt` files. This is step 1 of the pipeline — step 2 is the
[`epub_builder`](../epub_builder/README.md) module, which turns these `.txt`
files into a finished `.epub`.
Batch-transcribes (or translates) scanned pages into per-page `.txt`
files. Light novel and manga pages are handled by **separate scripts**,
since their layouts need genuinely different logic — dense running prose
vs. scattered speech bubbles that benefit from translation, a glossary,
and cross-page continuity context:
## Choosing a backend
| | Light novel | Manga |
|---|---|---|
| Recommended script | [`novel_ocr.py`](#novel_ocrpy-recommended-for-light-novels) | [`manga_ocr_llm.py`](#manga_ocr_llmpy-recommended-for-manga) |
| Pure-OCR alternative | `google_vision_ocr.py --mode novel` | `google_vision_ocr.py --mode manga`, `local_mangaocr_ocr.py` |
| Layout assumed | Dense running prose, read top-to-bottom then right-to-left | Speech bubbles / narration boxes / SFX scattered across panels |
| Output | Continuous transcribed text per page | Numbered list, one entry per bubble, in manga reading order |
| Translation | — | `--translate`, with glossary + cross-page context |
| Script | Backend | Setup needed | Notes |
|---|---|---|---|
| `openrouter_ocr.py` | Multimodal LLM via any OpenAI-compatible API | API key + base URL | **Recommended.** Reads a whole page at once, no column segmentation needed. |
| `gemini_direct_ocr.py` | Gemini API directly | Google AI Studio API key | Same idea as above, direct instead of via a proxy. |
| `google_vision_ocr.py` | Google Cloud Vision (classic OCR) | Google Cloud project + billing enabled | No LLM context understanding, but solid on clean scans. Free tier covers a typical light novel volume, but Google still requires a billing account to be linked. |
| `local_mangaocr_ocr.py` | [manga-ocr](https://github.com/kha-white/manga-ocr), fully offline | None — no account, no API key | Slower to set up quality-wise: manga-ocr expects short text blocks, so this script auto-segments each page into vertical columns before OCR-ing each one. |
For a light novel, this is step 1 of the pipeline — step 2 is
[`epub_builder`](../epub_builder/README.md), which turns the resulting
`.txt` files into a finished `.epub`. For manga, these `.txt` files are
meant as reference for your own typesetting workflow; `epub_builder`
targets prose light novels and doesn't lay out manga pages.
If you have API access to a multimodal model (Gemini, GPT-4V-class models,
etc.) through any provider, `openrouter_ocr.py` is the easiest and generally
gives the best results with the least fuss.
## `novel_ocr.py` (recommended for light novels)
## Setup for `openrouter_ocr.py` (recommended)
Reads each page with a multimodal LLM via any OpenAI-compatible API
(OpenRouter, a direct provider endpoint, a self-hosted proxy, etc. —
including Gemini, GPT-4V-class models, or anything else exposed through
such an endpoint) and transcribes the vertical Japanese prose. Kept
deliberately simple — transcription only, no translate/glossary/context —
since that's genuinely all a novel page needs.
```bash
pip install openai pillow natsort tqdm
```
1. Copy `config.example.json` to `config.json` and fill in your `api_key`,
`base_url` (your provider's OpenAI-compatible endpoint, usually ending in
`/v1`), and `model` identifier.
2. Optionally edit `prompt.txt` — it's plain English text, no need to touch
any code to tweak the instructions given to the model.
1. From the repo root, copy `config.example.json` to `config.json` and
fill in the `ocr` section's `api_key`, `base_url` (your provider's
OpenAI-compatible endpoint, usually ending in `/v1`), and `model`
identifier. This same file is shared with `manga_ocr_llm.py` and the
`epub_builder` module. `temperature`/`max_tokens`/`top_p`/
`reasoning_effort` are optional — see [API parameters](#api-parameters).
2. Optionally edit `prompt_novel.txt` — plain English text, no need to
touch any code to tweak the instructions given to the model.
```bash
python openrouter_ocr.py --input ./pages --output ./out
python novel_ocr.py --input ./pages --output ./out
```
- `--input`: folder with scanned page images (jpg/png/...), named so that
@@ -51,17 +63,232 @@ python openrouter_ocr.py --input ./pages --output ./out
`epub_builder` module knows to treat it as "no text on this page".
- `--sleep N` adds a delay (seconds) between requests if you're hitting
rate limits.
- `--config` overrides the config.json path (defaults to the repo root).
- `--prompt-file` overrides the prompt file (defaults to `prompt_novel.txt`).
## Other backends
## `manga_ocr_llm.py` (recommended for manga)
`gemini_direct_ocr.py`, `google_vision_ocr.py`, and `local_mangaocr_ocr.py`
follow the same `--input`/`--output` convention and produce the same
`pages_txt/*.txt` + `combined.md` output — see each script's own docstring
for backend-specific setup.
Same idea as `novel_ocr.py` any OpenAI-compatible API, whole page in one
request — but built around what a manga page actually needs: bubbles
transcribed as a numbered, typed, reading-order list, optional direct
translation, a glossary for consistent names/terms, and continuity context
from previous pages.
```bash
pip install openai pillow natsort tqdm
```
Setup is identical to `novel_ocr.py` (same `config.json`, same `ocr`
section) — just point at `prompt_manga.txt` / `prompt_manga_translate.txt`
if you want to edit the instructions.
```bash
# transcribe
python manga_ocr_llm.py --input ./pages --output ./out
# translate directly instead
python manga_ocr_llm.py --input ./pages --output ./out --translate --target-lang Russian
```
Output format is a numbered list per page:
```
1. [DIALOGUE] ...
2. [SFX] ...
3. [NARRATION] ...
```
where entries are ordered manga-style (panels right-to-left top-to-bottom;
bubbles within a panel the same way), and TYPE is one of DIALOGUE,
THOUGHT, NARRATION, or SFX. Same `--input`/`--output`/`--sleep`/`--config`/
`--prompt-file` conventions as `novel_ocr.py`.
### Translation (`--translate`)
- `--target-lang`: written out as a plain language name (`Russian`,
`English`, `Spanish`, ...) — it's dropped directly into the prompt as
"translate all text on the page into {target-lang}". Defaults to
`English`.
- This is a plain LLM translation, not a professional-quality localization
pass — treat it as a strong first draft to edit, not a final one.
- Use a different `--output` folder than your transcription run — both
write to `pages_txt/<name>.txt`, and the script skips a page whose
output file already exists, so reusing the same folder would just
return the old transcription instead of translating.
### Glossary
For a consistent translation of recurring names and terms, copy
[`glossary.example.md`](glossary.example.md) to `glossary.md` right here in
the `ocr/` folder (next to the scripts, not with your page images — a
single stable file you keep building up across books/chapters, which also
makes it a natural fit for an editing UI later). It's auto-detected and
sent along with every `--translate` request, e.g.:
```markdown
## Names
- 澤村・スペンサー・英梨々: Савамура Спенсер Эрири
## Terms
- ビジュアルノベル: Визуальная новелла
## Notes
- Keep Japanese suffixes (e.g. -chan, -san).
```
The file isn't parsed — it's appended to the prompt more or less as-is —
so feel free to add/reorder/rename sections, drop entries, or write extra
freeform notes for the model. Override the auto-detected path with
`--glossary /path/to/glossary.md` if you keep it somewhere else.
### Continuity context from previous pages
`--context-pages N` (default `2`) also sends along the translations of the
last N pages, so the model can keep character voice consistent and resolve
things that only make sense given what was just said — an ongoing
exchange, a pronoun referring back to something on the previous page, a
punchline that depends on the setup a page earlier. Manga bubbles are
terse and full of exactly this kind of dependency, which is why this
exists here and not in `novel_ocr.py`.
```bash
python manga_ocr_llm.py --input ./pages --output ./out --translate --context-pages 4
```
- Only kicks in with `--translate` (there's nothing to carry forward when
just transcribing).
- Pulls from `pages_txt/` in your `--output` folder — the translations
already produced earlier in this same run (or a previous run you're
resuming). Blank/`[NO_TEXT]` pages are skipped when building context,
since they add nothing.
- `--context-pages 0` disables it.
- Token cost is minimal — manga dialogue is short — but the log for each
page (see below) records exactly which previous pages were included, if
you want to check.
### Lookahead context from upcoming pages
`--context-pages-ahead N` (default `0`) goes the other direction: it sends
along the **original Japanese** (never a translation) of the next N pages,
for things that only make sense once you know what happens next — a
pronoun whose gender only becomes clear a page later, a line whose real
addressee is revealed afterward, a joke whose setup pays off on the
following page. Professional manga translators read a whole chapter before
translating any of it for exactly this reason.
```bash
python manga_ocr_llm.py --input ./pages --output ./out --translate --context-pages-ahead 1
```
**Deliberately the original Japanese, never a draft translation.** An
earlier version of this feature was going to run a cheap first-pass
*translation* of upcoming pages and feed that forward as context. Don't do
that — a translation is someone's (or something's) interpretation, not raw
fact, and even with a "treat this as an unreliable draft" instruction, a
model given a wrong reading in the context tends to partially inherit it.
A rushed, context-blind draft translation of the *next* page is exactly as
likely to be wrong as a rushed translation of the current one — so this
would be laundering a coin flip's worth of noise into looking like ground
truth. Raw Japanese has no such failure mode: it's just data, correctly
transcribed once and reused, and the model draws its own conclusions from
it exactly the same way it would from the current page.
- Only kicks in with `--translate`.
- Before translating a page, this makes sure the next N pages' Japanese
text is available:
1. **Hand-prepared or previously-cached `.txt`** — if `--context-src-dir`
(default `<output>/context_src/`) already has a file named exactly
like that page's image stem (e.g. `page005.txt` for `page005.jpg`),
it's used as-is, no OCR call made. You can drop hand-corrected
transcriptions in here yourself ahead of time if you want full
control — nothing requires them to come from a script.
2. **Otherwise, OCR'd on the fly** via `--ahead-ocr-backend` (`llm`
default — the same OpenAI-compatible API, transcription prompt, no
translation; or `local` — offline `manga-ocr`, needs the `manga-ocr`
and `opencv-python` packages) and cached into `context_src/` for
reuse — a page used as lookahead context is only ever OCR'd once,
even though it'll come up again as the "current" page (or as another
page's lookahead) later in the run.
- `--context-pages-ahead 0` (default) disables it entirely — no extra OCR
calls, no `context_src/` folder created.
- Extra OCR calls mean extra latency/cost on pages that need lookahead —
small in absolute terms (manga bubbles are short), but worth knowing
it's there. The log for each page records exactly which upcoming pages'
Japanese was included (`context_pages_ahead_used`).
- Combine freely with `--context-pages` — a page's prompt can include both
past *translations* and future *Japanese* at once, kept in clearly
separate, clearly labeled sections so the model doesn't confuse the two.
## API parameters
`novel_ocr.py` and `manga_ocr_llm.py` (the two OpenAI-compatible-API
scripts) read request parameters from the `ocr` section of `config.json`,
each overridable with a matching CLI flag:
| config.json field | CLI flag | Notes |
|---|---|---|
| `temperature` | `--temperature` | Default `0` (deterministic — you want the same page OCR'd the same way every time). |
| `max_tokens` | `--max-tokens` | Response length cap. Omitted from the request unless set. |
| `top_p` | `--top-p` | Omitted from the request unless set. |
| `reasoning_effort` | `--reasoning-effort` | e.g. `low`/`medium`/`high`. Passed through as `extra_body`, since support varies by model/provider — if your model/provider ignores it, it's simply a no-op rather than an error. |
`google_vision_ocr.py` doesn't take any of these (classic OCR, no model
parameters); `local_mangaocr_ocr.py` doesn't either (a fixed local model).
## Pure-OCR alternatives (no translation)
| Script | Backend | Setup needed | Notes |
|---|---|---|---|
| `google_vision_ocr.py` | Google Cloud Vision (classic OCR) | Google Cloud project + billing enabled | No LLM context understanding, but solid on clean scans. `--mode novel`/`--mode manga` (see below). Free tier covers a typical volume, but Google still requires a billing account to be linked. |
| `local_mangaocr_ocr.py` | [manga-ocr](https://github.com/kha-white/manga-ocr), fully offline | None — no account, no API key | `--mode novel` (column segmentation) or `--mode manga` (speech-bubble detection, closer to what manga-ocr was actually trained on). |
Both are transcription-only (no `--translate`) and follow the same
`--input`/`--output`/`--mode` convention as the LLM scripts, writing to
the same `pages_txt/*.txt` + `combined.md` layout. See each script's own
docstring for setup and manga-mode caveats (`local_mangaocr_ocr.py` in
particular — bubble detection is a geometric heuristic, so pass `--debug`
to sanity-check the detected reading order).
`google_vision_ocr.py` reads its `credentials` path from config.json's
**`vision`** section (not `ocr` — Google Cloud Vision has nothing to do
with the OpenAI-compatible API the other scripts use):
```json
{ "vision": { "credentials": "/path/to/service-account-key.json" } }
```
## Request/response logs
`novel_ocr.py`, `manga_ocr_llm.py`, and `google_vision_ocr.py` each write
one JSON log file per page to `logs/` at the repo root (created
automatically) — the full prompt/request sent, every retry attempt, and
the full raw API response (or the error, if it failed). Handy for
debugging a bad transcription/translation, checking token usage, or just
seeing exactly what was sent.
```
logs/
20260901_153012_042311_page003.json
20260901_153034_198822_page004.json
...
```
- The image itself is never embedded in the log (only its filename/size) —
everything else about the request is recorded as-is.
- `--log-dir` points logging at a different folder; `--no-log` disables it.
- Logs accumulate across every run (nothing is deleted automatically) —
clear out `logs/` periodically if it grows large.
- `local_mangaocr_ocr.py` makes no network requests (fully offline), so
there's no "response" to log for it.
## Next step
Once you have a `pages_txt/` folder full of `.txt` files, manually sort the
pages into the folder structure `epub_builder` expects (see
[`epub_builder/README.md`](../epub_builder/README.md)), then run the epub
builder.
Once you have a `pages_txt/` folder full of `.txt` files:
- **Light novel**: manually sort the pages into the folder structure
`epub_builder` expects (see
[`epub_builder/README.md`](../epub_builder/README.md)), then run the epub
builder.
- **Manga**: these `.txt` files (one numbered bubble list per page) are
meant as transcription/translation reference for your own typesetting
workflow — `epub_builder` targets prose light novels and doesn't lay out
manga pages.
+22 -9
View File
@@ -1,15 +1,28 @@
"""
OCR module: batch-transcribes scanned light novel pages (vertical Japanese
text) into per-page .txt files, ready for the epub_builder module.
OCR module: batch-transcribes (or translates) scanned pages into per-page
.txt files. Light novel and manga pages are handled by separate scripts,
since their layouts need genuinely different logic (dense running prose
vs. scattered, typed speech bubbles that benefit from translation +
glossary + cross-page continuity context).
Several interchangeable backends are provided as standalone scripts:
Novel (light novel, dense running prose):
- novel_ocr.py Any OpenAI-compatible API (OpenRouter, a direct
provider endpoint, a self-hosted proxy, etc.)
with a multimodal model. Recommended.
- google_vision_ocr.py Classic OCR via Google Cloud Vision (no LLM,
--mode novel).
- openrouter_ocr.py Recommended. Any OpenAI-compatible API (OpenRouter,
a self-hosted proxy, etc.) with a multimodal model.
- gemini_direct_ocr.py Same idea, but calling the Gemini API directly.
- google_vision_ocr.py Classic OCR via Google Cloud Vision (no LLM).
- local_mangaocr_ocr.py Fully offline, no cloud account, via manga-ocr.
Manga (speech bubbles / narration boxes / SFX):
- manga_ocr_llm.py Any OpenAI-compatible API. Transcribe or
--translate, with --glossary and --context-pages
for cross-page continuity. Recommended.
- google_vision_ocr.py Classic OCR via Google Cloud Vision (no LLM,
--mode manga; transcription only, no translation).
- local_mangaocr_ocr.py Fully offline, no cloud account, via manga-ocr
(also supports --mode novel via column
segmentation, transcription only).
Each script is self-contained and runnable directly, e.g.:
python -m ocr.openrouter_ocr --input ./pages --output ./out
python -m ocr.novel_ocr --input ./pages --output ./out
python -m ocr.manga_ocr_llm --input ./pages --output ./out --translate
"""
-5
View File
@@ -1,5 +0,0 @@
{
"api_key": "PUT_YOUR_API_KEY_HERE",
"base_url": "https://your-provider.example.com/v1",
"model": "google/gemini-3.7-flash"
}
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via the
Gemini API directly (not through a proxy).
Usage:
export GEMINI_API_KEY="your_key"
python gemini_direct_ocr.py --input ./pages --output ./out
Requirements:
pip install google-genai pillow natsort tqdm
If you access Gemini (or another model) through OpenRouter or a similar
OpenAI-compatible proxy instead of a direct Google API key, use
openrouter_ocr.py instead — it's the recommended entry point for this
project and shares the same prompt.txt / config.json workflow.
"""
import argparse
import os
import sys
import time
from pathlib import Path
from google import genai
from google.genai import types
from natsort import natsorted
from PIL import Image
from tqdm import tqdm
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
PROMPT = """\
This is a scanned page from a Japanese light novel, printed in vertical text (縦書き).
Transcribe ALL Japanese text on the page, preserving correct reading order
(columns read top-to-bottom, then right-to-left).
Rules:
- Do not translate or summarize — output only the transcribed Japanese text.
- No line numbers, no commentary, no explanations of your own.
- Furigana may be omitted (only the base kanji/kana text is needed).
- If the page has no text at all (illustration-only, blank, cover/technical
page), output exactly: [NO_TEXT]
- Preserve paragraph breaks where the layout clearly shows them.
"""
def ocr_image(client: genai.Client, model: str, path: Path, retries: int = 3) -> str:
img = Image.open(path)
# Downscale very large scans — speeds up and cheapens the request without
# a noticeable loss of OCR quality.
max_dim = 2200
if max(img.size) > max_dim:
ratio = max_dim / max(img.size)
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
last_err = None
for attempt in range(retries):
try:
response = client.models.generate_content(
model=model,
contents=[PROMPT, img],
config=types.GenerateContentConfig(temperature=0),
)
text = (response.text or "").strip()
return text
except Exception as e: # noqa: BLE001
last_err = e
time.sleep(3 * (attempt + 1))
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
def main():
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via the Gemini API")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument("--model", default="gemini-3.7-flash", help="Gemini model name")
parser.add_argument(
"--api-key", default=None,
help="API key (falls back to the GEMINI_API_KEY environment variable)"
)
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument(
"--sleep", type=float, default=0.0,
help="Delay in seconds between requests (useful if you're hitting rate limits)"
)
args = parser.parse_args()
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("No API key found. Pass --api-key or set GEMINI_API_KEY.", file=sys.stderr)
sys.exit(1)
input_dir = Path(args.input)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
pages_dir = output_dir / "pages_txt"
pages_dir.mkdir(exist_ok=True)
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
images = natsorted(images, key=lambda p: p.name)
if not images:
print(f"No images found in {input_dir}.", file=sys.stderr)
sys.exit(1)
print(f"Pages found: {len(images)}")
client = genai.Client(api_key=api_key)
combined_path = output_dir / "combined.md"
failed = []
with open(combined_path, "w", encoding="utf-8") as combined_f:
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
txt_out = pages_dir / f"{img_path.stem}.txt"
if txt_out.exists():
text = txt_out.read_text(encoding="utf-8")
else:
try:
text = ocr_image(client, args.model, img_path)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
failed.append(img_path.name)
text = ""
# Do NOT write a file to disk on failure — see openrouter_ocr.py
# for the reasoning.
if args.sleep:
time.sleep(args.sleep)
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
combined_f.write(text)
print(f"\nDone. Combined file: {combined_path}")
print(f"Per-page files: {pages_dir}")
if failed:
print(f"\nFailed to OCR {len(failed)} page(s):")
for name in failed:
print(f" - {name}")
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
# Glossary
A glossary of established translations for names, recurring terms, and any
standing style notes. Copy this file to `glossary.md` in this same `ocr/`
folder (next to the scripts — not with your page images) and it's picked
up automatically whenever you run any OCR script with `--translate` (or
point at a different file explicitly with `--glossary`).
The exact Markdown structure below isn't parsed or validated — it's sent to
the model as-is, so feel free to add/remove sections or entries. Keep the
`## Names` / `## Terms` / `## Notes` headings if you want a rough separation
between "must stay consistent" name spellings, recurring in-universe terms,
and general translation style notes.
## Names
- 澤村・スペンサー・英梨々: Савамура Спенсер Эрири
## Terms
- ビジュアルノベル: Визуальная новелла
## Notes
- Keep Japanese suffixes (e.g. -chan, -san).
+185 -10
View File
@@ -4,7 +4,11 @@ Batch-OCR for scanned pages of a Japanese light novel (vertical text) via
Google Cloud Vision.
Usage:
python google_vision_ocr.py --input ./pages --output ./out --credentials ./key.json
python google_vision_ocr.py --input ./pages --output ./out
# (credentials path comes from config.json's vision.credentials, or pass --credentials)
# for manga instead of a light novel:
python google_vision_ocr.py --input ./pages --output ./out --mode manga
Requirements:
pip install google-cloud-vision natsort tqdm
@@ -24,16 +28,23 @@ the free tier (1000 requests/month covers ~600 pages comfortably). No charge
should occur unless you exceed that quota.
This is a classic OCR engine (not an LLM) — generally solid for image
quality, but it doesn't understand context the way a multimodal model does.
For light novel pages with dense vertical prose, openrouter_ocr.py /
gemini_direct_ocr.py usually give better results with less setup friction.
quality, but it doesn't understand context the way a multimodal model does,
and (unlike the LLM-based scripts) it can't translate. For light novel
pages with dense vertical prose, novel_ocr.py usually gives better results
with less setup friction; for manga, see manga_ocr_llm.py.
Every request/response is logged as one JSON file under logs/ at the repo
root (--log-dir to change, --no-log to disable).
"""
import argparse
import io
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from google.cloud import vision
@@ -41,10 +52,43 @@ from natsort import natsorted
from tqdm import tqdm
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent
def ocr_image(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3) -> str:
"""OCRs a single scan, returning text in reading order."""
def load_config(config_path: Path) -> dict:
"""Loads the shared config.json and returns its "vision" section
(Google Cloud Vision has nothing to do with the OpenAI-compatible "ocr"
section used by novel_ocr.py / manga_ocr_llm.py)."""
if not config_path.exists():
return {}
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"Failed to parse {config_path}: {e}", file=sys.stderr)
sys.exit(1)
return data.get("vision", data) if isinstance(data, dict) else {}
def write_log(log_dir: Path, page_name: str, entry: dict) -> None:
"""Writes one JSON log file per request/response (see novel_ocr.py
for the rationale — same format, minus the image payload itself)."""
log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", page_name)
log_path = log_dir / f"{timestamp}_{safe_name}.json"
log_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def _response_to_dict(response) -> dict:
try:
return vision.AnnotateImageResponse.to_dict(response)
except Exception: # noqa: BLE001
return {"full_text_annotation_text": getattr(getattr(response, "full_text_annotation", None), "text", None)}
def ocr_image_novel(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3, log_entry: dict = None) -> str:
"""OCRs a single scan of running prose, returning text in reading order."""
with io.open(path, "rb") as f:
content = f.read()
@@ -52,6 +96,14 @@ def ocr_image(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3)
# The "ja" language hint helps the model handle vertical Japanese more accurately
image_context = vision.ImageContext(language_hints=["ja"])
if log_entry is not None:
log_entry["request"] = {
"feature": "document_text_detection",
"language_hints": ["ja"],
"image": f"<omitted: {path.name}, {path.stat().st_size} bytes>",
}
log_entry["attempts"] = []
last_err = None
for attempt in range(retries):
try:
@@ -60,24 +112,132 @@ def ocr_image(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3)
)
if response.error.message:
raise RuntimeError(response.error.message)
return response.full_text_annotation.text
text = response.full_text_annotation.text
if log_entry is not None:
log_entry["attempts"].append(
{"attempt": attempt + 1, "success": True, "response": _response_to_dict(response)}
)
return text
except Exception as e: # noqa: BLE001
last_err = e
if log_entry is not None:
log_entry["attempts"].append({"attempt": attempt + 1, "success": False, "error": str(e)})
time.sleep(2 * (attempt + 1))
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
def ocr_image_manga(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3, log_entry: dict = None) -> str:
"""OCRs a single manga page.
document_text_detection assumes a running paragraph flow, which falls
apart on manga: bubbles are scattered blocks, not one paragraph. Instead
this groups Vision's per-paragraph bounding boxes into text-block
"clusters" and orders them in manga reading order: clusters right-to-left
by their rightmost edge, breaking ties top-to-bottom.
"""
with io.open(path, "rb") as f:
content = f.read()
image = vision.Image(content=content)
image_context = vision.ImageContext(language_hints=["ja"])
if log_entry is not None:
log_entry["request"] = {
"feature": "document_text_detection",
"language_hints": ["ja"],
"image": f"<omitted: {path.name}, {path.stat().st_size} bytes>",
}
log_entry["attempts"] = []
last_err = None
for attempt in range(retries):
try:
response = client.document_text_detection(
image=image, image_context=image_context
)
if response.error.message:
raise RuntimeError(response.error.message)
if log_entry is not None:
log_entry["attempts"].append(
{"attempt": attempt + 1, "success": True, "response": _response_to_dict(response)}
)
break
except Exception as e: # noqa: BLE001
last_err = e
if log_entry is not None:
log_entry["attempts"].append({"attempt": attempt + 1, "success": False, "error": str(e)})
time.sleep(2 * (attempt + 1))
else:
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
blocks = []
for page in response.full_text_annotation.pages:
for block in page.blocks:
xs = [v.x for v in block.bounding_box.vertices]
ys = [v.y for v in block.bounding_box.vertices]
text = ""
for paragraph in block.paragraphs:
words = []
for word in paragraph.words:
words.append("".join(s.text for s in word.symbols))
text += "".join(words)
if text.strip():
blocks.append({"text": text.strip(), "x_max": max(xs), "y_min": min(ys)})
if not blocks:
result = "[NO_TEXT]"
else:
# Manga reading order: right-to-left, breaking ties top-to-bottom. This
# is a coarse heuristic (true panel/bubble order can't be recovered from
# plain bounding boxes) — always spot-check against the page.
blocks.sort(key=lambda b: (-b["x_max"], b["y_min"]))
result = "\n".join(f"{i}. {b['text']}" for i, b in enumerate(blocks, start=1))
if log_entry is not None:
log_entry["reading_order_result"] = result
return result
def main():
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via Google Cloud Vision")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument("--credentials", required=True, help="Path to the service-account key.json")
parser.add_argument(
"--credentials", default=None,
help="Path to the service-account key.json (defaults to config.json's vision.credentials)"
)
parser.add_argument(
"--config", default=str(ROOT_DIR / "config.json"),
help="Path to config.json with a \"vision\" section. Defaults to config.json at the repo root."
)
parser.add_argument(
"--mode", choices=["novel", "manga"], default="novel",
help="novel: dense running prose, full-page reading order (default). "
"manga: scattered bubbles, grouped and sorted in manga reading order."
)
parser.add_argument(
"--start-page", type=int, default=1, help="Page number to start the header numbering from"
)
parser.add_argument(
"--log-dir", default=str(ROOT_DIR / "logs"),
help="Folder for per-page request/response logs (one JSON file per page). "
"Defaults to logs/ at the repo root."
)
parser.add_argument(
"--no-log", action="store_true",
help="Disable request/response logging entirely"
)
args = parser.parse_args()
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = args.credentials
config = load_config(Path(args.config))
credentials = args.credentials or config.get("credentials")
if not credentials:
print(
f"No credentials found. Set vision.credentials in {args.config} or pass --credentials.",
file=sys.stderr,
)
sys.exit(1)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials
input_dir = Path(args.input)
output_dir = Path(args.output)
@@ -94,6 +254,8 @@ def main():
print(f"Pages found: {len(images)}")
log_dir = None if args.no_log else Path(args.log_dir)
client = vision.ImageAnnotatorClient()
combined_path = output_dir / "combined.md"
@@ -107,21 +269,34 @@ def main():
if txt_out.exists():
text = txt_out.read_text(encoding="utf-8")
else:
log_entry = {
"timestamp": datetime.now().isoformat(),
"backend": "google_vision",
"page": img_path.name,
"mode": args.mode,
} if log_dir is not None else None
try:
text = ocr_image(client, img_path)
ocr_fn = ocr_image_manga if args.mode == "manga" else ocr_image_novel
text = ocr_fn(client, img_path, log_entry=log_entry)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
failed.append(img_path.name)
text = ""
if log_entry is not None:
log_entry["error"] = str(e)
# Do NOT write a file to disk on failure — otherwise the next
# run would see the file exists and skip retrying it.
if log_entry is not None:
write_log(log_dir, img_path.stem, log_entry)
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
combined_f.write(text)
print(f"\nDone. Combined file: {combined_path}")
print(f"Per-page files: {pages_dir}")
if log_dir is not None:
print(f"Request/response logs: {log_dir}")
if failed:
print(f"\nFailed to OCR {len(failed)} page(s):")
for name in failed:
+193 -18
View File
@@ -1,31 +1,42 @@
#!/usr/bin/env python3
"""
Fully local, offline batch-OCR for scanned pages of a Japanese light novel
(vertical text) via manga-ocr — no cloud account or credit card required.
Fully local, offline batch-OCR for scanned Japanese pages via manga-ocr —
no cloud account or credit card required. Supports two page layouts via
--mode:
novel (default) — dense running prose (light novel pages). Each page is
cut into vertical text columns (by detecting whitespace gaps between
columns), columns are sorted right-to-left, and each is OCR'd
separately, since manga-ocr expects short text blocks rather than a
whole page of prose.
manga — speech bubbles / narration boxes scattered over one or more
panels. Bubbles are detected via their outline shape (a closed,
fairly convex blob of ink enclosing a lighter fill), sorted into an
approximate manga reading order (right-to-left, top-to-bottom, with
panel rows inferred from vertical overlap), and each is OCR'd
separately as a whole bubble crop — the input format manga-ocr was
actually trained on.
Usage:
python local_mangaocr_ocr.py --input ./pages --output ./out
python local_mangaocr_ocr.py --input ./pages --output ./out --mode manga
Requirements:
pip install manga-ocr opencv-python pillow natsort tqdm
The first run downloads manga-ocr's model weights (~400 MB) from
HuggingFace; after that everything runs offline. Without a GPU it runs on
CPU, just slower (roughly 1-3 sec per column).
CPU, just slower (roughly 1-3 sec per column/bubble).
How it works:
1. Each page is cut into vertical text columns (by detecting whitespace
gaps between columns — typical light novel layout).
2. Columns are sorted right-to-left (the reading order for vertical
Japanese text).
3. Each column is OCR'd separately via manga-ocr.
4. Results are joined back into per-page text.
manga-ocr was trained mainly on manga speech bubbles (short text blocks),
not dense full-page prose, so column segmentation matters a lot here for
quality. If segmentation performs poorly on your scans (e.g. unusual
layout), pass --whole-page to feed the model the full page without cutting
it into columns (simpler, but usually lower quality on dense prose).
Notes on --mode manga:
- Bubble detection is a geometric heuristic (contour shape + fill), not
a trained detector, so it can miss borderless bubbles, split a bubble
with a long speaker "tail", or get the reading order wrong on unusual
layouts. Pass --debug to also write an annotated copy of each page
(numbered boxes) to <output>/bubble_debug/ so you can quickly spot
and manually fix any misordered or missed pages in the .txt output.
- A page with no detected bubbles (splash art, etc.) gets [NO_TEXT].
"""
import argparse
@@ -85,6 +96,153 @@ def _gap_too_small(has_text: np.ndarray, x: int, gap_threshold: int) -> bool:
return (end - x) < gap_threshold
def find_bubbles(img_gray: np.ndarray, min_area_frac: float = 0.0015, max_area_frac: float = 0.35):
"""Detects speech-bubble-like shapes and returns their bounding boxes.
Speech bubbles are (usually) a closed ink outline enclosing a lighter
fill. Binarizing+inverting turns that outline into a blob whose *outer*
contour is a good stand-in for the bubble's overall shape, so bubbles
can be picked out by area and convexity without needing a trained
detector:
1. Otsu-threshold + invert: ink -> white, everything else -> black.
2. Morphological close: bridges small gaps in the outline (dashed
bubble borders, a bubble "tail", anti-aliasing) so it forms one
solid ring instead of several fragments.
3. External contours only (RETR_EXTERNAL): a bubble's ring becomes one
blob-like contour; panel frames and page borders are filtered out
by area/aspect below.
4. Keep contours that are a plausible bubble: not too small/large
relative to the page, and fairly convex (area close to its
convex-hull area) — panel borders, gutters, and stray ink specks
don't pass this.
Returns a list of (x, y, w, h) bounding boxes, unsorted, deduplicated.
"""
page_area = img_gray.shape[0] * img_gray.shape[1]
_, binary = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2)
# RETR_LIST (not RETR_EXTERNAL): a bubble drawn inside a panel is nested
# inside the panel border's contour, so RETR_EXTERNAL would only return
# the panel border and miss every bubble in it. RETR_LIST returns every
# contour (panel borders, bubble rings, stray marks); the area/aspect/
# solidity filters below do the actual selecting.
contours, _ = cv2.findContours(closed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area_frac * page_area or area > max_area_frac * page_area:
continue
hull = cv2.convexHull(cnt)
hull_area = cv2.contourArea(hull)
if hull_area <= 0:
continue
solidity = area / hull_area
if solidity < 0.55: # panel borders / stray ink are far less convex than a bubble
continue
x, y, w, h = cv2.boundingRect(cnt)
aspect = w / h if h else 0
if aspect < 0.15 or aspect > 6.0: # rule out thin frame edges/gutter slivers
continue
candidates.append((area, (x, y, w, h)))
# A bubble's outline has thickness, so its outer and inner edge each
# produce their own (near-identical, nested) contour — keep only the
# larger of each such pair via simple greedy IoU suppression.
candidates.sort(key=lambda c: c[0], reverse=True)
boxes = []
for _, box in candidates:
if not any(_iou(box, kept) > 0.5 for kept in boxes):
boxes.append(box)
return boxes
def _iou(a, b) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
ix1, iy1 = max(ax, bx), max(ay, by)
ix2, iy2 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
inter = iw * ih
if inter == 0:
return 0.0
union = aw * ah + bw * bh - inter
return inter / union
def sort_manga_reading_order(boxes, row_overlap_ratio: float = 0.4):
"""Sorts bounding boxes into an approximate manga reading order.
Groups boxes into "rows" (panel bands) by vertical overlap, orders rows
top-to-bottom, then orders boxes within a row right-to-left — the
standard reading order for a Japanese manga page. This is a heuristic:
layouts with tall panels spanning multiple "rows" of a neighboring
column can still come out wrong, which is what --debug is for.
"""
remaining = sorted(boxes, key=lambda b: b[1]) # top-to-bottom as a starting point
rows = []
for box in remaining:
x, y, w, h = box
placed = False
for row in rows:
ry_min = min(b[1] for b in row)
ry_max = max(b[1] + b[3] for b in row)
overlap = min(y + h, ry_max) - max(y, ry_min)
if overlap > row_overlap_ratio * min(h, ry_max - ry_min):
row.append(box)
placed = True
break
if not placed:
rows.append([box])
rows.sort(key=lambda row: min(b[1] for b in row))
ordered = []
for row in rows:
row.sort(key=lambda b: b[0] + b[2], reverse=True) # right edge, right-to-left
ordered.extend(row)
return ordered
def ocr_page_manga(mocr, pil_img: Image.Image, debug_path: Path = None) -> str:
img_np = np.array(pil_img.convert("L"))
boxes = find_bubbles(img_np)
if not boxes:
return "[NO_TEXT]"
ordered = sort_manga_reading_order(boxes)
if debug_path is not None:
debug_img = cv2.cvtColor(img_np, cv2.COLOR_GRAY2BGR)
for i, (x, y, w, h) in enumerate(ordered, start=1):
cv2.rectangle(debug_img, (x, y), (x + w, y + h), (0, 0, 255), 3)
cv2.putText(debug_img, str(i), (x + 4, y + 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2)
debug_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(debug_path), debug_img)
entries = []
for i, (x, y, w, h) in enumerate(ordered, start=1):
pad = 4
crop = pil_img.crop((
max(0, x - pad), max(0, y - pad),
min(pil_img.width, x + w + pad), min(pil_img.height, y + h + pad),
))
text = mocr(crop)
if text.strip():
entries.append(f"{i}. {text.strip()}")
return "\n".join(entries) if entries else "[NO_TEXT]"
def ocr_page(mocr, pil_img: Image.Image, whole_page: bool) -> str:
if whole_page:
return mocr(pil_img)
@@ -111,9 +269,19 @@ def main():
parser = argparse.ArgumentParser(description="Local batch-OCR for a light novel via manga-ocr")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument(
"--mode", choices=["novel", "manga"], default="novel",
help="novel: column-segmented running prose (default). "
"manga: bubble-detected panels, sorted in manga reading order."
)
parser.add_argument(
"--whole-page", action="store_true",
help="Skip column segmentation, feed the whole page to the model at once"
help="[novel mode] Skip column segmentation, feed the whole page to the model at once"
)
parser.add_argument(
"--debug", action="store_true",
help="[manga mode] Also save annotated pages (numbered bubble boxes) to "
"<output>/bubble_debug/, to sanity-check detection/reading order"
)
parser.add_argument("--start-page", type=int, default=1)
args = parser.parse_args()
@@ -149,7 +317,14 @@ def main():
else:
try:
pil_img = Image.open(img_path)
text = ocr_page(mocr, pil_img, args.whole_page)
if args.mode == "manga":
debug_path = (
output_dir / "bubble_debug" / f"{img_path.stem}.jpg"
if args.debug else None
)
text = ocr_page_manga(mocr, pil_img, debug_path)
else:
text = ocr_page(mocr, pil_img, args.whole_page)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
+567
View File
@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""
Batch-OCR (or batch-translate) for scanned manga pages via any
OpenAI-compatible API (OpenRouter, a direct provider endpoint, a
self-hosted proxy, etc.) — including Gemini, GPT-4V-class models, or
anything else exposed through such an endpoint.
Manga pages split their text across scattered speech bubbles, thought
bubbles, narration boxes, and SFX rather than running prose, so this script
has genuinely different logic from novel_ocr.py: it transcribes each page
as a numbered, typed, reading-order list of bubbles, and — since that's
what most people actually want out of a manga OCR pass — can translate
directly instead, with a glossary for consistent names/terms and a sliding
window of previous pages' translations for continuity.
Usage (after filling in config.json):
python manga_ocr_llm.py --input ./pages --output ./out
# translate instead of transcribing:
python manga_ocr_llm.py --input ./pages --output ./out \\
--translate --target-lang Russian
# ...with a glossary of established name/term translations:
python manga_ocr_llm.py --input ./pages --output ./out \\
--translate --target-lang Russian --glossary ../ocr/glossary.md
# (glossary.md sitting next to this script is picked up automatically
# even without --glossary — see ocr/glossary.example.md for the format)
# ...with more/less continuity context from previous pages (default: 2):
python manga_ocr_llm.py --input ./pages --output ./out \\
--translate --target-lang Russian --context-pages 4
# ...with lookahead context from upcoming (untranslated) pages, in the
# original Japanese — helps with twists, who's-talking-to-whom, jokes
# that pay off a page later:
python manga_ocr_llm.py --input ./pages --output ./out \\
--translate --target-lang Russian --context-pages-ahead 1
Requirements:
pip install openai pillow natsort tqdm
One-time setup:
1. Copy config.example.json (repo root) -> config.json
2. Fill in the "ocr" section: api_key, base_url, model (temperature/
max_tokens/top_p/reasoning_effort are optional — see config.example.json)
3. Optionally edit prompt_manga.txt (transcription) or
prompt_manga_translate.txt (--translate) to fit your book / house style
Every request/response is logged as one JSON file under logs/ at the repo
root (--log-dir to change, --no-log to disable) — full prompt, model
params, every retry attempt, and the full raw API response (the base64
image itself is never included, only its path/size).
"""
import argparse
import base64
import io
import json
import os
import re
import sys
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from natsort import natsorted
from openai import OpenAI
from PIL import Image
from tqdm import tqdm
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent
sys.path.insert(0, str(SCRIPT_DIR)) # so `from local_mangaocr_ocr import ...` works when run from elsewhere
def load_config(config_path: Path) -> dict:
"""Loads the shared config.json and returns its "ocr" section.
Falls back to treating the whole file as the ocr config if there's no
"ocr" key, so a bare {"api_key": ...} style file still works.
"""
if not config_path.exists():
return {}
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"Failed to parse {config_path}: {e}", file=sys.stderr)
sys.exit(1)
return data.get("ocr", data) if isinstance(data, dict) else {}
def build_request_kwargs(config: dict, args: argparse.Namespace) -> tuple[dict, dict]:
"""Resolves OpenAI-API-style request params: CLI flag > config.json > a
sensible default. Returns (kwargs, extra_body) — standard params go
straight into the request; reasoning_effort goes through extra_body
since support for it varies by provider/model and extra_body is the
designed passthrough for exactly that.
"""
temperature = args.temperature if args.temperature is not None else config.get("temperature", 0)
max_tokens = args.max_tokens if args.max_tokens is not None else config.get("max_tokens")
top_p = args.top_p if args.top_p is not None else config.get("top_p")
reasoning_effort = args.reasoning_effort or config.get("reasoning_effort")
kwargs = {"temperature": temperature}
if max_tokens is not None:
kwargs["max_tokens"] = max_tokens
if top_p is not None:
kwargs["top_p"] = top_p
extra_body = {}
if reasoning_effort:
extra_body["reasoning_effort"] = reasoning_effort
return kwargs, extra_body
def load_prompt(prompt_path: Path, target_language: str = None, glossary: str = None) -> str:
if not prompt_path.exists():
print(f"Prompt file not found: {prompt_path}", file=sys.stderr)
sys.exit(1)
prompt = prompt_path.read_text(encoding="utf-8").strip()
if target_language:
prompt = prompt.replace("{target_language}", target_language)
if glossary:
prompt += (
"\n\n---\n\n"
"Glossary: use these established translations for the following "
"names/terms for consistency. Anything not listed here, translate "
"naturally.\n\n" + glossary
)
return prompt
def find_glossary(explicit_path: str) -> Path:
"""Resolves the glossary path: explicit --glossary if given, otherwise
an auto-detected glossary.md next to the ocr scripts themselves (a
stable, book-independent location — handy for a future UI to edit)."""
if explicit_path:
path = Path(explicit_path)
if not path.exists():
print(f"Glossary file not found: {path}", file=sys.stderr)
sys.exit(1)
return path
auto_path = SCRIPT_DIR / "glossary.md"
return auto_path if auto_path.exists() else None
def build_context_block(past_pages: list, ahead_pages: list) -> str:
"""Builds a prompt suffix with:
- past_pages: the last N *translated* pages, for continuity (character
voice, an ongoing exchange, pronouns/referents that only make sense
given what was just said).
- ahead_pages: the next N pages' raw *Japanese* transcription (never a
draft translation — see the ocr/README.md design note on why), for
context that only becomes clear from what's about to happen (a
twist, who's actually being addressed, a joke that pays off a page
later).
Both are lists of (page_name, text) tuples, oldest/nearest first.
"""
if not past_pages and not ahead_pages:
return ""
parts = []
if past_pages:
parts.append(
"\n\n---\n\n"
"Context: translations of the immediately preceding page(s), for "
"continuity only (character voice, an ongoing conversation, "
"pronouns/referents). Do NOT re-translate or repeat any of this "
"in your output — translate only the NEW page shown in the image.\n"
)
for name, text in past_pages:
parts.append(f"\n[Previous page: {name}]\n{text}")
if ahead_pages:
parts.append(
"\n\n---\n\n"
"Context: the ORIGINAL JAPANESE (not a translation) of the "
"upcoming page(s) that follow the one you're translating now. "
"Use this only to correctly resolve things that depend on what "
"happens next — pronoun/referent gender, who a line is actually "
"addressed to, a setup whose payoff lands later. Do NOT "
"translate or otherwise output any of this — translate only the "
"CURRENT page shown in the image.\n"
)
for name, text in ahead_pages:
parts.append(f"\n[Upcoming page ({name}), original Japanese]\n{text}")
return "".join(parts)
def get_japanese_context(
img_path: Path, context_src_dir: Path,
ahead_ocr_backend: str, ahead_ocr_state: dict,
) -> str:
"""Resolves the raw Japanese transcription of a page used as lookahead
context, in priority order:
1. A .txt already sitting in context_src_dir with the same stem —
hand-prepared, or cached from a previous run/page. Used as-is.
2. Otherwise, OCR it now with the chosen backend (--ahead-ocr-backend)
and cache the result into context_src_dir, so the next page that
needs this same page as context doesn't re-OCR it.
Returns "" (and prints a warning) if OCR isn't available/fails — a
missing bit of lookahead context isn't worth failing the whole page
over, it just means less context than requested.
"""
cached_path = context_src_dir / f"{img_path.stem}.txt"
if cached_path.exists():
return cached_path.read_text(encoding="utf-8").strip()
try:
if ahead_ocr_backend == "local":
if "mocr" not in ahead_ocr_state:
from local_mangaocr_ocr import ocr_page_manga
from manga_ocr import MangaOcr
print("Loading local manga-ocr model for lookahead context (first use only)...")
ahead_ocr_state["mocr"] = MangaOcr()
ahead_ocr_state["ocr_page_manga"] = ocr_page_manga
pil_img = Image.open(img_path)
text = ahead_ocr_state["ocr_page_manga"](ahead_ocr_state["mocr"], pil_img)
else: # "llm"
client = ahead_ocr_state["client"]
model = ahead_ocr_state["model"]
request_kwargs = ahead_ocr_state["request_kwargs"]
extra_body = ahead_ocr_state["extra_body"]
prompt = ahead_ocr_state["prompt"]
text = ocr_image(client, model, prompt, img_path, request_kwargs, extra_body)
except Exception as e: # noqa: BLE001
print(f"\nWarning: couldn't OCR {img_path.name} for lookahead context: {e}", file=sys.stderr)
return ""
text = text.strip()
context_src_dir.mkdir(parents=True, exist_ok=True)
cached_path.write_text(text, encoding="utf-8")
return text
def write_log(log_dir: Path, page_name: str, entry: dict) -> None:
"""Writes one JSON log file per request/response.
The base64 image payload itself is never included (megabytes of no
debugging value, one per page) — only its path/size — but everything
else (full prompt text, model params, every retry attempt, the full raw
API response) is recorded as-is.
"""
log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", page_name)
log_path = log_dir / f"{timestamp}_{safe_name}.json"
log_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def image_to_data_url(path: Path, max_dim: int = 2200) -> str:
"""Downscale (if needed) and encode an image as a base64 data URL."""
img = Image.open(path)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if max(img.size) > max_dim:
ratio = max_dim / max(img.size)
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def ocr_image(
client: OpenAI, model: str, prompt: str, path: Path,
request_kwargs: dict, extra_body: dict,
retries: int = 3, log_entry: dict = None,
) -> str:
data_url = image_to_data_url(path)
if log_entry is not None:
log_entry["request"] = {
"model": model,
**request_kwargs,
"extra_body": extra_body or None,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"<omitted: {path.name}, {path.stat().st_size} bytes>"}},
],
}
],
}
log_entry["attempts"] = []
last_err = None
for attempt in range(retries):
try:
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
extra_body=extra_body or None,
**request_kwargs,
)
text = (response.choices[0].message.content or "").strip()
if log_entry is not None:
try:
raw_response = response.model_dump()
except Exception: # noqa: BLE001
raw_response = {"content": text}
log_entry["attempts"].append({"attempt": attempt + 1, "success": True, "response": raw_response})
return text
except Exception as e: # noqa: BLE001
last_err = e
if log_entry is not None:
log_entry["attempts"].append({"attempt": attempt + 1, "success": False, "error": str(e)})
time.sleep(3 * (attempt + 1))
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
def main():
parser = argparse.ArgumentParser(description="Batch-OCR/translate manga pages via an OpenAI-compatible API")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument(
"--config", default=str(ROOT_DIR / "config.json"),
help="Path to config.json with an \"ocr\" section (api_key/base_url/model/...). "
"Defaults to config.json at the repo root."
)
parser.add_argument(
"--translate", action="store_true",
help="Translate the page instead of transcribing the Japanese "
"(uses prompt_manga_translate.txt by default)"
)
parser.add_argument(
"--target-lang", default="English",
help="Target language for --translate, written out as a plain name "
"(e.g. Russian, English, Spanish). Default: English"
)
parser.add_argument(
"--glossary", default=None,
help="[--translate only] Path to a glossary.md of established name/term "
"translations, sent along with the prompt for consistency (see "
"ocr/glossary.example.md). Auto-detected as glossary.md next to "
"this script if not given."
)
parser.add_argument(
"--context-pages", type=int, default=2,
help="[--translate only] Include the last N translated pages in the "
"prompt for continuity (character voice, ongoing dialogue, "
"pronouns/referents). 0 disables it. Default: 2"
)
parser.add_argument(
"--context-pages-ahead", type=int, default=0,
help="[--translate only] Also include the ORIGINAL JAPANESE (never a "
"draft translation — see ocr/README.md) of the next N pages, "
"for context that depends on what happens next (a twist, "
"who's actually being addressed, a joke that pays off later). "
"0 (default) disables it. Before translating a page, this "
"makes sure the next N pages' Japanese text is available "
"(OCR'ing it now if there's no cached/hand-prepared .txt yet), "
"so pages needing lookahead take a bit longer."
)
parser.add_argument(
"--ahead-ocr-backend", choices=["llm", "local"], default="llm",
help="[--context-pages-ahead only] How to OCR an upcoming page's "
"Japanese text when no cached/hand-prepared .txt is found for "
"it yet. 'llm': the same OpenAI-compatible API as the main "
"translation (transcription prompt, no translation). 'local': "
"manga-ocr running fully offline (requires the manga-ocr and "
"opencv-python packages). Default: llm"
)
parser.add_argument(
"--context-src-dir", default=None,
help="[--context-pages-ahead only] Folder holding the upcoming pages' "
"original-Japanese .txt files, named exactly like the page image "
"stems (e.g. page005.txt for page005.jpg) — hand-prepared ones "
"are used as-is; OCR'd-on-the-fly ones are cached here too. "
"Defaults to <output>/context_src/"
)
parser.add_argument(
"--prompt-file", default=None,
help="Path to the prompt file (defaults to prompt_manga.txt, or "
"prompt_manga_translate.txt with --translate)"
)
parser.add_argument("--base-url", default=None, help="Override base_url from config.json")
parser.add_argument("--model", default=None, help="Override model from config.json")
parser.add_argument("--api-key", default=None, help="Override api_key from config.json")
parser.add_argument(
"--temperature", type=float, default=None,
help="Override temperature from config.json (default if unset anywhere: 0)"
)
parser.add_argument("--max-tokens", type=int, default=None, help="Override max_tokens from config.json")
parser.add_argument("--top-p", type=float, default=None, help="Override top_p from config.json")
parser.add_argument(
"--reasoning-effort", default=None,
help="Override reasoning_effort from config.json (e.g. low/medium/high — support "
"depends on the model/provider; omitted from the request unless set)"
)
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument(
"--sleep", type=float, default=0.0,
help="Delay in seconds between requests (useful if you're hitting rate limits)"
)
parser.add_argument(
"--log-dir", default=str(ROOT_DIR / "logs"),
help="Folder for per-page request/response logs (one JSON file per page). "
"Defaults to logs/ at the repo root."
)
parser.add_argument(
"--no-log", action="store_true",
help="Disable request/response logging entirely"
)
args = parser.parse_args()
config = load_config(Path(args.config))
api_key = args.api_key or config.get("api_key") or os.environ.get("API_KEY")
base_url = args.base_url or config.get("base_url")
model = args.model or config.get("model")
missing = [name for name, val in [("api_key", api_key), ("base_url", base_url), ("model", model)] if not val]
if missing:
print(
f"Missing settings: {', '.join(missing)}. "
f"Fill them in {args.config} (see config.example.json) or pass --api-key/--base-url/--model.",
file=sys.stderr,
)
sys.exit(1)
request_kwargs, extra_body = build_request_kwargs(config, args)
if args.translate:
default_prompt_name = "prompt_manga_translate.txt"
target_language = args.target_lang
glossary_path = find_glossary(args.glossary)
glossary = glossary_path.read_text(encoding="utf-8").strip() if glossary_path else None
if glossary_path:
print(f"Using glossary: {glossary_path}")
else:
default_prompt_name = "prompt_manga.txt"
target_language = None
glossary = None
prompt_file = Path(args.prompt_file) if args.prompt_file else SCRIPT_DIR / default_prompt_name
prompt = load_prompt(prompt_file, target_language, glossary)
use_ahead_context = args.translate and args.context_pages_ahead > 0
ahead_ocr_state = {}
if use_ahead_context and args.ahead_ocr_backend == "llm":
# Plain transcription prompt/params for OCR'ing lookahead pages —
# never the translate prompt (we want the raw Japanese, not a
# draft translation — see the design note in build_context_block).
ahead_ocr_state["prompt"] = load_prompt(SCRIPT_DIR / "prompt_manga.txt")
ahead_ocr_state["request_kwargs"] = request_kwargs
ahead_ocr_state["extra_body"] = extra_body
# client/model are filled in below, once the real client exists.
input_dir = Path(args.input)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
pages_dir = output_dir / "pages_txt"
pages_dir.mkdir(exist_ok=True)
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
images = natsorted(images, key=lambda p: p.name)
if not images:
print(f"No images found in {input_dir}.", file=sys.stderr)
sys.exit(1)
print(f"Pages found: {len(images)}")
log_dir = None if args.no_log else Path(args.log_dir)
use_context = args.translate and args.context_pages > 0
recent_context = deque(maxlen=args.context_pages) if use_context else None
context_src_dir = Path(args.context_src_dir) if args.context_src_dir else output_dir / "context_src"
client = OpenAI(base_url=base_url, api_key=api_key)
if use_ahead_context and args.ahead_ocr_backend == "llm":
ahead_ocr_state["client"] = client
ahead_ocr_state["model"] = model
combined_path = output_dir / "combined.md"
failed = []
with open(combined_path, "w", encoding="utf-8") as combined_f:
for pos, img_path in enumerate(tqdm(images, desc="OCR")):
idx = args.start_page + pos
txt_out = pages_dir / f"{img_path.stem}.txt"
if txt_out.exists():
text = txt_out.read_text(encoding="utf-8")
else:
page_prompt = prompt
context_used_past = []
context_used_ahead = []
if use_context and recent_context:
context_used_past = list(recent_context)
if use_ahead_context:
for future_img in images[pos + 1: pos + 1 + args.context_pages_ahead]:
jp_text = get_japanese_context(future_img, context_src_dir, args.ahead_ocr_backend, ahead_ocr_state)
if jp_text and jp_text != "[NO_TEXT]":
context_used_ahead.append((future_img.stem, jp_text))
if context_used_past or context_used_ahead:
page_prompt = prompt + build_context_block(context_used_past, context_used_ahead)
log_entry = {
"timestamp": datetime.now().isoformat(),
"backend": "manga_ocr_llm",
"page": img_path.name,
"translate": args.translate,
"target_lang": args.target_lang if args.translate else None,
"glossary_used": bool(glossary),
"context_pages_used": [name for name, _ in context_used_past],
"context_pages_ahead_used": [name for name, _ in context_used_ahead],
"model": model,
"base_url": base_url,
} if log_dir is not None else None
try:
text = ocr_image(client, model, page_prompt, img_path, request_kwargs, extra_body, log_entry=log_entry)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
failed.append(img_path.name)
text = ""
if log_entry is not None:
log_entry["error"] = str(e)
# Do NOT write a file to disk on failure — otherwise the next
# run would see the file exists and assume the page is already
# done, silently skipping a retry forever.
if log_entry is not None:
write_log(log_dir, img_path.stem, log_entry)
if args.sleep:
time.sleep(args.sleep)
# Feed this page's (already-completed-or-just-translated) text
# forward as context for the following pages. Blank/[NO_TEXT]
# pages carry no useful continuity, so skip adding those.
if use_context and text.strip() and text.strip() != "[NO_TEXT]":
recent_context.append((img_path.stem, text.strip()))
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
combined_f.write(text)
print(f"\nDone. Combined file: {combined_path}")
print(f"Per-page files: {pages_dir}")
if use_ahead_context:
print(f"Cached lookahead Japanese context: {context_src_dir}")
if log_dir is not None:
print(f"Request/response logs: {log_dir}")
if failed:
print(f"\nFailed to OCR {len(failed)} page(s):")
for name in failed:
print(f" - {name}")
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
if __name__ == "__main__":
main()
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via any
OpenAI-compatible API (OpenRouter, a direct provider endpoint, a
self-hosted proxy, etc.) — including Gemini, GPT-4V-class models, or
anything else exposed through such an endpoint.
This lets a multimodal LLM "read" a page image directly, including
vertical Japanese text, without any column-segmentation preprocessing.
Deliberately simple: light novel pages are dense running prose, and all
this script needs to do is transcribe them accurately. It has no
translate/glossary/context-page machinery — see manga_ocr_llm.py for that,
which is a genuinely different job (short, scattered dialogue that needs
continuity handling).
Usage (after filling in config.json):
python novel_ocr.py --input ./pages --output ./out
Requirements:
pip install openai pillow natsort tqdm
One-time setup:
1. Copy config.example.json (repo root) -> config.json
2. Fill in the "ocr" section: api_key, base_url, model (temperature/
max_tokens/top_p/reasoning_effort are optional — see config.example.json)
3. Optionally edit prompt_novel.txt to fit your book / house style
Every request/response is logged as one JSON file under logs/ at the repo
root (--log-dir to change, --no-log to disable) — full prompt, model
params, every retry attempt, and the full raw API response (the base64
image itself is never included, only its path/size).
"""
import argparse
import base64
import io
import json
import os
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from natsort import natsorted
from openai import OpenAI
from PIL import Image
from tqdm import tqdm
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent
def load_config(config_path: Path) -> dict:
"""Loads the shared config.json and returns its "ocr" section.
Falls back to treating the whole file as the ocr config if there's no
"ocr" key, so a bare {"api_key": ...} style file still works.
"""
if not config_path.exists():
return {}
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"Failed to parse {config_path}: {e}", file=sys.stderr)
sys.exit(1)
return data.get("ocr", data) if isinstance(data, dict) else {}
def load_prompt(prompt_path: Path) -> str:
if not prompt_path.exists():
print(f"Prompt file not found: {prompt_path}", file=sys.stderr)
sys.exit(1)
return prompt_path.read_text(encoding="utf-8").strip()
def build_request_kwargs(config: dict, args: argparse.Namespace) -> tuple[dict, dict]:
"""Resolves OpenAI-API-style request params: CLI flag > config.json > a
sensible default. Returns (kwargs, extra_body) — standard params go
straight into the request; reasoning_effort goes through extra_body
since support for it varies by provider/model and extra_body is the
designed passthrough for exactly that.
"""
temperature = args.temperature if args.temperature is not None else config.get("temperature", 0)
max_tokens = args.max_tokens if args.max_tokens is not None else config.get("max_tokens")
top_p = args.top_p if args.top_p is not None else config.get("top_p")
reasoning_effort = args.reasoning_effort or config.get("reasoning_effort")
kwargs = {"temperature": temperature}
if max_tokens is not None:
kwargs["max_tokens"] = max_tokens
if top_p is not None:
kwargs["top_p"] = top_p
extra_body = {}
if reasoning_effort:
extra_body["reasoning_effort"] = reasoning_effort
return kwargs, extra_body
def write_log(log_dir: Path, page_name: str, entry: dict) -> None:
"""Writes one JSON log file per request/response.
The base64 image payload itself is never included (megabytes of no
debugging value, one per page) — only its path/size — but everything
else (full prompt text, model params, every retry attempt, the full raw
API response) is recorded as-is.
"""
log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", page_name)
log_path = log_dir / f"{timestamp}_{safe_name}.json"
log_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
def image_to_data_url(path: Path, max_dim: int = 2200) -> str:
"""Downscale (if needed) and encode an image as a base64 data URL."""
img = Image.open(path)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if max(img.size) > max_dim:
ratio = max_dim / max(img.size)
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def ocr_image(
client: OpenAI, model: str, prompt: str, path: Path,
request_kwargs: dict, extra_body: dict,
retries: int = 3, log_entry: dict = None,
) -> str:
data_url = image_to_data_url(path)
if log_entry is not None:
log_entry["request"] = {
"model": model,
**request_kwargs,
"extra_body": extra_body or None,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"<omitted: {path.name}, {path.stat().st_size} bytes>"}},
],
}
],
}
log_entry["attempts"] = []
last_err = None
for attempt in range(retries):
try:
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
extra_body=extra_body or None,
**request_kwargs,
)
text = (response.choices[0].message.content or "").strip()
if log_entry is not None:
try:
raw_response = response.model_dump()
except Exception: # noqa: BLE001
raw_response = {"content": text}
log_entry["attempts"].append({"attempt": attempt + 1, "success": True, "response": raw_response})
return text
except Exception as e: # noqa: BLE001
last_err = e
if log_entry is not None:
log_entry["attempts"].append({"attempt": attempt + 1, "success": False, "error": str(e)})
time.sleep(3 * (attempt + 1))
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
def main():
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via an OpenAI-compatible API")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument(
"--config", default=str(ROOT_DIR / "config.json"),
help="Path to config.json with an \"ocr\" section (api_key/base_url/model/...). "
"Defaults to config.json at the repo root."
)
parser.add_argument(
"--prompt-file", default=str(SCRIPT_DIR / "prompt_novel.txt"),
help="Path to the prompt file (defaults to prompt_novel.txt next to this script)"
)
parser.add_argument("--base-url", default=None, help="Override base_url from config.json")
parser.add_argument("--model", default=None, help="Override model from config.json")
parser.add_argument("--api-key", default=None, help="Override api_key from config.json")
parser.add_argument(
"--temperature", type=float, default=None,
help="Override temperature from config.json (default if unset anywhere: 0)"
)
parser.add_argument("--max-tokens", type=int, default=None, help="Override max_tokens from config.json")
parser.add_argument("--top-p", type=float, default=None, help="Override top_p from config.json")
parser.add_argument(
"--reasoning-effort", default=None,
help="Override reasoning_effort from config.json (e.g. low/medium/high — support "
"depends on the model/provider; omitted from the request unless set)"
)
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument(
"--sleep", type=float, default=0.0,
help="Delay in seconds between requests (useful if you're hitting rate limits)"
)
parser.add_argument(
"--log-dir", default=str(ROOT_DIR / "logs"),
help="Folder for per-page request/response logs (one JSON file per page). "
"Defaults to logs/ at the repo root."
)
parser.add_argument(
"--no-log", action="store_true",
help="Disable request/response logging entirely"
)
args = parser.parse_args()
config = load_config(Path(args.config))
api_key = args.api_key or config.get("api_key") or os.environ.get("API_KEY")
base_url = args.base_url or config.get("base_url")
model = args.model or config.get("model")
missing = [name for name, val in [("api_key", api_key), ("base_url", base_url), ("model", model)] if not val]
if missing:
print(
f"Missing settings: {', '.join(missing)}. "
f"Fill them in {args.config} (see config.example.json) or pass --api-key/--base-url/--model.",
file=sys.stderr,
)
sys.exit(1)
request_kwargs, extra_body = build_request_kwargs(config, args)
prompt = load_prompt(Path(args.prompt_file))
input_dir = Path(args.input)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
pages_dir = output_dir / "pages_txt"
pages_dir.mkdir(exist_ok=True)
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
images = natsorted(images, key=lambda p: p.name)
if not images:
print(f"No images found in {input_dir}.", file=sys.stderr)
sys.exit(1)
print(f"Pages found: {len(images)}")
log_dir = None if args.no_log else Path(args.log_dir)
client = OpenAI(base_url=base_url, api_key=api_key)
combined_path = output_dir / "combined.md"
failed = []
with open(combined_path, "w", encoding="utf-8") as combined_f:
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
txt_out = pages_dir / f"{img_path.stem}.txt"
if txt_out.exists():
text = txt_out.read_text(encoding="utf-8")
else:
log_entry = {
"timestamp": datetime.now().isoformat(),
"backend": "novel_ocr",
"page": img_path.name,
"model": model,
"base_url": base_url,
} if log_dir is not None else None
try:
text = ocr_image(client, model, prompt, img_path, request_kwargs, extra_body, log_entry=log_entry)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
failed.append(img_path.name)
text = ""
if log_entry is not None:
log_entry["error"] = str(e)
# Do NOT write a file to disk on failure — otherwise the next
# run would see the file exists and assume the page is already
# done, silently skipping a retry forever.
if log_entry is not None:
write_log(log_dir, img_path.stem, log_entry)
if args.sleep:
time.sleep(args.sleep)
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
combined_f.write(text)
print(f"\nDone. Combined file: {combined_path}")
print(f"Per-page files: {pages_dir}")
if log_dir is not None:
print(f"Request/response logs: {log_dir}")
if failed:
print(f"\nFailed to OCR {len(failed)} page(s):")
for name in failed:
print(f" - {name}")
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
if __name__ == "__main__":
main()
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
"""
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via any
OpenAI-compatible API (OpenRouter, a self-hosted proxy, etc.).
This is the recommended OCR backend for this project: it lets a multimodal
LLM "read" a page image directly, including vertical Japanese text, without
any column-segmentation preprocessing.
Usage (after filling in config.json):
python openrouter_ocr.py --input ./pages --output ./out
Requirements:
pip install openai pillow natsort tqdm
One-time setup:
1. Copy config.example.json -> config.json
2. Fill in your api_key, base_url and model
3. Optionally edit prompt.txt to fit your book / house style
"""
import argparse
import base64
import io
import json
import os
import sys
import time
from pathlib import Path
from natsort import natsorted
from openai import OpenAI
from PIL import Image
from tqdm import tqdm
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
SCRIPT_DIR = Path(__file__).resolve().parent
def load_config(config_path: Path) -> dict:
if not config_path.exists():
return {}
try:
return json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"Failed to parse {config_path}: {e}", file=sys.stderr)
sys.exit(1)
def load_prompt(prompt_path: Path) -> str:
if not prompt_path.exists():
print(f"Prompt file not found: {prompt_path}", file=sys.stderr)
sys.exit(1)
return prompt_path.read_text(encoding="utf-8").strip()
def image_to_data_url(path: Path, max_dim: int = 2200) -> str:
"""Downscale (if needed) and encode an image as a base64 data URL."""
img = Image.open(path)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if max(img.size) > max_dim:
ratio = max_dim / max(img.size)
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def ocr_image(client: OpenAI, model: str, prompt: str, path: Path, retries: int = 3) -> str:
data_url = image_to_data_url(path)
last_err = None
for attempt in range(retries):
try:
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
)
text = (response.choices[0].message.content or "").strip()
return text
except Exception as e: # noqa: BLE001
last_err = e
time.sleep(3 * (attempt + 1))
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
def main():
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via an OpenAI-compatible API")
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
parser.add_argument("--output", required=True, help="Folder for the OCR results")
parser.add_argument(
"--config", default=str(SCRIPT_DIR / "config.json"),
help="Path to config.json with api_key/base_url/model (defaults to config.json next to this script)"
)
parser.add_argument(
"--prompt-file", default=str(SCRIPT_DIR / "prompt.txt"),
help="Path to the prompt file (defaults to prompt.txt next to this script)"
)
parser.add_argument("--base-url", default=None, help="Override base_url from config.json")
parser.add_argument("--model", default=None, help="Override model from config.json")
parser.add_argument("--api-key", default=None, help="Override api_key from config.json")
parser.add_argument("--start-page", type=int, default=1)
parser.add_argument(
"--sleep", type=float, default=0.0,
help="Delay in seconds between requests (useful if you're hitting rate limits)"
)
args = parser.parse_args()
config = load_config(Path(args.config))
api_key = args.api_key or config.get("api_key") or os.environ.get("API_KEY")
base_url = args.base_url or config.get("base_url")
model = args.model or config.get("model")
missing = [name for name, val in [("api_key", api_key), ("base_url", base_url), ("model", model)] if not val]
if missing:
print(
f"Missing settings: {', '.join(missing)}. "
f"Fill them in {args.config} (see config.example.json) or pass --api-key/--base-url/--model.",
file=sys.stderr,
)
sys.exit(1)
prompt = load_prompt(Path(args.prompt_file))
input_dir = Path(args.input)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
pages_dir = output_dir / "pages_txt"
pages_dir.mkdir(exist_ok=True)
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
images = natsorted(images, key=lambda p: p.name)
if not images:
print(f"No images found in {input_dir}.", file=sys.stderr)
sys.exit(1)
print(f"Pages found: {len(images)}")
client = OpenAI(base_url=base_url, api_key=api_key)
combined_path = output_dir / "combined.md"
failed = []
with open(combined_path, "w", encoding="utf-8") as combined_f:
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
txt_out = pages_dir / f"{img_path.stem}.txt"
if txt_out.exists():
text = txt_out.read_text(encoding="utf-8")
else:
try:
text = ocr_image(client, model, prompt, img_path)
txt_out.write_text(text, encoding="utf-8")
except Exception as e: # noqa: BLE001
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
failed.append(img_path.name)
text = ""
# Do NOT write a file to disk on failure — otherwise the next
# run would see the file exists and assume the page is already
# done, silently skipping a retry forever.
if args.sleep:
time.sleep(args.sleep)
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
combined_f.write(text)
print(f"\nDone. Combined file: {combined_path}")
print(f"Per-page files: {pages_dir}")
if failed:
print(f"\nFailed to OCR {len(failed)} page(s):")
for name in failed:
print(f" - {name}")
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
This is a scanned page from a Japanese manga. Unlike running prose, text is
split across speech bubbles, thought bubbles, narration boxes, and sound
effects (SFX) scattered over one or more panels.
Transcribe ALL Japanese text on the page as a numbered list, one entry per
bubble/box/SFX, in manga reading order:
- Panels are read right-to-left, top-to-bottom.
- Within a panel, bubbles are read right-to-left, top-to-bottom, following
the natural flow implied by tail direction / speaker position where it's
ambiguous.
Format each entry exactly as:
N. [TYPE] text
Where TYPE is one of:
- DIALOGUE — normal speech-bubble text
- THOUGHT — thought-bubble text (usually a cloud-shaped bubble)
- NARRATION — caption/narration box text (usually a square/rectangular box)
- SFX — a sound effect drawn directly on the art, not inside a bubble
Rules:
- Mark furigana using Aozora Bunko notation: base_text《reading》, e.g.
本文《ほんぶん》.
- Transcribe each bubble/box/SFX as ONE entry, even if it spans multiple
lines inside the art — join its lines with a single space.
- Do not translate, summarize, or explain — output only the transcribed
Japanese text.
- No commentary, no markdown formatting beyond the "N. [TYPE] text" list.
- If the page has no text at all (splash art, blank, cover), output
exactly: [NO_TEXT]
+40
View File
@@ -0,0 +1,40 @@
This is a scanned page from a Japanese manga. Text is split across speech
bubbles, thought bubbles, narration boxes, and sound effects (SFX) rather
than running prose.
Instead of transcribing the Japanese, TRANSLATE all text on the page into
{target_language}. Output a numbered list, one entry per bubble/box/SFX, in
manga reading order:
- Panels are read right-to-left, top-to-bottom.
- Within a panel, bubbles are read right-to-left, top-to-bottom, following
the natural flow implied by tail direction / speaker position where it's
ambiguous.
Format each entry exactly as:
N. [TYPE] translated text
Where TYPE is one of:
- DIALOGUE — normal speech-bubble text
- THOUGHT — thought-bubble text (usually a cloud-shaped bubble)
- NARRATION — caption/narration box text (usually a square/rectangular box)
- SFX — a sound effect drawn directly on the art, not inside a bubble
Rules:
- Translate naturally and idiomatically into {target_language} — don't
produce a stiff, literal word-for-word rendering. Preserve each
character's tone/register (casual, formal, rude, childish, archaic, etc.)
as far as {target_language} allows.
- For SFX, prefer a natural {target_language} equivalent sound effect over a
literal translation when a well-known one exists (e.g. a heartbeat SFX ->
"thump thump" rather than a literal gloss); if there's no good
equivalent, transliterate the Japanese reading instead of translating it
literally.
- Keep honorifics (-san, -kun, -chan, senpai, etc.) untranslated if
{target_language} has no natural equivalent, unless the whole line reads
better without them — use your judgment.
- Do not include the original Japanese text, romaji, or any commentary —
output only the translated line for each entry.
- Treat each bubble/box/SFX as ONE entry, even if it spans multiple lines
inside the art — join it into a single line for that entry.
- If the page has no text at all (splash art, blank, cover), output
exactly: [NO_TEXT]
+5 -6
View File
@@ -5,16 +5,15 @@ tqdm
# --- OCR backends: install only the one(s) you actually use ---
# ocr/openrouter_ocr.py (recommended) — any OpenAI-compatible API
# ocr/novel_ocr.py (recommended for light novels) — any OpenAI-compatible API
# ocr/manga_ocr_llm.py (recommended for manga) — same API, plus translation
openai
# ocr/gemini_direct_ocr.py — direct Gemini API access
# google-genai
# ocr/google_vision_ocr.py — Google Cloud Vision
# ocr/google_vision_ocr.py — Google Cloud Vision (pure OCR, no translation)
# google-cloud-vision
# ocr/local_mangaocr_ocr.py — fully offline, no cloud account
# ocr/local_mangaocr_ocr.py — fully offline, no cloud account, pure OCR
# also needed for ocr/manga_ocr_llm.py's --ahead-ocr-backend local
# manga-ocr
# opencv-python
# numpy