Video-Text-to-Text
Transformers
Safetensors
qwen3_5
image-text-to-text
robotics
reward-model
vlm
gr00t
system-2
Instructions to use nvidia/Flora-Rewarder with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use nvidia/Flora-Rewarder with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("nvidia/Flora-Rewarder") model = AutoModelForMultimodalLM.from_pretrained("nvidia/Flora-Rewarder", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add Flora inference examples
#1
by OmarLaymounNvidia - opened
- batch_utils/README.md +130 -0
- batch_utils/__init__.py +1 -0
- batch_utils/core.py +803 -0
- batch_utils/dense_reward.py +124 -0
- batch_utils/engine.py +151 -0
- batch_utils/examples/jobs.jsonl +2 -0
- batch_utils/examples/rubric_jobs.jsonl +1 -0
- batch_utils/requirements.txt +8 -0
- batch_utils/rubric_reward.py +128 -0
- batch_utils/terminal_reward.py +106 -0
batch_utils/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FLORA Rewarder: example inference
|
| 2 |
+
|
| 3 |
+
Example scripts for the three reward prompts of the FLORA Rewarder. One set of weights answers
|
| 4 |
+
all three; the prompt selects the reward.
|
| 5 |
+
|
| 6 |
+
| Script | Reward | Model input | Output |
|
| 7 |
+
| --- | --- | --- | --- |
|
| 8 |
+
| `terminal_reward.py` | Episode success, 1–5 | Whole episode, up to 64 frames at up to 10 FPS, camera collage | Score, `P(1..5)`, expected score |
|
| 9 |
+
| `dense_reward.py` | Action quality, 1–3 per 0.5 s | Overlapping 16 s clips, 64 frames at 4 FPS, camera collage | Score per frame, segments, rationales for bad spans |
|
| 10 |
+
| `rubric_reward.py` | Rubric step scores | One video per camera, 2 FPS (up to 166 frames each), rubric | Thinking trace, one score per step |
|
| 11 |
+
|
| 12 |
+
Scores: terminal `1` = complete failure, `5` = complete success; dense `1` = failure,
|
| 13 |
+
`2` = suboptimal, `3` = normal.
|
| 14 |
+
|
| 15 |
+
## Setup
|
| 16 |
+
|
| 17 |
+
Validated with Python 3.12 and NVIDIA H100 80 GB GPUs: one GPU for terminal and dense rewards,
|
| 18 |
+
two for rubric rewards with the default settings (`--tensor-parallel-size 2`).
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
pip install -r batch_utils/requirements.txt
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
Run the scripts from a downloaded copy of this repository, the directory that contains
|
| 25 |
+
`batch_utils/`; they load the weights stored next to it. To load the weights from elsewhere,
|
| 26 |
+
pass `--model` with a Hub repository id or a local directory, or set `FLORA_REWARDER_REPO`.
|
| 27 |
+
The first run compiles GPU kernels and can take several minutes before scoring starts.
|
| 28 |
+
`--dry-run` reads the videos and prints the first prompt without loading the model.
|
| 29 |
+
|
| 30 |
+
## Inputs
|
| 31 |
+
|
| 32 |
+
Each episode is one line of a JSONL file; video paths are relative to that file. Camera videos
|
| 33 |
+
of one episode must be synchronized: same frame rate and frame count, starting together.
|
| 34 |
+
|
| 35 |
+
```json
|
| 36 |
+
{"id": "ep001", "task": "Put the cup on the tray.", "videos": ["left_wrist.mp4", "top.mp4", "right_wrist.mp4"]}
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
`task` is one plain-language instruction for the whole episode.
|
| 40 |
+
|
| 41 |
+
**Camera order (terminal and dense).** The views of each time step are tiled into one frame:
|
| 42 |
+
one view as is, two or three side by side, four as a 2 × 2 grid, in the order given. Use the
|
| 43 |
+
order the model was trained with:
|
| 44 |
+
|
| 45 |
+
| Views | Order |
|
| 46 |
+
| --- | --- |
|
| 47 |
+
| 2 | left camera, right camera (for example a left exterior camera and the wrist camera) |
|
| 48 |
+
| 3 | left, center, right (for example left wrist, top, right wrist) |
|
| 49 |
+
| 4 | top, front or bottom, left, right |
|
| 50 |
+
|
| 51 |
+
A single episode can also be passed on the command line:
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
python -m batch_utils.terminal_reward --video left_wrist.mp4 top.mp4 right_wrist.mp4 \
|
| 55 |
+
--task "Put the cup on the tray." --output terminal.jsonl
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Terminal reward
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
python -m batch_utils.terminal_reward --jobs batch_utils/examples/jobs.jsonl --output terminal.jsonl
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Frames are sampled uniformly over the whole episode: `min(64, floor(10 × duration in s))`,
|
| 65 |
+
rounded down to an even count. Add `--with-rationale` for a one-line explanation before the
|
| 66 |
+
score. Each output line has:
|
| 67 |
+
|
| 68 |
+
- `score`: the generated score, 1–5;
|
| 69 |
+
- `probabilities`: `P(1..5)` at the score token, and `expected_score`, their mean;
|
| 70 |
+
- `progress`: `(expected_score − 1) / 4`, a value in [0, 1] for ranking, not a calibrated
|
| 71 |
+
success probability.
|
| 72 |
+
|
| 73 |
+
## Dense reward
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
python -m batch_utils.dense_reward --jobs batch_utils/examples/jobs.jsonl --output dense.jsonl
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
The episode is split into 16 s clips (64 frames at 4 FPS) that start every 10 s; the last clip
|
| 80 |
+
ends at the episode end. The model scores the 32 frame pairs (0.5 s each) of every clip and
|
| 81 |
+
writes a rationale for every span scored 1 or 2. Clips are stitched by dropping the last 4 s
|
| 82 |
+
of every clip except the final one and giving each frame the score of the nearest kept pair.
|
| 83 |
+
|
| 84 |
+
Each output line has `segments` (start and end time, score, rationale), `per_second` (the score
|
| 85 |
+
at the middle of each second), `fraction` of time per score, and the raw answer of every clip.
|
| 86 |
+
`--frame-scores` adds one score per source frame. Clips whose answer cannot be parsed are
|
| 87 |
+
scored normal and counted in `invalid_clips`.
|
| 88 |
+
|
| 89 |
+
Dense scores are retrospective: a clip also shows the seconds after each scored moment. Use
|
| 90 |
+
them for offline labeling and data filtering, not as an online signal.
|
| 91 |
+
|
| 92 |
+
## Rubric reward
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
python -m batch_utils.rubric_reward --jobs batch_utils/examples/rubric_jobs.jsonl --output rubric.jsonl
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
A rubric job also names the robot and describes every camera, in video order:
|
| 99 |
+
|
| 100 |
+
```json
|
| 101 |
+
{"id": "ep001", "task": "Put the cup on the tray.", "robot": "bimanual robot",
|
| 102 |
+
"cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"],
|
| 103 |
+
"videos": ["top.mp4", "left_wrist.mp4", "right_wrist.mp4"],
|
| 104 |
+
"rubric": [
|
| 105 |
+
{"action": "Grasp the cup", "grading": [
|
| 106 |
+
{"label": "Not attempted", "points": 0.0},
|
| 107 |
+
{"label": "Grasped after re-tries", "points": 0.1},
|
| 108 |
+
{"label": "Clean grasp", "points": 0.2}]},
|
| 109 |
+
{"action": "Set the cup on the tray", "grading": [
|
| 110 |
+
{"label": "Not attempted", "points": 0.0},
|
| 111 |
+
{"label": "Placed but tipped over", "points": 0.4},
|
| 112 |
+
{"label": "Placed upright", "points": 0.8}]}]}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Each step lists the scores that may be awarded; the step maxima should sum to 1. Instead of
|
| 116 |
+
`robot` and `cameras`, a job may give `header`, the full opening paragraph of the prompt.
|
| 117 |
+
|
| 118 |
+
The model thinks before it answers `ANSWER: [...]`. The script samples `--num-samples` answers
|
| 119 |
+
(default 9) and reports the medoid: the answer with the smallest summed L1 distance to the
|
| 120 |
+
others. Use `--num-samples 1` for a single answer. Traces are often several thousand tokens;
|
| 121 |
+
`--keep-thinking` writes them to the output.
|
| 122 |
+
|
| 123 |
+
## Notes
|
| 124 |
+
|
| 125 |
+
- Terminal and dense answers are decoded greedily with the answer format enforced; rubric
|
| 126 |
+
answers are sampled at temperature 1.
|
| 127 |
+
- Videos are read at their native frame rate, and the model receives absolute episode times.
|
| 128 |
+
Keep the native videos; do not resample them.
|
| 129 |
+
- Validate reward mappings on held-out episodes from your own robot before using them for
|
| 130 |
+
training or data selection.
|
batch_utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Example inference scripts for the FLORA rewarder: terminal, dense and rubric rewards."""
|
batch_utils/core.py
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inputs, prompts and output parsing for the FLORA rewarder.
|
| 2 |
+
|
| 3 |
+
One model answers three prompts:
|
| 4 |
+
|
| 5 |
+
* terminal: one 1-5 score for a whole episode (1 = complete failure, 5 = complete success);
|
| 6 |
+
* dense: one 1-3 action-quality score per 0.5 s (1 = failure, 2 = suboptimal, 3 = normal),
|
| 7 |
+
with a short rationale for every non-normal span;
|
| 8 |
+
* rubric: a score for every step of a user-defined rubric, after a thinking trace.
|
| 9 |
+
|
| 10 |
+
This module has no inference backend. It builds the chat messages and the exact video
|
| 11 |
+
frames the model expects, and turns the generated text back into rewards.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from collections.abc import Mapping, Sequence
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from functools import cache
|
| 20 |
+
import json
|
| 21 |
+
import math
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
import re
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
from PIL import Image
|
| 28 |
+
|
| 29 |
+
VIDEO_PLACEHOLDER = "__video__"
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Video reading
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
FPS_RELATIVE_TOLERANCE = 0.01
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class VideoInfo:
|
| 40 |
+
path: str
|
| 41 |
+
fps: float
|
| 42 |
+
num_frames: int
|
| 43 |
+
width: int
|
| 44 |
+
height: int
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def duration_s(self) -> float:
|
| 48 |
+
return self.num_frames / self.fps
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def probe_video(path: str | Path) -> VideoInfo:
|
| 52 |
+
"""Frame rate, frame count and size of the first video stream."""
|
| 53 |
+
import av
|
| 54 |
+
|
| 55 |
+
with av.open(str(path)) as container:
|
| 56 |
+
stream = container.streams.video[0]
|
| 57 |
+
rate = stream.average_rate or stream.guessed_rate
|
| 58 |
+
if not rate or float(rate) <= 0:
|
| 59 |
+
raise ValueError(f"{path}: the video has no usable frame rate")
|
| 60 |
+
width, height = int(stream.codec_context.width), int(stream.codec_context.height)
|
| 61 |
+
frames = sum(1 for packet in container.demux(stream) if packet.size)
|
| 62 |
+
if frames < 1:
|
| 63 |
+
raise ValueError(f"{path}: the video has no frames")
|
| 64 |
+
return VideoInfo(str(path), float(rate), frames, width, height)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def read_frames(
|
| 68 |
+
path: str | Path, indices: Sequence[int], size: tuple[int, int] | None = None
|
| 69 |
+
) -> dict[int, np.ndarray]:
|
| 70 |
+
"""Decode the requested frame indices as RGB uint8 arrays, optionally resized.
|
| 71 |
+
|
| 72 |
+
Resizing uses bilinear interpolation to ``size`` (width, height), with no crop or padding.
|
| 73 |
+
"""
|
| 74 |
+
import av
|
| 75 |
+
|
| 76 |
+
wanted = sorted({int(i) for i in indices})
|
| 77 |
+
out: dict[int, np.ndarray] = {}
|
| 78 |
+
if not wanted:
|
| 79 |
+
return out
|
| 80 |
+
with av.open(str(path)) as container:
|
| 81 |
+
stream = container.streams.video[0]
|
| 82 |
+
stream.thread_type = "AUTO"
|
| 83 |
+
pending = iter(wanted)
|
| 84 |
+
target = next(pending)
|
| 85 |
+
for position, frame in enumerate(container.decode(stream)):
|
| 86 |
+
if position != target:
|
| 87 |
+
continue
|
| 88 |
+
image = frame.to_image()
|
| 89 |
+
if size is not None and image.size != tuple(size):
|
| 90 |
+
image = image.resize(tuple(size), resample=Image.Resampling.BILINEAR)
|
| 91 |
+
out[position] = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
| 92 |
+
target = next(pending, None)
|
| 93 |
+
if target is None:
|
| 94 |
+
break
|
| 95 |
+
if len(out) != len(wanted):
|
| 96 |
+
raise ValueError(f"{path}: decoded fewer frames than its container reports")
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def check_synchronized(videos: Sequence[VideoInfo]) -> tuple[float, int]:
|
| 101 |
+
"""Camera views must share one frame clock. Returns (fps, frame count)."""
|
| 102 |
+
if not videos:
|
| 103 |
+
raise ValueError("at least one camera video is required")
|
| 104 |
+
fps = videos[0].fps
|
| 105 |
+
for video in videos[1:]:
|
| 106 |
+
if abs(video.fps - fps) > FPS_RELATIVE_TOLERANCE * fps:
|
| 107 |
+
raise ValueError(f"camera videos have different frame rates: {fps} vs {video.fps}")
|
| 108 |
+
counts = [video.num_frames for video in videos]
|
| 109 |
+
if max(counts) - min(counts) > max(1, round(0.01 * max(counts))):
|
| 110 |
+
raise ValueError(f"camera videos have different lengths: {sorted(set(counts))} frames")
|
| 111 |
+
return fps, min(counts)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Multi-view collage (terminal and dense prompts)
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# All camera views of one time step are tiled into one frame: one view as is, two or three
|
| 118 |
+
# views side by side, four views as a 2 x 2 grid, in the order given. Every view is resized
|
| 119 |
+
# to a 32-pixel-aligned cell of about 100k pixels, with no crop, padding or gutter.
|
| 120 |
+
|
| 121 |
+
ALIGN = 32
|
| 122 |
+
TARGET_PIXELS_PER_VIEW = 100_000
|
| 123 |
+
MAX_VISUAL_TOKENS = 14_336
|
| 124 |
+
MAX_VIEWS = 4
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _aspect_distortion(source_width: int, source_height: int, width: int, height: int) -> float:
|
| 128 |
+
return 100.0 * abs((width / height) / (source_width / source_height) - 1.0)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _cell_loss(source: tuple[int, int], width: int, height: int) -> float:
|
| 132 |
+
distortion = _aspect_distortion(*source, width, height)
|
| 133 |
+
area_error = abs(math.log((width * height) / TARGET_PIXELS_PER_VIEW))
|
| 134 |
+
over_limit = max(0.0, distortion - 4.0)
|
| 135 |
+
return (distortion / 4.0) ** 2 + (area_error / 0.32) ** 2 + 10_000.0 * over_limit**2
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _candidate_widths(source: tuple[int, int], height: int) -> list[int]:
|
| 139 |
+
center = round(source[0] / source[1] * height / ALIGN) * ALIGN
|
| 140 |
+
return sorted(
|
| 141 |
+
{
|
| 142 |
+
center + offset * ALIGN
|
| 143 |
+
for offset in range(-4, 5)
|
| 144 |
+
if 2 * ALIGN <= center + offset * ALIGN <= 32 * ALIGN
|
| 145 |
+
}
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _heights() -> range:
|
| 150 |
+
return range(4 * ALIGN, 17 * ALIGN, ALIGN)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _tokens(width: int, height: int) -> int:
|
| 154 |
+
return 32 * (width // ALIGN) * (height // ALIGN)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _single_cell(source: tuple[int, int]) -> tuple[int, int]:
|
| 158 |
+
candidates = [
|
| 159 |
+
(_cell_loss(source, width, height), width, height)
|
| 160 |
+
for height in _heights()
|
| 161 |
+
for width in _candidate_widths(source, height)
|
| 162 |
+
]
|
| 163 |
+
_, width, height = min(candidates)
|
| 164 |
+
return width, height
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _row_cells(sources: Sequence[tuple[int, int]]):
|
| 168 |
+
candidates = []
|
| 169 |
+
for height in _heights():
|
| 170 |
+
widths = [
|
| 171 |
+
min(_candidate_widths(s, height), key=lambda w, s=s: _cell_loss(s, w, height))
|
| 172 |
+
for s in sources
|
| 173 |
+
]
|
| 174 |
+
loss = sum(_cell_loss(s, w, height) for s, w in zip(sources, widths))
|
| 175 |
+
if _tokens(sum(widths), height) <= MAX_VISUAL_TOKENS:
|
| 176 |
+
candidates.append((loss, [(w, height) for w in widths], sum(widths), height))
|
| 177 |
+
if not candidates:
|
| 178 |
+
raise ValueError("no side-by-side layout fits the visual token budget")
|
| 179 |
+
_, cells, total_width, total_height = min(candidates)
|
| 180 |
+
return cells, total_width, total_height
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _row_options(first: tuple[int, int], second: tuple[int, int]):
|
| 184 |
+
options: dict[int, tuple[float, int, int, int]] = {}
|
| 185 |
+
for height in _heights():
|
| 186 |
+
for w1 in _candidate_widths(first, height):
|
| 187 |
+
for w2 in _candidate_widths(second, height):
|
| 188 |
+
loss = _cell_loss(first, w1, height) + _cell_loss(second, w2, height)
|
| 189 |
+
current = options.get(w1 + w2)
|
| 190 |
+
if current is None or loss < current[0]:
|
| 191 |
+
options[w1 + w2] = (loss, height, w1, w2)
|
| 192 |
+
return options
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _grid_cells(sources: Sequence[tuple[int, int]]):
|
| 196 |
+
top_options = _row_options(sources[0], sources[1])
|
| 197 |
+
bottom_options = _row_options(sources[2], sources[3])
|
| 198 |
+
candidates = []
|
| 199 |
+
for total_width in top_options.keys() & bottom_options.keys():
|
| 200 |
+
top, bottom = top_options[total_width], bottom_options[total_width]
|
| 201 |
+
if _tokens(total_width, top[1] + bottom[1]) <= MAX_VISUAL_TOKENS:
|
| 202 |
+
candidates.append((top[0] + bottom[0], total_width, top, bottom))
|
| 203 |
+
if not candidates:
|
| 204 |
+
raise ValueError("no 2 x 2 layout fits the visual token budget")
|
| 205 |
+
_, total_width, top, bottom = min(candidates)
|
| 206 |
+
(_, top_h, tl_w, tr_w), (_, bottom_h, bl_w, br_w) = top, bottom
|
| 207 |
+
rectangles = [
|
| 208 |
+
(0, 0, tl_w, top_h),
|
| 209 |
+
(tl_w, 0, tr_w, top_h),
|
| 210 |
+
(0, top_h, bl_w, bottom_h),
|
| 211 |
+
(bl_w, top_h, br_w, bottom_h),
|
| 212 |
+
]
|
| 213 |
+
return rectangles, total_width, top_h + bottom_h
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@cache
|
| 217 |
+
def plan_collage(sources: tuple[tuple[int, int], ...]):
|
| 218 |
+
"""Return ``(rectangles, width, height)``: one ``(x, y, w, h)`` cell per view, in order."""
|
| 219 |
+
if not 1 <= len(sources) <= MAX_VIEWS:
|
| 220 |
+
raise ValueError(f"1 to {MAX_VIEWS} camera views are supported, got {len(sources)}")
|
| 221 |
+
if len(sources) == 1:
|
| 222 |
+
width, height = _single_cell(sources[0])
|
| 223 |
+
return ((0, 0, width, height),), width, height
|
| 224 |
+
if len(sources) in (2, 3):
|
| 225 |
+
cells, width, height = _row_cells(sources)
|
| 226 |
+
rectangles, x = [], 0
|
| 227 |
+
for w, h in cells:
|
| 228 |
+
rectangles.append((x, 0, w, h))
|
| 229 |
+
x += w
|
| 230 |
+
return tuple(rectangles), width, height
|
| 231 |
+
rectangles, width, height = _grid_cells(sources)
|
| 232 |
+
return tuple(rectangles), width, height
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class Collage:
|
| 236 |
+
"""Synchronized camera videos composed into one collage video."""
|
| 237 |
+
|
| 238 |
+
def __init__(self, paths: Sequence[str | Path]):
|
| 239 |
+
if not paths:
|
| 240 |
+
raise ValueError("at least one camera video is required")
|
| 241 |
+
self.videos = [probe_video(path) for path in paths]
|
| 242 |
+
self.fps, self.num_frames = check_synchronized(self.videos)
|
| 243 |
+
sources = tuple((v.width, v.height) for v in self.videos)
|
| 244 |
+
self.rectangles, self.width, self.height = plan_collage(sources)
|
| 245 |
+
self._cache: dict[int, np.ndarray] = {}
|
| 246 |
+
|
| 247 |
+
@property
|
| 248 |
+
def duration_s(self) -> float:
|
| 249 |
+
return self.num_frames / self.fps
|
| 250 |
+
|
| 251 |
+
def release(self) -> None:
|
| 252 |
+
"""Drop decoded frames."""
|
| 253 |
+
self._cache.clear()
|
| 254 |
+
|
| 255 |
+
def frames(self, indices: Sequence[int]) -> np.ndarray:
|
| 256 |
+
"""``[T, H, W, 3]`` uint8 collage frames for source frame ``indices``."""
|
| 257 |
+
missing = sorted({int(i) for i in indices} - set(self._cache))
|
| 258 |
+
if missing:
|
| 259 |
+
per_view = [
|
| 260 |
+
read_frames(video.path, missing, size=(w, h))
|
| 261 |
+
for video, (_, _, w, h) in zip(self.videos, self.rectangles)
|
| 262 |
+
]
|
| 263 |
+
for index in missing:
|
| 264 |
+
canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
| 265 |
+
for frames, (x, y, w, h) in zip(per_view, self.rectangles):
|
| 266 |
+
canvas[y : y + h, x : x + w] = frames[index]
|
| 267 |
+
self._cache[index] = canvas
|
| 268 |
+
return np.stack([self._cache[int(i)] for i in indices])
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def video_metadata(indices: Sequence[int], fps: float, num_frames: int) -> dict[str, Any]:
|
| 272 |
+
"""Frame-clock metadata so the model's timestamps are absolute episode seconds."""
|
| 273 |
+
return {
|
| 274 |
+
"total_num_frames": int(num_frames),
|
| 275 |
+
"fps": float(fps),
|
| 276 |
+
"frames_indices": [int(i) for i in indices],
|
| 277 |
+
"duration": float(num_frames) / float(fps),
|
| 278 |
+
"do_sample_frames": False,
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _messages(system: str, user: str) -> list[dict[str, Any]]:
|
| 283 |
+
before, marker, after = user.partition("<video>")
|
| 284 |
+
if not marker:
|
| 285 |
+
raise AssertionError("user prompt lost its video marker")
|
| 286 |
+
content: list[dict[str, Any]] = []
|
| 287 |
+
if before.strip():
|
| 288 |
+
content.append({"type": "text", "text": before.strip()})
|
| 289 |
+
content.append({"type": "video", "video": VIDEO_PLACEHOLDER})
|
| 290 |
+
if after.strip():
|
| 291 |
+
content.append({"type": "text", "text": after.strip()})
|
| 292 |
+
return [{"role": "system", "content": system}, {"role": "user", "content": content}]
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _compact(text: str) -> str:
|
| 296 |
+
compact = " ".join(str(text or "").split())
|
| 297 |
+
if not compact:
|
| 298 |
+
raise ValueError("a non-empty task instruction is required")
|
| 299 |
+
return compact
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ---------------------------------------------------------------------------
|
| 303 |
+
# Terminal reward: one 1-5 score per episode
|
| 304 |
+
# ---------------------------------------------------------------------------
|
| 305 |
+
|
| 306 |
+
TERMINAL_MAX_FRAMES = 64
|
| 307 |
+
TERMINAL_MAX_SAMPLE_FPS = 10.0
|
| 308 |
+
TERMINAL_TOP_LOGPROBS = 20
|
| 309 |
+
|
| 310 |
+
_TERMINAL_SYSTEM = """You are a terminal-success reward model for robot trajectories.
|
| 311 |
+
Given the task instruction and the complete trajectory video, predict a score from 1 to 5.
|
| 312 |
+
A complete failure is 1. A complete success is 5.
|
| 313 |
+
Judge only what is visible in the trajectory and follow the requested output format exactly."""
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def terminal_frame_indices(num_frames: int, fps: float) -> list[int]:
|
| 317 |
+
"""Uniform whole-episode sampling: ``min(64, floor(duration * 10))`` frames, even, >= 2.
|
| 318 |
+
|
| 319 |
+
The frame count follows the episode duration; keep it, the model relies on it.
|
| 320 |
+
"""
|
| 321 |
+
if num_frames <= 0 or not math.isfinite(fps) or fps <= 0:
|
| 322 |
+
raise ValueError(f"invalid timing: frames={num_frames}, fps={fps}")
|
| 323 |
+
count = min(TERMINAL_MAX_FRAMES, max(2, math.floor(num_frames / fps * TERMINAL_MAX_SAMPLE_FPS)))
|
| 324 |
+
count = max(2, count - count % 2)
|
| 325 |
+
if num_frames == 1:
|
| 326 |
+
return [0, 0]
|
| 327 |
+
return [round(i * (num_frames - 1) / (count - 1)) for i in range(count)]
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def terminal_messages(task: str, with_rationale: bool = False) -> list[dict[str, Any]]:
|
| 331 |
+
if with_rationale:
|
| 332 |
+
suffix = (
|
| 333 |
+
"Provide one short, compact rationale followed by the score. Return exactly:\n"
|
| 334 |
+
"Rationale: <rationale>\n\nScore: <1-5>"
|
| 335 |
+
)
|
| 336 |
+
else:
|
| 337 |
+
suffix = "Do not provide a rationale. Return exactly:\nScore: <1-5>"
|
| 338 |
+
user = (
|
| 339 |
+
f"Task: {_compact(task)}\n\n"
|
| 340 |
+
"Complete trajectory video:\n<video>\n\n"
|
| 341 |
+
"Evaluate the terminal success of the task."
|
| 342 |
+
)
|
| 343 |
+
return _messages(f"{_TERMINAL_SYSTEM}\n{suffix}", user)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def terminal_regex(with_rationale: bool = False) -> str:
|
| 347 |
+
return r"Rationale: [^\n]{1,512}\n\nScore: [1-5]" if with_rationale else r"Score: [1-5]"
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def terminal_max_tokens(with_rationale: bool = False) -> int:
|
| 351 |
+
return 160 if with_rationale else 12
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def parse_terminal(text: str, with_rationale: bool = False) -> tuple[int, str | None]:
|
| 355 |
+
if with_rationale:
|
| 356 |
+
match = re.fullmatch(r"Rationale:\s*(.*?)\n\s*\nScore:\s*([1-5])\s*$", text.strip(), re.DOTALL)
|
| 357 |
+
if match is None or not " ".join(match.group(1).split()):
|
| 358 |
+
raise ValueError(f"unexpected terminal answer: {text!r}")
|
| 359 |
+
return int(match.group(2)), " ".join(match.group(1).split())
|
| 360 |
+
match = re.fullmatch(r"Score:\s*([1-5])\s*$", text.strip())
|
| 361 |
+
if match is None:
|
| 362 |
+
raise ValueError(f"unexpected terminal answer: {text!r}")
|
| 363 |
+
return int(match.group(1)), None
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def score_probabilities(
|
| 367 |
+
positions: Sequence[tuple[str, float, Sequence[tuple[str, float]]]], score: int
|
| 368 |
+
) -> tuple[dict[str, float], float]:
|
| 369 |
+
"""``P(1..5)`` at the generated score token, and the expected score.
|
| 370 |
+
|
| 371 |
+
``positions`` has one ``(generated token text, its logprob, top candidates)`` entry per
|
| 372 |
+
generated token, where top candidates are ``(token text, logprob)`` pairs.
|
| 373 |
+
"""
|
| 374 |
+
for token, logprob, candidates in reversed(list(positions)):
|
| 375 |
+
if token.strip() != str(score):
|
| 376 |
+
continue
|
| 377 |
+
mass = dict.fromkeys(range(1, 6), 0.0)
|
| 378 |
+
seen = set()
|
| 379 |
+
for text, value in [(token, logprob), *candidates]:
|
| 380 |
+
if text.strip() in {"1", "2", "3", "4", "5"} and (text, value) not in seen:
|
| 381 |
+
seen.add((text, value))
|
| 382 |
+
mass[int(text.strip())] += math.exp(value)
|
| 383 |
+
total = sum(mass.values())
|
| 384 |
+
probabilities = {str(k): mass[k] / total for k in range(1, 6)}
|
| 385 |
+
return probabilities, sum(k * probabilities[str(k)] for k in range(1, 6))
|
| 386 |
+
raise ValueError("the generated score token is missing from the logprobs")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
# Dense reward: one 1-3 score per 0.5 s
|
| 391 |
+
# ---------------------------------------------------------------------------
|
| 392 |
+
# An episode is covered by 16 s clips of 64 collage frames at 4 FPS. Frame position 9 is the
|
| 393 |
+
# clip anchor; consecutive anchors are 10 s apart, and the last clip ends at the episode end.
|
| 394 |
+
# The vision encoder merges each pair of frames, so a clip has 32 groups of 0.5 s, and the
|
| 395 |
+
# model scores every group. Clips overlap; stitching drops the last 4 s of every clip except
|
| 396 |
+
# the final one and gives every source frame the score of the nearest kept group.
|
| 397 |
+
|
| 398 |
+
DENSE_FRAMES = 64
|
| 399 |
+
DENSE_FPS = 4.0
|
| 400 |
+
DENSE_GROUPS = 32
|
| 401 |
+
DENSE_HISTORY = 8
|
| 402 |
+
DENSE_SCORED_SPAN = 40
|
| 403 |
+
DENSE_STRIDE_S = 10.0
|
| 404 |
+
DENSE_TAIL_TRIM_S = 4.0
|
| 405 |
+
DENSE_MAX_TOKENS = 640
|
| 406 |
+
MAX_RATIONALE_CHARS = 240
|
| 407 |
+
LABELS = {1: "failure", 2: "suboptimal", 3: "normal"}
|
| 408 |
+
|
| 409 |
+
_DENSE_SYSTEM = f"""You are a dense action-quality reward model for robot manipulation.
|
| 410 |
+
You watch a synchronized multiview clip and predict action quality at the finest temporal resolution visible to the model.
|
| 411 |
+
|
| 412 |
+
The clip contains exactly {DENSE_FRAMES} sampled frames at {DENSE_FPS:g} FPS. The vision encoder combines each consecutive pair of frames into one temporal group, so you must predict exactly {DENSE_GROUPS} ordered groups. Group 1 contains frames 1-2, group 2 contains frames 3-4, and group {DENSE_GROUPS} contains frames {DENSE_FRAMES - 1}-{DENSE_FRAMES}.
|
| 413 |
+
The clip is retrospective: a later recovery must not improve an earlier group and a later unrelated mistake must not degrade it.
|
| 414 |
+
|
| 415 |
+
Use exactly this score scale:
|
| 416 |
+
1 = failure: the action causes or clearly constitutes a major task failure, unsafe event, destructive mistake, or loss of task viability.
|
| 417 |
+
2 = suboptimal: the action is noticeably hesitant, inefficient, imprecise, or locally incorrect, but the task remains recoverable.
|
| 418 |
+
3 = normal: the action is appropriate, safe, and reasonably efficient for the visible situation.
|
| 419 |
+
|
| 420 |
+
Return the worst score before the temporal sequence. Worst must equal the minimum of all {DENSE_GROUPS} quality digits. Quality must contain exactly {DENSE_GROUPS} characters from 1, 2, or 3 with no spaces; character i is the score for group i.
|
| 421 |
+
For every contiguous group range scored 1 or 2, add one short rationale grounded only in visible evidence. Groups scored 3 get no rationale. Rationale ranges must exactly match the contiguous non-normal runs in Quality.
|
| 422 |
+
|
| 423 |
+
Return exactly:
|
| 424 |
+
Worst: <1, 2, or 3>
|
| 425 |
+
Quality:
|
| 426 |
+
<{DENSE_GROUPS} digits with no spaces>
|
| 427 |
+
Rationales:
|
| 428 |
+
<first group>-<last group> <short visible-evidence rationale>
|
| 429 |
+
<one line per contiguous group range scored 1 or 2, in time order>"""
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
@dataclass(frozen=True)
|
| 433 |
+
class DenseWindow:
|
| 434 |
+
index: int
|
| 435 |
+
anchor: int
|
| 436 |
+
frame_indices: tuple[int, ...]
|
| 437 |
+
real: tuple[bool, ...]
|
| 438 |
+
is_last: bool
|
| 439 |
+
fps: float
|
| 440 |
+
|
| 441 |
+
@property
|
| 442 |
+
def start_s(self) -> float:
|
| 443 |
+
return self.frame_indices[0] / self.fps
|
| 444 |
+
|
| 445 |
+
@property
|
| 446 |
+
def end_s(self) -> float:
|
| 447 |
+
return self.frame_indices[-1] / self.fps
|
| 448 |
+
|
| 449 |
+
def group_frames(self, group: int) -> tuple[int, int]:
|
| 450 |
+
"""Source frames of 1-based ``group``."""
|
| 451 |
+
return self.frame_indices[2 * group - 2], self.frame_indices[2 * group - 1]
|
| 452 |
+
|
| 453 |
+
def group_valid(self, group: int) -> bool:
|
| 454 |
+
"""A group is valid when at least one of its frames is inside the episode."""
|
| 455 |
+
return self.real[2 * group - 2] or self.real[2 * group - 1]
|
| 456 |
+
|
| 457 |
+
def group_time_s(self, group: int) -> float:
|
| 458 |
+
left, right = self.group_frames(group)
|
| 459 |
+
return (left + right) / (2.0 * self.fps)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def dense_windows(num_frames: int, fps: float, stride_s: float = DENSE_STRIDE_S) -> list[DenseWindow]:
|
| 463 |
+
if num_frames < 1:
|
| 464 |
+
raise ValueError("episode length must be positive")
|
| 465 |
+
stride = round(stride_s * fps)
|
| 466 |
+
if stride < 1:
|
| 467 |
+
raise ValueError(f"window stride collapses at {fps} FPS")
|
| 468 |
+
last_scored = round((DENSE_SCORED_SPAN - 1) * fps / DENSE_FPS)
|
| 469 |
+
maximum = max(0, num_frames - 1 - last_scored)
|
| 470 |
+
anchors = list(range(0, maximum, stride))
|
| 471 |
+
if not anchors or anchors[-1] != maximum:
|
| 472 |
+
anchors.append(maximum)
|
| 473 |
+
steps = range(-DENSE_HISTORY, DENSE_FRAMES - DENSE_HISTORY)
|
| 474 |
+
windows = []
|
| 475 |
+
for index, anchor in enumerate(anchors):
|
| 476 |
+
positions = [round(anchor + step * fps / DENSE_FPS) for step in steps]
|
| 477 |
+
raw = [anchor + round(step * fps / DENSE_FPS) for step in steps]
|
| 478 |
+
windows.append(
|
| 479 |
+
DenseWindow(
|
| 480 |
+
index=index,
|
| 481 |
+
anchor=anchor,
|
| 482 |
+
frame_indices=tuple(min(num_frames - 1, max(0, value)) for value in positions),
|
| 483 |
+
real=tuple(0 <= value < num_frames for value in raw),
|
| 484 |
+
is_last=index == len(anchors) - 1,
|
| 485 |
+
fps=float(fps),
|
| 486 |
+
)
|
| 487 |
+
)
|
| 488 |
+
return windows
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def dense_messages(task: str, window: DenseWindow) -> list[dict[str, Any]]:
|
| 492 |
+
user = "\n".join(
|
| 493 |
+
[
|
| 494 |
+
f"Task: {_compact(task)}",
|
| 495 |
+
"",
|
| 496 |
+
"Synchronized multiview clip:",
|
| 497 |
+
"<video>",
|
| 498 |
+
"",
|
| 499 |
+
(
|
| 500 |
+
f"The clip contains {DENSE_FRAMES} sampled frame positions at {DENSE_FPS:g} FPS "
|
| 501 |
+
f"({DENSE_FRAMES / DENSE_FPS:g} seconds nominal span) and {DENSE_GROUPS} visible "
|
| 502 |
+
f"temporal groups of 2 frames each. The sampled positions run from "
|
| 503 |
+
f"t={window.start_s:.3f} to t={window.end_s:.3f} seconds of the episode. "
|
| 504 |
+
"Episode boundaries repeat the nearest available frame."
|
| 505 |
+
),
|
| 506 |
+
f"Predict Worst, all {DENSE_GROUPS} group scores, and rationales for non-normal runs.",
|
| 507 |
+
]
|
| 508 |
+
)
|
| 509 |
+
return _messages(_DENSE_SYSTEM, user)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def dense_regex() -> str:
|
| 513 |
+
numbers = sorted(range(1, DENSE_GROUPS + 1), key=lambda v: -len(str(v)))
|
| 514 |
+
group = "(?:" + "|".join(str(v) for v in numbers) + ")"
|
| 515 |
+
rationale = f"{group}-{group} [^\\n]{{1,{MAX_RATIONALE_CHARS}}}"
|
| 516 |
+
return f"Worst: [123]\\nQuality:\\n[123]{{{DENSE_GROUPS}}}\\nRationales:(?:\\n{rationale})*"
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@dataclass(frozen=True)
|
| 520 |
+
class DenseAnswer:
|
| 521 |
+
scores: tuple[int, ...] # one 1-3 score per group
|
| 522 |
+
regions: tuple[tuple[int, int, int, str | None], ...] # (first group, last group, score, rationale)
|
| 523 |
+
valid: bool
|
| 524 |
+
error: str | None = None
|
| 525 |
+
unmatched_rationales: int = 0 # rationale lines whose span is not a non-normal run
|
| 526 |
+
missing_rationales: int = 0 # non-normal runs without a rationale line
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _runs(values: Sequence[int]) -> list[tuple[int, int, int]]:
|
| 530 |
+
"""(start, end_exclusive, value) runs of equal values."""
|
| 531 |
+
runs, start = [], 0
|
| 532 |
+
for end in range(1, len(values) + 1):
|
| 533 |
+
if end == len(values) or values[end] != values[start]:
|
| 534 |
+
runs.append((start, end, int(values[start])))
|
| 535 |
+
start = end
|
| 536 |
+
return runs
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def parse_dense(text: str) -> DenseAnswer:
|
| 540 |
+
"""Strict parse. An unparseable answer becomes all-normal with ``valid=False``."""
|
| 541 |
+
try:
|
| 542 |
+
lines = text.strip("\n").split("\n")
|
| 543 |
+
if len(lines) < 4 or lines[1] != "Quality:" or lines[3] != "Rationales:":
|
| 544 |
+
raise ValueError("missing Worst / Quality / Rationales headers")
|
| 545 |
+
worst = re.fullmatch(r"Worst: ([123])", lines[0])
|
| 546 |
+
if worst is None or re.fullmatch(rf"[123]{{{DENSE_GROUPS}}}", lines[2]) is None:
|
| 547 |
+
raise ValueError("malformed Worst or Quality field")
|
| 548 |
+
scores = tuple(int(c) for c in lines[2])
|
| 549 |
+
if int(worst.group(1)) != min(scores):
|
| 550 |
+
raise ValueError("Worst disagrees with Quality")
|
| 551 |
+
given = {}
|
| 552 |
+
for line in lines[4:]:
|
| 553 |
+
match = re.fullmatch(r"(\d{1,2})-(\d{1,2}) (.+)", line)
|
| 554 |
+
if match is None:
|
| 555 |
+
raise ValueError(f"malformed rationale line: {line!r}")
|
| 556 |
+
given[(int(match.group(1)), int(match.group(2)))] = " ".join(match.group(3).split())
|
| 557 |
+
except ValueError as error:
|
| 558 |
+
return DenseAnswer((3,) * DENSE_GROUPS, ((1, DENSE_GROUPS, 3, None),), False, str(error))
|
| 559 |
+
runs = [(start + 1, end, score) for start, end, score in _runs(scores)]
|
| 560 |
+
expected = {(first, last) for first, last, score in runs if score < 3}
|
| 561 |
+
regions = tuple((first, last, score, given.get((first, last))) for first, last, score in runs)
|
| 562 |
+
return DenseAnswer(scores, regions, True, None, len(set(given) - expected), len(expected - set(given)))
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def window_regions(window: DenseWindow, answer: DenseAnswer) -> list[dict[str, Any]]:
|
| 566 |
+
"""Same-score group runs of one clip, with absolute times and rationales."""
|
| 567 |
+
return [
|
| 568 |
+
{
|
| 569 |
+
"first_group": first,
|
| 570 |
+
"last_group": last,
|
| 571 |
+
"score": score,
|
| 572 |
+
"label": LABELS[score],
|
| 573 |
+
"start_s": round(window.group_time_s(first), 3),
|
| 574 |
+
"end_s": round(window.group_time_s(last), 3),
|
| 575 |
+
"rationale": rationale,
|
| 576 |
+
}
|
| 577 |
+
for first, last, score, rationale in answer.regions
|
| 578 |
+
]
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def stitch_dense(
|
| 582 |
+
windows: Sequence[DenseWindow],
|
| 583 |
+
answers: Sequence[DenseAnswer],
|
| 584 |
+
num_frames: int,
|
| 585 |
+
tail_trim_s: float = DENSE_TAIL_TRIM_S,
|
| 586 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 587 |
+
"""Per-source-frame scores from overlapping clips.
|
| 588 |
+
|
| 589 |
+
Every clip except the last drops its final ``tail_trim_s`` seconds. Each source frame then
|
| 590 |
+
takes the score of the nearest kept group; ties prefer the group farther from its clip's
|
| 591 |
+
edges, then the earlier clip. Returns ``(scores, clip index, group)`` per source frame.
|
| 592 |
+
"""
|
| 593 |
+
if tail_trim_s < 0 or (tail_trim_s * DENSE_FPS) % 2:
|
| 594 |
+
raise ValueError("tail trim must be a non-negative multiple of 0.5 s")
|
| 595 |
+
trim_groups = round(tail_trim_s * DENSE_FPS / 2)
|
| 596 |
+
if trim_groups >= DENSE_GROUPS:
|
| 597 |
+
raise ValueError("tail trim removes every group")
|
| 598 |
+
rows = [] # (frame, -interiority, clip, group, score)
|
| 599 |
+
for window, answer in zip(windows, answers, strict=True):
|
| 600 |
+
kept = DENSE_GROUPS if window.is_last else DENSE_GROUPS - trim_groups
|
| 601 |
+
for group in range(1, kept + 1):
|
| 602 |
+
if not window.group_valid(group):
|
| 603 |
+
continue
|
| 604 |
+
interiority = min(group - 1, DENSE_GROUPS - group)
|
| 605 |
+
for frame in sorted(set(window.group_frames(group))):
|
| 606 |
+
rows.append((frame, -interiority, window.index, group, answer.scores[group - 1]))
|
| 607 |
+
if not rows:
|
| 608 |
+
raise ValueError("no kept groups cover the episode")
|
| 609 |
+
rows.sort(key=lambda row: (row[1], row[2], row[3]))
|
| 610 |
+
frames = np.asarray([row[0] for row in rows], dtype=np.int64)
|
| 611 |
+
best = np.empty(num_frames, dtype=np.int64)
|
| 612 |
+
for start in range(0, num_frames, 4096):
|
| 613 |
+
query = np.arange(start, min(num_frames, start + 4096), dtype=np.int64)
|
| 614 |
+
best[start : start + len(query)] = np.argmin(np.abs(frames[None, :] - query[:, None]), axis=1)
|
| 615 |
+
pick = [rows[i] for i in best]
|
| 616 |
+
return (
|
| 617 |
+
np.asarray([row[4] for row in pick], dtype=np.int8),
|
| 618 |
+
np.asarray([row[2] for row in pick], dtype=np.int32),
|
| 619 |
+
np.asarray([row[3] for row in pick], dtype=np.int32),
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def dense_segments(
|
| 624 |
+
scores: np.ndarray,
|
| 625 |
+
clip_of_frame: np.ndarray,
|
| 626 |
+
group_of_frame: np.ndarray,
|
| 627 |
+
answers: Sequence[DenseAnswer],
|
| 628 |
+
fps: float,
|
| 629 |
+
) -> list[dict[str, Any]]:
|
| 630 |
+
"""Episode-level runs (end-exclusive frames) with the rationale covering most frames."""
|
| 631 |
+
segments = []
|
| 632 |
+
for start, end, score in _runs(scores.tolist()):
|
| 633 |
+
rationale = None
|
| 634 |
+
if score < 3:
|
| 635 |
+
votes: Counter[str] = Counter()
|
| 636 |
+
for frame in range(start, end):
|
| 637 |
+
answer, group = answers[int(clip_of_frame[frame])], int(group_of_frame[frame])
|
| 638 |
+
for first, last, _, text in answer.regions:
|
| 639 |
+
if first <= group <= last and text:
|
| 640 |
+
votes[text] += 1
|
| 641 |
+
break
|
| 642 |
+
if votes:
|
| 643 |
+
rationale = max(sorted(votes), key=lambda text: (votes[text], text))
|
| 644 |
+
segments.append(
|
| 645 |
+
{
|
| 646 |
+
"start_frame": start,
|
| 647 |
+
"end_frame": end,
|
| 648 |
+
"start_s": round(start / fps, 3),
|
| 649 |
+
"end_s": round(end / fps, 3),
|
| 650 |
+
"score": score,
|
| 651 |
+
"label": LABELS[score],
|
| 652 |
+
"rationale": rationale,
|
| 653 |
+
}
|
| 654 |
+
)
|
| 655 |
+
return segments
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
# ---------------------------------------------------------------------------
|
| 659 |
+
# Rubric reward: a score for every step of a user-defined rubric
|
| 660 |
+
# ---------------------------------------------------------------------------
|
| 661 |
+
# Each camera is a separate video ("Video 1", "Video 2", ...), not a collage. The model
|
| 662 |
+
# thinks first, then answers ``ANSWER: [s_1, ..., s_n]`` with one allowed value per step.
|
| 663 |
+
|
| 664 |
+
RUBRIC_SYSTEM = "You are a helpful assistant."
|
| 665 |
+
RUBRIC_FPS = 2.0
|
| 666 |
+
RUBRIC_MAX_FRAMES = 166
|
| 667 |
+
RUBRIC_MIN_PIXELS = 64 * 32 * 32
|
| 668 |
+
RUBRIC_MAX_PIXELS = 128 * 32 * 32
|
| 669 |
+
RUBRIC_TOTAL_PIXELS = 224 * 1024 * 32 * 32
|
| 670 |
+
_COUNT = {1: "one video", 2: "two time-synchronized videos", 3: "three time-synchronized videos",
|
| 671 |
+
4: "four time-synchronized videos"}
|
| 672 |
+
|
| 673 |
+
_RUBRIC_INSTRUCTIONS = (
|
| 674 |
+
"Score how well the robot performed, using the rubric below. The rubric lists the task's "
|
| 675 |
+
"steps; each step has its own maximum number of points and an explicit list of the point "
|
| 676 |
+
"values you may award for it. For every step, award exactly one of the values listed for "
|
| 677 |
+
"that step -- not a value in between.\n"
|
| 678 |
+
)
|
| 679 |
+
_RUBRIC_FOOTER = (
|
| 680 |
+
"\nRespond with your scores as a JSON array of numbers -- one score per step, in the "
|
| 681 |
+
"exact order listed above (a length-{n} array). Each number must be one of the values "
|
| 682 |
+
"allowed for that step. Output the array on a single line prefixed with 'ANSWER:' and "
|
| 683 |
+
"nothing after it, e.g.\nANSWER: {example}\n"
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def rubric_header(robot: str, cameras: Sequence[str]) -> str:
|
| 688 |
+
"""Describe the robot and every camera, in video order.
|
| 689 |
+
|
| 690 |
+
Example: ``rubric_header("bimanual robot", ["a top (scene) camera", "a left-wrist camera",
|
| 691 |
+
"a right-wrist camera"])``.
|
| 692 |
+
"""
|
| 693 |
+
if not 1 <= len(cameras) <= 4:
|
| 694 |
+
raise ValueError("1 to 4 camera videos are supported")
|
| 695 |
+
views = [f"Video {i} is {_compact(text)}" for i, text in enumerate(cameras, 1)]
|
| 696 |
+
if len(views) > 1:
|
| 697 |
+
views[-1] = "and " + views[-1]
|
| 698 |
+
return (
|
| 699 |
+
"You are an expert evaluator of robot manipulation. You are shown "
|
| 700 |
+
f"{_COUNT[len(cameras)]} of a {_compact(robot)} performing one task: {', '.join(views)}.\n\n"
|
| 701 |
+
)
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
def _steps(rubric: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
| 705 |
+
steps = []
|
| 706 |
+
for item in rubric:
|
| 707 |
+
grading = [{"label": str(g["label"]), "points": float(g["points"])} for g in item["grading"]]
|
| 708 |
+
if not grading:
|
| 709 |
+
raise ValueError("every rubric step needs at least one allowed value")
|
| 710 |
+
maximum = float(item.get("max_score", max(g["points"] for g in grading)))
|
| 711 |
+
steps.append({"action": _compact(item["action"]), "max_score": maximum, "grading": grading})
|
| 712 |
+
if not steps:
|
| 713 |
+
raise ValueError("the rubric has no steps")
|
| 714 |
+
return steps
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
def rubric_prompt(
|
| 718 |
+
task: str,
|
| 719 |
+
rubric: Sequence[Mapping[str, Any]],
|
| 720 |
+
header: str,
|
| 721 |
+
task_name: str | None = None,
|
| 722 |
+
) -> str:
|
| 723 |
+
"""The text that follows the camera videos.
|
| 724 |
+
|
| 725 |
+
``rubric`` is a list of steps: ``{"action": str, "grading": [{"label": str, "points": float},
|
| 726 |
+
...]}`` with optional ``max_score`` (default: the largest allowed value). The step maxima
|
| 727 |
+
should sum to 1.
|
| 728 |
+
"""
|
| 729 |
+
steps = _steps(rubric)
|
| 730 |
+
instruction = _compact(task)
|
| 731 |
+
lines = [header + _RUBRIC_INSTRUCTIONS, f"Task: {_compact(task_name or instruction)}"]
|
| 732 |
+
lines.append(f'Instruction to the robot: "{instruction}"')
|
| 733 |
+
lines.append("\nScoring rubric -- award one of the listed values for each step:")
|
| 734 |
+
for i, step in enumerate(steps, 1):
|
| 735 |
+
lines.append(f" {i}. {step['action']} (max {step['max_score']:g})")
|
| 736 |
+
tiers = " | ".join(f"{g['label']} = {g['points']:.2f}" for g in step["grading"])
|
| 737 |
+
lines.append(f" award one of: {tiers}")
|
| 738 |
+
example = json.dumps([round(step["max_score"], 3) for step in steps])
|
| 739 |
+
lines.append(_RUBRIC_FOOTER.format(n=len(steps), example=example))
|
| 740 |
+
return "\n".join(lines)
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def rubric_messages(prompt: str, video_paths: Sequence[str | Path], fps: float = RUBRIC_FPS,
|
| 744 |
+
max_frames: int = RUBRIC_MAX_FRAMES) -> list[dict[str, Any]]:
|
| 745 |
+
"""Chat messages in the format read by the video loader of ``qwen_vl_utils``."""
|
| 746 |
+
video = {"min_pixels": RUBRIC_MIN_PIXELS, "max_pixels": RUBRIC_MAX_PIXELS,
|
| 747 |
+
"total_pixels": RUBRIC_TOTAL_PIXELS, "max_frames": max_frames, "fps": fps}
|
| 748 |
+
content = [{"type": "video", "video": str(path), **video} for path in video_paths]
|
| 749 |
+
content.append({"type": "text", "text": prompt})
|
| 750 |
+
return [{"role": "system", "content": RUBRIC_SYSTEM}, {"role": "user", "content": content}]
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def split_thinking(text: str) -> tuple[str, str | None]:
|
| 754 |
+
"""``(answer, thinking)``; the answer is empty when generation stopped inside the trace."""
|
| 755 |
+
before, closed, after = text.partition("</think>")
|
| 756 |
+
if not closed:
|
| 757 |
+
return "", before.replace("<think>", "", 1).strip() or None
|
| 758 |
+
return after.strip(), before.replace("<think>", "", 1).strip() or None
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def parse_rubric(answer: str, rubric: Sequence[Mapping[str, Any]]) -> tuple[list[float], bool]:
|
| 762 |
+
"""Scores after the last ``ANSWER:``, aligned to the steps and clamped to ``[0, max]``.
|
| 763 |
+
|
| 764 |
+
Returns ``(scores, ok)``. A missing or wrong-length answer gives ``ok=False``; missing
|
| 765 |
+
steps are scored 0.
|
| 766 |
+
"""
|
| 767 |
+
steps = _steps(rubric)
|
| 768 |
+
parsed = None
|
| 769 |
+
if answer:
|
| 770 |
+
segments = re.split(r"ANSWER\s*:", answer, flags=re.IGNORECASE)
|
| 771 |
+
spaces = [segments[-1]] + ([answer] if len(segments) > 1 else [])
|
| 772 |
+
for space in spaces:
|
| 773 |
+
for match in re.finditer(r"\[[^\[\]]*\]", space, re.DOTALL):
|
| 774 |
+
try:
|
| 775 |
+
values = json.loads(match.group(0))
|
| 776 |
+
except json.JSONDecodeError:
|
| 777 |
+
numbers = re.findall(r"-?\d+(?:\.\d+)?", match.group(0))
|
| 778 |
+
values = [float(x) for x in numbers] if numbers else None
|
| 779 |
+
if isinstance(values, list) and values and all(isinstance(v, (int, float)) for v in values):
|
| 780 |
+
parsed = [float(v) for v in values]
|
| 781 |
+
break
|
| 782 |
+
if parsed is not None:
|
| 783 |
+
break
|
| 784 |
+
if parsed is None:
|
| 785 |
+
numbers = re.findall(r"-?\d+(?:\.\d+)?", spaces[0])
|
| 786 |
+
parsed = [float(x) for x in numbers[: len(steps)]] if numbers else None
|
| 787 |
+
ok = parsed is not None and len(parsed) == len(steps)
|
| 788 |
+
values = list(parsed or []) + [0.0] * len(steps)
|
| 789 |
+
return [min(max(0.0, values[i]), steps[i]["max_score"]) for i in range(len(steps))], ok
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def medoid(score_vectors: Sequence[Sequence[float]]) -> int:
|
| 793 |
+
"""Index of the vector with the smallest summed L1 distance to all others."""
|
| 794 |
+
if not score_vectors:
|
| 795 |
+
raise ValueError("no parsed rubric answers")
|
| 796 |
+
|
| 797 |
+
def l1(a, b):
|
| 798 |
+
return sum(abs(x - y) for x, y in zip(a, b))
|
| 799 |
+
|
| 800 |
+
return min(
|
| 801 |
+
range(len(score_vectors)),
|
| 802 |
+
key=lambda i: (sum(l1(score_vectors[i], other) for other in score_vectors), i),
|
| 803 |
+
)
|
batch_utils/dense_reward.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dense reward: one 1-3 action-quality score per 0.5 s, with rationales for bad spans.
|
| 2 |
+
|
| 3 |
+
python -m batch_utils.dense_reward --jobs jobs.jsonl --output dense.jsonl
|
| 4 |
+
python -m batch_utils.dense_reward --video top.mp4 wrist.mp4 --task "Put the cup on the tray." --output dense.jsonl
|
| 5 |
+
|
| 6 |
+
Each job is ``{"id": ..., "task": ..., "videos": [camera videos, in collage order]}``.
|
| 7 |
+
Scores: 1 = failure, 2 = suboptimal, 3 = normal. The episode is scored in overlapping 16 s
|
| 8 |
+
clips that are stitched into one score per source frame. Scoring is retrospective (a clip
|
| 9 |
+
sees 4 s past each kept moment), so use it for offline labeling, not as an online signal.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from collections import Counter
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from . import core
|
| 19 |
+
from .engine import (
|
| 20 |
+
COLLAGE_VIDEO_SIZE,
|
| 21 |
+
base_parser,
|
| 22 |
+
load_llm,
|
| 23 |
+
load_processor,
|
| 24 |
+
log,
|
| 25 |
+
open_output,
|
| 26 |
+
read_jobs,
|
| 27 |
+
render,
|
| 28 |
+
resolve_model,
|
| 29 |
+
write_record,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def summarize(job, collage, windows, answers, texts, tail_trim_s, include_frames):
|
| 34 |
+
scores, clip_of_frame, group_of_frame = core.stitch_dense(windows, answers, collage.num_frames, tail_trim_s)
|
| 35 |
+
counts = Counter(scores.tolist())
|
| 36 |
+
record = {
|
| 37 |
+
"id": job["id"],
|
| 38 |
+
"task": job["task"],
|
| 39 |
+
"fps": collage.fps,
|
| 40 |
+
"num_frames": collage.num_frames,
|
| 41 |
+
"duration_s": round(collage.duration_s, 3),
|
| 42 |
+
"worst_score": int(scores.min()),
|
| 43 |
+
"mean_score": round(float(scores.mean()), 4),
|
| 44 |
+
"fraction": {core.LABELS[s]: round(counts.get(s, 0) / collage.num_frames, 4) for s in (1, 2, 3)},
|
| 45 |
+
"segments": core.dense_segments(scores, clip_of_frame, group_of_frame, answers, collage.fps),
|
| 46 |
+
# One score per second of video, read at the middle of each second.
|
| 47 |
+
"per_second": [int(scores[min(collage.num_frames - 1, int((t + 0.5) * collage.fps))])
|
| 48 |
+
for t in range(int(np.ceil(collage.duration_s)))],
|
| 49 |
+
"clips": [
|
| 50 |
+
{
|
| 51 |
+
"start_s": round(window.start_s, 3),
|
| 52 |
+
"end_s": round(window.end_s, 3),
|
| 53 |
+
"quality": "".join(map(str, answer.scores)),
|
| 54 |
+
"regions": core.window_regions(window, answer),
|
| 55 |
+
"valid": answer.valid,
|
| 56 |
+
"output": text,
|
| 57 |
+
}
|
| 58 |
+
for window, answer, text in zip(windows, answers, texts)
|
| 59 |
+
],
|
| 60 |
+
"invalid_clips": sum(not a.valid for a in answers),
|
| 61 |
+
}
|
| 62 |
+
if include_frames:
|
| 63 |
+
record["frame_scores"] = scores.tolist()
|
| 64 |
+
return record
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main(argv=None) -> int:
|
| 68 |
+
p = base_parser("FLORA rewarder: dense action-quality scores, 1-3 per 0.5 s.")
|
| 69 |
+
p.add_argument("--tail-trim", type=float, default=core.DENSE_TAIL_TRIM_S,
|
| 70 |
+
help="Seconds dropped from the end of every clip but the last when stitching.")
|
| 71 |
+
p.add_argument("--frame-scores", action="store_true", help="Also write one score per source frame.")
|
| 72 |
+
p.add_argument("--max-model-len", type=int, default=16384)
|
| 73 |
+
args = p.parse_args(argv)
|
| 74 |
+
jobs = read_jobs(args)
|
| 75 |
+
model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
|
| 76 |
+
processor = load_processor(model_path)
|
| 77 |
+
|
| 78 |
+
if args.dry_run:
|
| 79 |
+
job = jobs[0]
|
| 80 |
+
collage = core.Collage(job["videos"])
|
| 81 |
+
windows = core.dense_windows(collage.num_frames, collage.fps)
|
| 82 |
+
prompt = render(processor, core.dense_messages(job["task"], windows[0]), thinking=False)
|
| 83 |
+
log(f"{job['id']}: {collage.num_frames} frames at {collage.fps:g} FPS, collage "
|
| 84 |
+
f"{collage.width}x{collage.height}, {len(windows)} clips\n{prompt}")
|
| 85 |
+
return 0
|
| 86 |
+
|
| 87 |
+
from vllm import SamplingParams
|
| 88 |
+
from vllm.sampling_params import StructuredOutputsParams
|
| 89 |
+
|
| 90 |
+
llm = load_llm(model_path, args, max_model_len=args.max_model_len,
|
| 91 |
+
mm_processor_kwargs={"do_sample_frames": False, "size": COLLAGE_VIDEO_SIZE})
|
| 92 |
+
params = SamplingParams(temperature=0.0, max_tokens=core.DENSE_MAX_TOKENS,
|
| 93 |
+
structured_outputs=StructuredOutputsParams(regex=core.dense_regex()))
|
| 94 |
+
with open_output(args) as stream:
|
| 95 |
+
for number, job in enumerate(jobs, 1):
|
| 96 |
+
collage = core.Collage(job["videos"])
|
| 97 |
+
windows = core.dense_windows(collage.num_frames, collage.fps)
|
| 98 |
+
collage.frames(sorted({i for window in windows for i in window.frame_indices})) # one decoding pass
|
| 99 |
+
texts = []
|
| 100 |
+
for start in range(0, len(windows), args.max_num_seqs):
|
| 101 |
+
batch = windows[start : start + args.max_num_seqs]
|
| 102 |
+
requests = [
|
| 103 |
+
{
|
| 104 |
+
"prompt": render(processor, core.dense_messages(job["task"], window), thinking=False),
|
| 105 |
+
"multi_modal_data": {
|
| 106 |
+
"video": (
|
| 107 |
+
collage.frames(window.frame_indices),
|
| 108 |
+
core.video_metadata(window.frame_indices, collage.fps, collage.num_frames),
|
| 109 |
+
)
|
| 110 |
+
},
|
| 111 |
+
"mm_processor_kwargs": {"do_sample_frames": False},
|
| 112 |
+
}
|
| 113 |
+
for window in batch
|
| 114 |
+
]
|
| 115 |
+
texts += [out.outputs[0].text for out in llm.generate(requests, params, use_tqdm=False)]
|
| 116 |
+
answers = [core.parse_dense(text) for text in texts]
|
| 117 |
+
write_record(stream, summarize(job, collage, windows, answers, texts, args.tail_trim, args.frame_scores))
|
| 118 |
+
collage.release()
|
| 119 |
+
log(f"dense: {number}/{len(jobs)} episodes ({len(windows)} clips)")
|
| 120 |
+
return 0
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
raise SystemExit(main())
|
batch_utils/engine.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared command-line plumbing and the offline vLLM engine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
from typing import Any, Iterable
|
| 12 |
+
|
| 13 |
+
# Without --model: $FLORA_REWARDER_REPO, else the downloaded copy of the repository that holds
|
| 14 |
+
# batch_utils/ (absolute, not resolved: Hub cache files are symlinks).
|
| 15 |
+
MODEL_ENV = "FLORA_REWARDER_REPO"
|
| 16 |
+
REPOSITORY_COPY = Path(__file__).absolute().parent.parent
|
| 17 |
+
# Pixel budget for collage clips (terminal and dense): at least ``shortest_edge`` and at most
|
| 18 |
+
# ``longest_edge`` pixels over all frames of one clip. Collages are planned inside this budget,
|
| 19 |
+
# so frames reach the model at their planned size.
|
| 20 |
+
# Configuration and tokenizer files: enough to render prompts for --dry-run.
|
| 21 |
+
PROCESSOR_FILES = ["*.json", "*.jinja", "*.txt"]
|
| 22 |
+
COLLAGE_VIDEO_SIZE = {"shortest_edge": 4096, "longest_edge": 29_360_128}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def default_model() -> str | None:
|
| 26 |
+
if os.environ.get(MODEL_ENV):
|
| 27 |
+
return os.environ[MODEL_ENV]
|
| 28 |
+
if (REPOSITORY_COPY / "model.safetensors.index.json").is_file():
|
| 29 |
+
return str(REPOSITORY_COPY)
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def base_parser(description: str, *, tensor_parallel_size: int = 1) -> argparse.ArgumentParser:
|
| 34 |
+
p = argparse.ArgumentParser(description=description)
|
| 35 |
+
source = p.add_mutually_exclusive_group(required=True)
|
| 36 |
+
source.add_argument("--jobs", type=Path, help="JSONL file, one episode per line (paths relative to the file).")
|
| 37 |
+
source.add_argument("--video", type=Path, nargs="+", help="Camera video(s) of one episode, in order.")
|
| 38 |
+
p.add_argument("--task", help="Task instruction for --video.")
|
| 39 |
+
p.add_argument("--output", type=Path, required=True, help="Output JSONL file.")
|
| 40 |
+
p.add_argument("--overwrite", action="store_true", help="Replace an existing output file.")
|
| 41 |
+
p.add_argument("--model", default=default_model(),
|
| 42 |
+
help=f"Hub repository id or local directory (default: ${MODEL_ENV}, else this repository's copy).")
|
| 43 |
+
p.add_argument("--revision", default=None, help="Hub revision (branch, tag or commit).")
|
| 44 |
+
p.add_argument("--tensor-parallel-size", type=int, default=tensor_parallel_size)
|
| 45 |
+
p.add_argument("--gpu-memory-utilization", type=float, default=0.88)
|
| 46 |
+
p.add_argument("--max-num-seqs", type=int, default=8, help="Concurrent requests in the engine.")
|
| 47 |
+
p.add_argument("--seed", type=int, default=0)
|
| 48 |
+
p.add_argument("--dry-run", action="store_true", help="Read the videos and print the first prompt; no model.")
|
| 49 |
+
return p
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def read_jobs(args: argparse.Namespace, extra_fields: Iterable[str] = ()) -> list[dict[str, Any]]:
|
| 53 |
+
"""Jobs from ``--jobs`` or from ``--video``/``--task``; video paths become absolute."""
|
| 54 |
+
if args.video:
|
| 55 |
+
if not args.task:
|
| 56 |
+
raise SystemExit("--task is required with --video")
|
| 57 |
+
return [{"id": "episode", "task": args.task, "videos": [str(p.expanduser().resolve()) for p in args.video]}]
|
| 58 |
+
root = args.jobs.expanduser().resolve().parent
|
| 59 |
+
allowed = {"id", "task", "videos", *extra_fields}
|
| 60 |
+
jobs, ids = [], set()
|
| 61 |
+
for number, line in enumerate(args.jobs.read_text().splitlines(), 1):
|
| 62 |
+
if not line.strip():
|
| 63 |
+
continue
|
| 64 |
+
job = json.loads(line)
|
| 65 |
+
if not isinstance(job, dict) or set(job) - allowed or not {"task", "videos"} <= set(job):
|
| 66 |
+
raise SystemExit(f"{args.jobs}:{number}: expected keys {sorted(allowed)}")
|
| 67 |
+
job.setdefault("id", f"episode_{number:06d}")
|
| 68 |
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", str(job["id"])) or job["id"] in ids:
|
| 69 |
+
raise SystemExit(f"{args.jobs}:{number}: invalid or duplicate id {job['id']!r}")
|
| 70 |
+
ids.add(job["id"])
|
| 71 |
+
if not isinstance(job["videos"], list) or not job["videos"]:
|
| 72 |
+
raise SystemExit(f"{args.jobs}:{number}: 'videos' must be a non-empty list")
|
| 73 |
+
job["videos"] = [str((root / Path(v).expanduser()).resolve()) for v in job["videos"]]
|
| 74 |
+
jobs.append(job)
|
| 75 |
+
if not jobs:
|
| 76 |
+
raise SystemExit(f"{args.jobs} has no jobs")
|
| 77 |
+
return jobs
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def open_output(args: argparse.Namespace):
|
| 81 |
+
if args.output.exists() and not args.overwrite:
|
| 82 |
+
raise SystemExit(f"{args.output} exists; pass --overwrite to replace it")
|
| 83 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
return args.output.open("w")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def write_record(stream, record: dict[str, Any]) -> None:
|
| 88 |
+
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 89 |
+
stream.flush()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def resolve_model(model: str | None, revision: str | None = None, *, weights: bool = True) -> str:
|
| 93 |
+
"""A local directory with the model; ``weights=False`` fetches only the processor files."""
|
| 94 |
+
if not model:
|
| 95 |
+
raise SystemExit(f"Pass --model (a Hub repository id or a local directory) or set {MODEL_ENV}.")
|
| 96 |
+
path = Path(model).expanduser()
|
| 97 |
+
if path.is_dir():
|
| 98 |
+
return str(path.resolve())
|
| 99 |
+
from huggingface_hub import snapshot_download
|
| 100 |
+
|
| 101 |
+
return snapshot_download(model, revision=revision, allow_patterns=None if weights else PROCESSOR_FILES)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def load_processor(path: str):
|
| 105 |
+
from transformers import AutoProcessor
|
| 106 |
+
|
| 107 |
+
return AutoProcessor.from_pretrained(path)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_llm(
|
| 111 |
+
path: str,
|
| 112 |
+
args: argparse.Namespace,
|
| 113 |
+
*,
|
| 114 |
+
max_model_len: int,
|
| 115 |
+
videos_per_prompt: int = 1,
|
| 116 |
+
mm_processor_kwargs: dict[str, Any] | None = None,
|
| 117 |
+
**engine_kwargs: Any,
|
| 118 |
+
):
|
| 119 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 120 |
+
from vllm import LLM
|
| 121 |
+
|
| 122 |
+
return LLM(
|
| 123 |
+
model=path,
|
| 124 |
+
tensor_parallel_size=args.tensor_parallel_size,
|
| 125 |
+
dtype="bfloat16",
|
| 126 |
+
gpu_memory_utilization=args.gpu_memory_utilization,
|
| 127 |
+
max_model_len=max_model_len,
|
| 128 |
+
max_num_seqs=args.max_num_seqs,
|
| 129 |
+
limit_mm_per_prompt={"image": 0, "video": videos_per_prompt},
|
| 130 |
+
mm_processor_kwargs=mm_processor_kwargs,
|
| 131 |
+
# Triton prefill for the linear-attention layers: the configuration the reported
|
| 132 |
+
# results were measured with.
|
| 133 |
+
gdn_prefill_backend="triton",
|
| 134 |
+
enable_prefix_caching=True,
|
| 135 |
+
seed=args.seed,
|
| 136 |
+
**engine_kwargs,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def render(processor, messages, *, thinking: bool, vision_ids: bool = False) -> str:
|
| 141 |
+
return processor.apply_chat_template(
|
| 142 |
+
messages,
|
| 143 |
+
tokenize=False,
|
| 144 |
+
add_generation_prompt=True,
|
| 145 |
+
enable_thinking=thinking,
|
| 146 |
+
add_vision_id=vision_ids,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def log(message: str) -> None:
|
| 151 |
+
print(message, file=sys.stderr, flush=True)
|
batch_utils/examples/jobs.jsonl
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": "episode_0001", "task": "Put the cup on the tray.", "videos": ["videos/episode_0001/left_wrist.mp4", "videos/episode_0001/top.mp4", "videos/episode_0001/right_wrist.mp4"]}
|
| 2 |
+
{"id": "episode_0002", "task": "Open the drawer and put the sponge inside.", "videos": ["videos/episode_0002/exterior_left.mp4", "videos/episode_0002/wrist.mp4", "videos/episode_0002/exterior_right.mp4"]}
|
batch_utils/examples/rubric_jobs.jsonl
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"id": "episode_0001", "task": "Put the cup on the tray.", "robot": "bimanual robot", "cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"], "videos": ["videos/episode_0001/top.mp4", "videos/episode_0001/left_wrist.mp4", "videos/episode_0001/right_wrist.mp4"], "rubric": [{"action": "Grasp the cup", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Grasped after re-tries", "points": 0.1}, {"label": "Clean grasp", "points": 0.2}]}, {"action": "Carry the cup to the tray", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Slipped or dropped on the way", "points": 0.15}, {"label": "Carried to the tray", "points": 0.3}]}, {"action": "Set the cup upright on the tray", "grading": [{"label": "Not attempted", "points": 0.0}, {"label": "Placed but tipped over", "points": 0.25}, {"label": "Placed upright", "points": 0.5}]}]}
|
batch_utils/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
vllm==0.24.0
|
| 2 |
+
transformers==5.8.1
|
| 3 |
+
qwen-vl-utils==0.0.14
|
| 4 |
+
decord==0.6.0
|
| 5 |
+
av>=15
|
| 6 |
+
numpy<3
|
| 7 |
+
Pillow>=11,<13
|
| 8 |
+
huggingface_hub>=1.0
|
batch_utils/rubric_reward.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rubric reward: a score for every step of a user-defined rubric.
|
| 2 |
+
|
| 3 |
+
python -m batch_utils.rubric_reward --jobs rubric_jobs.jsonl --output rubric.jsonl
|
| 4 |
+
|
| 5 |
+
Each job gives the camera videos (each camera is a separate video, in the order the camera
|
| 6 |
+
descriptions list them), the robot, the task instruction and the rubric:
|
| 7 |
+
|
| 8 |
+
{"id": "ep01", "task": "Put the cup on the tray.",
|
| 9 |
+
"robot": "bimanual robot",
|
| 10 |
+
"cameras": ["a top (scene) camera", "a left-wrist camera", "a right-wrist camera"],
|
| 11 |
+
"videos": ["top.mp4", "left_wrist.mp4", "right_wrist.mp4"],
|
| 12 |
+
"rubric": [{"action": "Grasp the cup",
|
| 13 |
+
"grading": [{"label": "Not attempted", "points": 0.0},
|
| 14 |
+
{"label": "Grasped after re-tries", "points": 0.1},
|
| 15 |
+
{"label": "Clean grasp", "points": 0.2}]}, ...]}
|
| 16 |
+
|
| 17 |
+
Instead of ``robot`` and ``cameras``, a job may give ``header``: the full opening paragraph
|
| 18 |
+
that describes the robot and Video 1..N (ending with a blank line). The step maxima should sum
|
| 19 |
+
to 1. The model thinks before answering. Several answers are sampled per episode and the final
|
| 20 |
+
scores are the medoid answer (the answer closest, in summed L1 distance, to all others).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import os
|
| 26 |
+
|
| 27 |
+
from . import core
|
| 28 |
+
from .engine import base_parser, load_llm, load_processor, log, open_output, read_jobs, render, resolve_model, write_record
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def video_inputs(messages):
|
| 32 |
+
"""Decode every camera video at the rubric frame rate and pixel budget."""
|
| 33 |
+
if "FORCE_QWENVL_VIDEO_READER" not in os.environ:
|
| 34 |
+
try:
|
| 35 |
+
import decord # noqa: F401
|
| 36 |
+
|
| 37 |
+
os.environ["FORCE_QWENVL_VIDEO_READER"] = "decord"
|
| 38 |
+
except ImportError:
|
| 39 |
+
pass
|
| 40 |
+
from qwen_vl_utils import process_vision_info
|
| 41 |
+
|
| 42 |
+
_, videos, _ = process_vision_info(
|
| 43 |
+
[messages], return_video_kwargs=True, image_patch_size=16, return_video_metadata=True
|
| 44 |
+
)
|
| 45 |
+
inputs = []
|
| 46 |
+
for frames, metadata in videos:
|
| 47 |
+
metadata = dict(metadata)
|
| 48 |
+
metadata.setdefault("duration", metadata["total_num_frames"] / metadata["fps"])
|
| 49 |
+
metadata["do_sample_frames"] = False
|
| 50 |
+
inputs.append((frames.numpy(), metadata))
|
| 51 |
+
return inputs
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main(argv=None) -> int:
|
| 55 |
+
p = base_parser("FLORA rewarder: rubric scores with thinking.", tensor_parallel_size=2)
|
| 56 |
+
p.add_argument("--num-samples", type=int, default=9, help="Answers sampled per episode; the medoid is reported.")
|
| 57 |
+
p.add_argument("--fps", type=float, default=core.RUBRIC_FPS, help="Frames per second read from each camera.")
|
| 58 |
+
p.add_argument("--max-frames", type=int, default=core.RUBRIC_MAX_FRAMES, help="Frame cap per camera.")
|
| 59 |
+
p.add_argument("--temperature", type=float, default=1.0)
|
| 60 |
+
p.add_argument("--max-new-tokens", type=int, default=20480)
|
| 61 |
+
p.add_argument("--max-model-len", type=int, default=98304)
|
| 62 |
+
p.add_argument("--max-num-batched-tokens", type=int, default=24576)
|
| 63 |
+
p.add_argument("--keep-thinking", action="store_true", help="Write every sample's thinking trace.")
|
| 64 |
+
p.set_defaults(gpu_memory_utilization=0.85)
|
| 65 |
+
args = p.parse_args(argv)
|
| 66 |
+
jobs = read_jobs(args, extra_fields=("robot", "cameras", "rubric", "task_name", "header"))
|
| 67 |
+
for job in jobs:
|
| 68 |
+
if "rubric" not in job or not ({"robot", "cameras"} <= set(job) or "header" in job):
|
| 69 |
+
raise SystemExit(f"{job['id']}: rubric jobs need 'rubric' and 'robot' + 'cameras' (or 'header')")
|
| 70 |
+
if "cameras" in job and len(job["cameras"]) != len(job["videos"]):
|
| 71 |
+
raise SystemExit(f"{job['id']}: give one camera description per video")
|
| 72 |
+
model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
|
| 73 |
+
processor = load_processor(model_path)
|
| 74 |
+
|
| 75 |
+
def prepare(job):
|
| 76 |
+
header = job.get("header") or core.rubric_header(job["robot"], job["cameras"])
|
| 77 |
+
prompt = core.rubric_prompt(job["task"], job["rubric"], header, job.get("task_name"))
|
| 78 |
+
messages = core.rubric_messages(prompt, job["videos"], fps=args.fps, max_frames=args.max_frames)
|
| 79 |
+
return render(processor, messages, thinking=True, vision_ids=True), messages
|
| 80 |
+
|
| 81 |
+
if args.dry_run:
|
| 82 |
+
text, messages = prepare(jobs[0])
|
| 83 |
+
frames = [(tuple(f.shape), m["fps"]) for f, m in video_inputs(messages)]
|
| 84 |
+
log(f"{jobs[0]['id']}: camera frames {frames}\n{text}")
|
| 85 |
+
return 0
|
| 86 |
+
|
| 87 |
+
from vllm import SamplingParams
|
| 88 |
+
|
| 89 |
+
llm = load_llm(model_path, args, max_model_len=args.max_model_len,
|
| 90 |
+
videos_per_prompt=max(len(job["videos"]) for job in jobs),
|
| 91 |
+
mm_processor_kwargs={"do_resize": False},
|
| 92 |
+
max_num_batched_tokens=args.max_num_batched_tokens)
|
| 93 |
+
params = SamplingParams(n=args.num_samples, temperature=args.temperature, top_p=1.0, top_k=0,
|
| 94 |
+
max_tokens=args.max_new_tokens)
|
| 95 |
+
with open_output(args) as stream:
|
| 96 |
+
for number, job in enumerate(jobs, 1):
|
| 97 |
+
text, messages = prepare(job)
|
| 98 |
+
request = {"prompt": text, "multi_modal_data": {"video": video_inputs(messages)}}
|
| 99 |
+
output = llm.generate([request], params, use_tqdm=False)[0]
|
| 100 |
+
samples = []
|
| 101 |
+
for completion in output.outputs:
|
| 102 |
+
answer, thinking = core.split_thinking(completion.text)
|
| 103 |
+
scores, ok = core.parse_rubric(answer, job["rubric"])
|
| 104 |
+
sample = {"scores": scores, "parsed": ok, "answer": answer,
|
| 105 |
+
"generated_tokens": len(completion.token_ids)}
|
| 106 |
+
if args.keep_thinking:
|
| 107 |
+
sample["thinking"] = thinking
|
| 108 |
+
samples.append(sample)
|
| 109 |
+
parsed = [i for i, s in enumerate(samples) if s["parsed"]]
|
| 110 |
+
best = parsed[core.medoid([samples[i]["scores"] for i in parsed])] if parsed else None
|
| 111 |
+
scores = samples[best]["scores"] if best is not None else None
|
| 112 |
+
write_record(stream, {
|
| 113 |
+
"id": job["id"],
|
| 114 |
+
"task": job["task"],
|
| 115 |
+
"steps": [step["action"] for step in job["rubric"]],
|
| 116 |
+
"scores": scores,
|
| 117 |
+
"total": round(sum(scores), 4) if scores is not None else None,
|
| 118 |
+
"medoid_sample": best,
|
| 119 |
+
"parsed_samples": len(parsed),
|
| 120 |
+
"samples": samples,
|
| 121 |
+
"prompt_tokens": len(output.prompt_token_ids),
|
| 122 |
+
})
|
| 123 |
+
log(f"rubric: {number}/{len(jobs)} episodes, {len(parsed)}/{len(samples)} answers parsed")
|
| 124 |
+
return 0
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
raise SystemExit(main())
|
batch_utils/terminal_reward.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Terminal reward: one 1-5 success score for each whole episode.
|
| 2 |
+
|
| 3 |
+
python -m batch_utils.terminal_reward --jobs jobs.jsonl --output terminal.jsonl
|
| 4 |
+
python -m batch_utils.terminal_reward --video top.mp4 wrist.mp4 --task "Put the cup on the tray." --output terminal.jsonl
|
| 5 |
+
|
| 6 |
+
Each job is ``{"id": ..., "task": ..., "videos": [camera videos, in collage order]}``.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from . import core
|
| 12 |
+
from .engine import (
|
| 13 |
+
COLLAGE_VIDEO_SIZE,
|
| 14 |
+
base_parser,
|
| 15 |
+
load_llm,
|
| 16 |
+
load_processor,
|
| 17 |
+
log,
|
| 18 |
+
open_output,
|
| 19 |
+
read_jobs,
|
| 20 |
+
render,
|
| 21 |
+
resolve_model,
|
| 22 |
+
write_record,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main(argv=None) -> int:
|
| 27 |
+
p = base_parser("FLORA rewarder: terminal (episode success) score, 1-5.")
|
| 28 |
+
p.add_argument("--with-rationale", action="store_true", help="Ask for a one-line rationale before the score.")
|
| 29 |
+
p.add_argument("--max-model-len", type=int, default=16384)
|
| 30 |
+
args = p.parse_args(argv)
|
| 31 |
+
jobs = read_jobs(args)
|
| 32 |
+
model_path = resolve_model(args.model, args.revision, weights=not args.dry_run)
|
| 33 |
+
processor = load_processor(model_path)
|
| 34 |
+
|
| 35 |
+
prepared = []
|
| 36 |
+
for job in jobs:
|
| 37 |
+
collage = core.Collage(job["videos"])
|
| 38 |
+
indices = core.terminal_frame_indices(collage.num_frames, collage.fps)
|
| 39 |
+
messages = core.terminal_messages(job["task"], args.with_rationale)
|
| 40 |
+
prepared.append((job, collage, indices, render(processor, messages, thinking=False)))
|
| 41 |
+
if args.dry_run:
|
| 42 |
+
job, collage, indices, prompt = prepared[0]
|
| 43 |
+
log(f"{job['id']}: {collage.num_frames} frames at {collage.fps:g} FPS, "
|
| 44 |
+
f"collage {collage.width}x{collage.height}, {len(indices)} sampled frames\n{prompt}")
|
| 45 |
+
return 0
|
| 46 |
+
|
| 47 |
+
from vllm import SamplingParams
|
| 48 |
+
from vllm.sampling_params import StructuredOutputsParams
|
| 49 |
+
|
| 50 |
+
llm = load_llm(model_path, args, max_model_len=args.max_model_len,
|
| 51 |
+
mm_processor_kwargs={"do_sample_frames": False, "size": COLLAGE_VIDEO_SIZE},
|
| 52 |
+
max_logprobs=core.TERMINAL_TOP_LOGPROBS)
|
| 53 |
+
tokenizer = llm.get_tokenizer()
|
| 54 |
+
params = SamplingParams(
|
| 55 |
+
temperature=0.0,
|
| 56 |
+
max_tokens=core.terminal_max_tokens(args.with_rationale),
|
| 57 |
+
logprobs=core.TERMINAL_TOP_LOGPROBS,
|
| 58 |
+
structured_outputs=StructuredOutputsParams(regex=core.terminal_regex(args.with_rationale)),
|
| 59 |
+
)
|
| 60 |
+
with open_output(args) as stream:
|
| 61 |
+
for start in range(0, len(prepared), args.max_num_seqs):
|
| 62 |
+
batch = prepared[start : start + args.max_num_seqs]
|
| 63 |
+
requests = [
|
| 64 |
+
{
|
| 65 |
+
"prompt": prompt,
|
| 66 |
+
"multi_modal_data": {
|
| 67 |
+
"video": (collage.frames(indices), core.video_metadata(indices, collage.fps, collage.num_frames))
|
| 68 |
+
},
|
| 69 |
+
"mm_processor_kwargs": {"do_sample_frames": False},
|
| 70 |
+
}
|
| 71 |
+
for _, collage, indices, prompt in batch
|
| 72 |
+
]
|
| 73 |
+
outputs = llm.generate(requests, params, use_tqdm=False)
|
| 74 |
+
for _, collage, _, _ in batch:
|
| 75 |
+
collage.release()
|
| 76 |
+
for (job, collage, indices, _), output in zip(batch, outputs):
|
| 77 |
+
completion = output.outputs[0]
|
| 78 |
+
score, rationale = core.parse_terminal(completion.text, args.with_rationale)
|
| 79 |
+
positions = [
|
| 80 |
+
(
|
| 81 |
+
tokenizer.decode([token]),
|
| 82 |
+
float(candidates[token].logprob),
|
| 83 |
+
[(tokenizer.decode([i]), float(c.logprob)) for i, c in candidates.items()],
|
| 84 |
+
)
|
| 85 |
+
for token, candidates in zip(completion.token_ids, completion.logprobs)
|
| 86 |
+
]
|
| 87 |
+
probabilities, expected = core.score_probabilities(positions, score)
|
| 88 |
+
write_record(stream, {
|
| 89 |
+
"id": job["id"],
|
| 90 |
+
"task": job["task"],
|
| 91 |
+
"score": score,
|
| 92 |
+
"expected_score": round(expected, 4),
|
| 93 |
+
# Expected score rescaled to [0, 1]: a progress value, not a success probability.
|
| 94 |
+
"progress": round((expected - 1.0) / 4.0, 4),
|
| 95 |
+
"probabilities": {k: round(v, 4) for k, v in probabilities.items()},
|
| 96 |
+
"rationale": rationale,
|
| 97 |
+
"duration_s": round(collage.duration_s, 3),
|
| 98 |
+
"sampled_frames": len(indices),
|
| 99 |
+
"output": completion.text,
|
| 100 |
+
})
|
| 101 |
+
log(f"terminal: {min(start + len(batch), len(prepared))}/{len(prepared)} episodes")
|
| 102 |
+
return 0
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
raise SystemExit(main())
|