依赖:

{
  "name": "face-recognition-h5",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "npx vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .eslintignore"
  },
  "dependencies": {
    "@mediapipe/face_detection": "^0.4.1646425229",
    "@mediapipe/hands": "^0.4.1675469240",
    "@mediapipe/pose": "^0.5.1675469404",
    "@tensorflow-models/face-detection": "^1.0.3",
    "@tensorflow-models/hand-pose-detection": "^2.0.1",
    "@tensorflow-models/pose-detection": "^2.1.3",
    "@tensorflow/tfjs": "^4.22.0",
    "@tensorflow/tfjs-backend-webgpu": "^4.22.0",
    "vant": "^4.9.10",
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^8.0.0",
    "@typescript-eslint/parser": "^8.0.0",
    "@vitejs/plugin-vue": "^5.2.0",
    "eslint": "^9.8.0",
    "eslint-plugin-vue": "^9.27.0",
    "sass": "^1.77.8",
    "typescript": "^5.7.2",
    "unplugin-vue-components": "^0.27.4",
    "vite": "^7.3.6",
    "vue-tsc": "^2.2.8"
  }
}

组件:

<script setup lang="ts">
import FaceDetection from './components/FaceDetection.vue'
</script>

<template>
  <div class="app">
    <FaceDetection />
  </div>
</template>

<style scoped>
.app {
  width: 100%;
  height: 100%;
}
</style>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCamera } from '@/composables/useCamera'
import { useFaceDetection } from '@/composables/useFaceDetection'
import { useHandDetection } from '@/composables/useHandDetection'
import { recognizeGesture, GestureType, clearGestureHistory } from '@/utils/gestureRecognizer'
import GestureModal from '@/components/GestureModal.vue'
import GestureEffect from '@/components/GestureEffect.vue'
import { Loading, showToast } from 'vant'

const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const isDetecting = ref(false)
const facesCount = ref(0)
const isModelLoading = ref(true)
const isFaceEnabled = ref(true)
const isHandEnabled = ref(true)

const showImageModal = ref(false)
const showTextModal = ref(false)
const showShakeEffect = ref(false)
const currentGesture = ref<GestureType>('none')
const gestureText = ref('')

const encouragementTexts = [
  '太棒了!继续加油!',
  '你做得很好!',
  '保持自信!',
  '做得漂亮!',
  '你是最棒的!',
  '继续努力!'
]

const { startCamera, stopCamera, error, videoReady, videoDimensions } = useCamera(videoRef)
const { initModel: initFaceModel, detectFaces, stopDetection: stopFaceDetection } = useFaceDetection(videoRef, canvasRef)
const { initModel: initHandModel, detectHands, stopDetection: stopHandDetection } = useHandDetection(videoRef)

let animationId: number | null = null
let detectInterval: number | null = null
const latestFaces = ref<any[]>([])
const latestHands = ref<any[]>([])

const handleGesture = (gesture: GestureType) => {
  currentGesture.value = gesture
  
  switch (gesture) {
    case 'fist':
      if (!showImageModal.value) {
        showImageModal.value = true
        showTextModal.value = false
        showShakeEffect.value = false
      }
      break
    case 'open':
      showImageModal.value = false
      showTextModal.value = false
      showShakeEffect.value = false
      break
    case 'thumbs_up':
      if (!showTextModal.value) {
        showTextModal.value = true
        gestureText.value = encouragementTexts[Math.floor(Math.random() * encouragementTexts.length)]
        showImageModal.value = false
        showShakeEffect.value = false
      }
      break
    case 'shaking':
      if (!showShakeEffect.value) {
        showShakeEffect.value = true
        showImageModal.value = false
        showTextModal.value = false
      }
      break
    case 'none':
      break
  }
}

const startDetection = async () => {
  if (isDetecting.value) return
  
  try {
    isModelLoading.value = true
    
    await nextTick()
    
    const container = canvasRef.value?.parentElement
    if (!container) {
      throw new Error('容器元素不存在')
    }
    
    const containerWidth = container.clientWidth
    const containerHeight = container.clientHeight
    
    console.log('Container dimensions:', containerWidth, 'x', containerHeight)
    
    if (isFaceEnabled.value) {
      await initFaceModel()
    }
    
    if (isHandEnabled.value) {
      await initHandModel()
    }
    
    isModelLoading.value = false
    
    await startCamera(containerWidth, containerHeight)
    if (error.value) {
      showToast({ message: error.value, type: 'fail' })
      return
    }
    
    isDetecting.value = true
    detectionWorker()
    renderLoop()
  } catch (err) {
    console.error('Failed to start detection:', err)
    showToast({ message: '启动失败,请检查相机权限', type: 'fail' })
    isModelLoading.value = false
  }
}

const detectionWorker = async () => {
  while (isDetecting.value) {
    try {
      if (isFaceEnabled.value) {
        const faces = await detectFaces()
        latestFaces.value = faces
        facesCount.value = faces.length
      }
      
      if (isHandEnabled.value) {
        const hands = await detectHands()
        latestHands.value = hands
        
        if (hands.length > 0) {
          const gestures = recognizeGesture(hands)
          if (gestures.length > 0) {
            handleGesture(gestures[0].type)
          }
        } else {
          if (currentGesture.value !== 'none') {
            currentGesture.value = 'none'
          }
        }
      }
    } catch (err) {
      console.error('Detection worker error:', err)
    }
    
    await new Promise(resolve => setTimeout(resolve, 100))
  }
}

