Spaces:
Running
Running
Commit ·
24836e5
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .coverage +0 -0
- README.md +15 -0
- debug_gesture.py +202 -0
- index.html +19 -0
- pyproject.toml +46 -0
- rock_paper_scissors.egg-info/PKG-INFO +31 -0
- rock_paper_scissors.egg-info/SOURCES.txt +223 -0
- rock_paper_scissors.egg-info/dependency_links.txt +1 -0
- rock_paper_scissors.egg-info/entry_points.txt +2 -0
- rock_paper_scissors.egg-info/requires.txt +10 -0
- rock_paper_scissors.egg-info/top_level.txt +3 -0
- rock_paper_scissors/__init__.py +1 -0
- rock_paper_scissors/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/__pycache__/main.cpython-311.pyc +0 -0
- rock_paper_scissors/audio/__init__.py +8 -0
- rock_paper_scissors/audio/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/audio/__pycache__/sound_manager.cpython-311.pyc +0 -0
- rock_paper_scissors/audio/__pycache__/sounds.cpython-311.pyc +0 -0
- rock_paper_scissors/audio/sound_manager.py +209 -0
- rock_paper_scissors/audio/sounds.py +89 -0
- rock_paper_scissors/config/__init__.py +3 -0
- rock_paper_scissors/config/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/config/__pycache__/settings.cpython-311.pyc +0 -0
- rock_paper_scissors/config/settings.py +126 -0
- rock_paper_scissors/detection/__init__.py +14 -0
- rock_paper_scissors/detection/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/__pycache__/gesture_detector.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/__pycache__/hand_detector.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/__pycache__/motion_detector.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/__pycache__/status.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/__pycache__/wave_detector.cpython-311.pyc +0 -0
- rock_paper_scissors/detection/gesture_detector.py +376 -0
- rock_paper_scissors/detection/hand_detector.py +131 -0
- rock_paper_scissors/detection/motion_detector.py +73 -0
- rock_paper_scissors/detection/status.py +38 -0
- rock_paper_scissors/detection/wave_detector.py +189 -0
- rock_paper_scissors/game/__init__.py +12 -0
- rock_paper_scissors/game/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/game/__pycache__/game_logic.cpython-311.pyc +0 -0
- rock_paper_scissors/game/__pycache__/state_machine.cpython-311.pyc +0 -0
- rock_paper_scissors/game/__pycache__/states.cpython-311.pyc +0 -0
- rock_paper_scissors/game/game_logic.py +39 -0
- rock_paper_scissors/game/state_machine.py +157 -0
- rock_paper_scissors/game/states.py +28 -0
- rock_paper_scissors/main.py +319 -0
- rock_paper_scissors/robot/__init__.py +9 -0
- rock_paper_scissors/robot/__pycache__/__init__.cpython-311.pyc +0 -0
- rock_paper_scissors/robot/__pycache__/animations.cpython-311.pyc +0 -0
- rock_paper_scissors/robot/__pycache__/antenna_poses.cpython-311.pyc +0 -0
- rock_paper_scissors/robot/__pycache__/head_poses.cpython-311.pyc +0 -0
.coverage
ADDED
|
Binary file (53.2 kB). View file
|
|
|
README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rock Paper Scissors
|
| 3 |
+
emoji: ✊✌️🖐️
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: An intuitive rock-paper-scissors game you play with Reachy Mini.
|
| 9 |
+
tags:
|
| 10 |
+
- reachy_mini
|
| 11 |
+
- reachy_mini_python_app
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
debug_gesture.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""ジェスチャー認識のデバッグスクリプト
|
| 3 |
+
|
| 4 |
+
カメラから手を検出し、各指の状態と判定結果をリアルタイムで表示します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
from rock_paper_scissors.detection import HandDetector, GestureDetector
|
| 10 |
+
from rock_paper_scissors.detection.gesture_detector import (
|
| 11 |
+
LandmarkIndex,
|
| 12 |
+
FINGER_EXTENDED_ANGLE_THRESHOLD,
|
| 13 |
+
FINGER_CURLED_ANGLE_THRESHOLD,
|
| 14 |
+
)
|
| 15 |
+
from rock_paper_scissors.game.states import Hand
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def calculate_finger_angle(landmarks: np.ndarray, mcp_idx: int, pip_idx: int, dip_idx: int) -> float:
|
| 19 |
+
"""指の曲がり角度を計算"""
|
| 20 |
+
mcp = landmarks[mcp_idx][:2]
|
| 21 |
+
pip = landmarks[pip_idx][:2]
|
| 22 |
+
dip = landmarks[dip_idx][:2]
|
| 23 |
+
|
| 24 |
+
v1 = mcp - pip
|
| 25 |
+
v2 = dip - pip
|
| 26 |
+
|
| 27 |
+
norm1 = np.linalg.norm(v1)
|
| 28 |
+
norm2 = np.linalg.norm(v2)
|
| 29 |
+
if norm1 < 1e-10 or norm2 < 1e-10:
|
| 30 |
+
return 180.0
|
| 31 |
+
|
| 32 |
+
cos_angle = np.dot(v1, v2) / (norm1 * norm2)
|
| 33 |
+
angle = np.arccos(np.clip(cos_angle, -1.0, 1.0))
|
| 34 |
+
return np.degrees(angle)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_finger_debug_info(landmarks: np.ndarray) -> dict:
|
| 38 |
+
"""各指の詳細情報を取得"""
|
| 39 |
+
info = {}
|
| 40 |
+
|
| 41 |
+
# 各指の情報
|
| 42 |
+
fingers = {
|
| 43 |
+
"index": (LandmarkIndex.INDEX_MCP, LandmarkIndex.INDEX_PIP, LandmarkIndex.INDEX_DIP, LandmarkIndex.INDEX_TIP),
|
| 44 |
+
"middle": (LandmarkIndex.MIDDLE_MCP, LandmarkIndex.MIDDLE_PIP, LandmarkIndex.MIDDLE_DIP, LandmarkIndex.MIDDLE_TIP),
|
| 45 |
+
"ring": (LandmarkIndex.RING_MCP, LandmarkIndex.RING_PIP, LandmarkIndex.RING_DIP, LandmarkIndex.RING_TIP),
|
| 46 |
+
"pinky": (LandmarkIndex.PINKY_MCP, LandmarkIndex.PINKY_PIP, LandmarkIndex.PINKY_DIP, LandmarkIndex.PINKY_TIP),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
for name, (mcp_idx, pip_idx, dip_idx, tip_idx) in fingers.items():
|
| 50 |
+
tip = landmarks[tip_idx]
|
| 51 |
+
pip = landmarks[pip_idx]
|
| 52 |
+
mcp = landmarks[mcp_idx]
|
| 53 |
+
|
| 54 |
+
# 距離計算
|
| 55 |
+
tip_to_mcp = np.linalg.norm(tip[:2] - mcp[:2])
|
| 56 |
+
pip_to_mcp = np.linalg.norm(pip[:2] - mcp[:2])
|
| 57 |
+
ratio = tip_to_mcp / pip_to_mcp if pip_to_mcp > 0 else 0
|
| 58 |
+
|
| 59 |
+
# 角度計算
|
| 60 |
+
angle = calculate_finger_angle(landmarks, mcp_idx, pip_idx, dip_idx)
|
| 61 |
+
|
| 62 |
+
# Y座標の比較
|
| 63 |
+
tip_above_pip = tip[1] < pip[1]
|
| 64 |
+
|
| 65 |
+
# 判定
|
| 66 |
+
distance_extended = tip_above_pip and ratio > 0.9
|
| 67 |
+
angle_curled = angle < FINGER_CURLED_ANGLE_THRESHOLD
|
| 68 |
+
|
| 69 |
+
# 最終判定(距離ベースで伸びていると判定されても、角度が小さければ閉じている)
|
| 70 |
+
is_extended = distance_extended and not angle_curled
|
| 71 |
+
|
| 72 |
+
info[name] = {
|
| 73 |
+
"tip_y": tip[1],
|
| 74 |
+
"pip_y": pip[1],
|
| 75 |
+
"tip_above_pip": tip_above_pip,
|
| 76 |
+
"distance_ratio": ratio,
|
| 77 |
+
"angle": angle,
|
| 78 |
+
"distance_extended": distance_extended,
|
| 79 |
+
"angle_curled": angle_curled,
|
| 80 |
+
"is_extended": is_extended,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# 親指
|
| 84 |
+
thumb_tip = landmarks[LandmarkIndex.THUMB_TIP]
|
| 85 |
+
thumb_ip = landmarks[LandmarkIndex.THUMB_IP]
|
| 86 |
+
thumb_mcp = landmarks[LandmarkIndex.THUMB_MCP]
|
| 87 |
+
tip_to_mcp = np.linalg.norm(thumb_tip[:2] - thumb_mcp[:2])
|
| 88 |
+
ip_to_mcp = np.linalg.norm(thumb_ip[:2] - thumb_mcp[:2])
|
| 89 |
+
thumb_extended = tip_to_mcp > ip_to_mcp * 1.2
|
| 90 |
+
|
| 91 |
+
info["thumb"] = {
|
| 92 |
+
"tip_to_mcp": tip_to_mcp,
|
| 93 |
+
"ip_to_mcp": ip_to_mcp,
|
| 94 |
+
"ratio": tip_to_mcp / ip_to_mcp if ip_to_mcp > 0 else 0,
|
| 95 |
+
"is_extended": thumb_extended,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return info
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def main():
|
| 102 |
+
print("=" * 60)
|
| 103 |
+
print("ジェスチャー認識デバッグツール")
|
| 104 |
+
print("=" * 60)
|
| 105 |
+
print(f"角度閾値: 伸びている >= {FINGER_EXTENDED_ANGLE_THRESHOLD}°, 曲がっている < {FINGER_CURLED_ANGLE_THRESHOLD}°")
|
| 106 |
+
print("距離閾値: 0.9 (tip_to_mcp / pip_to_mcp)")
|
| 107 |
+
print("'q' で終了")
|
| 108 |
+
print("=" * 60)
|
| 109 |
+
|
| 110 |
+
hand_detector = HandDetector()
|
| 111 |
+
gesture_detector = GestureDetector()
|
| 112 |
+
|
| 113 |
+
cap = cv2.VideoCapture(0)
|
| 114 |
+
if not cap.isOpened():
|
| 115 |
+
print("カメラを開けませんでした")
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
while True:
|
| 120 |
+
ret, frame = cap.read()
|
| 121 |
+
if not ret:
|
| 122 |
+
break
|
| 123 |
+
|
| 124 |
+
# 左右反転(鏡像)
|
| 125 |
+
frame = cv2.flip(frame, 1)
|
| 126 |
+
|
| 127 |
+
# 手を検出
|
| 128 |
+
hand_data = hand_detector.detect(frame)
|
| 129 |
+
|
| 130 |
+
if hand_data is not None:
|
| 131 |
+
landmarks = hand_data.landmarks
|
| 132 |
+
|
| 133 |
+
# ジェスチャー判定
|
| 134 |
+
gesture, confidence = gesture_detector.detect(landmarks)
|
| 135 |
+
|
| 136 |
+
# デバッグ情報を取得
|
| 137 |
+
debug_info = get_finger_debug_info(landmarks)
|
| 138 |
+
|
| 139 |
+
# 画面に表示
|
| 140 |
+
y_offset = 30
|
| 141 |
+
|
| 142 |
+
# ジェスチャー結果
|
| 143 |
+
gesture_text = f"Gesture: {gesture.value} (conf: {confidence:.2f})"
|
| 144 |
+
color = (0, 255, 0) if gesture != Hand.UNKNOWN else (0, 0, 255)
|
| 145 |
+
cv2.putText(frame, gesture_text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
|
| 146 |
+
y_offset += 35
|
| 147 |
+
|
| 148 |
+
# 拡張カウント
|
| 149 |
+
extended_count = sum(1 for name in ["thumb", "index", "middle", "ring", "pinky"]
|
| 150 |
+
if debug_info[name]["is_extended"])
|
| 151 |
+
cv2.putText(frame, f"Extended fingers: {extended_count}", (10, y_offset),
|
| 152 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
| 153 |
+
y_offset += 25
|
| 154 |
+
|
| 155 |
+
# 各指の情報
|
| 156 |
+
for finger_name in ["thumb", "index", "middle", "ring", "pinky"]:
|
| 157 |
+
info = debug_info[finger_name]
|
| 158 |
+
|
| 159 |
+
if finger_name == "thumb":
|
| 160 |
+
text = f"{finger_name}: ext={info['is_extended']} (ratio={info['ratio']:.2f})"
|
| 161 |
+
else:
|
| 162 |
+
text = f"{finger_name}: ext={info['is_extended']} (ratio={info['distance_ratio']:.2f}, angle={info['angle']:.0f}°)"
|
| 163 |
+
|
| 164 |
+
color = (0, 255, 0) if info["is_extended"] else (0, 0, 255)
|
| 165 |
+
cv2.putText(frame, text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
|
| 166 |
+
y_offset += 20
|
| 167 |
+
|
| 168 |
+
# ランドマークを描画
|
| 169 |
+
for i, lm in enumerate(landmarks):
|
| 170 |
+
x = int(lm[0] * frame.shape[1])
|
| 171 |
+
y = int(lm[1] * frame.shape[0])
|
| 172 |
+
cv2.circle(frame, (x, y), 3, (0, 255, 255), -1)
|
| 173 |
+
cv2.putText(frame, str(i), (x + 5, y), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1)
|
| 174 |
+
|
| 175 |
+
# コンソールにも出力(1秒に1回)
|
| 176 |
+
import time
|
| 177 |
+
if not hasattr(main, 'last_print') or time.time() - main.last_print > 1.0:
|
| 178 |
+
main.last_print = time.time()
|
| 179 |
+
print(f"\n--- {gesture.value} (conf: {confidence:.2f}) ---")
|
| 180 |
+
for finger_name in ["thumb", "index", "middle", "ring", "pinky"]:
|
| 181 |
+
info = debug_info[finger_name]
|
| 182 |
+
ext_str = "○" if info["is_extended"] else "×"
|
| 183 |
+
if finger_name == "thumb":
|
| 184 |
+
print(f" {finger_name}: {ext_str} (ratio={info['ratio']:.2f})")
|
| 185 |
+
else:
|
| 186 |
+
print(f" {finger_name}: {ext_str} (ratio={info['distance_ratio']:.2f}, angle={info['angle']:.0f}°, tip_above={info['tip_above_pip']})")
|
| 187 |
+
else:
|
| 188 |
+
cv2.putText(frame, "No hand detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
|
| 189 |
+
|
| 190 |
+
cv2.imshow("Gesture Debug", frame)
|
| 191 |
+
|
| 192 |
+
if cv2.waitKey(1) & 0xFF == ord('q'):
|
| 193 |
+
break
|
| 194 |
+
|
| 195 |
+
finally:
|
| 196 |
+
cap.release()
|
| 197 |
+
cv2.destroyAllWindows()
|
| 198 |
+
hand_detector.close()
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
main()
|
index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width" />
|
| 6 |
+
<title>My static Space</title>
|
| 7 |
+
<link rel="stylesheet" href="style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="card">
|
| 11 |
+
<h1>Welcome to your static Space!</h1>
|
| 12 |
+
<p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
|
| 13 |
+
<p>
|
| 14 |
+
Also don't forget to check the
|
| 15 |
+
<a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
|
| 16 |
+
</p>
|
| 17 |
+
</div>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
[project]
|
| 7 |
+
name = "rock_paper_scissors"
|
| 8 |
+
version = "0.1.0"
|
| 9 |
+
description = "Rock Paper Scissors game for Reachy Mini - gesture-based interaction for children"
|
| 10 |
+
readme = "README.md"
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"reachy-mini",
|
| 14 |
+
"mediapipe>=0.10.0",
|
| 15 |
+
"numpy>=1.24.0",
|
| 16 |
+
]
|
| 17 |
+
keywords = ["reachy-mini-app"]
|
| 18 |
+
|
| 19 |
+
[project.optional-dependencies]
|
| 20 |
+
dev = [
|
| 21 |
+
"pytest>=7.0.0",
|
| 22 |
+
"pytest-cov>=4.0.0",
|
| 23 |
+
]
|
| 24 |
+
audio = [
|
| 25 |
+
"ttastromech>=0.1.0",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
[project.entry-points."reachy_mini_apps"]
|
| 29 |
+
rock_paper_scissors = "rock_paper_scissors.main:RockPaperScissors"
|
| 30 |
+
|
| 31 |
+
[tool.setuptools]
|
| 32 |
+
package-dir = { "" = "." }
|
| 33 |
+
include-package-data = true
|
| 34 |
+
|
| 35 |
+
[tool.setuptools.packages.find]
|
| 36 |
+
where = ["."]
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.package-data]
|
| 39 |
+
rock_paper_scissors = ["**/*"]
|
| 40 |
+
|
| 41 |
+
[tool.pytest.ini_options]
|
| 42 |
+
testpaths = ["tests"]
|
| 43 |
+
python_files = ["test_*.py"]
|
| 44 |
+
python_classes = ["Test*"]
|
| 45 |
+
python_functions = ["test_*"]
|
| 46 |
+
addopts = "-v --cov=rock_paper_scissors --cov-report=term-missing"
|
rock_paper_scissors.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: rock_paper_scissors
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Rock Paper Scissors game for Reachy Mini - gesture-based interaction for children
|
| 5 |
+
Keywords: reachy-mini-app
|
| 6 |
+
Requires-Python: >=3.10
|
| 7 |
+
Description-Content-Type: text/markdown
|
| 8 |
+
Requires-Dist: reachy-mini
|
| 9 |
+
Requires-Dist: mediapipe>=0.10.0
|
| 10 |
+
Requires-Dist: numpy>=1.24.0
|
| 11 |
+
Provides-Extra: dev
|
| 12 |
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
| 13 |
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
| 14 |
+
Provides-Extra: audio
|
| 15 |
+
Requires-Dist: ttastromech>=0.1.0; extra == "audio"
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
title: Rock Paper Scissors
|
| 19 |
+
emoji: ✊✌️🖐️
|
| 20 |
+
colorFrom: pink
|
| 21 |
+
colorTo: pink
|
| 22 |
+
sdk: static
|
| 23 |
+
pinned: false
|
| 24 |
+
short_description: An intuitive rock-paper-scissors game you play with Reachy Mini.
|
| 25 |
+
tags:
|
| 26 |
+
- reachy_mini
|
| 27 |
+
- reachy_mini_python_app
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
rock_paper_scissors.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/__init__.py
|
| 4 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/main.py
|
| 5 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/audio/__init__.py
|
| 6 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/audio/sound_manager.py
|
| 7 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/audio/sounds.py
|
| 8 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/config/__init__.py
|
| 9 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/config/settings.py
|
| 10 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/__init__.py
|
| 11 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/gesture_detector.py
|
| 12 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/hand_detector.py
|
| 13 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/motion_detector.py
|
| 14 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/status.py
|
| 15 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/detection/wave_detector.py
|
| 16 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/game/__init__.py
|
| 17 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/game/game_logic.py
|
| 18 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/game/state_machine.py
|
| 19 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/game/states.py
|
| 20 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/robot/__init__.py
|
| 21 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/robot/animations.py
|
| 22 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/robot/antenna_poses.py
|
| 23 |
+
./build/lib/build/lib/build/lib/rock_paper_scissors/robot/head_poses.py
|
| 24 |
+
./build/lib/build/lib/build/lib/tests/__init__.py
|
| 25 |
+
./build/lib/build/lib/build/lib/tests/test_animation_sound_sync.py
|
| 26 |
+
./build/lib/build/lib/build/lib/tests/test_animation_speed.py
|
| 27 |
+
./build/lib/build/lib/build/lib/tests/test_game_logic.py
|
| 28 |
+
./build/lib/build/lib/build/lib/tests/test_gesture_detector.py
|
| 29 |
+
./build/lib/build/lib/build/lib/tests/test_hand_detection_status.py
|
| 30 |
+
./build/lib/build/lib/build/lib/tests/test_model_path.py
|
| 31 |
+
./build/lib/build/lib/build/lib/tests/test_sad_animation.py
|
| 32 |
+
./build/lib/build/lib/build/lib/tests/test_sound_manager.py
|
| 33 |
+
./build/lib/build/lib/build/lib/tests/test_state_machine.py
|
| 34 |
+
./build/lib/build/lib/build/lib/tests/test_ui_language.py
|
| 35 |
+
./build/lib/build/lib/build/lib/tests/test_wave_detector.py
|
| 36 |
+
./build/lib/build/lib/rock_paper_scissors/__init__.py
|
| 37 |
+
./build/lib/build/lib/rock_paper_scissors/main.py
|
| 38 |
+
./build/lib/build/lib/rock_paper_scissors/audio/__init__.py
|
| 39 |
+
./build/lib/build/lib/rock_paper_scissors/audio/sound_manager.py
|
| 40 |
+
./build/lib/build/lib/rock_paper_scissors/audio/sounds.py
|
| 41 |
+
./build/lib/build/lib/rock_paper_scissors/config/__init__.py
|
| 42 |
+
./build/lib/build/lib/rock_paper_scissors/config/settings.py
|
| 43 |
+
./build/lib/build/lib/rock_paper_scissors/detection/__init__.py
|
| 44 |
+
./build/lib/build/lib/rock_paper_scissors/detection/gesture_detector.py
|
| 45 |
+
./build/lib/build/lib/rock_paper_scissors/detection/hand_detector.py
|
| 46 |
+
./build/lib/build/lib/rock_paper_scissors/detection/motion_detector.py
|
| 47 |
+
./build/lib/build/lib/rock_paper_scissors/detection/status.py
|
| 48 |
+
./build/lib/build/lib/rock_paper_scissors/detection/wave_detector.py
|
| 49 |
+
./build/lib/build/lib/rock_paper_scissors/game/__init__.py
|
| 50 |
+
./build/lib/build/lib/rock_paper_scissors/game/game_logic.py
|
| 51 |
+
./build/lib/build/lib/rock_paper_scissors/game/state_machine.py
|
| 52 |
+
./build/lib/build/lib/rock_paper_scissors/game/states.py
|
| 53 |
+
./build/lib/build/lib/rock_paper_scissors/robot/__init__.py
|
| 54 |
+
./build/lib/build/lib/rock_paper_scissors/robot/animations.py
|
| 55 |
+
./build/lib/build/lib/rock_paper_scissors/robot/antenna_poses.py
|
| 56 |
+
./build/lib/build/lib/rock_paper_scissors/robot/head_poses.py
|
| 57 |
+
./build/lib/build/lib/tests/__init__.py
|
| 58 |
+
./build/lib/build/lib/tests/test_animation_sound_sync.py
|
| 59 |
+
./build/lib/build/lib/tests/test_animation_speed.py
|
| 60 |
+
./build/lib/build/lib/tests/test_game_logic.py
|
| 61 |
+
./build/lib/build/lib/tests/test_gesture_detector.py
|
| 62 |
+
./build/lib/build/lib/tests/test_hand_detection_status.py
|
| 63 |
+
./build/lib/build/lib/tests/test_model_path.py
|
| 64 |
+
./build/lib/build/lib/tests/test_sad_animation.py
|
| 65 |
+
./build/lib/build/lib/tests/test_sound_manager.py
|
| 66 |
+
./build/lib/build/lib/tests/test_state_machine.py
|
| 67 |
+
./build/lib/build/lib/tests/test_ui_language.py
|
| 68 |
+
./build/lib/build/lib/tests/test_wave_detector.py
|
| 69 |
+
./build/lib/rock_paper_scissors/__init__.py
|
| 70 |
+
./build/lib/rock_paper_scissors/main.py
|
| 71 |
+
./build/lib/rock_paper_scissors/audio/__init__.py
|
| 72 |
+
./build/lib/rock_paper_scissors/audio/sound_manager.py
|
| 73 |
+
./build/lib/rock_paper_scissors/audio/sounds.py
|
| 74 |
+
./build/lib/rock_paper_scissors/config/__init__.py
|
| 75 |
+
./build/lib/rock_paper_scissors/config/settings.py
|
| 76 |
+
./build/lib/rock_paper_scissors/detection/__init__.py
|
| 77 |
+
./build/lib/rock_paper_scissors/detection/gesture_detector.py
|
| 78 |
+
./build/lib/rock_paper_scissors/detection/hand_detector.py
|
| 79 |
+
./build/lib/rock_paper_scissors/detection/motion_detector.py
|
| 80 |
+
./build/lib/rock_paper_scissors/detection/status.py
|
| 81 |
+
./build/lib/rock_paper_scissors/detection/wave_detector.py
|
| 82 |
+
./build/lib/rock_paper_scissors/game/__init__.py
|
| 83 |
+
./build/lib/rock_paper_scissors/game/game_logic.py
|
| 84 |
+
./build/lib/rock_paper_scissors/game/state_machine.py
|
| 85 |
+
./build/lib/rock_paper_scissors/game/states.py
|
| 86 |
+
./build/lib/rock_paper_scissors/robot/__init__.py
|
| 87 |
+
./build/lib/rock_paper_scissors/robot/animations.py
|
| 88 |
+
./build/lib/rock_paper_scissors/robot/antenna_poses.py
|
| 89 |
+
./build/lib/rock_paper_scissors/robot/head_poses.py
|
| 90 |
+
./build/lib/tests/__init__.py
|
| 91 |
+
./build/lib/tests/test_animation_sound_sync.py
|
| 92 |
+
./build/lib/tests/test_animation_speed.py
|
| 93 |
+
./build/lib/tests/test_game_logic.py
|
| 94 |
+
./build/lib/tests/test_gesture_detector.py
|
| 95 |
+
./build/lib/tests/test_hand_detection_status.py
|
| 96 |
+
./build/lib/tests/test_model_path.py
|
| 97 |
+
./build/lib/tests/test_sad_animation.py
|
| 98 |
+
./build/lib/tests/test_sound_manager.py
|
| 99 |
+
./build/lib/tests/test_state_machine.py
|
| 100 |
+
./build/lib/tests/test_ui_language.py
|
| 101 |
+
./build/lib/tests/test_wave_detector.py
|
| 102 |
+
./rock_paper_scissors/__init__.py
|
| 103 |
+
./rock_paper_scissors/main.py
|
| 104 |
+
./rock_paper_scissors/__pycache__/__init__.cpython-311.pyc
|
| 105 |
+
./rock_paper_scissors/__pycache__/main.cpython-311.pyc
|
| 106 |
+
./rock_paper_scissors/audio/__init__.py
|
| 107 |
+
./rock_paper_scissors/audio/sound_manager.py
|
| 108 |
+
./rock_paper_scissors/audio/sounds.py
|
| 109 |
+
./rock_paper_scissors/audio/__pycache__/__init__.cpython-311.pyc
|
| 110 |
+
./rock_paper_scissors/audio/__pycache__/sound_manager.cpython-311.pyc
|
| 111 |
+
./rock_paper_scissors/audio/__pycache__/sounds.cpython-311.pyc
|
| 112 |
+
./rock_paper_scissors/config/__init__.py
|
| 113 |
+
./rock_paper_scissors/config/settings.py
|
| 114 |
+
./rock_paper_scissors/config/__pycache__/__init__.cpython-311.pyc
|
| 115 |
+
./rock_paper_scissors/config/__pycache__/settings.cpython-311.pyc
|
| 116 |
+
./rock_paper_scissors/detection/__init__.py
|
| 117 |
+
./rock_paper_scissors/detection/gesture_detector.py
|
| 118 |
+
./rock_paper_scissors/detection/hand_detector.py
|
| 119 |
+
./rock_paper_scissors/detection/motion_detector.py
|
| 120 |
+
./rock_paper_scissors/detection/status.py
|
| 121 |
+
./rock_paper_scissors/detection/wave_detector.py
|
| 122 |
+
./rock_paper_scissors/detection/__pycache__/__init__.cpython-311.pyc
|
| 123 |
+
./rock_paper_scissors/detection/__pycache__/gesture_detector.cpython-311.pyc
|
| 124 |
+
./rock_paper_scissors/detection/__pycache__/hand_detector.cpython-311.pyc
|
| 125 |
+
./rock_paper_scissors/detection/__pycache__/motion_detector.cpython-311.pyc
|
| 126 |
+
./rock_paper_scissors/detection/__pycache__/status.cpython-311.pyc
|
| 127 |
+
./rock_paper_scissors/detection/__pycache__/wave_detector.cpython-311.pyc
|
| 128 |
+
./rock_paper_scissors/game/__init__.py
|
| 129 |
+
./rock_paper_scissors/game/game_logic.py
|
| 130 |
+
./rock_paper_scissors/game/state_machine.py
|
| 131 |
+
./rock_paper_scissors/game/states.py
|
| 132 |
+
./rock_paper_scissors/game/__pycache__/__init__.cpython-311.pyc
|
| 133 |
+
./rock_paper_scissors/game/__pycache__/game_logic.cpython-311.pyc
|
| 134 |
+
./rock_paper_scissors/game/__pycache__/state_machine.cpython-311.pyc
|
| 135 |
+
./rock_paper_scissors/game/__pycache__/states.cpython-311.pyc
|
| 136 |
+
./rock_paper_scissors/models/hand_landmarker.task
|
| 137 |
+
./rock_paper_scissors/robot/__init__.py
|
| 138 |
+
./rock_paper_scissors/robot/animations.py
|
| 139 |
+
./rock_paper_scissors/robot/antenna_poses.py
|
| 140 |
+
./rock_paper_scissors/robot/head_poses.py
|
| 141 |
+
./rock_paper_scissors/robot/__pycache__/__init__.cpython-311.pyc
|
| 142 |
+
./rock_paper_scissors/robot/__pycache__/animations.cpython-311.pyc
|
| 143 |
+
./rock_paper_scissors/robot/__pycache__/antenna_poses.cpython-311.pyc
|
| 144 |
+
./rock_paper_scissors/robot/__pycache__/head_poses.cpython-311.pyc
|
| 145 |
+
./rock_paper_scissors/static/index.html
|
| 146 |
+
./rock_paper_scissors/static/main.js
|
| 147 |
+
./rock_paper_scissors/static/style.css
|
| 148 |
+
./tests/__init__.py
|
| 149 |
+
./tests/test_animation_sound_sync.py
|
| 150 |
+
./tests/test_animation_speed.py
|
| 151 |
+
./tests/test_game_logic.py
|
| 152 |
+
./tests/test_gesture_detector.py
|
| 153 |
+
./tests/test_hand_detection_status.py
|
| 154 |
+
./tests/test_model_path.py
|
| 155 |
+
./tests/test_sad_animation.py
|
| 156 |
+
./tests/test_sound_manager.py
|
| 157 |
+
./tests/test_state_machine.py
|
| 158 |
+
./tests/test_ui_language.py
|
| 159 |
+
./tests/test_wave_detector.py
|
| 160 |
+
rock_paper_scissors/__init__.py
|
| 161 |
+
rock_paper_scissors/main.py
|
| 162 |
+
rock_paper_scissors.egg-info/PKG-INFO
|
| 163 |
+
rock_paper_scissors.egg-info/SOURCES.txt
|
| 164 |
+
rock_paper_scissors.egg-info/dependency_links.txt
|
| 165 |
+
rock_paper_scissors.egg-info/entry_points.txt
|
| 166 |
+
rock_paper_scissors.egg-info/requires.txt
|
| 167 |
+
rock_paper_scissors.egg-info/top_level.txt
|
| 168 |
+
rock_paper_scissors/__pycache__/__init__.cpython-311.pyc
|
| 169 |
+
rock_paper_scissors/__pycache__/main.cpython-311.pyc
|
| 170 |
+
rock_paper_scissors/audio/__init__.py
|
| 171 |
+
rock_paper_scissors/audio/sound_manager.py
|
| 172 |
+
rock_paper_scissors/audio/sounds.py
|
| 173 |
+
rock_paper_scissors/audio/__pycache__/__init__.cpython-311.pyc
|
| 174 |
+
rock_paper_scissors/audio/__pycache__/sound_manager.cpython-311.pyc
|
| 175 |
+
rock_paper_scissors/audio/__pycache__/sounds.cpython-311.pyc
|
| 176 |
+
rock_paper_scissors/config/__init__.py
|
| 177 |
+
rock_paper_scissors/config/settings.py
|
| 178 |
+
rock_paper_scissors/config/__pycache__/__init__.cpython-311.pyc
|
| 179 |
+
rock_paper_scissors/config/__pycache__/settings.cpython-311.pyc
|
| 180 |
+
rock_paper_scissors/detection/__init__.py
|
| 181 |
+
rock_paper_scissors/detection/gesture_detector.py
|
| 182 |
+
rock_paper_scissors/detection/hand_detector.py
|
| 183 |
+
rock_paper_scissors/detection/motion_detector.py
|
| 184 |
+
rock_paper_scissors/detection/status.py
|
| 185 |
+
rock_paper_scissors/detection/wave_detector.py
|
| 186 |
+
rock_paper_scissors/detection/__pycache__/__init__.cpython-311.pyc
|
| 187 |
+
rock_paper_scissors/detection/__pycache__/gesture_detector.cpython-311.pyc
|
| 188 |
+
rock_paper_scissors/detection/__pycache__/hand_detector.cpython-311.pyc
|
| 189 |
+
rock_paper_scissors/detection/__pycache__/motion_detector.cpython-311.pyc
|
| 190 |
+
rock_paper_scissors/detection/__pycache__/status.cpython-311.pyc
|
| 191 |
+
rock_paper_scissors/detection/__pycache__/wave_detector.cpython-311.pyc
|
| 192 |
+
rock_paper_scissors/game/__init__.py
|
| 193 |
+
rock_paper_scissors/game/game_logic.py
|
| 194 |
+
rock_paper_scissors/game/state_machine.py
|
| 195 |
+
rock_paper_scissors/game/states.py
|
| 196 |
+
rock_paper_scissors/game/__pycache__/__init__.cpython-311.pyc
|
| 197 |
+
rock_paper_scissors/game/__pycache__/game_logic.cpython-311.pyc
|
| 198 |
+
rock_paper_scissors/game/__pycache__/state_machine.cpython-311.pyc
|
| 199 |
+
rock_paper_scissors/game/__pycache__/states.cpython-311.pyc
|
| 200 |
+
rock_paper_scissors/models/hand_landmarker.task
|
| 201 |
+
rock_paper_scissors/robot/__init__.py
|
| 202 |
+
rock_paper_scissors/robot/animations.py
|
| 203 |
+
rock_paper_scissors/robot/antenna_poses.py
|
| 204 |
+
rock_paper_scissors/robot/head_poses.py
|
| 205 |
+
rock_paper_scissors/robot/__pycache__/__init__.cpython-311.pyc
|
| 206 |
+
rock_paper_scissors/robot/__pycache__/animations.cpython-311.pyc
|
| 207 |
+
rock_paper_scissors/robot/__pycache__/antenna_poses.cpython-311.pyc
|
| 208 |
+
rock_paper_scissors/robot/__pycache__/head_poses.cpython-311.pyc
|
| 209 |
+
rock_paper_scissors/static/index.html
|
| 210 |
+
rock_paper_scissors/static/main.js
|
| 211 |
+
rock_paper_scissors/static/style.css
|
| 212 |
+
tests/__init__.py
|
| 213 |
+
tests/test_animation_sound_sync.py
|
| 214 |
+
tests/test_animation_speed.py
|
| 215 |
+
tests/test_game_logic.py
|
| 216 |
+
tests/test_gesture_detector.py
|
| 217 |
+
tests/test_hand_detection_status.py
|
| 218 |
+
tests/test_model_path.py
|
| 219 |
+
tests/test_sad_animation.py
|
| 220 |
+
tests/test_sound_manager.py
|
| 221 |
+
tests/test_state_machine.py
|
| 222 |
+
tests/test_ui_language.py
|
| 223 |
+
tests/test_wave_detector.py
|
rock_paper_scissors.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
rock_paper_scissors.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[reachy_mini_apps]
|
| 2 |
+
rock_paper_scissors = rock_paper_scissors.main:RockPaperScissors
|
rock_paper_scissors.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
reachy-mini
|
| 2 |
+
mediapipe>=0.10.0
|
| 3 |
+
numpy>=1.24.0
|
| 4 |
+
|
| 5 |
+
[audio]
|
| 6 |
+
ttastromech>=0.1.0
|
| 7 |
+
|
| 8 |
+
[dev]
|
| 9 |
+
pytest>=7.0.0
|
| 10 |
+
pytest-cov>=4.0.0
|
rock_paper_scissors.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
build
|
| 2 |
+
rock_paper_scissors
|
| 3 |
+
tests
|
rock_paper_scissors/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Rock Paper Scissors Game for Reachy Mini
|
rock_paper_scissors/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (211 Bytes). View file
|
|
|
rock_paper_scissors/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
rock_paper_scissors/audio/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .sounds import SoundPatterns, SoundType
|
| 2 |
+
from .sound_manager import SoundManager
|
| 3 |
+
|
| 4 |
+
__all__ = [
|
| 5 |
+
"SoundPatterns",
|
| 6 |
+
"SoundType",
|
| 7 |
+
"SoundManager",
|
| 8 |
+
]
|
rock_paper_scissors/audio/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (416 Bytes). View file
|
|
|
rock_paper_scissors/audio/__pycache__/sound_manager.cpython-311.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
rock_paper_scissors/audio/__pycache__/sounds.cpython-311.pyc
ADDED
|
Binary file (3.15 kB). View file
|
|
|
rock_paper_scissors/audio/sound_manager.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""音声管理モジュール"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
import threading
|
| 6 |
+
import wave
|
| 7 |
+
from typing import Protocol, Optional, TYPE_CHECKING
|
| 8 |
+
|
| 9 |
+
from .sounds import SoundType, SoundPatterns
|
| 10 |
+
|
| 11 |
+
# ttastromechのインポートを試みる
|
| 12 |
+
try:
|
| 13 |
+
from ttastromech import TTAstromech
|
| 14 |
+
TTASTROMECH_AVAILABLE = True
|
| 15 |
+
except ImportError:
|
| 16 |
+
TTASTROMECH_AVAILABLE = False
|
| 17 |
+
TTAstromech = None # type: ignore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class MediaProtocol(Protocol):
|
| 21 |
+
"""Reachy Mini のメディアインターフェース"""
|
| 22 |
+
|
| 23 |
+
def play_sound(self, sound_file: str) -> None:
|
| 24 |
+
...
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SoundManager:
|
| 28 |
+
"""
|
| 29 |
+
音声の生成と再生を管理するクラス
|
| 30 |
+
|
| 31 |
+
ttastromechが利用可能な場合はそれを使用し、
|
| 32 |
+
利用不可能な場合はフォールバック(無音またはWAVファイル)を使用する。
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, cache_dir: Optional[str] = None, volume: float = 1.0):
|
| 36 |
+
"""
|
| 37 |
+
Args:
|
| 38 |
+
cache_dir: 生成した音声ファイルのキャッシュディレクトリ
|
| 39 |
+
volume: 音量(0.0〜1.0)
|
| 40 |
+
"""
|
| 41 |
+
self._cache_dir = cache_dir or tempfile.gettempdir()
|
| 42 |
+
self._cache: dict[SoundType, str] = {}
|
| 43 |
+
self._media: Optional[MediaProtocol] = None
|
| 44 |
+
self._lock = threading.Lock()
|
| 45 |
+
self._r2: Optional["TTAstromech"] = None
|
| 46 |
+
self._wav_params: Optional[wave._wave_params] = None
|
| 47 |
+
self._volume = max(0.0, min(1.0, volume)) # 0.0〜1.0に制限
|
| 48 |
+
|
| 49 |
+
# 起動時にttastromechを初期化して音声ファイルを生成
|
| 50 |
+
if TTASTROMECH_AVAILABLE:
|
| 51 |
+
self._init_ttastromech()
|
| 52 |
+
self._pregenerate_sounds()
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def volume(self) -> float:
|
| 56 |
+
"""音量(0.0〜1.0)"""
|
| 57 |
+
return self._volume
|
| 58 |
+
|
| 59 |
+
@volume.setter
|
| 60 |
+
def volume(self, value: float):
|
| 61 |
+
"""音量を設定(0.0〜1.0に制限)"""
|
| 62 |
+
self._volume = max(0.0, min(1.0, value))
|
| 63 |
+
|
| 64 |
+
def _init_ttastromech(self):
|
| 65 |
+
"""ttastromechを初期化してWAVパラメータを取得"""
|
| 66 |
+
if not TTASTROMECH_AVAILABLE:
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
self._r2 = TTAstromech()
|
| 70 |
+
|
| 71 |
+
# サンプルWAVファイルからパラメータを取得
|
| 72 |
+
sample_wav = self._r2.root.format('a')
|
| 73 |
+
with wave.open(sample_wav, 'rb') as wf:
|
| 74 |
+
self._wav_params = wf.getparams()
|
| 75 |
+
|
| 76 |
+
def set_media(self, media: MediaProtocol):
|
| 77 |
+
"""Reachy Mini のメディアインターフェースを設定"""
|
| 78 |
+
self._media = media
|
| 79 |
+
|
| 80 |
+
def _pregenerate_sounds(self):
|
| 81 |
+
"""全ての音声パターンを事前生成"""
|
| 82 |
+
for sound_type in SoundType:
|
| 83 |
+
self._generate_sound(sound_type)
|
| 84 |
+
|
| 85 |
+
def _text_to_audio_data(self, text: str) -> bytes:
|
| 86 |
+
"""
|
| 87 |
+
テキストからttastromechの音声バイトデータを生成
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
text: 変換するテキスト
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
音声バイトデータ
|
| 94 |
+
"""
|
| 95 |
+
if self._r2 is None:
|
| 96 |
+
return b''
|
| 97 |
+
|
| 98 |
+
audio_chunks = []
|
| 99 |
+
for char in text.lower():
|
| 100 |
+
if char in self._r2.syllabary:
|
| 101 |
+
chunk = self._r2.generate(char)
|
| 102 |
+
audio_chunks.append(chunk)
|
| 103 |
+
|
| 104 |
+
raw_audio = b''.join(audio_chunks)
|
| 105 |
+
|
| 106 |
+
# 音量調整(1.0以外の場合)
|
| 107 |
+
if self._volume < 1.0 and raw_audio:
|
| 108 |
+
raw_audio = self._apply_volume(raw_audio)
|
| 109 |
+
|
| 110 |
+
return raw_audio
|
| 111 |
+
|
| 112 |
+
def _apply_volume(self, audio_data: bytes) -> bytes:
|
| 113 |
+
"""
|
| 114 |
+
音声データに音量を適用
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
audio_data: 元の音声データ(16bit PCM)
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
音量調整後の音声データ
|
| 121 |
+
"""
|
| 122 |
+
import struct
|
| 123 |
+
|
| 124 |
+
# 16bit (2bytes) のサンプルとして処理
|
| 125 |
+
num_samples = len(audio_data) // 2
|
| 126 |
+
samples = struct.unpack(f'<{num_samples}h', audio_data)
|
| 127 |
+
|
| 128 |
+
# 音量を適用
|
| 129 |
+
adjusted = [int(s * self._volume) for s in samples]
|
| 130 |
+
|
| 131 |
+
# クリッピング防止(int16の範囲内に収める)
|
| 132 |
+
adjusted = [max(-32768, min(32767, s)) for s in adjusted]
|
| 133 |
+
|
| 134 |
+
return struct.pack(f'<{num_samples}h', *adjusted)
|
| 135 |
+
|
| 136 |
+
def _generate_sound(self, sound_type: SoundType) -> Optional[str]:
|
| 137 |
+
"""
|
| 138 |
+
ttastromechで音声ファイルを生成
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
sound_type: 音声タイプ
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
生成されたファイルパス、失敗時はNone
|
| 145 |
+
"""
|
| 146 |
+
if not TTASTROMECH_AVAILABLE or self._r2 is None or self._wav_params is None:
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
pattern = SoundPatterns.get(sound_type)
|
| 150 |
+
file_path = os.path.join(
|
| 151 |
+
self._cache_dir,
|
| 152 |
+
f"rps_{sound_type.name.lower()}.wav"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
# テキストから音声データを生成
|
| 157 |
+
audio_data = self._text_to_audio_data(pattern.text)
|
| 158 |
+
|
| 159 |
+
if not audio_data:
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
# WAVファイルとして保存
|
| 163 |
+
with wave.open(file_path, 'wb') as wf:
|
| 164 |
+
wf.setparams(self._wav_params)
|
| 165 |
+
wf.writeframes(audio_data)
|
| 166 |
+
|
| 167 |
+
self._cache[sound_type] = file_path
|
| 168 |
+
return file_path
|
| 169 |
+
except Exception as e:
|
| 170 |
+
print(f"Warning: Failed to generate sound for {sound_type}: {e}")
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
def play(self, sound_type: SoundType):
|
| 174 |
+
"""
|
| 175 |
+
音声を再生
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
sound_type: 再生する音声タイプ
|
| 179 |
+
"""
|
| 180 |
+
if self._media is None:
|
| 181 |
+
return
|
| 182 |
+
|
| 183 |
+
with self._lock:
|
| 184 |
+
file_path = self._cache.get(sound_type)
|
| 185 |
+
|
| 186 |
+
if file_path is None and TTASTROMECH_AVAILABLE:
|
| 187 |
+
file_path = self._generate_sound(sound_type)
|
| 188 |
+
|
| 189 |
+
if file_path and os.path.exists(file_path):
|
| 190 |
+
try:
|
| 191 |
+
self._media.play_sound(file_path)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
print(f"Warning: Failed to play sound: {e}")
|
| 194 |
+
|
| 195 |
+
def play_async(self, sound_type: SoundType):
|
| 196 |
+
"""
|
| 197 |
+
非同期で音声を再生
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
sound_type: 再生する音声タイプ
|
| 201 |
+
"""
|
| 202 |
+
thread = threading.Thread(target=self.play, args=(sound_type,))
|
| 203 |
+
thread.daemon = True
|
| 204 |
+
thread.start()
|
| 205 |
+
|
| 206 |
+
@property
|
| 207 |
+
def is_available(self) -> bool:
|
| 208 |
+
"""ttastromechが利用可能かどうか"""
|
| 209 |
+
return TTASTROMECH_AVAILABLE
|
rock_paper_scissors/audio/sounds.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""音声パターンの定義"""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from enum import Enum, auto
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SoundType(Enum):
|
| 8 |
+
"""音声の種類"""
|
| 9 |
+
IDLE = auto() # 待機時の短い音
|
| 10 |
+
HELLO = auto() # 呼びかけ
|
| 11 |
+
SUCCESS = auto() # 認識成功(上昇音)
|
| 12 |
+
COUNTDOWN = auto() # カウントダウン
|
| 13 |
+
WIN = auto() # 勝ち
|
| 14 |
+
LOSE = auto() # 負け
|
| 15 |
+
DRAW = auto() # あいこ
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class SoundPattern:
|
| 20 |
+
"""音声パターンの設定"""
|
| 21 |
+
text: str # ttastromechに渡すテキスト(意味は不要)
|
| 22 |
+
description: str # 説明(デバッグ用)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SoundPatterns:
|
| 26 |
+
"""
|
| 27 |
+
音声パターンの定義
|
| 28 |
+
|
| 29 |
+
ttastromechは任意のテキストからR2-D2風の機械音を生成する。
|
| 30 |
+
短い文字: e,f,d,k,r,o,h,g,i,n (~72-82ms)
|
| 31 |
+
長い文字: a,b,c (~585-662ms)
|
| 32 |
+
動きと同期させるため最短の文字を使用。
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
# 短い音(IDLE用)- 1音 (~72ms)
|
| 36 |
+
IDLE = SoundPattern(
|
| 37 |
+
text="e",
|
| 38 |
+
description="短い機械音"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# 呼びかけ(HELLO用)- 2音 (~150ms)
|
| 42 |
+
HELLO = SoundPattern(
|
| 43 |
+
text="de",
|
| 44 |
+
description="明るめの短いフレーズ"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# 認識成功 - 1音 (~75ms)
|
| 48 |
+
SUCCESS = SoundPattern(
|
| 49 |
+
text="d",
|
| 50 |
+
description="上昇音"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# カウントダウン - 1音 (~76ms)
|
| 54 |
+
COUNTDOWN = SoundPattern(
|
| 55 |
+
text="k",
|
| 56 |
+
description="クリック音"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# 勝ち - 2音 (~150ms)
|
| 60 |
+
WIN = SoundPattern(
|
| 61 |
+
text="fd",
|
| 62 |
+
description="高音ビープ"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# 負け - 1音 (~77ms)
|
| 66 |
+
LOSE = SoundPattern(
|
| 67 |
+
text="o",
|
| 68 |
+
description="低音"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# あいこ - 2音 (~150ms)
|
| 72 |
+
DRAW = SoundPattern(
|
| 73 |
+
text="eh",
|
| 74 |
+
description="コミカルな短音"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
@classmethod
|
| 78 |
+
def get(cls, sound_type: SoundType) -> SoundPattern:
|
| 79 |
+
"""音声タイプから対応するパターンを取得"""
|
| 80 |
+
mapping = {
|
| 81 |
+
SoundType.IDLE: cls.IDLE,
|
| 82 |
+
SoundType.HELLO: cls.HELLO,
|
| 83 |
+
SoundType.SUCCESS: cls.SUCCESS,
|
| 84 |
+
SoundType.COUNTDOWN: cls.COUNTDOWN,
|
| 85 |
+
SoundType.WIN: cls.WIN,
|
| 86 |
+
SoundType.LOSE: cls.LOSE,
|
| 87 |
+
SoundType.DRAW: cls.DRAW,
|
| 88 |
+
}
|
| 89 |
+
return mapping.get(sound_type, cls.IDLE)
|
rock_paper_scissors/config/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .settings import Settings, settings
|
| 2 |
+
|
| 3 |
+
__all__ = ["Settings", "settings"]
|
rock_paper_scissors/config/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (323 Bytes). View file
|
|
|
rock_paper_scissors/config/__pycache__/settings.cpython-311.pyc
ADDED
|
Binary file (6.2 kB). View file
|
|
|
rock_paper_scissors/config/settings.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""設定値の一元管理モジュール"""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class AntennaSettings:
|
| 9 |
+
"""アンテナの角度設定(ラジアン)"""
|
| 10 |
+
# じゃんけんの手
|
| 11 |
+
rock: tuple[float, float] = (0.5, -0.5) # グー: 内側に閉じる
|
| 12 |
+
scissors: tuple[float, float] = (-0.5, 1.5) # チョキ: 非対称
|
| 13 |
+
paper: tuple[float, float] = (-2.0, 2.0) # パー: 大きく開く
|
| 14 |
+
|
| 15 |
+
# カウントダウン
|
| 16 |
+
countdown_3: tuple[float, float] = (-1.0, 1.0) # 両方上
|
| 17 |
+
countdown_2: tuple[float, float] = (-1.0, 0.0) # 右だけ上
|
| 18 |
+
countdown_1: tuple[float, float] = (0.0, 0.0) # 両方下(溜め)
|
| 19 |
+
|
| 20 |
+
# その他
|
| 21 |
+
neutral: tuple[float, float] = (0.0, 0.0)
|
| 22 |
+
open_wide: tuple[float, float] = (-2.0, 2.0) # HELLO用
|
| 23 |
+
|
| 24 |
+
# 呼吸表現
|
| 25 |
+
breathing_amplitude: float = 0.2 # ラジアン
|
| 26 |
+
breathing_frequency: float = 0.3 # Hz(通常速度)
|
| 27 |
+
|
| 28 |
+
# 手振り誘導
|
| 29 |
+
wave_amplitude: float = 0.5 # ラジアン
|
| 30 |
+
wave_frequency: float = 0.1 # Hz(とてもゆっくり、元の1/5)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class HeadSettings:
|
| 35 |
+
"""頭のポーズ設定(度)"""
|
| 36 |
+
# IDLE
|
| 37 |
+
idle_yaw_amplitude: float = 10.0 # 左右の振れ幅
|
| 38 |
+
idle_yaw_frequency: float = 0.1 # Hz
|
| 39 |
+
|
| 40 |
+
# うなずき
|
| 41 |
+
nod_pitch: float = -15.0 # 下向き
|
| 42 |
+
|
| 43 |
+
# しょんぼり(負け)- 公式サンプルは7-9秒の非常にゆっくりした動き
|
| 44 |
+
# 注意: Reachy Miniではpitchがプラスで下向き
|
| 45 |
+
sad_pitch: float = 25.0 # より深くうつむく(プラスで下向き)
|
| 46 |
+
sad_duration: float = 1.5 # 悲しい状態の持続時間(秒)
|
| 47 |
+
sad_shake_amplitude: float = 3.0 # 悲しい首振りの振幅(度)- 小さく控えめ
|
| 48 |
+
sad_shake_frequency: float = 0.15 # 悲しい首振りの周波数(Hz)- 非常にゆっくり
|
| 49 |
+
|
| 50 |
+
# 首傾げ(あいこ)
|
| 51 |
+
tilt_roll: float = 15.0
|
| 52 |
+
|
| 53 |
+
# 勝ち(左右シェイク)
|
| 54 |
+
shake_amplitude: float = 8.0
|
| 55 |
+
shake_frequency: float = 1.2 # Hz(ゆっくりシェイク)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass(frozen=True)
|
| 59 |
+
class DetectionSettings:
|
| 60 |
+
"""検出関連の設定"""
|
| 61 |
+
# 手を振る検出
|
| 62 |
+
wave_min_x_displacement: float = 0.08 # 画面幅に対する割合(緩和: 0.15→0.08)
|
| 63 |
+
wave_min_cycles: int = 1 # 最小往復回数
|
| 64 |
+
wave_min_frames: int = 3 # 最小連続フレーム数(緩和: 5→3)
|
| 65 |
+
wave_timeout_seconds: float = 2.0 # 検出タイムアウト(緩和: 1.0→2.0秒)
|
| 66 |
+
|
| 67 |
+
# 動き検出(IDLE→HELLO)
|
| 68 |
+
motion_threshold: float = 0.05 # フレーム差分の閾値
|
| 69 |
+
motion_min_frames: int = 3 # 最小連続フレーム数
|
| 70 |
+
|
| 71 |
+
# ジェスチャ検出
|
| 72 |
+
gesture_confidence_threshold: float = 0.6 # 信頼度閾値(UNKNOWNを減らすため下げる)
|
| 73 |
+
|
| 74 |
+
# MediaPipe設定
|
| 75 |
+
max_num_hands: int = 1
|
| 76 |
+
min_detection_confidence: float = 0.3 # グー(拳)検出のためさらに低く設定
|
| 77 |
+
min_tracking_confidence: float = 0.3 # トラッキング
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass(frozen=True)
|
| 81 |
+
class TimingSettings:
|
| 82 |
+
"""タイミング設定(秒)"""
|
| 83 |
+
# 状態遷移
|
| 84 |
+
hello_duration: float = 1.5 # HELLO動作の長さ
|
| 85 |
+
ready_timeout: float = 10.0 # READY状態のタイムアウト
|
| 86 |
+
countdown_interval: float = 0.5 # カウントダウン各ステップ
|
| 87 |
+
countdown_pause: float = 0.3 # 「1」の後の溜め
|
| 88 |
+
play_min_duration: float = 1.5 # PLAY状態の最小表示時間(手を確認する間)
|
| 89 |
+
react_duration: float = 2.5 # リアクションの長さ
|
| 90 |
+
|
| 91 |
+
# アニメーション
|
| 92 |
+
animation_settle_time: float = 0.2 # 動作前の溜め
|
| 93 |
+
|
| 94 |
+
# 音声
|
| 95 |
+
idle_sound_interval: tuple[float, float] = (5.0, 10.0) # ランダム間隔
|
| 96 |
+
|
| 97 |
+
# メインループ
|
| 98 |
+
loop_interval: float = 0.02 # 50Hz
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass(frozen=True)
|
| 102 |
+
class MotorSettings:
|
| 103 |
+
"""モーター安全設定"""
|
| 104 |
+
max_speed: float = 0.5 # 最大速度(0-1)
|
| 105 |
+
default_duration: float = 0.3 # デフォルトの移動時間
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@dataclass(frozen=True)
|
| 109 |
+
class AudioSettings:
|
| 110 |
+
"""音声設定"""
|
| 111 |
+
volume: float = 0.3 # 音量(0.0〜1.0)- ttastromechは大きいので30%推奨
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@dataclass(frozen=True)
|
| 115 |
+
class Settings:
|
| 116 |
+
"""全設定をまとめたクラス"""
|
| 117 |
+
antenna: AntennaSettings = field(default_factory=AntennaSettings)
|
| 118 |
+
head: HeadSettings = field(default_factory=HeadSettings)
|
| 119 |
+
detection: DetectionSettings = field(default_factory=DetectionSettings)
|
| 120 |
+
timing: TimingSettings = field(default_factory=TimingSettings)
|
| 121 |
+
motor: MotorSettings = field(default_factory=MotorSettings)
|
| 122 |
+
audio: AudioSettings = field(default_factory=AudioSettings)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# シングルトンインスタンス
|
| 126 |
+
settings = Settings()
|
rock_paper_scissors/detection/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .hand_detector import HandDetector
|
| 2 |
+
from .wave_detector import WaveDetector
|
| 3 |
+
from .gesture_detector import GestureDetector, detect_hand_gesture
|
| 4 |
+
from .motion_detector import MotionDetector
|
| 5 |
+
from .status import HandDetectionStatus
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"HandDetector",
|
| 9 |
+
"WaveDetector",
|
| 10 |
+
"GestureDetector",
|
| 11 |
+
"detect_hand_gesture",
|
| 12 |
+
"MotionDetector",
|
| 13 |
+
"HandDetectionStatus",
|
| 14 |
+
]
|
rock_paper_scissors/detection/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (659 Bytes). View file
|
|
|
rock_paper_scissors/detection/__pycache__/gesture_detector.cpython-311.pyc
ADDED
|
Binary file (14.8 kB). View file
|
|
|
rock_paper_scissors/detection/__pycache__/hand_detector.cpython-311.pyc
ADDED
|
Binary file (6.12 kB). View file
|
|
|
rock_paper_scissors/detection/__pycache__/motion_detector.cpython-311.pyc
ADDED
|
Binary file (3.31 kB). View file
|
|
|
rock_paper_scissors/detection/__pycache__/status.cpython-311.pyc
ADDED
|
Binary file (1.67 kB). View file
|
|
|
rock_paper_scissors/detection/__pycache__/wave_detector.cpython-311.pyc
ADDED
|
Binary file (8.2 kB). View file
|
|
|
rock_paper_scissors/detection/gesture_detector.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""グー/チョキ/パーのジェスチャ検出
|
| 2 |
+
|
| 3 |
+
改善された判定ロジック:
|
| 4 |
+
1. 指の曲がり具合を角度で判定(手の傾きに強い)
|
| 5 |
+
2. 距離ベースの判定との組み合わせ
|
| 6 |
+
3. 各ジェスチャに最適化された信頼度計算
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from ..game.states import Hand
|
| 12 |
+
from ..config import settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# MediaPipe Hands のランドマークインデックス
|
| 16 |
+
class LandmarkIndex:
|
| 17 |
+
WRIST = 0
|
| 18 |
+
THUMB_CMC = 1
|
| 19 |
+
THUMB_MCP = 2
|
| 20 |
+
THUMB_IP = 3
|
| 21 |
+
THUMB_TIP = 4
|
| 22 |
+
INDEX_MCP = 5
|
| 23 |
+
INDEX_PIP = 6
|
| 24 |
+
INDEX_DIP = 7
|
| 25 |
+
INDEX_TIP = 8
|
| 26 |
+
MIDDLE_MCP = 9
|
| 27 |
+
MIDDLE_PIP = 10
|
| 28 |
+
MIDDLE_DIP = 11
|
| 29 |
+
MIDDLE_TIP = 12
|
| 30 |
+
RING_MCP = 13
|
| 31 |
+
RING_PIP = 14
|
| 32 |
+
RING_DIP = 15
|
| 33 |
+
RING_TIP = 16
|
| 34 |
+
PINKY_MCP = 17
|
| 35 |
+
PINKY_PIP = 18
|
| 36 |
+
PINKY_DIP = 19
|
| 37 |
+
PINKY_TIP = 20
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# 角度判定の閾値
|
| 41 |
+
FINGER_EXTENDED_ANGLE_THRESHOLD = 150 # 度: これ以上で伸びている
|
| 42 |
+
FINGER_CURLED_ANGLE_THRESHOLD = 130 # 度: これ以下で曲がっている
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class GestureDetector:
|
| 46 |
+
"""じゃんけんの手形を判定するクラス"""
|
| 47 |
+
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self._last_gesture: Optional[Hand] = None
|
| 50 |
+
self._gesture_count = 0
|
| 51 |
+
self._stable_frames = 3 # 同じジェスチャが続くフレーム数
|
| 52 |
+
|
| 53 |
+
def reset(self):
|
| 54 |
+
"""状態をリセット"""
|
| 55 |
+
self._last_gesture = None
|
| 56 |
+
self._gesture_count = 0
|
| 57 |
+
|
| 58 |
+
def detect(self, landmarks: np.ndarray) -> tuple[Hand, float]:
|
| 59 |
+
"""
|
| 60 |
+
ランドマークからじゃんけんの手を判定
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
landmarks: shape (21, 3) のランドマーク配列
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
tuple[Hand, float]: (判定結果, 信頼度スコア)
|
| 67 |
+
"""
|
| 68 |
+
# 各指が開いているかを判定
|
| 69 |
+
fingers_extended = self._get_fingers_extended(landmarks)
|
| 70 |
+
|
| 71 |
+
# ジェスチャを判定(ランドマークも渡してチョキの緩い判定に使用)
|
| 72 |
+
gesture, confidence = self._classify_gesture(fingers_extended, landmarks)
|
| 73 |
+
|
| 74 |
+
return gesture, confidence
|
| 75 |
+
|
| 76 |
+
def detect_stable(self, landmarks: np.ndarray) -> tuple[Hand, float]:
|
| 77 |
+
"""
|
| 78 |
+
安定したジェスチャを検出(数フレーム連続で同じ場合のみ)
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
landmarks: shape (21, 3) のランドマーク配列
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
tuple[Hand, float]: (判定結果, 信頼度スコア)
|
| 85 |
+
"""
|
| 86 |
+
gesture, confidence = self.detect(landmarks)
|
| 87 |
+
|
| 88 |
+
if gesture == self._last_gesture:
|
| 89 |
+
self._gesture_count += 1
|
| 90 |
+
else:
|
| 91 |
+
self._last_gesture = gesture
|
| 92 |
+
self._gesture_count = 1
|
| 93 |
+
|
| 94 |
+
if self._gesture_count >= self._stable_frames:
|
| 95 |
+
return gesture, confidence
|
| 96 |
+
|
| 97 |
+
return Hand.UNKNOWN, 0.0
|
| 98 |
+
|
| 99 |
+
def _get_fingers_extended(self, landmarks: np.ndarray) -> dict[str, bool]:
|
| 100 |
+
"""各指が伸びているかを判定
|
| 101 |
+
|
| 102 |
+
距離ベースを主判定とし、角度ベースは補助的に使用。
|
| 103 |
+
角度が明らかに小さい(曲がっている)場合は距離判定を上書き。
|
| 104 |
+
"""
|
| 105 |
+
# 親指: TIP が IP より外側にあるか
|
| 106 |
+
thumb_extended = self._is_thumb_extended(landmarks)
|
| 107 |
+
|
| 108 |
+
# 他の指: 距離ベース判定(主判定)
|
| 109 |
+
index_extended = self._is_finger_extended(
|
| 110 |
+
landmarks, LandmarkIndex.INDEX_TIP, LandmarkIndex.INDEX_PIP
|
| 111 |
+
)
|
| 112 |
+
middle_extended = self._is_finger_extended(
|
| 113 |
+
landmarks, LandmarkIndex.MIDDLE_TIP, LandmarkIndex.MIDDLE_PIP
|
| 114 |
+
)
|
| 115 |
+
ring_extended = self._is_finger_extended(
|
| 116 |
+
landmarks, LandmarkIndex.RING_TIP, LandmarkIndex.RING_PIP
|
| 117 |
+
)
|
| 118 |
+
pinky_extended = self._is_finger_extended(
|
| 119 |
+
landmarks, LandmarkIndex.PINKY_TIP, LandmarkIndex.PINKY_PIP
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# 角度ベースでのカール判定(明らかに曲がっている場合は上書き)
|
| 123 |
+
# 角度が小さい(曲がっている)場合は「閉じている」と判定
|
| 124 |
+
index_angle = self._calculate_finger_angle(
|
| 125 |
+
landmarks, LandmarkIndex.INDEX_MCP, LandmarkIndex.INDEX_PIP, LandmarkIndex.INDEX_DIP
|
| 126 |
+
)
|
| 127 |
+
middle_angle = self._calculate_finger_angle(
|
| 128 |
+
landmarks, LandmarkIndex.MIDDLE_MCP, LandmarkIndex.MIDDLE_PIP, LandmarkIndex.MIDDLE_DIP
|
| 129 |
+
)
|
| 130 |
+
ring_angle = self._calculate_finger_angle(
|
| 131 |
+
landmarks, LandmarkIndex.RING_MCP, LandmarkIndex.RING_PIP, LandmarkIndex.RING_DIP
|
| 132 |
+
)
|
| 133 |
+
pinky_angle = self._calculate_finger_angle(
|
| 134 |
+
landmarks, LandmarkIndex.PINKY_MCP, LandmarkIndex.PINKY_PIP, LandmarkIndex.PINKY_DIP
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# 角度が明らかに小さい場合は閉じていると判定
|
| 138 |
+
if index_angle < FINGER_CURLED_ANGLE_THRESHOLD:
|
| 139 |
+
index_extended = False
|
| 140 |
+
if middle_angle < FINGER_CURLED_ANGLE_THRESHOLD:
|
| 141 |
+
middle_extended = False
|
| 142 |
+
if ring_angle < FINGER_CURLED_ANGLE_THRESHOLD:
|
| 143 |
+
ring_extended = False
|
| 144 |
+
if pinky_angle < FINGER_CURLED_ANGLE_THRESHOLD:
|
| 145 |
+
pinky_extended = False
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"thumb": thumb_extended,
|
| 149 |
+
"index": index_extended,
|
| 150 |
+
"middle": middle_extended,
|
| 151 |
+
"ring": ring_extended,
|
| 152 |
+
"pinky": pinky_extended,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
def _is_thumb_extended(self, landmarks: np.ndarray) -> bool:
|
| 156 |
+
"""親指が伸びているか判定"""
|
| 157 |
+
thumb_tip = landmarks[LandmarkIndex.THUMB_TIP]
|
| 158 |
+
thumb_ip = landmarks[LandmarkIndex.THUMB_IP]
|
| 159 |
+
thumb_mcp = landmarks[LandmarkIndex.THUMB_MCP]
|
| 160 |
+
|
| 161 |
+
# 親指のTIPがMCPから十分離れているか
|
| 162 |
+
tip_to_mcp = np.linalg.norm(thumb_tip[:2] - thumb_mcp[:2])
|
| 163 |
+
ip_to_mcp = np.linalg.norm(thumb_ip[:2] - thumb_mcp[:2])
|
| 164 |
+
|
| 165 |
+
return tip_to_mcp > ip_to_mcp * 1.2
|
| 166 |
+
|
| 167 |
+
def _is_finger_extended(
|
| 168 |
+
self, landmarks: np.ndarray, tip_idx: int, pip_idx: int,
|
| 169 |
+
relaxed: bool = False
|
| 170 |
+
) -> bool:
|
| 171 |
+
"""指が伸びているか判定(親指以外)
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
landmarks: ランドマーク配列
|
| 175 |
+
tip_idx: TIPのインデックス
|
| 176 |
+
pip_idx: PIPのインデックス
|
| 177 |
+
relaxed: Trueの場合、より緩い閾値を使用(チョキ判定用)
|
| 178 |
+
"""
|
| 179 |
+
tip = landmarks[tip_idx]
|
| 180 |
+
pip = landmarks[pip_idx]
|
| 181 |
+
mcp_idx = pip_idx - 1 # MCPはPIPの1つ前
|
| 182 |
+
|
| 183 |
+
# 閾値: relaxedモードでは0.7、通常は0.9
|
| 184 |
+
threshold = 0.7 if relaxed else 0.9
|
| 185 |
+
|
| 186 |
+
# 指のTIPがPIPより上(y座標が小さい)にあるか
|
| 187 |
+
# また、TIPとMCPの距離が十分あるか
|
| 188 |
+
if mcp_idx >= 0:
|
| 189 |
+
mcp = landmarks[mcp_idx]
|
| 190 |
+
tip_to_mcp = np.linalg.norm(tip[:2] - mcp[:2])
|
| 191 |
+
pip_to_mcp = np.linalg.norm(pip[:2] - mcp[:2])
|
| 192 |
+
|
| 193 |
+
# TIPがPIPより上にあり、かつ十分伸びている
|
| 194 |
+
return tip[1] < pip[1] and tip_to_mcp > pip_to_mcp * threshold
|
| 195 |
+
|
| 196 |
+
return tip[1] < pip[1]
|
| 197 |
+
|
| 198 |
+
def _is_finger_extended_for_scissors(
|
| 199 |
+
self, landmarks: np.ndarray, tip_idx: int, pip_idx: int
|
| 200 |
+
) -> bool:
|
| 201 |
+
"""チョキ判定用の緩い指伸び判定"""
|
| 202 |
+
return self._is_finger_extended(landmarks, tip_idx, pip_idx, relaxed=True)
|
| 203 |
+
|
| 204 |
+
def _calculate_finger_angle(self, landmarks: np.ndarray, mcp_idx: int, pip_idx: int, dip_idx: int) -> float:
|
| 205 |
+
"""指の曲がり角度を計算(度数法)
|
| 206 |
+
|
| 207 |
+
MCP-PIP-DIPの角度を計算。180度に近いほど伸びている。
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
landmarks: ランドマーク配列
|
| 211 |
+
mcp_idx: MCPのインデックス
|
| 212 |
+
pip_idx: PIPのインデックス
|
| 213 |
+
dip_idx: DIPのインデックス
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
角度(度)
|
| 217 |
+
"""
|
| 218 |
+
mcp = landmarks[mcp_idx][:2]
|
| 219 |
+
pip = landmarks[pip_idx][:2]
|
| 220 |
+
dip = landmarks[dip_idx][:2]
|
| 221 |
+
|
| 222 |
+
v1 = mcp - pip
|
| 223 |
+
v2 = dip - pip
|
| 224 |
+
|
| 225 |
+
# ゼロベクトル対策
|
| 226 |
+
norm1 = np.linalg.norm(v1)
|
| 227 |
+
norm2 = np.linalg.norm(v2)
|
| 228 |
+
if norm1 < 1e-10 or norm2 < 1e-10:
|
| 229 |
+
return 180.0 # ゼロベクトルの場合は伸びていると仮定
|
| 230 |
+
|
| 231 |
+
cos_angle = np.dot(v1, v2) / (norm1 * norm2)
|
| 232 |
+
angle = np.arccos(np.clip(cos_angle, -1.0, 1.0))
|
| 233 |
+
return np.degrees(angle)
|
| 234 |
+
|
| 235 |
+
def _is_finger_extended_by_angle(self, landmarks: np.ndarray, finger_name: str) -> bool:
|
| 236 |
+
"""角度ベースで指が伸びているか判定
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
landmarks: ランドマーク配列
|
| 240 |
+
finger_name: "index", "middle", "ring", "pinky"
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
伸びているかどうか
|
| 244 |
+
"""
|
| 245 |
+
finger_indices = {
|
| 246 |
+
"index": (LandmarkIndex.INDEX_MCP, LandmarkIndex.INDEX_PIP, LandmarkIndex.INDEX_DIP),
|
| 247 |
+
"middle": (LandmarkIndex.MIDDLE_MCP, LandmarkIndex.MIDDLE_PIP, LandmarkIndex.MIDDLE_DIP),
|
| 248 |
+
"ring": (LandmarkIndex.RING_MCP, LandmarkIndex.RING_PIP, LandmarkIndex.RING_DIP),
|
| 249 |
+
"pinky": (LandmarkIndex.PINKY_MCP, LandmarkIndex.PINKY_PIP, LandmarkIndex.PINKY_DIP),
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
if finger_name not in finger_indices:
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
mcp_idx, pip_idx, dip_idx = finger_indices[finger_name]
|
| 256 |
+
angle = self._calculate_finger_angle(landmarks, mcp_idx, pip_idx, dip_idx)
|
| 257 |
+
|
| 258 |
+
return angle >= FINGER_EXTENDED_ANGLE_THRESHOLD
|
| 259 |
+
|
| 260 |
+
def _classify_gesture(
|
| 261 |
+
self, fingers: dict[str, bool], landmarks: Optional[np.ndarray] = None
|
| 262 |
+
) -> tuple[Hand, float]:
|
| 263 |
+
"""指の状態からジェスチャを分類
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
fingers: 各指が伸びているかのdict
|
| 267 |
+
landmarks: ランドマーク配列(チョキの緩い判定に使用)
|
| 268 |
+
"""
|
| 269 |
+
extended_count = sum(fingers.values())
|
| 270 |
+
|
| 271 |
+
# 4本の主要な指(人差し指、中指、薬指、小指)が閉じているか
|
| 272 |
+
main_fingers_closed = not fingers["index"] and not fingers["middle"] and \
|
| 273 |
+
not fingers["ring"] and not fingers["pinky"]
|
| 274 |
+
|
| 275 |
+
# パー: 全ての指が開いている
|
| 276 |
+
if extended_count >= 4:
|
| 277 |
+
confidence = extended_count / 5.0
|
| 278 |
+
return Hand.PAPER, confidence
|
| 279 |
+
|
| 280 |
+
# チョキ: 人差し指と中指のみ開いている(緩い判定も試行)
|
| 281 |
+
# 通常の閾値での判定
|
| 282 |
+
if (fingers["index"] and fingers["middle"] and
|
| 283 |
+
not fingers["ring"] and not fingers["pinky"]):
|
| 284 |
+
confidence = 0.9
|
| 285 |
+
return Hand.SCISSORS, confidence
|
| 286 |
+
|
| 287 |
+
# 緩い閾値でのチョキ判定(通常判定で失敗した場合)
|
| 288 |
+
if landmarks is not None:
|
| 289 |
+
scissors_result = self._try_scissors_with_relaxed_threshold(landmarks)
|
| 290 |
+
if scissors_result is not None:
|
| 291 |
+
return scissors_result
|
| 292 |
+
|
| 293 |
+
# グー: 4本の主要な指が閉じている(親指は開いていてもOK)
|
| 294 |
+
if main_fingers_closed:
|
| 295 |
+
# 親指の状態に関わらず、主要な4指が閉じていれば高い信頼度
|
| 296 |
+
confidence = 0.95 if not fingers["thumb"] else 0.9
|
| 297 |
+
return Hand.ROCK, confidence
|
| 298 |
+
|
| 299 |
+
# グー: 1本だけ開いている(通常は親指)
|
| 300 |
+
if extended_count <= 1:
|
| 301 |
+
# 親指だけ開いている場合は高い信頼度
|
| 302 |
+
if fingers["thumb"] and main_fingers_closed:
|
| 303 |
+
confidence = 0.9
|
| 304 |
+
else:
|
| 305 |
+
confidence = (5 - extended_count) / 5.0
|
| 306 |
+
return Hand.ROCK, confidence
|
| 307 |
+
|
| 308 |
+
# 2本開いている場合(チョキではない組み合わせ)
|
| 309 |
+
# チョキ = 人差し指 + 中指 なので、それ以外の2本はグーに近い
|
| 310 |
+
if extended_count == 2:
|
| 311 |
+
# 人差し指と中指の組み合わせ以外
|
| 312 |
+
if not (fingers["index"] and fingers["middle"]):
|
| 313 |
+
# 親指が含まれる2本の場合はグー寄り
|
| 314 |
+
confidence = 0.6
|
| 315 |
+
return Hand.ROCK, confidence
|
| 316 |
+
|
| 317 |
+
# 3本開いている場合
|
| 318 |
+
# チョキの基本形(人差し指 + 中指)+ 1本追加
|
| 319 |
+
if extended_count == 3:
|
| 320 |
+
# 人差し指と中指が開いていて、他1本も開いている場合
|
| 321 |
+
if fingers["index"] and fingers["middle"]:
|
| 322 |
+
# パーに近づいているが、チョキとして判定
|
| 323 |
+
confidence = 0.7
|
| 324 |
+
return Hand.SCISSORS, confidence
|
| 325 |
+
else:
|
| 326 |
+
# 人差し指と中指以外の3本が開いている場合はパー寄り
|
| 327 |
+
confidence = 0.6
|
| 328 |
+
return Hand.PAPER, confidence
|
| 329 |
+
|
| 330 |
+
# それ以外は判定不能(理論上ここには到達しにくい)
|
| 331 |
+
return Hand.UNKNOWN, 0.0
|
| 332 |
+
|
| 333 |
+
def _try_scissors_with_relaxed_threshold(
|
| 334 |
+
self, landmarks: np.ndarray
|
| 335 |
+
) -> Optional[tuple[Hand, float]]:
|
| 336 |
+
"""緩い閾値でチョキを判定
|
| 337 |
+
|
| 338 |
+
通常の閾値では人差し指や中指が「伸びていない」と判定された場合でも、
|
| 339 |
+
チョキとして認識できるケースがある。
|
| 340 |
+
"""
|
| 341 |
+
# 緩い閾値で人差し指と中指を判定
|
| 342 |
+
index_extended = self._is_finger_extended_for_scissors(
|
| 343 |
+
landmarks, LandmarkIndex.INDEX_TIP, LandmarkIndex.INDEX_PIP
|
| 344 |
+
)
|
| 345 |
+
middle_extended = self._is_finger_extended_for_scissors(
|
| 346 |
+
landmarks, LandmarkIndex.MIDDLE_TIP, LandmarkIndex.MIDDLE_PIP
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# 薬指と小指は通常の閾値で閉じている必要がある
|
| 350 |
+
ring_extended = self._is_finger_extended(
|
| 351 |
+
landmarks, LandmarkIndex.RING_TIP, LandmarkIndex.RING_PIP
|
| 352 |
+
)
|
| 353 |
+
pinky_extended = self._is_finger_extended(
|
| 354 |
+
landmarks, LandmarkIndex.PINKY_TIP, LandmarkIndex.PINKY_PIP
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
if (index_extended and middle_extended and
|
| 358 |
+
not ring_extended and not pinky_extended):
|
| 359 |
+
# 緩い閾値で検出されたため、信頼度は少し低め
|
| 360 |
+
return Hand.SCISSORS, 0.75
|
| 361 |
+
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def detect_hand_gesture(landmarks: np.ndarray) -> tuple[Hand, float]:
|
| 366 |
+
"""
|
| 367 |
+
便利関数: ランドマークからジェスチャを判定
|
| 368 |
+
|
| 369 |
+
Args:
|
| 370 |
+
landmarks: shape (21, 3) のランドマーク配列
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
tuple[Hand, float]: (判定結果, 信頼度スコア)
|
| 374 |
+
"""
|
| 375 |
+
detector = GestureDetector()
|
| 376 |
+
return detector.detect(landmarks)
|
rock_paper_scissors/detection/hand_detector.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediaPipe Hands のラッパー(Task API対応)"""
|
| 2 |
+
|
| 3 |
+
from typing import Optional, NamedTuple
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..config import settings
|
| 8 |
+
|
| 9 |
+
# MediaPipe Task APIのインポート
|
| 10 |
+
try:
|
| 11 |
+
import mediapipe as mp
|
| 12 |
+
from mediapipe.tasks import python as mp_tasks
|
| 13 |
+
from mediapipe.tasks.python import vision
|
| 14 |
+
MEDIAPIPE_AVAILABLE = True
|
| 15 |
+
except ImportError:
|
| 16 |
+
MEDIAPIPE_AVAILABLE = False
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class HandLandmarks(NamedTuple):
|
| 20 |
+
"""手のランドマーク情報"""
|
| 21 |
+
landmarks: np.ndarray # shape: (21, 3) - x, y, z
|
| 22 |
+
center_x: float # 手の中心x座標(0-1)
|
| 23 |
+
center_y: float # 手の中心y座標(0-1)
|
| 24 |
+
handedness: str # "Left" or "Right"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_model_path() -> Path:
|
| 28 |
+
"""モデルファイルのパスを取得"""
|
| 29 |
+
# パッケージディレクトリからの相対パス
|
| 30 |
+
package_dir = Path(__file__).parent.parent
|
| 31 |
+
model_path = package_dir / "models" / "hand_landmarker.task"
|
| 32 |
+
|
| 33 |
+
if model_path.exists():
|
| 34 |
+
return model_path
|
| 35 |
+
|
| 36 |
+
# 代替パス(プロジェクトルート)
|
| 37 |
+
alt_path = package_dir.parent / "models" / "hand_landmarker.task"
|
| 38 |
+
if alt_path.exists():
|
| 39 |
+
return alt_path
|
| 40 |
+
|
| 41 |
+
raise FileNotFoundError(
|
| 42 |
+
f"Model file not found. Please download it:\n"
|
| 43 |
+
f"mkdir -p {package_dir / 'models'} && "
|
| 44 |
+
f"curl -L -o {model_path} "
|
| 45 |
+
f"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/latest/hand_landmarker.task"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class HandDetector:
|
| 50 |
+
"""MediaPipe HandLandmarker を使った手検出(Task API)"""
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
if not MEDIAPIPE_AVAILABLE:
|
| 54 |
+
raise RuntimeError("MediaPipe is not installed. Run: pip install mediapipe")
|
| 55 |
+
|
| 56 |
+
model_path = _get_model_path()
|
| 57 |
+
|
| 58 |
+
# HandLandmarkerの設定
|
| 59 |
+
base_options = mp_tasks.BaseOptions(
|
| 60 |
+
model_asset_path=str(model_path)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
options = vision.HandLandmarkerOptions(
|
| 64 |
+
base_options=base_options,
|
| 65 |
+
running_mode=vision.RunningMode.IMAGE,
|
| 66 |
+
num_hands=settings.detection.max_num_hands,
|
| 67 |
+
min_hand_detection_confidence=settings.detection.min_detection_confidence,
|
| 68 |
+
min_hand_presence_confidence=settings.detection.min_detection_confidence,
|
| 69 |
+
min_tracking_confidence=settings.detection.min_tracking_confidence,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
self._landmarker = vision.HandLandmarker.create_from_options(options)
|
| 73 |
+
|
| 74 |
+
def detect(self, frame: np.ndarray) -> Optional[HandLandmarks]:
|
| 75 |
+
"""
|
| 76 |
+
フレームから手を検出
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
frame: BGR形式の画像(OpenCV形式)
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
HandLandmarks: 検出された手のランドマーク、検出されなければNone
|
| 83 |
+
"""
|
| 84 |
+
# BGRからRGBに変換
|
| 85 |
+
rgb_frame = frame[:, :, ::-1].copy()
|
| 86 |
+
|
| 87 |
+
# MediaPipe Imageに変換
|
| 88 |
+
mp_image = mp.Image(
|
| 89 |
+
image_format=mp.ImageFormat.SRGB,
|
| 90 |
+
data=rgb_frame
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# 検出実行
|
| 94 |
+
result = self._landmarker.detect(mp_image)
|
| 95 |
+
|
| 96 |
+
if not result.hand_landmarks:
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
# 最初の手のみを使用
|
| 100 |
+
hand_landmarks = result.hand_landmarks[0]
|
| 101 |
+
handedness = result.handedness[0][0].category_name if result.handedness else "Unknown"
|
| 102 |
+
|
| 103 |
+
# ランドマークをnumpy配列に変換
|
| 104 |
+
landmarks = np.array([
|
| 105 |
+
[lm.x, lm.y, lm.z]
|
| 106 |
+
for lm in hand_landmarks
|
| 107 |
+
])
|
| 108 |
+
|
| 109 |
+
# 手の中心を計算(手首と中指の付け根の中点)
|
| 110 |
+
wrist = landmarks[0]
|
| 111 |
+
middle_mcp = landmarks[9]
|
| 112 |
+
center_x = (wrist[0] + middle_mcp[0]) / 2
|
| 113 |
+
center_y = (wrist[1] + middle_mcp[1]) / 2
|
| 114 |
+
|
| 115 |
+
return HandLandmarks(
|
| 116 |
+
landmarks=landmarks,
|
| 117 |
+
center_x=center_x,
|
| 118 |
+
center_y=center_y,
|
| 119 |
+
handedness=handedness,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def close(self):
|
| 123 |
+
"""リソースを解放"""
|
| 124 |
+
if hasattr(self, '_landmarker'):
|
| 125 |
+
self._landmarker.close()
|
| 126 |
+
|
| 127 |
+
def __enter__(self):
|
| 128 |
+
return self
|
| 129 |
+
|
| 130 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 131 |
+
self.close()
|
rock_paper_scissors/detection/motion_detector.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""動き検出(IDLE→HELLO遷移用)"""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from ..config import settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class MotionDetector:
|
| 9 |
+
"""
|
| 10 |
+
フレーム差分による動き検出
|
| 11 |
+
|
| 12 |
+
IDLEステートで人の存在を検出するために使用
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self._prev_frame: Optional[np.ndarray] = None
|
| 17 |
+
self._motion_count = 0
|
| 18 |
+
|
| 19 |
+
def reset(self):
|
| 20 |
+
"""状態をリセット"""
|
| 21 |
+
self._prev_frame = None
|
| 22 |
+
self._motion_count = 0
|
| 23 |
+
|
| 24 |
+
def detect(self, frame: np.ndarray) -> bool:
|
| 25 |
+
"""
|
| 26 |
+
フレームから動きを検出
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
frame: BGR形式の画像
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
True: 動きを検出
|
| 33 |
+
False: 動きなし
|
| 34 |
+
"""
|
| 35 |
+
# グレースケールに変換してリサイズ(処理軽減)
|
| 36 |
+
gray = self._preprocess(frame)
|
| 37 |
+
|
| 38 |
+
if self._prev_frame is None:
|
| 39 |
+
self._prev_frame = gray
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
# フレーム差分を計算
|
| 43 |
+
diff = np.abs(gray.astype(np.int16) - self._prev_frame.astype(np.int16))
|
| 44 |
+
motion_ratio = np.mean(diff) / 255.0
|
| 45 |
+
|
| 46 |
+
self._prev_frame = gray
|
| 47 |
+
|
| 48 |
+
# 閾値判定
|
| 49 |
+
if motion_ratio > settings.detection.motion_threshold:
|
| 50 |
+
self._motion_count += 1
|
| 51 |
+
else:
|
| 52 |
+
self._motion_count = max(0, self._motion_count - 1)
|
| 53 |
+
|
| 54 |
+
# 連続フレームで動きを検出
|
| 55 |
+
return self._motion_count >= settings.detection.motion_min_frames
|
| 56 |
+
|
| 57 |
+
def _preprocess(self, frame: np.ndarray) -> np.ndarray:
|
| 58 |
+
"""フレームの前処理"""
|
| 59 |
+
# グレースケールに変換
|
| 60 |
+
if len(frame.shape) == 3:
|
| 61 |
+
gray = np.mean(frame, axis=2).astype(np.uint8)
|
| 62 |
+
else:
|
| 63 |
+
gray = frame
|
| 64 |
+
|
| 65 |
+
# リサイズ(処理軽減)
|
| 66 |
+
h, w = gray.shape[:2]
|
| 67 |
+
scale = 0.25
|
| 68 |
+
new_h, new_w = int(h * scale), int(w * scale)
|
| 69 |
+
|
| 70 |
+
# 簡易リサイズ(numpy のみ使用)
|
| 71 |
+
resized = gray[::4, ::4]
|
| 72 |
+
|
| 73 |
+
return resized
|
rock_paper_scissors/detection/status.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""手検出ステータスの定義"""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class HandDetectionStatus:
|
| 9 |
+
"""手検出の状態を表すクラス"""
|
| 10 |
+
|
| 11 |
+
# 検出機能の利用可否
|
| 12 |
+
available: bool = False
|
| 13 |
+
|
| 14 |
+
# 検出結果
|
| 15 |
+
detected: bool = False
|
| 16 |
+
|
| 17 |
+
# 手の位置(0.0-1.0、画面座標)
|
| 18 |
+
center_x: Optional[float] = None
|
| 19 |
+
center_y: Optional[float] = None
|
| 20 |
+
|
| 21 |
+
# ジェスチャ情報
|
| 22 |
+
gesture: Optional[str] = None # "rock", "paper", "scissors", None
|
| 23 |
+
gesture_confidence: float = 0.0
|
| 24 |
+
|
| 25 |
+
# ランドマーク情報
|
| 26 |
+
landmark_count: int = 0
|
| 27 |
+
|
| 28 |
+
def to_dict(self) -> dict:
|
| 29 |
+
"""APIレスポンス用のdict変換"""
|
| 30 |
+
return {
|
| 31 |
+
"hand_detection_available": self.available,
|
| 32 |
+
"hand_detected": self.detected,
|
| 33 |
+
"hand_center_x": self.center_x,
|
| 34 |
+
"hand_center_y": self.center_y,
|
| 35 |
+
"detected_gesture": self.gesture,
|
| 36 |
+
"gesture_confidence": self.gesture_confidence,
|
| 37 |
+
"landmark_count": self.landmark_count,
|
| 38 |
+
}
|
rock_paper_scissors/detection/wave_detector.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""手を振るジェスチャの検出"""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from collections import deque
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from ..config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class WaveDetector:
|
| 10 |
+
"""
|
| 11 |
+
手を振るジェスチャを検出するクラス
|
| 12 |
+
|
| 13 |
+
検出条件:
|
| 14 |
+
- 手のx座標が一定距離以上を左右に往復
|
| 15 |
+
- 左→右→左(または逆)を1回以上
|
| 16 |
+
- 連続5フレーム以上で検出
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self._history: deque[tuple[float, float]] = deque(maxlen=30) # (timestamp, x)
|
| 21 |
+
self._last_direction: Optional[str] = None # "left" or "right"
|
| 22 |
+
self._direction_changes = 0
|
| 23 |
+
self._wave_start_time: Optional[float] = None
|
| 24 |
+
|
| 25 |
+
def reset(self):
|
| 26 |
+
"""検出状態をリセット"""
|
| 27 |
+
self._history.clear()
|
| 28 |
+
self._last_direction = None
|
| 29 |
+
self._direction_changes = 0
|
| 30 |
+
self._wave_start_time = None
|
| 31 |
+
|
| 32 |
+
def update(self, center_x: float) -> bool:
|
| 33 |
+
"""
|
| 34 |
+
手の位置を更新し、振りジェスチャを検出
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
center_x: 手の中心x座標(0-1)
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
True: 手振りを検出
|
| 41 |
+
False: 検出されず
|
| 42 |
+
"""
|
| 43 |
+
current_time = time.time()
|
| 44 |
+
self._history.append((current_time, center_x))
|
| 45 |
+
|
| 46 |
+
# 履歴が少なすぎる場合
|
| 47 |
+
if len(self._history) < settings.detection.wave_min_frames:
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
# 古いデータを除外(タイムアウト)
|
| 51 |
+
timeout = settings.detection.wave_timeout_seconds
|
| 52 |
+
while (self._history and
|
| 53 |
+
current_time - self._history[0][0] > timeout):
|
| 54 |
+
self._history.popleft()
|
| 55 |
+
|
| 56 |
+
if len(self._history) < settings.detection.wave_min_frames:
|
| 57 |
+
self.reset()
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
# 移動方向を判定
|
| 61 |
+
return self._detect_wave()
|
| 62 |
+
|
| 63 |
+
def _detect_wave(self) -> bool:
|
| 64 |
+
"""波形パターンを検出"""
|
| 65 |
+
if len(self._history) < 3:
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
# 最近のx座標を取得
|
| 69 |
+
recent_x = [x for _, x in self._history]
|
| 70 |
+
|
| 71 |
+
# 移動平均でノイズ除去
|
| 72 |
+
smoothed = self._moving_average(recent_x, window=3)
|
| 73 |
+
|
| 74 |
+
if len(smoothed) < 3:
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# 方向変化を検出
|
| 78 |
+
threshold = settings.detection.wave_min_x_displacement
|
| 79 |
+
direction_changes = 0
|
| 80 |
+
last_direction = None
|
| 81 |
+
max_displacement = 0
|
| 82 |
+
|
| 83 |
+
for i in range(1, len(smoothed)):
|
| 84 |
+
diff = smoothed[i] - smoothed[i - 1]
|
| 85 |
+
|
| 86 |
+
if abs(diff) < 0.01: # ノイズ無視
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
current_direction = "right" if diff > 0 else "left"
|
| 90 |
+
|
| 91 |
+
if last_direction is not None and current_direction != last_direction:
|
| 92 |
+
direction_changes += 1
|
| 93 |
+
|
| 94 |
+
last_direction = current_direction
|
| 95 |
+
|
| 96 |
+
# 最大変位を追跡
|
| 97 |
+
if i > 0:
|
| 98 |
+
displacement = abs(smoothed[i] - smoothed[0])
|
| 99 |
+
max_displacement = max(max_displacement, displacement)
|
| 100 |
+
|
| 101 |
+
# 判定条件: 十分な方向変化と変位
|
| 102 |
+
if (direction_changes >= settings.detection.wave_min_cycles * 2 and
|
| 103 |
+
max_displacement >= threshold):
|
| 104 |
+
self.reset()
|
| 105 |
+
return True
|
| 106 |
+
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
def get_progress(self) -> float:
|
| 110 |
+
"""
|
| 111 |
+
手振り検出の進捗を取得(0.0-1.0)
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
float: 検出条件に対する進捗度
|
| 115 |
+
"""
|
| 116 |
+
if len(self._history) < 3:
|
| 117 |
+
return 0.0
|
| 118 |
+
|
| 119 |
+
recent_x = [x for _, x in self._history]
|
| 120 |
+
smoothed = self._moving_average(recent_x, window=3)
|
| 121 |
+
|
| 122 |
+
if len(smoothed) < 3:
|
| 123 |
+
return 0.0
|
| 124 |
+
|
| 125 |
+
# 方向変化をカウント
|
| 126 |
+
direction_changes = self._count_direction_changes(smoothed)
|
| 127 |
+
|
| 128 |
+
# 最大変位を計算
|
| 129 |
+
max_displacement = max(smoothed) - min(smoothed) if smoothed else 0.0
|
| 130 |
+
|
| 131 |
+
# 進捗を計算
|
| 132 |
+
required_changes = settings.detection.wave_min_cycles * 2
|
| 133 |
+
required_displacement = settings.detection.wave_min_x_displacement
|
| 134 |
+
|
| 135 |
+
change_progress = min(direction_changes / required_changes, 1.0)
|
| 136 |
+
displacement_progress = min(max_displacement / required_displacement, 1.0)
|
| 137 |
+
|
| 138 |
+
return (change_progress + displacement_progress) / 2.0
|
| 139 |
+
|
| 140 |
+
def get_direction_changes(self) -> int:
|
| 141 |
+
"""
|
| 142 |
+
方向変化の回数を取得
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
int: 方向変化の回数
|
| 146 |
+
"""
|
| 147 |
+
if len(self._history) < 3:
|
| 148 |
+
return 0
|
| 149 |
+
|
| 150 |
+
recent_x = [x for _, x in self._history]
|
| 151 |
+
smoothed = self._moving_average(recent_x, window=3)
|
| 152 |
+
|
| 153 |
+
return self._count_direction_changes(smoothed)
|
| 154 |
+
|
| 155 |
+
def _count_direction_changes(self, smoothed: list[float]) -> int:
|
| 156 |
+
"""方向変化をカウント"""
|
| 157 |
+
if len(smoothed) < 2:
|
| 158 |
+
return 0
|
| 159 |
+
|
| 160 |
+
direction_changes = 0
|
| 161 |
+
last_direction = None
|
| 162 |
+
|
| 163 |
+
for i in range(1, len(smoothed)):
|
| 164 |
+
diff = smoothed[i] - smoothed[i - 1]
|
| 165 |
+
|
| 166 |
+
if abs(diff) < 0.01:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
current_direction = "right" if diff > 0 else "left"
|
| 170 |
+
|
| 171 |
+
if last_direction is not None and current_direction != last_direction:
|
| 172 |
+
direction_changes += 1
|
| 173 |
+
|
| 174 |
+
last_direction = current_direction
|
| 175 |
+
|
| 176 |
+
return direction_changes
|
| 177 |
+
|
| 178 |
+
@staticmethod
|
| 179 |
+
def _moving_average(data: list[float], window: int = 3) -> list[float]:
|
| 180 |
+
"""移動平均を計算"""
|
| 181 |
+
if len(data) < window:
|
| 182 |
+
return data
|
| 183 |
+
|
| 184 |
+
result = []
|
| 185 |
+
for i in range(len(data) - window + 1):
|
| 186 |
+
avg = sum(data[i:i + window]) / window
|
| 187 |
+
result.append(avg)
|
| 188 |
+
|
| 189 |
+
return result
|
rock_paper_scissors/game/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .states import GameState, Hand, GameResult
|
| 2 |
+
from .state_machine import StateMachine
|
| 3 |
+
from .game_logic import determine_winner, get_random_hand
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"GameState",
|
| 7 |
+
"Hand",
|
| 8 |
+
"GameResult",
|
| 9 |
+
"StateMachine",
|
| 10 |
+
"determine_winner",
|
| 11 |
+
"get_random_hand",
|
| 12 |
+
]
|
rock_paper_scissors/game/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (547 Bytes). View file
|
|
|
rock_paper_scissors/game/__pycache__/game_logic.cpython-311.pyc
ADDED
|
Binary file (1.64 kB). View file
|
|
|
rock_paper_scissors/game/__pycache__/state_machine.cpython-311.pyc
ADDED
|
Binary file (8.02 kB). View file
|
|
|
rock_paper_scissors/game/__pycache__/states.cpython-311.pyc
ADDED
|
Binary file (1.47 kB). View file
|
|
|
rock_paper_scissors/game/game_logic.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""じゃんけんの勝敗判定ロジック"""
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
from .states import Hand, GameResult
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def get_random_hand() -> Hand:
|
| 8 |
+
"""Reachy Miniの手をランダムに決定"""
|
| 9 |
+
return random.choice([Hand.ROCK, Hand.PAPER, Hand.SCISSORS])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def determine_winner(reachy_hand: Hand, user_hand: Hand) -> GameResult:
|
| 13 |
+
"""
|
| 14 |
+
勝敗を判定する(Reachy Mini視点)
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
reachy_hand: Reachy Miniの手
|
| 18 |
+
user_hand: ユーザーの手
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
GameResult: Reachy Mini視点での勝敗
|
| 22 |
+
"""
|
| 23 |
+
if user_hand == Hand.UNKNOWN:
|
| 24 |
+
return GameResult.DRAW
|
| 25 |
+
|
| 26 |
+
if reachy_hand == user_hand:
|
| 27 |
+
return GameResult.DRAW
|
| 28 |
+
|
| 29 |
+
# Reachy Miniが勝つパターン
|
| 30 |
+
win_patterns = {
|
| 31 |
+
(Hand.ROCK, Hand.SCISSORS),
|
| 32 |
+
(Hand.SCISSORS, Hand.PAPER),
|
| 33 |
+
(Hand.PAPER, Hand.ROCK),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (reachy_hand, user_hand) in win_patterns:
|
| 37 |
+
return GameResult.WIN
|
| 38 |
+
|
| 39 |
+
return GameResult.LOSE
|
rock_paper_scissors/game/state_machine.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""状態遷移マシン"""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Callable, Optional
|
| 5 |
+
from .states import GameState, Hand, GameResult
|
| 6 |
+
from .game_logic import get_random_hand, determine_winner
|
| 7 |
+
from ..config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class StateMachine:
|
| 11 |
+
"""
|
| 12 |
+
ゲームの状態遷移を管理するクラス
|
| 13 |
+
|
| 14 |
+
状態遷移図:
|
| 15 |
+
IDLE -> COUNTDOWN (手振り検出)
|
| 16 |
+
COUNTDOWN -> PLAY (自動)
|
| 17 |
+
PLAY -> REACT (自動)
|
| 18 |
+
REACT -> IDLE (自動)
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self._state = GameState.IDLE
|
| 23 |
+
self._state_start_time = time.time()
|
| 24 |
+
self._countdown_step = 0
|
| 25 |
+
self._reachy_hand: Optional[Hand] = None
|
| 26 |
+
self._user_hand: Optional[Hand] = None
|
| 27 |
+
self._game_result: Optional[GameResult] = None
|
| 28 |
+
|
| 29 |
+
# コールバック
|
| 30 |
+
self._on_state_change: Optional[Callable[[GameState, GameState], None]] = None
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def state(self) -> GameState:
|
| 34 |
+
"""現在の状態"""
|
| 35 |
+
return self._state
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def state_elapsed_time(self) -> float:
|
| 39 |
+
"""現在の状態に入ってからの経過時間"""
|
| 40 |
+
return time.time() - self._state_start_time
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def countdown_step(self) -> int:
|
| 44 |
+
"""カウントダウンのステップ(3, 2, 1)"""
|
| 45 |
+
return self._countdown_step
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def reachy_hand(self) -> Optional[Hand]:
|
| 49 |
+
"""Reachy Miniの手"""
|
| 50 |
+
return self._reachy_hand
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def user_hand(self) -> Optional[Hand]:
|
| 54 |
+
"""ユーザーの手"""
|
| 55 |
+
return self._user_hand
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def game_result(self) -> Optional[GameResult]:
|
| 59 |
+
"""ゲーム結果"""
|
| 60 |
+
return self._game_result
|
| 61 |
+
|
| 62 |
+
def set_on_state_change(self, callback: Callable[[GameState, GameState], None]):
|
| 63 |
+
"""状態変更時のコールバックを設定"""
|
| 64 |
+
self._on_state_change = callback
|
| 65 |
+
|
| 66 |
+
def _transition_to(self, new_state: GameState):
|
| 67 |
+
"""状態を遷移させる"""
|
| 68 |
+
old_state = self._state
|
| 69 |
+
self._state = new_state
|
| 70 |
+
self._state_start_time = time.time()
|
| 71 |
+
|
| 72 |
+
if self._on_state_change:
|
| 73 |
+
self._on_state_change(old_state, new_state)
|
| 74 |
+
|
| 75 |
+
def on_wave_detected(self):
|
| 76 |
+
"""手振りが検出された時(IDLE -> COUNTDOWN)"""
|
| 77 |
+
if self._state == GameState.IDLE:
|
| 78 |
+
self._countdown_step = 3
|
| 79 |
+
self._transition_to(GameState.COUNTDOWN)
|
| 80 |
+
|
| 81 |
+
def on_countdown_tick(self) -> bool:
|
| 82 |
+
"""
|
| 83 |
+
カウントダウンを1つ進める
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
True: カウントダウン継続中
|
| 87 |
+
False: カウントダウン終了(PLAY へ遷移)
|
| 88 |
+
"""
|
| 89 |
+
if self._state != GameState.COUNTDOWN:
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
self._countdown_step -= 1
|
| 93 |
+
|
| 94 |
+
if self._countdown_step <= 0:
|
| 95 |
+
self._start_play()
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
def _start_play(self):
|
| 101 |
+
"""PLAY状態を開始"""
|
| 102 |
+
self._reachy_hand = get_random_hand()
|
| 103 |
+
self._transition_to(GameState.PLAY)
|
| 104 |
+
|
| 105 |
+
def on_user_hand_detected(self, hand: Hand):
|
| 106 |
+
"""ユーザーの手が検出された時"""
|
| 107 |
+
if self._state == GameState.PLAY:
|
| 108 |
+
# 最小表示時間中は手を保存するだけ(update()で遷移処理)
|
| 109 |
+
self._user_hand = hand
|
| 110 |
+
|
| 111 |
+
# 最小表示時間が経過していればすぐに遷移
|
| 112 |
+
if self.state_elapsed_time >= settings.timing.play_min_duration:
|
| 113 |
+
self._game_result = determine_winner(self._reachy_hand, hand)
|
| 114 |
+
self._transition_to(GameState.REACT)
|
| 115 |
+
|
| 116 |
+
def on_react_complete(self):
|
| 117 |
+
"""リアクションが完了した時(REACT -> IDLE)"""
|
| 118 |
+
if self._state == GameState.REACT:
|
| 119 |
+
self._reset_game_data()
|
| 120 |
+
self._transition_to(GameState.IDLE)
|
| 121 |
+
|
| 122 |
+
def force_idle(self):
|
| 123 |
+
"""異常時にIDLE状態に強制遷移"""
|
| 124 |
+
self._reset_game_data()
|
| 125 |
+
self._transition_to(GameState.IDLE)
|
| 126 |
+
|
| 127 |
+
def _reset_game_data(self):
|
| 128 |
+
"""ゲームデータをリセット"""
|
| 129 |
+
self._reachy_hand = None
|
| 130 |
+
self._user_hand = None
|
| 131 |
+
self._game_result = None
|
| 132 |
+
self._countdown_step = 0
|
| 133 |
+
|
| 134 |
+
def update(self) -> bool:
|
| 135 |
+
"""
|
| 136 |
+
状態を更新する(メインループから呼び出し)
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
True: 状態が変更された
|
| 140 |
+
False: 状態は変更されなかった
|
| 141 |
+
"""
|
| 142 |
+
elapsed = self.state_elapsed_time
|
| 143 |
+
|
| 144 |
+
# PLAY: 最小表示時間経過後、保留中のユーザーの手があればREACTへ
|
| 145 |
+
if self._state == GameState.PLAY:
|
| 146 |
+
if elapsed >= settings.timing.play_min_duration and self._user_hand is not None:
|
| 147 |
+
self._game_result = determine_winner(self._reachy_hand, self._user_hand)
|
| 148 |
+
self._transition_to(GameState.REACT)
|
| 149 |
+
return True
|
| 150 |
+
|
| 151 |
+
# REACT: 一定時間後にIDLEへ
|
| 152 |
+
if self._state == GameState.REACT:
|
| 153 |
+
if elapsed >= settings.timing.react_duration:
|
| 154 |
+
self.on_react_complete()
|
| 155 |
+
return True
|
| 156 |
+
|
| 157 |
+
return False
|
rock_paper_scissors/game/states.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ゲーム状態の定義"""
|
| 2 |
+
|
| 3 |
+
from enum import Enum, auto
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class GameState(Enum):
|
| 7 |
+
"""ゲームの状態"""
|
| 8 |
+
IDLE = auto() # 待機状態
|
| 9 |
+
HELLO = auto() # 呼びかけ
|
| 10 |
+
READY = auto() # スタート待ち
|
| 11 |
+
COUNTDOWN = auto() # カウントダウン
|
| 12 |
+
PLAY = auto() # じゃんけん判定
|
| 13 |
+
REACT = auto() # 結果リアクション
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Hand(Enum):
|
| 17 |
+
"""じゃんけんの手"""
|
| 18 |
+
ROCK = "rock" # グー
|
| 19 |
+
PAPER = "paper" # パー
|
| 20 |
+
SCISSORS = "scissors" # チョキ
|
| 21 |
+
UNKNOWN = "unknown" # 認識不可
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class GameResult(Enum):
|
| 25 |
+
"""勝敗結果"""
|
| 26 |
+
WIN = "win" # Reachy Miniの勝ち
|
| 27 |
+
LOSE = "lose" # Reachy Miniの負け
|
| 28 |
+
DRAW = "draw" # あいこ
|
rock_paper_scissors/main.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rock Paper Scissors - Reachy Mini じゃんけんアプリ
|
| 3 |
+
|
| 4 |
+
子ども向けの直感的なじゃんけんゲーム。
|
| 5 |
+
画面・言語に依存せず、ジェスチャ・音・動きのみで遊べる。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
import random
|
| 11 |
+
from typing import Optional
|
| 12 |
+
import base64
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 17 |
+
|
| 18 |
+
from .game import StateMachine, GameState, Hand, GameResult
|
| 19 |
+
from .detection import WaveDetector, GestureDetector, HandDetectionStatus
|
| 20 |
+
from .robot import AnimationController
|
| 21 |
+
from .audio import SoundManager, SoundType
|
| 22 |
+
from .config import settings
|
| 23 |
+
|
| 24 |
+
# MediaPipeのインポート(オプショナル)
|
| 25 |
+
try:
|
| 26 |
+
from .detection import HandDetector
|
| 27 |
+
# 実際にインスタンス化できるか確認
|
| 28 |
+
_test_detector = HandDetector()
|
| 29 |
+
_test_detector.close()
|
| 30 |
+
HAND_DETECTOR_AVAILABLE = True
|
| 31 |
+
print("✅ Hand detection is available")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
HAND_DETECTOR_AVAILABLE = False
|
| 34 |
+
print(f"⚠️ Hand detection not available: {e}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class RockPaperScissors(ReachyMiniApp):
|
| 38 |
+
"""Reachy Mini じゃんけんアプリ"""
|
| 39 |
+
|
| 40 |
+
# ブラウザでステータスを確認するためのURL
|
| 41 |
+
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 42 |
+
request_media_backend: str | None = None
|
| 43 |
+
|
| 44 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
| 45 |
+
"""メインループ"""
|
| 46 |
+
# モジュールの初期化
|
| 47 |
+
state_machine = StateMachine()
|
| 48 |
+
animation = AnimationController()
|
| 49 |
+
sound_manager = SoundManager(volume=settings.audio.volume) # 設定から音量を取得
|
| 50 |
+
wave_detector = WaveDetector()
|
| 51 |
+
gesture_detector = GestureDetector()
|
| 52 |
+
|
| 53 |
+
# オプショナル: 手検出(MediaPipeが利用可能な場合)
|
| 54 |
+
hand_detector: Optional[HandDetector] = None
|
| 55 |
+
if HAND_DETECTOR_AVAILABLE:
|
| 56 |
+
try:
|
| 57 |
+
hand_detector = HandDetector()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Warning: Hand detector not available: {e}")
|
| 60 |
+
|
| 61 |
+
# サウンドマネージャーにメディアを設定
|
| 62 |
+
sound_manager.set_media(reachy_mini.media)
|
| 63 |
+
|
| 64 |
+
# アニメーションコントローラーにサウンドマネージャーを設定(動きと音の同期)
|
| 65 |
+
animation.set_sound_manager(sound_manager)
|
| 66 |
+
|
| 67 |
+
# 共有状態(Web UIから参照)
|
| 68 |
+
shared_state = {
|
| 69 |
+
"game_state": "IDLE",
|
| 70 |
+
"reachy_hand": None,
|
| 71 |
+
"user_hand": None,
|
| 72 |
+
"game_result": None,
|
| 73 |
+
"countdown_step": 0,
|
| 74 |
+
"frame_base64": None,
|
| 75 |
+
"frame_available": False,
|
| 76 |
+
"frame_size": None,
|
| 77 |
+
# 手検出詳細情報
|
| 78 |
+
"hand_detection_available": HAND_DETECTOR_AVAILABLE,
|
| 79 |
+
"hand_detected": False,
|
| 80 |
+
"hand_center_x": None,
|
| 81 |
+
"hand_center_y": None,
|
| 82 |
+
"detected_gesture": None,
|
| 83 |
+
"gesture_confidence": 0.0,
|
| 84 |
+
"landmark_count": 0,
|
| 85 |
+
# 手振り検出情報
|
| 86 |
+
"wave_progress": 0.0,
|
| 87 |
+
"wave_direction_changes": 0,
|
| 88 |
+
}
|
| 89 |
+
state_lock = threading.Lock()
|
| 90 |
+
|
| 91 |
+
# 状態変更時のコールバック
|
| 92 |
+
def on_state_change(old_state: GameState, new_state: GameState):
|
| 93 |
+
self._handle_state_change(
|
| 94 |
+
old_state, new_state, state_machine,
|
| 95 |
+
sound_manager, wave_detector, gesture_detector
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
state_machine.set_on_state_change(on_state_change)
|
| 99 |
+
|
| 100 |
+
# Web API エンドポイントを設定
|
| 101 |
+
if self.settings_app is not None:
|
| 102 |
+
self._setup_api_endpoints(
|
| 103 |
+
shared_state, state_lock, state_machine,
|
| 104 |
+
wave_detector, gesture_detector
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# カウントダウン用の変数
|
| 108 |
+
last_idle_sound_time = time.time()
|
| 109 |
+
next_idle_sound_interval = random.uniform(*settings.timing.idle_sound_interval)
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
while not stop_event.is_set():
|
| 113 |
+
loop_start = time.time()
|
| 114 |
+
|
| 115 |
+
# カメラフレームを取得
|
| 116 |
+
frame = self._get_frame(reachy_mini)
|
| 117 |
+
|
| 118 |
+
# 状態に応じた処理
|
| 119 |
+
current_state = state_machine.state
|
| 120 |
+
hand_detected = False
|
| 121 |
+
hand_status = HandDetectionStatus(available=HAND_DETECTOR_AVAILABLE)
|
| 122 |
+
|
| 123 |
+
# すべての状態で手検出を試みる
|
| 124 |
+
hand_data = None
|
| 125 |
+
if frame is not None and hand_detector:
|
| 126 |
+
try:
|
| 127 |
+
hand_data = hand_detector.detect(frame)
|
| 128 |
+
if hand_data:
|
| 129 |
+
hand_detected = True
|
| 130 |
+
hand_status.detected = True
|
| 131 |
+
hand_status.center_x = hand_data.center_x
|
| 132 |
+
hand_status.center_y = hand_data.center_y
|
| 133 |
+
hand_status.landmark_count = len(hand_data.landmarks)
|
| 134 |
+
|
| 135 |
+
# ジェスチャ認識も常に行う
|
| 136 |
+
detected_hand, confidence = gesture_detector.detect(hand_data.landmarks)
|
| 137 |
+
hand_status.gesture = detected_hand.value if detected_hand != Hand.UNKNOWN else None
|
| 138 |
+
hand_status.gesture_confidence = confidence
|
| 139 |
+
|
| 140 |
+
# 手振り検出を更新し、IDLE状態なら遷移
|
| 141 |
+
wave_detected = wave_detector.update(hand_data.center_x)
|
| 142 |
+
if wave_detected and current_state == GameState.IDLE:
|
| 143 |
+
sound_manager.play_async(SoundType.SUCCESS)
|
| 144 |
+
state_machine.on_wave_detected()
|
| 145 |
+
except Exception as e:
|
| 146 |
+
print(f"Hand detection error: {e}")
|
| 147 |
+
|
| 148 |
+
if current_state == GameState.IDLE:
|
| 149 |
+
# 定期的な音(手振りを待っている間)
|
| 150 |
+
if time.time() - last_idle_sound_time > next_idle_sound_interval:
|
| 151 |
+
sound_manager.play_async(SoundType.IDLE)
|
| 152 |
+
last_idle_sound_time = time.time()
|
| 153 |
+
next_idle_sound_interval = random.uniform(
|
| 154 |
+
*settings.timing.idle_sound_interval
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
elif current_state == GameState.COUNTDOWN:
|
| 158 |
+
# カウントダウン処理(音なし、動きのみ)
|
| 159 |
+
elapsed = state_machine.state_elapsed_time
|
| 160 |
+
step = state_machine.countdown_step
|
| 161 |
+
step_time = (3 - step) * settings.timing.countdown_interval
|
| 162 |
+
|
| 163 |
+
if elapsed >= step_time + settings.timing.countdown_interval:
|
| 164 |
+
state_machine.on_countdown_tick()
|
| 165 |
+
|
| 166 |
+
elif current_state == GameState.PLAY:
|
| 167 |
+
# じゃんけん判定(hand_dataは上の共通処理で取得済み)
|
| 168 |
+
user_hand = Hand.UNKNOWN
|
| 169 |
+
|
| 170 |
+
if hand_status.gesture and hand_status.gesture_confidence >= settings.detection.gesture_confidence_threshold:
|
| 171 |
+
user_hand = Hand(hand_status.gesture)
|
| 172 |
+
|
| 173 |
+
# 少し待ってから判定(ユーザーが手を出す時間)
|
| 174 |
+
if state_machine.state_elapsed_time >= 0.5:
|
| 175 |
+
state_machine.on_user_hand_detected(user_hand)
|
| 176 |
+
|
| 177 |
+
# 自動状態遷移(タイムアウトなど)
|
| 178 |
+
state_machine.update()
|
| 179 |
+
|
| 180 |
+
# アニメーション更新
|
| 181 |
+
animation.set_state(
|
| 182 |
+
state_machine.state,
|
| 183 |
+
reachy_hand=state_machine.reachy_hand,
|
| 184 |
+
game_result=state_machine.game_result,
|
| 185 |
+
countdown_step=state_machine.countdown_step,
|
| 186 |
+
)
|
| 187 |
+
animation.apply_to_robot(reachy_mini)
|
| 188 |
+
|
| 189 |
+
# 共有状態を更新
|
| 190 |
+
with state_lock:
|
| 191 |
+
shared_state["game_state"] = state_machine.state.name
|
| 192 |
+
shared_state["reachy_hand"] = (
|
| 193 |
+
state_machine.reachy_hand.value
|
| 194 |
+
if state_machine.reachy_hand else None
|
| 195 |
+
)
|
| 196 |
+
shared_state["user_hand"] = (
|
| 197 |
+
state_machine.user_hand.value
|
| 198 |
+
if state_machine.user_hand else None
|
| 199 |
+
)
|
| 200 |
+
shared_state["game_result"] = (
|
| 201 |
+
state_machine.game_result.value
|
| 202 |
+
if state_machine.game_result else None
|
| 203 |
+
)
|
| 204 |
+
shared_state["countdown_step"] = state_machine.countdown_step
|
| 205 |
+
|
| 206 |
+
# 手検出詳細情報
|
| 207 |
+
shared_state["hand_detection_available"] = hand_status.available
|
| 208 |
+
shared_state["hand_detected"] = hand_status.detected
|
| 209 |
+
shared_state["hand_center_x"] = hand_status.center_x
|
| 210 |
+
shared_state["hand_center_y"] = hand_status.center_y
|
| 211 |
+
shared_state["detected_gesture"] = hand_status.gesture
|
| 212 |
+
shared_state["gesture_confidence"] = hand_status.gesture_confidence
|
| 213 |
+
shared_state["landmark_count"] = hand_status.landmark_count
|
| 214 |
+
|
| 215 |
+
# 手振り検出情報
|
| 216 |
+
shared_state["wave_progress"] = wave_detector.get_progress()
|
| 217 |
+
shared_state["wave_direction_changes"] = wave_detector.get_direction_changes()
|
| 218 |
+
|
| 219 |
+
# フレーム情報
|
| 220 |
+
if frame is not None:
|
| 221 |
+
shared_state["frame_available"] = True
|
| 222 |
+
shared_state["frame_size"] = f"{frame.shape[1]}x{frame.shape[0]}"
|
| 223 |
+
# フレームをBase64エンコード(Web UI用)
|
| 224 |
+
try:
|
| 225 |
+
import cv2
|
| 226 |
+
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 50])
|
| 227 |
+
shared_state["frame_base64"] = base64.b64encode(buffer).decode('utf-8')
|
| 228 |
+
except Exception:
|
| 229 |
+
shared_state["frame_base64"] = None
|
| 230 |
+
else:
|
| 231 |
+
shared_state["frame_available"] = False
|
| 232 |
+
shared_state["frame_size"] = None
|
| 233 |
+
shared_state["frame_base64"] = None
|
| 234 |
+
|
| 235 |
+
# ループ間隔を維持
|
| 236 |
+
elapsed = time.time() - loop_start
|
| 237 |
+
sleep_time = max(0, settings.timing.loop_interval - elapsed)
|
| 238 |
+
time.sleep(sleep_time)
|
| 239 |
+
|
| 240 |
+
except Exception as e:
|
| 241 |
+
print(f"Error in main loop: {e}")
|
| 242 |
+
import traceback
|
| 243 |
+
traceback.print_exc()
|
| 244 |
+
# 異常時はIDLE状態に復帰を試みる
|
| 245 |
+
state_machine.force_idle()
|
| 246 |
+
|
| 247 |
+
finally:
|
| 248 |
+
# リソースの解放
|
| 249 |
+
if hand_detector:
|
| 250 |
+
hand_detector.close()
|
| 251 |
+
|
| 252 |
+
def _setup_api_endpoints(
|
| 253 |
+
self,
|
| 254 |
+
shared_state: dict,
|
| 255 |
+
state_lock: threading.Lock,
|
| 256 |
+
state_machine: StateMachine,
|
| 257 |
+
wave_detector: WaveDetector,
|
| 258 |
+
gesture_detector: GestureDetector,
|
| 259 |
+
):
|
| 260 |
+
"""Web API エンドポイントを設定"""
|
| 261 |
+
|
| 262 |
+
class TriggerAction(BaseModel):
|
| 263 |
+
action: str
|
| 264 |
+
|
| 265 |
+
@self.settings_app.get("/api/status")
|
| 266 |
+
def get_status():
|
| 267 |
+
"""現在の状態を取得"""
|
| 268 |
+
with state_lock:
|
| 269 |
+
return shared_state.copy()
|
| 270 |
+
|
| 271 |
+
@self.settings_app.post("/api/trigger")
|
| 272 |
+
def trigger_action(data: TriggerAction):
|
| 273 |
+
"""手動で状態遷移をトリガー(デバッグ用)"""
|
| 274 |
+
action = data.action
|
| 275 |
+
|
| 276 |
+
if action == "wave":
|
| 277 |
+
state_machine.on_wave_detected()
|
| 278 |
+
elif action == "rock":
|
| 279 |
+
state_machine.on_user_hand_detected(Hand.ROCK)
|
| 280 |
+
elif action == "paper":
|
| 281 |
+
state_machine.on_user_hand_detected(Hand.PAPER)
|
| 282 |
+
elif action == "scissors":
|
| 283 |
+
state_machine.on_user_hand_detected(Hand.SCISSORS)
|
| 284 |
+
elif action == "reset":
|
| 285 |
+
state_machine.force_idle()
|
| 286 |
+
|
| 287 |
+
return {"success": True, "action": action}
|
| 288 |
+
|
| 289 |
+
def _get_frame(self, reachy_mini: ReachyMini) -> Optional[np.ndarray]:
|
| 290 |
+
"""カメラフレームを取得"""
|
| 291 |
+
try:
|
| 292 |
+
return reachy_mini.media.get_frame()
|
| 293 |
+
except Exception:
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
def _handle_state_change(
|
| 297 |
+
self,
|
| 298 |
+
old_state: GameState,
|
| 299 |
+
new_state: GameState,
|
| 300 |
+
state_machine: StateMachine,
|
| 301 |
+
sound_manager: SoundManager,
|
| 302 |
+
wave_detector: WaveDetector,
|
| 303 |
+
gesture_detector: GestureDetector,
|
| 304 |
+
):
|
| 305 |
+
"""状態変更時の処理"""
|
| 306 |
+
# 状態に応じた処理
|
| 307 |
+
if new_state == GameState.IDLE:
|
| 308 |
+
wave_detector.reset()
|
| 309 |
+
gesture_detector.reset()
|
| 310 |
+
# 注: REACT/COUNTDOWNの音声再生はAnimationControllerで行う
|
| 311 |
+
# (動きと音の同期のため)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
if __name__ == "__main__":
|
| 315 |
+
app = RockPaperScissors()
|
| 316 |
+
try:
|
| 317 |
+
app.wrapped_run()
|
| 318 |
+
except KeyboardInterrupt:
|
| 319 |
+
app.stop()
|
rock_paper_scissors/robot/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .antenna_poses import AntennaPoses
|
| 2 |
+
from .head_poses import HeadPoses
|
| 3 |
+
from .animations import AnimationController
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"AntennaPoses",
|
| 7 |
+
"HeadPoses",
|
| 8 |
+
"AnimationController",
|
| 9 |
+
]
|
rock_paper_scissors/robot/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (460 Bytes). View file
|
|
|
rock_paper_scissors/robot/__pycache__/animations.cpython-311.pyc
ADDED
|
Binary file (12.3 kB). View file
|
|
|
rock_paper_scissors/robot/__pycache__/antenna_poses.cpython-311.pyc
ADDED
|
Binary file (6.54 kB). View file
|
|
|
rock_paper_scissors/robot/__pycache__/head_poses.cpython-311.pyc
ADDED
|
Binary file (5.67 kB). View file
|
|
|