SVG与预测

This commit is contained in:
feie9456 2025-09-20 08:11:57 +08:00
parent fa27a24bb6
commit 0619bafbf9
8 changed files with 1087 additions and 255 deletions

View File

@ -12,6 +12,7 @@
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.8.3",
"vite": "^7.1.6",
"vite-plugin-mkcert": "^1.17.8",
"vue-tsc": "^3.0.7",
},
},
@ -161,24 +162,72 @@
"alien-signals": ["alien-signals@2.0.7", "", {}, "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -191,6 +240,8 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"rollup": ["rollup@4.50.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.50.2", "@rollup/rollup-android-arm64": "4.50.2", "@rollup/rollup-darwin-arm64": "4.50.2", "@rollup/rollup-darwin-x64": "4.50.2", "@rollup/rollup-freebsd-arm64": "4.50.2", "@rollup/rollup-freebsd-x64": "4.50.2", "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", "@rollup/rollup-linux-arm-musleabihf": "4.50.2", "@rollup/rollup-linux-arm64-gnu": "4.50.2", "@rollup/rollup-linux-arm64-musl": "4.50.2", "@rollup/rollup-linux-loong64-gnu": "4.50.2", "@rollup/rollup-linux-ppc64-gnu": "4.50.2", "@rollup/rollup-linux-riscv64-gnu": "4.50.2", "@rollup/rollup-linux-riscv64-musl": "4.50.2", "@rollup/rollup-linux-s390x-gnu": "4.50.2", "@rollup/rollup-linux-x64-gnu": "4.50.2", "@rollup/rollup-linux-x64-musl": "4.50.2", "@rollup/rollup-openharmony-arm64": "4.50.2", "@rollup/rollup-win32-arm64-msvc": "4.50.2", "@rollup/rollup-win32-ia32-msvc": "4.50.2", "@rollup/rollup-win32-x64-msvc": "4.50.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -201,6 +252,8 @@
"vite": ["vite@7.1.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ=="],
"vite-plugin-mkcert": ["vite-plugin-mkcert@1.17.8", "", { "dependencies": { "axios": "^1.8.3", "debug": "^4.4.0", "picocolors": "^1.1.1" }, "peerDependencies": { "vite": ">=3" } }, "sha512-S+4tNEyGqdZQ3RLAG54ETeO2qyURHWrVjUWKYikLAbmhh/iJ+36gDEja4OWwFyXNuvyXcZwNt5TZZR9itPeG5Q=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.21", "", { "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", "@vue/runtime-dom": "3.5.21", "@vue/server-renderer": "3.5.21", "@vue/shared": "3.5.21" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA=="],

View File

@ -17,6 +17,7 @@
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.8.3",
"vite": "^7.1.6",
"vite-plugin-mkcert": "^1.17.8",
"vue-tsc": "^3.0.7"
}
}

File diff suppressed because it is too large Load Diff

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);
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}
// 只有必要时才懒构灰度图 + 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);
}
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];
@ -157,20 +143,23 @@ 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,
redRectRotation: redProps ? angleDegFromDir(redProps.dirLong) : null,
greenRectRotation: grnProps ? angleDegFromDir(grnProps.dirLong) : null,
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
};
@ -179,14 +168,106 @@ export function processImage(imgData: ImageData): ProcessImageResult {
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 };
}

View File

@ -0,0 +1,3 @@
body {
background: #0b0f19;
}

View File

@ -5,8 +5,6 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true

View File

@ -15,8 +15,6 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true

View File

@ -1,7 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import mkcert from 'vite-plugin-mkcert'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue(), mkcert()],
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})