94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
2112 lines
71 KiB
Python
2112 lines
71 KiB
Python
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
|
import base64
|
|
import html
|
|
import io
|
|
import json
|
|
import math
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
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, ImageDraw, ImageFont
|
|
|
|
from sglang.multimodal_gen.runtime.platforms import current_platform
|
|
from sglang.multimodal_gen.runtime.utils.common import get_bool_env_var
|
|
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
|
from sglang.multimodal_gen.runtime.utils.perf_logger import (
|
|
RequestPerfRecord,
|
|
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_CI_DATA_REVISION = "9a64abec5a7517a9f2b04ac1b4eab4173adb2d38"
|
|
|
|
if current_platform.is_npu():
|
|
SGL_TEST_FILES_CI_DATA_REVISION = "6b62f4b6825c76a25fd2ba28248df68f2b400e65"
|
|
|
|
SGL_TEST_FILES_CONSISTENCY_GT_ROOT = (
|
|
"https://raw.githubusercontent.com/"
|
|
f"sgl-project/ci-data/{SGL_TEST_FILES_CI_DATA_REVISION}/"
|
|
"diffusion-ci/consistency_gt"
|
|
)
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE = (
|
|
f"{SGL_TEST_FILES_CONSISTENCY_GT_ROOT}/official_generated"
|
|
)
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE = (
|
|
f"{SGL_TEST_FILES_CONSISTENCY_GT_ROOT}/sglang_generated"
|
|
)
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE_ASCEND = (
|
|
f"{SGL_TEST_FILES_CONSISTENCY_GT_ROOT}/official_generated/ascend"
|
|
)
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE_ASCEND = (
|
|
f"{SGL_TEST_FILES_CONSISTENCY_GT_ROOT}/sglang_generated/ascend"
|
|
)
|
|
|
|
SGL_TEST_FILES_CONSISTENCY_GT_BASE = SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE
|
|
|
|
if current_platform.is_npu():
|
|
SGL_TEST_FILES_CONSISTENCY_GT_BASE = (
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE_ASCEND
|
|
)
|
|
|
|
CONSISTENCY_PLATFORM_ENV = "SGLANG_DIFFUSION_CONSISTENCY_PLATFORM"
|
|
CONSISTENCY_THRESHOLD_DIR = (
|
|
Path(__file__).resolve().parent / "server" / "consistency_thresholds"
|
|
)
|
|
CONSISTENCY_THRESHOLD_FILE_BY_PLATFORM = {
|
|
"h100": "h100.json",
|
|
"b200": "b200.json",
|
|
"5090": "5090.json",
|
|
}
|
|
CONSISTENCY_PLATFORM_ALIASES = {
|
|
"sm90": "h100",
|
|
"hopper": "h100",
|
|
"h100": "h100",
|
|
"sm100": "b200",
|
|
"blackwell": "b200",
|
|
"b200": "b200",
|
|
"sm120": "5090",
|
|
"rtx5090": "5090",
|
|
"5090": "5090",
|
|
}
|
|
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] = {}
|
|
_official_consistency_gt_outputs_cache: dict[str, frozenset[str]] | None = None
|
|
CONSISTENCY_GT_CASE_ALIASES = {
|
|
"fsdp-inference": "zimage_image_t2i_2_gpus",
|
|
}
|
|
OFFICIAL_CONSISTENCY_GT_SKIP_CASES = frozenset(
|
|
{
|
|
# Official references for these cases need regeneration or parity triage.
|
|
# Prefer existing sglang-generated GT instead of relaxing thresholds over
|
|
# large semantic/content mismatches.
|
|
"ltx_2_3_hq_pipeline",
|
|
"ltx_2_two_stage_t2v",
|
|
"qwen_image_edit_2509_ti2i",
|
|
}
|
|
)
|
|
# Case keys whose remote GT has been positively confirmed present. Cached so a
|
|
# case that probes GT existence more than once in a single run — e.g. a
|
|
# consistency check followed by the LoRA basic-API check, which re-validates
|
|
# after merge/set_lora — does not re-hit the remote store. A single transient
|
|
# miss on a *later* probe must not turn an already-confirmed GT into a spurious
|
|
# "GT not found". Only positive (exists) results are cached; misses are not, so
|
|
# a genuinely-absent GT is still reported.
|
|
_gt_exists_remote_cache: set[str] = set()
|
|
|
|
|
|
def _load_clip_processor_with_roberta_processing_compat(
|
|
clip_processor_cls, *args, **kwargs
|
|
):
|
|
from tokenizers import processors
|
|
|
|
roberta_processing = processors.RobertaProcessing
|
|
|
|
def roberta_processing_compat(*processor_args, **processor_kwargs):
|
|
if "sep" in processor_kwargs and "cls" in processor_kwargs:
|
|
sep = processor_kwargs.pop("sep")
|
|
cls_token = processor_kwargs.pop("cls")
|
|
return roberta_processing(
|
|
sep, cls_token, *processor_args, **processor_kwargs
|
|
)
|
|
return roberta_processing(*processor_args, **processor_kwargs)
|
|
|
|
processors.RobertaProcessing = roberta_processing_compat
|
|
try:
|
|
return clip_processor_cls.from_pretrained(*args, **kwargs)
|
|
finally:
|
|
processors.RobertaProcessing = roberta_processing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Common model IDs for diffusion tests
|
|
#
|
|
# Centralised here so every test file references the same constants instead
|
|
# of scattering hard-coded strings. When adding a new model that will be
|
|
# reused across tests, define it here.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_SMALL_MODEL_NAME_FOR_TEST = "Tongyi-MAI/Z-Image-Turbo"
|
|
DEFAULT_AR_MODEL_NAME_FOR_TEST = "zai-org/GLM-Image"
|
|
|
|
# Cosmos3 generation models
|
|
DEFAULT_COSMOS3_NANO_MODEL_NAME_FOR_TEST = "nvidia/Cosmos3-Nano"
|
|
|
|
# Qwen image generation models
|
|
DEFAULT_QWEN_IMAGE_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image"
|
|
DEFAULT_QWEN_IMAGE_2512_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image-2512"
|
|
DEFAULT_QWEN_IMAGE_EDIT_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image-Edit"
|
|
DEFAULT_QWEN_IMAGE_EDIT_2509_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image-Edit-2509"
|
|
DEFAULT_QWEN_IMAGE_EDIT_2511_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image-Edit-2511"
|
|
DEFAULT_QWEN_IMAGE_LAYERED_MODEL_NAME_FOR_TEST = "Qwen/Qwen-Image-Layered"
|
|
|
|
# JoyAI image editing models
|
|
DEFAULT_JOYAI_IMAGE_EDIT_MODEL_NAME_FOR_TEST = "jdopensource/JoyAI-Image-Edit-Diffusers"
|
|
|
|
# FLUX image generation models
|
|
DEFAULT_FLUX_1_DEV_MODEL_NAME_FOR_TEST = "black-forest-labs/FLUX.1-dev"
|
|
DEFAULT_FLUX_2_DEV_MODEL_NAME_FOR_TEST = "black-forest-labs/FLUX.2-dev"
|
|
DEFAULT_FLUX_2_KLEIN_4B_MODEL_NAME_FOR_TEST = "black-forest-labs/FLUX.2-klein-4B"
|
|
DEFAULT_FLUX_2_KLEIN_BASE_4B_MODEL_NAME_FOR_TEST = (
|
|
"black-forest-labs/FLUX.2-klein-base-4B"
|
|
)
|
|
|
|
# Wan video generation models
|
|
DEFAULT_WAN_2_1_T2V_1_3B_MODEL_NAME_FOR_TEST = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
|
|
DEFAULT_WAN_2_1_T2V_14B_MODEL_NAME_FOR_TEST = "Wan-AI/Wan2.1-T2V-14B-Diffusers"
|
|
DEFAULT_WAN_2_1_I2V_14B_480P_MODEL_NAME_FOR_TEST = (
|
|
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers"
|
|
)
|
|
DEFAULT_WAN_2_1_I2V_14B_720P_MODEL_NAME_FOR_TEST = (
|
|
"Wan-AI/Wan2.1-I2V-14B-720P-Diffusers"
|
|
)
|
|
DEFAULT_WAN_2_2_TI2V_5B_MODEL_NAME_FOR_TEST = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
|
|
DEFAULT_WAN_2_2_T2V_A14B_MODEL_NAME_FOR_TEST = "Wan-AI/Wan2.2-T2V-A14B-Diffusers"
|
|
DEFAULT_WAN_2_2_I2V_A14B_MODEL_NAME_FOR_TEST = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
|
|
|
# MOVA video generation models
|
|
DEFAULT_MOVA_360P_MODEL_NAME_FOR_TEST = "OpenMOSS-Team/MOVA-360p"
|
|
|
|
# SANA-WM world model (TI2V with optional camera conditioning)
|
|
DEFAULT_SANA_WM_MODEL_NAME_FOR_TEST = "Efficient-Large-Model/SANA-WM_bidirectional"
|
|
DEFAULT_SANA_WM_STREAMING_MODEL_NAME_FOR_TEST = (
|
|
"Efficient-Large-Model/SANA-WM_streaming"
|
|
)
|
|
|
|
|
|
def print_value_formatted(description: str, value: int | float | str):
|
|
"""Helper function to print a metric value formatted."""
|
|
if isinstance(value, int):
|
|
if value >= 1e6:
|
|
value_str = f"{value / 1e6:<30.2f}M"
|
|
elif value >= 1e3:
|
|
value_str = f"{value / 1e3:<30.2f}K"
|
|
else:
|
|
value_str = f"{value:<30}"
|
|
elif isinstance(value, float):
|
|
value_str = f"{value:<30.2f}"
|
|
else:
|
|
value_str = f"{value:<30}"
|
|
|
|
print(f"{description:<45} {value_str}")
|
|
|
|
|
|
def print_divider(length: int, char: str = "-"):
|
|
"""Helper function to print a divider line."""
|
|
print(char * length)
|
|
|
|
|
|
def is_image_url(image_path: str | Path | None) -> bool:
|
|
"""Check if image_path is a URL."""
|
|
if image_path is None:
|
|
return False
|
|
return isinstance(image_path, str) and (
|
|
image_path.startswith("http://") or image_path.startswith("https://")
|
|
)
|
|
|
|
|
|
def probe_port(host="127.0.0.1", port=30010, timeout=2.0) -> bool:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(timeout)
|
|
try:
|
|
s.connect((host, port))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def is_in_ci() -> bool:
|
|
return get_bool_env_var("SGLANG_IS_IN_CI")
|
|
|
|
|
|
def get_dynamic_server_port() -> int:
|
|
cuda_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "0")
|
|
if not cuda_devices:
|
|
cuda_devices = "0"
|
|
try:
|
|
first_device_id = int(cuda_devices.split(",")[0].strip()[0])
|
|
except (ValueError, IndexError):
|
|
first_device_id = 0
|
|
|
|
if is_in_ci():
|
|
base_port = 10000 + first_device_id * 2000
|
|
else:
|
|
base_port = 20000 + first_device_id * 1000
|
|
|
|
return base_port + 1000
|
|
|
|
|
|
def find_free_port(host: str = "127.0.0.1") -> int:
|
|
"""Bind to port 0 and let the OS assign an available port."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind((host, 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def wait_for_server_health(
|
|
base_url: str,
|
|
path: str = "/health",
|
|
timeout: float = 180.0,
|
|
interval: float = 1.0,
|
|
) -> None:
|
|
"""Poll ``GET <base_url><path>`` until it returns HTTP 200."""
|
|
deadline = time.time() + timeout
|
|
last_err: httpx.RequestError | None = None
|
|
last_status: int | None = None
|
|
while time.time() < deadline:
|
|
try:
|
|
r = httpx.get(urljoin(base_url, path), timeout=5.0)
|
|
last_status = r.status_code
|
|
if r.status_code == 200:
|
|
return
|
|
except httpx.RequestError as e:
|
|
last_err = e
|
|
time.sleep(interval)
|
|
raise TimeoutError(
|
|
f"Server at {urljoin(base_url, path)} not healthy after {timeout}s. "
|
|
f"{last_status=} {last_err=}"
|
|
)
|
|
|
|
|
|
def post_json(
|
|
base_url: str,
|
|
path: str,
|
|
payload: dict,
|
|
timeout: float = 300.0,
|
|
) -> httpx.Response:
|
|
"""POST JSON to ``<base_url><path>`` and return the response."""
|
|
return httpx.post(urljoin(base_url, path), json=payload, timeout=timeout)
|
|
|
|
|
|
def run_command(command: list[str]) -> bool:
|
|
"""Run a CLI command and return whether it succeeded."""
|
|
print(f"Running command: {' '.join(command)}", flush=True)
|
|
with subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding="utf-8",
|
|
) as process:
|
|
assert process.stdout is not None
|
|
for line in process.stdout:
|
|
sys.stdout.write(line)
|
|
process.wait()
|
|
if process.returncode == 0:
|
|
return True
|
|
print(f"Command failed with exit code {process.returncode}", flush=True)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GPU memory helpers (nvidia-smi)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def query_gpu_mem_used_mib(gpu_index: int = 0, required: bool = False) -> int | None:
|
|
"""Return GPU memory usage in MiB via ``nvidia-smi``, or *None* on failure.
|
|
|
|
When *required* is ``True`` the function raises instead of returning ``None``.
|
|
"""
|
|
try:
|
|
out = subprocess.check_output(
|
|
[
|
|
"nvidia-smi",
|
|
f"--id={gpu_index}",
|
|
"--query-gpu=memory.used",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
text=True,
|
|
).strip()
|
|
return int(out.splitlines()[0].strip())
|
|
except Exception as e:
|
|
logger.warning(f"nvidia-smi memory query failed: {type(e).__name__}: {e}")
|
|
assert not required, (
|
|
"nvidia-smi memory query is unavailable; "
|
|
"cannot enforce GPU memory assertions."
|
|
)
|
|
return None
|
|
|
|
|
|
def require_gpu_mem_query(gpu_index: int = 0) -> int:
|
|
"""Same as :func:`query_gpu_mem_used_mib` but asserts availability.
|
|
|
|
Raises ``AssertionError`` when ``nvidia-smi`` is unavailable instead of
|
|
returning ``None``, so callers can rely on a valid ``int`` result.
|
|
"""
|
|
mem = query_gpu_mem_used_mib(gpu_index, required=True)
|
|
assert mem is not None
|
|
return mem
|
|
|
|
|
|
def assert_gpu_mem_changed(
|
|
label: str,
|
|
before_mib: int,
|
|
after_mib: int,
|
|
min_delta_mib: int,
|
|
) -> None:
|
|
"""Assert that GPU memory changed by at least *min_delta_mib* MiB."""
|
|
delta = abs(after_mib - before_mib)
|
|
logger.debug(
|
|
f"[MEM] {label}: before={before_mib} MiB after={after_mib} MiB |delta|={delta} MiB"
|
|
)
|
|
assert delta >= min_delta_mib, (
|
|
f"GPU memory change too small for '{label}': "
|
|
f"|after-before|={delta} MiB < {min_delta_mib} MiB "
|
|
f"(before={before_mib} MiB, after={after_mib} MiB)"
|
|
)
|
|
|
|
|
|
def is_mp4(data: bytes) -> bool:
|
|
"""Check if data represents a valid MP4 file by magic bytes."""
|
|
if len(data) < 8:
|
|
return False
|
|
return data[4:8] == b"ftyp"
|
|
|
|
|
|
def is_jpeg(data: bytes) -> bool:
|
|
# JPEG files start with: FF D8 FF
|
|
return data.startswith(b"\xff\xd8\xff")
|
|
|
|
|
|
def is_png(data):
|
|
# PNG files start with: 89 50 4E 47 0D 0A 1A 0A
|
|
return data.startswith(b"\x89PNG\r\n\x1a\n")
|
|
|
|
|
|
def is_webp(data: bytes) -> bool:
|
|
# WebP files start with: RIFF....WEBP
|
|
return data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
|
|
|
|
|
def detect_image_format(data: bytes) -> str:
|
|
"""Detect image format from bytes (magic). Returns 'png'|'jpeg'|'webp'; default 'png'."""
|
|
if len(data) < 12:
|
|
return "png"
|
|
if is_png(data):
|
|
return "png"
|
|
if is_jpeg(data):
|
|
return "jpeg"
|
|
if is_webp(data):
|
|
return "webp"
|
|
return "png"
|
|
|
|
|
|
def get_expected_image_format(
|
|
output_format: str | None = None,
|
|
background: str | None = None,
|
|
) -> str:
|
|
"""Infer expected image format based on request parameters.
|
|
Args:
|
|
output_format: The output_format parameter from the request (png/jpeg/webp/jpg)
|
|
background: The background parameter from the request (transparent/opaque/auto)
|
|
Returns:
|
|
Expected file extension: "jpg", "png", or "webp"
|
|
"""
|
|
fmt = (output_format or "").lower()
|
|
if fmt in {"png", "webp", "jpeg", "jpg"}:
|
|
return "jpg" if fmt == "jpeg" else fmt
|
|
if (background or "auto").lower() == "transparent":
|
|
return "png"
|
|
return "jpg" # Default
|
|
|
|
|
|
def wait_for_port(host="127.0.0.1", port=30010, deadline=300.0, interval=0.5):
|
|
end = time.time() + deadline
|
|
last_err = None
|
|
while time.time() < end:
|
|
if probe_port(host, port, timeout=interval):
|
|
return True
|
|
time.sleep(interval)
|
|
raise TimeoutError(f"Port {host}:{port} not ready. Last error: {last_err}")
|
|
|
|
|
|
def check_image_size(ut, image, width, height):
|
|
# check image size
|
|
ut.assertEqual(image.size, (width, height))
|
|
|
|
|
|
def get_perf_log_dir() -> Path:
|
|
"""Gets the performance log directory from the centralized sglang utility."""
|
|
log_dir_str = get_diffusion_perf_log_dir()
|
|
if not log_dir_str:
|
|
raise RuntimeError(
|
|
"Performance logging is disabled (SGLANG_PERF_LOG_DIR is empty), "
|
|
"but a test tried to access the log directory."
|
|
)
|
|
return Path(log_dir_str)
|
|
|
|
|
|
def _ensure_log_path(log_dir: Path) -> Path:
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
return log_dir / "performance.log"
|
|
|
|
|
|
def clear_perf_log(log_dir: Path) -> Path:
|
|
"""Delete the perf log file so tests can watch for fresh entries."""
|
|
log_path = _ensure_log_path(log_dir)
|
|
if log_path.exists():
|
|
log_path.unlink()
|
|
logger.info("[server-test] Monitoring perf log at %s", log_path.as_posix())
|
|
return log_path
|
|
|
|
|
|
def prepare_perf_log() -> tuple[Path, Path]:
|
|
"""Convenience helper to resolve and clear the perf log in one call."""
|
|
log_dir = get_perf_log_dir()
|
|
log_path = clear_perf_log(log_dir)
|
|
return log_dir, log_path
|
|
|
|
|
|
def read_perf_logs(log_path: Path) -> list[RequestPerfRecord]:
|
|
if not log_path.exists():
|
|
return []
|
|
records: list[RequestPerfRecord] = []
|
|
with log_path.open("r", encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
record_dict = json.loads(line)
|
|
records.append(RequestPerfRecord(**record_dict))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return records
|
|
|
|
|
|
def wait_for_req_perf_record(
|
|
request_id: str,
|
|
log_path: Path,
|
|
timeout: float = 30.0,
|
|
) -> RequestPerfRecord | None:
|
|
"""
|
|
the stage metrics of this request should be in the performance_log file with {request-id}
|
|
"""
|
|
logger.info(f"Waiting for req perf record with request id: {request_id}")
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
records = read_perf_logs(log_path)
|
|
for record in records:
|
|
if record.request_id == request_id:
|
|
return record
|
|
|
|
time.sleep(0.5)
|
|
|
|
if os.environ.get("SGLANG_GEN_BASELINE", "0") == "1":
|
|
return None
|
|
|
|
logger.error(f"record: {records}")
|
|
raise AssertionError(f"Timeout waiting for stage metrics for request {request_id} ")
|
|
|
|
|
|
def validate_image(b64_json: str) -> None:
|
|
"""Decode and validate that image is PNG or JPEG."""
|
|
image_bytes = base64.b64decode(b64_json)
|
|
assert is_png(image_bytes) or is_jpeg(image_bytes), "Image must be PNG or JPEG"
|
|
|
|
|
|
def validate_video(b64_json: str) -> None:
|
|
"""Decode and validate that video is a valid format."""
|
|
video_bytes = base64.b64decode(b64_json)
|
|
is_webm = video_bytes[:4] == b"\x1a\x45\xdf\xa3"
|
|
assert is_mp4(video_bytes) or is_webm, "Video must be MP4 or WebM"
|
|
|
|
|
|
def validate_openai_video(video_bytes: bytes) -> None:
|
|
"""Validate that video is MP4 or WebM by magic bytes."""
|
|
is_webm = video_bytes.startswith(b"\x1a\x45\xdf\xa3")
|
|
assert is_mp4(video_bytes) or is_webm, "Video must be MP4 or WebM"
|
|
|
|
|
|
def validate_image_file(
|
|
file_path: str,
|
|
expected_filename: str,
|
|
expected_width: int | None = None,
|
|
expected_height: int | None = None,
|
|
output_format: str | None = None,
|
|
background: str | None = None,
|
|
) -> None:
|
|
"""Validate image output file: existence, extension, size, filename, format, dimensions."""
|
|
# Infer expected format from request parameters
|
|
expected_ext = get_expected_image_format(output_format, background)
|
|
|
|
# 1. File existence
|
|
assert os.path.exists(file_path), f"Image file does not exist: {file_path}"
|
|
|
|
# 2. Extension check
|
|
assert file_path.endswith(
|
|
f".{expected_ext}"
|
|
), f"Expected .{expected_ext} extension, got: {file_path}"
|
|
|
|
# 3. File size > 0
|
|
file_size = os.path.getsize(file_path)
|
|
assert file_size > 0, f"Image file is empty: {file_path}"
|
|
|
|
# 4. Filename validation
|
|
actual_filename = os.path.basename(file_path)
|
|
assert (
|
|
actual_filename == expected_filename
|
|
), f"Filename mismatch: expected '{expected_filename}', got '{actual_filename}'"
|
|
|
|
# 5. Image format validation (magic bytes check based on expected format)
|
|
with open(file_path, "rb") as f:
|
|
header = f.read(12) # Read enough bytes for webp detection
|
|
if expected_ext == "png":
|
|
assert is_png(header), f"File is not a valid PNG: {file_path}"
|
|
elif expected_ext == "jpg":
|
|
assert is_jpeg(header), f"File is not a valid JPEG: {file_path}"
|
|
elif expected_ext == "webp":
|
|
assert is_webp(header), f"File is not a valid WebP: {file_path}"
|
|
|
|
# 6. Image dimension validation (reuse PIL)
|
|
if expected_width is not None and expected_height is not None:
|
|
with Image.open(file_path) as img:
|
|
width, height = img.size
|
|
assert (
|
|
width == expected_width
|
|
), f"Width mismatch: expected {expected_width}, got {width}"
|
|
assert (
|
|
height == expected_height
|
|
), f"Height mismatch: expected {expected_height}, got {height}"
|
|
|
|
|
|
def _get_video_dimensions_from_metadata(
|
|
cap: cv2.VideoCapture,
|
|
) -> tuple[int, int] | None:
|
|
"""Get video dimensions from metadata properties.
|
|
|
|
Args:
|
|
cap: OpenCV VideoCapture object
|
|
|
|
Returns:
|
|
Tuple of (width, height) if successful, None if metadata is invalid
|
|
"""
|
|
width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
|
|
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
|
|
|
|
if width == 0 or height == 0:
|
|
return None
|
|
|
|
return int(width), int(height)
|
|
|
|
|
|
def _get_video_dimensions_from_frame(cap: cv2.VideoCapture) -> tuple[int, int]:
|
|
"""Get video dimensions by reading the first frame.
|
|
|
|
Args:
|
|
cap: OpenCV VideoCapture object
|
|
|
|
Returns:
|
|
Tuple of (width, height)
|
|
|
|
"""
|
|
ret, frame = cap.read()
|
|
if not ret or frame is None:
|
|
raise ValueError("Unable to read video frame to get dimensions")
|
|
|
|
# frame.shape is (height, width, channels)
|
|
height, width = frame.shape[:2]
|
|
return int(width), int(height)
|
|
|
|
|
|
def get_video_dimensions(file_path: str) -> tuple[int, int]:
|
|
"""Get video dimensions (width, height) from a video file.
|
|
|
|
Tries to get dimensions from metadata first, falls back to reading first frame.
|
|
|
|
Returns:
|
|
Tuple of (width, height)
|
|
|
|
"""
|
|
cap = cv2.VideoCapture(file_path)
|
|
try:
|
|
# Try to get dimensions from metadata first
|
|
dimensions = _get_video_dimensions_from_metadata(cap)
|
|
if dimensions is not None:
|
|
return dimensions
|
|
|
|
# Fall back to reading first frame
|
|
return _get_video_dimensions_from_frame(cap)
|
|
finally:
|
|
cap.release()
|
|
|
|
|
|
def get_video_frame_count(file_path: str) -> int:
|
|
"""Return the number of frames in a video file using OpenCV."""
|
|
cap = cv2.VideoCapture(file_path)
|
|
try:
|
|
count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
if count > 0:
|
|
return count
|
|
# Fallback: count frames manually
|
|
n = 0
|
|
while cap.read()[0]:
|
|
n += 1
|
|
return n
|
|
finally:
|
|
cap.release()
|
|
|
|
|
|
def validate_video_file(
|
|
file_path: str,
|
|
expected_filename: str,
|
|
expected_width: int | None = None,
|
|
expected_height: int | None = None,
|
|
) -> None:
|
|
"""Validate video output file: existence, extension, size, filename, format, dimensions."""
|
|
# 1. File existence
|
|
assert os.path.exists(file_path), f"Video file does not exist: {file_path}"
|
|
|
|
# 2. Extension check
|
|
assert file_path.endswith(".mp4"), f"Expected .mp4 extension, got: {file_path}"
|
|
|
|
# 3. File size > 0
|
|
file_size = os.path.getsize(file_path)
|
|
assert file_size > 0, f"Video file is empty: {file_path}"
|
|
|
|
# 4. Filename validation
|
|
actual_filename = os.path.basename(file_path)
|
|
assert (
|
|
actual_filename == expected_filename
|
|
), f"Filename mismatch: expected '{expected_filename}', got '{actual_filename}'"
|
|
|
|
# 5. Video format validation (reuse is_mp4)
|
|
with open(file_path, "rb") as f:
|
|
header = f.read(32)
|
|
assert is_mp4(header), f"File is not a valid MP4: {file_path}"
|
|
|
|
# 6. Video dimension validation (using OpenCV)
|
|
if expected_width is not None and expected_height is not None:
|
|
actual_width, actual_height = get_video_dimensions(file_path)
|
|
assert (
|
|
actual_width == expected_width
|
|
), f"Video width mismatch: expected {expected_width}, got {actual_width}"
|
|
assert (
|
|
actual_height == expected_height
|
|
), f"Video height mismatch: expected {expected_height}, got {actual_height}"
|
|
|
|
|
|
def _normalize_consistency_platform(platform: str) -> str:
|
|
normalized = platform.strip().lower().replace("_", "-")
|
|
normalized = normalized.replace("-", "")
|
|
if normalized not in CONSISTENCY_PLATFORM_ALIASES:
|
|
valid = ", ".join(sorted(CONSISTENCY_THRESHOLD_FILE_BY_PLATFORM))
|
|
raise ValueError(
|
|
f"Invalid diffusion consistency platform {platform!r}. "
|
|
f"Expected one of: {valid}"
|
|
)
|
|
return CONSISTENCY_PLATFORM_ALIASES[normalized]
|
|
|
|
|
|
def get_consistency_platform() -> str:
|
|
override = os.getenv(CONSISTENCY_PLATFORM_ENV)
|
|
if override:
|
|
return _normalize_consistency_platform(override)
|
|
if current_platform.is_sm120():
|
|
return "5090"
|
|
if current_platform.is_blackwell():
|
|
return "b200"
|
|
return "h100"
|
|
|
|
|
|
def get_consistency_threshold_path(platform: str | None = None) -> Path:
|
|
threshold_platform = (
|
|
_normalize_consistency_platform(platform)
|
|
if platform is not None
|
|
else get_consistency_platform()
|
|
)
|
|
return (
|
|
CONSISTENCY_THRESHOLD_DIR
|
|
/ CONSISTENCY_THRESHOLD_FILE_BY_PLATFORM[threshold_platform]
|
|
)
|
|
|
|
|
|
def _load_threshold_file(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
return {}
|
|
with path.open("r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _merge_threshold_metadata(
|
|
base: dict[str, Any], override: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
merged = dict(base)
|
|
if "cases" in base or "cases" in override:
|
|
merged["cases"] = {
|
|
**base.get("cases", {}),
|
|
**override.get("cases", {}),
|
|
}
|
|
for key, value in override.items():
|
|
if key != "cases":
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
def _load_threshold_json() -> dict[str, Any]:
|
|
metadata = _load_threshold_file(get_consistency_threshold_path("h100"))
|
|
platform = get_consistency_platform()
|
|
if platform == "h100":
|
|
return metadata
|
|
return _merge_threshold_metadata(
|
|
metadata,
|
|
_load_threshold_file(get_consistency_threshold_path(platform)),
|
|
)
|
|
|
|
|
|
@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}")
|
|
try:
|
|
processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
|
|
except TypeError as e:
|
|
if "RobertaProcessing" not in str(e):
|
|
raise
|
|
logger.warning(
|
|
"Fast CLIP processor failed (%s), retrying with use_fast=False", e
|
|
)
|
|
processor = _load_clip_processor_with_roberta_processing_compat(
|
|
CLIPProcessor,
|
|
CLIP_MODEL_NAME,
|
|
use_fast=False,
|
|
)
|
|
model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
|
|
|
|
# ci server tests keep the generation server alive while consistency runs
|
|
device = (
|
|
"cpu" if is_in_ci() else ("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 "jpg"
|
|
of = output_format.lower()
|
|
if of == "jpeg":
|
|
return "jpg"
|
|
if of in ("png", "webp", "jpg"):
|
|
return of
|
|
return "png"
|
|
|
|
|
|
def get_consistency_gt_case_id(case_id: str) -> str:
|
|
return CONSISTENCY_GT_CASE_ALIASES.get(case_id, case_id)
|
|
|
|
|
|
def _consistency_gt_filenames(
|
|
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
|
|
) -> list[str]:
|
|
"""Return the list of GT image filenames for a case. Reused by GT generation and consistency check."""
|
|
case_id = get_consistency_gt_case_id(case_id)
|
|
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",
|
|
]
|
|
ext = output_format_to_ext(output_format)
|
|
return [f"{case_id}_{n}gpu.{ext}"]
|
|
|
|
|
|
def _base_consistency_gt_candidates(
|
|
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
|
|
) -> list[str]:
|
|
case_id = get_consistency_gt_case_id(case_id)
|
|
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_candidate_sets(
|
|
case_id: str, num_gpus: int, is_video: bool, output_format: str | None = None
|
|
) -> list[list[str]]:
|
|
candidates = _base_consistency_gt_candidates(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if _is_ascend_consistency_case(case_id) or current_platform.is_npu():
|
|
return [candidates]
|
|
platform = get_consistency_platform()
|
|
return [[f"{platform}/{candidate}" for candidate in candidates], candidates]
|
|
|
|
|
|
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."""
|
|
return [
|
|
candidate
|
|
for candidate_set in get_consistency_gt_candidate_sets(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
for candidate in candidate_set
|
|
]
|
|
|
|
|
|
def _action_consistency_gt_filenames(case_id: str, num_gpus: int) -> list[str]:
|
|
case_id = get_consistency_gt_case_id(case_id)
|
|
return [f"{case_id}_{num_gpus}gpu.json"]
|
|
|
|
|
|
def get_action_consistency_gt_candidate_sets(
|
|
case_id: str,
|
|
num_gpus: int,
|
|
) -> list[list[str]]:
|
|
candidates = _action_consistency_gt_filenames(case_id, num_gpus)
|
|
if _is_ascend_consistency_case(case_id) or current_platform.is_npu():
|
|
return [candidates]
|
|
platform = get_consistency_platform()
|
|
return [[f"{platform}/{candidate}" for candidate in candidates], candidates]
|
|
|
|
|
|
def get_action_consistency_gt_candidates(case_id: str, num_gpus: int) -> list[str]:
|
|
return [
|
|
candidate
|
|
for candidate_set in get_action_consistency_gt_candidate_sets(case_id, num_gpus)
|
|
for candidate in candidate_set
|
|
]
|
|
|
|
|
|
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."""
|
|
files = _find_remote_consistency_gt_files(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if files:
|
|
return files
|
|
|
|
return _remote_consistency_gt_candidates(
|
|
SGL_TEST_FILES_CONSISTENCY_GT_BASE, case_id, num_gpus, is_video, output_format
|
|
)
|
|
|
|
|
|
def get_action_consistency_gt_remote_files(
|
|
case_id: str, num_gpus: int
|
|
) -> list[tuple[str, str]]:
|
|
files = _find_remote_action_consistency_gt_files(case_id, num_gpus)
|
|
if files:
|
|
return files
|
|
filenames = get_action_consistency_gt_candidates(case_id, num_gpus)
|
|
return [
|
|
(filename, f"{SGL_TEST_FILES_CONSISTENCY_GT_BASE}/{filename}")
|
|
for filename in filenames
|
|
]
|
|
|
|
|
|
def _remote_consistency_gt_candidates(
|
|
base_url: str,
|
|
case_id: str,
|
|
num_gpus: int,
|
|
is_video: bool,
|
|
output_format: str | None = None,
|
|
) -> list[tuple[str, str]]:
|
|
filenames = get_consistency_gt_candidates(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
return [(filename, f"{base_url}/{filename}") for filename in filenames]
|
|
|
|
|
|
def _is_ascend_consistency_case(case_id: str) -> bool:
|
|
return "npu" in case_id
|
|
|
|
|
|
def _load_official_consistency_gt_outputs() -> dict[str, frozenset[str]]:
|
|
"""Return case_id -> declared official GT outputs from the pinned ci-data map."""
|
|
global _official_consistency_gt_outputs_cache
|
|
if _official_consistency_gt_outputs_cache is not None:
|
|
return _official_consistency_gt_outputs_cache
|
|
|
|
url = f"{SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE}/case_map.json"
|
|
outputs_by_case: dict[str, frozenset[str]] = {}
|
|
try:
|
|
resp = requests.get(url, timeout=30)
|
|
try:
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
else:
|
|
data = {}
|
|
logger.warning(
|
|
"Failed to load official consistency GT case map from %s: HTTP %s",
|
|
url,
|
|
resp.status_code,
|
|
)
|
|
finally:
|
|
resp.close()
|
|
except (ValueError, requests.RequestException) as exc:
|
|
data = {}
|
|
logger.warning(
|
|
"Failed to load official consistency GT case map from %s: %s",
|
|
url,
|
|
exc,
|
|
)
|
|
|
|
cases = data.get("cases", {}) if isinstance(data, dict) else {}
|
|
if isinstance(cases, dict):
|
|
for case_id, metadata in cases.items():
|
|
outputs = metadata.get("outputs", []) if isinstance(metadata, dict) else []
|
|
if isinstance(outputs, list):
|
|
outputs_by_case[str(case_id)] = frozenset(str(item) for item in outputs)
|
|
|
|
_official_consistency_gt_outputs_cache = outputs_by_case
|
|
return outputs_by_case
|
|
|
|
|
|
def _official_consistency_gt_outputs_for_case(case_id: str) -> frozenset[str]:
|
|
return _load_official_consistency_gt_outputs().get(case_id, frozenset())
|
|
|
|
|
|
def _is_official_consistency_gt_base_url(base_url: str) -> bool:
|
|
return base_url in (
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE,
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE_ASCEND,
|
|
)
|
|
|
|
|
|
def _official_consistency_gt_candidate_is_declared(case_id: str, filename: str) -> bool:
|
|
outputs = _official_consistency_gt_outputs_for_case(case_id)
|
|
return filename in outputs or filename.rsplit("/", 1)[-1] in outputs
|
|
|
|
|
|
def _remote_consistency_gt_base_urls(case_id: str) -> tuple[str, ...]:
|
|
if case_id in OFFICIAL_CONSISTENCY_GT_SKIP_CASES:
|
|
if _is_ascend_consistency_case(case_id) or current_platform.is_npu():
|
|
return (
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE_ASCEND,
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,
|
|
)
|
|
return (SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,)
|
|
has_declared_official_gt = bool(_official_consistency_gt_outputs_for_case(case_id))
|
|
if _is_ascend_consistency_case(case_id) or current_platform.is_npu():
|
|
if has_declared_official_gt:
|
|
return (
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE_ASCEND,
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE_ASCEND,
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE,
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,
|
|
)
|
|
return (
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE_ASCEND,
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,
|
|
)
|
|
if has_declared_official_gt:
|
|
return (
|
|
SGL_TEST_FILES_OFFICIAL_CONSISTENCY_GT_BASE,
|
|
SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,
|
|
)
|
|
return (SGL_TEST_FILES_SGLANG_CONSISTENCY_GT_BASE,)
|
|
|
|
|
|
def _remote_file_exists(url: str) -> bool | None:
|
|
"""Probe whether a remote GT file exists, robust to transient failures."""
|
|
attempts = 5
|
|
backoff = 1.0
|
|
saw_absent = False # observed a clean (non-rate-limit) 4xx at least once
|
|
for attempt in range(attempts):
|
|
for method in ("head", "get"):
|
|
try:
|
|
if method == "head":
|
|
resp = requests.head(url, timeout=30, allow_redirects=True)
|
|
else:
|
|
resp = requests.get(
|
|
url,
|
|
timeout=30,
|
|
allow_redirects=True,
|
|
headers={"Range": "bytes=0-0"},
|
|
stream=True,
|
|
)
|
|
try:
|
|
if resp.status_code in (200, 206):
|
|
return True
|
|
if resp.status_code == 404 or (
|
|
resp.status_code not in (403, 405, 429)
|
|
and resp.status_code < 500
|
|
):
|
|
# Clean 4xx -> "absent", but don't trust it yet: a
|
|
# freshly-pinned commit can briefly 404 on the CDN.
|
|
# Keep retrying and let a later 200 win
|
|
saw_absent = True
|
|
# 403/405/429/5xx -> transient; keep retrying.
|
|
finally:
|
|
resp.close()
|
|
except requests.RequestException:
|
|
pass
|
|
if attempt < attempts - 1:
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * 2, 16.0)
|
|
# Never saw a 200/206 across all attempts.
|
|
if saw_absent:
|
|
return False # consistently absent -> genuinely missing
|
|
return None # only transient failures -> uncertain (caller assumes present)
|
|
|
|
|
|
def _load_remote_gt_image(url: str) -> np.ndarray:
|
|
last_error: Exception | None = None
|
|
for _ in range(3):
|
|
try:
|
|
resp = requests.get(url, timeout=60)
|
|
try:
|
|
if resp.status_code == 200:
|
|
image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
|
return np.array(image)
|
|
last_error = FileNotFoundError(f"GT image not found: {url}")
|
|
if resp.status_code not in (403, 429) and resp.status_code < 500:
|
|
break
|
|
finally:
|
|
resp.close()
|
|
except requests.RequestException as exc:
|
|
last_error = exc
|
|
raise FileNotFoundError(f"GT image not found: {url}") from last_error
|
|
|
|
|
|
def _find_remote_consistency_gt_files(
|
|
case_id: str,
|
|
num_gpus: int,
|
|
is_video: bool,
|
|
output_format: str | None = None,
|
|
) -> list[tuple[str, str]]:
|
|
for filenames in get_consistency_gt_candidate_sets(
|
|
case_id, num_gpus, is_video, output_format
|
|
):
|
|
for base_url in _remote_consistency_gt_base_urls(case_id):
|
|
candidates = [
|
|
(filename, f"{base_url}/{filename}") for filename in filenames
|
|
]
|
|
if _is_official_consistency_gt_base_url(base_url):
|
|
candidates = [
|
|
(filename, url)
|
|
for filename, url in candidates
|
|
if _official_consistency_gt_candidate_is_declared(case_id, filename)
|
|
]
|
|
if not candidates or (is_video and len(candidates) != len(filenames)):
|
|
continue
|
|
if is_video:
|
|
exists = [_remote_file_exists(url) for _, url in candidates]
|
|
if all(status is not False for status in exists):
|
|
return candidates
|
|
continue
|
|
uncertain_candidate = None
|
|
for filename, url in candidates:
|
|
exists = _remote_file_exists(url)
|
|
if exists is True:
|
|
return [(filename, url)]
|
|
if exists is None and uncertain_candidate is None:
|
|
uncertain_candidate = (filename, url)
|
|
if uncertain_candidate is not None:
|
|
return [uncertain_candidate]
|
|
return []
|
|
|
|
|
|
def _find_remote_action_consistency_gt_files(
|
|
case_id: str,
|
|
num_gpus: int,
|
|
) -> list[tuple[str, str]]:
|
|
for filenames in get_action_consistency_gt_candidate_sets(case_id, num_gpus):
|
|
for base_url in _remote_consistency_gt_base_urls(case_id):
|
|
candidates = [
|
|
(filename, f"{base_url}/{filename}") for filename in filenames
|
|
]
|
|
if _is_official_consistency_gt_base_url(base_url):
|
|
candidates = [
|
|
(filename, url)
|
|
for filename, url in candidates
|
|
if _official_consistency_gt_candidate_is_declared(case_id, filename)
|
|
]
|
|
if not candidates:
|
|
continue
|
|
uncertain_candidate = None
|
|
for filename, url in candidates:
|
|
exists = _remote_file_exists(url)
|
|
if exists is True:
|
|
return [(filename, url)]
|
|
if exists is None and uncertain_candidate is None:
|
|
uncertain_candidate = (filename, url)
|
|
if uncertain_candidate is not None:
|
|
return [uncertain_candidate]
|
|
return []
|
|
|
|
|
|
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"
|
|
platform = get_consistency_platform()
|
|
return f"{platform}:{case_id}:{num_gpus}:{is_video}:{output_format or ''}:{source}"
|
|
|
|
|
|
def _get_action_consistency_gt_cache_key(case_id: str, num_gpus: int) -> str:
|
|
gt_dir = _get_consistency_gt_dir()
|
|
source = str(gt_dir) if gt_dir is not None else "remote"
|
|
platform = get_consistency_platform()
|
|
return f"{platform}:{case_id}:{num_gpus}:action:{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
|
|
|
|
images: list[np.ndarray] = []
|
|
|
|
gt_dir = _get_consistency_gt_dir()
|
|
if gt_dir is not None:
|
|
candidate_sets = get_consistency_gt_candidate_sets(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if is_video:
|
|
selected = None
|
|
for candidates in candidate_sets:
|
|
if all((gt_dir / fn).exists() for fn in candidates):
|
|
selected = candidates
|
|
break
|
|
if selected is None:
|
|
tried = ", ".join(
|
|
candidate
|
|
for candidates in candidate_sets
|
|
for candidate in candidates
|
|
)
|
|
raise FileNotFoundError(
|
|
f"GT images not found in {gt_dir}. Tried: {tried}"
|
|
)
|
|
for fn in selected:
|
|
images.append(np.array(Image.open(gt_dir / fn).convert("RGB")))
|
|
else:
|
|
path = None
|
|
for fn in get_consistency_gt_candidates(
|
|
case_id, num_gpus, is_video, output_format
|
|
):
|
|
candidate = gt_dir / fn
|
|
if candidate.exists():
|
|
path = candidate
|
|
break
|
|
if path is None:
|
|
candidates = get_consistency_gt_candidates(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
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:
|
|
remote_files = _find_remote_consistency_gt_files(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if not remote_files:
|
|
candidates = get_consistency_gt_candidates(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
raise FileNotFoundError(
|
|
f"GT image not found for {case_id}. Tried: {', '.join(candidates)}"
|
|
)
|
|
for _, url in remote_files:
|
|
images.append(_load_remote_gt_image(url))
|
|
source_dir = remote_files[0][1].rsplit("/", 1)[0]
|
|
logger.info(f"Loaded {len(images)} GT images for {case_id} from {source_dir}")
|
|
|
|
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_remote_gt_json(url: str) -> dict[str, Any]:
|
|
last_error: Exception | None = None
|
|
for _ in range(3):
|
|
try:
|
|
resp = requests.get(url, timeout=60)
|
|
try:
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
last_error = FileNotFoundError(f"GT JSON not found: {url}")
|
|
if resp.status_code not in (403, 429) and resp.status_code < 500:
|
|
break
|
|
finally:
|
|
resp.close()
|
|
except (ValueError, requests.RequestException) as exc:
|
|
last_error = exc
|
|
raise FileNotFoundError(f"GT JSON not found: {url}") from last_error
|
|
|
|
|
|
def load_action_consistency_gt(case_id: str, num_gpus: int) -> dict[str, Any]:
|
|
cache_key = _get_action_consistency_gt_cache_key(case_id, num_gpus)
|
|
cached = _consistency_gt_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
gt_dir = _get_consistency_gt_dir()
|
|
if gt_dir is not None:
|
|
path = None
|
|
for fn in get_action_consistency_gt_candidates(case_id, num_gpus):
|
|
candidate = gt_dir / fn
|
|
if candidate.exists():
|
|
path = candidate
|
|
break
|
|
if path is None:
|
|
candidates = get_action_consistency_gt_candidates(case_id, num_gpus)
|
|
raise FileNotFoundError(
|
|
f"GT action JSON not found in {gt_dir}. Tried: {', '.join(candidates)}"
|
|
)
|
|
with path.open("r", encoding="utf-8") as f:
|
|
loaded_gt = json.load(f)
|
|
logger.info("Loaded action GT for %s from %s", case_id, path)
|
|
else:
|
|
remote_files = _find_remote_action_consistency_gt_files(case_id, num_gpus)
|
|
if not remote_files:
|
|
candidates = get_action_consistency_gt_candidates(case_id, num_gpus)
|
|
raise FileNotFoundError(
|
|
f"GT action JSON not found for {case_id}. Tried: {', '.join(candidates)}"
|
|
)
|
|
loaded_gt = _load_remote_gt_json(remote_files[0][1])
|
|
logger.info("Loaded action GT for %s from %s", case_id, remote_files[0][1])
|
|
|
|
_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:
|
|
candidate_sets = get_consistency_gt_candidate_sets(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if is_video:
|
|
return any(
|
|
all((gt_dir / candidate).exists() for candidate in candidate_set)
|
|
for candidate_set in candidate_sets
|
|
)
|
|
return any(
|
|
(gt_dir / candidate).exists()
|
|
for candidate_set in candidate_sets
|
|
for candidate in candidate_set
|
|
)
|
|
|
|
cache_key = _get_consistency_gt_cache_key(
|
|
case_id, num_gpus, is_video, output_format
|
|
)
|
|
if cache_key in _gt_exists_remote_cache:
|
|
return True
|
|
found = bool(
|
|
_find_remote_consistency_gt_files(case_id, num_gpus, is_video, output_format)
|
|
)
|
|
if found:
|
|
_gt_exists_remote_cache.add(cache_key)
|
|
return found
|
|
|
|
|
|
def action_gt_exists(case_id: str, num_gpus: int) -> bool:
|
|
gt_dir = _get_consistency_gt_dir()
|
|
if gt_dir is not None:
|
|
return any(
|
|
(gt_dir / candidate).exists()
|
|
for candidate_set in get_action_consistency_gt_candidate_sets(
|
|
case_id, num_gpus
|
|
)
|
|
for candidate in candidate_set
|
|
)
|
|
|
|
cache_key = _get_action_consistency_gt_cache_key(case_id, num_gpus)
|
|
if cache_key in _gt_exists_remote_cache:
|
|
return True
|
|
found = bool(_find_remote_action_consistency_gt_files(case_id, num_gpus))
|
|
if found:
|
|
_gt_exists_remote_cache.add(cache_key)
|
|
return found
|
|
|
|
|
|
def extract_key_frames_from_video(
|
|
video_bytes: bytes,
|
|
num_frames: int | None = None,
|
|
) -> list[np.ndarray]:
|
|
"""
|
|
Extract key frames (first, middle, last) from video bytes.
|
|
|
|
Args:
|
|
video_bytes: Raw video bytes (MP4 format)
|
|
num_frames: Total number of frames (if known), used for validation
|
|
|
|
Returns:
|
|
List of numpy arrays [first_frame, middle_frame, last_frame].
|
|
"""
|
|
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
|
tmp.write(video_bytes)
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
cap = cv2.VideoCapture(tmp_path)
|
|
if not cap.isOpened():
|
|
raise ValueError("Failed to open video file")
|
|
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
if total_frames < 1:
|
|
raise ValueError("Video has no frames")
|
|
|
|
first_idx = 0
|
|
mid_idx = total_frames // 2
|
|
last_idx = total_frames - 1
|
|
key_indices = [first_idx, mid_idx, last_idx]
|
|
|
|
frames = []
|
|
for idx in key_indices:
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
raise ValueError(f"Failed to read frame at index {idx}")
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
frames.append(frame_rgb)
|
|
|
|
cap.release()
|
|
logger.info(
|
|
f"Extracted {len(frames)} key frames from video "
|
|
f"(total: {total_frames}, indices: {key_indices})"
|
|
)
|
|
return frames
|
|
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
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"[Consistency Check] {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
|
|
|
|
|
|
def _safe_artifact_name(name: str) -> str:
|
|
return "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
|
|
|
|
|
|
def _format_metric_value(value: float) -> str:
|
|
if math.isinf(value):
|
|
return "inf"
|
|
if math.isnan(value):
|
|
return "nan"
|
|
return f"{value:.4f}"
|
|
|
|
|
|
def _json_metric_value(value: float) -> float | str:
|
|
if math.isinf(value) or math.isnan(value):
|
|
return _format_metric_value(value)
|
|
return round(value, 6)
|
|
|
|
|
|
def _metric_items(metric: FrameConsistencyMetrics) -> list[tuple[str, float, bool]]:
|
|
return [
|
|
("clip", metric.clip_similarity, metric.clip_passed),
|
|
("ssim", metric.ssim, metric.ssim_passed),
|
|
("psnr", metric.psnr, metric.psnr_passed),
|
|
("mean_abs_diff", metric.mean_abs_diff, metric.mean_abs_diff_passed),
|
|
]
|
|
|
|
|
|
def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
|
|
box = draw.textbbox((0, 0), text, font=font)
|
|
return box[2] - box[0]
|
|
|
|
|
|
def _resize_for_comparison(image: np.ndarray, max_size: tuple[int, int]) -> Image.Image:
|
|
pil_image = Image.fromarray(_ensure_rgb_uint8_image(image)).copy()
|
|
pil_image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
return pil_image
|
|
|
|
|
|
def _draw_metric_items(
|
|
draw: ImageDraw.ImageDraw,
|
|
x: int,
|
|
y: int,
|
|
metric: FrameConsistencyMetrics,
|
|
font: ImageFont.ImageFont,
|
|
) -> None:
|
|
cursor = x
|
|
for index, (name, value, passed) in enumerate(_metric_items(metric)):
|
|
text = f"{name}={_format_metric_value(value)}"
|
|
fill = (30, 110, 55) if passed else (185, 35, 35)
|
|
draw.text((cursor, y), text, fill=fill, font=font)
|
|
cursor += _text_width(draw, text, font)
|
|
if index != 3:
|
|
separator = " | "
|
|
draw.text((cursor, y), separator, fill=(95, 95, 95), font=font)
|
|
cursor += _text_width(draw, separator, font)
|
|
|
|
|
|
def _make_consistency_failure_image(
|
|
case_id: str,
|
|
num_gpus: int,
|
|
output_frames: list[np.ndarray],
|
|
gt_data: LoadedConsistencyGT,
|
|
result: ConsistencyResult,
|
|
is_video: bool,
|
|
) -> Image.Image:
|
|
font = ImageFont.load_default()
|
|
max_thumb_size = (520, 520) if len(output_frames) == 1 else (480, 320)
|
|
gt_thumbs = [
|
|
_resize_for_comparison(image, max_thumb_size) for image in gt_data.images
|
|
]
|
|
output_thumbs = [
|
|
_resize_for_comparison(image, max_thumb_size) for image in output_frames
|
|
]
|
|
thumb_width = max_thumb_size[0]
|
|
|
|
margin = 24
|
|
column_gap = 24
|
|
label_height = 42
|
|
metric_height = 30
|
|
row_gap = 18
|
|
frame_rows = []
|
|
for gt_image, output_image in zip(gt_thumbs, output_thumbs):
|
|
image_height = max(gt_image.height, output_image.height)
|
|
frame_rows.append((gt_image, output_image, image_height))
|
|
|
|
header_lines = [
|
|
f"Consistency failure: {case_id}",
|
|
f"modality={'video' if is_video else 'image'} | gpus={num_gpus} | frames={len(output_frames)}",
|
|
(
|
|
"thresholds: "
|
|
f"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}"
|
|
),
|
|
(
|
|
"worst: "
|
|
f"clip={_format_metric_value(result.min_similarity)} "
|
|
f"ssim={_format_metric_value(result.min_ssim)} "
|
|
f"psnr={_format_metric_value(result.min_psnr)} "
|
|
f"mean_abs_diff={_format_metric_value(result.max_mean_abs_diff)}"
|
|
),
|
|
]
|
|
header_height = 24 + len(header_lines) * 18 + 16
|
|
width = max(960, margin * 2 + thumb_width * 2 + column_gap)
|
|
height = (
|
|
margin
|
|
+ header_height
|
|
+ sum(label_height + row[2] + metric_height for row in frame_rows)
|
|
+ row_gap * max(0, len(frame_rows) - 1)
|
|
+ margin
|
|
)
|
|
|
|
image = Image.new("RGB", (width, height), (245, 246, 248))
|
|
draw = ImageDraw.Draw(image)
|
|
|
|
y = margin
|
|
for line in header_lines:
|
|
draw.text((margin, y), line, fill=(25, 25, 25), font=font)
|
|
y += 18
|
|
y = margin + header_height
|
|
|
|
left_x = margin
|
|
right_x = margin + thumb_width + column_gap
|
|
for idx, (gt_image, output_image, image_height) in enumerate(frame_rows):
|
|
row_height = label_height + image_height + metric_height
|
|
draw.rectangle(
|
|
[margin - 8, y - 8, width - margin + 8, y + row_height + 8],
|
|
fill=(255, 255, 255),
|
|
outline=(222, 225, 230),
|
|
)
|
|
frame_label = "image" if len(frame_rows) == 1 else f"frame {idx}"
|
|
draw.text((left_x, y), f"GT {frame_label}", fill=(35, 35, 35), font=font)
|
|
draw.text(
|
|
(right_x, y), f"CI generated {frame_label}", fill=(35, 35, 35), font=font
|
|
)
|
|
|
|
image_y = y + label_height
|
|
image.paste(gt_image, (left_x + (thumb_width - gt_image.width) // 2, image_y))
|
|
image.paste(
|
|
output_image,
|
|
(right_x + (thumb_width - output_image.width) // 2, image_y),
|
|
)
|
|
|
|
metric_y = image_y + image_height + 10
|
|
_draw_metric_items(draw, left_x, metric_y, result.frame_metrics[idx], font)
|
|
y += row_height + row_gap
|
|
|
|
return image
|
|
|
|
|
|
def _consistency_failure_record(
|
|
case_id: str,
|
|
num_gpus: int,
|
|
result: ConsistencyResult,
|
|
is_video: bool,
|
|
output_format: str | None,
|
|
image_name: str,
|
|
generated_files: list[str],
|
|
gt_remote_files: list[tuple[str, str]] | None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"case_id": case_id,
|
|
"num_gpus": num_gpus,
|
|
"is_video": is_video,
|
|
"output_format": output_format,
|
|
"comparison_png": image_name,
|
|
"generated_files": generated_files,
|
|
"metrics": {
|
|
"min_clip_similarity": _json_metric_value(result.min_similarity),
|
|
"min_ssim": _json_metric_value(result.min_ssim),
|
|
"min_psnr": _json_metric_value(result.min_psnr),
|
|
"max_mean_abs_diff": _json_metric_value(result.max_mean_abs_diff),
|
|
},
|
|
"thresholds": {
|
|
"clip_threshold": result.thresholds.clip_threshold,
|
|
"ssim_threshold": result.thresholds.ssim_threshold,
|
|
"psnr_threshold": result.thresholds.psnr_threshold,
|
|
"mean_abs_diff_threshold": result.thresholds.mean_abs_diff_threshold,
|
|
},
|
|
"frames": [
|
|
{
|
|
"frame_index": metric.frame_index,
|
|
"clip_similarity": _json_metric_value(metric.clip_similarity),
|
|
"ssim": _json_metric_value(metric.ssim),
|
|
"psnr": _json_metric_value(metric.psnr),
|
|
"mean_abs_diff": _json_metric_value(metric.mean_abs_diff),
|
|
"clip_passed": metric.clip_passed,
|
|
"ssim_passed": metric.ssim_passed,
|
|
"psnr_passed": metric.psnr_passed,
|
|
"mean_abs_diff_passed": metric.mean_abs_diff_passed,
|
|
}
|
|
for metric in result.frame_metrics
|
|
],
|
|
"gt_files": [
|
|
{"filename": filename, "url": url}
|
|
for filename, url in (gt_remote_files or [])
|
|
],
|
|
}
|
|
|
|
|
|
def _save_generated_artifact_images(
|
|
out_dir: Path,
|
|
case_id: str,
|
|
num_gpus: int,
|
|
output_frames: list[np.ndarray],
|
|
is_video: bool,
|
|
output_format: str | None,
|
|
) -> list[str]:
|
|
generated_dir = out_dir / "generated"
|
|
generated_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
safe_case_id = _safe_artifact_name(case_id)
|
|
if is_video:
|
|
suffixes = ("frame_0", "frame_mid", "frame_last")
|
|
filenames = [
|
|
f"{safe_case_id}_{num_gpus}gpu_{suffix}.png"
|
|
for suffix in suffixes[: len(output_frames)]
|
|
]
|
|
else:
|
|
ext = output_format_to_ext(output_format)
|
|
filenames = [f"{safe_case_id}_{num_gpus}gpu.{ext}"]
|
|
|
|
generated_files = []
|
|
for frame, filename in zip(output_frames, filenames):
|
|
path = generated_dir / filename
|
|
Image.fromarray(_ensure_rgb_uint8_image(frame)).save(path)
|
|
generated_files.append(str(path.relative_to(out_dir)))
|
|
return generated_files
|
|
|
|
|
|
def _write_consistency_failure_index(
|
|
out_dir: Path,
|
|
records: list[dict[str, Any]],
|
|
) -> None:
|
|
sections = []
|
|
for record in sorted(records, key=lambda r: (r["case_id"], r["num_gpus"])):
|
|
case_id = html.escape(record["case_id"])
|
|
png = html.escape(record["comparison_png"])
|
|
metrics = record["metrics"]
|
|
generated_links = "".join(
|
|
f'<li><a href="{html.escape(path)}">{html.escape(path)}</a></li>'
|
|
for path in record.get("generated_files", [])
|
|
)
|
|
generated_html = (
|
|
f"<p>Generated images:</p><ul>{generated_links}</ul>"
|
|
if generated_links
|
|
else ""
|
|
)
|
|
sections.append(
|
|
"<section>"
|
|
f"<h2>{case_id} ({record['num_gpus']} GPU)</h2>"
|
|
"<p>"
|
|
f"clip={metrics['min_clip_similarity']} | "
|
|
f"ssim={metrics['min_ssim']} | "
|
|
f"psnr={metrics['min_psnr']} | "
|
|
f"mean_abs_diff={metrics['max_mean_abs_diff']}"
|
|
"</p>"
|
|
f'<img src="{png}" alt="{case_id} comparison">'
|
|
f"{generated_html}"
|
|
"</section>"
|
|
)
|
|
|
|
doc = (
|
|
'<!doctype html><html><head><meta charset="utf-8">'
|
|
"<title>Diffusion consistency failures</title>"
|
|
"<style>"
|
|
"body{font-family:sans-serif;margin:24px;background:#f5f6f8;color:#202124}"
|
|
"section{margin:0 0 28px;padding:16px;background:white;border:1px solid #ddd;border-radius:6px}"
|
|
"h2{font-size:18px;margin:0 0 8px}"
|
|
"p{margin:0 0 12px;color:#444}"
|
|
"ul{margin:0 0 12px;padding-left:20px}"
|
|
"img{max-width:100%;height:auto;border:1px solid #ddd}"
|
|
"</style></head><body>"
|
|
"<h1>Diffusion consistency failures</h1>" + "".join(sections) + "</body></html>"
|
|
)
|
|
(out_dir / "index.html").write_text(doc, encoding="utf-8")
|
|
|
|
|
|
def save_consistency_failure_artifact(
|
|
artifact_dir: str | Path | None,
|
|
case_id: str,
|
|
num_gpus: int,
|
|
output_frames: list[np.ndarray],
|
|
gt_data: LoadedConsistencyGT,
|
|
result: ConsistencyResult,
|
|
is_video: bool,
|
|
output_format: str | None = None,
|
|
gt_remote_files: list[tuple[str, str]] | None = None,
|
|
) -> Path | None:
|
|
if not artifact_dir:
|
|
return None
|
|
|
|
out_dir = Path(artifact_dir) / "consistency_failures"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
safe_case_id = _safe_artifact_name(case_id)
|
|
image_name = f"{safe_case_id}.png"
|
|
image_path = out_dir / image_name
|
|
comparison = _make_consistency_failure_image(
|
|
case_id=case_id,
|
|
num_gpus=num_gpus,
|
|
output_frames=output_frames,
|
|
gt_data=gt_data,
|
|
result=result,
|
|
is_video=is_video,
|
|
)
|
|
comparison.save(image_path)
|
|
|
|
generated_files = _save_generated_artifact_images(
|
|
out_dir=out_dir,
|
|
case_id=case_id,
|
|
num_gpus=num_gpus,
|
|
output_frames=output_frames,
|
|
is_video=is_video,
|
|
output_format=output_format,
|
|
)
|
|
|
|
record = _consistency_failure_record(
|
|
case_id=case_id,
|
|
num_gpus=num_gpus,
|
|
result=result,
|
|
is_video=is_video,
|
|
output_format=output_format,
|
|
image_name=image_name,
|
|
generated_files=generated_files,
|
|
gt_remote_files=gt_remote_files,
|
|
)
|
|
case_json_path = out_dir / f"{safe_case_id}.json"
|
|
case_json_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
|
|
summary_path = out_dir / "summary.json"
|
|
records = []
|
|
if summary_path.exists():
|
|
records = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
records = [
|
|
item
|
|
for item in records
|
|
if not (item.get("case_id") == case_id and item.get("num_gpus") == num_gpus)
|
|
]
|
|
records.append(record)
|
|
summary_path.write_text(json.dumps(records, indent=2) + "\n", encoding="utf-8")
|
|
_write_consistency_failure_index(out_dir, records)
|
|
return image_path
|