330ba7a93d
- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite - Frontend: React admin (startliste, passeringer, gjennomgang, resultater) - Docker: docker-compose med depot/processed/data-volumer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.0 KiB
Python
109 lines
3.0 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
|
||
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 _extract_bib_number(texts: list[tuple]) -> tuple[Optional[str], float, bool]:
|
||
"""
|
||
Finn beste siffersekvens blant OCR-treff.
|
||
Returnerer (sifre, konfidens, partial).
|
||
"""
|
||
candidates = []
|
||
for (_, text, conf) in texts:
|
||
# Behold kun sifre
|
||
digits = re.sub(r"[^0-9]", "", text)
|
||
if digits:
|
||
candidates.append((digits, float(conf)))
|
||
|
||
if not candidates:
|
||
return None, 0.0, False
|
||
|
||
# Velg kandidat med høyest konfidens
|
||
best_digits, best_conf = max(candidates, key=lambda x: x[1])
|
||
|
||
# Heuristikk: 1–2 sifre kan tyde på delvis synlig nummer
|
||
partial = len(best_digits) < 2
|
||
|
||
return best_digits, best_conf, partial
|
||
|
||
|
||
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 = _extract_bib_number(results)
|
||
|
||
return OcrResult(
|
||
digits=digits,
|
||
confidence=confidence,
|
||
partial=partial,
|
||
raw_texts=raw_texts,
|
||
)
|
||
except Exception as e:
|
||
return OcrResult(
|
||
digits=None,
|
||
confidence=0.0,
|
||
partial=False,
|
||
raw_texts=[f"ERROR: {e}"],
|
||
)
|