24645dfd11
Build & Deploy / build-and-deploy (push) Has been cancelled
Within a burst sequence from the same station, the image where the athlete is physically closest to the camera gives the most accurate passage timestamp. Proximity is measured by bib bounding box area (larger = closer). When a duplicate is detected: - New image closer: update timestamp + image path, delete old image - Existing image closer: discard new image Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
117 lines
3.5 KiB
Python
117 lines
3.5 KiB
Python
"""
|
||
Startnummer-deteksjon og OCR.
|
||
Bruker EasyOCR for å lese sifre fra bib-nummerlapp.
|
||
Returnerer en OcrResult med tall, konfidens og partial-flagg.
|
||
"""
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import cv2
|
||
import numpy as np
|
||
|
||
# EasyOCR lastes lazy for å unngå lang oppstartstid
|
||
_reader = None
|
||
|
||
|
||
def _get_reader():
|
||
global _reader
|
||
if _reader is None:
|
||
import easyocr
|
||
_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
|
||
return _reader
|
||
|
||
|
||
@dataclass
|
||
class OcrResult:
|
||
digits: Optional[str] # Gjenkjente sifre, f.eks. "42", None hvis ingen
|
||
confidence: float # 0.0–1.0
|
||
partial: bool # True hvis nummeret trolig er delvis skjult
|
||
proximity_score: float = 0.0 # Areal av detektert bib-boks (px²) — større = nærmere kamera
|
||
raw_texts: list[str] = field(default_factory=list) # Alle OCR-treff for debug
|
||
|
||
|
||
def _preprocess(image_path: Path) -> np.ndarray:
|
||
"""Forbered bilde for OCR: gråskala, kontrast, skarphet."""
|
||
img = cv2.imread(str(image_path))
|
||
if img is None:
|
||
raise ValueError(f"Kunne ikke lese bilde: {image_path}")
|
||
|
||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||
|
||
# CLAHE for bedre kontrast i varierende lys
|
||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||
enhanced = clahe.apply(gray)
|
||
|
||
# Skaler opp hvis bildet er lite (OCR er bedre på større tekst)
|
||
h, w = enhanced.shape
|
||
if max(h, w) < 800:
|
||
scale = 800 / max(h, w)
|
||
enhanced = cv2.resize(
|
||
enhanced, (int(w * scale), int(h * scale)),
|
||
interpolation=cv2.INTER_CUBIC,
|
||
)
|
||
|
||
return enhanced
|
||
|
||
|
||
def _bbox_area(bbox) -> float:
|
||
"""Beregn areal av EasyOCR bounding box [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]."""
|
||
xs = [p[0] for p in bbox]
|
||
ys = [p[1] for p in bbox]
|
||
return (max(xs) - min(xs)) * (max(ys) - min(ys))
|
||
|
||
|
||
def _extract_bib_number(texts: list[tuple]) -> tuple[Optional[str], float, bool, float]:
|
||
"""
|
||
Finn beste siffersekvens blant OCR-treff.
|
||
Returnerer (sifre, konfidens, partial, proximity_score).
|
||
proximity_score = areal av bounding box i piksler² (større = nærmere kamera).
|
||
"""
|
||
candidates = []
|
||
for (bbox, text, conf) in texts:
|
||
digits = re.sub(r"[^0-9]", "", text)
|
||
if digits:
|
||
candidates.append((digits, float(conf), _bbox_area(bbox)))
|
||
|
||
if not candidates:
|
||
return None, 0.0, False, 0.0
|
||
|
||
# Velg kandidat med høyest konfidens
|
||
best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1])
|
||
|
||
partial = len(best_digits) < 2
|
||
|
||
return best_digits, best_conf, partial, best_area
|
||
|
||
|
||
def read_bib(image_path: Path) -> OcrResult:
|
||
"""
|
||
Les startnummer fra bildet.
|
||
Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil.
|
||
"""
|
||
try:
|
||
processed = _preprocess(image_path)
|
||
reader = _get_reader()
|
||
results = reader.readtext(processed, detail=1, paragraph=False)
|
||
|
||
raw_texts = [text for (_, text, _) in results]
|
||
digits, confidence, partial, proximity_score = _extract_bib_number(results)
|
||
|
||
return OcrResult(
|
||
digits=digits,
|
||
confidence=confidence,
|
||
partial=partial,
|
||
proximity_score=proximity_score,
|
||
raw_texts=raw_texts,
|
||
)
|
||
except Exception as e:
|
||
return OcrResult(
|
||
digits=None,
|
||
confidence=0.0,
|
||
partial=False,
|
||
raw_texts=[f"ERROR: {e}"],
|
||
)
|