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:
2026-03-20 15:01:33 +01:00
commit 330ba7a93d
35 changed files with 5038 additions and 0 deletions
+108
View File
@@ -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.01.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: 12 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}"],
)