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' }, | |
| ]); | |
| function initCharts() { | |
| const speedChartCtx = document.getElementById('speedChart').getContext('2d'); | |
| const accelerationChartCtx = document.getElementById('accelerationChart').getContext('2d'); | |
| speedChart = new Chart(speedChartCtx, { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [{ | |
| label: 'Movement Speed (px/s)', | |
| data: [], | |
| borderColor: 'rgba(75, 192, 192, 1)', | |
| tension: 0.4, | |
| borderWidth: 2, | |
| fill: false | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: { | |
| duration: 0 | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Speed' | |
| } | |
| }, | |
| x: { | |
| title: { | |
| display: true, | |
| text: 'Time' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| accelerationChart = new Chart(accelerationChartCtx, { | |
| type: 'line', | |
| data: { | |
| labels: [], | |
| datasets: [{ | |
| label: 'Acceleration (px/s²)', | |
| data: [], | |
| borderColor: 'rgba(255, 99, 132, 1)', | |
| tension: 0.4, | |
| borderWidth: 2, | |
| fill: false | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: { | |
| duration: 0 | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Acceleration' | |
| } | |
| }, | |
| x: { | |
| title: { | |
| display: true, | |
| text: 'Time' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // 计算姿态变化的速度和加速度 | |
| function calculateMotionMetrics(currentPose, timestamp) { | |
| if (!previousPoseData || !currentPose.poseLandmarks) { | |
| previousPoseData = currentPose; | |
| 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; | |
| currentPose.poseLandmarks.forEach((landmark, index) => { | |
| if (previousPoseData.poseLandmarks[index]) { | |
| const dx = landmark.x - previousPoseData.poseLandmarks[index].x; | |
| const dy = landmark.y - previousPoseData.poseLandmarks[index].y; | |
| const displacement = Math.sqrt(dx * dx + dy * dy); | |
| totalDisplacement += displacement; | |
| validPoints++; | |
| } | |
| }); | |
| const averageDisplacement = validPoints > 0 ? totalDisplacement / validPoints : 0; | |
| const currentSpeed = averageDisplacement / deltaTime; | |
| // 计算加速度 | |
| const previousSpeed = speedChart.data.datasets[0].data[speedChart.data.datasets[0].data.length - 1] || 0; | |
| const acceleration = (currentSpeed - previousSpeed) / deltaTime; | |
| // 更新先前数据 | |
| previousPoseData = currentPose; | |
| lastTimestamp = timestamp; | |
| return { speed: currentSpeed, acceleration: acceleration }; | |
| } | |
| // 更新图表数据 | |
| function updateCharts(metrics) { | |
| const timestamp = new Date().toLocaleTimeString(); | |
| const maxDataPoints = 50; // 限制数据点数量 | |
| // 更新速度图表 | |
| speedChart.data.labels.push(timestamp); | |
| speedChart.data.datasets[0].data.push(metrics.speed); | |
| if (speedChart.data.labels.length > maxDataPoints) { | |
| speedChart.data.labels.shift(); | |
| speedChart.data.datasets[0].data.shift(); | |
| } | |
| speedChart.update('none'); | |
| // 更新加速度图表 | |
| accelerationChart.data.labels.push(timestamp); | |
| accelerationChart.data.datasets[0].data.push(metrics.acceleration); | |
| if (accelerationChart.data.labels.length > maxDataPoints) { | |
| accelerationChart.data.labels.shift(); | |
| accelerationChart.data.datasets[0].data.shift(); | |
| } | |
| accelerationChart.update('none'); | |
| } | |
| 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); | |
| } | |
| // 连接肘部到手部。首先做这个,这样其他图形将绘制在这些标记之上。 | |
| canvasCtx.lineWidth = 5; | |
| if (results.poseLandmarks) { | |
| if (results.rightHandLandmarks) { | |
| canvasCtx.strokeStyle = 'white'; | |
| connect(canvasCtx, [[ | |
| results.poseLandmarks[mpHolistic.POSE_LANDMARKS.RIGHT_ELBOW], | |
| results.rightHandLandmarks[0] | |
| ]]); | |
| } | |
| if (results.leftHandLandmarks) { | |
| canvasCtx.strokeStyle = 'white'; | |
| connect(canvasCtx, [[ | |
| results.poseLandmarks[mpHolistic.POSE_LANDMARKS.LEFT_ELBOW], | |
| results.leftHandLandmarks[0] | |
| ]]); | |
| } | |
| } | |
| // 计算并更新动作指标 | |
| const metrics = calculateMotionMetrics(results, performance.now()); | |
| updateCharts(metrics); | |
| // 姿势... | |
| drawingUtils.drawConnectors(canvasCtx, results.poseLandmarks, mpHolistic.POSE_CONNECTIONS, { color: 'white' }); | |
| drawingUtils.drawLandmarks(canvasCtx, Object.values(mpHolistic.POSE_LANDMARKS_LEFT) | |
| .map(index => results.poseLandmarks[index]), { visibilityMin: 0.65, color: 'white', fillColor: 'rgb(255,138,0)' }); | |
| drawingUtils.drawLandmarks(canvasCtx, Object.values(mpHolistic.POSE_LANDMARKS_RIGHT) | |
| .map(index => results.poseLandmarks[index]), { visibilityMin: 0.65, color: 'white', fillColor: 'rgb(0,217,231)' }); | |
| // 手... | |
| 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); | |
| } | |
| }); | |
| 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); | |
| } | |
| }); | |
| // 面部... | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_TESSELATION, { color: '#C0C0C070', lineWidth: 1 }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_RIGHT_EYE, { color: 'rgb(0,217,231)' }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_RIGHT_EYEBROW, { color: 'rgb(0,217,231)' }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LEFT_EYE, { color: 'rgb(255,138,0)' }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LEFT_EYEBROW, { color: 'rgb(255,138,0)' }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_FACE_OVAL, { color: '#E0E0E0', lineWidth: 5 }); | |
| drawingUtils.drawConnectors(canvasCtx, results.faceLandmarks, mpHolistic.FACEMESH_LIPS, { color: '#E0E0E0', lineWidth: 5 }); | |
| canvasCtx.restore(); | |
| } | |
| // 视频上传处理 | |
| function handleVideoUpload(file) { | |
| const formData = new FormData(); | |
| formData.append('video', file); | |
| // 重置图表数据 | |
| speedChart.data.labels = []; | |
| speedChart.data.datasets[0].data = []; | |
| accelerationChart.data.labels = []; | |
| accelerationChart.data.datasets[0].data = []; | |
| previousPoseData = null; | |
| lastTimestamp = 0; | |
| // 发送视频到服务器 | |
| fetch('/upload_video', { | |
| method: 'POST', | |
| body: formData | |
| }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.success) { | |
| // 更新视频源 | |
| videoElement.src = URL.createObjectURL(file); | |
| videoElement.play(); | |
| } else { | |
| console.error('Video upload failed:', data.error); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Error uploading video:', error); | |
| }); | |
| } | |
| 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(); | |
| }); |