Pointf5ive commited on
Commit
ad64790
·
1 Parent(s): be1ecc2

Speed up Smoke Signal OCR and add live progress

Browse files
Files changed (1) hide show
  1. 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
- def run_ocr() -> tuple:
712
- """Run Surya OCR on all profiled PDFs."""
713
- df = load_manifest_df()
714
- debug = f"[DEBUG] SS_ROOT={SS_ROOT}\nMANIFEST_CSV={MANIFEST_CSV}\nCSV exists={MANIFEST_CSV.exists()}\n"
715
- if not df.empty:
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
- log = []
 
 
 
 
737
 
738
- # Try to import/load Surya (new predictor API first, then legacy API)
739
  surya = None
740
  surya_error = None
741
- log.append(log_line("Loading Surya models (may take a moment)..."))
 
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
- log.append(log_line(f"✓ Surya models loaded ({surya['api']})"))
 
 
 
782
  else:
783
  log.append(log_line(f"⚠ Surya unavailable ({surya_error}) — falling back to text extraction only"))
784
 
785
- queue_rows = []
 
 
 
 
 
 
 
 
 
 
 
 
786
 
787
  for _, row in eligible.iterrows():
788
- book_id = row["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
- profile = json.load(open(profile_path))
 
 
795
  ocr_pages = []
796
  review_pages = []
797
  quarantine_pages = []
798
  cal = load_calibration()
 
 
 
 
 
 
 
 
 
799
 
800
- for page_data in profile["pages"]:
 
 
801
  page_num = page_data["page_number"]
802
- route = page_data["route"]
803
 
804
  if route == "embedded_text":
805
- # Extract directly via fitz
806
- try:
807
- import fitz
808
- pdf_path = SOURCE_DIR / row["filename"]
809
- doc = fitz.open(str(pdf_path))
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
- mat = fitz.Matrix(300/72, 300/72)
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
- doc.close()
 
836
 
837
- img = Image.open(render_path).convert("RGB")
838
- if surya["api"] == "predictor-v2":
839
- result = surya["rec_predictor"](
840
- [img],
841
- task_names=[surya["task_name"]],
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
- regions = []
858
- for line in page_result.text_lines:
859
- txt = line.text.strip()
860
- if txt:
861
- c = float(line.confidence) if hasattr(line, "confidence") else 1.0
862
- bbox = getattr(line, "bbox", None)
863
- if bbox is None:
864
- bbox = getattr(line, "polygon", None)
865
- regions.append({"text": txt, "confidence": round(c,4), "bbox": bbox, "word_count": len(txt.split())})
866
-
867
- conf = sum(r["confidence"]*r["word_count"] for r in regions) / max(sum(r["word_count"] for r in regions), 1) if regions else 0.0
868
- conf = round(conf, 4)
869
- method = "surya"
870
-
871
- # Update profile with render path
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
- log.append(log_line(f" ⚠ Page {page_num}: {e}"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
  regions = []
878
- conf = 0.0
879
- method = "error"
 
 
 
 
 
880
  else:
881
- # No surya — skip OCR pages
882
  regions = []
883
- conf = 0.0
884
- method = "skipped-no-surya"
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, "filename": row["filename"],
917
- "page": page_num, "region_id": region_id,
 
 
918
  "region_class": "narration",
919
- "crop_path": page_data.get("render_path",""),
920
  "raw_ocr": raw_text,
921
- "confidence": conf, "confidence_class": conf_class,
 
922
  "status": "quarantine" if conf_class == "quarantine" else "pending",
923
- "reviewer": "", "correction": "", "reason_code": "",
 
 
924
  })
925
 
926
- # Save OCR raw
 
 
 
 
 
 
 
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, "filename": row["filename"],
 
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
- # Save updated profile (with render paths)
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