From 9c101246119c191fbd98c75a83a46a52a15f8902 Mon Sep 17 00:00:00 2001 From: steinhelge Date: Fri, 20 Mar 2026 15:27:39 +0100 Subject: [PATCH] Keep all burst images, use last timestamp as passage time - passage_images table stores every image in a burst sequence - Passage timestamp = last image (chronologically) in the burst - Review UI: image slider to browse all burst images, slider ends at the official passage time (rightmost = last image) - API: GET /api/passages/{id}/images Co-Authored-By: Claude Sonnet 4.6 --- backend/api.py | 8 +- backend/passage_log.py | 167 ++++++++++++++++-------------- backend/profile_db.py | 11 +- frontend/src/api.js | 1 + frontend/src/pages/ReviewPage.jsx | 95 +++++++++++------ 5 files changed, 170 insertions(+), 112 deletions(-) diff --git a/backend/api.py b/backend/api.py index ff1a4f3..f3d9bd6 100644 --- a/backend/api.py +++ b/backend/api.py @@ -16,7 +16,7 @@ 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 passage_log import delete_passage, get_passage_images, get_passages, log_passage, resolve_passage from profile_db import ( clear_startlist, delete_athlete, @@ -133,6 +133,12 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti return {"ok": True} +@app.get("/api/passages/{passage_id}/images") +async def passage_images(passage_id: str, db=Depends(get_connection)): + """Hent alle bilder for en passering, kronologisk sortert.""" + 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) diff --git a/backend/passage_log.py b/backend/passage_log.py index 86c8de8..172502f 100644 --- a/backend/passage_log.py +++ b/backend/passage_log.py @@ -1,41 +1,47 @@ """ Skriv og query passeringslogg i SQLite. + +Deduplisering: + Bilder av samme utøver (bib) ved samme stasjon innen DEDUP_WINDOW_SECONDS + grupperes under én passering. Alle bilder bevares i passage_images. + Passeringstidsstempel = siste bilde i burst-sekvensen (nyeste timestamp). """ import uuid -from datetime import datetime, timedelta, timezone -from pathlib import Path +from datetime import datetime, timedelta from typing import Optional import aiosqlite -# Tidsvindu for deduplisering: bilder av samme utøver ved samme stasjon DEDUP_WINDOW_SECONDS = 2 -async def _find_duplicate( +async def _find_active_passage( db: aiosqlite.Connection, bib_number: str, station: str, timestamp_utc: datetime, ) -> Optional[dict]: """ - Finn eksisterende passering med samme bib og stasjon innen DEDUP_WINDOW_SECONDS. - Returnerer raden, eller None. + Finn eksisterende passering med samme bib og stasjon der nytt bilde faller + innen DEDUP_WINDOW_SECONDS etter siste registrerte bilde i passeringen. """ - window_start = (timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat() - window_end = (timestamp_utc + timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat() - async with db.execute( """ - SELECT passage_id, proximity_score, source_image - FROM passages - WHERE bib_number = ? AND station = ? - AND timestamp_utc BETWEEN ? AND ? - ORDER BY proximity_score DESC + SELECT p.passage_id, MAX(pi.timestamp_utc) AS last_image_ts + FROM passages p + JOIN passage_images pi ON pi.passage_id = p.passage_id + WHERE p.bib_number = ? AND p.station = ? + GROUP BY p.passage_id + HAVING last_image_ts >= ? + ORDER BY last_image_ts DESC LIMIT 1 """, - (bib_number, station, window_start, window_end), + ( + bib_number, + station, + (timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat(), + ), ) as cur: row = await cur.fetchone() return dict(row) if row else None @@ -59,83 +65,90 @@ async def log_passage( review_note: Optional[str] = None, ) -> str: """ - Logg én passering med deduplisering. + Logg ett bilde som en passering. - Hvis et bilde av samme utøver ved samme stasjon allerede er logget innen - DEDUP_WINDOW_SECONDS, beholder vi bildet nærmest kamera (høyest proximity_score) - ettersom det gir det mest nøyaktige tidsstempelet. + - Er bildet del av en pågående burst (bib+stasjon innen vinduet)? + → Legg til i passage_images, oppdater passeringstidsstempel til dette + bildet (siste i tid = offisiell passeringstid). + - Nytt bib/stasjon eller utenfor vinduet? + → Opprett ny passering. - Returnerer passage_id (enten ny eller eksisterende). + Returnerer passage_id. """ - if bib_number: - duplicate = await _find_duplicate(db, bib_number, station, timestamp_utc) - if duplicate: - if proximity_score > duplicate["proximity_score"]: - # Nytt bilde er nærmere kamera — oppdater tidsstempel og bildesti - old_image = duplicate["source_image"] - await db.execute( - """ - UPDATE passages - SET timestamp_utc = ?, proximity_score = ?, - source_image = ?, confidence = ?, id_method = ? - WHERE passage_id = ? - """, - ( - timestamp_utc.isoformat(), - proximity_score, - source_image, - confidence, - id_method, - duplicate["passage_id"], - ), - ) - await db.commit() - # Slett det gamle, dårligere bildet - _delete_image_file(old_image) - return duplicate["passage_id"] - else: - # Eksisterende bilde er nærmere kamera — forkast nytt bilde - _delete_image_file(source_image) - return duplicate["passage_id"] + image_id = str(uuid.uuid4()) + if bib_number: + existing = await _find_active_passage(db, bib_number, station, timestamp_utc) + if existing: + passage_id = existing["passage_id"] + # Legg til bilde i sekvensen + await db.execute( + """ + INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score) + VALUES (?, ?, ?, ?, ?) + """, + (image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score), + ) + # Oppdater passeringen med siste bilde som offisiell tid og bildesti + await db.execute( + """ + UPDATE passages + SET timestamp_utc = ?, source_image = ?, confidence = ?, id_method = ? + WHERE passage_id = ? + """, + ( + timestamp_utc.isoformat(), + source_image, + confidence, + id_method, + passage_id, + ), + ) + await db.commit() + return passage_id + + # Ny passering 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, proximity_score, id_method, source_image, + confidence, id_method, source_image, needs_review, review_note - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - passage_id, - profile_id, - bib_number, - station, - timestamp_utc.isoformat(), - gps_lat, - gps_lon, - gps_alt, - confidence, - proximity_score, - id_method, - source_image, - int(needs_review), - review_note, + 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.execute( + """ + INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score) + VALUES (?, ?, ?, ?, ?) + """, + (image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score), + ) await db.commit() return passage_id -def _delete_image_file(path: str) -> None: - """Slett bildefil stille — logg advarsel ved feil.""" - import logging - try: - Path(path).unlink(missing_ok=True) - except Exception as e: - logging.getLogger(__name__).warning("Kunne ikke slette duplikatbilde %s: %s", path, e) +async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[dict]: + """Hent alle bilder for en passering, kronologisk sortert.""" + async with db.execute( + """ + SELECT image_id, image_path, timestamp_utc, proximity_score + FROM passage_images + WHERE passage_id = ? + ORDER BY timestamp_utc + """, + (passage_id,), + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] async def get_passages( @@ -144,10 +157,8 @@ async def get_passages( 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) @@ -159,7 +170,6 @@ async def get_passages( params.append(int(needs_review)) where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - query = f""" SELECT p.*, a.name, a.club FROM passages p @@ -179,7 +189,6 @@ async def resolve_passage( bib_number: Optional[str], review_note: Optional[str] = None, ) -> bool: - """Manuell oppdatering av en passering etter gjennomgang.""" cur = await db.execute( """ UPDATE passages @@ -194,8 +203,6 @@ async def resolve_passage( async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool: - cur = await db.execute( - "DELETE FROM passages WHERE passage_id = ?", (passage_id,) - ) + cur = await db.execute("DELETE FROM passages WHERE passage_id = ?", (passage_id,)) await db.commit() return cur.rowcount > 0 diff --git a/backend/profile_db.py b/backend/profile_db.py index ba4bdc1..d65d7f9 100644 --- a/backend/profile_db.py +++ b/backend/profile_db.py @@ -33,7 +33,6 @@ async def init_db(db: aiosqlite.Connection) -> None: gps_lon REAL, gps_alt REAL, confidence REAL, - proximity_score REAL NOT NULL DEFAULT 0, id_method TEXT, source_image TEXT, needs_review INTEGER NOT NULL DEFAULT 0, @@ -41,9 +40,19 @@ async def init_db(db: aiosqlite.Connection) -> None: created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS passage_images ( + image_id TEXT PRIMARY KEY, + passage_id TEXT NOT NULL REFERENCES passages(passage_id) ON DELETE CASCADE, + image_path TEXT NOT NULL, + timestamp_utc TEXT NOT NULL, + proximity_score REAL NOT NULL DEFAULT 0, + 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); + CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id); """) await db.commit() diff --git a/frontend/src/api.js b/frontend/src/api.js index 30f5318..dbfead6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -28,6 +28,7 @@ export const api = { return request(`/passages${qs ? '?' + qs : ''}`) }, getReviewQueue: () => request('/passages/review'), + getPassageImages: (id) => request(`/passages/${id}/images`), resolvePassage: (id, body) => request(`/passages/${id}/resolve`, { method: 'POST', diff --git a/frontend/src/pages/ReviewPage.jsx b/frontend/src/pages/ReviewPage.jsx index 282f434..58b20b7 100644 --- a/frontend/src/pages/ReviewPage.jsx +++ b/frontend/src/pages/ReviewPage.jsx @@ -3,7 +3,64 @@ import { api } from '../api.js' function fmtTs(ts) { if (!ts) return '—' - return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' }) + return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false }) +} + +function imgSrc(path) { + if (!path) return null + return `/api/images/${path.replace(/^\/processed\//, '')}` +} + +function ImageSlider({ passageId, currentImage }) { + const [images, setImages] = useState([]) + const [index, setIndex] = useState(0) + + useEffect(() => { + api.getPassageImages(passageId).then(imgs => { + setImages(imgs) + // Start på siste bilde (passeringstidspunktet) + setIndex(imgs.length > 0 ? imgs.length - 1 : 0) + }) + }, [passageId]) + + if (images.length === 0) { + const src = imgSrc(currentImage) + return src + ? Passeringsbilde + :
Bilde ikke tilgjengelig
+ } + + const current = images[index] + + return ( +
+ {`Bilde + {images.length > 1 && ( +
+ setIndex(Number(e.target.value))} + style={{ width: '100%' }} + /> +
+ {fmtTs(images[0].timestamp_utc)} + + {index + 1} / {images.length} + {index === images.length - 1 && ' — passeringstid'} + + {fmtTs(images[images.length - 1].timestamp_utc)} +
+
+ )} +
+ ) } function ReviewCard({ passage, athletes, onResolved, onDeleted }) { @@ -33,28 +90,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) { onDeleted() } - // Bygg bildesti relativt til /api/images/ - const imgSrc = passage.source_image - ? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}` - : null - return (
- {imgSrc ? ( - Passeringsbilde - ) : ( -
- Bilde ikke tilgjengelig -
- )} +
- + @@ -71,7 +117,7 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) { /> {matchedAthlete && ( - {matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''} + {matchedAthlete.name}{matchedAthlete.club ? ` (${matchedAthlete.club})` : ''} )} {bib && !matchedAthlete && ( @@ -92,16 +138,10 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
- - +
@@ -124,20 +164,15 @@ export default function ReviewPage() { useEffect(() => { load() }, []) - const removeFromQueue = (passageId) => { - setQueue(q => q.filter(p => p.passage_id !== passageId)) - } + const removeFromQueue = (passageId) => setQueue(q => q.filter(p => p.passage_id !== passageId)) return ( <>

Manuell gjennomgang

- {loading ? (

Laster...

) : queue.length === 0 ? ( -
-

Ingen passeringer venter på gjennomgang.

-
+

Ingen passeringer venter på gjennomgang.

) : ( <>
Stasjon{passage.station}
Tidspunkt{fmtTs(passage.timestamp_utc)}
Passeringstid{fmtTs(passage.timestamp_utc)}
OCR-resultat{passage.bib_number || 'ingen'}
Konfidens{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}
Merknad{passage.review_note || '—'}