const renderLoop = () => {
  if (!isDetecting.value || !videoRef.value || !canvasRef.value || !videoReady.value) {
    animationId = requestAnimationFrame(renderLoop)
    return
  }
  
  const video = videoRef.value
  const canvas = canvasRef.value
  const ctx = canvas.getContext('2d')
  
  if (!ctx) {
    animationId = requestAnimationFrame(renderLoop)
    return
  }
  
  try {
    const container = canvas.parentElement
    if (!container) {
      animationId = requestAnimationFrame(renderLoop)
      return
    }
    
    const containerWidth = container.clientWidth
    const containerHeight = container.clientHeight
    const videoWidth = video.videoWidth
    const videoHeight = video.videoHeight
    
    if (videoWidth === 0 || videoHeight === 0) {
      animationId = requestAnimationFrame(renderLoop)
      return
    }
    
    if (canvas.width !== containerWidth || canvas.height !== containerHeight) {
      canvas.width = containerWidth
      canvas.height = containerHeight
    }
    
    canvas.style.width = containerWidth + 'px'
    canvas.style.height = containerHeight + 'px'
    
    const videoAspect = videoWidth / videoHeight
    const containerAspect = containerWidth / containerHeight
    
    let drawScale = 1
    let drawOffsetX = 0
    let drawOffsetY = 0
    
    if (videoAspect > containerAspect) {
      drawScale = containerWidth / videoWidth
      const scaledHeight = videoHeight * drawScale
      drawOffsetY = (containerHeight - scaledHeight) / 2
    } else {
      drawScale = containerHeight / videoHeight
      const scaledWidth = videoWidth * drawScale
      drawOffsetX = (containerWidth - scaledWidth) / 2
    }
    
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    
    ctx.save()
    ctx.drawImage(video, drawOffsetX, drawOffsetY, videoWidth * drawScale, videoHeight * drawScale)
    ctx.restore()
    
    const transformPoint = (x: number, y: number) => ({
      x: x * drawScale + drawOffsetX,
      y: y * drawScale + drawOffsetY
    })
    
    if (isFaceEnabled.value && latestFaces.value.length > 0) {
      drawFaces(ctx, latestFaces.value, transformPoint)
    }
    
    if (isHandEnabled.value && latestHands.value.length > 0) {
      latestHands.value.forEach(hand => {
        drawHand(ctx!, hand, transformPoint)
      })
    }
  } catch (err) {
    console.error('Render error:', err)
  }
  
  animationId = requestAnimationFrame(renderLoop)
}

const drawFaces = (ctx: CanvasRenderingContext2D, faces: any[], transformPoint: (x: number, y: number) => { x: number; y: number }) => {
  faces.forEach((face, index) => {
    if (!face.box || face.box.width === 0) return
    
    const { xMin, yMin, width: boxWidth, height: boxHeight } = face.box
    
    const colors = ['#1989fa', '#ee0a24', '#07c160', '#ff976a', '#7232dd']
    const color = colors[index % colors.length]
    
    const topLeft = transformPoint(xMin, yMin)
    const bottomRight = transformPoint(xMin + boxWidth, yMin + boxHeight)
    const w = bottomRight.x - topLeft.x
    const h = bottomRight.y - topLeft.y
    
    ctx.strokeStyle = color
    ctx.lineWidth = 3
    ctx.strokeRect(topLeft.x, topLeft.y, w, h)
    
    const label = `人脸 ${index + 1}`
    ctx.fillStyle = color
    ctx.fillRect(topLeft.x, topLeft.y - 24, 60, 20)
    ctx.fillStyle = '#fff'
    ctx.font = '12px sans-serif'
    ctx.fillText(label, topLeft.x + 5, topLeft.y - 8)
    
    if (face.keypoints) {
      face.keypoints.forEach((point: any) => {
        const p = transformPoint(point.x, point.y)
        ctx.fillStyle = '#ee0a24'
        ctx.beginPath()
        ctx.arc(p.x, p.y, 4, 0, Math.PI * 2)
        ctx.fill()
      })
    }
  })
}

