first commit

This commit is contained in:
feie9454 2025-09-19 16:37:37 +08:00
commit fa27a24bb6
18 changed files with 1068 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

210
bun.lock Normal file
View File

@ -0,0 +1,210 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "chaos-tracker-online",
"dependencies": {
"@techstark/opencv-js": "^4.10.0-release.1",
"vue": "^3.5.21",
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.8.3",
"vite": "^7.1.6",
"vue-tsc": "^3.0.7",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.29", "", {}, "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.50.2", "", { "os": "android", "cpu": "arm" }, "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.50.2", "", { "os": "android", "cpu": "arm64" }, "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.50.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.50.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.50.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.50.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.50.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.50.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.50.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.50.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.50.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.50.2", "", { "os": "linux", "cpu": "none" }, "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.50.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.50.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.50.2", "", { "os": "linux", "cpu": "x64" }, "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.50.2", "", { "os": "none", "cpu": "arm64" }, "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.50.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.50.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.50.2", "", { "os": "win32", "cpu": "x64" }, "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA=="],
"@techstark/opencv-js": ["@techstark/opencv-js@4.10.0-release.1", "", {}, "sha512-S4XELidRiQeA0q1s9VQLo540wCxUo24r1O4C+LqZ6llX+sPCXvZCPv3Ice8dEIr0uavyZ8YZeKXSBdDgMXSXjw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.29" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.25" } }, "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw=="],
"@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="],
"@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="],
"@volar/typescript": ["@volar/typescript@2.4.23", "", { "dependencies": { "@volar/language-core": "2.4.23", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.21", "", { "dependencies": { "@babel/parser": "^7.28.3", "@vue/shared": "3.5.21", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.21", "", { "dependencies": { "@vue/compiler-core": "3.5.21", "@vue/shared": "3.5.21" } }, "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.21", "", { "dependencies": { "@babel/parser": "^7.28.3", "@vue/compiler-core": "3.5.21", "@vue/compiler-dom": "3.5.21", "@vue/compiler-ssr": "3.5.21", "@vue/shared": "3.5.21", "estree-walker": "^2.0.2", "magic-string": "^0.30.18", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.21", "", { "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/shared": "3.5.21" } }, "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w=="],
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
"@vue/language-core": ["@vue/language-core@3.0.7", "", { "dependencies": { "@volar/language-core": "2.4.23", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^2.0.5", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-0sqqyqJ0Gn33JH3TdIsZLCZZ8Gr4kwlg8iYOnOrDDkJKSjFurlQY/bEFQx5zs7SX2C/bjMkmPYq/NiyY1fTOkw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.21", "", { "dependencies": { "@vue/shared": "3.5.21" } }, "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.21", "", { "dependencies": { "@vue/reactivity": "3.5.21", "@vue/shared": "3.5.21" } }, "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.21", "", { "dependencies": { "@vue/reactivity": "3.5.21", "@vue/runtime-core": "3.5.21", "@vue/shared": "3.5.21", "csstype": "^3.1.3" } }, "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.21", "", { "dependencies": { "@vue/compiler-ssr": "3.5.21", "@vue/shared": "3.5.21" }, "peerDependencies": { "vue": "3.5.21" } }, "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA=="],
"@vue/shared": ["@vue/shared@3.5.21", "", {}, "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw=="],
"@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="],
"alien-signals": ["alien-signals@2.0.7", "", {}, "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"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=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"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=="],
"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=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"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=="],
"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=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"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=="],
"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=="],
"vue-tsc": ["vue-tsc@3.0.7", "", { "dependencies": { "@volar/typescript": "2.4.23", "@vue/language-core": "3.0.7" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-BSMmW8GGEgHykrv7mRk6zfTdK+tw4MBZY/x6fFa7IkdXK3s/8hQRacPjG9/8YKFDIWGhBocwi6PlkQQ/93OgIQ=="],
}
}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "chaos-tracker-online",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@techstark/opencv-js": "^4.10.0-release.1",
"vue": "^3.5.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.8.3",
"vite": "^7.1.6",
"vue-tsc": "^3.0.7"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

396
src/App.vue Normal file
View File

@ -0,0 +1,396 @@
<template>
<div class="wrap">
<div class="stage">
<!-- 隐藏视频元素仅用于抓帧 -->
<video
ref="videoEl"
playsinline
muted
autoplay
class="hidden-video"
></video>
<!-- 可见的绘制画布 -->
<canvas ref="canvasEl" class="canvas"></canvas>
<!-- HUD 面板 -->
<div class="hud">
<div class="row">
<button class="btn" @click="toggle()">
{{ running ? '停止' : '启动' }}
</button>
<button class="btn" @click="switchFacing()">
切换摄像头{{ facingModeLabel }}
</button>
</div>
<div class="row small">
<span>FPS: {{ fps.toFixed(1) }}</span>
<span v-if="err" class="err">错误{{ err }}</span>
</div>
<div class="metrics">
<div class="group">
<h4>Green</h4>
<div>x: {{ fmt(metrics.greenRectCenterX) }}, y: {{ fmt(metrics.greenRectCenterY) }}</div>
<div>θ: {{ fmt(metrics.greenRectRotation) }}°</div>
<div>L×W: {{ fmt(metrics.greenRectLength) }} × {{ fmt(metrics.greenRectWidth) }}</div>
</div>
<div class="group">
<h4>Blue</h4>
<div>x: {{ fmt(metrics.blueRectCenterX) }}, y: {{ fmt(metrics.blueRectCenterY) }}</div>
<div>θ: {{ fmt(metrics.blueRectRotation) }}°</div>
<div>L×W: {{ fmt(metrics.blueRectLength) }} × {{ fmt(metrics.blueRectWidth) }}</div>
</div>
</div>
</div>
</div>
<p class="hint">
提示蓝色矩形基于红色/绿色关系构造若未检测到所需目标相关字段会为空null画面也不会绘制对应图形
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, reactive, computed } from 'vue'
import cv from '@techstark/opencv-js'
import { processImage } from './lib/processImage' //
type Metrics = ReturnType<typeof processImage>
// /
const videoEl = ref<HTMLVideoElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
//
const running = ref(false)
const fps = ref(0)
const err = ref<string | null>(null)
// OpenCV WASM
const cvReady = ref(false)
const metrics = reactive<Metrics>({
greenRectCenterX: null,
greenRectCenterY: null,
blueRectCenterX: null,
blueRectCenterY: null,
greenRectRotation: null,
blueRectRotation: null,
greenRectLength: null,
blueRectLength: null,
greenRectWidth: null,
blueRectWidth: null
})
//
const facingMode = ref<'environment' | 'user'>('environment')
const facingModeLabel = computed(() =>
facingMode.value === 'environment' ? '后置' : '前置'
)
// RAF
let rafId = 0
let lastT = 0
//
let stream: MediaStream | null = null
function fmt(n: number | null) {
return n == null ? '—' : n.toFixed(1)
}
function drawRotRect(
g: CanvasRenderingContext2D,
cx: number,
cy: number,
longLen: number,
shortLen: number,
angleDeg: number,
color: string
) {
const rad = (angleDeg * Math.PI) / 180
const dir = { x: Math.cos(rad), y: Math.sin(rad) }
const perp = { x: -dir.y, y: dir.x }
const hl = longLen / 2
const hw = shortLen / 2
const p0 = { x: cx - dir.x * hl - perp.x * hw, y: cy - dir.y * hl - perp.y * hw }
const p1 = { x: cx + dir.x * hl - perp.x * hw, y: cy + dir.y * hl - perp.y * hw }
const p2 = { x: cx + dir.x * hl + perp.x * hw, y: cy + dir.y * hl + perp.y * hw }
const p3 = { x: cx - dir.x * hl + perp.x * hw, y: cy - dir.y * hl + perp.y * hw }
g.save()
g.lineWidth = 2
g.strokeStyle = color
g.beginPath()
g.moveTo(p0.x, p0.y)
g.lineTo(p1.x, p1.y)
g.lineTo(p2.x, p2.y)
g.lineTo(p3.x, p3.y)
g.closePath()
g.stroke()
// + 线
g.fillStyle = color
g.beginPath()
g.arc(cx, cy, 4, 0, Math.PI * 2)
g.fill()
g.beginPath()
g.moveTo(cx, cy)
g.lineTo(cx + dir.x * (hl * 0.8), cy + dir.y * (hl * 0.8))
g.stroke()
g.restore()
}
function clearCanvas(g: CanvasRenderingContext2D, w: number, h: number) {
g.clearRect(0, 0, w, h)
}
function resizeCanvasToVideo(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
const vw = video.videoWidth || 640
const vh = video.videoHeight || 480
//
canvas.width = vw
canvas.height = vh
// CSS
canvas.style.width = '100%'
canvas.style.height = 'auto'
}
function copyMetrics(next: Metrics) {
;(Object.keys(metrics) as (keyof Metrics)[]).forEach((k) => {
metrics[k] = next[k] as any
})
}
async function ensureCVReady(): Promise<void> {
// @techstark/opencv-js WASM getBuildInformation
try {
if ((cv as any)?.getBuildInformation) {
cvReady.value = true
return
}
await new Promise<void>((resolve) => {
;(cv as any).onRuntimeInitialized = () => {
cvReady.value = true
resolve()
}
})
} catch {
// cvReady
}
}
async function startStream() {
try {
err.value = null
//
await stopStream()
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facingMode.value }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
})
if (!videoEl.value) return
videoEl.value.srcObject = stream
// Safari/
await new Promise<void>((res) => {
if (!videoEl.value) return res()
const v = videoEl.value
if (v.readyState >= 1) return res()
const onMeta = () => {
v.removeEventListener('loadedmetadata', onMeta)
res()
}
v.addEventListener('loadedmetadata', onMeta)
})
try {
await videoEl.value.play()
} catch {
//
}
if (!canvasEl.value || !videoEl.value) return
ctx = canvasEl.value.getContext('2d')
if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
resizeCanvasToVideo(videoEl.value, canvasEl.value)
running.value = true
lastT = performance.now()
loop()
} catch (e: any) {
err.value = e?.message ?? String(e)
running.value = false
}
}
async function stopStream() {
cancelAnimationFrame(rafId)
rafId = 0
if (stream) {
stream.getTracks().forEach((t) => t.stop())
stream = null
}
running.value = false
}
function loop() {
if (!running.value || !videoEl.value || !canvasEl.value || !ctx) return
const now = performance.now()
const dt = now - lastT
lastT = now
fps.value = 1000 / (dt || 16.7)
//
const vw = canvasEl.value.width
const vh = canvasEl.value.height
ctx.drawImage(videoEl.value, 0, 0, vw, vh)
try {
//
const imgData = ctx.getImageData(0, 0, vw, vh)
if (cvReady.value) {
const res = processImage(imgData)
copyMetrics(res)
}
//
if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null && metrics.greenRectLength && metrics.greenRectWidth && metrics.greenRectRotation != null) {
drawRotRect(
ctx,
metrics.greenRectCenterX,
metrics.greenRectCenterY,
metrics.greenRectLength,
metrics.greenRectWidth,
metrics.greenRectRotation,
'#00ff6a'
)
}
if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null && metrics.blueRectLength && metrics.blueRectWidth && metrics.blueRectRotation != null) {
drawRotRect(
ctx,
metrics.blueRectCenterX,
metrics.blueRectCenterY,
metrics.blueRectLength,
metrics.blueRectWidth,
metrics.blueRectRotation,
'#2e6bff'
)
}
} catch (e: any) {
// HUD
err.value = e?.message ?? String(e)
}
rafId = requestAnimationFrame(loop)
}
async function toggle() {
if (running.value) {
await stopStream()
} else {
// CV
ensureCVReady()
await startStream()
}
}
async function switchFacing() {
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
if (running.value) {
await startStream()
}
}
onMounted(async () => {
// CV
ensureCVReady()
//
await startStream()
})
onBeforeUnmount(async () => {
await stopStream()
ctx = null
})
</script>
<style scoped>
.wrap {
display: grid;
gap: 12px;
}
.stage {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #0b0f19;
border: 1px solid rgba(255,255,255,0.08);
}
.hidden-video {
display: none; /* 仅用于抓帧即可 */
}
.canvas {
display: block;
width: 100%;
height: auto;
image-rendering: crisp-edges;
}
.hud {
position: absolute;
top: 12px;
right: 12px;
display: grid;
gap: 8px;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(6px);
color: #e9eef7;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
min-width: 220px;
}
.hud .row {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.hud .row.small {
font-size: 12px;
opacity: 0.9;
justify-content: flex-start;
gap: 16px;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
font-size: 12px;
line-height: 1.35;
}
.metrics .group h4 {
margin: 0 0 4px 0;
font-size: 12px;
font-weight: 700;
opacity: 0.9;
}
.err { color: #ffb4b4; }
.btn {
appearance: none;
background: #1a5cff;
color: white;
border: none;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.btn:hover { filter: brightness(1.05); }
.hint {
color: #5f6b7b;
font-size: 12px;
margin: 0;
}
</style>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

292
src/lib/processImage.ts Normal file
View File

@ -0,0 +1,292 @@
// tsconfig tip: set "skipLibCheck": true if your bundler yells about opencv.js types.
import cv from "@techstark/opencv-js";
export type ProcessImageResult = {
greenRectCenterX: number | null;
greenRectCenterY: number | null;
blueRectCenterX: number | null;
blueRectCenterY: number | null;
greenRectRotation: number | null; // degrees in [0,180)
blueRectRotation: number | null; // degrees in [0,180)
greenRectLength: number | null; // long side
blueRectLength: number | null; // long side (ray length)
greenRectWidth: number | null; // short side
blueRectWidth: number | null; // short side = red short side
};
// Geometry helpers use fixed-length tuples to satisfy TS with noUncheckedIndexedAccess
type Vec2 = [number, number];
type Box4 = [Vec2, Vec2, Vec2, Vec2];
type RectProps = {
box: Box4; // 4 points [x,y]
center: Vec2; // [x,y]
longLen: number;
shortLen: number;
dirLong: Vec2; // unit vector along long edge
};
export function processImage(imgData: ImageData): ProcessImageResult {
const src = cv.matFromImageData(imgData); // CV_8UC4 RGBA
const toFree: cv.Mat[] = [src];
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(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 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;
})();
// ---- 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 blur = new cv.Mat(); toFree.push(blur);
cv.GaussianBlur(masked, blur, new cv.Size(0, 0), 3.0, 3.0, cv.BORDER_DEFAULT);
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}
}
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 ----
let blueCenter: Vec2 | null = null;
let blueDir: Vec2 | null = null;
let blueLen: number | null = null;
let blueW: number | null = null;
if (redProps && grnProps) {
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),
midpoint(r1, r2),
midpoint(r2, r3),
midpoint(r3, r0)
];
// farthest midpoint from green center
let farIdx = 0, maxD2 = -1;
mids.forEach((m, i) => {
const dx = m[0] - gCenter[0];
const dy = m[1] - gCenter[1];
const d2 = dx * dx + dy * dy;
if (d2 > maxD2) { maxD2 = d2; farIdx = i; }
});
const [m0, m1, m2, m3] = mids;
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]);
if (dot(toG, dir) < 0) dir = scale(dir, -1);
const proj = dot(toG, dir); // scalar projection
if (proj > 1.0) {
blueLen = proj;
blueW = redProps.shortLen;
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 = {
greenRectCenterX: grnProps ? grnProps.center[0] : null,
greenRectCenterY: grnProps ? grnProps.center[1] : null,
blueRectCenterX: blueCenter ? blueCenter[0] : null,
blueRectCenterY: blueCenter ? blueCenter[1] : 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
};
// ---- cleanup ----
try {
// nothing else
} finally {
kernel3.delete();
toFree.forEach(m => { try { m.delete(); } catch { /* noop */ } });
}
return res;
}
/* ----------------------- helpers ----------------------- */
function v2(x: number, y: number): Vec2 { return [x, y]; }
function f32(arr: readonly number[]): Vec2 { return [arr[0] ?? 0, arr[1] ?? 0]; }
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]);
}
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]);
}
function angleDegFromDir(d: Vec2): number {
let ang = Math.atan2(d[1], d[0]) * 180 / Math.PI;
ang = (ang + 360) % 180; // [0,180)
return ang;
}
function orderBoxClockwise(box: Box4): Box4 {
const c = centroid(box);
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]!];
}
function centroid(pts: ReadonlyArray<Vec2>): Vec2 {
let x = 0, y = 0;
for (const p of pts) { x += p[0]; y += p[1]; }
return f32([x / pts.length, y / pts.length]);
}
function edgeLengths(box: Box4): number[] {
const [p0, p1, p2, p3] = box;
return [
Math.hypot(p1[0] - p0[0], p1[1] - p0[1]),
Math.hypot(p2[0] - p1[0], p2[1] - p1[1]),
Math.hypot(p3[0] - p2[0], p3[1] - p2[1]),
Math.hypot(p0[0] - p3[0], p0[1] - p3[1])
];
}
function rectPropsFromBox(boxIn: Box4): RectProps {
const box = orderBoxClockwise(boxIn);
const center = centroid(box);
const L = edgeLengths(box);
const longLen = Math.max(...L);
const shortLen = Math.min(...L);
const idx = L.indexOf(longLen) as 0 | 1 | 2 | 3;
const [p0, p1, p2, p3] = box;
let a: Vec2, b: Vec2;
switch (idx) {
case 0: a = p0; b = p1; break;
case 1: a = p1; b = p2; break;
case 2: a = p2; b = p3; break;
case 3: a = p3; b = p0; break;
}
const dirLong = normalize(sub(b, a));
return { box, center, longLen, shortLen, dirLong };
}
function rotatedRectToPoints(rr: { center: { x: number, y: number }, size: { width: number, height: number }, angle: number }): Box4 {
const cx = rr.center.x, cy = rr.center.y;
const w = rr.size.width, h = rr.size.height;
const ang = rr.angle * Math.PI / 180.0;
const cosA = Math.cos(ang), sinA = Math.sin(ang);
const hw = w * 0.5, hh = h * 0.5;
const local: [number, number][] = [
[-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]));
}
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 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));
return { box: [p0, p1, p2, p3], center: c };
}

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

0
src/style.css Normal file
View File

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tsconfig.app.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})