14 KiB
Qwen Image ControlNet Support and getattr Recursion Error Fix — Detailed Explanation
This document fully explains the cause and solution (with code) for the following two fixes applied to ComfyUI-QwenImageLoraLoader’s ComfyQwenImageWrapper:
- AttributeError:
'ComfyQwenImageWrapper' object has no attribute 'process_img'
→ Error when using Qwen Image Union ControlNet (e.g. Fun ControlNet) together with Nunchaku Qwen Image model - RecursionError:
maximum recursion depth exceeded
→ Infinite recursion when accessingmodel_wrapper.modelinNunchakuQwenImageLoraStackV3and similar code
Files Modified
- ComfyUI-QwenImageLoraLoader/wrappers/qwenimage.py
- Class:
ComfyQwenImageWrapper(inherits fromnn.Module)
- Class:
Issue 1: Missing base_model Interface Required by ControlNet
Symptom
- Error:
AttributeError: 'ComfyQwenImageWrapper' object has no attribute 'process_img' - Location: Inside
QwenImageFunControlNetModel.forwardin ComfyUI’scomfy/ldm/qwen_image/controlnet.py - Condition: When running KSampler in a workflow that loads a Nunchaku Qwen Image model and applies Qwen Image Union ControlNet (Fun ControlNet)
Call Path (Summary)
KSampler
→ common_ksampler
→ comfy.sample.sample
→ … (TiledDiffusion / EasyCache etc. wrappers)
→ samplers: calc_cond_batch
→ controlnet.get_control
→ control_model(x=..., base_model=model, ...)
→ QwenImageFunControlNetModel.forward(..., base_model=...)
→ base_model.process_img(x) ← AttributeError when model is ComfyQwenImageWrapper
The model passed to the sampler is already wrapped as ComfyQwenImageWrapper by ComfyQwenImageLoader etc. when using Nunchaku. ControlNet receives this object as base_model and calls e.g. base_model.process_img(x).
What ComfyUI’s ControlNet Expects from base_model
Inside forward in comfy/ldm/qwen_image/controlnet.py, the following are required on base_model:
| Requirement | Usage (example) |
|---|---|
base_model.process_img(x) |
Obtain hidden_states / img_ids from latent x |
base_model.patch_size |
Text position computation |
base_model.pe_embedder(ids) |
Position encoding |
base_model.img_in(hidden_states) |
Linear projection of image tokens |
base_model.txt_norm(context) / base_model.txt_in(...) |
Text-side normalization and projection |
base_model.time_text_embed(timesteps, ...) |
Timestep embedding |
ComfyUI’s QwenImageTransformer2DModel (and Nunchaku’s NunchakuQwenImageTransformer2DModel) have all of these. The wrapper ComfyQwenImageWrapper did not expose these attributes other than forward, so when base_model was the wrapper, an AttributeError occurred.
Root Cause Summary
- Cause:
ComfyQwenImageWrapperwas focused only on delegating the inferenceforward, and did not provide the base_model interface (process_imgand the attributes above) that ControlNet assumes. - Responsibility: ComfyUI’s ControlNet assumes that the passed
base_modelis Qwen Image–like; the wrapper must satisfy the same interface (this fix is on the wrapper side).
Fix for Issue 1
Fix 1-A: Add process_img Method
Goal: Allow ControlNet to call base_model.process_img(x).
Added code (wrappers/qwenimage.py):
def process_img(self, x, index=0, h_offset=0, w_offset=0):
"""
Delegate to the inner model so Qwen Image ControlNet (e.g. Fun ControlNet)
can call base_model.process_img(x). Required when using Union CN with
Nunchaku Qwen Image model wrapped by this wrapper.
"""
if self.model is None:
raise RuntimeError("Model has been unloaded. Cannot call process_img.")
return self.model.process_img(x, index=index, h_offset=h_offset, w_offset=w_offset)
- Signature and return value match ComfyUI’s
QwenImageTransformer2DModel.process_img(e.g. 3-tuple(hidden_states, img_ids, orig_shape)); the call is delegated to the innerself.model(Nunchaku model). - Here
self.modelis accessed via normal attribute access (not through__getattr__) to avoid the recursion described in Issue 2. Becauseprocess_imgis defined as a method onComfyQwenImageWrapper,__getattr__is not used for this call, andself.modelis resolved viann.Module’s_modules["model"].
Fix 1-B: Forward Other base_model Attributes via __getattr__
Goal: When the wrapper does not have attributes such as patch_size, pe_embedder, img_in, txt_norm, txt_in, time_text_embed that ControlNet uses, forward them to the inner model.
Added code (final form including the recursion fix for Issue 2; see below):
- A
__getattr__was added that, when an attribute name not present on the wrapper is accessed, returns the attribute of the same name from the innerself.model. - This allows ControlNet to use the above attributes transparently even when
base_modelisComfyQwenImageWrapper.
Issue 2: Infinite Recursion from self.model Access Inside getattr
Symptom
- Error:
RecursionError: maximum recursion depth exceeded - Location: In
load_lora_stackinComfyUI-QwenImageLoraLoader/nodes/lora/qwenimage_v3.py, attransformer = model_wrapper.model - Condition: When
NunchakuQwenImageLoraStackV3(or similar) receives amodel_wrapperalready wrapped withComfyQwenImageWrapperand accessesmodel_wrapper.modelto obtain the inner transformer
Stack Trace (Excerpt)
File "...\qwenimage_v3.py", line 255, in load_lora_stack
transformer = model_wrapper.model
File "...\wrappers\qwenimage.py", line 85, in __getattr__
if self.model is None:
[Previous line repeated 1493 more times]
RecursionError: maximum recursion depth exceeded
So: “get model → __getattr__ is called → it references self.model → __getattr__ is called again” — a loop.
Root Cause: How nn.Module Stores Attributes and getattr
ComfyQwenImageWrappersubclassestorch.nn.Module.- In
nn.Module, child modules assigned with e.g.self.model = modelare stored in_modules["model"], not in__dict__["model"](perModule.__setattr__). - With normal attribute access,
Module.__getattribute__consults_modules, soobj.modelis resolved without recursion. __getattr__(self, name)is only invoked when the normal__getattribute__did not find the attribute. Depending on the environment and Python’s resolution order, access tomodelcan be routed to__getattr__.- If inside
__getattr__we then referenceself.model, attribute resolution runs again for “modelonself”, and the same__getattr__is invoked again. When the name is"model", touchingself.modelto return the inner model becomes the source of recursion.
Summary:
- Cause: Referencing
self.modelinside__getattr__causes infinite recursion when__getattr__is called for the name"model", because that reference triggers__getattr__again. - Responsibility: Only this wrapper implementation. Not a bug in ComfyUI or Nunchaku.
Fix for Issue 2: Do Not Reference self.model Inside getattr
Approach: Never use the attribute access self.model inside __getattr__. Obtain the inner model from object.__getattribute__(self, "_modules").
- Because
nn.Modulekeeps child modules inself._modules, we can get_moduleswithout going through__getattr__viaobject.__getattribute__(self, "_modules"). - The
"model"entry in that_modulesis the inner Nunchaku model held by the wrapper. - Callers using
model_wrapper.modelexpect the “inner transformer”; whenname == "model", we return that inner model.
Full __getattr__ after the fix (wrappers/qwenimage.py):
def __getattr__(self, name):
"""
Forward attribute access to the inner model so Qwen Image ControlNet
(e.g. Fun ControlNet) can access patch_size, pe_embedder, img_in,
txt_norm, txt_in, time_text_embed, etc. when base_model is this wrapper.
Use _modules to get the inner model to avoid recursion: inside this
__getattr__, accessing self.model would trigger __getattr__ again.
"""
try:
inner = object.__getattribute__(self, "_modules").get("model")
except (AttributeError, KeyError):
inner = None
if inner is None:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
if name == "model":
return inner
return getattr(inner, name)
Points
object.__getattribute__(self, "_modules")- Retrieves
_moduleswithout going through this class’s ornn.Module’s__getattr__.
- Retrieves
.get("model")- Gets the inner model registered as a child module;
Noneif missing.
- Gets the inner model registered as a child module;
- When
name == "model", returninner- Matches the node’s expectation that
model_wrapper.modelreturns the inner transformer.
- Matches the node’s expectation that
- For any other name
- ControlNet attributes like
patch_size,pe_embedder,img_inare all forwarded to the inner model viagetattr(inner, name).
- ControlNet attributes like
This satisfies both requirements:
model_wrapper.model→ returns the inner model without recursionbase_model.patch_sizeetc. → forwarded to the inner model so ControlNet’s expected interface is provided
Order of Fixes and Dependencies
- For Issue 1:
- Add
process_img - Add
__getattr__for ControlNet attribute forwarding (initially implemented with aself.modelreference)
- Add
- Issue 2 appears:
- That
self.modelreference inside__getattr__caused recursion when accessingmodel_wrapper.model.
- That
- For Issue 2:
- Stop using
self.modelinside__getattr__and obtain the inner model viaobject.__getattribute__(self, "_modules").get("model").
- Stop using
Result: Both Issue 1 (ControlNet) and Issue 2 (LoRA stack model access) are resolved.
Summary of Modified Code (Relevant Parts)
Excerpt of what was added/changed on ComfyQwenImageWrapper:
# --- Fix 1-A: Add process_img ---
def process_img(self, x, index=0, h_offset=0, w_offset=0):
"""
Delegate to the inner model so Qwen Image ControlNet (e.g. Fun ControlNet)
can call base_model.process_img(x). Required when using Union CN with
Nunchaku Qwen Image model wrapped by this wrapper.
"""
if self.model is None:
raise RuntimeError("Model has been unloaded. Cannot call process_img.")
return self.model.process_img(x, index=index, h_offset=h_offset, w_offset=w_offset)
# --- Fix 1-B & Issue 2: __getattr__ (recursion-safe) ---
def __getattr__(self, name):
"""
Forward attribute access to the inner model so Qwen Image ControlNet
(e.g. Fun ControlNet) can access patch_size, pe_embedder, img_in,
txt_norm, txt_in, time_text_embed, etc. when base_model is this wrapper.
Use _modules to get the inner model to avoid recursion: inside this
__getattr__, accessing self.model would trigger __getattr__ again.
"""
try:
inner = object.__getattribute__(self, "_modules").get("model")
except (AttributeError, KeyError):
inner = None
if inner is None:
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
if name == "model":
return inner
return getattr(inner, name)
- The
self.modelinprocess_imgdoes not go through__getattr__for that call (becauseprocess_imgis called as a method on the wrapper), so it does not recurse. - All attribute access (
model_wrapper.model,base_model.patch_size, etc.) goes through the above__getattr__, which only uses theinnertaken from_modules["model"], so recursion does not occur.
Technical Notes
ControlNet Usage in ComfyUI (Reference)
The aim of this change is to make QwenImageFunControlNetModel.forward in comfy/ldm/qwen_image/controlnet.py work transparently when base_model is the wrapper.
# Inside forward in controlnet.py (excerpt)
hidden_states, img_ids, _ = base_model.process_img(x)
# ...
txt_start = round(
max(
((x.shape[-1] + (base_model.patch_size // 2)) // base_model.patch_size) // 2,
((x.shape[-2] + (base_model.patch_size // 2)) // base_model.patch_size) // 2,
)
)
# ...
image_rotary_emb = base_model.pe_embedder(ids).to(x.dtype).contiguous()
hidden_states = base_model.img_in(hidden_states)
encoder_hidden_states = base_model.txt_norm(context)
encoder_hidden_states = base_model.txt_in(encoder_hidden_states)
temb = (
base_model.time_text_embed(timesteps, hidden_states)
if guidance is None
else base_model.time_text_embed(timesteps, guidance, hidden_states)
)
Because the wrapper forwards all of the above to the inner model via process_img and __getattr__, this code works unchanged when base_model is ComfyQwenImageWrapper.
Relation to Other Issues
- Issue #25 (ComfyUI 0.4.0 Model Management Errors) is a different NoneType-related problem involving ComfyUI’s
model_management.pyand GC/weak references. It has a different cause and symptoms from the AttributeError (process_img) and RecursionError (getattr) addressed here.
See COMFYUI_0.4.0_MODEL_MANAGEMENT_ERRORS.md for details.
Summary
| Item | Issue 1 (ControlNet) | Issue 2 (RecursionError) |
|---|---|---|
| Symptom | AttributeError: ... has no attribute 'process_img' |
RecursionError: maximum recursion depth exceeded |
| Cause | Wrapper did not provide ControlNet’s base_model interface | Referencing self.model inside __getattr__ caused recursion when getting model |
| Fix | Add process_img + attribute-forwarding __getattr__ |
Inside __getattr__, get inner model from _modules["model"] |
| File | wrappers/qwenimage.py |
Same |
With these two fixes:
- Qwen Image Union ControlNet (e.g. Fun ControlNet) can be used together with Nunchaku Qwen Image model, and
- Code that obtains the inner transformer via
model_wrapper.model(e.g. NunchakuQwenImageLoraStackV3) runs without recursion.