From 35b7be680edf15d652e853dd108e0c0494ee8b01 Mon Sep 17 00:00:00 2001 From: Poison Flower Date: Sun, 6 Sep 2026 15:42:29 +0300 Subject: [PATCH] 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 --- .gitignore | 39 ++ README.md | 68 ++-- config.example.json | 35 ++ epub_builder/README.md | 58 +-- epub_builder/build_epub.py | 23 +- epub_builder/chapters/.gitkeep | 0 epub_builder/config.example.json | 21 - epub_builder/font/.gitkeep | 0 epub_builder/furigana.py | 2 +- ocr/README.md | 287 ++++++++++++-- ocr/__init__.py | 31 +- ocr/config.example.json | 5 - ocr/gemini_direct_ocr.py | 146 ------- ocr/glossary.example.md | 25 ++ ocr/google_vision_ocr.py | 195 ++++++++- ocr/local_mangaocr_ocr.py | 211 +++++++++- ocr/manga_ocr_llm.py | 567 +++++++++++++++++++++++++++ ocr/novel_ocr.py | 320 +++++++++++++++ ocr/openrouter_ocr.py | 191 --------- ocr/prompt_manga.txt | 30 ++ ocr/prompt_manga_translate.txt | 40 ++ ocr/{prompt.txt => prompt_novel.txt} | 0 requirements.txt | 11 +- 23 files changed, 1811 insertions(+), 494 deletions(-) create mode 100644 .gitignore create mode 100644 config.example.json create mode 100644 epub_builder/chapters/.gitkeep delete mode 100644 epub_builder/config.example.json create mode 100644 epub_builder/font/.gitkeep delete mode 100644 ocr/config.example.json delete mode 100644 ocr/gemini_direct_ocr.py create mode 100644 ocr/glossary.example.md create mode 100644 ocr/manga_ocr_llm.py create mode 100644 ocr/novel_ocr.py delete mode 100644 ocr/openrouter_ocr.py create mode 100644 ocr/prompt_manga.txt create mode 100644 ocr/prompt_manga_translate.txt rename ocr/{prompt.txt => prompt_novel.txt} (100%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..838e031 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index ef058bb..5385893 100644 --- a/README.md +++ b/README.md @@ -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 `` 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/.txt + ocr/ module → transcribes (or translates) each page into + pages_txt/.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 `` 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 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..99ddb4a --- /dev/null +++ b/config.example.json @@ -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": "" + } + } +} diff --git a/epub_builder/README.md b/epub_builder/README.md index a9b1601..6bfa38d 100644 --- a/epub_builder/README.md +++ b/epub_builder/README.md @@ -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 `` 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 diff --git a/epub_builder/build_epub.py b/epub_builder/build_epub.py index 15ee4da..f8308c2 100644 --- a/epub_builder/build_epub.py +++ b/epub_builder/build_epub.py @@ -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") diff --git a/epub_builder/chapters/.gitkeep b/epub_builder/chapters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/epub_builder/config.example.json b/epub_builder/config.example.json deleted file mode 100644 index 736eecf..0000000 --- a/epub_builder/config.example.json +++ /dev/null @@ -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": "" - } -} diff --git a/epub_builder/font/.gitkeep b/epub_builder/font/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/epub_builder/furigana.py b/epub_builder/furigana.py index 1054852..74e3ad7 100644 --- a/epub_builder/furigana.py +++ b/epub_builder/furigana.py @@ -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 markup. Used by build_epub.py, and also runnable standalone as a quick preview/debugging tool for a single page. diff --git a/ocr/README.md b/ocr/README.md index ac5f71b..a1a1af9 100644 --- a/ocr/README.md +++ b/ocr/README.md @@ -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/.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 `/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. diff --git a/ocr/__init__.py b/ocr/__init__.py index 099cc92..bee6f76 100644 --- a/ocr/__init__.py +++ b/ocr/__init__.py @@ -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 """ diff --git a/ocr/config.example.json b/ocr/config.example.json deleted file mode 100644 index 15eea5c..0000000 --- a/ocr/config.example.json +++ /dev/null @@ -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" -} diff --git a/ocr/gemini_direct_ocr.py b/ocr/gemini_direct_ocr.py deleted file mode 100644 index 5292379..0000000 --- a/ocr/gemini_direct_ocr.py +++ /dev/null @@ -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\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() diff --git a/ocr/glossary.example.md b/ocr/glossary.example.md new file mode 100644 index 0000000..240269f --- /dev/null +++ b/ocr/glossary.example.md @@ -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). diff --git a/ocr/google_vision_ocr.py b/ocr/google_vision_ocr.py index d34d9de..4603251 100644 --- a/ocr/google_vision_ocr.py +++ b/ocr/google_vision_ocr.py @@ -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"", + } + 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"", + } + 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\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: diff --git a/ocr/local_mangaocr_ocr.py b/ocr/local_mangaocr_ocr.py index 2f35d42..d7ca642 100644 --- a/ocr/local_mangaocr_ocr.py +++ b/ocr/local_mangaocr_ocr.py @@ -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 /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 " + "/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) diff --git a/ocr/manga_ocr_llm.py b/ocr/manga_ocr_llm.py new file mode 100644 index 0000000..83573b8 --- /dev/null +++ b/ocr/manga_ocr_llm.py @@ -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""}}, + ], + } + ], + } + 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 /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\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() diff --git a/ocr/novel_ocr.py b/ocr/novel_ocr.py new file mode 100644 index 0000000..ede9693 --- /dev/null +++ b/ocr/novel_ocr.py @@ -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""}}, + ], + } + ], + } + 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\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() diff --git a/ocr/openrouter_ocr.py b/ocr/openrouter_ocr.py deleted file mode 100644 index d58565a..0000000 --- a/ocr/openrouter_ocr.py +++ /dev/null @@ -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\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() diff --git a/ocr/prompt_manga.txt b/ocr/prompt_manga.txt new file mode 100644 index 0000000..26f6d60 --- /dev/null +++ b/ocr/prompt_manga.txt @@ -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] diff --git a/ocr/prompt_manga_translate.txt b/ocr/prompt_manga_translate.txt new file mode 100644 index 0000000..59a2576 --- /dev/null +++ b/ocr/prompt_manga_translate.txt @@ -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] diff --git a/ocr/prompt.txt b/ocr/prompt_novel.txt similarity index 100% rename from ocr/prompt.txt rename to ocr/prompt_novel.txt diff --git a/requirements.txt b/requirements.txt index d2867ed..6bde425 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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