Repository navigation
[V-JEPA2][Bug fix] Resize video frames on the uint8 grid like HF's processor - #697
Conversation
e82cc28 to
cb97ba6
Compare
| test/modular/test_image_load_rgb.py | ||
| test/modular/test_request_media_totals.py | ||
| test/modular/test_wan_vae_compile_options.py | ||
| test/modular/vjepa2/test_preprocess.py |
There was a problem hiding this comment.
[P2] test/cpu-core.txt has been deleted on main, so this hunk is a modify/delete conflict. Resolving it by keeping the file breaks CI.
On current main, CPU Core collects every module under CPU_TEST_PATHS (scripts/ci/select_cpu_tests.py), so test/modular/vjepa2/test_preprocess.py runs without any manifest entry. If the rebase keeps the file, test/ci/test_cpu_selection.py::test_no_hand_kept_cpu_test_list fails because the file exists again. Drop this hunk when rebasing.
This comment has been minimized.
This comment has been minimized.
The float resize differed from VJEPA2VideoProcessor by at most one uint8 step per pixel, and V-JEPA 2's embedding is that sensitive: HF's own fp32 encoder on those pixels scores cosine 0.84-0.94 against its processor's. Resize the uint8 frames through torchvision with antialias and normalize the way HF does, so the pixels match exactly. Platform-independent: this changes vjepa2 and vjepa2_ac outputs on NVIDIA too, toward the reference.
HF hosts no processor for the AC checkpoint. Upstream encodes a DROID frame as the centred square of its short side resized to 256 (make_transforms at scale and aspect ratio 1, as its notebook and WorldModel use). The plain encoders' resize and crop read a 158x158 window of a 320x180 frame instead of 180x180, which moved upstream's step-0 latents to cosine 0.79-0.93. (cherry picked from commit f94f9d7)
d25ccc8 to
c0fffe5
Compare
|
/review |
Automated review🟡 No findings on Description
What the review checked
Advisory, not a merge gate. Rules: |
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
The pixels now match HF's processor, but the uint8 round trip makes preprocessing a large clip slower than on main (see the inline comment).
| frames = nn.functional.interpolate(frames, size=(new_h, new_w), mode="bilinear", align_corners=False) | ||
| new_h, new_w = int(h * resize_short / w), resize_short | ||
| # load_video's frames are uint8 / 255, so this round trip is exact | ||
| pixels = (frames * 255.0).round().clamp(0, 255).to(torch.uint8) |
There was a problem hiding this comment.
This undones the / 255 at the end of VJepa2Model.load_video. At 64 frames of 1080p it allocates three float32 copies of the clip, 1.6 GB each, before the resize, which is far more memory traffic than main's whole resize.
Doing what Qwen3.5 does would remove it:
load_videoreturns the uint8 frames_preprocess_videocalls the checkpoint's processor throughAutoVideoProcessor, which reads the crop size, mean and std fromvideo_preprocessor_config.json
There was a problem hiding this comment.
Good catch, thanks. Changed it the way you suggested.
load_videoreturns the uint8 frames as decoded, no/ 255- the HF checkpoints go through their own processor via
AutoVideoProcessor, so crop size, mean and std come fromvideo_preprocessor_config.json. Its crop has to matchconfig.json, and it loads inwarmup_preprocess - the AC model keeps the official repo's central square crop (on main since V-JEPA2-AC: take the central square crop the official repo uses #921), now also from the bytes, one frame's square in float at a time. Same floats as upstream's transform, bit for bit
64 frames of 1080p on CPU, from the decoder's uint8 frames to video_frames (load_video + process_prompt). Peak RSS above the idle process, median of 15 runs, 4 threads is the API server's default preprocess pool.
| peak RSS | 4 threads | 1 thread | |
|---|---|---|---|
| main | 2.0 GB | 0.39 s | 1.36 s |
| c0fffe5 | 4.8 GB | 1.53 s | 4.25 s |
| now | 0.9 GB | 0.31 s | 0.82 s |
The peaks include the 0.4 GB of decoded frames. The AC path at the same size goes from 2.0 GB and 0.37 s on main to 0.46 GB and 0.16 s. Pixels still match HF's processor at the four test sizes. Main is up to 54 uint8 steps off at 480x640, where its float resize skips antialiasing.
I merged main into the branch instead of rebasing, so your review is still in the history. Served numbers on an H200 coming next.
vjepa2_model.py and test_preprocess.py take main's side. Main already has the AC model's central square crop (#921), and the next commits redo this branch's preprocessing on top of it.
HF's processor resizes the uint8 frames with antialias and rounds, and V-JEPA 2 is sensitive to that: resizing floats instead (one uint8 step at most per pixel) gave a cosine of 0.84-0.94 against HF in fp32. Since load_video divided the frames by 255, matching HF meant turning them back into bytes before the resize, three float32 copies of the clip at 1.6 GB each for 64 frames of 1080p. load_video now returns the uint8 frames as decoded. The HF checkpoints run them through their own VJEPA2VideoProcessor, loaded with AutoVideoProcessor from video_preprocessor_config.json like Qwen3.5 does, and warmed up with the data worker. The AC model keeps the official repo's central square crop, now from the bytes too, with one frame's square in float at a time. That gives the same floats as upstream's whole-clip transform.
The parity test with HF's processor now goes through process_prompt and a processor read from a checkpoint directory, on channels-last frames like torchcodec's. New tests cover load_video handing over the decoded bytes, a processor whose crop disagrees with the config, and the AC crop against upstream's float transform.
|
Served numbers on one H200 for 82a9b62.
Two passes over the clips give identical outputs. Preprocessing per request served (
|
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
LGTM, with one small point below.
| self._video_processor = processor | ||
| return self._video_processor | ||
|
|
||
| def warmup_preprocess(self) -> None: |
There was a problem hiding this comment.
A checkpoint without video_preprocessor_config.json hits a bug that main doesn't have: warm_up_model only logs what warmup_preprocess raises, so the server starts and then fails every video request. Loading the processor in __init__, beside _load_config, refuses such a checkpoint at startup, along with one whose processor crops another size.
warm_up_model only logs what warmup_preprocess raises, so a checkpoint without video_preprocessor_config.json, or whose processor crops another size, started and then failed every video request. Loading the processor beside _load_config refuses such a checkpoint at startup. The AC model has no processor, and without weights nothing is loaded, like the HF config.
A checkpoint directory with a tiny config.json but no video_preprocessor_config.json, and one whose processor crops 384, both fail at construction on CPU. The HF parity tests build the model from a checkpoint directory the same way.
…) into versioned outputs (3/3)
V-JEPA2 embeddings served by M* sat at cosine 0.83-0.89 against the HF fp32 model, while HF's own bf16 run sits at 0.94-0.98. The gap was preprocessing, not the model.
_preprocess_videoresized the frames as floats withinterpolate, and HF'sVJEPA2VideoProcessorresizes the uint8 frames with antialias and rounds. On an upscale like DROID's 320x180 the two differ by at most one uint8 step per pixel, and V-JEPA2 is that sensitive. The HF fp32 model fed our pixels scores 0.838-0.944 against the same model on its own processor's pixels, which is the served gap to the digit. On a downscale the float resize also skips antialiasing, up to 54 uint8 steps off at 480x640.This PR runs the checkpoint's own processor.
load_videoreturns the uint8 frames as decoded, andprocess_prompthands them to theVJEPA2VideoProcessorthatAutoVideoProcessorloads fromvideo_preprocessor_config.json(crop size, mean and std, crop checked againstconfig.json), like Qwen3.5 does. The AC model keeps the official repo's central square crop from #921, now also from the bytes, one frame's square in float at a time, bit-identical to upstream's transform. No float copy of the full-size clip is made.This changes the outputs of
vjepa2andvjepa2_acon every platform, toward the reference.64 frames of 1080p on CPU, from the decoder's uint8 frames to
video_frames. Peak RSS above the idle process, median of 15 runs, 4 threads is the API server's default preprocess pool.test/modular/vjepa2/test_preprocess.pyruns the served path and HF's processor on synthetic clips at four sizes and asserts they match within 1e-4. The old code fails all four, and "within one uint8 step" would not have caught this. It also coversload_videohanding over the decoded bytes, a processor whose crop disagrees with the config, and the AC crop against upstream's float transform.ruff check .pytest test/modular/vjepa2/test_preprocess.py