diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 9a9134a..7aa60c1 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -165,7 +165,12 @@ This project has no local Content Seal decoder. Meta Model API outputs and Meta CDN copies carry an XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` companion tag, which `identify` reports through the existing Made-with-AI path; that IPTC code is a standard, not a Meta-exclusive -signal, so it cannot key a strength cohort the way the C2PA issuer does. The +signal, so it cannot key a strength cohort the way the C2PA issuer does. Since +0.33.0 a standalone AI digital-source tag (no C2PA manifest) additionally emits +the additive `content_seal` signal - the strength router's Meta bet as evidence, +medium confidence, with the same caveat - so clients select pixel removal from +the signal list exactly the way InvisMark is additive over `soft_binding`. It +is an attribution, never a decode. The external oracle is `https://meta.ai/identification`: anonymous, no login, accepts image, video, and audio, enforces an unspecified daily identification limit, and answers with model attribution (`Muse Image 1 - Meta`) plus a diff --git a/pyproject.toml b/pyproject.toml index 7e56a2d..63b6b18 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.32.1" +version = "0.33.0" 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/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 9fdbb11..b0a2d71 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.32.1" +__version__ = "0.33.0" __all__ = [ "BatchSummary", diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 98e380c..3f2b164 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -1278,12 +1278,12 @@ def _identify_from_evidence( # vendor-token inference must not add a second, differently-attributed # invisible watermark (Microsoft Designer: "Azure OpenAI ImageGen" agent + # the InvisMark watermarked action read as "SynthID per OpenAI"). - + soft_binding_vendors = soft_binding_vendors_in(region) if ( not synthid and trained_source and c2pa_marker_in(head) - and not soft_binding_vendors_in(region) + and not soft_binding_vendors and (vendors := synthid_evidence_vendors_in(region)) ): synthid = synthid_verdict(", ".join(vendors)) @@ -1300,7 +1300,7 @@ def _identify_from_evidence( # ── C2PA soft-binding: a named forensic/third-party watermark vendor ─ # (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when # the watermark itself can't be decoded; names whose watermark stamped the pixels. - soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None) + soft_binding = meta.get("soft_binding") or (", ".join(soft_binding_vendors) if soft_binding_vendors else None) if soft_binding: soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm") soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value") @@ -1334,6 +1334,21 @@ def _identify_from_evidence( if standalone_iptc: signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high")) watermarks.append("IPTC digitalSourceType (Made with AI)") + # Muse Image stamps every output with the invisible Content Seal, and this + # tag is the only provenance such a file carries - the same measured bet + # the strength router makes (vendor_for_strength -> "meta"). Emit the seal + # as its own stable signal, the way InvisMark is additive over + # soft_binding, so clients select pixel removal from the signal list + # instead of parsing caveats. It is an attribution, not a decode: no + # public Content Seal decoder exists, hence "medium". + signals.append( + Signal( + "content_seal", + "Meta Muse Content Seal pixel watermark (attributed by the standalone AI digital-source tag)", + "medium", + ) + ) + watermarks.append("Invisible Content Seal watermark (Meta Muse attribution)") caveats.append(_IPTC_ONLY_CAVEAT) caveats.append(_CONTENT_SEAL_CAVEAT) if platform is None: diff --git a/tests/test_identify.py b/tests/test_identify.py index c7d77bc..80fb73d 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -666,6 +666,35 @@ class TestIdentifyRealSamples: assert r.is_ai_generated is True assert r.ai_source_kind == "enhanced" + def test_standalone_ai_tag_attributes_the_content_seal(self, tmp_path: Path): + """A standalone AI digital-source tag emits the seal as its own signal. + + Muse Image outputs carry no C2PA; this tag is their only provenance, and + Muse stamps every output with the invisible Content Seal. The signal is + the strength router's Meta bet as evidence - an attribution, not a decode + (no public decoder exists), so its confidence is medium and the caveat + still points at the oracle. + """ + p = tmp_path / "muse-tag.jpg" + p.write_bytes( + b'\xff\xd8\xff\xe1\xff\xd9' + ) + + r = identify(p, check_visible=False, check_invisible=False) + + names = [s.name for s in r.signals] + assert "iptc" in names + assert "content_seal" in names + seal = next(s for s in r.signals if s.name == "content_seal") + assert seal.confidence == "medium" + assert "Invisible Content Seal watermark (Meta Muse attribution)" in r.watermarks + assert any("meta.ai/identification" in c for c in r.caveats) + + def test_c2pa_backed_file_gets_no_content_seal_attribution(self): + """C2PA issuers win first: a manifest-backed file is not Meta-routed.""" + r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False, check_invisible=False) + assert "content_seal" not in [s.name for s in r.signals] + def test_flux_bfl_c2pa_png(self): # flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA). r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False) diff --git a/uv.lock b/uv.lock index 5ab775b..604b397 100644 --- a/uv.lock +++ b/uv.lock @@ -3169,7 +3169,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.32.1" +version = "0.33.0" source = { editable = "." } dependencies = [ { name = "c2pa-python" },