diff --git a/docs/module-internals.md b/docs/module-internals.md index 33632fe..7ae85b9 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -510,13 +510,14 @@ convert `signingCredential.untrusted` into trusted based on a vendor-name match. Vendor attribution comes from the registry in [`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived issuer and platform maps should not be maintained separately. -For an AI C2PA claim, a recognized product in `claim_generator` takes precedence -over the certificate issuer: an application can sign through an upstream model -provider without becoming that provider's product. Only exact product mappings -receive this precedence; an unknown claim generator still falls back to issuer -attribution. An unmapped issuer org reads as unknown-signer C2PA with no platform; -that is how Ideogram was surfaced (4 private-corpus files signed "Ideogram, Inc", -2026-08-08) before its vendor row was added on 2026-08-27. +A recognized product in `claim_generator` takes precedence over the certificate +issuer: an application can sign through an upstream model provider without +becoming that provider's product. Exact products are also useful provenance on a +non-AI edit, such as CapCut; recognizing the product does not itself change the AI +verdict. An unknown claim generator falls back to issuer attribution. An unmapped +issuer org reads as unknown-signer C2PA with no platform; that is how Ideogram was +surfaced (4 private-corpus files signed "Ideogram, Inc", 2026-08-08) before its +vendor row was added on 2026-08-27. ### Metadata scanning and stripping diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index f6beac5..40ece3e 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -31,6 +31,15 @@ Exact claim-generator mappings also cover `Grok Imagine` as xAI Grok Imagine and `Suno` as Suno. Both can carry a valid AI source claim without a separately readable issuer, so issuer-only attribution leaves the platform blank. +Some signers expose the product in `signature_info` while retaining a generic +claim generator. xAI Grok Imagine is recognized from that signed identity even +when the generator is only `c2pa-rs`; Producer.ai is handled the same way. +FastVid has an exact claim-generator mapping. SPRING (SG) PTE. LTD. is retained +as the signer on AI-source claims without guessing which of its products created +the file. TikTok, ByteDance, CapCut, Anthropic Claude file provenance, and vivo +Camera are neutral signer or capture attributions: without an AI source assertion, +none of them changes the AI verdict. + **OpenRouter raster metadata survey (2026-09-02):** a paid Recraft V3 API output carried a valid C2PA claim with `claim_generator` `recraft.ai`, a `c2pa.created` action, and `trainedAlgorithmicMedia`; that exact generator now @@ -39,7 +48,7 @@ Riverflow V2 Fast API outputs carried no locally readable C2PA, XMP, EXIF, or container metadata. Their absence is evidence about those exact files and API routes, not a claim that every Krea or Riverflow export is metadata-free. -**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance Volcano Engine"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance Dreamina" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise. +**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance Volcano Engine"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance Dreamina" with **`asserts_ai=True`**. The broader **issuer** `b"Bytedance Pte"` is registered only as a neutral signer because that same entity also signs non-AI CapCut edits; it has no AI platform and cannot assert an AI verdict. Keying the product attribution on the `Dreamina` generator token remains the precise path. - **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`). A JSON `generationParams.modelName` value is parsed structurally and reduced to a recognized model name, rather than exposing its surrounding prompt JSON as a platform label. - **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf). - **xAI / Grok signature scheme (DETECTED by `metadata.xai_signature`, built 2026-05-26, expanded 2026-09-13).** diff --git a/pyproject.toml b/pyproject.toml index c434fc5..745bc51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ # on it, including the ComfyUI node package. The console script below carries # the same weight, since users have it on PATH. name = "remove-ai-watermarks" -version = "0.40.2" +version = "0.40.3" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/scripts/README.md b/scripts/README.md index 0c15f93..52d1eeb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,7 +9,7 @@ explicitly names a tracked canonical result. | Script | Purpose | | --- | --- | -| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. | +| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, normalized C2PA review fields, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. Its schema-versioned checkpoint resumes safely after interruption. | | `detection_timing.py` | Record per-method metadata and verdict timings. | | `detection_timing_report.py` | Aggregate timing records by method and segment. | | `fidelity_metrics.py` | Compute objective image-fidelity metrics for paired outputs. | diff --git a/scripts/corpus_gap_scan.py b/scripts/corpus_gap_scan.py index 683aa5c..52b3460 100644 --- a/scripts/corpus_gap_scan.py +++ b/scripts/corpus_gap_scan.py @@ -26,9 +26,13 @@ Usage: --since 2026-09-01 --report .local-eval/detector-report-weekly.csv Rows stream into ``.progress.jsonl`` and resume by relative path after an -interruption. Use a new report name for a new code/corpus snapshot, or pass -``--restart`` when intentionally replacing the checkpoint. The final CSV is -written atomically after every selected row is present. +interruption. A schema version prevents an older checkpoint from silently +producing blank fields after the report grows. Use a new report name for a new +code/corpus snapshot, or pass ``--restart`` when intentionally replacing the +checkpoint. The final CSV is written atomically after every selected row is +present. C2PA candidates include normalized signer, claim-generator, action, +source-type, and validation fields for aggregate review without reopening every +manifest. """ from __future__ import annotations @@ -52,7 +56,7 @@ from _corpus_scan import corpus_day, corpus_files, repair_jsonl_tail from _plain_console import Console, Table from remove_ai_watermarks.identify import _metadata_region as identify_metadata_region -from remove_ai_watermarks.identify import identify +from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence from remove_ai_watermarks.metadata import ( IPTC_AI_FIELD_MARKERS, IPTC_AI_MARKERS, @@ -93,6 +97,7 @@ MARKERS: tuple[bytes, ...] = ( b"Stability AI", ) REPORT_FIELDS: tuple[str, ...] = ( + "report_schema_version", "path", "suffix", "lib_version", @@ -101,18 +106,24 @@ REPORT_FIELDS: tuple[str, ...] = ( "confidence", "watermarks", "signals", + "c2pa_issuer", + "c2pa_claim_generator", + "c2pa_actions", + "c2pa_source_type", + "c2pa_validation", "integrity_clashes", "markers", "candidate_classes", "error", ) +_REPORT_SCHEMA_VERSION = "2" _DISPLAY_CANDIDATE_LIMIT = 50 _WORKER_BATCH_SIZE = 16 def _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]: row = dict.fromkeys(REPORT_FIELDS, "") - row.update(path=path, suffix=suffix, lib_version=lib_version) + row.update(report_schema_version=_REPORT_SCHEMA_VERSION, path=path, suffix=suffix, lib_version=lib_version) return row @@ -183,7 +194,8 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]: path = Path(path_str) suffix = path.suffix.lower() try: - rep = identify(path, check_visible=False, check_invisible=False) + evidence = extract_provenance_evidence(path) + rep = identify_from_evidence(evidence) except Exception as exc: log.warning("identify failed on %s: %s", relative_path, exc) row = _base_row(relative_path, suffix, lib_version) @@ -191,7 +203,15 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]: row["error"] = f"{type(exc).__name__}: {exc}"[:300].replace("\n", " ") else: row = _row(rep, path=relative_path, suffix=suffix, lib_version=lib_version) - hits = _marker_hits_for_path(path) + c2pa = evidence.c2pa_info + row.update( + c2pa_issuer=str(c2pa.get("issuer", "")), + c2pa_claim_generator=str(c2pa.get("claim_generator", "")), + c2pa_actions=str(c2pa.get("actions", "")), + c2pa_source_type=str(c2pa.get("source_type", "")), + c2pa_validation=str(c2pa.get("c2pa_validation_state", "")), + ) + hits = _marker_hits(identify_metadata_region(evidence.scan)) row["markers"] = "|".join(hits) row["candidate_classes"] = "|".join(_candidate_classes(rep, hits)) return row @@ -215,7 +235,11 @@ def _read_checkpoint(path: Path) -> dict[str, dict[str, str]]: row = json.loads(line) except (json.JSONDecodeError, UnicodeDecodeError): continue - if isinstance(row, dict) and isinstance(row.get("path"), str): + if ( + isinstance(row, dict) + and row.get("report_schema_version") == _REPORT_SCHEMA_VERSION + and isinstance(row.get("path"), str) + ): rows[row["path"]] = {field: str(row.get(field, "")) for field in REPORT_FIELDS} return rows diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 9220ba1..b9a327a 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.40.2" +__version__ = "0.40.3" __all__ = [ "BatchItemResult", diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 4eae085..681c83b 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -18,6 +18,7 @@ from remove_ai_watermarks._internal.constants import ( C2PA_AI_TOOLS, C2PA_AI_VENDORS, C2PA_CHUNK_TYPE, + C2PA_IDENTITY_AI_ORGS, C2PA_ISSUERS, C2PA_SIGNATURES, C2PA_SOFT_BINDING_REGISTRY, @@ -529,7 +530,8 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: info["ai_tool"] = ", ".join(tools) if actions: info["actions"] = ", ".join(actions) - if claim_generator_asserts_ai: + signer_identity_asserts_ai = not C2PA_IDENTITY_AI_ORGS.isdisjoint(issuers) + if claim_generator_asserts_ai or signer_identity_asserts_ai: info["c2pa_identity_ai"] = True generated = any( diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 328a10e..3d53914 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -32,7 +32,7 @@ C2PA_SIGNATURES = tuple( @dataclass(frozen=True, slots=True) class C2paAiVendor: - """One issuer signature and its normalized product attribution.""" + """One C2PA identity and its AI-product or neutral-signer attribution.""" issuer: bytes org: str @@ -41,6 +41,8 @@ class C2paAiVendor: synthid: bool = False asserts_ai: bool = False synthid_requires_watermark_action: bool = False + signer_platform: str | None = None + raw_signer_platforms: tuple[tuple[bytes, str], ...] = () def _vendor( @@ -52,6 +54,8 @@ def _vendor( synthid: bool = False, asserts_ai: bool = False, synthid_requires_watermark_action: bool = False, + signer_platform: str | None = None, + raw_signer_platforms: tuple[tuple[bytes, str], ...] = (), ) -> C2paAiVendor: token = issuer.encode() if isinstance(issuer, str) else issuer return C2paAiVendor( @@ -62,6 +66,8 @@ def _vendor( synthid=synthid, asserts_ai=asserts_ai, synthid_requires_watermark_action=synthid_requires_watermark_action, + signer_platform=signer_platform, + raw_signer_platforms=raw_signer_platforms, ) @@ -109,11 +115,68 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( # "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus # uploads 2026-08-08 that identify reported as unknown-signer C2PA. _vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True), + _vendor(b"xAI Grok Imagine", "xAI Grok Imagine", "xAI Grok Imagine", "xAI", asserts_ai=True), + _vendor(b"Producer.ai", "Producer.ai", "Producer.ai", "Producer.ai", asserts_ai=True), + # This signature names only SPRING's legal entity, not a product. Preserve + # that signer identity without guessing which product produced a file. + _vendor(b"SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING"), + # These companies also sign ordinary edits and publications. Register the + # signer identity for provenance display, but provide no AI platform and do + # not let the identity assert an AI verdict. + _vendor( + b"TikTok Inc.", + "TikTok", + None, + None, + signer_platform="TikTok (C2PA signer)", + ), + _vendor( + b"Bytedance Pte", + "ByteDance", + None, + None, + signer_platform="ByteDance (C2PA signer)", + raw_signer_platforms=( + (b"Bytedance Pte. Ltd.", "ByteDance (C2PA signer)"), + (b"CapCut/", "CapCut (C2PA signer)"), + (b"capcut c2pa-rs", "CapCut (C2PA signer)"), + ), + ), + _vendor( + b"Anthropic Claude Content Signing", + "Anthropic Claude", + None, + None, + signer_platform="Anthropic Claude (C2PA signer)", + ), + _vendor( + b"Samsung Galaxy", + "Samsung Galaxy", + None, + None, + signer_platform="Samsung Galaxy (C2PA)", + ), + _vendor( + b"com.asus.gallery", + "ASUS Gallery", + None, + None, + signer_platform="ASUS Gallery (C2PA signer)", + ), _vendor(b"Truepic", "Truepic", None, None), ) 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) +C2PA_SIGNER_PLATFORM_BY_ORG = { + vendor.org: vendor.signer_platform for vendor in C2PA_AI_VENDORS if vendor.signer_platform is not None +} +C2PA_SIGNER_PLATFORMS = tuple( + pair + for vendor in C2PA_AI_VENDORS + if vendor.signer_platform is not None + for pair in (vendor.raw_signer_platforms or ((vendor.issuer, vendor.signer_platform),)) +) # Product-specific claim generators can sign through a different upstream issuer. # Keep this attribution beside the issuer registry so every C2PA consumer has one @@ -128,6 +191,8 @@ C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = ( ("recraft.ai", "Recraft"), ("topaz labs image api", "Topaz Labs"), ("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"), + ("fastvid", "FastVid"), + ("capcut", "CapCut (C2PA signer)"), ) C2PA_AI_TOOLS = { diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 7121888..89316e8 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -44,6 +44,8 @@ from remove_ai_watermarks._internal.constants import ( C2PA_CLAIM_GENERATOR_PLATFORMS, C2PA_IDENTITY_AI_ORGS, C2PA_ISSUERS, + C2PA_SIGNER_PLATFORM_BY_ORG, + C2PA_SIGNER_PLATFORMS, ) from remove_ai_watermarks._internal.schema import require_schema_version from remove_ai_watermarks.metadata import ( @@ -631,7 +633,8 @@ def _ai_tools_in(data: bytes) -> list[str]: # Only tokens verified against a real signed file are listed (Leica, Nikon, # Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured. # Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so -# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash). +# it lives in the neutral `C2PA_SIGNER_PLATFORMS` registry (it must not feed the +# camera clash). _DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = ( (b"lc_c2pa", "Leica (camera, C2PA capture)"), (b"Leica Camera", "Leica (camera, C2PA capture)"), @@ -646,6 +649,8 @@ _DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = ( # Truepic is a C2PA signing authority whose name appears in the trust chain # of unrelated manifests (e.g. OpenAI), so the bare token mis-attributes. (b"Truepic_Lens", "Truepic Lens (verified capture)"), + (b"vivo Camera", "vivo (camera, C2PA capture)"), + (b"vivo X300 Pro", "vivo (camera, C2PA capture)"), ) @@ -743,15 +748,14 @@ def _device_platform(head: bytes) -> str | None: # (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos). # com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI # source type or genAIType on the samples, so it never asserts is_ai). -_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = ( - (b"Samsung Galaxy", "Samsung Galaxy (C2PA)"), - (b"com.asus.gallery", "ASUS Gallery (C2PA signer)"), -) - - -def _signer_platform(head: bytes) -> str | None: - """Map a C2PA editing-app / AI-capable-device signer token to a platform.""" - return _first_token_match(head, _SIGNER_C2PA_PLATFORM) +def _signer_platform(head: bytes, issuers: list[str]) -> str | None: + """Map a raw or normalized C2PA signer identity to a neutral platform.""" + raw = _first_token_match(head, C2PA_SIGNER_PLATFORMS) + normalized = next( + (C2PA_SIGNER_PLATFORM_BY_ORG[org] for org in issuers if org in C2PA_SIGNER_PLATFORM_BY_ORG), + None, + ) + return raw or normalized def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None: @@ -1178,7 +1182,6 @@ def _identify_from_evidence( # below keeps the full buffer: their markers are long and distinctive. region = _metadata_region(head) camera_label = _device_platform(region) - signer_label = _signer_platform(region) # ── C2PA Content Credentials ──────────────────────────────────── has_c2pa = bool(info) or c2pa_marker_in(head) @@ -1189,6 +1192,7 @@ def _identify_from_evidence( # by substring made the displayed reason a second, looser rule than the verdict. failed_c2pa_codes = [str(code) for code in cast("list[object]", info.get("c2pa_failed_codes", []))] issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region) + signer_label = _signer_platform(region, issuers) # Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo # (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in # _internal.c2pa._structured_manifest_fields (covers PNG + any container the c2pa-python @@ -1216,15 +1220,18 @@ def _identify_from_evidence( or (", ".join(tools) if (tools := _ai_tools_in(region)) else None) ) # Platform: a distinctive device/camera token in the manifest wins (it is the - # signer/producer), then an editing-app/AI-device signer (Samsung Galaxy, - # ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan - # alone mis-attributed real samples (Leica->Truepic timestamp authority, - # Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that. + # signer/producer), then an exact product generator, then an editing-app or + # AI-device signer (Samsung Galaxy, ASUS Gallery), with the issuer byte-scan + # only as fallback. The issuer scan alone mis-attributed real samples + # (Leica->Truepic timestamp authority, Nikon->Adobe namespace, Pixel->Google + # Gemini) -- the token scans fix that. platform = ( ( camera_label + # Exact product generators are useful provenance even when the + # signed operation is a non-AI edit (for example, CapCut). + or _claim_generator_platform(generator) or signer_label - or (_claim_generator_platform(generator) if c2pa_is_ai else None) or (_claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None) or _attribute_platform(issuers, is_ai=c2pa_is_ai) ) diff --git a/tests/test_identify.py b/tests/test_identify.py index 318adbd..8291f3d 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -46,6 +46,12 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence" SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" +def _write_c2pa_jpeg(tmp_path: Path, name: str, blob: bytes) -> Path: + path = tmp_path / name + path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9") + return path + + class TestProvenanceEvidence: def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path): path = tmp_path / "firefly.png" @@ -646,13 +652,8 @@ class TestIdentifySamsungGalaxy: so the signer attribution must NOT trip the camera-vs-AI integrity clash. """ - def _jpeg(self, tmp_path: Path, name: str, blob: bytes) -> Path: - path = tmp_path / name - path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9") - return path - def test_galaxy_trained_source_is_unverified_ai(self, tmp_path: Path): - path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia") + path = _write_c2pa_jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia") r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is True assert r.confidence == "medium" @@ -664,7 +665,7 @@ class TestIdentifySamsungGalaxy: def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path): # The Galaxy S24 case: no trainedAlgorithmicMedia, genAIType is the only # AI marker -- previously missed, now a medium-confidence verdict. - path = self._jpeg( + path = _write_c2pa_jpeg( tmp_path, "s24.jpg", b'Samsung Galaxy Galaxy S24 c2pa-rs PhotoEditor_Re_Edit_Data{"genAIType":1}' ) r = identify(path, check_visible=False, check_invisible=False) @@ -677,7 +678,7 @@ class TestIdentifySamsungGalaxy: def test_asus_gallery_signer_not_ai(self, tmp_path: Path): # ASUS Gallery signs edited photos; no AI source-type or genAIType, so the # platform is attributed but the verdict stays unknown. - path = self._jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker") + path = _write_c2pa_jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker") r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is None assert r.platform == "ASUS Gallery (C2PA signer)" @@ -687,13 +688,88 @@ class TestIdentifySamsungGalaxy: # A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but # NO AI source-type / genAIType. It must stay is_ai=None -- the device cert # is authenticity provenance of a real photo, not an AI-generation signal. - path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker") + path = _write_c2pa_jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker") r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is None assert r.platform == "Samsung Galaxy (C2PA)" assert any("C2PA" in w for w in r.watermarks) +class TestIdentifyDiscoveredC2paSigners: + @pytest.mark.parametrize( + ("blob", "platform"), + [ + (b"Anthropic Claude Content Signing", "Anthropic Claude (C2PA signer)"), + (b"CapCut/ c2pa-rs", "CapCut (C2PA signer)"), + (b"TikTok Inc.", "TikTok (C2PA signer)"), + (b"vivo Camera vivo X300 Pro", "vivo (camera, C2PA capture)"), + ], + ) + def test_non_ai_signer_is_attributed_without_asserting_ai(self, tmp_path: Path, blob: bytes, platform: str): + path = _write_c2pa_jpeg(tmp_path, "signed.jpg", blob) + + report = identify(path, check_visible=False, check_invisible=False) + + assert report.is_ai_generated is None + assert report.platform == platform + + def test_structured_non_ai_capcut_generator_is_attributed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = tmp_path / "edited.png" + from PIL import Image + + Image.new("RGB", (32, 32)).save(path) + monkeypatch.setattr( + "remove_ai_watermarks.identify.extract_c2pa_info", + lambda _path: { + "has_c2pa": True, + "issuer": "ByteDance", + "claim_generator": "CapCut/ c2pa-tool/0.1.0 c2pa-rs/0.31.3", + }, + ) + + report = identify(path, check_visible=False, check_invisible=False) + + assert report.is_ai_generated is None + assert report.platform == "CapCut (C2PA signer)" + + @pytest.mark.parametrize( + ("issuer", "claim_generator", "platform"), + [ + ("xAI Grok Imagine", "c2pa-rs", "xAI Grok Imagine"), + ("Producer.ai", "producer_ai", "Producer.ai"), + ("", "FastVid", "FastVid"), + ("SPRING (SG) PTE. LTD.", "c2pa-rs", "SPRING (SG) PTE. LTD."), + ], + ) + def test_ai_signer_or_generator_is_attributed( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + issuer: str, + claim_generator: str, + platform: str, + ): + path = tmp_path / "generated.png" + from PIL import Image + + Image.new("RGB", (32, 32)).save(path) + monkeypatch.setattr( + "remove_ai_watermarks.identify.extract_c2pa_info", + lambda _path: { + "has_c2pa": True, + "issuer": issuer, + "claim_generator": claim_generator, + "source_type": "trainedAlgorithmicMedia (AI-generated)", + "ai_source_kind": "generated", + }, + ) + + report = identify(path, check_visible=False, check_invisible=False) + + assert report.is_ai_generated is True + assert report.platform == platform + + # ── End-to-end verdicts on real fixtures ──────────────────────────── diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index f47ef76..07abfc2 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -211,6 +211,43 @@ class TestC2PA: assert info["ai_tool"] == "Dreamina" assert info["c2pa_identity_ai"] is True + def test_structured_xai_signer_identity_is_normalized(self): + store = { + "active_manifest": "active", + "manifests": { + "active": { + "signature_info": { + "issuer": "Self-signed ephemeral certificate (Content Authenticity SDK) -- LOCAL USE ONLY", + "common_name": "xAI Grok Imagine", + }, + "assertions": [], + } + }, + } + + info = c2pa_info_from_manifest_store(store) + + assert info["issuer"] == "xAI Grok Imagine" + assert info["c2pa_identity_ai"] is True + + @pytest.mark.parametrize( + ("signature", "issuer"), + [ + ({"issuer": "Bytedance Pte. Ltd."}, "ByteDance"), + ({"issuer": "TikTok Inc."}, "TikTok"), + ], + ) + def test_structured_publisher_identity_does_not_assert_ai(self, signature, issuer): + store = { + "active_manifest": "active", + "manifests": {"active": {"signature_info": signature, "assertions": []}}, + } + + info = c2pa_info_from_manifest_store(store) + + assert info["issuer"] == issuer + assert "c2pa_identity_ai" not in info + def test_structured_invismark_exposes_algorithm_and_watermark_id(self): watermark_id = "83424621-03cb-40e3-9808-a9fae837156d" store = { diff --git a/tests/test_signal_discovery_scripts.py b/tests/test_signal_discovery_scripts.py index 68282e9..334aec9 100644 --- a/tests/test_signal_discovery_scripts.py +++ b/tests/test_signal_discovery_scripts.py @@ -128,17 +128,57 @@ def test_gap_worker_error_is_a_complete_resumable_row(monkeypatch, tmp_path): path.write_bytes(b"not an image") calls = [] - def fail(_path, **kwargs): - calls.append(kwargs) + def fail(_path): + calls.append(_path) raise ValueError("broken\ninput") - monkeypatch.setattr(gap, "identify", fail) + monkeypatch.setattr(gap, "extract_provenance_evidence", fail) row = gap._scan_one((str(path), "bad.bin", "1.2.3")) assert set(row) == set(gap.REPORT_FIELDS) assert row["candidate_classes"] == "identify_error" assert row["error"] == "ValueError: broken input" - assert calls == [{"check_visible": False, "check_invisible": False}] + assert calls == [path] + + +def test_gap_worker_records_c2pa_review_fields(monkeypatch, tmp_path): + path = tmp_path / "signed.png" + path.touch() + evidence = SimpleNamespace( + c2pa_info={ + "issuer": "Example signer", + "claim_generator": "Example generator", + "actions": "created", + "source_type": "trainedAlgorithmicMedia (AI-generated)", + "c2pa_validation_state": "Valid", + }, + scan=b"c2pa", + ) + monkeypatch.setattr(gap, "extract_provenance_evidence", lambda _path: evidence) + monkeypatch.setattr( + gap, + "identify_from_evidence", + lambda actual: ( + SimpleNamespace( + is_ai_generated=True, + platform="Example", + confidence="high", + watermarks=["C2PA"], + signals=[SimpleNamespace(name="c2pa")], + integrity_clashes=[], + ) + if actual is evidence + else None + ), + ) + + row = gap._scan_one((str(path), "signed.png", "1.2.3")) + + assert row["c2pa_issuer"] == "Example signer" + assert row["c2pa_claim_generator"] == "Example generator" + assert row["c2pa_actions"] == "created" + assert row["c2pa_source_type"] == "trainedAlgorithmicMedia (AI-generated)" + assert row["c2pa_validation"] == "Valid" def test_gap_summary_does_not_report_a_negative_hidden_count(capsys): diff --git a/uv.lock b/uv.lock index a324739..c5b246e 100644 --- a/uv.lock +++ b/uv.lock @@ -3449,7 +3449,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.40.2" +version = "0.40.3" source = { editable = "." } dependencies = [ { name = "c2pa-python" },