Støtte for flere bibs per bilde, EXIF-metadata og zoom i gjennomgang
Build & Deploy / build-and-deploy (push) Successful in 46s

- OCR: ny read_all_bibs() returnerer alle unike startnumre (≥2 sifre) per bilde
- Ingest: oppretter én passering per bib (ikke bare beste), ingen bib → needs_review
- image_tagger.py: skriv/les bib-metadata som JSON i EXIF UserComment (piexif)
- Ingest + resolve: tagger bildefilen med bibs automatisk og ved manuell bekreftelse
- API: POST /api/passages/{id}/reanalyze — re-kjør OCR på eksisterende bilde
- API: POST /api/passages/{id}/resolve oppdaterer nå EXIF med bekreftet bib
- races: ny kolonne bib_filter_enabled (med automatisk migrering) + per-løp toggle
- ReviewPage: Re-analyser-knapp og klikk-for-zoom med scroll/drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 09:00:24 +01:00
parent 018f84efd8
commit 45f7a77171
8 changed files with 526 additions and 128 deletions
+103 -2
View File
@@ -68,6 +68,7 @@ class RaceRequest(BaseModel):
name: str name: str
date: Optional[str] = None date: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
bib_filter_enabled: bool = False
@app.get("/api/races") @app.get("/api/races")
@@ -83,12 +84,12 @@ async def active_race(db=Depends(get_connection)):
@app.post("/api/races") @app.post("/api/races")
async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)): async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)):
return await create_race(db, body.name, body.date, body.description) return await create_race(db, body.name, body.date, body.description, body.bib_filter_enabled)
@app.put("/api/races/{race_id}") @app.put("/api/races/{race_id}")
async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)): async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)):
ok = await update_race(db, race_id, body.name, body.date, body.description) ok = await update_race(db, race_id, body.name, body.date, body.description, body.bib_filter_enabled)
if not ok: if not ok:
raise HTTPException(404, "Løp ikke funnet") raise HTTPException(404, "Løp ikke funnet")
return await get_race(db, race_id) return await get_race(db, race_id)
@@ -223,6 +224,30 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
review_note=body.review_note) review_note=body.review_note)
if not ok: if not ok:
raise HTTPException(404, "Passering ikke funnet") raise HTTPException(404, "Passering ikke funnet")
# Oppdater EXIF-metadata med bekreftet startnummer
if body.bib_number:
from image_tagger import write_bib_tags, read_bib_tags
async with db.execute(
"SELECT source_image, station, race_id FROM passages WHERE passage_id = ?",
(passage_id,),
) as cur:
row = await cur.fetchone()
if row:
img_path = Path(row["source_image"])
if img_path.exists():
# Behold allerede taggede bibs, legg til bekreftet
existing = read_bib_tags(img_path)
all_bibs = list(dict.fromkeys(
(existing or {}).get("bibs", []) + [body.bib_number]
))
write_bib_tags(
img_path, all_bibs,
station=row["station"],
race_id=row["race_id"],
confirmed=True,
)
return {"ok": True} return {"ok": True}
@@ -239,6 +264,82 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)):
return {"ok": True} return {"ok": True}
@app.post("/api/passages/{passage_id}/reanalyze")
async def reanalyze_passage(passage_id: str, db=Depends(get_connection)):
"""
Kjør OCR på nytt på passeringens kildebilde.
Oppretter nye passeringer for eventuelle startnumre som ikke allerede er logget fra dette bildet.
Returnerer alle funne startnumre og eventuelle nye passage_id-er.
"""
from datetime import timezone as _tz
from ocr import read_all_bibs
from ingest import MIN_AUTO_CONFIDENCE
from profile_db import get_athlete_by_bib
async with db.execute("SELECT * FROM passages WHERE passage_id = ?", (passage_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "Passering ikke funnet")
passage = dict(row)
image_path = Path(passage["source_image"])
if not image_path.exists():
raise HTTPException(404, "Kildebilde ikke funnet på disk")
bibs = read_all_bibs(image_path)
# Finn bib-numre som allerede er logget fra dette bildet
async with db.execute(
"SELECT bib_number FROM passages WHERE source_image = ?", (passage["source_image"],)
) as cur:
existing_bibs = {r["bib_number"] for r in await cur.fetchall()}
new_passages = []
ts_str = passage["timestamp_utc"]
ts = datetime.fromisoformat(ts_str)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=_tz.utc)
for ocr in bibs:
if ocr.digits in existing_bibs:
continue
confidence = ocr.confidence
needs_review = confidence < MIN_AUTO_CONFIDENCE
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
review_note = "low_confidence" if needs_review else None
profile_id = None
if ocr.digits and not needs_review:
athlete = await get_athlete_by_bib(db, ocr.digits)
if athlete:
profile_id = athlete["profile_id"]
new_id = await log_passage(
db,
race_id=passage["race_id"],
profile_id=profile_id,
bib_number=ocr.digits,
station=passage["station"],
timestamp_utc=ts,
gps_lat=passage["gps_lat"],
gps_lon=passage["gps_lon"],
gps_alt=passage["gps_alt"],
confidence=confidence,
proximity_score=ocr.proximity_score,
id_method=id_method,
source_image=passage["source_image"],
needs_review=needs_review,
review_note=review_note,
)
new_passages.append({"passage_id": new_id, "bib_number": ocr.digits, "confidence": confidence})
return {
"found_bibs": [ocr.digits for ocr in bibs],
"new_passages": new_passages,
}
# ===================== # =====================
# Resultater # Resultater
# ===================== # =====================
+89
View File
@@ -0,0 +1,89 @@
"""
Skriv og les bib-metadata i bildefiler via EXIF UserComment (JSON).
Bruker piexif + Pillow som allerede er i requirements.txt.
Metadata lagres i Exif.UserComment som:
b"ASCII\x00\x00\x00" + JSON-bytes
Eksempel-innhold:
{"bibs": ["42", "87"], "station": "finish", "confidence": 0.93, "confirmed": true}
"""
import json
import logging
from pathlib import Path
from typing import Optional
import piexif
from PIL import Image
logger = logging.getLogger(__name__)
_ASCII_PREFIX = b"ASCII\x00\x00\x00"
def write_bib_tags(
image_path: Path,
bibs: list[str],
*,
station: Optional[str] = None,
race_id: Optional[str] = None,
confidence: Optional[float] = None,
confirmed: bool = False,
) -> None:
"""
Skriv startnummer-metadata til bildefil som EXIF UserComment (JSON).
Bevarer all eksisterende EXIF. Feiler stille — krasjer ikke ingest-prosessen.
"""
data: dict = {"bibs": bibs}
if station:
data["station"] = station
if race_id:
data["race_id"] = race_id
if confidence is not None:
data["confidence"] = round(confidence, 4)
if confirmed:
data["confirmed"] = True
json_bytes = json.dumps(data, ensure_ascii=True, separators=(",", ":")).encode("ascii")
comment_bytes = _ASCII_PREFIX + json_bytes
try:
img = Image.open(image_path)
raw_exif = img.info.get("exif")
if raw_exif:
exif_dict = piexif.load(raw_exif)
else:
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}}
exif_dict.setdefault("Exif", {})[piexif.ExifIFD.UserComment] = comment_bytes
new_exif = piexif.dump(exif_dict)
# Lagre til midlertidig fil, rename for atomisk skriving
tmp = image_path.with_suffix(".tmp" + image_path.suffix)
img.save(tmp, exif=new_exif)
tmp.replace(image_path)
logger.debug("EXIF-tags skrevet til %s: bibs=%s", image_path.name, bibs)
except Exception as e:
logger.warning("Kunne ikke skrive EXIF-tags til %s: %s", image_path, e)
def read_bib_tags(image_path: Path) -> Optional[dict]:
"""
Les startnummer-metadata fra EXIF UserComment.
Returnerer dict (med nøkkel 'bibs') eller None hvis ikke satt.
"""
try:
img = Image.open(image_path)
raw_exif = img.info.get("exif")
if not raw_exif:
return None
exif_dict = piexif.load(raw_exif)
comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment)
if not comment or not comment.startswith(_ASCII_PREFIX):
return None
json_str = comment[len(_ASCII_PREFIX):].decode("ascii", errors="replace")
return json.loads(json_str)
except Exception:
return None
+70 -28
View File
@@ -21,7 +21,8 @@ from watchdog.events import FileSystemEventHandler, FileCreatedEvent
from watchdog.observers import Observer from watchdog.observers import Observer
from exif_parser import ExifError, parse_image from exif_parser import ExifError, parse_image
from ocr import read_bib from image_tagger import write_bib_tags
from ocr import read_all_bibs
from passage_log import log_passage from passage_log import log_passage
from profile_db import get_or_create_athlete, init_db from profile_db import get_or_create_athlete, init_db
@@ -70,40 +71,52 @@ async def process_image(path: Path) -> None:
return return
# --- OCR --- # --- OCR ---
ocr = read_bib(path) bibs = read_all_bibs(path)
logger.debug("OCR: digits=%s conf=%.2f", ocr.digits, ocr.confidence) logger.debug("OCR: %d startnumre funnet", len(bibs))
# --- Flytt til processed/ --- # --- Flytt til processed/ ---
dest = _destination_path(path, meta.timestamp_utc) dest = _destination_path(path, meta.timestamp_utc)
shutil.move(str(path), str(dest)) shutil.move(str(path), str(dest))
logger.info("Flyttet til: %s", dest) logger.info("Flyttet til: %s", dest)
# --- Bestem konfidens og review-flagg ---
confidence = ocr.confidence
needs_review = False
review_note = None
id_method = "bib_ocr"
if ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE:
needs_review = True
review_note = "number_unreadable" if ocr.digits is None else "low_confidence"
id_method = "bib_ocr_uncertain"
# --- Koble mot profil-DB ---
profile_id = None
bib_number = ocr.digits
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
await init_db(db) await init_db(db)
if bib_number and not needs_review: if not bibs:
profile_id = await get_or_create_athlete(db, bib_number) # Ingen bib funnet — legg til manuell gjennomgang
await log_passage(
db,
profile_id=None,
bib_number=None,
station=meta.station or "unknown",
timestamp_utc=meta.timestamp_utc,
gps_lat=meta.gps_lat,
gps_lon=meta.gps_lon,
gps_alt=meta.gps_alt,
confidence=0.0,
proximity_score=0.0,
id_method="bib_ocr_uncertain",
source_image=str(dest),
needs_review=True,
review_note="number_unreadable",
)
logger.info("Passering logget (ingen bib): station=%s", meta.station)
else:
for ocr in bibs:
confidence = ocr.confidence
needs_review = confidence < MIN_AUTO_CONFIDENCE
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
review_note = "low_confidence" if needs_review else None
profile_id = None
if ocr.digits and not needs_review:
profile_id = await get_or_create_athlete(db, ocr.digits)
await log_passage( await log_passage(
db, db,
profile_id=profile_id, profile_id=profile_id,
bib_number=bib_number, bib_number=ocr.digits,
station=meta.station or "unknown", station=meta.station or "unknown",
timestamp_utc=meta.timestamp_utc, timestamp_utc=meta.timestamp_utc,
gps_lat=meta.gps_lat, gps_lat=meta.gps_lat,
@@ -118,9 +131,14 @@ async def process_image(path: Path) -> None:
) )
logger.info( logger.info(
"Passering logget: bib=%s station=%s needs_review=%s", "Passering logget: bib=%s station=%s needs_review=%s",
bib_number, meta.station, needs_review, ocr.digits, meta.station, needs_review,
) )
# Skriv alle funne bibs til EXIF-metadata i filen
found_bibs = [ocr.digits for ocr in bibs if ocr.digits]
if found_bibs:
write_bib_tags(dest, found_bibs, station=meta.station or "unknown")
async def process_image_with_override( async def process_image_with_override(
path: Path, path: Path,
@@ -148,16 +166,35 @@ async def process_image_with_override(
except ExifError: except ExifError:
timestamp = datetime.now(timezone.utc) timestamp = datetime.now(timezone.utc)
ocr = read_bib(path) bibs = read_all_bibs(path)
dest = _destination_path(path, timestamp) dest = _destination_path(path, timestamp)
shutil.move(str(path), str(dest)) shutil.move(str(path), str(dest))
confidence = ocr.confidence if not bibs:
needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE await log_passage(
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain" db,
review_note = None if not needs_review else ( race_id=race_id,
"number_unreadable" if ocr.digits is None else "low_confidence" profile_id=None,
bib_number=None,
station=station_name,
timestamp_utc=timestamp,
gps_lat=gps_lat or 0.0,
gps_lon=gps_lon or 0.0,
gps_alt=gps_alt,
confidence=0.0,
proximity_score=0.0,
id_method="bib_ocr_uncertain",
source_image=str(dest),
needs_review=True,
review_note="number_unreadable",
) )
logger.info("Passering logget (ingen bib): station=%s", station_name)
else:
for ocr in bibs:
confidence = ocr.confidence
needs_review = confidence < MIN_AUTO_CONFIDENCE
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
review_note = "low_confidence" if needs_review else None
profile_id = None profile_id = None
if ocr.digits and not needs_review: if ocr.digits and not needs_review:
@@ -182,6 +219,11 @@ async def process_image_with_override(
) )
logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name) logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name)
# Skriv alle funne bibs til EXIF-metadata i filen
found_bibs = [ocr.digits for ocr in bibs if ocr.digits]
if found_bibs:
write_bib_tags(dest, found_bibs, station=station_name, race_id=race_id)
async def process_existing() -> None: async def process_existing() -> None:
"""Behandle bilder som allerede ligger i depot/ ved oppstart.""" """Behandle bilder som allerede ligger i depot/ ved oppstart."""
+53 -35
View File
@@ -64,33 +64,11 @@ def _bbox_area(bbox) -> float:
return (max(xs) - min(xs)) * (max(ys) - min(ys)) return (max(xs) - min(xs)) * (max(ys) - min(ys))
def _extract_bib_number(texts: list[tuple]) -> tuple[Optional[str], float, bool, float]: def read_all_bibs(image_path: Path) -> list["OcrResult"]:
""" """
Finn beste siffersekvens blant OCR-treff. Les alle startnumre fra bildet.
Returnerer (sifre, konfidens, partial, proximity_score). Returnerer én OcrResult per unikt startnummer funnet (minst 2 sifre).
proximity_score = areal av bounding box i piksler² (større = nærmere kamera). Tom liste hvis ingen funnet eller ved feil.
"""
candidates = []
for (bbox, text, conf) in texts:
digits = re.sub(r"[^0-9]", "", text)
if digits:
candidates.append((digits, float(conf), _bbox_area(bbox)))
if not candidates:
return None, 0.0, False, 0.0
# Velg kandidat med høyest konfidens
best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1])
partial = len(best_digits) < 2
return best_digits, best_conf, partial, best_area
def read_bib(image_path: Path) -> OcrResult:
"""
Les startnummer fra bildet.
Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil.
""" """
try: try:
processed = _preprocess(image_path) processed = _preprocess(image_path)
@@ -98,19 +76,59 @@ def read_bib(image_path: Path) -> OcrResult:
results = reader.readtext(processed, detail=1, paragraph=False) results = reader.readtext(processed, detail=1, paragraph=False)
raw_texts = [text for (_, text, _) in results] raw_texts = [text for (_, text, _) in results]
digits, confidence, partial, proximity_score = _extract_bib_number(results)
return OcrResult( # Best konfidens + areal per unikt siffersekvens (minst 2 sifre)
best: dict[str, tuple[float, float]] = {} # digits -> (conf, area)
for (bbox, text, conf) in results:
digits = re.sub(r"[^0-9]", "", text)
if len(digits) < 2:
continue
area = _bbox_area(bbox)
if digits not in best or float(conf) > best[digits][0]:
best[digits] = (float(conf), area)
return [
OcrResult(
digits=digits, digits=digits,
confidence=confidence, confidence=conf,
partial=partial, partial=False,
proximity_score=proximity_score, proximity_score=area,
raw_texts=raw_texts, raw_texts=raw_texts,
) )
for digits, (conf, area) in best.items()
]
except Exception as e: except Exception as e:
return [OcrResult(digits=None, confidence=0.0, partial=False, raw_texts=[f"ERROR: {e}"])]
def read_bib(image_path: Path) -> OcrResult:
"""
Les beste startnummer fra bildet (bakoverkompatibel).
Returnerer OcrResult. Aldri exception — fallback til konfidens 0 ved feil.
"""
bibs = read_all_bibs(image_path)
if not bibs or bibs[0].digits is None:
# Sjekk også enkle sifre (partial) for bakoverkompatibilitet
try:
processed = _preprocess(image_path)
reader = _get_reader()
results = reader.readtext(processed, detail=1, paragraph=False)
raw_texts = [text for (_, text, _) in results]
candidates = []
for (bbox, text, conf) in results:
digits = re.sub(r"[^0-9]", "", text)
if digits:
candidates.append((digits, float(conf), _bbox_area(bbox)))
if candidates:
best_digits, best_conf, best_area = max(candidates, key=lambda x: x[1])
return OcrResult( return OcrResult(
digits=None, digits=best_digits,
confidence=0.0, confidence=best_conf,
partial=False, partial=len(best_digits) < 2,
raw_texts=[f"ERROR: {e}"], proximity_score=best_area,
raw_texts=raw_texts,
) )
except Exception:
pass
return OcrResult(digits=None, confidence=0.0, partial=False)
return max(bibs, key=lambda r: r.confidence)
+15 -4
View File
@@ -16,6 +16,7 @@ async def init_race_tables(db: aiosqlite.Connection) -> None:
date TEXT, date TEXT,
description TEXT, description TEXT,
is_active INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 0,
bib_filter_enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -34,6 +35,14 @@ async def init_race_tables(db: aiosqlite.Connection) -> None:
CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order); CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order);
""") """)
# Migrering: legg til kolonne for eksisterende databaser
try:
await db.execute(
"ALTER TABLE races ADD COLUMN bib_filter_enabled INTEGER NOT NULL DEFAULT 0"
)
await db.commit()
except Exception:
pass # Kolonnen finnes allerede
await db.commit() await db.commit()
@@ -44,11 +53,12 @@ async def create_race(
name: str, name: str,
date: Optional[str] = None, date: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
bib_filter_enabled: bool = False,
) -> dict: ) -> dict:
race_id = str(uuid.uuid4()) race_id = str(uuid.uuid4())
await db.execute( await db.execute(
"INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)", "INSERT INTO races (race_id, name, date, description, bib_filter_enabled) VALUES (?, ?, ?, ?, ?)",
(race_id, name, date, description), (race_id, name, date, description, int(bib_filter_enabled)),
) )
# Sett som aktivt hvis det er første løpet # Sett som aktivt hvis det er første løpet
async with db.execute("SELECT COUNT(*) FROM races") as cur: async with db.execute("SELECT COUNT(*) FROM races") as cur:
@@ -86,10 +96,11 @@ async def update_race(
name: str, name: str,
date: Optional[str], date: Optional[str],
description: Optional[str], description: Optional[str],
bib_filter_enabled: bool = False,
) -> bool: ) -> bool:
cur = await db.execute( cur = await db.execute(
"UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?", "UPDATE races SET name = ?, date = ?, description = ?, bib_filter_enabled = ? WHERE race_id = ?",
(name, date, description, race_id), (name, date, description, int(bib_filter_enabled), race_id),
) )
await db.commit() await db.commit()
return cur.rowcount > 0 return cur.rowcount > 0
+1
View File
@@ -51,6 +51,7 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }), deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }),
// Resultater // Resultater
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`), getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
+16
View File
@@ -63,6 +63,18 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [newStation, setNewStation] = useState({ display_name: '', name: '' }) const [newStation, setNewStation] = useState({ display_name: '', name: '' })
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [bibFilter, setBibFilter] = useState(!!race.bib_filter_enabled)
const handleBibFilterToggle = async (enabled) => {
setBibFilter(enabled)
await api.updateRace(race.race_id, {
name: race.name,
date: race.date || null,
description: race.description || null,
bib_filter_enabled: enabled,
})
onUpdated()
}
const loadStations = async () => { const loadStations = async () => {
const s = await api.getStations(race.race_id) const s = await api.getStations(race.race_id)
@@ -99,6 +111,10 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
{race.date && <span style={{ marginLeft: 8, color: '#888', fontSize: '0.85rem' }}>{race.date}</span>} {race.date && <span style={{ marginLeft: 8, color: '#888', fontSize: '0.85rem' }}>{race.date}</span>}
{isActive && <span className="badge badge-success" style={{ marginLeft: 8 }}>Aktivt</span>} {isActive && <span className="badge badge-success" style={{ marginLeft: 8 }}>Aktivt</span>}
{race.description && <p style={{ fontSize: '0.85rem', color: '#666', marginTop: 4 }}>{race.description}</p>} {race.description && <p style={{ fontSize: '0.85rem', color: '#666', marginTop: 4 }}>{race.description}</p>}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 4, fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
<input type="checkbox" checked={bibFilter} onChange={e => handleBibFilterToggle(e.target.checked)} />
Filtrer OCR mot startliste
</label>
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
{!isActive && ( {!isActive && (
+128 -8
View File
@@ -1,6 +1,68 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js' import { api } from '../api.js'
function ZoomOverlay({ src, onClose }) {
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragging, setDragging] = useState(false)
const dragStart = useRef(null)
const handleWheel = (e) => {
e.preventDefault()
setScale(s => Math.min(8, Math.max(1, s - e.deltaY * 0.005)))
}
const handleMouseDown = (e) => {
setDragging(true)
dragStart.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }
}
const handleMouseMove = (e) => {
if (!dragging || !dragStart.current) return
setOffset({ x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y })
}
const handleMouseUp = () => setDragging(false)
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab', maxWidth: '95vw', maxHeight: '95vh' }}
>
<img
src={src}
alt="Zoomet bilde"
style={{
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
transformOrigin: 'center',
display: 'block',
maxWidth: '90vw',
maxHeight: '90vh',
userSelect: 'none',
transition: dragging ? 'none' : 'transform 0.1s',
}}
draggable={false}
/>
</div>
<div style={{ position: 'absolute', top: 16, right: 20, color: '#fff', fontSize: '0.8rem', opacity: 0.7 }}>
Rull for zoom · Dra for å flytte · Klikk utenfor for å lukke
</div>
</div>
)
}
function fmtTs(ts) { function fmtTs(ts) {
if (!ts) return '—' if (!ts) return '—'
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false }) return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false })
@@ -14,31 +76,48 @@ function imgSrc(path) {
function ImageSlider({ passageId, currentImage }) { function ImageSlider({ passageId, currentImage }) {
const [images, setImages] = useState([]) const [images, setImages] = useState([])
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
const [zoomSrc, setZoomSrc] = useState(null)
useEffect(() => { useEffect(() => {
api.getPassageImages(passageId).then(imgs => { api.getPassageImages(passageId).then(imgs => {
setImages(imgs) setImages(imgs)
// Start på siste bilde (passeringstidspunktet)
setIndex(imgs.length > 0 ? imgs.length - 1 : 0) setIndex(imgs.length > 0 ? imgs.length - 1 : 0)
}) })
}, [passageId]) }, [passageId])
if (images.length === 0) { if (images.length === 0) {
const src = imgSrc(currentImage) const src = imgSrc(currentImage)
return src 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> <img
src={src}
alt="Passeringsbilde"
className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/>
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
</>
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
)
} }
const current = images[index] const current = images[index]
const src = imgSrc(current.image_path)
return ( return (
<div> <div>
<img <img
src={imgSrc(current.image_path)} src={src}
alt={`Bilde ${index + 1} av ${images.length}`} alt={`Bilde ${index + 1} av ${images.length}`}
className="image-preview" className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/> />
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
{images.length > 1 && ( {images.length > 1 && (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<input <input
@@ -63,10 +142,12 @@ function ImageSlider({ passageId, currentImage }) {
) )
} }
function ReviewCard({ passage, athletes, onResolved, onDeleted }) { function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed }) {
const [bib, setBib] = useState(passage.bib_number || '') const [bib, setBib] = useState(passage.bib_number || '')
const [note, setNote] = useState('') const [note, setNote] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [reanalyzing, setReanalyzing] = useState(false)
const [reanalyzeResult, setReanalyzeResult] = useState(null)
const matchedAthlete = athletes.find(a => a.bib_number === bib) const matchedAthlete = athletes.find(a => a.bib_number === bib)
@@ -90,6 +171,22 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
onDeleted() onDeleted()
} }
const handleReanalyze = async () => {
setReanalyzing(true)
setReanalyzeResult(null)
try {
const result = await api.reanalyzePassage(passage.passage_id)
setReanalyzeResult(result)
if (result.new_passages.length > 0) {
onReanalyzed()
}
} catch (e) {
setReanalyzeResult({ error: e.message })
} finally {
setReanalyzing(false)
}
}
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' }}>
@@ -137,12 +234,34 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
/> />
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button className="btn-success" onClick={handleResolve} disabled={saving}> <button className="btn-success" onClick={handleResolve} disabled={saving}>
{saving ? 'Lagrer...' : 'Bekreft'} {saving ? 'Lagrer...' : 'Bekreft'}
</button> </button>
<button onClick={handleReanalyze} disabled={reanalyzing} style={{ background: '#8e44ad', color: '#fff', border: 'none', borderRadius: 4, padding: '0.4rem 0.9rem', cursor: 'pointer' }}>
{reanalyzing ? 'Analyserer...' : 'Re-analyser bilde'}
</button>
<button className="btn-danger" onClick={handleDelete}>Slett</button> <button className="btn-danger" onClick={handleDelete}>Slett</button>
</div> </div>
{reanalyzeResult && (
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', padding: '0.5rem 0.75rem', background: '#f5f5f5', borderRadius: 4 }}>
{reanalyzeResult.error ? (
<span style={{ color: '#e74c3c' }}>Feil: {reanalyzeResult.error}</span>
) : (
<>
<div>Funne bibs: {reanalyzeResult.found_bibs.length > 0 ? reanalyzeResult.found_bibs.join(', ') : 'ingen'}</div>
{reanalyzeResult.new_passages.length > 0 ? (
<div style={{ color: '#27ae60', marginTop: 2 }}>
{reanalyzeResult.new_passages.length} ny(e) passering(er) opprettet: {reanalyzeResult.new_passages.map(p => p.bib_number).join(', ')}
</div>
) : (
<div style={{ color: '#888', marginTop: 2 }}>Ingen nye bibs funnet.</div>
)}
</>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -188,6 +307,7 @@ export default function ReviewPage({ activeRace }) {
athletes={athletes} athletes={athletes}
onResolved={() => removeFromQueue(p.passage_id)} onResolved={() => removeFromQueue(p.passage_id)}
onDeleted={() => removeFromQueue(p.passage_id)} onDeleted={() => removeFromQueue(p.passage_id)}
onReanalyzed={load}
/> />
))} ))}
</> </>