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:
parent
1bafb74058
commit
faff07f252
3 changed files with 1251 additions and 0 deletions
234
recognization/code/ocr.py
Normal file
234
recognization/code/ocr.py
Normal 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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue