Compare commits

...

No commits in common. 'master' and 'gesture_for_chrome' have entirely different histories.

80 changed files with 26888 additions and 809 deletions
Split View
  1. +25
    -3
      .gitignore
  2. +5
    -0
      .vscode/settings.json
  3. +21
    -0
      LICENSE
  4. +1
    -149
      README.md
  5. +1
    -0
      backend/.gitignore
  6. +1
    -0
      backend/Procfile
  7. +120
    -0
      backend/app.py
  8. BIN
      backend/instance/pairing_codes.db
  9. +24
    -0
      backend/requirements.txt
  10. +1
    -0
      backend/runtime.txt
  11. +1
    -0
      chrome-extension/dist/background.js
  12. BIN
      chrome-extension/dist/icons/icon128.png
  13. BIN
      chrome-extension/dist/icons/icon16.png
  14. BIN
      chrome-extension/dist/icons/icon32.png
  15. BIN
      chrome-extension/dist/icons/icon48.png
  16. +1
    -0
      chrome-extension/dist/index.html
  17. +2
    -0
      chrome-extension/dist/index.js
  18. +29
    -0
      chrome-extension/dist/index.js.LICENSE.txt
  19. +1
    -0
      chrome-extension/dist/js/background.js
  20. +1
    -0
      chrome-extension/dist/js/contentScript.js
  21. BIN
      chrome-extension/dist/js/images/logo.png
  22. +1
    -0
      chrome-extension/dist/js/index.html
  23. +2
    -0
      chrome-extension/dist/js/index.js
  24. +29
    -0
      chrome-extension/dist/js/index.js.LICENSE.txt
  25. +28
    -0
      chrome-extension/dist/manifest.json
  26. +1
    -0
      chrome-extension/dist/popup.html
  27. +2
    -0
      chrome-extension/dist/popup.js
  28. +29
    -0
      chrome-extension/dist/popup.js.LICENSE.txt
  29. BIN
      chrome-extension/icons/icon128.png
  30. BIN
      chrome-extension/icons/icon16.png
  31. BIN
      chrome-extension/icons/icon32.png
  32. BIN
      chrome-extension/icons/icon48.png
  33. +28
    -0
      chrome-extension/manifest.json
  34. +7017
    -0
      chrome-extension/package-lock.json
  35. +38
    -0
      chrome-extension/package.json
  36. BIN
      chrome-extension/src/assets/logo.png
  37. +106
    -0
      chrome-extension/src/background.ts
  38. +54
    -0
      chrome-extension/src/contentScript.ts
  39. +4
    -0
      chrome-extension/src/images.d.ts
  40. +13
    -0
      chrome-extension/src/index.tsx
  41. +89
    -0
      chrome-extension/src/popup.tsx
  42. +77
    -0
      chrome-extension/src/popupStyles.css
  43. +14
    -0
      chrome-extension/tsconfig.json
  44. +68
    -0
      chrome-extension/webpack.config.js
  45. +18031
    -0
      frontend/package-lock.json
  46. +57
    -0
      frontend/package.json
  47. BIN
      frontend/public/favicon.ico
  48. +42
    -0
      frontend/public/index.html
  49. BIN
      frontend/public/instruction.png
  50. BIN
      frontend/public/logo.png
  51. BIN
      frontend/public/logo192.png
  52. BIN
      frontend/public/logo512.png
  53. +25
    -0
      frontend/public/manifest.json
  54. +3
    -0
      frontend/public/robots.txt
  55. +280
    -0
      frontend/src/App.css
  56. +9
    -0
      frontend/src/App.test.tsx
  57. +59
    -0
      frontend/src/App.tsx
  58. +251
    -0
      frontend/src/Camera.tsx
  59. +146
    -0
      frontend/src/SettingsModal.tsx
  60. BIN
      frontend/src/assets/instruction.png
  61. BIN
      frontend/src/assets/logo.png
  62. +13
    -0
      frontend/src/index.css
  63. +19
    -0
      frontend/src/index.tsx
  64. +1
    -0
      frontend/src/logo.svg
  65. +1
    -0
      frontend/src/react-app-env.d.ts
  66. +15
    -0
      frontend/src/reportWebVitals.ts
  67. +5
    -0
      frontend/src/setupTests.ts
  68. +71
    -0
      frontend/src/utils/GestureDetection.ts
  69. +26
    -0
      frontend/tsconfig.json
  70. BIN
      img/主平台自动化测试结果.png
  71. +0
    -75
      sprint1/sprint1-planning.md
  72. +0
    -10
      sprint1/迭代计划.md
  73. BIN
      sprint1/隔空手势识别系统Sprint1.pptx
  74. BIN
      sprint2/WaveControl-sprint2.pptx
  75. +0
    -120
      sprint2/sprint2.md
  76. BIN
      sprint3/Sprint 3 技术文档:手势识别与游戏控制.pptx
  77. +0
    -146
      sprint3/sprint3.md
  78. BIN
      sprint4/WaveControl-sprint4.pptx
  79. +0
    -306
      项目测试文档.md
  80. BIN
      项目测试结果.xlsx

