multimodalart HF Staff commited on
Commit
a3b1781
·
verified ·
1 Parent(s): 1fd17e8

RynnValue-4B temporal distance demo

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/put_the_box_in_the_drawer_and_close_it.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/put_the_box_in_the_drawer_and_close_it.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ examples/so101_pink_lego_brick_into_box.mp4 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -4,50 +4,36 @@ emoji: 🤖
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 5.50.0
8
  app_file: app.py
9
- short_description: Predict remaining time to task completion in robot videos
10
  python_version: "3.12"
11
- startup_duration_timeout: 1h
12
  pinned: false
13
  license: apache-2.0
 
14
  models:
15
  - Alibaba-DAMO-Academy/RynnValue-4B
 
16
  ---
17
 
18
  # RynnValue-4B
19
 
20
  Interactive demo of [Alibaba-DAMO-Academy/RynnValue-4B](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-4B),
21
- a Qwen3-VL-based value model for robot manipulation.
22
-
23
- Upload a manipulation video plus the task instruction and the model returns:
24
-
25
- - a **remaining-time curve** — for every prefix of the video, the predicted
26
- minimum number of seconds until the task is complete — rendered alongside the
27
- video and compared with the true remaining video time;
28
- - a short **analysis**: what the agent is doing, whether the video matches the
29
- instruction, and whether the task was completed.
30
-
31
- ## How it works
32
-
33
- The demo follows the authors' reference implementation
34
- ([`rynn_infer/inference.py`](https://github.com/alibaba-damo-academy/RynnValue/blob/main/rynn_infer/inference.py)
35
- and `rynn_infer/plot_utils.py`):
36
-
37
- 1. the video is decoded once into a uniformly spaced frame pool;
38
- 2. prefix-uniform sampling — for each evaluated step `i`, `frames[0:i]` is
39
- resampled to *N* frames and the **last** `<value>` prediction slot is read
40
- out, so each score only conditions on frames seen so far;
41
- 3. a final generation pass over the full video produces the `Analysis` block;
42
- 4. the value curve is composited next to the video, mirroring `plot_utils.py`.
43
 
44
- The custom `pred_slot_isolated_eager` attention shipped with the checkpoint is
45
- enabled explicitly, exactly as the reference CLI does.
 
46
 
47
- Runs on ZeroGPU (bf16, ~10 GB of weights).
 
 
 
48
 
49
- ## Example asset
50
 
51
- `examples/put_the_box_in_the_drawer_and_close_it.mp4` is the official example
52
- clip from the [RynnValue repository](https://github.com/alibaba-damo-academy/RynnValue)
53
- (Apache-2.0), re-encoded to 640 px wide.
 
 
 
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.14.0
8
  app_file: app.py
 
9
  python_version: "3.12"
 
10
  pinned: false
11
  license: apache-2.0
12
+ short_description: Predict remaining time to task completion in robot videos
13
  models:
14
  - Alibaba-DAMO-Academy/RynnValue-4B
15
+ startup_duration_timeout: 45m
16
  ---
17
 
18
  # RynnValue-4B
19
 
20
  Interactive demo of [Alibaba-DAMO-Academy/RynnValue-4B](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-4B),
21
+ a value foundation model for robot manipulation built on RynnBrain (Qwen3-VL).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
+ Give it a manipulation video plus the task instruction and it predicts the **temporal distance** —
24
+ the remaining seconds until the task is complete — for a series of video prefixes, and generates a
25
+ short analysis (video description, whether the video matches the instruction, whether it succeeded).
26
 
27
+ The inference protocol mirrors the authors' reference script `rynn_infer/inference.py`
28
+ (prefix-uniform sampling, last prediction slot read out per prefix, `pred_slot_isolated_eager`
29
+ attention, one `generate()` pass for the Analysis block) and reproduces its side-by-side
30
+ "video + value trend" rendering.
31
 
32
+ ## Example assets
33
 
34
+ - `examples/put_the_box_in_the_drawer_and_close_it.mp4` the authors' own sample from
35
+ [github.com/alibaba-damo-academy/RynnValue](https://github.com/alibaba-damo-academy/RynnValue)
36
+ (`example/`), Apache-2.0, downscaled to 640×360.
37
+ - `examples/so101_pink_lego_brick_into_box.mp4` — episode 0 (side camera) of
38
+ [lerobot/svla_so101_pickplace](https://huggingface.co/datasets/lerobot/svla_so101_pickplace),
39
+ Apache-2.0.
app.py CHANGED
@@ -1,117 +1,161 @@
1
  """RynnValue-4B — robot-manipulation value model demo.
2
 
3
- Given a manipulation video and a task instruction, RynnValue predicts, for each
4
- prefix of the video, the *minimum remaining time (in seconds) until the task is
5
- complete*, plus a short textual analysis (description / instruction match /
6
- success).
7
-
8
- The inference path mirrors the official reference implementation
9
- (https://github.com/alibaba-damo-academy/RynnValue, `rynn_infer/inference.py`
10
- and `rynn_infer/plot_utils.py`, Apache-2.0): prefix-uniform sampling of the
11
- frames, one value read-out per prefix, and a synchronized trend plot rendered
12
- next to the video.
 
 
13
  """
14
 
15
  import os
16
 
17
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
18
 
19
- import spaces # noqa: E402 must precede any CUDA-touching import
20
 
21
- import io # noqa: E402
22
- import math # noqa: E402
23
  import re # noqa: E402
24
  import tempfile # noqa: E402
25
  import time # noqa: E402
26
- from typing import Any, Dict, List, Optional, Tuple # noqa: E402
27
 
28
- import numpy as np # noqa: E402
29
- import torch # noqa: E402
30
  import gradio as gr # noqa: E402
31
  import imageio.v2 as imageio # noqa: E402
32
  import matplotlib # noqa: E402
33
 
34
  matplotlib.use("Agg")
35
  import matplotlib.pyplot as plt # noqa: E402
 
 
36
  from PIL import Image, ImageDraw, ImageFont # noqa: E402
37
  from transformers import AutoConfig, AutoModel, AutoProcessor # noqa: E402
38
 
39
- # --------------------------------------------------------------------------- #
40
- # Model
41
- # --------------------------------------------------------------------------- #
42
-
43
  MODEL_ID = "Alibaba-DAMO-Academy/RynnValue-4B"
44
- DTYPE = torch.bfloat16
45
 
46
- _config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
47
- # The exported checkpoint doesn't persist the attention implementation, so force
48
- # the custom prediction-slot isolation attention (as the reference CLI does).
49
- _config._attn_implementation = "pred_slot_isolated_eager"
 
 
 
 
 
50
 
51
  model = AutoModel.from_pretrained(
52
  MODEL_ID,
53
- config=_config,
54
  trust_remote_code=True,
55
- torch_dtype=DTYPE,
56
  )
57
- # The value heads are built in fp32 regardless of `torch_dtype`; the reference
58
- # CLI casts the whole model with `.to(device=..., dtype=...)`, so do the same or
59
- # the head matmuls hit "mat1 and mat2 must have the same dtype".
60
- model = model.eval().to(device="cuda", dtype=DTYPE)
61
 
62
  processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
63
  tokenizer = processor.tokenizer
64
  EOS_TOKEN_ID = tokenizer.convert_tokens_to_ids("<|im_end|>")
65
 
66
- # --------------------------------------------------------------------------- #
67
- # Defaults / limits
68
- # --------------------------------------------------------------------------- #
69
 
70
- # Embodiment / camera phrasings taken from the authors' robometer constants
71
- # (robometer/robometer/configs/constants.py) so they stay in-distribution.
72
  DEFAULT_ROBOT = "a Franka single-arm robot"
73
  DEFAULT_CAMERA = "the main camera"
74
-
75
- DEFAULT_NUM_FRAMES = 16 # frames per prefix fed to the model
76
- DEFAULT_NUM_STEPS = 16 # prefixes evaluated along the video
77
- DEFAULT_MAX_SIDE = 448 # longest side of a model input frame
78
  DEFAULT_MAX_NEW_TOKENS = 128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- POOL_SIZE = 128 # decoded frames kept as the model frame pool
81
- DISPLAY_MAX_SIDE = 640 # longest side of the rendered output video
82
- MAX_RENDER_FRAMES = 480 # cap on frames written to the output video
83
- MAX_DECODE_FRAMES = 3600 # hard cap on decoded frames (~2 min @ 30 fps)
84
-
85
-
86
- # --------------------------------------------------------------------------- #
87
- # Video helpers
88
- # --------------------------------------------------------------------------- #
89
-
90
-
91
- def _even(x: int) -> int:
92
- x = int(round(x))
93
- return x if x % 2 == 0 else x + 1
94
 
95
 
96
- def _fit(size: Tuple[int, int], max_side: int) -> Tuple[int, int]:
97
- w, h = size
 
 
 
98
  if max(w, h) <= max_side:
99
- return _even(w), _even(h)
100
- scale = max_side / float(max(w, h))
101
- return _even(w * scale), _even(h * scale)
102
-
103
-
104
- def _probe(video_path: str) -> Dict[str, Any]:
105
- reader = imageio.get_reader(video_path, format="FFMPEG")
106
- try:
107
- meta = dict(reader.get_meta_data())
108
- finally:
109
- reader.close()
110
- return meta
111
 
112
 
113
- def sample_frame_indices(total: int, num_frames: int) -> List[int]:
114
- """Uniformly pick ``num_frames`` indices from ``total`` frames (reference logic)."""
115
  if num_frames <= 0 or num_frames >= total:
116
  return list(range(total))
117
  if num_frames == 1:
@@ -120,142 +164,62 @@ def sample_frame_indices(total: int, num_frames: int) -> List[int]:
120
  return [int(round(j * step)) for j in range(num_frames)]
121
 
122
 
123
- def build_frame_pool(video_path: str, max_image_side: int = DEFAULT_MAX_SIDE) -> Dict[str, Any]:
124
- """Decode a video once and keep a uniformly-spaced pool of resized frames."""
125
- if not video_path:
126
- raise gr.Error("Please upload a video first.")
127
-
128
- meta = _probe(video_path)
129
- src_fps = float(meta.get("fps") or 30.0)
130
- if not math.isfinite(src_fps) or src_fps <= 0:
131
- src_fps = 30.0
132
- src_size = tuple(meta.get("source_size") or meta.get("size") or (640, 480))
133
- duration = float(meta.get("duration") or 0.0)
134
- n_est = max(1, int(round(duration * src_fps)))
135
-
136
- pool_px = _fit(src_size, int(max_image_side))
137
- stride = max(1, math.ceil(n_est / POOL_SIZE))
138
-
139
- frames: List[Image.Image] = []
140
- kept: List[int] = []
141
- last_frame = None
142
- n_total = 0
143
-
144
- reader = imageio.get_reader(video_path, format="FFMPEG", size=pool_px)
145
- try:
146
- for i, frame in enumerate(reader):
147
- if i >= MAX_DECODE_FRAMES:
148
- break
149
- n_total = i + 1
150
- last_frame = frame
151
- if i % stride == 0:
152
- frames.append(Image.fromarray(frame).convert("RGB"))
153
- kept.append(i)
154
- finally:
155
- reader.close()
156
 
157
- if not frames:
158
- raise gr.Error("Could not decode any frames from that video.")
 
159
 
160
- if kept[-1] != n_total - 1 and last_frame is not None:
161
- frames.append(Image.fromarray(last_frame).convert("RGB"))
162
- kept.append(n_total - 1)
163
 
164
- if len(frames) > POOL_SIZE:
165
- sel = np.linspace(0, len(frames) - 1, POOL_SIZE).astype(int)
166
- frames = [frames[k] for k in sel]
167
- kept = [int(kept[k]) for k in sel]
168
 
169
  return {
170
- "video_path": video_path,
171
- "pool": frames,
172
- "pool_idx": kept,
173
- "n_total": int(n_total),
174
- "src_fps": float(src_fps),
175
- "src_size": (int(src_size[0]), int(src_size[1])),
176
  }
177
 
178
 
179
- def _nearest_pool_pos(pool_idx: np.ndarray, j: int) -> int:
180
- p = int(np.searchsorted(pool_idx, j))
181
- if p <= 0:
182
- return 0
183
- if p >= len(pool_idx):
184
- return len(pool_idx) - 1
185
- return p if abs(pool_idx[p] - j) < abs(pool_idx[p - 1] - j) else p - 1
186
-
187
 
188
- # --------------------------------------------------------------------------- #
189
- # Trend rendering (adapted from rynn_infer/plot_utils.py, Apache-2.0)
190
- # --------------------------------------------------------------------------- #
191
 
 
 
 
 
 
192
 
193
- def _fig_to_pil(fig, size=None) -> Image.Image:
194
- buf = io.BytesIO()
195
- fig.savefig(buf, format="png")
196
- buf.seek(0)
197
- img = Image.open(buf).convert("RGB")
198
- buf.close()
199
- plt.close(fig)
200
- if size is not None:
201
- img = img.resize(size)
202
- return img
203
 
 
 
204
 
205
- def _format_time(seconds: float) -> str:
206
- minutes = int(seconds // 60)
207
- seconds_int = int(seconds % 60)
208
- millis = int((seconds - int(seconds)) * 1000)
209
- return f"{minutes:02d}:{seconds_int:02d}.{millis:03d}"
210
-
211
-
212
- def _make_trend_plot(
213
- value,
214
- current_idx: int,
215
- fps: float,
216
- size=(400, 300),
217
- title: str = "Remaining Time (s)",
218
- task_title: Optional[str] = None,
219
- baseline_label: str = "ground-truth remaining",
220
- sampled_indices=None,
221
- ) -> Image.Image:
222
  w, h = size
223
  dpi = 100
224
- fig, ax1 = plt.subplots(figsize=(max(w / dpi, 1.0), max(h / dpi, 1.0)), dpi=dpi,
225
- constrained_layout=True)
226
-
227
- small_mode = (w < 320 or h < 260)
228
- medium_mode = (w < 420 or h < 320)
229
- if small_mode:
230
- title_fs, label_fs, tick_fs, legend_fs = 9, 8, 7, 7
231
- line_w, marker_s1, marker_s2, show_legend = 1.5, 20, 16, False
232
- elif medium_mode:
233
- title_fs, label_fs, tick_fs, legend_fs = 10, 9, 8, 8
234
- line_w, marker_s1, marker_s2, show_legend = 1.8, 24, 20, True
235
- else:
236
- title_fs, label_fs, tick_fs, legend_fs = 12, 10, 9, 9
237
- line_w, marker_s1, marker_s2, show_legend = 2.0, 30, 25, True
238
-
239
- if sampled_indices is not None:
240
- x = np.asarray(sampled_indices, dtype=float)
241
- total_frames = int(x[-1]) + 1
242
- remaining_curve = (x[-1] - x) / float(fps)
243
- else:
244
- x = np.arange(len(value))
245
- total_frames = len(value)
246
- remaining_curve = (len(value) - 1 - np.arange(len(value))) / float(fps)
247
- y = np.asarray(value, dtype=float)
248
-
249
- ax1.plot(x[: current_idx + 1], y[: current_idx + 1], color="tab:blue",
250
- linewidth=line_w, label="predicted")
251
- ax1.scatter([x[current_idx]], [y[current_idx]], color="red", s=marker_s1,
252
- zorder=3, label="current")
253
- ax1.set_xlabel("Frame", fontsize=label_fs)
254
- ax1.set_ylabel("Predicted (s)", color="tab:blue", fontsize=label_fs)
255
- ax1.tick_params(axis="x", labelsize=tick_fs)
256
- ax1.tick_params(axis="y", labelcolor="tab:blue", labelsize=tick_fs)
257
  ax1.grid(True, alpha=0.3)
258
- ax1.set_xlim(0, max(total_frames - 1, 1))
259
 
260
  y_min, y_max = float(np.min(y)), float(np.max(y))
261
  if y_min == y_max:
@@ -265,255 +229,214 @@ def _make_trend_plot(
265
  ax1.set_ylim(y_min - margin, y_max + margin)
266
 
267
  ax2 = ax1.twinx()
268
- ax2.plot(x, remaining_curve, color="green", linestyle="--", linewidth=line_w,
269
- label=baseline_label)
270
- ax2.scatter([x[current_idx]], [remaining_curve[current_idx]], color="green",
271
- s=marker_s2, zorder=3)
272
- ax2.set_ylabel("Remain (s)" if small_mode else "Video remaining (s)",
273
- color="green", fontsize=label_fs)
274
- ax2.tick_params(axis="y", labelcolor="green", labelsize=tick_fs)
275
-
276
- rt_min, rt_max = float(np.min(remaining_curve)), float(np.max(remaining_curve))
277
  if rt_min == rt_max:
278
  rt_min -= 1.0
279
  rt_max += 1.0
280
  rt_margin = 0.05 * (rt_max - rt_min)
281
  ax2.set_ylim(rt_min - rt_margin, rt_max + rt_margin)
282
 
283
- if small_mode or task_title is None:
284
- full_title = title
285
- else:
286
- short = task_title if len(task_title) <= 42 else task_title[:41] + "…"
287
- full_title = f"{short}\n{title}"
288
- ax1.set_title(full_title, fontsize=title_fs)
289
-
290
- if show_legend:
291
- lines1, labels1 = ax1.get_legend_handles_labels()
292
- lines2, labels2 = ax2.get_legend_handles_labels()
293
- ax1.legend(lines1 + lines2, labels1 + labels2, loc="best", fontsize=legend_fs)
294
-
295
- return _fig_to_pil(fig, size=size)
 
 
 
 
296
 
297
 
298
- def _load_font(size: int):
299
  try:
300
  return ImageFont.load_default(size=size)
301
  except TypeError: # Pillow < 10.1
302
  return ImageFont.load_default()
303
 
304
 
305
- def _draw_overlay_text(img: Image.Image, lines, font, xy=(10, 10), line_spacing=6) -> Image.Image:
 
306
  draw = ImageDraw.Draw(img)
307
- x, y = xy
308
  for line in lines:
309
- draw.text((x, y), line, fill=(255, 235, 60), font=font,
310
- stroke_width=2, stroke_fill=(0, 0, 0))
311
- bbox = draw.textbbox((x, y), line, font=font, stroke_width=2)
312
- y += (bbox[3] - bbox[1]) + line_spacing
 
313
  return img
314
 
315
 
316
- def _ceil_to_multiple(x: int, multiple: int) -> int:
317
- return ((x + multiple - 1) // multiple) * multiple
318
 
319
 
320
- def render_trend_video(
321
- state: Dict[str, Any],
322
- values: List[float],
323
- sampled_indices: List[int],
324
- instruction: str,
325
- ) -> str:
326
- """Composite the source video with a synchronized value-trend plot."""
327
- video_path = state["video_path"]
328
- n_total = int(state["n_total"])
329
- src_fps = float(state["src_fps"])
330
 
331
- disp_w, disp_h = _fit(state["src_size"], DISPLAY_MAX_SIDE)
332
- base_h = max(disp_h, 240)
333
- base_w = int(round(disp_w * base_h / disp_h)) if disp_h else disp_w
334
 
335
- plot_w = max(260, min(int(base_w * 0.45), 520))
336
- plot_h = max(240, base_h)
337
-
338
- plots = [
339
- _make_trend_plot(
340
- values, k, src_fps, size=(plot_w, plot_h),
341
- title="Remaining Time (s)", task_title=instruction,
342
- sampled_indices=sampled_indices,
343
- )
344
- for k in range(len(values))
345
- ]
346
- if plot_h != base_h:
347
  plots = [p.resize((plot_w, base_h)) for p in plots]
348
 
349
- sampled = np.asarray(sampled_indices)
350
- stride = max(1, math.ceil(n_total / MAX_RENDER_FRAMES))
351
- out_fps = max(4.0, src_fps / stride)
352
 
 
353
  canvas_w = _ceil_to_multiple(base_w + plot_w, 16)
354
  canvas_h = _ceil_to_multiple(base_h, 16)
355
 
356
- font = _load_font(18)
357
- out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
358
- writer = imageio.get_writer(out_path, fps=out_fps, codec="libx264",
359
- macro_block_size=16, quality=7)
360
- reader = imageio.get_reader(video_path, format="FFMPEG", size=(disp_w, disp_h))
 
 
 
361
  try:
362
- for i, frame in enumerate(reader):
363
- if i >= n_total:
364
- break
365
- if i % stride:
366
- continue
367
- img = Image.fromarray(frame).convert("RGB")
368
- if img.size != (base_w, base_h):
369
- img = img.resize((base_w, base_h))
370
-
371
- k = max(0, int(np.searchsorted(sampled, i, side="right")) - 1)
372
- remaining = (n_total - 1 - i) / src_fps
373
- img = _draw_overlay_text(
374
  img,
375
  [
376
- f"predicted remaining: {values[k]:.2f}s",
377
- f"video remaining: {_format_time(remaining)}",
 
378
  ],
379
  font,
380
  )
381
-
382
  canvas = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
383
- canvas.paste(img, (0, 0))
384
- canvas.paste(plots[k], (base_w, 0))
385
  writer.append_data(np.asarray(canvas))
386
  finally:
387
- reader.close()
388
  writer.close()
389
- return out_path
390
 
391
 
392
- # --------------------------------------------------------------------------- #
393
  # Inference
394
- # --------------------------------------------------------------------------- #
395
-
396
- _DESCRIPTION_RE = re.compile(r"-\s*Video Description:\s*(.+)", re.IGNORECASE)
397
- _MATCH_RE = re.compile(r"-\s*Match:\s*(Yes|No)", re.IGNORECASE)
398
- _SUCCESS_RE = re.compile(r"-\s*Success:\s*(Yes|No)", re.IGNORECASE)
399
-
400
-
401
- def parse_analysis(text: str) -> Dict[str, Optional[str]]:
402
- def _first(pattern):
403
- m = pattern.search(text)
404
- return m.group(1).strip() if m else None
405
-
406
- return {
407
- "description": _first(_DESCRIPTION_RE),
408
- "match": _first(_MATCH_RE),
409
- "success": _first(_SUCCESS_RE),
410
- }
411
-
412
-
413
- def _batch_size_for(num_frames: int, frame_px: Tuple[int, int]) -> int:
414
- """Pick a batch size that keeps the eager attention matrices bounded."""
415
- w, h = frame_px
416
- tokens_per_frame = max(1, (w * h) // 1024) # patch 16, merge 2
417
- seq = num_frames * (tokens_per_frame + 24) + 160
418
- # 32 heads x 4 bytes, ~3 GB transient budget for the attention weights.
419
- budget = 3.0e9 / (128.0 * float(seq) ** 2)
420
- return int(max(1, min(4, math.floor(budget))))
421
-
422
-
423
- def _estimate_duration(
424
- state=None,
425
- instruction: str = "",
426
- robot_description: str = DEFAULT_ROBOT,
427
- camera_description: str = DEFAULT_CAMERA,
428
- num_frames: int = DEFAULT_NUM_FRAMES,
429
- num_steps: int = DEFAULT_NUM_STEPS,
430
- max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
431
  *args,
432
  **kwargs,
433
- ) -> int:
434
  try:
435
- n_f, n_s = int(num_frames), int(num_steps)
436
- n_t = int(max_new_tokens)
437
- except Exception:
438
- n_f, n_s, n_t = DEFAULT_NUM_FRAMES, DEFAULT_NUM_STEPS, DEFAULT_MAX_NEW_TOKENS
439
- return int(min(240, 45 + 0.30 * n_f * n_s + 0.10 * n_t))
440
-
441
-
442
- @spaces.GPU(duration=_estimate_duration)
443
- def predict_values(
444
- state: Dict[str, Any],
445
- instruction: str,
446
- robot_description: str = DEFAULT_ROBOT,
447
- camera_description: str = DEFAULT_CAMERA,
448
- num_frames: int = DEFAULT_NUM_FRAMES,
449
- num_steps: int = DEFAULT_NUM_STEPS,
450
- max_new_tokens: int = DEFAULT_MAX_NEW_TOKENS,
451
- ) -> Tuple[Dict[str, Any], str, str]:
452
- """Predict the remaining-time value curve and the analysis for a video.
453
-
454
- Args:
455
- state: decoded frame-pool produced by the preparation step.
456
- instruction: the task the robot is supposed to accomplish.
457
- robot_description: embodiment phrase for the prompt meta block.
458
- camera_description: camera viewpoint phrase for the prompt meta block.
459
- num_frames: frames uniformly resampled from each video prefix.
460
- num_steps: number of prefixes evaluated along the video.
461
- max_new_tokens: token budget for the generated Analysis block.
462
-
463
- Returns:
464
- A payload with the value curve, a markdown analysis, and a status line.
465
- """
466
- if not state:
467
- raise gr.Error("Please upload a video first.")
468
  instruction = (instruction or "").strip()
469
  if not instruction:
470
- raise gr.Error("Please enter a task instruction.")
471
 
472
  robot_description = (robot_description or "").strip() or None
473
  camera_description = (camera_description or "").strip() or None
474
  if robot_description is None and camera_description is None:
 
 
475
  robot_description = DEFAULT_ROBOT
476
 
477
- num_frames = int(max(2, min(32, int(num_frames))))
478
- num_steps = int(max(4, min(32, int(num_steps))))
479
- max_new_tokens = int(max(16, min(256, int(max_new_tokens))))
 
480
 
481
- pool: List[Image.Image] = state["pool"]
482
- pool_idx = np.asarray(state["pool_idx"])
483
- n_total = int(state["n_total"])
484
- device = torch.device("cuda")
485
 
486
- t0 = time.perf_counter()
487
 
488
- # Prefix-uniform sampling (mirrors robometer's compute_episode_progress):
489
- # for each evaluated step, frames[0:end] are resampled to `num_frames` and a
490
- # single value (the last prediction slot) is read out, so each score only
491
- # conditions on frames seen so far.
492
- eval_indices = sample_frame_indices(n_total, num_steps)
493
-
494
- def build_prefix_sample(end_idx: int):
495
- wanted = np.linspace(0, end_idx, num_frames, dtype=int)
496
- images = [pool[_nearest_pool_pos(pool_idx, int(j))] for j in wanted]
497
  return processor.process_episode(
498
  instruction=instruction,
499
- images=images,
500
  robot_description=robot_description,
501
  camera_description=camera_description,
502
  )
503
 
504
  def run_batch(samples):
505
- batch = dict(
506
- input_ids=torch.cat([s["input_ids"] for s in samples], dim=0).to(device).long(),
507
- attention_mask=torch.cat([s["attention_mask"] for s in samples], dim=0).to(device).long(),
508
- pixel_values=torch.cat(
509
- [s["pixel_values"].flatten(0, 1) for s in samples], dim=0
510
- ).to(device),
511
- image_grid_thw=torch.cat(
512
- [s["image_grid_thw"].flatten(0, 1) for s in samples], dim=0
513
- ).to(device).long(),
 
 
 
514
  )
515
  with torch.inference_mode():
516
- outputs = model(**batch)
517
  pred = outputs.value.pred_value
518
  if pred.dim() == 2 and pred.shape[0] == 1:
519
  pred = pred.reshape(len(samples), -1)
@@ -525,28 +448,30 @@ def predict_values(
525
  pred = pred[:, 0]
526
  return pred.float().reshape(-1).tolist()
527
 
528
- batch_size = _batch_size_for(num_frames, pool[0].size)
529
- values: List[float] = []
530
  final_sample = None
531
- pending = []
532
  for step, end_idx in enumerate(eval_indices):
533
  sample = build_prefix_sample(end_idx)
534
- if step == len(eval_indices) - 1:
535
  final_sample = sample
536
- pending.append(sample)
537
- if len(pending) >= batch_size or step == len(eval_indices) - 1:
538
- values.extend(run_batch(pending))
539
- pending = []
540
- t_value = time.perf_counter()
541
-
542
- # Analysis pass over the final prefix (the whole video, uniformly sampled).
543
- input_ids = final_sample["input_ids"].to(device).long()
 
 
 
544
  with torch.inference_mode():
545
  gen_out = model.generate(
546
  input_ids=input_ids,
547
- attention_mask=final_sample["attention_mask"].to(device).long(),
548
- pixel_values=final_sample["pixel_values"].flatten(0, 1).to(device),
549
- image_grid_thw=final_sample["image_grid_thw"].flatten(0, 1).to(device).long(),
550
  max_new_tokens=max_new_tokens,
551
  do_sample=False,
552
  num_beams=1,
@@ -554,190 +479,149 @@ def predict_values(
554
  pad_token_id=EOS_TOKEN_ID,
555
  use_cache=True,
556
  )
557
- raw_text = tokenizer.decode(gen_out[0, input_ids.shape[1]:], skip_special_tokens=True)
558
- analysis = parse_analysis(raw_text)
559
- t_end = time.perf_counter()
560
-
561
- print(
562
- f"[timing] steps={len(eval_indices)} frames/prefix={num_frames} "
563
- f"batch={batch_size} value={t_value - t0:.1f}s analysis={t_end - t_value:.1f}s "
564
- f"total={t_end - t0:.1f}s",
565
- flush=True,
566
- )
567
 
568
- def _flag(v):
 
 
 
 
569
  if v is None:
570
  return "—"
571
  return "✅ Yes" if v.lower() == "yes" else "❌ No"
572
 
573
- md = "\n".join(
 
574
  [
575
- f"**Predicted remaining time at the first frame:** `{values[0]:.2f} s` ",
576
- f"**Predicted remaining time at the last frame:** `{values[-1]:.2f} s` ",
577
- f"**Actual video length:** `{(n_total - 1) / float(state['src_fps']):.2f} s`",
578
  "",
579
- f"**Video description:** {analysis['description'] or '—'}",
 
580
  "",
581
- f"**Matches the instruction:** {_flag(analysis['match'])}",
 
 
582
  "",
583
- f"**Task completed:** {_flag(analysis['success'])}",
 
584
  ]
585
  )
 
586
 
587
- payload = {"values": values, "eval_indices": eval_indices, "raw": raw_text}
588
- status = (
589
- f"Scored {len(eval_indices)} prefixes ({num_frames} frames each) in "
590
- f"{t_end - t0:.1f}s — rendering the trend video…"
591
- )
592
- return payload, md, status
593
-
594
-
595
- # --------------------------------------------------------------------------- #
596
- # Gradio handlers
597
- # --------------------------------------------------------------------------- #
598
-
599
-
600
- def prepare_video(video_path: str, max_image_side: int = DEFAULT_MAX_SIDE):
601
- """Decode the uploaded video into a frame pool for the model."""
602
- state = build_frame_pool(video_path, int(max_image_side))
603
- status = (
604
- f"Decoded {state['n_total']} frames @ {state['src_fps']:.1f} fps "
605
- f"({(state['n_total'] - 1) / state['src_fps']:.1f}s) — running RynnValue…"
606
- )
607
- return state, status
608
 
609
-
610
- def render_result(state: Dict[str, Any], payload: Dict[str, Any], instruction: str):
611
- """Render the annotated video with the synchronized value curve."""
612
- t0 = time.perf_counter()
613
- path = render_trend_video(state, payload["values"], payload["eval_indices"], instruction)
614
- print(f"[timing] render={time.perf_counter() - t0:.1f}s", flush=True)
615
- return path, "Done."
616
-
617
-
618
- def run_all(video: str, instruction: str):
619
- """Analyse a robot manipulation video against a task instruction.
620
-
621
- Args:
622
- video: path to the manipulation video.
623
- instruction: the task the robot is supposed to accomplish.
624
-
625
- Returns:
626
- The annotated video with the remaining-time curve, and the text analysis.
627
- """
628
- state, _ = prepare_video(video, DEFAULT_MAX_SIDE)
629
- payload, md, _ = predict_values(
630
- state,
631
- instruction,
632
- DEFAULT_ROBOT,
633
- DEFAULT_CAMERA,
634
- DEFAULT_NUM_FRAMES,
635
- DEFAULT_NUM_STEPS,
636
- DEFAULT_MAX_NEW_TOKENS,
637
- )
638
- path, _ = render_result(state, payload, instruction)
639
- return path, md
640
-
641
-
642
- # --------------------------------------------------------------------------- #
643
  # UI
644
- # --------------------------------------------------------------------------- #
645
-
646
- CSS = """
647
- #col-container { max-width: 1180px; margin: 0 auto; }
648
- .dark .gradio-container { color: var(--body-text-color); }
649
- """
650
 
651
- INTRO = """# RynnValue-4B — how much longer until the robot is done?
 
652
 
653
- [RynnValue](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-4B) is a
654
- vision-language value model for robot manipulation. Given a video and a task
655
- instruction it predicts the **minimum remaining time until the task is complete**
656
- for every point in the video, and writes a short analysis of what it sees.
657
 
658
- [model](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-4B) ·
659
- [code](https://github.com/alibaba-damo-academy/RynnValue) ·
660
- [project page](https://alibaba-damo-academy.github.io/RynnValue.github.io/)
661
- """
662
-
663
- EXAMPLE_VIDEO = "examples/put_the_box_in_the_drawer_and_close_it.mp4"
664
 
665
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="RynnValue-4B") as demo:
666
- state = gr.State()
667
- payload_state = gr.State()
668
 
669
- with gr.Column(elem_id="col-container"):
670
- gr.Markdown(INTRO)
 
 
 
671
 
672
- with gr.Row():
673
- with gr.Column(scale=1):
674
- video_in = gr.Video(label="Manipulation video", height=300)
675
- instruction_in = gr.Textbox(
676
- label="Task instruction",
677
- placeholder="Put the box in the drawer and close it",
678
- lines=2,
679
- )
680
- run_btn = gr.Button("Analyse", variant="primary")
681
- with gr.Column(scale=1):
682
- video_out = gr.Video(label="Value curve", height=300, autoplay=True)
683
- analysis_out = gr.Markdown(label="Analysis")
684
 
685
- status_out = gr.Markdown("")
 
686
 
687
- with gr.Accordion("Advanced settings", open=False):
688
- with gr.Row():
689
- robot_in = gr.Textbox(label="Robot description", value=DEFAULT_ROBOT)
690
- camera_in = gr.Textbox(label="Camera description", value=DEFAULT_CAMERA)
691
- with gr.Row():
692
- frames_in = gr.Slider(4, 32, value=DEFAULT_NUM_FRAMES, step=2,
693
- label="Frames per prefix")
694
- steps_in = gr.Slider(4, 32, value=DEFAULT_NUM_STEPS, step=1,
695
- label="Prefixes evaluated (curve resolution)")
696
  with gr.Row():
697
- side_in = gr.Slider(320, 640, value=DEFAULT_MAX_SIDE, step=64,
698
- label="Max frame side (px)")
699
- tokens_in = gr.Slider(32, 256, value=DEFAULT_MAX_NEW_TOKENS, step=16,
700
- label="Max new tokens (analysis)")
701
-
702
- gr.Examples(
703
- examples=[
704
- [EXAMPLE_VIDEO, "Put the box in the drawer and close it"],
705
- [EXAMPLE_VIDEO, "Pick up the apple and put it on the plate"],
706
- ],
707
- inputs=[video_in, instruction_in],
708
- outputs=[video_out, analysis_out],
709
- fn=run_all,
710
- cache_examples=True,
711
- cache_mode="lazy",
712
- label="Examples (the second one deliberately mismatches the video)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  )
714
 
715
- # Single-call entry point for the API / MCP clients (the UI uses the
716
- # three-step chain below so that only the model pass holds the GPU).
717
- api_btn = gr.Button("analyze", visible=False)
718
-
719
- run_btn.click(
720
- prepare_video,
721
- inputs=[video_in, side_in],
722
- outputs=[state, status_out],
723
- api_name=False,
724
- ).then(
725
- predict_values,
726
- inputs=[state, instruction_in, robot_in, camera_in, frames_in, steps_in, tokens_in],
727
- outputs=[payload_state, analysis_out, status_out],
728
- api_name=False,
729
- ).then(
730
- render_result,
731
- inputs=[state, payload_state, instruction_in],
732
- outputs=[video_out, status_out],
733
- api_name=False,
734
- )
735
 
736
- api_btn.click(
737
- run_all,
738
- inputs=[video_in, instruction_in],
739
- outputs=[video_out, analysis_out],
740
- api_name="analyze",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  )
742
 
743
  if __name__ == "__main__":
 
1
  """RynnValue-4B — robot-manipulation value model demo.
2
 
3
+ Given a manipulation video and the task instruction, RynnValue predicts the
4
+ *temporal distance* (remaining seconds until the task is done) for a sequence of
5
+ video prefixes, plus a short natural-language analysis (description / does the
6
+ video match the instruction / did it succeed).
7
+
8
+ The inference path mirrors the authors' reference script
9
+ (`rynn_infer/inference.py` in github.com/alibaba-damo-academy/RynnValue):
10
+ prefix-uniform sampling for each evaluated step `i` the prefix `frames[0:i+1]`
11
+ is resampled to `num_frames` with `np.linspace` and the value of the *last*
12
+ prediction slot is read out, so every score only conditions on frames seen so
13
+ far — followed by one `generate()` pass on the full-video prefix for the
14
+ Analysis block, and the same side-by-side "video + trend plot" rendering.
15
  """
16
 
17
  import os
18
 
19
  os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
20
 
21
+ import spaces # noqa: E402 (must precede torch/CUDA imports)
22
 
 
 
23
  import re # noqa: E402
24
  import tempfile # noqa: E402
25
  import time # noqa: E402
 
26
 
 
 
27
  import gradio as gr # noqa: E402
28
  import imageio.v2 as imageio # noqa: E402
29
  import matplotlib # noqa: E402
30
 
31
  matplotlib.use("Agg")
32
  import matplotlib.pyplot as plt # noqa: E402
33
+ import numpy as np # noqa: E402
34
+ import torch # noqa: E402
35
  from PIL import Image, ImageDraw, ImageFont # noqa: E402
36
  from transformers import AutoConfig, AutoModel, AutoProcessor # noqa: E402
37
 
 
 
 
 
38
  MODEL_ID = "Alibaba-DAMO-Academy/RynnValue-4B"
 
39
 
40
+ # ---------------------------------------------------------------------------
41
+ # Model
42
+ # ---------------------------------------------------------------------------
43
+
44
+ # The exported checkpoint bundles an older config that does not persist the
45
+ # attention implementation, so force the custom prediction-slot isolation
46
+ # attention exactly like the reference script does.
47
+ config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)
48
+ config._attn_implementation = "pred_slot_isolated_eager"
49
 
50
  model = AutoModel.from_pretrained(
51
  MODEL_ID,
52
+ config=config,
53
  trust_remote_code=True,
54
+ torch_dtype=torch.bfloat16,
55
  )
56
+ model = model.to(device="cuda", dtype=torch.bfloat16).eval()
 
 
 
57
 
58
  processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
59
  tokenizer = processor.tokenizer
60
  EOS_TOKEN_ID = tokenizer.convert_tokens_to_ids("<|im_end|>")
61
 
62
+ # ---------------------------------------------------------------------------
63
+ # Defaults
64
+ # ---------------------------------------------------------------------------
65
 
 
 
66
  DEFAULT_ROBOT = "a Franka single-arm robot"
67
  DEFAULT_CAMERA = "the main camera"
68
+ DEFAULT_NUM_FRAMES = 32
69
+ DEFAULT_NUM_STEPS = 24
70
+ DEFAULT_MAX_IMAGE_SIDE = 384
 
71
  DEFAULT_MAX_NEW_TOKENS = 128
72
+ BATCH_SIZE = 2
73
+
74
+ MAX_DECODE_FRAMES = 900 # hard cap on decoded frames (memory guard)
75
+ RENDER_MAX_SIDE = 640 # frames are downscaled to this for the rendered video
76
+
77
+ ROBOT_CHOICES = [
78
+ "a Franka single-arm robot",
79
+ "a Franka dual-arm robot",
80
+ "an SO-101 single-arm robot",
81
+ "a Koch dual-arm robot",
82
+ "an xArm single-arm robot",
83
+ "a Trossen dual-arm robot",
84
+ "a UR5 single-arm robot",
85
+ "a human demonstrator",
86
+ ]
87
+ CAMERA_CHOICES = [
88
+ "the main camera",
89
+ "the side camera",
90
+ "the top-down camera",
91
+ "the wrist-mounted camera",
92
+ "the main left camera",
93
+ "the main right camera",
94
+ "the egocentric camera",
95
+ ]
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Video I/O
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def load_video_frames(video_path):
103
+ """Decode a video to RGB PIL frames, capped at MAX_DECODE_FRAMES.
104
+
105
+ Returns (frames, effective_fps). Long videos are decoded with a stride so
106
+ memory stays bounded; the effective fps is scaled to match, which keeps the
107
+ wall-clock timeline (and therefore the seconds axis) correct.
108
+ """
109
+ if not video_path or not os.path.isfile(video_path):
110
+ raise gr.Error("Please provide a video file.")
111
+
112
+ reader = imageio.get_reader(video_path)
113
+ meta = reader.get_meta_data()
114
+ fps = float(meta.get("fps") or 30.0)
115
+ if not np.isfinite(fps) or fps <= 0:
116
+ fps = 30.0
117
+ duration = meta.get("duration")
118
+ estimated = int(duration * fps) if duration and np.isfinite(duration) else None
119
+
120
+ stride = 1
121
+ if estimated and estimated > MAX_DECODE_FRAMES:
122
+ stride = int(np.ceil(estimated / MAX_DECODE_FRAMES))
123
+
124
+ frames = []
125
+ try:
126
+ for i, raw in enumerate(reader):
127
+ if i % stride:
128
+ continue
129
+ img = Image.fromarray(raw).convert("RGB")
130
+ w, h = img.size
131
+ if max(w, h) > RENDER_MAX_SIDE:
132
+ s = RENDER_MAX_SIDE / max(w, h)
133
+ img = img.resize((max(1, round(w * s)), max(1, round(h * s))), Image.BICUBIC)
134
+ frames.append(img)
135
+ if len(frames) >= MAX_DECODE_FRAMES:
136
+ break
137
+ finally:
138
+ reader.close()
139
 
140
+ if not frames:
141
+ raise gr.Error(f"No frames could be decoded from {os.path.basename(video_path)}.")
142
+ return frames, fps / stride
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
+ def resize_frames(frames, max_side):
146
+ """Downscale frames so the longer side is at most ``max_side``."""
147
+ if max_side <= 0:
148
+ return frames
149
+ w, h = frames[0].size
150
  if max(w, h) <= max_side:
151
+ return frames
152
+ scale = max_side / max(w, h)
153
+ new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
154
+ return [f.resize(new_size, resample=Image.BICUBIC) for f in frames]
 
 
 
 
 
 
 
 
155
 
156
 
157
+ def sample_frame_indices(total, num_frames):
158
+ """Uniformly pick ``num_frames`` indices out of ``total`` frames."""
159
  if num_frames <= 0 or num_frames >= total:
160
  return list(range(total))
161
  if num_frames == 1:
 
164
  return [int(round(j * step)) for j in range(num_frames)]
165
 
166
 
167
+ # ---------------------------------------------------------------------------
168
+ # Analysis parsing (same regexes as the reference script)
169
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
+ _DESCRIPTION_RE = re.compile(r"-\s*Video Description:\s*(.+)", re.IGNORECASE)
172
+ _MATCH_RE = re.compile(r"-\s*Match:\s*(Yes|No)", re.IGNORECASE)
173
+ _SUCCESS_RE = re.compile(r"-\s*Success:\s*(Yes|No)", re.IGNORECASE)
174
 
 
 
 
175
 
176
+ def parse_analysis(text):
177
+ def _first(pattern):
178
+ m = pattern.search(text)
179
+ return m.group(1).strip() if m else None
180
 
181
  return {
182
+ "description": _first(_DESCRIPTION_RE),
183
+ "match": _first(_MATCH_RE),
184
+ "success": _first(_SUCCESS_RE),
 
 
 
185
  }
186
 
187
 
188
+ # ---------------------------------------------------------------------------
189
+ # Trend plot + video rendering
190
+ # ---------------------------------------------------------------------------
 
 
 
 
 
191
 
 
 
 
192
 
193
+ def _format_time(seconds):
194
+ minutes = int(seconds // 60)
195
+ secs = int(seconds % 60)
196
+ millis = int((seconds - int(seconds)) * 1000)
197
+ return f"{minutes:02d}:{secs:02d}.{millis:03d}"
198
 
 
 
 
 
 
 
 
 
 
 
199
 
200
+ def _render_trend_frames(values, sampled_indices, fps, size, title, task_title):
201
+ """Render one plot image per prediction step.
202
 
203
+ The plot only changes at the sampled indices, so we render
204
+ ``len(values)`` images (a couple of dozen) and reuse them across the video
205
+ instead of re-rendering a figure for every frame.
206
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  w, h = size
208
  dpi = 100
209
+ fig, ax1 = plt.subplots(figsize=(w / dpi, h / dpi), dpi=dpi, constrained_layout=True)
210
+
211
+ x = np.asarray(sampled_indices, dtype=float)
212
+ y = np.asarray(values, dtype=float)
213
+ remaining = (x[-1] - x) / float(fps)
214
+
215
+ (line,) = ax1.plot([], [], color="tab:blue", linewidth=2.0, label="predicted value")
216
+ pt1 = ax1.scatter([x[0]], [y[0]], color="red", s=30, zorder=3, label="current value")
217
+ ax1.set_xlabel("Frame", fontsize=9)
218
+ ax1.set_ylabel("Value", color="tab:blue", fontsize=9)
219
+ ax1.tick_params(axis="x", labelsize=8)
220
+ ax1.tick_params(axis="y", labelcolor="tab:blue", labelsize=8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  ax1.grid(True, alpha=0.3)
222
+ ax1.set_xlim(0, max(float(x[-1]), 1.0))
223
 
224
  y_min, y_max = float(np.min(y)), float(np.max(y))
225
  if y_min == y_max:
 
229
  ax1.set_ylim(y_min - margin, y_max + margin)
230
 
231
  ax2 = ax1.twinx()
232
+ ax2.plot(x, remaining, color="green", linestyle="--", linewidth=2.0, label="remaining time")
233
+ pt2 = ax2.scatter(
234
+ [x[0]], [remaining[0]], color="green", s=25, zorder=3, label="current remaining"
235
+ )
236
+ ax2.set_ylabel("Remaining Time (s)", color="green", fontsize=9)
237
+ ax2.tick_params(axis="y", labelcolor="green", labelsize=8)
238
+ rt_min, rt_max = float(np.min(remaining)), float(np.max(remaining))
 
 
239
  if rt_min == rt_max:
240
  rt_min -= 1.0
241
  rt_max += 1.0
242
  rt_margin = 0.05 * (rt_max - rt_min)
243
  ax2.set_ylim(rt_min - rt_margin, rt_max + rt_margin)
244
 
245
+ short_task = task_title if len(task_title) <= 42 else task_title[:39] + "..."
246
+ ax1.set_title(f"{short_task}\n{title}", fontsize=10)
247
+
248
+ lines1, labels1 = ax1.get_legend_handles_labels()
249
+ lines2, labels2 = ax2.get_legend_handles_labels()
250
+ ax1.legend(lines1 + lines2, labels1 + labels2, loc="best", fontsize=7)
251
+
252
+ images = []
253
+ for i in range(len(y)):
254
+ line.set_data(x[: i + 1], y[: i + 1])
255
+ pt1.set_offsets(np.array([[x[i], y[i]]]))
256
+ pt2.set_offsets(np.array([[x[i], remaining[i]]]))
257
+ fig.canvas.draw()
258
+ buf = np.asarray(fig.canvas.buffer_rgba())[:, :, :3].copy()
259
+ images.append(Image.fromarray(buf))
260
+ plt.close(fig)
261
+ return images
262
 
263
 
264
+ def _overlay_font(size):
265
  try:
266
  return ImageFont.load_default(size=size)
267
  except TypeError: # Pillow < 10.1
268
  return ImageFont.load_default()
269
 
270
 
271
+ def _draw_overlay_text(img, lines, font):
272
+ img = img.copy()
273
  draw = ImageDraw.Draw(img)
274
+ x, y = 8, 8
275
  for line in lines:
276
+ draw.text(
277
+ (x, y), line, fill=(255, 235, 59), font=font, stroke_width=2, stroke_fill=(0, 0, 0)
278
+ )
279
+ bbox = draw.textbbox((x, y), line, font=font)
280
+ y += (bbox[3] - bbox[1]) + 8
281
  return img
282
 
283
 
284
+ def _ceil_to_multiple(v, m):
285
+ return ((v + m - 1) // m) * m
286
 
287
 
288
+ def save_video_with_trend(frames, values, sampled_indices, fps, output_path, task_title):
289
+ """Side-by-side video: annotated frames + synchronized value trend plot."""
290
+ base_w, base_h = frames[0].size
291
+ if base_h < 260:
292
+ scale = 260.0 / base_h
293
+ base_w, base_h = int(round(base_w * scale)), 260
294
+ frames = [f.resize((base_w, base_h)) for f in frames]
 
 
 
295
 
296
+ plot_w = int(min(520, max(300, base_w * 0.75)))
297
+ plot_h = max(260, base_h)
 
298
 
299
+ plots = _render_trend_frames(
300
+ values,
301
+ sampled_indices,
302
+ fps,
303
+ (plot_w, plot_h),
304
+ "Remaining Time (s)",
305
+ task_title,
306
+ )
307
+ if plots[0].height != base_h:
 
 
 
308
  plots = [p.resize((plot_w, base_h)) for p in plots]
309
 
310
+ idx_to_pos = {idx: pos for pos, idx in enumerate(sampled_indices)}
311
+ n = len(frames)
312
+ remaining_all = (n - 1 - np.arange(n)) / float(fps)
313
 
314
+ font = _overlay_font(16)
315
  canvas_w = _ceil_to_multiple(base_w + plot_w, 16)
316
  canvas_h = _ceil_to_multiple(base_h, 16)
317
 
318
+ writer = imageio.get_writer(
319
+ output_path,
320
+ fps=max(1.0, float(fps)),
321
+ codec="libx264",
322
+ quality=7,
323
+ macro_block_size=None,
324
+ ffmpeg_params=["-pix_fmt", "yuv420p"],
325
+ )
326
  try:
327
+ pos = 0
328
+ short_task = task_title if len(task_title) <= 48 else task_title[:45] + "..."
329
+ for i, img in enumerate(frames):
330
+ if i in idx_to_pos:
331
+ pos = idx_to_pos[i]
332
+ left = _draw_overlay_text(
 
 
 
 
 
 
333
  img,
334
  [
335
+ f"task: {short_task}",
336
+ f"predicted remaining: {values[pos]:.2f}s",
337
+ f"actual remaining: {_format_time(remaining_all[i])}",
338
  ],
339
  font,
340
  )
 
341
  canvas = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
342
+ canvas.paste(left, (0, 0))
343
+ canvas.paste(plots[pos], (base_w, 0))
344
  writer.append_data(np.asarray(canvas))
345
  finally:
 
346
  writer.close()
347
+ return output_path
348
 
349
 
350
+ # ---------------------------------------------------------------------------
351
  # Inference
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ def _gpu_duration(
356
+ video=None,
357
+ instruction="",
358
+ robot_description=DEFAULT_ROBOT,
359
+ camera_description=DEFAULT_CAMERA,
360
+ num_frames=DEFAULT_NUM_FRAMES,
361
+ num_steps=DEFAULT_NUM_STEPS,
362
+ max_image_side=DEFAULT_MAX_IMAGE_SIDE,
363
+ max_new_tokens=DEFAULT_MAX_NEW_TOKENS,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  *args,
365
  **kwargs,
366
+ ):
367
  try:
368
+ nf = int(num_frames)
369
+ ns = int(num_steps)
370
+ side = int(max_image_side)
371
+ except (TypeError, ValueError):
372
+ nf, ns, side = DEFAULT_NUM_FRAMES, DEFAULT_NUM_STEPS, DEFAULT_MAX_IMAGE_SIDE
373
+ # base (load + render) + per prefix-forward cost, scaled by tokens per frame
374
+ work = ns * nf * (side / 384.0) ** 2
375
+ return int(min(300, 45 + 0.09 * work))
376
+
377
+
378
+ @spaces.GPU(duration=_gpu_duration)
379
+ def analyze(
380
+ video,
381
+ instruction,
382
+ robot_description=DEFAULT_ROBOT,
383
+ camera_description=DEFAULT_CAMERA,
384
+ num_frames=DEFAULT_NUM_FRAMES,
385
+ num_steps=DEFAULT_NUM_STEPS,
386
+ max_image_side=DEFAULT_MAX_IMAGE_SIDE,
387
+ max_new_tokens=DEFAULT_MAX_NEW_TOKENS,
388
+ progress=gr.Progress(),
389
+ ):
390
+ started = time.time()
 
 
 
 
 
 
 
 
 
 
391
  instruction = (instruction or "").strip()
392
  if not instruction:
393
+ raise gr.Error("Please describe the task the agent is supposed to perform.")
394
 
395
  robot_description = (robot_description or "").strip() or None
396
  camera_description = (camera_description or "").strip() or None
397
  if robot_description is None and camera_description is None:
398
+ # The checkpoint was trained with use_meta=True; the conversation
399
+ # builder refuses to build a prompt without at least one meta field.
400
  robot_description = DEFAULT_ROBOT
401
 
402
+ num_frames = int(num_frames)
403
+ num_steps = int(num_steps)
404
+ max_image_side = int(max_image_side)
405
+ max_new_tokens = int(max_new_tokens)
406
 
407
+ progress(0.02, desc="Decoding video...")
408
+ frames, fps = load_video_frames(video)
409
+ total = len(frames)
410
+ model_images = resize_frames(frames, max_image_side)
411
 
412
+ eval_indices = sample_frame_indices(total, min(num_steps, total))
413
 
414
+ def build_prefix_sample(end_idx):
415
+ frame_idx = np.linspace(0, end_idx, num_frames, dtype=int)
416
+ prefix_images = [model_images[j] for j in frame_idx]
 
 
 
 
 
 
417
  return processor.process_episode(
418
  instruction=instruction,
419
+ images=prefix_images,
420
  robot_description=robot_description,
421
  camera_description=camera_description,
422
  )
423
 
424
  def run_batch(samples):
425
+ """One forward pass over a batch of prefixes -> last-slot value each."""
426
+ batch_kwargs = dict(
427
+ input_ids=torch.cat([s["input_ids"] for s in samples], dim=0).to("cuda").long(),
428
+ attention_mask=torch.cat([s["attention_mask"] for s in samples], dim=0)
429
+ .to("cuda")
430
+ .long(),
431
+ pixel_values=torch.cat([s["pixel_values"].flatten(0, 1) for s in samples], dim=0)
432
+ .to("cuda")
433
+ .to(torch.bfloat16),
434
+ image_grid_thw=torch.cat([s["image_grid_thw"].flatten(0, 1) for s in samples], dim=0)
435
+ .to("cuda")
436
+ .long(),
437
  )
438
  with torch.inference_mode():
439
+ outputs = model(**batch_kwargs)
440
  pred = outputs.value.pred_value
441
  if pred.dim() == 2 and pred.shape[0] == 1:
442
  pred = pred.reshape(len(samples), -1)
 
448
  pred = pred[:, 0]
449
  return pred.float().reshape(-1).tolist()
450
 
451
+ pred_value = []
 
452
  final_sample = None
453
+ batch = []
454
  for step, end_idx in enumerate(eval_indices):
455
  sample = build_prefix_sample(end_idx)
456
+ if end_idx == eval_indices[-1]:
457
  final_sample = sample
458
+ batch.append(sample)
459
+ if len(batch) >= BATCH_SIZE or step == len(eval_indices) - 1:
460
+ pred_value.extend(run_batch(batch))
461
+ batch = []
462
+ progress(
463
+ 0.05 + 0.75 * len(pred_value) / len(eval_indices),
464
+ desc=f"Value pass {len(pred_value)}/{len(eval_indices)}",
465
+ )
466
+
467
+ progress(0.82, desc="Generating analysis...")
468
+ input_ids = final_sample["input_ids"].to("cuda").long()
469
  with torch.inference_mode():
470
  gen_out = model.generate(
471
  input_ids=input_ids,
472
+ attention_mask=final_sample["attention_mask"].to("cuda").long(),
473
+ pixel_values=final_sample["pixel_values"].flatten(0, 1).to("cuda").to(torch.bfloat16),
474
+ image_grid_thw=final_sample["image_grid_thw"].flatten(0, 1).to("cuda").long(),
475
  max_new_tokens=max_new_tokens,
476
  do_sample=False,
477
  num_beams=1,
 
479
  pad_token_id=EOS_TOKEN_ID,
480
  use_cache=True,
481
  )
482
+ analysis_text = tokenizer.decode(gen_out[0, input_ids.shape[1] :], skip_special_tokens=True)
483
+ analysis = parse_analysis(analysis_text)
484
+ gpu_seconds = time.time() - started
 
 
 
 
 
 
 
485
 
486
+ progress(0.88, desc="Rendering trend video...")
487
+ out_path = os.path.join(tempfile.mkdtemp(), "rynnvalue_trend.mp4")
488
+ save_video_with_trend(frames, pred_value, eval_indices, fps, out_path, instruction)
489
+
490
+ def _verdict(v):
491
  if v is None:
492
  return "—"
493
  return "✅ Yes" if v.lower() == "yes" else "❌ No"
494
 
495
+ description = analysis["description"] or (analysis_text.strip() or "—")
496
+ summary = "\n".join(
497
  [
498
+ f"**Task instruction** {instruction}",
499
+ "",
500
+ f"**Video description** {description}",
501
  "",
502
+ f"**Matches the instruction?** {_verdict(analysis['match'])} &nbsp;&nbsp;·&nbsp;&nbsp; "
503
+ f"**Task completed?** {_verdict(analysis['success'])}",
504
  "",
505
+ f"**Predicted remaining time** {pred_value[0]:.2f} s at the first frame → "
506
+ f"{pred_value[-1]:.2f} s at the last frame "
507
+ f"(video is {(total - 1) / fps:.1f} s long, {total} frames decoded).",
508
  "",
509
+ f"<sub>{len(eval_indices)} prefix evaluations · {num_frames} frames per prefix · "
510
+ f"{gpu_seconds:.1f} s on GPU</sub>",
511
  ]
512
  )
513
+ return out_path, summary
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  # UI
518
+ # ---------------------------------------------------------------------------
 
 
 
 
 
519
 
520
+ DESCRIPTION = """
521
+ <div align="center">
522
 
523
+ # 🤖 RynnValue-4B — how much longer until the robot is done?
 
 
 
524
 
525
+ [Model](https://huggingface.co/Alibaba-DAMO-Academy/RynnValue-4B) ·
526
+ [Paper](https://arxiv.org/abs/2608.09853) ·
527
+ [GitHub](https://github.com/alibaba-damo-academy/RynnValue) ·
528
+ [Project page](https://alibaba-damo-academy.github.io/RynnValue.github.io/)
 
 
529
 
530
+ </div>
 
 
531
 
532
+ **RynnValue** is a value foundation model for robot manipulation, built on RynnBrain (Qwen3-VL).
533
+ Given a manipulation video *and* a task instruction, it predicts the **temporal distance** —
534
+ the remaining time in seconds until the task is complete — for a series of video prefixes, and
535
+ generates a short analysis: what the video shows, whether it **matches** the instruction, and
536
+ whether the task **succeeded**.
537
 
538
+ The output video plays the clip next to the predicted value curve (blue) alongside the actual
539
+ remaining time of the clip (dashed green).
540
+ """
 
 
 
 
 
 
 
 
 
541
 
542
+ with gr.Blocks(theme=gr.themes.Citrus(), title="RynnValue-4B") as demo:
543
+ gr.Markdown(DESCRIPTION)
544
 
545
+ with gr.Row():
546
+ with gr.Column(scale=1):
547
+ video_in = gr.Video(label="Manipulation video", sources=["upload"], height=320)
548
+ instruction = gr.Textbox(
549
+ label="Task instruction",
550
+ placeholder="Put the box in the drawer and close it",
551
+ lines=2,
552
+ )
553
+ run_btn = gr.Button("Predict temporal distance", variant="primary")
554
  with gr.Row():
555
+ robot_desc = gr.Dropdown(
556
+ label="Embodiment",
557
+ choices=ROBOT_CHOICES,
558
+ value=DEFAULT_ROBOT,
559
+ allow_custom_value=True,
560
+ )
561
+ camera_desc = gr.Dropdown(
562
+ label="Camera viewpoint",
563
+ choices=CAMERA_CHOICES,
564
+ value=DEFAULT_CAMERA,
565
+ allow_custom_value=True,
566
+ )
567
+ with gr.Column(scale=1):
568
+ video_out = gr.Video(label="Value trend", height=360, autoplay=True)
569
+ analysis_md = gr.Markdown(label="Analysis")
570
+
571
+ with gr.Accordion("Advanced options", open=False):
572
+ gr.Markdown(
573
+ "The model is queried once per curve point, on the prefix of the video seen so far "
574
+ "(the authors' `rynn_infer/inference.py` protocol). More curve points and more frames "
575
+ "per prefix mean a smoother, better-conditioned curve — and a longer run."
576
+ )
577
+ num_frames = gr.Slider(
578
+ 8, 64, value=DEFAULT_NUM_FRAMES, step=8, label="Frames per prefix (context length)"
579
+ )
580
+ num_steps = gr.Slider(
581
+ 8, 48, value=DEFAULT_NUM_STEPS, step=4, label="Curve points (prefix evaluations)"
582
+ )
583
+ max_image_side = gr.Slider(
584
+ 256, 640, value=DEFAULT_MAX_IMAGE_SIDE, step=64, label="Max frame side fed to the model"
585
+ )
586
+ max_new_tokens = gr.Slider(
587
+ 32, 256, value=DEFAULT_MAX_NEW_TOKENS, step=32, label="Analysis token budget"
588
  )
589
 
590
+ inputs = [
591
+ video_in,
592
+ instruction,
593
+ robot_desc,
594
+ camera_desc,
595
+ num_frames,
596
+ num_steps,
597
+ max_image_side,
598
+ max_new_tokens,
599
+ ]
600
+ outputs = [video_out, analysis_md]
601
+
602
+ run_btn.click(fn=analyze, inputs=inputs, outputs=outputs)
 
 
 
 
 
 
 
603
 
604
+ gr.Examples(
605
+ examples=[
606
+ [
607
+ "examples/put_the_box_in_the_drawer_and_close_it.mp4",
608
+ "Put the box in the drawer and close it",
609
+ "a Franka single-arm robot",
610
+ "the main camera",
611
+ ],
612
+ [
613
+ "examples/so101_pink_lego_brick_into_box.mp4",
614
+ "Put the pink lego brick into the transparent box",
615
+ "an SO-101 single-arm robot",
616
+ "the side camera",
617
+ ],
618
+ ],
619
+ inputs=[video_in, instruction, robot_desc, camera_desc],
620
+ outputs=outputs,
621
+ fn=analyze,
622
+ cache_examples=True,
623
+ cache_mode="lazy",
624
+ label="Examples",
625
  )
626
 
627
  if __name__ == "__main__":
examples/put_the_box_in_the_drawer_and_close_it.mp4 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:a364c348ca4bbf172c68c923c412d2a161b484442e0635881676776fe7a46dfb
3
- size 1536090
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8fe70130284147c403115b2c477fa6d2e453396d807a12bd9838c27e0bf5fe06
3
+ size 1167523
examples/so101_pink_lego_brick_into_box.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5b7e6b10aed77ee1324b8256717606c394b194ebf0c78149f650cdef08c2a2fd
3
+ size 275446
requirements.txt CHANGED
@@ -1,9 +1,13 @@
 
 
 
 
 
1
  transformers==4.57.6
2
  accelerate
3
- torchvision
4
  einops
5
  numpy
6
  pillow
7
- matplotlib
8
  imageio
9
  imageio-ffmpeg
 
 
1
+ torch==2.11.0
2
+ torchvision
3
+ # RynnValue's remote code targets the Qwen3-VL internals of the 4.57 line
4
+ # (`Qwen3VLProcessorKwargs`, `ALL_MASK_ATTENTION_FUNCTIONS._global_mapping`,
5
+ # `TransformersKwargs`); the model card pins `transformers>=4.57.0,<5`.
6
  transformers==4.57.6
7
  accelerate
 
8
  einops
9
  numpy
10
  pillow
 
11
  imageio
12
  imageio-ffmpeg
13
+ matplotlib