diff --git a/docs/cli.md b/docs/cli.md index a3386a5..6963d0c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -416,6 +416,12 @@ remove-ai-watermarks invisible image.png -o clean.png \ --pipeline qwen-zimage --text-manifest verified-lines.json --force ``` +Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it +was measured to return detector-visible OpenAI SynthID on poster-scale manifests +(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the +0.27.0 research behavior; text-box fidelity lost by the default is well under one +MAE point on the measured fixtures. + The manifest is a JSON object with `schema_version: 1`, `verified: true`, decoded RGB dimensions, `source_pixel_sha256`, and a non-empty `lines` array. Each line has an integer `[x1, y1, x2, y2]` box, exact `text`, a non-empty `script`, and an optional diff --git a/docs/python-api.md b/docs/python-api.md index 385ea0e..55b4662 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -614,6 +614,13 @@ postprocessing combinations that were not evaluated. `InvisibleOptions` exposes same field for `remove_all`; after a visible-stage edit, the manifest must be built against the staged pixels rather than the pristine source. +Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is **off by +default** (`fidelity_anchor=False`): that whole-frame blend was measured to +return detector-visible OpenAI SynthID on poster-scale manifests (official +Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6 +without it, controls and base outputs validated in the same sessions). Pass +`fidelity_anchor=True` to reproduce the 0.27.0 research behavior. + `remove_watermark` takes strength, seed, tiling, resolution, and postprocessing controls. It takes no model id, step count or guidance scale, and neither does the constructor: each profile pins its model stack, its per-stage schedule and CFG diff --git a/docs/text-protection-research.md b/docs/text-protection-research.md index 01bc1b4..84775de 100644 --- a/docs/text-protection-research.md +++ b/docs/text-protection-research.md @@ -343,3 +343,29 @@ The stable errors were punctuation: one lost English comma and an ideographic comma consistently replaced by ASCII. Thus crop agreement and confidence do not verify literal text. The helper is retained only to seed annotations; every proposal needs manual review before it can enter the restoration compositor. + +## Production-path anchor leak and fix (2026-08-19) + +The engine `text_manifest=` path (unlike the research compositor above) blends a +15% Qwen-VAE fidelity anchor across the WHOLE frame before glyph restoration. +Certified through the official OpenAI Content Provenance API on the exact +production path (qwen-zimage, seed 0, no strength override, untiled, LANCZOS +resize-back, metadata strip; library 0.27.0 @ 50d9f448): + +- controls (pixel-identical metadata-stripped sources): `synthid: detected` - + the pixel channel was proven live before every session; +- base outputs: `clean` x6 (two 20-line posters, 3 attempts each); +- text-restored WITH the anchor: `synthid: detected` x6, deterministic; +- text-restored with `FIDELITY_BLEND_ALPHA = 0` (runtime ablation, base bytes + identical between runs): `clean` x6. + +The anchor was therefore the leak channel: a 15% global donor admixture over a +scrubbed frame returns enough OpenAI SynthID for the provider detector. Fidelity +cost of dropping it (source-referenced text-box MAE): 11.60->11.72, 7.79->7.86, +and 7.57->8.13 across the two posters and the CJK sign - well under one point +against base MAEs of 24.7/16.8/10.2. Since 0.27.1 the anchor is off by default +(`fidelity_anchor` parameter on `remove_watermark` / `InvisibleOptions` / +`--fidelity-anchor`); Google rows were not re-queried because the default +strength leaves that fixture's base detected (the vendor-strength lane tracks +that separately). Certification artifacts: raiw-app +`data/certification/text-restoration-2026-08-19-A1/` (sha256-stamped rows). diff --git a/pyproject.toml b/pyproject.toml index d4ca275..0484fcc 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.27.0" +version = "0.27.1" 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 247fb97..7252578 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.27.0" +__version__ = "0.27.1" __all__ = [ "BatchSummary", diff --git a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py index 64671a9..b441552 100644 --- a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py +++ b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py @@ -1071,6 +1071,7 @@ class QwenZImagePipeline: tile_size: int = 1024, tile_overlap: int = 128, text_manifest: VerifiedTextManifest | None = None, + fidelity_anchor: bool = False, ) -> Image.Image: """Execute global regeneration and masked face repair.""" self._require_cuda() @@ -1120,7 +1121,16 @@ class QwenZImagePipeline: restore_verified_text, ) - self._progress("Blending the Qwen-VAE fidelity anchor...") - anchor = blend_fidelity_anchor(result, donor) + # The anchor is OFF by default since 0.27.1: blending 15% of the Qwen-VAE + # donor ACROSS THE WHOLE FRAME returned detector-visible OpenAI SynthID on + # poster-scale manifests (official Content Provenance API, 2026-08-19: + # detected x6 with the anchor, clean x6 without it, base clean throughout; + # see docs/text-protection-research.md). ``fidelity_anchor=True`` keeps the + # 0.27.0 research behavior for reproduction. + if fidelity_anchor: + self._progress("Blending the Qwen-VAE fidelity anchor...") + anchor = blend_fidelity_anchor(result, donor) + else: + anchor = result self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...") return restore_verified_text(image, anchor, donor, text_manifest.lines) diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 117623f..ae80ea9 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -244,6 +244,7 @@ class InvisibleOptions: tile_size: int = 1024 tile_overlap: int = 128 text_manifest: Path | None = None + fidelity_anchor: bool = False # What the invisible stage did. "unavailable" is the one outcome the caller must @@ -525,6 +526,7 @@ def _run_invisible( tile_size=opts.tile_size, tile_overlap=opts.tile_overlap, text_manifest=opts.text_manifest, + fidelity_anchor=opts.fidelity_anchor, ) say("invisible", "removed") return "removed" diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 4849edb..1f15a3d 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -321,6 +321,16 @@ _text_manifest_option = click.option( ), ) +_fidelity_anchor_option = click.option( + "--fidelity-anchor/--no-fidelity-anchor", + default=False, + help=( + "With --text-manifest: blend 15% of the Qwen-VAE donor across the whole " + "frame. Off by default - the global blend was measured to return " + "detector-visible OpenAI SynthID on poster-scale manifests." + ), +) + _visible_backend_option = click.option( "--backend", @@ -798,6 +808,7 @@ def cmd_erase( @_force_option @_cpu_offload_option @_text_manifest_option +@_fidelity_anchor_option @click.pass_context def cmd_invisible( ctx: click.Context, @@ -818,6 +829,7 @@ def cmd_invisible( force: bool, cpu_offload: bool, text_manifest: Path | None, + fidelity_anchor: bool, ) -> None: """Remove invisible AI watermarks (SynthID, StableSignature, TreeRing). @@ -880,6 +892,7 @@ def cmd_invisible( tile_size=tile_size, tile_overlap=tile_overlap, text_manifest=text_manifest, + fidelity_anchor=fidelity_anchor, ) except (OSError, RuntimeError, ValueError) as exc: console.print(f" Error: {exc}") @@ -1428,6 +1441,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo @_force_option @_cpu_offload_option @_text_manifest_option +@_fidelity_anchor_option @click.pass_context def cmd_all( ctx: click.Context, @@ -1450,6 +1464,7 @@ def cmd_all( force: bool, cpu_offload: bool, text_manifest: Path | None, + fidelity_anchor: bool, ) -> None: """Remove ALL watermarks: visible + invisible + metadata. @@ -1528,6 +1543,7 @@ def cmd_all( tile_size=tile_size, tile_overlap=tile_overlap, text_manifest=text_manifest, + fidelity_anchor=fidelity_anchor, ), force=force, progress=progress, diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index 4ca8a59..93c75bc 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -150,6 +150,7 @@ class InvisibleEngine: tile_size: int = 1024, tile_overlap: int = 128, text_manifest: Path | None = None, + fidelity_anchor: bool = False, ) -> Path: """Remove invisible watermark from an image. @@ -187,6 +188,13 @@ class InvisibleEngine: Requires the ``text-restoration`` extra and the ``qwen-zimage`` profile. Incompatible with tiling, downscaling, humanize, unsharp, and adaptive polish because those combinations are not calibrated. + fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame + before glyph restoration. OFF by default since 0.27.1: that global + blend was measured to return detector-visible OpenAI SynthID on + poster-scale manifests (detected x6 with the anchor vs clean x6 + without it, base clean; official Content Provenance API, + 2026-08-19 - docs/text-protection-research.md). ``True`` reproduces + the 0.27.0 research behavior. Requires ``text_manifest``. Returns: Path to the cleaned image. @@ -196,6 +204,8 @@ class InvisibleEngine: seed = resolve_seed(seed) adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile) + if fidelity_anchor and text_manifest is None: + raise ValueError("fidelity_anchor requires a text manifest") if text_manifest is not None: if self._remover.model_profile != QWEN_ZIMAGE_PROFILE: raise ValueError("--text-manifest is supported only by the qwen-zimage profile") @@ -270,6 +280,7 @@ class InvisibleEngine: tile_size=tile_size, tile_overlap=tile_overlap, text_manifest=verified_text, + fidelity_anchor=fidelity_anchor, ) # Post-processing chain: decode the diffusion output ONCE, apply the diff --git a/tests/test_invisible_engine.py b/tests/test_invisible_engine.py index f459aa0..320fa9e 100644 --- a/tests/test_invisible_engine.py +++ b/tests/test_invisible_engine.py @@ -73,6 +73,15 @@ class TestVerifiedTextMode: **kwargs, ) + def test_rejects_fidelity_anchor_without_manifest(self, tmp_path): + import pytest + + with pytest.raises(ValueError, match="fidelity_anchor requires a text manifest"): + self._engine().remove_watermark( + tmp_path / "unused.png", + fidelity_anchor=True, + ) + def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch): import json @@ -111,6 +120,14 @@ class TestVerifiedTextMode: engine.remove_watermark(source, output, text_manifest=manifest) assert seen["text_manifest"].lines[0].text == "Exact" + # Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless + # explicitly requested (measured to return detector-visible OpenAI SynthID + # on poster-scale manifests; see docs/text-protection-research.md). + assert seen["fidelity_anchor"] is False + + engine.remove_watermark(source, output, text_manifest=manifest, fidelity_anchor=True) + + assert seen["fidelity_anchor"] is True class TestNativeOutputSize: diff --git a/tests/test_qwen_zimage_pipeline.py b/tests/test_qwen_zimage_pipeline.py index e1b4824..7e9eadc 100644 --- a/tests/test_qwen_zimage_pipeline.py +++ b/tests/test_qwen_zimage_pipeline.py @@ -646,9 +646,17 @@ def test_no_face_path_still_runs_verified_text_restoration(monkeypatch): result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest) + assert result is restored + # Off by default since 0.27.1 (leak finding, docs/text-protection-research.md): + # no whole-frame donor blend; the raw global result feeds restoration. + blend.assert_not_called() + restore.assert_called_once_with(source, global_result, donor, manifest.lines) + + result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True) + assert result is restored blend.assert_called_once_with(global_result, donor) - restore.assert_called_once_with(source, anchor, donor, manifest.lines) + restore.assert_called_with(source, anchor, donor, manifest.lines) def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch): diff --git a/uv.lock b/uv.lock index fa3a725..ee7fc4c 100644 --- a/uv.lock +++ b/uv.lock @@ -2508,7 +2508,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.27.0" +version = "0.27.1" source = { editable = "." } dependencies = [ { name = "c2pa-python" },