kotonebot 0.3.1__py3-none-any.whl → 0.5.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.
Files changed (66) hide show
  1. kotonebot/__init__.py +39 -39
  2. kotonebot/backend/bot.py +312 -302
  3. kotonebot/backend/color.py +525 -525
  4. kotonebot/backend/context/__init__.py +3 -3
  5. kotonebot/backend/context/context.py +49 -56
  6. kotonebot/backend/context/task_action.py +183 -175
  7. kotonebot/backend/core.py +129 -126
  8. kotonebot/backend/debug/entry.py +89 -89
  9. kotonebot/backend/debug/mock.py +78 -78
  10. kotonebot/backend/debug/server.py +222 -222
  11. kotonebot/backend/debug/vars.py +351 -351
  12. kotonebot/backend/dispatch.py +227 -227
  13. kotonebot/backend/flow_controller.py +196 -196
  14. kotonebot/backend/loop.py +12 -88
  15. kotonebot/backend/ocr.py +535 -529
  16. kotonebot/backend/preprocessor.py +103 -103
  17. kotonebot/client/__init__.py +9 -9
  18. kotonebot/client/device.py +528 -502
  19. kotonebot/client/fast_screenshot.py +377 -377
  20. kotonebot/client/host/__init__.py +43 -12
  21. kotonebot/client/host/adb_common.py +107 -94
  22. kotonebot/client/host/custom.py +118 -114
  23. kotonebot/client/host/leidian_host.py +196 -201
  24. kotonebot/client/host/mumu12_host.py +353 -358
  25. kotonebot/client/host/protocol.py +214 -213
  26. kotonebot/client/host/windows_common.py +58 -55
  27. kotonebot/client/implements/__init__.py +71 -7
  28. kotonebot/client/implements/adb.py +89 -85
  29. kotonebot/client/implements/adb_raw.py +162 -158
  30. kotonebot/client/implements/nemu_ipc/__init__.py +11 -7
  31. kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +284 -284
  32. kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -327
  33. kotonebot/client/implements/remote_windows.py +188 -192
  34. kotonebot/client/implements/uiautomator2.py +85 -81
  35. kotonebot/client/implements/windows.py +176 -168
  36. kotonebot/client/protocol.py +69 -69
  37. kotonebot/client/registration.py +24 -24
  38. kotonebot/config/base_config.py +96 -96
  39. kotonebot/config/manager.py +36 -36
  40. kotonebot/errors.py +76 -71
  41. kotonebot/interop/win/__init__.py +10 -0
  42. kotonebot/interop/win/_mouse.py +311 -0
  43. kotonebot/interop/win/message_box.py +313 -313
  44. kotonebot/interop/win/reg.py +37 -37
  45. kotonebot/interop/win/shortcut.py +43 -43
  46. kotonebot/interop/win/task_dialog.py +513 -469
  47. kotonebot/logging/__init__.py +2 -2
  48. kotonebot/logging/log.py +17 -17
  49. kotonebot/primitives/__init__.py +17 -17
  50. kotonebot/primitives/geometry.py +862 -290
  51. kotonebot/primitives/visual.py +63 -63
  52. kotonebot/tools/mirror.py +354 -354
  53. kotonebot/ui/file_host/sensio.py +36 -36
  54. kotonebot/ui/file_host/tmp_send.py +54 -54
  55. kotonebot/ui/pushkit/__init__.py +3 -3
  56. kotonebot/ui/pushkit/image_host.py +88 -87
  57. kotonebot/ui/pushkit/protocol.py +13 -13
  58. kotonebot/ui/pushkit/wxpusher.py +54 -53
  59. kotonebot/ui/user.py +148 -143
  60. kotonebot/util.py +436 -409
  61. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/METADATA +82 -76
  62. kotonebot-0.5.0.dist-info/RECORD +71 -0
  63. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/licenses/LICENSE +673 -673
  64. kotonebot-0.3.1.dist-info/RECORD +0 -70
  65. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/WHEEL +0 -0
  66. {kotonebot-0.3.1.dist-info → kotonebot-0.5.0.dist-info}/top_level.txt +0 -0
