| """EXIF / XMP / IPTC extractor — Pillow-based, single-pass.""" |
|
|
| from __future__ import annotations |
|
|
| import io |
| from typing import Any, Optional |
|
|
| from PIL import Image, UnidentifiedImageError |
|
|
|
|
| |
| |
| |
| def extract_all(image_bytes: bytes) -> dict: |
| """Extract EXIF + GPS + XMP + IPTC + format in a single Pillow open. |
| |
| Returns a dict with keys: |
| format, exif (dict), gps (dict), gps_coords (dict|None), |
| xmp (bytes|None), camera_make, camera_model, software, capture_time, |
| error (str|None) |
| """ |
| result = { |
| "format": None, |
| "exif": {}, |
| "gps": {}, |
| "gps_coords": None, |
| "xmp": None, |
| "iptc": {}, |
| "camera_make": None, |
| "camera_model": None, |
| "software": None, |
| "capture_time": None, |
| "error": None, |
| } |
| if not image_bytes: |
| result["error"] = "No image bytes provided" |
| return result |
|
|
| try: |
| img = Image.open(io.BytesIO(image_bytes)) |
| except UnidentifiedImageError: |
| result["error"] = "Pillow could not identify image format" |
| return result |
| except Exception as e: |
| result["error"] = f"Pillow open error: {e}" |
| return result |
|
|
| result["format"] = img.format or "UNKNOWN" |
|
|
| |
| try: |
| exif_info = img._getexif() |
| except Exception: |
| exif_info = None |
|
|
| if exif_info: |
| from PIL.ExifTags import TAGS, GPSTAGS |
| for tag_id, value in exif_info.items(): |
| tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") |
| if tag_name == "GPSInfo": |
| for gps_tag_id, gps_value in value.items(): |
| gps_tag_name = GPSTAGS.get(gps_tag_id, f"GPS_{gps_tag_id}") |
| result["gps"][gps_tag_name] = gps_value |
| else: |
| result["exif"][tag_name] = ( |
| value if isinstance(value, (int, float, str)) else str(value) |
| ) |
| if tag_name == "Make": |
| result["camera_make"] = str(value) |
| elif tag_name == "Model": |
| result["camera_model"] = str(value) |
| elif tag_name == "Software": |
| result["software"] = str(value) |
| elif tag_name in ("DateTimeOriginal", "DateTime"): |
| result["capture_time"] = str(value) |
|
|
| |
| if result["gps"]: |
| result["gps_coords"] = gps_to_coords(result["gps"]) |
|
|
| |
| try: |
| result["xmp"] = img.info.get("xmp") or img.info.get("XMP") |
| except Exception: |
| pass |
|
|
| |
| try: |
| iptc = img.info.get("iptc") or img.info.get("IPTC") |
| if iptc: |
| result["iptc"] = {"raw_length": len(iptc) if hasattr(iptc, "__len__") else 0} |
| except Exception: |
| pass |
|
|
| return result |
|
|
|
|
| |
| |
| |
| def extract_exif(image_bytes: bytes) -> dict: |
| """Return only the EXIF dict (no GPS, no XMP).""" |
| return extract_all(image_bytes)["exif"] |
|
|
|
|
| def extract_gps(image_bytes: bytes) -> Optional[dict]: |
| """Return only the GPS coordinates dict, or None.""" |
| return extract_all(image_bytes)["gps_coords"] |
|
|
|
|
| |
| |
| |
| def gps_to_coords(gps: dict) -> Optional[dict]: |
| """Convert EXIF GPS dict to {lat, lon} floats. |
| |
| Handles both raw degree tuples (48, 51, 24) and Pillow IFDRational |
| tuples ((48, 1), (51, 1), (24, 1)). |
| """ |
| try: |
| if not gps: |
| return None |
|
|
| def _to_float(val): |
| """Convert a value to float, handling IFDRational tuples.""" |
| if isinstance(val, (int, float)): |
| return float(val) |
| if isinstance(val, (tuple, list)) and len(val) == 2: |
| num, den = val |
| return float(num) / float(den) if den else 0.0 |
| return float(val) |
|
|
| def _convert_dms(value): |
| """Convert degrees/minutes/seconds to decimal degrees.""" |
| if value is None: |
| return None |
| d, m, s = value |
| return _to_float(d) + _to_float(m) / 60.0 + _to_float(s) / 3600.0 |
|
|
| lat = _convert_dms(gps.get("GPSLatitude")) |
| lon = _convert_dms(gps.get("GPSLongitude")) |
| if lat is None or lon is None: |
| return None |
|
|
| if gps.get("GPSLatitudeRef", "N") == "S": |
| lat = -lat |
| if gps.get("GPSLongitudeRef", "E") == "W": |
| lon = -lon |
| return {"lat": round(lat, 6), "lon": round(lon, 6)} |
| except Exception: |
| return None |
|
|
|
|
| def parse_pillow_image(image_bytes: bytes) -> tuple[Optional[Image.Image], Optional[str]]: |
| """Open a Pillow image from bytes. Returns (image, error).""" |
| try: |
| return Image.open(io.BytesIO(image_bytes)), None |
| except Exception as e: |
| return None, str(e) |
|
|