feat(metadata): SynthID-source detection, C2PA parser consolidation, corpus + tests

Detect SynthID-bearing images via their C2PA companion: a manifest signed by a
SynthID-using vendor (Google/OpenAI) on AI-generated content implies an
invisible SynthID pixel watermark. Verified end-to-end against the vendor
oracles (openai.com/verify, Gemini "Verify with SynthID").

- metadata: synthid_source() + synthid_watermark verdict in get_ai_metadata,
  surfaced as a `metadata --check` callout. Format-agnostic (PNG caBX parser +
  JPEG/WebP/AVIF/HEIF/JXL binary scan).
- constants: SYNTHID_C2PA_ISSUERS {Google, OpenAI}; +opened/placed actions.
- c2pa: single CBOR-aware parser (_cbor_text_after) replaces glitchy regex
  (fixes fGPT-4o claim_generator); removed duplicate _scan_png_c2pa_chunk from
  metadata; shared synthid_verdict / synthid_vendors_in helpers.
- corpus: scripts/synthid_corpus.py ingest tool + data/synthid_corpus/
  (manifest tracked, images gitignored) for a labeled reference set.
- tests: +38 across C2PA parser internals, extract/inject round-trip, ISOBMFF
  container stripping, all IPTC AI markers, and invisible watermark strength
  tiers (SynthID/StableSignature/TreeRing/StegaStamp/RingID/RivaGAN/...).

