Spaces:
Sleeping
Sleeping
Pointf5ive commited on
Commit ·
ad64790
1
Parent(s): be1ecc2
Speed up Smoke Signal OCR and add live progress
Browse files- smoke_signal_tab.py +238 -113
smoke_signal_tab.py
CHANGED
|
@@ -708,37 +708,23 @@ def _profile_status_html() -> str:
|
|
| 708 |
|
| 709 |
|
| 710 |
# ── Step 3: OCR ────────────────────────────────────────────────────────────────
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
debug += f"Manifest rows={len(df)}\nStatuses={df['status'].value_counts().to_dict()}\n"
|
| 717 |
-
else:
|
| 718 |
-
debug += "Manifest is EMPTY\n"
|
| 719 |
|
| 720 |
-
if df.empty:
|
| 721 |
-
return _ocr_status_html(), debug + "No sources. Complete Steps 1-2 first."
|
| 722 |
-
|
| 723 |
-
eligible = df[df["status"].isin(["profiled", "ocred", "rendered"])]
|
| 724 |
-
if eligible.empty:
|
| 725 |
-
unknown_or_excluded = df[df["rights_class"].isin(["unknown", "excluded"])] if "rights_class" in df.columns else df.iloc[0:0]
|
| 726 |
-
pending = df[df["status"] == "pending"] if "status" in df.columns else df.iloc[0:0]
|
| 727 |
-
debug += (
|
| 728 |
-
f"Eligible rows={len(eligible)}\n"
|
| 729 |
-
f"Pending rows={len(pending)}\n"
|
| 730 |
-
f"Unknown/excluded rights={len(unknown_or_excluded)}\n"
|
| 731 |
-
"Tip: In Step 1, update rights_class away from unknown/excluded, "
|
| 732 |
-
"then rerun Step 2 Profile.\n"
|
| 733 |
-
)
|
| 734 |
-
return _ocr_status_html(), debug + "No profiled PDFs. Complete Step 2 first."
|
| 735 |
|
| 736 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
|
| 738 |
-
# Try to import/load Surya (new predictor API first, then legacy API)
|
| 739 |
surya = None
|
| 740 |
surya_error = None
|
| 741 |
-
|
|
|
|
| 742 |
try:
|
| 743 |
from surya.foundation import FoundationPredictor
|
| 744 |
from surya.detection import DetectionPredictor
|
|
@@ -760,6 +746,9 @@ def run_ocr() -> tuple:
|
|
| 760 |
}
|
| 761 |
except Exception as e:
|
| 762 |
surya_error = e
|
|
|
|
|
|
|
|
|
|
| 763 |
try:
|
| 764 |
from surya.ocr import run_ocr as surya_run
|
| 765 |
from surya.model.detection.model import load_model as load_det
|
|
@@ -777,114 +766,239 @@ def run_ocr() -> tuple:
|
|
| 777 |
except Exception as legacy_error:
|
| 778 |
surya_error = f"{surya_error}; legacy={legacy_error}"
|
| 779 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
if surya:
|
| 781 |
-
|
|
|
|
|
|
|
|
|
|
| 782 |
else:
|
| 783 |
log.append(log_line(f"⚠ Surya unavailable ({surya_error}) — falling back to text extraction only"))
|
| 784 |
|
| 785 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
|
| 787 |
for _, row in eligible.iterrows():
|
| 788 |
-
book_id
|
| 789 |
profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
|
| 790 |
if not profile_path.exists():
|
| 791 |
log.append(log_line(f"✗ {book_id}: no profile"))
|
| 792 |
continue
|
| 793 |
|
| 794 |
-
|
|
|
|
|
|
|
| 795 |
ocr_pages = []
|
| 796 |
review_pages = []
|
| 797 |
quarantine_pages = []
|
| 798 |
cal = load_calibration()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
|
| 800 |
-
|
|
|
|
|
|
|
| 801 |
page_num = page_data["page_number"]
|
| 802 |
-
route
|
| 803 |
|
| 804 |
if route == "embedded_text":
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
page = doc[page_num - 1]
|
| 811 |
-
text = page.get_text("text").strip()
|
| 812 |
-
doc.close()
|
| 813 |
-
regions = [{"text": text, "confidence": 0.99, "bbox": [0,0,100,100], "word_count": len(text.split())}]
|
| 814 |
-
conf = 0.99
|
| 815 |
-
method = "embedded-text"
|
| 816 |
-
except Exception:
|
| 817 |
-
regions = []
|
| 818 |
-
conf = 0.0
|
| 819 |
-
method = "error"
|
| 820 |
-
elif surya:
|
| 821 |
try:
|
| 822 |
-
from PIL import Image
|
| 823 |
-
import fitz
|
| 824 |
-
pdf_path = SOURCE_DIR / row["filename"]
|
| 825 |
-
doc = fitz.open(str(pdf_path))
|
| 826 |
page = doc[page_num - 1]
|
| 827 |
-
|
| 828 |
-
pix = page.get_pixmap(matrix=mat, alpha=False)
|
| 829 |
-
|
| 830 |
-
# Save render
|
| 831 |
render_dir = RENDERS_DIR / book_id
|
| 832 |
render_dir.mkdir(exist_ok=True)
|
| 833 |
render_path = render_dir / f"{book_id}_page_{page_num:04d}_300dpi.png"
|
| 834 |
pix.save(str(render_path))
|
| 835 |
-
|
|
|
|
| 836 |
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
det_predictor=surya["det_predictor"],
|
| 843 |
-
highres_images=[img],
|
| 844 |
-
math_mode=True,
|
| 845 |
-
)
|
| 846 |
-
else:
|
| 847 |
-
result = surya["run"](
|
| 848 |
-
[img],
|
| 849 |
-
[["en"]],
|
| 850 |
-
surya["det_model"],
|
| 851 |
-
surya["det_proc"],
|
| 852 |
-
surya["rec_model"],
|
| 853 |
-
surya["rec_proc"],
|
| 854 |
-
)
|
| 855 |
-
page_result = result[0]
|
| 856 |
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
page_data["render_path"] = str(render_path.relative_to(SS_ROOT))
|
| 873 |
-
page_data["render_dpi"] = 300
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
except Exception as e:
|
| 876 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
regions = []
|
| 878 |
-
conf
|
| 879 |
-
method
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
else:
|
| 881 |
-
# No surya — skip OCR pages
|
| 882 |
regions = []
|
| 883 |
-
conf
|
| 884 |
-
method
|
| 885 |
|
| 886 |
-
# Classify confidence
|
| 887 |
-
default_cal = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 888 |
if conf >= default_cal["auto_accept"]:
|
| 889 |
conf_class = "auto-accept"
|
| 890 |
elif conf >= default_cal["review"]:
|
|
@@ -908,27 +1022,39 @@ def run_ocr() -> tuple:
|
|
| 908 |
"ocred_at": datetime.utcnow().isoformat() + "Z",
|
| 909 |
})
|
| 910 |
|
| 911 |
-
# Add to review queue
|
| 912 |
if conf_class in ("review-required", "low-confidence", "quarantine"):
|
| 913 |
raw_text = " ".join(r["text"] for r in regions)[:500]
|
| 914 |
region_id = f"{book_id}_p{page_num:04d}"
|
| 915 |
queue_rows.append({
|
| 916 |
-
"book_id": book_id,
|
| 917 |
-
"
|
|
|
|
|
|
|
| 918 |
"region_class": "narration",
|
| 919 |
-
"crop_path": page_data.get("render_path",""),
|
| 920 |
"raw_ocr": raw_text,
|
| 921 |
-
"confidence": conf,
|
|
|
|
| 922 |
"status": "quarantine" if conf_class == "quarantine" else "pending",
|
| 923 |
-
"reviewer": "",
|
|
|
|
|
|
|
| 924 |
})
|
| 925 |
|
| 926 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
ocr_dir = OCR_RAW_DIR / book_id
|
| 928 |
ocr_dir.mkdir(exist_ok=True)
|
| 929 |
-
with open(ocr_dir / f"{book_id}_ocr_raw.json", "w") as f:
|
| 930 |
json.dump({
|
| 931 |
-
"book_id": book_id,
|
|
|
|
| 932 |
"source_hash": row["sha256"],
|
| 933 |
"page_count": len(ocr_pages),
|
| 934 |
"review_queue": review_pages,
|
|
@@ -936,15 +1062,13 @@ def run_ocr() -> tuple:
|
|
| 936 |
"pages": ocr_pages,
|
| 937 |
}, f, indent=2)
|
| 938 |
|
| 939 |
-
|
| 940 |
-
with open(profile_path, "w") as f:
|
| 941 |
json.dump(profile, f, indent=2)
|
| 942 |
|
| 943 |
df.loc[df["book_id"] == book_id, "status"] = "ocred"
|
| 944 |
total_review = len(review_pages) + len(quarantine_pages)
|
| 945 |
log.append(log_line(f"✓ {book_id}: {len(ocr_pages)}pp — review queue: {total_review}"))
|
| 946 |
|
| 947 |
-
# Write review queue
|
| 948 |
if queue_rows:
|
| 949 |
qdf = pd.DataFrame(queue_rows)
|
| 950 |
if QUEUE_CSV.exists():
|
|
@@ -952,6 +1076,7 @@ def run_ocr() -> tuple:
|
|
| 952 |
qdf = pd.concat([existing, qdf], ignore_index=True).drop_duplicates(subset=["region_id"])
|
| 953 |
qdf.to_csv(QUEUE_CSV, index=False)
|
| 954 |
|
|
|
|
| 955 |
save_manifest_df(df)
|
| 956 |
return _ocr_status_html(), "\n".join(log)
|
| 957 |
|
|
|
|
| 708 |
|
| 709 |
|
| 710 |
# ── Step 3: OCR ────────────────────────────────────────────────────────────────
|
| 711 |
+
_SURYA_RUNTIME = None
|
| 712 |
+
try:
|
| 713 |
+
SS_SURYA_BATCH_SIZE = max(1, int(os.environ.get("SS_SURYA_BATCH_SIZE", "4")))
|
| 714 |
+
except Exception:
|
| 715 |
+
SS_SURYA_BATCH_SIZE = 4
|
|
|
|
|
|
|
|
|
|
| 716 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
|
| 718 |
+
def _load_surya_runtime():
|
| 719 |
+
"""Load Surya once per app process and reuse between OCR runs."""
|
| 720 |
+
global _SURYA_RUNTIME
|
| 721 |
+
if _SURYA_RUNTIME is not None:
|
| 722 |
+
return _SURYA_RUNTIME, None, True
|
| 723 |
|
|
|
|
| 724 |
surya = None
|
| 725 |
surya_error = None
|
| 726 |
+
|
| 727 |
+
# New API (surya-ocr>=0.17 style)
|
| 728 |
try:
|
| 729 |
from surya.foundation import FoundationPredictor
|
| 730 |
from surya.detection import DetectionPredictor
|
|
|
|
| 746 |
}
|
| 747 |
except Exception as e:
|
| 748 |
surya_error = e
|
| 749 |
+
|
| 750 |
+
# Legacy API (surya-ocr<=0.6 style)
|
| 751 |
+
if surya is None:
|
| 752 |
try:
|
| 753 |
from surya.ocr import run_ocr as surya_run
|
| 754 |
from surya.model.detection.model import load_model as load_det
|
|
|
|
| 766 |
except Exception as legacy_error:
|
| 767 |
surya_error = f"{surya_error}; legacy={legacy_error}"
|
| 768 |
|
| 769 |
+
if surya is not None:
|
| 770 |
+
_SURYA_RUNTIME = surya
|
| 771 |
+
return surya, None, False
|
| 772 |
+
|
| 773 |
+
return None, str(surya_error), False
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _run_surya_batch(images, surya: dict):
|
| 777 |
+
"""Run a batch of PIL images through Surya using either API shape."""
|
| 778 |
+
if surya.get("api") == "predictor-v2":
|
| 779 |
+
return surya["rec_predictor"](
|
| 780 |
+
images,
|
| 781 |
+
task_names=[surya["task_name"]] * len(images),
|
| 782 |
+
det_predictor=surya["det_predictor"],
|
| 783 |
+
highres_images=images,
|
| 784 |
+
math_mode=True,
|
| 785 |
+
)
|
| 786 |
+
|
| 787 |
+
return surya["run"](
|
| 788 |
+
images,
|
| 789 |
+
[["en"]] * len(images),
|
| 790 |
+
surya["det_model"],
|
| 791 |
+
surya["det_proc"],
|
| 792 |
+
surya["rec_model"],
|
| 793 |
+
surya["rec_proc"],
|
| 794 |
+
)
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
def _regions_from_page_result(page_result):
|
| 798 |
+
regions = []
|
| 799 |
+
for line in getattr(page_result, "text_lines", []):
|
| 800 |
+
txt = (getattr(line, "text", "") or "").strip()
|
| 801 |
+
if not txt:
|
| 802 |
+
continue
|
| 803 |
+
|
| 804 |
+
conf = float(getattr(line, "confidence", 1.0))
|
| 805 |
+
bbox = getattr(line, "bbox", None)
|
| 806 |
+
if bbox is None:
|
| 807 |
+
bbox = getattr(line, "polygon", None)
|
| 808 |
+
|
| 809 |
+
regions.append({
|
| 810 |
+
"text": txt,
|
| 811 |
+
"confidence": round(conf, 4),
|
| 812 |
+
"bbox": bbox,
|
| 813 |
+
"word_count": len(txt.split()),
|
| 814 |
+
})
|
| 815 |
+
|
| 816 |
+
weighted_total = sum(r["confidence"] * r["word_count"] for r in regions)
|
| 817 |
+
weighted_words = sum(r["word_count"] for r in regions)
|
| 818 |
+
conf = round(weighted_total / max(weighted_words, 1), 4) if regions else 0.0
|
| 819 |
+
return regions, conf
|
| 820 |
+
|
| 821 |
+
|
| 822 |
+
def run_ocr(progress=gr.Progress(track_tqdm=False)) -> tuple:
|
| 823 |
+
"""Run Surya OCR on all profiled PDFs."""
|
| 824 |
+
df = load_manifest_df()
|
| 825 |
+
debug = f"[DEBUG] SS_ROOT={SS_ROOT}\nMANIFEST_CSV={MANIFEST_CSV}\nCSV exists={MANIFEST_CSV.exists()}\n"
|
| 826 |
+
if not df.empty:
|
| 827 |
+
debug += f"Manifest rows={len(df)}\nStatuses={df['status'].value_counts().to_dict()}\n"
|
| 828 |
+
else:
|
| 829 |
+
debug += "Manifest is EMPTY\n"
|
| 830 |
+
|
| 831 |
+
if df.empty:
|
| 832 |
+
return _ocr_status_html(), debug + "No sources. Complete Steps 1-2 first."
|
| 833 |
+
|
| 834 |
+
eligible = df[df["status"].isin(["profiled", "ocred", "rendered"])]
|
| 835 |
+
if eligible.empty:
|
| 836 |
+
unknown_or_excluded = df[df["rights_class"].isin(["unknown", "excluded"])] if "rights_class" in df.columns else df.iloc[0:0]
|
| 837 |
+
pending = df[df["status"] == "pending"] if "status" in df.columns else df.iloc[0:0]
|
| 838 |
+
debug += (
|
| 839 |
+
f"Eligible rows={len(eligible)}\n"
|
| 840 |
+
f"Pending rows={len(pending)}\n"
|
| 841 |
+
f"Unknown/excluded rights={len(unknown_or_excluded)}\n"
|
| 842 |
+
"Tip: In Step 1, update rights_class away from unknown/excluded, "
|
| 843 |
+
"then rerun Step 2 Profile.\n"
|
| 844 |
+
)
|
| 845 |
+
return _ocr_status_html(), debug + "No profiled PDFs. Complete Step 2 first."
|
| 846 |
+
|
| 847 |
+
log = []
|
| 848 |
+
queue_rows = []
|
| 849 |
+
|
| 850 |
+
progress(0, desc="Preparing OCR run...")
|
| 851 |
+
|
| 852 |
+
surya, surya_error, reused = _load_surya_runtime()
|
| 853 |
if surya:
|
| 854 |
+
if reused:
|
| 855 |
+
log.append(log_line(f"✓ Reusing Surya models ({surya['api']})"))
|
| 856 |
+
else:
|
| 857 |
+
log.append(log_line(f"✓ Surya models loaded ({surya['api']})"))
|
| 858 |
else:
|
| 859 |
log.append(log_line(f"⚠ Surya unavailable ({surya_error}) — falling back to text extraction only"))
|
| 860 |
|
| 861 |
+
total_pages_planned = 0
|
| 862 |
+
for _, row in eligible.iterrows():
|
| 863 |
+
profile_path = PROFILES_DIR / f"{row['book_id']}_page_profile.json"
|
| 864 |
+
if not profile_path.exists():
|
| 865 |
+
continue
|
| 866 |
+
try:
|
| 867 |
+
with open(profile_path, encoding="utf-8") as f:
|
| 868 |
+
profile = json.load(f)
|
| 869 |
+
total_pages_planned += len(profile.get("pages", []))
|
| 870 |
+
except Exception:
|
| 871 |
+
continue
|
| 872 |
+
total_pages_planned = max(total_pages_planned, 1)
|
| 873 |
+
processed_pages = 0
|
| 874 |
|
| 875 |
for _, row in eligible.iterrows():
|
| 876 |
+
book_id = row["book_id"]
|
| 877 |
profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
|
| 878 |
if not profile_path.exists():
|
| 879 |
log.append(log_line(f"✗ {book_id}: no profile"))
|
| 880 |
continue
|
| 881 |
|
| 882 |
+
with open(profile_path, encoding="utf-8") as f:
|
| 883 |
+
profile = json.load(f)
|
| 884 |
+
|
| 885 |
ocr_pages = []
|
| 886 |
review_pages = []
|
| 887 |
quarantine_pages = []
|
| 888 |
cal = load_calibration()
|
| 889 |
+
default_cal = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 890 |
+
|
| 891 |
+
doc = None
|
| 892 |
+
try:
|
| 893 |
+
import fitz
|
| 894 |
+
pdf_path = SOURCE_DIR / row["filename"]
|
| 895 |
+
doc = fitz.open(str(pdf_path))
|
| 896 |
+
except Exception as e:
|
| 897 |
+
log.append(log_line(f"⚠ {book_id}: PDF open failed ({e})"))
|
| 898 |
|
| 899 |
+
# First pass: render OCR/hybrid pages once and keep PIL images for batch OCR.
|
| 900 |
+
ocr_targets = []
|
| 901 |
+
for page_data in profile.get("pages", []):
|
| 902 |
page_num = page_data["page_number"]
|
| 903 |
+
route = page_data["route"]
|
| 904 |
|
| 905 |
if route == "embedded_text":
|
| 906 |
+
continue
|
| 907 |
+
|
| 908 |
+
render_path = None
|
| 909 |
+
img = None
|
| 910 |
+
if doc is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 911 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
page = doc[page_num - 1]
|
| 913 |
+
pix = page.get_pixmap(dpi=300, alpha=False)
|
|
|
|
|
|
|
|
|
|
| 914 |
render_dir = RENDERS_DIR / book_id
|
| 915 |
render_dir.mkdir(exist_ok=True)
|
| 916 |
render_path = render_dir / f"{book_id}_page_{page_num:04d}_300dpi.png"
|
| 917 |
pix.save(str(render_path))
|
| 918 |
+
page_data["render_path"] = str(render_path.relative_to(SS_ROOT))
|
| 919 |
+
page_data["render_dpi"] = 300
|
| 920 |
|
| 921 |
+
if surya is not None:
|
| 922 |
+
from PIL import Image
|
| 923 |
+
img = Image.open(render_path).convert("RGB")
|
| 924 |
+
except Exception as e:
|
| 925 |
+
log.append(log_line(f" ⚠ {book_id} p{page_num}: render failed ({e})"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
|
| 927 |
+
ocr_targets.append({
|
| 928 |
+
"page_num": page_num,
|
| 929 |
+
"route": route,
|
| 930 |
+
"image": img,
|
| 931 |
+
"render_path": page_data.get("render_path"),
|
| 932 |
+
})
|
| 933 |
+
|
| 934 |
+
# Batch OCR for non-embedded pages.
|
| 935 |
+
ocr_lookup = {}
|
| 936 |
+
if surya is not None and ocr_targets:
|
| 937 |
+
batch_size = SS_SURYA_BATCH_SIZE
|
| 938 |
+
for start in range(0, len(ocr_targets), batch_size):
|
| 939 |
+
batch = ocr_targets[start:start + batch_size]
|
| 940 |
+
batch_pages = [item["page_num"] for item in batch]
|
| 941 |
+
batch_images = [item["image"] for item in batch if item["image"] is not None]
|
|
|
|
|
|
|
| 942 |
|
| 943 |
+
try:
|
| 944 |
+
if len(batch_images) != len(batch):
|
| 945 |
+
raise RuntimeError("One or more page renders missing for OCR batch.")
|
| 946 |
+
|
| 947 |
+
predictions = _run_surya_batch(batch_images, surya)
|
| 948 |
+
for item, page_result in zip(batch, predictions):
|
| 949 |
+
regions, conf = _regions_from_page_result(page_result)
|
| 950 |
+
ocr_lookup[item["page_num"]] = {
|
| 951 |
+
"regions": regions,
|
| 952 |
+
"confidence": conf,
|
| 953 |
+
"method": "surya",
|
| 954 |
+
}
|
| 955 |
except Exception as e:
|
| 956 |
+
for item in batch:
|
| 957 |
+
ocr_lookup[item["page_num"]] = {
|
| 958 |
+
"regions": [],
|
| 959 |
+
"confidence": 0.0,
|
| 960 |
+
"method": "error",
|
| 961 |
+
}
|
| 962 |
+
log.append(log_line(f" ⚠ {book_id} batch {batch_pages[0]}-{batch_pages[-1]}: {e}"))
|
| 963 |
+
finally:
|
| 964 |
+
for img in batch_images:
|
| 965 |
+
try:
|
| 966 |
+
img.close()
|
| 967 |
+
except Exception:
|
| 968 |
+
pass
|
| 969 |
+
|
| 970 |
+
# Second pass: build page-level OCR output and review queue.
|
| 971 |
+
for page_data in profile.get("pages", []):
|
| 972 |
+
page_num = page_data["page_number"]
|
| 973 |
+
route = page_data["route"]
|
| 974 |
+
|
| 975 |
+
progress(
|
| 976 |
+
(processed_pages, total_pages_planned),
|
| 977 |
+
desc=f"OCR {book_id} p{page_num}/{len(profile.get('pages', []))}",
|
| 978 |
+
)
|
| 979 |
+
|
| 980 |
+
if route == "embedded_text":
|
| 981 |
+
try:
|
| 982 |
+
if doc is None:
|
| 983 |
+
raise RuntimeError("PDF document not open")
|
| 984 |
+
text = doc[page_num - 1].get_text("text").strip()
|
| 985 |
+
regions = [{"text": text, "confidence": 0.99, "bbox": [0, 0, 100, 100], "word_count": len(text.split())}]
|
| 986 |
+
conf = 0.99
|
| 987 |
+
method = "embedded-text"
|
| 988 |
+
except Exception:
|
| 989 |
regions = []
|
| 990 |
+
conf = 0.0
|
| 991 |
+
method = "error"
|
| 992 |
+
elif surya:
|
| 993 |
+
page_out = ocr_lookup.get(page_num, {"regions": [], "confidence": 0.0, "method": "error"})
|
| 994 |
+
regions = page_out["regions"]
|
| 995 |
+
conf = page_out["confidence"]
|
| 996 |
+
method = page_out["method"]
|
| 997 |
else:
|
|
|
|
| 998 |
regions = []
|
| 999 |
+
conf = 0.0
|
| 1000 |
+
method = "skipped-no-surya"
|
| 1001 |
|
|
|
|
|
|
|
| 1002 |
if conf >= default_cal["auto_accept"]:
|
| 1003 |
conf_class = "auto-accept"
|
| 1004 |
elif conf >= default_cal["review"]:
|
|
|
|
| 1022 |
"ocred_at": datetime.utcnow().isoformat() + "Z",
|
| 1023 |
})
|
| 1024 |
|
|
|
|
| 1025 |
if conf_class in ("review-required", "low-confidence", "quarantine"):
|
| 1026 |
raw_text = " ".join(r["text"] for r in regions)[:500]
|
| 1027 |
region_id = f"{book_id}_p{page_num:04d}"
|
| 1028 |
queue_rows.append({
|
| 1029 |
+
"book_id": book_id,
|
| 1030 |
+
"filename": row["filename"],
|
| 1031 |
+
"page": page_num,
|
| 1032 |
+
"region_id": region_id,
|
| 1033 |
"region_class": "narration",
|
| 1034 |
+
"crop_path": page_data.get("render_path", ""),
|
| 1035 |
"raw_ocr": raw_text,
|
| 1036 |
+
"confidence": conf,
|
| 1037 |
+
"confidence_class": conf_class,
|
| 1038 |
"status": "quarantine" if conf_class == "quarantine" else "pending",
|
| 1039 |
+
"reviewer": "",
|
| 1040 |
+
"correction": "",
|
| 1041 |
+
"reason_code": "",
|
| 1042 |
})
|
| 1043 |
|
| 1044 |
+
processed_pages += 1
|
| 1045 |
+
|
| 1046 |
+
if doc is not None:
|
| 1047 |
+
try:
|
| 1048 |
+
doc.close()
|
| 1049 |
+
except Exception:
|
| 1050 |
+
pass
|
| 1051 |
+
|
| 1052 |
ocr_dir = OCR_RAW_DIR / book_id
|
| 1053 |
ocr_dir.mkdir(exist_ok=True)
|
| 1054 |
+
with open(ocr_dir / f"{book_id}_ocr_raw.json", "w", encoding="utf-8") as f:
|
| 1055 |
json.dump({
|
| 1056 |
+
"book_id": book_id,
|
| 1057 |
+
"filename": row["filename"],
|
| 1058 |
"source_hash": row["sha256"],
|
| 1059 |
"page_count": len(ocr_pages),
|
| 1060 |
"review_queue": review_pages,
|
|
|
|
| 1062 |
"pages": ocr_pages,
|
| 1063 |
}, f, indent=2)
|
| 1064 |
|
| 1065 |
+
with open(profile_path, "w", encoding="utf-8") as f:
|
|
|
|
| 1066 |
json.dump(profile, f, indent=2)
|
| 1067 |
|
| 1068 |
df.loc[df["book_id"] == book_id, "status"] = "ocred"
|
| 1069 |
total_review = len(review_pages) + len(quarantine_pages)
|
| 1070 |
log.append(log_line(f"✓ {book_id}: {len(ocr_pages)}pp — review queue: {total_review}"))
|
| 1071 |
|
|
|
|
| 1072 |
if queue_rows:
|
| 1073 |
qdf = pd.DataFrame(queue_rows)
|
| 1074 |
if QUEUE_CSV.exists():
|
|
|
|
| 1076 |
qdf = pd.concat([existing, qdf], ignore_index=True).drop_duplicates(subset=["region_id"])
|
| 1077 |
qdf.to_csv(QUEUE_CSV, index=False)
|
| 1078 |
|
| 1079 |
+
progress((total_pages_planned, total_pages_planned), desc="OCR complete")
|
| 1080 |
save_manifest_df(df)
|
| 1081 |
return _ocr_status_html(), "\n".join(log)
|
| 1082 |
|