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/__init__.py +14 -0
- autoclicker/app.py +21 -0
- autoclicker/capture.py +94 -0
- autoclicker/clicker.py +212 -0
- autoclicker/dpi.py +30 -0
- autoclicker/gui/__init__.py +1 -0
- autoclicker/gui/flow_connection.py +91 -0
- autoclicker/gui/flow_editor.py +340 -0
- autoclicker/gui/flow_node.py +107 -0
- autoclicker/gui/hotkeys.py +40 -0
- autoclicker/gui/main_window.py +651 -0
- autoclicker/gui/monitor.py +84 -0
- autoclicker/gui/preview_label.py +176 -0
- autoclicker/gui/qt_runner.py +71 -0
- autoclicker/gui/recorder.py +163 -0
- autoclicker/gui/scheduler_widget.py +160 -0
- autoclicker/gui/snippet_library.py +117 -0
- autoclicker/gui/task_widget.py +547 -0
- autoclicker/matcher.py +152 -0
- autoclicker/models.py +468 -0
- autoclicker/ocr.py +63 -0
- autoclicker/run.py +64 -0
- autoclicker/runner.py +666 -0
- autoclicker/windows.py +53 -0
- pyclickerrpa-0.1.0.dist-info/METADATA +90 -0
- pyclickerrpa-0.1.0.dist-info/RECORD +29 -0
- pyclickerrpa-0.1.0.dist-info/WHEEL +5 -0
- pyclickerrpa-0.1.0.dist-info/entry_points.txt +5 -0
- pyclickerrpa-0.1.0.dist-info/top_level.txt +1 -0
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
|