Initial commit: MVP tidtakingssystem
- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite - Frontend: React admin (startliste, passeringer, gjennomgang, resultater) - Docker: docker-compose med depot/processed/data-volumer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Bildehåndtering:
|
||||
- Overvåk depot/-katalogen for nye bilder
|
||||
- Valider EXIF
|
||||
- Kjør OCR
|
||||
- Flytt til processed/ med unikt filnavn
|
||||
- Logg passering til DB
|
||||
|
||||
Kan kjøres som egen prosess (python ingest.py) eller importeres av API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from exif_parser import ExifError, parse_image
|
||||
from ocr import read_bib
|
||||
from passage_log import log_passage
|
||||
from profile_db import get_athlete_by_bib, init_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEPOT_DIR = Path("/depot")
|
||||
PROCESSED_DIR = Path("/processed")
|
||||
REJECTED_DIR = DEPOT_DIR / "rejected"
|
||||
DB_PATH = "/data/timing.db"
|
||||
|
||||
# Konfidens-terskel for automatisk logging
|
||||
MIN_AUTO_CONFIDENCE = 0.75
|
||||
|
||||
VALID_SUFFIXES = {".jpg", ".jpeg", ".png"}
|
||||
|
||||
|
||||
def _destination_path(source: Path, timestamp) -> Path:
|
||||
"""
|
||||
Bygg destinasjonssti: processed/<år>/<måned>/<uuid>_<originalfilnavn>
|
||||
"""
|
||||
year = timestamp.strftime("%Y")
|
||||
month = timestamp.strftime("%m")
|
||||
unique_name = f"{uuid.uuid4().hex}_{source.name}"
|
||||
dest = PROCESSED_DIR / year / month / unique_name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
return dest
|
||||
|
||||
|
||||
async def process_image(path: Path) -> None:
|
||||
"""
|
||||
Behandle ett bilde: valider EXIF, kjør OCR, flytt fil, logg passering.
|
||||
"""
|
||||
if path.suffix.lower() not in VALID_SUFFIXES:
|
||||
logger.debug("Ignorerer ikke-bilde: %s", path)
|
||||
return
|
||||
|
||||
logger.info("Behandler: %s", path.name)
|
||||
|
||||
# --- EXIF-validering ---
|
||||
try:
|
||||
meta = parse_image(path)
|
||||
except ExifError as e:
|
||||
logger.warning("Ugyldig EXIF i %s: %s — avviser", path.name, e)
|
||||
REJECTED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), str(REJECTED_DIR / path.name))
|
||||
return
|
||||
|
||||
# --- OCR ---
|
||||
ocr = read_bib(path)
|
||||
logger.debug("OCR: digits=%s conf=%.2f", ocr.digits, ocr.confidence)
|
||||
|
||||
# --- Flytt til processed/ ---
|
||||
dest = _destination_path(path, meta.timestamp_utc)
|
||||
shutil.move(str(path), str(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:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
|
||||
if bib_number and not needs_review:
|
||||
athlete = await get_athlete_by_bib(db, bib_number)
|
||||
if athlete:
|
||||
profile_id = athlete["profile_id"]
|
||||
else:
|
||||
logger.debug("Ukjent startnummer: %s", bib_number)
|
||||
|
||||
await log_passage(
|
||||
db,
|
||||
profile_id=profile_id,
|
||||
bib_number=bib_number,
|
||||
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=confidence,
|
||||
id_method=id_method,
|
||||
source_image=str(dest),
|
||||
needs_review=needs_review,
|
||||
review_note=review_note,
|
||||
)
|
||||
logger.info(
|
||||
"Passering logget: bib=%s station=%s needs_review=%s",
|
||||
bib_number, meta.station, needs_review,
|
||||
)
|
||||
|
||||
|
||||
async def process_existing() -> None:
|
||||
"""Behandle bilder som allerede ligger i depot/ ved oppstart."""
|
||||
for path in sorted(DEPOT_DIR.glob("*")):
|
||||
if path.is_file() and path.suffix.lower() in VALID_SUFFIXES:
|
||||
await process_image(path)
|
||||
|
||||
|
||||
class DepotHandler(FileSystemEventHandler):
|
||||
"""Watchdog-handler: kaller process_image ved nye filer."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
|
||||
def on_created(self, event: FileCreatedEvent):
|
||||
if not event.is_directory:
|
||||
path = Path(event.src_path)
|
||||
asyncio.run_coroutine_threadsafe(process_image(path), self._loop)
|
||||
|
||||
|
||||
async def watch_depot() -> None:
|
||||
"""Start filsystem-overvåkning av depot/."""
|
||||
loop = asyncio.get_running_loop()
|
||||
handler = DepotHandler(loop)
|
||||
observer = Observer()
|
||||
observer.schedule(handler, str(DEPOT_DIR), recursive=False)
|
||||
observer.start()
|
||||
logger.info("Overvåker depot: %s", DEPOT_DIR)
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
await process_existing()
|
||||
await watch_depot()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user