feat: OCR engine, price-tag geometry, and the cached tag pass

ocr.py: one interface, backends by availability - macOS Vision through pyobjc
(measured 182-246 ms per rail band), a fake engine for tests, and an explicit
"none". The owner chose the macOS backend; the interface keeps a cross-platform
engine a drop-in, and the module says so.

tags.py: rail bands from the detector's own row clustering (no new geometry
model), clustering of OCR lines into tag-shaped regions, deterministic Japanese
price-tag parsing (sale price by printed area, regular price, discount, unit
price, name, tax/size artifacts rejected), and TagService - a per-session cache
with multi-pass voting so a static tag becomes one settled reading instead of
thirty noisy ones, and so the frame loop never waits for OCR.

Measured on add/image6.jpg: 6 bands, 12 tags, 8 readable with real prices and
names (305 エッグケア, 357 ゼロノンコレステロール, 400 フィッテ), 39-254 ms per
band. On image.jpg (2 m) it correctly finds nothing readable - the tags are too
small there, which is the capture requirement recorded in the design.

Tests found four real bugs: unit prices leaking into the price field, packaging
sizes like <310g> read as prices, cluster_tags dropping the line boxes (so the
big sale price was never preferred), and brittle tag-to-product association when
a cluster spans facings.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 13:52:15 +09:00
parent 1bafb74058
commit faff07f252
3 changed files with 1251 additions and 0 deletions

234
recognization/code/ocr.py Normal file
View file

