ysharma HF Staff commited on
Commit
9986d4e
·
verified ·
1 Parent(s): 2a28fe1

Microduck Lab: run the real shipped RL policies from a gr.Workflow

Browse files
Files changed (10) hide show
  1. README.md +95 -6
  2. app.py +27 -0
  3. build_workflow.py +153 -0
  4. duck.py +344 -0
  5. nodes.py +236 -0
  6. packages.txt +3 -0
  7. requirements.txt +7 -0
  8. routine.py +205 -0
  9. test_pipelines.py +141 -0
  10. workflow.json +271 -0
README.md CHANGED
@@ -1,13 +1,102 @@
1
  ---
2
- title: Gr Workflow Microduck Lab
3
- emoji: 🦀
4
- colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }