Merge branch 'main' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into main
This commit is contained in:
commit
bc3ffa474d
5
.gitignore
vendored
5
.gitignore
vendored
@ -11,6 +11,7 @@ frontend/src/renderer/node_modules/
|
|||||||
|
|
||||||
# Python 缓存文件
|
# Python 缓存文件
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
backend/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
@ -45,3 +46,7 @@ build/
|
|||||||
# 临时文件
|
# 临时文件
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
backend/__pycache__/app.cpython-311.pyc
|
||||||
|
backend/__pycache__/app.cpython-311.pyc
|
||||||
|
backend/__pycache__/database.cpython-311.pyc
|
||||||
|
backend/__pycache__/app.cpython-311.pyc
|
||||||
|
@ -107,7 +107,10 @@ def init_app():
|
|||||||
device_manager = DeviceManager(db_manager)
|
device_manager = DeviceManager(db_manager)
|
||||||
device_manager.set_socketio(socketio) # 设置WebSocket连接
|
device_manager.set_socketio(socketio) # 设置WebSocket连接
|
||||||
|
|
||||||
|
# 自动启动推流以填充帧缓存
|
||||||
|
if device_manager.device_status['camera']:
|
||||||
|
streaming_result = device_manager.start_streaming()
|
||||||
|
logger.info(f'自动启动推流结果: {streaming_result}')
|
||||||
|
|
||||||
# 初始化视频流管理器
|
# 初始化视频流管理器
|
||||||
video_stream_manager = VideoStreamManager(socketio)
|
video_stream_manager = VideoStreamManager(socketio)
|
||||||
@ -138,6 +141,31 @@ def api_health_check():
|
|||||||
'version': '1.0.0'
|
'version': '1.0.0'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route('/api/frame-cache/status', methods=['GET'])
|
||||||
|
def get_frame_cache_status():
|
||||||
|
"""获取帧缓存状态"""
|
||||||
|
try:
|
||||||
|
if device_manager:
|
||||||
|
cache_info = device_manager.get_frame_cache_info()
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': cache_info,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '设备管理器未初始化',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'获取帧缓存状态失败: {e}')
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}), 500
|
||||||
|
|
||||||
# ==================== 认证API ====================
|
# ==================== 认证API ====================
|
||||||
|
|
||||||
@app.route('/api/auth/login', methods=['POST'])
|
@app.route('/api/auth/login', methods=['POST'])
|
||||||
@ -594,6 +622,7 @@ def calibrate_imu():
|
|||||||
|
|
||||||
|
|
||||||
# ==================== 视频推流API ====================
|
# ==================== 视频推流API ====================
|
||||||
|
|
||||||
@app.route('/api/streaming/start', methods=['POST'])
|
@app.route('/api/streaming/start', methods=['POST'])
|
||||||
def start_video_streaming():
|
def start_video_streaming():
|
||||||
"""启动视频推流"""
|
"""启动视频推流"""
|
||||||
@ -694,21 +723,23 @@ def stop_detection(session_id):
|
|||||||
# video_data = data.get('videoData') if data else None
|
# video_data = data.get('videoData') if data else None
|
||||||
video_data = data['videoData']
|
video_data = data['videoData']
|
||||||
mime_type = data.get('mimeType', 'video/webm;codecs=vp9') # 默认webm格式
|
mime_type = data.get('mimeType', 'video/webm;codecs=vp9') # 默认webm格式
|
||||||
|
import base64
|
||||||
# 验证base64视频数据格式
|
# 验证base64视频数据格式
|
||||||
if not video_data.startswith('data:video/'):
|
if not video_data.startswith('data:video/'):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': '无效的视频数据格式'
|
'message': '无效的视频数据格式'
|
||||||
}), 400
|
}), 400
|
||||||
# try:
|
try:
|
||||||
# header, encoded = video_data.split(',', 1)
|
header, encoded = video_data.split(',', 1)
|
||||||
# video_bytes = base64.b64decode(encoded)
|
video_bytes = base64.b64decode(encoded)
|
||||||
# except Exception as e:
|
# with open(r'D:/111.webm', 'wb') as f:
|
||||||
# return jsonify({
|
# f.write(video_bytes)
|
||||||
# 'success': False,
|
except Exception as e:
|
||||||
# 'message': f'视频数据解码失败: {str(e)}'
|
return jsonify({
|
||||||
# }), 400
|
'success': False,
|
||||||
|
'message': f'视频数据解码失败: {str(e)}'
|
||||||
|
}), 400
|
||||||
# 停止同步录制,传递视频数据
|
# 停止同步录制,传递视频数据
|
||||||
try:
|
try:
|
||||||
logger.debug(f'调用device_manager.stop_recording,session_id: {session_id}, video_data长度: {len(video_data) if video_data else 0}')
|
logger.debug(f'调用device_manager.stop_recording,session_id: {session_id}, video_data长度: {len(video_data) if video_data else 0}')
|
||||||
@ -716,7 +747,7 @@ def stop_detection(session_id):
|
|||||||
logger.warning(f'视频数据为空,session_id: {session_id}')
|
logger.warning(f'视频数据为空,session_id: {session_id}')
|
||||||
else:
|
else:
|
||||||
logger.debug(f'视频数据长度: {len(video_data)} 字符,约 {len(video_data)*3/4/1024:.2f} KB, session_id: {session_id}')
|
logger.debug(f'视频数据长度: {len(video_data)} 字符,约 {len(video_data)*3/4/1024:.2f} KB, session_id: {session_id}')
|
||||||
restrt=device_manager.stop_recording(session_id, video_data_base64=video_data)
|
restrt=device_manager.stop_recording(session_id, video_data_base64=video_bytes)
|
||||||
logger.error(restrt)
|
logger.error(restrt)
|
||||||
except Exception as rec_e:
|
except Exception as rec_e:
|
||||||
logger.error(f'停止同步录制失败: {rec_e}', exc_info=True)
|
logger.error(f'停止同步录制失败: {rec_e}', exc_info=True)
|
||||||
@ -786,7 +817,7 @@ def collect_detection_data(session_id):
|
|||||||
# 获取请求数据
|
# 获取请求数据
|
||||||
data = flask_request.get_json() or {}
|
data = flask_request.get_json() or {}
|
||||||
patient_id = data.get('patient_id')
|
patient_id = data.get('patient_id')
|
||||||
screen_image_base64 = data.get('screen_image')
|
screen_image_base64 = data.get('imageData')
|
||||||
|
|
||||||
# 如果没有提供patient_id,从会话信息中获取
|
# 如果没有提供patient_id,从会话信息中获取
|
||||||
if not patient_id:
|
if not patient_id:
|
||||||
@ -871,14 +902,29 @@ def stop_sync_recording():
|
|||||||
data = flask_request.get_json()
|
data = flask_request.get_json()
|
||||||
session_id = data.get('session_id')
|
session_id = data.get('session_id')
|
||||||
video_data = data.get('videoData') # 新增接收前端传递的视频数据
|
video_data = data.get('videoData') # 新增接收前端传递的视频数据
|
||||||
|
if not video_data.startswith('data:video/'):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '无效的视频数据格式'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 提取base64数据
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
header, encoded = video_data.split(',', 1)
|
||||||
|
video_bytes = base64.b64decode(encoded)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'视频数据解码失败: {str(e)}'
|
||||||
|
}), 400
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': '缺少必要参数: session_id'
|
'error': '缺少必要参数: session_id'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
result = device_manager.stop_recording(session_id, video_data_base64=video_data)
|
result = device_manager.stop_recording(session_id, video_data_base64=video_bytes)
|
||||||
|
|
||||||
if result['success']:
|
if result['success']:
|
||||||
logger.info(f'同步录制已停止 - 会话ID: {session_id}')
|
logger.info(f'同步录制已停止 - 会话ID: {session_id}')
|
||||||
|
@ -31,7 +31,7 @@ from database import DatabaseManager
|
|||||||
try:
|
try:
|
||||||
import pykinect_azure as pykinect
|
import pykinect_azure as pykinect
|
||||||
# 重新启用FemtoBolt功能,使用正确的Orbbec SDK K4A Wrapper路径
|
# 重新启用FemtoBolt功能,使用正确的Orbbec SDK K4A Wrapper路径
|
||||||
FEMTOBOLT_AVAILABLE = False
|
FEMTOBOLT_AVAILABLE = True
|
||||||
print("信息: pykinect_azure库已安装,FemtoBolt深度相机功能已启用")
|
print("信息: pykinect_azure库已安装,FemtoBolt深度相机功能已启用")
|
||||||
print("使用Orbbec SDK K4A Wrapper以确保与FemtoBolt设备的兼容性")
|
print("使用Orbbec SDK K4A Wrapper以确保与FemtoBolt设备的兼容性")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -57,6 +57,7 @@ class DeviceManager:
|
|||||||
}
|
}
|
||||||
self.calibration_data = {}
|
self.calibration_data = {}
|
||||||
self.data_lock = threading.Lock()
|
self.data_lock = threading.Lock()
|
||||||
|
self.camera_lock = threading.Lock() # 摄像头访问锁
|
||||||
self.latest_data = {}
|
self.latest_data = {}
|
||||||
|
|
||||||
# 数据库连接
|
# 数据库连接
|
||||||
@ -69,6 +70,12 @@ class DeviceManager:
|
|||||||
self.femtobolt_streaming_thread = None
|
self.femtobolt_streaming_thread = None
|
||||||
self.streaming_stop_event = threading.Event()
|
self.streaming_stop_event = threading.Event()
|
||||||
|
|
||||||
|
# 全局帧缓存机制
|
||||||
|
self.frame_cache = {}
|
||||||
|
self.frame_cache_lock = threading.RLock() # 可重入锁
|
||||||
|
self.max_cache_size = 10 # 最大缓存帧数
|
||||||
|
self.cache_timeout = 5.0 # 缓存超时时间(秒)
|
||||||
|
|
||||||
# 同步录制状态
|
# 同步录制状态
|
||||||
self.sync_recording = False
|
self.sync_recording = False
|
||||||
self.current_session_id = None
|
self.current_session_id = None
|
||||||
@ -105,6 +112,8 @@ class DeviceManager:
|
|||||||
# 初始化设备
|
# 初始化设备
|
||||||
self._init_devices()
|
self._init_devices()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _init_devices(self):
|
def _init_devices(self):
|
||||||
"""初始化所有设备"""
|
"""初始化所有设备"""
|
||||||
try:
|
try:
|
||||||
@ -141,6 +150,8 @@ class DeviceManager:
|
|||||||
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||||
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||||
self.camera.set(cv2.CAP_PROP_FPS, 30)
|
self.camera.set(cv2.CAP_PROP_FPS, 30)
|
||||||
|
# 设置缓冲区大小为1,避免帧积累
|
||||||
|
self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
|
||||||
self.device_status['camera'] = True
|
self.device_status['camera'] = True
|
||||||
logger.info(f'摄像头初始化成功,设备索引: {device_index}')
|
logger.info(f'摄像头初始化成功,设备索引: {device_index}')
|
||||||
@ -334,9 +345,11 @@ class DeviceManager:
|
|||||||
"""刷新设备连接"""
|
"""刷新设备连接"""
|
||||||
logger.info('刷新设备连接...')
|
logger.info('刷新设备连接...')
|
||||||
|
|
||||||
# 重新初始化所有设备
|
# 使用锁保护摄像头重新初始化
|
||||||
if self.camera:
|
with self.camera_lock:
|
||||||
self.camera.release()
|
if self.camera:
|
||||||
|
self.camera.release()
|
||||||
|
self.camera = None
|
||||||
|
|
||||||
self._init_devices()
|
self._init_devices()
|
||||||
|
|
||||||
@ -484,6 +497,8 @@ class DeviceManager:
|
|||||||
return {'status': 'failed', 'error': str(e)}
|
return {'status': 'failed', 'error': str(e)}
|
||||||
|
|
||||||
def collect_data(self, session_id: str, patient_id: str, screen_image_base64: str = None) -> Dict[str, Any]:
|
def collect_data(self, session_id: str, patient_id: str, screen_image_base64: str = None) -> Dict[str, Any]:
|
||||||
|
# 实例化VideoStreamManager(VideoStreamManager类在同一文件中定义)
|
||||||
|
video_stream_manager = VideoStreamManager()
|
||||||
"""采集所有设备数据并保存到指定目录结构
|
"""采集所有设备数据并保存到指定目录结构
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -529,12 +544,15 @@ class DeviceManager:
|
|||||||
# data['body_pose'] = json.dumps(body_pose_data)
|
# data['body_pose'] = json.dumps(body_pose_data)
|
||||||
# logger.debug(f'身体姿态数据采集成功: {session_id}')
|
# logger.debug(f'身体姿态数据采集成功: {session_id}')
|
||||||
|
|
||||||
# # 3. 采集身体视频截图(从FemtoBolt深度相机获取)
|
# 3. 采集身体视频截图(从FemtoBolt深度相机获取)
|
||||||
# if self.device_status['femtobolt']:
|
if self.device_status['femtobolt']:
|
||||||
# body_image_path = self._capture_body_image(data_dir)
|
try:
|
||||||
# if body_image_path:
|
body_image_path = video_stream_manager._capture_body_image(data_dir, self)
|
||||||
# data['body_image'] = str(body_image_path)
|
if body_image_path:
|
||||||
# logger.debug(f'身体截图保存成功: {body_image_path}')
|
data['body_image'] = str(body_image_path)
|
||||||
|
logger.debug(f'身体截图保存成功: {body_image_path}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'调用_video_stream_manager._capture_body_image异常: {e}')
|
||||||
|
|
||||||
# # 4. 采集足部压力数据(从压力传感器获取)
|
# # 4. 采集足部压力数据(从压力传感器获取)
|
||||||
# if self.device_status['pressure']:
|
# if self.device_status['pressure']:
|
||||||
@ -543,12 +561,12 @@ class DeviceManager:
|
|||||||
# data['foot_data'] = json.dumps(foot_data)
|
# data['foot_data'] = json.dumps(foot_data)
|
||||||
# logger.debug(f'足部压力数据采集成功: {session_id}')
|
# logger.debug(f'足部压力数据采集成功: {session_id}')
|
||||||
|
|
||||||
# # 5. 采集足部监测视频截图(从摄像头获取)
|
# 5. 采集足部监测视频截图(从摄像头获取)
|
||||||
# if self.device_status['camera']:
|
if self.device_status['camera']:
|
||||||
# foot_image_path = self._capture_foot_image(data_dir)
|
foot_image_path = video_stream_manager._capture_foot_image(data_dir, self)
|
||||||
# if foot_image_path:
|
if foot_image_path:
|
||||||
# data['foot_image'] = str(foot_image_path)
|
data['foot_image'] = str(foot_image_path)
|
||||||
# logger.debug(f'足部截图保存成功: {foot_image_path}')
|
logger.debug(f'足部截图保存成功: {foot_image_path}')
|
||||||
|
|
||||||
# # 6. 生成足底压力数据图(从压力传感器数据生成)
|
# # 6. 生成足底压力数据图(从压力传感器数据生成)
|
||||||
# if self.device_status['pressure']:
|
# if self.device_status['pressure']:
|
||||||
@ -560,7 +578,7 @@ class DeviceManager:
|
|||||||
# 7. 保存屏幕录制截图(从前端传入的base64数据)
|
# 7. 保存屏幕录制截图(从前端传入的base64数据)
|
||||||
if screen_image_base64:
|
if screen_image_base64:
|
||||||
try:
|
try:
|
||||||
logger.debug(f'屏幕截图保存.................{screen_image_base64}')
|
# logger.debug(f'屏幕截图保存.................{screen_image_base64}')
|
||||||
# 保存屏幕截图的base64数据为图片文件
|
# 保存屏幕截图的base64数据为图片文件
|
||||||
screen_image_path = None
|
screen_image_path = None
|
||||||
if screen_image_base64:
|
if screen_image_base64:
|
||||||
@ -978,37 +996,88 @@ class DeviceManager:
|
|||||||
def _camera_streaming_thread(self):
|
def _camera_streaming_thread(self):
|
||||||
"""足部监视摄像头推流线程"""
|
"""足部监视摄像头推流线程"""
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
|
consecutive_failures = 0
|
||||||
|
max_consecutive_failures = 10
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.camera_streaming and not self.streaming_stop_event.is_set():
|
while self.camera_streaming and not self.streaming_stop_event.is_set():
|
||||||
if self.camera and self.camera.isOpened():
|
if self.camera:
|
||||||
ret, frame = self.camera.read()
|
# 使用摄像头锁避免与录制和截图功能冲突
|
||||||
if ret and self.socketio:
|
with self.camera_lock:
|
||||||
# 编码并推送帧
|
# 检查摄像头状态
|
||||||
try:
|
if not self.camera.isOpened():
|
||||||
# 调整帧大小以减少网络负载
|
logger.warning('推流线程检测到摄像头已关闭,尝试重新打开')
|
||||||
height, width = frame.shape[:2]
|
device_index = 0
|
||||||
if width > 640:
|
if self.db_manager:
|
||||||
scale = 640 / width
|
try:
|
||||||
new_width = 640
|
monitor_config = self.db_manager.get_system_setting('monitor_device_index')
|
||||||
new_height = int(height * scale)
|
if monitor_config:
|
||||||
frame = cv2.resize(frame, (new_width, new_height))
|
device_index = int(monitor_config)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# JPEG编码
|
self.camera.open(device_index)
|
||||||
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 80]
|
if self.camera.isOpened():
|
||||||
success, buffer = cv2.imencode('.jpg', frame, encode_param)
|
# 重新设置摄像头参数
|
||||||
|
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||||
|
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||||
|
self.camera.set(cv2.CAP_PROP_FPS, 30)
|
||||||
|
self.camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
logger.info('推流线程摄像头重新打开成功')
|
||||||
|
consecutive_failures = 0
|
||||||
|
else:
|
||||||
|
logger.error('推流线程摄像头重新打开失败')
|
||||||
|
consecutive_failures += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
if success:
|
ret, frame = self.camera.read()
|
||||||
jpg_as_text = base64.b64encode(buffer).decode('utf-8')
|
|
||||||
self.socketio.emit('video_frame', {
|
|
||||||
'image': jpg_as_text,
|
|
||||||
'frame_id': frame_count,
|
|
||||||
'timestamp': time.time()
|
|
||||||
})
|
|
||||||
frame_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
if ret and frame is not None:
|
||||||
logger.debug(f'摄像头帧推送失败: {e}')
|
# 保存原始帧到全局缓存
|
||||||
|
self._save_frame_to_cache(frame, 'camera')
|
||||||
|
|
||||||
|
if self.socketio:
|
||||||
|
# 编码并推送帧
|
||||||
|
try:
|
||||||
|
# 调整帧大小以减少网络负载
|
||||||
|
display_frame = frame.copy()
|
||||||
|
height, width = display_frame.shape[:2]
|
||||||
|
if width > 640:
|
||||||
|
scale = 640 / width
|
||||||
|
new_width = 640
|
||||||
|
new_height = int(height * scale)
|
||||||
|
display_frame = cv2.resize(display_frame, (new_width, new_height))
|
||||||
|
|
||||||
|
# JPEG编码
|
||||||
|
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 80]
|
||||||
|
success, buffer = cv2.imencode('.jpg', display_frame, encode_param)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
jpg_as_text = base64.b64encode(buffer).decode('utf-8')
|
||||||
|
self.socketio.emit('video_frame', {
|
||||||
|
'image': jpg_as_text,
|
||||||
|
'frame_id': frame_count,
|
||||||
|
'timestamp': time.time()
|
||||||
|
})
|
||||||
|
frame_count += 1
|
||||||
|
consecutive_failures = 0 # 重置失败计数
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
consecutive_failures += 1
|
||||||
|
if consecutive_failures <= 3:
|
||||||
|
logger.debug(f'摄像头帧推送失败 (连续失败{consecutive_failures}次): {e}')
|
||||||
|
else:
|
||||||
|
consecutive_failures += 1
|
||||||
|
if consecutive_failures <= 3:
|
||||||
|
logger.warning(f"推流线程无法从足部摄像头获取帧 (连续失败{consecutive_failures}次)")
|
||||||
|
|
||||||
|
elif consecutive_failures == max_consecutive_failures:
|
||||||
|
logger.error(f"推流线程足部摄像头连续失败{max_consecutive_failures}次,可能需要重启设备")
|
||||||
|
|
||||||
|
time.sleep(0.1) # 短暂等待
|
||||||
|
else:
|
||||||
|
time.sleep(0.1) # 摄像头不可用时等待
|
||||||
|
|
||||||
# 控制帧率
|
# 控制帧率
|
||||||
# time.sleep(1/30) # 30 FPS
|
# time.sleep(1/30) # 30 FPS
|
||||||
@ -1388,7 +1457,7 @@ class DeviceManager:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def stop_recording(self, session_id: str, video_data_base64: str = None) -> Dict[str, Any]:
|
def stop_recording(self, session_id: str, video_data_base64) -> Dict[str, Any]:
|
||||||
"""停止同步录制
|
"""停止同步录制
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1424,7 +1493,7 @@ class DeviceManager:
|
|||||||
# 定义视频文件路径
|
# 定义视频文件路径
|
||||||
feet_video_path = os.path.join(base_path, 'feet.mp4')
|
feet_video_path = os.path.join(base_path, 'feet.mp4')
|
||||||
body_video_path = os.path.join(base_path, 'body.mp4')
|
body_video_path = os.path.join(base_path, 'body.mp4')
|
||||||
screen_video_path = os.path.join(base_path, 'screen.mp4')
|
screen_video_path = os.path.join(base_path, 'screen.webm')
|
||||||
|
|
||||||
# 等待录制线程结束
|
# 等待录制线程结束
|
||||||
threads_to_join = [
|
threads_to_join = [
|
||||||
@ -1445,19 +1514,24 @@ class DeviceManager:
|
|||||||
|
|
||||||
# 清理视频写入器并收集文件信息
|
# 清理视频写入器并收集文件信息
|
||||||
video_files = self._cleanup_video_writers()
|
video_files = self._cleanup_video_writers()
|
||||||
|
|
||||||
# 保存传入的屏幕录制视频数据,替代原有屏幕录制视频保存逻辑
|
# 保存传入的屏幕录制视频数据,替代原有屏幕录制视频保存逻辑
|
||||||
if video_data_base64:
|
# video_bytes = base64.b64decode(video_data_base64)
|
||||||
try:
|
with open(screen_video_path, 'wb') as f:
|
||||||
video_bytes = base64.b64decode(video_data_base64)
|
f.write(video_data_base64)
|
||||||
with open(screen_video_path, 'wb') as f:
|
video_files.append(screen_video_path)
|
||||||
f.write(video_bytes)
|
logger.info(f'屏幕录制视频保存成功,路径: {screen_video_path}, 文件大小: {os.path.getsize(screen_video_path)} 字节')
|
||||||
video_files.append(screen_video_path)
|
# # 保存传入的屏幕录制视频数据,替代原有屏幕录制视频保存逻辑
|
||||||
logger.info(f'屏幕录制视频保存成功,路径: {screen_video_path}, 文件大小: {os.path.getsize(screen_video_path)} 字节')
|
# if video_data_base64:
|
||||||
except Exception as e:
|
# try:
|
||||||
logger.error(f'保存屏幕录制视频失败: {e}', exc_info=True)
|
# # video_bytes = base64.b64decode(video_data_base64)
|
||||||
logger.debug(f'视频数据长度: {len(video_data_base64)}')
|
# with open(screen_video_path, 'wb') as f:
|
||||||
raise
|
# f.write(video_data_base64)
|
||||||
|
# video_files.append(screen_video_path)
|
||||||
|
# logger.info(f'屏幕录制视频保存成功,路径: {screen_video_path}, 文件大小: {os.path.getsize(screen_video_path)} 字节')
|
||||||
|
# except Exception as e:
|
||||||
|
# logger.error(f'保存屏幕录制视频失败: {e}', exc_info=True)
|
||||||
|
# logger.debug(f'视频数据长度: {len(video_data_base64)}')
|
||||||
|
# raise
|
||||||
|
|
||||||
result['video_files'] = video_files
|
result['video_files'] = video_files
|
||||||
|
|
||||||
@ -1507,12 +1581,35 @@ class DeviceManager:
|
|||||||
|
|
||||||
def _feet_recording_thread(self):
|
def _feet_recording_thread(self):
|
||||||
"""足部视频录制线程"""
|
"""足部视频录制线程"""
|
||||||
|
consecutive_failures = 0
|
||||||
|
max_consecutive_failures = 10
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.sync_recording and not self.recording_stop_event.is_set():
|
while self.sync_recording and not self.recording_stop_event.is_set():
|
||||||
if self.camera and self.camera.isOpened() and self.feet_video_writer:
|
if self.feet_video_writer:
|
||||||
ret, frame = self.camera.read()
|
# 从全局缓存获取最新帧
|
||||||
if ret:
|
frame, frame_timestamp = self._get_latest_frame_from_cache('camera')
|
||||||
|
|
||||||
|
if frame is not None:
|
||||||
|
# 写入录制文件
|
||||||
self.feet_video_writer.write(frame)
|
self.feet_video_writer.write(frame)
|
||||||
|
consecutive_failures = 0 # 重置失败计数
|
||||||
|
|
||||||
|
# 记录录制统计
|
||||||
|
if hasattr(self, 'recording_frame_count'):
|
||||||
|
self.recording_frame_count += 1
|
||||||
|
else:
|
||||||
|
self.recording_frame_count = 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
consecutive_failures += 1
|
||||||
|
if consecutive_failures <= 3:
|
||||||
|
logger.warning(f"录制线程无法从缓存获取帧 (连续失败{consecutive_failures}次)")
|
||||||
|
elif consecutive_failures == max_consecutive_failures:
|
||||||
|
logger.error(f"录制线程连续失败{max_consecutive_failures}次,可能缓存无数据或推流已停止")
|
||||||
|
|
||||||
|
# 等待一段时间再重试
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
time.sleep(1/30) # 30 FPS
|
time.sleep(1/30) # 30 FPS
|
||||||
|
|
||||||
@ -1606,6 +1703,13 @@ class DeviceManager:
|
|||||||
|
|
||||||
return video_files
|
return video_files
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""析构函数,确保资源被正确释放"""
|
||||||
|
try:
|
||||||
|
self.cleanup()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'析构函数清理资源失败: {e}')
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""清理资源"""
|
"""清理资源"""
|
||||||
try:
|
try:
|
||||||
@ -1616,8 +1720,14 @@ class DeviceManager:
|
|||||||
if self.sync_recording:
|
if self.sync_recording:
|
||||||
self.stop_recording(self.current_session_id)
|
self.stop_recording(self.current_session_id)
|
||||||
|
|
||||||
if self.camera:
|
|
||||||
self.camera.release()
|
|
||||||
|
# 使用锁保护摄像头释放
|
||||||
|
with self.camera_lock:
|
||||||
|
if self.camera:
|
||||||
|
self.camera.release()
|
||||||
|
self.camera = None
|
||||||
|
logger.debug('摄像头资源已释放')
|
||||||
|
|
||||||
if hasattr(self, 'video_writer') and self.video_writer:
|
if hasattr(self, 'video_writer') and self.video_writer:
|
||||||
self.video_writer.release()
|
self.video_writer.release()
|
||||||
@ -1640,6 +1750,14 @@ class DeviceManager:
|
|||||||
if self.femtobolt_camera:
|
if self.femtobolt_camera:
|
||||||
self.femtobolt_camera = None
|
self.femtobolt_camera = None
|
||||||
|
|
||||||
|
# 清理帧缓存
|
||||||
|
try:
|
||||||
|
with self.frame_cache_lock:
|
||||||
|
self.frame_cache.clear()
|
||||||
|
logger.debug('帧缓存已清理')
|
||||||
|
except Exception as cache_error:
|
||||||
|
logger.error(f'清理帧缓存失败: {cache_error}')
|
||||||
|
|
||||||
logger.debug('设备资源已清理')
|
logger.debug('设备资源已清理')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -2197,18 +2315,112 @@ class VideoStreamManager:
|
|||||||
logger.error(f'身体姿态数据采集失败: {e}')
|
logger.error(f'身体姿态数据采集失败: {e}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _capture_body_image(self, data_dir: Path) -> Optional[str]:
|
def _capture_body_image(self, data_dir: Path, device_manager) -> Optional[str]:
|
||||||
"""采集身体视频截图(从FemtoBolt深度相机获取)"""
|
"""采集身体视频截图(从FemtoBolt深度相机获取)"""
|
||||||
try:
|
try:
|
||||||
# 模拟从FemtoBolt深度相机获取图像
|
image = None
|
||||||
# 实际实现中应该从深度相机获取真实图像
|
|
||||||
image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
|
# 检查是否有device_manager实例且FemtoBolt深度相机可用
|
||||||
|
if (device_manager is not None and
|
||||||
|
FEMTOBOLT_AVAILABLE and
|
||||||
|
hasattr(device_manager, 'femtobolt_camera') and
|
||||||
|
device_manager.femtobolt_camera is not None):
|
||||||
|
|
||||||
|
# 从FemtoBolt深度相机获取真实图像
|
||||||
|
logger.info('正在从FemtoBolt深度相机获取身体图像...')
|
||||||
|
capture = device_manager.femtobolt_camera.update()
|
||||||
|
|
||||||
|
if capture is not None:
|
||||||
|
# 获取深度图像
|
||||||
|
ret, depth_image = capture.get_depth_image()
|
||||||
|
if ret and depth_image is not None:
|
||||||
|
# 读取config.ini中的深度范围配置
|
||||||
|
import configparser
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read('config.ini')
|
||||||
|
try:
|
||||||
|
depth_range_min = int(config.get('DEFAULT', 'femtobolt_depth_range_min', fallback='1400'))
|
||||||
|
depth_range_max = int(config.get('DEFAULT', 'femtobolt_depth_range_max', fallback='1900'))
|
||||||
|
except Exception:
|
||||||
|
depth_range_min = None
|
||||||
|
depth_range_max = None
|
||||||
|
|
||||||
|
# 优化深度图彩色映射,范围外用黑色,区间内用Jet模型从蓝色到黄色到红色渐变
|
||||||
|
if depth_range_min is not None and depth_range_max is not None:
|
||||||
|
# 归一化深度值到0-255范围
|
||||||
|
depth_normalized = np.clip(depth_image, depth_range_min, depth_range_max)
|
||||||
|
depth_normalized = ((depth_normalized - depth_range_min) / (depth_range_max - depth_range_min) * 255).astype(np.uint8)
|
||||||
|
|
||||||
|
# 应用OpenCV的COLORMAP_JET进行伪彩色映射
|
||||||
|
depth_colored = cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET)
|
||||||
|
|
||||||
|
# 范围外用黑色
|
||||||
|
mask_outside = (depth_image < depth_range_min) | (depth_image > depth_range_max)
|
||||||
|
depth_colored[mask_outside] = [0, 0, 0] # BGR黑色
|
||||||
|
else:
|
||||||
|
# 如果没有配置,使用默认伪彩色映射
|
||||||
|
depth_colored = cv2.convertScaleAbs(depth_image, alpha=0.03)
|
||||||
|
depth_colored = cv2.applyColorMap(depth_colored, cv2.COLORMAP_JET)
|
||||||
|
|
||||||
|
# 转换颜色格式(如果需要)
|
||||||
|
if len(depth_colored.shape) == 3 and depth_colored.shape[2] == 4:
|
||||||
|
depth_colored = cv2.cvtColor(depth_colored, cv2.COLOR_BGRA2BGR)
|
||||||
|
elif len(depth_colored.shape) == 3 and depth_colored.shape[2] == 3:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 预处理:裁剪成宽460,高819,保持高度不裁剪,宽度从中间裁剪
|
||||||
|
height, width = depth_colored.shape[:2]
|
||||||
|
target_width = 460
|
||||||
|
target_height = 819
|
||||||
|
|
||||||
|
# 计算宽度裁剪起点
|
||||||
|
if width > target_width:
|
||||||
|
left = (width - target_width) // 2
|
||||||
|
right = left + target_width
|
||||||
|
cropped_image = depth_colored[:, left:right]
|
||||||
|
else:
|
||||||
|
cropped_image = depth_colored
|
||||||
|
|
||||||
|
# 如果高度不足target_height,进行上下填充黑边
|
||||||
|
cropped_height = cropped_image.shape[0]
|
||||||
|
if cropped_height < target_height:
|
||||||
|
pad_top = (target_height - cropped_height) // 2
|
||||||
|
pad_bottom = target_height - cropped_height - pad_top
|
||||||
|
cropped_image = cv2.copyMakeBorder(cropped_image, pad_top, pad_bottom, 0, 0, cv2.BORDER_CONSTANT, value=[0,0,0])
|
||||||
|
elif cropped_height > target_height:
|
||||||
|
# 如果高度超过target_height,裁剪高度中间部分
|
||||||
|
top = (cropped_height - target_height) // 2
|
||||||
|
cropped_image = cropped_image[top:top+target_height, :]
|
||||||
|
|
||||||
|
# 最终调整大小,保持宽460,高819
|
||||||
|
image = cv2.resize(cropped_image, (target_width, target_height))
|
||||||
|
|
||||||
|
logger.info(f'成功获取FemtoBolt深度图像,尺寸: {image.shape}')
|
||||||
|
else:
|
||||||
|
logger.warning('无法从FemtoBolt获取深度图像,使用模拟图像')
|
||||||
|
# 使用模拟图像作为备用
|
||||||
|
image = np.zeros((819, 460, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (410, 769), (0, 255, 0), 2)
|
||||||
|
cv2.putText(image, 'FemtoBolt Unavailable', (75, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
else:
|
||||||
|
logger.warning('FemtoBolt capture为None,使用模拟图像')
|
||||||
|
# 使用模拟图像作为备用
|
||||||
|
image = np.zeros((819, 460, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (410, 769), (0, 255, 0), 2)
|
||||||
|
cv2.putText(image, 'Capture Failed', (120, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
else:
|
||||||
|
logger.warning('FemtoBolt深度相机不可用,使用模拟图像')
|
||||||
|
# 使用模拟图像作为备用
|
||||||
|
image = np.zeros((819, 460, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (410, 769), (0, 255, 0), 2)
|
||||||
|
cv2.putText(image, 'Camera Not Available', (60, 400), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
|
||||||
# 保存图片
|
# 保存图片
|
||||||
image_path = data_dir / 'body_image.jpg'
|
image_path = data_dir / 'body_image.jpg'
|
||||||
cv2.imwrite(str(image_path), image)
|
cv2.imwrite(str(image_path), image)
|
||||||
|
logger.info(f'身体图像已保存到: {image_path}')
|
||||||
|
|
||||||
return str(image_path.relative_to(Path.cwd()))
|
return image_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'身体截图保存失败: {e}')
|
logger.error(f'身体截图保存失败: {e}')
|
||||||
return None
|
return None
|
||||||
@ -2251,26 +2463,54 @@ class VideoStreamManager:
|
|||||||
logger.error(f'足部压力数据采集失败: {e}')
|
logger.error(f'足部压力数据采集失败: {e}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _capture_foot_image(self, data_dir: Path) -> Optional[str]:
|
def _capture_foot_image(self, data_dir: Path, device_manager) -> Optional[str]:
|
||||||
"""采集足部监测视频截图(从摄像头获取)"""
|
"""采集足部监测视频截图(从全局缓存获取)"""
|
||||||
try:
|
try:
|
||||||
if self.camera is not None:
|
image = None
|
||||||
ret, frame = self.camera.read()
|
|
||||||
if ret:
|
|
||||||
# 保存图片
|
|
||||||
image_path = data_dir / 'foot_image.jpg'
|
|
||||||
cv2.imwrite(str(image_path), frame)
|
|
||||||
return str(image_path.relative_to(Path.cwd()))
|
|
||||||
|
|
||||||
# 如果摄像头不可用,生成模拟图像
|
# 检查是否有device_manager实例
|
||||||
image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
|
if device_manager is not None:
|
||||||
|
logger.info('正在从全局缓存获取最新图像...')
|
||||||
|
|
||||||
|
# 从全局缓存获取最新帧
|
||||||
|
frame, frame_timestamp = device_manager._get_latest_frame_from_cache('camera')
|
||||||
|
|
||||||
|
if frame is not None:
|
||||||
|
# 使用缓存中的图像
|
||||||
|
image = frame.copy() # 复制帧数据避免引用问题
|
||||||
|
current_time = time.time()
|
||||||
|
frame_age = current_time - frame_timestamp if frame_timestamp else 0
|
||||||
|
logger.info(f'成功获取缓存图像,尺寸: {image.shape},帧龄: {frame_age:.2f}秒')
|
||||||
|
else:
|
||||||
|
logger.warning('缓存中无可用图像,使用模拟图像')
|
||||||
|
image = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (590, 430), (0, 255, 0), 2)
|
||||||
|
cv2.putText(image, 'No Cached Frame', (120, 250), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
else:
|
||||||
|
logger.warning('设备管理器不可用,使用模拟图像')
|
||||||
|
# 使用模拟图像作为备用
|
||||||
|
image = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (590, 430), (0, 255, 0), 2)
|
||||||
|
cv2.putText(image, 'Device Manager N/A', (100, 250), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
|
||||||
|
# 保存图片
|
||||||
image_path = data_dir / 'foot_image.jpg'
|
image_path = data_dir / 'foot_image.jpg'
|
||||||
cv2.imwrite(str(image_path), image)
|
cv2.imwrite(str(image_path), image)
|
||||||
|
logger.info(f'足部图像已保存到: {image_path}')
|
||||||
|
|
||||||
return str(image_path.relative_to(Path.cwd()))
|
return image_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'足部截图保存失败: {e}')
|
logger.error(f'足部截图保存失败: {e}')
|
||||||
return None
|
# 即使出错也要保存一个模拟图像
|
||||||
|
try:
|
||||||
|
image = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||||
|
cv2.rectangle(image, (50, 50), (590, 430), (255, 0, 0), 2)
|
||||||
|
cv2.putText(image, 'Error Occurred', (180, 250), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||||
|
image_path = data_dir / 'foot_image.jpg'
|
||||||
|
cv2.imwrite(str(image_path), image)
|
||||||
|
return image_path
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _generate_foot_pressure_image(self, data_dir: Path) -> Optional[str]:
|
def _generate_foot_pressure_image(self, data_dir: Path) -> Optional[str]:
|
||||||
"""生成足底压力数据图(从压力传感器数据生成)"""
|
"""生成足底压力数据图(从压力传感器数据生成)"""
|
||||||
|
@ -16,7 +16,7 @@ api.interceptors.request.use(
|
|||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
config.baseURL = window.electronAPI.getBackendUrl()
|
config.baseURL = window.electronAPI.getBackendUrl()
|
||||||
} else {
|
} else {
|
||||||
config.baseURL = 'http://192.168.1.58:5000'
|
config.baseURL = 'http://192.168.1.173:5000'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加时间戳防止缓存
|
// 添加时间戳防止缓存
|
||||||
@ -599,7 +599,7 @@ export const getBackendUrl = () => {
|
|||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
return window.electronAPI.getBackendUrl()
|
return window.electronAPI.getBackendUrl()
|
||||||
} else {
|
} else {
|
||||||
return 'http://192.168.1.58:5000'
|
return 'http://192.168.1.173:5000'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user