diff --git a/doctr/io/exporters.py b/doctr/io/exporters.py index c78409aadc..c626c41409 100644 --- a/doctr/io/exporters.py +++ b/doctr/io/exporters.py @@ -157,160 +157,203 @@ def page_reading_order( Returns: the ordered items, their layout label (None without layout) and the effective reading direction """ - from doctr.io.elements import Block, LayoutElement, Table - from doctr.models.reading_order import ( - ReadingOrderPredictor, - deskew_reading_geometries, - layout_label_role, - normalize_layout_label, - resolve_reading_segments, - ) - from doctr.models.reading_order.base import _FURNITURE_ROLES, _float_text_label, _to_boxes + from doctr.models.reading_order import ReadingOrderPredictor, deskew_reading_geometries, resolve_reading_segments signature = _reading_order_signature(page, direction) cached = getattr(page, "_reading_order_cache", None) if cached is not None and cached[0] == signature: - items, labels, resolved = cached[1] - return _select_items(list(items), list(labels), resolved, include_figures) - - texts = [word.value for block in page.blocks for line in block.lines for word in line.words] - language = page.language.get("value") if isinstance(page.language, dict) else None - direction = ReadingOrderPredictor(direction=direction).resolve_direction(texts, language=language) - region_geoms = [region.geometry for region in page.layout] - region_labels = [region.type for region in page.layout] + cached_items, cached_labels, resolved = cached[1] + return _select_items(list(cached_items), list(cached_labels), resolved, include_figures) lines = [line for block in page.blocks for line in block.lines] - # Figures take part in the ordering as floats - figures = picture_regions(page) - elements: list[Any] = [*lines, *page.tables, *figures] - if len(elements) == 0: - _store_reading_order(page, signature, ([], [], direction)) - return [], [], direction - # De-skew once so labeling, ordering and region grouping share the same upright frame; the page angle is - # estimated from the word polygons, which carry the detection model's true orientation - elt_geoms, region_geoms = deskew_reading_geometries( - [elt.geometry for elt in elements], - region_geoms, - page_shape=page.dimensions, - angle_geoms=[word.geometry for line in lines for word in line.words], + words = [word for line in lines for word in line.words] + language = page.language.get("value") if isinstance(page.language, dict) else None + direction = ReadingOrderPredictor(direction=direction).resolve_direction( + [word.value for word in words], language=language ) - elt_labels: list[str | None] = [None] * len(elements) - elt_regions = _covering_region_indices(elt_geoms, region_geoms, region_labels=region_labels) - if len(region_geoms) > 0: - elt_labels = [str(region_labels[reg]) if reg >= 0 else None for reg in elt_regions] + # Lines, tables and figures (as floats) are ordered together + elements: list[Any] = [*lines, *page.tables, *picture_regions(page)] + items: list[Any] = [] + labels: list[str | None] = [] + if len(elements) > 0: + # De-skew once so labeling, ordering and region grouping share the same upright frame; the page angle is + # estimated from the word polygons, which carry the detection model's true orientation + elt_geoms, region_geoms = deskew_reading_geometries( + [elt.geometry for elt in elements], + [region.geometry for region in page.layout], + page_shape=page.dimensions, + angle_geoms=[word.geometry for word in words], + ) + region_labels = [region.type for region in page.layout] + elt_regions = _covering_region_indices(elt_geoms, region_geoms, region_labels=region_labels) + elt_labels = _element_labels(page, elements, elt_geoms, elt_regions, region_geoms) + segments = resolve_reading_segments( + elt_geoms, + direction=direction, + labels=[_order_label(elt, label) for elt, label in zip(elements, elt_labels)], + page_shape=page.dimensions, + column_voters=_column_voters(elements, elt_geoms, elt_regions, region_geoms, region_labels), + region_groups=_merge_overlapping_regions(elt_regions, region_geoms, region_labels), + ) + items, labels = _segments_to_items(page, elements, segments, elt_labels, elt_regions) + _store_reading_order(page, signature, (items, labels, direction)) + return _select_items(list(items), list(labels), direction, include_figures) + + +def _element_labels( + page: "Page", elements: list[Any], elt_geoms: list[Any], elt_regions: list[int], region_geoms: list[Any] +) -> list[str | None]: + """Return the layout label of each element: the label of its layout region (None for none), 'Table' for a table + + A figure takes its own type, unless it lies in page furniture (e.g. a logo in the page header): it is then read with + the furniture, along with its text. + """ + from doctr.io.elements import LayoutElement, Table + from doctr.models.reading_order import layout_label_role + from doctr.models.reading_order.base import _FURNITURE_ROLES - # A figure lying in page furniture (e.g. a logo in the page header) is read with it, along with its text + region_labels = [str(region.type) for region in page.layout] + labels: list[str | None] = [region_labels[reg] if reg >= 0 else None for reg in elt_regions] furniture = [reg for reg, label in enumerate(region_labels) if layout_label_role(label) in _FURNITURE_ROLES] - fig_idcs = [idx for idx, elt in enumerate(elements) if isinstance(elt, LayoutElement)] - fig_furniture = _covering_region_indices( - [elt_geoms[idx] for idx in fig_idcs], [region_geoms[reg] for reg in furniture] + figures = [idx for idx, elt in enumerate(elements) if isinstance(elt, LayoutElement)] + in_furniture = _covering_region_indices( + [elt_geoms[idx] for idx in figures], [region_geoms[reg] for reg in furniture] ) region_index = {id(region): reg for reg, region in enumerate(page.layout)} - furniture_labels: dict[int, str] = {} # figure region index -> furniture label - for idx, reg in zip(fig_idcs, fig_furniture): - elt_labels[idx] = str(region_labels[furniture[reg]]) if reg >= 0 else elements[idx].type + furniture_of_figure: dict[int, str] = {} # figure region index -> label of the furniture holding it + for idx, reg in zip(figures, in_furniture): + labels[idx] = region_labels[furniture[reg]] if reg >= 0 else elements[idx].type if reg >= 0: - furniture_labels[region_index[id(elements[idx])]] = str(region_labels[furniture[reg]]) + furniture_of_figure[region_index[id(elements[idx])]] = region_labels[furniture[reg]] for idx, elt in enumerate(elements): if isinstance(elt, Table): - elt_labels[idx] = "Table" - elif not isinstance(elt, LayoutElement) and elt_regions[idx] in furniture_labels: - elt_labels[idx] = furniture_labels[elt_regions[idx]] - # Page-wide figures and tables do not vote in the multi-column detection, nor do the text lines of a page-wide table - # region: its cells (e.g. the amounts of a receipt detected as a table) would read as columns + labels[idx] = "Table" + elif not isinstance(elt, LayoutElement) and elt_regions[idx] in furniture_of_figure: + labels[idx] = furniture_of_figure[elt_regions[idx]] + return labels + + +def _order_label(element: Any, label: str | None) -> str | None: + """Return the label used to order an element: a text line in a table or figure region keeps the label of its + region, but is grouped into paragraphs like regular text""" + from doctr.io.elements import LayoutElement, Table + from doctr.models.reading_order import layout_label_role + from doctr.models.reading_order.base import _float_text_label + + if not isinstance(element, (Table, LayoutElement)) and layout_label_role(label) == "float": + return _float_text_label(str(label)) + return label + + +def _column_voters( + elements: list[Any], elt_geoms: list[Any], elt_regions: list[int], region_geoms: list[Any], region_labels: list[str] +) -> list[bool]: + """Return the elements voting in the multi-column detection + + Page-wide figures and tables do not vote, nor do the text lines of a page-wide table region: its cells (e.g. the + amounts of a receipt detected as a table) would read as columns. + """ + from doctr.io.elements import LayoutElement, Table + from doctr.models.reading_order import normalize_layout_label + from doctr.models.reading_order.base import _to_boxes + boxes = _to_boxes(elt_geoms) span = float(boxes[:, 2].max() - boxes[:, 0].min()) or 1.0 - region_boxes = _to_boxes(region_geoms) if len(region_geoms) > 0 else np.zeros((0, 4)) + region_boxes = _to_boxes(region_geoms) wide_tables = { reg for reg, (label, box) in enumerate(zip(region_labels, region_boxes)) if normalize_layout_label(label) == "table" and box[2] - box[0] > 0.5 * span } - column_voters = [ - not (isinstance(elt, (LayoutElement, Table)) and box[2] - box[0] > 0.5 * span) - and (isinstance(elt, (LayoutElement, Table)) or reg not in wide_tables) + return [ + not (box[2] - box[0] > 0.5 * span) if isinstance(elt, (LayoutElement, Table)) else reg not in wide_tables for elt, box, reg in zip(elements, boxes, elt_regions) ] - # A text line in a table or figure region keeps its label, but is grouped into paragraphs like regular text - order_labels = [ - _float_text_label(str(label)) - if not isinstance(elt, (Table, LayoutElement)) and layout_label_role(label) == "float" - else label - for elt, label in zip(elements, elt_labels) - ] - segments = resolve_reading_segments( - elt_geoms, - direction=direction, - labels=order_labels, - page_shape=page.dimensions, - column_voters=column_voters, - region_groups=_merge_overlapping_regions(elt_regions, region_geoms, region_labels), - ) - def _is_float_item(idx: int) -> bool: - return isinstance(elements[idx], (Table, LayoutElement)) - def _split_floats(segment: list[int]) -> list[list[int]]: - """Split the tables and figures out of a segment (a figure in page furniture can share one)""" - runs: list[list[int]] = [] - for idx in segment: - if runs and not _is_float_item(idx) and not _is_float_item(runs[-1][-1]): - runs[-1].append(idx) - else: - runs.append([idx]) - return runs - - segments = [run for segment in segments for run in _split_floats(segment)] - - items = [] - labels = [] - line_owner = {id(line): idx for idx, block in enumerate(page.blocks) for line in block.lines} - pending_artefacts = {idx: list(block.artefacts) for idx, block in enumerate(page.blocks) if block.artefacts} - - def _claim_artefacts(block_lines: list[Any]) -> list[Any]: - claimed: list[Any] = [] - for line in block_lines: - owner = line_owner.get(id(line)) - if owner is not None and owner in pending_artefacts: - claimed.extend(pending_artefacts.pop(owner)) - return claimed - - # Region index covering each element, used to group the lines of a wrapped list item under a single bullet - region_idx = elt_regions - open_list_region: int | None = None # region of the list bullet currently being built (None outside a list) - for segment in segments: - first = elements[segment[0]] - seg_label = elt_labels[segment[0]] +def _segments_to_items( + page: "Page", elements: list[Any], segments: list[list[int]], elt_labels: list[str | None], elt_regions: list[int] +) -> tuple[list[Any], list[str | None]]: + """Turn reading-ordered segments of elements into page items: one block per paragraph, one block per list item + (a list item wrapped over several lines is one block), tables and figures as they are + + The artefacts of a block follow its first line; those of blocks without lines go to the last block. + """ + from doctr.io.elements import LayoutElement, Table + from doctr.models.reading_order import normalize_layout_label + + artefacts = _ArtefactClaims(page.blocks) + items: list[Any] = [] + labels: list[str | None] = [] + list_region: int | None = None # region of the list item being built + for run in (run for segment in segments for run in _float_runs(elements, segment)): + first, label = elements[run[0]], elt_labels[run[0]] if isinstance(first, (Table, LayoutElement)): items.append(first) - labels.append("Table" if isinstance(first, Table) else seg_label) - open_list_region = None - continue - if normalize_layout_label(seg_label) in _LIST_LABELS: - # One bullet per list-item region: consecutive lines sharing the same region are one bullet, so a - # list item wrapped over several visual lines renders as a single bullet point. - for idx in segment: - region = region_idx[idx] - if open_list_region is not None and region == open_list_region and region != -1: - merged = [*items[-1].lines, elements[idx]] - items[-1] = Block(lines=merged, artefacts=[*items[-1].artefacts, *_claim_artefacts([merged[-1]])]) + labels.append("Table" if isinstance(first, Table) else label) + list_region = None + elif normalize_layout_label(label) in _LIST_LABELS: + # One bullet per list-item region: consecutive lines of the same region are a single bullet + for idx in run: + region = elt_regions[idx] + if region != -1 and region == list_region: + items[-1] = artefacts.block([*items[-1].lines, elements[idx]], items[-1].artefacts) else: - items.append(Block(lines=[elements[idx]], artefacts=_claim_artefacts([elements[idx]]))) - labels.append(seg_label) - open_list_region = region + items.append(artefacts.block([elements[idx]])) + labels.append(label) + list_region = region else: - block_lines = [elements[idx] for idx in segment] - items.append(Block(lines=block_lines, artefacts=_claim_artefacts(block_lines))) - labels.append(seg_label) - open_list_region = None - # Artefacts of blocks without any line stay attached to the page - leftover = [artefact for artefacts in pending_artefacts.values() for artefact in artefacts] - if leftover: + items.append(artefacts.block([elements[idx] for idx in run])) + labels.append(label) + list_region = None + artefacts.attach_leftovers(items) + return items, labels + + +class _ArtefactClaims: + """Hand the artefacts of the page blocks over to the blocks built in reading order + + Args: + blocks: the blocks of the page + """ + + def __init__(self, blocks: list[Any]) -> None: + self.owner = {id(line): idx for idx, block in enumerate(blocks) for line in block.lines} + self.pending = {idx: list(block.artefacts) for idx, block in enumerate(blocks) if block.artefacts} + + def block(self, lines: list[Any], artefacts: list[Any] | None = None) -> Any: + """Build a block, which claims the artefacts of the blocks its lines come from (unless already claimed)""" + from doctr.io.elements import Block + + claimed = list(artefacts or []) + for line in lines: + owner = self.owner.get(id(line)) + if owner is not None and owner in self.pending: + claimed.extend(self.pending.pop(owner)) + return Block(lines=lines, artefacts=claimed) + + def attach_leftovers(self, items: list[Any]) -> None: + """Attach the artefacts of blocks without any line to the last block, so they stay on the page""" + from doctr.io.elements import Block + + leftover = [artefact for artefacts in self.pending.values() for artefact in artefacts] last_block = next((item for item in reversed(items) if isinstance(item, Block)), None) - if last_block is not None: + if leftover and last_block is not None: last_block.artefacts = [*last_block.artefacts, *leftover] - _store_reading_order(page, signature, (items, labels, direction)) - return _select_items(list(items), list(labels), direction, include_figures) + + +def _float_runs(elements: list[Any], segment: list[int]) -> list[list[int]]: + """Split the tables and figures out of a segment (a figure in page furniture can share one)""" + from doctr.io.elements import LayoutElement, Table + + runs: list[list[int]] = [] + for idx in segment: + is_float = isinstance(elements[idx], (Table, LayoutElement)) + if runs and not is_float and not isinstance(elements[runs[-1][-1]], (Table, LayoutElement)): + runs[-1].append(idx) + else: + runs.append([idx]) + return runs def _select_items( @@ -392,6 +435,145 @@ class _FigurePlan(NamedTuple): hidden: set[int] # text left out of the export +class _PageFigures: + """The figures of a linearized page, in the upright frame of the reading order + + Args: + page: the page + items: the linearized page content, figures included + labels: the layout label of each item + """ + + def __init__(self, page: "Page", items: list[Any], labels: list[str | None]) -> None: + from doctr.io.elements import Block + from doctr.models.reading_order import deskew_reading_geometries + + self.items, self.labels = items, labels + # Same upright frame as the reading order, for the items and the lines of the blocks + block_lines = [(idx, line) for idx, item in enumerate(items) if isinstance(item, Block) for line in item.lines] + upright, upright_regions = deskew_reading_geometries( + [item.geometry for item in items] + [line.geometry for _, line in block_lines], + [region.geometry for region in page.layout], + page_shape=page.dimensions, + angle_geoms=[word.geometry for block in page.blocks for line in block.lines for word in line.words], + ) + self.boxes = [_xyxy(geom) for geom in upright[: len(items)]] + self.line_boxes: dict[int, list[tuple[float, ...]]] = {} + for (owner, _), geom in zip(block_lines, upright[len(items) :]): + self.line_boxes.setdefault(owner, []).append(_xyxy(geom)) + self.region_of = _covering_region_indices( + upright[: len(items)], upright_regions, region_labels=[region.type for region in page.layout] + ) + + def in_furniture(self, fig: int) -> bool: + """Whether a figure is read with the page furniture""" + from doctr.models.reading_order import layout_label_role + from doctr.models.reading_order.base import _FURNITURE_ROLES + + return layout_label_role(self.labels[fig]) in _FURNITURE_ROLES + + def without_furniture(self, figs: list[int]) -> tuple[list[int], set[int]]: + """Leave out the figures read with the page furniture + + Returns: + the other figures, and the text of the figures left out + """ + furniture = [fig for fig in figs if self.in_furniture(fig)] + return [fig for fig in figs if fig not in furniture], {text for fig in furniture for text in self.text_of(fig)} + + def _inside(self, idx: int, fig: int) -> bool: + """Whether each line of a block lies in a figure, by at least half of its area""" + target = self.boxes[fig] + + def _coverage(box: tuple[float, ...]) -> float: + inter = max(min(box[2], target[2]) - max(box[0], target[0]), 0.0) * max( + min(box[3], target[3]) - max(box[1], target[1]), 0.0 + ) + return inter / max((box[2] - box[0]) * (box[3] - box[1]), 1e-9) + + return all(_coverage(box) >= 0.5 for box in self.line_boxes.get(idx, [self.boxes[idx]])) + + def _is_block(self, idx: int) -> bool: + from doctr.io.elements import Block + + return 0 <= idx < len(self.items) and isinstance(self.items[idx], Block) + + def _is_caption(self, idx: int) -> bool: + from doctr.models.reading_order import normalize_layout_label + + return self._is_block(idx) and normalize_layout_label(self.labels[idx]) == "caption" + + def text_of(self, fig: int) -> set[int]: + """The blocks inside a figure and labeled as part of it (or as furniture, for a figure in furniture)""" + from doctr.models.reading_order import layout_label_role + from doctr.models.reading_order.base import _FURNITURE_ROLES + + in_furniture = self.in_furniture(fig) + return { + idx + for idx in range(len(self.items)) + if self._is_block(idx) + and ( + is_picture_label(self.labels[idx]) + or (in_furniture and layout_label_role(self.labels[idx]) in _FURNITURE_ROLES) + ) + and self._inside(idx, fig) + } + + def _caption_run(self, start: int) -> tuple[int, ...]: + """The caption blocks of the caption region of a block (a caption region can span several blocks)""" + if self.region_of[start] == -1: + return (start,) + return tuple( + idx + for idx in range(len(self.items)) + if self._is_caption(idx) and self.region_of[idx] == self.region_of[start] + ) + + def _distance(self, run: tuple[int, ...], target: int) -> float: + from doctr.models.reading_order.base import _caption_distance + + run_box = tuple(func(self.boxes[idx][k] for idx in run) for k, func in enumerate((min, min, max, max))) + return _caption_distance(run_box, self.boxes[target]) + + def _table_without_caption(self, idx: int, run: tuple[int, ...]) -> bool: + """Whether a table stands next to a caption run, without its own caption on its other side""" + from doctr.io.elements import Table + + if not (0 <= idx < len(self.items) and isinstance(self.items[idx], Table)): + return False + return not self._is_caption(idx + 1 if idx > run[-1] else idx - 1) + + def captions(self, figures: list[int]) -> dict[int, tuple[int, ...]]: + """Pair figures and captions: each caption read right before a figure, or right after the text inside it, goes + to its closest float (a table without caption competes for it), and each figure keeps its closest caption + + Args: + figures: the figures to caption + + Returns: + the caption blocks of each captioned figure + """ + claims: dict[tuple[int, ...], list[int]] = {} + for fig in figures: + after = fig + 1 + while self._is_block(after) and not self._is_caption(after) and self._inside(after, fig): + after += 1 + for cap in (fig - 1, after): + if self._is_caption(cap): + claims.setdefault(self._caption_run(cap), []).append(fig) + captions: dict[int, tuple[int, ...]] = {} + for run, candidates in claims.items(): + tables = [idx for idx in (run[0] - 1, run[-1] + 1) if self._table_without_caption(idx, run)] + best = min([*candidates, *tables], key=lambda target: self._distance(run, target)) + if best in tables or ( + best in captions and self._distance(captions[best], best) <= self._distance(run, best) + ): + continue + captions[best] = run + return captions + + class _PageTextExporter: """Shared logic of the reading-order-aware text exporters @@ -489,136 +671,40 @@ def _plan_figures( Returns: the figure markup, the captions rendered with their figure and the text to leave out """ - from doctr.io.elements import Block, LayoutElement, Table - from doctr.models.reading_order import deskew_reading_geometries, layout_label_role, normalize_layout_label - from doctr.models.reading_order.base import _FURNITURE_ROLES, _caption_distance + from doctr.io.elements import LayoutElement figure_idcs = [idx for idx, item in enumerate(items) if isinstance(item, LayoutElement)] render = self.supports_figures and encoder.enabled if not figure_idcs or not (render or not include_furniture): return _FigurePlan({}, set(), set()) - # Same upright frame as the reading order (the lines of the blocks too) - line_owners = [idx for idx, item in enumerate(items) if isinstance(item, Block) for _ in item.lines] - upright, upright_regions = deskew_reading_geometries( - [item.geometry for item in items] - + [line.geometry for item in items if isinstance(item, Block) for line in item.lines], - [region.geometry for region in page.layout], - page_shape=page.dimensions, - angle_geoms=[word.geometry for block in page.blocks for line in block.lines for word in line.words], - ) - boxes = [_xyxy(geom) for geom in upright[: len(items)]] - line_boxes: dict[int, list[tuple[float, ...]]] = {} - for owner, geom in zip(line_owners, upright[len(items) :]): - line_boxes.setdefault(owner, []).append(_xyxy(geom)) - - def _coverage(box: tuple[float, ...], target: tuple[float, ...]) -> float: - """Share of `box` covered by `target`""" - inter = max(min(box[2], target[2]) - max(box[0], target[0]), 0.0) * max( - min(box[3], target[3]) - max(box[1], target[1]), 0.0 - ) - return inter / max((box[2] - box[0]) * (box[3] - box[1]), 1e-9) - - def _inside(idx: int, fig_idx: int) -> bool: - """Whether each line of a block lies in a figure""" - return all(_coverage(box, boxes[fig_idx]) >= 0.5 for box in line_boxes.get(idx, [boxes[idx]])) - - def _figure_text(fig_idx: int) -> set[int]: - """Blocks covered by a figure and labeled as part of it (or as furniture, for a figure in furniture)""" - in_furniture = layout_label_role(labels[fig_idx]) in _FURNITURE_ROLES - return { - idx - for idx, item in enumerate(items) - if isinstance(item, Block) - and ( - is_picture_label(labels[idx]) - or (in_furniture and layout_label_role(labels[idx]) in _FURNITURE_ROLES) - ) - and _inside(idx, fig_idx) - } - + figures = _PageFigures(page, items, labels) hidden: set[int] = set() if not include_furniture: - # Figures read with the page furniture are left out with it - furniture = {idx for idx in figure_idcs if layout_label_role(labels[idx]) in _FURNITURE_ROLES} - for idx in furniture: - hidden |= _figure_text(idx) - figure_idcs = [idx for idx in figure_idcs if idx not in furniture] + # Figures read with the page furniture are left out with it, along with their text + figure_idcs, hidden = figures.without_furniture(figure_idcs) if not render or not figure_idcs: return _FigurePlan({}, set(), hidden) sources = {idx: encoder.source(page, items[idx], rank) for rank, idx in enumerate(figure_idcs, start=1)} - # Only a figure exported with its pixels makes its text redundant - for idx in figure_idcs: - if sources[idx] is not None: - hidden |= _figure_text(idx) - region_of = _covering_region_indices( - upright[: len(items)], upright_regions, region_labels=[r.type for r in page.layout] - ) - - def _is_caption(idx: int) -> bool: - return isinstance(items[idx], Block) and normalize_layout_label(labels[idx]) == "caption" - - def _is_inner_text(idx: int, fig_idx: int) -> bool: - return isinstance(items[idx], Block) and not _is_caption(idx) and _inside(idx, fig_idx) - - def _caption_run(start: int) -> tuple[int, ...]: - # A caption region can span several blocks - if region_of[start] == -1: - return (start,) - return tuple(idx for idx in range(len(items)) if _is_caption(idx) and region_of[idx] == region_of[start]) - - def _run_box(run: tuple[int, ...]) -> tuple[float, ...]: - return tuple(func(boxes[idx][k] for idx in run) for k, func in enumerate((min, min, max, max))) - - # Candidate captions: right before the figure, or right after the text inside it - claims: dict[tuple[int, ...], list[int]] = {} - for idx in figure_idcs: - if sources[idx] is None: - continue - after = idx + 1 - while after < len(items) and _is_inner_text(after, idx): - after += 1 - for cap in (idx - 1, after): - if 0 <= cap < len(items) and _is_caption(cap): - claims.setdefault(_caption_run(cap), []).append(idx) - - def _table_without_caption(idx: int, run: tuple[int, ...]) -> bool: - # A table with its own caption on its other side does not compete for this one - if not (0 <= idx < len(items) and isinstance(items[idx], Table)): - return False - far = idx + 1 if idx > run[-1] else idx - 1 - return not (0 <= far < len(items) and _is_caption(far)) - - # Each caption goes to its closest float, and each figure keeps its closest caption - captions: dict[int, tuple[int, ...]] = {} - for run, candidates in claims.items(): - cap_box = _run_box(run) - tables = [idx for idx in (run[0] - 1, run[-1] + 1) if _table_without_caption(idx, run)] - best = min([*candidates, *tables], key=lambda elt: _caption_distance(cap_box, boxes[elt])) - if best in tables: - continue - dist = _caption_distance(cap_box, boxes[best]) - if best in captions and _caption_distance(_run_box(captions[best]), boxes[best]) <= dist: - continue - captions[best] = run - + # Only a figure exported with its pixels makes its text redundant, and takes a caption as alternative text + shown = [idx for idx in figure_idcs if sources[idx] is not None] + hidden |= {text for idx in shown for text in figures.text_of(idx)} + captions = figures.captions(shown) markup: dict[int, str] = {} consumed: set[int] = set() for idx in figure_idcs: - caption = None - if idx in captions: - lines = [ - line - for cap_idx in captions[idx] - for line in self._block_lines(items[cap_idx], direction, False, auto) - ] - caption = _join_caption_lines(lines) or None - if caption is not None: - consumed.update(captions[idx]) + caption = self._caption_text([items[cap] for cap in captions.get(idx, ())], direction, auto) + if caption is not None: + consumed.update(captions[idx]) markup[idx] = self.render_figure(sources[idx], caption, escape=escape) return _FigurePlan(markup, consumed, hidden) + def _caption_text(self, blocks: list[Any], direction: str, auto: bool) -> str | None: + """Join the lines of the caption blocks of a figure into one caption (None without any text)""" + lines = [line for block in blocks for line in self._block_lines(block, direction, False, auto)] + return _join_caption_lines(lines) or None + def export_page( self, page: "Page", diff --git a/doctr/io/figures.py b/doctr/io/figures.py index e30d0014e0..15aa91b382 100644 --- a/doctr/io/figures.py +++ b/doctr/io/figures.py @@ -69,16 +69,15 @@ def picture_regions(page: "Page") -> list["LayoutElement"]: Returns: the figure regions, in detection order """ - from doctr.models.reading_order.base import _to_boxes + from doctr.models.reading_order.base import _box_areas, _pairwise_overlaps, _to_boxes regions = [region for region in (getattr(page, "layout", None) or []) if is_picture_region(region)] if len(regions) < 2: return regions boxes = _to_boxes([region.geometry for region in regions]) - inter_w = np.minimum(boxes[:, None, 2], boxes[None, :, 2]) - np.maximum(boxes[:, None, 0], boxes[None, :, 0]) - inter_h = np.minimum(boxes[:, None, 3], boxes[None, :, 3]) - np.maximum(boxes[:, None, 1], boxes[None, :, 1]) - areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) - inside = np.clip(inter_w, 0, None) * np.clip(inter_h, 0, None) >= 0.5 * areas[:, None] + inter_w, inter_h = _pairwise_overlaps(boxes, boxes) + areas = _box_areas(boxes) + inside = inter_w * inter_h >= 0.5 * areas[:, None] # A region is nested when half of it lies in a larger region (or in an earlier one of the same area) rank = np.empty(len(regions), dtype=int) rank[np.argsort(-areas, kind="stable")] = np.arange(len(regions)) diff --git a/doctr/models/builder.py b/doctr/models/builder.py index ec9c31da0f..779161b92e 100644 --- a/doctr/models/builder.py +++ b/doctr/models/builder.py @@ -131,52 +131,31 @@ def _resolve_sub_lines( A list of (sub-)lines computed from the original line (words) """ break_dist = self.paragraph_break if break_dist is None else break_dist - lines = [] - # Sort words horizontally - word_idcs = [word_idcs[idx] for idx in boxes[word_idcs, 0].argsort().tolist()] - - # Eventually split line horizontally - if len(word_idcs) < 2: - lines.append(word_idcs) - else: - sub_line = [word_idcs[0]] - right = boxes[word_idcs[0], 2] # a box can be nested in an earlier one: keep the max right edge - groups, coverage = word_regions if word_regions is not None else (None, None) - region = -1 if groups is None else int(groups[word_idcs[0]]) # region of the sub-line - prev = word_idcs[0] - for i in word_idcs[1:]: - horiz_break = True - - # Compute distance between the sub-line and the box - dist = boxes[i, 0] - right - # If the distance is lower than the break distance, same sub-line - if dist < break_dist: - horiz_break = False - # Unless the box and the sub-line lie in distinct regions (e.g. columns separated by a narrow gutter) - box_region = -1 if groups is None else int(groups[i]) - if ( + groups, coverage = word_regions if word_regions is not None else (None, None) + lines: list[list[int]] = [] + right, region, prev = 0.0, -1, -1 # right edge, region and last word of the current sub-line + for idx in [word_idcs[i] for i in boxes[word_idcs, 0].argsort().tolist()]: + box_region = -1 if groups is None else int(groups[idx]) + # A new sub-line after a gap of at least `break_dist` from the sub-line (a box can be nested in an earlier + # one: its max right edge is used), or between two distinct regions (e.g. columns with a narrow gutter) + if ( + not lines + or not boxes[idx, 0] - right < break_dist + or ( coverage is not None and box_region >= 0 and region >= 0 and box_region != region - and coverage[i, region] < 0.5 + and coverage[idx, region] < 0.5 and coverage[prev, box_region] < 0.5 - ): - horiz_break = True - - if horiz_break: - lines.append(sub_line) - sub_line = [] - right = boxes[i, 2] - region = -1 - - sub_line.append(i) - right = max(right, boxes[i, 2]) - if region < 0: - region = box_region - prev = i - lines.append(sub_line) - + ) + ): + lines.append([]) + right, region = boxes[idx, 2], -1 + lines[-1].append(idx) + right = max(right, boxes[idx, 2]) + region = box_region if region < 0 else region + prev = idx return lines @staticmethod @@ -198,7 +177,9 @@ def _word_regions( the region group of each box (-1 for none) and the coverage of each box by each group, None without regions """ from doctr.models.reading_order.base import ( + _box_areas, _covering_regions, + _pairwise_overlaps, _region_groups, _to_boxes, deskew_reading_geometries, @@ -214,19 +195,15 @@ def _word_regions( regions, words = _to_boxes(region_geoms), _to_boxes(word_geoms) groups = np.asarray(_region_groups(regions, region_labels)) covering = np.asarray(_covering_regions(words, regions, 0.5, region_labels)) - inter_w = np.minimum(words[:, None, 2], regions[None, :, 2]) - np.maximum( - words[:, None, 0], regions[None, :, 0] - ) - inter_h = np.minimum(words[:, None, 3], regions[None, :, 3]) - np.maximum( - words[:, None, 1], regions[None, :, 1] - ) - x_cov = np.clip(inter_w, 0, None) / np.clip(words[:, 2:3] - words[:, 0:1], 1e-9, None) - y_cov = np.clip(inter_h, 0, None) / np.clip(words[:, 3:4] - words[:, 1:2], 1e-9, None) + inter_w, inter_h = _pairwise_overlaps(words, regions) + x_cov = inter_w / np.clip(words[:, 2:3] - words[:, 0:1], 1e-9, None) + y_cov = inter_h / np.clip(words[:, 3:4] - words[:, 1:2], 1e-9, None) uncovered = np.flatnonzero(covering < 0) - if uncovered.size > 0: # e.g. a word sticking out of a region slightly too short - areas = (regions[:, 2] - regions[:, 0]) * (regions[:, 3] - regions[:, 1]) + if ( + uncovered.size > 0 + ): # e.g. a word sticking out of a region slightly too short: the smallest region holding it holding = (x_cov[uncovered] >= 0.9) & (y_cov[uncovered] >= 0.3) - smallest = np.where(holding, areas[None, :], np.inf).argmin(axis=1) + smallest = np.where(holding, _box_areas(regions)[None, :], np.inf).argmin(axis=1) covering[uncovered] = np.where(holding.any(axis=1), smallest, -1) coverage = np.zeros((words.shape[0], regions.shape[0])) np.maximum.at(coverage.T, groups, (x_cov * y_cov).T) @@ -582,6 +559,88 @@ def _localize_logic(cells: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], norm.append(nc) return norm, max_row + 1, max_col + 1 + @classmethod + def _table_grids( + cls, page_table: dict[str, Any] | list[dict[str, Any]] | None + ) -> list[tuple[list[dict[str, Any]], int, int]]: + """Normalize the table structure prediction(s) of a page to non-empty grids with local logical coordinates + + Args: + page_table: a single grid, a list of grids or None (cf. `_build_tables`) + + Returns: + the cells, number of rows and number of columns of each non-empty grid + """ + raw_tables = [] if page_table is None else [page_table] if isinstance(page_table, dict) else list(page_table) + return [cls._localize_logic(raw["cells"]) for raw in raw_tables if raw and len(raw.get("cells", [])) > 0] + + @classmethod + def _assign_cell_words(cls, centers: np.ndarray, polys: np.ndarray, consumed: np.ndarray) -> list[list[int]]: + """Assign each word not yet consumed to at most one cell of a table, and flag it as consumed + + A word goes to the first cell containing its center. A word landing just outside every cell but inside the + table goes to the nearest cell, within half a median cell diagonal, so table text is not dropped into the body. + + Args: + centers: the (N, 2) word centers + polys: the (C, 4, 2) cell polygons, vertices ordered TL, TR, BR, BL + consumed: the (N,) mask of the words already assigned to a table, updated in place + + Returns: + the word indices of each cell + """ + cell_words: list[list[int]] = [[] for _ in polys] + free = np.flatnonzero(~consumed) + if free.size == 0: + return cell_words + inside = cls._points_in_polygons(centers[free], polys) # (F, C) + assigned = inside.any(axis=1) + for w_idx, c_idx in zip(free[assigned], inside[assigned].argmax(axis=1)): + cell_words[c_idx].append(int(w_idx)) + consumed[w_idx] = True + leftover = free[~assigned] + if leftover.size > 0: + (tx0, ty0), (tx1, ty1) = polys.reshape(-1, 2).min(axis=0), polys.reshape(-1, 2).max(axis=0) + cell_centers = polys.mean(axis=1) # (C, 2) + max_dist = 0.5 * float(np.median(np.linalg.norm(polys[:, 2] - polys[:, 0], axis=1))) # TL->BR diagonal + in_table = ( + (centers[leftover, 0] >= tx0) + & (centers[leftover, 0] <= tx1) + & (centers[leftover, 1] >= ty0) + & (centers[leftover, 1] <= ty1) + ) + for w_idx in leftover[in_table]: + dists = np.linalg.norm(cell_centers - centers[w_idx], axis=1) + nearest = int(dists.argmin()) + if dists[nearest] <= max_dist: + cell_words[nearest].append(int(w_idx)) + consumed[w_idx] = True + return cell_words + + @staticmethod + def _deskew_centers(centers: np.ndarray, polys: np.ndarray) -> np.ndarray: + """Rotate word centers by the angle of a table, to order the words along its own axes + + Args: + centers: the (N, 2) word centers + polys: the (C, 4, 2) cell polygons, vertices ordered TL, TR, BR, BL + + Returns: + the (N, 2) de-skewed centers + """ + top_edges = polys[:, 1] - polys[:, 0] # TR - TL + angle = float(np.median(np.arctan2(top_edges[:, 1], top_edges[:, 0]))) + cos_a, sin_a = np.cos(-angle), np.sin(-angle) + pivot = centers.mean(axis=0) + shifted = centers - pivot + return np.stack( + [ + pivot[0] + shifted[:, 0] * cos_a - shifted[:, 1] * sin_a, + pivot[1] + shifted[:, 0] * sin_a + shifted[:, 1] * cos_a, + ], + axis=1, + ) + def _build_tables( self, boxes: np.ndarray, @@ -601,117 +660,37 @@ def _build_tables( a tuple with the list of `Table` objects of the page (one per provided table) and a boolean mask of shape (N,) that is True for words assigned to a table (to be removed from `blocks`) """ - num_words = boxes.shape[0] - consumed = np.zeros(num_words, dtype=bool) - if page_table is None: + consumed = np.zeros(boxes.shape[0], dtype=bool) + grids = self._table_grids(page_table) + if len(grids) == 0: return [], consumed - - # Normalize the prediction(s) to a list of per-table grids with local 0-based logical coordinates - raw_tables = [page_table] if isinstance(page_table, dict) else list(page_table) - table_dicts: list[dict[str, Any]] = [] - for raw in raw_tables: - if not raw or len(raw.get("cells", [])) == 0: - continue - cells, n_rows, n_cols = self._localize_logic(raw["cells"]) - table_dicts.append({"cells": cells, "num_rows": n_rows, "num_cols": n_cols}) - if len(table_dicts) == 0: - return [], consumed - - centers = self._word_centers(boxes) if num_words > 0 else np.empty((0, 2)) - # Geometry format follows the page's word geometry: straight 2-point boxes when the word boxes are - # (N, 4), 4-point polygons when they are (N, 4, 2). + # The geometries follow the page's word geometry: straight 2-point boxes for (N, 4) word boxes, 4-point polygons + # for (N, 4, 2) ones. Word heights are rotation-invariant (left edge length for polygons). straight = boxes.ndim != 3 - # Rotation-invariant word heights (left edge length for polygons), used to cluster cell words into rows - if num_words == 0: - word_heights = np.empty(0) - elif straight: - word_heights = boxes[:, 3] - boxes[:, 1] + centers = self._word_centers(boxes) if boxes.shape[0] > 0 else np.empty((0, 2)) + if boxes.shape[0] == 0: + heights = np.empty(0) else: - word_heights = np.linalg.norm(boxes[:, 3] - boxes[:, 0], axis=1) - - tables_out: list[Table] = [] - for table_dict in table_dicts: - cells = table_dict["cells"] - if len(cells) == 0: - continue - cell_polys = [self._as_cell_polygon(cell["geometry"]) for cell in cells] - polys_arr = np.stack(cell_polys) # (C, 4, 2), vertices ordered TL, TR, BR, BL - - # Order along the table's own axes instead by de-skewing the centers with the table angle - order_centers = centers - if not straight and centers.shape[0] > 0: - top_edges = polys_arr[:, 1] - polys_arr[:, 0] # TR - TL - angle = float(np.median(np.arctan2(top_edges[:, 1], top_edges[:, 0]))) - cos_a, sin_a = np.cos(-angle), np.sin(-angle) - pivot = centers.mean(axis=0) - shifted = centers - pivot - order_centers = np.stack( - [ - pivot[0] + shifted[:, 0] * cos_a - shifted[:, 1] * sin_a, - pivot[1] + shifted[:, 0] * sin_a + shifted[:, 1] * cos_a, - ], - axis=1, - ) - - # Assign each (still unassigned) word to at most one cell of this table - cell_word_idcs: list[list[int]] = [[] for _ in cells] - free_idcs = np.flatnonzero(~consumed) - if free_idcs.size > 0: - # The first cell (in cell order) whose polygon contains the word center - inside = self._points_in_polygons(centers[free_idcs], polys_arr) # (F, C) - assigned = inside.any(axis=1) - first_cell = np.where(assigned, inside.argmax(axis=1), -1) - for w_idx, c_idx in zip(free_idcs, first_cell): - if c_idx >= 0: - cell_word_idcs[c_idx].append(int(w_idx)) - consumed[w_idx] = True - - # Words that landed just outside every cell but still inside the table region are attached - # to the nearest cell, so table text is not dropped into the body. - # The capture radius is bounded by the cell size to avoid pulling in body words. - leftover = free_idcs[~assigned] - if leftover.size > 0: - tx0, ty0 = polys_arr[..., 0].min(), polys_arr[..., 1].min() - tx1, ty1 = polys_arr[..., 0].max(), polys_arr[..., 1].max() - cell_centers = polys_arr.mean(axis=1) # (C, 2) - cell_sizes = np.linalg.norm(polys_arr[:, 2] - polys_arr[:, 0], axis=1) # TL->BR diagonal - max_dist = 0.5 * float(np.median(cell_sizes)) - in_region = ( - (centers[leftover, 0] >= tx0) - & (centers[leftover, 0] <= tx1) - & (centers[leftover, 1] >= ty0) - & (centers[leftover, 1] <= ty1) - ) - for w_idx in leftover[in_region]: - dists = np.linalg.norm(cell_centers - centers[w_idx], axis=1) - nearest = int(dists.argmin()) - if dists[nearest] <= max_dist: - cell_word_idcs[nearest].append(int(w_idx)) - consumed[w_idx] = True - - # Build the cells - table_cells: list[TableCell] = [] - for cell, poly, w_idcs in zip(cells, cell_polys, cell_word_idcs): + heights = boxes[:, 3] - boxes[:, 1] if straight else np.linalg.norm(boxes[:, 3] - boxes[:, 0], axis=1) + + tables: list[Table] = [] + for cells, num_rows, num_cols in grids: + polys = np.stack([self._as_cell_polygon(cell["geometry"]) for cell in cells]) # (C, 4, 2) + order_centers = centers if straight or centers.shape[0] == 0 else self._deskew_centers(centers, polys) + table_cells = [] + for cell, poly, w_idcs in zip(cells, polys, self._assign_cell_words(centers, polys, consumed)): if len(w_idcs) > 0: # Reading order inside the cell: rows top to bottom, words left to right (table axes) - ordered = self._order_cell_words(w_idcs, order_centers, word_heights) + ordered = self._order_cell_words(w_idcs, order_centers, heights) value = " ".join(word_preds[i][0] for i in ordered) confidence = float(np.mean([word_preds[i][1] for i in ordered])) else: value, confidence = "", float(cell["score"]) - if straight: - xs, ys = poly[:, 0], poly[:, 1] - geometry: Any = ( - (float(xs.min()), float(ys.min())), - (float(xs.max()), float(ys.max())), - ) - else: - geometry = tuple(tuple(float(c) for c in pt) for pt in poly.tolist()) table_cells.append( TableCell( value=value, confidence=confidence, - geometry=geometry, + geometry=self._export_geometry(poly, straight), row_start=int(cell["row_start"]), row_end=int(cell["row_end"]), col_start=int(cell["col_start"]), @@ -719,27 +698,29 @@ def _build_tables( objectness_score=float(cell["score"]), ) ) - # Enclosing geometry of the whole table - all_pts = np.concatenate(cell_polys, axis=0) - xmin, ymin = float(all_pts[:, 0].min()), float(all_pts[:, 1].min()) - xmax, ymax = float(all_pts[:, 0].max()), float(all_pts[:, 1].max()) - table_geometry: Any = ( - ((xmin, ymin), (xmax, ymax)) if straight else ((xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)) - ) - table_confidence = float(np.mean([float(cell["score"]) for cell in cells])) - - tables_out.append( + (xmin, ymin), (xmax, ymax) = polys.reshape(-1, 2).min(axis=0), polys.reshape(-1, 2).max(axis=0) + corners = np.asarray([[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax]]) + tables.append( Table( cells=table_cells, - num_rows=int(table_dict["num_rows"]), - num_cols=int(table_dict["num_cols"]), - geometry=table_geometry, - confidence=table_confidence, + num_rows=num_rows, + num_cols=num_cols, + geometry=self._export_geometry(corners, straight), + confidence=float(np.mean([float(cell["score"]) for cell in cells])), ) ) + return tables, consumed - return tables_out, consumed + @staticmethod + def _export_geometry(poly: np.ndarray, straight: bool) -> Any: + """Export a (4, 2) polygon as a straight 2-point box or as a 4-point polygon, with Python floats""" + if straight: + return ( + (float(poly[:, 0].min()), float(poly[:, 1].min())), + (float(poly[:, 0].max()), float(poly[:, 1].max())), + ) + return tuple(tuple(float(c) for c in pt) for pt in poly.tolist()) def _build_blocks( self, @@ -771,49 +752,32 @@ def _build_blocks( if boxes.shape[0] == 0: return [] - # Decide whether we try to form lines - _boxes = boxes - if self.resolve_lines: + straight_or_rotated = boxes if boxes.ndim == 3 else boxes[:, :4] + if not self.resolve_lines: + # One line for all boxes, one block for the line + lines = [self._sort_boxes(straight_or_rotated, shape)[0].tolist()] + else: word_regions = ( - self._word_regions(_boxes, [elt.geometry for elt in layout], [elt.type for elt in layout], shape) + self._word_regions(boxes, [elt.geometry for elt in layout], [elt.type for elt in layout], shape) if layout else None ) - lines = self._resolve_lines(_boxes if _boxes.ndim == 3 else _boxes[:, :4], shape, word_regions) - # Decide whether we try to form blocks - if self.resolve_blocks and len(lines) > 1: - _blocks = self._resolve_blocks(_boxes if _boxes.ndim == 3 else _boxes[:, :4], lines) - else: - _blocks = [lines] - else: - # Sort bounding boxes, one line for all boxes, one block for the line - lines = [self._sort_boxes(_boxes if _boxes.ndim == 3 else _boxes[:, :4], shape)[0]] # type: ignore[list-item] - _blocks = [lines] - - blocks = [ - Block([ - Line([ - Word( - *word_preds[idx], - tuple(tuple(pt) for pt in boxes[idx].tolist()), # type: ignore[arg-type] - float(objectness_scores[idx]), - crop_orientations[idx], - ) - if boxes.ndim == 3 - else Word( - *word_preds[idx], - ((boxes[idx, 0], boxes[idx, 1]), (boxes[idx, 2], boxes[idx, 3])), - float(objectness_scores[idx]), - crop_orientations[idx], - ) - for idx in line - ]) - for line in lines - ]) - for lines in _blocks - ] + lines = self._resolve_lines(straight_or_rotated, shape, word_regions) + blocks = ( + self._resolve_blocks(straight_or_rotated, lines) + if self.resolve_lines and self.resolve_blocks and len(lines) > 1 + else [lines] + ) - return blocks + def _word(idx: int) -> Word: + geometry = ( + tuple(tuple(pt) for pt in boxes[idx].tolist()) + if boxes.ndim == 3 + else ((boxes[idx, 0], boxes[idx, 1]), (boxes[idx, 2], boxes[idx, 3])) + ) + return Word(*word_preds[idx], geometry, float(objectness_scores[idx]), crop_orientations[idx]) # type: ignore[arg-type] + + return [Block([Line([_word(idx) for idx in line]) for line in block]) for block in blocks] @staticmethod def _words_to_boxes(words: list[Word]) -> np.ndarray: diff --git a/doctr/models/reading_order/base.py b/doctr/models/reading_order/base.py index cb10422ce6..ff1265ece9 100644 --- a/doctr/models/reading_order/base.py +++ b/doctr/models/reading_order/base.py @@ -35,15 +35,16 @@ # Unicode bidirectional categories of strong right-to-left characters (Hebrew-like & Arabic-like) _RTL_BIDI_CATEGORIES = {"R", "AL"} -# Normalized layout labels (DocLayNet-style) grouped by their role in the reading order -_HEADER_LABELS = {"page_header", "header", "running_header"} -_FOOTER_LABELS = {"page_footer", "footer", "running_footer"} -_FOOTNOTE_LABELS = {"footnote"} -_CAPTION_LABELS = {"caption"} -_FLOAT_LABELS = {"table", "picture", "figure", "image", "chart", "graphic"} - +# Role in the reading order of the normalized layout labels (DocLayNet-style); any other label is body text +_LABEL_ROLES = { + **dict.fromkeys(("page_header", "header", "running_header"), "header"), + **dict.fromkeys(("page_footer", "footer", "running_footer"), "footer"), + "footnote": "footnote", + "caption": "caption", + **dict.fromkeys(("table", "picture", "figure", "image", "chart", "graphic"), "float"), +} +_FLOAT_LABELS = {label for label, role in _LABEL_ROLES.items() if role == "float"} _FURNITURE_ROLES = ("header", "footer", "footnote") - # Normalized labels of the text lying in a float region without float element (e.g. a table without structure) _FLOAT_TEXT_LABELS = {f"{label}_text" for label in _FLOAT_LABELS} @@ -79,18 +80,7 @@ def layout_label_role(label: str | None) -> str: Returns: one of 'header', 'footer', 'footnote', 'caption', 'float' or 'body' """ - norm = normalize_layout_label(label) - if norm in _HEADER_LABELS: - return "header" - if norm in _FOOTER_LABELS: - return "footer" - if norm in _FOOTNOTE_LABELS: - return "footnote" - if norm in _CAPTION_LABELS: - return "caption" - if norm in _FLOAT_LABELS: - return "float" - return "body" + return _LABEL_ROLES.get(normalize_layout_label(label), "body") def _float_text_label(label: str) -> str: @@ -107,7 +97,7 @@ def detect_text_direction(texts: Iterable[str], language: str | None = None) -> detection language-independent and robust to mixed content. When no strong character is found, the optional ISO 639 language code is used as a fallback hint. - >>> from doctr.utils.reading_order import detect_text_direction + >>> from doctr.models.reading_order import detect_text_direction >>> detect_text_direction(["Hello", "world"]) 'ltr' >>> detect_text_direction(["مرحبا", "بالعالم"]) @@ -120,30 +110,31 @@ def detect_text_direction(texts: Iterable[str], language: str | None = None) -> Returns: either "ltr" or "rtl" """ - rtl_count = 0 - ltr_count = 0 + rtl_count = ltr_count = 0 for text in texts: if not isinstance(text, str): continue for char in text: bidi = unicodedata.bidirectional(char) - if bidi in _RTL_BIDI_CATEGORIES: - rtl_count += 1 - elif bidi == "L": - ltr_count += 1 + rtl_count += bidi in _RTL_BIDI_CATEGORIES + ltr_count += bidi == "L" if rtl_count == 0 and ltr_count == 0: - if isinstance(language, str) and language.split("-")[0].lower() in RTL_LANGUAGES: - return "rtl" - return "ltr" + rtl_hint = isinstance(language, str) and language.split("-")[0].lower() in RTL_LANGUAGES + return "rtl" if rtl_hint else "ltr" return "rtl" if rtl_count > ltr_count else "ltr" +# --- Geometry helpers ------------------------------------------------------------------------------------------------- + + def _geometry_to_xyxy(geometry: Any) -> tuple[float, float, float, float]: + """Return the enclosing box (xmin, ymin, xmax, ymax) of a box or a polygon""" pts = np.asarray(geometry, dtype=np.float64).reshape(-1, 2) return float(pts[:, 0].min()), float(pts[:, 1].min()), float(pts[:, 0].max()), float(pts[:, 1].max()) def _to_boxes(geoms: Sequence[Any] | np.ndarray) -> np.ndarray: + """Return the (N, 4) enclosing boxes of geometries in any docTR format""" if isinstance(geoms, np.ndarray) and geoms.ndim == 2 and geoms.shape[1] >= 4: return geoms[:, :4].astype(np.float64) if isinstance(geoms, np.ndarray) and geoms.ndim == 3: @@ -153,6 +144,25 @@ def _to_boxes(geoms: Sequence[Any] | np.ndarray) -> np.ndarray: return np.asarray([_geometry_to_xyxy(geom) for geom in geoms], dtype=np.float64) +def _box_areas(boxes: np.ndarray) -> np.ndarray: + return (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + + +def _pairwise_overlaps(boxes: np.ndarray, others: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Width and height of the intersection of each (N, 4) box with each (M, 4) box (0 when they do not overlap)""" + inter_w = np.minimum(boxes[:, None, 2], others[None, :, 2]) - np.maximum(boxes[:, None, 0], others[None, :, 0]) + inter_h = np.minimum(boxes[:, None, 3], others[None, :, 3]) - np.maximum(boxes[:, None, 1], others[None, :, 1]) + return np.clip(inter_w, 0, None), np.clip(inter_h, 0, None) + + +def _is_inside(boxes: np.ndarray, idx: int, target: int, min_coverage: float = 0.5) -> bool: + """Check whether box `idx` lies inside box `target`, by at least `min_coverage` of its area""" + x0, y0, x1, y1 = boxes[idx] + tx0, ty0, tx1, ty1 = boxes[target] + inter = max(min(x1, tx1) - max(x0, tx0), 0.0) * max(min(y1, ty1) - max(y0, ty0), 0.0) + return inter >= min_coverage * max((x1 - x0) * (y1 - y0), 1e-9) + + def _to_canonical_ltr(boxes: np.ndarray, direction: str) -> np.ndarray: """Map boxes to a canonical left-to-right, top-to-bottom space for the given reading direction. @@ -162,13 +172,12 @@ def _to_canonical_ltr(boxes: np.ndarray, direction: str) -> np.ndarray: if direction == "ltr" or boxes.shape[0] == 0: return boxes x0, y0, x1, y1 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + pivot = float(x0.min() + x1.max()) if direction == "rtl": # mirror horizontally - pivot = float(x0.min() + x1.max()) return np.stack([pivot - x1, y0, pivot - x0, y1], axis=1) if direction == "ttb-ltr": # vertical lines, columns ordered left to right: swap axes return np.stack([y0, x0, y1, x1], axis=1) if direction == "ttb-rtl": # vertical lines, columns ordered right to left: swap axes & mirror - pivot = float(x0.min() + x1.max()) return np.stack([y0, pivot - x1, y1, pivot - x0], axis=1) raise ValueError(f"invalid reading direction '{direction}', should be one of {SUPPORTED_DIRECTIONS[1:]}") @@ -195,6 +204,170 @@ def _strict_rank(primary: np.ndarray, secondary: np.ndarray) -> np.ndarray: return ranks +def deskew_reading_geometries( + geoms: Sequence[Any] | np.ndarray, + region_geoms: Sequence[Any] | np.ndarray | None = None, + page_shape: tuple[int, int] | None = None, + angle_geoms: Sequence[Any] | np.ndarray | None = None, + min_angle: float = 1.0, +) -> tuple[list[Any], list[Any]]: + """De-skew rotated geometries so the reading order can be computed in an upright frame. + + Args: + geoms: geometries of the elements to order, in any docTR format (cf. `sort_reading_order`) + region_geoms: optional geometries of the layout regions, de-skewed together with the elements + page_shape: the page dimensions (height, width). Required for an exact angle on non-square pages with + relative coordinates, since relative coordinates distort angles by the aspect ratio. + angle_geoms: optional reading-oriented 4-point polygons used to estimate the page angle + min_angle: minimum estimated angle (in degrees) to trigger the de-skew. Ordering is affected as soon + as the drift along a line approaches the line height (about 1 degree for a page-wide line), and a + small rigid rotation cannot change the order of an upright page, hence the low default. Beyond 45 + degrees the corner identification is ambiguous (an upstream orientation correction is needed) and + nothing is done. + + Returns: + the (possibly de-skewed) element and region geometries + """ + region_geoms = list(region_geoms) if region_geoms is not None else [] + pts = [np.asarray(geom, dtype=np.float64).reshape(-1, 2) for geom in geoms] + if len(pts) == 0 or any(p.shape[0] != 4 for p in pts): + return list(geoms), region_geoms # straight geometries: nothing to de-skew + height, width = page_shape if page_shape is not None else (1, 1) + scale = np.array([width, height], dtype=np.float64) + angle_pts = [ + np.asarray(geom, dtype=np.float64).reshape(-1, 2) for geom in (angle_geoms if angle_geoms is not None else []) + ] + if len(angle_pts) > 0 and all(p.shape[0] == 4 for p in angle_pts): + # Detection polygons are already reading-oriented (cf. `estimate_page_angle`): keep their vertex order + angle = estimate_page_angle(np.stack(angle_pts) * scale) + else: + # Normalize the vertex order (TL, TR, BR, BL) so the estimation does not depend on the vertex + angle = estimate_page_angle(np.stack([order_points(p * scale) for p in pts])) + if not np.isfinite(angle) or abs(angle) < min_angle or abs(angle) >= 45: + return list(geoms), region_geoms + # Rigid rotation zeroing the estimated angle, in pixels; the center is irrelevant for ordering purposes + theta = np.deg2rad(angle) + rot = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) + center = (np.concatenate(pts, axis=0) * scale).mean(axis=0) + + def _rotate(points: np.ndarray) -> np.ndarray: + if points.shape[0] != 4: # a straight region: rotate its 4 corners, not only its diagonal + (x0, y0), (x1, y1) = points.min(axis=0), points.max(axis=0) + points = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]) + return ((points * scale - center) @ rot.T + center) / scale + + regions = [_rotate(np.asarray(geom, dtype=np.float64).reshape(-1, 2)) for geom in region_geoms] + return [_rotate(p) for p in pts], regions + + +# --- Layout regions --------------------------------------------------------------------------------------------------- + + +def _covering_regions( + boxes: np.ndarray, + regions: np.ndarray, + min_coverage: float, + region_labels: Sequence[str | None] | None = None, +) -> list[int]: + """Return the index of the region covering each (N, 4) box by at least `min_coverage`, -1 if none + + Regions covering a box (nearly) as much as the best one tie: the smallest one wins, unless it is a float tied with + page furniture. + """ + inter_w, inter_h = _pairwise_overlaps(boxes, regions) + coverage = inter_w * inter_h / np.clip(_box_areas(boxes), 1e-9, None)[:, None] + region_areas = _box_areas(regions) + qualifies = coverage >= np.maximum(coverage.max(axis=1, keepdims=True) - 0.1, min_coverage) + # Regions nested in the best region of a box also tie when they cover most of it + reg_w, reg_h = _pairwise_overlaps(regions, regions) + nested = reg_w * reg_h >= 0.9 * np.clip(region_areas, 1e-9, None)[:, None] # nested[i, j]: region i lies inside j + qualifies |= (coverage >= max(min_coverage, 0.75)) & nested[:, coverage.argmax(axis=1)].T + ranked = np.where(qualifies, region_areas[None, :], np.inf) + best = ranked.argmin(axis=1) + result = [int(reg) if np.isfinite(ranked[idx, reg]) else -1 for idx, reg in enumerate(best)] + if region_labels is None or len(region_labels) != regions.shape[0]: + return result + + roles = [layout_label_role(label) for label in region_labels] + is_float = np.array([role == "float" for role in roles], dtype=bool) + is_furniture = np.array([role in _FURNITURE_ROLES for role in roles], dtype=bool) + if not (is_float.any() and is_furniture.any()): + return result + # The text of a float tied with page furniture (e.g. a logo in the page header) is read with the furniture + furniture_ranked = np.where(qualifies & is_furniture[None, :], region_areas[None, :], np.inf) + for idx, reg in enumerate(result): + if reg >= 0 and is_float[reg] and np.isfinite(furniture_ranked[idx].min()): + result[idx] = int(furniture_ranked[idx].argmin()) + return result + + +def _region_groups(regions: np.ndarray, labels: Sequence[str | None], min_overlap: float = 0.1) -> list[int]: + """Return the group of each (R, 4) layout region (the index of one of its members), grouping the regions of the + same label which overlap by more than `min_overlap` of the smaller one (e.g. two regions for a paragraph around a + figure) + """ + norm = [normalize_layout_label(label) for label in labels] + areas = _box_areas(regions) + parent = list(range(regions.shape[0])) + + def _find(idx: int) -> int: + while parent[idx] != idx: + parent[idx] = parent[parent[idx]] + idx = parent[idx] + return idx + + # Pairwise with scalar min / max, as the comparisons of degenerate (NaN) boxes depend on the argument order + for i, j in ((i, j) for i in range(len(parent)) for j in range(i + 1, len(parent)) if norm[i] == norm[j]): + inter = max(min(regions[i, 2], regions[j, 2]) - max(regions[i, 0], regions[j, 0]), 0.0) * max( + min(regions[i, 3], regions[j, 3]) - max(regions[i, 1], regions[j, 1]), 0.0 + ) + if inter > min_overlap * min(areas[i], areas[j]): + parent[_find(i)] = _find(j) + return [_find(idx) for idx in range(len(parent))] + + +def assign_layout_labels( + geoms: Sequence[Any] | np.ndarray, + layout_geoms: Sequence[Any] | np.ndarray, + layout_labels: Sequence[str], + min_coverage: float = 0.5, + page_shape: tuple[int, int] | None = None, + angle_geoms: Sequence[Any] | np.ndarray | None = None, +) -> list[str | None]: + """Assign a layout label to each element based on its overlap with the detected layout regions. + + Each element receives the label of the region covering the largest share of its area, provided this share reaches + `min_coverage`; otherwise its label is None (treated as regular body content). Among nested regions, the smallest + one wins (e.g. 'Caption' over 'Picture'), except for a float nested in page furniture (e.g. a logo in the page + header): its text takes the furniture label. + + Args: + geoms: geometries of the elements to label, in any docTR format + layout_geoms: geometries of the layout regions, in any docTR format + layout_labels: labels of the layout regions (e.g. `[region.type for region in page.layout]`) + min_coverage: minimum share of an element's area a region must cover to assign its label + page_shape: the page dimensions (height, width), used to de-skew rotated pages exactly (cf. + `deskew_reading_geometries`) + angle_geoms: optional reading-oriented 4-point polygons (typically the page's word polygons) used to + estimate the page angle on rotated pages (cf. `deskew_reading_geometries`) + + Returns: + the label of each element (None when no region covers it enough) + """ + # De-skew elements and regions together so the coverage is measured on tight boxes in the same frame + geoms, layout_geoms = deskew_reading_geometries(geoms, layout_geoms, page_shape=page_shape, angle_geoms=angle_geoms) + boxes, regions = _to_boxes(geoms), _to_boxes(layout_geoms) + if len(layout_labels) != regions.shape[0]: + raise ValueError(f"Incompatible number of labels ({len(layout_labels)}) and regions ({regions.shape[0]})") + if boxes.shape[0] == 0 or regions.shape[0] == 0: + return [None] * boxes.shape[0] + covering = _covering_regions(boxes, regions, min_coverage, layout_labels) + return [str(layout_labels[reg]) if reg >= 0 else None for reg in covering] + + +# --- Topological sort ------------------------------------------------------------------------------------------------- + + def _key_value_rows( boxes: np.ndarray, voters: np.ndarray, split: float, y_overlap: np.ndarray, y_overlap_threshold: float, span: float ) -> bool: @@ -215,17 +388,13 @@ def _key_value_rows( Returns: whether the split separates the keys from the values of key-value rows """ - num_voters = int(np.count_nonzero(voters)) - if num_voters > _KEY_VALUE_MAX_VOTERS: + if np.count_nonzero(voters) > _KEY_VALUE_MAX_VOTERS: return False x0, y0, x1, y1 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] - left_side = voters & ((x0 + x1) / 2 <= split) - right = np.flatnonzero(voters & ~left_side) - left = np.flatnonzero(left_side) - if right.size == 0 or left.size == 0: - return False + on_left = voters & ((x0 + x1) / 2 <= split) + left, right = np.flatnonzero(on_left), np.flatnonzero(voters & ~on_left) height = float(np.median(y1[voters] - y0[voters])) - if not height > 0: + if right.size == 0 or left.size == 0 or not height > 0: return False aspect = float(np.median((x1[right] - x0[right]) / np.maximum(y1[right] - y0[right], 1e-9))) if aspect >= _KEY_VALUE_MAX_ASPECT: @@ -237,8 +406,43 @@ def _key_value_rows( if np.count_nonzero(paired) < 0.5 * right.size: return False ends = np.where(level, x1[left][None, :], -np.inf).max(axis=1) - gaps = (x0[right][paired] - ends[paired]) / height - return float(np.median(gaps)) >= _KEY_VALUE_MIN_GAP + return float(np.median((x0[right][paired] - ends[paired]) / height)) >= _KEY_VALUE_MIN_GAP + + +def _column_gutter( + boxes: np.ndarray, voters: np.ndarray, y_overlap: np.ndarray, y_overlap_threshold: float +) -> np.ndarray | None: + """Detect a multi-column page: a vertical line separating the voters into two groups crossed by few of them + + Args: + boxes: the (N, 4) boxes in canonical LTR space + voters: the mask of the boxes used to detect the columns (all of them with fewer than 3 voters) + y_overlap: the (N, N) vertical overlap ratios of the boxes + y_overlap_threshold: the vertical overlap above which two boxes lie on the same row + + Returns: + the mask of the boxes crossing the gutter for a multi-column page, None for a single-column page + """ + if np.count_nonzero(voters) < 3: + voters = np.ones(boxes.shape[0], dtype=bool) + x0, x1 = boxes[:, 0], boxes[:, 2] + vx0, vx1, num_voters = x0[voters], x1[voters], int(np.count_nonzero(voters)) + if num_voters < 3: + return None + # Measured on the voters only, so that a wide figure does not shift the search window + left_edge = float(vx0.min()) + span = float(vx1.max()) - left_edge or 1.0 + tolerance = max(1, int(0.05 * num_voters)) + centers = (vx0 + vx1) / 2 + lo, hi = left_edge + 0.25 * span, left_edge + 0.75 * span + for split in np.unique(vx1[(vx1 >= lo) & (vx1 <= hi)]): + crossing = int(np.count_nonzero(np.minimum(vx1 - split, split - vx0) > 0.02 * span)) + left = int(np.count_nonzero(centers <= split)) + if crossing <= tolerance and left >= 0.25 * num_voters and num_voters - left >= 0.25 * num_voters: + if _key_value_rows(boxes, voters, float(split), y_overlap, y_overlap_threshold, span): + return None # rows of keys and values (e.g. a receipt): read row after row + return np.minimum(x1 - split, split - x0) > 0.02 * span + return None def _topological_order( @@ -266,19 +470,17 @@ def _topological_order( x0, y0, x1, y1 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] x_overlap = _overlap_ratios(x0, x1) y_overlap = _overlap_ratios(y0, y1) - # Strict total orders on both axes, so that the induced relations cannot create 2-cycles - x_rank = _strict_rank(x0, x1) - y_rank = _strict_rank(y0, y1) + x_rank, y_rank = _strict_rank(x0, x1), _strict_rank(y0, y1) - # i -> j edges: i must be read before j. The matrices are combined in place: every intermediate is an - # N x N array, so materializing them all at once dominates the memory of this function on dense pages. + # edges[i, j]: i must be read before j. The N x N matrices are combined in place, since materializing them all at + # once dominates the memory of this function on dense pages. x_linked = x_overlap > x_overlap_threshold - edges = y_rank[:, None] < y_rank[None, :] # i is above j - edges &= x_linked + edges = y_rank[:, None] < y_rank[None, :] # i is above j... + edges &= x_linked # ... in the same column same_row = y_overlap > y_overlap_threshold same_row &= ~x_linked - same_row &= x_rank[:, None] < x_rank[None, :] # i is on the left of j + same_row &= x_rank[:, None] < x_rank[None, :] # i is on the left of j on the same row # An element shifted between two rows (e.g. an amount on a crumpled receipt) only waits for the row it overlaps most best_left = np.max(y_overlap, axis=0, where=same_row, initial=0.0) weak_i, weak_j = np.nonzero(same_row & (y_overlap < best_left[None, :] - 0.1)) @@ -291,88 +493,57 @@ def _topological_order( del same_row np.fill_diagonal(edges, False) - in_degree = edges.sum(axis=0) - emitted = np.zeros(num_boxes, dtype=bool) - order: list[int] = [] - last = -1 - - # Detect whether the page is multi-column: if a vertical line can be drawn that separates the boxes into two - # groups with a small number of crossing boxes, the page is considered multi-column. This is used to - # favor the continuation of the current column when traversing the graph, which keeps multi-column bodies intact - # even for non-Manhattan layouts where recursive XY-cuts fail to find a valid split. - multi_column = False - page_width = float(x1.max() - x0.min()) or 1.0 - spanning = (x1 - x0) > 0.5 * page_width - bridging = spanning.copy() # elements which must not merge the columns: page-wide or crossing the gutter - if voters is None or np.count_nonzero(voters) < 3: - voters = np.ones(num_boxes, dtype=bool) - vx0, vx1, num_voters = x0[voters], x1[voters], int(np.count_nonzero(voters)) - if num_voters >= 3: - # Measured on the voters only, so that a wide figure does not shift the search window - left_edge = float(vx0.min()) - span = float(vx1.max()) - left_edge or 1.0 - tolerance = max(1, int(0.05 * num_voters)) - centers = (vx0 + vx1) / 2 - lo, hi = left_edge + 0.25 * span, left_edge + 0.75 * span - for split in np.unique(vx1[(vx1 >= lo) & (vx1 <= hi)]): - crossing = int(np.count_nonzero(np.minimum(vx1 - split, split - vx0) > 0.02 * span)) - left = int(np.count_nonzero(centers <= split)) - if crossing <= tolerance and left >= 0.25 * num_voters and num_voters - left >= 0.25 * num_voters: - if _key_value_rows(boxes, voters, float(split), y_overlap, y_overlap_threshold, span): - break # rows of keys and values (e.g. a receipt): read row after row - multi_column = True - bridging |= np.minimum(x1 - split, split - x0) > 0.02 * span - break - - # Column components: elements connected through horizontal overlap. This keeps the traversal inside a column even - # when the direct top-to-bottom continuation is momentarily broken by a fragmented or misaligned OCR line. + # A multi-column page favors the continuation of the current column (cf. `_continuation`) + spanning = (x1 - x0) > 0.5 * (float(x1.max() - x0.min()) or 1.0) + crossing = _column_gutter( + boxes, np.ones(num_boxes, dtype=bool) if voters is None else voters, y_overlap, y_overlap_threshold + ) + multi_column = crossing is not None + # Column components: elements connected through horizontal overlap, except those which must not merge the columns + # (page-wide or crossing the gutter). This keeps the traversal inside a column even when the direct top-to-bottom + # continuation is momentarily broken by a fragmented or misaligned OCR line. + bridging = spanning | crossing if multi_column else spanning col_edges = np.triu(x_linked, 1) # each pair once col_edges &= ~bridging[:, None] col_edges &= ~bridging[None, :] component = connected_components(csr_matrix(col_edges), directed=False, return_labels=True)[1] + def _continuation(last: int, ready: np.ndarray) -> np.ndarray: + """The available elements continuing the reading after `last`: below it in its column (multi-column page), + else on its row, else in its column component""" + if multi_column: + below = ready[(x_overlap[last, ready] > x_overlap_threshold) & (y0[ready] >= y0[last])] + if below.size > 0: + return below + level = ready[y_overlap[last, ready] > y_overlap_threshold] + if level.size > 0 or spanning[last]: + return level if level.size > 0 else ready + same_column = ready[component[ready] == component[last]] + return same_column if same_column.size > 0 else ready + + in_degree = edges.sum(axis=0) + emitted = np.zeros(num_boxes, dtype=bool) + order: list[int] = [] while len(order) < num_boxes: ready = np.flatnonzero((in_degree == 0) & ~emitted) if ready.size == 0: # cycle safety net (can only happen with degenerate overlapping geometries) - ready = np.flatnonzero(~emitted) - candidates = ready + candidates = np.flatnonzero(~emitted) else: - # Favor the continuation of the current column: the closest available element located below the - # last emitted one with a horizontal overlap - candidates = ( - ready[(x_overlap[last, ready] > x_overlap_threshold) & (y0[ready] >= y0[last])] - if last >= 0 and multi_column - else np.empty(0, dtype=int) - ) - if candidates.size == 0 and last >= 0: - candidates = ready[y_overlap[last, ready] > y_overlap_threshold] - if candidates.size == 0 and last >= 0 and not spanning[last]: - # Continuation broken (gap, fragment): stay in the same column before switching to another - same_column = ready[component[ready] == component[last]] - candidates = same_column if same_column.size else ready - elif candidates.size == 0: - candidates = ready + candidates = _continuation(order[-1], ready) if order else ready # Topmost first, then leftmost (in canonical space) next_idx = int(candidates[np.lexsort((x0[candidates], y0[candidates]))[0]]) order.append(next_idx) emitted[next_idx] = True in_degree = in_degree - edges[next_idx] - last = next_idx - return order -def _is_inside(boxes: np.ndarray, idx: int, target: int, min_coverage: float = 0.5) -> bool: - """Check whether box `idx` lies inside box `target`, by at least `min_coverage` of its area""" - x0, y0, x1, y1 = boxes[idx] - tx0, ty0, tx1, ty1 = boxes[target] - inter = max(min(x1, tx1) - max(x0, tx0), 0.0) * max(min(y1, ty1) - max(y0, ty0), 0.0) - return inter >= min_coverage * max((x1 - x0) * (y1 - y0), 1e-9) +# --- Floats & captions ------------------------------------------------------------------------------------------------ def _outer_floats(idcs: list[int], boxes: np.ndarray, labels: list[str]) -> list[int]: """Return the floats among `idcs` that are not nested in a larger float""" - areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + areas = _box_areas(boxes) floats = [idx for idx in idcs if labels[idx] in _FLOAT_LABELS] return [ idx @@ -386,7 +557,7 @@ def _float_members(idcs: list[int], boxes: np.ndarray, labels: list[str]) -> dic An element belongs to the smallest outer float covering at least half of it. """ - areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + areas = _box_areas(boxes) outer = _outer_floats(idcs, boxes, labels) members: dict[int, list[int]] = {} for idx in idcs: @@ -411,15 +582,12 @@ def _stands_beside(boxes: np.ndarray, run: list[int], region: list[int]) -> bool """ rx0, ry0 = boxes[run, 0].min(), boxes[run, 1].min() rx1, ry1 = boxes[run, 2].max(), boxes[run, 3].max() - for idx in region: - x0, y0, x1, y1 = boxes[idx] + for x0, y0, x1, y1 in boxes[region]: # Level with the run: at least half of the element height - v_inter = min(y1, ry1) - max(y0, ry0) - if v_inter < 0.5 * max(y1 - y0, 1e-9): + if min(y1, ry1) - max(y0, ry0) < 0.5 * max(y1 - y0, 1e-9): continue # Beside the run: horizontal overlap of at most 10% of the narrower width - h_inter = min(x1, rx1) - max(x0, rx0) - if h_inter <= 0.1 * max(min(x1 - x0, rx1 - rx0), 1e-9): + if min(x1, rx1) - max(x0, rx0) <= 0.1 * max(min(x1 - x0, rx1 - rx0), 1e-9): return True return False @@ -456,25 +624,24 @@ def _is_float(idx: int) -> bool: if not _is_float(out[pos]): pos += 1 continue + # out[pos:end] is a run of floats, read after out[pos - 1]: does it interrupt the region of that element? end = pos while end < len(out) and _is_float(out[end]): end += 1 - # out[pos:end] is a run of floats, read after the element out[pos - 1] group = int(groups[out[pos - 1]]) if pos > 0 else -1 - if group < 0 or end == len(out) or int(groups[out[end]]) != group: + interrupts = group >= 0 and end < len(out) and int(groups[out[end]]) == group + if interrupts and boxes is not None: + region = [idx for idx in out if int(groups[idx]) == group and not _is_float(idx)] + interrupts = _stands_beside(boxes, out[pos:end], region) + if not interrupts: pos = end continue + # Move the run after the rest of the region stop = end while stop < len(out) and not _is_float(out[stop]) and int(groups[out[stop]]) == group: stop += 1 - if boxes is not None: - region = [idx for idx in out if int(groups[idx]) == group and not _is_float(idx)] - if not _stands_beside(boxes, out[pos:end], region): - pos = end - continue out[pos:stop] = out[end:stop] + out[pos:end] - # Check the moved run again: it can interrupt the same region further down - pos = stop - (end - pos) + pos = stop - (end - pos) # check the moved run again: it can interrupt the same region further down return out @@ -532,15 +699,15 @@ def _read_after(target: int, unit: list[int]) -> int: for unit in _caption_units(caption_idcs, region_groups): cx0, cy0 = boxes[unit, 0].min(), boxes[unit, 1].min() cx1, cy1 = boxes[unit, 2].max(), boxes[unit, 3].max() - best_target, best_dist = -1, float("inf") - for target in float_idcs: - dist = _caption_distance((cx0, cy0, cx1, cy1), boxes[target]) + target, best_dist = -1, float("inf") + for flt in float_idcs: + dist = _caption_distance((cx0, cy0, cx1, cy1), boxes[flt]) if dist < best_dist: - best_target, best_dist = target, dist - if best_target >= 0 and best_dist <= max_distance: + target, best_dist = flt, dist + if target >= 0 and best_dist <= max_distance: # Read right before the float when above its center, otherwise after it - above = (cy0 + cy1) / 2 <= (boxes[best_target, 1] + boxes[best_target, 3]) / 2 - pos = order.index(best_target) if above else _read_after(best_target, unit) + above = (cy0 + cy1) / 2 <= (boxes[target, 1] + boxes[target, 3]) / 2 + pos = order.index(target) if above else _read_after(target, unit) else: in_column = [idx for idx in order if min(boxes[idx, 2], cx1) - max(boxes[idx, 0], cx0) > 0] above_idcs = [idx for idx in in_column if (boxes[idx, 1] + boxes[idx, 3]) / 2 < cy0] @@ -553,68 +720,72 @@ def _read_after(target: int, unit: list[int]) -> int: return order -def deskew_reading_geometries( +# --- Public ordering functions ---------------------------------------------------------------------------------------- + + +def _canonical_boxes( geoms: Sequence[Any] | np.ndarray, - region_geoms: Sequence[Any] | np.ndarray | None = None, - page_shape: tuple[int, int] | None = None, - angle_geoms: Sequence[Any] | np.ndarray | None = None, - min_angle: float = 1.0, -) -> tuple[list[Any], list[Any]]: - """De-skew rotated geometries so the reading order can be computed in an upright frame. + direction: str, + labels: Sequence[str | None] | None, + region_groups: Sequence[int] | None, + page_shape: tuple[int, int] | None, + angle_geoms: Sequence[Any] | np.ndarray | None, +) -> np.ndarray: + """Check the inputs of the ordering functions and return the de-skewed boxes in canonical LTR space""" + if direction not in SUPPORTED_DIRECTIONS[1:]: + raise ValueError(f"invalid reading direction '{direction}', should be one of {SUPPORTED_DIRECTIONS[1:]}") + geoms, _ = deskew_reading_geometries(geoms, page_shape=page_shape, angle_geoms=angle_geoms) + boxes = _to_boxes(geoms) + if labels is not None and len(labels) != boxes.shape[0]: + raise ValueError(f"Incompatible number of labels ({len(labels)}) and geometries ({boxes.shape[0]})") + if region_groups is not None and len(region_groups) != boxes.shape[0]: + raise ValueError( + f"Incompatible number of region groups ({len(region_groups)}) and geometries ({boxes.shape[0]})" + ) + return _to_canonical_ltr(boxes, direction) - Args: - geoms: geometries of the elements to order, in any docTR format (cf. `sort_reading_order`) - region_geoms: optional geometries of the layout regions, de-skewed together with the elements - page_shape: the page dimensions (height, width). Required for an exact angle on non-square pages with - relative coordinates, since relative coordinates distort angles by the aspect ratio. - angle_geoms: optional reading-oriented 4-point polygons used to estimate the page angle - min_angle: minimum estimated angle (in degrees) to trigger the de-skew. Ordering is affected as soon - as the drift along a line approaches the line height (about 1 degree for a page-wide line), and a - small rigid rotation cannot change the order of an upright page, hence the low default. Beyond 45 - degrees the corner identification is ambiguous (an upstream orientation correction is needed) and - nothing is done. - Returns: - the (possibly de-skewed) element and region geometries - """ - region_geoms = list(region_geoms) if region_geoms is not None else [] - pts = [np.asarray(geom, dtype=np.float64).reshape(-1, 2) for geom in geoms] - if len(pts) == 0 or any(p.shape[0] != 4 for p in pts): - return list(geoms), region_geoms # straight geometries: nothing to de-skew - height, width = page_shape if page_shape is not None else (1, 1) - scale = np.array([width, height], dtype=np.float64) - angle_source = angle_geoms if angle_geoms is not None else [] - angle_pts = [np.asarray(geom, dtype=np.float64).reshape(-1, 2) for geom in angle_source] - if len(angle_pts) > 0 and all(p.shape[0] == 4 for p in angle_pts): - # Detection polygons are already reading-oriented (cf. `estimate_page_angle`): keep their vertex order - angle = estimate_page_angle(np.stack(angle_pts) * scale) - else: - # Normalize the vertex order (TL, TR, BR, BL) so the estimation does not depend on the vertex - angle = estimate_page_angle(np.stack([order_points(p * scale) for p in pts])) - if not np.isfinite(angle) or abs(angle) < min_angle or abs(angle) >= 45: - return list(geoms), region_geoms - # Rigid rotation zeroing the estimated angle; the center is irrelevant for ordering purposes - theta = np.deg2rad(angle) - rot = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) - all_pts = np.concatenate(pts, axis=0) * scale - center = all_pts.mean(axis=0) +def _sort( + canonical: np.ndarray, + labels: Sequence[str | None] | None, + x_overlap_threshold: float, + y_overlap_threshold: float, + caption_max_distance: float, + column_voters: Sequence[bool] | None, + region_groups: Sequence[int] | None, +) -> list[int]: + """Order boxes in canonical LTR space (cf. `sort_reading_order`)""" + num_boxes = canonical.shape[0] + if num_boxes <= 1: + return list(range(num_boxes)) + voters = np.ones(num_boxes, dtype=bool) if column_voters is None else np.asarray(column_voters, dtype=bool) - def _rotate(points: np.ndarray) -> np.ndarray: - return ((points * scale - center) @ rot.T + center) / scale + def _order(idcs: list[int]) -> list[int]: + sub_order = _topological_order(canonical[idcs], x_overlap_threshold, y_overlap_threshold, voters[idcs]) + return [idcs[i] for i in sub_order] - deskewed = [_rotate(p) for p in pts] + if labels is None: + return _order(list(range(num_boxes))) - def _corners(points: np.ndarray) -> np.ndarray: - # Straight ((xmin, ymin), (xmax, ymax)) regions must be expanded to their 4 corners before rotating, - # otherwise only the diagonal would be rotated and the resulting extent would be underestimated - if points.shape[0] == 4: - return points - (x0, y0), (x1, y1) = points.min(axis=0), points.max(axis=0) - return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]) + norm_labels = [normalize_layout_label(label) for label in labels] + groups: dict[str, list[int]] = {"header": [], "body": [], "caption": [], "footnote": [], "footer": []} + for idx, label in enumerate(norm_labels): + role = layout_label_role(label) + groups["body" if role == "float" else role].append(idx) - region_pts = [np.asarray(geom, dtype=np.float64).reshape(-1, 2) for geom in region_geoms] - regions_out = [_rotate(_corners(p)) for p in region_pts] - return deskewed, regions_out + # Read the elements inside a float (e.g. the text of a figure) right after it + members = _float_members(groups["body"], canonical, norm_labels) + owner = {idx: flt for flt, idcs in members.items() for idx in idcs} + body_order = _order(groups["body"]) + inside = {flt: [idx for idx in body_order if owner.get(idx) == flt] for flt in members} + body_order = [elt for idx in body_order if idx not in owner for elt in (idx, *inside.get(idx, []))] + if region_groups is not None: + # A float standing beside a region must not interrupt it + body_order = _defer_floats(body_order, norm_labels, members, region_groups, canonical) + body_order = _attach_captions( + body_order, _order(groups["caption"]), canonical, norm_labels, caption_max_distance, region_groups + ) + return _order(groups["header"]) + body_order + _order(groups["footnote"]) + _order(groups["footer"]) def sort_reading_order( @@ -667,54 +838,67 @@ def sort_reading_order( Returns: the permutation of the input indices which sorts the elements in reading order """ - if direction not in SUPPORTED_DIRECTIONS[1:]: - raise ValueError(f"invalid reading direction '{direction}', should be one of {SUPPORTED_DIRECTIONS[1:]}") - geoms, _ = deskew_reading_geometries(geoms, page_shape=page_shape, angle_geoms=angle_geoms) - boxes = _to_boxes(geoms) - num_boxes = boxes.shape[0] - if labels is not None and len(labels) != num_boxes: - raise ValueError(f"Incompatible number of labels ({len(labels)}) and geometries ({num_boxes})") - if region_groups is not None and len(region_groups) != num_boxes: - raise ValueError(f"Incompatible number of region groups ({len(region_groups)}) and geometries ({num_boxes})") - if num_boxes <= 1: - return list(range(num_boxes)) - - canonical = _to_canonical_ltr(boxes, direction) - - voters = np.ones(num_boxes, dtype=bool) if column_voters is None else np.asarray(column_voters, dtype=bool) + canonical = _canonical_boxes(geoms, direction, labels, region_groups, page_shape, angle_geoms) + return _sort( + canonical, labels, x_overlap_threshold, y_overlap_threshold, caption_max_distance, column_voters, region_groups + ) - def _order(idcs: list[int]) -> list[int]: - if len(idcs) == 0: - return [] - sub_order = _topological_order(canonical[idcs], x_overlap_threshold, y_overlap_threshold, voters[idcs]) - return [idcs[i] for i in sub_order] - if labels is None: - return _order(list(range(num_boxes))) +class _SegmentJoins: + """Criteria to join two consecutive elements of the reading order in the same segment (cf. + `resolve_reading_segments`), in canonical LTR space""" - norm_labels = [normalize_layout_label(label) for label in labels] - groups: dict[str, list[int]] = {"header": [], "body": [], "caption": [], "footnote": [], "footer": []} - for idx, label in enumerate(norm_labels): - role = layout_label_role(label) - groups["body" if role == "float" else role].append(idx) + def __init__( + self, + boxes: np.ndarray, + norm_labels: list[str], + region_groups: Sequence[int] | None, + x_overlap_threshold: float, + y_overlap_threshold: float, + ) -> None: + self.boxes, self.labels, self.groups = boxes, norm_labels, region_groups + self.roles = [layout_label_role(label) for label in norm_labels] + self.median_height = float(np.median(boxes[:, 3] - boxes[:, 1])) + self.x_overlap_threshold, self.y_overlap_threshold = x_overlap_threshold, y_overlap_threshold + + @staticmethod + def _overlap(start: float, end: float, other_start: float, other_end: float) -> float: + """1D overlap of two intervals, relative to the shorter one""" + inter = min(end, other_end) - max(start, other_start) + return inter / max(min(end - start, other_end - other_start), 1e-9) + + def _same_region(self, prev: int, cur: int) -> bool: + return self.groups is None or self.groups[prev] == self.groups[cur] + + def compatible(self, prev: int, cur: int) -> bool: + """Same label (not a float) and same layout region""" + return self.labels[prev] == self.labels[cur] and self.roles[cur] != "float" and self._same_region(prev, cur) + + def stacked(self, row: np.ndarray, cur: int, paragraph_gap: float) -> bool: + """Right below the last row of the segment, in the same column""" + box = self.boxes[cur] + return ( + self._overlap(row[0], row[2], box[0], box[2]) > self.x_overlap_threshold + and box[1] - row[3] <= paragraph_gap * self.median_height + ) - body_order = _order(groups["body"]) - # Read the elements inside a float (e.g. the text of a figure) right after it - members = _float_members(groups["body"], canonical, norm_labels) - if members: - owner = {idx: flt for flt, idcs in members.items() for idx in idcs} - read_inside: dict[int, list[int]] = {flt: [] for flt in members} - for idx in body_order: - if idx in owner: - read_inside[owner[idx]].append(idx) - body_order = [elt for idx in body_order if idx not in owner for elt in (idx, *read_inside.get(idx, []))] - if region_groups is not None: - # A float standing beside a region must not interrupt it - body_order = _defer_floats(body_order, norm_labels, members, region_groups, canonical) - body_order = _attach_captions( - body_order, _order(groups["caption"]), canonical, norm_labels, caption_max_distance, region_groups - ) - return _order(groups["header"]) + body_order + _order(groups["footnote"]) + _order(groups["footer"]) + def same_row(self, prev: int, cur: int, row_gap: float, aspect: float) -> bool: + """On the row of the previous element, after it: the rest of a line split at a wide word gap (at most + `row_gap` median heights, any gap within a region of body text)""" + box, prev_box = self.boxes[cur], self.boxes[prev] + return ( + self._overlap(prev_box[1], prev_box[3], box[1], box[3]) > self.y_overlap_threshold + and box[0] > prev_box[0] + and ( + (box[0] - prev_box[2]) * aspect <= row_gap * self.median_height + or ( + self.groups is not None + and self.groups[cur] >= 0 + and self._same_region(prev, cur) + and self.roles[cur] == "body" + ) + ) + ) def resolve_reading_segments( @@ -766,10 +950,10 @@ def resolve_reading_segments( a partition of the input indices into reading-ordered segments (each segment being itself in reading order) """ - # De-skew once so the ordering and the vertical-gap measurement share the same upright frame - geoms, _ = deskew_reading_geometries(geoms, page_shape=page_shape, angle_geoms=angle_geoms) + # The elements are ordered on their upright geometries, in which the gaps between them are measured as well + upright, _ = deskew_reading_geometries(geoms, page_shape=page_shape, angle_geoms=angle_geoms) order = sort_reading_order( - geoms, + upright, direction=direction, labels=labels, x_overlap_threshold=x_overlap_threshold, @@ -780,166 +964,30 @@ def resolve_reading_segments( ) if len(order) == 0: return [] - canonical = _to_canonical_ltr(_to_boxes(geoms), direction) - median_height = float(np.median(canonical[:, 3] - canonical[:, 1])) + canonical = _to_canonical_ltr(_to_boxes(upright), direction) aspect = 1.0 # horizontal distances in the height unit if page_shape is not None and page_shape[0] > 0 and page_shape[1] > 0: aspect = page_shape[0] / page_shape[1] if direction.startswith("ttb") else page_shape[1] / page_shape[0] norm_labels = [normalize_layout_label(labels[idx] if labels is not None else None) for idx in range(len(order))] - + joins = _SegmentJoins(canonical, norm_labels, region_groups, x_overlap_threshold, y_overlap_threshold) segments: list[list[int]] = [[order[0]]] row = canonical[order[0]].copy() # extent of the last visual row of the current segment for prev, cur in zip(order[:-1], order[1:]): - box = canonical[cur] - x_gap = min(row[2], box[2]) - max(row[0], box[0]) - x_overlap = x_gap / max(min(row[2] - row[0], box[2] - box[0]), 1e-9) - y_inter = min(canonical[prev, 3], box[3]) - max(canonical[prev, 1], box[1]) - y_overlap = y_inter / max(min(canonical[prev, 3] - canonical[prev, 1], box[3] - box[1]), 1e-9) - stacked = x_overlap > x_overlap_threshold and box[1] - row[3] <= paragraph_gap * median_height - same_region = region_groups is None or region_groups[prev] == region_groups[cur] - # The rest of a line split at a wide word gap (any gap within a region of body text) - same_row = ( - y_overlap > y_overlap_threshold - and box[0] > canonical[prev, 0] - and ( - (box[0] - canonical[prev, 2]) * aspect <= row_gap * median_height - or ( - region_groups is not None - and region_groups[cur] >= 0 - and same_region - and layout_label_role(norm_labels[cur]) == "body" - ) - ) - ) - same_segment = ( - norm_labels[prev] == norm_labels[cur] - and layout_label_role(norm_labels[cur]) != "float" - and same_region - and (stacked or same_row) - ) - if same_segment: + stacked, same_row = joins.stacked(row, cur, paragraph_gap), joins.same_row(prev, cur, row_gap, aspect) + joined = joins.compatible(prev, cur) and (stacked or same_row) + if joined: segments[-1].append(cur) else: segments.append([cur]) - if same_segment and same_row and not stacked: - row = np.concatenate((np.minimum(row[:2], box[:2]), np.maximum(row[2:], box[2:]))) - else: - row = box.copy() + box = canonical[cur] + row = ( + np.concatenate((np.minimum(row[:2], box[:2]), np.maximum(row[2:], box[2:]))) + if joined and same_row and not stacked + else box.copy() + ) return segments -def assign_layout_labels( - geoms: Sequence[Any] | np.ndarray, - layout_geoms: Sequence[Any] | np.ndarray, - layout_labels: Sequence[str], - min_coverage: float = 0.5, - page_shape: tuple[int, int] | None = None, - angle_geoms: Sequence[Any] | np.ndarray | None = None, -) -> list[str | None]: - """Assign a layout label to each element based on its overlap with the detected layout regions. - - Each element receives the label of the region covering the largest share of its area, provided this share reaches - `min_coverage`; otherwise its label is None (treated as regular body content). Among nested regions, the smallest - one wins (e.g. 'Caption' over 'Picture'), except for a float nested in page furniture (e.g. a logo in the page - header): its text takes the furniture label. - - Args: - geoms: geometries of the elements to label, in any docTR format - layout_geoms: geometries of the layout regions, in any docTR format - layout_labels: labels of the layout regions (e.g. `[region.type for region in page.layout]`) - min_coverage: minimum share of an element's area a region must cover to assign its label - page_shape: the page dimensions (height, width), used to de-skew rotated pages exactly (cf. - `deskew_reading_geometries`) - angle_geoms: optional reading-oriented 4-point polygons (typically the page's word polygons) used to - estimate the page angle on rotated pages (cf. `deskew_reading_geometries`) - - Returns: - the label of each element (None when no region covers it enough) - """ - # De-skew elements and regions together so the coverage is measured on tight boxes in the same frame - geoms, layout_geoms = deskew_reading_geometries(geoms, layout_geoms, page_shape=page_shape, angle_geoms=angle_geoms) - boxes, regions = _to_boxes(geoms), _to_boxes(layout_geoms) - if len(layout_labels) != regions.shape[0]: - raise ValueError(f"Incompatible number of labels ({len(layout_labels)}) and regions ({regions.shape[0]})") - if boxes.shape[0] == 0 or regions.shape[0] == 0: - return [None] * boxes.shape[0] - - return [ - str(layout_labels[reg_idx]) if reg_idx >= 0 else None - for reg_idx in _covering_regions(boxes, regions, min_coverage, layout_labels) - ] - - -def _region_groups(regions: np.ndarray, labels: Sequence[str | None], min_overlap: float = 0.1) -> list[int]: - """Return the group of each (R, 4) layout region (the index of one of its members), grouping the regions of the - same label which overlap by more than `min_overlap` of the smaller one (e.g. two regions for a paragraph around a - figure) - """ - norm = [normalize_layout_label(label) for label in labels] - areas = (regions[:, 2] - regions[:, 0]) * (regions[:, 3] - regions[:, 1]) - parent = list(range(regions.shape[0])) - - def _find(idx: int) -> int: - while parent[idx] != idx: - parent[idx] = parent[parent[idx]] - idx = parent[idx] - return idx - - for i in range(regions.shape[0]): - for j in range(i + 1, regions.shape[0]): - if norm[i] != norm[j]: - continue - inter = max(min(regions[i, 2], regions[j, 2]) - max(regions[i, 0], regions[j, 0]), 0.0) * max( - min(regions[i, 3], regions[j, 3]) - max(regions[i, 1], regions[j, 1]), 0.0 - ) - if inter > min_overlap * min(areas[i], areas[j]): - parent[_find(i)] = _find(j) - return [_find(idx) for idx in range(regions.shape[0])] - - -def _covering_regions( - boxes: np.ndarray, - regions: np.ndarray, - min_coverage: float, - region_labels: Sequence[str | None] | None = None, -) -> list[int]: - """Return the index of the region covering each (N, 4) box by at least `min_coverage`, -1 if none - - Regions covering a box (nearly) as much as the best one tie: the smallest one wins, unless it is a float tied with - page furniture. - """ - inter_w = np.minimum(boxes[:, None, 2], regions[None, :, 2]) - np.maximum(boxes[:, None, 0], regions[None, :, 0]) - inter_h = np.minimum(boxes[:, None, 3], regions[None, :, 3]) - np.maximum(boxes[:, None, 1], regions[None, :, 1]) - inter = np.clip(inter_w, 0, None) * np.clip(inter_h, 0, None) - areas = np.clip((boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]), 1e-9, None) - coverage = inter / areas[:, None] - region_areas = (regions[:, 2] - regions[:, 0]) * (regions[:, 3] - regions[:, 1]) - qualifies = coverage >= np.maximum(coverage.max(axis=1, keepdims=True) - 0.1, min_coverage) - # Regions nested in the best region of a box also tie when they cover most of it - rx0, ry0, rx1, ry1 = regions[:, 0], regions[:, 1], regions[:, 2], regions[:, 3] - reg_inter_w = np.minimum(rx1[:, None], rx1[None, :]) - np.maximum(rx0[:, None], rx0[None, :]) - reg_inter_h = np.minimum(ry1[:, None], ry1[None, :]) - np.maximum(ry0[:, None], ry0[None, :]) - reg_inter = np.clip(reg_inter_w, 0, None) * np.clip(reg_inter_h, 0, None) - nested = reg_inter >= 0.9 * np.clip(region_areas, 1e-9, None)[:, None] # nested[i, j]: region i lies inside j - qualifies |= (coverage >= max(min_coverage, 0.75)) & nested[:, coverage.argmax(axis=1)].T - ranked = np.where(qualifies, region_areas[None, :], np.inf) - best = ranked.argmin(axis=1) - result = [int(reg) if np.isfinite(ranked[idx, reg]) else -1 for idx, reg in enumerate(best)] - if region_labels is None or len(region_labels) != regions.shape[0]: - return result - - roles = [layout_label_role(label) for label in region_labels] - is_float = np.array([role == "float" for role in roles], dtype=bool) - is_furniture = np.array([role in _FURNITURE_ROLES for role in roles], dtype=bool) - if not (is_float.any() and is_furniture.any()): - return result - furniture_ranked = np.where(qualifies & is_furniture[None, :], region_areas[None, :], np.inf) - for idx, reg in enumerate(result): - if reg >= 0 and is_float[reg] and np.isfinite(furniture_ranked[idx].min()): - result[idx] = int(furniture_ranked[idx].argmin()) - return result - - class ReadingOrderPredictor(NestedObject): """Language-independent reading order estimation for document elements. diff --git a/tests/common/test_io_exporters.py b/tests/common/test_io_exporters.py index 945ea32c8e..95a27116d7 100644 --- a/tests/common/test_io_exporters.py +++ b/tests/common/test_io_exporters.py @@ -1,5 +1,6 @@ import json import re +from xml.etree import ElementTree as ET import cv2 import numpy as np @@ -9,20 +10,27 @@ from doctr.io import elements from doctr.io.exporters import ( AsciiDocExporter, + DocumentExportsMixin, HTMLExporter, + KIEPageExportsMixin, MarkdownExporter, + PageExportsMixin, TextExporter, XMLExporter, + _covering_region_indices, _join_caption_lines, + _PageTextExporter, ordered_line_words, page_reading_order, to_json_safe, ) from doctr.io.figures import FigureEncoder +_ORIENTATION = {"value": 0, "confidence": None} + def _word_at(text, x0, y0, x1, y1): - return elements.Word(text, 0.95, ((x0, y0), (x1, y1)), 0.9, {"value": 0, "confidence": None}) + return elements.Word(text, 0.95, ((x0, y0), (x1, y1)), 0.9, _ORIENTATION) def _line_at(text, x0, y0, x1, y1, rtl=False): @@ -35,10 +43,31 @@ def _line_at(text, x0, y0, x1, y1, rtl=False): ]) +def _page(lines, image=None, dims=(1000, 800), **kwargs): + """A page whose lines form a single block, as in the default builder configuration""" + image = np.zeros((10, 10, 3), dtype=np.uint8) if image is None else image + return elements.Page(image, [elements.Block(lines=lines)], 0, dims, **kwargs) + + def _texts(items): return [[" ".join(word.value for word in line.words) for line in item.lines] for item in items] +def _flat_texts(items): + return [text for item in _texts(items) for text in item] + + +def _prediction(value, y0, y1): + return elements.Prediction(value, 0.9, ((0.1, y0), (0.9, y1)), 0.9, _ORIENTATION) + + +def _kie_page(predictions=None): + """A KIE page whose predictions are stored bottom-first""" + if predictions is None: + predictions = {CLASS_NAME: [_prediction("second", 0.5, 0.6), _prediction("first", 0.1, 0.2)]} + return elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) + + def _reading_order_page(): """A page in the default builder configuration (single block) with a title, 2 columns & a footer""" lines = [_line_at("A Two Column Study", 0.2, 0.05, 0.8, 0.09)] @@ -54,12 +83,53 @@ def _reading_order_page(): elements.LayoutElement("List-item", 0.97, ((0.06, 0.38), (0.48, 0.45))), elements.LayoutElement("Page-footer", 0.97, ((0.35, 0.94), (0.65, 0.98))), ] - return elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), layout=layout - ) + return _page(lines, layout=layout) + + +def _order_cells(): + return [ + elements.TableCell("Name", 0.9, ((0.1, 0.55), (0.4, 0.6)), 0, 0, 0, 0), + elements.TableCell("Qty", 0.9, ((0.4, 0.55), (0.7, 0.6)), 0, 0, 1, 1), + elements.TableCell("Bolt", 0.9, ((0.1, 0.6), (0.4, 0.65)), 1, 1, 0, 0), + elements.TableCell("12|3", 0.9, ((0.4, 0.6), (0.7, 0.65)), 1, 1, 1, 1), + ] + + +def _table_page(num_rows=2, num_cols=1, rotated=False): + """A page with an intro line and one recognized table""" + geo = ((0.1, 0.6), (0.4, 0.66), (0.4, 0.71), (0.1, 0.71)) if rotated else ((0.1, 0.6), (0.4, 0.71)) + cells = [ + elements.TableCell("head", 0.9, ((0.1, 0.6), (0.4, 0.65)), 0, 0, 0, 0), + elements.TableCell("body", 0.9, ((0.1, 0.66), (0.4, 0.71)), 1, 1, 0, 0), + ] + table = elements.Table(cells=cells, num_rows=num_rows, num_cols=num_cols, geometry=geo) + return _page([_line_at("intro line", 0.1, 0.1, 0.9, 0.15)], tables=[table]) + + +def _rotated_line(text, x0, y0, x1, y1, deg, height, width): + """A line rotated by `deg` degrees around the center of a page of the given size (in pixels)""" + angle = np.deg2rad(deg) + rot = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) + center = np.array([width / 2, height / 2]) + tokens = text.split() + step = (x1 - x0) / len(tokens) + words = [] + for idx, token in enumerate(tokens): + pts = np.array([ + [(x0 + idx * step) * width, y0 * height], + [(x0 + (idx + 0.9) * step) * width, y0 * height], + [(x0 + (idx + 0.9) * step) * width, y1 * height], + [(x0 + idx * step) * width, y1 * height], + ]) + pts = ((pts - center) @ rot.T + center) / [width, height] + words.append(elements.Word(token, 0.9, tuple(tuple(pt) for pt in pts), 0.9, _ORIENTATION)) + return elements.Line(words) -def test_page_items_in_reading_order(): +# --- Reading order --------------------------------------------------------------------------------------------------- + + +def test_page_reading_order(): page = _reading_order_page() items = page.items_in_reading_order() assert all(isinstance(item, elements.Block) for item in items) @@ -69,115 +139,122 @@ def test_page_items_in_reading_order(): assert rendered.index("left line 0 left line 1 left line 2") < rendered.index( "right line 0 right line 1 right line 2" ) + # The JSON export follows the reading order too... + exported = [ + " ".join(word["value"] for line in block["lines"] for word in line["words"]) + for block in page.export()["blocks"] + ] + assert exported[0] == "A Two Column Study" + assert exported[-1] == "Page 3 of 12" + assert exported.index("left line 0 left line 1 left line 2") < exported.index( + "right line 0 right line 1 right line 2" + ) + # ... which can be opted out of + raw = page.export(reading_order=False)["blocks"] + assert len(raw) == 1 and len(raw[0]["lines"]) == 9 + # render() too, with the page furniture that can be dropped, exactly like in the Markdown / HTML exports + assert page.render().splitlines()[0] == "A Two Column Study" + assert "Page 3 of 12" in page.render() + assert "Page 3 of 12" not in page.render(include_furniture=False) + # Multi-block pages are ordered at the block level top = elements.Block([_line_at("first words", 0.1, 0.1, 0.9, 0.15)]) bottom = elements.Block([_line_at("last words", 0.1, 0.5, 0.9, 0.55)]) page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [bottom, top], 0, (1000, 800)) assert [block.render() for block in page.items_in_reading_order()] == ["first words", "last words"] + # The blocks are stored right-first, but render() linearizes them like the other exporters + left = elements.Block( + lines=[_line_at("left top", 0.08, 0.1, 0.45, 0.13), _line_at("left low", 0.08, 0.2, 0.45, 0.23)] + ) + right = elements.Block( + lines=[_line_at("right top", 0.55, 0.1, 0.92, 0.13), _line_at("right low", 0.55, 0.2, 0.92, 0.23)] + ) + page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [right, left], 0, (1000, 800)) + assert page.render() == "left top\n\nleft low\n\nright top\n\nright low" + assert page.render(block_break=" | ") == "left top | left low | right top | right low" + # Single-block and empty pages + single = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [left], 0, (1000, 800)) + assert single.render(block_break=" | ") == "left top | left low" + assert elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [], 0, (1000, 800)).render() == "" # Degenerate page dimensions lines = [_line_at("first line here", 0.1, 0.1, 0.5, 0.13), _line_at("second part", 0.6, 0.1, 0.9, 0.13)] for dims in [(0, 0), (0, 100), (100, 0)]: - page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, dims) - assert [text for item in _texts(page_reading_order(page)[0]) for text in item] == [ - "first line here", - "second part", - ] + assert _flat_texts(page_reading_order(_page(lines, dims=dims))[0]) == ["first line here", "second part"] - -def test_page_export_as_markdown(): - page = _reading_order_page() - markdown = page.export_as_markdown() - parts = markdown.split("\n\n") - assert parts[0] == "# A Two Column Study" - assert parts[1] == "left line 0\nleft line 1\nleft line 2" - # The list item belongs to the left column, hence it is read before the right column - assert parts[2] == "- \\- item one" # list item, with the raw OCR dash escaped - assert parts[3] == "right line 0\nright line 1\nright line 2" - assert parts[4] == "Page 3 of 12" - # Page furniture can be dropped - assert "Page 3 of 12" not in page.export_as_markdown(include_furniture=False) - # Markdown structural characters are escaped by default + # Artefacts survive the regrouping + artefact = elements.Artefact("qr_code", 0.9, ((0.1, 0.8), (0.2, 0.9))) page = elements.Page( np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block([_line_at("*bold* #tag [link]", 0.1, 0.1, 0.9, 0.15)])], + [elements.Block(lines=[_line_at("a line", 0.1, 0.1, 0.9, 0.15)], artefacts=[artefact])], 0, (1000, 800), ) - assert page.export_as_markdown() == "\\*bold\\* \\#tag \\[link\\]" - assert page.export_as_markdown(escape=False) == "*bold* #tag [link]" - # Empty pages export to an empty string - assert elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [], 0, (1000, 800)).export_as_markdown() == "" + assert page.export()["blocks"][0]["artefacts"] == [artefact.export()] + # ... also those of a block without any line + artefact = elements.Artefact("qr_code", 0.9, ((0.7, 0.7), (0.9, 0.9))) + orphan = elements.Block(lines=[], artefacts=[artefact], geometry=((0.7, 0.7), (0.9, 0.9)), objectness_score=0.9) + text = elements.Block(lines=[_line_at("some text", 0.1, 0.1, 0.9, 0.15)]) + page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [orphan, text], 0, (1000, 800)) + items = page.items_in_reading_order() + assert [artefact.export() for artefact in items[-1].artefacts] == [artefact.export()] + assert page.export()["blocks"][-1]["artefacts"] == [artefact.export()] @pytest.mark.parametrize( - "line, expected", + "height, width, lines, degrees", [ - ("3. not a list", "3\\. not a list"), - ("12) neither", "12\\) neither"), - (" 7.", " 7\\."), - ("3", "3"), - ("2022 was a year", "2022 was a year"), - ("1.5 million", "1.5 million"), - ("- dash", "\\- dash"), - ("# hash", "\\# hash"), + ( + 1000, + 800, + [ + ("big page title", 0.1, 0.05, 0.9, 0.09), + ("left one", 0.1, 0.15, 0.45, 0.19), + ("left two", 0.1, 0.21, 0.45, 0.25), + ("left three", 0.1, 0.27, 0.45, 0.31), + ("right one", 0.55, 0.15, 0.9, 0.19), + ("right two", 0.55, 0.21, 0.9, 0.25), + ("right three", 0.55, 0.27, 0.9, 0.31), + ], + (15, 25), + ), + ( + 800, + 1200, + [ + ("big page title", 0.1, 0.05, 0.9, 0.09), + ("left one", 0.1, 0.15, 0.45, 0.19), + ("left two", 0.1, 0.21, 0.45, 0.25), + ("right one", 0.55, 0.15, 0.9, 0.19), + ("right two", 0.55, 0.21, 0.9, 0.25), + ], + (-35, 35), + ), ], + ids=["portrait", "landscape"], ) -def test_markdown_finalize_line(line, expected): - assert MarkdownExporter().finalize_line(line) == expected - +def test_page_reading_order_rotated(height, width, lines, degrees): + # A rotated page is read like the same page without rotation + def _rotated_page(deg): + return _page([_rotated_line(*line, deg, height, width) for line in lines], dims=(height, width)) -def test_page_export_as_markdown_rtl(): - # Two columns of Arabic text: the right column is read first, and the words of each line are emitted - # from the rightmost to the leftmost one - lines = [ - _line_at("النص في العمود الأيمن", 0.54, 0.1, 0.92, 0.14, rtl=True), - _line_at("النص في العمود الأيسر", 0.08, 0.1, 0.46, 0.14, rtl=True), - ] - page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800)) - markdown = page.export_as_markdown() - assert markdown == "النص في العمود الأيمن\n\nالنص في العمود الأيسر" - # An explicit direction takes precedence over the detection - assert page.export_as_markdown(direction="ltr").startswith("الأيسر") + expected = _rotated_page(0).export_as_markdown() + assert expected.split()[:3] == ["big", "page", "title"] + for deg in degrees: + assert _rotated_page(deg).export_as_markdown().split() == expected.split() -def test_page_export_with_tables(): - cells = [ - elements.TableCell("Name", 0.9, ((0.1, 0.55), (0.4, 0.6)), 0, 0, 0, 0), - elements.TableCell("Qty", 0.9, ((0.4, 0.55), (0.7, 0.6)), 0, 0, 1, 1), - elements.TableCell("Bolt", 0.9, ((0.1, 0.6), (0.4, 0.65)), 1, 1, 0, 0), - elements.TableCell("12|3", 0.9, ((0.4, 0.6), (0.7, 0.65)), 1, 1, 1, 1), - ] - table = elements.Table(cells, 2, 2, ((0.1, 0.55), (0.7, 0.65)), 0.95) - lines = [ - _line_at("before the table", 0.1, 0.1, 0.9, 0.14), - _line_at("after the table", 0.1, 0.7, 0.9, 0.74), - ] - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), tables=[table] - ) - markdown = page.export_as_markdown() - assert markdown.split("\n\n") == [ - "before the table", - "| Name | Qty |\n| --- | --- |\n| Bolt | 12\\|3 |", - "after the table", - ] - asciidoc = page.export_as_asciidoc() - assert "|===\n|Name |Qty\n\n|Bolt |12\\|3\n|===" in asciidoc - assert asciidoc.index("before the table") < asciidoc.index("|===") < asciidoc.index("after the table") +def test_page_reading_order_with_tables(): # A page-wide table does not prevent the columns above and below it from being detected lines = [_line_at("A title over both columns", 0.2, 0.02, 0.8, 0.06)] for col, x in (("left", 0.05), ("right", 0.55)): lines += [_line_at(f"{col} {i}", x, 0.1 + 0.05 * i, x + 0.4, 0.13 + 0.05 * i) for i in range(3)] lines += [_line_at(f"{col} {i}", x, 0.3 + 0.05 * i, x + 0.4, 0.33 + 0.05 * i) for i in range(3, 6)] - table = elements.Table(cells, 2, 2, ((0.05, 0.25), (0.95, 0.28)), 0.95) - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), tables=[table] - ) + page = _page(lines, tables=[elements.Table(_order_cells(), 2, 2, ((0.05, 0.25), (0.95, 0.28)), 0.95)]) blocks = [item for item in page.items_in_reading_order() if isinstance(item, elements.Block)] - texts = [text for item in _texts(blocks) for text in item] # Each section is read column after column sections = [[f"{col} {i}" for col in ("left", "right") for i in idcs] for idcs in (range(3), range(3, 6))] - assert texts == ["A title over both columns", *sections[0], *sections[1]] + assert _flat_texts(blocks) == ["A title over both columns", *sections[0], *sections[1]] + # A table region from the layout model, without table structure (`detect_tables=False`): its lines keep the # region label, but they are not floats themselves, so they are neither nested into each other nor isolated lines = [ @@ -186,20 +263,15 @@ def test_page_export_with_tables(): _line_at("EUR", 0.80, 0.42, 0.88, 0.45), # second header row, under "Gesamt": mostly inside the first line _line_at("1 Deutsch 22,50 22,50", 0.10, 0.48, 0.90, 0.52), ] - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=lines)], - 0, - (1000, 800), - layout=[elements.LayoutElement("Table", 0.9, ((0.08, 0.38), (0.92, 0.54)))], - ) + page = _page(lines, layout=[elements.LayoutElement("Table", 0.9, ((0.08, 0.38), (0.92, 0.54)))]) items, labels, _ = page_reading_order(page) - texts = [text for item in _texts(items) for text in item] + texts = _flat_texts(items) assert texts == ["Pos Datum Einzelpreis Gesamt", "pro UE", "EUR", "1 Deutsch 22,50 22,50"] assert labels == ["Table"] * len(items) assert len(items) < len(lines) # Rendered like the rows of a table: one paragraph per line assert page.export_as_markdown().split("\n\n") == texts + # The rows of a table region level with the paragraph beside it are read after that paragraph lines = [_line_at(f"intro line {i} of the body", 0.08, 0.05 + 0.04 * i, 0.92, 0.08 + 0.04 * i) for i in range(6)] lines += [ @@ -213,19 +285,14 @@ def test_page_export_with_tables(): elements.LayoutElement("Table", 0.9, ((0.60, 0.29), (0.94, 0.46))), elements.LayoutElement("Text", 0.9, ((0.06, 0.49), (0.94, 0.74))), ] - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), layout=layout - ) - items, labels, _ = page_reading_order(page) - texts = [text for item in _texts(items) for text in item] + items, labels, _ = page_reading_order(_page(lines, layout=layout)) + texts = _flat_texts(items) paragraph = [texts.index(f"para line {i} beside the table") for i in range(4)] rows = [texts.index(f"Row{i} {i}0") for i in range(4)] assert paragraph == list(range(paragraph[0], paragraph[0] + 4)) assert rows == list(range(paragraph[-1] + 1, paragraph[-1] + 5)) assert {label for item, label in zip(_texts(items), labels) if item[0].startswith("Row")} == {"Table"} - -def test_page_reading_order_page_wide_table_region(): # A receipt detected as one page-wide table under its header: the cells left over by a small recognized table are # rows of that table, not two text columns, whether or not the page-wide table element votes in the column detection lines = [ @@ -243,232 +310,185 @@ def test_page_reading_order_page_wide_table_region(): ] expected = [text for i in range(8) for text in (f"Item {i} description", f"Remark for item {i} written out")] for tables in ([], [elements.Table(cells, 2, 1, ((0.04, 0.10), (0.96, 0.16)), 0.9)]): - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=lines)], - 0, - (1000, 800), - layout=layout, - tables=tables, - ) - items, _, _ = page_reading_order(page) - texts = [text for item in _texts(item for item in items if isinstance(item, elements.Block)) for text in item] + items, _, _ = page_reading_order(_page(lines, layout=layout, tables=tables)) + texts = _flat_texts(item for item in items if isinstance(item, elements.Block)) assert texts == ["Corner Shop", "12 Main Street, Springfield", "2026-10-26 09:13", *expected] -def test_page_export_as_asciidoc(): - page = _reading_order_page() - asciidoc = page.export_as_asciidoc() - parts = asciidoc.split("\n\n") - assert parts[0] == "== A Two Column Study" - assert parts[2] == "* {empty}- item one" - assert "Page 3 of 12" not in page.export_as_asciidoc(include_furniture=False) +def test_reading_direction(): + # Two columns of Arabic text: the right column is read first, and the words of each line are emitted + # from the rightmost to the leftmost one + lines = [ + _line_at("النص في العمود الأيمن", 0.54, 0.1, 0.92, 0.14, rtl=True), + _line_at("النص في العمود الأيسر", 0.08, 0.1, 0.46, 0.14, rtl=True), + ] + page = _page(lines) + assert page.export_as_markdown() == "النص في العمود الأيمن\n\nالنص في العمود الأيسر" + # An explicit direction takes precedence over the detection + assert page.export_as_markdown(direction="ltr").startswith("الأيسر") + line = _line_at("one two three", 0.1, 0.1, 0.7, 0.15) + assert [word.render() for word in ordered_line_words(line, "ltr")] == ["one", "two", "three"] + assert [word.render() for word in ordered_line_words(line, "rtl")] == ["three", "two", "one"] + # Vertical pages read their words top to bottom + stacked = elements.Line([_word_at(value, 0.1, 0.1 * idx, 0.2, 0.1 * idx + 0.05) for idx, value in enumerate("abc")]) + assert [word.render() for word in ordered_line_words(stacked, "ttb-rtl")] == ["a", "b", "c"] + assert [word.render() for word in ordered_line_words(stacked, "ttb-ltr")] == ["a", "b", "c"] + # A single-word line short-circuits the per-line detection + single = elements.Line([_word_at("solo", 0.1, 0.1, 0.2, 0.15)]) + assert [word.render() for word in ordered_line_words(single, "auto", auto=True)] == ["solo"] -def test_page_export_as(): + # A vertical direction reaches every exporter page = _reading_order_page() - assert page.export_as("markdown") == page.export_as("md") == page.export_as_markdown() - assert page.export_as("adoc") == page.export_as_asciidoc() - assert page.export_as("text") == page.render() - assert page.export_as("json") == page.export() - assert isinstance(page.export_as("xml")[0], bytes) - assert page.export_as("markdown", include_furniture=False) == page.export_as_markdown(include_furniture=False) - with pytest.raises(ValueError): - page.export_as("yaml") + for export in (page.render, page.export_as_markdown, page.export_as_asciidoc, page.export_as_html): + assert isinstance(export(direction="ttb-ltr"), str) + assert isinstance(page.export_as_xml(direction="ttb-rtl")[0], bytes) -def test_document_export_as_markdown(): - pages = [ - elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block([_line_at(f"page {idx} content", 0.1, 0.1, 0.9, 0.15)])], - idx, - (1000, 800), - ) - for idx in range(2) +def test_document_forwards_reading_order_options(): + left = elements.Block(lines=[_line_at("left col", 0.08, 0.2, 0.45, 0.23)]) + right = elements.Block(lines=[_line_at("right col", 0.55, 0.2, 0.92, 0.23)]) + footer = elements.Block(lines=[_line_at("Page 1 of 2", 0.4, 0.95, 0.6, 0.97)]) + layout = [ + elements.LayoutElement("Text", 0.98, ((0.06, 0.18), (0.48, 0.26))), + elements.LayoutElement("Text", 0.98, ((0.52, 0.18), (0.94, 0.26))), + elements.LayoutElement("Page-footer", 0.97, ((0.35, 0.94), (0.65, 0.98))), ] - doc = elements.Document(pages) - assert doc.export_as_markdown() == "page 0 content\n\n---\n\npage 1 content" - assert doc.export_as_asciidoc() == "page 0 content\n\n<<<\n\npage 1 content" - assert doc.export_as_markdown(page_break="\n\n") == "page 0 content\n\npage 1 content" - assert doc.export_as("markdown") == doc.export_as_markdown() - assert doc.export_as("text") == doc.render() - assert doc.export_as("json") == doc.export() - assert len(doc.export_as("xml")) == 2 - with pytest.raises(ValueError): - doc.export_as("pdf") + page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [right, left, footer], 0, (1000, 800), layout=layout) + doc = elements.Document([page, page]) + assert ( + doc.render(page_break=" || ") == "left col\n\nright col\n\nPage 1 of 2 || left col\n\nright col\n\nPage 1 of 2" + ) + assert "Page 1 of 2" not in doc.render(include_furniture=False) + assert doc.render(block_break=" ", page_break=" || ").startswith("left col right col") + assert doc.export_as("text", include_furniture=False) == doc.render(include_furniture=False) + ordered = doc.export()["pages"][0]["blocks"] + assert [block["lines"][0]["words"][0]["value"] for block in ordered] == ["left", "right", "Page"] + raw = doc.export(reading_order=False)["pages"][0]["blocks"] + assert [block["lines"][0]["words"][0]["value"] for block in raw] == ["right", "left", "Page"] + assert doc.export_as("json", reading_order=False) == doc.export(reading_order=False) + json.dumps(doc.export(reading_order=False)) + + # KIE documents + doc = elements.KIEDocument([_kie_page()]) + assert doc.render() == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" + # The reading direction reaches KIEPage.render through the document + assert doc.render(direction="ttb-ltr") == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" + assert [p["value"] for p in doc.export()["pages"][0]["predictions"][CLASS_NAME]] == ["first", "second"] + raw = doc.export(reading_order=False)["pages"][0]["predictions"][CLASS_NAME] + assert [p["value"] for p in raw] == ["second", "first"] + json.dumps(doc.export(reading_order=False)) -def test_kie_page_export_as_markdown(): - predictions = { - CLASS_NAME: [ - elements.Prediction("second", 0.9, ((0.1, 0.5), (0.9, 0.6)), 0.9, {"value": 0, "confidence": None}), - elements.Prediction("first", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None}), - ] - } - page = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) +def test_kie_page_exports(): + page = _kie_page() + # Predictions are exported in reading order + assert page.render() == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" assert page.export_as_markdown() == f"**{CLASS_NAME}**\n\n- first\n- second" assert page.export_as_asciidoc() == f"*{CLASS_NAME}*\n\n* first\n* second" assert page.export_as("md") == page.export_as_markdown() with pytest.raises(ValueError): page.export_as("yaml") - doc = elements.KIEDocument([page]) - assert doc.export_as_markdown() == page.export_as_markdown() + assert elements.KIEDocument([page]).export_as_markdown() == page.export_as_markdown() + assert [p["value"] for p in page.export()["predictions"][CLASS_NAME]] == ["first", "second"] + assert [p["value"] for p in page.export(reading_order=False)["predictions"][CLASS_NAME]] == ["second", "first"] + xml = page.export_as_xml()[0].decode() + assert xml.index(">first<") < xml.index(">second<") + # Classes without any prediction are skipped by every text exporter + kie = _kie_page({CLASS_NAME: [_prediction("second", 0.5, 0.6), _prediction("first", 0.1, 0.2)], "empty": []}) + assert TextExporter().export_kie_page(kie) == f"{CLASS_NAME}:\n\nfirst\nsecond" + assert "empty" not in MarkdownExporter().export_kie_page(kie) + assert "empty" not in AsciiDocExporter().export_kie_page(kie) + assert "empty" not in HTMLExporter().export_kie_page(kie) + # export_document dispatches on the page type and uses the format-specific page break + mixed = elements.Document([_page([_line_at("a page", 0.1, 0.1, 0.9, 0.15)]), kie]) + assert TextExporter().export_document(mixed) == f"a page\n\n\n\n{CLASS_NAME}:\n\nfirst\nsecond" + assert TextExporter().export_document(mixed, page_break=" // ").startswith("a page // ") -def test_page_export_as_markdown_list_items(): - # Three separate single-line list items, each covered by its own List-item region -> three bullets - lines = [_line_at(f"item number {idx}", 0.1, 0.1 + 0.1 * idx, 0.5, 0.13 + 0.1 * idx) for idx in range(3)] - layout = [ - elements.LayoutElement("List-item", 0.9, ((0.08, 0.09 + 0.1 * idx), (0.52, 0.14 + 0.1 * idx))) - for idx in range(3) - ] - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), layout=layout - ) - assert page.export_as_markdown() == "- item number 0\n- item number 1\n- item number 2" - assert page.export_as_asciidoc() == "* item number 0\n* item number 1\n* item number 2" - # each list item is its own block in reading order - items = page.items_in_reading_order() - assert len(items) == 3 - assert all(len(item.lines) == 1 for item in items) +# --- Text formats ---------------------------------------------------------------------------------------------------- -def test_page_export_as_markdown_wrapped_list_item(): - # A single list item wrapped over three visual lines (one region) must render as ONE bullet, while a - # second item (another region) is a second bullet. - lines = [ - _line_at("first item wrapping over", 0.1, 0.10, 0.9, 0.13), - _line_at("several visual lines here", 0.1, 0.14, 0.9, 0.17), - _line_at("until it finally ends", 0.1, 0.18, 0.6, 0.21), - _line_at("second short item", 0.1, 0.26, 0.5, 0.29), - ] - layout = [ - elements.LayoutElement("List-item", 0.9, ((0.08, 0.09), (0.92, 0.22))), - elements.LayoutElement("List-item", 0.9, ((0.08, 0.25), (0.52, 0.30))), - ] - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), [elements.Block(lines=lines)], 0, (1000, 800), layout=layout - ) - assert page.export_as_markdown() == ( - "- first item wrapping over several visual lines here until it finally ends\n- second short item" - ) - items = page.items_in_reading_order() - assert len(items) == 2 - assert len(items[0].lines) == 3 and len(items[1].lines) == 1 +def test_page_export_as_markdown(): + page = _reading_order_page() + parts = page.export_as_markdown().split("\n\n") + assert parts[0] == "# A Two Column Study" + assert parts[1] == "left line 0\nleft line 1\nleft line 2" + # The list item belongs to the left column, hence it is read before the right column + assert parts[2] == "- \\- item one" # list item, with the raw OCR dash escaped + assert parts[3] == "right line 0\nright line 1\nright line 2" + assert parts[4] == "Page 3 of 12" + # Page furniture can be dropped + assert "Page 3 of 12" not in page.export_as_markdown(include_furniture=False) + # AsciiDoc follows the same structure + parts = page.export_as_asciidoc().split("\n\n") + assert parts[0] == "== A Two Column Study" + assert parts[2] == "* {empty}- item one" + assert "Page 3 of 12" not in page.export_as_asciidoc(include_furniture=False) + # Markdown structural characters are escaped by default + page = _page([_line_at("*bold* #tag [link]", 0.1, 0.1, 0.9, 0.15)]) + assert page.export_as_markdown() == "\\*bold\\* \\#tag \\[link\\]" + assert page.export_as_markdown(escape=False) == "*bold* #tag [link]" + # Empty pages export to an empty string + assert elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [], 0, (1000, 800)).export_as_markdown() == "" -def test_page_export_as_markdown_rotated_page(): - height, width = 1000, 800 - - def _rot_line(text, x0, y0, x1, y1, deg): - angle = np.deg2rad(deg) - rot = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) - center = np.array([width / 2, height / 2]) - tokens = text.split() - step = (x1 - x0) / len(tokens) - words = [] - for idx, token in enumerate(tokens): - pts = np.array([ - [(x0 + idx * step) * width, y0 * height], - [(x0 + (idx + 0.9) * step) * width, y0 * height], - [(x0 + (idx + 0.9) * step) * width, y1 * height], - [(x0 + idx * step) * width, y1 * height], - ]) - pts = ((pts - center) @ rot.T + center) / [width, height] - words.append( - elements.Word(token, 0.9, tuple(tuple(pt) for pt in pts), 0.9, {"value": 0, "confidence": None}) - ) - return elements.Line(words) +@pytest.mark.parametrize( + "line, expected", + [ + ("3. not a list", "3\\. not a list"), + ("12) neither", "12\\) neither"), + (" 7.", " 7\\."), + ("3", "3"), + ("2022 was a year", "2022 was a year"), + ("1.5 million", "1.5 million"), + ("- dash", "\\- dash"), + ("# hash", "\\# hash"), + ], +) +def test_markdown_finalize_line(line, expected): + assert MarkdownExporter().finalize_line(line) == expected + + +def test_page_export_list_items(): + # Three separate single-line list items, each covered by its own List-item region -> three bullets + lines = [_line_at(f"item number {idx}", 0.1, 0.1 + 0.1 * idx, 0.5, 0.13 + 0.1 * idx) for idx in range(3)] layout = [ - ("big page title", 0.1, 0.05, 0.9, 0.09), - ("left one", 0.1, 0.15, 0.45, 0.19), - ("left two", 0.1, 0.21, 0.45, 0.25), - ("left three", 0.1, 0.27, 0.45, 0.31), - ("right one", 0.55, 0.15, 0.9, 0.19), - ("right two", 0.55, 0.21, 0.9, 0.25), - ("right three", 0.55, 0.27, 0.9, 0.31), + elements.LayoutElement("List-item", 0.9, ((0.08, 0.09 + 0.1 * idx), (0.52, 0.14 + 0.1 * idx))) + for idx in range(3) ] - expected = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_rot_line(*args, 0) for args in layout])], - 0, - (height, width), - ).export_as_markdown() - assert expected.split()[:3] == ["big", "page", "title"] - for deg in (15, 25): - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_rot_line(*args, deg) for args in layout])], - 0, - (height, width), - ) - assert page.export_as_markdown().split() == expected.split() - - -def test_page_export_as_markdown_rotated_landscape_page(): - height, width = 800, 1200 - - def _rot_line(text, x0, y0, x1, y1, deg): - angle = np.deg2rad(deg) - rot = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) - center = np.array([width / 2, height / 2]) - tokens = text.split() - step = (x1 - x0) / len(tokens) - words = [] - for idx, token in enumerate(tokens): - pts = np.array([ - [(x0 + idx * step) * width, y0 * height], - [(x0 + (idx + 0.9) * step) * width, y0 * height], - [(x0 + (idx + 0.9) * step) * width, y1 * height], - [(x0 + idx * step) * width, y1 * height], - ]) - pts = ((pts - center) @ rot.T + center) / [width, height] - words.append( - elements.Word(token, 0.9, tuple(tuple(pt) for pt in pts), 0.9, {"value": 0, "confidence": None}) - ) - return elements.Line(words) + page = _page(lines, layout=layout) + assert page.export_as_markdown() == "- item number 0\n- item number 1\n- item number 2" + assert page.export_as_asciidoc() == "* item number 0\n* item number 1\n* item number 2" + # each list item is its own block in reading order + items = page.items_in_reading_order() + assert len(items) == 3 + assert all(len(item.lines) == 1 for item in items) + # list items render as one
real text
" -def test_export_mixins_carry_full_api(): +def test_export_as_dispatch(): + page = _reading_order_page() + assert page.export_as("markdown") == page.export_as("md") == page.export_as_markdown() + assert page.export_as("adoc") == page.export_as_asciidoc() + assert page.export_as("text") == page.render() + assert page.export_as("json") == page.export() + assert isinstance(page.export_as("xml")[0], bytes) + assert page.export_as("markdown", include_furniture=False) == page.export_as_markdown(include_furniture=False) + with pytest.raises(ValueError): + page.export_as("yaml") # The element export surface comes from the mixins in doctr.io.exporters, with the API unchanged - from doctr.io.exporters import DocumentExportsMixin, KIEPageExportsMixin, PageExportsMixin - for method in ( "render", "export_as_xml", @@ -506,308 +533,75 @@ def test_export_mixins_carry_full_api(): assert getattr(elements.KIEPage, method) is getattr(KIEPageExportsMixin, method) assert getattr(elements.Document, method) is getattr(DocumentExportsMixin, method) assert elements.Page.items_in_reading_order is PageExportsMixin.items_in_reading_order - page = _reading_order_page() - # dispatcher covers every format - for fmt in ("markdown", "md", "asciidoc", "adoc", "html", "text", "txt", "json", "dict", "xml", "hocr"): - page.export_as(fmt) - with pytest.raises(ValueError): - page.export_as("pptx") - - -def test_page_render(): - left = elements.Block( - lines=[_line_at("left top", 0.08, 0.1, 0.45, 0.13), _line_at("left low", 0.08, 0.2, 0.45, 0.23)] - ) - right = elements.Block( - lines=[_line_at("right top", 0.55, 0.1, 0.92, 0.13), _line_at("right low", 0.55, 0.2, 0.92, 0.23)] - ) - # The blocks are stored right-first, but render() linearizes them like the other exporters - page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [right, left], 0, (1000, 800)) - assert page.render() == "left top\n\nleft low\n\nright top\n\nright low" - assert page.render(block_break=" | ") == "left top | left low | right top | right low" - # Single-block and empty pages - single = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [left], 0, (1000, 800)) - assert single.render(block_break=" | ") == "left top | left low" - assert elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [], 0, (1000, 800)).render() == "" - - -def test_page_render_includes_tables_and_furniture_flag(): - page = _reading_order_page() - assert page.render().splitlines()[0] == "A Two Column Study" - assert "Page 3 of 12" in page.render() - # Page furniture can be dropped, exactly like in the Markdown / HTML exports - assert "Page 3 of 12" not in page.render(include_furniture=False) - # Recognized tables are part of the plain text render - cells = [ - elements.TableCell("head", 0.9, ((0.1, 0.6), (0.4, 0.65)), 0, 0, 0, 0), - elements.TableCell("body", 0.9, ((0.1, 0.66), (0.4, 0.71)), 1, 1, 0, 0), - ] - table = elements.Table(cells=cells, num_rows=2, num_cols=1, geometry=((0.1, 0.6), (0.4, 0.71))) - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_line_at("intro line", 0.1, 0.1, 0.9, 0.15)])], - 0, - (1000, 800), - tables=[table], - ) - assert page.render() == "intro line\n\nhead\nbody" - - -def test_export_is_json_serializable(): - import json - - # Straight boxes built from a detection array carry np.float32 coordinates - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [ - elements.Block( - lines=[ - elements.Line([ - elements.Word( - "np", - np.float32(0.9), - ((np.float32(0.1), np.float32(0.1)), (np.float32(0.4), np.float32(0.2))), - np.float32(0.8), - {"value": np.int64(0), "confidence": None}, - ) - ]) - ] - ) - ], - np.int64(0), - (np.int64(1000), np.int64(800)), - {"value": np.float32(0.0), "confidence": np.float32(1.0)}, - ) - exported = page.export() - assert json.loads(json.dumps(exported)) is not None - word = exported["blocks"][0]["lines"][0]["words"][0] - assert [coord for point in word["geometry"] for coord in point] == pytest.approx([0.1, 0.1, 0.4, 0.2]) - assert all(type(coord) is float for point in word["geometry"] for coord in point) - assert type(word["confidence"]) is float and type(word["objectness_score"]) is float - assert type(word["crop_orientation"]["value"]) is int - assert exported["page_idx"] == 0 and type(exported["page_idx"]) is int - assert exported["dimensions"] == (1000, 800) and all(type(dim) is int for dim in exported["dimensions"]) - # Rotated lines / blocks expose their geometry as a numpy array, exported as nested tuples - poly = np.asarray([[0.1, 0.1], [0.4, 0.1], [0.4, 0.2], [0.1, 0.2]], dtype=np.float32) - rot_word = elements.Word("rot", 0.9, poly, 0.8, {"value": 0, "confidence": None}) - rot_page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[elements.Line([rot_word])])], - 0, - (1000, 800), - ) - assert isinstance(rot_page.blocks[0].geometry, np.ndarray) # untouched in memory - rot_export = rot_page.export() - assert isinstance(rot_export["blocks"][0]["geometry"], tuple) - json.dumps(rot_export) - # ... and a whole document round-trips through JSON - doc = elements.Document([page, rot_page]) - assert elements.Document.from_dict(json.loads(json.dumps(doc.export()))) is not None - - -def test_export(): - page = _reading_order_page() - rendered = [ - " ".join(word["value"] for line in block["lines"] for word in line["words"]) - for block in page.export()["blocks"] - ] - assert rendered[0] == "A Two Column Study" - assert rendered[-1] == "Page 3 of 12" - assert rendered.index("left line 0 left line 1 left line 2") < rendered.index( - "right line 0 right line 1 right line 2" - ) - # The linearization can be opted out of - raw = page.export(reading_order=False)["blocks"] - assert len(raw) == 1 and len(raw[0]["lines"]) == 9 - # Artefacts survive the regrouping - artefact = elements.Artefact("qr_code", 0.9, ((0.1, 0.8), (0.2, 0.9))) - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_line_at("a line", 0.1, 0.1, 0.9, 0.15)], artefacts=[artefact])], - 0, - (1000, 800), - ) - assert page.export()["blocks"][0]["artefacts"] == [artefact.export()] - - -def test_xml_export(): - import re - - page = _reading_order_page() - xml = page.export_as_xml()[0].decode() - words = re.findall(r'class="ocrx_word"[^>]*>([^<]*)<', xml) - assert words[:4] == ["A", "Two", "Column", "Study"] - assert words[-4:] == ["Page", "3", "of", "12"] - assert words.index("left") < words.index("right") - # Recognized tables are serialized as an hOCR text area (they used to be dropped) - cells = [ - elements.TableCell("head", 0.9, ((0.1, 0.6), (0.4, 0.65)), 0, 0, 0, 0), - elements.TableCell("body", 0.9, ((0.1, 0.66), (0.4, 0.71)), 1, 1, 0, 0), - ] - table = elements.Table(cells=cells, num_rows=2, num_cols=1, geometry=((0.1, 0.6), (0.4, 0.71))) - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_line_at("intro line", 0.1, 0.1, 0.9, 0.15)])], - 0, - (1000, 800), - tables=[table], - ) - xml = page.export_as_xml()[0].decode() - assert 'id="table_1"' in xml and ">head<" in xml and ">body<" in xml - - -def test_kie_page_export(): - predictions = { - CLASS_NAME: [ - elements.Prediction("second", 0.9, ((0.1, 0.5), (0.9, 0.6)), 0.9, {"value": 0, "confidence": None}), - elements.Prediction("first", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None}), - ] - } - page = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) - assert [p["value"] for p in page.export()["predictions"][CLASS_NAME]] == ["first", "second"] - assert [p["value"] for p in page.export(reading_order=False)["predictions"][CLASS_NAME]] == ["second", "first"] - xml = page.export_as_xml()[0].decode() - assert xml.index(">first<") < xml.index(">second<") - - -def test_kie_page_render_keeps_reading_order(): - predictions = { - CLASS_NAME: [ - elements.Prediction("second", 0.9, ((0.1, 0.5), (0.9, 0.6)), 0.9, {"value": 0, "confidence": None}), - elements.Prediction("first", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None}), - ] - } - page = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) - assert page.render() == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" + # The dispatcher covers every format, for every element + page = _page([_line_at("page text", 0.1, 0.1, 0.9, 0.15)]) + kie_page = _kie_page({CLASS_NAME: [_prediction("value", 0.1, 0.2)]}) + formats = ("markdown", "md", "asciidoc", "adoc", "html", "text", "txt", "json", "dict", "xml", "hocr") + for element in (page, kie_page, elements.Document([page]), elements.KIEDocument([kie_page])): + for fmt in formats: + assert element.export_as(fmt) is not None + with pytest.raises(ValueError, match="unsupported export format"): + element.export_as("pptx") + # The document-level named methods carry the page break of their format + doc = elements.Document([page, page]) + assert doc.export_as_html() == "page text
page text
" + assert doc.export_as_html(page_break="\n") == "page text
\npage text
" + assert doc.export_as_markdown() == "page text\n\n---\n\npage text" + assert doc.export_as_asciidoc() == "page text\n\n<<<\n\npage text" + assert doc.export_as_markdown(page_break="\n\n") == "page text\n\npage text" + assert doc.export_as("markdown") == doc.export_as_markdown() + assert doc.export_as("text") == doc.render() + assert doc.export_as("json") == doc.export() + assert len(doc.export_as("xml")) == 2 and len(doc.export_as_xml()) == 2 + with pytest.raises(ValueError): + doc.export_as("pdf") + kie_doc = elements.KIEDocument([kie_page, kie_page]) + kie_html = f"| head | " in html and "body | " in html assert html.index("
|---|
| only |
|---|
only text
" -def test_text_exporter_kie_and_document(): - predictions = { - CLASS_NAME: [ - elements.Prediction("second", 0.9, ((0.1, 0.5), (0.9, 0.6)), 0.9, {"value": 0, "confidence": None}), - elements.Prediction("first", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None}), - ], - "empty": [], - } - kie = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) - # Classes without any prediction are skipped by every text exporter - assert TextExporter().export_kie_page(kie) == f"{CLASS_NAME}:\n\nfirst\nsecond" - assert "empty" not in MarkdownExporter().export_kie_page(kie) - assert "empty" not in AsciiDocExporter().export_kie_page(kie) - assert "empty" not in HTMLExporter().export_kie_page(kie) - # export_document dispatches on the page type and uses the format-specific page break - page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_line_at("a page", 0.1, 0.1, 0.9, 0.15)])], - 0, - (1000, 800), - ) - mixed = elements.Document([page, kie]) - assert TextExporter().export_document(mixed) == f"a page\n\n\n\n{CLASS_NAME}:\n\nfirst\nsecond" - assert TextExporter().export_document(mixed, page_break=" // ").startswith("a page // ") +# --- XML / JSON ------------------------------------------------------------------------------------------------------ -def test_base_text_exporter_is_abstract(): - from doctr.io.exporters import _PageTextExporter - - table = elements.Table(cells=[], num_rows=0, num_cols=0, geometry=((0.0, 0.0), (0.0, 0.0))) - with pytest.raises(NotImplementedError): - _PageTextExporter().render_table(table) - with pytest.raises(NotImplementedError): - _PageTextExporter().class_header("cls") - - -def test_blank_lines_are_skipped(): - # Words recognized as empty strings must not produce an empty paragraph / list item - blank = elements.Block(lines=[elements.Line([_word_at("", 0.1, 0.1, 0.4, 0.15)])]) - text = elements.Block(lines=[_line_at("real text", 0.1, 0.5, 0.9, 0.55)]) - page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [blank, text], 0, (1000, 800)) - assert page.render() == "real text" - assert page.export_as_markdown() == "real text" - assert page.export_as_html() == "real text
" +def test_xml_export(): + page = _reading_order_page() + xml = page.export_as_xml()[0].decode() + words = re.findall(r'class="ocrx_word"[^>]*>([^<]*)<', xml) + assert words[:4] == ["A", "Two", "Column", "Study"] + assert words[-4:] == ["Page", "3", "of", "12"] + assert words.index("left") < words.index("right") + # The mixin method delegates to the exporter class, so the output is identical + xml_bytes, tree = XMLExporter().export_page(page) + assert isinstance(xml_bytes, bytes) and isinstance(tree, ET.ElementTree) + assert page.export_as_xml()[0] == xml_bytes + assert page.export_as("xml")[0] == xml_bytes + kie = _kie_page({CLASS_NAME: [_prediction("hi", 0.1, 0.2)]}) + assert kie.export_as_xml()[0] == XMLExporter().export_kie_page(kie)[0] + doc = elements.Document([page, page]) + doc_xml = XMLExporter().export_document(doc) + assert len(doc_xml) == 2 and doc.export_as_xml()[0][0] == doc_xml[0][0] + # Recognized tables are serialized as an hOCR text area (they used to be dropped) + xml = _table_page().export_as_xml()[0].decode() + assert 'id="table_1"' in xml and ">head<" in xml and ">body<" in xml + # The linearization can be opted out of: blocks first, then tables, in their stored order + xml = _table_page().export_as_xml(reading_order=False)[0].decode() + assert xml.index("intro") < xml.index(">head<") -def test_xml_export_edge_cases(): - from doctr.io.exporters import _covering_region_indices + # x_size (text height in pixels) and x_fsize (font size in points) are estimated from the + # element box height; the dpi kwarg scales the pixel-to-point conversion (default 72) + page = _page([_line_at("one two", 0.1, 0.1, 0.9, 0.2)]) + xml = page.export_as_xml()[0].decode() + # the line spans y=0.1..0.2 of a 1000px-high page -> 100px text height, 100pt at 72 dpi + assert "x_size 100; x_fsize 100;" in xml + assert "x_size 0;" not in xml + assert "x_size 100; x_fsize 50;" in page.export_as_xml(dpi=144)[0].decode() + # table rows and KIE predictions carry the estimated sizes as well + # row 0 spans y=0.6..0.65 -> 50px, row 1 spans y=0.66..0.71 -> 50px + assert "x_size 50; x_fsize 50;" in _table_page().export_as_xml()[0].decode() + assert "x_size 100; x_fsize 100;" in kie.export_as_xml()[0].decode() assert _covering_region_indices([], [((0.0, 0.0), (1.0, 1.0))]) == [] # Rotated geometries are rejected with an explicit error, for blocks, tables and KIE predictions alike poly = np.asarray([[0.1, 0.1], [0.4, 0.1], [0.4, 0.2], [0.1, 0.2]], dtype=np.float32) - rot_page = elements.Page( - np.zeros((10, 10, 3), dtype=np.uint8), - [ - elements.Block( - lines=[elements.Line([elements.Word("rot", 0.9, poly, 0.9, {"value": 0, "confidence": None})])] - ) - ], - 0, - (1000, 800), - ) with pytest.raises(TypeError): - rot_page.export_as_xml() + _page([elements.Line([elements.Word("rot", 0.9, poly, 0.9, _ORIENTATION)])]).export_as_xml() with pytest.raises(TypeError): XMLExporter().export_page(_table_page(rotated=True)) - rot_kie = elements.KIEPage( - np.zeros((10, 10, 3), dtype=np.uint8), - {CLASS_NAME: [elements.Prediction("rot", 0.9, poly, 0.9, {"value": 0, "confidence": None})]}, - 0, - (1000, 800), - ) with pytest.raises(TypeError): - rot_kie.export_as_xml() - # The linearization can be opted out of: blocks first, then tables, in their stored order - xml = _table_page().export_as_xml(reading_order=False)[0].decode() - assert xml.index("intro") < xml.index(">head<") - - -def test_document_forwards_reading_order_options(): - left = elements.Block(lines=[_line_at("left col", 0.08, 0.2, 0.45, 0.23)]) - right = elements.Block(lines=[_line_at("right col", 0.55, 0.2, 0.92, 0.23)]) - footer = elements.Block(lines=[_line_at("Page 1 of 2", 0.4, 0.95, 0.6, 0.97)]) - layout = [ - elements.LayoutElement("Text", 0.98, ((0.06, 0.18), (0.48, 0.26))), - elements.LayoutElement("Text", 0.98, ((0.52, 0.18), (0.94, 0.26))), - elements.LayoutElement("Page-footer", 0.97, ((0.35, 0.94), (0.65, 0.98))), - ] - page = elements.Page(np.zeros((10, 10, 3), dtype=np.uint8), [right, left, footer], 0, (1000, 800), layout=layout) - doc = elements.Document([page, page]) - - assert ( - doc.render(page_break=" || ") == "left col\n\nright col\n\nPage 1 of 2 || left col\n\nright col\n\nPage 1 of 2" - ) - assert "Page 1 of 2" not in doc.render(include_furniture=False) - assert doc.render(block_break=" ", page_break=" || ").startswith("left col right col") - assert doc.export_as("text", include_furniture=False) == doc.render(include_furniture=False) - - ordered = doc.export()["pages"][0]["blocks"] - assert [block["lines"][0]["words"][0]["value"] for block in ordered] == ["left", "right", "Page"] - raw = doc.export(reading_order=False)["pages"][0]["blocks"] - assert [block["lines"][0]["words"][0]["value"] for block in raw] == ["right", "left", "Page"] - assert doc.export_as("json", reading_order=False) == doc.export(reading_order=False) - json.dumps(doc.export(reading_order=False)) - - -def test_kie_document_forwards_reading_order_options(): - predictions = { - CLASS_NAME: [ - elements.Prediction("second", 0.9, ((0.1, 0.5), (0.9, 0.6)), 0.9, {"value": 0, "confidence": None}), - elements.Prediction("first", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None}), - ] - } - page = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) - doc = elements.KIEDocument([page]) - assert doc.render() == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" - # The reading direction reaches KIEPage.render through the document - assert doc.render(direction="ttb-ltr") == f"{CLASS_NAME}: first\n\n{CLASS_NAME}: second" - assert [p["value"] for p in doc.export()["pages"][0]["predictions"][CLASS_NAME]] == ["first", "second"] - raw = doc.export(reading_order=False)["pages"][0]["predictions"][CLASS_NAME] - assert [p["value"] for p in raw] == ["second", "first"] - json.dumps(doc.export(reading_order=False)) + _kie_page({CLASS_NAME: [elements.Prediction("rot", 0.9, poly, 0.9, _ORIENTATION)]}).export_as_xml() -def test_every_format_is_reachable_from_a_document(): +def test_json_export(): + # Straight boxes built from a detection array carry np.float32 coordinates page = elements.Page( np.zeros((10, 10, 3), dtype=np.uint8), - [elements.Block(lines=[_line_at("page text", 0.1, 0.1, 0.9, 0.15)])], - 0, - (1000, 800), + [ + elements.Block( + lines=[ + elements.Line([ + elements.Word( + "np", + np.float32(0.9), + ((np.float32(0.1), np.float32(0.1)), (np.float32(0.4), np.float32(0.2))), + np.float32(0.8), + {"value": np.int64(0), "confidence": None}, + ) + ]) + ] + ) + ], + np.int64(0), + (np.int64(1000), np.int64(800)), + {"value": np.float32(0.0), "confidence": np.float32(1.0)}, ) - predictions = { - CLASS_NAME: [elements.Prediction("value", 0.9, ((0.1, 0.1), (0.9, 0.2)), 0.9, {"value": 0, "confidence": None})] - } - kie_page = elements.KIEPage(np.zeros((10, 10, 3), dtype=np.uint8), predictions, 0, (1000, 800)) - formats = ("markdown", "md", "asciidoc", "adoc", "html", "text", "txt", "json", "dict", "xml", "hocr") + exported = page.export() + assert json.loads(json.dumps(exported)) is not None + word = exported["blocks"][0]["lines"][0]["words"][0] + assert [coord for point in word["geometry"] for coord in point] == pytest.approx([0.1, 0.1, 0.4, 0.2]) + assert all(type(coord) is float for point in word["geometry"] for coord in point) + assert type(word["confidence"]) is float and type(word["objectness_score"]) is float + assert type(word["crop_orientation"]["value"]) is int + assert exported["page_idx"] == 0 and type(exported["page_idx"]) is int + assert exported["dimensions"] == (1000, 800) and all(type(dim) is int for dim in exported["dimensions"]) + # Rotated lines / blocks expose their geometry as a numpy array, exported as nested tuples + poly = np.asarray([[0.1, 0.1], [0.4, 0.1], [0.4, 0.2], [0.1, 0.2]], dtype=np.float32) + rot_page = _page([elements.Line([elements.Word("rot", 0.9, poly, 0.8, _ORIENTATION)])]) + assert isinstance(rot_page.blocks[0].geometry, np.ndarray) # untouched in memory + rot_export = rot_page.export() + assert isinstance(rot_export["blocks"][0]["geometry"], tuple) + json.dumps(rot_export) + # ... and a whole document round-trips through JSON + doc = elements.Document([page, rot_page]) + assert elements.Document.from_dict(json.loads(json.dumps(doc.export()))) is not None - for element in (page, kie_page, elements.Document([page]), elements.KIEDocument([kie_page])): - for fmt in formats: - assert element.export_as(fmt) is not None - with pytest.raises(ValueError, match="unsupported export format"): - element.export_as("pptx") + # 0-d arrays and numpy scalars collapse to their Python equivalent + assert to_json_safe(np.array(3.5)) == 3.5 and type(to_json_safe(np.array(3.5))) is float + assert to_json_safe(np.int64(7)) == 7 and type(to_json_safe(np.int64(7))) is int + assert to_json_safe(np.bool_(True)) is True + # arrays become nested tuples, so `create_obj_patch` still recognizes an exported geometry + assert to_json_safe(np.asarray([[0.0, 1.0], [2.0, 3.0]])) == ((0.0, 1.0), (2.0, 3.0)) + # lists stay lists, sets are normalized to lists, dict keys to str + assert to_json_safe([np.float32(1.0), (np.int8(2),)]) == [1.0, (2,)] + assert sorted(to_json_safe({np.int64(1), np.int64(2)})) == [1, 2] + assert to_json_safe({np.int64(1): np.float32(0.5)}) == {"1": 0.5} + # anything already built-in is returned untouched + assert to_json_safe("text") == "text" and to_json_safe(None) is None + json.dumps(to_json_safe({"a": [np.float32(0.1)], "b": np.arange(3)})) - # The document-level named methods carry the page break of their format - doc = elements.Document([page, page]) - assert doc.export_as_html() == "page text
page text
" - assert doc.export_as_html(page_break="\n") == "page text
\npage text
" - assert doc.export_as_markdown().count("\n\n---\n\n") == 1 - assert doc.export_as_asciidoc().count("\n\n<<<\n\n") == 1 - assert len(doc.export_as_xml()) == 2 - kie_doc = elements.KIEDocument([kie_page, kie_page]) - kie_html = f"