+ 25
- 3
.gitignore View File

@ -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*

+ 5
- 0
.vscode/settings.json View File

@ -0,0 +1,5 @@
{
"python-envs.defaultEnvManager": "ms-python.python:conda",
"python-envs.defaultPackageManager": "ms-python.python:conda",
"python-envs.pythonProjects": []
}

+ 21
- 0
LICENSE View File

@ -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
README.md View File

@ -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

+ 1
- 0
backend/.gitignore View File

@ -0,0 +1 @@
.idea

+ 1
- 0
backend/Procfile View File

@ -0,0 +1 @@
web: gunicorn --worker-class eventlet -w 1 app:app

+ 120
- 0
backend/app.py View File

@ -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)

BIN
backend/instance/pairing_codes.db View File


+ 24
- 0
backend/requirements.txt View File

@ -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

+ 1
- 0
backend/runtime.txt View File

@ -0,0 +1 @@
python-3.10.13

+ 1
- 0
chrome-extension/dist/background.js
File diff suppressed because it is too large
View File


BIN
chrome-extension/dist/icons/icon128.png View File

Before After
Width: 128  |  Height: 128  |  Size: 12 KiB

BIN
chrome-extension/dist/icons/icon16.png View File

Before After
Width: 16  |  Height: 16  |  Size: 520 B

BIN
chrome-extension/dist/icons/icon32.png View File

Before After
Width: 32  |  Height: 32  |  Size: 1.4 KiB

BIN
chrome-extension/dist/icons/icon48.png View File

Before After
Width: 48  |  Height: 48  |  Size: 2.5 KiB

+ 1
- 0
chrome-extension/dist/index.html View File

@ -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>

+ 2
- 0
chrome-extension/dist/index.js
File diff suppressed because it is too large
View File


+ 29
- 0
chrome-extension/dist/index.js.LICENSE.txt View File

@ -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.
*/

+ 1
- 0
chrome-extension/dist/js/background.js
File diff suppressed because it is too large
View File


+ 1
- 0
chrome-extension/dist/js/contentScript.js View File

@ -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)})})();

BIN
chrome-extension/dist/js/images/logo.png View File

Before After
Width: 239  |  Height: 239  |  Size: 29 KiB

+ 1
- 0
chrome-extension/dist/js/index.html View File

@ -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>

+ 2
- 0
chrome-extension/dist/js/index.js
File diff suppressed because it is too large
View File


+ 29
- 0
chrome-extension/dist/js/index.js.LICENSE.txt View File

@ -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.
*/

+ 28
- 0
chrome-extension/dist/manifest.json View File

@ -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'"
}
}

+ 1
- 0
chrome-extension/dist/popup.html View File

@ -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>

+ 2
- 0
chrome-extension/dist/popup.js
File diff suppressed because it is too large
View File


+ 29
- 0
chrome-extension/dist/popup.js.LICENSE.txt View File

@ -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.
*/

BIN
chrome-extension/icons/icon128.png View File

Before After
Width: 128  |  Height: 128  |  Size: 12 KiB

BIN
chrome-extension/icons/icon16.png View File

Before After
Width: 16  |  Height: 16  |  Size: 520 B

BIN
chrome-extension/icons/icon32.png View File

Before After
Width: 32  |  Height: 32  |  Size: 1.4 KiB

BIN
chrome-extension/icons/icon48.png View File

Before After
Width: 48  |  Height: 48  |  Size: 2.5 KiB

+ 28
- 0
chrome-extension/manifest.json View File

@ -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'"
}
}

+ 7017
- 0
chrome-extension/package-lock.json
File diff suppressed because it is too large
View File


+ 38
- 0
chrome-extension/package.json View File

@ -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"
}
}

BIN
chrome-extension/src/assets/logo.png View File

Before After
Width: 239  |  Height: 239  |  Size: 29 KiB

+ 106
- 0
chrome-extension/src/background.ts View File

@ -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;
}
});

+ 54
- 0
chrome-extension/src/contentScript.ts View File

@ -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);
}

+ 4
- 0
chrome-extension/src/images.d.ts View File

@ -0,0 +1,4 @@
declare module '*.png' {
const content: string;
export default content;
}

+ 13
- 0
chrome-extension/src/index.tsx View File

@ -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>
);

+ 89
- 0
chrome-extension/src/popup.tsx View File

