Spaces:
Running
Running
| import DeviceDetector from "https://cdn.skypack.dev/[email protected]"; | |
| // 用法: testSupport({client?: string, os?: string}[]) | |
| // Client 和 os 是正则表达式。 | |
| // 参见: https://cdn.jsdelivr.net/npm/[email protected]/README.md | |
| // 了解 client 和 os 的合法值 | |
| // 导入必要的库 | |
| // 初始化速度和加速度图表 | |
| let speedChart, accelerationChart; | |
| let previousPoseData = null; | |
| let lastTimestamp = 0; | |
| testSupport([ | |
| { client: 'Chrome' }, | |
| ]); | |
| // 图表相关的常量配置 | |
| const CHART_CONFIG = { | |
| maxDataPoints: 50, | |
| updateInterval: 100, // 图表更新间隔(ms) | |
| colors: { | |
| speed: { | |
| primary: 'rgba(75, 192, 192, 1)', | |
| background: 'rgba(75, 192, 192, 0.1)' | |
| }, | |
| acceleration: { | |
| primary: 'rgba(255, 99, 132, 1)', | |
| background: 'rgba(255, 99, 132, 0.1)' | |
| } | |
| } | |
| }; | |
| // 初始化图表 | |
| function initCharts() { | |
| // 创建图表容器 | |
| const chartsContainer = document.createElement('div'); | |
| chartsContainer.className = 'charts-container'; | |
| document.querySelector('.container').appendChild(chartsContainer); | |
| // 速度图表容器 | |
| const speedChartContainer = document.createElement('div'); | |
| speedChartContainer.className = 'chart-card'; | |
| const speedCanvas = document.createElement('canvas'); | |
| speedCanvas.id = 'speedChart'; | |
| speedChartContainer.appendChild(speedCanvas); | |
| chartsContainer.appendChild(speedChartContainer); | |
| // 加速度图表容器 | |
| const accelerationChartContainer = document.createElement('div'); | |
| accelerationChartContainer.className = 'chart-card'; | |
| const accelerationCanvas = document.createElement('canvas'); | |
| accelerationCanvas.id = 'accelerationChart'; | |
| accelerationChartContainer.appendChild(accelerationCanvas); | |
| chartsContainer.appendChild(accelerationChartContainer); | |
| // 速度图表配置 | |
| speedChart = new Chart(speedCanvas.getContext('2d'), { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [{ | |
| label: '运动速度 (像素/秒)', | |
| data: [], | |
| borderColor: CHART_CONFIG.colors.speed.primary, | |
| backgroundColor: CHART_CONFIG.colors.speed.background, | |
| tension: 0.4, | |
| borderWidth: 2, | |
| fill: true, | |
| pointRadius: 0, | |
| pointHitRadius: 10 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: { | |
| duration: 0 | |
| }, | |
| interaction: { | |
| intersect: false, | |
| mode: 'index' | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'top', | |
| labels: { | |
| font: { | |
| family: '"Titillium Web", sans-serif', | |
| size: 14 | |
| }, | |
| color: '#333' | |
| } | |
| }, | |
| tooltip: { | |
| enabled: true, | |
| backgroundColor: 'rgba(0, 0, 0, 0.7)', | |
| titleFont: { | |
| family: '"Titillium Web", sans-serif' | |
| }, | |
| bodyFont: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| grid: { | |
| color: 'rgba(0, 0, 0, 0.1)' | |
| }, | |
| ticks: { | |
| font: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| }, | |
| x: { | |
| grid: { | |
| display: false | |
| }, | |
| ticks: { | |
| maxRotation: 0, | |
| maxTicksLimit: 5, | |
| font: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // 加速度图表配置 | |
| accelerationChart = new Chart(accelerationCanvas.getContext('2d'), { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [{ | |
| label: '加速度 (像素/秒²)', | |
| data: [], | |
| borderColor: CHART_CONFIG.colors.acceleration.primary, | |
| backgroundColor: CHART_CONFIG.colors.acceleration.background, | |
| tension: 0.4, | |
| borderWidth: 2, | |
| fill: true, | |
| pointRadius: 0, | |
| pointHitRadius: 10 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: { | |
| duration: 0 | |
| }, | |
| interaction: { | |
| intersect: false, | |
| mode: 'index' | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'top', | |
| labels: { | |
| font: { | |
| family: '"Titillium Web", sans-serif', | |
| size: 14 | |
| }, | |
| color: '#333' | |
| } | |
| }, | |
| tooltip: { | |
| enabled: true, | |
| backgroundColor: 'rgba(0, 0, 0, 0.7)', | |
| titleFont: { | |
| family: '"Titillium Web", sans-serif' | |
| }, | |
| bodyFont: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| grid: { | |
| color: 'rgba(0, 0, 0, 0.1)' | |
| }, | |
| ticks: { | |
| font: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| }, | |
| x: { | |
| grid: { | |
| display: false | |
| }, | |
| ticks: { | |
| maxRotation: 0, | |
| maxTicksLimit: 5, | |
| font: { | |
| family: '"Titillium Web", sans-serif' | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // 添加响应式处理 | |
| window.addEventListener('resize', () => { | |
| speedChart.resize(); | |
| accelerationChart.resize(); | |
| }); | |
| } | |
| // 计算姿态变化的速度和加速度 | |
| function calculateMotionMetrics(results, timestamp) { | |
| // 基本验证 | |
| if (!results || !results.poseLandmarks || !Array.isArray(results.poseLandmarks)) { | |
| return { speed: 0, acceleration: 0 }; | |
| } | |
| // 初始化状态 | |
| if (!previousPoseData || !previousPoseData.poseLandmarks) { | |
| previousPoseData = { | |
| poseLandmarks: [...results.poseLandmarks] | |
| }; | |
| lastTimestamp = timestamp; | |
| return { speed: 0, acceleration: 0 }; | |
| } | |
| const deltaTime = (timestamp - lastTimestamp) / 1000; // 转换为秒 | |
| if (deltaTime === 0) { | |
| return { speed: 0, acceleration: 0 }; | |
| } | |
| // 计算关键点的平均位移 | |
| let totalDisplacement = 0; | |
| let validPoints = 0; | |
| try { | |
| // 只使用有效的关键点进行计算 | |
| results.poseLandmarks.forEach((landmark, index) => { | |
| const prevLandmark = previousPoseData.poseLandmarks[index]; | |
| // 确保当前和前一帧的关键点都存在且有效 | |
| if (landmark && prevLandmark && | |
| typeof landmark.x === 'number' && | |
| typeof landmark.y === 'number' && | |
| typeof prevLandmark.x === 'number' && | |
| typeof prevLandmark.y === 'number' && | |
| // 可选:检查可见性阈值 | |
| (!landmark.visibility || landmark.visibility > 0.5) && | |
| (!prevLandmark.visibility || prevLandmark.visibility > 0.5)) { | |
| const dx = landmark.x - prevLandmark.x; | |
| const dy = landmark.y - prevLandmark.y; | |
| const displacement = Math.sqrt(dx * dx + dy * dy); | |
| // 过滤掉异常大的位移 | |
| if (displacement < 1.0) { // 根据需要调整阈值 | |
| totalDisplacement += displacement; | |
| validPoints++; | |
| } | |
| } | |
| }); | |
| } catch (error) { | |
| console.warn('Error calculating displacement:', error); | |
| return { speed: 0, acceleration: 0 }; | |
| } | |
| // 如果没有有效点,返回零值 | |
| if (validPoints === 0) { | |
| return { speed: 0, acceleration: 0 }; | |
| } | |
| // 计算平均位移和速度 | |
| const averageDisplacement = totalDisplacement / validPoints; | |
| const currentSpeed = averageDisplacement / deltaTime; | |
| // 获取上一次的速度,如果不存在则使用0 | |
| let previousSpeed = 0; | |
| try { | |
| previousSpeed = speedChart.data.datasets[0].data[speedChart.data.datasets[0].data.length - 1] || 0; | |
| } catch (error) { | |
| console.warn('Error accessing previous speed:', error); | |
| } | |
| // 计算加速度 | |
| const acceleration = (currentSpeed - previousSpeed) / deltaTime; | |
| // 更新先前数据用于下一帧计算 | |
| previousPoseData = { | |
| poseLandmarks: [...results.poseLandmarks] | |
| }; | |
| lastTimestamp = timestamp; | |
| // 添加一些基本的数值验证 | |
| const metrics = { | |
| speed: isFinite(currentSpeed) ? Math.min(Math.max(currentSpeed, 0), 1000) : 0, | |
| acceleration: isFinite(acceleration) ? Math.min(Math.max(acceleration, -1000), 1000) : 0 | |
| }; | |
| return metrics; | |
| } | |
| // 更新图表数据的函数也需要添加错误处理 | |
| function updateCharts(metrics) { | |
| if (!metrics || typeof metrics.speed !== 'number' || typeof metrics.acceleration !== 'number') { | |
| console.warn('Invalid metrics data:', metrics); | |
| return; | |
| } | |
| try { | |
| const timestamp = new Date().toLocaleTimeString('zh-CN', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit' | |
| }); | |
| // 检查图表对象是否存在且正确初始化 | |
| if (!speedChart || !speedChart.data || !speedChart.data.labels) { | |
| console.warn('Speed chart not properly initialized'); | |
| return; | |
| } | |
| if (!accelerationChart || !accelerationChart.data || !accelerationChart.data.labels) { | |
| console.warn('Acceleration chart not properly initialized'); | |
| return; | |
| } | |
| // 更新速度图表 | |
| speedChart.data.labels.push(timestamp); | |
| speedChart.data.datasets[0].data.push(metrics.speed); | |
| if (speedChart.data.labels.length > CHART_CONFIG.maxDataPoints) { | |
| speedChart.data.labels.shift(); | |
| speedChart.data.datasets[0].data.shift(); | |
| } | |
| // 更新加速度图表 | |
| accelerationChart.data.labels.push(timestamp); | |
| accelerationChart.data.datasets[0].data.push(metrics.acceleration); | |
| if (accelerationChart.data.labels.length > CHART_CONFIG.maxDataPoints) { | |
| accelerationChart.data.labels.shift(); | |
| accelerationChart.data.datasets[0].data.shift(); | |
| } | |
| // 使用 requestAnimationFrame 优化图表更新 | |
| requestAnimationFrame(() => { | |
| try { | |
| speedChart.update('none'); | |
| accelerationChart.update('none'); | |
| } catch (error) { | |
| console.warn('Error updating charts:', error); | |
| } | |
| }); | |
| } catch (error) { | |
| console.warn('Error in updateCharts:', error); | |
| } | |
| } | |
| function testSupport(supportedDevices) { | |
| const deviceDetector = new DeviceDetector(); | |
| const detectedDevice = deviceDetector.parse(navigator.userAgent); | |
| let isSupported = false; | |
| for (const device of supportedDevices) { | |
| if (device.client !== undefined) { | |
| const re = new RegExp(`^${device.client}$`); | |
| if (!re.test(detectedDevice.client.name)) { | |
| continue; | |
| } | |
| } | |
| if (device.os !== undefined) { | |
| const re = new RegExp(`^${device.os}$`); | |
| if (!re.test(detectedDevice.os.name)) { | |
| continue; | |
| } | |
| } | |
| isSupported = true; | |
| break; | |
| } | |
| if (!isSupported) { | |
| alert(`此演示在 ${detectedDevice.client.name}/${detectedDevice.os.name} 上运行时 ` + | |
| `目前不能很好地支持,继续使用需自担风险。`); | |
| } | |
| } | |
| const controls = window; | |
| const mpHolistic = window; | |
| const drawingUtils = window; | |
| const config = { locateFile: (file) => { | |
| return `https://cdn.jsdelivr.net/npm/@mediapipe/holistic@` + | |
| `${mpHolistic.VERSION}/${file}`; | |
| } }; | |
| // 我们的输入帧将来自这里。 | |
| const videoElement = document.getElementsByClassName('input_video')[0]; | |
| const canvasElement = document.getElementsByClassName('output_canvas')[0]; | |
| const controlsElement = document.getElementsByClassName('control-panel')[0]; | |
| const canvasCtx = canvasElement.getContext('2d'); | |
| // 我们稍后会将这个添加到控制面板中,但我们会在这里保存它, | |
| // 以便每次图形运行时都可以调用 tick()。 | |
| const fpsControl = new controls.FPS(); | |
| // 优化:在隐藏动画完成后关闭动画旋转器。 | |
| const spinner = document.querySelector('.loading'); | |
| spinner.ontransitionend = () => { | |
| spinner.style.display = 'none'; | |
| }; | |
| function removeElements(landmarks, elements) { | |
| for (const element of elements) { | |
| delete landmarks[element]; | |
| } | |
| } | |
| function removeLandmarks(results) { | |
| if (results.poseLandmarks) { | |
| removeElements(results.poseLandmarks, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 16, 17, 18, 19, 20, 21, 22]); | |
| } | |
| } | |
| function connect(ctx, connectors) { | |
| const canvas = ctx.canvas; | |
| for (const connector of connectors) { | |
| const from = connector[0]; | |
| const to = connector[1]; | |
| if (from && to) { | |
| if (from.visibility && to.visibility && | |
| (from.visibility < 0.1 || to.visibility < 0.1)) { | |
| continue; | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(from.x * canvas.width, from.y * canvas.height); | |
| ctx.lineTo(to.x * canvas.width, to.y * canvas.height); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| let activeEffect = 'mask'; | |
| function onResults(results) { | |
| // 隐藏旋转器 | |
| document.body.classList.add('loaded'); | |
| // 移除我们不想绘制的关键点 | |
| removeLandmarks(results); | |
| // 更新帧率 | |
| fpsControl.tick(); | |
| // 绘制叠加层 | |
| canvasCtx.save(); | |
| canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); | |
| if (results.segmentationMask) { | |
| canvasCtx.drawImage(results.segmentationMask, 0, 0, canvasElement.width, canvasElement.height); | |
| // 仅覆盖现有像素 | |
| if (activeEffect === 'mask' || activeEffect === 'both') { | |
| canvasCtx.globalCompositeOperation = 'source-in'; | |
| canvasCtx.fillStyle = '#00FF007F'; | |
| canvasCtx.fillRect(0, 0, canvasElement.width, canvasElement.height); | |
| } else { | |
| canvasCtx.globalCompositeOperation = 'source-out'; | |
| canvasCtx.fillStyle = '#0000FF7F'; | |
| canvasCtx.fillRect(0, 0, canvasElement.width, canvasElement.height); | |
| } | |
| // 仅覆盖缺失的像素 | |
| canvasCtx.globalCompositeOperation = 'destination-atop'; | |
| canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height); | |
| canvasCtx.globalCompositeOperation = 'source-over'; | |
| } else { | |
| canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height); | |
| } | |
| // 计算并更新动作指标 | |
| const metrics = calculateMotionMetrics(results, performance.now()); | |
| updateCharts(metrics); | |
| // 添加安全检查 | |
| if (!results.poseLandmarks || !mpHolistic.POSE_LANDMARKS) { | |
| canvasCtx.restore(); | |
| return; | |
| } | |
| // 连接肘部到手部 | |
| canvasCtx.lineWidth = 5; | |
| if (results.poseLandmarks) { | |
| if (results.rightHandLandmarks && results.poseLandmarks[mpHolistic.POSE_LANDMARKS.RIGHT_ELBOW]) { | |
| canvasCtx.strokeStyle = 'white'; | |
| connect(canvasCtx, [[ | |
| results.poseLandmarks[mpHolistic.POSE_LANDMARKS.RIGHT_ELBOW], | |
| results.rightHandLandmarks[0] | |
| ]]); | |
| } | |
| if (results.leftHandLandmarks && results.poseLandmarks[mpHolistic.POSE_LANDMARKS.LEFT_ELBOW]) { | |
| canvasCtx.strokeStyle = 'white'; | |
| connect(canvasCtx, [[ | |
| results.poseLandmarks[mpHolistic.POSE_LANDMARKS.LEFT_ELBOW], | |
| results.leftHandLandmarks[0] | |
| ]]); | |
| } | |
| } | |
| // 绘制姿态连接点 | |
| if (results.poseLandmarks && mpHolistic.POSE_CONNECTIONS) { | |
| drawingUtils.drawConnectors(canvasCtx, results.poseLandmarks, mpHolistic.POSE_CONNECTIONS, { color: 'white' }); | |
| } | |
| // 绘制左侧姿态关键点 | |
| if (results.poseLandmarks && mpHolistic.POSE_LANDMARKS_LEFT) { | |
| const leftLandmarks = Object.values(mpHolistic.POSE_LANDMARKS_LEFT) | |
| .map(index => results.poseLandmarks[index]) | |
| .filter(landmark => landmark !== undefined); | |
| if (leftLandmarks.length > 0) { | |
| drawingUtils.drawLandmarks(canvasCtx, leftLandmarks, | |
| { visibilityMin: 0.65, color: 'white', fillColor: 'rgb(255,138,0)' }); | |
| } | |
| } | |
| // 绘制右侧姿态关键点 | |
| if (results.poseLandmarks && mpHolistic.POSE_LANDMARKS_RIGHT) { | |
| const rightLandmarks = Object.values(mpHolistic.POSE_LANDMARKS_RIGHT) | |
| .map(index => results.poseLandmarks[index]) | |
| .filter(landmark => landmark !== undefined); | |
| if (rightLandmarks.length > 0) { | |
| drawingUtils.drawLandmarks(canvasCtx, rightLandmarks, | |
| { visibilityMin: 0.65, color: 'white', fillColor: 'rgb(0,217,231)' }); | |
| } | |
| } | |
| // 绘制手部 | |
| if (results.rightHandLandmarks && mpHolistic.HAND_CONNECTIONS) { | |
| drawingUtils.drawConnectors(canvasCtx, results.rightHandLandmarks, mpHolistic.HAND_CONNECTIONS, { color: 'white' }); | |
| drawingUtils.drawLandmarks(canvasCtx, results.rightHandLandmarks, { | |
| color: 'white', | |
| fillColor: 'rgb(0,217,231)', | |
| lineWidth: 2, | |
| radius: (data) => { | |
| return drawingUtils.lerp(data.from.z, -0.15, .1, 10, 1); | |
| } | |
| }); | |
| } | |
| if (results.leftHandLandmarks && mpHolistic.HAND_CONNECTIONS) { | |
| drawingUtils.drawConnectors(canvasCtx, results.leftHandLandmarks, mpHolistic.HAND_CONNECTIONS, { color: 'white' }); | |
| drawingUtils.drawLandmarks(canvasCtx, results.leftHandLandmarks, { | |
| color: 'white', | |
| fillColor: 'rgb(255,138,0)', | |
| lineWidth: 2, | |
| radius: (data) => { | |
| return drawingUtils.lerp(data.from.z, -0.15, .1, 10, 1); | |
| } | |
| }); | |
| } | |
| // 绘制面部 | |
| if (results.faceLandmarks && mpHolistic.FACEMESH_TESSELATION) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_TESSELATION, | |
| { color: '#C0C0C070', lineWidth: 1 }); | |
| if (mpHolistic.FACEMESH_RIGHT_EYE) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_RIGHT_EYE, | |
| { color: 'rgb(0,217,231)' }); | |
| } | |
| if (mpHolistic.FACEMESH_RIGHT_EYEBROW) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_RIGHT_EYEBROW, | |
| { color: 'rgb(0,217,231)' }); | |
| } | |
| if (mpHolistic.FACEMESH_LEFT_EYE) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LEFT_EYE, | |
| { color: 'rgb(255,138,0)' }); | |
| } | |
| if (mpHolistic.FACEMESH_LEFT_EYEBROW) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LEFT_EYEBROW, | |
| { color: 'rgb(255,138,0)' }); | |
| } | |
| if (mpHolistic.FACEMESH_FACE_OVAL) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_FACE_OVAL, | |
| { color: '#E0E0E0', lineWidth: 5 }); | |
| } | |
| if (mpHolistic.FACEMESH_LIPS) { | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LIPS, | |
| { color: '#E0E0E0', lineWidth: 5 }); | |
| } | |
| } | |
| canvasCtx.restore(); | |
| } | |
| // 视频上传处理 | |
| function handleVideoUpload(file) { | |
| // 创建视频 URL | |
| const videoUrl = URL.createObjectURL(file); | |
| // 重置图表数据 | |
| speedChart.data.labels = []; | |
| speedChart.data.datasets[0].data = []; | |
| accelerationChart.data.labels = []; | |
| accelerationChart.data.datasets[0].data = []; | |
| previousPoseData = null; | |
| lastTimestamp = 0; | |
| // 重置姿态检测 | |
| holistic.reset(); | |
| // 更新视频源 | |
| videoElement.src = videoUrl; | |
| // 设置视频加载完成后的处理 | |
| videoElement.onloadedmetadata = () => { | |
| // 调整画布大小以匹配视频尺寸 | |
| const aspect = videoElement.videoHeight / videoElement.videoWidth; | |
| let width, height; | |
| if (window.innerWidth > window.innerHeight) { | |
| height = window.innerHeight; | |
| width = height / aspect; | |
| } else { | |
| width = window.innerWidth; | |
| height = width * aspect; | |
| } | |
| canvasElement.width = width; | |
| canvasElement.height = height; | |
| // 创建用于视频处理的动画帧 | |
| let animationId; | |
| async function processFrame() { | |
| if (videoElement.paused || videoElement.ended) { | |
| cancelAnimationFrame(animationId); | |
| return; | |
| } | |
| // 发送当前帧到姿态检测 | |
| await holistic.send({ | |
| image: videoElement | |
| }); | |
| // 继续处理下一帧 | |
| animationId = requestAnimationFrame(processFrame); | |
| } | |
| // 视频播放事件处理 | |
| videoElement.onplay = () => { | |
| processFrame(); | |
| }; | |
| // 添加视频控制按钮事件监听 | |
| const playPauseBtn = document.createElement('button'); | |
| playPauseBtn.textContent = '播放/暂停'; | |
| playPauseBtn.className = 'control-button'; | |
| playPauseBtn.onclick = () => { | |
| if (videoElement.paused) { | |
| videoElement.play(); | |
| } else { | |
| videoElement.pause(); | |
| } | |
| }; | |
| const restartBtn = document.createElement('button'); | |
| restartBtn.textContent = '重新开始'; | |
| restartBtn.className = 'control-button'; | |
| restartBtn.onclick = () => { | |
| videoElement.currentTime = 0; | |
| if (videoElement.paused) { | |
| videoElement.play(); | |
| } | |
| }; | |
| // 添加控制按钮到界面 | |
| const controlsContainer = document.createElement('div'); | |
| controlsContainer.className = 'video-controls'; | |
| controlsContainer.appendChild(playPauseBtn); | |
| controlsContainer.appendChild(restartBtn); | |
| // 找到合适的位置插入控制按钮 | |
| const container = document.querySelector('.container') || document.body; | |
| container.appendChild(controlsContainer); | |
| }; | |
| // 添加错误处理 | |
| videoElement.onerror = () => { | |
| console.error('视频加载失败'); | |
| alert('视频加载失败,请尝试其他视频文件'); | |
| }; | |
| } | |
| // 添加一些基本的样式 | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .video-controls { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 1000; | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .control-button { | |
| padding: 10px 20px; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| } | |
| .control-button:hover { | |
| background-color: rgba(0, 0, 0, 0.9); | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| const holistic = new mpHolistic.Holistic(config); | |
| holistic.onResults(onResults); | |
| // 呈现一个控制面板,用户可以通过它操作解决方案选项。 | |
| new controls | |
| .ControlPanel(controlsElement, { | |
| selfieMode: true, | |
| modelComplexity: 1, | |
| smoothLandmarks: true, | |
| enableSegmentation: false, | |
| smoothSegmentation: true, | |
| minDetectionConfidence: 0.5, | |
| minTrackingConfidence: 0.5, | |
| effect: 'background', | |
| }) | |
| .add([ | |
| new controls.StaticText({ title: 'MediaPipe 全身姿态检测' }), | |
| fpsControl, | |
| new controls.Toggle({ title: '自拍模式', field: 'selfieMode' }), | |
| new controls.SourcePicker({ | |
| onSourceChanged: () => { | |
| // 重置,因为在源更改之间重置时,姿势会给出更好的结果。 | |
| holistic.reset(); | |
| }, | |
| onFrame: async (input, size) => { | |
| const aspect = size.height / size.width; | |
| let width, height; | |
| if (window.innerWidth > window.innerHeight) { | |
| height = window.innerHeight; | |
| width = height / aspect; | |
| } | |
| else { | |
| width = window.innerWidth; | |
| height = width * aspect; | |
| } | |
| canvasElement.width = width; | |
| canvasElement.height = height; | |
| await holistic.send({ image: input }); | |
| }, | |
| }), | |
| new controls.Slider({ | |
| title: '模型复杂度', | |
| field: 'modelComplexity', | |
| discrete: ['轻量', '完整', '重度'], | |
| }), | |
| new controls.Toggle({ title: '平滑关键点', field: 'smoothLandmarks' }), | |
| new controls.Toggle({ title: '启用分割', field: 'enableSegmentation' }), | |
| new controls.Toggle({ title: '平滑分割', field: 'smoothSegmentation' }), | |
| new controls.Slider({ | |
| title: '最小检测置信度', | |
| field: 'minDetectionConfidence', | |
| range: [0, 1], | |
| step: 0.01 | |
| }), | |
| new controls.Slider({ | |
| title: '最小跟踪置信度', | |
| field: 'minTrackingConfidence', | |
| range: [0, 1], | |
| step: 0.01 | |
| }), | |
| new controls.Slider({ | |
| title: '效果', | |
| field: 'effect', | |
| discrete: { 'background': '背景', 'mask': '前景' }, | |
| }), | |
| ]) | |
| .on(x => { | |
| const options = x; | |
| videoElement.classList.toggle('selfie', options.selfieMode); | |
| activeEffect = x['effect']; | |
| holistic.setOptions(options); | |
| }); | |
| // 初始化函数 | |
| function initialize() { | |
| // 初始化图表 | |
| initCharts(); | |
| // 设置视频上传处理 | |
| const videoUploadInput = document.querySelector('#video-upload'); | |
| if (videoUploadInput) { | |
| videoUploadInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| handleVideoUpload(e.target.files[0]); | |
| } | |
| }); | |
| } | |
| // 初始化姿态检测 | |
| const holistic = new mpHolistic.Holistic(config); | |
| holistic.onResults(onResults); | |
| // ... 保持其他原有的初始化逻辑 ... | |
| } | |
| // 启动应用 | |
| window.addEventListener('load', initialize); | |
| // 保持原有的窗口大小调整逻辑 | |
| window.addEventListener('resize', () => { | |
| const aspect = videoElement.videoHeight / videoElement.videoWidth; | |
| let width, height; | |
| if (window.innerWidth > window.innerHeight) { | |
| height = window.innerHeight; | |
| width = height / aspect; | |
| } else { | |
| width = window.innerWidth; | |
| height = width * aspect; | |
| } | |
| canvasElement.width = width; | |
| canvasElement.height = height; | |
| // 重新调整图表大小 | |
| speedChart.resize(); | |
| accelerationChart.resize(); | |
| }); |