@ -1,3 +1,25 @@ | |||
node_modules/ | |||
src-tauri/ | |||
~$项目测试结果.xlsx | |||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
# dependencies | |||
node_modules | |||
.pnp | |||
.pnp.js | |||
venv | |||
# testing | |||
coverage | |||
# production | |||
build | |||
# misc | |||
.DS_Store | |||
.env.local | |||
.env.development.local | |||
.env.test.local | |||
.env.production.local | |||
.idea | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* |
@ -0,0 +1,5 @@ | |||
{ | |||
"python-envs.defaultEnvManager": "ms-python.python:conda", | |||
"python-envs.defaultPackageManager": "ms-python.python:conda", | |||
"python-envs.pythonProjects": [] | |||
} |
@ -0,0 +1,21 @@ | |||
MIT License | |||
Copyright (c) 2024 Andrew Yang | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@ -1,149 +1 @@ | |||
# 📡 WaveControl 隔空手势控制系统 | |||
> 一套融合**手势识别**与**语音控制**的非接触式人机交互系统,包含主控制平台、手语学习平台 WaveSign、赛车游戏控制模块三大子系统,致力于打造自然、高效、多场景适配的“举手即控”体验。 | |||
## 项目简介 | |||
在厨房、医疗、演讲等“无法触控”或“不便触控”的环境中,传统鼠标/键盘交互模式效率低、操作受限。**WaveControl**以此为切入点,构建了一个基于摄像头识别的隔空控制系统,融合手势识别与语音识别,实现对系统级输入(键盘/鼠标)、手语教学以及游戏控制等功能。 | |||
项目采用模块化架构设计,包含三大子系统: | |||
1. **主控制平台**:支持窗口操作、媒体控制、鼠标替代、游戏映射等功能 | |||
2. **赛车游戏交互系统**:实现与《Rush Rally Origins》等赛车游戏的隔空手柄交互 | |||
3. **WaveSign 手语通**:面向听障人群的手语学习与社区互动平台 | |||
## 测试信息 | |||
本项目配套了完整的[测试文档](./项目测试文档.md),涵盖以下子模块: | |||
- ✅ **主控制系统测试程序**:手势识别 → 键盘鼠标映射 → 交互反馈 | |||
- ✅ **游戏控制测试程序**:手势实时识别 → 虚拟手柄信号发送 → 赛车游戏响应验证 | |||
- ✅ **手语识别与打分测试程序**:摄像头实时捕捉动作 → MediaPipe评分 → UI动画与文本反馈 | |||
- ✅ **用户交互测试**:社区发帖、点赞评论、任务管理、日程提醒等核心功能均有覆盖 | |||
测试方式支持浏览器调试 + 控制台运行日志追踪,若遇白屏可通过 Chrome DevTools 检查模块加载或路径引用情况。 | |||
## Git 分支说明 | |||
| 分支名 | 描述 | 跳转链接 | | |||
| -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | | |||
| `master` | 主分支,已完成整合,适用于演示与部署 | [🔗 master 分支](https://gitee.com/wydhhh/software-engineering/tree/master/) | | |||
| `finalv1` | 前后端初步融合尝试,手势 → 页面响应逻辑测试阶段 | [🔗 finalv1](https://gitee.com/wydhhh/software-engineering/tree/finalv1/) | | |||
| `finalv2` | 完成手势识别与前端事件联动,页面按钮联动测试 | [🔗 finalv2](https://gitee.com/wydhhh/software-engineering/tree/finalv2/) | | |||
| `finalv3` | 增加语音识别、手势控制切换、音乐控制、手语平台接入、各子系统联调优化 | [🔗 finalv3](https://gitee.com/wydhhh/software-engineering/tree/finalv3/) | | |||
| `gesture` | 手势识别逻辑独立开发模块 | [🔗 gesture](https://gitee.com/wydhhh/software-engineering/tree/gesture/) | | |||
| `gesture_for_chrome` | 针对 Chrome 插件开发的手势控制方案(PPT翻页) | [🔗 gesture_for_chrome](https://gitee.com/wydhhh/software-engineering/tree/gesture_for_chrome/) | | |||
| `gesture-game` | 初步游戏控制实验,控制小球移动 | [🔗 gesture-game](https://gitee.com/wydhhh/software-engineering/tree/gesture-game/) | | |||
| `game_control` | 控制《Rush Rally Origins》赛车游戏,已支持加速转向等 | [🔗 game_control](https://gitee.com/wydhhh/software-engineering/tree/game_control/) | | |||
| `wavesign` | 手语通子系统开发主线,包括教学评分、社区、日程等 | [🔗 wavesign](https://gitee.com/wydhhh/software-engineering/tree/wavesign/) | | |||
| `web` | 最初的网页原型设计,UI 静态草稿 | [🔗 web](https://gitee.com/wydhhh/software-engineering/tree/web/) | | |||
| `screenshot` | 手势控制截屏模块,用于快速抓取操作界面 | [🔗 screenshot](https://gitee.com/wydhhh/software-engineering/tree/screenshot/) | | |||
| `vosk_inc` | 接入 VOSK 实现语音识别与实时字幕展示 | [🔗 vosk_inc](https://gitee.com/wydhhh/software-engineering/tree/vosk_inc/) | | |||
## 技术架构 | |||
| 层级 | 技术方案 | | |||
| ------------ | ------------------------------------------------------------ | | |||
| 前端 | vue3 + TypeScript +HTML + CSS + Tailwind CSS + JavaScript + PySide2(Qt GUI) | | |||
| 后端 | Django 4.x(主平台 + 手语通) + Python 脚本逻辑(游戏控制) | | |||
| 手势识别 | MediaPipe Hand Landmarker | | |||
| 虚拟设备控制 | 键盘鼠标模拟、vgamepad 虚拟手柄(XInput) | | |||
| 数据处理 | Kalman Filter(手势抖动滤波)、SQLite3 数据库 | | |||
## 模块介绍 | |||
### 1️⃣ 主控制平台(WaveControl) | |||
##### ✌️ **功能特色** | |||
- 多种预设手势操作(点击、滚动、后退等) | |||
- 手势范围调节与自定义手势库 | |||
- 支持语音识别辅助控制 | |||
- 界面直观,状态反馈实时 | |||
##### 🎯 应用场景 | |||
- 📺 沙发上追剧时,不再找遥控器,用手势暂停/快进 | |||
- 👨🏫 教学/演讲中,用手势控制 PPT 流畅翻页 | |||
- 🧑🍳 厨房做饭时,隔空查食谱不怕弄脏设备 | |||
- 🏥 医疗/无菌操作室中,非接触式操作电脑界面 | |||
- 🕹️ 游戏中挥手即控,沉浸感倍增 | |||
🖼️ **UI 示例** | |||
- 控制主面板(准确率/响应时间/识别窗口) | |||
- 手势管理面板(快捷操作映射设置) | |||
### 2️⃣ 游戏控制模块 | |||
🔗 项目演示:[游戏控制项目演示视频](https://www.bilibili.com/video/BV1H5gLzsEn8?vd_source=46a0e2ec60dfb8a247c96905ee47d378) | |||
🎮 目标:**无需实体手柄,通过摄像头即可玩赛车游戏!** | |||
适配游戏:Steam平台《Rush Rally Origins》及支持 Xbox手柄的其他游戏 | |||
**实现要点:** | |||
| 功能 | 技术说明 | | |||
| ------------ | ----------------------------------------------------------- | | |||
| 摄像头识别 | OpenCV + MediaPipe | | |||
| 手势控制映射 | 👍右手拇指上扬 = 加速 👍左手 = 刹车 ✋左倾 = 左转,右倾 = 右转 | | |||
| 虚拟手柄接口 | vgamepad + XInput | | |||
| 抖动滤除 | Kalman 滤波器平滑动作 | | |||
| UI反馈 | PySide2 构建调试窗口 | | |||
### 3️⃣ 手语通子项目:**WaveSign** | |||
🔗 项目演示:[手语通项目演示视频](https://www.bilibili.com/video/BV1Fig5zHEhq?vd_source=46a0e2ec60dfb8a247c96905ee47d378) | |||
项目定位:帮助听障人群及其家人朋友学习、练习、交流手语的综合平台 | |||
**功能模块:** | |||
- ✅ **手语教学与评分系统**:上传视频或用摄像头练习手语动作,系统打分反馈 | |||
- 🗺️ **课程地图与互动练习**:任务式学习,配合卡片式巩固练习 | |||
- 👥 **社区交流**:发帖、评论、点赞、关注等功能 | |||
- 📅 **日程管理**:内置待办事项与日历,辅助学习安排 | |||
- 🧭 **生活服务**: | |||
- 出行导航(结合地图与实时提醒) | |||
- 辅助器具推荐 | |||
- 就业信息推送 | |||
- 无障碍亲子/技能活动预告 | |||
**技术实现:** | |||
- Django + SQLite 构建用户系统与服务逻辑 | |||
- 使用 MediaPipe 实时评分用户手语表现 | |||
- 前端页面响应式 + 卡片式交互体验 | |||
## 项目成员 | |||
王云岱 朱子玥 杨嘉莉 | |||
## 📌 项目进度 | |||
- ✅ 第一轮:系统原型 + UI设计 + 手势识别框架 | |||
- ✅ 第二轮:主平台功能实现 + 手势控制实现 | |||
- ✅ 第三轮:打通语音识别 + 游戏交互控制 + 手语通平台初步实现 | |||
- ✅ 第四轮:赛车游戏交互实现 + 手语通平台完整搭建 | |||
- 🧪 第五轮:综合测试 + 用户体验调优 + 结项演示 | |||
chrome-plugin |
@ -0,0 +1 @@ | |||
.idea |
@ -0,0 +1 @@ | |||
web: gunicorn --worker-class eventlet -w 1 app:app |
@ -0,0 +1,120 @@ | |||
from flask import Flask, jsonify, request | |||
from flask_socketio import SocketIO, emit | |||
from flask_cors import CORS | |||
from flask_sqlalchemy import SQLAlchemy | |||
from datetime import datetime, timedelta | |||
from threading import Thread | |||
import time | |||
import random | |||
app = Flask(__name__) | |||
CORS(app) | |||
socketio = SocketIO(app, cors_allowed_origins="*") | |||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///pairing_codes.db' | |||
db = SQLAlchemy(app) | |||
class PairingCode(db.Model): | |||
id = db.Column(db.Integer, primary_key=True) | |||
code = db.Column(db.String(80), unique=True, nullable=False) | |||
session_id = db.Column(db.String(120), unique=True, nullable=True) | |||
timestamp = db.Column(db.DateTime, default=db.func.current_timestamp()) | |||
last_heartbeat = db.Column(db.DateTime, default=db.func.current_timestamp()) | |||
with app.app_context(): | |||
db.create_all() | |||
def cleanup_expired_codes(): | |||
while True: | |||
with app.app_context(): | |||
expired_time = datetime.utcnow() - timedelta(minutes=15) | |||
expired_codes = PairingCode.query.filter(PairingCode.last_heartbeat < expired_time).all() | |||
for code in expired_codes: | |||
db.session.delete(code) | |||
db.session.commit() | |||
time.sleep(600) | |||
cleanup_thread = Thread(target=cleanup_expired_codes) | |||
cleanup_thread.start() | |||
@app.route('/generate_code', methods=['GET']) | |||
def generate_code(): | |||
while True: | |||
code = str(random.randint(1000, 9999)) | |||
existing_code = PairingCode.query.filter_by(code=code).first() | |||
if not existing_code: | |||
break | |||
new_code = PairingCode(code=code) | |||
db.session.add(new_code) | |||
db.session.commit() | |||
return jsonify({"code": code}) | |||
@socketio.on('pair') | |||
def handle_pairing(json): | |||
code = json.get('code') | |||
pairing_code = PairingCode.query.filter_by(code=code).first() | |||
if pairing_code: | |||
pairing_code.session_id = request.sid | |||
PairingCode.query.filter(PairingCode.session_id == request.sid, PairingCode.id != pairing_code.id).delete() | |||
db.session.commit() | |||
emit('paired', {'status': 'success'}, room=request.sid) | |||
else: | |||
emit('error', {'status': 'invalid_code'}, room=request.sid) | |||
@socketio.on('heartbeat') | |||
def handle_heartbeat(json): | |||
code = json.get('code') | |||
pairing_code = PairingCode.query.filter_by(code=code).first() | |||
if pairing_code: | |||
now_without_ms = datetime.utcnow().replace(microsecond=0) | |||
pairing_code.last_heartbeat = now_without_ms | |||
db.session.commit() | |||
@app.route('/validate_code', methods=['POST']) | |||
def validate_code(): | |||
data = request.json | |||
code = data.get('code') | |||
pairing_code = PairingCode.query.filter_by(code=code).first() | |||
if pairing_code and pairing_code.last_heartbeat: | |||
time_since_last_heartbeat = datetime.utcnow() - pairing_code.last_heartbeat | |||
if time_since_last_heartbeat < timedelta(minutes=15): | |||
return jsonify({'status': 'valid'}) | |||
else: | |||
return jsonify({'status': 'expired'}), 400 | |||
else: | |||
return jsonify({'status': 'invalid'}), 400 | |||
@app.route('/send_gesture', methods=['POST']) | |||
def handle_gesture(): | |||
data = request.json | |||
code = data.get('code') | |||
gesture_value = data.get('gesture') | |||
pairing_code = PairingCode.query.filter_by(code=code).first() | |||
if pairing_code and pairing_code.session_id: | |||
socketio.emit('gesture_event', {'gesture': gesture_value}, room=pairing_code.session_id) | |||
return jsonify({'status': 'success'}) | |||
else: | |||
return jsonify({'status': 'code_not_paired'}), 400 | |||
if __name__ == '__main__': | |||
socketio.run(app, debug=True) |
@ -0,0 +1,24 @@ | |||
bidict==0.22.1 | |||
blinker==1.7.0 | |||
click==8.1.7 | |||
dnspython==2.4.2 | |||
eventlet==0.34.2 | |||
Flask==3.0.0 | |||
Flask-Cors==4.0.0 | |||
Flask-SocketIO==5.3.6 | |||
Flask-SQLAlchemy==3.1.1 | |||
greenlet==3.0.3 | |||
gunicorn==21.2.0 | |||
h11==0.14.0 | |||
itsdangerous==2.1.2 | |||
Jinja2==3.1.2 | |||
MarkupSafe==2.1.3 | |||
packaging==23.2 | |||
python-engineio==4.8.0 | |||
python-socketio==5.10.0 | |||
simple-websocket==1.0.0 | |||
six==1.16.0 | |||
SQLAlchemy==2.0.41 | |||
typing_extensions==4.13.1 | |||
Werkzeug==3.0.1 | |||
wsproto==1.2.0 |
@ -0,0 +1 @@ | |||
python-3.10.13 |
@ -0,0 +1 @@ | |||
<!doctype html><html><head><meta charset="utf-8"><title>React extension</title><meta name="viewport" content="width=device-width,initial-scale=1"><script defer="defer" src="index.js"></script></head><body></body></html> |
@ -0,0 +1,29 @@ | |||
/** | |||
* @license React | |||
* react-dom.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* react.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* scheduler.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ |
@ -0,0 +1 @@ | |||
(()=>{function e(e,t){if(window.location.href.includes("docs.google.com/presentation")){const n=document.querySelector(".punch-present-iframe");n&&n.contentWindow?function(e,t,n){let o=new KeyboardEvent("keydown",{key:t,keyCode:n,code:t,bubbles:!0,shiftKey:!1,ctrlKey:!1,metaKey:!1});e.dispatchEvent(o),setTimeout(()=>{let o=new KeyboardEvent("keyup",{key:t,keyCode:n,code:t,bubbles:!0,shiftKey:!1,ctrlKey:!1});e.dispatchEvent(o)},100)}(n.contentDocument||n.contentWindow.document,e,t):console.warn("Google Slides presentation iframe not found")}else console.warn("Unsupported presentation platform")}chrome.runtime.onMessage.addListener(function(t,n,o){"Left"===t.gesture?e("ArrowLeft",37):"Right"===t.gesture&&e("ArrowRight",39)})})(); |
@ -0,0 +1 @@ | |||
<!doctype html><html><head><meta charset="utf-8"><title>React extension</title><meta name="viewport" content="width=device-width,initial-scale=1"><script defer="defer" src="index.js"></script></head><body></body></html> |
@ -0,0 +1,29 @@ | |||
/** | |||
* @license React | |||
* react-dom.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* react.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* scheduler.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ |
@ -0,0 +1,28 @@ | |||
{ | |||
"manifest_version": 3, | |||
"name": "Gesture Presenter", | |||
"version": "1.0", | |||
"description": "Control Slides with just hand gestures!", | |||
"icons": { | |||
"16": "icons/icon16.png", | |||
"32": "icons/icon32.png", | |||
"48": "icons/icon48.png", | |||
"128": "icons/icon128.png" | |||
}, | |||
"permissions": ["storage", "tabs"], | |||
"content_scripts": [ | |||
{ | |||
"matches": ["https://docs.google.com/presentation/*"], | |||
"js": ["js/contentScript.js"] | |||
} | |||
], | |||
"background": { | |||
"service_worker": "js/background.js" | |||
}, | |||
"action": { | |||
"default_popup": "js/index.html" | |||
}, | |||
"content_security_policy": { | |||
"extension_pages": "script-src 'self'; object-src 'self'" | |||
} | |||
} |
@ -0,0 +1 @@ | |||
<!doctype html><html><head><title>GesturePresent Popup</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"><script defer="defer" src="popup.js"></script></head><body><div id="root"></div><script src="popup.js"></script></body></html> |
@ -0,0 +1,29 @@ | |||
/** | |||
* @license React | |||
* react-dom.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* react.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ | |||
/** | |||
* @license React | |||
* scheduler.production.min.js | |||
* | |||
* Copyright (c) Facebook, Inc. and its affiliates. | |||
* | |||
* This source code is licensed under the MIT license found in the | |||
* LICENSE file in the root directory of this source tree. | |||
*/ |
@ -0,0 +1,28 @@ | |||
{ | |||
"manifest_version": 3, | |||
"name": "Gesture Presenter", | |||
"version": "1.0", | |||
"description": "Control Slides with just hand gestures!", | |||
"icons": { | |||
"16": "icons/icon16.png", | |||
"32": "icons/icon32.png", | |||
"48": "icons/icon48.png", | |||
"128": "icons/icon128.png" | |||
}, | |||
"permissions": ["storage", "tabs"], | |||
"content_scripts": [ | |||
{ | |||
"matches": ["https://docs.google.com/presentation/*"], | |||
"js": ["js/contentScript.js"] | |||
} | |||
], | |||
"background": { | |||
"service_worker": "js/background.js" | |||
}, | |||
"action": { | |||
"default_popup": "js/index.html" | |||
}, | |||
"content_security_policy": { | |||
"extension_pages": "script-src 'self'; object-src 'self'" | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
{ | |||
"name": "gesture-presenter-extension", | |||
"version": "1.0.0", | |||
"description": "Control Google Slides With Just Your Hand Gestures!", | |||
"main": "background.js", | |||
"scripts": { | |||
"build": "webpack --mode production --config webpack.config.js", | |||
"watch": "webpack -w --config webpack.config.js", | |||
"test": "echo \"Error: no test specified\" && exit 1" | |||
}, | |||
"keywords": [], | |||
"author": "", | |||
"license": "ISC", | |||
"devDependencies": { | |||
"@babel/core": "^7.23.6", | |||
"@babel/preset-env": "^7.23.6", | |||
"@babel/preset-typescript": "^7.23.3", | |||
"@types/chrome": "^0.0.254", | |||
"babel-loader": "^9.1.3", | |||
"copy-webpack-plugin": "^11.0.0", | |||
"css-loader": "^6.8.1", | |||
"file-loader": "^6.2.0", | |||
"html-webpack-plugin": "^5.6.0", | |||
"style-loader": "^3.3.3", | |||
"ts-loader": "^9.5.1", | |||
"typescript": "^5.3.3", | |||
"webpack": "^5.100.2", | |||
"webpack-cli": "^5.1.4", | |||
"webpack-dev-server": "^4.15.1" | |||
}, | |||
"dependencies": { | |||
"@types/react": "^18.2.45", | |||
"@types/react-dom": "^18.2.18", | |||
"react": "^18.2.0", | |||
"react-dom": "^18.2.0", | |||
"socket.io-client": "^4.7.2" | |||
} | |||
} |
@ -0,0 +1,106 @@ | |||
import { DefaultEventsMap } from '@socket.io/component-emitter'; | |||
import io, {Socket} from 'socket.io-client'; | |||
let socket: Socket<DefaultEventsMap, DefaultEventsMap>; | |||
let heartbeatInterval: number; | |||
let currentPairingCode: string | null = null; | |||
let lastHeartbeatTime: Date = new Date(); | |||
let lastGestureTime: Date = new Date(); | |||
const startHeartbeat = () => { | |||
if (heartbeatInterval) clearInterval(heartbeatInterval); | |||
heartbeatInterval = setInterval(() => { | |||
if (socket && socket.connected && currentPairingCode) { | |||
socket.emit('heartbeat', { code: currentPairingCode }); | |||
lastHeartbeatTime = new Date(); | |||
console.log("Heartbeat sent for code:", currentPairingCode); | |||
} else { | |||
clearInterval(heartbeatInterval); | |||
} | |||
}, 25000); | |||
}; | |||
const shouldRegenerateCode = () => { | |||
const now = new Date(); | |||
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60000); | |||
const oneHourAgo = new Date(now.getTime() - 60 * 60000); | |||
return lastHeartbeatTime < fifteenMinutesAgo || lastGestureTime < oneHourAgo; | |||
}; | |||
const connectToSocket = () => { | |||
socket = io("https://gesture-presenter-bc9d819e6d43.herokuapp.com", { transports: ['websocket'] }); | |||
socket.on('connect', () => { | |||
console.log('Connected to the server'); | |||
requestNewCode(); | |||
startHeartbeat(); | |||
}); | |||
socket.on('gesture_event', (data) => { | |||
console.log('Gesture received:', data.gesture); | |||
lastGestureTime = new Date(); | |||
chrome.runtime.sendMessage({ type: 'gesture', gesture: data.gesture }); | |||
chrome.tabs.query({active: true, currentWindow: true, url: 'https://docs.google.com/presentation/*'}, function(tabs) { | |||
if (tabs.length === 0) { | |||
console.log('No active tab found'); | |||
return; | |||
} | |||
chrome.tabs.sendMessage(tabs[0].id, { gesture: data.gesture }); | |||
}); | |||
}); | |||
socket.on('error', (data) => { | |||
if (data.status === 'invalid_code') { | |||
console.error('Invalid code, requesting a new one'); | |||
requestNewCode(); | |||
} | |||
}); | |||
socket.on('disconnect', () => { | |||
console.log('Disconnected from the server'); | |||
clearInterval(heartbeatInterval); | |||
}); | |||
}; | |||
const requestNewCode = () => { | |||
fetch('https://gesture-presenter-bc9d819e6d43.herokuapp.com/generate_code') | |||
.then(response => response.json()) | |||
.then(data => { | |||
currentPairingCode = data.code; | |||
lastHeartbeatTime = new Date(); | |||
lastGestureTime = new Date(); | |||
socket.emit('pair', { code: data.code }); | |||
chrome.storage.local.set({ pairingCode: data.code }); | |||
console.log("New pairing code:", data.code); | |||
}) | |||
.catch(error => console.error('Error fetching pairing code:', error)); | |||
}; | |||
chrome.runtime.onInstalled.addListener(() => { | |||
connectToSocket(); | |||
}); | |||
chrome.runtime.onStartup.addListener(() => { | |||
connectToSocket(); | |||
}); | |||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { | |||
if (!socket || !socket.connected) { | |||
connectToSocket(); | |||
} | |||
if (message.type === 'getPairingCode') { | |||
if (shouldRegenerateCode()) { | |||
requestNewCode(); | |||
} | |||
sendResponse({ status: 'codeRequested'}); | |||
return true; | |||
} else if (message.type === 'requestNewCode') { | |||
requestNewCode(); | |||
sendResponse({ status: 'newCodeRequested' }); | |||
return true; | |||
} | |||
}); |
@ -0,0 +1,54 @@ | |||
interface GestureRequest { | |||
gesture: string; | |||
} | |||
chrome.runtime.onMessage.addListener(function(request: GestureRequest, sender, sendResponse) { | |||
if (request.gesture === "Left") { | |||
simulateKeyPress("ArrowLeft", 37); | |||
} else if (request.gesture === "Right") { | |||
simulateKeyPress("ArrowRight", 39); | |||
} | |||
}); | |||
function simulateKeyPress(key: string, keyCode: number): void { | |||
const currentUrl = window.location.href; | |||
// Check for Google Slides | |||
if (currentUrl.includes("docs.google.com/presentation")) { | |||
const googleSlidesIframe = document.querySelector('.punch-present-iframe') as HTMLIFrameElement; | |||
if (googleSlidesIframe && googleSlidesIframe.contentWindow) { | |||
const presDoc = googleSlidesIframe.contentDocument || googleSlidesIframe.contentWindow.document; | |||
dispatchKeyEvent(presDoc, key, keyCode); | |||
} else { | |||
console.warn('Google Slides presentation iframe not found'); | |||
} | |||
} | |||
else { | |||
console.warn('Unsupported presentation platform'); | |||
} | |||
} | |||
function dispatchKeyEvent(target: Document | Element, key: string, keyCode: number) { | |||
let keyDownEvent = new KeyboardEvent("keydown", { | |||
key: key, | |||
keyCode: keyCode, | |||
code: key, | |||
bubbles: true, | |||
shiftKey: false, | |||
ctrlKey: false, | |||
metaKey: false | |||
}); | |||
target.dispatchEvent(keyDownEvent); | |||
setTimeout(() => { | |||
let keyUpEvent = new KeyboardEvent("keyup", { | |||
key: key, | |||
keyCode: keyCode, | |||
code: key, | |||
bubbles: true, | |||
shiftKey: false, | |||
ctrlKey: false, | |||
}); | |||
target.dispatchEvent(keyUpEvent); | |||
}, 100); | |||
} |
@ -0,0 +1,4 @@ | |||
declare module '*.png' { | |||
const content: string; | |||
export default content; | |||
} |
@ -0,0 +1,13 @@ | |||
import React from 'react'; | |||
import ReactDOM from 'react-dom/client'; | |||
import Popup from './popup'; | |||
const root = document.createElement("div") | |||
root.className = "container" | |||
document.body.appendChild(root) | |||
const rootDiv = ReactDOM.createRoot(root); | |||
rootDiv.render( | |||
<React.StrictMode> | |||
<Popup /> | |||
</React.StrictMode> | |||
); |
@ -0,0 +1,89 @@ | |||
import React, {useEffect, useState} from 'react'; | |||
import logoImg from './assets/logo.png'; | |||
import './popupStyles.css'; | |||
interface GestureMessage { | |||
type: string; | |||
gesture?: string; | |||
} | |||
const Popup = () => { | |||
const [pairingCode, setPairingCode] = useState(''); | |||
useEffect(() => { | |||
chrome.runtime.sendMessage({ type: 'getPairingCode' }, (response) => { | |||
updatePairingCode(); | |||
}); | |||
chrome.storage.onChanged.addListener((changes, namespace) => { | |||
if ('pairingCode' in changes) { | |||
updatePairingCode(); | |||
} | |||
}); | |||
const handleGestureMessage = ( | |||
message: GestureMessage, | |||
sender: chrome.runtime.MessageSender, | |||
sendResponse: (response?: any) => void | |||
) => { | |||
if (message.type === 'gesture') { | |||
document.body.style.backgroundColor = message.gesture === 'Left' ? '#FFC1C1' : '#98FB98'; | |||
setTimeout(() => { | |||
document.body.style.backgroundColor = 'white'; | |||
}, 500); | |||
} | |||
}; | |||
chrome.runtime.onMessage.addListener(handleGestureMessage); | |||
return () => { | |||
chrome.runtime.onMessage.removeListener(handleGestureMessage); | |||
}; | |||
}, []); | |||
const updatePairingCode = () => { | |||
chrome.storage.local.get('pairingCode', (result) => { | |||
if (result.pairingCode) { | |||
setPairingCode(result.pairingCode); | |||
} | |||
}); | |||
}; | |||
const requestNewCode = () => { | |||
chrome.runtime.sendMessage({ type: 'requestNewCode' }, (response) => { | |||
console.log(response.status); | |||
}); | |||
}; | |||
return ( | |||
<div className="popupContainer"> | |||
<div className="header"> | |||
<h2 className="title">GesturePresenter</h2> | |||
<img src={logoImg} alt="Logo" className="logo" /> | |||
</div> | |||
<h3 className="pairingCodeTitle">Pairing Code</h3> | |||
<div className="codeSection"> | |||
<div className="codeContainer">{pairingCode}</div> | |||
<button onClick={() => requestNewCode()} className="refreshButton"> | |||
<svg xmlns="http://www.w3.org/2000/svg" height="28" width="28" viewBox="0 0 512 512"> | |||
<path fill="currentColor" d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160H352c-17.7 0-32 | |||
14.3-32 32s14.3 32 32 32H463.5c0 0 0 0 0 0h.4c17.7 0 32-14.3 32-32V80c0-17.7-14.3-32-32-32s-32 14.3-32 | |||
32v35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 | |||
40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 | |||
1.7-.4 3.4-.4 5.1V432c0 17.7 14.3 32 32 32s32-14.3 32-32V396.9l17.6 17.5 0 0c87.5 87.4 229.3 87.4 316.7 | |||
0c24.4-24.4 42.1-53.1 52.9-83.7c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 | |||
59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352H160c17.7 0 32-14.3 32-32s-14.3-32-32-32H48.4c-1.6 0-3.2 | |||
.1-4.8 .3s-3.1 .5-4.6 1z"/> | |||
</svg> | |||
</button> | |||
</div> | |||
<p className="instructions"> | |||
Please open <br/><a href="https://anonymousaaardvark.github.io/GesturePresenter/" target="_blank" rel="noopener noreferrer"> | |||
https://anonymousaaardvark.github.io | |||
/GesturePresenter<br/></a> on your mobile device and enter the code. | |||
</p> | |||
</div> | |||
); | |||
}; | |||
export default Popup; |
@ -0,0 +1,77 @@ | |||
.popupContainer { | |||
text-align: center; | |||
padding: 10px; | |||
font-size: 14px; | |||
font-family: 'Open Sans', sans-serif; | |||
color: #333; | |||
background-color: #f7f7f7; | |||
width: 300px; | |||
} | |||
.header { | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
} | |||
.title { | |||
margin: 7px 0 0 0; | |||
color: #4a4a4a; | |||
font-size: 1.7rem; | |||
} | |||
.logo { | |||
margin-left: 10px; | |||
height: 37px; | |||
width: auto; | |||
} | |||
.pairingCodeTitle { | |||
margin: 20px 0 0 0; | |||
font-weight: 400; | |||
font-size: 1.25rem; | |||
} | |||
.codeContainer { | |||
font-size: 2.25rem; | |||
padding: 5px; | |||
background-color: #eaeaea; | |||
border-radius: 5px; | |||
color: #333; | |||
} | |||
.instructions { | |||
margin: 20px 0 0 0; | |||
line-height: 1.5; | |||
font-size: 15px; | |||
color: #555; | |||
} | |||
.codeSection { | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
margin: 2px 0 5px 0; | |||
} | |||
.refreshButton { | |||
margin-left: 5px; | |||
background-color: #eaeaea; | |||
border: none; | |||
cursor: pointer; | |||
border-radius: 5px; | |||
height: 50px; | |||
padding: 5px; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
transition: background-color 0.3s, transform 0.3s; | |||
} | |||
.refreshButton:hover { | |||
background-color: #cccccc; | |||
} | |||
.refreshButton:active { | |||
transform: scale(0.95); | |||
} |
@ -0,0 +1,14 @@ | |||
{ | |||
"compilerOptions": { | |||
"outDir": "./dist", | |||
"noImplicitAny": true, | |||
"module": "es6", | |||
"target": "es6", | |||
"jsx": "react", | |||
"allowJs": true, | |||
"moduleResolution": "node", | |||
"types": ["chrome"], | |||
"allowSyntheticDefaultImports": true | |||
}, | |||
"include": ["src/**/*"] | |||
} |
@ -0,0 +1,68 @@ | |||
const path = require('path'); | |||
const HtmlWebpackPlugin = require('html-webpack-plugin'); | |||
const CopyWebpackPlugin = require('copy-webpack-plugin'); | |||
module.exports = { | |||
entry: { | |||
index: './src/index.tsx', | |||
background: './src/background.ts', | |||
contentScript: './src/contentScript.ts' | |||
}, | |||
module: { | |||
rules: [ | |||
{ | |||
test: /\.tsx?$/, | |||
use: 'ts-loader', | |||
exclude: /node_modules/ | |||
}, | |||
{ | |||
test: /\.js$/, | |||
use: 'babel-loader', | |||
exclude: /node_modules/ | |||
}, | |||
{ | |||
test: /\.css$/, | |||
use: ['style-loader', 'css-loader'] | |||
}, | |||
{ | |||
test: /\.(png|jpe?g|gif)$/i, | |||
use: [ | |||
{ | |||
loader: 'file-loader', | |||
options: { | |||
outputPath: 'images', | |||
name: '[name].[ext]', | |||
}, | |||
}, | |||
], | |||
}, | |||
] | |||
}, | |||
resolve: { | |||
extensions: ['.tsx', '.ts', '.js'] | |||
}, | |||
plugins: [ | |||
new CopyWebpackPlugin({ | |||
patterns: [ | |||
{ from: "manifest.json", to: "../manifest.json" }, | |||
{ from: "icons", to: "../icons"} | |||
], | |||
}), | |||
...getHtmlPlugins(["index"]), | |||
], | |||
output: { | |||
path: path.join(__dirname, "dist/js"), | |||
filename: "[name].js", | |||
}, | |||
}; | |||
function getHtmlPlugins(chunks) { | |||
return chunks.map( | |||
(chunk) => | |||
new HtmlWebpackPlugin({ | |||
title: "React extension", | |||
filename: `${chunk}.html`, | |||
chunks: [chunk], | |||
}) | |||
); | |||
} |
@ -0,0 +1,57 @@ | |||
{ | |||
"name": "gesture-presenter-frontend", | |||
"version": "0.1.0", | |||
"private": true, | |||
"dependencies": { | |||
"@fortawesome/fontawesome-svg-core": "^6.5.1", | |||
"@fortawesome/free-brands-svg-icons": "^6.5.1", | |||
"@fortawesome/free-solid-svg-icons": "^6.5.1", | |||
"@fortawesome/react-fontawesome": "^0.2.0", | |||
"@mediapipe/drawing_utils": "^0.3.1675466124", | |||
"@mediapipe/hands": "^0.4.1675469240", | |||
"@testing-library/jest-dom": "^5.17.0", | |||
"@testing-library/react": "^13.4.0", | |||
"@testing-library/user-event": "^13.5.0", | |||
"@types/jest": "^27.5.2", | |||
"@types/node": "^16.18.68", | |||
"@types/react": "^18.2.45", | |||
"@types/react-dom": "^18.2.18", | |||
"axios": "^1.6.2", | |||
"react": "^18.2.0", | |||
"react-dom": "^18.2.0", | |||
"react-scripts": "5.0.1", | |||
"socket.io-client": "^4.7.2", | |||
"typescript": "^4.9.5", | |||
"web-vitals": "^2.1.4" | |||
}, | |||
"scripts": { | |||
"start": "react-scripts start", | |||
"build": "react-scripts build", | |||
"test": "react-scripts test", | |||
"eject": "react-scripts eject", | |||
"predeploy": "npm run build", | |||
"deploy": "gh-pages -d build" | |||
}, | |||
"eslintConfig": { | |||
"extends": [ | |||
"react-app", | |||
"react-app/jest" | |||
] | |||
}, | |||
"browserslist": { | |||
"production": [ | |||
">0.2%", | |||
"not dead", | |||
"not op_mini all" | |||
], | |||
"development": [ | |||
"last 1 chrome version", | |||
"last 1 firefox version", | |||
"last 1 safari version" | |||
] | |||
}, | |||
"devDependencies": { | |||
"gh-pages": "^6.1.1" | |||
}, | |||
"homepage": "https://gesturepresenter.com/" | |||
} |
@ -0,0 +1,42 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | |||
<meta name="theme-color" content="#000000" /> | |||
<meta | |||
name="description" | |||
content="Web site created using create-react-app" | |||
/> | |||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | |||
<!-- | |||
manifest.json provides metadata used when your web app is installed on a | |||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ | |||
--> | |||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | |||
<!-- | |||
Notice the use of %PUBLIC_URL% in the tags above. | |||
It will be replaced with the URL of the `public` folder during the build. | |||
Only files inside the `public` folder can be referenced from the HTML. | |||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | |||
work correctly both with client-side routing and a non-root public URL. | |||
Learn how to configure a non-root public URL by running `npm run build`. | |||
--> | |||
<title>Gesture Presenter</title> | |||
</head> | |||
<body> | |||
<noscript>You need to enable JavaScript to run this app.</noscript> | |||
<div id="root"></div> | |||
<!-- | |||
This HTML file is a template. | |||
If you open it directly in the browser, you will see an empty page. | |||
You can add webfonts, meta tags, or analytics to this file. | |||
The build step will place the bundled scripts into the <body> tag. | |||
To begin the development, run `npm start` or `yarn start`. | |||
To create a production bundle, use `npm run build` or `yarn build`. | |||
--> | |||
</body> | |||
</html> |
@ -0,0 +1,25 @@ | |||
{ | |||
"short_name": "Gesture Presenter", | |||
"name": "Control Google Slides With Just Hand Gestures!", | |||
"icons": [ | |||
{ | |||
"src": "favicon.ico", | |||
"sizes": "64x64 32x32 24x24 16x16", | |||
"type": "image/x-icon" | |||
}, | |||
{ | |||
"src": "logo192.png", | |||
"type": "image/png", | |||
"sizes": "192x192" | |||
}, | |||
{ | |||
"src": "logo512.png", | |||
"type": "image/png", | |||
"sizes": "512x512" | |||
} | |||
], | |||
"start_url": ".", | |||
"display": "standalone", | |||
"theme_color": "#000000", | |||
"background_color": "#ffffff" | |||
} |
@ -0,0 +1,3 @@ | |||
# https://www.robotstxt.org/robotstxt.html | |||
User-agent: * | |||
Disallow: |
@ -0,0 +1,280 @@ | |||
* { | |||
margin: 0; | |||
padding: 0; | |||
box-sizing: border-box; | |||
} | |||
html { | |||
box-sizing: border-box; | |||
} | |||
*, *::before, *::after { | |||
box-sizing: inherit; | |||
} | |||
body { | |||
font-family: 'Open Sans', sans-serif; | |||
font-size: 16px; | |||
line-height: 1.6; | |||
color: #333; | |||
background-color: #fff; | |||
} | |||
.modal-overlay { | |||
position: fixed; | |||
top: 0; | |||
left: 0; | |||
width: 100%; | |||
height: 100%; | |||
background-color: rgba(0, 0, 0, 0.5); | |||
z-index: 15; | |||
} | |||
.settings-modal { | |||
position: fixed; | |||
top: 50%; | |||
left: 50%; | |||
transform: translate(-50%, -50%); | |||
z-index: 20; | |||
background-color: rgba(0, 0, 0, 0.5); | |||
color: #fff; | |||
padding: 10px; | |||
border-radius: 10px; | |||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); | |||
width: 80%; | |||
max-width: 450px; | |||
max-height: 80dvh; | |||
overflow-y: auto; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
justify-content: flex-start; | |||
} | |||
.title-container { | |||
display: flex; | |||
flex-direction: row !important; | |||
align-items: center; | |||
justify-content: center; | |||
} | |||
.title-container h2 { | |||
color: #fff; | |||
text-align: center; | |||
font-size: 1.5rem; | |||
margin-right: 10px; | |||
} | |||
.logo { | |||
width: 30px; | |||
margin-bottom: 5px; | |||
height: auto; | |||
} | |||
.instructions { | |||
color: #fff; | |||
text-align: center; | |||
margin-bottom: 10px; | |||
} | |||
.error-message { | |||
color: #ff8080; | |||
background-color: rgba(255, 107, 107, 0.1); | |||
border: 1px solid #ff8080; | |||
padding: 8px; | |||
margin-top: 10px; | |||
margin-bottom: 20px; | |||
border-radius: 4px; | |||
text-align: center; | |||
font-size: 0.9rem; | |||
width: calc(100% - 20px) !important; | |||
} | |||
.safari-note { | |||
color: #fff; | |||
font-size: 0.9rem; | |||
font-style: italic; | |||
text-align: center; | |||
margin: 10px 0; | |||
} | |||
.settings-modal div { | |||
width: 100%; | |||
margin: 5px 0; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
} | |||
.settings-modal label { | |||
margin-bottom: 0.5rem; | |||
color: #fff; | |||
font-weight: bold; | |||
} | |||
.settings-modal input[type="tel"] { | |||
width: calc(100% - 20px); | |||
padding: 10px; | |||
border: 1px solid #ddd; | |||
border-radius: 5px; | |||
background-color: rgba(51, 51, 51, 0.5); | |||
color: #fff; | |||
font-size: 1rem; | |||
text-align: center; | |||
margin-bottom: 10px; | |||
} | |||
.swap-container { | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
border: 1px solid #ddd; | |||
background-color: rgba(51, 51, 51, 0.5); | |||
border-radius: 5px; | |||
cursor: pointer; | |||
flex-direction: row !important; | |||
margin: 0 0 10px 0!important; | |||
width: calc(100% - 20px) !important; | |||
} | |||
.swap-label { | |||
font-weight: bold; | |||
text-align: center; | |||
width: 64px; | |||
} | |||
.instruction-container { | |||
display: flex; | |||
flex-direction: row !important; | |||
justify-content: space-evenly; | |||
align-items: center; | |||
width: 100%; | |||
} | |||
.instruction-img { | |||
width: 40px; | |||
height: auto; | |||
filter: invert(); | |||
padding-right: 5px; | |||
} | |||
.img-flip { | |||
-webkit-transform: scaleX(-1); | |||
transform: scaleX(-1); | |||
padding-right: 0; | |||
padding-left: 5px; | |||
} | |||
.settings-modal input[type="checkbox"] { | |||
width: 1.75rem; | |||
height: 1.75rem; | |||
cursor: pointer; | |||
} | |||
.settings-modal .submit-button { | |||
background-color: #007bff; | |||
color: #fff; | |||
border: none; | |||
padding: 10px 15px; | |||
border-radius: 5px; | |||
cursor: pointer; | |||
transition: background-color 0.3s ease; | |||
font-size: 1rem; | |||
width: 50%; | |||
margin: 10px 0; | |||
opacity: 1; | |||
} | |||
.settings-modal .submit-button.disabled { | |||
background-color: #007bff; | |||
opacity: 0.7; | |||
cursor: not-allowed; | |||
} | |||
.additional-links { | |||
position: fixed; | |||
bottom: 10px; | |||
right: 10px; | |||
border-radius: 10px; | |||
background-color: rgba(0, 0, 0, 0.5); | |||
display: flex; | |||
flex-direction: row; | |||
justify-content: space-evenly; | |||
width: 110px; | |||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); | |||
} | |||
.additional-links a { | |||
color: white; | |||
font-size: 35px; | |||
text-decoration: none; | |||
margin: 0 5px; | |||
} | |||
.camera-feed, | |||
.canvas-overlay { | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
} | |||
.canvas-overlay { | |||
z-index: 10; | |||
} | |||
.camera-container { | |||
position: relative; | |||
width: 100%; | |||
height: 100dvh; | |||
overflow: hidden; | |||
} | |||
.camera-feed, .canvas-overlay { | |||
width: 100%; | |||
height: 100%; | |||
object-fit: cover; | |||
} | |||
.camera-feed.user-facing { | |||
transform: scaleX(-1); | |||
} | |||
.camera-button { | |||
position: absolute; | |||
font-size: 2.5rem; | |||
z-index: 10; | |||
background-color: rgba(0, 0, 0, 0.5); | |||
color: #fff; | |||
border: none; | |||
border-radius: 50%; | |||
padding: 15px; | |||
width: 4.5rem; | |||
cursor: pointer; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
} | |||
.show-settings { | |||
bottom: 20px; | |||
left: 20px; | |||
} | |||
.switch-camera { | |||
bottom: 20px; | |||
right: 20px; | |||
} | |||
@media screen and (orientation: landscape) { | |||
.switch-camera { | |||
top: 20px; | |||
right: 20px; | |||
bottom: auto; | |||
} | |||
.show-settings { | |||
bottom: 20px; | |||
right: 20px; | |||
left: auto; | |||
} | |||
} |
@ -0,0 +1,9 @@ | |||
import React from 'react'; | |||
import { render, screen } from '@testing-library/react'; | |||
import App from './App'; | |||
test('renders learn react link', () => { | |||
render(<App />); | |||
const linkElement = screen.getByText(/learn react/i); | |||
expect(linkElement).toBeInTheDocument(); | |||
}); |
@ -0,0 +1,59 @@ | |||
import React, { useState } from 'react'; | |||
import Camera from './Camera'; | |||
import SettingsModal from "./SettingsModal"; | |||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; | |||
import {faChrome, faGithub} from "@fortawesome/free-brands-svg-icons"; | |||
const App: React.FC = () => { | |||
const [flipped, setFlipped] = useState(false); | |||
const [isModalVisible, setIsModalVisible] = useState(true); | |||
const [pairingCode, setPairingCode] = useState(''); | |||
const showModal = () => { | |||
setIsModalVisible(true); | |||
}; | |||
const hideModal = () => { | |||
setIsModalVisible(false); | |||
}; | |||
const handlePairingCodeSubmit = (code: string) => { | |||
console.log("Pairing code submitted:", code); | |||
setPairingCode(code); | |||
setIsModalVisible(false); | |||
}; | |||
const handleToggleGestureSwap = () => { | |||
setFlipped(!flipped); | |||
}; | |||
return ( | |||
<div> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> | |||
{isModalVisible && <div className="modal-overlay"> | |||
<div className="additional-links"> | |||
<a href="https://chromewebstore.google.com/" target="_blank" rel="noopener noreferrer" title="Download Chrome Extension"> | |||
<FontAwesomeIcon icon={faChrome} /> | |||
</a> | |||
<a href="https://gitee.com/wydhhh/software-engineering/tree/gesture_for_chrome/" target="_blank" rel="noopener noreferrer" title="GitHub Repository"> | |||
<FontAwesomeIcon icon={faGithub} /> | |||
</a> | |||
</div> | |||
</div>} | |||
{isModalVisible && <SettingsModal | |||
onPairingCodeSubmit={handlePairingCodeSubmit} | |||
onToggleGestureSwap={handleToggleGestureSwap} | |||
flipped={flipped} | |||
pairingCode={pairingCode} | |||
/>} | |||
<Camera | |||
pairingCode={pairingCode} | |||
flipped={flipped} | |||
onSettingsClick={showModal} | |||
modalVisible={isModalVisible} | |||
/> | |||
</div> | |||
); | |||
}; | |||
export default App; |
@ -0,0 +1,251 @@ | |||
import React, { useState, useEffect, useRef } from 'react'; | |||
import axios from "axios"; | |||
import { Hands, HAND_CONNECTIONS, Results, VERSION } from '@mediapipe/hands'; | |||
import { drawConnectors, drawLandmarks } from '@mediapipe/drawing_utils'; | |||
import { getGesture } from './utils/GestureDetection' | |||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; | |||
import { faArrowsRotate, faGear } from '@fortawesome/free-solid-svg-icons'; | |||
import './App.css'; | |||
interface CameraProps { | |||
pairingCode: string; | |||
flipped: boolean; | |||
onSettingsClick: () => void; | |||
modalVisible: boolean; | |||
} | |||
const Camera: React.FC<CameraProps> = ({ pairingCode, flipped, onSettingsClick, modalVisible }) => { | |||
const [isUserFacing, setIsUserFacing] = useState(true); | |||
const videoRef = useRef<HTMLVideoElement>(null); | |||
const canvasRef = useRef<HTMLCanvasElement>(null); | |||
const handsRef = useRef<Hands | null>(null); | |||
const isSendingRef = useRef(false); | |||
const intervalIdRef = useRef<number | undefined>(undefined); | |||
const [gestureResult, setGestureResult] = useState(0); | |||
useEffect(() => { | |||
if (intervalIdRef.current !== undefined) { | |||
clearInterval(intervalIdRef.current); | |||
intervalIdRef.current = undefined; | |||
} | |||
const handleGestureStable = () => { | |||
if (gestureResult !== 0 && pairingCode !== '' && !modalVisible) { | |||
let gestureValue = gestureResult === 1 ? 'Right' : 'Left'; | |||
const url = 'https://gesture-presenter-bc9d819e6d43.herokuapp.com/send_gesture'; | |||
axios.post(url, { | |||
code: pairingCode, | |||
gesture: gestureValue | |||
}) | |||
.then(response => { | |||
console.log('Gesture sent successfully:', response.data); | |||
}) | |||
.catch(error => { | |||
console.error('Error sending gesture:', error); | |||
onSettingsClick(); | |||
}); | |||
} | |||
}; | |||
let secondTimeoutId: NodeJS.Timeout; | |||
if (gestureResult !== 0) { | |||
const timeoutId = setTimeout(() => { | |||
handleGestureStable(); | |||
secondTimeoutId = setTimeout(() => { | |||
intervalIdRef.current = window.setInterval(handleGestureStable, 500); | |||
}, 250); | |||
}, 150); | |||
return () => { | |||
clearTimeout(timeoutId); | |||
if (secondTimeoutId !== undefined) { | |||
clearTimeout(secondTimeoutId); | |||
} | |||
if (intervalIdRef.current !== undefined) { | |||
clearInterval(intervalIdRef.current); | |||
intervalIdRef.current = undefined; | |||
} | |||
}; | |||
} | |||
}, [gestureResult]); | |||
useEffect(() => { | |||
const getUserMedia = async () => { | |||
try { | |||
const stream = await navigator.mediaDevices.getUserMedia({ | |||
video: { | |||
facingMode: isUserFacing ? "user" : "environment", | |||
width: { ideal: 500 }, | |||
height: { ideal: 500 } | |||
} | |||
}); | |||
const video = videoRef.current; | |||
if (video) { | |||
video.srcObject = stream; | |||
video.onloadedmetadata = () => { | |||
video.play().then(() => { | |||
if (canvasRef.current) { | |||
canvasRef.current.width = video.videoWidth; | |||
canvasRef.current.height = video.videoHeight; | |||
initializeMediaPipe(); | |||
} | |||
}); | |||
}; | |||
} | |||
} catch (err) { | |||
console.error("Error accessing media devices:", err); | |||
} | |||
}; | |||
const handleResize = () => { | |||
setTimeout(() => { | |||
const video = videoRef.current; | |||
if (video && canvasRef.current) { | |||
canvasRef.current.width = video.videoWidth; | |||
canvasRef.current.height = video.videoHeight; | |||
} | |||
}, 1000); // might not be very reliable | |||
}; | |||
getUserMedia(); | |||
window.addEventListener('resize', handleResize); | |||
return () => { | |||
window.addEventListener('resize', handleResize); | |||
const stream = videoRef.current?.srcObject as MediaStream; | |||
stream?.getTracks().forEach(track => track.stop()); | |||
if (handsRef.current) { | |||
handsRef.current.close(); | |||
handsRef.current = null; | |||
} | |||
}; | |||
}, [isUserFacing, flipped]); | |||
const initializeMediaPipe = () => { | |||
if (handsRef.current) { | |||
handsRef.current.close(); | |||
} | |||
const hands = new Hands({ | |||
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands@${VERSION}/${file}`, | |||
}); | |||
hands.setOptions({ | |||
modelComplexity: 1, | |||
minDetectionConfidence: 0.5, | |||
minTrackingConfidence: 0.5, | |||
maxNumHands: 6, | |||
}); | |||
hands.onResults(onResults); | |||
handsRef.current = hands; | |||
sendToMediaPipe(); | |||
}; | |||
const sendToMediaPipe = async () => { | |||
if (isSendingRef.current) return; | |||
isSendingRef.current = true; | |||
if (videoRef.current && handsRef.current) { | |||
try { | |||
await handsRef.current.send({ image: videoRef.current }); | |||
} catch (error) { | |||
console.error('Error in sendToMediaPipe:', error); | |||
setTimeout(sendToMediaPipe, 500); | |||
} finally { | |||
isSendingRef.current = false; | |||
} | |||
} else { | |||
isSendingRef.current = false; | |||
} | |||
}; | |||
const onResults = (results: Results) => { | |||
const canvas = canvasRef.current; | |||
const ctx = canvas?.getContext('2d'); | |||
const video = videoRef.current; | |||
if (!canvas || !ctx || !video) return; | |||
const videoWidth = video.videoWidth; | |||
const videoHeight = video.videoHeight; | |||
ctx.save(); | |||
ctx.clearRect(0, 0, canvas.width, canvas.height); | |||
if (isUserFacing) { | |||
ctx.translate(canvas.width, 0); | |||
ctx.scale(-1, 1); | |||
} | |||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |||
let vote = 0; | |||
results.multiHandLandmarks?.forEach(landmarks => { | |||
let scaledLandmarks = landmarks.map(landmark => ({ | |||
x: landmark.x * videoWidth, | |||
y: landmark.y * videoHeight, | |||
z: landmark.z | |||
})); | |||
let gestureResult = getGesture(scaledLandmarks); | |||
if ((flipped || isUserFacing) && flipped !== isUserFacing) { | |||
gestureResult = gestureResult === "Left" ? "Right" : gestureResult === "Right" ? "Left" : gestureResult; | |||
} | |||
vote += (gestureResult === "Right") ? 1 : (gestureResult === "Left") ? -1 : 0; | |||
const handStyles: { [key: string]: { connectorStyle: any, landmarkStyle: any } } = { | |||
'Right': { | |||
connectorStyle: { color: '#00FF00', lineWidth: 1 }, | |||
landmarkStyle: { color: '#00FF00', radius: 1 } | |||
}, | |||
'Left': { | |||
connectorStyle: { color: '#FF0000', lineWidth: 1 }, | |||
landmarkStyle: { color: '#FF0000', radius: 1 } | |||
}, | |||
'None': { | |||
connectorStyle: { color: '#00BFFF', lineWidth: .5 }, | |||
landmarkStyle: { color: '#00BFFF', radius: .5 } | |||
} | |||
}; | |||
const currentStyle = handStyles[gestureResult] || handStyles['None']; | |||
drawConnectors(ctx, landmarks, HAND_CONNECTIONS, currentStyle.connectorStyle); | |||
drawLandmarks(ctx, landmarks, currentStyle.landmarkStyle); | |||
}); | |||
setGestureResult(vote) | |||
ctx.restore(); | |||
requestAnimationFrame(sendToMediaPipe); | |||
}; | |||
const flipCamera = () => { | |||
setIsUserFacing(!isUserFacing); | |||
} | |||
return ( | |||
<div className="camera-container"> | |||
<canvas ref={canvasRef} className="canvas-overlay" /> | |||
<video ref={videoRef} className={`camera-feed ${isUserFacing ? 'user-facing' : ''}`} autoPlay playsInline /> | |||
{!modalVisible && ( | |||
<button onClick={onSettingsClick} className="camera-button show-settings"> | |||
<FontAwesomeIcon icon={faGear} /> | |||
</button> | |||
)} | |||
{!modalVisible && ( | |||
<button onClick={flipCamera} className="camera-button switch-camera"> | |||
<FontAwesomeIcon icon={faArrowsRotate} /> | |||
</button> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default Camera; |
@ -0,0 +1,146 @@ | |||
import React, { useState, useEffect } from 'react'; | |||
import axios from "axios"; | |||
import logo from './assets/logo.png'; | |||
import instruction from './assets/instruction.png'; | |||
interface SettingsModalProps { | |||
onPairingCodeSubmit: (code: string) => void; | |||
onToggleGestureSwap: () => void; | |||
flipped: boolean; | |||
pairingCode: string; | |||
} | |||
interface PairingResponse { | |||
status: string; | |||
message?: string; | |||
} | |||
const SettingsModal: React.FC<SettingsModalProps> = ({ | |||
onPairingCodeSubmit, | |||
onToggleGestureSwap, | |||
flipped, | |||
pairingCode, | |||
}) => { | |||
const [pairingCodeInput, setPairingCodeInput] = useState<string>(pairingCode); | |||
const [errorMessage, setErrorMessage] = useState<string>(''); | |||
useEffect(() => { | |||
if (pairingCode !== '') { | |||
validatePairingCode(pairingCodeInput) | |||
.then(isValid => { | |||
if (isValid) { | |||
console.log('Pairing code is valid'); | |||
setErrorMessage(''); | |||
} else { | |||
console.log('Pairing code no longer available'); | |||
setErrorMessage('Pairing code no longer available. Please try another code.'); | |||
setPairingCodeInput(''); | |||
} | |||
}) | |||
.catch(error => { | |||
console.error('Error validating code:', error); | |||
setErrorMessage('Error validating pairing code. Please try again.'); | |||
}); | |||
} | |||
}, [pairingCode]); | |||
const handlePairingCodeInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||
const input = e.target.value; | |||
if (input.match(/^\d{0,4}$/)) { | |||
setPairingCodeInput(input); | |||
setErrorMessage(''); | |||
} | |||
}; | |||
const validatePairingCode = async (code: string): Promise<boolean> => { | |||
try { | |||
const response = await axios.post('https://gesture-presenter-bc9d819e6d43.herokuapp.com/validate_code', { code }); | |||
return response.data.status === 'valid'; | |||
} catch (error) { | |||
console.error('Error validating code:', error); | |||
return false; | |||
} | |||
}; | |||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||
if (e.key === 'Enter' && pairingCodeInput.length === 4) { | |||
handleSubmit(); | |||
} | |||
}; | |||
const handleSubmit = async () => { | |||
const isValid = await validatePairingCode(pairingCodeInput); | |||
if (isValid) { | |||
console.log('Pairing code is valid'); | |||
setErrorMessage(''); | |||
onPairingCodeSubmit(pairingCodeInput); | |||
} else { | |||
console.log('Invalid pairing code'); | |||
setErrorMessage('Invalid pairing code. Please try again.'); | |||
setPairingCodeInput(''); | |||
} | |||
}; | |||
const isMobileSafari = () => { | |||
return /iP(ad|od|hone)/i.test(navigator.platform) && /Safari/i.test(navigator.userAgent) && !/CriOS/i.test(navigator.userAgent); | |||
}; | |||
return ( | |||
<div className="settings-modal"> | |||
<div className="title-container"> | |||
<h2>GesturePresenter</h2> | |||
<img src={logo} alt="Logo" className="logo" /> | |||
</div> | |||
<p className="instructions">Enter your pairing code and adjust gesture settings. </p> | |||
{errorMessage && <div className="error-message">{errorMessage}</div>} | |||
<div> | |||
<label htmlFor="pairingCode">Pairing Code:</label> | |||
<input | |||
id="pairingCode" | |||
type="tel" | |||
pattern="[0-9]*" | |||
inputMode="numeric" | |||
value={pairingCodeInput} | |||
onChange={handlePairingCodeInputChange} | |||
onKeyDown={handleKeyDown} | |||
autoComplete="off" | |||
/> | |||
</div> | |||
<label htmlFor="gestureSwap">Swap Gesture Directions:</label> | |||
<div className="swap-container" onClick={onToggleGestureSwap}> | |||
<div className="instruction-container"> | |||
<img src={instruction} alt="Instruction Left" className="instruction-img img-flip" /> | |||
<span className="swap-label" style={{ color: flipped ? '#0ab20a' : '#ff0000'}}> | |||
{flipped ? 'Next' : 'Back'} | |||
</span> | |||
</div> | |||
<input | |||
id="gestureSwap" | |||
type="checkbox" | |||
checked={flipped} | |||
onChange={onToggleGestureSwap} | |||
/> | |||
<div className="instruction-container"> | |||
<span className="swap-label" style={{ color: flipped ? '#ff0000' : '#0ab20a'}}> | |||
{flipped ? 'Back' : 'Next'} | |||
</span> | |||
<img src={instruction} alt="Instruction Right" className="instruction-img" /> | |||
</div> | |||
</div> | |||
{isMobileSafari() && ( | |||
<p className="safari-note">iOS Safari users: hide the toolbar by tapping the | |||
"<span style={{ fontSize: '.6rem' }}>A</span>A" icon on the bar, | |||
and then selecting the "Hide Toolbar" option.</p> | |||
)} | |||
<button | |||
onClick={() => handleSubmit()} | |||
disabled={pairingCodeInput.length !== 4} | |||
className={`submit-button ${pairingCodeInput.length !== 4 ? 'disabled' : ''}`} | |||
> | |||
Submit | |||
</button> | |||
</div> | |||
); | |||
}; | |||
export default SettingsModal; |
@ -0,0 +1,13 @@ | |||
body { | |||
margin: 0; | |||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | |||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | |||
sans-serif; | |||
-webkit-font-smoothing: antialiased; | |||
-moz-osx-font-smoothing: grayscale; | |||
} | |||
code { | |||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | |||
monospace; | |||
} |
@ -0,0 +1,19 @@ | |||
import React from 'react'; | |||
import ReactDOM from 'react-dom/client'; | |||
import './index.css'; | |||
import App from './App'; | |||
import reportWebVitals from './reportWebVitals'; | |||
const root = ReactDOM.createRoot( | |||
document.getElementById('root') as HTMLElement | |||
); | |||
root.render( | |||
<React.StrictMode> | |||
<App /> | |||
</React.StrictMode> | |||
); | |||
// If you want to start measuring performance in your app, pass a function | |||
// to log results (for example: reportWebVitals(console.log)) | |||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals | |||
reportWebVitals(); |
@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> |
@ -0,0 +1 @@ | |||
/// <reference types="react-scripts" /> |
@ -0,0 +1,15 @@ | |||
import { ReportHandler } from 'web-vitals'; | |||
const reportWebVitals = (onPerfEntry?: ReportHandler) => { | |||
if (onPerfEntry && onPerfEntry instanceof Function) { | |||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { | |||
getCLS(onPerfEntry); | |||
getFID(onPerfEntry); | |||
getFCP(onPerfEntry); | |||
getLCP(onPerfEntry); | |||
getTTFB(onPerfEntry); | |||
}); | |||
} | |||
}; | |||
export default reportWebVitals; |
@ -0,0 +1,5 @@ | |||
// jest-dom adds custom jest matchers for asserting on DOM nodes. | |||
// allows you to do things like: | |||
// expect(element).toHaveTextContent(/react/i) | |||
// learn more: https://github.com/testing-library/jest-dom | |||
import '@testing-library/jest-dom'; |
@ -0,0 +1,71 @@ | |||
import { NormalizedLandmark, NormalizedLandmarkList } from '@mediapipe/hands'; | |||
function getCoordinates(landmark: NormalizedLandmark): number[] { | |||
return [landmark.x, landmark.y, landmark.z]; | |||
} | |||
function vectorSubtract(vec1: number[], vec2: number[]): number[] { | |||
return vec1.map((v, i) => v - vec2[i]); | |||
} | |||
function magnitude(vec: number[]): number { | |||
return Math.sqrt(vec.reduce((acc, val) => acc + val * val, 0)); | |||
} | |||
function dotProduct(vec1: number[], vec2: number[]): number { | |||
return vec1.reduce((acc, v, i) => acc + v * vec2[i], 0); | |||
} | |||
function cosineSimilarity(vec1: number[], vec2: number[]): number { | |||
return dotProduct(vec1, vec2) / (magnitude(vec1) * magnitude(vec2)); | |||
} | |||
function euclideanDistance(coord1: number[], coord2: number[]): number { | |||
return Math.sqrt(coord1.reduce((acc, v, i) => acc + Math.pow(v - coord2[i], 2), 0)); | |||
} | |||
function determineHandOrientation(landmark0: NormalizedLandmark, landmark9: NormalizedLandmark): string { | |||
const [x0, y0] = getCoordinates(landmark0); | |||
const [x9, y9] = getCoordinates(landmark9); | |||
const xd = x9 - x0; | |||
const yd = y9 - y0; | |||
if (xd > 0 && -2 <= yd / xd && yd / xd <= -0.05) return "Right"; | |||
if (xd < 0 && 0.05 <= yd / xd && yd / xd <= 2) return "Left"; | |||
return "None"; | |||
} | |||
function isFingerClosed(base: NormalizedLandmark, knuckle: NormalizedLandmark, joint: NormalizedLandmark, tip: NormalizedLandmark): boolean { | |||
const baseCoords = getCoordinates(base); | |||
return euclideanDistance(getCoordinates(tip), baseCoords) < | |||
1.2 * euclideanDistance(getCoordinates(knuckle), baseCoords) && | |||
euclideanDistance(getCoordinates(tip), baseCoords) < | |||
euclideanDistance(getCoordinates(joint), baseCoords); | |||
} | |||
function isThumbPointerExtended(thumb: NormalizedLandmark[], pointer: NormalizedLandmark[]): boolean { | |||
const vecThumb = vectorSubtract(getCoordinates(thumb[0]), getCoordinates(thumb[1])); | |||
if (vecThumb[1] / Math.abs(vecThumb[0]) < .75) return false; | |||
const vecPointer = pointer.map((p, i, arr) => | |||
(i < arr.length - 1 ? vectorSubtract(getCoordinates(p), getCoordinates(arr[i + 1])) : [0, 0, 0])); | |||
const pointerStraight = cosineSimilarity(vecPointer[0], vecPointer[1]) > 0.95 && | |||
cosineSimilarity(vecPointer[1], vecPointer[2]) > 0.95; | |||
const thumbPointerOrthogonal = cosineSimilarity(vecThumb, vecPointer[0]) < 0.85; | |||
return pointerStraight && thumbPointerOrthogonal; | |||
} | |||
function getGesture(lmList: NormalizedLandmarkList): string { | |||
if (!lmList || lmList.length === 0) return "None"; | |||
const thumbExtended = isThumbPointerExtended([lmList[2], lmList[3]], [lmList[5], lmList[6], lmList[7], lmList[8]]); | |||
if (!thumbExtended) return "None"; | |||
const closedFingers = [9, 13, 17].every(i => isFingerClosed(lmList[0], lmList[i], lmList[i + 2], lmList[i + 3])); | |||
if (!closedFingers) return "None"; | |||
return determineHandOrientation(lmList[0], lmList[9]); | |||
} | |||
export { getGesture }; |
@ -0,0 +1,26 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es5", | |||
"lib": [ | |||
"dom", | |||
"dom.iterable", | |||
"esnext" | |||
], | |||
"allowJs": true, | |||
"skipLibCheck": true, | |||
"esModuleInterop": true, | |||
"allowSyntheticDefaultImports": true, | |||
"strict": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"resolveJsonModule": true, | |||
"isolatedModules": true, | |||
"noEmit": true, | |||
"jsx": "react-jsx" | |||
}, | |||
"include": [ | |||
"src" | |||
] | |||
} |
@ -1,75 +0,0 @@ | |||
### PPT1 标题 时间 组员 | |||
- 标题:隔空手势识别系统Sprint1 | |||
- 时间:[具体时间] | |||
- 组员:[组员姓名] | |||
### PPT2 motivation | |||
在当今数字化时代,传统的鼠标键盘操作方式存在一定的局限性,如在某些场景下操作不够便捷、卫生等问题。隔空手势识别系统的出现,能够为用户提供更加自然、便捷的交互方式,填补了市场在非接触式交互领域的空白。它可以广泛应用于智能家居、智能办公、教育演示等多个场景,为用户带来全新的体验。 | |||
### PPT3 影响地图 | |||
- why? | |||
- 解决传统交互方式在特殊场景下的不便,如疫情期间减少接触、医疗场景避免交叉感染等。 | |||
- 满足用户对更加自然、便捷交互方式的需求,提升用户体验。 | |||
- 助力企业提升产品的科技感和竞争力,开拓新的市场领域。 | |||
- who? | |||
- 普通消费者:用于家庭娱乐、智能家居控制等。 | |||
- 办公人群:在会议演示、办公操作中提高效率。 | |||
- 教育工作者:在教学过程中进行更加生动的演示。 | |||
- 医疗人员:在医疗操作中避免交叉感染。 | |||
- how? | |||
- 核心部分分工:算法团队负责手势识别和语音识别算法的开发;软件团队负责系统的整体架构和交互界面的设计;硬件团队负责传感器等硬件设备的选型和开发。 | |||
- 采用的技术:计算机视觉技术用于手势识别,语音识别技术用于语音交互,深度学习算法提高识别的准确性和稳定性。 | |||
- what? | |||
- 核心功能:远程操控鼠标指针、点击操作、复杂手势识别与响应、语音识别与指令执行。 | |||
### PPT4 头脑风暴 | |||
- 点子1:在游戏中使用手势进行角色控制,增强游戏的沉浸感。 | |||
- 点子2:在商场的自助购物终端上使用手势操作,提高购物效率。 | |||
- 点子3:在汽车驾驶中,通过手势控制车内娱乐系统和导航系统,提高驾驶安全性。 | |||
- 点子4:在智能健身房中,使用手势控制健身设备和课程选择。 | |||
- 点子5:在虚拟现实和增强现实场景中,使用手势进行更加自然的交互。 | |||
### PPT5 basic design | |||
系统采用分层架构设计,包括硬件层、驱动层、算法层和应用层。硬件层负责数据的采集,驱动层负责硬件设备的驱动和数据传输,算法层负责手势和语音的识别,应用层负责与用户的交互和业务逻辑的处理。 | |||
### PPT6 用户故事 | |||
- card1 | |||
- conversation1:用户在客厅看电视,想要切换频道,但不想起身找遥控器。 | |||
- confirmation1:用户通过隔空手势轻松切换频道,无需使用遥控器。 | |||
- card2 | |||
- conversation2:办公人员在会议中需要展示文档,想要翻页但不想触碰鼠标。 | |||
- confirmation2:办公人员通过手势实现文档的翻页操作,提高会议效率。 | |||
- card3 | |||
- conversation3:教育工作者在教学过程中,想要展示图片但不想走到电脑前操作。 | |||
- confirmation3:教育工作者通过手势控制图片的切换和缩放,使教学更加生动。 | |||
### PPT7 初步开发构想 | |||
- v1(本周四):完成硬件设备的选型和采购,搭建开发环境,实现基本的手势识别算法。 | |||
- v2(下周一):完成系统的整体架构设计,实现手势识别与鼠标指针的初步关联。 | |||
- v3(下周四):完善手势识别的准确性和稳定性,实现语音识别功能,并进行初步的测试。 | |||
- v4(最终):完成系统的所有功能开发,进行全面的测试和优化,准备上线发布。 | |||
### PPT8 初步模块设想 | |||
- 子模块:硬件模块、手势识别模块、语音识别模块、交互界面模块、业务逻辑模块。 | |||
- 工作量对比:硬件模块和算法模块的工作量相对较大,交互界面模块和业务逻辑模块的工作量相对较小。可以用简单的数字比例表示,如硬件模块:30%,手势识别模块:25%,语音识别模块:20%,交互界面模块:15%,业务逻辑模块:10%。 | |||
- 优先级:手势识别模块和硬件模块的优先级较高,需要优先开发;语音识别模块和交互界面模块的优先级次之;业务逻辑模块的优先级相对较低。 | |||
### PPT9 future view | |||
未来,我们将不断优化系统的性能和功能,拓展更多的应用场景。例如,与更多的智能家居设备进行集成,实现更加智能化的家居控制;在工业领域应用,提高生产效率和安全性等。 | |||
### PPT10 用户旅程 | |||
- 优化点1:手势识别的准确性和稳定性需要进一步提高,减少误识别的情况。 | |||
- 优化点2:语音识别的灵敏度和准确性需要优化,确保在不同环境下都能准确识别用户的语音指令。 | |||
- 优化点3:交互界面的设计需要更加简洁直观,提高用户的操作体验。 | |||
- 优化点4:系统的响应速度需要加快,减少用户操作的等待时间。 | |||
- 优化点5:增加更多的手势和语音指令,满足用户多样化的需求。 | |||
- 优化点6:提高系统的兼容性,支持更多的操作系统和硬件设备。 | |||
### PPT11 在迭代中开发 | |||
- sprint planning:在每个冲刺阶段开始前,制定详细的计划,明确目标和任务。 | |||
- daily scrum:每天进行短会,沟通工作进展和遇到的问题。 | |||
- sprint review:在每个冲刺阶段结束后,进行评审,展示成果并收集反馈。 | |||
- sprint retrospective:对每个冲刺阶段进行回顾和总结,分析问题并提出改进措施。 | |||
### PPT12 thanks | |||
感谢大家的聆听! |
@ -1,10 +0,0 @@ | |||
# 📈 迭代计划(Sprint Plan) | |||
## 🗓️ 项目时间线 | |||
| 时间 | 内容说明 | | |||
| -------- | ------------------------------------------------- | | |||
| 本周四 | 🧩 第一次迭代演示(系统架构设计 + 原型 + UI 草稿) | | |||
| 下周一 | 🔁 第二次迭代演示(打通手势识别 → 系统控制的闭环) | | |||
| 下周四 | 🎙 第三次迭代演示(加入语音控制,初步功能联动) | | |||
| 下下周三 | 🚀 第四次迭代汇报(最终系统完整交付 + 场景演示) | |
@ -1,120 +0,0 @@ | |||
## 一、本轮迭代目标 | |||
本 Sprint 主要目标是完成 **基于浏览器端的手势控制系统雏形**,实现如下基础功能: | |||
- 搭建前端 Web 应用结构与 UI 页面 | |||
- 集成 MediaPipe 手势识别模型,完成实时手势检测 | |||
- 映射部分手势为系统控制行为(鼠标移动、点击等) | |||
- 搭建基础手势处理逻辑与组件封装结构 | |||
- 支持语音识别指令发送流程(准备后端对接) | |||
------ | |||
## 二、本轮主要完成内容 | |||
| 类别 | 工作内容 | | |||
| -------- | ------------------------------------------------------------ | | |||
| 前端搭建 | 基于 Vite + Vue 3 + TypeScript 完成项目结构、模块划分、页面初始化 | | |||
| 手势识别 | 集成 Google MediaPipe WASM 模型,完成实时检测与渲染 | | |||
| 控制逻辑 | 封装 `GestureHandler`、`TriggerAction`,实现手势到系统操作的映射 | | |||
| UI设计 | 完成仪表盘式主界面设计,支持响应式、自定义手势预览等 | | |||
| 模型结构 | 初步梳理 `Detector` 识别流程,统一封装初始化与推理逻辑 | | |||
| 后端准备 | 初始化 Python 接口 WebSocket 构建,支持语音识别交互 | | |||
------ | |||
## 三、系统结构设计 | |||
``` | |||
[用户摄像头] | |||
↓ | |||
[VideoDetector.vue] → 捕捉视频帧,传入检测器 | |||
↓ | |||
[Detector.ts] → 调用 MediaPipe 识别手势 | |||
↓ | |||
[GestureHandler.ts] → 映射行为(鼠标/键盘/语音) | |||
↓ | |||
[TriggerAction.ts] → 向后端/系统发出控制指令 | |||
↓ | |||
[Python 后端](语音识别/扩展指令) ← WebSocket 接入 | |||
``` | |||
------ | |||
## 四、核心技术栈与理由 | |||
| 技术 | 用途 | 原因 | | |||
| ---------------- | -------------------- | ------------------------------------------ | | |||
| Vue 3 + Vite | 前端框架 + 构建工具 | 快速开发,热更新快,Composition API 更灵活 | | |||
| TypeScript | 增强类型约束 | 减少运行时错误,提升可维护性 | | |||
| MediaPipe Tasks | 手势识别模型 | 体积小,支持浏览器部署,准确率高 | | |||
| WebSocket | 前后端实时通信 | 保持实时交互流畅 | | |||
| Python + FastAPI | 后端扩展接口(语音) | 快速搭建接口,后续支持 Py 模型运行 | | |||
------ | |||
## 五、主要功能点与说明 | |||
### 1. Detector 类封装 | |||
- 初始化 WASM 模型与识别器 | |||
- 封装 `detect()` 每帧调用逻辑 | |||
- 支持获取 `landmarks`, `gestures`, `handedness` | |||
### 2. 手势识别与映射 | |||
- 仅识别食指 → 鼠标移动 | |||
- 食指 + 中指 → 鼠标点击 | |||
- 食指 + 拇指捏合 → 滚动 | |||
- 四指竖起 → 发送快捷键 | |||
- 小指+拇指 → 启动语音识别(前端 WebSocket) | |||
### 3. UI 界面模块 | |||
- 实时视频预览窗口 | |||
- 子窗口:手势反馈 / 执行动作提示 | |||
- 配置面板:开关检测 / 配置行为映射 | |||
------ | |||
## 六、识别代码示例(关键手势) | |||
```ts | |||
if (gesture === HandGesture.ONLY_INDEX_UP) { | |||
this.triggerAction.moveMouse(x, y) | |||
} | |||
if (gesture === HandGesture.INDEX_AND_THUMB_UP) { | |||
this.triggerAction.scrollUp() | |||
} | |||
``` | |||
- 每种手势匹配后,调用封装的 WebSocket + 系统接口发送动作 | |||
- `TriggerAction` 支持复用 | |||
------ | |||
## 七、问题与解决方案 | |||
| 问题描述 | 解决方法 | | |||
| --------------------------------- | ---------------------------------------------------- | | |||
| WebSocket 连接后反复断开 | 增加连接状态判断 `readyState !== OPEN`,避免提前发送 | | |||
| Tauri 插件调用 `invoke undefined` | 使用 `npm run tauri dev` 启动,而非普通浏览器启动 | | |||
| 页面刷新后路由丢失(404) | Vue Router 改为 `createWebHashHistory()` 模式 | | |||
| 模型未加载或报错提示 | 增加 loading 控制,捕捉初始化异常 | | |||
------ | |||
## 八、未来规划(Sprint 2 预告) | |||
| 方向 | 说明 | | |||
| ----------------- | --------------------------------------------------- | | |||
| 多手势拓展 | 三指滚动、多指触发多功能、多手联合判断 | | |||
| 自定义 .task 模型 | 支持用户采集数据,自定义模型替换现有 MediaPipe 模型 | | |||
| 后端语音指令集成 | Vosk 模型识别语音,触发系统控制 | | |||
| 设置项管理 | 实现用户自定义快捷键、手势配置界面 | |
@ -1,146 +0,0 @@ | |||
# Sprint 3 技术文档 | |||
**识别到控制闭环打通 + 支持游戏操作** | |||
------ | |||
## 一、概述 | |||
本阶段实现了一个运行于浏览器端的手势识别系统,结合 MediaPipe 和 WebSocket,打通从摄像头手势识别 → 系统控制(鼠标、滚动、键盘) → 控制小游戏角色的全流程。 | |||
新增支持多种系统控制动作和游戏动作,包括:左右移动、跳跃、组合跳跃等。 | |||
------ | |||
## 二、模块结构概览 | |||
模块分工如下: | |||
- **Detector.ts** | |||
加载并初始化 MediaPipe 手势模型,检测手部关键点与手势状态,并调用 `GestureHandler`。 | |||
- **GestureHandler.ts** | |||
将识别出的手势映射为系统操作(鼠标、滚动、键盘指令等),支持连续确认、平滑处理、动作节流。 | |||
- **TriggerAction.ts** | |||
封装 WebSocket 通信,向后端发送 JSON 格式的控制命令。 | |||
``` | |||
[摄像头视频流] | |||
↓ | |||
[VideoDetector.vue] | |||
- 捕捉视频帧 | |||
- 显示实时画面 | |||
↓ | |||
[Detector.ts] | |||
- 使用 MediaPipe Tasks WASM 模型进行手势识别 | |||
- 返回关键点、手势类型 | |||
↓ | |||
[GestureHandler.ts] | |||
- 将识别结果转为行为 | |||
- 识别特定组合,如食指 + 拇指 → 滚动 | |||
↓ | |||
[TriggerAction.ts] | |||
- 向 WebSocket 发送控制指令 | |||
- 控制鼠标/键盘/后端动作 | |||
↓ | |||
[后端 Python] | |||
- WebSocket 接口接收指令 | |||
- 准备语音接口/控制中枢 | |||
``` | |||
------ | |||
## 三、识别手势说明(扩展后) | |||
每帧识别手指竖起状态(拇指到小指,0 或 1),组合为状态串,查表识别对应手势: | |||
| 状态串 | 手势名 | 动作描述 | | |||
| ----------- | ------------------- | ------------------------- | | |||
| `0,1,0,0,0` | only_index_up | 鼠标移动 | | |||
| `1,1,0,0,0` | index_and_thumb_up | 鼠标点击 | | |||
| `0,0,1,1,1` | scroll_gesture_2 | 页面滚动 | | |||
| `1,0,1,1,1` | scroll_gesture_2 | 页面滚动(兼容变体) | | |||
| `0,1,1,1,1` | four_fingers_up | 发送快捷键(如播放/全屏) | | |||
| `1,1,1,1,1` | stop_gesture | 暂停/开始识别 | | |||
| `1,0,0,0,1` | voice_gesture_start | 启动语音识别 | | |||
| `0,0,0,0,0` | voice_gesture_stop | 停止语音识别 | | |||
| `0,1,1,0,0` | jump | 小人跳跃(上键) | | |||
| `1,1,1,0,0` | rightjump | 小人右跳(右+上组合) | | |||
| 自定义判断 | direction_right | 小人右移(长按右键) | | |||
| 自定义判断 | delete_gesture | 小人左移(长按左键) | | |||
------ | |||
## 四、控制行为逻辑说明 | |||
### 鼠标控制 | |||
- **移动**:基于食指指尖位置映射至屏幕坐标,支持平滑处理,减少抖动。 | |||
- **点击**:食指与拇指同时上举,节流处理防止重复点击。 | |||
- **滚动**:捏合(拇指+食指)后上下移动控制滚动方向,带阈值判断。 | |||
### 快捷键/系统操作 | |||
- 四指上举 → 发送自定义按键(如全屏、暂停、下一集等)。 | |||
- 五指上举 → 暂停/开始识别。支持进度提示。 | |||
- 删除手势(右手拇指伸出,其余收起)→ 连续发送 Backspace/方向键。 | |||
- 自定义 holdKey 方法支持模拟长按。 | |||
### 游戏控制扩展(键盘模拟) | |||
| 手势名 | 动作 | | |||
| --------------- | -------------------- | | |||
| delete_gesture | 向左移动(左键长按) | | |||
| direction_right | 向右移动(右键长按) | | |||
| jump | 向上跳跃(上键) | | |||
| rightjump | 右跳(右后延迟上) | | |||
------ | |||
## 五、性能与稳定性处理 | |||
| 问题 | 解决方案 | | |||
| ------------------ | ------------------------------------------------------ | | |||
| 手势误判 | 连续帧确认(minGestureCount ≥ 5) | | |||
| 鼠标抖动 | 屏幕坐标引入平滑系数 `smoothening` | | |||
| 动作重复触发 | 节流控制点击/滚动/快捷键/方向键(如 `CLICK_INTERVAL`) | | |||
| WebSocket 中断重连 | 异常断线后自动重连,带重试时间间隔 | | |||
| 滚动误触 | 拇指与食指距离阈值控制是否处于“捏合”状态 | | |||
| UI 状态提示不同步 | 使用全局 store(如 `app_store.sub_window_info`)更新 | | |||
------ | |||
## 六、技术特性总结 | |||
- 支持 MediaPipe WASM 模型在前端本地运行,低延迟识别 | |||
- 所有动作模块化封装,便于未来扩展新手势、新控制指令 | |||
- 手势控制与后端通过 WebSocket 实时交互,接口稳定 | |||
- 支持游戏场景(虚拟角色)控制,具备实际互动展示能力 | |||
- 控制逻辑可配置:如识别区域、快捷键内容、手势灵敏度 | |||
------ | |||
## 七、后续优化建议(下一阶段) | |||
| 方向 | 目标 | | |||
| -------------------- | ----------------------------------------------- | | |||
| 增加左手手势组合识别 | 允许双手协同控制,比如捏合+四指等 | | |||
| 提升模型识别精度 | 支持定制 MediaPipe .task 文件、自采样训练 | | |||
| UI 设置面板 | 提供手势→动作自定义、阈值调节、快捷键修改 | | |||
| 引入语音控制 | Whisper/Vosk 实现语音命令解析,结合手势联动使用 | | |||
| 多模式切换 | 视频控制、PPT 控制、游戏控制等可切换交互模式 | | |||
| 场景演示/视频录制 | 准备真实交互展示视频,用于演示或上线宣传 | | |||
------ | |||
## 八、结语 | |||
本轮迭代成功实现了从手势识别到控制动作的完整闭环,扩展支持了游戏角色控制(多方向移动与跳跃),并在操作流畅性、准确性与系统解耦方面都取得实质进展。后续可以围绕用户可配置性、语音识别联动与多场景适配进一步扩展系统能力。 |
@ -1,306 +0,0 @@ | |||
# 📋 WaveControl 隔空手势控制系统 - 测试文档 | |||
## 一、测试概述 | |||
### 1.1 测试目标 | |||
确保系统三个核心子模块功能完整、交互稳定、性能可靠,满足多场景下的非接触式人机交互需求,包括主控制平台、手语通平台、游戏控制模块。 | |||
### 1.2 测试对象 | |||
- 主控制系统(控制面板 + 手势库管理 + 语音识别) | |||
- WaveSign 手语通系统(教学、评分、社区) | |||
- 虚拟赛车手柄系统(游戏中控制响应) | |||
### 1.3 测试类型 | |||
| 测试类型 | 说明 | | |||
| ------------ | ------------------------------------------ | | |||
| 功能测试 | 各模块是否能完成核心功能 | | |||
| 集成测试 | 各子模块之间的数据流与交互是否正确 | | |||
| 性能测试 | 系统是否在高帧率下稳定运行、响应及时 | | |||
| 边界测试 | 识别模糊手势、断网、摄像头断连等异常情况 | | |||
| 用户体验测试 | 普通用户是否能流畅使用、易上手、有清晰反馈 | | |||
## 二、测试环境 | |||
| 项目 | 配置 | | |||
| ----------- | --------------------------------- | | |||
| 操作系统 | Windows 10 / 11、MacOS | | |||
| Python 版本 | Python 3.8+ | | |||
| 浏览器 | Chrome / Edge | | |||
| 识别设备 | USB 外接摄像头 / 笔记本自带摄像头 | | |||
| 游戏平台 | Steam 平台《Rush Rally Origins》 | | |||
## 三、测试用例设计 | |||
### ✅ 主控制系统 | |||
| 序号 | 手势名称 | 手势动作说明 | 所属类型 | | |||
| ---- | ------------ | --------------------------------------------- | -------- | | |||
| 01 | 光标控制 | 竖起食指滑动控制光标位置 | 通用控制 | | |||
| 02 | 鼠标左键点击 | 食指 + 大拇指上举执行点击 | 通用控制 | | |||
| 03 | 滚动控制 | okay 手势(食指+拇指捏合),上下移动滚动页面 | 通用控制 | | |||
| 04 | 全屏控制 | 四指并拢向上 → 触发设定键(默认 f 键) | 通用控制 | | |||
| 05 | 退格 | 特定手势触发退格键 | 通用控制 | | |||
| 06 | 开始语音识别 | 六指手势触发语音识别启动 | 通用控制 | | |||
| 07 | 结束语音识别 | 拳头手势触发语音识别停止 | 通用控制 | | |||
| 08 | 暂停/继续 | 单手张开保持 1.5 秒触发暂停/继续识别 | 通用控制 | | |||
| 09 | 向右移动 | 拇指上抬,其余手指收回 → 控制游戏角色向右移动 | 游戏控制 | | |||
| 10 | 跳跃 | 食指、中指上举 → 控制跳跃动作 | 游戏控制 | | |||
| 11 | 右跳跃 | 拇指 + 食指 + 中指上举 → 控制右跳跃 | 游戏控制 | | |||
| 12 | 上一首 | 大拇指向左摆动 → 上一首音乐 | 音乐控制 | | |||
| 13 | 下一首 | 大拇指向右摆动 → 下一首音乐 | 音乐控制 | | |||
| 14 | 暂停/播放 | 比耶手势(✌️ ) → 暂停或播放音乐 | 音乐控制 | | |||
| 15 | 切换音乐模式 | rock 手势(🤘)→ 切换音乐/普通控制模式 | 模式切换 | | |||
### 🤟 手语通 WaveSign | |||
#### ✅ 1. **SLClassroom(手语教室)模块** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| -------- | ------------------ | --------------------------- | --------------------------- | | |||
| TC-SL-01 | 摄像头实时识别 | 摄像头接通后手势是否被识别 | 返回手语内容 + 实时评分动画 | | |||
| TC-SL-02 | 视频上传评分 | 上传手语视频后是否正常评分 | 返回分数、标准建议 | | |||
| TC-SL-03 | 视频课程学习流程 | 是否能顺序播放、标记已学 | 视频播放正常,课程解锁 | | |||
| TC-SL-04 | 翻转卡片练习 | 卡片是否翻转 + 显示正确答案 | 点击翻面后显示预设解释 | | |||
| TC-SL-05 | 任务式课程地图跳转 | 点击课程节点是否正确跳转 | 跳转至对应课程页 | | |||
#### ✅ 2. **Community(社区系统)模块** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| --------- | ------------- | -------------------------- | -------------------------- | | |||
| TC-COM-01 | 发帖功能 | 输入文字/图片/视频发帖 | 帖子成功展示 + ID 唯一标识 | | |||
| TC-COM-02 | 评论功能 | 帖子下评论 + 删除 | 评论正常显示/删除 | | |||
| TC-COM-03 | 点赞机制 | 点赞后数值变化 | 点赞数+1,重复点则取消 | | |||
| TC-COM-04 | 热门话题显示 | 帖子互动数高时是否上热门区 | 热门区出现帖子 | | |||
| TC-COM-05 | 内容审核机制 | 敏感词是否被拦截/提示 | 给出“内容不合规”提示 | | |||
| TC-COM-06 | 标签推荐 | 选择话题是否推荐相关内容 | 推荐结果合理、及时加载 | | |||
| TC-COM-07 | 关注/取关系统 | 关注后是否成功建立关注关系 | 动态展示更新 | | |||
#### ✅ 3. **Schedule(日程与任务模块)** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| --------- | -------------------- | -------------------------- | ------------------------- | | |||
| TC-SCH-01 | 添加任务清单 | 是否可设置日期/优先级 | 列表显示任务 + 状态可勾选 | | |||
| TC-SCH-02 | 事件提醒触发 | 设置提醒是否能按时通知 | 到时弹出提醒/响铃提示 | | |||
| TC-SCH-03 | 日/周/月视图切换 | 是否能无误切换不同日历视图 | 各视图正常显示 | | |||
| TC-SCH-04 | 删除任务是否更新视图 | 删除后是否同步更新 | 日历图/列表同步清除 | | |||
#### ✅ 4. **LifeServing(生活服务)模块** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| -------- | ---------------- | ---------------------------------- | ---------------------------- | | |||
| TC-LS-01 | 发布内容管理 | 发布好物推荐/活动等信息 | 内容展示无误 | | |||
| TC-LS-02 | 辅助器具推荐 | 推荐列表是否分类清晰/加载正确 | 分类显示 + 图片正常 | | |||
| TC-LS-03 | 学习设备推荐 | 展示硬件学习工具列表 | 内容图文加载完整 | | |||
| TC-LS-04 | 就业信息推送 | 职位内容、公司信息展示是否完整 | 包含岗位名称、描述、联系方式 | | |||
| TC-LS-05 | 残障友好企业标识 | 是否加V / 标签区分 | 有“友好企业”提示 | | |||
| TC-LS-06 | 无障碍路线规划 | 是否能绘制无台阶/电梯路线 | 地图路径合理 | | |||
| TC-LS-07 | 实时公交提醒 | 路线是否加载正确/更新是否及时 | 实时展示公交进站时间 | | |||
| TC-LS-08 | 活动预告展示 | 亲子/文娱/演出分类信息加载是否完整 | 可报名 + 有时间地点说明 | | |||
#### ✅ 5. **MyPage(个人中心)模块** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| -------- | ------------------ | ------------------------------- | ---------------------------- | | |||
| TC-MY-01 | 修改头像 | 上传头像 + 预览功能是否生效 | 显示新头像 | | |||
| TC-MY-02 | 修改资料 | 昵称/简介/联系方式可修改 | 保存后页面同步更新 | | |||
| TC-MY-03 | 收藏管理 | 收藏帖子/课程后是否能查看 | 我的收藏页显示内容 | | |||
| TC-MY-04 | 账号注册/登录/登出 | 是否可注册新用户 + 正确跳转状态 | 新用户进入首页,旧账号可退出 | | |||
| TC-MY-05 | 权限角色识别 | 是否区分普通用户/审核员角色 | 页面显示不同选项 | | |||
#### ✅ 6. **技术实现相关(稳定性/架构)测试** | |||
| 用例编号 | 用例名称 | 测试点 | 预期结果 | | |||
| ---------- | --------------------- | ----------------------------- | ------------------------ | | |||
| TC-TECH-01 | SQLite 数据读写测试 | 批量操作课程/帖子是否存取正常 | 数据不丢失,响应时间正常 | | |||
| TC-TECH-02 | MediaPipe模型崩溃恢复 | 强行关闭摄像头后是否重连 | 显示重连提示/自动恢复 | | |||
| TC-TECH-03 | Tailwind 前端样式响应 | 各分辨率下页面是否响应式变化 | 不溢出,元素自适应 | | |||
### 🏎️ 游戏控制模块 | |||
| 用例编号 | 模块 | 用例名称 | 前置条件 | 测试步骤 | 预期结果 | 优先级 | | |||
| ---------- | -------- | ---------------- | ------------------------ | -------------------------------- | ---------------------------------- | ------ | | |||
| TC-GAME-01 | 游戏控制 | 加速动作识别 | 摄像头运行正常,程序启动 | 举起右手拇指上扬 | 车辆开始加速 | 高 | | |||
| TC-GAME-02 | 游戏控制 | 刹车动作识别 | 同上 | 举起左手拇指上扬 | 车辆开始减速 | 高 | | |||
| TC-GAME-03 | 游戏控制 | 左右转向动作识别 | 同上 | 向左倾手掌 | 车辆向左转弯 | 高 | | |||
| TC-GAME-04 | 游戏控制 | 手势连续识别切换 | 程序运行中 | 快速从加速 → 左转 → 刹车切换手势 | 每步操作都有反馈,游戏响应流畅 | 高 | | |||
| TC-GAME-05 | 游戏控制 | 识别抖动干扰测试 | 程序运行中,抖动手指 | 快速小幅度摆动手指 | 系统不误触操作,保持稳定 | 中 | | |||
| TC-GAME-06 | 游戏控制 | 虚拟手柄断连恢复 | 手柄模拟开启中 | 断开 vgamepad 端口 → 重连 | 程序检测中断并尝试自动恢复 | 中 | | |||
| TC-GAME-07 | 游戏控制 | 低帧率下性能表现 | 模拟20fps摄像头 | 尝试完成左右转、加速等动作 | 出现识别滞后,界面给出低帧警告 | 中 | | |||
| TC-GAME-08 | 游戏控制 | UI反馈准确性 | 摄像头运行中 | 做出加速动作,观察 UI 面板反馈 | 显示“当前动作:加速”,图像区域标亮 | 中 | | |||
## 四、测试程序实现与技术支撑 | |||
本项目测试除使用主系统 UI 操作外,亦开发了两套独立测试程序用于识别稳定性、输入准确性与边界场景的验证,覆盖核心逻辑路径,支撑高频回归测试与离线分析。 | |||
### 4.1 手势识别测试程序(wavecontrol-test 模块) | |||
项目测试脚本集中存放于 `wavecontrol-test/src/` (gesture分支)路径下,采用 TypeScript + Vue3 框架实现,通过 MediaPipe 实时检测与手势逻辑模块协同,实现系统功能验证。 | |||
#### 4.1.1 hand_landmark 模块 | |||
- **detector.ts** | |||
核心手部关键点检测模块,封装对 MediaPipe 的调用逻辑,统一输出手部21个关键点的坐标、置信度等数据。 | |||
- 功能点:初始化摄像头流、绑定回调、封装模型参数。 | |||
- 用于:为 `VideoDetector.vue` 和 `gesture_handler.ts` 提供关键点数据源。 | |||
- **gesture_handler.ts** | |||
手势解析与事件派发模块,将 landmark 数据解析为具体手势动作(如光标控制、点击、跳跃等)。 | |||
- 支持自定义手势库扩展。 | |||
- 与游戏控制模块或系统控制指令绑定。 | |||
- **VideoDetector.vue** | |||
Vue 组件封装,展示摄像头实时画面 + 可视化 landmark 点位(调试模式用)。 | |||
- 提供测试 UI 面板,便于调试每个手势识别过程。 | |||
- 集成 FPS 状态、实时识别手势结果反馈。 | |||
#### 4.1.2 独立运行说明与调试提示 | |||
- **模块定位:** | |||
`wavecontrol-test` 为独立测试工程,当前未集成至主项目的 UI 页面路由体系,主要用于**手势识别逻辑的单元测试与调试验证**。 | |||
- **运行状态说明:** | |||
启动后页面加载为空白(白屏),属**正常表现**,原因如下: | |||
- 项目尚未渲染任何主界面组件; | |||
- 测试逻辑运行主要依赖控制台输出来验证关键逻辑(如 landmark 检测、手势判断等)。 | |||
- **调试建议:** | |||
启动后打开浏览器开发者工具(快捷键 `F12` 或 `Ctrl+Shift+I`),查看: | |||
- 控制台(Console):打印识别结果、错误日志、手势状态; | |||
- 网络(Network):确认模型文件是否成功加载; | |||
- 元素(Elements):手动挂载 `VideoDetector.vue`注入测试组件进行临时渲染调试。 | |||
### 4.2 游戏控制模块测试(`test.py` 等) | |||
### 4.2.1 模块定位与结构 | |||
该模块为游戏控制核心动作识别的测试环境,主要用于模拟真实场景下用户的手势输入,评估系统能否准确识别特定动作(如加速、转弯、刹车等),并通过 OpenCV 实时可视化手势状态与角度变化。 | |||
- **所在目录:** `wavecontrol/test_py/`(`game_control` 分支) | |||
- **相关脚本:** | |||
- `test.py`:主测试入口,执行实时手势识别与状态展示。 | |||
- `gesture_detector.py`:封装具体的手势识别逻辑。 | |||
- `utils.py`:通用工具类,如角度计算、数据格式处理等。 | |||
- `test_xbox.py`:拟用于连接虚拟 Xbox 控制器,模拟游戏输入。 | |||
- `test_gui.py`:图形界面测试入口。 | |||
### 4.2.2 核心测试逻辑(基于 `test.py`) | |||
**功能点解析:** | |||
- **手势状态追踪:** | |||
通过 `gesture_status` 字典记录五类状态: | |||
- `Left Fist`、`Right Fist`:用于映射刹车/加速等动作 | |||
- `Left Thumb`、`Right Thumb`:映射转向或音量等功能 | |||
- `Angle`:表示当前手势的偏转角度,用于模拟方向盘行为或特殊动作 | |||
- **视觉反馈:** | |||
使用 `OpenCV` (`cv2`) 实时将识别结果叠加在摄像头图像上,显示五种状态的当前值,方便快速人工验证是否识别准确。 | |||
- **模块化调用:** | |||
`GestureDetector` 实例通过 `gesture_detector.py` 导入,使得识别逻辑可独立测试、方便集成到主游戏模块。 | |||
------ | |||
#### 4.2.3 使用说明 | |||
- **启动方法:** | |||
``` | |||
python test.py | |||
``` | |||
**运行效果:** | |||
- 打开摄像头窗口; | |||
- 实时显示识别到的五项手势状态; | |||
- 开发者可通过手部动作验证识别准确性。 | |||
- **测试目标:** | |||
- 验证关键手势在自然使用状态下的识别准确率; | |||
- 评估系统在不同光照/距离/角度条件下对“加速”“刹车”“左右转”的响应稳定性; | |||
- 检查 OpenCV 图像反馈是否与真实操作一致,便于回归对比。 | |||
## 五、测试结果分析 | |||
#### 项目整体手动测试结果: | |||
[⌛️项目测试结果](./项目测试结果.xlsx) | |||
#### 主平台自动化测试结果: | |||
全部测试通过,覆盖率达79% | |||
 | |||
#### WaveSign平台自动化测试结果: | |||
全部测试通过,覆盖率达93% | |||
具体测试设计和结果见以下链接: | |||
[WaveSign测试文档](https://gitee.com/wydhhh/software-engineering/blob/wavesign/WaveSign%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.md) | |||
[WaveSign测试结果html报告](https://gitee.com/wydhhh/software-engineering/tree/wavesign/htmlcov) | |||
### **5.1 测试通过率** | |||
- **总体通过率:** | |||
在所有模块(主控制系统 + WaveSign + 游戏控制模块)共计 **50+ 条用例 × 10次测试 = 500+ 次执行记录**中,约 **88%** 的测试结果为“通过”,**12%** 的测试记录显示“未通过”。 | |||
- **通过率较高的模块:** | |||
- **主控制系统:**通过率 **>90%**,大部分基础手势(如光标控制、鼠标点击、滚动控制)表现稳定。 | |||
- **WaveSign 社区与个人中心模块:**通过率 **约95%**,发帖、评论、关注、资料修改等常规操作无明显问题。 | |||
- **通过率较低的模块:** | |||
- **WaveSign SLClassroom(手语教室):**实时识别和视频上传评分测试中偶尔出现 **识别延迟** 或 **评分不稳定**,通过率约 **80%**。 | |||
- **游戏控制模块:**“低帧率下性能表现”与“识别抖动干扰测试”未通过率稍高(**20% 左右**),主要原因是低帧率摄像头模拟下识别滞后明显。 | |||
------ | |||
### **5.2 典型问题分析** | |||
- **(1)识别类问题:** | |||
- 当背景复杂或光照不足时,MediaPipe 模型识别准确率下降,个别测试记录显示 **响应时间超过0.7s**,甚至未正确识别手势。 | |||
- “六指手势启动语音识别”在部分测试中未触发,需要优化手势阈值。 | |||
- **(2)性能问题:** | |||
- 游戏模块在模拟 **20fps 低帧率**摄像头时,出现识别滞后,UI延迟反馈。 | |||
- 部分 UI 动画在高分辨率(4K)设备上存在轻微卡顿,需要进一步优化前端渲染。 | |||
- **(3)社区系统:** | |||
- 敏感词拦截逻辑偶尔误报,如普通词语被识别为敏感词。 | |||
- **(4)任务提醒功能:** | |||
- **日/周/月视图切换**测试中发现,在多任务快速切换时,少数场景下界面刷新不完全。 | |||
------ | |||
### **5.3 综合结论** | |||
- WaveControl 系统整体功能 **满足预期目标**,大部分核心功能已稳定实现。 | |||