phucsd's picture
feat(studio): set Cham V24 as default model and temporarily hide V25
c81c228 verified
Raw History Blame Contribute Delete
76.9 kB
import os
import sys
# Force single-threaded execution for OpenMP and MKL to prevent conflicts between OpenCV and PaddlePaddle on Windows
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
import json
import base64
import cv2
import re
import numpy as np
# Monkeypatch NumPy 2.x to support legacy symbols removed/deprecated for PaddleOCR and imgaug
if not hasattr(np, 'sctypes'):
np.sctypes = {
'int': [np.int8, np.int16, np.int32, np.int64],
'uint': [np.uint8, np.uint16, np.uint32, np.uint64],
'float': [np.float16, np.float32, np.float64],
'complex': [np.complex64, np.complex128],
'others': [bool, object, bytes, str]
}
if not hasattr(np, 'bool'):
np.bool = bool
if not hasattr(np, 'int'):
np.int = int
if not hasattr(np, 'float'):
np.float = float
if not hasattr(np, 'typeDict'):
np.typeDict = {}
from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse
# Force UTF-8 stdout encoding on Windows
if sys.stdout and sys.stdout.encoding != 'utf-8':
try:
sys.stdout.reconfigure(encoding='utf-8')
except AttributeError:
pass
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
paddleocr_dir = os.path.join(PROJECT_ROOT, "PaddleOCR")
if paddleocr_dir not in sys.path:
# Use append instead of insert(0) to prevent shadowing PyPI paddleocr
sys.path.append(paddleocr_dir)
# Add ocr-training to sys.path so 'scripts.generate_data' can be imported
ocr_training_dir = os.path.join(os.path.dirname(PROJECT_ROOT), "ocr-training")
if not os.path.exists(ocr_training_dir):
ocr_training_dir = os.path.join(os.path.dirname(PROJECT_ROOT), "ocr_training")
if ocr_training_dir not in sys.path:
sys.path.insert(0, ocr_training_dir)
# Global variables for OCR models
ocr_models = {}
det_model = None
class OfficialPaddleOCRWrapper:
def __init__(self, lang):
self.lang = lang
self.ocr = None
def _init_ocr(self):
if self.ocr is None:
print("DEBUG: [OfficialPaddleOCRWrapper] Importing paddleocr...")
from paddleocr import PaddleOCR
ocr_rec_batch_size = int(os.getenv("OCR_REC_BATCH_SIZE", "16"))
import paddleocr
version_str = getattr(paddleocr, "__version__", "2.0.0")
print(f"DEBUG: [OfficialPaddleOCRWrapper] paddleocr version={version_str}")
if version_str.startswith("3."):
# PP-OCRv6 (paddleocr 3.x)
print("DEBUG: [OfficialPaddleOCRWrapper] Instantiating PP-OCRv6...")
self.ocr = PaddleOCR(
use_textline_orientation=False,
lang=self.lang,
text_recognition_batch_size=ocr_rec_batch_size
)
else:
# PP-OCRv4 / PP-OCRv5 (paddleocr 2.x)
print("DEBUG: [OfficialPaddleOCRWrapper] Instantiating PP-OCRv4...")
self.ocr = PaddleOCR(
use_angle_cls=False,
show_log=False,
lang=self.lang,
rec=True,
det=False,
rec_batch_num=ocr_rec_batch_size
)
print("DEBUG: [OfficialPaddleOCRWrapper] Instantiated successfully.")
def __call__(self, img_list):
print(f"DEBUG: [OfficialPaddleOCRWrapper] Received {len(img_list)} images")
self._init_ocr()
if not img_list:
return [], 0.0
try:
import paddleocr
version_str = getattr(paddleocr, "__version__", "2.0.0")
if version_str.startswith("3."):
print("DEBUG: [OfficialPaddleOCRWrapper] Calling PP-OCRv6 text_rec_model...")
rec_res = self.ocr.paddlex_pipeline._pipeline.text_rec_model(img_list)
print("DEBUG: [OfficialPaddleOCRWrapper] PP-OCRv6 prediction finished.")
results = []
for r in rec_res:
pred_text = r['rec_text']
conf = r['rec_score']
results.append((pred_text, float(conf)))
return results, 0.0
else:
# PaddleOCR 2.x batching
print("DEBUG: [OfficialPaddleOCRWrapper] Calling PP-OCRv4 text_recognizer...")
rec_res, _ = self.ocr.text_recognizer(img_list)
print("DEBUG: [OfficialPaddleOCRWrapper] PP-OCRv4 prediction finished.")
results = []
for res in rec_res:
pred_text, conf = res if res else ("", 0.0)
results.append((pred_text, float(conf)))
return results, 0.0
except Exception as e:
print(f"DEBUG: [OfficialPaddleOCRWrapper] Batch failed, falling back to sequential: {e}")
results = []
for img in img_list:
try:
res = self.ocr.ocr(img, det=False, cls=False)
if res and res[0] and res[0][0]:
text, conf = res[0][0]
results.append((text, conf))
else:
results.append(("", 0.0))
except Exception:
results.append(("", 0.0))
return results, 0.0
class AutoRoutingOCRWrapper:
def __init__(self, cham_model, viet_model):
self.cham_model = cham_model
self.viet_model = viet_model
def __call__(self, img_list):
if not img_list:
return [], 0.0
# 1. Predict all images with Cham model in a single batch
print(f"DEBUG: [AutoRouting] Calling Cham model for {len(img_list)} images...")
cham_res, _ = self.cham_model(img_list)
print("DEBUG: [AutoRouting] Cham model prediction finished.")
# 2. Identify indices requiring Vietnamese OCR
viet_indices = []
viet_imgs = []
for i, (cham_text, cham_conf) in enumerate(cham_res):
if cham_conf < 0.65:
viet_indices.append(i)
viet_imgs.append(img_list[i])
# 3. Predict those with Vietnamese model in a single batch
viet_res = []
if viet_imgs:
print(f"DEBUG: [AutoRouting] Calling Viet model for {len(viet_imgs)} images...")
viet_batch_res, _ = self.viet_model(viet_imgs)
viet_res = viet_batch_res
print("DEBUG: [AutoRouting] Viet model prediction finished.")
# 4. Merge results keeping router logic
final_results = [None] * len(img_list)
viet_ptr = 0
for i in range(len(img_list)):
cham_text, cham_conf = cham_res[i]
if cham_conf >= 0.65:
final_results[i] = (cham_text, cham_conf)
else:
if viet_ptr < len(viet_res):
viet_text, viet_conf = viet_res[viet_ptr]
else:
viet_text, viet_conf = ("", 0.0)
viet_ptr += 1
if cham_conf >= 0.40 and cham_conf > viet_conf - 0.15:
final_results[i] = (cham_text, cham_conf)
else:
final_results[i] = (viet_text, viet_conf)
return final_results, 0.0
def get_ocr_model(version):
"""Loads and caches the PaddleOCR model for the given version dynamically"""
global ocr_models
if version in ocr_models:
return ocr_models[version]
if version in ['en', 'fr', 'japan']:
ocr_model = OfficialPaddleOCRWrapper(lang=version)
ocr_models[version] = ocr_model
return ocr_model
if version == 'auto':
cham_model = get_ocr_model('v24')
viet_model = get_ocr_model('vi')
ocr_model = AutoRoutingOCRWrapper(cham_model, viet_model)
ocr_models[version] = ocr_model
return ocr_model
from tools.infer.predict_rec import TextRecognizer
import tools.infer.utility as utility
if version == 'vi':
model_dir = os.path.join(PROJECT_ROOT, "data", "output", "rec_vietnamese_infer")
dict_path = os.path.join(PROJECT_ROOT, "data", "vietnamese_dict.txt")
model_ready = os.path.exists(os.path.join(model_dir, "inference.pdiparams")) and os.path.exists(dict_path)
if not model_ready:
try:
print(f"📥 Downloading dedicated Vietnamese OCR model to {model_dir}...")
os.makedirs(model_dir, exist_ok=True)
import urllib.request
base_url = "https://huggingface.co/anbeo2004/paddleocr-v5-rec-vi-custom/resolve/main/"
dl_files = {
"custom_dict.txt": dict_path,
"inference.json": os.path.join(model_dir, "inference.json"),
"inference.yml": os.path.join(model_dir, "inference.yml"),
"inference.pdiparams": os.path.join(model_dir, "inference.pdiparams")
}
for fname, ftarget in dl_files.items():
if not os.path.exists(ftarget):
urllib.request.urlretrieve(base_url + fname, ftarget)
print("✅ Downloaded Vietnamese OCR model successfully.")
except Exception as e:
print(f"⚠️ Failed to auto-download Vietnamese model: {e}")
ocr_model = OfficialPaddleOCRWrapper(lang='vi')
ocr_models[version] = ocr_model
return ocr_model
else:
model_dir = os.path.join(PROJECT_ROOT, "data", "output", f"rec_cham_inference_{version}")
dict_path = os.path.join(PROJECT_ROOT, "data", f"cham_dict_{version}.txt")
if not os.path.exists(model_dir):
# Fallback to Golden Baseline v23 if version-specific folder is missing
model_dir = os.path.join(PROJECT_ROOT, "data", "output", "rec_cham_inference_v23")
if not os.path.exists(dict_path):
dict_path = os.path.join(PROJECT_ROOT, "data", "cham_dict_v23.txt")
sys_argv_backup = sys.argv
sys.argv = [sys.argv[0]]
args = utility.parse_args()
sys.argv = sys_argv_backup
args.rec_model_dir = model_dir
args.rec_char_dict_path = dict_path
import paddle
args.use_gpu = paddle.is_compiled_with_cuda()
args.rec_image_shape = "3, 48, 480" if version == "v25" else "3, 48, 320"
args.use_space_char = True
# Batch size config: default to 1 on CPU to prevent padding latency blowup from varying crop aspect ratios
env_batch = os.getenv("OCR_REC_BATCH_SIZE")
if env_batch:
ocr_rec_batch_size = int(env_batch)
else:
ocr_rec_batch_size = 1 if not paddle.is_compiled_with_cuda() else 16
args.rec_batch_num = ocr_rec_batch_size
if args.use_gpu:
args.enable_mkldnn = False
args.ir_optim = True
args.precision = "fp32"
else:
# CPU Settings: Disable MKLDNN by default on Windows to prevent OneDNN deadlocks inside socket handlers
env_mkldnn = os.getenv("ENABLE_MKLDNN", "False").lower() in ("true", "1")
args.enable_mkldnn = env_mkldnn
args.mkldnn_cache_capacity = 32
env_threads = os.getenv("CPU_THREADS")
if env_threads:
args.cpu_threads = int(env_threads)
else:
# Default to 1 thread to prevent OpenMP deadlocks on Windows socket servers
args.cpu_threads = 1
args.ir_optim = False # Disable on CPU to prevent OneDNN crash
args.precision = "fp32"
print(f"⚙️ Loading PaddleOCR Model {version} (GPU: {args.use_gpu}, MKLDNN: {args.enable_mkldnn}, Batch: {args.rec_batch_num}, Threads: {args.cpu_threads})...")
ocr_model = TextRecognizer(args)
# Warmup the model with a dummy image to trigger compilation/allocator caching
try:
dummy_img = np.zeros((48, 320, 3), dtype=np.uint8)
ocr_model([dummy_img])
print(f"🔥 Model {version} warmed up successfully.")
except Exception as e:
print(f"Failed to warmup model: {e}")
ocr_models[version] = ocr_model
return ocr_model
def get_det_model():
"""Loads and caches the PaddleOCR TextDetector model for DBNet segmentation."""
global det_model
if det_model is not None:
return det_model
from tools.infer.predict_det import TextDetector
import tools.infer.utility as utility
cham_det_dir = os.path.join(PROJECT_ROOT, "data", "output", "ch_PP-OCRv4_det_cham_infer")
has_cham_model = os.path.exists(os.path.join(cham_det_dir, "inference.pdmodel"))
if has_cham_model:
det_model_dir = cham_det_dir
print(f"🌟 Using dedicated Cham Text Detection model: {det_model_dir}")
else:
det_model_dir = os.path.join(PROJECT_ROOT, "data", "output", "ch_PP-OCRv4_det_infer")
model_file_exists = os.path.exists(os.path.join(det_model_dir, "inference.pdmodel")) or os.path.exists(os.path.join(det_model_dir, "inference.json"))
if not model_file_exists:
print(f"📥 Downloading ch_PP-OCRv4_det_infer to {det_model_dir}...")
os.makedirs(det_model_dir, exist_ok=True)
url = "https://paddleocr.bj.bcebos.com/PP-OCRv4/chinese/ch_PP-OCRv4_det_infer.tar"
tar_path = os.path.join(det_model_dir, "det.tar")
import urllib.request, tarfile, shutil
urllib.request.urlretrieve(url, tar_path)
with tarfile.open(tar_path) as tar:
tar.extractall(det_model_dir)
sub = os.path.join(det_model_dir, "ch_PP-OCRv4_det_infer")
if os.path.exists(sub):
for f in os.listdir(sub):
shutil.move(os.path.join(sub, f), os.path.join(det_model_dir, f))
os.rmdir(sub)
if os.path.exists(tar_path):
os.remove(tar_path)
print("✅ Downloaded and extracted ch_PP-OCRv4_det_infer successfully.")
sys_argv_backup = sys.argv
sys.argv = [sys.argv[0]]
args = utility.parse_args()
sys.argv = sys_argv_backup
args.use_gpu = False
args.enable_mkldnn = False
args.ir_optim = False
args.det_algorithm = 'DB'
args.det_model_dir = det_model_dir
args.det_db_thresh = 0.25
args.det_db_box_thresh = 0.5
args.det_db_unclip_ratio = 1.8
print(f"⚙️ Loading PaddleOCR DBNet Detector from {det_model_dir}...")
det_model = TextDetector(args)
print("🔥 PaddleOCR DBNet Detector loaded successfully.")
return det_model
def merge_line_boxes(boxes, img_w, img_h):
"""
Merges horizontally separated bounding boxes on the same line into a single unified line crop.
Prevents splitting a single line into separate fragments across distant words.
"""
if boxes is None or len(boxes) == 0:
return []
rects = []
for b in boxes:
pts = np.array(b, dtype=np.float32)
x1 = float(np.min(pts[:, 0]))
y1 = float(np.min(pts[:, 1]))
x2 = float(np.max(pts[:, 0]))
y2 = float(np.max(pts[:, 1]))
if (x2 - x1) <= 3 or (y2 - y1) <= 3:
continue
rects.append([x1, y1, x2, y2, pts])
rects = sorted(rects, key=lambda r: (r[1], r[0]))
merged = True
while merged:
merged = False
new_rects = []
skip = set()
for i in range(len(rects)):
if i in skip:
continue
r1 = rects[i]
for j in range(i + 1, len(rects)):
if j in skip:
continue
r2 = rects[j]
h1 = r1[3] - r1[1]
h2 = r2[3] - r2[1]
min_h = min(h1, h2)
if min_h <= 0:
continue
v_inter = max(0.0, min(r1[3], r2[3]) - max(r1[1], r2[1]))
v_overlap = v_inter / min_h
if r1[2] <= r2[0]:
x_dist = r2[0] - r1[2]
elif r2[2] <= r1[0]:
x_dist = r1[0] - r2[2]
else:
x_dist = 0.0
if v_overlap >= 0.50 and x_dist <= max(40.0, 2.5 * min_h):
new_x1 = max(0.0, min(r1[0], r2[0]))
new_y1 = max(0.0, min(r1[1], r2[1]))
new_x2 = min(float(img_w), max(r1[2], r2[2]))
new_y2 = min(float(img_h), max(r1[3], r2[3]))
new_pts = np.array([
[new_x1, new_y1],
[new_x2, new_y1],
[new_x2, new_y2],
[new_x1, new_y2]
], dtype=np.float32)
r1 = [new_x1, new_y1, new_x2, new_y2, new_pts]
skip.add(j)
merged = True
new_rects.append(r1)
rects = new_rects
# Line sorting: sort by vertical center
rects = sorted(rects, key=lambda r: (r[1] + r[3]) / 2.0)
return [r[4] for r in rects]
def check_inverted_polarity(img):
"""
Detects if an image has inverted polarity (light/white text on dark/colored background).
"""
if img is None or img.size == 0:
return False
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
h, w = gray.shape[:2]
pad_h = max(1, int(h * 0.05))
pad_w = max(1, int(w * 0.05))
border = np.concatenate([
gray[0:pad_h, :].flatten(),
gray[-pad_h:, :].flatten(),
gray[:, 0:pad_w].flatten(),
gray[:, -pad_w:].flatten()
])
bg_val = float(np.median(border))
fg_val = float(np.percentile(gray, 95))
return bool(bg_val < 145 and (fg_val - bg_val) > 35)
def preprocess_crop_for_rec(crop_img):
"""
Normalizes a crop for OCR recognition. If inverted (white text on dark background),
binarizes via Otsu to ensure black text on clean white background for SVTR/CTC models.
"""
if crop_img is None or crop_img.size == 0:
return crop_img
if check_inverted_polarity(crop_img):
gray = cv2.cvtColor(crop_img, cv2.COLOR_BGR2GRAY) if len(crop_img.shape) == 3 else crop_img
_, bin_inv = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
return cv2.cvtColor(bin_inv, cv2.COLOR_GRAY2BGR)
return crop_img
def segment_lines_dbnet(img, det_model):
"""
Performs deep-learning based text line segmentation using PaddleOCR DBNet.
Returns:
final_crops: list of cropped BGR images corresponding to each detected line
lines_metadata: list of metadata dicts matching the Studio response schema
"""
import tools.infer.utility as utility
from tools.infer.predict_system import sorted_boxes
is_inverted = check_inverted_polarity(img)
det_img = (255 - img) if is_inverted else img
dt_boxes, elapse = det_model(det_img)
if dt_boxes is None or len(dt_boxes) == 0:
return [], []
h, w = img.shape[:2]
# Merge horizontal box fragments on the same line
dt_boxes = merge_line_boxes(dt_boxes, w, h)
final_crops = []
lines_metadata = []
for idx, box in enumerate(dt_boxes):
pts = np.array(box, dtype=np.float32)
x_min = int(max(0, np.min(pts[:, 0])))
x_max = int(min(w, np.max(pts[:, 0])))
y_min = int(max(0, np.min(pts[:, 1])))
y_max = int(min(h, np.max(pts[:, 1])))
# Get perspective crop
crop_img = utility.get_rotate_crop_image(img, pts)
final_crops.append(crop_img)
meta = {
'line_id': idx,
'core_box': [y_min, y_max],
'bbox': [x_min, y_min, x_max, y_max],
'polygon': pts.tolist(),
'overlap_prev_px': 0,
'overlap_next_px': 0,
'crop_confidence': 1.0,
'selected_crop_type': 'dbnet',
'crop_sanity_score': 1.0,
'needs_review': False,
'candidates': {
'dbnet': {
'prediction': '',
'confidence': 0.0
}
}
}
lines_metadata.append(meta)
return final_crops, lines_metadata
# ==============================================================================
# Line Segmentation Algorithms
# ==============================================================================
def segment_lines_adaptive(img, threshold_pct=0.08, gap_threshold=12):
"""Adaptive Projection Profile line segmentation"""
is_inverted = check_inverted_polarity(img)
proc_img = (255 - img) if is_inverted else img
gray = cv2.cvtColor(proc_img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
hist = np.sum(thresh, axis=1)
max_val = np.max(hist)
threshold = max_val * threshold_pct
active_rows = np.where(hist >= threshold)[0]
if len(active_rows) == 0:
return [], []
line_ranges = []
start = active_rows[0]
for i in range(1, len(active_rows)):
if active_rows[i] - active_rows[i-1] > gap_threshold:
line_ranges.append((start, active_rows[i-1]))
start = active_rows[i]
line_ranges.append((start, active_rows[-1]))
# Return ranges and crop coordinates
cropped_lines = []
coords = []
pad = 8
h, w = gray.shape
for s, e in line_ranges:
s_pad = max(0, s - pad)
e_pad = min(h, e + pad)
cropped_lines.append(img[s_pad:e_pad, :])
coords.append((s_pad, e_pad))
return cropped_lines, coords
def segment_lines_valleys(img, window_size=25, min_dist=35):
"""Valley Detection (local minima) line segmentation"""
is_inverted = check_inverted_polarity(img)
proc_img = (255 - img) if is_inverted else img
gray = cv2.cvtColor(proc_img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
hist = np.sum(thresh, axis=1)
smoothed = np.convolve(hist, np.ones(window_size)/window_size, mode='same')
valleys = []
radius = max(3, int(window_size / 2))
for i in range(20, len(smoothed) - 20):
if smoothed[i] == np.min(smoothed[i-radius:i+radius+1]):
if not valleys or i - valleys[-1] > min_dist:
valleys.append(i)
split_points = [0] + valleys + [len(smoothed)]
cropped_lines = []
coords = []
for idx in range(len(split_points) - 1):
s = split_points[idx]
e = split_points[idx + 1]
if e - s < 15:
continue
cropped_lines.append(img[s:e, :])
coords.append((s, e))
return cropped_lines, coords
def calculate_crop_sanity_score(conf, pred_text, median_len, crop_img, ctype):
"""Calculates sanity score for a crop prediction"""
if not pred_text or crop_img is None or crop_img.size == 0:
return -1.0
score = conf
# Penalize if prediction length is too far from median
if median_len > 0:
ratio = len(pred_text) / median_len
if ratio > 1.4 or ratio < 0.6:
score *= 0.8
# Penalize if there is significant ink touching the top or bottom edges
# We check the top 3 and bottom 3 rows
gray = cv2.cvtColor(crop_img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
h, w = thresh.shape
if h > 6:
# Removed edge ink penalty because it falsely penalizes tight crops that perfectly bound the diacritics.
pass
# Boost safe slightly because it inherently suffers from resize-down confidence drops
if ctype == "safe":
score *= 1.15
elif ctype in ["upper_rescue", "lower_rescue"]:
score *= 1.10
elif ctype == "loose":
score *= 1.05
return score
def compute_cer(pred, gt):
if len(pred) == 0 and len(gt) == 0:
return 0.0
if len(gt) == 0:
return 1.0
import difflib
similarity = difflib.SequenceMatcher(None, pred, gt).ratio()
return 1.0 - similarity
def has_important_cham_sign_changes(pred1, pred2):
if pred1 == pred2:
return False
import difflib
diff = difflib.ndiff(pred1, pred2)
for d in diff:
if d.startswith('- ') or d.startswith('+ '):
char = d[2]
# Cham Unicode Block: U+AA00 to U+AA5F (with diacritics / vowels U+AA29 to U+AA4D)
# Also support legacy ranges U+A800 to U+A87F
if '\uAA00' <= char <= '\uAA5F' or '\uA800' <= char <= '\uA87F':
return True
return False
def segment_lines_advanced(img, base_coords, ocr_model):
"""
Advanced Multi-Crop Strategy with Risk-Gated Batched Inference
"""
import time
import cv2
import numpy as np
t_start = time.time()
if not base_coords:
return [], [], {
"candidate_generation_sec": 0.0,
"recognition_batch_pass1_sec": 0.0,
"recognition_batch_pass2_sec": 0.0,
"selection_sec": 0.0,
"total_segmentation_sec": 0.0,
"num_lines": 0,
"num_candidates_pass1": 0,
"num_candidates_pass2": 0,
"ocr_model_calls": 0
}
img_h, img_w, _ = img.shape
heights = [float(e - s) for s, e in base_coords]
valid_heights = [h for h in heights if h <= 0.15 * img_h]
median_line_height = float(np.median(valid_heights)) if valid_heights else (float(np.median(heights)) if heights else 30.0)
# [Pre-compute legacy bounds, CCA, etc., identical to original until Phase 1]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(thresh, connectivity=8)
line_components = [set() for _ in base_coords]
ambiguous_components = set()
cca_bounds = []
for i in range(1, num_labels):
x, y, w, h, area = stats[i]
# Ignore tiny noise (< 25px area or < 5px dim) or massive image-wide background blobs
if area < 25 or h < 5 or w < 5 or area > 0.25 * img_h * img_w or h > 0.6 * img_h:
continue
cy = y + h / 2
assigned_lines = []
for l_idx, (s, e) in enumerate(base_coords):
if s - 5 <= cy <= e + 5:
assigned_lines.append(l_idx)
if len(assigned_lines) == 1:
line_components[assigned_lines[0]].add(i)
elif len(assigned_lines) > 1:
ambiguous_components.add(i)
for l_idx in assigned_lines:
line_components[l_idx].add(i)
valid_base_coords = []
valid_line_components = []
true_core_coords = []
for idx, (s, e) in enumerate(base_coords):
comps = line_components[idx]
total_area = sum(stats[i][4] for i in comps) if comps else 0
# If line has no text components or total ink area is below minimum threshold, skip empty background line
if not comps or total_area < 80:
continue
core_s = min(stats[i][1] for i in comps)
core_e = max(stats[i][1] + stats[i][3] for i in comps)
valid_base_coords.append((s, e))
valid_line_components.append(comps)
true_core_coords.append((core_s, core_e))
# Fallback safeguard: If all lines were filtered out, preserve original base_coords
if not true_core_coords:
valid_base_coords = list(base_coords)
valid_line_components = list(line_components)
true_core_coords = [(s, e) for s, e in base_coords]
# Pre-Pass 1: Deduplicate heavily overlapping base line bands (IoM > 0.40)
if len(true_core_coords) > 1:
dedup_base = []
dedup_comps = []
dedup_core = []
for (bs, be), comps, (cs, ce) in zip(valid_base_coords, valid_line_components, true_core_coords):
h1 = max(1, ce - cs)
keep = True
for k_idx in range(len(dedup_base)):
k_cs, k_ce = dedup_core[k_idx]
h2 = max(1, k_ce - k_cs)
inter = max(0, min(ce, k_ce) - max(cs, k_cs))
iom = inter / min(h1, h2)
if iom > 0.40:
if (ce - cs) < (k_ce - k_cs):
dedup_base[k_idx] = (bs, be)
dedup_comps[k_idx] = comps
dedup_core[k_idx] = (cs, ce)
keep = False
break
if keep:
dedup_base.append((bs, be))
dedup_comps.append(comps)
dedup_core.append((cs, ce))
valid_base_coords = dedup_base
valid_line_components = dedup_comps
true_core_coords = dedup_core
base_coords = valid_base_coords
# Re-assign line_components based on true_core_coords to capture both top diacritics and main consonants
line_components = [set() for _ in true_core_coords]
for i in range(1, num_labels):
x, y, w, h, area = stats[i]
if area < 25 or h < 5 or w < 5 or area > 0.25 * img_h * img_w or h > 0.6 * img_h:
continue
cy = y + h / 2
for l_idx, (cs, ce) in enumerate(true_core_coords):
if cs - 8 <= cy <= ce + 20:
line_components[l_idx].add(i)
page_safe = True
for idx in range(len(base_coords) - 1):
if true_core_coords[idx+1][0] - true_core_coords[idx][1] < 0.15 * median_line_height:
page_safe = False
break
# Phase 1: Candidate Generation (Pass 1)
t_candidate = time.time()
pass1_candidates = []
lines_meta = []
for idx, (s, e) in enumerate(true_core_coords):
comps = line_components[idx]
if comps:
# Sort components horizontally by x position
sorted_comps = sorted(list(comps), key=lambda i: stats[i][0])
# Filter out non-text graphic background blobs (like scroll ends, clouds, background artwork)
text_comps = []
for i in sorted_comps:
x, y, w, h, area = stats[i]
if (area > 300 and y < 15) or (area > 500 and w / max(1, h) > 2.2 and y < 25) or (area > 1000 and y < 10):
continue
text_comps.append(i)
if not text_comps:
text_comps = sorted_comps
# Perform 1D Horizontal Adaptive Clustering
max_cluster_gap = max(25, min(45, int(1.0 * median_line_height)))
clusters = []
curr_cluster = []
for i in text_comps:
if not curr_cluster:
curr_cluster.append(i)
else:
prev_i = curr_cluster[-1]
prev_right = stats[prev_i][0] + stats[prev_i][2]
curr_left = stats[i][0]
if curr_left - prev_right <= max_cluster_gap:
curr_cluster.append(i)
else:
clusters.append(curr_cluster)
curr_cluster = [i]
if curr_cluster:
clusters.append(curr_cluster)
# Identify primary text cluster with highest ink area
if clusters:
main_c_idx = max(range(len(clusters)), key=lambda idx: sum(stats[i][4] for i in clusters[idx]))
main_c = clusters[main_c_idx]
main_left = min(stats[i][0] for i in main_c)
main_right = max(stats[i][0] + stats[i][2] for i in main_c)
# Prune isolated satellite outlier clusters (separated by > 1.2 * line_height from main text block)
outlier_gap = max(35, min(60, int(1.2 * median_line_height)))
best_cluster = []
for c in clusters:
c_left = min(stats[i][0] for i in c)
c_right = max(stats[i][0] + stats[i][2] for i in c)
if not (c_right < main_left - outlier_gap or c_left > main_right + outlier_gap):
best_cluster.extend(c)
else:
best_cluster = text_comps
xmin = min(stats[i][0] for i in best_cluster)
xmax = max(stats[i][0] + stats[i][2] for i in best_cluster)
ymin = min(stats[i][1] for i in best_cluster)
ymax = max(stats[i][1] + stats[i][3] for i in best_cluster)
else:
xmin, xmax = 0, img_w
ymin, ymax = int(s), int(e)
line_meta = {
"line_id": idx,
"core_box": [int(s), int(e)],
"bbox": [int(xmin), int(ymin), int(xmax), int(ymax)],
"needs_review": False,
"reason": [],
"candidates": {},
"overlap_prev_px": 0,
"overlap_next_px": 0
}
core_top = true_core_coords[idx][0]
core_bot = true_core_coords[idx][1]
prev_bot = true_core_coords[idx-1][1] if idx > 0 else 0
next_top = true_core_coords[idx+1][0] if idx < len(true_core_coords)-1 else img_h
gap_prev = core_top - prev_bot if idx > 0 else 999999
gap_next = next_top - core_bot if idx < len(true_core_coords)-1 else 999999
median_ink_height = median_line_height
is_gap_safe = min(gap_prev, gap_next) >= 0.35 * median_ink_height
valley_s, valley_e = base_coords[idx]
has_edge_ink_risk = False
if int(valley_s) > 0 and core_top <= int(valley_s) + 2:
has_edge_ink_risk = True
if int(valley_e) < img_h and core_bot >= int(valley_e) - 2:
has_edge_ink_risk = True
close_risk = False
if min(gap_prev, gap_next) < 0.25 * median_line_height:
close_risk = True
line_meta["reason"].append("very_close_lines_or_touching_diacritics")
pad_tight_top = int(0.05 * median_line_height)
pad_tight_bot = int(0.05 * median_line_height)
safety_margin_px = max(2, int(0.05 * median_line_height))
if close_risk or not page_safe:
safe_top = ymin - safety_margin_px
safe_bot = ymax + safety_margin_px
else:
safe_top = valley_s
safe_bot = valley_e
min_top_pad = int(0.40 * median_line_height)
min_bot_pad = int(0.40 * median_line_height)
safe_top = min(safe_top, core_top - min_top_pad)
safe_bot = max(safe_bot, core_bot + min_bot_pad)
if idx > 0:
safe_top = max(safe_top, true_core_coords[idx-1][1] + 2)
if idx < len(true_core_coords) - 1:
safe_bot = min(safe_bot, true_core_coords[idx+1][0] - 2)
pad_safe_top = int(core_top - safe_top)
pad_safe_bot = int(safe_bot - core_bot)
def clamp_crop(pad_t, pad_b):
c_s = max(0, core_top - pad_t)
c_e = min(img_h, core_bot + pad_b)
ov_prev = max(0, prev_bot - c_s) if idx > 0 else 0
ov_next = max(0, c_e - next_top) if idx < len(true_core_coords)-1 else 0
return (int(c_s), int(c_e), int(ov_prev), int(ov_next))
candidates_to_run = ["safe"]
for ctype in candidates_to_run:
if ctype == "safe":
c_s, c_e, o_p, o_n = clamp_crop(pad_safe_top, pad_safe_bot)
c_img = img[c_s:c_e, :].copy()
if ctype == "safe" and (close_risk or not page_safe):
components_to_erase = set()
for other_idx in range(len(true_core_coords)):
if other_idx != idx:
components_to_erase.update(line_components[other_idx])
components_to_erase.difference_update(line_components[idx])
# Fast LUT lookup instead of np.isin (prevents CPU freeze on large label sets)
erase_lut = np.zeros(num_labels, dtype=bool)
if components_to_erase:
erase_lut[list(components_to_erase)] = True
label_roi = labels[c_s:c_e, :]
erase_mask = erase_lut[label_roi]
c_img[erase_mask] = [255, 255, 255]
cand_obj = {
"line_id": idx,
"crop_type": ctype,
"crop_img": c_img,
"crop_box": [c_s, c_e],
"overlap_prev_px": o_p,
"overlap_next_px": o_n,
"prediction": "",
"confidence": 0.0,
"pred_length": 0
}
pass1_candidates.append(cand_obj)
line_meta["candidates"][ctype] = cand_obj
# Store metadata and flags for later
line_meta["close_risk"] = close_risk
line_meta["page_safe"] = page_safe
line_meta["is_gap_safe"] = is_gap_safe
line_meta["has_edge_ink_risk"] = has_edge_ink_risk
line_meta["has_ambiguous_cc"] = any(c in ambiguous_components for c in line_components[idx])
lines_meta.append(line_meta)
candidate_generation_sec = time.time() - t_candidate
# Phase 3: Pass 1 Batch Recognition
t_pass1 = time.time()
if pass1_candidates:
imgs = [preprocess_crop_for_rec(c["crop_img"]) for c in pass1_candidates]
# ocr_model accepts a list of images and returns list of (text, conf)
# However, PaddleOCR's predict() might fail if empty list or invalid dims.
valid_imgs = []
valid_indices = []
for i, m in enumerate(imgs):
if m.size > 0 and m.shape[0] >= 8 and m.shape[1] >= 8:
# Prevent aspect ratio blowup that causes CPU hang during inference resizing
scaled_w = int(m.shape[1] * 48 / m.shape[0])
if scaled_w > 2000:
# Downscale horizontally to protect the sequence processing layer
m = cv2.resize(m, (2000, m.shape[0]))
valid_imgs.append(m)
valid_indices.append(i)
if valid_imgs:
try:
print(f"DEBUG: [segment_lines_advanced] Calling ocr_model for Pass 1 ({len(valid_imgs)} images)...")
results, _ = ocr_model(valid_imgs)
print("DEBUG: [segment_lines_advanced] Pass 1 prediction finished.")
for vi, res in zip(valid_indices, results):
pred_text, conf = res if res else ("", 0.0)
pred_text = pred_text.strip()
pass1_candidates[vi]["prediction"] = pred_text
pass1_candidates[vi]["confidence"] = float(conf)
pass1_candidates[vi]["pred_length"] = len(pred_text)
except Exception as e:
print(f"DEBUG: [segment_lines_advanced] Batch inference pass 1 failed: {e}")
recognition_batch_pass1_sec = time.time() - t_pass1
# Phase 6: Two-Pass Fallback
t_pass2_gen = time.time()
pass2_candidates = []
for idx, line_meta in enumerate(lines_meta):
# Determine if we need Pass 2
cands = line_meta["candidates"]
best_conf = max([c["confidence"] for c in cands.values()]) if cands else 0.0
is_clean_line = line_meta["is_gap_safe"] and not line_meta["has_edge_ink_risk"]
needs_pass2 = False
if is_clean_line:
# Clean line: only run Pass 2 if confidence is below acceptable threshold (0.75)
if best_conf < 0.75:
needs_pass2 = True
else:
# Risky line: run Pass 2 if confidence is below high safety threshold (0.90)
if best_conf < 0.90:
needs_pass2 = True
if needs_pass2:
core_top = true_core_coords[idx][0]
core_bot = true_core_coords[idx][1]
prev_bot = true_core_coords[idx-1][1] if idx > 0 else 0
next_top = true_core_coords[idx+1][0] if idx < len(true_core_coords)-1 else img_h
pad_safe_top = int(core_top - cands["safe"]["crop_box"][0]) if "safe" in cands else 0
pad_safe_bot = int(cands["safe"]["crop_box"][1] - core_bot) if "safe" in cands else 0
pad_loose_top = int(0.80 * median_line_height)
pad_loose_bot = int(0.80 * median_line_height)
pad_tight_top = int(0.05 * median_line_height)
pad_tight_bot = int(0.05 * median_line_height)
pad_upper_rescue_top = pad_loose_top
pad_upper_rescue_bot = pad_safe_bot
pad_lower_rescue_top = pad_safe_top
pad_lower_rescue_bot = pad_loose_bot
def clamp_crop2(pad_t, pad_b):
c_s = max(0, core_top - pad_t)
c_e = min(img_h, core_bot + pad_b)
ov_prev = max(0, prev_bot - c_s) if idx > 0 else 0
ov_next = max(0, c_e - next_top) if idx < len(true_core_coords)-1 else 0
return (int(c_s), int(c_e), int(ov_prev), int(ov_next))
# We need to generate tight crop now since it wasn't generated in Pass 1
tight_s, tight_e, tight_op, tight_on = clamp_crop2(pad_tight_top, pad_tight_bot)
tight_img = img[tight_s:tight_e, :].copy()
tight_cand = {
"line_id": idx,
"crop_type": "tight",
"crop_img": tight_img,
"crop_box": [tight_s, tight_e],
"overlap_prev_px": tight_op,
"overlap_next_px": tight_on,
"prediction": "",
"confidence": 0.0,
"pred_length": 0
}
pass2_candidates.append(tight_cand)
line_meta["candidates"]["tight"] = tight_cand
for ctype, pt, pb in [
("upper_rescue", pad_upper_rescue_top, pad_upper_rescue_bot),
("lower_rescue", pad_lower_rescue_top, pad_lower_rescue_bot),
("loose", pad_loose_top, pad_loose_bot)
]:
c_s, c_e, o_p, o_n = clamp_crop2(pt, pb)
c_img = img[c_s:c_e, :].copy()
cand_obj = {
"line_id": idx,
"crop_type": ctype,
"crop_img": c_img,
"crop_box": [c_s, c_e],
"overlap_prev_px": o_p,
"overlap_next_px": o_n,
"prediction": "",
"confidence": 0.0,
"pred_length": 0
}
pass2_candidates.append(cand_obj)
line_meta["candidates"][ctype] = cand_obj
# Run Batch Pass 2 with coordinate-level deduplication
t_pass2_rec = time.time()
if pass2_candidates:
# Cache for coordinate-level deduplication: (s, e) -> (prediction, confidence)
seen_boxes = {}
# Pre-populate seen_boxes with Pass 1 (safe) results to avoid re-running identical boxes
for line_meta in lines_meta:
if "safe" in line_meta["candidates"]:
safe_c = line_meta["candidates"]["safe"]
seen_boxes[tuple(safe_c["crop_box"])] = (safe_c["prediction"], safe_c["confidence"])
exec_candidates = [] # candidates that require actual OCR execution
deferred_copies = [] # candidates that will copy results from an identical box
for cand in pass2_candidates:
c_box = tuple(cand["crop_box"])
if c_box in seen_boxes:
res = seen_boxes[c_box]
if res[0] != "" or res[1] > 0.0:
cand["prediction"] = res[0]
cand["confidence"] = res[1]
cand["pred_length"] = len(res[0])
else:
deferred_copies.append((cand, c_box))
else:
exec_candidates.append(cand)
seen_boxes[c_box] = ("", 0.0) # mark as pending
if exec_candidates:
imgs = [preprocess_crop_for_rec(c["crop_img"]) for c in exec_candidates]
valid_imgs = []
valid_indices = []
for i, m in enumerate(imgs):
if m.size > 0 and m.shape[0] >= 8 and m.shape[1] >= 8:
# Prevent aspect ratio blowup that causes CPU hang during inference resizing
scaled_w = int(m.shape[1] * 48 / m.shape[0])
if scaled_w > 2000:
# Downscale horizontally to protect the sequence processing layer
m = cv2.resize(m, (2000, m.shape[0]))
valid_imgs.append(m)
valid_indices.append(i)
if valid_imgs:
try:
print(f"DEBUG: [segment_lines_advanced] Calling ocr_model for Pass 2 ({len(valid_imgs)} images, deduplicated from {len(pass2_candidates)})...")
results, _ = ocr_model(valid_imgs)
print("DEBUG: [segment_lines_advanced] Pass 2 prediction finished.")
for vi, res in zip(valid_indices, results):
pred_text, conf = res if res else ("", 0.0)
pred_text = pred_text.strip()
exec_cand = exec_candidates[vi]
exec_cand["prediction"] = pred_text
exec_cand["confidence"] = float(conf)
exec_cand["pred_length"] = len(pred_text)
seen_boxes[tuple(exec_cand["crop_box"])] = (pred_text, float(conf))
except Exception as e:
print(f"DEBUG: [segment_lines_advanced] Batch inference pass 2 failed: {e}")
# Process deferred copies
for cand, c_box in deferred_copies:
pred_text, conf = seen_boxes[c_box]
cand["prediction"] = pred_text
cand["confidence"] = conf
cand["pred_length"] = len(pred_text)
recognition_batch_pass2_sec = time.time() - t_pass2_rec
# Selection and Finalization
t_select = time.time()
final_crops = []
for idx, line_meta in enumerate(lines_meta):
cand_data = line_meta["candidates"]
# Determine best candidate
eval_order = ["safe", "tight"]
for ctype in ["upper_rescue", "lower_rescue", "loose"]:
if ctype in cand_data:
eval_order.append(ctype)
selected_ctype = None
for ctype in ["safe", "tight"]:
if ctype in cand_data and cand_data[ctype]["confidence"] >= 0.90 and cand_data[ctype]["pred_length"] > 0:
selected_ctype = ctype
break
if selected_ctype is not None:
best_score = cand_data[selected_ctype]["confidence"]
cand_data[selected_ctype]["sanity_score"] = best_score
else:
run_lengths = [float(c["pred_length"]) for c in cand_data.values() if c["pred_length"] > 0]
median_len = float(np.median(run_lengths)) if run_lengths else 0.0
best_score = -1.0
selected_ctype = "safe" if "safe" in cand_data else "tight"
for ctype in eval_order:
if ctype not in cand_data:
continue
data = cand_data[ctype]
score = calculate_crop_sanity_score(data["confidence"], data["prediction"], median_len, data["crop_img"], ctype)
if ctype == "loose" and "safe" in cand_data:
safe_data = cand_data["safe"]
if safe_data["pred_length"] > 0:
ratio = data["pred_length"] / safe_data["pred_length"]
if ratio > 1.35:
score *= 0.3
if safe_data["confidence"] - data["confidence"] > 0.10:
score *= 0.4
tokens = data["prediction"].split()
single_chars = sum(1 for t in tokens if len(t) == 1)
if len(tokens) > 0 and single_chars / len(tokens) > 0.3:
score *= 0.5
if len(safe_data["prediction"]) > 0:
import difflib
similarity = difflib.SequenceMatcher(None, data["prediction"], safe_data["prediction"]).ratio()
if similarity < 0.5:
score *= 0.6
data["sanity_score"] = score
if score > best_score:
best_score = score
selected_ctype = ctype
if line_meta["page_safe"] and selected_ctype not in ["safe", "tight"]:
safe_score = cand_data.get("safe", {}).get("sanity_score", 0)
if safe_score > best_score * 0.9:
selected_ctype = "safe"
best_score = safe_score
line_meta["selected_crop_type"] = selected_ctype
line_meta["crop_sanity_score"] = best_score
sel_data = cand_data[selected_ctype]
# Flags
needs_review_reasons = []
if line_meta["has_ambiguous_cc"]:
needs_review_reasons.append("ambiguous_component_assignment")
if selected_ctype not in ["safe", "tight"]:
if sel_data["overlap_prev_px"] > 5 or sel_data["overlap_next_px"] > 5:
needs_review_reasons.append("adjacent_line_contamination")
# Only flag needs_review for multi-crop disagreement if important Cham signs change
# or if there is a significant confidence drop (>= 0.15) compared to safe crop
if len(cand_data) > 1 and "safe" in cand_data:
safe_pred = cand_data["safe"].get("prediction", "")
safe_conf = cand_data["safe"].get("confidence", 0.0)
has_cham_sign_diff = False
for ctype, cand in cand_data.items():
if ctype != "safe" and has_important_cham_sign_changes(safe_pred, cand.get("prediction", "")):
has_cham_sign_diff = True
break
if has_cham_sign_diff:
needs_review_reasons.append("important_cham_sign_change")
if safe_conf - sel_data["confidence"] >= 0.15:
needs_review_reasons.append("significant_confidence_drop")
line_meta["needs_review"] = len(needs_review_reasons) > 0
line_meta["reason"] = needs_review_reasons
if sel_data["confidence"] > 0.8:
line_meta["crop_confidence"] = "high"
elif sel_data["confidence"] > 0.5:
line_meta["crop_confidence"] = "medium"
else:
line_meta["crop_confidence"] = "low"
line_meta["overlap_prev_px"] = sel_data["overlap_prev_px"]
line_meta["overlap_next_px"] = sel_data["overlap_next_px"]
# Compatibility with downstream (app expects ["img"] in candidate)
for c in cand_data.values():
c["img"] = c["crop_img"]
final_crops.append(sel_data["crop_img"])
selection_sec = time.time() - t_select
total_sec = time.time() - t_start
print("\n[DEBUG lines_meta before NMS]:")
for m in lines_meta:
print(f" Line {m['line_id']}: bbox={m['bbox']}, core_box={m['core_box']}")
# Phase 7: Non-Text Background Line Pruning & 2D Vertical/Horizontal NMS
filtered_lines_meta = []
filtered_final_crops = []
for meta, crop in zip(lines_meta, final_crops):
sel_type = meta.get("selected_crop_type", "safe")
cand_sel = meta["candidates"].get(sel_type, {})
conf = cand_sel.get("confidence", 0.0)
pred = cand_sel.get("prediction", "").strip()
has_cham_char = any('\uAA00' <= char <= '\uAA5F' or '\uA800' <= char <= '\uA87F' for char in pred)
has_alphanumeric = any(char.isalnum() for char in pred)
# Strict filter out false positive lines from background artwork (dragons, elephants, ocean graphics)
# Only treat as fake background artwork if confidence < 0.35 AND neither Cham nor alphanumeric characters are found
is_fake_background = (conf < 0.35 and not has_cham_char and not has_alphanumeric)
if not is_fake_background and conf >= 0.15:
filtered_lines_meta.append(meta)
filtered_final_crops.append(crop)
# Safeguard: If all lines were filtered, preserve the single highest-confidence candidate line
if not filtered_lines_meta and lines_meta:
best_i = max(range(len(lines_meta)), key=lambda i: lines_meta[i]["candidates"].get(lines_meta[i].get("selected_crop_type", "safe"), {}).get("confidence", 0.0))
filtered_lines_meta = [lines_meta[best_i]]
filtered_final_crops = [final_crops[best_i]]
# Non-Maximum Suppression (NMS) with 2D IoM (checks both vertical and horizontal overlaps)
if len(filtered_lines_meta) > 1:
indexed_items = []
for meta, crop in zip(filtered_lines_meta, filtered_final_crops):
sel_type = meta.get("selected_crop_type", "safe")
cand_sel = meta["candidates"].get(sel_type, {})
conf = cand_sel.get("confidence", 0.0)
box = meta["core_box"]
bbox = meta.get("bbox", [0, box[0], img_w, box[1]])
indexed_items.append((conf, box, bbox, meta, crop))
indexed_items.sort(key=lambda x: x[0], reverse=True)
nms_kept = []
for conf, box, bbox, meta, crop in indexed_items:
s1, e1 = box
h1 = max(1, e1 - s1)
x1_min, _, x1_max, _ = bbox
w1 = max(1, x1_max - x1_min)
keep = True
for k_conf, k_box, k_bbox, _, _ in nms_kept:
s2, e2 = k_box
h2 = max(1, e2 - s2)
inter_s = max(s1, s2)
inter_e = min(e1, e2)
inter_y = max(0, inter_e - inter_s)
iom_y = inter_y / min(h1, h2)
# Check horizontal overlap to avoid suppressing side-by-side columns
x2_min, _, x2_max, _ = k_bbox
w2 = max(1, x2_max - x2_min)
inter_x = max(0, min(x1_max, x2_max) - max(x1_min, x2_min))
iom_x = inter_x / min(w1, w2)
# Only suppress if both vertical and horizontal overlaps are significant
if iom_y > 0.40 and iom_x > 0.30:
keep = False
break
if keep:
nms_kept.append((conf, box, bbox, meta, crop))
nms_kept.sort(key=lambda x: x[1][0])
filtered_lines_meta = [x[3] for x in nms_kept]
filtered_final_crops = [x[4] for x in nms_kept]
# Relative confidence pruning against max document confidence
if len(filtered_lines_meta) > 1:
max_doc_conf = max(
meta["candidates"].get(meta.get("selected_crop_type", "safe"), {}).get("confidence", 0.0)
for meta in filtered_lines_meta
)
if max_doc_conf > 0.60:
rel_filtered_meta = []
rel_filtered_crops = []
for meta, crop in zip(filtered_lines_meta, filtered_final_crops):
sel_type = meta.get("selected_crop_type", "safe")
cand_sel = meta["candidates"].get(sel_type, {})
conf = cand_sel.get("confidence", 0.0)
pred = cand_sel.get("prediction", "").strip()
has_cham_char = any('\uAA00' <= char <= '\uAA5F' or '\uA800' <= char <= '\uA87F' for char in pred)
# If a line has real Cham text, never prune it even if confidence is lower
# Only prune pure low-confidence non-Cham noise blobs
if conf < 0.35 and (max_doc_conf - conf > 0.35) and not has_cham_char:
continue
rel_filtered_meta.append(meta)
rel_filtered_crops.append(crop)
if rel_filtered_meta:
filtered_lines_meta = rel_filtered_meta
filtered_final_crops = rel_filtered_crops
lines_meta = filtered_lines_meta
final_crops = filtered_final_crops
# Phase 8: Profiler
print(f"""
⏱️ Profiler [segment_lines_advanced]:
- candidate_generation_sec: {candidate_generation_sec:.4f}s
- recognition_batch_pass1_sec: {recognition_batch_pass1_sec:.4f}s
- recognition_batch_pass2_sec: {recognition_batch_pass2_sec:.4f}s
- selection_sec: {selection_sec:.4f}s
- total_sec: {total_sec:.4f}s
- num_lines: {len(lines_meta)}
- num_candidates_pass1: {len(pass1_candidates)}
- num_candidates_pass2: {len(pass2_candidates)}
- ocr_model_calls: { (1 if pass1_candidates else 0) + (1 if pass2_candidates else 0) }
""")
profiler_dict = {
"candidate_generation_sec": round(candidate_generation_sec, 4),
"recognition_batch_pass1_sec": round(recognition_batch_pass1_sec, 4),
"recognition_batch_pass2_sec": round(recognition_batch_pass2_sec, 4),
"selection_sec": round(selection_sec, 4),
"total_segmentation_sec": round(total_sec, 4),
"num_lines": len(lines_meta),
"num_candidates_pass1": len(pass1_candidates),
"num_candidates_pass2": len(pass2_candidates),
"ocr_model_calls": (1 if pass1_candidates else 0) + (1 if pass2_candidates else 0)
}
return final_crops, lines_meta, profiler_dict
# ==============================================================================
# Web Server Request Handler
# ==============================================================================
class ChamOCRRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed_path = urllib.parse.urlparse(self.path).path
if parsed_path in ('/', '/index.html'):
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.end_headers()
dir_path = os.path.dirname(os.path.abspath(__file__))
html_path = os.path.join(dir_path, 'index.html')
if os.path.exists(html_path):
with open(html_path, 'r', encoding='utf-8') as f:
html_content = f.read()
else:
html_content = "<h1>Error: index.html not found!</h1>"
self.wfile.write(html_content.encode('utf-8'))
elif parsed_path in ('/research', '/research.html', '/methodology', '/paper'):
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'public, max-age=1800')
self.end_headers()
dir_path = os.path.dirname(os.path.abspath(__file__))
research_path = os.path.join(dir_path, 'research.html')
if os.path.exists(research_path):
with open(research_path, 'r', encoding='utf-8') as f:
content = f.read()
else:
content = "<h1>Error: research.html not found!</h1>"
self.wfile.write(content.encode('utf-8'))
elif parsed_path in ('/health', '/api/health'):
self.send_response(200)
self.send_header('Content-type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(b"OK")
else:
self.send_error(404, 'File Not Found')
def do_POST(self):
import time
t_start = time.time()
print(f"📥 Received POST request: {self.path}...")
if self.path == '/log':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
try:
log_data = json.loads(post_data.decode('utf-8'))
print(f"\n🚨 [FRONTEND LOG] {log_data.get('message')}\n")
except Exception as e:
print(f"Failed to parse log: {e}")
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({"status": "success"}).encode('utf-8'))
return
if self.path == '/ocr':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
print(f"⏱️ Read request body in: {time.time() - t_start:.4f}s")
t_parse = time.time()
data = json.loads(post_data.decode('utf-8'))
# Extract parameters
img_b64 = data['image']
model_ver = data.get('model', 'v24')
method = data.get('method', 'dbnet')
threshold = float(data.get('threshold', 0.05))
gap = int(data.get('gap', 12))
win = int(data.get('window', 25))
# Decode image
if ',' in img_b64:
img_b64 = img_b64.split(',', 1)[1]
missing_padding = len(img_b64) % 4
if missing_padding:
img_b64 += '=' * (4 - missing_padding)
try:
img_data = base64.b64decode(img_b64)
nparr = np.frombuffer(img_data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
except Exception as e:
print(f"❌ Error decoding image: {e}")
self.send_json_response({'error': f'Unable to decode image data: {e}'}, 400)
return
print(f"⏱️ Parsed parameters and decoded image in: {time.time() - t_parse:.4f}s")
if img is None:
self.send_json_response({'error': 'Unable to read image (corrupted base64 payload)'}, 400)
return
# Perform segmentation
t_model = time.time()
try:
ocr = get_ocr_model(model_ver)
except Exception as e:
import traceback
print(f"❌ Error loading OCR model '{model_ver}': {traceback.format_exc()}")
self.send_json_response({'error': f'Model loading failure ({model_ver}): {e}'}, 200)
return
print(f"⏱️ Loaded model in: {time.time() - t_model:.4f}s")
t_seg = time.time()
base_segmentation_sec = 0.0
profiler_dict = {
'method': method,
'base_segmentation_sec': 0.0,
'legacy_fast_pass_count': 0,
'pass2_rescue_count': 0
}
if method == 'dbnet':
print("🔍 Running PaddleOCR DBNet segmentation...")
try:
det = get_det_model()
final_crops, lines_metadata = segment_lines_dbnet(img, det)
except Exception as e:
import traceback
print(f"❌ DBNet segmentation error: {traceback.format_exc()}")
final_crops, lines_metadata = [], []
base_segmentation_sec = time.time() - t_seg
if not final_crops:
print("⚠️ DBNet found 0 text lines, falling back to Valley segmentation...")
_, coords = segment_lines_valleys(img, window_size=win, min_dist=gap)
final_crops, lines_metadata, profiler_dict = segment_lines_advanced(img, coords, ocr)
else:
t_rec = time.time()
rec_crops = [preprocess_crop_for_rec(c) for c in final_crops]
rec_res, _ = ocr(rec_crops)
rec_sec = time.time() - t_rec
print(f"⏱️ DBNet batch recognition ({len(final_crops)} lines) took: {rec_sec:.4f}s")
for idx, (pred_text, conf) in enumerate(rec_res):
lines_metadata[idx]['candidates']['dbnet']['prediction'] = pred_text
lines_metadata[idx]['candidates']['dbnet']['confidence'] = conf
profiler_dict['base_segmentation_sec'] = round(base_segmentation_sec, 4)
profiler_dict['rec_inference_sec'] = round(rec_sec, 4)
profiler_dict['num_lines'] = len(final_crops)
profiler_dict['total_segmentation_sec'] = round(base_segmentation_sec, 4)
profiler_dict['ocr_model_calls'] = 1
else:
if method == 'valley':
_, coords = segment_lines_valleys(img, window_size=win, min_dist=gap)
else:
_, coords = segment_lines_adaptive(img, threshold_pct=threshold, gap_threshold=gap)
base_segmentation_sec = time.time() - t_seg
print(f"⏱️ Base line segmentation took: {base_segmentation_sec:.4f}s")
t_adv = time.time()
# Apply Advanced Segmentation and Multi-Crop
final_crops, lines_metadata, profiler_dict = segment_lines_advanced(img, coords, ocr)
print(f"⏱️ segment_lines_advanced took: {time.time() - t_adv:.4f}s")
t_resp = time.time()
# Prepare backward-compatible response
results = []
try:
for idx, (line_img, meta) in enumerate(zip(final_crops, lines_metadata)):
pred_text = meta['candidates'][meta['selected_crop_type']]['prediction']
confidence = meta['candidates'][meta['selected_crop_type']]['confidence']
if model_ver in ['v24', 'v25', 'v26']:
try:
from scripts.generate_data import normalize_unicode
pred_text = normalize_unicode(pred_text)
except Exception as e:
print(f"Warning: normalize_unicode failed: {e}")
# Encode cropped line to base64 for frontend display
_, buffer = cv2.imencode('.png', line_img)
line_b64 = base64.b64encode(buffer).decode('utf-8')
# Backward-compatible format
print(f"📥 [DO_POST RAW META] line_id={meta.get('line_id')}, bbox={meta.get('bbox')}, keys={list(meta.keys())}")
results.append({
'index': idx + 1,
'text': pred_text,
'confidence': float(confidence),
'coords': meta['core_box'],
'bbox': meta.get('bbox', [0, meta['core_box'][0], img.shape[1], meta['core_box'][1]]),
'polygon': meta.get('polygon'),
'image': line_b64,
'segmentation': {
'overlap_prev_px': meta.get('overlap_prev_px', 0),
'overlap_next_px': meta.get('overlap_next_px', 0),
'crop_confidence': meta.get('crop_confidence', 1.0),
'selected_crop_type': meta.get('selected_crop_type', 'standard'),
'crop_sanity_score': meta.get('crop_sanity_score', 1.0),
'needs_review': meta.get('needs_review', False),
'reason': meta.get('reason', '')
}
})
print(f"📥 [DO_POST RESULT] Line {meta['line_id']+1}: text='{pred_text[:15]}...', conf={confidence:.4f}, bbox={meta.get('bbox')}")
except Exception as e:
import traceback
print(f"❌ Error during OCR formatting: {traceback.format_exc()}")
self.send_json_response({'error': f'OCR transcription engine error: {e}'}, 200)
return
print(f"⏱️ Formatted response in: {time.time() - t_resp:.4f}s")
t_send = time.time()
profiler_dict['backend_total_sec'] = round(time.time() - t_start, 4)
profiler_dict['base_segmentation_sec'] = round(base_segmentation_sec, 4)
self.send_json_response({'lines': results, 'profiler': profiler_dict})
print(f"⏱️ Sent response in: {time.time() - t_send:.4f}s")
print(f"🎉 Total POST /ocr execution time: {profiler_dict['backend_total_sec']:.4f}s")
elif self.path == '/save_correction':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
text = data.get('text', '')
dest_file = os.path.join(PROJECT_ROOT, "data", "ocr_corrections.txt")
os.makedirs(os.path.dirname(dest_file), exist_ok=True)
with open(dest_file, 'a', encoding='utf-8', newline='\n') as f:
f.write(text.strip() + '\n')
self.send_json_response({'success': True, 'path': dest_file})
elif self.path == '/ocr_crop':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
img_b64 = data.get('image', '')
model_ver = data.get('model', 'v24')
bbox = data.get('bbox', [0, 0, 0, 0])
if ',' in img_b64:
img_b64 = img_b64.split(',', 1)[1]
missing_padding = len(img_b64) % 4
if missing_padding:
img_b64 += '=' * (4 - missing_padding)
try:
img_data = base64.b64decode(img_b64)
nparr = np.frombuffer(img_data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
except Exception as e:
self.send_json_response({'error': f'Unable to decode image data: {e}'}, 400)
return
if img is None:
self.send_json_response({'error': 'Unable to read image'}, 400)
return
h, w = img.shape[:2]
x1, y1, x2, y2 = [int(v) for v in bbox]
x1 = max(0, min(w - 1, x1))
x2 = max(x1 + 1, min(w, x2))
y1 = max(0, min(h - 1, y1))
y2 = max(y1 + 1, min(h, y2))
crop_img = img[y1:y2, x1:x2]
if crop_img.size == 0:
self.send_json_response({'error': 'Empty cropped region'}, 400)
return
try:
ocr = get_ocr_model(model_ver)
res, _ = ocr([crop_img])
pred_text, conf = res[0] if res else ("", 0.0)
except Exception as e:
self.send_json_response({'error': f'Cropped OCR transcription error ({model_ver}): {e}'}, 200)
return
if model_ver in ['v24', 'v25', 'v26']:
try:
from scripts.generate_data import normalize_unicode
pred_text = normalize_unicode(pred_text)
except Exception as e:
print(f"Warning: normalize_unicode failed: {e}")
_, buffer = cv2.imencode('.png', crop_img)
crop_b64 = base64.b64encode(buffer).decode('utf-8')
self.send_json_response({
'text': pred_text,
'confidence': float(conf),
'crop_image': f"data:image/png;base64,{crop_b64}",
'bbox': [x1, y1, x2, y2]
})
else:
self.send_error(404, 'Endpoint Not Found')
def send_json_response(self, data, status=200):
self.send_response(status)
self.send_header('Content-type', 'application/json; charset=utf-8')
# Allow Cross-Origin for flexibility
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode('utf-8'))
# ==============================================================================
# HTML Dashboard Frontend Template
# ==============================================================================
HTML_TEMPLATE = ""
def run_server():
env_port = int(os.getenv("PORT", "7860"))
preferred_ports = [env_port, 7860, 8080, 8081, 8082, 8501, 8888, 0]
# Remove duplicate ports while preserving order
seen = set()
preferred_ports = [p for p in preferred_ports if not (p in seen or seen.add(p))]
httpd = None
actual_port = None
for port in preferred_ports:
try:
server_address = ('0.0.0.0', port)
httpd = HTTPServer(server_address, ChamOCRRequestHandler)
actual_port = httpd.server_address[1]
break
except OSError:
print(f"⚠️ Port {port} is occupied, trying next...")
continue
if httpd is None:
print("❌ Could not bind to any port.")
return
print(f"======================================================================")
print(f"🚀 Cham OCR Diagnostic Studio is running at: http://localhost:{actual_port}")
print(f"📁 Corrections will be saved to: ocr-studio/data/ocr_corrections.txt")
print(f"======================================================================")
try:
get_ocr_model('v24')
except Exception as e:
print(f"⚠️ Could not pre-load model v24: {e}. It will load when requested.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopping server...")
httpd.server_close()
if __name__ == '__main__':
run_server()