File size: 10,361 Bytes
9e61e4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
"""
Gradio EXIF / GPS metadata viewer — multi-image edition.

Features:
  - Upload MANY JPEGs at once.
  - Extract the ENTIRE metadata set: file info, PIL container info (icc/jfif/dpi),
    the base IFD, the Exif sub-IFD (camera/exposure), GPS IFD, and Interop IFD.
  - Live progress bar that advances per image, naming the file in progress.
  - Per-image NSFW gate + malformed/oversized guards (bad files are reported,
    not fatal — the batch keeps going).
  - Stateless handler: nothing persisted, nothing shared between users.

Run locally:        python app.py
Share with testers: GRADIO_SHARE=1 python app.py
"""

import io
import os
from fractions import Fraction

import gradio as gr
from PIL import Image, ExifTags, UnidentifiedImageError
from PIL.TiffImagePlugin import IFDRational

# ----------------------------------------------------------------------------
# Config
# ----------------------------------------------------------------------------
MAX_BYTES = 15 * 1024 * 1024
ALLOWED_FORMATS = {"JPEG", "MPO"}
MAX_DIMENSION = 12000
NSFW_THRESHOLD = 0.80
SHARE = os.environ.get("GRADIO_SHARE", "0") == "1"

Image.MAX_IMAGE_PIXELS = 100_000_000

# ----------------------------------------------------------------------------
# NSFW classifier (optional: skipped if transformers/torch absent)
# ----------------------------------------------------------------------------
_nsfw_pipe = None


def _get_nsfw_pipe():
    global _nsfw_pipe
    if _nsfw_pipe is None:
        try:
            from transformers import pipeline
            _nsfw_pipe = pipeline(
                "image-classification", model="Falconsai/nsfw_image_detection"
            )
        except Exception as e:  # noqa: BLE001
            print(f"[nsfw] classifier unavailable, skipping gate: {e}")
            _nsfw_pipe = False
    return _nsfw_pipe


def nsfw_score(img: Image.Image) -> float:
    pipe = _get_nsfw_pipe()
    if not pipe:
        return 0.0
    for r in pipe(img.convert("RGB")):
        if r["label"].lower() == "nsfw":
            return float(r["score"])
    return 0.0


# ----------------------------------------------------------------------------
# JSON sanitizing — EXIF values are full of rationals, bytes, nested tuples
# ----------------------------------------------------------------------------
def _clean(v, depth=0):
    if depth > 6:
        return str(v)
    if isinstance(v, IFDRational):
        try:
            return float(v)
        except (ZeroDivisionError, ValueError):
            return f"{v.numerator}/{v.denominator}"
    if isinstance(v, bytes):
        if len(v) <= 64:
            try:
                return v.decode("utf-8", "replace").rstrip("\x00")
            except Exception:  # noqa: BLE001
                return v.hex()
        return f"<{len(v)} bytes>"
    if isinstance(v, (tuple, list)):
        return [_clean(x, depth + 1) for x in v]
    if isinstance(v, dict):
        return {str(k): _clean(val, depth + 1) for k, val in v.items()}
    if isinstance(v, (int, float, str, bool)) or v is None:
        return v
    return str(v)


# ----------------------------------------------------------------------------
# GPS decoding (needs raw rationals, so done before _clean)
# ----------------------------------------------------------------------------
def _ratio_to_float(x) -> float:
    try:
        return float(x)
    except (TypeError, ValueError):
        if isinstance(x, tuple) and len(x) == 2:
            return x[0] / x[1]
        return float(Fraction(x))


def _dms_to_decimal(dms, ref) -> float:
    deg, minute, sec = (_ratio_to_float(x) for x in dms)
    dec = deg + minute / 60.0 + sec / 3600.0
    return round(-dec if ref in ("S", "W") else dec, 6)


def _decode_gps(gps_ifd):
    g = {ExifTags.GPSTAGS.get(k, k): v for k, v in gps_ifd.items()}
    if "GPSLatitude" not in g or "GPSLongitude" not in g:
        return None
    try:
        out = {
            "latitude": _dms_to_decimal(g["GPSLatitude"], g.get("GPSLatitudeRef", "N")),
            "longitude": _dms_to_decimal(g["GPSLongitude"], g.get("GPSLongitudeRef", "E")),
        }
        if "GPSAltitude" in g:
            out["altitude_m"] = round(_ratio_to_float(g["GPSAltitude"]), 1)
        return out
    except Exception as e:  # noqa: BLE001
        print(f"[gps] parse error: {e}")
        return None