const drawHand = (ctx: CanvasRenderingContext2D, hand: any, transformPoint: (x: number, y: number) => { x: number; y: number }) => {
  if (!hand.keypoints) return
  
  const keypoints = hand.keypoints
  
  const connections = [
    ['wrist', 'thumb_cmc'],
    ['thumb_cmc', 'thumb_mcp'],
    ['thumb_mcp', 'thumb_ip'],
    ['thumb_ip', 'thumb_tip'],
    ['wrist', 'index_finger_mcp'],
    ['index_finger_mcp', 'index_finger_pip'],
    ['index_finger_pip', 'index_finger_dip'],
    ['index_finger_dip', 'index_finger_tip'],
    ['wrist', 'middle_finger_mcp'],
    ['middle_finger_mcp', 'middle_finger_pip'],
    ['middle_finger_pip', 'middle_finger_dip'],
    ['middle_finger_dip', 'middle_finger_tip'],
    ['wrist', 'ring_finger_mcp'],
    ['ring_finger_mcp', 'ring_finger_pip'],
    ['ring_finger_pip', 'ring_finger_dip'],
    ['ring_finger_dip', 'ring_finger_tip'],
    ['wrist', 'pinky_finger_mcp'],
    ['pinky_finger_mcp', 'pinky_finger_pip'],
    ['pinky_finger_pip', 'pinky_finger_dip'],
    ['pinky_finger_dip', 'pinky_finger_tip']
  ]
  
  const color = hand.handedness === 'Right' ? '#07c160' : '#ff976a'
  ctx.strokeStyle = color
  ctx.lineWidth = 4
  
  connections.forEach(([from, to]) => {
    const fromPoint = keypoints.find((k: any) => k.name === from)
    const toPoint = keypoints.find((k: any) => k.name === to)
    
    if (fromPoint && toPoint) {
      const fromP = transformPoint(fromPoint.x, fromPoint.y)
      const toP = transformPoint(toPoint.x, toPoint.y)
      ctx.beginPath()
      ctx.moveTo(fromP.x, fromP.y)
      ctx.lineTo(toP.x, toP.y)
      ctx.stroke()
    }
  })
  
  keypoints.forEach((point: any) => {
    const p = transformPoint(point.x, point.y)
    ctx.fillStyle = color
    ctx.beginPath()
    ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
    ctx.fill()
    ctx.fillStyle = '#fff'
    ctx.beginPath()
    ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
    ctx.fill()
  })
  
  const wrist = keypoints.find((k: any) => k.name === 'wrist')
  if (wrist) {
    const p = transformPoint(wrist.x, wrist.y)
    ctx.fillStyle = 'rgba(0,0,0,0.6)'
    ctx.font = '12px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText(hand.handedness, p.x, p.y - 15)
  }
}

const stop = () => {
  isDetecting.value = false
  facesCount.value = 0
  latestFaces.value = []
  latestHands.value = []
  showImageModal.value = false
  showTextModal.value = false
  showShakeEffect.value = false
  currentGesture.value = 'none'
  clearGestureHistory()
  if (animationId) {
    cancelAnimationFrame(animationId)
    animationId = null
  }
  if (detectInterval) {
    clearInterval(detectInterval)
    detectInterval = null
  }
  stopFaceDetection()
  stopHandDetection()
  stopCamera()
}

onMounted(() => {
  startDetection()
})

onUnmounted(() => {
  stop()
})
</script>

<template>
  <div class="face-detection-container">
    <div class="video-wrapper">
      <video
        ref="videoRef"
        class="camera-video"
        autoplay
        playsinline
        muted
      />
      <canvas
        ref="canvasRef"
        class="detection-canvas"
      />
      
      <!-- <div v-if="isModelLoading" class="loading-overlay">
        <Loading type="spinner" size="32" />
        <p class="loading-text">正在加载识别模型...</p>
      </div>
      
      <div v-if="!isModelLoading && !isDetecting" class="status-overlay">
        <p class="status-text">点击开始按钮启动检测</p>
      </div> -->
    </div>
    
    <div class="control-panel">
      <div class="info-row">
        <span class="info-label">检测到人脸:</span>
        <span class="info-value">{{ facesCount }}</span>
      </div>
      
      <div class="toggle-row">
        <label class="toggle-label">
          <input type="checkbox" v-model="isFaceEnabled" :disabled="isModelLoading" />
          <span class="toggle-text">人脸识别</span>
        </label>
        <label class="toggle-label">
          <input type="checkbox" v-model="isHandEnabled" :disabled="isModelLoading" />
          <span class="toggle-text">手势识别</span>
        </label>
      </div>
      
      <div class="button-group">
        <button
          v-if="!isDetecting"
          class="btn btn-primary"
          @click="startDetection"
          :disabled="isModelLoading"
        >
          {{ isModelLoading ? '加载中...' : '开始检测' }}
        </button>
        <button
          v-else
          class="btn btn-danger"
          @click="stop"
        >
          停止检测
        </button>
      </div>
    </div>
    
    <GestureModal
      :visible="showImageModal"
      type="image"
      image-src="/src/assets/yzl.jpg"
      @close="showImageModal = false"
    />
    
    <GestureModal
      :visible="showTextModal"
      type="text"
      :text="gestureText"
      @close="showTextModal = false"
    />
    
    <GestureEffect :visible="showShakeEffect" />
  </div>
</template>

<style scoped>
.face-detection-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #000;
}

.video-wrapper {
  flex: 1;
  position: relative;
  overflow: hidden;
}

.camera-video {
  display: none;
}

.detection-canvas {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
}

.loading-overlay,
.status-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
}

.loading-text,
.status-text {
  color: #fff;
  margin-top: 16px;
  font-size: 14px;
}

.control-panel {
  padding: 16px;
  background-color: #fff;
  border-top-left-radius: 16px;
  border-top-right-radius: 16px;
}

.info-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

.info-label {
  font-size: 14px;
  color: #666;
}

.info-value {
  font-size: 24px;
  font-weight: bold;
  color: #1989fa;
}

.toggle-row {
  display: flex;
  gap: 20px;
  margin-bottom: 16px;
}

.toggle-label {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}

