feat(invisible): add Qwen-Image img2img pipeline (--pipeline qwen)

A third diffusion pipeline alongside sdxl/controlnet: Qwen-Image (20B MMDiT,
Apache-2.0 code AND weights) img2img. The scrub still comes from the img2img
strength; Qwen preserves text (incl. CJK) and structure markedly better than
SDXL at the scrub floor, so it over-regenerates real photos far less (directly
targets the controlnet over-regeneration that degrades real uploads).

- watermark_profiles: QWEN_MODEL_ID, normalize_profile accepts "qwen".
- WatermarkRemover: _load_qwen_pipeline (bf16, loads Qwen base unless --model
  overridden, clear ImportError if diffusers lacks the class), _run_qwen (no
  MPS fallback -- 20B is CUDA/cloud-class), dispatch in _generate_one/preload,
  pure _build_qwen_kwargs (true_cfg_scale, not guidance_scale).
- Shared _base_load_kwargs() across all three loaders (dtype + token).
- CLI --pipeline gains "qwen"; invisible_engine threads it through.
- scripts/qwen_scrub_prototype.py: standalone PEP 723 GPU experiment.

Prototype oracle floors (Modal A100-80GB, single seed, controls SynthID-positive,
PENDING seed-repeat cert): OpenAI clears at strength ~0.10, Gemini at ~0.30 (0.20
still detected), with CJK text + faces faithful where controlnet plasticizes. The
Gemini floor is higher than the shared default ladder, so pass an explicit
--strength for Gemini on this pipeline until a Qwen-specific ladder is certified.

The model-running path is CUDA-only (untestable locally); unit tests cover the
pure call-shape (_build_qwen_kwargs) and profile normalization without torch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-19 20:44:36 -07:00
parent 0c0c6c6b03
commit 76e3d4154c
10 changed files with 309 additions and 24 deletions
+30
View File
@@ -115,6 +115,7 @@ class TestModelProfiles:
def test_canonical_profiles_unchanged(self):
assert normalize_profile("sdxl") == "sdxl"
assert normalize_profile("controlnet") == "controlnet"
assert normalize_profile("qwen") == "qwen"
def test_default_alias_resolves_to_sdxl(self):
# "default" is the legacy alias for "sdxl" (back-compat for existing scripts).
@@ -125,6 +126,35 @@ class TestModelProfiles:
assert normalize_profile("CONTROLNET") == "controlnet"
class TestQwenKwargs:
"""_build_qwen_kwargs is pure (no torch); guards the Qwen-Image call shape.
watermark_remover imports torch under a try/except, so the module (and this pure
helper) imports fine in the core+dev CI env where torch is absent.
"""
def test_uses_true_cfg_not_guidance_scale(self):
from remove_ai_watermarks.noai.watermark_remover import _build_qwen_kwargs
gen = object()
kwargs = _build_qwen_kwargs("IMG", strength=0.3, num_inference_steps=40, true_cfg_scale=4.0, generator=gen)
# Qwen uses true_cfg_scale, NOT SDXL's guidance_scale.
assert kwargs["true_cfg_scale"] == 4.0
assert "guidance_scale" not in kwargs
# The scrub still comes from strength; image + generator pass through.
assert kwargs["strength"] == 0.3
assert kwargs["image"] == "IMG"
assert kwargs["generator"] is gen
# Faithful-regeneration prompt + an explicit negative prompt.
assert kwargs["prompt"]
assert kwargs["negative_prompt"]
def test_qwen_model_id_is_qwen_image(self):
from remove_ai_watermarks.noai.watermark_profiles import QWEN_MODEL_ID
assert QWEN_MODEL_ID == "Qwen/Qwen-Image"
class TestResolveStrength:
"""resolve_strength applies the vendor default only when strength is unset."""