File size: 11,124 Bytes
9f818c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1

import math
from pathlib import Path
from typing import Literal

import numpy as np
import torch
import torchvision.io
import torchvision.transforms.functional as TF
from PIL import Image

from cosmos_framework.data.vfm.sequence_packing import SequencePlan
from cosmos_framework.data.vfm.utils import VIDEO_RES_SIZE_INFO


def resize_pil_image(image: Image.Image, max_size: int, padding_constant: int) -> Image.Image:
    """Resize a PIL image so the max side length is at most *max_size* and both
    dimensions are divisible by *padding_constant*.

    Args:
        image: Input PIL image.
        max_size: Maximum allowed side length (longest edge will be at most this).
        padding_constant: Both height and width are rounded down to the nearest
            multiple of this value.

    Returns:
        Resized PIL image.
    """
    orig_w, orig_h = image.size
    scale = max_size / max(orig_w, orig_h)
    new_w = int(orig_w * scale)
    new_h = int(orig_h * scale)
    new_w = (new_w // padding_constant) * padding_constant
    new_h = (new_h // padding_constant) * padding_constant
    new_w = max(new_w, padding_constant)
    new_h = max(new_h, padding_constant)
    return image.resize(
        (new_w, new_h),
        Image.LANCZOS,  # type: ignore
    )


def _resize_and_center_crop(frames: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
    """Aspect-ratio-preserving resize followed by center crop."""
    orig_h, orig_w = frames.shape[2], frames.shape[3]
    scaling_ratio = max(target_w / orig_w, target_h / orig_h)
    resize_h = int(math.ceil(scaling_ratio * orig_h))
    resize_w = int(math.ceil(scaling_ratio * orig_w))
    frames = TF.resize(frames, [resize_h, resize_w])  # [...,resize_h,resize_w]
    frames = TF.center_crop(frames, [target_h, target_w])  # [...,target_h,target_w]
    return frames


def load_conditioning_image_pixels(image_path: Path, target_h: int, target_w: int) -> torch.Tensor:
    """Load an image as resized/cropped uint8 pixels in ``[3, H, W]``."""
    with image_path.open("rb") as f:
        img = Image.open(f).convert("RGB")
    img_tensor = torch.from_numpy(np.array(img)).permute(2, 0, 1).float().unsqueeze(0)  # [1,3,H,W]
    img_tensor = _resize_and_center_crop(img_tensor, target_h, target_w)  # [1,3,target_h,target_w]
    return img_tensor.squeeze(0).round().clamp(0, 255).to(torch.uint8)  # [3,target_h,target_w]


def load_prompt_upsampling_image(image_path: Path, target_h: int, target_w: int) -> Image.Image:
    """Load an image as resized/cropped RGB PIL pixels for VLM prompt upsampling."""
    img_tensor = load_conditioning_image_pixels(image_path, target_h, target_w)  # [3,target_h,target_w]
    img_array = img_tensor.permute(1, 2, 0).contiguous().cpu().numpy()  # [target_h,target_w,3]
    return Image.fromarray(img_array, mode="RGB")


def load_conditioning_image(image_path: Path, target_h: int, target_w: int) -> torch.Tensor:
    """Load an image as conditioning frames from local or remote path; returns (3, 1, H, W) in [-1, 1]."""
    img_tensor = load_conditioning_image_pixels(image_path, target_h, target_w).float()  # [3,target_h,target_w]
    img_tensor = img_tensor / 127.5 - 1.0  # [3,target_h,target_w]
    return img_tensor.unsqueeze(1)  # [3,1,target_h,target_w]


def load_conditioning_video(
    video_path: Path,
    target_h: int,
    target_w: int,
    max_frames: int,
    *,
    keep: Literal["first", "last"] = "first",
) -> torch.Tensor:
    """Load video frames for conditioning; returns (3, T, H, W) in [-1, 1].

    ``keep`` selects which ``max_frames`` to take when the input is longer.
    """
    frames, _, _ = torchvision.io.read_video(str(video_path), pts_unit="sec")
    frames = frames[-max_frames:] if keep == "last" else frames[:max_frames]  # [T,H,W,3]
    frames_tchw = frames.permute(0, 3, 1, 2).float()  # [T,3,H,W]
    frames_resized = _resize_and_center_crop(frames_tchw, target_h, target_w)  # [T,3,target_h,target_w]
    frames_normalized = frames_resized / 127.5 - 1.0  # [T,3,target_h,target_w]
    return frames_normalized.permute(1, 0, 2, 3)  # [3,T,target_h,target_w]


def pil_to_conditioning_frames(pil_img: Image.Image) -> tuple[torch.Tensor, int, int]:
    """Convert a PIL image to a conditioning tensor in [-1, 1] and return (frames, h, w)."""
    w, h = pil_img.size
    img_tensor = torch.from_numpy(np.array(pil_img)).permute(2, 0, 1).float()  # [3,H,W]
    return (img_tensor / 127.5 - 1.0).unsqueeze(1), h, w  # [3,1,H,W]


def build_conditioned_video_batch(
    conditioning_frames: torch.Tensor,
    condition_frames_vision: list[int],
    w: int,
    h: int,
    num_frames: int,
    fps: float,
    batch_size: int = 1,
) -> dict:
    """Build a data batch with conditioning frames and sequence plans for generation."""
    t_cond = conditioning_frames.shape[1]
    video_data = torch.zeros(1, 3, num_frames, h, w, dtype=torch.bfloat16)  # [1,3,num_frames,h,w]
    t_fill = min(t_cond, num_frames)
    video_data[0, :, :t_fill, :, :] = conditioning_frames[:, :t_fill, :, :].to(dtype=torch.bfloat16)  # [3,t_fill,h,w]
    if t_fill < num_frames:
        video_data[0, :, t_fill:, :, :] = video_data[0, :, t_fill - 1 : t_fill, :, :].expand(
            -1, num_frames - t_fill, -1, -1
        )  # [3,num_frames-t_fill,h,w]
    video_list = [video_data.cuda() for _ in range(batch_size)]  # list of [1,3,num_frames,h,w]
    image_size = [torch.tensor([[h, w, h, w]], dtype=torch.float32).cuda() for _ in range(batch_size)]  # list of [1,4]
    sequence_plans = [
        SequencePlan(has_text=True, has_vision=True, condition_frame_indexes_vision=list(condition_frames_vision))
        for _ in range(batch_size)
    ]
    return {
        "dataset_name": "video_data",
        "video": video_list,
        "image_size": image_size,
        "t5_text_embeddings": torch.randn(batch_size, 512, 1024).cuda().to(dtype=torch.bfloat16),  # [B,512,1024]
        "fps": torch.full((batch_size,), float(fps)).cuda(),  # [B]
        "conditioning_fps": torch.full((batch_size,), float(fps)).cuda(),  # [B]
        "num_frames": torch.full((batch_size,), num_frames).cuda(),  # [B]
        "is_preprocessed": True,
        "sequence_plan": sequence_plans,
    }


def build_image_edit_batch(
    conditioning_frames: torch.Tensor,
    h: int,
    w: int,
    batch_size: int = 1,
) -> dict:
    """Build a data batch for image-to-image editing."""
    image = conditioning_frames.unsqueeze(0).cuda().to(dtype=torch.bfloat16)  # [1,3,1,h,w]
    sequence_plans = [
        SequencePlan(has_text=True, has_vision=True, condition_frame_indexes_vision=[]) for _ in range(batch_size)
    ]
    image_size = torch.tensor([[h, w, h, w]], dtype=torch.float32).cuda()  # [1,4]
    return {
        "dataset_name": "image_data",
        "images": [image, image] * batch_size,
        "image_size": [image_size, image_size] * batch_size,
        "num_frames": [torch.tensor([2], dtype=torch.int64).cuda() for _ in range(batch_size)],  # list of [1]
        "num_vision_items_per_sample": [2 for _ in range(batch_size)],
        "is_preprocessed": True,
        "sequence_plan": sequence_plans,
    }


_VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"}


def detect_aspect_ratio(width: int, height: int) -> str:
    """Return the closest supported aspect-ratio key for a frame size."""
    aspect_ratios = np.array([16 / 9, 4 / 3, 1, 3 / 4, 9 / 16])
    aspect_ratio_keys = ["16,9", "4,3", "1,1", "3,4", "9,16"]
    current = width / height
    return aspect_ratio_keys[int(np.argmin((aspect_ratios - current) ** 2))]


def read_media_frames(path: Path, max_frames: int) -> tuple[torch.Tensor, float]:
    """Read an image or video into a uint8 tensor of shape (C, T, H, W)."""
    ext = path.suffix.lower()
    if ext in _IMAGE_EXTENSIONS:
        with path.open("rb") as f:
            image = Image.open(f).convert("RGB")
        frames = torch.from_numpy(np.array(image)).permute(2, 0, 1).unsqueeze(1)
        return frames, 1.0
    if ext not in _VIDEO_EXTENSIONS:
        raise ValueError(f"Unsupported media extension: {ext}")
    return _read_video_frames(path, max_frames=max_frames)


def _read_video_frames(path: Path, max_frames: int) -> tuple[torch.Tensor, float]:
    """Read video frames through PyAV to avoid torchvision.io.read_video removal."""

    import av

    try:
        container = av.open(str(path))
    except (OSError, av.error.FFmpegError) as exc:
        raise OSError(f"Failed to open video {path}: {exc}") from exc

    try:
        stream = container.streams.video[0]
        fps = float(stream.average_rate) if stream.average_rate is not None else 24.0
        frames = []
        for frame in container.decode(stream):
            frames.append(torch.from_numpy(frame.to_rgb().to_ndarray()).permute(2, 0, 1))
            if len(frames) >= max_frames:
                break
    except (OSError, av.error.FFmpegError) as exc:
        raise OSError(f"Failed to decode video {path}: {exc}") from exc
    finally:
        container.close()

    if not frames:
        raise ValueError(f"No frames decoded from video {path}")
    return torch.stack(frames, dim=1).to(torch.uint8), fps


def read_and_resize_media(
    path: Path,
    *,
    resolution: str,
    aspect_ratio: str | None,
    max_frames: int,
) -> tuple[torch.Tensor, float, str, tuple[int, int]]:
    """Read an image/video and resize it to the requested resolution bucket."""
    raw_frames, fps = read_media_frames(path, max_frames=max_frames)
    original_hw = (raw_frames.shape[2], raw_frames.shape[3])
    detected_aspect_ratio = detect_aspect_ratio(raw_frames.shape[3], raw_frames.shape[2])
    final_aspect_ratio = aspect_ratio or detected_aspect_ratio
    width, height = VIDEO_RES_SIZE_INFO[resolution][final_aspect_ratio]
    resized = _resize_and_center_crop(raw_frames.permute(1, 0, 2, 3), height, width)
    return resized.permute(1, 0, 2, 3), fps, final_aspect_ratio, original_hw


def uint8_to_normalized_float(tensor: torch.Tensor, dtype: torch.dtype = torch.bfloat16) -> torch.Tensor:
    """Convert uint8 [0, 255] frames into normalized [-1, 1] frames."""
    return tensor.to(dtype=dtype) / 127.5 - 1.0


def pad_temporal_frames(frames: torch.Tensor, target_frames: int) -> torch.Tensor:
    """Pad a (C, T, H, W) tensor along time using reflection/repeat behavior."""
    num_frames = frames.shape[1]
    if num_frames >= target_frames:
        return frames
    if num_frames == 0:
        raise ValueError("Cannot pad an empty frame tensor.")
    padded = frames
    while padded.shape[1] < target_frames:
        pad_len = min(padded.shape[1] - 1, target_frames - padded.shape[1])
        if pad_len <= 0:
            pad_frame = padded[:, -1:].repeat(1, target_frames - padded.shape[1], 1, 1)
            padded = torch.cat([padded, pad_frame], dim=1)
            break
        padded = torch.cat([padded, padded.flip(dims=[1])[:, :pad_len]], dim=1)
    return padded