1079 lines
34 KiB
Vue
1079 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, onBeforeUnmount, ref, reactive, computed, watch } from 'vue'
|
||
import cv from '@techstark/opencv-js'
|
||
import { processImage } from './lib/processImage' // ← 根据你的实际路径调整
|
||
|
||
type Metrics = ReturnType<typeof processImage>
|
||
|
||
// 画布/视频引用
|
||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||
let ctx: CanvasRenderingContext2D | null = null
|
||
// 离屏处理画布:用于抓帧与图像处理,fetch 期间继续更新它
|
||
let procCanvasEl: OffscreenCanvas | null = null
|
||
let procCtx: OffscreenCanvasRenderingContext2D | null = null
|
||
// 视图尺寸(用于 SVG overlay 同步尺寸)
|
||
const viewW = ref(0)
|
||
const viewH = ref(0)
|
||
|
||
// 状态
|
||
const running = ref(false)
|
||
const fps = ref(0)
|
||
const err = ref<string | null>(null)
|
||
const isFetching = ref(false)
|
||
// 是否显示底层可见画布
|
||
const showCanvas = ref(true)
|
||
// OpenCV 就绪标记:仅在完成 WASM 初始化后才进行图像处理
|
||
const cvReady = ref(false)
|
||
const metrics = reactive<Metrics>({
|
||
redRectCenterX: null,
|
||
redRectCenterY: null,
|
||
greenRectCenterX: null,
|
||
greenRectCenterY: null,
|
||
blueRectCenterX: null,
|
||
blueRectCenterY: null,
|
||
redRectRotation: null,
|
||
greenRectRotation: null,
|
||
blueRectRotation: null,
|
||
redRectLength: null,
|
||
greenRectLength: null,
|
||
blueRectLength: null,
|
||
redRectWidth: null,
|
||
greenRectWidth: null,
|
||
blueRectWidth: null
|
||
})
|
||
|
||
// 圆心拟合(每180帧)
|
||
type Pt = { x: number; y: number }
|
||
const redCircleCenter = ref<Pt | null>(null)
|
||
const blueCircleCenter = ref<Pt | null>(null)
|
||
const greenCircleCenter = ref<Pt | null>(null)
|
||
const redPts: Pt[] = []
|
||
const bluePts: Pt[] = []
|
||
const greenPts: Pt[] = []
|
||
const fitWindow = 180
|
||
let frameCounter = 0
|
||
|
||
// 每帧相对圆心的极坐标(cos/sin/角度)
|
||
const redPolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
|
||
const bluePolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
|
||
const greenPolar = ref<{ cos: number; sin: number; angleDeg: number } | null>(null)
|
||
// 保存最近半径用于将预测的 pos_sin/cos 转回像素坐标
|
||
let redRadius: number | null = null
|
||
let greenRadius: number | null = null
|
||
|
||
// 预测缓冲:仅当填满180帧才触发一次请求
|
||
const framesBuffer: number[][] = []
|
||
let lastAddedTs: number | null = null
|
||
// 预测绘制数据(原始返回),在 fetch 成功后更新
|
||
let predictionsRaw: number[][] | null = null
|
||
let predictionHorizon = 0
|
||
|
||
// SVG 渲染所需的细杆数据(仅在可见刷新时更新,fetch 期间冻结)
|
||
type Rod = { cx: number; cy: number; longLen: number; shortLen: number; angleDeg: number; angleRad: number; color: string; opacity?: number }
|
||
const displayDetectedRods = ref<Rod[]>([])
|
||
const displayPredictedRods = ref<Rod[]>([])
|
||
type CenterMarker = { x: number; y: number; color: string }
|
||
const displayCenters = ref<CenterMarker[]>([])
|
||
|
||
// 使用代数最小二乘(Kasa)解 x^2 + y^2 + A x + B y + C = 0
|
||
function fitCircle(points: Pt[]): { cx: number; cy: number; r: number } | null {
|
||
const n = points.length
|
||
if (n < 3) return null
|
||
let Sx = 0, Sy = 0, Sxx = 0, Syy = 0, Sxy = 0
|
||
let Sxz = 0, Syz = 0, Sz = 0 // z = -(x^2 + y^2)
|
||
for (const { x, y } of points) {
|
||
const xx = x * x
|
||
const yy = y * y
|
||
const z = -(xx + yy)
|
||
Sx += x
|
||
Sy += y
|
||
Sxx += xx
|
||
Syy += yy
|
||
Sxy += x * y
|
||
Sxz += x * z
|
||
Syz += y * z
|
||
Sz += z
|
||
}
|
||
// 正规方程 M p = b, p = [A, B, C]
|
||
const M = [
|
||
[Sxx, Sxy, Sx],
|
||
[Sxy, Syy, Sy],
|
||
[Sx, Sy, n]
|
||
] as [number, number, number][]
|
||
const b = [Sxz, Syz, Sz] as [number, number, number]
|
||
|
||
const p = solveSym3(M, b)
|
||
if (!p) return null
|
||
const [A, B, C] = p
|
||
const cx = -A / 2
|
||
const cy = -B / 2
|
||
const rad2 = cx * cx + cy * cy - C
|
||
if (!(rad2 > 0)) return { cx, cy, r: 0 }
|
||
return { cx, cy, r: Math.sqrt(rad2) }
|
||
}
|
||
|
||
function solveSym3(M: [number, number, number][], b: [number, number, number]): [number, number, number] | null {
|
||
if (M.length < 3 || b.length < 3) return null
|
||
const r0 = M[0]; const r1 = M[1]; const r2 = M[2]
|
||
if (!r0 || !r1 || !r2 || r0.length < 3 || r1.length < 3 || r2.length < 3) return null
|
||
const [m00, m01, m02] = r0
|
||
const [m10, m11, m12] = r1
|
||
const [m20, m21, m22] = r2
|
||
const [d0, d1, d2] = b
|
||
|
||
const det =
|
||
m00 * (m11 * m22 - m12 * m21) -
|
||
m01 * (m10 * m22 - m12 * m20) +
|
||
m02 * (m10 * m21 - m11 * m20)
|
||
if (Math.abs(det) < 1e-8) return null
|
||
|
||
// 替换第0列
|
||
const detA =
|
||
d0 * (m11 * m22 - m12 * m21) -
|
||
m01 * (d1 * m22 - m12 * d2) +
|
||
m02 * (d1 * m21 - m11 * d2)
|
||
// 替换第1列
|
||
const detB =
|
||
m00 * (d1 * m22 - m12 * d2) -
|
||
d0 * (m10 * m22 - m12 * m20) +
|
||
m02 * (m10 * d2 - d1 * m20)
|
||
// 替换第2列
|
||
const detC =
|
||
m00 * (m11 * d2 - d1 * m21) -
|
||
m01 * (m10 * d2 - d1 * m20) +
|
||
d0 * (m10 * m21 - m11 * m20)
|
||
|
||
const A = detA / det
|
||
const B = detB / det
|
||
const C = detC / det
|
||
return [A, B, C]
|
||
}
|
||
|
||
// 录制 CSV
|
||
const isRecording = ref(false)
|
||
let recordRows: string[] = []
|
||
let recordStartT = 0
|
||
let recordFrameIndex = 0
|
||
// 固定表头顺序(“所有数据”= 所有 metrics 字段)
|
||
const metricKeys: (keyof Metrics)[] = [
|
||
'redRectCenterX', 'redRectCenterY',
|
||
'greenRectCenterX', 'greenRectCenterY',
|
||
'blueRectCenterX', 'blueRectCenterY',
|
||
'redRectRotation', 'greenRectRotation', 'blueRectRotation',
|
||
'redRectLength', 'greenRectLength', 'blueRectLength',
|
||
'redRectWidth', 'greenRectWidth', 'blueRectWidth'
|
||
]
|
||
|
||
function startRecording() {
|
||
recordRows = []
|
||
recordStartT = performance.now()
|
||
recordFrameIndex = 0
|
||
isRecording.value = true
|
||
}
|
||
|
||
function stopRecordingAndDownload() {
|
||
isRecording.value = false
|
||
// 生成 CSV 内容
|
||
const header = ['frameIndex', 'time', ...metricKeys]
|
||
const csvLines = [header.join(','), ...recordRows]
|
||
const csvContent = '\ufeff' + csvLines.join('\r\n') // 加 BOM 便于 Excel 识别
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
const ts = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
const fname = `metrics-${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.csv`
|
||
a.href = url
|
||
a.download = fname
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||
}
|
||
|
||
function toggleRecord() {
|
||
if (!isRecording.value) startRecording()
|
||
else stopRecordingAndDownload()
|
||
}
|
||
|
||
// 输入源:摄像头 | 本地文件
|
||
const sourceType = ref<'camera' | 'file'>('camera')
|
||
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||
const fileUrl = ref<string | null>(null)
|
||
const fileName = ref<string | null>(null)
|
||
const fileLoop = ref<boolean>(true)
|
||
|
||
const imageMaxLength = 960
|
||
|
||
// 摄像头方向
|
||
const facingMode = ref<'environment' | 'user'>('environment')
|
||
const facingModeLabel = computed(() =>
|
||
facingMode.value === 'environment' ? '后置' : '前置'
|
||
)
|
||
|
||
// RAF 相关
|
||
let rafId = 0
|
||
let lastT = 0
|
||
|
||
// 资源引用
|
||
let stream: MediaStream | null = null
|
||
|
||
function fmt(n: number | null) {
|
||
return n == null ? '—' : n.toFixed(1)
|
||
}
|
||
|
||
function resizeCanvasToVideo(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
|
||
const vw = video.videoWidth
|
||
const vh = video.videoHeight
|
||
|
||
const scaleRatio = Math.min(1, imageMaxLength / Math.max(vw, vh))
|
||
// 实际像素尺寸
|
||
canvas.width = Math.round(vw * scaleRatio)
|
||
canvas.height = Math.round(vh * scaleRatio)
|
||
// CSS 自适应
|
||
canvas.style.width = '100%'
|
||
canvas.style.height = 'auto'
|
||
// 同步离屏处理画布尺寸
|
||
if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvas.width, canvas.height)
|
||
if (!procCtx) procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
|
||
// 同步 SVG 尺寸
|
||
viewW.value = canvas.width
|
||
viewH.value = canvas.height
|
||
}
|
||
|
||
function copyMetrics(next: Metrics) {
|
||
; (Object.keys(metrics) as (keyof Metrics)[]).forEach((k) => {
|
||
metrics[k] = next[k] as any
|
||
})
|
||
}
|
||
|
||
async function ensureCVReady(): Promise<void> {
|
||
// @techstark/opencv-js 在 WASM 初始化完成后可调用 getBuildInformation
|
||
try {
|
||
if ((cv as any)?.getBuildInformation) {
|
||
cvReady.value = true
|
||
return
|
||
}
|
||
await new Promise<void>((resolve) => {
|
||
; (cv as any).onRuntimeInitialized = () => {
|
||
cvReady.value = true
|
||
resolve()
|
||
}
|
||
})
|
||
} catch {
|
||
// 忽略异常,让后续帧内判断 cvReady 决定是否处理
|
||
}
|
||
}
|
||
|
||
async function startStream() {
|
||
try {
|
||
err.value = null
|
||
// 关闭旧流
|
||
await stopStream()
|
||
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: { ideal: facingMode.value }, width: { ideal: 960 }, height: { ideal: 540 } },
|
||
audio: false
|
||
})
|
||
if (!videoEl.value) return
|
||
// 清空可能存在的文件来源
|
||
videoEl.value.src = ''
|
||
videoEl.value.removeAttribute('src')
|
||
videoEl.value.srcObject = stream
|
||
|
||
// 等待元数据并尝试播放(兼容 Safari/部分浏览器自动播放策略)
|
||
await new Promise<void>((res) => {
|
||
if (!videoEl.value) return res()
|
||
const v = videoEl.value
|
||
if (v.readyState >= 1) return res()
|
||
const onMeta = () => {
|
||
v.removeEventListener('loadedmetadata', onMeta)
|
||
res()
|
||
}
|
||
v.addEventListener('loadedmetadata', onMeta)
|
||
})
|
||
try {
|
||
await videoEl.value.play()
|
||
} catch {
|
||
// 若被策略拦截,依赖用户点击“启动”后再次尝试
|
||
}
|
||
|
||
if (!canvasEl.value || !videoEl.value) return
|
||
ctx = canvasEl.value.getContext('2d')
|
||
if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
|
||
resizeCanvasToVideo(videoEl.value, canvasEl.value)
|
||
// 初始化离屏处理上下文
|
||
if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvasEl.value.width, canvasEl.value.height)
|
||
procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
|
||
if (!procCtx) throw new Error('离屏 CanvasRenderingContext2D 获取失败')
|
||
|
||
// 重置计数与缓存
|
||
frameCounter = 0
|
||
redPts.length = 0
|
||
bluePts.length = 0
|
||
redCircleCenter.value = null
|
||
blueCircleCenter.value = null
|
||
greenPts.length = 0
|
||
greenCircleCenter.value = null
|
||
redPolar.value = null
|
||
bluePolar.value = null
|
||
greenPolar.value = null
|
||
framesBuffer.length = 0
|
||
lastAddedTs = null
|
||
predictionsRaw = null
|
||
predictionHorizon = 0
|
||
isFetching.value = false
|
||
redRadius = null
|
||
greenRadius = null
|
||
|
||
running.value = true
|
||
lastT = performance.now()
|
||
loop()
|
||
} catch (e: any) {
|
||
err.value = e?.message ?? String(e)
|
||
running.value = false
|
||
}
|
||
}
|
||
|
||
async function stopStream() {
|
||
cancelAnimationFrame(rafId)
|
||
rafId = 0
|
||
if (stream) {
|
||
stream.getTracks().forEach((t) => t.stop())
|
||
stream = null
|
||
}
|
||
if (videoEl.value) {
|
||
try { videoEl.value.pause() } catch { }
|
||
// 清除摄像头或文件源
|
||
videoEl.value.srcObject = null
|
||
if (videoEl.value.src) {
|
||
videoEl.value.removeAttribute('src')
|
||
}
|
||
}
|
||
running.value = false
|
||
}
|
||
|
||
async function startFilePlayback() {
|
||
try {
|
||
err.value = null
|
||
await stopStream()
|
||
|
||
if (!videoEl.value) throw new Error('视频元素不存在')
|
||
if (!fileUrl.value) throw new Error('请先选择本地视频文件')
|
||
|
||
videoEl.value.srcObject = null
|
||
videoEl.value.src = fileUrl.value
|
||
videoEl.value.loop = !!fileLoop.value
|
||
|
||
await new Promise<void>((res) => {
|
||
if (!videoEl.value) return res()
|
||
const v = videoEl.value
|
||
if (v.readyState >= 1 && v.videoWidth && v.videoHeight) return res()
|
||
const onMeta = () => {
|
||
v.removeEventListener('loadedmetadata', onMeta)
|
||
res()
|
||
}
|
||
v.addEventListener('loadedmetadata', onMeta)
|
||
})
|
||
|
||
try {
|
||
await videoEl.value.play()
|
||
} catch {
|
||
// 可能需要用户手势
|
||
}
|
||
|
||
if (!canvasEl.value || !videoEl.value) return
|
||
ctx = canvasEl.value.getContext('2d')
|
||
if (!ctx) throw new Error('CanvasRenderingContext2D 获取失败')
|
||
resizeCanvasToVideo(videoEl.value, canvasEl.value)
|
||
// 初始化离屏处理上下文
|
||
if (!procCanvasEl) procCanvasEl = new OffscreenCanvas(canvasEl.value.width, canvasEl.value.height)
|
||
procCtx = procCanvasEl.getContext('2d', {willReadFrequently: true})
|
||
if (!procCtx) throw new Error('离屏 CanvasRenderingContext2D 获取失败')
|
||
|
||
// 重置计数与缓存
|
||
frameCounter = 0
|
||
redPts.length = 0
|
||
bluePts.length = 0
|
||
redCircleCenter.value = null
|
||
blueCircleCenter.value = null
|
||
greenPts.length = 0
|
||
greenCircleCenter.value = null
|
||
redPolar.value = null
|
||
bluePolar.value = null
|
||
greenPolar.value = null
|
||
framesBuffer.length = 0
|
||
lastAddedTs = null
|
||
predictionsRaw = null
|
||
predictionHorizon = 0
|
||
isFetching.value = false
|
||
redRadius = null
|
||
greenRadius = null
|
||
|
||
running.value = true
|
||
lastT = performance.now()
|
||
loop()
|
||
} catch (e: any) {
|
||
err.value = e?.message ?? String(e)
|
||
running.value = false
|
||
}
|
||
}
|
||
|
||
async function loop() {
|
||
if (!running.value || !videoEl.value || !canvasEl.value || !ctx) return
|
||
|
||
const now = performance.now()
|
||
const dt = now - lastT
|
||
lastT = now
|
||
fps.value = 1000 / (dt || 16.7)
|
||
|
||
// 将视频帧绘制到离屏画布(可在 fetch 期间继续处理)
|
||
const vw = canvasEl.value.width
|
||
const vh = canvasEl.value.height
|
||
if (procCtx) {
|
||
procCtx.drawImage(videoEl.value, 0, 0, vw, vh)
|
||
}
|
||
|
||
try {
|
||
// 本帧是否应在绘制后启动 fetch
|
||
let shouldStartFetch = false
|
||
// 取像素、识别
|
||
const baseCtx = procCtx ?? ctx
|
||
if (!baseCtx) throw new Error('处理上下文获取失败')
|
||
const imgData = baseCtx.getImageData(0, 0, vw, vh)
|
||
if (cvReady.value) {
|
||
const res = processImage(imgData)
|
||
copyMetrics(res)
|
||
}
|
||
|
||
// 收集点到滑动窗口
|
||
if (metrics.redRectCenterX != null && metrics.redRectCenterY != null) {
|
||
redPts.push({ x: metrics.redRectCenterX, y: metrics.redRectCenterY })
|
||
if (redPts.length > fitWindow) redPts.shift()
|
||
}
|
||
if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null) {
|
||
bluePts.push({ x: metrics.blueRectCenterX, y: metrics.blueRectCenterY })
|
||
if (bluePts.length > fitWindow) bluePts.shift()
|
||
}
|
||
if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null) {
|
||
greenPts.push({ x: metrics.greenRectCenterX, y: metrics.greenRectCenterY })
|
||
if (greenPts.length > fitWindow) greenPts.shift()
|
||
}
|
||
|
||
// 拟合一次圆心
|
||
frameCounter += 1
|
||
if (frameCounter % fitWindow === 0) {
|
||
const rf = fitCircle(redPts)
|
||
const bf = fitCircle(bluePts)
|
||
const gf = fitCircle(greenPts)
|
||
redCircleCenter.value = rf ? { x: rf.cx, y: rf.cy } : null
|
||
blueCircleCenter.value = bf ? { x: bf.cx, y: bf.cy } : null
|
||
greenCircleCenter.value = gf ? { x: gf.cx, y: gf.cy } : null
|
||
}
|
||
|
||
// 每帧计算相对圆心的 cos/sin/角度
|
||
if (
|
||
redCircleCenter.value &&
|
||
metrics.redRectCenterX != null && metrics.redRectCenterY != null
|
||
) {
|
||
const dx = metrics.redRectCenterX - redCircleCenter.value.x
|
||
const dy = metrics.redRectCenterY - redCircleCenter.value.y
|
||
const r = Math.hypot(dx, dy)
|
||
if (r > 1e-6) {
|
||
const c = dx / r
|
||
const s = dy / r
|
||
const ang = (Math.atan2(dy, dx) * 180) / Math.PI
|
||
const angleDeg = ang >= 0 ? ang : ang + 360
|
||
redPolar.value = { cos: c, sin: s, angleDeg }
|
||
redRadius = r
|
||
} else {
|
||
redPolar.value = null
|
||
redRadius = null
|
||
}
|
||
} else {
|
||
redPolar.value = null
|
||
}
|
||
if (
|
||
blueCircleCenter.value &&
|
||
metrics.blueRectCenterX != null && metrics.blueRectCenterY != null
|
||
) {
|
||
const dx = metrics.blueRectCenterX - blueCircleCenter.value.x
|
||
const dy = metrics.blueRectCenterY - blueCircleCenter.value.y
|
||
const r = Math.hypot(dx, dy)
|
||
if (r > 1e-6) {
|
||
const c = dx / r
|
||
const s = dy / r
|
||
const ang = (Math.atan2(dy, dx) * 180) / Math.PI
|
||
const angleDeg = ang >= 0 ? ang : ang + 360
|
||
bluePolar.value = { cos: c, sin: s, angleDeg }
|
||
} else {
|
||
bluePolar.value = null
|
||
}
|
||
} else {
|
||
bluePolar.value = null
|
||
}
|
||
|
||
// 计算绿色相对圆心的 cos/sin/角度
|
||
if (
|
||
greenCircleCenter.value &&
|
||
metrics.greenRectCenterX != null && metrics.greenRectCenterY != null
|
||
) {
|
||
const dx = metrics.greenRectCenterX - greenCircleCenter.value.x
|
||
const dy = metrics.greenRectCenterY - greenCircleCenter.value.y
|
||
const r = Math.hypot(dx, dy)
|
||
if (r > 1e-6) {
|
||
const c = dx / r
|
||
const s = dy / r
|
||
const ang = (Math.atan2(dy, dx) * 180) / Math.PI
|
||
const angleDeg = ang >= 0 ? ang : ang + 360
|
||
greenPolar.value = { cos: c, sin: s, angleDeg }
|
||
greenRadius = r
|
||
} else {
|
||
greenPolar.value = null
|
||
greenRadius = null
|
||
}
|
||
} else {
|
||
greenPolar.value = null
|
||
}
|
||
|
||
// 维护180帧序列,仅在具备红绿圆心与必要数据时加入
|
||
const haveCenters = !!(redCircleCenter.value && greenCircleCenter.value)
|
||
const havePolars = !!(redPolar.value && greenPolar.value)
|
||
const redRotDeg = metrics.redRectRotation
|
||
const greenRotDeg = metrics.greenRectRotation
|
||
const haveAngles = redRotDeg != null && greenRotDeg != null
|
||
if (haveCenters && havePolars && haveAngles) {
|
||
if (lastAddedTs == null) {
|
||
// 初始化上一时间戳,不立即插入,确保 delta_time = t - t_-1
|
||
lastAddedTs = now
|
||
} else {
|
||
const dtSec = (now - lastAddedTs) / 1000
|
||
lastAddedTs = now
|
||
const redRotRad = (redRotDeg! * Math.PI) / 180
|
||
const greenRotRad = (greenRotDeg! * Math.PI) / 180
|
||
const rp = redPolar.value!
|
||
const gp = greenPolar.value!
|
||
const frameRow: number[] = [
|
||
Number(dtSec.toFixed(6)),
|
||
Math.sin(greenRotRad), Math.cos(greenRotRad),
|
||
Math.sin(redRotRad), Math.cos(redRotRad),
|
||
// green_pos_sin, green_pos_cos, red_pos_sin, red_pos_cos
|
||
gp.sin, gp.cos, rp.sin, rp.cos
|
||
]
|
||
framesBuffer.push(frameRow)
|
||
if (framesBuffer.length > 180) framesBuffer.shift()
|
||
// 满 180 并且未在 fetch 中:在本帧绘制之后再启动 fetch,避免画面永远不刷新
|
||
if (framesBuffer.length === 180 && !isFetching.value) {
|
||
shouldStartFetch = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// 若处于录制状态,写入一行
|
||
if (isRecording.value) {
|
||
const tSec = (now - recordStartT) / 1000
|
||
const values: (string | number)[] = [
|
||
recordFrameIndex,
|
||
tSec.toFixed(3)
|
||
]
|
||
for (const k of metricKeys) {
|
||
const v = metrics[k]
|
||
values.push(v == null ? '' : (typeof v === 'number' ? v.toFixed(3) : String(v)))
|
||
}
|
||
recordRows.push(values.join(','))
|
||
recordFrameIndex += 1
|
||
}
|
||
|
||
// 可见画布在 fetch 期间冻结;非 fetch 时才刷新
|
||
if (!isFetching.value && ctx) {
|
||
// 先绘制当前视频帧背景(来自离屏)
|
||
if (procCanvasEl) ctx.drawImage(procCanvasEl, 0, 0, vw, vh)
|
||
|
||
// 组装 SVG 检测细杆(仅在可见刷新时更新)
|
||
const rods: Rod[] = []
|
||
if (metrics.blueRectCenterX != null && metrics.blueRectCenterY != null && metrics.blueRectLength && metrics.blueRectWidth && metrics.blueRectRotation != null) {
|
||
rods.push({
|
||
cx: metrics.blueRectCenterX,
|
||
cy: metrics.blueRectCenterY,
|
||
longLen: metrics.blueRectLength,
|
||
shortLen: metrics.blueRectWidth,
|
||
angleDeg: metrics.blueRectRotation,
|
||
angleRad: (metrics.blueRectRotation * Math.PI) / 180,
|
||
color: '#2e6bff',
|
||
opacity: 1
|
||
})
|
||
}
|
||
if (metrics.redRectCenterX != null && metrics.redRectCenterY != null && metrics.redRectLength && metrics.redRectWidth && metrics.redRectRotation != null) {
|
||
rods.push({
|
||
cx: metrics.redRectCenterX,
|
||
cy: metrics.redRectCenterY,
|
||
longLen: metrics.redRectLength,
|
||
shortLen: metrics.redRectWidth,
|
||
angleDeg: metrics.redRectRotation,
|
||
angleRad: (metrics.redRectRotation * Math.PI) / 180,
|
||
color: '#ff4d4d',
|
||
opacity: 1
|
||
})
|
||
}
|
||
if (metrics.greenRectCenterX != null && metrics.greenRectCenterY != null && metrics.greenRectLength && metrics.greenRectWidth && metrics.greenRectRotation != null) {
|
||
rods.push({
|
||
cx: metrics.greenRectCenterX,
|
||
cy: metrics.greenRectCenterY,
|
||
longLen: metrics.greenRectLength,
|
||
shortLen: metrics.greenRectWidth,
|
||
angleDeg: metrics.greenRectRotation,
|
||
angleRad: (metrics.greenRectRotation * Math.PI) / 180,
|
||
color: '#00ff6a',
|
||
opacity: 1
|
||
})
|
||
}
|
||
displayDetectedRods.value = rods
|
||
|
||
// 组装 SVG 圆心指示器(红/蓝/绿)
|
||
const centers: CenterMarker[] = []
|
||
if (redCircleCenter.value) centers.push({ x: redCircleCenter.value.x, y: redCircleCenter.value.y, color: '#ff4d4d' })
|
||
if (blueCircleCenter.value) centers.push({ x: blueCircleCenter.value.x, y: blueCircleCenter.value.y, color: '#2e6bff' })
|
||
if (greenCircleCenter.value) centers.push({ x: greenCircleCenter.value.x, y: greenCircleCenter.value.y, color: '#00ff6a' })
|
||
displayCenters.value = centers
|
||
|
||
// 组装 SVG 预测细杆(渐隐)
|
||
const prods: Rod[] = []
|
||
if (
|
||
predictionsRaw && predictionsRaw.length > 0 &&
|
||
redCircleCenter.value && greenCircleCenter.value &&
|
||
redRadius != null && greenRadius != null
|
||
) {
|
||
const gh = predictionsRaw.length
|
||
const steps = Math.min(15, gh)
|
||
|
||
// 小工具:角度归一化到 [0, 360)
|
||
const normDeg = (deg: number) => ((deg % 360) + 360) % 360
|
||
// 小工具:若模型输出的 pos_sin/pos_cos 有数值漂移,先归一化
|
||
const normVec = (c: number, s: number) => {
|
||
const len = Math.hypot(c, s)
|
||
if (len > 1e-6) return [c / len, s / len] as const
|
||
return [c, s] as const
|
||
}
|
||
|
||
for (let i = 0; i < steps; i++) {
|
||
const row = predictionsRaw[i]
|
||
if (!row || row.length < 9) continue
|
||
// "delta_time", "green_angle_sin", "green_angle_cos", "red_angle_sin", "red_angle_cos", "green_pos_sin", "green_pos_cos", "red_pos_sin", "red_pos_cos"
|
||
const g_as = Number(row[1])
|
||
const g_ac = Number(row[2])
|
||
const r_as = Number(row[3])
|
||
const r_ac = Number(row[4])
|
||
const g_ps = Number(row[5])
|
||
const g_pc = Number(row[6])
|
||
const r_ps = Number(row[7])
|
||
const r_pc = Number(row[8])
|
||
|
||
// 渐隐:越远未来越淡
|
||
const a = Math.max(0, 1 - i / steps)
|
||
|
||
// 以拟合圆心 + 半径 + 预测的 位置角(sin/cos)反解中心坐标
|
||
const [g_pc_n, g_ps_n] = normVec(g_pc, g_ps)
|
||
const gx = greenCircleCenter.value.x + (greenRadius as number) * g_pc_n
|
||
const gy = greenCircleCenter.value.y + (greenRadius as number) * g_ps_n
|
||
// 预测杆朝向角:由 (sin, cos) 反解角度(直接用于 SVG,y 轴向下时无需额外翻转)
|
||
const gang = normDeg((Math.atan2(g_as, g_ac) * 180) / Math.PI)
|
||
const gL = (metrics.greenRectLength && metrics.greenRectLength > 0) ? metrics.greenRectLength : 40
|
||
const gW = (metrics.greenRectWidth && metrics.greenRectWidth > 0) ? metrics.greenRectWidth : 4
|
||
prods.push({ cx: gx, cy: gy, longLen: gL as number, shortLen: gW as number, angleDeg: gang, angleRad: (gang * Math.PI) / 180, color: '#00ff6a', opacity: a })
|
||
|
||
const [r_pc_n, r_ps_n] = normVec(r_pc, r_ps)
|
||
const rx = redCircleCenter.value.x + (redRadius as number) * r_pc_n
|
||
const ry = redCircleCenter.value.y + (redRadius as number) * r_ps_n
|
||
const rang = normDeg((Math.atan2(r_as, r_ac) * 180) / Math.PI)
|
||
const rL = (metrics.redRectLength && metrics.redRectLength > 0) ? metrics.redRectLength : 40
|
||
const rW = (metrics.redRectWidth && metrics.redRectWidth > 0) ? metrics.redRectWidth : 4
|
||
prods.push({ cx: rx, cy: ry, longLen: rL as number, shortLen: rW as number, angleDeg: rang, angleRad: (rang * Math.PI) / 180, color: '#ff4d4d', opacity: a })
|
||
}
|
||
}
|
||
displayPredictedRods.value = prods
|
||
}
|
||
|
||
// 本帧绘制完成后再启动 fetch(若需要)
|
||
/* if (shouldStartFetch) {
|
||
isFetching.value = true
|
||
const payload = framesBuffer.slice() // 不改动缓冲
|
||
fetch('/api/predict', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ frames: payload })
|
||
})
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error('预测接口错误: ' + res.status)
|
||
const data = await res.json()
|
||
if (Array.isArray(data?.predictions)) {
|
||
predictionsRaw = data.predictions as number[][]
|
||
predictionHorizon = Number(data?.horizon ?? predictionsRaw.length)
|
||
} else {
|
||
predictionsRaw = null
|
||
predictionHorizon = 0
|
||
}
|
||
})
|
||
.catch(() => { })
|
||
.finally(() => { isFetching.value = false })
|
||
}
|
||
*/ } catch (e: any) {
|
||
// 单帧异常不中断,显示在 HUD
|
||
err.value = e?.message ?? String(e)
|
||
}
|
||
|
||
rafId = requestAnimationFrame(loop)
|
||
}
|
||
|
||
async function toggle() {
|
||
if (running.value) {
|
||
await stopStream()
|
||
} else {
|
||
// 不阻塞摄像头启动,让 CV 就绪在后台进行
|
||
ensureCVReady()
|
||
if (sourceType.value === 'camera') {
|
||
await startStream()
|
||
} else {
|
||
await startFilePlayback()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function switchFacing() {
|
||
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||
if (running.value) {
|
||
await startStream()
|
||
}
|
||
}
|
||
|
||
function onPickFile(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const f = input.files && input.files[0]
|
||
if (!f) return
|
||
// 回收旧 URL
|
||
if (fileUrl.value) {
|
||
URL.revokeObjectURL(fileUrl.value)
|
||
fileUrl.value = null
|
||
}
|
||
fileUrl.value = URL.createObjectURL(f)
|
||
fileName.value = f.name
|
||
// 若当前处于文件模式且已点击启动,则自动重启播放
|
||
if (sourceType.value === 'file' && running.value) {
|
||
startFilePlayback()
|
||
}
|
||
}
|
||
|
||
// 当切换输入源时,根据状态进行处理
|
||
watch(
|
||
() => sourceType.value,
|
||
async () => {
|
||
// 切换源:如果正在运行就切换到对应启动;否则仅停止,等待用户点击“启动”
|
||
if (running.value) {
|
||
if (sourceType.value === 'camera') {
|
||
await startStream()
|
||
} else {
|
||
await startFilePlayback()
|
||
}
|
||
} else {
|
||
await stopStream()
|
||
}
|
||
}
|
||
)
|
||
|
||
onMounted(async () => {
|
||
// 后台等待 CV 就绪
|
||
ensureCVReady()
|
||
// 自动启动(可改为手动)
|
||
await startStream()
|
||
})
|
||
|
||
onBeforeUnmount(async () => {
|
||
await stopStream()
|
||
// 统一释放文件 URL
|
||
if (fileUrl.value) {
|
||
URL.revokeObjectURL(fileUrl.value)
|
||
fileUrl.value = null
|
||
}
|
||
ctx = null
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="wrap">
|
||
<div class="stage">
|
||
<!-- 隐藏视频元素仅用于抓帧 -->
|
||
<video ref="videoEl" playsinline muted autoplay
|
||
class="hidden-video"></video>
|
||
|
||
<!-- 可见的绘制画布(可通过 HUD 开关隐藏) -->
|
||
<canvas ref="canvasEl" class="canvas" v-show="showCanvas"></canvas>
|
||
|
||
<!-- SVG 叠加层:用于渲染细杆(检测与预测) -->
|
||
<svg class="overlay" :width="viewW" :height="viewH"
|
||
:viewBox="`0 0 ${viewW} ${viewH}`">
|
||
<!-- 圆心指示器(SVG 实现) -->
|
||
<g>
|
||
<g v-for="(c, i) in displayCenters" :key="'c-' + i">
|
||
<circle :cx="c.x" :cy="c.y" r="4" :fill="c.color" />
|
||
<line :x1="c.x - 8" :y1="c.y" :x2="c.x + 8" :y2="c.y"
|
||
:stroke="c.color" stroke-width="2" />
|
||
<line :x1="c.x" :y1="c.y - 8" :x2="c.x" :y2="c.y + 8"
|
||
:stroke="c.color" stroke-width="2" />
|
||
</g>
|
||
</g>
|
||
<!-- 检测到的细杆(实色、全不透明) -->
|
||
<g>
|
||
<g v-for="(rod, i) in displayDetectedRods" :key="'d-' + i"
|
||
:opacity="rod.opacity ?? 1">
|
||
<rect :x="rod.cx - rod.longLen / 2" :y="rod.cy - rod.shortLen / 2"
|
||
:width="rod.longLen" :height="rod.shortLen"
|
||
:transform="`rotate(${rod.angleDeg} ${rod.cx} ${rod.cy})`"
|
||
fill="none" :stroke="rod.color" stroke-width="2" />
|
||
<circle :cx="rod.cx" :cy="rod.cy" r="3" :fill="rod.color" />
|
||
<line :x1="rod.cx" :y1="rod.cy"
|
||
:x2="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.4)"
|
||
:y2="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.4)"
|
||
:stroke="rod.color" stroke-width="2" />
|
||
</g>
|
||
</g>
|
||
<!-- 预测细杆(渐隐) -->
|
||
<g>
|
||
<g v-for="(rod, i) in displayPredictedRods" :key="'p-' + i"
|
||
:opacity="rod.opacity ?? 1">
|
||
<rect :x="rod.cx - rod.longLen / 2" :y="rod.cy - rod.shortLen / 2"
|
||
:width="rod.longLen" :height="rod.shortLen"
|
||
:transform="`rotate(${rod.angleDeg} ${rod.cx} ${rod.cy})`"
|
||
fill="none" :stroke="rod.color" stroke-width="2" />
|
||
<circle :cx="rod.cx" :cy="rod.cy" r="3" :fill="rod.color" />
|
||
<line :x1="rod.cx" :y1="rod.cy"
|
||
:x2="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.4)"
|
||
:y2="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.4)"
|
||
:stroke="rod.color" stroke-width="2" />
|
||
<!-- 预测帧序号(从 1 开始),标注在杆的前端稍远处 -->
|
||
<text
|
||
:x="rod.cx + Math.cos(rod.angleRad) * (rod.longLen * 0.5 + 8)"
|
||
:y="rod.cy + Math.sin(rod.angleRad) * (rod.longLen * 0.5 + 8)"
|
||
:fill="rod.color"
|
||
stroke="black"
|
||
stroke-width="0.8"
|
||
font-size="10"
|
||
dominant-baseline="middle"
|
||
text-anchor="middle"
|
||
>{{ i + 1 }}</text>
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
|
||
<!-- HUD 面板 -->
|
||
<div class="hud">
|
||
<div class="row">
|
||
<label class="label">来源:</label>
|
||
<select class="select" v-model="sourceType">
|
||
<option value="camera">摄像头</option>
|
||
<option value="file">本地视频</option>
|
||
</select>
|
||
</div>
|
||
<div class="row" v-if="sourceType === 'file'">
|
||
<input ref="fileInputEl" class="file-input" type="file"
|
||
accept="video/*" @change="onPickFile" />
|
||
</div>
|
||
<div class="row small" v-if="sourceType === 'file'">
|
||
<label class="checkbox">
|
||
<input type="checkbox" v-model="fileLoop" /> 循环播放
|
||
</label>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn" @click="toggle()">
|
||
{{ running ? '停止' : '启动' }}
|
||
</button>
|
||
<button class="btn" v-if="sourceType === 'camera'"
|
||
@click="switchFacing()">
|
||
切换摄像头({{ facingModeLabel }})
|
||
</button>
|
||
<button class="btn" :disabled="!running" @click="toggleRecord()">
|
||
{{ isRecording ? '停止并下载' : '开始记录' }}
|
||
</button>
|
||
</div>
|
||
<div class="row small">
|
||
<span>FPS: {{ fps.toFixed(1) }}</span>
|
||
<span v-if="isRecording">记录中…</span>
|
||
<span v-if="err" class="err">错误:{{ err }}</span>
|
||
</div>
|
||
<div class="row small">
|
||
<label class="checkbox">
|
||
<input type="checkbox" v-model="showCanvas" /> 显示底层画面
|
||
</label>
|
||
</div>
|
||
<div class="metrics">
|
||
<div class="group">
|
||
<h4>Red</h4>
|
||
<div>x: {{ fmt(metrics.redRectCenterX) }}, y: {{
|
||
fmt(metrics.redRectCenterY) }}</div>
|
||
<div>θ: {{ fmt(metrics.redRectRotation) }}°</div>
|
||
<div>L×W: {{ fmt(metrics.redRectLength) }} × {{
|
||
fmt(metrics.redRectWidth) }}</div>
|
||
<div>{{ redPolar?.cos.toFixed(2) }} {{ redPolar?.sin.toFixed(2) }}
|
||
</div>
|
||
</div>
|
||
<div class="group">
|
||
<h4>Green</h4>
|
||
<div>x: {{ fmt(metrics.greenRectCenterX) }}, y: {{
|
||
fmt(metrics.greenRectCenterY) }}</div>
|
||
<div>θ: {{ fmt(metrics.greenRectRotation) }}°</div>
|
||
<div>L×W: {{ fmt(metrics.greenRectLength) }} × {{
|
||
fmt(metrics.greenRectWidth) }}</div>
|
||
</div>
|
||
<div class="group">
|
||
<h4>Blue</h4>
|
||
<div>x: {{ fmt(metrics.blueRectCenterX) }}, y: {{
|
||
fmt(metrics.blueRectCenterY) }}</div>
|
||
<div>θ: {{ fmt(metrics.blueRectRotation) }}°</div>
|
||
<div>L×W: {{ fmt(metrics.blueRectLength) }} × {{
|
||
fmt(metrics.blueRectWidth) }}</div>
|
||
<div>{{ bluePolar?.cos.toFixed(2) }} {{ bluePolar?.sin.toFixed(2) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
<style scoped>
|
||
.wrap {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.stage {
|
||
position: relative;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.hidden-video {
|
||
display: none;
|
||
/* 仅用于抓帧即可 */
|
||
}
|
||
|
||
.canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
image-rendering: crisp-edges;
|
||
}
|
||
|
||
/* SVG 覆盖层:绝对定位于画布之上 */
|
||
.overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
|
||
/* 低于 HUD,高于画布 */
|
||
}
|
||
|
||
.hud {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
display: grid;
|
||
gap: 8px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
color: #e9eef7;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
z-index: 3;
|
||
/* 确保在 SVG 之上 */
|
||
}
|
||
|
||
.hud .row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.hud .row.small {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
justify-content: flex-start;
|
||
gap: 16px;
|
||
}
|
||
|
||
.label {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.select {
|
||
flex: 1;
|
||
padding: 4px 6px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: #e9eef7;
|
||
}
|
||
|
||
.file-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.file-name {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: #c9d4e5;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.metrics {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px 12px;
|
||
font-size: 10px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.metrics .group h4 {
|
||
margin: 0 0 4px 0;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.err {
|
||
color: #ffb4b4;
|
||
}
|
||
|
||
.btn {
|
||
appearance: none;
|
||
background: #1a5cff;
|
||
color: white;
|
||
border: none;
|
||
padding: 6px 10px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn:hover {
|
||
filter: brightness(1.05);
|
||
}
|
||
|
||
.hint {
|
||
font-size: 12px;
|
||
margin: 0;
|
||
}
|
||
</style> |