Initial commit: MVP tidtakingssystem
- 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>
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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}"],
|
||||
)
|
||||
Reference in New Issue
Block a user