Skip to content

hunyuan_image model/pipeline review #13605

Description

@hlky

hunyuan_image model/pipeline review

Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423

Review performed against the repository review rules.

Duplicate-search status: searched hunyuan_image, HunyuanImagePipeline, AutoencoderKLHunyuanImage, AutoencoderKLHunyuanImageRefiner, plus targeted tiling/shortcut/shape terms. Broad matches were the original integration PR #12333 and HunyuanImage 3.0 request #12412; I did not find duplicates for the issues below. A second gh search batch hit GitHub API rate limits, so I completed targeted duplicate checks with web search.

Issue 1: Base VAE default scaling factor breaks pipeline decode

Affected code:

@register_to_config
def__init__(
self,
in_channels: int,
out_channels: int,
latent_channels: int,
block_out_channels: tuple[int, ...],
layers_per_block: int,
spatial_compression_ratio: int,
sample_size: int,
scaling_factor: float=None,
downsample_match_channel: bool=True,
upsample_match_channel: bool=True,
) ->None:
# fmt: on
super().__init__()
self.encoder=HunyuanImageEncoder2D(
in_channels=in_channels,
z_channels=latent_channels,
block_out_channels=block_out_channels,
num_res_blocks=layers_per_block,
spatial_compression_ratio=spatial_compression_ratio,
downsample_match_channel=downsample_match_channel,
)
self.decoder=HunyuanImageDecoder2D(
z_channels=latent_channels,
out_channels=out_channels,
block_out_channels=list(reversed(block_out_channels)),
num_res_blocks=layers_per_block,
spatial_compression_ratio=spatial_compression_ratio,
upsample_match_channel=upsample_match_channel,
)
# Tiling and slicing configuration
self.use_slicing=False
self.use_tiling=False
# Tiling parameters
self.tile_sample_min_size=sample_size
self.tile_latent_min_size=sample_size//spatial_compression_ratio
self.tile_overlap_factor=0.25

ifoutput_type=="latent":
image=latents
else:
latents=latents.to(self.vae.dtype) /self.vae.config.scaling_factor
image=self.vae.decode(latents, return_dict=False)[0]
image=self.image_processor.postprocess(image, output_type=output_type)

Problem:
AutoencoderKLHunyuanImage defaults scaling_factor to None, but HunyuanImagePipeline always divides latents by self.vae.config.scaling_factor before decoding.

Impact:
A pipeline assembled from default tiny components can denoise successfully and then fail at decode with TypeError. The refiner VAE already has a numeric default.

Reproduction:

importtorchfromdiffusersimportAutoencoderKLHunyuanImage, FlowMatchEulerDiscreteScheduler, HunyuanImagePipeline, HunyuanImageTransformer2DModeltransformer=HunyuanImageTransformer2DModel(
in_channels=4, out_channels=4, num_attention_heads=1, attention_head_dim=4,
num_layers=0, num_single_layers=0, num_refiner_layers=0,
patch_size=(1, 1), text_embed_dim=4, text_embed_2_dim=4, rope_axes_dim=(2, 2),
)
vae=AutoencoderKLHunyuanImage(
in_channels=3, out_channels=3, latent_channels=4, block_out_channels=(32,),
layers_per_block=1, spatial_compression_ratio=8, sample_size=32,
)
pipe=HunyuanImagePipeline(
scheduler=FlowMatchEulerDiscreteScheduler(), vae=vae, text_encoder=None, tokenizer=None,
text_encoder_2=None, tokenizer_2=None, transformer=transformer, guider=None, ocr_guider=None,
)
pipe(
prompt=None,
prompt_embeds=torch.randn(1, 1, 4),
prompt_embeds_mask=torch.ones(1, 1, dtype=torch.long),
prompt_embeds_2=torch.zeros(1, 1, 4),
prompt_embeds_mask_2=torch.zeros(1, 1, dtype=torch.long),
height=16, width=16, num_inference_steps=1, output_type="pt",
)

Relevant precedent:

@register_to_config
def__init__(
self,
in_channels: int=3,
out_channels: int=3,
latent_channels: int=32,
block_out_channels: tuple[int, ...] = (128, 256, 512, 1024, 1024),
layers_per_block: int=2,
spatial_compression_ratio: int=16,
temporal_compression_ratio: int=4,
downsample_match_channel: bool=True,
upsample_match_channel: bool=True,
scaling_factor: float=1.03682,
) ->None:

Suggested fix:

scaling_factor: float=0.476986

Issue 2: HunyuanImagePipelineOutput is not exported from the pipeline package

Affected code:

