Skip to content

Commit fbf2d40

Browse files
authored
Merge pull request #238 from KraHsu/dev/wuji-cli-entry
feat(wuji): unified `wuji` console entry for deploy toolchain
2 parents e56fdb0 + 9f1fac0 commit fbf2d40

9 files changed

Lines changed: 184 additions & 30 deletions

File tree

‎examples/wuji/pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ deploy-vision = ["opencv-contrib-python", "pupil-apriltags", "pyyaml"]
1616
# of `deploy` so the headless core stays binary-free. Pinned to match wuji-mjlab.
1717
deploy-hand = ["wujihandpy==1.5.1"]
1818

19+
[project.scripts]
20+
# Unified deploy/real2sim entry: `uv run wuji <command>` (see deploy/README.md).
21+
wuji = "genelab_wuji.cli:main"
22+
1923
[project.entry-points."genelab.extensions"]
2024
genelab_wuji = "genelab_wuji.tasks:register"
2125

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Unified ``wuji`` command-line entry for the deploy + real2sim toolchain.
2+
3+
Installed as a console script by the ``examples/wuji[deploy]`` extra, so the
4+
whole pipeline runs through one verb instead of long ``python -m`` module paths::
5+
6+
uv run wuji <command> [options]
7+
uv run wuji play --ckpt policy.onnx --real --goal-mode random
8+
uv run wuji <command> --help # per-command options
9+
10+
Each command lazily imports its target module so that, e.g., ``wuji play`` does
11+
not pull in the Hikvision MVS SDK that ``wuji observer`` needs (and vice-versa) —
12+
the observer module raises at import time when the SDK is absent.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import importlib
18+
import sys
19+
20+
# command -> (module, prefix-args prepended before user args)
21+
# prefix-args let two verbs share one module (hand_utils' check / home subparser).
22+
_COMMANDS: dict[str, tuple[str, tuple[str, ...]]] = {
23+
"check": ("genelab_wuji.deploy.scripts.hand_utils", ("check",)),
24+
"home": ("genelab_wuji.deploy.scripts.hand_utils", ("home",)),
25+
"observer": ("genelab_wuji.deploy.scripts.cube_world_observer", ()),
26+
"viewer": ("genelab_wuji.deploy.scripts.toreal_viewer", ()),
27+
"calib": ("genelab_wuji.deploy.scripts.calib_check", ()),
28+
"play": ("genelab_wuji.deploy.scripts.play_real", ()),
29+
}
30+
31+
_HELP: dict[str, str] = {
32+
"check": "read-only hand-bridge test (connection + encoder sanity)",
33+
"home": "ease-in-out ramp the hand to the grasp pose",
34+
"observer": "Hikvision camera -> ArUco cube pose, published on ZMQ",
35+
"viewer": "real2sim Genesis mirror of the observed cube",
36+
"calib": "calibration viewer: live hand + observed cube vs. digital twin",
37+
"play": "deploy control loop (real/mock) + goal modes + success monitor",
38+
}
39+
40+
41+
def _usage() -> str:
42+
width = max(len(c) for c in _COMMANDS)
43+
lines = ["usage: wuji <command> [options]", "", "commands:"]
44+
lines += [f" {name:<{width}} {_HELP[name]}" for name in _COMMANDS]
45+
lines += ["", "run `wuji <command> --help` for per-command options."]
46+
return "\n".join(lines)
47+
48+
49+
def main(argv: list[str] | None = None) -> int:
50+
argv = list(sys.argv[1:] if argv is None else argv)
51+
if not argv:
52+
print(_usage(), file=sys.stderr)
53+
return 2
54+
cmd, rest = argv[0], argv[1:]
55+
if cmd in ("-h", "--help", "help"):
56+
print(_usage())
57+
return 0
58+
if cmd not in _COMMANDS:
59+
print(f"wuji: unknown command {cmd!r}\n", file=sys.stderr)
60+
print(_usage(), file=sys.stderr)
61+
return 2
62+
module_name, prefix = _COMMANDS[cmd]
63+
module = importlib.import_module(module_name)
64+
return int(module.main([*prefix, *rest]) or 0)
65+
66+
67+
if __name__ == "__main__":
68+
raise SystemExit(main())

