SVG与预测

This commit is contained in:
2025-09-20 08:11:57 +08:00
parent fa27a24bb6
commit 0619bafbf9
8 changed files with 1087 additions and 255 deletions
+185 -106
View File
@@ -2,14 +2,19 @@
import cv from "@techstark/opencv-js";
export type ProcessImageResult = {
redRectCenterX: number | null;
redRectCenterY: number | null;
greenRectCenterX: number | null;
greenRectCenterY: number | null;
blueRectCenterX: number | null;
blueRectCenterY: number | null;
redRectRotation: number | null; // degrees in [0,180)
greenRectRotation: number | null; // degrees in [0,180)
blueRectRotation: number | null; // degrees in [0,180)
redRectLength: number | null; // long side
greenRectLength: number | null; // long side
blueRectLength: number | null; // long side (ray length)
redRectWidth: number | null; // short side
greenRectWidth: number | null; // short side
blueRectWidth: number | null; // short side = red short side
};
@@ -26,95 +31,78 @@ type RectProps = {
dirLong: Vec2; // unit vector along long edge
};
/* ----------------------- fast kernels/scalars (cached) ----------------------- */
const getKernel3 = (() => {
let K: cv.Mat | null = null;
return () => (K ??= cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)));
})();
const S = (h: number, s: number, v: number) => new cv.Scalar(h, s, v, 0);
// red has two ranges
const RED_LOW1 = S(0, 70, 70);
const RED_UP1 = S(5, 255, 255);
const RED_LOW2 = S(175, 70, 70);
const RED_UP2 = S(179, 255, 255);
const GRN_LOW = S(35, 50, 50);
const GRN_UP = S(85, 255, 255);
// 小工具:接受 Scalar 下界/上界调用 inRange(类型层面不支持 Scalar 的兜底)
function inRangeScalar(src: cv.Mat, low: cv.Scalar, up: cv.Scalar, dst: cv.Mat): void {
// 部分 @techstark/opencv-js 类型未声明 Scalar 重载,但运行时 OpenCV.js 支持
// 运行不支持 Scalar,退回到分配 full-size bound mats(注意释放)
const lowMat = new cv.Mat(src.rows, src.cols, src.type(), low);
const upMat = new cv.Mat(src.rows, src.cols, src.type(), up);
try {
cv.inRange(src, lowMat, upMat, dst);
} finally {
lowMat.delete();
upMat.delete();
}
}
/* ----------------------- main ----------------------- */
export function processImage(imgData: ImageData): ProcessImageResult {
const src = cv.matFromImageData(imgData); // CV_8UC4 RGBA
const toFree: cv.Mat[] = [src];
// 颜色空间转换:由于类型定义里缺少 COLOR_RGBA2HSV,改为 RGBA -> RGB -> HSV 两步
const rgb = new cv.Mat(); toFree.push(rgb);
cv.cvtColor(src, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat(); toFree.push(hsv);
cv.cvtColor(src, rgb, cv.COLOR_RGBA2RGB);
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const gray = new cv.Mat(); toFree.push(gray);
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
// ---- color masks (HSV) ----
const kernel3 = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
// red = two ranges
const redMask = (() => {
const low1 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [0, 80, 80, 0]);
const up1 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [5, 255, 255, 0]);
const low2 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [175, 80, 80, 0]);
const up2 = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [179, 255, 255, 0]);
const m1 = new cv.Mat(); const m2 = new cv.Mat();
cv.inRange(hsv, low1, up1, m1);
cv.inRange(hsv, low2, up2, m2);
const rm = new cv.Mat();
cv.bitwise_or(m1, m2, rm);
// morphology open -> close
cv.morphologyEx(rm, rm, cv.MORPH_OPEN, kernel3);
cv.morphologyEx(rm, rm, cv.MORPH_CLOSE, kernel3);
low1.delete(); up1.delete(); low2.delete(); up2.delete(); m1.delete(); m2.delete();
toFree.push(rm);
return rm;
})();
const kernel3 = getKernel3();
const grnMask = (() => {
const low = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [35, 80, 80, 0]);
const up = new cv.Mat(hsv.rows, hsv.cols, hsv.type(), [85, 255, 255, 0]);
const gm = new cv.Mat();
cv.inRange(hsv, low, up, gm);
cv.morphologyEx(gm, gm, cv.MORPH_OPEN, kernel3);
cv.morphologyEx(gm, gm, cv.MORPH_CLOSE, kernel3);
low.delete(); up.delete();
toFree.push(gm);
return gm;
})();
// 用 Scalar 直接 inRange,避免每帧构造整幅 low/high Mat
const redMask = new cv.Mat(); toFree.push(redMask);
const tmp1 = new cv.Mat(); const tmp2 = new cv.Mat(); toFree.push(tmp1, tmp2);
inRangeScalar(hsv, RED_LOW1, RED_UP1, tmp1);
inRangeScalar(hsv, RED_LOW2, RED_UP2, tmp2);
cv.bitwise_or(tmp1, tmp2, redMask);
cv.morphologyEx(redMask, redMask, cv.MORPH_OPEN, kernel3);
cv.morphologyEx(redMask, redMask, cv.MORPH_CLOSE, kernel3);
// ---- detect largest rect (by area) within mask ----
const detectLargestBox = (graySrc: cv.Mat, mask: cv.Mat): RectProps | null => {
const masked = new cv.Mat(); toFree.push(masked);
cv.bitwise_and(graySrc, graySrc, masked, mask);
const grnMask = new cv.Mat(); toFree.push(grnMask);
inRangeScalar(hsv, GRN_LOW, GRN_UP, grnMask);
cv.morphologyEx(grnMask, grnMask, cv.MORPH_OPEN, kernel3);
cv.morphologyEx(grnMask, grnMask, cv.MORPH_CLOSE, kernel3);
const blur = new cv.Mat(); toFree.push(blur);
cv.GaussianBlur(masked, blur, new cv.Size(0, 0), 3.0, 3.0, cv.BORDER_DEFAULT);
// ---- FAST PATH: 直接用掩码轮廓 -> minAreaRect (无需灰度+边缘)----
let redProps = rectFromMaskFast(redMask);
let grnProps = rectFromMaskFast(grnMask);
const high = new cv.Mat(); toFree.push(high);
// unsharp: (1+alpha)*I + (-alpha)*blur
cv.addWeighted(masked, 3.0 /*1+2*/, blur, -2.0, 0, high);
// 只有必要时才懒构灰度图 + ROI Fallback
let gray: cv.Mat | null = null;
if (!redProps || !grnProps) {
gray = new cv.Mat(); toFree.push(gray);
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
if (!redProps) redProps = rectFromMaskWithROI(gray, redMask, kernel3);
if (!grnProps) grnProps = rectFromMaskWithROI(gray, grnMask, kernel3);
}
const edges = new cv.Mat(); toFree.push(edges);
cv.Canny(high, edges, 40, 120, 3, true);
const edgesD = new cv.Mat(); toFree.push(edgesD);
cv.dilate(edges, edgesD, kernel3);
const contours = new cv.MatVector(); const hierarchy = new cv.Mat();
cv.findContours(edgesD, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
let bestArea = 0;
let bestRect: any = null; // rotated rect (object)
for (let i = 0; i < contours.size(); i++) {
const cnt = contours.get(i);
const area = cv.contourArea(cnt);
if (area > 300 && area > bestArea) {
bestArea = area;
bestRect = cv.minAreaRect(cnt); // {center:{x,y}, size:{width,height}, angle:number}
}
cnt.delete();
}
contours.delete(); hierarchy.delete();
if (!bestRect) return null;
const box = rotatedRectToPoints(bestRect); // Float32Array[2] * 4
const props = rectPropsFromBox(box);
return props;
};
const redProps = detectLargestBox(gray, redMask);
const grnProps = detectLargestBox(gray, grnMask);
// ---- build blue rectangle from rules ----
// ---- build blue rectangle from rules (保持原逻辑) ----
let blueCenter: Vec2 | null = null;
let blueDir: Vec2 | null = null;
let blueLen: number | null = null;
@@ -124,7 +112,6 @@ export function processImage(imgData: ImageData): ProcessImageResult {
const rBox = orderBoxClockwise(redProps.box);
const gCenter = grnProps.center;
// midpoints of 4 edges (typed as Box4 to avoid undefined indexes)
const [r0, r1, r2, r3] = rBox;
const mids: Box4 = [
midpoint(r0, r1),
@@ -133,7 +120,6 @@ export function processImage(imgData: ImageData): ProcessImageResult {
midpoint(r3, r0)
];
// farthest midpoint from green center
let farIdx = 0, maxD2 = -1;
mids.forEach((m, i) => {
const dx = m[0] - gCenter[0];
@@ -145,9 +131,9 @@ export function processImage(imgData: ImageData): ProcessImageResult {
const startPt: Vec2 = farIdx === 0 ? m0 : farIdx === 1 ? m1 : farIdx === 2 ? m2 : m3;
// long direction of red; point toward projection to green center
const baseDir = redProps.dirLong;
let dir: Vec2 = [baseDir[0], baseDir[1]];
const toG: Vec2 = v2(gCenter[0] - startPt[0], gCenter[1] - startPt[1]);
const baseDir = redProps.dirLong;
let dir: Vec2 = [baseDir[0], baseDir[1]];
const toG: Vec2 = v2(gCenter[0] - startPt[0], gCenter[1] - startPt[1]);
if (dot(toG, dir) < 0) dir = scale(dir, -1);
const proj = dot(toG, dir); // scalar projection
@@ -157,36 +143,131 @@ export function processImage(imgData: ImageData): ProcessImageResult {
const bb = blueBoxFromRay(startPt, dir, blueLen, blueW);
blueCenter = bb.center;
blueDir = dir;
// const blueBox = bb.box; // (available if you later want corners)
}
}
// ---- assemble metrics ----
const res: ProcessImageResult = {
redRectCenterX: redProps ? redProps.center[0] : null,
redRectCenterY: redProps ? redProps.center[1] : null,
greenRectCenterX: grnProps ? grnProps.center[0] : null,
greenRectCenterY: grnProps ? grnProps.center[1] : null,
blueRectCenterX: blueCenter ? blueCenter[0] : null,
blueRectCenterY: blueCenter ? blueCenter[1] : null,
blueRectCenterX: blueCenter ? blueCenter[0] : null,
blueRectCenterY: blueCenter ? blueCenter[1] : null,
redRectRotation: redProps ? angleDegFromDir(redProps.dirLong) : null,
greenRectRotation: grnProps ? angleDegFromDir(grnProps.dirLong) : null,
blueRectRotation: blueDir ? angleDegFromDir(blueDir) : null,
greenRectLength: grnProps ? grnProps.longLen : null,
blueRectLength: blueLen,
greenRectWidth: grnProps ? grnProps.shortLen : null,
blueRectWidth: blueW
blueRectRotation: blueDir ? angleDegFromDir(blueDir) : null,
redRectLength: redProps ? redProps.longLen : null,
greenRectLength: grnProps ? grnProps.longLen : null,
blueRectLength: blueLen,
redRectWidth: redProps ? redProps.shortLen : null,
greenRectWidth: grnProps ? grnProps.shortLen : null,
blueRectWidth: blueW
};
// ---- cleanup ----
try {
// nothing else
} finally {
kernel3.delete();
toFree.forEach(m => { try { m.delete(); } catch { /* noop */ } });
// kernel3 是缓存的,不要 delete
}
return res;
}
/* ----------------------- helpers ----------------------- */
/* ----------------------- FAST helpers ----------------------- */
// 1) 超快路径:直接在二值掩码上找最大轮廓 -> minAreaRect
function rectFromMaskFast(mask: cv.Mat): RectProps | null {
// 空掩码直接早停
if (cv.countNonZero(mask) === 0) return null;
const contours = new cv.MatVector(); const hierarchy = new cv.Mat();
cv.findContours(mask, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
let bestIdx = -1, bestArea = 0;
for (let i = 0; i < contours.size(); i++) {
const cnt = contours.get(i);
const a = cv.contourArea(cnt);
if (a > 300 && a > bestArea) { bestArea = a; bestIdx = i; }
}
let props: RectProps | null = null;
if (bestIdx >= 0) {
const cnt = contours.get(bestIdx);
const rr = cv.minAreaRect(cnt); // {center:{x,y}, size:{width,height}, angle:number}
const box = rotatedRectToPoints(rr);
props = rectPropsFromBox(box as Box4);
}
contours.delete(); hierarchy.delete();
return props;
}
// 2) ROI 回退:仅在掩码最大连通区域的外接矩形(略扩展)内做一次精细找边
function rectFromMaskWithROI(gray: cv.Mat, mask: cv.Mat, kernel3: cv.Mat): RectProps | null {
if (cv.countNonZero(mask) === 0) return null;
// 先取最大轮廓的 axis-aligned bbox 作为 ROI
const contours = new cv.MatVector(); const hierarchy = new cv.Mat();
cv.findContours(mask, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
let bestIdx = -1, bestArea = 0;
let bestRect: cv.Rect | null = null;
for (let i = 0; i < contours.size(); i++) {
const cnt = contours.get(i);
const a = cv.contourArea(cnt);
if (a > 300 && a > bestArea) {
bestArea = a; bestIdx = i;
bestRect = cv.boundingRect(cnt);
}
}
if (bestIdx < 0 || !bestRect) { contours.delete(); hierarchy.delete(); return null; }
// 扩边一些 margin,限制 ROI,避免整幅操作
const margin = 8;
const rx = Math.max(0, bestRect.x - margin);
const ry = Math.max(0, bestRect.y - margin);
const rw = Math.min(gray.cols - rx, bestRect.width + margin * 2);
const rh = Math.min(gray.rows - ry, bestRect.height + margin * 2);
const roi = new cv.Rect(rx, ry, rw, rh);
const gRoi = gray.roi(roi);
const edges = new cv.Mat(); const blur = new cv.Mat(); const edgesD = new cv.Mat();
try {
// 更轻量:只做一次高斯 + Canny + dilation
cv.GaussianBlur(gRoi, blur, new cv.Size(3, 3), 0, 0, cv.BORDER_DEFAULT);
cv.Canny(blur, edges, 40, 120, 3, true);
cv.dilate(edges, edgesD, kernel3);
const cs = new cv.MatVector(); const h = new cv.Mat();
cv.findContours(edgesD, cs, h, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
let bestA = 0, rrAny: any = null;
for (let i = 0; i < cs.size(); i++) {
const c = cs.get(i);
const a = cv.contourArea(c);
if (a > 300 && a > bestA) { bestA = a; rrAny = cv.minAreaRect(c); }
}
cs.delete(); h.delete();
if (!rrAny) return null;
// 注意 ROI 偏移,坐标回到全局
const rrGlobal = {
center: { x: rrAny.center.x + rx, y: rrAny.center.y + ry },
size: rrAny.size,
angle: rrAny.angle
};
const box = rotatedRectToPoints(rrGlobal);
return rectPropsFromBox(box as Box4);
} finally {
gRoi.delete(); edges.delete(); blur.delete(); edgesD.delete();
contours.delete(); hierarchy.delete();
}
}
/* ----------------------- geometry/helpers (原有+少量小函数) ----------------------- */
function v2(x: number, y: number): Vec2 { return [x, y]; }
function f32(arr: readonly number[]): Vec2 { return [arr[0] ?? 0, arr[1] ?? 0]; }
@@ -195,11 +276,10 @@ function dot(a: Vec2, b: Vec2): number { return a[0] * b[0] + a[1] * b[1]; }
function norm(a: Vec2): number { return Math.hypot(a[0], a[1]); }
function normalize(a: Vec2): Vec2 {
const n = norm(a) + 1e-9;
return f32([a[0]/n, a[1]/n]);
return f32([a[0] / n, a[1] / n]);
}
function scale(a: Vec2, k: number): Vec2 { return f32([a[0]*k, a[1]*k]); }
function add(a: Vec2, b: Vec2): Vec2 { return f32([a[0]+b[0], a[1]+b[1]]); }
function sub(a: Vec2, b: Vec2): Vec2 { return f32([a[0]-b[0], a[1]-b[1]]); }
function scale(a: Vec2, k: number): Vec2 { return f32([a[0] * k, a[1] * k]); }
function add(a: Vec2, b: Vec2): Vec2 { return f32([a[0] + b[0], a[1] + b[1]]); }
function midpoint(a: Vec2, b: Vec2): Vec2 {
return f32([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]);
@@ -216,7 +296,6 @@ function orderBoxClockwise(box: Box4): Box4 {
const withAng = box.map(p => ({ p, a: Math.atan2(p[1] - c[1], p[0] - c[0]) }));
withAng.sort((u, v) => u.a - v.a); // -pi..pi
const sorted = withAng.map(o => o.p) as Vec2[];
// ensure Box4 length 4 (non-null assertion since input is Box4)
return [sorted[0]!, sorted[1]!, sorted[2]!, sorted[3]!];
}
@@ -251,7 +330,7 @@ function rectPropsFromBox(boxIn: Box4): RectProps {
case 2: a = p2; b = p3; break;
case 3: a = p3; b = p0; break;
}
const dirLong = normalize(sub(b, a));
const dirLong = normalize([(b[0] - a[0]), (b[1] - a[1])]);
return { box, center, longLen, shortLen, dirLong };
}
@@ -263,30 +342,30 @@ function rotatedRectToPoints(rr: { center: { x: number, y: number }, size: { wid
const hw = w * 0.5, hh = h * 0.5;
const local: [number, number][] = [
[-hw, -hh], [ hw, -hh],
[ hw, hh], [-hw, hh]
[-hw, -hh], [hw, -hh],
[hw, hh], [-hw, hh]
];
const pts: Vec2[] = [];
for (const [lx, ly] of local) {
const x = cx + lx * cosA - ly * sinA;
const y = cy + lx * sinA + ly * cosA;
pts.push(f32([x, y]));
pts.push([x, y]);
}
return [pts[0]!, pts[1]!, pts[2]!, pts[3]!];
}
function blueBoxFromRay(startPt: Vec2, dirUnitIn: Vec2, length: number, width: number): { box: Box4, center: Vec2 } {
const dir = normalize(dirUnitIn);
const perp = f32([-dir[1], dir[0]]);
const perp: Vec2 = [-dir[1], dir[0]];
const c = add(startPt, scale(dir, length * 0.5));
const halfL = scale(dir, length * 0.5);
const halfW = scale(perp, width * 0.5);
const p0 = sub(sub(c, halfL), halfW);
const p1 = add(sub(c, halfW), halfL);
const p2 = add(add(c, halfL), halfW);
const p3 = add(sub(c, halfW), scale(halfL, -1));
const p0 = [c[0] - halfL[0] - halfW[0], c[1] - halfL[1] - halfW[1]] as Vec2;
const p1 = [c[0] + halfL[0] - halfW[0], c[1] + halfL[1] - halfW[1]] as Vec2;
const p2 = [c[0] + halfL[0] + halfW[0], c[1] + halfL[1] + halfW[1]] as Vec2;
const p3 = [c[0] - halfL[0] + halfW[0], c[1] - halfL[1] + halfW[1]] as Vec2;
return { box: [p0, p1, p2, p3], center: c };
}
}