This commit is contained in:
2025-09-04 23:13:35 +08:00
commit ce2116733e
20 changed files with 1202 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
<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');
let rawCtx: CanvasRenderingContext2D;
let processedCtx: CanvasRenderingContext2D;
onMounted(() => {
rawCtx = rawCanvas.value!.getContext('2d', { willReadFrequently: true })!;
processedCtx = processedCanvas.value!.getContext('2d')!;
});
const maxFrames = 128;
const seqLen = 120;
const decodeProgress = ref(0);
const trackProgress = ref(0);
const centerPosArr = ref<({ x: number; y: number } | null)[]>([]);
const predictedPosArr = ref<[number, number][][]>([]);
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++) {
selectedIndex.value = index;
await reqNextFrame();
const center = tracker.detectYellowBall(rawCtx.getImageData(0, 0, w, h), lastPos);
centerPosArr.value[index] = center;
lastPos = center;
console.log(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
}
for (let index = firstAvailableIndex; index < centerPosArr.value.length - seqLen; index++) {
const seq = centerPosArr.value.slice(index, index + seqLen).map(p => p ? [p.x, p.y] : [0, 0]);
const body = {
sequences: [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();
console.log(json);
predictedPosArr.value[index + seqLen] = json.pred_xy[0];
}
}
}
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]>, 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();
}
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);
// 每帧重绘背景与部分轨迹
renderBaseFrame(index);
drawPredictionPartial(preds, 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)}%` };
});
</script>
<template>
<div>
<input type="file" name="video" id="video" @change="handleFileChange"
accept="video/*" />
解码:<progress :value="decodeProgress" :max="1"></progress>
追踪:<progress :value="trackProgress" :max="frames.length - 1"></progress>
<input type="range" min="0" :max="frames.length - 1" style="width: 100%;"
v-model="selectedIndex" />
<!-- 自定义时间轴:淡蓝色=用于预测的输入序列,绿色渐变=预测范围,红线=当前帧位置 -->
<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>
<canvas ref="raw-canvas" style="display: none;"/>
<canvas ref="processed-canvas" />
</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;
}
.timeline .cursor {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
background: #ff3b30;
}
</style>
+1
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

+41
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>
+220
View File
@@ -0,0 +1,220 @@
import cv from '@techstark/opencv-js'
export interface TrackerParams {
lowerYellow: any;
upperYellow: any;
blurSize: number;
morphKernel: number;
minContourArea: number;
useRoi: boolean;
roiScaleX: number;
roiScaleY: number;
}
export interface Position {
x: number;
y: number;
}
export class YellowBallTracker {
public srcMat: cv.Mat | null = null
private hsvMat: cv.Mat | null = null
private blurredMat: cv.Mat | null = null
private maskMat: cv.Mat | null = null
private hierarchy: cv.Mat | null = null
private contours: cv.MatVector | null = null
private morphKernelMat: cv.Mat | null = null
private lowerBoundMat: cv.Mat | null = null
private upperBoundMat: cv.Mat | null = null
private params: TrackerParams
constructor(params: TrackerParams) {
this.params = params
}
public updateKernel() {
if (this.morphKernelMat) this.morphKernelMat.delete()
this.morphKernelMat = cv.Mat.ones(this.params.morphKernel, this.params.morphKernel, cv.CV_8U)
}
public initMats(w: number, h: number) {
this.cleanupMats()
this.srcMat = new cv.Mat(h, w, cv.CV_8UC4)
this.hsvMat = new cv.Mat()
this.blurredMat = new cv.Mat()
this.maskMat = new cv.Mat()
this.hierarchy = new cv.Mat()
this.contours = new cv.MatVector()
this.updateKernel()
// 创建阈值 Mat
this.lowerBoundMat = new cv.Mat(h, w, cv.CV_8UC3, this.params.lowerYellow)
this.upperBoundMat = new cv.Mat(h, w, cv.CV_8UC3, this.params.upperYellow)
}
private calculateROI(lastPos: Position): { x: number, y: number, width: number, height: number } {
const width = this.srcMat!.cols * this.params.roiScaleX
const height = this.srcMat!.rows * this.params.roiScaleY
const x = Math.max(0, Math.min(this.srcMat!.cols - width, lastPos.x - width / 2))
const y = Math.max(0, Math.min(this.srcMat!.rows - height, lastPos.y - height / 2))
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height)
}
}
private drawROIDebugInfo(roi: { x: number, y: number, width: number, height: number }) {
cv.rectangle(this.srcMat!,
new cv.Point(roi.x, roi.y),
new cv.Point(roi.x + roi.width, roi.y + roi.height),
new cv.Scalar(255, 0, 0, 255), 2)
}
private processImage(inputMat: cv.Mat, lowerBound: cv.Mat, upperBound: cv.Mat) {
// 颜色空间转换和模糊处理
cv.cvtColor(inputMat, this.hsvMat!, cv.COLOR_RGBA2RGB)
cv.cvtColor(this.hsvMat!, this.hsvMat!, cv.COLOR_RGB2HSV)
cv.blur(this.hsvMat!, this.blurredMat!, new cv.Size(this.params.blurSize, this.params.blurSize))
// 颜色范围过滤
cv.inRange(this.blurredMat!, lowerBound, upperBound, this.maskMat!)
// 形态学操作
cv.morphologyEx(this.maskMat!, this.maskMat!, cv.MORPH_OPEN, this.morphKernelMat!)
cv.morphologyEx(this.maskMat!, this.maskMat!, cv.MORPH_CLOSE, this.morphKernelMat!)
cv.dilate(this.maskMat!, this.maskMat!, this.morphKernelMat!)
}
private findBestContour(offsetX: number = 0, offsetY: number = 0): Position | null {
// 查找轮廓
this.contours!.delete()
this.contours = new cv.MatVector()
cv.findContours(this.maskMat!, this.contours, this.hierarchy!, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
// 找到最大面积的轮廓
let maxArea = 0
let best: Position | null = null
for (let i = 0; i < this.contours.size(); i++) {
const cnt = this.contours.get(i)
const area = cv.contourArea(cnt)
if (area > this.params.minContourArea && area > maxArea) {
maxArea = area
const circle = cv.minEnclosingCircle(cnt)
best = {
x: Math.round(circle.center.x + offsetX),
y: Math.round(circle.center.y + offsetY)
}
}
cnt.delete()
}
return best
}
private drawDetectionResult(position: Position) {
cv.circle(this.srcMat!, new cv.Point(position.x, position.y), 10, new cv.Scalar(0, 255, 0, 255), 2)
}
public detectYellowBall(imgData: ImageData, lastPos?: Position | null): Position | null {
if (!this.srcMat || !this.hsvMat || !this.blurredMat || !this.maskMat || !this.hierarchy ||
!this.contours || !this.morphKernelMat || !this.lowerBoundMat || !this.upperBoundMat) {
console.error('Mats not initialized')
return null
}
// Set image data
this.srcMat.data.set(imgData.data)
let best: Position | null = null
let srcRoiMat = new cv.Mat()
try {
// 尝试ROI检测
if (this.params.useRoi && lastPos) {
const roi = this.calculateROI(lastPos)
this.drawROIDebugInfo(roi)
// 提取ROI区域
const roiRect = new cv.Rect(roi.x, roi.y, roi.width, roi.height)
srcRoiMat = this.srcMat.roi(roiRect)
// 为ROI区域创建合适大小的阈值Mat
const roiLowerBound = new cv.Mat(roi.height, roi.width, cv.CV_8UC3, this.params.lowerYellow)
const roiUpperBound = new cv.Mat(roi.height, roi.width, cv.CV_8UC3, this.params.upperYellow)
try {
this.processImage(srcRoiMat, roiLowerBound, roiUpperBound)
best = this.findBestContour(roi.x, roi.y)
} finally {
roiLowerBound.delete()
roiUpperBound.delete()
}
}
// 如果ROI中没找到或者没启用ROI,就在全图搜索
if (!best) {
if (this.params.useRoi && lastPos) {
console.log("ROI失败,全域搜索")
}
this.processImage(this.srcMat, this.lowerBoundMat, this.upperBoundMat)
best = this.findBestContour()
}
// 在原图上绘制找到的点
if (best) {
this.drawDetectionResult(best)
}
} finally {
// 清理资源
srcRoiMat.delete()
}
return best
}
public getOutputCanvas(targetCanvas: HTMLCanvasElement) {
if (!this.srcMat) return null
cv.imshow(targetCanvas, this.srcMat)
return targetCanvas
}
public updateParams(params: Partial<TrackerParams>) {
this.params = { ...this.params, ...params }
// 如果更新了morphKernel,需要重新创建kernel
if ('morphKernel' in params) {
this.updateKernel()
}
}
public cleanupMats() {
// 清理资源
if (this.srcMat) this.srcMat.delete()
if (this.hsvMat) this.hsvMat.delete()
if (this.blurredMat) this.blurredMat.delete()
if (this.maskMat) this.maskMat.delete()
if (this.hierarchy) this.hierarchy.delete()
if (this.contours) this.contours.delete()
if (this.morphKernelMat) this.morphKernelMat.delete()
if (this.lowerBoundMat) this.lowerBoundMat.delete()
if (this.upperBoundMat) this.upperBoundMat.delete()
// 重置引用
this.srcMat = null
this.hsvMat = null
this.blurredMat = null
this.maskMat = null
this.hierarchy = null
this.contours = null
this.morphKernelMat = null
this.lowerBoundMat = null
this.upperBoundMat = null
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
View File
+109
View File
@@ -0,0 +1,109 @@
export async function downloadRes(url: string, onProgress: (progress: number) => void): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'arraybuffer'
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = event.loaded / event.total
onProgress(percentComplete)
}
}
xhr.onload = () => {
if (xhr.status !== 200) {
reject(new Error(`Failed to load video: ${xhr.statusText}`))
return
}
if (!xhr.response) {
reject(new Error('Failed to load video data'))
return
}
resolve(xhr.response)
}
xhr.onerror = () => reject(new Error('Network error occurred'))
xhr.send()
})
}
// 创建 Worker 并处理帧提取
async function extractFramesWithWorker(
blobPath: string,
expectFrames: number,
onProgress: (progress: number, totalSize: number) => void
): Promise<VideoFrame[]> {
let totalSize = 0
return new Promise((resolve, reject) => {
const frames: VideoFrame[] = []
const worker = new Worker(new URL('./worker.js', import.meta.url),
{ type: 'classic' }
)
const handleWorkerMessage = (event: MessageEvent) => {
const data = event.data
if (!data) return
for (const key in data) {
if (key === 'append_frame') {
const frame = data[key] as VideoFrame
frames.push(frame)
totalSize += frame.allocationSize()
onProgress(frames.length / expectFrames, totalSize )
if (frames.length >= expectFrames) {
worker.removeEventListener('message', handleWorkerMessage)
worker.terminate()
resolve(frames.slice(0, expectFrames))
return
}
} else {
// console.log(`[vFrameExtractor]:`, key, data[key])
}
}
}
worker.addEventListener('message', handleWorkerMessage)
worker.addEventListener('error', (error) => {
worker.terminate()
reject(new Error(`Worker error: ${error.message}`))
})
worker.postMessage({ dataUri: blobPath })
})
}
// 主函数
export async function extractFrameFromVideo(
videoPath: string,
expectFrames: number,
onProgress?: (progress: { download: number, decode: number}) => void
): Promise<VideoFrame[]> {
try {
// 下载视频
const arrayBuffer = await downloadRes(videoPath, (downloadProgress) => {
onProgress?.({ download: downloadProgress, decode: 0})
})
// 创建 Blob 和 URL
const blob = new Blob([arrayBuffer], { type: 'video/mp4' })
const blobPath = URL.createObjectURL(blob)
try {
// 提取帧
const frames = await extractFramesWithWorker(blobPath, expectFrames, (decodeProgress) => {
onProgress?.({ download: 1, decode: decodeProgress })
})
return frames
} finally {
// 清理 URL
URL.revokeObjectURL(blobPath)
}
} catch (error) {
throw error instanceof Error ? error : new Error('Unknown error occurred')
}
}
+174
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />