Open to the Stringman community: training data, datagen/trainer/room importer, avoid_furniture, [home] reports
Browse files- AGENTS.md +50 -1
- HOME_REPORT.md +28 -0
- README.md +9 -0
- scripts/cdpr_datagen.py +358 -0
- scripts/import_room.py +357 -0
- scripts/train_cdpr_wm.py +358 -0
AGENTS.md
CHANGED
|
@@ -98,8 +98,57 @@ This opens one pull request on `MacroSwan/pilot-tasks` containing the result, yo
|
|
| 98 |
page is the discussion: people and agents comment there, the maintainers re-run it, and the best merged tuning becomes
|
| 99 |
the next person's starting point. Ask your person before submitting. It publishes under their Hugging Face account.
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
## Where things are
|
| 102 |
|
| 103 |
`README.md` covers the model and the numbers. `cablepilot/cli.py` has the commands. `cablepilot/core/cards.py` defines
|
| 104 |
the card schema: the predicates, the skills and `signature()`. `cablepilot/cardrun.py` maps cards to the cable skills
|
| 105 |
-
and writes the result format. `cablepilot/stringman.py` is the real robot adapter.
|
|
|
|
| 98 |
page is the discussion: people and agents comment there, the maintainers re-run it, and the best merged tuning becomes
|
| 99 |
the next person's starting point. Ask your person before submitting. It publishes under their Hugging Face account.
|
| 100 |
|
| 101 |
+
## Step 6: tell everyone how it went in your home
|
| 102 |
+
|
| 103 |
+
The maintainers do not have a Stringman. Your report is how anyone learns whether this works in a real home, so write
|
| 104 |
+
one even when (above all when) it went badly. Open a **Discussion** on this repository, titled
|
| 105 |
+
`[home] <room> 路 <card> 路 <what happened in five words>`, filled from [`HOME_REPORT.md`](HOME_REPORT.md). It covers the
|
| 106 |
+
room, the rig, what you changed to integrate it, what the robot did, what broke, and what you would change. Attach
|
| 107 |
+
`doctor.json`, `rig.yaml` and the run's `result.json`. If you have a real-robot log, open a PR on the dataset
|
| 108 |
+
`MacroSwan/real-runs` with it as well (format in its README; blur faces first). Read the other `[home]` threads
|
| 109 |
+
before you start: someone may already have fixed your problem. Answer them when you can.
|
| 110 |
+
|
| 111 |
+
## Step 7: change the model itself
|
| 112 |
+
|
| 113 |
+
Everything the model learned from is public, so you can regenerate it, retrain it and replace it without asking anyone.
|
| 114 |
+
|
| 115 |
+
| what | where |
|
| 116 |
+
|---|---|
|
| 117 |
+
| the training data: 12,000 task-free trajectories (100 h of simulated motion, each in its own random room) | dataset `MacroSwan/cablepilot-motion` |
|
| 118 |
+
| how that data is made (the motion recipe, randomised physics, the host's real spool loop) | `scripts/cdpr_datagen.py` (docstring) |
|
| 119 |
+
| training, then test 1 (open-loop error at 2 s on held-out rooms, next to MuJoCo and to the physics alone) | `scripts/train_cdpr_wm.py` |
|
| 120 |
+
| a real Stringman room from a LeRobot recording (anchors, named places, floor image) | `scripts/import_room.py` |
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
hf download MacroSwan/cablepilot-motion --repo-type dataset --local-dir data/motion # ~280 MB, or regenerate:
|
| 124 |
+
python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 12000 --seed 0 --workers 4 --out data/motion
|
| 125 |
+
python scripts/train_cdpr_wm.py --data data/motion --out weights/mine.pt # minutes on a laptop CPU, no GPU
|
| 126 |
+
python scripts/train_cdpr_wm.py --eval-only --data data/motion --out weights/stringman_g1.pt # the bar to beat
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
The released `weights/stringman_g1.pt` scores **19.6 mm** median grasp-point error at 2 s on 500 held-out windows
|
| 130 |
+
(the MuJoCo oracle scores 21.0 mm). A new model has to beat that on the same test, and then score at least as well on a
|
| 131 |
+
task (`cablepilot run`, 3 episodes, both rooms), before it replaces the old one. Open a PR here with the weights, the
|
| 132 |
+
test-1 JSON written next to them, the run's `result.json` and one line of `why`. To use your model in a run, point
|
| 133 |
+
`configs/planner_learned.yaml` at it locally (a tuning file may never change the model).
|
| 134 |
+
|
| 135 |
+
**Your own recordings are the best test set there is.** Any Stringman LeRobot dataset with `anchor_poses`, for example
|
| 136 |
+
`naavox/stringman_cleans`, holds the real state and the real commands. Converting a few of its episodes into the
|
| 137 |
+
`cdpr_datagen` format and running `--eval-only --test-all` on them gives the model's first error measured on a real
|
| 138 |
+
machine. Nobody has done that yet: it is the most useful thing on the list below.
|
| 139 |
+
|
| 140 |
+
## Open jobs (pick one, say so in a Discussion first)
|
| 141 |
+
|
| 142 |
+
1. **The model on real data.** Test 1 on real Stringman episodes (see Step 7). Then fine-tune on them, if it helps.
|
| 143 |
+
2. **`avoid_furniture`.** The card is in `MacroSwan/pilot-tasks`. `cablepilot check` says what is missing: a `near`
|
| 144 |
+
skill (a runner around `clear_floor.GotoTask`) and a furnished room (`scripts/import_room.py` plus the furniture
|
| 145 |
+
boxes that `runner.obstacles` reads).
|
| 146 |
+
3. **`stringman.py` on hardware.** Check each `ASSUME` against `nf_robot`, then `hover()` in `dry_run`, then for real.
|
| 147 |
+
4. **Your room.** Import it with `scripts/import_room.py` and add it as a `--world`, so that everyone's cards are scored in
|
| 148 |
+
a third real room.
|
| 149 |
+
|
| 150 |
## Where things are
|
| 151 |
|
| 152 |
`README.md` covers the model and the numbers. `cablepilot/cli.py` has the commands. `cablepilot/core/cards.py` defines
|
| 153 |
the card schema: the predicates, the skills and `signature()`. `cablepilot/cardrun.py` maps cards to the cable skills
|
| 154 |
+
and writes the result format. `cablepilot/stringman.py` is the real robot adapter. `scripts/` holds the data generator, the trainer and the room importer.
|
HOME_REPORT.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# [home] report template
|
| 2 |
+
|
| 3 |
+
Copy this into a new Discussion on `MacroSwan/cablepilot`, titled `[home] <room> 路 <card> 路 <five words>`.
|
| 4 |
+
Write what happened, including what went wrong. A failed integration, well described, is worth more than a good score.
|
| 5 |
+
|
| 6 |
+
**Who:** `<agent> for @<person>` 路 cablepilot commit `<sha>` 路 date
|
| 7 |
+
|
| 8 |
+
**Room:** kind (bedroom, playroom, ...), rough size, floor (rug, wood, tiles), what is in the way (bed, desk, chair
|
| 9 |
+
legs, cables on the floor), light. `doctor.json` attached.
|
| 10 |
+
|
| 11 |
+
**Rig:** robot and firmware version, gripper, cameras, the computer that plans (CPU, threads, `doctor` speed line).
|
| 12 |
+
`rig.yaml` attached.
|
| 13 |
+
|
| 14 |
+
**How we integrated it:** what you had to change or write to make it run here, in order: places bound, adapter
|
| 15 |
+
`ASSUME`s checked or fixed, tunings, code. Link the commits or PRs.
|
| 16 |
+
|
| 17 |
+
**What ran:** simulation (`--world`, seeds, episodes) and/or the real robot (`dry_run`, `hover()`, task). `result.json`
|
| 18 |
+
attached. Real log: link to the `MacroSwan/real-runs` PR.
|
| 19 |
+
|
| 20 |
+
**What the robot did:** the score, s per item, furniture contacts, max tension, plan time. Then in words: where it hesitated,
|
| 21 |
+
swung, missed, touched something.
|
| 22 |
+
|
| 23 |
+
**What broke, and the guess why:**
|
| 24 |
+
|
| 25 |
+
**Compared with what you use now (VLA, ACT, scripted):** success, time per item, compute, and the setup effort for a
|
| 26 |
+
new task. Only what you measured.
|
| 27 |
+
|
| 28 |
+
**What we would change next:**
|
README.md
CHANGED
|
@@ -11,6 +11,7 @@ tags:
|
|
| 11 |
- task-cards
|
| 12 |
datasets:
|
| 13 |
- MacroSwan/pilot-tasks
|
|
|
|
| 14 |
---
|
| 15 |
|
| 16 |
# cablepilot
|
|
@@ -119,6 +120,14 @@ State (14): gantry position and velocity, pole swing and its rate, wrist angle a
|
|
| 119 |
Command (5): gantry velocity (what `move_direction_speed` takes), wrist rate, closure target. Model step 0.1 s,
|
| 120 |
1 s of history.
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
## Status
|
| 123 |
|
| 124 |
- **Simulation:** proven. Model test on 500 held-out windows in 310 unseen rooms: grasp point 19.6 mm median error at
|
|
|
|
| 11 |
- task-cards
|
| 12 |
datasets:
|
| 13 |
- MacroSwan/pilot-tasks
|
| 14 |
+
- MacroSwan/cablepilot-motion
|
| 15 |
---
|
| 16 |
|
| 17 |
# cablepilot
|
|
|
|
| 120 |
Command (5): gantry velocity (what `move_direction_speed` takes), wrist rate, closure target. Model step 0.1 s,
|
| 121 |
1 s of history.
|
| 122 |
|
| 123 |
+
## Train it yourself
|
| 124 |
+
|
| 125 |
+
The model was trained on the public dataset [`MacroSwan/cablepilot-motion`](https://huggingface.co/datasets/MacroSwan/cablepilot-motion):
|
| 126 |
+
12,000 trajectories, about 100 hours of task-free simulated motion, each in its own random room. It can be regenerated
|
| 127 |
+
in about 15 minutes on 4 cores (`scripts/cdpr_datagen.py`) and retrained on a laptop CPU without a GPU
|
| 128 |
+
(`scripts/train_cdpr_wm.py`). `scripts/import_room.py` turns a Stringman LeRobot recording into a simulated room.
|
| 129 |
+
AGENTS.md, Step 7, gives the commands and the bar a new model has to beat.
|
| 130 |
+
|
| 131 |
## Status
|
| 132 |
|
| 133 |
- **Simulation:** proven. Model test on 500 held-out windows in 310 unseen rooms: grasp point 19.6 mm median error at
|
scripts/cdpr_datagen.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stringman world-model data: task-free trajectories in the twin, the SkyJEPA recipe (scripts/arm_datagen.py's, on the
|
| 2 |
+
cable robot). No target, no cost, no task label in the files: only how the machine moves when commanded.
|
| 3 |
+
|
| 4 |
+
A NEW RANDOM ROOM PER TRAJECTORY (sim/scene.random_room; --fixed-room for a test set of one real room): the model is a
|
| 5 |
+
model of the machine, not of a place (2026-10-08), and a room enters its dynamics only through the line geometry,
|
| 6 |
+
which is stored with each trajectory (mounts, runs) and given to the model as an input.
|
| 7 |
+
|
| 8 |
+
python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 20 --out data/cdpr_v0_sample
|
| 9 |
+
python scripts/cdpr_datagen.py --scenario configs/scenarios/clear_playroom.yaml --n 4000 --workers 7 --out data/cdpr_v0_playroom
|
| 10 |
+
|
| 11 |
+
Files (shards of 100):
|
| 12 |
+
states (N, T+1, 14) p, v, swing s, s_dot, wrist, wrist_dot, closure, closure_dot (sim.kin), AS ESTIMATED (noise added)
|
| 13 |
+
actions (N, T, 5) the command SENT at each record step: gantry velocity (3), wrist rate, closure target
|
| 14 |
+
tensions (N, T+1, 4) line tensions (N): an observation, NOT a model input (uncalibrated on the real robot)
|
| 15 |
+
payload (N, T+1) kg held at the grasp point (bookkeeping: the model must infer it)
|
| 16 |
+
eta (N, P) the trajectory's randomised physics (eta_names)
|
| 17 |
+
mounts (N, 4, 3) the room's mount points (line order of the firmware), runs (N, 4) the indirect lines' fixed runs
|
| 18 |
+
Recorded at 10 Hz (dt 0.1 s = half a planner step: the planner's 0.2 s is 2 model steps), 30 s per trajectory.
|
| 19 |
+
|
| 20 |
+
The machine is driven exactly as the real host drives it (sim.host.SpoolState: velocity -> line speeds by
|
| 21 |
+
finite-difference IK at the ESTIMATED position through MISCALIBRATED mounts, 50 Hz spool loop with the tension floor
|
| 22 |
+
and soft mute, acceleration-limited motors, 60 Hz gripper loop), with a command latency. Planning model = the
|
| 23 |
+
machine-only twin (no furniture, no contacts): the grasp point is kept >= 4 cm above the floor (floor contact is a task's
|
| 24 |
+
business, not the machine's).
|
| 25 |
+
|
| 26 |
+
Motion = a chain of segments chosen at random until the trajectory is full:
|
| 27 |
+
goto to a random reachable point (inside the mount polygon by 0.35 m, 3-line worst tension <= 26 N), 0.1-0.35 m/s
|
| 28 |
+
dwell hold still 1-4 s (the robot mostly hovers and waits)
|
| 29 |
+
stop a fast goto cut to zero half way: swing excitation and its decay
|
| 30 |
+
pump horizontal sinusoid at 0.7-1.3 x the swing frequency, small amplitude: the swing's resonance
|
| 31 |
+
descend down to a grasp height (G 4-15 cm), hold, back up (the pick's vertical regime)
|
| 32 |
+
plan what MPPI asks: a new command every planner step (0.2 s), AR(0.9) random walk, 1-3 x the planner's sigma
|
| 33 |
+
Wrist: rate pulses (half the segments); fingers: a new closure target (40 % of segments).
|
| 34 |
+
Payload: attached (20-500 g) or dropped at 35 % of segment boundaries: the "weight changes" regime.
|
| 35 |
+
Randomised per trajectory (eta): gantry and gripper mass x0.85-1.15, line EA 2-15 kN (log), line damping x0.5-2,
|
| 36 |
+
spool acceleration 0.10-0.30 m/s^2, command latency 0-0.15 s, mount calibration error sigma 0-4 cm (the host's IK only),
|
| 37 |
+
swing damping x0.3-3. Estimator noise as the sim estimator's (perception/oracle_estimator.StateEstimate).
|
| 38 |
+
"""
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import argparse
|
| 42 |
+
import copy
|
| 43 |
+
import json
|
| 44 |
+
import sys
|
| 45 |
+
import time
|
| 46 |
+
from collections import deque
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
import mujoco
|
| 50 |
+
import numpy as np
|
| 51 |
+
import torch
|
| 52 |
+
|
| 53 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 54 |
+
sys.path.insert(0, str(ROOT))
|
| 55 |
+
|
| 56 |
+
from cablepilot.perception.oracle_estimator import StateEstimate # noqa: E402
|
| 57 |
+
from cablepilot.sim.host import HostModel, SpoolState # noqa: E402
|
| 58 |
+
from cablepilot.sim.kin import ccw_polygon, worst_hang_tensions # noqa: E402
|
| 59 |
+
from cablepilot.sim.scene import (Indices, build_model, grasp_point_local, load_config, mounts, random_room, room_centre, # noqa: E402
|
| 60 |
+
set_room_geometry, settle_spools, total_mass)
|
| 61 |
+
from cablepilot.sim.state import to_state # noqa: E402
|
| 62 |
+
|
| 63 |
+
SEGMENTS = {"goto": 0.25, "dwell": 0.10, "stop": 0.10, "pump": 0.10, "descend": 0.15, "plan": 0.30}
|
| 64 |
+
ETA_NAMES = ["gantry_mass_scale", "gripper_mass_scale", "EA_N", "line_damping_scale", "line_accel_mps2", "latency_s",
|
| 65 |
+
"calib_sigma_m", "swing_damping_scale", "payload_max_kg"]
|
| 66 |
+
DT = 0.1
|
| 67 |
+
D_G = -0.5147 # grasp point on the pole, Stringman hand (tasks/pick_place.PickPlaceTask.dG); per hand: Generator.dG
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Generator:
|
| 71 |
+
def __init__(self, scenario: str, seconds: float = 30.0, random_rooms: bool = True, hand: str | None = None):
|
| 72 |
+
self.cfg = load_config(scenario=scenario, hand=hand)
|
| 73 |
+
# grasp point's distance down the pole (SO-101: hand origin + gripperframe, -0.472; its 7.9 mm lateral offset
|
| 74 |
+
# does not matter for keeping it off the floor)
|
| 75 |
+
self.dG = float(self.cfg["body"]["gripper_origin_z_m"] + grasp_point_local(self.cfg)[2])
|
| 76 |
+
self.base_cfg = self.cfg
|
| 77 |
+
self.random_rooms = random_rooms
|
| 78 |
+
self.m = build_model(self.cfg, world=False, payload_kg=1e-4)
|
| 79 |
+
self.d = mujoco.MjData(self.m)
|
| 80 |
+
self.idx = Indices(self.m)
|
| 81 |
+
self.h = self.m.opt.timestep
|
| 82 |
+
self.sub = int(round(DT / self.h))
|
| 83 |
+
self.T = int(round(seconds / DT))
|
| 84 |
+
self.M = mounts(self.cfg)
|
| 85 |
+
self.poly = ccw_polygon(self.M[:, :2])
|
| 86 |
+
self.pay = self.m.body("payload").id
|
| 87 |
+
m = self.m
|
| 88 |
+
self.orig = {k: getattr(m, k).copy() for k in ("body_mass", "body_inertia", "actuator_gainprm", "actuator_biasprm",
|
| 89 |
+
"dof_damping")}
|
| 90 |
+
self.gripper, self.gantry = m.body("gripper").id, m.body("gantry").id
|
| 91 |
+
self.swing_dofs = slice(self.idx.gv + 3, self.idx.gv + 6)
|
| 92 |
+
|
| 93 |
+
# ------------------------------------------------------------------------------------- geometry
|
| 94 |
+
def inside_by(self, xy):
|
| 95 |
+
a, b = self.poly, np.roll(self.poly, -1, 0)
|
| 96 |
+
e = b - a
|
| 97 |
+
n = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
|
| 98 |
+
return float(((np.asarray(xy) - a) * n).sum(1).min())
|
| 99 |
+
|
| 100 |
+
def tension_ok(self, p, lim=26.0):
|
| 101 |
+
t = worst_hang_tensions(torch.as_tensor(self.M, dtype=torch.float32), torch.as_tensor(p, dtype=torch.float32),
|
| 102 |
+
total_mass(self.m) + 0.5)
|
| 103 |
+
return float(t.max()) <= lim
|
| 104 |
+
|
| 105 |
+
def random_point(self, rng, z_lo=None):
|
| 106 |
+
lo, hi = self.poly.min(0), self.poly.max(0)
|
| 107 |
+
for _ in range(1000):
|
| 108 |
+
xy = rng.uniform(lo, hi)
|
| 109 |
+
if self.inside_by(xy) < 0.35:
|
| 110 |
+
continue
|
| 111 |
+
gz = rng.uniform(0.04, 0.15) if z_lo else rng.uniform(0.15, 1.2)
|
| 112 |
+
p = np.r_[xy, gz - self.dG]
|
| 113 |
+
if self.tension_ok(p):
|
| 114 |
+
return p
|
| 115 |
+
return np.r_[self.poly.mean(0), 1.0]
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------------------------- randomisation
|
| 118 |
+
def randomise(self, rng) -> tuple[dict, HostModel]:
|
| 119 |
+
m, o = self.m, self.orig
|
| 120 |
+
for k, v in o.items():
|
| 121 |
+
getattr(m, k)[:] = v
|
| 122 |
+
if self.random_rooms: # a new room every trajectory: the model learns the machine, not a place
|
| 123 |
+
self.cfg = set_room_geometry(self.base_cfg, m, self.idx, random_room(rng))
|
| 124 |
+
self.M = mounts(self.cfg)
|
| 125 |
+
self.poly = ccw_polygon(self.M[:, :2])
|
| 126 |
+
e = {"gantry_mass_scale": rng.uniform(0.85, 1.15), "gripper_mass_scale": rng.uniform(0.85, 1.15),
|
| 127 |
+
"EA_N": float(np.exp(rng.uniform(np.log(2000), np.log(15000)))), "line_damping_scale": rng.uniform(0.5, 2.0),
|
| 128 |
+
"line_accel_mps2": rng.uniform(0.10, 0.30), "latency_s": rng.uniform(0.0, 0.15),
|
| 129 |
+
"calib_sigma_m": rng.uniform(0.0, 0.04), "swing_damping_scale": float(np.exp(rng.uniform(np.log(0.3), np.log(3.0)))),
|
| 130 |
+
"payload_max_kg": rng.uniform(0.0, 0.5)}
|
| 131 |
+
m.body_mass[self.gantry] *= e["gantry_mass_scale"]
|
| 132 |
+
m.body_inertia[self.gantry] *= e["gantry_mass_scale"]
|
| 133 |
+
m.body_mass[self.gripper] *= e["gripper_mass_scale"]
|
| 134 |
+
m.body_inertia[self.gripper] *= e["gripper_mass_scale"]
|
| 135 |
+
m.dof_damping[self.swing_dofs] *= e["swing_damping_scale"]
|
| 136 |
+
run = np.asarray(self.cfg["room"]["indirect_run_m"], float)
|
| 137 |
+
span = np.linalg.norm(self.M - np.r_[room_centre(self.cfg), 1.2], axis=1)
|
| 138 |
+
for i, a in enumerate(self.idx.spool):
|
| 139 |
+
k = e["EA_N"] / (span[i] + run[i])
|
| 140 |
+
m.actuator_gainprm[a, 0] = k
|
| 141 |
+
m.actuator_biasprm[a, 1] = -k
|
| 142 |
+
m.actuator_biasprm[a, 2] *= e["line_damping_scale"]
|
| 143 |
+
cfg_h = copy.deepcopy(self.cfg) # the host's belief of where the mounts are
|
| 144 |
+
cfg_h["room"]["mounts_xyz"] = (self.M + rng.normal(0, e["calib_sigma_m"], self.M.shape)).tolist()
|
| 145 |
+
host = HostModel(cfg_h, self.h, self.idx.travel)
|
| 146 |
+
host.acc = e["line_accel_mps2"]
|
| 147 |
+
return e, host
|
| 148 |
+
|
| 149 |
+
# ------------------------------------------------------------------------------------- commands
|
| 150 |
+
def segments(self, rng):
|
| 151 |
+
"""Endless generator of (kind, function(state, t_in_segment) -> (action, done))."""
|
| 152 |
+
sig = np.array([0.06, 0.06, 0.03, 0.4])
|
| 153 |
+
omega = np.sqrt(9.81 / 0.3089)
|
| 154 |
+
names, probs = list(SEGMENTS), np.array(list(SEGMENTS.values()))
|
| 155 |
+
while True:
|
| 156 |
+
kind = str(rng.choice(names, p=probs / probs.sum()))
|
| 157 |
+
wrist_rate = rng.choice([0.0, 0.0, rng.uniform(-1.5, 1.5)])
|
| 158 |
+
close = None if rng.random() > 0.4 else float(rng.choice([0.0, 1.0, rng.uniform(0, 1)]))
|
| 159 |
+
if kind in ("goto", "stop", "descend"):
|
| 160 |
+
tgt = self.random_point(rng, z_lo=(kind == "descend"))
|
| 161 |
+
vmax = rng.uniform(0.1, 0.35)
|
| 162 |
+
cut = rng.uniform(1.0, 4.0) if kind == "stop" else None
|
| 163 |
+
hold = rng.uniform(0.5, 2.5)
|
| 164 |
+
state = {"arrived": None}
|
| 165 |
+
|
| 166 |
+
def f(x, t, tgt=tgt, vmax=vmax, cut=cut, hold=hold, state=state):
|
| 167 |
+
if cut is not None and t > cut:
|
| 168 |
+
return np.zeros(3), t > cut + rng.uniform(2.0, 5.0)
|
| 169 |
+
d = tgt - x[:3]
|
| 170 |
+
v = d * 0.8
|
| 171 |
+
n = np.linalg.norm(v)
|
| 172 |
+
v = v * min(1.0, vmax / max(n, 1e-9))
|
| 173 |
+
if np.linalg.norm(d) < 0.03:
|
| 174 |
+
state["arrived"] = state["arrived"] or t
|
| 175 |
+
done = state["arrived"] is not None and t - state["arrived"] > hold or t > 25
|
| 176 |
+
return v, done
|
| 177 |
+
elif kind == "dwell":
|
| 178 |
+
dur = rng.uniform(1.0, 4.0)
|
| 179 |
+
|
| 180 |
+
def f(x, t, dur=dur):
|
| 181 |
+
return np.zeros(3), t > dur
|
| 182 |
+
elif kind == "pump":
|
| 183 |
+
w = omega * rng.uniform(0.7, 1.3)
|
| 184 |
+
amp = rng.uniform(0.02, 0.08)
|
| 185 |
+
ang = rng.uniform(0, 2 * np.pi)
|
| 186 |
+
dur = rng.uniform(2.0, 5.0)
|
| 187 |
+
|
| 188 |
+
def f(x, t, w=w, amp=amp, ang=ang, dur=dur):
|
| 189 |
+
s = amp * np.sin(w * t) if t < dur else 0.0
|
| 190 |
+
return np.array([s * np.cos(ang), s * np.sin(ang), 0.0]), t > dur + 3.0
|
| 191 |
+
else: # plan: AR(0.9) random walk, a new command every 0.2 s
|
| 192 |
+
scale = rng.uniform(1, 3)
|
| 193 |
+
st = {"eps": np.zeros(4), "base": None, "k": -1}
|
| 194 |
+
steps = int(rng.integers(8, 30))
|
| 195 |
+
|
| 196 |
+
def f(x, t, scale=scale, st=st, steps=steps):
|
| 197 |
+
k = int(round(t / 0.2))
|
| 198 |
+
if st["base"] is None:
|
| 199 |
+
st["base"] = np.r_[x[3:6], 0.0]
|
| 200 |
+
if k != st["k"]:
|
| 201 |
+
st["k"] = k
|
| 202 |
+
st["eps"] = 0.9 * st["eps"] + np.sqrt(1 - 0.81) * rng.normal(0, 1, 4) * sig * scale
|
| 203 |
+
v = st["base"][:3] * 0.9 ** k + st["eps"][:3]
|
| 204 |
+
return v, k >= steps
|
| 205 |
+
yield kind, f, wrist_rate, close
|
| 206 |
+
|
| 207 |
+
# ------------------------------------------------------------------------------------- one trajectory
|
| 208 |
+
def trajectory(self, seed: int) -> dict | None:
|
| 209 |
+
rng = np.random.default_rng(seed)
|
| 210 |
+
m, d, idx = self.m, self.d, self.idx
|
| 211 |
+
eta, host = self.randomise(rng)
|
| 212 |
+
mujoco.mj_resetData(m, d)
|
| 213 |
+
p0 = self.random_point(rng)
|
| 214 |
+
d.qpos[idx.gq:idx.gq + 3] = p0
|
| 215 |
+
d.qpos[idx.fq] = d.qpos[idx.frq] = -idx.travel
|
| 216 |
+
m.body_mass[self.pay] = 1e-4
|
| 217 |
+
settle_spools(m, d, idx, self.cfg)
|
| 218 |
+
d.ctrl[idx.finger_ctrl] = -idx.travel
|
| 219 |
+
for _ in range(int(1.0 / self.h)):
|
| 220 |
+
mujoco.mj_step(m, d)
|
| 221 |
+
x = to_state(m, d, idx)
|
| 222 |
+
spools = SpoolState(host, self.cfg, x)
|
| 223 |
+
noise = StateEstimate(np.random.default_rng(seed + 1))
|
| 224 |
+
# command latency, in PHYSICS steps: a command sent at tick k reaches the host lat_sub physics steps later.
|
| 225 |
+
# (v0 counted it in physics steps but delayed it by that many 0.1 s ticks: up to 3.7 s; the data's velocity
|
| 226 |
+
# followed its command at ~0.4 /s instead of ~2-3 /s and the model learned that commands barely move the robot.)
|
| 227 |
+
lat_sub = int(round(eta["latency_s"] / self.h))
|
| 228 |
+
pending = deque() # (physics step at which it applies, action)
|
| 229 |
+
applied = np.r_[np.zeros(3), 0.0, x[12]]
|
| 230 |
+
n_phys = 0
|
| 231 |
+
T = self.T
|
| 232 |
+
S = np.empty((T + 1, 14))
|
| 233 |
+
A = np.empty((T, 5))
|
| 234 |
+
TEN = np.empty((T + 1, 4))
|
| 235 |
+
PAY = np.empty(T + 1)
|
| 236 |
+
kinds = {}
|
| 237 |
+
segs = self.segments(rng)
|
| 238 |
+
kind, f, wr, cl = next(segs)
|
| 239 |
+
t_seg, close = 0.0, x[12]
|
| 240 |
+
v_prev = np.zeros(3)
|
| 241 |
+
|
| 242 |
+
def rec(k):
|
| 243 |
+
S[k] = noise(to_state(m, d, idx))
|
| 244 |
+
TEN[k] = -d.actuator_force[idx.spool]
|
| 245 |
+
PAY[k] = m.body_mass[self.pay]
|
| 246 |
+
|
| 247 |
+
rec(0)
|
| 248 |
+
for k in range(T):
|
| 249 |
+
xe = S[k]
|
| 250 |
+
v, done = f(xe, t_seg)
|
| 251 |
+
if done:
|
| 252 |
+
kinds[kind] = kinds.get(kind, 0) + 1
|
| 253 |
+
kind, f, wr, cl = next(segs)
|
| 254 |
+
t_seg = 0.0
|
| 255 |
+
if cl is not None:
|
| 256 |
+
close = cl
|
| 257 |
+
if rng.random() < 0.35: # the payload changes at a segment boundary
|
| 258 |
+
on = m.body_mass[self.pay] > 1e-3
|
| 259 |
+
m.body_mass[self.pay] = 1e-4 if on else max(1e-4, rng.uniform(0.02, 0.5) * eta["payload_max_kg"] / 0.5)
|
| 260 |
+
m.body_inertia[self.pay] = 1e-6
|
| 261 |
+
v, _ = f(xe, t_seg)
|
| 262 |
+
# near the floor, descend no faster than the spools can stop: stopping distance v^2 / 2a with a = half the
|
| 263 |
+
# line acceleration (a 0.35 m/s descent needs ~0.3 m; the first sample put G 23 cm below the floor). The fw
|
| 264 |
+
# visual servo does the same ("descent speed as a function of how far there is left to fall").
|
| 265 |
+
G = xe[:3] + self.dG * np.r_[xe[6:8], 1.0]
|
| 266 |
+
v = v.copy()
|
| 267 |
+
v[2] = max(v[2], -np.sqrt(2 * 0.5 * eta["line_accel_mps2"] * max(G[2] - 0.04, 0.0)))
|
| 268 |
+
# keep inside: a command that would leave the reachable box is turned back toward the centre
|
| 269 |
+
if self.inside_by(xe[:2]) < 0.25 or G[2] < 0.08 or not self.tension_ok(xe[:3], 30.0):
|
| 270 |
+
c = np.r_[self.poly.mean(0), 1.0]
|
| 271 |
+
v = 0.15 * (c - xe[:3]) / max(np.linalg.norm(c - xe[:3]), 1e-6)
|
| 272 |
+
sp = np.linalg.norm(v)
|
| 273 |
+
v = v * min(1.0, 0.35 / max(sp, 1e-9))
|
| 274 |
+
a = np.r_[v, wr if t_seg < 3.0 else 0.0, close]
|
| 275 |
+
A[k] = a
|
| 276 |
+
pending.append((n_phys + lat_sub, a, xe[:3].copy()))
|
| 277 |
+
for _ in range(self.sub):
|
| 278 |
+
while pending and pending[0][0] <= n_phys:
|
| 279 |
+
_, applied, p_at_send = pending.popleft()
|
| 280 |
+
spools.command(p_at_send, applied)
|
| 281 |
+
n_phys += 1
|
| 282 |
+
u = spools.substep(-d.actuator_force[idx.spool], self.h)
|
| 283 |
+
d.ctrl[idx.spool] = u[:4]
|
| 284 |
+
d.ctrl[idx.wrist_ctrl], d.ctrl[idx.finger_ctrl] = u[4], u[5]
|
| 285 |
+
mujoco.mj_step(m, d)
|
| 286 |
+
if not np.isfinite(d.qpos).all() or np.abs(d.qvel[:6]).max() > 20:
|
| 287 |
+
return None
|
| 288 |
+
rec(k + 1)
|
| 289 |
+
t_seg += DT
|
| 290 |
+
v_prev = v
|
| 291 |
+
return {"states": S.astype(np.float32), "actions": A.astype(np.float32), "tensions": TEN.astype(np.float32),
|
| 292 |
+
"payload": PAY.astype(np.float32), "eta": np.array([eta[k] for k in ETA_NAMES], np.float32), "kinds": kinds,
|
| 293 |
+
"mounts": self.M.astype(np.float32), "runs": np.asarray(self.cfg["room"]["indirect_run_m"], np.float32)}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _shard_job(args):
|
| 297 |
+
out, scenario, shard_i, seed0, n, seconds, random_rooms, hand = args
|
| 298 |
+
gen = Generator(scenario, seconds, random_rooms, hand)
|
| 299 |
+
rows, rejected, kinds = [], 0, {}
|
| 300 |
+
t0 = time.perf_counter()
|
| 301 |
+
for i in range(n):
|
| 302 |
+
tr = gen.trajectory(seed0 + i)
|
| 303 |
+
if tr is None:
|
| 304 |
+
rejected += 1
|
| 305 |
+
continue
|
| 306 |
+
for k, v in tr["kinds"].items():
|
| 307 |
+
kinds[k] = kinds.get(k, 0) + v
|
| 308 |
+
rows.append(tr)
|
| 309 |
+
shard = {k: np.stack([r[k] for r in rows]) for k in ("states", "actions", "tensions", "payload", "eta", "mounts", "runs")}
|
| 310 |
+
np.savez_compressed(Path(out) / f"shard_{shard_i:04d}.npz", **shard, eta_names=np.array(ETA_NAMES), dt=DT)
|
| 311 |
+
return len(rows), rejected, kinds, time.perf_counter() - t0
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def main():
|
| 315 |
+
ap = argparse.ArgumentParser()
|
| 316 |
+
ap.add_argument("--scenario", default="configs/scenarios/clear_playroom.yaml")
|
| 317 |
+
ap.add_argument("--n", type=int, default=20)
|
| 318 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 319 |
+
ap.add_argument("--seconds", type=float, default=30.0)
|
| 320 |
+
ap.add_argument("--shard-size", type=int, default=100)
|
| 321 |
+
ap.add_argument("--workers", type=int, default=1)
|
| 322 |
+
ap.add_argument("--hand", default=None, help="stringman | so101 (default: configs/stringman.yaml `hand`)")
|
| 323 |
+
ap.add_argument("--fixed-room", action="store_true", help="the scenario's room for every trajectory (test sets of a real room)")
|
| 324 |
+
ap.add_argument("--out", default=str(ROOT / "data/cdpr_v0_sample"))
|
| 325 |
+
args = ap.parse_args()
|
| 326 |
+
out = Path(args.out)
|
| 327 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 328 |
+
size = min(args.shard_size, args.n)
|
| 329 |
+
jobs = [(str(out), args.scenario, i, args.seed + i * size, size, args.seconds, not args.fixed_room, args.hand)
|
| 330 |
+
for i in range(args.n // size)]
|
| 331 |
+
t0 = time.perf_counter()
|
| 332 |
+
done, rej, kinds, cpu = 0, 0, {}, 0.0
|
| 333 |
+
if args.workers > 1:
|
| 334 |
+
from multiprocessing import Pool
|
| 335 |
+
with Pool(args.workers) as pool:
|
| 336 |
+
it = pool.imap_unordered(_shard_job, jobs)
|
| 337 |
+
for k, (n, r, kd, s) in enumerate(it):
|
| 338 |
+
done, rej, cpu = done + n, rej + r, cpu + s
|
| 339 |
+
for a, v in kd.items():
|
| 340 |
+
kinds[a] = kinds.get(a, 0) + v
|
| 341 |
+
print(f" {k + 1}/{len(jobs)} shards, {done} trajectories, {time.perf_counter() - t0:.0f} s", flush=True)
|
| 342 |
+
else:
|
| 343 |
+
for job in jobs:
|
| 344 |
+
n, r, kd, s = _shard_job(job)
|
| 345 |
+
done, rej, cpu = done + n, rej + r, cpu + s
|
| 346 |
+
for a, v in kd.items():
|
| 347 |
+
kinds[a] = kinds.get(a, 0) + v
|
| 348 |
+
meta = {"trajectories": done, "rejected": rej, "seconds_each": args.seconds, "rate_hz": 1 / DT,
|
| 349 |
+
"scenario": args.scenario, "hand": Generator(args.scenario, 1.0, False, args.hand).cfg["hand"],
|
| 350 |
+
"dG": Generator(args.scenario, 1.0, False, args.hand).dG, "random_rooms": not args.fixed_room, "wall_s": time.perf_counter() - t0, "cpu_s": cpu,
|
| 351 |
+
"cpu_s_per_trajectory": cpu / max(done, 1), "segments": kinds, "eta_names": ETA_NAMES,
|
| 352 |
+
"model_state": "states (14): p, v, s, s_dot, wrist, wrist_dot, closure, closure_dot. tensions NOT a model input."}
|
| 353 |
+
(out / "manifest.json").write_text(json.dumps(meta, indent=2))
|
| 354 |
+
print(json.dumps(meta, indent=1))
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
main()
|
scripts/import_room.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import a real Stringman room from a naavox LeRobot dataset into the twin: geometry, named places, the floor image.
|
| 2 |
+
|
| 3 |
+
hf download naavox/playroom-sep29-2 --repo-type dataset --local-dir data/hf/playroom-sep29-2
|
| 4 |
+
python scripts/import_room.py --dataset data/hf/playroom-sep29-2 --conf conf_playroom --calibrations <fw>/src/nf_robot/ml/calibrations --name playroom
|
| 5 |
+
python scripts/import_room.py --dataset data/hf/bedroom-sep6 --conf conf_bedroom_move_clutter_2 --name bedroom
|
| 6 |
+
uv run python scripts/import_room.py --building commercial_real_estate --office r5 --name cre_r5
|
| 7 |
+
|
| 8 |
+
A BUILDING (the room layer, milestone R, 2026-10-13): one office cut out of a scanned building
|
| 9 |
+
(~/robot-lab/buildings/<name>/, shared by every robot; see import_building's docstring). Writes configs/rooms/<name>.yaml
|
| 10 |
+
and data/rooms/<name>/ (visual meshes, the fixed map's points; outside git, rebuilt by this command).
|
| 11 |
+
|
| 12 |
+
Writes configs/rooms/<name>.yaml and assets/rooms/<name>/floor.png. Everything comes from the recording itself, in its
|
| 13 |
+
own room frame (fw ml/calibrations/readme.md: a config is only valid for the sessions it was recorded with):
|
| 14 |
+
- anchors: the `anchor_poses` feature (datasets from August 2026 on carry it per frame; constant within a session)
|
| 15 |
+
- named places: observation.state `<place>_bearing` / `_distance`, which are in the GRIPPER frame:
|
| 16 |
+
place = gripper_pos_xy + distance * (sin(a), cos(a)), a = bearing + spin
|
| 17 |
+
(found 2026-10-08 by trying the conventions: this one puts every frame of playroom-sep29-2 on the same point, std
|
| 18 |
+
0.00 m; the others scatter by 0.6-1.8 m). A place whose solution is the origin is unset.
|
| 19 |
+
- floor: the median of the episodes' first overhead frames. The overhead camera is the firmware's orthographic floor
|
| 20 |
+
view (host/floor_view.py: room origin at the image centre, +y up, 5 m across), so it drops onto the twin's floor
|
| 21 |
+
at its true scale. Static clutter stays in it: it is the room's look, not something to pick.
|
| 22 |
+
- eyelets (the indirect lines' far corners) are NOT in the recording: taken from the room's calibration file
|
| 23 |
+
(fw ml/calibrations) through the 2D similarity that maps that file's two anchors onto the recording's, z = the
|
| 24 |
+
recording's anchor height. APPROXIMATE (that file is an older calibration of the same room).
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import argparse
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import sys
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import yaml
|
| 37 |
+
|
| 38 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 39 |
+
sys.path.insert(0, str(ROOT))
|
| 40 |
+
# the firmware's calibration files (cranebot3-firmware src/nf_robot/ml/calibrations): --calibrations or $NF_CALIBRATIONS
|
| 41 |
+
FW_CAL = Path(os.environ.get("NF_CALIBRATIONS", "nf_robot/ml/calibrations"))
|
| 42 |
+
PLACES = ("hamper", "toybox", "trashcan", "gamepad", "parking_location")
|
| 43 |
+
EXTENT_M = 5.0 # fw host/floor_view.EXTENT_M
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def similarity_2d(src: np.ndarray, dst: np.ndarray):
|
| 47 |
+
"""Scale, rotation, translation mapping 2 src points onto 2 dst points (complex-number form)."""
|
| 48 |
+
s0, s1 = complex(*src[0]), complex(*src[1])
|
| 49 |
+
d0, d1 = complex(*dst[0]), complex(*dst[1])
|
| 50 |
+
a = (d1 - d0) / (s1 - s0)
|
| 51 |
+
b = d0 - a * s0
|
| 52 |
+
return lambda p: np.array([(a * complex(*p) + b).real, (a * complex(*p) + b).imag]), abs(a)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def floor_median(ds: Path, n_max: int = 60) -> np.ndarray:
|
| 56 |
+
import av
|
| 57 |
+
import pandas as pd
|
| 58 |
+
ep = pd.read_parquet(ds / "meta/episodes/chunk-000/file-000.parquet")
|
| 59 |
+
col = "videos/observation.images.overhead_camera/from_timestamp"
|
| 60 |
+
fidx = "videos/observation.images.overhead_camera/file_index"
|
| 61 |
+
ts = sorted(ep.loc[ep[fidx] == 0, col].values[:n_max] + 0.5)
|
| 62 |
+
c = av.open(str(ds / "videos/observation.images.overhead_camera/chunk-000/file-000.mp4"))
|
| 63 |
+
s = c.streams.video[0]
|
| 64 |
+
s.thread_type = "AUTO"
|
| 65 |
+
out, i = [], 0
|
| 66 |
+
for f in c.decode(s):
|
| 67 |
+
t = float(f.pts * s.time_base)
|
| 68 |
+
while i < len(ts) and t >= ts[i]:
|
| 69 |
+
out.append(f.to_ndarray(format="rgb24"))
|
| 70 |
+
i += 1
|
| 71 |
+
if i >= len(ts):
|
| 72 |
+
break
|
| 73 |
+
return np.median(np.stack(out), 0).astype(np.uint8), len(out)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main():
|
| 77 |
+
ap = argparse.ArgumentParser()
|
| 78 |
+
ap.add_argument("--dataset")
|
| 79 |
+
ap.add_argument("--conf", help="fw ml/calibrations/<conf>.json for the eyelets")
|
| 80 |
+
ap.add_argument("--calibrations", help="the firmware's ml/calibrations folder (default $NF_CALIBRATIONS)")
|
| 81 |
+
ap.add_argument("--building", help="a scanned building (~/robot-lab/buildings/<name>): import one office of it")
|
| 82 |
+
ap.add_argument("--office", help="the office's id in building.yaml (e.g. r5)")
|
| 83 |
+
ap.add_argument("--anchor-z", type=float, default=2.65, help="mount height: ceiling 2.85 less the winch block")
|
| 84 |
+
ap.add_argument("--name", required=True)
|
| 85 |
+
args = ap.parse_args()
|
| 86 |
+
if args.building:
|
| 87 |
+
return import_building(args)
|
| 88 |
+
import pandas as pd
|
| 89 |
+
ds = Path(args.dataset)
|
| 90 |
+
info = json.loads((ds / "meta/info.json").read_text())
|
| 91 |
+
df = pd.read_parquet(ds / "data/chunk-000/file-000.parquet")
|
| 92 |
+
names = info["features"]["observation.state"]["names"]
|
| 93 |
+
S = np.stack(df["observation.state"].values)
|
| 94 |
+
col = lambda k: S[:, names.index(k)] # noqa: E731
|
| 95 |
+
ap_ = np.stack(df["anchor_poses"].values)
|
| 96 |
+
assert len(np.unique(ap_.round(4), axis=0)) == 1, "anchors moved within the recording"
|
| 97 |
+
a = ap_[0].reshape(2, 6) # rx, ry, rz, x, y, z per anchor
|
| 98 |
+
anchors = a[:, 3:6]
|
| 99 |
+
|
| 100 |
+
places = {}
|
| 101 |
+
g = np.c_[col("gripper_pos_x"), col("gripper_pos_y")]
|
| 102 |
+
for p in PLACES:
|
| 103 |
+
ang = col(f"{p}_bearing") + col("spin")
|
| 104 |
+
P = g + col(f"{p}_distance")[:, None] * np.c_[np.sin(ang), np.cos(ang)]
|
| 105 |
+
med, sd = np.median(P, 0), P.std(0).max()
|
| 106 |
+
if sd < 0.02 and np.linalg.norm(med) > 0.05:
|
| 107 |
+
places[p] = [round(float(med[0]), 3), round(float(med[1]), 3)]
|
| 108 |
+
|
| 109 |
+
conf = json.loads((Path(args.calibrations or FW_CAL) / f"{args.conf}.json").read_text())
|
| 110 |
+
ca = np.array([[x["pose"]["position"][k] if "pose" in x else x["position"][k] for k in "xy"] for x in conf["anchors"]])
|
| 111 |
+
ce = np.array([[x["indirectLine"]["eyeletPos"][k] for k in "xy"] for x in conf["anchors"]])
|
| 112 |
+
f, scale = similarity_2d(ca, anchors[:, :2])
|
| 113 |
+
eyelets = np.array([f(e) for e in ce])
|
| 114 |
+
z = anchors[:, 2]
|
| 115 |
+
# line order (fw-sim): 0 anchor0 direct, 1 anchor0 indirect via its eyelet, 2 anchor1 direct, 3 anchor1 indirect
|
| 116 |
+
mounts = [anchors[0, :2], eyelets[0], anchors[1, :2], eyelets[1]]
|
| 117 |
+
mz = [z[0], z[0], z[1], z[1]]
|
| 118 |
+
runs = [0.0, float(np.linalg.norm(eyelets[0] - anchors[0, :2])), 0.0, float(np.linalg.norm(eyelets[1] - anchors[1, :2]))]
|
| 119 |
+
|
| 120 |
+
img, n = floor_median(ds)
|
| 121 |
+
out_dir = ROOT / "assets/rooms" / args.name
|
| 122 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
from PIL import Image
|
| 124 |
+
Image.fromarray(img).save(out_dir / "floor.png")
|
| 125 |
+
gan = np.c_[col("gantry_position_x"), col("gantry_position_y"), col("gantry_position_z")]
|
| 126 |
+
room = {
|
| 127 |
+
"source": {"dataset": f"naavox/{ds.name}", "frames": int(len(df)), "conf_for_eyelets": args.conf,
|
| 128 |
+
"eyelet_similarity_scale": round(float(scale), 3), "floor_median_of": int(n)},
|
| 129 |
+
"mounts_xyz": [[round(float(m[0]), 3), round(float(m[1]), 3), round(float(zz), 3)] for m, zz in zip(mounts, mz)],
|
| 130 |
+
"indirect_run_m": [round(r, 3) for r in runs],
|
| 131 |
+
"ceiling_m": round(float(z.max()) + 0.13, 3), # docs: anchors ~13 cm below the ceiling
|
| 132 |
+
"named_places": places,
|
| 133 |
+
"floor_image": {"path": f"assets/rooms/{args.name}/floor.png", "extent_m": EXTENT_M, "centre": [0.0, 0.0]},
|
| 134 |
+
"recorded_gantry_box": [[round(float(gan[:, i].min()), 2), round(float(gan[:, i].max()), 2)] for i in range(3)],
|
| 135 |
+
"anchor_yaw_rad": [round(float(a[0, 2]), 3), round(float(a[1, 2]), 3)],
|
| 136 |
+
}
|
| 137 |
+
path = ROOT / "configs/rooms" / f"{args.name}.yaml"
|
| 138 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 139 |
+
path.write_text(f"# Imported by scripts/import_room.py from naavox/{ds.name} (see its docstring for each field's source).\n"
|
| 140 |
+
+ yaml.safe_dump(room, sort_keys=False))
|
| 141 |
+
print(path.read_text())
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ----------------------------------------------------------------------------------------------- a scanned building
|
| 145 |
+
def floor_field(P, box, fz, cell=0.25):
|
| 146 |
+
"""The scanned floor's height as a smooth field f(x, y): the scan is not flat. r5's floor points wander from -6.7 to
|
| 147 |
+
+1.0 cm (0.6 m cell medians, 2026-10-13; a RANSAC plane came out 1.05 deg tilted and left the open floor at
|
| 148 |
+
+1.7 cm median): Polycam drift, not a sloping office. Per 25 cm cell: the median of the points within 4 cm of
|
| 149 |
+
the cell's 10th percentile (the floor, not a chair leg), cells with < 20 such points filled from the nearest
|
| 150 |
+
measured ones (under the table), then smoothed (sigma 2 cells). Returns f (callable on (n, 2)), its grid."""
|
| 151 |
+
from scipy import ndimage
|
| 152 |
+
from scipy.interpolate import NearestNDInterpolator, RegularGridInterpolator
|
| 153 |
+
x0, x1, y0, y1 = box
|
| 154 |
+
Q = P[(P[:, 0] > x0) & (P[:, 0] < x1) & (P[:, 1] > y0) & (P[:, 1] < y1) & (np.abs(P[:, 2] - fz) < 0.12)]
|
| 155 |
+
nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
|
| 156 |
+
ij = np.minimum(((Q[:, :2] - [x0, y0]) / cell).astype(int), [nx - 1, ny - 1])
|
| 157 |
+
G = np.full((nx, ny), np.nan)
|
| 158 |
+
key = ij[:, 0] * ny + ij[:, 1]
|
| 159 |
+
order = np.argsort(key)
|
| 160 |
+
ks, starts = np.unique(key[order], return_index=True)
|
| 161 |
+
for k, a, b in zip(ks, starts, np.r_[starts[1:], len(order)]):
|
| 162 |
+
z = Q[order[a:b], 2]
|
| 163 |
+
lo = np.percentile(z, 10)
|
| 164 |
+
z = z[z < lo + 0.04]
|
| 165 |
+
if len(z) >= 20:
|
| 166 |
+
G[k // ny, k % ny] = np.median(z)
|
| 167 |
+
ok = ~np.isnan(G)
|
| 168 |
+
ii, jj = np.nonzero(ok)
|
| 169 |
+
fill = NearestNDInterpolator(np.c_[ii, jj], G[ok])
|
| 170 |
+
ai, aj = np.nonzero(~ok)
|
| 171 |
+
if len(ai):
|
| 172 |
+
G[~ok] = fill(np.c_[ai, aj])
|
| 173 |
+
G = ndimage.gaussian_filter(G, 2.0, mode="nearest")
|
| 174 |
+
xs, ys = x0 + (np.arange(nx) + 0.5) * cell, y0 + (np.arange(ny) + 0.5) * cell
|
| 175 |
+
f = RegularGridInterpolator((xs, ys), G, bounds_error=False, fill_value=None)
|
| 176 |
+
return lambda xy: f(np.clip(xy, [xs[0], ys[0]], [xs[-1], ys[-1]])), G, float(ok.mean())
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def import_building(args):
|
| 180 |
+
"""One office of a scanned building -> a room for the cable robot.
|
| 181 |
+
|
| 182 |
+
- mounts: the office's four corners at anchor height, found from the scan, not typed in: the free space in the
|
| 183 |
+
band 1.0 m .. anchor_z + 0.15 m (wall points occupy a 4 cm grid; the anchor band alone leaked out of r5
|
| 184 |
+
through two scan holes in its walls, 2026-10-13), the connected part around the office centre,
|
| 185 |
+
eroded 5 cm (fw definitions: eyelets 5 cm off the walls); the corner = the free cell furthest along
|
| 186 |
+
(+-1, +-1). Order as scripts/make_room3d.py (CCW from +x+y; lines 0, 2 direct, 1, 3 via eyelets along a wall).
|
| 187 |
+
- the room frame: origin on the floor under the mounts' centre, axes = the building's (pose in building.yaml)
|
| 188 |
+
- the visual mesh: the OBJ's faces in the office box (+30 cm: its walls), the ceiling dropped (the overhead
|
| 189 |
+
camera hangs under it), one mesh per texture. VISUAL ONLY in the plant (contype 0).
|
| 190 |
+
- the fixed map's points: the PLY in the same crop (data/rooms/<name>/fixed_points.npy, xyz + rgb).
|
| 191 |
+
- supports: the floor and each large horizontal surface (upward mesh faces 0.3-1.5 m, clustered by height and
|
| 192 |
+
place, >= 0.25 m^2): a table top's height and box. The plant gets a thin collision box under each so waste
|
| 193 |
+
can lie on it (the mesh itself has no contacts).
|
| 194 |
+
- the bin (named place `hamper`): free floor near a wall, >= 0.6 m inside the mount polygon, nothing of the
|
| 195 |
+
scan 0.1-1.5 m above within 0.45 m."""
|
| 196 |
+
from scipy import ndimage
|
| 197 |
+
from cablepilot.roommap.scan import load_building, load_mtl, load_obj, load_ply, obj_to_frame, write_obj
|
| 198 |
+
bdir, B = load_building(args.building)
|
| 199 |
+
off = B["offices"][args.office]
|
| 200 |
+
x0, x1, y0, y1 = off["box"]
|
| 201 |
+
fz = float(B["floor_z"])
|
| 202 |
+
P, C = load_ply(bdir / B["files"]["points"])
|
| 203 |
+
pad = 0.3
|
| 204 |
+
near = (P[:, 0] > x0 - pad) & (P[:, 0] < x1 + pad) & (P[:, 1] > y0 - pad) & (P[:, 1] < y1 + pad)
|
| 205 |
+
# the office's own floor, FOUND (armpilot scan_furniture: RANSAC, "found, not given"): the building's floor_z is
|
| 206 |
+
# the whole floor's histogram peak, and r5's floor lies 2.2 cm under it (median, 2026-10-13); a level-floor
|
| 207 |
+
# assumption left a speckle of "obstacle" voxels 2-4 cm up all over the room
|
| 208 |
+
ffield, G, measured = floor_field(P, (x0 - pad, x1 + pad, y0 - pad, y1 + pad), fz)
|
| 209 |
+
P = P.copy()
|
| 210 |
+
P[near, 2] -= ffield(P[near, :2]) # flatten (the same correction for the mesh below)
|
| 211 |
+
fz_mean = float(np.mean(G))
|
| 212 |
+
|
| 213 |
+
# mounts: free space at anchor height
|
| 214 |
+
res = 0.04
|
| 215 |
+
lo = np.array([x0 - pad, y0 - pad])
|
| 216 |
+
shape = (np.array([x1 - x0, y1 - y0]) + 2 * pad) / res
|
| 217 |
+
shape = shape.astype(int) + 1
|
| 218 |
+
band = near & (P[:, 2] > 1.0) & (P[:, 2] < args.anchor_z + 0.15)
|
| 219 |
+
occ = np.zeros(shape, bool)
|
| 220 |
+
ij = ((P[band, :2] - lo) / res).astype(int)
|
| 221 |
+
occ[ij[:, 0], ij[:, 1]] = True
|
| 222 |
+
occ = ndimage.binary_closing(occ, iterations=2) # the scan's 2.2 cm points leave pinholes in a wall
|
| 223 |
+
lab, _ = ndimage.label(~occ)
|
| 224 |
+
c_ij = ((np.array([(x0 + x1) / 2, (y0 + y1) / 2]) - lo) / res).astype(int)
|
| 225 |
+
free = ndimage.binary_erosion(lab == lab[tuple(c_ij)], iterations=2) # 2 cells = 8 cm from a wall point's cell
|
| 226 |
+
fi, fj = np.nonzero(free)
|
| 227 |
+
cells = lo + (np.c_[fi, fj] + 0.5) * res
|
| 228 |
+
M = []
|
| 229 |
+
for d in ((1, 1), (-1, 1), (-1, -1), (1, -1)):
|
| 230 |
+
M.append(cells[np.argmax(cells @ np.array(d, float))])
|
| 231 |
+
M = np.array(M)
|
| 232 |
+
O = M.mean(0).round(3) # the room frame's origin (floor under the mounts' centre)
|
| 233 |
+
mounts = np.c_[M - O, np.full(4, args.anchor_z)]
|
| 234 |
+
runs = [0.0, float(np.linalg.norm(M[1] - M[0])), 0.0, float(np.linalg.norm(M[3] - M[2]))]
|
| 235 |
+
|
| 236 |
+
out = ROOT / "data/rooms" / args.name
|
| 237 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 238 |
+
keep = near & (P[:, 2] > -0.05) & (P[:, 2] < off["ceiling_m"] - 0.1)
|
| 239 |
+
pts = np.c_[P[keep] - [*O, 0], C[keep]].astype(np.float32)
|
| 240 |
+
np.save(out / "fixed_points.npy", pts)
|
| 241 |
+
|
| 242 |
+
V, T, Fc, Mt = load_obj(bdir / B["files"]["mesh"])
|
| 243 |
+
V = obj_to_frame(V) # the mesh shares the PLY's frame (same capture)
|
| 244 |
+
V[:, 2] -= ffield(V[:, :2])
|
| 245 |
+
V = V - [*O, 0]
|
| 246 |
+
A = V[Fc[:, :, 0]]
|
| 247 |
+
nrm = np.cross(A[:, 1] - A[:, 0], A[:, 2] - A[:, 0])
|
| 248 |
+
area = np.linalg.norm(nrm, axis=1) / 2
|
| 249 |
+
up = nrm[:, 2] / (2 * area + 1e-12)
|
| 250 |
+
cen = A.mean(1)
|
| 251 |
+
bx0, bx1, by0, by1 = x0 - O[0] - pad, x1 - O[0] + pad, y0 - O[1] - pad, y1 - O[1] + pad
|
| 252 |
+
inbox = (cen[:, 0] > bx0) & (cen[:, 0] < bx1) & (cen[:, 1] > by0) & (cen[:, 1] < by1)
|
| 253 |
+
keepF = inbox & ~((cen[:, 2] > off["ceiling_m"] - 0.25) & (up < -0.5))
|
| 254 |
+
mtl = load_mtl(bdir / B["files"]["mesh"].replace(" ", "_").replace(".obj", ".mtl"))
|
| 255 |
+
meshes = []
|
| 256 |
+
for k, mat in enumerate(sorted(set(Mt[keepF]))):
|
| 257 |
+
sel = keepF & (Mt == mat)
|
| 258 |
+
n = write_obj(out / f"mesh_{k}.obj", V, T, Fc[sel])
|
| 259 |
+
from PIL import Image # MuJoCo reads PNG textures, not the scan's JPEGs
|
| 260 |
+
Image.open(bdir / mtl[mat]).convert("RGB").save(out / f"mesh_{k}.png")
|
| 261 |
+
meshes.append({"obj": f"mesh_{k}.obj", "texture": f"mesh_{k}.png", "faces": int(n)})
|
| 262 |
+
|
| 263 |
+
# supports: large horizontal surfaces above the floor
|
| 264 |
+
hz = keepF & (up > 0.95) & (cen[:, 2] > 0.3) & (cen[:, 2] < 1.5)
|
| 265 |
+
supports = [{"name": "floor", "z": 0.0}]
|
| 266 |
+
g = 0.05
|
| 267 |
+
zb = np.round(cen[hz, 2] / 0.02).astype(int)
|
| 268 |
+
for zk in np.unique(zb):
|
| 269 |
+
s = np.nonzero(hz)[0][np.abs(zb - zk) <= 1]
|
| 270 |
+
if area[s].sum() < 0.25:
|
| 271 |
+
continue
|
| 272 |
+
q = ((cen[s, :2] - [bx0, by0]) / g).astype(int)
|
| 273 |
+
grid = np.zeros((int((bx1 - bx0) / g) + 1, int((by1 - by0) / g) + 1), bool)
|
| 274 |
+
grid[q[:, 0], q[:, 1]] = True
|
| 275 |
+
grid = ndimage.binary_closing(grid, iterations=2)
|
| 276 |
+
lab, nl = ndimage.label(grid)
|
| 277 |
+
for L in range(1, nl + 1):
|
| 278 |
+
mem = lab[q[:, 0], q[:, 1]] == L
|
| 279 |
+
if area[s[mem]].sum() < 0.25 or any(abs(sp["z"] - np.average(cen[s[mem], 2], weights=area[s[mem]])) < 0.03
|
| 280 |
+
and sp.get("_l") == (zk, L) for sp in supports):
|
| 281 |
+
continue
|
| 282 |
+
xy = cen[s[mem], :2]
|
| 283 |
+
zz = float(np.average(cen[s[mem], 2], weights=area[s[mem]]))
|
| 284 |
+
if any(sp["name"] != "floor" and abs(sp["z"] - zz) < 0.03 and np.all(np.abs(np.mean(xy, 0) - np.mean(sp["box"], 1)) < 0.3)
|
| 285 |
+
for sp in supports):
|
| 286 |
+
continue # the same table seen in the neighbouring height bin
|
| 287 |
+
bxy = np.percentile(xy, [3, 97], axis=0)
|
| 288 |
+
supports.append({"name": f"top{len(supports)}", "z": round(zz, 3),
|
| 289 |
+
"box": [[round(float(bxy[0, 0]), 3), round(float(bxy[1, 0]), 3)],
|
| 290 |
+
[round(float(bxy[0, 1]), 3), round(float(bxy[1, 1]), 3)]],
|
| 291 |
+
"area_m2": round(float(area[s[mem]].sum()), 2)})
|
| 292 |
+
|
| 293 |
+
# one table, one support: the height bins overlap (r5's table came out three times, 0.710 / 0.717 / 0.718 m,
|
| 294 |
+
# boxes inside each other, 2026-10-13): merge same-height (3 cm) supports whose boxes overlap into their union
|
| 295 |
+
tops = [sp for sp in supports if "box" in sp]
|
| 296 |
+
merged = True
|
| 297 |
+
while merged:
|
| 298 |
+
merged = False
|
| 299 |
+
for i in range(len(tops)):
|
| 300 |
+
for j in range(i + 1, len(tops)):
|
| 301 |
+
a, b = tops[i], tops[j]
|
| 302 |
+
(ax0, ax1), (ay0, ay1) = a["box"]
|
| 303 |
+
(bx0_, bx1_), (by0_, by1_) = b["box"]
|
| 304 |
+
if abs(a["z"] - b["z"]) < 0.03 and ax0 < bx1_ and bx0_ < ax1 and ay0 < by1_ and by0_ < ay1:
|
| 305 |
+
w = a["area_m2"] + b["area_m2"]
|
| 306 |
+
a["z"] = round((a["z"] * a["area_m2"] + b["z"] * b["area_m2"]) / w, 3)
|
| 307 |
+
a["box"] = [[min(ax0, bx0_), max(ax1, bx1_)], [min(ay0, by0_), max(ay1, by1_)]]
|
| 308 |
+
a["area_m2"] = round(max(a["area_m2"], b["area_m2"]), 2) # nested bins: the larger, not the sum
|
| 309 |
+
tops.pop(j)
|
| 310 |
+
merged = True
|
| 311 |
+
break
|
| 312 |
+
if merged:
|
| 313 |
+
break
|
| 314 |
+
supports = [supports[0]] + [{**t, "name": f"table{k}"} for k, t in enumerate(tops)]
|
| 315 |
+
|
| 316 |
+
# the bin: free floor near a wall
|
| 317 |
+
from cablepilot.sim.kin import ccw_polygon
|
| 318 |
+
poly = ccw_polygon(mounts[:, :2])
|
| 319 |
+
e = np.roll(poly, -1, 0) - poly
|
| 320 |
+
nr = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
|
| 321 |
+
tall = pts[(pts[:, 2] > 0.1) & (pts[:, 2] < 1.5), :2]
|
| 322 |
+
from scipy.spatial import cKDTree
|
| 323 |
+
tree = cKDTree(tall)
|
| 324 |
+
best = None
|
| 325 |
+
for x in np.arange(poly[:, 0].min(), poly[:, 0].max(), 0.05):
|
| 326 |
+
for y in np.arange(poly[:, 1].min(), poly[:, 1].max(), 0.05):
|
| 327 |
+
q = np.array([x, y])
|
| 328 |
+
ins = ((q - poly) * nr).sum(1).min()
|
| 329 |
+
if ins < 0.6 or tree.query(q)[0] < 0.45:
|
| 330 |
+
continue
|
| 331 |
+
if best is None or ins < best[0]: # nearest a wall (yet >= 0.6 m in)
|
| 332 |
+
best = (ins, q)
|
| 333 |
+
hamper = best[1].round(3).tolist()
|
| 334 |
+
|
| 335 |
+
room = {"source": {"building": str(bdir).replace(str(Path.home()), "~"), "office": args.office, "imported_by": "scripts/import_room.py --building",
|
| 336 |
+
"scan_points_kept": int(keep.sum())},
|
| 337 |
+
"floor_flattened": {"note": "scan z - f(x, y), f = the scanned floor's smooth height (floor_field)",
|
| 338 |
+
"f_range_m": [round(float(G.min()), 4), round(float(G.max()), 4)],
|
| 339 |
+
"cells_measured": round(measured, 2)},
|
| 340 |
+
"building_pose": {"xyz": [float(O[0]), float(O[1]), round(fz_mean, 4)], "yaw_rad": 0.0,
|
| 341 |
+
"note": "room frame = building frame shifted by xyz (floor at z 0); same axes"},
|
| 342 |
+
"size_m": [round(float(np.ptp(M[:, 0])), 3), round(float(np.ptp(M[:, 1])), 3)],
|
| 343 |
+
"mounts_xyz": mounts.round(3).tolist(), "indirect_run_m": [round(r, 3) for r in runs],
|
| 344 |
+
"ceiling_m": float(off["ceiling_m"]),
|
| 345 |
+
"named_places": {"hamper": hamper},
|
| 346 |
+
"scan": {"dir": f"data/rooms/{args.name}", "meshes": meshes, "points": "fixed_points.npy",
|
| 347 |
+
"box": [[round(float(bx0), 3), round(float(bx1), 3)], [round(float(by0), 3), round(float(by1), 3)]]},
|
| 348 |
+
"supports": supports}
|
| 349 |
+
path = ROOT / "configs/rooms" / f"{args.name}.yaml"
|
| 350 |
+
path.write_text(f"# One office of a scanned building ({B['name']}, office {args.office}), imported by "
|
| 351 |
+
f"scripts/import_room.py --building (see import_building's docstring for each field's source).\n"
|
| 352 |
+
+ yaml.safe_dump(room, sort_keys=False))
|
| 353 |
+
print(path.read_text())
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
if __name__ == "__main__":
|
| 357 |
+
main()
|
scripts/train_cdpr_wm.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the Stringman SkyJEPA (cablepilot/model/learned.py) on task-free data, then test 1: open-loop prediction.
|
| 2 |
+
(armpilot's scripts/train_arm_wm.py on the cable robot.)
|
| 3 |
+
|
| 4 |
+
python scripts/train_cdpr_wm.py --data data/cdpr_v0_playroom --out checkpoints/cdpr_wm_v0.pt
|
| 5 |
+
python scripts/train_cdpr_wm.py --eval-only --out checkpoints/cdpr_wm_v0.pt
|
| 6 |
+
|
| 7 |
+
Stage 1 (task-free JEPA): predict the next 2 s of state latents from 1 s of history and the commands,
|
| 8 |
+
loss = MSE(pred, target) + 0.02 SIGReg(pred), as SkyJEPA. Stage 2: the prober on frozen latents, loss = weighted MSE of
|
| 9 |
+
the integrated states (normalised) against the recorded ones, the end of the horizon weighted most. The prober's last
|
| 10 |
+
layer starts at zero: training starts exactly at the hand-written backbone and learns only the correction.
|
| 11 |
+
|
| 12 |
+
Test 1 (docs/MILESTONES.md M1, the bar set before training): on held-out windows, after 20 steps (2.0 s): grasp point
|
| 13 |
+
error <= 2 cm median (also on payload windows), pole tilt error <= 2 deg median; next to the nominal oracle (MuJoCo, no
|
| 14 |
+
eta, no payload) and the backbone alone (nothing learned).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import math
|
| 21 |
+
import sys
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn as nn
|
| 28 |
+
|
| 29 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 30 |
+
sys.path.insert(0, str(ROOT))
|
| 31 |
+
|
| 32 |
+
from cablepilot.model.learned import JEPA, Normalizer, Prober, features, integrate # noqa: E402
|
| 33 |
+
|
| 34 |
+
H, L = 10, 20
|
| 35 |
+
L_SWING_DATA = 0.3089 # the data's pendulum (fw, Stringman hand); load() sets it from the manifest's hand
|
| 36 |
+
D_G = -0.5147 # grasp point down the pole; load() sets it from the data's manifest (SO-101 hand: -0.472)
|
| 37 |
+
# the machine the data came from (manifest "machine"; Stringman when absent) and its backbone priors: the person lift's
|
| 38 |
+
# swing runs over a 2.4x range of lengths (empty harness .. a person), its spools ramp at 0.8 m/s^2 (P1, 2026-10-12)
|
| 39 |
+
MACHINE, OMEGA_SPAN, A_MAX0 = "configs/stringman.yaml", 0.3, 0.25
|
| 40 |
+
# Stage-2 error scales per state dim, in METRES / radians: an error of one scale costs 1. In normalised units (v0) a 5 cm
|
| 41 |
+
# position error cost ~0.003 next to the swing and rate terms, and the grasp-point error at 2 s ROSE during stage 2
|
| 42 |
+
# (53 -> 68 mm) while the loss fell; near-still windows came out at 80 mm against 33 mm for "integrate the command"
|
| 43 |
+
# (2026-10-08). p 2 cm, v 5 cm/s, s 0.01 (~0.6 deg), s_dot 0.1 /s, wrist 0.1 rad, wrist rate 0.5, closure 0.05, its rate 0.5.
|
| 44 |
+
E_SCALE = torch.tensor([0.02, 0.02, 0.02, 0.05, 0.05, 0.05, 0.01, 0.01, 0.1, 0.1, 0.1, 0.5, 0.05, 0.5])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class SIGReg(nn.Module):
|
| 48 |
+
"""LeJEPA's Sketched Isotropic Gaussian Regularisation (copied from armpilot, from LeDrone/skyjepa)."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, n_directions=64, n_knots=17):
|
| 51 |
+
super().__init__()
|
| 52 |
+
nodes, weights = np.polynomial.hermite.hermgauss(n_knots)
|
| 53 |
+
self.n = n_directions
|
| 54 |
+
self.register_buffer("nodes", torch.tensor(nodes, dtype=torch.float32))
|
| 55 |
+
self.register_buffer("weights", torch.tensor(weights, dtype=torch.float32))
|
| 56 |
+
self.register_buffer("gauss_cf", torch.tensor(np.exp(-nodes ** 2 / 2.0), dtype=torch.float32))
|
| 57 |
+
|
| 58 |
+
def forward(self, lat): # (T, B, D)
|
| 59 |
+
T, B, D = lat.shape
|
| 60 |
+
u = torch.randn(self.n, D)
|
| 61 |
+
u = u / (u.norm(dim=1, keepdim=True) + 1e-8)
|
| 62 |
+
stat = lat.new_zeros(())
|
| 63 |
+
for t in range(T):
|
| 64 |
+
arg = (lat[t] @ u.t()).unsqueeze(-1) * self.nodes.view(1, 1, -1)
|
| 65 |
+
C, S = torch.cos(arg).mean(0), torch.sin(arg).mean(0)
|
| 66 |
+
stat = stat + B * (((C - self.gauss_cf) ** 2 + S ** 2) * self.weights).sum(1).mean()
|
| 67 |
+
return stat / T
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load(data):
|
| 71 |
+
"""One dataset directory, or several joined with commas: (X, A, P, M, R, dt). M (N, 4, 3) mounts and R (N, 4) runs
|
| 72 |
+
per trajectory (shards from a fixed room without them: the scenario's)."""
|
| 73 |
+
dirs = [Path(d) for d in str(data).split(",")]
|
| 74 |
+
parts = []
|
| 75 |
+
for d in dirs:
|
| 76 |
+
shards = sorted(d.glob("shard_*.npz"))
|
| 77 |
+
X, A, P, M, R = [], [], [], [], []
|
| 78 |
+
man = json.loads((d / "manifest.json").read_text())
|
| 79 |
+
global D_G, L_SWING_DATA, MACHINE, OMEGA_SPAN, A_MAX0
|
| 80 |
+
D_G = float(man.get("dG", D_G))
|
| 81 |
+
from cablepilot.sim.scene import load_config as _lc
|
| 82 |
+
if man.get("machine"): # another machine (P1: the person lift): its own pendulum and spools
|
| 83 |
+
MACHINE = man["machine"]
|
| 84 |
+
mc = _lc(machine=MACHINE)
|
| 85 |
+
L_SWING_DATA = float(mc["body"]["swing_length_m"])
|
| 86 |
+
A_MAX0 = float(mc["spool"]["line_accel_mps2"])
|
| 87 |
+
OMEGA_SPAN = 1.0 if mc.get("hand") == "harness" else 0.3
|
| 88 |
+
elif man.get("hand"): # the hand's pendulum, to start the backbone's swing from (Prober l_swing)
|
| 89 |
+
L_SWING_DATA = float(_lc(hand=man["hand"])["body"]["swing_length_m"])
|
| 90 |
+
for f in shards:
|
| 91 |
+
z = np.load(f)
|
| 92 |
+
X.append(z["states"])
|
| 93 |
+
A.append(z["actions"])
|
| 94 |
+
P.append(z["payload"])
|
| 95 |
+
if "mounts" in z:
|
| 96 |
+
M.append(z["mounts"])
|
| 97 |
+
R.append(z["runs"])
|
| 98 |
+
else:
|
| 99 |
+
from cablepilot.sim.scene import load_config, mounts
|
| 100 |
+
c = load_config(machine=MACHINE, scenario=man["scenario"])
|
| 101 |
+
M.append(np.repeat(mounts(c)[None].astype(np.float32), len(z["states"]), 0))
|
| 102 |
+
R.append(np.repeat(np.asarray(c["room"]["indirect_run_m"], np.float32)[None], len(z["states"]), 0))
|
| 103 |
+
parts.append([np.concatenate(v) for v in (X, A, P, M, R)] + [float(np.load(shards[0])["dt"])])
|
| 104 |
+
if len(parts) == 1:
|
| 105 |
+
return tuple(parts[0])
|
| 106 |
+
k = min(len(p[0]) for p in parts) # interleave so that the held-out tail holds trajectories of every set
|
| 107 |
+
return tuple(np.stack([p[j][:k] for p in parts], 1).reshape(-1, *parts[0][j].shape[1:]) for j in range(5)) + (parts[0][5],)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def windows(X, A, idx, rng, B, M, P=None):
|
| 111 |
+
i = rng.choice(idx, B)
|
| 112 |
+
t = rng.integers(0, A.shape[1] - (H + L) + 1, B)
|
| 113 |
+
ar = np.arange(H + 1 + L)
|
| 114 |
+
out = [torch.as_tensor(X[i[:, None], t[:, None] + ar]), torch.as_tensor(A[i[:, None], t[:, None] + ar[:-1]]),
|
| 115 |
+
torch.as_tensor(M[i])]
|
| 116 |
+
if P is not None:
|
| 117 |
+
out += [P[i[:, None], t[:, None] + ar], i]
|
| 118 |
+
return out
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def lr_at(step, total, peak, final=1e-4, warm=200):
|
| 122 |
+
if step < warm:
|
| 123 |
+
return peak * step / warm
|
| 124 |
+
p = (step - warm) / max(1, total - warm)
|
| 125 |
+
return final + (peak - final) * 0.5 * (1 + math.cos(math.pi * p))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def grasp_point(x):
|
| 129 |
+
s = x[..., 6:8]
|
| 130 |
+
z = np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))
|
| 131 |
+
return x[..., 0:3] + D_G * np.concatenate([s, z], -1)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def train(args):
|
| 135 |
+
X, A, P, M, R, dt = load(args.data)
|
| 136 |
+
n = len(X)
|
| 137 |
+
rng = np.random.default_rng(0)
|
| 138 |
+
test_idx, train_idx = np.arange(n - args.test, n), np.arange(0, n - args.test)
|
| 139 |
+
with torch.no_grad():
|
| 140 |
+
sub = features(torch.as_tensor(X[train_idx[:2000], ::5]), torch.as_tensor(M[train_idx[:2000]])[:, None]).reshape(-1, 28).numpy()
|
| 141 |
+
asub = A[train_idx[:2000]].reshape(-1, 5)
|
| 142 |
+
norm = Normalizer(sub.mean(0), sub.std(0) + 1e-3, asub.mean(0), asub.std(0) + 1e-3)
|
| 143 |
+
cfg = {"latent": 24, "action_latent": 12, "hidden": 48, "prober_hidden": 64, "H": H, "L": L, "dt": dt,
|
| 144 |
+
"data": str(args.data), "trajectories_train": int(len(train_idx)), "l_swing": L_SWING_DATA,
|
| 145 |
+
"omega_span": OMEGA_SPAN, "a_max0": A_MAX0, "machine": MACHINE}
|
| 146 |
+
torch.manual_seed(0)
|
| 147 |
+
jepa, prober, sig = JEPA(cfg["latent"], cfg["action_latent"], cfg["hidden"]), Prober(cfg["latent"], cfg["prober_hidden"], cfg["l_swing"], cfg["omega_span"], cfg["a_max0"]), SIGReg()
|
| 148 |
+
n_j, n_p = sum(p.numel() for p in jepa.parameters()), sum(p.numel() for p in prober.parameters())
|
| 149 |
+
print(f"{n} trajectories ({len(train_idx)} train, {len(test_idx)} test), dt {dt * 1000:.0f} ms; "
|
| 150 |
+
f"JEPA {n_j} params, prober {n_p}", flush=True)
|
| 151 |
+
t0 = time.perf_counter()
|
| 152 |
+
opt = torch.optim.Adam(jepa.parameters(), lr=args.lr, weight_decay=1e-5)
|
| 153 |
+
if args.stage1_from: # reuse stage 1 (frozen latents) from an earlier checkpoint
|
| 154 |
+
old = torch.load(args.stage1_from, weights_only=False)
|
| 155 |
+
jepa.load_state_dict(old["jepa"])
|
| 156 |
+
norm = Normalizer(**old["norm"])
|
| 157 |
+
print(f"stage 1 from {args.stage1_from}", flush=True)
|
| 158 |
+
for step in range(0 if args.stage1_from else args.steps1):
|
| 159 |
+
for g in opt.param_groups:
|
| 160 |
+
g["lr"] = lr_at(step, args.steps1, args.lr)
|
| 161 |
+
xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
|
| 162 |
+
pred, tgt = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
|
| 163 |
+
l_pred = ((pred - tgt) ** 2).mean()
|
| 164 |
+
l_sig = sig(pred.transpose(0, 1)[::4])
|
| 165 |
+
loss = l_pred + 0.02 * l_sig
|
| 166 |
+
opt.zero_grad()
|
| 167 |
+
loss.backward()
|
| 168 |
+
nn.utils.clip_grad_norm_(jepa.parameters(), 0.5)
|
| 169 |
+
opt.step()
|
| 170 |
+
if step % 250 == 0 or step == args.steps1 - 1:
|
| 171 |
+
std = pred.detach().reshape(-1, pred.shape[-1]).std(0).min().item()
|
| 172 |
+
print(f" stage 1 step {step:5d} L_pred {l_pred.item():.4f} L_sig {l_sig.item():.3f} "
|
| 173 |
+
f"latent min std {std:.3f} {time.perf_counter() - t0:.0f} s", flush=True)
|
| 174 |
+
t1 = time.perf_counter() - t0
|
| 175 |
+
jepa.eval()
|
| 176 |
+
for p in jepa.parameters():
|
| 177 |
+
p.requires_grad_(False)
|
| 178 |
+
opt = torch.optim.Adam(prober.parameters(), lr=args.lr2, weight_decay=1e-5)
|
| 179 |
+
tw = torch.linspace(0.3, 1.0, L)[None, :, None]
|
| 180 |
+
for step in range(args.steps2):
|
| 181 |
+
for g in opt.param_groups:
|
| 182 |
+
g["lr"] = lr_at(step, args.steps2, args.lr2)
|
| 183 |
+
xs, acts, Mw = windows(X, A, train_idx, rng, args.batch, M)
|
| 184 |
+
with torch.no_grad():
|
| 185 |
+
pred, _ = jepa(norm.s(features(xs, Mw[:, None])), norm.a(acts), H)
|
| 186 |
+
out = integrate(xs[:, H], acts[:, H:], acts[:, H - 1], pred, prober, dt, norm, Mw)
|
| 187 |
+
err = nn.functional.huber_loss((out - xs[:, H + 1:]) / E_SCALE, torch.zeros_like(out), reduction="none", delta=3.0)
|
| 188 |
+
loss = (err * tw).mean()
|
| 189 |
+
opt.zero_grad()
|
| 190 |
+
loss.backward()
|
| 191 |
+
nn.utils.clip_grad_norm_(prober.parameters(), 0.5)
|
| 192 |
+
opt.step()
|
| 193 |
+
if step % 250 == 0 or step == args.steps2 - 1:
|
| 194 |
+
g_err = np.linalg.norm(grasp_point(out[:, -1].detach().numpy()) - grasp_point(xs[:, -1].numpy()), axis=-1)
|
| 195 |
+
print(f" stage 2 step {step:5d} loss {loss.item():.4f} grasp-point error at 2 s median "
|
| 196 |
+
f"{1000 * np.median(g_err):.1f} mm {time.perf_counter() - t0:.0f} s", flush=True)
|
| 197 |
+
ckpt = {"config": cfg, "jepa": jepa.state_dict(), "prober": prober.state_dict(), "norm": norm.state_dict(),
|
| 198 |
+
"train_s": time.perf_counter() - t0, "stage1_s": t1, "params": {"jepa": n_j, "prober": n_p}}
|
| 199 |
+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
| 200 |
+
torch.save(ckpt, args.out)
|
| 201 |
+
print(f"saved {args.out} ({time.perf_counter() - t0:.0f} s)")
|
| 202 |
+
return X, A, P, M, R, test_idx
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def tilt_angle_err(a, b):
|
| 206 |
+
def axis(x):
|
| 207 |
+
s = x[..., 6:8]
|
| 208 |
+
return np.concatenate([s, np.sqrt(np.clip(1 - (s ** 2).sum(-1, keepdims=True), 1e-6, None))], -1)
|
| 209 |
+
c = np.clip((axis(a) * axis(b)).sum(-1), -1, 1)
|
| 210 |
+
return np.degrees(np.arccos(c))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test1(args, data=None, test_idx=None):
|
| 214 |
+
from cablepilot.model.learned import LearnedCDPRModel
|
| 215 |
+
from cablepilot.model.oracle import OracleWorldModel
|
| 216 |
+
from cablepilot.sim.scene import load_config
|
| 217 |
+
if data is None:
|
| 218 |
+
data = load(args.data)
|
| 219 |
+
test_idx = np.arange(len(data[0]) - args.test, len(data[0]))
|
| 220 |
+
X, A, P, M, R, dt = data
|
| 221 |
+
manifest = json.loads((Path(str(args.data).split(",")[0]) / "manifest.json").read_text())
|
| 222 |
+
cfg = load_config(machine=manifest.get("machine", "configs/stringman.yaml"), scenario=manifest["scenario"],
|
| 223 |
+
hand=manifest.get("hand"))
|
| 224 |
+
person = cfg.get("hand") == "harness"
|
| 225 |
+
ck = torch.load(args.out, weights_only=False)
|
| 226 |
+
model = LearnedCDPRModel(ck, dt=ck["config"]["dt"])
|
| 227 |
+
rng = np.random.default_rng(123)
|
| 228 |
+
xs, acts, Mw, pay, traj = windows(X, A, test_idx, rng, args.n_eval, M, P)
|
| 229 |
+
xs, acts = xs.numpy(), acts.numpy()
|
| 230 |
+
payload_w = (pay > 1e-3).any(1)
|
| 231 |
+
event_w = np.ptp(pay, axis=1) > 1.0 # the person's weight coming on / going off / shifting
|
| 232 |
+
t0 = time.perf_counter()
|
| 233 |
+
learned = []
|
| 234 |
+
for b in range(len(xs)):
|
| 235 |
+
model.set_room(M[traj[b]])
|
| 236 |
+
learned.append(model.predict(xs[b, :H + 1], acts[b, :H], acts[b:b + 1, H:]).numpy())
|
| 237 |
+
learned = np.concatenate(learned)
|
| 238 |
+
t_l = (time.perf_counter() - t0) / len(xs)
|
| 239 |
+
# the person lift's stiff lines need the 2 ms step; its oracle is told 75 kg (the range's middle), never the truth
|
| 240 |
+
orc = OracleWorldModel(cfg, dt=ck["config"]["dt"], timestep=0.002 if person else 0.01, nthread=4,
|
| 241 |
+
payload_kg=75.0 if person else 0.0)
|
| 242 |
+
t0 = time.perf_counter()
|
| 243 |
+
oracle, last = [], None
|
| 244 |
+
for b in range(len(xs)):
|
| 245 |
+
j = traj[b]
|
| 246 |
+
if last is None or not np.allclose(M[j], last): # the oracle in this window's room
|
| 247 |
+
orc.set_room({"mounts_xyz": M[j].tolist(), "indirect_run_m": R[j].tolist()})
|
| 248 |
+
last = M[j]
|
| 249 |
+
oracle.append(orc.rollout(xs[b, H][None], acts[b, H - 1][None], acts[b:b + 1, H:]).numpy())
|
| 250 |
+
oracle = np.concatenate(oracle)
|
| 251 |
+
t_o = (time.perf_counter() - t0) / len(xs)
|
| 252 |
+
fresh = Prober(ck["config"]["latent"], ck["config"]["prober_hidden"], ck["config"].get("l_swing", 0.3089),
|
| 253 |
+
ck["config"].get("omega_span", 0.3), ck["config"].get("a_max0", 0.25)) # the hand-written backbone alone
|
| 254 |
+
with torch.no_grad():
|
| 255 |
+
bb = integrate(torch.as_tensor(xs[:, H]), torch.as_tensor(acts[:, H:]), torch.as_tensor(acts[:, H - 1]),
|
| 256 |
+
torch.zeros(len(xs), L, ck["config"]["latent"]), fresh, ck["config"]["dt"], model.norm,
|
| 257 |
+
torch.as_tensor(M[traj])).numpy()
|
| 258 |
+
truth = xs[:, -1]
|
| 259 |
+
res = {}
|
| 260 |
+
for name, pr in (("learned", learned[:, -1]), ("oracle", oracle[:, -1]), ("backbone", bb[:, -1])):
|
| 261 |
+
e = np.linalg.norm(grasp_point(pr) - grasp_point(truth), axis=-1) * 1000
|
| 262 |
+
ea = tilt_angle_err(pr, truth)
|
| 263 |
+
eh = np.linalg.norm(pr[:, 0:3] - truth[:, 0:3], axis=-1) * 1000
|
| 264 |
+
res[name] = {"g_med_mm": float(np.median(e)), "g_p90_mm": float(np.percentile(e, 90)),
|
| 265 |
+
"g_payload_med_mm": float(np.median(e[payload_w])), "tilt_med_deg": float(np.median(ea)),
|
| 266 |
+
"tilt_p90_deg": float(np.percentile(ea, 90)),
|
| 267 |
+
"hook_med_mm": float(np.median(eh)), "hook_event_med_mm": float(np.median(eh[event_w])) if event_w.any() else None,
|
| 268 |
+
"tilt_event_med_deg": float(np.median(ea[event_w])) if event_w.any() else None}
|
| 269 |
+
print(f"\nTEST 1 路 {args.data} 路 open loop, {len(xs)} held-out windows ({payload_w.sum()} with payload on or changing), "
|
| 270 |
+
f"{len(set(map(tuple, M[traj].reshape(len(traj), -1).round(3))))} rooms, after {L} steps ({L * ck['config']['dt']:.1f} s)")
|
| 271 |
+
print("(backbone = hand-written physics alone, nothing learned; oracle = MuJoCo in the same room, nominal physics, no payload)")
|
| 272 |
+
print(f"{'model':9s} {'grasp pt median':>15s} {'p90':>8s} {'payload median':>15s} {'tilt median':>12s} {'p90':>7s}")
|
| 273 |
+
for k, r in res.items():
|
| 274 |
+
print(f"{k:9s} {r['g_med_mm']:12.1f} mm {r['g_p90_mm']:5.1f} mm {r['g_payload_med_mm']:12.1f} mm "
|
| 275 |
+
f"{r['tilt_med_deg']:9.2f} deg {r['tilt_p90_deg']:4.2f}")
|
| 276 |
+
r = res["learned"]
|
| 277 |
+
if person: # P1 (docs/MILESTONES.md): hook <= 20 mm, also on attach / set-down windows; swing <= 1.5 deg
|
| 278 |
+
f1 = lambda v: "n/a" if v is None else f"{v:.1f}"
|
| 279 |
+
print(f"\nP1 路 {event_w.sum()} windows where the person's weight changes. At 2 s:")
|
| 280 |
+
for k, v in res.items():
|
| 281 |
+
print(f" {k:9s} hook {v['hook_med_mm']:.1f} mm (events {f1(v['hook_event_med_mm'])}), swing "
|
| 282 |
+
f"{v['tilt_med_deg']:.2f} deg (events {f1(v['tilt_event_med_deg'])}), person COM {v['g_med_mm']:.1f} mm")
|
| 283 |
+
ok = (r["hook_med_mm"] <= 20 and (r["hook_event_med_mm"] is None or r["hook_event_med_mm"] <= 20)
|
| 284 |
+
and r["tilt_med_deg"] <= 1.5)
|
| 285 |
+
print(f"P1 model bar (hook <= 20 mm, also on events; swing <= 1.5 deg): {'YES' if ok else 'NO'}")
|
| 286 |
+
res["weigh"] = weigh_test(args, cfg, test_idx)
|
| 287 |
+
else:
|
| 288 |
+
ok = r["g_med_mm"] <= 20 and r["g_payload_med_mm"] <= 20 and r["tilt_med_deg"] <= 2
|
| 289 |
+
print(f"pass (<= 20 mm, <= 20 mm on payload, <= 2 deg): {'YES' if ok else 'NO'}")
|
| 290 |
+
print(f"learned {t_l * 1000:.1f} ms per window (S = 1), oracle {t_o * 1000:.1f} ms")
|
| 291 |
+
Path(args.out).with_suffix(f".test1.{Path(str(args.data).split(',')[0]).name}.json").write_text(json.dumps(res, indent=1))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def weigh_test(args, cfg, test_idx):
|
| 295 |
+
"""P1's weighing bar: 1 s after every attach ends in the held-out trajectories, the lines' weighing
|
| 296 |
+
(perception/weigh.py: mean vertical pull over that second / g - the rig's nominal mass) within 10 % of the person."""
|
| 297 |
+
from cablepilot.perception.weigh import rig_mass, weigh_kg
|
| 298 |
+
shards = sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))
|
| 299 |
+
S, TEN, PAY, MO = [], [], [], []
|
| 300 |
+
for f in shards:
|
| 301 |
+
z = np.load(f)
|
| 302 |
+
S.append(z["states"]), TEN.append(z["tensions"]), PAY.append(z["payload"]), MO.append(z["mounts"])
|
| 303 |
+
S, TEN, PAY, MO = (np.concatenate(v) for v in (S, TEN, PAY, MO))
|
| 304 |
+
rig, errs = rig_mass(cfg), []
|
| 305 |
+
for j in test_idx:
|
| 306 |
+
p = PAY[j]
|
| 307 |
+
k = 1
|
| 308 |
+
while k < len(p):
|
| 309 |
+
if p[k - 1] < 1.0 <= p[k]: # an attach starts
|
| 310 |
+
e = k
|
| 311 |
+
while e + 1 < len(p) and abs(p[e + 1] - p[e]) > 1e-3:
|
| 312 |
+
e += 1 # ... and ends at e
|
| 313 |
+
# an attach that ENDS with a person on (2026-10-12: one attach in the held-out set was cut off by a
|
| 314 |
+
# set-down back to 0 kg, and the first run divided by that 0)
|
| 315 |
+
if e + 10 < len(p) and p[e] >= 1.0 and abs(p[e + 10] - p[e]) < 1e-3:
|
| 316 |
+
w = slice(e + 1, e + 11) # the second after it ends
|
| 317 |
+
est = weigh_kg(TEN[j, w], S[j, w, 0:3], MO[j], rig)
|
| 318 |
+
errs.append(abs(est - p[e + 10]) / p[e + 10])
|
| 319 |
+
k = e + 1
|
| 320 |
+
k += 1
|
| 321 |
+
errs = np.array(errs) * 100
|
| 322 |
+
if not len(errs):
|
| 323 |
+
print("weighing: no complete attach in the held-out set")
|
| 324 |
+
return None
|
| 325 |
+
r = {"attaches": int(len(errs)), "median_pct": float(np.median(errs)), "p90_pct": float(np.percentile(errs, 90)),
|
| 326 |
+
"max_pct": float(errs.max()), "within_10pct": float((errs <= 10).mean())}
|
| 327 |
+
print(f"P1 weighing bar (within 10 % 1 s after an attach ends): {r['attaches']} attaches, median {r['median_pct']:.1f} %, "
|
| 328 |
+
f"p90 {r['p90_pct']:.1f} %, max {r['max_pct']:.1f} %, within 10 %: {100 * r['within_10pct']:.0f} % "
|
| 329 |
+
f"{'YES' if r['within_10pct'] == 1.0 else 'NO'}")
|
| 330 |
+
return r
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def main():
|
| 334 |
+
ap = argparse.ArgumentParser()
|
| 335 |
+
ap.add_argument("--data", default=str(ROOT / "data/cdpr_v0_playroom"))
|
| 336 |
+
ap.add_argument("--out", default=str(ROOT / "checkpoints/cdpr_wm_v0.pt"))
|
| 337 |
+
ap.add_argument("--steps1", type=int, default=3000)
|
| 338 |
+
ap.add_argument("--steps2", type=int, default=3000)
|
| 339 |
+
ap.add_argument("--batch", type=int, default=256)
|
| 340 |
+
ap.add_argument("--lr", type=float, default=3e-3)
|
| 341 |
+
ap.add_argument("--test", type=int, default=500, help="last N trajectories held out")
|
| 342 |
+
ap.add_argument("--n-eval", type=int, default=500)
|
| 343 |
+
ap.add_argument("--test-all", action="store_true", help="eval-only: every trajectory of --data is held out (an unseen room)")
|
| 344 |
+
ap.add_argument("--eval-only", action="store_true")
|
| 345 |
+
ap.add_argument("--stage1-from", default=None, help="checkpoint whose stage 1 (JEPA, normaliser) is reused")
|
| 346 |
+
ap.add_argument("--lr2", type=float, default=1e-3)
|
| 347 |
+
args = ap.parse_args()
|
| 348 |
+
if args.eval_only:
|
| 349 |
+
if args.test_all:
|
| 350 |
+
d = load(args.data)
|
| 351 |
+
return test1(args, d, np.arange(len(d[0])))
|
| 352 |
+
return test1(args)
|
| 353 |
+
X, A, P, M, R, test_idx = train(args)
|
| 354 |
+
test1(args, (X, A, P, M, R, float(np.load(sorted(Path(str(args.data).split(",")[0]).glob("shard_*.npz"))[0])["dt"])), test_idx)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
main()
|