From ab528ec0e86c9f2ac0d940e7e510ba8a5db2b5bb Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Wed, 26 Aug 2026 22:59:23 -0700 Subject: [PATCH] Ship the measured Meta Content Seal cohort with auto routing and --vendor override Full Meta Muse Image support in the invisible-removal path: - QWEN_ZIMAGE_META_STRENGTH = 0.1: derived by the standard worst-boundary-plus-cross-source-spread method over five oracle-bracketed generations (data/contentseal/manifest.csv) - Auto mode: vendor_for_strength routes a file whose only provenance is the standalone AI IPTC trainedAlgorithmicMedia tag onto the meta cohort; C2PA issuers win first, so Google/OpenAI/Microsoft routing is unchanged. Muse WebP outputs place the XMP in a tail chunk, so the scan uses the shared chunk-aware metadata.scan_head rather than a plain head read - Explicit override: --vendor on invisible/all/batch and InvisibleOptions.vendor name the cohort on stripped files; naming a cohort asserts the watermark is present, so the no-signal gate treats it like --force at both the CLI and API seams - sdxl-zimage has no measured Meta rung: an explicit meta vendor falls to the conservative unknown 0.25 rather than inventing one - identify emits a Content Seal caveat pointing at the removal path - The legacy visible 'Imagined with AI' mark stays unregistered: a dedicated sample hunt (newsroom mockups, community posts, press screenshots, dead imagine.meta.com, broken Wayback captures) found no pixel-verifiable capture, and the registry rule forbids encoding a corner without one. erase --region remains its removal path; outcome recorded in the landscape Co-Authored-By: Claude Fable 4.5 --- data/contentseal/README.md | 7 +-- docs/cli.md | 17 ++++++ docs/module-internals.md | 28 +++++---- docs/python-api.md | 12 ++++ docs/supported-signals.md | 12 +++- docs/watermarking-landscape.md | 4 +- .../_internal/watermark_profiles.py | 54 ++++++++++++++++- src/remove_ai_watermarks/api.py | 9 ++- src/remove_ai_watermarks/cli.py | 59 +++++++++++++++++-- src/remove_ai_watermarks/identify.py | 6 ++ tests/test_cli.py | 33 +++++++++++ tests/test_platform.py | 52 +++++++++++++++- 12 files changed, 260 insertions(+), 33 deletions(-) diff --git a/data/contentseal/README.md b/data/contentseal/README.md index 878b753..c6ed21b 100644 --- a/data/contentseal/README.md +++ b/data/contentseal/README.md @@ -78,10 +78,9 @@ Measured (2026-08-26/27, oracle `meta.ai/identification`): between easiest and hardest). - Derived Meta floor by the existing worst-boundary-plus-cross-source-spread method: 0.06 + (0.0525 - 0.015) = 0.0975, rounded to **0.1**. -- Not shipped as a constant: no provenance signal routes Muse outputs onto a - vendor cohort, so the default curve stays authoritative and 0.1 is recorded - as the floor to encode if an explicit Meta override is ever added. - +- Shipped as `QWEN_ZIMAGE_META_STRENGTH`: auto mode routes standalone-AI-IPTC + files onto the cohort, and `--vendor meta` / `InvisibleOptions.vendor` + names it explicitly on stripped files (implying the scrub runs). ## Regeneration API key is not stored in this repository. Regenerate with the script pattern diff --git a/docs/cli.md b/docs/cli.md index 0148dbf..ed67bf9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -387,6 +387,23 @@ detected. Use `--force` when you know the image should be processed: remove-ai-watermarks invisible image.png -o clean.png --force ``` +### Choose a strength cohort + +`--vendor` selects the cohort the default strength resolves from. `auto` +(the default) derives it from provenance: the C2PA issuer for +OpenAI/Google/Microsoft, and the standalone AI IPTC tag for Meta Content Seal +(Muse output carries no C2PA; the tag is a standard code, so C2PA evidence +always wins first). An explicit value both names the cohort for stripped files +and implies the scrub runs -- naming the cohort asserts the pixel watermark is +present -- exactly like `--force` plus a measured floor: + +```bash +remove-ai-watermarks invisible muse_output.webp -o clean.png --vendor meta +``` + +The same option exists on `all` and `batch`, and as +`InvisibleOptions(vendor="meta")` in the Python API. + ### Choose a pipeline | Pipeline | When to use it | diff --git a/docs/module-internals.md b/docs/module-internals.md index 71ca860..b664e8a 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1170,18 +1170,22 @@ unreachable constant would be dead code. Full spread: worst first-clean boundary (0.0525, 0.06] on lighthouse, easiest source already clean at 0.015. Following the same derivation as the OpenAI and Microsoft floors (worst clean boundary plus one full observed cross-source -spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. This is a -candidate `meta` floor, not yet a shipped constant: Muse outputs carry no C2PA, -so `vendor_for_strength` has no signal that could route them onto the flat -vendor map automatically. Encoding 0.1 as `QWEN_ZIMAGE_META_STRENGTH` makes -sense only once the tool gains an explicit way to opt into a Meta cohort (for -example a CLI `--vendor meta` override); the default resolution-adaptive curve -(~0.1305 at 2.56 MP) already clears every measured source, so default behavior -needs no change. Oracle verdicts carry a per-generation ID and creation timestamp -embedded in the watermark payload; both survived the 512 px resize and JPEG q85 -rows, so payload recovery outlives the detection threshold. Oracle session -limits are per-IP, server-side, and sliding-window: clearing cookies and storage -does not reset them, and a burst exhausts the window minutes after it reopens. +spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. Shipped as +`QWEN_ZIMAGE_META_STRENGTH` with two routing paths: auto mode routes a file +whose only provenance is the standalone AI IPTC tag onto the cohort +(`vendor_for_strength` checks C2PA issuers first, so Google/OpenAI/Microsoft +evidence always wins, and the tag's other users ship no invisible watermark +this profile targets), and `--vendor meta` / `InvisibleOptions.vendor` names the +cohort explicitly on stripped files, implying the scrub runs (naming the cohort +asserts the watermark is present). sdxl-zimage has no measured Meta rung and an +explicit meta vendor there falls to the conservative unknown 0.25. The default +resolution-adaptive curve (~0.1305 at 2.56 MP) also clears every measured +source, so default behavior needed no change. Oracle verdicts carry a +generation ID and creation timestamp embedded in the watermark payload; both +survived the 512 px resize and JPEG q85 rows, so payload recovery outlives the +detection threshold. Oracle session limits are per-IP, server-side, and +sliding-window: clearing cookies and storage does not reset them, and a burst +exhausts the window minutes after it reopens. ### Static prompt embeddings diff --git a/docs/python-api.md b/docs/python-api.md index e766774..a0f8992 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -112,6 +112,18 @@ raiw.remove_all( ) ``` +`vendor="meta"` names a strength cohort explicitly (the measured Content Seal +floor) for a stripped file whose provenance no longer carries the AI IPTC tag, +and implies the scrub runs: + +```python +raiw.remove_all( + "muse_output.webp", + "clean.png", + invisible=InvisibleOptions(vendor="meta"), +) +``` + `InvisibleOptions` carries only what `InvisibleEngine` itself takes, and uses the engine's own parameter names and defaults. `force`, which decides whether the engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 2a71fa8..4902414 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -172,9 +172,15 @@ documentation never mentions the seal. The default `qwen-zimage` profile clears Content Seal at the default resolution-adaptive strength (oracle-verified on 2.56 MP generations); measured strength boundaries are recorded in `data/contentseal/manifest.csv` and -[module internals](module-internals.md#meta-content-seal-boundaries-for-qwen-zimage) -(derived Meta floor 0.1 by the standard spread method, not shipped as a constant -because no provenance signal can route Muse output onto a vendor cohort). +[module internals](module-internals.md#meta-content-seal-boundaries-for-qwen-zimage). +The derived Meta floor (0.1 by the standard spread method) ships as a measured +cohort: auto mode routes a file whose only provenance is the standalone AI IPTC +tag to it (the tag is not Meta-exclusive; other tag users ship no invisible +watermark this profile targets, and Google/OpenAI/Microsoft C2PA evidence +always wins first), and `invisible --vendor meta` (also `all` and `batch`, and +`InvisibleOptions.vendor` in the API) names the cohort explicitly on stripped +files. An explicit vendor implies the scrub runs: naming the cohort asserts the +pixel watermark is present. The seal survives resizing, JPEG recompression, and metadata stripping; it dies to center crops of a third to a half, matching the Reuters 2026-07-11 finding that Meta's detector missed 55% of cropped Muse images. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index f661a1a..8147616 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -161,9 +161,9 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp **Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example. -**Meta Muse Image / Content Seal: current state VERIFIED 2026-08-26 against live artifacts.** Muse Image (launched 2026-07-07, first image model from Meta Superintelligence Labs) puts no visible mark on output. Every output carries Content Seal, a proprietary invisible pixel watermark; research lineage is open (`github.com/facebookresearch/content-seal`, Pixel Seal / VideoSeal / Watermark Anything), but the deployed implementation is proprietary and unpublished. API outputs and Meta CDN copies also carry XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` (a standard IPTC code, not Meta-exclusive). The only reader is the anonymous web oracle `meta.ai/identification`, which returns model attribution plus a per-generation ID and timestamp embedded in the payload. Verified robustness (our corpus, `data/contentseal/`): seal survives CDN WebP transcode, 512 px resize, JPEG q85, and full metadata stripping; it is lost to center crops of 33-50% linear size (Reuters measured 55% detector misses on cropped images, 2026-07-11). The default `qwen-zimage` profile clears it (oracle-verified). Legacy paragraph retained below. +**Meta Muse Image / Content Seal: current state VERIFIED 2026-08-26 against live artifacts.** Muse Image (launched 2026-07-07, first image model from Meta Superintelligence Labs) puts no visible mark on output. Every output carries Content Seal, a proprietary invisible pixel watermark; research lineage is open (`github.com/facebookresearch/content-seal`, Pixel Seal / VideoSeal / Watermark Anything), but the deployed implementation is proprietary and unpublished. API outputs and Meta CDN copies also carry XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` (a standard IPTC code, not Meta-exclusive). The only reader is the anonymous web oracle `meta.ai/identification`, which returns model attribution plus a per-generation ID and timestamp embedded in the payload. Verified robustness (our corpus, `data/contentseal/`): seal survives CDN WebP transcode, 512 px resize, JPEG q85, and full metadata stripping; it is lost to center crops of 33-50% linear size (Reuters measured 55% detector misses on cropped images, 2026-07-11). Removal is fully supported and oracle-verified on the default profile, with a measured Meta strength floor (0.1) shipped behind auto IPTC routing and an explicit `--vendor meta` override; see [supported signals](supported-signals.md) and [module internals](module-internals.md). Legacy paragraph retained below. -**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. `identify` reads the supported IPTC disclosure; it does not decode Meta's proprietary invisible watermark. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`. +**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. A dedicated 2026-08-27 sample hunt failed to obtain one: the Feb-2024 newsroom images are UI mockups whose photos carry no in-pixel mark; community posts (Threads `C8_rS_MuEId` titled "lower left corner", a Facebook share) corroborate bottom-LEFT verbally but their files defeat pixel verification; press screenshots predate the mark (Dec 2023); `imagine.meta.com` is dead (redirects to meta.ai) and its Wayback captures are broken SPA error pages. The mark's generator no longer exists (Muse output has no visible mark), so no fresh sample can be made. Removal stays on the generic `erase --region` path until a legacy capture surfaces. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`. **Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found. diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 1f0ddb9..aacf459 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -86,10 +86,26 @@ QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675 # measured corpus margin, not a universal InvisMark threshold. QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15 +# Meta Muse Image stamps every output with Content Seal, but no provenance signal +# survives to route it: the outputs carry no C2PA, and their IPTC +# trainedAlgorithmicMedia companion tag is a standard code many platforms use, so +# it cannot key this cohort the way an issuer keys the others. The floor is +# therefore selected by an explicit --vendor meta override, never by detection. +# Derivation (oracle meta.ai/identification, 2026-08-26/27, corpus in +# data/contentseal/): five independent 2.56 MP generations bracketed at +# lighthouse (0.0525, 0.06], fox (0.03, 0.0375], night_city (0.03, 0.0375], +# mug <= 0.03, text <= 0.015. Worst clean boundary plus one full observed +# cross-source spread: 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to 0.1. +# sdxl-zimage has no measured Meta floor; its vendor map stays without a meta +# entry so an explicit --vendor meta there falls to the unknown 0.25, which is +# above this floor and therefore conservative. +QWEN_ZIMAGE_META_STRENGTH = 0.1 + _QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR: dict[str, float] = { "google": QWEN_ZIMAGE_GOOGLE_STRENGTH, "openai": QWEN_ZIMAGE_OPENAI_STRENGTH, "microsoft": QWEN_ZIMAGE_MICROSOFT_STRENGTH, + "meta": QWEN_ZIMAGE_META_STRENGTH, } @@ -129,7 +145,8 @@ def strength_default_help() -> str: return ( "profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a " f"flat OpenAI {QWEN_ZIMAGE_OPENAI_STRENGTH} / Google {QWEN_ZIMAGE_GOOGLE_STRENGTH} / " - f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} floors; sdxl-zimage " + f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} / Meta Content Seal " + f"{QWEN_ZIMAGE_META_STRENGTH} floors; sdxl-zimage " f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / " f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)" ) @@ -167,8 +184,18 @@ def resolve_strength( return resolution_adaptive_denoise(*size) -def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft"] | None: - """Select the strength cohort from non-invalid pixel-watermark provenance.""" +def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft", "meta"] | None: + """Select the strength cohort from non-invalid pixel-watermark provenance. + + OpenAI / Google / Microsoft come from their C2PA issuers. Meta is the + fallback cohort: Muse Image carries no C2PA at all, and its only readable + companion is the IPTC ``trainedAlgorithmicMedia`` XMP tag -- a standard code + other platforms also use. Attributing that tag to Meta is a measured bet, + not an identification: the other tag users in this project's model (ByteDance + products, X) ship no invisible pixel watermark this profile targets, so the + worst misroute spends the Meta floor (0.1) where the resolution curve would + have spent a similar amount, and Google/OpenAI files never reach this arm + because their C2PA matched first.""" try: from remove_ai_watermarks._internal.c2pa import ( c2pa_info_has_invalid_credential, @@ -187,4 +214,25 @@ def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "micros return "openai" if not c2pa_info_has_invalid_credential(info) and c2pa_info_has_invismark(info): return "microsoft" + if _standalone_iptc_ai_tag(image_path): + return "meta" return None + + +def _standalone_iptc_ai_tag(image_path: Path) -> bool: + """True when the file carries an AI IPTC marker with no C2PA around it. + + Mirrors identify's ``standalone_iptc`` condition (the tag is only + trustworthy as platform evidence when no manifest supersedes it) without + importing the heavy identify module: the shared chunk-aware + :func:`metadata.scan_head` window -- Muse WebP outputs place their XMP + packet in a tail chunk up to hundreds of KB past a plain head read, which + is exactly what scan_head's extensions exist to catch. + """ + try: + from remove_ai_watermarks.metadata import IPTC_AI_MARKERS, c2pa_marker_in, scan_head + + scan = scan_head(image_path) + except Exception: + return False + return any(marker in scan for marker in IPTC_AI_MARKERS) and not c2pa_marker_in(scan) diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index ae80ea9..84d2ccb 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -232,6 +232,7 @@ class InvisibleOptions: strength: float | None = None pipeline: str = "qwen-zimage" + vendor: str | None = None seed: int | None = None hf_token: str | None = None humanize: float = 0.0 @@ -487,13 +488,17 @@ def _run_invisible( if not is_available(): say("invisible", "unavailable") return "unavailable" - if not (force or evidence.has_invisible_target()): + if not (force or opts.vendor is not None or evidence.has_invisible_target()): say("invisible", "no-signal") return "no-signal" from remove_ai_watermarks._internal.watermark_profiles import resolve_strength, vendor_for_strength - vendor = vendor_for_strength(vendor_source) + # An explicit vendor override wins over detection and implies the scrub runs: + # naming the cohort (e.g. "meta" for Muse Image Content Seal, which carries no + # provenance to detect) asserts the pixel watermark is present, so the no-signal + # gate must not skip it. + vendor = opts.vendor or vendor_for_strength(vendor_source) # Report the strength the engine will actually execute, resolved the same way it # resolves it, so the reported value cannot drift from the executed one. with suppress(Exception): diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 5e475a4..fc99622 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -274,6 +274,32 @@ _strength_option = click.option( default=None, help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.", ) +# Explicit strength-cohort override. Auto-detection reads the C2PA issuer, so it +# covers OpenAI / Google / Microsoft; Meta Content Seal has no provenance signal +# (no C2PA; the IPTC tag is a standard code), and an unknown or stripped manifest +# also leaves the resolution-adaptive curve in charge -- this flag is the way to +# name the cohort when the user knows what the file does not say. +_vendor_option = click.option( + "--vendor", + type=click.Choice(["auto", "openai", "google", "microsoft", "meta"]), + default="auto", + help=( + "Strength cohort for the invisible-removal default, and it implies the scrub " + "runs even without a local signal: naming the cohort asserts the pixel " + "watermark is present. auto: derive from C2PA provenance, else " + "resolution-adaptive. Set explicitly when the source is known but unreadable " + "(e.g. meta for Muse Image Content Seal, which never carries C2PA)." + ), +) + + +def _explicit_vendor(vendor: str | None) -> str | None: + """Normalize --vendor's ``auto`` default to None for the engine/API seam. + + One helper so the three diffusion commands cannot drift on the spelling.""" + return None if vendor in (None, "auto") else vendor + + _seed_option = click.option( "--seed", type=int, @@ -796,6 +822,7 @@ def cmd_erase( @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @_output_option @_strength_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -815,6 +842,7 @@ def cmd_invisible( source: Path, output: Path | None, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -851,11 +879,17 @@ def cmd_invisible( if output is None: output = source.with_stem(source.stem + "_clean") + # An explicit --vendor wins over detection (see the option help) and implies the + # scrub runs: naming the cohort asserts the pixel watermark is present, so the + # no-signal gate must not skip it. Resolved BEFORE the gate for the same reason. + resolved_vendor = _explicit_vendor(vendor) + # Gate BEFORE building the engine: skip the destructive regeneration when no # invisible AI watermark is locally detectable (it would only degrade a clean # image -- dominant paid score-0 cause), so the common skip path pays nothing for - # engine construction. A skip never claims the image is clean; --force overrides. - if _should_skip_invisible_scrub(force, source): + # engine construction. A skip never claims the image is clean; --force and an + # explicit --vendor override. + if _should_skip_invisible_scrub(force or resolved_vendor is not None, source): _no_invisible_signal_exit(source) def progress_cb(msg: str) -> None: @@ -870,11 +904,18 @@ def cmd_invisible( ) # Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the - # displayed and executed strength agree on the vendor-adaptive default. - vendor = vendor_for_strength(source) + # displayed and executed strength agree on the vendor-adaptive default. An explicit + # --vendor override wins over detection: it names a cohort the file cannot prove + # (Meta Content Seal never carries C2PA; a stripped manifest proves nothing). + detected_vendor = vendor_for_strength(source) if resolved_vendor is None else None + vendor_label = resolved_vendor or detected_vendor + vendor_note = " (override)" if resolved_vendor else "" console.print(f" Input: {source.name}") console.print(f" Pipeline: {pipeline}") - console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}") + console.print( + f" Strength: {_resolved_strength_for_display(source, strength, vendor_label, pipeline)}" + + (f" [vendor: {vendor_label}{vendor_note}]" if vendor_label else "") + ) t0 = time.monotonic() try: @@ -887,7 +928,7 @@ def cmd_invisible( unsharp=unsharp, adaptive_polish=adaptive_polish, max_resolution=max_resolution, - vendor=vendor, + vendor=vendor_label, tile=tile, tile_size=tile_size, tile_overlap=tile_overlap, @@ -1448,6 +1489,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo @_visible_backend_option @_visible_sensitivity_option @_strength_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -1469,6 +1511,7 @@ def cmd_all( backend: str, sensitivity: str, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -1549,6 +1592,7 @@ def cmd_all( sensitivity=_parse_sensitivity(sensitivity), invisible=InvisibleOptions( strength=strength, + vendor=_explicit_vendor(vendor), pipeline=pipeline, seed=seed, hf_token=hf_token, @@ -1643,6 +1687,7 @@ def _batch_engine(mode: str, options: InvisibleOptions) -> object | None: @_visible_backend_option @_visible_sensitivity_option @_humanize_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -1660,6 +1705,7 @@ def cmd_batch( mode: str, output_dir: Path | None, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -1697,6 +1743,7 @@ def cmd_batch( invisible_options = InvisibleOptions( strength=strength, + vendor=_explicit_vendor(vendor), pipeline=pipeline, seed=seed, hf_token=hf_token, diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index c5c6bd3..ee8d1b0 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -135,6 +135,11 @@ _C2PA_INVALID_CAVEAT = ( "are retained only as removal hints, not as verified provenance." ) _IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform." +_CONTENT_SEAL_CAVEAT = ( + "Meta Muse Image outputs carry the invisible Content Seal pixel watermark, which has no " + "local decoder; `invisible` removes it (auto when this tag is present, or `--vendor meta` " + "on stripped files) and meta.ai/identification verifies it." +) _INVISIBLE_WM_CAVEAT = ( "The open invisible watermark is fragile: it does not survive JPEG re-encoding " "or resizing, so it confirms origin only on a pristine (un-re-encoded) file." @@ -1318,6 +1323,7 @@ def _identify_from_evidence( signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high")) watermarks.append("IPTC digitalSourceType (Made with AI)") caveats.append(_IPTC_ONLY_CAVEAT) + caveats.append(_CONTENT_SEAL_CAVEAT) if platform is None: # Apple Photos Clean Up (Apple Intelligence object removal) marks # the edit with photoshop:Credit / IPTC "Apple Photos Clean Up" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bdc26a..dcd2b92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -426,6 +426,39 @@ class TestInvisibleCommand: assert result.exit_code == 0, result.output mock_engine.remove_watermark.assert_called_once() + def test_invisible_explicit_vendor_implies_force_and_sets_cohort(self, runner, sample_png, tmp_path): + """--vendor meta names a cohort the file cannot prove (Content Seal carries + no C2PA), so it must both bypass the no-signal skip and arrive at the engine + as the vendor, where it resolves to the measured Meta floor (not the + area-curve value the same size would otherwise get).""" + mock_cls, mock_engine = _mock_invisible_engine() + output = tmp_path / "clean.png" + with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), + patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), + patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), + ): + result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--vendor", "meta"]) + assert result.exit_code == 0, result.output + kwargs = mock_engine.remove_watermark.call_args.kwargs + assert kwargs["vendor"] == "meta" + assert "0.1" in result.output # the resolved Meta floor, printed + assert "0.094" not in result.output # not the sample's area-curve value (200x200 -> ~0.0944) + + def test_invisible_vendor_auto_keeps_detection_semantics(self, runner, sample_png, tmp_path): + """--vendor auto is the default spelled out: detection still runs and a + no-signal file still skips.""" + mock_cls, mock_engine = _mock_invisible_engine() + output = tmp_path / "clean.png" + with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), + patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), + patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), + ): + result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--vendor", "auto"]) + assert result.exit_code == 2, result.output + mock_engine.remove_watermark.assert_not_called() + def test_invisible_runs_without_force_when_signal_present(self, runner, tmp_path): """An image carrying an AI metadata signal IS a scrub target, so the run proceeds with no --force needed.""" diff --git a/tests/test_platform.py b/tests/test_platform.py index ceeea88..9378c9e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -6,6 +6,7 @@ answer and a clean refusal rather than a fallback ladder. from __future__ import annotations +import struct from pathlib import Path from unittest.mock import MagicMock, patch @@ -15,6 +16,7 @@ from remove_ai_watermarks._internal.utils import get_image_format, is_supported_ from remove_ai_watermarks._internal.watermark_profiles import ( PROFILE_CHOICES, QWEN_ZIMAGE_GOOGLE_STRENGTH, + QWEN_ZIMAGE_META_STRENGTH, QWEN_ZIMAGE_OPENAI_STRENGTH, REMOVAL_MODULES, SDXL_ZIMAGE_GEMINI_STRENGTH, @@ -198,6 +200,30 @@ class TestNoReembeddedWatermark: assert "add_watermarker" not in calls["controlnet"] +# Minimal WebP stub whose XMP chunk carries the IPTC trainedAlgorithmicMedia +# tag exactly as Muse outputs place it (built inline so the test has no binary +# fixture dependency). +_XMP_PAYLOAD = ( + b'' + b'' + b"" +) + + +def _webp_stub(xmp: bytes | None) -> bytes: + def chunk(cid: bytes, data: bytes) -> bytes: + return cid + struct.pack("