init
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# OCR module
|
||||
|
||||
Batch-transcribes scanned light novel pages (vertical Japanese text) into
|
||||
per-page `.txt` files. This is step 1 of the pipeline — step 2 is the
|
||||
[`epub_builder`](../epub_builder/README.md) module, which turns these `.txt`
|
||||
files into a finished `.epub`.
|
||||
|
||||
## Choosing a backend
|
||||
|
||||
| Script | Backend | Setup needed | Notes |
|
||||
|---|---|---|---|
|
||||
| `openrouter_ocr.py` | Multimodal LLM via any OpenAI-compatible API | API key + base URL | **Recommended.** Reads a whole page at once, no column segmentation needed. |
|
||||
| `gemini_direct_ocr.py` | Gemini API directly | Google AI Studio API key | Same idea as above, direct instead of via a proxy. |
|
||||
| `google_vision_ocr.py` | Google Cloud Vision (classic OCR) | Google Cloud project + billing enabled | No LLM context understanding, but solid on clean scans. Free tier covers a typical light novel volume, but Google still requires a billing account to be linked. |
|
||||
| `local_mangaocr_ocr.py` | [manga-ocr](https://github.com/kha-white/manga-ocr), fully offline | None — no account, no API key | Slower to set up quality-wise: manga-ocr expects short text blocks, so this script auto-segments each page into vertical columns before OCR-ing each one. |
|
||||
|
||||
If you have API access to a multimodal model (Gemini, GPT-4V-class models,
|
||||
etc.) through any provider, `openrouter_ocr.py` is the easiest and generally
|
||||
gives the best results with the least fuss.
|
||||
|
||||
## Setup for `openrouter_ocr.py` (recommended)
|
||||
|
||||
```bash
|
||||
pip install openai pillow natsort tqdm
|
||||
```
|
||||
|
||||
1. Copy `config.example.json` to `config.json` and fill in your `api_key`,
|
||||
`base_url` (your provider's OpenAI-compatible endpoint, usually ending in
|
||||
`/v1`), and `model` identifier.
|
||||
2. Optionally edit `prompt.txt` — it's plain English text, no need to touch
|
||||
any code to tweak the instructions given to the model.
|
||||
|
||||
```bash
|
||||
python openrouter_ocr.py --input ./pages --output ./out
|
||||
```
|
||||
|
||||
- `--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.
|
||||
- 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 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".
|
||||
- `--sleep N` adds a delay (seconds) between requests if you're hitting
|
||||
rate limits.
|
||||
|
||||
## Other backends
|
||||
|
||||
`gemini_direct_ocr.py`, `google_vision_ocr.py`, and `local_mangaocr_ocr.py`
|
||||
follow the same `--input`/`--output` convention and produce the same
|
||||
`pages_txt/*.txt` + `combined.md` output — see each script's own docstring
|
||||
for backend-specific setup.
|
||||
|
||||
## Next step
|
||||
|
||||
Once you have a `pages_txt/` folder full of `.txt` files, manually sort the
|
||||
pages into the folder structure `epub_builder` expects (see
|
||||
[`epub_builder/README.md`](../epub_builder/README.md)), then run the epub
|
||||
builder.
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
OCR module: batch-transcribes scanned light novel pages (vertical Japanese
|
||||
text) into per-page .txt files, ready for the epub_builder module.
|
||||
|
||||
Several interchangeable backends are provided as standalone scripts:
|
||||
|
||||
- openrouter_ocr.py Recommended. Any OpenAI-compatible API (OpenRouter,
|
||||
a self-hosted proxy, etc.) with a multimodal model.
|
||||
- gemini_direct_ocr.py Same idea, but calling the Gemini API directly.
|
||||
- google_vision_ocr.py Classic OCR via Google Cloud Vision (no LLM).
|
||||
- local_mangaocr_ocr.py Fully offline, no cloud account, via manga-ocr.
|
||||
|
||||
Each script is self-contained and runnable directly, e.g.:
|
||||
python -m ocr.openrouter_ocr --input ./pages --output ./out
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"api_key": "PUT_YOUR_API_KEY_HERE",
|
||||
"base_url": "https://your-provider.example.com/v1",
|
||||
"model": "google/gemini-3.7-flash"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via the
|
||||
Gemini API directly (not through a proxy).
|
||||
|
||||
Usage:
|
||||
export GEMINI_API_KEY="your_key"
|
||||
python gemini_direct_ocr.py --input ./pages --output ./out
|
||||
|
||||
Requirements:
|
||||
pip install google-genai pillow natsort tqdm
|
||||
|
||||
If you access Gemini (or another model) through OpenRouter or a similar
|
||||
OpenAI-compatible proxy instead of a direct Google API key, use
|
||||
openrouter_ocr.py instead — it's the recommended entry point for this
|
||||
project and shares the same prompt.txt / config.json workflow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from natsort import natsorted
|
||||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
||||
|
||||
PROMPT = """\
|
||||
This is a scanned page from a Japanese light novel, printed in vertical text (縦書き).
|
||||
Transcribe ALL Japanese text on the page, preserving correct reading order
|
||||
(columns read top-to-bottom, then right-to-left).
|
||||
|
||||
Rules:
|
||||
- Do not translate or summarize — output only the transcribed Japanese text.
|
||||
- No line numbers, no commentary, no explanations of your own.
|
||||
- Furigana may be omitted (only the base kanji/kana text is needed).
|
||||
- If the page has no text at all (illustration-only, blank, cover/technical
|
||||
page), output exactly: [NO_TEXT]
|
||||
- Preserve paragraph breaks where the layout clearly shows them.
|
||||
"""
|
||||
|
||||
|
||||
def ocr_image(client: genai.Client, model: str, path: Path, retries: int = 3) -> str:
|
||||
img = Image.open(path)
|
||||
# Downscale very large scans — speeds up and cheapens the request without
|
||||
# a noticeable loss of OCR quality.
|
||||
max_dim = 2200
|
||||
if max(img.size) > max_dim:
|
||||
ratio = max_dim / max(img.size)
|
||||
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
|
||||
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=[PROMPT, img],
|
||||
config=types.GenerateContentConfig(temperature=0),
|
||||
)
|
||||
text = (response.text or "").strip()
|
||||
return text
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = e
|
||||
time.sleep(3 * (attempt + 1))
|
||||
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via the Gemini API")
|
||||
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
|
||||
parser.add_argument("--output", required=True, help="Folder for the OCR results")
|
||||
parser.add_argument("--model", default="gemini-3.7-flash", help="Gemini model name")
|
||||
parser.add_argument(
|
||||
"--api-key", default=None,
|
||||
help="API key (falls back to the GEMINI_API_KEY environment variable)"
|
||||
)
|
||||
parser.add_argument("--start-page", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--sleep", type=float, default=0.0,
|
||||
help="Delay in seconds between requests (useful if you're hitting rate limits)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
print("No API key found. Pass --api-key or set GEMINI_API_KEY.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_dir = Path(args.input)
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
pages_dir = output_dir / "pages_txt"
|
||||
pages_dir.mkdir(exist_ok=True)
|
||||
|
||||
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
|
||||
images = natsorted(images, key=lambda p: p.name)
|
||||
|
||||
if not images:
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
|
||||
with open(combined_path, "w", encoding="utf-8") as combined_f:
|
||||
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
|
||||
txt_out = pages_dir / f"{img_path.stem}.txt"
|
||||
|
||||
if txt_out.exists():
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
text = ocr_image(client, args.model, img_path)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
# Do NOT write a file to disk on failure — see openrouter_ocr.py
|
||||
# for the reasoning.
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
|
||||
combined_f.write(text)
|
||||
|
||||
print(f"\nDone. Combined file: {combined_path}")
|
||||
print(f"Per-page files: {pages_dir}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
print(f" - {name}")
|
||||
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via
|
||||
Google Cloud Vision.
|
||||
|
||||
Usage:
|
||||
python google_vision_ocr.py --input ./pages --output ./out --credentials ./key.json
|
||||
|
||||
Requirements:
|
||||
pip install google-cloud-vision natsort tqdm
|
||||
|
||||
Google Cloud setup (one-time, ~5-10 minutes):
|
||||
1. Go to https://console.cloud.google.com/
|
||||
2. Create a project (or use an existing one)
|
||||
3. Search for "Vision API" -> Enable
|
||||
4. Go to "APIs & Services" -> "Credentials" -> "Create Credentials" -> "Service account"
|
||||
5. Create the service account (role can be left unset, or "Editor")
|
||||
6. Open the account -> Keys -> Add Key -> JSON -> downloads key.json
|
||||
7. Point --credentials at that file
|
||||
|
||||
Note: as of writing, Google requires a billing account to be enabled on the
|
||||
project before the Vision API will respond, even though usage stays within
|
||||
the free tier (1000 requests/month covers ~600 pages comfortably). No charge
|
||||
should occur unless you exceed that quota.
|
||||
|
||||
This is a classic OCR engine (not an LLM) — generally solid for image
|
||||
quality, but it doesn't understand context the way a multimodal model does.
|
||||
For light novel pages with dense vertical prose, openrouter_ocr.py /
|
||||
gemini_direct_ocr.py usually give better results with less setup friction.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from google.cloud import vision
|
||||
from natsort import natsorted
|
||||
from tqdm import tqdm
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
||||
|
||||
|
||||
def ocr_image(client: vision.ImageAnnotatorClient, path: Path, retries: int = 3) -> str:
|
||||
"""OCRs a single scan, returning text in reading order."""
|
||||
with io.open(path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
image = vision.Image(content=content)
|
||||
# The "ja" language hint helps the model handle vertical Japanese more accurately
|
||||
image_context = vision.ImageContext(language_hints=["ja"])
|
||||
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = client.document_text_detection(
|
||||
image=image, image_context=image_context
|
||||
)
|
||||
if response.error.message:
|
||||
raise RuntimeError(response.error.message)
|
||||
return response.full_text_annotation.text
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = e
|
||||
time.sleep(2 * (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 Google Cloud Vision")
|
||||
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
|
||||
parser.add_argument("--output", required=True, help="Folder for the OCR results")
|
||||
parser.add_argument("--credentials", required=True, help="Path to the service-account key.json")
|
||||
parser.add_argument(
|
||||
"--start-page", type=int, default=1, help="Page number to start the header numbering from"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = args.credentials
|
||||
|
||||
input_dir = Path(args.input)
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
pages_dir = output_dir / "pages_txt"
|
||||
pages_dir.mkdir(exist_ok=True)
|
||||
|
||||
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
|
||||
images = natsorted(images, key=lambda p: p.name)
|
||||
|
||||
if not images:
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
|
||||
client = vision.ImageAnnotatorClient()
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
|
||||
with open(combined_path, "w", encoding="utf-8") as combined_f:
|
||||
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
|
||||
txt_out = pages_dir / f"{img_path.stem}.txt"
|
||||
|
||||
# Skip pages already OCR'd — handy if a previous run was interrupted
|
||||
if txt_out.exists():
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
text = ocr_image(client, img_path)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
# Do NOT write a file to disk on failure — otherwise the next
|
||||
# run would see the file exists and skip retrying it.
|
||||
|
||||
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
|
||||
combined_f.write(text)
|
||||
|
||||
print(f"\nDone. Combined file: {combined_path}")
|
||||
print(f"Per-page files: {pages_dir}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
print(f" - {name}")
|
||||
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fully local, offline batch-OCR for scanned pages of a Japanese light novel
|
||||
(vertical text) via manga-ocr — no cloud account or credit card required.
|
||||
|
||||
Usage:
|
||||
python local_mangaocr_ocr.py --input ./pages --output ./out
|
||||
|
||||
Requirements:
|
||||
pip install manga-ocr opencv-python pillow natsort tqdm
|
||||
|
||||
The first run downloads manga-ocr's model weights (~400 MB) from
|
||||
HuggingFace; after that everything runs offline. Without a GPU it runs on
|
||||
CPU, just slower (roughly 1-3 sec per column).
|
||||
|
||||
How it works:
|
||||
1. Each page is cut into vertical text columns (by detecting whitespace
|
||||
gaps between columns — typical light novel layout).
|
||||
2. Columns are sorted right-to-left (the reading order for vertical
|
||||
Japanese text).
|
||||
3. Each column is OCR'd separately via manga-ocr.
|
||||
4. Results are joined back into per-page text.
|
||||
|
||||
manga-ocr was trained mainly on manga speech bubbles (short text blocks),
|
||||
not dense full-page prose, so column segmentation matters a lot here for
|
||||
quality. If segmentation performs poorly on your scans (e.g. unusual
|
||||
layout), pass --whole-page to feed the model the full page without cutting
|
||||
it into columns (simpler, but usually lower quality on dense prose).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from natsort import natsorted
|
||||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
||||
|
||||
|
||||
def find_columns(img_gray: np.ndarray, min_col_width: int = 12, gap_threshold: int = 4):
|
||||
"""Finds x-ranges of vertical text columns via a pixel-density projection.
|
||||
|
||||
Returns a list of (x_start, x_end), sorted RIGHT-TO-LEFT (the reading
|
||||
order for vertical Japanese text).
|
||||
"""
|
||||
# Binarize: text (dark) -> white, background -> black
|
||||
_, binary = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
|
||||
# Dilate vertically a bit to merge characters within a column into one solid strip
|
||||
kernel = np.ones((25, 1), np.uint8)
|
||||
dilated = cv2.dilate(binary, kernel, iterations=1)
|
||||
|
||||
col_sums = dilated.sum(axis=0) # text density per pixel column
|
||||
has_text = col_sums > 0
|
||||
|
||||
columns = []
|
||||
x = 0
|
||||
width = len(has_text)
|
||||
while x < width:
|
||||
if has_text[x]:
|
||||
start = x
|
||||
while x < width and (has_text[x] or _gap_too_small(has_text, x, gap_threshold)):
|
||||
x += 1
|
||||
end = x
|
||||
if end - start >= min_col_width:
|
||||
columns.append((start, end))
|
||||
else:
|
||||
x += 1
|
||||
|
||||
columns.sort(key=lambda c: c[0], reverse=True) # right-to-left
|
||||
return columns
|
||||
|
||||
|
||||
def _gap_too_small(has_text: np.ndarray, x: int, gap_threshold: int) -> bool:
|
||||
"""Checks whether a text-free gap is shorter than gap_threshold (to avoid splitting a column needlessly)."""
|
||||
if has_text[x]:
|
||||
return False
|
||||
end = x
|
||||
while end < len(has_text) and not has_text[end]:
|
||||
end += 1
|
||||
return (end - x) < gap_threshold
|
||||
|
||||
|
||||
def ocr_page(mocr, pil_img: Image.Image, whole_page: bool) -> str:
|
||||
if whole_page:
|
||||
return mocr(pil_img)
|
||||
|
||||
img_np = np.array(pil_img.convert("L"))
|
||||
columns = find_columns(img_np)
|
||||
|
||||
if not columns:
|
||||
# No columns detected (e.g. an illustration-only page) — fall back to the whole page
|
||||
return mocr(pil_img)
|
||||
|
||||
texts = []
|
||||
for x_start, x_end in columns:
|
||||
pad = 4
|
||||
crop = pil_img.crop((max(0, x_start - pad), 0, min(pil_img.width, x_end + pad), pil_img.height))
|
||||
text = mocr(crop)
|
||||
if text.strip():
|
||||
texts.append(text.strip())
|
||||
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Local batch-OCR for a light novel via manga-ocr")
|
||||
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
|
||||
parser.add_argument("--output", required=True, help="Folder for the OCR results")
|
||||
parser.add_argument(
|
||||
"--whole-page", action="store_true",
|
||||
help="Skip column segmentation, feed the whole page to the model at once"
|
||||
)
|
||||
parser.add_argument("--start-page", type=int, default=1)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_dir = Path(args.input)
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
pages_dir = output_dir / "pages_txt"
|
||||
pages_dir.mkdir(exist_ok=True)
|
||||
|
||||
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
|
||||
images = natsorted(images, key=lambda p: p.name)
|
||||
|
||||
if not images:
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
print("Loading the manga-ocr model (downloads weights on first run, ~400 MB)...")
|
||||
|
||||
from manga_ocr import MangaOcr
|
||||
mocr = MangaOcr()
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
|
||||
with open(combined_path, "w", encoding="utf-8") as combined_f:
|
||||
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
|
||||
txt_out = pages_dir / f"{img_path.stem}.txt"
|
||||
|
||||
if txt_out.exists():
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
pil_img = Image.open(img_path)
|
||||
text = ocr_page(mocr, pil_img, args.whole_page)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
# Do NOT write a file to disk on failure — otherwise the next
|
||||
# run would see the file exists and skip retrying it.
|
||||
|
||||
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
|
||||
combined_f.write(text)
|
||||
|
||||
print(f"\nDone. Combined file: {combined_path}")
|
||||
print(f"Per-page files: {pages_dir}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
print(f" - {name}")
|
||||
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch-OCR for scanned pages of a Japanese light novel (vertical text) via any
|
||||
OpenAI-compatible API (OpenRouter, a self-hosted proxy, etc.).
|
||||
|
||||
This is the recommended OCR backend for this project: it lets a multimodal
|
||||
LLM "read" a page image directly, including vertical Japanese text, without
|
||||
any column-segmentation preprocessing.
|
||||
|
||||
Usage (after filling in config.json):
|
||||
python openrouter_ocr.py --input ./pages --output ./out
|
||||
|
||||
Requirements:
|
||||
pip install openai pillow natsort tqdm
|
||||
|
||||
One-time setup:
|
||||
1. Copy config.example.json -> config.json
|
||||
2. Fill in your api_key, base_url and model
|
||||
3. Optionally edit prompt.txt to fit your book / house style
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from natsort import natsorted
|
||||
from openai import OpenAI
|
||||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict:
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Failed to parse {config_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_prompt(prompt_path: Path) -> str:
|
||||
if not prompt_path.exists():
|
||||
print(f"Prompt file not found: {prompt_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return prompt_path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def image_to_data_url(path: Path, max_dim: int = 2200) -> str:
|
||||
"""Downscale (if needed) and encode an image as a base64 data URL."""
|
||||
img = Image.open(path)
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
if max(img.size) > max_dim:
|
||||
ratio = max_dim / max(img.size)
|
||||
img = img.resize((int(img.width * ratio), int(img.height * ratio)))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def ocr_image(client: OpenAI, model: str, prompt: str, path: Path, retries: int = 3) -> str:
|
||||
data_url = image_to_data_url(path)
|
||||
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
temperature=0,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
text = (response.choices[0].message.content or "").strip()
|
||||
return text
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = e
|
||||
time.sleep(3 * (attempt + 1))
|
||||
raise RuntimeError(f"Failed to OCR {path.name} after {retries} attempts: {last_err}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Batch-OCR a light novel via an OpenAI-compatible API")
|
||||
parser.add_argument("--input", required=True, help="Folder with scanned page images (jpg/png)")
|
||||
parser.add_argument("--output", required=True, help="Folder for the OCR results")
|
||||
parser.add_argument(
|
||||
"--config", default=str(SCRIPT_DIR / "config.json"),
|
||||
help="Path to config.json with api_key/base_url/model (defaults to config.json next to this script)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt-file", default=str(SCRIPT_DIR / "prompt.txt"),
|
||||
help="Path to the prompt file (defaults to prompt.txt next to this script)"
|
||||
)
|
||||
parser.add_argument("--base-url", default=None, help="Override base_url from config.json")
|
||||
parser.add_argument("--model", default=None, help="Override model from config.json")
|
||||
parser.add_argument("--api-key", default=None, help="Override api_key from config.json")
|
||||
parser.add_argument("--start-page", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--sleep", type=float, default=0.0,
|
||||
help="Delay in seconds between requests (useful if you're hitting rate limits)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(Path(args.config))
|
||||
|
||||
api_key = args.api_key or config.get("api_key") or os.environ.get("API_KEY")
|
||||
base_url = args.base_url or config.get("base_url")
|
||||
model = args.model or config.get("model")
|
||||
|
||||
missing = [name for name, val in [("api_key", api_key), ("base_url", base_url), ("model", model)] if not val]
|
||||
if missing:
|
||||
print(
|
||||
f"Missing settings: {', '.join(missing)}. "
|
||||
f"Fill them in {args.config} (see config.example.json) or pass --api-key/--base-url/--model.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
prompt = load_prompt(Path(args.prompt_file))
|
||||
|
||||
input_dir = Path(args.input)
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
pages_dir = output_dir / "pages_txt"
|
||||
pages_dir.mkdir(exist_ok=True)
|
||||
|
||||
images = [p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTS]
|
||||
images = natsorted(images, key=lambda p: p.name)
|
||||
|
||||
if not images:
|
||||
print(f"No images found in {input_dir}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Pages found: {len(images)}")
|
||||
|
||||
client = OpenAI(base_url=base_url, api_key=api_key)
|
||||
|
||||
combined_path = output_dir / "combined.md"
|
||||
failed = []
|
||||
|
||||
with open(combined_path, "w", encoding="utf-8") as combined_f:
|
||||
for idx, img_path in enumerate(tqdm(images, desc="OCR"), start=args.start_page):
|
||||
txt_out = pages_dir / f"{img_path.stem}.txt"
|
||||
|
||||
if txt_out.exists():
|
||||
text = txt_out.read_text(encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
text = ocr_image(client, model, prompt, img_path)
|
||||
txt_out.write_text(text, encoding="utf-8")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"\nError on {img_path.name}: {e}", file=sys.stderr)
|
||||
failed.append(img_path.name)
|
||||
text = ""
|
||||
# Do NOT write a file to disk on failure — otherwise the next
|
||||
# run would see the file exists and assume the page is already
|
||||
# done, silently skipping a retry forever.
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
combined_f.write(f"\n\n<!-- page {idx}: {img_path.name} -->\n\n")
|
||||
combined_f.write(text)
|
||||
|
||||
print(f"\nDone. Combined file: {combined_path}")
|
||||
print(f"Per-page files: {pages_dir}")
|
||||
if failed:
|
||||
print(f"\nFailed to OCR {len(failed)} page(s):")
|
||||
for name in failed:
|
||||
print(f" - {name}")
|
||||
print("Re-run the script with the same --output folder — already-done pages will not be redone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
This is a scanned page from a Japanese light novel, printed in vertical text (縦書き). Transcribe ALL Japanese text on the page, preserving correct reading order (columns read top-to-bottom, then right-to-left).
|
||||
|
||||
Rules:
|
||||
- Mark furigana using Aozora Bunko notation: base_text《reading》, e.g. 本文《ほんぶん》.
|
||||
- If the furigana applies to only part of a word, or the start of the kanji run is ambiguous, mark the exact start of the base text with a leading |, e.g. |中《ちゅう》.
|
||||
- If a compound word has furigana split across its parts (as printed), keep them as separate 《》 groups in reading order, e.g. |事務《じむ》|所《しょ》.
|
||||
- Include header/margin/footer text exactly as printed (page number, chapter title, in-story date/time stamp, file code). 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 at the very start of your output, do not repeat it twice.
|
||||
- Do not translate, summarize, or explain — output only the transcribed Japanese text.
|
||||
- No line numbers, no commentary, no markdown formatting.
|
||||
- Each paragraph goes on its own line. 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