kcz358 commited on
Commit
1fe085e
·
verified ·
1 Parent(s): 5c78cab

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

Browse files

Fixes microsoft/Mage#28.

Two independent defects made a single processor(images=..., videos=...) call
produce visual tensors that do not line up with the prompt placeholders:

1. codec_video_processing_mage_vl.rewrite_text_with_codec_positions used
text.find(VISION_START) / text.rfind(VISION_END), which spans from the
first vision block to the last. Any image block sitting between them was
wiped out along with the video block, so the images lost their
placeholders entirely and generate() raised
'Image features and image tokens do not match'. Now matches exactly one
<|vision_start|><|video_pad|><|vision_end|> block, mirroring what the
frames backend already did.

2. processing_mage_vl.__call__ ran the IMAGE PATH after the video branches
had already rewritten the video block into literal <|image_pad|> runs.
_expand_image_pads restarts from the start of the string on every
replace, so it consumed the video's placeholders. The tensor
concatenation was also unconditionally video-rows-then-image-rows, which
is only correct when the video precedes every image in the prompt. The
image path now runs first (while video placeholders are still
<|video_pad|>), both video branches emit per-visual slots, and the slots
are concatenated in recorded prompt order.

Reordering rows is safe for the vision tower: _build_cu_seqlens blocks
strictly per image_grid_thw row, so image and video rows never attend to
each other regardless of position.

Verification
------------
Single-modality paths are byte-identical to the stock processor
(torch.equal on input_ids / pixel_values / image_grid_thw / patch_positions):
codec video-only, frames video-only, 1 image, 2 images -> all True

Mixed images + video, stock vs patched:
codec [image, video] 3775/3776 ValueError -> 3776/3776 match
codec [video, image] 3775/3776 ValueError -> 3776/3776 match
codec [image, image, video] 5822/5824 ValueError -> 5824/5824 match
codec [video, image, image] 5822/5824 ValueError -> 5824/5824 match
frames all four orders totals matched by coincidence but the k-th
placeholder run was paired with the wrong
tensor rows; runs == grid rows is now True
element-by-element

Generation with unmodified weights, examples/dog.jpg + soccer-broadcast.mp4:
codec [video, image] 'A dog is in the reference photo, and the video shows
a sports broadcast with four commentators...'
frames [video, image] 'A dog is in the reference photo, and the video shows
a football match between England and Argentina.'

Not addressed: the codec branch still replicates one text per video, so
multiple videos in a single prompt remains unsupported, as before.

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