Coverage for src / quber / core / figures / crosscheck.py: 94%
77 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"""Check every cell the scan read against the parse's reading of the same cell.
3The scan is the reader for a table printed as an image, and it has one failure
4mode: it turns thousands separators into decimal points. The parse reads the same
5image and is already in the artifacts. It is not a second table and contributes
6no structure — it is a second opinion on what a cell says, available for free,
7and it catches that failure.
9Every cell in the scan's grid is checked. A cell leaves here either confirmed by
10a second reader or read off the page by an agent. None stands on the scan's word
11alone:
13- The parse read the same text. Two independent readers agree, and the cell
14 stands.
15- The parse read something different. The agent reads the cell.
16- The parse has no cell there. The agent reads the cell. Having no second
17 opinion is a reason to look harder, not a reason to wave it through.
19Cells pair by how much one box covers the other, taken against the smaller of
20the two. The readers agree on where a cell is and disagree on how much room it
21takes: a row heading is bound by the parse to the word itself and by the scan to
22the whole row band, one box sitting entirely inside the other. Measured against
23their combined area those overlap almost not at all.
25Texts are compared as they stand. A currency symbol read as a letter, a comma
26read as a period, a parenthesis dropped from a negative — each changes what the
27document states, and a rule deciding which of those were too cosmetic to ask
28about would be deciding the thing the agent is there to decide.
30This checks values, not coverage. If the page prints a line the scan drew no box
31around, the table is short a row and nothing here notices.
32"""
34from __future__ import annotations
36from typing import Dict, List, Optional, Tuple
38from docling_core.types.doc.base import CoordOrigin
39from docling_core.types.doc.document import TableItem
40from loguru import logger
42from quber.agents.cell_reader import CellQuestion, CellReader
43from quber.core.extractors.camelot.acquire import column_letter, grid_to_addressed_markdown
44from quber.core.extractors.camelot.correspondence.geometry import coverage_fraction
45from quber.core.figures.geometry import NormBox, norm_box
46from quber.core.figures.models import ScannedTable
48#: Minimum coverage for a scan cell and a parse cell to be the same cell,
49#: measured against the smaller box so a tight box inside a wide one registers.
50MATCH_FRACTION = 0.10
53async def crosscheck_cells(
54 scanned: ScannedTable,
55 table_item: Optional[TableItem],
56 page_dims: Tuple[float, float],
57 table_image: bytes,
58 reader: Optional[CellReader],
59 match_fraction: float = MATCH_FRACTION,
60) -> Tuple[List[List[str]], int]:
61 """The scan's grid with every unconfirmed cell read off the page.
63 Returns the grid and how many cells the agent read. The grid keeps its shape:
64 only cell text changes, never the rows, the columns or the boxes.
66 Without a reader, or without a parse table to check against, the grid is
67 returned unchanged and the reason is logged. A run that cannot check is not
68 the same as a run that checked and found nothing.
69 """
70 if reader is None:
71 logger.info("Cell check: page {} no reader configured; the scan's grid stands", scanned.page)
72 return scanned.cells, 0
74 questions, addresses = _questions(scanned, table_item, page_dims, match_fraction)
75 if not questions:
76 logger.info("Cell check: page {} every cell confirmed by the parse", scanned.page)
77 return scanned.cells, 0
79 logger.info(
80 "Cell check: page {} asking about {} of {} cell(s)",
81 scanned.page,
82 len(questions),
83 sum(1 for row in scanned.cells for c in row if c.strip()),
84 )
85 readings = await reader.read_cells(table_image, grid_to_addressed_markdown(scanned.cells), questions)
87 grid = [list(row) for row in scanned.cells]
88 read = 0
89 asked = {q.address for q in questions}
90 for cell in readings.cells:
91 if cell.address not in asked:
92 logger.warning(
93 "Cell check: page {} the reader returned {} which it was not asked about; ignored",
94 scanned.page,
95 cell.address,
96 )
97 continue
98 row, col = addresses[cell.address]
99 if grid[row][col] != cell.value:
100 logger.info(
101 "Cell check: page {} {} {!r} -> {!r}",
102 scanned.page,
103 cell.address,
104 grid[row][col],
105 cell.value,
106 )
107 grid[row][col] = cell.value
108 read += 1
110 missing = asked - {c.address for c in readings.cells}
111 if missing:
112 logger.warning(
113 "Cell check: page {} the reader returned nothing for {}; those cells keep the scan's reading",
114 scanned.page,
115 sorted(missing),
116 )
117 return grid, read
120def _questions(
121 scanned: ScannedTable,
122 table_item: Optional[TableItem],
123 page_dims: Tuple[float, float],
124 match_fraction: float,
125) -> Tuple[List[CellQuestion], Dict[str, Tuple[int, int]]]:
126 """The cells the parse did not confirm, and where each address sits in the grid."""
127 parse_cells = _parse_cells(table_item, page_dims)
129 questions: List[CellQuestion] = []
130 addresses: Dict[str, Tuple[int, int]] = {}
131 for row, texts in enumerate(scanned.cells):
132 for col, text in enumerate(texts):
133 if not text.strip():
134 continue
135 box = norm_box(scanned.cell_boxes[row][col] if col < len(scanned.cell_boxes[row]) else None)
136 other = _best_match(box, parse_cells, match_fraction)
137 if other is not None and other == text:
138 continue
139 address = f"{column_letter(col)}{row + 1}"
140 addresses[address] = (row, col)
141 questions.append(CellQuestion(address=address, scan_read=text, parse_read=other))
142 return questions, addresses
145def _parse_cells(
146 table_item: Optional[TableItem], page_dims: Tuple[float, float]
147) -> List[Tuple[str, NormBox]]:
148 """The parse's cells for this table, boxed in the normalized top-left frame."""
149 if table_item is None:
150 return []
151 width, height = page_dims
152 out: List[Tuple[str, NormBox]] = []
153 for cell in table_item.data.table_cells:
154 bbox = cell.bbox
155 if bbox is None or not (cell.text or "").strip():
156 continue
157 if bbox.coord_origin == CoordOrigin.TOPLEFT:
158 box = (bbox.l / width, min(bbox.t, bbox.b) / height, bbox.r / width, max(bbox.t, bbox.b) / height)
159 else:
160 box = (
161 bbox.l / width,
162 (height - max(bbox.t, bbox.b)) / height,
163 bbox.r / width,
164 (height - min(bbox.t, bbox.b)) / height,
165 )
166 out.append((cell.text, box))
167 return out
170def _best_match(
171 box: Optional[NormBox], parse_cells: List[Tuple[str, NormBox]], match_fraction: float
172) -> Optional[str]:
173 """What the parse read in the cell covering `box` best, or none past the floor."""
174 if box is None:
175 return None
176 best_text: Optional[str] = None
177 best = match_fraction
178 for text, other in parse_cells:
179 cov = max(coverage_fraction(box, other), coverage_fraction(other, box))
180 if cov >= best:
181 best, best_text = cov, text
182 return best_text