Skip to content

Commit f9495c7

Browse files
authored
[tests] refactor v-series pipeline tests (#14653)
refactor v-series pipeline tests
1 parent e86072f commit f9495c7

4 files changed

Lines changed: 91 additions & 383 deletions

File tree

src/diffusers/pipelines/visualcloze/pipeline_visualcloze_combined.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ def __init__(
146146
transformer=transformer,
147147
scheduler=scheduler,
148148
)
149+
# `resolution` is not a module, so it has to be registered explicitly to survive a
150+
# `save_pretrained` / `from_pretrained` round-trip.
151+
self.register_to_config(resolution=resolution)
149152

150153
self.generation_pipe = VisualClozeGenerationPipeline(
151154
vae=vae,
@@ -376,6 +379,10 @@ def __call__(
376379
output_type=output_type if upsampling_strength == 0 else "pil",
377380
)
378381
if upsampling_strength == 0:
382+
# Offload all models. The inner pipelines free their own (empty) hooks, so the ones installed on this
383+
# pipeline by `enable_model_cpu_offload` have to be freed here.
384+
self.maybe_free_model_hooks()
385+
379386
if not return_dict:
380387
return (generation_output,)
381388

@@ -434,6 +441,9 @@ def __call__(
434441
else:
435442
output = image
436443

444+
# Offload all models
445+
self.maybe_free_model_hooks()
446+
437447
if not return_dict:
438448
return (output,)
439449

src/diffusers/pipelines/visualcloze/pipeline_visualcloze_generation.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ def __init__(
175175
transformer=transformer,
176176
scheduler=scheduler,
177177
)
178+
# `resolution` is not a module, so it has to be registered explicitly to survive a
179+
# `save_pretrained` / `from_pretrained` round-trip.
180+
self.register_to_config(resolution=resolution)
178181
self.resolution = resolution
179182
self.vae_scale_factor = 2 ** (len(self.vae.config.block_out_channels) - 1) if getattr(self, "vae", None) else 8
180183
# Flux latents are turned into 2x2 patches and packed. This means the latent width and height has to be divisible
@@ -715,8 +718,9 @@ def __call__(
715718
Pre-generated pooled text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting.
716719
If not provided, pooled text embeddings will be generated from `prompt` input argument.
717720
output_type (`str`, *optional*, defaults to `"pil"`):
718-
The output format of the generate image. Choose between
719-
[PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image` or `np.array`.
721+
The output format of the generate image. Choose between `"pil"`
722+
([PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image`), `"np"` (`np.array`) or `"pt"`
723+
(`torch.Tensor`).
720724
return_dict (`bool`, *optional*, defaults to `True`):
721725
Whether or not to return a [`~pipelines.flux.FluxPipelineOutput`] instead of a plain tuple.
722726
joint_attention_kwargs (`dict`, *optional*):
@@ -907,11 +911,17 @@ def __call__(
907911
if cur_target_position[i]:
908912
if output_type == "pil":
909913
cropped.append(cur_image.crop((start, 0, start + size[1], size[0])))
914+
elif output_type == "pt":
915+
# `"pt"` images are `(channels, height, width)`, unlike the `(height, width, channels)`
916+
# layout of `"np"`, so the spatial crop applies to the last two axes.
917+
cropped.append(cur_image[:, 0 : size[0], start : start + size[1]])
910918
else:
911919
cropped.append(cur_image[0 : size[0], start : start + size[1]])
912920
start += size[1]
913921
image.append(cropped)
914-
if output_type != "pil":
922+
if output_type == "pt":
923+
image = torch.stack([arr for sub_image in image for arr in sub_image], dim=0)
924+
elif output_type != "pil":
915925
image = np.concatenate([arr[None] for sub_image in image for arr in sub_image], axis=0)
916926

917927
# Offload all models
Lines changed: 39 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,20 @@
11
import random
2-
import tempfile
3-
import unittest
42

53
import numpy as np
4+
import pytest
65
import torch
76
from PIL import Image
87
from transformers import AutoConfig, AutoTokenizer, CLIPTextConfig, CLIPTextModel, CLIPTokenizer, T5EncoderModel
98

10-
import diffusers
119
from diffusers import AutoencoderKL, FlowMatchEulerDiscreteScheduler, FluxTransformer2DModel, VisualClozePipeline
12-
from diffusers.utils import logging
1310

14-
from ...testing_utils import (
15-
CaptureLogger,
16-
enable_full_determinism,
17-
floats_tensor,
18-
require_accelerator,
19-
torch_device,
20-
)
21-
from ..test_pipelines_common import PipelineTesterMixin, to_np
11+
from ...testing_utils import floats_tensor, torch_device
12+
from ..testing_utils import BasePipelineTesterConfig, MemoryTesterMixin, PipelineTesterMixin
2213

2314

24-
enable_full_determinism()
25-
26-
27-
class VisualClozePipelineFastTests(unittest.TestCase, PipelineTesterMixin):
15+
class VisualClozePipelineTesterConfig(BasePipelineTesterConfig):
2816
pipeline_class = VisualClozePipeline
29-
params = frozenset(
17+
required_input_params_in_call_signature = frozenset(
3018
[
3119
"task_prompt",
3220
"content_prompt",
@@ -38,10 +26,8 @@ class VisualClozePipelineFastTests(unittest.TestCase, PipelineTesterMixin):
3826
"upsampling_strength",
3927
]
4028
)
41-
batch_params = frozenset(["task_prompt", "content_prompt", "image"])
42-
test_xformers_attention = False
43-
test_layerwise_casting = True
44-
test_group_offloading = True
29+
batch_input_params = frozenset(["task_prompt", "content_prompt", "image"])
30+
output_shape = (3, 32, 32)
4531

4632
def get_dummy_components(self):
4733
torch.manual_seed(0)
@@ -109,7 +95,7 @@ def get_dummy_components(self):
10995
"resolution": 32,
11096
}
11197

112-
def get_dummy_inputs(self, device, seed=0):
98+
def get_dummy_inputs(self, seed=0):
11399
# Create example images to simulate the input format required by VisualCloze
114100
context_image = [
115101
Image.fromarray(floats_tensor((32, 32, 3), rng=random.Random(seed), scale=255).numpy().astype(np.uint8))
@@ -128,44 +114,41 @@ def get_dummy_inputs(self, device, seed=0):
128114
query_image, # Query image
129115
]
130116

131-
if str(device).startswith("mps"):
132-
generator = torch.manual_seed(seed)
133-
else:
134-
generator = torch.Generator(device="cpu").manual_seed(seed)
135-
136117
inputs = {
137118
"task_prompt": "Each row outlines a logical process, starting from [IMAGE1] gray-based depth map with detailed object contours, to achieve [IMAGE2] an image with flawless clarity.",
138119
"content_prompt": "A beautiful landscape with mountains and a lake",
139120
"image": image,
140-
"generator": generator,
121+
"generator": self.get_generator(seed),
141122
"num_inference_steps": 2,
142123
"guidance_scale": 5.0,
143124
"upsampling_height": 32,
144125
"upsampling_width": 32,
145126
"max_sequence_length": 77,
146-
"output_type": "np",
147127
"upsampling_strength": 0.4,
128+
# Request torch outputs so tests compare torch tensors directly (see `BasePipelineTesterConfig`).
129+
# Note `"pt"` images are `(batch, channels, height, width)`, unlike `"np"` (`(batch, h, w, c)`).
130+
"output_type": "pt",
148131
}
149132
return inputs
150133

151-
def test_visualcloze_different_prompts(self):
152-
pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device)
153134

154-
inputs = self.get_dummy_inputs(torch_device)
155-
output_same_prompt = pipe(**inputs).images[0]
135+
class TestVisualClozePipeline(VisualClozePipelineTesterConfig, PipelineTesterMixin):
136+
def test_visualcloze_different_task_prompts(self, expected_min_diff=1e-1):
137+
pipe = self.get_pipeline().to(torch_device)
156138

157-
inputs = self.get_dummy_inputs(torch_device)
158-
inputs["task_prompt"] = "A different task to perform."
159-
output_different_prompts = pipe(**inputs).images[0]
139+
inputs = self.get_dummy_inputs()
140+
output_original = pipe(**inputs).images[0]
160141

161-
max_diff = np.abs(output_same_prompt - output_different_prompts).max()
142+
inputs["task_prompt"] = "A different task description for image generation"
143+
output_different_task = pipe(**inputs).images[0]
162144

163-
# Outputs should be different
164-
assert max_diff > 1e-6
145+
# Different task prompts should produce different outputs
146+
max_diff = (output_original - output_different_task).abs().max()
147+
assert max_diff > expected_min_diff, "Outputs should be different for different task prompts."
165148

166149
def test_visualcloze_image_output_shape(self):
167-
pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device)
168-
inputs = self.get_dummy_inputs(torch_device)
150+
pipe = self.get_pipeline().to(torch_device)
151+
inputs = self.get_dummy_inputs()
169152

170153
height_width_pairs = [(32, 32), (72, 57)]
171154
for height, width in height_width_pairs:
@@ -174,15 +157,14 @@ def test_visualcloze_image_output_shape(self):
174157

175158
inputs.update({"upsampling_height": height, "upsampling_width": width})
176159
image = pipe(**inputs).images[0]
177-
output_height, output_width, _ = image.shape
178-
assert (output_height, output_width) == (expected_height, expected_width)
179-
180-
def test_inference_batch_single_identical(self):
181-
self._test_inference_batch_single_identical(expected_max_diff=1e-3)
160+
_, output_height, output_width = image.shape
161+
assert (output_height, output_width) == (expected_height, expected_width), (
162+
f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}"
163+
)
182164

183165
def test_upsampling_strength(self, expected_min_diff=1e-1):
184-
pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device)
185-
inputs = self.get_dummy_inputs(torch_device)
166+
pipe = self.get_pipeline().to(torch_device)
167+
inputs = self.get_dummy_inputs()
186168

187169
# Test different upsampling strengths
188170
inputs["upsampling_strength"] = 0.2
@@ -192,159 +174,19 @@ def test_upsampling_strength(self, expected_min_diff=1e-1):
192174
output_full_upsampling = pipe(**inputs).images[0]
193175

194176
# Different upsampling strengths should produce different outputs
195-
max_diff = np.abs(output_no_upsampling - output_full_upsampling).max()
196-
assert max_diff > expected_min_diff
197-
198-
def test_different_task_prompts(self, expected_min_diff=1e-1):
199-
pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device)
200-
inputs = self.get_dummy_inputs(torch_device)
201-
202-
output_original = pipe(**inputs).images[0]
203-
204-
inputs["task_prompt"] = "A different task description for image generation"
205-
output_different_task = pipe(**inputs).images[0]
177+
max_diff = (output_no_upsampling - output_full_upsampling).abs().max()
178+
assert max_diff > expected_min_diff, "Outputs should be different for different upsampling strengths."
206179

