Spaces:
Running
Running
| """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)) | |