main
py 497 lines 15.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Train-consistent video preprocessing for Dots3Note."""
4
5 from __future__ import annotations
6
7 import hashlib
8 import io
9 import math
10 import os
11 import random
12 from dataclasses import dataclass
13 from typing import Literal
14
15 import numpy as np
16 from PIL import Image
17
18 _ALIGN = 28
19 _MIN_FRAMES = 4
20 _PF_FLOOR = 128
21 _PF_CEIL = 1024
22 _FPS_CAP = 1.0
23 _FPS_MIN = 0.2
24 _FRAME_OVERHEAD = 15
25 _BUDGET_OVERHEAD = 2240
26 _INTERLEAVE_MIN_SECONDS = 1.0
27 _AUDIO_SAMPLE_RATE = 16000
28 _AUDIO_SAMPLES_PER_TOKEN = 1280
29 _AUDIO_CHUNK_SECONDS = 30
30
31
32 @dataclass(frozen=True)
33 class Dots3NoteVideoPart:
34 kind: Literal["text", "image", "audio"]
35 value: str | Image.Image | np.ndarray
36
37
38 def _token_len(tokenizer, text: str) -> int:
39 if not text:
40 return 0
41 if hasattr(tokenizer, "encode"):
42 return len(tokenizer.encode(text, add_special_tokens=False))
43 return len(tokenizer(text, add_special_tokens=False)["input_ids"])
44
45
46 def _compute_target_size(
47 orig_h: int,
48 orig_w: int,
49 min_pixels: int,
50 max_pixels: int,
51 ) -> tuple[int, int]:
52 height = max(_ALIGN, round(orig_h / _ALIGN) * _ALIGN)
53 width = max(_ALIGN, round(orig_w / _ALIGN) * _ALIGN)
54 if height * width > max_pixels:
55 beta = math.sqrt(orig_h * orig_w / max_pixels)
56 height = max(_ALIGN, math.floor(orig_h / beta / _ALIGN) * _ALIGN)
57 width = max(_ALIGN, math.floor(orig_w / beta / _ALIGN) * _ALIGN)
58 elif height * width < min_pixels:
59 beta = math.sqrt(min_pixels / max(1, orig_h * orig_w))
60 height = math.ceil(orig_h * beta / _ALIGN) * _ALIGN
61 width = math.ceil(orig_w * beta / _ALIGN) * _ALIGN
62 if height * width > max_pixels:
63 beta = math.sqrt(height * width / max_pixels)
64 height = max(_ALIGN, math.floor(height / beta / _ALIGN) * _ALIGN)
65 width = max(_ALIGN, math.floor(width / beta / _ALIGN) * _ALIGN)
66 return int(height), int(width)
67
68
69 def _real_patches_at(
70 orig_h: int,
71 orig_w: int,
72 patch_cap: int,
73 ) -> int:
74 target_h, target_w = _compute_target_size(
75 orig_h,
76 orig_w,
77 _PF_FLOOR * _ALIGN * _ALIGN,
78 max(_PF_FLOOR, patch_cap) * _ALIGN * _ALIGN,
79 )
80 return (target_h // _ALIGN) * (target_w // _ALIGN)
81
82
83 def _frame_hard_cap(seq_length: int) -> int:
84 need = max(1, (seq_length - _BUDGET_OVERHEAD) // (_PF_FLOOR + 15))
85 if need <= 1024:
86 return 1024
87 cap = 1
88 while cap < need:
89 cap <<= 1
90 return cap
91
92
93 def _solve_degrade(
94 visual_budget: int,
95 duration: float,
96 orig_h: int,
97 orig_w: int,
98 orig_fps: float,
99 seq_length: int,
100 ) -> tuple[int, int]:
101 aligned_h = max(_ALIGN, round(orig_h / _ALIGN) * _ALIGN)
102 aligned_w = max(_ALIGN, round(orig_w / _ALIGN) * _ALIGN)
103 orig_max_pf = (aligned_h // _ALIGN) * (aligned_w // _ALIGN)
104 fps_cap = min(_FPS_CAP, max(orig_fps, 1e-6))
105 pf_cap = min(_PF_CEIL, max(orig_max_pf, _PF_FLOOR))
106 frame_cap = _frame_hard_cap(seq_length)
107
108 def usage(scale: float) -> tuple[int, float, int, int]:
109 fps = _FPS_MIN + scale * (fps_cap - _FPS_MIN)
110 patch_cap = _PF_FLOOR + scale * (pf_cap - _PF_FLOOR)
111 num_frames = max(
112 _MIN_FRAMES,
113 min(int(round(duration * fps)), frame_cap),
114 )
115 patches = _real_patches_at(
116 orig_h,
117 orig_w,
118 int(round(patch_cap)),
119 )
120 return (
121 num_frames * (patches + _FRAME_OVERHEAD),
122 fps,
123 int(round(patch_cap)),
124 num_frames,
125 )
126
127 if usage(1.0)[0] <= visual_budget:
128 _, _, patch_cap, num_frames = usage(1.0)
129 return num_frames, patch_cap
130
131 floor_cost = _real_patches_at(orig_h, orig_w, _PF_FLOOR) + _FRAME_OVERHEAD
132 if usage(0.0)[0] > visual_budget:
133 return max(
134 _MIN_FRAMES,
135 min(visual_budget // floor_cost, frame_cap),
136 ), _PF_FLOOR
137
138 low, high = 0.0, 1.0
139 for _ in range(50):
140 mid = (low + high) / 2
141 if usage(mid)[0] <= visual_budget:
142 low = mid
143 else:
144 high = mid
145 _, _, patch_cap, num_frames = usage(low)
146 return num_frames, patch_cap
147
148
149 def _audio_tokens(duration: float, sample_rate: int) -> int:
150 if duration <= 0:
151 return 0
152 total_samples = int(duration * sample_rate)
153 chunk_samples = _AUDIO_CHUNK_SECONDS * sample_rate
154 num_tokens = 0
155 position = 0
156 while position < total_samples:
157 length = min(chunk_samples, total_samples - position)
158 num_tokens += math.ceil(length / _AUDIO_SAMPLES_PER_TOKEN)
159 position += chunk_samples
160 return num_tokens + 2
161
162
163 def _decode_audio(
164 video_bytes: bytes,
165 sample_rate: int,
166 ) -> tuple[np.ndarray | None, float]:
167 from torchcodec.decoders import AudioDecoder
168
169 try:
170 decoder = AudioDecoder(
171 video_bytes,
172 sample_rate=sample_rate,
173 num_channels=1,
174 )
175 samples = decoder.get_all_samples()
176 except Exception:
177 return None, 0.0
178
179 waveform = samples.data
180 if waveform is None or waveform.numel() == 0:
181 return None, 0.0
182 if waveform.ndim == 2:
183 waveform = waveform.mean(dim=0) if waveform.shape[0] > 1 else waveform[0]
184 array = waveform.cpu().numpy()
185 pcm = (np.clip(array, -1.0, 1.0) * 32767.0).astype(np.int16)
186 return pcm, float(pcm.shape[0]) / sample_rate
187
188
189 def _open_video(video_bytes: bytes):
190 from torchcodec.decoders import VideoDecoder
191
192 try:
193 return VideoDecoder(
194 video_bytes,
195 dimension_order="NHWC",
196 num_ffmpeg_threads=1,
197 seek_mode="approximate",
198 )
199 except TypeError:
200 return VideoDecoder(
201 video_bytes,
202 dimension_order="NHWC",
203 num_ffmpeg_threads=1,
204 )
205
206
207 def _jpeg_roundtrip(image: Image.Image, quality: int) -> Image.Image:
208 buffer = io.BytesIO()
209 image.save(buffer, format="JPEG", quality=quality)
210 buffer.seek(0)
211 with Image.open(buffer) as decoded:
212 return decoded.convert("RGB").copy()
213
214
215 def _decode_frames(
216 video_bytes: bytes,
217 visual_budget: int,
218 seq_length: int,
219 jpeg_quality: int,
220 ) -> tuple[list[tuple[float, Image.Image]], float]:
221 decoder = _open_video(video_bytes)
222 metadata = decoder.metadata
223 duration = float(metadata.duration_seconds or 0)
224 orig_h = int(metadata.height)
225 orig_w = int(metadata.width)
226 total_frames = int(getattr(metadata, "num_frames", 0) or 0)
227 orig_fps = float(getattr(metadata, "average_fps", 0) or 0) or 25.0
228 if duration <= 0 or orig_h <= 0 or orig_w <= 0:
229 raise ValueError(
230 "Invalid video metadata: "
231 f"duration={duration}, height={orig_h}, width={orig_w}"
232 )
233 if total_frames <= 0:
234 total_frames = max(1, int(duration * orig_fps))
235
236 num_frames, patch_cap = _solve_degrade(
237 visual_budget,
238 duration,
239 orig_h,
240 orig_w,
241 orig_fps,
242 seq_length,
243 )
244 aligned_h = max(_ALIGN, round(orig_h / _ALIGN) * _ALIGN)
245 aligned_w = max(_ALIGN, round(orig_w / _ALIGN) * _ALIGN)
246 orig_max_pf = (aligned_h // _ALIGN) * (aligned_w // _ALIGN)
247 max_pixels = min(patch_cap, orig_max_pf) * _ALIGN * _ALIGN
248 target_h, target_w = _compute_target_size(
249 orig_h,
250 orig_w,
251 _PF_FLOOR * _ALIGN * _ALIGN,
252 max_pixels,
253 )
254 num_frames = max(_MIN_FRAMES, min(num_frames, total_frames))
255 if num_frames == 1:
256 indices = [0]
257 else:
258 step = (total_frames - 1) / (num_frames - 1)
259 indices = [int(round(i * step)) for i in range(num_frames)]
260 indices = sorted({max(0, min(index, total_frames - 1)) for index in indices})
261
262 try:
263 decoded = decoder.get_frames_at(indices=indices).data
264 except (IndexError, RuntimeError):
265 safe = list(indices)
266 while safe and safe[-1] > 0:
267 safe.pop()
268 try:
269 decoded = decoder.get_frames_at(indices=safe).data
270 indices = safe
271 break
272 except (IndexError, RuntimeError):
273 continue
274 else:
275 raise
276
277 frames: list[tuple[float, Image.Image]] = []
278 for index, frame in zip(indices, decoded):
279 array = frame.cpu().numpy() if hasattr(frame, "cpu") else np.asarray(frame)
280 image = Image.fromarray(array)
281 if image.size != (target_w, target_h):
282 image = image.resize((target_w, target_h), Image.Resampling.BICUBIC)
283 frames.append(
284 (round(index / orig_fps, 3), _jpeg_roundtrip(image, jpeg_quality))
285 )
286 return frames, duration
287
288
289 def _decoded_frames(video) -> tuple[np.ndarray, float]:
290 metadata = None
291 if isinstance(video, tuple):
292 video, metadata = video
293 frames = np.asarray(video)
294 if frames.ndim != 4 or frames.shape[-1] not in (3, 4):
295 raise TypeError(
296 "Decoded NOTE video must have shape (frames, height, width, channels)"
297 )
298 fps = float((metadata or {}).get("fps", 1.0)) if metadata else 1.0
299 return frames, max(fps, 1e-6)
300
301
302 def _prepare_decoded_frames(
303 video,
304 visual_budget: int,
305 seq_length: int,
306 jpeg_quality: int,
307 ) -> tuple[list[tuple[float, Image.Image]], float]:
308 frames, fps = _decoded_frames(video)
309 duration = len(frames) / fps
310 orig_h, orig_w = frames.shape[1:3]
311 num_frames, patch_cap = _solve_degrade(
312 visual_budget,
313 duration,
314 orig_h,
315 orig_w,
316 fps,
317 seq_length,
318 )
319 num_frames = min(max(1, num_frames), len(frames))
320 indices = np.linspace(0, len(frames) - 1, num_frames).round().astype(int)
321 target_h, target_w = _compute_target_size(
322 orig_h,
323 orig_w,
324 _PF_FLOOR * _ALIGN * _ALIGN,
325 patch_cap * _ALIGN * _ALIGN,
326 )
327 output = []
328 for index in sorted(set(indices.tolist())):
329 image = Image.fromarray(frames[index]).convert("RGB")
330 if image.size != (target_w, target_h):
331 image = image.resize((target_w, target_h), Image.Resampling.BICUBIC)
332 output.append((round(index / fps, 3), _jpeg_roundtrip(image, jpeg_quality)))
333 return output, duration
334
335
336 def _format_timestamp(seconds: float) -> str:
337 total_centiseconds = int(round(max(seconds, 0.0) * 100))
338 hours = total_centiseconds // 360000
339 minutes = (total_centiseconds // 6000) % 60
340 secs = (total_centiseconds // 100) % 60
341 centiseconds = total_centiseconds % 100
342 return f"{hours:02d}:{minutes:02d}:{secs:02d}.{centiseconds:02d}"
343
344
345 def _group_bounds(
346 num_frames: int,
347 duration: float,
348 mode: str,
349 rng: random.Random,
350 ) -> list[int]:
351 if num_frames <= 1 or duration <= 0:
352 return [0, num_frames]
353 max_groups = min(
354 num_frames,
355 max(1, int(duration // _INTERLEAVE_MIN_SECONDS)),
356 )
357 if mode == "whole" or max_groups <= 1:
358 groups = 1
359 elif mode == "eval30":
360 groups = round(math.sqrt(max_groups))
361 elif mode == "eval_ek":
362 groups = round((max_groups - 1) / math.log(max_groups))
363 elif mode == "logk":
364 groups = round(math.exp(rng.uniform(0.0, math.log(max_groups))))
365 else:
366 raise ValueError(f"Unsupported video k_mode: {mode}")
367 groups = max(1, min(max_groups, groups))
368 if groups == 1:
369 return [0, num_frames]
370 if mode == "logk":
371 cuts = sorted(rng.sample(range(1, num_frames), groups - 1))
372 else:
373 cuts = sorted(
374 {
375 round(index * num_frames / groups)
376 for index in range(1, groups)
377 if 0 < round(index * num_frames / groups) < num_frames
378 }
379 )
380 return [0, *cuts, num_frames]
381
382
383 def preprocess_dots3_note_video(
384 video,
385 *,
386 tokenizer,
387 question: str,
388 seq: int,
389 output_reserve: int | None = None,
390 audio_cap: float = 1.0,
391 audio_sample_rate: int = _AUDIO_SAMPLE_RATE,
392 k_mode: str = "eval_ek",
393 max_new_tokens: int = 0,
394 ) -> list[Dots3NoteVideoPart]:
395 """Expand one video into timestamped, interleaved image/audio parts."""
396 if seq <= 0:
397 raise ValueError(f"seq must be positive, got {seq}")
398 configured_reserve = seq // 4 if output_reserve is None else output_reserve
399 effective_reserve = max(configured_reserve, max_new_tokens)
400 if effective_reserve >= seq:
401 raise ValueError(
402 "output_reserve/max_new_tokens must leave room for video input"
403 )
404 if audio_cap < 0:
405 raise ValueError(f"audio_cap must be non-negative, got {audio_cap}")
406 if audio_sample_rate <= 0:
407 raise ValueError(f"audio_sample_rate must be positive, got {audio_sample_rate}")
408
409 seq_length = seq - effective_reserve
410 jpeg_quality = int(os.environ.get("XHS_VIDEO_JPEG_QUALITY", "85"))
411 pcm: np.ndarray | None = None
412 audio_duration = 0.0
413 if isinstance(video, bytes) and audio_cap > 0:
414 pcm, audio_duration = _decode_audio(video, audio_sample_rate)
415
416 audio_token_count = (
417 _audio_tokens(audio_duration, audio_sample_rate) if pcm is not None else 0
418 )
419 estimated_frames = max(1, int(audio_duration * _FPS_CAP))
420 max_groups = min(
421 estimated_frames,
422 max(1, int(audio_duration // _INTERLEAVE_MIN_SECONDS)),
423 )
424 reserved_audio_tokens = audio_token_count + 3 * max_groups
425 min_visual_tokens = _MIN_FRAMES * (_PF_FLOOR + _FRAME_OVERHEAD)
426 if (
427 audio_token_count > audio_cap * seq_length
428 or reserved_audio_tokens + min_visual_tokens + _BUDGET_OVERHEAD > seq_length
429 ):
430 pcm = None
431 audio_duration = 0.0
432 reserved_audio_tokens = 0
433
434 overhead = (
435 _token_len(
436 tokenizer,
437 "<|system|>You are a helpful assistant.<|endofsystem|>\n",
438 )
439 + 2
440 + _token_len(tokenizer, "<video_0>")
441 + 64
442 )
443 visual_budget = max(
444 _PF_FLOOR + _FRAME_OVERHEAD,
445 seq_length - overhead - reserved_audio_tokens,
446 )
447 if isinstance(video, bytes):
448 frames, video_duration = _decode_frames(
449 video,
450 visual_budget,
451 seq_length,
452 jpeg_quality,
453 )
454 else:
455 frames, video_duration = _prepare_decoded_frames(
456 video,
457 visual_budget,
458 seq_length,
459 jpeg_quality,
460 )
461
462 if pcm is None:
463 parts: list[Dots3NoteVideoPart] = []
464 for timestamp, image in frames:
465 parts.append(
466 Dots3NoteVideoPart("text", f"<{_format_timestamp(timestamp)}>")
467 )
468 parts.append(Dots3NoteVideoPart("image", image))
469 return parts
470
471 video_id = hashlib.sha1(video).hexdigest()
472 record_key = hashlib.sha1(f"{video_id}|{question}".encode()).hexdigest()
473 seed_hex = hashlib.sha1(f"42|flatten|{record_key}".encode()).hexdigest()
474 rng = random.Random(int(seed_hex[:8], 16))
475 bounds = _group_bounds(len(frames), audio_duration, k_mode, rng)
476 parts = []
477 for group in range(len(bounds) - 1):
478 start, end = bounds[group], bounds[group + 1]
479 if end <= start:
480 continue
481 start_time = 0.0 if group == 0 else frames[start][0]
482 end_time = audio_duration if group == len(bounds) - 2 else frames[end][0]
483 if end_time <= start_time:
484 end_time = start_time + audio_duration / max(1, len(bounds) - 1)
485 for timestamp, image in frames[start:end]:
486 parts.append(
487 Dots3NoteVideoPart("text", f"<{_format_timestamp(timestamp)}>")
488 )
489 parts.append(Dots3NoteVideoPart("image", image))
490 sample_start = max(0, int(round(start_time * audio_sample_rate)))
491 sample_end = min(len(pcm), int(round(end_time * audio_sample_rate)))
492 if sample_end > sample_start:
493 segment = np.ascontiguousarray(
494 pcm[sample_start:sample_end].astype(np.float32) / 32768.0
495 )
496 parts.append(Dots3NoteVideoPart("audio", segment))
497 return parts