Fix SynthID provenance evidence and release 0.26.1

This commit is contained in:
Victor Kuznetsov
2026-08-06 18:17:40 -07:00
parent d3eeba3a33
commit 14b7247e0b
21 changed files with 235 additions and 127 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.26.0"
__version__ = "0.26.1"
__all__ = [
"BatchSummary",
+26 -8
View File
@@ -15,12 +15,12 @@ from typing import TYPE_CHECKING, Any, cast
from remove_ai_watermarks._internal.constants import (
C2PA_ACTIONS,
C2PA_AI_TOOLS,
C2PA_AI_VENDORS,
C2PA_CHUNK_TYPE,
C2PA_ISSUERS,
C2PA_SIGNATURES,
C2PA_SOFT_BINDINGS,
PNG_SIGNATURE,
SYNTHID_C2PA_ISSUERS,
)
logger = logging.getLogger(__name__)
@@ -200,18 +200,34 @@ def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
def synthid_verdict(vendors: str) -> str:
"""Describe why metadata implies a likely pixel-level SynthID watermark."""
return f"likely present ({vendors} embeds SynthID with C2PA)"
"""Describe why supported provenance establishes a SynthID watermark."""
return f"present according to {vendors} provenance"
def _names_present(buffer: bytes, registry: dict[bytes, str]) -> list[str]:
return sorted({label for token, label in registry.items() if token in buffer})
def synthid_vendors_in(buffer: bytes) -> list[str]:
"""List matching C2PA issuers known to pair their manifests with SynthID."""
registry = {token: label for token, label in C2PA_ISSUERS.items() if token in SYNTHID_C2PA_ISSUERS}
return _names_present(buffer, registry)
def synthid_evidence_vendors_in(buffer: bytes, *, has_watermark_action: bool | None = None) -> list[str]:
"""List issuers whose provenance establishes SynthID for this asset.
Google applies SynthID to all media generated by its tools, so its AI C2PA
provenance is sufficient. OpenAI C2PA predates OpenAI's SynthID rollout;
current manifests distinguish the watermarked generation with the explicit
``c2pa.watermarked.*`` action. A legacy OpenAI issuer token alone therefore
remains provenance evidence, but not SynthID evidence.
"""
if has_watermark_action is None:
has_watermark_action = b"c2pa.watermarked" in buffer
return sorted(
{
vendor.org
for vendor in C2PA_AI_VENDORS
if vendor.synthid
and vendor.issuer in buffer
and (has_watermark_action or not vendor.synthid_requires_watermark_action)
}
)
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
@@ -244,7 +260,9 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
elif b"algorithmicMedia" in buffer:
info["source_type"] = "algorithmicMedia"
synthid = synthid_vendors_in(buffer)
if b"c2pa.watermarked" in buffer:
info["watermarked"] = True
synthid = synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False))
if ai_source and synthid:
info["synthid_vendors"] = synthid
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
@@ -38,6 +38,7 @@ class C2paAiVendor:
needle: str | None
synthid: bool = False
asserts_ai: bool = False
synthid_requires_watermark_action: bool = False
def _vendor(
@@ -48,16 +49,32 @@ def _vendor(
*,
synthid: bool = False,
asserts_ai: bool = False,
synthid_requires_watermark_action: bool = False,
) -> C2paAiVendor:
token = issuer.encode() if isinstance(issuer, str) else issuer
return C2paAiVendor(token, org, platform, needle, synthid, asserts_ai)
return C2paAiVendor(
token,
org,
platform,
needle,
synthid=synthid,
asserts_ai=asserts_ai,
synthid_requires_watermark_action=synthid_requires_watermark_action,
)
# Order is product priority when a manifest mentions more than one organization.
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
_vendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
_vendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
_vendor(b"OpenAI", "OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)", "OpenAI", synthid=True),
_vendor(
b"OpenAI",
"OpenAI",
"OpenAI (ChatGPT / gpt-image / DALL-E / Sora)",
"OpenAI",
synthid=True,
synthid_requires_watermark_action=True,
),
_vendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
_vendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
_vendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
@@ -85,7 +102,6 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
SYNTHID_C2PA_ISSUERS = frozenset(vendor.issuer for vendor in C2PA_AI_VENDORS if vendor.synthid)
C2PA_AI_TOOLS = {
token.encode(): label
@@ -148,7 +164,7 @@ AI_GENERATOR_TOKENS = frozenset(
}
)
_C2PA_ACTION_NAMES = _tokens("created|converted|edited|filtered|cropped|resized|opened|placed")
_C2PA_ACTION_NAMES = _tokens("created|converted|edited|filtered|cropped|resized|opened|placed|watermarked.unbound")
C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAMES}
@@ -135,7 +135,7 @@ def resolve_strength(
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
"""Select the strength cohort using the input's SynthID provenance proxy."""
"""Select the strength cohort using the input's SynthID provenance evidence."""
try:
from remove_ai_watermarks.metadata import synthid_source
+2 -2
View File
@@ -460,7 +460,7 @@ def _no_invisible_signal_exit(source: Path) -> NoReturn:
the scrub regardless.
"""
console.print(
" No invisible AI watermark detected (no C2PA/SynthID proxy, no open\n"
" No invisible AI watermark detected (no C2PA/SynthID provenance, no open\n"
" watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
" only degrade the image with nothing to remove, so no output was written.\n"
" This does NOT prove the image is clean: a pixel watermark such as SynthID\n"
@@ -882,7 +882,7 @@ def _print_metadata_report(source: Path, has_ai: bool, metadata: dict[str, str])
console.print(f" Warning: AI metadata detected in {source.name}:")
if synthid := metadata.get("synthid_watermark"):
console.print(f" Warning: SynthID watermark (inferred from C2PA metadata) {synthid}")
console.print(f" Warning: SynthID watermark {synthid}")
table = Table(show_header=True, header_style="bold")
table.add_column("Key", style="cyan")
table.add_column("Value")
+16 -22
View File
@@ -6,8 +6,8 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
the signing platform (OpenAI, Google, Adobe, Microsoft).
- **IPTC ``digitalSourceType``** "Made with AI" marker (Meta, X, others).
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
- **SynthID metadata proxy** -- a C2PA companion from a SynthID-using vendor
(Google / OpenAI) implies the invisible pixel watermark.
- **SynthID provenance evidence** -- Google AI C2PA follows Google's all-media
policy; current OpenAI C2PA explicitly declares a watermark action.
- **Registered visible marks** (optional; needs cv2/numpy, no GPU) through the
shared watermark registry.
@@ -31,7 +31,7 @@ from remove_ai_watermarks._internal.c2pa import (
cbor_text_after,
extract_c2pa_info,
soft_binding_vendors_in,
synthid_vendors_in,
synthid_evidence_vendors_in,
synthid_verdict,
)
from remove_ai_watermarks._internal.constants import (
@@ -110,12 +110,8 @@ _STRIP_CAVEAT = (
"text chunks are stripped by re-encoding, screenshots, or social-media upload."
)
_SYNTHID_CAVEAT = (
"SynthID is a metadata proxy here; the pixel watermark is not locally "
"verifiable (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
)
_OPENAI_CAVEAT = (
"OpenAI began pairing SynthID with C2PA around 2026-05; OpenAI images from "
"before the rollout carry C2PA without SynthID, so the SynthID verdict is 'likely'."
"SynthID presence comes from supported provenance here; the pixel watermark is not locally "
"decoded (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
)
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
_INVISIBLE_WM_CAVEAT = (
@@ -451,7 +447,7 @@ class ProvenanceReport:
# signal: AIGC, local gen params, xAI, ...).
ai_source_kind: str | None = None
# True when the AI verdict rests on a metadata or embedded-invisible signal
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
# (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local gen params, EXIF/xAI, or
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
# set of signals an invisible/diffusion scrub targets: a visible-only or
@@ -697,7 +693,7 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
# and a SynthID-Google proxy, or Adobe Firefly + its Adobe TrustMark soft
# and Google SynthID provenance, or Adobe Firefly + its Adobe TrustMark soft
# binding); two DIFFERENT keys from independent generator stamps are a
# contradiction (a C2PA OpenAI manifest on an image whose EXIF says "Ideogram
# AI"). Substring match on the lowercased platform/detail string; first hit wins,
@@ -755,14 +751,14 @@ def _vendor_of(text: str | None) -> str | None:
# Clash-detection provenance sources. Rule 1 (below) flags two AI vendors only
# when they come from *independent* signals. The C2PA issuer attribution and the
# SynthID proxy are NOT independent -- the proxy is inferred from the same C2PA
# SynthID evidence are NOT independent -- both are read from the same C2PA
# manifest -- so they share one source. A multi-actor manifest (a product wrapping
# another vendor's engine, e.g. Microsoft+OpenAI or Microsoft+Google; or an edit
# chain like Adobe over a Gemini original) legitimately names several vendors in
# one valid chain and must not read as spoofing. Families not listed here are each
# their own independent source (EXIF/XMP generator, IPTC AISystemUsed, AIGC, ...).
# The single C2PA-manifest source shared by the issuer attribution and the SynthID
# proxy (both inferred from the same embedded manifest). Rule 2 keys off it too:
# evidence (both read from the same embedded manifest). Rule 2 keys off it too:
# the camera device label is read from this manifest, so an AI marker is a clash
# only when its source differs from this (i.e. it is genuinely independent).
_C2PA_MANIFEST_SOURCE = "c2pa_manifest"
@@ -788,7 +784,7 @@ def _integrity_clashes(
Args:
ai_vendors: family name -> normalized AI-origin vendor, one entry per
generator-stamped signal (C2PA issuer when the source is AI, SynthID
proxy, EXIF/XMP generator tag, IPTC AISystemUsed, xAI, AIGC label).
provenance, EXIF/XMP generator tag, IPTC AISystemUsed, xAI, AIGC label).
camera_label: a camera/verified-capture C2PA device platform, if one was
identified (Pixel, Leica, Sony, Nikon, Truepic), else None.
camera_has_ai_marker: True when an AI-generation stamp coexists with the
@@ -802,7 +798,7 @@ def _integrity_clashes(
# Rule 1: two genuinely INDEPENDENT signals naming different AI vendors. Two
# families clash only when they belong to different provenance sources (see
# _CLASH_SOURCE) AND name different vendors -- so multiple vendors named within
# one C2PA manifest (c2pa issuer + synthid proxy) do not flag.
# one C2PA manifest (C2PA issuer + SynthID provenance) do not flag.
# The generic TC260 AIGC label is a Chinese regulatory "this is AI" stamp. When a
# Chinese TC260-applying vendor (ByteDance) is ALSO attributed, the label is that
# vendor's own stamp on its own output, so attribute it to that vendor -- a legit
@@ -835,7 +831,7 @@ def _integrity_clashes(
# a contradiction. A device that both captures and runs on-device generative
# AI (Google Pixel Magic Editor / Pixel Studio) records the capture and the
# AI edit in ONE manifest, so the AI vendor is named only from that same
# manifest (c2pa issuer + synthid proxy) -- a legitimate edit chain, not a
# manifest (C2PA issuer + SynthID provenance) -- a legitimate edit chain, not a
# spoof. An EXIF/XMP generator, IPTC field, TC260 AIGC label, or second
# manifest naming AI on a camera capture is the real laundering tell.
independent_ai_marker = any(grp != _C2PA_MANIFEST_SOURCE for grp in source.values())
@@ -1134,7 +1130,7 @@ def _identify_from_evidence(
if platform is None:
platform = f"C2PA signer: {cloud_vendor} (cloud manifest)"
# ── SynthID metadata proxy ──────────────────────────────────────
# ── SynthID provenance evidence ─────────────────────────────────
# Structured first (the PNG caBX parser and the manifest store both fill
# `synthid_watermark`), then the byte scan for the containers that keep the
# manifest where no parser reaches it.
@@ -1151,13 +1147,11 @@ def _identify_from_evidence(
# reusing the derived `has_c2pa` / `source_kind` above, which are broader:
# the file path's answer must not move.
trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_vendors_in(region)):
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_evidence_vendors_in(region)):
synthid = synthid_verdict(", ".join(vendors))
if synthid:
watermarks.append(f"SynthID watermark, inferred from C2PA metadata ({synthid})")
watermarks.append(f"SynthID watermark ({synthid})")
caveats.append(_SYNTHID_CAVEAT)
if _vendor_of(synthid) == "OpenAI":
caveats.append(_OPENAI_CAVEAT)
if v := _vendor_of(synthid):
ai_vendor_claims["synthid"] = v
@@ -1423,7 +1417,7 @@ def has_invisible_target(image_path: Path) -> bool:
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
is handled by the separate visible pass and is NOT a diffusion target -- and
``check_invisible=True`` so an open watermark counts. Returns
``report.ai_from_metadata`` (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
+12 -12
View File
@@ -787,18 +787,18 @@ def _iptc_ai_system_impl(image_path: Path) -> str | None:
def synthid_source(image_path: Path) -> str | None:
"""Return the vendor name(s) if the image carries a SynthID pixel watermark.
"""Return the vendor name(s) when provenance establishes SynthID.
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.
This is provenance-based, not a local pixel decode. Google states that all
media generated by its tools carries SynthID, so Google AI C2PA establishes
the mark. OpenAI C2PA existed before OpenAI adopted SynthID, so OpenAI also
requires the explicit ``c2pa.watermarked.*`` action used by current manifests.
Adobe Firefly and Microsoft 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).
The evidence is readable only while the C2PA manifest is intact. Absence is
not proof: 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).
@@ -806,7 +806,7 @@ def synthid_source(image_path: Path) -> str | None:
Returns:
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
"""
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info, synthid_vendors_in
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info, synthid_evidence_vendors_in
# PNG: the caBX chunk parser gives a clean, structured issuer.
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
@@ -822,7 +822,7 @@ def synthid_source(image_path: Path) -> str | None:
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)
matched = synthid_evidence_vendors_in(data)
return ", ".join(matched) if matched else None