.toggle-label input[type="checkbox"] {
  width: 40px;
  height: 22px;
  appearance: none;
  background-color: #e8e8e8;
  border-radius: 11px;
  position: relative;
  cursor: pointer;
  transition: background-color 0.2s;
}

.toggle-label input[type="checkbox"]::after {
  content: '';
  position: absolute;
  width: 18px;
  height: 18px;
  background-color: #fff;
  border-radius: 50%;
  top: 2px;
  left: 2px;
  transition: transform 0.2s;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.toggle-label input[type="checkbox"]:checked {
  background-color: #1989fa;
}

.toggle-label input[type="checkbox"]:checked::after {
  transform: translateX(18px);
}

.toggle-label input[type="checkbox"]:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toggle-text {
  font-size: 14px;
  color: #333;
}

.button-group {
  display: flex;
  gap: 12px;
}

.btn {
  flex: 1;
  height: 44px;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: opacity 0.2s;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-primary {
  background-color: #1989fa;
  color: #fff;
}

.btn-danger {
  background-color: #ee0a24;
  color: #fff;
}
</style>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'

const props = defineProps<{
  visible: boolean
}>()

const particles = ref<Array<{
  id: number
  x: number
  y: number
  size: number
  color: string
  vx: number
  vy: number
  opacity: number
  rotation: number
  rotationSpeed: number
}>>([])

let animationId: number | null = null
let particleId = 0

const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9']

const createParticle = () => {
  const centerX = window.innerWidth / 2
  const centerY = window.innerHeight / 2
  const angle = Math.random() * Math.PI * 2
  const speed = 3 + Math.random() * 5
  
  particles.value.push({
    id: particleId++,
    x: centerX,
    y: centerY,
    size: 4 + Math.random() * 8,
    color: colors[Math.floor(Math.random() * colors.length)],
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    opacity: 1,
    rotation: Math.random() * 360,
    rotationSpeed: (Math.random() - 0.5) * 10
  })
}

const animate = () => {
  particles.value = particles.value
    .map(p => ({
      ...p,
      x: p.x + p.vx,
      y: p.y + p.vy,
      vx: p.vx * 0.98,
      vy: p.vy * 0.98,
      opacity: p.opacity - 0.015,
      rotation: p.rotation + p.rotationSpeed
    }))
    .filter(p => p.opacity > 0)
  
  if (particles.value.length < 30 && props.visible) {
    createParticle()
    createParticle()
  }
  
  animationId = requestAnimationFrame(animate)
}

watch(() => props.visible, (val) => {
  if (val) {
    for (let i = 0; i < 10; i++) {
      createParticle()
    }
  } else {
    particles.value = []
  }
})

onMounted(() => {
  animate()
})

onUnmounted(() => {
  if (animationId) {
    cancelAnimationFrame(animationId)
  }
})
</script>

<template>
  <div v-if="visible" class="gesture-effect-container">
    <svg class="gesture-effect-svg" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <filter id="glow">
          <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
          <feMerge>
            <feMergeNode in="coloredBlur"/>
            <feMergeNode in="SourceGraphic"/>
          </feMerge>
        </filter>
      </defs>
      
      <g filter="url(#glow)">
        <circle
          v-for="p in particles"
          :key="p.id"
          :cx="p.x"
          :cy="p.y"
          :r="p.size"
          :fill="p.color"
          :opacity="p.opacity"
          :transform="`rotate(${p.rotation} ${p.x} ${p.y})`"
          class="particle"
        />
      </g>
      
      <g class="ring-animation">
        <circle
          cx="50%"
          cy="50%"
          r="60"
          fill="none"
          stroke="rgba(255,255,255,0.3)"
          stroke-width="2"
          class="ring ring-1"
        />
        <circle
          cx="50%"
          cy="50%"
          r="80"
          fill="none"
          stroke="rgba(255,255,255,0.2)"
          stroke-width="2"
          class="ring ring-2"
        />
        <circle
          cx="50%"
          cy="50%"
          r="100"
          fill="none"
          stroke="rgba(255,255,255,0.1)"
          stroke-width="2"
          class="ring ring-3"
        />
      </g>
      
      <g class="sparkles">
        <polygon
          v-for="i in 8"
          :key="i"
          :points="getStarPoints(i)"
          fill="#fff"
          :opacity="0.6"
          class="sparkle"
          :style="{ animationDelay: `${i * 0.1}s` }"
        />
      </g>
    </svg>
  </div>
</template>

<script lang="ts">
function getStarPoints(index: number): string {
  const centerX = window.innerWidth / 2
  const centerY = window.innerHeight / 2
  const radius = 40 + (index % 3) * 20
  const angle = (index * 45 - 90) * (Math.PI / 180)
  const x = centerX + Math.cos(angle) * radius
  const y = centerY + Math.sin(angle) * radius
  return `${x},${y} ${x + 3},${y} ${x + 1.5},${y - 3} ${x + 4.5},${y - 3} ${x + 2},${y - 5}`
}
</script>

<style scoped>
.gesture-effect-container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  pointer-events: none;
  z-index: 999;
}

.gesture-effect-svg {
  width: 100%;
  height: 100%;
}

.particle {
  transition: opacity 0.1s ease;
}

.ring {
  transform-origin: center;
}

.ring-1 {
  animation: ring-pulse 1.5s ease-out infinite;
}

.ring-2 {
  animation: ring-pulse 1.5s ease-out infinite 0.3s;
}

.ring-3 {
  animation: ring-pulse 1.5s ease-out infinite 0.6s;
}

@keyframes ring-pulse {
  0% {
    r: 40;
    opacity: 0.8;
  }
  100% {
    r: 120;
    opacity: 0;
  }
}

.sparkle {
  animation: sparkle-blink 0.5s ease-in-out infinite alternate;
}

@keyframes sparkle-blink {
  0% {
    opacity: 0.3;
    transform: scale(0.8);
  }
  100% {
    opacity: 1;
    transform: scale(1.2);
  }
}
</style>
<script setup lang="ts">
import { watch } from 'vue'

const props = defineProps<{
  visible: boolean
  type: 'image' | 'text'
  imageSrc?: string
  text?: string
}>()

const emit = defineEmits<{
  (e: 'close'): void
}>()

watch(() => props.visible, (val) => {
  if (!val) {
    emit('close')
  }
})
</script>

<template>
  <Transition name="modal">
    <div v-if="visible" class="gesture-modal-overlay" @click="emit('close')">
      <div class="gesture-modal-content" @click.stop>
        <div v-if="type === 'image'" class="modal-image-wrapper">
          <img :src="imageSrc" alt="Gesture Image" class="modal-image" />
          <div class="modal-close-btn" @click="emit('close')">×</div>
        </div>
        <div v-else class="modal-text-wrapper">
          <div class="modal-icon">🎉</div>
          <p class="modal-text">{{ text }}</p>
          <div class="modal-close-btn" @click="emit('close')">×</div>
        </div>
      </div>
    </div>
  </Transition>
</template>

<style scoped>
.gesture-modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  backdrop-filter: blur(5px);
}

