| """Air drawing with hand gestures.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import sys |
| import time |
| from collections import deque |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent)) |
|
|
| import cv2 |
| import numpy as np |
|
|
| import ui |
| from camera import CameraStream |
| from canvas import COLOR_NAMES, Canvas |
| from gestures import Gesture, GestureRecognizer |
| from menu import Menu |
| from mp_tracker import MediaPipeTracker |
| from renderer3d import Object3D |
| from smoothing import LandmarkFilter, OneEuroFilter, ScalarEMA |
|
|
| WINDOW = "Hand Tracking Drawing" |
| GRAB_HOLD_SECONDS = 0.35 |
| OK_HOLD_SECONDS = 0.5 |
| PINKY_HOLD_SECONDS = 0.4 |
| WAVE_WINDOW_SECONDS = 1.3 |
| WAVE_AMPLITUDE_RATIO = 0.35 |
| WAVE_FLIPS = 4 |
| WAVE_MEMORY_SECONDS = 0.7 |
| WAVE_PALM_GRACE = 0.5 |
| BYE_SECONDS = 1.0 |
| STROKE_GRACE_SECONDS = 0.14 |
| TOAST_SECONDS = 2.2 |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| """Parse command line arguments.""" |
| root = Path(__file__).resolve().parent.parent |
| p = argparse.ArgumentParser(description="Hand tracking drawing (MediaPipe + OpenCV)") |
| p.add_argument("--camera", type=int, default=0) |
| p.add_argument("--mode", choices=("2d", "3d", "poses"), default="2d") |
| p.add_argument("--model", default=str(root / "models" / "hand_landmarker.task")) |
| p.add_argument("--depth-model", default=str(root / "models" / "yolo26s-depth.pt")) |
| p.add_argument("--depth-size", type=int, default=384) |
| p.add_argument("--depth-every", type=int, default=2) |
| p.add_argument("--pose-model", default=str(root / "models" / "yolo26s-pose.pt")) |
| p.add_argument("--pose-size", type=int, default=640) |
| p.add_argument("--pose-conf", type=float, default=0.35) |
| p.add_argument("--conf", type=float, default=0.25) |
| p.add_argument("--width", type=int, default=1280) |
| p.add_argument("--height", type=int, default=720) |
| p.add_argument("--max-hands", type=int, default=2) |
| p.add_argument("--output", default=str(root / "output")) |
| p.add_argument("--no-mirror", action="store_true") |
| p.add_argument("--no-menu", action="store_true") |
| p.add_argument("--show-skeleton", action="store_true", default=True) |
| p.add_argument("--video-mode", action="store_true", default=True) |
| p.add_argument("--no-video-mode", dest="video_mode", action="store_false") |
| p.add_argument("--debug", action="store_true") |
| return p.parse_args() |
|
|
|
|
| class WaveDetector: |
| """Side to side shaking detector.""" |
|
|
| def __init__(self, window: float = WAVE_WINDOW_SECONDS, flips: int = WAVE_FLIPS) -> None: |
| self.window = window |
| self.flips = flips |
| self._dir = 0 |
| self._extreme: float | None = None |
| self._times: deque[float] = deque() |
|
|
| def reset(self) -> None: |
| """Drop tracking state.""" |
| self._dir = 0 |
| self._extreme = None |
| self._times.clear() |
|
|
| def update(self, x: float, amplitude: float, now: float) -> bool: |
| """Feed one sample.""" |
| while self._times and now - self._times[0] > self.window: |
| self._times.popleft() |
| if self._extreme is None: |
| self._extreme = x |
| return False |
| delta = x - self._extreme |
| if abs(delta) >= amplitude: |
| direction = 1 if delta > 0 else -1 |
| if direction != self._dir: |
| if self._dir != 0: |
| self._times.append(now) |
| self._dir = direction |
| self._extreme = x |
| return len(self._times) >= self.flips |
|
|
|
|
| class App: |
| """Main application loop.""" |
|
|
| def __init__(self, args: argparse.Namespace) -> None: |
| self.args = args |
| self.cam = CameraStream(args.camera, args.width, args.height) |
| self.h, self.w = self.cam.shape |
|
|
| print(f"[i] camera: {self.w}x{self.h} @ {self.cam.source_fps:.0f} FPS", flush=True) |
| self.tracker = MediaPipeTracker(args.model, conf=args.conf, |
| max_hands=args.max_hands, video_mode=args.video_mode) |
| self.device = self.tracker.device |
| print(f"[i] backend: mediapipe device: {self.device}", flush=True) |
| print(f"[i] model: {args.model}", flush=True) |
| self.recognizers = [GestureRecognizer() for _ in range(args.max_hands)] |
| self._slot_centers: list[np.ndarray | None] = [None] * args.max_hands |
| self._kp_filters = [LandmarkFilter() for _ in range(args.max_hands)] |
| self.canvas = Canvas(self.w, self.h, args.output) |
| self.tip_filter = OneEuroFilter(min_cutoff=1.0, beta=0.06) |
|
|
| self.mode_3d = False |
| self.object3d: Object3D | None = None |
| self._object_rev = -1 |
| self.yaw, self.pitch, self.roll = 0.4, -0.25, 0.0 |
| self.scale = 1.0 |
| self.yaw_s, self.pitch_s = ScalarEMA(0.4, self.yaw), ScalarEMA(0.4, self.pitch) |
| self.scale_s = ScalarEMA(0.3, 1.0) |
| self._grab: tuple[np.ndarray, float, float] | None = None |
| self._grab_span: tuple[float, float] | None = None |
|
|
| self.show_help = False |
| self.show_debug = args.debug |
| self.show_skeleton = args.show_skeleton |
| self.grab_since: float | None = None |
| self._ok_since: float | None = None |
| self._ok_latched = False |
| self._palm_armed = True |
| self._middle_latched = False |
| self._pinky_since: float | None = None |
| self._pinky_latched = False |
| self._wave = [WaveDetector() for _ in range(args.max_hands)] |
| self._wave_until = [0.0] * args.max_hands |
| self._palm_seen = [0.0] * args.max_hands |
| self._bye_at: float | None = None |
| self._draw_lost_since: float | None = None |
| self._zoom_anchor: list[float] | None = None |
| self.toast_text = "" |
| self.toast_until = 0.0 |
| self.fps = 0.0 |
| self._last_t = time.perf_counter() |
| self._last_render: np.ndarray | None = None |
|
|
| def on_frame(self, frame: np.ndarray) -> None: |
| """Per frame hook for subclasses.""" |
|
|
| def toast(self, text: str, seconds: float = TOAST_SECONDS) -> None: |
| """Show transient message.""" |
| self.toast_text = text |
| self.toast_until = time.time() + seconds |
|
|
| def build_object(self) -> bool: |
| """Rebuild 3D object.""" |
| if self.object3d is not None and self._object_rev == self.canvas.revision: |
| return True |
| crop = self.canvas.cropped_bgra(padding=28) |
| if crop is None: |
| self.toast("Draw something first") |
| return False |
| self.object3d = Object3D(crop) |
| self._object_rev = self.canvas.revision |
| return True |
|
|
| def enter_3d(self) -> None: |
| """Switch to 3D mode.""" |
| if self.build_object(): |
| self.mode_3d = True |
| self._palm_armed = False |
| self.canvas.end() |
| self.toast("Grabbed. Move fist to rotate") |
|
|
| def exit_3d(self) -> None: |
| """Return to drawing mode.""" |
| self.mode_3d = False |
| self._grab = None |
| self._grab_span = None |
| self.toast("Back to drawing") |
|
|
| def clear_canvas(self) -> None: |
| """Wipe the board.""" |
| self.stop_stroke(force=True) |
| self.canvas.clear() |
| self.object3d = None |
| self._object_rev = -1 |
| self._grab = None |
| self._grab_span = None |
| self.grab_since = None |
| if self.mode_3d: |
| self.mode_3d = False |
| self.toast("Canvas cleared") |
|
|
| def handle_ok(self, states: list) -> None: |
| """Clear on OK gesture.""" |
| ok = any(s.gesture == Gesture.OK and s.stable for s in states) |
| if not ok: |
| self._ok_since = None |
| self._ok_latched = False |
| return |
| now = time.time() |
| self._ok_since = self._ok_since or now |
| if not self._ok_latched and now - self._ok_since >= OK_HOLD_SECONDS: |
| self._ok_latched = True |
| self.clear_canvas() |
|
|
| def handle_middle(self, states: list) -> None: |
| """React to middle finger.""" |
| shown = any(s.gesture == Gesture.MIDDLE and s.stable for s in states) |
| if not shown: |
| self._middle_latched = False |
| return |
| if not self._middle_latched: |
| self._middle_latched = True |
| self.toast(":((", 1.0) |
|
|
| def handle_pinky(self, states: list) -> None: |
| """Undo on pinky gesture.""" |
| shown = any(s.gesture == Gesture.PINKY and s.stable for s in states) |
| if not shown: |
| self._pinky_since = None |
| self._pinky_latched = False |
| return |
| now = time.time() |
| self._pinky_since = self._pinky_since or now |
| if not self._pinky_latched and now - self._pinky_since >= PINKY_HOLD_SECONDS: |
| self._pinky_latched = True |
| if self.canvas.strokes: |
| self.stop_stroke(force=True) |
| self.canvas.undo() |
| self.toast("Undo last stroke") |
| else: |
| self.toast("Nothing to undo") |
|
|
| def handle_draw_mode(self, states: list, dt: float) -> None: |
| """Drive drawing and grab.""" |
| drawing = [s for s in states if s.gesture == Gesture.DRAW] |
| grabbing = [s for s in states if s.gesture == Gesture.GRAB] |
| now = time.time() |
|
|
| if len(grabbing) >= 2: |
| self.stop_stroke(force=True) |
| self.zoom_canvas(grabbing) |
| self.grab_since = None |
| return |
| self._zoom_anchor = None |
|
|
| if drawing: |
| state = drawing[0] |
| self.canvas.begin() |
| self.canvas.add_point(self.tip_filter(state.cursor, dt)) |
| self._draw_lost_since = None |
| self.grab_since = None |
| return |
|
|
| self.stop_stroke() |
|
|
| if grabbing: |
| self.grab_since = self.grab_since or now |
| if now - self.grab_since >= GRAB_HOLD_SECONDS: |
| self.grab_since = None |
| self.enter_3d() |
| else: |
| self.grab_since = None |
|
|
| def stop_stroke(self, force: bool = False) -> None: |
| """End stroke after grace.""" |
| if force: |
| self._draw_lost_since = None |
| self.canvas.end() |
| self.tip_filter.reset() |
| return |
| now = time.time() |
| self._draw_lost_since = self._draw_lost_since or now |
| if now - self._draw_lost_since >= STROKE_GRACE_SECONDS: |
| self.canvas.end() |
| self.tip_filter.reset() |
|
|
| def zoom_canvas(self, grabbing: list) -> None: |
| """Scale canvas by fists.""" |
| span = float(np.linalg.norm(grabbing[0].pinch_point - grabbing[1].pinch_point)) |
| if self._zoom_anchor is None: |
| self._zoom_anchor = [span, 1.0] |
| return |
| span0, applied = self._zoom_anchor |
| if span0 < 1e-3: |
| return |
| target = float(np.clip(span / span0, 0.25, 4.0)) |
| delta = target / applied |
| if abs(delta - 1.0) < 0.02: |
| return |
| span_px = self.canvas.content_span() |
| diag = float(np.hypot(self.w, self.h)) |
| if (delta > 1 and span_px > diag * 0.95) or (delta < 1 and span_px < 40): |
| return |
| self.canvas.scale_content(delta) |
| self._zoom_anchor[1] = target |
| self.toast(f"Canvas scale {target * 100:.0f}%") |
|
|
| def handle_3d_mode(self, states: list, dt: float) -> None: |
| """Rotate and scale object.""" |
| grabbing = [s for s in states if s.gesture == Gesture.GRAB] |
|
|
| if grabbing: |
| p = grabbing[0].pinch_point |
| if self._grab is None: |
| self._grab = (p.copy(), self.yaw, self.pitch) |
| anchor, yaw0, pitch0 = self._grab |
| dx = (p[0] - anchor[0]) / self.w |
| dy = (p[1] - anchor[1]) / self.h |
| self.yaw = yaw0 + dx * 2.0 * np.pi * 1.1 |
| self.pitch = float(np.clip(pitch0 + dy * np.pi * 1.1, -1.3, 1.3)) |
|
|
| if len(grabbing) >= 2: |
| span = float(np.linalg.norm(grabbing[0].pinch_point - grabbing[1].pinch_point)) |
| if self._grab_span is None: |
| self._grab_span = (span, self.scale) |
| span0, scale0 = self._grab_span |
| if span0 > 1e-3: |
| self.scale = float(np.clip(scale0 * (span / span0), 0.35, 2.6)) |
| else: |
| self._grab_span = None |
| else: |
| self._grab = None |
| self._grab_span = None |
| self.yaw += 0.25 * dt |
|
|
| palm = any(s.gesture == Gesture.OPEN_PALM for s in states) |
| if not palm: |
| self._palm_armed = True |
| elif grabbing: |
| self._palm_armed = False |
| elif self._palm_armed: |
| self.exit_3d() |
|
|
| def assign_slots(self, hands, dt: float = 1 / 30) -> list: |
| """Bind hands to slots.""" |
| n = len(self.recognizers) |
| slots: list = [None] * n |
| free = list(range(n)) |
| for hand in hands[:n]: |
| c = hand.center |
| best, best_d = None, float("inf") |
| for s in free: |
| prev = self._slot_centers[s] |
| d = float(np.linalg.norm(c - prev)) if prev is not None else 1e6 |
| if d < best_d: |
| best, best_d = s, d |
| if best is None: |
| break |
| slots[best] = hand |
| free.remove(best) |
|
|
| result = [] |
| now = time.time() |
| for i in range(n): |
| hand = slots[i] |
| if hand is None: |
| self._slot_centers[i] = None |
| self._kp_filters[i].reset() |
| self.recognizers[i](None) |
| self._wave[i].reset() |
| self._wave_until[i] = 0.0 |
| self._palm_seen[i] = 0.0 |
| continue |
| hand.keypoints = self._kp_filters[i](hand.keypoints, dt) |
| self._slot_centers[i] = hand.center |
| state = self.recognizers[i](hand) |
| self.track_wave(i, hand, state, now) |
| result.append((hand, state)) |
| return result |
|
|
| def track_wave(self, slot: int, hand, state, now: float) -> None: |
| """Follow one waving hand.""" |
| if state.gesture == Gesture.OPEN_PALM: |
| self._palm_seen[slot] = now |
| if now - self._palm_seen[slot] > WAVE_PALM_GRACE: |
| self._wave[slot].reset() |
| self._wave_until[slot] = 0.0 |
| return |
| amplitude = hand.scale * WAVE_AMPLITUDE_RATIO |
| if self._wave[slot].update(float(hand.center[0]), amplitude, now): |
| self._wave_until[slot] = now + WAVE_MEMORY_SECONDS |
|
|
| def handle_goodbye(self) -> None: |
| """Quit on two waving hands.""" |
| if self._bye_at is not None: |
| return |
| now = time.time() |
| if sum(1 for until in self._wave_until if now < until) >= 2: |
| self._bye_at = now + BYE_SECONDS |
| self.toast("Bye :)", BYE_SECONDS) |
|
|
| @staticmethod |
| def active_state(states: list): |
| """Pick the acting hand.""" |
| if not states: |
| return None |
| for wanted in (Gesture.DRAW, Gesture.GRAB, Gesture.MOVE): |
| for s in states: |
| if s.gesture == wanted: |
| return s |
| return max(states, key=lambda s: s.confidence) |
|
|
| def render(self, frame: np.ndarray, hands, states) -> np.ndarray: |
| """Compose the output frame.""" |
| if self.mode_3d and self.object3d is not None: |
| out = cv2.convertScaleAbs(frame, alpha=0.32) |
| out = self.object3d.render( |
| out, |
| yaw=self.yaw_s(self.yaw), |
| pitch=self.pitch_s(self.pitch), |
| roll=self.roll, |
| scale=self.scale_s(self.scale), |
| ) |
| else: |
| out = self.canvas.composite_over(frame) |
|
|
| if self.show_skeleton: |
| for hand, st in zip(hands, states): |
| ui.draw_hand(out, hand, st) |
| active = self.active_state(states) |
| if not self.mode_3d and active is not None: |
| ui.draw_cursor(out, active.cursor, active.gesture, |
| self.canvas.color, self.canvas.thickness) |
|
|
| progress = 0.0 |
| if self.grab_since is not None: |
| progress = (time.time() - self.grab_since) / GRAB_HOLD_SECONDS |
|
|
| shown = Gesture.NONE if active is None else active.gesture |
| if not self.mode_3d and len([s for s in states if s.gesture == Gesture.GRAB]) >= 2: |
| shown = Gesture.ZOOM |
| ui.draw_hud( |
| out, |
| gesture=shown, |
| hands_info=[(s.handedness, s.gesture) for s in states], |
| mode_3d=self.mode_3d, |
| fps=self.fps, |
| device=self.device, |
| color=self.canvas.color, |
| thickness=self.canvas.thickness, |
| strokes=len(self.canvas.strokes), |
| clear_progress=progress, |
| toast=self.toast_text if time.time() < self.toast_until else "", |
| ) |
| if self.show_debug and states: |
| ui.draw_debug(out, hands, states) |
| if self.show_help: |
| ui.draw_help(out) |
| return out |
|
|
| def handle_key(self, key: int) -> bool: |
| """Handle one keypress.""" |
| if key in (ord("q"), 27): |
| return False |
| if key == ord("s"): |
| if self.canvas.is_empty: |
| self.toast("Canvas is empty") |
| else: |
| paths = self.canvas.save() |
| print("[+] saved:", flush=True) |
| for p in paths: |
| print(" ", p, flush=True) |
| self.toast(f"Saved {paths[0].name}") |
| elif key == ord("c"): |
| self.clear_canvas() |
| elif key == ord("u"): |
| self.canvas.undo() |
| self.toast("Undo last stroke") |
| elif key in (ord("["), ord("-"), ord(",")): |
| self.canvas.set_thickness(self.canvas.thickness - 2) |
| self.toast(f"Size {self.canvas.thickness}") |
| elif key in (ord("]"), ord("="), ord("+"), ord(".")): |
| self.canvas.set_thickness(self.canvas.thickness + 2) |
| self.toast(f"Size {self.canvas.thickness}") |
| elif key == ord("r"): |
| self.yaw, self.pitch, self.scale = 0.4, -0.25, 1.0 |
| self.yaw_s.set(self.yaw) |
| self.pitch_s.set(self.pitch) |
| self.scale_s.set(1.0) |
| elif key == ord("h"): |
| self.show_help = not self.show_help |
| elif key == ord("k"): |
| self.show_skeleton = not self.show_skeleton |
| elif key == ord("d"): |
| self.show_debug = not self.show_debug |
| self.toast("Debug " + ("on" if self.show_debug else "off")) |
| elif key == ord(" "): |
| if self.mode_3d: |
| self.exit_3d() |
| else: |
| self.enter_3d() |
| elif ord("1") <= key <= ord("6"): |
| self.canvas.set_color(key - ord("1")) |
| self.toast(f"Color {COLOR_NAMES[(key - ord('1')) % len(COLOR_NAMES)]}") |
| return True |
|
|
| def run(self) -> None: |
| """Run the main loop.""" |
| cv2.namedWindow(WINDOW, cv2.WINDOW_NORMAL) |
| cv2.resizeWindow(WINDOW, self.w, self.h) |
| print("[i] window open. H help, Q quit.", flush=True) |
| self.toast("One finger to draw. H for help") |
|
|
| reason = "loop finished" |
| frames = 0 |
| seq = -1 |
| while True: |
| frame, seq = self.cam.wait_next(seq) |
| if self.cam.failed: |
| reason = "camera stopped delivering frames" |
| break |
| if not self.args.no_mirror: |
| frame = cv2.flip(frame, 1) |
| if frame.shape[:2] != (self.h, self.w): |
| frame = cv2.resize(frame, (self.w, self.h)) |
|
|
| now = time.perf_counter() |
| dt = max(now - self._last_t, 1e-4) |
| self._last_t = now |
| self.fps += 0.12 * (1.0 / dt - self.fps) |
|
|
| self.on_frame(frame) |
| detected = self.tracker(frame) |
| paired = self.assign_slots(detected, dt) |
| hands = [h for h, _ in paired] |
| states = [s for _, s in paired] |
|
|
| self.handle_ok(states) |
| self.handle_middle(states) |
| self.handle_pinky(states) |
| self.handle_goodbye() |
|
|
| if self.mode_3d: |
| self.handle_3d_mode(states, dt) |
| else: |
| self.handle_draw_mode(states, dt) |
|
|
| out = self.render(frame, hands, states) |
| self._last_render = out |
| cv2.imshow(WINDOW, out) |
|
|
| key = cv2.waitKey(1) & 0xFF |
| frames += 1 |
| if frames % 150 == 0: |
| print(f"[i] frames: {frames} {self.fps:.1f} FPS hands: {len(hands)}", flush=True) |
|
|
| if key != 255 and not self.handle_key(key): |
| reason = f"quit key pressed ({key})" |
| break |
| if cv2.getWindowProperty(WINDOW, cv2.WND_PROP_VISIBLE) < 1: |
| reason = "window closed" |
| break |
| if self._bye_at is not None and time.time() >= self._bye_at: |
| reason = "goodbye wave" |
| break |
|
|
| print(f"[i] stopped: {reason} (frames processed: {frames})", flush=True) |
| self.cam.release() |
| cv2.destroyAllWindows() |
|
|
|
|
| def build_app(args: argparse.Namespace): |
| """Create the app for a mode.""" |
| if args.mode == "3d": |
| from app3d import App3D |
|
|
| return App3D(args) |
| if args.mode == "poses": |
| from apppose import AppPose |
|
|
| return AppPose(args) |
| return App(args) |
|
|
|
|
| def main() -> int: |
| """Program entry point.""" |
| args = parse_args() |
| if not args.no_menu: |
| mode = Menu().run() |
| if mode is None: |
| print("[i] no mode selected", flush=True) |
| return 0 |
| args.mode = mode |
| try: |
| build_app(args).run() |
| except (FileNotFoundError, RuntimeError, ImportError) as exc: |
| print(f"[!] {exc}", file=sys.stderr) |
| return 1 |
| except KeyboardInterrupt: |
| print("\n[i] stopped by user") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|