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,156 @@
|
||||
"""
|
||||
Parse og normaliser EXIF-metadata fra bilder.
|
||||
Returnerer et strukturert dict eller kaster ExifError ved manglende/ugyldig data.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ExifError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageMetadata:
|
||||
timestamp_utc: datetime
|
||||
gps_lat: float
|
||||
gps_lon: float
|
||||
gps_alt: Optional[float]
|
||||
station: Optional[str] # fra ImageDescription eller CameraLabel
|
||||
|
||||
|
||||
def _rational_to_float(rational) -> float:
|
||||
"""Konverter EXIF rational (teller, nevner) til float."""
|
||||
if isinstance(rational, tuple):
|
||||
num, den = rational
|
||||
if den == 0:
|
||||
return 0.0
|
||||
return num / den
|
||||
return float(rational)
|
||||
|
||||
|
||||
def _parse_gps_coord(dms_rationals, ref: str) -> float:
|
||||
"""Konverter GPS DMS rationals + referanse til desimalgrader."""
|
||||
deg = _rational_to_float(dms_rationals[0])
|
||||
min_ = _rational_to_float(dms_rationals[1])
|
||||
sec = _rational_to_float(dms_rationals[2])
|
||||
value = deg + min_ / 60.0 + sec / 3600.0
|
||||
if ref in ("S", "W"):
|
||||
value = -value
|
||||
return value
|
||||
|
||||
|
||||
def _parse_timestamp(exif_dict: dict) -> datetime:
|
||||
"""
|
||||
Hent tidsstempel fra DateTimeOriginal + SubSecTimeOriginal.
|
||||
Returnerer datetime i UTC. Kaster ExifError hvis manglende.
|
||||
"""
|
||||
ifd0 = exif_dict.get("Exif", {})
|
||||
|
||||
raw_dt = ifd0.get(piexif.ExifIFD.DateTimeOriginal)
|
||||
if not raw_dt:
|
||||
raise ExifError("Mangler DateTimeOriginal i EXIF")
|
||||
|
||||
if isinstance(raw_dt, bytes):
|
||||
raw_dt = raw_dt.decode("ascii", errors="replace")
|
||||
|
||||
# Format: "2024:06:15 10:23:45"
|
||||
try:
|
||||
dt = datetime.strptime(raw_dt.strip(), "%Y:%m:%d %H:%M:%S")
|
||||
except ValueError as e:
|
||||
raise ExifError(f"Ugyldig DateTimeOriginal-format: {raw_dt!r}") from e
|
||||
|
||||
# Sub-sekunder
|
||||
raw_sub = ifd0.get(piexif.ExifIFD.SubSecTimeOriginal)
|
||||
microseconds = 0
|
||||
if raw_sub:
|
||||
if isinstance(raw_sub, bytes):
|
||||
raw_sub = raw_sub.decode("ascii", errors="replace")
|
||||
sub_str = raw_sub.strip()
|
||||
if sub_str.isdigit():
|
||||
# Pad/truncate til 6 siffer (mikrosekunder)
|
||||
sub_str = (sub_str + "000000")[:6]
|
||||
microseconds = int(sub_str)
|
||||
|
||||
dt = dt.replace(microsecond=microseconds, tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _parse_gps(exif_dict: dict) -> tuple[float, float, Optional[float]]:
|
||||
"""
|
||||
Hent GPS-koordinater. Kaster ExifError hvis lat/lon mangler.
|
||||
"""
|
||||
gps = exif_dict.get("GPS", {})
|
||||
|
||||
lat_dms = gps.get(piexif.GPSIFD.GPSLatitude)
|
||||
lat_ref = gps.get(piexif.GPSIFD.GPSLatitudeRef)
|
||||
lon_dms = gps.get(piexif.GPSIFD.GPSLongitude)
|
||||
lon_ref = gps.get(piexif.GPSIFD.GPSLongitudeRef)
|
||||
|
||||
if not (lat_dms and lat_ref and lon_dms and lon_ref):
|
||||
raise ExifError("Mangler GPS-koordinater (lat/lon) i EXIF")
|
||||
|
||||
if isinstance(lat_ref, bytes):
|
||||
lat_ref = lat_ref.decode("ascii")
|
||||
if isinstance(lon_ref, bytes):
|
||||
lon_ref = lon_ref.decode("ascii")
|
||||
|
||||
lat = _parse_gps_coord(lat_dms, lat_ref)
|
||||
lon = _parse_gps_coord(lon_dms, lon_ref)
|
||||
|
||||
alt = None
|
||||
alt_raw = gps.get(piexif.GPSIFD.GPSAltitude)
|
||||
if alt_raw:
|
||||
alt = _rational_to_float(alt_raw)
|
||||
alt_ref = gps.get(piexif.GPSIFD.GPSAltitudeRef, 0)
|
||||
if alt_ref == 1:
|
||||
alt = -alt
|
||||
|
||||
return lat, lon, alt
|
||||
|
||||
|
||||
def _parse_station(exif_dict: dict) -> Optional[str]:
|
||||
"""Hent stasjonsnavn fra ImageDescription."""
|
||||
ifd0 = exif_dict.get("0th", {})
|
||||
desc = ifd0.get(piexif.ImageIFD.ImageDescription)
|
||||
if desc:
|
||||
if isinstance(desc, bytes):
|
||||
desc = desc.decode("utf-8", errors="replace")
|
||||
return desc.strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def parse_image(path: Path) -> ImageMetadata:
|
||||
"""
|
||||
Les EXIF fra bildefil og returner ImageMetadata.
|
||||
Kaster ExifError hvis påkrevde felt mangler.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(path)
|
||||
raw_exif = img.info.get("exif")
|
||||
if not raw_exif:
|
||||
raise ExifError("Bildet har ingen EXIF-data")
|
||||
exif_dict = piexif.load(raw_exif)
|
||||
except ExifError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ExifError(f"Kunne ikke lese EXIF: {e}") from e
|
||||
|
||||
timestamp = _parse_timestamp(exif_dict)
|
||||
lat, lon, alt = _parse_gps(exif_dict)
|
||||
station = _parse_station(exif_dict)
|
||||
|
||||
return ImageMetadata(
|
||||
timestamp_utc=timestamp,
|
||||
gps_lat=lat,
|
||||
gps_lon=lon,
|
||||
gps_alt=alt,
|
||||
station=station,
|
||||
)
|
||||
Reference in New Issue
Block a user