uart example for giner
This commit is contained in:
@@ -0,0 +1,461 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""UART finger controller for Radxa / Dragon Q6A (sensor-side receiver).
|
||||||
|
|
||||||
|
Combines ORY-FINGER processing (same path as ``examples/finger_pipeline.py``)
|
||||||
|
with a line-based UART command interface. A host (or ``uart_send.py``-style
|
||||||
|
transmitter) sends commands; this script replies and streams forces.
|
||||||
|
|
||||||
|
Dependencies (beyond orisys):
|
||||||
|
pip install pyserial
|
||||||
|
|
||||||
|
Default UART (Dragon Q6A):
|
||||||
|
port=/dev/ttyHS1 baud=115200 8N1
|
||||||
|
|
||||||
|
Commands (one line, case-insensitive):
|
||||||
|
search List cameras (/dev/video*) via orisys.capture.list_cameras
|
||||||
|
connect [source] Open FingerRuntime (camera index, /dev/videoN, or video file)
|
||||||
|
run Stream FNORMAL / FSHEARX / FSHEARY until stop (or EOF)
|
||||||
|
stop Stop force streaming; keep source connected
|
||||||
|
quit | exit Close runtime and serial, then exit
|
||||||
|
|
||||||
|
Replies:
|
||||||
|
camera,<index>,<path>,<WxH>
|
||||||
|
OK search <N>
|
||||||
|
OK connect <source>
|
||||||
|
OK run
|
||||||
|
force,<fnormal>,<fshearx>,<fsheary>
|
||||||
|
OK eof Video file ended (when --no-loop-video)
|
||||||
|
OK stop
|
||||||
|
OK quit
|
||||||
|
ERR <context> <message>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Live camera over UART
|
||||||
|
python scripts/example_uart.py --port /dev/ttyHS1 --config finger
|
||||||
|
|
||||||
|
# Offline debug from a recorded file (no camera needed)
|
||||||
|
python scripts/example_uart.py --port /dev/ttyHS1 --video /path/to/clip.mp4
|
||||||
|
# then UART: connect (uses --video) or connect /path/to/clip.mp4
|
||||||
|
# then UART: run
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise SystemExit(
|
||||||
|
"pyserial is required for this script.\n"
|
||||||
|
" pip install pyserial\n"
|
||||||
|
f"Import error: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from orisys.capture import is_live_camera, list_cameras
|
||||||
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
|
Source = Union[int, str]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_source(token: str) -> Optional[Source]:
|
||||||
|
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
|
||||||
|
text = str(token).strip().strip('"').strip("'")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
|
||||||
|
return int(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _is_video_file(source: Source) -> bool:
|
||||||
|
"""True when source is a filesystem video path (not a live camera)."""
|
||||||
|
if is_live_camera(source):
|
||||||
|
return False
|
||||||
|
if isinstance(source, int):
|
||||||
|
return False
|
||||||
|
path = Path(str(source))
|
||||||
|
return path.is_file() or path.suffix.lower() in {
|
||||||
|
".mp4",
|
||||||
|
".avi",
|
||||||
|
".mkv",
|
||||||
|
".mov",
|
||||||
|
".m4v",
|
||||||
|
".wmv",
|
||||||
|
".webm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UartFingerController:
|
||||||
|
"""Serial command dispatcher around a single FingerRuntime."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ser: serial.Serial,
|
||||||
|
*,
|
||||||
|
config: str = "finger",
|
||||||
|
default_source: Source = 0,
|
||||||
|
max_camera_index: int = 10,
|
||||||
|
loop_video: bool = True,
|
||||||
|
show: bool = False,
|
||||||
|
verbose: bool = False,
|
||||||
|
):
|
||||||
|
self.ser = ser
|
||||||
|
self.config = config
|
||||||
|
self.default_source = default_source
|
||||||
|
self.max_camera_index = max_camera_index
|
||||||
|
self.loop_video = loop_video
|
||||||
|
self.show = show
|
||||||
|
self.verbose = verbose
|
||||||
|
|
||||||
|
self.runtime: Optional[FingerRuntime] = None
|
||||||
|
self._connected_source: Optional[Source] = None
|
||||||
|
self._last_search_source: Optional[Source] = None
|
||||||
|
self._streaming = False
|
||||||
|
self._should_quit = False
|
||||||
|
|
||||||
|
def reply(self, line: str) -> None:
|
||||||
|
text = line.rstrip("\r\n") + "\n"
|
||||||
|
self.ser.write(text.encode("utf-8"))
|
||||||
|
self.ser.flush()
|
||||||
|
if self.verbose:
|
||||||
|
print(f"<< {text.rstrip()}", flush=True)
|
||||||
|
|
||||||
|
def close_runtime(self) -> None:
|
||||||
|
self._streaming = False
|
||||||
|
if self.runtime is not None:
|
||||||
|
try:
|
||||||
|
self.runtime.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self.runtime = None
|
||||||
|
self._connected_source = None
|
||||||
|
if self.show:
|
||||||
|
try:
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _resolve_connect_source(self, args: list[str]) -> Source:
|
||||||
|
if args:
|
||||||
|
# Join remaining tokens so unquoted paths with spaces still work.
|
||||||
|
joined = " ".join(args).strip()
|
||||||
|
parsed = _parse_source(joined)
|
||||||
|
if parsed is None:
|
||||||
|
raise ValueError("empty connect source")
|
||||||
|
return parsed
|
||||||
|
# Prefer --video default over last search so offline debug stays on file.
|
||||||
|
if isinstance(self.default_source, str) and not is_live_camera(self.default_source):
|
||||||
|
return self.default_source
|
||||||
|
if self._last_search_source is not None:
|
||||||
|
return self._last_search_source
|
||||||
|
return self.default_source
|
||||||
|
|
||||||
|
def cmd_search(self, _args: list[str]) -> None:
|
||||||
|
try:
|
||||||
|
cameras = list_cameras(max_index=self.max_camera_index)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.reply(f"ERR search {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_search_source = None
|
||||||
|
for cam in cameras:
|
||||||
|
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
|
||||||
|
size = f"{cam.width}x{cam.height}"
|
||||||
|
self.reply(f"camera,{cam.index},{path},{size}")
|
||||||
|
if self._last_search_source is None:
|
||||||
|
self._last_search_source = cam.index
|
||||||
|
self.reply(f"OK search {len(cameras)}")
|
||||||
|
|
||||||
|
def cmd_connect(self, args: list[str]) -> None:
|
||||||
|
try:
|
||||||
|
source = self._resolve_connect_source(args)
|
||||||
|
except ValueError as exc:
|
||||||
|
self.reply(f"ERR connect {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(source, str) and not is_live_camera(source):
|
||||||
|
path = Path(source)
|
||||||
|
if not path.is_file():
|
||||||
|
self.reply(f"ERR connect file not found: {source}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._streaming = False
|
||||||
|
self.close_runtime()
|
||||||
|
try:
|
||||||
|
runtime = FingerRuntime.open(
|
||||||
|
config=self.config,
|
||||||
|
video=source,
|
||||||
|
verbose=self.verbose,
|
||||||
|
)
|
||||||
|
runtime.warmup(color=False)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.reply(f"ERR connect {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.runtime = runtime
|
||||||
|
self._connected_source = source
|
||||||
|
kind = "file" if _is_video_file(source) else "camera"
|
||||||
|
self.reply(f"OK connect {source} ({kind})")
|
||||||
|
|
||||||
|
def _rewind_video(self) -> bool:
|
||||||
|
"""Seek connected file source back to frame 0. Return True on success."""
|
||||||
|
if self.runtime is None or self._connected_source is None:
|
||||||
|
return False
|
||||||
|
if not _is_video_file(self._connected_source):
|
||||||
|
return False
|
||||||
|
cap = self.runtime.capture
|
||||||
|
if cap is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(cap.set(cv2.CAP_PROP_POS_FRAMES, 0))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]:
|
||||||
|
"""Read one command line if available; return None on timeout/empty."""
|
||||||
|
if not blocking and self.ser.in_waiting <= 0:
|
||||||
|
return None
|
||||||
|
raw = self.ser.readline()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
return line or None
|
||||||
|
|
||||||
|
def cmd_run(self, _args: list[str]) -> None:
|
||||||
|
if self.runtime is None:
|
||||||
|
self.reply("ERR run not connected")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._streaming = True
|
||||||
|
self.reply("OK run")
|
||||||
|
tactile = self.runtime.tactile
|
||||||
|
is_file = self._connected_source is not None and _is_video_file(self._connected_source)
|
||||||
|
|
||||||
|
while self._streaming and not self._should_quit:
|
||||||
|
ok, unrolled = self.runtime.grab_and_process(color=False)
|
||||||
|
if not ok:
|
||||||
|
if is_file and self.loop_video and self._rewind_video():
|
||||||
|
if self.verbose:
|
||||||
|
print("video EOF: looping", flush=True)
|
||||||
|
continue
|
||||||
|
if is_file:
|
||||||
|
self.reply("OK eof")
|
||||||
|
else:
|
||||||
|
self.reply("ERR run grab failed")
|
||||||
|
self._streaming = False
|
||||||
|
break
|
||||||
|
|
||||||
|
tactile.compute_deformation()
|
||||||
|
tactile.compute_contact()
|
||||||
|
fnormal, fshearx, fsheary = tactile.read_info(
|
||||||
|
tactile.info.FNORMAL,
|
||||||
|
tactile.info.FSHEARX,
|
||||||
|
tactile.info.FSHEARY,
|
||||||
|
)
|
||||||
|
self.reply(f"force,{float(fnormal):.6f},{float(fshearx):.6f},{float(fsheary):.6f}")
|
||||||
|
|
||||||
|
if self.show:
|
||||||
|
try:
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
arrows = orisys.util.draw_arrows(
|
||||||
|
unrolled,
|
||||||
|
tactile.flow,
|
||||||
|
threshold=0.4,
|
||||||
|
grid_spacing=10,
|
||||||
|
arrow_scale=1.0,
|
||||||
|
valid_mask=tactile.valid_mask,
|
||||||
|
)
|
||||||
|
cv2.imshow("UV Unrolled", arrows)
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord("q"):
|
||||||
|
self._should_quit = True
|
||||||
|
break
|
||||||
|
if key == ord("r"):
|
||||||
|
tactile.reset()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
if self.verbose:
|
||||||
|
print(f"show error: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Non-blocking command poll so force streaming is not delayed by UART timeout.
|
||||||
|
pending = self._poll_command_line(blocking=False)
|
||||||
|
if pending is not None:
|
||||||
|
if self.verbose:
|
||||||
|
print(f">> {pending}", flush=True)
|
||||||
|
if not self._dispatch(pending, allow_run=False):
|
||||||
|
break
|
||||||
|
|
||||||
|
def cmd_stop(self, _args: list[str]) -> None:
|
||||||
|
self._streaming = False
|
||||||
|
self.reply("OK stop")
|
||||||
|
|
||||||
|
def cmd_quit(self, _args: list[str]) -> None:
|
||||||
|
self._streaming = False
|
||||||
|
self._should_quit = True
|
||||||
|
self.reply("OK quit")
|
||||||
|
|
||||||
|
def _dispatch(self, line: str, *, allow_run: bool = True) -> bool:
|
||||||
|
"""Dispatch one command line. Return False if the main loop should exit."""
|
||||||
|
try:
|
||||||
|
parts = shlex.split(line)
|
||||||
|
except ValueError:
|
||||||
|
parts = line.split()
|
||||||
|
if not parts:
|
||||||
|
return True
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
|
if cmd == "search":
|
||||||
|
self.cmd_search(args)
|
||||||
|
elif cmd == "connect":
|
||||||
|
self.cmd_connect(args)
|
||||||
|
elif cmd == "run":
|
||||||
|
if not allow_run:
|
||||||
|
self.reply("ERR run already running")
|
||||||
|
else:
|
||||||
|
self.cmd_run(args)
|
||||||
|
elif cmd == "stop":
|
||||||
|
self.cmd_stop(args)
|
||||||
|
elif cmd in ("quit", "exit"):
|
||||||
|
self.cmd_quit(args)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.reply(f"ERR unknown {cmd}")
|
||||||
|
return not self._should_quit
|
||||||
|
|
||||||
|
def serve(self) -> None:
|
||||||
|
self.reply("OK ready")
|
||||||
|
while not self._should_quit:
|
||||||
|
line = self._poll_command_line()
|
||||||
|
if line is None:
|
||||||
|
continue
|
||||||
|
if self.verbose:
|
||||||
|
print(f">> {line}", flush=True)
|
||||||
|
if not self._dispatch(line):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
|
||||||
|
try:
|
||||||
|
return serial.Serial(
|
||||||
|
port=port,
|
||||||
|
baudrate=baud,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except serial.SerialException as exc:
|
||||||
|
print(f"Serial error: {exc}", file=sys.stderr)
|
||||||
|
print("Check:", file=sys.stderr)
|
||||||
|
print(f" 1. Device node {port} exists", file=sys.stderr)
|
||||||
|
print(" 2. User has access (sudo or dialout group)", file=sys.stderr)
|
||||||
|
print(" 3. Wiring / USB-TTL connection", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="UART finger controller (search/connect/run/stop force stream)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", default="/dev/ttyHS1", help="Serial device (default: /dev/ttyHS1)")
|
||||||
|
parser.add_argument("--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=0.1,
|
||||||
|
help="Serial readline timeout seconds (default: 0.1)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--config", "-c", default="finger", help="Orisys config name or path")
|
||||||
|
parser.add_argument(
|
||||||
|
"--camera-id",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Default camera index when connect has no argument (ignored if --video is set)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
default=None,
|
||||||
|
help="Default video file for connect (offline debug without a camera)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-camera-index",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help="Highest index probed by search",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--loop-video",
|
||||||
|
dest="loop_video",
|
||||||
|
action="store_true",
|
||||||
|
default=True,
|
||||||
|
help="Loop video file on EOF during run (default: on)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-loop-video",
|
||||||
|
dest="loop_video",
|
||||||
|
action="store_false",
|
||||||
|
help="Stop run with OK eof when video file ends",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--show",
|
||||||
|
action="store_true",
|
||||||
|
help="Show UV unrolled OpenCV window (local debug only)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="Echo UART traffic and SDK logs")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.video:
|
||||||
|
video_path = Path(args.video)
|
||||||
|
if not video_path.is_file():
|
||||||
|
print(f"Video file not found: {args.video}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
default_source: Source = str(video_path.resolve())
|
||||||
|
else:
|
||||||
|
default_source = args.camera_id
|
||||||
|
|
||||||
|
ser = open_serial(args.port, args.baud, args.timeout)
|
||||||
|
controller = UartFingerController(
|
||||||
|
ser,
|
||||||
|
config=args.config,
|
||||||
|
default_source=default_source,
|
||||||
|
max_camera_index=args.max_camera_index,
|
||||||
|
loop_video=args.loop_video,
|
||||||
|
show=args.show,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"UART finger controller on {args.port} @ {args.baud} bps", flush=True)
|
||||||
|
print(f"Default source: {default_source}", flush=True)
|
||||||
|
print("Commands: search | connect [source|file] | run | stop | quit", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
controller.serve()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nInterrupted.", flush=True)
|
||||||
|
try:
|
||||||
|
controller.reply("OK quit")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
controller.close_runtime()
|
||||||
|
if ser.is_open:
|
||||||
|
ser.close()
|
||||||
|
print("Serial closed.", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user