Files
planet/motion_agent/recognizer.py
2026-05-10 22:06:01 +08:00

119 lines
3.5 KiB
Python

"""Gesture recognizer interfaces.
The production recognizer is intentionally thin in this skeleton. It validates
that optional CV dependencies are available and keeps the protocol independent
from any specific model implementation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from .cameras import MotionAgentDependencyError
from .events import GestureName, SkeletonEvent, SkeletonJoint, now_ms
@dataclass(frozen=True)
class GestureObservation:
gesture: GestureName
confidence: float
intensity: float = 1.0
timestamp_ms: int | None = None
class GestureRecognizer(Protocol):
name: str
def recognize(self, frame: Any) -> GestureObservation | None:
"""Return a gesture observation for the current frame."""
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
"""Return normalized skeleton debug data when available."""
class MediaPipeGestureRecognizer:
name = "mediapipe-opencv"
def __init__(self) -> None:
try:
import cv2 # noqa: F401
import mediapipe # noqa: F401
except ImportError as exc:
raise MotionAgentDependencyError(
"MediaPipe and OpenCV are required for live gesture recognition. "
"Add mediapipe and opencv-python with uv, or use --dry-run for protocol testing."
) from exc
def recognize(self, frame: Any) -> GestureObservation | None:
_ = frame
return None
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
_ = frame, camera_id, mode, matched_gesture, confidence
return None
class NullGestureRecognizer:
name = "dry-run"
def recognize(self, frame: Any) -> GestureObservation | None:
_ = frame
return None
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
_ = frame
now = now_ms()
sway = ((now // 250) % 6 - 2.5) * 0.015
joints = [
SkeletonJoint("head", 0.5, 0.18, 1.0),
SkeletonJoint("neck", 0.5, 0.3, 1.0),
SkeletonJoint("left_shoulder", 0.38, 0.34, 1.0),
SkeletonJoint("right_shoulder", 0.62, 0.34, 1.0),
SkeletonJoint("left_elbow", 0.31 + sway, 0.48, 0.95),
SkeletonJoint("right_elbow", 0.69 - sway, 0.48, 0.95),
SkeletonJoint("left_wrist", 0.24 + sway, 0.62, 0.9),
SkeletonJoint("right_wrist", 0.76 - sway, 0.62, 0.9),
]
bones = [
("head", "neck"),
("neck", "left_shoulder"),
("neck", "right_shoulder"),
("left_shoulder", "left_elbow"),
("left_elbow", "left_wrist"),
("right_shoulder", "right_elbow"),
("right_elbow", "right_wrist"),
]
return SkeletonEvent(
joints=joints,
bones=bones,
matched_gesture=matched_gesture,
confidence=confidence,
camera_id=camera_id,
mode=mode,
)