mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-12 09:27:15 +08:00
JoyImageEdit is an image-edit diffusion transformer from JD (jd-opensource),
Apache 2.0. This adds native ComfyUI support so it loads and runs like other
edit models (load checkpoint -> TextEncode + ReferenceLatent -> KSampler ->
VAEDecode), with no diffusers dependency.
Architecture:
- Transformer (comfy/ldm/joyimage/model.py): dual-stream (img/txt) DiT with a
Conv3d patch embed (patch_size [1,2,2]), Wan-style learnable modulation,
and 3D RoPE (rope_dim_list [16,56,56]). All attention goes through
comfy.ldm.modules.attention.optimized_attention.
- Text encoder (comfy/text_encoders/{qwen3_vl,joyimage}.py): a reusable
Qwen3-VL multimodal stack (vision tower + LM) in qwen3_vl.py, plus a thin
JoyImage-specific layer (prompt templates, drop_idx, tokenizer, te() factory)
in joyimage.py that depends on it. text_dim 4096.
- VAE: reuses the existing Wan 2.1 latent format (AutoencoderKLWan), no new
latent format.
- Edit conditioning: reuses the reference_latents mechanism. Reference and
noise latents are stacked on a new n-slot dimension and rotated at the model
boundary (model_base.JoyImage), so the transformer stays 5D-in/5D-out.
Guidance-rescale is built into the CFG path.
Model wiring:
- model_base.JoyImage uses ModelType.FLOW with sampling_settings
multiplier=1000 (the time embedding is trained on t in [0,1000]) and
shift=1.5; FLOW's linear time_snr_shift matches the diffusers
FlowMatchEuler sigma schedule.
- model_detection sniffs the transformer state-dict (double_blocks.*,
condition_embedder.*, 5D img_in Conv3d) to route image_model="joyimage".
- supported_models.JoyImage and the CLIPLoader "joyimage" type register it.
User-facing node TextEncodeJoyImageEdit (comfy_extras/nodes_joyimage.py)
bucket-resizes the input image to the nearest 1024-base bucket, encodes the
prompt with the image, and emits both the conditioning and the bucketed image
so the same pixels feed VAEEncode and the negative encode (JoyImage requires
noise and reference latents to share spatial dims).
912 lines
41 KiB
Python
912 lines
41 KiB
Python
"""Generic Qwen3-VL multimodal stack.
|
|
|
|
Sibling of `comfy.text_encoders.qwen_vl` (which only ships the Qwen2-VL vision
|
|
tower). Qwen3-VL differs from Qwen2-VL in: full attention vision blocks,
|
|
GELU MLP via `linear_fc{1,2}`, LayerNorm (not RMSNorm), learned `pos_embed`,
|
|
and a deepstack-merger contract that additively injects intermediate vision
|
|
features into specific decoder layers at visual-token positions.
|
|
|
|
Public exports:
|
|
- `Qwen3VLConfig` — dataclass for the Qwen3-VL text decoder
|
|
- `Qwen3VLVisionConfig` — dataclass for the Qwen3-VL vision tower
|
|
- `Qwen3VLVisionModel` — vision tower; forward returns
|
|
`(image_features, deepstack_features)`
|
|
- `Qwen3VLDecoder` — forked Llama2-style decoder with per-layer
|
|
deepstack residual injection
|
|
- `Qwen3VLBase` — outer wrapper holding `model.{language_model,
|
|
visual}` plus root `lm_head` to bijectively
|
|
match a `model.*` / `lm_head` checkpoint
|
|
- `process_qwen3vl_image` — preprocess one (1, H, W, C) image in [0,1]
|
|
into (flatten_patches, grid_thw)
|
|
"""
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional, Tuple
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
from comfy.ldm.modules.attention import optimized_attention_for_device
|
|
from comfy.text_encoders.llama import (
|
|
MLP,
|
|
RMSNorm,
|
|
apply_rope,
|
|
precompute_freqs_cis,
|
|
)
|
|
|
|
|
|
# Defaults track the JoyImageEdit checkpoint (text_encoder/config.json) but the
|
|
# class is intended for any Qwen3-VL deployment; override fields as needed.
|
|
@dataclass
|
|
class Qwen3VLConfig:
|
|
vocab_size: int = 151936
|
|
hidden_size: int = 4096
|
|
intermediate_size: int = 12288
|
|
num_hidden_layers: int = 36
|
|
num_attention_heads: int = 32
|
|
num_key_value_heads: int = 8
|
|
max_position_embeddings: int = 262144
|
|
rms_norm_eps: float = 1e-6
|
|
rope_theta: float = 5000000.0
|
|
transformer_type: str = "llama"
|
|
head_dim: int = 128
|
|
rms_norm_add: bool = False
|
|
mlp_activation: str = "silu"
|
|
qkv_bias: bool = False
|
|
rope_dims: Tuple[int, int, int] = (24, 20, 20)
|
|
interleaved_mrope: bool = True
|
|
q_norm: str = "gemma3"
|
|
k_norm: str = "gemma3"
|
|
rope_scale = None
|
|
final_norm: bool = True
|
|
lm_head: bool = True
|
|
stop_tokens: Tuple[int, int] = (151643, 151645)
|
|
# Decoder layer indices that receive deepstack residuals from the vision
|
|
# tower. transformers' `Qwen3VLTextModel` injects merger outputs after
|
|
# decoder layers ``range(len(deepstack_visual_embeds))`` — i.e. after the
|
|
# first 3 layers (0, 1, 2) for the standard 3-merger setup, regardless of
|
|
# the vision-side ``deepstack_visual_indexes=[8, 16, 24]``. The decoder
|
|
# injection layers and the vision tap layers are distinct concepts; they
|
|
# share the count (3) but not the indices.
|
|
deepstack_decoder_inject_layers: Tuple[int, ...] = (0, 1, 2)
|
|
|
|
|
|
@dataclass
|
|
class Qwen3VLVisionConfig:
|
|
hidden_size: int = 1152
|
|
intermediate_size: int = 4304
|
|
out_hidden_size: int = 4096
|
|
num_heads: int = 16
|
|
depth: int = 27
|
|
patch_size: int = 16
|
|
temporal_patch_size: int = 2
|
|
spatial_merge_size: int = 2
|
|
num_position_embeddings: int = 2304
|
|
deepstack_visual_indexes: Tuple[int, ...] = (8, 16, 24)
|
|
image_mean: Tuple[float, float, float] = (0.5, 0.5, 0.5)
|
|
image_std: Tuple[float, float, float] = (0.5, 0.5, 0.5)
|
|
min_pixels: int = 65536
|
|
max_pixels: int = 16777216
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image preprocessing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def process_qwen3vl_image(
|
|
image: torch.Tensor,
|
|
min_pixels: int = 65536,
|
|
max_pixels: int = 16777216,
|
|
patch_size: int = 16,
|
|
temporal_patch_size: int = 2,
|
|
merge_size: int = 2,
|
|
image_mean: Optional[List[float]] = None,
|
|
image_std: Optional[List[float]] = None,
|
|
):
|
|
"""Resize, normalize and patch-flatten a single (B=1, H, W, C) image tensor in [0, 1].
|
|
|
|
Returns ``(flatten_patches, grid_thw)`` ready for `Qwen3VLVisionModel.forward`.
|
|
Mirrors `Qwen2VLImageProcessorFast` (used by the Qwen3VLProcessor): bucket
|
|
size to a multiple of ``patch_size*merge_size``, clamp by min/max pixels,
|
|
bicubic resize, normalize by mean/std, then unfold into temporal*spatial
|
|
patches using a single-frame temporal repeat.
|
|
"""
|
|
if image_mean is None:
|
|
image_mean = [0.5, 0.5, 0.5]
|
|
if image_std is None:
|
|
image_std = [0.5, 0.5, 0.5]
|
|
|
|
if image.dim() == 3:
|
|
image = image.unsqueeze(0)
|
|
batch, height, width, channels = image.shape
|
|
if batch != 1:
|
|
raise ValueError("process_qwen3vl_image expects one image (B=1) at a time.")
|
|
device = image.device
|
|
|
|
image = image.permute(0, 3, 1, 2) # (1, C, H, W)
|
|
img = image[0]
|
|
|
|
factor = patch_size * merge_size
|
|
h_bar = round(height / factor) * factor
|
|
w_bar = round(width / factor) * factor
|
|
if h_bar * w_bar > max_pixels:
|
|
beta = math.sqrt((height * width) / max_pixels)
|
|
h_bar = max(factor, math.floor(height / beta / factor) * factor)
|
|
w_bar = max(factor, math.floor(width / beta / factor) * factor)
|
|
elif h_bar * w_bar < min_pixels:
|
|
beta = math.sqrt(min_pixels / (height * width))
|
|
h_bar = math.ceil(height * beta / factor) * factor
|
|
w_bar = math.ceil(width * beta / factor) * factor
|
|
|
|
img_resized = F.interpolate(
|
|
img.unsqueeze(0), size=(h_bar, w_bar), mode="bicubic", align_corners=False,
|
|
).squeeze(0).clamp(0.0, 1.0)
|
|
|
|
normalized = img_resized.clone()
|
|
for c in range(3):
|
|
normalized[c] = (img_resized[c] - image_mean[c]) / image_std[c]
|
|
|
|
grid_h = h_bar // patch_size
|
|
grid_w = w_bar // patch_size
|
|
grid_thw = torch.tensor([[1, grid_h, grid_w]], device=device, dtype=torch.long)
|
|
|
|
# Single-frame inputs are duplicated along T to fill the 2-frame temporal
|
|
# patch kernel; matches Qwen2VLImageProcessorFast for static images.
|
|
pixel_values = normalized.unsqueeze(0).repeat(temporal_patch_size, 1, 1, 1)
|
|
grid_t = 1
|
|
channel = pixel_values.shape[1]
|
|
patches = pixel_values.reshape(
|
|
grid_t, temporal_patch_size, channel,
|
|
grid_h // merge_size, merge_size, patch_size,
|
|
grid_w // merge_size, merge_size, patch_size,
|
|
)
|
|
patches = patches.permute(0, 3, 6, 4, 7, 2, 1, 5, 8)
|
|
flatten_patches = patches.reshape(
|
|
grid_t * grid_h * grid_w,
|
|
channel * temporal_patch_size * patch_size * patch_size,
|
|
)
|
|
return flatten_patches, grid_thw
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vision tower
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _Qwen3VLVisionPatchEmbed(nn.Module):
|
|
def __init__(self, hidden_size, patch_size, temporal_patch_size, in_channels=3,
|
|
device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.patch_size = patch_size
|
|
self.temporal_patch_size = temporal_patch_size
|
|
self.in_channels = in_channels
|
|
self.embed_dim = hidden_size
|
|
self.proj = ops.Conv3d(
|
|
in_channels, hidden_size,
|
|
kernel_size=[temporal_patch_size, patch_size, patch_size],
|
|
stride=[temporal_patch_size, patch_size, patch_size],
|
|
bias=True, device=device, dtype=dtype,
|
|
)
|
|
|
|
def forward(self, hidden_states):
|
|
hidden_states = hidden_states.view(
|
|
-1, self.in_channels, self.temporal_patch_size, self.patch_size, self.patch_size,
|
|
)
|
|
hidden_states = self.proj(hidden_states)
|
|
return hidden_states.view(-1, self.embed_dim)
|
|
|
|
|
|
class _Qwen3VLVisionMLP(nn.Module):
|
|
def __init__(self, hidden_size, intermediate_size, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.linear_fc1 = ops.Linear(hidden_size, intermediate_size, bias=True, device=device, dtype=dtype)
|
|
self.linear_fc2 = ops.Linear(intermediate_size, hidden_size, bias=True, device=device, dtype=dtype)
|
|
|
|
def forward(self, x):
|
|
return self.linear_fc2(F.gelu(self.linear_fc1(x), approximate="tanh"))
|
|
|
|
|
|
class _Qwen3VLVisionAttention(nn.Module):
|
|
def __init__(self, hidden_size, num_heads, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.num_heads = num_heads
|
|
self.head_dim = hidden_size // num_heads
|
|
self.qkv = ops.Linear(hidden_size, hidden_size * 3, bias=True, device=device, dtype=dtype)
|
|
self.proj = ops.Linear(hidden_size, hidden_size, bias=True, device=device, dtype=dtype)
|
|
|
|
def forward(self, hidden_states, position_embeddings, cu_seqlens, optimized_attention):
|
|
seq_length = hidden_states.shape[0]
|
|
qkv = self.qkv(hidden_states).reshape(seq_length, 3, self.num_heads, self.head_dim)
|
|
q, k, v = qkv.permute(1, 0, 2, 3).unbind(0)
|
|
|
|
cos, sin = position_embeddings
|
|
cos = cos.unsqueeze(-2).float()
|
|
sin = sin.unsqueeze(-2).float()
|
|
q_orig_dtype = q.dtype
|
|
q_f = q.float()
|
|
k_f = k.float()
|
|
q_rot = torch.cat((-q_f[..., q_f.shape[-1] // 2:], q_f[..., : q_f.shape[-1] // 2]), dim=-1)
|
|
k_rot = torch.cat((-k_f[..., k_f.shape[-1] // 2:], k_f[..., : k_f.shape[-1] // 2]), dim=-1)
|
|
q = ((q_f * cos) + (q_rot * sin)).to(q_orig_dtype)
|
|
k = ((k_f * cos) + (k_rot * sin)).to(q_orig_dtype)
|
|
|
|
q = q.transpose(0, 1).unsqueeze(0) # (1, H, S, D)
|
|
k = k.transpose(0, 1).unsqueeze(0)
|
|
v = v.transpose(0, 1).unsqueeze(0)
|
|
|
|
# Per-image full attention: split by cu_seqlens and run independently.
|
|
lengths = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
|
|
splits = [torch.split(t, lengths, dim=2) for t in (q, k, v)]
|
|
outs = [optimized_attention(qq, kk, vv, self.num_heads, skip_reshape=True) for qq, kk, vv in zip(*splits)]
|
|
out = torch.cat(outs, dim=1)
|
|
out = out.reshape(seq_length, -1)
|
|
return self.proj(out)
|
|
|
|
|
|
class _Qwen3VLVisionBlock(nn.Module):
|
|
def __init__(self, hidden_size, intermediate_size, num_heads, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.norm1 = ops.LayerNorm(hidden_size, eps=1e-6, device=device, dtype=dtype)
|
|
self.norm2 = ops.LayerNorm(hidden_size, eps=1e-6, device=device, dtype=dtype)
|
|
self.attn = _Qwen3VLVisionAttention(hidden_size, num_heads, device=device, dtype=dtype, ops=ops)
|
|
self.mlp = _Qwen3VLVisionMLP(hidden_size, intermediate_size, device=device, dtype=dtype, ops=ops)
|
|
|
|
def forward(self, hidden_states, position_embeddings, cu_seqlens, optimized_attention):
|
|
hidden_states = hidden_states + self.attn(
|
|
self.norm1(hidden_states), position_embeddings, cu_seqlens, optimized_attention,
|
|
)
|
|
hidden_states = hidden_states + self.mlp(self.norm2(hidden_states))
|
|
return hidden_states
|
|
|
|
|
|
class _Qwen3VLPatchMerger(nn.Module):
|
|
def __init__(self, hidden_size, out_hidden_size, spatial_merge_size,
|
|
use_postshuffle_norm, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
merged_size = hidden_size * (spatial_merge_size ** 2)
|
|
self.use_postshuffle_norm = use_postshuffle_norm
|
|
norm_dim = merged_size if use_postshuffle_norm else hidden_size
|
|
self.norm = ops.LayerNorm(norm_dim, eps=1e-6, device=device, dtype=dtype)
|
|
self.linear_fc1 = ops.Linear(merged_size, merged_size, bias=True, device=device, dtype=dtype)
|
|
self.linear_fc2 = ops.Linear(merged_size, out_hidden_size, bias=True, device=device, dtype=dtype)
|
|
self.merged_size = merged_size
|
|
|
|
def forward(self, x):
|
|
if self.use_postshuffle_norm:
|
|
x = self.norm(x.view(-1, self.merged_size))
|
|
else:
|
|
x = self.norm(x).view(-1, self.merged_size)
|
|
x = self.linear_fc2(F.gelu(self.linear_fc1(x), approximate="none"))
|
|
return x
|
|
|
|
|
|
class Qwen3VLVisionModel(nn.Module):
|
|
"""Qwen3-VL vision tower.
|
|
|
|
forward returns ``(image_features, deepstack_features)`` where
|
|
``image_features`` is the merger output ``(N_merged, out_hidden_size)`` and
|
|
``deepstack_features`` is a list of per-merger outputs (same shape) — one
|
|
per index in ``deepstack_visual_indexes``. The caller is responsible for
|
|
additively injecting each ``deepstack_features[k]`` into language-model
|
|
hidden states at the matching layer at visual-token positions.
|
|
"""
|
|
|
|
def __init__(self, config: Optional[Qwen3VLVisionConfig] = None,
|
|
device=None, dtype=None, ops=None, **kwargs):
|
|
super().__init__()
|
|
if config is None:
|
|
config = Qwen3VLVisionConfig(**kwargs)
|
|
self.config = config
|
|
self.spatial_merge_size = config.spatial_merge_size
|
|
self.patch_size = config.patch_size
|
|
self.num_grid_per_side = int(config.num_position_embeddings ** 0.5)
|
|
self.head_dim = config.hidden_size // config.num_heads
|
|
self.deepstack_visual_indexes = list(config.deepstack_visual_indexes)
|
|
|
|
self.patch_embed = _Qwen3VLVisionPatchEmbed(
|
|
config.hidden_size, config.patch_size, config.temporal_patch_size, in_channels=3,
|
|
device=device, dtype=dtype, ops=ops,
|
|
)
|
|
self.pos_embed = ops.Embedding(config.num_position_embeddings, config.hidden_size,
|
|
device=device, dtype=dtype)
|
|
self.blocks = nn.ModuleList([
|
|
_Qwen3VLVisionBlock(config.hidden_size, config.intermediate_size, config.num_heads,
|
|
device=device, dtype=dtype, ops=ops)
|
|
for _ in range(config.depth)
|
|
])
|
|
self.merger = _Qwen3VLPatchMerger(
|
|
config.hidden_size, config.out_hidden_size, config.spatial_merge_size,
|
|
use_postshuffle_norm=False, device=device, dtype=dtype, ops=ops,
|
|
)
|
|
self.deepstack_merger_list = nn.ModuleList([
|
|
_Qwen3VLPatchMerger(
|
|
config.hidden_size, config.out_hidden_size, config.spatial_merge_size,
|
|
use_postshuffle_norm=True, device=device, dtype=dtype, ops=ops,
|
|
) for _ in range(len(self.deepstack_visual_indexes))
|
|
])
|
|
|
|
def _rotary_pos_emb(self, grid_thw):
|
|
merge_size = self.spatial_merge_size
|
|
grid_thw_list = grid_thw.tolist()
|
|
max_hw = max(max(h, w) for _, h, w in grid_thw_list)
|
|
device = self.pos_embed.weight.device
|
|
dim = self.head_dim // 2
|
|
inv_freq = 1.0 / (10000.0 ** (torch.arange(0, dim, 2, dtype=torch.float, device=device) / dim))
|
|
seq = torch.arange(max_hw, device=device, dtype=inv_freq.dtype)
|
|
freq_table = torch.outer(seq, inv_freq)
|
|
|
|
total_tokens = sum(t * h * w for t, h, w in grid_thw_list)
|
|
pos_ids = torch.empty((total_tokens, 2), dtype=torch.long, device=device)
|
|
offset = 0
|
|
for num_frames, height, width in grid_thw_list:
|
|
merged_h, merged_w = height // merge_size, width // merge_size
|
|
block_rows = torch.arange(merged_h, device=device)
|
|
block_cols = torch.arange(merged_w, device=device)
|
|
intra = torch.arange(merge_size, device=device)
|
|
row_idx = (block_rows[:, None, None, None] * merge_size + intra[None, None, :, None]).expand(
|
|
merged_h, merged_w, merge_size, merge_size).reshape(-1)
|
|
col_idx = (block_cols[None, :, None, None] * merge_size + intra[None, None, None, :]).expand(
|
|
merged_h, merged_w, merge_size, merge_size).reshape(-1)
|
|
coords = torch.stack((row_idx, col_idx), dim=-1)
|
|
if num_frames > 1:
|
|
coords = coords.repeat(num_frames, 1)
|
|
n = coords.shape[0]
|
|
pos_ids[offset: offset + n] = coords
|
|
offset += n
|
|
return freq_table[pos_ids].flatten(1)
|
|
|
|
def _fast_pos_embed_interpolate(self, grid_thw):
|
|
# Bilinear interpolation over the learned `pos_embed` grid into the
|
|
# actual (grid_h, grid_w) requested by this image.
|
|
grid_thw_list = grid_thw.tolist()
|
|
device = self.pos_embed.weight.device
|
|
idx_lists = [[] for _ in range(4)]
|
|
weight_lists = [[] for _ in range(4)]
|
|
grid_hs = [r[1] for r in grid_thw_list]
|
|
grid_ws = [r[2] for r in grid_thw_list]
|
|
grid_ts = [r[0] for r in grid_thw_list]
|
|
for t, h, w in grid_thw_list:
|
|
h_idxs = torch.linspace(0, self.num_grid_per_side - 1, h)
|
|
w_idxs = torch.linspace(0, self.num_grid_per_side - 1, w)
|
|
hf = h_idxs.int()
|
|
wf = w_idxs.int()
|
|
hc = (h_idxs.int() + 1).clip(max=self.num_grid_per_side - 1)
|
|
wc = (w_idxs.int() + 1).clip(max=self.num_grid_per_side - 1)
|
|
dh = h_idxs - hf
|
|
dw = w_idxs - wf
|
|
base_h = hf * self.num_grid_per_side
|
|
base_h_ceil = hc * self.num_grid_per_side
|
|
indices = [
|
|
(base_h[None].T + wf[None]).flatten(),
|
|
(base_h[None].T + wc[None]).flatten(),
|
|
(base_h_ceil[None].T + wf[None]).flatten(),
|
|
(base_h_ceil[None].T + wc[None]).flatten(),
|
|
]
|
|
weights = [
|
|
((1 - dh)[None].T * (1 - dw)[None]).flatten(),
|
|
((1 - dh)[None].T * dw[None]).flatten(),
|
|
(dh[None].T * (1 - dw)[None]).flatten(),
|
|
(dh[None].T * dw[None]).flatten(),
|
|
]
|
|
for i in range(4):
|
|
idx_lists[i].extend(indices[i].tolist())
|
|
weight_lists[i].extend(weights[i].tolist())
|
|
idx_tensor = torch.tensor(idx_lists, dtype=torch.long, device=device)
|
|
weight_tensor = torch.tensor(weight_lists, dtype=self.pos_embed.weight.dtype, device=device)
|
|
pos_embeds = self.pos_embed(idx_tensor) * weight_tensor[:, :, None]
|
|
patch_pos = pos_embeds[0] + pos_embeds[1] + pos_embeds[2] + pos_embeds[3]
|
|
patch_pos = patch_pos.split([h * w for h, w in zip(grid_hs, grid_ws)])
|
|
out = []
|
|
merge_size = self.spatial_merge_size
|
|
for pe, t, h, w in zip(patch_pos, grid_ts, grid_hs, grid_ws):
|
|
pe = pe.repeat(t, 1)
|
|
pe = (pe.view(t, h // merge_size, merge_size, w // merge_size, merge_size, -1)
|
|
.permute(0, 1, 3, 2, 4, 5).flatten(0, 4))
|
|
out.append(pe)
|
|
return torch.cat(out)
|
|
|
|
def forward(self, pixel_values, grid_thw):
|
|
optimized_attention = optimized_attention_for_device(pixel_values.device, mask=False, small_input=True)
|
|
hidden_states = self.patch_embed(pixel_values)
|
|
pos_embeds = self._fast_pos_embed_interpolate(grid_thw)
|
|
hidden_states = hidden_states + pos_embeds.to(device=hidden_states.device, dtype=hidden_states.dtype)
|
|
|
|
rotary_pos_emb = self._rotary_pos_emb(grid_thw).to(hidden_states.device)
|
|
seq_len = hidden_states.size(0)
|
|
rotary_pos_emb = rotary_pos_emb.reshape(seq_len, -1)
|
|
emb = torch.cat((rotary_pos_emb, rotary_pos_emb), dim=-1)
|
|
position_embeddings = (emb.cos(), emb.sin())
|
|
|
|
cu_seqlens = torch.repeat_interleave(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).cumsum(
|
|
dim=0, dtype=torch.int32)
|
|
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
|
|
|
|
deepstack_features: List[torch.Tensor] = []
|
|
deepstack_set = set(self.deepstack_visual_indexes)
|
|
for layer_num, blk in enumerate(self.blocks):
|
|
hidden_states = blk(hidden_states, position_embeddings, cu_seqlens, optimized_attention)
|
|
if layer_num in deepstack_set:
|
|
ds_idx = self.deepstack_visual_indexes.index(layer_num)
|
|
deepstack_features.append(self.deepstack_merger_list[ds_idx](hidden_states))
|
|
|
|
if len(deepstack_features) != len(self.deepstack_visual_indexes):
|
|
raise RuntimeError(
|
|
f"Qwen3VLVisionModel: produced {len(deepstack_features)} deepstack features "
|
|
f"but configured for {len(self.deepstack_visual_indexes)}; "
|
|
f"deepstack_visual_indexes={self.deepstack_visual_indexes} contained an "
|
|
f"out-of-range layer."
|
|
)
|
|
|
|
image_features = self.merger(hidden_states)
|
|
return image_features, deepstack_features
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Decoder (forked from Llama2_) with deepstack residual injection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _Qwen3VLAttention(nn.Module):
|
|
"""Qwen3-VL self-attention. Equivalent to `comfy.text_encoders.llama.Attention`
|
|
with `q_norm/k_norm = "gemma3"` and `qkv_bias = False`; forked here only so
|
|
that `Qwen3VLDecoder` does not depend on the private `Attention` symbol of
|
|
`llama.py` (which is intentionally not part of its public surface).
|
|
"""
|
|
|
|
def __init__(self, config: Qwen3VLConfig, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.num_heads = config.num_attention_heads
|
|
self.num_kv_heads = config.num_key_value_heads
|
|
self.head_dim = config.head_dim
|
|
self.inner_size = self.num_heads * self.head_dim
|
|
|
|
self.q_proj = ops.Linear(config.hidden_size, self.inner_size, bias=config.qkv_bias, device=device, dtype=dtype)
|
|
self.k_proj = ops.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=config.qkv_bias, device=device, dtype=dtype)
|
|
self.v_proj = ops.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=config.qkv_bias, device=device, dtype=dtype)
|
|
self.o_proj = ops.Linear(self.inner_size, config.hidden_size, bias=False, device=device, dtype=dtype)
|
|
|
|
if config.q_norm == "gemma3":
|
|
self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype)
|
|
else:
|
|
self.q_norm = None
|
|
if config.k_norm == "gemma3":
|
|
self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype)
|
|
else:
|
|
self.k_norm = None
|
|
|
|
def forward(self, hidden_states, attention_mask, freqs_cis, optimized_attention):
|
|
batch_size, seq_length, _ = hidden_states.shape
|
|
|
|
xq = self.q_proj(hidden_states).view(batch_size, seq_length, self.num_heads, self.head_dim).transpose(1, 2)
|
|
xk = self.k_proj(hidden_states).view(batch_size, seq_length, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
|
xv = self.v_proj(hidden_states).view(batch_size, seq_length, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
|
|
|
if self.q_norm is not None:
|
|
xq = self.q_norm(xq)
|
|
if self.k_norm is not None:
|
|
xk = self.k_norm(xk)
|
|
|
|
xq, xk = apply_rope(xq, xk, freqs_cis=freqs_cis)
|
|
|
|
xk = xk.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
|
|
xv = xv.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
|
|
|
|
output = optimized_attention(xq, xk, xv, self.num_heads, mask=attention_mask, skip_reshape=True)
|
|
return self.o_proj(output)
|
|
|
|
|
|
class _Qwen3VLDecoderLayer(nn.Module):
|
|
def __init__(self, config: Qwen3VLConfig, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.self_attn = _Qwen3VLAttention(config, device=device, dtype=dtype, ops=ops)
|
|
self.mlp = MLP(config, device=device, dtype=dtype, ops=ops)
|
|
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, device=device, dtype=dtype)
|
|
self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, device=device, dtype=dtype)
|
|
|
|
def forward(self, x, attention_mask, freqs_cis, optimized_attention):
|
|
residual = x
|
|
x = self.input_layernorm(x)
|
|
x = self.self_attn(
|
|
hidden_states=x,
|
|
attention_mask=attention_mask,
|
|
freqs_cis=freqs_cis,
|
|
optimized_attention=optimized_attention,
|
|
)
|
|
x = residual + x
|
|
|
|
residual = x
|
|
x = self.post_attention_layernorm(x)
|
|
x = self.mlp(x)
|
|
x = residual + x
|
|
return x
|
|
|
|
|
|
class Qwen3VLDecoder(nn.Module):
|
|
"""Forked Llama2-style decoder for Qwen3-VL.
|
|
|
|
Constructor surface is compatible with `comfy.text_encoders.llama.Llama2_`
|
|
(config dataclass + ``device/dtype/ops``). Forward signature additionally
|
|
accepts ``deepstack_residuals`` and ``deepstack_layer_indices`` to enable
|
|
the Qwen3-VL deepstack injection that vanilla `Llama2_` does not support.
|
|
|
|
Deepstack contract:
|
|
``deepstack_residuals`` is a list of full-sequence tensors, each of shape
|
|
``(B, seq_len, hidden_size)``, with **zeros at non-visual positions** and
|
|
the corresponding ``deepstack_merger_list[k]`` output at visual-token
|
|
positions. Index ``k`` in ``deepstack_residuals`` is added into the
|
|
hidden state **after decoder layer**
|
|
``deepstack_layer_indices[k]`` runs (matching transformers'
|
|
``Qwen3VLTextModel`` semantics). Lengths of the two lists must match;
|
|
indices must be in ``[0, num_hidden_layers)``. Mismatch raises.
|
|
"""
|
|
|
|
def __init__(self, config: Qwen3VLConfig, device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.config = config
|
|
self.vocab_size = config.vocab_size
|
|
|
|
self.embed_tokens = ops.Embedding(config.vocab_size, config.hidden_size, device=device, dtype=dtype)
|
|
self.layers = nn.ModuleList([
|
|
_Qwen3VLDecoderLayer(config, device=device, dtype=dtype, ops=ops)
|
|
for _ in range(config.num_hidden_layers)
|
|
])
|
|
|
|
if config.final_norm:
|
|
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=config.rms_norm_add,
|
|
device=device, dtype=dtype)
|
|
else:
|
|
self.norm = None
|
|
|
|
def compute_freqs_cis(self, position_ids, device):
|
|
return precompute_freqs_cis(
|
|
self.config.head_dim,
|
|
position_ids,
|
|
self.config.rope_theta,
|
|
self.config.rope_scale,
|
|
list(self.config.rope_dims) if self.config.rope_dims is not None else None,
|
|
interleaved_mrope=getattr(self.config, "interleaved_mrope", False),
|
|
device=device,
|
|
)
|
|
|
|
def forward(
|
|
self,
|
|
x,
|
|
attention_mask=None,
|
|
embeds=None,
|
|
num_tokens=None,
|
|
intermediate_output=None,
|
|
final_layer_norm_intermediate=True,
|
|
dtype=None,
|
|
position_ids=None,
|
|
embeds_info=(),
|
|
deepstack_residuals=None,
|
|
deepstack_layer_indices=None,
|
|
# Forward-compat with `Llama2_.forward` signature; not used here
|
|
# (this fork doesn't implement KV-cache generation).
|
|
past_key_values=None,
|
|
input_ids=None,
|
|
):
|
|
if embeds is not None:
|
|
x = embeds
|
|
else:
|
|
x = self.embed_tokens(x, out_dtype=dtype)
|
|
|
|
seq_len = x.shape[1]
|
|
|
|
# Validate deepstack arguments up front. No silent fallbacks.
|
|
if deepstack_residuals is not None or deepstack_layer_indices is not None:
|
|
if deepstack_residuals is None or deepstack_layer_indices is None:
|
|
raise ValueError(
|
|
"Qwen3VLDecoder.forward: deepstack_residuals and "
|
|
"deepstack_layer_indices must be supplied together "
|
|
f"(got residuals={'set' if deepstack_residuals is not None else 'None'}, "
|
|
f"indices={'set' if deepstack_layer_indices is not None else 'None'})."
|
|
)
|
|
if len(deepstack_residuals) != len(deepstack_layer_indices):
|
|
raise ValueError(
|
|
f"Qwen3VLDecoder.forward: deepstack_residuals has length "
|
|
f"{len(deepstack_residuals)} but deepstack_layer_indices has length "
|
|
f"{len(deepstack_layer_indices)}; the two must match 1:1."
|
|
)
|
|
for k, idx in enumerate(deepstack_layer_indices):
|
|
if not (0 <= idx < len(self.layers)):
|
|
raise ValueError(
|
|
f"Qwen3VLDecoder.forward: deepstack_layer_indices[{k}]={idx} "
|
|
f"out of range for {len(self.layers)} decoder layers."
|
|
)
|
|
r = deepstack_residuals[k]
|
|
if r.shape[0] != x.shape[0] or r.shape[1] != seq_len or r.shape[2] != x.shape[2]:
|
|
raise ValueError(
|
|
f"Qwen3VLDecoder.forward: deepstack_residuals[{k}].shape={tuple(r.shape)} "
|
|
f"does not match (B, seq_len, hidden_size)={tuple(x.shape)}."
|
|
)
|
|
inject_at = {int(layer_idx): k for k, layer_idx in enumerate(deepstack_layer_indices)}
|
|
else:
|
|
inject_at = {}
|
|
|
|
if position_ids is None:
|
|
position_ids = torch.arange(0, seq_len, device=x.device).unsqueeze(0)
|
|
|
|
freqs_cis = self.compute_freqs_cis(position_ids, x.device)
|
|
|
|
mask = None
|
|
if attention_mask is not None:
|
|
mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(
|
|
attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1])
|
|
mask = mask.masked_fill(mask.to(torch.bool), torch.finfo(x.dtype).min / 4)
|
|
|
|
if seq_len > 1:
|
|
causal_mask = torch.empty(seq_len, seq_len, dtype=x.dtype, device=x.device).fill_(
|
|
torch.finfo(x.dtype).min / 4).triu_(1)
|
|
if mask is not None:
|
|
mask += causal_mask
|
|
else:
|
|
mask = causal_mask
|
|
|
|
optimized_attention = optimized_attention_for_device(x.device, mask=mask is not None, small_input=True)
|
|
|
|
intermediate = None
|
|
all_intermediate = None
|
|
only_layers = None
|
|
resolved_intermediate_output = intermediate_output
|
|
if intermediate_output is not None:
|
|
if isinstance(intermediate_output, list):
|
|
all_intermediate = []
|
|
only_layers = set(intermediate_output)
|
|
elif intermediate_output == "all":
|
|
all_intermediate = []
|
|
resolved_intermediate_output = None
|
|
elif intermediate_output < 0:
|
|
resolved_intermediate_output = len(self.layers) + intermediate_output
|
|
|
|
for i, layer in enumerate(self.layers):
|
|
if all_intermediate is not None:
|
|
if only_layers is None or (i in only_layers):
|
|
all_intermediate.append(x.unsqueeze(1).clone())
|
|
|
|
x = layer(
|
|
x=x,
|
|
attention_mask=mask,
|
|
freqs_cis=freqs_cis,
|
|
optimized_attention=optimized_attention,
|
|
)
|
|
|
|
if i == resolved_intermediate_output:
|
|
intermediate = x.clone()
|
|
|
|
if i in inject_at:
|
|
# Additive injection at visual-token positions; non-visual
|
|
# positions in the residual tensor are zero. Applied AFTER
|
|
# the decoder layer.
|
|
x = x + deepstack_residuals[inject_at[i]].to(dtype=x.dtype)
|
|
|
|
if self.norm is not None:
|
|
x = self.norm(x)
|
|
|
|
if all_intermediate is not None:
|
|
if only_layers is None or ((len(self.layers)) in only_layers):
|
|
all_intermediate.append(x.unsqueeze(1).clone())
|
|
intermediate = torch.cat(all_intermediate, dim=1)
|
|
|
|
if intermediate is not None and final_layer_norm_intermediate and self.norm is not None:
|
|
intermediate = self.norm(intermediate)
|
|
|
|
return x, intermediate
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Outer wrapper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _Qwen3VLInnerModel(nn.Module):
|
|
"""Holds ``language_model`` and ``visual`` so checkpoint keys match the
|
|
``model.language_model.*`` / ``model.visual.*`` namespace produced by
|
|
``Qwen3VLForConditionalGeneration``.
|
|
"""
|
|
|
|
def __init__(self, config: Qwen3VLConfig, vision_config: Qwen3VLVisionConfig,
|
|
device=None, dtype=None, ops=None):
|
|
super().__init__()
|
|
self.config = config
|
|
self.language_model = Qwen3VLDecoder(config, device=device, dtype=dtype, ops=ops)
|
|
self.visual = Qwen3VLVisionModel(vision_config, device=device, dtype=dtype, ops=ops)
|
|
|
|
@property
|
|
def embed_tokens(self):
|
|
return self.language_model.embed_tokens
|
|
|
|
def forward(self, *args, **kwargs):
|
|
return self.language_model.forward(*args, **kwargs)
|
|
|
|
|
|
class Qwen3VLBase(torch.nn.Module):
|
|
"""Generic Qwen3-VL multimodal stack with the
|
|
``model.{language_model,visual}`` + root ``lm_head`` namespace.
|
|
|
|
Subclasses are expected to plug in 3D MRoPE position-id construction (for
|
|
image-token blocks) by overriding ``forward`` or
|
|
``build_image_position_ids`` to consume the ``embeds_info`` list produced
|
|
by ``comfy.sd1_clip.SDClipModel.process_tokens``. Plain text-only callers
|
|
can use ``forward`` directly.
|
|
"""
|
|
|
|
def __init__(self, config_dict, dtype, device, operations,
|
|
config_cls=Qwen3VLConfig, vision_config_cls=Qwen3VLVisionConfig,
|
|
vision_config_dict: Optional[dict] = None):
|
|
super().__init__()
|
|
config = config_cls(**config_dict)
|
|
self.config = config
|
|
self.num_layers = config.num_hidden_layers
|
|
self.dtype = dtype
|
|
|
|
if vision_config_dict is None:
|
|
vision_config = vision_config_cls()
|
|
else:
|
|
vision_config = vision_config_cls(**vision_config_dict)
|
|
|
|
if len(vision_config.deepstack_visual_indexes) != len(config.deepstack_decoder_inject_layers):
|
|
raise ValueError(
|
|
f"Qwen3VLBase: vision_config has "
|
|
f"{len(vision_config.deepstack_visual_indexes)} deepstack mergers "
|
|
f"but text config has {len(config.deepstack_decoder_inject_layers)} "
|
|
f"deepstack injection layers; lengths must match."
|
|
)
|
|
|
|
self.model = _Qwen3VLInnerModel(config, vision_config, device=device, dtype=dtype, ops=operations)
|
|
# `lm_head` lives at the root of a Qwen3VLForConditionalGeneration
|
|
# checkpoint. Required for clean state-dict loading even when callers
|
|
# only use the encoder for hidden states.
|
|
if config.lm_head:
|
|
self.lm_head = operations.Linear(
|
|
config.hidden_size, config.vocab_size, bias=False, device=device, dtype=dtype,
|
|
)
|
|
|
|
# --- Public surface mirroring `comfy.text_encoders.llama.BaseLlama` ----
|
|
|
|
def get_input_embeddings(self):
|
|
return self.model.embed_tokens
|
|
|
|
def set_input_embeddings(self, embeddings):
|
|
self.model.language_model.embed_tokens = embeddings
|
|
|
|
# --- Vision / preprocessing -----------------------------------------------
|
|
|
|
def preprocess_embed(self, embed, device):
|
|
"""Run the vision tower for one ``{"type": "image", "data": tensor}``
|
|
embed and return ``(merged_features, extra)`` where ``extra`` is a
|
|
dict ``{"grid": grid_thw, "deepstack": deepstack_features}``. The
|
|
``deepstack`` list has one tensor per
|
|
``vision_config.deepstack_visual_indexes`` entry, each of shape
|
|
``(N_merged, hidden_size)`` — same shape as ``merged_features``.
|
|
"""
|
|
if embed["type"] != "image":
|
|
return None, None
|
|
pixel_values, grid_thw = process_qwen3vl_image(embed["data"])
|
|
pixel_values = pixel_values.to(device, dtype=torch.float32)
|
|
grid_thw = grid_thw.to(device)
|
|
merged, deepstack = self.model.visual(pixel_values, grid_thw)
|
|
return merged, {"grid": grid_thw, "deepstack": deepstack}
|
|
|
|
# --- Position ids ---------------------------------------------------------
|
|
|
|
def build_position_ids(self, embeds, attention_mask, embeds_info):
|
|
"""Build the (3, seq_len) MRoPE position-id matrix for an embed sequence
|
|
that may contain image-token blocks. Mirrors
|
|
`comfy.text_encoders.llama.Qwen25_7BVLI.forward`'s position-id logic
|
|
but reads ``grid`` from ``e["extra"]["grid"]`` rather than
|
|
``e["extra"]`` directly.
|
|
"""
|
|
grid = None
|
|
position_ids = None
|
|
offset = 0
|
|
for e in embeds_info:
|
|
if e.get("type") != "image":
|
|
continue
|
|
extra = e.get("extra", None)
|
|
if not isinstance(extra, dict) or "grid" not in extra:
|
|
raise ValueError(
|
|
"Qwen3VLBase.build_position_ids: image embed extra is missing 'grid'."
|
|
)
|
|
grid = extra["grid"]
|
|
start = e.get("index")
|
|
if position_ids is None:
|
|
position_ids = torch.ones((3, embeds.shape[1]), device=embeds.device, dtype=torch.long)
|
|
position_ids[:, :start] = torch.arange(0, start, device=embeds.device)
|
|
end = e.get("size") + start
|
|
len_max = int(grid.max()) // 2
|
|
start_next = len_max + start
|
|
if attention_mask is not None:
|
|
after_mask = attention_mask[0, end:]
|
|
text_positions = after_mask.cumsum(0) - 1 + start_next + offset
|
|
position_ids[:, end:] = torch.where(
|
|
after_mask.bool(), text_positions, position_ids[0, end:],
|
|
)
|
|
else:
|
|
position_ids[:, end:] = torch.arange(
|
|
start_next + offset, start_next + (embeds.shape[1] - end) + offset,
|
|
device=embeds.device,
|
|
)
|
|
position_ids[0, start:end] = start + offset
|
|
max_d = int(grid[0][1]) // 2
|
|
position_ids[1, start:end] = torch.arange(
|
|
start + offset, start + max_d + offset, device=embeds.device,
|
|
).unsqueeze(1).repeat(1, math.ceil((end - start) / max_d)).flatten(0)[:end - start]
|
|
max_d = int(grid[0][2]) // 2
|
|
position_ids[2, start:end] = torch.arange(
|
|
start + offset, start + max_d + offset, device=embeds.device,
|
|
).unsqueeze(0).repeat(math.ceil((end - start) / max_d), 1).flatten(0)[:end - start]
|
|
offset += len_max - (end - start)
|
|
|
|
return position_ids if grid is not None else None
|
|
|
|
# --- Deepstack residual construction --------------------------------------
|
|
|
|
def build_deepstack_residuals(self, embeds, embeds_info):
|
|
"""Construct the per-merger zero-padded residual tensors that
|
|
`Qwen3VLDecoder.forward` expects. Returns
|
|
``(residuals, layer_indices)`` or ``(None, None)`` if no images are
|
|
present in the sequence.
|
|
|
|
Each residual has shape ``(B, seq_len, hidden_size)``, with the
|
|
corresponding deepstack feature placed at visual-token positions and
|
|
zeros elsewhere. If multiple images share one batch, all of them
|
|
contribute residuals in order.
|
|
"""
|
|
num_mergers = len(self.config.deepstack_decoder_inject_layers)
|
|
any_image = any(e.get("type") == "image" for e in embeds_info)
|
|
if not any_image:
|
|
return None, None
|
|
|
|
B, seq_len, hidden_size = embeds.shape
|
|
residuals = [
|
|
torch.zeros((B, seq_len, hidden_size), device=embeds.device, dtype=embeds.dtype)
|
|
for _ in range(num_mergers)
|
|
]
|
|
for e in embeds_info:
|
|
if e.get("type") != "image":
|
|
continue
|
|
extra = e.get("extra", None)
|
|
if not isinstance(extra, dict) or "deepstack" not in extra:
|
|
raise ValueError(
|
|
"Qwen3VLBase.build_deepstack_residuals: image embed extra is missing 'deepstack'."
|
|
)
|
|
ds_features = extra["deepstack"]
|
|
if len(ds_features) != num_mergers:
|
|
raise ValueError(
|
|
f"Qwen3VLBase.build_deepstack_residuals: expected {num_mergers} deepstack "
|
|
f"features per image but got {len(ds_features)}."
|
|
)
|
|
start = e.get("index")
|
|
size = e.get("size")
|
|
for k, feat in enumerate(ds_features):
|
|
if feat.shape[0] != size:
|
|
raise ValueError(
|
|
f"Qwen3VLBase.build_deepstack_residuals: deepstack feature #{k} has "
|
|
f"{feat.shape[0]} tokens but image embed claims {size} positions."
|
|
)
|
|
residuals[k][:, start:start + size, :] = feat.to(dtype=embeds.dtype).unsqueeze(0)
|
|
|
|
return residuals, list(self.config.deepstack_decoder_inject_layers)
|
|
|
|
# --- Forward --------------------------------------------------------------
|
|
|
|
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None,
|
|
intermediate_output=None, final_layer_norm_intermediate=True,
|
|
dtype=None, embeds_info=()):
|
|
position_ids = self.build_position_ids(embeds, attention_mask, embeds_info) if embeds is not None else None
|
|
deepstack_residuals, deepstack_layer_indices = (
|
|
self.build_deepstack_residuals(embeds, embeds_info) if embeds is not None else (None, None)
|
|
)
|
|
return self.model(
|
|
x,
|
|
attention_mask=attention_mask,
|
|
embeds=embeds,
|
|
num_tokens=num_tokens,
|
|
intermediate_output=intermediate_output,
|
|
final_layer_norm_intermediate=final_layer_norm_intermediate,
|
|
dtype=dtype,
|
|
position_ids=position_ids,
|
|
deepstack_residuals=deepstack_residuals,
|
|
deepstack_layer_indices=deepstack_layer_indices,
|
|
)
|