From b3de4c0085f648b5ae10528168513f0e2479275e Mon Sep 17 00:00:00 2001 From: "Guillaume Meyer (The Opinionated Man)" <1385518+guillaumemeyer@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:45:23 -0700 Subject: [PATCH] fix: detect AI generator product names in PNG text metadata (#120) (#125) --- service/scripts/image_meta.py | 149 +++++++++++++++++++++++++++- tests/test_ai_generator_hints.py | 161 +++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 tests/test_ai_generator_hints.py diff --git a/service/scripts/image_meta.py b/service/scripts/image_meta.py index e2b883b..70892df 100755 --- a/service/scripts/image_meta.py +++ b/service/scripts/image_meta.py @@ -104,6 +104,53 @@ AI_META_HINTS = ( b"dcterms:provenance", ) +# Well-known AI generator product/model names. These are matched ONLY +# against the values of generator-bearing PNG text-chunk keys (Software, +# Creator, parameters) — see _generator_product_hits — and deliberately +# kept out of the flat AI_META_HINTS blob scan: bare "Gemini", "Sora", +# or "Firefly" are ordinary words that can appear in captions, and only a +# generator field makes them evidence of AI provenance. +AI_GENERATOR_PRODUCTS = ( + b"ChatGPT", + b"DALL-E", + b"Midjourney", + b"Stable Diffusion", + b"SDXL", + b"FLUX", + b"DreamStudio", + b"Leonardo AI", + b"Leonardo.Ai", + b"Craiyon", + b"NovelAI", + b"Ideogram", + b"TensorArt", + b"Recraft", + b"Clipdrop", + b"DeepAI", + b"NightCafe", + b"Bing Image Creator", + b"Adobe Firefly", + b"Firefly", + b"Gemini", + b"Imagen", + b"Grok", + b"Sora", + b"Veo", + b"Kling", + b"Runway", + b"Luma", + b"Qwen", + b"GPT-4", + b"GPT-5", +) + +# PNG text-chunk keys whose value can name the generating model/tool. +# tEXt/iTXt "Software" and "Creator" are the classic generator fields; +# "parameters" is what Stable Diffusion WebUI writes with the full +# generation string. Values of other keys (Comment, Description, Title, +# ...) are free text and are never scanned for product names. +_GENERATOR_TEXT_KEYS = ("software", "creator", "parameters") + @dataclass class ImageInspectReport: @@ -172,6 +219,94 @@ def _contains_any(blob: bytes, needles: tuple[bytes, ...]) -> list[str]: return found +def _png_text_entries(payload: bytes, ctype: bytes) -> list[tuple[str, str]]: + """Parse a PNG text-chunk payload into (key, value) pairs. + + Handles tEXt (latin-1), zTXt (zlib-compressed text), and iTXt + (UTF-8, optionally compressed). Malformed or undecodable chunks + yield whatever pairs are recoverable; nothing is raised. + """ + entries: list[tuple[str, str]] = [] + if ctype == b"tEXt": + key, sep, text = payload.partition(b"\x00") + if sep: + entries.append( + ( + key.decode("latin-1", errors="replace"), + text.decode("latin-1", errors="replace"), + ) + ) + elif ctype == b"zTXt": + key, sep, rest = payload.partition(b"\x00") + if not sep or len(rest) < 2: + return entries + try: + text = zlib.decompress(rest[1:]) + except zlib.error: + return entries + entries.append( + ( + key.decode("latin-1", errors="replace"), + text.decode("latin-1", errors="replace"), + ) + ) + elif ctype == b"iTXt": + key, sep, rest = payload.partition(b"\x00") + if not sep or len(rest) < 4: + return entries + comp_flag = rest[0] + rest = rest[2:] # skip compression flag + method + _lang, sep2, rest = rest.partition(b"\x00") + if not sep2: + return entries + _tkey, sep3, text = rest.partition(b"\x00") + if not sep3: + return entries + if comp_flag == 1: + try: + text = zlib.decompress(text) + except zlib.error: + return entries + entries.append( + ( + key.decode("latin-1", errors="replace"), + text.decode("utf-8", errors="replace"), + ) + ) + return entries + + +def _generator_product_hits(entries: list[tuple[str, str]]) -> list[str]: + """Product-name hits scoped to generator-bearing text-chunk keys. + + Returns labels like "Software=ChatGPT" (one per matching product). + Matching is a case-insensitive substring check on the value, mirroring + _contains_any, but only for _GENERATOR_TEXT_KEYS values. + """ + hits: list[str] = [] + for key, value in entries: + if key.strip().lower() not in _GENERATOR_TEXT_KEYS: + continue + low = value.lower() + for product in AI_GENERATOR_PRODUCTS: + label = product.decode("ascii", errors="replace") + if label.lower() in low: + hits.append(f"{key.strip()}={label}") + return hits + + +def _text_chunk_is_ai(payload: bytes, ctype: bytes) -> bool: + """True when a PNG text chunk carries AI/C2PA markers. + + Flat markers (AI_META_HINTS + C2PA_MARKERS) match anywhere in the + payload; generator product names only match generator-bearing key + values (see _generator_product_hits). + """ + if _contains_any(payload, AI_META_HINTS + C2PA_MARKERS): + return True + return bool(_generator_product_hits(_png_text_entries(payload, ctype))) + + def inspect_png(data: bytes) -> tuple[bool, bool, list[str]]: findings: list[str] = [] has_c2pa = False @@ -195,11 +330,19 @@ def inspect_png(data: bytes) -> tuple[bool, bool, list[str]]: findings.append(f"PNG chunk {name} (possible C2PA container)") if ctype in (b"tEXt", b"zTXt", b"iTXt", b"eXIf"): hits = _contains_any(payload, AI_META_HINTS + C2PA_MARKERS) - if hits: + product_hits = ( + _generator_product_hits(_png_text_entries(payload, ctype)) + if ctype in (b"tEXt", b"zTXt", b"iTXt") + else [] + ) + if hits or product_hits: has_ai = True if any(h.lower() in ("c2pa", "contentcredentials", "jumb") for h in hits): has_c2pa = True - findings.append(f"PNG {name}: {', '.join(hits[:8])}") + parts = [", ".join(hits[:8])] if hits else [] + if product_hits: + parts.append(f"AI generator ({', '.join(product_hits[:8])})") + findings.append(f"PNG {name}: {'; '.join(parts)}") if ctype == b"IEND": break pos = chunk_end + 4 # skip CRC @@ -1568,7 +1711,7 @@ def strip_png(data: bytes, *, strip_all_text: bool = True) -> tuple[bytes, list[ drop = True actions.append(f"drop chunk {name}") elif ctype in (b"tEXt", b"zTXt", b"iTXt"): - if strip_all_text or _contains_any(payload, AI_META_HINTS + C2PA_MARKERS): + if strip_all_text or _text_chunk_is_ai(payload, ctype): drop = True actions.append(f"drop chunk {name}") elif _contains_any(ctype + payload, C2PA_MARKERS) and ctype not in ( diff --git a/tests/test_ai_generator_hints.py b/tests/test_ai_generator_hints.py new file mode 100644 index 0000000..d93374a --- /dev/null +++ b/tests/test_ai_generator_hints.py @@ -0,0 +1,161 @@ +"""PNG tEXt/iTXt/zTXt generator product-name hints (#120). + +AI_META_HINTS covers vendors (OpenAI, Anthropic, ...) but not the product +names generators actually write into tEXt "Software" / "Creator" / +"parameters" keys (ChatGPT, DALL-E, Midjourney, ...). These tests pin the +key-scoped product matching: a generator field names a well-known product +→ AI metadata; the same words in free text (Comment) stay clean. +""" + +from __future__ import annotations + +import struct +import sys +import zlib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "service" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from image_meta import AI_GENERATOR_PRODUCTS, inspect_image, inspect_png, strip_png + +# Product names from the issue's repro table. +ISSUE_PRODUCTS = [ + "ChatGPT", + "DALL-E", + "Midjourney", + "Stable Diffusion", + "Gemini", + "Imagen", + "Adobe Firefly", + "Grok", + "Sora", +] + + +def _png_chunk(ctype: bytes, payload: bytes) -> bytes: + crc = zlib.crc32(ctype) + crc = zlib.crc32(payload, crc) & 0xFFFFFFFF + return struct.pack(">I", len(payload)) + ctype + payload + struct.pack(">I", crc) + + +def _minimal_png_with_text_chunk(ctype: bytes, payload: bytes) -> bytes: + sig = b"\x89PNG\r\n\x1a\n" + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + idat = zlib.compress(b"\x00\x00\x00") + return ( + sig + + _png_chunk(b"IHDR", ihdr) + + _png_chunk(ctype, payload) + + _png_chunk(b"IDAT", idat) + + _png_chunk(b"IEND", b"") + ) + + +def _text(key: str, value: str) -> bytes: + return key.encode("latin-1") + b"\x00" + value.encode("latin-1") + + +@pytest.mark.parametrize("product", ISSUE_PRODUCTS) +def test_software_tag_naming_generator_flags_ai(product: str): + data = _minimal_png_with_text_chunk(b"tEXt", _text("Software", product)) + has_c2pa, has_ai, findings = inspect_png(data) + assert has_c2pa is False + assert has_ai is True + assert any("AI generator" in f and product in f for f in findings) + + +@pytest.mark.parametrize("product", [p.decode("ascii") for p in AI_GENERATOR_PRODUCTS]) +def test_every_generator_product_hint_matches_in_software_tag(product: str): + data = _minimal_png_with_text_chunk(b"tEXt", _text("Software", product)) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is True + assert any("AI generator" in f for f in findings) + + +def test_creator_and_parameters_keys_are_scoped(): + creator = _minimal_png_with_text_chunk(b"tEXt", _text("Creator", "DALL-E 3")) + assert inspect_png(creator)[1] is True + parameters = _minimal_png_with_text_chunk( + b"tEXt", _text("parameters", "Steps: 20, Sampler: DPM++ 2M, Model: SDXL base 1.0") + ) + assert inspect_png(parameters)[1] is True + # Model filenames (sd_xl_base_1.0) are not product names and stay clean. + plain_model = _minimal_png_with_text_chunk( + b"tEXt", _text("parameters", "Steps: 20, Sampler: DPM++ 2M, Model: sd_xl_base_1.0") + ) + assert inspect_png(plain_model)[1] is False + + +def test_vendor_name_still_flags_via_flat_hints(): + data = _minimal_png_with_text_chunk(b"tEXt", _text("Software", "OpenAI")) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is True + assert any("OpenAI" in f for f in findings) + + +def test_generator_word_in_comment_does_not_false_positive(): + data = _minimal_png_with_text_chunk( + b"tEXt", + _text("Comment", "Hiking near the Gemini constellation with my dog Sora"), + ) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is False + assert findings == [] + + +def test_generated_by_comment_still_flags_via_flat_hints(): + data = _minimal_png_with_text_chunk(b"tEXt", _text("Comment", "Generated by AI")) + _has_c2pa, has_ai, _findings = inspect_png(data) + assert has_ai is True + + +def test_lowercase_key_and_value_match(): + data = _minimal_png_with_text_chunk(b"tEXt", _text("software", "chatgpt")) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is True + assert any("AI generator" in f for f in findings) + + +def test_ztext_compressed_software_matches(): + payload = b"Software\x00\x00" + zlib.compress(b"ChatGPT") + data = _minimal_png_with_text_chunk(b"zTXt", payload) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is True + assert any("AI generator" in f for f in findings) + + +def test_itext_software_matches(): + # iTXt: keyword \0 comp-flag(0) comp-method(0) \0 lang \0 tkey \0 text + payload = b"Software\x00\x00\x00\x00\x00ChatGPT" + data = _minimal_png_with_text_chunk(b"iTXt", payload) + _has_c2pa, has_ai, findings = inspect_png(data) + assert has_ai is True + assert any("AI generator" in f for f in findings) + + +def test_strip_keep_mode_drops_generator_tagged_chunk(): + data = _minimal_png_with_text_chunk(b"tEXt", _text("Software", "ChatGPT")) + cleaned, actions = strip_png(data, strip_all_text=False) + assert b"ChatGPT" not in cleaned + assert any("drop" in a for a in actions) + + +def test_strip_keep_mode_keeps_benign_text_chunk(): + data = _minimal_png_with_text_chunk( + b"tEXt", _text("Comment", "Hiking near the Gemini constellation") + ) + cleaned, actions = strip_png(data, strip_all_text=False) + assert b"Gemini" in cleaned + assert not any("drop" in a for a in actions) + + +def test_inspect_image_end_to_end(tmp_path: Path): + src = tmp_path / "chatgpt.png" + src.write_bytes(_minimal_png_with_text_chunk(b"tEXt", _text("Software", "ChatGPT"))) + report = inspect_image(src) + assert report.has_ai_metadata is True + assert any("AI generator" in f for f in report.findings)