提交深度相机配置及其他采集功能

This commit is contained in:
root 2025-08-03 21:50:50 +08:00
parent a10c695ced
commit 480750cfcc
20 changed files with 226915 additions and 2388 deletions

76351
Log/OrbbecSDK.log.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,12 +8,16 @@
- **实时姿态检测**: 基于MediaPipe的高精度人体姿态识别 - **实时姿态检测**: 基于MediaPipe的高精度人体姿态识别
- **多传感器融合**: 整合摄像头、IMU传感器和压力传感器数据 - **多传感器融合**: 整合摄像头、IMU传感器和压力传感器数据
- **智能分析引擎**: 多维度平衡能力评估算法 - **智能分析引擎**: 多维度平衡能力评估算法
- **实时视频流**: WebSocket实时视频传输和显示
- **检测数据采集**: 一键采集当前检测状态的数据快照
- **视频录制功能**: 支持检测过程的视频录制和回放
- **可视化报告**: 直观的数据图表和分析报告 - **可视化报告**: 直观的数据图表和分析报告
- **历史数据管理**: 完整的检测记录存储和对比分析 - **历史数据管理**: 完整的检测记录存储和对比分析
### 🔧 技术特点 ### 🔧 技术特点
- **现代化架构**: Vue 3 + Python Flask 前后端分离 - **现代化架构**: Vue 3 + Python Flask 前后端分离
- **实时通信**: WebSocket 实时数据传输 - **实时通信**: WebSocket 实时数据传输和视频流
- **多媒体支持**: 集成视频录制、截图和数据采集功能
- **跨平台支持**: Windows、macOS、Linux - **跨平台支持**: Windows、macOS、Linux
- **模块化设计**: 清晰的目录结构,易于扩展和维护 - **模块化设计**: 清晰的目录结构,易于扩展和维护
- **数据安全**: 本地存储,保护用户隐私 - **数据安全**: 本地存储,保护用户隐私
@ -170,9 +174,12 @@ python main.py [选项]
1. **选择患者**: 从患者列表中选择或新建患者 1. **选择患者**: 从患者列表中选择或新建患者
2. **设备准备**: 确保摄像头和传感器正常连接 2. **设备准备**: 确保摄像头和传感器正常连接
3. **参数配置**: 设置检测时长、采样频率等参数 3. **开始检测**: 点击开始按钮进行实时检测
4. **开始检测**: 点击开始按钮进行实时检测 4. **实时监控**: 通过实时视频流观察检测过程
5. **查看结果**: 检测完成后查看分析结果和建议 5. **数据采集**: 在检测过程中点击"检测数据采集"按钮获取当前状态数据
6. **视频录制**: 使用"开始录制"/"停止录制"按钮记录检测过程
7. **停止检测**: 完成检测并自动计算检测时长
8. **查看结果**: 检测完成后查看分析结果和建议
### 4. 数据分析 ### 4. 数据分析
@ -214,10 +221,17 @@ python main.py [选项]
- `main.py`: 主启动脚本和进程管理 - `main.py`: 主启动脚本和进程管理
- `app.py`: 主应用和 API 路由 - `app.py`: 主应用和 API 路由
- `database.py`: SQLite 数据库操作 - `database.py`: SQLite 数据库操作
- `device_manager.py`: 硬件设备管理 - `device_manager.py`: 硬件设备管理和视频流处理
- `detection_engine.py`: 检测算法引擎 - `detection_engine.py`: 检测算法引擎
- `data_processor.py`: 数据分析和处理 - `data_processor.py`: 数据分析和处理
### 主要API端点
- `POST /api/detection/start`: 开始检测会话
- `POST /api/detection/{session_id}/stop`: 停止检测会话
- `POST /api/detection/{session_id}/collect`: 采集检测数据
- `WebSocket /ws`: 实时数据和视频流传输
### 前端开发 ### 前端开发
前端使用 Vue 3 + Element Plus主要特性 前端使用 Vue 3 + Element Plus主要特性
@ -314,6 +328,12 @@ A: 检查后端服务是否正常启动,确认防火墙设置,查看浏览
**Q: 检测结果不准确** **Q: 检测结果不准确**
A: 确保设备已正确校准,检查环境光线条件,调整检测参数设置。 A: 确保设备已正确校准,检查环境光线条件,调整检测参数设置。
**Q: 视频录制失败**
A: 检查磁盘空间是否充足,确认录制权限设置,查看后端日志中的错误信息。
**Q: WebSocket连接断开**
A: 检查网络连接稳定性确认防火墙未阻止WebSocket连接尝试刷新页面重新连接。
### 日志查看 ### 日志查看
系统日志保存在 `logs/` 目录下: 系统日志保存在 `logs/` 目录下:
@ -359,6 +379,10 @@ A: 确保设备已正确校准,检查环境光线条件,调整检测参数
"sensors": [p1, p2, p3, p4], "sensors": [p1, p2, p3, p4],
"center_of_pressure": [x, y] "center_of_pressure": [x, y]
} }
},
"recording": {
"video_path": "path/to/video.mp4",
"screenshots": ["path/to/screenshot1.png"]
} }
} }
``` ```
@ -408,6 +432,14 @@ A: 确保设备已正确校准,检查环境光线条件,调整检测参数
## 更新日志 ## 更新日志
### v1.2.0 (2024-01-20)
- **检测功能增强**: 新增检测数据采集功能,支持实时数据快照
- **视频录制**: 集成视频录制功能,支持检测过程的完整记录
- **实时视频流**: 优化WebSocket视频传输提供流畅的实时监控
- **API优化**: 重构检测相关API使用RESTful设计模式
- **用户体验**: 改进检测界面,添加录制控制和数据采集按钮
- **生命周期管理**: 完善组件生命周期处理,确保资源正确释放
### v1.1.0 (2024-01-15) ### v1.1.0 (2024-01-15)
- **项目重构**: 前后端完全分离,优化项目结构 - **项目重构**: 前后端完全分离,优化项目结构
- **新增脚本**: 添加一键安装和启动脚本 (install.bat, start_dev.bat, start_prod.bat) - **新增脚本**: 添加一键安装和启动脚本 (install.bat, start_dev.bat, start_prod.bat)