_import_structure["pipeline_hunyuanimage"] = ["HunyuanImagePipeline"]
_import_structure["pipeline_hunyuanimage_refiner"] = ["HunyuanImageRefinerPipeline"]
ifTYPE_CHECKINGorDIFFUSERS_SLOW_IMPORT:
try:
ifnot (is_transformers_available() andis_torch_available()):
raiseOptionalDependencyNotAvailable()
exceptOptionalDependencyNotAvailable:
from ...utils.dummy_torch_and_transformers_objectsimport*
else:
from .pipeline_hunyuanimageimportHunyuanImagePipeline
from .pipeline_hunyuanimage_refinerimportHunyuanImageRefinerPipeline

classHunyuanImagePipelineOutput(BaseOutput):
"""
Output class for HunyuanImage pipelines.
Args:
images (`list[PIL.Image.Image]` or `np.ndarray`)
List of denoised PIL images of length `batch_size` or numpy array of shape `(batch_size, height, width,
num_channels)`. PIL images or numpy array present the denoised images of the diffusion pipeline.
"""
images: list[PIL.Image.Image, np.ndarray]

Problem:
The output class exists and is documented, but diffusers.pipelines.hunyuan_image only lazy-exports the two pipelines.

Impact:
Users cannot import the output type from the package namespace, unlike similar pipeline families.

Reproduction:

fromdiffusers.pipelines.hunyuan_imageimportHunyuanImagePipelineOutput

Relevant precedent:

_import_structure= {"pipeline_output": ["QwenImagePipelineOutput", "QwenImagePriorReduxPipelineOutput"]}
try:
ifnot (is_transformers_available() andis_torch_available()):
raiseOptionalDependencyNotAvailable()
exceptOptionalDependencyNotAvailable:
from ...utilsimportdummy_torch_and_transformers_objects# noqa F403
_dummy_objects.update(get_objects_from_module(dummy_torch_and_transformers_objects))
else:
_import_structure["modeling_qwenimage"] = ["ReduxImageEncoder"]
_import_structure["pipeline_qwenimage"] = ["QwenImagePipeline"]
_import_structure["pipeline_qwenimage_controlnet"] = ["QwenImageControlNetPipeline"]
_import_structure["pipeline_qwenimage_controlnet_inpaint"] = ["QwenImageControlNetInpaintPipeline"]
_import_structure["pipeline_qwenimage_edit"] = ["QwenImageEditPipeline"]
_import_structure["pipeline_qwenimage_edit_inpaint"] = ["QwenImageEditInpaintPipeline"]
_import_structure["pipeline_qwenimage_edit_plus"] = ["QwenImageEditPlusPipeline"]
_import_structure["pipeline_qwenimage_img2img"] = ["QwenImageImg2ImgPipeline"]
_import_structure["pipeline_qwenimage_inpaint"] = ["QwenImageInpaintPipeline"]
_import_structure["pipeline_qwenimage_layered"] = ["QwenImageLayeredPipeline"]
ifTYPE_CHECKINGorDIFFUSERS_SLOW_IMPORT:
try:
ifnot (is_transformers_available() andis_torch_available()):
raiseOptionalDependencyNotAvailable()
exceptOptionalDependencyNotAvailable:
from ...utils.dummy_torch_and_transformers_objectsimport*# noqa F403
else:
from .pipeline_qwenimageimportQwenImagePipeline

Suggested fix:

_import_structure["pipeline_output"] = ["HunyuanImagePipelineOutput"]
...
from .pipeline_outputimportHunyuanImagePipelineOutput

Issue 3: Base VAE tiled encode crashes on 4D image tensors

Affected code:

deftiled_encode(self, x: torch.Tensor) ->torch.Tensor:
"""
Encode input using spatial tiling strategy.
Args:
x (`torch.Tensor`): Input tensor of shape (B, C, T, H, W).
Returns:
`torch.Tensor`:
The latent representation of the encoded images.
"""
_, _, _, height, width=x.shape
overlap_size=int(self.tile_sample_min_size* (1-self.tile_overlap_factor))
blend_extent=int(self.tile_latent_min_size*self.tile_overlap_factor)
row_limit=self.tile_latent_min_size-blend_extent
rows= []
foriinrange(0, height, overlap_size):
row= []
forjinrange(0, width, overlap_size):
tile=x[:, :, :, i : i+self.tile_sample_min_size, j : j+self.tile_sample_min_size]
tile=self.encoder(tile)
row.append(tile)
rows.append(row)
result_rows= []
fori, rowinenumerate(rows):
result_row= []
forj, tileinenumerate(row):
ifi>0:
tile=self.blend_v(rows[i-1][j], tile, blend_extent)
ifj>0:
tile=self.blend_h(row[j-1], tile, blend_extent)
result_row.append(tile[:, :, :, :row_limit, :row_limit])
result_rows.append(torch.cat(result_row, dim=-1))
moments=torch.cat(result_rows, dim=-2)
returnmoments

Problem:
AutoencoderKLHunyuanImage is a 2D image VAE, but tiled_encode() unpacks x.shape as if it were 5D and slices tiles with an extra temporal dimension.

Impact:
vae.enable_tiling(); vae.encode(image) fails for image tensors large enough to trigger tiling. Existing tests cover tiled decode through the pipeline, but not tiled encode.

