330ba7a93d
- 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>
157 lines
4.4 KiB
Python
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,
|
|
)
|