.gesture-modal-content {
  position: relative;
  max-width: 80%;
  max-height: 80%;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}

.modal-image-wrapper {
  position: relative;
}

.modal-image {
  width: 100%;
  height: auto;
  display: block;
  border-radius: 16px;
}

.modal-text-wrapper {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  padding: 40px 30px;
  border-radius: 16px;
  text-align: center;
  color: white;
}

.modal-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.modal-text {
  font-size: 20px;
  font-weight: 500;
  line-height: 1.6;
  margin: 0;
}

.modal-close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.9);
  color: #333;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.modal-close-btn:hover {
  background: white;
  transform: scale(1.1);
}

.modal-enter-active,
.modal-leave-active {
  transition: all 0.3s ease;
}

.modal-enter-from,
.modal-leave-to {
  opacity: 0;
}

.modal-enter-from .gesture-modal-content,
.modal-leave-to .gesture-modal-content {
  transform: scale(0.8);
}
</style>

脚本

import { ref } from 'vue'

export function useCamera(videoRef: { value: HTMLVideoElement | null }) {
  const error = ref('')
  const videoReady = ref(false)
  const videoDimensions = ref({ width: 0, height: 0 })
  let stream: MediaStream | null = null

  const waitForVideoReady = (video: HTMLVideoElement): Promise<void> => {
    return new Promise((resolve) => {
      if (video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0) {
        resolve()
        return
      }
      
      const onLoadedData = () => {
        video.removeEventListener('loadeddata', onLoadedData)
        resolve()
      }
      
      const onTimeUpdate = () => {
        if (video.videoWidth > 0 && video.videoHeight > 0) {
          video.removeEventListener('timeupdate', onTimeUpdate)
          resolve()
        }
      }
      
      video.addEventListener('loadeddata', onLoadedData)
      video.addEventListener('timeupdate', onTimeUpdate)
      
      setTimeout(() => {
        video.removeEventListener('loadeddata', onLoadedData)
        video.removeEventListener('timeupdate', onTimeUpdate)
        resolve()
      }, 5000)
    })
  }

  const startCamera = async (containerWidth: number, containerHeight: number) => {
    error.value = ''
    videoReady.value = false
    
    try {
      const constraints: MediaStreamConstraints = {
        video: {
          facingMode: 'user',
          width: { ideal: containerWidth },
          height: { ideal: containerHeight }
        },
        audio: false
      }
      
      stream = await navigator.mediaDevices.getUserMedia(constraints)
      
      if (videoRef.value) {
        videoRef.value.srcObject = stream
        await videoRef.value.play()
        await waitForVideoReady(videoRef.value)
        
        videoDimensions.value = {
          width: videoRef.value.videoWidth,
          height: videoRef.value.videoHeight
        }
        
        console.log('Video ready:', videoDimensions.value.width, 'x', videoDimensions.value.height)
        
        if (videoDimensions.value.width === 0 || videoDimensions.value.height === 0) {
          throw new Error('视频尺寸为0,无法进行检测')
        }
        
        videoReady.value = true
      }
    } catch (err) {
      console.error('Camera error:', err)
      if (err instanceof DOMException) {
        if (err.name === 'NotAllowedError') {
          error.value = '相机权限被拒绝,请在设置中允许访问相机'
        } else if (err.name === 'NotFoundError') {
          error.value = '未检测到相机设备'
        } else if (err.name === 'NotReadableError') {
          error.value = '相机被其他应用占用'
        } else {
          error.value = '无法访问相机: ' + err.message
        }
      } else {
        error.value = err instanceof Error ? err.message : '相机启动失败'
      }
      throw err
    }
  }

  const stopCamera = () => {
    videoReady.value = false
    videoDimensions.value = { width: 0, height: 0 }
    if (stream) {
      stream.getTracks().forEach(track => track.stop())
      stream = null
    }
    if (videoRef.value) {
      videoRef.value.srcObject = null
    }
  }

  return {
    startCamera,
    stopCamera,
    error,
    videoReady,
    videoDimensions
  }
}
import * as tf from '@tensorflow/tfjs'
import { FaceDetection } from '@mediapipe/face_detection'

