Skip to content

Commit e6c5b03

Browse files
feat: add image locate step type with template matching, grid click, and config panel
- Backend: image storage endpoints for task reference images - Backend: click() supports grid=(col,row) and splits=(h,v) for region clicks - Backend: _exists() catches TimeoutError for image-based checks - Backend: ws_run_task copies referenced images to temp dir for execution - Backend: parse/generate # @imglocate JSON metadata in .py steps - Frontend: new 'imglocate' slash command for image-based positioning - Frontend: screenshot canvas drag-to-crop for template image capture - Frontend: image step card with preview, action badge, and grid params - Frontend: config dialog with grid overlay, cell click selection, sliders - Frontend: code generation for click/wait_show/wait_hide image actions - CSS: styles for image step cards and config panel
1 parent 8cc736c commit e6c5b03

7 files changed

Lines changed: 656 additions & 42 deletions

File tree

ha4t/api.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,32 @@ def perform_click(x, y, duration):
8383
device.driver(**args[0], **kwargs).long_click(duration=duration)
8484
elif kwargs.get("image"):
8585
path = os.path.join(_CF.CURRENT_PATH, kwargs["image"])
86-
pos = match_loop(screenshot_func=device.driver.screenshot, template=path, timeout=kwargs.get("timeout", 10),
87-
threshold=kwargs.get("threshold", 0.8))
86+
grid = kwargs.pop("grid", None)
87+
splits = kwargs.pop("splits", None)
88+
if grid and splits:
89+
# Grid-based click: match template, then click center of specified grid cell
90+
import cv2
91+
from ha4t.aircv.cv import Template
92+
source_image = device.driver.screenshot()
93+
source_image = np.array(source_image.resize((_CF.SCREEN_WIDTH, _CF.SCREEN_HEIGHT)))
94+
template = Template(path, threshold=kwargs.get("threshold", 0.8), rgb=kwargs.get("rgb", False),
95+
scale_max=kwargs.get("scale_max", 800), scale_step=kwargs.get("scale_step", 0.005))
96+
result = template._cv_match(source_image)
97+
if not result:
98+
raise TimeoutError(f"图片匹配失败: {path}")
99+
rect = result["rectangle"] # [(x1,y1), (x2,y2), (x3,y3), (x4,y4)]
100+
x1, y1 = rect[0]
101+
x3, y3 = rect[2]
102+
w = x3 - x1
103+
h = y3 - y1
104+
cell_w = w / splits[0]
105+
cell_h = h / splits[1]
106+
target_x = x1 + cell_w * (grid[0] + 0.5)
107+
target_y = y1 + cell_h * (grid[1] + 0.5)
108+
pos = (int(target_x), int(target_y))
109+
else:
110+
pos = match_loop(screenshot_func=device.driver.screenshot, template=path, timeout=kwargs.get("timeout", 10),
111+
threshold=kwargs.get("threshold", 0.8))
88112
perform_click(*pos, duration)
89113
else:
90114
timeout = kwargs.pop("timeout", 3)
@@ -143,11 +167,11 @@ def _exists(*args, **kwargs) -> bool:
143167
else:
144168
if kwargs.get("image"):
145169
path = os.path.join(_CF.CURRENT_PATH, kwargs["image"])
146-
pos = match_loop(screenshot_func=device.driver.screenshot, template=path, timeout=kwargs.get("timeout", 10),
147-
threshold=kwargs.get("threshold", 0.8))
148-
if pos:
149-
return True
150-
else:
170+
try:
171+
pos = match_loop(screenshot_func=device.driver.screenshot, template=path, timeout=kwargs.get("timeout", 10),
172+
threshold=kwargs.get("threshold", 0.8))
173+
return True if pos else False
174+
except Exception:
151175
return False
152176
else:
153177
return device.driver(**kwargs).exists

ha4t/editor/routers/api.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22

3+
import base64
34
import json
45
import os
56
import re
@@ -22,6 +23,7 @@
2223
from ha4t.editor._models import ( ApiResponse, XPathLiteRequest,
2324
TaskFile, TaskSaveRequest, TaskRunRequest, TaskStepResult, TaskRunResponse
2425
)
26+
from pydantic import BaseModel
2527
from ha4t.editor.parser.xpath_lite import XPathLiteGenerator
2628

2729

