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+
116import gc
2- import inspect
3- import unittest
417
518import numpy as np
19+ import pytest
620import torch
721from transformers import CLIPTextConfig , CLIPTextModel , CLIPTokenizer
822
1428)
1529
1630from ...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
2749enable_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