Skip to content

Commit 4e0466f

Browse files
fix: image preprocessing for cosmos3 (#14519)
fix: preserve Cosmos3 conditioning image aspect ratio
1 parent ac56fa2 commit 4e0466f

5 files changed

Lines changed: 164 additions & 14 deletions

File tree

docs/source/en/api/pipelines/cosmos3.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ result.video[0].save("cosmos3_t2i.jpg", format="JPEG", quality=85)
205205

206206
## Image-to-video
207207

208-
Pass a conditioning image via `image=`. The pipeline anchors frame 0 to the supplied image and denoises the rest. Upsample with `--mode image2video` to produce the JSON prompt.
208+
Pass a conditioning image via `image=`. The pipeline anchors frame 0 to the supplied image and denoises the rest. The image is resized while preserving its aspect ratio, center-cropped to the requested output size, and normalized with uint8-equivalent rounding. Upsample with `--mode image2video` to produce the JSON prompt.
209209

210210
<hfoptions id="model">
211211
<hfoption id="Nano">

src/diffusers/modular_pipelines/cosmos/encoders.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import math
2+
3+
import numpy as np
14
import torch
5+
import torch.nn.functional as F
6+
from PIL import Image
27
from transformers import AutoTokenizer
38

49
from ...configuration_utils import FrozenDict
@@ -17,6 +22,49 @@
1722
logger = logging.get_logger(__name__)
1823

1924

25+
# Copied from diffusers.pipelines.cosmos.pipeline_cosmos3_omni._preprocess_conditioning_image
26+
def _preprocess_conditioning_image(
27+
image: Image.Image | np.ndarray | torch.Tensor, height: int, width: int
28+
) -> torch.Tensor:
29+
"""Preprocess one Cosmos3 conditioning image to ``[1, 3, H, W]`` in ``[-1, 1]``."""
30+
if isinstance(image, Image.Image):
31+
image = torch.from_numpy(np.array(image.convert("RGB"), copy=True)).permute(2, 0, 1).unsqueeze(0)
32+
elif isinstance(image, np.ndarray):
33+
image = torch.from_numpy(image)
34+
image = image.unsqueeze(0) if image.ndim == 3 else image
35+
image = image.permute(0, 3, 1, 2)
36+
else:
37+
image = image.unsqueeze(0) if image.ndim == 3 else image
38+
39+
if image.ndim != 4 or image.shape[0] != 1 or image.shape[1] != 3:
40+
raise ValueError(f"`image` must describe one RGB image, got shape {tuple(image.shape)}.")
41+
42+
is_integer_input = not image.is_floating_point()
43+
image = image.to(dtype=torch.float32)
44+
if not is_integer_input:
45+
if image.min() < 0:
46+
image = (image + 1.0) * 127.5
47+
elif image.max() <= 1.0:
48+
image = image * 255.0
49+
50+
source_height, source_width = image.shape[-2:]
51+
scale = max(width / source_width, height / source_height)
52+
resized_height = math.ceil(scale * source_height)
53+
resized_width = math.ceil(scale * source_width)
54+
image = F.interpolate(
55+
image,
56+
size=(resized_height, resized_width),
57+
mode="bilinear",
58+
align_corners=False,
59+
antialias=True,
60+
)
61+
crop_top = round((resized_height - height) / 2)
62+
crop_left = round((resized_width - width) / 2)
63+
image = image[:, :, crop_top : crop_top + height, crop_left : crop_left + width]
64+
image = image.round().clamp(0, 255) / 127.5 - 1.0
65+
return image
66+
67+
2068
# Transfer conditions on control signals (edge/blur/depth/seg/wsm), so it uses its own system prompt instead of the
2169
# plain image/video ones. Defined here (not on the task pipeline) so the transfer text block is self-contained.
2270
_SYSTEM_PROMPT_TRANSFER = (
@@ -587,12 +635,6 @@ def description(self) -> str:
587635
def expected_components(self) -> list[ComponentSpec]:
588636
return [
589637
ComponentSpec("vae", AutoencoderKLWan),
590-
ComponentSpec(
591-
"video_processor",
592-
VideoProcessor,
593-
config=FrozenDict({"vae_scale_factor": 16, "resample": "bilinear"}),
594-
default_creation_method="from_config",
595-
),
596638
]
597639

598640
@property
@@ -645,7 +687,7 @@ def __call__(self, components: Cosmos3OmniModularPipeline, state: PipelineState)
645687
f"`height` and `width` must be multiples of {sf}, got ({block_state.height}, {block_state.width})."
646688
)
647689

648-
conditioning_frame_2d = components.video_processor.preprocess(
690+
conditioning_frame_2d = _preprocess_conditioning_image(
649691
block_state.image, height=block_state.height, width=block_state.width
650692
).to(device=device, dtype=dtype)
651693

src/diffusers/pipelines/cosmos/pipeline_cosmos3_omni.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,48 @@ def __init__(self, *args, **kwargs):
5252
)
5353

5454

55+
def _preprocess_conditioning_image(
56+
image: Image.Image | np.ndarray | torch.Tensor, height: int, width: int
57+
) -> torch.Tensor:
58+
"""Preprocess one Cosmos3 conditioning image to ``[1, 3, H, W]`` in ``[-1, 1]``."""
59+
if isinstance(image, Image.Image):
60+
image = torch.from_numpy(np.array(image.convert("RGB"), copy=True)).permute(2, 0, 1).unsqueeze(0)
61+
elif isinstance(image, np.ndarray):
62+
image = torch.from_numpy(image)
63+
image = image.unsqueeze(0) if image.ndim == 3 else image
64+
image = image.permute(0, 3, 1, 2)
65+
else:
66+
image = image.unsqueeze(0) if image.ndim == 3 else image
67+
68+
if image.ndim != 4 or image.shape[0] != 1 or image.shape[1] != 3:
69+
raise ValueError(f"`image` must describe one RGB image, got shape {tuple(image.shape)}.")
70+
71+
is_integer_input = not image.is_floating_point()
72+
image = image.to(dtype=torch.float32)
73+
if not is_integer_input:
74+
if image.min() < 0:
75+
image = (image + 1.0) * 127.5
76+
elif image.max() <= 1.0:
77+
image = image * 255.0
78+
79+
source_height, source_width = image.shape[-2:]
80+
scale = max(width / source_width, height / source_height)
81+
resized_height = math.ceil(scale * source_height)
82+
resized_width = math.ceil(scale * source_width)
83+
image = F.interpolate(
84+
image,
85+
size=(resized_height, resized_width),
86+
mode="bilinear",
87+
align_corners=False,
88+
antialias=True,
89+
)
90+
crop_top = round((resized_height - height) / 2)
91+
crop_left = round((resized_width - width) / 2)
92+
image = image[:, :, crop_top : crop_top + height, crop_left : crop_left + width]
93+
image = image.round().clamp(0, 255) / 127.5 - 1.0
94+
return image
95+
96+
5597
# ============================================================================
5698
# Sequence layout: data structures + builders for the joint token sequence
5799
# ============================================================================
@@ -714,7 +756,7 @@ def _remove_action_video_padding_from_latent(
714756

715757
def prepare_latents(
716758
self,
717-
image: torch.Tensor | None = None,
759+
image: Image.Image | np.ndarray | torch.Tensor | None = None,
718760
video: list[Image.Image] | torch.Tensor | np.ndarray | None = None,
719761
condition_frame_indexes_vision: Iterable[int] = (0, 1),
720762
condition_video_keep: Literal["first", "last"] = "first",
@@ -754,10 +796,9 @@ def prepare_latents(
754796
# Video-to-video conditioning: a top-level `video` without an action run.
755797
has_video_condition = video is not None and action is None
756798

757-
# video_processor.preprocess handles PIL/np/tensor → [1, 3, H, W] in [-1, 1], resized to (height, width).
758799
conditioning_frame_2d: torch.Tensor | None = None
759800
if image is not None:
760-
conditioning_frame_2d = self.video_processor.preprocess(image, height=height, width=width).to(
801+
conditioning_frame_2d = _preprocess_conditioning_image(image, height=height, width=width).to(
761802
device=device, dtype=dtype
762803
)
763804

@@ -1272,7 +1313,7 @@ def __call__(
12721313
self,
12731314
prompt: str | list[str],
12741315
negative_prompt: str | list[str] | None = None,
1275-
image: torch.Tensor | None = None,
1316+
image: Image.Image | np.ndarray | torch.Tensor | None = None,
12761317
video: list[Image.Image] | torch.Tensor | np.ndarray | None = None,
12771318
condition_frame_indexes_vision: Iterable[int] = (0, 1),
12781319
condition_video_keep: Literal["first", "last"] = "first",
@@ -1314,9 +1355,10 @@ def __call__(
13141355
per call.
13151356
negative_prompt (`str` or `List[str]`, *optional*):
13161357
The negative prompt used for classifier-free guidance. When `None`, the empty string is used.
1317-
image (`torch.Tensor` or `PIL.Image.Image`, *optional*):
1358+
image (`PIL.Image.Image`, `np.ndarray`, or `torch.Tensor`, *optional*):
13181359
Optional conditioning frame for image-to-video. The pipeline anchors frame 0 to this image and denoises
1319-
the remaining frames. Ignored when `num_frames == 1`. Not used for action runs (pass `action` instead).
1360+
the remaining frames. The image is resized while preserving its aspect ratio, then center-cropped to
1361+
`height` and `width`. Ignored when `num_frames == 1`. Not used for action runs (pass `action` instead).
13201362
Mutually exclusive with `video`.
13211363
video (`List[PIL.Image.Image]`, `torch.Tensor`, or `np.ndarray`, *optional*):
13221364
Optional conditioning clip for video-to-video. The leading frames are kept clean at the latent indexes

tests/modular_pipelines/cosmos/test_modular_pipeline_cosmos3.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import numpy as np
1617
import pytest
1718
import torch
1819
from PIL import Image
@@ -208,6 +209,35 @@ def test_vae_encoder_is_standalone_and_validates_conditioning_inputs(self):
208209
with pytest.raises(ValueError, match="image-to-image generation is not supported"):
209210
pipe(**inputs, output=self.output_name)
210211

212+
def test_image_encoder_uses_native_aspect_preserving_center_crop(self):
213+
pipe = self.get_pipeline()
214+
image_encoder = pipe.blocks.sub_blocks["vae_encoder"].sub_blocks["image_conditioning"]
215+
image_pipe = image_encoder.init_pipeline(self.pretrained_model_name_or_path)
216+
image_pipe.load_components(dtype=torch.float32)
217+
218+
image = np.zeros((32, 64, 3), dtype=np.uint8)
219+
image[:, :16] = [255, 0, 0]
220+
image[:, 16:48] = [0, 255, 0]
221+
image[:, 48:] = [0, 0, 255]
222+
center_crop = Image.fromarray(image[:, 16:48])
223+
224+
wide_outputs = image_pipe(
225+
image=Image.fromarray(image),
226+
num_frames=5,
227+
height=32,
228+
width=32,
229+
output=["x0_tokens_vision"],
230+
)
231+
crop_outputs = image_pipe(
232+
image=center_crop,
233+
num_frames=5,
234+
height=32,
235+
width=32,
236+
output=["x0_tokens_vision"],
237+
)
238+
239+
torch.testing.assert_close(wide_outputs["x0_tokens_vision"], crop_outputs["x0_tokens_vision"])
240+
211241
@pytest.mark.parametrize("prompt_name", ["prompt", "negative_prompt"])
212242
def test_rejects_batched_prompts(self, prompt_name):
213243
pipe = self.get_pipeline()

tests/pipelines/cosmos/test_cosmos3.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
import unittest
1616
from unittest import mock
1717

18+
import numpy as np
1819
import torch
20+
from PIL import Image
1921
from transformers import AutoTokenizer
2022

2123
from diffusers import AutoencoderKLWan, Cosmos3OmniPipeline, Cosmos3OmniTransformer, UniPCMultistepScheduler
24+
from diffusers.pipelines.cosmos.pipeline_cosmos3_omni import _preprocess_conditioning_image
2225

2326
from ...testing_utils import enable_full_determinism, torch_device
2427
from ..pipeline_params import TEXT_TO_IMAGE_BATCH_PARAMS, TEXT_TO_IMAGE_PARAMS
@@ -130,6 +133,39 @@ def test_cosmos3_tokenize_prompt_uses_checkpoint_system_prompt_default(self):
130133

131134
assert all(call.args[0][0]["role"] == "user" for call in apply_chat_template.call_args_list)
132135

136+
def test_i2v_image_preprocessing_preserves_aspect_ratio(self):
137+
image = np.zeros((2, 4, 3), dtype=np.uint8)
138+
image[:, 0] = [255, 0, 0]
139+
image[:, 1] = [0, 255, 0]
140+
image[:, 2] = [0, 0, 255]
141+
image[:, 3] = [255, 255, 255]
142+
143+
actual = _preprocess_conditioning_image(Image.fromarray(image), height=2, width=2)
144+
expected_pixels = torch.tensor(
145+
[[[[0, 0], [0, 0]], [[255, 0], [255, 0]], [[0, 255], [0, 255]]]], dtype=torch.float32
146+
)
147+
expected = expected_pixels / 127.5 - 1.0
148+
149+
torch.testing.assert_close(actual, expected)
150+
151+
def test_i2v_pipeline_uses_native_preprocessing(self):
152+
pipeline = self.pipeline_class(**self.get_dummy_components()).to(torch_device)
153+
pipeline.set_progress_bar_config(disable=None)
154+
155+
image = np.zeros((16, 32, 3), dtype=np.uint8)
156+
image[:, :8] = [255, 0, 0]
157+
image[:, 8:24] = [0, 255, 0]
158+
image[:, 24:] = [0, 0, 255]
159+
center_crop = Image.fromarray(image[:, 8:24])
160+
inputs = self.get_dummy_inputs(torch_device)
161+
inputs.update(image=Image.fromarray(image), num_frames=5, output_type="latent")
162+
163+
wide_output = pipeline(**inputs).video
164+
inputs.update(image=center_crop, generator=torch.Generator(device="cpu").manual_seed(0))
165+
crop_output = pipeline(**inputs).video
166+
167+
torch.testing.assert_close(wide_output, crop_output)
168+
133169
@unittest.skip("Cosmos3 currently supports one prompt per pipeline call.")
134170
def test_inference_batch_consistent(self):
135171
pass

0 commit comments

Comments
 (0)