45f7a77171
Build & Deploy / build-and-deploy (push) Successful in 46s
- 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>
436 lines
13 KiB
Python
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"}
|