interface DetectedFace {
  box: {
    xMin: number
    yMin: number
    width: number
    height: number
    xMax: number
    yMax: number
  }
  keypoints: Array<{
    x: number
    y: number
    name: string
  }>
}

export function useFaceDetection(
  videoRef: { value: HTMLVideoElement | null },
  canvasRef: { value: HTMLCanvasElement | null }
) {
  let detector: FaceDetection | null = null
  let ctx: CanvasRenderingContext2D | null = null
  let frameCount = 0

  const initModel = async () => {
    if (detector) return
    
    try {
      await tf.setBackend('webgl')
      
      detector = new FaceDetection({
        locateFile: (file) => {
          return `/models/face_detection/${file}`
        }
      })
      
      detector.setOptions({
        model: 'short',
      })
      
      console.log('Face detection model initialized')
    } catch (err) {
      console.error('Failed to initialize model:', err)
      throw err
    }
  }

  const detectFaces = async (): Promise<DetectedFace[]> => {
    if (!detector || !videoRef.value) return []
    
    const video = videoRef.value
    
    return new Promise((resolve) => {
      detector!.onResults((results) => {
        if (!results.detections || results.detections.length === 0) {
          resolve([])
          return
        }
        
        const width = video.videoWidth
        const height = video.videoHeight
        
        const faces = results.detections.map(detection => {
          const box = detection.boundingBox
          const xMin = (box.xCenter - box.width / 2) * width
          const yMin = (box.yCenter - box.height / 2) * height
          const boxWidth = box.width * width
          const boxHeight = box.height * height
          
          const keypointNames = ['rightEye', 'leftEye', 'noseTip', 'mouthCenter', 'rightEarTragion', 'leftEarTragion']
          
          return {
            box: {
              xMin,
              yMin,
              width: boxWidth,
              height: boxHeight,
              xMax: xMin + boxWidth,
              yMax: yMin + boxHeight
            },
            keypoints: detection.landmarks.map((lm, index) => ({
              x: lm.x * width,
              y: lm.y * height,
              name: keypointNames[index] || ''
            }))
          }
        })
        
        resolve(faces)
      })
      
      detector!.send({ image: video })
    })
  }

  const stopDetection = () => {
    if (detector) {
      detector.close()
      detector = null
    }
  }

  return {
    initModel,
    detectFaces,
    stopDetection
  }
}
import * as tf from '@tensorflow/tfjs'
import { Hands } from '@mediapipe/hands'

interface HandKeypoint {
  x: number
  y: number
  z?: number
  score: number
  name: string
}

interface DetectedHand {
  keypoints: HandKeypoint[]
  score: number
  handedness: string
}

export function useHandDetection(videoRef: { value: HTMLVideoElement | null }) {
  let detector: Hands | null = null

  const initModel = async () => {
    if (detector) return
    
    try {
      await tf.setBackend('webgl')
      
      detector = new Hands({
        locateFile: (file) => {
          return `/models/hands/${file}`
        }
      })
      
      detector.setOptions({
        maxNumHands: 2,
        modelComplexity: 1,
        minDetectionConfidence: 0.5,
        minTrackingConfidence: 0.5
      })
      
      console.log('Hand detection model initialized')
    } catch (err) {
      console.error('Failed to initialize hand model:', err)
      throw err
    }
  }

  const detectHands = async (): Promise<DetectedHand[]> => {
    if (!detector || !videoRef.value) return []
    
    const video = videoRef.value
    
    return new Promise((resolve) => {
      detector!.onResults((results) => {
        if (!results.multiHandLandmarks || results.multiHandLandmarks.length === 0) {
          resolve([])
          return
        }
        
        const width = video.videoWidth
        const height = video.videoHeight
        
        const keypointNames = [
          'wrist',
          'thumb_cmc', 'thumb_mcp', 'thumb_ip', 'thumb_tip',
          'index_finger_mcp', 'index_finger_pip', 'index_finger_dip', 'index_finger_tip',
          'middle_finger_mcp', 'middle_finger_pip', 'middle_finger_dip', 'middle_finger_tip',
          'ring_finger_mcp', 'ring_finger_pip', 'ring_finger_dip', 'ring_finger_tip',
          'pinky_finger_mcp', 'pinky_finger_pip', 'pinky_finger_dip', 'pinky_finger_tip'
        ]
        
        const hands = results.multiHandLandmarks.map((landmarks, index) => ({
          keypoints: landmarks.map((lm, lmIndex) => ({
            x: lm.x * width,
            y: lm.y * height,
            z: lm.z,
            score: lm.visibility || 0,
            name: keypointNames[lmIndex] || ''
          })),
          score: results.multiHandedness[index]?.score || 0,
          handedness: results.multiHandedness[index]?.label || 'Unknown'
        }))
        
        resolve(hands)
      })
      
      detector!.send({ image: video })
    })
  }

  const stopDetection = () => {
    if (detector) {
      detector.close()
      detector = null
    }
  }

  return {
    initModel,
    detectHands,
    stopDetection
  }
}
import * as tf from '@tensorflow/tfjs'
import { Pose } from '@mediapipe/pose'

