kotonebot 0.5.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.
- 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/context.py +1002 -1002
- kotonebot/backend/context/task_action.py +183 -183
- kotonebot/backend/core.py +86 -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/image.py +36 -5
- kotonebot/backend/loop.py +222 -208
- kotonebot/backend/ocr.py +535 -535
- kotonebot/backend/preprocessor.py +103 -103
- kotonebot/client/__init__.py +9 -9
- kotonebot/client/device.py +369 -529
- kotonebot/client/fast_screenshot.py +377 -377
- kotonebot/client/host/__init__.py +43 -43
- kotonebot/client/host/adb_common.py +101 -107
- kotonebot/client/host/custom.py +118 -118
- kotonebot/client/host/leidian_host.py +196 -196
- kotonebot/client/host/mumu12_host.py +353 -353
- kotonebot/client/host/protocol.py +214 -214
- kotonebot/client/host/windows_common.py +58 -58
- kotonebot/client/implements/__init__.py +65 -70
- kotonebot/client/implements/adb.py +89 -89
- kotonebot/client/implements/nemu_ipc/__init__.py +11 -11
- 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 -85
- kotonebot/client/implements/windows.py +176 -176
- kotonebot/client/protocol.py +69 -69
- kotonebot/client/registration.py +24 -24
- kotonebot/client/scaler.py +467 -0
- kotonebot/config/base_config.py +96 -96
- kotonebot/config/config.py +61 -0
- kotonebot/config/manager.py +36 -36
- kotonebot/core/__init__.py +13 -0
- kotonebot/core/entities/base.py +182 -0
- kotonebot/core/entities/compound.py +75 -0
- kotonebot/core/entities/ocr.py +117 -0
- kotonebot/core/entities/template_match.py +198 -0
- kotonebot/devtools/__init__.py +42 -0
- kotonebot/devtools/cli/__init__.py +6 -0
- kotonebot/devtools/cli/main.py +53 -0
- kotonebot/{tools → devtools}/mirror.py +354 -354
- kotonebot/devtools/project/project.py +41 -0
- kotonebot/devtools/project/scanner.py +202 -0
- kotonebot/devtools/project/schema.py +99 -0
- kotonebot/devtools/resgen/__init__.py +42 -0
- kotonebot/devtools/resgen/codegen.py +331 -0
- kotonebot/devtools/resgen/core.py +94 -0
- kotonebot/devtools/resgen/parsers.py +360 -0
- kotonebot/devtools/resgen/utils.py +158 -0
- kotonebot/devtools/resgen/validation.py +115 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
- kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
- kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
- kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
- kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
- kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
- kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
- kotonebot/devtools/web/dist/index.html +25 -0
- kotonebot/devtools/web/server/__init__.py +0 -0
- kotonebot/devtools/web/server/rest_api.py +217 -0
- kotonebot/devtools/web/server/server.py +85 -0
- kotonebot/errors.py +76 -76
- kotonebot/interop/win/__init__.py +11 -9
- kotonebot/interop/win/_mouse.py +310 -310
- kotonebot/interop/win/message_box.py +313 -313
- kotonebot/interop/win/reg.py +37 -37
- kotonebot/interop/win/shake_mouse.py +224 -0
- 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 +19 -17
- kotonebot/primitives/geometry.py +1067 -862
- kotonebot/primitives/visual.py +143 -63
- 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 -88
- kotonebot/ui/pushkit/protocol.py +13 -13
- kotonebot/ui/pushkit/wxpusher.py +54 -54
- kotonebot/ui/user.py +148 -148
- kotonebot/util.py +436 -436
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +84 -82
- kotonebot-0.6.0.dist-info/RECORD +105 -0
- kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +673 -673
- kotonebot/client/implements/adb_raw.py +0 -163
- kotonebot-0.5.0.dist-info/RECORD +0 -71
- /kotonebot/{tools → devtools/project}/__init__.py +0 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
from .core import SchemaParser, ResourceNode, ImageAsset, BoxData, PointData, PrefabData
|
|
6
|
+
from .utils import to_camel_case, ImageProcessor
|
|
7
|
+
from .validation import MetaValidationError, detect_and_validate_meta_schema
|
|
8
|
+
|
|
9
|
+
class ParserRegistry:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._parsers: List[SchemaParser] = []
|
|
12
|
+
|
|
13
|
+
def register(self, parser: SchemaParser):
|
|
14
|
+
self._parsers.append(parser)
|
|
15
|
+
|
|
16
|
+
def parse_file(self, file_path: str, context: Dict[str, Any]) -> List[ResourceNode]:
|
|
17
|
+
for parser in self._parsers:
|
|
18
|
+
if parser.can_parse(file_path):
|
|
19
|
+
return parser.parse(file_path, context)
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class KotoneV1Parser(SchemaParser):
|
|
24
|
+
def can_parse(self, file_path: str) -> bool:
|
|
25
|
+
if not file_path.endswith('.png.json'):
|
|
26
|
+
return False
|
|
27
|
+
# 使用统一的 schema 检测逻辑:只有在结构被认为是合法的
|
|
28
|
+
# simple/complex meta 时才返回 True。
|
|
29
|
+
try:
|
|
30
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
info = detect_and_validate_meta_schema(data)
|
|
33
|
+
# 支持 simple 与 v2 两种格式
|
|
34
|
+
return info.format in ("simple", "v2")
|
|
35
|
+
except (json.JSONDecodeError, OSError, MetaValidationError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def parse(self, file_path: str, context: Dict[str, Any]) -> List[ResourceNode]:
|
|
39
|
+
"""
|
|
40
|
+
解析 V2 格式的 meta。
|
|
41
|
+
Context 需要包含: 'output_img_dir' (图片输出目录)
|
|
42
|
+
"""
|
|
43
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
schema_info = detect_and_validate_meta_schema(data)
|
|
46
|
+
output_dir = context.get('output_img_dir', 'tmp')
|
|
47
|
+
png_file = file_path.replace('.json', '')
|
|
48
|
+
|
|
49
|
+
if schema_info.format == "simple":
|
|
50
|
+
definition = data.get("definition")
|
|
51
|
+
if not isinstance(definition, dict):
|
|
52
|
+
raise MetaValidationError("Simple meta missing 'definition' object")
|
|
53
|
+
return self._parse_simple_definition(definition, png_file, output_dir, context)
|
|
54
|
+
|
|
55
|
+
if schema_info.format == "v2":
|
|
56
|
+
return self._parse_v2_schema(data, png_file, output_dir, context)
|
|
57
|
+
|
|
58
|
+
raise MetaValidationError(f"KotoneV1Parser cannot parse meta format: {schema_info.format}")
|
|
59
|
+
|
|
60
|
+
def _parse_v2_schema(self, data: Dict[str, Any], png_file: str, output_dir: str, context: Dict[str, Any]) -> List[ResourceNode]:
|
|
61
|
+
resources: List[ResourceNode] = []
|
|
62
|
+
definitions = data.get('definitions', {})
|
|
63
|
+
|
|
64
|
+
for def_id, definition in definitions.items():
|
|
65
|
+
def_type = definition['type']
|
|
66
|
+
name = definition.get('name')
|
|
67
|
+
|
|
68
|
+
if not name:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
name_parts = name.split('.')
|
|
72
|
+
class_path = [to_camel_case(p) for p in name_parts[:-1]]
|
|
73
|
+
attr_name = name_parts[-1]
|
|
74
|
+
display_name = definition.get('displayName', attr_name)
|
|
75
|
+
desc = definition.get('description', '')
|
|
76
|
+
|
|
77
|
+
metadata = {
|
|
78
|
+
'class_path': class_path,
|
|
79
|
+
'origin_file': os.path.abspath(png_file),
|
|
80
|
+
'display_name': display_name,
|
|
81
|
+
'description': desc
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
props = definition.get('props', {})
|
|
85
|
+
|
|
86
|
+
if def_type == 'template':
|
|
87
|
+
target_prop = None
|
|
88
|
+
target_key = None
|
|
89
|
+
|
|
90
|
+
for k, v in props.items():
|
|
91
|
+
if isinstance(v, dict) and v.get('kind') == 'image':
|
|
92
|
+
target_prop = v
|
|
93
|
+
target_key = k
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if not target_prop:
|
|
97
|
+
for k, v in props.items():
|
|
98
|
+
if isinstance(v, dict) and v.get('kind') == 'rect':
|
|
99
|
+
target_prop = v
|
|
100
|
+
target_key = k
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
if target_prop:
|
|
104
|
+
rect = (target_prop['x1'], target_prop['y1'], target_prop['x2'], target_prop['y2'])
|
|
105
|
+
final_name = f'{def_id}_{target_key}.png'
|
|
106
|
+
metadata['abs_path'] = ImageProcessor.save_crop_to_path(png_file, rect, output_dir, final_name)
|
|
107
|
+
|
|
108
|
+
node = ResourceNode(
|
|
109
|
+
name=attr_name,
|
|
110
|
+
type='template',
|
|
111
|
+
value=ImageAsset(path=metadata['abs_path'], rect=rect),
|
|
112
|
+
docstring=self._build_docstring(display_name, desc, class_path, metadata['abs_path'], png_file),
|
|
113
|
+
metadata=metadata
|
|
114
|
+
)
|
|
115
|
+
resources.append(node)
|
|
116
|
+
|
|
117
|
+
elif def_type == 'prefab':
|
|
118
|
+
prefab_id = definition.get('prefab_id')
|
|
119
|
+
|
|
120
|
+
prefab_props = {}
|
|
121
|
+
for k, v in props.items():
|
|
122
|
+
if isinstance(v, dict) and v.get('kind') == 'image':
|
|
123
|
+
rect = (v['x1'], v['y1'], v['x2'], v['y2'])
|
|
124
|
+
final_name = f'{def_id}_{k}.png'
|
|
125
|
+
path = ImageProcessor.save_crop_to_path(png_file, rect, output_dir, final_name)
|
|
126
|
+
prefab_props[k] = ImageAsset(path=path, rect=rect)
|
|
127
|
+
elif isinstance(v, dict) and v.get('kind') == 'rect':
|
|
128
|
+
# keep rect as dict for now; generator can decide how to emit
|
|
129
|
+
prefab_props[k] = v
|
|
130
|
+
elif isinstance(v, dict) and v.get('kind') == 'point':
|
|
131
|
+
prefab_props[k] = v
|
|
132
|
+
else:
|
|
133
|
+
prefab_props[k] = v
|
|
134
|
+
|
|
135
|
+
primary_image = prefab_props.get('templateImage') or prefab_props.get('image')
|
|
136
|
+
if not isinstance(primary_image, ImageAsset):
|
|
137
|
+
for vv in prefab_props.values():
|
|
138
|
+
if isinstance(vv, ImageAsset):
|
|
139
|
+
primary_image = vv
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
node = ResourceNode(
|
|
143
|
+
name=attr_name,
|
|
144
|
+
type='prefab',
|
|
145
|
+
value=PrefabData(
|
|
146
|
+
image=primary_image,
|
|
147
|
+
prefab_id=prefab_id,
|
|
148
|
+
props=prefab_props
|
|
149
|
+
),
|
|
150
|
+
docstring=self._build_docstring(display_name, desc, class_path, None, png_file),
|
|
151
|
+
metadata=metadata
|
|
152
|
+
)
|
|
153
|
+
resources.append(node)
|
|
154
|
+
|
|
155
|
+
elif def_type == 'hint-box':
|
|
156
|
+
# 寻找 rect 或 image 类型的 props 来生成 BoxData
|
|
157
|
+
target_prop = None
|
|
158
|
+
for k, v in props.items():
|
|
159
|
+
if isinstance(v, dict) and v.get('kind') in ('rect', 'image'):
|
|
160
|
+
target_prop = v
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
if target_prop:
|
|
164
|
+
rect = (target_prop['x1'], target_prop['y1'], target_prop['x2'], target_prop['y2'])
|
|
165
|
+
node = ResourceNode(
|
|
166
|
+
name=attr_name,
|
|
167
|
+
type='hint-box',
|
|
168
|
+
value=BoxData(x1=rect[0], y1=rect[1], x2=rect[2], y2=rect[3]),
|
|
169
|
+
docstring=self._build_docstring(display_name, desc, class_path, None, png_file),
|
|
170
|
+
metadata=metadata
|
|
171
|
+
)
|
|
172
|
+
resources.append(node)
|
|
173
|
+
|
|
174
|
+
elif def_type == 'hint-point':
|
|
175
|
+
# 解析 point
|
|
176
|
+
target_prop = None
|
|
177
|
+
for k, v in props.items():
|
|
178
|
+
if isinstance(v, dict) and v.get('kind') == 'point':
|
|
179
|
+
target_prop = v
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if target_prop:
|
|
183
|
+
pt = (target_prop['x'], target_prop['y'])
|
|
184
|
+
node = ResourceNode(
|
|
185
|
+
name=attr_name,
|
|
186
|
+
type='hint-point',
|
|
187
|
+
value=PointData(x=pt[0], y=pt[1]),
|
|
188
|
+
docstring=self._build_docstring(display_name, desc, class_path, None, png_file),
|
|
189
|
+
metadata=metadata
|
|
190
|
+
)
|
|
191
|
+
resources.append(node)
|
|
192
|
+
|
|
193
|
+
return resources
|
|
194
|
+
|
|
195
|
+
def _build_docstring(self, name, desc, path_list, img_path, origin_path):
|
|
196
|
+
lines = [
|
|
197
|
+
f"名称:{name}\\n",
|
|
198
|
+
f"描述:{desc}\\n",
|
|
199
|
+
f"模块:`{'.'.join(path_list)}`\\n"
|
|
200
|
+
]
|
|
201
|
+
# 注意:这里我们只存放纯文本信息,图片标签的生成留给 Generator
|
|
202
|
+
# 但为了方便,我们把图片路径存入 metadata,Generator 读取 metadata 生成 <img> 标签
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_simple_definition(
|
|
207
|
+
self,
|
|
208
|
+
definition: Dict[str, Any],
|
|
209
|
+
png_file: str,
|
|
210
|
+
output_dir: str,
|
|
211
|
+
context: Dict[str, Any],
|
|
212
|
+
) -> List[ResourceNode]:
|
|
213
|
+
"""Parse a single-definition simple meta file.
|
|
214
|
+
|
|
215
|
+
当前仅支持 `type == "template"` 与 `type == "prefab"` 的简单资源:
|
|
216
|
+
- 不依赖 annotations;
|
|
217
|
+
- 直接复制整张图片作为模板或 prefab 图像来源。
|
|
218
|
+
|
|
219
|
+
针对简单格式:
|
|
220
|
+
- `name` 与 `displayName` 均可为空或缺省;
|
|
221
|
+
- 当为空时,按照原有简单格式(BasicSpriteParser)的逻辑自动推导:
|
|
222
|
+
* name: 由文件名转换得到的 CamelCase 属性名;
|
|
223
|
+
* displayName: 使用原始文件名(含扩展名)。
|
|
224
|
+
|
|
225
|
+
其他类型在缺少 annotations 的情况下暂不支持,会抛出 MetaValidationError,
|
|
226
|
+
以避免产生语义不明确的结果。
|
|
227
|
+
"""
|
|
228
|
+
def_type = definition.get("type")
|
|
229
|
+
if def_type not in ("template", "prefab"):
|
|
230
|
+
raise MetaValidationError(
|
|
231
|
+
f"Simple meta currently only supports type 'template' or 'prefab', got '{def_type}'."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# --- 基于文件路径的默认推导(复用 BasicSpriteParser 逻辑) ---
|
|
235
|
+
root_scan_path = context.get('root_scan_path', '')
|
|
236
|
+
file_name = os.path.basename(png_file)
|
|
237
|
+
name_no_ext = file_name.replace('.png', '')
|
|
238
|
+
try:
|
|
239
|
+
rel_dir = os.path.dirname(os.path.relpath(png_file, root_scan_path)) if root_scan_path else ''
|
|
240
|
+
except ValueError:
|
|
241
|
+
# os.path.relpath 可能在 root_scan_path 非法时抛错,此时退回空相对目录
|
|
242
|
+
rel_dir = ''
|
|
243
|
+
|
|
244
|
+
path_class_path = [
|
|
245
|
+
to_camel_case(p)
|
|
246
|
+
for p in rel_dir.split(os.sep)
|
|
247
|
+
if p and p != '.'
|
|
248
|
+
]
|
|
249
|
+
path_attr_name = to_camel_case(name_no_ext)
|
|
250
|
+
path_display_name = file_name
|
|
251
|
+
|
|
252
|
+
# --- 处理 name(可选) ---
|
|
253
|
+
raw_name = definition.get("name")
|
|
254
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
255
|
+
name_parts = raw_name.split('.')
|
|
256
|
+
class_path = [to_camel_case(p) for p in name_parts[:-1]]
|
|
257
|
+
attr_name = name_parts[-1]
|
|
258
|
+
else:
|
|
259
|
+
class_path = path_class_path
|
|
260
|
+
attr_name = path_attr_name
|
|
261
|
+
|
|
262
|
+
# --- 处理 displayName(可选) ---
|
|
263
|
+
raw_display_name = definition.get('displayName')
|
|
264
|
+
if isinstance(raw_display_name, str) and raw_display_name.strip():
|
|
265
|
+
display_name = raw_display_name
|
|
266
|
+
else:
|
|
267
|
+
# 没有显式 displayName 时,沿用简单格式原有行为:使用文件名
|
|
268
|
+
display_name = path_display_name
|
|
269
|
+
|
|
270
|
+
desc = definition.get('description', '')
|
|
271
|
+
|
|
272
|
+
# 复制整张图片作为资源
|
|
273
|
+
img_uuid = str(uuid.uuid4())
|
|
274
|
+
new_name = f"{img_uuid}.png"
|
|
275
|
+
final_path = ImageProcessor.copy_image(png_file, output_dir, new_name)
|
|
276
|
+
|
|
277
|
+
metadata = {
|
|
278
|
+
'class_path': class_path,
|
|
279
|
+
'origin_file': os.path.abspath(png_file),
|
|
280
|
+
'abs_path': os.path.abspath(final_path),
|
|
281
|
+
'isSimple': True,
|
|
282
|
+
'display_name': display_name,
|
|
283
|
+
'description': desc,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if def_type == "template":
|
|
287
|
+
node = ResourceNode(
|
|
288
|
+
name=attr_name,
|
|
289
|
+
type='template',
|
|
290
|
+
value=ImageAsset(path=metadata['abs_path'], rect=None),
|
|
291
|
+
docstring=self._build_docstring(display_name, desc, class_path, metadata['abs_path'], png_file),
|
|
292
|
+
metadata=metadata,
|
|
293
|
+
)
|
|
294
|
+
else: # prefab
|
|
295
|
+
prefab_id_ref = definition.get('prefab_id')
|
|
296
|
+
if not isinstance(prefab_id_ref, str) or not prefab_id_ref.strip():
|
|
297
|
+
raise MetaValidationError(f"Prefab definition missing prefab_id in simple meta for {png_file}")
|
|
298
|
+
|
|
299
|
+
node = ResourceNode(
|
|
300
|
+
name=attr_name,
|
|
301
|
+
type='prefab',
|
|
302
|
+
value=PrefabData(
|
|
303
|
+
image=ImageAsset(path=metadata['abs_path'], rect=None),
|
|
304
|
+
prefab_id=prefab_id_ref,
|
|
305
|
+
),
|
|
306
|
+
docstring=self._build_docstring(display_name, desc, class_path, metadata['abs_path'], png_file),
|
|
307
|
+
metadata=metadata,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return [node]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --- 2. Basic Sprite Parser (无 Json 的普通图片) ---
|
|
314
|
+
|
|
315
|
+
class BasicSpriteParser(SchemaParser):
|
|
316
|
+
def can_parse(self, file_path: str) -> bool:
|
|
317
|
+
# 只有是 png 且没有对应的 json 文件时
|
|
318
|
+
if not file_path.endswith('.png'):
|
|
319
|
+
return False
|
|
320
|
+
if os.path.exists(file_path + '.json'):
|
|
321
|
+
return False
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
def parse(self, file_path: str, context: Dict[str, Any]) -> List[ResourceNode]:
|
|
325
|
+
output_dir = context.get('output_img_dir', 'tmp')
|
|
326
|
+
root_scan_path = context.get('root_scan_path', '')
|
|
327
|
+
|
|
328
|
+
file_name = os.path.basename(file_path)
|
|
329
|
+
name_no_ext = file_name.replace('.png', '')
|
|
330
|
+
|
|
331
|
+
# 计算 class path: 相对路径文件夹转 CamelCase
|
|
332
|
+
rel_dir = os.path.dirname(os.path.relpath(file_path, root_scan_path))
|
|
333
|
+
class_path = [to_camel_case(p) for p in rel_dir.split(os.sep) if p and p != '.']
|
|
334
|
+
|
|
335
|
+
# 复制图片
|
|
336
|
+
img_uuid = str(uuid.uuid4())
|
|
337
|
+
new_name = f"{img_uuid}.png"
|
|
338
|
+
final_path = ImageProcessor.copy_image(file_path, output_dir, new_name)
|
|
339
|
+
|
|
340
|
+
attr_name = to_camel_case(name_no_ext)
|
|
341
|
+
display_name = file_name
|
|
342
|
+
|
|
343
|
+
metadata = {
|
|
344
|
+
'class_path': class_path,
|
|
345
|
+
'origin_file': os.path.abspath(file_path),
|
|
346
|
+
'abs_path': final_path,
|
|
347
|
+
'display_name': display_name
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
doc = f"名称:{display_name}\\n\n模块:`{'.'.join(class_path)}`\\n"
|
|
351
|
+
|
|
352
|
+
return [ResourceNode(
|
|
353
|
+
name=attr_name,
|
|
354
|
+
type='template',
|
|
355
|
+
value=ImageAsset(path=final_path, rect=None),
|
|
356
|
+
docstring=doc,
|
|
357
|
+
metadata=metadata
|
|
358
|
+
)]
|
|
359
|
+
|
|
360
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import cv2
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import List, Dict, Tuple
|
|
5
|
+
from .core import ResourceNode, ClassNode
|
|
6
|
+
|
|
7
|
+
# --- 字符串处理 ---
|
|
8
|
+
def to_camel_case(s: str) -> str:
|
|
9
|
+
"""使用简单黑名单把不可出现在变量名的字符当作分隔符,生成 PascalCase。
|
|
10
|
+
|
|
11
|
+
实现策略(简单黑名单):
|
|
12
|
+
- 将 `string.punctuation`(标点)和空白字符视为分隔符,但保留下划线 `_`,因为下划线可以出现在变量名中。
|
|
13
|
+
- 连续的分隔符视为单个分隔符。
|
|
14
|
+
- 如果原始字符串中没有任何分隔符,则返回原样(保持 Unicode 字符如 CJK、emoji 的大小写/形式)。
|
|
15
|
+
"""
|
|
16
|
+
import string as _string
|
|
17
|
+
|
|
18
|
+
# 构造黑名单:标点 + 空白(包含下划线 `_`,因为我们把下划线也视为常见的分隔符)
|
|
19
|
+
blacklist = set(_string.punctuation) | set(_string.whitespace)
|
|
20
|
+
|
|
21
|
+
# 检查是否存在任意分隔符字符
|
|
22
|
+
if not any((ch in blacklist) for ch in s):
|
|
23
|
+
# 如果没有分隔符:
|
|
24
|
+
# - 若字符串包含大写字母(可能已是驼峰/混合大小写),则保留原样;
|
|
25
|
+
# - 否则将首字母大写以兼容先前的期望('hello' -> 'Hello')
|
|
26
|
+
if any(ch.isupper() for ch in s):
|
|
27
|
+
return s
|
|
28
|
+
return s.capitalize()
|
|
29
|
+
|
|
30
|
+
# 按黑名单分割:手工扫描以支持任意 Unicode 字符
|
|
31
|
+
parts = []
|
|
32
|
+
cur = []
|
|
33
|
+
for ch in s:
|
|
34
|
+
if ch in blacklist:
|
|
35
|
+
if cur:
|
|
36
|
+
parts.append(''.join(cur))
|
|
37
|
+
cur = []
|
|
38
|
+
else:
|
|
39
|
+
cur.append(ch)
|
|
40
|
+
if cur:
|
|
41
|
+
parts.append(''.join(cur))
|
|
42
|
+
|
|
43
|
+
def cap(p: str) -> str:
|
|
44
|
+
return p[0].upper() + p[1:] if p else ''
|
|
45
|
+
|
|
46
|
+
return ''.join(cap(p) for p in parts)
|
|
47
|
+
|
|
48
|
+
def unify_path(path: str) -> str:
|
|
49
|
+
return path.replace('\\', '/')
|
|
50
|
+
|
|
51
|
+
# --- 树构建逻辑 ---
|
|
52
|
+
def build_class_tree(resources: List[ResourceNode]) -> List[ClassNode]:
|
|
53
|
+
"""
|
|
54
|
+
将扁平的资源列表转换为树状 ClassNode 结构。
|
|
55
|
+
依赖 resource.metadata['class_path'] (List[str])
|
|
56
|
+
"""
|
|
57
|
+
root_map: Dict[str, ClassNode] = {}
|
|
58
|
+
|
|
59
|
+
# 辅助:获取或创建节点
|
|
60
|
+
node_registry: Dict[str, ClassNode] = {} # full_path -> ClassNode
|
|
61
|
+
|
|
62
|
+
def get_node(path_parts: List[str]) -> ClassNode:
|
|
63
|
+
key = ".".join(path_parts)
|
|
64
|
+
if key in node_registry:
|
|
65
|
+
return node_registry[key]
|
|
66
|
+
|
|
67
|
+
name = path_parts[-1]
|
|
68
|
+
node = ClassNode(name=name)
|
|
69
|
+
node_registry[key] = node
|
|
70
|
+
|
|
71
|
+
# 如果是顶层节点
|
|
72
|
+
if len(path_parts) == 1:
|
|
73
|
+
root_map[name] = node
|
|
74
|
+
else:
|
|
75
|
+
# 挂载到父节点
|
|
76
|
+
parent = get_node(path_parts[:-1])
|
|
77
|
+
# 避免重复添加
|
|
78
|
+
if node not in parent.children:
|
|
79
|
+
parent.children.append(node)
|
|
80
|
+
|
|
81
|
+
return node
|
|
82
|
+
|
|
83
|
+
for res in resources:
|
|
84
|
+
class_path = res.metadata.get('class_path', [])
|
|
85
|
+
if not class_path:
|
|
86
|
+
continue # 或者是挂在默认根节点
|
|
87
|
+
|
|
88
|
+
# 获取该资源所属的类节点
|
|
89
|
+
parent_node = get_node(class_path)
|
|
90
|
+
parent_node.attributes.append(res)
|
|
91
|
+
|
|
92
|
+
return list(root_map.values())
|
|
93
|
+
|
|
94
|
+
# --- 图片处理工具 ---
|
|
95
|
+
class ImageProcessor:
|
|
96
|
+
@staticmethod
|
|
97
|
+
def save_crop(source_path: str, rect: Tuple[float, float, float, float], output_dir: str, prefix: str) -> str:
|
|
98
|
+
"""
|
|
99
|
+
裁剪图片并保存。
|
|
100
|
+
rect: (x1, y1, x2, y2)
|
|
101
|
+
Returns: 保存后的绝对路径
|
|
102
|
+
"""
|
|
103
|
+
if not os.path.exists(output_dir):
|
|
104
|
+
os.makedirs(output_dir)
|
|
105
|
+
|
|
106
|
+
img = cv2.imread(source_path)
|
|
107
|
+
if img is None:
|
|
108
|
+
raise ValueError(f"Could not read image: {source_path}")
|
|
109
|
+
|
|
110
|
+
x1, y1, x2, y2 = map(int, rect)
|
|
111
|
+
# 边界检查
|
|
112
|
+
h, w = img.shape[:2]
|
|
113
|
+
x1, y1 = max(0, x1), max(0, y1)
|
|
114
|
+
x2, y2 = min(w, x2), min(h, y2)
|
|
115
|
+
|
|
116
|
+
clip = img[y1:y2, x1:x2]
|
|
117
|
+
filename = f"{prefix}_{uuid.uuid4().hex[:8]}.png"
|
|
118
|
+
out_path = os.path.join(output_dir, filename)
|
|
119
|
+
|
|
120
|
+
cv2.imwrite(out_path, clip)
|
|
121
|
+
return os.path.abspath(out_path)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def save_crop_to_path(source_path: str, rect: Tuple[float, float, float, float], output_dir: str, filename: str) -> str:
|
|
125
|
+
"""裁剪图片并使用固定文件名保存。
|
|
126
|
+
|
|
127
|
+
主要用于 Meta V2 ImageProp 导出的切片命名:<definitionId>_<propKey>.png。
|
|
128
|
+
"""
|
|
129
|
+
if not os.path.exists(output_dir):
|
|
130
|
+
os.makedirs(output_dir)
|
|
131
|
+
|
|
132
|
+
img = cv2.imread(source_path)
|
|
133
|
+
if img is None:
|
|
134
|
+
raise ValueError(f"Could not read image: {source_path}")
|
|
135
|
+
|
|
136
|
+
x1, y1, x2, y2 = map(int, rect)
|
|
137
|
+
h, w = img.shape[:2]
|
|
138
|
+
x1, y1 = max(0, x1), max(0, y1)
|
|
139
|
+
x2, y2 = min(w, x2), min(h, y2)
|
|
140
|
+
|
|
141
|
+
clip = img[y1:y2, x1:x2]
|
|
142
|
+
out_path = os.path.join(output_dir, filename)
|
|
143
|
+
cv2.imwrite(out_path, clip)
|
|
144
|
+
return os.path.abspath(out_path)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def copy_image(source_path: str, output_dir: str, new_name: str | None = None) -> str:
|
|
148
|
+
"""复制图片并返回绝对路径"""
|
|
149
|
+
import shutil
|
|
150
|
+
if not os.path.exists(output_dir):
|
|
151
|
+
os.makedirs(output_dir)
|
|
152
|
+
|
|
153
|
+
if new_name is None:
|
|
154
|
+
new_name = os.path.basename(source_path)
|
|
155
|
+
|
|
156
|
+
dst_path = os.path.join(output_dir, new_name)
|
|
157
|
+
shutil.copy(source_path, dst_path)
|
|
158
|
+
return os.path.abspath(dst_path)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MetaValidationError(ValueError):
|
|
8
|
+
"""Raised when a meta JSON file does not conform to expected schema."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MetaFormat = Literal["simple", "complex", "v2"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MetaSchemaInfo:
|
|
16
|
+
"""Lightweight description of detected meta schema.
|
|
17
|
+
|
|
18
|
+
This is intentionally minimal for now but can be extended later
|
|
19
|
+
(e.g. to carry source path, name list, etc.).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
format: MetaFormat
|
|
23
|
+
is_simple_flag: bool | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ensure_bool_or_none(value: Any, field_name: str) -> bool | None:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(value, bool):
|
|
30
|
+
return value
|
|
31
|
+
raise MetaValidationError(f"Field '{field_name}' must be boolean if present.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect_and_validate_meta_schema(data: Dict[str, Any]) -> MetaSchemaInfo:
|
|
35
|
+
"""Detect whether meta JSON is in simple/complex/V2 format and validate structure.
|
|
36
|
+
|
|
37
|
+
Rules:
|
|
38
|
+
- Meta V2 format:
|
|
39
|
+
* Top-level `version` MUST be 2.
|
|
40
|
+
* MUST contain `definitions` (object).
|
|
41
|
+
* MUST NOT contain `annotations`.
|
|
42
|
+
- Simple format (V1):
|
|
43
|
+
* Top-level `isSimple` MUST be true.
|
|
44
|
+
* MUST contain `definition` (object).
|
|
45
|
+
* MUST NOT contain `definitions` or `annotations`.
|
|
46
|
+
- Complex format (V1):
|
|
47
|
+
* `isSimple` is absent or false (these two are strictly equivalent).
|
|
48
|
+
* MUST contain `definitions` and `annotations`.
|
|
49
|
+
* MUST NOT contain `definition`.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise MetaValidationError("Meta JSON root must be an object.")
|
|
54
|
+
|
|
55
|
+
# --- Meta V2 branch (versioned schema, no annotations) ---
|
|
56
|
+
version = data.get("version")
|
|
57
|
+
if version is not None:
|
|
58
|
+
if version != 2:
|
|
59
|
+
raise MetaValidationError(f"Unsupported meta version: {version!r}")
|
|
60
|
+
|
|
61
|
+
has_definitions = "definitions" in data
|
|
62
|
+
has_annotations = "annotations" in data
|
|
63
|
+
|
|
64
|
+
if not has_definitions:
|
|
65
|
+
raise MetaValidationError("Meta V2 must contain field 'definitions'.")
|
|
66
|
+
if has_annotations:
|
|
67
|
+
raise MetaValidationError("Meta V2 must not contain field 'annotations'.")
|
|
68
|
+
|
|
69
|
+
definitions = data["definitions"]
|
|
70
|
+
if not isinstance(definitions, dict):
|
|
71
|
+
raise MetaValidationError("Field 'definitions' must be an object (mapping).")
|
|
72
|
+
|
|
73
|
+
return MetaSchemaInfo(format="v2", is_simple_flag=None)
|
|
74
|
+
|
|
75
|
+
raw_flag = data.get("isSimple")
|
|
76
|
+
is_simple_flag = _ensure_bool_or_none(raw_flag, "isSimple")
|
|
77
|
+
|
|
78
|
+
has_definition = "definition" in data
|
|
79
|
+
has_definitions = "definitions" in data
|
|
80
|
+
has_annotations = "annotations" in data
|
|
81
|
+
|
|
82
|
+
# --- Simple format branch (V1) ---
|
|
83
|
+
if is_simple_flag is True:
|
|
84
|
+
if not has_definition:
|
|
85
|
+
raise MetaValidationError("Simple meta must contain field 'definition'.")
|
|
86
|
+
if has_definitions or has_annotations:
|
|
87
|
+
raise MetaValidationError(
|
|
88
|
+
"Simple meta must not contain 'definitions' or 'annotations'."
|
|
89
|
+
)
|
|
90
|
+
definition = data["definition"]
|
|
91
|
+
if not isinstance(definition, dict):
|
|
92
|
+
raise MetaValidationError("Field 'definition' must be an object.")
|
|
93
|
+
return MetaSchemaInfo(format="simple", is_simple_flag=True)
|
|
94
|
+
|
|
95
|
+
# --- Complex format branch (V1, isSimple is false or missing) ---
|
|
96
|
+
if has_definition:
|
|
97
|
+
# For complex meta, we never allow the single `definition` field.
|
|
98
|
+
raise MetaValidationError(
|
|
99
|
+
"Complex meta must not contain top-level field 'definition'."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not (has_definitions and has_annotations):
|
|
103
|
+
raise MetaValidationError(
|
|
104
|
+
"Complex meta must contain both 'definitions' and 'annotations'."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Basic structural checks; detailed content validation can be added later.
|
|
108
|
+
definitions = data["definitions"]
|
|
109
|
+
annotations = data["annotations"]
|
|
110
|
+
if not isinstance(definitions, dict):
|
|
111
|
+
raise MetaValidationError("Field 'definitions' must be an object (mapping).")
|
|
112
|
+
if not isinstance(annotations, list):
|
|
113
|
+
raise MetaValidationError("Field 'annotations' must be an array.")
|
|
114
|
+
|
|
115
|
+
return MetaSchemaInfo(format="complex", is_simple_flag=False)
|
|
Binary file
|
|
Binary file
|