#!/usr/bin/env python3 """ saqr_g1_bridge.py Bridge between Saqr PPE detection and the Unitree G1 arm action client. Spawns Saqr (Project/Saqr/saqr.py) as a subprocess, parses its event stream, and triggers the G1 'reject' arm action (id=13) whenever a tracked person transitions to UNSAFE (= DANGER). SAFE and PARTIAL never trigger an action. Saqr event line format (from emit_event in saqr.py): ID 0001 | NEW | UNSAFE | wearing: ... | missing: ... | unknown: ... ID 0001 | STATUS_CHANGE | SAFE | wearing: ... | missing: ... | unknown: ... Usage: # default: webcam, default DDS interface python3 saqr_g1_bridge.py # specify camera and DDS interface python3 saqr_g1_bridge.py --source 0 --iface enp3s0 # dry run (no robot movement, just print decisions) python3 saqr_g1_bridge.py --dry-run # forward extra args to saqr.py after a `--` python3 saqr_g1_bridge.py --iface eth0 -- --conf 0.4 --imgsz 640 """ from __future__ import annotations import argparse import os import re import signal import subprocess import sys import threading import time from pathlib import Path from typing import Dict, Optional # ── Defaults ───────────────────────────────────────────────────────────────── HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parent.parent # .../yslootahtech SAQR_DIR = REPO_ROOT / "Project" / "Saqr" SAQR_SCRIPT = SAQR_DIR / "saqr.py" DANGER_STATUS = "UNSAFE" REJECT_ACTION = "reject" RELEASE_ACTION = "release arm" # ID NNNN | EVENT_TYPE | STATUS | wearing: ... | missing: ... | unknown: ... EVENT_RE = re.compile( r"^ID\s+(?P\d+)\s*\|\s*" r"(?PNEW|STATUS_CHANGE)\s*\|\s*" r"(?PSAFE|PARTIAL|UNSAFE)\s*\|" ) # ── G1 arm controller (lazy import: SDK only loaded when not in dry-run) ───── class ArmController: def __init__(self, iface: Optional[str], timeout: float, dry_run: bool): self.dry_run = dry_run if dry_run: print("[BRIDGE] DRY RUN — G1 SDK will not be loaded.", flush=True) self.client = None return from unitree_sdk2py.core.channel import ChannelFactoryInitialize from unitree_sdk2py.g1.arm.g1_arm_action_client import ( G1ArmActionClient, action_map, ) self._action_map = action_map if iface: ChannelFactoryInitialize(0, iface) else: ChannelFactoryInitialize(0) self.client = G1ArmActionClient() self.client.SetTimeout(timeout) self.client.Init() print(f"[BRIDGE] G1ArmActionClient ready (iface={iface or 'default'})", flush=True) def reject(self, release_after: float): if self.dry_run: print(f"[BRIDGE] (dry) would run '{REJECT_ACTION}' " f"then release after {release_after:.1f}s", flush=True) return if REJECT_ACTION not in self._action_map: print(f"[BRIDGE][ERR] '{REJECT_ACTION}' not in SDK action_map", flush=True) return print(f"[BRIDGE] -> {REJECT_ACTION}", flush=True) self.client.ExecuteAction(self._action_map[REJECT_ACTION]) if release_after > 0: time.sleep(release_after) print(f"[BRIDGE] -> {RELEASE_ACTION}", flush=True) self.client.ExecuteAction(self._action_map[RELEASE_ACTION]) # ── Bridge ─────────────────────────────────────────────────────────────────── class Bridge: def __init__( self, arm: ArmController, cooldown_s: float, release_after_s: float, ): self.arm = arm self.cooldown_s = cooldown_s self.release_after_s = release_after_s self.last_status: Dict[int, str] = {} self.last_trigger_t: Dict[int, float] = {} self._lock = threading.Lock() def handle_line(self, line: str): line = line.rstrip() if not line: return # Always echo Saqr output so the user still sees the live stream. print(line, flush=True) m = EVENT_RE.match(line) if not m: return track_id = int(m.group("id")) status = m.group("status") with self._lock: prev = self.last_status.get(track_id) self.last_status[track_id] = status if status != DANGER_STATUS: return # Trigger only on transitions into UNSAFE, with per-id cooldown. now = time.time() last_t = self.last_trigger_t.get(track_id, 0.0) transitioned = (prev != DANGER_STATUS) cooled_down = (now - last_t) >= self.cooldown_s if not (transitioned and cooled_down): return self.last_trigger_t[track_id] = now # Run the arm action outside the lock so we don't block parsing. try: self.arm.reject(release_after=self.release_after_s) except Exception as e: print(f"[BRIDGE][ERR] arm reject failed: {e}", flush=True) # ── Saqr subprocess management ─────────────────────────────────────────────── def build_saqr_cmd(saqr_extra_args: list[str]) -> list[str]: if not SAQR_SCRIPT.exists(): sys.exit(f"[BRIDGE][FATAL] saqr.py not found at: {SAQR_SCRIPT}") # -u for unbuffered stdout (so events arrive line-by-line). return [sys.executable, "-u", str(SAQR_SCRIPT), *saqr_extra_args] def split_argv(argv: list[str]) -> tuple[list[str], list[str]]: """Split bridge args from saqr passthrough args at the first '--'.""" if "--" in argv: idx = argv.index("--") return argv[:idx], argv[idx + 1 :] return argv, [] def main(): bridge_argv, saqr_extra = split_argv(sys.argv[1:]) ap = argparse.ArgumentParser( description="Bridge Saqr PPE events to the G1 arm 'reject' action." ) ap.add_argument("--iface", default=None, help="DDS network interface (e.g. enp3s0). Optional.") ap.add_argument("--timeout", type=float, default=10.0, help="G1 arm client timeout (seconds).") ap.add_argument("--cooldown", type=float, default=8.0, help="Per-track-id seconds before reject can re-trigger.") ap.add_argument("--release-after", type=float, default=2.0, help="Seconds before auto-running 'release arm' (0 = never).") ap.add_argument("--dry-run", action="store_true", help="Parse and decide but never call the SDK.") # Convenience pass-throughs to saqr.py (you can also use `-- ...`). ap.add_argument("--source", default=None, help="Saqr --source (0/realsense/path). Default: leave to saqr.") ap.add_argument("--headless", action="store_true", help="Pass --headless to saqr.") ap.add_argument("--saqr-conf", type=float, default=None, help="Pass --conf to saqr.") ap.add_argument("--imgsz", type=int, default=None, help="Pass --imgsz to saqr.") ap.add_argument("--device", default=None, help="Pass --device to saqr (e.g. cpu / 0 / cuda:0).") args = ap.parse_args(bridge_argv) # Build saqr args from convenience flags + raw passthrough. saqr_args: list[str] = [] if args.source is not None: saqr_args += ["--source", args.source] if args.headless: saqr_args += ["--headless"] if args.saqr_conf is not None: saqr_args += ["--conf", str(args.saqr_conf)] if args.imgsz is not None: saqr_args += ["--imgsz", str(args.imgsz)] if args.device is not None: saqr_args += ["--device", args.device] saqr_args += saqr_extra arm = ArmController(iface=args.iface, timeout=args.timeout, dry_run=args.dry_run) bridge = Bridge( arm=arm, cooldown_s=args.cooldown, release_after_s=args.release_after, ) cmd = build_saqr_cmd(saqr_args) print(f"[BRIDGE] launching: {' '.join(cmd)}", flush=True) print(f"[BRIDGE] cwd: {SAQR_DIR}", flush=True) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" proc = subprocess.Popen( cmd, cwd=str(SAQR_DIR), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, text=True, env=env, ) def _forward_signal(signum, _frame): print(f"[BRIDGE] signal {signum} -> stopping saqr", flush=True) try: proc.send_signal(signum) except Exception: pass signal.signal(signal.SIGINT, _forward_signal) signal.signal(signal.SIGTERM, _forward_signal) try: assert proc.stdout is not None for line in proc.stdout: bridge.handle_line(line) finally: rc = proc.wait() print(f"[BRIDGE] saqr exited rc={rc}", flush=True) sys.exit(rc) if __name__ == "__main__": main()