@@ -134,24 +136,35 @@ def press_key(platform: str, serial: str, key: str):
134136
# ── Task API (.py + # --step--) ──────────────────────────
135137

136138

139+
import json as _json
140+
137141
def _parse_py_steps(content: str) -> list:
138-
"""Parse # --step-- markers, return list of {code}"""
142+
"""Parse # --step-- markers, return list of {code, meta}"""
139143
lines = content.split('\n')
140144
steps = []
141145
buf = []
146+
meta = None
142147
in_step = False
143148
for line in lines:
144149
stripped = line.strip()
145150
if stripped == _STEP_MARKER:
146151
if in_step and buf:
147-
steps.append('\n'.join(buf).strip())
152+
steps.append({'code': '\n'.join(buf).strip(), 'meta': meta})
148153
buf = []
154+
meta = None
149155
in_step = True
150156
continue
151-
if in_step and stripped and not stripped.startswith('#'):
152-
buf.append(line)
157+
if in_step:
158+
if stripped.startswith('# @imglocate'):
159+
try:
160+
meta = _json.loads(stripped[len('# @imglocate'):].strip())
161+
except Exception:
162+
pass
163+
continue
164+
if stripped and not stripped.startswith('#'):
165+
buf.append(line)
153166
if in_step and buf:
154-
steps.append('\n'.join(buf).strip())
167+
steps.append({'code': '\n'.join(buf).strip(), 'meta': meta})
155168
return steps
156169

157170

@@ -168,7 +181,7 @@ def _extract_meta(content: str) -> dict:
168181
return {'name': name, 'desc': desc, 'platform': platform}
169182

170183

171-
def _generate_py(name, desc, platform, step_codes, extra_lines=None):
184+
def _generate_py(name, desc, platform, steps, extra_lines=None):
172185
lines = ['# name: ' + name]
173186
if desc:
174187
lines.append('# desc: ' + desc)
@@ -181,9 +194,12 @@ def _generate_py(name, desc, platform, step_codes, extra_lines=None):
181194
lines.append('')
182195
if extra_lines:
183196
lines.extend(extra_lines)
184-
for code in step_codes:
197+
for step in steps:
185198
lines.append(_STEP_MARKER)
186-
lines.append(code)
199+
meta = step.get('meta')
200+
if meta and meta.get('_type') == 'imglocate':
201+
lines.append('# @imglocate ' + _json.dumps(meta, ensure_ascii=False, separators=(',', ':')))
202+
lines.append(step['code'])
187203
lines.append('')
188204
return '\n'.join(lines)
189205

@@ -221,6 +237,42 @@ def save_task(filename: str, req: TaskSaveRequest):
221237
return ApiResponse.doSuccess({"filename": filename, "saved": True})
222238

223239

240+
@router.get("/tasks/{filename:path}/images", response_model=ApiResponse)
241+
def list_task_images(filename: str):
242+
stem = Path(filename).stem
243+
img_dir = TASKS_DIR / f"{stem}_imgs"
244+
if not img_dir.exists():
245+
return ApiResponse.doSuccess([])
246+
files = [p.name for p in sorted(img_dir.glob("*.png"))]
247+
return ApiResponse.doSuccess(files)
248+
249+
250+
@router.get("/tasks/{filename:path}/images/{imgname}", response_model=ApiResponse)
251+
def get_task_image(filename: str, imgname: str):
252+
stem = Path(filename).stem
253+
img_path = TASKS_DIR / f"{stem}_imgs" / imgname
254+
if not img_path.exists():
255+
return ApiResponse.doError(f"Image not found: {imgname}")
256+
data = base64.b64encode(img_path.read_bytes()).decode("utf-8")
257+
return ApiResponse.doSuccess({"filename": imgname, "data": data})
258+
259+
260+
class ImageUploadRequest(BaseModel):
261+
data: str
262+
263+
@router.post("/tasks/{filename:path}/images/{imgname}", response_model=ApiResponse)
264+
def save_task_image(filename: str, imgname: str, req: ImageUploadRequest):
265+
stem = Path(filename).stem
266+
img_dir = TASKS_DIR / f"{stem}_imgs"
267+
img_dir.mkdir(parents=True, exist_ok=True)
268+
img_path = img_dir / imgname
269+
data = req.data or ""
270+
if data.startswith("data:image"):
271+
data = data.split(",", 1)[1]
272+
img_path.write_bytes(base64.b64decode(data))
273+
return ApiResponse.doSuccess({"filename": imgname, "saved": True})
274+
275+
224276
@router.websocket("/ws/run")
225277
async def ws_run_task(ws: WebSocket):
226278
await ws.accept()
@@ -242,6 +294,16 @@ async def ws_run_task(ws: WebSocket):
242294
tmppy = tmpdir / (filename or "untitled.py")
243295
tmppy.write_text(content, encoding="utf-8")
244296

