update
This commit is contained in:
+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:
|
||||
|
||||
Reference in New Issue
Block a user