vi3ion 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_interface/__init__.py +2 -0
- agent_interface/mcp_server.py +12 -0
- vi3ion/__init__.py +3 -0
- vi3ion/analyzer.py +353 -0
- vi3ion/attention.py +96 -0
- vi3ion/brief.py +266 -0
- vi3ion/caption.py +145 -0
- vi3ion/cjk_glyphs.py +326 -0
- vi3ion/cli.py +1064 -0
- vi3ion/code_verify.py +173 -0
- vi3ion/colors.py +56 -0
- vi3ion/composition.py +371 -0
- vi3ion/config.py +229 -0
- vi3ion/core/__init__.py +1 -0
- vi3ion/core/context/__init__.py +3 -0
- vi3ion/core/context/session.py +25 -0
- vi3ion/core/coordinates.py +113 -0
- vi3ion/core/engine.py +1275 -0
- vi3ion/core/geometry/__init__.py +4 -0
- vi3ion/core/geometry/contours.py +23 -0
- vi3ion/core/geometry/edges.py +45 -0
- vi3ion/core/geometry/measurements.py +100 -0
- vi3ion/core/geometry/pipeline.py +83 -0
- vi3ion/core/geometry/shapes.py +215 -0
- vi3ion/core/ingestion/__init__.py +3 -0
- vi3ion/core/ingestion/loader.py +202 -0
- vi3ion/core/ocr/__init__.py +5 -0
- vi3ion/core/ocr/japan.py +105 -0
- vi3ion/core/ocr/models/japan_rec_crnn.onnx +0 -0
- vi3ion/core/ocr/pipeline.py +153 -0
- vi3ion/core/ocr/providers.py +152 -0
- vi3ion/core/ocr/reading_order.py +38 -0
- vi3ion/core/pixels/__init__.py +14 -0
- vi3ion/core/pixels/colors.py +128 -0
- vi3ion/core/pixels/element_color.py +209 -0
- vi3ion/core/pixels/pipeline.py +113 -0
- vi3ion/core/pixels/segmentation.py +71 -0
- vi3ion/core/pixels/statistics.py +66 -0
- vi3ion/core/semantics/__init__.py +67 -0
- vi3ion/core/semantics/clip_tags.py +108 -0
- vi3ion/core/semantics/florence.py +223 -0
- vi3ion/core/semantics/interface.py +68 -0
- vi3ion/core/semantics/merge.py +189 -0
- vi3ion/core/semantics/mock.py +83 -0
- vi3ion/core/semantics/owlvit.py +132 -0
- vi3ion/core/semantics/pipeline.py +178 -0
- vi3ion/core/semantics/remote.py +169 -0
- vi3ion/core/spatial/__init__.py +4 -0
- vi3ion/core/spatial/pipeline.py +125 -0
- vi3ion/core/spatial/relationships.py +201 -0
- vi3ion/core/spatial/scene_graph.py +66 -0
- vi3ion/core/summary.py +414 -0
- vi3ion/core/verification/__init__.py +4 -0
- vi3ion/core/verification/claims.py +278 -0
- vi3ion/core/verification/verifier.py +963 -0
- vi3ion/describe_shapes.py +300 -0
- vi3ion/errors.py +27 -0
- vi3ion/evaluate.py +311 -0
- vi3ion/evidence.py +187 -0
- vi3ion/footer_fix.py +137 -0
- vi3ion/fused_words.py +107 -0
- vi3ion/image_memory.py +444 -0
- vi3ion/index_sequence.py +201 -0
- vi3ion/local_stack.py +494 -0
- vi3ion/mcp_server.py +413 -0
- vi3ion/path_hints.py +88 -0
- vi3ion/relook.py +244 -0
- vi3ion/relook_rows.py +225 -0
- vi3ion/schemas/__init__.py +43 -0
- vi3ion/schemas/common.py +275 -0
- vi3ion/schemas/requests.py +94 -0
- vi3ion/schemas/responses.py +65 -0
- vi3ion/server/__init__.py +1 -0
- vi3ion/server/app.py +88 -0
- vi3ion/server/deps.py +9 -0
- vi3ion/server/routes/__init__.py +5 -0
- vi3ion/server/routes/images.py +251 -0
- vi3ion/server/routes/system.py +47 -0
- vi3ion/server/routes/tools.py +152 -0
- vi3ion/skill/SKILL.md +126 -0
- vi3ion/storage/__init__.py +4 -0
- vi3ion/storage/database.py +521 -0
- vi3ion/storage/image_store.py +45 -0
- vi3ion/text_merge.py +289 -0
- vi3ion/visual_diff.py +130 -0
- vi3ion/word_split.py +197 -0
- vi3ion-0.1.0.dist-info/METADATA +369 -0
- vi3ion-0.1.0.dist-info/RECORD +93 -0
- vi3ion-0.1.0.dist-info/WHEEL +5 -0
- vi3ion-0.1.0.dist-info/entry_points.txt +3 -0
- vi3ion-0.1.0.dist-info/licenses/LICENSE +201 -0
- vi3ion-0.1.0.dist-info/licenses/NOTICE +21 -0
- vi3ion-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Legacy shim: the MCP server now lives in vi3ion.mcp_server.
|
|
2
|
+
|
|
3
|
+
Launchers built from older entry points (``from agent_interface.mcp_server
|
|
4
|
+
import main``) keep working through this module instead of failing with
|
|
5
|
+
ModuleNotFoundError. Running the module directly also starts the server.
|
|
6
|
+
"""
|
|
7
|
+
from vi3ion.mcp_server import main # noqa: F401
|
|
8
|
+
|
|
9
|
+
__all__ = ["main"]
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
12
|
+
main()
|
vi3ion/__init__.py
ADDED
vi3ion/analyzer.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Text layer: OCR -> rows -> spatial regions -> budgeted, annotated items.
|
|
2
|
+
|
|
3
|
+
This is the "exact text and layout" half of the brief. Drop rules:
|
|
4
|
+
- confidence < 0.30 -> dropped and logged, counted per region
|
|
5
|
+
- confidence < 0.80 -> marked ``(uncertain)``
|
|
6
|
+
- per-region budget -> dropped and logged (counts reported per region)
|
|
7
|
+
|
|
8
|
+
Merged/fragmented OCR text is repaired by :mod:`vi3ion.text_merge` and
|
|
9
|
+
:mod:`vi3ion.fused_words`, which run between OCR and region assignment.
|
|
10
|
+
Second-look passes (contrast, 2x small-text bands, per-row index recovery) live
|
|
11
|
+
in :mod:`vi3ion.relook` and :mod:`vi3ion.relook_rows`; index flags are reasoned
|
|
12
|
+
about in :mod:`vi3ion.index_sequence`. Shape/graph data is intentionally not
|
|
13
|
+
surfaced here; it belongs in debug logs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import cv2
|
|
24
|
+
|
|
25
|
+
from .cjk_glyphs import recover_cjk
|
|
26
|
+
from .core.ocr.providers import get_ocr_provider
|
|
27
|
+
from .footer_fix import reconstruct_footer
|
|
28
|
+
from .fused_words import split_lowercase_words
|
|
29
|
+
from .index_sequence import sequence_correct
|
|
30
|
+
from .relook import contrast_items, small_text_items
|
|
31
|
+
from .relook_rows import recover_rows
|
|
32
|
+
from .word_split import split_body_words
|
|
33
|
+
from .text_merge import ( # noqa: F401 (re-exported for callers)
|
|
34
|
+
REGION_ORDER,
|
|
35
|
+
TextItem,
|
|
36
|
+
join_same_row,
|
|
37
|
+
pair_numbers_across_rows,
|
|
38
|
+
split_merged_words,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("vi3ion.analyzer")
|
|
42
|
+
|
|
43
|
+
TOP_FRACTION = 0.12
|
|
44
|
+
BOTTOM_FRACTION = 0.08
|
|
45
|
+
COLUMN_FRACTION = 0.25
|
|
46
|
+
# Display text wider than this is anchored by its left edge: a headline that
|
|
47
|
+
# starts in the left column belongs there even when its centre crosses 25%.
|
|
48
|
+
WIDE_TEXT_FRACTION = 0.25
|
|
49
|
+
CONF_FLOOR = 0.30
|
|
50
|
+
CONF_UNCERTAIN = 0.80
|
|
51
|
+
|
|
52
|
+
# Budget share per region (items), roughly 1500 tokens total when combined with
|
|
53
|
+
# the shape and evidence sections. The bottom bar is never starved.
|
|
54
|
+
REGION_BUDGET = {
|
|
55
|
+
"top_bar": 8,
|
|
56
|
+
"left_column": 12,
|
|
57
|
+
"centre": 8,
|
|
58
|
+
"right_column": 14,
|
|
59
|
+
"bottom_bar": 14,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AnalyzerResult:
|
|
65
|
+
items: list[TextItem] = field(default_factory=list)
|
|
66
|
+
sections: dict[str, list[TextItem]] = field(default_factory=dict)
|
|
67
|
+
omitted: dict[str, int] = field(default_factory=dict)
|
|
68
|
+
untranscribed: dict[str, int] = field(default_factory=dict)
|
|
69
|
+
width: int = 0
|
|
70
|
+
height: int = 0
|
|
71
|
+
duration_ms: float = 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ensure_debug_log() -> Path:
|
|
75
|
+
debug_dir = Path("data") / "debug"
|
|
76
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
path = debug_dir / "analyzer.log"
|
|
78
|
+
handler = logging.FileHandler(path, encoding="utf-8")
|
|
79
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
80
|
+
if logger.handlers:
|
|
81
|
+
logger.handlers = [handler]
|
|
82
|
+
else:
|
|
83
|
+
logger.addHandler(handler)
|
|
84
|
+
logger.setLevel(logging.DEBUG)
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ocr_lines(bgr: np.ndarray, path: Path):
|
|
89
|
+
"""Primary OCR pass. Returns (kept, dropped) with drops kept for counting."""
|
|
90
|
+
provider = get_ocr_provider()
|
|
91
|
+
if not provider.available:
|
|
92
|
+
logger.warning("OCR provider unavailable: %s", path)
|
|
93
|
+
return [], []
|
|
94
|
+
kept, dropped = [], []
|
|
95
|
+
for line in provider.recognize(bgr):
|
|
96
|
+
text = line.text.strip()
|
|
97
|
+
if not text:
|
|
98
|
+
logger.debug("drop: empty text @ %s", path)
|
|
99
|
+
continue
|
|
100
|
+
if line.confidence < CONF_FLOOR:
|
|
101
|
+
logger.debug(
|
|
102
|
+
"drop: confidence %.2f < %.2f %r @ %s",
|
|
103
|
+
line.confidence, CONF_FLOOR, text, path,
|
|
104
|
+
)
|
|
105
|
+
dropped.append((text, float(line.confidence), tuple(line.bbox)))
|
|
106
|
+
continue
|
|
107
|
+
kept.append(line)
|
|
108
|
+
return kept, dropped
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _text_items(lines, force_uncertain: bool = False) -> list[TextItem]:
|
|
112
|
+
return [
|
|
113
|
+
TextItem(
|
|
114
|
+
value=line.text.strip(),
|
|
115
|
+
bbox=tuple(float(v) for v in line.bbox),
|
|
116
|
+
confidence=float(line.confidence),
|
|
117
|
+
uncertain=force_uncertain or line.confidence < CONF_UNCERTAIN,
|
|
118
|
+
)
|
|
119
|
+
for line in lines
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _group_rows(items: list[TextItem]) -> list[list[TextItem]]:
|
|
124
|
+
"""Cluster by vertical overlap; sort rows top->bottom, items left->right."""
|
|
125
|
+
ordered = sorted(items, key=lambda item: (item.bbox[1], item.bbox[0]))
|
|
126
|
+
rows: list[list[TextItem]] = []
|
|
127
|
+
for item in ordered:
|
|
128
|
+
y0, y1 = item.bbox[1], item.bbox[3]
|
|
129
|
+
placed = False
|
|
130
|
+
for row in rows:
|
|
131
|
+
r0 = min(i.bbox[1] for i in row)
|
|
132
|
+
r1 = max(i.bbox[3] for i in row)
|
|
133
|
+
overlap = min(y1, r1) - max(y0, r0)
|
|
134
|
+
if overlap > 0.5 * min(y1 - y0, r1 - r0):
|
|
135
|
+
row.append(item)
|
|
136
|
+
placed = True
|
|
137
|
+
break
|
|
138
|
+
if not placed:
|
|
139
|
+
rows.append([item])
|
|
140
|
+
for row in rows:
|
|
141
|
+
row.sort(key=lambda item: item.bbox[0])
|
|
142
|
+
rows.sort(key=lambda row: min(i.bbox[1] for i in row))
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _region_of(bbox, width: int, height: int) -> str:
|
|
147
|
+
"""Spatial region for one box. Wide display text is anchored by its left edge."""
|
|
148
|
+
x0, y0, x1, y1 = bbox
|
|
149
|
+
if y1 < TOP_FRACTION * height:
|
|
150
|
+
return "top_bar"
|
|
151
|
+
if y0 > (1 - BOTTOM_FRACTION) * height:
|
|
152
|
+
return "bottom_bar"
|
|
153
|
+
anchor = x0 if (x1 - x0) > WIDE_TEXT_FRACTION * width else (x0 + x1) / 2
|
|
154
|
+
if anchor < COLUMN_FRACTION * width:
|
|
155
|
+
return "left_column"
|
|
156
|
+
if anchor > (1 - COLUMN_FRACTION) * width:
|
|
157
|
+
return "right_column"
|
|
158
|
+
return "centre"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _assign_regions(items: list[TextItem], width: int, height: int) -> None:
|
|
162
|
+
for item in items:
|
|
163
|
+
item.region = _region_of(item.bbox, width, height)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_body_text(item: TextItem) -> bool:
|
|
167
|
+
"""Paragraph prose: short item, contains lowercase, not a bare number."""
|
|
168
|
+
h = item.bbox[3] - item.bbox[1]
|
|
169
|
+
if not 10 <= h <= 40:
|
|
170
|
+
return False
|
|
171
|
+
if any(ch.islower() for ch in item.value):
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _vertical_adjacent(above: TextItem, below: TextItem) -> bool:
|
|
177
|
+
"""Two same-column lines with strong x-overlap and a small gap."""
|
|
178
|
+
ox = min(above.bbox[2], below.bbox[2]) - max(above.bbox[0], below.bbox[0])
|
|
179
|
+
w = min(above.bbox[2] - above.bbox[0], below.bbox[2] - below.bbox[0])
|
|
180
|
+
if w <= 0 or ox < 0.35 * w:
|
|
181
|
+
return False
|
|
182
|
+
gap = below.bbox[1] - above.bbox[3]
|
|
183
|
+
h = above.bbox[3] - above.bbox[1]
|
|
184
|
+
return 0 <= gap <= 2.2 * h
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _join_paragraphs(sections: dict[str, list[TextItem]]) -> dict[str, list[TextItem]]:
|
|
188
|
+
"""Merge vertically adjacent body-text lines in a column into one paragraph.
|
|
189
|
+
|
|
190
|
+
Only small lowercase-containing lines are considered, so headlines and
|
|
191
|
+
uppercase titles never merge. The joined item inherits every line's text
|
|
192
|
+
in reading order and is marked uncertain.
|
|
193
|
+
"""
|
|
194
|
+
for region in ("left_column", "centre"):
|
|
195
|
+
items = sorted(sections[region], key=lambda i: (i.bbox[1], i.bbox[0]))
|
|
196
|
+
out: list[TextItem] = []
|
|
197
|
+
i = 0
|
|
198
|
+
while i < len(items):
|
|
199
|
+
group = [items[i]]
|
|
200
|
+
j = i + 1
|
|
201
|
+
while j < len(items) and _is_body_text(group[-1]) and _is_body_text(items[j]):
|
|
202
|
+
if not _vertical_adjacent(group[-1], items[j]):
|
|
203
|
+
break
|
|
204
|
+
group.append(items[j])
|
|
205
|
+
j += 1
|
|
206
|
+
if len(group) > 1:
|
|
207
|
+
value = " ".join(g.value for g in group)
|
|
208
|
+
box = (
|
|
209
|
+
min(g.bbox[0] for g in group),
|
|
210
|
+
min(g.bbox[1] for g in group),
|
|
211
|
+
max(g.bbox[2] for g in group),
|
|
212
|
+
max(g.bbox[3] for g in group),
|
|
213
|
+
)
|
|
214
|
+
out.append(
|
|
215
|
+
TextItem(
|
|
216
|
+
value=value,
|
|
217
|
+
bbox=box,
|
|
218
|
+
confidence=min(g.confidence for g in group),
|
|
219
|
+
region=region,
|
|
220
|
+
uncertain=any(g.uncertain for g in group),
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
i = j
|
|
224
|
+
else:
|
|
225
|
+
out.append(items[i])
|
|
226
|
+
i += 1
|
|
227
|
+
sections[region] = out
|
|
228
|
+
return sections
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _select_budget(sections: dict[str, list[TextItem]]) -> dict[str, int]:
|
|
232
|
+
omitted: dict[str, int] = {}
|
|
233
|
+
for region in REGION_ORDER:
|
|
234
|
+
items = sections.get(region, [])
|
|
235
|
+
cap = REGION_BUDGET[region]
|
|
236
|
+
if len(items) > cap:
|
|
237
|
+
for item in items[cap:]:
|
|
238
|
+
logger.debug("drop: budget %s (%s)", item.value, region)
|
|
239
|
+
sections[region] = items[:cap]
|
|
240
|
+
omitted[region] = len(items) - cap
|
|
241
|
+
return omitted
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _measure_item_colors(bgr, sections) -> None:
|
|
245
|
+
"""Attach a measured colour to every text item (ink + local background)."""
|
|
246
|
+
from .core.pixels.element_color import measure_element
|
|
247
|
+
from .text_merge import _glyph_ink
|
|
248
|
+
|
|
249
|
+
height, width = bgr.shape[:2]
|
|
250
|
+
for region_items in sections.values():
|
|
251
|
+
for item in region_items:
|
|
252
|
+
try:
|
|
253
|
+
x0, y0, x1, y1 = (int(round(v)) for v in item.bbox)
|
|
254
|
+
except Exception: # noqa: BLE001
|
|
255
|
+
continue
|
|
256
|
+
x0 = max(0, min(width - 1, x0))
|
|
257
|
+
y0 = max(0, min(height - 1, y0))
|
|
258
|
+
x1 = max(x0 + 1, min(width, x1))
|
|
259
|
+
y1 = max(y0 + 1, min(height, y1))
|
|
260
|
+
crop = bgr[y0:y1, x0:x1]
|
|
261
|
+
if crop.size == 0:
|
|
262
|
+
continue
|
|
263
|
+
mask = None
|
|
264
|
+
try:
|
|
265
|
+
mask = _glyph_ink(crop)
|
|
266
|
+
except Exception: # noqa: BLE001
|
|
267
|
+
mask = None
|
|
268
|
+
try:
|
|
269
|
+
item.color = measure_element(
|
|
270
|
+
bgr, (x0, y0, x1, y1), mask if mask is not None else None
|
|
271
|
+
)
|
|
272
|
+
except Exception: # noqa: BLE001 - colour must never break the brief
|
|
273
|
+
item.color = None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def analyze_image(image_path: str | Path) -> AnalyzerResult:
|
|
277
|
+
started = time.perf_counter()
|
|
278
|
+
_ensure_debug_log()
|
|
279
|
+
path = Path(image_path)
|
|
280
|
+
bgr = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
|
281
|
+
if bgr is None:
|
|
282
|
+
raise ValueError(f"could not decode image: {path}")
|
|
283
|
+
height, width = bgr.shape[:2]
|
|
284
|
+
|
|
285
|
+
# Small captures (low-DPI screenshots, scaled thumbnails) lose text to the
|
|
286
|
+
# OCR pass. Upscale before OCR and map the boxes back afterwards.
|
|
287
|
+
ocr_bgr = bgr
|
|
288
|
+
scale = 1.0
|
|
289
|
+
if max(width, height) < 900:
|
|
290
|
+
scale = 2.0 if max(width, height) >= 400 else 3.0
|
|
291
|
+
ocr_bgr = cv2.resize(
|
|
292
|
+
bgr, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_CUBIC
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
raw, dropped = _ocr_lines(ocr_bgr, path)
|
|
296
|
+
if scale != 1.0:
|
|
297
|
+
for line in raw:
|
|
298
|
+
x0, y0, x1, y1 = line.bbox
|
|
299
|
+
line.bbox = (x0 / scale, y0 / scale, x1 / scale, y1 / scale)
|
|
300
|
+
if line.polygon:
|
|
301
|
+
line.polygon = [[x / scale, y / scale] for x, y in line.polygon]
|
|
302
|
+
dropped = [
|
|
303
|
+
(text, conf, (x0 / scale, y0 / scale, x1 / scale, y1 / scale))
|
|
304
|
+
for text, conf, (x0, y0, x1, y1) in dropped
|
|
305
|
+
]
|
|
306
|
+
items = _text_items(raw)
|
|
307
|
+
items.extend(contrast_items(bgr, items, path))
|
|
308
|
+
items.extend(small_text_items(bgr, items, path))
|
|
309
|
+
items = split_merged_words(bgr, items)
|
|
310
|
+
items = split_lowercase_words(bgr, items)
|
|
311
|
+
items = split_body_words(bgr, items)
|
|
312
|
+
_assign_regions(items, width, height)
|
|
313
|
+
items.extend(recover_rows(bgr, items, width, height))
|
|
314
|
+
# CJK margin recovery is the most expensive extra pass; skip it in
|
|
315
|
+
# low-resource mode (local models rarely need marginal kanji).
|
|
316
|
+
from .config import get_settings
|
|
317
|
+
|
|
318
|
+
if not get_settings().low_resource:
|
|
319
|
+
items.extend(recover_cjk(bgr, items, width, height))
|
|
320
|
+
|
|
321
|
+
sections: dict[str, list[TextItem]] = {region: [] for region in REGION_ORDER}
|
|
322
|
+
for row in _group_rows(items):
|
|
323
|
+
for item in row:
|
|
324
|
+
sections[item.region].append(item)
|
|
325
|
+
sections = join_same_row(sections)
|
|
326
|
+
sections = _join_paragraphs(sections)
|
|
327
|
+
# Index reasoning runs after joins so merged rows (label + margin CJK)
|
|
328
|
+
# still get their expected-number flag.
|
|
329
|
+
for region in REGION_ORDER:
|
|
330
|
+
sequence_correct(sections[region])
|
|
331
|
+
sections = pair_numbers_across_rows(sections)
|
|
332
|
+
sections["bottom_bar"] = reconstruct_footer(bgr, sections["bottom_bar"])
|
|
333
|
+
|
|
334
|
+
# Measure each element's own colour (ink on its local background). The
|
|
335
|
+
# glyph mask separates letters from the surface behind them.
|
|
336
|
+
_measure_item_colors(bgr, sections)
|
|
337
|
+
|
|
338
|
+
untranscribed: dict[str, int] = {}
|
|
339
|
+
for text, confidence, bbox in dropped:
|
|
340
|
+
region = _region_of(bbox, width, height)
|
|
341
|
+
untranscribed[region] = untranscribed.get(region, 0) + 1
|
|
342
|
+
logger.debug("not transcribed (%.2f) %r in %s", confidence, text, region)
|
|
343
|
+
|
|
344
|
+
omitted = _select_budget(sections)
|
|
345
|
+
return AnalyzerResult(
|
|
346
|
+
items=items,
|
|
347
|
+
sections=sections,
|
|
348
|
+
omitted=omitted,
|
|
349
|
+
untranscribed=untranscribed,
|
|
350
|
+
width=width,
|
|
351
|
+
height=height,
|
|
352
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
353
|
+
)
|
vi3ion/attention.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Context-driven attention relook.
|
|
2
|
+
|
|
3
|
+
When the user message supplies terms (the described bug area), only OCR lines
|
|
4
|
+
whose text overlaps those terms are re-read at a higher scale. This focuses
|
|
5
|
+
compute where the user is looking and leaves the rest of the frame untouched.
|
|
6
|
+
Everything produced here is still evidence: re-reads are only accepted when
|
|
7
|
+
the recogniser is more confident, and the updated lines keep their geometry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
|
|
17
|
+
from .core.ocr.providers import OCRLine, get_ocr_provider
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("vi3ion.analyzer")
|
|
20
|
+
|
|
21
|
+
MAX_BOXES = 8
|
|
22
|
+
REL_R_SCALES = (2, 3)
|
|
23
|
+
REL_PAD = 4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _term_words(terms: list[str]) -> set[str]:
|
|
27
|
+
words: set[str] = set()
|
|
28
|
+
for term in terms:
|
|
29
|
+
for w in re.findall(r"[0-9a-z\u3040-\u30ff\u3400-\u9fff]+", term.lower()):
|
|
30
|
+
if len(w) >= 2:
|
|
31
|
+
words.add(w)
|
|
32
|
+
return words
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def overlap_score(text: str, term_words: set[str]) -> float:
|
|
36
|
+
if not term_words or not text:
|
|
37
|
+
return 0.0
|
|
38
|
+
words = {w for w in re.findall(r"[0-9a-z\u3040-\u30ff\u3400-\u9fff]+", text.lower()) if len(w) >= 2}
|
|
39
|
+
if not words:
|
|
40
|
+
return 0.0
|
|
41
|
+
hit = len(words & term_words)
|
|
42
|
+
# strong weight for exact phrase containment
|
|
43
|
+
any_contain = any(tw in text.lower() for tw in term_words if len(tw) >= 4)
|
|
44
|
+
return (hit / len(term_words)) + (0.6 if any_contain else 0.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def attention_relook(
|
|
48
|
+
bgr,
|
|
49
|
+
lines: list[OCRLine],
|
|
50
|
+
terms: list[str],
|
|
51
|
+
provider=None,
|
|
52
|
+
max_boxes: int = MAX_BOXES,
|
|
53
|
+
) -> tuple[int, float]:
|
|
54
|
+
"""Re-read only lines overlapping the context terms; returns (updated, ms)."""
|
|
55
|
+
if not terms or not lines:
|
|
56
|
+
return 0, 0.0
|
|
57
|
+
provider = provider or get_ocr_provider()
|
|
58
|
+
if not provider.available:
|
|
59
|
+
return 0, 0.0
|
|
60
|
+
term_words = _term_words(terms)
|
|
61
|
+
scored = [(overlap_score(line.text, term_words), line) for line in lines]
|
|
62
|
+
scored.sort(key=lambda pair: -pair[0])
|
|
63
|
+
selected = [line for score, line in scored if score > 0.0][:max_boxes]
|
|
64
|
+
if not selected:
|
|
65
|
+
return 0, 0.0
|
|
66
|
+
|
|
67
|
+
import time
|
|
68
|
+
|
|
69
|
+
started = time.perf_counter()
|
|
70
|
+
updated = 0
|
|
71
|
+
for line in selected:
|
|
72
|
+
x0, y0, x1, y1 = (int(round(v)) for v in line.bbox)
|
|
73
|
+
crop = bgr[max(0, y0 - REL_PAD):y1 + REL_PAD, max(0, x0 - REL_PAD):x1 + REL_PAD]
|
|
74
|
+
if crop.size == 0:
|
|
75
|
+
continue
|
|
76
|
+
best: tuple[float, str] | None = None
|
|
77
|
+
for scale in REL_R_SCALES:
|
|
78
|
+
big = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
|
79
|
+
for reread in provider.recognize(big):
|
|
80
|
+
text = reread.text.strip()
|
|
81
|
+
if not text:
|
|
82
|
+
continue
|
|
83
|
+
if best is None or reread.confidence > best[0]:
|
|
84
|
+
best = (float(reread.confidence), text)
|
|
85
|
+
if best is None or best[0] <= line.confidence:
|
|
86
|
+
continue
|
|
87
|
+
# keep the reading only when it overlaps/contains the original text
|
|
88
|
+
old = re.sub(r"[^0-9a-z\u3040-\u30ff\u3400-\u9fff]+", "", line.text.lower())
|
|
89
|
+
new = re.sub(r"[^0-9a-z\u3040-\u30ff\u3400-\u9fff]+", "", best[1].lower())
|
|
90
|
+
if not old or not new or (old not in new and new not in old):
|
|
91
|
+
continue
|
|
92
|
+
line.text = best[1]
|
|
93
|
+
line.confidence = best[0]
|
|
94
|
+
updated += 1
|
|
95
|
+
logger.debug("attention relook %r -> %r @ %.2f", old, new, best[0])
|
|
96
|
+
return updated, (time.perf_counter() - started) * 1000.0
|