266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
#!/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<id>\d+)\s*\|\s*"
|
|
r"(?P<event>NEW|STATUS_CHANGE)\s*\|\s*"
|
|
r"(?P<status>SAFE|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()
|