Reproduction:

importtorchfromdiffusersimportAutoencoderKLHunyuanImagevae=AutoencoderKLHunyuanImage(
in_channels=3, out_channels=3, latent_channels=4, block_out_channels=(32,),
layers_per_block=1, spatial_compression_ratio=1, sample_size=8,
)
vae.enable_tiling(tile_sample_min_size=4)
vae.encode(torch.randn(1, 3, 8, 8))

Relevant precedent:
The same file's tiled_decode() correctly treats base VAE latents as 4D:

deftiled_decode(self, z: torch.Tensor, return_dict: bool=True) ->DecoderOutput|torch.Tensor:
"""
Decode latent using spatial tiling strategy.
Args:
z (`torch.Tensor`): Latent tensor of shape (B, C, H, W).
return_dict (`bool`, *optional*, defaults to `True`):
Whether or not to return a [`~models.vae.DecoderOutput`] instead of a plain tuple.
Returns:
[`~models.vae.DecoderOutput`] or `tuple`:
If return_dict is True, a [`~models.vae.DecoderOutput`] is returned, otherwise a plain `tuple` is
returned.
"""
_, _, height, width=z.shape
overlap_size=int(self.tile_latent_min_size* (1-self.tile_overlap_factor))
blend_extent=int(self.tile_sample_min_size*self.tile_overlap_factor)
row_limit=self.tile_sample_min_size-blend_extent
rows= []
foriinrange(0, height, overlap_size):
row= []
forjinrange(0, width, overlap_size):
tile=z[:, :, i : i+self.tile_latent_min_size, j : j+self.tile_latent_min_size]
decoded=self.decoder(tile)
row.append(decoded)
rows.append(row)
result_rows= []
fori, rowinenumerate(rows):
result_row= []
forj, tileinenumerate(row):
ifi>0:
tile=self.blend_v(rows[i-1][j], tile, blend_extent)
ifj>0:
tile=self.blend_h(row[j-1], tile, blend_extent)
result_row.append(tile[:, :, :row_limit, :row_limit])
result_rows.append(torch.cat(result_row, dim=-1))
dec=torch.cat(result_rows, dim=-2)
ifnotreturn_dict:
return (dec,)
returnDecoderOutput(sample=dec)

Suggested fix:

_, _, height, width=x.shape
...
tile=x[:, :, i : i+self.tile_sample_min_size, j : j+self.tile_sample_min_size]
...
result_row.append(tile[:, :, :row_limit, :row_limit])

Issue 4: Refiner VAE tiling mixes sample and latent units

Affected code:

deftiled_encode(self, x: torch.Tensor) ->torch.Tensor:
r"""Encode a batch of images using a tiled encoder.
Args:
x (`torch.Tensor`): Input batch of videos.
Returns:
`torch.Tensor`:
The latent representation of the encoded videos.
"""
_, _, _, height, width=x.shape
tile_latent_min_height=self.tile_sample_min_height//self.spatial_compression_ratio
tile_latent_min_width=self.tile_sample_min_width//self.spatial_compression_ratio
overlap_height=int(tile_latent_min_height* (1-self.tile_overlap_factor)) # 256 * (1 - 0.25) = 192
overlap_width=int(tile_latent_min_width* (1-self.tile_overlap_factor)) # 256 * (1 - 0.25) = 192
blend_height=int(tile_latent_min_height*self.tile_overlap_factor) # 8 * 0.25 = 2
blend_width=int(tile_latent_min_width*self.tile_overlap_factor) # 8 * 0.25 = 2
row_limit_height=tile_latent_min_height-blend_height# 8 - 2 = 6
row_limit_width=tile_latent_min_width-blend_width# 8 - 2 = 6
rows= []
foriinrange(0, height, overlap_height):
row= []
forjinrange(0, width, overlap_width):
tile=x[
:,
:,
:,
i : i+self.tile_sample_min_height,
j : j+self.tile_sample_min_width,
]
tile=self.encoder(tile)
row.append(tile)
rows.append(row)
result_rows= []
fori, rowinenumerate(rows):
result_row= []
forj, tileinenumerate(row):
ifi>0:
tile=self.blend_v(rows[i-1][j], tile, blend_height)
ifj>0:
tile=self.blend_h(row[j-1], tile, blend_width)
result_row.append(tile[:, :, :, :row_limit_height, :row_limit_width])
result_rows.append(torch.cat(result_row, dim=-1))
moments=torch.cat(result_rows, dim=-2)
returnmoments

