File size: 11,095 Bytes
0185029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""Monkey-patch qwen_vl_utils to force the decord video backend.

Why this is needed
==================

* Evaluators call ``process_vision_info(..., return_video_metadata=True)``,
  which is the **new-API** form of ``qwen_vl_utils``. New-API
  ``fetch_video`` expects the underlying backend reader to return a
  3-tuple ``(video_tensor, video_metadata, sample_fps)``.

* Newer ``qwen_vl_utils`` (>= 0.0.10) hard-codes
  ``VIDEO_READER_BACKENDS["torchvision"](ele)`` inside ``fetch_video``, so
  ``FORCE_QWENVL_VIDEO_READER=decord`` no longer takes effect. The
  torchvision reader on some machines fails with
  ``KeyError: 'video_fps'`` because the mp4 metadata is incomplete.

* Older ``_read_video_decord`` (<= 0.0.8) returns a single tensor; if we
  just alias ``"torchvision" -> _read_video_decord``, the 3-value unpack
  in ``fetch_video`` blows up with ``RuntimeError: Error reading <path>``.

Strategy
========

Replace ``vp.fetch_video`` with our own version that uses ``decord``
directly and returns the **new-API 3-tuple** the rest of qwen_vl_utils
expects. This way both old- and new-API call sites work, and we never
have to ship a patched copy of qwen_vl_utils itself.

