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 typing import Optional
from ingest import watch_depot, process_existing 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 ( from profile_db import (
clear_startlist, clear_startlist,
delete_athlete, delete_athlete,
@@ -133,6 +133,12 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
return {"ok": True} 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}") @app.delete("/api/passages/{passage_id}")
async def remove_passage(passage_id: str, db=Depends(get_connection)): async def remove_passage(passage_id: str, db=Depends(get_connection)):
ok = await delete_passage(db, passage_id) ok = await delete_passage(db, passage_id)
+73 -66
View File
@@ -1,41 +1,47 @@
""" """
Skriv og query passeringslogg i SQLite. 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 import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional from typing import Optional
import aiosqlite import aiosqlite
# Tidsvindu for deduplisering: bilder av samme utøver ved samme stasjon
DEDUP_WINDOW_SECONDS = 2 DEDUP_WINDOW_SECONDS = 2
async def _find_duplicate( async def _find_active_passage(
db: aiosqlite.Connection, db: aiosqlite.Connection,
bib_number: str, bib_number: str,
station: str, station: str,
timestamp_utc: datetime, timestamp_utc: datetime,
) -> Optional[dict]: ) -> Optional[dict]:
""" """
Finn eksisterende passering med samme bib og stasjon innen DEDUP_WINDOW_SECONDS. Finn eksisterende passering med samme bib og stasjon der nytt bilde faller
Returnerer raden, eller None. 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( async with db.execute(
""" """
SELECT passage_id, proximity_score, source_image SELECT p.passage_id, MAX(pi.timestamp_utc) AS last_image_ts
FROM passages FROM passages p
WHERE bib_number = ? AND station = ? JOIN passage_images pi ON pi.passage_id = p.passage_id
AND timestamp_utc BETWEEN ? AND ? WHERE p.bib_number = ? AND p.station = ?
ORDER BY proximity_score DESC GROUP BY p.passage_id
HAVING last_image_ts >= ?
ORDER BY last_image_ts DESC
LIMIT 1 LIMIT 1
""", """,
(bib_number, station, window_start, window_end), (
bib_number,
station,
(timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat(),
),
) as cur: ) as cur:
row = await cur.fetchone() row = await cur.fetchone()
return dict(row) if row else None return dict(row) if row else None
@@ -59,83 +65,90 @@ async def log_passage(
review_note: Optional[str] = None, review_note: Optional[str] = None,
) -> str: ) -> 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 - Er bildet del av en pågående burst (bib+stasjon innen vinduet)?
DEDUP_WINDOW_SECONDS, beholder vi bildet nærmest kamera (høyest proximity_score) → Legg til i passage_images, oppdater passeringstidsstempel til dette
ettersom det gir det mest nøyaktige tidsstempelet. 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.
""" """
image_id = str(uuid.uuid4())
if bib_number: if bib_number:
duplicate = await _find_duplicate(db, bib_number, station, timestamp_utc) existing = await _find_active_passage(db, bib_number, station, timestamp_utc)
if duplicate: if existing:
if proximity_score > duplicate["proximity_score"]: passage_id = existing["passage_id"]
# Nytt bilde er nærmere kamera — oppdater tidsstempel og bildesti # Legg til bilde i sekvensen
old_image = duplicate["source_image"] 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( await db.execute(
""" """
UPDATE passages UPDATE passages
SET timestamp_utc = ?, proximity_score = ?, SET timestamp_utc = ?, source_image = ?, confidence = ?, id_method = ?
source_image = ?, confidence = ?, id_method = ?
WHERE passage_id = ? WHERE passage_id = ?
""", """,
( (
timestamp_utc.isoformat(), timestamp_utc.isoformat(),
proximity_score,
source_image, source_image,
confidence, confidence,
id_method, id_method,
duplicate["passage_id"], passage_id,
), ),
) )
await db.commit() await db.commit()
# Slett det gamle, dårligere bildet return passage_id
_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"]
# Ny passering
passage_id = str(uuid.uuid4()) passage_id = str(uuid.uuid4())
await db.execute( await db.execute(
""" """
INSERT INTO passages ( INSERT INTO passages (
passage_id, profile_id, bib_number, station, passage_id, profile_id, bib_number, station,
timestamp_utc, gps_lat, gps_lon, gps_alt, timestamp_utc, gps_lat, gps_lon, gps_alt,
confidence, proximity_score, id_method, source_image, confidence, id_method, source_image,
needs_review, review_note needs_review, review_note
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
passage_id, passage_id, profile_id, bib_number, station,
profile_id, timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
bib_number, confidence, id_method, source_image,
station, int(needs_review), review_note,
timestamp_utc.isoformat(),
gps_lat,
gps_lon,
gps_alt,
confidence,
proximity_score,
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() await db.commit()
return passage_id return passage_id
def _delete_image_file(path: str) -> None: async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[dict]:
"""Slett bildefil stille — logg advarsel ved feil.""" """Hent alle bilder for en passering, kronologisk sortert."""
import logging async with db.execute(
try: """
Path(path).unlink(missing_ok=True) SELECT image_id, image_path, timestamp_utc, proximity_score
except Exception as e: FROM passage_images
logging.getLogger(__name__).warning("Kunne ikke slette duplikatbilde %s: %s", path, e) 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( async def get_passages(
@@ -144,10 +157,8 @@ async def get_passages(
station: Optional[str] = None, station: Optional[str] = None,
needs_review: Optional[bool] = None, needs_review: Optional[bool] = None,
) -> list[dict]: ) -> list[dict]:
"""Hent passeringer med valgfrie filtre."""
clauses = [] clauses = []
params = [] params = []
if profile_id is not None: if profile_id is not None:
clauses.append("p.profile_id = ?") clauses.append("p.profile_id = ?")
params.append(profile_id) params.append(profile_id)
@@ -159,7 +170,6 @@ async def get_passages(
params.append(int(needs_review)) params.append(int(needs_review))
where = ("WHERE " + " AND ".join(clauses)) if clauses else "" where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
query = f""" query = f"""
SELECT p.*, a.name, a.club SELECT p.*, a.name, a.club
FROM passages p FROM passages p
@@ -179,7 +189,6 @@ async def resolve_passage(
bib_number: Optional[str], bib_number: Optional[str],
review_note: Optional[str] = None, review_note: Optional[str] = None,
) -> bool: ) -> bool:
"""Manuell oppdatering av en passering etter gjennomgang."""
cur = await db.execute( cur = await db.execute(
""" """
UPDATE passages UPDATE passages
@@ -194,8 +203,6 @@ async def resolve_passage(
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool: async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
cur = await db.execute( cur = await db.execute("DELETE FROM passages WHERE passage_id = ?", (passage_id,))
"DELETE FROM passages WHERE passage_id = ?", (passage_id,)
)
await db.commit() await db.commit()
return cur.rowcount > 0 return cur.rowcount > 0
+10 -1
View File
@@ -33,7 +33,6 @@ async def init_db(db: aiosqlite.Connection) -> None:
gps_lon REAL, gps_lon REAL,
gps_alt REAL, gps_alt REAL,
confidence REAL, confidence REAL,
proximity_score REAL NOT NULL DEFAULT 0,
id_method TEXT, id_method TEXT,
source_image TEXT, source_image TEXT,
needs_review INTEGER NOT NULL DEFAULT 0, 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')) 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_profile ON passages(profile_id);
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station); 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_passages_needs_review ON passages(needs_review);
CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id);
""") """)
await db.commit() await db.commit()
+1
View File
@@ -28,6 +28,7 @@ export const api = {
return request(`/passages${qs ? '?' + qs : ''}`) return request(`/passages${qs ? '?' + qs : ''}`)
}, },
getReviewQueue: () => request('/passages/review'), getReviewQueue: () => request('/passages/review'),
getPassageImages: (id) => request(`/passages/${id}/images`),
resolvePassage: (id, body) => resolvePassage: (id, body) =>
request(`/passages/${id}/resolve`, { request(`/passages/${id}/resolve`, {
method: 'POST', method: 'POST',
+64 -29
View File
@@ -3,7 +3,64 @@ import { api } from '../api.js'
function fmtTs(ts) { function fmtTs(ts) {
if (!ts) return '—' 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
? <img src={src} alt="Passeringsbilde" className="image-preview" />
: <div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
}
const current = images[index]
return (
<div>
<img
src={imgSrc(current.image_path)}
alt={`Bilde ${index + 1} av ${images.length}`}
className="image-preview"
/>
{images.length > 1 && (
<div style={{ marginTop: '0.5rem' }}>
<input
type="range"
min={0}
max={images.length - 1}
value={index}
onChange={e => setIndex(Number(e.target.value))}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', marginTop: '0.2rem' }}>
<span>{fmtTs(images[0].timestamp_utc)}</span>
<span style={{ color: index === images.length - 1 ? '#2ecc71' : '#888', fontWeight: index === images.length - 1 ? 600 : 400 }}>
{index + 1} / {images.length}
{index === images.length - 1 && ' — passeringstid'}
</span>
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
</div>
</div>
)}
</div>
)
} }
function ReviewCard({ passage, athletes, onResolved, onDeleted }) { function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
@@ -33,28 +90,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
onDeleted() onDeleted()
} }
// Bygg bildesti relativt til /api/images/
const imgSrc = passage.source_image
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
: null
return ( return (
<div className="card"> <div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div> <div>
{imgSrc ? ( <ImageSlider passageId={passage.passage_id} currentImage={passage.source_image} />
<img src={imgSrc} alt="Passeringsbilde" className="image-preview" />
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>
Bilde ikke tilgjengelig
</div>
)}
</div> </div>
<div> <div>
<table style={{ marginBottom: '1rem' }}> <table style={{ marginBottom: '1rem' }}>
<tbody> <tbody>
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr> <tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
<tr><td><strong>Tidspunkt</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr> <tr><td><strong>Passeringstid</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr>
<tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr> <tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr>
<tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr> <tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr>
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr> <tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
@@ -92,16 +138,10 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<button <button className="btn-success" onClick={handleResolve} disabled={saving}>
className="btn-success"
onClick={handleResolve}
disabled={saving}
>
{saving ? 'Lagrer...' : 'Bekreft'} {saving ? 'Lagrer...' : 'Bekreft'}
</button> </button>
<button className="btn-danger" onClick={handleDelete}> <button className="btn-danger" onClick={handleDelete}>Slett</button>
Slett
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -124,20 +164,15 @@ export default function ReviewPage() {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const removeFromQueue = (passageId) => { const removeFromQueue = (passageId) => setQueue(q => q.filter(p => p.passage_id !== passageId))
setQueue(q => q.filter(p => p.passage_id !== passageId))
}
return ( return (
<> <>
<h1>Manuell gjennomgang</h1> <h1>Manuell gjennomgang</h1>
{loading ? ( {loading ? (
<p>Laster...</p> <p>Laster...</p>
) : queue.length === 0 ? ( ) : queue.length === 0 ? (
<div className="card"> <div className="card"><p className="empty-state">Ingen passeringer venter gjennomgang.</p></div>
<p className="empty-state">Ingen passeringer venter gjennomgang.</p>
</div>
) : ( ) : (
<> <>
<div className="alert alert-info" style={{ marginBottom: '1rem' }}> <div className="alert alert-info" style={{ marginBottom: '1rem' }}>