SmartestHome/hosts/thin-client/configs/gesture-control/gesture_pointer.py

239 lines
9.9 KiB
Python

"""Camera hand-gesture pointer control. Installed to /opt/gesture-control/.
Open hand moves the pointer, closed fist clicks. Started by /usr/local/bin/gesture-control
from the sway config; see that wrapper for why it is a session process.
PRIVACY, and the reason the structure of this file looks the way it does: enabling this
means a camera continuously captures and analyses video of the room. main() therefore
reads gesture-config.json and returns before OpenCV or MediaPipe are imported at all —
the "enabled": false default does not mean the camera is opened and its frames dropped,
it means the video stack is never loaded and /dev/video* is never opened. Keep the
imports where they are.
Why this is not a module inside thinclient_agent/, despite reusing two of its modules:
that package is installed to /opt/thinclient-agent and runs on the SYSTEM interpreter
against apt's python3-paho-mqtt. MediaPipe is PyPI-only and lives in its own venv
(1100-gesture-control.hook.chroot, same PEP 668 reasoning as the voice satellite). A
module sitting in a package whose interpreter cannot import its own dependencies would
be a trap. The two modules it does import — input_control and runtime_state — are
stdlib-only, so they load fine from inside the venv via PYTHONPATH.
Why velocity control rather than trackpad-style frame-to-frame deltas: at the inference
rates this hardware can sustain (~10-15 fps, see README) a delta model is both jittery
and runs out of frame — you would have to lift and re-place your hand like a mouse. Hand
offset from the centre of the frame driving a pointer SPEED instead is self-recentering,
has an obvious rest state (hand in the middle = pointer stopped), and degrades into
"slightly slower pointer" rather than "wrong pointer" when frames are dropped.
The pointer anchor is the middle-finger knuckle (landmark 9), not a fingertip: it barely
moves as the fingers curl, so the open-hand-to-fist transition does not drag the pointer
off whatever you were about to click.
"""
from __future__ import annotations
import logging
import math
import os
import sys
import time
from thinclient_agent.input_control import InputControl
from thinclient_agent.runtime_state import ensure_runtime_copy, load_json
log = logging.getLogger("gesture-control")
CONFIG_FILENAME = "gesture-config.json"
WRIST = 0
PALM_ANCHOR = 9
FINGER_TIPS = (8, 12, 16, 20)
FINGER_PIPS = (6, 10, 14, 18)
# Curl is measured per finger as "is the tip nearer the wrist than its middle joint",
# which is scale- and rotation-invariant and so needs no calibration for how far away
# the person is standing. The gap between the two thresholds is deliberate: a hand
# somewhere between the two states is neither, and does nothing.
FIST_MIN_CURLED = 4
OPEN_MAX_CURLED = 1
def _distance(a, b) -> float:
return math.hypot(a.x - b.x, a.y - b.y)
def _curled_fingers(landmarks) -> int:
wrist = landmarks[WRIST]
return sum(
1
for tip, pip in zip(FINGER_TIPS, FINGER_PIPS)
if _distance(landmarks[tip], wrist) < _distance(landmarks[pip], wrist)
)
class GesturePointer:
def __init__(self, config: dict, input_control: InputControl):
self._input = input_control
self._dead_zone = float(config.get("dead_zone") or 0.08)
self._speed = float(config.get("pointer_speed") or 900)
self._mirror = bool(config.get("mirror", True))
self._fist_hold = float(config.get("fist_hold_seconds") or 0.4)
self._cooldown = float(config.get("click_cooldown_seconds") or 1.0)
self._fist_since: float | None = None
self._click_armed = True
# -inf, not 0.0: these are time.monotonic() values, which are uptime-relative, so
# 0.0 would silently swallow the first click of a session started soon after boot.
self._last_click = float("-inf")
def _axis_delta(self, normalised: float, elapsed: float) -> float:
offset = normalised - 0.5
magnitude = abs(offset) - self._dead_zone
if magnitude <= 0:
return 0.0
# Rescaled so the speed ramps from zero at the edge of the dead zone up to the
# full configured speed at the frame edge, rather than jumping to a fraction of
# it the moment the dead zone is crossed.
travel = max(0.5 - self._dead_zone, 1e-6)
return math.copysign(magnitude / travel, offset) * self._speed * elapsed
def handle(self, landmarks, now: float, elapsed: float) -> None:
if landmarks is None:
self._fist_since = None
self._click_armed = True
return
curled = _curled_fingers(landmarks)
if curled >= FIST_MIN_CURLED:
# No movement while the fist is closed: a click that drifts the pointer
# between the press and whatever the user was aiming at is worse than a
# click that does not fire.
if self._fist_since is None:
self._fist_since = now
elif (
self._click_armed
and now - self._fist_since >= self._fist_hold
and now - self._last_click >= self._cooldown
):
log.info("fist held, clicking")
self._input.click("LEFT")
self._last_click = now
self._click_armed = False
return
self._fist_since = None
if curled <= OPEN_MAX_CURLED:
# Re-arming only on a clearly open hand, not merely on "not a fist", is what
# makes a held fist one click instead of a repeat.
self._click_armed = True
anchor = landmarks[PALM_ANCHOR]
x = 1.0 - anchor.x if self._mirror else anchor.x
self._input.move_relative(
self._axis_delta(x, elapsed), self._axis_delta(anchor.y, elapsed)
)
def run(config: dict) -> int:
# Imported here rather than at module scope so that the disabled default in main()
# never loads the video stack. See the module docstring.
import cv2
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision as mp_vision
model_path = str(config.get("model_path") or "/opt/gesture-control/hand_landmarker.task")
device = str(config.get("camera_device") or "/dev/video0")
capture = cv2.VideoCapture(device, cv2.CAP_V4L2)
if not capture.isOpened():
log.error("could not open %s — is a camera plugged in and is this user in the "
"'video' group?", device)
return 1
capture.set(cv2.CAP_PROP_FRAME_WIDTH, int(config.get("frame_width") or 640))
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, int(config.get("frame_height") or 480))
capture.set(cv2.CAP_PROP_FPS, int(config.get("capture_fps") or 30))
capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
inference_fps = float(config.get("inference_fps") or 12)
inference_period = 1.0 / inference_fps if inference_fps > 0 else 0.0
landmarker = mp_vision.HandLandmarker.create_from_options(
mp_vision.HandLandmarkerOptions(
base_options=mp_python.BaseOptions(model_asset_path=model_path),
# VIDEO rather than LIVE_STREAM: LIVE_STREAM hands results back on a callback
# thread, which buys nothing here because the loop below is already the only
# consumer and is deliberately rate-limited.
running_mode=mp_vision.RunningMode.VIDEO,
num_hands=1,
min_hand_detection_confidence=0.6,
min_hand_presence_confidence=0.6,
min_tracking_confidence=0.5,
)
)
# Plain os.environ, not SwayControl.session_env(): unlike thinclient-agent, which is
# a system service outside the session and has to reconstruct SWAYSOCK/XDG_RUNTIME_DIR
# by hand, this process is started by sway itself and already has them.
pointer = GesturePointer(config, InputControl(os.environ.copy))
log.info("gesture control running on %s at ~%.0f inference fps", device, inference_fps)
# Seeded with the current time rather than 0, so the first analysed frame gets a
# sane elapsed and cannot start the session by flinging the pointer a clamped step.
last_inference = time.monotonic()
try:
while True:
# Every frame is read even though most are discarded: leaving them queued in
# V4L2 would mean the frame that does get analysed is progressively older
# than the hand actually in front of the camera.
ok, frame = capture.read()
if not ok:
log.warning("camera read failed; stopping")
return 1
now = time.monotonic()
if now - last_inference < inference_period:
continue
elapsed = min(now - last_inference, 0.5)
last_inference = now
image = mp.Image(
image_format=mp.ImageFormat.SRGB,
data=cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),
)
result = landmarker.detect_for_video(image, int(now * 1000))
hands = getattr(result, "hand_landmarks", None) or []
pointer.handle(hands[0] if hands else None, now, elapsed)
except KeyboardInterrupt:
return 0
finally:
capture.release()
landmarker.close()
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
config = load_json(ensure_runtime_copy(CONFIG_FILENAME))
if config.get("enabled") is not True:
log.info(
"gesture control is disabled (the default) — the camera will not be opened. "
"Set \"enabled\": true in /var/lib/thinclient-agent/%s to turn it on.",
CONFIG_FILENAME,
)
return 0
log.warning(
"gesture control is ENABLED — a camera is about to start continuously capturing "
"and analysing video of this room for as long as this session is up."
)
return run(config)
if __name__ == "__main__":
raise SystemExit(main())