PRA-only-demo/src/main/local-vision/ui-detect-worker.ts

230 lines
8.0 KiB
TypeScript

import fs from "node:fs";
import { parentPort, workerData } from "node:worker_threads";
import { InferenceSession, Tensor } from "onnxruntime-node";
import { createCanvas, loadImage } from "@napi-rs/canvas";
export interface LocalUiBox {
x: number;
y: number;
width: number;
height: number;
category: string;
confidence: 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;
}
type ModelOutput = { output0: Tensor };
type TensorLike = { dims: number[]; data: Float32Array };
const inputName = "images";
const outputName = "output0";
const inputSize = 640;
const defaultScoreThreshold = 0.25;
const defaultIouThreshold = 0.42;
const maxBoxes = 160;
const startupModelPath = String((workerData as { modelPath?: string }).modelPath || "");
const modelLabels = readModelLabels(startupModelPath);
let sessionPromise: Promise<InferenceSession> | null = null;
function loadSession(): Promise<InferenceSession> {
if (!sessionPromise) {
const modelPath = String((workerData as { modelPath?: string }).modelPath || "");
sessionPromise = InferenceSession.create(modelPath, {
executionProviders: ["cpu"],
graphOptimizationLevel: "all",
executionMode: "sequential"
});
}
return sessionPromise;
}
function iou(a: LocalUiBox, b: LocalUiBox): number {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.width, b.x + b.width);
const y2 = Math.min(a.y + a.height, b.y + b.height);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
if (intersection <= 0) return 0;
return intersection / (a.width * a.height + b.width * b.height - intersection);
}
function nms(boxes: LocalUiBox[], iouThreshold: number): LocalUiBox[] {
const sorted = [...boxes].sort((a, b) => b.confidence - a.confidence);
const kept: LocalUiBox[] = [];
for (const box of sorted) {
if (kept.every(item => iou(item, box) <= iouThreshold)) kept.push(box);
}
return kept;
}
function tensorAccessor(output: TensorLike, anchorCount: number, channelCount: number, channelFirst: boolean) {
return (channel: number, anchor: number): number => channelFirst
? output.data[channel * anchorCount + anchor]
: output.data[anchor * channelCount + channel];
}
// YOLO 导出的 ONNX 通常不含标签元数据。约定同名 *.labels.json 保存类别:
// 1) ["button", "input"] 2) { "labels": ["button", "input"] }
function readModelLabels(modelPath: string): string[] | undefined {
const sidecarPath = modelPath.replace(/\.onnx$/i, ".labels.json");
try {
const parsed: unknown = JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
const values = Array.isArray(parsed) ? parsed : (parsed as { labels?: unknown })?.labels;
if (!Array.isArray(values)) return undefined;
const labels = values
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(item => String(item))
.filter(Boolean);
return labels.length ? labels : undefined;
} catch {
return undefined;
}
}
function extractBoxes(
output: TensorLike,
sourceWidth: number,
sourceHeight: number,
scoreThreshold: number,
iouThreshold: number
): LocalUiBox[] {
// YOLO/OmniParser exports are commonly [1,C,N] or [1,N,C]. C=4+classCount.
if (output.dims.length < 2) throw new Error(`不支持的模型输出维度:${output.dims.join("x")}`);
const [, first, second] = output.dims;
const channelFirst = first <= second;
const channelCount = channelFirst ? first : second;
const anchorCount = channelFirst ? second : first;
if (channelCount < 5) throw new Error(`模型输出通道数异常:${channelCount}`);
const at = tensorAccessor(output, anchorCount, channelCount, channelFirst);
const classCount = channelCount - 4;
const padded = Math.max(sourceWidth, sourceHeight);
const scale = inputSize / padded;
const drawnWidth = Math.round(sourceWidth * scale);
const drawnHeight = Math.round(sourceHeight * scale);
const offsetX = (inputSize - drawnWidth) / 2;
const offsetY = (inputSize - drawnHeight) / 2;
const minSide = Math.max(8, Math.min(sourceWidth, sourceHeight) * 0.006);
const labels = modelLabels;
const raw: LocalUiBox[] = [];
for (let index = 0; index < anchorCount; index++) {
let confidence = 0;
let classIndex = 0;
if (classCount === 1) {
confidence = at(4, index);
} else {
for (let candidate = 0; candidate < classCount; candidate++) {
const score = at(4 + candidate, index);
if (score > confidence) {
confidence = score;
classIndex = candidate;
}
}
}
if (!Number.isFinite(confidence) || confidence < scoreThreshold) continue;
const cx = at(0, index);
const cy = at(1, index);
const width = at(2, index);
const height = at(3, index);
let x = (cx - width / 2 - offsetX) / scale;
let y = (cy - height / 2 - offsetY) / scale;
let boxWidth = width / scale;
let boxHeight = height / scale;
x = Math.max(0, Math.min(sourceWidth - 1, x));
y = Math.max(0, Math.min(sourceHeight - 1, y));
boxWidth = Math.max(2, Math.min(sourceWidth - x, boxWidth));
boxHeight = Math.max(2, Math.min(sourceHeight - y, boxHeight));
if (boxWidth < minSide || boxHeight < minSide) continue;
const areaRatio = (boxWidth * boxHeight) / (sourceWidth * sourceHeight);
if (areaRatio > 0.92) continue;
raw.push({
x: Math.round(x),
y: Math.round(y),
width: Math.round(boxWidth),
height: Math.round(boxHeight),
category: labels?.[classIndex]
?? (classCount <= 1 ? "控件" : `类别${classIndex + 1}`),
confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 1000) / 1000
});
}
return nms(raw, iouThreshold)
.sort((a, b) => b.confidence - a.confidence || a.y - b.y || a.x - b.x)
.slice(0, maxBoxes)
.sort((a, b) => a.y - b.y || a.x - b.x);
}
async function detect(request: DetectRequest): Promise<LocalUiBox[]> {
const model = await loadSession();
const source = await loadImage(request.image);
const width = request.width || source.width;
const height = request.height || source.height;
const canvas = createCanvas(inputSize, inputSize);
const context = canvas.getContext("2d");
context.fillStyle = "#727272";
context.fillRect(0, 0, inputSize, inputSize);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const scale = Math.min(inputSize / width, inputSize / height);
context.drawImage(
source,
(inputSize - width * scale) / 2,
(inputSize - height * scale) / 2,
width * scale,
height * scale
);
const pixels = context.getImageData(0, 0, inputSize, inputSize).data;
const tensorData = new Float32Array(3 * inputSize * inputSize);
const planeSize = inputSize * inputSize;
for (let index = 0; index < planeSize; index++) {
tensorData[index] = pixels[index * 4] / 255;
tensorData[planeSize + index] = pixels[index * 4 + 1] / 255;
tensorData[planeSize * 2 + index] = pixels[index * 4 + 2] / 255;
}
const results = await model.run({ [inputName]: new Tensor("float32", tensorData, [1, 3, inputSize, inputSize]) });
const output = results[outputName] as unknown as TensorLike;
return extractBoxes(
output,
width,
height,
request.scoreThreshold ?? defaultScoreThreshold,
request.iouThreshold ?? defaultIouThreshold
);
}
parentPort?.on("message", async (request: DetectRequest) => {
const started = Date.now();
try {
const boxes = await detect(request);
const response: DetectResponse = { id: request.id, boxes, elapsedMs: Date.now() - started };
parentPort?.postMessage(response);
} catch (error) {
const response: DetectResponse = {
id: request.id,
error: error instanceof Error ? error.message : String(error),
elapsedMs: Date.now() - started
};
parentPort?.postMessage(response);
}
});