vue简易大模型项目
·
依赖:
{
"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()
更多推荐
所有评论(0)