PyClickerRPA 0.1.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.
autoclicker/matcher.py ADDED
@@ -0,0 +1,152 @@
1
+ """Template matching + OCR text matching — port of matcher.cpp.
2
+
3
+ Image matching uses OpenCV TM_CCOEFF_NORMED with greedy peak suppression
4
+ (mirrors ``Matcher::findImage``); OCR goes through native PaddleOCR (see
5
+ ocr.py) instead of the C++ socket daemon.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+
16
+ @dataclass
17
+ class MatchResult:
18
+ cx: int
19
+ cy: int
20
+ x: int
21
+ y: int
22
+ w: int
23
+ h: int
24
+ confidence: float
25
+
26
+
27
+ @dataclass
28
+ class TextResult:
29
+ text: str
30
+ cx: int
31
+ cy: int
32
+ x: int
33
+ y: int
34
+ w: int
35
+ h: int
36
+ confidence: float
37
+
38
+
39
+ # ─────────────────── Image matching ───────────────────
40
+
41
+ def find_image(screen: np.ndarray, templ: np.ndarray, threshold: float = 0.8) -> list[MatchResult]:
42
+ """Find up to 10 occurrences of *templ* in *screen* (TM_CCOEFF_NORMED)."""
43
+ results: list[MatchResult] = []
44
+ if screen is None or templ is None or screen.size == 0 or templ.size == 0:
45
+ return results
46
+ th, tw = templ.shape[:2]
47
+ sh, sw = screen.shape[:2]
48
+ if tw > sw or th > sh:
49
+ return results
50
+
51
+ result = cv2.matchTemplate(screen, templ, cv2.TM_CCOEFF_NORMED)
52
+ while True:
53
+ _, max_val, _, max_loc = cv2.minMaxLoc(result)
54
+ if max_val < threshold:
55
+ break
56
+ mx, my = max_loc
57
+ results.append(MatchResult(mx + tw // 2, my + th // 2, mx, my, tw, th, float(max_val)))
58
+ # Blank out the matched region so the next peak is a distinct hit
59
+ cv2.rectangle(result, (mx, my), (mx + tw, my + th), 0.0, -1)
60
+ if len(results) >= 10:
61
+ break
62
+ return results
63
+
64
+
65
+ _SCALES = (1.0, 0.9, 1.1, 0.8, 1.2)
66
+
67
+
68
+ def find_image_multiscale(screen: np.ndarray, templ: np.ndarray, threshold: float = 0.8) -> list[MatchResult]:
69
+ """Try several template scales; return the highest-confidence result set."""
70
+ best: list[MatchResult] = []
71
+ best_conf = 0.0
72
+ for scale in _SCALES:
73
+ scaled = templ if scale == 1.0 else cv2.resize(templ, None, fx=scale, fy=scale, interpolation=cv2.INTER_LINEAR)
74
+ if scaled.shape[1] > screen.shape[1] or scaled.shape[0] > screen.shape[0]:
75
+ continue
76
+ results = find_image(screen, scaled, threshold)
77
+ if results and results[0].confidence > best_conf:
78
+ best_conf = results[0].confidence
79
+ best = results
80
+ return best
81
+
82
+
83
+ # ─────────────────── ROI helpers (from taskrunner.cpp) ───────────────────
84
+
85
+ def apply_roi(screen: np.ndarray, roi: Optional[tuple[int, int, int, int]]) -> tuple[np.ndarray, int, int]:
86
+ """Return (sub_image, offset_x, offset_y). roi None/empty -> whole screen."""
87
+ if not roi:
88
+ return screen, 0, 0
89
+ rx, ry, rw, rh = roi
90
+ if rw <= 0 or rh <= 0:
91
+ return screen, 0, 0
92
+ x = max(0, rx)
93
+ y = max(0, ry)
94
+ w = min(rw, screen.shape[1] - x)
95
+ h = min(rh, screen.shape[0] - y)
96
+ if w <= 0 or h <= 0:
97
+ return screen, 0, 0
98
+ return screen[y:y + h, x:x + w], x, y
99
+
100
+
101
+ def find_image_roi(screen, templ, threshold, roi, multi_scale=False) -> list[MatchResult]:
102
+ region, ox, oy = apply_roi(screen, roi)
103
+ results = (find_image_multiscale(region, templ, threshold) if multi_scale
104
+ else find_image(region, templ, threshold))
105
+ if ox or oy:
106
+ for r in results:
107
+ r.cx += ox
108
+ r.cy += oy
109
+ r.x += ox
110
+ r.y += oy
111
+ return results
112
+
113
+
114
+ # ─────────────────── Text matching ───────────────────
115
+
116
+ def fuzzy_match(text: str, target: str) -> bool:
117
+ """Port of Matcher::fuzzyMatch: substring, whitespace-stripped, 70% char overlap."""
118
+ if target.lower() in text.lower():
119
+ return True
120
+ ct = "".join(text.split())
121
+ tt = "".join(target.split())
122
+ if tt.lower() in ct.lower():
123
+ return True
124
+ if len(tt) >= 2 and len(ct) >= 2:
125
+ remaining = list(ct.lower())
126
+ matched = 0
127
+ for c in tt.lower():
128
+ if c in remaining:
129
+ remaining.remove(c)
130
+ matched += 1
131
+ if matched / len(tt) >= 0.7:
132
+ return True
133
+ return False
134
+
135
+
136
+ def match_text(all_results: list[TextResult], target: str, fuzzy: bool = True) -> list[TextResult]:
137
+ if not target:
138
+ return list(all_results)
139
+ matched = [r for r in all_results
140
+ if (fuzzy_match(r.text, target) if fuzzy else target.lower() in r.text.lower())]
141
+ matched.sort(key=lambda r: r.confidence, reverse=True)
142
+ return matched
143
+
144
+
145
+ def ocr_all(screen: np.ndarray) -> list[TextResult]:
146
+ """Run OCR on the screenshot (native PaddleOCR)."""
147
+ from autoclicker import ocr
148
+ return ocr.recognize(screen)
149
+
150
+
151
+ def find_text(screen: np.ndarray, target: str) -> list[TextResult]:
152
+ return match_text(ocr_all(screen), target, fuzzy=True)
autoclicker/models.py ADDED
@@ -0,0 +1,468 @@
1
+ """Task / Step data model — a faithful Python port of the C++ task.h / task.cpp.
2
+
3
+ The JSON schema is kept byte-compatible with the C++ AutoClicker so .json task
4
+ files can be exchanged both ways: ``type`` is a string name (e.g. "click_image"),
5
+ and fields at their default value are omitted on write, exactly like
6
+ ``Step::toJson`` / ``Step::fromJson``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Optional
15
+
16
+
17
+ class StepType(str, Enum):
18
+ ClickImage = "click_image"
19
+ ClickText = "click_text"
20
+ ClickPosition = "click_pos"
21
+ Wait = "wait"
22
+ Loop = "loop"
23
+ IfImageFound = "if_image"
24
+ IfTextFound = "if_text"
25
+ IfElse = "if_else"
26
+ CallSub = "call_sub"
27
+ SetVar = "set_var"
28
+ RandomWait = "random_wait"
29
+ KeyPress = "key_press"
30
+ TypeText = "type_text"
31
+ Hotkey = "hotkey"
32
+ LongPress = "long_press"
33
+ Drag = "drag"
34
+ Pinch = "pinch"
35
+ WaitImage = "wait_image"
36
+ WaitImageGone = "wait_image_gone"
37
+ Scroll = "scroll"
38
+ IfColorMatch = "if_color"
39
+ DoUntil = "do_until"
40
+ MergeImage = "merge_image"
41
+ MultiDrag = "multi_drag"
42
+ Goto = "goto"
43
+ IfImageCount = "if_image_count"
44
+ IfVar = "if_var"
45
+ ClickAll = "click_all"
46
+ GroupBegin = "group_begin"
47
+ GroupEnd = "group_end"
48
+ WhileCondition = "while_condition"
49
+ # Python-only extensions (C++ treats unknown types as Wait)
50
+ ReadText = "read_text"
51
+ ClickRelative = "click_relative"
52
+
53
+ @classmethod
54
+ def _missing_(cls, value): # unknown name -> Wait, mirroring stepTypeFromName
55
+ return cls.Wait
56
+
57
+
58
+ class UntilCondition(str, Enum):
59
+ NoneCond = "none"
60
+ ImageFound = "image_found"
61
+ ImageGone = "image_gone"
62
+ TextFound = "text_found"
63
+ ColorMatch = "color_match"
64
+
65
+ @classmethod
66
+ def _missing_(cls, value):
67
+ return cls.NoneCond
68
+
69
+
70
+ class RetryPolicy(str, Enum):
71
+ NoneP = "none"
72
+ Retry = "retry"
73
+ Skip = "skip"
74
+ Abort = "abort"
75
+
76
+ @classmethod
77
+ def _missing_(cls, value):
78
+ return cls.NoneP
79
+
80
+
81
+ @dataclass
82
+ class RepeatConfig:
83
+ times: int = 1
84
+ delayMs: int = 500
85
+
86
+
87
+ @dataclass
88
+ class UntilConfig:
89
+ type: UntilCondition = UntilCondition.NoneCond
90
+ image: str = ""
91
+ text: str = ""
92
+ threshold: float = 0.8
93
+ color: str = ""
94
+ colorTolerance: int = 30
95
+ x: int = 0
96
+ y: int = 0
97
+ timeoutMs: int = 30000
98
+
99
+
100
+ @dataclass
101
+ class RetryConfig:
102
+ policy: RetryPolicy = RetryPolicy.NoneP
103
+ count: int = 3
104
+ delayMs: int = 1000
105
+
106
+
107
+ @dataclass
108
+ class Step:
109
+ type: StepType = StepType.Wait
110
+ imagePath: str = ""
111
+ imagePaths: list[str] = field(default_factory=list)
112
+ text: str = ""
113
+ x: int = 0
114
+ y: int = 0
115
+ waitMs: int = 1000
116
+ waitMsMax: int = 2000
117
+ loopTarget: int = 0
118
+ loopCount: int = -1
119
+ threshold: float = 0.8
120
+ timeoutMs: int = 5000
121
+ elseSkip: int = 1
122
+ subTaskPath: str = ""
123
+ varName: str = ""
124
+ varValue: str = ""
125
+ x2: int = 0
126
+ y2: int = 0
127
+ durationMs: int = 500
128
+ keyName: str = ""
129
+ modifierKey: str = ""
130
+ holdMs: int = 1000
131
+ pinchStartDist: int = 100
132
+ pinchEndDist: int = 50
133
+ scrollAmount: int = 3
134
+ mergeDelayMs: int = 1000
135
+ dragPoints: list[tuple[int, int]] = field(default_factory=list)
136
+ dragStepDelay: int = 500
137
+ gotoTarget: int = 0
138
+ minMatchCount: int = 2
139
+ compareOp: str = ""
140
+ compareValue: str = ""
141
+ clickDelayMs: int = 300
142
+ groupName: str = ""
143
+ disabled: bool = False
144
+ multiScale: bool = False
145
+ roiRect: Optional[tuple[int, int, int, int]] = None # (x, y, w, h); None = full screen
146
+ randomOffset: int = 0
147
+ targetColor: str = ""
148
+ colorTolerance: int = 30
149
+ repeat: RepeatConfig = field(default_factory=RepeatConfig)
150
+ until: UntilConfig = field(default_factory=UntilConfig)
151
+ retry: RetryConfig = field(default_factory=RetryConfig)
152
+
153
+ # ------------------------------------------------------------------
154
+ # JSON (mirrors C++ Step::toJson / fromJson: string type, omit-at-default)
155
+ # ------------------------------------------------------------------
156
+ def to_json(self) -> dict:
157
+ obj: dict = {}
158
+ obj["type"] = self.type.value
159
+ if self.imagePath:
160
+ obj["image"] = self.imagePath
161
+ if self.imagePaths:
162
+ obj["images"] = list(self.imagePaths)
163
+ if self.text:
164
+ obj["text"] = self.text
165
+ if self.x != 0 or self.y != 0:
166
+ obj["x"] = self.x
167
+ obj["y"] = self.y
168
+ obj["wait_ms"] = self.waitMs
169
+ obj["wait_ms_max"] = self.waitMsMax
170
+ obj["loop_target"] = self.loopTarget
171
+ obj["loop_count"] = self.loopCount
172
+ obj["threshold"] = self.threshold
173
+ obj["timeout_ms"] = self.timeoutMs
174
+ obj["else_skip"] = self.elseSkip
175
+ if self.subTaskPath:
176
+ obj["sub_task"] = self.subTaskPath
177
+ if self.varName:
178
+ obj["var_name"] = self.varName
179
+ obj["var_value"] = self.varValue
180
+ obj["retry_policy"] = self.retry.policy.value
181
+ obj["retry_count"] = self.retry.count
182
+ obj["retry_delay_ms"] = self.retry.delayMs
183
+ if self.x2 != 0 or self.y2 != 0:
184
+ obj["x2"] = self.x2
185
+ obj["y2"] = self.y2
186
+ obj["duration_ms"] = self.durationMs
187
+ if self.keyName:
188
+ obj["key"] = self.keyName
189
+ if self.modifierKey:
190
+ obj["modifier"] = self.modifierKey
191
+ obj["hold_ms"] = self.holdMs
192
+ obj["pinch_start"] = self.pinchStartDist
193
+ obj["pinch_end"] = self.pinchEndDist
194
+ obj["scroll_amount"] = self.scrollAmount
195
+ if self.mergeDelayMs != 1000:
196
+ obj["merge_delay_ms"] = self.mergeDelayMs
197
+ if self.dragPoints:
198
+ obj["drag_points"] = [{"x": p[0], "y": p[1]} for p in self.dragPoints]
199
+ if self.dragStepDelay != 500:
200
+ obj["drag_step_delay"] = self.dragStepDelay
201
+ if self.gotoTarget != 0:
202
+ obj["goto_target"] = self.gotoTarget
203
+ if self.minMatchCount != 2:
204
+ obj["min_match_count"] = self.minMatchCount
205
+ if self.compareOp:
206
+ obj["compare_op"] = self.compareOp
207
+ if self.compareValue:
208
+ obj["compare_value"] = self.compareValue
209
+ if self.clickDelayMs != 300:
210
+ obj["click_delay_ms"] = self.clickDelayMs
211
+ if self.groupName:
212
+ obj["group_name"] = self.groupName
213
+ if self.disabled:
214
+ obj["disabled"] = True
215
+ if self.multiScale:
216
+ obj["multi_scale"] = True
217
+ if self.roiRect is not None:
218
+ rx, ry, rw, rh = self.roiRect
219
+ obj["roi"] = {"x": rx, "y": ry, "w": rw, "h": rh}
220
+ if self.randomOffset > 0:
221
+ obj["random_offset"] = self.randomOffset
222
+ if self.targetColor:
223
+ obj["target_color"] = self.targetColor
224
+ obj["color_tolerance"] = self.colorTolerance
225
+ if self.repeat.times != 1:
226
+ obj["repeat_times"] = self.repeat.times
227
+ if self.repeat.delayMs != 500:
228
+ obj["repeat_delay_ms"] = self.repeat.delayMs
229
+ if self.until.type != UntilCondition.NoneCond:
230
+ obj["until_type"] = self.until.type.value
231
+ if self.until.image:
232
+ obj["until_image"] = self.until.image
233
+ if self.until.text:
234
+ obj["until_text"] = self.until.text
235
+ obj["until_threshold"] = self.until.threshold
236
+ if self.until.color:
237
+ obj["until_color"] = self.until.color
238
+ obj["until_color_tolerance"] = self.until.colorTolerance
239
+ if self.until.x != 0 or self.until.y != 0:
240
+ obj["until_x"] = self.until.x
241
+ obj["until_y"] = self.until.y
242
+ obj["until_timeout_ms"] = self.until.timeoutMs
243
+ return obj
244
+
245
+ @staticmethod
246
+ def from_json(obj: dict) -> "Step":
247
+ s = Step()
248
+ s.type = StepType(obj.get("type", "wait"))
249
+ s.imagePath = obj.get("image", "")
250
+ s.imagePaths = list(obj.get("images", []))
251
+ s.text = obj.get("text", "")
252
+ s.x = int(obj.get("x", 0))
253
+ s.y = int(obj.get("y", 0))
254
+ s.waitMs = int(obj.get("wait_ms", 1000))
255
+ s.waitMsMax = int(obj.get("wait_ms_max", 2000))
256
+ s.loopTarget = int(obj.get("loop_target", 0))
257
+ s.loopCount = int(obj.get("loop_count", -1))
258
+ s.threshold = float(obj.get("threshold", 0.8))
259
+ s.timeoutMs = int(obj.get("timeout_ms", 5000))
260
+ s.elseSkip = int(obj.get("else_skip", 1))
261
+ s.subTaskPath = obj.get("sub_task", "")
262
+ s.varName = obj.get("var_name", "")
263
+ s.varValue = obj.get("var_value", "")
264
+ s.retry.policy = RetryPolicy(obj.get("retry_policy", "none"))
265
+ s.retry.count = int(obj.get("retry_count", 3))
266
+ s.retry.delayMs = int(obj.get("retry_delay_ms", 1000))
267
+ s.x2 = int(obj.get("x2", 0))
268
+ s.y2 = int(obj.get("y2", 0))
269
+ s.durationMs = int(obj.get("duration_ms", 500))
270
+ s.keyName = obj.get("key", "")
271
+ s.modifierKey = obj.get("modifier", "")
272
+ s.holdMs = int(obj.get("hold_ms", 1000))
273
+ s.pinchStartDist = int(obj.get("pinch_start", 100))
274
+ s.pinchEndDist = int(obj.get("pinch_end", 50))
275
+ s.scrollAmount = int(obj.get("scroll_amount", 3))
276
+ s.mergeDelayMs = int(obj.get("merge_delay_ms", 1000))
277
+ s.dragPoints = [(int(p["x"]), int(p["y"])) for p in obj.get("drag_points", [])]
278
+ s.dragStepDelay = int(obj.get("drag_step_delay", 500))
279
+ s.gotoTarget = int(obj.get("goto_target", 0))
280
+ s.minMatchCount = int(obj.get("min_match_count", 2))
281
+ s.compareOp = obj.get("compare_op", "")
282
+ s.compareValue = obj.get("compare_value", "")
283
+ s.clickDelayMs = int(obj.get("click_delay_ms", 300))
284
+ s.groupName = obj.get("group_name", "")
285
+ s.disabled = bool(obj.get("disabled", False))
286
+ s.multiScale = bool(obj.get("multi_scale", False))
287
+ if "roi" in obj:
288
+ r = obj["roi"]
289
+ s.roiRect = (int(r["x"]), int(r["y"]), int(r["w"]), int(r["h"]))
290
+ s.randomOffset = int(obj.get("random_offset", 0))
291
+ s.targetColor = obj.get("target_color", "")
292
+ s.colorTolerance = int(obj.get("color_tolerance", 30))
293
+ s.repeat.times = int(obj.get("repeat_times", 1))
294
+ s.repeat.delayMs = int(obj.get("repeat_delay_ms", 500))
295
+ s.until.type = UntilCondition(obj.get("until_type", "none"))
296
+ s.until.image = obj.get("until_image", "")
297
+ s.until.text = obj.get("until_text", "")
298
+ s.until.threshold = float(obj.get("until_threshold", 0.8))
299
+ s.until.color = obj.get("until_color", "")
300
+ s.until.colorTolerance = int(obj.get("until_color_tolerance", 30))
301
+ s.until.x = int(obj.get("until_x", 0))
302
+ s.until.y = int(obj.get("until_y", 0))
303
+ s.until.timeoutMs = int(obj.get("until_timeout_ms", 30000))
304
+ return s
305
+
306
+ # ------------------------------------------------------------------
307
+ # Human-readable label (mirrors Step::displayName for log/UI parity)
308
+ # ------------------------------------------------------------------
309
+ def display_name(self) -> str:
310
+ t = self.type
311
+
312
+ def fname(p: str) -> str:
313
+ return os.path.basename(p)
314
+
315
+ def stem(p: str) -> str:
316
+ return os.path.splitext(os.path.basename(p))[0]
317
+
318
+ if t == StepType.DoUntil:
319
+ cond = self._until_cond_text_dountil()
320
+ return f"循环直到 步骤{self.loopTarget + 1} ({cond})"
321
+
322
+ base = ""
323
+ if t == StepType.ClickImage:
324
+ base = (f"找图点击: [{len(self.imagePaths)}个模板]" if self.imagePaths
325
+ else f"找图点击: {fname(self.imagePath)}")
326
+ elif t == StepType.ClickText:
327
+ base = f'找字点击: "{self.text}"'
328
+ elif t == StepType.ClickPosition:
329
+ base = f"坐标点击: ({self.x}, {self.y})"
330
+ elif t == StepType.Wait:
331
+ base = f"等待 {self.waitMs}ms"
332
+ elif t == StepType.RandomWait:
333
+ base = f"随机等待 {self.waitMs}~{self.waitMsMax}ms"
334
+ elif t == StepType.Loop:
335
+ base = (f"循环到步骤 {self.loopTarget + 1} (无限)" if self.loopCount < 0
336
+ else f"循环到步骤 {self.loopTarget + 1} ({self.loopCount}次)")
337
+ elif t == StepType.IfImageFound:
338
+ base = (f"条件(图片): [{len(self.imagePaths)}个模板]" if self.imagePaths
339
+ else f"条件(图片): {fname(self.imagePath)}")
340
+ elif t == StepType.IfTextFound:
341
+ base = f'条件(文字): "{self.text}"'
342
+ elif t == StepType.IfElse:
343
+ base = f"If/Else (否则跳过 {self.elseSkip} 步)"
344
+ elif t == StepType.CallSub:
345
+ base = f"调用子任务: {stem(self.subTaskPath)}"
346
+ elif t == StepType.SetVar:
347
+ base = f"设置变量: {self.varName} = {self.varValue}"
348
+ elif t == StepType.KeyPress:
349
+ base = f"按键: {self.keyName}"
350
+ elif t == StepType.TypeText:
351
+ base = f'输入文字: "{self.text}"'
352
+ elif t == StepType.Hotkey:
353
+ base = f"快捷键: {self.modifierKey}+{self.keyName}"
354
+ elif t == StepType.LongPress:
355
+ base = f"长按: ({self.x},{self.y}) {self.holdMs}ms"
356
+ elif t == StepType.Drag:
357
+ base = f"拖拽: ({self.x},{self.y})→({self.x2},{self.y2})"
358
+ elif t == StepType.Pinch:
359
+ base = f"捏合: {self.pinchStartDist}→{self.pinchEndDist}"
360
+ elif t == StepType.WaitImage:
361
+ base = (f"等待图片出现: [{len(self.imagePaths)}个模板]" if self.imagePaths
362
+ else f"等待图片出现: {fname(self.imagePath)}")
363
+ elif t == StepType.WaitImageGone:
364
+ base = (f"等待图片消失: [{len(self.imagePaths)}个模板]" if self.imagePaths
365
+ else f"等待图片消失: {fname(self.imagePath)}")
366
+ elif t == StepType.Scroll:
367
+ base = f"滚轮: ({self.x},{self.y}) {self.scrollAmount}格"
368
+ elif t == StepType.IfColorMatch:
369
+ base = f"条件(颜色): ({self.x},{self.y}) {self.targetColor}"
370
+ elif t == StepType.MergeImage:
371
+ base = (f"合成拖拽: [{len(self.imagePaths)}个模板]" if self.imagePaths
372
+ else f"合成拖拽: {fname(self.imagePath)}")
373
+ elif t == StepType.MultiDrag:
374
+ base = f"多点拖拽: {len(self.dragPoints)}个点"
375
+ elif t == StepType.Goto:
376
+ base = f"跳转到步骤 {self.gotoTarget + 1}"
377
+ elif t == StepType.IfImageCount:
378
+ img = f"[{len(self.imagePaths)}个模板]" if self.imagePaths else fname(self.imagePath)
379
+ base = f"条件(图片数量≥{self.minMatchCount}): {img}"
380
+ elif t == StepType.IfVar:
381
+ base = f"条件(变量): {self.varName} {self.compareOp} {self.compareValue}"
382
+ elif t == StepType.ClickAll:
383
+ img = f"[{len(self.imagePaths)}个模板]" if self.imagePaths else fname(self.imagePath)
384
+ base = f"点击所有匹配: {img}"
385
+ elif t == StepType.GroupBegin:
386
+ base = f"┌ 分组: {self.groupName or '未命名'}"
387
+ if self.disabled:
388
+ base += " [已禁用]"
389
+ elif t == StepType.GroupEnd:
390
+ base = "└ 分组结束"
391
+ elif t == StepType.WhileCondition:
392
+ base = f"While({self._while_cond_text()}) →步骤{self.loopTarget + 1}"
393
+ elif t == StepType.ReadText:
394
+ mode = "数字" if self.varValue == "number" else "文字"
395
+ base = f"读取{mode}→变量: {self.varName}"
396
+ elif t == StepType.ClickRelative:
397
+ base = f"相对点击: last+({self.x},{self.y})"
398
+ else:
399
+ base = "Unknown"
400
+
401
+ if self.repeat.times > 1:
402
+ base += f" ×{self.repeat.times}"
403
+ elif self.repeat.times < 0:
404
+ base += " ×∞"
405
+ if self.until.type != UntilCondition.NoneCond:
406
+ u = self.until
407
+ if u.type == UntilCondition.ImageFound:
408
+ base += f" →直到出现:{fname(u.image)}"
409
+ elif u.type == UntilCondition.ImageGone:
410
+ base += f" →直到消失:{fname(u.image)}"
411
+ elif u.type == UntilCondition.TextFound:
412
+ base += f' →直到:"{u.text}"'
413
+ elif u.type == UntilCondition.ColorMatch:
414
+ base += f" →直到颜色:{u.color}"
415
+ return base
416
+
417
+ def _while_cond_text(self) -> str:
418
+ u = self.until
419
+ if u.type == UntilCondition.ImageFound:
420
+ return f"图片存在: {os.path.basename(u.image)}"
421
+ if u.type == UntilCondition.ImageGone:
422
+ return f"图片不存在: {os.path.basename(u.image)}"
423
+ if u.type == UntilCondition.TextFound:
424
+ return f'文字存在: "{u.text}"'
425
+ if u.type == UntilCondition.ColorMatch:
426
+ return f"颜色匹配: {u.color}"
427
+ return f"变量 {self.varName} {self.compareOp} {self.compareValue}"
428
+
429
+ def _until_cond_text_dountil(self) -> str:
430
+ u = self.until
431
+ if u.type == UntilCondition.ImageFound:
432
+ return f"图片出现: {os.path.basename(u.image)}"
433
+ if u.type == UntilCondition.ImageGone:
434
+ return f"图片消失: {os.path.basename(u.image)}"
435
+ if u.type == UntilCondition.TextFound:
436
+ return f'文字出现: "{u.text}"'
437
+ if u.type == UntilCondition.ColorMatch:
438
+ return f"颜色匹配: ({u.x},{u.y}) {u.color}"
439
+ return "无条件"
440
+
441
+
442
+ @dataclass
443
+ class Task:
444
+ name: str = "Untitled"
445
+ steps: list[Step] = field(default_factory=list)
446
+
447
+ def to_json(self) -> dict:
448
+ return {"name": self.name, "steps": [s.to_json() for s in self.steps]}
449
+
450
+ @staticmethod
451
+ def from_json(doc: dict) -> "Task":
452
+ task = Task()
453
+ task.name = doc.get("name", "Untitled")
454
+ task.steps = [Step.from_json(o) for o in doc.get("steps", [])]
455
+ return task
456
+
457
+ def save_to_file(self, path: str) -> bool:
458
+ try:
459
+ with open(path, "w", encoding="utf-8") as f:
460
+ json.dump(self.to_json(), f, ensure_ascii=False, indent=4, sort_keys=True)
461
+ return True
462
+ except OSError:
463
+ return False
464
+
465
+ @staticmethod
466
+ def load_from_file(path: str) -> "Task":
467
+ with open(path, "r", encoding="utf-8") as f:
468
+ return Task.from_json(json.load(f))
autoclicker/ocr.py ADDED
@@ -0,0 +1,63 @@
1
+ """Native PaddleOCR wrapper (lazy singleton).
2
+
3
+ Replaces the C++ socket OCR daemon. Mirrors the preprocessing in matcher.cpp:
4
+ images narrower than 800px are upscaled 2x before OCR and coordinates scaled
5
+ back. PaddleOCR is imported lazily so the rest of the engine works without it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import cv2
10
+ import numpy as np
11
+
12
+ _engine = None
13
+ _import_failed = False
14
+
15
+
16
+ def _get_engine():
17
+ global _engine, _import_failed
18
+ if _engine is not None:
19
+ return _engine
20
+ if _import_failed:
21
+ return None
22
+ try:
23
+ from paddleocr import PaddleOCR
24
+ _engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
25
+ except Exception:
26
+ _import_failed = True
27
+ return None
28
+ return _engine
29
+
30
+
31
+ def is_available() -> bool:
32
+ return _get_engine() is not None
33
+
34
+
35
+ def recognize(screen: np.ndarray):
36
+ """Return list of TextResult for text in *screen* (empty if OCR unavailable)."""
37
+ from autoclicker.matcher import TextResult
38
+
39
+ engine = _get_engine()
40
+ if engine is None or screen is None or screen.size == 0:
41
+ return []
42
+
43
+ scale = 2.0 if screen.shape[1] < 800 else 1.0
44
+ img = (cv2.resize(screen, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
45
+ if scale > 1.0 else screen)
46
+
47
+ try:
48
+ raw = engine.ocr(img, cls=True)
49
+ except Exception:
50
+ return []
51
+
52
+ results: list[TextResult] = []
53
+ if not raw or raw[0] is None:
54
+ return results
55
+ for line in raw[0]:
56
+ box, (text, conf) = line[0], line[1]
57
+ xs = [p[0] for p in box]
58
+ ys = [p[1] for p in box]
59
+ x, y = min(xs), min(ys)
60
+ w, h = max(xs) - x, max(ys) - y
61
+ x, y, w, h = int(x / scale), int(y / scale), int(w / scale), int(h / scale)
62
+ results.append(TextResult(text, x + w // 2, y + h // 2, x, y, w, h, float(conf)))
63
+ return results