diff --git a/docs/known-limitations.md b/docs/known-limitations.md index d986311..137df50 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -178,8 +178,12 @@ The two profiles resolve an unset strength differently, because different things were measured for each. `qwen-zimage` reads it from image area, through the resolution-adaptive denoise -curve. The vendor is deliberately ignored: the curve, not the issuer, is what was -calibrated. +curve. The vendor is deliberately ignored for openai/unknown content: the curve, +not the issuer, is what was calibrated. One measured exception (0.27.2): +Google-provenance content takes the flat `QWEN_ZIMAGE_GOOGLE_STRENGTH` 0.30 floor +- the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full +production path (2026-08-18), while 0.30 anchors measured clean on two fixtures +in two accounts. `sdxl-zimage` reads it from the C2PA issuer, on a flat ladder: diff --git a/docs/synthid.md b/docs/synthid.md index 35c9caf..2935af2 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -769,3 +769,15 @@ reproducible verification requires a fixed seed. 8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video Watermarking.** arXiv:2505.21620. https://arxiv.org/abs/2505.21620 + +**Google floor on qwen-zimage (0.27.2, 2026-08-19).** The bracket above did not +survive the full production path: on the 4.33 MP CJK-sign fixture (visible-stage +sparkle removal -> qwen-zimage seed 0 at the curve's 0.154 top -> resize-back -> +metadata strip), the Gemini verifier detected SynthID x3 with a valid +pixel-identical stripped control in the same session. Google-provenance content +now takes a flat `QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30` floor instead of the area +curve - 0.30 anchors measured clean in Gemini on two fixtures (CJK sign + +18-face) at 3/3 checks across two work accounts, and stayed clean with the +vae-glyphs donor layer on top; openai/unknown content keeps the resolution +curve. An explicit strength still wins. Certification artifacts: raiw-app +`data/certification/text-restoration-2026-08-18/` (the failing re-baseline). diff --git a/pyproject.toml b/pyproject.toml index 0484fcc..aded025 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.1" +version = "0.27.2" 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 7252578..f9e8ccf 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.1" +__version__ = "0.27.2" __all__ = [ "BatchSummary", diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index d2b2cf4..1e7468e 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -69,6 +69,14 @@ SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15 SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25 SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH +# qwen-zimage keeps its resolution curve for openai/unknown content, but Google +# content takes this flat oracle-measured floor instead of the curve (whose top, +# 0.154, left a 4.33 MP CJK-sign fixture detected x3 on the full production path +# with a valid pixel control, 2026-08-18). 0.30 anchors measured clean in Gemini +# across two fixtures and two work accounts (CJK sign + 18-face, 3/3 checks each, +# 2026-08 research) and stayed clean under the vae-glyphs donor layer on top. +QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30 + # sdxl-zimage picks its strength from the VENDOR (unlike qwen-zimage, which derives it # from image area). An unlisted or unknown vendor falls back to the Gemini value. @@ -103,7 +111,8 @@ def resolve_adaptive_polish(adaptive_polish: bool | None, pipeline: str) -> bool def strength_default_help() -> str: """Describe the live default policy without duplicating its values.""" return ( - "profile-adaptive (qwen-zimage uses resolution-adaptive denoise; sdxl-zimage " + "profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a " + f"flat oracle-measured Google floor of {QWEN_ZIMAGE_GOOGLE_STRENGTH}; sdxl-zimage " f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / " f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)" ) @@ -118,10 +127,14 @@ def resolve_strength( ) -> float: """Resolve a user override or the calibrated policy for a profile and vendor. - Total by design. qwen-zimage picks its strength from image area rather than from - the vendor, so it needs ``size``; returning ``None`` for it instead would push that + Total by design. qwen-zimage picks its strength from image area rather than + from the vendor, so it needs ``size``; returning ``None`` for it instead would push that branch onto every caller and move one of the two strength policies outside this module. ``size`` is required for qwen-zimage without an explicit strength. + One measured exception since 0.27.2: Google-provenance content on qwen-zimage + takes the flat ``QWEN_ZIMAGE_GOOGLE_STRENGTH`` floor instead of the area curve + (the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full + production path; see the constant's comment). """ if strength is not None: return strength @@ -129,6 +142,8 @@ def resolve_strength( return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH) if size is None: raise ValueError("qwen-zimage resolves strength from image area, so size is required") + if (vendor or "").casefold() == "google": + return QWEN_ZIMAGE_GOOGLE_STRENGTH from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise return resolution_adaptive_denoise(*size) diff --git a/tests/test_platform.py b/tests/test_platform.py index 337e68e..bf81d43 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -14,6 +14,7 @@ import pytest from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format from remove_ai_watermarks._internal.watermark_profiles import ( PROFILE_CHOICES, + QWEN_ZIMAGE_GOOGLE_STRENGTH, REMOVAL_MODULES, SDXL_ZIMAGE_GEMINI_STRENGTH, SDXL_ZIMAGE_OPENAI_STRENGTH, @@ -204,16 +205,21 @@ class TestResolveStrength: qwen-zimage picks strength from image area, so it takes the size. Returning None for it would push that branch onto every caller and leave one of the two - strength policies living outside this module. The vendor is ignored here on - purpose - the curve, not the issuer, is what was calibrated. + strength policies living outside this module. Google content is the one + measured exception since 0.27.2: it takes the flat oracle-measured floor + instead of the curve (the curve's 0.154 top left a 4.33 MP fixture + oracle-detected on the full production path). """ - assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154) + assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154) assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084) + assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH + # The floor holds at every size, not only above the curve's top rung. + assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH def test_qwen_zimage_without_a_size_fails_loudly(self): """A missing size must not silently fall back to some vendor value.""" with pytest.raises(ValueError, match="size is required"): - resolve_strength(None, "google", "qwen-zimage") + resolve_strength(None, "openai", "qwen-zimage") def test_sdxl_zimage_uses_its_flat_vendor_ladder(self): diff --git a/tests/test_qwen_zimage_pipeline.py b/tests/test_qwen_zimage_pipeline.py index 7e9eadc..46a8cf4 100644 --- a/tests/test_qwen_zimage_pipeline.py +++ b/tests/test_qwen_zimage_pipeline.py @@ -866,6 +866,7 @@ def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path, def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone(): """An SDXL global pass needs more strength than Qwen, so it gets its own policy.""" from remove_ai_watermarks._internal.watermark_profiles import ( + QWEN_ZIMAGE_GOOGLE_STRENGTH, SDXL_ZIMAGE_GEMINI_STRENGTH, SDXL_ZIMAGE_OPENAI_STRENGTH, resolve_strength, @@ -875,11 +876,14 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH) # Unknown provenance takes the stricter of the two. assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH) - # An explicit value still wins, and qwen-zimage is untouched by this ladder: it - # defers to its resolution curve rather than to a vendor value. + # An explicit value still wins, and qwen-zimage's curve is untouched by this + # ladder - with the one measured exception: Google content takes the flat + # oracle floor (0.27.2), not the area curve. assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4) assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154) - assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154) + assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx( + QWEN_ZIMAGE_GOOGLE_STRENGTH + ) def test_sdxl_zimage_shares_the_fixed_seed_contract(): diff --git a/uv.lock b/uv.lock index ee7fc4c..99d0440 100644 --- a/uv.lock +++ b/uv.lock @@ -2508,7 +2508,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.27.1" +version = "0.27.2" source = { editable = "." } dependencies = [ { name = "c2pa-python" },