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