init
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<div class="home" :class="{ 'tracker-open': stage === 'tracker' }">
|
||||
<!-- 全局隐藏文件选择(两端都可用) -->
|
||||
<input ref="fileInput" type="file" accept="video/*" class="sr-only" @change="onVideoFileChange" />
|
||||
<input ref="csvInput" type="file" accept=".csv,text/csv" class="sr-only" @change="onCsvFileChange" />
|
||||
|
||||
<!-- ===== HERO / LANDING ===== -->
|
||||
<section v-if="stage === 'hero'" ref="hero" class="hero" role="region" aria-label="欢迎">
|
||||
<canvas ref="fx" class="fx" aria-hidden="true"></canvas>
|
||||
<div class="hero-inner">
|
||||
<h1 ref="logo" class="logo"><span class="accent">Pendulum</span> Lab</h1>
|
||||
<p ref="subtitle" class="subtitle">实时追踪 · 物理建模 · 一键分析</p>
|
||||
<div ref="cta" class="cta">
|
||||
<button class="btn primary" @click="openCamera">使用摄像头</button>
|
||||
<button class="btn secondary" @click="pickFile">选择视频</button>
|
||||
<button class="btn ghost" @click="pickCSV">导入 CSV</button>
|
||||
</div>
|
||||
<div class="note">为横屏移动端优化 · 建议将手机横置</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== TRACKER STAGE ===== -->
|
||||
<section v-if="stage === 'tracker'" class="stage" role="region" aria-label="实时追踪">
|
||||
<video ref="video" muted playsinline class="video-el" style="height:0"></video>
|
||||
<canvas ref="canvas" class="preview-canvas"></canvas>
|
||||
|
||||
<!-- 性能面板 -->
|
||||
<div class="performance">
|
||||
<canvas height="64" width="192" ref="frameTimeCanvas"></canvas>
|
||||
<div class="stats" v-if="pInfo.frameTime.length">
|
||||
<div class="stat-item"><span class="stat-label">平均</span><span class="stat-value">{{ pInfo.avgFrameTime
|
||||
}}ms</span></div>
|
||||
<div class="stat-item"><span class="stat-label">最小</span><span class="stat-value">{{ pInfo.minFrameTime
|
||||
}}ms</span></div>
|
||||
<div class="stat-item"><span class="stat-label">最大</span><span class="stat-value">{{ pInfo.maxFrameTime
|
||||
}}ms</span></div>
|
||||
<div class="stat-item"><span class="stat-label">FPS</span><span class="stat-value">{{ pInfo.fps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 位置图表 -->
|
||||
<div class="position-charts">
|
||||
<div class="chart"><canvas height="64" width="192" ref="xPosCanvas"></canvas>
|
||||
<div class="chart-label">X坐标</div>
|
||||
</div>
|
||||
<div class="chart"><canvas height="64" width="192" ref="yPosCanvas"></canvas>
|
||||
<div class="chart-label">Y坐标</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上录制/导出 -->
|
||||
<div class="record-controls">
|
||||
<button @click="toggleRecording" :class="{ 'recording': isRecording }">{{ isRecording ? '停止记录' : '开始记录'
|
||||
}}</button>
|
||||
<button @click="exportCSV" :disabled="!recordData.length">导出 CSV</button>
|
||||
<button class="btn ghost" @click="backToHero">退出</button>
|
||||
<span class="record-info" v-if="isRecording">已记录 <span class="mono" aria-live="polite">{{ recordData.length
|
||||
}}</span> 帧</span>
|
||||
</div>
|
||||
|
||||
<!-- 分析抽屉 -->
|
||||
<PendulumAnalysis v-if="showAnalysis && recordData.length" :data="recordData" :mmPerPixel="mmPerPixel"
|
||||
@close="showAnalysis = false" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch, onMounted, onUnmounted, nextTick, useTemplateRef } from 'vue';
|
||||
import gsap from 'gsap';
|
||||
import cv from '@techstark/opencv-js';
|
||||
import PendulumAnalysis from './components/PendulumAnalysis.vue';
|
||||
import { YellowBallTracker, type Position, type TrackerParams } from './utils/yellowBallTracker';
|
||||
import { drawPositionChart, drawPerformanceChart, MAX_POSITION_HISTORY, POSITION_CHART_PADDING, FIXED_MIN_Y, FIXED_MAX_Y } from './utils/chartUtils';
|
||||
|
||||
// ===== Config =====
|
||||
const apiBase = (import.meta as any).env?.VITE_API_BASE || '';
|
||||
const mmPerPixel = 0.166; // 如果你的 x/y 已是毫米,改成 1
|
||||
|
||||
// ===== Stage (hero <-> tracker) =====
|
||||
const stage = ref<'hero' | 'tracker'>('hero');
|
||||
async function openCamera() {
|
||||
stage.value = 'tracker';
|
||||
await nextTick();
|
||||
startCamera();
|
||||
}
|
||||
function pickFile() { (fileInput.value as HTMLInputElement)?.click(); }
|
||||
function pickCSV() { (csvInput.value as HTMLInputElement)?.click(); }
|
||||
function backToHero() { stopStream(); resetTracking(); stage.value = 'hero'; }
|
||||
|
||||
// ===== HERO anim + particles =====
|
||||
const hero = useTemplateRef('hero');
|
||||
const fx = useTemplateRef('fx');
|
||||
|
||||
let rafId = 0;
|
||||
function startFx() {
|
||||
if (!fx.value) return;
|
||||
const DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const ctx = fx.value.getContext('2d')!;
|
||||
const resize = () => { if (!fx.value) return; fx.value!.width = fx.value!.clientWidth * DPR; fx.value!.height = fx.value!.clientHeight * DPR; };
|
||||
resize();
|
||||
const dots = Array.from({ length: 40 }, () => ({ x: Math.random() * fx.value!.width, y: Math.random() * fx.value!.height, r: (1 + Math.random() * 2) * DPR, vx: (Math.random() - 0.5) * 0.25 * DPR, vy: (Math.random() - 0.5) * 0.25 * DPR }));
|
||||
const loop = () => {
|
||||
if (!fx.value) return;
|
||||
const w = fx.value!.width, h = fx.value!.height; ctx.clearRect(0, 0, w, h); ctx.globalCompositeOperation = 'lighter';
|
||||
for (const d of dots) { d.x += d.vx; d.y += d.vy; if (d.x < 0 || d.x > w) d.vx *= -1; if (d.y < 0 || d.y > h) d.vy *= -1; const g = ctx.createRadialGradient(d.x, d.y, 0, d.x, d.y, d.r * 4); g.addColorStop(0, 'rgba(0,255,170,0.35)'); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(d.x, d.y, d.r * 4, 0, Math.PI * 2); ctx.fill(); }
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
window.addEventListener('resize', resize, { passive: true });
|
||||
loop();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||
tl.from(hero.value, { autoAlpha: 0, duration: 0.4 })
|
||||
.from('.logo', { y: 20, autoAlpha: 0, duration: 0.6 })
|
||||
.from('.subtitle', { y: 16, autoAlpha: 0, duration: 0.5 }, '-=0.25')
|
||||
.from('.cta', { y: 12, autoAlpha: 0, duration: 0.45 }, '-=0.25');
|
||||
startFx();
|
||||
});
|
||||
|
||||
onUnmounted(() => cancelAnimationFrame(rafId));
|
||||
|
||||
// ===== Tracking / OpenCV =====
|
||||
const video = ref<HTMLVideoElement | null>(null);
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
const xPosCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const yPosCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const frameTimeCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const csvInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let frameCount = 0; let lastPosition: Position | null = null; let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const cvReady = ref(false); const cvStarted = ref(false);
|
||||
cv.onRuntimeInitialized = () => (cvReady.value = true);
|
||||
|
||||
const isRecording = ref(false);
|
||||
const recordData = reactive<{ frame: number; time: number; x: number; y: number }[]>([]);
|
||||
const showAnalysis = ref(false);
|
||||
|
||||
const positionData = reactive({ xHistory: [] as number[], yHistory: [] as number[], xRange: { min: 0, max: 100 }, yRange: { min: 0, max: 100 } });
|
||||
const params: TrackerParams = reactive({ 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 });
|
||||
let tracker: YellowBallTracker | null = null;
|
||||
|
||||
const pInfo = reactive({ lastUpdateTime: 0, frameTime: [] as number[], avgFrameTime: 0, minFrameTime: 0, maxFrameTime: 0, fps: 0, fpsUpdateTime: 0, frameCountForFps: 0 });
|
||||
|
||||
watch(() => [positionData.xHistory, positionData.yHistory], ([xH, yH]) => {
|
||||
if (!xH.length || !yH.length) return;
|
||||
// X
|
||||
const xMin = Math.min(...xH), xMax = Math.max(...xH); const xPad = (xMax - xMin) * 0.1 || POSITION_CHART_PADDING; positionData.xRange = { min: xMin - xPad, max: xMax + xPad };
|
||||
drawPositionChart(xPosCanvas.value!, xH, positionData.xRange, 'X位置');
|
||||
// Y
|
||||
const yMin = Math.min(...yH), yMax = Math.max(...yH); const yPad = (yMax - yMin) * 0.1 || POSITION_CHART_PADDING; positionData.yRange = { min: yMin - yPad, max: yMax + yPad };
|
||||
drawPositionChart(yPosCanvas.value!, yH, positionData.yRange, 'Y位置');
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => pInfo.frameTime, (v) => {
|
||||
if (!v.length) return;
|
||||
const min = Math.min(...v), max = Math.max(...v), avg = v.reduce((a, b) => a + b, 0) / v.length;
|
||||
pInfo.minFrameTime = Math.round(min * 100) / 100; pInfo.maxFrameTime = Math.round(max * 100) / 100; pInfo.avgFrameTime = Math.round(avg * 100) / 100;
|
||||
drawPerformanceChart(frameTimeCanvas.value!, v, FIXED_MIN_Y, FIXED_MAX_Y);
|
||||
}, { deep: true });
|
||||
|
||||
function processFrame() {
|
||||
if (!video.value || !canvas.value || !cvReady.value || video.value.readyState < 2) { requestAnimationFrame(processFrame); return; }
|
||||
if (video.value.ended) return;
|
||||
pInfo.lastUpdateTime = performance.now(); cvStarted.value = true;
|
||||
if (!ctx) ctx = canvas.value.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
// auto-resize canvas to video
|
||||
if (canvas.value.width !== video.value.videoWidth || canvas.value.height !== video.value.videoHeight) {
|
||||
canvas.value.width = video.value.videoWidth; canvas.value.height = video.value.videoHeight;
|
||||
}
|
||||
|
||||
ctx.drawImage(video.value, 0, 0, canvas.value.width, canvas.value.height);
|
||||
const currentFrameTime = video.value.currentTime;
|
||||
const imgData = ctx.getImageData(0, 0, canvas.value.width, canvas.value.height);
|
||||
|
||||
if (!tracker) { tracker = new YellowBallTracker(params); tracker.initMats(canvas.value.width, canvas.value.height); }
|
||||
else if (canvas.value.width !== tracker.srcMat?.cols || canvas.value.height !== tracker.srcMat?.rows) {
|
||||
tracker.initMats(canvas.value.width, canvas.value.height);
|
||||
}
|
||||
|
||||
const center = tracker.detectYellowBall(imgData, lastPosition); lastPosition = center;
|
||||
|
||||
if (center) {
|
||||
positionData.xHistory.push(center.x); positionData.yHistory.push(center.y);
|
||||
if (positionData.xHistory.length > MAX_POSITION_HISTORY) positionData.xHistory.shift();
|
||||
if (positionData.yHistory.length > MAX_POSITION_HISTORY) positionData.yHistory.shift();
|
||||
if (isRecording.value) {
|
||||
if (!showAnalysis.value) {
|
||||
recordData.push({ frame: frameCount, time: currentFrameTime, x: center.x, y: center.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示处理输出
|
||||
tracker.getOutputCanvas(canvas.value);
|
||||
|
||||
// 性能统计
|
||||
const now = performance.now(); const ft = now - pInfo.lastUpdateTime; if (pInfo.frameTime.length >= 128) pInfo.frameTime.shift(); pInfo.frameTime.push(ft);
|
||||
frameCount++; pInfo.frameCountForFps++;
|
||||
if (now - pInfo.fpsUpdateTime >= 1000) { pInfo.fps = Math.round(pInfo.frameCountForFps * 1000 / (now - pInfo.fpsUpdateTime)); pInfo.frameCountForFps = 0; pInfo.fpsUpdateTime = now; }
|
||||
|
||||
// 下一帧
|
||||
// @ts-ignore
|
||||
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
try {
|
||||
stopStream(); resetTracking();
|
||||
const constraints: MediaStreamConstraints = { video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 60, max: 120 }, facingMode: { ideal: 'environment' } }, audio: false } as any;
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
if (video.value) {
|
||||
video.value.srcObject = stream; await video.value.play().catch(() => { }); // @ts-ignore
|
||||
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||
}
|
||||
} catch (e) { console.error('摄像头启动失败', e); alert('无法访问摄像头,请检查权限设置'); }
|
||||
}
|
||||
|
||||
function stopStream() { if (!stream) return; stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
|
||||
async function onVideoFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
resetTracking(); stopStream();
|
||||
// 先切到追踪页,保证 video/canvas 已挂载
|
||||
if (stage.value !== 'tracker') { stage.value = 'tracker'; await nextTick(); }
|
||||
const url = URL.createObjectURL(file);
|
||||
if (!video.value) { await nextTick(); }
|
||||
if (!video.value) { console.warn('video 元素未就绪'); return; }
|
||||
|
||||
video.value.srcObject = null; video.value.src = url; video.value.muted = true; video.value.playsInline = true;
|
||||
await video.value.play().catch(() => { });
|
||||
// @ts-ignore
|
||||
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||
|
||||
// 自动开始记录;视频播完后自动弹出分析
|
||||
isRecording.value = true; recordData.splice(0);
|
||||
const onEnded = () => {
|
||||
if (isRecording.value) {
|
||||
isRecording.value = false;
|
||||
healthCheck().finally(() => { showAnalysis.value = true; });
|
||||
}
|
||||
video.value?.removeEventListener('ended', onEnded);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
video.value.addEventListener('ended', onEnded);
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
isRecording.value = !isRecording.value;
|
||||
if (isRecording.value) { recordData.splice(0, recordData.length); showAnalysis.value = false; }
|
||||
else { // 结束 -> 打开分析
|
||||
healthCheck().finally(() => { showAnalysis.value = true; });
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() { if (!recordData.length) return; let csv = 'frame,time,x,y\n'; for (const r of recordData) { csv += `${r.frame},${r.time},${r.x},${r.y}\n`; } const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pendulum_positions_${new Date().toISOString().replace(/[:.]/g, '-')}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 100); }
|
||||
|
||||
function resetTracking() { frameCount = 0; lastPosition = null; positionData.xHistory = []; positionData.yHistory = []; recordData.splice(0); isRecording.value = false; pInfo.fps = 0; pInfo.fpsUpdateTime = performance.now(); pInfo.frameCountForFps = 0; pInfo.frameTime = []; ctx = null; }
|
||||
|
||||
async function onCsvFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// 读取CSV文件内容
|
||||
const text = await file.text();
|
||||
const lines = text.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) {
|
||||
alert('CSV文件格式不正确,需要包含标题行和数据行');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析标题行,找出各列的索引
|
||||
const headers = lines[0].toLowerCase().split(',').map(h => h.trim());
|
||||
const frameIndex = headers.findIndex(h => h.includes('frame'));
|
||||
const timeIndex = headers.findIndex(h => h.includes('time'));
|
||||
const xIndex = headers.findIndex(h => h.includes('x'));
|
||||
const yIndex = headers.findIndex(h => h.includes('y'));
|
||||
|
||||
// 验证必要的列是否存在
|
||||
if (timeIndex === -1 || xIndex === -1 || yIndex === -1) {
|
||||
alert('CSV文件必须包含time、x、y列');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析数据行
|
||||
const csvData: { frame: number; time: number; x: number; y: number }[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = lines[i].split(',').map(cell => cell.trim());
|
||||
if (row.length < Math.max(timeIndex, xIndex, yIndex) + 1) continue;
|
||||
|
||||
const frame = frameIndex >= 0 ? parseFloat(row[frameIndex]) || i - 1 : i - 1;
|
||||
const time = parseFloat(row[timeIndex]);
|
||||
const x = parseFloat(row[xIndex]);
|
||||
const y = parseFloat(row[yIndex]);
|
||||
|
||||
// 跳过无效数据
|
||||
if (isNaN(time) || isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
csvData.push({ frame, time, x, y });
|
||||
}
|
||||
|
||||
if (csvData.length === 0) {
|
||||
alert('CSV文件中没有找到有效的数据行');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空当前的记录数据,将CSV数据设置为记录数据
|
||||
recordData.splice(0, recordData.length, ...csvData);
|
||||
|
||||
// 切换到追踪页面(如果当前不在)
|
||||
if (stage.value !== 'tracker') {
|
||||
stage.value = 'tracker';
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
// 直接打开分析抽屉
|
||||
showAnalysis.value = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('CSV文件解析错误:', error);
|
||||
alert('CSV文件解析失败,请检查文件格式');
|
||||
}
|
||||
}
|
||||
|
||||
async function healthCheck() { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 3000); try { const resp = await fetch(`${apiBase.replace(/\/+$/, '')}/api/health`, { signal: ctrl.signal }); return resp.ok; } catch { return false; } finally { clearTimeout(timer); } }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100dvh;
|
||||
background: #0b0f12;
|
||||
color: #e8f8f4;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
}
|
||||
|
||||
.fx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: clamp(28px, 6vw, 58px);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.logo .accent {
|
||||
color: #00ffaa;
|
||||
text-shadow: 0 0 22px rgba(0, 255, 170, .35);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
opacity: .9;
|
||||
margin: 0 0 18px;
|
||||
font-size: clamp(14px, 2.4vw, 18px);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid rgba(255, 255, 255, .18);
|
||||
background: rgba(255, 255, 255, .06);
|
||||
color: #e8f8f4;
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: .2s
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, .12);
|
||||
border-color: rgba(255, 255, 255, .28)
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, rgba(0, 255, 170, .25), rgba(0, 255, 170, .15));
|
||||
border-color: rgba(0, 255, 170, .35);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: rgba(255, 255, 255, .08);
|
||||
border-color: rgba(255, 255, 255, .25)
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 14px;
|
||||
opacity: .75;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
outline: 1px solid #222;
|
||||
}
|
||||
|
||||
.video-el {
|
||||
display: none
|
||||
}
|
||||
|
||||
.performance {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, .2);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.performance canvas {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(0, 0, 0, .4);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, .7)
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #00ffaa
|
||||
}
|
||||
|
||||
.position-charts {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, .2);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, .7);
|
||||
margin-top: 2px
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.controls select,
|
||||
.record-controls button {
|
||||
background: rgba(0, 0, 0, .6);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, .3);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.controls select:hover {
|
||||
background: rgba(0, 0, 0, .8);
|
||||
border-color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.record-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, .1)
|
||||
}
|
||||
|
||||
button.recording {
|
||||
background: rgba(255, 50, 50, .8) !important;
|
||||
animation: pulse 2s infinite
|
||||
}
|
||||
|
||||
.record-info {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, .7);
|
||||
}
|
||||
|
||||
.record-info .mono {
|
||||
display: inline-block;
|
||||
min-width: 5ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: .7
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
</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,779 @@
|
||||
<template>
|
||||
<div class="pendulum-analysis" role="dialog" aria-modal="true">
|
||||
<div class="safe-area">
|
||||
<header class="header">
|
||||
<h2 class="title">单摆分析</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn ghost"
|
||||
@click="onRetry"
|
||||
:disabled="loading"
|
||||
v-show="!showLengthInput"
|
||||
>
|
||||
{{ loading ? '分析中…' : '重新分析' }}
|
||||
</button>
|
||||
<button class="btn danger" @click="closeAnalysis" aria-label="关闭">×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div class="status-bar" v-if="loading || error || lastUpdated">
|
||||
<div class="loading" v-if="loading">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<span>正在分析数据,请稍候…</span>
|
||||
</div>
|
||||
<div class="error" v-else-if="error">
|
||||
<span>{{ error }}</span>
|
||||
<button class="btn small" @click="onRetry">重试</button>
|
||||
</div>
|
||||
<div class="meta" v-else-if="lastUpdated">
|
||||
<span>上次更新:{{ lastUpdated }}</span>
|
||||
<span v-if="sessionId">会话:{{ sessionId.slice(0, 8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="content">
|
||||
<!-- 摆长输入界面 -->
|
||||
<section v-if="showLengthInput" class="length-input-section">
|
||||
<h3>请输入摆长参数</h3>
|
||||
<div class="input-form">
|
||||
<div class="input-group">
|
||||
<label for="pendulum-length">摆长 (米):</label>
|
||||
<input
|
||||
id="pendulum-length"
|
||||
v-model.number="pendulumLength"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0.1"
|
||||
max="10"
|
||||
class="length-input"
|
||||
placeholder="请输入摆长,如 1.0"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-actions">
|
||||
<button class="btn primary" @click="startAnalysis" :disabled="!isValidLength">
|
||||
开始分析
|
||||
</button>
|
||||
<button class="btn ghost" @click="closeAnalysis">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-tips">
|
||||
<p>提示:</p>
|
||||
<ul>
|
||||
<li>摆长是从悬挂点到小球中心的距离</li>
|
||||
<li>请确保输入的摆长单位为米</li>
|
||||
<li>典型的实验室单摆摆长范围:0.5-2.0米</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 分析结果界面(原有内容) -->
|
||||
<template v-else>
|
||||
<!-- 左侧:图片栅格(横屏优先) -->
|
||||
<section class="results" aria-label="分析图像">
|
||||
<!-- 骨架屏 -->
|
||||
<div v-if="loading && !results.length" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 3" :key="'skel-' + i"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length" class="image-grid">
|
||||
<figure v-for="(image, index) in results" :key="index" class="result-item" @click="openLightbox(index)">
|
||||
<img :src="image.url" :alt="image.title" class="result-image" loading="eager" decoding="async" />
|
||||
<figcaption class="result-title">{{ image.title }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty">
|
||||
上传数据后将显示分析图像
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:参数信息(粘性面板) -->
|
||||
<aside class="analysis-info" v-if="analysisInfo">
|
||||
<h3>分析结果</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item" v-for="row in formattedInfo" :key="row.key">
|
||||
<div class="info-label">{{ row.label }}</div>
|
||||
<div class="info-value">{{ row.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<p>提示:</p>
|
||||
<ul>
|
||||
<li>点击左侧图像可全屏查看。</li>
|
||||
<li>如网络抖动,可点“重新分析”。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 图片灯箱 -->
|
||||
<div v-if="lightbox.open" class="lightbox" @click.self="closeLightbox">
|
||||
<button class="btn danger close-lightbox" @click="closeLightbox" aria-label="关闭预览">×</button>
|
||||
<div class="lightbox-inner">
|
||||
<img :src="results[lightbox.index].url" :alt="results[lightbox.index].title" />
|
||||
<div class="lightbox-caption">{{ results[lightbox.index].title }}</div>
|
||||
</div>
|
||||
<button v-if="results.length > 1" class="nav prev" @click.stop="navLightbox(-1)" aria-label="上一张">‹</button>
|
||||
<button v-if="results.length > 1" class="nav next" @click.stop="navLightbox(1)" aria-label="下一张">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
|
||||
type RawPoint = { frame: number; time: number; x: number; y: number };
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: RawPoint[],
|
||||
/** 可选:后端基础地址,如 'http://localhost:5000';默认同源 */
|
||||
apiBase?: string,
|
||||
/** 可选:组件打开时自动分析 */
|
||||
autoAnalyze?: boolean
|
||||
}>(), {
|
||||
apiBase: '',
|
||||
autoAnalyze: false
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const results = ref<{ url: string; title: string }[]>([]);
|
||||
const analysisInfo = ref<Record<string, any> | null>(null);
|
||||
const sessionId = ref<string | null>(null);
|
||||
const lastUpdated = ref<string | null>(null);
|
||||
const pendulumLength = ref<number>(1.0); // 摆长,单位:米
|
||||
const showLengthInput = ref(true); // 显示摆长输入界面
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const closeAnalysis = () => emit('close');
|
||||
const onRetry = () => analyzeData(true);
|
||||
|
||||
/** 验证摆长输入是否有效 */
|
||||
const isValidLength = computed(() => {
|
||||
return pendulumLength.value > 0.1 && pendulumLength.value <= 10;
|
||||
});
|
||||
|
||||
/** 开始分析 */
|
||||
const startAnalysis = () => {
|
||||
if (!isValidLength.value) return;
|
||||
showLengthInput.value = false;
|
||||
analyzeData(true);
|
||||
};
|
||||
|
||||
/** 将相对 URL 前缀化为 {apiBase}+path,保证跨域部署可用 */
|
||||
const withBase = (url: string) => {
|
||||
if (!url) return url;
|
||||
try {
|
||||
// 已是绝对 URL
|
||||
new URL(url);
|
||||
return url;
|
||||
} catch {
|
||||
const base = (props.apiBase || '').replace(/\/+$/, '');
|
||||
return base + url;
|
||||
}
|
||||
};
|
||||
|
||||
/** 新后端字段映射与格式化 */
|
||||
const labelMap: Record<string, string> = {
|
||||
pendulum_length_m: '摆长',
|
||||
gravity_acceleration_m_s2: '重力加速度',
|
||||
damping_coefficient_s_inv: '阻尼系数 γ',
|
||||
period_s: '周期',
|
||||
angle_max_rad: '最大摆角'
|
||||
};
|
||||
const unitMap: Record<string, string> = {
|
||||
pendulum_length_m: ' m',
|
||||
gravity_acceleration_m_s2: ' m/s²',
|
||||
damping_coefficient_s_inv: ' s⁻¹',
|
||||
period_s: ' s',
|
||||
angle_max_rad: ' rad'
|
||||
};
|
||||
const formatNumber = (v: any) => typeof v === 'number' ? (Math.abs(v) < 1e-3 ? v.toExponential(3) : v.toFixed(4)) : v;
|
||||
|
||||
const formattedInfo = computed(() => {
|
||||
if (!analysisInfo.value) return [];
|
||||
return Object.entries(analysisInfo.value).map(([key, val]) => ({
|
||||
key,
|
||||
label: labelMap[key] || key,
|
||||
value: `${formatNumber(val)}${unitMap[key] || ''}`
|
||||
}));
|
||||
});
|
||||
|
||||
/** 生成 CSV(列满足后端:time, x, y, pendulum_length) */
|
||||
const buildCsv = () => {
|
||||
const lines = ['time,x,y,pendulum_length']; // 添加摆长列
|
||||
for (const r of props.data) {
|
||||
lines.push(`${r.time},${r.x},${r.y},${pendulumLength.value}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/** 图片标题 */
|
||||
const getImageTitle = (index: number) => {
|
||||
const titles = [
|
||||
'单摆拟合分析',
|
||||
'物理参数摘要',
|
||||
'周期与摆角关系',
|
||||
];
|
||||
return titles[index] || `图像 ${index + 1}`;
|
||||
};
|
||||
|
||||
/** 主流程:发送到 {apiBase}/api/analyze */
|
||||
const analyzeData = async (force = false) => {
|
||||
if (!props.data?.length) return;
|
||||
if (loading.value && !force) return;
|
||||
|
||||
// 取消上一次请求
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
if (force) results.value = [];
|
||||
|
||||
try {
|
||||
const csv = buildCsv();
|
||||
const fd = new FormData();
|
||||
fd.append('csv_file', new Blob([csv], { type: 'text/csv' }), 'pendulum_data.csv');
|
||||
|
||||
const timeout = setTimeout(() => controller?.abort(), 60_000);
|
||||
const endpoint = `${(props.apiBase || '').replace(/\/+$/, '')}/api/analyze`;
|
||||
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal: controller.signal
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
// 解析错误
|
||||
if (!resp.ok) {
|
||||
let msg = `服务器错误: ${resp.status}`;
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) msg = j.error;
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
|
||||
if (json.success === false) {
|
||||
throw new Error(json.error || '分析失败');
|
||||
}
|
||||
|
||||
// 结果
|
||||
sessionId.value = json.session_id || null;
|
||||
|
||||
if (Array.isArray(json.images)) {
|
||||
results.value = json.images.map((p: string, i: number) => ({
|
||||
url: withBase(p),
|
||||
title: getImageTitle(i)
|
||||
}));
|
||||
} else {
|
||||
results.value = [];
|
||||
}
|
||||
|
||||
analysisInfo.value = json.analysis_data || null;
|
||||
lastUpdated.value = new Date().toLocaleString();
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
error.value = '请求已取消';
|
||||
} else if (
|
||||
e?.message?.includes('Failed to fetch') ||
|
||||
e?.message?.includes('NetworkError') ||
|
||||
e?.message?.includes('Network request failed')
|
||||
) {
|
||||
error.value = '网络异常,请检查连接后重试';
|
||||
} else {
|
||||
error.value = e?.message || '分析过程中发生错误';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoAnalyze && props.data?.length) {
|
||||
// 如果设置了自动分析,跳过摆长输入界面
|
||||
showLengthInput.value = false;
|
||||
analyzeData(true);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
console.log('PendulumAnalysis component unmounted');
|
||||
|
||||
});
|
||||
|
||||
/** 灯箱逻辑 */
|
||||
const lightbox = ref({ open: false, index: 0 });
|
||||
const openLightbox = (i: number) => {
|
||||
lightbox.value.open = true;
|
||||
lightbox.value.index = i;
|
||||
nextTick(() => {
|
||||
// 禁止页面滚动(移动端体验更好)
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
};
|
||||
const closeLightbox = () => {
|
||||
lightbox.value.open = false;
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
const navLightbox = (step: number) => {
|
||||
if (!results.value.length) return;
|
||||
const n = results.value.length;
|
||||
lightbox.value.index = (lightbox.value.index + step + n) % n;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== 基础布局(横屏优先) ========== */
|
||||
.pendulum-analysis {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: radial-gradient(1200px 800px at 70% -10%, rgba(0, 255, 170, 0.08), transparent 60%),
|
||||
#0b0f12;
|
||||
color: #e8f8f4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.safe-area {
|
||||
padding: calc(12px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.5px;
|
||||
color: #00ffaa;
|
||||
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e8f8f4;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform .05s ease, background .2s ease, border-color .2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
border-color: rgba(255, 90, 90, 0.45);
|
||||
color: #ffdede;
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: rgba(255, 90, 90, 0.15);
|
||||
}
|
||||
|
||||
/* 状态条 */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(0, 255, 170, 0.35);
|
||||
border-top-color: #00ffaa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff7a7a;
|
||||
}
|
||||
|
||||
/* 主内容区:横屏优先双列 */
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
/* 图像区域更大 */
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px),
|
||||
(orientation: portrait) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 摆长输入界面 */
|
||||
.length-input-section {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.length-input-section h3 {
|
||||
margin: 0 0 24px 0;
|
||||
color: #00ffaa;
|
||||
font-size: 20px;
|
||||
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
|
||||
}
|
||||
|
||||
.input-form {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
color: #d6efe8;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.length-input {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e8f8f4;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.length-input:focus {
|
||||
outline: none;
|
||||
border-color: #00ffaa;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.length-input::placeholder {
|
||||
color: rgba(232, 248, 244, 0.5);
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #00ffaa, #00cc88);
|
||||
border-color: #00ffaa;
|
||||
color: #0b0f12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: linear-gradient(135deg, #00cc88, #00aa77);
|
||||
}
|
||||
|
||||
.btn.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(232, 248, 244, 0.5);
|
||||
}
|
||||
|
||||
.input-tips {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
text-align: left;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.input-tips ul {
|
||||
margin: 4px 0 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 左侧:图像区 */
|
||||
.results {
|
||||
min-height: 40vh;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.image-grid {
|
||||
grid-auto-rows: minmax(200px, 36vh);
|
||||
}
|
||||
}
|
||||
|
||||
.result-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||
cursor: zoom-in;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
border-color: rgba(0, 255, 170, 0.25);
|
||||
}
|
||||
|
||||
.result-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
color: #cfeee6;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: min(36vh, 320px);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 25%, rgba(255, 255, 255, 0.12) 37%, rgba(255, 255, 255, 0.06) 63%) 0 0 / 400% 100%,
|
||||
rgba(255, 255, 255, 0.04);
|
||||
animation: shimmer 1.6s infinite;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* 右侧:参数面板(粘性) */
|
||||
.analysis-info {
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
align-self: start;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.2));
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.analysis-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #00ffaa;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #d6efe8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #00ffaa;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
ul{
|
||||
margin: 2px 0 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 灯箱 */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.86);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: env(safe-area-inset-top) 20px env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.lightbox-inner {
|
||||
width: 95vw;
|
||||
height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lightbox-inner img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #cfeee6;
|
||||
}
|
||||
|
||||
.close-lightbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.nav.prev {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.nav.next {
|
||||
right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
body{
|
||||
margin: 0;
|
||||
background-color: #1F1F1F;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './main.css'
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type StartOptions = {
|
||||
preferRearCamera?: boolean;
|
||||
width?: number; height?: number; fps?: number;
|
||||
deviceId?: string;
|
||||
};
|
||||
|
||||
export function useCamera() {
|
||||
const stream = ref<MediaStream | null>(null);
|
||||
const devices = ref<MediaDeviceInfo[]>([]);
|
||||
const activeDeviceId = ref<string | null>(null);
|
||||
|
||||
async function enumerate() {
|
||||
try {
|
||||
const list = await navigator.mediaDevices.enumerateDevices();
|
||||
devices.value = list.filter(d => d.kind === 'videoinput');
|
||||
return devices.value;
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function start(opts: StartOptions = {}) {
|
||||
await stop();
|
||||
const preferRear = opts.preferRearCamera ?? true;
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
width: { ideal: opts.width ?? 1280 },
|
||||
height: { ideal: opts.height ?? 720 },
|
||||
frameRate: { ideal: opts.fps ?? 60, max: 120 },
|
||||
...(opts.deviceId ? { deviceId: { exact: opts.deviceId } } : { facingMode: preferRear ? { ideal: 'environment' } : 'user' }),
|
||||
},
|
||||
audio: false
|
||||
} as any;
|
||||
|
||||
const s = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
stream.value = s;
|
||||
|
||||
// remember deviceId if available
|
||||
const track = s.getVideoTracks()[0];
|
||||
// @ts-ignore
|
||||
const settings = track.getSettings?.() || {};
|
||||
activeDeviceId.value = settings.deviceId || null;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!stream.value) return;
|
||||
for (const t of stream.value.getTracks()) t.stop();
|
||||
stream.value = null;
|
||||
}
|
||||
|
||||
return { stream, devices, activeDeviceId, enumerate, start, stop };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
export interface ChartRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface PerformanceInfo {
|
||||
lastUpdateTime: number;
|
||||
frameTime: number[];
|
||||
avgFrameTime: number;
|
||||
minFrameTime: number;
|
||||
maxFrameTime: number;
|
||||
fps: number;
|
||||
fpsUpdateTime: number;
|
||||
frameCountForFps: number;
|
||||
}
|
||||
|
||||
// 固定的y轴范围常量
|
||||
export const FIXED_MIN_Y = 0; // 最小帧时间 (ms)
|
||||
export const FIXED_MAX_Y = 100; // 最大帧时间 (ms),可根据实际情况调整
|
||||
|
||||
// 位置图表的常量
|
||||
export const MAX_POSITION_HISTORY = 128; // 最多保存的历史点数
|
||||
export const POSITION_CHART_PADDING = 20; // 图表边距,防止数值贴边
|
||||
|
||||
/**
|
||||
* 绘制性能图表
|
||||
*/
|
||||
export function drawPerformanceChart(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
data: number[],
|
||||
minY = FIXED_MIN_Y,
|
||||
maxY = FIXED_MAX_Y
|
||||
) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 画布尺寸
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 绘制背景
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 绘制网格线
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// 水平网格线
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = (i / 4) * height;
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
}
|
||||
|
||||
// 垂直网格线
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const x = (i / 8) * width;
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制y轴刻度值
|
||||
ctx.font = '8px Arial';
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.textAlign = 'left';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = height - (i / 4) * height;
|
||||
const value = Math.round(minY + (i / 4) * (maxY - minY));
|
||||
ctx.fillText(`${value}ms`, 2, y - 2);
|
||||
}
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
// 绘制曲线阴影
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, 'rgba(0, 255, 170, 0.5)');
|
||||
gradient.addColorStop(1, 'rgba(0, 255, 170, 0.05)');
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
// 使用固定的Y轴范围进行归一化
|
||||
const normalizedValue = Math.min(1, Math.max(0, (data[i] - minY) / (maxY - minY)));
|
||||
const y = height - normalizedValue * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// 完成区域填充路径
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(0, height);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// 绘制原始数据曲线
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
// 使用固定的Y轴范围进行归一化
|
||||
const normalizedValue = Math.min(1, Math.max(0, (data[i] - minY) / (maxY - minY)));
|
||||
const y = height - normalizedValue * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = '#00ffaa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制平均线
|
||||
if (data.length > 0) {
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
const normalizedAvg = Math.min(1, Math.max(0, (avg - minY) / (maxY - minY)));
|
||||
const avgY = height - normalizedAvg * height;
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.moveTo(0, avgY);
|
||||
ctx.lineTo(width, avgY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制位置图表
|
||||
*/
|
||||
export function drawPositionChart(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
data: number[],
|
||||
range: ChartRange,
|
||||
label: string
|
||||
) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 画布尺寸
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 绘制背景
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 绘制网格线
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// 水平网格线
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = (i / 4) * height;
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
}
|
||||
|
||||
// 垂直网格线
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const x = (i / 8) * width;
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制y轴刻度值
|
||||
ctx.font = '8px Arial';
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.textAlign = 'left';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = height - (i / 4) * height;
|
||||
const value = Math.round(range.min + (i / 4) * (range.max - range.min));
|
||||
ctx.fillText(`${value}`, 2, y - 2);
|
||||
}
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
// 绘制曲线阴影
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, label === 'X位置' ? 'rgba(255, 100, 100, 0.5)' : 'rgba(100, 100, 255, 0.5)');
|
||||
gradient.addColorStop(1, label === 'X位置' ? 'rgba(255, 100, 100, 0.05)' : 'rgba(100, 100, 255, 0.05)');
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
// 归一化数据点
|
||||
const normalizedValue = Math.min(1, Math.max(0, (data[i] - range.min) / (range.max - range.min)));
|
||||
const y = height - normalizedValue * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// 完成区域填充路径
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(0, height);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// 绘制原始数据曲线
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
// 归一化数据点
|
||||
const normalizedValue = Math.min(1, Math.max(0, (data[i] - range.min) / (range.max - range.min)));
|
||||
const y = height - normalizedValue * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = label === 'X位置' ? '#ff6464' : '#6464ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user