0909m
This commit is contained in:
parent
3e2f695c08
commit
6aacd3208d
5
bun.lock
5
bun.lock
@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@techstark/opencv-js": "^4.10.0-release.1",
|
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
@ -145,6 +146,8 @@
|
|||||||
|
|
||||||
"@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/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
|
||||||
|
|
||||||
|
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||||
|
|
||||||
"@vue/language-core": ["@vue/language-core@3.0.6", "", { "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-e2RRzYWm+qGm8apUHW1wA5RQxzNhkqbbKdbKhiDUcmMrNAZGyM8aTiL3UrTqkaFI5s7wJRGGrp4u3jgusuBp2A=="],
|
"@vue/language-core": ["@vue/language-core@3.0.6", "", { "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-e2RRzYWm+qGm8apUHW1wA5RQxzNhkqbbKdbKhiDUcmMrNAZGyM8aTiL3UrTqkaFI5s7wJRGGrp4u3jgusuBp2A=="],
|
||||||
|
|
||||||
"@vue/reactivity": ["@vue/reactivity@3.5.21", "", { "dependencies": { "@vue/shared": "3.5.21" } }, "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA=="],
|
"@vue/reactivity": ["@vue/reactivity@3.5.21", "", { "dependencies": { "@vue/shared": "3.5.21" } }, "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA=="],
|
||||||
@ -205,6 +208,8 @@
|
|||||||
|
|
||||||
"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": ["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-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
||||||
|
|
||||||
"vue-tsc": ["vue-tsc@3.0.6", "", { "dependencies": { "@volar/typescript": "2.4.23", "@vue/language-core": "3.0.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-Tbs8Whd43R2e2nxez4WXPvvdjGbW24rOSgRhLOHXzWiT4pcP4G7KeWh0YCn18rF4bVwv7tggLLZ6MJnO6jXPBg=="],
|
"vue-tsc": ["vue-tsc@3.0.6", "", { "dependencies": { "@volar/typescript": "2.4.23", "@vue/language-core": "3.0.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-Tbs8Whd43R2e2nxez4WXPvvdjGbW24rOSgRhLOHXzWiT4pcP4G7KeWh0YCn18rF4bVwv7tggLLZ6MJnO6jXPBg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@techstark/opencv-js": "^4.10.0-release.1",
|
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||||
"vue": "^3.5.18"
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
441
src/App.vue
441
src/App.vue
@ -1,440 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref, useTemplateRef, watch, computed, type CSSProperties } from 'vue';
|
|
||||||
import { extractFrameFromVideo } from './vfex';
|
|
||||||
import { YellowBallTracker } from './core';
|
|
||||||
import cv from '@techstark/opencv-js';
|
|
||||||
|
|
||||||
const frames = ref<VideoFrame[]>([]);
|
|
||||||
|
|
||||||
const rawCanvas = useTemplateRef('raw-canvas');
|
|
||||||
const processedCanvas = useTemplateRef('processed-canvas');
|
|
||||||
const decodeProgress = ref(0);
|
|
||||||
const trackProgress = ref(0);
|
|
||||||
const predictProgress = ref(0);
|
|
||||||
|
|
||||||
const showActual = ref(false);
|
|
||||||
|
|
||||||
let rawCtx: CanvasRenderingContext2D;
|
|
||||||
let processedCtx: CanvasRenderingContext2D;
|
|
||||||
onMounted(() => {
|
|
||||||
rawCtx = rawCanvas.value!.getContext('2d', { willReadFrequently: true })!;
|
|
||||||
processedCtx = processedCanvas.value!.getContext('2d')!;
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxFrames = 512;
|
|
||||||
const seqLen = 120;
|
|
||||||
|
|
||||||
|
|
||||||
const centerPosArr = ref<({ x: number; y: number } | null)[]>([]);
|
|
||||||
const predictedPosArr = ref<[number, number][][]>([]);
|
|
||||||
|
|
||||||
// 计算序列的稳定哈希,用作 localStorage 的键
|
|
||||||
const MAX_BATCH = 64;
|
|
||||||
function hashSequence(seq: Array<[number, number]>): string {
|
|
||||||
const s = JSON.stringify(seq);
|
|
||||||
let h = 5381;
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
h = ((h << 5) + h) + s.charCodeAt(i); // h*33 + c
|
|
||||||
h |= 0; // 32-bit
|
|
||||||
}
|
|
||||||
// 仅使用哈希值本身作为键名,避免与其它缓存键冲突的概率
|
|
||||||
return (h >>> 0).toString(16);
|
|
||||||
}
|
|
||||||
async function handleFileChange(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
if (input.files && input.files[0]) {
|
|
||||||
const file = input.files[0];
|
|
||||||
console.log('Selected file:', file);
|
|
||||||
// You can add further processing of the file here
|
|
||||||
frames.value = await extractFrameFromVideo(URL.createObjectURL(file), maxFrames, (p) => decodeProgress.value = p.decode)
|
|
||||||
|
|
||||||
const { codedHeight: h, codedWidth: w } = frames.value[0];
|
|
||||||
|
|
||||||
console.log("解码完成", frames.value.length, h, w);
|
|
||||||
|
|
||||||
|
|
||||||
if (!rawCanvas.value || !processedCanvas.value) return;
|
|
||||||
rawCanvas.value.width = w;
|
|
||||||
rawCanvas.value.height = h;
|
|
||||||
processedCanvas.value.width = w;
|
|
||||||
processedCanvas.value.height = h;
|
|
||||||
|
|
||||||
const tracker = new YellowBallTracker({ lowerYellow: new cv.Scalar(20, 100, 100, 0), upperYellow: new cv.Scalar(40, 255, 255, 255), blurSize: 7, morphKernel: 5, minContourArea: 20, useRoi: true, roiScaleX: 0.25, roiScaleY: 0.35 });
|
|
||||||
tracker.initMats(w, h);
|
|
||||||
|
|
||||||
let lastPos = null
|
|
||||||
|
|
||||||
for (let index = 0; index < frames.value.length; index++) {
|
|
||||||
const cacheKeyname = `${file.name}-${index}`;
|
|
||||||
if (localStorage.getItem(cacheKeyname)) {
|
|
||||||
centerPosArr.value[index] = JSON.parse(localStorage.getItem(cacheKeyname)!) as { x: number; y: number } | null;
|
|
||||||
trackProgress.value++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
selectedIndex.value = index;
|
|
||||||
await reqNextFrame();
|
|
||||||
|
|
||||||
const center = tracker.detectYellowBall(rawCtx.getImageData(0, 0, w, h), lastPos);
|
|
||||||
centerPosArr.value[index] = center;
|
|
||||||
lastPos = center;
|
|
||||||
localStorage.setItem(cacheKeyname, JSON.stringify(center));
|
|
||||||
|
|
||||||
trackProgress.value++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstAvailableIndex = centerPosArr.value.findIndex(c => c !== null);
|
|
||||||
if (firstAvailableIndex === -1) {
|
|
||||||
console.warn("未检测到小球");
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstAvailableIndex + seqLen >= centerPosArr.value.length) {
|
|
||||||
console.warn("视频帧数不足,无法预测");
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 准备任务清单:每个任务 = { seq, key, outIndex }
|
|
||||||
type Task = { seq: Array<[number, number]>; key: string; outIndex: number };
|
|
||||||
const tasks: Task[] = [];
|
|
||||||
|
|
||||||
const lastStart = centerPosArr.value.length - seqLen; // 不包含
|
|
||||||
for (let start = firstAvailableIndex; start < lastStart; start++) {
|
|
||||||
const seq_: Array<[number, number]> = centerPosArr.value
|
|
||||||
.slice(start, start + seqLen)
|
|
||||||
.map(p => p ? [p.x, p.y] as [number, number] : [0, 0]);
|
|
||||||
const key = hashSequence(seq_);
|
|
||||||
const outIndex = start + seqLen; // 预测对应的“当前帧”索引
|
|
||||||
tasks.push({ seq: seq_, key, outIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试本地缓存命中
|
|
||||||
const pending: Task[] = [];
|
|
||||||
for (const t of tasks) {
|
|
||||||
const cached = localStorage.getItem(t.key);
|
|
||||||
if (cached) {
|
|
||||||
try {
|
|
||||||
const pred: [number, number][] = JSON.parse(cached);
|
|
||||||
predictedPosArr.value[t.outIndex] = pred;
|
|
||||||
// 进度按已完成的最大下标推进
|
|
||||||
predictProgress.value = Math.max(predictProgress.value, t.outIndex);
|
|
||||||
} catch { pending.push(t); }
|
|
||||||
} else {
|
|
||||||
pending.push(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分批请求后端,最多 64 条/批
|
|
||||||
for (let i = 0; i < pending.length; i += MAX_BATCH) {
|
|
||||||
const batch = pending.slice(i, i + MAX_BATCH);
|
|
||||||
const body = {
|
|
||||||
sequences: batch.map(b => b.seq),
|
|
||||||
steps: 30,
|
|
||||||
return_angles: true,
|
|
||||||
unwrap_from_last: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await fetch("http://127.0.0.1:8000/predict", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
const json = await resp.json();
|
|
||||||
const preds: [number, number][][] = json.pred_xy || [];
|
|
||||||
|
|
||||||
// 将结果回填到对应位置并写入缓存
|
|
||||||
for (let j = 0; j < batch.length; j++) {
|
|
||||||
const t = batch[j];
|
|
||||||
const pred = preds[j];
|
|
||||||
if (!pred) continue;
|
|
||||||
predictedPosArr.value[t.outIndex] = pred;
|
|
||||||
localStorage.setItem(t.key, JSON.stringify(pred))
|
|
||||||
predictProgress.value = Math.max(predictProgress.value, t.outIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqNextFrame = () => new Promise<void>((resolve) => {
|
|
||||||
requestAnimationFrame(() => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedIndex = ref(0);
|
|
||||||
// ========================
|
|
||||||
// 预测轨迹动画渲染辅助
|
|
||||||
// ========================
|
|
||||||
let animReqId: number | null = null;
|
|
||||||
let animStartTs = 0;
|
|
||||||
|
|
||||||
function cancelPredictionAnim() {
|
|
||||||
if (animReqId !== null) {
|
|
||||||
cancelAnimationFrame(animReqId);
|
|
||||||
animReqId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBaseFrame(index: number) {
|
|
||||||
const frame = frames.value[index];
|
|
||||||
if (!frame) return;
|
|
||||||
// 背景帧
|
|
||||||
rawCtx.drawImage(frame, 0, 0);
|
|
||||||
processedCtx.drawImage(frame, 0, 0);
|
|
||||||
// 检测到的小球标记
|
|
||||||
if (centerPosArr.value[index]) {
|
|
||||||
const { x, y } = centerPosArr.value[index]!;
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.strokeStyle = 'red';
|
|
||||||
processedCtx.lineWidth = 4;
|
|
||||||
processedCtx.arc(x, y, 20, 0, Math.PI * 2);
|
|
||||||
processedCtx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPredictionPartial(preds: Array<[number, number]>, actual: Array<[number, number]>, count: number) {
|
|
||||||
if (!preds.length) return;
|
|
||||||
const [sx, sy] = preds[0];
|
|
||||||
const [ex, ey] = preds[preds.length - 1];
|
|
||||||
const gradient = processedCtx.createLinearGradient(sx, sy, ex, ey);
|
|
||||||
gradient.addColorStop(0, 'rgba(0, 255, 0, 0.95)');
|
|
||||||
gradient.addColorStop(1, 'rgba(0, 255, 0, 0.3)');
|
|
||||||
|
|
||||||
if (count <= 0) {
|
|
||||||
// 只有起点时,画一个小点提升可见性
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.fillStyle = 'rgba(0, 255, 0, 0.95)';
|
|
||||||
processedCtx.arc(sx, sy, 3, 0, Math.PI * 2);
|
|
||||||
processedCtx.fill();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = Math.min(count, preds.length - 1);
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.lineWidth = 12;
|
|
||||||
processedCtx.lineCap = 'round';
|
|
||||||
processedCtx.lineJoin = 'round';
|
|
||||||
processedCtx.moveTo(sx, sy);
|
|
||||||
for (let i = 1; i <= last; i++) {
|
|
||||||
const [x, y] = preds[i];
|
|
||||||
processedCtx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
processedCtx.strokeStyle = gradient;
|
|
||||||
processedCtx.stroke();
|
|
||||||
// 在终点画一个蓝色小球
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.strokeStyle = 'blue';
|
|
||||||
processedCtx.lineWidth = 4;
|
|
||||||
processedCtx.arc(preds[last][0], preds[last][1], 20, 0, Math.PI * 2);
|
|
||||||
processedCtx.stroke();
|
|
||||||
|
|
||||||
if (!showActual.value) return
|
|
||||||
const gradient2 = processedCtx.createLinearGradient(sx, sy, ex, ey);
|
|
||||||
gradient2.addColorStop(0, 'rgba(255, 255, 0, 0.95)');
|
|
||||||
gradient2.addColorStop(1, 'rgba(255, 255, 0, 0.3)');
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.lineWidth = 12;
|
|
||||||
processedCtx.lineCap = 'round';
|
|
||||||
processedCtx.lineJoin = 'round';
|
|
||||||
processedCtx.moveTo(sx, sy);
|
|
||||||
for (let i = 1; i <= last; i++) {
|
|
||||||
let pos = actual[i]
|
|
||||||
if (!pos) break
|
|
||||||
const [x, y] = pos;
|
|
||||||
processedCtx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
processedCtx.strokeStyle = gradient2;
|
|
||||||
processedCtx.stroke();
|
|
||||||
// 在终点画一个黄色小球
|
|
||||||
processedCtx.beginPath();
|
|
||||||
processedCtx.strokeStyle = 'yellow';
|
|
||||||
processedCtx.lineWidth = 4;
|
|
||||||
processedCtx.arc(actual[last][0], actual[last][1], 20, 0, Math.PI * 2);
|
|
||||||
processedCtx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
function animatePrediction(index: number) {
|
|
||||||
const preds = predictedPosArr.value[index];
|
|
||||||
if (!preds || preds.length === 0) return;
|
|
||||||
|
|
||||||
cancelPredictionAnim();
|
|
||||||
animStartTs = performance.now();
|
|
||||||
|
|
||||||
const totalSegments = Math.max(1, preds.length - 1);
|
|
||||||
const duration = 800; // ms
|
|
||||||
const perSeg = duration / totalSegments;
|
|
||||||
|
|
||||||
const step = (now: number) => {
|
|
||||||
const elapsed = now - animStartTs;
|
|
||||||
// 目标画到的点下标(包含起点),至少为 1(起点),至多为 preds.length - 1
|
|
||||||
const targetIdx = Math.min(preds.length - 1, Math.floor(elapsed / perSeg) + 1);
|
|
||||||
const actual: [number, number][] = centerPosArr.value.slice(index, index + targetIdx + 1).map(p => p ? [p.x, p.y] as [number, number] : [0, 0]);
|
|
||||||
|
|
||||||
// 每帧重绘背景与部分轨迹
|
|
||||||
renderBaseFrame(index);
|
|
||||||
drawPredictionPartial(preds, actual, targetIdx);
|
|
||||||
|
|
||||||
if (targetIdx < preds.length - 1) {
|
|
||||||
animReqId = requestAnimationFrame(step);
|
|
||||||
} else {
|
|
||||||
// 最后一帧,收尾并清空动画句柄
|
|
||||||
animReqId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
animReqId = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
watch(selectedIndex, async (newIndex) => {
|
|
||||||
if (!rawCanvas.value || !processedCanvas.value) return;
|
|
||||||
if (!rawCtx || !processedCtx) return;
|
|
||||||
cancelPredictionAnim();
|
|
||||||
renderBaseFrame(newIndex);
|
|
||||||
// 如果当前帧已有预测,则启动动画;否则等待预测到达时再动画
|
|
||||||
if (predictedPosArr.value[newIndex]) {
|
|
||||||
animatePrediction(newIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// 时间轴进度条(当前帧、用于预测的范围、预测范围)
|
|
||||||
// ========================
|
|
||||||
const framesLen = computed(() => frames.value.length);
|
|
||||||
const currentIndex = computed(() => selectedIndex.value);
|
|
||||||
|
|
||||||
function clampIndex(i: number) {
|
|
||||||
const max = Math.max(0, framesLen.value - 1);
|
|
||||||
return Math.min(Math.max(i, 0), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPercent(i: number) {
|
|
||||||
if (framesLen.value <= 1) return 0;
|
|
||||||
const total = framesLen.value - 1;
|
|
||||||
const clamped = clampIndex(i);
|
|
||||||
return (clamped / total) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seqStartIdx = computed(() => Math.max(0, currentIndex.value - seqLen));
|
|
||||||
const seqEndIdx = computed(() => currentIndex.value - 1);
|
|
||||||
|
|
||||||
const predLen = computed(() => {
|
|
||||||
const preds = predictedPosArr.value[currentIndex.value];
|
|
||||||
return preds ? preds.length : 0;
|
|
||||||
});
|
|
||||||
const predStartIdx = computed(() => currentIndex.value);
|
|
||||||
const predEndIdx = computed(() => currentIndex.value + Math.max(0, predLen.value - 1));
|
|
||||||
|
|
||||||
const seqRangeStyle = computed<CSSProperties>(() => {
|
|
||||||
if (framesLen.value === 0) return { display: 'none' };
|
|
||||||
const start = seqStartIdx.value;
|
|
||||||
const end = seqEndIdx.value;
|
|
||||||
if (end < start) return { display: 'none' };
|
|
||||||
const left = toPercent(start);
|
|
||||||
const width = Math.max(0, toPercent(end) - toPercent(start));
|
|
||||||
return {
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${width}%`,
|
|
||||||
background: 'rgba(0, 153, 255, 0.35)'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const predRangeStyle = computed<CSSProperties>(() => {
|
|
||||||
if (framesLen.value === 0) return { display: 'none' };
|
|
||||||
if (predLen.value === 0) return { display: 'none' };
|
|
||||||
const start = predStartIdx.value;
|
|
||||||
const end = Math.min(framesLen.value - 1, predEndIdx.value);
|
|
||||||
if (end < start) return { display: 'none' };
|
|
||||||
const left = toPercent(start);
|
|
||||||
const width = Math.max(0, toPercent(end) - toPercent(start));
|
|
||||||
return {
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${width}%`,
|
|
||||||
background: 'linear-gradient(90deg, rgba(0,255,0,0.95), rgba(0,255,0,0.3))'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentCursorStyle = computed<CSSProperties>(() => {
|
|
||||||
if (framesLen.value === 0) return { left: '0%' };
|
|
||||||
return { left: `${toPercent(currentIndex.value - 0.5)}%` };
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1 style="font-size: larger;">基于 Transformer 的单摆轨迹预测模型</h1>
|
<RouterView />
|
||||||
<div>
|
</template>
|
||||||
<input type="file" name="video" id="video" @change="handleFileChange"
|
|
||||||
accept="video/*" /><br>
|
|
||||||
解码:<progress :value="decodeProgress" :max="1"></progress><br>
|
|
||||||
追踪:<progress :value="trackProgress" :max="frames.length - 1"></progress><br>
|
|
||||||
预测:<progress :value="predictProgress"
|
|
||||||
:max="frames.length - 1"></progress><br>
|
|
||||||
|
|
||||||
显示实际:<input type="checkbox" name="" id="" v-model="showActual">
|
|
||||||
<span style="color: gray;font-size: small;float: inline-end;">
|
|
||||||
<span v-if="showActual">黄色为实际运动轨迹,</span>
|
|
||||||
<span>蓝色为预测运动轨迹</span>
|
|
||||||
</span>
|
|
||||||
<!-- 自定义时间轴:淡蓝色=用于预测的输入序列,绿色渐变=预测范围,红线=当前帧位置 -->
|
|
||||||
<canvas ref="raw-canvas" style="display: none;" />
|
|
||||||
<canvas ref="processed-canvas" />
|
|
||||||
<div class="timeline" aria-label="prediction timeline">
|
|
||||||
<div class="range seq" :style="seqRangeStyle">参考</div>
|
|
||||||
<div class="range pred" :style="predRangeStyle">预测</div>
|
|
||||||
<div class="cursor" :style="currentCursorStyle"> </div>
|
|
||||||
</div>
|
|
||||||
<div class="index-indicator">
|
|
||||||
<span>0</span>
|
|
||||||
<span :style="currentCursorStyle"
|
|
||||||
style="position: absolute;transform: translateX(-50%);">{{ currentIndex
|
|
||||||
}}</span>
|
|
||||||
<span>{{ frames.length - 1 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="range" min="0" :max="frames.length - 1" style="width: 100%;"
|
|
||||||
:value="selectedIndex"
|
|
||||||
@input="(e) => selectedIndex = Number((e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
canvas {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
position: relative;
|
|
||||||
height: 12px;
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #eeeeee;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline .range {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline .cursor {
|
|
||||||
position: absolute;
|
|
||||||
top: -3px;
|
|
||||||
bottom: -3px;
|
|
||||||
width: 2px;
|
|
||||||
background: #ff3b30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-indicator {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
131
src/ChaosP.vue
Normal file
131
src/ChaosP.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import raw from './data/output_data.csv?raw'
|
||||||
|
|
||||||
|
// 解析 CSV(忽略空行 & 去除首尾空白)
|
||||||
|
interface Row {
|
||||||
|
frame: number
|
||||||
|
timestamp: number
|
||||||
|
redAngle: number
|
||||||
|
yellowAngle: number
|
||||||
|
yellowX: number
|
||||||
|
yellowY: number
|
||||||
|
redX: number
|
||||||
|
redY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = raw
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.slice(1) // 去掉表头
|
||||||
|
.map(line => line.split(','))
|
||||||
|
.filter(cols => cols.length >= 8)
|
||||||
|
.map(([frame, timestamp, redAngle, yellowAngle, yellowX, yellowY, redX, redY]) => ({
|
||||||
|
frame: Number(frame),
|
||||||
|
timestamp: Number(timestamp),
|
||||||
|
redAngle: Number(redAngle),
|
||||||
|
yellowAngle: Number(yellowAngle),
|
||||||
|
yellowX: Number(yellowX),
|
||||||
|
yellowY: Number(yellowY),
|
||||||
|
redX: Number(redX),
|
||||||
|
redY: Number(redY)
|
||||||
|
}))
|
||||||
|
.filter(r => !Number.isNaN(r.frame))
|
||||||
|
|
||||||
|
const curIndex = ref(0)
|
||||||
|
const curRow = computed(() => rows[curIndex.value] || rows[0])
|
||||||
|
|
||||||
|
// 计算边界用于自适应 viewBox
|
||||||
|
const minX = Math.min(...rows.map(r => Math.min(r.yellowX, r.redX)))
|
||||||
|
const maxX = Math.max(...rows.map(r => Math.max(r.yellowX, r.redX)))
|
||||||
|
const minY = Math.min(...rows.map(r => Math.min(r.yellowY, r.redY)))
|
||||||
|
const maxY = Math.max(...rows.map(r => Math.max(r.yellowY, r.redY)))
|
||||||
|
const margin = 40
|
||||||
|
const viewBox = computed(() => {
|
||||||
|
const w = maxX - minX || 100
|
||||||
|
const h = maxY - minY || 100
|
||||||
|
return `${minX - margin} ${minY - margin} ${w + margin * 2} ${h + margin * 2}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 轨迹(到当前帧)
|
||||||
|
const yellowPath = computed(() => rows.slice(0, curIndex.value + 1).map(r => `${r.yellowX},${r.yellowY}`).join(' '))
|
||||||
|
const redPath = computed(() => rows.slice(0, curIndex.value + 1).map(r => `${r.redX},${r.redY}`).join(' '))
|
||||||
|
|
||||||
|
// 播放控制(可选增强)
|
||||||
|
const playing = ref(false)
|
||||||
|
let raf: number | null = null
|
||||||
|
const speed = ref(1) // 每次推进帧数
|
||||||
|
|
||||||
|
// 条状矩形参数
|
||||||
|
const barLength = ref(400)
|
||||||
|
const barThickness = ref(40)
|
||||||
|
|
||||||
|
// 生成 transform & rect 属性
|
||||||
|
const yellowTransform = computed(() => `translate(${curRow.value.yellowX},${curRow.value.yellowY}) rotate(${curRow.value.yellowAngle})`)
|
||||||
|
const redTransform = computed(() => `translate(${curRow.value.redX},${curRow.value.redY}) rotate(${curRow.value.redAngle})`)
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
if (!playing.value) return
|
||||||
|
curIndex.value = (curIndex.value + speed.value) % rows.length
|
||||||
|
raf = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
function togglePlay() {
|
||||||
|
playing.value = !playing.value
|
||||||
|
if (playing.value) step()
|
||||||
|
else if (raf) cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.5rem;flex-wrap:wrap;">
|
||||||
|
<button @click="togglePlay">{{ playing ? '暂停' : '播放' }}</button>
|
||||||
|
<label>速度
|
||||||
|
<select v-model.number="speed">
|
||||||
|
<option :value="1">1x</option>
|
||||||
|
<option :value="2">2x</option>
|
||||||
|
<option :value="5">5x</option>
|
||||||
|
<option :value="10">10x</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>长度 <input type="number" v-model.number="barLength" min="10" style="width:80px" /></label>
|
||||||
|
<label>厚度 <input type="number" v-model.number="barThickness" min="2" style="width:70px" /></label>
|
||||||
|
<input type="range" min="0" :max="rows.length - 1" v-model.number="curIndex" style="flex:1;min-width:200px;" />
|
||||||
|
<div>帧: {{ curRow.frame }} / {{ rows[rows.length-1].frame }}</div>
|
||||||
|
<div>t={{ curRow.timestamp.toFixed(3) }}s</div>
|
||||||
|
</div>
|
||||||
|
<svg :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<!-- 轨迹 -->
|
||||||
|
<polyline :points="yellowPath" stroke="gold" stroke-width="6" fill="none" stroke-linejoin="round" stroke-linecap="round" />
|
||||||
|
<polyline :points="redPath" stroke="red" stroke-width="6" fill="none" stroke-linejoin="round" stroke-linecap="round" />
|
||||||
|
<!-- 条状矩形(以中心为原点旋转) -->
|
||||||
|
<g :transform="yellowTransform">
|
||||||
|
<rect :x="-barLength/2" :y="-barThickness/2" :width="barLength" :height="barThickness" fill="rgba(255,215,0,0.35)" stroke="gold" stroke-width="6" rx="8" ry="8" />
|
||||||
|
<!-- 指示朝向的小头 -->
|
||||||
|
<circle :cx="barLength/2" cy="0" :r="barThickness/4" fill="gold" />
|
||||||
|
</g>
|
||||||
|
<g :transform="redTransform">
|
||||||
|
<rect :x="-barLength/2" :y="-barThickness/2" :width="barLength" :height="barThickness" fill="rgba(255,0,0,0.35)" stroke="red" stroke-width="6" rx="8" ry="8" />
|
||||||
|
<circle :cx="barLength/2" cy="0" :r="barThickness/4" fill="red" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 标注文字 -->
|
||||||
|
<g font-size="48" font-family="monospace" stroke="none">
|
||||||
|
<text :x="curRow.yellowX + 40" :y="curRow.yellowY - 20" fill="gold">Y θ={{ curRow.yellowAngle.toFixed(1) }}°</text>
|
||||||
|
<text :x="curRow.redX + 40" :y="curRow.redY - 20" fill="red">R θ={{ curRow.redAngle.toFixed(1) }}°</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
button, select {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
360
src/SingleP.vue
Normal file
360
src/SingleP.vue
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, useTemplateRef, watch, computed, type CSSProperties } from 'vue';
|
||||||
|
import { extractFrameFromVideo } from './vfex';
|
||||||
|
import { YellowBallTracker } from './core';
|
||||||
|
import cv from '@techstark/opencv-js';
|
||||||
|
import ProgressBar from './components/ProgressBar.vue';
|
||||||
|
|
||||||
|
const frames = ref<VideoFrame[]>([]);
|
||||||
|
|
||||||
|
const rawCanvas = useTemplateRef('raw-canvas');
|
||||||
|
const processedCanvas = useTemplateRef('processed-canvas');
|
||||||
|
const decodeProgress = ref(0);
|
||||||
|
const trackProgress = ref(0);
|
||||||
|
const predictProgress = ref(0);
|
||||||
|
|
||||||
|
const showActual = ref(false);
|
||||||
|
|
||||||
|
let rawCtx: CanvasRenderingContext2D;
|
||||||
|
let processedCtx: CanvasRenderingContext2D;
|
||||||
|
onMounted(() => {
|
||||||
|
rawCtx = rawCanvas.value!.getContext('2d', { willReadFrequently: true })!;
|
||||||
|
processedCtx = processedCanvas.value!.getContext('2d')!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFrames = 512;
|
||||||
|
const seqLen = 120;
|
||||||
|
|
||||||
|
|
||||||
|
const centerPosArr = ref<({ x: number; y: number } | null)[]>([]);
|
||||||
|
const predictedPosArr = ref<[number, number][][]>([]);
|
||||||
|
|
||||||
|
// 计算序列的稳定哈希,用作 localStorage 的键
|
||||||
|
const MAX_BATCH = 64;
|
||||||
|
function hashSequence(seq: Array<[number, number]>): string {
|
||||||
|
const s = JSON.stringify(seq);
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h = ((h << 5) + h) + s.charCodeAt(i); // h*33 + c
|
||||||
|
h |= 0; // 32-bit
|
||||||
|
}
|
||||||
|
// 仅使用哈希值本身作为键名,避免与其它缓存键冲突的概率
|
||||||
|
return (h >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
async function handleFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
const file = input.files[0];
|
||||||
|
console.log('Selected file:', file);
|
||||||
|
// You can add further processing of the file here
|
||||||
|
frames.value = await extractFrameFromVideo(URL.createObjectURL(file), maxFrames, (p) => decodeProgress.value = p.decode)
|
||||||
|
|
||||||
|
const { codedHeight: h, codedWidth: w } = frames.value[0];
|
||||||
|
|
||||||
|
console.log("解码完成", frames.value.length, h, w);
|
||||||
|
|
||||||
|
|
||||||
|
if (!rawCanvas.value || !processedCanvas.value) return;
|
||||||
|
rawCanvas.value.width = w;
|
||||||
|
rawCanvas.value.height = h;
|
||||||
|
processedCanvas.value.width = w;
|
||||||
|
processedCanvas.value.height = h;
|
||||||
|
|
||||||
|
const tracker = new YellowBallTracker({ lowerYellow: new cv.Scalar(20, 100, 100, 0), upperYellow: new cv.Scalar(40, 255, 255, 255), blurSize: 7, morphKernel: 5, minContourArea: 20, useRoi: true, roiScaleX: 0.25, roiScaleY: 0.35 });
|
||||||
|
tracker.initMats(w, h);
|
||||||
|
|
||||||
|
let lastPos = null
|
||||||
|
|
||||||
|
for (let index = 0; index < frames.value.length; index++) {
|
||||||
|
const cacheKeyname = `${file.name}-${index}`;
|
||||||
|
if (localStorage.getItem(cacheKeyname)) {
|
||||||
|
centerPosArr.value[index] = JSON.parse(localStorage.getItem(cacheKeyname)!) as { x: number; y: number } | null;
|
||||||
|
trackProgress.value++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selectedIndex.value = index;
|
||||||
|
await reqNextFrame();
|
||||||
|
|
||||||
|
const center = tracker.detectYellowBall(rawCtx.getImageData(0, 0, w, h), lastPos);
|
||||||
|
centerPosArr.value[index] = center;
|
||||||
|
lastPos = center;
|
||||||
|
localStorage.setItem(cacheKeyname, JSON.stringify(center));
|
||||||
|
|
||||||
|
trackProgress.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstAvailableIndex = centerPosArr.value.findIndex(c => c !== null);
|
||||||
|
if (firstAvailableIndex === -1) {
|
||||||
|
console.warn("未检测到小球");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstAvailableIndex + seqLen >= centerPosArr.value.length) {
|
||||||
|
console.warn("视频帧数不足,无法预测");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 准备任务清单:每个任务 = { seq, key, outIndex }
|
||||||
|
type Task = { seq: Array<[number, number]>; key: string; outIndex: number };
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
|
||||||
|
const lastStart = centerPosArr.value.length - seqLen; // 不包含
|
||||||
|
for (let start = firstAvailableIndex; start < lastStart; start++) {
|
||||||
|
const seq_: Array<[number, number]> = centerPosArr.value
|
||||||
|
.slice(start, start + seqLen)
|
||||||
|
.map(p => p ? [p.x, p.y] as [number, number] : [0, 0]);
|
||||||
|
const key = hashSequence(seq_);
|
||||||
|
const outIndex = start + seqLen; // 预测对应的“当前帧”索引
|
||||||
|
tasks.push({ seq: seq_, key, outIndex });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试本地缓存命中
|
||||||
|
const pending: Task[] = [];
|
||||||
|
for (const t of tasks) {
|
||||||
|
const cached = localStorage.getItem(t.key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const pred: [number, number][] = JSON.parse(cached);
|
||||||
|
predictedPosArr.value[t.outIndex] = pred;
|
||||||
|
// 进度按已完成的最大下标推进
|
||||||
|
predictProgress.value = Math.max(predictProgress.value, t.outIndex);
|
||||||
|
} catch { pending.push(t); }
|
||||||
|
} else {
|
||||||
|
pending.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批请求后端,最多 64 条/批
|
||||||
|
for (let i = 0; i < pending.length; i += MAX_BATCH) {
|
||||||
|
const batch = pending.slice(i, i + MAX_BATCH);
|
||||||
|
const body = {
|
||||||
|
sequences: batch.map(b => b.seq),
|
||||||
|
steps: 30,
|
||||||
|
return_angles: true,
|
||||||
|
unwrap_from_last: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await fetch("http://127.0.0.1:8000/predict", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
const preds: [number, number][][] = json.pred_xy || [];
|
||||||
|
|
||||||
|
// 将结果回填到对应位置并写入缓存
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const t = batch[j];
|
||||||
|
const pred = preds[j];
|
||||||
|
if (!pred) continue;
|
||||||
|
predictedPosArr.value[t.outIndex] = pred;
|
||||||
|
localStorage.setItem(t.key, JSON.stringify(pred))
|
||||||
|
predictProgress.value = Math.max(predictProgress.value, t.outIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqNextFrame = () => new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
// ========================
|
||||||
|
// 预测轨迹动画渲染辅助
|
||||||
|
// ========================
|
||||||
|
let animReqId: number | null = null;
|
||||||
|
let animStartTs = 0;
|
||||||
|
|
||||||
|
function cancelPredictionAnim() {
|
||||||
|
if (animReqId !== null) {
|
||||||
|
cancelAnimationFrame(animReqId);
|
||||||
|
animReqId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBaseFrame(index: number) {
|
||||||
|
const frame = frames.value[index];
|
||||||
|
if (!frame) return;
|
||||||
|
// 背景帧
|
||||||
|
rawCtx.drawImage(frame, 0, 0);
|
||||||
|
processedCtx.drawImage(frame, 0, 0);
|
||||||
|
// 检测到的小球标记
|
||||||
|
if (centerPosArr.value[index]) {
|
||||||
|
const { x, y } = centerPosArr.value[index]!;
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.strokeStyle = 'red';
|
||||||
|
processedCtx.lineWidth = 4;
|
||||||
|
processedCtx.arc(x, y, 20, 0, Math.PI * 2);
|
||||||
|
processedCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPredictionPartial(preds: Array<[number, number]>, actual: Array<[number, number]>, count: number) {
|
||||||
|
if (!preds.length) return;
|
||||||
|
const [sx, sy] = preds[0];
|
||||||
|
const [ex, ey] = preds[preds.length - 1];
|
||||||
|
const gradient = processedCtx.createLinearGradient(sx, sy, ex, ey);
|
||||||
|
gradient.addColorStop(0, 'rgba(0, 255, 0, 0.95)');
|
||||||
|
gradient.addColorStop(1, 'rgba(0, 255, 0, 0.3)');
|
||||||
|
|
||||||
|
if (count <= 0) {
|
||||||
|
// 只有起点时,画一个小点提升可见性
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.fillStyle = 'rgba(0, 255, 0, 0.95)';
|
||||||
|
processedCtx.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||||
|
processedCtx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = Math.min(count, preds.length - 1);
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.lineWidth = 12;
|
||||||
|
processedCtx.lineCap = 'round';
|
||||||
|
processedCtx.lineJoin = 'round';
|
||||||
|
processedCtx.moveTo(sx, sy);
|
||||||
|
for (let i = 1; i <= last; i++) {
|
||||||
|
const [x, y] = preds[i];
|
||||||
|
processedCtx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
processedCtx.strokeStyle = gradient;
|
||||||
|
processedCtx.stroke();
|
||||||
|
// 在终点画一个蓝色小球
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.strokeStyle = 'blue';
|
||||||
|
processedCtx.lineWidth = 4;
|
||||||
|
processedCtx.arc(preds[last][0], preds[last][1], 20, 0, Math.PI * 2);
|
||||||
|
processedCtx.stroke();
|
||||||
|
|
||||||
|
if (!showActual.value) return
|
||||||
|
const gradient2 = processedCtx.createLinearGradient(sx, sy, ex, ey);
|
||||||
|
gradient2.addColorStop(0, 'rgba(255, 255, 0, 0.95)');
|
||||||
|
gradient2.addColorStop(1, 'rgba(255, 255, 0, 0.3)');
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.lineWidth = 12;
|
||||||
|
processedCtx.lineCap = 'round';
|
||||||
|
processedCtx.lineJoin = 'round';
|
||||||
|
processedCtx.moveTo(sx, sy);
|
||||||
|
for (let i = 1; i <= last; i++) {
|
||||||
|
let pos = actual[i]
|
||||||
|
if (!pos) break
|
||||||
|
const [x, y] = pos;
|
||||||
|
processedCtx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
processedCtx.strokeStyle = gradient2;
|
||||||
|
processedCtx.stroke();
|
||||||
|
// 在终点画一个黄色小球
|
||||||
|
processedCtx.beginPath();
|
||||||
|
processedCtx.strokeStyle = 'yellow';
|
||||||
|
processedCtx.lineWidth = 4;
|
||||||
|
processedCtx.arc(actual[last][0], actual[last][1], 20, 0, Math.PI * 2);
|
||||||
|
processedCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function animatePrediction(index: number) {
|
||||||
|
const preds = predictedPosArr.value[index];
|
||||||
|
if (!preds || preds.length === 0) return;
|
||||||
|
|
||||||
|
cancelPredictionAnim();
|
||||||
|
animStartTs = performance.now();
|
||||||
|
|
||||||
|
const totalSegments = Math.max(1, preds.length - 1);
|
||||||
|
const duration = 800; // ms
|
||||||
|
const perSeg = duration / totalSegments;
|
||||||
|
|
||||||
|
const step = (now: number) => {
|
||||||
|
const elapsed = now - animStartTs;
|
||||||
|
// 目标画到的点下标(包含起点),至少为 1(起点),至多为 preds.length - 1
|
||||||
|
const targetIdx = Math.min(preds.length - 1, Math.floor(elapsed / perSeg) + 1);
|
||||||
|
const actual: [number, number][] = centerPosArr.value.slice(index, index + targetIdx + 1).map(p => p ? [p.x, p.y] as [number, number] : [0, 0]);
|
||||||
|
|
||||||
|
// 每帧重绘背景与部分轨迹
|
||||||
|
renderBaseFrame(index);
|
||||||
|
drawPredictionPartial(preds, actual, targetIdx);
|
||||||
|
|
||||||
|
if (targetIdx < preds.length - 1) {
|
||||||
|
animReqId = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
// 最后一帧,收尾并清空动画句柄
|
||||||
|
animReqId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animReqId = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
watch(selectedIndex, async (newIndex) => {
|
||||||
|
if (!rawCanvas.value || !processedCanvas.value) return;
|
||||||
|
if (!rawCtx || !processedCtx) return;
|
||||||
|
cancelPredictionAnim();
|
||||||
|
renderBaseFrame(newIndex);
|
||||||
|
// 如果当前帧已有预测,则启动动画;否则等待预测到达时再动画
|
||||||
|
if (predictedPosArr.value[newIndex]) {
|
||||||
|
animatePrediction(newIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 style="font-size: larger;">基于 Transformer 的单摆轨迹预测模型</h1>
|
||||||
|
<div>
|
||||||
|
<input type="file" name="video" id="video" @change="handleFileChange"
|
||||||
|
accept="video/*" /><br>
|
||||||
|
解码:<progress :value="decodeProgress" :max="1"></progress><br>
|
||||||
|
追踪:<progress :value="trackProgress" :max="frames.length - 1"></progress><br>
|
||||||
|
预测:<progress :value="predictProgress"
|
||||||
|
:max="frames.length - 1"></progress><br>
|
||||||
|
|
||||||
|
显示实际:<input type="checkbox" name="" id="" v-model="showActual">
|
||||||
|
<span style="color: gray;font-size: small;float: inline-end;">
|
||||||
|
<span v-if="showActual">黄色为实际运动轨迹,</span>
|
||||||
|
<span>蓝色为预测运动轨迹</span>
|
||||||
|
</span>
|
||||||
|
<!-- 自定义时间轴:淡蓝色=用于预测的输入序列,绿色渐变=预测范围,红线=当前帧位置 -->
|
||||||
|
<canvas ref="raw-canvas" style="display: none;" />
|
||||||
|
<canvas ref="processed-canvas" />
|
||||||
|
<ProgressBar :totalFrames="frames.length" :currentIndex="selectedIndex"
|
||||||
|
:pred-length="predictedPosArr[selectedIndex]?.length" :seq-length="seqLen" />
|
||||||
|
|
||||||
|
<input type="range" min="0" :max="frames.length - 1" style="width: 100%;"
|
||||||
|
:value="selectedIndex"
|
||||||
|
@input="(e) => selectedIndex = Number((e.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eeeeee;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .range {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 2px;
|
||||||
|
background: #ff3b30;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
127
src/components/ProgressBar.vue
Normal file
127
src/components/ProgressBar.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type CSSProperties } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
totalFrames: number
|
||||||
|
seqLength?: number
|
||||||
|
predLength?: number
|
||||||
|
currentIndex: number
|
||||||
|
predIncludesCurrent?: boolean
|
||||||
|
}>(), {
|
||||||
|
predIncludesCurrent: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算当前指针位置(百分比)
|
||||||
|
const currentCursorStyle = computed<CSSProperties>(() => {
|
||||||
|
const total = props.totalFrames;
|
||||||
|
const cur = clamp(props.currentIndex, 0, total);
|
||||||
|
return { left: pct(cur / (total || 1)) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 参考(历史/输入)序列区间: 从 (currentIndex - seqLength) 到 currentIndex(不含)
|
||||||
|
const seqRangeStyle = computed<CSSProperties | undefined>(() => {
|
||||||
|
if (props.seqLength == null) return undefined;
|
||||||
|
const total = props.totalFrames;
|
||||||
|
if (total <= 0) return undefined;
|
||||||
|
const end = clamp(props.currentIndex, 0, total); // 末尾(不含)
|
||||||
|
const start = clamp(end - props.seqLength, 0, total);
|
||||||
|
if (start >= end) return undefined;
|
||||||
|
return rngStyle(start, end - start, total);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预测区间: 若包含当前帧,则从 currentIndex 开始;否则从 currentIndex+1 开始,共 predLength 帧
|
||||||
|
// 可视化时使用闭区间长度 => width = frameCount
|
||||||
|
const predRangeStyle = computed<CSSProperties | undefined>(() => {
|
||||||
|
if (props.predLength == null) return undefined;
|
||||||
|
const total = props.totalFrames;
|
||||||
|
if (total <= 0) return undefined;
|
||||||
|
const startBase = props.predIncludesCurrent ? props.currentIndex : props.currentIndex + 1;
|
||||||
|
const start = clamp(startBase, 0, total);
|
||||||
|
const frameCount = props.predLength;
|
||||||
|
if (frameCount <= 0) return undefined;
|
||||||
|
let rawEnd = start + frameCount; // 末尾(不含)
|
||||||
|
rawEnd = clamp(rawEnd, 0, total + 1); // 允许到 total+1(末尾不含)
|
||||||
|
const width = rawEnd - start;
|
||||||
|
if (width <= 0) return undefined;
|
||||||
|
// 若越界导致实际宽度变小但仍>0,则继续展示
|
||||||
|
return rngStyle(start, width, total);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
function clamp(v: number, min: number, max: number) { return Math.min(Math.max(v, min), max); }
|
||||||
|
function pct(v: number) { return (v * 100).toFixed(3) + '%'; }
|
||||||
|
// start: 起始帧索引, widthFrames: 帧数量, total: 最大索引(假定索引范围 0..total)
|
||||||
|
function rngStyle(start: number, widthFrames: number, total: number): CSSProperties {
|
||||||
|
// 这里假设 total 即最大索引 => 总帧数量约 = total
|
||||||
|
// 使用 total 作为分母可使 0 和 total 对应条的两端。
|
||||||
|
const denom = total || 1;
|
||||||
|
return {
|
||||||
|
left: pct(start / denom),
|
||||||
|
width: pct(widthFrames / denom)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="timeline" aria-label="prediction timeline">
|
||||||
|
<div v-if="seqRangeStyle" class="range seq" :style="seqRangeStyle">参考</div>
|
||||||
|
<div v-if="predRangeStyle" class="range pred" :style="predRangeStyle">预测</div>
|
||||||
|
<div class="cursor" :style="currentCursorStyle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="index-indicator">
|
||||||
|
<span>0</span>
|
||||||
|
<span :style="currentCursorStyle"
|
||||||
|
style="position: absolute;transform: translateX(-50%);">{{ currentIndex }}</span>
|
||||||
|
<span>{{ totalFrames }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eeeeee;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .range {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .range.seq {
|
||||||
|
background: rgba(0, 153, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .range.pred {
|
||||||
|
background: linear-gradient(90deg, rgba(0, 255, 0, 0.95), rgba(0, 255, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline .cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 2px;
|
||||||
|
background: #ff3b30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-indicator {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1762
src/data/output_data.csv
Normal file
1762
src/data/output_data.csv
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,8 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
.mount('#app')
|
||||||
16
src/router.ts
Normal file
16
src/router.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import SingleP from './SingleP.vue'
|
||||||
|
import ChaosP from './ChaosP.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/single', component: SingleP },
|
||||||
|
{ path: '/chaos', component: ChaosP },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
82
src/uniP.vue
Normal file
82
src/uniP.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, useTemplateRef, onMounted } from 'vue'
|
||||||
|
import { extractFrameFromVideo } from './vfex';
|
||||||
|
|
||||||
|
const frames = ref<VideoFrame[]>([]);
|
||||||
|
|
||||||
|
const rawCanvas = useTemplateRef('raw-canvas');
|
||||||
|
const processedCanvas = useTemplateRef('processed-canvas');
|
||||||
|
const decodeProgress = ref(0);
|
||||||
|
const trackProgress = ref(0);
|
||||||
|
const predictProgress = ref(0);
|
||||||
|
const showActual = ref(false);
|
||||||
|
|
||||||
|
let rawCtx: CanvasRenderingContext2D;
|
||||||
|
let processedCtx: CanvasRenderingContext2D;
|
||||||
|
onMounted(() => {
|
||||||
|
rawCtx = rawCanvas.value!.getContext('2d', { willReadFrequently: true })!;
|
||||||
|
processedCtx = processedCanvas.value!.getContext('2d')!;
|
||||||
|
});
|
||||||
|
async function handleFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
const file = input.files[0];
|
||||||
|
console.log('Selected file:', file);
|
||||||
|
// You can add further processing of the file here
|
||||||
|
frames.value = await extractFrameFromVideo(URL.createObjectURL(file), maxFrames, (p) => decodeProgress.value = p.decode)
|
||||||
|
|
||||||
|
const { codedHeight: h, codedWidth: w } = frames.value[0];
|
||||||
|
|
||||||
|
console.log("解码完成", frames.value.length, h, w);
|
||||||
|
|
||||||
|
|
||||||
|
if (!rawCanvas.value || !processedCanvas.value) return;
|
||||||
|
rawCanvas.value.width = w;
|
||||||
|
rawCanvas.value.height = h;
|
||||||
|
processedCanvas.value.width = w;
|
||||||
|
processedCanvas.value.height = h;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 style="font-size: larger;">基于 Transformer 的单摆轨迹预测模型</h1>
|
||||||
|
<div>
|
||||||
|
<input type="file" name="video" id="video" @change="handleFileChange"
|
||||||
|
accept="video/*" /><br>
|
||||||
|
解码:<progress :value="decodeProgress" :max="1"></progress><br>
|
||||||
|
追踪:<progress :value="trackProgress"
|
||||||
|
:max="frames.length - 1"></progress><br>
|
||||||
|
预测:<progress :value="predictProgress"
|
||||||
|
:max="frames.length - 1"></progress><br>
|
||||||
|
|
||||||
|
显示实际:<input type="checkbox" name="" id="" v-model="showActual">
|
||||||
|
<span style="color: gray;font-size: small;float: inline-end;">
|
||||||
|
<span v-if="showActual">黄色为实际运动轨迹,</span>
|
||||||
|
<span>蓝色为预测运动轨迹</span>
|
||||||
|
</span>
|
||||||
|
<!-- 自定义时间轴:淡蓝色=用于预测的输入序列,绿色渐变=预测范围,红线=当前帧位置 -->
|
||||||
|
<canvas ref="raw-canvas" style="display: none;" />
|
||||||
|
<canvas ref="processed-canvas" />
|
||||||
|
<div class="timeline" aria-label="prediction timeline">
|
||||||
|
<div class="range seq" :style="seqRangeStyle">参考</div>
|
||||||
|
<div class="range pred" :style="predRangeStyle">预测</div>
|
||||||
|
<div class="cursor" :style="currentCursorStyle"> </div>
|
||||||
|
</div>
|
||||||
|
<div class="index-indicator">
|
||||||
|
<span>0</span>
|
||||||
|
<span :style="currentCursorStyle"
|
||||||
|
style="position: absolute;transform: translateX(-50%);">{{
|
||||||
|
selectedIndex
|
||||||
|
}}</span>
|
||||||
|
<span>{{ frames.length - 1 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="range" min="0" :max="frames.length - 1"
|
||||||
|
style="width: 100%;" :value="selectedIndex"
|
||||||
|
@input="(e) => selectedIndex = Number((e.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
x
Reference in New Issue
Block a user