| |
| |
| |
|
|
| """Plain-data types and configuration. |
| |
| Every tunable that used to be an environment variable read at import time lives here instead. |
| That fixes a real defect: callers corrected the old module-level defaults by setting os.environ |
| *before* importing the engine, two shipped callers set different values, and importing it |
| directly gave a third pipeline. These defaults ARE the canonical recipe. |
| |
| stdlib-only -- importable with no GPU stack and no PIL. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import dataclasses |
| from dataclasses import dataclass, field |
|
|
| from idp_contract import TableFormat |
|
|
|
|
| class LayoutSchemaError(ValueError): |
| """An incoming layout record cannot be interpreted unambiguously. |
| |
| Raised rather than warned because the alternative is silent and wrong. A label the taxonomy |
| does not contain falls through to ``Text``, so a table would be sent the prose prompt and |
| come back as flattened text with nothing in the output saying so. Our own detector only ever |
| emits ``layout.labels.CLASSES``, so an unrecognised label always means either a third-party |
| taxonomy -- which should declare ``type`` -- or a typo. Both are worth stopping for. |
| |
| ``map_label`` itself stays total: 19 of the 37 classes are deliberately absent from |
| ``LABEL_TO_TYPE`` and correctly mean ``Text``, so leniency belongs there. Validation belongs |
| here, at the boundary where foreign JSON enters. |
| """ |
|
|
|
|
| def _unknown(value, candidates, kind: str) -> str: |
| """``unknown label 'Tabel'; did you mean 'Table'?`` -- the suggestion is most of the value.""" |
| import difflib |
|
|
| match = difflib.get_close_matches(str(value), list(candidates), n=1, cutoff=0.6) |
| hint = f"; did you mean {match[0]!r}?" if match else "" |
| return f"unknown {kind} {value!r}{hint}" |
|
|
|
|
| @dataclass |
| class Block: |
| """One detected region. ``text`` is None before OCR, and "" for blocks deliberately not |
| transcribed (kept in place rather than deleted).""" |
|
|
| order: int |
| label: str |
| type: str |
| bbox_xyxy: list[float] |
| conf: float |
| text: str | None = None |
|
|
| def as_record(self) -> dict: |
| |
| |
| record: dict = { |
| "order": self.order, |
| "label": self.label, |
| "type": self.type, |
| "bbox_xyxy": [round(float(v), 1) for v in self.bbox_xyxy], |
| "conf": round(float(self.conf), 3), |
| } |
| if self.text is not None: |
| record["text"] = self.text |
| return record |
|
|
| @classmethod |
| def problems(cls, record: dict) -> list[str]: |
| """Everything wrong with one incoming block record, as readable strings. |
| |
| Returns rather than raises so a whole page can be reported at once. Fixing a 60-block |
| layout one exception at a time is the difference between one edit and sixty. |
| """ |
| from idp_model_labels import CLASSES |
| from idp_contract import DROP_TYPES, KEPT_BLOCK_TYPES |
|
|
| found: list[str] = [] |
| for key in ("order", "bbox_xyxy"): |
| if key not in record: |
| found.append(f"missing required key {key!r}") |
|
|
| bbox = record.get("bbox_xyxy") |
| if bbox is not None: |
| try: |
| if len([float(v) for v in bbox]) != 4: |
| found.append(f"bbox_xyxy must be 4 numbers [x0, y0, x1, y1], got {bbox!r}") |
| except (TypeError, ValueError): |
| found.append(f"bbox_xyxy must be 4 numbers [x0, y0, x1, y1], got {bbox!r}") |
|
|
| |
| |
| |
| |
| declared = record.get("type") |
| if declared: |
| valid = tuple(KEPT_BLOCK_TYPES) + tuple(sorted(DROP_TYPES)) |
| if str(declared) not in valid: |
| found.append(_unknown(declared, valid, "type")) |
| else: |
| label = str(record.get("label", "")).strip() |
| if label.lower() not in {c.strip().lower() for c in CLASSES}: |
| found.append( |
| _unknown(label, CLASSES, "label") |
| + ' -- use a spelling from layout.labels.CLASSES, or declare "type"' |
| " explicitly if your detector has its own taxonomy" |
| ) |
| return found |
|
|
| @classmethod |
| def from_record(cls, record: dict, *, strict: bool = True) -> Block: |
| |
| from idp_contract import map_label |
|
|
| if strict: |
| found = cls.problems(record) |
| if found: |
| raise LayoutSchemaError("; ".join(found)) |
|
|
| label = record.get("label", "") |
| return cls( |
| order=int(record["order"]), |
| label=str(label), |
| type=str(record.get("type") or map_label(label)), |
| bbox_xyxy=[float(v) for v in record["bbox_xyxy"]], |
| conf=float(record.get("conf", 1.0)), |
| text=record.get("text"), |
| ) |
|
|
| def copy(self) -> Block: |
| return dataclasses.replace(self, bbox_xyxy=list(self.bbox_xyxy)) |
|
|
|
|
| @dataclass |
| class PageResult: |
| image: str |
| width: int |
| height: int |
| blocks: list[Block] = field(default_factory=list) |
| markdown: str | None = None |
|
|
| def as_record(self) -> dict: |
| return { |
| "image": self.image, |
| "width": self.width, |
| "height": self.height, |
| "blocks": [b.as_record() for b in self.blocks], |
| } |
|
|
| @classmethod |
| def from_record(cls, record: dict, *, strict: bool = True) -> PageResult: |
| """``strict=False`` replays a layout without validating it -- for reading back files |
| written before validation existed, not for new integrations.""" |
| blocks = list(record.get("blocks", [])) |
|
|
| if strict: |
| errors = [ |
| f" block[{i}] (order={b.get('order', '?')!r}): {problem}" |
| for i, b in enumerate(blocks) |
| for problem in Block.problems(b) |
| ] |
| if errors: |
| raise LayoutSchemaError( |
| f"{len(errors)} problem(s) in layout for " |
| f"{record.get('image', '<unknown>')!r}:\n" + "\n".join(errors) |
| ) |
|
|
| return cls( |
| image=str(record["image"]), |
| width=int(record["width"]), |
| height=int(record["height"]), |
| |
| blocks=[Block.from_record(b, strict=False) for b in blocks], |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class LayoutConfig: |
| conf: float = 0.5 |
| img_size: int = 1024 |
| device: str = "cuda" |
|
|
|
|
| @dataclass(frozen=True) |
| class CropConfig: |
| """The clamp is on area, not on a side: pinning a side exploded elongated crops (a 122:1 |
| rule line became ~32k image tokens and wedged the engine).""" |
|
|
| min_px_side: int = 256 |
| max_px_side: int = 1536 |
| pad_px: int = 0 |
|
|
| @property |
| def min_px(self) -> int: |
| return self.min_px_side**2 |
|
|
| @property |
| def max_px(self) -> int: |
| return self.max_px_side**2 |
|
|
|
|
| |
| DEDUP_MODES = ("both", "text_only", "eq_only") |
|
|
|
|
| @dataclass(frozen=True) |
| class DedupConfig: |
| """IndicDocLayout over-produces equation boxes nested inside the paragraphs and display |
| arrays that already contain them; transcribing both emits the same math twice.""" |
|
|
| nest: bool = True |
| mode: str = "both" |
| contain: float = 0.90 |
| wrap: float = 0.5 |
| nested: float = 0.70 |
|
|
| def __post_init__(self) -> None: |
| if self.mode not in DEDUP_MODES: |
| raise ValueError(f"DedupConfig.mode must be one of {DEDUP_MODES}, got {self.mode!r}") |
|
|
|
|
| @dataclass(frozen=True) |
| class RecognizerConfig: |
| max_model_len: int = 8192 |
| max_tokens: int = 2048 |
| temperature: float = 0.0 |
| gpu_memory_utilization: float = 0.80 |
| dtype: str = "bfloat16" |
| |
| |
| batch_size: int = 2048 |
| enforce_eager: bool = True |
| table_format: TableFormat = TableFormat.HTML |
|
|
| def merged(self, **overrides) -> RecognizerConfig: |
| """New config with non-None overrides applied; unknown keys raise.""" |
| known = {f.name for f in dataclasses.fields(self)} |
| unknown = sorted(set(overrides) - known) |
| if unknown: |
| raise TypeError(f"Unknown RecognizerConfig field(s): {', '.join(unknown)}") |
| return dataclasses.replace(self, **{k: v for k, v in overrides.items() if v is not None}) |
|
|