视频播放、截图、录像功能提交
This commit is contained in:
parent
2da25a0437
commit
061f4b3f71
226
MEMORY_OPTIMIZATION_GUIDE.md
Normal file
226
MEMORY_OPTIMIZATION_GUIDE.md
Normal file
@ -0,0 +1,226 @@
|
||||
# 内存优化指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南介绍了身体平衡评估系统中WebSocket视频流的内存优化功能,帮助您解决浏览器内存占用缓慢增长的问题。
|
||||
|
||||
## 问题描述
|
||||
|
||||
在长时间运行WebSocket视频流时,可能会遇到以下问题:
|
||||
- 浏览器内存使用量持续增长
|
||||
- 页面响应速度逐渐变慢
|
||||
- 系统资源占用过高
|
||||
- 可能导致浏览器崩溃
|
||||
|
||||
## 优化方案
|
||||
|
||||
### 1. 前端优化
|
||||
|
||||
#### 1.1 帧率控制
|
||||
- **最大FPS限制**: 默认15fps,可根据需要调整
|
||||
- **跳帧机制**: 每3帧显示1帧,减少处理负担
|
||||
- **自适应帧率**: 根据内存使用情况动态调整
|
||||
|
||||
#### 1.2 内存监控
|
||||
- **实时监控**: 每10秒检查一次内存使用情况
|
||||
- **自动清理**: 内存使用率超过80%时自动垃圾回收
|
||||
- **手动控制**: 提供手动垃圾回收按钮
|
||||
|
||||
#### 1.3 图像缓存优化
|
||||
- **缓存复用**: 重用Image对象,避免频繁创建
|
||||
- **及时清理**: 及时释放不再使用的图像资源
|
||||
- **强制重绘**: 使用requestAnimationFrame优化渲染
|
||||
|
||||
### 2. 后端优化
|
||||
|
||||
#### 2.1 图像压缩
|
||||
- **尺寸压缩**: 最大分辨率320x240
|
||||
- **质量压缩**: JPEG质量设为50%
|
||||
- **编码优化**: 禁用渐进式JPEG,启用优化
|
||||
|
||||
#### 2.2 内存管理
|
||||
- **内存限制**: 后端最大内存使用100MB
|
||||
- **定期检查**: 每50帧检查一次内存使用
|
||||
- **强制清理**: 内存超限时强制垃圾回收
|
||||
|
||||
#### 2.3 帧处理优化
|
||||
- **跳帧处理**: 每3帧处理1帧
|
||||
- **队列管理**: 队列大小限制为1,确保实时性
|
||||
- **及时释放**: 处理完成后立即释放帧内存
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 启动优化版本
|
||||
|
||||
确保使用最新的优化版本:
|
||||
```bash
|
||||
# 启动后端(已包含内存优化)
|
||||
python backend/app.py
|
||||
|
||||
# 打开前端页面
|
||||
# 访问 frontend_websocket_example.html
|
||||
```
|
||||
|
||||
### 2. 监控内存使用
|
||||
|
||||
在前端页面中:
|
||||
1. 点击 **"内存使用情况"** 按钮查看当前内存状态
|
||||
2. 观察以下指标:
|
||||
- 已使用内存
|
||||
- 总分配内存
|
||||
- 内存使用率
|
||||
- 接收帧数和跳帧率
|
||||
|
||||
### 3. 手动优化
|
||||
|
||||
当发现内存使用过高时:
|
||||
1. 点击 **"强制垃圾回收"** 按钮
|
||||
2. 观察内存使用情况的变化
|
||||
3. 如果问题持续,考虑刷新页面
|
||||
|
||||
### 4. 配置调整
|
||||
|
||||
编辑 `memory_config.py` 文件来调整优化参数:
|
||||
|
||||
```python
|
||||
# 前端配置
|
||||
MAX_FPS = 15 # 降低可减少内存使用
|
||||
FRAME_SKIP_THRESHOLD = 2 # 增加可减少处理负担
|
||||
MAX_MEMORY_USAGE_PERCENT = 80 # 调整自动清理阈值
|
||||
|
||||
# 后端配置
|
||||
JPEG_QUALITY = 50 # 降低可减少内存使用
|
||||
MAX_FRAME_WIDTH = 320 # 降低可减少内存使用
|
||||
MAX_BACKEND_MEMORY_MB = 100 # 调整内存限制
|
||||
```
|
||||
|
||||
## 性能调优建议
|
||||
|
||||
### 1. 根据硬件调整
|
||||
|
||||
**低配置设备**:
|
||||
```python
|
||||
MAX_FPS = 10
|
||||
FRAME_SKIP_THRESHOLD = 4
|
||||
JPEG_QUALITY = 40
|
||||
MAX_FRAME_WIDTH = 240
|
||||
```
|
||||
|
||||
**高配置设备**:
|
||||
```python
|
||||
MAX_FPS = 20
|
||||
FRAME_SKIP_THRESHOLD = 1
|
||||
JPEG_QUALITY = 60
|
||||
MAX_FRAME_WIDTH = 480
|
||||
```
|
||||
|
||||
### 2. 根据网络调整
|
||||
|
||||
**慢速网络**:
|
||||
- 降低帧率和质量
|
||||
- 增加跳帧比例
|
||||
- 减小图像尺寸
|
||||
|
||||
**快速网络**:
|
||||
- 可适当提高帧率和质量
|
||||
- 减少跳帧比例
|
||||
|
||||
### 3. 长时间运行优化
|
||||
|
||||
对于需要长时间运行的场景:
|
||||
1. 设置更严格的内存限制
|
||||
2. 增加自动清理频率
|
||||
3. 定期刷新页面(建议每2-4小时)
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 内存仍然增长
|
||||
|
||||
**可能原因**:
|
||||
- 浏览器不支持强制垃圾回收
|
||||
- 其他页面或扩展占用内存
|
||||
- 系统内存不足
|
||||
|
||||
**解决方案**:
|
||||
1. 使用Chrome浏览器并启用 `--enable-precise-memory-info` 标志
|
||||
2. 关闭其他不必要的标签页
|
||||
3. 降低优化参数设置
|
||||
4. 定期刷新页面
|
||||
|
||||
### 2. 视频卡顿
|
||||
|
||||
**可能原因**:
|
||||
- 帧率设置过低
|
||||
- 跳帧比例过高
|
||||
- 网络延迟
|
||||
|
||||
**解决方案**:
|
||||
1. 适当提高MAX_FPS
|
||||
2. 减少FRAME_SKIP_THRESHOLD
|
||||
3. 检查网络连接
|
||||
4. 优化后端处理性能
|
||||
|
||||
### 3. 图像质量差
|
||||
|
||||
**可能原因**:
|
||||
- JPEG质量设置过低
|
||||
- 图像尺寸压缩过度
|
||||
|
||||
**解决方案**:
|
||||
1. 提高JPEG_QUALITY(建议不超过70)
|
||||
2. 增加MAX_FRAME_WIDTH和MAX_FRAME_HEIGHT
|
||||
3. 平衡质量和内存使用
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 1. 前端监控
|
||||
|
||||
在浏览器控制台中查看:
|
||||
```javascript
|
||||
// 查看内存使用
|
||||
console.log(performance.memory);
|
||||
|
||||
// 查看优化统计
|
||||
console.log('帧数:', frameCount);
|
||||
console.log('跳帧数:', frameSkipCount);
|
||||
```
|
||||
|
||||
### 2. 后端日志
|
||||
|
||||
查看后端日志中的内存相关信息:
|
||||
```
|
||||
[INFO] 内存使用: 45.2MB
|
||||
[WARNING] 内存使用过高: 105.3MB,强制清理
|
||||
[INFO] 垃圾回收完成,内存降至: 38.7MB
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **定期监控**: 每隔一段时间检查内存使用情况
|
||||
2. **合理配置**: 根据实际需求调整优化参数
|
||||
3. **及时清理**: 发现内存异常时及时进行清理
|
||||
4. **版本更新**: 保持使用最新的优化版本
|
||||
5. **硬件匹配**: 根据硬件配置调整参数
|
||||
|
||||
## 技术原理
|
||||
|
||||
### 1. 内存泄漏原因
|
||||
- 频繁创建Image对象
|
||||
- Base64字符串累积
|
||||
- 事件监听器未正确清理
|
||||
- 浏览器垃圾回收不及时
|
||||
|
||||
### 2. 优化原理
|
||||
- 对象复用减少创建开销
|
||||
- 及时释放减少内存占用
|
||||
- 跳帧处理减少处理负担
|
||||
- 强制垃圾回收释放内存
|
||||
|
||||
### 3. 性能平衡
|
||||
- 内存使用 vs 视频质量
|
||||
- 实时性 vs 资源占用
|
||||
- 用户体验 vs 系统稳定性
|
||||
|
||||
---
|
||||
|
||||
如有其他问题,请查看项目文档或联系技术支持。
|
319
backend/app.py
319
backend/app.py
@ -22,6 +22,9 @@ import base64
|
||||
import configparser
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import queue
|
||||
import gc
|
||||
import psutil
|
||||
import os
|
||||
|
||||
# 添加当前目录到Python路径
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
@ -49,7 +52,7 @@ app.config['SECRET_KEY'] = 'body-balance-detection-system-2024'
|
||||
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading')
|
||||
|
||||
# 启用CORS支持
|
||||
CORS(app, origins=['http://localhost:3000', 'http://localhost:3001', 'file://*'])
|
||||
CORS(app, origins='*', supports_credentials=True, allow_headers=['Content-Type', 'Authorization'], methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'])
|
||||
|
||||
# 读取RTSP配置
|
||||
config = configparser.ConfigParser()
|
||||
@ -68,30 +71,92 @@ rtsp_running = False
|
||||
# 用于异步编码的线程池和队列
|
||||
encoding_executor = ThreadPoolExecutor(max_workers=2)
|
||||
frame_queue = queue.Queue(maxsize=1) # 只保留最新的一帧
|
||||
# 内存优化配置
|
||||
frame_skip_counter = 0
|
||||
FRAME_SKIP_RATIO = 1 # 每3帧发送1帧,减少网络和内存压力
|
||||
MAX_FRAME_SIZE = (640, 480) # 进一步减小帧尺寸以节省内存
|
||||
MAX_MEMORY_USAGE = 200 * 1024 * 1024 # 100MB内存限制
|
||||
memory_check_counter = 0
|
||||
MEMORY_CHECK_INTERVAL = 50 # 每50帧检查一次内存
|
||||
|
||||
def get_memory_usage():
|
||||
"""获取当前进程内存使用量(字节)"""
|
||||
try:
|
||||
process = psutil.Process(os.getpid())
|
||||
return process.memory_info().rss
|
||||
except:
|
||||
return 0
|
||||
|
||||
def async_encode_frame(frame, frame_count):
|
||||
"""异步编码帧"""
|
||||
"""异步编码帧 - 内存优化版本"""
|
||||
global memory_check_counter
|
||||
|
||||
try:
|
||||
# 优化图像尺寸:平衡质量和性能
|
||||
# 内存检查
|
||||
memory_check_counter += 1
|
||||
if memory_check_counter >= MEMORY_CHECK_INTERVAL:
|
||||
memory_check_counter = 0
|
||||
current_memory = get_memory_usage()
|
||||
if current_memory > MAX_MEMORY_USAGE:
|
||||
logger.warning(f"内存使用过高: {current_memory / 1024 / 1024:.2f}MB,强制清理")
|
||||
gc.collect()
|
||||
# 如果内存仍然过高,跳过此帧
|
||||
if get_memory_usage() > MAX_MEMORY_USAGE:
|
||||
del frame
|
||||
return
|
||||
|
||||
# 更激进的图像尺寸压缩以节省内存
|
||||
height, width = frame.shape[:2]
|
||||
if width > 480: # 适度压缩到480宽度,保持更好的图像质量
|
||||
scale = 480 / width
|
||||
new_width = 480
|
||||
target_width, target_height = MAX_FRAME_SIZE
|
||||
|
||||
if width > target_width or height > target_height:
|
||||
# 计算缩放比例,保持宽高比
|
||||
scale_w = target_width / width
|
||||
scale_h = target_height / height
|
||||
scale = min(scale_w, scale_h)
|
||||
|
||||
new_width = int(width * scale)
|
||||
new_height = int(height * scale)
|
||||
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) # 使用更好的插值方法
|
||||
|
||||
# 使用更快的插值方法减少CPU使用
|
||||
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# 优化JPEG编码参数:平衡质量和速度
|
||||
# 优化JPEG编码参数:优先考虑速度和内存
|
||||
encode_param = [
|
||||
int(cv2.IMWRITE_JPEG_QUALITY), 75, # 提高质量到75,保持较好的图像效果
|
||||
int(cv2.IMWRITE_JPEG_OPTIMIZE), 1 # 启用优化以获得更好的压缩效果
|
||||
int(cv2.IMWRITE_JPEG_QUALITY), 50, # 进一步降低质量以减少内存使用
|
||||
int(cv2.IMWRITE_JPEG_OPTIMIZE), 1, # 启用优化
|
||||
int(cv2.IMWRITE_JPEG_PROGRESSIVE), 0 # 禁用渐进式以减少内存
|
||||
]
|
||||
_, buffer = cv2.imencode('.jpg', frame, encode_param)
|
||||
jpg_as_text = base64.b64encode(buffer).decode('utf-8')
|
||||
|
||||
socketio.emit('rtsp_frame', {'image': jpg_as_text, 'frame_id': frame_count})
|
||||
success, buffer = cv2.imencode('.jpg', frame, encode_param)
|
||||
if not success:
|
||||
logger.error('图像编码失败')
|
||||
return
|
||||
|
||||
# 立即释放frame内存
|
||||
del frame
|
||||
|
||||
jpg_as_text = base64.b64encode(buffer).decode('utf-8')
|
||||
|
||||
# 立即释放buffer内存
|
||||
del buffer
|
||||
|
||||
# 发送数据
|
||||
socketio.emit('rtsp_frame', {
|
||||
'image': jpg_as_text,
|
||||
'frame_id': frame_count,
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
# 立即释放base64字符串
|
||||
del jpg_as_text
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'异步编码帧失败: {e}')
|
||||
finally:
|
||||
# 定期强制垃圾回收
|
||||
if memory_check_counter % 10 == 0:
|
||||
gc.collect()
|
||||
|
||||
def frame_encoding_worker():
|
||||
"""帧编码工作线程"""
|
||||
@ -651,6 +716,177 @@ def run_detection(session_id, settings):
|
||||
current_detection['status'] = 'error'
|
||||
current_detection['error'] = str(e)
|
||||
|
||||
# ==================== 截图保存API ====================
|
||||
|
||||
@app.route('/api/screenshots/save', methods=['POST'])
|
||||
def save_screenshot():
|
||||
"""保存截图"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# 验证必需参数
|
||||
required_fields = ['patientId', 'patientName', 'sessionId', 'imageData']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'缺少必需参数: {field}'
|
||||
}), 400
|
||||
|
||||
patient_id = data['patientId']
|
||||
patient_name = data['patientName']
|
||||
session_id = data['sessionId']
|
||||
image_data = data['imageData']
|
||||
|
||||
# 验证base64图片数据格式
|
||||
if not image_data.startswith('data:image/'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '无效的图片数据格式'
|
||||
}), 400
|
||||
|
||||
# 提取base64数据
|
||||
try:
|
||||
header, encoded = image_data.split(',', 1)
|
||||
image_bytes = base64.b64decode(encoded)
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'图片数据解码失败: {str(e)}'
|
||||
}), 400
|
||||
|
||||
# 创建文件夹结构
|
||||
screenshots_dir = Path('screenshots')
|
||||
patient_dir = screenshots_dir / f'{patient_id}_{patient_name}'
|
||||
session_dir = patient_dir / f'{session_id}'
|
||||
|
||||
# 确保目录存在
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名(4位流水号)
|
||||
existing_files = list(session_dir.glob(f'{patient_id}_{session_id}_*.png'))
|
||||
next_number = len(existing_files) + 1
|
||||
filename = f'{patient_id}_{session_id}_{next_number:04d}.png'
|
||||
filepath = session_dir / filename
|
||||
|
||||
# 保存图片文件
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# 记录到数据库(如果需要)
|
||||
try:
|
||||
# 这里可以添加数据库记录逻辑
|
||||
# db_manager.save_screenshot_record(patient_id, session_id, str(filepath))
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f'保存截图记录到数据库失败: {e}')
|
||||
|
||||
logger.info(f'截图保存成功: {filepath}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '截图保存成功',
|
||||
'filepath': str(filepath),
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'保存截图失败: {e}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'保存截图失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# ==================== 录像保存API ====================
|
||||
|
||||
@app.route('/api/recordings/save', methods=['POST'])
|
||||
def save_recording():
|
||||
"""保存录像"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# 验证必需参数
|
||||
required_fields = ['patientId', 'patientName', 'sessionId', 'videoData']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'缺少必需参数: {field}'
|
||||
}), 400
|
||||
|
||||
patient_id = data['patientId']
|
||||
patient_name = data['patientName']
|
||||
session_id = data['sessionId']
|
||||
video_data = data['videoData']
|
||||
mime_type = data.get('mimeType', 'video/webm;codecs=vp9') # 默认webm格式
|
||||
|
||||
# 验证base64视频数据格式
|
||||
if not video_data.startswith('data:video/'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '无效的视频数据格式'
|
||||
}), 400
|
||||
|
||||
# 提取base64数据
|
||||
try:
|
||||
header, encoded = video_data.split(',', 1)
|
||||
video_bytes = base64.b64decode(encoded)
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'视频数据解码失败: {str(e)}'
|
||||
}), 400
|
||||
|
||||
# 创建文件夹结构(与截图保存相同的结构)
|
||||
recordings_dir = Path('screenshots') # 使用同一个根目录
|
||||
patient_dir = recordings_dir / f'{patient_id}_{patient_name}'
|
||||
session_dir = patient_dir / f'{session_id}'
|
||||
|
||||
# 确保目录存在
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 根据mimeType确定文件扩展名
|
||||
if 'mp4' in mime_type:
|
||||
file_extension = 'mp4'
|
||||
elif 'webm' in mime_type:
|
||||
file_extension = 'webm'
|
||||
else:
|
||||
file_extension = 'webm' # 默认扩展名
|
||||
|
||||
# 生成文件名
|
||||
filename = f'{patient_id}_{session_id}_recording.{file_extension}'
|
||||
filepath = session_dir / filename
|
||||
|
||||
logger.info(f'录像格式: {mime_type}, 文件扩展名: {file_extension}')
|
||||
|
||||
# 保存视频文件
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(video_bytes)
|
||||
|
||||
# 记录到数据库(如果需要)
|
||||
try:
|
||||
# 这里可以添加数据库记录逻辑
|
||||
# db_manager.save_recording_record(patient_id, session_id, str(filepath))
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f'保存录像记录到数据库失败: {e}')
|
||||
|
||||
logger.info(f'录像保存成功: {filepath}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '录像保存成功',
|
||||
'filepath': str(filepath),
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'保存录像失败: {e}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'保存录像失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# ==================== 错误处理 ====================
|
||||
|
||||
@app.errorhandler(404)
|
||||
@ -662,16 +898,32 @@ def internal_error(error):
|
||||
return jsonify({'success': False, 'error': '服务器内部错误'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(description='Body Balance Evaluation System Backend')
|
||||
parser.add_argument('--host', default=None, help='Host address to bind to')
|
||||
parser.add_argument('--port', type=int, default=None, help='Port number to bind to')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# 初始化应用
|
||||
init_app()
|
||||
|
||||
# 确定主机和端口
|
||||
host = args.host if args.host else config.get('SERVER', 'host', fallback='127.0.0.1')
|
||||
port = args.port if args.port else config.getint('SERVER', 'port', fallback=5000)
|
||||
debug = args.debug if args.debug else config.getboolean('APP', 'debug', fallback=False)
|
||||
|
||||
# 启动Flask+SocketIO服务
|
||||
logger.info('启动后端服务...')
|
||||
logger.info(f'启动后端服务... Host: {host}, Port: {port}, Debug: {debug}')
|
||||
socketio.run(app,
|
||||
host=config.get('SERVER', 'host', fallback='0.0.0.0'),
|
||||
port=config.getint('SERVER', 'port', fallback=5000),
|
||||
debug=config.getboolean('APP', 'debug', fallback=False),
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
use_reloader=False, # 禁用热重载以避免进程问题
|
||||
log_output=True, # 启用详细日志
|
||||
allow_unsafe_werkzeug=True
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
@ -778,25 +1030,37 @@ def generate_rtsp_frames():
|
||||
|
||||
error_count = 0 # 重置错误计数
|
||||
|
||||
# 最激进的帧跳过策略:大量跳过旧帧,确保绝对实时性
|
||||
# 连续读取并丢弃多帧,只保留最新的一帧
|
||||
# 内存优化的帧跳过策略
|
||||
# 减少跳帧数量,避免过度内存使用
|
||||
skip_count = 0
|
||||
while skip_count < 10: # 最多跳过10帧
|
||||
while skip_count < 3: # 减少到最多跳过3帧
|
||||
temp_ret, temp_frame = cap.read()
|
||||
if temp_ret:
|
||||
# 立即释放之前的帧
|
||||
if 'frame' in locals():
|
||||
del frame
|
||||
frame = temp_frame
|
||||
skip_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# 时间同步检查:如果距离上次处理时间太短,跳过这一帧
|
||||
# 降低帧率以减少内存压力
|
||||
current_time = time.time()
|
||||
if current_time - last_frame_time < 1/60: # 保持60fps的最大频率
|
||||
if current_time - last_frame_time < 1/20: # 降低到20fps最大频率
|
||||
continue
|
||||
last_frame_time = current_time
|
||||
|
||||
frame_count += 1
|
||||
|
||||
# 实现帧跳过以减少内存和网络压力
|
||||
global frame_skip_counter
|
||||
frame_skip_counter += 1
|
||||
|
||||
if frame_skip_counter % (FRAME_SKIP_RATIO + 1) != 0:
|
||||
# 跳过此帧,立即释放内存
|
||||
del frame
|
||||
continue
|
||||
|
||||
try:
|
||||
# 将帧放入队列进行异步处理
|
||||
try:
|
||||
@ -805,13 +1069,20 @@ def generate_rtsp_frames():
|
||||
except queue.Full:
|
||||
# 队列满了,清空队列并放入新帧
|
||||
try:
|
||||
frame_queue.get_nowait()
|
||||
old_frame, _ = frame_queue.get_nowait()
|
||||
del old_frame # 立即释放旧帧内存
|
||||
except queue.Empty:
|
||||
pass
|
||||
frame_queue.put_nowait((frame.copy(), frame_count))
|
||||
|
||||
# 立即释放原始帧内存
|
||||
del frame
|
||||
|
||||
if frame_count % 60 == 0: # 每60帧记录一次
|
||||
logger.info(f'已推送 {frame_count} 帧到编码队列,图像尺寸: {frame.shape[:2]}')
|
||||
logger.info(f'已推送 {frame_count} 帧到编码队列,跳过率: {FRAME_SKIP_RATIO}/{FRAME_SKIP_RATIO+1}')
|
||||
# 定期强制垃圾回收
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'帧队列处理失败: {e}')
|
||||
|
@ -5,7 +5,7 @@ debug = false
|
||||
log_level = INFO
|
||||
|
||||
[SERVER]
|
||||
host = 0.0.0.0
|
||||
host = 127.0.0.1
|
||||
port = 5000
|
||||
cors_origins = *
|
||||
|
||||
|
@ -40,6 +40,7 @@ pytest
|
||||
pytest-cov
|
||||
flake8
|
||||
black
|
||||
debugpy
|
||||
|
||||
# Other dependencies
|
||||
pyyaml
|
||||
|
@ -8,18 +8,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"pinia": "^2.1.6",
|
||||
"element-plus": "^2.3.9",
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"axios": "^1.5.0",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.1"
|
||||
"element-plus": "^2.3.9",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^2.1.6",
|
||||
"vue": "^3.3.4",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"vite": "^4.4.9",
|
||||
"sass": "^1.66.1"
|
||||
"sass": "^1.66.1",
|
||||
"vite": "^4.4.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,19 +19,20 @@
|
||||
<span class="page-title">实时检测</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="isStart == false"
|
||||
@click="isStart = true"
|
||||
v-if="!isStart"
|
||||
@click="handleStartStop"
|
||||
:disabled="!isConnected"
|
||||
type="primary"
|
||||
class="start-btn"
|
||||
style="background-image: linear-gradient(to right, rgb(236, 50, 166), rgb(160, 5, 216));
|
||||
--el-button-border-color: #409EFF;
|
||||
--el-button-border-color: transparent "
|
||||
>
|
||||
开始
|
||||
{{ isConnected ? '开始' : '连接中...' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isStart == true"
|
||||
@click="isStart = false"
|
||||
v-if="isStart"
|
||||
@click="handleStartStop"
|
||||
type="primary"
|
||||
class="start-btn"
|
||||
style="background-image: linear-gradient(to right, rgb(236, 50, 166), rgb(160, 5, 216));
|
||||
@ -42,6 +43,8 @@
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isStart == true"
|
||||
@click="handleScreenshot"
|
||||
:loading="screenshotLoading"
|
||||
type="primary"
|
||||
style="width: 80px;background-image: linear-gradient(to right, rgb(250, 167, 6), rgb(160, 5, 216));
|
||||
--el-button-border-color: #409EFF;
|
||||
@ -61,7 +64,7 @@
|
||||
</header>
|
||||
|
||||
<!-- 核心内容区(网格布局) -->
|
||||
<div class="content-grid">
|
||||
<div id="detectare" class="content-grid">
|
||||
<!-- 身体姿态模块 -->
|
||||
<div class="module-card body-posture" style="width: 25%; ">
|
||||
<div class="module-header">
|
||||
@ -298,61 +301,763 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { wsManager } from '@/services/api'
|
||||
import { io } from 'socket.io-client'
|
||||
import html2canvas from 'html2canvas'
|
||||
const router = useRouter()
|
||||
|
||||
const isStart = ref(false)
|
||||
const isConnected = ref(false)
|
||||
const rtspImgSrc = ref('')
|
||||
const screenshotLoading = ref(false)
|
||||
const isRecording = ref(false)
|
||||
|
||||
// 录像相关变量
|
||||
let mediaRecorder = null
|
||||
let recordedChunks = []
|
||||
let recordingStream = null
|
||||
let currentMimeType = null // 当前录制的视频格式
|
||||
|
||||
// 患者信息(从页面获取或通过API获取)
|
||||
const patientInfo = ref({
|
||||
id: '2101',
|
||||
name: '张三',
|
||||
sessionId: null // 检查记录ID,实际使用时应从路由或API获取
|
||||
})
|
||||
|
||||
// WebSocket相关变量
|
||||
let socket = null
|
||||
let frameCount = 0
|
||||
let lastFrameTime = 0
|
||||
let imageCache = null
|
||||
let frameSkipCount = 0
|
||||
|
||||
// 后端服务器地址配置
|
||||
const BACKEND_URL = 'http://localhost:5000'
|
||||
|
||||
// 内存优化配置
|
||||
const FRAME_SKIP_THRESHOLD = 1 // 每2帧显示1帧
|
||||
const MAX_FPS = 25 // 最大显示帧率
|
||||
const MIN_FRAME_INTERVAL = 1000 / MAX_FPS // 最小帧间隔(ms)
|
||||
|
||||
// 内存监控定时器
|
||||
let memoryMonitorTimer = null
|
||||
|
||||
// 模拟历史数据
|
||||
const historyData = ref([
|
||||
{ id: 3, rotLeft: '-55.2°', rotRight: '54.2°', tiltLeft: '-17.7°', tiltRight: '18.2°', pitchDown: '-20.2°', pitchUp: '10.5°' },
|
||||
{ id: 2, rotLeft: '-55.8°', rotRight: '56.2°', tiltLeft: '-17.5°', tiltRight: '17.9°', pitchDown: '-21.2°', pitchUp: '12.1°' },
|
||||
{ id: 1, rotLeft: '-56.1°', rotRight: '55.7°', tiltLeft: '-17.5°', tiltRight: '18.5°', pitchDown: '-22.2°', pitchUp: '11.5°' }
|
||||
])
|
||||
|
||||
function routeTo(path){
|
||||
router.push(`/`)
|
||||
// router.push(`/patient/edit/${selectedPatient.value.id}`)
|
||||
}
|
||||
|
||||
// 返回按钮逻辑
|
||||
const handleBack = () => {
|
||||
// 可添加路由跳转逻辑
|
||||
console.log('返回上一页')
|
||||
}
|
||||
const rtspImgSrc = ref('')
|
||||
let ws = null
|
||||
|
||||
onMounted(() => {
|
||||
// 使用Socket.IO连接
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getBackendUrl().then(url => {
|
||||
// 使用wsManager连接
|
||||
wsManager.connect(url)
|
||||
|
||||
// 监听连接成功事件
|
||||
wsManager.on('connect', () => {
|
||||
console.log('WebSocket连接成功')
|
||||
wsManager.emit('start_rtsp', {})
|
||||
})
|
||||
|
||||
// 监听RTSP帧数据
|
||||
wsManager.on('rtsp_frame', (data) => {
|
||||
if (data.image) {
|
||||
rtspImgSrc.value = 'data:image/jpeg;base64,' + data.image
|
||||
// WebSocket连接函数
|
||||
function connectWebSocket() {
|
||||
try {
|
||||
console.log('正在连接到', BACKEND_URL)
|
||||
|
||||
// 创建Socket.IO连接
|
||||
socket = io(BACKEND_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 10000,
|
||||
forceNew: true
|
||||
})
|
||||
|
||||
// 连接成功事件
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket连接成功!Socket ID:', socket.id)
|
||||
isConnected.value = true
|
||||
})
|
||||
|
||||
// 连接失败事件
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接失败:', error.message)
|
||||
isConnected.value = false
|
||||
})
|
||||
|
||||
// 断开连接事件
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('⚠️ 连接断开:', reason)
|
||||
isConnected.value = false
|
||||
hideVideo()
|
||||
})
|
||||
|
||||
// 监听RTSP状态事件
|
||||
socket.on('rtsp_status', (data) => {
|
||||
console.log('📺 RTSP状态:', data)
|
||||
})
|
||||
|
||||
// 监听RTSP帧数据
|
||||
socket.on('rtsp_frame', (data) => {
|
||||
if (data.image) {
|
||||
frameCount++
|
||||
|
||||
// 帧率控制和跳帧优化
|
||||
const currentTime = Date.now()
|
||||
if (currentTime - lastFrameTime < MIN_FRAME_INTERVAL) {
|
||||
frameSkipCount++
|
||||
return // 跳过此帧以控制帧率
|
||||
}
|
||||
})
|
||||
|
||||
// 每隔几帧显示一次,减少内存压力
|
||||
if (frameCount % (FRAME_SKIP_THRESHOLD + 1) === 0) {
|
||||
displayFrameOptimized(data.image)
|
||||
lastFrameTime = currentTime
|
||||
}
|
||||
|
||||
if (frameCount % 60 === 0) { // 每60帧记录一次
|
||||
console.log(`🎬 已接收 ${frameCount} 帧,跳过 ${frameSkipCount} 帧`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听错误事件
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ Socket错误:', error)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 连接异常:', error.message)
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
function disconnectWebSocket() {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
console.log('主动断开连接')
|
||||
isConnected.value = false
|
||||
hideVideo()
|
||||
}
|
||||
}
|
||||
|
||||
// 启动RTSP
|
||||
function startRtsp() {
|
||||
if (socket && socket.connected) {
|
||||
console.log('🚀 发送start_rtsp事件')
|
||||
|
||||
socket.emit('start_rtsp', {}, (ack) => {
|
||||
if (ack) {
|
||||
console.log('✅ start_rtsp事件已确认:', ack)
|
||||
} else {
|
||||
console.log('⚠️ start_rtsp事件无确认响应')
|
||||
}
|
||||
})
|
||||
|
||||
frameCount = 0
|
||||
|
||||
// 设置超时检查
|
||||
setTimeout(() => {
|
||||
if (frameCount === 0) {
|
||||
console.log('⏰ 5秒后仍未收到视频帧,可能存在问题')
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
} else {
|
||||
console.error('❌ WebSocket未连接,无法启动RTSP')
|
||||
}
|
||||
}
|
||||
|
||||
// 停止RTSP
|
||||
function stopRtsp() {
|
||||
if (socket && socket.connected) {
|
||||
console.log('🛑 发送stop_rtsp事件')
|
||||
socket.emit('stop_rtsp', {}, (ack) => {
|
||||
if (ack) {
|
||||
console.log('✅ stop_rtsp事件已确认:', ack)
|
||||
} else {
|
||||
console.log('⚠️ stop_rtsp事件无确认响应')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error('❌ WebSocket未连接,无法停止RTSP')
|
||||
}
|
||||
}
|
||||
|
||||
// 优化的帧显示函数,减少内存泄漏
|
||||
function displayFrameOptimized(base64Image) {
|
||||
try {
|
||||
// 清理之前的图像缓存
|
||||
if (imageCache) {
|
||||
imageCache.src = ''
|
||||
imageCache = null
|
||||
}
|
||||
|
||||
// 主动清理之前的dataUrl,避免内存泄漏
|
||||
const oldSrc = rtspImgSrc.value
|
||||
if (oldSrc && oldSrc.startsWith('data:')) {
|
||||
rtspImgSrc.value = ''
|
||||
// 立即释放引用
|
||||
setTimeout(() => {
|
||||
// 确保旧的dataUrl被释放
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 直接设置图像源,避免创建额外的Image对象
|
||||
const dataUrl = 'data:image/jpeg;base64,' + base64Image
|
||||
|
||||
// 使用nextTick优化渲染
|
||||
nextTick(() => {
|
||||
rtspImgSrc.value = dataUrl
|
||||
})
|
||||
|
||||
// 更频繁的轻量级内存清理
|
||||
if (frameCount % 30 === 0) {
|
||||
// 对于不支持window.gc的浏览器,使用轻量级清理
|
||||
if (window.gc) {
|
||||
window.gc()
|
||||
} else {
|
||||
// 创建少量临时对象触发小规模垃圾回收
|
||||
const temp = new Array(50).fill(null)
|
||||
temp.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 每150帧进行一次深度清理
|
||||
if (frameCount % 150 === 0) {
|
||||
setTimeout(() => {
|
||||
forceGarbageCollection()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// 每500帧进行一次彻底的内存重置
|
||||
if (frameCount % 500 === 0) {
|
||||
console.log(`🔄 执行深度内存重置 (第${frameCount}帧)`)
|
||||
setTimeout(() => {
|
||||
// 临时清空图像,强制释放内存
|
||||
const currentSrc = rtspImgSrc.value
|
||||
rtspImgSrc.value = ''
|
||||
setTimeout(() => {
|
||||
rtspImgSrc.value = currentSrc
|
||||
forceGarbageCollection()
|
||||
}, 100)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('显示帧失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏视频
|
||||
function hideVideo() {
|
||||
rtspImgSrc.value = ''
|
||||
|
||||
// 清理缓存
|
||||
if (imageCache) {
|
||||
imageCache.src = ''
|
||||
imageCache = null
|
||||
}
|
||||
|
||||
// 重置计数器
|
||||
frameCount = 0
|
||||
frameSkipCount = 0
|
||||
lastFrameTime = 0
|
||||
|
||||
// 强制垃圾回收
|
||||
if (window.gc) {
|
||||
window.gc()
|
||||
}
|
||||
}
|
||||
|
||||
// 截图功能
|
||||
async function handleScreenshot() {
|
||||
if (screenshotLoading.value) return
|
||||
|
||||
try {
|
||||
screenshotLoading.value = true
|
||||
|
||||
// 显示进度提示
|
||||
ElMessage.info('正在生成截图...')
|
||||
|
||||
// 获取要截图的DOM元素
|
||||
const element = document.getElementById('detectare')
|
||||
if (!element) {
|
||||
throw new Error('未找到截图区域')
|
||||
}
|
||||
|
||||
// 使用html2canvas进行截图
|
||||
const canvas = await html2canvas(element, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scale: 1,
|
||||
logging: false
|
||||
})
|
||||
|
||||
// 将canvas转换为base64
|
||||
const base64Image = canvas.toDataURL('image/png')
|
||||
|
||||
// 显示保存进度
|
||||
ElMessage.info('正在保存截图...')
|
||||
|
||||
// 检查是否有活跃的会话ID
|
||||
if (!patientInfo.value.sessionId) {
|
||||
throw new Error('请先开始检测再进行截图')
|
||||
}
|
||||
|
||||
// 调用后端API保存截图
|
||||
const result = await saveScreenshot({
|
||||
patientId: patientInfo.value.id,
|
||||
patientName: patientInfo.value.name,
|
||||
sessionId: patientInfo.value.sessionId,
|
||||
imageData: base64Image
|
||||
})
|
||||
|
||||
// 显示成功消息和文件路径
|
||||
ElMessage.success({
|
||||
message: `截图保存成功!文件路径: ${result.filepath}`,
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
console.log('✅ 截图保存成功:', result.filepath)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 截图失败:', error)
|
||||
|
||||
// 根据错误类型显示不同的错误消息
|
||||
let errorMessage = '截图失败'
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
errorMessage = '网络连接失败,请检查后端服务是否正常运行'
|
||||
} else if (error.message.includes('服务器错误')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('未找到截图区域')) {
|
||||
errorMessage = '截图区域不存在,请刷新页面重试'
|
||||
} else {
|
||||
errorMessage = `截图失败: ${error.message}`
|
||||
}
|
||||
|
||||
ElMessage.error({
|
||||
message: errorMessage,
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
} finally {
|
||||
screenshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成检查记录ID
|
||||
function generateSessionId() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const hour = String(now.getHours()).padStart(2, '0')
|
||||
const minute = String(now.getMinutes()).padStart(2, '0')
|
||||
const second = String(now.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${year}${month}${day}${hour}${minute}${second}`
|
||||
}
|
||||
|
||||
// 调用后端API保存截图
|
||||
async function saveScreenshot(data) {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/screenshots/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
console.log('📸 截图保存成功:', result.filepath)
|
||||
return result
|
||||
} else {
|
||||
throw new Error(result.message || '保存失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 保存截图API调用失败:', error)
|
||||
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
throw new Error('网络连接失败,请检查后端服务是否正常运行')
|
||||
} else if (error.message.includes('HTTP')) {
|
||||
throw new Error(`服务器错误: ${error.message}`)
|
||||
} else {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始录像
|
||||
async function startRecording() {
|
||||
try {
|
||||
console.log('🎬 开始录像...')
|
||||
|
||||
// 获取要录制的区域
|
||||
const targetElement = document.getElementById('detectare')
|
||||
if (!targetElement) {
|
||||
throw new Error('未找到录制区域')
|
||||
}
|
||||
|
||||
// 使用getDisplayMedia API录制屏幕区域
|
||||
// 注意:由于浏览器限制,我们使用captureStream方式
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 设置canvas尺寸
|
||||
const rect = targetElement.getBoundingClientRect()
|
||||
canvas.width = rect.width
|
||||
canvas.height = rect.height
|
||||
|
||||
// 创建录制流
|
||||
recordingStream = canvas.captureStream(30) // 30fps
|
||||
|
||||
// 初始化MediaRecorder
|
||||
// 尝试使用mp4格式,如果不支持则回退到webm
|
||||
let mimeType = 'video/mp4;codecs=avc1.42E01E,mp4a.40.2'
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||
mimeType = 'video/webm;codecs=vp9'
|
||||
console.log('⚠️ 浏览器不支持MP4录制,使用WebM格式')
|
||||
} else {
|
||||
console.log('✅ 使用MP4格式录制')
|
||||
}
|
||||
|
||||
mediaRecorder = new MediaRecorder(recordingStream, {
|
||||
mimeType: mimeType
|
||||
})
|
||||
|
||||
// 保存当前使用的格式
|
||||
currentMimeType = mimeType
|
||||
|
||||
recordedChunks = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
console.log('🎬 录像停止,开始保存...')
|
||||
await saveRecording()
|
||||
}
|
||||
|
||||
// 开始录制
|
||||
mediaRecorder.start(1000) // 每秒收集一次数据
|
||||
isRecording.value = true
|
||||
|
||||
// 开始定期捕获目标区域
|
||||
startCapturingArea(targetElement, canvas, ctx)
|
||||
|
||||
console.log('✅ 录像已开始')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 开始录像失败:', error)
|
||||
ElMessage.error(`开始录像失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 定期捕获区域内容到canvas
|
||||
function startCapturingArea(element, canvas, ctx) {
|
||||
const captureFrame = () => {
|
||||
if (!isRecording.value) return
|
||||
|
||||
// 使用html2canvas捕获元素
|
||||
html2canvas(element, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#1E1E1E',
|
||||
scale: 1,
|
||||
logging: false,
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
}).then(capturedCanvas => {
|
||||
// 将捕获的内容绘制到录制canvas上
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(capturedCanvas, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 监听连接错误
|
||||
wsManager.on('connect_error', (error) => {
|
||||
console.error('WebSocket连接失败:', error)
|
||||
})
|
||||
// 继续下一帧
|
||||
if (isRecording.value) {
|
||||
setTimeout(captureFrame, 1000/30) // 30fps
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('捕获帧失败:', error)
|
||||
if (isRecording.value) {
|
||||
setTimeout(captureFrame, 1000/30)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
captureFrame()
|
||||
}
|
||||
|
||||
// 停止录像
|
||||
function stopRecording() {
|
||||
try {
|
||||
console.log('🛑 停止录像...')
|
||||
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
// 设置停止事件监听器,在录像停止后自动保存
|
||||
mediaRecorder.addEventListener('stop', () => {
|
||||
console.log('📹 录像数据准备完成,开始保存...')
|
||||
saveRecording()
|
||||
}, { once: true })
|
||||
|
||||
mediaRecorder.stop()
|
||||
} else {
|
||||
// 如果没有正在录制的内容,但有录制数据,直接保存
|
||||
if (recordedChunks.length > 0) {
|
||||
console.log('📹 发现未保存的录像数据,开始保存...')
|
||||
saveRecording()
|
||||
}
|
||||
}
|
||||
|
||||
if (recordingStream) {
|
||||
recordingStream.getTracks().forEach(track => track.stop())
|
||||
recordingStream = null
|
||||
}
|
||||
|
||||
isRecording.value = false
|
||||
console.log('✅ 录像已停止')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 停止录像失败:', error)
|
||||
ElMessage.error(`停止录像失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存录像
|
||||
async function saveRecording() {
|
||||
try {
|
||||
if (recordedChunks.length === 0) {
|
||||
throw new Error('没有录制数据')
|
||||
}
|
||||
|
||||
// 验证必需的患者信息
|
||||
if (!patientInfo.value.id || !patientInfo.value.name || !patientInfo.value.sessionId) {
|
||||
throw new Error(`缺少必需的患者信息: ID=${patientInfo.value.id}, 姓名=${patientInfo.value.name}, 会话ID=${patientInfo.value.sessionId}`)
|
||||
}
|
||||
|
||||
console.log('📝 准备保存录像,患者信息:', {
|
||||
id: patientInfo.value.id,
|
||||
name: patientInfo.value.name,
|
||||
sessionId: patientInfo.value.sessionId
|
||||
})
|
||||
|
||||
// 创建视频blob
|
||||
const blob = new Blob(recordedChunks, { type: 'video/webm' })
|
||||
console.log('📹 录像数据大小:', (blob.size / 1024 / 1024).toFixed(2), 'MB')
|
||||
|
||||
// 转换为base64
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const base64Data = reader.result
|
||||
|
||||
// 调用后端API保存录像
|
||||
const response = await fetch(`${BACKEND_URL}/api/recordings/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
patientId: patientInfo.value.id,
|
||||
patientName: patientInfo.value.name,
|
||||
sessionId: patientInfo.value.sessionId,
|
||||
videoData: base64Data,
|
||||
mimeType: currentMimeType || 'video/webm;codecs=vp9'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
console.log('🎬 录像保存成功:', result.filepath)
|
||||
ElMessage.success({
|
||||
message: `录像保存成功!文件路径: ${result.filepath}`,
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
// 清空录制数据,避免重复保存
|
||||
recordedChunks.length = 0
|
||||
console.log('🧹 录像数据已清空')
|
||||
|
||||
// 录像保存完成后,清空会话ID,正式结束会话
|
||||
patientInfo.value.sessionId = null
|
||||
console.log('✅ 会话正式结束,会话ID已清空')
|
||||
} else {
|
||||
throw new Error(result.message || '保存失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 保存录像失败:', error)
|
||||
ElMessage.error({
|
||||
message: `保存录像失败: ${error.message}`,
|
||||
duration: 5000
|
||||
})
|
||||
|
||||
// 即使保存失败,也要清空会话ID,避免状态混乱
|
||||
patientInfo.value.sessionId = null
|
||||
console.log('⚠️ 录像保存失败,但会话已结束,会话ID已清空')
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('❌ 读取录像数据失败')
|
||||
ElMessage.error('读取录像数据失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 保存录像失败:', error)
|
||||
ElMessage.error(`保存录像失败: ${error.message}`)
|
||||
|
||||
// 即使保存失败,也要清空会话ID,避免状态混乱
|
||||
patientInfo.value.sessionId = null
|
||||
console.log('⚠️ 录像保存失败,但会话已结束,会话ID已清空')
|
||||
}
|
||||
}
|
||||
|
||||
// 强制垃圾回收函数
|
||||
function forceGarbageCollection() {
|
||||
console.log('🧹 尝试强制垃圾回收...')
|
||||
|
||||
// 清理可能的内存泄漏
|
||||
if (imageCache) {
|
||||
imageCache.src = ''
|
||||
imageCache = null
|
||||
}
|
||||
|
||||
// 清理当前显示的图像数据
|
||||
if (rtspImgSrc.value && rtspImgSrc.value.startsWith('data:')) {
|
||||
const currentSrc = rtspImgSrc.value
|
||||
rtspImgSrc.value = ''
|
||||
// 短暂延迟后恢复,触发浏览器内存回收
|
||||
setTimeout(() => {
|
||||
if (!rtspImgSrc.value) {
|
||||
rtspImgSrc.value = currentSrc
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// 强制垃圾回收(如果浏览器支持)
|
||||
if (window.gc) {
|
||||
window.gc()
|
||||
console.log('✅ 强制垃圾回收完成')
|
||||
} else {
|
||||
// 浏览器不支持window.gc时的替代方案
|
||||
console.log('⚠️ 浏览器不支持强制垃圾回收,使用替代清理方案')
|
||||
|
||||
// 创建大量临时对象然后释放,触发浏览器自动垃圾回收
|
||||
const tempArrays = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tempArrays.push(new Array(1000).fill(null))
|
||||
}
|
||||
tempArrays.length = 0
|
||||
|
||||
// 使用setTimeout让浏览器有机会进行垃圾回收
|
||||
setTimeout(() => {
|
||||
console.log('✅ 替代内存清理完成')
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 定期内存监控
|
||||
function startMemoryMonitoring() {
|
||||
if (memoryMonitorTimer) {
|
||||
clearInterval(memoryMonitorTimer)
|
||||
}
|
||||
|
||||
memoryMonitorTimer = setInterval(() => {
|
||||
if (performance.memory && frameCount > 0) {
|
||||
const memory = performance.memory
|
||||
const usagePercent = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100
|
||||
|
||||
// 如果内存使用率超过80%,自动进行垃圾回收
|
||||
if (usagePercent > 80) {
|
||||
console.log(`⚠️ 内存使用率过高: ${usagePercent.toFixed(2)}%,自动清理...`)
|
||||
forceGarbageCollection()
|
||||
}
|
||||
|
||||
// 每分钟记录一次内存使用情况
|
||||
if (frameCount % 1500 === 0) {
|
||||
console.log(`📊 内存使用情况: ${(memory.usedJSHeapSize / 1024 / 1024).toFixed(2)}MB / ${(memory.jsHeapSizeLimit / 1024 / 1024).toFixed(2)}MB (${usagePercent.toFixed(2)}%)`)
|
||||
}
|
||||
}
|
||||
}, 10000) // 每10秒检查一次
|
||||
}
|
||||
|
||||
// 停止内存监控
|
||||
function stopMemoryMonitoring() {
|
||||
if (memoryMonitorTimer) {
|
||||
clearInterval(memoryMonitorTimer)
|
||||
memoryMonitorTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理开始/停止按钮点击
|
||||
function handleStartStop() {
|
||||
if (isStart.value) {
|
||||
// 停止RTSP和录像
|
||||
stopRtsp()
|
||||
stopRecording()
|
||||
isStart.value = false
|
||||
// 注意:不要立即清空会话ID,等录像保存完成后再清空
|
||||
console.log('🛑 检测会话结束,等待录像保存完成...')
|
||||
} else {
|
||||
// 启动RTSP和录像
|
||||
if (isConnected.value) {
|
||||
// 生成新的会话ID
|
||||
patientInfo.value.sessionId = generateSessionId()
|
||||
console.log('🚀 开始新的检测会话,会话ID:', patientInfo.value.sessionId)
|
||||
|
||||
startRtsp()
|
||||
startRecording()
|
||||
isStart.value = true
|
||||
} else {
|
||||
console.error('WebSocket未连接,无法启动RTSP')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面加载时立即建立WebSocket连接
|
||||
connectWebSocket()
|
||||
|
||||
// 启动内存监控
|
||||
startMemoryMonitoring()
|
||||
|
||||
console.log('🔧 内存优化已启用: 最大FPS=' + MAX_FPS + ', 跳帧阈值=' + (FRAME_SKIP_THRESHOLD + 1))
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 停止RTSP并断开连接
|
||||
wsManager.emit('stop_rtsp', {})
|
||||
wsManager.disconnect()
|
||||
// 页面卸载时停止RTSP并断开连接
|
||||
if (isStart.value) {
|
||||
stopRtsp()
|
||||
}
|
||||
disconnectWebSocket()
|
||||
|
||||
// 停止内存监控
|
||||
stopMemoryMonitoring()
|
||||
|
||||
// 最后一次强制垃圾回收
|
||||
forceGarbageCollection()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -89,6 +89,8 @@
|
||||
<button onclick="testSocketConnection()">测试Socket连接</button>
|
||||
<button onclick="clearLog()">清空日志</button>
|
||||
<button onclick="showDebugInfo()">显示调试信息</button>
|
||||
<button onclick="showMemoryInfo()">内存使用情况</button>
|
||||
<button onclick="forceGarbageCollection()">强制垃圾回收</button>
|
||||
</div>
|
||||
|
||||
<div id="debugInfo" style="display: none; margin-top: 10px; padding: 10px; background-color: #e9ecef; border-radius: 4px;">
|
||||
@ -96,6 +98,11 @@
|
||||
<div id="debugContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="memoryInfo" style="display: none; margin-top: 10px; padding: 10px; background-color: #fff3cd; border-radius: 4px;">
|
||||
<h4>内存使用情况</h4>
|
||||
<div id="memoryContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="videoContainer">
|
||||
<h3>RTSP视频流</h3>
|
||||
<img id="rtspImage" src="" alt="RTSP视频流" style="display: none;">
|
||||
@ -108,11 +115,19 @@
|
||||
<script>
|
||||
let socket = null;
|
||||
let frameCount = 0;
|
||||
let lastFrameTime = 0;
|
||||
let imageCache = null; // 图像缓存,避免重复创建
|
||||
let frameSkipCount = 0; // 跳帧计数
|
||||
|
||||
// 后端服务器地址配置
|
||||
const BACKEND_URL = 'http://localhost:5000'; // 根据实际情况修改
|
||||
// 如果是远程服务器,使用: 'http://192.168.1.173:5000'
|
||||
|
||||
// 内存优化配置
|
||||
const FRAME_SKIP_THRESHOLD = 1; // 每3帧显示1帧
|
||||
const MAX_FPS = 30; // 最大显示帧率
|
||||
const MIN_FRAME_INTERVAL = 1000 / MAX_FPS; // 最小帧间隔(ms)
|
||||
|
||||
function log(message) {
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
@ -192,9 +207,22 @@
|
||||
socket.on('rtsp_frame', (data) => {
|
||||
if (data.image) {
|
||||
frameCount++;
|
||||
displayFrame(data.image);
|
||||
if (frameCount % 30 === 0) { // 每30帧记录一次
|
||||
log(`🎬 已接收 ${frameCount} 帧`);
|
||||
|
||||
// 帧率控制和跳帧优化
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastFrameTime < MIN_FRAME_INTERVAL) {
|
||||
frameSkipCount++;
|
||||
return; // 跳过此帧以控制帧率
|
||||
}
|
||||
|
||||
// 每隔几帧显示一次,减少内存压力
|
||||
if (frameCount % (FRAME_SKIP_THRESHOLD + 1) === 0) {
|
||||
displayFrameOptimized(data.image);
|
||||
lastFrameTime = currentTime;
|
||||
}
|
||||
|
||||
if (frameCount % 60 === 0) { // 每60帧记录一次
|
||||
log(`🎬 已接收 ${frameCount} 帧,跳过 ${frameSkipCount} 帧`);
|
||||
}
|
||||
|
||||
} else {
|
||||
@ -273,41 +301,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
function displayFrame(base64Image) {
|
||||
// 优化的帧显示函数,减少内存泄漏
|
||||
function displayFrameOptimized(base64Image) {
|
||||
const img = document.getElementById('rtspImage');
|
||||
const noVideo = document.getElementById('noVideo');
|
||||
|
||||
const dataUrl = 'data:image/jpeg;base64,' + base64Image;
|
||||
|
||||
// 创建新的Image对象来预加载,确保图像能正确显示
|
||||
const newImg = new Image();
|
||||
newImg.onload = function() {
|
||||
img.src = dataUrl;
|
||||
img.style.display = 'block';
|
||||
noVideo.style.display = 'none';
|
||||
try {
|
||||
// 直接设置图像源,避免创建额外的Image对象
|
||||
const dataUrl = 'data:image/jpeg;base64,' + base64Image;
|
||||
|
||||
// 强制重绘
|
||||
img.style.transform = 'scale(1.001)';
|
||||
setTimeout(() => {
|
||||
img.style.transform = 'scale(1)';
|
||||
}, 1);
|
||||
};
|
||||
|
||||
newImg.onerror = function() {
|
||||
console.error('图像加载失败');
|
||||
log('❌ 图像加载失败,可能是base64数据损坏');
|
||||
};
|
||||
|
||||
newImg.src = dataUrl;
|
||||
// 清理之前的图像缓存
|
||||
if (imageCache) {
|
||||
imageCache.src = '';
|
||||
imageCache = null;
|
||||
}
|
||||
|
||||
// 使用requestAnimationFrame优化渲染
|
||||
requestAnimationFrame(() => {
|
||||
img.src = dataUrl;
|
||||
img.style.display = 'block';
|
||||
noVideo.style.display = 'none';
|
||||
});
|
||||
|
||||
// 定期清理base64数据URL缓存
|
||||
if (frameCount % 100 === 0) {
|
||||
// 强制垃圾回收(如果浏览器支持)
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('显示帧失败:', error);
|
||||
log('❌ 显示帧失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原始函数作为备用
|
||||
function displayFrame(base64Image) {
|
||||
displayFrameOptimized(base64Image);
|
||||
}
|
||||
|
||||
function hideVideo() {
|
||||
const img = document.getElementById('rtspImage');
|
||||
const noVideo = document.getElementById('noVideo');
|
||||
|
||||
// 清理图像资源
|
||||
img.style.display = 'none';
|
||||
img.src = '';
|
||||
noVideo.style.display = 'block';
|
||||
|
||||
// 清理缓存
|
||||
if (imageCache) {
|
||||
imageCache.src = '';
|
||||
imageCache = null;
|
||||
}
|
||||
|
||||
// 重置计数器
|
||||
frameCount = 0;
|
||||
frameSkipCount = 0;
|
||||
lastFrameTime = 0;
|
||||
|
||||
// 强制垃圾回收
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
|
||||
// 检查后端服务状态
|
||||
@ -384,11 +442,84 @@
|
||||
debugInfo.style.display = debugInfo.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// 显示内存使用情况
|
||||
function showMemoryInfo() {
|
||||
const memoryInfo = document.getElementById('memoryInfo');
|
||||
const memoryContent = document.getElementById('memoryContent');
|
||||
|
||||
let info = '<strong>内存使用情况:</strong><br>';
|
||||
|
||||
// 检查浏览器内存API支持
|
||||
if (performance.memory) {
|
||||
const memory = performance.memory;
|
||||
info += `已使用内存: ${(memory.usedJSHeapSize / 1024 / 1024).toFixed(2)} MB<br>`;
|
||||
info += `总分配内存: ${(memory.totalJSHeapSize / 1024 / 1024).toFixed(2)} MB<br>`;
|
||||
info += `内存限制: ${(memory.jsHeapSizeLimit / 1024 / 1024).toFixed(2)} MB<br>`;
|
||||
info += `内存使用率: ${((memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100).toFixed(2)}%<br>`;
|
||||
} else {
|
||||
info += '浏览器不支持内存API<br>';
|
||||
}
|
||||
|
||||
info += `<br><strong>优化状态:</strong><br>`;
|
||||
info += `接收帧数: ${frameCount}<br>`;
|
||||
info += `跳过帧数: ${frameSkipCount}<br>`;
|
||||
info += `跳帧率: ${frameCount > 0 ? ((frameSkipCount / frameCount) * 100).toFixed(2) : 0}%<br>`;
|
||||
info += `最大FPS限制: ${MAX_FPS}<br>`;
|
||||
info += `帧跳过阈值: 每${FRAME_SKIP_THRESHOLD + 1}帧显示1帧<br>`;
|
||||
|
||||
memoryContent.innerHTML = info;
|
||||
memoryInfo.style.display = memoryInfo.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// 强制垃圾回收
|
||||
function forceGarbageCollection() {
|
||||
log('🧹 尝试强制垃圾回收...');
|
||||
|
||||
// 清理可能的内存泄漏
|
||||
if (imageCache) {
|
||||
imageCache.src = '';
|
||||
imageCache = null;
|
||||
}
|
||||
|
||||
// 强制垃圾回收(如果浏览器支持)
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
log('✅ 强制垃圾回收完成');
|
||||
} else {
|
||||
log('⚠️ 浏览器不支持强制垃圾回收');
|
||||
}
|
||||
|
||||
// 显示内存使用情况
|
||||
setTimeout(() => {
|
||||
showMemoryInfo();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 定期内存监控
|
||||
function startMemoryMonitoring() {
|
||||
setInterval(() => {
|
||||
if (performance.memory && frameCount > 0) {
|
||||
const memory = performance.memory;
|
||||
const usagePercent = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100;
|
||||
|
||||
// 如果内存使用率超过80%,自动进行垃圾回收
|
||||
if (usagePercent > 80) {
|
||||
log(`⚠️ 内存使用率过高: ${usagePercent.toFixed(2)}%,自动清理...`);
|
||||
forceGarbageCollection();
|
||||
}
|
||||
}
|
||||
}, 10000); // 每10秒检查一次
|
||||
}
|
||||
|
||||
// 页面加载完成后的初始化
|
||||
window.onload = function() {
|
||||
log('📄 页面加载完成,可以开始连接WebSocket');
|
||||
log('🌐 后端地址: ' + BACKEND_URL);
|
||||
log('⚠️ 请确保后端服务已启动');
|
||||
log('🔧 内存优化已启用: 最大FPS=' + MAX_FPS + ', 跳帧阈值=' + (FRAME_SKIP_THRESHOLD + 1));
|
||||
|
||||
// 启动内存监控
|
||||
startMemoryMonitoring();
|
||||
|
||||
// 自动检查后端状态
|
||||
setTimeout(() => {
|
||||
|
140
memory_config.py
Normal file
140
memory_config.py
Normal file
@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
内存优化配置文件
|
||||
用于配置WebSocket视频流的内存使用参数
|
||||
"""
|
||||
|
||||
# ==================== 前端内存优化配置 ====================
|
||||
|
||||
# 帧率控制
|
||||
MAX_FPS = 15 # 最大帧率,降低可减少内存使用
|
||||
FRAME_SKIP_THRESHOLD = 2 # 跳帧阈值,每N+1帧显示1帧
|
||||
|
||||
# 内存监控
|
||||
MEMORY_CHECK_INTERVAL_MS = 10000 # 内存检查间隔(毫秒)
|
||||
MAX_MEMORY_USAGE_PERCENT = 80 # 内存使用率阈值(%),超过则自动清理
|
||||
|
||||
# 图像缓存
|
||||
ENABLE_IMAGE_CACHE = True # 是否启用图像缓存优化
|
||||
FORCE_GC_INTERVAL = 100 # 强制垃圾回收间隔(帧数)
|
||||
|
||||
# ==================== 后端内存优化配置 ====================
|
||||
|
||||
# 帧处理
|
||||
BACKEND_FRAME_SKIP_RATIO = 3 # 后端跳帧比例,每N帧处理1帧
|
||||
MAX_FRAME_WIDTH = 320 # 最大帧宽度
|
||||
MAX_FRAME_HEIGHT = 240 # 最大帧高度
|
||||
|
||||
# 内存限制
|
||||
MAX_BACKEND_MEMORY_MB = 100 # 后端最大内存使用(MB)
|
||||
BACKEND_MEMORY_CHECK_INTERVAL = 50 # 后端内存检查间隔(帧数)
|
||||
|
||||
# JPEG编码优化
|
||||
JPEG_QUALITY = 50 # JPEG质量(1-100),越低内存使用越少
|
||||
JPEG_PROGRESSIVE = False # 是否启用渐进式JPEG
|
||||
JPEG_OPTIMIZE = True # 是否启用JPEG优化
|
||||
|
||||
# 队列管理
|
||||
FRAME_QUEUE_SIZE = 1 # 帧队列大小,设为1确保实时性
|
||||
MAX_FRAME_RATE = 20 # 最大帧率限制
|
||||
|
||||
# ==================== 调试和监控配置 ====================
|
||||
|
||||
# 日志级别
|
||||
MEMORY_LOG_LEVEL = 'INFO' # DEBUG, INFO, WARNING, ERROR
|
||||
ENABLE_MEMORY_LOGGING = True # 是否启用内存使用日志
|
||||
|
||||
# 性能监控
|
||||
ENABLE_PERFORMANCE_MONITORING = True # 是否启用性能监控
|
||||
PERFORMANCE_LOG_INTERVAL = 60 # 性能日志间隔(秒)
|
||||
|
||||
# ==================== 自适应优化配置 ====================
|
||||
|
||||
# 自适应帧率
|
||||
ENABLE_ADAPTIVE_FRAMERATE = True # 是否启用自适应帧率
|
||||
MIN_FPS = 5 # 最小帧率
|
||||
MAX_ADAPTIVE_FPS = 30 # 最大自适应帧率
|
||||
|
||||
# 自适应质量
|
||||
ENABLE_ADAPTIVE_QUALITY = True # 是否启用自适应质量
|
||||
MIN_JPEG_QUALITY = 30 # 最小JPEG质量
|
||||
MAX_JPEG_QUALITY = 80 # 最大JPEG质量
|
||||
|
||||
# 内存压力阈值
|
||||
MEMORY_PRESSURE_LEVELS = {
|
||||
'low': 50, # 低压力阈值(%)
|
||||
'medium': 70, # 中等压力阈值(%)
|
||||
'high': 85, # 高压力阈值(%)
|
||||
'critical': 95 # 临界压力阈值(%)
|
||||
}
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
def get_memory_config():
|
||||
"""获取内存配置字典"""
|
||||
return {
|
||||
'frontend': {
|
||||
'max_fps': MAX_FPS,
|
||||
'frame_skip_threshold': FRAME_SKIP_THRESHOLD,
|
||||
'memory_check_interval': MEMORY_CHECK_INTERVAL_MS,
|
||||
'max_memory_percent': MAX_MEMORY_USAGE_PERCENT,
|
||||
'enable_cache': ENABLE_IMAGE_CACHE,
|
||||
'force_gc_interval': FORCE_GC_INTERVAL
|
||||
},
|
||||
'backend': {
|
||||
'frame_skip_ratio': BACKEND_FRAME_SKIP_RATIO,
|
||||
'max_frame_size': (MAX_FRAME_WIDTH, MAX_FRAME_HEIGHT),
|
||||
'max_memory_mb': MAX_BACKEND_MEMORY_MB,
|
||||
'memory_check_interval': BACKEND_MEMORY_CHECK_INTERVAL,
|
||||
'jpeg_quality': JPEG_QUALITY,
|
||||
'jpeg_progressive': JPEG_PROGRESSIVE,
|
||||
'jpeg_optimize': JPEG_OPTIMIZE,
|
||||
'queue_size': FRAME_QUEUE_SIZE,
|
||||
'max_frame_rate': MAX_FRAME_RATE
|
||||
},
|
||||
'monitoring': {
|
||||
'log_level': MEMORY_LOG_LEVEL,
|
||||
'enable_memory_logging': ENABLE_MEMORY_LOGGING,
|
||||
'enable_performance_monitoring': ENABLE_PERFORMANCE_MONITORING,
|
||||
'performance_log_interval': PERFORMANCE_LOG_INTERVAL
|
||||
},
|
||||
'adaptive': {
|
||||
'enable_adaptive_framerate': ENABLE_ADAPTIVE_FRAMERATE,
|
||||
'min_fps': MIN_FPS,
|
||||
'max_adaptive_fps': MAX_ADAPTIVE_FPS,
|
||||
'enable_adaptive_quality': ENABLE_ADAPTIVE_QUALITY,
|
||||
'min_jpeg_quality': MIN_JPEG_QUALITY,
|
||||
'max_jpeg_quality': MAX_JPEG_QUALITY,
|
||||
'memory_pressure_levels': MEMORY_PRESSURE_LEVELS
|
||||
}
|
||||
}
|
||||
|
||||
def apply_memory_config(config_dict):
|
||||
"""应用内存配置"""
|
||||
global MAX_FPS, FRAME_SKIP_THRESHOLD, MAX_MEMORY_USAGE_PERCENT
|
||||
global BACKEND_FRAME_SKIP_RATIO, MAX_FRAME_WIDTH, MAX_FRAME_HEIGHT
|
||||
global MAX_BACKEND_MEMORY_MB, JPEG_QUALITY
|
||||
|
||||
if 'frontend' in config_dict:
|
||||
frontend = config_dict['frontend']
|
||||
MAX_FPS = frontend.get('max_fps', MAX_FPS)
|
||||
FRAME_SKIP_THRESHOLD = frontend.get('frame_skip_threshold', FRAME_SKIP_THRESHOLD)
|
||||
MAX_MEMORY_USAGE_PERCENT = frontend.get('max_memory_percent', MAX_MEMORY_USAGE_PERCENT)
|
||||
|
||||
if 'backend' in config_dict:
|
||||
backend = config_dict['backend']
|
||||
BACKEND_FRAME_SKIP_RATIO = backend.get('frame_skip_ratio', BACKEND_FRAME_SKIP_RATIO)
|
||||
if 'max_frame_size' in backend:
|
||||
MAX_FRAME_WIDTH, MAX_FRAME_HEIGHT = backend['max_frame_size']
|
||||
MAX_BACKEND_MEMORY_MB = backend.get('max_memory_mb', MAX_BACKEND_MEMORY_MB)
|
||||
JPEG_QUALITY = backend.get('jpeg_quality', JPEG_QUALITY)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试配置
|
||||
config = get_memory_config()
|
||||
print("当前内存优化配置:")
|
||||
for category, settings in config.items():
|
||||
print(f"\n{category.upper()}:")
|
||||
for key, value in settings.items():
|
||||
print(f" {key}: {value}")
|
16
package.json
16
package.json
@ -61,15 +61,16 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^27.0.0",
|
||||
"electron-builder": "latest",
|
||||
"electron-packager": "^17.1.2",
|
||||
"concurrently": "^7.6.0",
|
||||
"cross-env": "^7.0.3"
|
||||
"electron-packager": "^17.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.5.0",
|
||||
"electron-log": "^4.4.8"
|
||||
"electron-log": "^4.4.8",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"config": {
|
||||
"backend_host": "0.0.0.0",
|
||||
@ -105,8 +106,8 @@
|
||||
"target": "nsis",
|
||||
"icon": "assets/icon.ico",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@ -115,5 +116,4 @@
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
222
test_screenshot.html
Normal file
222
test_screenshot.html
Normal file
@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>截图功能测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-area {
|
||||
border: 2px dashed #ccc;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: linear-gradient(45deg, #f0f0f0, #e0e0e0);
|
||||
text-align: center;
|
||||
}
|
||||
.button {
|
||||
background: linear-gradient(to right, rgb(236, 50, 166), rgb(160, 5, 216));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px;
|
||||
}
|
||||
.button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>截图功能测试页面</h1>
|
||||
|
||||
<div id="detectare" class="test-area">
|
||||
<h2>这是要截图的区域</h2>
|
||||
<p>患者ID: 2101</p>
|
||||
<p>患者姓名: 张三</p>
|
||||
<p>测试时间: <span id="currentTime"></span></p>
|
||||
<div style="display: flex; justify-content: space-around; margin: 20px 0;">
|
||||
<div style="background: #ff6b6b; color: white; padding: 20px; border-radius: 10px;">
|
||||
<h3>模块1</h3>
|
||||
<p>数据: 85%</p>
|
||||
</div>
|
||||
<div style="background: #4ecdc4; color: white; padding: 20px; border-radius: 10px;">
|
||||
<h3>模块2</h3>
|
||||
<p>数据: 92%</p>
|
||||
</div>
|
||||
<div style="background: #45b7d1; color: white; padding: 20px; border-radius: 10px;">
|
||||
<h3>模块3</h3>
|
||||
<p>数据: 78%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<button id="screenshotBtn" class="button">📸 截图测试</button>
|
||||
<button id="checkBackendBtn" class="button">🔍 检查后端</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h3>使用说明:</h3>
|
||||
<ol>
|
||||
<li>确保后端服务已启动 (python debug_server.py)</li>
|
||||
<li>点击"检查后端"按钮验证后端连接</li>
|
||||
<li>点击"截图测试"按钮进行截图</li>
|
||||
<li>截图将保存到 screenshots/2101_张三/ 文件夹中</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<script>
|
||||
const BACKEND_URL = 'http://localhost:5000';
|
||||
|
||||
// 更新当前时间
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
document.getElementById('currentTime').textContent = now.toLocaleString('zh-CN');
|
||||
}
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
|
||||
// 显示状态消息
|
||||
function showStatus(message, type = 'info') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
||||
}
|
||||
|
||||
// 检查后端连接
|
||||
async function checkBackend() {
|
||||
try {
|
||||
showStatus('正在检查后端连接...', 'info');
|
||||
const response = await fetch(`${BACKEND_URL}/health`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
showStatus('✅ 后端连接正常', 'success');
|
||||
} else {
|
||||
showStatus('❌ 后端响应异常', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus(`❌ 后端连接失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 截图功能
|
||||
async function takeScreenshot() {
|
||||
const btn = document.getElementById('screenshotBtn');
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '📸 截图中...';
|
||||
showStatus('正在生成截图...', 'info');
|
||||
|
||||
// 获取要截图的元素
|
||||
const element = document.getElementById('detectare');
|
||||
if (!element) {
|
||||
throw new Error('未找到截图区域');
|
||||
}
|
||||
|
||||
// 使用html2canvas进行截图
|
||||
const canvas = await html2canvas(element, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scale: 1,
|
||||
logging: false
|
||||
});
|
||||
|
||||
// 转换为base64
|
||||
const base64Image = canvas.toDataURL('image/png');
|
||||
|
||||
showStatus('正在保存截图...', 'info');
|
||||
|
||||
// 生成检查记录ID
|
||||
const now = new Date();
|
||||
const sessionId = now.getFullYear() +
|
||||
String(now.getMonth() + 1).padStart(2, '0') +
|
||||
String(now.getDate()).padStart(2, '0') +
|
||||
String(now.getHours()).padStart(2, '0') +
|
||||
String(now.getMinutes()).padStart(2, '0') +
|
||||
String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
// 调用后端API保存截图
|
||||
const response = await fetch(`${BACKEND_URL}/api/screenshots/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
patientId: '2101',
|
||||
patientName: '张三',
|
||||
sessionId: sessionId,
|
||||
imageData: base64Image
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showStatus(`✅ 截图保存成功!<br>文件路径: ${result.filepath}`, 'success');
|
||||
} else {
|
||||
throw new Error(result.message || '保存失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error);
|
||||
showStatus(`❌ 截图失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📸 截图测试';
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
document.getElementById('screenshotBtn').addEventListener('click', takeScreenshot);
|
||||
document.getElementById('checkBackendBtn').addEventListener('click', checkBackend);
|
||||
|
||||
// 页面加载时自动检查后端
|
||||
window.addEventListener('load', checkBackend);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user