Spaces:
Running on Zero
Running on Zero
File size: 14,879 Bytes
2874635 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 | #!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from dataclasses import asdict
from pathlib import Path
from typing import Callable, Optional, Union
import cv2
import numpy as np
try: # HF Space ships the torch backend only;
import onnxruntime as ort # onnxruntime is a local-dev convenience.
except ImportError: # pragma: no cover
ort = None
import cartouche_matcher as CM
import enhance_img
import layout_detector
import spatial_logic as SL
import sphinx_corrector as SC
"""
pipeline.py — end-to-end glue (V4).
bytes/path/ndarray
[optional enhance_img.enhance() — OFF by default; A/B on grand_glyphs
showed it deletes real signs and hallucinates fakes on clean images]
ONNX global pass (letterbox resize — matches Ultralytics V4 training)
postprocess_onnx (conf NMS top-3)
tag_cartouche_members
merge_duplicate_boxes
outer: cluster_quadrats + assemble_reading_order + sphinx_corrector.correct()
cartouches: per-cartouche RO (single_line=True)
+ cartouche_matcher.match_cartouche + apply_panel_consensus
Smoke test:
python pipeline.py <image>
"""
ROOT = Path(__file__).parent
# Drop cartouches whose bbox sits within this fraction of the image border.
# Egyptian cartouches always have both bracket ends visible; a clipped one
# is a photo-crop artifact and its interior is incomplete by definition.
EDGE_FRAC = 0.01
def _draw_detections(bgr: np.ndarray, detections) -> np.ndarray:
"""
YOLO-style annotated copy of the image: one box per detection with
'class conf' label. Cartouches gold and thicker; signs green;
unknowns grey. Label/line size scales with image resolution.
"""
out = bgr.copy()
H, W = out.shape[:2]
thick = max(1, round(min(H, W) / 640))
font_scale = min(H, W) / 1600
font = cv2.FONT_HERSHEY_SIMPLEX
for d in detections:
x1, y1, x2, y2 = (int(round(v)) for v in d.bbox)
cls, score = d.top3[0]
if d.is_cartouche():
color, t = (0, 190, 255), thick * 2 # gold (BGR)
elif cls.lower() == 'unknown':
color, t = (160, 160, 160), thick # grey
else:
color, t = (80, 200, 60), thick # green
cv2.rectangle(out, (x1, y1), (x2, y2), color, t)
label = f'{cls} {min(score, 0.998):.2f}'
(tw, th), base = cv2.getTextSize(label, font, font_scale, thick)
ty = y1 - 4 if y1 - th - base - 4 >= 0 else y2 + th + base + 4
cv2.rectangle(out, (x1, ty - th - base),
(x1 + tw + 2, ty + base),
color, -1)
cv2.putText(out, label, (x1 + 1, ty),
font, font_scale,
(0, 0, 0), thick,
cv2.LINE_AA)
return out
def _filter_edge_cartouches(detections, img_w: int, img_h: int) -> list:
"""Drop cartouche-class detections whose bbox touches any image edge."""
ex, ey = EDGE_FRAC * img_w, EDGE_FRAC * img_h #| EDGE_FRAC * img_h
kept = []
for d in detections:
if d.is_cartouche():
x1, y1, x2, y2 = d.bbox
if (x1 <= ex or y1 <= ey or x2 >= img_w - ex or y2 >= img_h - ey):
continue
kept.append(d)
return kept
class SphinxPipeline:
"""Single-shot pipeline. Loads heavy artifacts once at construction."""
def __init__(
self,
onnx_path : Optional[Path] = None,
class_map : Path = ROOT / 'artifacts' / 'class_map50_v9.json',
trie_pkl : Path = ROOT / 'artifacts' / 'sphinx_trie_v4.pkl',
bbaw_parquet : Path = ROOT / 'artifacts' / 'bbaw_clean.parquet',
confusion_csv : Path = ROOT / 'artifacts' / 'confusion_matrix_v9_normalized.csv',
imgsz : int = 1024,
providers : Optional[list] = None,
infer_fn : Optional[Callable[[np.ndarray], list]] = None,
):
"""
Detection backend is injected, not hardcoded.
infer_fn -- a Callable[[bgr ndarray], list[Detection]]; on the HF Space
this is infer_torch.make_torch_infer_fn() (v10 .pt, ZeroGPU).
onnx_path -- local-dev alternative: builds the ONNX backend instead. The
two are interchangeable because both letterbox identically
and both end in SL.postprocess_onnx, so the top-3 slot
contract the corrector depends on is preserved either way.
Exactly one of infer_fn / onnx_path must be given.
"""
if (infer_fn is None) == (onnx_path is None):
raise ValueError('pass exactly one of infer_fn= or onnx_path=')
self.class_names = list(json.load(open(class_map)).keys())
assert len(self.class_names) == 150, \
f'expected 150 classes, got {len(self.class_names)}'
conf = Path(confusion_csv) if Path(confusion_csv).exists() else None
self.trie, self.log_prob, self.unigrams, self.sub_cost = \
SC.load_corrector(trie_pkl, bbaw_parquet, confusion_csv=conf)
self.royal_names = CM.load_royal_names()
self.imgsz = imgsz
if infer_fn is not None:
self.session = None
self.infer_global = infer_fn
else:
if ort is None:
raise ImportError(
'onnxruntime is not installed — pass infer_fn= instead '
'(the Space uses the torch/ZeroGPU backend).')
if not Path(onnx_path).exists():
raise FileNotFoundError(f'ONNX weights not found: {onnx_path}')
providers = providers or ['CPUExecutionProvider']
self.session = ort.InferenceSession(str(onnx_path), providers=providers)
self.infer_global = SL.make_onnx_infer_fn(
self.session, self.class_names,
conf_thresh=SL.CONF_THRESHOLD, imgsz=imgsz, mode='letterbox',
)
@staticmethod
def _load_image(image: Union[str, Path, bytes, bytearray, np.ndarray]) -> np.ndarray:
# TODO: use PIL
if isinstance(image, np.ndarray):
return image
if isinstance(image, (bytes, bytearray)):
arr = np.frombuffer(image, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
else:
bgr = cv2.imread(str(image))
if bgr is None:
raise ValueError(f'could not decode image: {image!r}')
return bgr
def run(
self,
image : Union[str, Path, bytes, bytearray, np.ndarray],
*,
direction : str = 'rtl',
layout : Optional[str] = None, # 'rows' | 'columns' | None=auto
use_enhance : bool = False,
preset : str = 'default',
annotate : bool = False, # add 'annotated_bgr' ndarray to result
) -> dict:
bgr = self._load_image(image)
if use_enhance:
bgr = enhance_img.enhance(bgr, preset=preset)
H, W = bgr.shape[:2]
# 1) Global detection (YOLO-driven)
dets = self.infer_global(bgr)
# 1a) Drop edge-clipped cartouches (partial bracket -> incomplete
# interior). Must run before layout vote so the cartouche-aspect
# signal isn't poisoned by tiny edge slivers.
dets = _filter_edge_cartouches(dets, W, H)
# 1a') Collapse twin cartouche boxes over the same physical cartouche
# (a low CARTOUCHE_CONF admits weak duplicates that the 0.50 NMS
# leaves alone). Must run before tagging so interior signs bind
# to the surviving box, and before the layout vote so duplicate
# cartouches don't skew the cartouche-aspect signal.
dets = SL.merge_duplicate_cartouches(dets)
# 1b) Layout should be supplied by the caller — the geometric
# auto-detector is UNRELIABLE on real walls (misvotes rows vs
# columns). Kept only as a last-resort fallback for callers
# that genuinely cannot know; the API makes layout mandatory.
if layout is None:
layout = layout_detector.detect_layout_from_detections(dets, W, H)
import logging
logging.getLogger('sphinxeyes.pipeline').warning(
f'layout not supplied — auto-detector guessed {layout!r}. '
f'This heuristic is unreliable; pass layout explicitly.')
if layout not in ('rows', 'columns'):
layout = 'rows'
# 2) Cartouche containment + duplicate merge
cart_idxs = SL.tag_cartouche_members(dets, img_w=W, img_h=H)
dets = SL.merge_duplicate_boxes(dets)
cart_idxs = [i for i, d in enumerate(dets) if d.is_cartouche()]
# 3) Outer text reading order + corrector
outer = [d for d in dets
if not d.inside_cartouche and not d.is_cartouche()]
outer_quads = SL.cluster_quadrats(outer, layout=layout)
outer_ro = SL.assemble_reading_order(
outer_quads, layout=layout, direction=direction)
canon_outer = self._canon_slots(outer_ro.slots)
correction = SC.correct(
canon_outer, self.trie, self.log_prob, self.unigrams,
sub_cost_matrix=self.sub_cost,
boundary_hints=outer_ro.boundary_hints,
)
# 4) Cartouches (interior reading + royal-name match + consensus)
cart_results : list[Optional[CM.RoyalMatch]] = []
cart_slots_all: list[list[list[tuple[str, float]]]] = []
cart_meta : list[dict] = []
for ci in cart_idxs:
members = [d for d in dets
if d.inside_cartouche and d.cartouche_id == ci]
if not members:
cart_results.append(None)
cart_slots_all.append([])
cart_meta.append({'bbox': dets[ci].bbox, 'n_members': 0})
continue
quads = SL.cluster_quadrats(members, layout=layout)
ro = SL.assemble_reading_order(
quads, layout=layout, direction=direction,
single_line=True, extent=dets[ci].bbox,
)
slots = self._canon_slots(ro.slots)
cart_slots_all.append(slots)
cart_results.append(
CM.match_cartouche(slots, self.royal_names, sub_cost=self.sub_cost)
)
cart_meta.append({'bbox': dets[ci].bbox, 'n_members': len(members)})
cart_final = CM.apply_panel_consensus(
cart_results, cart_slots_all, self.royal_names,
sub_cost=self.sub_cost,
)
result = {
'layout' : layout,
'direction' : direction,
'image_shape' : (H, W),
'n_detections' : len(dets),
'n_cartouches' : len(cart_idxs),
'outer': {
'slots' : canon_outer,
'boundary_hints' : outer_ro.boundary_hints,
'n_synthetic' : outer_ro.n_synthetic,
'correction' : correction.to_dict(),
},
'cartouches': [
{
'bbox' : meta['bbox'],
'n_members' : meta['n_members'],
'slots' : slots,
'inferred' : inferred,
'translit' : m.translit if m else None,
'english' : m.english if m else None,
'spelling' : m.spelling if m else None,
'score' : m.score if m else None,
'aligned_codes' : m.aligned_codes if m else None,
'verified' : m.verified if m else None,
}
for (m, inferred), slots, meta in zip(
cart_final, cart_slots_all, cart_meta)
],
}
if annotate:
# ndarray, not JSON-serializable — API layer encodes to JPEG.
result['annotated_bgr'] = _draw_detections(bgr, dets)
return result
@staticmethod
def _canon_slots(slots):
return [[(CM.normalize_code(c), float(s)) for c, s in slot]
for slot in slots]
# Thin CLI — prints a run summary; all tests live in tests/ (pytest)
def _summarize(result: dict) -> None:
print(f"layout={result['layout']} direction={result['direction']} "
f"shape={result['image_shape']}")
print(f"detections={result['n_detections']} "
f"cartouches={result['n_cartouches']}")
o = result['outer']
corr = o['correction']
print(f"\n--- OUTER TEXT ---")
print(f" slots={len(o['slots'])} synthetic={o['n_synthetic']} "
f"boundary_hints={o['boundary_hints']}")
print(f" corrected: {' '.join(corr.get('flat_corrected_seq', []))}")
print(f" translit : {corr.get('flat_translit', '')}")
print(f" score : {corr.get('score', 0):.2f} "
f"fallback={corr.get('had_fallback')}")
print(f"\n--- CARTOUCHES ---")
for k, c in enumerate(result['cartouches']):
tag = (f"{c['translit']} ({'inferred' if c['inferred'] else 'direct'}, "
f"score={c['score']:.2f})") if c['translit'] else "REFUSED"
print(f" #{k} members={c['n_members']:<2} -> {tag}")
def main():
"""
Local-dev smoke CLI (ONNX backend — the Space itself never runs this).
python src/pipeline.py <image> [rows|columns]
Point SPHINX_ONNX at a v9 .onnx; defaults to the parent repo's copy so the
extraction can be verified without installing torch.
"""
import os
# No default image: test_image_vn5.png is CONTAMINATED (CLAUDE.md
# fragile breakpoint #8) — always require an explicit path.
if len(sys.argv) < 2:
sys.exit('usage: python pipeline.py <image> [rows|columns] '
'(e.g. Unas1c.jpg, image_2_test.jpg)')
img = sys.argv[1]
layout = sys.argv[2] if len(sys.argv) > 2 else 'columns'
if not Path(img).exists():
sys.exit(f'image not found: {img}')
onnx = Path(os.getenv(
'SPHINX_ONNX', ROOT.parents[1] / 'artifacts' / 'best_model_v9.onnx'))
print(f"Loading pipeline (onnx={onnx}) ...")
p = SphinxPipeline(onnx_path=onnx)
print(f"Running on {img} (layout={layout}) ...")
result = p.run(img, layout=layout)
_summarize(result)
if __name__ == '__main__':
main()
|