kotonebot 0.4.0__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.
- kotonebot/__init__.py +39 -39
- kotonebot/backend/bot.py +312 -312
- kotonebot/backend/color.py +525 -525
- kotonebot/backend/context/__init__.py +3 -3
- kotonebot/backend/context/task_action.py +183 -183
- kotonebot/backend/core.py +129 -129
- kotonebot/backend/debug/entry.py +89 -89
- kotonebot/backend/debug/mock.py +78 -78
- kotonebot/backend/debug/server.py +222 -222
- kotonebot/backend/debug/vars.py +351 -351
- kotonebot/backend/dispatch.py +227 -227
- kotonebot/backend/flow_controller.py +196 -196
- kotonebot/backend/ocr.py +535 -529
- kotonebot/backend/preprocessor.py +103 -103
- kotonebot/client/__init__.py +9 -9
- kotonebot/client/device.py +528 -503
- kotonebot/client/fast_screenshot.py +377 -377
- kotonebot/client/host/__init__.py +43 -12
- kotonebot/client/host/adb_common.py +107 -103
- kotonebot/client/host/custom.py +118 -114
- kotonebot/client/host/leidian_host.py +196 -201
- kotonebot/client/host/mumu12_host.py +353 -358
- kotonebot/client/host/protocol.py +214 -213
- kotonebot/client/host/windows_common.py +58 -58
- kotonebot/client/implements/__init__.py +71 -15
- kotonebot/client/implements/adb.py +89 -85
- kotonebot/client/implements/adb_raw.py +162 -158
- kotonebot/client/implements/nemu_ipc/__init__.py +11 -7
- kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +284 -284
- kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -327
- kotonebot/client/implements/remote_windows.py +188 -188
- kotonebot/client/implements/uiautomator2.py +85 -81
- kotonebot/client/implements/windows.py +176 -172
- kotonebot/client/protocol.py +69 -69
- kotonebot/client/registration.py +24 -24
- kotonebot/config/base_config.py +96 -96
- kotonebot/config/manager.py +36 -36
- kotonebot/errors.py +76 -71
- kotonebot/interop/win/__init__.py +10 -3
- kotonebot/interop/win/_mouse.py +311 -0
- kotonebot/interop/win/message_box.py +313 -313
- kotonebot/interop/win/reg.py +37 -37
- kotonebot/interop/win/shortcut.py +43 -43
- kotonebot/interop/win/task_dialog.py +513 -513
- kotonebot/logging/__init__.py +2 -2
- kotonebot/logging/log.py +17 -17
- kotonebot/primitives/__init__.py +17 -17
- kotonebot/primitives/geometry.py +862 -290
- kotonebot/primitives/visual.py +63 -63
- kotonebot/tools/mirror.py +354 -354
- kotonebot/ui/file_host/sensio.py +36 -36
- kotonebot/ui/file_host/tmp_send.py +54 -54
- kotonebot/ui/pushkit/__init__.py +3 -3
- kotonebot/ui/pushkit/image_host.py +88 -87
- kotonebot/ui/pushkit/protocol.py +13 -13
- kotonebot/ui/pushkit/wxpusher.py +54 -53
- kotonebot/ui/user.py +148 -148
- kotonebot/util.py +436 -436
- {kotonebot-0.4.0.dist-info → kotonebot-0.5.0.dist-info}/METADATA +82 -81
- kotonebot-0.5.0.dist-info/RECORD +71 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.5.0.dist-info}/licenses/LICENSE +673 -673
- kotonebot-0.4.0.dist-info/RECORD +0 -70
- {kotonebot-0.4.0.dist-info → kotonebot-0.5.0.dist-info}/WHEEL +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.5.0.dist-info}/top_level.txt +0 -0
kotonebot/util.py
CHANGED
|
@@ -1,436 +1,436 @@
|
|
|
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)
|
|
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)
|