|
| 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() |
0 commit comments