feat: CV 组件支持可选搜索区域(第一阶段)

- locateTextOnScreen 支持区域参数: 框选截图(imagePath)+屏幕偏移(x,y) 或纯坐标范围过滤
- locateFor 读取 searchRegion/region 参数; parseRegionArg 兼容 JSON 与图片路径
- 框选截图时 OCR 只识别该图, 命中坐标加偏移得到屏幕绝对坐标, 避免同名文本干扰
- pickRegionToField: region 类型字段存完整 {x,y,width,height,imagePath}
- 明日继续: catalog 给 CV 四组件加 searchRegion 字段 + region 分支屏幕框选按钮
This commit is contained in:
yu 2026-11-02 17:55:18 +08:00
parent c93543be1c
commit 5aa113ea0e
2 changed files with 72 additions and 20 deletions

View File

@ -44,28 +44,72 @@ async function grabScreen(imagePath: string): Promise<void> {
interface OcrLine { text: string; box?: number[][]; mean?: number }
export async function locateTextOnScreen(target: string, nodeId: string): Promise<{ target: string; text: string; cx: number; cy: number }> {
const shotPath = path.join(os.tmpdir(), "rpa-cv-screen-" + Date.now() + ".png");
await grabScreen(shotPath);
/** 搜索区域: 框选生成时含 imagePath(区域截图)+x/y(屏幕偏移); 也可只给坐标范围 */
interface CvRegion { imagePath?: string; x?: number; y?: number; width?: number; height?: number }
function parseRegionArg(value: unknown): CvRegion | null {
if (!value) return null;
if (typeof value === "object") return value as CvRegion;
const s = String(value).trim();
if (!s) return null;
if (s.startsWith("{")) {
try { return JSON.parse(s) as CvRegion; } catch { return null; }
}
if (/\.(png|jpe?g|bmp)$/i.test(s) || fs.existsSync(s)) return { imagePath: s };
return null;
}
function boxCenter(box: number[][]): { cx: number; cy: number } {
const xs = box.map(p => p[0]);
const ys = box.map(p => p[1]);
return { cx: (Math.min(...xs) + Math.max(...xs)) / 2, cy: (Math.min(...ys) + Math.max(...ys)) / 2 };
}
export async function locateTextOnScreen(
target: string,
nodeId: string,
region?: CvRegion | null
): Promise<{ target: string; text: string; cx: number; cy: number }> {
let shotPath: string | null = null;
let imagePath: string;
let offsetX = 0;
let offsetY = 0;
if (region?.imagePath && fs.existsSync(region.imagePath)) {
// 框选截图: OCR 只识别该图, 命中坐标加上框选时的屏幕偏移得到绝对坐标
imagePath = region.imagePath;
offsetX = Number(region.x ?? 0) || 0;
offsetY = Number(region.y ?? 0) || 0;
} else {
shotPath = path.join(os.tmpdir(), "rpa-cv-screen-" + Date.now() + ".png");
await grabScreen(shotPath);
imagePath = shotPath;
}
const ocr = await getOcr();
const result = await ocr.detect(shotPath);
const result = await ocr.detect(imagePath);
const texts = result.texts ?? [];
const keyword = target.trim();
const hit = texts.find(t => t.text && t.text.includes(keyword));
if (!hit || !hit.box || hit.box.length < 4) {
// 找不到目标时保留截屏, 便于排查(截屏范围/遮挡/清晰度)
throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword, {
nodeId,
componentId: nodeId,
recommendation: "确认目标文本在屏幕上可见且清晰;本次截屏已保留: " + shotPath
const candidates = texts.filter(t => t.text && t.text.includes(keyword) && t.box && t.box.length >= 4);
let hit: OcrLine | undefined = candidates[0];
// 未用框选图但给了坐标范围时, 过滤中心落在区域内的命中
if (hit && !region?.imagePath && region?.width && region?.height) {
const rx = Number(region.x ?? 0) || 0;
const ry = Number(region.y ?? 0) || 0;
hit = candidates.find(t => {
const c = boxCenter(t.box!);
return c.cx >= rx && c.cx <= rx + region.width! && c.cy >= ry && c.cy <= ry + region.height!;
});
}
try { fs.unlinkSync(shotPath); } catch {}
const xs = hit.box.map(p => p[0]);
const ys = hit.box.map(p => p[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
return { target: keyword, text: hit.text, cx, cy };
if (!hit || !hit.box || hit.box.length < 4) {
// 找不到目标时保留截屏, 便于排查(截屏范围/遮挡/清晰度)
throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword + (region?.imagePath ? "(搜索范围: 框选区域)" : ""), {
nodeId,
componentId: nodeId,
recommendation: "确认目标文本在" + (region?.imagePath ? "框选区域内" : "屏幕上") + "可见且清晰;本次识别图片已保留: " + (region?.imagePath ?? shotPath)
});
}
if (shotPath) { try { fs.unlinkSync(shotPath); } catch {} }
const c = boxCenter(hit.box);
return { target: keyword, text: hit.text, cx: c.cx + offsetX, cy: c.cy + offsetY };
}
function screenUser32(): string {
return [
@ -116,7 +160,9 @@ function coordinateScript(x: number, y: number, action: "hover" | "click" | "dou
async function locateFor(args: Record<string, unknown>, node: { id: string; componentId: string }) {
const target = cvTarget(args, node.id);
const located = await locateTextOnScreen(target, node.id);
// 可选搜索区域: 框选生成 {x,y,width,height,imagePath}, 限定 OCR 查找范围(字段明天接入 UI)
const region = parseRegionArg(args.searchRegion ?? args.region);
const located = await locateTextOnScreen(target, node.id, region);
const offsetX = Number(args.offsetX ?? args.offset_x ?? 0) || 0;
const offsetY = Number(args.offsetY ?? args.offset_y ?? 0) || 0;
return { located, x: Math.round(located.cx + offsetX), y: Math.round(located.cy + offsetY) };

View File

@ -609,8 +609,14 @@ function FieldControl({
setPicking(true);
try {
const region = await window.rpa.desktop.pickRegion();
onChange(field.key, region.imagePath);
onToast("已框选并截图:" + region.width + "×" + region.height);
if (field.widget === "region") {
// region 字段存完整信息: 坐标+尺寸+截图路径, 运行时可据此定位区域内容在屏幕上的绝对位置
onChange(field.key, JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height, imagePath: region.imagePath }));
onToast("已框选区域:" + region.width + "×" + region.height + " @(" + region.x + "," + region.y + ")");
} else {
onChange(field.key, region.imagePath);
onToast("已框选并截图:" + region.width + "×" + region.height);
}
} catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error");
} finally {