Spaces:
Running on Zero
Running on Zero
RynnValue-4B temporal distance demo
Browse files- .gitattributes +1 -0
- README.md +18 -32
- app.py +431 -547
- examples/put_the_box_in_the_drawer_and_close_it.mp4 +2 -2
- examples/so101_pink_lego_brick_into_box.mp4 +3 -0
- requirements.txt +6 -2
.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:
|
| 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
|
| 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 |
-
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
## Example
|
| 50 |
|
| 51 |
-
`examples/put_the_box_in_the_drawer_and_close_it.mp4`
|
| 52 |
-
|
| 53 |
-
(Apache-2.0
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
The inference path mirrors the
|
| 9 |
-
(
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
import os
|
| 16 |
|
| 17 |
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 18 |
|
| 19 |
-
import spaces # noqa: E402
|
| 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 |
-
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
model = AutoModel.from_pretrained(
|
| 52 |
MODEL_ID,
|
| 53 |
-
config=
|
| 54 |
trust_remote_code=True,
|
| 55 |
-
torch_dtype=
|
| 56 |
)
|
| 57 |
-
|
| 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
|
| 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 |
-
|
| 76 |
-
|
| 77 |
-
DEFAULT_MAX_SIDE = 448 # longest side of a model input frame
|
| 78 |
DEFAULT_MAX_NEW_TOKENS = 128
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 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
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 98 |
if max(w, h) <= max_side:
|
| 99 |
-
return
|
| 100 |
-
scale = max_side /
|
| 101 |
-
|
| 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
|
| 114 |
-
"""Uniformly pick ``num_frames`` indices
|
| 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 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
| 158 |
-
|
|
|
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
|
| 169 |
return {
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 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=(
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 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(
|
| 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,
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
ax2.set_ylabel("
|
| 273 |
-
|
| 274 |
-
|
| 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 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
ax1.
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
|
| 298 |
-
def
|
| 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
|
|
|
|
| 306 |
draw = ImageDraw.Draw(img)
|
| 307 |
-
x, y =
|
| 308 |
for line in lines:
|
| 309 |
-
draw.text(
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
| 313 |
return img
|
| 314 |
|
| 315 |
|
| 316 |
-
def _ceil_to_multiple(
|
| 317 |
-
return ((
|
| 318 |
|
| 319 |
|
| 320 |
-
def
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
video_path = state["video_path"]
|
| 328 |
-
n_total = int(state["n_total"])
|
| 329 |
-
src_fps = float(state["src_fps"])
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
base_w = int(round(disp_w * base_h / disp_h)) if disp_h else disp_w
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 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 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
|
|
|
|
| 353 |
canvas_w = _ceil_to_multiple(base_w + plot_w, 16)
|
| 354 |
canvas_h = _ceil_to_multiple(base_h, 16)
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
| 361 |
try:
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
if i
|
| 366 |
-
|
| 367 |
-
|
| 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"
|
| 377 |
-
f"
|
|
|
|
| 378 |
],
|
| 379 |
font,
|
| 380 |
)
|
| 381 |
-
|
| 382 |
canvas = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
|
| 383 |
-
canvas.paste(
|
| 384 |
-
canvas.paste(plots[
|
| 385 |
writer.append_data(np.asarray(canvas))
|
| 386 |
finally:
|
| 387 |
-
reader.close()
|
| 388 |
writer.close()
|
| 389 |
-
return
|
| 390 |
|
| 391 |
|
| 392 |
-
# ---------------------------------------------------------------------------
|
| 393 |
# Inference
|
| 394 |
-
# ---------------------------------------------------------------------------
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 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 |
-
)
|
| 434 |
try:
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 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
|
| 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(
|
| 478 |
-
num_steps = int(
|
| 479 |
-
|
|
|
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
|
| 486 |
-
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 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=
|
| 500 |
robot_description=robot_description,
|
| 501 |
camera_description=camera_description,
|
| 502 |
)
|
| 503 |
|
| 504 |
def run_batch(samples):
|
| 505 |
-
batch
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
| 514 |
)
|
| 515 |
with torch.inference_mode():
|
| 516 |
-
outputs = model(**
|
| 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 |
-
|
| 529 |
-
values: List[float] = []
|
| 530 |
final_sample = None
|
| 531 |
-
|
| 532 |
for step, end_idx in enumerate(eval_indices):
|
| 533 |
sample = build_prefix_sample(end_idx)
|
| 534 |
-
if
|
| 535 |
final_sample = sample
|
| 536 |
-
|
| 537 |
-
if len(
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
| 544 |
with torch.inference_mode():
|
| 545 |
gen_out = model.generate(
|
| 546 |
input_ids=input_ids,
|
| 547 |
-
attention_mask=final_sample["attention_mask"].to(
|
| 548 |
-
pixel_values=final_sample["pixel_values"].flatten(0, 1).to(
|
| 549 |
-
image_grid_thw=final_sample["image_grid_thw"].flatten(0, 1).to(
|
| 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 |
-
|
| 558 |
-
analysis = parse_analysis(
|
| 559 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
if v is None:
|
| 570 |
return "—"
|
| 571 |
return "✅ Yes" if v.lower() == "yes" else "❌ No"
|
| 572 |
|
| 573 |
-
|
|
|
|
| 574 |
[
|
| 575 |
-
f"**
|
| 576 |
-
|
| 577 |
-
f"**
|
| 578 |
"",
|
| 579 |
-
f"**
|
|
|
|
| 580 |
"",
|
| 581 |
-
f"**
|
|
|
|
|
|
|
| 582 |
"",
|
| 583 |
-
f"
|
|
|
|
| 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 |
-
|
|
|
|
| 652 |
|
| 653 |
-
|
| 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 |
-
[
|
| 659 |
-
[
|
| 660 |
-
[
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
EXAMPLE_VIDEO = "examples/put_the_box_in_the_drawer_and_close_it.mp4"
|
| 664 |
|
| 665 |
-
|
| 666 |
-
state = gr.State()
|
| 667 |
-
payload_state = gr.State()
|
| 668 |
|
| 669 |
-
|
| 670 |
-
|
|
|
|
|
|
|
|
|
|
| 671 |
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 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 |
-
|
|
|
|
| 686 |
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
with gr.Row():
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
)
|
| 714 |
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 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 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'])} · "
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|