2025-09-23 12:59:26 +08:00

1079 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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) 反解角度(直接用于 SVGy 轴向下时无需额外翻转)
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>