futurefantasy commited on
Commit
3b3fcfd
·
verified ·
1 Parent(s): f8c2464

Update quick start docs and public prediction video workflow

Browse files
README.md CHANGED
@@ -11,16 +11,16 @@ tags:
11
 
12
  # VLAC2-Qwen3VL-30B-A3B-Progress
13
 
14
- VLAC2-Qwen3VL-30B-A3B-Progress 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 dense progress curve.
15
 
16
- This release contains the inference-ready model weights, tokenizer and processor files, three bundled demo episodes, and a minimal local inference example.
17
 
18
  ## Model
19
 
20
  - Base model: `Qwen/Qwen3-VL-30B-A3B-Instruct`
21
  - Architecture: `Qwen3VLMoeForConditionalGeneration`
22
  - Recommended VLAC model type in local evaluation scripts: `qwen3_moe_vl`
23
- - Bundled quick start uses the `chunk_all` prompt and samples the input video at `2 Hz`
24
 
25
  ## Highlights
26
 
@@ -45,19 +45,46 @@ model = AutoModelForImageTextToText.from_pretrained(
45
 
46
  ## Quick Start
47
 
48
- Run one of the bundled examples from the model release root:
49
 
50
  ```bash
51
- python quick_start/run_example.py --example-id example_01
52
- python quick_start/run_example.py --example-id example_02
53
- python quick_start/run_example.py --example-id example_03
54
  ```
55
 
56
- For local VLAC benchmark scripts, use:
57
 
58
  ```bash
59
- MODEL_PATH=/path/to/VLAC2-Qwen3VL-30B-A3B-Progress
60
- MODEL_TYPE=qwen3_moe_vl
61
  ```
62
 
63
- More details are provided in `quick_start/README.md`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  # VLAC2-Qwen3VL-30B-A3B-Progress
13
 
14
+ VLAC2-Qwen3VL-30B-A3B-Progress 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.
15
 
16
+ This release contains the inference-ready model weights, tokenizer and processor files, three bundled demo episodes, reference prediction outputs, and minimal local inference utilities.
17
 
18
  ## Model
19
 
20
  - Base model: `Qwen/Qwen3-VL-30B-A3B-Instruct`
21
  - Architecture: `Qwen3VLMoeForConditionalGeneration`
22
  - Recommended VLAC model type in local evaluation scripts: `qwen3_moe_vl`
23
+ - Bundled quick start defaults to the `chunk_all` prompt and `2 Hz` video sampling
24
 
25
  ## Highlights
26
 
 
45
 
46
  ## Quick Start
47
 
48
+ Run progress inference on an arbitrary video:
49
 
50
  ```bash
51
+ python quick_start/run_example.py \
52
+ --video-path /path/to/video.mp4 \
53
+ --task-instruction "把洋葱放进快递箱里。"
54
  ```
55
 
56
+ Render a prediction-only preview video from the JSONL output:
57
 
58
  ```bash
59
+ python quick_start/render_prediction_video.py \
60
+ --input-jsonl quick_start/outputs/video.jsonl
61
  ```
62
 
63
+ ## Reproduce Bundled Examples
64
+
65
+ You can reproduce the bundled reference outputs directly from the release root.
66
+
67
+ Single example:
68
+
69
+ ```bash
70
+ python quick_start/run_example.py \
71
+ --model-path /path/to/VLAC2-Qwen3VL-30B-A3B-Progress \
72
+ --metadata-path examples/example_01/metadata.json \
73
+ --output-jsonl quick_start/outputs/example_01.jsonl
74
+
75
+ python quick_start/render_prediction_video.py \
76
+ --input-jsonl quick_start/outputs/example_01.jsonl \
77
+ --output-video quick_start/outputs/example_01_pred_progress.mp4
78
+ ```
79
+
80
+ Verify consistency with the bundled reference outputs:
81
+
82
+ ```bash
83
+ python quick_start/verify_prediction_consistency.py \
84
+ --pred-jsonl quick_start/outputs/example_01.jsonl \
85
+ --reference-jsonl examples/reference_outputs/example_01.jsonl \
86
+ --pred-video quick_start/outputs/example_01_pred_progress.mp4 \
87
+ --reference-video examples/reference_outputs/example_01_pred_progress.mp4
88
+ ```
89
+
90
+ More detailed usage is provided in `quick_start/README.md`.
examples/reference_outputs/example_01_pred_progress.mp4 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:95c5e7bb99377d2844d15d4772313e749e8cd5a176ab03ca19ebc54a5ab30a7a
3
- size 2088505
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ea6fb101f9823664cd7531ce5297f9490c40e6de7e5c1bb584b9d6fa5223301
3
+ size 2043716
examples/reference_outputs/example_02_pred_progress.mp4 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:013ffd92c2eeecebbbf748f2bc543b5ad62d9b52635b52e186c6d2d5bf48811e
3
- size 2603165
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:691fe25a519518ac2c24ba86c71ab7714d41f5616482cc0f079b874f897f679f
3
+ size 2546146
examples/reference_outputs/example_03_pred_progress.mp4 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:684ef749e15396234157b0d09b536f0020b48b34eefdc26f8feb9b7f81d218de
3
- size 1119731
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:211dfef38fd8e374f20b4bcf83a607fd6e3461fafb53a8228386995bbab118fe
3
+ size 1436259
quick_start/README.md CHANGED
@@ -1,7 +1,7 @@
1
  # Quick Start
2
 
3
  This quick start runs VLAC progress inference on an arbitrary input video.
4
- It samples the video at `2 Hz` by default, sends the sampled frames and prompt to the model, and writes one JSONL row with the raw response and parsed predicted progress points.
5
 
6
  ## Requirements
7
 
@@ -12,11 +12,12 @@ It samples the video at `2 Hz` by default, sends the sampled frames and prompt t
12
  - `decord`
13
  - `numpy`
14
  - `Pillow`
 
15
  - at least `1` GPU with enough memory for this `30B` checkpoint
16
 
17
- ## Run
18
 
19
- From the model release root:
20
 
21
  ```bash
22
  python quick_start/run_example.py \
@@ -24,7 +25,7 @@ python quick_start/run_example.py \
24
  --task-instruction "把洋葱放进快递箱里。"
25
  ```
26
 
27
- If you have a more detailed plan, pass it as text or file:
28
 
29
  ```bash
30
  python quick_start/run_example.py \
@@ -50,19 +51,70 @@ python quick_start/run_example.py \
50
  --task-instruction "把洋葱放进快递箱里。"
51
  ```
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ## Output
54
 
55
- By default the script writes:
56
 
57
  - `quick_start/outputs/<video_stem>.jsonl`
58
 
59
  Each output file contains one JSONL row with:
60
 
61
  - input video path
 
62
  - sampled frame indices and timestamps
63
  - decoded video statistics
64
  - prompt text
65
  - raw model response
66
  - parsed predicted key points: `时间 / 进度`
67
 
68
- No ground truth, alignment, metrics, or comparison plots are produced by this quick start script.
 
 
 
 
 
 
 
1
  # Quick Start
2
 
3
  This quick start runs VLAC progress inference on an arbitrary input video.
4
+ It samples the input video at `2 Hz` by default, sends the sampled frames and prompt to the model, writes one JSONL row with the raw response and parsed predicted progress points, and can optionally render a predicted-progress preview video with a separate helper script.
5
 
6
  ## Requirements
7
 
 
12
  - `decord`
13
  - `numpy`
14
  - `Pillow`
15
+ - `opencv-python` for preview video rendering
16
  - at least `1` GPU with enough memory for this `30B` checkpoint
17
 
18
+ ## Generic Inference
19
 
20
+ Run on an arbitrary input video with a task instruction:
21
 
22
  ```bash
23
  python quick_start/run_example.py \
 
25
  --task-instruction "把洋葱放进快递箱里。"
26
  ```
27
 
28
+ If you have a more detailed task plan, pass it as text or file:
29
 
30
  ```bash
31
  python quick_start/run_example.py \
 
51
  --task-instruction "把洋葱放进快递箱里。"
52
  ```
53
 
54
+ ## Use Bundled Examples
55
+
56
+ The bundled examples already provide `metadata.json` files with task information, so you can reproduce the reference outputs directly from the release root.
57
+
58
+ Single example:
59
+
60
+ ```bash
61
+ python quick_start/run_example.py \
62
+ --model-path /path/to/VLAC2-Qwen3VL-30B-A3B-Progress \
63
+ --metadata-path examples/example_01/metadata.json \
64
+ --output-jsonl quick_start/outputs/example_01.jsonl
65
+
66
+ python quick_start/render_prediction_video.py \
67
+ --input-jsonl quick_start/outputs/example_01.jsonl \
68
+ --output-video quick_start/outputs/example_01_pred_progress.mp4
69
+
70
+ python quick_start/verify_prediction_consistency.py \
71
+ --pred-jsonl quick_start/outputs/example_01.jsonl \
72
+ --reference-jsonl examples/reference_outputs/example_01.jsonl \
73
+ --pred-video quick_start/outputs/example_01_pred_progress.mp4 \
74
+ --reference-video examples/reference_outputs/example_01_pred_progress.mp4
75
+ ```
76
+
77
+ All three bundled examples:
78
+
79
+ ```bash
80
+ for ex in example_01 example_02 example_03; do
81
+ python quick_start/run_example.py \
82
+ --model-path /path/to/VLAC2-Qwen3VL-30B-A3B-Progress \
83
+ --metadata-path "examples/${ex}/metadata.json" \
84
+ --output-jsonl "quick_start/outputs/${ex}.jsonl"
85
+
86
+ python quick_start/render_prediction_video.py \
87
+ --input-jsonl "quick_start/outputs/${ex}.jsonl" \
88
+ --output-video "quick_start/outputs/${ex}_pred_progress.mp4"
89
+
90
+ python quick_start/verify_prediction_consistency.py \
91
+ --pred-jsonl "quick_start/outputs/${ex}.jsonl" \
92
+ --reference-jsonl "examples/reference_outputs/${ex}.jsonl" \
93
+ --pred-video "quick_start/outputs/${ex}_pred_progress.mp4" \
94
+ --reference-video "examples/reference_outputs/${ex}_pred_progress.mp4"
95
+ done
96
+ ```
97
+
98
  ## Output
99
 
100
+ By default the inference script writes:
101
 
102
  - `quick_start/outputs/<video_stem>.jsonl`
103
 
104
  Each output file contains one JSONL row with:
105
 
106
  - input video path
107
+ - optional metadata fields when `--metadata-path` is used
108
  - sampled frame indices and timestamps
109
  - decoded video statistics
110
  - prompt text
111
  - raw model response
112
  - parsed predicted key points: `时间 / 进度`
113
 
114
+ The preview video helper writes:
115
+
116
+ - `<jsonl_stem>_pred_progress.mp4`
117
+
118
+ The preview video shows the source video on top and predicted progress-so-far on the bottom.
119
+
120
+ No ground truth, alignment, metrics, or comparison plots are produced by the inference script or preview renderer.
quick_start/render_prediction_video.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 CHANGED
@@ -7,6 +7,7 @@ import os
7
  import re
8
  import tempfile
9
  from pathlib import Path
 
10
 
11
  import numpy as np
12
  from PIL import Image
@@ -37,8 +38,14 @@ def parse_args() -> argparse.Namespace:
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",
@@ -92,8 +99,9 @@ def parse_args() -> argparse.Namespace:
92
 
93
 
94
  def read_text_value(text_value: str | None, file_value: Path | None, *, field_name: str) -> str | None:
 
95
  if text_value is not None and file_value is not None:
96
- raise SystemExit(f"Only one of --{field_name} and --{field_name}-file may be set.")
97
  if file_value is not None:
98
  if not file_value.exists():
99
  raise SystemExit(f"Missing {field_name} file: {file_value}")
@@ -103,6 +111,29 @@ def read_text_value(text_value: str | None, file_value: Path | None, *, field_na
103
  return None
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def build_chunk_all_prompt(task_instruction: str, task_plan: str | None) -> str:
107
  task_instruction = task_instruction.strip()
108
  task_plan = (task_plan or "").strip()
@@ -116,23 +147,64 @@ def build_chunk_all_prompt(task_instruction: str, task_plan: str | None) -> str:
116
  )
117
 
118
 
119
- def resolve_prompt(args: argparse.Namespace) -> tuple[str, str, str | None, str | None]:
120
  prompt = read_text_value(args.prompt, args.prompt_file, field_name="prompt")
121
- task_plan = read_text_value(args.task_plan, args.task_plan_file, field_name="task-plan")
122
- task_instruction = args.task_instruction.strip() if args.task_instruction else None
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  if prompt:
125
  return prompt, "raw_prompt", task_instruction, task_plan
126
 
127
  if not task_instruction:
128
  raise SystemExit(
129
- "Either provide --prompt/--prompt-file, or provide --task-instruction "
130
- "with optional --task-plan/--task-plan-file."
131
  )
132
 
133
  return build_chunk_all_prompt(task_instruction, task_plan), "task_instruction_plus_plan", task_instruction, task_plan
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def compute_sampled_indices(num_frames: int, fps: float, sample_hz: float) -> tuple[list[int], list[float]]:
137
  if num_frames <= 0:
138
  raise SystemExit(f"Invalid decoded frame count: {num_frames}")
@@ -286,14 +358,14 @@ def parse_point_blocks(text: str) -> tuple[list[float], list[float]]:
286
 
287
  def main() -> int:
288
  args = parse_args()
289
- video_path = args.video_path.resolve()
290
  model_path = args.model_path.resolve()
291
- if not video_path.exists():
292
- raise SystemExit(f"Missing video: {video_path}")
293
  if not model_path.exists():
294
  raise SystemExit(f"Missing model path: {model_path}")
295
 
296
- prompt, prompt_source, task_instruction, task_plan = resolve_prompt(args)
 
 
297
  output_path = args.output_jsonl or (THIS_DIR / "outputs" / f"{video_path.stem}.jsonl")
298
  output_path.parent.mkdir(parents=True, exist_ok=True)
299
 
@@ -329,23 +401,36 @@ def main() -> int:
329
  )[0].choices[0].message.content