‎examples/wuji/src/genelab_wuji/deploy/README.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,29 +70,32 @@ export LD_LIBRARY_PATH=/opt/MVS/lib/64:/opt/MVS/lib/32:$LD_LIBRARY_PATH
7070

7171
## Run
7272

73+
All deploy steps run through the unified `wuji` entry (installed by the `deploy`
74+
extra); `wuji <command> --help` lists each command's options.
75+
7376
```bash
74-
# 0) export a trained policy to ONNX
75-
genelab export Genelab-Reorient-Wuji-Hand-v0 PATH/model.pt --format onnx --out policy.onnx
77+
# 0) export a trained policy to ONNX (genelab's own CLI)
78+
uv run genelab export Genelab-Reorient-Wuji-Hand-v0 PATH/model.pt --format onnx --out policy.onnx
7679

7780
# 1) smoke-test the control loop, no hardware, no ZMQ, no viewer
78-
python -m genelab_wuji.deploy.scripts.play_real --ckpt policy.onnx --mock --no-zmq --no-viewer --steps 100
81+
uv run wuji play --ckpt policy.onnx --mock --no-zmq --no-viewer --steps 100
7982

8083
# 1.5) bring up the real hand bridge (needs wujihandpy): check first, then home
81-
python -m genelab_wuji.deploy.scripts.hand_utils check # READ-ONLY: connection + encoder sanity
82-
python -m genelab_wuji.deploy.scripts.hand_utils home # 3s ease-in-out ramp to the grasp pose
84+
uv run wuji check # READ-ONLY: connection + encoder sanity
85+
uv run wuji home # 3s ease-in-out ramp to the grasp pose
8386

8487
# 2) vision: detect the cube and publish its tag-frame pose on ZMQ:5555 (needs MVS env)
85-
python -m genelab_wuji.deploy.scripts.cube_world_observer --preview # terminal A
86-
python -m genelab_wuji.deploy.scripts.toreal_viewer # terminal B (real2sim mirror)
88+
uv run wuji observer --preview # terminal A
89+
uv run wuji viewer # terminal B (real2sim mirror)
8790

8891
# 2.5) calibration check: home the hand, render live hand + observed cube in the twin
89-
python -m genelab_wuji.deploy.scripts.calib_check # (needs the observer running)
92+
uv run wuji calib # (needs the observer running)
9093

9194
# 3) drive the real hand from the live observer feed (Genesis mirror viewer on by default,
9295
# showing the live hand + observed cube + goal; pass --no-viewer for headless).
9396
# goal modes: --goal-mode random (uniform-SO3, resampled on success) |
9497
# fixed --goal-quat w,x,y,z | external (goal from toreal_viewer ZMQ)
95-
python -m genelab_wuji.deploy.scripts.play_real --ckpt policy.onnx --real --goal-mode random
98+
uv run wuji play --ckpt policy.onnx --real --goal-mode random
9699
```
97100

98101
`play_real` mirrors the live hand (encoders) + observed cube + goal in a Genesis viewer

‎examples/wuji/src/genelab_wuji/deploy/scripts/calib_check.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
control beyond the initial homing ramp — the hand stays at home.
1616
1717
Run:
18-
python -m genelab_wuji.deploy.scripts.cube_world_observer & # publisher
19-
python -m genelab_wuji.deploy.scripts.calib_check # this tool
20-
python -m genelab_wuji.deploy.scripts.calib_check --mock # no hardware
18+
uv run wuji observer & # publisher
19+
uv run wuji calib # this tool
20+
uv run wuji calib --mock # no hardware
2121
2222
Press Ctrl+C or close the viewer window to exit. Needs a GPU + display (Genesis
2323
viewer); the transform math itself is covered by the headless deploy tests.
@@ -61,7 +61,7 @@ def _loop(env: Any, drv: HandDriverBase, cube_recv: CubeReceiver | None, rate_hz
6161
print("\n[calib-check] interrupted by user.")
6262

6363

64-
def main() -> int:
64+
def main(argv: list[str] | None = None) -> int:
6565
parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
6666
parser.add_argument("--cube-port", type=int, default=DEFAULT_CUBE_PORT)
6767
parser.add_argument("--host", default="localhost")
@@ -77,7 +77,7 @@ def main() -> int:
7777
parser.add_argument(
7878
"--mock", action="store_true", help="use the mock hand (no hardware; renders home pose)"
7979
)
80-
args = parser.parse_args()
80+
args = parser.parse_args(argv)
8181

8282
cube_recv: CubeReceiver | None = None
8383
if not args.no_cube_zmq:

‎examples/wuji/src/genelab_wuji/deploy/scripts/cube_world_observer.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
- ZMQ publishing on port 5555
1717
1818
Usage:
19-
python -m genelab_wuji.deploy.scripts.cube_world_observer --preview # With visualization
20-
python -m genelab_wuji.deploy.scripts.cube_world_observer # Headless mode
19+
uv run wuji observer --preview # With visualization
20+
uv run wuji observer # Headless mode
2121
2222
On startup, the world coordinate system is auto-sampled (100 frames by default),
2323
then a fixed world frame is used. Press 'w' to resample the world frame.
@@ -1442,7 +1442,7 @@ def cleanup(self):
14421442
print("Cleanup done.")
14431443

14441444

1445-
def main():
1445+
def main(argv=None):
14461446
import argparse
14471447

14481448
# Load config from file first
@@ -1474,7 +1474,7 @@ def main():
14741474
"resolving to config/cube_tags<suffix>.json, or a literal "
14751475
"path. Default: config/cube_tags.json (54mm).",
14761476
)
1477-
args = parser.parse_args()
1477+
args = parser.parse_args(argv)
14781478
cube_config_path = resolve_cube_config_path(args.cube)
14791479

