"""Offline PP-OCRv6 adapter for SnapOtter's isolated OCR runtime. This module deliberately has no import-time dependency on RapidOCR, OpenCV, or ONNX Runtime. Model and provider validation happens first; the pinned runtime libraries are imported only when an OCR session is actually created. """ from __future__ import annotations import importlib.metadata import json import math import os import re import tempfile import unicodedata from dataclasses import dataclass, replace from difflib import SequenceMatcher from pathlib import Path from typing import Any, Mapping, MutableMapping, Protocol, Sequence MAX_BOXES = 1_000 # Auto language reuses unified-recognizer crops for the Korean second pass. # Bound their retained RGB footprint to roughly 48 MiB before any crop is made. MAX_AUTO_KOREAN_CROP_PIXELS = 16_000_000 AUTO_KOREAN_RECOGNITION_BATCH_SIZE = 16 MAX_IMAGE_OCR_OUTPUT_BYTES = 1_000_000 # Kept in lockstep with packages/ai/src/tesseract-pdf.ts. This byte budget # includes page headings and separators for both Fast and accurate PDF OCR. MAX_PDF_OCR_OUTPUT_BYTES = 1_000_000 MAX_OUTPUT_BYTES_PER_MEGAPIXEL = 200_000 MIN_IMAGE_OUTPUT_BYTES = 10_000 MAX_VARIANT_PIXELS = 40_000_000 MAX_INPUT_SIDE = 40_000 MAX_DETECTOR_TILES = 64 MAX_ROTATION_CANDIDATES = 2 MAX_DETECTOR_TILE_SIDE = 1_536 MIN_DETECTOR_TILE_SIDE = 32 DETECTOR_TILE_OVERLAP = 256 MAX_ONNX_INTRA_OP_THREADS = 4 ONNX_INTER_OP_THREADS = 1 EXPECTED_RAPIDOCR_VERSION = "3.9.1" EXPECTED_ONNXRUNTIME_VERSION = "1.20.1" SUPPORTED_QUALITIES = frozenset(("balanced", "best")) SUPPORTED_LANGUAGES = frozenset(("auto", "en", "de", "fr", "es", "zh", "ja", "ko")) RASTER_SUFFIXES = frozenset((".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp")) MODEL_FILENAMES: Mapping[str, str] = { "small_det": "PP-OCRv6_det_small.onnx", "small_rec": "PP-OCRv6_rec_small.onnx", "medium_det": "PP-OCRv6_det_medium.onnx", "medium_rec": "PP-OCRv6_rec_medium.onnx", "korean_rec": "korean_PP-OCRv5_mobile_rec.onnx", "textline_orientation": "PP-LCNet_x0_25_textline_ori.onnx", "document_orientation": "PP-LCNet_x1_0_doc_ori.onnx", "unified_dict": "ppocrv6_dict.txt", "korean_dict": "korean_dict.txt", } _SMOKE_GLYPHS: Mapping[str, tuple[str, ...]] = { " ": ("00000",) * 7, "0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"), "5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"), "A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"), "E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"), "N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"), "O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"), "P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"), "R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"), "S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"), "T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"), } def _functional_smoke_fixture() -> bytes: """Return a dependency-free, text-bearing PGM used for activation inference.""" width, height, scale = 1024, 192, 12 pixels = bytearray([255]) * (width * height) left, top = 40, 48 for character in "SNAPOTTER 505": glyph = _SMOKE_GLYPHS[character] for row, values in enumerate(glyph): for column, value in enumerate(values): if value != "1": continue x_start = left + column * scale y_start = top + row * scale for y in range(y_start, y_start + scale): offset = y * width + x_start pixels[offset : offset + scale] = b"\x00" * scale left += 6 * scale return f"P5\n{width} {height}\n255\n".encode("ascii") + pixels class OcrLimitError(RuntimeError): """The OCR engine produced output beyond a configured safety ceiling.""" @dataclass(frozen=True) class RawLine: text: str confidence: float polygon: tuple[tuple[float, float], ...] @dataclass(frozen=True) class RawRecognition: text: str confidence: float @dataclass(frozen=True) class RawCandidate: lines: tuple[RawLine, ...] width: int height: int crops: tuple[Any, ...] = () @dataclass(frozen=True) class OrientationPrediction: angle: int confidence: float class OrientationClassifier(Protocol): def initialize(self) -> None: ... def classify(self, image_path: Path) -> OrientationPrediction: ... class ImageVariants(Protocol): def rotate(self, source: Any, angle: int) -> Any: ... def enhance(self, source: Any) -> Any: ... class OcrBackend(Protocol): def recognize(self, image_input: Any, *, retain_crops: bool = False) -> RawCandidate: ... def recognize_crops(self, crops: tuple[Any, ...]) -> tuple[RawRecognition, ...]: ... class BackendFactory(Protocol): def create( self, *, tier: str, recognizer: str, paths: Mapping[str, Path], ) -> OcrBackend: ... @dataclass(frozen=True) class SelectorCalibration: confidence_coverage: float mean_confidence: float polygon_coherence: float reading_order_coherence: float variant_policy: "VariantPolicy | None" = None @dataclass(frozen=True) class VariantPolicy: orientation_min_confidence: float orientation_min_score_gain: float preprocess_min_score_gain: float @dataclass(frozen=True) class CandidateChoice: candidate: RawCandidate source: Any source_label: str warnings: tuple[str, ...] = () @dataclass class _VariantPlan: rotations_ready: bool = False rotations: tuple[tuple[int, Any], ...] = () enhanced: dict[str, Any] | None = None def __post_init__(self) -> None: if self.enhanced is None: self.enhanced = {} def _onnx_thread_counts() -> tuple[int, int]: affinity = getattr(os, "sched_getaffinity", None) if affinity is not None: try: available_cpus = len(affinity(0)) except (OSError, NotImplementedError): pass else: if available_cpus > 0: return min(available_cpus, MAX_ONNX_INTRA_OP_THREADS), ONNX_INTER_OP_THREADS available_cpus = os.cpu_count() or 1 intra_op_threads = min(max(available_cpus, 1), MAX_ONNX_INTRA_OP_THREADS) return intra_op_threads, ONNX_INTER_OP_THREADS def _is_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def _normalize_polygon(value: Any) -> tuple[tuple[float, float], ...]: try: points = tuple((float(point[0]), float(point[1])) for point in value) except (TypeError, ValueError, IndexError) as error: raise RuntimeError("RapidOCR returned a malformed text polygon") from error if len(points) != 4 or not all(math.isfinite(v) for point in points for v in point): raise RuntimeError("RapidOCR returned a malformed text polygon") return points def _shared_crop_pixels(polygon: tuple[tuple[float, float], ...]) -> int: top_width = math.hypot( polygon[1][0] - polygon[0][0], polygon[1][1] - polygon[0][1], ) bottom_width = math.hypot( polygon[2][0] - polygon[3][0], polygon[2][1] - polygon[3][1], ) left_height = math.hypot( polygon[3][0] - polygon[0][0], polygon[3][1] - polygon[0][1], ) right_height = math.hypot( polygon[2][0] - polygon[1][0], polygon[2][1] - polygon[1][1], ) width = max(1, math.ceil(max(top_width, bottom_width))) height = max(1, math.ceil(max(left_height, right_height))) return width * height def _candidate_from_rapidocr( result: Any, cropper: Any, source: Any = None, *, retain_crops: bool = False, ) -> RawCandidate: raw_boxes = getattr(result, "boxes", None) raw_texts = getattr(result, "txts", None) raw_scores = getattr(result, "scores", None) image = getattr(result, "img", None) shape = getattr(image, "shape", None) if shape is None or len(shape) < 2: # RapidOCR 3.9.1 deliberately returns img/boxes/txts/scores=None when # detection finds no text. Recover only that documented empty shape # from the bounded source decoder; malformed non-empty output remains # a hard failure. if raw_boxes is None and raw_texts is None and raw_scores is None and source is not None: fallback_shape = getattr(source, "shape", None) fallback = ( source if fallback_shape is not None and len(fallback_shape) >= 2 else CvImageVariants._load(source) ) height, width = int(fallback.shape[0]), int(fallback.shape[1]) return RawCandidate(lines=(), width=width, height=height, crops=()) raise RuntimeError("RapidOCR did not return source image dimensions") height, width = int(shape[0]), int(shape[1]) if width <= 0 or height <= 0: raise RuntimeError("RapidOCR returned invalid source image dimensions") if raw_boxes is None and raw_texts is None and raw_scores is None: return RawCandidate(lines=(), width=width, height=height, crops=()) if raw_boxes is None or raw_texts is None or raw_scores is None: raise RuntimeError("RapidOCR returned an incomplete OCR result") if not (len(raw_boxes) == len(raw_texts) == len(raw_scores)): raise RuntimeError("RapidOCR returned mismatched OCR result lengths") if len(raw_boxes) > MAX_BOXES: raise OcrLimitError(f"OCR output exceeded the {MAX_BOXES} boxes limit") lines = [] crops = [] retained_crop_pixels = 0 for raw_box, raw_text, raw_score in zip(raw_boxes, raw_texts, raw_scores): if not isinstance(raw_text, str) or not _is_number(raw_score): raise RuntimeError("RapidOCR returned malformed recognition output") polygon = _normalize_polygon(raw_box) lines.append(RawLine(raw_text, float(raw_score), polygon)) if retain_crops: crop_pixels = _shared_crop_pixels(polygon) if retained_crop_pixels + crop_pixels > MAX_AUTO_KOREAN_CROP_PIXELS: raise OcrLimitError( "OCR shared Korean crop pixels exceeded the " f"{MAX_AUTO_KOREAN_CROP_PIXELS} pixels limit" ) crops.append(cropper(image, raw_box)) retained_crop_pixels += crop_pixels return RawCandidate(tuple(lines), width, height, tuple(crops)) @dataclass(frozen=True) class _TileAxis: start: int stop: int owned_start: float owned_stop: float @dataclass(frozen=True) class _TiledLine: line: RawLine crop: Any crop_pixels: int tile_id: tuple[int, int] owned: bool interior_margin: float def _tile_axis(length: int) -> tuple[_TileAxis, ...]: if length <= MAX_DETECTOR_TILE_SIDE: return (_TileAxis(0, length, 0.0, float(length)),) stride = MAX_DETECTOR_TILE_SIDE - DETECTOR_TILE_OVERLAP starts = list(range(0, length - MAX_DETECTOR_TILE_SIDE + 1, stride)) final_start = length - MAX_DETECTOR_TILE_SIDE if starts[-1] != final_start: starts.append(final_start) axes = [] for index, start in enumerate(starts): stop = min(start + MAX_DETECTOR_TILE_SIDE, length) owned_start = ( 0.0 if index == 0 else (start + min(starts[index - 1] + MAX_DETECTOR_TILE_SIDE, length)) / 2 ) owned_stop = ( float(length) if index == len(starts) - 1 else (stop + starts[index + 1]) / 2 ) axes.append(_TileAxis(start, stop, owned_start, owned_stop)) return tuple(axes) def _pad_detector_input(image: Any) -> Any: """Pad tiny dimensions without scaling so RapidOCR never expands the long side.""" height, width = int(image.shape[0]), int(image.shape[1]) pad_bottom = max(0, MIN_DETECTOR_TILE_SIDE - height) pad_right = max(0, MIN_DETECTOR_TILE_SIDE - width) if pad_bottom == 0 and pad_right == 0: return image import cv2 padded = cv2.copyMakeBorder( image, 0, pad_bottom, 0, pad_right, cv2.BORDER_REPLICATE, ) expected_shape = (height + pad_bottom, width + pad_right) if tuple(int(value) for value in padded.shape[:2]) != expected_shape: raise RuntimeError("OCR detector padding returned unexpected dimensions") return padded def _restore_detector_extent(candidate: RawCandidate, width: int, height: int) -> RawCandidate: """Remove padding-only detections and restore the native tile dimensions.""" if candidate.width == width and candidate.height == height: return candidate lines = [] crops = [] has_crops = bool(candidate.crops) for index, line in enumerate(candidate.lines): left, top, right, bottom = _bounds(line) center_x = (left + right) / 2 center_y = (top + bottom) / 2 if not (0 <= center_x < width and 0 <= center_y < height): continue lines.append( replace( line, polygon=tuple( ( min(max(x, 0.0), float(width)), min(max(y, 0.0), float(height)), ) for x, y in line.polygon ), ) ) if has_crops: crops.append(candidate.crops[index]) return RawCandidate(tuple(lines), width, height, tuple(crops)) def _translate_line(line: RawLine, left: int, top: int) -> RawLine: return replace( line, polygon=tuple((x + left, y + top) for x, y in line.polygon), ) def _normalized_line_text(text: str) -> str: return re.sub(r"[^\w]+", "", text.casefold(), flags=re.UNICODE) def _cross_tile_duplicate(left: _TiledLine, right: _TiledLine) -> bool: left_bounds = _bounds(left.line) right_bounds = _bounds(right.line) overlap_width = max( 0.0, min(left_bounds[2], right_bounds[2]) - max(left_bounds[0], right_bounds[0]) ) overlap_height = max( 0.0, min(left_bounds[3], right_bounds[3]) - max(left_bounds[1], right_bounds[1]) ) left_area = max( (left_bounds[2] - left_bounds[0]) * (left_bounds[3] - left_bounds[1]), 1.0 ) right_area = max( (right_bounds[2] - right_bounds[0]) * (right_bounds[3] - right_bounds[1]), 1.0 ) overlap_ratio = overlap_width * overlap_height / min(left_area, right_area) if overlap_ratio < 0.5: return False left_text = _normalized_line_text(left.line.text) right_text = _normalized_line_text(right.line.text) if not left_text or not right_text: return False shorter, longer = sorted((left_text, right_text), key=len) similarity = SequenceMatcher(None, left_text, right_text, autojunk=False) longest_match = max((block.size for block in similarity.get_matching_blocks()), default=0) return ( left_text == right_text or (len(shorter) >= 3 and shorter in longer) or similarity.ratio() >= 0.72 or ( overlap_ratio >= 0.8 and min(left.interior_margin, right.interior_margin) <= 2 and longest_match >= 3 and longest_match / len(shorter) >= 0.4 ) ) def _tiled_line_priority(entry: _TiledLine) -> tuple[bool, int, float, float]: return ( entry.owned, len(_normalized_line_text(entry.line.text)), min(max(entry.line.confidence, 0.0), 1.0), entry.interior_margin, ) class _RapidOcrBackend: def __init__(self, engine: Any, cropper: Any, recognition_input_type: Any): self._engine = engine self._cropper = cropper self._recognition_input_type = recognition_input_type def _recognize_extent( self, image: Any, width: int, height: int, *, retain_crops: bool, ) -> RawCandidate: detector_input = _pad_detector_input(image) detector_height, detector_width = ( int(detector_input.shape[0]), int(detector_input.shape[1]), ) if ( detector_height < MIN_DETECTOR_TILE_SIDE or detector_width < MIN_DETECTOR_TILE_SIDE or detector_height > MAX_DETECTOR_TILE_SIDE or detector_width > MAX_DETECTOR_TILE_SIDE ): raise OcrLimitError("OCR detector input dimensions escaped the bounded tile range") candidate = _candidate_from_rapidocr( self._engine(detector_input), self._cropper, detector_input, retain_crops=retain_crops, ) if candidate.width != detector_width or candidate.height != detector_height: raise RuntimeError("RapidOCR returned dimensions that do not match its detector input") return _restore_detector_extent(candidate, width, height) def recognize(self, image_input: Any, *, retain_crops: bool = False) -> RawCandidate: image = CvImageVariants._load(image_input) height, width = int(image.shape[0]), int(image.shape[1]) if width <= 0 or height <= 0: raise OcrLimitError("OCR input has invalid image dimensions") if width > MAX_INPUT_SIDE or height > MAX_INPUT_SIDE: raise OcrLimitError( f"OCR input exceeds the {MAX_INPUT_SIDE:,} pixel dimension safety limit" ) if width * height > MAX_VARIANT_PIXELS: raise OcrLimitError( f"OCR input exceeds the {MAX_VARIANT_PIXELS:,} pixel safety limit" ) x_axes = _tile_axis(width) y_axes = _tile_axis(height) tile_count = len(x_axes) * len(y_axes) if tile_count > MAX_DETECTOR_TILES: raise OcrLimitError( f"OCR detector tiling exceeded the {MAX_DETECTOR_TILES} tile safety limit" ) if len(x_axes) == 1 and len(y_axes) == 1: return self._recognize_extent( image, width, height, retain_crops=retain_crops, ) merged: list[_TiledLine] = [] retained_crop_pixels = 0 for y_index, y_axis in enumerate(y_axes): for x_index, x_axis in enumerate(x_axes): tile = image[y_axis.start : y_axis.stop, x_axis.start : x_axis.stop] tile_height = y_axis.stop - y_axis.start tile_width = x_axis.stop - x_axis.start candidate = self._recognize_extent( tile, tile_width, tile_height, retain_crops=retain_crops, ) for line_index, line in enumerate(candidate.lines): local_left, local_top, local_right, local_bottom = _bounds(line) center_x = x_axis.start + (local_left + local_right) / 2 center_y = y_axis.start + (local_top + local_bottom) / 2 entry = _TiledLine( line=_translate_line(line, x_axis.start, y_axis.start), crop=(candidate.crops[line_index] if retain_crops else None), crop_pixels=(_shared_crop_pixels(line.polygon) if retain_crops else 0), tile_id=(x_index, y_index), owned=( x_axis.owned_start <= center_x < x_axis.owned_stop and y_axis.owned_start <= center_y < y_axis.owned_stop ), interior_margin=min( local_left, tile_width - local_right, local_top, tile_height - local_bottom, ), ) duplicate_index = next( ( index for index, existing in enumerate(merged) if existing.tile_id != entry.tile_id and _cross_tile_duplicate(existing, entry) ), None, ) if duplicate_index is None: if ( retained_crop_pixels + entry.crop_pixels > MAX_AUTO_KOREAN_CROP_PIXELS ): raise OcrLimitError( "OCR shared Korean crop pixels exceeded the " f"{MAX_AUTO_KOREAN_CROP_PIXELS} pixels limit" ) merged.append(entry) retained_crop_pixels += entry.crop_pixels if len(merged) > MAX_BOXES: raise OcrLimitError(f"OCR output exceeded the {MAX_BOXES} boxes limit") elif _tiled_line_priority(entry) > _tiled_line_priority( merged[duplicate_index] ): next_crop_pixels = ( retained_crop_pixels - merged[duplicate_index].crop_pixels + entry.crop_pixels ) if next_crop_pixels > MAX_AUTO_KOREAN_CROP_PIXELS: raise OcrLimitError( "OCR shared Korean crop pixels exceeded the " f"{MAX_AUTO_KOREAN_CROP_PIXELS} pixels limit" ) merged[duplicate_index] = entry retained_crop_pixels = next_crop_pixels merged.sort(key=lambda entry: _position(entry.line)) return RawCandidate( lines=tuple(entry.line for entry in merged), width=width, height=height, crops=tuple(entry.crop for entry in merged) if retain_crops else (), ) def recognize_crops(self, crops: tuple[Any, ...]) -> tuple[RawRecognition, ...]: if not crops: return () output = [] for start in range(0, len(crops), AUTO_KOREAN_RECOGNITION_BATCH_SIZE): batch = crops[start : start + AUTO_KOREAN_RECOGNITION_BATCH_SIZE] classified = self._engine.text_cls(list(batch)) oriented_crops = getattr(classified, "img_list", None) if oriented_crops is None or len(oriented_crops) != len(batch): raise RuntimeError("RapidOCR returned mismatched shared-crop orientation output") result = self._engine.text_rec( self._recognition_input_type(img=oriented_crops, return_word_box=False) ) texts = getattr(result, "txts", None) scores = getattr(result, "scores", None) if ( texts is None or scores is None or len(texts) != len(batch) or len(scores) != len(batch) ): raise RuntimeError("RapidOCR returned mismatched shared-crop recognition output") for text, score in zip(texts, scores): if not isinstance(text, str) or not _is_number(score): raise RuntimeError( "RapidOCR returned malformed shared-crop recognition output" ) output.append(RawRecognition(text, float(score))) return tuple(output) class RapidOcrBackendFactory: """Construct RapidOCR 3.9.1 CPU sessions with only explicit local models.""" def __init__(self, environ: Mapping[str, str]): self._environ = environ self._libraries: tuple[Any, Any, Any, Any] | None = None def _load_libraries(self) -> tuple[Any, Any, Any, Any]: if self._libraries is not None: return self._libraries expected_rapidocr = self._environ.get( "SNAPOTTER_OCR_EXPECTED_RAPIDOCR_VERSION", EXPECTED_RAPIDOCR_VERSION ) expected_ort = self._environ.get( "SNAPOTTER_OCR_EXPECTED_ONNXRUNTIME_VERSION", EXPECTED_ONNXRUNTIME_VERSION ) rapidocr_version = importlib.metadata.version("rapidocr") ort_version = importlib.metadata.version("onnxruntime") if rapidocr_version != expected_rapidocr: raise RuntimeError( f"Unsupported RapidOCR version {rapidocr_version}; expected {expected_rapidocr}" ) if ort_version != expected_ort: raise RuntimeError( f"Unsupported ONNX Runtime version {ort_version}; expected {expected_ort}" ) from rapidocr import RapidOCR from rapidocr.ch_ppocr_rec.typings import TextRecInput from rapidocr.utils.download_file import DownloadFile from rapidocr.utils.process_img import get_rotate_crop_image from rapidocr.utils.typings import OCRVersion def blocked_download(_class: Any, *_args: Any, **_kwargs: Any) -> None: raise RuntimeError("OCR runtime model downloads are disabled") # Explicit model paths below mean this should never be called. Replacing # the downloader makes that invariant fail closed if RapidOCR regresses. DownloadFile.run = classmethod(blocked_download) self._libraries = (RapidOCR, TextRecInput, get_rotate_crop_image, OCRVersion) return self._libraries def create( self, *, tier: str, recognizer: str, paths: Mapping[str, Path], ) -> OcrBackend: if tier not in ("small", "medium") or recognizer not in ("unified", "korean"): raise ValueError("Unsupported RapidOCR backend profile") RapidOCR, recognition_input_type, cropper, ocr_version = self._load_libraries() rec_path = paths["korean_rec"] if recognizer == "korean" else paths[f"{tier}_rec"] rec_dict = paths["korean_dict"] if recognizer == "korean" else paths["unified_dict"] intra_op_threads, inter_op_threads = _onnx_thread_counts() params = { "Global.log_level": "critical", "Global.model_root_dir": str(paths["model_root"]), # The backend tiles before inference. This remains an independent # defense if a future call path accidentally bypasses tiling. "Global.max_side_len": MAX_DETECTOR_TILE_SIDE, "Global.min_side_len": MIN_DETECTOR_TILE_SIDE, "Global.use_det": True, "Global.use_cls": True, "Global.use_rec": True, "EngineConfig.onnxruntime.use_cuda": False, "EngineConfig.onnxruntime.use_dml": False, "EngineConfig.onnxruntime.use_cann": False, "EngineConfig.onnxruntime.use_coreml": False, "EngineConfig.onnxruntime.intra_op_num_threads": intra_op_threads, "EngineConfig.onnxruntime.inter_op_num_threads": inter_op_threads, "Det.model_path": str(paths[f"{tier}_det"]), "Det.limit_type": "max", "Det.limit_side_len": MAX_DETECTOR_TILE_SIDE, "Det.max_candidates": MAX_BOXES, "Cls.ocr_version": ocr_version.PPOCRV5, "Cls.model_path": str(paths["textline_orientation"]), "Rec.model_path": str(rec_path), "Rec.rec_keys_path": str(rec_dict), } return _RapidOcrBackend(RapidOCR(params=params), cropper, recognition_input_type) class OnnxDocumentOrientation: """Bounded CPU inference for the official four-way document orientation model.""" def __init__(self, model_path: Path): self._model_path = model_path self._session: Any | None = None def _get_session(self) -> Any: if self._session is not None: return self._session import onnxruntime intra_op_threads, inter_op_threads = _onnx_thread_counts() session_options = onnxruntime.SessionOptions() session_options.intra_op_num_threads = intra_op_threads session_options.inter_op_num_threads = inter_op_threads session = onnxruntime.InferenceSession( str(self._model_path), sess_options=session_options, providers=["CPUExecutionProvider"], ) if not session.get_providers() or session.get_providers()[0] != "CPUExecutionProvider": raise RuntimeError("Document orientation model did not initialize on the CPU provider") inputs = session.get_inputs() outputs = session.get_outputs() if len(inputs) != 1 or len(outputs) < 1: raise RuntimeError("Document orientation model has an unsupported ONNX signature") self._session = session return session def initialize(self) -> None: """Eagerly validate the lazy ONNX session during activation smoke tests.""" self._get_session() @staticmethod def _preprocess(image_path: Path) -> Any: import cv2 import numpy as np encoded = np.fromfile(str(image_path), dtype=np.uint8) image = cv2.imdecode(encoded, cv2.IMREAD_COLOR) if image is None or image.ndim != 3 or image.shape[2] != 3: raise RuntimeError("Document orientation model could not decode the input image") height, width = image.shape[:2] if height <= 0 or width <= 0 or height * width > MAX_VARIANT_PIXELS: raise OcrLimitError( f"Document orientation candidates are limited to {MAX_VARIANT_PIXELS} pixels" ) # The model's original pipeline scales the short side to 256 and then # takes a 224px center crop. Crop the equivalent source square first, # so an extreme long side is never materialized at the 256x scale. crop_side = max(1, min(min(height, width), round(min(height, width) * 224 / 256))) left = (width - crop_side) // 2 top = (height - crop_side) // 2 center_crop = image[top : top + crop_side, left : left + crop_side] center_crop = cv2.cvtColor(center_crop, cv2.COLOR_BGR2RGB) resized = cv2.resize(center_crop, (224, 224), interpolation=cv2.INTER_LINEAR) normalized = resized.astype("float32") * (1.0 / 255.0) normalized -= np.asarray((0.485, 0.456, 0.406), dtype="float32") normalized /= np.asarray((0.229, 0.224, 0.225), dtype="float32") return np.transpose(normalized, (2, 0, 1))[None, ...] def classify(self, image_path: Path) -> OrientationPrediction: import numpy as np session = self._get_session() values = np.asarray( session.run(None, {session.get_inputs()[0].name: self._preprocess(image_path)})[0] ).reshape(-1) if values.size != 4 or not np.all(np.isfinite(values)): raise RuntimeError("Document orientation model returned malformed scores") if np.all(values >= 0) and math.isclose(float(values.sum()), 1.0, abs_tol=1e-3): probabilities = values else: shifted = values - np.max(values) exponentials = np.exp(shifted) probabilities = exponentials / exponentials.sum() index = int(np.argmax(probabilities)) return OrientationPrediction((0, 90, 180, 270)[index], float(probabilities[index])) class CvImageVariants: """Create bounded in-memory variants; the source file is never modified.""" @staticmethod def _load(source: Any) -> Any: import cv2 import numpy as np if isinstance(source, (str, Path)): encoded = np.fromfile(str(source), dtype=np.uint8) image = cv2.imdecode(encoded, cv2.IMREAD_COLOR) else: image = np.asarray(source) if image is None or image.ndim != 3 or image.shape[2] != 3: raise RuntimeError("OCR variant processing requires a three-channel image") height, width = image.shape[:2] if height <= 0 or width <= 0 or height * width > MAX_VARIANT_PIXELS: raise OcrLimitError(f"OCR image variants are limited to {MAX_VARIANT_PIXELS} pixels") return image def rotate(self, source: Any, angle: int) -> Any: import cv2 image = self._load(source) rotation_codes = { 90: cv2.ROTATE_90_CLOCKWISE, 180: cv2.ROTATE_180, 270: cv2.ROTATE_90_COUNTERCLOCKWISE, } if angle not in rotation_codes: raise ValueError("OCR rotation candidate must be 90, 180, or 270 degrees") return cv2.rotate(image, rotation_codes[angle]) def enhance(self, source: Any) -> Any: import cv2 image = self._load(source) lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) lightness, channel_a, channel_b = cv2.split(lab) enhanced_lightness = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8)).apply(lightness) return cv2.cvtColor( cv2.merge((enhanced_lightness, channel_a, channel_b)), cv2.COLOR_LAB2BGR, ) def _read_calibration(path: Path) -> SelectorCalibration | None: try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(raw, dict) or raw.get("schemaVersion") != 1: return None if raw.get("selectorVersion") != "best-v1" or not isinstance(raw.get("weights"), dict): return None weights = raw["weights"] keys = ( "confidenceCoverage", "meanConfidence", "polygonCoherence", "readingOrderCoherence", ) if set(weights) != set(keys) or not all(_is_number(weights.get(key)) for key in keys): return None values = [float(weights[key]) for key in keys] if any(value < 0 or value > 1 for value in values) or not math.isclose( sum(values), 1.0, abs_tol=1e-6 ): return None variant_policy = None raw_policy = raw.get("variantPolicy") if raw_policy is not None: policy_keys = ( "orientationMinConfidence", "orientationMinScoreGain", "preprocessMinScoreGain", ) if ( not isinstance(raw_policy, dict) or set(raw_policy) != set(policy_keys) or not all(_is_number(raw_policy.get(key)) for key in policy_keys) ): return None policy_values = [float(raw_policy[key]) for key in policy_keys] if any(value < 0 or value > 1 for value in policy_values): return None variant_policy = VariantPolicy(*policy_values) return SelectorCalibration(*values, variant_policy) def _polygon_area(polygon: tuple[tuple[float, float], ...]) -> float: return abs( sum( polygon[index][0] * polygon[(index + 1) % 4][1] - polygon[(index + 1) % 4][0] * polygon[index][1] for index in range(4) ) / 2 ) def _position(line: RawLine) -> tuple[float, float]: return (min(point[1] for point in line.polygon), min(point[0] for point in line.polygon)) def _bounds(line: RawLine) -> tuple[float, float, float, float]: xs = [point[0] for point in line.polygon] ys = [point[1] for point in line.polygon] return min(xs), min(ys), max(xs), max(ys) def _rows(lines: Sequence[RawLine]) -> list[list[RawLine]]: """Group overlapping polygons into rows, then order each row left-to-right.""" groups: list[dict[str, Any]] = [] for line in sorted(lines, key=lambda value: (_bounds(value)[1], _bounds(value)[0])): left, top, right, bottom = _bounds(line) height = max(bottom - top, 1.0) best_group = None best_overlap = 0.0 for group in groups: overlap = max(0.0, min(bottom, group["bottom"]) - max(top, group["top"])) overlap_ratio = overlap / min(height, max(group["bottom"] - group["top"], 1.0)) center_distance = abs((top + bottom) / 2 - group["center"]) if overlap_ratio >= 0.35 or center_distance <= max(height, group["height"]) * 0.45: if overlap_ratio >= best_overlap: best_group, best_overlap = group, overlap_ratio if best_group is None: groups.append( { "lines": [line], "top": top, "bottom": bottom, "center": (top + bottom) / 2, "height": height, "left": left, "right": right, } ) continue best_group["lines"].append(line) best_group["top"] = min(best_group["top"], top) best_group["bottom"] = max(best_group["bottom"], bottom) best_group["center"] = (best_group["top"] + best_group["bottom"]) / 2 best_group["height"] = best_group["bottom"] - best_group["top"] best_group["left"] = min(best_group["left"], left) best_group["right"] = max(best_group["right"], right) groups.sort(key=lambda group: (group["top"], group["left"])) return [sorted(group["lines"], key=lambda line: _bounds(line)[0]) for group in groups] def _median_line_height(lines: Sequence[RawLine]) -> float: heights = sorted(max(_bounds(line)[3] - _bounds(line)[1], 1.0) for line in lines) middle = len(heights) // 2 if len(heights) % 2: return heights[middle] return (heights[middle - 1] + heights[middle]) / 2 def _column_split( lines: Sequence[RawLine], page_width: int ) -> tuple[list[RawLine], list[RawLine]] | None: """Find a conservative full-height gutter between multi-line text columns.""" if len(lines) < 4: return None ordered = sorted(lines, key=lambda line: (_bounds(line)[0], _bounds(line)[1])) minimum_gap = max(_median_line_height(ordered) * 2, page_width * 0.04) prefix_right = -math.inf best: tuple[float, list[RawLine], list[RawLine]] | None = None for index, line in enumerate(ordered[:-1], start=1): prefix_right = max(prefix_right, _bounds(line)[2]) if index < 2 or len(ordered) - index < 2: continue next_left = _bounds(ordered[index])[0] gap = next_left - prefix_right if gap < minimum_gap: continue left_lines = ordered[:index] right_lines = ordered[index:] left_top = min(_bounds(value)[1] for value in left_lines) left_bottom = max(_bounds(value)[3] for value in left_lines) right_top = min(_bounds(value)[1] for value in right_lines) right_bottom = max(_bounds(value)[3] for value in right_lines) vertical_overlap = max(0.0, min(left_bottom, right_bottom) - max(left_top, right_top)) smaller_span = max(min(left_bottom - left_top, right_bottom - right_top), 1.0) if vertical_overlap < max(_median_line_height(ordered), smaller_span * 0.25): continue if best is None or gap > best[0]: best = (gap, left_lines, right_lines) return None if best is None else (best[1], best[2]) def _horizontal_columns(lines: Sequence[RawLine], page_width: int) -> list[list[RawLine]]: split = _column_split(lines, page_width) if split is not None: left, right = split return _horizontal_columns(left, page_width) + _horizontal_columns(right, page_width) # A title or footer may bridge an otherwise unambiguous gutter. Only set # those wide lines aside when every one sits wholly outside the body. wide_lines = [ line for line in lines if _bounds(line)[2] - _bounds(line)[0] >= page_width * 0.6 ] body_lines = [line for line in lines if line not in wide_lines] if wide_lines and _column_split(body_lines, page_width) is not None: body_top = min(_bounds(line)[1] for line in body_lines) body_bottom = max(_bounds(line)[3] for line in body_lines) before = [line for line in wide_lines if _bounds(line)[3] <= body_top] after = [line for line in wide_lines if _bounds(line)[1] >= body_bottom] if len(before) + len(after) == len(wide_lines): groups: list[list[RawLine]] = [] if before: groups.append(before) groups.extend(_horizontal_columns(body_lines, page_width)) if after: groups.append(after) return groups return [list(lines)] def _vertical_columns(lines: Sequence[RawLine]) -> list[list[RawLine]]: """Group vertical fragments by x, ordered in Japanese right-to-left flow.""" groups: list[dict[str, Any]] = [] for line in sorted(lines, key=lambda value: (-_bounds(value)[2], _bounds(value)[1])): left, top, right, bottom = _bounds(line) width = max(right - left, 1.0) best_group = None best_overlap = 0.0 for group in groups: overlap = max(0.0, min(right, group["right"]) - max(left, group["left"])) overlap_ratio = overlap / min(width, max(group["right"] - group["left"], 1.0)) center_distance = abs((left + right) / 2 - group["center"]) if overlap_ratio >= 0.35 or center_distance <= max(width, group["width"]) * 0.45: if overlap_ratio >= best_overlap: best_group, best_overlap = group, overlap_ratio if best_group is None: groups.append( { "lines": [line], "left": left, "right": right, "center": (left + right) / 2, "width": width, } ) continue best_group["lines"].append(line) best_group["left"] = min(best_group["left"], left) best_group["right"] = max(best_group["right"], right) best_group["center"] = (best_group["left"] + best_group["right"]) / 2 best_group["width"] = best_group["right"] - best_group["left"] groups.sort(key=lambda group: (-group["right"], group["left"])) return [sorted(group["lines"], key=lambda line: _bounds(line)[1]) for group in groups] def _is_cjk_boundary(character: str) -> bool: return ( ("\u3040" <= character <= "\u30ff") or ("\u3400" <= character <= "\u9fff") or ("\uac00" <= character <= "\ud7af") or ("\u1100" <= character <= "\u11ff") ) def _join_row(lines: Sequence[RawLine]) -> str: output = "" previous: RawLine | None = None for line in lines: text = line.text.strip() if not text: continue if not output or previous is None: output = text previous = line continue previous_bounds = _bounds(previous) current_bounds = _bounds(line) height = max(previous_bounds[3] - previous_bounds[1], current_bounds[3] - current_bounds[1], 1) gap = current_bounds[0] - previous_bounds[2] if gap < 0: for duplicate_count in range(min(8, len(output), len(text)), 0, -1): if output.endswith(text[:duplicate_count]): text = text[duplicate_count:] break if not text: previous = line continue touches = gap <= height * 0.25 punctuation = text[0] in ",.;:!?%)]}" or output[-1] in "([{" cjk = _is_cjk_boundary(output[-1]) and _is_cjk_boundary(text[0]) output += ("" if touches or punctuation or cjk else " ") + text previous = line return output def _join_vertical_column(lines: Sequence[RawLine]) -> str: output = "" for line in lines: text = line.text.strip() if not text: continue for duplicate_count in range(min(8, len(output), len(text)), 0, -1): if output.endswith(text[:duplicate_count]): text = text[duplicate_count:] break output += text return output def _contains_kana(lines: Sequence[RawLine]) -> bool: return any( "\u3040" <= character <= "\u30ff" for line in lines for character in line.text ) def _reading_order_coherence(lines: Sequence[RawLine]) -> float: if len(lines) < 2: return 1.0 positions = [_position(line) for line in lines] coherent = 0 for previous, current in zip(positions, positions[1:]): same_row = abs(previous[0] - current[0]) <= 12 if current[0] >= previous[0] - 12 and (not same_row or current[1] >= previous[1]): coherent += 1 return coherent / (len(lines) - 1) def _selector_score(candidate: RawCandidate, calibration: SelectorCalibration) -> float: if not candidate.lines: return 0.0 megapixels = max((candidate.width * candidate.height) / 1_000_000, 0.01) weighted_characters = sum( len(re.sub(r"\s+", "", line.text)) * min(max(line.confidence, 0.0), 1.0) for line in candidate.lines ) # Coverage is deliberately saturated. A noisy candidate cannot win merely # by emitting unbounded low-confidence text. confidence_coverage = min(1.0, weighted_characters / max(64.0, megapixels * 500.0)) mean_confidence = sum(min(max(line.confidence, 0.0), 1.0) for line in candidate.lines) / len( candidate.lines ) polygon_coherence = sum(_polygon_area(line.polygon) > 1.0 for line in candidate.lines) / len( candidate.lines ) reading_order = _reading_order_coherence(candidate.lines) score = ( confidence_coverage * calibration.confidence_coverage + mean_confidence * calibration.mean_confidence + polygon_coherence * calibration.polygon_coherence + reading_order * calibration.reading_order_coherence ) return min(max(score, 0.0), 1.0) def _vertical_text_ratio(candidate: RawCandidate) -> float: if not candidate.lines: return 0.0 vertical = 0 for line in candidate.lines: left, top, right, bottom = _bounds(line) if bottom - top >= max(right - left, 1.0) * 1.5: vertical += 1 return vertical / len(candidate.lines) def _hangul_count(text: str) -> int: return sum( ("\uac00" <= character <= "\ud7af") or ("\u1100" <= character <= "\u11ff") or ("\u3130" <= character <= "\u318f") or ("\ua960" <= character <= "\ua97f") or ("\ud7b0" <= character <= "\ud7ff") for character in text ) def _non_hangul_alnum(text: str) -> str: normalized = unicodedata.normalize("NFKC", text).casefold() return "".join( character for character in normalized if character.isalnum() and _hangul_count(character) == 0 ) def _prefer_korean(unified: RawLine, korean: RawRecognition) -> bool: visible = [character for character in korean.text if not character.isspace()] hangul = _hangul_count(korean.text) if hangul < 2 or not visible: return False ratio = hangul / len(visible) score = min(max(korean.confidence, 0.0), 1.0) unified_score = min(max(unified.confidence, 0.0), 1.0) direct_preference = ( ratio >= 0.35 and score >= 0.60 and score >= unified_score + 0.03 ) or (ratio >= 0.65 and score >= 0.70 and score >= unified_score - 0.03) unified_visible = [character for character in unified.text if not character.isspace()] recovered_hangul = hangul - _hangul_count(unified.text) non_hangul_similarity = SequenceMatcher( None, _non_hangul_alnum(unified.text), _non_hangul_alnum(korean.text), autojunk=False, ).ratio() mixed_script_recovery = ( recovered_hangul >= 4 and ratio >= 0.20 and len(visible) >= len(unified_visible) + 4 and non_hangul_similarity >= 0.80 and score >= 0.80 and score >= unified_score - 0.02 ) return direct_preference or mixed_script_recovery def _preserve_unified_prefix(unified: str, korean: str) -> str: first_hangul = next( (index for index, character in enumerate(korean) if _hangul_count(character)), None, ) if first_hangul is None: return korean korean_prefix = _non_hangul_alnum(korean[:first_hangul]) if not korean_prefix: return korean for index in range(len(unified)): unified_prefix = _non_hangul_alnum(unified[: index + 1]) if unified_prefix == korean_prefix: prefix = unified[: index + 1].rstrip() suffix = korean[first_hangul:].lstrip() if prefix and suffix: return f"{prefix} {suffix}" break if not korean_prefix.startswith(unified_prefix): break return korean def _merge_auto_korean( candidate: RawCandidate, recognitions: tuple[RawRecognition, ...] ) -> RawCandidate: if len(recognitions) != len(candidate.lines): raise RuntimeError("Korean recognizer did not return one result per shared crop") merged = [] for unified, korean in zip(candidate.lines, recognitions): if not isinstance(korean.text, str) or not _is_number(korean.confidence): raise RuntimeError("Korean recognizer returned malformed output") if _prefer_korean(unified, korean): merged.append( RawLine( _preserve_unified_prefix(unified.text, korean.text), korean.confidence, unified.polygon, ) ) else: merged.append(unified) return replace(candidate, lines=tuple(merged), crops=()) class OcrRuntime: def __init__( self, *, root: Path | str | None = None, backend_factory: BackendFactory | None = None, environ: Mapping[str, str] | None = None, calibration_path: Path | str | None = None, orientation_classifier: OrientationClassifier | None = None, image_variants: ImageVariants | None = None, ): self._environ = environ if environ is not None else os.environ default_root = Path(__file__).resolve().parent.parent self.root = Path( root or self._environ.get("SNAPOTTER_RUNTIME_ROOT", str(default_root)) ).resolve() self.model_root = self.root / "models" self.paths: MutableMapping[str, Path] = {"model_root": self.model_root} for model_id, filename in MODEL_FILENAMES.items(): path = self.model_root / filename if not path.is_file(): raise FileNotFoundError(f"Required OCR model {model_id} is missing: {path}") self.paths[model_id] = path self.provider = self._read_provider() self.device = "cpu" self.runtime_version = self._environ.get("SNAPOTTER_OCR_ARTIFACT_VERSION", "unknown") self.target = self._environ.get("SNAPOTTER_OCR_RUNTIME_TARGET", "unknown") self.calibration_path = Path( calibration_path or (self.model_root / "best-v1-calibration.json") ) self.calibration = _read_calibration(self.calibration_path) self._factory = backend_factory or RapidOcrBackendFactory(self._environ) self._backends: dict[tuple[str, str], OcrBackend] = {} self._orientation = orientation_classifier or OnnxDocumentOrientation( self.paths["document_orientation"] ) self._image_variants = image_variants or CvImageVariants() def _read_provider(self) -> str: raw = self._environ.get("SNAPOTTER_OCR_PROVIDERS_JSON", '["CPUExecutionProvider"]') try: providers = json.loads(raw) except json.JSONDecodeError as error: raise ValueError("OCR provider descriptor is not valid JSON") from error if providers != ["CPUExecutionProvider"]: raise ValueError("This OCR runtime requires exactly CPUExecutionProvider") return providers[0] def _backend(self, tier: str, recognizer: str) -> OcrBackend: key = (tier, recognizer) if key not in self._backends: self._backends[key] = self._factory.create( tier=tier, recognizer=recognizer, paths=dict(self.paths), ) return self._backends[key] def smoke(self) -> dict[str, str]: """Run one bounded inference through every model family on the CPU provider.""" if self.calibration is None: raise RuntimeError("Best selector calibration is unavailable or invalid") fixture_path: Path | None = None try: with tempfile.NamedTemporaryFile( mode="wb", prefix="snapotter-ocr-smoke-", suffix=".pgm", delete=False ) as fixture: fixture.write(_functional_smoke_fixture()) fixture.flush() os.fsync(fixture.fileno()) fixture_path = Path(fixture.name) for recognizer in ("unified", "korean"): for tier in ("small", "medium"): candidate = self._backend(tier, recognizer).recognize(fixture_path) self._validate_candidate(candidate) if not any(line.text.strip() for line in candidate.lines): raise RuntimeError( f"OCR {tier}/{recognizer} activation inference returned no text" ) self._orientation.initialize() orientation = self._orientation.classify(fixture_path) if ( orientation.angle not in (0, 90, 180, 270) or not _is_number(orientation.confidence) or not 0 <= float(orientation.confidence) <= 1 ): raise RuntimeError("Document orientation activation inference was malformed") finally: if fixture_path is not None: fixture_path.unlink(missing_ok=True) return { "provider": self.provider, "device": self.device, "runtimeVersion": self.runtime_version, "target": self.target, "representativeModel": ( "PP-OCRv6-small+medium+both-korean-pipelines+document-orientation" ), } @staticmethod def _settings(raw: Mapping[str, Any] | None) -> dict[str, Any]: if raw is None: raw = {} if not isinstance(raw, Mapping): raise ValueError("OCR settings must be an object") quality = raw.get("quality", "balanced") language = raw.get("language", "auto") enhance = raw.get("enhance", False) if quality not in SUPPORTED_QUALITIES: raise ValueError("Accurate OCR quality must be balanced or best") if language not in SUPPORTED_LANGUAGES: raise ValueError(f"Unsupported OCR language: {language}") if not isinstance(enhance, bool): raise ValueError("OCR enhance setting must be boolean") return {"quality": quality, "language": language, "enhance": enhance} @staticmethod def _image_path(value: Path | str, *, raster_page: bool = False) -> Path: text = str(value) if "://" in text or text.startswith("data:"): raise ValueError("OCR accepts only local image paths") path = Path(value) if not path.is_file(): raise FileNotFoundError(f"OCR input image does not exist: {path}") if raster_page and path.suffix.lower() not in RASTER_SUFFIXES: raise ValueError("PDF OCR runtime accepts rasterized page images, not PDF files") return path def _run_tier(self, image_input: Any, tier: str, language: str) -> RawCandidate: recognizer = "korean" if language == "ko" else "unified" candidate = self._backend(tier, recognizer).recognize( image_input, retain_crops=language == "auto", ) self._validate_candidate(candidate) if language == "auto" and candidate.lines: korean_backend = self._backend(tier, "korean") recognitions = korean_backend.recognize_crops(candidate.crops) candidate = _merge_auto_korean(candidate, recognitions) self._validate_candidate(candidate) return candidate def _prepare_rotations( self, image_path: Path, plan: _VariantPlan, warnings: list[str], ) -> tuple[tuple[int, Any], ...]: if plan.rotations_ready: return plan.rotations plan.rotations_ready = True policy = self.calibration.variant_policy if self.calibration else None if policy is None: return () try: prediction = self._orientation.classify(image_path) if ( prediction.angle not in (0, 90, 180, 270) or not _is_number(prediction.confidence) ): raise RuntimeError("Document orientation model returned an invalid prediction") if prediction.confidence < policy.orientation_min_confidence or prediction.angle == 0: return () angles = (180,) if prediction.angle == 180 else (90, 270) rotations = [] for angle in angles[:MAX_ROTATION_CANDIDATES]: try: rotations.append((angle, self._image_variants.rotate(image_path, angle))) except Exception as error: warnings.append(f"Document rotation {angle}° candidate skipped: {error}") plan.rotations = tuple(rotations) except Exception as error: warnings.append(f"Document orientation candidate scoring skipped: {error}") return plan.rotations def _tier_choice( self, image_path: Path, tier: str, language: str, enhance: bool, plan: _VariantPlan, warnings: list[str], ) -> CandidateChoice: original = self._run_tier(image_path, tier, language) choice = CandidateChoice(original, image_path, "original") calibration = self.calibration policy = calibration.variant_policy if calibration else None if calibration is None or policy is None: return choice original_score = _selector_score(original, calibration) best_rotation: tuple[int, Any, RawCandidate, float] | None = None for angle, source in self._prepare_rotations(image_path, plan, warnings): try: candidate = self._run_tier(source, tier, language) score = _selector_score(candidate, calibration) except Exception as error: warnings.append(f"Document rotation {angle}° OCR candidate skipped: {error}") continue if best_rotation is None or score > best_rotation[3]: best_rotation = (angle, source, candidate, score) if best_rotation is not None: required_gain = policy.orientation_min_score_gain if language == "ja" and _vertical_text_ratio(original) >= 0.5: required_gain = max(required_gain, 0.15) if best_rotation[3] >= original_score + required_gain: angle, source, candidate, score = best_rotation choice = CandidateChoice( candidate, source, f"rotation-{angle}", ( f"Selected document rotation {angle}° after calibrated OCR score " f"improved by {score - original_score:.3f}.", ), ) if not enhance: return choice assert plan.enhanced is not None try: if choice.source_label not in plan.enhanced: plan.enhanced[choice.source_label] = self._image_variants.enhance(choice.source) enhanced_source = plan.enhanced[choice.source_label] enhanced = self._run_tier(enhanced_source, tier, language) selected_score = _selector_score(choice.candidate, calibration) enhanced_score = _selector_score(enhanced, calibration) if enhanced_score >= selected_score + policy.preprocess_min_score_gain: return CandidateChoice( enhanced, enhanced_source, f"enhanced-{choice.source_label}", choice.warnings + ( "Selected conservative contrast preprocessing after calibrated OCR " f"score improved by {enhanced_score - selected_score:.3f}.", ), ) except Exception as error: warnings.append(f"OCR preprocessing candidate skipped: {error}") return choice @staticmethod def _validate_candidate(candidate: RawCandidate) -> None: if not isinstance(candidate, RawCandidate) or candidate.width <= 0 or candidate.height <= 0: raise RuntimeError("OCR backend returned an invalid candidate") if len(candidate.lines) > MAX_BOXES: raise OcrLimitError(f"OCR output exceeded the {MAX_BOXES} boxes limit") if candidate.crops and len(candidate.crops) != len(candidate.lines): raise RuntimeError("OCR backend returned mismatched crops and lines") for line in candidate.lines: if not isinstance(line.text, str) or not _is_number(line.confidence): raise RuntimeError("OCR backend returned a malformed line") _normalize_polygon(line.polygon) @staticmethod def _validate_text_output(candidate: RawCandidate, text: str) -> None: byte_limit = min( MAX_IMAGE_OCR_OUTPUT_BYTES, max( MIN_IMAGE_OUTPUT_BYTES, math.ceil( candidate.width * candidate.height / 1_000_000 * MAX_OUTPUT_BYTES_PER_MEGAPIXEL ), ), ) if len(text.encode("utf-8")) > byte_limit: raise OcrLimitError( f"OCR output exceeded the {byte_limit} bytes limit for this image" ) @staticmethod def _text(candidate: RawCandidate, language: str) -> str: lines = [line for line in candidate.lines if line.text.strip()] if not lines: return "" if _vertical_text_ratio(candidate) >= 0.5 and ( language == "ja" or (language == "auto" and _contains_kana(lines)) ): vertical_lines = [ line for line in lines if _bounds(line)[3] - _bounds(line)[1] >= max(_bounds(line)[2] - _bounds(line)[0], 1.0) * 1.5 ] horizontal_lines = [line for line in lines if line not in vertical_lines] body_top = min(_bounds(line)[1] for line in vertical_lines) body_bottom = max(_bounds(line)[3] for line in vertical_lines) before = [line for line in horizontal_lines if _bounds(line)[3] <= body_top] after = [line for line in horizontal_lines if _bounds(line)[1] >= body_bottom] if len(before) + len(after) == len(horizontal_lines): parts = [text for row in _rows(before) if (text := _join_row(row))] parts.extend( text for column in _vertical_columns(vertical_lines) if (text := _join_vertical_column(column)) ) parts.extend(text for row in _rows(after) if (text := _join_row(row))) return "\n".join(parts) return "\n".join( text for column in _horizontal_columns(lines, candidate.width) for row in _rows(column) if (text := _join_row(row)) ) def recognize_image( self, image_path: Path | str, raw_settings: Mapping[str, Any] | None = None ) -> dict[str, Any]: path = self._image_path(image_path) settings = self._settings(raw_settings) quality = settings["quality"] language = settings["language"] warnings: list[str] = [] plan = _VariantPlan() if quality == "balanced": selected_tier = "small" choice = self._tier_choice( path, selected_tier, language, settings["enhance"], plan, warnings, ) model_version = "PP-OCRv6-small" else: if self.calibration is None: raise RuntimeError("Best selector calibration is unavailable or invalid") choices = [ ( tier, self._tier_choice( path, tier, language, settings["enhance"], plan, warnings, ), ) for tier in ("small", "medium") ] tier_scores = { tier: _selector_score(tier_choice.candidate, self.calibration) for tier, tier_choice in choices } selected_tier, choice = max( choices, key=lambda tier_choice: ( tier_scores[tier_choice[0]], tier_choice[0] == "medium", ), ) model_version = f"PP-OCRv6-best-v1-{selected_tier}" warnings.append( f"Selected calibrated Best OCR tier {selected_tier} " f"(small={tier_scores['small']:.3f}, medium={tier_scores['medium']:.3f})." ) selected = choice.candidate warnings.extend(choice.warnings) if language == "ko" or (language == "auto" and selected.lines): model_version += "+korean-PP-OCRv5" text = self._text(selected, language) self._validate_text_output(selected, text) return { "success": True, "text": text, "engine": "rapidocr-onnx", "requestedQuality": quality, "actualQuality": quality, "device": self.device, "provider": self.provider, "runtimeVersion": self.runtime_version, "modelVersion": model_version, "degraded": False, "warnings": list(dict.fromkeys(warnings)), } def recognize_pages( self, pages: Sequence[tuple[int, Path | str]], raw_settings: Mapping[str, Any] | None = None, ) -> dict[str, Any]: settings = self._settings(raw_settings) if not pages: raise ValueError("PDF OCR requires at least one rasterized page") if len(pages) > 50: raise OcrLimitError("PDF OCR accepts at most 50 rasterized pages") validated_pages: list[tuple[int, Path]] = [] seen_pages: set[int] = set() for page_number, raw_path in pages: if not isinstance(page_number, int) or isinstance(page_number, bool) or page_number < 1: raise ValueError("Rasterized PDF page numbers must be positive integers") if page_number in seen_pages: raise ValueError("Rasterized PDF page numbers must be unique") seen_pages.add(page_number) validated_pages.append((page_number, self._image_path(raw_path, raster_page=True))) parts: list[str] = [] retained_output_bytes = 0 warnings: list[str] = [] model_versions: set[str] = set() for page_number, path in sorted(validated_pages): result = self.recognize_image(path, settings) part = f"--- Page {page_number} ---\n\n{result['text']}" separator = "" if not parts else "\n\n" added_bytes = len(f"{separator}{part}".encode("utf-8")) if retained_output_bytes + added_bytes > MAX_PDF_OCR_OUTPUT_BYTES: raise OcrLimitError( "PDF OCR exceeded the " f"{MAX_PDF_OCR_OUTPUT_BYTES} byte aggregate output limit" ) parts.append(part) retained_output_bytes += added_bytes warnings.extend(result["warnings"]) model_versions.add(result["modelVersion"]) text = "\n\n".join(parts) if len(model_versions) == 1: model_version = next(iter(model_versions)) else: provenance = ",".join(sorted(model_versions)) prefix = ( "PP-OCRv6-best-v1-mixed" if settings["quality"] == "best" else "PP-OCRv6-balanced-mixed" ) model_version = f"{prefix}[{provenance}]" return { "success": True, "text": text, "pages": len(parts), "engine": "rapidocr-onnx", "requestedQuality": settings["quality"], "actualQuality": settings["quality"], "device": self.device, "provider": self.provider, "runtimeVersion": self.runtime_version, "modelVersion": model_version, "degraded": False, "warnings": list(dict.fromkeys(warnings)), }