330
 
331
  pred_point_times_sec, pred_point_progress = parse_point_blocks(response)
332
- output_row = {
333
- "video_path": str(video_path),
334
- "task_instruction": task_instruction,
335
- "task_plan": task_plan,
336
- "prompt_source": prompt_source,
337
- "prompt_variant": "chunk_all",
338
- "input_sample_hz": float(args.sample_hz),
339
- "input_frame_indices": sampled_indices,
340
- "input_timestamps_sec": sampled_timestamps_sec,
341
- "input_frame_count": len(sampled_indices),
342
- "decoded_video_stats": video_stats,
343
- "prompt": prompt,
344
- "response": response,
345
- "pred_curve_parse_ok": bool(pred_point_progress),
346
- "pred_curve_point_times_sec": pred_point_times_sec,
347
- "pred_curve_point_progress": pred_point_progress,
348
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  output_path.write_text(json.dumps(output_row, ensure_ascii=False) + "\n", encoding="utf-8")
350
 
351
  print(f"video_path={video_path}")
 
7
  import re
8
  import tempfile
9
  from pathlib import Path
10
+ from typing import Any
11
 
12
  import numpy as np
13
  from PIL import Image
 
38
  parser.add_argument(
39
  "--video-path",
40
  type=Path,
41
+ default=None,
42
+ help="Path to the input video. Optional when --metadata-path is provided and metadata contains video_path.",
43
+ )
44
+ parser.add_argument(
45
+ "--metadata-path",
46
+ type=Path,
47
+ default=None,
48
+ help="Optional metadata JSON path. If provided, task fields and relative video_path can be inferred from it.",
49
  )
50
  parser.add_argument(
51
  "--task-instruction",
 
99
 
100
 
101
  def read_text_value(text_value: str | None, file_value: Path | None, *, field_name: str) -> str | None:
102
+ cli_name = field_name.replace("_", "-")
103
  if text_value is not None and file_value is not None:
104
+ raise SystemExit(f"Only one of --{cli_name} and --{cli_name}-file may be set.")
105
  if file_value is not None:
106
  if not file_value.exists():
107
  raise SystemExit(f"Missing {field_name} file: {file_value}")
 
111
  return None
112
 
113
 
114
+ def load_metadata(metadata_path: Path | None) -> tuple[Path | None, dict[str, Any] | None]:
115
+ if metadata_path is None:
116
+ return None, None
117
+ resolved = metadata_path.resolve()
118
+ if not resolved.exists():
119
+ raise SystemExit(f"Missing metadata JSON: {resolved}")
120
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
121
+ if not isinstance(payload, dict):
122
+ raise SystemExit(f"Metadata JSON must contain an object: {resolved}")
123
+ return resolved, payload
124
+
125
+
126
+ def extract_plan_text(task_instruction: str, task_description: str) -> str:
127
+ lines = [line.strip() for line in str(task_description or "").splitlines() if line.strip()]
128
+ if not lines:
129
+ return ""
130
+ first_line = lines[0].rstrip("::")
131
+ normalized_instruction = str(task_instruction or "").strip().rstrip("::")
132
+ if normalized_instruction and first_line == normalized_instruction:
133
+ lines = lines[1:]
134
+ return "\n".join(lines).strip()
135
+
136
+
137
  def build_chunk_all_prompt(task_instruction: str, task_plan: str | None) -> str:
138
  task_instruction = task_instruction.strip()
139
  task_plan = (task_plan or "").strip()
 
147
  )
148
 
149
 
150
+ def resolve_prompt(args: argparse.Namespace, metadata: dict[str, Any] | None) -> tuple[str, str, str | None, str | None]:
151
  prompt = read_text_value(args.prompt, args.prompt_file, field_name="prompt")
152
+ task_plan = read_text_value(args.task_plan, args.task_plan_file, field_name="task_plan")
153
+
154
+ metadata_task_instruction = None
155
+ metadata_task_plan = None
156
+ if metadata is not None:
157
+ metadata_task_instruction = str(metadata.get("task_instruction") or "").strip() or None
158
+ metadata_task_description = str(metadata.get("task_description") or "").strip()
159
+ if metadata_task_instruction and metadata_task_description:
160
+ metadata_task_plan = extract_plan_text(metadata_task_instruction, metadata_task_description) or None
161
+
162
+ task_instruction = args.task_instruction.strip() if args.task_instruction else metadata_task_instruction
163
+ if task_plan is None:
164
+ task_plan = metadata_task_plan
165
 
166
  if prompt:
167
  return prompt, "raw_prompt", task_instruction, task_plan
168
 
169
  if not task_instruction:
170
  raise SystemExit(
171
+ "Either provide --prompt/--prompt-file, or provide --task-instruction, "
172
+ "or provide --metadata-path with task_instruction."
173
  )
174
 
175
  return build_chunk_all_prompt(task_instruction, task_plan), "task_instruction_plus_plan", task_instruction, task_plan
176
 
177
 
178
+ def resolve_video_path(
179
+ video_path_arg: Path | None,
180
+ metadata: dict[str, Any] | None,
181
+ ) -> Path:
182
+ if video_path_arg is not None:
183
+ video_path = video_path_arg.resolve()
184
+ if not video_path.exists():
185
+ raise SystemExit(f"Missing video: {video_path}")
186
+ return video_path
187
+
188
+ if metadata is None or not metadata.get("video_path"):
189
+ raise SystemExit("Either provide --video-path, or provide --metadata-path with video_path.")
190
+
191
+ metadata_video_path = Path(str(metadata["video_path"]))
192
+ if metadata_video_path.is_absolute():
193
+ video_path = metadata_video_path
194
+ else:
195
+ video_path = (MODEL_ROOT / metadata_video_path).resolve()
196
+ if not video_path.exists():
197
+ raise SystemExit(f"Missing video resolved from metadata: {video_path}")
198
+ return video_path
199
+
200
+
201
+ def maybe_relativize_to_model_root(path: Path) -> str:
202
+ try:
203
+ return str(path.resolve().relative_to(MODEL_ROOT.resolve()))
204
+ except ValueError:
205
+ return str(path.resolve())
206
+
207
+
208
  def compute_sampled_indices(num_frames: int, fps: float, sample_hz: float) -> tuple[list[int], list[float]]:
209
  if num_frames <= 0:
210
  raise SystemExit(f"Invalid decoded frame count: {num_frames}")
 
358
 
359
  def main() -> int:
360
  args = parse_args()
361
+ metadata_path, metadata = load_metadata(args.metadata_path)
362
  model_path = args.model_path.resolve()
 
 
363
  if not model_path.exists():
364
  raise SystemExit(f"Missing model path: {model_path}")
365
 
366
+ prompt, prompt_source, task_instruction, task_plan = resolve_prompt(args, metadata)
367
+ video_path = resolve_video_path(args.video_path, metadata)
368
+ video_path_for_output = maybe_relativize_to_model_root(video_path)
369
  output_path = args.output_jsonl or (THIS_DIR / "outputs" / f"{video_path.stem}.jsonl")
370
  output_path.parent.mkdir(parents=True, exist_ok=True)
371
 
 
401
  )[0].choices[0].message.content
