[diffusion][feature] Add LingBot-Video T2I and TI2V generation modes (#5311)
Signed-off-by: wtz2333 <2955110911@qq.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
11f633aa83
commit
36135ef09a
28 files changed
+2410
-98
No files matched your search
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.models.lingbot_video.image_condition import (
|
||||
apply_clean_prefix,
|
||||
geometry_align_image,
|
||||
prepare_ti2v_image_condition,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _gradient_image(width: int, height: int) -> Image.Image:
|
||||
x = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
|
||||
y = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
|
||||
rgb = np.stack(
|
||||
[
|
||||
np.broadcast_to(x, (height, width)),
|
||||
np.broadcast_to(y, (height, width)),
|
||||
np.full((height, width), 127, dtype=np.uint8),
|
||||
],
|
||||
axis=-1,
|
||||
)
|
||||
return Image.fromarray(rgb, mode="RGB")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_size", [(160, 64), (64, 160), (96, 96)])
|
||||
def test_geometry_align_image_supports_source_aspect_ratios(source_size):
|
||||
pixel = geometry_align_image(_gradient_image(*source_size), 64, 96)
|
||||
|
||||
assert pixel.shape == (1, 3, 1, 64, 96)
|
||||
assert pixel.dtype == torch.float32
|
||||
assert 0 <= pixel.min() <= pixel.max() <= 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_size", "target_size"),
|
||||
[((160, 64), (64, 96)), ((64, 160), (96, 64))],
|
||||
)
|
||||
def test_geometry_align_image_center_crops_without_stretching(source_size, target_size):
|
||||
width, height = source_size
|
||||
target_height, target_width = target_size
|
||||
rgb = np.full((height, width, 3), (255, 0, 0), dtype=np.uint8)
|
||||
if width > target_width:
|
||||
left = (width - target_width) // 2
|
||||
rgb[:, left : left + target_width] = (0, 255, 0)
|
||||
else:
|
||||
top = (height - target_height) // 2
|
||||
rgb[top : top + target_height] = (0, 255, 0)
|
||||
|
||||
pixel = geometry_align_image(
|
||||
Image.fromarray(rgb, mode="RGB"),
|
||||
target_height,
|
||||
target_width,
|
||||
)
|
||||
|
||||
assert pixel.shape == (1, 3, 1, target_height, target_width)
|
||||
expected_green = torch.tensor((0.0, 1.0, 0.0)).view(1, 3, 1, 1, 1)
|
||||
assert torch.equal(pixel, expected_green.expand_as(pixel))
|
||||
|
||||
|
||||
class _LatentDistribution:
|
||||
def __init__(self, latent: torch.Tensor):
|
||||
self.latent = latent
|
||||
|
||||
def sample(self, generator=None):
|
||||
del generator
|
||||
return self.latent
|
||||
|
||||
|
||||
class _CapturingVAE(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(()))
|
||||
self.config = SimpleNamespace(
|
||||
latents_mean=[0.0, 0.0, 0.0],
|
||||
latents_std=[1.0, 1.0, 1.0],
|
||||
)
|
||||
self.encoded_pixel = None
|
||||
|
||||
def encode(self, pixel):
|
||||
self.encoded_pixel = pixel.detach().clone()
|
||||
return SimpleNamespace(latent_dist=_LatentDistribution(pixel))
|
||||
|
||||
|
||||
def test_ti2v_vlm_and_vae_paths_share_geometry_aligned_image():
|
||||
vae = _CapturingVAE()
|
||||
condition = prepare_ti2v_image_condition(
|
||||
_gradient_image(160, 64),
|
||||
height=64,
|
||||
width=96,
|
||||
vae=vae,
|
||||
vision_patch_size=16,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
|
||||
vae_pixel = (vae.encoded_pixel + 1.0) / 2.0
|
||||
vae_image = vae_pixel[0, :, 0].permute(1, 2, 0).mul(255).byte().numpy()
|
||||
assert condition.vlm_image.size == (96, 64)
|
||||
np.testing.assert_allclose(
|
||||
np.asarray(condition.vlm_image),
|
||||
vae_image,
|
||||
rtol=0,
|
||||
atol=1,
|
||||
)
|
||||
|
||||
|
||||
def test_apply_clean_prefix_only_overwrites_condition_frames():
|
||||
latents = torch.arange(20, dtype=torch.float32).reshape(1, 1, 5, 2, 2)
|
||||
original_tail = latents[:, :, 2:].clone()
|
||||
clean = torch.full((1, 1, 2, 2, 2), -3.0)
|
||||
|
||||
returned = apply_clean_prefix(latents, clean)
|
||||
|
||||
assert returned is latents
|
||||
assert torch.equal(latents[:, :, :2], clean)
|
||||
assert torch.equal(latents[:, :, 2:], original_tail)
|
||||
|
||||
|
||||
def test_apply_clean_prefix_rejects_incompatible_shapes():
|
||||
with pytest.raises(ValueError, match="shape must match"):
|
||||
apply_clean_prefix(
|
||||
torch.zeros(1, 2, 3, 4, 4),
|
||||
torch.zeros(1, 3, 1, 4, 4),
|
||||
)
|
||||
@@ -6,9 +6,10 @@ from types import SimpleNamespace
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
@@ -22,6 +23,8 @@ def _make_pipeline():
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.default_negative_prompt = "default negative"
|
||||
pipeline.default_image_negative_prompt = "default image negative"
|
||||
pipeline.img_prompt_template = "<image>"
|
||||
pipeline.od_config = SimpleNamespace(flow_shift=None)
|
||||
return pipeline
|
||||
|
||||
@@ -37,17 +40,24 @@ def test_lingbot_video_pipeline_import_and_registry():
|
||||
LingBotVideoPipeline,
|
||||
LingBotVideoTransformer3DModel,
|
||||
get_lingbot_video_post_process_func,
|
||||
get_lingbot_video_pre_process_func,
|
||||
)
|
||||
from vllm_omni.diffusion.registry import (
|
||||
_DIFFUSION_MODELS,
|
||||
_DIFFUSION_POST_PROCESS_FUNCS,
|
||||
_DIFFUSION_PRE_PROCESS_FUNCS,
|
||||
)
|
||||
from vllm_omni.diffusion.registry import _DIFFUSION_MODELS, _DIFFUSION_POST_PROCESS_FUNCS
|
||||
|
||||
assert LingBotVideoPipeline is not None
|
||||
assert LingBotVideoTransformer3DModel is not None
|
||||
assert get_lingbot_video_pre_process_func is not None
|
||||
assert get_lingbot_video_post_process_func is not None
|
||||
assert _DIFFUSION_MODELS["LingBotVideoPipeline"] == (
|
||||
"lingbot_video",
|
||||
"pipeline_lingbot_video",
|
||||
"LingBotVideoPipeline",
|
||||
)
|
||||
assert _DIFFUSION_PRE_PROCESS_FUNCS["LingBotVideoPipeline"] == "get_lingbot_video_pre_process_func"
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS["LingBotVideoPipeline"] == "get_lingbot_video_post_process_func"
|
||||
|
||||
|
||||
@@ -58,6 +68,7 @@ def test_component_discovery_declarations():
|
||||
assert LingBotVideoPipeline._encoder_modules == ["text_encoder"]
|
||||
assert LingBotVideoPipeline._vae_modules == ["vae"]
|
||||
assert LingBotVideoPipeline.supports_step_execution is False
|
||||
assert LingBotVideoPipeline.max_outputs_per_prompt == 1
|
||||
|
||||
|
||||
def test_extra_body_params_include_video_flow_shift_alias():
|
||||
@@ -69,17 +80,47 @@ def test_extra_body_params_include_video_flow_shift_alias():
|
||||
assert "batch_cfg" in params
|
||||
|
||||
|
||||
def test_extract_prompt_string_and_mapping():
|
||||
from vllm_omni.diffusion.models.lingbot_video.pipeline_lingbot_video import _extract_prompt
|
||||
def test_preprocess_preserves_geometry_and_converts_rgb(tmp_path):
|
||||
from vllm_omni.diffusion.models.lingbot_video import (
|
||||
get_lingbot_video_pre_process_func,
|
||||
)
|
||||
|
||||
string_req = SimpleNamespace(prompt="a robot waves")
|
||||
assert _extract_prompt(string_req) == ("a robot waves", None)
|
||||
image_path = tmp_path / "reference.png"
|
||||
Image.new("L", (48, 32), color=127).save(image_path)
|
||||
request = OmniDiffusionRequest(
|
||||
prompt={
|
||||
"prompt": "a robot walks",
|
||||
"modalities": ["video"],
|
||||
"multi_modal_data": {"image": str(image_path)},
|
||||
},
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id="lingbot-preprocess-test",
|
||||
)
|
||||
|
||||
mapping_req = SimpleNamespace(prompt={"prompt": "a robot walks", "negative_prompt": "low quality"})
|
||||
assert _extract_prompt(mapping_req) == ("a robot walks", "low quality")
|
||||
result = get_lingbot_video_pre_process_func(SimpleNamespace())(request)
|
||||
|
||||
with pytest.raises(TypeError, match="Unsupported LingBot prompt type"):
|
||||
_extract_prompt(SimpleNamespace(prompt=["not", "supported"]))
|
||||
image = result.prompt["multi_modal_data"]["image"]
|
||||
assert image.mode == "RGB"
|
||||
assert image.size == (48, 32)
|
||||
|
||||
|
||||
def test_preprocess_marks_internal_image_warmup_as_video():
|
||||
from vllm_omni.diffusion.models.lingbot_video import (
|
||||
get_lingbot_video_pre_process_func,
|
||||
)
|
||||
|
||||
request = OmniDiffusionRequest(
|
||||
prompt={
|
||||
"prompt": "dummy run",
|
||||
"multi_modal_data": {"image": Image.new("RGB", (32, 32))},
|
||||
},
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id=DUMMY_DIFFUSION_REQUEST_ID,
|
||||
)
|
||||
|
||||
result = get_lingbot_video_pre_process_func(SimpleNamespace())(request)
|
||||
|
||||
assert result.prompt["modalities"] == ["video"]
|
||||
|
||||
|
||||
def test_check_inputs_accepts_t2v_shapes_and_rejects_invalid_values():
|
||||
@@ -104,6 +145,59 @@ def test_postprocess_keeps_torch_by_default_and_converts_np_when_requested():
|
||||
array = post(frames, SimpleNamespace(output_type="np"))
|
||||
assert isinstance(array, np.ndarray)
|
||||
assert array.shape == (2, 4, 4, 3)
|
||||
video = post({"video": frames}, SimpleNamespace(output_type="pt"))
|
||||
assert set(video) == {"video"}
|
||||
assert video["video"] is frames
|
||||
image = post(
|
||||
{"image": torch.ones(4, 4, 3)},
|
||||
SimpleNamespace(output_type="pt"),
|
||||
)
|
||||
assert set(image) == {"image"}
|
||||
assert isinstance(image["image"], np.ndarray)
|
||||
assert image["image"].shape == (4, 4, 3)
|
||||
latent = torch.ones(1, 4, 1, 2, 2)
|
||||
latent_output = post({"image": latent}, SimpleNamespace(output_type="latent"))
|
||||
assert set(latent_output) == {"image"}
|
||||
assert latent_output["image"] is latent
|
||||
|
||||
|
||||
def test_postprocess_preserves_image_contract_for_serving():
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.models.lingbot_video import get_lingbot_video_post_process_func
|
||||
from vllm_omni.diffusion.output_formatter import (
|
||||
format_diffusion_outputs,
|
||||
normalize_diffusion_postprocess_output,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.api_server import _extract_images_from_result
|
||||
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
num_inference_steps=1,
|
||||
output_type="pt",
|
||||
resolution=64,
|
||||
)
|
||||
request = OmniDiffusionRequest(
|
||||
prompt={"prompt": "a robot", "modalities": ["image"]},
|
||||
sampling_params=sampling,
|
||||
request_id="lingbot-image-contract-test",
|
||||
)
|
||||
raw_output = {"image": torch.ones(8, 12, 3)}
|
||||
processed = get_lingbot_video_post_process_func(SimpleNamespace())(raw_output, sampling)
|
||||
normalized = normalize_diffusion_postprocess_output(processed)
|
||||
|
||||
assert normalized.primary_key == "image"
|
||||
[result] = format_diffusion_outputs(
|
||||
request=request,
|
||||
od_config=SimpleNamespace(model_class_name="LingBotVideoPipeline"),
|
||||
diffusion_output=DiffusionOutput(output=raw_output),
|
||||
output_data=raw_output,
|
||||
postprocess_output=normalized,
|
||||
)
|
||||
|
||||
assert len(result.images) == 1
|
||||
assert isinstance(result.images[0], np.ndarray)
|
||||
endpoint_images = _extract_images_from_result(result)
|
||||
assert len(endpoint_images) == 1
|
||||
assert endpoint_images[0].size == (12, 8)
|
||||
|
||||
|
||||
def test_forward_resolves_t2v_sampling_and_flow_shift_alias():
|
||||
@@ -116,7 +210,11 @@ def test_forward_resolves_t2v_sampling_and_flow_shift_alias():
|
||||
|
||||
pipeline._generate = fake_generate
|
||||
req = _make_request_batch(
|
||||
{"prompt": "a robot arm", "negative_prompt": "bad hands"},
|
||||
{
|
||||
"prompt": "a robot arm",
|
||||
"negative_prompt": "bad hands",
|
||||
"modalities": ["video"],
|
||||
},
|
||||
height=192,
|
||||
width=320,
|
||||
num_frames=9,
|
||||
@@ -129,10 +227,12 @@ def test_forward_resolves_t2v_sampling_and_flow_shift_alias():
|
||||
|
||||
out = pipeline.forward(req)
|
||||
|
||||
assert torch.equal(out.output, torch.zeros(9, 192, 320, 3))
|
||||
assert torch.equal(out.output["video"], torch.zeros(9, 192, 320, 3))
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["prompt"] == "a robot arm"
|
||||
assert call["mode"].value == "t2v"
|
||||
assert call["input_image"] is None
|
||||
assert call["negative_prompt"] == "bad hands"
|
||||
assert call["height"] == 192
|
||||
assert call["width"] == 320
|
||||
@@ -194,6 +294,222 @@ def test_forward_prefers_shift_over_flow_shift_and_defaults_negative_prompt():
|
||||
assert calls[0]["negative_prompt"] == "default negative"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "num_frames", "expected_mode", "output_key", "default_negative"),
|
||||
[
|
||||
(
|
||||
{"prompt": "a still robot", "modalities": ["image"]},
|
||||
1,
|
||||
"t2i",
|
||||
"image",
|
||||
"default image negative",
|
||||
),
|
||||
(
|
||||
{
|
||||
"prompt": "a moving robot",
|
||||
"modalities": ["video"],
|
||||
"multi_modal_data": {"image": Image.new("RGB", (48, 32), color="blue")},
|
||||
},
|
||||
9,
|
||||
"ti2v",
|
||||
"video",
|
||||
"default negative",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_forward_dispatches_generation_mode_and_output(
|
||||
prompt,
|
||||
num_frames,
|
||||
expected_mode,
|
||||
output_key,
|
||||
default_negative,
|
||||
):
|
||||
pipeline = _make_pipeline()
|
||||
calls = []
|
||||
|
||||
def fake_generate(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return torch.zeros(192, 320, 3) if output_key == "image" else torch.zeros(num_frames, 192, 320, 3)
|
||||
|
||||
pipeline._generate = fake_generate
|
||||
output = pipeline.forward(
|
||||
_make_request_batch(
|
||||
prompt,
|
||||
height=192,
|
||||
width=320,
|
||||
num_frames=num_frames,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=3.0,
|
||||
seed=42,
|
||||
output_type="pt",
|
||||
)
|
||||
)
|
||||
|
||||
assert set(output.output) == {output_key}
|
||||
assert calls[0]["mode"].value == expected_mode
|
||||
assert calls[0]["negative_prompt"] == default_negative
|
||||
if expected_mode == "ti2v":
|
||||
assert isinstance(calls[0]["input_image"], Image.Image)
|
||||
|
||||
|
||||
def test_batched_cfg_pads_image_conditioned_embeddings_and_masks():
|
||||
from vllm_omni.diffusion.models.lingbot_video.pipeline_lingbot_video import (
|
||||
_batch_cfg_prompt_inputs,
|
||||
)
|
||||
|
||||
positive = torch.ones(1, 7, 4)
|
||||
positive_mask = torch.ones(1, 7, dtype=torch.long)
|
||||
negative = torch.full((1, 9, 4), 2.0)
|
||||
negative_mask = torch.ones(1, 9, dtype=torch.long)
|
||||
|
||||
embeds, mask = _batch_cfg_prompt_inputs(
|
||||
positive,
|
||||
positive_mask,
|
||||
negative,
|
||||
negative_mask,
|
||||
)
|
||||
|
||||
assert embeds.shape == (2, 9, 4)
|
||||
assert mask.shape == (2, 9)
|
||||
assert torch.equal(mask[0], torch.tensor([1, 1, 1, 1, 1, 1, 1, 0, 0]))
|
||||
assert torch.equal(mask[1], torch.ones(9, dtype=torch.long))
|
||||
|
||||
|
||||
class _RecordingTransformer(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(()))
|
||||
self.config = SimpleNamespace(in_channels=1)
|
||||
self.latent_inputs = []
|
||||
|
||||
def forward(
|
||||
self,
|
||||
latents,
|
||||
timestep,
|
||||
prompt_embeds,
|
||||
*,
|
||||
encoder_attention_mask,
|
||||
return_dict,
|
||||
):
|
||||
del timestep, prompt_embeds, encoder_attention_mask, return_dict
|
||||
self.latent_inputs.append(latents.detach().clone())
|
||||
return (torch.zeros_like(latents),)
|
||||
|
||||
|
||||
class _CorruptingScheduler:
|
||||
sigma_max = 1.0
|
||||
sigma_min = 0.0
|
||||
|
||||
def set_timesteps(self, num_inference_steps, *, device, shift, **kwargs):
|
||||
del num_inference_steps, shift, kwargs
|
||||
self.timesteps = torch.tensor([1000.0, 500.0], device=device)
|
||||
|
||||
def step(
|
||||
self,
|
||||
noise_pred,
|
||||
timestep,
|
||||
latents,
|
||||
*,
|
||||
return_dict,
|
||||
generator,
|
||||
):
|
||||
del noise_pred, timestep, return_dict, generator
|
||||
return (latents + 7.0,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("guidance_scale", "batch_cfg", "expected_prompts", "expected_transformer_calls"),
|
||||
[
|
||||
pytest.param(2.0, False, ["positive", "negative"], 4, id="sequential-cfg"),
|
||||
pytest.param(2.0, True, ["positive", "negative"], 2, id="batched-cfg"),
|
||||
pytest.param(1.0, False, ["positive"], 2, id="cfg-disabled"),
|
||||
],
|
||||
)
|
||||
def test_ti2v_reinjects_clean_prefix_across_cfg_modes(
|
||||
guidance_scale,
|
||||
batch_cfg,
|
||||
expected_prompts,
|
||||
expected_transformer_calls,
|
||||
):
|
||||
from vllm_omni.diffusion.models.lingbot_video import (
|
||||
LingBotGenerationMode,
|
||||
LingBotImageCondition,
|
||||
)
|
||||
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.transformer = _RecordingTransformer()
|
||||
pipeline.scheduler = _CorruptingScheduler()
|
||||
pipeline.progress_bar = lambda timesteps: timesteps
|
||||
pipeline.prepare_latents = lambda *args, **kwargs: torch.zeros(1, 1, 3, 1, 1)
|
||||
vlm_image = Image.new("RGB", (32, 32), color="red")
|
||||
condition = LingBotImageCondition(
|
||||
vlm_image=vlm_image,
|
||||
clean_latent=torch.full((1, 1, 1, 1, 1), 5.0),
|
||||
)
|
||||
pipeline.prepare_ti2v_image_condition = lambda *args, **kwargs: condition
|
||||
encode_calls = []
|
||||
|
||||
def fake_encode(prompt, *, images, device):
|
||||
del device
|
||||
encode_calls.append((prompt, images))
|
||||
return torch.ones(1, 2, 4), torch.ones(1, 2, dtype=torch.long)
|
||||
|
||||
pipeline.encode_prompt = fake_encode
|
||||
|
||||
latents = pipeline._generate(
|
||||
prompt="positive",
|
||||
mode=LingBotGenerationMode.TI2V,
|
||||
input_image=vlm_image,
|
||||
negative_prompt="negative",
|
||||
height=16,
|
||||
width=16,
|
||||
num_frames=9,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=guidance_scale,
|
||||
shift=3.0,
|
||||
output_type="latent",
|
||||
batch_cfg=batch_cfg,
|
||||
)
|
||||
|
||||
assert [call[0] for call in encode_calls] == expected_prompts
|
||||
assert all(call[1] == [vlm_image] for call in encode_calls)
|
||||
assert len(pipeline.transformer.latent_inputs) == expected_transformer_calls
|
||||
for item in pipeline.transformer.latent_inputs:
|
||||
expected_prefix = condition.clean_latent.expand(item.shape[0], -1, -1, -1, -1)
|
||||
assert torch.equal(item[:, :, :1], expected_prefix)
|
||||
assert torch.equal(latents[:, :, :1], condition.clean_latent)
|
||||
assert torch.equal(latents[:, :, 1:], torch.full((1, 1, 2, 1, 1), 14.0))
|
||||
|
||||
|
||||
def test_t2i_decodes_and_returns_the_unique_frame():
|
||||
from vllm_omni.diffusion.models.lingbot_video import LingBotGenerationMode
|
||||
|
||||
pipeline = _make_pipeline()
|
||||
pipeline.transformer = _RecordingTransformer()
|
||||
pipeline.scheduler = _CorruptingScheduler()
|
||||
pipeline.progress_bar = lambda timesteps: timesteps
|
||||
pipeline.prepare_latents = lambda *args, **kwargs: torch.zeros(1, 1, 1, 2, 2)
|
||||
pipeline.encode_prompt = lambda *args, **kwargs: (
|
||||
torch.ones(1, 2, 4),
|
||||
torch.ones(1, 2, dtype=torch.long),
|
||||
)
|
||||
pipeline._decode_latents = lambda latents: torch.ones(1, 16, 16, 3)
|
||||
|
||||
image = pipeline._generate(
|
||||
prompt="a still robot",
|
||||
mode=LingBotGenerationMode.T2I,
|
||||
height=16,
|
||||
width=16,
|
||||
num_frames=1,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=1.0,
|
||||
shift=3.0,
|
||||
output_type="pt",
|
||||
)
|
||||
|
||||
assert image.shape == (16, 16, 3)
|
||||
|
||||
|
||||
def test_forward_rejects_multi_request_batches():
|
||||
pipeline = _make_pipeline()
|
||||
first = OmniDiffusionRequest(
|
||||
@@ -211,6 +527,45 @@ def test_forward_rejects_multi_request_batches():
|
||||
pipeline.forward(DiffusionRequestBatch([first, second]))
|
||||
|
||||
|
||||
def test_forward_marks_invalid_request_contract_as_client_error():
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pipeline = _make_pipeline()
|
||||
request = _make_request_batch(
|
||||
{
|
||||
"prompt": "unsupported image edit",
|
||||
"modalities": ["image"],
|
||||
"multi_modal_data": {"image": Image.new("RGB", (32, 32), color="blue")},
|
||||
},
|
||||
height=192,
|
||||
width=320,
|
||||
num_frames=1,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match="does not accept") as exc_info:
|
||||
pipeline.forward(request)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_forward_marks_unsupported_output_count_as_client_error():
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pipeline = _make_pipeline()
|
||||
request = _make_request_batch(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
height=192,
|
||||
width=320,
|
||||
num_frames=9,
|
||||
num_outputs_per_prompt=2,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match="one output per prompt") as exc_info:
|
||||
pipeline.forward(request)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_load_weights_rejects_external_weight_stream():
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.lingbot_video.request_utils import (
|
||||
LingBotGenerationMode,
|
||||
caption_from_lingbot_prompt,
|
||||
normalize_lingbot_num_frames,
|
||||
normalize_lingbot_request,
|
||||
resolve_lingbot_mode,
|
||||
resolve_lingbot_num_frames,
|
||||
resolve_lingbot_output_dimensions,
|
||||
resolve_lingbot_size,
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _request(prompt, **sampling_overrides):
|
||||
return SimpleNamespace(
|
||||
prompt=prompt,
|
||||
sampling_params=OmniDiffusionSamplingParams(**sampling_overrides),
|
||||
)
|
||||
|
||||
|
||||
def _normalize(prompt, **sampling_overrides):
|
||||
return normalize_lingbot_request(
|
||||
_request(prompt, **sampling_overrides),
|
||||
default_negative_prompt="video default",
|
||||
default_image_negative_prompt="image default",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "expected"),
|
||||
[
|
||||
({"prompt": "still", "modalities": ["image"]}, LingBotGenerationMode.T2I),
|
||||
({"prompt": "motion", "modalities": ["video"]}, LingBotGenerationMode.T2V),
|
||||
(
|
||||
{
|
||||
"prompt": "motion",
|
||||
"modalities": ["video"],
|
||||
"multi_modal_data": {"image": Image.new("RGB", (8, 8))},
|
||||
},
|
||||
LingBotGenerationMode.TI2V,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_resolve_lingbot_mode(prompt, expected):
|
||||
assert resolve_lingbot_mode(prompt) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "message"),
|
||||
[
|
||||
(
|
||||
{"prompt": "x", "modalities": ["image"], "multi_modal_data": {"image": Image.new("RGB", (8, 8))}},
|
||||
"does not accept",
|
||||
),
|
||||
(
|
||||
{
|
||||
"prompt": "x",
|
||||
"modalities": ["video"],
|
||||
"multi_modal_data": {"image": [Image.new("RGB", (8, 8)), Image.new("RGB", (8, 8))]},
|
||||
},
|
||||
"exactly one",
|
||||
),
|
||||
({"prompt": "x", "modalities": ["video"], "multi_modal_data": {"video": [1]}}, "reference modalities"),
|
||||
({"prompt": "x", "multi_modal_data": {"image": Image.new("RGB", (8, 8))}}, "explicitly set"),
|
||||
({"prompt": "x", "modalities": ["audio"]}, "output modality"),
|
||||
],
|
||||
)
|
||||
def test_resolve_lingbot_mode_rejects_invalid_media_combinations(prompt, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
resolve_lingbot_mode(prompt)
|
||||
|
||||
|
||||
def test_caption_from_lingbot_prompt_preserves_text_and_compact_json():
|
||||
assert caption_from_lingbot_prompt("plain text") == "plain text"
|
||||
assert caption_from_lingbot_prompt({"caption": "caption only", "duration": 4}) == "caption only"
|
||||
assert caption_from_lingbot_prompt({"caption": {"中文": True, "nested": {"value": 2}}}) == (
|
||||
'{"中文":true,"nested":{"value":2}}'
|
||||
)
|
||||
assert (
|
||||
caption_from_lingbot_prompt(
|
||||
{"scene": "海边", "camera": {"move": "pan"}, "duration": 5, "fps": 24, "size": "320x192"}
|
||||
)
|
||||
== '{"scene":"海边","camera":{"move":"pan"}}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", [{"duration": 4, "fps": 24}, {"caption": ""}, ""])
|
||||
def test_caption_from_lingbot_prompt_rejects_empty_caption(prompt):
|
||||
with pytest.raises(ValueError, match="empty|must not be empty"):
|
||||
caption_from_lingbot_prompt(prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("num_frames", "expected"),
|
||||
[(1, 1), (2, 5), (4, 5), (5, 5), (96, 97), (98, 101), (121, 121)],
|
||||
)
|
||||
def test_normalize_lingbot_num_frames_rounds_up_to_4n_plus_1(num_frames, expected):
|
||||
assert normalize_lingbot_num_frames(num_frames) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_frames", [0, -1, 1.5, "invalid"])
|
||||
def test_normalize_lingbot_num_frames_rejects_invalid_values(num_frames):
|
||||
with pytest.raises(ValueError, match="num_frames"):
|
||||
normalize_lingbot_num_frames(num_frames)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("duration", "fps", "expected"),
|
||||
[(4, 24, 97), (5, 24, 121), (8, 24, 193), (4, 12, 49), (4, 30, 121)],
|
||||
)
|
||||
def test_resolve_lingbot_num_frames_rounds_up(duration, fps, expected):
|
||||
assert resolve_lingbot_num_frames(duration, fps) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("duration", "fps"), [(0, 24), (-1, 24), (0.01, 24), (1, 0), (1, -1)])
|
||||
def test_resolve_lingbot_num_frames_rejects_non_positive_or_empty_results(duration, fps):
|
||||
with pytest.raises(ValueError, match="duration|fps|at least one"):
|
||||
resolve_lingbot_num_frames(duration, fps)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resolution", "ratio", "expected"),
|
||||
[
|
||||
("192p", "1:1", (192, 192)),
|
||||
("192p", "9:16", (192, 320)),
|
||||
("192p", "16:9", (320, 192)),
|
||||
("192p", "3:4", (192, 256)),
|
||||
("192p", "4:3", (256, 192)),
|
||||
("480p", "1:1", (480, 480)),
|
||||
("480p", "9:16", (480, 832)),
|
||||
("480p", "16:9", (832, 480)),
|
||||
("480p", "3:4", (480, 640)),
|
||||
("480p", "4:3", (640, 480)),
|
||||
("720p", "1:1", (736, 736)),
|
||||
("720p", "9:16", (736, 1280)),
|
||||
("720p", "16:9", (1280, 736)),
|
||||
("720p", "3:4", (736, 960)),
|
||||
("720p", "4:3", (960, 736)),
|
||||
("1080p", "1:1", (1088, 1088)),
|
||||
("1080p", "9:16", (1088, 1920)),
|
||||
("1080p", "16:9", (1920, 1088)),
|
||||
("1080p", "3:4", (1088, 1440)),
|
||||
("1080p", "4:3", (1440, 1088)),
|
||||
("2k", "1:1", (1440, 1440)),
|
||||
("2k", "9:16", (1440, 2560)),
|
||||
("2k", "16:9", (2560, 1440)),
|
||||
("2k", "3:4", (1440, 1920)),
|
||||
("2k", "4:3", (1920, 1440)),
|
||||
("4k", "1:1", (2176, 2176)),
|
||||
("4k", "9:16", (2176, 3840)),
|
||||
("4k", "16:9", (3840, 2176)),
|
||||
("4k", "3:4", (2176, 2880)),
|
||||
("4k", "4:3", (2880, 2176)),
|
||||
],
|
||||
)
|
||||
def test_resolve_lingbot_size_supports_all_official_presets(resolution, ratio, expected):
|
||||
assert resolve_lingbot_size(resolution=resolution, ratio=ratio) == expected
|
||||
|
||||
|
||||
def test_resolve_lingbot_size_validates_sources_and_alignment():
|
||||
assert resolve_lingbot_size(size="320x192") == (192, 320)
|
||||
assert resolve_lingbot_size(width=320, height=192) == (192, 320)
|
||||
with pytest.raises(ValueError, match="provided together"):
|
||||
resolve_lingbot_size(width=320)
|
||||
with pytest.raises(ValueError, match="provided together"):
|
||||
resolve_lingbot_size(resolution="192p")
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
resolve_lingbot_size(width=320, height=192, resolution="192p", ratio="9:16")
|
||||
with pytest.raises(ValueError, match="multiples of 16"):
|
||||
resolve_lingbot_size(width=321, height=192)
|
||||
|
||||
|
||||
def test_resolve_lingbot_output_dimensions_matches_request_priority():
|
||||
assert resolve_lingbot_output_dimensions(
|
||||
sampling_width=480,
|
||||
sampling_height=480,
|
||||
prompt_fields={"resolution": "720p", "ratio": "16:9"},
|
||||
) == (736, 1280)
|
||||
|
||||
assert resolve_lingbot_output_dimensions(
|
||||
sampling_width=480,
|
||||
sampling_height=480,
|
||||
prompt_fields={"width": 320, "height": 192},
|
||||
) == (320, 192)
|
||||
|
||||
assert resolve_lingbot_output_dimensions(
|
||||
sampling_width=320,
|
||||
sampling_height=192,
|
||||
) == (320, 192)
|
||||
|
||||
assert resolve_lingbot_output_dimensions() == (480, 480)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_uses_mode_defaults_and_preserves_empty_negative():
|
||||
t2i = _normalize(
|
||||
{"prompt": "still", "modalities": ["image"], "negative_prompt": ""},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=1,
|
||||
)
|
||||
assert t2i.mode is LingBotGenerationMode.T2I
|
||||
assert t2i.num_frames == 1
|
||||
assert t2i.negative_prompt == ""
|
||||
|
||||
t2v = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=9,
|
||||
)
|
||||
assert t2v.mode is LingBotGenerationMode.T2V
|
||||
assert t2v.negative_prompt == "video default"
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_rejects_explicit_zero_flow_shift():
|
||||
with pytest.raises(ValueError, match="shift.*positive"):
|
||||
_normalize(
|
||||
{"prompt": "still", "modalities": ["image"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=1,
|
||||
extra_args={"flow_shift": 0},
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_rejects_explicit_zero_width():
|
||||
with pytest.raises(ValueError, match="width"):
|
||||
_normalize(
|
||||
{"prompt": "still", "modalities": ["image"]},
|
||||
width=0,
|
||||
height=192,
|
||||
num_frames=1,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_rounds_all_video_frame_counts():
|
||||
config = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=96,
|
||||
fps=24,
|
||||
frame_rate=24,
|
||||
)
|
||||
assert config.num_frames == 97
|
||||
|
||||
config = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=121,
|
||||
fps=24,
|
||||
frame_rate=24,
|
||||
)
|
||||
assert config.num_frames == 121
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_prefers_explicit_frames_over_duration():
|
||||
online = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=9,
|
||||
fps=24,
|
||||
extra_args={"duration": 5},
|
||||
)
|
||||
assert online.num_frames == 9
|
||||
|
||||
offline = _normalize(
|
||||
{
|
||||
"prompt": {"caption": "motion", "duration": 5, "num_frames": 9},
|
||||
"modalities": ["video"],
|
||||
},
|
||||
width=320,
|
||||
height=192,
|
||||
fps=24,
|
||||
)
|
||||
assert offline.num_frames == 9
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_uses_duration_then_model_default():
|
||||
duration = _normalize(
|
||||
{"prompt": {"caption": "motion", "duration": 5}, "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
fps=24,
|
||||
)
|
||||
assert duration.num_frames == 121
|
||||
|
||||
default = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
)
|
||||
assert default.num_frames == 81
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_rejects_contract_conflicts():
|
||||
with pytest.raises(ValueError, match="text-to-image.*duration"):
|
||||
_normalize(
|
||||
{"prompt": {"caption": "still", "duration": 4}, "modalities": ["image"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=1,
|
||||
)
|
||||
with pytest.raises(ValueError, match="text-to-image requires"):
|
||||
_normalize(
|
||||
{"prompt": "still", "modalities": ["image"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=5,
|
||||
)
|
||||
with pytest.raises(ValueError, match="seconds.*duration"):
|
||||
_normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=320,
|
||||
height=192,
|
||||
num_frames=96,
|
||||
extra_args={"seconds": 4, "duration": 4},
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_lingbot_request_model_size_overrides_sampling_dimensions():
|
||||
# A prompt-provided size overrides the sampling width/height.
|
||||
config = _normalize(
|
||||
{"prompt": {"caption": "motion", "size": "320x192"}, "modalities": ["video"]},
|
||||
width=480,
|
||||
height=480,
|
||||
num_frames=81,
|
||||
)
|
||||
assert (config.height, config.width) == (192, 320)
|
||||
|
||||
# Without a prompt size, the sampling dimensions are used.
|
||||
config = _normalize(
|
||||
{"prompt": "motion", "modalities": ["video"]},
|
||||
width=480,
|
||||
height=480,
|
||||
num_frames=81,
|
||||
)
|
||||
assert (config.height, config.width) == (480, 480)
|
||||
Reference in new issue
Block a user