futurefantasy commited on
Commit
87894ff
·
verified ·
1 Parent(s): 039bd91

Update model card and repository split

Browse files

Keep model assets on the Hub and move examples/quick-start code to the GitHub repository.

LICENSE ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ TODO: replace this file with the final VLAC-Cut model license before public release.
2
+
3
+ Do not publish this model repository as open source until the license text has been finalized.
NOTICE ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ VLAC-Cut model release
2
+
3
+ TODO: replace this file with the final model notice, base-model attribution, and third-party notices before public release.
README.md CHANGED
@@ -1,26 +1,51 @@
1
  ---
2
  library_name: transformers
3
  pipeline_tag: image-text-to-text
 
4
  tags:
5
  - robotics
 
6
  - video-understanding
7
- - multimodal
8
  - progress-estimation
 
 
 
9
  ---
10
 
11
  # VLAC-Cut
12
 
13
- VLAC-Cut is a video-language progress estimation model for robotic manipulation. Given a task description and a video, it predicts time-progress keypoints that can be aligned into a progress curve.
14
 
15
- This release contains the inference-ready model weights, tokenizer and processor files, three example videos, reference prediction outputs, and minimal local inference utilities.
 
 
 
 
16
 
17
  ## Highlights
18
 
19
- - Progress estimation for long-horizon manipulation videos
20
- - Rollback and regression recognition in non-expert trajectories
21
- - Example videos from both expert and non-expert trajectories
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- ## Use with Transformers
 
 
 
 
24
 
25
  ```python
26
  from transformers import AutoModelForImageTextToText, AutoProcessor
@@ -30,39 +55,76 @@ model_id = "InternRobotics/VLAC-Cut"
30
  processor = AutoProcessor.from_pretrained(model_id)
31
  model = AutoModelForImageTextToText.from_pretrained(
32
  model_id,
33
- torch_dtype="auto",
34
  device_map="auto",
35
  )
 
36
  ```
37
 
38
  ## Quick Start
39
 
40
- Run progress inference on an arbitrary video:
41
 
42
  ```bash
43
- python quick_start/run_example.py \
44
- --model-path /path/to/VLAC-Cut \
45
- --video-path /path/to/video.mp4 \
 
 
 
46
  --task-instruction "<natural-language task instruction>" \
47
  --task-plan $'<optional step-by-step task plan>' \
48
- --output-jsonl quick_start/outputs/video.jsonl
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  ```
50
 
51
- - `--video-path`: required input video.
52
- - `--task-instruction`: task description, unless `--prompt` is used instead.
53
- - `--task-plan`: optional step-by-step plan text for the default `chunk_all` prompt.
54
- - `--model-path`: optional. Defaults to the parent directory of `quick_start/`.
55
- - `--output-jsonl`: optional. If omitted, the script prints the prediction only and does not save a JSONL file.
56
- - `--prompt`: optional full prompt override.
57
- - `--sample-hz`: optional inference control. Default: `2.0`.
58
 
59
- Render a prediction-only preview video from the JSONL output:
60
 
61
  ```bash
62
- python quick_start/render_prediction_video.py \
63
- --input-jsonl quick_start/outputs/video.jsonl
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  ```
65
 
66
- Videos under `examples/` can be used with the same interface by passing the video path and prompt text directly.
67
 
68
- More detailed usage is provided in `quick_start/README.md`.
 
1
  ---
2
  library_name: transformers
3
  pipeline_tag: image-text-to-text
4
+ base_model: Qwen/Qwen3-VL-30B-A3B-Instruct
5
  tags:
6
  - robotics
7
+ - embodied-ai
8
  - video-understanding
 
9
  - progress-estimation
10
+ - reward-modeling
11
+ - qwen3-vl
12
+ license: other
13
  ---
14
 
15
  # VLAC-Cut
16
 
17
+ **Video-based task progress estimation model for robot manipulation.**
18
 