deftiled_decode(self, z: torch.Tensor) ->torch.Tensor:
r"""
Decode a batch of images using a tiled decoder.
Args:
z (`torch.Tensor`): Input batch of latent vectors.
return_dict (`bool`, *optional*, defaults to `True`):
Whether or not to return a [`~models.vae.DecoderOutput`] instead of a plain tuple.
Returns:
[`~models.vae.DecoderOutput`] or `tuple`:
If return_dict is True, a [`~models.vae.DecoderOutput`] is returned, otherwise a plain `tuple` is
returned.
"""
_, _, _, height, width=z.shape
tile_latent_min_height=self.tile_sample_min_height//self.spatial_compression_ratio
tile_latent_min_width=self.tile_sample_min_width//self.spatial_compression_ratio
overlap_height=int(tile_latent_min_height* (1-self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
overlap_width=int(tile_latent_min_width* (1-self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
blend_height=int(tile_latent_min_height*self.tile_overlap_factor) # 256 * 0.25 = 64
blend_width=int(tile_latent_min_width*self.tile_overlap_factor) # 256 * 0.25 = 64
row_limit_height=tile_latent_min_height-blend_height# 256 - 64 = 192
row_limit_width=tile_latent_min_width-blend_width# 256 - 64 = 192
rows= []
foriinrange(0, height, overlap_height):
row= []
forjinrange(0, width, overlap_width):
tile=z[
:,
:,
:,
i : i+tile_latent_min_height,
j : j+tile_latent_min_width,
]
decoded=self.decoder(tile)
row.append(decoded)
rows.append(row)
result_rows= []
fori, rowinenumerate(rows):
result_row= []
forj, tileinenumerate(row):
ifi>0:
tile=self.blend_v(rows[i-1][j], tile, blend_height)
ifj>0:
tile=self.blend_h(row[j-1], tile, blend_width)
result_row.append(tile[:, :, :, :row_limit_height, :row_limit_width])
result_rows.append(torch.cat(result_row, dim=-1))
dec=torch.cat(result_rows, dim=-2)
returndec

Problem:
tiled_encode() iterates over sample-space height/width using latent-space overlap values. tiled_decode() crops decoded sample tiles using latent-space row limits.

Impact:
Tiled encode can generate invalid edge tiles, and tiled decode returns the wrong spatial size.

Reproduction:

importtorchfromdiffusersimportAutoencoderKLHunyuanImageRefinervae=AutoencoderKLHunyuanImageRefiner(
in_channels=3, out_channels=3, latent_channels=4, block_out_channels=(8, 8),
layers_per_block=0, spatial_compression_ratio=2, temporal_compression_ratio=1,
)
z=torch.randn(1, 4, 1, 8, 8)
plain=vae.decode(z).sample.shapevae.enable_tiling(tile_sample_min_height=8, tile_sample_min_width=8, tile_overlap_factor=0.25)
tiled=vae.decode(z).sample.shapeprint(plain, tiled) # plain is [1, 3, 1, 16, 16], tiled is [1, 3, 1, 9, 9]

Relevant precedent:

deftiled_encode(self, x: torch.Tensor) ->torch.Tensor:
r"""Encode a batch of images using a tiled encoder.
Args:
x (`torch.Tensor`): Input batch of videos.
Returns:
`torch.Tensor`:
The latent representation of the encoded videos.
"""
_, _, _, height, width=x.shape
overlap_height=int(self.tile_sample_min_height* (1-self.tile_overlap_factor)) # 256 * (1 - 0.25) = 192
overlap_width=int(self.tile_sample_min_width* (1-self.tile_overlap_factor)) # 256 * (1 - 0.25) = 192
blend_height=int(self.tile_latent_min_height*self.tile_overlap_factor) # 8 * 0.25 = 2
blend_width=int(self.tile_latent_min_width*self.tile_overlap_factor) # 8 * 0.25 = 2
row_limit_height=self.tile_latent_min_height-blend_height# 8 - 2 = 6
row_limit_width=self.tile_latent_min_width-blend_width# 8 - 2 = 6
rows= []
foriinrange(0, height, overlap_height):
row= []
forjinrange(0, width, overlap_width):
tile=x[
:,
:,
:,
i : i+self.tile_sample_min_height,
j : j+self.tile_sample_min_width,
]
tile=self.encoder(tile)
row.append(tile)
rows.append(row)
result_rows= []
fori, rowinenumerate(rows):
result_row= []
forj, tileinenumerate(row):
ifi>0:
tile=self.blend_v(rows[i-1][j], tile, blend_height)
ifj>0:
tile=self.blend_h(row[j-1], tile, blend_width)
result_row.append(tile[:, :, :, :row_limit_height, :row_limit_width])
result_rows.append(torch.cat(result_row, dim=-1))
moments=torch.cat(result_rows, dim=-2)
returnmoments
deftiled_decode(self, z: torch.Tensor) ->torch.Tensor:
r"""
Decode a batch of images using a tiled decoder.
Args:
z (`torch.Tensor`): Input batch of latent vectors.
return_dict (`bool`, *optional*, defaults to `True`):
Whether or not to return a [`~models.vae.DecoderOutput`] instead of a plain tuple.
Returns:
[`~models.vae.DecoderOutput`] or `tuple`:
If return_dict is True, a [`~models.vae.DecoderOutput`] is returned, otherwise a plain `tuple` is
returned.
"""
_, _, _, height, width=z.shape
overlap_height=int(self.tile_latent_min_height* (1-self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
overlap_width=int(self.tile_latent_min_width* (1-self.tile_overlap_factor)) # 8 * (1 - 0.25) = 6
blend_height=int(self.tile_sample_min_height*self.tile_overlap_factor) # 256 * 0.25 = 64
blend_width=int(self.tile_sample_min_width*self.tile_overlap_factor) # 256 * 0.25 = 64
row_limit_height=self.tile_sample_min_height-blend_height# 256 - 64 = 192
row_limit_width=self.tile_sample_min_width-blend_width# 256 - 64 = 192

Suggested fix:
Use sample-space stride/overlap for encode iteration, latent-space crop sizes for encoded tiles, latent-space stride for decode iteration, and sample-space crop sizes for decoded tiles. The existing tile_sample_stride_height / tile_sample_stride_width fields should either be used or removed.

Issue 5: Base VAE shortcut projection is applied to the wrong tensor

Affected code:

defforward(self, x):
# Apply shortcut connection
residual=x
# First normalization and activation
x=self.norm1(x)
x=self.nonlinearity(x)
x=self.conv1(x)
x=self.norm2(x)
x=self.nonlinearity(x)
x=self.conv2(x)
ifself.conv_shortcutisnotNone:
x=self.conv_shortcut(x)
# Add residual connection
returnx+residual

Problem:
When in_channels != out_channels, HunyuanImageResnetBlock.forward() applies conv_shortcut to x after conv2, not to the saved residual.

Impact:
Non-default serialized configs with downsample_match_channel=False or upsample_match_channel=False crash when a resnet block changes channels.

Reproduction:

importtorchfromdiffusersimportAutoencoderKLHunyuanImagevae=AutoencoderKLHunyuanImage(
in_channels=3, out_channels=3, latent_channels=4, block_out_channels=(32, 64),
layers_per_block=1, spatial_compression_ratio=2, sample_size=16,
downsample_match_channel=False,
)
vae.encode(torch.randn(1, 3, 16, 16))

Relevant precedent:

defforward(self, hidden_states: torch.Tensor) ->torch.Tensor:
residual=hidden_states
hidden_states=self.norm1(hidden_states)
hidden_states=self.nonlinearity(hidden_states)
hidden_states=self.conv1(hidden_states)
hidden_states=self.norm2(hidden_states)
hidden_states=self.nonlinearity(hidden_states)
hidden_states=self.conv2(hidden_states)
ifself.conv_shortcutisnotNone:
residual=self.conv_shortcut(residual)
returnhidden_states+residual

Suggested fix:

ifself.conv_shortcutisnotNone:
residual=self.conv_shortcut(residual)
returnx+residual

Issue 6: Transformer silently truncates latent sizes not divisible by patch_size

Affected code:

post_patch_sizes=tuple(d//pford, pinzip(sizes, self.config.patch_size))
# 1. RoPE
image_rotary_emb=self.rope(hidden_states)
# 2. Conditional embeddings
encoder_attention_mask=encoder_attention_mask.bool()
temb=self.time_guidance_embed(timestep, guidance=guidance, timestep_r=timestep_r)
hidden_states=self.x_embedder(hidden_states)
encoder_hidden_states=self.context_embedder(encoder_hidden_states, timestep, encoder_attention_mask)
ifself.context_embedder_2isnotNoneandencoder_hidden_states_2isnotNone:
encoder_hidden_states_2=self.context_embedder_2(encoder_hidden_states_2)
encoder_attention_mask_2=encoder_attention_mask_2.bool()
# reorder and combine text tokens: combine valid tokens first, then padding
new_encoder_hidden_states= []
new_encoder_attention_mask= []
fortext, text_mask, text_2, text_mask_2inzip(
encoder_hidden_states, encoder_attention_mask, encoder_hidden_states_2, encoder_attention_mask_2
):
# Concatenate: [valid_mllm, valid_byt5, invalid_mllm, invalid_byt5]
new_encoder_hidden_states.append(
torch.cat(
[
text_2[text_mask_2], # valid byt5
text[text_mask], # valid mllm
text_2[~text_mask_2], # invalid byt5
text[~text_mask], # invalid mllm
],
dim=0,
)
)
# Apply same reordering to attention masks
new_encoder_attention_mask.append(
torch.cat(
[
text_mask_2[text_mask_2],
text_mask[text_mask],
text_mask_2[~text_mask_2],
text_mask[~text_mask],
],
dim=0,
)
)
encoder_hidden_states=torch.stack(new_encoder_hidden_states)
encoder_attention_mask=torch.stack(new_encoder_attention_mask)
attention_mask=torch.nn.functional.pad(encoder_attention_mask, (hidden_states.shape[1], 0), value=True)
attention_mask=attention_mask.unsqueeze(1).unsqueeze(2)
# 3. Transformer blocks
iftorch.is_grad_enabled() andself.gradient_checkpointing:
forblockinself.transformer_blocks:
hidden_states, encoder_hidden_states=self._gradient_checkpointing_func(
block,
hidden_states,
encoder_hidden_states,
temb,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
)
forblockinself.single_transformer_blocks:
hidden_states, encoder_hidden_states=self._gradient_checkpointing_func(
block,
hidden_states,
encoder_hidden_states,
temb,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
)
else:
forblockinself.transformer_blocks:
hidden_states, encoder_hidden_states=block(
hidden_states,
encoder_hidden_states,
temb,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
)
forblockinself.single_transformer_blocks:
hidden_states, encoder_hidden_states=block(
hidden_states,
encoder_hidden_states,
temb,
attention_mask=attention_mask,
image_rotary_emb=image_rotary_emb,
)
# 4. Output projection
hidden_states=self.norm_out(hidden_states, temb)
hidden_states=self.proj_out(hidden_states)
# 5. unpatchify
# reshape: [batch_size, *post_patch_dims, channels, *patch_size]
out_channels=self.config.out_channels
reshape_dims= [batch_size] +list(post_patch_sizes) + [out_channels] +list(self.config.patch_size)
hidden_states=hidden_states.reshape(*reshape_dims)
# create permutation pattern: batch, channels, then interleave post_patch and patch dims
# For 4D: [0, 3, 1, 4, 2, 5] -> batch, channels, post_patch_height, patch_size_height, post_patch_width, patch_size_width
# For 5D: [0, 4, 1, 5, 2, 6, 3, 7] -> batch, channels, post_patch_frame, patch_size_frame, post_patch_height, patch_size_height, post_patch_width, patch_size_width
ndim=len(post_patch_sizes)
permute_pattern= [0, ndim+1] # batch, channels
foriinrange(ndim):
permute_pattern.extend([i+1, ndim+2+i]) # post_patch_sizes[i], patch_sizes[i]
hidden_states=hidden_states.permute(*permute_pattern)
# flatten patch dimensions: flatten each (post_patch_size, patch_size) pair
# batch_size, channels, post_patch_sizes[0] * patch_sizes[0], post_patch_sizes[1] * patch_sizes[1], ...
final_dims= [batch_size, out_channels] + [
post_patch*patchforpost_patch, patchinzip(post_patch_sizes, self.config.patch_size)
]
hidden_states=hidden_states.reshape(*final_dims)

ifheight% (self.vae_scale_factor*2) !=0orwidth% (self.vae_scale_factor*2) !=0:
logger.warning(
f"`height` and `width` have to be divisible by {self.vae_scale_factor*2} but are {height} and {width}. Dimensions will be resized accordingly"
)
ifcallback_on_step_end_tensor_inputsisnotNoneandnotall(
kinself._callback_tensor_inputsforkincallback_on_step_end_tensor_inputs
):
raiseValueError(
f"`callback_on_step_end_tensor_inputs` has to be in {self._callback_tensor_inputs}, but found {[kforkincallback_on_step_end_tensor_inputsifknotinself._callback_tensor_inputs]}"
)
ifpromptisnotNoneandprompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `prompt`: {prompt} and `prompt_embeds`: {prompt_embeds}. Please make sure to"
" only forward one of the two."
)
elifpromptisNoneandprompt_embedsisNone:
raiseValueError(
"Provide either `prompt` or `prompt_embeds`. Cannot leave both `prompt` and `prompt_embeds` undefined."
)
elifpromptisnotNoneand (notisinstance(prompt, str) andnotisinstance(prompt, list)):
raiseValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
ifnegative_promptisnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt`: {negative_prompt} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
ifprompt_embedsisnotNoneandprompt_embeds_maskisNone:
raiseValueError(
"If `prompt_embeds` are provided, `prompt_embeds_mask` also have to be passed. Make sure to generate `prompt_embeds_mask` from the same text encoder that was used to generate `prompt_embeds`."
)
ifnegative_prompt_embedsisnotNoneandnegative_prompt_embeds_maskisNone:
raiseValueError(
"If `negative_prompt_embeds` are provided, `negative_prompt_embeds_mask` also have to be passed. Make sure to generate `negative_prompt_embeds_mask` from the same text encoder that was used to generate `negative_prompt_embeds`."
)
ifpromptisNoneandprompt_embeds_2isNone:
raiseValueError(
"Provide either `prompt` or `prompt_embeds_2`. Cannot leave both `prompt` and `prompt_embeds_2` undefined."
)
ifprompt_embeds_2isnotNoneandprompt_embeds_mask_2isNone:
raiseValueError(
"If `prompt_embeds_2` are provided, `prompt_embeds_mask_2` also have to be passed. Make sure to generate `prompt_embeds_mask_2` from the same text encoder that was used to generate `prompt_embeds_2`."
)
ifnegative_prompt_embeds_2isnotNoneandnegative_prompt_embeds_mask_2isNone:
raiseValueError(
"If `negative_prompt_embeds_2` are provided, `negative_prompt_embeds_mask_2` also have to be passed. Make sure to generate `negative_prompt_embeds_mask_2` from the same text encoder that was used to generate `negative_prompt_embeds_2`."
)
defprepare_latents(
self,
batch_size,
num_channels_latents,
height,
width,
dtype,
device,
generator,
latents=None,
):
height=int(height) //self.vae_scale_factor
width=int(width) //self.vae_scale_factor
shape= (batch_size, num_channels_latents, height, width)
iflatentsisnotNone:
returnlatents.to(device=device, dtype=dtype)
ifisinstance(generator, list) andlen(generator) !=batch_size:
raiseValueError(
f"You have passed a list of generators of length {len(generator)}, but requested an effective batch"
f" size of {batch_size}. Make sure the batch size matches the length of the generators."
)
latents=randn_tensor(shape, generator=generator, device=device, dtype=dtype)
returnlatents

ifheight% (self.vae_scale_factor*2) !=0orwidth% (self.vae_scale_factor*2) !=0:
logger.warning(
f"`height` and `width` have to be divisible by {self.vae_scale_factor*2} but are {height} and {width}. Dimensions will be resized accordingly"
)
ifcallback_on_step_end_tensor_inputsisnotNoneandnotall(
kinself._callback_tensor_inputsforkincallback_on_step_end_tensor_inputs
):
raiseValueError(
f"`callback_on_step_end_tensor_inputs` has to be in {self._callback_tensor_inputs}, but found {[kforkincallback_on_step_end_tensor_inputsifknotinself._callback_tensor_inputs]}"
)
ifpromptisnotNoneandprompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `prompt`: {prompt} and `prompt_embeds`: {prompt_embeds}. Please make sure to"
" only forward one of the two."
)
elifpromptisNoneandprompt_embedsisNone:
raiseValueError(
"Provide either `prompt` or `prompt_embeds`. Cannot leave both `prompt` and `prompt_embeds` undefined."
)
elifpromptisnotNoneand (notisinstance(prompt, str) andnotisinstance(prompt, list)):
raiseValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}")
ifnegative_promptisnotNoneandnegative_prompt_embedsisnotNone:
raiseValueError(
f"Cannot forward both `negative_prompt`: {negative_prompt} and `negative_prompt_embeds`:"
f" {negative_prompt_embeds}. Please make sure to only forward one of the two."
)
ifprompt_embedsisnotNoneandprompt_embeds_maskisNone:
raiseValueError(
"If `prompt_embeds` are provided, `prompt_embeds_mask` also have to be passed. Make sure to generate `prompt_embeds_mask` from the same text encoder that was used to generate `prompt_embeds`."
)
ifnegative_prompt_embedsisnotNoneandnegative_prompt_embeds_maskisNone:
raiseValueError(
"If `negative_prompt_embeds` are provided, `negative_prompt_embeds_mask` also have to be passed. Make sure to generate `negative_prompt_embeds_mask` from the same text encoder that was used to generate `negative_prompt_embeds`."
)
defprepare_latents(
self,
image_latents,
batch_size,
num_channels_latents,
height,
width,
dtype,
device,
generator,
latents=None,
strength=0.25,
):
height=int(height) //self.vae_scale_factor
width=int(width) //self.vae_scale_factor
shape= (batch_size, num_channels_latents, 1, height, width)
iflatentsisNone:
latents=randn_tensor(shape, generator=generator, device=device, dtype=dtype)
else:
latents=latents.to(device=device, dtype=dtype)
ifbatch_size>image_latents.shape[0] andbatch_size%image_latents.shape[0] ==0:
# expand init_latents for batch_size
additional_image_per_prompt=batch_size//image_latents.shape[0]
image_latents=torch.cat([image_latents] *additional_image_per_prompt, dim=0)
elifbatch_size>image_latents.shape[0] andbatch_size%image_latents.shape[0] !=0:
raiseValueError(
f"Cannot duplicate `image` of batch size {image_latents.shape[0]} to {batch_size} text prompts."
)
ifisinstance(generator, list) andlen(generator) !=batch_size:
raiseValueError(
f"You have passed a list of generators of length {len(generator)}, but requested an effective batch"
f" size of {batch_size}. Make sure the batch size matches the length of the generators."
)
noise=randn_tensor(shape, generator=generator, device=device, dtype=dtype)
cond_latents=strength*noise+ (1-strength) *image_latents
returnlatents, cond_latents

Problem:
The transformer computes post_patch_sizes = d // p and unpatchifies to post_patch * patch, silently dropping remainder pixels. The pipelines warn about divisibility but do not round latent sizes to the transformer patch grid.

Impact:
Custom configs with patch_size > 1 can produce a model output smaller than the input latents, leading to scheduler shape errors or silent shape loss when the model is used directly.

Reproduction:

importtorchfromdiffusersimportHunyuanImageTransformer2DModelmodel=HunyuanImageTransformer2DModel(
in_channels=4, out_channels=4, num_attention_heads=1, attention_head_dim=4,
num_layers=0, num_single_layers=0, num_refiner_layers=0,
patch_size=(2, 2), text_embed_dim=4, rope_axes_dim=(2, 2),
)
out=model(
hidden_states=torch.randn(1, 4, 3, 3),
timestep=torch.ones(1),
encoder_hidden_states=torch.randn(1, 1, 4),
encoder_attention_mask=torch.ones(1, 1, dtype=torch.long),
).sampleprint(out.shape) # torch.Size([1, 4, 2, 2])

Relevant precedent:

# latent height and width to be divisible by 2.
height=2* (int(height) // (vae_scale_factor*2))
width=2* (int(width) // (vae_scale_factor*2))

Suggested fix:

ifany(size%patch!=0forsize, patchinzip(sizes, self.config.patch_size)):
raiseValueError(f"`hidden_states` spatial/temporal sizes {sizes} must be divisible by patch_size {self.config.patch_size}.")

Issue 7: Base VAE has checkpointing code but disables the public capability flag

Affected code:

defforward(self, x: torch.Tensor) ->torch.Tensor:
x=self.conv_in(x)
## downsamples
fordown_blockinself.down_blocks:
iftorch.is_grad_enabled() andself.gradient_checkpointing:
x=self._gradient_checkpointing_func(down_block, x)
else:
x=down_block(x)
## middle
iftorch.is_grad_enabled() andself.gradient_checkpointing:
x=self._gradient_checkpointing_func(self.mid_block, x)

defforward(self, x: torch.Tensor) ->torch.Tensor:
h=self.conv_in(x) +x.repeat_interleave(repeats=self.repeat, dim=1)
iftorch.is_grad_enabled() andself.gradient_checkpointing:
h=self._gradient_checkpointing_func(self.mid_block, h)
else:
h=self.mid_block(h)
forup_blockinself.up_blocks:
iftorch.is_grad_enabled() andself.gradient_checkpointing:
h=self._gradient_checkpointing_func(up_block, h)

Problem:
The encoder and decoder contain self._gradient_checkpointing_func(...) branches, but AutoencoderKLHunyuanImage._supports_gradient_checkpointing is False.

Impact:
Training or fine-tuning code cannot enable gradient checkpointing on the base VAE even though the implementation paths exist.

Reproduction:

fromdiffusersimportAutoencoderKLHunyuanImagevae=AutoencoderKLHunyuanImage(
in_channels=3, out_channels=3, latent_channels=4, block_out_channels=(32,),
layers_per_block=1, spatial_compression_ratio=1, sample_size=8,
)
vae.enable_gradient_checkpointing()

Relevant precedent:

Suggested fix:

_supports_gradient_checkpointing=True

Issue 8: Test coverage is incomplete, including missing slow tests

Affected code:

classHunyuanImagePipelineFastTests(
PipelineTesterMixin,
FirstBlockCacheTesterMixin,
unittest.TestCase,
):
pipeline_class=HunyuanImagePipeline

Problem:
There is one fast pipeline test class for HunyuanImagePipeline. I found no model fast tests for HunyuanImageTransformer2DModel, AutoencoderKLHunyuanImage, or AutoencoderKLHunyuanImageRefiner, no fast tests for HunyuanImageRefinerPipeline, and no @slow tests for this family.

Impact:
The broken VAE encode tiling, refiner tiling, refiner pipeline paths, model patch-size behavior, and default scaling-factor failure are not covered.

Reproduction:

frompathlibimportPathfiles=list(Path("tests").rglob("*.py"))
hunyuan_files= [pforpinfilesif"hunyuan_image"instr(p).lower() or"hunyuanimage"inp.read_text(encoding="utf-8", errors="ignore")]
print([str(p) forpinhunyuan_files])
print("has_refiner_pipeline_test", any("HunyuanImageRefinerPipeline"inp.read_text(encoding="utf-8", errors="ignore") forpinhunyuan_files))
print("has_slow_test", any("@slow"inp.read_text(encoding="utf-8", errors="ignore") forpinhunyuan_files))

Relevant precedent:
Other families keep slow pipeline tests alongside fast tests, for example:

Suggested fix:
Add focused model tests under tests/models/ for the transformer and both VAEs, add fast refiner pipeline tests with tiny components, and add at least one slow test for the base and refiner pretrained checkpoints. I attempted the existing fast Hunyuan test file locally with .venv, but collection failed before running tests because this environment's PyTorch build lacks torch._C._distributed_c10d.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions