354 lines
18 KiB
Markdown
354 lines
18 KiB
Markdown
# 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 speech bubbles that benefit from translation, a glossary,
|
|
and cross-page continuity context:
|
|
|
|
| | 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 | `--translate`, with glossary + cross-page context |
|
|
|
|
`novel_ocr.py` and `manga_ocr_llm.py` share their translate/state/logging
|
|
infrastructure via [`common.py`](common.py) — the only thing genuinely
|
|
different between them is layout (reading order, output format), which is
|
|
exactly why they're still two scripts rather than split along an
|
|
OCR-vs-translate axis: translating manga still needs to know about panels
|
|
and bubble types, and translating a novel would still want the same
|
|
glossary/context machinery, so the layout split is what actually avoids
|
|
duplicated logic.
|
|
|
|
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.
|
|
|
|
## `novel_ocr.py` (recommended for light novels)
|
|
|
|
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 — or
|
|
translates it directly with `--translate` (same glossary/context
|
|
machinery as `manga_ocr_llm.py`, via the shared [`common.py`](common.py)).
|
|
Kept deliberately simpler than `manga_ocr_llm.py` on the transcription
|
|
side — no bubble types/reading-order logic — since a novel page is one
|
|
linear column of prose, not scattered panels.
|
|
|
|
```bash
|
|
pip install openai pillow natsort tqdm
|
|
```
|
|
|
|
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` (transcription) or
|
|
`prompt_novel_translate.txt` (`--translate`) — plain English text, no
|
|
need to touch any code to tweak the instructions given to the model.
|
|
|
|
```bash
|
|
python novel_ocr.py --input ./pages --output ./out
|
|
|
|
# ...or translate directly instead of transcribing:
|
|
python novel_ocr.py --input ./pages --output ./out --translate --target-lang Russian
|
|
```
|
|
|
|
- `--input`: folder with scanned page images (jpg/png/...), named so that
|
|
alphabetical sorting matches page order (natural sort is used, so
|
|
`page2.jpg` and `page10.jpg` sort correctly too).
|
|
- `--output`: folder for results. Creates `pages_txt/<name>.txt` (one
|
|
file per page), a `combined.md` preview of the whole run, and a
|
|
`state.json` manifest (see below).
|
|
- If interrupted, just re-run with the same `--output` — pages that already
|
|
have a `.txt` file are skipped, so nothing already done gets re-sent
|
|
(and re-billed).
|
|
- A page that fails to OCR (network error, rate limit, truncated/empty
|
|
response, etc.) does **not** get a file written for it, specifically so
|
|
the next run retries it instead of silently treating it as done.
|
|
- A page the model judges to be empty (illustration-only, blank, or a
|
|
cover/technical page) gets a file containing exactly the literal text
|
|
`[NO_TEXT]` — this is a deliberate marker, not an OCR failure. The
|
|
`epub_builder` module knows to treat it as "no text on this page".
|
|
- `--pages "3,7-12,20"` limits the run to just those pages (1-based
|
|
positions in the sorted page list) — combine with `--redo` to re-OCR/
|
|
re-translate just a few pages, e.g. after tweaking the prompt or
|
|
glossary, without redoing the whole book.
|
|
- `--redo` re-runs pages even if a `.txt` already exists for them.
|
|
- `--start-page N` skips everything before page N entirely (not just a
|
|
relabelling — pages before it are never sent).
|
|
- `--no-state` ignores `state.json` and falls back to plain "file exists =
|
|
done" behaviour.
|
|
- `--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`,
|
|
or `prompt_novel_translate.txt` with `--translate`).
|
|
|
|
### `state.json`
|
|
|
|
Both `novel_ocr.py` and `manga_ocr_llm.py` write a `state.json` manifest to
|
|
`--output`, recording (per page) the model, a hash of the exact prompt
|
|
used, token usage, and a timestamp. This is what makes `--redo`/`--pages`
|
|
targeted re-runs possible, and it's also how the script notices (and warns
|
|
about, non-fatally) pages that were transcribed/translated under a prompt
|
|
or glossary that's since changed — it won't re-OCR them on its own, but
|
|
tells you to consider `--redo` for them.
|
|
|
|
## `manga_ocr_llm.py` (recommended for manga)
|
|
|
|
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`/`--pages`/`--redo`/
|
|
`--start-page`/`--no-state`/`--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. (`--redo` re-runs
|
|
it in place if you'd rather overwrite than use a separate folder.)
|
|
- Everything under Glossary/Continuity context/Lookahead context below
|
|
applies identically to `novel_ocr.py --translate` — the `--glossary`/
|
|
`--context-pages`/`--target-lang` flags are the exact same flags,
|
|
implemented once in [`common.py`](common.py). `--context-pages-ahead`
|
|
is manga-only for now (see that section for why light novel prose
|
|
doesn't need it as much).
|
|
|
|
### 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, shared by
|
|
both `novel_ocr.py` and `manga_ocr_llm.py`, and 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" } }
|
|
```
|
|
|
|
## System/user prompt split (caching)
|
|
|
|
For `novel_ocr.py` and `manga_ocr_llm.py`, every request is sent as a
|
|
`system` message (the format instructions, and, with `--translate`, the
|
|
glossary — identical on every page of a run) plus a `user` message (the
|
|
page image, and, with `--context-pages`/`--context-pages-ahead`, that
|
|
page's sliding-window context). Most OpenAI-compatible providers cache a
|
|
repeated `system` prefix, so a 300-page book isn't re-billed/re-processed
|
|
for the same instructions on every single page — only the truly per-page
|
|
part (image + context) is new each time. This is implemented once in
|
|
`common.py`'s `ocr_image()`/`build_system_prompt()`/`build_context_block()`
|
|
and used identically by both scripts.
|
|
|
|
## 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:
|
|
- **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.
|