Merge pull request #74 from wiltodelta/feat/sdxl-zimage-profile

Add the sdxl-zimage profile: the same recipe on an SDXL global stage
This commit is contained in:
Victor Kuznetsov
2026-08-02 21:27:54 -07:00
committed by GitHub
6 changed files with 353 additions and 21 deletions
+50
View File
@@ -589,6 +589,56 @@ orchestration, YuNet integration, SAM selection, masks, sizing helpers, and pixe
compositing are implemented for this runtime. Changing a calibrated model input
requires the same provider-oracle and identity evaluation as a model change.
### SDXL plus Z-Image
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` subclasses
`QwenZImagePipeline` and overrides only `_run_global` and `preload`, so the face stage
is inherited rather than copied and cannot drift between the profiles; a test asserts
the shared methods are the same objects.
Four things are architecture-bound and swap with the model: the ControlNet
(`xinsir/controlnet-canny-sdxl-1.0`), the four-step distillation LoRA
(`ByteDance/SDXL-Lightning` at its documented strength 1.0, not the reference graph's
0.8, which belongs to a different LoRA), the sampler (Euler with trailing spacing, no
AuraFlow shift), and the latent grid (8 px against Qwen's 16).
**Strength is architecture-bound too, and that is the easy mistake.** An SDXL global
pass leaves SynthID at the strength Qwen needs: verified through the Gemini app on a
native 2816x1536 original, 0.154 is FOUND while 0.20, 0.25 and 0.30 are clean. So this
profile takes a vendor policy (`SDXL_ZIMAGE_OPENAI_STRENGTH` 0.15,
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.25, unknown following Gemini) rather than
`resolution_adaptive_denoise`. Flat values are what was measured; no size dependence
has been established for this stage, so none is asserted.
`requested_steps` exists because the two runtimes truncate differently. DiffSynth sets
`sigma_start = denoising_strength` and runs every requested step across the shortened
sigma range; Diffusers img2img truncates the step *count*
(`init_timestep = int(steps * strength)`), so asking it for four steps at 0.15 executes
**zero** and returns a bare VAE round-trip.
This profile is not deployed. Before it could be, it needs the other three Gemini
originals, OpenAI re-verified at 0.15, a flat-graphic content class, and a low
resolution case -- every verdict so far comes from one fixture and one seed.
### Measured provider boundaries for qwen-zimage
Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
everything it has been tested at:
| oracle | fixture size | detected at | clean from |
|---|---|---|---|
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
| Gemini app | 4.33 MP | 0.08 | 0.10 |
| Gemini app | 0.57 MP | -- | 0.0896 (the curve's own value) |
| Gemini app | 1.40 MP | -- | 0.1066 (the curve's own value) |
Read the last two rows before concluding the curve's low end is under-driven. Against
the 4.33 MP Gemini boundary the sub-1 MP rungs of 0.084-0.094 look short, but at those
sizes the curve's own values verify clean, which is what a resolution-scaled
requirement would predict. There is no measured size at which the shipped curve fails,
so it is left alone.
### Static prompt embeddings
Both stages prompt with module constants, and at CFG 1.0 DiffSynth's
@@ -0,0 +1,154 @@
"""The qwen-zimage recipe with an SDXL global stage.
Only the global regeneration model changes. The face stage is inherited verbatim
from :class:`QwenZImagePipeline` -- same YuNet detection, same SAM masks, same
Z-Image Turbo repair of the original crops, same feathered compositing -- so a
change there cannot silently diverge between the two profiles.
Three pieces cannot be shared, because they are bound to the architecture: the
ControlNet, the four-step distillation LoRA, and the sampler. Strength is bound to
it too, which is the part that is easy to miss: an SDXL global pass leaves SynthID
at the strength Qwen needs. See ``watermark_profiles.SDXL_ZIMAGE_OPENAI_STRENGTH``.
"""
# Diffusers and torch expose mostly untyped tensor APIs. Keep the relaxation local
# to this optional ML boundary.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportPrivateUsage=false, reportPrivateImportUsage=false
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any
from PIL import Image
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
GLOBAL_STEPS,
QwenZImagePipeline,
build_canny_control_image,
)
from remove_ai_watermarks._internal.watermark_profiles import (
CONTROLNET_CANNY_MODEL,
DEFAULT_MODEL_ID,
SDXL_LIGHTNING_MODEL_ID,
SDXL_LIGHTNING_PATTERN,
)
log = logging.getLogger(__name__)
SDXL_VAE_MODEL_ID = "madebyollin/sdxl-vae-fp16-fix"
# SDXL aligns to an 8-pixel latent grid, against Qwen's 16.
_LATENT_GRID = 8
def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
"""Floor dimensions to SDXL's latent grid without changing aspect."""
return max(_LATENT_GRID, (width // _LATENT_GRID) * _LATENT_GRID), max(
_LATENT_GRID, (height // _LATENT_GRID) * _LATENT_GRID
)
def requested_steps(effective_steps: int, strength: float) -> int:
"""Translate "spend N denoising steps" into what Diffusers has to be asked for.
The two runtimes truncate differently and it is easy to port this wrong.
DiffSynth sets ``sigma_start = denoising_strength`` and then runs *every*
requested step across the shortened sigma range. Diffusers img2img instead
truncates the step *count* (``init_timestep = int(steps * strength)``), so
asking it for four steps at strength 0.15 runs **zero** and returns nothing but
a VAE round-trip. Ask for enough that ``effective_steps`` actually execute.
"""
return max(1, math.ceil(effective_steps / max(float(strength), 1e-6)))
@dataclass
class SdxlZImagePipeline(QwenZImagePipeline):
"""Lazy runtime for the SDXL global stage plus the inherited face stage."""
def __post_init__(self) -> None:
super().__post_init__()
self._sdxl_pipe: Any = None
def _load_sdxl(self) -> Any:
if self._sdxl_pipe is not None:
return self._sdxl_pipe
self._require_cuda()
import torch
from diffusers import (
AutoencoderKL,
ControlNetModel,
EulerDiscreteScheduler,
StableDiffusionXLControlNetImg2ImgPipeline,
)
from huggingface_hub import hf_hub_download
self._progress("Loading SDXL, Lightning LoRA, and Canny ControlNet...")
token = {"token": self.hf_token} if self.hf_token else {}
controlnet = ControlNetModel.from_pretrained(CONTROLNET_CANNY_MODEL, torch_dtype=torch.float16, **token)
vae = AutoencoderKL.from_pretrained(SDXL_VAE_MODEL_ID, torch_dtype=torch.float16, **token)
pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(
DEFAULT_MODEL_ID,
controlnet=controlnet,
vae=vae,
torch_dtype=torch.float16,
variant="fp16",
add_watermarker=False,
**token,
).to(self.device)
# SDXL's own four-step distillation, at the strength its authors document.
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a
# different LoRA on a different architecture would be imitation, not parity.
pipe.load_lora_weights(hf_hub_download(SDXL_LIGHTNING_MODEL_ID, SDXL_LIGHTNING_PATTERN, **token))
pipe.fuse_lora()
# SDXL-Lightning is distilled against trailing timestep spacing.
pipe.scheduler = EulerDiscreteScheduler.from_config(pipe.scheduler.config, timestep_spacing="trailing")
self._sdxl_pipe = pipe
return pipe
def preload(self, *, global_only: bool = False) -> None:
"""Eagerly load the mandatory stage and, by default, the face stack."""
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _yunet_model_path
self._load_sdxl()
_yunet_model_path()
if not global_only:
self._load_zimage()
self._load_sam()
def _run_global(self, image: Image.Image, strength: float, seed: int | None) -> Image.Image:
import torch
pipe = self._load_sdxl()
target = sdxl_target_size(image.width, image.height)
prepared = image if image.size == target else image.resize(target, Image.Resampling.LANCZOS)
control = build_canny_control_image(prepared)
steps = requested_steps(GLOBAL_STEPS, strength)
self._progress(f"Running SDXL Canny pass: strength={strength:.4f}, steps={GLOBAL_STEPS} of {steps}...")
generator = torch.Generator(device=self.device).manual_seed(seed) if seed is not None else None
result = pipe(
prompt=self._global_prompt(),
negative_prompt=self._global_negative(),
image=prepared,
control_image=control,
controlnet_conditioning_scale=float(self.controlnet_conditioning_scale),
strength=float(strength),
num_inference_steps=steps,
guidance_scale=1.0,
generator=generator,
).images[0]
if result.size != image.size:
result = result.resize(image.size, Image.Resampling.LANCZOS)
return result.convert("RGB")
@staticmethod
def _global_prompt() -> str:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _GLOBAL_PROMPT
return _GLOBAL_PROMPT
@staticmethod
def _global_negative() -> str:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _GLOBAL_NEGATIVE
return _GLOBAL_NEGATIVE
@@ -15,6 +15,10 @@ CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
SDXL_PROFILE = "sdxl"
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
SDXL_ZIMAGE_PROFILE = "sdxl-zimage"
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
OPENAI_STRENGTH = 0.10
GEMINI_STRENGTH = 0.15
@@ -25,6 +29,22 @@ QWEN_OPENAI_STRENGTH = 0.10
QWEN_GEMINI_STRENGTH = 0.25
QWEN_UNKNOWN_STRENGTH = QWEN_GEMINI_STRENGTH
# sdxl-zimage runs the qwen-zimage recipe on an SDXL global stage, and strength is
# architecture-bound: at Qwen's 0.154 an SDXL global pass leaves SynthID on a native
# 2816x1536 Gemini original, while 0.20, 0.25 and 0.30 all read clean in the Gemini
# app. 0.25 keeps a rung of margin over that boundary, which the historical SDXL
# certification argues for -- it recorded 0.20 as DETECTED against Gemini on an
# older SDXL pipeline. OpenAI is the easier oracle: the profile already cleared
# openai.com/verify at 0.1102, so 0.15 sits above what was verified rather than on
# it. Unknown follows Gemini, the stricter of the two.
#
# Unlike qwen-zimage this is a flat vendor policy rather than a resolution curve,
# because flat values are what was measured. Every verdict above comes from a fixed
# strength at one size; no size dependence has been established for this stage.
SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
@dataclass(frozen=True)
class _StrengthPolicy:
@@ -43,7 +63,16 @@ _QWEN_POLICY = _StrengthPolicy(
unknown=QWEN_UNKNOWN_STRENGTH,
by_vendor={"openai": QWEN_OPENAI_STRENGTH, "google": QWEN_GEMINI_STRENGTH},
)
_ALIASES = {"default": SDXL_PROFILE, "qwen_zimage": QWEN_ZIMAGE_PROFILE}
_SDXL_ZIMAGE_POLICY = _StrengthPolicy(
unknown=SDXL_ZIMAGE_UNKNOWN_STRENGTH,
by_vendor={"openai": SDXL_ZIMAGE_OPENAI_STRENGTH, "google": SDXL_ZIMAGE_GEMINI_STRENGTH},
)
_ALIASES = {
"default": SDXL_PROFILE,
"qwen_zimage": QWEN_ZIMAGE_PROFILE,
"sdxl_zimage": SDXL_ZIMAGE_PROFILE,
}
_FOUR_STEP_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE})
def normalize_profile(profile: str) -> str:
@@ -56,14 +85,14 @@ def resolve_steps(num_inference_steps: int | None, pipeline: str) -> int:
"""Return an explicit step count or the selected profile's default."""
if num_inference_steps is not None:
return num_inference_steps
return 4 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else 50
return 4 if normalize_profile(pipeline) in _FOUR_STEP_PROFILES else 50
def resolve_seed(seed: int | None, pipeline: str) -> int | None:
"""Keep the fixed Qwen plus Z-Image profile reproducible by default."""
"""Keep the fixed four-step Z-Image profiles reproducible by default."""
if seed is not None:
return seed
return 0 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else None
return 0 if normalize_profile(pipeline) in _FOUR_STEP_PROFILES else None
def strength_default_help() -> str:
@@ -79,7 +108,13 @@ def resolve_strength(strength: float | None, vendor: str | None = None, pipeline
"""Resolve a user override or the calibrated policy for a profile and vendor."""
if strength is not None:
return strength
policy = _QWEN_POLICY if pipeline is not None and normalize_profile(pipeline) == "qwen" else _STANDARD_POLICY
profile = normalize_profile(pipeline) if pipeline is not None else ""
if profile == "qwen":
policy = _QWEN_POLICY
elif profile == SDXL_ZIMAGE_PROFILE:
policy = _SDXL_ZIMAGE_POLICY
else:
policy = _STANDARD_POLICY
return policy.choose(vendor)
@@ -17,6 +17,7 @@ from remove_ai_watermarks._internal.watermark_profiles import (
DEFAULT_STRENGTH,
QWEN_MODEL_ID,
QWEN_ZIMAGE_PROFILE,
SDXL_ZIMAGE_PROFILE,
normalize_profile,
resolve_seed,
resolve_steps,
@@ -30,6 +31,14 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Both two-stage profiles share the face stage, the four-step schedule, CFG 1.0, the
# fixed model stack and the native-resolution contract; only the global model differs.
_ZIMAGE_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE})
_ZIMAGE_STACKS = {
QWEN_ZIMAGE_PROFILE: "Qwen-Image-2512 and Z-Image",
SDXL_ZIMAGE_PROFILE: "SDXL and Z-Image",
}
try:
import torch
@@ -195,8 +204,10 @@ class WatermarkRemover:
) -> None:
requested_model = model_id or self.DEFAULT_MODEL_ID
self.model_profile = normalize_profile(pipeline)
if self.model_profile == QWEN_ZIMAGE_PROFILE and model_id not in {None, self.DEFAULT_MODEL_ID}:
raise ValueError("The qwen-zimage profile uses a fixed Qwen-Image-2512 and Z-Image model stack.")
if self.model_profile in _ZIMAGE_PROFILES and model_id not in {None, self.DEFAULT_MODEL_ID}:
raise ValueError(
f"The {self.model_profile} profile uses a fixed {_ZIMAGE_STACKS[self.model_profile]} model stack."
)
self.model_id = (
"Qwen/Qwen-Image-2512 + Tongyi-MAI/Z-Image-Turbo"
if self.model_profile == QWEN_ZIMAGE_PROFILE
@@ -212,6 +223,10 @@ class WatermarkRemover:
self.torch_dtype = torch_dtype
elif self.device in {"cpu", "mps"}:
self.torch_dtype = torch.float32 # type: ignore[union-attr]
elif self.model_profile == SDXL_ZIMAGE_PROFILE:
# SDXL ships fp16 weights and an fp16-safe VAE; bf16 would give up the
# variant without buying anything on this architecture.
self.torch_dtype = torch.float16 # type: ignore[union-attr]
elif self.model_profile in {"qwen", QWEN_ZIMAGE_PROFILE}:
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
else:
@@ -233,7 +248,7 @@ class WatermarkRemover:
def preload(self, *, global_only: bool = False) -> None:
"""Materialize the selected model stack before the first request."""
if self.model_profile == QWEN_ZIMAGE_PROFILE:
if self.model_profile in _ZIMAGE_PROFILES:
self._load_qwen_zimage_pipeline().preload(global_only=global_only)
elif self.model_profile == "qwen":
self._load_qwen_pipeline()
@@ -344,9 +359,16 @@ class WatermarkRemover:
def _load_qwen_zimage_pipeline(self) -> Any:
if self._qwen_zimage_pipeline is None:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
if getattr(self, "model_profile", QWEN_ZIMAGE_PROFILE) == SDXL_ZIMAGE_PROFILE:
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
SdxlZImagePipeline as _Pipeline,
)
else:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
QwenZImagePipeline as _Pipeline,
)
self._qwen_zimage_pipeline = QwenZImagePipeline(
self._qwen_zimage_pipeline = _Pipeline(
device=self.device,
torch_dtype=self.torch_dtype,
hf_token=self.hf_token,
@@ -471,7 +493,7 @@ class WatermarkRemover:
tile_size: int,
tile_overlap: int,
) -> Image.Image:
if self.model_profile == QWEN_ZIMAGE_PROFILE:
if self.model_profile in _ZIMAGE_PROFILES:
return self._run_qwen_zimage(
image,
strength,
@@ -543,14 +565,12 @@ class WatermarkRemover:
resolved_seed = resolve_seed(seed, self.model_profile)
steps = resolve_steps(num_inference_steps, self.model_profile)
guidance = (
1.0 if guidance_scale is None and self.model_profile == QWEN_ZIMAGE_PROFILE else guidance_scale or 7.5
)
if self.model_profile == QWEN_ZIMAGE_PROFILE:
guidance = 1.0 if guidance_scale is None and self.model_profile in _ZIMAGE_PROFILES else guidance_scale or 7.5
if self.model_profile in _ZIMAGE_PROFILES:
if steps != 4:
raise ValueError("The qwen-zimage profile requires 4 steps.")
raise ValueError(f"The {self.model_profile} profile requires 4 steps.")
if guidance != 1.0:
raise ValueError("The qwen-zimage profile requires CFG 1.0.")
raise ValueError(f"The {self.model_profile} profile requires CFG 1.0.")
else:
steps = viable_steps(steps, resolved_strength)
+6 -4
View File
@@ -107,7 +107,9 @@ class InvisibleEngine:
flat-graphic content), or "qwen" (Qwen-Image 20B img2img, best text/
structure preservation but CUDA/cloud-class), or "qwen-zimage"
(Qwen-Image-2512 Lightning + Canny, then SAM-masked Z-Image face
repair; CUDA-only). "default" aliases "sdxl".
repair; CUDA-only), or "sdxl-zimage" (the same recipe and the same face
stage on an SDXL global pass, vendor-adaptive strength because an SDXL
global stage needs more of it; CUDA-only). "default" aliases "sdxl".
hf_token: HuggingFace API token.
progress_callback: Optional callback for progress messages.
controlnet_conditioning_scale: ControlNet structure-preservation
@@ -238,7 +240,7 @@ class InvisibleEngine:
if num_inference_steps is None:
profile = getattr(self._remover, "model_profile", None)
num_inference_steps = 4 if profile == "qwen-zimage" else 100
num_inference_steps = 4 if profile in {"qwen-zimage", "sdxl-zimage"} else 100
profile = getattr(self._remover, "model_profile", "controlnet")
seed = resolve_seed(seed, profile)
@@ -266,7 +268,7 @@ class InvisibleEngine:
# Keep an explicit max cap available for callers, but do not apply the SDXL
# 1024px minimum-resolution floor to this profile.
effective_min_resolution = (
0 if getattr(self._remover, "model_profile", None) == "qwen-zimage" else min_resolution
0 if getattr(self._remover, "model_profile", None) in {"qwen-zimage", "sdxl-zimage"} else min_resolution
)
target = _target_size(
image.width,
@@ -392,7 +394,7 @@ class InvisibleEngine:
"""Remove invisible watermarks from all images in a directory."""
if steps is None:
profile = getattr(self._remover, "model_profile", None)
steps = 4 if profile == "qwen-zimage" else 50
steps = 4 if profile in {"qwen-zimage", "sdxl-zimage"} else 50
return self._remover.remove_watermark_batch(
input_dir=input_dir,
output_dir=output_dir,
+71
View File
@@ -728,3 +728,74 @@ def test_invisible_engine_uses_qwen_zimage_step_default(tmp_image_path, tmp_path
)
assert engine._remover.remove_watermark.call_args.kwargs["num_inference_steps"] == 4
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 (
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
resolve_strength,
)
assert resolve_strength(None, "openai", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_OPENAI_STRENGTH)
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 the older profiles are untouched.
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
assert resolve_strength(None, "openai", "controlnet") == pytest.approx(0.10)
assert resolve_strength(None, "google", "controlnet") == pytest.approx(0.15)
def test_sdxl_zimage_shares_the_four_step_seed_and_step_contract():
from remove_ai_watermarks._internal.watermark_profiles import (
normalize_profile,
resolve_seed,
resolve_steps,
)
assert normalize_profile("sdxl_zimage") == "sdxl-zimage"
assert resolve_steps(None, "sdxl-zimage") == 4
assert resolve_seed(None, "sdxl-zimage") == 0
assert resolve_steps(None, "controlnet") == 50
assert resolve_seed(None, "controlnet") is None
def test_sdxl_requested_steps_compensate_for_the_diffusers_truncation():
"""Diffusers truncates the step COUNT where DiffSynth truncates the sigma range.
Asking Diffusers for four steps at strength 0.15 runs int(4 * 0.15) = 0 and
returns a bare VAE round-trip, so the request has to be scaled up instead.
"""
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import requested_steps
for strength in (0.15, 0.25, 0.0896):
steps = requested_steps(4, strength)
assert int(steps * strength) >= 4
# Naively asking for four would have under-spent every time, and at the
# strengths this profile actually uses it would have run nothing at all.
assert int(4 * strength) < 4
assert int(4 * 0.15) == 0
def test_sdxl_zimage_floors_to_its_own_latent_grid():
"""SDXL aligns to 8 pixels where Qwen aligns to 16."""
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _target_size
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import sdxl_target_size
assert sdxl_target_size(1122, 1402) == (1120, 1400)
assert _target_size(1122, 1402) == (1120, 1392)
assert sdxl_target_size(3, 3) == (8, 8)
def test_sdxl_zimage_inherits_the_face_stage_rather_than_copying_it():
"""The face stage must not be able to diverge between the two profiles."""
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
assert issubclass(SdxlZImagePipeline, QwenZImagePipeline)
for shared in ("_run_faces", "_sam_masks", "_load_zimage", "_load_sam", "run"):
assert getattr(SdxlZImagePipeline, shared) is getattr(QwenZImagePipeline, shared)
# Only the global stage and what it needs may differ.
assert SdxlZImagePipeline._run_global is not QwenZImagePipeline._run_global