Files
timing/backend/exif_parser.py
steinhelge 330ba7a93d 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>
2026-03-20 15:01:33 +01:00

157 lines
4.4 KiB
Python

"""
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,
)