Fix <|image_pad|> misalignment when images= and videos= coexist

#3
by kcz358 - opened
codec_video_processing_mage_vl.py CHANGED
@@ -27,6 +27,7 @@ from __future__ import annotations
27
  import hashlib
28
  import json
29
  import os
 
30
  import shutil
31
  import subprocess
32
  import sys
@@ -49,6 +50,13 @@ from PIL import Image
49
  VISION_START = "<|vision_start|>"
50
  VISION_END = "<|vision_end|>"
51
  IMAGE_PAD = "<|image_pad|>"
 
 
 
 
 
 
 
52
 
53
 
54
  # ----------------------------------------------------------------- config
@@ -222,13 +230,13 @@ def rewrite_text_with_codec_positions(
222
  for timestamp, token_count in _timestamp_runs(patch_positions, fps, decimals):
223
  parts.extend([timestamp, VISION_START, IMAGE_PAD * token_count, VISION_END, "\n"])
224
  vision_text = "".join(parts)
225
- first_vs, last_ve = text.find(VISION_START), text.rfind(VISION_END)
226
- if first_vs == -1 or last_ve == -1:
227
  return text
228
- tail_start = last_ve + len(VISION_END)
229
  if tail_start < len(text) and text[tail_start] == "\n":
230
  tail_start += 1
231
- return text[:first_vs] + vision_text + text[tail_start:]
232
 
233
 
234
  def drop_padding_canvases(
 
27
  import hashlib
28
  import json
29
  import os
30
+ import re
31
  import shutil
32
  import subprocess
33
  import sys
 
50
  VISION_START = "<|vision_start|>"
51
  VISION_END = "<|vision_end|>"
52
  IMAGE_PAD = "<|image_pad|>"
53
+ VIDEO_PAD = "<|video_pad|>"
54
+
55
+ # Matches exactly one chat-template video placeholder block, so that image
56
+ # blocks elsewhere in the prompt are left untouched.
57
+ _VIDEO_BLOCK_RE = re.compile(
58
+ re.escape(VISION_START) + r"\s*" + re.escape(VIDEO_PAD) + r"\s*" + re.escape(VISION_END)
59
+ )
60
 
61
 
62
  # ----------------------------------------------------------------- config
 
230
  for timestamp, token_count in _timestamp_runs(patch_positions, fps, decimals):
231
  parts.extend([timestamp, VISION_START, IMAGE_PAD * token_count, VISION_END, "\n"])
232
  vision_text = "".join(parts)
233
+ match = _VIDEO_BLOCK_RE.search(text)
234
+ if match is None:
235
  return text
236
+ tail_start = match.end()
237
  if tail_start < len(text) and text[tail_start] == "\n":
238
  tail_start += 1
239
+ return text[:match.start()] + vision_text + text[tail_start:]
240
 
241
 
242
  def drop_padding_canvases(
processing_mage_vl.py CHANGED
@@ -237,6 +237,68 @@ class MageVLProcessor:
237
 
238
  out: dict = {}
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  # ---------------- CODEC VIDEO BACKEND ----------------
241
  # Codec path: replaces the frame-sampling VideoProcessor entirely.
242
  # Each video -> N canvases + src_patch_position; we rewrite the
@@ -284,6 +346,7 @@ class MageVLProcessor:
284
  if len(rewritten_texts) != len(videos_list):
285
  if len(rewritten_texts) == 1 and len(videos_list) >= 1:
286
  rewritten_texts = rewritten_texts * len(videos_list)
 
287
  else:
288
  raise ValueError(
289
  f"codec video backend: got {len(rewritten_texts)} texts but {len(videos_list)} videos"
@@ -311,9 +374,7 @@ class MageVLProcessor:
311
  all_grid_thw.append(image_grid_thw)
312
  all_patch_positions.append(patch_positions)
313
 
314
- out["pixel_values"] = torch.cat(all_pixel_values, dim=0)
315
- out["image_grid_thw"] = torch.cat(all_grid_thw, dim=0)
316
- out["patch_positions"] = torch.cat(all_patch_positions, dim=0)
317
  text = rewritten_texts
318
  # Codec branch handled the video. Suppress the frame-sampling block below.
319
  videos = None
@@ -412,68 +473,40 @@ class MageVLProcessor:
412
  # image_grid_thw is the only adjustment needed for the model's
413
  # forward to treat each frame as a separate image (matching the
414
  # multi-image inference path).
415
- out["pixel_values"] = video_outputs["pixel_values_videos"]
416
  vgthw = video_outputs["video_grid_thw"]
417
- expanded_rows = []
 
 
418
  for row in vgthw:
419
  T_v, H_v, W_v = int(row[0]), int(row[1]), int(row[2])
420
- expanded_rows.extend([[1, H_v, W_v]] * T_v)
421
- out["image_grid_thw"] = torch.tensor(expanded_rows, dtype=vgthw.dtype)
422
- out["patch_positions"] = video_outputs["patch_positions"]
423
-
424
- # ---------------- IMAGE PATH ----------------
425
- if images is not None:
426
- if self.image_processor is None:
427
- raise ValueError("images passed but no image_processor configured.")
428
- image_outputs = self.image_processor(
429
- images=images, return_tensors="pt"
430
- )
431
- image_grid_thw = image_outputs["image_grid_thw"]
432
-
433
- # Expand each <|image_pad|> placeholder to the number of merged tokens.
434
- sms = self.spatial_merge_size
435
- merge_factor = sms * sms
436
- image_token_counts = (
437
- (image_grid_thw[:, 0] * image_grid_thw[:, 1] * image_grid_thw[:, 2])
438
- // merge_factor
439
- ).tolist()
440
- img_idx = 0
441
-
442
- def _expand_image_pads(s: str) -> str:
443
- nonlocal img_idx
444
- while IMAGE_PAD in s:
445
- if img_idx >= len(image_token_counts):
446
- break
447
- n = int(image_token_counts[img_idx])
448
- s = s.replace(IMAGE_PAD, "<|placeholder|>" * n, 1)
449
- img_idx += 1
450
- return s.replace("<|placeholder|>", IMAGE_PAD)
451
-
452
- text = [_expand_image_pads(s) for s in text]
453
-
454
- # If videos and images coexist, prefer concatenation of patch tensors.
455
- if "pixel_values" in out:
456
- out["pixel_values"] = torch.cat(
457
- [out["pixel_values"], image_outputs["pixel_values"]], dim=0
458
- )
459
- out["image_grid_thw"] = torch.cat(
460
- [out["image_grid_thw"], image_outputs["image_grid_thw"]], dim=0
461
- )
462
- # Build image patch_positions and concat.
463
- from .video_processing_mage_vl import build_patch_positions
464
- image_pp = build_patch_positions(
465
- image_outputs["image_grid_thw"], spatial_merge_size=sms
466
- )
467
- out["patch_positions"] = torch.cat(
468
- [out["patch_positions"], image_pp], dim=0
469
- )
470
  else:
471
- out["pixel_values"] = image_outputs["pixel_values"]
472
- out["image_grid_thw"] = image_outputs["image_grid_thw"]
473
- from .video_processing_mage_vl import build_patch_positions
474
- out["patch_positions"] = build_patch_positions(
475
- image_outputs["image_grid_thw"], spatial_merge_size=sms
476
- )
 
 
477
 
478
  # ---------------- VIDEO PATH FINAL EXPANSION ----------------
479
  # When `videos` was given (and possibly without `images`), the per-frame
 
237
 
238
  out: dict = {}
239
 
240
+ # ---------------- VISUAL PLACEHOLDER ORDER ----------------
241
+ # Record the prompt order of every visual placeholder *before* any
242
+ # rewriting, so the visual tensors can be concatenated in exactly the
243
+ # order the model consumes the <|image_pad|> slots.
244
+ _pad_re = re.compile(re.escape(IMAGE_PAD) + "|" + re.escape(VIDEO_PAD))
245
+ order_per_text = [
246
+ ["image" if m.group(0) == IMAGE_PAD else "video" for m in _pad_re.finditer(s)]
247
+ for s in text
248
+ ]
249
+
250
+ # Per-visual (pixel_values, image_grid_thw, patch_positions) triples,
251
+ # kept separate until the final prompt-order assembly.
252
+ image_slots: List[tuple] = []
253
+ video_slots: List[tuple] = []
254
+
255
+ # ---------------- IMAGE PATH ----------------
256
+ # Runs *before* the video paths: at this point every <|image_pad|> in
257
+ # `text` still belongs to a real image, because video placeholders are
258
+ # still <|video_pad|>. Running it afterwards would let the image
259
+ # expansion consume placeholders the video rewrite had just emitted.
260
+ if images is not None:
261
+ if self.image_processor is None:
262
+ raise ValueError("images passed but no image_processor configured.")
263
+ image_outputs = self.image_processor(images=images, return_tensors="pt")
264
+ image_grid_thw = image_outputs["image_grid_thw"]
265
+
266
+ # Expand each <|image_pad|> placeholder to the number of merged tokens.
267
+ sms = self.spatial_merge_size
268
+ merge_factor = sms * sms
269
+ image_token_counts = (
270
+ (image_grid_thw[:, 0] * image_grid_thw[:, 1] * image_grid_thw[:, 2])
271
+ // merge_factor
272
+ ).tolist()
273
+ img_idx = 0
274
+
275
+ def _expand_image_pads(s: str) -> str:
276
+ nonlocal img_idx
277
+ while IMAGE_PAD in s:
278
+ if img_idx >= len(image_token_counts):
279
+ break
280
+ n = int(image_token_counts[img_idx])
281
+ s = s.replace(IMAGE_PAD, "<|placeholder|>" * n, 1)
282
+ img_idx += 1
283
+ return s.replace("<|placeholder|>", IMAGE_PAD)
284
+
285
+ text = [_expand_image_pads(s) for s in text]
286
+
287
+ try:
288
+ from .video_processing_mage_vl import build_patch_positions
289
+ except ImportError:
290
+ from video_processing_mage_vl import build_patch_positions
291
+ image_pp = build_patch_positions(image_grid_thw, spatial_merge_size=sms)
292
+ offset = 0
293
+ for row in image_grid_thw:
294
+ n = int(row[0]) * int(row[1]) * int(row[2])
295
+ image_slots.append((
296
+ image_outputs["pixel_values"][offset: offset + n],
297
+ row.unsqueeze(0),
298
+ image_pp[offset: offset + n],
299
+ ))
300
+ offset += n
301
+
302
  # ---------------- CODEC VIDEO BACKEND ----------------
303
  # Codec path: replaces the frame-sampling VideoProcessor entirely.
304
  # Each video -> N canvases + src_patch_position; we rewrite the
 
346
  if len(rewritten_texts) != len(videos_list):
347
  if len(rewritten_texts) == 1 and len(videos_list) >= 1:
348
  rewritten_texts = rewritten_texts * len(videos_list)
349
+ order_per_text = order_per_text * len(videos_list)
350
  else:
351
  raise ValueError(
352
  f"codec video backend: got {len(rewritten_texts)} texts but {len(videos_list)} videos"
 
374
  all_grid_thw.append(image_grid_thw)
375
  all_patch_positions.append(patch_positions)
376
 
377
+ video_slots.extend(zip(all_pixel_values, all_grid_thw, all_patch_positions))
 
 
378
  text = rewritten_texts
379
  # Codec branch handled the video. Suppress the frame-sampling block below.
380
  videos = None
 
473
  # image_grid_thw is the only adjustment needed for the model's
474
  # forward to treat each frame as a separate image (matching the
475
  # multi-image inference path).
 
476
  vgthw = video_outputs["video_grid_thw"]
477
+ video_pv = video_outputs["pixel_values_videos"]
478
+ video_pp = video_outputs["patch_positions"]
479
+ offset = 0
480
  for row in vgthw:
481
  T_v, H_v, W_v = int(row[0]), int(row[1]), int(row[2])
482
+ n = T_v * H_v * W_v
483
+ video_slots.append((
484
+ video_pv[offset: offset + n],
485
+ torch.tensor([[1, H_v, W_v]] * T_v, dtype=vgthw.dtype),
486
+ video_pp[offset: offset + n],
487
+ ))
488
+ offset += n
489
+
490
+ # ---------------- ASSEMBLE VISUALS IN PROMPT ORDER ----------------
491
+ # The model consumes <|image_pad|> slots in prompt order and
492
+ # pixel_values in row order, so the visual tensors must be stacked in
493
+ # the order their placeholders appear in the prompt.
494
+ if image_slots or video_slots:
495
+ flat_order = [kind for per_text in order_per_text for kind in per_text]
496
+ n_img = sum(k == "image" for k in flat_order)
497
+ n_vid = sum(k == "video" for k in flat_order)
498
+ if n_img == len(image_slots) and n_vid == len(video_slots):
499
+ img_it, vid_it = iter(image_slots), iter(video_slots)
500
+ ordered = [next(img_it) if k == "image" else next(vid_it) for k in flat_order]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  else:
502
+ # Placeholder bookkeeping did not line up (e.g. a caller-supplied
503
+ # prompt that bypassed the chat template). Fall back to the legacy
504
+ # videos-then-images concatenation rather than failing hard.
505
+ ordered = list(video_slots) + list(image_slots)
506
+ out["pixel_values"] = torch.cat([s[0] for s in ordered], dim=0)
507
+ out["image_grid_thw"] = torch.cat([s[1] for s in ordered], dim=0)
508
+ out["patch_positions"] = torch.cat([s[2] for s in ordered], dim=0)
509
+
510
 
511
  # ---------------- VIDEO PATH FINAL EXPANSION ----------------
512
  # When `videos` was given (and possibly without `images`), the per-frame