File size: 5,293 Bytes
892fa81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""EXIF / XMP / IPTC extractor — Pillow-based, single-pass."""

from __future__ import annotations

import io
from typing import Any, Optional

from PIL import Image, UnidentifiedImageError


# --------------------------------------------------------------------------- #
# Top-level: extract everything in one pass
# --------------------------------------------------------------------------- #
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"

    # EXIF
    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)

    # GPS coordinates
    if result["gps"]:
        result["gps_coords"] = gps_to_coords(result["gps"])

    # XMP (raw bytes)
    try:
        result["xmp"] = img.info.get("xmp") or img.info.get("XMP")
    except Exception:
        pass

    # IPTC
    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


# --------------------------------------------------------------------------- #
# Granular accessors (for providers that only need one piece)
# --------------------------------------------------------------------------- #
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"]


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
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)