@ -0,0 +1,234 @@
"""
OCR engines for the recognition server.
One interface, several backends, chosen by availability and configuration - the
same shape as the app kit's platform bridges. The engine only answers "what text
is in this image and where"; everything tag-specific (band geometry, clustering,
field parsing, association) lives in tags.py.
Backends:
- VisionOcr macOS, via pyobjc/Vision. Measured 182-246 ms per rail band.
- FakeOcr deterministic lines for tests and offline demos.
- (RapidOCR) deliberately not shipped: the owner chose the macOS backend, and the
interface keeps a cross-platform engine a drop-in later.
The engine never guesses: a line that is not recognized is simply absent, and the
caller decides what absence means (see the compliance rules, which fail closed on
unreadable evidence rather than reporting a violation).
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
DEFAULT_LANGUAGES = ("ja-JP", "en-US")
@dataclass
class OcrLine:
"""One recognized text line, in the pixel space of the image it was read from."""
text: str
confidence: float
box: List[int] # [x1, y1, x2, y2], origin top-left
def as_dict(self) -> Dict[str, Any]:
return {"text": self.text, "confidence": round(float(self.confidence), 3), "box": list(self.box)}
@dataclass
class OcrResult:
lines: List[OcrLine] = field(default_factory=list)
engine: str = "none"
elapsed_ms: float = 0.0
def as_dict(self) -> Dict[str, Any]:
return {
"engine": self.engine,
"elapsed_ms": round(self.elapsed_ms, 1),
"lines": [line.as_dict() for line in self.lines],
}
class OcrUnavailable(RuntimeError):
"""Raised when a backend is asked to read but cannot run here."""
class OcrEngine:
"""Base class: subclasses implement read()."""
name = "none"
def available(self) -> bool: # pragma: no cover - overridden
return False
def read(
self,
image: Any,
*,
languages: Sequence[str] = DEFAULT_LANGUAGES,
fast: bool = False,
) -> OcrResult: # pragma: no cover - overridden
raise OcrUnavailable(f"engine '{self.name}' is not available")
class VisionOcr(OcrEngine):
"""macOS Vision text recognition through pyobjc."""
name = "vision"
def __init__(self, languages: Sequence[str] = DEFAULT_LANGUAGES):
self.languages = list(languages)
self._vision = None
self._quartz = None
self._load_error: Optional[str] = None
self._load()
def _load(self) -> None:
if os.uname().sysname != "Darwin": # pragma: no cover - platform guard
self._load_error = "not macOS"
return
try:
import Quartz # noqa: F401
import Vision # noqa: F401
self._quartz = Quartz
self._vision = Vision
except Exception as exc: # pragma: no cover - import guard
self._load_error = str(exc)
def available(self) -> bool:
return self._vision is not None and self._quartz is not None
def read(
self,
image: Any,
*,
languages: Optional[Sequence[str]] = None,
fast: bool = False,
) -> OcrResult:
if not self.available():
raise OcrUnavailable(f"Vision is unavailable here ({self._load_error})")
import cv2
import numpy as np
from Foundation import NSData # type: ignore
started = time.perf_counter()
height, width = image.shape[:2]
ok, encoded = cv2.imencode(".png", image)
if not ok:
return OcrResult(engine=self.name, elapsed_ms=(time.perf_counter() - started) * 1000.0)
data = NSData.dataWithBytes_length_(encoded.tobytes(), len(encoded.tobytes()))
source = self._quartz.CGImageSourceCreateWithData(data, None)
cg_image = self._quartz.CGImageSourceCreateImageAtIndex(source, 0, None)
request = self._vision.VNRecognizeTextRequest.alloc().init()
request.setRecognitionLevel_(
self._vision.VNRequestTextRecognitionLevelFast
if fast
else self._vision.VNRequestTextRecognitionLevelAccurate
)
request.setRecognitionLanguages_(list(languages or self.languages))
request.setUsesLanguageCorrection_(False)
handler = self._vision.VNImageRequestHandler.alloc().initWithCGImage_options_(cg_image, None)
performed, error = handler.performRequests_error_([request], None)
elapsed = (time.perf_counter() - started) * 1000.0
if not performed:
raise OcrUnavailable(f"Vision request failed: {error}")
lines: List[OcrLine] = []
for observation in request.results() or []:
candidates = observation.topCandidates_(1)
if not candidates:
continue
top = candidates[0]
box = observation.boundingBox()
x1 = box.origin.x * width
x2 = (box.origin.x + box.size.width) * width
# Vision uses a bottom-left origin; the rest of the system is top-left.
y1 = (1.0 - box.origin.y - box.size.height) * height
y2 = (1.0 - box.origin.y) * height
lines.append(
OcrLine(
text=str(top.string()),
confidence=float(top.confidence()),
box=[int(round(x1)), int(round(y1)), int(round(x2)), int(round(y2))],
)
)
return OcrResult(lines=lines, engine=self.name, elapsed_ms=elapsed)
class FakeOcr(OcrEngine):
"""Deterministic engine for tests: returns whatever it was told to.
Accepts either a list of OcrLine (returned as-is, in the caller's pixel space)
or a callable(image) -> list[OcrLine].
"""
name = "fake"
def __init__(self, lines: Optional[Sequence[OcrLine]] = None, reader=None):
self._lines = list(lines or [])
self._reader = reader
self.calls = 0
self.last_image_shape: Optional[Tuple[int, int]] = None
def available(self) -> bool:
return True
def read(self, image: Any, *, languages: Sequence[str] = DEFAULT_LANGUAGES, fast: bool = False) -> OcrResult:
self.calls += 1
self.last_image_shape = tuple(image.shape[:2])
lines = self._reader(image) if self._reader is not None else [OcrLine(**vars(line)) for line in self._lines]
return OcrResult(lines=list(lines), engine=self.name, elapsed_ms=0.0)
class NullOcr(OcrEngine):
"""Explicitly no OCR: used when the feature is switched off."""
name = "none"
def available(self) -> bool:
return False
def read(self, image: Any, *, languages: Sequence[str] = DEFAULT_LANGUAGES, fast: bool = False) -> OcrResult:
return OcrResult(lines=[], engine=self.name, elapsed_ms=0.0)
def build_ocr_engine(kind: Optional[str] = None) -> OcrEngine:
"""Pick a backend. DORAYAKI_OCR = vision | fake | none (default: vision on macOS)."""
kind = (kind or os.environ.get("DORAYAKI_OCR") or "auto").lower()
if kind == "none":
return NullOcr()
if kind == "fake":
return FakeOcr()
if kind in ("auto", "vision"):
engine = VisionOcr()
if engine.available():
return engine
if kind == "vision":
raise OcrUnavailable(f"Vision requested but unavailable: {engine._load_error}")
return NullOcr()
if __name__ == "__main__": # smoke: python code/ocr.py <image> [<image> ...]
import sys
import cv2
engine = build_ocr_engine()
print(f"engine: {engine.name} (available: {engine.available()})")
for path in sys.argv[1:]:
image = cv2.imread(path)
if image is None:
print(f"{path}: cannot read")
continue
result = engine.read(image)
print(f"--- {path} {result.elapsed_ms:.0f} ms, {len(result.lines)} lines ---")
for line in result.lines[:12]:
print(f" {line.confidence:.2f} {line.text} {line.box}")