Disable the patch with ``DISABLE_QWENVL_DECORD_PATCH=1`` (e.g. when
debugging on a machine where torchvision works fine).
"""
from __future__ import annotations

import math
import os
import sys
from typing import Any


def _smart_nframes(ele: dict, *, total_frames: int, video_fps: float) -> int:
    """Mirror qwen_vl_utils.vision_process.smart_nframes: pick the number
    of frames to sample given fps / max_frames hints in the message dict.

    We re-implement instead of importing because qwen_vl_utils versions
    differ on the public name (``smart_nframes`` vs internal helper).
    """
    FPS = float(ele.get("fps", 2.0))
    MIN_FRAMES = int(ele.get("min_frames", 4))
    MAX_FRAMES = int(ele.get("max_frames", 768))
    nframes = ele.get("nframes")
    if nframes is not None:
        return max(1, min(int(nframes), total_frames))
    duration = total_frames / max(1e-6, video_fps)
    if FPS <= 0:
        FPS = 2.0
    n = int(round(duration * FPS))
    n = max(MIN_FRAMES, min(MAX_FRAMES, n))
    n = max(1, min(n, total_frames))
    return n


def _resolve_video_path(video_path: str) -> str:
    """If the input path doesn't exist, try a few fallbacks before giving up.

    Benchmark annotations join a base prefix with a relative media path, so a
    layout that nests media one level differently than the annotation expects
    (for example ``./Evaluation/got10k/<file>.mp4`` against an on-disk
    ``<base>/got10k/<file>.mp4``) would otherwise fail to decode.

    Resolution order:
      0. exact path (default)
      1. drop ``/Evaluation/`` -> retry
      2. drop leading ``Evaluation/`` from the raw component if EVAL_BASE_PREFIX_OVERRIDE
      3. take basename + walk under override prefix (slow last resort)
    """
    if os.path.isfile(video_path):
        return video_path

    candidates = []

    # (1) Drop the spurious "/Evaluation/" component anywhere in the path.
    if "/Evaluation/" in video_path:
        candidates.append(video_path.replace("/Evaluation/", "/", 1))

    # (2) Override-prefix-based rewrites.
    override = os.getenv("EVAL_BASE_PREFIX_OVERRIDE", "").rstrip("/")
    base_name = os.path.basename(video_path)
    parent = os.path.dirname(video_path)
    if override:
        rel = video_path
        for prefix in ("Evaluation/", "evaluation/", "./Evaluation/", "./"):
            if rel.startswith(prefix):
                rel = rel[len(prefix):]
                break
        candidates.append(os.path.join(override, rel))
        candidates.append(os.path.join(override, base_name))
        pdir = os.path.basename(parent)
        if pdir:
            candidates.append(os.path.join(override, pdir, base_name))

    for c in candidates:
        if os.path.isfile(c):
            print(f"[qwenvl_decord_patch] path-resolved {video_path!r} β†’ {c!r}",
                  file=sys.stderr)
            return c

    # (3) Last resort: walk under override (or the path's grandparent).
    walk_root = override or os.path.dirname(parent) or parent
    if walk_root and os.path.isdir(walk_root):
        for root, dirs, files in os.walk(walk_root):
            if base_name in files:
                hit = os.path.join(root, base_name)
                print(f"[qwenvl_decord_patch] walked-resolved {video_path!r} β†’ {hit!r}",
                      file=sys.stderr)
                return hit
            depth = root[len(walk_root):].count(os.sep)
            if depth >= 4:
                dirs.clear()

    return video_path


def _decord_fetch_video_new_api(ele: dict, *args, **kwargs):
    """Drop-in replacement for ``qwen_vl_utils.vision_process.fetch_video``.

    The exact signature of fetch_video varies across qwen_vl_utils
    versions, but we observe the following calling conventions in the
    wild:

      v0.0.8     : fetch_video(ele, image_factor)             β†’ tensor
      v0.0.10+   : fetch_video(ele, image_factor=…,
                                  return_video_sample_fps=True,
                                  return_video_metadata=False) β†’ (video, sample_fps)
      v0.0.10+   : fetch_video(ele, …, return_video_metadata=True) β†’ (video, metadata, sample_fps)

    We therefore inspect ``return_video_sample_fps`` /
    ``return_video_metadata`` kwargs and return whichever shape the
    caller asked for. The actual decode is always decord-based.
    """
    import torch
    import decord

    video_path = ele["video"]
    if isinstance(video_path, str) and video_path.startswith("file://"):
        video_path = video_path[len("file://"):]

    # Multi-tier fallback path resolution (see _resolve_video_path docstring)
    video_path = _resolve_video_path(video_path)

    if "video_start" in ele or "video_end" in ele:
        # Trim window. decord supports ranges via numeric indices, but no
        # evaluated benchmark sets these, so we keep it simple.
        raise NotImplementedError(
            "video_start/video_end not implemented in decord patch; "
            "the evaluated benchmarks do not need this."
        )

    vr = decord.VideoReader(video_path)
    total_frames = len(vr)
    video_fps = float(vr.get_avg_fps())
    if total_frames <= 0 or not math.isfinite(video_fps) or video_fps <= 0:
        raise RuntimeError(
            f"decord-patch: invalid video {video_path!r} "
            f"(total_frames={total_frames}, video_fps={video_fps})"
        )

    nframes = _smart_nframes(ele, total_frames=total_frames, video_fps=video_fps)
    idx = torch.linspace(0, total_frames - 1, nframes).round().long().tolist()
    arr = vr.get_batch(idx).asnumpy()  # (T, H, W, C)
    video = torch.from_numpy(arr).permute(0, 3, 1, 2).contiguous()  # (T, C, H, W)

    sample_fps = nframes / (total_frames / video_fps) if total_frames > 0 else float(video_fps)

    # metadata fields are chosen to be compatible with transformers'
    # VideoMetadata dataclass (qwen3-vl image processor instantiates this
    # from the dict we return). Field set across qwen2/qwen3-vl versions:
    #   fps, total_num_frames, duration, frames_indices, video_backend
    # We deliberately DO NOT include "video_fps" β€” that name causes
    # ``VideoMetadata.__init__() got an unexpected keyword argument 'video_fps'``
    # on newer transformers.
    metadata = {
        "fps": float(video_fps),
        "total_num_frames": int(total_frames),
        "duration": float(total_frames / video_fps),
        "frames_indices": [int(i) for i in idx],
        "video_backend": "decord",
    }

    # Decide return shape based on what the caller asked for.
    # NOTE on shapes: qwen_vl_utils versions disagree.
    #   * Some versions:   fetch_video(...) β†’ (video, metadata, sample_fps)
    #   * Other versions:  fetch_video(...) β†’ ((video, metadata), sample_fps)
    #                        (i.e. the wrapper bundles the metadata INSIDE the
    #                         video return so that process_vision_info can keep
    #                         a 2-tuple unpack.)
    # The pinned qwen_vl_utils release uses the 2-tuple-with-bundled-metadata
    # variant, so we default to that when both flags are set. Override with
    # QWENVL_RETURN_SHAPE=3tuple if your stack expects the 3-element form.
    want_metadata = bool(kwargs.get("return_video_metadata", False))
    want_sample_fps = bool(kwargs.get("return_video_sample_fps", False))
    return_shape = os.getenv("QWENVL_RETURN_SHAPE", "auto").strip().lower()

    if want_metadata and want_sample_fps:
        if return_shape == "3tuple":
            return video, metadata, float(sample_fps)
        # default "auto" / "bundled" β†’ 2-tuple form
        return (video, metadata), float(sample_fps)
    if want_metadata:
        return video, metadata
    if want_sample_fps:
        return video, float(sample_fps)
    return video


def _apply_patch() -> None:
    if os.getenv("DISABLE_QWENVL_DECORD_PATCH", "0") == "1":
        print("[qwenvl_decord_patch] disabled via env", file=sys.stderr)
        return

    try:
        from qwen_vl_utils import vision_process as vp
    except Exception as exc:  # pragma: no cover
        print(f"[qwenvl_decord_patch] qwen_vl_utils import failed: {exc}",
              file=sys.stderr)
        return

    # Make sure decord is importable up front so we fail loudly here, not
    # mid-eval, if it's not installed.
    try:
        import decord  # noqa: F401
    except Exception as exc:
        print(f"[qwenvl_decord_patch] decord not installed: {exc}",
              file=sys.stderr)
        print("                       Install with: pip install decord",
              file=sys.stderr)
        return

    # Belt: try the env-based switch (no-op on new versions).
    os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "decord")

    # Suspenders: replace fetch_video itself.
    if not hasattr(vp, "fetch_video"):
        print("[qwenvl_decord_patch] vp.fetch_video missing, abort",
              file=sys.stderr)
        return

    # Also alias the torchvision backend to our decord function in case any
    # downstream code re-reads VIDEO_READER_BACKENDS directly.
    backends = getattr(vp, "VIDEO_READER_BACKENDS", None)
    if isinstance(backends, dict):
        # Wrap to satisfy the SINGLE-tensor return convention used by some
        # internal callers (the older ``_read_video_torchvision`` returns
        # just a tensor in 0.0.8 path).
        def _single_tensor_decord(ele):
            v, _meta, _fps = _decord_fetch_video_new_api(ele)
            return v
        backends["torchvision"] = _single_tensor_decord

    vp.fetch_video = _decord_fetch_video_new_api

    print(
        f"[qwenvl_decord_patch] applied. "
        f"vp.fetch_video β†’ decord (return shape auto-adapts to "
        f"return_video_metadata / return_video_sample_fps kwargs; "
        f"override with QWENVL_RETURN_SHAPE=3tuple). "
        f"file={vp.__file__}",
        file=sys.stderr,
    )


_apply_patch()