roblox-studio-physical-operation-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ """
2
+ 工具栏按钮状态检测模块 - 模板匹配版本
3
+
4
+ 使用 OpenCV 模板匹配在截图中定位按钮,然后通过颜色判断状态。
5
+
6
+ 按钮布局(从左到右):
7
+ 1. 播放按钮(Play): 三角形 ▶
8
+ 2. 暂停按钮(Pause): 双竖线 ‖
9
+ 3. 停止按钮(Stop): 方块 ■
10
+
11
+ 颜色状态:
12
+ - 灰色 = disabled
13
+ - 彩色 = enabled (绿色播放、蓝色暂停、红色停止)
14
+ """
15
+
16
+ import ctypes
17
+ import os
18
+ from typing import Optional, List, Tuple
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+
22
+ try:
23
+ import cv2
24
+ import numpy as np
25
+ import win32gui
26
+ import win32ui
27
+ from PIL import Image
28
+ except ImportError as e:
29
+ raise ImportError(f"需要安装依赖: pip install opencv-python pywin32 Pillow numpy: {e}")
30
+
31
+
32
+ class ButtonState(Enum):
33
+ """按钮状态"""
34
+ DISABLED = "disabled" # 灰色,不可用
35
+ ENABLED = "enabled" # 彩色,可用
36
+ UNKNOWN = "unknown" # 无法识别
37
+
38
+
39
+ class ButtonType(Enum):
40
+ """按钮类型"""
41
+ PLAY = "play"
42
+ PAUSE = "pause"
43
+ STOP = "stop"
44
+
45
+
46
+ @dataclass
47
+ class ButtonInfo:
48
+ """按钮信息"""
49
+ button_type: ButtonType
50
+ x: int
51
+ y: int
52
+ width: int
53
+ height: int
54
+ state: ButtonState
55
+ color_type: str # "gray", "green", "blue", "red"
56
+ confidence: float # 模板匹配置信度
57
+
58
+
59
+ @dataclass
60
+ class ToolbarState:
61
+ """工具栏状态"""
62
+ play: ButtonState
63
+ pause: ButtonState
64
+ stop: ButtonState
65
+ game_state: str # "stopped", "running", "paused"
66
+ buttons: List[ButtonInfo] = None
67
+
68
+ def to_dict(self) -> dict:
69
+ result = {
70
+ "play": self.play.value,
71
+ "pause": self.pause.value,
72
+ "stop": self.stop.value,
73
+ "game_state": self.game_state
74
+ }
75
+ if self.buttons:
76
+ result["buttons_detail"] = [
77
+ {
78
+ "type": b.button_type.value,
79
+ "x": b.x, "y": b.y,
80
+ "width": b.width, "height": b.height,
81
+ "state": b.state.value,
82
+ "color_type": b.color_type,
83
+ "confidence": round(b.confidence, 3)
84
+ }
85
+ for b in self.buttons
86
+ ]
87
+ return result
88
+
89
+
90
+ # 模板文件路径
91
+ TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
92
+
93
+
94
+ def capture_window_to_image(hwnd: int) -> Optional[Image.Image]:
95
+ """截取窗口为 PIL Image 对象"""
96
+ try:
97
+ rect = win32gui.GetWindowRect(hwnd)
98
+ width = rect[2] - rect[0]
99
+ height = rect[3] - rect[1]
100
+
101
+ if width <= 0 or height <= 0:
102
+ return None
103
+
104
+ hwnd_dc = win32gui.GetWindowDC(hwnd)
105
+ mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
106
+ save_dc = mfc_dc.CreateCompatibleDC()
107
+
108
+ bitmap = win32ui.CreateBitmap()
109
+ bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
110
+ save_dc.SelectObject(bitmap)
111
+
112
+ ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2)
113
+
114
+ bmpinfo = bitmap.GetInfo()
115
+ bmpstr = bitmap.GetBitmapBits(True)
116
+ img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']),
117
+ bmpstr, 'raw', 'BGRX', 0, 1)
118
+
119
+ win32gui.DeleteObject(bitmap.GetHandle())
120
+ save_dc.DeleteDC()
121
+ mfc_dc.DeleteDC()
122
+ win32gui.ReleaseDC(hwnd, hwnd_dc)
123
+
124
+ return img
125
+ except Exception:
126
+ return None
127
+
128
+
129
+ def pil_to_cv2(pil_image: Image.Image) -> np.ndarray:
130
+ """PIL Image 转 OpenCV 格式"""
131
+ return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
132
+
133
+
134
+ def rgb_to_hsv(r: int, g: int, b: int) -> Tuple[float, float, float]:
135
+ """RGB 转 HSV (H: 0-360, S: 0-100, V: 0-100)"""
136
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
137
+ max_val = max(r, g, b)
138
+ min_val = min(r, g, b)
139
+ diff = max_val - min_val
140
+
141
+ v = max_val * 100
142
+
143
+ if max_val == 0:
144
+ s = 0
145
+ else:
146
+ s = (diff / max_val) * 100
147
+
148
+ if diff == 0:
149
+ h = 0
150
+ elif max_val == r:
151
+ h = 60 * (((g - b) / diff) % 6)
152
+ elif max_val == g:
153
+ h = 60 * (((b - r) / diff) + 2)
154
+ else:
155
+ h = 60 * (((r - g) / diff) + 4)
156
+
157
+ if h < 0:
158
+ h += 360
159
+
160
+ return h, s, v
161
+
162
+
163
+ def load_template(name: str) -> Optional[np.ndarray]:
164
+ """加载模板图片(灰度)"""
165
+ path = os.path.join(TEMPLATE_DIR, f"{name}.png")
166
+ if not os.path.exists(path):
167
+ return None
168
+ return cv2.imread(path, cv2.IMREAD_GRAYSCALE)
169
+
170
+
171
+ def find_button_by_template(screenshot_gray: np.ndarray, template: np.ndarray,
172
+ threshold: float = 0.7) -> Optional[Tuple[int, int, float]]:
173
+ """
174
+ 使用模板匹配在截图中查找按钮
175
+
176
+ Args:
177
+ screenshot_gray: 灰度截图
178
+ template: 灰度模板
179
+ threshold: 匹配阈值
180
+
181
+ Returns:
182
+ (x, y, confidence) 或 None
183
+ """
184
+ if template is None:
185
+ return None
186
+
187
+ result = cv2.matchTemplate(screenshot_gray, template, cv2.TM_CCOEFF_NORMED)
188
+ min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
189
+
190
+ if max_val >= threshold:
191
+ return (max_loc[0], max_loc[1], max_val)
192
+
193
+ return None
194
+
195
+
196
+ def analyze_button_color(pil_image: Image.Image, x: int, y: int,
197
+ width: int, height: int, button_type: str = None) -> Tuple[ButtonState, str]:
198
+ """
199
+ 分析按钮区域的颜色来判断状态
200
+
201
+ 对于暂停按钮,使用亮度判断(竖线颜色):
202
+ - 浅灰色 (亮度 > 120) = disabled (游戏停止)
203
+ - 深灰色 (亮度 < 120) = enabled (游戏运行中)
204
+
205
+ 对于其他按钮,使用彩色判断:
206
+ - 有彩色 = enabled
207
+ - 灰色 = disabled
208
+
209
+ Returns:
210
+ (state, color_type)
211
+ """
212
+ # 饱和度阈值
213
+ COLOR_THRESHOLD = 50
214
+
215
+ red_count = 0
216
+ green_count = 0
217
+ blue_count = 0
218
+ dark_pixel_count = 0 # 深色像素计数
219
+ total = 0
220
+ total_dark_brightness = 0 # 深色像素的亮度总和
221
+
222
+ for dy in range(height):
223
+ for dx in range(width):
224
+ try:
225
+ r, g, b = pil_image.getpixel((x + dx, y + dy))[:3]
226
+ h, s, v = rgb_to_hsv(r, g, b)
227
+ brightness = (r + g + b) / 3
228
+ total += 1
229
+
230
+ # 统计非白色像素(按钮图标本身)
231
+ if brightness < 200:
232
+ dark_pixel_count += 1
233
+ total_dark_brightness += brightness
234
+
235
+ if s > COLOR_THRESHOLD and v > 30:
236
+ if h <= 30 or h >= 330: # 红色
237
+ red_count += 1
238
+ elif 80 <= h <= 160: # 绿色
239
+ green_count += 1
240
+ elif 180 <= h <= 250: # 蓝色
241
+ blue_count += 1
242
+ except:
243
+ pass
244
+
245
+ if total == 0:
246
+ return ButtonState.UNKNOWN, "unknown"
247
+
248
+ # 暂停按钮特殊处理:用图标亮度判断
249
+ if button_type == "pause" and dark_pixel_count > 0:
250
+ avg_dark_brightness = total_dark_brightness / dark_pixel_count
251
+ # 亮度 > 120 = 浅灰色 = disabled (游戏停止)
252
+ # 亮度 < 120 = 深灰色 = enabled (游戏运行中)
253
+ if avg_dark_brightness < 120:
254
+ return ButtonState.ENABLED, "dark"
255
+ else:
256
+ return ButtonState.DISABLED, "light"
257
+
258
+ # 其他按钮:用彩色判断
259
+ min_ratio = 0.03
260
+ if red_count > total * min_ratio:
261
+ return ButtonState.ENABLED, "red"
262
+ elif green_count > total * min_ratio:
263
+ return ButtonState.ENABLED, "green"
264
+ elif blue_count > total * min_ratio:
265
+ return ButtonState.ENABLED, "blue"
266
+ else:
267
+ return ButtonState.DISABLED, "gray"
268
+
269
+
270
+ def restore_window_if_minimized(hwnd: int) -> bool:
271
+ """
272
+ 如果窗口最小化,则恢复窗口
273
+
274
+ Args:
275
+ hwnd: 窗口句柄
276
+
277
+ Returns:
278
+ True 如果窗口已恢复或本来就不是最小化
279
+ """
280
+ import ctypes
281
+ import time
282
+
283
+ # 检查是否最小化
284
+ is_iconic = ctypes.windll.user32.IsIconic(hwnd)
285
+ if is_iconic:
286
+ # SW_RESTORE = 9
287
+ ctypes.windll.user32.ShowWindow(hwnd, 9)
288
+ time.sleep(0.3) # 等待窗口恢复
289
+
290
+ return True
291
+
292
+
293
+ def detect_toolbar_state(hwnd: int) -> Optional[ToolbarState]:
294
+ """
295
+ 检测工具栏按钮状态
296
+
297
+ Args:
298
+ hwnd: Roblox Studio 窗口句柄
299
+
300
+ Returns:
301
+ ToolbarState 对象,失败返回 None
302
+ """
303
+ # 如果窗口最小化,先恢复
304
+ restore_window_if_minimized(hwnd)
305
+
306
+ # 截取窗口
307
+ pil_image = capture_window_to_image(hwnd)
308
+ if pil_image is None:
309
+ return None
310
+
311
+ # 转换为 OpenCV 格式
312
+ cv_image = pil_to_cv2(pil_image)
313
+ gray_image = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
314
+
315
+ # 加载模板
316
+ play_template = load_template("play")
317
+ pause_template = load_template("pause")
318
+ stop_template = load_template("stop")
319
+
320
+ buttons = []
321
+ play_state = ButtonState.UNKNOWN
322
+ pause_state = ButtonState.UNKNOWN
323
+ stop_state = ButtonState.UNKNOWN
324
+
325
+ # 查找播放按钮
326
+ if play_template is not None:
327
+ result = find_button_by_template(gray_image, play_template)
328
+ if result:
329
+ x, y, confidence = result
330
+ h, w = play_template.shape
331
+ state, color = analyze_button_color(pil_image, x, y, w, h)
332
+ play_state = state
333
+ buttons.append(ButtonInfo(
334
+ button_type=ButtonType.PLAY,
335
+ x=x, y=y, width=w, height=h,
336
+ state=state, color_type=color, confidence=confidence
337
+ ))
338
+
339
+ # 查找暂停按钮
340
+ if pause_template is not None:
341
+ result = find_button_by_template(gray_image, pause_template)
342
+ if result:
343
+ x, y, confidence = result
344
+ h, w = pause_template.shape
345
+ state, color = analyze_button_color(pil_image, x, y, w, h, button_type="pause")
346
+ pause_state = state
347
+ buttons.append(ButtonInfo(
348
+ button_type=ButtonType.PAUSE,
349
+ x=x, y=y, width=w, height=h,
350
+ state=state, color_type=color, confidence=confidence
351
+ ))
352
+
353
+ # 查找停止按钮
354
+ if stop_template is not None:
355
+ result = find_button_by_template(gray_image, stop_template)
356
+ if result:
357
+ x, y, confidence = result
358
+ h, w = stop_template.shape
359
+ state, color = analyze_button_color(pil_image, x, y, w, h)
360
+ stop_state = state
361
+ buttons.append(ButtonInfo(
362
+ button_type=ButtonType.STOP,
363
+ x=x, y=y, width=w, height=h,
364
+ state=state, color_type=color, confidence=confidence
365
+ ))
366
+
367
+ # 推断游戏状态
368
+ game_state = infer_game_state(play_state, pause_state, stop_state)
369
+
370
+ return ToolbarState(
371
+ play=play_state,
372
+ pause=pause_state,
373
+ stop=stop_state,
374
+ game_state=game_state,
375
+ buttons=buttons
376
+ )
377
+
378
+
379
+ def infer_game_state(play: ButtonState, pause: ButtonState, stop: ButtonState) -> str:
380
+ """
381
+ 根据按钮状态推断游戏状态
382
+
383
+ 规则(基于实际观察):
384
+ - 暂停按钮深灰色(enabled) -> 游戏运行中
385
+ - 暂停按钮浅灰色(disabled) -> 游戏停止
386
+ """
387
+ if pause == ButtonState.ENABLED:
388
+ # 暂停按钮深灰色 = 游戏运行中
389
+ return "running"
390
+ else:
391
+ # 暂停按钮浅灰色 = 游戏停止
392
+ return "stopped"
393
+
394
+
395
+ def detect_toolbar_state_with_debug(hwnd: int, debug_output_path: str = None) -> Tuple[Optional[ToolbarState], dict]:
396
+ """
397
+ 检测工具栏状态(带调试信息)
398
+ """
399
+ debug_info = {}
400
+
401
+ # 如果窗口最小化,先恢复
402
+ restore_window_if_minimized(hwnd)
403
+
404
+ # 截取窗口
405
+ pil_image = capture_window_to_image(hwnd)
406
+ if pil_image is None:
407
+ debug_info["error"] = "Failed to capture window"
408
+ return None, debug_info
409
+
410
+ debug_info["window_size"] = pil_image.size
411
+
412
+ # 转换为 OpenCV 格式
413
+ cv_image = pil_to_cv2(pil_image)
414
+ gray_image = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
415
+
416
+ # 加载模板
417
+ play_template = load_template("play")
418
+ pause_template = load_template("pause")
419
+ stop_template = load_template("stop")
420
+
421
+ debug_info["templates_loaded"] = {
422
+ "play": play_template is not None,
423
+ "pause": pause_template is not None,
424
+ "stop": stop_template is not None
425
+ }
426
+
427
+ buttons = []
428
+ play_state = ButtonState.UNKNOWN
429
+ pause_state = ButtonState.UNKNOWN
430
+ stop_state = ButtonState.UNKNOWN
431
+
432
+ # 查找播放按钮
433
+ if play_template is not None:
434
+ result = find_button_by_template(gray_image, play_template)
435
+ if result:
436
+ x, y, confidence = result
437
+ h, w = play_template.shape
438
+ state, color = analyze_button_color(pil_image, x, y, w, h)
439
+ play_state = state
440
+ buttons.append(ButtonInfo(
441
+ button_type=ButtonType.PLAY,
442
+ x=x, y=y, width=w, height=h,
443
+ state=state, color_type=color, confidence=confidence
444
+ ))
445
+ debug_info["play"] = {"x": x, "y": y, "confidence": confidence, "state": state.value, "color": color}
446
+ else:
447
+ debug_info["play"] = {"error": "not found"}
448
+
449
+ # 查找暂停按钮
450
+ if pause_template is not None:
451
+ result = find_button_by_template(gray_image, pause_template)
452
+ if result:
453
+ x, y, confidence = result
454
+ h, w = pause_template.shape
455
+ state, color = analyze_button_color(pil_image, x, y, w, h, button_type="pause")
456
+ pause_state = state
457
+ buttons.append(ButtonInfo(
458
+ button_type=ButtonType.PAUSE,
459
+ x=x, y=y, width=w, height=h,
460
+ state=state, color_type=color, confidence=confidence
461
+ ))
462
+ debug_info["pause"] = {"x": x, "y": y, "confidence": confidence, "state": state.value, "color": color}
463
+ else:
464
+ debug_info["pause"] = {"error": "not found"}
465
+
466
+ # 查找停止按钮
467
+ if stop_template is not None:
468
+ result = find_button_by_template(gray_image, stop_template)
469
+ if result:
470
+ x, y, confidence = result
471
+ h, w = stop_template.shape
472
+ state, color = analyze_button_color(pil_image, x, y, w, h)
473
+ stop_state = state
474
+ buttons.append(ButtonInfo(
475
+ button_type=ButtonType.STOP,
476
+ x=x, y=y, width=w, height=h,
477
+ state=state, color_type=color, confidence=confidence
478
+ ))
479
+ debug_info["stop"] = {"x": x, "y": y, "confidence": confidence, "state": state.value, "color": color}
480
+ else:
481
+ debug_info["stop"] = {"error": "not found"}
482
+
483
+ # 保存调试图像
484
+ if debug_output_path:
485
+ debug_img = cv_image.copy()
486
+ colors = {
487
+ ButtonType.PLAY: (0, 255, 0), # 绿色
488
+ ButtonType.PAUSE: (255, 255, 0), # 青色
489
+ ButtonType.STOP: (0, 0, 255) # 红色
490
+ }
491
+ for btn in buttons:
492
+ color = colors.get(btn.button_type, (255, 255, 255))
493
+ cv2.rectangle(debug_img, (btn.x, btn.y),
494
+ (btn.x + btn.width, btn.y + btn.height), color, 2)
495
+ label = f"{btn.button_type.value}:{btn.color_type}"
496
+ cv2.putText(debug_img, label, (btn.x, btn.y - 5),
497
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
498
+ cv2.imwrite(debug_output_path, debug_img)
499
+ debug_info["debug_image"] = debug_output_path
500
+
501
+ # 推断游戏状态
502
+ game_state = infer_game_state(play_state, pause_state, stop_state)
503
+ debug_info["game_state"] = game_state
504
+
505
+ toolbar_state = ToolbarState(
506
+ play=play_state,
507
+ pause=pause_state,
508
+ stop=stop_state,
509
+ game_state=game_state,
510
+ buttons=buttons
511
+ )
512
+
513
+ return toolbar_state, debug_info