提交深度相机配置及其他采集功能
This commit is contained in:
parent
a10c695ced
commit
480750cfcc
76351
Log/OrbbecSDK.log.txt
Normal file
76351
Log/OrbbecSDK.log.txt
Normal file
File diff suppressed because it is too large
Load Diff
42
README.md
42
README.md
@ -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
144151
backend/Log/OrbbecSDK.log.txt
Normal file
File diff suppressed because it is too large
Load Diff
735
backend/app.py
735
backend/app.py
@ -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
|
||||||
|
|
||||||
|
result = device_manager.stop_streaming()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info('视频推流已停止')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {'patient_id': patient_id},
|
'message': '视频推流已停止'
|
||||||
'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 = 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
|
|
||||||
|
|
||||||
# 解析settings参数
|
|
||||||
try:
|
try:
|
||||||
if isinstance(settings_str, str):
|
settings = json.loads(settings)
|
||||||
settings = json.loads(settings_str)
|
|
||||||
else:
|
|
||||||
settings = settings_str or {}
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
settings = {}
|
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}')
|
||||||
# 初始化检测状态
|
|
||||||
current_detection = {
|
|
||||||
'session_id': session_id,
|
|
||||||
'patient_id': patient_id,
|
|
||||||
'status': 'running',
|
|
||||||
'start_time': datetime.now(),
|
|
||||||
'settings': settings,
|
|
||||||
'data_points': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 启动检测线程
|
|
||||||
detection_thread = threading.Thread(
|
|
||||||
target=run_detection,
|
|
||||||
args=(session_id, settings)
|
|
||||||
)
|
|
||||||
detection_thread.start()
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {'session_id': session_id},
|
'session_id': session_id,
|
||||||
'message': '检测已开始'
|
'message': '检测会话创建成功'
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
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/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':
|
|
||||||
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
|
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': False,
|
||||||
'message': '检测已停止',
|
'error': '缺少会话ID'
|
||||||
'sessionId': session_id,
|
}), 400
|
||||||
'duration': duration
|
|
||||||
})
|
|
||||||
|
|
||||||
current_detection = None
|
# 更新会话状态为已完成
|
||||||
|
success = db_manager.update_session_status(session_id, 'completed')
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f'检测会话已停止 - 会话ID: {session_id}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '检测已停止'
|
'message': '检测已停止'
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '停止检测失败'
|
||||||
|
}), 500
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'停止检测失败: {e}')
|
logger.error(f'停止检测失败: {e}')
|
||||||
return jsonify({
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
'success': False,
|
|
||||||
'message': f'停止检测失败: {str(e)}'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
@app.route('/api/sessions/<session_id>/video-path', methods=['PUT'])
|
@app.route('/api/detection/<session_id>/status', methods=['GET'])
|
||||||
def update_session_video_path(session_id):
|
def get_detection_status(session_id):
|
||||||
"""更新会话视频路径"""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
if not data or 'videoPath' not in data:
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': '缺少视频路径参数'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
video_path = data['videoPath']
|
|
||||||
|
|
||||||
# 更新数据库中的视频路径
|
|
||||||
db_manager.update_session_video_path(session_id, video_path)
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
if not current_detection:
|
if not db_manager:
|
||||||
|
return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '缺少会话ID'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 获取会话数据
|
||||||
|
session_data = db_manager.get_session_data(session_id)
|
||||||
|
|
||||||
|
if session_data:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {'status': 'idle'}
|
'data': session_data
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
# 计算运行时间
|
|
||||||
if current_detection.get('status') == 'running':
|
|
||||||
elapsed = (datetime.now() - current_detection['start_time']).total_seconds()
|
|
||||||
current_detection['elapsed_time'] = int(elapsed)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': False,
|
||||||
'data': current_detection
|
'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
|
|
||||||
|
|
||||||
# ==================== 数据分析API ====================
|
|
||||||
|
|
||||||
@app.route('/api/analysis/session/<session_id>', methods=['GET'])
|
|
||||||
def analyze_session(session_id):
|
|
||||||
"""分析检测会话数据"""
|
|
||||||
try:
|
|
||||||
# 获取会话数据
|
|
||||||
session_data = db_manager.get_session_data(session_id)
|
session_data = db_manager.get_session_data(session_id)
|
||||||
if not session_data:
|
if not session_data:
|
||||||
return jsonify({'success': False, 'error': '会话不存在'}), 404
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '检测会话不存在'
|
||||||
|
}), 404
|
||||||
|
patient_id = session_data.get('patient_id')
|
||||||
|
|
||||||
# 进行数据分析
|
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,
|
||||||
|
patient_id=patient_id,
|
||||||
|
screen_image_base64=screen_image_base64
|
||||||
|
)
|
||||||
|
|
||||||
|
# 将采集的数据保存到数据库
|
||||||
|
if collected_data:
|
||||||
|
db_manager.save_detection_data(session_id, collected_data)
|
||||||
|
logger.info(f'检测数据采集并保存成功: {session_id}')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': analysis_result
|
'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
|
||||||
|
|
||||||
@app.route('/api/export/report/<session_id>', methods=['GET'])
|
# ==================== 同步录制API ====================
|
||||||
def export_report(session_id):
|
|
||||||
"""导出检测报告"""
|
@app.route('/api/recording/sync/start', methods=['POST'])
|
||||||
|
def start_sync_recording():
|
||||||
|
"""启动同步录制"""
|
||||||
try:
|
try:
|
||||||
# 生成报告
|
if not device_manager:
|
||||||
report_path = data_processor.generate_report(session_id)
|
return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
|
||||||
|
|
||||||
if not os.path.exists(report_path):
|
data = flask_request.get_json()
|
||||||
return jsonify({'success': False, 'error': '报告生成失败'}), 500
|
session_id = data.get('session_id')
|
||||||
|
patient_id = data.get('patient_id')
|
||||||
|
|
||||||
return send_file(
|
if not session_id or not patient_id:
|
||||||
report_path,
|
return jsonify({
|
||||||
as_attachment=True,
|
'success': False,
|
||||||
download_name=f'detection_report_{session_id}.pdf'
|
'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:
|
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 ====================
|
@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.
@ -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
|
|
@ -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_sessions_time ON detection_sessions (start_time)')
|
# 患者表索引
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_session ON detection_data (session_id)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_name ON patients (name)') # 患者姓名索引
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_phone ON patients (phone)') # 患者电话索引
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_email ON patients (email)') # 患者邮箱索引
|
||||||
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_sessions_patient ON detection_sessions (patient_id)') # 患者ID索引
|
||||||
|
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('''
|
cursor.execute('''
|
||||||
INSERT INTO detection_data (session_id, data_type, data_value, timestamp)
|
INSERT INTO detection_data (
|
||||||
VALUES (?, ?, ?, ?)
|
id, session_id, head_pose, body_pose, body_image,
|
||||||
''', (session_id, data_type, json.dumps(data_value), china_time))
|
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:
|
||||||
|
@ -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
BIN
backend/dll/k4a.dll
Normal file
Binary file not shown.
58
backend/test_socketio.html
Normal file
58
backend/test_socketio.html
Normal 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
44
backend/test_socketio.py
Normal 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)
|
2546
backend/tests/Log/OrbbecSDK.log.txt
Normal file
2546
backend/tests/Log/OrbbecSDK.log.txt
Normal file
File diff suppressed because it is too large
Load Diff
273
backend/tests/azure_kinect_image_example.py
Normal file
273
backend/tests/azure_kinect_image_example.py
Normal 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()
|
@ -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
|
||||||
)
|
)
|
||||||
|
241
document/检测数据和图像采集规则说明.md
Normal file
241
document/检测数据和图像采集规则说明.md
Normal 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月*
|
||||||
|
*维护人员: 系统开发团队*
|
297
document/视频推流和录制规则说明.md
Normal file
297
document/视频推流和录制规则说明.md
Normal 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日*
|
||||||
|
*维护人员: 系统开发团队*
|
@ -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,19 +1138,21 @@ async function stopDetection() {
|
|||||||
duration = Math.floor((Date.now() - patientInfo.value.detectionStartTime) / 1000)
|
duration = Math.floor((Date.now() - patientInfo.value.detectionStartTime) / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止录像
|
// 如果正在录制,停止录制
|
||||||
|
if (isRecording.value) {
|
||||||
stopRecording()
|
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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -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
606
package-lock.json
generated
@ -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",
|
||||||
|
@ -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:
|
||||||
|
Loading…
Reference in New Issue
Block a user