视频播放、截图、录像功能提交

This commit is contained in:
zhaozilong12 2025-07-30 19:09:15 +08:00
parent 2da25a0437
commit 061f4b3f71
10 changed files with 1800 additions and 103 deletions

View 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 系统稳定性
---
如有其他问题,请查看项目文档或联系技术支持。

View File

@ -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):
"""异步编码帧"""
try:
# 优化图像尺寸:平衡质量和性能
height, width = frame.shape[:2]
if width > 480: # 适度压缩到480宽度保持更好的图像质量
scale = 480 / width
new_width = 480
new_height = int(height * scale)
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) # 使用更好的插值方法
"""异步编码帧 - 内存优化版本"""
global memory_check_counter
# 优化JPEG编码参数平衡质量和速度
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]
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)
# 使用更快的插值方法减少CPU使用
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_AREA)
# 优化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)
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')
socketio.emit('rtsp_frame', {'image': jpg_as_text, 'frame_id': frame_count})
# 立即释放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}')

View File

@ -5,7 +5,7 @@ debug = false
log_level = INFO
[SERVER]
host = 0.0.0.0
host = 127.0.0.1
port = 5000
cors_origins = *

View File

@ -40,6 +40,7 @@ pytest
pytest-cov
flake8
black
debugpy
# Other dependencies
pyyaml

View File

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

View File

@ -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 // 21
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)
// WebSocket
function connectWebSocket() {
try {
console.log('正在连接到', BACKEND_URL)
//
wsManager.on('connect', () => {
console.log('WebSocket连接成功')
wsManager.emit('start_rtsp', {})
// 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
wsManager.on('rtsp_frame', (data) => {
socket.on('rtsp_frame', (data) => {
if (data.image) {
rtspImgSrc.value = 'data:image/jpeg;base64,' + 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}`)
}
}
})
//
wsManager.on('connect_error', (error) => {
console.error('WebSocket连接失败:', error)
//
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
})
// canvasbase64
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)
//
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>

View File

@ -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');
try {
// 直接设置图像源避免创建额外的Image对象
const dataUrl = 'data:image/jpeg;base64,' + base64Image;
// 创建新的Image对象来预加载确保图像能正确显示
const newImg = new Image();
newImg.onload = function() {
// 清理之前的图像缓存
if (imageCache) {
imageCache.src = '';
imageCache = null;
}
// 使用requestAnimationFrame优化渲染
requestAnimationFrame(() => {
img.src = dataUrl;
img.style.display = 'block';
noVideo.style.display = 'none';
});
// 强制重绘
img.style.transform = 'scale(1.001)';
setTimeout(() => {
img.style.transform = 'scale(1)';
}, 1);
};
// 定期清理base64数据URL缓存
if (frameCount % 100 === 0) {
// 强制垃圾回收(如果浏览器支持)
if (window.gc) {
window.gc();
}
}
newImg.onerror = function() {
console.error('图像加载失败');
log('❌ 图像加载失败可能是base64数据损坏');
};
} catch (error) {
console.error('显示帧失败:', error);
log('❌ 显示帧失败: ' + error.message);
}
}
newImg.src = dataUrl;
// 保留原始函数作为备用
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
View 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}")

View File

@ -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",
@ -115,5 +116,4 @@
"createStartMenuShortcut": true
}
}
}

222
test_screenshot.html Normal file
View 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>