9c10124611
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>
209 lines
6.2 KiB
Python
209 lines
6.2 KiB
Python
"""
|
|
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
|
|
from typing import Optional
|
|
|
|
import aiosqlite
|
|
|
|
DEDUP_WINDOW_SECONDS = 2
|
|
|
|
|
|
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 der nytt bilde faller
|
|
innen DEDUP_WINDOW_SECONDS etter siste registrerte bilde i passeringen.
|
|
"""
|
|
async with db.execute(
|
|
"""
|
|
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,
|
|
(timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat(),
|
|
),
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
async def log_passage(
|
|
db: aiosqlite.Connection,
|
|
*,
|
|
profile_id: Optional[str],
|
|
bib_number: Optional[str],
|
|
station: str,
|
|
timestamp_utc: datetime,
|
|
gps_lat: float,
|
|
gps_lon: float,
|
|
gps_alt: Optional[float],
|
|
confidence: float,
|
|
proximity_score: float = 0.0,
|
|
id_method: str,
|
|
source_image: str,
|
|
needs_review: bool = False,
|
|
review_note: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Logg ett bilde som en passering.
|
|
|
|
- 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.
|
|
"""
|
|
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, id_method, source_image,
|
|
needs_review, review_note
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
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
|
|
|
|
|
|
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(
|
|
db: aiosqlite.Connection,
|
|
profile_id: Optional[str] = None,
|
|
station: Optional[str] = None,
|
|
needs_review: Optional[bool] = None,
|
|
) -> list[dict]:
|
|
clauses = []
|
|
params = []
|
|
if profile_id is not None:
|
|
clauses.append("p.profile_id = ?")
|
|
params.append(profile_id)
|
|
if station is not None:
|
|
clauses.append("p.station = ?")
|
|
params.append(station)
|
|
if needs_review is not None:
|
|
clauses.append("p.needs_review = ?")
|
|
params.append(int(needs_review))
|
|
|
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
query = f"""
|
|
SELECT p.*, a.name, a.club
|
|
FROM passages p
|
|
LEFT JOIN athletes a ON a.profile_id = p.profile_id
|
|
{where}
|
|
ORDER BY p.timestamp_utc
|
|
"""
|
|
async with db.execute(query, params) as cur:
|
|
rows = await cur.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
async def resolve_passage(
|
|
db: aiosqlite.Connection,
|
|
passage_id: str,
|
|
profile_id: Optional[str],
|
|
bib_number: Optional[str],
|
|
review_note: Optional[str] = None,
|
|
) -> bool:
|
|
cur = await db.execute(
|
|
"""
|
|
UPDATE passages
|
|
SET profile_id = ?, bib_number = ?, needs_review = 0,
|
|
review_note = ?, id_method = 'manual'
|
|
WHERE passage_id = ?
|
|
""",
|
|
(profile_id, bib_number, review_note, passage_id),
|
|
)
|
|
await db.commit()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
|
|
cur = await db.execute("DELETE FROM passages WHERE passage_id = ?", (passage_id,))
|
|
await db.commit()
|
|
return cur.rowcount > 0
|