Files
pf-ocr/ocr/google_vision_ocr.py
T
2026-09-01 00:30:34 +03:00

134 lines
5.0 KiB
Python

#!/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()