[Benchmark][MiniCPM-o] Add Video-MME dataset support for Omni bench s… (#5868)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
271d2f51e2
commit
d7a22612db
12 files changed
+1809
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# <image_id>N</image_id> 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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: <root>/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)
|
||||
|
||||
Reference in new issue
Block a user