SVG与预测
This commit is contained in:
parent
fa27a24bb6
commit
0619bafbf9
53
bun.lock
53
bun.lock
@ -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=="],
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
967
src/App.vue
967
src/App.vue
File diff suppressed because it is too large
Load Diff
@ -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]; }
|
||||
@ -199,7 +280,6 @@ function normalize(a: Vec2): Vec2 {
|
||||
}
|
||||
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 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 };
|
||||
}
|
||||
|
||||
@ -271,22 +350,22 @@ function rotatedRectToPoints(rr: { center: { x: number, y: number }, size: { wid
|
||||
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 };
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
body {
|
||||
background: #0b0f19;
|
||||
}
|
||||
@ -5,8 +5,6 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
@ -15,8 +15,6 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user