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
+ ?
+ :
| 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 || '—'} |