kotonebot/util.py CHANGED
@@ -1,409 +1,436 @@
1
- import os
2
- import time
3
- import pstats
4
- import typing
5
- import logging
6
- import cProfile
7
- from importlib import resources
8
- from functools import lru_cache
9
- from typing import Literal, Callable, TYPE_CHECKING, TypeGuard
10
- from typing_extensions import deprecated
11
-
12
- import cv2
13
- from cv2.typing import MatLike
14
- import numpy as np
15
-
16
- if TYPE_CHECKING:
17
- from kotonebot.client.protocol import Device
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
-
23
- # Rect = tuple[int, int, int, int]
24
- # """左上X, 左上Y, 宽度, 高度"""
25
- # Point = tuple[int, int]
26
- # """X, Y"""
27
- #
28
- # def is_rect(rect: typing.Any) -> TypeGuard[Rect]:
29
- # return isinstance(rect, typing.Sequence) and len(rect) == 4 and all(isinstance(i, int) for i in rect)
30
- #
31
- # def is_point(point: typing.Any) -> TypeGuard[Point]:
32
- # return isinstance(point, typing.Sequence) and len(point) == 2 and all(isinstance(i, int) for i in point)
33
-
34
- @deprecated('使用 HintBox 类与 Devtool 工具替代')
35
- def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float = 1) -> MatLike:
36
- """
37
- 按比例裁剪图像。
38
-
39
- :param img: 图像
40
- :param x1: 裁剪区域左上角相对X坐标。范围 [0, 1],默认为 0
41
- :param y1: 裁剪区域左上角相对Y坐标。范围 [0, 1],默认为 0
42
- :param x2: 裁剪区域右下角相对X坐标。范围 [0, 1],默认为 1
43
- :param y2: 裁剪区域右下角相对Y坐标。范围 [0, 1],默认为 1
44
- """
45
- h, w = img.shape[:2]
46
- x1_px = int(w * x1)
47
- y1_px = int(h * y1)
48
- x2_px = int(w * x2)
49
- y2_px = int(h * y2)
50
- return img[y1_px:y2_px, x1_px:x2_px]
51
-
52
- # @deprecated('使用 numpy 的切片替代')
53
- # def crop_rect(img: MatLike, rect: Rect) -> MatLike:
54
- # """
55
- # 按范围裁剪图像。
56
- #
57
- # :param img: 图像
58
- # :param rect: 裁剪区域。
59
- # """
60
- # x, y, w, h = rect
61
- # return img[y:y+h, x:x+w]
62
-
63
- class DeviceHookContextManager:
64
- def __init__(
65
- self,
66
- device: 'Device',
67
- *,
68
- screenshot_hook_before: Callable[[], MatLike|None] | None = None,
69
- screenshot_hook_after: Callable[[MatLike], MatLike] | None = None,
70
- click_hook_before: Callable[[int, int], tuple[int, int]] | None = None,
71
- ):
72
- self.device = device
73
- self.screenshot_hook_before = screenshot_hook_before
74
- self.screenshot_hook_after = screenshot_hook_after
75
- self.click_hook_before = click_hook_before
76
-
77
- self.old_screenshot_hook_before = self.device.screenshot_hook_before
78
- self.old_screenshot_hook_after = self.device.screenshot_hook_after
79
-
80
- def __enter__(self):
81
- if self.screenshot_hook_before is not None:
82
- self.device.screenshot_hook_before = self.screenshot_hook_before
83
- if self.screenshot_hook_after is not None:
84
- self.device.screenshot_hook_after = self.screenshot_hook_after
85
- if self.click_hook_before is not None:
86
- self.device.click_hooks_before.append(self.click_hook_before)
87
- return self.device
88
-
89
- def __exit__(self, exc_type, exc_value, traceback):
90
- self.device.screenshot_hook_before = self.old_screenshot_hook_before
91
- self.device.screenshot_hook_after = self.old_screenshot_hook_after
92
- if self.click_hook_before is not None:
93
- self.device.click_hooks_before.remove(self.click_hook_before)
94
-
95
- @deprecated('使用 HintBox 类与 Devtool 工具替代')
96
- def cropped(
97
- device: 'Device',
98
- x1: float = 0,
99
- y1: float = 0,
100
- x2: float = 1,
101
- y2: float = 1,
102
- ) -> DeviceHookContextManager:
103
- """
104
- Hook 设备截图与点击操作,将截图裁剪为指定区域,并调整点击坐标。
105
-
106
- 在进行 OCR 识别或模板匹配时,可以先使用此函数缩小图像,加快速度。
107
-
108
- :param device: 设备对象
109
- :param x1: 裁剪区域左上角相对X坐标。范围 [0, 1],默认为 0
110
- :param y1: 裁剪区域左上角相对Y坐标。范围 [0, 1],默认为 0
111
- :param x2: 裁剪区域右下角相对X坐标。范围 [0, 1],默认为 1
112
- :param y2: 裁剪区域右下角相对Y坐标。范围 [0, 1],默认为 1
113
- """
114
- def _screenshot_hook(img: MatLike) -> MatLike:
115
- return crop(img, x1, y1, x2, y2)
116
- def _click_hook(x: int, y: int) -> tuple[int, int]:
117
- w, h = device.screen_size
118
- x_px = int(x1 * w + x)
119
- y_px = int(y1 * h + y)
120
- return x_px, y_px
121
- return DeviceHookContextManager(
122
- device,
123
- screenshot_hook_after=_screenshot_hook,
124
- click_hook_before=_click_hook,
125
- )
126
-
127
- def until(
128
- condition: Callable[[], bool],
129
- timeout: float=60,
130
- interval: float=0.5,
131
- critical: bool=False
132
- ) -> bool:
133
- """
134
- 等待条件成立,如果条件不成立,则返回 False 或抛出异常。
135
-
136
- :param condition: 条件函数。
137
- :param timeout: 等待时间,单位为秒。
138
- :param interval: 检查条件的时间间隔,单位为秒。
139
- :param critical: 如果条件不成立,是否抛出异常。
140
- """
141
- start = time.time()
142
- while not condition():
143
- if time.time() - start > timeout:
144
- if critical:
145
- raise TimeoutError(f"Timeout while waiting for condition {condition.__name__}.")
146
- return False
147
- time.sleep(interval)
148
- return True
149
-
150
-
151
- class AdaptiveWait:
152
- """
153
- 自适应延时。延迟时间会随着时间逐渐增加,直到达到最大延迟时间。
154
- """
155
- def __init__(
156
- self,
157
- base_interval: float = 0.5,
158
- max_interval: float = 10,
159
- *,
160
- timeout: float = -1,
161
- timeout_message: str = "Timeout",
162
- factor: float = 1.15,
163
- ):
164
- self.base_interval = base_interval
165
- self.max_interval = max_interval
166
- self.interval = base_interval
167
- self.factor = factor
168
- self.timeout = timeout
169
- self.start_time: float | None = time.time()
170
- self.timeout_message = timeout_message
171
-
172
- def __enter__(self):
173
- self.start_time = time.time()
174
- return self
175
-
176
- def __exit__(self, exc_type, exc_value, traceback):
177
- self.reset()
178
-
179
- def __call__(self):
180
- from kotonebot.backend.context import sleep
181
- if self.start_time is None:
182
- self.start_time = time.time()
183
- sleep(self.interval)
184
- self.interval = min(self.interval * self.factor, self.max_interval)
185
- if self.timeout > 0 and time.time() - self.start_time > self.timeout:
186
- raise TimeoutError(self.timeout_message)
187
-
188
- def reset(self):
189
- self.interval = self.base_interval
190
- self.start_time = None
191
-
192
- class Countdown:
193
- def __init__(self, sec: float):
194
- self.seconds = sec
195
- self.start_time: float | None = None
196
-
197
- def __str__(self):
198
- if self.start_time is None:
199
- return "Unstarted"
200
- else:
201
- return f"{self.seconds - (time.time() - self.start_time):.0f}s"
202
-
203
- @property
204
- def started(self) -> bool:
205
- return self.start_time is not None
206
-
207
- def start(self):
208
- if self.start_time is None:
209
- self.start_time = time.time()
210
- return self
211
-
212
- def stop(self):
213
- self.start_time = None
214
- return self
215
-
216
- def expired(self) -> bool:
217
- if self.start_time is None:
218
- return False
219
- else:
220
- return time.time() - self.start_time > self.seconds
221
-
222
- def reset(self):
223
- self.start_time = time.time()
224
- return self
225
-
226
- class Stopwatch:
227
- def __init__(self):
228
- self.start_time: float | None = None
229
- self.seconds: float = 0
230
-
231
- def start(self):
232
- if self.start_time is not None:
233
- logger.warning('Stopwatch already started.')
234
- else:
235
- self.start_time = time.time()
236
- return self
237
-
238
- def stop(self):
239
- if self.start_time is None:
240
- logger.warning('Stopwatch not started.')
241
- else:
242
- self.seconds = time.time() - self.start_time
243
- self.start_time = None
244
- return self
245
-
246
- @property
247
- def milliseconds(self) -> int:
248
- return int(self.seconds * 1000)
249
-
250
- class Interval:
251
- def __init__(self, seconds: float = 0.3):
252
- self.seconds = seconds
253
- self.start_time = time.time()
254
- self.last_wait_time = 0
255
-
256
- def wait(self):
257
- delta = time.time() - self.start_time
258
- if delta < self.seconds:
259
- time.sleep(self.seconds - delta)
260
- self.last_wait_time = time.time() - self.start_time
261
- self.start_time = time.time()
262
-
263
- def reset(self):
264
- self.start_time = time.time()
265
-
266
- class Throttler:
267
- """
268
- 限流器,在循环中用于限制某操作的频率。
269
-
270
- 示例代码:
271
- ```python
272
- while True:
273
- device.screenshot()
274
- if throttler.request() and image.find(...):
275
- do_something()
276
- ```
277
- """
278
- def __init__(self, interval: float, max_requests: int | None = None):
279
- self.max_requests = max_requests
280
- self.interval = interval
281
- self.last_request_time: float | None = None
282
- self.request_count = 0
283
-
284
- def request(self) -> bool:
285
- """
286
- 检查是否允许请求。此函数立即返回,不会阻塞。
287
-
288
- :return: 如果允许,返回 True,否则返回 False
289
- """
290
- current_time = time.time()
291
- if self.last_request_time is None or current_time - self.last_request_time >= self.interval:
292
- self.last_request_time = current_time
293
- self.request_count = 0
294
- return True
295
- else:
296
- return False
297
-
298
- def lf_path(path: str) -> str:
299
- standalone = os.path.join('kotonebot-resource', path)
300
- if os.path.exists(standalone):
301
- return standalone
302
- return str(resources.files('kaa.res') / path)
303
-
304
- class Profiler:
305
- """
306
- 性能分析器。对 `cProfile` 的简单封装。
307
-
308
- 使用方法:
309
- ```python
310
- with Profiler('profile.prof'):
311
- # ...
312
-
313
- # 或者
314
- profiler = Profiler('profile.prof')
315
- profiler.begin()
316
- # ...
317
- profiler.end()
318
- ```
319
- """
320
- def __init__(self, file_path: str):
321
-
322
- self.profiler = cProfile.Profile()
323
- self.stats = None
324
- self.file_path = file_path
325
-
326
- def __enter__(self):
327
- self.profiler.enable()
328
- return self
329
-
330
- def __exit__(self, exc_type, exc_value, traceback):
331
- self.profiler.disable()
332
- self.stats = pstats.Stats(self.profiler)
333
- self.stats.dump_stats(self.file_path)
334
-
335
- def begin(self):
336
- self.__enter__()
337
-
338
- def end(self):
339
- self.__exit__(None, None, None)
340
-
341
- def snakeviz(self) -> bool:
342
- if self.stats is None:
343
- logger.warning("Profiler still running. Exit/End Profiler before run snakeviz.")
344
- return False
345
- try:
346
- from snakeviz import cli
347
- cli.main([os.path.abspath(self.file_path)])
348
- return True
349
-
350
- except ImportError:
351
- logger.warning("snakeviz is not installed")
352
- return False
353
-
354
- def measure_time(
355
- logger: logging.Logger | None = None,
356
- level: Literal['debug', 'info', 'warning', 'error', 'critical'] = 'info',
357
- file_path: str | None = None
358
- ) -> Callable:
359
- """
360
- 测量函数执行时间的装饰器
361
-
362
- :param logger: logging.Logger实例,如果为None则使用root logger
363
- :param level: 日志级别,可以是'debug', 'info', 'warning', 'error', 'critical'
364
- :param file_path: 记录执行时间的文件路径,如果提供则会将结果追加到文件中
365
- """
366
- def decorator(func: Callable) -> Callable:
367
- def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
368
- start_time = time.time()
369
- result = func(*args, **kwargs)
370
- end_time = time.time()
371
-
372
- execution_time = end_time - start_time
373
- message = f'Function {func.__name__} execution time: {execution_time:.3f}秒'
374
-
375
- # 使用提供的logger或默认logger
376
- log = logger or logging.getLogger()
377
-
378
- # 获取对应的日志级别方法
379
- log_method = getattr(log, level.lower())
380
-
381
- # 输出执行时间
382
- log_method(message)
383
-
384
- # 如果提供了文件路径,将结果追加到文件中
385
- if file_path:
386
- with open(file_path, 'a', encoding='utf-8') as f:
387
- f.write(f'{time.strftime("%Y-%m-%d %H:%M:%S")} - {message}\n')
388
- return result
389
- return wrapper
390
- return decorator
391
-
392
- def cv2_imread(path: str, flags: int = cv2.IMREAD_COLOR) -> MatLike:
393
- """
394
- cv2.imread 的简单封装。
395
- 支持了对带中文的路径的读取。
396
-
397
- :param path: 图片路径
398
- :param flags: cv2.imread 的 flags 参数
399
- :return: OpenCV 图片
400
- """
401
- img = cv2.imdecode(np.fromfile(path,dtype=np.uint8), flags)
402
- return img
403
-
404
- def cv2_imwrite(path: str, img: MatLike):
405
- """
406
- cv2.imwrite 的简单封装。
407
- 支持了对带中文的路径的写入。
408
- """
409
- cv2.imencode('.png', img)[1].tofile(path)
1
+ import os
2
+ import time
3
+ import pstats
4
+ import typing
5
+ import logging
6
+ import cProfile
7
+ import platform
8
+ from importlib import resources
9
+ from functools import lru_cache
10
+ from typing import Literal, Callable, TYPE_CHECKING, TypeGuard
11
+ from typing_extensions import deprecated
12
+
13
+ import cv2
14
+ from cv2.typing import MatLike
15
+ import numpy as np
16
+
17
+ if TYPE_CHECKING:
18
+ from kotonebot.client.protocol import Device
19
+
20
+ logger = logging.getLogger(__name__)
21
+ _WINDOWS_ONLY_MSG = (
22
+ "This feature is only available on Windows. "
23
+ f"You are using {platform.system()}.\n"
24
+ "The requested feature is: {feature_name}\n"
25
+ )
26
+
27
+ def is_windows() -> bool:
28
+ """检查当前是否为 Windows 系统"""
29
+ return platform.system() == 'Windows'
30
+
31
+ def is_linux() -> bool:
32
+ """检查当前是否为 Linux 系统"""
33
+ return platform.system() == 'Linux'
34
+
35
+ def is_macos() -> bool:
36
+ """检查当前是否为 macOS 系统"""
37
+ return platform.system() == 'Darwin'
38
+
39
+ def require_windows(feature_name: str | None = None, class_: type | None = None) -> None:
40
+ """要求必须在 Windows 系统上运行,否则抛出 ImportError"""
41
+ if not is_windows():
42
+ feature_name = feature_name or 'not specified'
43
+ if class_:
44
+ full_name = '.'.join([class_.__module__, class_.__name__])
45
+ feature_name += f' ({full_name})'
46
+ raise ImportError(_WINDOWS_ONLY_MSG.format(feature_name=feature_name))
47
+
48
+
49
+
50
+ # Rect = tuple[int, int, int, int]
51
+ # """左上X, 左上Y, 宽度, 高度"""
52
+ # Point = tuple[int, int]
53
+ # """X, Y"""
54
+ #
55
+ # def is_rect(rect: typing.Any) -> TypeGuard[Rect]:
56
+ # return isinstance(rect, typing.Sequence) and len(rect) == 4 and all(isinstance(i, int) for i in rect)
57
+ #
58
+ # def is_point(point: typing.Any) -> TypeGuard[Point]:
59
+ # return isinstance(point, typing.Sequence) and len(point) == 2 and all(isinstance(i, int) for i in point)
60
+
61
+ @deprecated('使用 HintBox 类与 Devtool 工具替代')
62
+ def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float = 1) -> MatLike:
63
+ """
64
+ 按比例裁剪图像。
65
+
66
+ :param img: 图像
67
+ :param x1: 裁剪区域左上角相对X坐标。范围 [0, 1],默认为 0
68
+ :param y1: 裁剪区域左上角相对Y坐标。范围 [0, 1],默认为 0
69
+ :param x2: 裁剪区域右下角相对X坐标。范围 [0, 1],默认为 1
70
+ :param y2: 裁剪区域右下角相对Y坐标。范围 [0, 1],默认为 1
71
+ """
72
+ h, w = img.shape[:2]
73
+ x1_px = int(w * x1)
74
+ y1_px = int(h * y1)
75
+ x2_px = int(w * x2)
76
+ y2_px = int(h * y2)
77
+ return img[y1_px:y2_px, x1_px:x2_px]
78
+
79
+ # @deprecated('使用 numpy 的切片替代')
80
+ # def crop_rect(img: MatLike, rect: Rect) -> MatLike:
81
+ # """
82
+ # 按范围裁剪图像。
83
+ #
84
+ # :param img: 图像
85
+ # :param rect: 裁剪区域。
86
+ # """
87
+ # x, y, w, h = rect
88
+ # return img[y:y+h, x:x+w]
89
+
90
+ class DeviceHookContextManager:
91
+ def __init__(
92
+ self,
93
+ device: 'Device',
94
+ *,
95
+ screenshot_hook_before: Callable[[], MatLike|None] | None = None,
96
+ screenshot_hook_after: Callable[[MatLike], MatLike] | None = None,
97
+ click_hook_before: Callable[[int, int], tuple[int, int]] | None = None,
98
+ ):
99
+ self.device = device
100
+ self.screenshot_hook_before = screenshot_hook_before
101
+ self.screenshot_hook_after = screenshot_hook_after
102
+ self.click_hook_before = click_hook_before
103
+
104
+ self.old_screenshot_hook_before = self.device.screenshot_hook_before
105
+ self.old_screenshot_hook_after = self.device.screenshot_hook_after
106
+
107
+ def __enter__(self):
108
+ if self.screenshot_hook_before is not None:
109
+ self.device.screenshot_hook_before = self.screenshot_hook_before
110
+ if self.screenshot_hook_after is not None:
111
+ self.device.screenshot_hook_after = self.screenshot_hook_after
112
+ if self.click_hook_before is not None:
113
+ self.device.click_hooks_before.append(self.click_hook_before)
114
+ return self.device
115
+
116
+ def __exit__(self, exc_type, exc_value, traceback):
117
+ self.device.screenshot_hook_before = self.old_screenshot_hook_before
118
+ self.device.screenshot_hook_after = self.old_screenshot_hook_after
119
+ if self.click_hook_before is not None:
120
+ self.device.click_hooks_before.remove(self.click_hook_before)
121
+
122
+ @deprecated('使用 HintBox 类与 Devtool 工具替代')
123
+ def cropped(
124
+ device: 'Device',
125
+ x1: float = 0,
126
+ y1: float = 0,
127
+ x2: float = 1,
128
+ y2: float = 1,
129
+ ) -> DeviceHookContextManager:
130
+ """
131
+ Hook 设备截图与点击操作,将截图裁剪为指定区域,并调整点击坐标。
132
+
133
+ 在进行 OCR 识别或模板匹配时,可以先使用此函数缩小图像,加快速度。
134
+
135
+ :param device: 设备对象
136
+ :param x1: 裁剪区域左上角相对X坐标。范围 [0, 1],默认为 0
137
+ :param y1: 裁剪区域左上角相对Y坐标。范围 [0, 1],默认为 0
138
+ :param x2: 裁剪区域右下角相对X坐标。范围 [0, 1],默认为 1
139
+ :param y2: 裁剪区域右下角相对Y坐标。范围 [0, 1],默认为 1
140
+ """
141
+ def _screenshot_hook(img: MatLike) -> MatLike:
142
+ return crop(img, x1, y1, x2, y2)
143
+ def _click_hook(x: int, y: int) -> tuple[int, int]:
144
+ w, h = device.screen_size
145
+ x_px = int(x1 * w + x)
146
+ y_px = int(y1 * h + y)
147
+ return x_px, y_px
148
+ return DeviceHookContextManager(
149
+ device,
150
+ screenshot_hook_after=_screenshot_hook,
151
+ click_hook_before=_click_hook,
152
+ )
153
+
154
+ def until(
155
+ condition: Callable[[], bool],
156
+ timeout: float=60,
157
+ interval: float=0.5,
158
+ critical: bool=False
159
+ ) -> bool:
160
+ """
161
+ 等待条件成立,如果条件不成立,则返回 False 或抛出异常。
162
+
163
+ :param condition: 条件函数。
164
+ :param timeout: 等待时间,单位为秒。
165
+ :param interval: 检查条件的时间间隔,单位为秒。
166
+ :param critical: 如果条件不成立,是否抛出异常。
167
+ """
168
+ start = time.time()
169
+ while not condition():
170
+ if time.time() - start > timeout:
171
+ if critical:
172
+ raise TimeoutError(f"Timeout while waiting for condition {condition.__name__}.")
173
+ return False
174
+ time.sleep(interval)
175
+ return True
176
+
177
+
178
+ class AdaptiveWait:
179
+ """
180
+ 自适应延时。延迟时间会随着时间逐渐增加,直到达到最大延迟时间。
181
+ """
182
+ def __init__(
183
+ self,
184
+ base_interval: float = 0.5,
185
+ max_interval: float = 10,
186
+ *,
187
+ timeout: float = -1,
188
+ timeout_message: str = "Timeout",
189
+ factor: float = 1.15,
190
+ ):
191
+ self.base_interval = base_interval
192
+ self.max_interval = max_interval
193
+ self.interval = base_interval
194
+ self.factor = factor
195
+ self.timeout = timeout
196
+ self.start_time: float | None = time.time()
197
+ self.timeout_message = timeout_message
198
+
199
+ def __enter__(self):
200
+ self.start_time = time.time()
201
+ return self
202
+
203
+ def __exit__(self, exc_type, exc_value, traceback):
204
+ self.reset()
205
+
206
+ def __call__(self):
207
+ from kotonebot.backend.context import sleep
208
+ if self.start_time is None:
209
+ self.start_time = time.time()
210
+ sleep(self.interval)
211
+ self.interval = min(self.interval * self.factor, self.max_interval)
212
+ if self.timeout > 0 and time.time() - self.start_time > self.timeout:
213
+ raise TimeoutError(self.timeout_message)
214
+
215
+ def reset(self):
216
+ self.interval = self.base_interval
217
+ self.start_time = None
218
+
219
+ class Countdown:
220
+ def __init__(self, sec: float):
221
+ self.seconds = sec
222
+ self.start_time: float | None = None
223
+
224
+ def __str__(self):
225
+ if self.start_time is None:
226
+ return "Unstarted"
227
+ else:
228
+ return f"{self.seconds - (time.time() - self.start_time):.0f}s"
229
+
230
+ @property
231
+ def started(self) -> bool:
232
+ return self.start_time is not None
233
+
234
+ def start(self):
235
+ if self.start_time is None:
236
+ self.start_time = time.time()
237
+ return self
238
+
239
+ def stop(self):
240
+ self.start_time = None
241
+ return self
242
+
243
+ def expired(self) -> bool:
244
+ if self.start_time is None:
245
+ return False
246
+ else:
247
+ return time.time() - self.start_time > self.seconds
248
+
249
+ def reset(self):
250
+ self.start_time = time.time()
251
+ return self
252
+
253
+ class Stopwatch:
254
+ def __init__(self):
255
+ self.start_time: float | None = None
256
+ self.seconds: float = 0
257
+
258
+ def start(self):
259
+ if self.start_time is not None:
260
+ logger.warning('Stopwatch already started.')
261
+ else:
262
+ self.start_time = time.time()
263
+ return self
264
+
265
+ def stop(self):
266
+ if self.start_time is None:
267
+ logger.warning('Stopwatch not started.')
268
+ else:
269
+ self.seconds = time.time() - self.start_time
270
+ self.start_time = None
271
+ return self
272
+
273
+ @property
274
+ def milliseconds(self) -> int:
275
+ return int(self.seconds * 1000)
276
+
277
+ class Interval:
278
+ def __init__(self, seconds: float = 0.3):
279
+ self.seconds = seconds
280
+ self.start_time = time.time()
281
+ self.last_wait_time = 0
282
+
283
+ def wait(self):
284
+ delta = time.time() - self.start_time
285
+ if delta < self.seconds:
286
+ time.sleep(self.seconds - delta)
287
+ self.last_wait_time = time.time() - self.start_time
288
+ self.start_time = time.time()
289
+
290
+ def reset(self):
291
+ self.start_time = time.time()
292
+
293
+ class Throttler:
294
+ """
295
+ 限流器,在循环中用于限制某操作的频率。
296
+
297
+ 示例代码:
298
+ ```python
299
+ while True:
300
+ device.screenshot()
301
+ if throttler.request() and image.find(...):
302
+ do_something()
303
+ ```
304
+ """
305
+ def __init__(self, interval: float, max_requests: int | None = None):
306
+ self.max_requests = max_requests
307
+ self.interval = interval
308
+ self.last_request_time: float | None = None
309
+ self.request_count = 0
310
+
311
+ def request(self) -> bool:
312
+ """
313
+ 检查是否允许请求。此函数立即返回,不会阻塞。
314
+
315
+ :return: 如果允许,返回 True,否则返回 False
316
+ """
317
+ current_time = time.time()
318
+ if self.last_request_time is None or current_time - self.last_request_time >= self.interval:
319
+ self.last_request_time = current_time
320
+ self.request_count = 0
321
+ return True
322
+ else:
323
+ return False
324
+
325
+ def lf_path(path: str) -> str:
326
+ standalone = os.path.join('kotonebot-resource', path)
327
+ if os.path.exists(standalone):
328
+ return standalone
329
+ return str(resources.files('kaa.res') / path)
330
+
331
+ class Profiler:
332
+ """
333
+ 性能分析器。对 `cProfile` 的简单封装。
334
+
335
+ 使用方法:
336
+ ```python
337
+ with Profiler('profile.prof'):
338
+ # ...
339
+
340
+ # 或者
341
+ profiler = Profiler('profile.prof')
342
+ profiler.begin()
343
+ # ...
344
+ profiler.end()
345
+ ```
346
+ """
347
+ def __init__(self, file_path: str):
348
+
349
+ self.profiler = cProfile.Profile()
350
+ self.stats = None
351
+ self.file_path = file_path
352
+
353
+ def __enter__(self):
354
+ self.profiler.enable()
355
+ return self
356
+
357
+ def __exit__(self, exc_type, exc_value, traceback):
358
+ self.profiler.disable()
359
+ self.stats = pstats.Stats(self.profiler)
360
+ self.stats.dump_stats(self.file_path)
361
+
362
+ def begin(self):
363
+ self.__enter__()
364
+
365
+ def end(self):
366
+ self.__exit__(None, None, None)
367
+
368
+ def snakeviz(self) -> bool:
369
+ if self.stats is None:
370
+ logger.warning("Profiler still running. Exit/End Profiler before run snakeviz.")
371
+ return False
372
+ try:
373
+ from snakeviz import cli
374
+ cli.main([os.path.abspath(self.file_path)])
375
+ return True
376
+
377
+ except ImportError:
378
+ logger.warning("snakeviz is not installed")
379
+ return False
380
+
381
+ def measure_time(
382
+ logger: logging.Logger | None = None,
383
+ level: Literal['debug', 'info', 'warning', 'error', 'critical'] = 'info',
384
+ file_path: str | None = None
385
+ ) -> Callable:
386
+ """
387
+ 测量函数执行时间的装饰器
388
+
389
+ :param logger: logging.Logger实例,如果为None则使用root logger
390
+ :param level: 日志级别,可以是'debug', 'info', 'warning', 'error', 'critical'
391
+ :param file_path: 记录执行时间的文件路径,如果提供则会将结果追加到文件中
392
+ """
393
+ def decorator(func: Callable) -> Callable:
394
+ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
395
+ start_time = time.time()
396
+ result = func(*args, **kwargs)
397
+ end_time = time.time()
398
+
399
+ execution_time = end_time - start_time
400
+ message = f'Function {func.__name__} execution time: {execution_time:.3f}秒'
401
+
402
+ # 使用提供的logger或默认logger
403
+ log = logger or logging.getLogger()
404
+
405
+ # 获取对应的日志级别方法
406
+ log_method = getattr(log, level.lower())
407
+
408
+ # 输出执行时间
409
+ log_method(message)
410
+
411
+ # 如果提供了文件路径,将结果追加到文件中
412
+ if file_path:
413
+ with open(file_path, 'a', encoding='utf-8') as f:
414
+ f.write(f'{time.strftime("%Y-%m-%d %H:%M:%S")} - {message}\n')
415
+ return result
416
+ return wrapper
417
+ return decorator
418
+
419
+ def cv2_imread(path: str, flags: int = cv2.IMREAD_COLOR) -> MatLike:
420
+ """
421
+ 对 cv2.imread 的简单封装。
422
+ 支持了对带中文的路径的读取。
423
+
424
+ :param path: 图片路径
425
+ :param flags: cv2.imread 的 flags 参数
426
+ :return: OpenCV 图片
427
+ """
428
+ img = cv2.imdecode(np.fromfile(path,dtype=np.uint8), flags)
429
+ return img
430
+
431
+ def cv2_imwrite(path: str, img: MatLike):
432
+ """
433
+ 对 cv2.imwrite 的简单封装。
434
+ 支持了对带中文的路径的写入。
435
+ """
436
+ cv2.imencode('.png', img)[1].tofile(path)