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
+20
View File
@@ -0,0 +1,20 @@
# Data og bilder
data/
depot/
processed/
# Python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# Node
frontend/node_modules/
frontend/dist/
# IDE
.idea/
.vscode/
*.swp
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
WORKDIR /app
# Systemavhengigheter for OpenCV og EasyOCR
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgl1 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Kataloger monteres som volumer fra docker-compose
RUN mkdir -p /depot /processed /data
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
+177
View File
@@ -0,0 +1,177 @@
"""
FastAPI REST API for timing-systemet.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
import aiosqlite
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from ingest import watch_depot, process_existing
from passage_log import delete_passage, get_passages, log_passage, resolve_passage
from profile_db import (
clear_startlist,
delete_athlete,
get_db,
import_startlist_csv,
list_athletes,
upsert_athlete,
)
from results import get_results
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Behandle bilder som lå i depot fra før oppstart
await process_existing()
# Start fil-overvåkning i bakgrunnen
asyncio.create_task(watch_depot())
yield
app = FastAPI(title="Timing API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- DB-avhengighet ---
async def get_connection():
db = await get_db()
try:
yield db
finally:
await db.close()
# =====================
# Startliste-endepunkter
# =====================
@app.get("/api/athletes")
async def list_athletes_endpoint(db=Depends(get_connection)):
return await list_athletes(db)
@app.post("/api/athletes/import", summary="Last opp startliste som CSV")
async def import_csv(
file: UploadFile = File(...),
db=Depends(get_connection),
):
if not file.filename.endswith(".csv"):
raise HTTPException(400, "Kun CSV-filer støttes")
content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM
result = await import_startlist_csv(db, content)
return result
@app.delete("/api/athletes/all")
async def clear_all_athletes(db=Depends(get_connection)):
await clear_startlist(db)
return {"ok": True}
@app.delete("/api/athletes/{profile_id}")
async def remove_athlete(profile_id: str, db=Depends(get_connection)):
ok = await delete_athlete(db, profile_id)
if not ok:
raise HTTPException(404, "Utøver ikke funnet")
return {"ok": True}
# =====================
# Passeringer
# =====================
@app.get("/api/passages")
async def list_passages(
station: Optional[str] = None,
needs_review: Optional[bool] = None,
profile_id: Optional[str] = None,
db=Depends(get_connection),
):
return await get_passages(db, profile_id=profile_id, station=station, needs_review=needs_review)
@app.get("/api/passages/review", summary="Hent passeringer som trenger manuell gjennomgang")
async def review_queue(db=Depends(get_connection)):
return await get_passages(db, needs_review=True)
class ResolveRequest(BaseModel):
profile_id: Optional[str] = None
bib_number: Optional[str] = None
review_note: Optional[str] = None
@app.post("/api/passages/{passage_id}/resolve")
async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)):
ok = await resolve_passage(
db,
passage_id,
profile_id=body.profile_id,
bib_number=body.bib_number,
review_note=body.review_note,
)
if not ok:
raise HTTPException(404, "Passering ikke funnet")
return {"ok": True}
@app.delete("/api/passages/{passage_id}")
async def remove_passage(passage_id: str, db=Depends(get_connection)):
ok = await delete_passage(db, passage_id)
if not ok:
raise HTTPException(404, "Passering ikke funnet")
return {"ok": True}
# =====================
# Resultater
# =====================
@app.get("/api/results")
async def get_results_endpoint(db=Depends(get_connection)):
return await get_results(db)
# =====================
# Bilder
# =====================
@app.get("/api/images/{path:path}")
async def serve_image(path: str):
"""Lever behandlede bilder til frontend."""
full_path = Path("/processed") / path
if not full_path.exists() or not full_path.is_file():
raise HTTPException(404, "Bilde ikke funnet")
# Sjekk at stien er innenfor /processed (path traversal)
try:
full_path.resolve().relative_to(Path("/processed").resolve())
except ValueError:
raise HTTPException(403, "Forbudt")
return FileResponse(full_path)
# =====================
# Helse
# =====================
@app.get("/api/health")
async def health():
return {"status": "ok"}
+156
View File
@@ -0,0 +1,156 @@
"""
Parse og normaliser EXIF-metadata fra bilder.
Returnerer et strukturert dict eller kaster ExifError ved manglende/ugyldig data.
"""
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import piexif
from PIL import Image
class ExifError(ValueError):
pass
@dataclass
class ImageMetadata:
timestamp_utc: datetime
gps_lat: float
gps_lon: float
gps_alt: Optional[float]
station: Optional[str] # fra ImageDescription eller CameraLabel
def _rational_to_float(rational) -> float:
"""Konverter EXIF rational (teller, nevner) til float."""
if isinstance(rational, tuple):
num, den = rational
if den == 0:
return 0.0
return num / den
return float(rational)
def _parse_gps_coord(dms_rationals, ref: str) -> float:
"""Konverter GPS DMS rationals + referanse til desimalgrader."""
deg = _rational_to_float(dms_rationals[0])
min_ = _rational_to_float(dms_rationals[1])
sec = _rational_to_float(dms_rationals[2])
value = deg + min_ / 60.0 + sec / 3600.0
if ref in ("S", "W"):
value = -value
return value
def _parse_timestamp(exif_dict: dict) -> datetime:
"""
Hent tidsstempel fra DateTimeOriginal + SubSecTimeOriginal.
Returnerer datetime i UTC. Kaster ExifError hvis manglende.
"""
ifd0 = exif_dict.get("Exif", {})
raw_dt = ifd0.get(piexif.ExifIFD.DateTimeOriginal)
if not raw_dt:
raise ExifError("Mangler DateTimeOriginal i EXIF")
if isinstance(raw_dt, bytes):
raw_dt = raw_dt.decode("ascii", errors="replace")
# Format: "2024:06:15 10:23:45"
try:
dt = datetime.strptime(raw_dt.strip(), "%Y:%m:%d %H:%M:%S")
except ValueError as e:
raise ExifError(f"Ugyldig DateTimeOriginal-format: {raw_dt!r}") from e
# Sub-sekunder
raw_sub = ifd0.get(piexif.ExifIFD.SubSecTimeOriginal)
microseconds = 0
if raw_sub:
if isinstance(raw_sub, bytes):
raw_sub = raw_sub.decode("ascii", errors="replace")
sub_str = raw_sub.strip()
if sub_str.isdigit():
# Pad/truncate til 6 siffer (mikrosekunder)
sub_str = (sub_str + "000000")[:6]
microseconds = int(sub_str)
dt = dt.replace(microsecond=microseconds, tzinfo=timezone.utc)
return dt
def _parse_gps(exif_dict: dict) -> tuple[float, float, Optional[float]]:
"""
Hent GPS-koordinater. Kaster ExifError hvis lat/lon mangler.
"""
gps = exif_dict.get("GPS", {})
lat_dms = gps.get(piexif.GPSIFD.GPSLatitude)
lat_ref = gps.get(piexif.GPSIFD.GPSLatitudeRef)
lon_dms = gps.get(piexif.GPSIFD.GPSLongitude)
lon_ref = gps.get(piexif.GPSIFD.GPSLongitudeRef)
if not (lat_dms and lat_ref and lon_dms and lon_ref):
raise ExifError("Mangler GPS-koordinater (lat/lon) i EXIF")
if isinstance(lat_ref, bytes):
lat_ref = lat_ref.decode("ascii")
if isinstance(lon_ref, bytes):
lon_ref = lon_ref.decode("ascii")
lat = _parse_gps_coord(lat_dms, lat_ref)
lon = _parse_gps_coord(lon_dms, lon_ref)
alt = None
alt_raw = gps.get(piexif.GPSIFD.GPSAltitude)
if alt_raw:
alt = _rational_to_float(alt_raw)
alt_ref = gps.get(piexif.GPSIFD.GPSAltitudeRef, 0)
if alt_ref == 1:
alt = -alt
return lat, lon, alt
def _parse_station(exif_dict: dict) -> Optional[str]:
"""Hent stasjonsnavn fra ImageDescription."""
ifd0 = exif_dict.get("0th", {})
desc = ifd0.get(piexif.ImageIFD.ImageDescription)
if desc:
if isinstance(desc, bytes):
desc = desc.decode("utf-8", errors="replace")
return desc.strip() or None
return None
def parse_image(path: Path) -> ImageMetadata:
"""
Les EXIF fra bildefil og returner ImageMetadata.
Kaster ExifError hvis påkrevde felt mangler.
"""
try:
img = Image.open(path)
raw_exif = img.info.get("exif")
if not raw_exif:
raise ExifError("Bildet har ingen EXIF-data")
exif_dict = piexif.load(raw_exif)
except ExifError:
raise
except Exception as e:
raise ExifError(f"Kunne ikke lese EXIF: {e}") from e
timestamp = _parse_timestamp(exif_dict)
lat, lon, alt = _parse_gps(exif_dict)
station = _parse_station(exif_dict)
return ImageMetadata(
timestamp_utc=timestamp,
gps_lat=lat,
gps_lon=lon,
gps_alt=alt,
station=station,
)
+170
View File
@@ -0,0 +1,170 @@
"""
Bildehåndtering:
- Overvåk depot/-katalogen for nye bilder
- Valider EXIF
- Kjør OCR
- Flytt til processed/ med unikt filnavn
- Logg passering til DB
Kan kjøres som egen prosess (python ingest.py) eller importeres av API.
"""
import asyncio
import logging
import shutil
import uuid
from pathlib import Path
import aiosqlite
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
from watchdog.observers import Observer
from exif_parser import ExifError, parse_image
from ocr import read_bib
from passage_log import log_passage
from profile_db import get_athlete_by_bib, init_db
logger = logging.getLogger(__name__)
DEPOT_DIR = Path("/depot")
PROCESSED_DIR = Path("/processed")
REJECTED_DIR = DEPOT_DIR / "rejected"
DB_PATH = "/data/timing.db"
# Konfidens-terskel for automatisk logging
MIN_AUTO_CONFIDENCE = 0.75
VALID_SUFFIXES = {".jpg", ".jpeg", ".png"}
def _destination_path(source: Path, timestamp) -> Path:
"""
Bygg destinasjonssti: processed/<år>/<måned>/<uuid>_<originalfilnavn>
"""
year = timestamp.strftime("%Y")
month = timestamp.strftime("%m")
unique_name = f"{uuid.uuid4().hex}_{source.name}"
dest = PROCESSED_DIR / year / month / unique_name
dest.parent.mkdir(parents=True, exist_ok=True)
return dest
async def process_image(path: Path) -> None:
"""
Behandle ett bilde: valider EXIF, kjør OCR, flytt fil, logg passering.
"""
if path.suffix.lower() not in VALID_SUFFIXES:
logger.debug("Ignorerer ikke-bilde: %s", path)
return
logger.info("Behandler: %s", path.name)
# --- EXIF-validering ---
try:
meta = parse_image(path)
except ExifError as e:
logger.warning("Ugyldig EXIF i %s: %s — avviser", path.name, e)
REJECTED_DIR.mkdir(parents=True, exist_ok=True)
shutil.move(str(path), str(REJECTED_DIR / path.name))
return
# --- OCR ---
ocr = read_bib(path)
logger.debug("OCR: digits=%s conf=%.2f", ocr.digits, ocr.confidence)
# --- Flytt til processed/ ---
dest = _destination_path(path, meta.timestamp_utc)
shutil.move(str(path), str(dest))
logger.info("Flyttet til: %s", dest)
# --- Bestem konfidens og review-flagg ---
confidence = ocr.confidence
needs_review = False
review_note = None
id_method = "bib_ocr"
if ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE:
needs_review = True
review_note = "number_unreadable" if ocr.digits is None else "low_confidence"
id_method = "bib_ocr_uncertain"
# --- Koble mot profil-DB ---
profile_id = None
bib_number = ocr.digits
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await init_db(db)
if bib_number and not needs_review:
athlete = await get_athlete_by_bib(db, bib_number)
if athlete:
profile_id = athlete["profile_id"]
else:
logger.debug("Ukjent startnummer: %s", bib_number)
await log_passage(
db,
profile_id=profile_id,
bib_number=bib_number,
station=meta.station or "unknown",
timestamp_utc=meta.timestamp_utc,
gps_lat=meta.gps_lat,
gps_lon=meta.gps_lon,
gps_alt=meta.gps_alt,
confidence=confidence,
id_method=id_method,
source_image=str(dest),
needs_review=needs_review,
review_note=review_note,
)
logger.info(
"Passering logget: bib=%s station=%s needs_review=%s",
bib_number, meta.station, needs_review,
)
async def process_existing() -> None:
"""Behandle bilder som allerede ligger i depot/ ved oppstart."""
for path in sorted(DEPOT_DIR.glob("*")):
if path.is_file() and path.suffix.lower() in VALID_SUFFIXES:
await process_image(path)
class DepotHandler(FileSystemEventHandler):
"""Watchdog-handler: kaller process_image ved nye filer."""
def __init__(self, loop: asyncio.AbstractEventLoop):
self._loop = loop
def on_created(self, event: FileCreatedEvent):
if not event.is_directory:
path = Path(event.src_path)
asyncio.run_coroutine_threadsafe(process_image(path), self._loop)
async def watch_depot() -> None:
"""Start filsystem-overvåkning av depot/."""
loop = asyncio.get_running_loop()
handler = DepotHandler(loop)
observer = Observer()
observer.schedule(handler, str(DEPOT_DIR), recursive=False)
observer.start()
logger.info("Overvåker depot: %s", DEPOT_DIR)
try:
while True:
await asyncio.sleep(1)
finally:
observer.stop()
observer.join()
async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
await process_existing()
await watch_depot()
if __name__ == "__main__":
asyncio.run(main())
+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}"],
)
+119
View File
@@ -0,0 +1,119 @@
"""
Skriv og query passeringslogg i SQLite.
"""
import uuid
from datetime import datetime
from typing import Optional
import aiosqlite
async def log_passage(
db: aiosqlite.Connection,
*,
profile_id: Optional[str],
bib_number: Optional[str],
station: str,
timestamp_utc: datetime,
gps_lat: float,
gps_lon: float,
gps_alt: Optional[float],
confidence: float,
id_method: str,
source_image: str,
needs_review: bool = False,
review_note: Optional[str] = None,
) -> str:
"""Logg én passering. Returnerer passage_id."""
passage_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO passages (
passage_id, profile_id, bib_number, station,
timestamp_utc, gps_lat, gps_lon, gps_alt,
confidence, id_method, source_image,
needs_review, review_note
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
passage_id,
profile_id,
bib_number,
station,
timestamp_utc.isoformat(),
gps_lat,
gps_lon,
gps_alt,
confidence,
id_method,
source_image,
int(needs_review),
review_note,
),
)
await db.commit()
return passage_id
async def get_passages(
db: aiosqlite.Connection,
profile_id: Optional[str] = None,
station: Optional[str] = None,
needs_review: Optional[bool] = None,
) -> list[dict]:
"""Hent passeringer med valgfrie filtre."""
clauses = []
params = []
if profile_id is not None:
clauses.append("p.profile_id = ?")
params.append(profile_id)
if station is not None:
clauses.append("p.station = ?")
params.append(station)
if needs_review is not None:
clauses.append("p.needs_review = ?")
params.append(int(needs_review))
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
query = f"""
SELECT p.*, a.name, a.club
FROM passages p
LEFT JOIN athletes a ON a.profile_id = p.profile_id
{where}
ORDER BY p.timestamp_utc
"""
async with db.execute(query, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def resolve_passage(
db: aiosqlite.Connection,
passage_id: str,
profile_id: Optional[str],
bib_number: Optional[str],
review_note: Optional[str] = None,
) -> bool:
"""Manuell oppdatering av en passering etter gjennomgang."""
cur = await db.execute(
"""
UPDATE passages
SET profile_id = ?, bib_number = ?, needs_review = 0,
review_note = ?, id_method = 'manual'
WHERE passage_id = ?
""",
(profile_id, bib_number, review_note, passage_id),
)
await db.commit()
return cur.rowcount > 0
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
cur = await db.execute(
"DELETE FROM passages WHERE passage_id = ?", (passage_id,)
)
await db.commit()
return cur.rowcount > 0
+151
View File
@@ -0,0 +1,151 @@
"""
CRUD for utøverprofiler og startliste.
Bruker aiosqlite for async tilgang fra FastAPI.
"""
import csv
import io
import uuid
from typing import Optional
import aiosqlite
DB_PATH = "/data/timing.db"
async def init_db(db: aiosqlite.Connection) -> None:
await db.executescript("""
CREATE TABLE IF NOT EXISTS athletes (
profile_id TEXT PRIMARY KEY,
bib_number TEXT UNIQUE,
name TEXT,
club TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS passages (
passage_id TEXT PRIMARY KEY,
profile_id TEXT REFERENCES athletes(profile_id),
bib_number TEXT,
station TEXT NOT NULL,
timestamp_utc TEXT NOT NULL,
gps_lat REAL,
gps_lon REAL,
gps_alt REAL,
confidence REAL,
id_method TEXT,
source_image TEXT,
needs_review INTEGER NOT NULL DEFAULT 0,
review_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station);
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review);
""")
await db.commit()
async def get_db() -> aiosqlite.Connection:
db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row
await init_db(db)
return db
# --- Utøverprofiler ---
async def upsert_athlete(
db: aiosqlite.Connection,
bib_number: str,
name: str,
club: Optional[str] = None,
) -> str:
"""Opprett eller oppdater utøver basert på startnummer. Returnerer profile_id."""
async with db.execute(
"SELECT profile_id FROM athletes WHERE bib_number = ?", (bib_number,)
) as cur:
row = await cur.fetchone()
if row:
profile_id = row["profile_id"]
await db.execute(
"UPDATE athletes SET name = ?, club = ? WHERE profile_id = ?",
(name, club, profile_id),
)
else:
profile_id = str(uuid.uuid4())
await db.execute(
"INSERT INTO athletes (profile_id, bib_number, name, club) VALUES (?, ?, ?, ?)",
(profile_id, bib_number, name, club),
)
await db.commit()
return profile_id
async def import_startlist_csv(db: aiosqlite.Connection, csv_content: str) -> dict:
"""
Importer startliste fra CSV-streng.
Forventet kolonner: bib_number, name (og valgfritt: club)
Returnerer {'imported': N, 'errors': [...]}
"""
reader = csv.DictReader(io.StringIO(csv_content))
imported = 0
errors = []
# Normaliser kolonnenavn (fjern whitespace, lowercase)
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
if "bib_number" not in fieldnames and "bib" not in fieldnames:
return {"imported": 0, "errors": ["CSV mangler 'bib_number'-kolonne"]}
if "name" not in fieldnames:
return {"imported": 0, "errors": ["CSV mangler 'name'-kolonne"]}
bib_col = "bib_number" if "bib_number" in fieldnames else "bib"
for i, row in enumerate(reader, start=2):
# Normaliser nøkler
row = {k.strip().lower(): v.strip() for k, v in row.items()}
bib = row.get(bib_col, "").strip()
name = row.get("name", "").strip()
club = row.get("club", "").strip() or None
if not bib or not name:
errors.append(f"Rad {i}: mangler bib eller navn")
continue
try:
await upsert_athlete(db, bib, name, club)
imported += 1
except Exception as e:
errors.append(f"Rad {i}: {e}")
return {"imported": imported, "errors": errors}
async def get_athlete_by_bib(db: aiosqlite.Connection, bib: str) -> Optional[dict]:
async with db.execute(
"SELECT * FROM athletes WHERE bib_number = ?", (bib,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def list_athletes(db: aiosqlite.Connection) -> list[dict]:
async with db.execute(
"SELECT * FROM athletes ORDER BY CAST(bib_number AS INTEGER), bib_number"
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def delete_athlete(db: aiosqlite.Connection, profile_id: str) -> bool:
cur = await db.execute(
"DELETE FROM athletes WHERE profile_id = ?", (profile_id,)
)
await db.commit()
return cur.rowcount > 0
async def clear_startlist(db: aiosqlite.Connection) -> None:
await db.execute("DELETE FROM athletes")
await db.commit()
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pillow==11.1.0
piexif==1.1.3
easyocr==1.7.2
opencv-python-headless==4.11.0.86
python-multipart==0.0.20
watchdog==6.0.0
aiosqlite==0.21.0
+125
View File
@@ -0,0 +1,125 @@
"""
Beregn split-tider og totalresultat fra passeringsloggen.
"""
from datetime import datetime
from typing import Optional
import aiosqlite
async def get_results(db: aiosqlite.Connection) -> list[dict]:
"""
Hent totalresultat for alle utøvere som har passert start og mål.
Returnerer sortert liste med split-tider.
"""
# Hent alle bekreftede passeringer gruppert per utøver
async with db.execute("""
SELECT p.profile_id, p.bib_number, a.name, a.club,
p.station, p.timestamp_utc
FROM passages p
LEFT JOIN athletes a ON a.profile_id = p.profile_id
WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL
ORDER BY p.profile_id, p.timestamp_utc
""") as cur:
rows = await cur.fetchall()
# Grupper per utøver
athletes: dict[str, dict] = {}
for row in rows:
pid = row["profile_id"]
if pid not in athletes:
athletes[pid] = {
"profile_id": pid,
"bib_number": row["bib_number"],
"name": row["name"],
"club": row["club"],
"passages": [],
}
athletes[pid]["passages"].append({
"station": row["station"],
"timestamp_utc": row["timestamp_utc"],
})
results = []
for pid, data in athletes.items():
passages = data["passages"]
if not passages:
continue
# Finn start og mål
start_p = next((p for p in passages if p["station"] == "start"), None)
finish_p = next((p for p in passages if p["station"] == "finish"), None)
start_time = _parse_ts(start_p["timestamp_utc"]) if start_p else None
finish_time = _parse_ts(finish_p["timestamp_utc"]) if finish_p else None
total_seconds = None
if start_time and finish_time:
total_seconds = (finish_time - start_time).total_seconds()
# Split-tider mellom alle stasjoner
splits = []
prev_ts = start_time
prev_station = "start"
for p in passages:
if p["station"] == "start":
continue
ts = _parse_ts(p["timestamp_utc"])
split_s = (ts - prev_ts).total_seconds() if prev_ts else None
splits.append({
"from": prev_station,
"to": p["station"],
"split_seconds": split_s,
"split_formatted": _fmt_seconds(split_s),
"timestamp_utc": p["timestamp_utc"],
})
prev_ts = ts
prev_station = p["station"]
results.append({
"profile_id": pid,
"bib_number": data["bib_number"],
"name": data["name"] or f"Ukjent ({data['bib_number']})",
"club": data["club"],
"start_time": start_p["timestamp_utc"] if start_p else None,
"finish_time": finish_p["timestamp_utc"] if finish_p else None,
"total_seconds": total_seconds,
"total_formatted": _fmt_seconds(total_seconds),
"splits": splits,
"dnf": finish_time is None and start_time is not None,
"dns": start_time is None,
})
# Sorter: fullført (etter tid), DNF, DNS
results.sort(key=_sort_key)
return results
def _parse_ts(ts_str: str) -> Optional[datetime]:
if not ts_str:
return None
try:
return datetime.fromisoformat(ts_str)
except ValueError:
return None
def _fmt_seconds(seconds: Optional[float]) -> Optional[str]:
if seconds is None:
return None
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _sort_key(r: dict):
if r["dns"]:
return (3, 0)
if r["dnf"]:
return (2, 0)
return (1, r["total_seconds"] or float("inf"))
+20
View File
@@ -0,0 +1,20 @@
services:
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- ./depot:/depot
- ./processed:/processed
- ./data:/data
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA: alle ruter går til index.html
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API-kall til backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+2667
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+136
View File
@@ -0,0 +1,136 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
color: #222;
}
.navbar {
background: #1a1a2e;
color: white;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.navbar-brand {
font-weight: 700;
font-size: 1.1rem;
margin-right: 1rem;
}
.nav-link {
color: #ccc;
text-decoration: none;
font-size: 0.95rem;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
}
.nav-link:hover { color: white; }
.nav-link.active {
color: white;
border-bottom-color: #e94560;
}
.container {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; }
h2 { font-size: 1.1rem; margin-bottom: 0.75rem; }
.card {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
margin-bottom: 1.5rem;
}
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 2px solid #eee;
font-weight: 600;
color: #555;
}
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
button {
cursor: pointer;
border: none;
border-radius: 5px;
padding: 0.4rem 0.9rem;
font-size: 0.875rem;
font-weight: 500;
}
.btn-primary { background: #1a1a2e; color: white; }
.btn-primary:hover { background: #2a2a4e; }
.btn-danger { background: #e94560; color: white; }
.btn-danger:hover { background: #c73550; }
.btn-success { background: #2ecc71; color: white; }
.btn-success:hover { background: #27ae60; }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
.badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-warning { background: #fff3cd; color: #856404; }
.badge-success { background: #d1f2ea; color: #0e6655; }
.badge-danger { background: #fde8e8; color: #c0392b; }
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
.alert-info { background: #e8f4fd; color: #1a6a9a; }
.alert-error { background: #fde8e8; color: #c0392b; }
.alert-success { background: #d1f2ea; color: #0e6655; }
input[type="text"], input[type="file"], select, textarea {
border: 1px solid #ddd;
border-radius: 5px;
padding: 0.4rem 0.75rem;
font-size: 0.9rem;
width: 100%;
}
.form-row {
display: flex;
gap: 0.75rem;
align-items: flex-end;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.form-group { display: flex; flex-direction: column; gap: 0.25rem; }
.form-group label { font-size: 0.8rem; font-weight: 600; color: #555; }
.empty-state { text-align: center; color: #aaa; padding: 2rem; }
.image-preview {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #eee;
display: block;
margin: 0.5rem 0;
}
.rank { font-weight: 700; color: #888; width: 2rem; text-align: right; }
.rank-1 { color: #f39c12; }
.rank-2 { color: #7f8c8d; }
.rank-3 { color: #d35400; }
+35
View File
@@ -0,0 +1,35 @@
import { NavLink, Route, Routes } from 'react-router-dom'
import StartlistPage from './pages/StartlistPage.jsx'
import ReviewPage from './pages/ReviewPage.jsx'
import ResultsPage from './pages/ResultsPage.jsx'
import PassagesPage from './pages/PassagesPage.jsx'
import './App.css'
function Nav() {
const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')
return (
<nav className="navbar">
<span className="navbar-brand">Timing Admin</span>
<NavLink to="/" className={linkClass} end>Startliste</NavLink>
<NavLink to="/passages" className={linkClass}>Passeringer</NavLink>
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
<NavLink to="/results" className={linkClass}>Resultater</NavLink>
</nav>
)
}
export default function App() {
return (
<>
<Nav />
<main className="container">
<Routes>
<Route path="/" element={<StartlistPage />} />
<Route path="/passages" element={<PassagesPage />} />
<Route path="/review" element={<ReviewPage />} />
<Route path="/results" element={<ResultsPage />} />
</Routes>
</main>
</>
)
}
+41
View File
@@ -0,0 +1,41 @@
const BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, options)
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status} ${res.statusText}: ${text}`)
}
return res.json()
}
export const api = {
// Utøvere
getAthletes: () => request('/athletes'),
importCsv: (file) => {
const form = new FormData()
form.append('file', file)
return request('/athletes/import', { method: 'POST', body: form })
},
deleteAthlete: (id) => request(`/athletes/${id}`, { method: 'DELETE' }),
clearAthletes: () => request('/athletes/all', { method: 'DELETE' }),
// Passeringer
getPassages: (params = {}) => {
const qs = new URLSearchParams(
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null))
).toString()
return request(`/passages${qs ? '?' + qs : ''}`)
},
getReviewQueue: () => request('/passages/review'),
resolvePassage: (id, body) =>
request(`/passages/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
// Resultater
getResults: () => request('/results'),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+111
View File
@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
}
export default function PassagesPage() {
const [passages, setPassages] = useState([])
const [filter, setFilter] = useState({ station: '', needs_review: '' })
const load = async () => {
const params = {}
if (filter.station) params.station = filter.station
if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true'
const data = await api.getPassages(params)
setPassages(data)
}
useEffect(() => { load() }, [filter])
const handleDelete = async (id) => {
if (!confirm('Slett passering?')) return
await api.deletePassage(id)
await load()
}
return (
<>
<h1>Passeringer</h1>
<div className="card">
<div className="form-row">
<div className="form-group">
<label>Stasjon</label>
<input
type="text"
placeholder="f.eks. cp1"
style={{ width: 150 }}
value={filter.station}
onChange={e => setFilter(f => ({ ...f, station: e.target.value }))}
/>
</div>
<div className="form-group">
<label>Status</label>
<select
style={{ width: 180 }}
value={filter.needs_review}
onChange={e => setFilter(f => ({ ...f, needs_review: e.target.value }))}
>
<option value="">Alle</option>
<option value="false">Bekreftet</option>
<option value="true">Trenger gjennomgang</option>
</select>
</div>
</div>
</div>
<div className="card">
<h2>Passeringer ({passages.length})</h2>
{passages.length === 0 ? (
<p className="empty-state">Ingen passeringer funnet.</p>
) : (
<table>
<thead>
<tr>
<th>Startnr</th>
<th>Navn</th>
<th>Stasjon</th>
<th>Tidspunkt (UTC)</th>
<th>Konfidens</th>
<th>Metode</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{passages.map(p => (
<tr key={p.passage_id}>
<td><strong>{p.bib_number || '?'}</strong></td>
<td>{p.name || '—'}</td>
<td>{p.station}</td>
<td style={{ whiteSpace: 'nowrap' }}>{fmtTs(p.timestamp_utc)}</td>
<td>{p.confidence != null ? (p.confidence * 100).toFixed(0) + '%' : '—'}</td>
<td><code style={{ fontSize: '0.75rem' }}>{p.id_method}</code></td>
<td>
{p.needs_review
? <span className="badge badge-warning">Gjennomgang</span>
: <span className="badge badge-success">OK</span>}
</td>
<td>
<button
className="btn-danger btn-sm"
onClick={() => handleDelete(p.passage_id)}
>
Slett
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleTimeString('no-NO', { timeZone: 'UTC', hour12: false })
}
export default function ResultsPage() {
const [results, setResults] = useState([])
const [expanded, setExpanded] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getResults().then(data => {
setResults(data)
setLoading(false)
})
}, [])
const toggleExpand = (id) => setExpanded(e => e === id ? null : id)
if (loading) return <p>Laster...</p>
const finished = results.filter(r => !r.dns && !r.dnf)
const dnf = results.filter(r => r.dnf)
const dns = results.filter(r => r.dns)
return (
<>
<h1>Resultater</h1>
{results.length === 0 ? (
<div className="card">
<p className="empty-state">Ingen resultater tilgjengelig ennå.</p>
</div>
) : (
<div className="card">
<table>
<thead>
<tr>
<th style={{ width: '2.5rem' }}>Pl.</th>
<th>Startnr</th>
<th>Navn</th>
<th>Klubb</th>
<th>Start</th>
<th>Mål</th>
<th>Totaltid</th>
<th></th>
</tr>
</thead>
<tbody>
{finished.map((r, i) => (
<>
<tr key={r.profile_id} onClick={() => toggleExpand(r.profile_id)} style={{ cursor: 'pointer' }}>
<td className={`rank rank-${i + 1}`}>{i + 1}.</td>
<td><strong>{r.bib_number}</strong></td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td>{fmtTs(r.start_time)}</td>
<td>{fmtTs(r.finish_time)}</td>
<td><strong>{r.total_formatted}</strong></td>
<td style={{ color: '#aaa', fontSize: '0.8rem' }}>
{expanded === r.profile_id ? '▲' : '▼'}
</td>
</tr>
{expanded === r.profile_id && r.splits.length > 0 && (
<tr key={r.profile_id + '_splits'}>
<td colSpan={8} style={{ padding: '0.5rem 1rem 1rem' }}>
<table className="split-table" style={{ width: 'auto', minWidth: 300 }}>
<thead>
<tr>
<th>Fra</th>
<th>Til</th>
<th>Tid</th>
</tr>
</thead>
<tbody>
{r.splits.map((s, j) => (
<tr key={j}>
<td>{s.from}</td>
<td>{s.to}</td>
<td>{s.split_formatted || '—'}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
))}
{dnf.map(r => (
<tr key={r.profile_id} style={{ color: '#999' }}>
<td className="rank">DNF</td>
<td>{r.bib_number}</td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td>{fmtTs(r.start_time)}</td>
<td></td>
<td></td>
<td></td>
</tr>
))}
{dns.map(r => (
<tr key={r.profile_id} style={{ color: '#bbb' }}>
<td className="rank">DNS</td>
<td>{r.bib_number}</td>
<td>{r.name}</td>
<td>{r.club || '—'}</td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTs(ts) {
if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
}
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
const [bib, setBib] = useState(passage.bib_number || '')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const matchedAthlete = athletes.find(a => a.bib_number === bib)
const handleResolve = async () => {
setSaving(true)
try {
await api.resolvePassage(passage.passage_id, {
bib_number: bib || null,
profile_id: matchedAthlete?.profile_id || null,
review_note: note || null,
})
onResolved()
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Slett passering?')) return
await api.deletePassage(passage.passage_id)
onDeleted()
}
// Bygg bildesti relativt til /api/images/
const imgSrc = passage.source_image
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
: null
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div>
{imgSrc ? (
<img src={imgSrc} alt="Passeringsbilde" className="image-preview" />
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>
Bilde ikke tilgjengelig
</div>
)}
</div>
<div>
<table style={{ marginBottom: '1rem' }}>
<tbody>
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
<tr><td><strong>Tidspunkt</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr>
<tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr>
<tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr>
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
</tbody>
</table>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label>Startnummer</label>
<input
type="text"
value={bib}
onChange={e => setBib(e.target.value)}
placeholder="Skriv inn riktig startnummer"
/>
{matchedAthlete && (
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
{matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''}
</span>
)}
{bib && !matchedAthlete && (
<span style={{ fontSize: '0.8rem', color: '#e67e22', marginTop: 2 }}>
Startnummer ikke i startliste
</span>
)}
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label>Notat (valgfritt)</label>
<input
type="text"
value={note}
onChange={e => setNote(e.target.value)}
placeholder="f.eks. startnummer skjult av arm"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn-success"
onClick={handleResolve}
disabled={saving}
>
{saving ? 'Lagrer...' : 'Bekreft'}
</button>
<button className="btn-danger" onClick={handleDelete}>
Slett
</button>
</div>
</div>
</div>
</div>
)
}
export default function ReviewPage() {
const [queue, setQueue] = useState([])
const [athletes, setAthletes] = useState([])
const [loading, setLoading] = useState(true)
const load = async () => {
setLoading(true)
const [q, a] = await Promise.all([api.getReviewQueue(), api.getAthletes()])
setQueue(q)
setAthletes(a)
setLoading(false)
}
useEffect(() => { load() }, [])
const removeFromQueue = (passageId) => {
setQueue(q => q.filter(p => p.passage_id !== passageId))
}
return (
<>
<h1>Manuell gjennomgang</h1>
{loading ? (
<p>Laster...</p>
) : queue.length === 0 ? (
<div className="card">
<p className="empty-state">Ingen passeringer venter gjennomgang.</p>
</div>
) : (
<>
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
{queue.length} passering(er) venter manuell behandling.
</div>
{queue.map(p => (
<ReviewCard
key={p.passage_id}
passage={p}
athletes={athletes}
onResolved={() => removeFromQueue(p.passage_id)}
onDeleted={() => removeFromQueue(p.passage_id)}
/>
))}
</>
)}
</>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
export default function StartlistPage() {
const [athletes, setAthletes] = useState([])
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(false)
const fileRef = useRef()
const load = async () => {
const data = await api.getAthletes()
setAthletes(data)
}
useEffect(() => { load() }, [])
const handleImport = async (e) => {
e.preventDefault()
const file = fileRef.current.files[0]
if (!file) return
setLoading(true)
setStatus(null)
try {
const res = await api.importCsv(file)
setStatus({
type: 'success',
msg: `Importert ${res.imported} utøver(e).` +
(res.errors.length ? ` Feil: ${res.errors.join(', ')}` : ''),
})
await load()
} catch (err) {
setStatus({ type: 'error', msg: err.message })
} finally {
setLoading(false)
fileRef.current.value = ''
}
}
const handleDelete = async (id) => {
if (!confirm('Slett utøver?')) return
await api.deleteAthlete(id)
await load()
}
const handleClear = async () => {
if (!confirm('Slett hele startlisten?')) return
await api.clearAthletes()
setAthletes([])
setStatus({ type: 'success', msg: 'Startliste slettet.' })
}
return (
<>
<h1>Startliste</h1>
<div className="card">
<h2>Last opp CSV</h2>
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}>
Påkrevde kolonner: <code>bib_number</code> (eller <code>bib</code>), <code>name</code>.
Valgfritt: <code>club</code>.
</p>
<form onSubmit={handleImport} className="form-row">
<div className="form-group" style={{ flex: 1 }}>
<label>CSV-fil</label>
<input type="file" accept=".csv" ref={fileRef} required />
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Laster...' : 'Importer'}
</button>
</form>
{status && (
<div className={`alert alert-${status.type === 'error' ? 'error' : 'success'}`}>
{status.msg}
</div>
)}
</div>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h2>Utøvere ({athletes.length})</h2>
{athletes.length > 0 && (
<button className="btn-danger btn-sm" onClick={handleClear}>Slett alle</button>
)}
</div>
{athletes.length === 0 ? (
<p className="empty-state">Ingen utøvere registrert. Last opp en startliste.</p>
) : (
<table>
<thead>
<tr>
<th>Startnr</th>
<th>Navn</th>
<th>Klubb</th>
<th></th>
</tr>
</thead>
<tbody>
{athletes.map((a) => (
<tr key={a.profile_id}>
<td><strong>{a.bib_number}</strong></td>
<td>{a.name}</td>
<td>{a.club || '—'}</td>
<td>
<button
className="btn-danger btn-sm"
onClick={() => handleDelete(a.profile_id)}
>
Slett
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})
+270
View File
@@ -0,0 +1,270 @@
# Bildedrevet tidtakingssystem — teknisk spesifikasjon
## Oversikt
Et tidtakingssystem for løp uten elektroniske brikker. Utøvere identifiseres ved hjelp av bilder tatt ved start, passeringspunkter og mål. Bildene inneholder nøyaktig tidsstempel og GPS-posisjon i EXIF-metadata, noe som gjør det mulig å rekonstruere hver utøvers rute og passeringstidspunkter langs løypa.
Identifikasjon skjer ved kombinasjon av to metoder:
1. OCR på startnummer
2. Biometrisk persongjenkjenning (ansikt/kroppsform)
De to metodene forsterker hverandre — et delvis skjult startnummer kan disambigueres av persongjenkjenning, og omvendt.
---
## Kamerastasjoner
Kamera settes opp ved:
- **Start** — alle utøvere fotograferes ved avgang
- **Passeringspunkter** — vilkårlig antall stasjoner langs løypa (N ≥ 0)
- **Mål** — alle utøvere fotograferes ved ankomst
Hvert kamera må:
- Produsere JPEG/PNG med korrekt EXIF-data (se nedenfor)
- Ha burst-modus eller høy framerate for å håndtere bevegelsesuskarphet
- Synkroniseres mot NTP (nøyaktig klokkeslett er kritisk)
### EXIF-krav per bilde
Alle bilder MÅ ha følgende EXIF-felter satt:
| EXIF-felt | Innhold | Merknad |
|---|---|---|
| `DateTimeOriginal` | ISO 8601 UTC | Eks: `2024-06-15T10:23:45.123Z` |
| `SubSecTimeOriginal` | Millisekunder | For sub-sekund presisjon |
| `GPSLatitude` + `GPSLatitudeRef` | Desimalgrader | Stasjonens posisjon |
| `GPSLongitude` + `GPSLongitudeRef` | Desimalgrader | Stasjonens posisjon |
| `GPSAltitude` | Meter over havet | Valgfritt men anbefalt |
| `CameraLabel` / `ImageDescription` | Stasjonsnavn | Eks: `"start"`, `"cp1"`, `"finish"` |
EXIF-posisjonen representerer stasjonens faste posisjon (ikke utøverens bevegelse). For mobile stasjoner settes GPS per bilde fra kamerakontrollerens GPS-mottaker.
---
## Dataflyt og pipeline
```
Bilde (JPEG + EXIF)
[1] Inntak og preprocessing
- Valider EXIF (tid + GPS påkrevd, avvis ellers)
- Kø-basert mottak fra alle stasjoner
- Generer utsnitt av utøver (bounding box)
- Kvalitetsfiltrering: skarphet, lysforhold, utøver synlig
├──────────────────────────────────┐
▼ ▼
[2a] Startnummer-OCR [2b] Persongjenkjenning
- Detekter nummerlapp - Generer embedding fra
- Les sifre ansikt og/eller kropp
- Returner: sifre[], konfidens, - Match mot profil-DB
andel synlig (0.01.0) - Returner: profil_id,
konfidens, match-type
│ │
└──────────────┬───────────────────┘
[3] Konfidensbasert fusjonsmotor
- Kombiner OCR-resultat og personmatch
- Beslutningstre (se nedenfor)
- Output: identifikasjon + konfidensscore
├── Høy konfidens ──► [4a] Logg passering direkte
└── Lav konfidens ──► [4b] Flagg for manuell gjennomgang
[5] Resultatdatabase
- Utøver-ID, stasjon, tidsstempel (fra EXIF), GPS-pos
- Beregn split-tider og løyperute
```
---
## Fusjonslogikk
Prioriteringsrekkefølge ved konflikt:
```
1. Startnummer tydelig (konfidens > 0.90)
→ Bruk startnummer, ignorer person-match hvis konflikt
2. Startnummer delvis synlig (konfidens 0.500.90) + person-match
→ Kombiner: filtrer kandidater fra startnummer, disambiguer med person
→ Logg hvis entydig, flagg hvis flere kandidater gjenstår
3. Startnummer uleselig (konfidens < 0.50) + person-match (konfidens > 0.70)
→ Bruk person-match alene
→ Logg med merknad "number_unreadable"
4. Ingen av delene gir tilstrekkelig konfidens
→ Flagg for manuell gjennomgang med bilde og metadata
```
Minste akseptable konfidens for automatisk logging: konfigurbart, anbefalt startverdi `0.75`.
---
## Profildatabase (utøver-DB)
Utøverprofiler bygges opp suksessivt:
- **Bootstrapping:** Startkameraet genererer en profil for hver unik person som passerer. Profil-ID tildeles automatisk.
- **Kobling til startliste:** Valgfritt — kan mates inn med navn + startnummer + registreringsbilde. Kobles til profil ved første gjenkjenning.
- **Uten startliste:** Systemet fungerer fullt ut; utøvere får anonyme ID-er. Startliste kan legges til i etterkant for å sette navn.
### Skjema (forenklet)
```json
{
"profile_id": "uuid",
"bib_number": "42",
"name": "Ola Nordmann",
"embeddings": [ /* array av vektorer fra registreringsbilder */ ],
"registration_images": [ "path/to/img1.jpg" ],
"created_at": "2024-06-15T09:00:00Z"
}
```
---
## Passeringslogg
En passering lagres når en utøver identifiseres ved en stasjon:
```json
{
"passage_id": "uuid",
"profile_id": "uuid",
"bib_number": "42",
"station": "cp1",
"timestamp_utc": "2024-06-15T10:23:45.123Z",
"gps_lat": 61.1234,
"gps_lon": 10.5678,
"gps_alt": 910,
"confidence": 0.92,
"id_method": "bib_ocr+person",
"source_image": "path/to/image.jpg",
"needs_review": false
}
```
Fra passeringsloggen beregnes:
- Split-tider mellom stasjoner
- Totaltid start→mål
- Løyperute (GPS-spor av stasjonspasseringer)
- Estimert posisjon i løypa på ethvert tidspunkt (interpolasjon)
---
## Komponenter som skal implementeres
### Backend (Python anbefalt)
| Modul | Ansvar |
|---|---|
| `ingest.py` | Ta imot bilder, valider EXIF, kø til pipeline |
| `exif_parser.py` | Parse og normaliser EXIF-metadata |
| `ocr.py` | Startnummer-deteksjon og OCR |
| `recognition.py` | Persongjenkjenning, embedding, match mot profil-DB |
| `fusion.py` | Kombiner OCR + person, beslutningslogikk |
| `profile_db.py` | CRUD for utøverprofiler og embeddings |
| `passage_log.py` | Skriv og query passeringslogg |
| `results.py` | Beregn split-tider, totalresultat, løyperute |
| `review_queue.py` | Håndter manuelle gjennomganger |
### Foreslåtte biblioteker
- **OCR:** `easyocr` eller `paddleocr` (god på korte numre i varierende lysforhold)
- **Persongjenkjenning:** `deepface` eller `insightface` (lokalt, ingen sky-avhengighet)
- **EXIF-parsing:** `piexif` eller `exifread`
- **Database:** SQLite for enkel lokal drift, PostgreSQL for produksjon
- **Bildeprosessering:** `opencv-python`, `Pillow`
- **Kø:** `redis` + `rq` for asynkron pipeline, eller enkel filkø for MVP
### Frontend (valgfritt, React anbefalt)
- Live-visning av passeringer
- Leaflet-kart med GPS-posisjoner til stasjoner og utøveres rute
- Resultatliste med split-tider
- Manuell gjennomgangskø med bildevisning
---
## Kanttilfeller og håndtering
| Situasjon | Håndtering |
|---|---|
| Utøvere overlapper i bildet | Generer separate bounding boxes per utøver |
| Startnummeret er delvis bak arm/klesplagg | OCR returnerer synlige sifre + `partial: true` |
| Samme utøver fanges i burst-sekvens | Deduplisering: behold beste bilde per utøver per stasjon (±2 sek vindu) |
| Refleks/solblending gjør bildet ubrukelig | Kvalitetsfilter forkaster bildet; neste i burst brukes |
| EXIF mangler tid eller GPS | Bildet avvises med feillogg; manuell stasjon-tid kan angis som fallback |
| Ukjent person, uleselig nummer | Flagges med bilde til manuell gjennomgang |
| To utøvere med like startnummer (feil) | Flagges som konflikt; begge profiler merkes |
---
## Personvern
- Biometrisk gjenkjenning krever samtykke (GDPR artikkel 9 — særlig kategori)
- Anbefalt løsning: opt-in ved påmelding, registreringsbilde lastes opp i forkant
- Embeddings lagres, ikke råbilder, etter at passering er bekreftet (konfigurerbart)
- Råbilder bør slettes etter løpet med mindre deltaker har samtykket til lagring
- Systemet bør kunne kjøres i "bib-only"-modus uten persongjenkjenning for arrangement som ikke innhenter biometrisk samtykke
---
## Infrastruktur
### Container-drift
Hele systemet kjøres i Docker (docker-compose):
- **backend** — Python/FastAPI, pipeline og API
- **frontend** — React admin-grensesnitt (Nginx)
- **db** — SQLite via volum (PostgreSQL i produksjon)
Volumes:
- `./depot` — inntakskatalog for nye bilder (kamera-drop)
- `./processed` — bearbeidede bilder med unike filnavn
- `./data` — SQLite-database
### Bildehåndtering
1. Kamera/bruker legger bilder i `depot/`
2. `ingest.py` oppdager nye filer (inotify / polling)
3. EXIF valideres; ved godkjenning:
- Metadata lagres i DB
- Filen flyttes til `processed/<år>/<måned>/<uuid>_<originalfilnavn>`
- Depot-filen slettes
4. Ved ugyldig EXIF: filen flyttes til `depot/rejected/` med feillogg
---
## Admin-webgrensesnitt (React)
Tilgjengelig via nettleser, kommuniserer med backend via REST API.
| Skjerm | Funksjonalitet |
|---|---|
| Startliste | Last opp CSV, vis/rediger utøverliste |
| Live passeringer | Strøm av nylige passeringer fra alle stasjoner |
| Manuell gjennomgang | Vis bilder med lav konfidens, bekreft/korriger ID |
| Resultater | Split-tider og totalresultat per utøver |
| Stasjoner | Oversikt over kamerastasjoner og siste aktivitet |
---
## MVP-avgrensning (fase 1)
For å komme raskt i gang, begrens til:
1. EXIF-parsing og validering
2. Startnummer-OCR (ingen persongjenkjenning ennå)
3. Manuell startliste som CSV (bib_number → navn)
4. Passeringslogg til SQLite
5. REST API (FastAPI) for frontend
6. React admin-grensesnitt: startliste-import og manuell gjennomgang
7. Docker-oppsett med depot/processed-volumer
Persongjenkjenning og live Leaflet-kart legges til i fase 2.