File size: 4,081 Bytes
5dab1e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Self-contained provenance (C2PA/metadata) gate — works on raw upload bytes.

Ported from the detector repo's scripts/provenance_check.py. No external service
(:8061) and no special c2pa library: pure PIL + byte-signature scan. When a
generator/provenance signal is present it is HIGH precision -> force AI verdict.
Absence proves nothing (screenshots strip it) -> defer to the pixel model.
"""
from __future__ import annotations

import io
import json

from PIL import ExifTags, Image

SIGNATURES = {
    "C2PA/JUMBF": [b"c2pa", b"jumbf", b"urn:c2pa", b"claim_generator", b"caBX", b"contentauth", b"c2pa.assertions"],
    "OpenAI": [b"openai", b"dall-e", b"dall\xc2\xb7e", b"dalle", b"gpt-image", b"chatgpt"],
    "Adobe/Firefly": [b"firefly", b"cai:"],
    "StableDiffusion/Comfy": [b"stable diffusion", b"stable-diffusion", b"sd-webui", b"automatic1111",
                               b"comfyui", b"negative prompt"],
    "Midjourney": [b"midjourney", b"niji"],
    "Google/SynthID": [b"synthid", b"made with google", b"gemini", b"google ai", b"imagen 3", b"imagen 4"],
    "Other-gen": [b"novelai", b"leonardo.ai", b"ideogram", b"playground", b"seedream", b"flux.1", b"grok-image"],
    "AIGC-generic": [b"trainedalgorithmicmedia", b"compositesynthetic", b"ai-generated", b"genai"],
}

_EXIF_TAGS = {v: k for k, v in ExifTags.TAGS.items()}


def _scan(raw: bytes) -> set[str]:
    low = raw.lower()
    return {label for label, sigs in SIGNATURES.items() if any(s in low for s in sigs)}


def check_bytes(raw: bytes) -> dict:
    """Returns {'signals': [...], 'details': {...}} ; signals non-empty => AI."""
    details: dict = {}
    sig = _scan(raw)
    try:
        img = Image.open(io.BytesIO(raw))
        details["format"] = img.format
        info = {k: v for k, v in (img.info or {}).items()
                if isinstance(v, (str, bytes)) and k.lower() != "icc_profile"}
        if info:
            details["text_chunks"] = {
                k: (v if isinstance(v, str) else v.decode("latin-1", "ignore"))[:300]
                for k, v in info.items()
            }
        if "xmp" in img.info:
            xmp = img.info["xmp"]
            sig |= _scan(xmp if isinstance(xmp, bytes) else xmp.encode("latin-1", "ignore"))
        exif = img.getexif()
        if exif:
            wanted = ("Make", "Model", "Software", "Artist", "XPComment", "ImageDescription")
            ex = {name: str(exif[_EXIF_TAGS[name]])[:200]
                  for name in wanted if name in _EXIF_TAGS and _EXIF_TAGS[name] in exif}
            if ex:
                details["exif"] = ex
    except Exception as exc:  # noqa: BLE001
        details["pil_error"] = str(exc)

    blob = json.dumps(details, ensure_ascii=False).lower()
    for label, sigs in SIGNATURES.items():
        if any(s.decode("latin-1", "ignore") in blob for s in sigs):
            sig.add(label)

    return {"signals": sorted(sig), "details": details}


def check_filename(name: str | None) -> list[str]:
    """Weak mobile fallback for generator-exported filenames.

    Some phone gallery providers hand Expo a transcoded/cache copy that strips
    C2PA/JUMBF chunks. The filename is not cryptographic provenance, but app
    exports like "ChatGPT Image ..." are still useful as a high-confidence hint
    for the demo path when raw provenance was removed before upload.
    """
    low = (name or "").lower()
    found: list[str] = []
    if "chatgpt image" in low or "gpt-image" in low or "dall-e" in low or "dalle" in low:
        found.extend(["OpenAI", "Filename:AI-export"])
    if "midjourney" in low or "niji" in low:
        found.extend(["Midjourney", "Filename:AI-export"])
    if "stable diffusion" in low or "comfyui" in low or "sd-webui" in low:
        found.extend(["StableDiffusion/Comfy", "Filename:AI-export"])
    # Gemini app saves as "Gemini_Generated_Image_xxxxx.png"; Imagen exports similarly.
    if "gemini" in low or "imagen" in low or "google_ai" in low or "made with google" in low:
        found.extend(["Google/SynthID", "Filename:AI-export"])
    return sorted(set(found))