Spaces:
Sleeping
Sleeping
Microduck Lab: run the real shipped RL policies from a gr.Workflow
Browse files- README.md +95 -6
- app.py +27 -0
- build_workflow.py +153 -0
- duck.py +344 -0
- nodes.py +236 -0
- packages.txt +3 -0
- requirements.txt +7 -0
- routine.py +205 -0
- test_pipelines.py +141 -0
- workflow.json +271 -0
README.md
CHANGED
|
@@ -1,13 +1,102 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Microduck Lab · gr.Workflow
|
| 3 |
+
emoji: 🦆
|
| 4 |
+
colorFrom: yellow
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.22.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
hf_oauth_scopes:
|
| 12 |
+
- inference-api
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# 🦆 Microduck Lab
|
| 16 |
+
|
| 17 |
+
Describe a routine in plain English. A language model compiles it into a move
|
| 18 |
+
plan, and the **real reinforcement-learning policies that ship with
|
| 19 |
+
[Microduck](https://pollen-robotics.com/microduck/)** execute it against
|
| 20 |
+
MuJoCo physics — then you get the video, the telemetry and a report.
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
[routine text] ─▶ (fn) choreograph ─┬─▶ 📝 Move plan
|
| 24 |
+
LLM │
|
| 25 |
+
▼
|
| 26 |
+
[seed] ────────────────▶ (fn) perform ─┬─▶ 🎬 Routine video
|
| 27 |
+
MuJoCo + 6 ONNX policies ├─▶ 📊 Telemetry
|
| 28 |
+
└─▶ 📝 Report
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## This is not a re-implementation
|
| 32 |
+
|
| 33 |
+
Nothing about the duck is faked or hand-animated. At runtime the app downloads,
|
| 34 |
+
from the public Space [`multimodalart/microduck-ar`](https://huggingface.co/spaces/multimodalart/microduck-ar):
|
| 35 |
+
|
| 36 |
+
- `robot_allcollisions.xml` — the MJCF for the real robot, plus its 38 meshes
|
| 37 |
+
- six trained ONNX policies — `BEST_alpha_walking`, `BEST_alpha_stand`,
|
| 38 |
+
`ball_kick_left`, `ball_kick_right`, `roulade`, `alpha_ground_pick`
|
| 39 |
+
|
| 40 |
+
and steps them exactly the way the robot's runtime does:
|
| 41 |
+
|
| 42 |
+
| | |
|
| 43 |
+
|---|---|
|
| 44 |
+
| physics | MuJoCo, timestep 0.005 s, decimation 4 → **50 Hz** control |
|
| 45 |
+
| observation | 61-D: `ang_vel(3) + projected_gravity(3) + joint_pos(14) + joint_vel(14) + last_action(14) + command(13)` |
|
| 46 |
+
| action | `ctrl[j] = DEFAULT_POSE[j] + action[j]` |
|
| 47 |
+
| commands | forward 0.25 m/s, backward −0.2 m/s, yaw ±1.0 rad/s |
|
| 48 |
+
|
| 49 |
+
The observation layout, the action scaling, the one-shot state machines
|
| 50 |
+
(kick = 25 steps, ground-pick = a `[cos, sin]` phase clock over a 4 s period
|
| 51 |
+
exiting at 0.7) and the fall-recovery machine are ported from that Space's
|
| 52 |
+
`src/sim.js`, which is the reference implementation.
|
| 53 |
+
|
| 54 |
+
Falls are real. Ask for a barrel roll and the duck tumbles, the fall detector
|
| 55 |
+
fires, and the `stand` policy picks it back up — you can watch that happen in
|
| 56 |
+
the uprightness trace.
|
| 57 |
+
|
| 58 |
+
## Moves
|
| 59 |
+
|
| 60 |
+
Timed (take `seconds`): `forward`, `backward`, `strafe_left`, `strafe_right`,
|
| 61 |
+
`turn_left`, `turn_right`, `stand`.
|
| 62 |
+
|
| 63 |
+
One-shot (run their trained cycle): `kick_left`, `kick_right` (a ball is
|
| 64 |
+
spawned in front), `roll`, `pick`.
|
| 65 |
+
|
| 66 |
+
## Running it
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
pip install -r apps/09_microduck_lab/requirements.txt
|
| 70 |
+
python apps/09_microduck_lab/app.py
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
First launch downloads ~10 MB of meshes and policies into
|
| 74 |
+
`.microduck_cache/` (override with `MICRODUCK_CACHE`).
|
| 75 |
+
|
| 76 |
+
The choreographer uses `Qwen/Qwen3-4B-Instruct-2507` through HF Inference
|
| 77 |
+
Providers when a token is available — sign in on the Space, or set `HF_TOKEN`
|
| 78 |
+
locally. **Without a token it still works**: it falls back to a keyword
|
| 79 |
+
planner, so nothing is behind a sign-in wall.
|
| 80 |
+
|
| 81 |
+
## Files
|
| 82 |
+
|
| 83 |
+
| file | what it is |
|
| 84 |
+
|---|---|
|
| 85 |
+
| `duck.py` | asset fetch, MJCF assembly, the 50 Hz policy/physics loop, rendering |
|
| 86 |
+
| `routine.py` | the move vocabulary, plan parsing, rollout + telemetry |
|
| 87 |
+
| `nodes.py` | the two bound fn nodes: `choreograph`, `perform` |
|
| 88 |
+
| `build_workflow.py` | generates `workflow.json` — edit this, not the JSON |
|
| 89 |
+
| `test_pipelines.py` | runs every subject through the real `WorkflowExecutor` |
|
| 90 |
+
|
| 91 |
+
## Notes for anyone extending it
|
| 92 |
+
|
| 93 |
+
- Both operators are `fn` nodes on purpose. The canvas rewrites `model` node
|
| 94 |
+
ports to the endpoint schema, so anything wanting a richer control surface
|
| 95 |
+
has to be an `fn`.
|
| 96 |
+
- Structured data travels as `text`, never `json` or `dataframe` — the canvas
|
| 97 |
+
serializes those with `String(obj)` and the receiver gets `"[object Object]"`.
|
| 98 |
+
- The video and chart outputs are returned as `{"path", "url"}` dicts. The REST
|
| 99 |
+
API reads `path`; the canvas reads `url` and needs a `data:` URI.
|
| 100 |
+
- `workflow.json` is **autosaved by the canvas** whenever a dev server is
|
| 101 |
+
running. Kill every server before regenerating it, or your graph gets
|
| 102 |
+
written back over.
|
app.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
|
| 6 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 7 |
+
sys.path.insert(0, HERE)
|
| 8 |
+
|
| 9 |
+
from nodes import choreograph, perform # noqa: E402
|
| 10 |
+
|
| 11 |
+
# Pull the MJCF, meshes and policies during boot (~10 MB, ~45 s cold) so the
|
| 12 |
+
# first visitor's run doesn't pay for it. Non-fatal: the sim re-fetches lazily.
|
| 13 |
+
try:
|
| 14 |
+
import duck
|
| 15 |
+
duck.ensure_assets()
|
| 16 |
+
print("microduck assets ready")
|
| 17 |
+
except Exception as e:
|
| 18 |
+
print("microduck asset prefetch failed (" + type(e).__name__ + ": "
|
| 19 |
+
+ str(e) + "); will retry on first run")
|
| 20 |
+
|
| 21 |
+
demo = gr.Workflow(
|
| 22 |
+
os.path.join(HERE, "workflow.json"),
|
| 23 |
+
bind={"choreograph": choreograph, "perform": perform},
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
demo.launch()
|
build_workflow.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate workflow.json. Run this, don't hand-edit the JSON.
|
| 2 |
+
|
| 3 |
+
python build_workflow.py
|
| 4 |
+
|
| 5 |
+
Note the canvas AUTOSAVES workflow.json while a dev server is running, so kill
|
| 6 |
+
every stray server before regenerating, or the old graph will be written back.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 13 |
+
|
| 14 |
+
DEFAULT_ROUTINE = (
|
| 15 |
+
"waddle forward, turn left, then kick the ball with your right foot "
|
| 16 |
+
"and finish with a barrel roll"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def port(pid, label, ptype, required=False, output_index=None):
|
| 21 |
+
p = {"id": pid, "label": label, "type": ptype}
|
| 22 |
+
if required:
|
| 23 |
+
p["required"] = True
|
| 24 |
+
if output_index is not None:
|
| 25 |
+
p["output_index"] = output_index
|
| 26 |
+
return p
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def edge(eid, src, src_port, tgt, tgt_port, etype):
|
| 30 |
+
return {"id": eid, "from_node_id": src, "from_port_id": src_port,
|
| 31 |
+
"to_node_id": tgt, "to_port_id": tgt_port, "type": etype}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
GRAPH = {
|
| 35 |
+
"schema_version": "2",
|
| 36 |
+
"name": "Microduck Lab",
|
| 37 |
+
"references": [
|
| 38 |
+
{
|
| 39 |
+
"id": "ref_routine", "role": "reference", "label": "Routine",
|
| 40 |
+
"asset_type": "text",
|
| 41 |
+
"inputs": [port("in", "Routine", "text")],
|
| 42 |
+
"outputs": [port("out", "Routine", "text")],
|
| 43 |
+
"data": {"out": DEFAULT_ROUTINE},
|
| 44 |
+
"x": 40, "y": 60, "width": 250, "height": 150,
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"id": "ref_seed", "role": "reference", "label": "Seed",
|
| 48 |
+
"asset_type": "number",
|
| 49 |
+
"inputs": [port("in", "Seed", "number")],
|
| 50 |
+
"outputs": [port("out", "Seed", "number")],
|
| 51 |
+
"data": {"out": 0},
|
| 52 |
+
"x": 40, "y": 300, "width": 250, "height": 110,
|
| 53 |
+
},
|
| 54 |
+
],
|
| 55 |
+
"operators": [
|
| 56 |
+
{
|
| 57 |
+
"id": "op_plan", "role": "operator", "kind": "fn", "fn": "choreograph",
|
| 58 |
+
"label": "choreograph (LLM)",
|
| 59 |
+
"inputs": [port("in_routine", "routine", "text", required=True)],
|
| 60 |
+
"outputs": [port("out_0", "plan", "text", output_index=0)],
|
| 61 |
+
"data": {}, "x": 350, "y": 80, "width": 250, "height": 130,
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "op_run", "role": "operator", "kind": "fn", "fn": "perform",
|
| 65 |
+
"label": "perform (MuJoCo + ONNX)",
|
| 66 |
+
"inputs": [port("in_plan", "plan", "text", required=True),
|
| 67 |
+
port("in_seed", "seed", "number")],
|
| 68 |
+
"outputs": [port("out_0", "video", "video", output_index=0),
|
| 69 |
+
port("out_1", "chart", "image", output_index=1),
|
| 70 |
+
port("out_2", "report", "text", output_index=2)],
|
| 71 |
+
"data": {}, "x": 660, "y": 150, "width": 260, "height": 180,
|
| 72 |
+
},
|
| 73 |
+
],
|
| 74 |
+
"subjects": [
|
| 75 |
+
{
|
| 76 |
+
"id": "sub_video", "role": "subject", "label": "Routine video",
|
| 77 |
+
"asset_type": "video",
|
| 78 |
+
"inputs": [port("in", "Video", "video")],
|
| 79 |
+
"outputs": [], "data": {},
|
| 80 |
+
"x": 990, "y": 40, "width": 280, "height": 230,
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "sub_chart", "role": "subject", "label": "Telemetry",
|
| 84 |
+
"asset_type": "image",
|
| 85 |
+
"inputs": [port("in", "Telemetry", "image")],
|
| 86 |
+
"outputs": [], "data": {},
|
| 87 |
+
"x": 990, "y": 300, "width": 280, "height": 210,
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"id": "sub_report", "role": "subject", "label": "Report",
|
| 91 |
+
"asset_type": "text",
|
| 92 |
+
"inputs": [port("in", "Report", "text")],
|
| 93 |
+
"outputs": [], "data": {},
|
| 94 |
+
"x": 990, "y": 540, "width": 280, "height": 180,
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"id": "sub_plan", "role": "subject", "label": "Move plan",
|
| 98 |
+
"asset_type": "text",
|
| 99 |
+
"inputs": [port("in", "Plan", "text")],
|
| 100 |
+
"outputs": [], "data": {},
|
| 101 |
+
"x": 350, "y": 300, "width": 250, "height": 200,
|
| 102 |
+
},
|
| 103 |
+
],
|
| 104 |
+
"edges": [
|
| 105 |
+
edge("e1", "ref_routine", "out", "op_plan", "in_routine", "text"),
|
| 106 |
+
edge("e2", "op_plan", "out_0", "op_run", "in_plan", "text"),
|
| 107 |
+
edge("e3", "op_plan", "out_0", "sub_plan", "in", "text"),
|
| 108 |
+
edge("e4", "ref_seed", "out", "op_run", "in_seed", "number"),
|
| 109 |
+
edge("e5", "op_run", "out_0", "sub_video", "in", "video"),
|
| 110 |
+
edge("e6", "op_run", "out_1", "sub_chart", "in", "image"),
|
| 111 |
+
edge("e7", "op_run", "out_2", "sub_report", "in", "text"),
|
| 112 |
+
],
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def verify(graph):
|
| 117 |
+
"""Cheap structural checks: no dangling edges, no canvas-hostile port types."""
|
| 118 |
+
nodes = {n["id"]: n for n in
|
| 119 |
+
graph["references"] + graph["operators"] + graph["subjects"]}
|
| 120 |
+
problems = []
|
| 121 |
+
for e in graph["edges"]:
|
| 122 |
+
src, tgt = nodes.get(e["from_node_id"]), nodes.get(e["to_node_id"])
|
| 123 |
+
if src is None or tgt is None:
|
| 124 |
+
problems.append("edge " + e["id"] + " points at a missing node")
|
| 125 |
+
continue
|
| 126 |
+
if e["from_port_id"] not in {p["id"] for p in src["outputs"]}:
|
| 127 |
+
problems.append("edge " + e["id"] + ": no output port "
|
| 128 |
+
+ e["from_port_id"] + " on " + e["from_node_id"])
|
| 129 |
+
if e["to_port_id"] not in {p["id"] for p in tgt["inputs"]}:
|
| 130 |
+
problems.append("edge " + e["id"] + ": no input port "
|
| 131 |
+
+ e["to_port_id"] + " on " + e["to_node_id"])
|
| 132 |
+
for n in nodes.values():
|
| 133 |
+
for p in n["inputs"] + n["outputs"]:
|
| 134 |
+
# The canvas serializes these with String(obj) -> "[object Object]".
|
| 135 |
+
if p["type"] in ("json", "dataframe"):
|
| 136 |
+
problems.append(n["id"] + "." + p["id"] + " uses canvas-broken type "
|
| 137 |
+
+ p["type"])
|
| 138 |
+
return problems
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == "__main__":
|
| 142 |
+
issues = verify(GRAPH)
|
| 143 |
+
if issues:
|
| 144 |
+
raise SystemExit("refusing to write workflow.json:\n "
|
| 145 |
+
+ "\n ".join(issues))
|
| 146 |
+
out = os.path.join(HERE, "workflow.json")
|
| 147 |
+
with open(out, "w", encoding="utf-8") as f:
|
| 148 |
+
json.dump(GRAPH, f, indent=2)
|
| 149 |
+
f.write("\n")
|
| 150 |
+
print("wrote " + out)
|
| 151 |
+
print(" %d references, %d operators, %d subjects, %d edges" % (
|
| 152 |
+
len(GRAPH["references"]), len(GRAPH["operators"]),
|
| 153 |
+
len(GRAPH["subjects"]), len(GRAPH["edges"])))
|
duck.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Microduck simulation core.
|
| 2 |
+
|
| 3 |
+
Runs the REAL policies Hugging Face / Pollen Robotics ship with Microduck: the
|
| 4 |
+
MJCF and the ONNX checkpoints are fetched at runtime from the public Space
|
| 5 |
+
`multimodalart/microduck-ar`, so nothing is vendored here.
|
| 6 |
+
|
| 7 |
+
Physics: MuJoCo, timestep 0.005 s, decimation 4 -> 50 Hz control.
|
| 8 |
+
The observation layout (61D), the action scaling and the one-shot state
|
| 9 |
+
machines are ported from that Space's `src/sim.js`, the reference
|
| 10 |
+
implementation:
|
| 11 |
+
|
| 12 |
+
obs = [base_ang_vel(3), projected_gravity(3), joint_pos(14),
|
| 13 |
+
joint_vel(14), last_action(14), command(13)]
|
| 14 |
+
ctrl[j] = DEFAULT_POSE[j] + action[j] * ACTION_SCALE
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import xml.etree.ElementTree as ET
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
# A CPU Space has no GPU and no display, so MuJoCo's default EGL/GLFW backends
|
| 24 |
+
# cannot make a context. osmesa is the software rasteriser (libosmesa6 in
|
| 25 |
+
# packages.txt). Must be set before mujoco is imported.
|
| 26 |
+
if sys.platform.startswith("linux") and not os.environ.get("MUJOCO_GL"):
|
| 27 |
+
os.environ["MUJOCO_GL"] = "osmesa"
|
| 28 |
+
os.environ.setdefault("PYOPENGL_PLATFORM", "osmesa")
|
| 29 |
+
|
| 30 |
+
# -- Constants (from src/sim.js) -----------------------------------------
|
| 31 |
+
JOINT_NAMES = [
|
| 32 |
+
"left_hip_yaw", "left_hip_roll", "left_hip_pitch", "left_knee", "left_ankle",
|
| 33 |
+
"neck_pitch", "head_pitch", "head_yaw", "head_roll",
|
| 34 |
+
"right_hip_yaw", "right_hip_roll", "right_hip_pitch", "right_knee", "right_ankle",
|
| 35 |
+
]
|
| 36 |
+
DEFAULT_POSE = np.array([
|
| 37 |
+
0, -0.08726646259971647, -0.457924, -0.004940, 0.452984,
|
| 38 |
+
0.3490658503988659, 0.3490658503988659, 0, 0,
|
| 39 |
+
0, 0.08726646259971647, 0.457924, 0.004940, -0.452984,
|
| 40 |
+
], dtype=np.float32)
|
| 41 |
+
|
| 42 |
+
NUM_JOINTS = 14
|
| 43 |
+
OBS_SIZE = 61
|
| 44 |
+
CMD_SIZE = 13
|
| 45 |
+
ACTION_SCALE = 1.0
|
| 46 |
+
TIMESTEP = 0.005
|
| 47 |
+
DECIMATION = 4
|
| 48 |
+
CTRL_DT = TIMESTEP * DECIMATION # 50 Hz
|
| 49 |
+
|
| 50 |
+
VEL_FWD, VEL_BACK, VEL_ANG = 0.25, -0.2, 1.0
|
| 51 |
+
BALL_RADIUS = 0.05
|
| 52 |
+
BALL_PARK = "50 0 0.05"
|
| 53 |
+
FENCE_HALF, WALL_T, WALL_H = 1.0, 0.025, 0.125
|
| 54 |
+
|
| 55 |
+
KICK_STEPS = 25
|
| 56 |
+
POST_KICK_LOCK_STEPS = 20
|
| 57 |
+
GROUND_PICK_PERIOD_S = 4.0
|
| 58 |
+
GROUND_PICK_END_PHASE = 0.7
|
| 59 |
+
FALL_DEBOUNCE_STEPS = 10
|
| 60 |
+
FALL_SETTLE_STEPS = 15
|
| 61 |
+
RECOVER_UPRIGHT_STEPS = 50
|
| 62 |
+
RECOVER_GIVEUP_STEPS = 300
|
| 63 |
+
|
| 64 |
+
POLICY_FILES = {
|
| 65 |
+
"walk": "BEST_alpha_walking.onnx",
|
| 66 |
+
"stand": "BEST_alpha_stand.onnx",
|
| 67 |
+
"kickL": "ball_kick_left.onnx",
|
| 68 |
+
"kickR": "ball_kick_right.onnx",
|
| 69 |
+
"roll": "roulade.onnx",
|
| 70 |
+
"groundpick": "alpha_ground_pick.onnx",
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
SPACE = "https://huggingface.co/spaces/multimodalart/microduck-ar/resolve/main"
|
| 74 |
+
CACHE = os.environ.get("MICRODUCK_CACHE") or os.path.join(
|
| 75 |
+
os.path.dirname(os.path.abspath(__file__)), ".microduck_cache")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# -- Asset fetch ---------------------------------------------------------
|
| 79 |
+
def _get(rel, dest):
|
| 80 |
+
"""Download `rel` from the microduck-ar Space into `dest` once."""
|
| 81 |
+
if os.path.exists(dest) and os.path.getsize(dest) > 0:
|
| 82 |
+
return dest
|
| 83 |
+
import urllib.request
|
| 84 |
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
| 85 |
+
tmp = dest + ".part"
|
| 86 |
+
with urllib.request.urlopen(SPACE + "/" + rel, timeout=120) as r:
|
| 87 |
+
blob = r.read()
|
| 88 |
+
with open(tmp, "wb") as f:
|
| 89 |
+
f.write(blob)
|
| 90 |
+
os.replace(tmp, dest)
|
| 91 |
+
return dest
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def ensure_assets():
|
| 95 |
+
"""Fetch MJCF + meshes + policies. Returns (mjcf_path, mesh_dir, policy_dir)."""
|
| 96 |
+
mjcf = _get("public/robot/mjlab/robot_allcollisions.xml",
|
| 97 |
+
os.path.join(CACHE, "robot_allcollisions.xml"))
|
| 98 |
+
mesh_dir = os.path.join(CACHE, "assets")
|
| 99 |
+
root = ET.parse(mjcf).getroot()
|
| 100 |
+
for m in root.find("asset").iter("mesh"):
|
| 101 |
+
_get("public/robot/mjlab/meshes/" + m.get("file"),
|
| 102 |
+
os.path.join(mesh_dir, m.get("file")))
|
| 103 |
+
policy_dir = os.path.join(CACHE, "policies")
|
| 104 |
+
for fn in POLICY_FILES.values():
|
| 105 |
+
_get("public/policies/" + fn, os.path.join(policy_dir, fn))
|
| 106 |
+
return mjcf, mesh_dir, policy_dir
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# -- Model assembly ------------------------------------------------------
|
| 110 |
+
def build_xml(mjcf_path, mesh_dir):
|
| 111 |
+
"""Robot MJCF + floor, fence, ball, lights, chase camera and a STAND key.
|
| 112 |
+
|
| 113 |
+
Mirrors buildPhysicsXml() in sim.js, except the `visual` geoms are KEPT:
|
| 114 |
+
they are contype=0/conaffinity=0 so they cost nothing physically, and they
|
| 115 |
+
are what makes the render look like an actual duck.
|
| 116 |
+
"""
|
| 117 |
+
root = ET.parse(mjcf_path).getroot()
|
| 118 |
+
root.find("compiler").set("meshdir", mesh_dir)
|
| 119 |
+
ET.SubElement(root, "option", {"timestep": str(TIMESTEP)})
|
| 120 |
+
|
| 121 |
+
asset = root.find("asset")
|
| 122 |
+
ET.SubElement(asset, "texture", {
|
| 123 |
+
"name": "sky", "type": "skybox", "builtin": "gradient",
|
| 124 |
+
"rgb1": "0.30 0.50 0.82", "rgb2": "0.88 0.94 1.0",
|
| 125 |
+
"width": "256", "height": "256"})
|
| 126 |
+
ET.SubElement(asset, "texture", {
|
| 127 |
+
"name": "grid", "type": "2d", "builtin": "checker",
|
| 128 |
+
"width": "512", "height": "512",
|
| 129 |
+
"rgb1": "0.93 0.93 0.96", "rgb2": "0.78 0.81 0.88"})
|
| 130 |
+
ET.SubElement(asset, "material", {
|
| 131 |
+
"name": "gridmat", "texture": "grid", "texrepeat": "14 14",
|
| 132 |
+
"reflectance": "0.08"})
|
| 133 |
+
|
| 134 |
+
world = root.find("worldbody")
|
| 135 |
+
ET.SubElement(world, "geom", {
|
| 136 |
+
"name": "floor", "type": "plane", "size": "0 0 0.05",
|
| 137 |
+
"pos": "0 0 0", "material": "gridmat"})
|
| 138 |
+
ET.SubElement(world, "light", {
|
| 139 |
+
"pos": "0.6 -0.6 1.8", "dir": "-0.3 0.3 -1",
|
| 140 |
+
"directional": "true", "diffuse": "0.75 0.75 0.75"})
|
| 141 |
+
ET.SubElement(world, "light", {
|
| 142 |
+
"pos": "-1 1 1.2", "dir": "0.5 -0.5 -1",
|
| 143 |
+
"directional": "true", "diffuse": "0.25 0.25 0.3"})
|
| 144 |
+
|
| 145 |
+
off, span = FENCE_HALF + WALL_T, FENCE_HALF + 0.05
|
| 146 |
+
for name, pos, size in [
|
| 147 |
+
("wall_px", str(off) + " 0 " + str(WALL_H), str(WALL_T) + " " + str(span) + " " + str(WALL_H)),
|
| 148 |
+
("wall_nx", str(-off) + " 0 " + str(WALL_H), str(WALL_T) + " " + str(span) + " " + str(WALL_H)),
|
| 149 |
+
("wall_py", "0 " + str(off) + " " + str(WALL_H), str(span) + " " + str(WALL_T) + " " + str(WALL_H)),
|
| 150 |
+
("wall_ny", "0 " + str(-off) + " " + str(WALL_H), str(span) + " " + str(WALL_T) + " " + str(WALL_H)),
|
| 151 |
+
]:
|
| 152 |
+
# Invisible fence keeps the duck and ball in frame. group 3 = not drawn.
|
| 153 |
+
ET.SubElement(world, "geom", {
|
| 154 |
+
"name": name, "type": "box", "pos": pos, "size": size, "group": "3"})
|
| 155 |
+
|
| 156 |
+
ball = ET.SubElement(world, "body", {"name": "ball", "pos": BALL_PARK})
|
| 157 |
+
ET.SubElement(ball, "freejoint", {"name": "ball_freejoint"})
|
| 158 |
+
ET.SubElement(ball, "geom", {
|
| 159 |
+
"name": "ball_geom", "type": "sphere", "size": str(BALL_RADIUS),
|
| 160 |
+
"mass": "0.03", "friction": "0.4 0.01 0.003", "solref": "0.03 0.4",
|
| 161 |
+
"condim": "6", "rgba": "0.95 0.35 0.15 1"})
|
| 162 |
+
|
| 163 |
+
# STAND keyframe: freejoint(7) + every hinge in document order + ball(7).
|
| 164 |
+
pose = dict(zip(JOINT_NAMES, DEFAULT_POSE))
|
| 165 |
+
order = [j.get("name") for body in root.iter("body")
|
| 166 |
+
for j in body if j.tag == "joint"]
|
| 167 |
+
qj = " ".join(str(float(pose.get(n, 0.0))) for n in order)
|
| 168 |
+
kf = ET.SubElement(root, "keyframe")
|
| 169 |
+
ET.SubElement(kf, "key", {
|
| 170 |
+
"name": "STAND",
|
| 171 |
+
"qpos": "0 0 0.12 1 0 0 0 " + qj + " " + BALL_PARK + " 1 0 0 0",
|
| 172 |
+
"ctrl": " ".join(str(float(x)) for x in DEFAULT_POSE)})
|
| 173 |
+
return ET.tostring(root, encoding="unicode")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# -- The sim -------------------------------------------------------------
|
| 177 |
+
class Microduck:
|
| 178 |
+
def __init__(self, width=640, height=360, render=True):
|
| 179 |
+
import mujoco
|
| 180 |
+
import onnxruntime as ort
|
| 181 |
+
self.mj = mujoco
|
| 182 |
+
mjcf, mesh_dir, policy_dir = ensure_assets()
|
| 183 |
+
self.model = mujoco.MjModel.from_xml_string(build_xml(mjcf, mesh_dir))
|
| 184 |
+
self.data = mujoco.MjData(self.model)
|
| 185 |
+
|
| 186 |
+
opts = ort.SessionOptions()
|
| 187 |
+
opts.intra_op_num_threads = 1
|
| 188 |
+
self.sessions = {
|
| 189 |
+
k: ort.InferenceSession(
|
| 190 |
+
os.path.join(policy_dir, f), sess_options=opts,
|
| 191 |
+
providers=["CPUExecutionProvider"])
|
| 192 |
+
for k, f in POLICY_FILES.items()}
|
| 193 |
+
|
| 194 |
+
self.qadr = [self.model.jnt(n).qposadr[0] for n in JOINT_NAMES]
|
| 195 |
+
self.dadr = [self.model.jnt(n).dofadr[0] for n in JOINT_NAMES]
|
| 196 |
+
self.gyro = self.model.sensor("imu_ang_vel").adr[0]
|
| 197 |
+
self.trunk = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "trunk_base")
|
| 198 |
+
self.stand_key = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_KEY, "STAND")
|
| 199 |
+
self.ball_q = self.model.jnt("ball_freejoint").qposadr[0]
|
| 200 |
+
self.ball_d = self.model.jnt("ball_freejoint").dofadr[0]
|
| 201 |
+
|
| 202 |
+
# Shadow mapping costs ~90 ms/frame here and only buys a blocky
|
| 203 |
+
# contact shadow, so it is off; multisampling is cheap and kept.
|
| 204 |
+
self.model.vis.quality.shadowsize = 0
|
| 205 |
+
self.model.vis.quality.offsamples = 4
|
| 206 |
+
|
| 207 |
+
self.renderer = mujoco.Renderer(self.model, height, width) if render else None
|
| 208 |
+
self.opt = mujoco.MjvOption()
|
| 209 |
+
for g in range(len(self.opt.geomgroup)):
|
| 210 |
+
self.opt.geomgroup[g] = 1 if g <= 2 else 0 # hide collision + fence
|
| 211 |
+
self.cam = mujoco.MjvCamera()
|
| 212 |
+
self.cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
|
| 213 |
+
self.cam.trackbodyid = self.trunk
|
| 214 |
+
self.cam.distance = 0.62
|
| 215 |
+
self.cam.elevation = -10.0
|
| 216 |
+
self.cam.lookat[:] = [0.0, 0.0, 0.05]
|
| 217 |
+
self.cam_offset = 125.0 # degrees behind-left of the duck's heading
|
| 218 |
+
self._az = None
|
| 219 |
+
self.reset()
|
| 220 |
+
|
| 221 |
+
# -- state ------------------------------------------------------------
|
| 222 |
+
def reset(self):
|
| 223 |
+
self.mj.mj_resetDataKeyframe(self.model, self.data, self.stand_key)
|
| 224 |
+
self.mj.mj_forward(self.model, self.data)
|
| 225 |
+
self.last_action = np.zeros(NUM_JOINTS, dtype=np.float32)
|
| 226 |
+
self.recovery = None
|
| 227 |
+
self.fall_debounce = 0
|
| 228 |
+
self.post_kick_lock = 0
|
| 229 |
+
self.ball_active = False
|
| 230 |
+
|
| 231 |
+
def proj_gravity(self):
|
| 232 |
+
w, x, y, z = self.data.body(self.trunk).xquat
|
| 233 |
+
R = np.array([
|
| 234 |
+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
|
| 235 |
+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
|
| 236 |
+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]])
|
| 237 |
+
return R.T @ np.array([0.0, 0.0, -1.0])
|
| 238 |
+
|
| 239 |
+
def yaw(self):
|
| 240 |
+
q = self.data.qpos
|
| 241 |
+
return float(np.arctan2(2 * (q[3] * q[6] + q[4] * q[5]),
|
| 242 |
+
1 - 2 * (q[5] ** 2 + q[6] ** 2)))
|
| 243 |
+
|
| 244 |
+
def spawn_ball(self, rng):
|
| 245 |
+
q, v = self.data.qpos, self.data.qvel
|
| 246 |
+
heading = self.yaw() + (rng.random() - 0.5) * 0.5
|
| 247 |
+
dist = 0.32 + (rng.random() - 0.5) * 0.08
|
| 248 |
+
margin = BALL_RADIUS + 0.05
|
| 249 |
+
lo, hi = -FENCE_HALF + margin, FENCE_HALF - margin
|
| 250 |
+
q[self.ball_q] = float(np.clip(q[0] + np.cos(heading) * dist, lo, hi))
|
| 251 |
+
q[self.ball_q + 1] = float(np.clip(q[1] + np.sin(heading) * dist, lo, hi))
|
| 252 |
+
q[self.ball_q + 2] = BALL_RADIUS + 0.02
|
| 253 |
+
q[self.ball_q + 3:self.ball_q + 7] = [1, 0, 0, 0]
|
| 254 |
+
v[self.ball_d:self.ball_d + 6] = 0
|
| 255 |
+
self.mj.mj_forward(self.model, self.data)
|
| 256 |
+
self.ball_active = True
|
| 257 |
+
|
| 258 |
+
def park_ball(self):
|
| 259 |
+
q, v = self.data.qpos, self.data.qvel
|
| 260 |
+
q[self.ball_q:self.ball_q + 3] = [50, 0, BALL_RADIUS]
|
| 261 |
+
q[self.ball_q + 3:self.ball_q + 7] = [1, 0, 0, 0]
|
| 262 |
+
v[self.ball_d:self.ball_d + 6] = 0
|
| 263 |
+
self.mj.mj_forward(self.model, self.data)
|
| 264 |
+
self.ball_active = False
|
| 265 |
+
|
| 266 |
+
# -- one control step (50 Hz) -----------------------------------------
|
| 267 |
+
def control_step(self, mode, cmd3, pick_phase=None):
|
| 268 |
+
if self.recovery is not None:
|
| 269 |
+
policy = "stand" if self.recovery["state"] == "recovering" else None
|
| 270 |
+
else:
|
| 271 |
+
policy = mode
|
| 272 |
+
|
| 273 |
+
if policy is not None:
|
| 274 |
+
obs = np.zeros(OBS_SIZE, dtype=np.float32)
|
| 275 |
+
i = 0
|
| 276 |
+
obs[i:i + 3] = self.data.sensordata[self.gyro:self.gyro + 3]
|
| 277 |
+
i += 3
|
| 278 |
+
obs[i:i + 3] = self.proj_gravity()
|
| 279 |
+
i += 3
|
| 280 |
+
obs[i:i + NUM_JOINTS] = self.data.qpos[self.qadr] - DEFAULT_POSE
|
| 281 |
+
i += NUM_JOINTS
|
| 282 |
+
obs[i:i + NUM_JOINTS] = self.data.qvel[self.dadr]
|
| 283 |
+
i += NUM_JOINTS
|
| 284 |
+
obs[i:i + NUM_JOINTS] = self.last_action
|
| 285 |
+
i += NUM_JOINTS
|
| 286 |
+
cmd = np.zeros(CMD_SIZE, dtype=np.float32)
|
| 287 |
+
if mode == "groundpick" and pick_phase is not None:
|
| 288 |
+
# Phase clock encoded as [cos, sin] in the velocity slots.
|
| 289 |
+
a = 2 * np.pi * pick_phase
|
| 290 |
+
cmd[0], cmd[1] = np.cos(a), np.sin(a)
|
| 291 |
+
elif self.recovery is None and self.post_kick_lock == 0:
|
| 292 |
+
cmd[:3] = cmd3
|
| 293 |
+
obs[i:i + CMD_SIZE] = cmd
|
| 294 |
+
|
| 295 |
+
act = self.sessions[policy].run(None, {"obs": obs.reshape(1, -1)})[0][0]
|
| 296 |
+
self.last_action = act.astype(np.float32)
|
| 297 |
+
self.data.ctrl[:NUM_JOINTS] = DEFAULT_POSE + act * ACTION_SCALE
|
| 298 |
+
|
| 299 |
+
for _ in range(DECIMATION):
|
| 300 |
+
self.mj.mj_step(self.model, self.data)
|
| 301 |
+
|
| 302 |
+
if self.post_kick_lock > 0:
|
| 303 |
+
self.post_kick_lock -= 1
|
| 304 |
+
self._update_recovery(mode)
|
| 305 |
+
|
| 306 |
+
def _update_recovery(self, mode):
|
| 307 |
+
z, gz = float(self.data.qpos[2]), float(self.proj_gravity()[2])
|
| 308 |
+
if not np.isfinite(z) or not np.isfinite(gz):
|
| 309 |
+
self.reset()
|
| 310 |
+
return
|
| 311 |
+
fallen = gz > -0.5 or z < 0.02
|
| 312 |
+
if self.recovery is not None:
|
| 313 |
+
self.recovery["steps"] += 1
|
| 314 |
+
if self.recovery["state"] == "fallen":
|
| 315 |
+
if self.recovery["steps"] >= FALL_SETTLE_STEPS:
|
| 316 |
+
self.recovery = {"state": "recovering", "steps": 0, "upright": 0}
|
| 317 |
+
self.last_action[:] = 0
|
| 318 |
+
else:
|
| 319 |
+
self.recovery["upright"] = (
|
| 320 |
+
self.recovery["upright"] + 1 if gz < -0.85 else 0)
|
| 321 |
+
if self.recovery["upright"] >= RECOVER_UPRIGHT_STEPS:
|
| 322 |
+
self.recovery = None
|
| 323 |
+
self.last_action[:] = 0
|
| 324 |
+
elif self.recovery["steps"] >= RECOVER_GIVEUP_STEPS:
|
| 325 |
+
self.reset()
|
| 326 |
+
elif fallen and mode in ("walk", "stand") and self.post_kick_lock == 0:
|
| 327 |
+
self.fall_debounce += 1
|
| 328 |
+
if self.fall_debounce >= FALL_DEBOUNCE_STEPS:
|
| 329 |
+
self.fall_debounce = 0
|
| 330 |
+
self.recovery = {"state": "fallen", "steps": 0}
|
| 331 |
+
else:
|
| 332 |
+
self.fall_debounce = 0
|
| 333 |
+
|
| 334 |
+
def frame(self):
|
| 335 |
+
"""Render one frame from a tracking camera that eases in behind the duck."""
|
| 336 |
+
target = np.degrees(self.yaw()) + self.cam_offset
|
| 337 |
+
if self._az is None:
|
| 338 |
+
self._az = target
|
| 339 |
+
else:
|
| 340 |
+
# Shortest-arc smoothing, so a turn pans instead of snapping.
|
| 341 |
+
self._az += 0.05 * (((target - self._az + 180.0) % 360.0) - 180.0)
|
| 342 |
+
self.cam.azimuth = self._az
|
| 343 |
+
self.renderer.update_scene(self.data, camera=self.cam, scene_option=self.opt)
|
| 344 |
+
return self.renderer.render()
|
nodes.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The bound fn nodes for the Microduck Lab workflow.
|
| 2 |
+
|
| 3 |
+
Two operators:
|
| 4 |
+
choreograph(routine) -> plan JSON as TEXT
|
| 5 |
+
perform(plan, seed) -> (video, chart image, report text)
|
| 6 |
+
|
| 7 |
+
Everything structured travels as `text`, and both media outputs are returned
|
| 8 |
+
as FileData-shaped dicts, because the workflow canvas string-coerces `json`
|
| 9 |
+
and `dataframe` fn outputs and needs `{"path", "url"}` for media.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import tempfile
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from gradio import OAuthToken
|
| 21 |
+
|
| 22 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 23 |
+
|
| 24 |
+
import routine as R # noqa: E402
|
| 25 |
+
|
| 26 |
+
LLM = "Qwen/Qwen3-4B-Instruct-2507"
|
| 27 |
+
|
| 28 |
+
SYSTEM = """You choreograph routines for Microduck, a 25 cm bipedal robot.
|
| 29 |
+
|
| 30 |
+
Reply with ONLY a JSON array of steps. Each step is {"move": <name>} plus
|
| 31 |
+
"seconds" for the timed moves. Allowed moves:
|
| 32 |
+
|
| 33 |
+
timed (need "seconds", 0.2-8.0):
|
| 34 |
+
forward, backward, strafe_left, strafe_right,
|
| 35 |
+
turn_left, turn_right, stand
|
| 36 |
+
one-shot (no "seconds", they run their trained cycle):
|
| 37 |
+
kick_left, kick_right - kicks a ball, one is spawned in front
|
| 38 |
+
roll - a roulade / forward tumble
|
| 39 |
+
pick - bends down and picks an object off the floor
|
| 40 |
+
|
| 41 |
+
Rules:
|
| 42 |
+
- Include EVERY action the user asks for, in the order they ask for it.
|
| 43 |
+
Never drop one, never reorder them.
|
| 44 |
+
- Only add a move the user did not ask for if the routine needs it to make
|
| 45 |
+
sense (e.g. walking up to a ball before kicking it).
|
| 46 |
+
- Keep the total under 20 seconds, and end with a short "stand" so the
|
| 47 |
+
routine settles.
|
| 48 |
+
|
| 49 |
+
Output the JSON array and nothing else."""
|
| 50 |
+
|
| 51 |
+
# One worked example. Without it both Qwen3-4B and Gemma-3-27B silently drop
|
| 52 |
+
# the first move of a multi-part request; with it, both follow the order.
|
| 53 |
+
FEWSHOT = [
|
| 54 |
+
{"role": "user",
|
| 55 |
+
"content": "waddle forward a bit, then turn right and kick the ball"},
|
| 56 |
+
{"role": "assistant",
|
| 57 |
+
"content": '[{"move":"forward","seconds":2.5},'
|
| 58 |
+
'{"move":"turn_right","seconds":1.5},'
|
| 59 |
+
'{"move":"kick_right"},'
|
| 60 |
+
'{"move":"stand","seconds":1.0}]'},
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
# Words -> moves, for the no-token fallback choreographer.
|
| 64 |
+
_KEYWORDS = [
|
| 65 |
+
(("kick left", "left foot", "kick with its left"), {"move": "kick_left"}),
|
| 66 |
+
(("kick", "ball", "shoot", "football", "soccer", "penalty"), {"move": "kick_right"}),
|
| 67 |
+
(("roll", "roulade", "somersault", "tumble", "flip", "barrel"), {"move": "roll"}),
|
| 68 |
+
(("pick", "grab", "fetch", "sock", "pick up", "bend"), {"move": "pick"}),
|
| 69 |
+
(("turn left", "left turn", "veer left"), {"move": "turn_left", "seconds": 1.5}),
|
| 70 |
+
(("turn right", "right turn", "veer right"), {"move": "turn_right", "seconds": 1.5}),
|
| 71 |
+
(("spin", "pirouette", "circle", "twirl"), {"move": "turn_left", "seconds": 3.0}),
|
| 72 |
+
(("back", "reverse", "retreat"), {"move": "backward", "seconds": 2.0}),
|
| 73 |
+
(("wait", "pause", "stand", "still", "idle"), {"move": "stand", "seconds": 1.5}),
|
| 74 |
+
(("walk", "waddle", "forward", "march", "stroll", "ahead"),
|
| 75 |
+
{"move": "forward", "seconds": 2.5}),
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _fallback_plan(routine_text):
|
| 80 |
+
"""Keyword choreographer, used when no HF token is available.
|
| 81 |
+
|
| 82 |
+
Keeps the demo usable signed-out; the LLM path is the upgrade.
|
| 83 |
+
"""
|
| 84 |
+
text = (routine_text or "").lower()
|
| 85 |
+
steps, seen = [], set()
|
| 86 |
+
# Preserve the order the user mentioned things in.
|
| 87 |
+
hits = []
|
| 88 |
+
for words, step in _KEYWORDS:
|
| 89 |
+
pos = min((text.find(w) for w in words if w in text), default=-1)
|
| 90 |
+
if pos >= 0 and step["move"] not in seen:
|
| 91 |
+
seen.add(step["move"])
|
| 92 |
+
hits.append((pos, step))
|
| 93 |
+
for _, step in sorted(hits, key=lambda h: h[0]):
|
| 94 |
+
steps.append(dict(step))
|
| 95 |
+
if not steps:
|
| 96 |
+
steps = [{"move": "forward", "seconds": 2.5}]
|
| 97 |
+
if steps[0]["move"] in R.ONE_SHOT:
|
| 98 |
+
steps.insert(0, {"move": "forward", "seconds": 1.5})
|
| 99 |
+
steps.append({"move": "stand", "seconds": 1.0})
|
| 100 |
+
return steps
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def choreograph(routine_text: str, oauth_token: Optional[OAuthToken] = None) -> str:
|
| 104 |
+
"""Turn a plain-English routine into a Microduck move plan (JSON text)."""
|
| 105 |
+
routine_text = (routine_text or "").strip() or "waddle forward and look around"
|
| 106 |
+
token = getattr(oauth_token, "token", None) or os.environ.get("HF_TOKEN")
|
| 107 |
+
if token is None:
|
| 108 |
+
try:
|
| 109 |
+
from huggingface_hub import get_token
|
| 110 |
+
token = get_token()
|
| 111 |
+
except Exception:
|
| 112 |
+
token = None
|
| 113 |
+
|
| 114 |
+
steps = None
|
| 115 |
+
if token:
|
| 116 |
+
try:
|
| 117 |
+
from huggingface_hub import InferenceClient
|
| 118 |
+
reply = InferenceClient(token=token).chat_completion(
|
| 119 |
+
model=LLM,
|
| 120 |
+
messages=([{"role": "system", "content": SYSTEM}] + FEWSHOT
|
| 121 |
+
+ [{"role": "user", "content": routine_text}]),
|
| 122 |
+
max_tokens=400,
|
| 123 |
+
temperature=0.2,
|
| 124 |
+
).choices[0].message.content
|
| 125 |
+
parsed, _ = R.parse_plan(reply)
|
| 126 |
+
if parsed:
|
| 127 |
+
steps = parsed
|
| 128 |
+
except Exception:
|
| 129 |
+
steps = None # any provider hiccup -> keyword fallback
|
| 130 |
+
|
| 131 |
+
if steps is None:
|
| 132 |
+
steps = _fallback_plan(routine_text)
|
| 133 |
+
parsed, _ = R.parse_plan(json.dumps(steps))
|
| 134 |
+
steps = parsed or [{"move": "forward", "seconds": 2.5}]
|
| 135 |
+
return json.dumps(steps, indent=2)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _filedata(path, mime):
|
| 139 |
+
"""The one output shape both the canvas and the REST API accept."""
|
| 140 |
+
with open(path, "rb") as f:
|
| 141 |
+
b64 = base64.b64encode(f.read()).decode()
|
| 142 |
+
return {"path": path, "url": "data:" + mime + ";base64," + b64}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _chart(tel):
|
| 146 |
+
"""Ground track + uprightness, drawn as an image (dataframe/json ports are
|
| 147 |
+
string-coerced by the canvas, image ports are not)."""
|
| 148 |
+
import matplotlib
|
| 149 |
+
matplotlib.use("Agg")
|
| 150 |
+
import matplotlib.pyplot as plt
|
| 151 |
+
|
| 152 |
+
track = np.array(tel["track"]) if tel["track"] else np.zeros((1, 2))
|
| 153 |
+
upright = np.array(tel["upright"]) if tel["upright"] else np.zeros(1)
|
| 154 |
+
t = np.arange(len(upright)) / max(tel["fps"], 1)
|
| 155 |
+
|
| 156 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.0), dpi=110)
|
| 157 |
+
fig.patch.set_facecolor("white")
|
| 158 |
+
|
| 159 |
+
rec = np.array([m == "recover" for m in tel["modes"]])
|
| 160 |
+
ax1.plot(track[:, 0], track[:, 1], lw=2.0, color="#f0a500", zorder=2)
|
| 161 |
+
if rec.any():
|
| 162 |
+
ax1.scatter(track[rec, 0], track[rec, 1], s=14, color="#d94a2b",
|
| 163 |
+
zorder=3, label="recovering")
|
| 164 |
+
ax1.legend(loc="best", fontsize=8, frameon=False)
|
| 165 |
+
ax1.scatter([track[0, 0]], [track[0, 1]], s=70, marker="o",
|
| 166 |
+
color="#2e7d32", zorder=4)
|
| 167 |
+
ax1.scatter([track[-1, 0]], [track[-1, 1]], s=90, marker="*",
|
| 168 |
+
color="#1565c0", zorder=4)
|
| 169 |
+
ax1.set_title("ground track (start ● / end ★)", fontsize=10)
|
| 170 |
+
ax1.set_xlabel("x (m)"); ax1.set_ylabel("y (m)")
|
| 171 |
+
ax1.set_aspect("equal", adjustable="datalim")
|
| 172 |
+
ax1.grid(alpha=0.25)
|
| 173 |
+
|
| 174 |
+
ax2.plot(t, upright, lw=1.6, color="#37474f")
|
| 175 |
+
ax2.axhline(-1.0, ls=":", lw=1, color="#2e7d32")
|
| 176 |
+
ax2.axhline(-0.5, ls="--", lw=1, color="#d94a2b")
|
| 177 |
+
ax2.fill_between(t, -0.5, upright, where=(upright > -0.5),
|
| 178 |
+
color="#d94a2b", alpha=0.18)
|
| 179 |
+
ax2.set_ylim(1.05, -1.05)
|
| 180 |
+
ax2.set_title("uprightness (-1 = standing, > -0.5 = down)", fontsize=10)
|
| 181 |
+
ax2.set_xlabel("t (s)"); ax2.set_ylabel("projected gravity z")
|
| 182 |
+
ax2.grid(alpha=0.25)
|
| 183 |
+
|
| 184 |
+
fig.tight_layout()
|
| 185 |
+
png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
| 186 |
+
fig.savefig(png, facecolor="white")
|
| 187 |
+
plt.close(fig)
|
| 188 |
+
return png
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _report(plan_steps, tel):
|
| 192 |
+
lines = [
|
| 193 |
+
"Microduck routine - " + str(tel["duration_s"]) + " s of simulated time",
|
| 194 |
+
"",
|
| 195 |
+
"policies: the six ONNX checkpoints shipped with Microduck, stepped at",
|
| 196 |
+
"50 Hz against MuJoCo physics (timestep 0.005 s, decimation 4).",
|
| 197 |
+
"",
|
| 198 |
+
"step t (s) moved ",
|
| 199 |
+
"-------------------------------------------- ",
|
| 200 |
+
]
|
| 201 |
+
for entry in tel["log"]:
|
| 202 |
+
span = str(entry["t_start"]) + "-" + str(entry["t_end"])
|
| 203 |
+
lines.append("%-18s %-14s %6.3f m" % (entry["move"], span, entry["travelled_m"]))
|
| 204 |
+
lines += [
|
| 205 |
+
"",
|
| 206 |
+
"net displacement : " + str(tel["distance_m"]) + " m",
|
| 207 |
+
"falls + recoveries: " + str(tel["falls"]),
|
| 208 |
+
"ended upright : " + ("yes" if tel["final_upright"] < -0.85 else "no")
|
| 209 |
+
+ " (projected gravity z = " + str(tel["final_upright"]) + ")",
|
| 210 |
+
]
|
| 211 |
+
return "\n".join(lines)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def perform(plan: str, seed: float = 0, fps: float = 25):
|
| 215 |
+
"""Run a plan through the real policies. Returns (video, chart, report)."""
|
| 216 |
+
steps, notes = R.parse_plan(plan)
|
| 217 |
+
if not steps:
|
| 218 |
+
raise ValueError("No runnable moves in the plan. " + "; ".join(notes))
|
| 219 |
+
|
| 220 |
+
frames, tel = R.run(steps, seed=int(seed or 0), width=640, height=360,
|
| 221 |
+
fps=int(fps or 25))
|
| 222 |
+
|
| 223 |
+
import imageio
|
| 224 |
+
mp4 = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
|
| 225 |
+
imageio.mimsave(mp4, frames, fps=tel["fps"], codec="libx264",
|
| 226 |
+
quality=7, macro_block_size=1)
|
| 227 |
+
|
| 228 |
+
report = _report(steps, tel)
|
| 229 |
+
if notes:
|
| 230 |
+
report += "\n\nnotes: " + "; ".join(notes)
|
| 231 |
+
return (_filedata(mp4, "video/mp4"),
|
| 232 |
+
_filedata(_chart(tel), "image/png"),
|
| 233 |
+
report)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
BIND = {"choreograph": choreograph, "perform": perform}
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libosmesa6
|
| 2 |
+
libgl1
|
| 3 |
+
libglx-mesa0
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mujoco>=3.2
|
| 2 |
+
onnxruntime
|
| 3 |
+
numpy
|
| 4 |
+
matplotlib
|
| 5 |
+
imageio
|
| 6 |
+
imageio-ffmpeg
|
| 7 |
+
PyOpenGL
|
routine.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn a routine (a list of moves) into a rollout of the real policies.
|
| 2 |
+
|
| 3 |
+
A plan is a JSON list of steps, e.g.
|
| 4 |
+
|
| 5 |
+
[{"move": "forward", "seconds": 2.5},
|
| 6 |
+
{"move": "turn_left", "seconds": 1.2},
|
| 7 |
+
{"move": "kick_right"},
|
| 8 |
+
{"move": "roll"}]
|
| 9 |
+
|
| 10 |
+
Duration-based moves (`forward`..`stand`) take `seconds`; the one-shots
|
| 11 |
+
(`kick_*`, `roll`, `pick`) run for exactly as long as their trained cycle
|
| 12 |
+
lasts, so `seconds` is ignored for them.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
import duck as D
|
| 20 |
+
|
| 21 |
+
# move -> (policy mode, command [vx, vy, wz])
|
| 22 |
+
MOVES = {
|
| 23 |
+
"forward": ("walk", (D.VEL_FWD, 0.0, 0.0)),
|
| 24 |
+
"backward": ("walk", (D.VEL_BACK, 0.0, 0.0)),
|
| 25 |
+
"strafe_left": ("walk", (0.0, 0.15, 0.0)),
|
| 26 |
+
"strafe_right": ("walk", (0.0, -0.15, 0.0)),
|
| 27 |
+
"turn_left": ("walk", (0.0, 0.0, D.VEL_ANG)),
|
| 28 |
+
"turn_right": ("walk", (0.0, 0.0, -D.VEL_ANG)),
|
| 29 |
+
"stand": ("walk", (0.0, 0.0, 0.0)),
|
| 30 |
+
"kick_left": ("kickL", (0.0, 0.0, 0.0)),
|
| 31 |
+
"kick_right": ("kickR", (0.0, 0.0, 0.0)),
|
| 32 |
+
"roll": ("roll", (0.0, 0.0, 0.0)),
|
| 33 |
+
"pick": ("groundpick", (0.0, 0.0, 0.0)),
|
| 34 |
+
}
|
| 35 |
+
ONE_SHOT = {"kick_left", "kick_right", "roll", "pick"}
|
| 36 |
+
MAX_SECONDS = 30.0
|
| 37 |
+
|
| 38 |
+
# Loose spellings the LLM (or a human) may produce.
|
| 39 |
+
ALIASES = {
|
| 40 |
+
"walk": "forward", "walk_forward": "forward", "forwards": "forward",
|
| 41 |
+
"back": "backward", "backwards": "backward", "reverse": "backward",
|
| 42 |
+
"left": "turn_left", "right": "turn_right",
|
| 43 |
+
"rotate_left": "turn_left", "rotate_right": "turn_right",
|
| 44 |
+
"spin": "turn_left", "spin_left": "turn_left", "spin_right": "turn_right",
|
| 45 |
+
"sidestep_left": "strafe_left", "sidestep_right": "strafe_right",
|
| 46 |
+
"wait": "stand", "idle": "stand", "pause": "stand", "look": "stand",
|
| 47 |
+
"kick": "kick_right", "shoot": "kick_right",
|
| 48 |
+
"roulade": "roll", "somersault": "roll", "flip": "roll", "tumble": "roll",
|
| 49 |
+
"pickup": "pick", "pick_up": "pick", "grab": "pick", "fetch": "pick",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def parse_plan(text):
|
| 54 |
+
"""Parse a plan from JSON text. Returns (steps, notes).
|
| 55 |
+
|
| 56 |
+
Tolerant on purpose: the plan usually comes from an LLM. Unknown moves are
|
| 57 |
+
dropped and reported in `notes` rather than raising.
|
| 58 |
+
"""
|
| 59 |
+
notes = []
|
| 60 |
+
raw = (text or "").strip()
|
| 61 |
+
if not raw:
|
| 62 |
+
return [], ["empty plan"]
|
| 63 |
+
# Tolerate ```json fences and any prose around the array.
|
| 64 |
+
if "```" in raw:
|
| 65 |
+
raw = raw.split("```")[1]
|
| 66 |
+
if raw.lstrip().lower().startswith("json"):
|
| 67 |
+
raw = raw.lstrip()[4:]
|
| 68 |
+
start, end = raw.find("["), raw.rfind("]")
|
| 69 |
+
if start != -1 and end > start:
|
| 70 |
+
raw = raw[start:end + 1]
|
| 71 |
+
try:
|
| 72 |
+
parsed = json.loads(raw)
|
| 73 |
+
except json.JSONDecodeError as e:
|
| 74 |
+
return [], ["could not parse plan as JSON: " + str(e)]
|
| 75 |
+
if isinstance(parsed, dict):
|
| 76 |
+
parsed = parsed.get("plan") or parsed.get("steps") or [parsed]
|
| 77 |
+
if not isinstance(parsed, list):
|
| 78 |
+
return [], ["plan is not a list"]
|
| 79 |
+
|
| 80 |
+
steps, total = [], 0.0
|
| 81 |
+
for item in parsed:
|
| 82 |
+
if isinstance(item, str):
|
| 83 |
+
item = {"move": item}
|
| 84 |
+
if not isinstance(item, dict):
|
| 85 |
+
continue
|
| 86 |
+
name = str(item.get("move") or item.get("action") or "").strip().lower()
|
| 87 |
+
name = name.replace("-", "_").replace(" ", "_")
|
| 88 |
+
name = ALIASES.get(name, name)
|
| 89 |
+
if name not in MOVES:
|
| 90 |
+
notes.append("skipped unknown move " + repr(name))
|
| 91 |
+
continue
|
| 92 |
+
if name in ONE_SHOT:
|
| 93 |
+
secs = 0.0
|
| 94 |
+
else:
|
| 95 |
+
try:
|
| 96 |
+
secs = float(item.get("seconds", item.get("duration", 1.5)))
|
| 97 |
+
except (TypeError, ValueError):
|
| 98 |
+
secs = 1.5
|
| 99 |
+
secs = float(np.clip(secs, 0.2, 8.0))
|
| 100 |
+
if total + max(secs, 3.0) > MAX_SECONDS:
|
| 101 |
+
notes.append("plan truncated at " + str(MAX_SECONDS) + "s")
|
| 102 |
+
break
|
| 103 |
+
total += secs if secs else 3.0
|
| 104 |
+
steps.append({"move": name, "seconds": secs})
|
| 105 |
+
if not steps:
|
| 106 |
+
notes.append("no runnable moves found")
|
| 107 |
+
return steps, notes
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def run(plan_steps, seed=0, width=640, height=360, fps=25, on_frame=None):
|
| 111 |
+
"""Roll the plan out through the real policies.
|
| 112 |
+
|
| 113 |
+
Returns (frames, telemetry). `telemetry` carries the ground track, the
|
| 114 |
+
upright measure and a per-step log.
|
| 115 |
+
"""
|
| 116 |
+
sim = D.Microduck(width=width, height=height, render=on_frame is None)
|
| 117 |
+
rng = np.random.default_rng(seed)
|
| 118 |
+
every = max(1, int(round((1.0 / D.CTRL_DT) / fps))) # 50 Hz -> every 2nd
|
| 119 |
+
|
| 120 |
+
frames, track, upright, modes = [], [], [], []
|
| 121 |
+
log = []
|
| 122 |
+
tick = 0
|
| 123 |
+
|
| 124 |
+
def settle(min_steps=25, max_steps=400):
|
| 125 |
+
"""Hand back to the walk policy, and if the move ended on the floor let
|
| 126 |
+
the stand policy finish standing up before the next move starts."""
|
| 127 |
+
for n in range(max_steps):
|
| 128 |
+
do_step("walk", (0.0, 0.0, 0.0))
|
| 129 |
+
if n >= min_steps and sim.recovery is None:
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
def do_step(mode, cmd, phase=None):
|
| 133 |
+
nonlocal tick
|
| 134 |
+
sim.control_step(mode, cmd, phase)
|
| 135 |
+
if tick % every == 0:
|
| 136 |
+
img = sim.frame() if on_frame is None else on_frame(sim)
|
| 137 |
+
if img is not None:
|
| 138 |
+
frames.append(img)
|
| 139 |
+
track.append((float(sim.data.qpos[0]), float(sim.data.qpos[1])))
|
| 140 |
+
upright.append(float(sim.proj_gravity()[2]))
|
| 141 |
+
modes.append("recover" if sim.recovery else mode)
|
| 142 |
+
tick += 1
|
| 143 |
+
|
| 144 |
+
for step in plan_steps:
|
| 145 |
+
name = step["move"]
|
| 146 |
+
mode, cmd = MOVES[name]
|
| 147 |
+
t0 = tick * D.CTRL_DT
|
| 148 |
+
x0, y0 = float(sim.data.qpos[0]), float(sim.data.qpos[1])
|
| 149 |
+
|
| 150 |
+
if name in ("kick_left", "kick_right"):
|
| 151 |
+
if not sim.ball_active:
|
| 152 |
+
sim.spawn_ball(rng)
|
| 153 |
+
for _ in range(D.KICK_STEPS):
|
| 154 |
+
do_step(mode, cmd)
|
| 155 |
+
sim.post_kick_lock = D.POST_KICK_LOCK_STEPS
|
| 156 |
+
for _ in range(D.POST_KICK_LOCK_STEPS):
|
| 157 |
+
do_step("walk", (0.0, 0.0, 0.0))
|
| 158 |
+
|
| 159 |
+
elif name == "roll":
|
| 160 |
+
tipped, n = False, 0
|
| 161 |
+
while n < 150:
|
| 162 |
+
do_step(mode, cmd)
|
| 163 |
+
n += 1
|
| 164 |
+
gz = float(sim.proj_gravity()[2])
|
| 165 |
+
if gz > -0.3:
|
| 166 |
+
tipped = True
|
| 167 |
+
if tipped and gz < -0.85 and n >= 40:
|
| 168 |
+
break
|
| 169 |
+
sim.last_action[:] = 0
|
| 170 |
+
settle()
|
| 171 |
+
|
| 172 |
+
elif name == "pick":
|
| 173 |
+
phase, n = 0.0, 0
|
| 174 |
+
while phase < D.GROUND_PICK_END_PHASE and n < 300:
|
| 175 |
+
do_step(mode, cmd, phase)
|
| 176 |
+
phase += D.CTRL_DT / D.GROUND_PICK_PERIOD_S
|
| 177 |
+
n += 1
|
| 178 |
+
settle()
|
| 179 |
+
|
| 180 |
+
else:
|
| 181 |
+
for _ in range(int(round(step["seconds"] / D.CTRL_DT))):
|
| 182 |
+
do_step(mode, cmd)
|
| 183 |
+
|
| 184 |
+
log.append({
|
| 185 |
+
"move": name,
|
| 186 |
+
"t_start": round(t0, 2),
|
| 187 |
+
"t_end": round(tick * D.CTRL_DT, 2),
|
| 188 |
+
"travelled_m": round(float(np.hypot(sim.data.qpos[0] - x0,
|
| 189 |
+
sim.data.qpos[1] - y0)), 3),
|
| 190 |
+
"recovered": bool(sim.recovery),
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
telemetry = {
|
| 194 |
+
"track": track,
|
| 195 |
+
"upright": upright,
|
| 196 |
+
"modes": modes,
|
| 197 |
+
"log": log,
|
| 198 |
+
"duration_s": round(tick * D.CTRL_DT, 2),
|
| 199 |
+
"fps": fps,
|
| 200 |
+
"falls": sum(1 for a, b in zip(["walk"] + modes, modes)
|
| 201 |
+
if b == "recover" and a != "recover"),
|
| 202 |
+
"distance_m": round(float(np.hypot(sim.data.qpos[0], sim.data.qpos[1])), 3),
|
| 203 |
+
"final_upright": round(float(sim.proj_gravity()[2]), 3),
|
| 204 |
+
}
|
| 205 |
+
return frames, telemetry
|
test_pipelines.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end test: every subject in workflow.json through the real
|
| 2 |
+
`WorkflowExecutor` — the same code path the canvas and the REST API use.
|
| 3 |
+
|
| 4 |
+
python apps/09_microduck_lab/test_pipelines.py
|
| 5 |
+
|
| 6 |
+
Outputs land in ./_test_output for eyeballing. The choreographer hits an HF
|
| 7 |
+
Inference Provider when a token is present and otherwise falls back to the
|
| 8 |
+
keyword planner, so this runs offline too (after the first asset download).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
import types
|
| 18 |
+
import warnings
|
| 19 |
+
|
| 20 |
+
warnings.filterwarnings("ignore")
|
| 21 |
+
logging.disable(logging.CRITICAL)
|
| 22 |
+
|
| 23 |
+
for _s in (sys.stdout, sys.stderr):
|
| 24 |
+
try:
|
| 25 |
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
| 26 |
+
except (AttributeError, ValueError):
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 30 |
+
sys.path.insert(0, HERE)
|
| 31 |
+
|
| 32 |
+
import gradio.workflow as W # noqa: E402
|
| 33 |
+
from gradio.helpers import special_args # noqa: E402
|
| 34 |
+
from gradio.workflow_api import ( # noqa: E402
|
| 35 |
+
WorkflowExecutor,
|
| 36 |
+
WorkflowGraph,
|
| 37 |
+
group_free_inputs,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
import nodes as N # noqa: E402
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
from huggingface_hub import get_token
|
| 44 |
+
_tok = get_token()
|
| 45 |
+
except Exception:
|
| 46 |
+
_tok = None
|
| 47 |
+
TOKEN = types.SimpleNamespace(token=_tok or os.environ.get("HF_TOKEN"))
|
| 48 |
+
|
| 49 |
+
OUTDIR = os.path.join(HERE, "_test_output")
|
| 50 |
+
os.makedirs(OUTDIR, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def call_fn(data, request=None, token=None):
|
| 54 |
+
"""Mirror of gradio's bound-function server fn (workflow.py `call_fn`)."""
|
| 55 |
+
name = data[0] if data else ""
|
| 56 |
+
fn = N.BIND.get(name)
|
| 57 |
+
if fn is None:
|
| 58 |
+
return json.dumps({"error": "No function '" + str(name) + "' bound"})
|
| 59 |
+
try:
|
| 60 |
+
args = json.loads(data[1] if len(data) > 1 else "[]")
|
| 61 |
+
if not isinstance(args, list):
|
| 62 |
+
args = [args]
|
| 63 |
+
args, *_ = special_args(fn, args, request, None, token=token)
|
| 64 |
+
result = fn(*args)
|
| 65 |
+
return json.dumps(list(result) if isinstance(result, (list, tuple)) else [result])
|
| 66 |
+
except Exception as e:
|
| 67 |
+
return json.dumps({"error": type(e).__name__ + ": " + str(e)})
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
CALLERS = {"fn": call_fn, "model": W.call_model,
|
| 71 |
+
"space": W.call_space, "dataset": W.fetch_dataset}
|
| 72 |
+
|
| 73 |
+
with open(os.path.join(HERE, "workflow.json"), encoding="utf-8") as f:
|
| 74 |
+
GRAPH = WorkflowGraph.from_json(f.read())
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def seed_for(subject_id):
|
| 78 |
+
node = GRAPH.node_by_id[subject_id]
|
| 79 |
+
inputs = {}
|
| 80 |
+
for free in group_free_inputs(GRAPH, [node]):
|
| 81 |
+
ref = free["node"]
|
| 82 |
+
inputs[ref["id"]] = (ref.get("data") or {}).get("out")
|
| 83 |
+
return inputs
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def save(subject_id, value):
|
| 87 |
+
"""Persist an output so it can be looked at; return a one-line summary."""
|
| 88 |
+
if isinstance(value, dict) and value.get("path"):
|
| 89 |
+
src = value["path"]
|
| 90 |
+
ext = os.path.splitext(src)[1] or ".bin"
|
| 91 |
+
dst = os.path.join(OUTDIR, subject_id + ext)
|
| 92 |
+
shutil.copyfile(src, dst)
|
| 93 |
+
kb = os.path.getsize(dst) // 1024
|
| 94 |
+
url = str(value.get("url") or "")
|
| 95 |
+
shape = "path+url" if url.startswith("data:") else "path only (CANVAS WILL BREAK)"
|
| 96 |
+
return ext[1:].upper() + ", " + str(kb) + " KB, " + shape + " -> " + os.path.basename(dst)
|
| 97 |
+
|
| 98 |
+
if isinstance(value, str) and os.path.isfile(value):
|
| 99 |
+
dst = os.path.join(OUTDIR, subject_id + os.path.splitext(value)[1])
|
| 100 |
+
shutil.copyfile(value, dst)
|
| 101 |
+
return "file -> " + os.path.basename(dst)
|
| 102 |
+
|
| 103 |
+
text = str(value)
|
| 104 |
+
with open(os.path.join(OUTDIR, subject_id + ".txt"), "w", encoding="utf-8") as f:
|
| 105 |
+
f.write(text)
|
| 106 |
+
first = text.strip().splitlines()[0][:70] if text.strip() else "(empty)"
|
| 107 |
+
return "text (" + str(len(text)) + " chars): " + first
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main():
|
| 111 |
+
executor = WorkflowExecutor(GRAPH, CALLERS)
|
| 112 |
+
targets = [s["id"] for s in GRAPH.subjects]
|
| 113 |
+
if len(sys.argv) > 1:
|
| 114 |
+
f = [a.lower() for a in sys.argv[1:]]
|
| 115 |
+
targets = [t for t in targets if any(a in t.lower() for a in f)]
|
| 116 |
+
|
| 117 |
+
print("\nRunning " + str(len(targets)) + " output(s) through WorkflowExecutor")
|
| 118 |
+
print("outputs -> " + os.path.relpath(OUTDIR, os.getcwd()) + "\n")
|
| 119 |
+
|
| 120 |
+
passed, failed = [], []
|
| 121 |
+
for sid in targets:
|
| 122 |
+
label = GRAPH.node_by_id[sid]["label"]
|
| 123 |
+
t0 = time.time()
|
| 124 |
+
try:
|
| 125 |
+
value = executor.run(sid, seed_for(sid), request=None, token=TOKEN)
|
| 126 |
+
if value is None or value == "":
|
| 127 |
+
raise AssertionError("output was empty")
|
| 128 |
+
print(" PASS %-14s %-16s %6.1fs %s"
|
| 129 |
+
% (sid, label, time.time() - t0, save(sid, value)))
|
| 130 |
+
passed.append(sid)
|
| 131 |
+
except Exception as e:
|
| 132 |
+
print(" FAIL %-14s %-16s %6.1fs %s: %s"
|
| 133 |
+
% (sid, label, time.time() - t0, type(e).__name__, e))
|
| 134 |
+
failed.append(sid)
|
| 135 |
+
|
| 136 |
+
print("\n" + str(len(passed)) + " passed, " + str(len(failed)) + " failed")
|
| 137 |
+
return 1 if failed else 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
raise SystemExit(main())
|
workflow.json
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "2",
|
| 3 |
+
"name": "Microduck Lab",
|
| 4 |
+
"references": [
|
| 5 |
+
{
|
| 6 |
+
"id": "ref_routine",
|
| 7 |
+
"role": "reference",
|
| 8 |
+
"label": "Routine",
|
| 9 |
+
"asset_type": "text",
|
| 10 |
+
"inputs": [
|
| 11 |
+
{
|
| 12 |
+
"id": "in",
|
| 13 |
+
"label": "Routine",
|
| 14 |
+
"type": "text"
|
| 15 |
+
}
|
| 16 |
+
],
|
| 17 |
+
"outputs": [
|
| 18 |
+
{
|
| 19 |
+
"id": "out",
|
| 20 |
+
"label": "Routine",
|
| 21 |
+
"type": "text"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"data": {
|
| 25 |
+
"out": "waddle forward, turn left, then kick the ball with your right foot and finish with a barrel roll"
|
| 26 |
+
},
|
| 27 |
+
"x": 40,
|
| 28 |
+
"y": 60,
|
| 29 |
+
"width": 250,
|
| 30 |
+
"height": 150
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "ref_seed",
|
| 34 |
+
"role": "reference",
|
| 35 |
+
"label": "Seed",
|
| 36 |
+
"asset_type": "number",
|
| 37 |
+
"inputs": [
|
| 38 |
+
{
|
| 39 |
+
"id": "in",
|
| 40 |
+
"label": "Seed",
|
| 41 |
+
"type": "number"
|
| 42 |
+
}
|
| 43 |
+
],
|
| 44 |
+
"outputs": [
|
| 45 |
+
{
|
| 46 |
+
"id": "out",
|
| 47 |
+
"label": "Seed",
|
| 48 |
+
"type": "number"
|
| 49 |
+
}
|
| 50 |
+
],
|
| 51 |
+
"data": {
|
| 52 |
+
"out": 0
|
| 53 |
+
},
|
| 54 |
+
"x": 40,
|
| 55 |
+
"y": 300,
|
| 56 |
+
"width": 250,
|
| 57 |
+
"height": 110
|
| 58 |
+
}
|
| 59 |
+
],
|
| 60 |
+
"operators": [
|
| 61 |
+
{
|
| 62 |
+
"id": "op_plan",
|
| 63 |
+
"role": "operator",
|
| 64 |
+
"kind": "fn",
|
| 65 |
+
"fn": "choreograph",
|
| 66 |
+
"label": "choreograph (LLM)",
|
| 67 |
+
"inputs": [
|
| 68 |
+
{
|
| 69 |
+
"id": "in_routine",
|
| 70 |
+
"label": "routine",
|
| 71 |
+
"type": "text",
|
| 72 |
+
"required": true
|
| 73 |
+
}
|
| 74 |
+
],
|
| 75 |
+
"outputs": [
|
| 76 |
+
{
|
| 77 |
+
"id": "out_0",
|
| 78 |
+
"label": "plan",
|
| 79 |
+
"type": "text",
|
| 80 |
+
"output_index": 0
|
| 81 |
+
}
|
| 82 |
+
],
|
| 83 |
+
"data": {},
|
| 84 |
+
"x": 350,
|
| 85 |
+
"y": 80,
|
| 86 |
+
"width": 250,
|
| 87 |
+
"height": 130
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"id": "op_run",
|
| 91 |
+
"role": "operator",
|
| 92 |
+
"kind": "fn",
|
| 93 |
+
"fn": "perform",
|
| 94 |
+
"label": "perform (MuJoCo + ONNX)",
|
| 95 |
+
"inputs": [
|
| 96 |
+
{
|
| 97 |
+
"id": "in_plan",
|
| 98 |
+
"label": "plan",
|
| 99 |
+
"type": "text",
|
| 100 |
+
"required": true
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"id": "in_seed",
|
| 104 |
+
"label": "seed",
|
| 105 |
+
"type": "number"
|
| 106 |
+
}
|
| 107 |
+
],
|
| 108 |
+
"outputs": [
|
| 109 |
+
{
|
| 110 |
+
"id": "out_0",
|
| 111 |
+
"label": "video",
|
| 112 |
+
"type": "video",
|
| 113 |
+
"output_index": 0
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "out_1",
|
| 117 |
+
"label": "chart",
|
| 118 |
+
"type": "image",
|
| 119 |
+
"output_index": 1
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"id": "out_2",
|
| 123 |
+
"label": "report",
|
| 124 |
+
"type": "text",
|
| 125 |
+
"output_index": 2
|
| 126 |
+
}
|
| 127 |
+
],
|
| 128 |
+
"data": {},
|
| 129 |
+
"x": 660,
|
| 130 |
+
"y": 150,
|
| 131 |
+
"width": 260,
|
| 132 |
+
"height": 180
|
| 133 |
+
}
|
| 134 |
+
],
|
| 135 |
+
"subjects": [
|
| 136 |
+
{
|
| 137 |
+
"id": "sub_video",
|
| 138 |
+
"role": "subject",
|
| 139 |
+
"label": "Routine video",
|
| 140 |
+
"asset_type": "video",
|
| 141 |
+
"inputs": [
|
| 142 |
+
{
|
| 143 |
+
"id": "in",
|
| 144 |
+
"label": "Video",
|
| 145 |
+
"type": "video"
|
| 146 |
+
}
|
| 147 |
+
],
|
| 148 |
+
"outputs": [],
|
| 149 |
+
"data": {},
|
| 150 |
+
"x": 990,
|
| 151 |
+
"y": 40,
|
| 152 |
+
"width": 280,
|
| 153 |
+
"height": 230
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"id": "sub_chart",
|
| 157 |
+
"role": "subject",
|
| 158 |
+
"label": "Telemetry",
|
| 159 |
+
"asset_type": "image",
|
| 160 |
+
"inputs": [
|
| 161 |
+
{
|
| 162 |
+
"id": "in",
|
| 163 |
+
"label": "Telemetry",
|
| 164 |
+
"type": "image"
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
"outputs": [],
|
| 168 |
+
"data": {},
|
| 169 |
+
"x": 990,
|
| 170 |
+
"y": 300,
|
| 171 |
+
"width": 280,
|
| 172 |
+
"height": 210
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"id": "sub_report",
|
| 176 |
+
"role": "subject",
|
| 177 |
+
"label": "Report",
|
| 178 |
+
"asset_type": "text",
|
| 179 |
+
"inputs": [
|
| 180 |
+
{
|
| 181 |
+
"id": "in",
|
| 182 |
+
"label": "Report",
|
| 183 |
+
"type": "text"
|
| 184 |
+
}
|
| 185 |
+
],
|
| 186 |
+
"outputs": [],
|
| 187 |
+
"data": {},
|
| 188 |
+
"x": 990,
|
| 189 |
+
"y": 540,
|
| 190 |
+
"width": 280,
|
| 191 |
+
"height": 180
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"id": "sub_plan",
|
| 195 |
+
"role": "subject",
|
| 196 |
+
"label": "Move plan",
|
| 197 |
+
"asset_type": "text",
|
| 198 |
+
"inputs": [
|
| 199 |
+
{
|
| 200 |
+
"id": "in",
|
| 201 |
+
"label": "Plan",
|
| 202 |
+
"type": "text"
|
| 203 |
+
}
|
| 204 |
+
],
|
| 205 |
+
"outputs": [],
|
| 206 |
+
"data": {},
|
| 207 |
+
"x": 350,
|
| 208 |
+
"y": 300,
|
| 209 |
+
"width": 250,
|
| 210 |
+
"height": 200
|
| 211 |
+
}
|
| 212 |
+
],
|
| 213 |
+
"edges": [
|
| 214 |
+
{
|
| 215 |
+
"id": "e1",
|
| 216 |
+
"from_node_id": "ref_routine",
|
| 217 |
+
"from_port_id": "out",
|
| 218 |
+
"to_node_id": "op_plan",
|
| 219 |
+
"to_port_id": "in_routine",
|
| 220 |
+
"type": "text"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"id": "e2",
|
| 224 |
+
"from_node_id": "op_plan",
|
| 225 |
+
"from_port_id": "out_0",
|
| 226 |
+
"to_node_id": "op_run",
|
| 227 |
+
"to_port_id": "in_plan",
|
| 228 |
+
"type": "text"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"id": "e3",
|
| 232 |
+
"from_node_id": "op_plan",
|
| 233 |
+
"from_port_id": "out_0",
|
| 234 |
+
"to_node_id": "sub_plan",
|
| 235 |
+
"to_port_id": "in",
|
| 236 |
+
"type": "text"
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"id": "e4",
|
| 240 |
+
"from_node_id": "ref_seed",
|
| 241 |
+
"from_port_id": "out",
|
| 242 |
+
"to_node_id": "op_run",
|
| 243 |
+
"to_port_id": "in_seed",
|
| 244 |
+
"type": "number"
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"id": "e5",
|
| 248 |
+
"from_node_id": "op_run",
|
| 249 |
+
"from_port_id": "out_0",
|
| 250 |
+
"to_node_id": "sub_video",
|
| 251 |
+
"to_port_id": "in",
|
| 252 |
+
"type": "video"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"id": "e6",
|
| 256 |
+
"from_node_id": "op_run",
|
| 257 |
+
"from_port_id": "out_1",
|
| 258 |
+
"to_node_id": "sub_chart",
|
| 259 |
+
"to_port_id": "in",
|
| 260 |
+
"type": "image"
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"id": "e7",
|
| 264 |
+
"from_node_id": "op_run",
|
| 265 |
+
"from_port_id": "out_2",
|
| 266 |
+
"to_node_id": "sub_report",
|
| 267 |
+
"to_port_id": "in",
|
| 268 |
+
"type": "text"
|
| 269 |
+
}
|
| 270 |
+
]
|
| 271 |
+
}
|