Coverage for src / quber / core / extractors / camelot / correspondence / recovery.py: 31%
39 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""
2Guarded recovery for a detected table the full-page Camelot passes
3missed: one targeted, region-constrained stream pass aimed at a box,
4plus the pairing helper that picks a grid-locator region when the
5detector's own box drifted. The recovery can only upgrade a miss or
6no-op — never worse than reporting it.
7"""
9from __future__ import annotations
11import warnings
12from pathlib import Path
13from typing import Any, List, Optional, Tuple
15from quber.agents.grid_locator import LocatedTable
16from quber.core.extractors.camelot.acquire import (
17 CamelotCandidate,
18 cells_to_boxes,
19 df_to_cells,
20 grid_to_markdown,
21)
22from quber.core.extractors.camelot.tighten import tighten_cell_boxes
25def init_recovery_warning_filter() -> None:
26 """Silence camelot's expected 'No tables found in table area' miss.
28 The region-constrained recovery pass deliberately aims a stream parse at a
29 detector box that may hold no grid; that miss is reported separately as a
30 detected-not-extracted table, so the camelot warning is pure noise. A
31 persistent process-global filter is used (not a per-call context manager)
32 because the recovery runs across worker threads, where a context manager's
33 save/restore races and lets warnings leak through.
34 """
35 warnings.filterwarnings("ignore", message="No tables found in table area", category=UserWarning)
38init_recovery_warning_filter()
41def camelot_targeted(
42 source_str: str,
43 page: int,
44 area: str,
45 ordinal: int,
46 flavor: str = "stream",
47 row_tol: Optional[int] = None,
48 column_tol: Optional[int] = None,
49) -> Optional[CamelotCandidate]:
50 """Run a single region-constrained Camelot pass. Returns the
51 first table found in the region, or None if Camelot found none. Imports
52 camelot lazily (stream needs no OpenCV) and runs in-process; intended
53 to be called via asyncio.to_thread. Raises on Camelot-internal errors
54 (e.g. an empty region) — the caller guards and reports the table as
55 detected_not_extracted.
57 `row_tol`/`column_tol` override stream's grouping tolerances for a
58 capture-repair retry (e.g. a totals row typeset on a raised baseline
59 splits into two bands at the default tolerance and its numbers are
60 dropped at the band boundary). They apply to the stream flavor only.
61 """
62 import camelot
64 from quber.agents.completeness import page_words
66 kwargs: dict[str, Any] = {}
67 if flavor == "stream":
68 if row_tol is not None:
69 kwargs["row_tol"] = row_tol
70 if column_tol is not None:
71 kwargs["column_tol"] = column_tol
72 tables = camelot.read_pdf( # pyright: ignore[reportPrivateImportUsage,reportArgumentType]
73 source_str, pages=str(page), flavor=flavor, table_areas=[area], **kwargs
74 )
75 if not tables:
76 return None
77 t = tables[0]
78 # camelot exposes the table box only as the private `_bbox`; read it via
79 # getattr so the access is not flagged as private use.
80 raw_bbox = getattr(t, "_bbox", None)
81 bbox = tuple(raw_bbox) if raw_bbox else None
82 report = getattr(t, "parsing_report", {}) or {}
83 cells = df_to_cells(t.df)
84 cell_boxes = cells_to_boxes(getattr(t, "cells", None), cells)
85 _, page_h, words = page_words(Path(source_str), page)
86 cell_boxes = tighten_cell_boxes(cells, cell_boxes, words, page_h)
87 return CamelotCandidate(
88 candidate_id=f"recovery-p{page}-o{ordinal}",
89 flavor="stream" if flavor == "stream" else "lattice",
90 page=page,
91 bbox=bbox, # type: ignore[arg-type]
92 accuracy=float(report.get("accuracy", 0.0) or 0.0),
93 cells=cells,
94 cell_boxes=cell_boxes,
95 markdown=grid_to_markdown(cells),
96 )
99def nearest_grid_region(
100 located: List[LocatedTable],
101 consumed: set[int],
102 detector_bbox: Optional[Tuple[float, float, float, float]],
103) -> Optional[int]:
104 """Pick the unconsumed grid-located region best matching a detected table.
106 The detector's box drifts, but its vertical neighbourhood is roughly right
107 even when its extent is wrong, so we pair by nearest region center-y. When
108 the detector gave no box, fall back to reading order (first unconsumed).
109 Returns an index into `located`, or None if all regions are consumed.
110 """
111 candidates = [i for i in range(len(located)) if i not in consumed]
112 if not candidates:
113 return None
114 if detector_bbox is None:
115 return candidates[0]
116 center = (min(detector_bbox[1], detector_bbox[3]) + max(detector_bbox[1], detector_bbox[3])) / 2.0
117 return min(candidates, key=lambda i: abs((located[i].region[1] + located[i].region[3]) / 2.0 - center))