kotonebot 0.4.0__py3-none-any.whl → 0.6.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 (71) hide show
  1. kotonebot/backend/context/context.py +1002 -1002
  2. kotonebot/backend/core.py +6 -49
  3. kotonebot/backend/image.py +36 -5
  4. kotonebot/backend/loop.py +222 -208
  5. kotonebot/backend/ocr.py +7 -1
  6. kotonebot/client/device.py +108 -243
  7. kotonebot/client/host/__init__.py +34 -3
  8. kotonebot/client/host/adb_common.py +7 -9
  9. kotonebot/client/host/custom.py +6 -2
  10. kotonebot/client/host/leidian_host.py +2 -7
  11. kotonebot/client/host/mumu12_host.py +2 -7
  12. kotonebot/client/host/protocol.py +4 -3
  13. kotonebot/client/implements/__init__.py +62 -11
  14. kotonebot/client/implements/adb.py +5 -1
  15. kotonebot/client/implements/nemu_ipc/__init__.py +4 -0
  16. kotonebot/client/implements/uiautomator2.py +6 -2
  17. kotonebot/client/implements/windows.py +7 -3
  18. kotonebot/client/registration.py +1 -1
  19. kotonebot/client/scaler.py +467 -0
  20. kotonebot/config/base_config.py +1 -1
  21. kotonebot/config/config.py +61 -0
  22. kotonebot/core/__init__.py +13 -0
  23. kotonebot/core/entities/base.py +182 -0
  24. kotonebot/core/entities/compound.py +75 -0
  25. kotonebot/core/entities/ocr.py +117 -0
  26. kotonebot/core/entities/template_match.py +198 -0
  27. kotonebot/devtools/__init__.py +42 -0
  28. kotonebot/devtools/cli/__init__.py +6 -0
  29. kotonebot/devtools/cli/main.py +53 -0
  30. kotonebot/devtools/project/project.py +41 -0
  31. kotonebot/devtools/project/scanner.py +202 -0
  32. kotonebot/devtools/project/schema.py +99 -0
  33. kotonebot/devtools/resgen/__init__.py +42 -0
  34. kotonebot/devtools/resgen/codegen.py +331 -0
  35. kotonebot/devtools/resgen/core.py +94 -0
  36. kotonebot/devtools/resgen/parsers.py +360 -0
  37. kotonebot/devtools/resgen/utils.py +158 -0
  38. kotonebot/devtools/resgen/validation.py +115 -0
  39. kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
  40. kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
  41. kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
  42. kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
  43. kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
  44. kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
  45. kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
  46. kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
  47. kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
  48. kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
  49. kotonebot/devtools/web/dist/index.html +25 -0
  50. kotonebot/devtools/web/server/__init__.py +0 -0
  51. kotonebot/devtools/web/server/rest_api.py +217 -0
  52. kotonebot/devtools/web/server/server.py +85 -0
  53. kotonebot/errors.py +7 -2
  54. kotonebot/interop/win/__init__.py +10 -1
  55. kotonebot/interop/win/_mouse.py +311 -0
  56. kotonebot/interop/win/shake_mouse.py +224 -0
  57. kotonebot/primitives/__init__.py +3 -1
  58. kotonebot/primitives/geometry.py +817 -40
  59. kotonebot/primitives/visual.py +81 -1
  60. kotonebot/ui/pushkit/image_host.py +2 -1
  61. kotonebot/ui/pushkit/wxpusher.py +2 -1
  62. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +4 -1
  63. kotonebot-0.6.0.dist-info/RECORD +105 -0
  64. kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
  65. kotonebot/client/implements/adb_raw.py +0 -159
  66. kotonebot-0.4.0.dist-info/RECORD +0 -70
  67. /kotonebot/{tools → devtools}/mirror.py +0 -0
  68. /kotonebot/{tools → devtools/project}/__init__.py +0 -0
  69. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
  70. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +0 -0
  71. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
