Skip to content

Commit 991c49a

Browse files
committed
Add Pico signal diagnostics
1 parent 9aa533a commit 991c49a

4 files changed

Lines changed: 439 additions & 19 deletions

File tree

scripts/run/check_pico_signal.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Pico mocap/video signal diagnostic entry point."""
2+
3+
from __future__ import annotations
4+
5+
from collections import Counter
6+
import logging
7+
import time
8+
from typing import Any
9+
10+
import hydra
11+
import numpy as np
12+
from omegaconf import DictConfig
13+
14+
from teleopit.inputs.human_frame_validation import HumanFrameValidationResult, validate_human_frame
15+
from teleopit.inputs.pico4_provider import Pico4InputProvider
16+
from teleopit.inputs.pico_video import PicoVideoRuntime, bridge_video_source, parse_pico_video_config
17+
from teleopit.runtime.common import cfg_get
18+
19+
20+
logger = logging.getLogger("teleopit.tools.check_pico_signal")
21+
22+
23+
def _fmt_vec(values: tuple[float, ...] | None) -> str:
24+
if values is None:
25+
return "None"
26+
return "[" + ", ".join(f"{value:.4f}" for value in values) + "]"
27+
28+
29+
def _frame_stats(frame: dict[str, Any]) -> dict[str, Any]:
30+
positions = []
31+
quat_norms = []
32+
pelvis_pos = None
33+
for name, value in frame.items():
34+
try:
35+
pos, quat = value
36+
except Exception:
37+
continue
38+
try:
39+
pos_arr = np.asarray(pos, dtype=np.float64).reshape(-1)
40+
quat_arr = np.asarray(quat, dtype=np.float64).reshape(-1)
41+
except Exception:
42+
continue
43+
if pos_arr.shape[0] >= 3 and np.all(np.isfinite(pos_arr[:3])):
44+
positions.append(pos_arr[:3])
45+
if str(name) == "Pelvis":
46+
pelvis_pos = pos_arr[:3].copy()
47+
if quat_arr.size > 0 and np.all(np.isfinite(quat_arr)):
48+
quat_norms.append(float(np.linalg.norm(quat_arr)))
49+
50+
if not positions:
51+
return {}
52+
53+
pos = np.asarray(positions, dtype=np.float64)
54+
return {
55+
"pelvis_pos": pelvis_pos,
56+
"min_pos": np.min(pos, axis=0),
57+
"max_pos": np.max(pos, axis=0),
58+
"extent": np.ptp(pos, axis=0),
59+
"max_abs_pos": float(np.max(np.abs(pos))),
60+
"quat_norm_min": min(quat_norms) if quat_norms else None,
61+
"quat_norm_max": max(quat_norms) if quat_norms else None,
62+
}
63+
64+
65+
def _log_invalid(seq: int, age_ms: float, result: HumanFrameValidationResult) -> None:
66+
logger.warning(
67+
"Invalid Pico body frame | seq=%s age_ms=%.1f reason=%s joint=%s "
68+
"max_abs_pos=%s threshold=%s pos=%s quat=%s detail=%s",
69+
seq,
70+
age_ms,
71+
result.reason,
72+
result.joint_name,
73+
f"{result.max_abs_pos:.4f}" if result.max_abs_pos is not None else "None",
74+
f"{result.max_pos_value:.4f}" if result.max_pos_value is not None else "None",
75+
_fmt_vec(result.pos),
76+
_fmt_vec(result.quat),
77+
result.detail,
78+
)
79+
80+
81+
def _log_summary(
82+
*,
83+
window_s: float,
84+
total: int,
85+
valid: int,
86+
invalid_reasons: Counter[str],
87+
provider_fps: float,
88+
last_seq: int | None,
89+
last_age_ms: float | None,
90+
last_stats: dict[str, Any],
91+
pushed_video_frames: int,
92+
) -> None:
93+
if total <= 0:
94+
logger.info(
95+
"Pico signal summary | window=%.1fs samples=0 provider_fps=%.1f "
96+
"last_seq=%s video_frames=%d",
97+
window_s,
98+
provider_fps,
99+
last_seq,
100+
pushed_video_frames,
101+
)
102+
return
103+
104+
invalid = total - valid
105+
reason_text = ",".join(f"{reason}:{count}" for reason, count in invalid_reasons.most_common()) or "none"
106+
pelvis = last_stats.get("pelvis_pos")
107+
extent = last_stats.get("extent")
108+
min_pos = last_stats.get("min_pos")
109+
max_pos = last_stats.get("max_pos")
110+
logger.info(
111+
"Pico signal summary | window=%.1fs samples=%d valid=%d invalid=%d reasons=%s "
112+
"provider_fps=%.1f last_seq=%s last_age_ms=%s video_frames=%d "
113+
"max_abs_pos=%s pelvis=%s extent=%s min=%s max=%s quat_norm=[%s,%s]",
114+
window_s,
115+
total,
116+
valid,
117+
invalid,
118+
reason_text,
119+
provider_fps,
120+
last_seq,
121+
f"{last_age_ms:.1f}" if last_age_ms is not None else "None",
122+
pushed_video_frames,
123+
f"{last_stats.get('max_abs_pos'):.4f}" if "max_abs_pos" in last_stats else "None",
124+
_fmt_np_vec(pelvis),
125+
_fmt_np_vec(extent),
126+
_fmt_np_vec(min_pos),
127+
_fmt_np_vec(max_pos),
128+
_fmt_float(last_stats.get("quat_norm_min")),
129+
_fmt_float(last_stats.get("quat_norm_max")),
130+
)
131+
132+
133+
def _fmt_np_vec(values: Any) -> str:
134+
if values is None:
135+
return "None"
136+
arr = np.asarray(values, dtype=np.float64).reshape(-1)
137+
return "[" + ", ".join(f"{float(value):.4f}" for value in arr) + "]"
138+
139+
140+
def _fmt_float(value: Any) -> str:
141+
if value is None:
142+
return "None"
143+
return f"{float(value):.4f}"
144+
145+
146+
def _build_provider(cfg: DictConfig, video_enabled: bool) -> Pico4InputProvider:
147+
input_cfg = cfg_get(cfg, "input", {}) or {}
148+
video_cfg = parse_pico_video_config(input_cfg)
149+
return Pico4InputProvider(
150+
human_format=str(cfg_get(input_cfg, "human_format", "pico_bridge")),
151+
timeout=float(cfg_get(input_cfg, "pico4_timeout", 60.0)),
152+
buffer_size=int(cfg_get(input_cfg, "pico4_buffer_size", 60)),
153+
timestamp_gap_reset_s=float(cfg_get(input_cfg, "pico4_timestamp_gap_reset_s", 0.15)),
154+
pause_button=cfg_get(input_cfg, "pause_button", "A"),
155+
pause_debounce_s=float(cfg_get(input_cfg, "pause_debounce_s", 0.25)),
156+
bridge_host=str(cfg_get(input_cfg, "bridge_host", "0.0.0.0")),
157+
bridge_port=int(cfg_get(input_cfg, "bridge_port", 63901)),
158+
bridge_discovery=bool(cfg_get(input_cfg, "bridge_discovery", True)),
159+
bridge_advertise_ip=cfg_get(input_cfg, "bridge_advertise_ip", None),
160+
bridge_video=bridge_video_source(video_cfg),
161+
bridge_video_enabled=video_enabled,
162+
bridge_start_timeout=float(cfg_get(input_cfg, "bridge_start_timeout", 10.0)),
163+
bridge_history_size=int(cfg_get(input_cfg, "bridge_history_size", 120)),
164+
)
165+
166+
167+
@hydra.main(version_base=None, config_path="../../teleopit/configs", config_name="pico4_sim2real")
168+
def main(cfg: DictConfig) -> None:
169+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
170+
input_cfg = cfg_get(cfg, "input", {}) or {}
171+
video_cfg = parse_pico_video_config(input_cfg)
172+
mocap_switch = cfg_get(cfg, "mocap_switch", {}) or {}
173+
max_pos_value = float(cfg_get(mocap_switch, "max_position_value", 5.0))
174+
diag_cfg = cfg_get(cfg, "diagnostic", {}) or {}
175+
poll_hz = float(cfg_get(diag_cfg, "poll_hz", cfg_get(cfg_get(cfg, "multiprocess", {}) or {}, "pico_io_hz", 120.0)))
176+
summary_interval_s = float(cfg_get(diag_cfg, "summary_interval_s", 1.0))
177+
duration_s = float(cfg_get(diag_cfg, "duration_s", 0.0))
178+
179+
logger.info("Starting Pico signal diagnostic")
180+
logger.info(
181+
"Pico bridge | host=%s port=%s discovery=%s advertise_ip=%s",
182+
cfg_get(input_cfg, "bridge_host", "0.0.0.0"),
183+
cfg_get(input_cfg, "bridge_port", 63901),
184+
cfg_get(input_cfg, "bridge_discovery", True),
185+
cfg_get(input_cfg, "bridge_advertise_ip", None),
186+
)
187+
logger.info(
188+
"Signal check | max_position_value=%.3fm poll_hz=%.1f summary_interval_s=%.1f "
189+
"duration_s=%s video_enabled=%s video_source=%s",
190+
max_pos_value,
191+
poll_hz,
192+
summary_interval_s,
193+
f"{duration_s:.1f}" if duration_s > 0.0 else "until Ctrl-C",
194+
video_cfg.enabled,
195+
video_cfg.source,
196+
)
197+
198+
provider = _build_provider(cfg, video_cfg.enabled)
199+
video_runtime = PicoVideoRuntime(provider=provider, config=video_cfg, mode="sim2real")
200+
total = 0
201+
valid = 0
202+
invalid_reasons: Counter[str] = Counter()
203+
last_seq: int | None = None
204+
last_age_ms: float | None = None
205+
last_stats: dict[str, Any] = {}
206+
window_start_s = time.monotonic()
207+
start_s = window_start_s
208+
sleep_s = 1.0 / max(poll_hz, 1.0)
209+
210+
try:
211+
video_runtime.start()
212+
while True:
213+
now = time.monotonic()
214+
if duration_s > 0.0 and now - start_s >= duration_s:
215+
break
216+
217+
video_runtime.tick()
218+
if provider.has_frame():
219+
try:
220+
frame, timestamp_s, seq = provider.get_frame_packet()
221+
except Exception:
222+
logger.exception("Failed to read Pico body frame packet")
223+
else:
224+
seq = int(seq)
225+
if seq != last_seq:
226+
last_seq = seq
227+
last_age_ms = max((time.monotonic() - float(timestamp_s)) * 1000.0, 0.0)
228+
last_stats = _frame_stats(frame)
229+
result = validate_human_frame(frame, max_pos_value=max_pos_value)
230+
total += 1
231+
if result.valid:
232+
valid += 1
233+
else:
234+
invalid_reasons[result.reason] += 1
235+
_log_invalid(seq, last_age_ms, result)
236+
237+
now = time.monotonic()
238+
if now - window_start_s >= summary_interval_s:
239+
_log_summary(
240+
window_s=now - window_start_s,
241+
total=total,
242+
valid=valid,
243+
invalid_reasons=invalid_reasons,
244+
provider_fps=float(provider.fps),
245+
last_seq=last_seq,
246+
last_age_ms=last_age_ms,
247+
last_stats=last_stats,
248+
pushed_video_frames=video_runtime.pushed_frames,
249+
)
250+
total = 0
251+
valid = 0
252+
invalid_reasons.clear()
253+
window_start_s = now
254+
255+
time.sleep(sleep_s)
256+
except KeyboardInterrupt:
257+
logger.info("KeyboardInterrupt -- stopping Pico signal diagnostic")
258+
finally:
259+
video_runtime.stop()
260+
provider.close()
261+
262+
263+
if __name__ == "__main__":
264+
main()
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""HumanFrame sanity validation shared by realtime diagnostics and runtimes."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
import numpy as np
8+
9+
10+
@dataclass(frozen=True)
11+
class HumanFrameValidationResult:
12+
valid: bool
13+
reason: str = "ok"
14+
joint_name: str | None = None
15+
pos: tuple[float, ...] | None = None
16+
quat: tuple[float, ...] | None = None
17+
max_abs_pos: float | None = None
18+
max_pos_value: float | None = None
19+
detail: str = ""
20+
21+
22+
def validate_human_frame(frame: object, *, max_pos_value: float) -> HumanFrameValidationResult:
23+
"""Validate the same HumanFrame conditions used before realtime retargeting."""
24+
if not isinstance(frame, dict):
25+
return HumanFrameValidationResult(False, reason="frame_not_dict", detail=f"type={type(frame)!r}")
26+
27+
max_pos = float(max_pos_value)
28+
if not np.isfinite(max_pos) or max_pos <= 0.0:
29+
return HumanFrameValidationResult(
30+
False,
31+
reason="invalid_max_position_value",
32+
max_pos_value=max_pos,
33+
)
34+
35+
for name, value in frame.items():
36+
joint_name = str(name)
37+
try:
38+
pos, quat = value
39+
except Exception as exc:
40+
return HumanFrameValidationResult(
41+
False,
42+
reason="joint_unpack_failed",
43+
joint_name=joint_name,
44+
max_pos_value=max_pos,
45+
detail=str(exc),
46+
)
47+
48+
try:
49+
pos_arr = np.asarray(pos, dtype=np.float64).reshape(-1)
50+
except Exception as exc:
51+
return HumanFrameValidationResult(
52+
False,
53+
reason="position_cast_failed",
54+
joint_name=joint_name,
55+
max_pos_value=max_pos,
56+
detail=str(exc),
57+
)
58+
try:
59+
quat_arr = np.asarray(quat, dtype=np.float64).reshape(-1)
60+
except Exception as exc:
61+
return HumanFrameValidationResult(
62+
False,
63+
reason="quaternion_cast_failed",
64+
joint_name=joint_name,
65+
pos=_to_tuple(pos_arr),
66+
max_pos_value=max_pos,
67+
detail=str(exc),
68+
)
69+
70+
pos_tuple = _to_tuple(pos_arr)
71+
quat_tuple = _to_tuple(quat_arr)
72+
max_abs_pos = float(np.max(np.abs(pos_arr))) if pos_arr.size > 0 else 0.0
73+
74+
if np.any(np.isnan(pos_arr)):
75+
return HumanFrameValidationResult(
76+
False,
77+
reason="position_nan",
78+
joint_name=joint_name,
79+
pos=pos_tuple,
80+
quat=quat_tuple,
81+
max_abs_pos=max_abs_pos,
82+
max_pos_value=max_pos,
83+
)
84+
if np.any(np.isinf(pos_arr)):
85+
return HumanFrameValidationResult(
86+
False,
87+
reason="position_inf",
88+
joint_name=joint_name,
89+
pos=pos_tuple,
90+
quat=quat_tuple,
91+
max_abs_pos=max_abs_pos,
92+
max_pos_value=max_pos,
93+
)
94+
if np.any(np.abs(pos_arr) > max_pos):
95+
return HumanFrameValidationResult(
96+
False,
97+
reason="position_out_of_range",
98+
joint_name=joint_name,
99+
pos=pos_tuple,
100+
quat=quat_tuple,
101+
max_abs_pos=max_abs_pos,
102+
max_pos_value=max_pos,
103+
)
104+
if np.any(np.isnan(quat_arr)):
105+
return HumanFrameValidationResult(
106+
False,
107+
reason="quaternion_nan",
108+
joint_name=joint_name,
109+
pos=pos_tuple,
110+
quat=quat_tuple,
111+
max_abs_pos=max_abs_pos,
112+
max_pos_value=max_pos,
113+
)
114+
if np.any(np.isinf(quat_arr)):
115+
return HumanFrameValidationResult(
116+
False,
117+
reason="quaternion_inf",
118+
joint_name=joint_name,
119+
pos=pos_tuple,
120+
quat=quat_tuple,
121+
max_abs_pos=max_abs_pos,
122+
max_pos_value=max_pos,
123+
)
124+
125+
return HumanFrameValidationResult(True, max_pos_value=max_pos)
126+
127+
128+
def _to_tuple(values: np.ndarray) -> tuple[float, ...]:
129+
return tuple(float(value) for value in np.asarray(values, dtype=np.float64).reshape(-1))

0 commit comments

Comments
 (0)