@ -0,0 +1,43 @@ | |||
import { Hands } from '@mediapipe/hands'; | |||
import { Camera } from '@mediapipe/camera_utils'; | |||
const videoElement = document.createElement('video'); | |||
videoElement.style.display = 'none'; | |||
document.body.appendChild(videoElement); | |||
const hands = new Hands({ | |||
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`, | |||
}); | |||
hands.setOptions({ | |||
maxNumHands: 1, | |||
modelComplexity: 1, | |||
minDetectionConfidence: 0.7, | |||
minTrackingConfidence: 0.7, | |||
}); | |||
hands.onResults((results) => { | |||
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |||
const landmarks = results.multiHandLandmarks[0]; | |||
// 检测食指是否伸出:tip高于pip(y坐标更小) | |||
const tip = landmarks[8]; // 食指指尖 | |||
const pip = landmarks[6]; // 第二个关节 | |||
if (tip.y < pip.y) { | |||
console.log('✅ 食指伸出'); | |||
} else { | |||
console.log('❌ 食指未伸出'); | |||
} | |||
} | |||
}); | |||
const camera = new Camera(videoElement, { | |||
onFrame: async () => { | |||
await hands.send({ image: videoElement }); | |||
}, | |||
width: 640, | |||
height: 480, | |||
}); | |||
camera.start(); |
@ -0,0 +1,6 @@ | |||
{ | |||
"name": "wavecontrol", | |||
"lockfileVersion": 3, | |||
"requires": true, | |||
"packages": {} | |||
} |
@ -0,0 +1,31 @@ | |||
<script setup lang="ts"> | |||
import { Power } from "@icon-park/vue-next"; | |||
import { disable, enable } from "@tauri-apps/plugin-autostart"; | |||
import { watch } from "vue"; | |||
import { use_app_store } from "@/store/app"; | |||
const app_store = use_app_store(); | |||
watch( | |||
() => app_store.config.auto_start, | |||
async (value) => { | |||
if (value) { | |||
await enable(); | |||
} else { | |||
await disable(); | |||
} | |||
} | |||
); | |||
</script> | |||
<template> | |||
<n-space align="center" style="display: flex; align-items: center"> | |||
<span style="display: flex; align-items: center"> | |||
<n-icon size="20" style="margin-right: 8px"> | |||
<Power /> | |||
</n-icon> | |||
<span>{{ $t("开机自启动") }}</span> | |||
</span> | |||
<n-switch v-model:value="app_store.config.auto_start" /> | |||
</n-space> | |||
</template> |
@ -0,0 +1,77 @@ | |||
<template> | |||
<div class="circle-progress"> | |||
<svg :width="size" :height="size" viewBox="0 0 100 100"> | |||
<!-- 背景圆环 --> | |||
<circle | |||
cx="50" | |||
cy="50" | |||
:r="radius" | |||
fill="none" | |||
:stroke="backgroundColor" | |||
:stroke-width="strokeWidth" | |||
/> | |||
<!-- 进度圆环 --> | |||
<circle | |||
cx="50" | |||
cy="50" | |||
:r="radius" | |||
fill="none" | |||
:stroke="color" | |||
:stroke-width="strokeWidth" | |||
:stroke-dasharray="circumference" | |||
:stroke-dashoffset="dashOffset" | |||
class="progress" | |||
/> | |||
<!-- 中心文本 --> | |||
<slot> | |||
<text x="50" y="50" text-anchor="middle" dominant-baseline="middle" class="progress-text"> | |||
{{ text }} | |||
{{ percentage }}% | |||
</text> | |||
</slot> | |||
</svg> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { computed } from 'vue'; | |||
interface Props { | |||
percentage: number; | |||
size?: number; | |||
strokeWidth?: number; | |||
color?: string; | |||
backgroundColor?: string; | |||
text?: string; | |||
} | |||
const props = withDefaults(defineProps<Props>(), { | |||
size: 100, | |||
strokeWidth: 6, | |||
color: '#409eff', | |||
backgroundColor: '#e5e9f2', | |||
text: '' | |||
}); | |||
const radius = computed(() => 50 - props.strokeWidth / 2); | |||
const circumference = computed(() => 2 * Math.PI * radius.value); | |||
const dashOffset = computed(() => | |||
circumference.value * (1 - props.percentage / 100) | |||
); | |||
</script> | |||
<style lang="scss" scoped> | |||
.circle-progress { | |||
display: inline-block; | |||
.progress { | |||
transform: rotate(-90deg); | |||
transform-origin: center; | |||
} | |||
.progress-text { | |||
font-size: 14px; | |||
fill: #606266; | |||
} | |||
} | |||
</style> |
@ -0,0 +1,81 @@ | |||
<template> | |||
<div | |||
v-if="is_dev" | |||
class="dev-tool" | |||
:class="{ 'dev-tool--expanded': isExpanded }" | |||
> | |||
<div class="dev-tool__toggle" @click="toggleToolbox"> | |||
<span class="dev-tool__icon">🔧</span> | |||
</div> | |||
<!-- <div class="dev-tool__content" v-if="isExpanded"> | |||
<div class="dev-tool__item" @click="createSubWindowClick">创建子窗口</div> | |||
</div> --> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { createSubWindow } from "@/utils/subWindow"; | |||
import { ref } from "vue"; | |||
const is_dev = import.meta.env.DEV; | |||
const isExpanded = ref(false); | |||
const toggleToolbox = () => { | |||
isExpanded.value = !isExpanded.value; | |||
}; | |||
const createSubWindowClick = () => { | |||
createSubWindow("/sub-window", "subWindow"); | |||
}; | |||
</script> | |||
<style scoped lang="scss"> | |||
.dev-tool { | |||
position: fixed; | |||
top: 50%; | |||
left: 0; | |||
transform: translateY(-50%); | |||
background-color: #fff; | |||
border-radius: 0 8px 8px 0; | |||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |||
transition: all 0.3s ease; | |||
z-index: 9999; | |||
&--expanded { | |||
width: 200px; | |||
} | |||
&__toggle { | |||
padding: 10px; | |||
cursor: pointer; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
border-bottom: 1px solid #eee; | |||
} | |||
&__icon { | |||
font-size: 20px; | |||
} | |||
&__content { | |||
padding: 10px; | |||
} | |||
&__item { | |||
padding: 8px 12px; | |||
cursor: pointer; | |||
border-radius: 4px; | |||
margin-bottom: 5px; | |||
transition: background-color 0.2s ease; | |||
&:hover { | |||
background-color: #f5f5f5; | |||
} | |||
&:last-child { | |||
margin-bottom: 0; | |||
} | |||
} | |||
} | |||
</style> |
@ -0,0 +1,82 @@ | |||
<template> | |||
<n-card class="gesture-card" :bordered="false"> | |||
<n-space align="center" class="gesture-content"> | |||
<div class="gesture-icon" :class="{ 'double-hand': isDoubleHand }"> | |||
<slot name="icon"></slot> | |||
</div> | |||
<div class="gesture-info"> | |||
<h3>{{ title }}</h3> | |||
<p>{{ description }}</p> | |||
<slot name="extra"></slot> | |||
</div> | |||
</n-space> | |||
</n-card> | |||
</template> | |||
<script setup lang="ts"> | |||
defineProps<{ | |||
title: string; | |||
description: string; | |||
isDoubleHand?: boolean; | |||
}>(); | |||
</script> | |||
<style scoped lang="scss"> | |||
.gesture-card { | |||
transition: all 0.3s ease; | |||
background: linear-gradient(145deg, #f8faff, #ffffff); | |||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); | |||
border: 1px solid #e5e9f2; | |||
border-radius: 12px; | |||
&:hover { | |||
transform: translateY(-2px); | |||
box-shadow: 0 8px 24px rgba(64, 152, 252, 0.15); | |||
border-color: #4098fc; | |||
} | |||
} | |||
.gesture-content { | |||
padding: 12px; | |||
} | |||
.gesture-icon { | |||
background: rgba(64, 152, 252, 0.1); | |||
padding: 16px; | |||
border-radius: 12px; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
border: 1px solid rgba(64, 152, 252, 0.2); | |||
} | |||
.gesture-info { | |||
flex: 1; | |||
h3 { | |||
margin: 0 0 4px 0; | |||
font-size: 1.1rem; | |||
color: #2c3e50; | |||
} | |||
p { | |||
margin: 0; | |||
color: #666; | |||
font-size: 0.9rem; | |||
} | |||
} | |||
.double-hand { | |||
display: flex; | |||
gap: 8px; | |||
:deep(svg) { | |||
width: 32px; | |||
height: 32px; | |||
} | |||
} | |||
.flipped { | |||
transform: scaleX(-1); | |||
} | |||
</style> |
@ -0,0 +1,23 @@ | |||
<template> | |||
<component | |||
:is="icon" | |||
theme="outline" | |||
size="40" | |||
fill="#4098fc" | |||
:stroke-width="3" | |||
:class="{ flipped }" | |||
/> | |||
</template> | |||
<script setup lang="ts"> | |||
defineProps<{ | |||
icon: any; | |||
flipped?: boolean; | |||
}>(); | |||
</script> | |||
<style scoped> | |||
.flipped { | |||
transform: scaleX(-1); | |||
} | |||
</style> |
@ -0,0 +1,41 @@ | |||
<script setup lang="ts"> | |||
import type { MenuOption } from "naive-ui"; | |||
import { NMenu } from "naive-ui"; | |||
import { ref } from "vue"; | |||
import { useRouter } from "vue-router"; | |||
const router = useRouter(); | |||
const menuOptions: MenuOption[] = [ | |||
{ | |||
label: "首页", | |||
key: "/", | |||
}, | |||
{ | |||
label: "操作指南", | |||
key: "/guide", | |||
}, | |||
// { | |||
// label: "开发", | |||
// key: "/update", | |||
// } | |||
]; | |||
const activeKey = ref("/"); | |||
const handleUpdateValue = (key: string) => { | |||
activeKey.value = key; | |||
router.push(key); | |||
}; | |||
</script> | |||
<template> | |||
<n-menu | |||
:options="menuOptions" | |||
v-model:value="activeKey" | |||
mode="vertical" | |||
@update:value="handleUpdateValue" | |||
/> | |||
</template> | |||
<style scoped></style> |
@ -0,0 +1,24 @@ | |||
import { onMounted, ref } from 'vue'; | |||
import { Detector } from './hand_landmark/detector'; | |||
const videoRef = ref<HTMLVideoElement | null>(null); | |||
const detector = new Detector(); | |||
onMounted(async () => { | |||
await detector.initialize(); // 初始化模型 | |||
const video = videoRef.value!; | |||
video.width = 640; | |||
video.height = 480; | |||
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => { | |||
video.srcObject = stream; | |||
video.play(); | |||
// 每隔 200ms 检测一帧 | |||
setInterval(async () => { | |||
const result = await detector.detect(video); | |||
await detector.process(result); | |||
}, 200); | |||
}); | |||
}); |
@ -0,0 +1,183 @@ | |||
import { | |||
FilesetResolver, | |||
GestureRecognizer, | |||
} from "@mediapipe/tasks-vision"; | |||
// 手势 | |||
export enum HandGesture { | |||
// 食指举起,移动鼠标 | |||
ONLY_INDEX_UP = "only_index_up", | |||
} | |||
interface HandLandmark { | |||
x: number; | |||
y: number; | |||
z: number; | |||
} | |||
export interface HandInfo { | |||
landmarks: HandLandmark[]; | |||
handedness: "Left" | "Right"; | |||
score: number; | |||
categoryName?: string; | |||
} | |||
interface DetectionResult { | |||
leftHand?: HandInfo; | |||
rightHand?: HandInfo; | |||
// 原始检测结果,以防需要访问其他数据 | |||
rawResult: any; | |||
} | |||
/** | |||
* 检测器类 - 负责手势识别和手势分类 | |||
* 主要职责: | |||
* 1. 初始化 | |||
* 2. 检测视频帧中的手部 | |||
* 3. 分析手势类型(手指竖起等) | |||
* 4. 提供手部关键点查询方法 | |||
*/ | |||
export class Detector { | |||
private detector: GestureRecognizer | null = null; | |||
constructor(private modelPath = "/mediapipe/gesture_recognizer.task") {} | |||
async initialize(useCanvas = false) { | |||
const vision = await FilesetResolver.forVisionTasks("/mediapipe/wasm"); | |||
try { | |||
const params: any = { | |||
baseOptions: { | |||
modelAssetPath: this.modelPath, | |||
delegate: "GPU", | |||
}, | |||
runningMode: "VIDEO", | |||
numHands: 1, | |||
}; | |||
if (useCanvas) { | |||
params.canvas = document.createElement("canvas"); | |||
} | |||
this.detector = await GestureRecognizer.createFromOptions(vision, params); | |||
} catch (error: any) { | |||
if (error.toString().includes("kGpuService")) { | |||
await this.initialize(true); | |||
} else { | |||
throw error; | |||
} | |||
} | |||
} | |||
// 检测视频帧中的手部(保持独立) | |||
async detect(video: HTMLVideoElement): Promise<DetectionResult> { | |||
if (!this.detector) { | |||
throw new Error("检测器未初始化"); | |||
} | |||
const now = performance.now(); | |||
const result = await this.detector.recognizeForVideo(video, now); | |||
const detection: DetectionResult = { | |||
rawResult: result, | |||
}; | |||
if (result.landmarks && result.handedness) { | |||
for (let i = 0; i < result.landmarks.length; i++) { | |||
const hand: HandInfo = { | |||
landmarks: result.landmarks[i], | |||
handedness: result.handedness[i][0].categoryName as "Left" | "Right", | |||
score: result.handedness[i][0].score, | |||
}; | |||
if (result.gestures.length > 0) { | |||
hand.categoryName = result.gestures[0][0].categoryName; | |||
} | |||
if (hand.handedness === "Left") { | |||
detection.leftHand = hand; | |||
} else { | |||
detection.rightHand = hand; | |||
} | |||
} | |||
} | |||
return detection; | |||
} | |||
/** | |||
* 检测手指是否竖起 | |||
*/ | |||
static _fingersUp(hand: HandInfo): number[] { | |||
const fingers: number[] = []; | |||
const tipIds = [4, 8, 12, 16, 20]; // 从大拇指开始,依次为每个手指指尖 | |||
// 检测大拇指 | |||
if (hand.handedness === "Right") { | |||
if (hand.landmarks[tipIds[0]].x < hand.landmarks[tipIds[0] - 1].x) { | |||
fingers.push(0); | |||
} else { | |||
fingers.push(1); | |||
} | |||
} else { | |||
if (hand.landmarks[tipIds[0]].x > hand.landmarks[tipIds[0] - 1].x) { | |||
fingers.push(0); | |||
} else { | |||
fingers.push(1); | |||
} | |||
} | |||
// 检测其他四个手指 | |||
for (let id = 1; id < 5; id++) { | |||
if (hand.landmarks[tipIds[id]].y < hand.landmarks[tipIds[id] - 2].y) { | |||
fingers.push(1); | |||
} else { | |||
fingers.push(0); | |||
} | |||
} | |||
return fingers; | |||
} | |||
/** | |||
* 获取单个手的手势类型 | |||
*/ | |||
public static getSingleHandGesture(hand: HandInfo): HandGesture { | |||
const fingers = this._fingersUp(hand); | |||
const fingerState = fingers.join(","); | |||
// 定义手势映射表 | |||
const gestureMap = new Map<string, HandGesture>([ | |||
// 食指举起,移动鼠标 | |||
["0,1,0,0,0", HandGesture.ONLY_INDEX_UP], | |||
]); | |||
if (gestureMap.has(fingerState)) { | |||
return gestureMap.get(fingerState) as HandGesture; | |||
} | |||
// 返回默认值 | |||
return HandGesture.OTHER; | |||
} | |||
/** | |||
* 处理检测结果并执行相应动作 | |||
*/ | |||
async process(detection: DetectionResult): Promise<HandGesture> { | |||
const hand = detection.rightHand ?? detection.leftHand; | |||
if (!hand) { | |||
console.log("❌ 没检测到手"); | |||
return HandGesture.OTHER; | |||
} | |||
const gesture = Detector.getSingleHandGesture(hand); | |||
console.log("🎯 当前手势状态是:", gesture); | |||
if (gesture === HandGesture.ONLY_INDEX_UP) { | |||
console.log("✅ 识别到食指竖起!"); | |||
} | |||
return gesture; | |||
} | |||
} |
@ -0,0 +1,21 @@ | |||
// gesture_handler.ts | |||
import { HandGesture, HandInfo } from "./detector"; | |||
export class GestureHandler { | |||
private previousGesture: HandGesture | null = null; | |||
private previousGestureCount = 0; | |||
private minGestureCount = 3; | |||
handleGesture(gesture: HandGesture, hand: HandInfo) { | |||
if (gesture === this.previousGesture) { | |||
this.previousGestureCount++; | |||
} else { | |||
this.previousGesture = gesture; | |||
this.previousGestureCount = 1; | |||
} | |||
if (this.previousGestureCount >= this.minGestureCount) { | |||
console.log("识别手势类型:", gesture); | |||
} | |||
} | |||
} |
@ -0,0 +1,24 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
pnpm-debug.log* | |||
lerna-debug.log* | |||
node_modules | |||
dist | |||
dist-ssr | |||
*.local | |||
# Editor directories and files | |||
.vscode/* | |||
!.vscode/extensions.json | |||
.idea | |||
.DS_Store | |||
*.suo | |||
*.ntvs* | |||
*.njsproj | |||
*.sln | |||
*.sw? |
@ -0,0 +1,3 @@ | |||
{ | |||
"recommendations": ["Vue.volar"] | |||
} |
@ -0,0 +1,5 @@ | |||
# Vue 3 + TypeScript + Vite | |||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. | |||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). |
@ -0,0 +1,13 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
<title>Vite + Vue + TS</title> | |||
</head> | |||
<body> | |||
<div id="app"></div> | |||
<script type="module" src="/src/main.ts"></script> | |||
</body> | |||
</html> |
@ -0,0 +1,27 @@ | |||
{ | |||
"name": "wavecontrol-test", | |||
"private": true, | |||
"version": "0.0.0", | |||
"type": "module", | |||
"scripts": { | |||
"dev": "vite", | |||
"build": "vue-tsc -b && vite build", | |||
"preview": "vite preview" | |||
}, | |||
"dependencies": { | |||
"@mediapipe/tasks-vision": "^0.10.22-rc.20250304", | |||
"@tensorflow-models/handpose": "^0.1.0", | |||
"@tensorflow/tfjs-backend-webgl": "^4.22.0", | |||
"@tensorflow/tfjs-core": "^4.22.0", | |||
"fingerpose": "^0.1.0", | |||
"vue": "^3.5.17" | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^24.0.12", | |||
"@vitejs/plugin-vue": "^6.0.0", | |||
"@vue/tsconfig": "^0.7.0", | |||
"typescript": "~5.8.3", | |||
"vite": "^7.0.3", | |||
"vue-tsc": "^2.2.12" | |||
} | |||
} |
@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> |
@ -0,0 +1,29 @@ | |||
<template> | |||
<video ref="videoRef" autoplay playsinline muted style="display: none;" /> | |||
</template> | |||
<script setup lang="ts"> | |||
import { onMounted, ref } from 'vue'; | |||
import { Detector } from './hand_landmark/detector'; | |||
const videoRef = ref<HTMLVideoElement | null>(null); | |||
const detector = new Detector(); | |||
onMounted(async () => { | |||
await detector.initialize(); | |||
const video = videoRef.value!; | |||
video.width = 640; | |||
video.height = 480; | |||
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => { | |||
video.srcObject = stream; | |||
video.play(); | |||
setInterval(async () => { | |||
const result = await detector.detect(video); | |||
await detector.process(result); | |||
}, 200); | |||
}); | |||
}); | |||
</script> |
@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg> |
@ -0,0 +1,41 @@ | |||
<script setup lang="ts"> | |||
import { ref } from 'vue' | |||
defineProps<{ msg: string }>() | |||
const count = ref(0) | |||
</script> | |||
<template> | |||
<h1>{{ msg }}</h1> | |||
<div class="card"> | |||
<button type="button" @click="count++">count is {{ count }}</button> | |||
<p> | |||
Edit | |||
<code>components/HelloWorld.vue</code> to test HMR | |||
</p> | |||
</div> | |||
<p> | |||
Check out | |||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank" | |||
>create-vue</a | |||
>, the official Vue + Vite starter | |||
</p> | |||
<p> | |||
Learn more about IDE Support for Vue in the | |||
<a | |||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" | |||
target="_blank" | |||
>Vue Docs Scaling up Guide</a | |||
>. | |||
</p> | |||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p> | |||
</template> | |||
<style scoped> | |||
.read-the-docs { | |||
color: #888; | |||
} | |||
</style> |
@ -0,0 +1,24 @@ | |||
import { onMounted, ref } from 'vue'; | |||
import { Detector } from './hand_landmark/detector'; | |||
const videoRef = ref<HTMLVideoElement | null>(null); | |||
const detector = new Detector(); | |||
onMounted(async () => { | |||
await detector.initialize(); // 初始化模型 | |||
const video = videoRef.value!; | |||
video.width = 640; | |||
video.height = 480; | |||
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => { | |||
video.srcObject = stream; | |||
video.play(); | |||
// 每隔 200ms 检测一帧 | |||
setInterval(async () => { | |||
const result = await detector.detect(video); | |||
await detector.process(result); | |||
}, 200); | |||
}); | |||
}); |
@ -0,0 +1,184 @@ | |||
import { | |||
FilesetResolver, | |||
GestureRecognizer, | |||
} from "@mediapipe/tasks-vision"; | |||
// 手势 | |||
export enum HandGesture { | |||
// 食指举起,移动鼠标 | |||
ONLY_INDEX_UP = "only_index_up", | |||
} | |||
interface HandLandmark { | |||
x: number; | |||
y: number; | |||
z: number; | |||
} | |||
export interface HandInfo { | |||
landmarks: HandLandmark[]; | |||
handedness: "Left" | "Right"; | |||
score: number; | |||
categoryName?: string; | |||
} | |||
interface DetectionResult { | |||
leftHand?: HandInfo; | |||
rightHand?: HandInfo; | |||
// 原始检测结果,以防需要访问其他数据 | |||
rawResult: any; | |||
} | |||
/** | |||
* 检测器类 - 负责手势识别和手势分类 | |||
* 主要职责: | |||
* 1. 初始化 | |||
* 2. 检测视频帧中的手部 | |||
* 3. 分析手势类型(手指竖起等) | |||
* 4. 提供手部关键点查询方法 | |||
*/ | |||
export class Detector { | |||
private detector: GestureRecognizer | null = null; | |||
constructor(private modelPath = "/mediapipe/gesture_recognizer.task") {} | |||
async initialize(useCanvas = false) { | |||
const vision = await FilesetResolver.forVisionTasks("/mediapipe/wasm"); | |||
try { | |||
const params: any = { | |||
baseOptions: { | |||
modelAssetPath: this.modelPath, | |||
delegate: "GPU", | |||
}, | |||
runningMode: "VIDEO", | |||
numHands: 1, | |||
}; | |||
if (useCanvas) { | |||
params.canvas = document.createElement("canvas"); | |||
} | |||
this.detector = await GestureRecognizer.createFromOptions(vision, params); | |||
} catch (error: any) { | |||
if (error.toString().includes("kGpuService")) { | |||
await this.initialize(true); | |||
} else { | |||
throw error; | |||
} | |||
} | |||
} | |||
// 检测视频帧中的手部(保持独立) | |||
async detect(video: HTMLVideoElement): Promise<DetectionResult> { | |||
if (!this.detector) { | |||
throw new Error("检测器未初始化"); | |||
} | |||
const now = performance.now(); | |||
const result = await this.detector.recognizeForVideo(video, now); | |||
const detection: DetectionResult = { | |||
rawResult: result, | |||
}; | |||
if (result.landmarks && result.handedness) { | |||
for (let i = 0; i < result.landmarks.length; i++) { | |||
const hand: HandInfo = { | |||
landmarks: result.landmarks[i], | |||
handedness: result.handedness[i][0].categoryName as "Left" | "Right", | |||
score: result.handedness[i][0].score, | |||
}; | |||
if (result.gestures.length > 0) { | |||
hand.categoryName = result.gestures[0][0].categoryName; | |||
} | |||
if (hand.handedness === "Left") { | |||
detection.leftHand = hand; | |||
} else { | |||
detection.rightHand = hand; | |||
} | |||
} | |||
} | |||
return detection; | |||
} | |||
/** | |||
* 检测手指是否竖起 | |||
*/ | |||
static _fingersUp(hand: HandInfo): number[] { | |||
const fingers: number[] = []; | |||
const tipIds = [4, 8, 12, 16, 20]; // 从大拇指开始,依次为每个手指指尖 | |||
// 检测大拇指 | |||
if (hand.handedness === "Right") { | |||
if (hand.landmarks[tipIds[0]].x < hand.landmarks[tipIds[0] - 1].x) { | |||
fingers.push(0); | |||
} else { | |||
fingers.push(1); | |||
} | |||
} else { | |||
if (hand.landmarks[tipIds[0]].x > hand.landmarks[tipIds[0] - 1].x) { | |||
fingers.push(0); | |||
} else { | |||
fingers.push(1); | |||
} | |||
} | |||
// 检测其他四个手指 | |||
for (let id = 1; id < 5; id++) { | |||
if (hand.landmarks[tipIds[id]].y < hand.landmarks[tipIds[id] - 2].y) { | |||
fingers.push(1); | |||
} else { | |||
fingers.push(0); | |||
} | |||
} | |||
return fingers; | |||
} | |||
/** | |||
* 获取单个手的手势类型 | |||
*/ | |||
public static getSingleHandGesture(hand: HandInfo): HandGesture { | |||
const fingers = this._fingersUp(hand); | |||
const fingerState = fingers.join(","); | |||
// 定义手势映射表 | |||
const gestureMap = new Map<string, HandGesture>([ | |||
// 食指举起,移动鼠标 | |||
["0,1,0,0,0", HandGesture.ONLY_INDEX_UP], | |||
]); | |||
if (gestureMap.has(fingerState)) { | |||
return gestureMap.get(fingerState) as HandGesture; | |||
} | |||
// 返回默认值 | |||
return HandGesture.OTHER; | |||
} | |||
/** | |||
* 处理检测结果并执行相应动作 | |||
*/ | |||
async process(detection: DetectionResult): Promise<HandGesture> { | |||
const hand = detection.rightHand ?? detection.leftHand; | |||
if (!hand) { | |||
console.log("没检测到手"); | |||
return HandGesture.OTHER; | |||
} | |||
const gesture = Detector.getSingleHandGesture(hand); | |||
console.log("当前手势状态是:", gesture); | |||
if (gesture === HandGesture.ONLY_INDEX_UP) { | |||
console.log("识别到食指竖起!"); | |||
} | |||
return gesture; | |||
} | |||
} |
@ -0,0 +1,21 @@ | |||
// gesture_handler.ts | |||
import { HandGesture, HandInfo } from "./detector"; | |||
export class GestureHandler { | |||
private previousGesture: HandGesture | null = null; | |||
private previousGestureCount = 0; | |||
private minGestureCount = 3; | |||
handleGesture(gesture: HandGesture, hand: HandInfo) { | |||
if (gesture === this.previousGesture) { | |||
this.previousGestureCount++; | |||
} else { | |||
this.previousGesture = gesture; | |||
this.previousGestureCount = 1; | |||
} | |||
if (this.previousGestureCount >= this.minGestureCount) { | |||
console.log("识别手势类型:", gesture); | |||
} | |||
} | |||
} |
@ -0,0 +1,5 @@ | |||
import { createApp } from 'vue' | |||
import './style.css' | |||
import App from './App.vue' | |||
createApp(App).mount('#app') |
@ -0,0 +1,79 @@ | |||
:root { | |||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | |||
line-height: 1.5; | |||
font-weight: 400; | |||
color-scheme: light dark; | |||
color: rgba(255, 255, 255, 0.87); | |||
background-color: #242424; | |||
font-synthesis: none; | |||
text-rendering: optimizeLegibility; | |||
-webkit-font-smoothing: antialiased; | |||
-moz-osx-font-smoothing: grayscale; | |||
} | |||
a { | |||
font-weight: 500; | |||
color: #646cff; | |||
text-decoration: inherit; | |||
} | |||
a:hover { | |||
color: #535bf2; | |||
} | |||
body { | |||
margin: 0; | |||
display: flex; | |||
place-items: center; | |||
min-width: 320px; | |||
min-height: 100vh; | |||
} | |||
h1 { | |||
font-size: 3.2em; | |||
line-height: 1.1; | |||
} | |||
button { | |||
border-radius: 8px; | |||
border: 1px solid transparent; | |||
padding: 0.6em 1.2em; | |||
font-size: 1em; | |||
font-weight: 500; | |||
font-family: inherit; | |||
background-color: #1a1a1a; | |||
cursor: pointer; | |||
transition: border-color 0.25s; | |||
} | |||
button:hover { | |||
border-color: #646cff; | |||
} | |||
button:focus, | |||
button:focus-visible { | |||
outline: 4px auto -webkit-focus-ring-color; | |||
} | |||
.card { | |||
padding: 2em; | |||
} | |||
#app { | |||
max-width: 1280px; | |||
margin: 0 auto; | |||
padding: 2rem; | |||
text-align: center; | |||
} | |||
@media (prefers-color-scheme: light) { | |||
:root { | |||
color: #213547; | |||
background-color: #ffffff; | |||
} | |||
a:hover { | |||
color: #747bff; | |||
} | |||
button { | |||
background-color: #f9f9f9; | |||
} | |||
} |
@ -0,0 +1 @@ | |||
/// <reference types="vite/client" /> |
@ -0,0 +1,15 @@ | |||
{ | |||
"extends": "@vue/tsconfig/tsconfig.dom.json", | |||
"compilerOptions": { | |||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | |||
/* Linting */ | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"erasableSyntaxOnly": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"noUncheckedSideEffectImports": true | |||
}, | |||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] | |||
} |
@ -0,0 +1,7 @@ | |||
{ | |||
"files": [], | |||
"references": [ | |||
{ "path": "./tsconfig.app.json" }, | |||
{ "path": "./tsconfig.node.json" } | |||
] | |||
} |
@ -0,0 +1,25 @@ | |||
{ | |||
"compilerOptions": { | |||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | |||
"target": "ES2023", | |||
"lib": ["ES2023"], | |||
"module": "ESNext", | |||
"skipLibCheck": true, | |||
/* Bundler mode */ | |||
"moduleResolution": "bundler", | |||
"allowImportingTsExtensions": true, | |||
"verbatimModuleSyntax": true, | |||
"moduleDetection": "force", | |||
"noEmit": true, | |||
/* Linting */ | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"erasableSyntaxOnly": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"noUncheckedSideEffectImports": true | |||
}, | |||
"include": ["vite.config.ts"] | |||
} |
@ -0,0 +1,13 @@ | |||
/// <reference types="node" /> | |||
import { defineConfig } from 'vite' | |||
import vue from '@vitejs/plugin-vue' | |||
import path from 'path' | |||
export default defineConfig({ | |||
plugins: [vue()], | |||
resolve: { | |||
alias: { | |||
'@': path.resolve(__dirname, 'src'), | |||
}, | |||
}, | |||
}) |