@ -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;

+ 77
- 0
chrome-extension/src/popupStyles.css View File

@ -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);
}

+ 14
- 0
chrome-extension/tsconfig.json View File

@ -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/**/*"]
}

+ 68
- 0
chrome-extension/webpack.config.js View File

@ -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],
})
);
}

+ 18031
- 0
frontend/package-lock.json
File diff suppressed because it is too large
View File


+ 57
- 0
frontend/package.json View File

@ -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/"
}

BIN
frontend/public/favicon.ico View File

Before After

+ 42
- 0
frontend/public/index.html View File

@ -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>

BIN
frontend/public/instruction.png View File

Before After
Width: 510  |  Height: 453  |  Size: 22 KiB

BIN
frontend/public/logo.png View File

Before After
Width: 239  |  Height: 239  |  Size: 29 KiB

BIN
frontend/public/logo192.png View File

Before After
Width: 192  |  Height: 192  |  Size: 7.7 KiB

BIN
frontend/public/logo512.png View File

Before After
Width: 512  |  Height: 512  |  Size: 32 KiB

+ 25
- 0
frontend/public/manifest.json View File

@ -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"
}

+ 3
- 0
frontend/public/robots.txt View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

+ 280
- 0
frontend/src/App.css View File

@ -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;
}
}

+ 9
- 0
frontend/src/App.test.tsx View File

@ -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();
});

+ 59
- 0
frontend/src/App.tsx View File

@ -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;

+ 251
- 0
frontend/src/Camera.tsx View File

@ -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;

+ 146
- 0
frontend/src/SettingsModal.tsx View File

@ -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;

BIN
frontend/src/assets/instruction.png View File

Before After
Width: 510  |  Height: 453  |  Size: 22 KiB

BIN
frontend/src/assets/logo.png View File

Before After
Width: 239  |  Height: 239  |  Size: 29 KiB

+ 13
- 0
frontend/src/index.css View File

@ -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;
}

+ 19
- 0
frontend/src/index.tsx View File

@ -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();

+ 1
- 0
frontend/src/logo.svg View File

@ -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>

+ 1
- 0
frontend/src/react-app-env.d.ts View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

+ 15
- 0
frontend/src/reportWebVitals.ts View File

@ -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;

+ 5
- 0
frontend/src/setupTests.ts View File

@ -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';

+ 71
- 0
frontend/src/utils/GestureDetection.ts View File

@ -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 };

+ 26
- 0
frontend/tsconfig.json View File

@ -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"
]
}

BIN
img/主平台自动化测试结果.png View File

Before After
Width: 2036  |  Height: 681  |  Size: 93 KiB

+ 0
- 75
sprint1/sprint1-planning.md View File

@ -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
感谢大家的聆听!

+ 0
- 10
sprint1/迭代计划.md View File

@ -1,10 +0,0 @@
# 📈 迭代计划(Sprint Plan)
## 🗓️ 项目时间线
| 时间 | 内容说明 |
| -------- | ------------------------------------------------- |
| 本周四 | 🧩 第一次迭代演示(系统架构设计 + 原型 + UI 草稿) |
| 下周一 | 🔁 第二次迭代演示(打通手势识别 → 系统控制的闭环) |
| 下周四 | 🎙 第三次迭代演示(加入语音控制,初步功能联动) |
| 下下周三 | 🚀 第四次迭代汇报(最终系统完整交付 + 场景演示) |

BIN
sprint1/隔空手势识别系统Sprint1.pptx View File


BIN
sprint2/WaveControl-sprint2.pptx View File


+ 0
- 120
sprint2/sprint2.md View File

@ -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 模型识别语音,触发系统控制 |
| 设置项管理 | 实现用户自定义快捷键、手势配置界面 |

BIN
sprint3/Sprint 3 技术文档:手势识别与游戏控制.pptx View File


+ 0
- 146
sprint3/sprint3.md View File

@ -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 控制、游戏控制等可切换交互模式 |
| 场景演示/视频录制 | 准备真实交互展示视频,用于演示或上线宣传 |
------
## 八、结语
本轮迭代成功实现了从手势识别到控制动作的完整闭环,扩展支持了游戏角色控制(多方向移动与跳跃),并在操作流畅性、准确性与系统解耦方面都取得实质进展。后续可以围绕用户可配置性、语音识别联动与多场景适配进一步扩展系统能力。

BIN
sprint4/WaveControl-sprint4.pptx View File


+ 0
- 306
项目测试文档.md View File

@ -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%
![主平台自动化测试结果](./img/主平台自动化测试结果.png)
#### 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 系统整体功能 **满足预期目标**,大部分核心功能已稳定实现。

BIN
项目测试结果.xlsx View File


Loading…
Cancel
Save