Add end-to-end video input to the base Gemma 4 (gemma4) VLM#391
Add end-to-end video input to the base Gemma 4 (gemma4) VLM#391fdagostino wants to merge 2 commits into
Conversation
|
fix merge conflict |
| for video in videos { | ||
| let sequence = try await MediaProcessing.asProcessedSequence( | ||
| video, targetFPS: { _ in 1.0 }, maxFrames: config.videoMaxFrames | ||
| ) { frame in |
There was a problem hiding this comment.
Not a blocker, but this samples 1 frame per second from the video, up to the maxFrames (default is 32). If the video is longer, this will just take the prefix. mlx-vlm samples uniformly across the video.
I think the fix is just doing the arithmetic in targetFPS to match the frames desired vs the length. It looks like all the VLMs do something similar (fixed FPS), so I think we need something at the MediaProcessing level to assist with this and we can do all the models at once.
davidkoski
left a comment
There was a problem hiding this comment.
Changes look good to me. Thank you!
|
Looks like this needs swift-format run. |
Gemma 4 has no separate video encoder: video frames run through the same vision tower as images and scatter onto the <video> soft-token positions. The base `Gemma4` class was text + vision only — it never decoded `video_token_id`, `prepare` passed `videoTokenId: nil`, and `getInputEmbeddings` handled only images — even though the checkpoint config declares a video token (258884 for E4B) and `Gemma4Unified` already supports video the same way. - `Gemma4Configuration` decodes `video_token_id` (nil for older text+vision checkpoints, so their behavior is unchanged). - `getInputEmbeddings` accepts `videoPixelValues`; the image and video paths share a new `scatterVisionFeatures` helper, and the per-layer-input mask now excludes video soft tokens alongside image/audio. - `prepare` routes `input.video?.pixels` and passes the real `videoTokenId` to `gemma4TokenTypeIds`. - No weight/sanitize change — video reuses the vision tower. Adds Gemma4VideoInputTests: config decode + an image/video equivalence test (same pixels via image vs video produce identical logits). The existing Gemma4ChunkedPrefillTests still pass, confirming the shared-helper refactor leaves the text/image path unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes video input for the base `gemma4` VLM. A previous revision let the model accept `videoPixelValues`, but `Gemma4Processor.prepare` never populated `LMInput.video`, so a `UserInput` with video was silently dropped. Model: - `Gemma4Configuration` decodes `vision_soft_tokens_per_video_frame` (default 70). - A new `scatterVideoFeatures` runs each frame through the shared vision tower (image budget) and keeps the first `visionSoftTokensPerVideoFrame` tokens, then scatters `frames * 70` tokens onto `video_token_id`. Mirrors VincentGourbin's Swift port (pool-to-image-budget then truncate) and mlx-vlm's pooling. Processor: - `Gemma4ProcessorConfiguration` decodes `video_token_id` (258884), `video_soft_tokens_per_frame` (70), `video_max_frames` (32), and exposes a 432x432 `videoFixedSize` (multiple of patch_size * pooling_kernel_size). - `Gemma4Processor.processVideos` samples <=32 frames at ~1 fps via `MediaProcessing.asProcessedSequence` (.frames/.url/.avAsset), preprocesses each to the video size, and stacks them into `[totalFrames, C, H, W]`. - `Gemma4Processor.prepare` processes `input.videos`, sets `LMInput.video`, and expands each `<video>` placeholder into `BOI + video_token*70 + EOI` per frame. Tests (Gemma4VideoInputTests): config decode, image/video equivalence, multi-frame truncation, and processor frame extraction ([F, C, 432, 432]). Gemma4ChunkedPrefillTests and Gemma4UnifiedTests still pass. Follow-ups: per-frame `mm:ss` timestamp text, and the encoder-free `Gemma4Unified` processor video block. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dfb81af to
e67e7c3
Compare
|
Rebased on main and ran swift-format — the conflict was semantic (this PR predated the aspect-preserving image path from #405), so the resolution keeps #405's per-image slicing for stills untouched and routes video frames through the per-frame-budget scatter only. Also adapted the tiny-config tests to the new tower geometry (patch-count tokens) and dropped a helper #405 made redundant. 259 tests pass locally with the CI invocation. |
Summary
Add end-to-end video input to the base Gemma 4 VLM (
model_type: "gemma4"— E2B/E4B). Gemma 4 has no separate video encoder: sampled frames run through the same vision tower as images, are trimmed to a smaller per-frame budget, and scatter onto the<video>soft-token positions. This wires both halves — the model forward and theGemma4Processor— so aUserInputcarrying video actually reaches the model (previously it was silently dropped).What was missing
An earlier revision made the
Gemma4model acceptvideoPixelValuesbut nothing produced them:Gemma4Processor.prepareignoredinput.videos, soLMInput.videowas never populated. This PR completes the pipeline.Changes (
Libraries/MLXVLM/Models/Gemma4.swift)Model side
Gemma4Configurationdecodesvision_soft_tokens_per_video_frame(default 70).getInputEmbeddingsroutes video through a newscatterVideoFeatureshelper: each frame goes through the shared vision tower (producingvisionSoftTokensPerImagepooled tokens), the firstvisionSoftTokensPerVideoFrame(70) are kept, and the resultingframes × 70tokens scatter ontovideo_token_id. This mirrors the reference Swift port (VincentGourbin/gemma-4-swift-mlx, which pools to the image budget then truncates) and the pooling behaviour ofBlaizzy/mlx-vlm.Processor side
Gemma4ProcessorConfigurationdecodesvideo_token_id(258884),video_soft_tokens_per_frame(70),video_max_frames(32), and exposes avideoFixedSize(432×432 — a multiple ofpatch_size · pooling_kernel_size = 48, giving ~81 patch-pooled tokens that trim to 70 so the kept tokens cover the frame).Gemma4Processor.processVideossamples ≤32 frames at ~1 fps viaMediaProcessing.asProcessedSequence(handles.frames/.url/.avAsset), preprocesses each frame to the video size (sRGB → bicubic resize → normalize), and stacks them into[totalFrames, C, H, W].Gemma4Processor.preparenow processesinput.videos, setsLMInput.video, and expands each<video>placeholder into oneBOI + video_token×70 + EOIblock per sampled frame (per-video frame counts drive the expansion).Tests (
Tests/MLXLMTests/Gemma4VideoInputTests.swift)video_token_id/vision_soft_tokens_per_video_framedecode with the right defaults.video_token_id).processVideoson synthetic frames yields[F, C, 432, 432]with per-video counts summing to the frame axis.Gemma4ChunkedPrefillTestsandGemma4UnifiedTestsstill pass. End-to-end decode from a real clip is exercised on-device by the downstream app (the same way image and audio were validated on iPad).Scope / follow-ups
mm:ssmarker inside each block; this PR emits the frame blocks without the timestamp text (temporal grounding refinement — easy follow-up).Gemma4Unified(12B) processor still lacks a video block. That path is encoder-free (per-frame patch tensors + position ids, a different mechanism than the E-model vision tower), so it's tracked separately; the basegemma4E2B/E4B path (what most on-device use targets) is complete here.Relation to #390 / #392
Independent of #390 (KV-shared load fix) but the
gemma4E4B model won't load without it. The audio PR #392 vendors the video model-level change and will need to pick up these processor changes on rebase.🤖 Generated with Claude Code