File size: 9,575 Bytes
7b2177e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# GENERATED by hub/build_hub_package.py from src/bodhan_genai/ocr/engine/types.py -- do not edit.
# Vendored so this repo is self-contained: `pip install transformers torch pillow` is the
# whole install. See indic_doc_parser.py for usage.

"""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:
        # Key order is load-bearing: json.dump writes insertion order and the regression gate
        # compares byte for byte. Do not reorder.
        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}")

        # An explicit `type` is the documented escape hatch for a foreign taxonomy, so the label
        # is not checked when one is given -- but the type itself is. Left unchecked, `Tabel`
        # is accepted verbatim and never matches `Table`, which is the same silent failure one
        # level up.
        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:
        # `type` is derived when absent, so a third-party layout carrying only labels works.
        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"]),
            # Already validated above; re-checking each block would repeat the work.
            blocks=[Block.from_record(b, strict=False) for b in blocks],
        )


@dataclass(frozen=True)
class LayoutConfig:
    conf: float = 0.5  # below ~0.4, stains and page borders start scoring as blocks
    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  # largest single measured win; 0 disables upscaling
    max_px_side: int = 1536  # token ceiling
    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


#: both -- text-like OR a larger Equation | text_only -- text-like only | eq_only -- Equation only
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  # duplicate threshold in clean_layout
    wrap: float = 0.5  # a header counts as occupied at this containment
    nested: float = 0.70  # nested-equation threshold

    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  # greedy: the only setting reproducible run to run
    gpu_memory_utilization: float = 0.80
    dtype: str = "bfloat16"
    # One giant batch over tens of thousands of multi-modal requests wedges the vLLM V1
    # scheduler at 100% util with no progress; ~2k chunks run clean.
    batch_size: int = 2048
    enforce_eager: bool = True  # skips ~4 min of torch.compile on a 0.8B model
    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})