update
This commit is contained in:
+76
-17
@@ -12,7 +12,16 @@ and cross-page continuity context:
|
||||
| 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 |
|
||||
| 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
|
||||
@@ -25,9 +34,12 @@ targets prose light novels and doesn't lay out manga pages.
|
||||
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.
|
||||
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
|
||||
@@ -39,32 +51,57 @@ pip install openai pillow natsort tqdm
|
||||
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.
|
||||
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) plus a `combined.md` preview of the whole run.
|
||||
- `--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, etc.) does **not**
|
||||
get an empty file written for it, specifically so the next run retries it
|
||||
instead of silently treating it as done.
|
||||
- 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`).
|
||||
- `--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)
|
||||
|
||||
@@ -100,7 +137,8 @@ Output format is a numbered list per page:
|
||||
|
||||
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`/
|
||||
THOUGHT, NARRATION, or SFX. Same `--input`/`--output`/`--pages`/`--redo`/
|
||||
`--start-page`/`--no-state`/`--sleep`/`--config`/
|
||||
`--prompt-file` conventions as `novel_ocr.py`.
|
||||
|
||||
### Translation (`--translate`)
|
||||
@@ -114,16 +152,24 @@ THOUGHT, NARRATION, or SFX. Same `--input`/`--output`/`--sleep`/`--config`/
|
||||
- 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.
|
||||
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, 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.:
|
||||
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
|
||||
@@ -257,6 +303,19 @@ with the OpenAI-compatible API the other scripts use):
|
||||
{ "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
|
||||
|
||||
+15
-6
@@ -1,27 +1,36 @@
|
||||
"""
|
||||
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).
|
||||
since their LAYOUT needs genuinely different logic (dense running prose
|
||||
vs. scattered, typed speech bubbles) — but both support --translate with
|
||||
a glossary and cross-page continuity context, sharing that (and all the
|
||||
config/state/logging plumbing) via common.py rather than duplicating it.
|
||||
|
||||
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.
|
||||
with a multimodal model. Transcribe or
|
||||
--translate. Recommended.
|
||||
- google_vision_ocr.py Classic OCR via Google Cloud Vision (no LLM,
|
||||
--mode novel).
|
||||
|
||||
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.
|
||||
--translate, with --glossary, --context-pages,
|
||||
and --context-pages-ahead 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).
|
||||
|
||||
common.py: shared infrastructure used by novel_ocr.py and manga_ocr_llm.py
|
||||
— config/state/logging plumbing, the OpenAI-compatible request helper
|
||||
(with a system/user prompt split for provider-side caching), and the
|
||||
--translate helpers (glossary, sliding-window context). Not meant to
|
||||
be run directly.
|
||||
|
||||
Each script is self-contained and runnable directly, e.g.:
|
||||
python -m ocr.novel_ocr --input ./pages --output ./out
|
||||
python -m ocr.manga_ocr_llm --input ./pages --output ./out --translate
|
||||
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Shared infrastructure for the OCR scripts (novel_ocr.py, manga_ocr_llm.py).
|
||||
|
||||
Everything here is genuinely identical between the two "page layout"
|
||||
scripts — config/state/logging plumbing, the OpenAI-compatible request
|
||||
helper, and the translate-only helpers (glossary, sliding-window context).
|
||||
What's deliberately NOT here is anything that depends on page layout
|
||||
(reading order, output format, the prompt files themselves) — that stays
|
||||
in each script, since novel and manga pages need genuinely different
|
||||
logic there.
|
||||
|
||||
system/user prompt split (for provider-side prompt caching):
|
||||
Everything that stays IDENTICAL across every page of a run — the
|
||||
format/transcription instructions, and (for --translate) the glossary
|
||||
— is sent as the `system` message. Everything that changes per page —
|
||||
the image, and any sliding-window context — goes in `user`. Most
|
||||
OpenAI-compatible providers cache a repeated system prefix, so with a
|
||||
long book this avoids re-billing/re-processing the same instructions
|
||||
on every single page. Per-page context (previous/upcoming pages) is
|
||||
NOT cached-friendly by nature (it changes every page), so it belongs
|
||||
in `user`, never appended to the cacheable `system` text.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from openai import OpenAI
|
||||
from PIL import Image
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
||||
STATE_FILE = "state.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_config_section(config_path: Path, section: str) -> dict:
|
||||
"""Loads the shared config.json and returns one top-level section
|
||||
(e.g. "ocr"). Falls back to treating the whole file as that section if
|
||||
the key is missing, 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(section, data) if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load_prompt_file(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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page selection (--pages "3,7-12,20", --start-page)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_page_selection(spec: str, images: list) -> set | None:
|
||||
"""Parses a --pages selection like "3,7-12,20" into a set of page stems.
|
||||
|
||||
Numbers refer to the 1-based position in the naturally-sorted page list,
|
||||
so "3" is the third page, not a file literally named "3". Returns None
|
||||
when the spec is empty/absent, meaning "no explicit selection".
|
||||
"""
|
||||
if not spec:
|
||||
return None
|
||||
selected = set()
|
||||
for part in spec.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
m = re.fullmatch(r"(\d+)\s*(?:-\s*(\d+))?", part)
|
||||
if not m:
|
||||
raise ValueError(f"Invalid --pages entry: {part!r} (expected N or N-M)")
|
||||
start = int(m.group(1))
|
||||
end = int(m.group(2)) if m.group(2) else start
|
||||
if start < 1 or end < start:
|
||||
raise ValueError(f"Invalid --pages range: {part!r}")
|
||||
for n in range(start, end + 1):
|
||||
if n > len(images):
|
||||
raise ValueError(f"--pages refers to page {n}, but only {len(images)} page(s) were found")
|
||||
selected.add(images[n - 1].stem)
|
||||
return selected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# state.json manifest (resume/redo tracking, prompt-change detection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def prompt_fingerprint(prompt: str) -> str:
|
||||
"""Short stable hash of the prompt text, used to detect prompt edits."""
|
||||
import hashlib
|
||||
return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def load_state(output_dir: Path) -> dict:
|
||||
"""Loads the pages manifest, mapping page stem -> what produced it.
|
||||
|
||||
Returns {} if the manifest is missing or unreadable; callers must treat
|
||||
a page with no entry as "unverified" rather than "stale".
|
||||
"""
|
||||
path = output_dir / STATE_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e: # noqa: BLE001
|
||||
print(f"Warning: could not read {path} ({e}); existing pages are reused unverified.", file=sys.stderr)
|
||||
return {}
|
||||
return data.get("pages", {}) if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def save_state(output_dir: Path, pages: dict, meta: dict) -> None:
|
||||
"""Atomically rewrites the pages manifest (temp file + replace)."""
|
||||
path = output_dir / STATE_FILE
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"meta": meta, "pages": pages}, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def warn_if_prompt_changed(state: dict, images: list, prompt_hash: str, prompt_file_name: str) -> None:
|
||||
"""Warns (does not fail) if some already-transcribed pages were done
|
||||
with a prompt whose hash no longer matches the current one."""
|
||||
if not state:
|
||||
return
|
||||
stale = [
|
||||
p.stem for p in images
|
||||
if p.stem in state and state[p.stem].get("prompt_hash") not in (None, prompt_hash)
|
||||
]
|
||||
if stale:
|
||||
print(
|
||||
f"Note: {len(stale)} page(s) were transcribed with a different prompt "
|
||||
f"({prompt_file_name} changed). Use --redo to re-OCR them.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/response logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request params (CLI flag > config.json > default)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_request_kwargs(config: dict, args) -> 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image encoding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM OCR call, with system/user split for prompt caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def usage_dict(response) -> dict | None:
|
||||
"""Extracts token usage from a response, tolerating providers that omit it."""
|
||||
usage = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return None
|
||||
try:
|
||||
data = usage.model_dump() if hasattr(usage, "model_dump") else dict(usage)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return {k: v for k, v in data.items() if v is not None} or None
|
||||
|
||||
|
||||
def ocr_image(
|
||||
client: OpenAI, model: str, system_prompt: str, path: Path,
|
||||
request_kwargs: dict, extra_body: dict,
|
||||
user_text: str = None, retries: int = 3, log_entry: dict | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""OCRs (or translates) one page image.
|
||||
|
||||
system_prompt: the part of the prompt that's IDENTICAL across every
|
||||
page of this run (format instructions, glossary) — sent as the
|
||||
`system` message so OpenAI-compatible providers can cache it
|
||||
instead of reprocessing it on every page.
|
||||
user_text: optional per-page text that varies from page to page
|
||||
(sliding-window context) — sent alongside the image in `user`.
|
||||
Never put per-page content in system_prompt, or every page
|
||||
invalidates the cache instead of hitting it.
|
||||
|
||||
Returns (text, meta) where meta records what the provider reported
|
||||
about the accepted response (finish_reason, usage) so the caller can
|
||||
persist it (e.g. in the pages manifest).
|
||||
"""
|
||||
data_url = image_to_data_url(path)
|
||||
user_content = [{"type": "image_url", "image_url": {"url": data_url}}]
|
||||
if user_text:
|
||||
user_content.insert(0, {"type": "text", "text": user_text})
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
if log_entry is not None:
|
||||
logged_user_content = [
|
||||
{"type": "text", "text": user_text} if user_text else None,
|
||||
{"type": "image_url", "image_url": {"url": f"<omitted: {path.name}, {path.stat().st_size} bytes>"}},
|
||||
]
|
||||
log_entry["request"] = {
|
||||
"model": model,
|
||||
**request_kwargs,
|
||||
"extra_body": extra_body or None,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": [c for c in logged_user_content if c]},
|
||||
],
|
||||
}
|
||||
log_entry["attempts"] = []
|
||||
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
extra_body=extra_body or None,
|
||||
**request_kwargs,
|
||||
)
|
||||
choice = response.choices[0]
|
||||
text = (choice.message.content or "").strip()
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
|
||||
# A truncated or empty response must not be accepted as a
|
||||
# successful transcription — this file would otherwise be
|
||||
# written to disk and silently skipped on every future retry.
|
||||
if finish_reason == "length":
|
||||
raise RuntimeError(f"response truncated (finish_reason=length, {len(text)} chars)")
|
||||
if not text:
|
||||
raise RuntimeError(f"empty response (finish_reason={finish_reason})")
|
||||
|
||||
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, {"finish_reason": finish_reason, "usage": usage_dict(response)}
|
||||
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}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translation helpers: glossary + sliding-window context (--translate only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_glossary(script_dir: Path, explicit_path: str) -> Path | None:
|
||||
"""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_system_prompt(base_prompt: str, target_language: str = None, glossary: str = None) -> str:
|
||||
"""Builds the cacheable `system` prompt: format instructions, with the
|
||||
target language substituted in and the glossary appended.
|
||||
|
||||
Deliberately excludes per-page context (see build_context_block) —
|
||||
that changes every page and must go in `user`, or every page would
|
||||
invalidate the provider's cached system prefix instead of hitting it.
|
||||
"""
|
||||
prompt = base_prompt
|
||||
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 build_context_block(past_pages: list, ahead_pages: list) -> str:
|
||||
"""Builds the per-page `user` text carrying sliding-window context:
|
||||
- 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.
|
||||
Returns "" if there's nothing to add.
|
||||
|
||||
This text is per-page by nature and must never be folded into the
|
||||
cacheable system prompt (see build_system_prompt) — it changes on
|
||||
every single page, so appending it to system would defeat prompt
|
||||
caching entirely instead of the intended "cache the instructions,
|
||||
vary only the image/context" split.
|
||||
"""
|
||||
if not past_pages and not ahead_pages:
|
||||
return ""
|
||||
parts = []
|
||||
if past_pages:
|
||||
parts.append(
|
||||
"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:
|
||||
if parts:
|
||||
parts.append("\n\n---\n\n")
|
||||
parts.append(
|
||||
"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)
|
||||
+132
-230
@@ -8,10 +8,9 @@ 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.
|
||||
as a numbered, typed, reading-order list of bubbles. The --translate
|
||||
machinery (glossary, sliding-window context) is shared infrastructure from
|
||||
ocr/common.py — the same helpers novel_ocr.py uses for its own --translate.
|
||||
|
||||
Usage (after filling in config.json):
|
||||
python manga_ocr_llm.py --input ./pages --output ./out
|
||||
@@ -36,6 +35,11 @@ Usage (after filling in config.json):
|
||||
python manga_ocr_llm.py --input ./pages --output ./out \\
|
||||
--translate --target-lang Russian --context-pages-ahead 1
|
||||
|
||||
# re-OCR/re-translate just a few pages (e.g. after editing the prompt
|
||||
# or the glossary), without redoing the whole book:
|
||||
python manga_ocr_llm.py --input ./pages --output ./out \\
|
||||
--translate --pages "12,40-42" --redo
|
||||
|
||||
Requirements:
|
||||
pip install openai pillow natsort tqdm
|
||||
|
||||
@@ -46,18 +50,27 @@ One-time setup:
|
||||
3. Optionally edit prompt_manga.txt (transcription) or
|
||||
prompt_manga_translate.txt (--translate) to fit your book / house style
|
||||
|
||||
System/user prompt split: the format instructions (and, with --translate,
|
||||
the glossary) are sent as the `system` message, since they're identical on
|
||||
every page of a run — most OpenAI-compatible providers cache a repeated
|
||||
system prefix, so a long book isn't re-billed/re-processed for the same
|
||||
instructions on every single page. Only the image and any sliding-window
|
||||
context (which does vary page to page) go in `user`. See ocr/common.py's
|
||||
module docstring for more.
|
||||
|
||||
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).
|
||||
|
||||
Progress/resume state: like novel_ocr.py, this writes a state.json
|
||||
manifest to --output (model, prompt hash, usage, timestamps) so pages
|
||||
transcribed under a now-stale prompt/glossary can be spotted, and so
|
||||
--pages/--redo can target just the pages that actually need re-running.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
@@ -69,124 +82,12 @@ 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
|
||||
sys.path.insert(0, str(SCRIPT_DIR)) # so `import common` / `from local_mangaocr_ocr import ...` work when run from elsewhere
|
||||
import common
|
||||
|
||||
|
||||
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)
|
||||
IMAGE_EXTS = common.IMAGE_EXTS
|
||||
|
||||
|
||||
def get_japanese_context(
|
||||
@@ -224,8 +125,8 @@ def get_japanese_context(
|
||||
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)
|
||||
system_prompt = ahead_ocr_state["system_prompt"]
|
||||
text, _meta = common.ocr_image(client, model, system_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 ""
|
||||
@@ -236,93 +137,6 @@ def get_japanese_context(
|
||||
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)")
|
||||
@@ -381,7 +195,7 @@ def main():
|
||||
"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/"
|
||||
"Defaults to <o>/context_src/"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt-file", default=None,
|
||||
@@ -402,7 +216,23 @@ def main():
|
||||
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(
|
||||
"--start-page", type=int, default=1,
|
||||
help="1-based index of the first page to process (pages before it are skipped entirely)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pages", default=None,
|
||||
help="Only process these pages, e.g. \"3,7-12,20\" (1-based positions in the sorted page "
|
||||
"list). Combine with --redo to re-OCR/re-translate just those pages."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--redo", action="store_true",
|
||||
help="Re-OCR/re-translate pages even if output already exists (respects --pages if given)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-state", action="store_true",
|
||||
help="Ignore state.json and fall back to plain \"file exists = done\" behaviour"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep", type=float, default=0.0,
|
||||
help="Delay in seconds between requests (useful if you're hitting rate limits)"
|
||||
@@ -418,7 +248,7 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(Path(args.config))
|
||||
config = common.load_config_section(Path(args.config), "ocr")
|
||||
|
||||
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")
|
||||
@@ -433,12 +263,12 @@ def main():
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
request_kwargs, extra_body = build_request_kwargs(config, args)
|
||||
request_kwargs, extra_body = common.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_path = common.find_glossary(SCRIPT_DIR, 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}")
|
||||
@@ -447,15 +277,22 @@ def main():
|
||||
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)
|
||||
base_prompt = common.load_prompt_file(prompt_file)
|
||||
system_prompt = common.build_system_prompt(base_prompt, target_language, glossary)
|
||||
prompt_hash = common.prompt_fingerprint(system_prompt)
|
||||
|
||||
# Plain transcription system prompt for OCR'ing lookahead pages — never
|
||||
# the translate prompt (we want the raw Japanese, not a draft
|
||||
# translation — see the design note in common.build_context_block).
|
||||
ahead_system_prompt = (
|
||||
system_prompt if not args.translate
|
||||
else common.build_system_prompt(common.load_prompt_file(SCRIPT_DIR / "prompt_manga.txt"))
|
||||
)
|
||||
|
||||
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["system_prompt"] = ahead_system_prompt
|
||||
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.
|
||||
@@ -473,7 +310,33 @@ def main():
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
if args.start_page < 1:
|
||||
print("--start-page must be >= 1.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
total_found = len(images)
|
||||
|
||||
try:
|
||||
selected = common.parse_page_selection(args.pages, images) if args.pages else None
|
||||
except ValueError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --start-page skips pages entirely (matches novel_ocr.py); numbering in
|
||||
# combined.md picks back up from --start-page regardless.
|
||||
if args.start_page > 1:
|
||||
images = images[args.start_page - 1:]
|
||||
if selected is not None:
|
||||
images = [p for p in images if p.stem in selected]
|
||||
|
||||
if not images:
|
||||
print("No pages left to process after applying --start-page/--pages.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {total_found}; processing {len(images)}")
|
||||
|
||||
state = {} if args.no_state else common.load_state(output_dir)
|
||||
if state:
|
||||
common.warn_if_prompt_changed(state, images, prompt_hash, prompt_file.name)
|
||||
|
||||
log_dir = None if args.no_log else Path(args.log_dir)
|
||||
|
||||
@@ -490,16 +353,21 @@ def main():
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
redone = 0
|
||||
totals = {}
|
||||
|
||||
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"
|
||||
has_txt = txt_out.exists()
|
||||
# A page is skipped if its file exists, unless --redo was passed.
|
||||
# --no-state only disables the manifest, not this rule.
|
||||
needs_run = args.redo or not has_txt
|
||||
|
||||
if txt_out.exists():
|
||||
if not needs_run:
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
page_prompt = prompt
|
||||
context_used_past = []
|
||||
context_used_ahead = []
|
||||
if use_context and recent_context:
|
||||
@@ -509,8 +377,7 @@ def main():
|
||||
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)
|
||||
user_text = common.build_context_block(context_used_past, context_used_ahead) or None
|
||||
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
@@ -525,10 +392,27 @@ def main():
|
||||
"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)
|
||||
text, meta = common.ocr_image(
|
||||
client, model, system_prompt, img_path, request_kwargs, extra_body,
|
||||
user_text=user_text, log_entry=log_entry,
|
||||
)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
state[img_path.stem] = {
|
||||
"model": model,
|
||||
"prompt_hash": prompt_hash,
|
||||
"finish_reason": meta.get("finish_reason"),
|
||||
"usage": meta.get("usage"),
|
||||
"chars": len(text),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
if has_txt:
|
||||
redone += 1
|
||||
usage = meta.get("usage") or {}
|
||||
for key, val in usage.items():
|
||||
if isinstance(val, int):
|
||||
totals[key] = totals.get(key, 0) + val
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
tqdm.write(f"Error on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
if log_entry is not None:
|
||||
@@ -537,7 +421,7 @@ def main():
|
||||
# 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)
|
||||
common.write_log(log_dir, img_path.stem, log_entry)
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
@@ -550,12 +434,30 @@ def main():
|
||||
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
|
||||
combined_f.write(text)
|
||||
|
||||
if not args.no_state:
|
||||
common.save_state(output_dir, state, {
|
||||
"backend": "manga_ocr_llm",
|
||||
"model": model,
|
||||
"translate": args.translate,
|
||||
"target_lang": args.target_lang if args.translate else None,
|
||||
"prompt_file": prompt_file.name,
|
||||
"prompt_hash": prompt_hash,
|
||||
"pages_total": total_found,
|
||||
"updated": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
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 redone:
|
||||
print(f"Re-OCR'd: {redone} page(s)")
|
||||
if totals:
|
||||
print("Tokens: " + ", ".join(f"{k}={v}" for k, v in sorted(totals.items())))
|
||||
if not args.no_state:
|
||||
print(f"Manifest: {output_dir / common.STATE_FILE}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
|
||||
+187
-163
@@ -1,22 +1,27 @@
|
||||
#!/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.
|
||||
Batch-OCR (or batch-translate) 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).
|
||||
Deliberately simple compared to manga_ocr_llm.py: light novel pages are
|
||||
dense running prose read in one linear order, so there's no bubble
|
||||
type/reading-order logic here — see manga_ocr_llm.py for that, which is a
|
||||
genuinely different job (scattered speech bubbles across panels). The
|
||||
--translate machinery (glossary, sliding-window context) is shared
|
||||
infrastructure from ocr/common.py, used the same way by both scripts.
|
||||
|
||||
Usage (after filling in config.json):
|
||||
python novel_ocr.py --input ./pages --output ./out
|
||||
|
||||
# translate instead of transcribing:
|
||||
python novel_ocr.py --input ./pages --output ./out \\
|
||||
--translate --target-lang Russian
|
||||
|
||||
Requirements:
|
||||
pip install openai pillow natsort tqdm
|
||||
|
||||
@@ -24,7 +29,16 @@ 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
|
||||
3. Optionally edit prompt_novel.txt (transcription) or
|
||||
prompt_novel_translate.txt (--translate) to fit your book / house style
|
||||
|
||||
System/user prompt split: the format instructions (and, with --translate,
|
||||
the glossary) are sent as the `system` message, since they're identical on
|
||||
every page of a run — most OpenAI-compatible providers cache a repeated
|
||||
system prefix, so a long book isn't re-billed/re-processed for the same
|
||||
instructions on every single page. Only the image (and, with --translate
|
||||
--context-pages, a bit of sliding-window context) varies per page and goes
|
||||
in `user`. See ocr/common.py's module docstring for more.
|
||||
|
||||
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
|
||||
@@ -33,163 +47,27 @@ 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"}
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # so `import common` works when run from elsewhere
|
||||
import common
|
||||
|
||||
IMAGE_EXTS = common.IMAGE_EXTS
|
||||
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 = argparse.ArgumentParser(description="Batch-OCR/translate 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(
|
||||
@@ -198,8 +76,9 @@ def main():
|
||||
"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)"
|
||||
"--prompt-file", default=None,
|
||||
help="Path to the prompt file (defaults to prompt_novel.txt, or "
|
||||
"prompt_novel_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")
|
||||
@@ -215,7 +94,23 @@ def main():
|
||||
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(
|
||||
"--start-page", type=int, default=1,
|
||||
help="1-based index of the first page to process (pages before it are skipped entirely)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pages", default=None,
|
||||
help="Only process these pages, e.g. \"3,7-12,20\" (1-based positions in the sorted page "
|
||||
"list). Combine with --redo to re-OCR just those pages."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--redo", action="store_true",
|
||||
help="Re-OCR pages even if a transcription already exists (respects --pages if given)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-state", action="store_true",
|
||||
help="Ignore state.json and fall back to plain \"file exists = done\" behaviour"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep", type=float, default=0.0,
|
||||
help="Delay in seconds between requests (useful if you're hitting rate limits)"
|
||||
@@ -229,9 +124,35 @@ def main():
|
||||
"--no-log", action="store_true",
|
||||
help="Disable request/response logging entirely"
|
||||
)
|
||||
|
||||
# --- Translation (mirrors manga_ocr_llm.py) ---
|
||||
parser.add_argument(
|
||||
"--translate", action="store_true",
|
||||
help="Translate the page instead of transcribing the Japanese "
|
||||
"(uses prompt_novel_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. Shared with manga_ocr_llm.py."
|
||||
)
|
||||
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 narration, "
|
||||
"pronouns/referents). 0 disables it. Default: 2"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(Path(args.config))
|
||||
config = common.load_config_section(Path(args.config), "ocr")
|
||||
|
||||
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")
|
||||
@@ -246,8 +167,24 @@ def main():
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
request_kwargs, extra_body = build_request_kwargs(config, args)
|
||||
prompt = load_prompt(Path(args.prompt_file))
|
||||
request_kwargs, extra_body = common.build_request_kwargs(config, args)
|
||||
|
||||
if args.translate:
|
||||
default_prompt_name = "prompt_novel_translate.txt"
|
||||
target_language = args.target_lang
|
||||
glossary_path = common.find_glossary(SCRIPT_DIR, 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_novel.txt"
|
||||
target_language = None
|
||||
glossary = None
|
||||
|
||||
prompt_file = Path(args.prompt_file) if args.prompt_file else SCRIPT_DIR / default_prompt_name
|
||||
base_prompt = common.load_prompt_file(prompt_file)
|
||||
system_prompt = common.build_system_prompt(base_prompt, target_language, glossary)
|
||||
prompt_hash = common.prompt_fingerprint(system_prompt)
|
||||
|
||||
input_dir = Path(args.input)
|
||||
output_dir = Path(args.output)
|
||||
@@ -262,34 +199,95 @@ def main():
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
if args.start_page < 1:
|
||||
print("--start-page must be >= 1.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
total_found = len(images)
|
||||
|
||||
try:
|
||||
selected = common.parse_page_selection(args.pages, images) if args.pages else None
|
||||
except ValueError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --start-page now actually skips pages rather than only relabelling them.
|
||||
if args.start_page > 1:
|
||||
images = images[args.start_page - 1:]
|
||||
if selected is not None:
|
||||
images = [p for p in images if p.stem in selected]
|
||||
|
||||
if not images:
|
||||
print("No pages left to process after applying --start-page/--pages.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {total_found}; processing {len(images)}")
|
||||
|
||||
state = {} if args.no_state else common.load_state(output_dir)
|
||||
if state:
|
||||
common.warn_if_prompt_changed(state, images, prompt_hash, prompt_file.name)
|
||||
|
||||
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
|
||||
|
||||
client = OpenAI(base_url=base_url, api_key=api_key)
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
redone = 0
|
||||
totals = {}
|
||||
|
||||
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"
|
||||
has_txt = txt_out.exists()
|
||||
# A page is skipped if its file exists, unless --redo was passed.
|
||||
# --no-state only disables the manifest, not this rule.
|
||||
needs_run = args.redo or not has_txt
|
||||
|
||||
if txt_out.exists():
|
||||
if not needs_run:
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
user_text = None
|
||||
context_used = []
|
||||
if use_context and recent_context:
|
||||
context_used = list(recent_context)
|
||||
user_text = common.build_context_block(context_used, [])
|
||||
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"backend": "novel_ocr",
|
||||
"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],
|
||||
"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)
|
||||
text, meta = common.ocr_image(
|
||||
client, model, system_prompt, img_path, request_kwargs, extra_body,
|
||||
user_text=user_text, log_entry=log_entry,
|
||||
)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
state[img_path.stem] = {
|
||||
"model": model,
|
||||
"prompt_hash": prompt_hash,
|
||||
"finish_reason": meta.get("finish_reason"),
|
||||
"usage": meta.get("usage"),
|
||||
"chars": len(text),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
if has_txt:
|
||||
redone += 1
|
||||
usage = meta.get("usage") or {}
|
||||
for key, val in usage.items():
|
||||
if isinstance(val, int):
|
||||
totals[key] = totals.get(key, 0) + val
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
tqdm.write(f"Error on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
if log_entry is not None:
|
||||
@@ -298,17 +296,43 @@ def main():
|
||||
# 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)
|
||||
common.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)
|
||||
|
||||
# Written once per run (not per page) so an interrupted run simply
|
||||
# leaves the previous manifest in place rather than a partial one.
|
||||
if not args.no_state:
|
||||
common.save_state(output_dir, state, {
|
||||
"backend": "novel_ocr",
|
||||
"model": model,
|
||||
"translate": args.translate,
|
||||
"target_lang": args.target_lang if args.translate else None,
|
||||
"prompt_file": prompt_file.name,
|
||||
"prompt_hash": prompt_hash,
|
||||
"pages_total": total_found,
|
||||
"updated": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
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 redone:
|
||||
print(f"Re-OCR'd: {redone} page(s)")
|
||||
if totals:
|
||||
print("Tokens: " + ", ".join(f"{k}={v}" for k, v in sorted(totals.items())))
|
||||
if not args.no_state:
|
||||
print(f"Manifest: {output_dir / common.STATE_FILE}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
This is a scanned page from a Japanese light novel, printed in vertical
|
||||
text (縦書き). Read the page in correct reading order (columns top-to-bottom,
|
||||
then right-to-left).
|
||||
|
||||
Instead of transcribing the Japanese, TRANSLATE all running prose on the
|
||||
page into {target_language}.
|
||||
|
||||
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.)
|
||||
and the narrator's voice as far as {target_language} allows.
|
||||
- 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.
|
||||
- Include any header/margin/footer text (page number, chapter title,
|
||||
in-story date/time stamp) translated as well, as the first line of your
|
||||
output. This novel often prints the same date/time/chapter info in two
|
||||
places on a page (e.g. top margin and a vertical strip on the side) —
|
||||
merge these into ONE single header line, do not repeat it twice.
|
||||
- Do not include the original Japanese text, romaji, or any commentary —
|
||||
output only the translation.
|
||||
- No line numbers, no markdown formatting.
|
||||
- Each paragraph goes on its own line, matching the paragraph breaks of
|
||||
the original. Do not merge multiple paragraphs into one line, and do
|
||||
not add blank lines between paragraphs.
|
||||
- If the page has no text at all (illustration-only, blank, cover), output
|
||||
exactly: [NO_TEXT]
|
||||
Reference in New Issue
Block a user