diff --git a/.buildkite/cuda/test-nightly.yml b/.buildkite/cuda/test-nightly.yml index 8b641a0f4..ab9f0e060 100644 --- a/.buildkite/cuda/test-nightly.yml +++ b/.buildkite/cuda/test-nightly.yml @@ -122,7 +122,7 @@ steps: - export BENCHMARK_DIR=tests/dfx/perf/results - | set +e - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "cuda and H100" --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json EXIT=$$? buildkite-agent artifact upload "tests/dfx/perf/results/*.json" exit $$EXIT diff --git a/.buildkite/npu/test-npu-nightly.yml b/.buildkite/npu/test-npu-nightly.yml index 4925f84c6..b06201146 100644 --- a/.buildkite/npu/test-npu-nightly.yml +++ b/.buildkite/npu/test-npu-nightly.yml @@ -98,6 +98,22 @@ steps: EXIT=$$? buildkite-agent artifact upload "tests/dfx/perf/results/*.json" exit $$EXIT + - label: ":full_moon: Omni · MiniCPM-o 4.5 · Perf Test (A2)" + key: nightly-omni-performance-minicpmo-4-5-npu-a2 + timeout_in_minutes: 180 + mirror_hardwares: a2b3_npu_1 + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "npu and A2" --test-config-file tests/dfx/perf/tests/test_minicpmo_4_5.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT - label: ":full_moon: Omni · MiniCPM-o 4.5 · Duplex Seed-TTS Perf Test" key: nightly-omni-performance-minicpmo-4-5-duplex-seed-tts-npu timeout_in_minutes: 180 diff --git a/tests/dfx/perf/tests/test_minicpmo_4_5.json b/tests/dfx/perf/tests/test_minicpmo_4_5.json index 19b8540d3..3feaf2e4c 100644 --- a/tests/dfx/perf/tests/test_minicpmo_4_5.json +++ b/tests/dfx/perf/tests/test_minicpmo_4_5.json @@ -81,5 +81,60 @@ } } ] + }, + { + "test_name": "test_minicpmo_4_5_a2", + "mark": [ + { + "hardware_marks": { + "res": { + "npu": "A2" + }, + "num_cards": 1 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "openbmb/MiniCPM-o-4_5", + "extra_cli_args": [ + "--deploy-config", + "vllm_omni/deploy/minicpmo_4_5.yaml", + "--trust-remote-code" + ] + }, + "benchmark_params": [ + { + "dataset_name": "seed-tts", + "dataset_path": "zhaochenyang20/seed-tts-eval", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8 + ], + "no_oversample": true, + "trust_remote_code": true, + "seed_tts_locale": "en", + "extra_body": { + "modalities": [ + "text", + "audio" + ], + "chat_template_kwargs": { + "enable_thinking": false, + "use_tts_template": true + } + }, + "percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration" + } + ] } ] diff --git a/tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py b/tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py index 941a87c5b..ef939b9be 100644 --- a/tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py +++ b/tests/e2e/accuracy/minicpmo_4_5/test_minicpmo_4_5.py @@ -1,10 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 -"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS accuracy regression coverage. +"""MiniCPM-o 4.5 Daily-Omni + Seed-TTS + Video-MME accuracy regression coverage. Daily-Omni settings follow the MiniCPM interleaved AV recipe that reaches ~78% overall accuracy on Daily-Omni (``minicpm-interleave``, ``temperature=0``, text modalities with ``use_tts_template``, and server ``--interleave-mm-strings`` + 1fps / 128-frame media-io kwargs, also pinned in ``minicpmo_4_5.yaml``). + +Video-MME settings follow OpenBMB OmniEvalKit MiniCPM ``videomme`` (w/o subs): +``minicpm-frames``, ``max_frames=96``, ``temperature=0``, ``output_len=128``. +Official MiniCPM-o 4.5 reports **70.4**; the gate is **0.68** (~2pp margin). """ from __future__ import annotations @@ -35,12 +39,18 @@ _RESULT_DIR = Path( ) _MIN_DAILY_OMNI_ACCURACY = 0.78 +# MiniCPM-o 4.5 reports 70.4 on Video-MME (w/o subs); ~2pp margin like Daily-Omni. +_MIN_VIDEOMME_ACCURACY = float(os.environ.get("ACC_BENCH_MIN_VIDEOMME_ACCURACY", "0.68")) _MAX_SEED_TTS_MEAN_WER = 0.05 -# Match the validated Daily-Omni client body from daily_omni_bench.sh. +# Match the validated Daily-Omni / Video-MME client body from the MiniCPM run scripts. _DAILY_EXTRA_BODY = { "modalities": ["text"], "chat_template_kwargs": {"enable_thinking": False}, } +_VIDEOMME_EXTRA_BODY = { + "modalities": ["text"], + "chat_template_kwargs": {"enable_thinking": False}, +} _SEED_EXTRA_BODY = { "modalities": ["text", "audio"], "chat_template_kwargs": { @@ -58,6 +68,15 @@ _DAILY_OMNI_SERVER_ARGS = [ "--media-io-kwargs", '{"video":{"fps":1,"num_frames":128}}', ] +# Video-MME ``minicpm-frames`` sends sampled frames as image_url (no AV interleave). +# Allow local ``file://`` frame-cache URLs so CI does not need megabyte-scale base64 +# payloads; the autouse fixture still appends ``--videomme-inline-local-video`` when +# the allowlist is unavailable. +_VIDEOMME_SERVER_ARGS = [ + "--trust-remote-code", + "--allowed-local-media-path", + os.environ.get("VLLM_TEST_ALLOWED_LOCAL_MEDIA_PATH", "/"), +] _SEED_TTS_SERVER_ARGS = [ "--trust-remote-code", ] @@ -72,6 +91,14 @@ daily_test_params = [ ) ] +videomme_test_params = [ + OmniServerParams( + model=_MODEL, + stage_config_path=_DEPLOY_CONFIG, + server_args=list(_VIDEOMME_SERVER_ARGS), + ) +] + seed_test_params = [ OmniServerParams( model=_MODEL, @@ -88,18 +115,53 @@ def _require_vllm_cli() -> None: pytest.skip(str(exc)) -@pytest.fixture(autouse=True) -def _inline_daily_omni_media(monkeypatch: pytest.MonkeyPatch) -> None: - """Inline cached Hub/local videos because the test server has no local-media allowlist.""" - original = _acc_bench.daily_omni_bench_argv +def _optional_local_videomme_dataset_path() -> str | None: + """Return an explicit local Video-MME root, or ``None`` to use the Hub default. - def _wrapped() -> list[str]: - argv = list(original()) + Same rule as Daily-Omni / Seed-TTS: only an env-provided existing directory counts as + local. Hard-coded workspace paths are intentionally not probed so CI defaults to + ``lmms-lab/Video-MME`` via ``--videomme-repo``. + """ + for key in ("VLLM_VIDEOMME_DATASET_PATH", "VIDEOMME_ROOT"): + raw = os.environ.get(key, "").strip() + if not raw: + continue + path = Path(raw).expanduser() + if path.is_absolute() and path.is_dir(): + return str(path.resolve()) + return None + + +@pytest.fixture(autouse=True) +def _inline_local_media_when_needed(monkeypatch: pytest.MonkeyPatch) -> None: + """Inline Hub/local media when the server has no usable local-media allowlist. + + Daily-Omni always inlines (matches the historical Qwen/MiniCPM accuracy fixture). + Video-MME prefers ``file://`` via ``--allowed-local-media-path`` (see + ``_VIDEOMME_SERVER_ARGS``); force inline only when ``VLLM_VIDEOMME_FORCE_INLINE=1``. + """ + original_daily = _acc_bench.daily_omni_bench_argv + original_videomme = _acc_bench.videomme_bench_argv + + def _wrap_daily() -> list[str]: + argv = list(original_daily()) if "--daily-omni-inline-local-video" not in argv: argv.append("--daily-omni-inline-local-video") return argv - monkeypatch.setattr(_acc_bench, "daily_omni_bench_argv", _wrapped) + def _wrap_videomme() -> list[str]: + argv = list(original_videomme()) + force_inline = os.environ.get("VLLM_VIDEOMME_FORCE_INLINE", "").strip().lower() in { + "1", + "true", + "yes", + } + if force_inline and "--videomme-inline-local-video" not in argv: + argv.append("--videomme-inline-local-video") + return argv + + monkeypatch.setattr(_acc_bench, "daily_omni_bench_argv", _wrap_daily) + monkeypatch.setattr(_acc_bench, "videomme_bench_argv", _wrap_videomme) monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") @@ -114,6 +176,7 @@ def test_minicpmo_4_5_daily_omni_accuracy_bench(omni_server) -> None: omni_server, skip_seed=True, skip_daily=False, + skip_videomme=True, ) argv.extend( [ @@ -138,6 +201,55 @@ def test_minicpmo_4_5_daily_omni_accuracy_bench(omni_server) -> None: assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0 +@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1) +@pytest.mark.parametrize("omni_server", videomme_test_params, indirect=True) +def test_minicpmo_4_5_videomme_accuracy_bench(omni_server) -> None: + """Gate MiniCPM-o 4.5 Video-MME (w/o subs) overall accuracy at the OmniEvalKit recipe.""" + _require_vllm_cli() + pytest.importorskip("datasets") + pytest.importorskip("huggingface_hub") + pytest.importorskip("av") + + dataset_path = _optional_local_videomme_dataset_path() + num_prompts = int(os.environ.get("ACC_BENCH_VIDEOMME_NUM_PROMPTS", "2700")) + max_concurrency = int(os.environ.get("ACC_BENCH_VIDEOMME_MAX_CONCURRENCY", "4")) + + argv = build_acc_benchmark_cli_argv( + omni_server, + skip_seed=True, + skip_daily=True, + skip_videomme=False, + num_prompts=num_prompts, + max_concurrency=max_concurrency, + ) + argv.extend( + [ + "--result-dir", + str(_RESULT_DIR), + "--min-videomme-accuracy", + str(_MIN_VIDEOMME_ACCURACY), + "--temperature", + "0", + "--output-len", + "128", + "--videomme-pack-mode", + "minicpm-frames", + "--videomme-max-frames", + "96", + "--videomme-duration", + os.environ.get("ACC_BENCH_VIDEOMME_DURATION", "all"), + "--videomme-extra-body-json", + json.dumps(_VIDEOMME_EXTRA_BODY, separators=(",", ":")), + "--trust-remote-code", + ] + ) + # Absolute local root only; otherwise --videomme-repo (Hub) from build_acc_benchmark_cli_argv. + if dataset_path is not None: + argv.extend(["--videomme-dataset-path", dataset_path]) + + assert _acc_bench.run_acc_benchmark(_acc_bench.parse_acc_benchmark_args(argv)) == 0 + + @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1) @pytest.mark.parametrize("omni_server", seed_test_params, indirect=True) def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None: @@ -148,6 +260,7 @@ def test_minicpmo_4_5_seed_tts_wer_bench(omni_server) -> None: omni_server, skip_seed=False, skip_daily=True, + skip_videomme=True, max_concurrency=4, ) argv.extend( @@ -176,6 +289,7 @@ def test_minicpmo_4_5_duplex_seed_tts_wer_bench(omni_server) -> None: omni_server, skip_seed=False, skip_daily=True, + skip_videomme=True, num_prompts=50, max_concurrency=1, ) diff --git a/tests/e2e/accuracy/qwen3_omni/qwen3_omni_acc_bench_core.py b/tests/e2e/accuracy/qwen3_omni/qwen3_omni_acc_bench_core.py index dfc53a06e..d0b9f067e 100644 --- a/tests/e2e/accuracy/qwen3_omni/qwen3_omni_acc_bench_core.py +++ b/tests/e2e/accuracy/qwen3_omni/qwen3_omni_acc_bench_core.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -"""Shared helpers for Qwen3-Omni Daily-Omni / Seed-TTS ``vllm bench serve --omni`` accuracy runs. +"""Shared helpers for Omni Daily-Omni / Seed-TTS / Video-MME ``vllm bench serve --omni`` accuracy runs. Local dataset paths are **optional**. When ``VLLM_DAILY_OMNI_QA_JSON`` + ``VLLM_DAILY_OMNI_VIDEO_DIR`` point to existing files, those are used with inline video. Otherwise the benchmark falls back to @@ -9,6 +9,10 @@ bench request that needs media downloads ``Videos.tar`` from the Hub when no vid Similarly for Seed-TTS: a local directory wins; otherwise ``--dataset-path`` uses the Hub id and ``huggingface_hub.snapshot_download`` inside ``resolve_seed_tts_root`` pulls files on demand. +Video-MME follows the same pattern: an existing local directory via ``VLLM_VIDEOMME_DATASET_PATH`` / +``VIDEOMME_ROOT`` (or ``--videomme-dataset-path`` / ``--dataset-path``) wins; otherwise the Hub id +``lmms-lab/Video-MME`` is used and ``resolve_videomme_root`` downloads parquet + video archives. + Use :func:`build_acc_benchmark_cli_argv` to assemble ``argv`` for a live Omni server (host/port/model and small bench defaults) before ``parse_args`` / ``run_acc_benchmark`` in the accuracy driver. """ @@ -25,6 +29,7 @@ from typing import Any, Protocol DEFAULT_DAILY_OMNI_HF_REPO = "liarliar/Daily-Omni" DEFAULT_SEED_TTS_HF_REPO = "zhaochenyang20/seed-tts-eval" +DEFAULT_VIDEOMME_HF_REPO = "lmms-lab/Video-MME" class OmniBenchServerEndpoint(Protocol): @@ -40,6 +45,7 @@ def build_acc_benchmark_cli_argv( *, skip_seed: bool, skip_daily: bool, + skip_videomme: bool = True, num_prompts: int | None = None, max_concurrency: int | None = None, ) -> list[str]: @@ -49,6 +55,9 @@ def build_acc_benchmark_cli_argv( ``--num-prompts`` / ``--max-concurrency`` defaults (overridable via ``ACC_BENCH_NUM_PROMPTS`` / ``ACC_BENCH_MAX_CONCURRENCY``), and when Daily-Omni runs adds ``--daily-omni-repo`` so Hub QA matches :func:`daily_omni_bench_argv` once ``run_acc_benchmark`` mirrors ``--daily-omni-repo`` into env. + + Video-MME is **opt-in** (``skip_videomme=True`` by default) so existing Daily-Omni / Seed-TTS + callers stay unchanged; pass ``skip_videomme=False`` (emits ``--run-videomme``) to enable it. """ n_prompts = int(os.environ.get("ACC_BENCH_NUM_PROMPTS", "2000")) if num_prompts is None else int(num_prompts) n_conc = int(os.environ.get("ACC_BENCH_MAX_CONCURRENCY", "10")) if max_concurrency is None else int(max_concurrency) @@ -71,6 +80,12 @@ def build_acc_benchmark_cli_argv( argv.append("--skip-seed-tts") if skip_daily: argv.append("--skip-daily-omni") + if skip_videomme: + argv.append("--skip-videomme") + else: + argv.append("--run-videomme") + repo = os.environ.get("VLLM_VIDEOMME_REPO", DEFAULT_VIDEOMME_HF_REPO).strip() or DEFAULT_VIDEOMME_HF_REPO + argv.extend(["--videomme-repo", repo]) return argv @@ -100,6 +115,43 @@ def daily_omni_bench_argv() -> list[str]: ] +def videomme_bench_argv() -> list[str]: + """CLI args for Video-MME (after ``vllm bench serve --omni``). + + Same local-vs-Hub rule as :func:`seed_tts_bench_argv`: + + * If ``VLLM_VIDEOMME_DATASET_PATH`` / ``VIDEOMME_ROOT`` names an existing directory, + use that local mirror as ``--dataset-path``. + * Otherwise pass the Hub id (``VLLM_VIDEOMME_REPO`` / ``lmms-lab/Video-MME``); the child + bench downloads via ``huggingface_hub.snapshot_download``. + """ + root = os.environ.get("VLLM_VIDEOMME_DATASET_PATH", "").strip() or os.environ.get("VIDEOMME_ROOT", "").strip() + if root: + p = Path(root).expanduser() + # Preserve Hub ids verbatim. Only canonicalize when the value is a real directory. + if p.exists() and p.is_dir(): + return [ + "--dataset-name", + "videomme", + "--dataset-path", + str(p.resolve()), + ] + # Non-directory values (e.g. accidental Hub id in DATASET_PATH) fall through as path. + return [ + "--dataset-name", + "videomme", + "--dataset-path", + root, + ] + repo = os.environ.get("VLLM_VIDEOMME_REPO", DEFAULT_VIDEOMME_HF_REPO).strip() or DEFAULT_VIDEOMME_HF_REPO + return [ + "--dataset-name", + "videomme", + "--dataset-path", + repo, + ] + + def seed_tts_bench_argv(*, locale: str = "en") -> list[str]: """CLI args for Seed-TTS (after ``vllm bench serve --omni``).""" dp = os.environ.get("VLLM_SEED_TTS_DATASET_PATH", "").strip() @@ -209,6 +261,12 @@ def assert_daily_omni_scored(result: dict[str, Any]) -> None: assert int(result.get("daily_omni_evaluated_ok", 0) or 0) > 0, "no successful MCQ rows (daily_omni_evaluated_ok==0)" +def assert_videomme_scored(result: dict[str, Any]) -> None: + acc = result.get("videomme_accuracy") + assert acc is not None, "videomme_accuracy missing — wrong dataset or benchmark wiring" + assert int(result.get("videomme_evaluated_ok", 0) or 0) > 0, "no successful MCQ rows (videomme_evaluated_ok==0)" + + def assert_seed_tts_scored(result: dict[str, Any]) -> None: err = result.get("seed_tts_eval_setup_error") assert not err, f"Seed-TTS eval deps/setup failed: {err}" diff --git a/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py b/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py index 2bacfa798..27a07b9a9 100644 --- a/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py +++ b/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py @@ -11,6 +11,9 @@ H100 / MI325) because they launch the live Omni server inside the test. run **fails** if accuracy is strictly below **0.69** (``--min-daily-omni-accuracy`` / ``ACC_BENCH_MIN_DAILY_OMNI_ACCURACY``). 2. **Seed-TTS** — ``seed-tts-eval``-style metrics when ``--seed-tts-wer-eval`` is used (WER / SIM / UTMOS keys from :func:`compute_seed_tts_wer_metrics`). +3. **Video-MME** — opt-in via ``--run-videomme`` (skipped by default so Daily-Omni / Seed-TTS callers + stay unchanged). MiniCPM-o 4.5 reports **70.4** on Video-MME (w/o subs); the default floor is + **0.68** (``--min-videomme-accuracy`` / ``ACC_BENCH_MIN_VIDEOMME_ACCURACY``). Prerequisites ------------- @@ -21,7 +24,8 @@ Prerequisites On L4 you may need a smaller checkpoint, quantization, or tighter engine flags; this script only drives the **client** benchmark. -* ``vllm`` CLI from **vLLM-Omni** (so ``bench serve`` registers ``daily-omni`` / ``seed-tts``). +* ``vllm`` CLI from **vLLM-Omni** (so ``bench serve`` registers ``daily-omni`` / ``seed-tts`` / + ``videomme``). * **Daily-Omni** — if local ``qa.json`` + ``Videos/`` are not both provided (CLI or matching env), the client passes ``--dataset-path`` with a Hub id (default ``liarliar/Daily-Omni``). The **child** @@ -31,6 +35,13 @@ Prerequisites the dataset repo with ``--daily-omni-repo`` or ``VLLM_DAILY_OMNI_REPO``; override the tar repo with ``VLLM_DAILY_OMNI_MEDIA_REPO`` if needed. +* **Video-MME** — by default ``--dataset-path`` is the Hub id ``lmms-lab/Video-MME`` (override with + ``--videomme-repo`` / ``VLLM_VIDEOMME_REPO``). The child bench downloads parquet + video archives via + ``huggingface_hub.snapshot_download`` (needs ``huggingface_hub``, network or HF cache). Pass an + existing local root with ``--videomme-dataset-path`` / ``VLLM_VIDEOMME_DATASET_PATH`` / + ``VIDEOMME_ROOT`` to skip the Hub download. For CI servers without ``--allowed-local-media-path``, + pass ``--videomme-inline-local-video`` (or rely on the pytest fixture that appends it). + * **Seed-TTS** optional extras for WER/SIM/UTMOS:: pip install 'vllm-omni[dev]' @@ -55,6 +66,18 @@ Skip one suite, tighten gates:: --skip-daily-omni \\ --max-seed-tts-mean-wer 0.35 \\ --min-seed-tts-mean-sim 0.75 + +MiniCPM-o Video-MME (w/o subs):: + + python tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py \\ + --skip-daily-omni --skip-seed-tts --run-videomme \\ + --videomme-pack-mode minicpm-frames \\ + --videomme-max-frames 96 \\ + --temperature 0 --output-len 128 \\ + --min-videomme-accuracy 0.68 + + # Optional local mirror (absolute path) instead of Hub download: + # --videomme-dataset-path /path/to/Video-MME """ from __future__ import annotations @@ -75,6 +98,7 @@ from tests.e2e.accuracy.qwen3_omni.qwen3_omni_acc_bench_core import ( load_benchmark_result, run_vllm_bench_subprocess, seed_tts_bench_argv, + videomme_bench_argv, ) _REPO_ROOT = Path(__file__).resolve().parents[4] @@ -102,6 +126,20 @@ def _validate_daily_omni(result: dict[str, Any], *, min_accuracy: float | None) return errs +def _validate_videomme(result: dict[str, Any], *, min_accuracy: float | None) -> list[str]: + errs: list[str] = [] + acc = result.get("videomme_accuracy") + if acc is None: + errs.append("Missing videomme_accuracy (wrong dataset or no gold-evaluated rows).") + return errs + ev = int(result.get("videomme_evaluated_ok", 0) or 0) + if ev <= 0: + errs.append("videomme_evaluated_ok is 0; no successful MCQ rows to score.") + if min_accuracy is not None and float(acc) + 1e-12 < float(min_accuracy): + errs.append(f"videomme_accuracy={acc:.6f} < --min-videomme-accuracy={min_accuracy}") + return errs + + def _validate_seed_tts( result: dict[str, Any], *, @@ -130,7 +168,7 @@ def _validate_seed_tts( def sync_dataset_env_from_ns(ns: argparse.Namespace) -> None: - """Mirror CLI path flags into env vars read by ``daily_omni_bench_argv`` / ``seed_tts_bench_argv``.""" + """Mirror CLI path flags into env vars read by dataset ``*_bench_argv`` helpers.""" repo = getattr(ns, "daily_omni_repo", None) if repo is not None and str(repo).strip(): os.environ["VLLM_DAILY_OMNI_REPO"] = str(repo).strip() @@ -138,6 +176,14 @@ def sync_dataset_env_from_ns(ns: argparse.Namespace) -> None: os.environ["VLLM_DAILY_OMNI_QA_JSON"] = str(Path(ns.daily_omni_qa_json).expanduser().resolve()) if ns.daily_omni_video_dir is not None: os.environ["VLLM_DAILY_OMNI_VIDEO_DIR"] = str(Path(ns.daily_omni_video_dir).expanduser().resolve()) + vm_repo = getattr(ns, "videomme_repo", None) + if vm_repo is not None and str(vm_repo).strip(): + os.environ["VLLM_VIDEOMME_REPO"] = str(vm_repo).strip() + vm_path = getattr(ns, "videomme_dataset_path", None) + if vm_path is not None and str(vm_path).strip(): + raw = str(vm_path).strip() + p = Path(raw).expanduser() + os.environ["VLLM_VIDEOMME_DATASET_PATH"] = str(p.resolve()) if p.exists() and p.is_dir() else raw if ns.seed_tts_dataset_path is not None: # ``--seed-tts-dataset-path`` accepts either a local directory or a # Hugging Face repo id. Only resolve to an absolute filesystem path @@ -157,6 +203,9 @@ def _preserve_benchmark_dataset_env() -> Any: "VLLM_DAILY_OMNI_REPO", "VLLM_DAILY_OMNI_QA_JSON", "VLLM_DAILY_OMNI_VIDEO_DIR", + "VLLM_VIDEOMME_REPO", + "VLLM_VIDEOMME_DATASET_PATH", + "VIDEOMME_ROOT", "VLLM_SEED_TTS_DATASET_PATH", "SEED_TTS_ROOT", ) @@ -225,6 +274,46 @@ def run_daily_omni(ns: argparse.Namespace, vllm: str) -> Path: return out +def run_videomme(ns: argparse.Namespace, vllm: str) -> Path: + ns.result_dir.mkdir(parents=True, exist_ok=True) + tag = datetime.now().strftime("%Y%m%d-%H%M%S") + result_filename = f"omni_acc_videomme_{tag}.json" + extra = json.loads(ns.videomme_extra_body_json) + argv = ( + _build_common_args(ns, result_filename=result_filename) + + videomme_bench_argv() + + [ + "--disable-shuffle", + "--videomme-pack-mode", + ns.videomme_pack_mode, + "--videomme-duration", + ns.videomme_duration, + "--extra-body", + json.dumps(extra, ensure_ascii=False, separators=(",", ":")), + ] + ) + if ns.videomme_max_frames is not None: + argv.extend(["--videomme-max-frames", str(int(ns.videomme_max_frames))]) + if ns.videomme_use_subtitle: + argv.append("--videomme-use-subtitle") + if ns.videomme_inline_local_video: + argv.append("--videomme-inline-local-video") + if ns.videomme_save_eval_items: + argv.append("--videomme-save-eval-items") + if ns.videomme_video_dir is not None: + argv.extend(["--videomme-video-dir", str(Path(ns.videomme_video_dir).expanduser().resolve())]) + if ns.videomme_parquet is not None: + argv.extend(["--videomme-parquet", str(Path(ns.videomme_parquet).expanduser().resolve())]) + if ns.videomme_subtitle_dir is not None: + argv.extend(["--videomme-subtitle-dir", str(Path(ns.videomme_subtitle_dir).expanduser().resolve())]) + print("\n$", vllm, *argv, "\n", flush=True) + run_vllm_bench_subprocess(vllm, argv) + out = Path(ns.result_dir) / result_filename + if not out.is_file(): + raise FileNotFoundError(f"Expected result JSON at {out}") + return out + + def run_seed_tts(ns: argparse.Namespace, vllm: str) -> Path: ns.result_dir.mkdir(parents=True, exist_ok=True) tag = datetime.now().strftime("%Y%m%d-%H%M%S") @@ -312,6 +401,20 @@ def build_arg_parser() -> argparse.ArgumentParser: p.add_argument("--skip-daily-omni", action="store_true") p.add_argument("--skip-seed-tts", action="store_true") + # Video-MME is opt-in: default skip=True so existing Daily-Omni / Seed-TTS callers stay unchanged. + p.set_defaults(skip_videomme=True) + p.add_argument( + "--skip-videomme", + action="store_true", + dest="skip_videomme", + help="Skip Video-MME (default).", + ) + p.add_argument( + "--run-videomme", + action="store_false", + dest="skip_videomme", + help="Enable the Video-MME accuracy suite.", + ) p.add_argument( "--daily-omni-repo", @@ -358,6 +461,87 @@ def build_arg_parser() -> argparse.ArgumentParser: "Default baseline 0.69; override with env ACC_BENCH_MIN_DAILY_OMNI_ACCURACY or pass 0 to disable the floor.", ) + p.add_argument( + "--videomme-repo", + type=str, + default=None, + help="Hugging Face dataset id for Video-MME Hub mode (sets VLLM_VIDEOMME_REPO). " + "Default when no local path is set: lmms-lab/Video-MME. " + "Ignored when a local --videomme-dataset-path / VLLM_VIDEOMME_DATASET_PATH is used.", + ) + p.add_argument( + "--videomme-dataset-path", + type=str, + default=None, + help="Optional local Video-MME root (parquet + videos). Existing directories skip the Hub " + "download; Hub ids are also accepted and passed through as --dataset-path. " + "Sets VLLM_VIDEOMME_DATASET_PATH.", + ) + p.add_argument( + "--videomme-parquet", + type=Path, + default=None, + help="Optional explicit path to videomme/test-*.parquet.", + ) + p.add_argument( + "--videomme-video-dir", + type=Path, + default=None, + help="Optional explicit directory of extracted Video-MME videos.", + ) + p.add_argument( + "--videomme-subtitle-dir", + type=Path, + default=None, + help="Optional subtitle directory (used with --videomme-use-subtitle).", + ) + p.add_argument( + "--videomme-pack-mode", + choices=("minicpm-frames", "minicpm-interleave", "video_url"), + default="minicpm-frames", + help="Video-MME multimodal pack recipe (OmniEvalKit MiniCPM default: minicpm-frames).", + ) + p.add_argument( + "--videomme-max-frames", + type=int, + default=None, + help="Override max sampled frames (OmniEvalKit: 96 for minicpm-frames, 64 for interleave).", + ) + p.add_argument( + "--videomme-duration", + choices=("all", "short", "medium", "long"), + default="all", + help="Filter Video-MME by duration bucket (default: all).", + ) + p.add_argument( + "--videomme-use-subtitle", + action="store_true", + help="Prepend subtitle text to the user prompt (Video-MME w/ subs).", + ) + p.add_argument( + "--videomme-inline-local-video", + action="store_true", + help="Embed frames/audio as base64 data URLs (no --allowed-local-media-path on the server).", + ) + p.add_argument( + "--videomme-extra-body-json", + default='{"modalities":["text"],"chat_template_kwargs":{"enable_thinking":false}}', + help="JSON merged into each chat request for Video-MME.", + ) + p.add_argument( + "--videomme-save-eval-items", + action="store_true", + help="Include per-request Video-MME accuracy rows in the saved JSON.", + ) + p.add_argument( + "--min-videomme-accuracy", + type=float, + default=float((os.environ.get("ACC_BENCH_MIN_VIDEOMME_ACCURACY") or "0.68").strip() or "0.68"), + help="Fail when videomme_accuracy is strictly below this threshold (0–1). " + "Default 0.68 (~2pp under MiniCPM-o 4.5 reported 70.4 w/o subs); " + "override with ACC_BENCH_MIN_VIDEOMME_ACCURACY or pass 0 to disable the floor.", + ) + p.add_argument( "--seed-tts-dataset-path", type=str, @@ -427,7 +611,7 @@ def parse_acc_benchmark_args(argv: list[str] | None = None) -> argparse.Namespac def run_acc_benchmark(ns: argparse.Namespace) -> int: - """Run Daily-Omni and/or Seed-TTS client benches against a running server; return 0 on success.""" + """Run configured client benches against a running server; return 0 on success.""" failed: list[str] = [] with _preserve_benchmark_dataset_env(): @@ -451,6 +635,21 @@ def run_acc_benchmark(ns: argparse.Namespace) -> int: flush=True, ) + if not ns.skip_videomme: + path = run_videomme(ns, vllm) + print(f"\n[Video-MME] result JSON: {path}", flush=True) + data = load_benchmark_result(path) + errs = _validate_videomme(data, min_accuracy=ns.min_videomme_accuracy) + if errs: + failed.extend([f"[Video-MME] {e}" for e in errs]) + else: + print( + f"[Video-MME] videomme_accuracy={data.get('videomme_accuracy')} " + f"evaluated_ok={data.get('videomme_evaluated_ok')} " + f"per_duration={data.get('videomme_per_duration_accuracy')}", + flush=True, + ) + if not ns.skip_seed_tts: path = run_seed_tts(ns, vllm) print(f"\n[Seed-TTS] result JSON: {path}", flush=True) diff --git a/vllm_omni/benchmarks/data_modules/videomme_dataset.py b/vllm_omni/benchmarks/data_modules/videomme_dataset.py new file mode 100644 index 000000000..68a5263e6 --- /dev/null +++ b/vllm_omni/benchmarks/data_modules/videomme_dataset.py @@ -0,0 +1,894 @@ +"""Video-MME dataset loader for vLLM-Omni bench serve. + +Video-MME (lmms-lab/Video-MME) is a full-spectrum video MLLM benchmark with +900 videos / 2,700 MCQ pairs across short / medium / long durations. + +This loader follows OpenBMB OmniEvalKit's MiniCPM-o recipe +(``o_e_Kit/configs/generation_configs.json``): + +- ``videomme`` (default): frames only, ``max_frames=96``, ``load_av=false`` +- ``videomme_short``-style: interleaved AV at 1fps via ``minicpm-interleave`` + +Prompt template and option formatting match OmniEvalKit ``minicpmo.py``; frame +timestamps match OmniEvalKit ``_sample_video_frame_indices``. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import re +import time +import zipfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +try: + from vllm.benchmarks.datasets import BenchmarkDataset, SampleRequest +except ImportError: + from vllm.benchmarks.datasets import HuggingFaceDataset as BenchmarkDataset + from vllm.benchmarks.datasets import SampleRequest +from vllm.tokenizers import TokenizerLike +from vllm.tokenizers.hf import get_cached_tokenizer + +try: + from datasets import load_dataset +except ImportError: + load_dataset = None + +from vllm_omni.benchmarks.data_modules.daily_omni_dataset import ( + MINICPM_OMNI_SYSTEM_TEXT, + _ListDatasetIterator, + _numpy_to_wav_bytes, + _pil_to_jpeg_bytes, + _uniform_sample_indices, +) + +logger = logging.getLogger(__name__) + +_MINICPM_AUDIO_SR = 16000 + +# OmniEvalKit MiniCPM ``videomme`` / ``videomme_short`` defaults. +VIDEOMME_DEFAULT_MAX_FRAMES = 96 +VIDEOMME_SHORT_MAX_FRAMES = 64 +VIDEOMME_DEFAULT_HF_REPO = "lmms-lab/Video-MME" + +#: Decode forward to reach a target this close, seek to the preceding keyframe beyond it. +_SEEK_THRESHOLD_S = 3.0 + +#: How often ``sample`` reports progress while warming a cold frame cache. +_SAMPLE_PROGRESS_INTERVAL_S = 30.0 + +VideoMMEPackMode = Literal["minicpm-frames", "minicpm-interleave", "video_url"] +VideoMMEDurationFilter = Literal["all", "short", "medium", "long"] + +# OmniEvalKit ``generation_configs.json`` → ``videomme.user_prompt`` (``{media}`` stripped; +# media parts are prepended as OpenAI content parts, matching ``build_content``). +VIDEOMME_USER_PROMPT_TEMPLATE = ( + "Carefully read the following question and select the letter corresponding to the " + "correct answer.Highlight the applicable choices without giving explanations.\n" + "{question}\n" + "Options:\n" + "{options}\n" + "Please select the correct answer from the options above. Only respond with the letter." +) + +_OPTION_LETTERS = "ABCDEFGHIJKL" +_OPTION_PREFIX_RE = re.compile(r"^([A-L])\s*[.、::)]\s*(.*)$") + +_VIDEO_SUFFIXES = (".mp4", ".mkv", ".webm", ".avi") + + +def _iter_video_files(root: Path) -> Iterator[Path]: + """Yield video files under ``root``, skipping dot-dirs such as the frame cache.""" + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + for name in filenames: + if os.path.splitext(name)[1].lower() in _VIDEO_SUFFIXES: + yield Path(dirpath) / name + + +def _resolve_hf_cache_snapshot(path: Path) -> Path: + """Map a HF hub cache dir (``datasets--org--name``) to its checked-out snapshot dir.""" + snapshots = path / "snapshots" + if not snapshots.is_dir(): + return path + ref = path / "refs" / "main" + if ref.is_file(): + try: + target = snapshots / ref.read_text(encoding="utf-8").strip() + except OSError: + target = snapshots + if target.is_dir(): + return target + revisions = [p for p in snapshots.iterdir() if p.is_dir()] + if revisions: + return max(revisions, key=lambda p: p.stat().st_mtime) + return path + + +def resolve_videomme_local_root(dataset_path: str | None) -> Path | None: + """Return a local Video-MME root if ``dataset_path`` points at an on-disk mirror. + + Hub ids such as ``lmms-lab/Video-MME`` return ``None`` (they are not directories). + """ + raw = (dataset_path or "").strip() + if not raw: + return None + path = Path(raw).expanduser() + if not path.is_dir(): + return None + return _resolve_hf_cache_snapshot(path.resolve()) + + +def ensure_videomme_hub_root(repo_id: str) -> Path: + """Download the Video-MME dataset snapshot from Hugging Face and return its root. + + Mirrors :func:`vllm_omni.benchmarks.data_modules.seed_tts_dataset.resolve_seed_tts_root`: + Hub ids trigger ``snapshot_download``; callers then extract videos via + :func:`ensure_videomme_videos_extracted`. + + Raises: + ImportError: if ``huggingface_hub`` is not installed. + ValueError: if ``repo_id`` is empty. + """ + rid = (repo_id or "").strip() + if not rid: + raise ValueError("repo_id is required to download Video-MME from Hugging Face") + + try: + from huggingface_hub import snapshot_download + except ImportError as e: + raise ImportError( + "Install huggingface_hub to download Video-MME from the Hub, or pass a local " + "--dataset-path / --videomme-video-dir with parquet + extracted videos." + ) from e + + # Pull QA parquet + video/subtitle archives; skip unrelated repo files. + cache = snapshot_download( + repo_id=rid, + repo_type="dataset", + allow_patterns=[ + "videomme/**", + "*.parquet", + "videos_chunked_*.zip", + "subtitle.zip", + "subtitle/**", + "video/**", + "videos/**", + ], + ) + root = _resolve_hf_cache_snapshot(Path(cache).resolve()) + logger.info("Video-MME Hub snapshot ready at %s (repo=%s)", root, rid) + return root + + +def resolve_videomme_root(dataset_path: str | None) -> Path: + """Return a Video-MME root containing parquet and (or zip) video assets. + + * Existing local directories (absolute or relative) are used as-is. + * Otherwise ``dataset_path`` is treated as a Hugging Face dataset id and + downloaded via :func:`ensure_videomme_hub_root` (default + ``lmms-lab/Video-MME``). + """ + local = resolve_videomme_local_root(dataset_path) + if local is not None: + return local + rid = (dataset_path or "").strip() or VIDEOMME_DEFAULT_HF_REPO + return ensure_videomme_hub_root(rid) + + +def videomme_local_parquet(root: Path) -> Path | None: + for candidate in ( + root / "videomme" / "test-00000-of-00001.parquet", + root / "test-00000-of-00001.parquet", + ): + if candidate.is_file(): + return candidate + return None + + +def videomme_local_video_dir(root: Path) -> Path | None: + """Return a directory already holding extracted videos, flat or nested. + + Unzipping ``videos_chunked_*.zip`` in place yields ``videos/videos_chunked_NN/data/*.mp4``, + so probing only for ``video/*.mp4`` would miss it and trigger a redundant re-extract of + the whole ~95GB archive set. Lookups below walk the tree, so any depth is acceptable. + """ + for name in ("video", "videos", "data"): + candidate = root / name + if candidate.is_dir() and next(_iter_video_files(candidate), None) is not None: + return candidate + return None + + +def videomme_local_subtitle_dir(root: Path) -> Path | None: + candidate = root / "subtitle" + return candidate if candidate.is_dir() else None + + +def _unzip_member_flat(zf: zipfile.ZipFile, member: str, dest_dir: Path) -> None: + """Extract a zip member into ``dest_dir`` using only its basename (VLMEvalKit layout).""" + name = os.path.basename(member) + if not name: + return + dest = dest_dir / name + if dest.is_file() and dest.stat().st_size > 0: + return + dest_dir.mkdir(parents=True, exist_ok=True) + with zf.open(member) as src, open(dest, "wb") as out: + out.write(src.read()) + + +def ensure_videomme_videos_extracted(root: Path) -> Path: + """Ensure ``video/*.mp4`` exists under ``root`` by unzipping ``videos_chunked_*.zip``.""" + video_dir = root / "video" + marker = root / ".videomme_videos_extracted" + if marker.is_file() and video_dir.is_dir() and next(video_dir.glob("*.mp4"), None) is not None: + return video_dir + + # Honour a tree someone unzipped by hand rather than re-extracting ~95GB alongside it. + existing = videomme_local_video_dir(root) + if existing is not None and existing != video_dir: + return existing + + zips = [z for z in sorted(root.glob("videos_chunked_*.zip")) if z.is_file()] + if not zips and not video_dir.is_dir(): + raise FileNotFoundError(f"No Video-MME videos under {root}: expected video/*.mp4 or videos_chunked_*.zip") + + video_dir.mkdir(parents=True, exist_ok=True) + for zp in zips: + logger.info("Extracting Video-MME videos from %s -> %s", zp, video_dir) + with zipfile.ZipFile(zp, "r") as zf: + for member in zf.namelist(): + if member.lower().endswith((".mp4", ".mkv", ".webm", ".avi")): + _unzip_member_flat(zf, member, video_dir) + + marker.write_text("ok", encoding="utf-8") + return video_dir + + +def ensure_videomme_subtitles_extracted(root: Path) -> Path | None: + """Ensure ``subtitle/*.srt`` exists under ``root`` when ``subtitle.zip`` is present.""" + subtitle_dir = root / "subtitle" + if subtitle_dir.is_dir() and next(subtitle_dir.glob("*.srt"), None) is not None: + return subtitle_dir + + zip_path = root / "subtitle.zip" + if not zip_path.is_file(): + return subtitle_dir if subtitle_dir.is_dir() else None + + subtitle_dir.mkdir(parents=True, exist_ok=True) + logger.info("Extracting Video-MME subtitles from %s -> %s", zip_path, subtitle_dir) + with zipfile.ZipFile(zip_path, "r") as zf: + for member in zf.namelist(): + if member.lower().endswith(".srt"): + _unzip_member_flat(zf, member, subtitle_dir) + return subtitle_dir + + +def _probe_video_duration(video_path: Path) -> float: + """Container duration in seconds, without decoding the stream. + + Frame-counting fallbacks are deliberately avoided: Video-MME long videos run up to + an hour, and ``stream.frames == 0`` containers would otherwise force a full decode + just to learn the duration. + """ + import av + + with av.open(str(video_path)) as container: + stream = container.streams.video[0] + duration: float | None = None + if stream.duration is not None and stream.time_base is not None: + duration = float(stream.duration * stream.time_base) + elif container.duration is not None: + duration = container.duration / av.time_base + elif stream.frames and stream.average_rate: + duration = stream.frames / float(stream.average_rate) + + if not duration or duration <= 0: + raise ValueError(f"Could not determine duration of {video_path}") + return duration + + +def _decode_frames_at_timestamps( + video_path: Path, + timestamps: list[float], + *, + seek_threshold_s: float = _SEEK_THRESHOLD_S, +) -> list[Any]: + """Decode one RGB ``PIL.Image`` at (or just after) each ascending timestamp. + + A single forward pass over the stream — the Daily-Omni approach — is not affordable + here: Video-MME samples points spread across the whole video, so the last target sits + near the end and every frame of an hour-long file would be decoded. Targets within + ``seek_threshold_s`` of the decoder position are reached by decoding forward (cheaper + than a keyframe round-trip at 1fps spacing); farther ones seek to the preceding + keyframe first. + """ + import av + + if not timestamps: + return [] + + images: list[Any] = [] + with av.open(str(video_path)) as container: + stream = container.streams.video[0] + stream.thread_type = "AUTO" + time_base = float(stream.time_base) if stream.time_base else 0.0 + start_s = float(stream.start_time * stream.time_base) if stream.start_time else 0.0 + + decoder = None + frame = None + frame_time = -1.0 + image = None + + for ts in timestamps: + if decoder is None or ts < frame_time or (ts - frame_time) > seek_threshold_s: + if time_base > 0: + container.seek(int((ts + start_s) / time_base), stream=stream, backward=True) + else: + container.seek(0, stream=stream, backward=True) + decoder = container.decode(stream) + frame, image, frame_time = None, None, -1.0 + + while frame is None or frame_time < ts: + nxt = next(decoder, None) + if nxt is None: + break + frame, image = nxt, None + if nxt.pts is not None and time_base > 0: + frame_time = float(nxt.pts) * time_base - start_s + + if frame is None: + break + if image is None: + image = frame.to_image() + images.append(image) + + if not images: + raise ValueError(f"Decoded no frames from {video_path} at timestamps {timestamps[:4]}") + # Container duration can overshoot the real stream; repeat the last frame (decord clamping). + images.extend([images[-1]] * (len(timestamps) - len(images))) + return images + + +@dataclass +class VideoMMESampleRequest(SampleRequest): + """``SampleRequest`` with Video-MME gold labels for post-run accuracy scoring.""" + + videomme_gold_answer: str = "" + videomme_video_id: str = "" + videomme_question_id: str = "" + videomme_duration: str = "" + videomme_domain: str = "" + videomme_sub_category: str = "" + videomme_task_type: str = "" + omni_extra_body: dict[str, Any] | None = None + omni_chat_messages: list[dict[str, Any]] | None = None + omni_chat_mm_position: Literal["first", "last"] = "first" + + +class VideoMMEDataset(BenchmarkDataset): + """Video-MME MCQ dataset for Omni bench serve (MiniCPM-oriented packing).""" + + SUPPORTED_DATASET_PATHS: set[str] = {VIDEOMME_DEFAULT_HF_REPO} + DEFAULT_HF_DATASET_ID = VIDEOMME_DEFAULT_HF_REPO + IS_MULTIMODAL = True + DEFAULT_OUTPUT_LEN = 128 + + def __init__( + self, + dataset_path: str | None = None, + dataset_split: str = "test", + random_seed: int = 0, + video_dir: str | None = None, + subtitle_dir: str | None = None, + parquet_path: str | None = None, + pack_mode: VideoMMEPackMode = "minicpm-frames", + max_frames: int | None = None, + duration_filter: VideoMMEDurationFilter = "all", + use_subtitle: bool = False, + inline_local_video: bool = False, + trust_remote_code: bool = False, + dataset_subset: str | None = None, + no_stream: bool = False, + **kwargs, + ) -> None: + if pack_mode not in ("minicpm-frames", "minicpm-interleave", "video_url"): + raise ValueError( + f"pack_mode must be 'minicpm-frames', 'minicpm-interleave', or 'video_url', got {pack_mode!r}" + ) + if duration_filter not in ("all", "short", "medium", "long"): + raise ValueError(f"duration_filter must be all|short|medium|long, got {duration_filter!r}") + if parquet_path is None and dataset_path is None: + raise ValueError("Either 'parquet_path' (local) or 'dataset_path' (HF id / local mirror) must be provided.") + + self.parquet_path = Path(parquet_path) if parquet_path else None + self.dataset_path = dataset_path + self.dataset_split = dataset_split + self.dataset_subset = dataset_subset + self._hf_streaming = not no_stream + self.video_dir = Path(video_dir) if video_dir else None + self.subtitle_dir = Path(subtitle_dir) if subtitle_dir else None + self.pack_mode: VideoMMEPackMode = pack_mode + self.duration_filter: VideoMMEDurationFilter = duration_filter + self.use_subtitle = use_subtitle + self.inline_local_video = inline_local_video + self.trust_remote_code = trust_remote_code + self.max_frames = int( + max_frames + if max_frames is not None + else (VIDEOMME_SHORT_MAX_FRAMES if pack_mode == "minicpm-interleave" else VIDEOMME_DEFAULT_MAX_FRAMES) + ) + #: In-process memo of content parts; the on-disk frame cache survives across runs. + self._frame_cache: dict[str, list[dict[str, Any]]] = {} + self._video_index: dict[str, Path] | None = None + + super().__init__( + dataset_path=dataset_path if self.parquet_path is None else None, + random_seed=random_seed, + **kwargs, + ) + self.load_data() + logger.info( + "Loaded Video-MME: source=%s, pack_mode=%s, max_frames=%d, duration_filter=%s, " + "use_subtitle=%s, video_dir=%s", + str(self.parquet_path) if self.parquet_path else f"{dataset_path}/{dataset_split}", + pack_mode, + self.max_frames, + duration_filter, + use_subtitle, + self.video_dir, + ) + + # ------------------------------------------------------------------ loading + + def load_data(self) -> None: + if self.parquet_path is not None: + self._load_from_parquet(self.parquet_path) + return + + local_root = resolve_videomme_local_root(self.dataset_path) + if local_root is not None: + local_pq = videomme_local_parquet(local_root) + if local_pq is not None: + if self.video_dir is None: + try: + self.video_dir = ensure_videomme_videos_extracted(local_root) + except FileNotFoundError: + self.video_dir = videomme_local_video_dir(local_root) + if self.subtitle_dir is None: + self.subtitle_dir = ensure_videomme_subtitles_extracted(local_root) + self._load_from_parquet(local_pq) + return + + self._load_from_huggingface() + + def _load_from_parquet(self, path: Path) -> None: + try: + import pandas as pd + except ImportError as e: + raise ImportError("pandas is required to load Video-MME parquet") from e + if not path.is_file(): + raise FileNotFoundError(f"Video-MME parquet not found: {path}") + df = pd.read_parquet(path) + self._set_rows([row.to_dict() for _, row in df.iterrows()]) + + def _load_from_huggingface(self) -> None: + if load_dataset is None: + raise ImportError( + "datasets library is required for HuggingFace Video-MME loading. " + "Install with: pip install datasets, or pass --videomme-parquet." + ) + load_kw: dict[str, Any] = { + "split": self.dataset_split, + "streaming": self._hf_streaming, + "trust_remote_code": self.trust_remote_code, + } + if self.dataset_subset is not None: + load_kw["name"] = self.dataset_subset + ds = load_dataset(self.dataset_path, **load_kw) + self._set_rows([self._coerce_row(item) for item in ds]) + + def _set_rows(self, rows: list[dict[str, Any]]) -> None: + """Apply the duration filter and shuffle, then expose rows as ``self.data``.""" + if self.duration_filter != "all": + want = self.duration_filter.lower() + rows = [r for r in rows if str(r.get("duration") or "").strip().lower() == want] + if not getattr(self, "disable_shuffle", False) and self.random_seed is not None: + import random + + rows = rows[:] + random.Random(self.random_seed).shuffle(rows) + self.data = _ListDatasetIterator(rows) + + @staticmethod + def _coerce_row(item: Any) -> dict[str, Any]: + if isinstance(item, dict): + return item + if hasattr(item, "as_py"): + return dict(item.as_py()) + try: + return dict(item) + except (TypeError, ValueError): + return {k: item[k] for k in item} # type: ignore[misc] + + @staticmethod + def _normalize_fields(row: dict[str, Any]) -> dict[str, Any]: + # The parquet hands back a numpy array, whose truthiness raises, so an ``or`` chain + # over the alias keys is not usable here. + options: Any = [] + for key in ("options", "candidates", "choices"): + value = row.get(key) + if hasattr(value, "tolist"): + value = value.tolist() + if value is not None and len(value) > 0: + options = value + break + return { + "video_id": str(row.get("videoID") or row.get("video_id") or row.get("video") or "").strip(), + "question_id": str(row.get("question_id") or "").strip(), + "question": str(row.get("question") or "").strip(), + "options": [str(x) for x in list(options)], + "answer": str(row.get("answer") or "").strip(), + "duration": str(row.get("duration") or "").strip(), + "domain": str(row.get("domain") or "").strip(), + "sub_category": str(row.get("sub_category") or "").strip(), + "task_type": str(row.get("task_type") or "").strip(), + } + + # ------------------------------------------------------------------ sampling + + def sample( + self, + tokenizer: TokenizerLike, + num_requests: int, + output_len: int | None = None, + request_id_prefix: str = "", + no_oversample: bool = False, + **kwargs, + ) -> list[SampleRequest]: + if output_len is None: + output_len = self.DEFAULT_OUTPUT_LEN + + sampled: list[SampleRequest] = [] + cached_tokenizer = get_cached_tokenizer(tokenizer) + # A cold frame cache costs a few seconds per video, so the whole set takes tens of + # minutes before the first request goes out; report progress to keep that legible. + started = time.monotonic() + last_report = started + try: + total = min(num_requests, len(self.data)) + except TypeError: # HF streaming datasets are not sized + total = num_requests + for seen, item in enumerate(self.data, start=1): + if len(sampled) >= num_requests: + break + req = self._create_sample_request( + self._coerce_row(item), cached_tokenizer, output_len, request_id_prefix, len(sampled) + ) + if req: + sampled.append(req) + now = time.monotonic() + if now - last_report >= _SAMPLE_PROGRESS_INTERVAL_S: + logger.info( + "Video-MME preparing requests: %d/%d rows, %d ready, %.0fs elapsed", + seen, + total, + len(sampled), + now - started, + ) + last_report = now + + logger.info("Created %d sample requests from Video-MME in %.0fs", len(sampled), time.monotonic() - started) + self.maybe_oversample_requests(sampled, num_requests, request_id_prefix, no_oversample) + return sampled + + def _create_sample_request( + self, + qa_item: dict[str, Any], + tokenizer: TokenizerLike, + output_len: int, + request_id_prefix: str, + index: int, + ) -> SampleRequest | None: + fields = self._normalize_fields(qa_item) + if not fields["video_id"] or not fields["question"]: + logger.warning("Skipping Video-MME item without videoID/question: %r", fields["video_id"]) + return None + + mm_payload, omni_extra = self._compose_multimodal(fields["video_id"]) + if not mm_payload: + return None + + user_text = self._build_user_prompt(fields) + return VideoMMESampleRequest( + prompt=user_text, + prompt_len=len(tokenizer.encode(user_text)), + expected_output_len=output_len, + multi_modal_data=None, + request_id=f"{request_id_prefix}{index}", + videomme_gold_answer=fields["answer"], + videomme_video_id=fields["video_id"], + videomme_question_id=fields["question_id"], + videomme_duration=fields["duration"], + videomme_domain=fields["domain"], + videomme_sub_category=fields["sub_category"], + videomme_task_type=fields["task_type"], + omni_extra_body=omni_extra, + omni_chat_messages=self._build_openai_messages(mm_payload, user_text), + omni_chat_mm_position="first", + ) + + # ------------------------------------------------------------------ prompt + + @staticmethod + def _format_options(options: list[str]) -> str: + """Match OmniEvalKit ``_build_options_prompt`` (``A. content`` per line). + + The Hub parquet already ships options as ``"A. content"``. Existing labels are + dropped before relabelling so the prompt never reads ``A. A. content``, but only + when *every* option carries one — otherwise contents such as ``A.M. shift`` would + be truncated. + """ + if not options: + return "" + matches = [_OPTION_PREFIX_RE.match(str(o).strip()) for o in options] + relabel = all(m is not None and m.group(1) == key for m, key in zip(matches, _OPTION_LETTERS)) + lines = [] + for key, raw, m in zip(_OPTION_LETTERS, options, matches): + content = m.group(2).strip() if (relabel and m is not None) else str(raw).strip() + lines.append(f"{key}. {content}") + return "\n".join(lines) + + def _build_user_prompt(self, fields: dict[str, Any]) -> str: + prompt = VIDEOMME_USER_PROMPT_TEMPLATE.format( + question=fields["question"], + options=self._format_options(fields["options"]), + ) + if self.use_subtitle: + subs = self._load_subtitle_text(fields["video_id"]) + if subs: + prompt = f"This video's subtitles are listed below:\n{subs}\n{prompt}" + return prompt + + def _load_subtitle_text(self, video_id: str) -> str: + if not self.subtitle_dir: + return "" + path = self.subtitle_dir / f"{video_id}.srt" + if not path.is_file(): + return "" + try: + raw = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return "" + lines = [ + s.replace("\\N", " ") + for s in (line.strip() for line in raw.splitlines()) + if s and not s.isdigit() and "-->" not in s + ] + return "\n".join(lines) + + def _build_openai_messages( + self, + mm_payload: dict[str, Any] | list[dict[str, Any]], + user_text: str, + ) -> list[dict[str, Any]]: + mm_list = mm_payload if isinstance(mm_payload, list) else [mm_payload] + messages: list[dict[str, Any]] = [] + if MINICPM_OMNI_SYSTEM_TEXT: + messages.append({"role": "system", "content": [{"type": "text", "text": MINICPM_OMNI_SYSTEM_TEXT}]}) + messages.append({"role": "user", "content": [*mm_list, {"type": "text", "text": user_text}]}) + return messages + + # ------------------------------------------------------------------ media + + def _video_path_index(self) -> dict[str, Path]: + """Lazily map ``videoID -> path`` so nested layouts resolve after the flat probe misses.""" + if self._video_index is None: + index: dict[str, Path] = {} + if self.video_dir is not None: + for path in _iter_video_files(self.video_dir): + index.setdefault(path.stem, path) + logger.info("Indexed %d Video-MME video files under %s", len(index), self.video_dir) + self._video_index = index + return self._video_index + + def _resolve_local_video_path(self, video_id: str) -> Path | None: + if not self.video_dir or not video_id: + return None + for p in ( + self.video_dir / f"{video_id}.mp4", + self.video_dir / f"{video_id}.MP4", + self.video_dir / f"{video_id}.mkv", + self.video_dir / video_id / f"{video_id}.mp4", + ): + if p.is_file(): + return p + return self._video_path_index().get(video_id) + + def _media_part(self, path: Path, *, typ: str, mime: str) -> dict[str, Any]: + path = path.expanduser().resolve() + if self.inline_local_video: + b64 = base64.b64encode(path.read_bytes()).decode("ascii") + return {"type": typ, typ: {"url": f"data:{mime};base64,{b64}"}} + return {"type": typ, typ: {"url": path.as_uri()}} + + def _compose_multimodal( + self, + video_id: str, + ) -> tuple[dict[str, Any] | list[dict[str, Any]] | None, dict[str, Any] | None]: + # OmniEvalKit ``minicpmo.py`` uses max_slice_nums=1 / use_image_id=False whenever the + # sample carries a video path: 9 slices per frame would blow past max_model_len and + # N would label each frame as a separate picture. + extra: dict[str, Any] = { + "mm_processor_kwargs": { + "use_audio_in_video": False, + "max_slice_nums": 1, + "use_image_id": False, + } + } + + if self.pack_mode == "video_url": + video_path = self._resolve_local_video_path(video_id) + if video_path is None: + logger.warning("Video-MME video not found for video_id=%r under %s", video_id, self.video_dir) + return None, None + return self._media_part(video_path, typ="video_url", mime="video/mp4"), extra + + parts = self._get_minicpm_frame_parts(video_id, include_audio=self.pack_mode == "minicpm-interleave") + if not parts: + return None, None + return parts, extra + + def _frame_cache_dir(self, video_id: str, *, include_audio: bool) -> Path: + assert self.video_dir is not None + tag = f"f{self.max_frames}{'_av' if include_audio else ''}" + return self.video_dir / ".minicpm_videomme_frames" / video_id / tag + + def _cached_parts_from_disk(self, cache_dir: Path, *, include_audio: bool) -> list[dict[str, Any]] | None: + """Rebuild content parts from a previous run's frame dump, or ``None`` on a miss.""" + manifest = cache_dir / "manifest.json" + if not manifest.is_file(): + return None + try: + count = int(json.loads(manifest.read_text(encoding="utf-8"))["count"]) + except (OSError, ValueError, KeyError, TypeError): + return None + + parts: list[dict[str, Any]] = [] + for i in range(count): + frame_path = cache_dir / f"frame_{i:04d}.jpg" + if not frame_path.is_file(): + return None + parts.append({"type": "image_url", "image_url": {"url": frame_path.as_uri()}}) + if include_audio: + audio_path = cache_dir / f"audio_{i:04d}.wav" + if not audio_path.is_file(): + return None + parts.append({"type": "audio_url", "audio_url": {"url": audio_path.as_uri()}}) + return parts or None + + def _get_minicpm_frame_parts( + self, + video_id: str, + *, + include_audio: bool, + ) -> list[dict[str, Any]] | None: + cache_key = f"{video_id}|audio={int(include_audio)}|frames={self.max_frames}" + cached = self._frame_cache.get(cache_key) + if cached is not None: + return cached + + # Inline base64 must not be persisted or retained: 900 videos would pin GBs of RSS. + cache_dir = None if self.inline_local_video else self._frame_cache_dir(video_id, include_audio=include_audio) + if cache_dir is not None: + disk_parts = self._cached_parts_from_disk(cache_dir, include_audio=include_audio) + if disk_parts is not None: + self._frame_cache[cache_key] = disk_parts + return disk_parts + + video_path = self._resolve_local_video_path(video_id) + if video_path is None: + logger.warning( + "Video-MME MiniCPM pack requires local video under video_dir=%s for video_id=%r", + self.video_dir, + video_id, + ) + return None + + try: + frames, audio_segments = self._extract_frames_and_audio( + video_path, + include_audio=include_audio, + max_num_frames=self.max_frames, + ) + except Exception: + logger.exception("Failed Video-MME frame extract for video_id=%r path=%s", video_id, video_path) + return None + + if not frames: + logger.warning("No frames extracted for video_id=%r", video_id) + return None + if include_audio and len(audio_segments) != len(frames): + n = min(len(frames), len(audio_segments)) + frames, audio_segments = frames[:n], audio_segments[:n] + + if cache_dir is not None: + cache_dir.mkdir(parents=True, exist_ok=True) + + parts: list[dict[str, Any]] = [] + for i, frame in enumerate(frames): + parts.append( + self._emit_media(cache_dir, f"frame_{i:04d}.jpg", _pil_to_jpeg_bytes(frame), "image_url", "image/jpeg") + ) + if include_audio: + parts.append( + self._emit_media( + cache_dir, + f"audio_{i:04d}.wav", + _numpy_to_wav_bytes(audio_segments[i]), + "audio_url", + "audio/wav", + ) + ) + + if cache_dir is not None: + (cache_dir / "manifest.json").write_text(json.dumps({"count": len(frames)}), encoding="utf-8") + self._frame_cache[cache_key] = parts + logger.debug("Video-MME packed video_id=%r frames=%d parts=%d", video_id, len(frames), len(parts)) + return parts + + @staticmethod + def _emit_media(cache_dir: Path | None, name: str, payload: bytes, typ: str, mime: str) -> dict[str, Any]: + """Write ``payload`` to the frame cache and return a file URL part, or inline base64.""" + if cache_dir is None: + b64 = base64.b64encode(payload).decode("ascii") + return {"type": typ, typ: {"url": f"data:{mime};base64,{b64}"}} + path = cache_dir / name + if not path.is_file() or path.stat().st_size == 0: + path.write_bytes(payload) + return {"type": typ, typ: {"url": path.as_uri()}} + + @staticmethod + def _sample_timestamps(duration: float, max_num_frames: int) -> list[float]: + """Port of OmniEvalKit ``_sample_video_frame_indices`` timestamps.""" + if duration > max_num_frames: + grid = [round(i * 0.1, 1) for i in range(int(duration / 0.1))] + return [grid[i] for i in _uniform_sample_indices(len(grid), max_num_frames)] + # OmniEvalKit uses int(duration); clamp to >=1 so sub-second clips still yield a frame. + return [float(i) for i in range(max(1, int(duration)))] + + @classmethod + def _extract_frames_and_audio( + cls, + video_path: Path, + *, + include_audio: bool, + max_num_frames: int, + ) -> tuple[list[Any], list[Any]]: + import numpy as np + from vllm.multimodal.media.audio import load_audio + + duration = _probe_video_duration(video_path) + timestamps = cls._sample_timestamps(duration, max_num_frames) + frames = _decode_frames_at_timestamps(video_path, timestamps) + + audio_segments: list[Any] = [] + if include_audio: + audio_np, sr = load_audio(str(video_path), sr=_MINICPM_AUDIO_SR, mono=True) + for i, start_time in enumerate(timestamps): + end_time = timestamps[i + 1] if i < len(timestamps) - 1 else duration + segment = audio_np[int(start_time * sr) : int(end_time * sr)] + if i == len(timestamps) - 1 and len(segment) < 1600: + segment = np.concatenate([segment, np.zeros(1600 - len(segment), dtype=segment.dtype)]) + if len(segment) == 0: + segment = np.zeros(1600, dtype=np.float32) + audio_segments.append(segment) + + return frames, audio_segments diff --git a/vllm_omni/benchmarks/data_modules/videomme_eval.py b/vllm_omni/benchmarks/data_modules/videomme_eval.py new file mode 100644 index 000000000..a3e98c07f --- /dev/null +++ b/vllm_omni/benchmarks/data_modules/videomme_eval.py @@ -0,0 +1,214 @@ +"""Video-MME multiple-choice accuracy scoring for vLLM-Omni bench serve. + +Answer extraction ports ``extract_characters_regex`` from the official Video-MME +script (MME-Benchmarks ``eval_your_results.py``, also used by lmms-eval). + +**Denominator:** responses where no A–D can be extracted are counted as wrong, which +matches lmms-eval and OmniEvalKit (the numbers this bench is compared against). The +official standalone script instead drops them from ``answered``; ``videomme_parse_failed`` +is reported so that view can be recovered. +""" + +from __future__ import annotations + +import re +from typing import Any + +from vllm.benchmarks.lib.endpoint_request_func import RequestFuncOutput + +from vllm_omni.benchmarks.data_modules.videomme_dataset import VideoMMESampleRequest + +_VALID = frozenset("ABCD") + +VIDEOMME_DURATION_KEYS: tuple[str, ...] = ("short", "medium", "long") + +#: Report dimension -> ``VideoMMESampleRequest`` attribute (official Video-MME breakdowns). +_DIMENSIONS: dict[str, str] = { + "duration": "videomme_duration", + "domain": "videomme_domain", + "sub_category": "videomme_sub_category", + "task": "videomme_task_type", +} + +# Official prefix list, with the two accidental string concatenations in the upstream +# tuple (missing commas after "The best option is" / "Best answer:") split apart. +_ANSWER_PREFIXES = ( + "The best answer is", + "The correct answer is", + "The answer is", + "The answer", + "The best option is", + "The correct option is", + "Best answer:", + "Best option:", + "Answer:", + "Option:", + "The correct answer", + "The correct option", +) + + +def extract_characters_regex(text: str | None) -> str | None: + """Port of official Video-MME / lmms-eval ``extract_characters_regex``.""" + if not text: + return None + s = str(text).strip() + for prefix in _ANSWER_PREFIXES: + s = s.replace(prefix, "") + if len(s.split()) > 10 and not re.search(r"[ABCD]", s): + return None + match = re.search(r"[ABCD]", s) + return match.group(0) if match else None + + +def normalize_gold_answer(gold: str) -> str | None: + g = (gold or "").strip().upper() + if len(g) == 1 and g in _VALID: + return g + m = re.search(r"([ABCD])\b", g) + return m.group(1).upper() if m else None + + +def _bucket(req: VideoMMESampleRequest, dimension: str) -> str: + value = (getattr(req, _DIMENSIONS[dimension]) or "").strip() + if dimension == "duration": + return value.lower() + return value or "unknown" + + +def compute_videomme_accuracy_metrics( + input_requests: list[Any], + outputs: list[RequestFuncOutput], + *, + include_per_item: bool = False, +) -> dict[str, Any] | None: + """If all requests are :class:`VideoMMESampleRequest`, compute accuracy stats. + + Rows without a gold answer are skipped (``no_gold``). Failed HTTP requests are + excluded from ``videomme_accuracy`` and counted in + ``videomme_accuracy_incl_http_fail`` instead. + """ + if not input_requests or len(input_requests) != len(outputs): + return None + if not all(isinstance(r, VideoMMESampleRequest) for r in input_requests): + return None + + stats: dict[str, dict[str, dict[str, int]]] = { + dim: ({k: {"correct": 0, "total": 0} for k in VIDEOMME_DURATION_KEYS} if dim == "duration" else {}) + for dim in _DIMENSIONS + } + items: list[dict[str, Any]] = [] + correct = evaluated = no_gold = request_failed = parse_failed = 0 + + for req, out in zip(input_requests, outputs, strict=True): + assert isinstance(req, VideoMMESampleRequest) + gold_raw = (req.videomme_gold_answer or "").strip() + buckets = {dim: _bucket(req, dim) for dim in _DIMENSIONS} + row: dict[str, Any] = { + "request_id": req.request_id, + "video_id": req.videomme_video_id, + "question_id": req.videomme_question_id, + **buckets, + } + + if not gold_raw: + no_gold += 1 + items.append({**row, "skipped": True, "reason": "no_gold"}) + continue + + evaluated += 1 + gold_norm = normalize_gold_answer(gold_raw) + pred = extract_characters_regex(out.generated_text) if out.success else None + is_correct = bool(pred) and pred.upper() == (gold_norm or gold_raw).upper() + + if not out.success: + request_failed += 1 + else: + if pred is None: + parse_failed += 1 + # Only successful responses enter the per-dimension denominators, mirroring + # the overall videomme_accuracy definition. + for dim, key in buckets.items(): + bucket = stats[dim].setdefault(key, {"correct": 0, "total": 0}) + bucket["total"] += 1 + bucket["correct"] += int(is_correct) + + if is_correct: + correct += 1 + + items.append( + { + **row, + "gold": gold_raw, + "gold_normalized": gold_norm, + "predicted": pred, + "correct": is_correct, + "parse_failed": out.success and pred is None, + **({"error": (out.error or "")[:500]} if not out.success else {}), + } + ) + + evaluated_ok = evaluated - request_failed + result: dict[str, Any] = { + "videomme_accuracy": (correct / evaluated_ok) if evaluated_ok else None, + "videomme_accuracy_incl_http_fail": (correct / evaluated) if evaluated else None, + "videomme_correct": correct, + "videomme_evaluated": evaluated, + "videomme_evaluated_ok": evaluated_ok, + "videomme_no_gold": no_gold, + "videomme_request_failed": request_failed, + "videomme_parse_failed": parse_failed, + } + for dim, per_bucket in stats.items(): + result[f"videomme_per_{dim}"] = {k: dict(v) for k, v in per_bucket.items()} + result[f"videomme_per_{dim}_accuracy"] = { + k: (v["correct"] / v["total"]) if v["total"] else None for k, v in per_bucket.items() + } + if include_per_item: + result["videomme_eval_items"] = items + return result + + +def print_videomme_accuracy_summary(metrics: dict[str, Any]) -> None: + """Pretty-print the Video-MME accuracy block (stdout).""" + acc = metrics.get("videomme_accuracy") + if acc is None and not metrics.get("videomme_evaluated", 0): + return + + print("{s:{c}^{n}}".format(s=" Video-MME accuracy (MCQ) ", n=50, c="=")) + ok = int(metrics.get("videomme_evaluated_ok", 0) or 0) + if ok and acc is not None: + print(f"Overall Accuracy: {metrics.get('videomme_correct', 0)}/{ok} = {acc:.2%}") + else: + print("Overall Accuracy: 0/0 = N/A (no successful HTTP responses)") + for label, key in ( + ("Submitted (gold present):", "videomme_evaluated"), + ("Successful HTTP (denominator):", "videomme_evaluated_ok"), + ("Correct:", "videomme_correct"), + ("Skipped (no gold):", "videomme_no_gold"), + ("HTTP failed:", "videomme_request_failed"), + ("Parsed OK but no A-D found:", "videomme_parse_failed"), + ): + print(f"{label:<40} {metrics.get(key, 0):<10}") + + for dim, title in ( + ("duration", "Duration"), + ("domain", "Domain"), + ("sub_category", "Sub Category"), + ("task", "Task Type"), + ): + per_acc = metrics.get(f"videomme_per_{dim}_accuracy") or {} + if not per_acc: + continue + counts = metrics.get(f"videomme_per_{dim}") or {} + names = VIDEOMME_DURATION_KEYS if dim == "duration" else sorted(per_acc) + print(f"\n--- Accuracy by {title} ---") + for name in names: + st = counts.get(name) or {} + total = int(st.get("total", 0) or 0) + value = per_acc.get(name) + if total and value is not None: + print(f"{name}: {int(st.get('correct', 0))}/{total} = {value:.2%}") + else: + print(f"{name}: 0/0 = N/A") + print("=" * 50) diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index ccedfb75e..6b18f1b48 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -56,6 +56,18 @@ from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( ) from vllm_omni.benchmarks.data_modules.sound_effect_dataset import SoundEffectDataset from vllm_omni.benchmarks.data_modules.ttsd_dataset import TTSDDataset +from vllm_omni.benchmarks.data_modules.videomme_dataset import ( + VIDEOMME_DEFAULT_HF_REPO, + VideoMMEDataset, + VideoMMESampleRequest, + ensure_videomme_subtitles_extracted, + ensure_videomme_videos_extracted, + resolve_videomme_local_root, + resolve_videomme_root, + videomme_local_parquet, + videomme_local_subtitle_dir, + videomme_local_video_dir, +) from vllm_omni.experimental.fullduplex.client import ( RealtimeDuplexClient, summarize_session_request_metrics, @@ -151,6 +163,7 @@ def _pcm_s16le_to_seed_tts_wer_bytes( get_samples_old = datasets.get_samples _DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni" +_DEFAULT_VIDEOMME_REPO = VIDEOMME_DEFAULT_HF_REPO def _seed_tts_capture_pcm_for_wer() -> bool: @@ -189,6 +202,17 @@ def _attach_daily_omni_to_request_func_input(sample: SampleRequest, rfi: Request setattr(rfi, "mm_position", sample.omni_chat_mm_position) +def _attach_videomme_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None: + """Apply per-request OpenAI fields for Video-MME (same message path as Daily-Omni).""" + if not isinstance(sample, VideoMMESampleRequest): + return + rfi.extra_body = _merge_extra_body_mm_kwargs(rfi.extra_body, sample.omni_extra_body) + if sample.omni_chat_messages is not None: + setattr(rfi, "omni_chat_messages", sample.omni_chat_messages) + else: + setattr(rfi, "mm_position", sample.omni_chat_mm_position) + + def _attach_seed_tts_to_request_func_input(sample: SampleRequest, rfi: RequestFuncInput) -> None: """Merge Seed-TTS per-row TTS fields into ``extra_body`` and mark for PCM capture. @@ -238,11 +262,27 @@ def _daily_omni_repo_from_args(args) -> str | None: return None +def _videomme_repo_from_args(args) -> str | None: + """Resolve HuggingFace repo id for Video-MME from CLI args.""" + dp = getattr(args, "dataset_path", None) + hn = getattr(args, "hf_name", None) + supported = {p.lower() for p in VideoMMEDataset.SUPPORTED_DATASET_PATHS} + supported.add(_DEFAULT_VIDEOMME_REPO.lower()) + if isinstance(dp, str) and dp.strip().lower() in supported: + return dp.strip() + if isinstance(hn, str) and hn.strip().lower() in supported: + return hn.strip() + return None + + def get_samples(args, tokenizer): # Daily-Omni: explicit dataset name, or hf + matching path/hf-name is_daily_omni = args.dataset_name == "daily-omni" or ( args.dataset_name == "hf" and _daily_omni_repo_from_args(args) is not None ) + is_videomme = args.dataset_name == "videomme" or ( + args.dataset_name == "hf" and _videomme_repo_from_args(args) is not None + ) is_seed_tts = args.dataset_name in ( "seed-tts", "seed-tts-text", @@ -258,7 +298,7 @@ def get_samples(args, tokenizer): "openai-realtime-duplex", "daily-omni", ] - is_omni_dataset = is_daily_omni or is_seed_tts or args.dataset_name == "random-mm" + is_omni_dataset = is_daily_omni or is_videomme or is_seed_tts or args.dataset_name == "random-mm" if not is_omni_backend and not is_omni_dataset: # Not an omni-related request, delegate to original implementation @@ -376,6 +416,113 @@ def get_samples(args, tokenizer): ) return input_requests + if is_videomme: + if args.backend not in ["openai-chat-omni", "daily-omni"]: + raise ValueError( + f"Video-MME dataset requires a multimodal backend that supports video. " + f"Got backend='{args.backend}'. Please use '--backend openai-chat-omni'" + ) + + video_dir = getattr(args, "videomme_video_dir", None) + subtitle_dir = getattr(args, "videomme_subtitle_dir", None) + parquet_path = getattr(args, "videomme_parquet", None) + if isinstance(parquet_path, str): + parquet_path = parquet_path.strip() or None + dataset_split = getattr(args, "hf_split", None) or "test" + + # Local directory wins (absolute/relative existing path). Hub ids fall through to + # snapshot_download — same pattern as Seed-TTS / Daily-Omni. + local_root = resolve_videomme_local_root(getattr(args, "dataset_path", None)) or ( + resolve_videomme_local_root(getattr(args, "hf_name", None)) + ) + repo_id = _videomme_repo_from_args(args) + if local_root is None and video_dir is None and parquet_path is None: + # Default Hub mode: download parquet + video zips on demand. + if args.dataset_name == "videomme": + hub_id = repo_id or _DEFAULT_VIDEOMME_REPO + elif repo_id is not None: + hub_id = repo_id + else: + raise ValueError( + "Video-MME requires --videomme-parquet, a local --dataset-path mirror, or " + f"--dataset-path / --hf-name {_DEFAULT_VIDEOMME_REPO}." + ) + local_root = resolve_videomme_root(hub_id) + repo_id = hub_id + logger.info("Using Hugging Face Video-MME snapshot: root=%s repo=%s", local_root, hub_id) + elif local_root is not None: + logger.info("Using local Video-MME mirror: root=%s", local_root) + + if local_root is not None: + if parquet_path is None: + local_pq = videomme_local_parquet(local_root) + if local_pq is not None: + parquet_path = str(local_pq) + if video_dir is None: + try: + video_dir = str(ensure_videomme_videos_extracted(local_root)) + except FileNotFoundError: + found = videomme_local_video_dir(local_root) + video_dir = str(found) if found is not None else None + if subtitle_dir is None: + found_sub = ensure_videomme_subtitles_extracted(local_root) or videomme_local_subtitle_dir(local_root) + subtitle_dir = str(found_sub) if found_sub is not None else None + + if parquet_path is None: + if args.dataset_name == "videomme": + repo_id = repo_id or (str(local_root) if local_root is not None else _DEFAULT_VIDEOMME_REPO) + elif repo_id is None and local_root is None: + raise ValueError( + "Video-MME requires --videomme-parquet, a local --dataset-path mirror, or " + f"--dataset-path / --hf-name {_DEFAULT_VIDEOMME_REPO}." + ) + + if video_dir is None: + raise ValueError( + "Video-MME requires --videomme-video-dir pointing at extracted videos " + "(directory of {videoID}.mp4), or a local/Hub dataset root containing video/ " + "or videos_chunked_*.zip." + ) + + logger.info( + "Loading Video-MME: parquet=%s, hf_repo=%s, video_dir=%s, pack_mode=%s", + parquet_path, + repo_id, + video_dir, + getattr(args, "videomme_pack_mode", "minicpm-frames"), + ) + dataset = VideoMMEDataset( + parquet_path=parquet_path, + dataset_path=None if parquet_path is not None else (repo_id or str(local_root)), + dataset_split=dataset_split, + dataset_subset=getattr(args, "hf_subset", None), + random_seed=args.seed, + video_dir=video_dir, + subtitle_dir=subtitle_dir, + pack_mode=getattr(args, "videomme_pack_mode", "minicpm-frames"), + max_frames=getattr(args, "videomme_max_frames", None), + duration_filter=getattr(args, "videomme_duration", "all"), + use_subtitle=getattr(args, "videomme_use_subtitle", False), + inline_local_video=getattr(args, "videomme_inline_local_video", False), + trust_remote_code=getattr(args, "trust_remote_code", False), + no_stream=getattr(args, "no_stream", False), + disable_shuffle=getattr(args, "disable_shuffle", False), + ) + + out_len = getattr(args, "output_len", None) + if out_len is None: + out_len = getattr(args, "hf_output_len", None) + if out_len is None: + out_len = VideoMMEDataset.DEFAULT_OUTPUT_LEN + + return dataset.sample( + tokenizer=tokenizer, + num_requests=args.num_prompts, + output_len=out_len, + request_id_prefix=args.request_id_prefix, + no_oversample=args.no_oversample, + ) + if is_seed_tts: if args.backend not in ( "openai-audio-speech", @@ -1645,6 +1792,7 @@ async def benchmark( chat_messages=test_chat_messages, ) _attach_daily_omni_to_request_func_input(input_requests[0], test_input) + _attach_videomme_to_request_func_input(input_requests[0], test_input) _attach_seed_tts_to_request_func_input(input_requests[0], test_input) if ready_check_timeout_sec > 0: @@ -1710,6 +1858,7 @@ async def benchmark( chat_messages=test_chat_messages, ) _attach_daily_omni_to_request_func_input(input_requests[0], profile_input) + _attach_videomme_to_request_func_input(input_requests[0], profile_input) _attach_seed_tts_to_request_func_input(input_requests[0], profile_input) profile_output = await request_func(request_func_input=profile_input, session=session) if profile_output.success: @@ -1795,6 +1944,7 @@ async def benchmark( chat_messages=request.chat_messages, ) _attach_daily_omni_to_request_func_input(request, request_func_input) + _attach_videomme_to_request_func_input(request, request_func_input) _attach_seed_tts_to_request_func_input(request, request_func_input) tasks.append( asyncio.create_task(limited_request_func(request_func_input=request_func_input, session=session, pbar=pbar)) @@ -1892,6 +2042,21 @@ async def benchmark( result.update(_daily_acc) print_daily_omni_accuracy_summary(_daily_acc) + from vllm_omni.benchmarks.data_modules.videomme_eval import ( + compute_videomme_accuracy_metrics, + print_videomme_accuracy_summary, + ) + + _save_vm = os.environ.get("VIDEOMME_SAVE_EVAL_ITEMS", "").lower() in ( + "1", + "true", + "yes", + ) + _vm_acc = compute_videomme_accuracy_metrics(input_requests, outputs, include_per_item=_save_vm) + if _vm_acc is not None: + result.update(_vm_acc) + print_videomme_accuracy_summary(_vm_acc) + if _seed_tts_capture_pcm_for_wer(): from vllm_omni.benchmarks.data_modules.seed_tts_eval import ( compute_seed_tts_wer_metrics, diff --git a/vllm_omni/benchmarks/serve.py b/vllm_omni/benchmarks/serve.py index 8e3b19ea9..5ed6fddbe 100644 --- a/vllm_omni/benchmarks/serve.py +++ b/vllm_omni/benchmarks/serve.py @@ -23,6 +23,8 @@ def main(args: argparse.Namespace) -> dict[str, Any]: os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1" if getattr(args, "daily_omni_save_eval_items", False): os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1" + if getattr(args, "videomme_save_eval_items", False): + os.environ["VIDEOMME_SAVE_EVAL_ITEMS"] = "1" set_print_stage(getattr(args, "print_stage", False)) args.extra_body = maybe_enable_stage_metrics( getattr(args, "extra_body", None), diff --git a/vllm_omni/deploy/minicpmo_4_5.yaml b/vllm_omni/deploy/minicpmo_4_5.yaml index fbc2853df..fb3f1b829 100644 --- a/vllm_omni/deploy/minicpmo_4_5.yaml +++ b/vllm_omni/deploy/minicpmo_4_5.yaml @@ -35,8 +35,9 @@ stages: fps: 1 num_frames: 128 limit_mm_per_prompt: - # Daily-Omni MiniCPM pack: up to 64 interleaved image/audio pairs (1fps). - image: 64 + # Daily-Omni: up to 64 interleaved image/audio pairs (1fps). + # Video-MME minicpm-frames: OmniEvalKit samples up to 96 frames as image_url. + image: 96 audio: 64 video: 1 default_sampling_params: diff --git a/vllm_omni/entrypoints/cli/benchmark/cli_args.py b/vllm_omni/entrypoints/cli/benchmark/cli_args.py index 80d0e685d..08fceba8e 100644 --- a/vllm_omni/entrypoints/cli/benchmark/cli_args.py +++ b/vllm_omni/entrypoints/cli/benchmark/cli_args.py @@ -117,6 +117,78 @@ def add_daily_omni_cli_args(parser: argparse.ArgumentParser) -> None: ) +def add_videomme_cli_args(parser: argparse.ArgumentParser) -> None: + """Add CLI arguments specific to the Video-MME dataset.""" + group = parser.add_argument_group("Video-MME Dataset Options") + group.add_argument( + "--videomme-parquet", + type=str, + default=None, + help="Path to local Video-MME parquet " + "(e.g. videomme/test-00000-of-00001.parquet). When set, Hub QA loading is skipped.", + ) + group.add_argument( + "--videomme-video-dir", + type=str, + default=None, + help="Directory containing extracted Video-MME videos (videoID.mp4). " + "Typical layout after unzipping videos_chunked_*.zip: /video/. " + "When using file:// URLs, start the server with --allowed-local-media-path " + "covering this directory.", + ) + group.add_argument( + "--videomme-subtitle-dir", + type=str, + default=None, + help="Directory containing Video-MME .srt subtitles (videoID.srt). Used only with --videomme-use-subtitle.", + ) + group.add_argument( + "--videomme-pack-mode", + type=str, + choices=["minicpm-frames", "minicpm-interleave", "video_url"], + default="minicpm-frames", + help="Multimodal packing. " + "'minicpm-frames' (default): OmniEvalKit MiniCPM videomme recipe — sampled frames " + "as image_url only (max_frames=96). " + "'minicpm-interleave': OmniEvalKit videomme_short recipe — 1fps frame/audio pairs " + "(max_frames=64). " + "'video_url': single video_url part (models with native video input).", + ) + group.add_argument( + "--videomme-max-frames", + type=int, + default=None, + help="Override max sampled frames (OmniEvalKit defaults: 96 for minicpm-frames, 64 for minicpm-interleave).", + ) + group.add_argument( + "--videomme-duration", + type=str, + choices=["all", "short", "medium", "long"], + default="all", + help="Filter by Video-MME duration bucket (default: all).", + ) + group.add_argument( + "--videomme-use-subtitle", + action="store_true", + default=False, + help="Prepend subtitle text to the user prompt (Video-MME w/ subs setting).", + ) + group.add_argument( + "--videomme-inline-local-video", + action="store_true", + default=False, + help="Embed local frames/audio as base64 data URLs so the server does not need " + "--allowed-local-media-path. Increases request size; use for small --num-prompts.", + ) + group.add_argument( + "--videomme-save-eval-items", + action="store_true", + default=False, + help="Include per-request Video-MME accuracy rows in the saved JSON under " + "videomme_eval_items. Or set env VIDEOMME_SAVE_EVAL_ITEMS=1.", + ) + + def add_seed_tts_cli_args(parser: argparse.ArgumentParser) -> None: """Add CLI arguments for Seed-TTS benchmarks.""" group = parser.add_argument_group("Seed-TTS Dataset Options") @@ -183,6 +255,7 @@ def add_seed_tts_cli_args(parser: argparse.ArgumentParser) -> None: _OMNI_BENCH_DATASET_CHOICES = ( "daily-omni", + "videomme", "seed-tts", "seed-tts-text", "seed-tts-design", @@ -249,6 +322,7 @@ def update_omni_help(parser: argparse.ArgumentParser) -> None: def add_omni_args(parser: argparse.ArgumentParser) -> None: """Register all vLLM-Omni serving benchmark arguments.""" add_daily_omni_cli_args(parser) + add_videomme_cli_args(parser) add_seed_tts_cli_args(parser) add_multi_stage_cli_args(parser) add_diffusion_cli_args(parser)