402
 
403
  pred_point_times_sec, pred_point_progress = parse_point_blocks(response)
404
+
405
+ output_row: dict[str, Any] = {}
406
+ if metadata is not None:
407
+ for key in ("example_id", "bucket", "global_episode_id", "task_instruction", "task_description"):
408
+ if key in metadata:
409
+ output_row[key] = metadata.get(key)
410
+ if "task_instruction" not in output_row and task_instruction is not None:
411
+ output_row["task_instruction"] = task_instruction
412
+ if task_plan:
413
+ output_row["task_plan"] = task_plan
414
+ if metadata_path is not None:
415
+ output_row["metadata_path"] = maybe_relativize_to_model_root(metadata_path)
416
+
417
+ output_row.update(
418
+ {
419
+ "video_path": video_path_for_output,
420
+ "prompt_source": prompt_source,
421
+ "prompt_variant": "chunk_all" if prompt_source != "raw_prompt" else "custom",
422
+ "input_sample_hz": float(args.sample_hz),
423
+ "input_frame_indices": sampled_indices,
424
+ "input_timestamps_sec": sampled_timestamps_sec,
425
+ "input_frame_count": len(sampled_indices),
426
+ "decoded_video_stats": video_stats,
427
+ "prompt": prompt,
428
+ "response": response,
429
+ "pred_curve_parse_ok": bool(pred_point_progress),
430
+ "pred_curve_point_times_sec": pred_point_times_sec,
431
+ "pred_curve_point_progress": pred_point_progress,
432
+ }
433
+ )
434
  output_path.write_text(json.dumps(output_row, ensure_ascii=False) + "\n", encoding="utf-8")
