Keep all burst images, use last timestamp as passage time
Build & Deploy / build-and-deploy (push) Failing after 2m41s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 15:27:39 +01:00
parent 24645dfd11
commit 9c10124611
5 changed files with 170 additions and 112 deletions
+7 -1
View File
@@ -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)
+87 -80
View File
@@ -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
+10 -1
View File
@@ -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()