"""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())