[diffusion] CI: add consistency test (#15236)

Co-authored-by: daiweitao <dwti614707404@163.com>
This commit is contained in:
Prozac614
2026-04-07 09:50:23 +08:00
committed by GitHub
co-authored by daiweitao
parent e4b1366a46
commit ef2d4013d7
11 changed files with 1026 additions and 9 deletions
@@ -633,6 +633,7 @@ jobs:
-e SGLANG_NON_DENOISE_STAGE_TIME_TOLERANCE=0.6 \
-e SGLANG_DENOISE_STEP_TOLERANCE=0.6 \
-e SGLANG_DENOISE_AGG_TOLERANCE=0.3 \
-e SGLANG_SKIP_CONSISTENCY=1 \
-e SGLANG_TEST_NUM_INFERENCE_STEPS=5 \
-e AITER_JIT_DIR=/sgl-data/aiter-kernels \
-e MIOPEN_USER_DB_PATH=/sgl-data/miopen-cache \
@@ -762,6 +763,7 @@ jobs:
-e SGLANG_NON_DENOISE_STAGE_TIME_TOLERANCE=0.6 \
-e SGLANG_DENOISE_STEP_TOLERANCE=0.6 \
-e SGLANG_DENOISE_AGG_TOLERANCE=0.3 \
-e SGLANG_SKIP_CONSISTENCY=1 \
-e SGLANG_TEST_NUM_INFERENCE_STEPS=5 \
-e AITER_JIT_DIR=/sgl-data/aiter-kernels \
-e MIOPEN_USER_DB_PATH=/sgl-data/miopen-cache \
+2
View File
@@ -638,6 +638,7 @@ jobs:
-e SGLANG_NON_DENOISE_STAGE_TIME_TOLERANCE=0.6 \
-e SGLANG_DENOISE_STEP_TOLERANCE=0.6 \
-e SGLANG_DENOISE_AGG_TOLERANCE=0.3 \
-e SGLANG_SKIP_CONSISTENCY=1 \
-e SGLANG_TEST_NUM_INFERENCE_STEPS=5 \
-e AITER_JIT_DIR=/sgl-data/aiter-kernels \
-e MIOPEN_USER_DB_PATH=/sgl-data/miopen-cache \
@@ -765,6 +766,7 @@ jobs:
-e SGLANG_NON_DENOISE_STAGE_TIME_TOLERANCE=0.6 \
-e SGLANG_DENOISE_STEP_TOLERANCE=0.6 \
-e SGLANG_DENOISE_AGG_TOLERANCE=0.3 \
-e SGLANG_SKIP_CONSISTENCY=1 \
-e SGLANG_TEST_NUM_INFERENCE_STEPS=5 \
-e AITER_JIT_DIR=/sgl-data/aiter-kernels \
-e MIOPEN_USER_DB_PATH=/sgl-data/miopen-cache \
@@ -506,16 +506,25 @@ class LoRAPipeline(ComposedPipelineBase):
return bool(self.cur_adapter_name)
return target in self.cur_adapter_name
def load_lora_adapter(self, lora_path: str, lora_nickname: str, rank: int):
def load_lora_adapter(
self,
lora_path: str,
lora_nickname: str,
rank: int,
weight_name: str | None = None,
):
"""
Load the LoRA, and setup the lora_adapters for later weight replacement
"""
assert lora_path is not None
if weight_name is None and lora_path == self.server_args.lora_path:
weight_name = self.server_args.lora_weight_name
# Only rank 0 downloads to avoid race conditions where other ranks
# try to load incomplete downloads
if rank == 0:
lora_local_path = maybe_download_lora(lora_path)
lora_local_path = maybe_download_lora(lora_path, weight_name=weight_name)
else:
lora_local_path = None
@@ -525,7 +534,7 @@ class LoRAPipeline(ComposedPipelineBase):
# Non-rank-0 workers now download (will hit cache since rank 0 completed)
if rank != 0:
lora_local_path = maybe_download_lora(lora_path)
lora_local_path = maybe_download_lora(lora_path, weight_name=weight_name)
raw_state_dict = load_file(lora_local_path)
lora_state_dict = normalize_lora_state_dict(raw_state_dict, logger=logger)
@@ -149,6 +149,7 @@ class ServerArgs:
lora_path: str | None = None
lora_nickname: str = "default" # for swapping adapters in the pipeline
lora_scale: float = 1.0 # LoRA scale for merging (e.g., 0.125 for Hyper-SD)
lora_weight_name: str | None = None
# Component path overrides (key = model_index.json component name, value = path)
component_paths: dict[str, str] = field(default_factory=dict)
@@ -849,6 +850,12 @@ class ServerArgs:
default=ServerArgs.lora_scale,
help="LoRA scale for merging (e.g., 0.125 for Hyper-SD). Same as lora_scale in Diffusers",
)
parser.add_argument(
"--lora-weight-name",
type=str,
default=ServerArgs.lora_weight_name,
help="Specific safetensors filename to load from a multi-file LoRA repo",
)
# Add pipeline configuration arguments
PipelineConfig.add_cli_args(parser)
@@ -378,7 +378,10 @@ def check_gguf_file(model: str | os.PathLike) -> bool:
def maybe_download_lora(
model_name_or_path: str, local_dir: str | None = None, download: bool = True
model_name_or_path: str,
local_dir: str | None = None,
download: bool = True,
weight_name: str | None = None,
) -> str:
"""
Check if the model path is a Hugging Face Hub model ID and download it if needed.
@@ -386,6 +389,8 @@ def maybe_download_lora(
model_name_or_path: Local path or Hugging Face Hub model ID
local_dir: Local directory to save the model
download: Whether to download the model from Hugging Face Hub
weight_name: Specific safetensors filename to load (pins deterministic selection
for repos with multiple weight files)
Returns:
Local path to the model
@@ -403,14 +408,22 @@ def maybe_download_lora(
if os.path.isfile(local_path):
return local_path
weight_name = _best_guess_weight_name(local_path, file_extension=".safetensors")
if weight_name is not None:
target = os.path.join(local_path, weight_name)
if not os.path.isfile(target):
raise FileNotFoundError(
f"Specified lora_weight_name '{weight_name}' not found in {local_path}"
)
return target
guessed = _best_guess_weight_name(local_path, file_extension=".safetensors")
# AMD workaround: PR 15813 changed from model_name_or_path to local_path,
# which can return None. Fall back to original behavior on ROCm.
if weight_name is None and current_platform.is_rocm():
weight_name = _best_guess_weight_name(
if guessed is None and current_platform.is_rocm():
guessed = _best_guess_weight_name(
model_name_or_path, file_extension=".safetensors"
)
return os.path.join(local_path, weight_name)
return os.path.join(local_path, guessed)
def verify_model_config_and_directory(model_path: str) -> dict[str, Any]:
@@ -15,6 +15,7 @@ ONE_NPU_CASES: list[DiffusionTestCase] = [
modality="image",
),
T2I_sampling_params,
run_consistency_check=False,
),
# === Text to Video (T2V) ===
DiffusionTestCase(
@@ -27,6 +28,7 @@ ONE_NPU_CASES: list[DiffusionTestCase] = [
DiffusionSamplingParams(
prompt=T2V_PROMPT,
),
run_consistency_check=False,
),
]
@@ -41,6 +43,7 @@ TWO_NPU_CASES: list[DiffusionTestCase] = [
tp_size=2,
),
T2I_sampling_params,
run_consistency_check=False,
),
DiffusionTestCase(
"qwen_image_t2i_2npu",
@@ -53,6 +56,7 @@ TWO_NPU_CASES: list[DiffusionTestCase] = [
ring_degree=2,
),
T2I_sampling_params,
run_consistency_check=False,
),
]
@@ -70,5 +74,6 @@ EIGHT_NPU_CASES: list[DiffusionTestCase] = [
DiffusionSamplingParams(
prompt=T2V_PROMPT,
),
run_consistency_check=False,
),
]
@@ -0,0 +1,205 @@
{
"_comment": "Some cases use lower thresholds; raise them if quality/perf improves later.",
"cases": {
"qwen_image_t2i": {
"clip_threshold": 0.97,
"ssim_threshold": 0.84,
"psnr_threshold": 16.0,
"mean_abs_diff_threshold": 11.5
},
"flux_image_t2i": {
"clip_threshold": 0.92,
"ssim_threshold": 0.95,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 8.0
},
"flux_2_klein_image_t2i": {
"clip_threshold": 0.86,
"ssim_threshold": 0.60,
"psnr_threshold": 10.0,
"mean_abs_diff_threshold": 56.0
},
"zimage_image_t2i": {
"clip_threshold": 0.92,
"ssim_threshold": 0.90,
"psnr_threshold": 22.0,
"mean_abs_diff_threshold": 8.0
},
"zimage_image_t2i_multi_lora": {
"clip_threshold": 0.92,
"ssim_threshold": 0.92,
"psnr_threshold": 22.0,
"mean_abs_diff_threshold": 8.0
},
"qwen_image_t2i_cache_dit_enabled": {
"clip_threshold": 0.92,
"ssim_threshold": 0.86,
"psnr_threshold": 17.0,
"mean_abs_diff_threshold": 10.0
},
"layerwise_offload": {
"clip_threshold": 0.92,
"ssim_threshold": 0.91,
"psnr_threshold": 21.5,
"mean_abs_diff_threshold": 8.0
},
"zimage_image_t2i_fp8": {
"clip_threshold": 0.92,
"ssim_threshold": 0.84,
"psnr_threshold": 19.0,
"mean_abs_diff_threshold": 10.0
},
"sana_image_t2i": {
"clip_threshold": 0.91,
"ssim_threshold": 0.88,
"psnr_threshold": 21.0,
"mean_abs_diff_threshold": 8.4
},
"qwen_image_edit_2509_ti2i": {
"clip_threshold": 0.92,
"ssim_threshold": 0.65,
"psnr_threshold": 13.0,
"mean_abs_diff_threshold": 26.0
},
"qwen_image_layered_i2i": {
"clip_threshold": 0.92,
"ssim_threshold": 0.94,
"psnr_threshold": 28.0,
"mean_abs_diff_threshold": 8.0
},
"mova_360p_1gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.87,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"wan2_1_t2v_1_3b_lora_1gpu": {
"clip_threshold": 0.54,
"ssim_threshold": 0.40,
"psnr_threshold": 13.2,
"mean_abs_diff_threshold": 32.0
},
"wan2_2_ti2v_5b": {
"clip_threshold": 0.90,
"ssim_threshold": 0.81,
"psnr_threshold": 20.4,
"mean_abs_diff_threshold": 10.0
},
"fastwan2_2_ti2v_5b": {
"clip_threshold": 0.90,
"ssim_threshold": 0.88,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"turbo_wan2_1_t2v_1.3b": {
"clip_threshold": 0.90,
"ssim_threshold": 0.52,
"psnr_threshold": 9.5,
"mean_abs_diff_threshold": 46.0
},
"zimage_image_t2i_multi_lora": {
"clip_threshold": 0.90,
"ssim_threshold": 0.52,
"psnr_threshold": 9.5,
"mean_abs_diff_threshold": 46.0
},
"fsdp-inference": {
"clip_threshold": 0.92,
"ssim_threshold": 0.90,
"psnr_threshold": 21.5,
"mean_abs_diff_threshold": 8.0
},
"zimage_image_t2i_2_gpus_non_square": {
"clip_threshold": 0.92,
"ssim_threshold": 0.76,
"psnr_threshold": 14.8,
"mean_abs_diff_threshold": 17.5
},
"flux_2_image_t2i_2_gpus": {
"clip_threshold": 0.54,
"ssim_threshold": 0.9,
"psnr_threshold": 19,
"mean_abs_diff_threshold": 8.0
},
"zimage_image_t2i_2_gpus": {
"clip_threshold": 0.92,
"ssim_threshold": 0.90,
"psnr_threshold": 21.5,
"mean_abs_diff_threshold": 8.0
},
"flux_image_t2i_2_gpus": {
"clip_threshold": 0.92,
"ssim_threshold": 0.90,
"psnr_threshold": 18.7,
"mean_abs_diff_threshold": 8.0
},
"flux_2_klein_ti2i_2_gpus": {
"clip_threshold": 0.92,
"ssim_threshold": 0.77,
"psnr_threshold": 18.4,
"mean_abs_diff_threshold": 18.0
},
"wan2_2_t2v_a14b_teacache_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.72,
"psnr_threshold": 17.8,
"mean_abs_diff_threshold": 16.0
},
"wan2_1_t2v_14b_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.84,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"mova_360p_ring1_uly2": {
"clip_threshold": 0.90,
"ssim_threshold": 0.91,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"wan2_1_i2v_14b_lora_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.81,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"wan2_2_t2v_a14b_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.72,
"psnr_threshold": 17.8,
"mean_abs_diff_threshold": 16.0
},
"wan2_2_t2v_a14b_lora_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.81,
"psnr_threshold": 22.2,
"mean_abs_diff_threshold": 10.0
},
"mova_360p_ring2_uly1": {
"clip_threshold": 0.90,
"ssim_threshold": 0.91,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
},
"wan2_1_i2v_14b_480P_2gpu": {
"clip_threshold": 0.76,
"ssim_threshold": 0.51,
"psnr_threshold": 14.8,
"mean_abs_diff_threshold": 23.0
},
"wan2_1_i2v_14b_720P_2gpu": {
"clip_threshold": 0.90,
"ssim_threshold": 0.89,
"psnr_threshold": 24.0,
"mean_abs_diff_threshold": 10.0
}
},
"default_clip_threshold_image": 0.92,
"default_clip_threshold_video": 0.90,
"default_ssim_threshold_image": 0.95,
"default_psnr_threshold_image": 28.0,
"default_mean_abs_diff_threshold_image": 8.0,
"default_ssim_threshold_video": 0.92,
"default_psnr_threshold_video": 24.0,
"default_mean_abs_diff_threshold_video": 10.0
}
@@ -35,8 +35,16 @@ from sglang.multimodal_gen.test.server.testcase_configs import (
)
from sglang.multimodal_gen.test.test_utils import (
_consistency_gt_filenames,
_get_consistency_gt_dir,
compare_with_gt,
extract_key_frames_from_video,
get_consistency_gt_candidates,
get_consistency_gt_remote_files,
get_consistency_thresholds,
get_dynamic_server_port,
gt_exists,
image_bytes_to_numpy,
load_consistency_gt,
wait_for_req_perf_record,
)
@@ -445,6 +453,146 @@ Consider updating perf_baselines.json with the snippets below:
"""
logger.error(output)
def _validate_consistency(
self,
case: DiffusionTestCase,
content: bytes,
) -> None:
"""Validate output consistency against ground truth using CLIP similarity."""
if os.environ.get("SGLANG_SKIP_CONSISTENCY", "0") == "1":
logger.info(
f"[Consistency] Skipping consistency check for {case.id} (SGLANG_SKIP_CONSISTENCY=1)"
)
return
if not content:
logger.warning(
f"[Consistency] Skipping consistency check for {case.id}: "
"content is empty (generation may have timed out)"
)
return
num_gpus = case.server_args.num_gpus
is_video = case.server_args.modality == "video"
output_format = case.sampling_params.output_format
if not gt_exists(
case.id, num_gpus, is_video=is_video, output_format=output_format
):
if _get_consistency_gt_dir() is not None:
names = ", ".join(
get_consistency_gt_candidates(
case.id, num_gpus, is_video, output_format
)
)
else:
names = ", ".join(
_consistency_gt_filenames(
case.id, num_gpus, is_video, output_format
)
)
logger.error(f"""
--- MISSING GROUND TRUTH DETECTED ---
GT image(s) not found for '{case.id}'.
Add the expected file(s) to sglang-ci-data in diffusion-ci/consistency_gt/ with naming (n=num_gpus).
Image: {case.id}_{{n}}gpu.<ext> (ext from output_format: png, jpg, webp)
Video: {case.id}_{{n}}gpu_frame_0.png, {case.id}_{{n}}gpu_frame_mid.png, {case.id}_{{n}}gpu_frame_last.png
For this case, expected file(s): {names}
Repository: https://github.com/sglang-bot/sglang-ci-data (path: diffusion-ci/consistency_gt/)
(Optional) Per-case override in consistency_threshold.json:
"cases": {{
"{case.id}": {{
"clip_threshold": 0.92,
"ssim_threshold": 0.95,
"psnr_threshold": 28.0,
"mean_abs_diff_threshold": 8.0
}}
}}
""")
pytest.fail(
f"GT not found for {case.id}. See logs for instructions to add GT."
)
gt_data = load_consistency_gt(
case.id, num_gpus, is_video=is_video, output_format=output_format
)
thresholds = get_consistency_thresholds(case.id, is_video=is_video)
if is_video:
output_frames = extract_key_frames_from_video(content)
else:
output_frames = [image_bytes_to_numpy(content)]
result = compare_with_gt(
output_frames=output_frames,
gt_data=gt_data,
thresholds=thresholds,
case_id=case.id,
)
if not result.passed:
failed_frames = []
video_gt_info = ""
if is_video:
gt_remote_files = get_consistency_gt_remote_files(
case.id,
num_gpus,
is_video=True,
output_format=output_format,
)
video_gt_info = "\n".join(
f" - {filename}: {url}" for filename, url in gt_remote_files
)
for metric in result.frame_metrics:
failed_metrics = []
if not metric.clip_passed:
failed_metrics.append("clip")
if not metric.ssim_passed:
failed_metrics.append("ssim")
if not metric.psnr_passed:
failed_metrics.append("psnr")
if not metric.mean_abs_diff_passed:
failed_metrics.append("mean_abs_diff")
if failed_metrics:
failed_frames.append(
f" - f{metric.frame_index} "
f"[{', '.join(failed_metrics)}] "
f"clip={metric.clip_similarity:.4f} "
f"ssim={metric.ssim:.4f} "
f"psnr={metric.psnr:.4f} "
f"mean_abs_diff={metric.mean_abs_diff:.4f}"
)
pytest.fail(
f"Consistency check failed for {case.id}:\n"
f" Metrics: sim={result.min_similarity:.4f}, "
f"ssim={result.min_ssim:.4f}, "
f"psnr={result.min_psnr:.4f}, "
f"mean_abs_diff={result.max_mean_abs_diff:.4f}\n"
f" Thresholds: clip>={result.thresholds.clip_threshold}, "
f"ssim>={result.thresholds.ssim_threshold}, "
f"psnr>={result.thresholds.psnr_threshold}, "
f"mean_abs_diff<={result.thresholds.mean_abs_diff_threshold}\n"
f" Failed frames:\n"
+ "\n".join(failed_frames)
+ (
f"\n Compared GT frame files and links:\n{video_gt_info}"
if video_gt_info
else ""
)
)
logger.info(
f"[Consistency] {case.id}: PASSED "
f"(min_similarity={result.min_similarity:.4f}, "
f"min_ssim={result.min_ssim:.4f}, "
f"min_psnr={result.min_psnr:.4f}, "
f"max_mean_abs_diff={result.max_mean_abs_diff:.4f})"
)
def _save_gt_output(
self,
case: DiffusionTestCase,
@@ -903,6 +1051,9 @@ Consider updating perf_baselines.json with the snippets below:
if case.run_t2v_input_reference_check:
self._test_t2v_rejects_input_reference(diffusion_server, case)
if case.run_consistency_check:
self._validate_consistency(case, content)
# LoRA API functionality test with E2E validation (only for LoRA-enabled cases)
if case.run_lora_basic_api_check:
self._test_lora_api_functionality(diffusion_server, case, generate_fn)
@@ -248,6 +248,7 @@ class DiffusionTestCase:
server_args: DiffusionServerArgs
sampling_params: DiffusionSamplingParams
run_perf_check: bool = True
run_consistency_check: bool = True
run_models_api_check: bool = True
run_t2v_input_reference_check: bool = True
run_lora_basic_api_check: bool = False
@@ -777,6 +778,7 @@ if not current_platform.is_hip():
enable_warmup=False,
),
HUNYUAN3D_SHAPE_sampling_params,
run_consistency_check=False,
),
)
# Skip turbowan on AMD: Triton requires 81920 shared memory, but AMD only has 65536.
@@ -802,6 +804,7 @@ ONE_GPU_CASES_C = [
modality="image",
),
T2I_sampling_params,
run_consistency_check=False,
)
]
@@ -851,6 +854,10 @@ TWO_GPU_CASES_A = [
custom_validator="video",
num_gpus=2,
lora_path="Cseti/wan2.2-14B-Arcane_Jinx-lora-v1",
extras=[
"--lora-weight-name",
"985347-wan22_14B-low-Nfj1nx-e65.safetensors",
],
),
DiffusionSamplingParams(
prompt="Nfj1nx with blue hair, a woman walking in a cyberpunk city at night",
@@ -938,6 +945,7 @@ TWO_GPU_CASES_A = [
extras=["--pipeline-class-name LTX2TwoStagePipeline"],
),
T2V_sampling_params,
run_consistency_check=False,
),
]
@@ -0,0 +1,115 @@
import math
import numpy as np
from sglang.multimodal_gen.test import test_utils
from sglang.multimodal_gen.test.test_utils import (
ConsistencyThresholds,
LoadedConsistencyGT,
compare_with_gt,
compute_mean_abs_diff,
compute_psnr,
compute_ssim,
)
def _solid_image(value: int, size: int = 32) -> np.ndarray:
return np.full((size, size, 3), value, dtype=np.uint8)
def test_pixel_metrics_identical_image():
image = _solid_image(128)
ssim = compute_ssim(image, image)
psnr = compute_psnr(image, image)
mean_abs_diff = compute_mean_abs_diff(image, image)
assert ssim == 1.0
assert math.isinf(psnr)
assert mean_abs_diff == 0.0
def test_pixel_metrics_detect_different_image():
image = _solid_image(128)
other = _solid_image(0)
ssim = compute_ssim(image, other)
psnr = compute_psnr(image, other)
mean_abs_diff = compute_mean_abs_diff(image, other)
assert ssim < 0.95
assert psnr < 28.0
assert mean_abs_diff > 8.0
def test_compare_with_gt_passes_for_identical_image(monkeypatch):
gt_image = _solid_image(128)
monkeypatch.setattr(
test_utils,
"compute_clip_embedding",
lambda image: np.array([1.0, 0.0], dtype=np.float32),
)
result = compare_with_gt(
output_frames=[gt_image.copy()],
gt_data=LoadedConsistencyGT(
images=[gt_image.copy()],
embeddings=[np.array([1.0, 0.0], dtype=np.float32)],
),
thresholds=ConsistencyThresholds(
clip_threshold=0.92,
ssim_threshold=0.95,
psnr_threshold=28.0,
mean_abs_diff_threshold=8.0,
),
case_id="unit_image_pass",
)
assert result.passed is True
assert result.min_similarity == 1.0
assert result.min_ssim == 1.0
assert math.isinf(result.min_psnr)
assert result.max_mean_abs_diff == 0.0
def test_compare_with_gt_uses_worst_frame_for_video(monkeypatch):
gt_frame_0 = _solid_image(128)
gt_frame_1 = _solid_image(128)
bad_frame = _solid_image(0)
monkeypatch.setattr(
test_utils,
"compute_clip_embedding",
lambda image: np.array([1.0, 0.0], dtype=np.float32),
)
result = compare_with_gt(
output_frames=[gt_frame_0.copy(), bad_frame],
gt_data=LoadedConsistencyGT(
images=[gt_frame_0.copy(), gt_frame_1.copy()],
embeddings=[
np.array([1.0, 0.0], dtype=np.float32),
np.array([1.0, 0.0], dtype=np.float32),
],
),
thresholds=ConsistencyThresholds(
clip_threshold=0.92,
ssim_threshold=0.95,
psnr_threshold=28.0,
mean_abs_diff_threshold=8.0,
),
case_id="unit_video_fail",
)
assert result.passed is False
assert result.min_similarity == 1.0
assert result.min_ssim < 0.95
assert result.min_psnr < 28.0
assert result.max_mean_abs_diff > 8.0
assert any(
not metric.ssim_passed
or not metric.psnr_passed
or not metric.mean_abs_diff_passed
for metric in result.frame_metrics
)
+501 -1
View File
@@ -7,12 +7,15 @@ import socket
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
import cv2
import httpx
import numpy as np
import requests
from PIL import Image
from sglang.multimodal_gen.runtime.utils.common import get_bool_env_var
@@ -22,8 +25,27 @@ from sglang.multimodal_gen.runtime.utils.perf_logger import (
get_diffusion_perf_log_dir,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.test.server.testcase_configs import DiffusionTestCase
logger = init_logger(__name__)
SGL_TEST_FILES_CONSISTENCY_GT_BASE = "https://raw.githubusercontent.com/sglang-bot/sglang-ci-data/main/diffusion-ci/consistency_gt"
CONSISTENCY_THRESHOLD_JSON_PATH = (
Path(__file__).resolve().parent / "server" / "consistency_threshold.json"
)
CLIP_MODEL_NAME = "openai/clip-vit-large-patch14"
DEFAULT_CLIP_THRESHOLD_IMAGE = 0.92
DEFAULT_CLIP_THRESHOLD_VIDEO = 0.90
DEFAULT_SSIM_THRESHOLD_IMAGE = 0.95
DEFAULT_PSNR_THRESHOLD_IMAGE = 28.0
DEFAULT_MEAN_ABS_DIFF_THRESHOLD_IMAGE = 8.0
DEFAULT_SSIM_THRESHOLD_VIDEO = 0.92
DEFAULT_PSNR_THRESHOLD_VIDEO = 24.0
DEFAULT_MEAN_ABS_DIFF_THRESHOLD_VIDEO = 10.0
_clip_model_cache: dict[str, Any] = {}
_consistency_gt_cache: dict[str, Any] = {}
# ---------------------------------------------------------------------------
# Common model IDs for diffusion tests
#
@@ -560,10 +582,232 @@ def validate_video_file(
), f"Video height mismatch: expected {expected_height}, got {actual_height}"
def _load_threshold_json() -> dict[str, Any]:
"""Load consistency_threshold.json; returns {} if missing."""
if not CONSISTENCY_THRESHOLD_JSON_PATH.exists():
return {}
with CONSISTENCY_THRESHOLD_JSON_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
@dataclass
class ConsistencyThresholds:
clip_threshold: float
ssim_threshold: float
psnr_threshold: float
mean_abs_diff_threshold: float
def get_consistency_thresholds(
case_id: str,
is_video: bool,
metadata: dict[str, Any] | None = None,
) -> ConsistencyThresholds:
"""Get all consistency thresholds for a case."""
if metadata is None:
metadata = _load_threshold_json()
case_meta = metadata.get("cases", {}).get(case_id, {})
suffix = "video" if is_video else "image"
defaults = {
"clip_threshold": metadata.get(
f"default_clip_threshold_{suffix}",
DEFAULT_CLIP_THRESHOLD_VIDEO if is_video else DEFAULT_CLIP_THRESHOLD_IMAGE,
),
"ssim_threshold": metadata.get(
f"default_ssim_threshold_{suffix}",
DEFAULT_SSIM_THRESHOLD_VIDEO if is_video else DEFAULT_SSIM_THRESHOLD_IMAGE,
),
"psnr_threshold": metadata.get(
f"default_psnr_threshold_{suffix}",
DEFAULT_PSNR_THRESHOLD_VIDEO if is_video else DEFAULT_PSNR_THRESHOLD_IMAGE,
),
"mean_abs_diff_threshold": metadata.get(
f"default_mean_abs_diff_threshold_{suffix}",
(
DEFAULT_MEAN_ABS_DIFF_THRESHOLD_VIDEO
if is_video
else DEFAULT_MEAN_ABS_DIFF_THRESHOLD_IMAGE
),
),
}
return ConsistencyThresholds(
clip_threshold=float(
case_meta.get("clip_threshold", defaults["clip_threshold"])
),
ssim_threshold=float(
case_meta.get("ssim_threshold", defaults["ssim_threshold"])
),
psnr_threshold=float(
case_meta.get("psnr_threshold", defaults["psnr_threshold"])
),
mean_abs_diff_threshold=float(
case_meta.get(
"mean_abs_diff_threshold", defaults["mean_abs_diff_threshold"]
)
),
)
def get_clip_threshold(
case: "DiffusionTestCase",
metadata: dict[str, Any] | None = None,
) -> float:
"""Get CLIP similarity threshold for a consistency test case."""
return get_consistency_thresholds(
case_id=case.id,
is_video=case.server_args.modality == "video",
metadata=metadata,
).clip_threshold
@dataclass
class FrameConsistencyMetrics:
frame_index: int
clip_similarity: float
ssim: float
psnr: float
mean_abs_diff: float
clip_passed: bool
ssim_passed: bool
psnr_passed: bool
mean_abs_diff_passed: bool
@dataclass
class ConsistencyResult:
"""Result of a consistency comparison."""
case_id: str
passed: bool
similarity_scores: list[float]
min_similarity: float
threshold: float
min_ssim: float
min_psnr: float
max_mean_abs_diff: float
thresholds: ConsistencyThresholds
frame_metrics: list[FrameConsistencyMetrics]
@dataclass
class LoadedConsistencyGT:
images: list[np.ndarray]
embeddings: list[np.ndarray]
def get_clip_model() -> tuple[Any, Any]:
"""Get CLIP model and processor."""
global _clip_model_cache
if "model" not in _clip_model_cache:
try:
import torch
from transformers import CLIPModel, CLIPProcessor
except ImportError as exc:
raise ImportError(
"transformers and torch are required for CLIP consistency check."
) from exc
logger.info(f"Loading CLIP model: {CLIP_MODEL_NAME}")
processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
model.eval()
_clip_model_cache["model"] = model
_clip_model_cache["processor"] = processor
_clip_model_cache["device"] = device
logger.info(f"CLIP model loaded on {device}")
return _clip_model_cache["model"], _clip_model_cache["processor"]
def compute_clip_embedding(image: np.ndarray) -> np.ndarray:
"""Compute a normalized CLIP image embedding."""
try:
import torch
except ImportError as exc:
raise ImportError("torch is required for CLIP consistency check.") from exc
model, processor = get_clip_model()
device = _clip_model_cache["device"]
pil_image = Image.fromarray(image)
inputs = processor(images=pil_image, return_tensors="pt")
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
image_features = model.get_image_features(**inputs)
if hasattr(image_features, "image_embeds"):
image_features = image_features.image_embeds
elif hasattr(image_features, "pooler_output"):
image_features = image_features.pooler_output
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
return image_features.cpu().numpy().flatten()
def compute_clip_similarity(emb1: np.ndarray, emb2: np.ndarray) -> float:
"""Compute cosine similarity between two CLIP embeddings."""
return float(np.dot(emb1, emb2))
def _ensure_rgb_uint8_image(image: np.ndarray) -> np.ndarray:
"""Normalize image input for pixel-wise consistency metrics."""
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(f"Expected RGB HWC image, got shape={image.shape}")
if image.dtype == np.uint8:
return image
image = np.clip(image, 0, 255)
return image.astype(np.uint8)
def compute_ssim(image: np.ndarray, gt_image: np.ndarray) -> float:
"""Compute SSIM between two RGB images."""
from skimage.metrics import structural_similarity
image = _ensure_rgb_uint8_image(image)
gt_image = _ensure_rgb_uint8_image(gt_image)
if image.shape != gt_image.shape:
raise ValueError(
f"Image shape mismatch for SSIM: output={image.shape}, gt={gt_image.shape}"
)
return float(structural_similarity(image, gt_image, channel_axis=2, data_range=255))
def compute_psnr(image: np.ndarray, gt_image: np.ndarray) -> float:
"""Compute PSNR between two RGB images."""
from skimage.metrics import peak_signal_noise_ratio
image = _ensure_rgb_uint8_image(image)
gt_image = _ensure_rgb_uint8_image(gt_image)
if image.shape != gt_image.shape:
raise ValueError(
f"Image shape mismatch for PSNR: output={image.shape}, gt={gt_image.shape}"
)
return float(peak_signal_noise_ratio(gt_image, image, data_range=255))
def compute_mean_abs_diff(image: np.ndarray, gt_image: np.ndarray) -> float:
"""Compute mean absolute pixel difference between two RGB images."""
image = _ensure_rgb_uint8_image(image)
gt_image = _ensure_rgb_uint8_image(gt_image)
if image.shape != gt_image.shape:
raise ValueError(
f"Image shape mismatch for mean_abs_diff: output={image.shape}, gt={gt_image.shape}"
)
return float(np.abs(image.astype(np.float32) - gt_image.astype(np.float32)).mean())
def output_format_to_ext(output_format: str | None) -> str:
"""Map output_format to file extension. Used by GT naming and consistency check."""
if not output_format:
return "png"
return "jpg"
of = output_format.lower()
if of == "jpeg":
return "jpg"
@@ -587,6 +831,151 @@ def _consistency_gt_filenames(
return [f"{case_id}_{n}gpu.{ext}"]
def get_consistency_gt_candidates(
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
) -> list[str]:
"""Return candidate GT filenames for local consistency data."""
n = num_gpus
if is_video:
return [
f"{case_id}_{n}gpu_frame_0.png",
f"{case_id}_{n}gpu_frame_mid.png",
f"{case_id}_{n}gpu_frame_last.png",
]
base = f"{case_id}_{n}gpu"
preferred = output_format_to_ext(output_format)
exts = [preferred] + [e for e in ("png", "jpg", "webp") if e != preferred]
return [f"{base}.{e}" for e in exts]
def get_consistency_gt_remote_files(
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
) -> list[tuple[str, str]]:
"""Return GT filenames with their remote raw URLs."""
filenames = _consistency_gt_filenames(case_id, num_gpus, is_video, output_format)
return [
(filename, f"{SGL_TEST_FILES_CONSISTENCY_GT_BASE}/{filename}")
for filename in filenames
]
def _get_consistency_gt_dir() -> Path | None:
"""Return the local GT directory when configured."""
d = os.environ.get("SGLANG_CONSISTENCY_GT_DIR")
if not d:
return None
return Path(d).resolve()
def _get_consistency_gt_cache_key(
case_id: str,
num_gpus: int,
is_video: bool,
output_format: str | None,
) -> str:
gt_dir = _get_consistency_gt_dir()
source = str(gt_dir) if gt_dir is not None else "remote"
return f"{case_id}:{num_gpus}:{is_video}:{output_format or ''}:{source}"
def load_consistency_gt(
case_id: str,
num_gpus: int,
is_video: bool = False,
output_format: str | None = None,
) -> LoadedConsistencyGT:
"""Load GT images and CLIP embeddings for consistency checks."""
cache_key = _get_consistency_gt_cache_key(
case_id, num_gpus, is_video, output_format
)
cached = _consistency_gt_cache.get(cache_key)
if cached is not None:
return cached
filenames = _consistency_gt_filenames(case_id, num_gpus, is_video, output_format)
images: list[np.ndarray] = []
gt_dir = _get_consistency_gt_dir()
if gt_dir is not None:
candidates = get_consistency_gt_candidates(
case_id, num_gpus, is_video, output_format
)
if is_video:
for fn in candidates:
path = gt_dir / fn
if not path.exists():
raise FileNotFoundError(f"GT image not found: {path}")
arr = np.array(Image.open(path).convert("RGB"))
images.append(arr)
else:
path = None
for fn in candidates:
candidate = gt_dir / fn
if candidate.exists():
path = candidate
break
if path is None:
raise FileNotFoundError(
f"GT image not found in {gt_dir}. Tried: {', '.join(candidates)}"
)
images.append(np.array(Image.open(path).convert("RGB")))
logger.info(f"Loaded {len(images)} GT images for {case_id} from {gt_dir}")
else:
for fn in filenames:
url = f"{SGL_TEST_FILES_CONSISTENCY_GT_BASE}/{fn}"
resp = requests.get(url, timeout=30)
if resp.status_code != 200:
raise FileNotFoundError(f"GT image not found: {url}")
images.append(np.array(Image.open(io.BytesIO(resp.content)).convert("RGB")))
logger.info(f"Loaded {len(images)} GT images for {case_id} from sglang-ci-data")
embeddings = [compute_clip_embedding(arr) for arr in images]
loaded_gt = LoadedConsistencyGT(images=images, embeddings=embeddings)
_consistency_gt_cache[cache_key] = loaded_gt
return loaded_gt
def load_gt_embeddings(
case_id: str,
num_gpus: int,
is_video: bool = False,
output_format: str | None = None,
) -> list[np.ndarray]:
"""Load GT images and convert them into CLIP embeddings."""
return load_consistency_gt(
case_id=case_id,
num_gpus=num_gpus,
is_video=is_video,
output_format=output_format,
).embeddings
def gt_exists(
case_id: str,
num_gpus: int,
is_video: bool = False,
output_format: str | None = None,
) -> bool:
"""Check whether GT image(s) exist."""
gt_dir = _get_consistency_gt_dir()
if gt_dir is not None:
candidates = get_consistency_gt_candidates(
case_id, num_gpus, is_video, output_format
)
if is_video:
return all((gt_dir / c).exists() for c in candidates)
return any((gt_dir / c).exists() for c in candidates)
filenames = _consistency_gt_filenames(case_id, num_gpus, is_video, output_format)
fn = filenames[0]
url = f"{SGL_TEST_FILES_CONSISTENCY_GT_BASE}/{fn}"
try:
r = requests.head(url, timeout=10)
return r.status_code == 200
except Exception:
return False
def extract_key_frames_from_video(
video_bytes: bytes,
num_frames: int | None = None,
@@ -643,3 +1032,114 @@ def image_bytes_to_numpy(image_bytes: bytes) -> np.ndarray:
"""Convert image bytes to numpy array."""
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
return np.array(img)
def compare_with_gt(
output_frames: list[np.ndarray],
gt_data: LoadedConsistencyGT,
thresholds: ConsistencyThresholds,
case_id: str,
) -> ConsistencyResult:
"""Compare output frames with GT using CLIP and pixel-level metrics."""
if len(output_frames) != len(gt_data.embeddings):
raise ValueError(
f"Frame count mismatch: output={len(output_frames)}, gt={len(gt_data.embeddings)}"
)
similarity_scores = []
frame_metrics: list[FrameConsistencyMetrics] = []
for i, (out_frame, gt_frame, gt_emb) in enumerate(
zip(output_frames, gt_data.images, gt_data.embeddings)
):
out_frame = _ensure_rgb_uint8_image(out_frame)
gt_frame = _ensure_rgb_uint8_image(gt_frame)
if out_frame.shape != gt_frame.shape:
raise ValueError(
f"Frame shape mismatch for case {case_id}, frame {i}: "
f"output={out_frame.shape}, gt={gt_frame.shape}"
)
out_emb = compute_clip_embedding(out_frame)
clip_similarity = compute_clip_similarity(out_emb, gt_emb)
ssim = compute_ssim(out_frame, gt_frame)
psnr = compute_psnr(out_frame, gt_frame)
mean_abs_diff = compute_mean_abs_diff(out_frame, gt_frame)
similarity_scores.append(clip_similarity)
frame_metrics.append(
FrameConsistencyMetrics(
frame_index=i,
clip_similarity=clip_similarity,
ssim=ssim,
psnr=psnr,
mean_abs_diff=mean_abs_diff,
clip_passed=clip_similarity >= thresholds.clip_threshold,
ssim_passed=ssim >= thresholds.ssim_threshold,
psnr_passed=psnr >= thresholds.psnr_threshold,
mean_abs_diff_passed=(
mean_abs_diff <= thresholds.mean_abs_diff_threshold
),
)
)
min_similarity = min(similarity_scores)
min_ssim = min(metric.ssim for metric in frame_metrics)
min_psnr = min(metric.psnr for metric in frame_metrics)
max_mean_abs_diff = max(metric.mean_abs_diff for metric in frame_metrics)
passed = all(
metric.clip_passed
and metric.ssim_passed
and metric.psnr_passed
and metric.mean_abs_diff_passed
for metric in frame_metrics
)
result = ConsistencyResult(
case_id=case_id,
passed=passed,
similarity_scores=similarity_scores,
min_similarity=min_similarity,
threshold=thresholds.clip_threshold,
min_ssim=min_ssim,
min_psnr=min_psnr,
max_mean_abs_diff=max_mean_abs_diff,
thresholds=thresholds,
frame_metrics=frame_metrics,
)
status = "PASSED" if passed else "FAILED"
print(f"\n{'=' * 60}")
print(f"[CLIP Consistency] {case_id}: {status}")
print(
" Thresholds: "
f"clip>={thresholds.clip_threshold}, "
f"ssim>={thresholds.ssim_threshold}, "
f"psnr>={thresholds.psnr_threshold}, "
f"mean_abs_diff<={thresholds.mean_abs_diff_threshold}"
)
print(f" Min similarity: {min_similarity:.4f}")
print(f" Min SSIM: {min_ssim:.4f}")
print(f" Min PSNR: {min_psnr:.4f}")
print(f" Max mean_abs_diff: {max_mean_abs_diff:.4f}")
print(" Frame details:")
for metric in frame_metrics:
frame_status = (
"PASS"
if (
metric.clip_passed
and metric.ssim_passed
and metric.psnr_passed
and metric.mean_abs_diff_passed
)
else "FAIL"
)
print(
f" Frame {metric.frame_index}: "
f"clip={metric.clip_similarity:.4f} "
f"ssim={metric.ssim:.4f} "
f"psnr={metric.psnr:.4f} "
f"mean_abs_diff={metric.mean_abs_diff:.4f} "
f"{frame_status}"
)
print(f"{'=' * 60}\n")
return result