From 14b7247e0b1bd19c4a346c953a00367c0a34db8e Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Thu, 6 Aug 2026 18:17:40 -0700 Subject: [PATCH] Fix SynthID provenance evidence and release 0.26.1 --- data/synthid/README.md | 4 +- docs/known-limitations.md | 7 +-- docs/module-internals.md | 2 +- docs/supported-signals.md | 9 ++-- docs/synthid.md | 28 ++++++---- pyproject.toml | 2 +- src/remove_ai_watermarks/__init__.py | 2 +- src/remove_ai_watermarks/_internal/c2pa.py | 34 +++++++++--- .../_internal/constants.py | 24 +++++++-- .../_internal/watermark_profiles.py | 2 +- src/remove_ai_watermarks/cli.py | 4 +- src/remove_ai_watermarks/identify.py | 38 ++++++------- src/remove_ai_watermarks/metadata.py | 24 ++++----- tests/test_cli.py | 2 +- tests/test_identify.py | 54 ++++++++++++------- tests/test_metadata.py | 44 +++++++++------ tests/test_metadata_internals.py | 47 +++++++++++----- tests/test_platform.py | 2 +- tests/test_synthid_corpus.py | 8 +-- tests/test_video.py | 23 ++++++-- uv.lock | 2 +- 21 files changed, 235 insertions(+), 127 deletions(-) diff --git a/data/synthid/README.md b/data/synthid/README.md index 762424f..55cf3b5 100644 --- a/data/synthid/README.md +++ b/data/synthid/README.md @@ -56,7 +56,9 @@ Ground-truth quality, strongest first: - `gemini-app` — checked via the Gemini app "Verify with SynthID" feature. Gold standard for the pixel watermark (Google models). - `openai-verify` — checked via openai.com/verify (gold standard for OpenAI ChatGPT/Codex/API images). - `synthid-portal` — checked via Google's SynthID Detector portal. -- `c2pa-metadata` — issuer-only proxy (Google/OpenAI C2PA manifest present). Weaker: the C2PA can be stripped while the pixel watermark remains. +- `c2pa-metadata` — supported provenance evidence (Google AI C2PA, or OpenAI + C2PA with an explicit `c2pa.watermarked.*` action). Weaker than a provider + oracle: C2PA can be stripped while the pixel watermark remains. - `third-party` — label asserted by an external dataset, not independently verified. - `none` — unverified. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index be8e3fb..4d48ace 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -66,9 +66,10 @@ still alter small text or difficult faces. ### Removal cannot be verified locally for proprietary SynthID -The project has no public local SynthID pixel decoder. It can infer likely -presence from supported provenance metadata, but a missing metadata proxy is -not a negative pixel verdict. +The project has no public local SynthID pixel decoder. It recognizes presence +from supported provenance metadata: Google AI C2PA under Google's all-media +policy, or current OpenAI C2PA with an explicit watermark action. A missing +provenance signal is not a negative pixel verdict. For important outputs: diff --git a/docs/module-internals.md b/docs/module-internals.md index 31d48ec..a3d5645 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -436,7 +436,7 @@ metadata extraction from verdict logic: - `identify_from_evidence` evaluates that evidence without reopening the source. Rules that decide a verdict live here, not in extraction: extraction has two implementations, and a rule in only one of them is a rule the other lacks. The - SynthID proxy is the worked example — its structured form comes from the manifest, + SynthID provenance evidence is the worked example — its structured form comes from the manifest, and the byte-scan fallback for containers no parser reaches runs in the verdict, so both extractors reach the same answer. It did not, and the record path silently reported no SynthID for images the file path flagged. diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 60a27de..88d18f8 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -122,9 +122,12 @@ Current pipeline values, both CUDA-only: The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name is rejected at parse time rather than remapped onto a surviving profile. -SynthID does not have a public local pixel decoder in this project. The tool can -infer likely presence from supported provenance metadata, but after that -metadata is removed a local negative result is inconclusive. +SynthID does not have a public local pixel decoder in this project. The tool +recognizes presence from supported provenance: Google AI C2PA under Google's +all-media watermark policy, and current OpenAI C2PA carrying an explicit +`c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action does not +assert SynthID. After provenance metadata is removed, a local negative result +is still inconclusive. For MP4, MOV, and M4V, `video invisible` or the explicit `video all --invisible` option can regenerate the video through a VAE and strip diff --git a/docs/synthid.md b/docs/synthid.md index 9dee465..35c9caf 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -289,14 +289,19 @@ The external variant SynthID-O is available "through partnerships" only. Our tool cannot locally detect SynthID presence or absence -- this is by design, not a gap we can fill. -### 3.2 How our tool detects SynthID (metadata proxy) +### 3.2 How our tool recognizes SynthID from provenance -We detect SynthID indirectly: if the image's C2PA manifest is signed by a -known SynthID-using issuer (Google, OpenAI), we infer SynthID is present. This -is a **metadata proxy**, not a pixel watermark decode. It works while the C2PA -manifest is intact, and is silent once the manifest is stripped or the image -is re-encoded without C2PA (e.g., a screenshot, a social-media re-upload, or -after `metadata --remove`). +We recognize SynthID indirectly from supported C2PA evidence; this is not a +pixel watermark decode. Google states that all media generated by its tools is +watermarked, so Google AI C2PA establishes SynthID. OpenAI C2PA predates its +SynthID rollout, but current manifests add the signed +`c2pa.watermarked.unbound` action; OpenAI provenance establishes SynthID only +when that action is present. Legacy OpenAI C2PA without it remains valid origin +evidence but does not assert a pixel watermark. + +This works while the C2PA manifest is intact and is silent once the manifest is +stripped or the image is re-encoded without C2PA (e.g., a screenshot, a +social-media re-upload, or after `metadata --remove`). This is why: - `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is @@ -409,8 +414,9 @@ OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21): > "ChatGPT images include both C2PA metadata and SynthID watermarks." This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without -SynthID. Our C2PA proxy therefore over-reports SynthID presence on old images -(hence the `_OPENAI_CAVEAT` hedging flag in the codebase). +SynthID. Current OpenAI manifests distinguish the watermarked output with the +signed `c2pa.watermarked.unbound` action. The detector requires that action, so +old OpenAI C2PA remains an origin signal without becoming a SynthID claim. ### 4.3 Other vendors @@ -558,7 +564,7 @@ for `sdxl`, not a separate certification (no local SynthID detector to self-veri The higher strength costs little quality where it matters, because `controlnet` is now the default pipeline, so `sdxl` is reached only via an explicit `--pipeline sdxl` (a deliberate opt-down), where over-regeneration has no faces/text to damage. -This uses the vendor signal we DO have locally (the C2PA SynthID proxy) to avoid the +This uses the vendor signal we DO have locally (the C2PA SynthID provenance) to avoid the overkill of a single high default on OpenAI images, without needing a local pixel detector. An explicit `--strength` always wins. If the watermark still survives (e.g. a large native Gemini beyond the capped-1536 validation), raise toward 0.35-0.40 (0.40 @@ -617,7 +623,7 @@ Two constraints on reading this: - **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini app. The two oracles detect different payloads. - **A quiet `identify` output after processing is not proof of removal.** It - means the metadata proxy is gone. The pixel watermark state is unknown without + means the provenance evidence is gone. The pixel watermark state is unknown without an oracle check. - **After removal, the output may carry forensic artifacts** detectable by an independent classifier even if the vendor oracle reads negative. Defeating the diff --git a/pyproject.toml b/pyproject.toml index c93695e..df686a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remove-ai-watermarks" -version = "0.26.0" +version = "0.26.1" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.10.1" diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 7197d67..a34122d 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -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", diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index d89255a..3fe975c 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -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)) diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 46b370b..c55ed23 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -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} diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 9c649ef..d2b2cf4 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -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 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index cda3f1f..5adecb6 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -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") diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 2dfc324..f2cf6df 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -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 diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index eeae2e7..23ff68f 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index e6184d2..cf482b2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -398,7 +398,7 @@ class TestInvisibleCommand: """P0#5: when no invisible AI watermark is locally detectable, the diffusion scrub must NOT run (it would only degrade a clean image). Mirrors the visible no-mark contract: write no output, exit 2, and DO NOT imply the image is - clean (a stripped SynthID proxy is not proof of absence).""" + clean (stripped SynthID provenance is not proof of absence).""" mock_cls, mock_engine = _mock_invisible_engine() output = tmp_path / "clean.png" with ( diff --git a/tests/test_identify.py b/tests/test_identify.py index 330a9e5..7df8b7a 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -284,7 +284,10 @@ class TestIdentifyNonPng: assert "Imagen" in c2pa_signal.detail def test_openai_jpeg_has_synthid(self, tmp_path: Path): - path = self._c2pa_jpeg(tmp_path, b"OpenAI DALL-E ... trainedAlgorithmicMedia") + path = self._c2pa_jpeg( + tmp_path, + b"OpenAI DALL-E ... trainedAlgorithmicMedia ... c2pa.watermarked.unbound", + ) r = identify(path, check_visible=False) assert any("SynthID" in w for w in r.watermarks) @@ -446,7 +449,7 @@ class TestIdentifyRealSamples: assert r.platform assert "OpenAI" in r.platform assert any("C2PA" in w for w in r.watermarks) - assert any("SynthID" in w for w in r.watermarks) + assert not any("SynthID" in w for w in r.watermarks) def test_adobe_firefly_has_no_synthid(self): r = identify(SAMPLES_DIR / "firefly-1.png", check_visible=False) @@ -853,24 +856,25 @@ class TestIdentifyVisibleTextMarks: @pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present") class TestIdentifyCaveats: - def test_openai_hedge_caveat_present(self): + def test_legacy_openai_has_no_synthid_claim(self): r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False) - assert any("before the rollout" in c for c in r.caveats) + assert not any("SynthID" in watermark for watermark in r.watermarks) + assert not any("before the rollout" in c for c in r.caveats) - def test_synthid_proxy_caveat_present(self): + def test_legacy_openai_has_no_synthid_proxy_caveat(self): r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False) - assert any("not locally" in c for c in r.caveats) + assert not any("not locally" in c for c in r.caveats) def test_caveats_are_deduplicated(self): r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False) assert len(r.caveats) == len(set(r.caveats)) -class TestOpenAiCaveatVendorScoped: - """The OpenAI rollout caveat keys on the normalized SynthID vendor, not a raw - "OpenAI" substring over the issuer + verdict blob -- so a Google-SynthID - manifest with an incidental "OpenAI" byte elsewhere is not mislabeled, while - a genuine OpenAI manifest still gets the hedge. +class TestSynthIDProvenanceEvidence: + """Google's provider policy and OpenAI's explicit watermark action are evidence. + + A bare legacy OpenAI C2PA manifest is not: those credentials existed before + OpenAI adopted SynthID. """ @staticmethod @@ -906,14 +910,28 @@ class TestOpenAiCaveatVendorScoped: self._png_chunk(b"tEXt", b"note\x00signed via OpenAI trust chain"), ) r = identify(png, check_visible=False, check_invisible=False) - assert any("SynthID watermark, inferred from C2PA metadata (likely present (Google" in w for w in r.watermarks) + assert any("SynthID watermark (present according to Google" in w for w in r.watermarks) assert not any("before the rollout" in c for c in r.caveats) - def test_openai_synthid_still_gets_caveat(self, tmp_path: Path): - png = self._png(tmp_path, "oa.png", self._png_chunk(b"caBX", b"jumbc2pa OpenAI ... trainedAlgorithmicMedia")) + def test_openai_watermark_action_asserts_synthid(self, tmp_path: Path): + png = self._png( + tmp_path, + "oa.png", + self._png_chunk( + b"caBX", + b"jumbc2pa OpenAI ... trainedAlgorithmicMedia ... c2pa.watermarked.unbound", + ), + ) r = identify(png, check_visible=False, check_invisible=False) - assert any("SynthID watermark, inferred from C2PA metadata (likely present (OpenAI" in w for w in r.watermarks) - assert any("before the rollout" in c for c in r.caveats) + assert any("SynthID watermark (present according to OpenAI" in w for w in r.watermarks) + assert not any("before the rollout" in c for c in r.caveats) + + def test_legacy_openai_without_watermark_action_does_not_assert_synthid(self, tmp_path: Path): + png = self._png( + tmp_path, "oa-old.png", self._png_chunk(b"caBX", b"jumbc2pa OpenAI ... trainedAlgorithmicMedia") + ) + r = identify(png, check_visible=False, check_invisible=False) + assert not any("SynthID" in watermark for watermark in r.watermarks) def test_dreamina_png_cabx_without_source_type(self, tmp_path: Path): # Real Dreamina PNGs carry the "Dreamina/x.y" generator in an ingredient @@ -1160,13 +1178,13 @@ class TestIntegrityClashesHelper: assert "Ideogram" in clashes[0] def test_same_vendor_two_signals_no_clash(self): - # C2PA Google + SynthID-Google proxy is consistent, not a contradiction. + # C2PA Google + Google SynthID provenance is consistent, not a contradiction. assert _integrity_clashes({"c2pa": "Google", "synthid": "Google"}, None, camera_has_ai_marker=True) == [] def test_multi_actor_manifest_no_clash(self): # A multi-actor C2PA manifest names a product + the engine it wraps in ONE # valid chain (Microsoft Designer on OpenAI, Microsoft on Google, Adobe over - # a Gemini original). The c2pa issuer attribution and the SynthID proxy share + # a Gemini original). The C2PA issuer attribution and SynthID provenance share # the same manifest source, so the differing vendors must NOT read as a clash. for c2pa_vendor, synthid_vendor in (("Microsoft", "OpenAI"), ("Microsoft", "Google"), ("Adobe", "Google")): assert ( diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 9753b23..00dec60 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -497,7 +497,7 @@ class TestGetAiMetadataRealSample: meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png") assert "claim_generator" in meta assert "OpenAI" in meta["issuer"] - assert "OpenAI" in meta["synthid_watermark"] + assert "synthid_watermark" not in meta assert "trainedAlgorithmicMedia" in meta["source_type"] @@ -538,20 +538,18 @@ def test_bare_algorithmic_media_not_flagged_ai(tmp_path: Path): class TestSynthIDSource: """SynthID detection via the C2PA companion manifest. - Google (Imagen/Gemini) and OpenAI (ChatGPT/DALL-E/gpt-image) pair an - invisible SynthID pixel watermark with a C2PA manifest. Adobe Firefly and - Microsoft Designer sign C2PA Content Credentials but do NOT use SynthID, - so the discriminating signal is the C2PA *issuer*, not the mere presence - of a manifest. These tests run against real, committed sample images. + Google provenance implies SynthID under Google's all-media policy. OpenAI + provenance requires the explicit C2PA watermark action: older ChatGPT + Content Credentials predate SynthID and must not be upgraded into a pixel + watermark claim merely because the issuer is OpenAI. """ - def test_openai_chatgpt_is_synthid_source(self): - assert synthid_source(SAMPLES_DIR / "chatgpt-1.png") == "OpenAI" + def test_legacy_openai_chatgpt_is_not_synthid_source(self): + assert synthid_source(SAMPLES_DIR / "chatgpt-1.png") is None - def test_openai_verdict_in_get_ai_metadata(self): + def test_legacy_openai_verdict_absent_from_get_ai_metadata(self): meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png") - assert "synthid_watermark" in meta - assert "OpenAI" in meta["synthid_watermark"] + assert "synthid_watermark" not in meta def test_adobe_firefly_is_not_synthid_source(self): # Adobe signs C2PA (trainedAlgorithmicMedia) but embeds no SynthID. @@ -570,17 +568,33 @@ class TestSynthIDSourceNonPng: misses them. These use synthetic byte blobs (real fixtures aren't shipped). """ - def _c2pa_jpeg(self, tmp_path: Path, name: str, issuer: bytes, marker: bytes = b"trainedAlgorithmicMedia") -> Path: + def _c2pa_jpeg( + self, + tmp_path: Path, + name: str, + issuer: bytes, + marker: bytes = b"trainedAlgorithmicMedia", + action: bytes = b"", + ) -> Path: path = tmp_path / name # Minimal JPEG shell with an embedded C2PA-ish blob. - blob = b"jumbc2pa" + issuer + b"..." + marker + blob = b"jumbc2pa" + issuer + b"..." + marker + b"..." + action path.write_bytes(b"\xff\xd8\xff\xe1" + blob + b"\xff\xd9") return path - def test_openai_c2pa_in_jpeg(self, tmp_path: Path): - path = self._c2pa_jpeg(tmp_path, "chatgpt.jpg", b"OpenAI") + def test_openai_watermark_action_in_jpeg(self, tmp_path: Path): + path = self._c2pa_jpeg( + tmp_path, + "chatgpt.jpg", + b"OpenAI", + action=b"c2pa.watermarked.unbound", + ) assert synthid_source(path) == "OpenAI" + def test_legacy_openai_c2pa_without_watermark_action_is_none(self, tmp_path: Path): + path = self._c2pa_jpeg(tmp_path, "legacy-chatgpt.jpg", b"OpenAI") + assert synthid_source(path) is None + def test_google_c2pa_in_jpeg(self, tmp_path: Path): path = self._c2pa_jpeg(tmp_path, "gemini.jpg", b"Google") assert synthid_source(path) == "Google LLC" diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index 89afe0c..db51115 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -155,6 +155,13 @@ class TestC2PA: SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" +CURRENT_OPENAI_SAMPLE = ( + Path(__file__).resolve().parent.parent + / "data" + / "synthid" + / "originals" + / "ChatGPT Image May 30, 2026, 10_31_08 AM.png" +) @pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present") @@ -173,7 +180,14 @@ class TestC2PARealSamples: # CBOR-clean claim generator, no regex artifacts (e.g. "fGPT-4o"). assert info["claim_generator"] assert not info["claim_generator"].startswith("f") - assert "synthid_watermark" in info + assert "synthid_watermark" not in info + + @pytest.mark.skipif(not CURRENT_OPENAI_SAMPLE.exists(), reason="current OpenAI SynthID fixture not present") + def test_current_openai_watermark_action_asserts_synthid(self): + info = extract_c2pa_info(CURRENT_OPENAI_SAMPLE) + assert info["watermarked"] is True + assert "watermarked.unbound" in info["actions"] + assert "OpenAI" in info["synthid_watermark"] def test_extract_info_adobe_has_no_synthid(self): info = extract_c2pa_info(SAMPLES_DIR / "firefly-1.png") @@ -222,7 +236,7 @@ class TestC2PARealSamples: assert info["c2pa_manifest"].startswith("C2PA manifest (") # chunk path assert "OpenAI" in info["issuer"] assert "trainedAlgorithmicMedia" in info["source_type"] - assert "synthid_watermark" in info + assert "synthid_watermark" not in info class TestC2PAInjectValidation: @@ -269,7 +283,7 @@ class TestCborTextAfter: class TestSynthIDVerdict: def test_format(self): - assert synthid_verdict("OpenAI") == "likely present (OpenAI embeds SynthID with C2PA)" + assert synthid_verdict("OpenAI") == "present according to OpenAI provenance" def test_multiple_vendors(self): assert "Google LLC, OpenAI" in synthid_verdict("Google LLC, OpenAI") @@ -285,7 +299,7 @@ class TestParseChunkGuards: def test_clean_generator_kept(self): # "name" + CBOR text-string (head 0x69 = 0x60+9) "gpt-image" - chunk = b"...name" + bytes([0x69]) + b"gpt-image" + b"OpenAI trainedAlgorithmicMedia" + chunk = b"...name" + bytes([0x69]) + b"gpt-image" + b"OpenAI trainedAlgorithmicMedia c2pa.watermarked.unbound" info: dict = {} _parse_c2pa_chunk(chunk, info) assert info["claim_generator"] == "gpt-image" @@ -305,7 +319,7 @@ class TestC2PADigitalSourceType: """The three IPTC digitalSourceType variants drive the AI verdict. Only *trained* and *composite-with-trained* mean AI-generated (and so imply - a SynthID proxy for a SynthID vendor); plain ``algorithmicMedia`` is + SynthID provenance for a supported vendor); plain ``algorithmicMedia`` is procedural (not trained) and must NOT be flagged as AI. """ @@ -317,7 +331,12 @@ class TestC2PADigitalSourceType: assert "synthid_watermark" not in info # procedural, not AI-generated def test_composite_with_trained_is_ai_and_synthid(self): - chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI compositeWithTrainedAlgorithmicMedia" + chunk = ( + b"...name" + + bytes([0x69]) + + b"some-tool" + + b" OpenAI compositeWithTrainedAlgorithmicMedia c2pa.watermarked.unbound" + ) info: dict = {} _parse_c2pa_chunk(chunk, info) assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"] @@ -577,19 +596,19 @@ class TestC2paBufferScans: assert soft_binding_vendors_in(b"") == [] assert soft_binding_vendors_in(b"no soft-binding assertion here") == [] - def test_synthid_vendors_in_requires_synthid_issuer(self): - from remove_ai_watermarks._internal.c2pa import C2PA_ISSUERS, SYNTHID_C2PA_ISSUERS, synthid_vendors_in + def test_synthid_evidence_requires_openai_watermark_action_but_not_google_action(self): + from remove_ai_watermarks._internal.c2pa import synthid_evidence_vendors_in - syn_sig = next(s for s in C2PA_ISSUERS if s in SYNTHID_C2PA_ISSUERS) - non_sig = next(s for s in C2PA_ISSUERS if s not in SYNTHID_C2PA_ISSUERS) - assert C2PA_ISSUERS[syn_sig] in synthid_vendors_in(b"x" + syn_sig + b"x") - # an issuer that does NOT pair SynthID with C2PA must not be reported as one - assert C2PA_ISSUERS[non_sig] not in synthid_vendors_in(b"x" + non_sig + b"x") + assert synthid_evidence_vendors_in(b"c2pa OpenAI trainedAlgorithmicMedia") == [] + assert synthid_evidence_vendors_in(b"c2pa OpenAI trainedAlgorithmicMedia c2pa.watermarked.unbound") == [ + "OpenAI" + ] + assert synthid_evidence_vendors_in(b"c2pa Google trainedAlgorithmicMedia") == ["Google LLC"] def test_synthid_verdict_format(self): from remove_ai_watermarks._internal.c2pa import synthid_verdict - assert synthid_verdict("Google LLC") == "likely present (Google LLC embeds SynthID with C2PA)" + assert synthid_verdict("Google LLC") == "present according to Google LLC provenance" def _amf0_str(value: bytes, *, long: bool = False) -> bytes: diff --git a/tests/test_platform.py b/tests/test_platform.py index e4a7159..337e68e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -247,7 +247,7 @@ class TestResolveStrength: class TestVendorForStrength: - """vendor_for_strength normalizes the C2PA SynthID proxy to openai/google/None.""" + """vendor_for_strength normalizes SynthID provenance to openai/google/None.""" @staticmethod def _patch(value): diff --git a/tests/test_synthid_corpus.py b/tests/test_synthid_corpus.py index d68eaa0..31203f9 100644 --- a/tests/test_synthid_corpus.py +++ b/tests/test_synthid_corpus.py @@ -18,6 +18,7 @@ import synthid_corpus SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" CORPUS_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid" QUALITY_SET = CORPUS_DIR / "full-pipeline-quality.csv" +CURRENT_OPENAI_SAMPLE = CORPUS_DIR / "originals" / "ChatGPT Image May 30, 2026, 10_31_08 AM.png" EXPECTED_QUALITY_SOURCE_FILENAMES = { "ChatGPT Image May 30, 2026, 10_31_08 AM.png", @@ -57,11 +58,12 @@ def test_manifest_matches_canonical_originals() -> None: @pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present") class TestIngest: - def test_ingest_openai_flags_synthid_metadata(self, tmp_path: Path): + @pytest.mark.skipif(not CURRENT_OPENAI_SAMPLE.exists(), reason="current OpenAI SynthID fixture not present") + def test_ingest_current_openai_flags_synthid_metadata(self, tmp_path: Path): runner = CliRunner() result = runner.invoke( synthid_corpus.cli, - ["ingest", str(SAMPLES_DIR / "chatgpt-1.png"), "--label", "pos", "--root", str(tmp_path)], + ["ingest", str(CURRENT_OPENAI_SAMPLE), "--label", "pos", "--root", str(tmp_path)], ) assert result.exit_code == 0, result.output @@ -72,7 +74,7 @@ class TestIngest: assert row["synthid_metadata"] == "yes" assert int(row["width"]) > 0 assert int(row["height"]) > 0 - assert row["filename"] == "chatgpt-1.png" + assert row["filename"] == CURRENT_OPENAI_SAMPLE.name # Every label shares one canonical originals/ directory. assert (tmp_path / "originals" / row["filename"]).exists() diff --git a/tests/test_video.py b/tests/test_video.py index 0a6b20d..db7d0c2 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -37,7 +37,7 @@ def _box(box_type: bytes, payload: bytes) -> bytes: def _video_with_c2pa(path: Path) -> Path: - manifest = C2PA_UUID + b"OpenAI trainedAlgorithmicMedia" + manifest = C2PA_UUID + b"OpenAI trainedAlgorithmicMedia c2pa.watermarked.unbound" path.write_bytes(_MP4_FTYP + _box(b"uuid", manifest) + _box(b"mdat", _VIDEO_PAYLOAD)) return path @@ -277,7 +277,12 @@ def _write_synthetic_sora_clip( check=True, ) with path.open("ab") as stream: - stream.write(_box(b"uuid", C2PA_UUID + b"OpenAI Sora trainedAlgorithmicMedia")) + stream.write( + _box( + b"uuid", + C2PA_UUID + b"OpenAI Sora trainedAlgorithmicMedia c2pa.watermarked.unbound", + ) + ) def _write_vfr_sora_clip( @@ -345,7 +350,12 @@ def _write_vfr_sora_clip( command.append(str(path)) subprocess.run(command, capture_output=True, check=True) # noqa: S603 with path.open("ab") as stream: - stream.write(_box(b"uuid", C2PA_UUID + b"OpenAI Sora trainedAlgorithmicMedia")) + stream.write( + _box( + b"uuid", + C2PA_UUID + b"OpenAI Sora trainedAlgorithmicMedia c2pa.watermarked.unbound", + ) + ) def _decode_video(path: Path) -> tuple[list[np.ndarray], float]: @@ -612,7 +622,12 @@ class TestVideoMetadataApi: media_payload = b"x" * (8 * 1024 * 1024) source = _video_with_tc260(tmp_path / "source.mp4", media_payload=media_payload) with source.open("ab") as stream: - stream.write(_box(b"uuid", C2PA_UUID + b"OpenAI trainedAlgorithmicMedia")) + stream.write( + _box( + b"uuid", + C2PA_UUID + b"OpenAI trainedAlgorithmicMedia c2pa.watermarked.unbound", + ) + ) output = tmp_path / "clean.mp4" source_size = source.stat().st_size source_media_digest = hashlib.sha256(media_payload).digest() diff --git a/uv.lock b/uv.lock index ad9ee33..9cdaff2 100644 --- a/uv.lock +++ b/uv.lock @@ -3331,7 +3331,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.26.0" +version = "0.26.1" source = { editable = "." } dependencies = [ { name = "c2pa-python" },