207-
# Different task prompts should produce different outputs
208-
max_diff = np.abs(output_original - output_different_task).max()
209-
assert max_diff > expected_min_diff
180+
def test_inference_batch_single_identical(self, batch_size=3, expected_max_diff=1e-3):
181+
super().test_inference_batch_single_identical(batch_size=batch_size, expected_max_diff=expected_max_diff)
210182

211-
@unittest.skip(
212-
"Test not applicable because the pipeline being tested is a wrapper pipeline. CFG tests should be done on the inner pipelines."
183+
@pytest.mark.skip(
184+
"The pipeline being tested is a wrapper around two inner pipelines, so `guidance_scale` is not tracked on it. "
185+
"CFG tests should be done on the inner pipelines."
213186
)
214187
def test_callback_cfg(self):
215188
pass
216189

217-
def test_save_load_local(self, expected_max_difference=1e-3):
218-
components = self.get_dummy_components()
219-
pipe = self.pipeline_class(**components)
220-
for component in pipe.components.values():
221-
if hasattr(component, "set_default_attn_processor"):
222-
component.set_default_attn_processor()
223-
224-
pipe.to(torch_device)
225-
pipe.set_progress_bar_config(disable=None)
226-
227-
inputs = self.get_dummy_inputs(torch_device)
228-
output = pipe(**inputs)[0]
229-
230-
logger = logging.get_logger("diffusers.pipelines.pipeline_utils")
231-
logger.setLevel(diffusers.logging.INFO)
232-
233-
with tempfile.TemporaryDirectory() as tmpdir:
234-
pipe.save_pretrained(tmpdir, safe_serialization=False)
235-
236-
with CaptureLogger(logger) as cap_logger:
237-
# NOTE: Resolution must be set to 32 for loading otherwise will lead to OOM on CI hardware
238-
# This attribute is not serialized in the config of the pipeline
239-
pipe_loaded = self.pipeline_class.from_pretrained(tmpdir, resolution=32)
240-
241-
for component in pipe_loaded.components.values():
242-
if hasattr(component, "set_default_attn_processor"):
243-
component.set_default_attn_processor()
244190

245-
for name in pipe_loaded.components.keys():
246-
if name not in pipe_loaded._optional_components:
247-
assert name in str(cap_logger)
248-
249-
pipe_loaded.to(torch_device)
250-
pipe_loaded.set_progress_bar_config(disable=None)
251-
252-
inputs = self.get_dummy_inputs(torch_device)
253-
output_loaded = pipe_loaded(**inputs)[0]
254-
255-
max_diff = np.abs(to_np(output) - to_np(output_loaded)).max()
256-
self.assertLess(max_diff, expected_max_difference)
257-
258-
def test_save_load_optional_components(self, expected_max_difference=1e-4):
259-
if not hasattr(self.pipeline_class, "_optional_components"):
260-
return
261-
components = self.get_dummy_components()
262-
for key in components:
263-
if "text_encoder" in key and hasattr(components[key], "eval"):
264-
components[key].eval()
265-
pipe = self.pipeline_class(**components)
266-
for component in pipe.components.values():
267-
if hasattr(component, "set_default_attn_processor"):
268-
component.set_default_attn_processor()
269-
pipe.to(torch_device)
270-
pipe.set_progress_bar_config(disable=None)
271-
272-
# set all optional components to None
273-
for optional_component in pipe._optional_components:
274-
setattr(pipe, optional_component, None)
275-
276-
generator_device = "cpu"
277-
inputs = self.get_dummy_inputs(generator_device)
278-
torch.manual_seed(0)
279-
output = pipe(**inputs)[0]
280-
281-
with tempfile.TemporaryDirectory() as tmpdir:
282-
pipe.save_pretrained(tmpdir, safe_serialization=False)
283-
# NOTE: Resolution must be set to 32 for loading otherwise will lead to OOM on CI hardware
284-
# This attribute is not serialized in the config of the pipeline
285-
pipe_loaded = self.pipeline_class.from_pretrained(tmpdir, resolution=32)
286-
for component in pipe_loaded.components.values():
287-
if hasattr(component, "set_default_attn_processor"):
288-
component.set_default_attn_processor()
289-
pipe_loaded.to(torch_device)
290-
pipe_loaded.set_progress_bar_config(disable=None)
291-
292-
for optional_component in pipe._optional_components:
293-
self.assertTrue(
294-
getattr(pipe_loaded, optional_component) is None,
295-
f"`{optional_component}` did not stay set to None after loading.",
296-
)
297-
298-
inputs = self.get_dummy_inputs(generator_device)
299-
torch.manual_seed(0)
300-
output_loaded = pipe_loaded(**inputs)[0]
301-
302-
max_diff = np.abs(to_np(output) - to_np(output_loaded)).max()
303-
self.assertLess(max_diff, expected_max_difference)
304-
305-
@unittest.skipIf(torch_device not in ["cuda", "xpu"], reason="float16 requires CUDA or XPU")
306-
@require_accelerator
307-
def test_save_load_float16(self, expected_max_diff=1e-2):
308-
components = self.get_dummy_components()
309-
for name, module in components.items():
310-
if hasattr(module, "half"):
311-
components[name] = module.to(torch_device).half()
312-
313-
pipe = self.pipeline_class(**components)
314-
for component in pipe.components.values():
315-
if hasattr(component, "set_default_attn_processor"):
316-
component.set_default_attn_processor()
317-
pipe.to(torch_device)
318-
pipe.set_progress_bar_config(disable=None)
319-
320-
inputs = self.get_dummy_inputs(torch_device)
321-
output = pipe(**inputs)[0]
322-
323-
with tempfile.TemporaryDirectory() as tmpdir:
324-
pipe.save_pretrained(tmpdir)
325-
# NOTE: Resolution must be set to 32 for loading otherwise will lead to OOM on CI hardware
326-
# This attribute is not serialized in the config of the pipeline
327-
pipe_loaded = self.pipeline_class.from_pretrained(tmpdir, torch_dtype=torch.float16, resolution=32)
328-
for component in pipe_loaded.components.values():
329-
if hasattr(component, "set_default_attn_processor"):
330-
component.set_default_attn_processor()
331-
pipe_loaded.to(torch_device)
332-
pipe_loaded.set_progress_bar_config(disable=None)
333-
334-
for name, component in pipe_loaded.components.items():
335-
if hasattr(component, "dtype"):
336-
self.assertTrue(
337-
component.dtype == torch.float16,
338-
f"`{name}.dtype` switched from `float16` to {component.dtype} after loading.",
339-
)
340-
341-
inputs = self.get_dummy_inputs(torch_device)
342-
output_loaded = pipe_loaded(**inputs)[0]
343-
max_diff = np.abs(to_np(output) - to_np(output_loaded)).max()
344-
self.assertLess(
345-
max_diff, expected_max_diff, "The output of the fp16 pipeline changed after saving and loading."
346-
)
347-
348-
@unittest.skip("Test not supported.")
349-
def test_pipeline_with_accelerator_device_map(self):
350-
pass
191+
class TestVisualClozePipelineMemory(VisualClozePipelineTesterConfig, MemoryTesterMixin):
192+
"""Memory optimization tests (CPU offload, group offload, layerwise casting) for the VisualCloze pipeline."""

0 commit comments

Comments
 (0)