14801480
# Use config values, CLI args override

‎examples/wuji/src/genelab_wuji/deploy/scripts/hand_utils.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
and reports tracking error.
1212
1313
Usage:
14-
python -m genelab_wuji.deploy.scripts.hand_utils check
15-
python -m genelab_wuji.deploy.scripts.hand_utils home
14+
uv run wuji check
15+
uv run wuji home
1616
1717
The home pose is ``REORIENT_JOINT_POS`` (via ``config.default_joint_pos``) — the
1818
same grasp keyframe the policy and ``MockHandDriver`` start from, so after ``home``
@@ -146,7 +146,7 @@ def cmd_check(_args: argparse.Namespace) -> int:
146146

147147
print("\n" + "=" * 60)
148148
print("✓ All read operations succeeded. Hand bridge healthy.")
149-
print("✓ Next: ramp to home with `python -m genelab_wuji.deploy.scripts.hand_utils home`")
149+
print("✓ Next: ramp to home with `uv run wuji home`")
150150
print("=" * 60)
151151
return 0
152152

‎examples/wuji/src/genelab_wuji/deploy/scripts/play_real.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
1919
Usage:
2020
# Smoke run without hardware (mock hand, random goals):
21-
python -m genelab_wuji.deploy.scripts.play_real --ckpt policy.onnx --goal-mode random --steps 200
21+
uv run wuji play --ckpt policy.onnx --goal-mode random --steps 200
2222
2323
# Real hand, random goals resampled on success:
24-
python -m genelab_wuji.deploy.scripts.play_real --ckpt policy.onnx --real --goal-mode random
24+
uv run wuji play --ckpt policy.onnx --real --goal-mode random
2525
2626
# Real hand, goal driven by toreal_viewer over ZMQ:
27-
python -m genelab_wuji.deploy.scripts.play_real --ckpt policy.onnx --real --goal-mode external
27+
uv run wuji play --ckpt policy.onnx --real --goal-mode external
2828
"""
2929

3030
from __future__ import annotations
@@ -164,7 +164,7 @@ def close(self) -> None:
164164
self._env.close()
165165

166166

167-
def main() -> int:
167+
def main(argv: list[str] | None = None) -> int:
168168
parser = argparse.ArgumentParser(description=__doc__)
169169
parser.add_argument("--ckpt", required=True, help="exported policy.onnx")
170170
parser.add_argument("--metadata", default=None, help="policy metadata.json (auto-detected)")
@@ -192,7 +192,7 @@ def main() -> int:
192192
help="mirror the live hand + cube + goal in a Genesis viewer "
193193
"(default on; pass --no-viewer for headless / mock smoke runs)",
194194
)
195-
args = parser.parse_args()
195+
args = parser.parse_args(argv)
196196

197197
if args.control_dt <= 0:
198198
raise SystemExit("--control-dt must be > 0 (used for joint velocity + success timing)")

‎examples/wuji/src/genelab_wuji/deploy/scripts/toreal_viewer.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
99
Usage:
1010
# Terminal 1: real camera -> ZMQ (needs hardware; see cube_world_observer.py)
11-
python -m genelab_wuji.deploy.scripts.cube_world_observer
11+
uv run wuji observer
1212
1313
# Terminal 2: mirror the cube in the Genesis sim
14-
python -m genelab_wuji.deploy.scripts.toreal_viewer
14+
uv run wuji viewer
1515
1616
Run on a host with a GPU + display (Genesis viewer). The transform math itself is
1717
covered by the headless deploy tests.
@@ -27,12 +27,12 @@
2727
from genelab_wuji.deploy.zmq_bridge import DEFAULT_CUBE_PORT, CubeReceiver
2828

2929

30-
def main() -> int:
30+
def main(argv: list[str] | None = None) -> int:
3131
parser = argparse.ArgumentParser(description=__doc__)
3232
parser.add_argument("--cube-port", type=int, default=DEFAULT_CUBE_PORT)
3333
parser.add_argument("--host", default="localhost")
3434
parser.add_argument("--fps", type=float, default=60.0, help="viewer refresh rate")
35-
args = parser.parse_args()
35+
args = parser.parse_args(argv)
3636

3737
env = build_reorient_env()
3838
tag_pos_w, tag_quat_w = tag_world_pose(env) # fixed-base hand -> constant

‎tests/test_examples_wuji_cli.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Unit tests for the unified ``wuji`` console-script dispatcher.
2+
3+
Covers verb routing (including the shared hand_utils check/home prefix), the
4+
help/usage/unknown-command exit codes, and that each command lazily imports its
5+
target module (so e.g. `wuji play` does not require the camera SDK).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sys
11+
import types
12+
13+
import pytest
14+
15+
from genelab_wuji import cli
16+
17+
18+
def test_commands_map_to_real_modules():
19+
# Every advertised verb maps to a deploy.scripts module + a help line.
20+
for name, (module_name, prefix) in cli._COMMANDS.items():
21+
assert module_name.startswith("genelab_wuji.deploy.scripts.")
22+
assert isinstance(prefix, tuple)
23+
assert name in cli._HELP
24+
25+
26+
@pytest.mark.parametrize(
27+
"argv,code",
28+
[
29+
(["--help"], 0),
30+
(["help"], 0),
31+
([], 2),
32+
(["bogus"], 2),
33+
],
34+
)
35+
def test_top_level_exit_codes(argv, code):
36+
assert cli.main(argv) == code
37+
38+
39+
def test_usage_lists_every_command(capsys):
40+
cli.main(["--help"])
41+
out = capsys.readouterr().out
42+
for name in cli._COMMANDS:
43+
assert name in out
44+
45+
46+
def _install_stub(monkeypatch, target_module: str):
47+
"""Replace ``importlib.import_module`` so the dispatched module is a stub
48+
that records the argv it was handed instead of importing real hardware code."""
49+
seen: dict[str, list[str]] = {}
50+
stub = types.ModuleType(target_module)
51+
52+
def _main(argv):
53+
seen["argv"] = list(argv)
54+
return 0
55+
56+
stub.main = _main # type: ignore[attr-defined]
57+
monkeypatch.setattr(cli.importlib, "import_module", lambda name: stub)
58+
return seen
59+
60+
61+
def test_play_routes_args_through(monkeypatch):
62+
seen = _install_stub(monkeypatch, "genelab_wuji.deploy.scripts.play_real")
63+
rc = cli.main(["play", "--ckpt", "p.onnx", "--mock"])
64+
assert rc == 0
65+
assert seen["argv"] == ["--ckpt", "p.onnx", "--mock"]
66+
67+
68+
def test_check_prepends_subcommand(monkeypatch):
69+
# `wuji check` / `wuji home` share hand_utils.main via a prepended verb.
70+
seen = _install_stub(monkeypatch, "genelab_wuji.deploy.scripts.hand_utils")
71+
cli.main(["check", "--foo"])
72+
assert seen["argv"] == ["check", "--foo"]
73+
cli.main(["home"])
74+
assert seen["argv"] == ["home"]
75+
76+
77+
def test_main_reads_sys_argv_when_argv_none(monkeypatch):
78+
monkeypatch.setattr(sys, "argv", ["wuji", "--help"])
79+
assert cli.main() == 0

0 commit comments

Comments
 (0)