unsloth/studio/backend/tests/test_diffusion_controlnet.py
2026-07-05 11:40:35 +00:00

354 lines
14 KiB
Python

"""Tests for diffusion ControlNet support: discovery/resolve/preprocess/gate helpers, the
request-model validation, the family wiring, and the diffusers ControlNet pipe manager."""
from __future__ import annotations
import sys
import types
import pytest
from core.inference import diffusion_controlnet as dc
# ── Pure helpers ────────────────────────────────────────────────────────────
def test_sanitize_id():
assert dc.sanitize_id("owner/My ControlNet") == "My_ControlNet"
assert dc.sanitize_id("weird:<>name") == "weird_name"
assert dc.sanitize_id("") == "controlnet"
def test_list_controlnets_family_filter():
flux = {e.id for e in dc.list_controlnets(family = "flux.1")}
qwen = {e.id for e in dc.list_controlnets(family = "qwen-image")}
assert "flux-union-pro" in flux and "qwen-union" not in flux
assert "qwen-union" in qwen and "flux-union-pro" not in qwen
def test_resolve_controlnet_catalog_bare_repo_and_unknown():
r = dc.resolve_controlnet("flux-union-pro", family = "flux.1")
assert r.path == "Shakker-Labs/FLUX.1-dev-ControlNet-Union-Pro" and not r.is_local
# A bare public repo id passes through.
r2 = dc.resolve_controlnet("owner/some-controlnet")
assert r2.path == "owner/some-controlnet" and not r2.is_local
with pytest.raises(FileNotFoundError):
dc.resolve_controlnet("not-a-known-id")
def test_resolve_controlnet_rejects_filesystem_like_ids():
# The bare-repo fallback must never accept a path-shaped id: from_pretrained
# would treat it as a local directory, bypassing the controlnets_dir() contract.
for bad in ("/tmp/model", "../some/model", "./x/y", "~/x/y", "a/b/c", "C:\\x/y", ".hidden/x"):
with pytest.raises(FileNotFoundError):
dc.resolve_controlnet(bad)
def test_resolve_controlnet_enforces_family_match():
# A curated entry tagged for another family must be rejected before download so it
# never reaches the wrong ControlNet pipeline class.
with pytest.raises(ValueError, match = "not the"):
dc.resolve_controlnet("qwen-union", family = "flux.1")
# The matching family resolves fine, and no family (unfiltered) is permissive.
assert dc.resolve_controlnet("qwen-union", family = "qwen-image").path
assert dc.resolve_controlnet("qwen-union").path
def test_union_control_mode_maps_only_union_entries():
# Union entries map a known control type to its integer mode; passthrough / unknown
# types and non-union ids return None so the caller omits control_mode.
assert dc.union_control_mode("flux-union-pro", "canny") == 0
assert dc.union_control_mode("flux-union-pro", "depth") == 2
assert dc.union_control_mode("flux-union-pro", "pose") == 4
assert dc.union_control_mode("flux-union-pro", "passthrough") is None
assert dc.union_control_mode("some/bare-repo", "canny") is None
def test_resolve_controlnet_local(tmp_path, monkeypatch):
d = tmp_path / "controlnets"
d.mkdir()
cn = d / "my-cn"
cn.mkdir()
(cn / "config.json").write_text("{}")
(cn / "diffusion_pytorch_model.safetensors").write_bytes(b"x") # a loadable weight
monkeypatch.setattr(dc, "controlnets_dir", lambda: d)
entries = {e.id for e in dc.list_controlnets()}
assert "my-cn" in entries
r = dc.resolve_controlnet("my-cn")
assert r.is_local and r.path == str(cn)
def test_scan_local_skips_config_only_folder(tmp_path, monkeypatch):
# A folder with config.json but no weight/index (interrupted copy) must NOT be
# advertised: it would otherwise fail deep in from_pretrained as a generic 500.
d = tmp_path / "controlnets"
d.mkdir()
incomplete = d / "incomplete-cn"
incomplete.mkdir()
(incomplete / "config.json").write_text("{}")
monkeypatch.setattr(dc, "controlnets_dir", lambda: d)
assert "incomplete-cn" not in {e.id for e in dc.list_controlnets()}
# A sharded weight index counts as a loadable weight.
(incomplete / "diffusion_pytorch_model.safetensors.index.json").write_text("{}")
assert "incomplete-cn" in {e.id for e in dc.list_controlnets()}
def test_preprocess_control_passthrough_and_canny():
from PIL import Image
img = Image.new("RGB", (32, 24), (10, 20, 30))
# passthrough returns the same object.
assert dc.preprocess_control(img, "passthrough") is img
# a flat image has no edges -> canny falls back to passthrough (no black map).
assert dc.preprocess_control(img, "canny") is img
# an image with structure yields an edge map: RGB, same size, some white pixels.
import numpy as np
arr = np.zeros((24, 32, 3), np.uint8)
arr[:, 16:, :] = 255 # a hard vertical edge
edged = dc.preprocess_control(Image.fromarray(arr), "canny")
assert edged.mode == "RGB" and edged.size == (32, 24)
assert np.asarray(edged).max() == 255 # traced the edge
def test_supports_controlnet_matrix():
ok = dict(engine = "diffusers", family = "flux.1", has_controlnet_pipeline = True)
assert dc.supports_controlnet(**ok, model_kind = "pipeline", transformer_quant = None)
assert dc.supports_controlnet(**ok, model_kind = "single_file", transformer_quant = None)
# GGUF-via-diffusers and fp8/int8 dense are gated off, like LoRA.
assert not dc.supports_controlnet(**ok, model_kind = "gguf", transformer_quant = None)
assert not dc.supports_controlnet(**ok, model_kind = "single_file", transformer_quant = "fp8")
assert not dc.supports_controlnet(**ok, model_kind = "single_file", transformer_quant = "int8")
# native engine + a family without a CN pipeline are off.
assert not dc.supports_controlnet(
engine = "sd_cpp",
family = "flux.1",
has_controlnet_pipeline = True,
model_kind = "gguf",
transformer_quant = None,
)
assert not dc.supports_controlnet(
engine = "diffusers",
family = "z-image",
has_controlnet_pipeline = False,
model_kind = "pipeline",
transformer_quant = None,
)
# ── Request-model validation ────────────────────────────────────────────────
def test_controlnet_spec_and_request_validation():
from models.inference import ControlNetSpec, DiffusionGenerateRequest
assert DiffusionGenerateRequest(prompt = "x").controlnet is None
req = DiffusionGenerateRequest(
prompt = "x",
controlnet = {
"id": "flux-union-pro",
"image": "data",
"control_type": "canny",
"strength": 0.6,
},
)
assert req.controlnet.id == "flux-union-pro" and req.controlnet.strength == 0.6
# defaults
s = ControlNetSpec(id = "a", image = "b")
assert s.control_type == "passthrough" and s.strength == 1.0
assert s.guidance_start == 0.0 and s.guidance_end == 1.0
# bounds
with pytest.raises(Exception):
ControlNetSpec(id = "a", image = "b", strength = 3.0)
with pytest.raises(Exception):
ControlNetSpec(id = "a", image = "b", guidance_end = 1.5)
# ── Family wiring ───────────────────────────────────────────────────────────
def test_families_declare_controlnet_classes():
from core.inference.diffusion_families import _FAMILIES
by_name = {f.name: f for f in _FAMILIES}
assert by_name["flux.1"].controlnet_pipeline_class == "FluxControlNetPipeline"
assert by_name["flux.1"].controlnet_model_class == "FluxControlNetModel"
assert by_name["qwen-image"].controlnet_pipeline_class == "QwenImageControlNetPipeline"
# z-image has no diffusers ControlNet pipeline -> gated off.
assert by_name["z-image"].controlnet_pipeline_class is None
# ── Diffusers ControlNet pipe manager ───────────────────────────────────────
class _FakeCNModel:
@classmethod
def from_pretrained(
cls,
path,
torch_dtype = None,
token = None,
):
m = cls()
m.path = path
return m
def to(self, device):
self.device = device
return self
class _FakeCNPipe:
@classmethod
def from_pipe(
cls,
base,
controlnet = None,
torch_dtype = None,
):
p = cls()
p.base = base
p.controlnet = controlnet
return p
def _fake_diffusers():
mod = types.ModuleType("diffusers")
mod.FluxControlNetModel = _FakeCNModel
mod.FluxControlNetPipeline = _FakeCNPipe
return mod
def _state():
fam = types.SimpleNamespace(
name = "flux.1",
controlnet_pipeline_class = "FluxControlNetPipeline",
controlnet_model_class = "FluxControlNetModel",
)
return types.SimpleNamespace(
family = fam, dtype = "bf16", device = "cpu", hf_token = None, pipe = object()
)
def _allow_cn_security(monkeypatch):
"""Stub the Hub malware preflight to allow the load (hermetic, no network)."""
import utils.security
monkeypatch.setattr(
utils.security,
"evaluate_file_security",
lambda name, hf_token = None, **kw: types.SimpleNamespace(blocked = False, reason = ""),
)
def test_controlnet_pipe_loads_once_and_caches(monkeypatch):
import threading
from core.inference.diffusion import DiffusionBackend
monkeypatch.setitem(sys.modules, "diffusers", _fake_diffusers())
_allow_cn_security(monkeypatch)
b = DiffusionBackend()
st = _state()
# The pipe cache only commits while ``st`` is the CURRENT load (an unload racing
# from_pipe must not repopulate the cache), so mirror the loaded invariant.
b._state = st
resolved = dc.ResolvedControlNet("flux-union-pro", "repo/id", is_local = False)
p1 = b._controlnet_pipe(st, resolved, threading.Event())
assert isinstance(p1, _FakeCNPipe) and isinstance(p1.controlnet, _FakeCNModel)
assert p1.controlnet.path == "repo/id" and p1.controlnet.device == "cpu"
# cached: same id -> same model + same pipe, no reload.
p2 = b._controlnet_pipe(st, resolved, threading.Event())
assert p2 is p1
assert b._cn_models["flux-union-pro"] is p1.controlnet
def test_controlnet_pipe_blocks_flagged_remote_repo(monkeypatch):
# A bare owner/name ControlNet is accepted by resolve_controlnet without the base
# trust gate, so the load path must run the Hub malware preflight: a flagged remote
# repo must raise BEFORE from_pretrained downloads/deserializes it.
import threading
import utils.security
from core.inference.diffusion import DiffusionBackend
loaded = {"called": False}
class _TrapModel(_FakeCNModel):
@classmethod
def from_pretrained(
cls,
path,
torch_dtype = None,
token = None,
):
loaded["called"] = True
return super().from_pretrained(path, torch_dtype = torch_dtype, token = token)
mod = _fake_diffusers()
mod.FluxControlNetModel = _TrapModel
monkeypatch.setitem(sys.modules, "diffusers", mod)
monkeypatch.setattr(
utils.security,
"evaluate_file_security",
lambda name, hf_token = None, **kw: types.SimpleNamespace(
blocked = True, reason = "Hugging Face security scan flagged unsafe files: evil.bin"
),
)
b = DiffusionBackend()
st = _state()
b._state = st
resolved = dc.ResolvedControlNet("evil/cn", "evil/cn", is_local = False)
with pytest.raises(ValueError, match = "security scan flagged"):
b._controlnet_pipe(st, resolved, threading.Event())
assert loaded["called"] is False
def test_controlnet_pipe_skips_scan_for_local_dir(monkeypatch, tmp_path):
# A local dir the user picked has no Hub scan; the preflight must not block it even
# if the (unused) scan stub would say blocked.
import threading
import utils.security
from core.inference.diffusion import DiffusionBackend
monkeypatch.setitem(sys.modules, "diffusers", _fake_diffusers())
monkeypatch.setattr(
utils.security,
"evaluate_file_security",
lambda name, hf_token = None, **kw: types.SimpleNamespace(blocked = True, reason = "x"),
)
b = DiffusionBackend()
st = _state()
b._state = st
resolved = dc.ResolvedControlNet("my-cn", str(tmp_path), is_local = True)
p = b._controlnet_pipe(st, resolved, threading.Event())
assert isinstance(p, _FakeCNPipe)
def test_controlnet_pipe_rejects_family_without_classes():
import threading
from core.inference.diffusion import DiffusionBackend
b = DiffusionBackend()
st = _state()
st.family.controlnet_pipeline_class = None
with pytest.raises(ValueError, match = "not supported"):
b._controlnet_pipe(st, dc.ResolvedControlNet("x", "y", False), threading.Event())
def test_controlnet_pipe_not_cached_after_unload_race(monkeypatch):
# An unload that lands while from_pipe is assembling must not let the wrapper
# repopulate the cache around the torn-down base pipe.
import threading
from core.inference.diffusion import DiffusionBackend
monkeypatch.setitem(sys.modules, "diffusers", _fake_diffusers())
b = DiffusionBackend()
st = _state() # never committed to b._state: the load is already gone
resolved = dc.ResolvedControlNet("flux-union-pro", "repo/id", is_local = False)
with pytest.raises(RuntimeError, match = "cancelled"):
b._controlnet_pipe(st, resolved, threading.Event())
assert b._cn_pipes == {}