144151
backend/Log/OrbbecSDK.log.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@ import json
import time import time
import threading import threading
from datetime import datetime from datetime import datetime
from flask import Flask, request, jsonify, send_file from flask import Flask, jsonify, send_file
from flask import request as flask_request
from flask_cors import CORS from flask_cors import CORS
import sqlite3 import sqlite3
import logging import logging
@ -27,8 +28,6 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# 导入自定义模块 # 导入自定义模块
from database import DatabaseManager from database import DatabaseManager
from device_manager import DeviceManager, VideoStreamManager from device_manager import DeviceManager, VideoStreamManager
from detection_engine import DetectionEngine, detection_bp
from data_processor import DataProcessor
from utils import config as app_config from utils import config as app_config
# 配置日志 # 配置日志
@ -45,13 +44,16 @@ logger = logging.getLogger(__name__)
# 创建Flask应用 # 创建Flask应用
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = 'body-balance-detection-system-2024' app.config['SECRET_KEY'] = 'body-balance-detection-system-2024'
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading', logger=False, engineio_logger=False, ping_timeout=60, ping_interval=25) socketio = SocketIO(app,
cors_allowed_origins='*',
async_mode='threading',
logger=True,
engineio_logger=True)
# 启用CORS支持 # 启用CORS支持
CORS(app, origins='*', supports_credentials=True, allow_headers=['Content-Type', 'Authorization'], methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']) CORS(app, origins='*', supports_credentials=True, allow_headers=['Content-Type', 'Authorization'], methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'])
# 注册Blueprint # 注册Blueprint如需要可在此处添加
app.register_blueprint(detection_bp)
# 读取RTSP配置 # 读取RTSP配置
config = configparser.ConfigParser() config = configparser.ConfigParser()
@ -61,8 +63,6 @@ device_index = config.get('CAMERA', 'device_index', fallback=None)
# 全局变量 # 全局变量
db_manager = None db_manager = None
device_manager = None device_manager = None
detection_engine = None
data_processor = None
current_detection = None current_detection = None
detection_thread = None detection_thread = None
video_stream_manager = None video_stream_manager = None
@ -71,7 +71,7 @@ video_stream_manager = None
def init_app(): def init_app():
"""初始化应用""" """初始化应用"""
global db_manager, device_manager, detection_engine, data_processor, video_stream_manager global db_manager, device_manager, video_stream_manager
try: try:
# 创建必要的目录 # 创建必要的目录
@ -100,13 +100,10 @@ def init_app():
db_manager.init_database() db_manager.init_database()
# 初始化设备管理器 # 初始化设备管理器
device_manager = DeviceManager() device_manager = DeviceManager(db_manager)
device_manager.set_socketio(socketio) # 设置WebSocket连接
# 初始化检测引擎
detection_engine = DetectionEngine()
# 初始化数据处理器
data_processor = DataProcessor()
# 初始化视频流管理器 # 初始化视频流管理器
video_stream_manager = VideoStreamManager(socketio) video_stream_manager = VideoStreamManager(socketio)
@ -143,7 +140,7 @@ def api_health_check():
def login(): def login():
"""用户登录""" """用户登录"""
try: try:
data = request.get_json() data = flask_request.get_json()
username = data.get('username') username = data.get('username')
password = data.get('password') password = data.get('password')
remember = data.get('remember', False) remember = data.get('remember', False)
@ -202,7 +199,7 @@ def login():
def register(): def register():
"""用户注册""" """用户注册"""
try: try:
data = request.get_json() data = flask_request.get_json()
username = data.get('username') username = data.get('username')
password = data.get('password') password = data.get('password')
name = data.get('name') or data.get('email', '') name = data.get('name') or data.get('email', '')
@ -264,7 +261,7 @@ def verify_token():
"""验证token""" """验证token"""
try: try:
# 简单的token验证实际项目中应验证JWT等 # 简单的token验证实际项目中应验证JWT等
auth_header = request.headers.get('Authorization') auth_header = flask_request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '): if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1] token = auth_header.split(' ')[1]
# 这里可以添加真实的token验证逻辑 # 这里可以添加真实的token验证逻辑
@ -285,7 +282,7 @@ def verify_token():
def forgot_password(): def forgot_password():
"""忘记密码 - 根据用户名和手机号找回密码""" """忘记密码 - 根据用户名和手机号找回密码"""
try: try:
data = request.get_json() data = flask_request.get_json()
username = data.get('username') username = data.get('username')
phone = data.get('phone') phone = data.get('phone')
@ -359,12 +356,12 @@ def get_users():
"""获取用户列表(管理员功能)""" """获取用户列表(管理员功能)"""
try: try:
# 这里应该验证管理员权限 # 这里应该验证管理员权限
page = int(request.args.get('page', 1)) page = int(flask_request.args.get('page', 1))
size = int(request.args.get('size', 10)) size = int(flask_request.args.get('size', 10))
status = request.args.get('status') # active, inactive, all status = flask_request.args.get('status') # active, inactive, all
users = db_manager.get_users(page, size, status) users = db_manager.get_users(page, size, status)
total = db_manager.get_user_count(status) total = db_manager.get_users_count(status)
return jsonify({ return jsonify({
'success': True, 'success': True,
@ -385,7 +382,7 @@ def approve_user(user_id):
"""审核用户(管理员功能)""" """审核用户(管理员功能)"""
try: try:
# 这里应该验证管理员权限 # 这里应该验证管理员权限
data = request.get_json() data = flask_request.get_json()
approve = data.get('approve', True) approve = data.get('approve', True)
result = db_manager.approve_user(user_id, approve) result = db_manager.approve_user(user_id, approve)
@ -430,28 +427,6 @@ def delete_user(user_id):
logger.error(f'删除用户失败: {e}') logger.error(f'删除用户失败: {e}')
return jsonify({'success': False, 'message': '删除用户失败'}), 500 return jsonify({'success': False, 'message': '删除用户失败'}), 500
@app.route('/api/auth/reset-password', methods=['POST'])
def reset_password():
"""重置密码"""
try:
data = request.get_json()
token = data.get('token')
password = data.get('password')
if token and password:
return jsonify({
'success': True,
'message': '密码重置成功'
})
else:
return jsonify({
'success': False,
'message': '参数不完整'
}), 400
except Exception as e:
logger.error(f'重置密码失败: {e}')
return jsonify({'success': False, 'message': '重置失败'}), 500
@app.route('/api/system/info', methods=['GET']) @app.route('/api/system/info', methods=['GET'])
def get_system_info(): def get_system_info():
@ -468,6 +443,82 @@ def get_system_info():
logger.error(f'获取系统信息失败: {e}') logger.error(f'获取系统信息失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 患者管理API ====================
@app.route('/api/patients', methods=['GET'])
def get_patients():
"""获取患者列表"""
try:
page = int(flask_request.args.get('page', 1))
size = int(flask_request.args.get('size', 10))
keyword = flask_request.args.get('keyword', '')
patients = db_manager.get_patients(page, size, keyword)
total = db_manager.get_patients_count(keyword)
return jsonify({
'success': True,
'data': {
'patients': patients,
'total': total,
'page': page,
'size': size
}
})
except Exception as e:
logger.error(f'获取患者列表失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients', methods=['POST'])
def create_patient():
"""创建患者"""
try:
data = flask_request.get_json()
patient_id = db_manager.create_patient(data)
return jsonify({
'success': True,
'data': {'patient_id': patient_id},
'message': '患者创建成功'
})
except Exception as e:
logger.error(f'创建患者失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<patient_id>', methods=['PUT'])
def update_patient(patient_id):
"""更新患者信息"""
try:
data = flask_request.get_json()
db_manager.update_patient(patient_id, data)
return jsonify({'success': True, 'message': '患者信息更新成功'})
except Exception as e:
logger.error(f'更新患者信息失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<patient_id>', methods=['GET'])
def get_patient(patient_id):
"""获取单个患者信息"""
try:
patient = db_manager.get_patient(patient_id)
if patient:
return jsonify({'success': True, 'data': patient})
else:
return jsonify({'success': False, 'error': '患者不存在'}), 404
except Exception as e:
logger.error(f'获取患者信息失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<patient_id>', methods=['DELETE'])
def delete_patient(patient_id):
"""删除患者"""
try:
db_manager.delete_patient(patient_id)
return jsonify({'success': True, 'message': '患者删除成功'})
except Exception as e:
logger.error(f'删除患者失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 设备管理API ==================== # ==================== 设备管理API ====================
@app.route('/api/devices/status', methods=['GET']) @app.route('/api/devices/status', methods=['GET'])
@ -506,339 +557,296 @@ def calibrate_devices():
logger.error(f'设备校准失败: {e}') logger.error(f'设备校准失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 患者管理API ====================
@app.route('/api/patients', methods=['GET']) # ==================== 视频推流API ====================
def get_patients():
"""获取患者列表""" @app.route('/api/streaming/start', methods=['POST'])
def start_video_streaming():
"""启动视频推流"""
try: try:
page = int(request.args.get('page', 1)) if not device_manager:
size = int(request.args.get('size', 10)) return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
keyword = request.args.get('keyword', '')
patients = db_manager.get_patients(page, size, keyword) # 设置WebSocket连接
total = db_manager.get_patients_count(keyword) device_manager.set_socketio(socketio)
result = device_manager.start_streaming()
logger.info(f'视频推流启动结果: {result}')
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': { 'message': '视频推流已启动',
'patients': patients, 'streaming_status': result
'total': total,
'page': page,
'size': size
}
}) })
except Exception as e: except Exception as e:
logger.error(f'获取患者列表失败: {e}') logger.error(f'启动视频推流失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients', methods=['POST']) @app.route('/api/streaming/stop', methods=['POST'])
def create_patient(): def stop_video_streaming():
"""创建患者""" """停止视频推流"""
try: try:
data = request.get_json() if not device_manager:
patient_id = db_manager.create_patient(data) return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
return jsonify({
'success': True, result = device_manager.stop_streaming()
'data': {'patient_id': patient_id},
'message': '患者创建成功' if result:
}) logger.info('视频推流已停止')
except Exception as e: return jsonify({
logger.error(f'创建患者失败: {e}') 'success': True,
return jsonify({'success': False, 'error': str(e)}), 500 'message': '视频推流已停止'
})
@app.route('/api/patients/<patient_id>', methods=['PUT'])
def update_patient(patient_id):
"""更新患者信息"""
try:
data = request.get_json()
db_manager.update_patient(patient_id, data)
return jsonify({'success': True, 'message': '患者信息更新成功'})
except Exception as e:
logger.error(f'更新患者信息失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<patient_id>', methods=['GET'])
def get_patient(patient_id):
"""获取单个患者信息"""
try:
patient = db_manager.get_patient(patient_id)
if patient:
return jsonify({'success': True, 'data': patient})
else: else:
return jsonify({'success': False, 'error': '患者不存在'}), 404 return jsonify({
'success': False,
'error': '停止推流失败'
}), 500
except Exception as e: except Exception as e:
logger.error(f'获取患者信息失败: {e}') logger.error(f'停止视频推流失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<patient_id>', methods=['DELETE']) # ==================== 检测API ====================
def delete_patient(patient_id):
"""删除患者"""
try:
db_manager.delete_patient(patient_id)
return jsonify({'success': True, 'message': '患者删除成功'})
except Exception as e:
logger.error(f'删除患者失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 检测管理API ====================
@app.route('/api/detection/start', methods=['POST']) @app.route('/api/detection/start', methods=['POST'])
def start_detection(): def start_detection():
"""开始检测""" """开始检测"""
global current_detection, detection_thread
try: try:
data = request.get_json() if not db_manager:
patient_id = data.get('patientId') return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
settings_str = data.get('settings', '{}')
data = flask_request.get_json()
patient_id = data.get('patientId') or data.get('patient_id')
settings = data.get('settings', '{}')
creator_id = data.get('creator_id')
if not patient_id: if not patient_id:
return jsonify({'success': False, 'error': '缺少患者ID'}), 400 return jsonify({
'success': False,
'error': '缺少必要参数: patient_id'
}), 400
# 检查是否已有检测在进行 # 解析设置参数
if current_detection and current_detection.get('status') == 'running': if isinstance(settings, str):
return jsonify({'success': False, 'error': '已有检测在进行中'}), 400 try:
settings = json.loads(settings)
# 解析settings参数 except json.JSONDecodeError:
try: settings = {}
if isinstance(settings_str, str):
settings = json.loads(settings_str)
else:
settings = settings_str or {}
except json.JSONDecodeError:
settings = {}
# 设置默认值
duration = settings.get('duration', 300) # 默认5分钟
frequency = settings.get('frequency', 30) # 默认30Hz
# 创建检测会话 # 创建检测会话
session_id = db_manager.create_detection_session( session_id = db_manager.create_detection_session(
patient_id=patient_id, patient_id=str(patient_id),
settings=settings settings=settings,
creator_id=creator_id
) )
logger.info(f'创建检测会话成功: {session_id}, 患者ID: {patient_id}') if session_id:
logger.info(f'检测会话已创建 - 会话ID: {session_id}, 患者ID: {patient_id}')
# 初始化检测状态 return jsonify({
current_detection = { 'success': True,
'session_id': session_id, 'session_id': session_id,
'patient_id': patient_id, 'message': '检测会话创建成功'
'status': 'running', })
'start_time': datetime.now(), else:
'settings': settings, return jsonify({
'data_points': 0 'success': False,
} 'error': '创建检测会话失败'
}), 500
# 启动检测线程
detection_thread = threading.Thread(
target=run_detection,
args=(session_id, settings)
)
detection_thread.start()
return jsonify({
'success': True,
'data': {'session_id': session_id},
'message': '检测已开始'
})
except Exception as e: except Exception as e:
logger.error(f'开始检测失败: {e}') logger.error(f'开始检测失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/detection/stop', methods=['POST']) @app.route('/api/detection/<session_id>/stop', methods=['POST'])
def stop_detection(): def stop_detection(session_id):
"""停止检测""" """停止检测"""
global current_detection, detection_thread
try: try:
data = request.get_json() or {} if not db_manager:
session_id = data.get('sessionId') return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
frontend_duration = data.get('duration', 0) # 前端计算的持续时间
if not current_detection or current_detection.get('status') != 'running': if not session_id:
return jsonify({'success': False, 'error': '没有正在进行的检测'}), 400
# 更新检测状态
current_detection['status'] = 'stopped'
end_time = datetime.now()
current_detection['end_time'] = end_time
# 计算持续时间(优先使用前端传递的时间,否则使用后端计算的时间)
duration = frontend_duration
if duration <= 0:
start_time = current_detection.get('start_time')
duration = int((end_time - start_time).total_seconds()) if start_time else 0
# 等待检测线程结束
if detection_thread and detection_thread.is_alive():
detection_thread.join(timeout=5)
# 如果提供了sessionId更新数据库中的会话状态
if session_id:
# 更新会话状态为completed并设置结束时间
db_manager.update_session_status(session_id, 'completed')
# 更新持续时间
if duration > 0:
db_manager.update_session_duration(session_id, duration)
logger.info(f'检测会话已停止: {session_id}, 持续时间: {duration}')
current_detection = None
return jsonify({ return jsonify({
'success': True, 'success': False,
'message': '检测已停止', 'error': '缺少会话ID'
'sessionId': session_id,
'duration': duration
})
current_detection = None
return jsonify({
'success': True,
'message': '检测已停止'
})
except Exception as e:
logger.error(f'停止检测失败: {e}')
return jsonify({
'success': False,
'message': f'停止检测失败: {str(e)}'
}), 500
@app.route('/api/sessions/<session_id>/video-path', methods=['PUT'])
def update_session_video_path(session_id):
"""更新会话视频路径"""
try:
data = request.get_json()
if not data or 'videoPath' not in data:
return jsonify({
'success': False,
'message': '缺少视频路径参数'
}), 400 }), 400
video_path = data['videoPath'] # 更新会话状态为已完成
success = db_manager.update_session_status(session_id, 'completed')
# 更新数据库中的视频路径 if success:
db_manager.update_session_video_path(session_id, video_path) logger.info(f'检测会话已停止 - 会话ID: {session_id}')
return jsonify({
'success': True,
'message': '视频路径更新成功',
'sessionId': session_id,
'videoPath': video_path
})
except Exception as e:
logger.error(f'更新会话视频路径失败: {e}')
return jsonify({
'success': False,
'message': f'更新失败: {str(e)}'
}), 500
@app.route('/api/detection/status', methods=['GET'])
def get_detection_status():
"""获取检测状态"""
try:
if not current_detection:
return jsonify({ return jsonify({
'success': True, 'success': True,
'data': {'status': 'idle'} 'message': '检测已停止'
}) })
else:
return jsonify({
'success': False,
'error': '停止检测失败'
}), 500
except Exception as e:
logger.error(f'停止检测失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/detection/<session_id>/status', methods=['GET'])
def get_detection_status(session_id):
"""获取检测状态"""
try:
if not db_manager:
return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
# 计算运行时间 if not session_id:
if current_detection.get('status') == 'running': return jsonify({
elapsed = (datetime.now() - current_detection['start_time']).total_seconds() 'success': False,
current_detection['elapsed_time'] = int(elapsed) 'error': '缺少会话ID'
}), 400
return jsonify({ # 获取会话数据
'success': True, session_data = db_manager.get_session_data(session_id)
'data': current_detection
})
if session_data:
return jsonify({
'success': True,
'data': session_data
})
else:
return jsonify({
'success': False,
'error': '会话不存在'
}), 404
except Exception as e: except Exception as e:
logger.error(f'获取检测状态失败: {e}') logger.error(f'获取检测状态失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/detection/data', methods=['GET']) @app.route('/api/detection/<session_id>/collect', methods=['POST'])
def get_realtime_data(): def collect_detection_data(session_id):
"""获取实时检测数据""" """采集检测数据"""
try: try:
if not current_detection or current_detection.get('status') != 'running': if not db_manager:
return jsonify({'success': False, 'error': '没有正在进行的检测'}) return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
# 获取最新的检测数据 if not device_manager:
session_id = current_detection['session_id'] return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
data = detection_engine.get_latest_data(session_id)
return jsonify({ # 获取请求数据
'success': True, data = flask_request.get_json() or {}
'data': data patient_id = data.get('patient_id')
}) screen_image_base64 = data.get('screen_image')
except Exception as e: # 如果没有提供patient_id从会话信息中获取
logger.error(f'获取实时数据失败: {e}') if not patient_id:
return jsonify({'success': False, 'error': str(e)}), 500 session_data = db_manager.get_session_data(session_id)
if not session_data:
# ==================== 数据分析API ==================== return jsonify({
'success': False,
@app.route('/api/analysis/session/<session_id>', methods=['GET']) 'error': '检测会话不存在'
def analyze_session(session_id): }), 404
"""分析检测会话数据""" patient_id = session_data.get('patient_id')
try:
# 获取会话数据
session_data = db_manager.get_session_data(session_id)
if not session_data:
return jsonify({'success': False, 'error': '会话不存在'}), 404
# 进行数据分析 if not patient_id:
analysis_result = data_processor.analyze_session(session_data) return jsonify({
'success': False,
'error': '无法获取患者ID'
}), 400
# 保存分析结果 # 调用设备管理器采集数据
db_manager.save_analysis_result(session_id, analysis_result) collected_data = device_manager.collect_data(
session_id=session_id,
return jsonify({ patient_id=patient_id,
'success': True, screen_image_base64=screen_image_base64
'data': analysis_result
})
except Exception as e:
logger.error(f'分析会话数据失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/export/report/<session_id>', methods=['GET'])
def export_report(session_id):
"""导出检测报告"""
try:
# 生成报告
report_path = data_processor.generate_report(session_id)
if not os.path.exists(report_path):
return jsonify({'success': False, 'error': '报告生成失败'}), 500
return send_file(
report_path,
as_attachment=True,
download_name=f'detection_report_{session_id}.pdf'
) )
# 将采集的数据保存到数据库
if collected_data:
db_manager.save_detection_data(session_id, collected_data)
logger.info(f'检测数据采集并保存成功: {session_id}')
return jsonify({
'success': True,
'data': {
'session_id': session_id,
'timestamp': collected_data.get('timestamp'),
'data_collected': bool(collected_data)
},
'message': '数据采集成功'
})
except Exception as e: except Exception as e:
logger.error(f'导出报告失败: {e}') logger.error(f'采集检测数据失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 历史记录API ==================== # ==================== 同步录制API ====================
@app.route('/api/recording/sync/start', methods=['POST'])
def start_sync_recording():
"""启动同步录制"""
try:
if not device_manager:
return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
data = flask_request.get_json()
session_id = data.get('session_id')
patient_id = data.get('patient_id')
if not session_id or not patient_id:
return jsonify({
'success': False,
'error': '缺少必要参数: session_id 和 patient_id'
}), 400
result = device_manager.start_recording(session_id, patient_id)
if result['success']:
logger.info(f'同步录制已启动 - 会话ID: {session_id}, 患者ID: {patient_id}')
return jsonify(result)
else:
return jsonify(result), 500
except Exception as e:
logger.error(f'启动同步录制失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/recording/sync/stop', methods=['POST'])
def stop_sync_recording():
"""停止同步录制"""
try:
if not device_manager:
return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
data = flask_request.get_json()
session_id = data.get('session_id')
if not session_id:
return jsonify({
'success': False,
'error': '缺少必要参数: session_id'
}), 400
result = device_manager.stop_recording(session_id)
if result['success']:
logger.info(f'同步录制已停止 - 会话ID: {session_id}')
return jsonify(result)
else:
return jsonify(result), 500
except Exception as e:
logger.error(f'停止同步录制失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/history/sessions', methods=['GET']) @app.route('/api/history/sessions', methods=['GET'])
def get_detection_sessions(): def get_detection_sessions():
"""获取检测会话历史""" """获取检测会话历史"""
try: try:
page = int(request.args.get('page', 1)) page = int(flask_request.args.get('page', 1))
size = int(request.args.get('size', 10)) size = int(flask_request.args.get('size', 10))
patient_id = request.args.get('patient_id') patient_id = flask_request.args.get('patient_id')
sessions = db_manager.get_detection_sessions(page, size, patient_id) sessions = db_manager.get_detection_sessions(page, size, patient_id)
total = db_manager.get_sessions_count(patient_id) total = db_manager.get_sessions_count(patient_id)
@ -857,55 +865,6 @@ def get_detection_sessions():
logger.error(f'获取检测历史失败: {e}') logger.error(f'获取检测历史失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
def run_detection(session_id, settings):
"""运行检测的后台线程"""
global current_detection
try:
logger.info(f'开始检测会话: {session_id}')
# 检测循环
while (current_detection and
current_detection.get('status') == 'running'):
# 采集数据
if device_manager:
data = device_manager.collect_data()
if data:
# 保存数据到数据库
db_manager.save_detection_data(session_id, data)
current_detection['data_points'] += 1
# 根据采样频率控制循环间隔
frequency = settings.get('frequency', 60)
time.sleep(1.0 / frequency)
# 检查是否达到设定时长
duration = settings.get('duration', 0)
if duration > 0:
elapsed = (datetime.now() - current_detection['start_time']).total_seconds()
if elapsed >= duration:
current_detection['status'] = 'completed'
break
# 更新会话状态
if current_detection:
db_manager.update_session_status(
session_id,
current_detection['status'],
current_detection.get('data_points', 0)
)
logger.info(f'检测会话完成: {session_id}')
except Exception as e:
logger.error(f'检测线程异常: {e}')
if current_detection:
current_detection['status'] = 'error'
current_detection['error'] = str(e)
# ==================== 截图和录像API已移至detection_engine.py ====================
# 相关路由现在通过detection_bp Blueprint提供
# ==================== 错误处理 ==================== # ==================== 错误处理 ====================
@ -956,46 +915,112 @@ if __name__ == '__main__':
# ==================== WebSocket 事件处理 ==================== # ==================== WebSocket 事件处理 ====================
# 简单的测试事件处理器
@socketio.on('connect')
def handle_connect():
print('CLIENT CONNECTED!!!', flush=True)
logger.info('客户端已连接')
@socketio.on('disconnect')
def handle_disconnect():
print('CLIENT DISCONNECTED!!!', flush=True)
logger.info('客户端已断开连接')
# @socketio.on('start_video')
# def handle_start_video_new(data=None):
# print('=== START VIDEO EVENT RECEIVED ===', flush=True)
# print(f'Data received: {data}', flush=True)
# logger.info('=== START VIDEO EVENT RECEIVED ===')
# logger.info(f'Data received: {data}')
# emit('video_status', {'status': 'received', 'message': 'start_video事件已接收'})
# return {'status': 'success'}
# 原始的start_video处理逻辑暂时注释
@socketio.on('start_video') @socketio.on('start_video')
def handle_start_video(data=None): def handle_start_video(data=None):
logger.info(f'收到start_video事件客户端ID: {request.sid}, 数据: {data}')
try: try:
results = {'status': 'success', 'cameras': {}}
logger.info(f'video_stream_manager状态: {video_stream_manager is not None}')
logger.info(f'device_manager状态: {device_manager is not None}')
# 启动视频流管理器(普通摄像头)
if video_stream_manager: if video_stream_manager:
result = video_stream_manager.start_video_stream() logger.info('正在启动视频流管理器...')
emit('video_status', result) video_result = video_stream_manager.start_video_stream()
logger.info(f'视频流管理器启动结果: {video_result}')
results['cameras']['normal'] = video_result
else: else:
emit('video_status', {'status': 'error', 'message': '视频流管理器未初始化'}) logger.error('视频流管理器未初始化')
results['cameras']['normal'] = {'status': 'error', 'message': '视频流管理器未初始化'}
# 启动FemtoBolt深度相机
if device_manager:
logger.info('正在启动FemtoBolt深度相机...')
femtobolt_result = device_manager.start_femtobolt_stream()
logger.info(f'FemtoBolt启动结果: {femtobolt_result}')
if femtobolt_result:
results['cameras']['femtobolt'] = {'status': 'success', 'message': 'FemtoBolt深度相机推流已启动'}
else:
results['cameras']['femtobolt'] = {'status': 'error', 'message': 'FemtoBolt深度相机启动失败'}
else:
logger.error('设备管理器未初始化')
results['cameras']['femtobolt'] = {'status': 'error', 'message': '设备管理器未初始化'}
# 检查是否有任何相机启动成功
success_count = sum(1 for cam_result in results['cameras'].values() if cam_result.get('status') == 'success')
if success_count == 0:
results['status'] = 'error'
results['message'] = '所有相机启动失败'
elif success_count < len(results['cameras']):
results['status'] = 'partial'
results['message'] = f'{success_count}/{len(results["cameras"])}个相机启动成功'
else:
results['message'] = '所有相机启动成功'
logger.info(f'发送video_status事件: {results}')
emit('video_status', results)
except Exception as e: except Exception as e:
logger.error(f'启动视频流失败: {e}') logger.error(f'启动视频流失败: {e}', exc_info=True)
emit('video_status', {'status': 'error', 'message': f'启动失败: {str(e)}'}) emit('video_status', {'status': 'error', 'message': f'启动失败: {str(e)}'})
@socketio.on('stop_video') @socketio.on('stop_video')
def handle_stop_video(data=None): def handle_stop_video(data=None):
logger.info(f'收到stop_video事件客户端ID: {request.sid}, 数据: {data}') logger.info(f'收到stop_video事件数据: {data}')
try: try:
results = {'status': 'success', 'cameras': {}}
# 停止视频流管理器(普通摄像头)
if video_stream_manager: if video_stream_manager:
result = video_stream_manager.stop_video_stream() video_result = video_stream_manager.stop_video_stream()
emit('video_status', result) results['cameras']['normal'] = video_result
else: else:
emit('video_status', {'status': 'error', 'message': '视频流管理器未初始化'}) results['cameras']['normal'] = {'status': 'error', 'message': '视频流管理器未初始化'}
# 停止FemtoBolt深度相机
if device_manager:
device_manager.stop_femtobolt_stream()
results['cameras']['femtobolt'] = {'status': 'success', 'message': 'FemtoBolt深度相机推流已停止'}
else:
results['cameras']['femtobolt'] = {'status': 'error', 'message': '设备管理器未初始化'}
# 检查是否有任何相机停止成功
success_count = sum(1 for cam_result in results['cameras'].values() if cam_result.get('status') == 'success')
if success_count == 0:
results['status'] = 'error'
results['message'] = '所有相机停止失败'
elif success_count < len(results['cameras']):
results['status'] = 'partial'
results['message'] = f'{success_count}/{len(results["cameras"])}个相机停止成功'
else:
results['message'] = '所有相机停止成功'
emit('video_status', results)
except Exception as e: except Exception as e:
logger.error(f'停止视频流失败: {e}') logger.error(f'停止视频流失败: {e}')
emit('video_status', {'status': 'error', 'message': f'停止失败: {str(e)}'}) emit('video_status', {'status': 'error', 'message': f'停止失败: {str(e)}'})
@socketio.on('connect') # 重复的事件处理器已删除,使用前面定义的版本
def handle_connect():
logger.info(f'客户端连接: {request.sid}')
emit('connect_status', {'status': 'connected', 'sid': request.sid, 'message': '连接成功'})
@socketio.on('disconnect')
def handle_disconnect():
logger.info(f'客户端断开连接: {request.sid}')
@socketio.on('ping')
def handle_ping(data=None):
logger.info(f'收到ping事件客户端ID: {request.sid}, 数据: {data}')
emit('pong', {'timestamp': time.time(), 'message': 'pong'})

Binary file not shown.

View File

@ -1,730 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据处理模块
负责数据分析报告生成和数据导出
"""
import os
import json
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any, Tuple
import logging
from pathlib import Path
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.backends.backend_pdf import PdfPages
import seaborn as sns
from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from io import BytesIO
logger = logging.getLogger(__name__)
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False
sns.set_style("whitegrid")
class DataProcessor:
"""数据处理器"""
def __init__(self):
self.export_dir = Path('exports')
self.charts_dir = Path('charts')
# 创建必要的目录
self.export_dir.mkdir(exist_ok=True)
self.charts_dir.mkdir(exist_ok=True)
logger.info('数据处理器初始化完成')
def analyze_session(self, session_data: Dict[str, Any]) -> Dict[str, Any]:
"""分析会话数据"""
try:
if not session_data or 'data' not in session_data:
return {'error': '没有可分析的数据'}
data_points = session_data['data']
if not data_points:
return {'error': '数据为空'}
# 数据预处理
processed_data = self._preprocess_session_data(data_points)
# 统计分析
statistical_analysis = self._statistical_analysis(processed_data)
# 趋势分析
trend_analysis = self._trend_analysis(processed_data)
# 异常检测
anomaly_analysis = self._anomaly_detection(processed_data)
# 生成图表
charts = self._generate_charts(processed_data, session_data['id'])
analysis_result = {
'session_info': {
'session_id': session_data['id'],
'patient_name': session_data.get('patient_name', '未知'),
'start_time': session_data.get('start_time'),
'end_time': session_data.get('end_time'),
'duration': session_data.get('duration'),
'data_points': len(data_points)
},
'statistical': statistical_analysis,
'trends': trend_analysis,
'anomalies': anomaly_analysis,
'charts': charts,
'summary': self._generate_summary(statistical_analysis, trend_analysis, anomaly_analysis)
}
return analysis_result
except Exception as e:
logger.error(f'会话数据分析失败: {e}')
return {'error': str(e)}
def _preprocess_session_data(self, data_points: List[Dict]) -> Dict[str, List]:
"""预处理会话数据"""
processed = {
'timestamps': [],
'pressure_left': [],
'pressure_right': [],
'pressure_total': [],
'balance_index': [],
'center_of_pressure_x': [],
'center_of_pressure_y': [],
'imu_pitch': [],
'imu_roll': [],
'imu_accel_total': []
}
for point in data_points:
try:
# 解析时间戳
timestamp = datetime.fromisoformat(point['timestamp'].replace('Z', '+00:00'))
processed['timestamps'].append(timestamp)
# 解析数据值
data_value = point['data_value']
if point['data_type'] == 'pressure':
processed['pressure_left'].append(data_value.get('left_foot', 0))
processed['pressure_right'].append(data_value.get('right_foot', 0))
processed['pressure_total'].append(data_value.get('total_pressure', 0))
processed['balance_index'].append(data_value.get('balance_index', 0))
cop = data_value.get('center_of_pressure', {'x': 0, 'y': 0})
processed['center_of_pressure_x'].append(cop.get('x', 0))
processed['center_of_pressure_y'].append(cop.get('y', 0))
elif point['data_type'] == 'imu':
processed['imu_pitch'].append(data_value.get('pitch', 0))
processed['imu_roll'].append(data_value.get('roll', 0))
processed['imu_accel_total'].append(data_value.get('total_accel', 0))
except Exception as e:
logger.warning(f'数据点处理失败: {e}')
continue
return processed
def _statistical_analysis(self, data: Dict[str, List]) -> Dict[str, Any]:
"""统计分析"""
try:
stats = {}
# 压力数据统计
if data['pressure_total']:
pressure_total = np.array(data['pressure_total'])
stats['pressure'] = {
'mean': float(np.mean(pressure_total)),
'std': float(np.std(pressure_total)),
'min': float(np.min(pressure_total)),
'max': float(np.max(pressure_total)),
'median': float(np.median(pressure_total))
}
# 左右脚压力分析
if data['pressure_left'] and data['pressure_right']:
left_pressure = np.array(data['pressure_left'])
right_pressure = np.array(data['pressure_right'])
stats['pressure_distribution'] = {
'left_mean': float(np.mean(left_pressure)),
'right_mean': float(np.mean(right_pressure)),
'left_ratio': float(np.mean(left_pressure) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0,
'right_ratio': float(np.mean(right_pressure) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0,
'asymmetry': float(abs(np.mean(left_pressure) - np.mean(right_pressure)) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0
}
# 平衡指数统计
if data['balance_index']:
balance_index = np.array(data['balance_index'])
stats['balance'] = {
'mean': float(np.mean(balance_index)),
'std': float(np.std(balance_index)),
'min': float(np.min(balance_index)),
'max': float(np.max(balance_index)),
'stability_score': float(1.0 - np.std(balance_index)) # 稳定性评分
}
# 重心位置统计
if data['center_of_pressure_x'] and data['center_of_pressure_y']:
cop_x = np.array(data['center_of_pressure_x'])
cop_y = np.array(data['center_of_pressure_y'])
stats['center_of_pressure'] = {
'mean_x': float(np.mean(cop_x)),
'mean_y': float(np.mean(cop_y)),
'std_x': float(np.std(cop_x)),
'std_y': float(np.std(cop_y)),
'range_x': float(np.max(cop_x) - np.min(cop_x)),
'range_y': float(np.max(cop_y) - np.min(cop_y)),
'total_displacement': float(np.sqrt(np.std(cop_x)**2 + np.std(cop_y)**2))
}
# IMU数据统计
if data['imu_pitch'] and data['imu_roll']:
pitch = np.array(data['imu_pitch'])
roll = np.array(data['imu_roll'])
stats['posture'] = {
'mean_pitch': float(np.mean(pitch)),
'mean_roll': float(np.mean(roll)),
'std_pitch': float(np.std(pitch)),
'std_roll': float(np.std(roll)),
'max_pitch': float(np.max(np.abs(pitch))),
'max_roll': float(np.max(np.abs(roll))),
'posture_stability': float(1.0 - (np.std(pitch) + np.std(roll)) / 20) # 姿态稳定性
}
return stats
except Exception as e:
logger.error(f'统计分析失败: {e}')
return {}
def _trend_analysis(self, data: Dict[str, List]) -> Dict[str, Any]:
"""趋势分析"""
try:
trends = {}
# 平衡指数趋势
if data['balance_index'] and len(data['balance_index']) > 10:
balance_trend = self._calculate_trend(data['balance_index'])
trends['balance_trend'] = {
'slope': balance_trend['slope'],
'direction': 'improving' if balance_trend['slope'] < 0 else 'deteriorating' if balance_trend['slope'] > 0 else 'stable',
'correlation': balance_trend['correlation']
}
# 压力分布趋势
if data['pressure_left'] and data['pressure_right'] and len(data['pressure_left']) > 10:
left_trend = self._calculate_trend(data['pressure_left'])
right_trend = self._calculate_trend(data['pressure_right'])
trends['pressure_trend'] = {
'left_slope': left_trend['slope'],
'right_slope': right_trend['slope'],
'asymmetry_trend': 'increasing' if abs(left_trend['slope'] - right_trend['slope']) > 0.1 else 'stable'
}
# 姿态趋势
if data['imu_pitch'] and data['imu_roll'] and len(data['imu_pitch']) > 10:
pitch_trend = self._calculate_trend(data['imu_pitch'])
roll_trend = self._calculate_trend(data['imu_roll'])
trends['posture_trend'] = {
'pitch_slope': pitch_trend['slope'],
'roll_slope': roll_trend['slope'],
'stability_trend': 'improving' if abs(pitch_trend['slope']) + abs(roll_trend['slope']) < 0.1 else 'deteriorating'
}
return trends
except Exception as e:
logger.error(f'趋势分析失败: {e}')
return {}
def _calculate_trend(self, values: List[float]) -> Dict[str, float]:
"""计算趋势"""
try:
x = np.arange(len(values))
y = np.array(values)
# 线性回归
slope, intercept = np.polyfit(x, y, 1)
correlation = np.corrcoef(x, y)[0, 1]
return {
'slope': float(slope),
'intercept': float(intercept),
'correlation': float(correlation)
}
except Exception as e:
logger.error(f'趋势计算失败: {e}')
return {'slope': 0, 'intercept': 0, 'correlation': 0}
def _anomaly_detection(self, data: Dict[str, List]) -> Dict[str, Any]:
"""异常检测"""
try:
anomalies = {}
# 检测平衡指数异常
if data['balance_index']:
balance_anomalies = self._detect_outliers(data['balance_index'])
anomalies['balance_anomalies'] = {
'count': len(balance_anomalies),
'percentage': len(balance_anomalies) / len(data['balance_index']) * 100,
'indices': balance_anomalies
}
# 检测压力异常
if data['pressure_total']:
pressure_anomalies = self._detect_outliers(data['pressure_total'])
anomalies['pressure_anomalies'] = {
'count': len(pressure_anomalies),
'percentage': len(pressure_anomalies) / len(data['pressure_total']) * 100,
'indices': pressure_anomalies
}
# 检测姿态异常
if data['imu_pitch'] and data['imu_roll']:
pitch_anomalies = self._detect_outliers(data['imu_pitch'])
roll_anomalies = self._detect_outliers(data['imu_roll'])
anomalies['posture_anomalies'] = {
'pitch_count': len(pitch_anomalies),
'roll_count': len(roll_anomalies),
'total_percentage': (len(pitch_anomalies) + len(roll_anomalies)) / (len(data['imu_pitch']) + len(data['imu_roll'])) * 100
}
return anomalies
except Exception as e:
logger.error(f'异常检测失败: {e}')
return {}
def _detect_outliers(self, values: List[float], threshold: float = 2.0) -> List[int]:
"""检测异常值"""
try:
data = np.array(values)
mean = np.mean(data)
std = np.std(data)
# 使用Z-score方法检测异常值
z_scores = np.abs((data - mean) / std)
outlier_indices = np.where(z_scores > threshold)[0].tolist()
return outlier_indices
except Exception as e:
logger.error(f'异常值检测失败: {e}')
return []
def _generate_charts(self, data: Dict[str, List], session_id: str) -> Dict[str, str]:
"""生成图表"""
try:
charts = {}
# 创建会话专用目录
session_charts_dir = self.charts_dir / session_id
session_charts_dir.mkdir(exist_ok=True)
# 生成平衡指数时间序列图
if data['timestamps'] and data['balance_index']:
chart_path = self._create_balance_chart(data, session_charts_dir)
if chart_path:
charts['balance_chart'] = str(chart_path)
# 生成压力分布图
if data['timestamps'] and data['pressure_left'] and data['pressure_right']:
chart_path = self._create_pressure_chart(data, session_charts_dir)
if chart_path:
charts['pressure_chart'] = str(chart_path)
# 生成重心轨迹图
if data['center_of_pressure_x'] and data['center_of_pressure_y']:
chart_path = self._create_cop_trajectory_chart(data, session_charts_dir)
if chart_path:
charts['cop_trajectory_chart'] = str(chart_path)
# 生成姿态角度图
if data['timestamps'] and data['imu_pitch'] and data['imu_roll']:
chart_path = self._create_posture_chart(data, session_charts_dir)
if chart_path:
charts['posture_chart'] = str(chart_path)
return charts
except Exception as e:
logger.error(f'图表生成失败: {e}')
return {}
def _create_balance_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
"""创建平衡指数图表"""
try:
plt.figure(figsize=(12, 6))
timestamps = data['timestamps'][:len(data['balance_index'])]
plt.plot(timestamps, data['balance_index'], 'b-', linewidth=1.5, label='平衡指数')
# 添加平均线
mean_balance = np.mean(data['balance_index'])
plt.axhline(y=mean_balance, color='r', linestyle='--', alpha=0.7, label=f'平均值: {mean_balance:.3f}')
plt.title('平衡指数时间序列', fontsize=14, fontweight='bold')
plt.xlabel('时间', fontsize=12)
plt.ylabel('平衡指数', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
# 格式化时间轴
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
plt.xticks(rotation=45)
plt.tight_layout()
chart_path = output_dir / 'balance_chart.png'
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
plt.close()
return chart_path
except Exception as e:
logger.error(f'平衡图表创建失败: {e}')
plt.close()
return None
def _create_pressure_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
"""创建压力分布图表"""
try:
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
timestamps = data['timestamps'][:min(len(data['pressure_left']), len(data['pressure_right']))]
pressure_left = data['pressure_left'][:len(timestamps)]
pressure_right = data['pressure_right'][:len(timestamps)]
# 上图:左右脚压力对比
ax1.plot(timestamps, pressure_left, 'b-', linewidth=1.5, label='左脚压力')
ax1.plot(timestamps, pressure_right, 'r-', linewidth=1.5, label='右脚压力')
ax1.set_title('左右脚压力对比', fontsize=14, fontweight='bold')
ax1.set_ylabel('压力值', fontsize=12)
ax1.legend()
ax1.grid(True, alpha=0.3)
# 下图:压力比例
total_pressure = np.array(pressure_left) + np.array(pressure_right)
left_ratio = np.array(pressure_left) / total_pressure * 100
right_ratio = np.array(pressure_right) / total_pressure * 100
ax2.plot(timestamps, left_ratio, 'b-', linewidth=1.5, label='左脚比例')
ax2.plot(timestamps, right_ratio, 'r-', linewidth=1.5, label='右脚比例')
ax2.axhline(y=50, color='g', linestyle='--', alpha=0.7, label='理想平衡线')
ax2.set_title('左右脚压力比例', fontsize=14, fontweight='bold')
ax2.set_xlabel('时间', fontsize=12)
ax2.set_ylabel('压力比例 (%)', fontsize=12)
ax2.legend()
ax2.grid(True, alpha=0.3)
# 格式化时间轴
for ax in [ax1, ax2]:
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
plt.tight_layout()
chart_path = output_dir / 'pressure_chart.png'
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
plt.close()
return chart_path
except Exception as e:
logger.error(f'压力图表创建失败: {e}')
plt.close()
return None
def _create_cop_trajectory_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
"""创建重心轨迹图表"""
try:
plt.figure(figsize=(10, 8))
x_positions = data['center_of_pressure_x']
y_positions = data['center_of_pressure_y']
# 绘制轨迹
plt.plot(x_positions, y_positions, 'b-', linewidth=1, alpha=0.7, label='重心轨迹')
plt.scatter(x_positions[0], y_positions[0], color='g', s=100, marker='o', label='起始点')
plt.scatter(x_positions[-1], y_positions[-1], color='r', s=100, marker='s', label='结束点')
# 添加中心点
center_x = np.mean(x_positions)
center_y = np.mean(y_positions)
plt.scatter(center_x, center_y, color='orange', s=150, marker='*', label='平均中心')
# 添加置信椭圆
self._add_confidence_ellipse(x_positions, y_positions, plt.gca())
plt.title('重心轨迹图', fontsize=14, fontweight='bold')
plt.xlabel('X方向位移 (mm)', fontsize=12)
plt.ylabel('Y方向位移 (mm)', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
plt.axis('equal')
plt.tight_layout()
chart_path = output_dir / 'cop_trajectory_chart.png'
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
plt.close()
return chart_path
except Exception as e:
logger.error(f'重心轨迹图表创建失败: {e}')
plt.close()
return None
def _add_confidence_ellipse(self, x: List[float], y: List[float], ax, n_std: float = 2.0):
"""添加置信椭圆"""
try:
from matplotlib.patches import Ellipse
import matplotlib.transforms as transforms
x_arr = np.array(x)
y_arr = np.array(y)
cov = np.cov(x_arr, y_arr)
pearson = cov[0, 1] / np.sqrt(cov[0, 0] * cov[1, 1])
ell_radius_x = np.sqrt(1 + pearson)
ell_radius_y = np.sqrt(1 - pearson)
ellipse = Ellipse((0, 0), width=ell_radius_x * 2, height=ell_radius_y * 2,
facecolor='none', edgecolor='red', alpha=0.5, linestyle='--')
scale_x = np.sqrt(cov[0, 0]) * n_std
scale_y = np.sqrt(cov[1, 1]) * n_std
mean_x = np.mean(x_arr)
mean_y = np.mean(y_arr)
transf = transforms.Affine2D().scale(scale_x, scale_y).translate(mean_x, mean_y)
ellipse.set_transform(transf + ax.transData)
ax.add_patch(ellipse)
except Exception as e:
logger.warning(f'置信椭圆添加失败: {e}')
def _create_posture_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
"""创建姿态角度图表"""
try:
plt.figure(figsize=(12, 8))
timestamps = data['timestamps'][:min(len(data['imu_pitch']), len(data['imu_roll']))]
pitch = data['imu_pitch'][:len(timestamps)]
roll = data['imu_roll'][:len(timestamps)]
plt.subplot(2, 1, 1)
plt.plot(timestamps, pitch, 'b-', linewidth=1.5, label='俯仰角')
plt.axhline(y=0, color='g', linestyle='--', alpha=0.7, label='理想位置')
plt.title('俯仰角变化', fontsize=14, fontweight='bold')
plt.ylabel('角度 (度)', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
plt.subplot(2, 1, 2)
plt.plot(timestamps, roll, 'r-', linewidth=1.5, label='翻滚角')
plt.axhline(y=0, color='g', linestyle='--', alpha=0.7, label='理想位置')
plt.title('翻滚角变化', fontsize=14, fontweight='bold')
plt.xlabel('时间', fontsize=12)
plt.ylabel('角度 (度)', fontsize=12)
plt.legend()
plt.grid(True, alpha=0.3)
# 格式化时间轴
for i in range(1, 3):
ax = plt.subplot(2, 1, i)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
plt.tight_layout()
chart_path = output_dir / 'posture_chart.png'
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
plt.close()
return chart_path
except Exception as e:
logger.error(f'姿态图表创建失败: {e}')
plt.close()
return None
def _generate_summary(self, statistical: Dict, trends: Dict, anomalies: Dict) -> Dict[str, Any]:
"""生成分析摘要"""
try:
summary = {
'overall_assessment': 'normal',
'key_findings': [],
'recommendations': []
}
# 分析平衡状况
if 'balance' in statistical:
balance_mean = statistical['balance']['mean']
if balance_mean > 0.3:
summary['key_findings'].append('平衡能力较差,重心摆动较大')
summary['recommendations'].append('建议进行平衡训练,加强核心肌群锻炼')
summary['overall_assessment'] = 'poor'
elif balance_mean > 0.15:
summary['key_findings'].append('平衡能力一般,有改善空间')
summary['recommendations'].append('建议适当增加平衡练习')
if summary['overall_assessment'] == 'normal':
summary['overall_assessment'] = 'fair'
else:
summary['key_findings'].append('平衡能力良好')
# 分析压力分布
if 'pressure_distribution' in statistical:
asymmetry = statistical['pressure_distribution']['asymmetry']
if asymmetry > 0.2:
summary['key_findings'].append('左右脚压力分布不均,存在明显偏重')
summary['recommendations'].append('注意纠正站立姿势,均匀分配体重')
if summary['overall_assessment'] in ['normal', 'fair']:
summary['overall_assessment'] = 'fair'
# 分析姿态稳定性
if 'posture' in statistical:
posture_stability = statistical['posture'].get('posture_stability', 1.0)
if posture_stability < 0.7:
summary['key_findings'].append('姿态稳定性较差,身体摆动较大')
summary['recommendations'].append('建议进行姿态矫正训练')
if summary['overall_assessment'] == 'normal':
summary['overall_assessment'] = 'fair'
# 分析异常情况
if anomalies:
total_anomalies = 0
for key, value in anomalies.items():
if isinstance(value, dict) and 'count' in value:
total_anomalies += value['count']
if total_anomalies > 10:
summary['key_findings'].append(f'检测到{total_anomalies}个异常数据点')
summary['recommendations'].append('建议重新进行检测或咨询专业医师')
# 默认建议
if not summary['recommendations']:
summary['recommendations'].append('继续保持良好的平衡训练习惯')
return summary
except Exception as e:
logger.error(f'摘要生成失败: {e}')
return {
'overall_assessment': 'unknown',
'key_findings': ['分析过程中出现错误'],
'recommendations': ['建议重新进行检测']
}
def generate_report(self, session_id: str) -> str:
"""生成PDF报告"""
try:
# 这里应该从数据库获取会话数据和分析结果
# 目前返回一个模拟的报告路径
report_path = self.export_dir / f'report_{session_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
# 创建简单的PDF报告
self._create_simple_pdf_report(session_id, report_path)
return str(report_path)
except Exception as e:
logger.error(f'报告生成失败: {e}')
raise
def _create_simple_pdf_report(self, session_id: str, output_path: Path):
"""创建简单的PDF报告"""
try:
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
c = canvas.Canvas(str(output_path), pagesize=A4)
width, height = A4
# 标题
c.setFont("Helvetica-Bold", 20)
c.drawString(50, height - 50, "Balance Detection Report")
# 会话信息
c.setFont("Helvetica", 12)
y_position = height - 100
c.drawString(50, y_position, f"Session ID: {session_id}")
y_position -= 20
c.drawString(50, y_position, f"Report Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
y_position -= 40
# 报告内容
c.drawString(50, y_position, "Analysis Summary:")
y_position -= 20
c.drawString(70, y_position, "• Balance assessment completed")
y_position -= 20
c.drawString(70, y_position, "• Posture analysis performed")
y_position -= 20
c.drawString(70, y_position, "• Movement patterns evaluated")
c.save()
logger.info(f'PDF报告已生成: {output_path}')
except Exception as e:
logger.error(f'PDF报告创建失败: {e}')
raise
def export_data(self, session_id: str, format: str = 'csv') -> str:
"""导出数据"""
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if format.lower() == 'csv':
export_path = self.export_dir / f'data_{session_id}_{timestamp}.csv'
# 这里应该从数据库获取数据并导出为CSV
# 目前创建一个示例文件
with open(export_path, 'w', encoding='utf-8') as f:
f.write('timestamp,data_type,value\n')
f.write(f'{datetime.now().isoformat()},sample,0.5\n')
elif format.lower() == 'json':
export_path = self.export_dir / f'data_{session_id}_{timestamp}.json'
# 导出为JSON格式
sample_data = {
'session_id': session_id,
'export_time': datetime.now().isoformat(),
'data': []
}
with open(export_path, 'w', encoding='utf-8') as f:
json.dump(sample_data, f, ensure_ascii=False, indent=2)
else:
raise ValueError(f'不支持的导出格式: {format}')
logger.info(f'数据已导出: {export_path}')
return str(export_path)
except Exception as e:
logger.error(f'数据导出失败: {e}')
raise

View File

@ -27,6 +27,183 @@ class DatabaseManager:
"""获取中国时区的当前时间字符串""" """获取中国时区的当前时间字符串"""
return datetime.now(self.china_tz).strftime('%Y-%m-%d %H:%M:%S') return datetime.now(self.china_tz).strftime('%Y-%m-%d %H:%M:%S')
def generate_patient_id(self) -> str:
"""生成患者唯一标识(YYYYMMDD0000)年月日+四位序号"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 获取当前日期
china_tz = timezone(timedelta(hours=8))
today = datetime.now(china_tz).strftime('%Y%m%d')
# 使用循环确保生成唯一ID防止并发冲突
for attempt in range(10): # 最多尝试10次
# 查询今天已有的最大序号
cursor.execute('''
SELECT id FROM patients
WHERE id LIKE ?
ORDER BY id DESC
LIMIT 1
''', (f'{today}%',))
result = cursor.fetchone()
if result:
# 提取序号部分并加1
last_id = result[0]
if len(last_id) >= 12 and last_id[:8] == today:
last_sequence = int(last_id[8:12])
sequence = str(last_sequence + 1).zfill(4)
else:
sequence = '0001'
else:
# 今天第一个患者
sequence = '0001'
patient_id = f'{today}{sequence}'
# 检查ID是否已存在
cursor.execute('SELECT COUNT(*) FROM patients WHERE id = ?', (patient_id,))
if cursor.fetchone()[0] == 0:
return patient_id
# 如果10次尝试都失败使用UUID作为备用方案
logger.warning('患者ID生成重试次数过多使用UUID备用方案')
return str(uuid.uuid4())
except Exception as e:
logger.error(f'生成患者ID失败: {e}')
# 如果生成失败使用UUID作为备用方案
return str(uuid.uuid4())
def generate_user_id(self) -> str:
"""生成用户唯一标识,格式为六位数字序号(000001)"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 使用循环确保生成唯一ID防止并发冲突
for attempt in range(10): # 最多尝试10次
# 获取当前最大的用户ID只考虑六位数字格式的ID
cursor.execute('SELECT MAX(CAST(id AS INTEGER)) FROM users WHERE id GLOB "[0-9][0-9][0-9][0-9][0-9][0-9]"')
result = cursor.fetchone()[0]
if result is None:
# 如果没有用户记录从000001开始
next_id = 1
else:
next_id = result + 1
# 格式化为六位数字,前面补零
user_id = f"{next_id:06d}"
# 检查ID是否已存在
cursor.execute('SELECT COUNT(*) FROM users WHERE id = ?', (user_id,))
if cursor.fetchone()[0] == 0:
return user_id
# 如果10次尝试都失败使用时间戳+随机数作为备用方案
logger.warning('用户ID生成重试次数过多使用备用方案')
import time
import random
timestamp_suffix = str(int(time.time()))[-4:]
random_suffix = str(random.randint(10, 99))
return f"{timestamp_suffix}{random_suffix}"
except Exception as e:
logger.error(f'生成用户ID失败: {e}')
# 如果出错,使用时间戳作为备用方案
import time
return str(int(time.time()))[-6:]
def generate_session_id(self) -> str:
"""生成会话唯一标识(YYYYMMDDHHMMSS)年月日时分秒"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 获取当前时间
china_tz = timezone(timedelta(hours=8))
# 使用循环确保生成唯一ID防止并发冲突
for attempt in range(10): # 最多尝试10次
current_time = datetime.now(china_tz)
session_id = current_time.strftime('%Y%m%d%H%M%S')
# 检查ID是否已存在
cursor.execute('SELECT COUNT(*) FROM detection_sessions WHERE id = ?', (session_id,))
if cursor.fetchone()[0] == 0:
return session_id
# 如果存在冲突等待1秒后重试
import time
time.sleep(0.001) # 等待1毫秒
# 如果10次尝试都失败在时间后添加随机后缀
import random
current_time = datetime.now(china_tz)
base_id = current_time.strftime('%Y%m%d%H%M%S')
suffix = str(random.randint(10, 99))
session_id = f'{base_id}{suffix}'
# 最后检查一次
cursor.execute('SELECT COUNT(*) FROM detection_sessions WHERE id = ?', (session_id,))
if cursor.fetchone()[0] == 0:
return session_id
# 如果还是冲突使用UUID作为备用方案
logger.warning('会话ID生成重试次数过多使用UUID备用方案')
return str(uuid.uuid4())
except Exception as e:
logger.error(f'生成会话ID失败: {e}')
# 如果生成失败使用UUID作为备用方案
return str(uuid.uuid4())
def generate_detection_data_id(self) -> str:
"""生成检测数据记录唯一标识(YYYYMMDDHHMMSS)年月日时分秒"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 获取当前时间
china_tz = timezone(timedelta(hours=8))
# 使用循环确保生成唯一ID防止并发冲突
for attempt in range(10): # 最多尝试10次
current_time = datetime.now(china_tz)
data_id = current_time.strftime('%Y%m%d%H%M%S')
# 检查ID是否已存在
cursor.execute('SELECT COUNT(*) FROM detection_data WHERE id = ?', (data_id,))
if cursor.fetchone()[0] == 0:
return data_id
# 如果存在冲突等待1毫秒后重试
import time
time.sleep(0.001)
# 如果10次尝试都失败在时间后添加随机后缀
import random
current_time = datetime.now(china_tz)
base_id = current_time.strftime('%Y%m%d%H%M%S')
suffix = str(random.randint(100, 999))
data_id = f'{base_id}{suffix}'
# 最后检查一次
cursor.execute('SELECT COUNT(*) FROM detection_data WHERE id = ?', (data_id,))
if cursor.fetchone()[0] == 0:
return data_id
# 如果还是冲突使用UUID作为备用方案
logger.warning('检测数据ID生成重试次数过多使用UUID备用方案')
return str(uuid.uuid4())
except Exception as e:
logger.error(f'生成检测数据ID失败: {e}')
# 如果生成失败使用UUID作为备用方案
return str(uuid.uuid4())
def get_connection(self) -> sqlite3.Connection: def get_connection(self) -> sqlite3.Connection:
"""获取数据库连接""" """获取数据库连接"""
if not self.connection: if not self.connection:
@ -44,122 +221,153 @@ class DatabaseManager:
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# 创建用户表(医生)
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- 用户唯一标识(0000000)六位序号
name TEXT NOT NULL, -- 用户真实姓名
username TEXT UNIQUE NOT NULL, -- 用户名登录名
password TEXT NOT NULL, -- 密码
register_date TIMESTAMP, -- 注册日期
is_active BOOLEAN DEFAULT 1, -- 账户是否激活0=未激活1=已激活
user_type TEXT DEFAULT 'user', -- 用户类型user/admin/doctor
phone TEXT DEFAULT '', -- 联系电话
created_at TIMESTAMP, -- 记录创建时间
updated_at TIMESTAMP -- 记录更新时间
)
''')
# 创建患者表 # 创建患者表
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS patients ( CREATE TABLE IF NOT EXISTS patients (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY, -- 患者唯一标识(YYYYMMDD0000)年月日+四位序号
name TEXT NOT NULL, name TEXT NOT NULL, -- 患者姓名
gender TEXT, gender TEXT, -- 性别
age INTEGER, birth_date TIMESTAMP, -- 出生日期
birth_date TIMESTAMP, nationality TEXT, -- 民族
nationality TEXT, residence TEXT, -- 居住地
height REAL, height REAL, -- 身高cm
weight REAL, weight REAL, -- 体重kg
phone TEXT, shoe_size TEXT, -- 鞋码
shoe_size TEXT, phone TEXT, -- 电话号码
medical_history TEXT, email TEXT, -- 电子邮箱
notes TEXT, occupation TEXT, -- 职业
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, workplace TEXT, -- 工作单位
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP medical_history TEXT, -- 病史
notes TEXT, -- 备注信息
created_at TIMESTAMP, -- 记录创建时间
updated_at TIMESTAMP -- 记录更新时间
) )
''') ''')
# 创建检测会话表 # 创建检测会话表
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS detection_sessions ( CREATE TABLE IF NOT EXISTS detection_sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY, -- 会话唯一标识(YYYYMMDDHHMMSS)年月日时分秒
patient_id TEXT NOT NULL, patient_id TEXT NOT NULL, -- 患者ID外键
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, creator_id TEXT, -- 创建人ID医生ID外键
end_time TIMESTAMP, start_time TIMESTAMP, -- 检测开始时间
duration INTEGER, end_time TIMESTAMP, -- 检测结束时间
frequency INTEGER, duration INTEGER, -- 检测持续时间
status TEXT DEFAULT 'created', settings TEXT, -- 检测设置JSON格式
settings TEXT, normal_video_path TEXT, -- 足部检测视频文件路径
data_points INTEGER DEFAULT 0, femtobolt_video_path TEXT, -- 深度相机视频文件路径
video_path TEXT, screen_video_path TEXT, -- 屏幕录制视频路径
notes TEXT, diagnosis_info TEXT, -- 诊断信息
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, treatment_info TEXT, -- 处理信息
FOREIGN KEY (patient_id) REFERENCES patients (id) suggestion_info TEXT, -- 建议信息
status TEXT DEFAULT 'created', -- 会话状态created/running/completed/failed
created_at TIMESTAMP, -- 记录创建时间
FOREIGN KEY (patient_id) REFERENCES patients (id), -- 患者表外键约束
FOREIGN KEY (creator_id) REFERENCES users (id) -- 用户表外键约束
) )
''') ''')
# 创建检测数据表 # 创建检测数据表
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS detection_data ( CREATE TABLE IF NOT EXISTS detection_data (
id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT PRIMARY KEY, -- 记录唯一标识(YYYYMMDDHHMMSS)年月日时分秒
session_id TEXT NOT NULL, session_id TEXT NOT NULL, -- 检测会话ID外键
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, head_pose TEXT , -- 头部姿态数据JSON格式
data_type TEXT NOT NULL, body_pose TEXT , -- 身体姿态数据JSON格式
data_value TEXT NOT NULL, body_image TEXT, -- 身体视频截图存储路径
FOREIGN KEY (session_id) REFERENCES detection_sessions (id) foot_data TEXT , -- 足部姿态数据JSON格式
foot_image TEXT, -- 足部监测视频截图存储路径
foot_data_image TEXT, -- 足底压力数据图存储路径
screen_image TEXT, -- 屏幕录制视频截图存储路径
timestamp TIMESTAMP, -- 数据记录时间戳
FOREIGN KEY (session_id) REFERENCES detection_sessions (id) -- 检测会话表外键约束
) )
''') ''')
# 创建分析结果表
cursor.execute('''
CREATE TABLE IF NOT EXISTS analysis_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
analysis_type TEXT NOT NULL,
result_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES detection_sessions (id)
)
''')
# 创建系统设置表 # 创建系统设置表
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS system_settings ( CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY, -- 设置项键名唯一标识
value TEXT NOT NULL, value TEXT NOT NULL, -- 设置项值
description TEXT, description TEXT, -- 设置项描述说明
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP -- 最后更新时间
) )
''') ''')
# 创建用户表
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
register_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 0,
user_type TEXT DEFAULT 'user',
phone TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引 # 创建索引以提高查询性能
cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_name ON patients (name)') # 患者表索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_patient ON detection_sessions (patient_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_name ON patients (name)') # 患者姓名索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_time ON detection_sessions (start_time)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_phone ON patients (phone)') # 患者电话索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_session ON detection_data (session_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_email ON patients (email)') # 患者邮箱索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)') # 检测会话表索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_type ON users (user_type)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_patient ON detection_sessions (patient_id)') # 患者ID索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_phone ON users (phone)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_creator ON detection_sessions (creator_id)') # 创建人ID索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_time ON detection_sessions (start_time)') # 开始时间索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_status ON detection_sessions (status)') # 会话状态索引
# 检测数据表索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_session ON detection_data (session_id)') # 会话ID索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)') # 时间戳索引
# 用户表索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)') # 用户名索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_type ON users (user_type)') # 用户类型索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_phone ON users (phone)') # 用户电话索引
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_active ON users (is_active)') # 激活状态索引
# 插入默认管理员账户(如果不存在) # 插入默认管理员账户(如果不存在)
cursor.execute('SELECT COUNT(*) FROM users WHERE username = ?', ('admin',)) cursor.execute('SELECT COUNT(*) FROM users WHERE username = ?', ('admin',))
admin_exists = cursor.fetchone()[0] admin_exists = cursor.fetchone()[0]
if admin_exists == 0: if admin_exists == 0:
admin_id = str(uuid.uuid4()) admin_id = self.generate_user_id()
# 默认密码为 admin123明文存储 # 默认密码为 admin123明文存储
admin_password = 'admin123' admin_password = 'admin123'
# 使用中国时区时间
china_time = self.get_china_time()
cursor.execute(''' cursor.execute('''
INSERT INTO users (id, name, username, password, is_active, user_type) INSERT INTO users (id, name, username, password, is_active, user_type, register_date, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (admin_id, '系统管理员', 'admin', admin_password, 1, 'admin')) ''', (admin_id, '系统管理员', 'admin', admin_password, 1, 'admin', china_time, china_time, china_time))
logger.info('创建默认管理员账户: admin/admin123') logger.info('创建默认管理员账户: admin/admin123')
# 插入默认系统设置
china_time = self.get_china_time()
# 检查并插入默认摄像头设备索引配置
cursor.execute('SELECT COUNT(*) FROM system_settings WHERE key = ?', ('monitor_device_index',))
monitor_config_exists = cursor.fetchone()[0]
if monitor_config_exists == 0:
cursor.execute('''
INSERT INTO system_settings (key, value, description, updated_at)
VALUES (?, ?, ?, ?)
''', ('monitor_device_index', '1', '足部监视摄像头设备索引号', china_time))
logger.info('创建默认摄像头设备索引配置: 1')
conn.commit() conn.commit()
logger.info('数据库初始化完成') logger.info('数据库初始化完成')
@ -172,29 +380,38 @@ class DatabaseManager:
def create_patient(self, patient_data: Dict[str, Any]) -> str: def create_patient(self, patient_data: Dict[str, Any]) -> str:
"""创建患者记录""" """创建患者记录"""
# 验证必填字段
if not patient_data.get('name'):
raise ValueError('患者姓名不能为空')
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
patient_id = str(uuid.uuid4()) patient_id = self.generate_patient_id()
# 使用中国时区时间 # 使用中国时区时间
china_time = self.get_china_time() china_time = self.get_china_time()
cursor.execute(''' cursor.execute('''
INSERT INTO patients ( INSERT INTO patients (
id, name, gender, age, birth_date, height, weight, id, name, gender, birth_date, nationality, residence,
phone, shoe_size, medical_history, notes, created_at, updated_at height, weight, shoe_size, phone, email, occupation, workplace,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) medical_history, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
patient_id, patient_id,
patient_data.get('name'), patient_data.get('name'),
patient_data.get('gender'), patient_data.get('gender'),
patient_data.get('age'),
patient_data.get('birth_date'), patient_data.get('birth_date'),
patient_data.get('nationality'),
patient_data.get('residence'),
patient_data.get('height'), patient_data.get('height'),
patient_data.get('weight'), patient_data.get('weight'),
patient_data.get('phone'),
patient_data.get('shoe_size'), patient_data.get('shoe_size'),
patient_data.get('phone'),
patient_data.get('email'),
patient_data.get('occupation'),
patient_data.get('workplace'),
patient_data.get('medical_history'), patient_data.get('medical_history'),
patient_data.get('notes'), patient_data.get('notes'),
china_time, china_time,
@ -212,6 +429,12 @@ class DatabaseManager:
def get_patients(self, page: int = 1, size: int = 10, keyword: str = '') -> List[Dict]: def get_patients(self, page: int = 1, size: int = 10, keyword: str = '') -> List[Dict]:
"""获取患者列表""" """获取患者列表"""
# 验证分页参数
if page < 1:
page = 1
if size < 1 or size > 100:
size = 10
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -221,10 +444,10 @@ class DatabaseManager:
if keyword: if keyword:
cursor.execute(''' cursor.execute('''
SELECT * FROM patients SELECT * FROM patients
WHERE name LIKE ? OR phone LIKE ? WHERE name LIKE ? OR phone LIKE ? OR email LIKE ?
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
''', (f'%{keyword}%', f'%{keyword}%', size, offset)) ''', (f'%{keyword}%', f'%{keyword}%', f'%{keyword}%', size, offset))
else: else:
cursor.execute(''' cursor.execute('''
SELECT * FROM patients SELECT * FROM patients
@ -275,6 +498,14 @@ class DatabaseManager:
def update_patient(self, patient_id: str, patient_data: Dict[str, Any]): def update_patient(self, patient_id: str, patient_data: Dict[str, Any]):
"""更新患者信息""" """更新患者信息"""
# 验证必填字段
if not patient_data.get('name'):
raise ValueError('患者姓名不能为空')
# 验证患者是否存在
if not self.get_patient(patient_id):
raise ValueError(f'患者不存在: {patient_id}')
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -284,19 +515,23 @@ class DatabaseManager:
cursor.execute(''' cursor.execute('''
UPDATE patients SET UPDATE patients SET
name = ?, gender = ?, age = ?, birth_date = ?, height = ?, weight = ?, name = ?, gender = ?, birth_date = ?, nationality = ?,
phone = ?, shoe_size = ?, medical_history = ?, notes = ?, residence = ?, height = ?, weight = ?, shoe_size = ?, phone = ?, email = ?,
updated_at = ? occupation = ?, workplace = ?, medical_history = ?, notes = ?, updated_at = ?
WHERE id = ? WHERE id = ?
''', ( ''', (
patient_data.get('name'), patient_data.get('name'),
patient_data.get('gender'), patient_data.get('gender'),
patient_data.get('age'),
patient_data.get('birth_date'), patient_data.get('birth_date'),
patient_data.get('nationality'),
patient_data.get('residence'),
patient_data.get('height'), patient_data.get('height'),
patient_data.get('weight'), patient_data.get('weight'),
patient_data.get('phone'),
patient_data.get('shoe_size'), patient_data.get('shoe_size'),
patient_data.get('phone'),
patient_data.get('email'),
patient_data.get('occupation'),
patient_data.get('workplace'),
patient_data.get('medical_history'), patient_data.get('medical_history'),
patient_data.get('notes'), patient_data.get('notes'),
china_time, china_time,
@ -313,6 +548,10 @@ class DatabaseManager:
def delete_patient(self, patient_id: str): def delete_patient(self, patient_id: str):
"""删除患者记录""" """删除患者记录"""
# 验证患者是否存在
if not self.get_patient(patient_id):
raise ValueError(f'患者不存在: {patient_id}')
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -325,14 +564,6 @@ class DatabaseManager:
) )
''', (patient_id,)) ''', (patient_id,))
# 删除相关的分析结果
cursor.execute('''
DELETE FROM analysis_results
WHERE session_id IN (
SELECT id FROM detection_sessions WHERE patient_id = ?
)
''', (patient_id,))
# 删除检测会话 # 删除检测会话
cursor.execute('DELETE FROM detection_sessions WHERE patient_id = ?', (patient_id,)) cursor.execute('DELETE FROM detection_sessions WHERE patient_id = ?', (patient_id,))
@ -347,30 +578,75 @@ class DatabaseManager:
logger.error(f'删除患者记录失败: {e}') logger.error(f'删除患者记录失败: {e}')
raise raise
def batch_delete_patients(self, patient_ids: List[str]):
"""批量删除患者记录"""
if not patient_ids:
return
conn = self.get_connection()
cursor = conn.cursor()
try:
# 验证所有患者是否存在
placeholders = ','.join(['?' for _ in patient_ids])
cursor.execute(f'SELECT COUNT(*) FROM patients WHERE id IN ({placeholders})', patient_ids)
existing_count = cursor.fetchone()[0]
if existing_count != len(patient_ids):
raise ValueError('部分患者记录不存在')
# 批量删除相关的检测数据
cursor.execute(f'''
DELETE FROM detection_data
WHERE session_id IN (
SELECT id FROM detection_sessions WHERE patient_id IN ({placeholders})
)
''', patient_ids)
# 批量删除检测会话
cursor.execute(f'DELETE FROM detection_sessions WHERE patient_id IN ({placeholders})', patient_ids)
# 批量删除患者记录
cursor.execute(f'DELETE FROM patients WHERE id IN ({placeholders})', patient_ids)
conn.commit()
logger.info(f'批量删除患者记录: {len(patient_ids)}')
except Exception as e:
conn.rollback()
logger.error(f'批量删除患者记录失败: {e}')
raise
# ==================== 检测会话管理 ==================== # ==================== 检测会话管理 ====================
def create_detection_session(self, patient_id: str, settings: Dict[str, Any]) -> str: def create_detection_session(self, patient_id: str, settings: Dict[str, Any], creator_id: str = None) -> str:
"""创建检测会话""" """创建检测会话"""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
session_id = str(uuid.uuid4()) session_id = self.generate_session_id()
# 使用中国时区时间 # 使用中国时区时间
china_time = self.get_china_time() china_time = self.get_china_time()
cursor.execute(''' cursor.execute('''
INSERT INTO detection_sessions ( INSERT INTO detection_sessions (
id, patient_id, duration, frequency, settings, status, start_time, created_at id, patient_id, creator_id, duration, frequency, settings, status,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) diagnosis_info, treatment_info, suggestion_info, notes, start_time, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
session_id, session_id,
patient_id, patient_id,
creator_id,
settings.get('duration', 60), settings.get('duration', 60),
settings.get('frequency', 60), settings.get('frequency', 60),
json.dumps(settings), json.dumps(settings),
'created', 'created',
settings.get('diagnosis_info', ''),
settings.get('treatment_info', ''),
settings.get('suggestion_info', ''),
settings.get('notes', ''),
china_time, china_time,
china_time china_time
)) ))
@ -432,25 +708,122 @@ class DatabaseManager:
logger.error(f'更新会话持续时间失败: {e}') logger.error(f'更新会话持续时间失败: {e}')
raise raise
def update_session_video_path(self, session_id: str, video_path: str): def update_session_normal_video_path(self, session_id: str, video_path: str):
"""更新会话视频路径""" """更新会话足部检测视频路径"""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute(''' cursor.execute('''
UPDATE detection_sessions SET video_path = ? UPDATE detection_sessions SET normal_video_path = ?
WHERE id = ? WHERE id = ?
''', (video_path, session_id)) ''', (video_path, session_id))
conn.commit() conn.commit()
logger.info(f'更新会话视频路径: {session_id} -> {video_path}') logger.info(f'更新会话足部检测视频路径: {session_id} -> {video_path}')
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f'更新会话视频路径失败: {e}') logger.error(f'更新会话足部检测视频路径失败: {e}')
raise raise
def update_session_femtobolt_video_path(self, session_id: str, video_path: str):
"""更新会话深度相机视频路径"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE detection_sessions SET femtobolt_video_path = ?
WHERE id = ?
''', (video_path, session_id))
conn.commit()
logger.info(f'更新会话深度相机视频路径: {session_id} -> {video_path}')
except Exception as e:
conn.rollback()
logger.error(f'更新会话深度相机视频路径失败: {e}')
raise
def update_session_screen_video_path(self, session_id: str, video_path: str):
"""更新会话屏幕录制视频路径"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE detection_sessions SET screen_video_path = ?
WHERE id = ?
''', (video_path, session_id))
conn.commit()
logger.info(f'更新会话屏幕录制视频路径: {session_id} -> {video_path}')
except Exception as e:
conn.rollback()
logger.error(f'更新会话屏幕录制视频路径失败: {e}')
raise
def update_session_diagnosis_info(self, session_id: str, diagnosis_info: str):
"""更新会话诊断信息"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE detection_sessions SET diagnosis_info = ?
WHERE id = ?
''', (diagnosis_info, session_id))
conn.commit()
logger.info(f'更新会话诊断信息: {session_id}')
except Exception as e:
conn.rollback()
logger.error(f'更新会话诊断信息失败: {e}')
raise
def update_session_treatment_info(self, session_id: str, treatment_info: str):
"""更新会话处理信息"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE detection_sessions SET treatment_info = ?
WHERE id = ?
''', (treatment_info, session_id))
conn.commit()
logger.info(f'更新会话处理信息: {session_id}')
except Exception as e:
conn.rollback()
logger.error(f'更新会话处理信息失败: {e}')
raise
def update_session_suggestion_info(self, session_id: str, suggestion_info: str):
"""更新会话建议信息"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE detection_sessions SET suggestion_info = ?
WHERE id = ?
''', (suggestion_info, session_id))
conn.commit()
logger.info(f'更新会话建议信息: {session_id}')
except Exception as e:
conn.rollback()
logger.error(f'更新会话建议信息失败: {e}')
raise
def get_detection_sessions(self, page: int = 1, size: int = 10, patient_id: str = None) -> List[Dict]: def get_detection_sessions(self, page: int = 1, size: int = 10, patient_id: str = None) -> List[Dict]:
"""获取检测会话列表""" """获取检测会话列表"""
conn = self.get_connection() conn = self.get_connection()
@ -461,18 +834,20 @@ class DatabaseManager:
if patient_id: if patient_id:
cursor.execute(''' cursor.execute('''
SELECT s.*, p.name as patient_name SELECT s.*, p.name as patient_name, u.name as creator_name
FROM detection_sessions s FROM detection_sessions s
LEFT JOIN patients p ON s.patient_id = p.id LEFT JOIN patients p ON s.patient_id = p.id
LEFT JOIN users u ON s.creator_id = u.id
WHERE s.patient_id = ? WHERE s.patient_id = ?
ORDER BY s.start_time DESC ORDER BY s.start_time DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
''', (patient_id, size, offset)) ''', (patient_id, size, offset))
else: else:
cursor.execute(''' cursor.execute('''
SELECT s.*, p.name as patient_name SELECT s.*, p.name as patient_name, u.name as creator_name
FROM detection_sessions s FROM detection_sessions s
LEFT JOIN patients p ON s.patient_id = p.id LEFT JOIN patients p ON s.patient_id = p.id
LEFT JOIN users u ON s.creator_id = u.id
ORDER BY s.start_time DESC ORDER BY s.start_time DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
''', (size, offset)) ''', (size, offset))
@ -521,9 +896,10 @@ class DatabaseManager:
try: try:
# 获取会话基本信息 # 获取会话基本信息
cursor.execute(''' cursor.execute('''
SELECT s.*, p.name as patient_name SELECT s.*, p.name as patient_name, u.name as creator_name
FROM detection_sessions s FROM detection_sessions s
LEFT JOIN patients p ON s.patient_id = p.id LEFT JOIN patients p ON s.patient_id = p.id
LEFT JOIN users u ON s.creator_id = u.id
WHERE s.id = ? WHERE s.id = ?
''', (session_id,)) ''', (session_id,))
@ -565,7 +941,7 @@ class DatabaseManager:
logger.error(f'获取会话数据失败: {e}') logger.error(f'获取会话数据失败: {e}')
return None return None
# ==================== 检测数据管理 ==================== # ==================== 检测记录数据管理 ====================
def save_detection_data(self, session_id: str, data: Dict[str, Any]): def save_detection_data(self, session_id: str, data: Dict[str, Any]):
"""保存检测数据""" """保存检测数据"""
@ -576,14 +952,30 @@ class DatabaseManager:
# 使用中国时区时间 # 使用中国时区时间
china_time = self.get_china_time() china_time = self.get_china_time()
# 保存不同类型的数据 # 生成检测数据记录ID
for data_type, data_value in data.items(): data_id = self.generate_detection_data_id()
cursor.execute('''
INSERT INTO detection_data (session_id, data_type, data_value, timestamp) # 根据新的表结构保存数据
VALUES (?, ?, ?, ?) cursor.execute('''
''', (session_id, data_type, json.dumps(data_value), china_time)) INSERT INTO detection_data (
id, session_id, head_pose, body_pose, body_image,
foot_data, foot_image, foot_data_image, screen_image, timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
data_id,
session_id,
json.dumps(data.get('head_pose')) if data.get('head_pose') else None,
json.dumps(data.get('body_pose')) if data.get('body_pose') else None,
data.get('body_image'),
json.dumps(data.get('foot_data')) if data.get('foot_data') else None,
data.get('foot_image'),
data.get('foot_data_image'),
data.get('screen_image'),
china_time
))
conn.commit() conn.commit()
logger.info(f'保存检测数据: {data_id}')
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
@ -608,8 +1000,20 @@ class DatabaseManager:
for row in rows: for row in rows:
data_point = dict(row) data_point = dict(row)
# 解析JSON字段
try: try:
data_point['data_value'] = json.loads(data_point['data_value']) if data_point.get('head_pose'):
data_point['head_pose'] = json.loads(data_point['head_pose'])
except:
pass
try:
if data_point.get('body_pose'):
data_point['body_pose'] = json.loads(data_point['body_pose'])
except:
pass
try:
if data_point.get('foot_data'):
data_point['foot_data'] = json.loads(data_point['foot_data'])
except: except:
pass pass
data_points.append(data_point) data_points.append(data_point)
@ -620,56 +1024,28 @@ class DatabaseManager:
logger.error(f'获取最新检测数据失败: {e}') logger.error(f'获取最新检测数据失败: {e}')
return [] return []
# ==================== 分析结果管理 ==================== def delete_detection_data(self, data_id: str):
"""按主键删除检测数据记录"""
def save_analysis_result(self, session_id: str, analysis_result: Dict[str, Any]):
"""保存分析结果"""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
for analysis_type, result_data in analysis_result.items(): # 验证记录是否存在
cursor.execute(''' cursor.execute('SELECT COUNT(*) FROM detection_data WHERE id = ?', (data_id,))
INSERT INTO analysis_results (session_id, analysis_type, result_data) if cursor.fetchone()[0] == 0:
VALUES (?, ?, ?) raise ValueError(f'检测数据记录不存在: {data_id}')
''', (session_id, analysis_type, json.dumps(result_data)))
# 删除检测数据记录
cursor.execute('DELETE FROM detection_data WHERE id = ?', (data_id,))
conn.commit() conn.commit()
logger.info(f'保存分析结果: {session_id}') logger.info(f'删除检测数据记录: {data_id}')
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f'保存分析结果失败: {e}') logger.error(f'删除检测数据记录失败: {e}')
raise raise
def get_analysis_results(self, session_id: str) -> Dict[str, Any]:
"""获取分析结果"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
SELECT * FROM analysis_results
WHERE session_id = ?
ORDER BY created_at DESC
''', (session_id,))
rows = cursor.fetchall()
results = {}
for row in rows:
analysis_type = row['analysis_type']
try:
result_data = json.loads(row['result_data'])
results[analysis_type] = result_data
except:
results[analysis_type] = row['result_data']
return results
except Exception as e:
logger.error(f'获取分析结果失败: {e}')
return {}
# ==================== 系统设置管理 ==================== # ==================== 系统设置管理 ====================
@ -735,7 +1111,7 @@ class DatabaseManager:
'message': '手机号已存在' 'message': '手机号已存在'
} }
user_id = str(uuid.uuid4()) user_id = self.generate_user_id()
# 密码明文存储 # 密码明文存储
password = user_data['password'] password = user_data['password']
# 使用中国时区时间 # 使用中国时区时间
@ -975,6 +1351,58 @@ class DatabaseManager:
logger.error(f'删除用户失败: {e}') logger.error(f'删除用户失败: {e}')
raise raise
def get_system_setting(self, key: str, default_value: str = None) -> Optional[str]:
"""获取系统设置值"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('SELECT value FROM system_settings WHERE key = ?', (key,))
result = cursor.fetchone()
if result:
return result[0]
else:
logger.warning(f'系统设置项不存在: {key}')
return default_value
except Exception as e:
logger.error(f'获取系统设置失败: {e}')
return default_value
def set_system_setting(self, key: str, value: str, description: str = None):
"""设置系统设置值"""
conn = self.get_connection()
cursor = conn.cursor()
try:
china_time = self.get_china_time()
# 检查设置项是否存在
cursor.execute('SELECT COUNT(*) FROM system_settings WHERE key = ?', (key,))
exists = cursor.fetchone()[0]
if exists:
# 更新现有设置
cursor.execute('''
UPDATE system_settings SET value = ?, updated_at = ?
WHERE key = ?
''', (value, china_time, key))
else:
# 插入新设置
cursor.execute('''
INSERT INTO system_settings (key, value, description, updated_at)
VALUES (?, ?, ?, ?)
''', (key, value, description, china_time))
conn.commit()
logger.info(f'设置系统配置: {key} = {value}')
except Exception as e:
conn.rollback()
logger.error(f'设置系统配置失败: {e}')
raise
def close(self): def close(self):
"""关闭数据库连接""" """关闭数据库连接"""
if self.connection: if self.connection:

View File

@ -1,912 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
检测引擎模块
负责实时数据处理姿态分析和平衡评估
"""
import numpy as np
import cv2
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any, Tuple
import logging
import threading
from collections import deque
import json
import base64
import os
from pathlib import Path
from flask import Blueprint, request, jsonify
logger = logging.getLogger(__name__)
class DetectionEngine:
"""检测引擎"""
def __init__(self):
self.session_data = {} # 存储各会话的数据
self.data_lock = threading.Lock()
self.analysis_algorithms = {
'balance_analysis': BalanceAnalyzer(),
'posture_analysis': PostureAnalyzer(),
'movement_analysis': MovementAnalyzer()
}
# 创建必要的目录
self._ensure_directories()
logger.info('检测引擎初始化完成')
def _ensure_directories(self):
"""确保必要的目录存在"""
try:
# 使用根目录的data目录
root_data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
patients_dir = os.path.join(root_data_dir, 'patients')
os.makedirs(patients_dir, exist_ok=True)
logger.info('数据目录创建完成')
except Exception as e:
logger.error(f'创建数据目录失败: {e}')
def start_session(self, session_id: str, settings: Dict[str, Any]):
"""开始检测会话"""
with self.data_lock:
self.session_data[session_id] = {
'settings': settings,
'start_time': datetime.now(),
'data_buffer': deque(maxlen=1000), # 保留最近1000个数据点
'analysis_results': {},
'real_time_metrics': {}
}
logger.info(f'检测会话开始: {session_id}')
def process_data(self, session_id: str, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""处理实时数据"""
if session_id not in self.session_data:
logger.warning(f'会话不存在: {session_id}')
return {}
try:
# 数据预处理
processed_data = self._preprocess_data(raw_data)
# 存储数据
with self.data_lock:
self.session_data[session_id]['data_buffer'].append(processed_data)
# 实时分析
real_time_results = self._real_time_analysis(session_id, processed_data)
# 更新实时指标
with self.data_lock:
self.session_data[session_id]['real_time_metrics'].update(real_time_results)
return real_time_results
except Exception as e:
logger.error(f'数据处理失败: {e}')
return {}
def _preprocess_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""数据预处理"""
processed = {
'timestamp': raw_data.get('timestamp', datetime.now().isoformat())
}
# 处理摄像头数据
if 'camera' in raw_data:
camera_data = raw_data['camera']
if 'pose_data' in camera_data:
processed['pose'] = self._process_pose_data(camera_data['pose_data'])
# 处理IMU数据
if 'imu' in raw_data:
processed['imu'] = self._process_imu_data(raw_data['imu'])
# 处理压力数据
if 'pressure' in raw_data:
processed['pressure'] = self._process_pressure_data(raw_data['pressure'])
return processed
def _process_pose_data(self, pose_data: Dict[str, Any]) -> Dict[str, Any]:
"""处理姿态数据"""
return {
'center_of_gravity': pose_data.get('center_of_gravity', {'x': 0, 'y': 0}),
'body_angle': pose_data.get('body_angle', {'pitch': 0, 'roll': 0, 'yaw': 0}),
'confidence': pose_data.get('confidence', 0.0)
}
def _process_imu_data(self, imu_data: Dict[str, Any]) -> Dict[str, Any]:
"""处理IMU数据"""
# 计算合成加速度
accel = imu_data.get('accel', {'x': 0, 'y': 0, 'z': 0})
total_accel = np.sqrt(accel['x']**2 + accel['y']**2 + accel['z']**2)
# 计算倾斜角度
pitch = np.arctan2(accel['y'], np.sqrt(accel['x']**2 + accel['z']**2)) * 180 / np.pi
roll = np.arctan2(-accel['x'], accel['z']) * 180 / np.pi
return {
'accel': accel,
'gyro': imu_data.get('gyro', {'x': 0, 'y': 0, 'z': 0}),
'total_accel': total_accel,
'pitch': pitch,
'roll': roll,
'temperature': imu_data.get('temperature', 0)
}
def _process_pressure_data(self, pressure_data: Dict[str, Any]) -> Dict[str, Any]:
"""处理压力数据"""
left_foot = pressure_data.get('left_foot', 0)
right_foot = pressure_data.get('right_foot', 0)
total_pressure = left_foot + right_foot
# 计算压力分布比例
if total_pressure > 0:
left_ratio = left_foot / total_pressure
right_ratio = right_foot / total_pressure
balance_index = abs(left_ratio - right_ratio) # 平衡指数,越小越平衡
else:
left_ratio = right_ratio = 0.5
balance_index = 0
return {
'left_foot': left_foot,
'right_foot': right_foot,
'total_pressure': total_pressure,
'left_ratio': left_ratio,
'right_ratio': right_ratio,
'balance_index': balance_index,
'center_of_pressure': pressure_data.get('center_of_pressure', {'x': 0, 'y': 0})
}
def _real_time_analysis(self, session_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""实时分析"""
results = {}
try:
# 获取历史数据用于趋势分析
with self.data_lock:
data_buffer = list(self.session_data[session_id]['data_buffer'])
if len(data_buffer) < 2:
return results
# 平衡分析
if 'pressure' in data:
balance_result = self.analysis_algorithms['balance_analysis'].analyze_real_time(
data['pressure'], data_buffer
)
results['balance'] = balance_result
# 姿态分析
if 'pose' in data or 'imu' in data:
posture_result = self.analysis_algorithms['posture_analysis'].analyze_real_time(
data, data_buffer
)
results['posture'] = posture_result
# 运动分析
movement_result = self.analysis_algorithms['movement_analysis'].analyze_real_time(
data, data_buffer
)
results['movement'] = movement_result
except Exception as e:
logger.error(f'实时分析失败: {e}')
return results
def get_latest_data(self, session_id: str) -> Dict[str, Any]:
"""获取最新数据"""
if session_id not in self.session_data:
return {}
with self.data_lock:
session = self.session_data[session_id]
if not session['data_buffer']:
return {}
latest_data = session['data_buffer'][-1]
real_time_metrics = session['real_time_metrics'].copy()
return {
'latest_data': latest_data,
'real_time_metrics': real_time_metrics,
'data_count': len(session['data_buffer'])
}
def analyze_session(self, session_id: str) -> Dict[str, Any]:
"""分析整个会话数据"""
if session_id not in self.session_data:
logger.warning(f'会话不存在: {session_id}')
return {}
try:
with self.data_lock:
session = self.session_data[session_id]
data_buffer = list(session['data_buffer'])
settings = session['settings']
if not data_buffer:
return {'error': '没有数据可分析'}
analysis_results = {}
# 全面平衡分析
balance_analysis = self.analysis_algorithms['balance_analysis'].analyze_full_session(
data_buffer, settings
)
analysis_results['balance'] = balance_analysis
# 全面姿态分析
posture_analysis = self.analysis_algorithms['posture_analysis'].analyze_full_session(
data_buffer, settings
)
analysis_results['posture'] = posture_analysis
# 全面运动分析
movement_analysis = self.analysis_algorithms['movement_analysis'].analyze_full_session(
data_buffer, settings
)
analysis_results['movement'] = movement_analysis
# 综合评估
overall_assessment = self._generate_overall_assessment(analysis_results)
analysis_results['overall'] = overall_assessment
# 保存分析结果
with self.data_lock:
self.session_data[session_id]['analysis_results'] = analysis_results
logger.info(f'会话分析完成: {session_id}')
return analysis_results
except Exception as e:
logger.error(f'会话分析失败: {e}')
return {'error': str(e)}
def _generate_overall_assessment(self, analysis_results: Dict[str, Any]) -> Dict[str, Any]:
"""生成综合评估"""
try:
# 提取各项评分
balance_score = analysis_results.get('balance', {}).get('score', 0)
posture_score = analysis_results.get('posture', {}).get('score', 0)
movement_score = analysis_results.get('movement', {}).get('score', 0)
# 计算综合评分(加权平均)
weights = {'balance': 0.4, 'posture': 0.3, 'movement': 0.3}
overall_score = (
balance_score * weights['balance'] +
posture_score * weights['posture'] +
movement_score * weights['movement']
)
# 评估等级
if overall_score >= 90:
grade = 'A'
description = '优秀'
elif overall_score >= 80:
grade = 'B'
description = '良好'
elif overall_score >= 70:
grade = 'C'
description = '一般'
elif overall_score >= 60:
grade = 'D'
description = '较差'
else:
grade = 'E'
description = ''
# 生成建议
recommendations = self._generate_recommendations(analysis_results)
return {
'score': round(overall_score, 1),
'grade': grade,
'description': description,
'recommendations': recommendations,
'component_scores': {
'balance': balance_score,
'posture': posture_score,
'movement': movement_score
}
}
except Exception as e:
logger.error(f'综合评估生成失败: {e}')
return {'score': 0, 'grade': 'E', 'description': '评估失败'}
def _generate_recommendations(self, analysis_results: Dict[str, Any]) -> List[str]:
"""生成改善建议"""
recommendations = []
try:
# 平衡相关建议
balance_data = analysis_results.get('balance', {})
if balance_data.get('score', 0) < 80:
if balance_data.get('left_right_imbalance', 0) > 0.2:
recommendations.append('注意左右脚压力分布,建议进行单脚站立练习')
if balance_data.get('stability_index', 0) > 0.5:
recommendations.append('重心摆动较大,建议进行静态平衡训练')
# 姿态相关建议
posture_data = analysis_results.get('posture', {})
if posture_data.get('score', 0) < 80:
if abs(posture_data.get('avg_pitch', 0)) > 5:
recommendations.append('身体前后倾斜较明显,注意保持直立姿态')
if abs(posture_data.get('avg_roll', 0)) > 5:
recommendations.append('身体左右倾斜较明显,注意身体对称性')
# 运动相关建议
movement_data = analysis_results.get('movement', {})
if movement_data.get('score', 0) < 80:
if movement_data.get('movement_variability', 0) > 0.8:
recommendations.append('身体摆动过大,建议进行核心稳定性训练')
if movement_data.get('movement_frequency', 0) > 2:
recommendations.append('身体摆动频率较高,建议放松并专注于静态平衡')
# 通用建议
if not recommendations:
recommendations.append('整体表现良好,继续保持规律的平衡训练')
except Exception as e:
logger.error(f'建议生成失败: {e}')
recommendations = ['建议咨询专业医师进行详细评估']
return recommendations
def save_screenshot(self, patient_id: str, session_id: str, image_data: str, filename: str = None) -> Dict[str, Any]:
"""保存截图"""
try:
# 参数验证
if not patient_id or not image_data:
return {'success': False, 'error': '缺少必要参数'}
# 解码Base64图片数据
try:
# 移除data:image/jpeg;base64,前缀(如果存在)
if ',' in image_data:
image_data = image_data.split(',')[1]
image_bytes = base64.b64decode(image_data)
except Exception as e:
logger.error(f'Base64解码失败: {e}')
return {'success': False, 'error': 'Base64解码失败'}
# 生成文件名
if not filename:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'screenshot_{timestamp}.jpg'
# 确保文件名以.jpg结尾
if not filename.lower().endswith('.jpg'):
filename += '.jpg'
# 创建患者会话目录
root_data_dir = Path(os.path.dirname(os.path.dirname(__file__))) / 'data'
session_dir = root_data_dir / 'patients' / patient_id / session_id
session_dir.mkdir(parents=True, exist_ok=True)
# 保存文件
file_path = session_dir / filename
with open(file_path, 'wb') as f:
f.write(image_bytes)
logger.info(f'截图保存成功: {file_path}')
return {
'success': True,
'file_path': str(file_path),
'filename': filename
}
except Exception as e:
logger.error(f'保存截图失败: {e}')
return {'success': False, 'error': str(e)}
def save_recording(self, patient_id: str, session_id: str, video_data: str, filename: str = None) -> Dict[str, Any]:
"""保存录像"""
try:
# 参数验证
if not patient_id or not video_data:
return {'success': False, 'error': '缺少必要参数'}
# 解码Base64视频数据
try:
# 移除data:video/webm;base64,前缀(如果存在)
if ',' in video_data:
video_data = video_data.split(',')[1]
video_bytes = base64.b64decode(video_data)
except Exception as e:
logger.error(f'Base64解码失败: {e}')
return {'success': False, 'error': 'Base64解码失败'}
# 生成文件名
if not filename:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'recording_{timestamp}.mp4'
# 确保文件名以.mp4结尾
if not filename.lower().endswith('.mp4'):
filename += '.mp4'
# 创建患者会话目录
root_data_dir = Path(os.path.dirname(os.path.dirname(__file__))) / 'data'
session_dir = root_data_dir / 'patients' / patient_id / session_id
session_dir.mkdir(parents=True, exist_ok=True)
# 保存文件
file_path = session_dir / filename
with open(file_path, 'wb') as f:
f.write(video_bytes)
logger.info(f'录像保存成功: {file_path}')
return {
'success': True,
'file_path': str(file_path),
'filename': filename
}
except Exception as e:
logger.error(f'保存录像失败: {e}')
return {'success': False, 'error': str(e)}
def get_patient_files(self, patient_id: str) -> Dict[str, Any]:
"""获取患者的所有文件列表按会话ID组织"""
try:
root_data_dir = Path(os.path.dirname(os.path.dirname(__file__))) / 'data'
patient_dir = root_data_dir / 'patients' / patient_id
if not patient_dir.exists():
return {'sessions': {}}
sessions = {}
# 遍历患者目录下的所有会话目录
for session_dir in patient_dir.iterdir():
if session_dir.is_dir():
session_id = session_dir.name
screenshots = []
recordings = []
# 获取该会话下的截图列表
for file_path in session_dir.glob('screenshot_*.jpg'):
screenshots.append({
'filename': file_path.name,
'path': str(file_path),
'created_time': datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
})
# 获取该会话下的录像列表
for file_path in session_dir.glob('recording_*.mp4'):
recordings.append({
'filename': file_path.name,
'path': str(file_path),
'created_time': datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
})
sessions[session_id] = {
'screenshots': sorted(screenshots, key=lambda x: x['created_time'], reverse=True),
'recordings': sorted(recordings, key=lambda x: x['created_time'], reverse=True)
}
return {'sessions': sessions}
except Exception as e:
logger.error(f'获取患者文件列表失败: {e}')
return {'sessions': {}}
def end_session(self, session_id: str):
"""结束检测会话"""
if session_id in self.session_data:
with self.data_lock:
del self.session_data[session_id]
logger.info(f'检测会话结束: {session_id}')
class BalanceAnalyzer:
"""平衡分析器"""
def analyze_real_time(self, pressure_data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
"""实时平衡分析"""
try:
balance_index = pressure_data.get('balance_index', 0)
cop = pressure_data.get('center_of_pressure', {'x': 0, 'y': 0})
# 计算最近10个数据点的平衡稳定性
recent_data = data_buffer[-10:] if len(data_buffer) >= 10 else data_buffer
if recent_data and all('pressure' in d for d in recent_data):
balance_indices = [d['pressure'].get('balance_index', 0) for d in recent_data]
stability = 1.0 - np.std(balance_indices) # 标准差越小,稳定性越高
else:
stability = 0.5
return {
'balance_index': balance_index,
'stability': max(0, min(1, stability)),
'center_of_pressure': cop,
'status': 'stable' if balance_index < 0.2 else 'unstable'
}
except Exception as e:
logger.error(f'实时平衡分析失败: {e}')
return {'balance_index': 0, 'stability': 0, 'status': 'unknown'}
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
"""全会话平衡分析"""
try:
pressure_data = [d['pressure'] for d in data_buffer if 'pressure' in d]
if not pressure_data:
return {'score': 0, 'error': '没有压力数据'}
# 提取关键指标
balance_indices = [d.get('balance_index', 0) for d in pressure_data]
left_ratios = [d.get('left_ratio', 0.5) for d in pressure_data]
right_ratios = [d.get('right_ratio', 0.5) for d in pressure_data]
# 计算统计指标
avg_balance_index = np.mean(balance_indices)
std_balance_index = np.std(balance_indices)
max_balance_index = np.max(balance_indices)
# 左右脚不平衡程度
left_right_imbalance = abs(np.mean(left_ratios) - np.mean(right_ratios))
# 稳定性指数(基于标准差)
stability_index = std_balance_index
# 计算评分0-100分
balance_score = max(0, 100 - avg_balance_index * 200) # 平衡指数越小分数越高
stability_score = max(0, 100 - stability_index * 500) # 稳定性越高分数越高
symmetry_score = max(0, 100 - left_right_imbalance * 200) # 对称性越好分数越高
overall_score = (balance_score + stability_score + symmetry_score) / 3
return {
'score': round(overall_score, 1),
'avg_balance_index': round(avg_balance_index, 3),
'stability_index': round(stability_index, 3),
'left_right_imbalance': round(left_right_imbalance, 3),
'max_imbalance': round(max_balance_index, 3),
'data_points': len(pressure_data),
'component_scores': {
'balance': round(balance_score, 1),
'stability': round(stability_score, 1),
'symmetry': round(symmetry_score, 1)
}
}
except Exception as e:
logger.error(f'全会话平衡分析失败: {e}')
return {'score': 0, 'error': str(e)}
class PostureAnalyzer:
"""姿态分析器"""
def analyze_real_time(self, data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
"""实时姿态分析"""
try:
result = {}
# 分析IMU数据
if 'imu' in data:
imu_data = data['imu']
result['pitch'] = imu_data.get('pitch', 0)
result['roll'] = imu_data.get('roll', 0)
result['total_accel'] = imu_data.get('total_accel', 0)
# 分析姿态数据
if 'pose' in data:
pose_data = data['pose']
result['body_angle'] = pose_data.get('body_angle', {})
result['confidence'] = pose_data.get('confidence', 0)
# 计算姿态稳定性
recent_data = data_buffer[-5:] if len(data_buffer) >= 5 else data_buffer
if recent_data:
if 'imu' in data:
pitches = [d.get('imu', {}).get('pitch', 0) for d in recent_data if 'imu' in d]
rolls = [d.get('imu', {}).get('roll', 0) for d in recent_data if 'imu' in d]
if pitches and rolls:
pitch_stability = 1.0 - min(1.0, np.std(pitches) / 10)
roll_stability = 1.0 - min(1.0, np.std(rolls) / 10)
result['stability'] = (pitch_stability + roll_stability) / 2
return result
except Exception as e:
logger.error(f'实时姿态分析失败: {e}')
return {}
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
"""全会话姿态分析"""
try:
imu_data = [d['imu'] for d in data_buffer if 'imu' in d]
if not imu_data:
return {'score': 0, 'error': '没有IMU数据'}
# 提取角度数据
pitches = [d.get('pitch', 0) for d in imu_data]
rolls = [d.get('roll', 0) for d in imu_data]
# 计算统计指标
avg_pitch = np.mean(pitches)
avg_roll = np.mean(rolls)
std_pitch = np.std(pitches)
std_roll = np.std(rolls)
max_pitch = np.max(np.abs(pitches))
max_roll = np.max(np.abs(rolls))
# 计算评分
pitch_score = max(0, 100 - abs(avg_pitch) * 5) # 平均倾斜角度越小分数越高
roll_score = max(0, 100 - abs(avg_roll) * 5)
stability_score = max(0, 100 - (std_pitch + std_roll) * 10) # 稳定性越高分数越高
overall_score = (pitch_score + roll_score + stability_score) / 3
return {
'score': round(overall_score, 1),
'avg_pitch': round(avg_pitch, 2),
'avg_roll': round(avg_roll, 2),
'std_pitch': round(std_pitch, 2),
'std_roll': round(std_roll, 2),
'max_pitch': round(max_pitch, 2),
'max_roll': round(max_roll, 2),
'data_points': len(imu_data),
'component_scores': {
'pitch': round(pitch_score, 1),
'roll': round(roll_score, 1),
'stability': round(stability_score, 1)
}
}
except Exception as e:
logger.error(f'全会话姿态分析失败: {e}')
return {'score': 0, 'error': str(e)}
class MovementAnalyzer:
"""运动分析器"""
def analyze_real_time(self, data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
"""实时运动分析"""
try:
if len(data_buffer) < 5:
return {'movement_detected': False}
# 分析最近的运动模式
recent_data = data_buffer[-10:]
# 计算重心位置变化
if 'pressure' in data:
cop_positions = []
for d in recent_data:
if 'pressure' in d:
cop = d['pressure'].get('center_of_pressure', {'x': 0, 'y': 0})
cop_positions.append((cop['x'], cop['y']))
if len(cop_positions) >= 2:
# 计算运动幅度
x_positions = [pos[0] for pos in cop_positions]
y_positions = [pos[1] for pos in cop_positions]
movement_range_x = np.max(x_positions) - np.min(x_positions)
movement_range_y = np.max(y_positions) - np.min(y_positions)
return {
'movement_detected': movement_range_x > 5 or movement_range_y > 5,
'movement_range_x': movement_range_x,
'movement_range_y': movement_range_y,
'total_movement': np.sqrt(movement_range_x**2 + movement_range_y**2)
}
return {'movement_detected': False}
except Exception as e:
logger.error(f'实时运动分析失败: {e}')
return {'movement_detected': False}
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
"""全会话运动分析"""
try:
# 提取压力中心数据
cop_data = []
for d in data_buffer:
if 'pressure' in d:
cop = d['pressure'].get('center_of_pressure', {'x': 0, 'y': 0})
cop_data.append((cop['x'], cop['y']))
if len(cop_data) < 10:
return {'score': 0, 'error': '数据不足'}
# 计算运动指标
x_positions = [pos[0] for pos in cop_data]
y_positions = [pos[1] for pos in cop_data]
# 运动范围
movement_range_x = np.max(x_positions) - np.min(x_positions)
movement_range_y = np.max(y_positions) - np.min(y_positions)
total_range = np.sqrt(movement_range_x**2 + movement_range_y**2)
# 运动变异性
movement_variability = np.std(x_positions) + np.std(y_positions)
# 运动路径长度
path_length = 0
for i in range(1, len(cop_data)):
dx = cop_data[i][0] - cop_data[i-1][0]
dy = cop_data[i][1] - cop_data[i-1][1]
path_length += np.sqrt(dx**2 + dy**2)
# 运动频率分析(简化)
movement_frequency = path_length / len(cop_data) if len(cop_data) > 0 else 0
# 计算评分(运动幅度适中得分高)
range_score = max(0, 100 - total_range * 2) # 运动范围适中
variability_score = max(0, 100 - movement_variability * 10) # 变异性小
frequency_score = max(0, 100 - movement_frequency * 20) # 频率适中
overall_score = (range_score + variability_score + frequency_score) / 3
return {
'score': round(overall_score, 1),
'movement_range_x': round(movement_range_x, 2),
'movement_range_y': round(movement_range_y, 2),
'total_range': round(total_range, 2),
'movement_variability': round(movement_variability, 2),
'path_length': round(path_length, 2),
'movement_frequency': round(movement_frequency, 2),
'data_points': len(cop_data),
'component_scores': {
'range': round(range_score, 1),
'variability': round(variability_score, 1),
'frequency': round(frequency_score, 1)
}
}
except Exception as e:
logger.error(f'全会话运动分析失败: {e}')
return {'score': 0, 'status': 'error', 'error': str(e)}
# ==================== Flask 路由定义 ====================
# 创建Blueprint
detection_bp = Blueprint('detection', __name__)
# 全局detection_engine实例
detection_engine_instance = None
def init_detection_engine():
"""初始化检测引擎实例"""
global detection_engine_instance
if detection_engine_instance is None:
detection_engine_instance = DetectionEngine()
return detection_engine_instance
@detection_bp.route('/api/screenshots/save', methods=['POST'])
def save_screenshot():
"""保存截图"""
try:
engine = init_detection_engine()
data = request.get_json()
# 验证必需参数
required_fields = ['patientId', 'imageData', 'sessionId']
for field in required_fields:
if not data.get(field):
return jsonify({
'success': False,
'message': f'缺少必需参数: {field}'
}), 400
patient_id = data['patientId']
image_data = data['imageData']
session_id = data['sessionId']
filename = data.get('filename') # 可选参数
# 验证base64图片数据格式
if not image_data.startswith('data:image/'):
return jsonify({
'success': False,
'message': '无效的图片数据格式'
}), 400
# 调用detection_engine的保存截图方法
result = engine.save_screenshot(patient_id, session_id, image_data, filename)
if result['success']:
return jsonify({
'success': True,
'message': '截图保存成功',
'filepath': result['file_path'],
'filename': result['filename']
})
else:
return jsonify({
'success': False,
'message': result['error']
}), 400
except Exception as e:
logger.error(f'保存截图失败: {e}')
return jsonify({
'success': False,
'message': f'保存截图失败: {str(e)}'
}), 500
@detection_bp.route('/api/recordings/save', methods=['POST'])
def save_recording():
"""保存录像"""
try:
engine = init_detection_engine()
data = request.get_json()
# 验证必需参数
required_fields = ['patientId', 'videoData', 'sessionId']
for field in required_fields:
if not data.get(field):
return jsonify({
'success': False,
'message': f'缺少必需参数: {field}'
}), 400
patient_id = data['patientId']
video_data = data['videoData']
session_id = data['sessionId']
filename = data.get('filename') # 可选参数
# 验证base64视频数据格式
if not (video_data.startswith('data:video/mp4') or video_data.startswith('data:video/webm')):
return jsonify({
'success': False,
'message': '无效的视频数据格式仅支持MP4和WebM格式'
}), 400
# 调用detection_engine的保存录像方法
result = engine.save_recording(patient_id, session_id, video_data, filename)
if result['success']:
return jsonify({
'success': True,
'message': '录像保存成功',
'filepath': result['file_path'],
'filename': result['filename']
})
else:
return jsonify({
'success': False,
'message': result['error']
}), 400
except Exception as e:
logger.error(f'保存录像失败: {e}')
return jsonify({
'success': False,
'message': f'保存录像失败: {str(e)}'
}), 500
@detection_bp.route('/api/patients/<patient_id>/files', methods=['GET'])
def get_patient_files(patient_id):
"""获取患者的所有文件列表"""
try:
engine = init_detection_engine()
# 调用detection_engine的获取患者文件方法
result = engine.get_patient_files(patient_id)
return jsonify({
'success': True,
'data': result
})
except Exception as e:
logger.error(f'获取患者文件列表失败: {e}')
return jsonify({
'success': False,
'message': f'获取患者文件列表失败: {str(e)}'
}), 500

File diff suppressed because it is too large Load Diff

BIN
backend/dll/k4a.dll Normal file

Binary file not shown.

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>Socket.IO Test</title>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
<h1>Socket.IO Test Page</h1>
<div id="status">未连接</div>
<button onclick="sendStartVideo()">发送 start_video 事件</button>
<button onclick="sendTestEvent()">发送 test_event 事件</button>
<div id="messages"></div>
<script>
const socket = io('http://localhost:5001');
const statusDiv = document.getElementById('status');
const messagesDiv = document.getElementById('messages');
function addMessage(message) {
const div = document.createElement('div');
div.textContent = new Date().toLocaleTimeString() + ': ' + message;
messagesDiv.appendChild(div);
}
socket.on('connect', function() {
statusDiv.textContent = '已连接 - Socket ID: ' + socket.id;
addMessage('连接成功');
});
socket.on('disconnect', function() {
statusDiv.textContent = '已断开连接';
addMessage('连接断开');
});
socket.on('connect_response', function(data) {
addMessage('收到连接响应: ' + JSON.stringify(data));
});
socket.on('video_status', function(data) {
addMessage('收到视频状态: ' + JSON.stringify(data));
});
socket.on('test_response', function(data) {
addMessage('收到测试响应: ' + JSON.stringify(data));
});
function sendStartVideo() {
addMessage('发送 start_video 事件');
socket.emit('start_video', {});
}
function sendTestEvent() {
addMessage('发送 test_event 事件');
socket.emit('test_event', {});
}
</script>
</body>
</html>

44
backend/test_socketio.py Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask
from flask_socketio import SocketIO, emit
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
app.config['SECRET_KEY'] = 'test-secret-key'
socketio = SocketIO(app, cors_allowed_origins='*', logger=True, engineio_logger=True)
@socketio.on('connect')
def handle_connect():
print('=== CLIENT CONNECTED ===', flush=True)
logger.info('客户端已连接')
emit('connect_response', {'message': '连接成功'})
@socketio.on('disconnect')
def handle_disconnect():
print('=== CLIENT DISCONNECTED ===', flush=True)
logger.info('客户端已断开连接')
@socketio.on('start_video')
def handle_start_video(data=None):
print('=== START VIDEO EVENT RECEIVED ===', flush=True)
print(f'Data: {data}', flush=True)
logger.info('=== START VIDEO EVENT RECEIVED ===')
logger.info(f'Data: {data}')
emit('video_status', {'status': 'received', 'message': 'start_video事件已接收'})
return {'status': 'success'}
@socketio.on('test_event')
def handle_test_event(data=None):
print('=== TEST EVENT RECEIVED ===', flush=True)
logger.info('=== TEST EVENT RECEIVED ===')
emit('test_response', {'message': 'Test event received'})
if __name__ == '__main__':
print('启动测试Socket.IO服务器...')
socketio.run(app, host='0.0.0.0', port=5001, debug=False)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,273 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Azure Kinect SDK 1.4.0 图像获取示例
这个示例展示了如何使用pyKinectAzure库来获取Azure Kinect DK传感器的各种图像数据
- 彩色图像 (Color Image)
- 深度图像 (Depth Image)
- 红外图像 (Infrared Image)
- 彩色深度图像 (Colored Depth Image)
要求:
- Azure Kinect SDK 1.4.0
- pykinect_azure库
- OpenCV (cv2)
- numpy
使用方法:
1. 确保Azure Kinect DK设备已连接
2. 运行脚本: python azure_kinect_image_example.py
3. 'q' 键退出程序
4. '1' 键切换到彩色图像模式
5. '2' 键切换到深度图像模式
6. '3' 键切换到红外图像模式
7. '4' 键切换到彩色深度图像模式
8. 's' 键保存当前图像
"""
import cv2
import numpy as np
import os
from datetime import datetime
try:
import pykinect_azure as pykinect
except ImportError:
print("错误: 无法导入pykinect_azure库")
print("请使用以下命令安装: pip install pykinect_azure")
exit(1)
class AzureKinectImageCapture:
"""Azure Kinect图像捕获类"""
def __init__(self):
self.device = None
self.current_mode = 1 # 1:彩色, 2:深度, 3:红外, 4:彩色深度
self.save_counter = 0
# 创建保存图像的目录
self.save_dir = "captured_images"
if not os.path.exists(self.save_dir):
os.makedirs(self.save_dir)
def initialize_device(self):
"""初始化Azure Kinect设备"""
try:
# 初始化库,解决"Compatible Azure Kinect SDK not found"兼容性问题
#
# 方法1: 自动检测(可能遇到版本兼容性问题)
# pykinect.initialize_libraries()
#
# 方法2: 手动指定路径(推荐,解决版本兼容性问题)
# 可选的SDK路径按优先级排序
import platform
import os
sdk_paths = []
if platform.system() == "Windows":
# Orbbec SDK K4A Wrapper推荐
sdk_paths.append(r"D:\OrbbecSDK_K4A_Wrapper_v1.10.3_windows_202408091749\bin\k4a.dll")
else:
# Linux路径
sdk_paths.extend([
"/usr/lib/x86_64-linux-gnu/libk4a.so",
"/usr/local/lib/libk4a.so"
])
# 尝试使用可用的SDK路径
sdk_initialized = False
for sdk_path in sdk_paths:
if os.path.exists(sdk_path):
try:
print(f"尝试使用SDK路径: {sdk_path}")
pykinect.initialize_libraries(module_k4a_path=sdk_path)
print(f"✓ 成功使用SDK: {sdk_path}")
sdk_initialized = True
break
except Exception as e:
print(f"✗ SDK路径失败: {sdk_path} - {e}")
continue
# 如果所有手动路径都失败,尝试自动检测
if not sdk_initialized:
try:
print("尝试自动检测SDK...")
pykinect.initialize_libraries()
print("✓ 自动检测SDK成功")
sdk_initialized = True
except Exception as e:
print(f"✗ 自动检测失败: {e}")
if not sdk_initialized:
raise Exception("无法初始化Azure Kinect SDK。请检查SDK安装或手动指定正确的路径。")
# 配置设备参数
device_config = pykinect.default_configuration
device_config.color_resolution = pykinect.K4A_COLOR_RESOLUTION_1080P # 1080p彩色分辨率
device_config.depth_mode = pykinect.K4A_DEPTH_MODE_WFOV_2X2BINNED # 宽视场深度模式
device_config.camera_fps = pykinect.K4A_FRAMES_PER_SECOND_30 # 30 FPS
device_config.synchronized_images_only = True # 同步图像
print("设备配置:")
print(device_config)
# 启动设备
self.device = pykinect.start_device(config=device_config)
print("Azure Kinect设备初始化成功!")
return True
except Exception as e:
print(f"设备初始化失败: {e}")
print("请确保:")
print("1. Azure Kinect DK设备已正确连接")
print("2. Azure Kinect SDK 1.4.0已正确安装")
print("3. 设备驱动程序已安装")
return False
def get_color_image(self):
"""获取彩色图像"""
capture = self.device.update()
ret, color_image = capture.get_color_image()
return ret, color_image
def get_depth_image(self):
"""获取原始深度图像"""
capture = self.device.update()
ret, depth_image = capture.get_depth_image()
return ret, depth_image
def get_colored_depth_image(self):
"""获取彩色深度图像"""
capture = self.device.update()
ret, colored_depth_image = capture.get_colored_depth_image()
return ret, colored_depth_image
def get_infrared_image(self):
"""获取红外图像"""
capture = self.device.update()
ret, ir_image = capture.get_ir_image()
return ret, ir_image
def save_image(self, image, mode_name):
"""保存图像到文件"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{mode_name}_{timestamp}_{self.save_counter:03d}.png"
filepath = os.path.join(self.save_dir, filename)
cv2.imwrite(filepath, image)
print(f"图像已保存: {filepath}")
self.save_counter += 1
def display_instructions(self):
"""显示操作说明"""
instructions = [
"=== Azure Kinect 图像捕获示例 ===",
"操作说明:",
"1 - 切换到彩色图像模式",
"2 - 切换到深度图像模式",
"3 - 切换到红外图像模式",
"4 - 切换到彩色深度图像模式",
"s - 保存当前图像",
"q - 退出程序",
"================================="
]
for instruction in instructions:
print(instruction)
def run(self):
"""运行主循环"""
if not self.initialize_device():
return
self.display_instructions()
# 创建窗口
cv2.namedWindow('Azure Kinect Image', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Azure Kinect Image', 1280, 720)
mode_names = {
1: "Color Image",
2: "Depth Image",
3: "Infrared Image",
4: "Colored Depth Image"
}
print(f"\n当前模式: {mode_names[self.current_mode]}")
while True:
try:
# 根据当前模式获取相应图像
if self.current_mode == 1:
ret, image = self.get_color_image()
elif self.current_mode == 2:
ret, image = self.get_depth_image()
if ret:
# 将深度图像转换为可视化格式
image = cv2.convertScaleAbs(image, alpha=0.05)
elif self.current_mode == 3:
ret, image = self.get_infrared_image()
if ret:
# 将红外图像转换为可视化格式
image = cv2.convertScaleAbs(image, alpha=0.5)
elif self.current_mode == 4:
ret, image = self.get_colored_depth_image()
if not ret:
continue
# 在图像上添加模式信息
mode_text = f"Mode: {mode_names[self.current_mode]}"
cv2.putText(image, mode_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
1, (0, 255, 0), 2, cv2.LINE_AA)
# 显示图像
cv2.imshow('Azure Kinect Image', image)
# 处理按键
key = cv2.waitKey(1) & 0xFF
if key == ord('q'):
print("退出程序...")
break
elif key == ord('1'):
self.current_mode = 1
print(f"切换到: {mode_names[self.current_mode]}")
elif key == ord('2'):
self.current_mode = 2
print(f"切换到: {mode_names[self.current_mode]}")
elif key == ord('3'):
self.current_mode = 3
print(f"切换到: {mode_names[self.current_mode]}")
elif key == ord('4'):
self.current_mode = 4
print(f"切换到: {mode_names[self.current_mode]}")
elif key == ord('s'):
self.save_image(image, mode_names[self.current_mode].replace(" ", "_"))
except KeyboardInterrupt:
print("\n程序被用户中断")
break
except Exception as e:
print(f"运行时错误: {e}")
break
# 清理资源
cv2.destroyAllWindows()
print("程序结束")
def main():
"""主函数"""
print("Azure Kinect SDK 1.4.0 图像获取示例")
print("作者: AI Assistant")
print("版本: 1.0\n")
# 创建并运行图像捕获实例
capture = AzureKinectImageCapture()
capture.run()
if __name__ == "__main__":
main()

View File

@ -114,7 +114,7 @@ def start_debug_server():
host='0.0.0.0', # 允许所有IP访问 host='0.0.0.0', # 允许所有IP访问
port=5000, port=5000,
debug=True, debug=True,
use_reloader=True, # 启用热重载 use_reloader=False, # 禁用热重载以避免FemtoBolt设备资源冲突
log_output=True, # 输出详细日志 log_output=True, # 输出详细日志
allow_unsafe_werkzeug=True allow_unsafe_werkzeug=True
) )

View File

@ -0,0 +1,241 @@
# 数据采集规则说明
## 概述
本文档详细说明了身体平衡评估系统的数据采集规则,包括从各种传感器设备采集数据、生成图片并存储到指定路径的完整流程。
## 数据库表结构
### detection_data 表结构
```sql
CREATE TABLE IF NOT EXISTS detection_data (
id TEXT PRIMARY KEY, -- 记录唯一标识(YYYYMMDDHHMMSS)年月日时分秒
session_id TEXT NOT NULL, -- 检测会话ID外键
head_pose TEXT, -- 头部姿态数据JSON格式
body_pose TEXT, -- 身体姿态数据JSON格式
body_image TEXT, -- 身体视频截图存储路径
foot_data TEXT, -- 足部姿态数据JSON格式
foot_image TEXT, -- 足部监测视频截图存储路径
foot_data_image TEXT, -- 足底压力数据图存储路径
screen_image TEXT, -- 屏幕录制视频截图存储路径
timestamp TIMESTAMP, -- 数据记录时间戳
FOREIGN KEY (session_id) REFERENCES detection_sessions (id) -- 检测会话表外键约束
)
```
## 数据采集方法
### 方法签名
```python
def collect_data(self, session_id: str, patient_id: str, screen_image_base64: str = None) -> Dict[str, Any]
```
### 参数说明
- `session_id`: 检测会话ID用于关联检测会话
- `patient_id`: 患者ID用于构建存储路径
- `screen_image_base64`: 前端界面截图的base64编码数据可选
## 数据采集详细规则
### 1. 头部姿态数据 (head_pose)
**数据源**: IMU传感器设备
**存储格式**: JSON字符串
**数据内容**:
- `roll`: 翻滚角度 (-30° 到 30°)
- `pitch`: 俯仰角度 (-30° 到 30°)
- `yaw`: 偏航角度 (-180° 到 180°)
- `acceleration`: 三轴加速度数据 (x, y, z)
- `gyroscope`: 三轴陀螺仪数据 (x, y, z)
- `timestamp`: 数据采集时间戳
**示例数据**:
```json
{
"roll": 15.2,
"pitch": -8.7,
"yaw": 45.3,
"acceleration": {
"x": 0.5,
"y": -0.2,
"z": 9.8
},
"gyroscope": {
"x": 1.2,
"y": -0.8,
"z": 2.1
},
"timestamp": "2024-01-15T10:30:45.123"
}
```
### 2. 身体姿态数据 (body_pose)
**数据源**: FemtoBolt深度相机
**存储格式**: JSON字符串
**数据内容**:
- `keypoints`: 身体关键点坐标和置信度
- 头部、颈部、肩膀、肘部、手腕
- 脊柱、髋部、膝盖、脚踝
- `balance_score`: 平衡评分 (0.0-1.0)
- `center_of_mass`: 重心坐标
- `timestamp`: 数据采集时间戳
**示例数据**:
```json
{
"keypoints": {
"head": {"x": 320, "y": 100, "confidence": 0.95},
"neck": {"x": 320, "y": 150, "confidence": 0.92},
"left_shoulder": {"x": 280, "y": 180, "confidence": 0.88},
"right_shoulder": {"x": 360, "y": 180, "confidence": 0.90}
},
"balance_score": 0.85,
"center_of_mass": {"x": 320, "y": 350},
"timestamp": "2024-01-15T10:30:45.123"
}
```
### 3. 身体视频截图 (body_image)
**数据源**: FemtoBolt深度相机
**存储路径**: `data/patients/{患者ID}/{sessionID}/{采集时间}/body_image.jpg`
**文件格式**: JPG图片
**图片尺寸**: 640x480像素
### 4. 足部压力数据 (foot_data)
**数据源**: 压力传感器 (pressure_sensor)
**存储格式**: JSON字符串
**数据内容**:
- `left_foot`: 左脚各区域压力值
- `heel`: 脚跟压力
- `arch`: 足弓压力
- `ball`: 前脚掌压力
- `toes`: 脚趾压力
- `total_pressure`: 总压力
- `right_foot`: 右脚各区域压力值
- `balance_ratio`: 左右脚压力平衡比例
- `timestamp`: 数据采集时间戳
**示例数据**:
```json
{
"left_foot": {
"heel": 45.2,
"arch": 12.8,
"ball": 38.5,
"toes": 22.1,
"total_pressure": 118.6
},
"right_foot": {
"heel": 42.8,
"arch": 15.2,
"ball": 35.9,
"toes": 19.7,
"total_pressure": 113.6
},
"balance_ratio": 0.51,
"timestamp": "2024-01-15T10:30:45.123"
}
```
### 5. 足部监测视频截图 (foot_image)
**数据源**: 摄像头 (camera)
**存储路径**: `data/patients/{患者ID}/{sessionID}/{采集时间}/foot_image.jpg`
**文件格式**: JPG图片
**图片尺寸**: 640x480像素
### 6. 足底压力数据图 (foot_data_image)
**数据源**: 基于压力传感器数据生成的可视化图片
**存储路径**: `data/patients/{患者ID}/{sessionID}/{采集时间}/foot_data_image.jpg`
**文件格式**: JPG图片
**图片内容**: 足底压力分布热力图
**图片尺寸**: 400x600像素
### 7. 屏幕录制截图 (screen_image)
**数据源**: 前端界面截图base64编码数据
**存储路径**: `data/patients/{患者ID}/{sessionID}/{采集时间}/screen_image.jpg`
**文件格式**: JPG图片
**数据传输**: 通过参数 `screen_image_base64` 传入
## 目录结构规则
### 数据存储目录结构
```
data/
└── patients/
└── {患者ID}/
└── {sessionID}/
└── {采集时间}/
├── body_image.jpg # 身体视频截图
├── foot_image.jpg # 足部监测视频截图
├── foot_data_image.jpg # 足底压力数据图
└── screen_image.jpg # 屏幕录制截图
```
### 采集时间格式
- 格式: `YYYYMMDD_HHMMSS_mmm`
- 说明: 年月日_时分秒_毫秒
- 示例: `20240115_103045_123`
## 数据采集流程
1. **初始化**: 接收 `session_id`、`patient_id` 和可选的 `screen_image_base64` 参数
2. **生成时间戳**: 创建精确到毫秒的时间戳作为采集标识
3. **创建目录**: 根据患者ID、会话ID和时间戳创建存储目录
4. **设备状态检查**: 检查各设备连接状态
5. **数据采集**: 按顺序采集各设备数据
- IMU传感器 → 头部姿态数据
- FemtoBolt深度相机 → 身体姿态数据和身体截图
- 压力传感器 → 足部压力数据和压力分布图
- 摄像头 → 足部监测截图
- 前端界面 → 屏幕截图
6. **数据存储**: 将采集的数据保存到数据库和文件系统
7. **日志记录**: 记录采集过程和结果
## 错误处理
- 设备连接失败时,对应数据字段设为 `None`
- 文件保存失败时,记录错误日志并继续其他数据采集
- 采集过程中的异常不会中断整个流程
- 所有错误信息都会记录到系统日志中
## 数据质量保证
- 所有时间戳使用统一格式
- 图片文件使用相对路径存储
- JSON数据格式验证
- 文件完整性检查
- 设备状态实时监控
## 性能优化
- 并发数据采集(在设备支持的情况下)
- 图片压缩优化
- 内存使用监控
- 磁盘空间管理
- 数据采集超时控制
## 注意事项
1. 确保所有设备在采集前已正确初始化
2. 定期检查存储空间,避免磁盘满载
3. 采集频率应根据实际需求调整
4. 敏感数据需要加密存储
5. 定期备份重要数据
6. 遵循数据隐私保护规定
---
*本文档版本: 1.0*
*最后更新: 2025年8月*
*维护人员: 系统开发团队*

View File

@ -0,0 +1,297 @@
# 视频推流和录制规则说明
## 概述
本文档详细说明了身体平衡评估系统的视频推流和录制功能规则,包括足部监视视频推流、深度相机视频推流、多路视频同步录制以及数据库存储规范。
## 功能架构
### 1. 视频推流功能
#### 1.1 推流类型
- **足部监视视频推流**: 来自普通摄像头的足部监测画面
- **深度相机视频推流**: 来自FemtoBolt深度相机的身体姿态画面
#### 1.2 推流技术规范
- **传输协议**: WebSocket
- **推流格式**: Base64编码的JPEG图像帧
- **推流频率**: 30 FPS
- **线程模式**: 独立线程处理,避免阻塞主程序
- **目标端**: 前端Web页面实时显示
#### 1.3 推流实现方式
```python
# 足部监视视频推流线程
def _camera_streaming_thread(self):
# 从摄像头获取帧 → 编码 → WebSocket推送
# 深度相机视频推流线程
def _femtobolt_streaming_thread(self):
# 从FemtoBolt获取帧 → 编码 → WebSocket推送
```
### 2. 视频录制功能
#### 2.1 录制类型
根据`detection_sessions`表结构,系统需要同步录制三种视频:
1. **足部检测视频** (`normal_video_path`)
- 数据源: 普通摄像头
- 文件名: `feet.mp4`
- 内容: 足部监测画面
2. **深度相机视频** (`femtobolt_video_path`)
- 数据源: FemtoBolt深度相机
- 文件名: `body.mp4`
- 内容: 身体姿态深度画面
3. **屏幕录制视频** (`screen_video_path`)
- 数据源: 前端界面录制
- 文件名: `screen.mp4`
- 内容: 完整的检测界面录制
#### 2.2 存储路径规范
```
基础路径: data/patients/{患者ID}/{sessionID}/
├── feet.mp4 # 足部检测视频
├── body.mp4 # 深度相机视频
└── screen.mp4 # 屏幕录制视频
```
#### 2.3 录制参数规范
- **视频编码**: H.264 (mp4v)
- **分辨率**:
- 足部视频: 1280x720
- 深度相机视频: 1920x1080 (根据FemtoBolt配置)
- 屏幕录制: 根据前端界面尺寸
- **帧率**: 30 FPS
- **音频**: 不录制音频
## 核心方法设计
### 3.1 录制控制方法
#### 3.1.1 start_recording方法
```python
def start_recording(self, session_id: str, patient_id: str) -> Dict[str, bool]:
"""
同步启动三个视频录制功能
Args:
session_id: 检测会话ID
patient_id: 患者ID
Returns:
Dict: 各录制任务的启动状态
{
'feet_recording': bool,
'body_recording': bool,
'screen_recording': bool,
'session_updated': bool
}
"""
```
**功能流程**:
1. 创建录制目录: `data/patients/{patient_id}/{session_id}/`
2. 初始化三个VideoWriter对象
3. 启动三个录制线程
4. 更新`detection_sessions`表的视频路径字段
5. 返回启动状态
#### 3.1.2 stop_recording方法
```python
def stop_recording(self, session_id: str) -> Dict[str, Any]:
"""
同步停止所有视频录制
Args:
session_id: 检测会话ID
Returns:
Dict: 录制结果信息
{
'status': 'success'|'failed',
'video_files': {
'feet_video': str,
'body_video': str,
'screen_video': str
},
'file_sizes': Dict[str, int],
'duration': int
}
"""
```
**功能流程**:
1. 停止所有录制线程
2. 释放VideoWriter资源
3. 检查录制文件完整性
4. 更新`detection_sessions`表的结束时间和持续时间
5. 返回录制结果
### 3.2 推流控制方法
#### 3.2.1 start_streaming方法
```python
def start_streaming(self) -> Dict[str, bool]:
"""
启动视频推流
Returns:
Dict: 推流启动状态
{
'camera_streaming': bool,
'femtobolt_streaming': bool
}
"""
```
#### 3.2.2 stop_streaming方法
```python
def stop_streaming(self) -> bool:
"""
停止所有视频推流
Returns:
bool: 停止操作是否成功
"""
```
## 数据库集成规范
### 4.1 detection_sessions表字段映射
| 字段名 | 数据类型 | 说明 | 录制对应 |
|--------|----------|------|----------|
| `normal_video_path` | TEXT | 足部检测视频路径 | `feet.mp4` |
| `femtobolt_video_path` | TEXT | 深度相机视频路径 | `body.mp4` |
| `screen_video_path` | TEXT | 屏幕录制视频路径 | `screen.mp4` |
| `start_time` | TIMESTAMP | 检测开始时间 | 录制开始时间 |
| `end_time` | TIMESTAMP | 检测结束时间 | 录制结束时间 |
| `duration` | INTEGER | 检测持续时间(秒) | 录制持续时间 |
| `status` | TEXT | 会话状态 | recording/completed |
### 4.2 数据库操作时机
#### 4.2.1 录制开始时
```sql
UPDATE detection_sessions SET
normal_video_path = 'data/patients/{patient_id}/{session_id}/feet.mp4',
femtobolt_video_path = 'data/patients/{patient_id}/{session_id}/body.mp4',
screen_video_path = 'data/patients/{patient_id}/{session_id}/screen.mp4',
start_time = CURRENT_TIMESTAMP,
status = 'recording'
WHERE id = '{session_id}'
```
#### 4.2.2 录制结束时
```sql
UPDATE detection_sessions SET
end_time = CURRENT_TIMESTAMP,
duration = CAST((julianday(CURRENT_TIMESTAMP) - julianday(start_time)) * 86400 AS INTEGER),
status = 'completed'
WHERE id = '{session_id}'
```
## 线程管理规范
### 5.1 推流线程
- **线程名称**: `camera_streaming_thread`, `femtobolt_streaming_thread`
- **线程类型**: daemon线程
- **生命周期**: 与推流状态同步
- **异常处理**: 线程内部捕获异常,记录日志,不影响主程序
### 5.2 录制线程
- **线程名称**: `feet_recording_thread`, `body_recording_thread`, `screen_recording_thread`
- **线程类型**: 非daemon线程确保录制完整性
- **同步机制**: 使用threading.Event控制启停
- **资源管理**: 确保VideoWriter正确释放
## 错误处理和异常情况
### 6.1 设备异常处理
- 摄像头断开: 停止对应推流/录制,记录错误日志
- FemtoBolt设备异常: 降级到普通摄像头模式
- 存储空间不足: 停止录制,保存已录制内容
### 6.2 网络异常处理
- WebSocket连接断开: 自动重连机制
- 推流延迟过高: 降低推流质量或帧率
### 6.3 文件系统异常
- 目录创建失败: 使用备用路径
- 文件写入失败: 记录错误,尝试恢复
- 磁盘空间监控: 预警机制
## 性能优化策略
### 7.1 内存管理
- 帧缓冲区大小限制
- 及时释放图像数据
- 内存使用监控
### 7.2 CPU优化
- 多线程并行处理
- 图像编码优化
- 帧率自适应调整
### 7.3 存储优化
- 视频压缩参数调优
- 分段录制避免大文件
- 异步写入机制
## 配置参数
### 8.1 推流配置
```python
STREAMING_CONFIG = {
'fps': 30,
'quality': 80, # JPEG质量
'max_frame_buffer': 10,
'reconnect_interval': 5 # 秒
}
```
### 8.2 录制配置
```python
RECORDING_CONFIG = {
'fps': 30,
'codec': 'mp4v',
'max_file_size': 1024 * 1024 * 1024, # 1GB
'segment_duration': 600 # 10分钟分段
}
```
## 测试验证
### 9.1 功能测试
- [ ] 单独推流测试
- [ ] 同步录制测试
- [ ] 数据库更新测试
- [ ] 异常恢复测试
### 9.2 性能测试
- [ ] 长时间录制稳定性
- [ ] 多路并发性能
- [ ] 内存泄漏检测
- [ ] 存储空间使用
### 9.3 兼容性测试
- [ ] 不同设备组合
- [ ] 不同分辨率支持
- [ ] 浏览器兼容性
## 部署注意事项
1. **依赖库**: 确保OpenCV、WebSocket库版本兼容
2. **权限设置**: 摄像头访问权限、文件写入权限
3. **防火墙**: WebSocket端口开放
4. **存储规划**: 预估存储空间需求
5. **备份策略**: 重要录制文件的备份机制
---
*本文档版本: 1.0*
*最后更新: 2025年08月03日*
*维护人员: 系统开发团队*

View File

@ -42,15 +42,26 @@
停止检测 停止检测
</el-button> </el-button>
<el-button <el-button
v-if="isStart == true" @click="handleStartStopRecording"
@click="handleScreenshot" :disabled="!isConnected"
:loading="screenshotLoading" type="warning"
type="primary" class="start-btn"
style="width: 80px;background-image: linear-gradient(to right, rgb(250, 167, 6), rgb(160, 5, 216)); style="background-image: linear-gradient(to right, rgb(255, 193, 7), rgb(255, 87, 34));
--el-button-border-color: #409EFF; --el-button-border-color: #409EFF;
--el-button-border-color: transparent " --el-button-border-color: transparent; margin-left: 10px;"
> >
截图 {{ isRecording ? '停止录制' : '开始录制' }}
</el-button>
<el-button
v-if="isStart == true"
@click="handleDataCollection"
:loading="dataCollectionLoading"
type="success"
style="width: 120px;background-image: linear-gradient(to right, rgb(40, 167, 69), rgb(25, 135, 84));
--el-button-border-color: #409EFF;
--el-button-border-color: transparent; margin-left: 10px;"
>
检测数据采集
</el-button> </el-button>
</div> </div>
@ -75,12 +86,12 @@
</div> </div>
</div> </div>
<div style="display: flex;justify-content: center;height: 100%;padding-top: 10px;"> <div style="display: flex;justify-content: center;height: 100%;padding-top: 10px;">
<!-- 使用深度相机视频流替换静态图片 -->
<img <img
src="@/assets/posture.png" :src="depthCameraImgSrc || '@/assets/posture.png'"
alt="身体姿态热力图" alt="深度相机视频流"
class="posture-heatmap" style="width: 100%;height: calc(100% - 10px);object-fit:contain;background:#000;"
> >
<!-- <video src="@/assets/video.mp4"></video> -->
</div> </div>
</div> </div>
<div class="body-posture" style="width: 44%; display: flex; <div class="body-posture" style="width: 44%; display: flex;
@ -335,7 +346,9 @@ const route = useRoute()
const isStart = ref(false) const isStart = ref(false)
const isConnected = ref(false) const isConnected = ref(false)
const rtspImgSrc = ref('') const rtspImgSrc = ref('')
const depthCameraImgSrc = ref('') //
const screenshotLoading = ref(false) const screenshotLoading = ref(false)
const dataCollectionLoading = ref(false)
const isRecording = ref(false) const isRecording = ref(false)
// //
@ -436,10 +449,14 @@ function connectWebSocket() {
// //
socket.on('video_frame', (data) => { socket.on('video_frame', (data) => {
frameCount++ frameCount++
console.log(`📺 收到视频帧 #${frameCount}, 数据大小: ${data.image ? data.image.length : 0} 字符`)
displayFrame(data.image) displayFrame(data.image)
}) })
//
socket.on('depth_camera_frame', (data) => {
displayDepthCameraFrame(data.image)
})
// //
socket.on('error', (error) => { socket.on('error', (error) => {
console.error('❌ Socket错误:', error) console.error('❌ Socket错误:', error)
@ -514,19 +531,128 @@ function stopRtsp() {
function displayFrame(base64Image) { function displayFrame(base64Image) {
if (base64Image && base64Image.length > 0) { if (base64Image && base64Image.length > 0) {
rtspImgSrc.value = 'data:image/jpeg;base64,' + base64Image rtspImgSrc.value = 'data:image/jpeg;base64,' + base64Image
console.log(`🖼️ 视频帧已设置到img元素base64长度: ${base64Image.length}`)
} else { } else {
console.warn('⚠️ 收到空的视频帧数据') console.warn('⚠️ 收到空的视频帧数据')
} }
} }
//
function displayDepthCameraFrame(base64Image) {
if (base64Image && base64Image.length > 0) {
depthCameraImgSrc.value = 'data:image/jpeg;base64,' + base64Image
} else {
console.warn('⚠️ 收到空的深度相机帧数据')
}
}
// //
function hideVideo() { function hideVideo() {
rtspImgSrc.value = '' rtspImgSrc.value = ''
depthCameraImgSrc.value = ''
} }
// // /
function handleStartStopRecording() {
if (!isConnected.value) {
ElMessage.warning('WebSocket未连接无法操作')
return
}
if (isRecording.value) {
stopRecording()
} else {
startRecording()
}
}
//
async function handleDataCollection() {
if (dataCollectionLoading.value) return
try {
dataCollectionLoading.value = true
//
ElMessage.info('正在采集检测数据...')
// ID
if (!patientInfo.value.sessionId) {
throw new Error('请先开始检测再进行数据采集')
}
// API
const response = await fetch(`${BACKEND_URL}/api/detection/${patientInfo.value.sessionId}/collect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
patient_id: patientInfo.value.id,
timestamp: Date.now()
})
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
if (result.success) {
//
ElMessage.success({
message: `检测数据采集成功数据ID: ${result.dataId}`,
duration: 5000
})
console.log('✅ 检测数据采集成功:', result)
//
if (result.data) {
historyData.value.unshift({
id: result.dataId,
rotLeft: result.data.rotLeft || '-',
rotRight: result.data.rotRight || '-',
tiltLeft: result.data.tiltLeft || '-',
tiltRight: result.data.tiltRight || '-',
pitchDown: result.data.pitchDown || '-',
pitchUp: result.data.pitchUp || '-'
})
// 10
if (historyData.value.length > 10) {
historyData.value = historyData.value.slice(0, 10)
}
}
} else {
throw new Error(result.message || '数据采集失败')
}
} catch (error) {
console.error('❌ 检测数据采集失败:', error)
//
let errorMessage = '检测数据采集失败'
if (error.message.includes('网络连接失败')) {
errorMessage = '网络连接失败,请检查后端服务是否正常运行'
} else if (error.message.includes('服务器错误')) {
errorMessage = error.message
} else {
errorMessage = `检测数据采集失败: ${error.message}`
}
ElMessage.error({
message: errorMessage,
duration: 5000
})
} finally {
dataCollectionLoading.value = false
}
}
//
async function handleScreenshot() { async function handleScreenshot() {
if (screenshotLoading.value) return if (screenshotLoading.value) return
@ -987,8 +1113,6 @@ async function startDetection() {
patientInfo.value.detectionStartTime = Date.now() patientInfo.value.detectionStartTime = Date.now()
console.log('✅ 检测会话创建成功会话ID:', patientInfo.value.sessionId) console.log('✅ 检测会话创建成功会话ID:', patientInfo.value.sessionId)
//
startRecording()
isStart.value = true isStart.value = true
ElMessage.success('检测已开始') ElMessage.success('检测已开始')
@ -1014,22 +1138,24 @@ async function stopDetection() {
duration = Math.floor((Date.now() - patientInfo.value.detectionStartTime) / 1000) duration = Math.floor((Date.now() - patientInfo.value.detectionStartTime) / 1000)
} }
// //
stopRecording() if (isRecording.value) {
stopRecording()
}
isStart.value = false isStart.value = false
// API // API
if (patientInfo.value.sessionId) { if (patientInfo.value.sessionId) {
const response = await fetch(`${BACKEND_URL}/api/detection/stop`, { const response = await fetch(`${BACKEND_URL}/api/detection/${patientInfo.value.sessionId}/stop`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionId: patientInfo.value.sessionId, duration: duration
duration: duration })
}) })
})
if (response.ok) { if (response.ok) {
const result = await response.json() const result = await response.json()
@ -1042,8 +1168,9 @@ async function stopDetection() {
} }
} }
// // ID
patientInfo.value.detectionStartTime = null patientInfo.value.detectionStartTime = null
patientInfo.value.sessionId = null
ElMessage.success('检测已停止,视频继续播放') ElMessage.success('检测已停止,视频继续播放')
@ -1095,6 +1222,10 @@ const handleBeforeUnload = () => {
if (isRecording.value) { if (isRecording.value) {
stopRecording() stopRecording()
} }
//
if (isStart.value) {
stopDetection()
}
// //
stopRtsp() stopRtsp()
// WebSocket // WebSocket
@ -1107,7 +1238,7 @@ onMounted(() => {
// //
loadPatientInfo() loadPatientInfo()
// WebSocket // WebSocket
connectWebSocket() connectWebSocket()
// //
@ -1120,6 +1251,11 @@ onUnmounted(() => {
stopRecording() stopRecording()
} }
//
if (isStart.value) {
stopDetection()
}
// //
stopRtsp() stopRtsp()

606
package-lock.json generated
View File

@ -12,7 +12,10 @@
"dependencies": { "dependencies": {
"axios": "^1.5.0", "axios": "^1.5.0",
"electron-log": "^4.4.8", "electron-log": "^4.4.8",
"socket.io-client": "^4.8.1" "element-plus": "^2.10.4",
"html2canvas": "^1.4.1",
"socket.io-client": "^4.8.1",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
@ -26,6 +29,42 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
} }
}, },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.2", "version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
@ -35,6 +74,29 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@develar/schema-utils": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@ -562,6 +624,40 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@element-plus/icons-vue": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
"integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@ -685,6 +781,13 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"license": "MIT",
"peer": true
},
"node_modules/@malept/cross-spawn-promise": { "node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@ -807,6 +910,17 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@sindresorhus/is": { "node_modules/@sindresorhus/is": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -890,6 +1004,21 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -932,6 +1061,12 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
"license": "MIT"
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -942,6 +1077,209 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@vue/compiler-core": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.18.tgz",
"integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/shared": "3.5.18",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz",
"integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz",
"integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@vue/compiler-core": "3.5.18",
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz",
"integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.18.tgz",
"integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.18.tgz",
"integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/shared": "3.5.18"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz",
"integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.18",
"@vue/runtime-core": "3.5.18",
"@vue/shared": "3.5.18",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.18.tgz",
"integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"vue": "3.5.18"
}
},
"node_modules/@vue/shared": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.18.tgz",
"integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==",
"license": "MIT",
"peer": true
},
"node_modules/@vueuse/core": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz",
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.13.0",
"@vueuse/shared": "9.13.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.10", "version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@ -1188,6 +1526,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -1227,6 +1571,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -1913,6 +2266,22 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.30.0", "version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@ -1929,6 +2298,12 @@
"url": "https://opencollective.com/date-fns" "url": "https://opencollective.com/date-fns"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -2586,6 +2961,32 @@
"node": ">=6 <7 || >=8" "node": ">=6 <7 || >=8"
} }
}, },
"node_modules/element-plus": {
"version": "2.10.5",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.10.5.tgz",
"integrity": "sha512-O9wTDu3Tm51ACVByWrThtBhH4Ygefg1HGY5pyAaxnoIrj8uMN0GtZ4IREwR3Yw/6sM2HyxjrsGI/D46iUVP97A==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.1",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.13",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -2647,6 +3048,19 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -2728,6 +3142,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -2741,6 +3161,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
@ -3347,6 +3774,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@ -3776,8 +4216,24 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"dev": true },
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
}, },
"node_modules/lodash.get": { "node_modules/lodash.get": {
"version": "4.4.2", "version": "4.4.2",
@ -3823,6 +4279,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-fetch-happen": { "node_modules/make-fetch-happen": {
"version": "10.2.1", "version": "10.2.1",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
@ -3919,6 +4385,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/mime": { "node_modules/mime": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@ -4099,6 +4571,25 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
@ -4214,6 +4705,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/object-keys": { "node_modules/object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@ -4469,6 +4966,13 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true "dev": true
}, },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC",
"peer": true
},
"node_modules/pify": { "node_modules/pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
@ -4492,6 +4996,35 @@
"node": ">=10.4.0" "node": ">=10.4.0"
} }
}, },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postject": { "node_modules/postject": {
"version": "1.0.0-alpha.6", "version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
@ -5055,6 +5588,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": { "node_modules/source-map-support": {
"version": "0.5.21", "version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
@ -5374,6 +5917,15 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@ -5472,7 +6024,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5541,6 +6093,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/validate-npm-package-license": { "node_modules/validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -5566,6 +6127,43 @@
"node": ">=0.6.0" "node": ">=0.6.0"
} }
}, },
"node_modules/vue": {
"version": "3.5.18",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
"@vue/runtime-dom": "3.5.18",
"@vue/server-renderer": "3.5.18",
"@vue/shared": "3.5.18"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/wcwidth": { "node_modules/wcwidth": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",

View File

@ -25,7 +25,7 @@ try:
event_name, data = event event_name, data = event
print(f'收到事件: {event_name}, 数据类型: {type(data)}') print(f'收到事件: {event_name}, 数据类型: {type(data)}')
if event_name == 'video_frame' and isinstance(data, dict) and 'image' in data: if event_name == 'video_frame' and isinstance(data, dict) and 'image' in data:
print(f'收到图像数据,长度: {len(data["image"])} 字符') pass # 图像数据已接收
elif event_name == 'video_status': elif event_name == 'video_status':
print(f'视频状态: {data}') print(f'视频状态: {data}')
except socketio.exceptions.TimeoutError: except socketio.exceptions.TimeoutError: