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' && // 선택 사항: 가시성(visibility) 값이 일정 기준 이상일 때만 (!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); return { speed: 0, acceleration: 0 }; } // 유효 포인트가 없다면 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); } // 가속도 계산 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('잘못된 metrics 데이터:', metrics); return; } try { const timestamp = new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); // 차트 객체가 제대로 초기화되었는지 확인 if (!speedChart || !speedChart.data || !speedChart.data.labels) { console.warn('속도 차트가 정상적으로 초기화되지 않았습니다.'); return; } if (!accelerationChart || !accelerationChart.data || !accelerationChart.data.labels) { console.warn('가속도 차트가 정상적으로 초기화되지 않았습니다.'); 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); } }); } catch (error) { console.warn('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()이 호출될 때마다 참조할 FPS 컨트롤 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); // FPS 업데이트 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; // 영상을 처리하기 위한 animation frame 생성 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(); });