ascript 1.3.27__py3-none-win_amd64.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.
- ascript/__init__.py +8 -0
- ascript/hooks/__init__.py +0 -0
- ascript/hooks/hook-ascript.py +2 -0
- ascript/windows/__init__.py +29 -0
- ascript/windows/daos/__init__.py +0 -0
- ascript/windows/daos/screen.py +143 -0
- ascript/windows/hardware/__init__.py +5 -0
- ascript/windows/hardware/keyboard.py +279 -0
- ascript/windows/hardware/logitech_driver.py +179 -0
- ascript/windows/hardware/mouse.py +308 -0
- ascript/windows/license/__init__.py +16 -0
- ascript/windows/license/achttp.cp310-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp311-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp312-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp313-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp314-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp38-win_amd64.pyd +0 -0
- ascript/windows/license/achttp.cp39-win_amd64.pyd +0 -0
- ascript/windows/media/__init__.py +0 -0
- ascript/windows/resources/__init__.py +0 -0
- ascript/windows/resources/libs/logitech/x64/LogitechGkeyEnginesWrapper.dll +0 -0
- ascript/windows/resources/libs/logitech/x86/LogitechGkeyEnginesWrapper.dll +0 -0
- ascript/windows/resources/templates/__init__.py +0 -0
- ascript/windows/resources/templates/__init__py.win.template +51 -0
- ascript/windows/resources/templates/build.as.win.template +14 -0
- ascript/windows/resources/templates/build.py +71 -0
- ascript/windows/resources/templates/logo_windows.ico +0 -0
- ascript/windows/resources/templates/windows/main.py +50 -0
- ascript/windows/resources/templates/windows/pyproject.toml +44 -0
- ascript/windows/resources/templates/windows/res/img/logo.ico +0 -0
- ascript/windows/screen/__init__.py +13 -0
- ascript/windows/screen/as_cv.py +382 -0
- ascript/windows/screen/canny.py +128 -0
- ascript/windows/screen/code_scanner.py +72 -0
- ascript/windows/screen/color_algorithms.py +277 -0
- ascript/windows/screen/find_colors.py +251 -0
- ascript/windows/screen/find_images.py +217 -0
- ascript/windows/screen/ocr/__init__.py +4 -0
- ascript/windows/screen/ocr/ocr.py +223 -0
- ascript/windows/screen/ocr/rapidocr_hook.py +301 -0
- ascript/windows/screen/ocrx.py +169 -0
- ascript/windows/screen/screen_core.py +139 -0
- ascript/windows/store/__init__.py +21 -0
- ascript/windows/system/__init__.py +8 -0
- ascript/windows/system/device.py +355 -0
- ascript/windows/system/easy_print.py +115 -0
- ascript/windows/system/env.py +492 -0
- ascript/windows/system/exception/__init__.py +1 -0
- ascript/windows/system/key_value.py +145 -0
- ascript/windows/system/pip.py +175 -0
- ascript/windows/system/r.py +165 -0
- ascript/windows/system/runtime.py +62 -0
- ascript/windows/tools/__init__.py +27 -0
- ascript/windows/tools/__main__.py +21 -0
- ascript/windows/tools/action.json +33 -0
- ascript/windows/tools/action.py +168 -0
- ascript/windows/tools/colors_tool.py +391 -0
- ascript/windows/tools/config.py +26 -0
- ascript/windows/tools/dumptool.py +18 -0
- ascript/windows/tools/eel_api.py +495 -0
- ascript/windows/tools/gpplug/__init__.py +0 -0
- ascript/windows/tools/gpplug/plug.py +70 -0
- ascript/windows/tools/mouse_catch.py +168 -0
- ascript/windows/tools/plug_manager.py +38 -0
- ascript/windows/tools/tool_api.py +906 -0
- ascript/windows/tools/tool_window.py +56 -0
- ascript/windows/tools/uia_worker.py +365 -0
- ascript/windows/tools/web/app_store_window.html +481 -0
- ascript/windows/tools/web/colors.html +532 -0
- ascript/windows/tools/web/css/screen.css +1342 -0
- ascript/windows/tools/web/css/viewtree.css +357 -0
- ascript/windows/tools/web/flot_control.html +195 -0
- ascript/windows/tools/web/img/ico/favicon.png +0 -0
- ascript/windows/tools/web/img/ico/paintpalette.png +0 -0
- ascript/windows/tools/web/img/svg/find_colors.svg +1 -0
- ascript/windows/tools/web/img/svg/find_images.svg +1 -0
- ascript/windows/tools/web/img/svg/illustration-6.svg +89 -0
- ascript/windows/tools/web/img/svg/img_default.svg +1 -0
- ascript/windows/tools/web/img/svg/ocr.svg +1 -0
- ascript/windows/tools/web/js/screen/colors.js +466 -0
- ascript/windows/tools/web/js/screen/gp_plug.js +108 -0
- ascript/windows/tools/web/js/screen/image_mouse.js +464 -0
- ascript/windows/tools/web/js/screen/img_view.js +558 -0
- ascript/windows/tools/web/js/screen/plug.js +246 -0
- ascript/windows/tools/web/js/screen/screen.js +690 -0
- ascript/windows/tools/web/js/screen/screen_api.js +778 -0
- ascript/windows/tools/web/js/screen/screen_maker.js +10 -0
- ascript/windows/tools/web/js/utils/map.js +73 -0
- ascript/windows/tools/web/js/utils/upload.js +157 -0
- ascript/windows/tools/web/js/viewtree/niv_js.js +27 -0
- ascript/windows/tools/web/js/viewtree/vtree.js +1510 -0
- ascript/windows/tools/web/libs/bootstrap/css/bootstrap.min.css +6 -0
- ascript/windows/tools/web/libs/bootstrap/js/bootstrap.bundle.min.js +7 -0
- ascript/windows/tools/web/libs/bootstrap-icons/0-circle.svg +4 -0
- ascript/windows/tools/web/libs/bootstrap-icons/0-square.svg +4 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.css +2078 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.json +2052 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.min.css +5 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.scss +2090 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff +0 -0
- ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff2 +0 -0
- ascript/windows/tools/web/libs/feather-icons/dist/feather.min.js +13 -0
- ascript/windows/tools/web/libs/feather-icons/dist/icons.json +1 -0
- ascript/windows/tools/web/libs/jquery/core.js +399 -0
- ascript/windows/tools/web/libs/jquery/jquery.js +10598 -0
- ascript/windows/tools/web/libs/jquery/jquery.min.js +4 -0
- ascript/windows/tools/web/libs/jquery/jquery.min.js.zip +0 -0
- ascript/windows/tools/web/libs/jquery/jquery.slim.js +8495 -0
- ascript/windows/tools/web/libs/jquery/jquery.slim.min.js +2 -0
- ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.js +2134 -0
- ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.min.css +16 -0
- ascript/windows/tools/web/libs/jquery-zTree/css/zTreeStyle.css +97 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_close.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_open.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/2.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/3.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/4.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/5.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/6.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/7.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/8.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/diy/9.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/line_conn.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/loading.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/tree-node-btn.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.gif +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.png +0 -0
- ascript/windows/tools/web/libs/jquery-zTree/js/jquery.ztree.all.min.js +3 -0
- ascript/windows/tools/web/libs/treeview/css/jquery.treeview.css +79 -0
- ascript/windows/tools/web/libs/treeview/img/file.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/ico_loading.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon4.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon5.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/icon6.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/py.png +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-black-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-black.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-default-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-default.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-gray-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-gray.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-red-line.gif +0 -0
- ascript/windows/tools/web/libs/treeview/img/treeview-red.gif +0 -0
- ascript/windows/tools/web/libs/treeview/js/jquery.treeview.edit.js +37 -0
- ascript/windows/tools/web/libs/treeview/js/jquery.treeview.js +250 -0
- ascript/windows/tools/web/license_window.html +255 -0
- ascript/windows/tools/web/plugs/a.html +653 -0
- ascript/windows/tools/web/plugs/demo.html +52 -0
- ascript/windows/tools/web/plugs/find_colors.html +629 -0
- ascript/windows/tools/web/plugs/find_images.html +699 -0
- ascript/windows/tools/web/plugs/rapidocr.html +954 -0
- ascript/windows/tools/web/vtree.html +496 -0
- ascript/windows/tools/webview/assets/index-HymBPl-I.css +1 -0
- ascript/windows/tools/webview/assets/index-PqA_m8tL.js +17 -0
- ascript/windows/tools/webview/index.html +13 -0
- ascript/windows/ui/__init__.py +6 -0
- ascript/windows/ui/app_store_window.py +272 -0
- ascript/windows/ui/draw_window.py +231 -0
- ascript/windows/ui/flot_test.py +297 -0
- ascript/windows/ui/flot_window.py +488 -0
- ascript/windows/ui/license_window.py +111 -0
- ascript/windows/ui/web_window.py +466 -0
- ascript/windows/utils/__init__.py +0 -0
- ascript/windows/utils/node_utils.py +20 -0
- ascript/windows/utils/screen_utils.py +280 -0
- ascript/windows/window/Selector.py +1015 -0
- ascript/windows/window/Window.py +747 -0
- ascript/windows/window/__init__.py +5 -0
- ascript/windows/window/launcher.py +213 -0
- ascript/windows/window/uia_runtime.py +26 -0
- ascript-1.3.27.dist-info/METADATA +41 -0
- ascript-1.3.27.dist-info/RECORD +177 -0
- ascript-1.3.27.dist-info/WHEEL +5 -0
- ascript-1.3.27.dist-info/entry_points.txt +2 -0
- ascript-1.3.27.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from ..screen import (
|
|
14
|
+
Canny, CodeScanner, Colors, CompareColors, FindBlock, FindColors, FindImages,
|
|
15
|
+
Ocr, OcrX, Screen,
|
|
16
|
+
)
|
|
17
|
+
from ..screen.as_cv import AsCv
|
|
18
|
+
from ..window import Window
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolApi:
|
|
22
|
+
"""UI-independent API used by the SDK's WebView2 tools."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, tool: str, initial_hwnd: Optional[int] = None):
|
|
25
|
+
self.tool = tool
|
|
26
|
+
self.initial_hwnd = int(initial_hwnd) if initial_hwnd else None
|
|
27
|
+
self._last_image = None
|
|
28
|
+
self._last_image_bytes = None
|
|
29
|
+
self._active_image_path = None
|
|
30
|
+
project_root = os.environ.get("ASCRIPT_PROJECT_ROOT", "").strip()
|
|
31
|
+
self._project_root = os.path.realpath(project_root) if project_root else ""
|
|
32
|
+
self._project_name = os.environ.get("ASCRIPT_PROJECT_NAME", "")
|
|
33
|
+
self._workspace_root = os.path.realpath(os.path.dirname(self._project_root)) if self._project_root else ""
|
|
34
|
+
self._image_templates = {}
|
|
35
|
+
data_root = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
|
36
|
+
self._capture_dir = os.path.join(data_root, "AScript", "captures")
|
|
37
|
+
os.makedirs(self._capture_dir, exist_ok=True)
|
|
38
|
+
self._working_dir = os.path.join(self._capture_dir, "working")
|
|
39
|
+
os.makedirs(self._working_dir, exist_ok=True)
|
|
40
|
+
self._template_dir = os.path.join(self._working_dir, f"templates-{os.getpid()}")
|
|
41
|
+
os.makedirs(self._template_dir, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
def get_context(self) -> Dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"tool": self.tool,
|
|
46
|
+
"initialHwnd": self.initial_hwnd,
|
|
47
|
+
"captureDir": self._capture_dir,
|
|
48
|
+
"projectRoot": self._project_root or None,
|
|
49
|
+
"projectName": self._project_name or None,
|
|
50
|
+
"projects": self._workspace_projects(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def _workspace_projects(self):
|
|
54
|
+
if not self._workspace_root or not os.path.isdir(self._workspace_root):
|
|
55
|
+
return []
|
|
56
|
+
projects = []
|
|
57
|
+
try:
|
|
58
|
+
for name in sorted(os.listdir(self._workspace_root), key=str.casefold):
|
|
59
|
+
if not re.fullmatch(r"[\w\u4e00-\u9fff-]{1,64}", name, re.UNICODE):
|
|
60
|
+
continue
|
|
61
|
+
path = os.path.realpath(os.path.join(self._workspace_root, name))
|
|
62
|
+
if os.path.dirname(path) != self._workspace_root or not os.path.isdir(path):
|
|
63
|
+
continue
|
|
64
|
+
if os.path.isfile(os.path.join(path, "__init__.py")):
|
|
65
|
+
projects.append({"name": name, "path": path})
|
|
66
|
+
except OSError:
|
|
67
|
+
return []
|
|
68
|
+
return projects
|
|
69
|
+
|
|
70
|
+
def _resolve_project_root(self, project_name=None):
|
|
71
|
+
name = str(project_name or self._project_name).strip()
|
|
72
|
+
if not name:
|
|
73
|
+
raise ValueError("请选择要保存到的工程")
|
|
74
|
+
if not self._workspace_root or not re.fullmatch(r"[\w\u4e00-\u9fff-]{1,64}", name, re.UNICODE):
|
|
75
|
+
raise ValueError("工程名称无效")
|
|
76
|
+
root = os.path.realpath(os.path.join(self._workspace_root, name))
|
|
77
|
+
if os.path.dirname(root) != self._workspace_root or not os.path.isfile(os.path.join(root, "__init__.py")):
|
|
78
|
+
raise ValueError("所选工程不存在或不在当前工作区")
|
|
79
|
+
return root, name
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
@lru_cache(maxsize=256)
|
|
83
|
+
def _product_name(process_path: str) -> str:
|
|
84
|
+
"""Read the executable's localized ProductName from Windows version info."""
|
|
85
|
+
if not process_path or process_path == "Unknown":
|
|
86
|
+
return ""
|
|
87
|
+
try:
|
|
88
|
+
import win32api
|
|
89
|
+
|
|
90
|
+
raw = win32api.GetFileVersionInfo(process_path, "\\VarFileInfo\\Translation")
|
|
91
|
+
if isinstance(raw, str):
|
|
92
|
+
values = [ord(char) for char in raw]
|
|
93
|
+
translations = list(zip(values[0::2], values[1::2]))
|
|
94
|
+
elif isinstance(raw, (bytes, bytearray)):
|
|
95
|
+
translations = [
|
|
96
|
+
(int.from_bytes(raw[index:index + 2], "little"),
|
|
97
|
+
int.from_bytes(raw[index + 2:index + 4], "little"))
|
|
98
|
+
for index in range(0, len(raw) - 3, 4)
|
|
99
|
+
]
|
|
100
|
+
else:
|
|
101
|
+
translations = list(raw or [])
|
|
102
|
+
for language, codepage in translations:
|
|
103
|
+
if not (0 <= int(language) <= 0xFFFF and 0 <= int(codepage) <= 0xFFFF):
|
|
104
|
+
continue
|
|
105
|
+
path = f"\\StringFileInfo\\{language:04x}{codepage:04x}\\ProductName"
|
|
106
|
+
value = str(win32api.GetFileVersionInfo(process_path, path) or "").strip()
|
|
107
|
+
if value:
|
|
108
|
+
return value
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _application_name(cls, window: Window, process_path: str) -> str:
|
|
115
|
+
product_name = cls._product_name(process_path)
|
|
116
|
+
if product_name:
|
|
117
|
+
return product_name
|
|
118
|
+
process_name = window.process_name or ""
|
|
119
|
+
stem = os.path.splitext(process_name)[0].strip()
|
|
120
|
+
known_apps = {
|
|
121
|
+
"chrome": "Google Chrome",
|
|
122
|
+
"msedge": "Microsoft Edge",
|
|
123
|
+
"firefox": "Mozilla Firefox",
|
|
124
|
+
"notepad": "Notepad",
|
|
125
|
+
"explorer": "File Explorer",
|
|
126
|
+
}
|
|
127
|
+
if stem.casefold() in known_apps:
|
|
128
|
+
return known_apps[stem.casefold()]
|
|
129
|
+
generic_hosts = {"applicationframehost", "shellexperiencehost", "systemsettings"}
|
|
130
|
+
if stem.casefold() in generic_hosts and window.title:
|
|
131
|
+
return window.title.split(" - ", 1)[0].strip()
|
|
132
|
+
return stem or window.title or "未知应用"
|
|
133
|
+
|
|
134
|
+
def get_windows(self) -> Dict[str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
items = []
|
|
137
|
+
for window in Window.find_all(visible_only=True):
|
|
138
|
+
if window.title:
|
|
139
|
+
process_path = window.process_path
|
|
140
|
+
app_name = self._application_name(window, process_path)
|
|
141
|
+
items.append({
|
|
142
|
+
"display": f"{window.title} [{window.hwnd}]",
|
|
143
|
+
"hwnd": window.hwnd,
|
|
144
|
+
"title": window.title,
|
|
145
|
+
"app_name": app_name,
|
|
146
|
+
"process_name": window.process_name,
|
|
147
|
+
"process_path": process_path,
|
|
148
|
+
"class_name": window.class_name,
|
|
149
|
+
})
|
|
150
|
+
items.sort(key=lambda item: (
|
|
151
|
+
item["app_name"].casefold(), item["title"].casefold(), item["hwnd"]
|
|
152
|
+
))
|
|
153
|
+
return {"status": "success", "data": items}
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
156
|
+
|
|
157
|
+
def inspect_window(self, hwnd: int, max_depth: int = 0) -> Dict[str, Any]:
|
|
158
|
+
try:
|
|
159
|
+
target = Window(int(hwnd))
|
|
160
|
+
if not target.title:
|
|
161
|
+
return {"status": "error", "message": "窗口已关闭或句柄无效"}
|
|
162
|
+
depth = max(1, min(int(max_depth or 12), 30))
|
|
163
|
+
result = self._run_uia_worker(
|
|
164
|
+
["inspect", "--hwnd", str(target.hwnd), "--depth", str(depth)],
|
|
165
|
+
timeout=25,
|
|
166
|
+
)
|
|
167
|
+
if result.get("status") == "success":
|
|
168
|
+
return result
|
|
169
|
+
fallback = self._native_window_tree(
|
|
170
|
+
target, result.get("message") or "目标窗口未暴露 UIA 控件"
|
|
171
|
+
)
|
|
172
|
+
return fallback if fallback.get("status") == "success" else result
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
return {"status": "error", "message": str(exc)}
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _run_uia_worker(arguments, timeout: int, input_text: str = ""):
|
|
178
|
+
command = [sys.executable, "-u", "-m", "ascript.windows.tools.uia_worker", *arguments]
|
|
179
|
+
try:
|
|
180
|
+
completed = subprocess.run(
|
|
181
|
+
command, input=input_text, capture_output=True, text=True,
|
|
182
|
+
encoding="utf-8", errors="replace", timeout=timeout,
|
|
183
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
184
|
+
)
|
|
185
|
+
except subprocess.TimeoutExpired:
|
|
186
|
+
return {"status": "error", "message": f"UI Automation 探测超过 {timeout} 秒,已终止隔离进程"}
|
|
187
|
+
prefix = "ASCRIPT_UIA_RESULT="
|
|
188
|
+
for line in reversed(completed.stdout.splitlines()):
|
|
189
|
+
if line.startswith(prefix):
|
|
190
|
+
try:
|
|
191
|
+
import json
|
|
192
|
+
return json.loads(line[len(prefix):])
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
return {"status": "error", "message": f"UIA 结果解析失败: {exc}"}
|
|
195
|
+
message = completed.stderr.strip() or completed.stdout.strip()
|
|
196
|
+
return {"status": "error", "message": message or "UIA 隔离进程异常退出"}
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _count_nodes(node: Dict[str, Any]) -> int:
|
|
200
|
+
return 1 + sum(ToolApi._count_nodes(child) for child in node.get("children", []))
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _native_window_tree(target: Window, reason: str) -> Dict[str, Any]:
|
|
204
|
+
"""Fallback to real child HWNDs when the application exposes no useful UIA tree."""
|
|
205
|
+
import win32gui
|
|
206
|
+
|
|
207
|
+
root_hwnd = int(target.hwnd)
|
|
208
|
+
client = target.client_rect
|
|
209
|
+
handles = []
|
|
210
|
+
win32gui.EnumChildWindows(root_hwnd, lambda hwnd, output: output.append(hwnd), handles)
|
|
211
|
+
elements = {}
|
|
212
|
+
for hwnd in handles:
|
|
213
|
+
try:
|
|
214
|
+
rect = win32gui.GetWindowRect(hwnd)
|
|
215
|
+
elements[hwnd] = {
|
|
216
|
+
"name": win32gui.GetWindowText(hwnd) or "",
|
|
217
|
+
"type": "NativeWindow",
|
|
218
|
+
"res_id": "",
|
|
219
|
+
"memory_id": int(hwnd),
|
|
220
|
+
"class_name": win32gui.GetClassName(hwnd),
|
|
221
|
+
"handle": int(hwnd),
|
|
222
|
+
"rect": {
|
|
223
|
+
"left": rect[0] - client.left,
|
|
224
|
+
"top": rect[1] - client.top,
|
|
225
|
+
"right": rect[2] - client.left,
|
|
226
|
+
"bottom": rect[3] - client.top,
|
|
227
|
+
},
|
|
228
|
+
"enabled": bool(win32gui.IsWindowEnabled(hwnd)),
|
|
229
|
+
"visible": bool(win32gui.IsWindowVisible(hwnd)),
|
|
230
|
+
"clickable": False,
|
|
231
|
+
"value": "",
|
|
232
|
+
"description": "",
|
|
233
|
+
"password": False,
|
|
234
|
+
"checkable": False,
|
|
235
|
+
"checked": False,
|
|
236
|
+
"editable": False,
|
|
237
|
+
"focusable": False,
|
|
238
|
+
"focused": False,
|
|
239
|
+
"selected": False,
|
|
240
|
+
"depth": 1,
|
|
241
|
+
"child_count": 0,
|
|
242
|
+
"children": [],
|
|
243
|
+
}
|
|
244
|
+
except Exception:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
root = {
|
|
248
|
+
"name": target.title or "",
|
|
249
|
+
"type": "Window",
|
|
250
|
+
"res_id": "",
|
|
251
|
+
"memory_id": root_hwnd,
|
|
252
|
+
"class_name": target.class_name or "",
|
|
253
|
+
"handle": root_hwnd,
|
|
254
|
+
"rect": {"left": 0, "top": 0, "right": client.width, "bottom": client.height},
|
|
255
|
+
"enabled": True,
|
|
256
|
+
"visible": target.is_visible,
|
|
257
|
+
"clickable": False,
|
|
258
|
+
"value": "",
|
|
259
|
+
"description": reason,
|
|
260
|
+
"password": False,
|
|
261
|
+
"checkable": False,
|
|
262
|
+
"checked": False,
|
|
263
|
+
"editable": False,
|
|
264
|
+
"focusable": False,
|
|
265
|
+
"focused": False,
|
|
266
|
+
"selected": False,
|
|
267
|
+
"depth": 0,
|
|
268
|
+
"child_count": 0,
|
|
269
|
+
"children": [],
|
|
270
|
+
}
|
|
271
|
+
for hwnd, element in elements.items():
|
|
272
|
+
parent_hwnd = win32gui.GetParent(hwnd)
|
|
273
|
+
while parent_hwnd not in elements and parent_hwnd != root_hwnd:
|
|
274
|
+
parent_hwnd = win32gui.GetParent(parent_hwnd)
|
|
275
|
+
if not parent_hwnd:
|
|
276
|
+
break
|
|
277
|
+
parent = elements.get(parent_hwnd, root)
|
|
278
|
+
element["depth"] = parent["depth"] + 1
|
|
279
|
+
parent["children"].append(element)
|
|
280
|
+
parent["child_count"] += 1
|
|
281
|
+
count = ToolApi._count_nodes(root)
|
|
282
|
+
if count == 1:
|
|
283
|
+
return {"status": "empty", "message": f"{reason};Win32 子窗口树也为空"}
|
|
284
|
+
return {
|
|
285
|
+
"status": "success", "backend": "Win32 HWND fallback",
|
|
286
|
+
"node_count": count, "message": reason, "data": root,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def capture(self, hwnd: Optional[int] = None) -> Dict[str, Any]:
|
|
290
|
+
try:
|
|
291
|
+
if hwnd:
|
|
292
|
+
image = Window(int(hwnd)).capture()
|
|
293
|
+
else:
|
|
294
|
+
image = Screen.capture()
|
|
295
|
+
if image is None:
|
|
296
|
+
return {"status": "error", "message": "截图失败"}
|
|
297
|
+
result = self._set_image(image)
|
|
298
|
+
if result.get("status") == "success":
|
|
299
|
+
result["cache_name"] = os.path.basename(self._cache_current_image("capture"))
|
|
300
|
+
return result
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
return {"status": "error", "message": str(exc)}
|
|
303
|
+
|
|
304
|
+
def _set_image(self, image) -> Dict[str, Any]:
|
|
305
|
+
if image.mode != "RGB":
|
|
306
|
+
image = image.convert("RGB")
|
|
307
|
+
# Keep the lossless source for color algorithms. JPEG is only the
|
|
308
|
+
# transport/preview format and must not change sampled RGB values.
|
|
309
|
+
self._last_image = image.copy()
|
|
310
|
+
image_bytes = Screen.encode_image(image, "JPEG")
|
|
311
|
+
self._last_image_bytes = image_bytes
|
|
312
|
+
self._active_image_path = os.path.join(self._working_dir, "current.jpg")
|
|
313
|
+
with open(self._active_image_path, "wb") as active_file:
|
|
314
|
+
active_file.write(image_bytes)
|
|
315
|
+
encoded = base64.b64encode(image_bytes).decode("ascii")
|
|
316
|
+
return {
|
|
317
|
+
"status": "success",
|
|
318
|
+
"data": f"data:image/jpeg;base64,{encoded}",
|
|
319
|
+
"width": image.width,
|
|
320
|
+
"height": image.height,
|
|
321
|
+
"cache_path": self._active_image_path,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
def get_pixel_color(self, x: int, y: int) -> Dict[str, Any]:
|
|
325
|
+
if self._last_image is None:
|
|
326
|
+
return {"status": "error", "message": "请先截图"}
|
|
327
|
+
x, y = int(x), int(y)
|
|
328
|
+
if x < 0 or y < 0 or x >= self._last_image.width or y >= self._last_image.height:
|
|
329
|
+
return {"status": "error", "message": "取色坐标超出图片范围"}
|
|
330
|
+
red, green, blue = self._last_image.getpixel((x, y))[:3]
|
|
331
|
+
return {
|
|
332
|
+
"status": "success",
|
|
333
|
+
"data": f"#{red:02X}{green:02X}{blue:02X}",
|
|
334
|
+
"x": x,
|
|
335
|
+
"y": y,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def load_image(self, data_url: str) -> Dict[str, Any]:
|
|
339
|
+
try:
|
|
340
|
+
if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
|
|
341
|
+
return {"status": "error", "message": "请选择图片文件"}
|
|
342
|
+
header, encoded = data_url.split(",", 1)
|
|
343
|
+
if not header.endswith(";base64") or len(encoded) > 28_000_000:
|
|
344
|
+
return {"status": "error", "message": "图片格式不支持或文件过大(上限 20 MB)"}
|
|
345
|
+
from PIL import Image
|
|
346
|
+
|
|
347
|
+
with Image.open(io.BytesIO(base64.b64decode(encoded, validate=True))) as image:
|
|
348
|
+
if image.width * image.height > 80_000_000:
|
|
349
|
+
return {"status": "error", "message": "图片像素过大"}
|
|
350
|
+
image.load()
|
|
351
|
+
result = self._set_image(image)
|
|
352
|
+
if result.get("status") == "success":
|
|
353
|
+
result["cache_name"] = os.path.basename(self._cache_current_image("upload"))
|
|
354
|
+
return result
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
return {"status": "error", "message": f"图片打开失败:{exc}"}
|
|
357
|
+
|
|
358
|
+
def test_selector(self, expression: str) -> Dict[str, Any]:
|
|
359
|
+
result = self._run_uia_worker(["test"], timeout=15, input_text=expression)
|
|
360
|
+
result.setdefault("data", [])
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
def test_selector_spec(self, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
364
|
+
import json
|
|
365
|
+
|
|
366
|
+
result = self._run_uia_worker(
|
|
367
|
+
["test-spec"], timeout=15,
|
|
368
|
+
input_text=json.dumps(spec, ensure_ascii=False, separators=(",", ":")),
|
|
369
|
+
)
|
|
370
|
+
result.setdefault("data", [])
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
def find_colors(self, pattern: str, threshold: float = 0.95, max_results: int = 50,
|
|
374
|
+
rect=None, ori: int = 2, space: int = 5, mode: int = 0,
|
|
375
|
+
find_one: bool = False) -> Dict[str, Any]:
|
|
376
|
+
try:
|
|
377
|
+
if self._last_image is None:
|
|
378
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
379
|
+
results = FindColors.find_all(
|
|
380
|
+
pattern,
|
|
381
|
+
rect=self._rect(rect),
|
|
382
|
+
threshold=float(threshold),
|
|
383
|
+
ori=int(ori),
|
|
384
|
+
exclusion_radius=max(0, int(space)),
|
|
385
|
+
im_source=self._last_image,
|
|
386
|
+
max_res=1 if find_one else max(1, min(int(max_results), 500)),
|
|
387
|
+
mode=int(mode),
|
|
388
|
+
)
|
|
389
|
+
return {"status": "success", "data": [{"x": x, "y": y} for x, y in results]}
|
|
390
|
+
except Exception as exc:
|
|
391
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _rect(rect):
|
|
395
|
+
if not rect or len(rect) != 4:
|
|
396
|
+
return None
|
|
397
|
+
return [int(value) for value in rect]
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _encode_cv_image(image) -> str:
|
|
401
|
+
import cv2
|
|
402
|
+
|
|
403
|
+
ok, encoded = cv2.imencode(".png", image)
|
|
404
|
+
if not ok:
|
|
405
|
+
raise RuntimeError("图像编码失败")
|
|
406
|
+
return "data:image/png;base64," + base64.b64encode(encoded.tobytes()).decode("ascii")
|
|
407
|
+
|
|
408
|
+
def _full_preview(self, output, rect=None):
|
|
409
|
+
"""Composite a cropped algorithm preview back into capture coordinates."""
|
|
410
|
+
import cv2
|
|
411
|
+
|
|
412
|
+
source = AsCv.to_numpy(self._last_image).copy()
|
|
413
|
+
if output.ndim == 2:
|
|
414
|
+
output = cv2.cvtColor(output, cv2.COLOR_GRAY2BGR)
|
|
415
|
+
if rect is None:
|
|
416
|
+
return output
|
|
417
|
+
left, top, right, bottom = rect
|
|
418
|
+
target_width = max(1, right - left)
|
|
419
|
+
target_height = max(1, bottom - top)
|
|
420
|
+
if output.shape[1] != target_width or output.shape[0] != target_height:
|
|
421
|
+
output = cv2.resize(output, (target_width, target_height), interpolation=cv2.INTER_NEAREST)
|
|
422
|
+
source[top:bottom, left:right] = output
|
|
423
|
+
return source
|
|
424
|
+
|
|
425
|
+
def compare_colors(self, pattern: str, threshold: float = 0.95, required: float = 1.0,
|
|
426
|
+
mode: int = 0, tolerance=None) -> Dict[str, Any]:
|
|
427
|
+
try:
|
|
428
|
+
if self._last_image is None:
|
|
429
|
+
return {"status": "error", "message": "请先截图"}
|
|
430
|
+
diff = tolerance if tolerance is not None else float(threshold)
|
|
431
|
+
data = CompareColors.compare_detail(
|
|
432
|
+
pattern, diff=diff, sim=float(required), mode=int(mode), image=self._last_image
|
|
433
|
+
)
|
|
434
|
+
return {"status": "success", "data": data}
|
|
435
|
+
except Exception as exc:
|
|
436
|
+
return {"status": "error", "message": str(exc)}
|
|
437
|
+
|
|
438
|
+
def count_colors(self, colors: str, threshold: float = 0.95, rect=None,
|
|
439
|
+
draw_mode: int = Colors.DRAW_THRESHOLD, mode: int = 0) -> Dict[str, Any]:
|
|
440
|
+
try:
|
|
441
|
+
if self._last_image is None:
|
|
442
|
+
return {"status": "error", "message": "请先截图"}
|
|
443
|
+
search_rect = self._rect(rect)
|
|
444
|
+
output, count, offset = Colors.filter(
|
|
445
|
+
colors, rect=search_rect, sim=float(threshold), image=self._last_image,
|
|
446
|
+
draw_mode=int(draw_mode), mode=int(mode),
|
|
447
|
+
)
|
|
448
|
+
return {
|
|
449
|
+
"status": "success",
|
|
450
|
+
"data": {
|
|
451
|
+
"count": count,
|
|
452
|
+
"offset": list(offset),
|
|
453
|
+
"preview": self._encode_cv_image(self._full_preview(output, search_rect)),
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
except Exception as exc:
|
|
457
|
+
return {"status": "error", "message": str(exc)}
|
|
458
|
+
|
|
459
|
+
def find_blocks(self, colors: str, width: int = 50, height: int = 50, count: int = 100,
|
|
460
|
+
threshold: float = 0.9, rect=None, min_area: int = 10,
|
|
461
|
+
max_results: int = 100, mode: int = 0, space: int = 0) -> Dict[str, Any]:
|
|
462
|
+
try:
|
|
463
|
+
if self._last_image is None:
|
|
464
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
465
|
+
search_rect = self._rect(rect)
|
|
466
|
+
points = FindBlock.find_all(
|
|
467
|
+
colors, int(width), int(height), int(count), search_rect, float(threshold),
|
|
468
|
+
mode=int(mode), space=int(space), image=self._last_image,
|
|
469
|
+
max_results=int(max_results),
|
|
470
|
+
)
|
|
471
|
+
ranges = FindBlock.get_range(
|
|
472
|
+
colors, search_rect, float(threshold), mode=int(mode),
|
|
473
|
+
min_area=int(min_area), image=self._last_image
|
|
474
|
+
)
|
|
475
|
+
return {
|
|
476
|
+
"status": "success",
|
|
477
|
+
"data": {
|
|
478
|
+
"points": [{"x": x, "y": y} for x, y in points],
|
|
479
|
+
"ranges": ranges,
|
|
480
|
+
"count": len(ranges),
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
except Exception as exc:
|
|
484
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _safe_image_name(filename: str) -> str:
|
|
488
|
+
name = str(filename or "").strip()
|
|
489
|
+
if not name:
|
|
490
|
+
raise ValueError("请输入模板名称")
|
|
491
|
+
if os.path.basename(name) != name or not re.fullmatch(r"[^\\/:*?\"<>|\x00-\x1f]{1,120}", name):
|
|
492
|
+
raise ValueError("模板名称包含无效字符")
|
|
493
|
+
stem, extension = os.path.splitext(name)
|
|
494
|
+
if not stem:
|
|
495
|
+
raise ValueError("模板名称无效")
|
|
496
|
+
if extension.lower() != ".png":
|
|
497
|
+
name = stem + ".png"
|
|
498
|
+
return name
|
|
499
|
+
|
|
500
|
+
def _next_template_name(self) -> str:
|
|
501
|
+
used = {item["name"].casefold() for item in self._image_templates.values()}
|
|
502
|
+
index = 1
|
|
503
|
+
while f"{index}.png".casefold() in used:
|
|
504
|
+
index += 1
|
|
505
|
+
return f"{index}.png"
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _template_payload(template_id: str, item: Dict[str, Any]) -> Dict[str, Any]:
|
|
509
|
+
return {
|
|
510
|
+
"id": template_id,
|
|
511
|
+
"name": item["name"],
|
|
512
|
+
"width": item["width"],
|
|
513
|
+
"height": item["height"],
|
|
514
|
+
"thumbnail": item["thumbnail"],
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _template_image(image):
|
|
519
|
+
has_alpha = image.mode in {"RGBA", "LA"} or (
|
|
520
|
+
image.mode == "P" and "transparency" in image.info
|
|
521
|
+
)
|
|
522
|
+
return image.convert("RGBA" if has_alpha else "RGB")
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _image_thumbnail(image, maximum=(240, 140)) -> str:
|
|
526
|
+
thumbnail = image.copy()
|
|
527
|
+
thumbnail.thumbnail(maximum)
|
|
528
|
+
preview = io.BytesIO()
|
|
529
|
+
thumbnail.save(preview, format="PNG")
|
|
530
|
+
return "data:image/png;base64," + base64.b64encode(preview.getvalue()).decode("ascii")
|
|
531
|
+
|
|
532
|
+
def _store_image_template(self, image, filename: str = "") -> Dict[str, Any]:
|
|
533
|
+
name = self._safe_image_name(filename or self._next_template_name())
|
|
534
|
+
if any(item["name"].casefold() == name.casefold()
|
|
535
|
+
for item in self._image_templates.values()):
|
|
536
|
+
return {"status": "error", "message": f"模板名称已存在: {name}"}
|
|
537
|
+
image = self._template_image(image)
|
|
538
|
+
template_id = uuid.uuid4().hex
|
|
539
|
+
path = os.path.join(self._template_dir, template_id + ".png")
|
|
540
|
+
image.save(path, format="PNG")
|
|
541
|
+
item = {
|
|
542
|
+
"name": name,
|
|
543
|
+
"path": path,
|
|
544
|
+
"width": image.width,
|
|
545
|
+
"height": image.height,
|
|
546
|
+
"thumbnail": self._image_thumbnail(image),
|
|
547
|
+
}
|
|
548
|
+
self._image_templates[template_id] = item
|
|
549
|
+
return {
|
|
550
|
+
"status": "success",
|
|
551
|
+
"data": self._template_payload(template_id, item),
|
|
552
|
+
"next_name": self._next_template_name(),
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
def _project_image_dir(self, create: bool = False, project_name=None) -> str:
|
|
556
|
+
project_root, _ = self._resolve_project_root(project_name)
|
|
557
|
+
destination = os.path.realpath(os.path.join(project_root, "res", "img"))
|
|
558
|
+
if os.path.commonpath([project_root, destination]) != project_root:
|
|
559
|
+
raise ValueError("工程资源目录无效")
|
|
560
|
+
if create:
|
|
561
|
+
os.makedirs(destination, exist_ok=True)
|
|
562
|
+
return destination
|
|
563
|
+
|
|
564
|
+
def list_image_templates(self) -> Dict[str, Any]:
|
|
565
|
+
return {
|
|
566
|
+
"status": "success",
|
|
567
|
+
"data": [self._template_payload(template_id, item)
|
|
568
|
+
for template_id, item in self._image_templates.items()],
|
|
569
|
+
"next_name": self._next_template_name(),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
def add_image_template(self, rect, filename: str = "") -> Dict[str, Any]:
|
|
573
|
+
try:
|
|
574
|
+
if self._last_image is None:
|
|
575
|
+
return {"status": "error", "message": "请先截图"}
|
|
576
|
+
crop_rect = self._rect(rect)
|
|
577
|
+
if crop_rect is None:
|
|
578
|
+
return {"status": "error", "message": "请先框选要查找的子图"}
|
|
579
|
+
left, top, right, bottom = crop_rect
|
|
580
|
+
left = max(0, min(left, self._last_image.width))
|
|
581
|
+
right = max(0, min(right, self._last_image.width))
|
|
582
|
+
top = max(0, min(top, self._last_image.height))
|
|
583
|
+
bottom = max(0, min(bottom, self._last_image.height))
|
|
584
|
+
if right <= left or bottom <= top:
|
|
585
|
+
return {"status": "error", "message": "模板选区为空"}
|
|
586
|
+
image = self._last_image.crop((left, top, right, bottom))
|
|
587
|
+
return self._store_image_template(image, filename)
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
return {"status": "error", "message": str(exc)}
|
|
590
|
+
|
|
591
|
+
def import_image_template(self, data_url: str, filename: str = "") -> Dict[str, Any]:
|
|
592
|
+
try:
|
|
593
|
+
header, encoded = str(data_url).split(",", 1)
|
|
594
|
+
if not header.startswith("data:image/") or not header.endswith(";base64"):
|
|
595
|
+
return {"status": "error", "message": "小图格式不支持"}
|
|
596
|
+
if len(encoded) > 28_000_000:
|
|
597
|
+
return {"status": "error", "message": "小图文件过大(上限 20 MB)"}
|
|
598
|
+
from PIL import Image
|
|
599
|
+
|
|
600
|
+
with Image.open(io.BytesIO(base64.b64decode(encoded, validate=True))) as image:
|
|
601
|
+
if image.width * image.height > 80_000_000:
|
|
602
|
+
return {"status": "error", "message": "小图像素过大"}
|
|
603
|
+
image.load()
|
|
604
|
+
return self._store_image_template(image, filename)
|
|
605
|
+
except Exception as exc:
|
|
606
|
+
return {"status": "error", "message": f"小图导入失败:{exc}"}
|
|
607
|
+
|
|
608
|
+
def list_project_images(self) -> Dict[str, Any]:
|
|
609
|
+
try:
|
|
610
|
+
root = self._project_image_dir()
|
|
611
|
+
except ValueError as exc:
|
|
612
|
+
return {"status": "success", "data": [], "available": False, "message": str(exc)}
|
|
613
|
+
if not os.path.isdir(root):
|
|
614
|
+
return {"status": "success", "data": [], "available": True}
|
|
615
|
+
items = []
|
|
616
|
+
from PIL import Image
|
|
617
|
+
|
|
618
|
+
for name in sorted(os.listdir(root), key=str.casefold):
|
|
619
|
+
if not name.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".webp")):
|
|
620
|
+
continue
|
|
621
|
+
path = os.path.realpath(os.path.join(root, name))
|
|
622
|
+
if os.path.commonpath([root, path]) != root or not os.path.isfile(path):
|
|
623
|
+
continue
|
|
624
|
+
try:
|
|
625
|
+
with Image.open(path) as image:
|
|
626
|
+
image.load()
|
|
627
|
+
items.append({
|
|
628
|
+
"name": name,
|
|
629
|
+
"width": image.width,
|
|
630
|
+
"height": image.height,
|
|
631
|
+
"thumbnail": self._image_thumbnail(self._template_image(image), (120, 80)),
|
|
632
|
+
})
|
|
633
|
+
except (OSError, ValueError):
|
|
634
|
+
continue
|
|
635
|
+
return {"status": "success", "data": items, "available": True}
|
|
636
|
+
|
|
637
|
+
def add_project_image_template(self, filename: str) -> Dict[str, Any]:
|
|
638
|
+
try:
|
|
639
|
+
root = self._project_image_dir()
|
|
640
|
+
name = os.path.basename(str(filename))
|
|
641
|
+
if name != filename or not name.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".webp")):
|
|
642
|
+
return {"status": "error", "message": "工程图片名称无效"}
|
|
643
|
+
path = os.path.realpath(os.path.join(root, name))
|
|
644
|
+
if os.path.commonpath([root, path]) != root or not os.path.isfile(path):
|
|
645
|
+
return {"status": "error", "message": f"工程图片不存在: {name}"}
|
|
646
|
+
from PIL import Image
|
|
647
|
+
|
|
648
|
+
with Image.open(path) as image:
|
|
649
|
+
image.load()
|
|
650
|
+
return self._store_image_template(image, name)
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
return {"status": "error", "message": str(exc)}
|
|
653
|
+
|
|
654
|
+
def clear_image_templates(self) -> Dict[str, Any]:
|
|
655
|
+
removed = len(self._image_templates)
|
|
656
|
+
for item in self._image_templates.values():
|
|
657
|
+
try:
|
|
658
|
+
os.remove(item["path"])
|
|
659
|
+
except FileNotFoundError:
|
|
660
|
+
pass
|
|
661
|
+
self._image_templates.clear()
|
|
662
|
+
return {"status": "success", "removed": removed, "next_name": self._next_template_name()}
|
|
663
|
+
|
|
664
|
+
def rename_image_template(self, template_id: str, filename: str) -> Dict[str, Any]:
|
|
665
|
+
try:
|
|
666
|
+
item = self._image_templates.get(str(template_id))
|
|
667
|
+
if item is None:
|
|
668
|
+
return {"status": "error", "message": "模板不存在"}
|
|
669
|
+
name = self._safe_image_name(filename)
|
|
670
|
+
if any(key != template_id and value["name"].casefold() == name.casefold()
|
|
671
|
+
for key, value in self._image_templates.items()):
|
|
672
|
+
return {"status": "error", "message": f"模板名称已存在: {name}"}
|
|
673
|
+
item["name"] = name
|
|
674
|
+
return {"status": "success", "data": self._template_payload(template_id, item)}
|
|
675
|
+
except Exception as exc:
|
|
676
|
+
return {"status": "error", "message": str(exc)}
|
|
677
|
+
|
|
678
|
+
def delete_image_template(self, template_id: str) -> Dict[str, Any]:
|
|
679
|
+
item = self._image_templates.pop(str(template_id), None)
|
|
680
|
+
if item is None:
|
|
681
|
+
return {"status": "error", "message": "模板不存在"}
|
|
682
|
+
try:
|
|
683
|
+
os.remove(item["path"])
|
|
684
|
+
except FileNotFoundError:
|
|
685
|
+
pass
|
|
686
|
+
return {"status": "success", "next_name": self._next_template_name()}
|
|
687
|
+
|
|
688
|
+
def save_image_templates(self, project_name=None, overwrite: bool = False) -> Dict[str, Any]:
|
|
689
|
+
try:
|
|
690
|
+
destination = self._project_image_dir(create=True, project_name=project_name)
|
|
691
|
+
conflicts = [item["name"] for item in self._image_templates.values()
|
|
692
|
+
if os.path.exists(os.path.join(destination, item["name"]))]
|
|
693
|
+
if conflicts and not overwrite:
|
|
694
|
+
return {"status": "conflict", "message": "工程中已存在同名图片", "conflicts": conflicts}
|
|
695
|
+
saved = []
|
|
696
|
+
for item in self._image_templates.values():
|
|
697
|
+
target = os.path.join(destination, item["name"])
|
|
698
|
+
shutil.copyfile(item["path"], target)
|
|
699
|
+
saved.append(target)
|
|
700
|
+
return {"status": "success", "data": saved, "project": str(project_name or self._project_name)}
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
return {"status": "error", "message": str(exc)}
|
|
703
|
+
|
|
704
|
+
def find_images(self, template_ids, search_rect=None, confidence: float = 0.9,
|
|
705
|
+
max_results: int = 50, algorithm: str = "template",
|
|
706
|
+
rgb: bool = False, find_one: bool = False, ori: int = 2,
|
|
707
|
+
scale_range=None) -> Dict[str, Any]:
|
|
708
|
+
try:
|
|
709
|
+
if self._last_image is None:
|
|
710
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
711
|
+
ids = [str(value) for value in (template_ids or [])]
|
|
712
|
+
templates = [self._image_templates[value]["path"] for value in ids
|
|
713
|
+
if value in self._image_templates]
|
|
714
|
+
if not templates:
|
|
715
|
+
return {"status": "error", "message": "请先添加要查找的子图", "data": []}
|
|
716
|
+
method = str(algorithm or "template").lower()
|
|
717
|
+
function = {
|
|
718
|
+
"template": FindImages.find_template if find_one else FindImages.find_all_template,
|
|
719
|
+
"sift": FindImages.find_sift if find_one else FindImages.find_all_sift,
|
|
720
|
+
"mixed": FindImages.find if find_one else FindImages.find_all,
|
|
721
|
+
}.get(method)
|
|
722
|
+
if function is None:
|
|
723
|
+
return {"status": "error", "message": f"不支持的找图模式: {algorithm}", "data": []}
|
|
724
|
+
kwargs = {
|
|
725
|
+
"rect": self._rect(search_rect),
|
|
726
|
+
"confidence": float(confidence),
|
|
727
|
+
"rgb": bool(rgb),
|
|
728
|
+
"source_img": self._last_image,
|
|
729
|
+
"ori": int(ori),
|
|
730
|
+
"scale_range": scale_range,
|
|
731
|
+
}
|
|
732
|
+
if not find_one:
|
|
733
|
+
kwargs["maxcnt"] = max(1, min(int(max_results), 500))
|
|
734
|
+
matches = function(templates, **kwargs)
|
|
735
|
+
if find_one:
|
|
736
|
+
matches = [matches] if matches is not None else []
|
|
737
|
+
data = [match.to_dict() if hasattr(match, "to_dict") else dict(match) for match in matches]
|
|
738
|
+
return {"status": "success", "data": data}
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
741
|
+
|
|
742
|
+
def run_ocr(self, rect=None, confidence: float = 0.1, pattern: str = "", binary: int = -1,
|
|
743
|
+
operation: str = "recognize") -> Dict[str, Any]:
|
|
744
|
+
try:
|
|
745
|
+
if self._last_image is None:
|
|
746
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
747
|
+
kwargs = {
|
|
748
|
+
"rect": self._rect(rect), "confidence": float(confidence),
|
|
749
|
+
"image": self._last_image, "binary": int(binary),
|
|
750
|
+
}
|
|
751
|
+
if operation == "recognize":
|
|
752
|
+
data = [item.to_dict() for item in Ocr.recognize(pattern=pattern or None, **kwargs)]
|
|
753
|
+
elif operation == "find_all":
|
|
754
|
+
items = Ocr.find_all(text=pattern or None, **kwargs)
|
|
755
|
+
data = [item.to_dict() for item in items] or None
|
|
756
|
+
elif operation == "find":
|
|
757
|
+
item = Ocr.find(text=pattern or None, **kwargs)
|
|
758
|
+
data = item.to_dict() if item else None
|
|
759
|
+
elif operation == "exists":
|
|
760
|
+
data = Ocr.exists(text=pattern or None, **kwargs)
|
|
761
|
+
else:
|
|
762
|
+
raise ValueError(f"不支持的 OCR 操作: {operation}")
|
|
763
|
+
return {
|
|
764
|
+
"status": "success", "backend": Ocr.backend_info(),
|
|
765
|
+
"data": data,
|
|
766
|
+
}
|
|
767
|
+
except Exception as exc:
|
|
768
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
769
|
+
|
|
770
|
+
def run_ocrx(self, font_lib: str, rect=None, confidence: float = 0.9) -> Dict[str, Any]:
|
|
771
|
+
try:
|
|
772
|
+
if self._last_image is None:
|
|
773
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
774
|
+
result = OcrX.recognize(
|
|
775
|
+
font_lib, rect=self._rect(rect), region=float(confidence), image=self._last_image
|
|
776
|
+
)
|
|
777
|
+
return {"status": "success", "data": result.to_dict()}
|
|
778
|
+
except Exception as exc:
|
|
779
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
780
|
+
|
|
781
|
+
def run_canny(self, rect=None, low: int = 50, high: int = 150, algorithm: str = "canny",
|
|
782
|
+
min_area: int = 10) -> Dict[str, Any]:
|
|
783
|
+
try:
|
|
784
|
+
if self._last_image is None:
|
|
785
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
786
|
+
search_rect = self._rect(rect)
|
|
787
|
+
results, output, edges = Canny.detect(
|
|
788
|
+
int(low), int(high), image=self._last_image, rect=search_rect,
|
|
789
|
+
algo=algorithm, area_range=[int(min_area), 0], draw=Canny.DRAW_RECT,
|
|
790
|
+
return_image=True,
|
|
791
|
+
)
|
|
792
|
+
return {
|
|
793
|
+
"status": "success",
|
|
794
|
+
"data": {
|
|
795
|
+
"items": results,
|
|
796
|
+
"preview": self._encode_cv_image(self._full_preview(output, search_rect)),
|
|
797
|
+
"edges": self._encode_cv_image(edges),
|
|
798
|
+
},
|
|
799
|
+
}
|
|
800
|
+
except Exception as exc:
|
|
801
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
802
|
+
|
|
803
|
+
def scan_codes(self, rect=None) -> Dict[str, Any]:
|
|
804
|
+
try:
|
|
805
|
+
if self._last_image is None:
|
|
806
|
+
return {"status": "error", "message": "请先截图", "data": []}
|
|
807
|
+
return {
|
|
808
|
+
"status": "success",
|
|
809
|
+
"data": CodeScanner.scan(rect=self._rect(rect), image=self._last_image),
|
|
810
|
+
}
|
|
811
|
+
except Exception as exc:
|
|
812
|
+
return {"status": "error", "message": str(exc), "data": []}
|
|
813
|
+
|
|
814
|
+
def save_capture(self, filename: str = "capture.png") -> Dict[str, Any]:
|
|
815
|
+
if self._last_image is None:
|
|
816
|
+
return {"status": "error", "message": "没有可保存的截图"}
|
|
817
|
+
safe_name = os.path.basename(filename) or "capture.png"
|
|
818
|
+
if not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
819
|
+
safe_name += ".png"
|
|
820
|
+
path = os.path.join(self._capture_dir, safe_name)
|
|
821
|
+
self._last_image.save(path)
|
|
822
|
+
return {"status": "success", "path": path}
|
|
823
|
+
|
|
824
|
+
def _cache_current_image(self, source: str) -> str:
|
|
825
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
826
|
+
path = os.path.join(self._capture_dir, f"{source}-{stamp}.jpg")
|
|
827
|
+
with open(path, "wb") as cache_file:
|
|
828
|
+
cache_file.write(self._last_image_bytes)
|
|
829
|
+
return path
|
|
830
|
+
|
|
831
|
+
def list_capture_cache(self) -> Dict[str, Any]:
|
|
832
|
+
items = []
|
|
833
|
+
for name in os.listdir(self._capture_dir):
|
|
834
|
+
path = os.path.join(self._capture_dir, name)
|
|
835
|
+
if not os.path.isfile(path) or not name.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
836
|
+
continue
|
|
837
|
+
try:
|
|
838
|
+
stat = os.stat(path)
|
|
839
|
+
from PIL import Image
|
|
840
|
+
|
|
841
|
+
with Image.open(path) as image:
|
|
842
|
+
width, height = image.size
|
|
843
|
+
thumbnail = image.copy()
|
|
844
|
+
thumbnail.thumbnail((150, 90))
|
|
845
|
+
preview = io.BytesIO()
|
|
846
|
+
thumbnail.save(preview, format="JPEG", quality=75)
|
|
847
|
+
items.append({
|
|
848
|
+
"name": name,
|
|
849
|
+
"path": path,
|
|
850
|
+
"width": width,
|
|
851
|
+
"height": height,
|
|
852
|
+
"size": stat.st_size,
|
|
853
|
+
"modified": stat.st_mtime,
|
|
854
|
+
"thumbnail": "data:image/jpeg;base64," + base64.b64encode(preview.getvalue()).decode("ascii"),
|
|
855
|
+
})
|
|
856
|
+
except (OSError, ValueError):
|
|
857
|
+
continue
|
|
858
|
+
items.sort(key=lambda item: item["modified"], reverse=True)
|
|
859
|
+
return {"status": "success", "data": items}
|
|
860
|
+
|
|
861
|
+
def load_cached_capture(self, name: str) -> Dict[str, Any]:
|
|
862
|
+
try:
|
|
863
|
+
safe_name = os.path.basename(str(name))
|
|
864
|
+
if safe_name != name or not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
865
|
+
return {"status": "error", "message": "缓存图片名称无效"}
|
|
866
|
+
root = os.path.realpath(self._capture_dir)
|
|
867
|
+
path = os.path.realpath(os.path.join(root, safe_name))
|
|
868
|
+
if os.path.commonpath([root, path]) != root:
|
|
869
|
+
return {"status": "error", "message": "缓存图片路径无效"}
|
|
870
|
+
from PIL import Image
|
|
871
|
+
|
|
872
|
+
with Image.open(path) as image:
|
|
873
|
+
image.load()
|
|
874
|
+
result = self._set_image(image)
|
|
875
|
+
result["cache_name"] = safe_name
|
|
876
|
+
return result
|
|
877
|
+
except Exception as exc:
|
|
878
|
+
return {"status": "error", "message": f"缓存图片读取失败:{exc}"}
|
|
879
|
+
|
|
880
|
+
def delete_capture_cache(self, name: str) -> Dict[str, Any]:
|
|
881
|
+
safe_name = os.path.basename(str(name))
|
|
882
|
+
if safe_name != name or not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
|
|
883
|
+
return {"status": "error", "message": "缓存图片名称无效"}
|
|
884
|
+
root = os.path.realpath(self._capture_dir)
|
|
885
|
+
path = os.path.realpath(os.path.join(root, safe_name))
|
|
886
|
+
if os.path.commonpath([root, path]) != root:
|
|
887
|
+
return {"status": "error", "message": "缓存图片路径无效"}
|
|
888
|
+
try:
|
|
889
|
+
os.remove(path)
|
|
890
|
+
return {"status": "success"}
|
|
891
|
+
except Exception as exc:
|
|
892
|
+
return {"status": "error", "message": f"删除缓存失败:{exc}"}
|
|
893
|
+
|
|
894
|
+
def clear_capture_cache(self) -> Dict[str, Any]:
|
|
895
|
+
removed = 0
|
|
896
|
+
for item in self.list_capture_cache().get("data", []):
|
|
897
|
+
try:
|
|
898
|
+
os.remove(item["path"])
|
|
899
|
+
removed += 1
|
|
900
|
+
except OSError:
|
|
901
|
+
pass
|
|
902
|
+
return {"status": "success", "removed": removed}
|
|
903
|
+
|
|
904
|
+
def open_capture_folder(self) -> bool:
|
|
905
|
+
subprocess.Popen(["explorer.exe", self._capture_dir])
|
|
906
|
+
return True
|