Files
timing/backend/ocr.py
T
steinhelge 45f7a77171
Build & Deploy / build-and-deploy (push) Successful in 46s
Støtte for flere bibs per bilde, EXIF-metadata og zoom i gjennomgang
- OCR: ny read_all_bibs() returnerer alle unike startnumre (≥2 sifre) per bilde
- Ingest: oppretter én passering per bib (ikke bare beste), ingen bib → needs_review
- image_tagger.py: skriv/les bib-metadata som JSON i EXIF UserComment (piexif)
- Ingest + resolve: tagger bildefilen med bibs automatisk og ved manuell bekreftelse
- API: POST /api/passages/{id}/reanalyze — re-kjør OCR på eksisterende bilde
- API: POST /api/passages/{id}/resolve oppdaterer nå EXIF med bekreftet bib
- races: ny kolonne bib_filter_enabled (med automatisk migrering) + per-løp toggle
- ReviewPage: Re-analyser-knapp og klikk-for-zoom med scroll/drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:01:51 +01:00

135 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.01.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 read_all_bibs(image_path: Path) -> list["OcrResult"]:
"""
Les alle startnumre fra bildet.
Returnerer én OcrResult per unikt startnummer funnet (minst 2 sifre).
Tom liste hvis ingen funnet eller 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]
# Best konfidens + areal per unikt siffersekvens (minst 2 sifre)
best: dict[str, tuple[float, float]] = {} # digits -> (conf, area)
for (bbox, text, conf) in results:
digits = re.sub(r"[^0-9]", "", text)
if len(digits) < 2:
continue
area = _bbox_area(bbox)
if digits not in best or float(conf) > best[digits][0]:
best[digits] = (float(conf), area)
return [
OcrResult(
digits=digits,
confidence=conf,
partial=False,
proximity_score=area,
raw_texts=raw_texts,
)
for digits, (conf, area) in best.items()
]
except Exception as e:
return [OcrResult(digits=None, confidence=0.0, partial=False, raw_texts=[f"ERROR: {e}"])]
def read_bib(image_path: Path) -> OcrResult:
"""
Les beste startnummer fra bildet (bakoverkompatibel).
Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil.
"""
bibs = read_all_bibs(image_path)
if not bibs or bibs[0].digits is None:
# Sjekk også enkle sifre (partial) for bakoverkompatibilitet
try:
processed = _preprocess(image_path)
reader = _get_reader()
results = reader.readtext(processed, detail=1, paragraph=False)
raw_texts = [text for (_, text, _) in results]
candidates = []
for (bbox, text, conf) in results:
digits = re.sub(r"[^0-9]", "", text)
if digits:
candidates.append((digits, float(conf), _bbox_area(bbox)))
if candidates:
best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1])
return OcrResult(
digits=best_digits,
confidence=best_conf,
partial=len(best_digits) < 2,
proximity_score=best_area,
raw_texts=raw_texts,
)
except Exception:
pass
return OcrResult(digits=None, confidence=0.0, partial=False)
return max(bibs, key=lambda r: r.confidence)