init
This commit is contained in:
+319
@@ -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>
|
||||
@@ -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 |
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user