Files
timing/backend/api.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

436 lines
13 KiB
Python

"""
FastAPI REST API for timing-systemet.
"""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
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 pydantic import BaseModel
from typing import Optional
from ingest import watch_depot, process_existing
from passage_log import delete_passage, get_passage_images, get_passages, log_passage, resolve_passage
from profile_db import (
clear_startlist,
delete_athlete,
get_db,
get_or_create_athlete,
import_startlist_csv,
list_athletes,
)
from race_db import (
create_race, delete_race, get_active_race, get_race, get_station,
list_races, list_stations, set_active_race, update_race, upsert_station, delete_station,
)
from results import get_results
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
await process_existing()
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=["*"],
)
async def get_connection():
db = await get_db()
try:
yield db
finally:
await db.close()
# =====================
# Løp (races)
# =====================
class RaceRequest(BaseModel):
name: str
date: Optional[str] = None
description: Optional[str] = None
bib_filter_enabled: bool = False
@app.get("/api/races")
async def list_races_endpoint(db=Depends(get_connection)):
return await list_races(db)
@app.get("/api/races/active")
async def active_race(db=Depends(get_connection)):
race = await get_active_race(db)
return race or {}
@app.post("/api/races")
async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)):
return await create_race(db, body.name, body.date, body.description, body.bib_filter_enabled)
@app.put("/api/races/{race_id}")
async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)):
ok = await update_race(db, race_id, body.name, body.date, body.description, body.bib_filter_enabled)
if not ok:
raise HTTPException(404, "Løp ikke funnet")
return await get_race(db, race_id)
@app.post("/api/races/{race_id}/activate")
async def activate_race(race_id: str, db=Depends(get_connection)):
ok = await set_active_race(db, race_id)
if not ok:
raise HTTPException(404, "Løp ikke funnet")
return {"ok": True}
@app.delete("/api/races/{race_id}")
async def delete_race_endpoint(race_id: str, db=Depends(get_connection)):
ok = await delete_race(db, race_id)
if not ok:
raise HTTPException(404, "Løp ikke funnet")
return {"ok": True}
# =====================
# Stasjoner
# =====================
class StationRequest(BaseModel):
name: str
display_name: str
station_order: int
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
gps_alt: Optional[float] = None
@app.get("/api/races/{race_id}/stations")
async def list_stations_endpoint(race_id: str, db=Depends(get_connection)):
return await list_stations(db, race_id)
@app.post("/api/races/{race_id}/stations")
async def create_station_endpoint(race_id: str, body: StationRequest, db=Depends(get_connection)):
return await upsert_station(
db, race_id, body.name, body.display_name, body.station_order,
body.gps_lat, body.gps_lon, body.gps_alt,
)
@app.put("/api/races/{race_id}/stations/{station_id}")
async def update_station_endpoint(
race_id: str, station_id: str, body: StationRequest, db=Depends(get_connection)
):
return await upsert_station(
db, race_id, body.name, body.display_name, body.station_order,
body.gps_lat, body.gps_lon, body.gps_alt, station_id=station_id,
)
@app.delete("/api/races/{race_id}/stations/{station_id}")
async def delete_station_endpoint(race_id: str, station_id: str, db=Depends(get_connection)):
ok = await delete_station(db, station_id)
if not ok:
raise HTTPException(400, "Kan ikke slette start eller mål")
return {"ok": True}
# =====================
# Startliste
# =====================
@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")
return await import_startlist_csv(db, content)
@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(
race_id: Optional[str] = None,
station: Optional[str] = None,
needs_review: Optional[bool] = None,
profile_id: Optional[str] = None,
db=Depends(get_connection),
):
return await get_passages(db, race_id=race_id, profile_id=profile_id,
station=station, needs_review=needs_review)
@app.get("/api/passages/review")
async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)):
return await get_passages(db, race_id=race_id, 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)):
profile_id = body.profile_id
if body.bib_number and not profile_id:
profile_id = await get_or_create_athlete(db, body.bib_number)
ok = await resolve_passage(db, passage_id,
profile_id=profile_id,
bib_number=body.bib_number,
review_note=body.review_note)
if not ok:
raise HTTPException(404, "Passering ikke funnet")
# Oppdater EXIF-metadata med bekreftet startnummer
if body.bib_number:
from image_tagger import write_bib_tags, read_bib_tags
async with db.execute(
"SELECT source_image, station, race_id FROM passages WHERE passage_id = ?",
(passage_id,),
) as cur:
row = await cur.fetchone()
if row:
img_path = Path(row["source_image"])
if img_path.exists():
# Behold allerede taggede bibs, legg til bekreftet
existing = read_bib_tags(img_path)
all_bibs = list(dict.fromkeys(
(existing or {}).get("bibs", []) + [body.bib_number]
))
write_bib_tags(
img_path, all_bibs,
station=row["station"],
race_id=row["race_id"],
confirmed=True,
)
return {"ok": True}
@app.get("/api/passages/{passage_id}/images")
async def passage_images(passage_id: str, db=Depends(get_connection)):
return await get_passage_images(db, passage_id)
@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}
@app.post("/api/passages/{passage_id}/reanalyze")
async def reanalyze_passage(passage_id: str, db=Depends(get_connection)):
"""
Kjør OCR på nytt på passeringens kildebilde.
Oppretter nye passeringer for eventuelle startnumre som ikke allerede er logget fra dette bildet.
Returnerer alle funne startnumre og eventuelle nye passage_id-er.
"""
from datetime import timezone as _tz
from ocr import read_all_bibs
from ingest import MIN_AUTO_CONFIDENCE
from profile_db import get_athlete_by_bib
async with db.execute("SELECT * FROM passages WHERE passage_id = ?", (passage_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "Passering ikke funnet")
passage = dict(row)
image_path = Path(passage["source_image"])
if not image_path.exists():
raise HTTPException(404, "Kildebilde ikke funnet på disk")
bibs = read_all_bibs(image_path)
# Finn bib-numre som allerede er logget fra dette bildet
async with db.execute(
"SELECT bib_number FROM passages WHERE source_image = ?", (passage["source_image"],)
) as cur:
existing_bibs = {r["bib_number"] for r in await cur.fetchall()}
new_passages = []
ts_str = passage["timestamp_utc"]
ts = datetime.fromisoformat(ts_str)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=_tz.utc)
for ocr in bibs:
if ocr.digits in existing_bibs:
continue
confidence = ocr.confidence
needs_review = confidence < MIN_AUTO_CONFIDENCE
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
review_note = "low_confidence" if needs_review else None
profile_id = None
if ocr.digits and not needs_review:
athlete = await get_athlete_by_bib(db, ocr.digits)
if athlete:
profile_id = athlete["profile_id"]
new_id = await log_passage(
db,
race_id=passage["race_id"],
profile_id=profile_id,
bib_number=ocr.digits,
station=passage["station"],
timestamp_utc=ts,
gps_lat=passage["gps_lat"],
gps_lon=passage["gps_lon"],
gps_alt=passage["gps_alt"],
confidence=confidence,
proximity_score=ocr.proximity_score,
id_method=id_method,
source_image=passage["source_image"],
needs_review=needs_review,
review_note=review_note,
)
new_passages.append({"passage_id": new_id, "bib_number": ocr.digits, "confidence": confidence})
return {
"found_bibs": [ocr.digits for ocr in bibs],
"new_passages": new_passages,
}
# =====================
# Resultater
# =====================
@app.get("/api/results")
async def get_results_endpoint(race_id: Optional[str] = None, db=Depends(get_connection)):
return await get_results(db, race_id=race_id)
# =====================
# Bildeopplasting
# =====================
VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
@app.post("/api/upload")
async def upload_images(
files: list[UploadFile] = File(...),
race_id: Optional[str] = None,
station_id: Optional[str] = None,
db=Depends(get_connection),
):
"""
Last opp bilder til depot. Med race_id + station_id behandles bildene
direkte (ingen EXIF GPS-krav) og passeringen logges umiddelbart.
"""
# Hent stasjon for å få GPS og stasjonsnavn
station = None
if station_id:
station = await get_station(db, station_id)
if not station:
raise HTTPException(404, "Stasjon ikke funnet")
results = []
for file in files:
suffix = Path(file.filename).suffix.lower()
if suffix not in VALID_IMAGE_SUFFIXES:
results.append({"filename": file.filename, "ok": False, "error": "Ugyldig filtype"})
continue
dest = Path("/depot") / file.filename
if dest.exists():
dest = Path("/depot") / f"{uuid.uuid4().hex}_{file.filename}"
try:
content = await file.read()
dest.write_bytes(content)
if station:
# Direktebehandling — flytt til processed og logg passering
from ingest import process_image_with_override
await process_image_with_override(
dest,
race_id=race_id,
station_name=station["name"],
gps_lat=station["gps_lat"],
gps_lon=station["gps_lon"],
gps_alt=station["gps_alt"],
db=db,
)
results.append({"filename": file.filename, "ok": True, "saved_as": dest.name})
except Exception as e:
logger.exception("Feil ved opplasting av %s", file.filename)
results.append({"filename": file.filename, "ok": False, "error": str(e)})
return {"uploaded": sum(1 for r in results if r["ok"]), "results": results}
# =====================
# Bilder
# =====================
@app.get("/api/images/{path:path}")
async def serve_image(path: str):
full_path = Path("/processed") / path
if not full_path.exists() or not full_path.is_file():
raise HTTPException(404, "Bilde ikke funnet")
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"}