interface PoseKeypoint {
  x: number
  y: number
  z?: number
  score: number
  name: string
}

interface DetectedPose {
  keypoints: PoseKeypoint[]
  score: number
}

export function usePoseDetection(videoRef: { value: HTMLVideoElement | null }) {
  let detector: Pose | null = null

  const initModel = async () => {
    if (detector) return
    
    try {
      await tf.setBackend('webgl')
      
      detector = new Pose({
        locateFile: (file) => {
          return `/models/pose/${file}`
        }
      })
      
      detector.setOptions({
        modelComplexity: 1,
        smoothLandmarks: true,
        enableSegmentation: false,
        smoothSegmentation: false,
        minDetectionConfidence: 0.5,
        minTrackingConfidence: 0.5
      })
      
      console.log('Pose detection model initialized')
    } catch (err) {
      console.error('Failed to initialize pose model:', err)
      throw err
    }
  }

  const detectPoses = async (): Promise<DetectedPose[]> => {
    if (!detector || !videoRef.value) return []
    
    const video = videoRef.value
    
    return new Promise((resolve) => {
      detector!.onResults((results) => {
        if (!results.poseLandmarks || results.poseLandmarks.length === 0) {
          resolve([])
          return
        }
        
        const width = video.videoWidth
        const height = video.videoHeight
        
        const names = [
          'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',
          'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',
          'left_wrist', 'right_wrist', 'left_hip', 'right_hip',
          'left_knee', 'right_knee', 'left_ankle', 'right_ankle'
        ]
        
        const keypoints = results.poseLandmarks.map((lm, index) => ({
          x: lm.x * width,
          y: lm.y * height,
          z: lm.z,
          score: lm.visibility || 0,
          name: names[index] || ''
        }))
        
        resolve([{
          keypoints,
          score: results.poseScore || 0
        }])
      })
      
      detector!.send({ image: video })
    })
  }

  const stopDetection = () => {
    if (detector) {
      detector.close()
      detector = null
    }
  }

  return {
    initModel,
    detectPoses,
    stopDetection
  }
}

手势算法:

export type GestureType = 'none' | 'fist' | 'open' | 'thumbs_up' | 'shaking'

export interface GestureResult {
  type: GestureType
  confidence: number
  handIndex: number
}

interface Keypoint {
  x: number
  y: number
  name: string
}

interface HandData {
  keypoints: Keypoint[]
  handedness: string
}

const getFingerTip = (keypoints: Keypoint[], finger: string): Keypoint | null => {
  return keypoints.find(k => k.name === `${finger}_tip`) || null
}

const getFingerPip = (keypoints: Keypoint[], finger: string): Keypoint | null => {
  return keypoints.find(k => k.name === `${finger}_pip`) || null
}

const getFingerMcp = (keypoints: Keypoint[], finger: string): Keypoint | null => {
  return keypoints.find(k => k.name === `${finger}_mcp`) || null
}

const getThumbTip = (keypoints: Keypoint[]): Keypoint | null => {
  return keypoints.find(k => k.name === 'thumb_tip') || null
}

const getThumbIp = (keypoints: Keypoint[]): Keypoint | null => {
  return keypoints.find(k => k.name === 'thumb_ip') || null
}

const getThumbMcp = (keypoints: Keypoint[]): Keypoint | null => {
  return keypoints.find(k => k.name === 'thumb_mcp') || null
}

const isFingerExtended = (keypoints: Keypoint[], finger: string): boolean => {
  const tip = getFingerTip(keypoints, finger)
  const pip = getFingerPip(keypoints, finger)
  
  if (!tip || !pip) return false
  
  return tip.y < pip.y
}

const getWrist = (keypoints: Keypoint[]): Keypoint | null => {
  return keypoints.find(k => k.name === 'wrist') || null
}

const isThumbExtended = (keypoints: Keypoint[], handedness: string): boolean => {
  const tip = getThumbTip(keypoints)
  const mcp = getThumbMcp(keypoints)
  
  if (!tip || !mcp) return false
  
  if (handedness === 'Right') {
    return tip.x > mcp.x
  } else {
    return tip.x < mcp.x
  }
}

const isPalmFacingCamera = (keypoints: Keypoint[]): boolean => {
  const wrist = getWrist(keypoints)
  const indexTip = getFingerTip(keypoints, 'index_finger')
  const middleTip = getFingerTip(keypoints, 'middle_finger')
  
  if (!wrist || !indexTip || !middleTip) return false
  
  const wristZ = wrist.z || 0
  const avgFingerZ = ((indexTip.z || 0) + (middleTip.z || 0)) / 2
  
  return wristZ < avgFingerZ
}