@@ -4,9 +4,6 @@ from abc import ABC, abstractmethod
4
4
  from typing import Callable, TypeVar, Protocol, Any, Generic
5
5
  from dataclasses import dataclass
6
6
 
7
- from adbutils import adb, AdbTimeout, AdbError
8
- from adbutils._device import AdbDevice
9
-
10
7
  from kotonebot import logging
11
8
  from kotonebot.client import Device, DeviceImpl
12
9
 
@@ -118,7 +115,11 @@ class Instance(Generic[T_HostConfig], ABC):
118
115
  """
119
116
  raise NotImplementedError()
120
117
 
118
+ # TODO: [refactor] 这个方法不应该挂在 Instance,而是 AndroidEmulatorInstance 上
121
119
  def wait_available(self, timeout: float = 180):
120
+ from adbutils import adb, AdbTimeout, AdbError
121
+ from adbutils._device import AdbDevice
122
+
122
123
  logger.info('Starting to wait for emulator %s(127.0.0.1:%d) to be available...', self.name, self.adb_port)
123
124
  state = 0
124
125
  port = self.require_adb_port()
@@ -1,15 +1,66 @@
1
+ from typing import TYPE_CHECKING
1
2
  from kotonebot.util import is_windows, require_windows
2
3
 
3
- # 基础实现
4
- from . import adb # noqa: F401
5
- from . import adb_raw # noqa: F401
6
- from . import uiautomator2 # noqa: F401
4
+ if TYPE_CHECKING:
5
+ from .adb import AdbImpl, AdbImplConfig
6
+ from .uiautomator2 import UiAutomator2Impl
7
+ from .windows import WindowsImpl, WindowsImplConfig
8
+ from .remote_windows import RemoteWindowsImpl, RemoteWindowsImplConfig, RemoteWindowsServer
9
+ from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig, ExternalRendererIpc
7
10
 
8
- # Windows 实现(默认仅在 Windows 上导入)
9
- if is_windows():
11
+
12
+ def _require_windows():
13
+ global WindowsImpl, WindowsImplConfig
14
+ global RemoteWindowsImpl, RemoteWindowsImplConfig, RemoteWindowsServer
15
+ global NemuIpcImpl, NemuIpcImplConfig, ExternalRendererIpc
16
+
17
+ if not is_windows():
18
+ require_windows('"windows", "remote_windows" and "nemu_ipc" implementations')
19
+ from .windows import WindowsImpl, WindowsImplConfig
20
+ from .remote_windows import RemoteWindowsImpl, RemoteWindowsImplConfig, RemoteWindowsServer
21
+ from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig, ExternalRendererIpc
22
+
23
+ def _require_adb():
24
+ global AdbImpl, AdbImplConfig
25
+
26
+ from .adb import AdbImpl, AdbImplConfig
27
+
28
+ def _require_uiautomator2():
29
+ global UiAutomator2Impl
30
+
31
+ from .uiautomator2 import UiAutomator2Impl
32
+
33
+ _IMPORT_NAMES = [
34
+ (_require_windows, [
35
+ 'WindowsImpl', 'WindowsImplConfig',
36
+ 'RemoteWindowsImpl', 'RemoteWindowsImplConfig', 'RemoteWindowsServer',
37
+ 'NemuIpcImpl', 'NemuIpcImplConfig', 'ExternalRendererIpc'
38
+ ]),
39
+ (_require_adb, [
40
+ 'AdbImpl', 'AdbImplConfig',
41
+ ]),
42
+ (_require_uiautomator2, [
43
+ 'UiAutomator2Impl'
44
+ ]),
45
+ ]
46
+
47
+
48
+ def __getattr__(name: str):
49
+ for item in _IMPORT_NAMES:
50
+ if name in item[1]:
51
+ item[0]()
52
+ break
10
53
  try:
11
- from . import nemu_ipc # noqa: F401
12
- from . import windows # noqa: F401
13
- from . import remote_windows # noqa: F401
14
- except ImportError:
15
- require_windows('"windows" and "remote_windows" implementations')
54
+ return globals()[name]
55
+ except KeyError:
56
+ raise AttributeError(name=name)
57
+
58
+ __all__ = [
59
+ # windows
60
+ 'WindowsImpl', 'WindowsImplConfig',
61
+ 'RemoteWindowsImpl', 'RemoteWindowsImplConfig', 'RemoteWindowsServer',
62
+ 'NemuIpcImpl', 'NemuIpcImplConfig', 'ExternalRendererIpc',
63
+ # android
64
+ 'AdbImpl', 'AdbImplConfig',
65
+ 'UiAutomator2Impl'
66
+ ]
@@ -5,7 +5,11 @@ from typing_extensions import override
5
5
  import cv2
6
6
  import numpy as np
7
7
  from cv2.typing import MatLike
8
- from adbutils._device import AdbDevice as AdbUtilsDevice
8
+ try:
9
+ from adbutils._device import AdbDevice as AdbUtilsDevice
10
+ except ImportError as _e:
11
+ from kotonebot.errors import MissingDependencyError
12
+ raise MissingDependencyError(_e, 'android')
9
13
 
10
14
  from ..device import AndroidDevice
11
15
  from ..protocol import AndroidCommandable, Touchable, Screenshotable
@@ -1,3 +1,7 @@
1
+ # ruff: noqa: E402
2
+ from kotonebot.util import require_windows
3
+ require_windows('"RemoteWindowsImpl" implementation')
4
+
1
5
  from .external_renderer_ipc import ExternalRendererIpc
2
6
  from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
3
7
 
@@ -2,9 +2,13 @@ import time
2
2
  from typing import Literal
3
3
 
4
4
  import numpy as np
5
- import uiautomator2 as u2
5
+ try:
6
+ import uiautomator2 as u2
7
+ from adbutils._device import AdbDevice as AdbUtilsDevice
8
+ except ImportError as _e:
9
+ from kotonebot.errors import MissingDependencyError
10
+ raise MissingDependencyError(_e, 'android')
6
11
  from cv2.typing import MatLike
7
- from adbutils._device import AdbDevice as AdbUtilsDevice
8
12
 
9
13
  from kotonebot import logging
10
14
  from ..device import Device
@@ -9,10 +9,14 @@ from functools import cached_property
9
9
  from dataclasses import dataclass
10
10
 
11
11
  import cv2
12
- import win32ui
13
- import win32gui
14
12
  import numpy as np
15
- from ahk import AHK, MsgBoxIcon
13
+ try:
14
+ import win32ui
15
+ import win32gui
16
+ from ahk import AHK, MsgBoxIcon
17
+ except ImportError as _e:
18
+ from kotonebot.errors import MissingDependencyError
19
+ raise MissingDependencyError(_e, 'windows')
16
20
  from cv2.typing import MatLike
17
21
 
18
22
  from ..device import Device, WindowsDevice
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
  from .implements.windows import WindowsImplConfig
10
10
  from .implements.nemu_ipc import NemuIpcImplConfig
11
11
 
12
- AdbBasedImpl = Literal['adb', 'adb_raw', 'uiautomator2']
12
+ AdbBasedImpl = Literal['adb', 'uiautomator2']
13
13
  DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows', 'nemu_ipc']
14
14
 
15
15
  # --- 核心类型定义 ---
@@ -0,0 +1,467 @@
1
+ from abc import ABC
2
+ from typing import Any, overload
3
+ from typing_extensions import override
4
+
5
+ from cv2.typing import MatLike
6
+
7
+ from kotonebot.primitives.geometry import (
8
+ PointLike, RectLike, Size, SizeLike, AnyPointLike, unify_any_point
9
+ )
10
+ from kotonebot.primitives.geometry import (
11
+ is_point, is_point_f, is_rect, unify_rect,
12
+ Point, PointF, Rect
13
+ )
14
+
15
+ class AbstractScaler(ABC):
16
+ """用于定义当实际设备分辨率与预期分辨率不一致时缩放行为的接口。
17
+
18
+ 该接口定义了包括缩放图像、坐标转换、比例转换在内的方法。
19
+ """
20
+ def __init__(self) -> None:
21
+ # Accept either a `Size` instance or a plain (width, height) tuple.
22
+ self.physical_resolution: SizeLike | None = None
23
+ """物理分辨率 (width, height)。"""
24
+ self.logic_resolution: SizeLike | None = None
25
+ """逻辑分辨率 (width, height)。"""
26
+
27
+ def transform_screenshot(self, screenshot: MatLike) -> MatLike:
28
+ """处理设备画面截图数据。
29
+
30
+ :param screenshot: 原始截图数据。
31
+ :return: 处理后的截图数据。
32
+ """
33
+ ...
34
+
35
+ @overload
36
+ def logic_to_physical(self, v: AnyPointLike) -> AnyPointLike: ...
37
+ @overload
38
+ def logic_to_physical(self, v: RectLike) -> RectLike: ...
39
+ def logic_to_physical(self, v: AnyPointLike | RectLike) -> AnyPointLike | RectLike | Any:
40
+ """将逻辑坐标转换为物理坐标。
41
+
42
+ :param v: 逻辑坐标点或矩形。
43
+ :return: 转换后的物理坐标点或矩形。
44
+
45
+ Examples
46
+ --------
47
+ >>> scaler.logic_to_physical(Point(10, 20))
48
+ <<< Point(..., ...)
49
+ >>> scaler.logic_to_physical(PointF(10.6, 20.5))
50
+ <<< Point(..., ...)
51
+ >>> scaler.logic_to_physical((10, 20))
52
+ <<< Point(..., ...)
53
+ >>> scaler.logic_to_physical(Rect(10, 20, 30, 40))
54
+ <<< Rect(..., ..., ..., ...)
55
+ >>> scaler.logic_to_physical((10, 20, 30, 40))
56
+ <<< Rect(..., ..., ..., ...)
57
+ """
58
+ ...
59
+
60
+ @overload
61
+ def physical_to_logic(self, v: PointLike) -> PointLike: ...
62
+ @overload
63
+ def physical_to_logic(self, v: RectLike) -> RectLike: ...
64
+ def physical_to_logic(self, v: AnyPointLike | RectLike) -> AnyPointLike | RectLike | Any:
65
+ """将物理坐标转换为逻辑坐标。
66
+
67
+ :param v: 物理坐标点或矩形。
68
+ :return: 转换后的逻辑坐标点或矩形。
69
+
70
+ Examples
71
+ --------
72
+ 见 :meth:`logic_to_physical`。
73
+ """
74
+ ...
75
+
76
+ @overload
77
+ def fractional_to_physical(self, v: PointLike) -> PointLike: ...
78
+ @overload
79
+ def fractional_to_physical(self, v: RectLike) -> RectLike: ...
80
+ def fractional_to_physical(self, v: AnyPointLike | RectLike) -> AnyPointLike | RectLike | Any:
81
+ """将比例坐标转换为物理坐标。
82
+
83
+ :param v: 比例坐标点或矩形。
84
+ :return: 转换后的物理坐标点或矩形。
85
+
86
+ Examples
87
+ --------
88
+ 见 :meth:`logic_to_physical`。
89
+ """
90
+ ...
91
+
92
+ @overload
93
+ def physical_to_fractional(self, v: PointLike) -> PointLike: ...
94
+ @overload
95
+ def physical_to_fractional(self, v: RectLike) -> RectLike: ...
96
+ def physical_to_fractional(self, v: AnyPointLike | RectLike) -> AnyPointLike | RectLike | Any:
97
+ """将物理坐标转换为比例坐标。
98
+
99
+ :param v: 物理坐标点或矩形。
100
+ :return: 转换后的比例坐标点或矩形。
101
+
102
+ Examples
103
+ --------
104
+ 见 :meth:`logic_to_physical`。
105
+ """
106
+ ...
107
+
108
+
109
+ class ProportionalScaler(AbstractScaler):
110
+ """等比例缩放。
111
+
112
+ 支持在物理分辨率和逻辑分辨率之间进行等比例缩放转换。
113
+ 仅支持等比例缩放,若无法等比例缩放,则会抛出异常。
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ match_rotation: bool = True,
119
+ aspect_ratio_tolerance: float = 0.1
120
+ ):
121
+ """初始化等比例缩放器。"""
122
+ super().__init__()
123
+
124
+ self.match_rotation = match_rotation
125
+ """分辨率缩放是否自动匹配旋转。
126
+ 当目标与真实分辨率的宽高比不一致时,是否允许通过旋转(交换宽高)后再进行匹配。
127
+
128
+ True 表示忽略方向差异,只要宽高比一致就视为可缩放;False 表示必须匹配旋转。
129
+ """
130
+ self.aspect_ratio_tolerance = aspect_ratio_tolerance
131
+ """宽高比容差阈值。
132
+
133
+ 判断两分辨率宽高比差异是否接受的阈值。
134
+ 该值越小,对比例一致性的要求越严格。默认为 0.1(即 10% 容差)。
135
+ """
136
+
137
+ @property
138
+ def scale_ratio(self) -> float:
139
+ """获取物理分辨率相对于逻辑分辨率的缩放比例。
140
+
141
+ 由于是等比例缩放,长宽的缩放比例应当一致(在容差范围内)。
142
+ """
143
+ if self.physical_resolution is None:
144
+ raise RuntimeError("Physical resolution is not set.")
145
+ if self.logic_resolution is None:
146
+ return 1.0
147
+
148
+ phy_w, phy_h = self.physical_resolution
149
+ log_w, log_h = self.logic_resolution
150
+
151
+ if self.match_rotation:
152
+ return max(phy_w, phy_h) / max(log_w, log_h)
153
+
154
+ return phy_w / log_w
155
+
156
+ def _aspect_ratio_compatible(
157
+ self, src_size: SizeLike, tgt_size: SizeLike
158
+ ) -> bool:
159
+ """判断两个尺寸在宽高比意义上是否兼容。
160
+
161
+ 若 ``self.match_rotation`` 为 True,忽略方向(长边/短边)进行比较。
162
+ 判断标准由 ``self.aspect_ratio_tolerance`` 决定(默认 0.1)。
163
+ """
164
+ src_w, src_h = src_size
165
+ tgt_w, tgt_h = tgt_size
166
+
167
+ # 尺寸必须为正
168
+ if src_w <= 0 or src_h <= 0:
169
+ raise ValueError(f"Source size dimensions must be positive for scaling: {src_size}")
170
+ if tgt_w <= 0 or tgt_h <= 0:
171
+ raise ValueError(f"Target size dimensions must be positive for scaling: {tgt_size}")
172
+
173
+ tolerant = self.aspect_ratio_tolerance
174
+
175
+ # 直接比较宽高比
176
+ if abs((tgt_w / src_w) - (tgt_h / src_h)) <= tolerant:
177
+ return True
178
+
179
+ # 尝试忽略方向差异
180
+ if self.match_rotation:
181
+ ratio_src = max(src_w, src_h) / min(src_w, src_h)
182
+ ratio_tgt = max(tgt_w, tgt_h) / min(tgt_w, tgt_h)
183
+ return abs(ratio_src - ratio_tgt) <= tolerant
184
+
185
+ return False
186
+
187
+ def _assert_scalable(
188
+ self, source: SizeLike, target: SizeLike
189
+ ) -> SizeLike:
190
+ """校验分辨率是否可缩放,并返回调整后的目标分辨率。
191
+
192
+ 当 match_rotation 为 True 且源分辨率与目标分辨率的旋转方向不一致时,
193
+ 自动交换目标分辨率的宽高,使其与源分辨率的方向保持一致。
194
+
195
+ :param source: 源分辨率 (width, height)
196
+ :param target: 目标分辨率 (width, height)
197
+ :return: 调整后的目标分辨率 (width, height)
198
+ :raises UnscalableResolutionError: 若宽高比不兼容
199
+ """
200
+ from ..errors import UnscalableResolutionError
201
+
202
+ # 智能调整目标分辨率方向
203
+ adjusted_tgt_size = target
204
+ if self.match_rotation:
205
+ src_w, src_h = source
206
+ tgt_w, tgt_h = target
207
+
208
+ # 判断源分辨率和目标分辨率的方向
209
+ src_is_landscape = src_w > src_h
210
+ tgt_is_landscape = tgt_w > tgt_h
211
+
212
+ # 如果方向不一致,交换目标分辨率的宽高
213
+ if src_is_landscape != tgt_is_landscape:
214
+ adjusted_tgt_size = Size(tgt_h, tgt_w)
215
+
216
+ # 校验调整后的分辨率是否兼容
217
+ if not self._aspect_ratio_compatible(source, adjusted_tgt_size):
218
+ raise UnscalableResolutionError(tuple(target), tuple(source))
219
+
220
+ return adjusted_tgt_size
221
+
222
+ def transform_screenshot(self, screenshot: MatLike) -> MatLike:
223
+ """处理设备画面截图数据,将物理分辨率缩放到逻辑分辨率。
224
+
225
+ :param screenshot: 原始截图数据。
226
+ :return: 处理后的截图数据。
227
+ """
228
+ import cv2
229
+
230
+ if self.logic_resolution is None:
231
+ return screenshot
232
+
233
+ target_w, target_h = self.logic_resolution
234
+ h, w = screenshot.shape[:2]
235
+
236
+ # 校验分辨率是否可缩放并获取调整后的目标分辨率
237
+ adjusted_target = self._assert_scalable(Size(w, h), Size(target_w, target_h))
238
+
239
+ return cv2.resize(screenshot, tuple(adjusted_target))
240
+
241
+ def logic_to_physical(self, v: AnyPointLike | RectLike) -> Any:
242
+ """将逻辑坐标转换为物理坐标。
243
+
244
+ :param v: 逻辑坐标点或矩形。
245
+ :return: 转换后的物理坐标点或矩形。
246
+ """
247
+ if self.physical_resolution is None:
248
+ raise RuntimeError("Physical resolution is not set.")
249
+ if self.logic_resolution is None:
250
+ return v
251
+
252
+ # 校验分辨率是否可缩放
253
+ self._assert_scalable(self.logic_resolution, self.physical_resolution)
254
+
255
+ ratio = self.scale_ratio
256
+
257
+ # 处理点类型
258
+ if is_point(v) or is_point_f(v) or (isinstance(v, tuple) and len(v) == 2):
259
+ point = unify_any_point(v)
260
+
261
+ new_x = point.x * ratio
262
+ new_y = point.y * ratio
263
+
264
+ if isinstance(point, PointF):
265
+ return PointF(new_x, new_y, name=point.name)
266
+ else:
267
+ return Point(int(new_x), int(new_y), name=point.name)
268
+
269
+ # 处理矩形类型
270
+ if is_rect(v) or (isinstance(v, tuple) and len(v) == 4):
271
+ rect = unify_rect(v)
272
+
273
+ new_x = int(rect.x1 * ratio)
274
+ new_y = int(rect.y1 * ratio)
275
+ new_w = int(rect.w * ratio)
276
+ new_h = int(rect.h * ratio)
277
+
278
+ return Rect(new_x, new_y, new_w, new_h, name=rect.name)
279
+
280
+ return v
281
+
282
+ def physical_to_logic(self, v: AnyPointLike | RectLike) -> Any:
283
+ """将物理坐标转换为逻辑坐标。
284
+
285
+ :param v: 物理坐标点或矩形。
286
+ :return: 转换后的逻辑坐标点或矩形。
287
+ """
288
+ if self.physical_resolution is None:
289
+ raise RuntimeError("Physical resolution is not set.")
290
+ if self.logic_resolution is None:
291
+ return v
292
+
293
+ # 校验分辨率是否可缩放
294
+ self._assert_scalable(self.logic_resolution, self.physical_resolution)
295
+
296
+ # 类型断言:如果 logic_resolution 不为 None,则 _adjusted_logic_resolution 也不为 None
297
+ assert self.logic_resolution is not None
298
+
299
+ ratio = self.scale_ratio
300
+
301
+ # 处理点类型
302
+ if is_point(v) or is_point_f(v) or (isinstance(v, tuple) and len(v) == 2):
303
+ point = unify_any_point(v)
304
+
305
+ new_x = point.x / ratio
306
+ new_y = point.y / ratio
307
+
308
+ if isinstance(point, PointF):
309
+ return PointF(new_x, new_y, name=point.name)
310
+ else:
311
+ return Point(int(new_x), int(new_y), name=point.name)
312
+
313
+ # 处理矩形类型
314
+ if is_rect(v) or (isinstance(v, tuple) and len(v) == 4):
315
+ rect = unify_rect(v)
316
+
317
+ new_x = int(rect.x1 / ratio)
318
+ new_y = int(rect.y1 / ratio)
319
+ new_w = int(rect.w / ratio)
320
+ new_h = int(rect.h / ratio)
321
+
322
+ return Rect(new_x, new_y, new_w, new_h, name=rect.name)
323
+
324
+ return v
325
+
326
+ def fractional_to_physical(self, v: AnyPointLike | RectLike) -> Any:
327
+ """将比例坐标转换为物理坐标。
328
+
329
+ :param v: 比例坐标点或矩形(0-1范围)。
330
+ :return: 转换后的物理坐标点或矩形。
331
+ """
332
+ if self.physical_resolution is None:
333
+ raise RuntimeError("Physical resolution is not set.")
334
+
335
+ # 处理点类型
336
+ if is_point(v) or is_point_f(v) or (isinstance(v, tuple) and len(v) == 2):
337
+ point = unify_any_point(v)
338
+
339
+ physical_w, physical_h = self.physical_resolution
340
+
341
+ new_x = point.x * physical_w
342
+ new_y = point.y * physical_h
343
+
344
+ if isinstance(point, PointF):
345
+ return PointF(new_x, new_y, name=point.name)
346
+ else:
347
+ return Point(int(new_x), int(new_y), name=point.name)
348
+
349
+ # 处理矩形类型
350
+ if is_rect(v) or (isinstance(v, tuple) and len(v) == 4):
351
+ rect = unify_rect(v)
352
+
353
+ physical_w, physical_h = self.physical_resolution
354
+
355
+ new_x = int(rect.x1 * physical_w)
356
+ new_y = int(rect.y1 * physical_h)
357
+ new_w = int(rect.w * physical_w)
358
+ new_h = int(rect.h * physical_h)
359
+
360
+ return Rect(new_x, new_y, new_w, new_h, name=rect.name)
361
+
362
+ return v
363
+
364
+ def physical_to_fractional(self, v: AnyPointLike | RectLike) -> Any:
365
+ """将物理坐标转换为比例坐标。
366
+
367
+ :param v: 物理坐标点或矩形。
368
+ :return: 转换后的比例坐标点或矩形(0-1范围)。
369
+ """
370
+ if self.physical_resolution is None:
371
+ raise RuntimeError("Physical resolution is not set.")
372
+
373
+ # 处理点类型
374
+ if is_point(v) or is_point_f(v) or (isinstance(v, tuple) and len(v) == 2):
375
+ point = unify_any_point(v)
376
+
377
+ physical_w, physical_h = self.physical_resolution
378
+
379
+ new_x = point.x / physical_w
380
+ new_y = point.y / physical_h
381
+
382
+ # 比例坐标总是返回 PointF
383
+ return PointF(new_x, new_y, name=point.name)
384
+
385
+ # 处理矩形类型
386
+ if is_rect(v) or (isinstance(v, tuple) and len(v) == 4):
387
+ rect = unify_rect(v)
388
+
389
+ physical_w, physical_h = self.physical_resolution
390
+
391
+ new_x = rect.x1 / physical_w
392
+ new_y = rect.y1 / physical_h
393
+ new_w = rect.w / physical_w
394
+ new_h = rect.h / physical_h
395
+
396
+ # 比例坐标的矩形需要转换为整数,但这里保持浮点精度
397
+ # 实际使用时可能需要根据具体需求调整
398
+ return Rect(int(new_x * 10000), int(new_y * 10000), int(new_w * 10000), int(new_h * 10000), name=rect.name)
399
+
400
+ return v
401
+
402
+
403
+ class LandscapeGameScaler(ProportionalScaler):
404
+ """横屏游戏等比例缩放。
405
+
406
+ 对于横屏的游戏,通常若两个分辨率的长边一致,那么画面中元素大小也一致。
407
+ 因此此缩放器会根据长边进行等比例缩放判断。
408
+ """
409
+ def __init__(
410
+ self,
411
+ aspect_ratio_tolerance: float = 0.1
412
+ ):
413
+ """初始化横屏等比例缩放器。"""
414
+ super().__init__(
415
+ match_rotation=True,
416
+ aspect_ratio_tolerance=aspect_ratio_tolerance
417
+ )
418
+
419
+ @property
420
+ def scale_ratio(self) -> float:
421
+ if self.physical_resolution is None:
422
+ raise RuntimeError("Physical resolution is not set.")
423
+ if self.logic_resolution is None:
424
+ return 1.0
425
+
426
+ # 横屏游戏根据长边(max)计算缩放比例
427
+ # Unpack explicitly to support both tuple and Vector2D/Size
428
+ phy_w, phy_h = self.physical_resolution
429
+ log_w, log_h = self.logic_resolution
430
+
431
+ return max(phy_w, phy_h) / max(log_w, log_h)
432
+
433
+ @override
434
+ def _assert_scalable(self, source: SizeLike, target: SizeLike) -> Size:
435
+ return Size(int(source[0] / self.scale_ratio), int(source[1] / self.scale_ratio))
436
+
437
+
438
+ class PortraitGameScaler(ProportionalScaler):
439
+ """竖屏游戏等比例缩放。
440
+
441
+ 对于竖屏的游戏,通常以短边(宽度)为基准进行缩放。
442
+ """
443
+ def __init__(
444
+ self,
445
+ aspect_ratio_tolerance: float = 0.1
446
+ ):
447
+ """初始化竖屏等比例缩放器。"""
448
+ super().__init__(
449
+ match_rotation=True,
450
+ aspect_ratio_tolerance=aspect_ratio_tolerance
451
+ )
452
+
453
+ @property
454
+ def scale_ratio(self) -> float:
455
+ if self.physical_resolution is None:
456
+ raise RuntimeError("Physical resolution is not set.")
457
+ if self.logic_resolution is None:
458
+ return 1.0
459
+
460
+ # 竖屏游戏根据短边(min)计算缩放比例
461
+ phy_w, phy_h = self.physical_resolution
462
+ log_w, log_h = self.logic_resolution
463
+ return min(phy_w, phy_h) / min(log_w, log_h)
464
+
465
+ @override
466
+ def _assert_scalable(self, source: SizeLike, target: SizeLike) -> Size:
467
+ return Size(int(source[0] / self.scale_ratio), int(source[1] / self.scale_ratio))
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict
6
6
 
7
7
  T = TypeVar('T')
8
8
  BackendType = Literal['custom', 'mumu12', 'mumu12v5', 'leidian', 'dmm']
9
- DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
9
+ DeviceRecipes = Literal['adb', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
10
10
 
11
11
  class ConfigBaseModel(BaseModel):
12
12
  model_config = ConfigDict(use_attribute_docstrings=True)