Signed-off-by: zzehli <jaeli_ottawa@outlook.com> Signed-off-by: jl <jaeli_ottawa@outlook.com> Signed-off-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Cursor <cursoragent@cursor.com>
901 lines
33 KiB
Python
901 lines
33 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
"""L1 unit tests for the native Boogu-Image pipeline.
|
|
|
|
Two groups:
|
|
|
|
1. Constructor tests: all ``from_pretrained`` calls are mocked (Ovis pattern)
|
|
so the pipeline ``__init__`` wiring is exercised without downloading
|
|
weights or building the real transformer.
|
|
2. Prompt-encoding tests: a pipeline shell (``object.__new__``) is wired with
|
|
small deterministic fakes so the ported chat templating, processor kwargs,
|
|
CFG handling, and reshape logic can be verified numerically on CPU.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
from torch import nn
|
|
|
|
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig, TransformerConfig
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
|
|
|
_MODULE = "vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image"
|
|
|
|
_EMBED_DIM = 8
|
|
_SEQ_LEN = 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constructor tests (mocked from_pretrained)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_dependencies(mocker, monkeypatch):
|
|
"""Mock external components so ``__init__`` runs without real weights."""
|
|
# The full VLM wrapper has an ``lm_head``; the pipeline must strip it and
|
|
# keep the inner ``.model`` as the encoder.
|
|
inner_encoder = mocker.MagicMock(name="inner_qwen3vl_model")
|
|
inner_encoder.dtype = torch.float32
|
|
mllm_wrapper = mocker.MagicMock(name="qwen3vl_wrapper")
|
|
mllm_wrapper.model.to.return_value = inner_encoder
|
|
|
|
mock_processor = mocker.MagicMock(name="processor")
|
|
|
|
mock_vae = mocker.MagicMock(name="vae")
|
|
mock_vae.config.block_out_channels = [128, 256, 512, 512] # scale factor 8
|
|
mock_vae.to.return_value = mock_vae
|
|
|
|
mock_scheduler = mocker.MagicMock(name="scheduler")
|
|
|
|
monkeypatch.setattr(
|
|
f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained",
|
|
lambda *a, **k: mock_scheduler,
|
|
)
|
|
monkeypatch.setattr(
|
|
f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained",
|
|
lambda *a, **k: mllm_wrapper,
|
|
)
|
|
monkeypatch.setattr(
|
|
f"{_MODULE}.Qwen3VLProcessor.from_pretrained",
|
|
lambda *a, **k: mock_processor,
|
|
)
|
|
monkeypatch.setattr(
|
|
f"{_MODULE}.AutoencoderKL.from_pretrained",
|
|
lambda *a, **k: mock_vae,
|
|
)
|
|
|
|
mock_transformer_cls = mocker.MagicMock(name="transformer_cls")
|
|
mock_transformer_instance = mocker.MagicMock(name="transformer")
|
|
mock_transformer_cls.return_value = mock_transformer_instance
|
|
monkeypatch.setattr(f"{_MODULE}.BooguImageTransformer2DModel", mock_transformer_cls)
|
|
|
|
# Treat the dummy model id as a local path: skips hub prefetch.
|
|
mocker.patch("os.path.exists", return_value=True)
|
|
|
|
return {
|
|
"inner_encoder": inner_encoder,
|
|
"mllm_wrapper": mllm_wrapper,
|
|
"processor": mock_processor,
|
|
"vae": mock_vae,
|
|
"scheduler": mock_scheduler,
|
|
"transformer": mock_transformer_instance,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def boogu_pipeline(mock_dependencies):
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
BooguImagePipeline,
|
|
)
|
|
|
|
od_config = OmniDiffusionConfig(
|
|
model="dummy-boogu",
|
|
tf_model_config=TransformerConfig(params={}),
|
|
dtype=torch.float32,
|
|
num_gpus=1,
|
|
)
|
|
return BooguImagePipeline(od_config=od_config)
|
|
|
|
|
|
def test_boogu_image_pipeline_import():
|
|
from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline
|
|
|
|
assert BooguImagePipeline is not None
|
|
|
|
|
|
def test_component_discovery_declarations():
|
|
from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline
|
|
|
|
# CPU offload / HSDP discovery must find ``mllm`` (there is no
|
|
# ``text_encoder`` attribute on this pipeline).
|
|
assert BooguImagePipeline._dit_modules == ["transformer"]
|
|
assert BooguImagePipeline._encoder_modules == ["mllm"]
|
|
assert BooguImagePipeline._vae_modules == ["vae"]
|
|
|
|
|
|
def test_constructor_wires_components(boogu_pipeline, mock_dependencies):
|
|
assert boogu_pipeline.scheduler is mock_dependencies["scheduler"]
|
|
assert boogu_pipeline.processor is mock_dependencies["processor"]
|
|
assert boogu_pipeline.vae is mock_dependencies["vae"]
|
|
assert boogu_pipeline.transformer is mock_dependencies["transformer"]
|
|
assert boogu_pipeline.vae_scale_factor == 8
|
|
assert boogu_pipeline.default_sample_size == 128
|
|
assert hasattr(boogu_pipeline, "load_weights")
|
|
|
|
|
|
def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies):
|
|
# Upstream encodes with the inner Qwen3VLModel, not the generation wrapper.
|
|
assert boogu_pipeline.mllm is mock_dependencies["inner_encoder"]
|
|
|
|
|
|
def test_constructor_weights_sources(boogu_pipeline):
|
|
(source,) = boogu_pipeline.weights_sources
|
|
assert source.model_or_path == "dummy-boogu"
|
|
assert source.subfolder == "transformer"
|
|
assert source.prefix == "transformer."
|
|
assert source.fall_back_to_pt is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parallel_config", "cache_backend", "message"),
|
|
[
|
|
(DiffusionParallelConfig(tensor_parallel_size=2), "none", "Tensor parallelism"),
|
|
(DiffusionParallelConfig(ulysses_degree=2), "none", "Sequence parallelism"),
|
|
(DiffusionParallelConfig(ring_degree=2), "none", "Sequence parallelism"),
|
|
(DiffusionParallelConfig(cfg_parallel_size=2), "none", "CFG parallelism"),
|
|
(
|
|
DiffusionParallelConfig(use_hsdp=True, hsdp_shard_size=2),
|
|
"none",
|
|
"HSDP",
|
|
),
|
|
(DiffusionParallelConfig(), "cache_dit", "Cache backend 'cache_dit'"),
|
|
(DiffusionParallelConfig(), "tea_cache", "Cache backend 'tea_cache'"),
|
|
],
|
|
)
|
|
def test_constructor_rejects_unsupported_execution_modes(
|
|
mock_dependencies,
|
|
parallel_config,
|
|
cache_backend,
|
|
message,
|
|
):
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
BooguImagePipeline,
|
|
)
|
|
|
|
od_config = OmniDiffusionConfig(
|
|
model="dummy-boogu",
|
|
tf_model_config=TransformerConfig(params={}),
|
|
dtype=torch.float32,
|
|
parallel_config=parallel_config,
|
|
cache_backend=cache_backend,
|
|
)
|
|
|
|
with pytest.raises(NotImplementedError, match=message):
|
|
BooguImagePipeline(od_config=od_config)
|
|
|
|
# Validation happens before any checkpoint component is constructed.
|
|
mock_dependencies["mllm_wrapper"].model.to.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt-encoding tests (deterministic fakes, no constructor)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _RecordingProcessor:
|
|
"""Fake Qwen3VLProcessor: deterministic token ids derived from the text."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def apply_chat_template(self, prompts, **kwargs):
|
|
self.calls.append({"prompts": prompts, "kwargs": kwargs})
|
|
batch = len(prompts)
|
|
input_ids = torch.zeros(batch, _SEQ_LEN, dtype=torch.long)
|
|
for i, messages in enumerate(prompts):
|
|
system_text = messages[0]["content"][0]["text"]
|
|
user_text = messages[1]["content"][0]["text"]
|
|
input_ids[i, 0] = len(system_text) % 997
|
|
input_ids[i, 1] = len(user_text) % 997
|
|
input_ids[i, 2:] = torch.arange(2, _SEQ_LEN) + i
|
|
attention_mask = torch.ones(batch, _SEQ_LEN, dtype=torch.long)
|
|
attention_mask[:, -1] = 0 # fake right-padding
|
|
return {"input_ids": input_ids, "attention_mask": attention_mask}
|
|
|
|
|
|
class _FakeMLLM:
|
|
"""Fake Qwen3VLModel: hidden states are a deterministic function of ids."""
|
|
|
|
dtype = torch.bfloat16
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def __call__(self, input_ids=None, attention_mask=None, output_hidden_states=False, **kwargs):
|
|
self.calls.append({"output_hidden_states": output_hidden_states, **kwargs})
|
|
base = input_ids.to(torch.float32).unsqueeze(-1).repeat(1, 1, _EMBED_DIM)
|
|
hidden = base + torch.arange(_EMBED_DIM, dtype=torch.float32)
|
|
return SimpleNamespace(last_hidden_state=hidden, hidden_states=(hidden - 1, hidden))
|
|
|
|
|
|
def _make_encode_pipeline(num_instruction_feature_layers: int = 1):
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
SYSTEM_PROMPT_4_T2I_UNIFIED,
|
|
SYSTEM_PROMPT_4_TI2I_UNIFIED,
|
|
BooguImagePipeline,
|
|
)
|
|
|
|
pipeline = object.__new__(BooguImagePipeline)
|
|
nn.Module.__init__(pipeline)
|
|
pipeline._execution_device = torch.device("cpu")
|
|
pipeline.processor = _RecordingProcessor()
|
|
pipeline.mllm = _FakeMLLM()
|
|
pipeline.transformer = SimpleNamespace(
|
|
instruction_feature_configs={
|
|
"instruction_feat_dim": _EMBED_DIM,
|
|
"num_instruction_feature_layers": num_instruction_feature_layers,
|
|
"reduce_type": "mean",
|
|
},
|
|
dtype=torch.float32,
|
|
)
|
|
pipeline.SYSTEM_PROMPT_4_T2I = SYSTEM_PROMPT_4_T2I_UNIFIED
|
|
pipeline.SYSTEM_PROMPT_DROP = SYSTEM_PROMPT_4_TI2I_UNIFIED
|
|
return pipeline
|
|
|
|
|
|
def test_apply_chat_template_system_prompt_selection():
|
|
pipeline = _make_encode_pipeline()
|
|
|
|
messages = pipeline._apply_chat_template("a cat on a mat")
|
|
assert messages[0]["role"] == "system"
|
|
assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_T2I
|
|
assert messages[1]["role"] == "user"
|
|
assert messages[1]["content"][0]["text"] == "a cat on a mat"
|
|
|
|
# Empty and whitespace-only instructions select the DROP prompt (this is
|
|
# the path the default negative prompt "" takes).
|
|
for empty in ("", " ", None):
|
|
messages = pipeline._apply_chat_template(empty)
|
|
assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_DROP
|
|
|
|
|
|
def test_processor_called_with_upstream_kwargs():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.encode_prompt("a dog", do_classifier_free_guidance=False)
|
|
|
|
(call,) = pipeline.processor.calls
|
|
kwargs = call["kwargs"]
|
|
assert kwargs["padding"] == "longest"
|
|
assert kwargs["padding_side"] == "right"
|
|
assert kwargs["truncation"] is False
|
|
assert kwargs["max_length"] == 1280 # upstream __call__ default
|
|
assert kwargs["tokenize"] is True
|
|
assert kwargs["return_dict"] is True
|
|
assert kwargs["return_tensors"] == "pt"
|
|
|
|
|
|
def test_encode_prompt_shapes_and_dtype():
|
|
pipeline = _make_encode_pipeline()
|
|
embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt(["a dog", "a cat"])
|
|
|
|
assert embeds.shape == (2, _SEQ_LEN, _EMBED_DIM)
|
|
assert mask.shape == (2, _SEQ_LEN)
|
|
assert neg_embeds.shape == (2, _SEQ_LEN, _EMBED_DIM)
|
|
assert neg_mask.shape == (2, _SEQ_LEN)
|
|
# Cast to the MLLM dtype, mask passed through from the processor.
|
|
assert embeds.dtype == torch.bfloat16
|
|
assert neg_embeds.dtype == torch.bfloat16
|
|
assert torch.equal(mask[:, -1], torch.zeros(2, dtype=torch.long))
|
|
|
|
|
|
def test_single_layer_encoding_requests_hidden_states_once():
|
|
pipeline = _make_encode_pipeline()
|
|
|
|
pipeline._get_instruction_feature_embeds("a dog")
|
|
|
|
assert pipeline.mllm.calls == [{"output_hidden_states": True, "return_dict": True}]
|
|
|
|
|
|
def test_single_layer_encoding_propagates_mllm_failure():
|
|
pipeline = _make_encode_pipeline()
|
|
failure = RuntimeError("mllm failed")
|
|
|
|
class _FailingMLLM:
|
|
dtype = torch.bfloat16
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def __call__(self, **kwargs):
|
|
self.calls += 1
|
|
raise failure
|
|
|
|
pipeline.mllm = _FailingMLLM()
|
|
|
|
with pytest.raises(RuntimeError, match="mllm failed") as exc_info:
|
|
pipeline._get_instruction_feature_embeds("a dog")
|
|
|
|
assert exc_info.value is failure
|
|
assert pipeline.mllm.calls == 1
|
|
|
|
|
|
def test_encode_prompt_cfg_negative_default_is_empty_string():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.encode_prompt("a dog")
|
|
|
|
assert len(pipeline.processor.calls) == 2
|
|
negative_messages = pipeline.processor.calls[1]["prompts"]
|
|
assert len(negative_messages) == 1
|
|
# The default "" negative prompt goes through the DROP system prompt.
|
|
assert negative_messages[0][0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_DROP
|
|
assert negative_messages[0][1]["content"][0]["text"] == ""
|
|
|
|
|
|
def test_encode_prompt_without_cfg_skips_negative():
|
|
pipeline = _make_encode_pipeline()
|
|
embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt("a dog", do_classifier_free_guidance=False)
|
|
|
|
assert len(pipeline.processor.calls) == 1
|
|
assert embeds.shape == (1, _SEQ_LEN, _EMBED_DIM)
|
|
assert neg_embeds is None
|
|
assert neg_mask is None
|
|
|
|
|
|
def test_encode_prompt_explicit_negative_prompt():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.encode_prompt("a dog", negative_prompt="blurry, low quality")
|
|
|
|
negative_messages = pipeline.processor.calls[1]["prompts"]
|
|
assert negative_messages[0][0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_T2I
|
|
assert negative_messages[0][1]["content"][0]["text"] == "blurry, low quality"
|
|
|
|
|
|
def test_encode_prompt_negative_batch_mismatch_raises():
|
|
pipeline = _make_encode_pipeline()
|
|
with pytest.raises(ValueError, match="batch size"):
|
|
pipeline.encode_prompt(["a dog", "a cat"], negative_prompt=["only one"])
|
|
|
|
|
|
def test_encode_prompt_num_images_per_prompt_repeats_embeds():
|
|
pipeline = _make_encode_pipeline()
|
|
embeds, mask, neg_embeds, neg_mask = pipeline.encode_prompt("a dog", num_images_per_prompt=3)
|
|
|
|
assert embeds.shape == (3, _SEQ_LEN, _EMBED_DIM)
|
|
assert mask.shape == (3, _SEQ_LEN)
|
|
assert torch.equal(embeds[0], embeds[1])
|
|
assert torch.equal(embeds[0], embeds[2])
|
|
assert torch.equal(mask[0], mask[1])
|
|
assert neg_embeds.shape == (3, _SEQ_LEN, _EMBED_DIM)
|
|
|
|
|
|
def test_encode_prompt_precomputed_embeds_bypass_encoder():
|
|
pipeline = _make_encode_pipeline()
|
|
precomputed = torch.randn(1, _SEQ_LEN, _EMBED_DIM)
|
|
precomputed_mask = torch.ones(1, _SEQ_LEN, dtype=torch.long)
|
|
neg_precomputed = torch.randn(1, _SEQ_LEN, _EMBED_DIM)
|
|
|
|
embeds, mask, neg_embeds, _ = pipeline.encode_prompt(
|
|
"ignored",
|
|
prompt_embeds=precomputed,
|
|
prompt_attention_mask=precomputed_mask,
|
|
negative_prompt_embeds=neg_precomputed,
|
|
)
|
|
|
|
assert len(pipeline.processor.calls) == 0
|
|
assert torch.equal(embeds, precomputed)
|
|
assert torch.equal(neg_embeds, neg_precomputed)
|
|
|
|
|
|
def test_reshape_embeds_and_mask_list_branch():
|
|
# Multi-layer configs return a list of per-layer tensors; the reshape
|
|
# helper must handle both forms.
|
|
pipeline = _make_encode_pipeline()
|
|
layer = torch.arange(2 * _SEQ_LEN * _EMBED_DIM, dtype=torch.float32).view(2, _SEQ_LEN, _EMBED_DIM)
|
|
mask = torch.ones(2, _SEQ_LEN, dtype=torch.long)
|
|
|
|
batch_size, seq_len, reshaped, reshaped_mask = pipeline._reshape_embeds_and_mask([layer, layer + 1], mask, 2)
|
|
|
|
assert batch_size == 2
|
|
assert seq_len == _SEQ_LEN
|
|
assert isinstance(reshaped, list) and len(reshaped) == 2
|
|
assert reshaped[0].shape == (4, _SEQ_LEN, _EMBED_DIM)
|
|
# repeat(1, n, 1).view(b*n, ...) interleaves per-sample: rows [s0, s0, s1, s1]
|
|
assert torch.equal(reshaped[0][0], reshaped[0][1])
|
|
assert torch.equal(reshaped[0][2], reshaped[0][3])
|
|
|
|
|
|
class _FakeTransformer:
|
|
"""Fake denoiser: velocity 0 (identity flow) with the config the loop reads."""
|
|
|
|
in_channels = 4
|
|
axes_dim_rope = (8, 4, 4)
|
|
axes_lens = (32, 16, 16)
|
|
dtype = torch.float32
|
|
instruction_feature_configs = {
|
|
"instruction_feat_dim": _EMBED_DIM,
|
|
"num_instruction_feature_layers": 1,
|
|
"reduce_type": "mean",
|
|
}
|
|
|
|
def __call__(self, latents, timestep, instruction_embeds, freqs_cis, instruction_attention_mask, **kwargs):
|
|
return torch.zeros_like(latents)
|
|
|
|
|
|
class _FakeScheduler:
|
|
def set_timesteps(self, num_inference_steps, device=None, num_tokens=None):
|
|
self.timesteps = torch.linspace(0, 1, num_inference_steps + 1)[:-1]
|
|
|
|
def step(self, model_output, t, latents, return_dict=False):
|
|
return (latents,)
|
|
|
|
|
|
class _FakeDecodeVAE:
|
|
dtype = torch.float32
|
|
|
|
def __init__(self):
|
|
self.config = SimpleNamespace(scaling_factor=1.0, shift_factor=0.0, block_out_channels=[128, 256, 512, 512])
|
|
|
|
def decode(self, latents, return_dict=False):
|
|
batch = latents.shape[0]
|
|
return (torch.zeros(batch, 3, 16, 16),)
|
|
|
|
|
|
def _make_forward_pipeline():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.transformer = _FakeTransformer()
|
|
pipeline.scheduler = _FakeScheduler()
|
|
pipeline.vae = _FakeDecodeVAE()
|
|
pipeline.vae_scale_factor = 8
|
|
pipeline.default_sample_size = 128
|
|
return pipeline
|
|
|
|
|
|
def _make_request_batch(prompt, **sampling_overrides):
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
sampling = OmniDiffusionSamplingParams(**sampling_overrides)
|
|
return SimpleNamespace(prompts=[prompt], sampling_params=sampling)
|
|
|
|
|
|
def test_forward_returns_diffusion_output():
|
|
from vllm_omni.diffusion.data import DiffusionOutput
|
|
|
|
pipeline = _make_forward_pipeline()
|
|
req = _make_request_batch("a cat", height=64, width=64, num_inference_steps=2)
|
|
|
|
out = pipeline.forward(req)
|
|
|
|
assert isinstance(out, DiffusionOutput)
|
|
assert isinstance(out.output, torch.Tensor)
|
|
assert out.output.shape[0] == 1
|
|
assert torch.isfinite(out.output).all()
|
|
# CFG default is on (text guidance 4.0), so the encoder ran twice (pos + neg).
|
|
assert len(pipeline.processor.calls) == 2
|
|
|
|
|
|
def test_forward_cfg_off_when_guidance_one():
|
|
pipeline = _make_forward_pipeline()
|
|
req = _make_request_batch("a cat", height=64, width=64, num_inference_steps=2, guidance_scale=1.0)
|
|
# guidance_scale=1.0 is falsy-adjacent but explicitly disables CFG; the
|
|
# request layer would set guidance_scale_provided, so emulate that here.
|
|
req.sampling_params.guidance_scale_provided = True
|
|
|
|
pipeline.forward(req)
|
|
|
|
# Only the positive prompt is encoded when CFG is off.
|
|
assert len(pipeline.processor.calls) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Editing / TI2I: pre-process function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_edit_od_config(tmp_path, block_out_channels=(128, 256, 512, 512)):
|
|
import json
|
|
|
|
vae_dir = tmp_path / "vae"
|
|
vae_dir.mkdir(parents=True, exist_ok=True)
|
|
(vae_dir / "config.json").write_text(json.dumps({"block_out_channels": list(block_out_channels)}))
|
|
return OmniDiffusionConfig(
|
|
model=str(tmp_path),
|
|
tf_model_config=TransformerConfig(params={}),
|
|
dtype=torch.float32,
|
|
num_gpus=1,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"factory_name",
|
|
["get_boogu_image_pre_process_func", "get_boogu_image_post_process_func"],
|
|
)
|
|
@pytest.mark.parametrize("config_contents", [None, "{not valid json"])
|
|
def test_process_factory_reports_invalid_vae_config(tmp_path, factory_name, config_contents):
|
|
import importlib
|
|
|
|
vae_dir = tmp_path / "vae"
|
|
vae_dir.mkdir()
|
|
if config_contents is not None:
|
|
(vae_dir / "config.json").write_text(config_contents)
|
|
|
|
od_config = OmniDiffusionConfig(
|
|
model=str(tmp_path),
|
|
tf_model_config=TransformerConfig(params={}),
|
|
dtype=torch.float32,
|
|
num_gpus=1,
|
|
)
|
|
factory = getattr(importlib.import_module(_MODULE), factory_name)
|
|
|
|
with pytest.raises(RuntimeError, match=r"Failed to load Boogu VAE config from .*vae/config\.json"):
|
|
factory(od_config)
|
|
|
|
|
|
@pytest.mark.parametrize(("height", "width"), [(0, 16), (16, 0), (0, 0)])
|
|
def test_image_processor_rejects_non_positive_dimensions(height, width):
|
|
from vllm_omni.diffusion.models.boogu_image.image_processor import BooguImageProcessor
|
|
|
|
image_processor = BooguImageProcessor()
|
|
image = torch.zeros(1, 3, 16, 16)
|
|
|
|
with pytest.raises(ValueError, match=rf"height={height}, width={width}"):
|
|
image_processor.get_new_height_width(image, height=height, width=width)
|
|
|
|
|
|
def _make_diffusion_request(prompt, **sampling_overrides):
|
|
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
sampling = OmniDiffusionSamplingParams(**sampling_overrides)
|
|
return OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id="req-0")
|
|
|
|
|
|
def test_pre_process_no_image_is_noop(tmp_path):
|
|
import PIL.Image # noqa: F401 (import guard: PIL must be available)
|
|
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
get_boogu_image_pre_process_func,
|
|
)
|
|
|
|
pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path))
|
|
|
|
# Text-to-image request: no multimodal image -> returned unchanged.
|
|
req = _make_diffusion_request({"prompt": "a cat"}, height=123, width=456)
|
|
out = pre(req)
|
|
assert "additional_information" not in out.prompt
|
|
assert out.sampling_params.height == 123
|
|
assert out.sampling_params.width == 456
|
|
|
|
# A plain-string prompt cannot carry an image either.
|
|
str_req = _make_diffusion_request("a cat")
|
|
assert pre(str_req).prompt == "a cat"
|
|
|
|
|
|
def test_pre_process_populates_reference_and_align_res(tmp_path):
|
|
import PIL.Image
|
|
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
get_boogu_image_pre_process_func,
|
|
)
|
|
|
|
pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path))
|
|
|
|
image = PIL.Image.new("RGB", (1000, 500)) # (width, height)
|
|
req = _make_diffusion_request({"prompt": "make it winter", "multi_modal_data": {"image": image}})
|
|
out = pre(req)
|
|
|
|
ai = out.prompt["additional_information"]
|
|
assert "prompt_image" in ai and "preprocessed_image" in ai
|
|
|
|
# VLM copy is a PIL image, downscaled, never upscaled, aligned to 16.
|
|
prompt_image = ai["prompt_image"]
|
|
assert isinstance(prompt_image, PIL.Image.Image)
|
|
assert prompt_image.width % 16 == 0 and prompt_image.height % 16 == 0
|
|
assert prompt_image.width <= 1000 and prompt_image.height <= 500
|
|
assert max(prompt_image.width, prompt_image.height) <= 768
|
|
|
|
# VAE copy is a normalized [1, C, H, W] tensor aligned to 16.
|
|
vae = ai["preprocessed_image"]
|
|
assert isinstance(vae, torch.Tensor) and vae.ndim == 4 and vae.shape[0] == 1
|
|
assert vae.shape[-1] % 16 == 0 and vae.shape[-2] % 16 == 0
|
|
assert -1.0 <= float(vae.min()) and float(vae.max()) <= 1.0
|
|
|
|
# align_res: the request resolution follows the VAE-encoded reference dims.
|
|
assert out.sampling_params.height == vae.shape[-2]
|
|
assert out.sampling_params.width == vae.shape[-1]
|
|
|
|
|
|
def test_pre_process_rejects_multiple_images(tmp_path):
|
|
import PIL.Image
|
|
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
get_boogu_image_pre_process_func,
|
|
)
|
|
|
|
pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path))
|
|
imgs = [PIL.Image.new("RGB", (64, 64)), PIL.Image.new("RGB", (64, 64))]
|
|
req = _make_diffusion_request({"prompt": "combine", "multi_modal_data": {"image": imgs}})
|
|
|
|
with pytest.raises(ValueError, match="single reference image"):
|
|
pre(req)
|
|
|
|
|
|
def test_pre_process_single_image_in_list_is_accepted(tmp_path):
|
|
import PIL.Image
|
|
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
get_boogu_image_pre_process_func,
|
|
)
|
|
|
|
pre = get_boogu_image_pre_process_func(_make_edit_od_config(tmp_path))
|
|
req = _make_diffusion_request({"prompt": "edit", "multi_modal_data": {"image": [PIL.Image.new("RGB", (128, 128))]}})
|
|
out = pre(req)
|
|
assert "preprocessed_image" in out.prompt["additional_information"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Editing / TI2I: chat template + image-aware encoding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_edit_encode_pipeline():
|
|
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import (
|
|
SYSTEM_PROMPT_4_TI2I_UNIFIED,
|
|
)
|
|
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.SYSTEM_PROMPT_4_TI2I = SYSTEM_PROMPT_4_TI2I_UNIFIED
|
|
pipeline.SYSTEM_PROMPT_4_I2I = SYSTEM_PROMPT_4_TI2I_UNIFIED
|
|
return pipeline
|
|
|
|
|
|
def test_apply_chat_template_ti2i_places_image_before_text():
|
|
import PIL.Image
|
|
|
|
pipeline = _make_edit_encode_pipeline()
|
|
image = PIL.Image.new("RGB", (16, 16))
|
|
|
|
messages = pipeline._apply_chat_template("turn day into night", [image])
|
|
assert messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_TI2I
|
|
user_content = messages[1]["content"]
|
|
# Image content comes first, then the instruction text.
|
|
assert user_content[0]["type"] == "image"
|
|
assert user_content[0]["image"] is image
|
|
assert user_content[-1] == {"type": "text", "text": "turn day into night"}
|
|
|
|
# Empty instruction with an image selects the I2I system prompt.
|
|
empty_messages = pipeline._apply_chat_template("", [image])
|
|
assert empty_messages[0]["content"][0]["text"] == pipeline.SYSTEM_PROMPT_4_I2I
|
|
|
|
|
|
class _ImageAwareRecordingProcessor:
|
|
"""Records whether reference images reached the processor."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def apply_chat_template(self, prompts, **kwargs):
|
|
has_image = []
|
|
for messages in prompts:
|
|
user_content = messages[1]["content"]
|
|
has_image.append(any(c.get("type") == "image" for c in user_content))
|
|
self.calls.append({"prompts": prompts, "kwargs": kwargs, "has_image": has_image})
|
|
batch = len(prompts)
|
|
input_ids = torch.arange(batch * _SEQ_LEN, dtype=torch.long).view(batch, _SEQ_LEN)
|
|
attention_mask = torch.ones(batch, _SEQ_LEN, dtype=torch.long)
|
|
return {"input_ids": input_ids, "attention_mask": attention_mask}
|
|
|
|
|
|
def test_encode_prompt_attaches_images_to_positive_only():
|
|
import PIL.Image
|
|
|
|
pipeline = _make_edit_encode_pipeline()
|
|
pipeline.processor = _ImageAwareRecordingProcessor()
|
|
image = PIL.Image.new("RGB", (16, 16))
|
|
|
|
pipeline.encode_prompt(
|
|
"add a rainbow",
|
|
do_classifier_free_guidance=True,
|
|
input_images=[[image]],
|
|
)
|
|
|
|
# Positive call carries the image; the negative (CFG) call is image-free.
|
|
assert pipeline.processor.calls[0]["has_image"] == [True]
|
|
assert pipeline.processor.calls[1]["has_image"] == [False]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Editing / TI2I: reference-latent VAE encode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeRefVAE:
|
|
"""Fake VAE whose ``encode`` yields a known latent for shape/scaling checks."""
|
|
|
|
dtype = torch.float32
|
|
|
|
def __init__(self, scaling_factor=2.0, shift_factor=0.5):
|
|
self.config = SimpleNamespace(scaling_factor=scaling_factor, shift_factor=shift_factor)
|
|
self._latent = torch.ones(1, 4, 3, 5)
|
|
|
|
def encode(self, img):
|
|
dist = SimpleNamespace(sample=lambda generator=None: self._latent.clone())
|
|
return SimpleNamespace(latent_dist=dist)
|
|
|
|
|
|
def test_build_ref_latents_shape_and_scaling():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.vae = _FakeRefVAE(scaling_factor=2.0, shift_factor=0.5)
|
|
|
|
preprocessed = torch.zeros(1, 3, 48, 80) # normalized image tensor
|
|
ref_latents = pipeline._build_ref_latents([preprocessed], num_images_per_prompt=1, device=torch.device("cpu"))
|
|
|
|
assert len(ref_latents) == 1
|
|
(sample_latents,) = ref_latents
|
|
assert isinstance(sample_latents, list) and len(sample_latents) == 1
|
|
latent = sample_latents[0]
|
|
# squeeze(0) -> [C, H, W]; (1 - shift) * scaling = (1 - 0.5) * 2 = 1.0
|
|
assert latent.shape == (4, 3, 5)
|
|
assert torch.allclose(latent, torch.ones(4, 3, 5))
|
|
|
|
|
|
def test_build_ref_latents_expands_per_output_and_handles_none():
|
|
pipeline = _make_encode_pipeline()
|
|
pipeline.vae = _FakeRefVAE()
|
|
|
|
preprocessed = torch.zeros(1, 3, 48, 80)
|
|
ref_latents = pipeline._build_ref_latents([preprocessed, None], num_images_per_prompt=2, device=torch.device("cpu"))
|
|
|
|
# Two samples x 2 outputs each = 4 entries; the None sample stays None.
|
|
assert len(ref_latents) == 4
|
|
assert ref_latents[0] is ref_latents[1] # same sample repeated
|
|
assert ref_latents[2] is None and ref_latents[3] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Editing / TI2I: forward CFG branch selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _RecordingRefTransformer(_FakeTransformer):
|
|
"""Counts predictions per step and records the reference-latent argument."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def __call__(self, latents, timestep, instruction_embeds, freqs_cis, instruction_attention_mask, **kwargs):
|
|
self.calls.append(kwargs.get("ref_image_hidden_states"))
|
|
return torch.zeros_like(latents)
|
|
|
|
|
|
class _EditForwardVAE(_FakeDecodeVAE):
|
|
"""Adds a fake ``encode`` so the editing forward path can build ref latents."""
|
|
|
|
def encode(self, img):
|
|
dist = SimpleNamespace(sample=lambda generator=None: torch.zeros(1, 4, 8, 8))
|
|
return SimpleNamespace(latent_dist=dist)
|
|
|
|
|
|
def _make_edit_forward_pipeline():
|
|
pipeline = _make_edit_encode_pipeline()
|
|
# Image-aware processor: reference images appear as ``{"type": "image"}``
|
|
# content entries, which the default text-first fake cannot parse.
|
|
pipeline.processor = _ImageAwareRecordingProcessor()
|
|
pipeline.transformer = _RecordingRefTransformer()
|
|
pipeline.scheduler = _FakeScheduler()
|
|
pipeline.vae = _EditForwardVAE()
|
|
pipeline.vae_scale_factor = 8
|
|
pipeline.default_sample_size = 128
|
|
return pipeline
|
|
|
|
|
|
def _make_edit_request(**sampling_overrides):
|
|
import PIL.Image
|
|
|
|
image = PIL.Image.new("RGB", (64, 64))
|
|
prompt = {
|
|
"prompt": "make it winter",
|
|
"additional_information": {
|
|
"prompt_image": image,
|
|
"preprocessed_image": torch.zeros(1, 3, 64, 64),
|
|
},
|
|
}
|
|
sampling = _sampling(**sampling_overrides)
|
|
return SimpleNamespace(prompts=[prompt], sampling_params=sampling)
|
|
|
|
|
|
def _sampling(**overrides):
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
return OmniDiffusionSamplingParams(**overrides)
|
|
|
|
|
|
def _count_per_step(calls, num_steps):
|
|
assert len(calls) % num_steps == 0
|
|
return len(calls) // num_steps
|
|
|
|
|
|
def test_forward_ti2i_text_only_two_predictions_with_ref():
|
|
pipeline = _make_edit_forward_pipeline()
|
|
num_steps = 2
|
|
req = _make_edit_request(height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0)
|
|
req.sampling_params.guidance_scale_provided = True
|
|
|
|
pipeline.forward(req)
|
|
|
|
# Text-only ti2i: cond+ref and neg+ref -> 2 predictions/step, both carry ref.
|
|
assert _count_per_step(pipeline.transformer.calls, num_steps) == 2
|
|
assert all(ref is not None for ref in pipeline.transformer.calls)
|
|
|
|
|
|
def test_forward_ti2i_double_guidance_three_predictions():
|
|
pipeline = _make_edit_forward_pipeline()
|
|
num_steps = 2
|
|
req = _make_edit_request(
|
|
height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0, guidance_scale_2=2.0
|
|
)
|
|
req.sampling_params.guidance_scale_provided = True
|
|
req.sampling_params.guidance_scale_2_provided = True
|
|
|
|
pipeline.forward(req)
|
|
|
|
# Double guidance: cond+ref, neg+ref, neg+no-ref -> 3 predictions/step.
|
|
assert _count_per_step(pipeline.transformer.calls, num_steps) == 3
|
|
# Exactly one of the three per step drops the reference (neg+no-ref).
|
|
per_step = [pipeline.transformer.calls[i : i + 3] for i in range(0, len(pipeline.transformer.calls), 3)]
|
|
for step_calls in per_step:
|
|
assert sum(ref is None for ref in step_calls) == 1
|
|
|
|
|
|
def test_forward_ti2i_image_only_two_predictions_drop_ref():
|
|
pipeline = _make_edit_forward_pipeline()
|
|
num_steps = 2
|
|
# text guidance 1.0 (off, provided) + image guidance 2.0 (provided).
|
|
req = _make_edit_request(
|
|
height=64, width=64, num_inference_steps=num_steps, guidance_scale=1.0, guidance_scale_2=2.0
|
|
)
|
|
req.sampling_params.guidance_scale_provided = True
|
|
req.sampling_params.guidance_scale_2_provided = True
|
|
|
|
pipeline.forward(req)
|
|
|
|
# Image-only ti2i: cond+ref and cond+no-ref -> 2 predictions/step.
|
|
assert _count_per_step(pipeline.transformer.calls, num_steps) == 2
|
|
per_step = [pipeline.transformer.calls[i : i + 2] for i in range(0, len(pipeline.transformer.calls), 2)]
|
|
for step_calls in per_step:
|
|
assert sum(ref is None for ref in step_calls) == 1
|
|
|
|
|
|
def test_forward_ti2i_no_guidance_single_prediction_with_ref():
|
|
pipeline = _make_edit_forward_pipeline()
|
|
num_steps = 2
|
|
# Both guidances off/unprovided -> image guidance forced to 1.0, no CFG.
|
|
req = _make_edit_request(height=64, width=64, num_inference_steps=num_steps, guidance_scale=1.0)
|
|
req.sampling_params.guidance_scale_provided = True
|
|
|
|
pipeline.forward(req)
|
|
|
|
assert _count_per_step(pipeline.transformer.calls, num_steps) == 1
|
|
assert all(ref is not None for ref in pipeline.transformer.calls)
|
|
|
|
|
|
def test_forward_image_guidance_ignored_without_reference():
|
|
# guidance_scale_2 is set but there is no reference image (t2i request);
|
|
# image guidance must be forced off so this stays plain t2i CFG.
|
|
pipeline = _make_edit_forward_pipeline()
|
|
num_steps = 2
|
|
sampling = _sampling(height=64, width=64, num_inference_steps=num_steps, guidance_scale=5.0, guidance_scale_2=2.0)
|
|
sampling.guidance_scale_provided = True
|
|
sampling.guidance_scale_2_provided = True
|
|
req = SimpleNamespace(prompts=[{"prompt": "a cat"}], sampling_params=sampling)
|
|
|
|
pipeline.forward(req)
|
|
|
|
# t2i text CFG: cond + uncond -> 2 predictions/step, no ref anywhere.
|
|
assert _count_per_step(pipeline.transformer.calls, num_steps) == 2
|
|
assert all(ref is None for ref in pipeline.transformer.calls)
|