diff --git a/backend/api.py b/backend/api.py index 0ac0c6f..4a06eec 100644 --- a/backend/api.py +++ b/backend/api.py @@ -68,6 +68,7 @@ class RaceRequest(BaseModel): name: str date: Optional[str] = None description: Optional[str] = None + bib_filter_enabled: bool = False @app.get("/api/races") @@ -83,12 +84,12 @@ async def active_race(db=Depends(get_connection)): @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) + 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) + 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) @@ -223,6 +224,30 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti 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} @@ -239,6 +264,82 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)): 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 # ===================== diff --git a/backend/image_tagger.py b/backend/image_tagger.py new file mode 100644 index 0000000..3fc9162 --- /dev/null +++ b/backend/image_tagger.py @@ -0,0 +1,89 @@ +""" +Skriv og les bib-metadata i bildefiler via EXIF UserComment (JSON). + +Bruker piexif + Pillow som allerede er i requirements.txt. +Metadata lagres i Exif.UserComment som: + b"ASCII\x00\x00\x00" + JSON-bytes + +Eksempel-innhold: + {"bibs": ["42", "87"], "station": "finish", "confidence": 0.93, "confirmed": true} +""" + +import json +import logging +from pathlib import Path +from typing import Optional + +import piexif +from PIL import Image + +logger = logging.getLogger(__name__) + +_ASCII_PREFIX = b"ASCII\x00\x00\x00" + + +def write_bib_tags( + image_path: Path, + bibs: list[str], + *, + station: Optional[str] = None, + race_id: Optional[str] = None, + confidence: Optional[float] = None, + confirmed: bool = False, +) -> None: + """ + Skriv startnummer-metadata til bildefil som EXIF UserComment (JSON). + Bevarer all eksisterende EXIF. Feiler stille — krasjer ikke ingest-prosessen. + """ + data: dict = {"bibs": bibs} + if station: + data["station"] = station + if race_id: + data["race_id"] = race_id + if confidence is not None: + data["confidence"] = round(confidence, 4) + if confirmed: + data["confirmed"] = True + + json_bytes = json.dumps(data, ensure_ascii=True, separators=(",", ":")).encode("ascii") + comment_bytes = _ASCII_PREFIX + json_bytes + + try: + img = Image.open(image_path) + raw_exif = img.info.get("exif") + if raw_exif: + exif_dict = piexif.load(raw_exif) + else: + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}} + + exif_dict.setdefault("Exif", {})[piexif.ExifIFD.UserComment] = comment_bytes + new_exif = piexif.dump(exif_dict) + + # Lagre til midlertidig fil, rename for atomisk skriving + tmp = image_path.with_suffix(".tmp" + image_path.suffix) + img.save(tmp, exif=new_exif) + tmp.replace(image_path) + + logger.debug("EXIF-tags skrevet til %s: bibs=%s", image_path.name, bibs) + except Exception as e: + logger.warning("Kunne ikke skrive EXIF-tags til %s: %s", image_path, e) + + +def read_bib_tags(image_path: Path) -> Optional[dict]: + """ + Les startnummer-metadata fra EXIF UserComment. + Returnerer dict (med nøkkel 'bibs') eller None hvis ikke satt. + """ + try: + img = Image.open(image_path) + raw_exif = img.info.get("exif") + if not raw_exif: + return None + exif_dict = piexif.load(raw_exif) + comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment) + if not comment or not comment.startswith(_ASCII_PREFIX): + return None + json_str = comment[len(_ASCII_PREFIX):].decode("ascii", errors="replace") + return json.loads(json_str) + except Exception: + return None diff --git a/backend/ingest.py b/backend/ingest.py index 6a2da61..8037427 100644 --- a/backend/ingest.py +++ b/backend/ingest.py @@ -21,7 +21,8 @@ from watchdog.events import FileSystemEventHandler, FileCreatedEvent from watchdog.observers import Observer from exif_parser import ExifError, parse_image -from ocr import read_bib +from image_tagger import write_bib_tags +from ocr import read_all_bibs from passage_log import log_passage from profile_db import get_or_create_athlete, init_db @@ -70,56 +71,73 @@ async def process_image(path: Path) -> None: return # --- OCR --- - ocr = read_bib(path) - logger.debug("OCR: digits=%s conf=%.2f", ocr.digits, ocr.confidence) + bibs = read_all_bibs(path) + logger.debug("OCR: %d startnumre funnet", len(bibs)) # --- 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: - profile_id = await get_or_create_athlete(db, bib_number) + if not bibs: + # Ingen bib funnet — legg til manuell gjennomgang + await log_passage( + db, + profile_id=None, + bib_number=None, + 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=0.0, + proximity_score=0.0, + id_method="bib_ocr_uncertain", + source_image=str(dest), + needs_review=True, + review_note="number_unreadable", + ) + logger.info("Passering logget (ingen bib): station=%s", meta.station) + else: + for ocr in bibs: + 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 - 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, - proximity_score=ocr.proximity_score, - 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, - ) + profile_id = None + if ocr.digits and not needs_review: + profile_id = await get_or_create_athlete(db, ocr.digits) + + await log_passage( + db, + profile_id=profile_id, + bib_number=ocr.digits, + 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, + proximity_score=ocr.proximity_score, + 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", + ocr.digits, meta.station, needs_review, + ) + + # Skriv alle funne bibs til EXIF-metadata i filen + found_bibs = [ocr.digits for ocr in bibs if ocr.digits] + if found_bibs: + write_bib_tags(dest, found_bibs, station=meta.station or "unknown") async def process_image_with_override( @@ -148,39 +166,63 @@ async def process_image_with_override( except ExifError: timestamp = datetime.now(timezone.utc) - ocr = read_bib(path) + bibs = read_all_bibs(path) dest = _destination_path(path, timestamp) shutil.move(str(path), str(dest)) - confidence = ocr.confidence - needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE - id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain" - review_note = None if not needs_review else ( - "number_unreadable" if ocr.digits is None else "low_confidence" - ) + if not bibs: + await log_passage( + db, + race_id=race_id, + profile_id=None, + bib_number=None, + station=station_name, + timestamp_utc=timestamp, + gps_lat=gps_lat or 0.0, + gps_lon=gps_lon or 0.0, + gps_alt=gps_alt, + confidence=0.0, + proximity_score=0.0, + id_method="bib_ocr_uncertain", + source_image=str(dest), + needs_review=True, + review_note="number_unreadable", + ) + logger.info("Passering logget (ingen bib): station=%s", station_name) + else: + for ocr in bibs: + 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: - profile_id = await _get_or_create(db, ocr.digits) + profile_id = None + if ocr.digits and not needs_review: + profile_id = await _get_or_create(db, ocr.digits) - await log_passage( - db, - race_id=race_id, - profile_id=profile_id, - bib_number=ocr.digits, - station=station_name, - timestamp_utc=timestamp, - gps_lat=gps_lat or 0.0, - gps_lon=gps_lon or 0.0, - gps_alt=gps_alt, - confidence=confidence, - proximity_score=ocr.proximity_score, - id_method=id_method, - source_image=str(dest), - needs_review=needs_review, - review_note=review_note, - ) - logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name) + await log_passage( + db, + race_id=race_id, + profile_id=profile_id, + bib_number=ocr.digits, + station=station_name, + timestamp_utc=timestamp, + gps_lat=gps_lat or 0.0, + gps_lon=gps_lon or 0.0, + gps_alt=gps_alt, + confidence=confidence, + proximity_score=ocr.proximity_score, + id_method=id_method, + source_image=str(dest), + needs_review=needs_review, + review_note=review_note, + ) + logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name) + + # Skriv alle funne bibs til EXIF-metadata i filen + found_bibs = [ocr.digits for ocr in bibs if ocr.digits] + if found_bibs: + write_bib_tags(dest, found_bibs, station=station_name, race_id=race_id) async def process_existing() -> None: diff --git a/backend/ocr.py b/backend/ocr.py index a338270..8208e7e 100644 --- a/backend/ocr.py +++ b/backend/ocr.py @@ -64,33 +64,11 @@ def _bbox_area(bbox) -> float: return (max(xs) - min(xs)) * (max(ys) - min(ys)) -def _extract_bib_number(texts: list[tuple]) -> tuple[Optional[str], float, bool, float]: +def read_all_bibs(image_path: Path) -> list["OcrResult"]: """ - Finn beste siffersekvens blant OCR-treff. - Returnerer (sifre, konfidens, partial, proximity_score). - proximity_score = areal av bounding box i piksler² (større = nærmere kamera). - """ - candidates = [] - for (bbox, text, conf) in texts: - digits = re.sub(r"[^0-9]", "", text) - if digits: - candidates.append((digits, float(conf), _bbox_area(bbox))) - - if not candidates: - return None, 0.0, False, 0.0 - - # Velg kandidat med høyest konfidens - best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1]) - - partial = len(best_digits) < 2 - - return best_digits, best_conf, partial, best_area - - -def read_bib(image_path: Path) -> OcrResult: - """ - Les startnummer fra bildet. - Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil. + Les alle startnumre fra bildet. + Returnerer én OcrResult per unikt startnummer funnet (minst 2 sifre). + Tom liste hvis ingen funnet eller ved feil. """ try: processed = _preprocess(image_path) @@ -98,19 +76,59 @@ def read_bib(image_path: Path) -> OcrResult: results = reader.readtext(processed, detail=1, paragraph=False) raw_texts = [text for (_, text, _) in results] - digits, confidence, partial, proximity_score = _extract_bib_number(results) - return OcrResult( - digits=digits, - confidence=confidence, - partial=partial, - proximity_score=proximity_score, - raw_texts=raw_texts, - ) + # Best konfidens + areal per unikt siffersekvens (minst 2 sifre) + best: dict[str, tuple[float, float]] = {} # digits -> (conf, area) + for (bbox, text, conf) in results: + digits = re.sub(r"[^0-9]", "", text) + if len(digits) < 2: + continue + area = _bbox_area(bbox) + if digits not in best or float(conf) > best[digits][0]: + best[digits] = (float(conf), area) + + return [ + OcrResult( + digits=digits, + confidence=conf, + partial=False, + proximity_score=area, + raw_texts=raw_texts, + ) + for digits, (conf, area) in best.items() + ] except Exception as e: - return OcrResult( - digits=None, - confidence=0.0, - partial=False, - raw_texts=[f"ERROR: {e}"], - ) + return [OcrResult(digits=None, confidence=0.0, partial=False, raw_texts=[f"ERROR: {e}"])] + + +def read_bib(image_path: Path) -> OcrResult: + """ + Les beste startnummer fra bildet (bakoverkompatibel). + Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil. + """ + bibs = read_all_bibs(image_path) + if not bibs or bibs[0].digits is None: + # Sjekk også enkle sifre (partial) for bakoverkompatibilitet + try: + processed = _preprocess(image_path) + reader = _get_reader() + results = reader.readtext(processed, detail=1, paragraph=False) + raw_texts = [text for (_, text, _) in results] + candidates = [] + for (bbox, text, conf) in results: + digits = re.sub(r"[^0-9]", "", text) + if digits: + candidates.append((digits, float(conf), _bbox_area(bbox))) + if candidates: + best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1]) + return OcrResult( + digits=best_digits, + confidence=best_conf, + partial=len(best_digits) < 2, + proximity_score=best_area, + raw_texts=raw_texts, + ) + except Exception: + pass + return OcrResult(digits=None, confidence=0.0, partial=False) + return max(bibs, key=lambda r: r.confidence) diff --git a/backend/race_db.py b/backend/race_db.py index 446cf14..9b46b00 100644 --- a/backend/race_db.py +++ b/backend/race_db.py @@ -11,12 +11,13 @@ import aiosqlite async def init_race_tables(db: aiosqlite.Connection) -> None: await db.executescript(""" CREATE TABLE IF NOT EXISTS races ( - race_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - date TEXT, - description TEXT, - is_active INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + race_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + date TEXT, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + bib_filter_enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS stations ( @@ -34,6 +35,14 @@ async def init_race_tables(db: aiosqlite.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order); """) + # Migrering: legg til kolonne for eksisterende databaser + try: + await db.execute( + "ALTER TABLE races ADD COLUMN bib_filter_enabled INTEGER NOT NULL DEFAULT 0" + ) + await db.commit() + except Exception: + pass # Kolonnen finnes allerede await db.commit() @@ -44,11 +53,12 @@ async def create_race( name: str, date: Optional[str] = None, description: Optional[str] = None, + bib_filter_enabled: bool = False, ) -> dict: race_id = str(uuid.uuid4()) await db.execute( - "INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)", - (race_id, name, date, description), + "INSERT INTO races (race_id, name, date, description, bib_filter_enabled) VALUES (?, ?, ?, ?, ?)", + (race_id, name, date, description, int(bib_filter_enabled)), ) # Sett som aktivt hvis det er første løpet async with db.execute("SELECT COUNT(*) FROM races") as cur: @@ -86,10 +96,11 @@ async def update_race( name: str, date: Optional[str], description: Optional[str], + bib_filter_enabled: bool = False, ) -> bool: cur = await db.execute( - "UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?", - (name, date, description, race_id), + "UPDATE races SET name = ?, date = ?, description = ?, bib_filter_enabled = ? WHERE race_id = ?", + (name, date, description, int(bib_filter_enabled), race_id), ) await db.commit() return cur.rowcount > 0 diff --git a/frontend/src/api.js b/frontend/src/api.js index 0fc73a0..0bf04d0 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -51,6 +51,7 @@ export const api = { body: JSON.stringify(body), }), deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }), + reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }), // Resultater getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`), diff --git a/frontend/src/pages/RacePage.jsx b/frontend/src/pages/RacePage.jsx index 1fb1df0..a4377e2 100644 --- a/frontend/src/pages/RacePage.jsx +++ b/frontend/src/pages/RacePage.jsx @@ -63,6 +63,18 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) { const [expanded, setExpanded] = useState(false) const [newStation, setNewStation] = useState({ display_name: '', name: '' }) const [adding, setAdding] = useState(false) + const [bibFilter, setBibFilter] = useState(!!race.bib_filter_enabled) + + const handleBibFilterToggle = async (enabled) => { + setBibFilter(enabled) + await api.updateRace(race.race_id, { + name: race.name, + date: race.date || null, + description: race.description || null, + bib_filter_enabled: enabled, + }) + onUpdated() + } const loadStations = async () => { const s = await api.getStations(race.race_id) @@ -99,6 +111,10 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) { {race.date && {race.date}} {isActive && Aktivt} {race.description &&
{race.description}
} +