19
+ [Model](https://huggingface.co/InternRobotics/VLAC-Cut) · [Code](https://github.com/InternRobotics/VLAC-Cut) · [Paper](<PAPER_URL>) · [VPB Benchmark](https://huggingface.co/datasets/InternRobotics/VLAC-Cut-Benchmark)
20
+
21
+ VLAC-Cut predicts how a robot task progresses over time from a natural-language instruction and an execution video. Instead of assuming that progress always increases with time, the model captures non-monotonic execution patterns including advancement, stagnation, regression, and recovery.
22
+
23
+ The model is designed for long-horizon robot manipulation videos containing failed grasps, object drops, incorrect interactions, off-target placements, and corrective behavior.
24
 
25
  ## Highlights
26
 
27
+ - **Video-level progress understanding:** reasons over temporally ordered robot videos rather than isolated images or image pairs.
28
+ - **Non-monotonic process modeling:** recognizes advancement, stagnation, regression, and recovery.
29
+ - **Generative output:** produces timestamped progress predictions through the standard autoregressive language-model interface, without a task-specific regression head.
30
+
31
+ ## Model at a Glance
32
+
33
+ | Property | Description |
34
+ |---|---|
35
+ | Base model | `Qwen/Qwen3-VL-30B-A3B-Instruct` |
36
+ | Input | Task instruction, optional task plan, and sampled video frames |
37
+ | Output | Timestamped task progress |
38
+ | Default video sampling rate | `2 Hz` |
39
+
40
+ ## Model Design
41
+
42
+ VLAC-Cut is fine-tuned from Qwen3-VL-30B-A3B-Instruct and retains its original multimodal generation interface. The model receives a task instruction, an optional task plan, and sampled video frames, then generates task progress at different timestamps as text.
43
 
44
+ The training data also include supervision for robot behavior description, failure analysis, and correction planning, helping the model understand realistic robot execution processes.
45
+
46
+ This repository contains the model checkpoint and loading assets only. Inference scripts, examples, and VPB evaluation code are maintained in the GitHub repository.
47
+
48
+ ## Load with Transformers
49
 
50
  ```python
51
  from transformers import AutoModelForImageTextToText, AutoProcessor
 
55
  processor = AutoProcessor.from_pretrained(model_id)
56
  model = AutoModelForImageTextToText.from_pretrained(
57
  model_id,
58
+ dtype="auto",
59
  device_map="auto",
60
  )
61
+ model.eval()
62
  ```
63
 
64
  ## Quick Start
65
 
66
+ Run progress inference on a local video using the GitHub code:
67
 
68
  ```bash
69
+ git clone https://github.com/InternRobotics/VLAC-Cut
70
+ cd VLAC-Cut
71
+
72
+ python scripts/run_example.py \
73
+ --model-path InternRobotics/VLAC-Cut \
74
+ --video-path examples/example_01/episode.mp4 \
75
  --task-instruction "<natural-language task instruction>" \
76
  --task-plan $'<optional step-by-step task plan>' \
77
+ --output-jsonl outputs/example_01.jsonl
78
+ ```
79
+
80
+ Main arguments:
81
+
82
+ - `--model-path`: Hugging Face model id or local checkpoint path.
83
+ - `--video-path`: input video path.
84
+ - `--task-instruction`: natural-language task description.
85
+ - `--task-plan`: optional step-by-step task plan.
86
+ - `--sample-hz`: video sampling rate. Default: `2.0`.
87
+ - `--prompt`: optional full-prompt override.
88
+ - `--output-jsonl`: optional output path; if omitted, the model response is printed only.
89
+
90
+ Example response:
91
+
92
+ ```text
93
+ 时间: 0.0s, 进度: 0%
94
+ 时间: 1.0s, 进度: 25%
95
+ 时间: 2.0s, 进度: 40%
96
+ ```
97
+
98
+ Render a prediction preview video:
99
+
100
+ ```bash
101
+ python scripts/utils/render_prediction_video.py \
102
+ --input-jsonl outputs/example_01.jsonl \
103
+ --output-video outputs/example_01_pred_progress.mp4
104
  ```
105
 
106
+ ## Evaluation on VPB
 
 
 
 
 
 
107
 
108
+ VLAC-Cut is evaluated on the Video Progress Benchmark. The benchmark data are released at `InternRobotics/VLAC-Cut-Benchmark`, and the evaluator is provided in the GitHub repository:
109
 
110
  ```bash
111
+ python scripts/evaluate_vpb_predictions.py \
112
+ --benchmark-root /path/to/VLAC-Cut-Benchmark/splits \
113
+ --predictions predictions/vlac_cut_predictions.jsonl \
114
+ --out-json reports/vlac_cut_vpb_eval.json \
115
+ --out-md reports/vlac_cut_vpb_eval.md
116
+ ```
117
+
118
+ Report final VPB numbers here before public release.
119
+
120
+ ## Citation
121
+
122
+ Please replace the placeholder below with the final paper BibTeX before public release.
123
+
124
+ ```bibtex
125
+
126
  ```
127
 
128
+ ## License
129
 
130
+ See the license files distributed with this checkpoint. The base model and third-party training data remain subject to their original licenses and terms of use.
examples/example_01/episode.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:107c36cd11846dc0d622a2168fb0d2129d912dda5301c6748e0a1417ff5cde28
3
- size 1477548
 
 
 
 
examples/example_02/episode.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:52c1494005556db7242d49982cc0ee2d2be31962e0ce9dd59a590f96b5462ec5
3
- size 1908478
 
 
 
 
examples/example_03/episode.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9221f9782d815175fd851555d78e3950d4bd42ff362569b5d66da7be8f7df724
3
- size 1047570
 
 
 
 
examples/reference_outputs/example_01.jsonl DELETED
@@ -1 +0,0 @@
1
- {"task_instruction": "抓取紫色方块使其从方形洞口落入积木桶中", "task_plan": "爪夹开始移动:0%\n爪夹接近紫色方块:20%\n爪夹抓紧紫色方块:40%\n爪夹抓紧紫色方块接近方形洞口:60%\n爪夹将紫色方块对准方形洞口:80%\n紫色方块从方形洞口落入积木桶中,爪夹移开:100%", "video_path": "examples/example_01/episode.mp4", "prompt_source": "task_instruction_plus_plan", "prompt_variant": "chunk_all", "input_sample_hz": 2.0, "input_frame_indices": [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255], "input_timestamps_sec": [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5], "input_frame_count": 18, "decoded_video_stats": {"decoded_frame_count": 258.0, "decoded_avg_fps": 30.0, "decoded_duration_sec": 8.566667}, "prompt": "任务描述和具体规划: 抓取紫色方块使其从方形洞口落入积木桶中\n爪夹开始移动:0%\n爪夹接近紫色方块:20%\n爪夹抓紧紫色方块:40%\n爪夹抓紧紫色方块接近方形洞口:60%\n爪夹将紫色方块对准方形洞口:80%\n紫色方块从方形洞口落入积木桶中,爪夹移开:100%\n\n请根据任务描述和具体规划,找到并逐点生成视频中的关键动作点和相应的进度标注。输出格式要求:每个关键点一行,格式为:\n时间: X.Xs, 进度: Y%\n\n请严格按照上述格式输出,不要输出额外说明。", "response": "时间: 1.3s, 进度: 0%\n时间: 2.7s, 进度: 10%\n时间: 3.7s, 进度: 15%\n时间: 5.5s, 进度: 10%\n时间: 6.3s, 进度: -15%\n时间: 7.1s, 进度: -25%\n时间: 8.0s, 进度: -15%", "pred_curve_parse_ok": true, "pred_curve_point_times_sec": [1.3, 2.7, 3.7, 5.5, 6.3, 7.1, 8.0], "pred_curve_point_progress": [0.0, 10.0, 15.0, 10.0, -15.0, -25.0, -15.0], "preview_video_path": "examples/reference_outputs/example_01_pred_progress.mp4"}
 
 
examples/reference_outputs/example_01_pred_progress.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3ea6fb101f9823664cd7531ce5297f9490c40e6de7e5c1bb584b9d6fa5223301
3
- size 2043716
 
 
 
 
examples/reference_outputs/example_02.jsonl DELETED
@@ -1 +0,0 @@
1
- {"task_instruction": "把洋葱放进快递箱里。", "task_plan": "开始移动:0%\n爪夹靠近洋葱:20%\n爪夹抓取洋葱:40%\n爪夹抓住洋葱接近快递箱:60%\n爪夹将洋葱放入快递箱:80%\n爪夹松开,洋葱落入快递箱:100%", "video_path": "examples/example_02/episode.mp4", "prompt_source": "task_instruction_plus_plan", "prompt_variant": "chunk_all", "input_sample_hz": 2.0, "input_frame_indices": [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285], "input_timestamps_sec": [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5], "input_frame_count": 20, "decoded_video_stats": {"decoded_frame_count": 289.0, "decoded_avg_fps": 30.0, "decoded_duration_sec": 9.6}, "prompt": "任务描述和具体规划: 把洋葱放进快递箱里。\n开始移动:0%\n爪夹靠近洋葱:20%\n爪夹抓取洋葱:40%\n爪夹抓住洋葱接近快递箱:60%\n爪夹将洋葱放入快递箱:80%\n爪夹松开,洋葱落入快递箱:100%\n\n请根据任务描述和具体规划,找到并逐点生成视频中的关键动作点和相应的进度标注。输出格式要求:每个关键点一行,格式为:\n时间: X.Xs, 进度: Y%\n\n请严格按照上述格式输出,不要输出额外说明。", "response": "时间: 0.0s, 进度: 0%\n时间: 2.3s, 进度: 5%\n时间: 3.3s, 进度: 8%\n时间: 4.3s, 进度: 10%\n时间: 5.1s, 进度: -5%\n时间: 6.1s, 进度: -8%\n时间: 7.1s, 进度: -2%\n时间: 8.3s, 进度: 2%\n时间: 9.5s, 进度: 4%", "pred_curve_parse_ok": true, "pred_curve_point_times_sec": [0.0, 2.3, 3.3, 4.3, 5.1, 6.1, 7.1, 8.3, 9.5], "pred_curve_point_progress": [0.0, 5.0, 8.0, 10.0, -5.0, -8.0, -2.0, 2.0, 4.0], "preview_video_path": "examples/reference_outputs/example_02_pred_progress.mp4"}
 
 
examples/reference_outputs/example_02_pred_progress.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:691fe25a519518ac2c24ba86c71ab7714d41f5616482cc0f079b874f897f679f
3
- size 2546146
 
 
 
 
examples/reference_outputs/example_03.jsonl DELETED
@@ -1 +0,0 @@
1
- {"task_instruction": "将三角烧杯放在三脚架上。", "task_plan": "爪夹准备移动:0%\n爪夹开始移动:10%\n爪夹靠近三角烧杯:20%\n爪夹抓取三角烧杯:40%\n爪夹合拢并靠近三脚架:60%\n爪夹移动到三脚架的正上方:80%\n爪夹松开,三角烧杯落在三脚架上:100%", "video_path": "examples/example_03/episode.mp4", "prompt_source": "task_instruction_plus_plan", "prompt_variant": "chunk_all", "input_sample_hz": 2.0, "input_frame_indices": [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165], "input_timestamps_sec": [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5], "input_frame_count": 12, "decoded_video_stats": {"decoded_frame_count": 172.0, "decoded_avg_fps": 30.0, "decoded_duration_sec": 5.7}, "prompt": "任务描述和具体规划: 将三角烧杯放在三脚架上。\n爪夹准备移动:0%\n爪夹开始移动:10%\n爪夹靠近三角烧杯:20%\n爪夹抓取三角烧杯:40%\n爪夹合拢并靠近三脚架:60%\n爪夹移动到三脚架的正上方:80%\n爪夹松开,三角烧杯落在三脚架上:100%\n\n请根据任务描述和具体规划,找到并逐点生成视频中的关键动作点和相应的进度标注。输出格式要求:每个关键点一行,格式为:\n时间: X.Xs, 进度: Y%\n\n请严格按照上述格式输出,不要输出额外说明。", "response": "时间: 0.1s, 进度: 0%\n时间: 0.6s, 进度: 10%\n时间: 1.1s, 进度: 20%\n时间: 1.6s, 进度: 30%\n时间: 2.3s, 进度: 40%\n时间: 3.4s, 进度: 60%\n时间: 4.1s, 进度: 80%\n时间: 5.3s, 进度: 100%", "pred_curve_parse_ok": true, "pred_curve_point_times_sec": [0.1, 0.6, 1.1, 1.6, 2.3, 3.4, 4.1, 5.3], "pred_curve_point_progress": [0.0, 10.0, 20.0, 30.0, 40.0, 60.0, 80.0, 100.0], "preview_video_path": "examples/reference_outputs/example_03_pred_progress.mp4"}
 
 
examples/reference_outputs/example_03_pred_progress.mp4 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:211dfef38fd8e374f20b4bcf83a607fd6e3461fafb53a8228386995bbab118fe
3
- size 1436259
 
 
 
 
quick_start/README.md DELETED
@@ -1,93 +0,0 @@
1
- # Quick Start
2
-
3
- This quick start has two steps:
4
-
5
- 1. Run progress inference on a video. Optionally save one JSONL output.
6
- 2. Render a prediction-only preview video from a saved JSONL output.
7
-
8
- ## Requirements
9
-
10
- - `python >= 3.10`
11
- - `torch`
12
- - `transformers >= 4.57`
13
- - `ms-swift`
14
- - `decord`
15
- - `numpy`
16
- - `Pillow`
17
- - `opencv-python` for preview video rendering
18
- - at least `1` GPU with enough memory for this `30B` checkpoint
19
-
20
- ## Usage
21
-
22
- Run inference:
23
-
24
- ```bash
25
- python quick_start/run_example.py \
26
- --model-path /path/to/VLAC-Cut \
27
- --video-path /path/to/video.mp4 \
28
- --task-instruction "<natural-language task instruction>" \
29
- --task-plan $'<optional step-by-step task plan>' \
30
- --output-jsonl quick_start/outputs/video.jsonl
31
- ```
32
-
33
- Important arguments:
34
-
35
- - `--video-path`: required. Input video path.
36
- - `--task-instruction`: optional if `--prompt` is provided. Natural-language task description.
37
- - `--task-plan`: optional. Multi-line plan text appended after `--task-instruction` when building the default `chunk_all` prompt.
38
- - `--model-path`: optional. Defaults to the parent directory of `quick_start/`.
39
- - `--output-jsonl`: optional. If omitted, the script prints the prediction only and does not save a JSONL file.
40
- - `--sample-hz`: optional. Video sampling rate for inference. Default: `2.0`.
41
- - `--prompt`: optional full prompt override. If set, `--task-instruction` and `--task-plan` are ignored.
42
-
43
- Render a preview video:
44
-
45
- ```bash
46
- python quick_start/render_prediction_video.py \
47
- --input-jsonl quick_start/outputs/video.jsonl \
48
- --output-video quick_start/outputs/video_pred_progress.mp4
49
- ```
50
-
51
- ## Example Commands
52
-
53
- `example_01`
54
-
55
- ```bash
56
- python quick_start/run_example.py \
57
- --video-path examples/example_01/episode.mp4 \
58
- --task-instruction "抓取紫色方块使其从方形洞口落入积木桶中" \
59
- --task-plan $'爪夹开始移动:0%\n爪夹接近紫色方块:20%\n爪夹抓紧紫色方块:40%\n爪夹抓紧紫色方块接近方形洞口:60%\n爪夹将紫色方块对准方形洞口:80%\n紫色方块从方形洞口落入积木桶中,爪夹移开:100%' \
60
- --output-jsonl quick_start/outputs/example_01.jsonl
61
-
62
- python quick_start/render_prediction_video.py \
63
- --input-jsonl quick_start/outputs/example_01.jsonl \
64
- --output-video quick_start/outputs/example_01_pred_progress.mp4
65
- ```
66
-
67
- `example_02`
68
-
69
- ```bash
70
- python quick_start/run_example.py \
71
- --video-path examples/example_02/episode.mp4 \
72
- --task-instruction "把洋葱放进快递箱里。" \
73
- --task-plan $'开始移动:0%\n爪夹靠近洋葱:20%\n爪夹抓取洋葱:40%\n爪夹抓住洋葱接近快递箱:60%\n爪夹将洋葱放入快递箱:80%\n爪夹松开,洋葱落入快递箱:100%' \
74
- --output-jsonl quick_start/outputs/example_02.jsonl
75
-
76
- python quick_start/render_prediction_video.py \
77
- --input-jsonl quick_start/outputs/example_02.jsonl \
78
- --output-video quick_start/outputs/example_02_pred_progress.mp4
79
- ```
80
-
81
- `example_03`
82
-
83
- ```bash
84
- python quick_start/run_example.py \
85
- --video-path examples/example_03/episode.mp4 \
86
- --task-instruction "将三角烧杯放在三脚架上。" \
87
- --task-plan $'爪夹准备移动:0%\n爪夹开始移动:10%\n爪夹靠近三角烧杯:20%\n爪夹抓取三角烧杯:40%\n爪夹合拢并靠近三脚架:60%\n爪夹移动到三脚架的正上方:80%\n爪夹松开,三角烧杯落在三脚架上:100%' \
88
- --output-jsonl quick_start/outputs/example_03.jsonl
89
-
90
- python quick_start/render_prediction_video.py \
91
- --input-jsonl quick_start/outputs/example_03.jsonl \
92
- --output-video quick_start/outputs/example_03_pred_progress.mp4
93
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quick_start/render_prediction_video.py DELETED
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import math
7
- from pathlib import Path
8
-
9
- import cv2
10
- import numpy as np
11
-
12
- THIS_DIR = Path(__file__).resolve().parent
13
- MODEL_ROOT = THIS_DIR.parent
14
-
15
-
16
- def parse_args() -> argparse.Namespace:
17
- parser = argparse.ArgumentParser(
18
- description="Render a predicted-progress preview video from a quick_start JSONL output.",
19
- )
20
- parser.add_argument(
21
- "--input-jsonl",
22
- type=Path,
23
- required=True,
24
- help="Path to the quick_start JSONL output.",
25
- )
26
- parser.add_argument(
27
- "--video-path",
28
- type=Path,
29
- default=None,
30
- help="Optional input video override. Defaults to the video_path stored in the JSONL row.",
31
- )
32
- parser.add_argument(
33
- "--output-video",
34
- type=Path,
35
- default=None,
36
- help="Optional output path. Defaults to <input_jsonl_stem>_pred_progress.mp4",
37
- )
38
- parser.add_argument(
39
- "--panel-height",
40
- type=int,
41
- default=260,
42
- help="Height in pixels for the lower progress panel.",
43
- )
44
- return parser.parse_args()
45
-
46
-
47
- def load_row(path: Path) -> dict:
48
- lines = path.read_text(encoding="utf-8").splitlines()
49
- if not lines:
50
- raise SystemExit(f"Empty JSONL file: {path}")
51
- return json.loads(lines[0])
52
-
53
-
54
- def resolve_video_path(path_arg: Path | None, row: dict) -> Path:
55
- if path_arg is not None:
56
- path = path_arg.resolve()
57
- if not path.exists():
58
- raise SystemExit(f"Missing video: {path}")
59
- return path
60
-
61
- row_video_path = row.get("video_path")
62
- if not row_video_path:
63
- raise SystemExit("Missing video_path in JSONL row. Pass --video-path explicitly.")
64
- candidate = Path(str(row_video_path))
65
- if candidate.is_absolute():
66
- path = candidate
67
- else:
68
- path = (MODEL_ROOT / candidate).resolve()
69
- if not path.exists():
70
- raise SystemExit(f"Missing video resolved from JSONL row: {path}")
71
- return path
72
-
73
-
74
- def render_preview(
75
- *,
76
- row: dict,
77
- video_path: Path,
78
- output_path: Path,
79
- panel_height: int,
80
- ) -> None:
81
- pred_times = [float(x) for x in row.get("pred_curve_point_times_sec", [])]
82
- pred_vals = [float(x) for x in row.get("pred_curve_point_progress", [])]
83
- if not pred_times or not pred_vals:
84
- raise SystemExit("No predicted progress points found in JSONL row.")
85
-
86
- cap = cv2.VideoCapture(str(video_path))
87
- if not cap.isOpened():
88
- raise SystemExit(f"Failed to open video: {video_path}")
89
-
90
- fps = float(cap.get(cv2.CAP_PROP_FPS))
91
- frame_count = int(round(cap.get(cv2.CAP_PROP_FRAME_COUNT)))
92
- frame_w = int(round(cap.get(cv2.CAP_PROP_FRAME_WIDTH)))
93
- frame_h = int(round(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
94
- if fps <= 0 or frame_count <= 0 or frame_w <= 0 or frame_h <= 0:
95
- cap.release()
96
- raise SystemExit(
97
- f"Invalid decoded video stats from {video_path}: fps={fps}, frames={frame_count}, "
98
- f"width={frame_w}, height={frame_h}"
99
- )
100
-
101
- total_h = frame_h + panel_height
102
- writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (frame_w, total_h))
103
- if not writer.isOpened():
104
- cap.release()
105
- raise SystemExit(f"Failed to open output video writer: {output_path}")
106
-
107
- duration = max(0.0, (frame_count - 1) / fps)
108
- dense_t = np.arange(frame_count, dtype=np.float32) / fps
109
- dense_v = np.interp(
110
- dense_t,
111
- np.array(pred_times, dtype=np.float32),
112
- np.array(pred_vals, dtype=np.float32),
113
- left=float(pred_vals[0]),
114
- right=float(pred_vals[-1]),
115
- )
116
-
117
- y_min = min(-40.0, math.floor(min(pred_vals) / 10.0) * 10.0 - 10.0)
118
- y_max = max(100.0, math.ceil(max(pred_vals) / 10.0) * 10.0 + 10.0)
119
- left, right, top, bottom = 78, 24, 28, 52
120
- chart_x0, chart_x1 = left, frame_w - right
121
- chart_y0, chart_y1 = frame_h + top, total_h - bottom
122
- tick_vals = np.linspace(y_min, y_max, num=6)
123
- title = str(row.get("example_id") or video_path.stem)
124
-
125
- def map_x(t: float) -> int:
126
- if duration <= 1e-9:
127
- return chart_x0
128
- return int(round(chart_x0 + (chart_x1 - chart_x0) * (t / duration)))
129
-
130
- def map_y(v: float) -> int:
131
- ratio = (v - y_min) / max(1e-9, (y_max - y_min))
132
- return int(round(chart_y1 - ratio * (chart_y1 - chart_y0)))
133
-
134
- try:
135
- idx = 0
136
- while True:
137
- ok, frame = cap.read()
138
- if not ok:
139
- break
140
- if idx >= frame_count:
141
- break
142
-
143
- canvas = np.full((total_h, frame_w, 3), 255, dtype=np.uint8)
144
- canvas[:frame_h, :, :] = frame
145
-
146
- cv2.putText(
147
- canvas,
148
- f"{title} predicted progress so far",
149
- (18, frame_h + 22),
150
- cv2.FONT_HERSHEY_SIMPLEX,
151
- 0.65,
152
- (20, 20, 20),
153
- 2,
154
- cv2.LINE_AA,
155
- )
156
- cv2.rectangle(canvas, (chart_x0, chart_y0), (chart_x1, chart_y1), (210, 210, 210), 1)
157
-
158
- for tick in tick_vals:
159
- y = map_y(float(tick))
160
- cv2.line(canvas, (chart_x0, y), (chart_x1, y), (238, 238, 238), 1)
161
- cv2.putText(
162
- canvas,
163
- f"{int(round(tick))}",
164
- (12, y + 5),
165
- cv2.FONT_HERSHEY_SIMPLEX,
166
- 0.44,
167
- (90, 90, 90),
168
- 1,
169
- cv2.LINE_AA,
170
- )
171
-
172
- for frac in np.linspace(0.0, 1.0, num=5):
173
- t_mark = duration * frac
174
- x = map_x(float(t_mark))
175
- cv2.line(canvas, (x, chart_y0), (x, chart_y1), (245, 245, 245), 1)
176
- cv2.putText(
177
- canvas,
178
- f"{t_mark:.1f}s",
179
- (x - 16, total_h - 16),
180
- cv2.FONT_HERSHEY_SIMPLEX,
181
- 0.42,
182
- (100, 100, 100),
183
- 1,
184
- cv2.LINE_AA,
185
- )
186
-
187
- current_t = float(dense_t[idx])
188
- current_v = float(dense_v[idx])
189
- pts = [[map_x(float(dense_t[j])), map_y(float(dense_v[j]))] for j in range(idx + 1)]
190
- if len(pts) >= 2:
191
- cv2.polylines(canvas, [np.array(pts, dtype=np.int32)], False, (0, 140, 255), 3, cv2.LINE_AA)
192
- elif len(pts) == 1:
193
- cv2.circle(canvas, tuple(pts[0]), 3, (0, 140, 255), -1, cv2.LINE_AA)
194
-
195
- for t, v in zip(pred_times, pred_vals):
196
- if t <= current_t + 1e-6:
197
- cv2.circle(canvas, (map_x(t), map_y(v)), 5, (20, 20, 20), -1, cv2.LINE_AA)
198
-
199
- cx, cy = map_x(current_t), map_y(current_v)
200
- cv2.line(canvas, (cx, chart_y0), (cx, chart_y1), (120, 120, 120), 1)
201
- cv2.circle(canvas, (cx, cy), 6, (0, 90, 220), -1, cv2.LINE_AA)
202
- cv2.putText(
203
- canvas,
204
- f"time={current_t:.2f}s pred={current_v:.1f}%",
205
- (frame_w - 340, frame_h + 22),
206
- cv2.FONT_HERSHEY_SIMPLEX,
207
- 0.55,
208
- (30, 30, 30),
209
- 2,
210
- cv2.LINE_AA,
211
- )
212
-
213
- writer.write(canvas)
214
- idx += 1
215
- finally:
216
- cap.release()
217
- writer.release()
218
-
219
-
220
- def main() -> int:
221
- args = parse_args()
222
- input_jsonl = args.input_jsonl.resolve()
223
- if not input_jsonl.exists():
224
- raise SystemExit(f"Missing JSONL: {input_jsonl}")
225
- row = load_row(input_jsonl)
226
- video_path = resolve_video_path(args.video_path, row)
227
- output_path = args.output_video or input_jsonl.with_name(f"{input_jsonl.stem}_pred_progress.mp4")
228
- output_path.parent.mkdir(parents=True, exist_ok=True)
229
-
230
- render_preview(
231
- row=row,
232
- video_path=video_path,
233
- output_path=output_path,
234
- panel_height=args.panel_height,
235
- )
236
- print(f"input_jsonl={input_jsonl}")
237
- print(f"video_path={video_path}")
238
- print(f"output_video={output_path.resolve()}")
239
- return 0
240
-
241
-
242
- if __name__ == "__main__":
243
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
quick_start/run_example.py DELETED
@@ -1,360 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import os
7
- import re
8
- import tempfile
9
- from pathlib import Path
10
-
11
- import numpy as np
12
- from PIL import Image
13
-
14
- THIS_DIR = Path(__file__).resolve().parent
15
- MODEL_ROOT = THIS_DIR.parent
16
- DEFAULT_SAMPLE_HZ = 2.0
17
-
18
- SIGNED_NUM = r"([+-]?[0-9]+(?:\.[0-9]+)?)"
19
- POINT_TIME_RE = re.compile(r"(?:Time|时间)[::]?\s*([0-9]+(?:\.[0-9]+)?)\s*s?", re.IGNORECASE)
20
- POINT_PROGRESS_LINE_RE = re.compile(rf"(?im)^\s*(?:Progress|进度)[::]?\s*{SIGNED_NUM}\s*%")
21
- INLINE_POINT_RE = re.compile(
22
- rf"(?:Time|时间)[::]?\s*([0-9]+(?:\.[0-9]+)?)\s*s?\s*[,,]?\s*(?:Progress|进度)[::]?\s*{SIGNED_NUM}\s*%",
23
- re.IGNORECASE,
24
- )
25
-
26
-
27
- def parse_args() -> argparse.Namespace:
28
- parser = argparse.ArgumentParser(
29
- description="Run VLAC progress inference on an arbitrary input video.",
30
- )
31
- parser.add_argument(
32
- "--model-path",
33
- type=Path,
34
- default=MODEL_ROOT,
35
- help="Path to the released VLAC model directory.",
36
- )
37
- parser.add_argument(
38
- "--video-path",
39
- type=Path,
40
- required=True,
41
- help="Path to the input video.",
42
- )
43
- parser.add_argument(
44
- "--task-instruction",
45
- type=str,
46
- default=None,
47
- help="Task instruction used to build the default chunk_all prompt.",
48
- )
49
- parser.add_argument(
50
- "--task-plan",
51
- type=str,
52
- default=None,
53
- help="Optional task plan text appended after the task instruction.",
54
- )
55
- parser.add_argument(
56
- "--prompt",
57
- type=str,
58
- default=None,
59
- help="Optional full prompt override. If set, task instruction and plan are ignored.",
60
- )
61
- parser.add_argument(
62
- "--sample-hz",
63
- type=float,
64
- default=DEFAULT_SAMPLE_HZ,
65
- help="Video sampling rate in Hz. Defaults to 2.0.",
66
- )
67
- parser.add_argument(
68
- "--output-jsonl",
69
- type=Path,
70
- default=None,
71
- help="Optional output path. If omitted, print the response only and do not save a JSONL file.",
72
- )
73
- parser.add_argument(
74
- "--max-new-tokens",
75
- type=int,
76
- default=1024,
77
- help="Generation cap for the response.",
78
- )
79
- return parser.parse_args()
80
-
81
-
82
- def build_chunk_all_prompt(task_instruction: str, task_plan: str | None) -> str:
83
- task_instruction = task_instruction.strip()
84
- task_plan = (task_plan or "").strip()
85
- task_and_plan = task_instruction if not task_plan else f"{task_instruction}\n{task_plan}"
86
- return (
87
- f"任务描述和具体规划: {task_and_plan}\n\n"
88
- "请根据任务描述和具体规划,找到并逐点生成视频中的关键动作点和相应的进度标注。"
89
- "输出格式要求:每个关键点一行,格式为:\n"
90
- "时间: X.Xs, 进度: Y%\n\n"
91
- "请严格按照上述格式输出,不要输出额外说明。"
92
- )
93
-
94
-
95
- def resolve_prompt(args: argparse.Namespace) -> tuple[str, str, str | None, str | None]:
96
- prompt = args.prompt.strip() if args.prompt else None
97
- task_plan = args.task_plan.strip() if args.task_plan else None
98
- task_instruction = args.task_instruction.strip() if args.task_instruction else None
99
-
100
- if prompt:
101
- return prompt, "raw_prompt", task_instruction, task_plan
102
-
103
- if not task_instruction:
104
- raise SystemExit(
105
- "Either provide --prompt, or provide --task-instruction with optional --task-plan."
106
- )
107
-
108
- return build_chunk_all_prompt(task_instruction, task_plan), "task_instruction_plus_plan", task_instruction, task_plan
109
-
110
-
111
- def resolve_video_path(video_path_arg: Path) -> Path:
112
- video_path = video_path_arg.resolve()
113
- if not video_path.exists():
114
- raise SystemExit(f"Missing video: {video_path}")
115
- return video_path
116
-
117
-
118
- def maybe_relativize_to_model_root(path: Path) -> str:
119
- try:
120
- return str(path.resolve().relative_to(MODEL_ROOT.resolve()))
121
- except ValueError:
122
- return str(path.resolve())
123
-
124
-
125
- def compute_sampled_indices(num_frames: int, fps: float, sample_hz: float) -> tuple[list[int], list[float]]:
126
- if num_frames <= 0:
127
- raise SystemExit(f"Invalid decoded frame count: {num_frames}")
128
- if fps <= 0:
129
- raise SystemExit(f"Invalid decoded fps: {fps}")
130
- if sample_hz <= 0:
131
- raise SystemExit(f"sample_hz must be positive, got {sample_hz}")
132
-
133
- frame_ids = list(range(num_frames))
134
- eligible_arr = np.array(frame_ids, dtype=float)
135
- duration_sec = max(0.0, (num_frames - 1) / fps)
136
- step_sec = 1.0 / sample_hz
137
-
138
- target_times: list[float] = []
139
- current = 0.0
140
- eps = 1e-9
141
- while current <= duration_sec + eps:
142
- target_times.append(round(current, 6))
143
- current += step_sec
144
- if not target_times:
145
- target_times = [0.0]
146
-
147
- sampled_indices: list[int] = []
148
- sampled_timestamps_sec: list[float] = []
149
- seen = set()
150
- for target_time in target_times:
151
- target_frame = target_time * fps
152
- pos = int(np.argmin(np.abs(eligible_arr - target_frame)))
153
- idx = int(eligible_arr[pos])
154
- if idx in seen:
155
- continue
156
- seen.add(idx)
157
- sampled_indices.append(idx)
158
- sampled_timestamps_sec.append(round(idx / fps, 6))
159
- return sampled_indices, sampled_timestamps_sec
160
-
161
-
162
- def extract_sampled_frame_paths(
163
- video_path: Path,
164
- sample_hz: float,
165
- *,
166
- temp_dir: Path,
167
- ) -> tuple[list[str], dict[str, float], list[int], list[float]]:
168
- try:
169
- from decord import VideoReader, cpu
170
- except ImportError as exc:
171
- raise SystemExit("Missing dependency: decord is required for video sampling in quick_start.") from exc
172
-
173
- vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
174
- actual_frame_count = len(vr)
175
- actual_fps = float(vr.get_avg_fps())
176
- sampled_indices, sampled_timestamps_sec = compute_sampled_indices(actual_frame_count, actual_fps, sample_hz)
177
- if not sampled_indices:
178
- raise SystemExit("No sampled frame indices were generated.")
179
- if sampled_indices[-1] >= actual_frame_count:
180
- raise SystemExit(
181
- f"Decoded video is shorter than expected. Need frame index {sampled_indices[-1]}, "
182
- f"got {actual_frame_count} frames."
183
- )
184
-
185
- batch = vr.get_batch(sampled_indices).asnumpy()
186
- frame_paths: list[str] = []
187
- for idx, frame in zip(sampled_indices, batch, strict=True):
188
- frame_path = temp_dir / f"frame_{idx:06d}.jpg"
189
- Image.fromarray(frame).save(frame_path, quality=95)
190
- frame_paths.append(str(frame_path.resolve()))
191
-
192
- video_stats = {
193
- "decoded_frame_count": float(actual_frame_count),
194
- "decoded_avg_fps": actual_fps,
195
- "decoded_duration_sec": round(max(0.0, (actual_frame_count - 1) / actual_fps), 6),
196
- }
197
- return frame_paths, video_stats, sampled_indices, sampled_timestamps_sec
198
-
199
-
200
- def load_swift_runtime():
201
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
202
- os.environ.setdefault("IMAGE_MAX_TOKEN_NUM", "256")
203
- os.environ.setdefault("VIDEO_MAX_TOKEN_NUM", "256")
204
- os.environ.setdefault("VIDEO_MIN_TOKEN_NUM", "4")
205
- os.environ.setdefault("QWEN_VL_UTILS_MAX_FRAME_LIST", "0")
206
-
207
- try:
208
- import torch # noqa: F401
209
- except ImportError as exc:
210
- raise SystemExit("Missing dependency: torch is required for quick_start inference.") from exc
211
-
212
- try:
213
- from swift.llm import InferRequest, PtEngine, RequestConfig
214
- except ImportError:
215
- from swift.infer_engine import InferRequest, RequestConfig, TransformersEngine as PtEngine # type: ignore
216
- return InferRequest, PtEngine, RequestConfig
217
-
218
-
219
- def strip_code_fence(text: str) -> str:
220
- cleaned = str(text or "").strip()
221
- if cleaned.startswith("```") and cleaned.endswith("```"):
222
- lines = cleaned.splitlines()
223
- if len(lines) >= 3:
224
- return "\n".join(lines[1:-1]).strip()
225
- return cleaned
226
-
227
-
228
- def dedupe_sorted_points(times: list[float], values: list[float]) -> tuple[list[float], list[float]]:
229
- if not times:
230
- return [], []
231
- pairs = sorted(zip(times, values), key=lambda item: (item[0], item[1]))
232
- out_times: list[float] = []
233
- out_values: list[float] = []
234
- cur_time = pairs[0][0]
235
- bucket: list[float] = []
236
- for time_val, progress_val in pairs:
237
- if abs(time_val - cur_time) > 1e-9:
238
- out_times.append(float(cur_time))
239
- out_values.append(float(sum(bucket) / len(bucket)))
240
- cur_time = time_val
241
- bucket = [float(progress_val)]
242
- else:
243
- bucket.append(float(progress_val))
244
- out_times.append(float(cur_time))
245
- out_values.append(float(sum(bucket) / len(bucket)))
246
- return out_times, out_values
247
-
248
-
249
- def parse_point_blocks(text: str) -> tuple[list[float], list[float]]:
250
- cleaned = strip_code_fence(text)
251
- if not cleaned:
252
- return [], []
253
-
254
- inline_matches = INLINE_POINT_RE.findall(cleaned)
255
- if inline_matches:
256
- return dedupe_sorted_points(
257
- [float(time_val) for time_val, _ in inline_matches],
258
- [float(progress_val) for _, progress_val in inline_matches],
259
- )
260
-
261
- blocks = re.split(r"(?=(?:Time|时间)[::]?\s*[0-9])", cleaned, flags=re.IGNORECASE)
262
- times: list[float] = []
263
- values: list[float] = []
264
- for block in blocks:
265
- block = block.strip()
266
- if not block:
267
- continue
268
- time_match = POINT_TIME_RE.search(block)
269
- progress_match = POINT_PROGRESS_LINE_RE.search(block)
270
- if time_match and progress_match:
271
- times.append(float(time_match.group(1)))
272
- values.append(float(progress_match.group(1)))
273
- return dedupe_sorted_points(times, values)
274
-
275
-
276
- def main() -> int:
277
- args = parse_args()
278
- model_path = args.model_path.resolve()
279
- if not model_path.exists():
280
- raise SystemExit(f"Missing model path: {model_path}")
281
-
282
- prompt, prompt_source, task_instruction, task_plan = resolve_prompt(args)
283
- video_path = resolve_video_path(args.video_path)
284
- video_path_for_output = maybe_relativize_to_model_root(video_path)
285
- output_path = args.output_jsonl.resolve() if args.output_jsonl is not None else None
286
- if output_path is not None:
287
- output_path.parent.mkdir(parents=True, exist_ok=True)
288
-
289
- InferRequest, PtEngine, RequestConfig = load_swift_runtime()
290
- engine = PtEngine(
291
- str(model_path),
292
- model_type="qwen3_moe_vl",
293
- max_batch_size=1,
294
- device_map="auto",
295
- )
296
-
297
- with tempfile.TemporaryDirectory(prefix="vlac_quick_start_frames_") as temp_dir_str:
298
- temp_dir = Path(temp_dir_str)
299
- frame_paths, video_stats, sampled_indices, sampled_timestamps_sec = extract_sampled_frame_paths(
300
- video_path,
301
- args.sample_hz,
302
- temp_dir=temp_dir,
303
- )
304
- request = InferRequest(
305
- messages=[
306
- {
307
- "role": "user",
308
- "content": [
309
- {"type": "video", "video": frame_paths},
310
- {"type": "text", "text": prompt},
311
- ],
312
- }
313
- ]
314
- )
315
- response = engine.infer(
316
- [request],
317
- RequestConfig(max_tokens=args.max_new_tokens, temperature=0.0, top_k=1, top_p=1.0),
318
- )[0].choices[0].message.content
319
-
320
- pred_point_times_sec, pred_point_progress = parse_point_blocks(response)
321
-
322
- output_row: dict[str, object] = {}
323
- if task_instruction is not None:
324
- output_row["task_instruction"] = task_instruction
325
- if task_plan:
326
- output_row["task_plan"] = task_plan
327
-
328
- output_row.update(
329
- {
330
- "video_path": video_path_for_output,
331
- "prompt_source": prompt_source,
332
- "prompt_variant": "chunk_all" if prompt_source != "raw_prompt" else "custom",
333
- "input_sample_hz": float(args.sample_hz),
334
- "input_frame_indices": sampled_indices,
335
- "input_timestamps_sec": sampled_timestamps_sec,
336
- "input_frame_count": len(sampled_indices),
337
- "decoded_video_stats": video_stats,
338
- "prompt": prompt,
339
- "response": response,
340
- "pred_curve_parse_ok": bool(pred_point_progress),
341
- "pred_curve_point_times_sec": pred_point_times_sec,
342
- "pred_curve_point_progress": pred_point_progress,
343
- }
344
- )
345
- if output_path is not None:
346
- output_path.write_text(json.dumps(output_row, ensure_ascii=False) + "\n", encoding="utf-8")
347
-
348
- if output_path is not None:
349
- print(f"video_path={video_path}")
350
- print(f"output_jsonl={output_path}")
351
- print(f"input_sample_hz={float(args.sample_hz):.4f}")
352
- print(f"input_frame_count={len(sampled_indices)}")
353
- print(f"pred_keypoint_count={len(pred_point_progress)}")
354
- print()
355
- print(response)
356
- return 0
357
-
358
-
359
- if __name__ == "__main__":
360
- raise SystemExit(main())