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