297+
# Copy referenced images to temp dir so the script can find them
298+
stem = Path(filename).stem
299+
img_dir = TASKS_DIR / f"{stem}_imgs"
300+
if img_dir.exists():
301+
for img in img_dir.glob("*.png"):
302+
try:
303+
shutil.copy(img, tmpdir / img.name)
304+
except Exception:
305+
pass
306+
245307
step_codes = _parse_py_steps(content)
246308
total = len(step_codes)
247309

ha4t/editor/static/css/style.css

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,116 @@ code {
417417
.log-info { color: #888; }
418418
.log-ok { color: #67c23a; }
419419
.log-fail { color: #f56c6c; }
420-
.log-warn { color: #e6a23c; }
420+
.log-warn { color: #e6a23c; }
421+
422+
.capture-mode #hierarchyCanvas {
423+
cursor: crosshair !important;
424+
}
425+
426+
.capture-indicator {
427+
position: absolute;
428+
top: 35px;
429+
left: 50%;
430+
transform: translateX(-50%);
431+
background: rgba(54, 121, 227, 0.9);
432+
color: #fff;
433+
padding: 4px 12px;
434+
border-radius: 4px;
435+
font-size: 12px;
436+
z-index: 20;
437+
pointer-events: none;
438+
}
439+
440+
/* ── Image Step Card ── */
441+
.step-img {
442+
padding: 6px 12px;
443+
min-height: auto;
444+
}
445+
.img-step-card {
446+
display: flex;
447+
gap: 10px;
448+
width: 100%;
449+
height: 200px;
450+
}
451+
.img-step-preview {
452+
width: 160px;
453+
height: 100%;
454+
flex-shrink: 0;
455+
display: flex;
456+
align-items: center;
457+
justify-content: center;
458+
background: #1a2530;
459+
border-radius: 4px;
460+
overflow: hidden;
461+
border: 1px solid #2a3040;
462+
}
463+
.img-step-preview img {
464+
max-width: 100%;
465+
max-height: 100%;
466+
object-fit: contain;
467+
}
468+
.img-step-info {
469+
flex: 1;
470+
display: flex;
471+
flex-direction: column;
472+
gap: 4px;
473+
overflow: hidden;
474+
}
475+
.img-step-title {
476+
display: flex;
477+
align-items: center;
478+
gap: 4px;
479+
font-size: 12px;
480+
}
481+
.img-step-badge {
482+
background: #3679E3;
483+
color: #fff;
484+
padding: 1px 6px;
485+
border-radius: 3px;
486+
font-size: 11px;
487+
}
488+
.img-step-params {
489+
display: flex;
490+
flex-direction: column;
491+
gap: 2px;
492+
color: #8492a6;
493+
font-size: 11px;
494+
}
495+
.img-step-btns {
496+
display: flex;
497+
gap: 2px;
498+
margin-top: auto;
499+
}
500+
501+
/* ── Image Config Panel ── */
502+
.img-config-body {
503+
display: flex;
504+
flex-direction: column;
505+
gap: 10px;
506+
}
507+
.img-config-preview-wrap {
508+
position: relative;
509+
width: 100%;
510+
height: 220px;
511+
background: #1a2530;
512+
border-radius: 4px;
513+
overflow: hidden;
514+
border: 1px solid #2a3040;
515+
display: flex;
516+
align-items: center;
517+
justify-content: center;
518+
}
519+
.img-config-preview {
520+
max-width: 100%;
521+
max-height: 100%;
522+
object-fit: contain;
523+
}
524+
.img-config-grid {
525+
position: absolute;
526+
top: 0;
527+
left: 0;
528+
width: 100%;
529+
height: 100%;
530+
pointer-events: auto;
531+
cursor: crosshair;
532+
}

0 commit comments

Comments
 (0)