# ----------------------------------------------------------------------------
# Full metadata extraction — every IFD plus PIL container info
# ----------------------------------------------------------------------------
def extract_full_metadata(img: Image.Image, raw_size: int, fname: str):
    meta = {
        "file": {
            "name": fname,
            "size_bytes": raw_size,
            "format": img.format,
            "format_description": getattr(img, "format_description", None),
            "mode": img.mode,
            "width": img.width,
            "height": img.height,
        },
        # PIL container info: jfif, dpi, icc_profile, progression, etc.
        "info": {k: _clean(v) for k, v in img.info.items()},
    }

    exif = img.getexif()
    meta["exif_base"] = {
        ExifTags.TAGS.get(t, str(t)): _clean(v) for t, v in exif.items()
    }

    # Exif sub-IFD: the photographic metadata (ISO, exposure, lens, dates…)
    try:
        photo = exif.get_ifd(ExifTags.IFD.Exif)
        meta["exif_photo"] = {
            ExifTags.TAGS.get(t, str(t)): _clean(v) for t, v in photo.items()
        }
    except Exception:  # noqa: BLE001
        meta["exif_photo"] = {}

    # GPS IFD: raw (named) + decoded decimal coords
    gps_decoded = None
    try:
        gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
    except Exception:  # noqa: BLE001
        gps_ifd = {}
    if gps_ifd:
        meta["gps_raw"] = {
            ExifTags.GPSTAGS.get(t, str(t)): _clean(v) for t, v in gps_ifd.items()
        }
        gps_decoded = _decode_gps(gps_ifd)
        if gps_decoded:
            meta["gps"] = gps_decoded

    # Interop IFD (present on many camera JPEGs)
    try:
        interop = exif.get_ifd(ExifTags.IFD.Interop)
        if interop:
            meta["interop"] = {
                ExifTags.TAGS.get(t, str(t)): _clean(v) for t, v in interop.items()
            }
    except Exception:  # noqa: BLE001
        pass

    return meta, gps_decoded


# ----------------------------------------------------------------------------
# Validation
# ----------------------------------------------------------------------------
def load_and_validate(path: str):
    size = os.path.getsize(path)
    if size == 0:
        raise ValueError("Empty file.")
    if size > MAX_BYTES:
        raise ValueError(f"Too large ({size/1e6:.1f} MB, max {MAX_BYTES/1e6:.0f} MB).")

    with open(path, "rb") as f:
        raw = f.read()

    try:
        Image.open(io.BytesIO(raw)).verify()
    except (UnidentifiedImageError, OSError, ValueError) as e:
        raise ValueError(f"Not a valid image: {e}")

    img = Image.open(io.BytesIO(raw))
    if img.format not in ALLOWED_FORMATS:
        raise ValueError(f"Only JPEG accepted (got {img.format}).")
    if max(img.size) > MAX_DIMENSION:
        raise ValueError(f"Dimensions too large ({img.size}).")
    img.load()
    return img, size


# ----------------------------------------------------------------------------
# Handler — multi-image, with a per-image progress bar
# ----------------------------------------------------------------------------
def process(files, progress=gr.Progress()):
    if not files:
        return [], {}, None, "Upload one or more JPEGs to begin."

    gallery, all_meta, coords, rejected = [], {}, [], []
    n = len(files)

    for i, path in enumerate(files):
        name = os.path.basename(path)
        progress(i / n, desc=f"Processing {name}  ({i + 1}/{n})")

        try:
            img, raw_size = load_and_validate(path)
        except ValueError as e:
            rejected.append((name, str(e)))
            all_meta[name] = {"error": str(e)}
            continue
        except Exception as e:  # noqa: BLE001
            rejected.append((name, f"unreadable: {e}"))
            all_meta[name] = {"error": f"unreadable: {e}"}
            continue

        score = nsfw_score(img)
        if score >= NSFW_THRESHOLD:
            rejected.append((name, f"content filter ({score:.2f})"))
            all_meta[name] = {"error": f"rejected by content filter (score={score:.2f})"}
            continue

        meta, gps = extract_full_metadata(img, raw_size, name)
        all_meta[name] = meta
        gallery.append((img.convert("RGB"), name + (" 📍" if gps else "")))
        if gps:
            coords.append([name, gps["latitude"], gps["longitude"]])

    progress(1.0, desc="Done")

    summary = f"### Processed {len(gallery)}/{n} image(s)\n"
    if coords:
        summary += f"- 📍 {len(coords)} with GPS location\n"
    if rejected:
        summary += f"- ⚠️ {len(rejected)} rejected:\n"
        for nm, why in rejected:
            summary += f"  - **{nm}**: {why}\n"

    return gallery, all_meta, (coords or None), summary


# ----------------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------------
with gr.Blocks(title="JPEG Metadata Viewer") as demo:
    gr.Markdown(
        "# JPEG Metadata & Location Viewer\n"
        "Upload one or more JPEGs to inspect their full EXIF metadata. "
        "The tool reads embedded GPS location data when present."
    )

    with gr.Row():
        files_in = gr.File(
            label="Upload JPEGs",
            file_types=[".jpg", ".jpeg"],
            file_count="multiple",
            type="filepath",
        )
        status = gr.Markdown()

    run_btn = gr.Button("Extract metadata", variant="primary")
    gallery = gr.Gallery(label="Images", columns=4, height="auto")
    coords_df = gr.Dataframe(headers=["file", "lat", "lon"], label="GPS locations")
    meta_out = gr.JSON(label="Full metadata (per image)")

    run_btn.click(
        process,
        inputs=files_in,
        outputs=[gallery, meta_out, coords_df, status],
    )

demo.queue(default_concurrency_limit=4, max_size=64)

if __name__ == "__main__":
    demo.launch(
        share=SHARE,
        max_file_size=f"{MAX_BYTES // (1024 * 1024)}mb",
        show_error=True,
    )