trtd56 commited on
Commit
24836e5
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .coverage +0 -0
  2. README.md +15 -0
  3. debug_gesture.py +202 -0
  4. index.html +19 -0
  5. pyproject.toml +46 -0
  6. rock_paper_scissors.egg-info/PKG-INFO +31 -0
  7. rock_paper_scissors.egg-info/SOURCES.txt +223 -0
  8. rock_paper_scissors.egg-info/dependency_links.txt +1 -0
  9. rock_paper_scissors.egg-info/entry_points.txt +2 -0
  10. rock_paper_scissors.egg-info/requires.txt +10 -0
  11. rock_paper_scissors.egg-info/top_level.txt +3 -0
  12. rock_paper_scissors/__init__.py +1 -0
  13. rock_paper_scissors/__pycache__/__init__.cpython-311.pyc +0 -0
  14. rock_paper_scissors/__pycache__/main.cpython-311.pyc +0 -0
  15. rock_paper_scissors/audio/__init__.py +8 -0
  16. rock_paper_scissors/audio/__pycache__/__init__.cpython-311.pyc +0 -0
  17. rock_paper_scissors/audio/__pycache__/sound_manager.cpython-311.pyc +0 -0
  18. rock_paper_scissors/audio/__pycache__/sounds.cpython-311.pyc +0 -0
  19. rock_paper_scissors/audio/sound_manager.py +209 -0
  20. rock_paper_scissors/audio/sounds.py +89 -0
  21. rock_paper_scissors/config/__init__.py +3 -0
  22. rock_paper_scissors/config/__pycache__/__init__.cpython-311.pyc +0 -0
  23. rock_paper_scissors/config/__pycache__/settings.cpython-311.pyc +0 -0
  24. rock_paper_scissors/config/settings.py +126 -0
  25. rock_paper_scissors/detection/__init__.py +14 -0
  26. rock_paper_scissors/detection/__pycache__/__init__.cpython-311.pyc +0 -0
  27. rock_paper_scissors/detection/__pycache__/gesture_detector.cpython-311.pyc +0 -0
  28. rock_paper_scissors/detection/__pycache__/hand_detector.cpython-311.pyc +0 -0
  29. rock_paper_scissors/detection/__pycache__/motion_detector.cpython-311.pyc +0 -0
  30. rock_paper_scissors/detection/__pycache__/status.cpython-311.pyc +0 -0
  31. rock_paper_scissors/detection/__pycache__/wave_detector.cpython-311.pyc +0 -0
  32. rock_paper_scissors/detection/gesture_detector.py +376 -0
  33. rock_paper_scissors/detection/hand_detector.py +131 -0
  34. rock_paper_scissors/detection/motion_detector.py +73 -0
  35. rock_paper_scissors/detection/status.py +38 -0
  36. rock_paper_scissors/detection/wave_detector.py +189 -0
  37. rock_paper_scissors/game/__init__.py +12 -0
  38. rock_paper_scissors/game/__pycache__/__init__.cpython-311.pyc +0 -0
  39. rock_paper_scissors/game/__pycache__/game_logic.cpython-311.pyc +0 -0
  40. rock_paper_scissors/game/__pycache__/state_machine.cpython-311.pyc +0 -0
  41. rock_paper_scissors/game/__pycache__/states.cpython-311.pyc +0 -0
  42. rock_paper_scissors/game/game_logic.py +39 -0
  43. rock_paper_scissors/game/state_machine.py +157 -0
  44. rock_paper_scissors/game/states.py +28 -0
  45. rock_paper_scissors/main.py +319 -0
  46. rock_paper_scissors/robot/__init__.py +9 -0
  47. rock_paper_scissors/robot/__pycache__/__init__.cpython-311.pyc +0 -0
  48. rock_paper_scissors/robot/__pycache__/animations.cpython-311.pyc +0 -0
  49. rock_paper_scissors/robot/__pycache__/antenna_poses.cpython-311.pyc +0 -0
  50. 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