Skip to content

Commit 33a660c

Browse files
committed
Add OpenNeck diagnostic script
1 parent 3b22b0f commit 33a660c

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

scripts/dev/test_openneck.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env python3
2+
"""Exercise optional OpenNeck active-vision control."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import logging
8+
from pathlib import Path
9+
import sys
10+
import time
11+
12+
13+
REPO_ROOT = Path(__file__).resolve().parents[2]
14+
sys.path.insert(0, str(REPO_ROOT))
15+
16+
from teleopit.inputs.pico4_provider import Pico4InputProvider # noqa: E402
17+
from teleopit.sim2real.neck.config import NeckConfig # noqa: E402
18+
from teleopit.sim2real.neck.openneck import build_neck_device # noqa: E402
19+
from teleopit.sim2real.neck.worker import NeckRuntime # noqa: E402
20+
21+
22+
DEFAULT_RATE_HZ = 60.0
23+
DEFAULT_FRAME_TIMEOUT_S = 0.3
24+
DEFAULT_STEP_MAGNITUDE = 0.25
25+
DEFAULT_HOLD_S = 0.8
26+
DEFAULT_PICO_TIMEOUT_S = 60.0
27+
28+
29+
def parse_args() -> argparse.Namespace:
30+
parser = argparse.ArgumentParser(description="Test OpenNeck active-vision control")
31+
parser.add_argument(
32+
"--mode",
33+
choices=["direct", "pico"],
34+
default="direct",
35+
help=(
36+
"direct sends a conservative fixed motion pattern to OpenNeck; "
37+
"pico drives OpenNeck from live Pico head/body tracking through Teleopit's active-neck mapper."
38+
),
39+
)
40+
parser.add_argument("--port", default=None, help="Optional OpenNeck serial port, for example /dev/ttyACM0")
41+
parser.add_argument("--config", dest="config_path", default=None, help="Optional OpenNeck calibration config path")
42+
parser.add_argument("--dry-run", action="store_true", help="Compute/log commands without opening OpenNeck hardware")
43+
parser.add_argument("--rate-hz", type=float, default=DEFAULT_RATE_HZ)
44+
parser.add_argument("--frame-timeout-s", type=float, default=DEFAULT_FRAME_TIMEOUT_S)
45+
parser.add_argument(
46+
"--magnitude",
47+
type=float,
48+
default=DEFAULT_STEP_MAGNITUDE,
49+
help="Normalized direct-test command magnitude in [0, 1]. Keep this conservative.",
50+
)
51+
parser.add_argument("--hold-s", type=float, default=DEFAULT_HOLD_S, help="Seconds to hold each direct-test command")
52+
parser.add_argument("--duration-s", type=float, default=0.0, help="Pico mode duration; 0 means until Ctrl-C")
53+
parser.add_argument("--no-center-on-start", action="store_true")
54+
parser.add_argument("--no-center-on-shutdown", action="store_true")
55+
parser.add_argument("--release-on-shutdown", action="store_true")
56+
parser.add_argument("--use-body-reference", action=argparse.BooleanOptionalAction, default=True)
57+
parser.add_argument("--invert-yaw", action=argparse.BooleanOptionalAction, default=True)
58+
parser.add_argument("--invert-pitch", action=argparse.BooleanOptionalAction, default=True)
59+
parser.add_argument("--dead-zone-deg", type=float, default=0.5)
60+
parser.add_argument("--smoothing-alpha", type=float, default=0.35)
61+
parser.add_argument("--yaw-range-deg", type=float, default=90.0)
62+
parser.add_argument("--pitch-range-deg", type=float, default=60.0)
63+
parser.add_argument("--head-joint", default="Head")
64+
parser.add_argument("--body-reference-joint", default="Spine3")
65+
parser.add_argument("--bridge-host", default="0.0.0.0")
66+
parser.add_argument("--bridge-port", type=int, default=63901)
67+
parser.add_argument("--bridge-discovery", action=argparse.BooleanOptionalAction, default=True)
68+
parser.add_argument("--bridge-advertise-ip", default=None)
69+
args = parser.parse_args()
70+
if args.rate_hz <= 0:
71+
raise SystemExit("--rate-hz must be > 0")
72+
if args.frame_timeout_s <= 0:
73+
raise SystemExit("--frame-timeout-s must be > 0")
74+
if args.hold_s <= 0:
75+
raise SystemExit("--hold-s must be > 0")
76+
if args.duration_s < 0:
77+
raise SystemExit("--duration-s must be >= 0")
78+
if not 0.0 <= args.magnitude <= 1.0:
79+
raise SystemExit("--magnitude must be in [0, 1]")
80+
return args
81+
82+
83+
def make_neck_config(args: argparse.Namespace) -> NeckConfig:
84+
return NeckConfig(
85+
enabled=True,
86+
driver="openneck",
87+
config_path=args.config_path,
88+
port=args.port,
89+
rate_hz=args.rate_hz,
90+
frame_timeout_s=args.frame_timeout_s,
91+
active_modes=("mocap",),
92+
head_joint=args.head_joint,
93+
body_reference_joint=args.body_reference_joint,
94+
use_body_reference=bool(args.use_body_reference),
95+
dead_zone_deg=args.dead_zone_deg,
96+
smoothing_alpha=args.smoothing_alpha,
97+
yaw_range_deg=args.yaw_range_deg,
98+
pitch_range_deg=args.pitch_range_deg,
99+
invert_yaw=bool(args.invert_yaw),
100+
invert_pitch=bool(args.invert_pitch),
101+
center_on_start=not bool(args.no_center_on_start),
102+
center_on_shutdown=not bool(args.no_center_on_shutdown),
103+
release_on_shutdown=bool(args.release_on_shutdown),
104+
dry_run=bool(args.dry_run),
105+
)
106+
107+
108+
def make_pico_provider(args: argparse.Namespace) -> Pico4InputProvider:
109+
return Pico4InputProvider(
110+
timeout=DEFAULT_PICO_TIMEOUT_S,
111+
pause_button=None,
112+
arms_button=None,
113+
bridge_host=args.bridge_host,
114+
bridge_port=args.bridge_port,
115+
bridge_discovery=bool(args.bridge_discovery),
116+
bridge_advertise_ip=args.bridge_advertise_ip,
117+
bridge_video=None,
118+
bridge_video_enabled=False,
119+
)
120+
121+
122+
def run_direct(args: argparse.Namespace) -> None:
123+
cfg = make_neck_config(args)
124+
device = build_neck_device(cfg)
125+
magnitude = float(args.magnitude)
126+
pattern = [
127+
("center", 0.0, 0.0),
128+
("yaw right", magnitude, 0.0),
129+
("center", 0.0, 0.0),
130+
("yaw left", -magnitude, 0.0),
131+
("center", 0.0, 0.0),
132+
("pitch up", 0.0, magnitude),
133+
("center", 0.0, 0.0),
134+
("pitch down", 0.0, -magnitude),
135+
("center", 0.0, 0.0),
136+
]
137+
138+
print(
139+
f"Testing OpenNeck direct pattern | port={args.port} dry_run={args.dry_run} "
140+
f"magnitude={magnitude:.2f}",
141+
flush=True,
142+
)
143+
try:
144+
device.connect()
145+
if cfg.center_on_start:
146+
device.center()
147+
for label, yaw, pitch in pattern:
148+
print(f"{label}: yaw={yaw:.3f} pitch={pitch:.3f}", flush=True)
149+
device.move_norm(yaw, pitch)
150+
time.sleep(float(args.hold_s))
151+
except KeyboardInterrupt:
152+
print("Interrupted; shutting down OpenNeck", flush=True)
153+
finally:
154+
try:
155+
if cfg.center_on_shutdown:
156+
device.center()
157+
if cfg.release_on_shutdown:
158+
device.release()
159+
finally:
160+
device.close()
161+
162+
163+
def run_pico(args: argparse.Namespace) -> None:
164+
cfg = make_neck_config(args)
165+
provider = make_pico_provider(args)
166+
runtime = NeckRuntime(cfg)
167+
sleep_s = 1.0 / max(float(args.rate_hz), 1.0)
168+
deadline = time.monotonic() + float(args.duration_s) if args.duration_s > 0.0 else None
169+
last_seq = -1
170+
command_count = 0
171+
172+
print(
173+
"Testing OpenNeck active vision from live Pico body tracking. "
174+
"Hold your head neutral for the first valid body frame; press Ctrl-C to stop.",
175+
flush=True,
176+
)
177+
try:
178+
runtime.start()
179+
while deadline is None or time.monotonic() < deadline:
180+
now_s = time.monotonic()
181+
if provider.has_frame():
182+
frame, timestamp_s, seq = provider.get_frame_packet()
183+
if int(seq) != last_seq:
184+
moved = runtime.tick(
185+
frame=frame,
186+
frame_timestamp_s=timestamp_s,
187+
active=True,
188+
now_s=now_s,
189+
)
190+
if moved:
191+
command_count += 1
192+
last_seq = int(seq)
193+
age_ms = max((now_s - float(timestamp_s)) * 1000.0, 0.0)
194+
print(
195+
f"pico seq={seq} age={age_ms:.1f}ms moved={moved} commands={command_count}",
196+
flush=True,
197+
)
198+
else:
199+
runtime.tick(frame=None, frame_timestamp_s=None, active=True, now_s=now_s)
200+
time.sleep(sleep_s)
201+
except KeyboardInterrupt:
202+
print("Interrupted; shutting down OpenNeck", flush=True)
203+
finally:
204+
runtime.close()
205+
provider.close()
206+
207+
208+
def main() -> None:
209+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
210+
args = parse_args()
211+
if args.mode == "direct":
212+
run_direct(args)
213+
elif args.mode == "pico":
214+
run_pico(args)
215+
else:
216+
raise AssertionError(f"Unhandled mode: {args.mode}")
217+
218+
219+
if __name__ == "__main__":
220+
main()

0 commit comments

Comments
 (0)