Files
wehub-resource-sync 925e56bb5f
Unit tests / build (t4_gpu) (push) Has been cancelled
Unit tests / build (ubuntu-latest) (push) Has been cancelled
Unit tests / build (windows-latest) (push) Has been cancelled
Test CLI scripts / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:56 +08:00

389 lines
15 KiB
Python

"""RecognitionPredictor: per-block OCR via BLOCK_PROMPT.
Given page images and corresponding LayoutResult (or any list of LayoutBox),
crops each block, runs BLOCK_PROMPT, returns PageOCRResult per page.
"""
from __future__ import annotations
from typing import List, Optional
from PIL import Image
from surya.common.blank import is_blank_region
from surya.inference import SuryaInferenceManager, get_default_manager
from surya.inference.parsers import clean_block_html, parse_full_page_html
from surya.inference.prompts import (
PROMPT_TYPE_BLOCK,
PROMPT_TYPE_HIGH_ACCURACY_BBOX,
SKIP_OCR_LABELS,
)
from surya.inference.schema import BatchInputItem
from surya.inference.util import image_token_budget
from surya.layout.label import LAYOUT_PRED_RELABEL, TEXT_LABELS
from surya.layout.schema import LayoutResult
from surya.logging import get_logger
from surya.recognition.schema import (
BlockOCRResult,
PageOCRResult,
)
from surya.settings import settings
logger = get_logger()
# Surya's canonical labels we shouldn't OCR (mirrors model-emitted SKIP_OCR_LABELS
# after canonicalization).
SKIP_CANON_LABELS = {LAYOUT_PRED_RELABEL.get(lbl, lbl) for lbl in SKIP_OCR_LABELS}
# Full-page OCR regeneration schedule (chandra-style), used ONLY when
# settings.SURYA_FULLPAGE_REGEN is True. Round 0 is greedy; a page whose output
# loops / fails to parse is re-requested at escalating temperature (top_p 0.95)
# before resorting to the (slower) block-mode fallback. Mirrors chandra's
# retry_temperature = min(0.2*(n+1), 0.8) over MAX_VLLM_RETRIES=6 retries.
_REGEN_ROUNDS = [(0.0, None)] + [(min(0.2 * (n + 1), 0.8), 0.95) for n in range(6)]
def _crop_block(image: Image.Image, polygon, pad: int = 4) -> Image.Image:
xs = [p[0] for p in polygon]
ys = [p[1] for p in polygon]
x0 = max(0, int(min(xs)) - pad)
y0 = max(0, int(min(ys)) - pad)
x1 = min(image.size[0], int(max(xs)) + pad)
y1 = min(image.size[1], int(max(ys)) + pad)
if x1 <= x0 or y1 <= y0:
return image.crop((0, 0, 1, 1))
return image.crop((x0, y0, x1, y1))
def _drop_blank_text_blocks(
image: Image.Image,
blocks: List[BlockOCRResult],
) -> List[BlockOCRResult]:
"""Drop text-labeled blocks whose source page region is essentially blank.
Full-page OCR can emit text divs for regions that are visually empty
(margins, gutter space) — the model hallucinates a paragraph where there
is none. We crop the region, count near-white pixels, and drop the block
when the fraction exceeds ``blank_pixel_fraction``. Only text-like labels
(see ``TEXT_LABELS``) are eligible: tables, forms, equations, and visual
blocks may legitimately contain large whitespace and are left untouched.
"""
kept: List[BlockOCRResult] = []
dropped = 0
for blk in blocks:
if blk.label not in TEXT_LABELS or blk.skipped or blk.error:
kept.append(blk)
continue
crop = _crop_block(image, blk.polygon)
if not is_blank_region(crop):
kept.append(blk)
continue
dropped += 1
if dropped:
logger.info(f"dropped {dropped} blank text block(s) from full-page OCR")
return kept
def _detect_repeat_loop(
text: str,
base_max_repeats: int = 4,
window_size: int = 500,
scaling_factor: float = 3.0,
) -> bool:
"""True iff the tail of ``text`` ends in a repeating sequence.
Ported from chandra's detect_repeat_token. For each candidate length
1..window_size/2, takes that many trailing chars and counts consecutive
identical preceding blocks. Shorter loops need many repeats to count;
longer ones only need a few. Catches the typical decoder failure mode
where a page output gets stuck emitting the same div / phrase until it
hits max_tokens.
"""
if not text:
return False
for seq_len in range(1, window_size // 2 + 1):
candidate = text[-seq_len:]
max_repeats = int(base_max_repeats * (1 + scaling_factor / seq_len))
repeats = 0
pos = len(text) - seq_len
while pos >= 0 and text[pos : pos + seq_len] == candidate:
repeats += 1
pos -= seq_len
if repeats > max_repeats:
return True
return False
class RecognitionPredictor:
"""Per-block OCR. Construct with a SuryaInferenceManager (or rely on default)."""
def __init__(self, manager: Optional[SuryaInferenceManager] = None):
self.manager = manager
self._disable_tqdm = settings.DISABLE_TQDM
@property
def disable_tqdm(self) -> bool:
return self._disable_tqdm
@disable_tqdm.setter
def disable_tqdm(self, value: bool) -> None:
self._disable_tqdm = bool(value)
def to(self, *args, **kwargs):
return
def __call__(
self,
images: List[Image.Image],
layout_results: Optional[List[LayoutResult]] = None,
*,
full_page: Optional[bool] = None,
) -> List[PageOCRResult]:
"""Run OCR on each page.
Mode resolution:
- ``full_page=None`` (default): block mode if ``layout_results`` is
given, else full-page mode. This is the most-do-what-I-mean form.
- ``full_page=True``: full-page OCR (single HIGH_ACCURACY_BBOX_PROMPT
request per page). ``layout_results`` is ignored — a warning is
logged if it was supplied.
- ``full_page=False``: block mode (per-layout-block OCR request).
``layout_results`` is required.
Full-page is the more accurate path; block mode is for callers that
specifically need per-block crops (e.g. for downstream merging with
text-line detection).
"""
if not images:
return []
if full_page is None:
full_page = layout_results is None
if full_page:
if layout_results is not None:
logger.info(
"RecognitionPredictor called with full_page=True and "
"layout_results; layout will be used as fallback if the "
"full-page output devolves into a repetition loop."
)
return self._full_page_ocr(images, fallback_layout=layout_results)
if layout_results is None:
raise ValueError("layout_results required when full_page=False")
if len(images) != len(layout_results):
raise ValueError(
f"images and layout_results must be same length "
f"({len(images)} vs {len(layout_results)})"
)
manager = self.manager or get_default_manager()
# Build a flat batch across all pages for max concurrency
batch: List[BatchInputItem] = []
block_index_map: List[tuple[int, int]] = [] # (page_idx, block_idx)
skipped_flags: List[bool] = []
for page_idx, (img, layout) in enumerate(zip(images, layout_results)):
for block_idx, box in enumerate(layout.bboxes):
skip = box.label in SKIP_CANON_LABELS
skipped_flags.append(skip)
if skip:
continue
crop = _crop_block(img, box.polygon)
max_tokens = image_token_budget(
box.count, ceiling=settings.SURYA_MAX_TOKENS_BLOCK_CEILING
)
batch.append(
BatchInputItem(
image=crop,
prompt_type=PROMPT_TYPE_BLOCK,
max_tokens=max_tokens,
metadata={"page_idx": page_idx, "block_idx": block_idx},
)
)
block_index_map.append((page_idx, block_idx))
outputs = manager.generate(batch) if batch else []
# Index outputs by (page_idx, block_idx)
out_by_key = {}
for out in outputs:
key = (out.metadata["page_idx"], out.metadata["block_idx"])
out_by_key[key] = out
# Assemble PageOCRResult per page
results: List[PageOCRResult] = []
for page_idx, (img, layout) in enumerate(zip(images, layout_results)):
w, h = img.size
blocks: List[BlockOCRResult] = []
for block_idx, box in enumerate(layout.bboxes):
skip = box.label in SKIP_CANON_LABELS
if skip:
blocks.append(
BlockOCRResult(
polygon=box.polygon,
label=box.label,
raw_label=box.raw_label,
reading_order=box.position,
html="",
skipped=True,
confidence=1.0,
)
)
continue
out = out_by_key.get((page_idx, block_idx))
if out is None or out.error:
blocks.append(
BlockOCRResult(
polygon=box.polygon,
label=box.label,
raw_label=box.raw_label,
reading_order=box.position,
html="",
skipped=False,
error=True,
confidence=0.0,
)
)
continue
html = clean_block_html(out.raw)
conf = out.mean_token_prob if out.mean_token_prob is not None else 1.0
blocks.append(
BlockOCRResult(
polygon=box.polygon,
label=box.label,
raw_label=box.raw_label,
reading_order=box.position,
html=html,
skipped=False,
error=False,
confidence=conf,
raw_logprobs=out.logprobs,
)
)
results.append(
PageOCRResult(blocks=blocks, image_bbox=[0, 0, float(w), float(h)])
)
return results
def _full_page_ocr(
self,
images: List[Image.Image],
fallback_layout: Optional[List[LayoutResult]] = None,
) -> List[PageOCRResult]:
"""One HIGH_ACCURACY_BBOX_PROMPT request per page; parses divs into blocks.
On per-page failure (parse error, empty output, or a detected
repetition loop in the decoder output), falls back to layout +
block-mode OCR for that page only. ``fallback_layout``, if given,
provides per-page LayoutResults to use on fallback; otherwise the
LayoutPredictor is invoked lazily for just the affected pages.
"""
manager = self.manager or get_default_manager()
results: List[Optional[PageOCRResult]] = [None] * len(images)
def _build_page(out, img):
"""A good full-page output -> PageOCRResult; otherwise None (regen/fallback).
A genuinely blank page is a valid empty result (not a failure)."""
w, h = img.size
page_bbox = [0, 0, float(w), float(h)]
if out is None or out.error:
return None
if not out.raw:
return (
PageOCRResult(blocks=[], image_bbox=page_bbox)
if is_blank_region(img)
else None
)
if _detect_repeat_loop(out.raw):
return None
try:
parsed = parse_full_page_html(out.raw)
except Exception:
return None
confidence = out.mean_token_prob if out.mean_token_prob is not None else 1.0
blocks: List[BlockOCRResult] = []
for idx, item in enumerate(parsed):
x0 = item.bbox[0] / settings.BBOX_SCALE * w
y0 = item.bbox[1] / settings.BBOX_SCALE * h
x1 = item.bbox[2] / settings.BBOX_SCALE * w
y1 = item.bbox[3] / settings.BBOX_SCALE * h
polygon = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
canon = LAYOUT_PRED_RELABEL.get(item.label, item.label)
skipped = canon in SKIP_CANON_LABELS
blocks.append(
BlockOCRResult(
polygon=polygon,
label=canon,
raw_label=item.label,
reading_order=idx,
html="" if skipped else item.html,
skipped=skipped,
error=False,
confidence=confidence,
)
)
return PageOCRResult(
blocks=_drop_blank_text_blocks(img, blocks), image_bbox=page_bbox
)
# Progressive-temperature regeneration (chandra-style), gated by
# settings.SURYA_FULLPAGE_REGEN (default off -> single greedy pass, then
# block-mode fallback, i.e. surya's prior behavior).
rounds = _REGEN_ROUNDS if settings.SURYA_FULLPAGE_REGEN else [(0.0, None)]
pending = list(range(len(images)))
for round_i, (temp, top_p) in enumerate(rounds):
if not pending:
break
batch = [
BatchInputItem(
image=images[i],
prompt_type=PROMPT_TYPE_HIGH_ACCURACY_BBOX,
max_tokens=settings.SURYA_MAX_TOKENS_FULL_PAGE,
temperature=(temp if round_i > 0 else None),
top_p=top_p,
metadata={"page_idx": i},
)
for i in pending
]
out_by_page = {o.metadata["page_idx"]: o for o in manager.generate(batch)}
still: List[int] = []
for i in pending:
r = _build_page(out_by_page.get(i), images[i])
if r is not None:
results[i] = r
else:
still.append(i)
if still and round_i < len(rounds) - 1:
logger.info(
f"regenerating {len(still)} full-page output(s) at higher temperature"
)
pending = still
needs_fallback: List[int] = pending
# Block-mode fallback for any pages whose full-page output failed or looped.
if needs_fallback:
fb_images = [images[i] for i in needs_fallback]
if fallback_layout is not None:
fb_layouts = [fallback_layout[i] for i in needs_fallback]
else:
# Lazy import to avoid the surya.layout ↔ surya.recognition cycle.
from surya.layout import LayoutPredictor
logger.info(
f"running layout for {len(fb_images)} page(s) requiring "
f"block-mode fallback"
)
fb_layouts = LayoutPredictor(self.manager)(fb_images)
fb_results = self.__call__(fb_images, fb_layouts, full_page=False)
for fb_idx, page_idx in enumerate(needs_fallback):
results[page_idx] = fb_results[fb_idx]
# Backfill any still-None pages with empty results (defensive — shouldn't happen).
out_results: List[PageOCRResult] = []
for page_idx, img in enumerate(images):
r = results[page_idx]
if r is None:
w, h = img.size
r = PageOCRResult(blocks=[], image_bbox=[0, 0, float(w), float(h)])
out_results.append(r)
return out_results