ControlNet: address review findings on the diffusers path

- resolve_controlnet enforces catalog family compatibility so a direct API call
  cannot load a ControlNet built for another family through the wrong pipeline.
- Unknown ControlNet ids now surface as a 400 (call site maps FileNotFoundError
  to ValueError) instead of a generic 500.
- strength 0 disables ControlNet entirely, so a no-op selection never pays the
  download / VRAM cost; the control image is decoded and validated BEFORE the
  ControlNet is resolved or built, so a malformed image fails fast for the same reason.
- ControlNet loads use the base compute dtype (state.dtype is a display string,
  not a torch.dtype, so it silently fell back to float32) and honor the base
  offload policy via group offloading instead of forcing the module resident.
- Empty/malformed HF token coerced to anonymous access.
- Flux Union ControlNet control_mode mapped from the selected control type.
- resolve_controlnet drops the unused hf_token/cancel_event params.
- ControlNetSpec validates guidance_start <= guidance_end (clean 422).
- Images UI ControlNet Select shows its placeholder when nothing is selected.

Adds regression tests for family enforcement and the union control-mode map.
This commit is contained in:
Daniel Han 2026-07-02 01:08:02 +00:00
commit cdfc6f0f56
5 changed files with 173 additions and 42 deletions

View file

@ -1216,11 +1216,30 @@ class DiffusionBackend:
if cn_model is None:
if cancel.is_set():
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
cn_model = (
getattr(diffusers, model_cls_name)
.from_pretrained(resolved_cn.path, torch_dtype = state.dtype, token = state.hf_token)
.to(state.device)
import torch
# state.dtype is the display string saved at load ("bfloat16"), NOT a
# torch.dtype; pass the real dtype so diffusers loads the ControlNet at the
# base compute dtype instead of silently defaulting to float32 (extra VRAM).
cn_dtype = getattr(torch, str(state.dtype).replace("torch.", ""), None)
cn_model = getattr(diffusers, model_cls_name).from_pretrained(
resolved_cn.path,
torch_dtype = cn_dtype,
# An empty / malformed token means anonymous access; the HF client can
# raise on a blank credential instead of falling back, so coerce to None.
token = state.hf_token or None,
)
# Placement must follow the base model's offload policy. A resident base moves
# the ControlNet resident too; an offloaded (low-VRAM) base streams it through
# the device with group offloading instead of forcing the whole module onto the
# GPU, which would defeat the offload and risk an OOM. Best-effort: any failure
# falls back to the resident placement (the prior behaviour).
if getattr(state, "offload_policy", OFFLOAD_NONE) != OFFLOAD_NONE and (
_offload_controlnet_module(cn_model, state.device, logger)
):
pass
else:
cn_model = cn_model.to(state.device)
if cancel.is_set():
# An unload raced the blocking download above and already cleared the
# ControlNet caches; caching now would pin the module past the unload.
@ -1401,7 +1420,7 @@ class DiffusionBackend:
pipe = state.pipe
init_pil = mask_pil = None
control_pil = None
cn_scale = cn_gstart = cn_gend = None
cn_scale = cn_gstart = cn_gend = cn_mode = None
ref_extra: list = []
if getattr(state.family, "edit", False):
# Instruction editing: the loaded pipe is the edit pipeline. It always
@ -1472,40 +1491,55 @@ class DiffusionBackend:
if controlnet is not None:
from core.inference import diffusion_controlnet
if workflow != "txt2img":
raise ValueError(
"ControlNet currently combines with plain text-to-image only, not the "
f"{workflow} workflow."
)
if not diffusion_controlnet.supports_controlnet(
engine = "diffusers",
family = state.family.name,
has_controlnet_pipeline = bool(
getattr(state.family, "controlnet_pipeline_class", None)
),
model_kind = state.kind,
transformer_quant = state.transformer_quant,
):
raise ValueError(
"ControlNet is not supported for this model/quantisation on the "
"diffusers engine (needs a bf16 or bnb-4bit load of a family with a "
"ControlNet pipeline; not GGUF-via-diffusers or torchao fp8/int8)."
)
cn_id, cn_image_b64, cn_type, cn_strength, cn_gs, cn_ge = controlnet
resolved_cn = diffusion_controlnet.resolve_controlnet(
cn_id,
family = state.family.name,
hf_token = state.hf_token,
cancel_event = cancel,
)
pipe = self._controlnet_pipe(state, resolved_cn, cancel)
workflow = "controlnet"
src = _decode_b64_image(cn_image_b64, mode = "RGB")
# Control map at the OUTPUT size so it aligns with the generated latents.
control_pil = diffusion_controlnet.preprocess_control(src, cn_type).resize(
(width, height), Image.LANCZOS
)
cn_scale, cn_gstart, cn_gend = cn_strength, cn_gs, cn_ge
# strength 0 disables ControlNet (documented on the request model, and the
# frontend slider allows it): skip the whole path so a no-op selection never
# pays the multi-GB ControlNet download / VRAM cost.
if cn_strength in (None, 0, 0.0):
controlnet = None
else:
if workflow != "txt2img":
raise ValueError(
"ControlNet currently combines with plain text-to-image only, not "
f"the {workflow} workflow."
)
if not diffusion_controlnet.supports_controlnet(
engine = "diffusers",
family = state.family.name,
has_controlnet_pipeline = bool(
getattr(state.family, "controlnet_pipeline_class", None)
),
model_kind = state.kind,
transformer_quant = state.transformer_quant,
):
raise ValueError(
"ControlNet is not supported for this model/quantisation on the "
"diffusers engine (needs a bf16 or bnb-4bit load of a family with a "
"ControlNet pipeline; not GGUF-via-diffusers or torchao fp8/int8)."
)
# Decode + preprocess the control image FIRST so a malformed / unsupported
# image fails as a clean 400 BEFORE any ControlNet download or pipe build,
# rather than after paying that cost. Control map at the OUTPUT size so it
# aligns with the generated latents.
src = _decode_b64_image(cn_image_b64, mode = "RGB")
control_pil = diffusion_controlnet.preprocess_control(src, cn_type).resize(
(width, height), Image.LANCZOS
)
try:
resolved_cn = diffusion_controlnet.resolve_controlnet(
cn_id, family = state.family.name
)
except FileNotFoundError as exc:
# An unknown / missing ControlNet id is a bad selection -> 400, not a
# generic 500 (the route maps ValueError, not FileNotFoundError).
raise ValueError(str(exc)) from exc
pipe = self._controlnet_pipe(state, resolved_cn, cancel)
workflow = "controlnet"
cn_scale, cn_gstart, cn_gend = cn_strength, cn_gs, cn_ge
# Flux Union ControlNet selects the active mode by an integer
# ``control_mode`` (canny/depth/pose/...); map the chosen control type so
# the union model applies the right head instead of a default/wrong one.
cn_mode = diffusion_controlnet.union_control_mode(cn_id, cn_type)
# Auto-resize odd-sized inputs to a multiple of 16 for the workflows whose
# OUTPUT size is taken from the input image (img2img / inpaint / extend / edit),
# so an upload like 186px tall no longer fails the pipeline's divisibility check.
@ -1580,6 +1614,10 @@ class DiffusionBackend:
kwargs["control_guidance_start"] = cn_gstart
if "control_guidance_end" in call_params and cn_gend is not None:
kwargs["control_guidance_end"] = cn_gend
# Union ControlNet mode index (Flux); only when the pipe accepts it and the
# selected control type maps to a known mode.
if "control_mode" in call_params and cn_mode is not None:
kwargs["control_mode"] = cn_mode
gen = _GenState(total_steps = steps)
@ -1821,6 +1859,34 @@ def _hf_base_model(repo_id: str, hf_token: Optional[str]) -> Optional[str]:
return base if isinstance(base, str) and base.strip() else None
def _offload_controlnet_module(cn_model: Any, device: str, logger: Any) -> bool:
"""Stream a ControlNet module through ``device`` via diffusers group offloading.
Used when the base model was loaded with an offload policy: forcing the ControlNet
fully resident with ``.to(device)`` would defeat that low-VRAM placement and can OOM.
Group offloading is applied to this single module (it does not touch the base pipe's
existing hooks), so it is isolated and reversible. Returns True on success; on any
failure the caller falls back to a resident placement, so this never blocks a load."""
try:
import torch
from diffusers.hooks import apply_group_offloading
onload = torch.device(device)
apply_group_offloading(
cn_model,
onload_device = onload,
offload_device = torch.device("cpu"),
offload_type = "block_level",
num_blocks_per_group = 1,
use_stream = onload.type == "cuda",
)
return True
except Exception as exc: # noqa: BLE001 — offload is best-effort; resident is the fallback
if logger is not None:
logger.warning("diffusion.controlnet: group offload failed (%s); loading resident", exc)
return False
def _base_file_downloaded(rfilename: str) -> bool:
"""True for base-repo files ``from_pretrained`` actually fetches.

View file

@ -19,7 +19,6 @@ backend read an arbitrary location.
from __future__ import annotations
import re
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@ -142,17 +141,28 @@ def resolve_controlnet(
spec_id: str,
*,
family: Optional[str] = None,
hf_token: Optional[str] = None,
cancel_event: Optional[threading.Event] = None,
) -> ResolvedControlNet:
"""Resolve a ControlNet id to a loadable repo id / local dir.
Accepts a catalog/local id, or a bare public HF repo id (``owner/name``). The backend
loads the result with ``ControlNetModelClass.from_pretrained(path)`` (download + cache
handled there, like the base pipeline). Raises on an unknown id -> the caller maps to 400.
``family`` (the loaded base family) enforces catalog compatibility: a ControlNet is
architecture-specific, so a catalog entry tagged for another family is rejected here
with a clear error rather than being loaded through the wrong pipeline class later.
"""
entry = _catalog_by_id().get(spec_id)
if entry is not None:
# A curated/local entry may declare the families it is built for. A client that
# bypasses the UI filter (direct API call) could send an entry for another family;
# reject it before any download so it never reaches the wrong ControlNet pipeline.
fam = (family or "").strip().lower()
if entry.families and fam and fam not in {f.lower() for f in entry.families}:
raise ValueError(
f"ControlNet '{spec_id}' is for {', '.join(entry.families)}, not the loaded "
f"'{family}' model; pick a ControlNet built for this family."
)
if entry.source == "local":
path = entry.local_path or ""
if not path or not Path(path).is_dir():
@ -174,6 +184,33 @@ def resolve_controlnet(
)
# Union ControlNet mode indices. A single "union" model covers several control modes and
# selects the active one via an integer ``control_mode`` argument; these are the standard
# indices used by the FLUX.1 / Qwen-Image union ControlNets. "passthrough" (an already-made
# map) carries no intrinsic mode, so it maps to nothing (the caller omits control_mode).
_UNION_CONTROL_MODES: dict[str, int] = {
"canny": 0,
"tile": 1,
"depth": 2,
"blur": 3,
"pose": 4,
"gray": 5,
"lq": 6,
}
def union_control_mode(spec_id: str, control_type: str) -> Optional[int]:
"""The integer ``control_mode`` for a union ControlNet, or None.
Returns a mode only for a curated *union* catalog entry AND a control type that maps to a
known index; otherwise None so the caller omits the kwarg (a non-union ControlNet has a
single fixed mode, and 'passthrough' does not name one). Pure lookup, no network."""
entry = _catalog_by_id().get(spec_id)
if entry is None or not entry.is_union:
return None
return _UNION_CONTROL_MODES.get((control_type or "").strip().lower())
def preprocess_control(image: Any, control_type: str) -> Any:
"""Turn a source image into a control map.

View file

@ -1856,6 +1856,14 @@ class ControlNetSpec(BaseModel):
1.0, ge = 0.0, le = 1.0, description = "Fraction of steps at which ControlNet ends"
)
@model_validator(mode = "after")
def _check_guidance_range(self) -> "ControlNetSpec":
# An inverted range (start > end) means "act over no steps"; reject it as a clean
# 422 instead of letting the diffusers pipeline raise a 500 deep in the denoise.
if self.guidance_start > self.guidance_end:
raise ValueError("guidance_start must be <= guidance_end")
return self
class DiffusionGenerateRequest(BaseModel):
"""Request to generate one image from the loaded diffusion model."""

View file

@ -45,6 +45,26 @@ def test_resolve_controlnet_rejects_filesystem_like_ids():
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()

View file

@ -2095,7 +2095,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
hint="Condition the image on a control map (edges / depth / pose). Union models cover many types. Use 'Canny' to trace edges from your image, or 'Passthrough' if it is already a control map."
>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-2">
<Select value={controlnetId} onValueChange={setControlnetId}>
<Select value={controlnetId || undefined} onValueChange={setControlnetId}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Select a ControlNet" />
</SelectTrigger>