Files
rerun-io--rerun/examples/python/gesture_detection/gesture_detection.py
T
2026-07-13 13:05:14 +08:00

347 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Use the MediaPipe Gesture detection and Gesture landmark detection solutions to track hands and recognize gestures in images and videos."""
from __future__ import annotations
import argparse
import itertools
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Final
import cv2
import mediapipe as mp
import numpy as np
import requests
import tqdm
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
import rerun as rr # pip install rerun-sdk
import rerun.blueprint as rrb
if TYPE_CHECKING:
from collections.abc import Iterable
from mediapipe.tasks.python.components.containers import NormalizedLandmark
EXAMPLE_DIR: Final = Path(os.path.dirname(__file__))
DATASET_DIR: Final = EXAMPLE_DIR / "dataset" / "hand_gestures"
SAMPLE_IMAGE_PATH = EXAMPLE_DIR / "dataset" / "hand_gestures" / "victory.jpg"
# More samples: 'thumbs_down.jpg', 'victory.jpg', 'pointing_up.jpg', 'thumbs_up.jpg'
SAMPLE_IMAGE_URL = "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/victory.jpg"
SAMPLE_VIDEO_PATH = EXAMPLE_DIR / "dataset" / "hand_gestures" / "peace.mp4"
SAMPLE_VIDEO_URL = "https://storage.googleapis.com/rerun-example-datasets/hand_gestures/peace.mp4"
# Emojis from https://github.com/googlefonts/noto-emoji/tree/main
GESTURE_URL = (
"https://raw.githubusercontent.com/googlefonts/noto-emoji/9cde38ef5ee6f090ce23f9035e494cb390a2b051/png/128/"
)
# Mapping of gesture categories to corresponding emojis
GESTURE_PICTURES = {
"None": "emoji_u2754.png",
"Closed_Fist": "emoji_u270a.png",
"Open_Palm": "emoji_u270b.png",
"Pointing_Up": "emoji_u261d.png",
"Thumb_Down": "emoji_u1f44e.png",
"Thumb_Up": "emoji_u1f44d.png",
"Victory": "emoji_u270c.png",
"ILoveYou": "emoji_u1f91f.png",
}
class GestureDetectorLogger:
"""
Logger for the MediaPipe Gesture Detection solution.
This class provides logging and utility functions for handling gesture recognition.
For more information on MediaPipe Gesture Detection:
<https://developers.google.com/mediapipe/solutions/vision/gesture_recognizer>
"""
# URL to the pre-trained MediaPipe Gesture Detection model
MODEL_DIR: Final = EXAMPLE_DIR / "model"
MODEL_PATH: Final = (MODEL_DIR / "gesture_recognizer.task").resolve()
MODEL_URL: Final = "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/latest/gesture_recognizer.task"
def __init__(self, video_mode: bool = False) -> None:
self._video_mode = video_mode
if not self.MODEL_PATH.exists():
download_file(self.MODEL_URL, self.MODEL_PATH)
base_options = python.BaseOptions(model_asset_path=str(self.MODEL_PATH))
options = vision.GestureRecognizerOptions(
base_options=base_options,
running_mode=mp.tasks.vision.RunningMode.VIDEO if self._video_mode else mp.tasks.vision.RunningMode.IMAGE,
)
self.recognizer = vision.GestureRecognizer.create_from_options(options)
rr.log(
"/",
rr.AnnotationContext(
rr.ClassDescription(
info=rr.AnnotationInfo(id=0, label="Hand3D"),
keypoint_connections=mp.solutions.hands.HAND_CONNECTIONS,
),
),
static=True,
)
rr.log("hand3d", rr.ViewCoordinates.LEFT_HAND_Y_DOWN, static=True)
@staticmethod
def convert_landmarks_to_image_coordinates(
hand_landmarks: list[list[NormalizedLandmark]],
width: int,
height: int,
) -> list[tuple[int, int]]:
return [(int(lm.x * width), int(lm.y * height)) for hand_landmark in hand_landmarks for lm in hand_landmark]
@staticmethod
def convert_landmarks_to_3d(hand_landmarks: list[list[NormalizedLandmark]]) -> list[tuple[float, float, float]]:
return [(lm.x, lm.y, lm.z) for hand_landmark in hand_landmarks for lm in hand_landmark]
def detect_and_log(self, image: cv2.typing.MatLike, frame_time_nano: int) -> None:
# Recognize gestures in the image
height, width, _ = image.shape
image = mp.Image(image_format=mp.ImageFormat.SRGB, data=image)
recognition_result = (
self.recognizer.recognize_for_video(image, int(frame_time_nano / 1e6))
if self._video_mode
else self.recognizer.recognize(image)
)
for log_key in ["hand2d/points", "hand2d/connections", "hand3d/points"]:
rr.log(log_key, rr.Clear(recursive=True))
for gesture in recognition_result.gestures:
# Get the top gesture from the recognition result
gesture_category = gesture[0].category_name if recognition_result.gestures else "None"
self.present_detected_gesture(gesture_category) # Log the detected gesture
if recognition_result.hand_landmarks:
hand_landmarks = recognition_result.hand_landmarks
landmark_positions_3d = self.convert_landmarks_to_3d(hand_landmarks)
if landmark_positions_3d is not None:
rr.log(
"hand3d/points",
rr.Points3D(
landmark_positions_3d,
radii=20,
class_ids=0,
keypoint_ids=list(range(len(landmark_positions_3d))),
),
)
# Convert normalized coordinates to image coordinates
points = self.convert_landmarks_to_image_coordinates(hand_landmarks, width, height)
# Log points to the image and Hand Entity
rr.log("hand2d/points", rr.Points2D(points, radii=10, colors=[255, 0, 0]))
# Obtain hand connections from MediaPipe
mp_hands_connections = mp.solutions.hands.HAND_CONNECTIONS
points1 = [points[connection[0]] for connection in mp_hands_connections]
points2 = [points[connection[1]] for connection in mp_hands_connections]
# Log connections to the image and Hand Entity [128, 128, 128]
rr.log("hand2d/connections", rr.LineStrips2D(np.stack((points1, points2), axis=1), colors=[255, 165, 0]))
def present_detected_gesture(self, category: str) -> None:
# Get the corresponding ulr of the picture for the detected gesture category
gesture_pic = GESTURE_PICTURES.get(
category,
"emoji_u2754.png", # default
)
# Log the detection by using the appropriate image
rr.log(
"detection",
rr.TextDocument(f"![Image]({GESTURE_URL + gesture_pic})".strip(), media_type=rr.MediaType.MARKDOWN),
)
def download_file(url: str, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
logging.info("Downloading %s to %s", url, path)
response = requests.get(url, stream=True)
with tqdm.tqdm.wrapattr(
open(path, "wb"),
"write",
miniters=1,
total=int(response.headers.get("content-length", 0)),
desc=f"Downloading {path.name}",
) as f:
for chunk in response.iter_content(chunk_size=4096):
f.write(chunk)
def resize_image(image: cv2.typing.MatLike, max_dim: int | None) -> cv2.typing.MatLike:
"""Resize an image if it is larger than max_dim."""
if max_dim is None:
return image
height, width, _ = image.shape
scale = max_dim / max(height, width)
if scale < 1:
image = cv2.resize(image, (0, 0), fx=scale, fy=scale)
return image
def run_from_sample_image(path: Path | str) -> None:
"""Run the gesture recognition on a single image."""
image = cv2.imread(str(path))
# image = resize_image(image, max_dim)
rr.log("media/image", rr.Image(image, color_model="BGR"))
detect_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
logger = GestureDetectorLogger(video_mode=False)
logger.detect_and_log(detect_image, 0)
def run_from_video_capture(vid: int | str, max_frame_count: int | None) -> None:
"""
Run the detector on a video stream.
Parameters
----------
vid:
The video stream to run the detector on. Use 0/1 for the default camera or a path to a video file.
max_frame_count:
The maximum number of frames to process. If None, process all frames.
"""
cap = cv2.VideoCapture(vid)
fps = cap.get(cv2.CAP_PROP_FPS)
detector = GestureDetectorLogger(video_mode=True)
try:
it: Iterable[int] = itertools.count() if max_frame_count is None else range(max_frame_count)
for frame_idx in tqdm.tqdm(it, desc="Processing frames"):
# Capture frame-by-frame
ret, frame = cap.read()
if not ret:
break
# OpenCV sometimes returns a blank frame, so we skip it
if np.all(frame == 0):
continue
# frame = resize_image(frame, max_dim)
# get frame time
frame_time_nano = int(cap.get(cv2.CAP_PROP_POS_MSEC) * 1e6)
if frame_time_nano == 0:
# On some platforms it always returns zero, so we compute from the frame counter and fps
frame_time_nano = int(frame_idx * 1000 / fps * 1e6)
# log data
rr.set_time("frame_nr", sequence=frame_idx)
rr.set_time("frame_time", duration=1e-9 * frame_time_nano)
detector.detect_and_log(frame, frame_time_nano)
rr.log("media/video", rr.Image(frame, color_model="BGR").compress(jpeg_quality=75))
except KeyboardInterrupt:
pass
# When everything done, release the capture
cap.release()
cv2.destroyAllWindows()
def main() -> None:
# Ensure the logging gets written to stderr
logging.getLogger().addHandler(logging.StreamHandler())
logging.getLogger().setLevel("INFO")
# Set up argument parser with description
parser = argparse.ArgumentParser(
description="Uses the MediaPipe Gesture Recognition to track a hand and recognize gestures in image or video.",
)
parser.add_argument(
"--demo-image",
action="store_true",
help="Run on a demo image automatically downloaded",
)
parser.add_argument("--demo-video", action="store_true", help="Run on a demo image automatically downloaded.")
parser.add_argument(
"--image",
type=Path,
help="Run on the provided image",
)
parser.add_argument("--video", type=Path, help="Run on the provided video file.")
parser.add_argument(
"--camera",
type=int,
default=0,
help="Run from the camera stream (parameter is the camera ID, usually 0; or maybe 1 on mac)",
)
parser.add_argument(
"--max-frame",
type=int,
help="Stop after processing this many frames. If not specified, will run until interrupted.",
)
# Add Rerun specific arguments
rr.script_add_args(parser)
# Parse command line arguments
args, unknown = parser.parse_known_args()
for arg in unknown: # Log any unknown arguments
logging.warning(f"unknown arg: {arg}")
# Set up Rerun with script name
rr.script_setup(
args,
"rerun_example_mp_gesture_recognition",
default_blueprint=rrb.Horizontal(
rrb.Spatial2DView(name="Input & Hand", contents=["media/**", "hand2d/**"]),
rrb.Vertical(
rrb.Tabs(
rrb.Spatial3DView(name="Hand 3D", origin="hand3d"),
rrb.Spatial2DView(name="Hand 2D", origin="hand2d"),
),
rrb.TextDocumentView(name="Detection", origin="detection"),
row_shares=[3, 2],
),
column_shares=[3, 1],
),
)
# Choose the appropriate run mode based on provided arguments
if args.demo_image:
if not SAMPLE_IMAGE_PATH.exists():
download_file(SAMPLE_IMAGE_URL, SAMPLE_IMAGE_PATH)
run_from_sample_image(SAMPLE_IMAGE_PATH)
elif args.demo_video:
if not SAMPLE_VIDEO_PATH.exists():
download_file(SAMPLE_VIDEO_URL, SAMPLE_VIDEO_PATH)
run_from_video_capture(str(SAMPLE_VIDEO_PATH), args.max_frame)
elif args.image:
run_from_sample_image(args.image)
elif args.video:
run_from_video_capture(args.video, args.max_frame)
elif args.camera:
run_from_video_capture(int(args.camera), args.max_frame)
else:
if not SAMPLE_VIDEO_PATH.exists():
download_file(SAMPLE_VIDEO_URL, SAMPLE_VIDEO_PATH)
run_from_video_capture(str(SAMPLE_VIDEO_PATH), args.max_frame)
# Tear down Rerun script
rr.script_teardown(args)
if __name__ == "__main__":
main()