435
 
436
  print(f"video_path={video_path}")
quick_start/verify_prediction_consistency.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+
10
+ def parse_args() -> argparse.Namespace:
11
+ parser = argparse.ArgumentParser(
12
+ description="Verify that a newly generated prediction JSONL/video matches a reference output.",
13
+ )
14
+ parser.add_argument("--pred-jsonl", type=Path, required=True, help="Newly generated prediction JSONL.")
15
+ parser.add_argument("--reference-jsonl", type=Path, required=True, help="Reference prediction JSONL.")
16
+ parser.add_argument("--pred-video", type=Path, default=None, help="Optional newly generated preview video.")
17
+ parser.add_argument("--reference-video", type=Path, default=None, help="Optional reference preview video.")
18
+ parser.add_argument("--tolerance", type=float, default=1e-6, help="Numeric tolerance for float comparisons.")
19
+ return parser.parse_args()
20
+
21
+
22
+ def load_row(path: Path) -> dict:
23
+ lines = path.read_text(encoding="utf-8").splitlines()
24
+ if not lines:
25
+ raise SystemExit(f"Empty JSONL file: {path}")
26
+ return json.loads(lines[0])
27
+
28
+
29
+ def get_alias_value(row: dict, *keys: str):
30
+ for key in keys:
31
+ if key in row:
32
+ return row[key]
33
+ return None
34
+
35
+
36
+ def compare_numeric_lists(name: str, lhs, rhs, tolerance: float, errors: list[str]) -> None:
37
+ if lhs is None or rhs is None:
38
+ errors.append(f"{name}: missing on one side")
39
+ return
40
+ if len(lhs) != len(rhs):
41
+ errors.append(f"{name}: length mismatch {len(lhs)} != {len(rhs)}")
42
+ return
43
+ for idx, (a, b) in enumerate(zip(lhs, rhs, strict=True)):
44
+ if abs(float(a) - float(b)) > tolerance:
45
+ errors.append(f"{name}: mismatch at index {idx}: {a} != {b}")
46
+ return
47
+
48
+
49
+ def sha256_file(path: Path) -> str:
50
+ h = hashlib.sha256()
51
+ with path.open("rb") as f:
52
+ while True:
53
+ chunk = f.read(1024 * 1024)
54
+ if not chunk:
55
+ break
56
+ h.update(chunk)
57
+ return h.hexdigest()
58
+
59
+
60
+ def main() -> int:
61
+ args = parse_args()
62
+ pred_row = load_row(args.pred_jsonl.resolve())
63
+ ref_row = load_row(args.reference_jsonl.resolve())
64
+ errors: list[str] = []
65
+
66
+ pred_response = str(pred_row.get("response", "")).strip()
67
+ ref_response = str(ref_row.get("response", "")).strip()
68
+ if pred_response != ref_response:
69
+ errors.append("response: text mismatch")
70
+
71
+ pred_parse_ok = bool(pred_row.get("pred_curve_parse_ok"))
72
+ ref_parse_ok = bool(ref_row.get("pred_curve_parse_ok"))
73
+ if pred_parse_ok != ref_parse_ok:
74
+ errors.append(f"pred_curve_parse_ok: mismatch {pred_parse_ok} != {ref_parse_ok}")
75
+
76
+ compare_numeric_lists(
77
+ "pred_curve_point_times_sec",
78
+ pred_row.get("pred_curve_point_times_sec"),
79
+ ref_row.get("pred_curve_point_times_sec"),
80
+ args.tolerance,
81
+ errors,
82
+ )
83
+ compare_numeric_lists(
84
+ "pred_curve_point_progress",
85
+ pred_row.get("pred_curve_point_progress"),
86
+ ref_row.get("pred_curve_point_progress"),
87
+ args.tolerance,
88
+ errors,
89
+ )
90
+
91
+ compare_numeric_lists(
92
+ "input_frame_indices",
93
+ get_alias_value(pred_row, "input_frame_indices", "input_frame_indices_2hz"),
94
+ get_alias_value(ref_row, "input_frame_indices", "input_frame_indices_2hz"),
95
+ args.tolerance,
96
+ errors,
97
+ )
98
+ compare_numeric_lists(
99
+ "input_timestamps_sec",
100
+ get_alias_value(pred_row, "input_timestamps_sec", "input_timestamps_sec_2hz"),
101
+ get_alias_value(ref_row, "input_timestamps_sec", "input_timestamps_sec_2hz"),
102
+ args.tolerance,
103
+ errors,
104
+ )
105
+
106
+ pred_sample_hz = float(get_alias_value(pred_row, "input_sample_hz") or 0.0)
107
+ ref_sample_hz = float(get_alias_value(ref_row, "input_sample_hz") or 0.0)
108
+ if abs(pred_sample_hz - ref_sample_hz) > args.tolerance:
109
+ errors.append(f"input_sample_hz: mismatch {pred_sample_hz} != {ref_sample_hz}")
110
+
111
+ pred_frame_count = int(get_alias_value(pred_row, "input_frame_count", "input_frame_count_2hz") or 0)
112
+ ref_frame_count = int(get_alias_value(ref_row, "input_frame_count", "input_frame_count_2hz") or 0)
113
+ if pred_frame_count != ref_frame_count:
114
+ errors.append(f"input_frame_count: mismatch {pred_frame_count} != {ref_frame_count}")
115
+
116
+ if args.pred_video is not None or args.reference_video is not None:
117
+ if args.pred_video is None or args.reference_video is None:
118
+ errors.append("video comparison requires both --pred-video and --reference-video")
119
+ else:
120
+ pred_video = args.pred_video.resolve()
121
+ ref_video = args.reference_video.resolve()
122
+ if not pred_video.exists():
123
+ errors.append(f"Missing pred video: {pred_video}")
124
+ if not ref_video.exists():
125
+ errors.append(f"Missing reference video: {ref_video}")
126
+ if pred_video.exists() and ref_video.exists():
127
+ pred_sha = sha256_file(pred_video)
128
+ ref_sha = sha256_file(ref_video)
129
+ if pred_sha != ref_sha:
130
+ errors.append(f"preview_video: sha256 mismatch {pred_sha} != {ref_sha}")
131
+
132
+ if errors:
133
+ print("CONSISTENCY_CHECK=FAILED")
134
+ for item in errors:
135
+ print(item)
136
+ return 1
137
+
138
+ print("CONSISTENCY_CHECK=PASSED")
139
+ print(f"pred_jsonl={args.pred_jsonl.resolve()}")
140
+ print(f"reference_jsonl={args.reference_jsonl.resolve()}")
141
+ if args.pred_video is not None and args.reference_video is not None:
142
+ print(f"pred_video={args.pred_video.resolve()}")
143
+ print(f"reference_video={args.reference_video.resolve()}")
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())