24645dfd11
Build & Deploy / build-and-deploy (push) Has been cancelled
Within a burst sequence from the same station, the image where the athlete is physically closest to the camera gives the most accurate passage timestamp. Proximity is measured by bib bounding box area (larger = closer). When a duplicate is detected: - New image closer: update timestamp + image path, delete old image - Existing image closer: discard new image Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.0 KiB
Python
202 lines
6.0 KiB
Python
"""
|
|
Skriv og query passeringslogg i SQLite.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
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(
|
|
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.
|
|
"""
|
|
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
|
|
LIMIT 1
|
|
""",
|
|
(bib_number, station, window_start, window_end),
|
|
) 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 én passering med deduplisering.
|
|
|
|
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.
|
|
|
|
Returnerer passage_id (enten ny eller eksisterende).
|
|
"""
|
|
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"]
|
|
|
|
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,
|
|
needs_review, review_note
|
|
) 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,
|
|
),
|
|
)
|
|
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_passages(
|
|
db: aiosqlite.Connection,
|
|
profile_id: Optional[str] = None,
|
|
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)
|
|
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:
|
|
"""Manuell oppdatering av en passering etter gjennomgang."""
|
|
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
|