const isFist = (keypoints: Keypoint[], handedness: string): boolean => {
  const fingers = ['index_finger', 'middle_finger', 'ring_finger', 'pinky_finger']
  
  const allFingersBent = fingers.every(finger => !isFingerExtended(keypoints, finger))
  const thumbBent = !isThumbExtended(keypoints, handedness)
  const palmFacing = isPalmFacingCamera(keypoints)
  
  return allFingersBent && thumbBent && palmFacing
}

const isOpenHand = (keypoints: Keypoint[], handedness: string): boolean => {
  const fingers = ['index_finger', 'middle_finger', 'ring_finger', 'pinky_finger']
  
  const allFingersExtended = fingers.every(finger => isFingerExtended(keypoints, finger))
  const thumbExtended = isThumbExtended(keypoints, handedness)
  
  return allFingersExtended && thumbExtended
}

const isThumbsUp = (keypoints: Keypoint[], handedness: string): boolean => {
  const fingers = ['index_finger', 'middle_finger', 'ring_finger', 'pinky_finger']
  
  const allFingersBent = fingers.every(finger => !isFingerExtended(keypoints, finger))
  const thumbExtended = isThumbExtended(keypoints, handedness)
  
  return allFingersBent && thumbExtended
}

let lastPositions: Map<number, { x: number; y: number; time: number }> = new Map()

const isShaking = (keypoints: Keypoint[], handIndex: number): boolean => {
  const tip = getFingerTip(keypoints, 'index_finger') || getThumbTip(keypoints)
  
  if (!tip) return false
  
  const now = Date.now()
  const lastPos = lastPositions.get(handIndex)
  
  if (!lastPos) {
    lastPositions.set(handIndex, { x: tip.x, y: tip.y, time: now })
    return false
  }
  
  const deltaTime = now - lastPos.time
  if (deltaTime < 50) return false
  
  const dx = Math.abs(tip.x - lastPos.x)
  const dy = Math.abs(tip.y - lastPos.y)
  const distance = Math.sqrt(dx * dx + dy * dy)
  
  const speed = distance / deltaTime
  
  lastPositions.set(handIndex, { x: tip.x, y: tip.y, time: now })
  
  return speed > 0.5
}

export const recognizeGesture = (hands: HandData[]): GestureResult[] => {
  const results: GestureResult[] = []
  
  hands.forEach((hand, index) => {
    if (!hand.keypoints || hand.keypoints.length === 0) return
    
    if (isShaking(hand.keypoints, index)) {
      results.push({ type: 'shaking', confidence: 0.9, handIndex: index })
      return
    }
    
    if (isFist(hand.keypoints, hand.handedness)) {
      results.push({ type: 'fist', confidence: 0.95, handIndex: index })
      return
    }
    
    if (isThumbsUp(hand.keypoints, hand.handedness)) {
      results.push({ type: 'thumbs_up', confidence: 0.9, handIndex: index })
      return
    }
    
    if (isOpenHand(hand.keypoints, hand.handedness)) {
      results.push({ type: 'open', confidence: 0.95, handIndex: index })
      return
    }
    
    results.push({ type: 'none', confidence: 1, handIndex: index })
  })
  
  return results
}

export const clearGestureHistory = () => {
  lastPositions.clear()
}
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const modelsDir = path.join(__dirname, '../public/models')

const models = [
  {
    name: 'face_detection',
    url: 'https://storage.googleapis.com/tfhub-lite-models/mediapipe/tfjs-model/face_detection/lite/1/default/1/model.json'
  },
  {
    name: 'pose',
    url: 'https://storage.googleapis.com/tfhub-lite-models/google/movenet/singlepose/lightning/tflite/float16/4/model.json'
  }
]

async function downloadFile(url, dest) {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Failed to download ${url}: ${response.status}`)
  }
  const blob = await response.blob()
  const arrayBuffer = await blob.arrayBuffer()
  const buffer = Buffer.from(arrayBuffer)
  fs.writeFileSync(dest, buffer)
  console.log(`Downloaded: ${dest}`)
}

async function downloadModel(model) {
  const modelDir = path.join(modelsDir, model.name)
  if (!fs.existsSync(modelDir)) {
    fs.mkdirSync(modelDir, { recursive: true })
  }
  
  const modelJsonPath = path.join(modelDir, 'model.json')
  console.log(`Downloading ${model.name} model...`)
  
  try {
    await downloadFile(model.url, modelJsonPath)
    
    const modelJson = JSON.parse(fs.readFileSync(modelJsonPath, 'utf-8'))
    
    const weights = modelJson.weights || []
    for (const weight of weights) {
      const weightUrl = new URL(weight.path, model.url).href
      const weightPath = path.join(modelDir, weight.path)
      
      const weightDir = path.dirname(weightPath)
      if (!fs.existsSync(weightDir)) {
        fs.mkdirSync(weightDir, { recursive: true })
      }
      
      await downloadFile(weightUrl, weightPath)
    }
    
    console.log(`Successfully downloaded ${model.name} model!`)
  } catch (err) {
    console.error(`Failed to download ${model.name} model:`, err)
  }
}

async function main() {
  if (!fs.existsSync(modelsDir)) {
    fs.mkdirSync(modelsDir, { recursive: true })
  }
  
  for (const model of models) {
    await downloadModel(model)
  }
}

main()

更多推荐