import DeviceDetector from "https://cdn.skypack.dev/device-detector-js@2.2.10"; // 用法: testSupport({client?: string, os?: string}[]) // Client 和 os 是正则表达式。 // 参见: https://cdn.jsdelivr.net/npm/device-detector-js@2.2.10/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(); });