Støtte for flere bibs per bilde, EXIF-metadata og zoom i gjennomgang
Build & Deploy / build-and-deploy (push) Successful in 46s
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:
+103
-2
@@ -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
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
@@ -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
|
||||||
+110
-68
@@ -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,56 +71,73 @@ 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
|
||||||
|
|
||||||
await log_passage(
|
profile_id = None
|
||||||
db,
|
if ocr.digits and not needs_review:
|
||||||
profile_id=profile_id,
|
profile_id = await get_or_create_athlete(db, ocr.digits)
|
||||||
bib_number=bib_number,
|
|
||||||
station=meta.station or "unknown",
|
await log_passage(
|
||||||
timestamp_utc=meta.timestamp_utc,
|
db,
|
||||||
gps_lat=meta.gps_lat,
|
profile_id=profile_id,
|
||||||
gps_lon=meta.gps_lon,
|
bib_number=ocr.digits,
|
||||||
gps_alt=meta.gps_alt,
|
station=meta.station or "unknown",
|
||||||
confidence=confidence,
|
timestamp_utc=meta.timestamp_utc,
|
||||||
proximity_score=ocr.proximity_score,
|
gps_lat=meta.gps_lat,
|
||||||
id_method=id_method,
|
gps_lon=meta.gps_lon,
|
||||||
source_image=str(dest),
|
gps_alt=meta.gps_alt,
|
||||||
needs_review=needs_review,
|
confidence=confidence,
|
||||||
review_note=review_note,
|
proximity_score=ocr.proximity_score,
|
||||||
)
|
id_method=id_method,
|
||||||
logger.info(
|
source_image=str(dest),
|
||||||
"Passering logget: bib=%s station=%s needs_review=%s",
|
needs_review=needs_review,
|
||||||
bib_number, meta.station, needs_review,
|
review_note=review_note,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"Passering logget: bib=%s station=%s needs_review=%s",
|
||||||
|
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(
|
||||||
@@ -148,39 +166,63 @@ 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:
|
||||||
profile_id = await _get_or_create(db, ocr.digits)
|
profile_id = await _get_or_create(db, ocr.digits)
|
||||||
|
|
||||||
await log_passage(
|
await log_passage(
|
||||||
db,
|
db,
|
||||||
race_id=race_id,
|
race_id=race_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
bib_number=ocr.digits,
|
bib_number=ocr.digits,
|
||||||
station=station_name,
|
station=station_name,
|
||||||
timestamp_utc=timestamp,
|
timestamp_utc=timestamp,
|
||||||
gps_lat=gps_lat or 0.0,
|
gps_lat=gps_lat or 0.0,
|
||||||
gps_lon=gps_lon or 0.0,
|
gps_lon=gps_lon or 0.0,
|
||||||
gps_alt=gps_alt,
|
gps_alt=gps_alt,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
proximity_score=ocr.proximity_score,
|
proximity_score=ocr.proximity_score,
|
||||||
id_method=id_method,
|
id_method=id_method,
|
||||||
source_image=str(dest),
|
source_image=str(dest),
|
||||||
needs_review=needs_review,
|
needs_review=needs_review,
|
||||||
review_note=review_note,
|
review_note=review_note,
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
|||||||
+58
-40
@@ -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)
|
||||||
digits=digits,
|
best: dict[str, tuple[float, float]] = {} # digits -> (conf, area)
|
||||||
confidence=confidence,
|
for (bbox, text, conf) in results:
|
||||||
partial=partial,
|
digits = re.sub(r"[^0-9]", "", text)
|
||||||
proximity_score=proximity_score,
|
if len(digits) < 2:
|
||||||
raw_texts=raw_texts,
|
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,
|
||||||
|
confidence=conf,
|
||||||
|
partial=False,
|
||||||
|
proximity_score=area,
|
||||||
|
raw_texts=raw_texts,
|
||||||
|
)
|
||||||
|
for digits, (conf, area) in best.items()
|
||||||
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return OcrResult(
|
return [OcrResult(digits=None, confidence=0.0, partial=False, raw_texts=[f"ERROR: {e}"])]
|
||||||
digits=None,
|
|
||||||
confidence=0.0,
|
|
||||||
partial=False,
|
def read_bib(image_path: Path) -> OcrResult:
|
||||||
raw_texts=[f"ERROR: {e}"],
|
"""
|
||||||
)
|
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(
|
||||||
|
digits=best_digits,
|
||||||
|
confidence=best_conf,
|
||||||
|
partial=len(best_digits) < 2,
|
||||||
|
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)
|
||||||
|
|||||||
+21
-10
@@ -11,12 +11,13 @@ import aiosqlite
|
|||||||
async def init_race_tables(db: aiosqlite.Connection) -> None:
|
async def init_race_tables(db: aiosqlite.Connection) -> None:
|
||||||
await db.executescript("""
|
await db.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS races (
|
CREATE TABLE IF NOT EXISTS races (
|
||||||
race_id TEXT PRIMARY KEY,
|
race_id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
date TEXT,
|
date TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 0,
|
is_active INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
bib_filter_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stations (
|
CREATE TABLE IF NOT EXISTS stations (
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })}`),
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user