Skip to content

Commit 45b32f4

Browse files
authored
[tests] refactor l-series pipeline tests (#14637)
* refactor l-series pipeline tests * define sets explicitly.
1 parent 45ff6a6 commit 45b32f4

22 files changed

Lines changed: 1336 additions & 2331 deletions

‎src/diffusers/pipelines/latent_diffusion/pipeline_latent_diffusion.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ def __call__(
113113
generation. Can be used to tweak the same generation with different prompts. If not provided, a latents
114114
tensor is generated by sampling using the supplied random `generator`.
115115
output_type (`str`, *optional*, defaults to `"pil"`):
116-
The output format of the generated image. Choose between `PIL.Image` or `np.array`.
116+
The output format of the generated image. Choose between `"pil"` (`PIL.Image`), `"np"` (`np.array`) or
117+
`"pt"` (`torch.Tensor`).
117118
return_dict (`bool`, *optional*, defaults to `True`):
118119
Whether or not to return a [`ImagePipelineOutput`] instead of a plain tuple.
119120
@@ -220,9 +221,12 @@ def __call__(
220221
image = self.vqvae.decode(latents).sample
221222

222223
image = (image / 2 + 0.5).clamp(0, 1)
223-
image = image.cpu().permute(0, 2, 3, 1).numpy()
224-
if output_type == "pil":
225-
image = self.numpy_to_pil(image)
224+
225+
if output_type != "pt":
226+
image = image.cpu().permute(0, 2, 3, 1).numpy()
227+
228+
if output_type == "pil":
229+
image = self.numpy_to_pil(image)
226230

227231
if not return_dict:
228232
return (image,)

‎src/diffusers/pipelines/latent_diffusion/pipeline_latent_diffusion_superresolution.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ def __call__(
9797
A [`torch.Generator`](https://pytorch.org/docs/stable/generated/torch.Generator.html) to make
9898
generation deterministic.
9999
output_type (`str`, *optional*, defaults to `"pil"`):
100-
The output format of the generated image. Choose between `PIL.Image` or `np.array`.
100+
The output format of the generated image. Choose between `"pil"` (`PIL.Image`), `"np"` (`np.array`) or
101+
`"pt"` (`torch.Tensor`).
101102
return_dict (`bool`, *optional*, defaults to `True`):
102103
Whether or not to return a [`ImagePipelineOutput`] instead of a plain tuple.
103104
@@ -185,10 +186,12 @@ def __call__(
185186
image = self.vqvae.decode(latents).sample
186187
image = torch.clamp(image, -1.0, 1.0)
187188
image = image / 2 + 0.5
188-
image = image.cpu().permute(0, 2, 3, 1).numpy()
189189

190-
if output_type == "pil":
191-
image = self.numpy_to_pil(image)
190+
if output_type != "pt":
191+
image = image.cpu().permute(0, 2, 3, 1).numpy()
192+
193+
if output_type == "pil":
194+
image = self.numpy_to_pil(image)
192195

193196
if not return_dict:
194197
return (image,)

‎tests/pipelines/latent_consistency_models/test_latent_consistency_models.py‎

Lines changed: 120 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1+
# coding=utf-8
2+
# Copyright 2026 HuggingFace Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
116
import gc
2-
import inspect
3-
import unittest
417

518
import numpy as np
19+
import pytest
620
import torch
721
from transformers import CLIPTextConfig, CLIPTextModel, CLIPTokenizer
822

@@ -14,27 +28,43 @@
1428
)
1529

1630
from ...testing_utils import (
31+
assert_tensors_close,
1732
backend_empty_cache,
1833
enable_full_determinism,
1934
require_torch_accelerator,
2035
slow,
2136
torch_device,
2237
)
23-
from ..pipeline_params import TEXT_TO_IMAGE_BATCH_PARAMS, TEXT_TO_IMAGE_IMAGE_PARAMS, TEXT_TO_IMAGE_PARAMS
24-
from ..test_pipelines_common import IPAdapterTesterMixin, PipelineLatentTesterMixin, PipelineTesterMixin
38+
from ..testing_utils import (
39+
BasePipelineTesterConfig,
40+
IPAdapterTesterMixin,
41+
LoraMemoryTesterMixin,
42+
LoraTesterMixin,
43+
MemoryTesterMixin,
44+
PipelineTesterMixin,
45+
UNetLoraTesterMixin,
46+
)
2547

2648

2749
enable_full_determinism()
2850

2951

30-
class LatentConsistencyModelPipelineFastTests(
31-
IPAdapterTesterMixin, PipelineLatentTesterMixin, PipelineTesterMixin, unittest.TestCase
32-
):
52+
class LatentConsistencyModelPipelineTesterConfig(BasePipelineTesterConfig):
3353
pipeline_class = LatentConsistencyModelPipeline
34-
params = TEXT_TO_IMAGE_PARAMS - {"negative_prompt", "negative_prompt_embeds"}
35-
batch_params = TEXT_TO_IMAGE_BATCH_PARAMS - {"negative_prompt"}
36-
image_params = TEXT_TO_IMAGE_IMAGE_PARAMS
37-
image_latents_params = TEXT_TO_IMAGE_IMAGE_PARAMS
54+
# The canonical text-to-image sets minus `negative_prompt` / `negative_prompt_embeds`: LCM is
55+
# guidance-distilled and `__call__` takes no negative prompt.
56+
required_input_params_in_call_signature = frozenset(
57+
[
58+
"prompt",
59+
"height",
60+
"width",
61+
"guidance_scale",
62+
"prompt_embeds",
63+
"cross_attention_kwargs",
64+
]
65+
)
66+
batch_input_params = frozenset(["prompt"])
67+
output_shape = (3, 64, 64)
3868

3969
def get_dummy_components(self):
4070
torch.manual_seed(0)
@@ -82,7 +112,7 @@ def get_dummy_components(self):
82112
text_encoder = CLIPTextModel(text_encoder_config)
83113
tokenizer = CLIPTokenizer.from_pretrained("hf-internal-testing/tiny-random-clip")
84114

85-
components = {
115+
return {
86116
"unet": unet,
87117
"scheduler": scheduler,
88118
"vae": vae,
@@ -93,155 +123,144 @@ def get_dummy_components(self):
93123
"image_encoder": None,
94124
"requires_safety_checker": False,
95125
}
96-
return components
97-
98-
def get_dummy_inputs(self, device, seed=0):
99-
if str(device).startswith("mps"):
100-
generator = torch.manual_seed(seed)
101-
else:
102-
generator = torch.Generator(device=device).manual_seed(seed)
103-
inputs = {
126+
127+
def get_dummy_inputs(self):
128+
return {
104129
"prompt": "A painting of a squirrel eating a burger",
105-
"generator": generator,
130+
"generator": self.get_generator(0),
106131
"num_inference_steps": 2,
107132
"guidance_scale": 6.0,
108-
"output_type": "np",
133+
# Request torch outputs so tests compare torch tensors directly (see `BasePipelineTesterConfig`).
134+
# Note `"pt"` images are `(batch, channels, height, width)`, unlike `"np"` (`(batch, h, w, c)`).
135+
"output_type": "pt",
109136
}
110-
return inputs
111137

112-
def test_ip_adapter(self):
113-
expected_pipe_slice = None
114-
if torch_device == "cpu":
115-
expected_pipe_slice = np.array([0.1405, 0.5002, 0.5213, 0.1223, 0.3856, 0.4165, 0.5382, 0.3622, 0.3693])
116-
return super().test_ip_adapter(expected_pipe_slice=expected_pipe_slice)
117138

139+
class TestLatentConsistencyModelPipeline(LatentConsistencyModelPipelineTesterConfig, PipelineTesterMixin):
118140
def test_lcm_onestep(self):
119-
device = "cpu" # ensure determinism for the device-dependent torch.Generator
120-
121-
components = self.get_dummy_components()
122-
pipe = LatentConsistencyModelPipeline(**components)
123-
pipe = pipe.to(torch_device)
124-
pipe.set_progress_bar_config(disable=None)
141+
# Run on CPU: the expected slice below is CPU-specific.
142+
pipe = self.get_pipeline()
125143

126-
inputs = self.get_dummy_inputs(device)
144+
inputs = self.get_dummy_inputs()
127145
inputs["num_inference_steps"] = 1
128-
output = pipe(**inputs)
129-
image = output.images
130-
assert image.shape == (1, 64, 64, 3)
146+
image = pipe(**inputs).images
147+
assert image.shape == (1, *self.output_shape)
131148

132-
image_slice = image[0, -3:, -3:, -1]
133-
expected_slice = np.array([0.1444, 0.5229, 0.5344, 0.1384, 0.3999, 0.4320, 0.5345, 0.3559, 0.3685])
134-
assert np.abs(image_slice.flatten() - expected_slice).max() < 1e-3
149+
# fmt: off
150+
expected_slice = torch.tensor([0.1444, 0.5229, 0.5344, 0.1384, 0.3999, 0.4320, 0.5345, 0.3559, 0.3685])
151+
# fmt: on
152+
assert_tensors_close(image[0, -1, -3:, -3:].flatten(), expected_slice, atol=1e-3)
135153

136154
def test_lcm_multistep(self):
137-
device = "cpu" # ensure determinism for the device-dependent torch.Generator
138-
139-
components = self.get_dummy_components()
140-
pipe = LatentConsistencyModelPipeline(**components)
141-
pipe = pipe.to(torch_device)
142-
pipe.set_progress_bar_config(disable=None)
155+
# Run on CPU: the expected slice below is CPU-specific.
156+
pipe = self.get_pipeline()
143157

144-
inputs = self.get_dummy_inputs(device)
145-
output = pipe(**inputs)
146-
image = output.images
147-
assert image.shape == (1, 64, 64, 3)
158+
image = pipe(**self.get_dummy_inputs()).images
159+
assert image.shape == (1, *self.output_shape)
148160

149-
image_slice = image[0, -3:, -3:, -1]
150-
expected_slice = np.array([0.1405, 0.5002, 0.5213, 0.1223, 0.3856, 0.4165, 0.5382, 0.3622, 0.3693])
151-
assert np.abs(image_slice.flatten() - expected_slice).max() < 1e-3
161+
# fmt: off
162+
expected_slice = torch.tensor([0.1405, 0.5002, 0.5213, 0.1223, 0.3856, 0.4165, 0.5382, 0.3622, 0.3693])
163+
# fmt: on
164+
assert_tensors_close(image[0, -1, -3:, -3:].flatten(), expected_slice, atol=1e-3)
152165

153166
def test_lcm_custom_timesteps(self):
154-
device = "cpu" # ensure determinism for the device-dependent torch.Generator
167+
# Run on CPU: the expected slice below is CPU-specific.
168+
pipe = self.get_pipeline()
155169

156-
components = self.get_dummy_components()
157-
pipe = LatentConsistencyModelPipeline(**components)
158-
pipe = pipe.to(torch_device)
159-
pipe.set_progress_bar_config(disable=None)
160-
161-
inputs = self.get_dummy_inputs(device)
170+
inputs = self.get_dummy_inputs()
162171
del inputs["num_inference_steps"]
163172
inputs["timesteps"] = [999, 499]
164-
output = pipe(**inputs)
165-
image = output.images
166-
assert image.shape == (1, 64, 64, 3)
173+
image = pipe(**inputs).images
174+
assert image.shape == (1, *self.output_shape)
167175

168-
image_slice = image[0, -3:, -3:, -1]
169-
expected_slice = np.array([0.1405, 0.5002, 0.5213, 0.1223, 0.3856, 0.4165, 0.5382, 0.3622, 0.3693])
170-
assert np.abs(image_slice.flatten() - expected_slice).max() < 1e-3
176+
# Custom timesteps matching the default 2-step schedule reproduce `test_lcm_multistep`'s output.
177+
# fmt: off
178+
expected_slice = torch.tensor([0.1405, 0.5002, 0.5213, 0.1223, 0.3856, 0.4165, 0.5382, 0.3622, 0.3693])
179+
# fmt: on
180+
assert_tensors_close(image[0, -1, -3:, -3:].flatten(), expected_slice, atol=1e-3)
171181

172-
def test_inference_batch_single_identical(self):
173-
super().test_inference_batch_single_identical(expected_max_diff=5e-4)
182+
def test_inference_batch_single_identical(self, batch_size=3, expected_max_diff=5e-4):
183+
super().test_inference_batch_single_identical(batch_size=batch_size, expected_max_diff=expected_max_diff)
174184

175-
# skip because lcm pipeline apply cfg differently
185+
@pytest.mark.skip("LCM applies classifier-free guidance differently, so the shared CFG callback test cannot run.")
176186
def test_callback_cfg(self):
177187
pass
178188

179-
# override default test because the final latent variable is "denoised" instead of "latents"
180189
def test_callback_inputs(self):
181-
sig = inspect.signature(self.pipeline_class.__call__)
190+
# Overridden because the final latent variable is `denoised` rather than `latents`.
191+
pipe = self.get_pipeline().to(torch_device)
182192

183-
if not ("callback_on_step_end_tensor_inputs" in sig.parameters and "callback_on_step_end" in sig.parameters):
184-
return
185-
186-
components = self.get_dummy_components()
187-
pipe = self.pipeline_class(**components)
188-
pipe = pipe.to(torch_device)
189-
pipe.set_progress_bar_config(disable=None)
190-
191-
self.assertTrue(
192-
hasattr(pipe, "_callback_tensor_inputs"),
193-
f" {self.pipeline_class} should have `_callback_tensor_inputs` that defines a list of tensor variables its callback function can use as inputs",
193+
assert hasattr(pipe, "_callback_tensor_inputs"), (
194+
f"{self.pipeline_class} should have `_callback_tensor_inputs` that defines a list of tensor variables "
195+
"its callback function can use as inputs"
194196
)
195197

196198
def callback_inputs_test(pipe, i, t, callback_kwargs):
197-
missing_callback_inputs = set()
198-
for v in pipe._callback_tensor_inputs:
199-
if v not in callback_kwargs:
200-
missing_callback_inputs.add(v)
201-
self.assertTrue(
202-
len(missing_callback_inputs) == 0, f"Missing callback tensor inputs: {missing_callback_inputs}"
203-
)
204-
last_i = pipe.num_timesteps - 1
205-
if i == last_i:
199+
missing_callback_inputs = {v for v in pipe._callback_tensor_inputs if v not in callback_kwargs}
200+
assert len(missing_callback_inputs) == 0, f"Missing callback tensor inputs: {missing_callback_inputs}"
201+
if i == pipe.num_timesteps - 1:
206202
callback_kwargs["denoised"] = torch.zeros_like(callback_kwargs["denoised"])
207203
return callback_kwargs
208204

209-
inputs = self.get_dummy_inputs(torch_device)
205+
inputs = self.get_dummy_inputs()
210206
inputs["callback_on_step_end"] = callback_inputs_test
211207
inputs["callback_on_step_end_tensor_inputs"] = pipe._callback_tensor_inputs
212208
inputs["output_type"] = "latent"
213209

214210
output = pipe(**inputs)[0]
215211
assert output.abs().sum() == 0
216212

217-
def test_encode_prompt_works_in_isolation(self):
213+
def test_encode_prompt_works_in_isolation(self, extra_required_param_value_dict=None, atol=1e-4, rtol=1e-4):
214+
# `encode_prompt` requires `device` and `do_classifier_free_guidance`, neither of which `__call__`
215+
# exposes with a default for the shared test to pick up.
218216
extra_required_param_value_dict = {
219217
"device": torch.device(torch_device).type,
220-
"do_classifier_free_guidance": self.get_dummy_inputs(device=torch_device).get("guidance_scale", 1.0) > 1.0,
218+
"do_classifier_free_guidance": self.get_dummy_inputs().get("guidance_scale", 1.0) > 1.0,
221219
}
222-
return super().test_encode_prompt_works_in_isolation(extra_required_param_value_dict)
220+
super().test_encode_prompt_works_in_isolation(extra_required_param_value_dict, atol=atol, rtol=rtol)
221+
222+
223+
class TestLatentConsistencyModelPipelineMemory(LatentConsistencyModelPipelineTesterConfig, MemoryTesterMixin):
224+
"""Memory optimization tests (CPU offload, group offload, layerwise casting) for the LCM pipeline."""
225+
226+
227+
class TestLatentConsistencyModelPipelineIPAdapter(LatentConsistencyModelPipelineTesterConfig, IPAdapterTesterMixin):
228+
"""IP-Adapter tests for the LCM pipeline."""
229+
230+
231+
class TestLatentConsistencyModelPipelineLoRA(
232+
LatentConsistencyModelPipelineTesterConfig, LoraTesterMixin, UNetLoraTesterMixin
233+
):
234+
"""LoRA tests for the LCM pipeline."""
235+
236+
237+
class TestLatentConsistencyModelPipelineLoRAMemory(LatentConsistencyModelPipelineTesterConfig, LoraMemoryTesterMixin):
238+
"""LoRA x memory-optimization tests (group offload, CPU offload) for the LCM pipeline."""
223239

224240

225241
@slow
226242
@require_torch_accelerator
227-
class LatentConsistencyModelPipelineSlowTests(unittest.TestCase):
228-
def setUp(self):
243+
class TestLatentConsistencyModelPipelineIntegration:
244+
@pytest.fixture(autouse=True)
245+
def cleanup(self):
246+
gc.collect()
247+
backend_empty_cache(torch_device)
248+
yield
229249
gc.collect()
230250
backend_empty_cache(torch_device)
231251

232252
def get_inputs(self, device, generator_device="cpu", dtype=torch.float32, seed=0):
233253
generator = torch.Generator(device=generator_device).manual_seed(seed)
234254
latents = np.random.RandomState(seed).standard_normal((1, 4, 64, 64))
235255
latents = torch.from_numpy(latents).to(device=device, dtype=dtype)
236-
inputs = {
256+
return {
237257
"prompt": "a photograph of an astronaut riding a horse",
238258
"latents": latents,
239259
"generator": generator,
240260
"num_inference_steps": 3,
241261
"guidance_scale": 7.5,
242262
"output_type": "np",
243263
}
244-
return inputs
245264

246265
def test_lcm_onestep(self):
247266
pipe = LatentConsistencyModelPipeline.from_pretrained("SimianLuo/LCM_Dreamshaper_v7", safety_checker=None)
@@ -264,8 +283,7 @@ def test_lcm_multistep(self):
264283
pipe = pipe.to(torch_device)
265284
pipe.set_progress_bar_config(disable=None)
266285

267-
inputs = self.get_inputs(torch_device)
268-
image = pipe(**inputs).images
286+
image = pipe(**self.get_inputs(torch_device)).images
269287
assert image.shape == (1, 512, 512, 3)
270288

271289
image_slice = image[0, -3:, -3:, -1].flatten()

0 commit comments

Comments
 (0)