机器视觉VUE3手势识别+手势检测控制相机缩放
本项目成功实现了基于 MediaPipe 的手势识别和缩放控制功能,通过双手食指距离变化和单手张开程度两种方式实现了直观的视频缩放交互。系统具有以下特点:
1. **双模式缩放控制**:支持双手距离变化和单手张开程度两种缩放方式
2. **高兼容性**:实现了主备方案切换机制,确保在不同环境下的稳定运行
3. **优化的性能**:通过检测频率控制、平滑过渡等技术确保流畅的用户体验
4. **友好的界面**:提供了清晰的状态显示和操作指导
通过深入理解和优化这些技术点,可以进一步提升手势识别的准确性和交互的流畅性,为用户提供更加自然和便捷的操作体验。
本项目采用 **MediaPipe** 作为核心的手势识别引擎,结合 Vue 框架实现了实时手势检测和基于手势的视频缩放控制功能。系统主要分为以下几个核心模块:
1. **MediaPipe 初始化与加载模块**
2. **摄像头控制模块**
3. **手势识别核心模块**
4. **手势缩放控制模块**
5. **界面展示与交互模块**
<template><div class="gesture-recognition-container"><div class="header"><h1>MediaPipe 手势识别与缩放控制</h1><div class="instruction"><p>使用说明:</p><ul><li>点击按钮启用/禁用摄像头和手势识别</li><li>双手食指间距变化控制缩放</li><li>单手张开/闭合控制缩放</li></ul></div></div><div id="liveView" class="videoView"><button id="webcamButton" class="mdc-button mdc-button--raised" @click="toggleWebcam"><span class="mdc-button__ripple"></span><span class="mdc-button__label">{{ webcamRunning ? '关闭摄像头' : '开启摄像头' }}</span></button><div class="status-info"><div class="zoom-level">当前缩放级别: {{ (zoomLevel * 100).toFixed(0) }}%</div><div class="gesture-status" :class="{ detected: isGestureDetected }">{{ gestureStatusText }}</div></div><div style="position: relative;"><video id="webcam" style="position: absolute" autoplay playsinline ref="videoRef"></video><canvas class="output_canvas" id="output_canvas" style="position: absolute; left: 0px; top: 0px;" ref="canvasRef"></canvas><!-- 手势提示覆盖层 --><div class="gesture-guide" v-if="webcamRunning && !isGestureDetected"><p>请将手放在摄像头前</p></div></div></div></div>
</template><script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';// 常量定义
const MEDIAPIPE_VERSION = '0.10.0';
const MODEL_ASSET_PATH = 'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task';
const WASM_PATH = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`;
const FALLBACK_HANDS_VERSION = '0.4.1675469240';// 默认手部连接点配置
const DEFAULT_HAND_CONNECTIONS = [[0, 1], [1, 2], [2, 3], [3, 4], // 拇指[0, 5], [5, 6], [6, 7], [7, 8], // 食指[0, 9], [9, 10], [10, 11], [11, 12], // 中指[0, 13], [13, 14], [14, 15], [15, 16], // 无名指[0, 17], [17, 18], [18, 19], [19, 20] // 小指
];// 全局变量,用于存储MediaPipe组件
let HandLandmarker = null;
let FilesetResolver = null;// 模板引用
const videoRef = ref(null);
const canvasRef = ref(null);// 状态变量
const handLandmarker = ref(null);
let runningMode = 'IMAGE';
const webcamRunning = ref(false);
let lastVideoTime = -1;
let results = undefined;
let animationFrameId = null;
let detectionCounter = 0; // 用于控制检测频率// 缩放相关变量
const zoomLevel = ref(1.0); // 当前缩放级别
const minZoom = 0.5; // 最小缩放级别
const maxZoom = 2.0; // 最大缩放级别
const zoomSensitivity = ref(0.1); // 缩放灵敏度
const zoomSmoothFactor = 0.1; // 缩放平滑因子
let previousDistance = null; // 上一次手指间距离
let isScaling = false; // 是否正在缩放// 手势检测状态
const isGestureDetected = ref(false);
const lastGestureTime = ref(0);// 计算属性 - 手势状态文本
const gestureStatusText = computed(() => {if (!webcamRunning.value) return '未启用摄像头';if (isGestureDetected.value) return '已检测到手势';return '未检测到手势';
});// 组件挂载时初始化
onMounted(async () => {console.log('开始初始化MediaPipe手势识别模块...');await initializeMediaPipe();
});// 初始化MediaPipe的统一入口函数
const initializeMediaPipe = async () => {try {// 尝试主方法加载const mainLoadSuccess = await loadMediaPipe();if (mainLoadSuccess && HandLandmarker && FilesetResolver) {const landmarkerCreated = await createHandLandmarker();if (landmarkerCreated) {console.log('MediaPipe手势识别器初始化成功');return;}}// 如果主方法失败,尝试备选方案console.log('主方法失败,尝试备选方案...');await tryFallbackMethod();// 最终检查if (handLandmarker.value) {console.log('MediaPipe手势识别器通过备选方案初始化成功');} else {console.error('MediaPipe手势识别器初始化失败');showError('手势识别初始化失败,请刷新页面重试');}} catch (error) {console.error('MediaPipe初始化异常:', error);showError(`初始化失败: ${error.message}`);}
};// 显示错误提示
const showError = (message) => {alert(`错误: ${message}`);
};// 组件卸载时清理资源
onBeforeUnmount(() => {stopWebcam();if (animationFrameId) {cancelAnimationFrame(animationFrameId);}
});// 加载MediaPipe库(主方法)
const loadMediaPipe = async () => {try {// 检查window上是否已经通过CDN加载了MediaPipeif (window.MediaPipeTasks) {console.log('检测到已通过CDN加载的MediaPipe');HandLandmarker = window.MediaPipeTasks.vision.HandLandmarker;FilesetResolver = window.MediaPipeTasks.vision.FilesetResolver;} else {// 尝试动态导入console.log(`尝试动态导入MediaPipe模块 v${MEDIAPIPE_VERSION}`);const module = await import(`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}`);HandLandmarker = module.HandLandmarker;FilesetResolver = module.FilesetResolver;}return HandLandmarker && FilesetResolver;} catch (error) {console.error('加载MediaPipe库失败:', error);return false;}
};// 备选初始化方法(使用window.Hands)
const loadMediaPipeFallback = async () => {try {console.log('尝试使用备选方案加载MediaPipe Hands');// 检查window上是否已存在Hands对象if (window.Hands) {console.log('检测到已加载的Hands对象');// 创建一个兼容接口的手势识别器handLandmarker.value = createFallbackHandLandmarker();return true;} else {console.error('备选方案失败:window.Hands未定义');return false;}} catch (error) {console.error('备选方案初始化失败:', error);return false;}
};// 创建备选的手势识别器
const createFallbackHandLandmarker = () => {return {detect: async function(image) {try {const hands = new window.Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands@${FALLBACK_HANDS_VERSION}/${file}`});hands.setOptions({maxNumHands: 2,modelComplexity: 1,minDetectionConfidence: 0.5,minTrackingConfidence: 0.5});// 创建canvas用于处理图像const canvas = document.createElement('canvas');canvas.width = image.width || image.videoWidth;canvas.height = image.height || image.videoHeight;const ctx = canvas.getContext('2d');// 绘制图像到canvasctx.drawImage(image, 0, 0, canvas.width, canvas.height);// 获取图像数据const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);// 等待检测结果return new Promise((resolve) => {hands.onResults((results) => {// 转换结果格式以匹配主方法的返回格式const convertedResults = {landmarks: results.multiHandLandmarks ? results.multiHandLandmarks.map(landmarkList => landmarkList.map(point => ({x: point.x,y: point.y,z: point.z || 0}))) : []};resolve(convertedResults);});// 发送图像数据进行检测hands.send({ image: imageData });});} catch (error) {console.error('备选方案检测错误:', error);return { landmarks: [] };}},detectForVideo: async function(video, timestamp) {try {// 对于视频模式,复用detect方法return await this.detect(video);} catch (error) {console.error('备选方案视频检测错误:', error);return { landmarks: [] };}},setOptions: async function(options) {// 简单实现setOptions方法以保持接口一致console.log('设置备选方案选项:', options);}};
};// 尝试备选方法的统一处理函数
const tryFallbackMethod = async () => {try {// 检查是否已经尝试过备选方案if (window.hasTriedFallback) {console.log('已经尝试过备选方案,不再重复尝试');return;}window.hasTriedFallback = true;// 尝试备选初始化方法const fallbackSuccess = await loadMediaPipeFallback();if (!fallbackSuccess) {console.error('备选方案初始化失败');return;}// 确保HAND_CONNECTIONS可用ensureDrawingUtils();} catch (error) {console.error('备选方法处理失败:', error);}
};// 确保绘图工具函数可用
const ensureDrawingUtils = () => {// 设置默认手部连接点if (!window.HAND_CONNECTIONS) {window.HAND_CONNECTIONS = DEFAULT_HAND_CONNECTIONS;}// 提供默认的drawConnectors函数if (typeof window.drawConnectors !== 'function') {window.drawConnectors = createDefaultDrawConnectors();}// 提供默认的drawLandmarks函数if (typeof window.drawLandmarks !== 'function') {window.drawLandmarks = createDefaultDrawLandmarks();}
};// 创建默认的drawConnectors函数
const createDefaultDrawConnectors = () => {return (canvasCtx, landmarks, connections, options) => {try {canvasCtx.save();canvasCtx.strokeStyle = options.color || '#00FF00';canvasCtx.lineWidth = options.lineWidth || 5;canvasCtx.lineCap = 'round';connections.forEach(([startIdx, endIdx]) => {if (landmarks[startIdx] && landmarks[endIdx]) {canvasCtx.beginPath();canvasCtx.moveTo(landmarks[startIdx].x * canvasCtx.canvas.width, landmarks[startIdx].y * canvasCtx.canvas.height);canvasCtx.lineTo(landmarks[endIdx].x * canvasCtx.canvas.width, landmarks[endIdx].y * canvasCtx.canvas.height);canvasCtx.stroke();}});canvasCtx.restore();} catch (error) {console.error('drawConnectors错误:', error);}};
};// 创建默认的drawLandmarks函数
const createDefaultDrawLandmarks = () => {return (canvasCtx, landmarks, options) => {try {canvasCtx.save();canvasCtx.fillStyle = options.color || '#FF0000';canvasCtx.strokeStyle = options.strokeColor || '#FFFFFF';canvasCtx.lineWidth = options.lineWidth || 1;const radius = options.radius || 3;landmarks.forEach((landmark) => {canvasCtx.beginPath();canvasCtx.arc(landmark.x * canvasCtx.canvas.width, landmark.y * canvasCtx.canvas.height, radius, 0, 2 * Math.PI);canvasCtx.fill();canvasCtx.stroke();});canvasCtx.restore();} catch (error) {console.error('drawLandmarks错误:', error);}};
};// 创建手势识别器
const createHandLandmarker = async () => {try {if (!HandLandmarker || !FilesetResolver) {throw new Error('MediaPipe模块未正确加载');}const vision = await FilesetResolver.forVisionTasks(WASM_PATH);handLandmarker.value = await HandLandmarker.createFromOptions(vision, {baseOptions: {modelAssetPath: MODEL_ASSET_PATH,delegate: 'GPU'},runningMode: runningMode,numHands: 2,minHandDetectionConfidence: 0.7,minHandPresenceConfidence: 0.7,minTrackingConfidence: 0.7});return handLandmarker.value !== null;} catch (error) {console.error('创建HandLandmarker失败:', error);return false;}
};// 处理图片点击事件
const handleImageClick = async (event) => {if (!handLandmarker.value) {console.log('请等待HandLandmarker加载完成后再点击!');return;}// 确保运行模式为IMAGEif (runningMode === 'VIDEO') {runningMode = 'IMAGE';await handLandmarker.value.setOptions({ runningMode: 'IMAGE' });}// 移除之前绘制的地标const imageContainer = event.target.parentNode;const existingCanvases = imageContainer.querySelectorAll('.canvas');existingCanvases.forEach(canvas => canvas.remove());try {// 检测图像中的手部地标const result = handLandmarker.value.detect(event.target);console.log('检测结果:', result);// 创建并配置画布const canvas = document.createElement('canvas');canvas.className = 'canvas';canvas.width = event.target.naturalWidth;canvas.height = event.target.naturalHeight;canvas.style = `left: 0px;top: 0px;width: ${event.target.width}px;height: ${event.target.height}px;position: absolute;pointer-events: none;`;// 添加画布到图片容器imageContainer.appendChild(canvas);const ctx = canvas.getContext('2d');// 绘制手部地标if (result.landmarks) {for (const landmarks of result.landmarks) {window.drawConnectors(ctx, landmarks, window.HAND_CONNECTIONS, {color: '#00FF00',lineWidth: 5});window.drawLandmarks(ctx, landmarks, { color: '#FF0000', lineWidth: 1 });}}} catch (error) {console.error('处理图片点击时出错:', error);}
};// 切换摄像头状态
const toggleWebcam = async () => {if (!handLandmarker.value) {showError('请等待手势识别器加载完成!');return;}if (webcamRunning.value) {stopWebcam();} else {const success = await startWebcam();if (!success) {showError('启动摄像头失败,请检查摄像头权限');}}
};// 启动摄像头
const startWebcam = async () => {try {// 确保 getUserMedia 可用if (!navigator.mediaDevices?.getUserMedia) {throw new Error('您的浏览器不支持摄像头功能');}// 切换到视频模式runningMode = 'VIDEO';await handLandmarker.value.setOptions({ runningMode: 'VIDEO' });// 获取用户媒体设备权限 - 使用更具体的视频约束const stream = await navigator.mediaDevices.getUserMedia({video: {width: { ideal: 1280 },height: { ideal: 720 },facingMode: 'user'}});// 设置视频流if (videoRef.value) {videoRef.value.srcObject = stream;webcamRunning.value = true;// 重置状态previousDistance = null;isScaling = false;isGestureDetected.value = false;zoomLevel.value = 1.0;// 开始预测await predictWebcam();return true;}return false;} catch (error) {console.error('启动摄像头失败:', error);return false;}
};// 停止摄像头
const stopWebcam = () => {if (videoRef.value && videoRef.value.srcObject) {videoRef.value.srcObject.getTracks().forEach(track => track.stop());videoRef.value.srcObject = null;}webcamRunning.value = false;if (animationFrameId) {cancelAnimationFrame(animationFrameId);}
};// 持续从摄像头获取帧并进行检测
const predictWebcam = async () => {if (!webcamRunning.value || !videoRef.value || !canvasRef.value || !handLandmarker.value) {return;}try {// 设置画布尺寸const video = videoRef.value;const canvasElement = canvasRef.value;const canvasCtx = canvasElement.getContext('2d');// 确保视频元数据已加载if (video.videoWidth === 0 || video.videoHeight === 0) {setTimeout(predictWebcam, 100);return;}// 只在必要时更新画布尺寸if (canvasElement.width !== video.videoWidth || canvasElement.height !== video.videoHeight) {canvasElement.style.width = video.videoWidth;canvasElement.style.height = video.videoHeight;canvasElement.width = video.videoWidth;canvasElement.height = video.videoHeight;}// 获取当前时间并检测 - 控制检测频率const startTimeMs = performance.now();if (lastVideoTime !== video.currentTime) {lastVideoTime = video.currentTime;// 降低检测频率以提高性能 (每3帧检测一次)detectionCounter++;if (detectionCounter % 3 === 0) {results = await handLandmarker.value.detectForVideo(video, startTimeMs);// 更新手势检测状态updateGestureDetectionStatus(results);}}// 绘制结果canvasCtx.save();canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);// 直接绘制视频帧到canvas上canvasCtx.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);// 检查results是否存在以及其结构if (results && results.landmarks) {// 检测手势并调整缩放级别handleGestureScaling(results.landmarks, canvasCtx, canvasElement);// 使用统一的方式绘制地标drawHandLandmarks(canvasCtx, results.landmarks, canvasElement);}canvasCtx.restore();} catch (error) {console.warn('处理视频帧时出错:', error);}// 继续下一帧if (webcamRunning.value) {animationFrameId = requestAnimationFrame(predictWebcam);}
};// 更新手势检测状态
const updateGestureDetectionStatus = (results) => {if (results && results.landmarks && results.landmarks.length > 0) {isGestureDetected.value = true;lastGestureTime.value = Date.now();} else {// 延迟一小段时间再将状态设为未检测到,避免频繁切换if (Date.now() - lastGestureTime.value > 300) {isGestureDetected.value = false;}}
};// 绘制手部地标
const drawHandLandmarks = (canvasCtx, landmarksList, canvasElement) => {for (let i = 0; i < landmarksList.length; i++) {const landmarks = landmarksList[i];// 确保landmarks是数组if (!Array.isArray(landmarks)) continue;try {// 绘制连接线if (window.HAND_CONNECTIONS && typeof window.drawConnectors === 'function') {window.drawConnectors(canvasCtx, landmarks, window.HAND_CONNECTIONS, {color: '#00FF00',lineWidth: 5});} else {// 备选绘制方式drawConnectionsFallback(canvasCtx, landmarks, canvasElement);}// 绘制点if (typeof window.drawLandmarks === 'function') {window.drawLandmarks(canvasCtx, landmarks, {color: '#FF0000',lineWidth: 2,radius: 3});} else {// 备选绘制方式drawLandmarksFallback(canvasCtx, landmarks, canvasElement);}} catch (error) {console.error('绘制手部地标时出错:', error);}}
};// 备选的连接线绘制方法
const drawConnectionsFallback = (canvasCtx, landmarks, canvasElement) => {canvasCtx.strokeStyle = '#00FF00';canvasCtx.lineWidth = 5;canvasCtx.lineCap = 'round';const connections = window.HAND_CONNECTIONS || DEFAULT_HAND_CONNECTIONS;for (const connection of connections) {const startPoint = landmarks[connection[0]];const endPoint = landmarks[connection[1]];if (startPoint && endPoint && typeof startPoint.x === 'number' && typeof startPoint.y === 'number') {canvasCtx.beginPath();canvasCtx.moveTo(startPoint.x * canvasElement.width, startPoint.y * canvasElement.height);canvasCtx.lineTo(endPoint.x * canvasElement.width, endPoint.y * canvasElement.height);canvasCtx.stroke();}}
};// 备选的点绘制方法
const drawLandmarksFallback = (canvasCtx, landmarks, canvasElement) => {for (const point of landmarks) {if (point && typeof point.x === 'number' && typeof point.y === 'number') {canvasCtx.beginPath();canvasCtx.arc(point.x * canvasElement.width, point.y * canvasElement.height, 3, 0, 2 * Math.PI);canvasCtx.fillStyle = '#FF0000';canvasCtx.fill();canvasCtx.strokeStyle = '#FFFFFF';canvasCtx.lineWidth = 1;canvasCtx.stroke();}}
};// 处理手势缩放
const handleGestureScaling = (landmarks, canvasCtx, canvasElement) => {try {// 如果检测到两只手,使用两只手的距离来控制缩放if (landmarks.length >= 2) {handleTwoHandScaling(landmarks[0], landmarks[1]);} else if (landmarks.length === 1) {// 单手势缩放控制handleOneHandScaling(landmarks[0]);} else {// 没有检测到手,重置缩放状态resetScalingState(canvasCtx, canvasElement);}} catch (error) {console.error('处理手势缩放时出错:', error);}
};// 处理双手缩放
const handleTwoHandScaling = (firstHand, secondHand) => {// 确保手部地标有效if (!Array.isArray(firstHand) || firstHand.length < 9 || !Array.isArray(secondHand) || secondHand.length < 9) {return;}// 获取两只手的食指指尖位置(索引8)const firstIndexTip = firstHand[8];const secondIndexTip = secondHand[8];// 计算两只手食指间的欧几里得距离if (!firstIndexTip || !secondIndexTip || typeof firstIndexTip.x !== 'number' || typeof firstIndexTip.y !== 'number' ||typeof secondIndexTip.x !== 'number' || typeof secondIndexTip.y !== 'number') {return;}const distance = Math.sqrt(Math.pow(firstIndexTip.x - secondIndexTip.x, 2) + Math.pow(firstIndexTip.y - secondIndexTip.y, 2));// 如果是第一次计算距离,初始化previousDistanceif (previousDistance === null) {previousDistance = distance;return;}// 计算距离变化,并根据变化调整缩放级别const distanceChange = distance - previousDistance;const zoomChange = distanceChange * zoomSensitivity.value;// 更新缩放级别,但保持在限制范围内const newZoomLevel = Math.max(minZoom, Math.min(maxZoom, zoomLevel.value + zoomChange));// 只有当变化足够大时才更新缩放级别if (Math.abs(newZoomLevel - zoomLevel.value) > 0.01) {zoomLevel.value = newZoomLevel;isScaling = true;// 应用缩放效果到视频显示applyZoomToVideo();}// 更新上一次距离previousDistance = distance;
};// 处理单手缩放
const handleOneHandScaling = (hand) => {if (!Array.isArray(hand) || hand.length < 21) {return;}// 计算手指张开程度(使用拇指指尖和小指指尖的距离)const thumbTip = hand[4];const pinkyTip = hand[20];if (!thumbTip || !pinkyTip || typeof thumbTip.x !== 'number' || typeof thumbTip.y !== 'number' ||typeof pinkyTip.x !== 'number' || typeof pinkyTip.y !== 'number') {return;}const handOpenDistance = Math.sqrt(Math.pow(thumbTip.x - pinkyTip.x, 2) + Math.pow(thumbTip.y - pinkyTip.y, 2));// 归一化手势距离(0.1-0.3是典型的手张开范围)const normalizedDistance = Math.max(0, Math.min(1, (handOpenDistance - 0.1) / (0.3 - 0.1)));// 映射到缩放级别范围const targetZoom = minZoom + (maxZoom - minZoom) * normalizedDistance;// 平滑过渡到目标缩放级别zoomLevel.value = zoomLevel.value * (1 - zoomSmoothFactor) + targetZoom * zoomSmoothFactor;isScaling = true;// 应用缩放效果到视频显示applyZoomToVideo();
};// 重置缩放状态
const resetScalingState = (canvasCtx, canvasElement) => {previousDistance = null;if (isScaling) {isScaling = false;// 绘制缩放提示if (canvasCtx && canvasElement) {drawZoomIndicator(canvasCtx, canvasElement);}}
};// 应用缩放到视频显示
const applyZoomToVideo = () => {try {if (!videoRef.value || !canvasRef.value || !canvasRef.value.getContext) {return;}// 获取视频和画布元素const video = videoRef.value;const canvasElement = canvasRef.value;const canvasCtx = canvasElement.getContext('2d');// 确保视频和画布有效if (!video.videoWidth || !video.videoHeight) {return;}// 计算缩放后的显示区域const scaledWidth = canvasElement.width * (1 / zoomLevel.value);const scaledHeight = canvasElement.height * (1 / zoomLevel.value);const offsetX = (canvasElement.width - scaledWidth) / 2;const offsetY = (canvasElement.height - scaledHeight) / 2;// 清除画布canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);// 绘制缩放后的视频帧canvasCtx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, offsetX, offsetY, scaledWidth, scaledHeight);} catch (error) {console.error('应用缩放时出错:', error);}
};// 绘制缩放指示器
const drawZoomIndicator = (canvasCtx, canvasElement) => {try {// 保存当前绘图状态canvasCtx.save();// 设置指示器样式canvasCtx.fillStyle = 'rgba(0, 127, 139, 0.8)';canvasCtx.strokeStyle = '#f1f3f4';canvasCtx.lineWidth = 2;canvasCtx.font = '16px Arial';canvasCtx.textAlign = 'center';canvasCtx.textBaseline = 'middle';// 计算指示器位置和尺寸const indicatorWidth = 120;const indicatorHeight = 40;const indicatorX = canvasElement.width - indicatorWidth - 20;const indicatorY = 20;// 绘制指示器背景canvasCtx.beginPath();canvasCtx.roundRect(indicatorX, indicatorY, indicatorWidth, indicatorHeight, 8);canvasCtx.fill();canvasCtx.stroke();// 绘制缩放级别文本canvasCtx.fillStyle = '#f1f3f4';canvasCtx.fillText(`缩放: ${(zoomLevel.value * 100).toFixed(0)}%`, indicatorX + indicatorWidth / 2, indicatorY + indicatorHeight / 2);// 恢复绘图状态canvasCtx.restore();} catch (error) {console.error('绘制缩放指示器时出错:', error);}
};
</script><style scoped>
/* 导入Material Design样式 */
@import url('https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css');.gesture-recognition-container {font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;margin: 0;padding: 20px;color: #333;background-color: #f5f5f5;--mdc-theme-primary: #007f8b;--mdc-theme-on-primary: #f1f3f4;min-height: 100vh;
}.header {margin-bottom: 30px;text-align: center;
}.header h1 {color: #007f8b;margin-bottom: 15px;font-size: 28px;font-weight: 600;
}.instruction {background-color: #fff;padding: 15px 25px;border-radius: 8px;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);max-width: 600px;margin: 0 auto;
}.instruction p {font-weight: 600;margin-bottom: 10px;color: #007f8b;
}.instruction ul {text-align: left;margin: 0;padding-left: 20px;
}.instruction li {margin-bottom: 5px;line-height: 1.5;
}.videoView {position: relative;max-width: 800px;margin: 0 auto;background-color: #fff;padding: 20px;border-radius: 12px;box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}#webcamButton {width: 100%;max-width: 300px;margin: 0 auto 20px;display: block;padding: 12px 24px;font-size: 16px;font-weight: 600;background-color: #007f8b;color: white;border: none;border-radius: 8px;cursor: pointer;transition: all 0.3s ease;
}#webcamButton:hover {background-color: #005f6b;transform: translateY(-1px);box-shadow: 0 4px 12px rgba(0, 127, 139, 0.3);
}.status-info {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;padding: 10px 15px;background-color: #f0f8ff;border-radius: 8px;
}.zoom-level {font-weight: 600;color: #007f8b;font-size: 16px;
}.gesture-status {font-size: 14px;padding: 5px 12px;border-radius: 16px;background-color: #e0e0e0;color: #666;transition: all 0.3s ease;
}.gesture-status.detected {background-color: #d4edda;color: #155724;font-weight: 600;
}video {clear: both;display: block;transform: rotateY(180deg);-webkit-transform: rotateY(180deg);-moz-transform: rotateY(180deg);width: 100%;max-width: 100%;border-radius: 8px;
}.output_canvas {transform: rotateY(180deg);-webkit-transform: rotateY(180deg);-moz-transform: rotateY(180deg);width: 100%;max-width: 100%;border-radius: 8px;
}.gesture-guide {position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);background-color: rgba(0, 127, 139, 0.8);color: white;padding: 20px 40px;border-radius: 12px;font-size: 18px;font-weight: 600;text-align: center;z-index: 10;animation: pulse 2s infinite;
}@keyframes pulse {0% {opacity: 0.8;transform: translate(-50%, -50%) scale(1);}50% {opacity: 1;transform: translate(-50%, -50%) scale(1.05);}100% {opacity: 0.8;transform: translate(-50%, -50%) scale(1);}
}/* 响应式设计 */
@media (max-width: 768px) {.gesture-recognition-container {padding: 10px;}.header h1 {font-size: 24px;}.videoView {padding: 15px;}.status-info {flex-direction: column;gap: 10px;text-align: center;}.gesture-guide {font-size: 16px;padding: 15px 30px;}
}
</style>
引入相关库文件