146 lines
4.3 KiB
TypeScript
146 lines
4.3 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { Worker } from "node:worker_threads";
|
|
import { app } from "electron";
|
|
import { RuntimeError } from "../engine/types";
|
|
import type { LocalUiBox } from "./ui-detect-worker";
|
|
|
|
export interface UiDetectOptions {
|
|
image: Buffer;
|
|
width: number;
|
|
height: number;
|
|
modelPath?: string;
|
|
scoreThreshold?: number;
|
|
iouThreshold?: number;
|
|
}
|
|
|
|
interface DetectRequest {
|
|
id: number;
|
|
image: Buffer;
|
|
width: number;
|
|
height: number;
|
|
scoreThreshold?: number;
|
|
iouThreshold?: number;
|
|
}
|
|
|
|
interface DetectResponse {
|
|
id: number;
|
|
boxes?: LocalUiBox[];
|
|
error?: string;
|
|
elapsedMs?: number;
|
|
}
|
|
|
|
const workers = new Map<string, Worker>();
|
|
const pending = new Map<number, { resolve: (boxes: LocalUiBox[]) => void; reject: (error: unknown) => void; worker: Worker }>();
|
|
let nextRequestId = 1;
|
|
const MAX_LOADED_WORKERS = 4;
|
|
|
|
export function localUiModelPath(): string {
|
|
const candidates = [
|
|
path.join(process.resourcesPath || "", "models", "ui-icon-detect", "model.onnx"),
|
|
path.join(app.getAppPath(), "resources", "models", "ui-icon-detect", "model.onnx")
|
|
].filter(Boolean);
|
|
const found = candidates.find(candidate => fs.existsSync(candidate));
|
|
if (!found) {
|
|
throw new RuntimeError("local_vision_model_missing", "本地 UI 检测模型缺失", {
|
|
recommendation: "重新安装应用,或补齐 resources/models/ui-icon-detect/model.onnx"
|
|
});
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function defaultUiModelPath(): string {
|
|
return localUiModelPath();
|
|
}
|
|
|
|
function resolveModelPath(candidate?: string): string {
|
|
if (!candidate) return localUiModelPath();
|
|
const normalized = path.resolve(candidate);
|
|
if (!fs.existsSync(normalized) || !fs.statSync(normalized).isFile()) {
|
|
throw new RuntimeError("local_vision_model_missing", "模型文件不存在:" + normalized);
|
|
}
|
|
if (path.extname(normalized).toLowerCase() !== ".onnx") {
|
|
throw new RuntimeError("local_vision_model_invalid", "请选择 ONNX 模型文件");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function createWorker(modelPath: string): Worker {
|
|
const next = new Worker(path.join(__dirname, "local-vision/ui-detect-worker.js"), {
|
|
workerData: { modelPath },
|
|
resourceLimits: { maxOldGenerationSizeMb: 512 }
|
|
});
|
|
next.on("message", (message: DetectResponse) => {
|
|
const entry = pending.get(message.id);
|
|
if (!entry || entry.worker !== next) return;
|
|
pending.delete(message.id);
|
|
if (message.error) {
|
|
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测失败:" + message.error));
|
|
return;
|
|
}
|
|
entry.resolve(message.boxes || []);
|
|
});
|
|
next.on("error", error => {
|
|
for (const [id, entry] of pending) {
|
|
if (entry.worker === next) {
|
|
entry.reject(error);
|
|
pending.delete(id);
|
|
}
|
|
}
|
|
workers.delete(modelPath);
|
|
});
|
|
next.on("exit", code => {
|
|
for (const [id, entry] of pending) {
|
|
if (entry.worker === next) {
|
|
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测进程已退出:" + code));
|
|
pending.delete(id);
|
|
}
|
|
}
|
|
if (workers.get(modelPath) === next) workers.delete(modelPath);
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function getWorker(modelPath: string): Worker {
|
|
const existing = workers.get(modelPath);
|
|
if (existing) return existing;
|
|
while (workers.size >= MAX_LOADED_WORKERS) {
|
|
const oldest = workers.keys().next().value;
|
|
if (!oldest) break;
|
|
const worker = workers.get(oldest);
|
|
workers.delete(oldest);
|
|
void worker?.terminate();
|
|
}
|
|
const created = createWorker(modelPath);
|
|
workers.set(modelPath, created);
|
|
return created;
|
|
}
|
|
|
|
export async function detectUiElements(input: UiDetectOptions): Promise<LocalUiBox[]> {
|
|
const modelPath = resolveModelPath(input.modelPath);
|
|
const current = getWorker(modelPath);
|
|
const nextId = nextRequestId++;
|
|
|
|
const request: DetectRequest = {
|
|
id: nextId,
|
|
image: input.image,
|
|
width: input.width,
|
|
height: input.height,
|
|
scoreThreshold: input.scoreThreshold,
|
|
iouThreshold: input.iouThreshold
|
|
};
|
|
return new Promise<LocalUiBox[]>((resolve, reject) => {
|
|
pending.set(nextId, { resolve, reject, worker: current });
|
|
current.postMessage(request);
|
|
});
|
|
}
|
|
|
|
export function closeLocalVisionWorkers(): void {
|
|
for (const [modelPath, worker] of workers) {
|
|
workers.delete(modelPath);
|
|
void worker.terminate();
|
|
}
|
|
pending.clear();
|
|
}
|
|
|