diff --git a/docs/chroma1-engine-research.md b/docs/chroma1-engine-research.md index ccf114a..311df45 100644 --- a/docs/chroma1-engine-research.md +++ b/docs/chroma1-engine-research.md @@ -287,8 +287,10 @@ Both need the seed-sensitivity and face-stage composition checks called out above before shipping. Both checks have now been run (Pre-ship validation above): the Meta boundary is seed-stable across seeds 0-2 and the full profile path stays clean through the face stage on the shipped Google floor. -The profile ships with option 1's floors; option 2's adaptive policy remains -the documented follow-up. +The profile ships with option 1's floors plus a content-adaptive Google arm +(face content resolves to 0.125 instead of 0.40, keyed on YuNet detection). +Option 2's full adaptive policy for the remaining cohorts remains the +documented follow-up. ## Oracle session notes diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 67ec198..e38da7e 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -215,9 +215,11 @@ established for that stage. - OpenAI: `0.09` (below qwen's `0.07675` is false economy neither way -- the matched-strength fidelity favors Chroma1 on this cohort); - Microsoft InvisMark: `0.125` (below qwen's `0.15`); -- Google: `0.40` (above qwen's `0.27`; at this floor the regeneration destroys - dense text and collapses face identity -- the tradeoff that motivates the - documented content-adaptive follow-up); +- Google: `0.40` for zero-face content (above qwen's `0.27`; at this floor the + regeneration destroys dense text and collapses face identity -- the tradeoff + that motivates the adaptive arm below), or `0.125` when YuNet detects at + least one face (both measured face fixtures clear at 0.12 with zero + cross-source spread; oracle-verified 2026-08-30); - Meta Content Seal: `0.17` (above qwen's `0.1`); - unknown: `0.40`, following the strictest measured cohort. diff --git a/docs/module-internals.md b/docs/module-internals.md index 3df9779..4a16da8 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1174,13 +1174,17 @@ conditioning: the floors were measured on a plain strength pass. The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles): OpenAI 0.09 and Microsoft 0.125 (both BELOW qwen's, with better -matched-strength fidelity), Google 0.40 and Meta 0.17 (both ABOVE qwen's, -because Chroma1's per-fixture boundaries scatter wider). The -matched-strength addendum in the research doc is the honest read: at the -strength each image actually needs, Chroma1 regenerates better almost -everywhere except face identity (which the inherited Z-Image face stage -supplies); at the flat worst-case floors it destroys dense text and face -identity. A content-adaptive strength policy is the documented follow-up. +matched-strength fidelity), Google 0.40 for zero-face content / 0.125 when +faces are detected (a content-adaptive arm from the clean face-count split +in the calibration: both text cards need 0.25, both face fixtures clear at +0.12, so the YuNet detector -- already loaded for the face stage -- routes +the operating point), and Meta 0.17 (ABOVE qwen's, because Chroma1's +per-fixture boundaries scatter wider). The matched-strength addendum in the +research doc is the honest read: at the strength each image actually needs, +Chroma1 regenerates better almost everywhere except face identity (which the +inherited Z-Image face stage supplies); at the flat worst-case floors it +destroys dense text and face identity. The Google face-content arm is the +first shipped piece of the content-adaptive policy. ### SDXL plus Z-Image diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 071bd10..99dd1e5 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -153,6 +153,19 @@ CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40 CHROMA_ZIMAGE_META_STRENGTH = 0.17 CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH +# Content-adaptive Google floor: the four-fixture calibration showed a clean +# face-count split. Both zero-face fixtures (dense-text cards 633uuy and akdbei) +# need 0.25 first-clean; both face fixtures (y48j3c with 17 detected faces and +# 3mc4t9 with 7) clear at 0.12. The flat 0.40 floor is the zero-face policy +# (worst 0.25 + spread 0.13 = 0.38, rounded to 0.40). Face content can use the +# measured 0.125 rung instead: both face fixtures first-cleaned at 0.12, and +# with identical boundaries the cross-source spread is zero, so 0.125 (the next +# measured rung above 0.12) is the operating point. This split is +# Google-SynthID-specific: OpenAI's face fixture was HARDER than its text +# fixture (0.075 vs 0.06), so no other cohort gets an adaptive arm. +# Oracle-verified 2026-08-30; see docs/chroma1-engine-research.md. +CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH = 0.125 + _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = { "openai": CHROMA_ZIMAGE_OPENAI_STRENGTH, "google": CHROMA_ZIMAGE_GOOGLE_STRENGTH, @@ -204,6 +217,7 @@ def resolve_strength( pipeline: str | None = None, *, size: tuple[int, int] | None = None, + face_count: int | None = None, ) -> float: """Resolve a user override or the calibrated policy for a profile and vendor. @@ -222,7 +236,10 @@ def resolve_strength( if normalized == SDXL_ZIMAGE_PROFILE: return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH) if normalized == CHROMA_ZIMAGE_PROFILE: - return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), CHROMA_ZIMAGE_UNKNOWN_STRENGTH) + vendor_key = (vendor or "").casefold() + if vendor_key == "google" and face_count is not None and face_count > 0: + return CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH + return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get(vendor_key, CHROMA_ZIMAGE_UNKNOWN_STRENGTH) if size is None: raise ValueError("qwen-zimage resolves strength from image area, so size is required") vendor_strength = _QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR.get((vendor or "").casefold()) diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py index 3806f17..d6e6e41 100644 --- a/src/remove_ai_watermarks/_internal/watermark_remover.py +++ b/src/remove_ai_watermarks/_internal/watermark_remover.py @@ -188,6 +188,26 @@ class WatermarkRemover: remove_ai_metadata(output_path, output_path, keep_standard=True) + def _resolve_chroma_strength( + self, + strength: float | None, + vendor: str | None, + source: Image.Image, + ) -> float: + """Resolve strength, running face detection for the chroma Google arm. + + The content-adaptive Google floor needs a face count. YuNet is fast + (~50 ms) and the model download is already managed by the shared base. + """ + if self.model_profile != CHROMA_ZIMAGE_PROFILE or (vendor or "").casefold() != "google": + return resolve_strength(strength, vendor, self.model_profile, size=source.size) + if strength is not None: + return strength + from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces + + face_count = len(detect_faces(source)) + return resolve_strength(strength, vendor, self.model_profile, size=source.size, face_count=face_count) + def remove_watermark( self, image_path: Path, @@ -213,7 +233,7 @@ class WatermarkRemover: with Image.open(image_path) as opened: source = opened.convert("RGB") - resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size) + resolved_strength = self._resolve_chroma_strength(strength, vendor, source) if not 0.0 <= resolved_strength <= 1.0: raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}") if text_manifest is not None and self.model_profile in (SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_PROFILE): diff --git a/tests/test_qwen_zimage_pipeline.py b/tests/test_qwen_zimage_pipeline.py index 0ebe399..13d2f79 100644 --- a/tests/test_qwen_zimage_pipeline.py +++ b/tests/test_qwen_zimage_pipeline.py @@ -1204,3 +1204,34 @@ def test_chroma_vae_roundtrip_raises_not_implemented(): pipeline = ChromaZImagePipeline(device="cuda", torch_dtype=torch.bfloat16) with pytest.raises(NotImplementedError, match="does not expose a VAE donor"): pipeline._vae_roundtrip(Image.new("RGB", (32, 32))) + + +def test_chroma_google_face_content_gets_the_lower_adaptive_floor(): + """Face content on Google SynthID clears at 0.125, not 0.40. + + The four-fixture calibration showed a clean face-count split: both zero-face + text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm + saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content. + """ + from remove_ai_watermarks._internal.watermark_profiles import ( + CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH, + CHROMA_ZIMAGE_GOOGLE_STRENGTH, + resolve_strength, + ) + + assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH + # Face content gets the lower adaptive floor. + assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125) + assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125) + # Zero-face content gets the flat floor (text/graphic cohort). + assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40) + assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH + # No face_count hint: conservative (flat floor). + assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40) + # Other vendors ignore face_count: the split is Google-SynthID-specific. + assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.09) + assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17) + # An explicit strength still wins over the adaptive arm. + assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20) + # Other profiles are untouched by the face_count parameter. + assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)