From 7c9bec0ebaa15eb62e10d1591e7511d183693d25 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Wed, 29 Jul 2026 16:46:42 -0700 Subject: [PATCH] Ignore collector diagnostics in metadata evidence --- docs/module-internals.md | 4 ++- docs/python-api.md | 6 ++-- pyproject.toml | 2 +- src/remove_ai_watermarks/__init__.py | 2 +- src/remove_ai_watermarks/identify.py | 3 ++ tests/test_identify.py | 43 ++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/module-internals.md b/docs/module-internals.md index 550c834..9750e29 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -137,7 +137,9 @@ metadata extraction from verdict logic: - `extract_provenance_evidence` reads the supported metadata signals into `ProvenanceEvidence`. - `evidence_from_metadata_record` normalizes an externally collected nested - metadata record into the same evidence type without file access. + metadata record into the same evidence type without file access. Diagnostic + values under `error` and `kind` are excluded from evidence while nested raw + bytes remain available through encoded binary fields. - `identify_from_evidence` evaluates that evidence without reopening the source. - `identify` preserves the path-based API and adds the optional registered visible-mark and open invisible-watermark decoders after extraction. diff --git a/docs/python-api.md b/docs/python-api.md index 24ac64e..5cf04fc 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -132,8 +132,10 @@ report = identify_from_evidence(evidence) The normalizer recursively preserves text and byte values. It also decodes strings prefixed with `hex:` and fields named `base64` or ending in -`_base64`. Pass a C2PA manifest-store dictionary in `record["c2pa_store"]`, or -through the explicit `c2pa_manifest_store` argument. +`_base64`. Diagnostic values under `error` and `kind` are ignored because they +describe the collector rather than the source file. Pass a C2PA manifest-store +dictionary in `record["c2pa_store"]`, or through the explicit +`c2pa_manifest_store` argument. `identify_from_evidence` does not reopen the source file. It evaluates metadata only; registered visible marks and pixel-backed invisible watermarks remain in diff --git a/pyproject.toml b/pyproject.toml index 1103cfd..8242281 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remove-ai-watermarks" -version = "0.21.1" +version = "0.21.2" description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images" 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 a22d32b..a96a921 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.21.1" +__version__ = "0.21.2" __all__ = ["__version__", "remove_visible", "visible_provenance"] diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 1b83fcc..cfb4fdb 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -169,6 +169,7 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]: """Index nested metadata and recover common encoded binary values in one pass.""" pairs: list[tuple[str, Any]] = [] parts: list[bytes] = [] + diagnostic_keys = {"error", "kind"} def visit(item: Any) -> None: if isinstance(item, dict): @@ -177,6 +178,8 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]: key_text = str(key) pairs.append((key_text, nested)) parts.append(key_text.encode("utf-8", "replace")) + if key_text.lower() in diagnostic_keys: + continue if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")): encoded = nested.split("...TRUNCATED", 1)[0] with contextlib.suppress(ValueError, TypeError): diff --git a/tests/test_identify.py b/tests/test_identify.py index 9e7f3d8..c683d21 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -6,6 +6,7 @@ against the real committed C2PA / IPTC fixtures in data/fixtures/provenance/. from __future__ import annotations +import base64 import json import subprocess import sys @@ -61,6 +62,48 @@ class TestProvenanceEvidence: assert report.is_ai_generated is True assert {signal.name for signal in report.signals} >= {"gen_params", "xai_signature"} + def test_external_scanner_diagnostics_do_not_create_c2pa_evidence(self, tmp_path: Path): + path = tmp_path / "plain.jpg" + record = { + "c2pa_store": {"error": "ManifestNotFound: no JUMBF data found"}, + "jpeg": { + "segments": [ + { + "marker": "APP11", + "kind": "c2pa_or_jumbf", + "base64": "AAA=", + } + ] + }, + } + + report = identify_from_evidence(evidence_from_metadata_record(record, path=path)) + + assert report.is_ai_generated is None + assert report.signals == [] + assert report.watermarks == [] + + def test_external_scanner_raw_bytes_still_create_c2pa_evidence(self, tmp_path: Path): + path = tmp_path / "signed.jpg" + manifest = b"jumb c2pa OpenAI trainedAlgorithmicMedia" + record = { + "jpeg": { + "segments": [ + { + "marker": "APP11", + "kind": "c2pa_or_jumbf", + "base64": base64.b64encode(manifest).decode(), + } + ] + } + } + + report = identify_from_evidence(evidence_from_metadata_record(record, path=path)) + + assert report.is_ai_generated is True + assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)" + assert [signal.name for signal in report.signals] == ["c2pa"] + @pytest.mark.parametrize( "filename", [ diff --git a/uv.lock b/uv.lock index 9d600cf..8366acd 100644 --- a/uv.lock +++ b/uv.lock @@ -3187,7 +3187,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.21.1" +version = "0.21.2" source = { editable = "." } dependencies = [ { name = "c2pa-python" },