1008 lines
42 KiB
Python
1008 lines
42 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|||
|
|
|
|||
|
|
"""Video-MME dataset loader for vLLM-Omni bench serve.
|
|||
|
|
|
|||
|
|
Video-MME (lmms-eval/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 json
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
import tempfile
|
|||
|
|
import time
|
|||
|
|
import zipfile
|
|||
|
|
from collections.abc import Iterator
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Literal
|
|||
|
|
|
|||
|
|
import pybase64 as base64
|
|||
|
|
import regex as re
|
|||
|
|
|
|||
|
|
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-eval/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:
|
|||
|
|
revision = ref.read_text(encoding="utf-8").strip()
|
|||
|
|
except OSError:
|
|||
|
|
revision = ""
|
|||
|
|
# An empty or unreadable ref must not resolve to ``snapshots`` itself, which holds
|
|||
|
|
# no dataset files; fall through to the newest revision below.
|
|||
|
|
if revision and (snapshots / revision).is_dir():
|
|||
|
|
return snapshots / revision
|
|||
|
|
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 absolute_path(value: str | Path | None) -> Path | None:
|
|||
|
|
"""Return an absolute path, or ``None`` when ``value`` is empty."""
|
|||
|
|
if value is None:
|
|||
|
|
return None
|
|||
|
|
raw = str(value).strip()
|
|||
|
|
if not raw:
|
|||
|
|
return None
|
|||
|
|
return Path(raw).expanduser().resolve()
|
|||
|
|
|
|||
|
|
|
|||
|
|
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-eval/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 cached_videomme_snapshots() -> list[Path]:
|
|||
|
|
"""Usable Video-MME snapshots already in the Hub cache, whatever org published them.
|
|||
|
|
|
|||
|
|
The dataset is mirrored under several ids (``lmms-lab``/``lmms-eval``), so an offline
|
|||
|
|
run routinely requests one while a sibling is what actually sits on disk.
|
|||
|
|
"""
|
|||
|
|
from huggingface_hub import constants as hf_constants
|
|||
|
|
|
|||
|
|
cache = Path(hf_constants.HF_HUB_CACHE)
|
|||
|
|
if not cache.is_dir():
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
found: list[Path] = []
|
|||
|
|
for entry in sorted(cache.iterdir()):
|
|||
|
|
name = entry.name.lower()
|
|||
|
|
if not entry.is_dir() or not name.startswith("datasets--") or not name.endswith("--video-mme"):
|
|||
|
|
continue
|
|||
|
|
snapshot = _resolve_hf_cache_snapshot(entry)
|
|||
|
|
# A repo dir with no parquet is a partial download, not a usable root.
|
|||
|
|
if videomme_local_parquet(snapshot) is not None:
|
|||
|
|
found.append(snapshot)
|
|||
|
|
return found
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 ``hf_api().snapshot_download``; callers then extract videos via
|
|||
|
|
:func:`ensure_videomme_videos_extracted`.
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
ImportError: if Hugging Face Hub helpers are not installed.
|
|||
|
|
ValueError: if ``repo_id`` is empty.
|
|||
|
|
FileNotFoundError: if the snapshot is neither cached nor downloadable.
|
|||
|
|
"""
|
|||
|
|
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 constants as hf_constants
|
|||
|
|
from huggingface_hub.errors import (
|
|||
|
|
HfHubHTTPError,
|
|||
|
|
HFValidationError,
|
|||
|
|
LocalEntryNotFoundError,
|
|||
|
|
OfflineModeIsEnabled,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
from vllm_omni.transformers_utils.repo_utils import hf_api
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
offline = bool(hf_constants.HF_HUB_OFFLINE)
|
|||
|
|
try:
|
|||
|
|
# Pull QA parquet + video/subtitle archives; skip unrelated repo files.
|
|||
|
|
cache = hf_api().snapshot_download(
|
|||
|
|
repo_id=rid,
|
|||
|
|
repo_type="dataset",
|
|||
|
|
# Offline runs otherwise resolve repo metadata over the network first and
|
|||
|
|
# surface a connection failure instead of the missing cache entry.
|
|||
|
|
local_files_only=offline,
|
|||
|
|
allow_patterns=[
|
|||
|
|
"videomme/**",
|
|||
|
|
"*.parquet",
|
|||
|
|
"videos_chunked_*.zip",
|
|||
|
|
"subtitle.zip",
|
|||
|
|
"subtitle/**",
|
|||
|
|
"video/**",
|
|||
|
|
"videos/**",
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
# ``HfHubHTTPError`` also covers gated / missing repos; Video-MME requires accepting
|
|||
|
|
# the dataset terms, so a plain token-less run lands here rather than downloading.
|
|||
|
|
except (HFValidationError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e:
|
|||
|
|
if offline:
|
|||
|
|
reason = f"HF_HUB_OFFLINE is set and it is not cached under {hf_constants.HF_HUB_CACHE}"
|
|||
|
|
else:
|
|||
|
|
reason = f"snapshot_download failed with {type(e).__name__}"
|
|||
|
|
|
|||
|
|
hints = [
|
|||
|
|
"Point --dataset-path at a local Video-MME root (a directory holding "
|
|||
|
|
"videomme/test-*.parquet plus video/ or videos_chunked_*.zip), or pass "
|
|||
|
|
"--videomme-parquet / --videomme-video-dir."
|
|||
|
|
]
|
|||
|
|
cached = cached_videomme_snapshots()
|
|||
|
|
if cached:
|
|||
|
|
hints.append(
|
|||
|
|
"Video-MME is already cached under another repo id: " + ", ".join(str(p) for p in cached) + "."
|
|||
|
|
)
|
|||
|
|
if offline:
|
|||
|
|
hints.append("Unset HF_HUB_OFFLINE to download it instead.")
|
|||
|
|
|
|||
|
|
raise FileNotFoundError(
|
|||
|
|
f"Video-MME is unavailable from Hugging Face repo '{rid}': {reason}. " + " ".join(hints)
|
|||
|
|
) from e
|
|||
|
|
|
|||
|
|
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-eval/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 == zf.getinfo(member).file_size:
|
|||
|
|
return
|
|||
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
# Publish only after ZipFile has read and checked the complete member.
|
|||
|
|
with tempfile.TemporaryDirectory(prefix=".videomme-", dir=dest_dir) as temporary:
|
|||
|
|
temporary_path = Path(temporary) / name
|
|||
|
|
with zf.open(member) as src, temporary_path.open("wb") as out:
|
|||
|
|
shutil.copyfileobj(src, out)
|
|||
|
|
temporary_path.replace(dest)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
if next(_iter_video_files(video_dir), None) is None:
|
|||
|
|
# Marking an empty tree as extracted would make every later request log
|
|||
|
|
# "video not found" and yield a run with zero samples.
|
|||
|
|
raise FileNotFoundError(f"No Video-MME videos under {root}: expected video/*.mp4 or videos_chunked_*.zip")
|
|||
|
|
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 = ""
|
|||
|
|
videomme_skipped_rows: int = 0
|
|||
|
|
omni_extra_body: dict[str, Any] | None = None
|
|||
|
|
omni_chat_messages: list[dict[str, Any]] | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
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.")
|
|||
|
|
if pack_mode == "minicpm-interleave" and duration_filter != "short":
|
|||
|
|
logger.warning(
|
|||
|
|
"Video-MME 'minicpm-interleave' ports the OmniEvalKit videomme_short recipe. Beyond the "
|
|||
|
|
"short bucket, frames are spread across the whole clip and each paired audio segment covers "
|
|||
|
|
"the gap between them (tens of seconds on medium/long videos), which inflates the audio "
|
|||
|
|
"token count far past the 1s pairing the recipe assumes. Pass --videomme-duration short."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
self.parquet_path = absolute_path(parquet_path)
|
|||
|
|
self.dataset_path = dataset_path
|
|||
|
|
self.dataset_split = dataset_split
|
|||
|
|
self.dataset_subset = dataset_subset
|
|||
|
|
self._hf_streaming = not no_stream
|
|||
|
|
self.video_dir = absolute_path(video_dir)
|
|||
|
|
self.subtitle_dir = absolute_path(subtitle_dir)
|
|||
|
|
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)
|
|||
|
|
)
|
|||
|
|
if self.max_frames <= 0:
|
|||
|
|
raise ValueError("Video-MME max_frames must be positive")
|
|||
|
|
#: 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,
|
|||
|
|
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:
|
|||
|
|
"""Resolve local/Hub assets once, shared by the CLI and direct callers."""
|
|||
|
|
local_root = resolve_videomme_local_root(self.dataset_path)
|
|||
|
|
if local_root is None and self.parquet_path is None and self.video_dir is None:
|
|||
|
|
local_root = resolve_videomme_root(self.dataset_path)
|
|||
|
|
|
|||
|
|
if local_root is not None:
|
|||
|
|
if self.parquet_path is None:
|
|||
|
|
self.parquet_path = videomme_local_parquet(local_root)
|
|||
|
|
if self.parquet_path is None:
|
|||
|
|
raise FileNotFoundError(f"No Video-MME parquet under {local_root}; pass --videomme-parquet.")
|
|||
|
|
if self.video_dir is None:
|
|||
|
|
self.video_dir = ensure_videomme_videos_extracted(local_root)
|
|||
|
|
if self.use_subtitle and self.subtitle_dir is None:
|
|||
|
|
self.subtitle_dir = ensure_videomme_subtitles_extracted(local_root)
|
|||
|
|
|
|||
|
|
if self.video_dir is None or not self.video_dir.is_dir():
|
|||
|
|
raise ValueError(
|
|||
|
|
"Video-MME requires --videomme-video-dir pointing at extracted videos, "
|
|||
|
|
"or a local/Hub dataset root containing video/ or videos_chunked_*.zip."
|
|||
|
|
)
|
|||
|
|
if self.parquet_path is not None:
|
|||
|
|
self._load_from_parquet(self.parquet_path)
|
|||
|
|
else:
|
|||
|
|
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(df.to_dict(orient="records"))
|
|||
|
|
|
|||
|
|
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(list(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 _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 value is not None and 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
|
|||
|
|
total = min(num_requests, len(self.data))
|
|||
|
|
skipped_rows = 0
|
|||
|
|
seen = 0
|
|||
|
|
for seen, item in enumerate(self.data, start=1): # noqa: B007
|
|||
|
|
if len(sampled) >= num_requests:
|
|||
|
|
break
|
|||
|
|
req = self._create_sample_request(item, cached_tokenizer, output_len, request_id_prefix, len(sampled))
|
|||
|
|
if req is not None:
|
|||
|
|
sampled.append(req)
|
|||
|
|
else:
|
|||
|
|
skipped_rows += 1
|
|||
|
|
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)
|
|||
|
|
if not sampled and num_requests > 0:
|
|||
|
|
# Oversampling an empty list raises a bare IndexError; the usual cause is a
|
|||
|
|
# video_dir that holds none of the videoIDs referenced by the QA rows.
|
|||
|
|
raise ValueError(
|
|||
|
|
f"Video-MME produced no requests from {seen} QA rows "
|
|||
|
|
f"(duration_filter={self.duration_filter!r}, video_dir={self.video_dir}). "
|
|||
|
|
"Check --videomme-duration and that --videomme-video-dir holds the videoIDs the rows reference."
|
|||
|
|
)
|
|||
|
|
for request in sampled:
|
|||
|
|
assert isinstance(request, VideoMMESampleRequest)
|
|||
|
|
request.videomme_skipped_rows = skipped_rows
|
|||
|
|
self.maybe_oversample_requests(sampled, num_requests, request_id_prefix, no_oversample)
|
|||
|
|
unique_questions = {getattr(req, "videomme_question_id", "") or req.request_id for req in sampled}
|
|||
|
|
if len(sampled) > len(unique_questions):
|
|||
|
|
logger.warning(
|
|||
|
|
"Video-MME submitted %d requests covering %d unique questions "
|
|||
|
|
"(missing media is skipped, then the remainder may be reused). "
|
|||
|
|
"Submitted request count is not unique-question coverage.",
|
|||
|
|
len(sampled),
|
|||
|
|
len(unique_questions),
|
|||
|
|
)
|
|||
|
|
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),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------ 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 | None:
|
|||
|
|
"""Where the on-disk frame dump lives, or ``None`` when there is no ``video_dir`` to host it."""
|
|||
|
|
if self.video_dir is None:
|
|||
|
|
return 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() or frame_path.stat().st_size == 0:
|
|||
|
|
return None
|
|||
|
|
parts.append({"type": "image_url", "image_url": {"url": frame_path.expanduser().resolve().as_uri()}})
|
|||
|
|
if include_audio:
|
|||
|
|
audio_path = cache_dir / f"audio_{i:04d}.wav"
|
|||
|
|
if not audio_path.is_file() or audio_path.stat().st_size == 0:
|
|||
|
|
return None
|
|||
|
|
parts.append({"type": "audio_url", "audio_url": {"url": audio_path.expanduser().resolve().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)
|
|||
|
|
# A failed rebuild must not leave a manifest advertising a complete cache.
|
|||
|
|
(cache_dir / "manifest.json").unlink(missing_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).expanduser().resolve()
|
|||
|
|
# Reaching this path means the cache missed validation. Replace residual
|
|||
|
|
# files as well: a nonempty file may be from an interrupted write.
|
|||
|
|
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:
|
|||
|
|
return [round(i * 0.1, 1) for i in _uniform_sample_indices(int(duration / 0.1), 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
|