Pixel-level SynthID detection remains out of reach locally (Google's decoder is
proprietary); a from-scratch spectral pilot confirmed it does not separate real
content. See CLAUDE.md for the full evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
test-user
2026-05-24 11:32:46 -07:00
co-authored by Claude Opus 4.7
parent c1ff4e1cd9
commit f07ce10c72
14 changed files with 785 additions and 107 deletions
+2
View File
@@ -310,6 +310,8 @@ def cmd_metadata(
if has_ai:
console.print(f" [yellow]⚠[/] AI metadata detected in {source.name}:")
meta = get_ai_metadata(source)
if synthid := meta.get("synthid_watermark"):
console.print(f" [bold yellow]⚠ SynthID pixel watermark {synthid}[/]")
table = Table(show_header=True, header_style="bold")
table.add_column("Key", style="cyan")
table.add_column("Value")
+55 -71
View File
@@ -142,80 +142,45 @@ def has_ai_metadata(image_path: Path) -> bool:
return any(marker in data for marker in IPTC_AI_MARKERS)
def _scan_png_c2pa_chunk(image_path: Path) -> dict[str, str]:
"""Extract a human-readable summary of the C2PA manifest in a PNG file.
def synthid_source(image_path: Path) -> str | None:
"""Return the vendor name(s) if the image carries a SynthID pixel watermark.
PIL does not expose the caBX JUMBF box via ``img.info``, so we delegate
chunk extraction to the existing ``extract_c2pa_chunk`` helper and pull
key fields from the JUMBF payload without a full CBOR parser.
This is a *metadata-based* proxy: Google (Imagen/Gemini) and OpenAI
(ChatGPT/DALL-E/gpt-image) embed an invisible SynthID watermark alongside
a C2PA manifest, so a C2PA manifest signed by one of them on AI-generated
content implies SynthID in the pixels. Adobe Firefly / Microsoft Designer
sign C2PA but do not use SynthID, so they return None.
The verdict is reliable only while the C2PA manifest is intact -- absence
is not proof, because C2PA can be stripped while the pixel watermark
survives, and the pixel watermark itself is not locally detectable
(proprietary decoder).
Args:
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
Returns:
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
"""
import re
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_vendors_in
from remove_ai_watermarks.noai.c2pa import extract_c2pa_chunk
# PNG: the caBX chunk parser gives a clean, structured issuer.
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
if vendors:
return ", ".join(vendors)
raw = extract_c2pa_chunk(image_path)
if raw is None:
return {}
# extract_c2pa_chunk returns chunk_header (8 bytes) + data + crc (4 bytes).
payload = raw[8:-4]
result: dict[str, str] = {"c2pa_manifest": f"C2PA manifest ({len(payload)} bytes)"}
def _cbor_text_after(key: bytes) -> str | None:
"""Return the CBOR text-string immediately following ``key``.
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77),
1-byte (0x78 NN), and 2-byte (0x79 NN NN).
"""
idx = payload.find(key)
if idx < 0:
return None
p = idx + len(key)
if p >= len(payload):
return None
head = payload[p]
if 0x60 <= head <= 0x77:
length, start = head - 0x60, p + 1
elif head == 0x78 and p + 1 < len(payload):
length, start = payload[p + 1], p + 2
elif head == 0x79 and p + 2 < len(payload):
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
else:
return None
raw_str = payload[start : start + length]
try:
return raw_str.decode("utf-8")
except UnicodeDecodeError:
return raw_str.decode("latin1", errors="replace")
if generator := _cbor_text_after(b"name"):
result["claim_generator"] = generator
if spec := _cbor_text_after(b"specVersion"):
result["c2pa_spec"] = spec
dst_match = re.search(
rb"(http://cv\.iptc\.org/newscodes/digitalsourcetype/[A-Za-z0-9_-]+)",
payload,
)
if dst_match:
result["digital_source_type"] = dst_match.group(1).decode("latin1")
actions = sorted(
{m.decode("latin1") for m in re.findall(rb"c2pa\.(created|converted|edited|opened|placed)", payload)}
)
if actions:
result["c2pa_actions"] = ", ".join(actions)
# Scan cert DN printable strings for the signer org name.
signer_match = re.search(
rb"([A-Za-z][A-Za-z0-9 .,&'()\-]{2,48}OpenAI[A-Za-z0-9 .,&'()\-]{0,48})",
payload,
)
if signer_match:
result["signer"] = signer_match.group(1).decode("latin1").strip()
return result
# Non-PNG containers (JPEG APP11, WebP, AVIF/HEIF/JXL uuid box) keep the
# C2PA manifest where the PNG parser can't reach it. Binary-scan for the
# same signal: a C2PA manifest from a SynthID-using issuer on AI content.
with open(image_path, "rb") as f:
data = f.read(1024 * 1024)
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
if not (has_c2pa and ai_source):
return None
matched = synthid_vendors_in(data)
return ", ".join(matched) if matched else None
def get_ai_metadata(image_path: Path) -> dict[str, str]:
@@ -229,6 +194,8 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
"""
from PIL import Image
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_verdict
result: dict[str, str] = {}
with Image.open(image_path) as img:
@@ -241,7 +208,24 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
else:
result[key] = str(value)
result.update(_scan_png_c2pa_chunk(image_path))
# C2PA manifest fields from the single canonical parser (noai/c2pa.py).
c2pa = extract_c2pa_info(image_path)
for key in (
"c2pa_manifest",
"claim_generator",
"c2pa_spec",
"issuer",
"source_type",
"actions",
"synthid_watermark",
):
if key in c2pa:
result.setdefault(key, str(c2pa[key]))
# Non-PNG containers (JPEG/WebP/AVIF): extract_c2pa_info is PNG-only, so
# fall back to the format-agnostic source check for the SynthID verdict.
if "synthid_watermark" not in result and (vendor := synthid_source(image_path)):
result.setdefault("synthid_watermark", synthid_verdict(vendor))
return result
+67 -31
View File
@@ -29,6 +29,7 @@ from remove_ai_watermarks.noai.constants import (
C2PA_ISSUERS,
C2PA_SIGNATURES,
PNG_SIGNATURE,
SYNTHID_C2PA_ISSUERS,
)
@@ -129,10 +130,56 @@ def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
return c2pa_info
def _cbor_text_after(payload: bytes, key: bytes) -> str | None:
"""Return the CBOR text-string immediately following ``key`` in ``payload``.
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77), 1-byte
(0x78 NN), and 2-byte (0x79 NN NN). This reads the actual encoded value, so
it avoids the byte-grabbing artifacts a loose regex produces (e.g. the
leading length byte showing up as ``fGPT-4o``).
"""
idx = payload.find(key)
if idx < 0:
return None
p = idx + len(key)
if p >= len(payload):
return None
head = payload[p]
if 0x60 <= head <= 0x77:
length, start = head - 0x60, p + 1
elif head == 0x78 and p + 1 < len(payload):
length, start = payload[p + 1], p + 2
elif head == 0x79 and p + 2 < len(payload):
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
else:
return None
raw_str = payload[start : start + length]
try:
return raw_str.decode("utf-8")
except UnicodeDecodeError:
return raw_str.decode("latin1", errors="replace")
def synthid_verdict(vendors: str) -> str:
"""Human-readable SynthID-source verdict, shared by all callers."""
return f"likely present ({vendors} embeds SynthID with C2PA)"
def synthid_vendors_in(buffer: bytes) -> list[str]:
"""Return SynthID-using C2PA issuer names whose signature appears in ``buffer``.
Shared by the PNG caBX parser and the format-agnostic binary scan so both
apply the same SYNTHID_C2PA_ISSUERS rule against their respective bytes.
"""
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in buffer and sig in SYNTHID_C2PA_ISSUERS})
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
"""Parse C2PA chunk data and populate info dictionary."""
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
# Find issuers
issuers = []
issuers: list[str] = []
for sig, name in C2PA_ISSUERS.items():
if sig in chunk_data:
issuers.append(name)
@@ -140,44 +187,22 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
c2pa_info["issuer"] = ", ".join(set(issuers))
# Find AI tools
ai_tools = []
ai_tools: list[str] = []
for sig, name in C2PA_AI_TOOLS.items():
if sig in chunk_data:
ai_tools.append(name)
if ai_tools:
c2pa_info["ai_tool"] = ", ".join(set(ai_tools))
# Extract software agent (multiple patterns)
patterns = [
rb"softwareAgent.*?dname([^\x00]+?)(?:q|l|m|n)",
rb"software_agent[^\x00]*?([A-Za-z0-9_\-\.]+)",
rb"Software[^\x00]*?([A-Za-z0-9_\-\. ]+)",
]
for pattern in patterns:
match = re.search(pattern, chunk_data, re.DOTALL | re.IGNORECASE)
if match:
agent = match.group(1).decode("utf-8", errors="ignore").strip()
if agent and len(agent) < 100:
c2pa_info["software_agent"] = agent
break
# Extract claim generator (multiple patterns)
claim_patterns = [
rb"claim_generator[^\x00]*?([A-Za-z0-9_\-\.\/\:]+)",
rb"claimGenerator[^\x00]*?([A-Za-z0-9_\-\.\/\:]+)",
rb"dname([^\x00]{3,50})(?:q|l|m|n|i)",
]
for pattern in claim_patterns:
match = re.search(pattern, chunk_data, re.DOTALL | re.IGNORECASE)
if match:
gen_name = match.group(1).decode("utf-8", errors="ignore").strip()
# Filter out common false positives
if gen_name and len(gen_name) < 100 and not gen_name.startswith(("\\x", "\\\\x")):
c2pa_info["claim_generator"] = gen_name
break
# Claim generator and spec version: read the CBOR text-string values
# directly (regex byte-grabbing produced artifacts like ``fGPT-4o``).
if generator := _cbor_text_after(chunk_data, b"name"):
c2pa_info["claim_generator"] = generator
if spec := _cbor_text_after(chunk_data, b"specVersion"):
c2pa_info["c2pa_spec"] = spec
# Find actions
actions = []
actions: list[str] = []
for sig, name in C2PA_ACTIONS.items():
if sig in chunk_data:
actions.append(name)
@@ -192,12 +217,23 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
c2pa_info["timestamps"] = [t.decode("utf-8") for t in timestamp_matches[:3]]
# Find digital source type
ai_source = False
if b"trainedAlgorithmicMedia" in chunk_data:
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
ai_source = True
elif b"algorithmicMedia" in chunk_data:
c2pa_info["source_type"] = "algorithmicMedia"
elif b"compositeWithTrainedAlgorithmicMedia" in chunk_data:
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
ai_source = True
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
# vendor (Google/OpenAI) on AI-generated content implies an invisible
# SynthID watermark in the pixels (see SYNTHID_C2PA_ISSUERS).
synthid_vendors = synthid_vendors_in(chunk_data)
if synthid_vendors and ai_source:
c2pa_info["synthid_vendors"] = synthid_vendors
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
@@ -87,6 +87,27 @@ C2PA_ISSUERS = {
b"Truepic": "Truepic",
}
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
# watermark -- a metadata proxy for "SynthID is in the pixels":
# - Google (Imagen/Gemini): embeds SynthID, long-standing (DeepMind docs).
# - OpenAI (ChatGPT/Codex/API): pairs SynthID with C2PA since ~2026-05-20.
# Confirmed by OpenAI's Help Center ("C2PA and SynthID in OpenAI-generated
# images", updated 2026-05-21): "Images generated with ChatGPT, Codex, and
# our API include both C2PA metadata and SynthID watermarks." OpenAI also
# notes a signal may be absent if "the image was created before these
# signals were available" -- so OpenAI images from BEFORE the rollout carry
# C2PA WITHOUT SynthID (e.g. data/samples/openai-images-2/amur-leopard.png,
# C2PA timestamp 2026-04-22). For OpenAI the proxy is therefore "likely",
# not certain; the verdict string is hedged accordingly. OpenAI's own oracle
# is openai.com/verify (Google's is the Gemini app "Verify with SynthID").
# The issuer byte ("OpenAI"/"Google") is verified locally against data/samples;
# the SynthID pairing is documented behavior (Google: DeepMind; OpenAI: above).
# Adobe Firefly and Microsoft Designer sign C2PA but do NOT use SynthID, so a
# C2PA manifest alone is not a SynthID signal -- the issuer is. The pixel
# watermark is not locally detectable (proprietary decoder); the C2PA companion
# is the proxy, and only while the manifest is intact.
SYNTHID_C2PA_ISSUERS = frozenset({b"Google", b"OpenAI"})
# C2PA known AI tools
C2PA_AI_TOOLS = {
b"GPT-4o": "GPT-4o",
@@ -106,6 +127,8 @@ C2PA_ACTIONS = {
b"c2pa.filtered": "filtered",
b"c2pa.cropped": "cropped",
b"c2pa.resized": "resized",
b"c2pa.opened": "opened",
b"c2pa.placed": "placed",
}
# PNG signature