修改了代码和bug
This commit is contained in:
parent
900ca4dd2c
commit
22e6a3f48a
@ -19,7 +19,7 @@ path = D:/BodyCheck/file/
|
|||||||
|
|
||||||
[CAMERA1]
|
[CAMERA1]
|
||||||
enabled = True
|
enabled = True
|
||||||
device_index = 0
|
device_index = 2
|
||||||
width = 1280
|
width = 1280
|
||||||
height = 720
|
height = 720
|
||||||
fps = 30
|
fps = 30
|
||||||
@ -29,7 +29,7 @@ backend = directshow
|
|||||||
|
|
||||||
[CAMERA2]
|
[CAMERA2]
|
||||||
enabled = True
|
enabled = True
|
||||||
device_index = 1
|
device_index = 0
|
||||||
width = 1280
|
width = 1280
|
||||||
height = 720
|
height = 720
|
||||||
fps = 30
|
fps = 30
|
||||||
@ -50,12 +50,12 @@ synchronized_images_only = False
|
|||||||
|
|
||||||
[DEVICES]
|
[DEVICES]
|
||||||
imu_enabled = True
|
imu_enabled = True
|
||||||
imu_device_type = ble
|
imu_device_type = mock
|
||||||
imu_port = COM9
|
imu_port = COM9
|
||||||
imu_mac_address = ef:3c:1a:0a:fe:02
|
imu_mac_address = ef:3c:1a:0a:fe:02
|
||||||
imu_baudrate = 9600
|
imu_baudrate = 9600
|
||||||
pressure_enabled = True
|
pressure_enabled = True
|
||||||
pressure_device_type = real
|
pressure_device_type = mock
|
||||||
pressure_use_mock = False
|
pressure_use_mock = False
|
||||||
pressure_port = COM5
|
pressure_port = COM5
|
||||||
pressure_baudrate = 115200
|
pressure_baudrate = 115200
|
||||||
|
|||||||
@ -311,7 +311,7 @@ class BaseDevice(ABC):
|
|||||||
try:
|
try:
|
||||||
# 检查硬件连接状态
|
# 检查硬件连接状态
|
||||||
hardware_connected = self.check_hardware_connection()
|
hardware_connected = self.check_hardware_connection()
|
||||||
self.logger.info(f"检测到设备 {self.device_name} 硬件连接状态: {hardware_connected} is_connected:{self.is_connected}")
|
# self.logger.info(f"检测到设备 {self.device_name} 硬件连接状态: {hardware_connected} is_connected:{self.is_connected}")
|
||||||
# 如果硬件断开但软件状态仍为连接,则更新状态
|
# 如果硬件断开但软件状态仍为连接,则更新状态
|
||||||
if not hardware_connected and self.is_connected:
|
if not hardware_connected and self.is_connected:
|
||||||
self.logger.warning(f"检测到设备 {self.device_name} 硬件连接断开")
|
self.logger.warning(f"检测到设备 {self.device_name} 硬件连接断开")
|
||||||
|
|||||||
@ -4,201 +4,25 @@
|
|||||||
IMU传感器管理器
|
IMU传感器管理器
|
||||||
负责IMU传感器的连接、校准和头部姿态数据采集
|
负责IMU传感器的连接、校准和头部姿态数据采集
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import serial
|
import serial
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import json
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import struct
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .base_device import BaseDevice
|
from .base_device import BaseDevice
|
||||||
from .utils.socket_manager import SocketManager
|
|
||||||
from .utils.config_manager import ConfigManager
|
from .utils.config_manager import ConfigManager
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from base_device import BaseDevice
|
from base_device import BaseDevice
|
||||||
from utils.socket_manager import SocketManager
|
|
||||||
from utils.config_manager import ConfigManager
|
from utils.config_manager import ConfigManager
|
||||||
|
|
||||||
# 设置日志
|
# 设置日志
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RealIMUDevice:
|
|
||||||
"""真实IMU设备,通过串口读取姿态数据"""
|
|
||||||
def __init__(self, port, baudrate):
|
|
||||||
# 串口通信配置
|
|
||||||
self.port = port # 串口端口号(如COM3、/dev/ttyUSB0等)
|
|
||||||
self.baudrate = baudrate # 波特率,通常为9600或115200
|
|
||||||
self.ser = None # 串口连接对象,初始为空
|
|
||||||
|
|
||||||
# 数据缓冲区和校准相关
|
|
||||||
self.buffer = bytearray() # 接收数据的缓冲区
|
|
||||||
self.calibration_data = None # 校准数据,用于修正传感器偏差
|
|
||||||
|
|
||||||
# 头部姿态偏移量,用于校准初始姿态
|
|
||||||
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
|
|
||||||
|
|
||||||
# 最后一次读取的IMU数据
|
|
||||||
self.last_data = {
|
|
||||||
'roll': 0.0, # 横滚角(绕X轴旋转)
|
|
||||||
'pitch': 0.0, # 俯仰角(绕Y轴旋转)
|
|
||||||
'yaw': 0.0, # 偏航角(绕Z轴旋转)
|
|
||||||
'temperature': 25.0 # 传感器温度
|
|
||||||
}
|
|
||||||
logger.debug(f'RealIMUDevice 初始化: port={self.port}, baudrate={self.baudrate}')
|
|
||||||
self._connect()
|
|
||||||
|
|
||||||
def _connect(self):
|
|
||||||
try:
|
|
||||||
logger.debug(f'尝试打开串口: {self.port} @ {self.baudrate}')
|
|
||||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
|
|
||||||
if hasattr(self.ser, 'reset_input_buffer'):
|
|
||||||
try:
|
|
||||||
self.ser.reset_input_buffer()
|
|
||||||
logger.debug('已清空串口输入缓冲区')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f'重置串口输入缓冲区失败: {e}')
|
|
||||||
logger.info(f'IMU设备连接成功: {self.port} @ {self.baudrate}bps')
|
|
||||||
except Exception as e:
|
|
||||||
# logger.error(f'IMU设备连接失败: {e}', exc_info=True)
|
|
||||||
self.ser = None
|
|
||||||
|
|
||||||
def set_calibration(self, calibration: Dict[str, Any]):
|
|
||||||
self.calibration_data = calibration
|
|
||||||
if 'head_pose_offset' in calibration:
|
|
||||||
self.head_pose_offset = calibration['head_pose_offset']
|
|
||||||
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""应用校准:将当前姿态减去初始偏移,得到相对于初始姿态的变化量"""
|
|
||||||
if not raw_data or 'head_pose' not in raw_data:
|
|
||||||
return raw_data
|
|
||||||
|
|
||||||
# 应用校准偏移
|
|
||||||
calibrated_data = raw_data.copy()
|
|
||||||
head_pose = raw_data['head_pose'].copy()
|
|
||||||
angle=head_pose['rotation'] - self.head_pose_offset['rotation']
|
|
||||||
# 减去基准值(零点偏移)
|
|
||||||
head_pose['rotation'] = round(((angle + 180) % 360) - 180, 1)
|
|
||||||
head_pose['tilt'] = head_pose['tilt'] - self.head_pose_offset['tilt']
|
|
||||||
head_pose['pitch'] = head_pose['pitch'] - self.head_pose_offset['pitch']
|
|
||||||
|
|
||||||
calibrated_data['head_pose'] = head_pose
|
|
||||||
return calibrated_data
|
|
||||||
@staticmethod
|
|
||||||
def _checksum(data: bytes) -> int:
|
|
||||||
return sum(data[:-1]) & 0xFF
|
|
||||||
|
|
||||||
def _parse_packet(self, data: bytes) -> Optional[Dict[str, float]]:
|
|
||||||
if len(data) != 11:
|
|
||||||
logger.debug(f'无效数据包长度: {len(data)}')
|
|
||||||
return None
|
|
||||||
if data[0] != 0x55:
|
|
||||||
logger.debug(f'错误的包头: 0x{data[0]:02X}')
|
|
||||||
return None
|
|
||||||
if self._checksum(data) != data[-1]:
|
|
||||||
logger.debug(f'校验和错误: 期望{self._checksum(data):02X}, 实际{data[-1]:02X}')
|
|
||||||
return None
|
|
||||||
packet_type = data[1]
|
|
||||||
vals = [int.from_bytes(data[i:i+2], 'little', signed=True) for i in range(2, 10, 2)]
|
|
||||||
if packet_type == 0x53: # 姿态角,单位0.01°
|
|
||||||
pitchl, rxl, yawl, temp = vals # 注意这里 vals 已经是有符号整数
|
|
||||||
# 使用第一段代码里的比例系数
|
|
||||||
k_angle = 180.0
|
|
||||||
roll = -round(rxl / 32768.0 * k_angle,1)
|
|
||||||
pitch = -round(pitchl / 32768.0 * k_angle,1)
|
|
||||||
yaw = -round(yawl / 32768.0 * k_angle,1)
|
|
||||||
temp = temp / 100.0
|
|
||||||
self.last_data = {
|
|
||||||
'roll': roll,
|
|
||||||
'pitch': pitch,
|
|
||||||
'yaw': yaw,
|
|
||||||
'temperature': temp
|
|
||||||
}
|
|
||||||
# print(f'解析姿态角包: roll={roll}, pitch={pitch}, yaw={yaw}, temp={temp}')
|
|
||||||
return self.last_data
|
|
||||||
else:
|
|
||||||
# logger.debug(f'忽略的数据包类型: 0x{packet_type:02X}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
|
|
||||||
if not self.ser or not getattr(self.ser, 'is_open', False):
|
|
||||||
# logger.warning('IMU串口未连接,尝试重新连接...')
|
|
||||||
self._connect()
|
|
||||||
return {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
bytes_waiting = self.ser.in_waiting
|
|
||||||
if bytes_waiting:
|
|
||||||
# logger.debug(f'串口缓冲区待读字节: {bytes_waiting}')
|
|
||||||
chunk = self.ser.read(bytes_waiting)
|
|
||||||
# logger.debug(f'读取到字节: {len(chunk)}')
|
|
||||||
self.buffer.extend(chunk)
|
|
||||||
while len(self.buffer) >= 11:
|
|
||||||
if self.buffer[0] != 0x55:
|
|
||||||
dropped = self.buffer.pop(0)
|
|
||||||
logger.debug(f'丢弃无效字节: 0x{dropped:02X}')
|
|
||||||
continue
|
|
||||||
packet = bytes(self.buffer[:11])
|
|
||||||
parsed = self._parse_packet(packet)
|
|
||||||
del self.buffer[:11]
|
|
||||||
if parsed is not None:
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': parsed['yaw'], # rotation = roll
|
|
||||||
'tilt': parsed['roll'], # tilt = yaw
|
|
||||||
'pitch': parsed['pitch'] # pitch = pitch
|
|
||||||
},
|
|
||||||
'temperature': parsed['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
# logger.debug(f'映射后的头部姿态: {raw}')
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'IMU数据读取异常: {e}', exc_info=True)
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
if self.ser and getattr(self.ser, 'is_open', False):
|
|
||||||
self.ser.close()
|
|
||||||
logger.info('IMU设备串口已关闭')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BleIMUDevice:
|
class BleIMUDevice:
|
||||||
"""蓝牙IMU设备,基于bleak实现,解析逻辑参考tests/testblueimu.py"""
|
"""蓝牙IMU设备,基于bleak实现,解析逻辑参考tests/testblueimu.py"""
|
||||||
def __init__(self, mac_address: str):
|
def __init__(self, mac_address: str):
|
||||||
@ -445,6 +269,101 @@ class BleIMUDevice:
|
|||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
class MockIMUDevice:
|
||||||
|
def __init__(self):
|
||||||
|
self.running = False
|
||||||
|
self.thread = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._connected = False
|
||||||
|
self.calibration_data = None
|
||||||
|
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
|
||||||
|
self.last_data = {
|
||||||
|
'roll': 0.0,
|
||||||
|
'pitch': 0.0,
|
||||||
|
'yaw': 0.0,
|
||||||
|
'temperature': 25.0
|
||||||
|
}
|
||||||
|
self._phase = 0.0
|
||||||
|
|
||||||
|
def set_calibration(self, calibration: Dict[str, Any]):
|
||||||
|
self.calibration_data = calibration
|
||||||
|
if 'head_pose_offset' in calibration:
|
||||||
|
self.head_pose_offset = calibration['head_pose_offset']
|
||||||
|
|
||||||
|
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not raw_data or 'head_pose' not in raw_data:
|
||||||
|
return raw_data
|
||||||
|
calibrated_data = raw_data.copy()
|
||||||
|
head_pose = raw_data['head_pose'].copy()
|
||||||
|
angle = head_pose['rotation'] - self.head_pose_offset['rotation']
|
||||||
|
head_pose['rotation'] = round(((angle + 180) % 360) - 180, 1)
|
||||||
|
head_pose['tilt'] = round(head_pose['tilt'] - self.head_pose_offset['tilt'], 1)
|
||||||
|
head_pose['pitch'] = round(head_pose['pitch'] - self.head_pose_offset['pitch'], 1)
|
||||||
|
calibrated_data['head_pose'] = head_pose
|
||||||
|
return calibrated_data
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
self.running = True
|
||||||
|
self._connected = True
|
||||||
|
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
try:
|
||||||
|
if self.thread and self.thread.is_alive():
|
||||||
|
self.thread.join(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
raw = {
|
||||||
|
'head_pose': {
|
||||||
|
'rotation': self.last_data['yaw'],
|
||||||
|
'tilt': self.last_data['pitch'],
|
||||||
|
'pitch': self.last_data['roll']
|
||||||
|
},
|
||||||
|
'temperature': self.last_data.get('temperature', 25.0),
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
return self.apply_calibration(raw) if apply_calibration else raw
|
||||||
|
|
||||||
|
def _run_loop(self):
|
||||||
|
# 模拟IMU设备的后台数据生成线程(约60Hz),输出平滑变化的姿态与温度
|
||||||
|
import math
|
||||||
|
try:
|
||||||
|
while self.running:
|
||||||
|
# 相位累加,用于驱动正弦/余弦波形
|
||||||
|
self._phase += 0.05
|
||||||
|
# 航向角(yaw,左右旋转),幅度约±30°
|
||||||
|
yaw = math.sin(self._phase * 0.6) * 30.0
|
||||||
|
# yaw = 0
|
||||||
|
# 俯仰角(pitch,上下点头),幅度约±10°
|
||||||
|
pitch = math.sin(self._phase) * 5.0
|
||||||
|
# pitch = 0
|
||||||
|
# 横滚角(roll,左右侧倾),幅度约±8°
|
||||||
|
roll = math.cos(self._phase * 0.8) * 5.0
|
||||||
|
# roll = 0
|
||||||
|
# 写入最新数据,使用1位或2位小数以模拟设备精度
|
||||||
|
with self._lock:
|
||||||
|
self.last_data['yaw'] = round(yaw, 1)
|
||||||
|
self.last_data['pitch'] = round(pitch, 1)
|
||||||
|
self.last_data['roll'] = round(roll, 1)
|
||||||
|
# 温度模拟:以25℃为基准,叠加±0.5℃的轻微波动
|
||||||
|
self.last_data['temperature'] = round(25.0 + math.sin(self._phase * 0.2) * 0.5, 2)
|
||||||
|
# 控制输出频率为约60Hz
|
||||||
|
time.sleep(1.0 / 30.0)
|
||||||
|
except Exception:
|
||||||
|
# 忽略模拟线程异常,避免影响主流程
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
class IMUManager(BaseDevice):
|
class IMUManager(BaseDevice):
|
||||||
"""IMU传感器管理器"""
|
"""IMU传感器管理器"""
|
||||||
@ -470,7 +389,6 @@ class IMUManager(BaseDevice):
|
|||||||
self.port = config.get('port', 'COM7')
|
self.port = config.get('port', 'COM7')
|
||||||
self.baudrate = config.get('baudrate', 9600)
|
self.baudrate = config.get('baudrate', 9600)
|
||||||
self.device_type = config.get('device_type', 'ble') # 'real' | 'mock' | 'ble'
|
self.device_type = config.get('device_type', 'ble') # 'real' | 'mock' | 'ble'
|
||||||
self.use_mock = config.get('use_mock', False) # 保持向后兼容
|
|
||||||
self.mac_address = config.get('mac_address', '')
|
self.mac_address = config.get('mac_address', '')
|
||||||
# IMU设备实例
|
# IMU设备实例
|
||||||
self.imu_device = None
|
self.imu_device = None
|
||||||
@ -517,28 +435,16 @@ class IMUManager(BaseDevice):
|
|||||||
self.imu_device.start()
|
self.imu_device.start()
|
||||||
# 使用set_connected方法来正确启动连接监控线程
|
# 使用set_connected方法来正确启动连接监控线程
|
||||||
self.set_connected(True)
|
self.set_connected(True)
|
||||||
elif self.device_type == 'real' or (self.device_type != 'mock' and not self.use_mock):
|
|
||||||
self.logger.info(f"使用真实IMU设备 - 端口: {self.port}, 波特率: {self.baudrate}")
|
|
||||||
self.imu_device = RealIMUDevice(self.port, self.baudrate)
|
|
||||||
|
|
||||||
# 检查真实设备是否连接成功
|
|
||||||
if self.imu_device.ser is None:
|
|
||||||
self.logger.error(f"IMU设备连接失败: 无法打开串口 {self.port}")
|
|
||||||
self.is_connected = False
|
|
||||||
self.imu_device = None
|
|
||||||
return False
|
|
||||||
# 使用set_connected方法来正确启动连接监控线程
|
|
||||||
self.set_connected(True)
|
|
||||||
else:
|
else:
|
||||||
self.logger.info("使用模拟IMU设备")
|
self.logger.info("使用模拟IMU设备")
|
||||||
self.imu_device = MockIMUDevice()
|
self.imu_device = MockIMUDevice()
|
||||||
|
self.imu_device.start()
|
||||||
# 使用set_connected方法来正确启动连接监控线程
|
# 使用set_connected方法来正确启动连接监控线程
|
||||||
self.set_connected(True)
|
self.set_connected(True)
|
||||||
|
|
||||||
self._device_info.update({
|
self._device_info.update({
|
||||||
'port': self.port,
|
'port': self.port,
|
||||||
'baudrate': self.baudrate,
|
'baudrate': self.baudrate,
|
||||||
'use_mock': self.use_mock,
|
|
||||||
'mac_address': self.mac_address,
|
'mac_address': self.mac_address,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -696,13 +602,6 @@ class IMUManager(BaseDevice):
|
|||||||
data = self.imu_device.read_data(apply_calibration=True)
|
data = self.imu_device.read_data(apply_calibration=True)
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
# 缓存数据
|
|
||||||
# self.data_buffer.append(data)
|
|
||||||
# self.last_valid_data = data
|
|
||||||
|
|
||||||
# 更新心跳时间,防止连接监控线程判定为超时
|
|
||||||
# self.update_heartbeat()
|
|
||||||
|
|
||||||
# 发送数据到前端
|
# 发送数据到前端
|
||||||
if self._socketio:
|
if self._socketio:
|
||||||
self._socketio.emit('imu_data', data, namespace='/devices')
|
self._socketio.emit('imu_data', data, namespace='/devices')
|
||||||
@ -792,7 +691,6 @@ class IMUManager(BaseDevice):
|
|||||||
self.port = config.get('port', 'COM7')
|
self.port = config.get('port', 'COM7')
|
||||||
self.baudrate = config.get('baudrate', 9600)
|
self.baudrate = config.get('baudrate', 9600)
|
||||||
self.device_type = config.get('device_type', 'mock')
|
self.device_type = config.get('device_type', 'mock')
|
||||||
self.use_mock = config.get('use_mock', False)
|
|
||||||
self.mac_address = config.get('mac_address', '')
|
self.mac_address = config.get('mac_address', '')
|
||||||
|
|
||||||
# 更新数据缓存队列大小
|
# 更新数据缓存队列大小
|
||||||
|
|||||||
@ -1,649 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
IMU传感器管理器
|
|
||||||
负责IMU传感器的连接、校准和头部姿态数据采集
|
|
||||||
"""
|
|
||||||
|
|
||||||
import serial
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import numpy as np
|
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
|
||||||
import logging
|
|
||||||
from collections import deque
|
|
||||||
import struct
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .base_device import BaseDevice
|
|
||||||
from .utils.socket_manager import SocketManager
|
|
||||||
from .utils.config_manager import ConfigManager
|
|
||||||
except ImportError:
|
|
||||||
from base_device import BaseDevice
|
|
||||||
from utils.socket_manager import SocketManager
|
|
||||||
from utils.config_manager import ConfigManager
|
|
||||||
|
|
||||||
# 设置日志
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RealIMUDevice:
|
|
||||||
"""真实IMU设备,通过串口读取姿态数据"""
|
|
||||||
def __init__(self, port, baudrate):
|
|
||||||
self.port = port
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self.ser = None
|
|
||||||
self.buffer = bytearray()
|
|
||||||
self.calibration_data = None
|
|
||||||
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
|
|
||||||
self.last_data = {
|
|
||||||
'roll': 0.0,
|
|
||||||
'pitch': 0.0,
|
|
||||||
'yaw': 0.0,
|
|
||||||
'temperature': 25.0
|
|
||||||
}
|
|
||||||
logger.debug(f'RealIMUDevice 初始化: port={self.port}, baudrate={self.baudrate}')
|
|
||||||
self._connect()
|
|
||||||
|
|
||||||
def _connect(self):
|
|
||||||
try:
|
|
||||||
logger.debug(f'尝试打开串口: {self.port} @ {self.baudrate}')
|
|
||||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
|
|
||||||
if hasattr(self.ser, 'reset_input_buffer'):
|
|
||||||
try:
|
|
||||||
self.ser.reset_input_buffer()
|
|
||||||
logger.debug('已清空串口输入缓冲区')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f'重置串口输入缓冲区失败: {e}')
|
|
||||||
logger.info(f'IMU设备连接成功: {self.port} @ {self.baudrate}bps')
|
|
||||||
except Exception as e:
|
|
||||||
# logger.error(f'IMU设备连接失败: {e}', exc_info=True)
|
|
||||||
self.ser = None
|
|
||||||
|
|
||||||
def set_calibration(self, calibration: Dict[str, Any]):
|
|
||||||
self.calibration_data = calibration
|
|
||||||
if 'head_pose_offset' in calibration:
|
|
||||||
self.head_pose_offset = calibration['head_pose_offset']
|
|
||||||
logger.debug(f'应用IMU校准数据: {self.head_pose_offset}')
|
|
||||||
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""应用校准:将当前姿态减去初始偏移,得到相对于初始姿态的变化量"""
|
|
||||||
if not raw_data or 'head_pose' not in raw_data:
|
|
||||||
return raw_data
|
|
||||||
|
|
||||||
# 应用校准偏移
|
|
||||||
calibrated_data = raw_data.copy()
|
|
||||||
head_pose = raw_data['head_pose'].copy()
|
|
||||||
angle=head_pose['rotation'] - self.head_pose_offset['rotation']
|
|
||||||
# 减去基准值(零点偏移)
|
|
||||||
head_pose['rotation'] = ((angle + 180) % 360) - 180
|
|
||||||
head_pose['tilt'] = head_pose['tilt'] - self.head_pose_offset['tilt']
|
|
||||||
head_pose['pitch'] = head_pose['pitch'] - self.head_pose_offset['pitch']
|
|
||||||
|
|
||||||
calibrated_data['head_pose'] = head_pose
|
|
||||||
return calibrated_data
|
|
||||||
@staticmethod
|
|
||||||
def _checksum(data: bytes) -> int:
|
|
||||||
return sum(data[:-1]) & 0xFF
|
|
||||||
|
|
||||||
def _parse_packet(self, data: bytes) -> Optional[Dict[str, float]]:
|
|
||||||
if len(data) != 11:
|
|
||||||
logger.debug(f'无效数据包长度: {len(data)}')
|
|
||||||
return None
|
|
||||||
if data[0] != 0x55:
|
|
||||||
logger.debug(f'错误的包头: 0x{data[0]:02X}')
|
|
||||||
return None
|
|
||||||
if self._checksum(data) != data[-1]:
|
|
||||||
logger.debug(f'校验和错误: 期望{self._checksum(data):02X}, 实际{data[-1]:02X}')
|
|
||||||
return None
|
|
||||||
packet_type = data[1]
|
|
||||||
vals = [int.from_bytes(data[i:i+2], 'little', signed=True) for i in range(2, 10, 2)]
|
|
||||||
if packet_type == 0x53: # 姿态角,单位0.01°
|
|
||||||
pitchl, rxl, yawl, temp = vals # 注意这里 vals 已经是有符号整数
|
|
||||||
# 使用第一段代码里的比例系数
|
|
||||||
k_angle = 180.0
|
|
||||||
roll = -round(rxl / 32768.0 * k_angle,2)
|
|
||||||
pitch = -round(pitchl / 32768.0 * k_angle,2)
|
|
||||||
yaw = -round(yawl / 32768.0 * k_angle,2)
|
|
||||||
temp = temp / 100.0
|
|
||||||
self.last_data = {
|
|
||||||
'roll': roll,
|
|
||||||
'pitch': pitch,
|
|
||||||
'yaw': yaw,
|
|
||||||
'temperature': temp
|
|
||||||
}
|
|
||||||
# print(f'解析姿态角包: roll={roll}, pitch={pitch}, yaw={yaw}, temp={temp}')
|
|
||||||
return self.last_data
|
|
||||||
else:
|
|
||||||
# logger.debug(f'忽略的数据包类型: 0x{packet_type:02X}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
|
|
||||||
if not self.ser or not getattr(self.ser, 'is_open', False):
|
|
||||||
# logger.warning('IMU串口未连接,尝试重新连接...')
|
|
||||||
self._connect()
|
|
||||||
return {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
bytes_waiting = self.ser.in_waiting
|
|
||||||
if bytes_waiting:
|
|
||||||
# logger.debug(f'串口缓冲区待读字节: {bytes_waiting}')
|
|
||||||
chunk = self.ser.read(bytes_waiting)
|
|
||||||
# logger.debug(f'读取到字节: {len(chunk)}')
|
|
||||||
self.buffer.extend(chunk)
|
|
||||||
while len(self.buffer) >= 11:
|
|
||||||
if self.buffer[0] != 0x55:
|
|
||||||
dropped = self.buffer.pop(0)
|
|
||||||
logger.debug(f'丢弃无效字节: 0x{dropped:02X}')
|
|
||||||
continue
|
|
||||||
packet = bytes(self.buffer[:11])
|
|
||||||
parsed = self._parse_packet(packet)
|
|
||||||
del self.buffer[:11]
|
|
||||||
if parsed is not None:
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': parsed['yaw'], # rotation = roll
|
|
||||||
'tilt': parsed['roll'], # tilt = yaw
|
|
||||||
'pitch': parsed['pitch'] # pitch = pitch
|
|
||||||
},
|
|
||||||
'temperature': parsed['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
# logger.debug(f'映射后的头部姿态: {raw}')
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'IMU数据读取异常: {e}', exc_info=True)
|
|
||||||
raw = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': self.last_data['yaw'],
|
|
||||||
'tilt': self.last_data['roll'],
|
|
||||||
'pitch': self.last_data['pitch']
|
|
||||||
},
|
|
||||||
'temperature': self.last_data['temperature'],
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
return self.apply_calibration(raw) if apply_calibration else raw
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
if self.ser and getattr(self.ser, 'is_open', False):
|
|
||||||
self.ser.close()
|
|
||||||
logger.info('IMU设备串口已关闭')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MockIMUDevice:
|
|
||||||
"""模拟IMU设备"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.noise_level = 0.1
|
|
||||||
self.calibration_data = None # 校准数据
|
|
||||||
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0} # 头部姿态零点偏移
|
|
||||||
|
|
||||||
def set_calibration(self, calibration: Dict[str, Any]):
|
|
||||||
"""设置校准数据"""
|
|
||||||
self.calibration_data = calibration
|
|
||||||
if 'head_pose_offset' in calibration:
|
|
||||||
self.head_pose_offset = calibration['head_pose_offset']
|
|
||||||
|
|
||||||
def apply_calibration(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""应用校准:将当前姿态减去初始偏移,得到相对姿态"""
|
|
||||||
if not raw_data or 'head_pose' not in raw_data:
|
|
||||||
return raw_data
|
|
||||||
|
|
||||||
calibrated_data = raw_data.copy()
|
|
||||||
head_pose = raw_data['head_pose'].copy()
|
|
||||||
head_pose['rotation'] = head_pose['rotation'] - self.head_pose_offset['rotation']
|
|
||||||
head_pose['tilt'] = head_pose['tilt'] - self.head_pose_offset['tilt']
|
|
||||||
head_pose['pitch'] = head_pose['pitch'] - self.head_pose_offset['pitch']
|
|
||||||
calibrated_data['head_pose'] = head_pose
|
|
||||||
return calibrated_data
|
|
||||||
|
|
||||||
def read_data(self, apply_calibration: bool = True) -> Dict[str, Any]:
|
|
||||||
"""读取IMU数据"""
|
|
||||||
# 生成头部姿态角度数据,角度范围(-90°, +90°)
|
|
||||||
# 使用正弦波模拟自然的头部运动,添加随机噪声
|
|
||||||
import time
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
# 旋转角(左旋为负,右旋为正)
|
|
||||||
rotation_angle = 30 * np.sin(current_time * 0.5) + np.random.normal(0, self.noise_level * 5)
|
|
||||||
rotation_angle = np.clip(rotation_angle, -90, 90)
|
|
||||||
|
|
||||||
# 倾斜角(左倾为负,右倾为正)
|
|
||||||
tilt_angle = 20 * np.sin(current_time * 0.3 + np.pi/4) + np.random.normal(0, self.noise_level * 5)
|
|
||||||
tilt_angle = np.clip(tilt_angle, -90, 90)
|
|
||||||
|
|
||||||
# 俯仰角(俯角为负,仰角为正)
|
|
||||||
pitch_angle = 15 * np.sin(current_time * 0.7 + np.pi/2) + np.random.normal(0, self.noise_level * 5)
|
|
||||||
pitch_angle = np.clip(pitch_angle, -90, 90)
|
|
||||||
|
|
||||||
# 生成原始数据
|
|
||||||
raw_data = {
|
|
||||||
'head_pose': {
|
|
||||||
'rotation': rotation_angle, # 旋转角:左旋(-), 右旋(+)
|
|
||||||
'tilt': tilt_angle, # 倾斜角:左倾(-), 右倾(+)
|
|
||||||
'pitch': pitch_angle # 俯仰角:俯角(-), 仰角(+)
|
|
||||||
},
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
# 应用校准并返回
|
|
||||||
return self.apply_calibration(raw_data) if apply_calibration else raw_data
|
|
||||||
|
|
||||||
|
|
||||||
class IMUManager(BaseDevice):
|
|
||||||
"""IMU传感器管理器"""
|
|
||||||
|
|
||||||
def __init__(self, socketio, config_manager: Optional[ConfigManager] = None):
|
|
||||||
"""
|
|
||||||
初始化IMU管理器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
socketio: SocketIO实例
|
|
||||||
config_manager: 配置管理器实例
|
|
||||||
"""
|
|
||||||
# 配置管理
|
|
||||||
self.config_manager = config_manager or ConfigManager()
|
|
||||||
config = self.config_manager.get_device_config('imu')
|
|
||||||
|
|
||||||
super().__init__("imu", config)
|
|
||||||
|
|
||||||
# 保存socketio实例
|
|
||||||
self._socketio = socketio
|
|
||||||
|
|
||||||
# 设备配置
|
|
||||||
self.port = config.get('port', 'COM7')
|
|
||||||
self.baudrate = config.get('baudrate', 9600)
|
|
||||||
self.device_type = config.get('device_type', 'mock') # 'real' 或 'mock'
|
|
||||||
self.use_mock = config.get('use_mock', False) # 保持向后兼容
|
|
||||||
# IMU设备实例
|
|
||||||
self.imu_device = None
|
|
||||||
|
|
||||||
# 推流相关
|
|
||||||
self.imu_streaming = False
|
|
||||||
self.imu_thread = None
|
|
||||||
|
|
||||||
# 统计信息
|
|
||||||
self.data_count = 0
|
|
||||||
self.error_count = 0
|
|
||||||
|
|
||||||
# 校准相关
|
|
||||||
self.is_calibrated = False
|
|
||||||
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
|
|
||||||
|
|
||||||
# 数据缓存
|
|
||||||
self.data_buffer = deque(maxlen=100)
|
|
||||||
self.last_valid_data = None
|
|
||||||
|
|
||||||
self.logger.info(f"IMU管理器初始化完成 - 端口: {self.port}, 设备类型: {self.device_type}")
|
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
|
||||||
"""
|
|
||||||
初始化IMU设备
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 初始化是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.logger.info(f"正在初始化IMU设备...")
|
|
||||||
|
|
||||||
# 使用构造函数中已加载的配置,避免并发读取配置文件
|
|
||||||
self.logger.info(f"使用已加载配置: port={self.port}, baudrate={self.baudrate}, device_type={self.device_type}")
|
|
||||||
|
|
||||||
# 根据配置选择真实设备或模拟设备
|
|
||||||
# 优先使用device_type配置,如果没有则使用use_mock配置(向后兼容)
|
|
||||||
use_real_device = (self.device_type == 'real') or (not self.use_mock)
|
|
||||||
|
|
||||||
if use_real_device:
|
|
||||||
self.logger.info(f"使用真实IMU设备 - 端口: {self.port}, 波特率: {self.baudrate}")
|
|
||||||
self.imu_device = RealIMUDevice(self.port, self.baudrate)
|
|
||||||
|
|
||||||
# 检查真实设备是否连接成功
|
|
||||||
if self.imu_device.ser is None:
|
|
||||||
self.logger.error(f"IMU设备连接失败: 无法打开串口 {self.port}")
|
|
||||||
self.is_connected = False
|
|
||||||
self.imu_device = None
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.logger.info("使用模拟IMU设备")
|
|
||||||
self.imu_device = MockIMUDevice()
|
|
||||||
|
|
||||||
self.is_connected = True
|
|
||||||
self._device_info.update({
|
|
||||||
'port': self.port,
|
|
||||||
'baudrate': self.baudrate,
|
|
||||||
'use_mock': self.use_mock
|
|
||||||
})
|
|
||||||
|
|
||||||
self.logger.info("IMU初始化成功")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"IMU初始化失败: {e}")
|
|
||||||
self.is_connected = False
|
|
||||||
self.imu_device = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _quick_calibrate_imu(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
快速IMU零点校准(以当前姿态为基准)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: 校准结果
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not self.imu_device:
|
|
||||||
return {'status': 'error', 'error': 'IMU设备未初始化'}
|
|
||||||
|
|
||||||
self.logger.info('开始IMU快速零点校准...')
|
|
||||||
|
|
||||||
# 直接读取一次原始数据作为校准偏移量
|
|
||||||
raw_data = self.imu_device.read_data(apply_calibration=False)
|
|
||||||
if not raw_data or 'head_pose' not in raw_data:
|
|
||||||
return {'status': 'error', 'error': '无法读取IMU原始数据'}
|
|
||||||
|
|
||||||
# 使用当前姿态作为零点偏移
|
|
||||||
self.head_pose_offset = {
|
|
||||||
'rotation': raw_data['head_pose']['rotation'],
|
|
||||||
'tilt': raw_data['head_pose']['tilt'],
|
|
||||||
'pitch': raw_data['head_pose']['pitch']
|
|
||||||
}
|
|
||||||
|
|
||||||
# 应用校准到设备
|
|
||||||
calibration_data = {'head_pose_offset': self.head_pose_offset}
|
|
||||||
self.imu_device.set_calibration(calibration_data)
|
|
||||||
|
|
||||||
self.logger.info(f'IMU快速校准完成: {self.head_pose_offset}')
|
|
||||||
return {
|
|
||||||
'status': 'success',
|
|
||||||
'head_pose_offset': self.head_pose_offset
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f'IMU快速校准失败: {e}')
|
|
||||||
return {'status': 'error', 'error': str(e)}
|
|
||||||
|
|
||||||
def calibrate(self) -> bool:
|
|
||||||
"""
|
|
||||||
校准IMU传感器
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 校准是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not self.is_connected or not self.imu_device:
|
|
||||||
if not self.initialize():
|
|
||||||
self.logger.error("IMU设备未连接")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 使用快速校准方法
|
|
||||||
result = self._quick_calibrate_imu()
|
|
||||||
|
|
||||||
if result['status'] == 'success':
|
|
||||||
self.is_calibrated = True
|
|
||||||
self.logger.info("IMU校准成功")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.logger.error(f"IMU校准失败: {result.get('error', '未知错误')}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"IMU校准失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def start_streaming(self) -> bool:
|
|
||||||
"""
|
|
||||||
开始IMU数据流
|
|
||||||
|
|
||||||
Args:
|
|
||||||
socketio: SocketIO实例,用于数据推送
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 启动是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not self.is_connected or not self.imu_device:
|
|
||||||
if not self.initialize():
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.imu_streaming:
|
|
||||||
self.logger.warning("IMU数据流已在运行")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 启动前进行快速校准
|
|
||||||
if not self.is_calibrated:
|
|
||||||
self.logger.info("启动前进行快速零点校准...")
|
|
||||||
self._quick_calibrate_imu()
|
|
||||||
|
|
||||||
self.imu_streaming = True
|
|
||||||
self.imu_thread = threading.Thread(target=self._imu_streaming_thread, daemon=True)
|
|
||||||
self.imu_thread.start()
|
|
||||||
|
|
||||||
self.logger.info("IMU数据流启动成功")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"IMU数据流启动失败: {e}")
|
|
||||||
self.imu_streaming = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def stop_streaming(self) -> bool:
|
|
||||||
"""
|
|
||||||
停止IMU数据流
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 停止是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.imu_streaming = False
|
|
||||||
|
|
||||||
if self.imu_thread and self.imu_thread.is_alive():
|
|
||||||
self.imu_thread.join(timeout=3.0)
|
|
||||||
|
|
||||||
self.logger.info("IMU数据流已停止")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"停止IMU数据流失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _imu_streaming_thread(self):
|
|
||||||
"""
|
|
||||||
IMU数据流工作线程
|
|
||||||
"""
|
|
||||||
self.logger.info("IMU数据流工作线程启动")
|
|
||||||
|
|
||||||
while self.imu_streaming:
|
|
||||||
try:
|
|
||||||
if self.imu_device:
|
|
||||||
# 读取IMU数据
|
|
||||||
data = self.imu_device.read_data(apply_calibration=True)
|
|
||||||
|
|
||||||
if data:
|
|
||||||
# 缓存数据
|
|
||||||
# self.data_buffer.append(data)
|
|
||||||
# self.last_valid_data = data
|
|
||||||
|
|
||||||
# 发送数据到前端
|
|
||||||
if self._socketio:
|
|
||||||
self._socketio.emit('imu_data', data, namespace='/devices')
|
|
||||||
|
|
||||||
# 更新统计
|
|
||||||
self.data_count += 1
|
|
||||||
else:
|
|
||||||
self.error_count += 1
|
|
||||||
|
|
||||||
time.sleep(0.02) # 50Hz采样率
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"IMU数据流处理异常: {e}")
|
|
||||||
self.error_count += 1
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
self.logger.info("IMU数据流工作线程结束")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
获取设备状态
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: 设备状态信息
|
|
||||||
"""
|
|
||||||
status = super().get_status()
|
|
||||||
status.update({
|
|
||||||
'port': self.port,
|
|
||||||
'baudrate': self.baudrate,
|
|
||||||
'is_streaming': self.imu_streaming,
|
|
||||||
'is_calibrated': self.is_calibrated,
|
|
||||||
'data_count': self.data_count,
|
|
||||||
'error_count': self.error_count,
|
|
||||||
'buffer_size': len(self.data_buffer),
|
|
||||||
'has_data': self.last_valid_data is not None,
|
|
||||||
'head_pose_offset': self.head_pose_offset,
|
|
||||||
'device_type': 'mock' if self.use_mock else 'real'
|
|
||||||
})
|
|
||||||
return status
|
|
||||||
|
|
||||||
def get_latest_data(self) -> Optional[Dict[str, float]]:
|
|
||||||
"""
|
|
||||||
获取最新的IMU数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[Dict[str, float]]: 最新数据,无数据返回None
|
|
||||||
"""
|
|
||||||
return self.last_valid_data.copy() if self.last_valid_data else None
|
|
||||||
|
|
||||||
def collect_head_pose_data(self, duration: int = 10) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
收集头部姿态数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
duration: 收集时长(秒)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[Dict[str, Any]]: 收集到的数据列表
|
|
||||||
"""
|
|
||||||
collected_data = []
|
|
||||||
|
|
||||||
if not self.is_connected or not self.imu_device:
|
|
||||||
self.logger.error("IMU设备未连接")
|
|
||||||
return collected_data
|
|
||||||
|
|
||||||
self.logger.info(f"开始收集头部姿态数据,时长: {duration}秒")
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
while time.time() - start_time < duration:
|
|
||||||
try:
|
|
||||||
data = self.imu_device.read_data(apply_calibration=True)
|
|
||||||
if data:
|
|
||||||
# 添加时间戳
|
|
||||||
data['timestamp'] = time.time()
|
|
||||||
collected_data.append(data)
|
|
||||||
|
|
||||||
time.sleep(0.02) # 50Hz采样率
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"数据收集异常: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
self.logger.info(f"头部姿态数据收集完成,共收集 {len(collected_data)} 个样本")
|
|
||||||
return collected_data
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
"""
|
|
||||||
断开IMU设备连接
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.stop_streaming()
|
|
||||||
|
|
||||||
if self.imu_device:
|
|
||||||
self.imu_device = None
|
|
||||||
|
|
||||||
self.is_connected = False
|
|
||||||
self.logger.info("IMU设备已断开连接")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"断开IMU设备连接失败: {e}")
|
|
||||||
|
|
||||||
def reload_config(self) -> bool:
|
|
||||||
"""
|
|
||||||
重新加载设备配置
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 重新加载是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.logger.info("正在重新加载IMU配置...")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 获取最新配置
|
|
||||||
config = self.config_manager.get_device_config('imu')
|
|
||||||
|
|
||||||
# 更新配置属性
|
|
||||||
self.port = config.get('port', 'COM7')
|
|
||||||
self.baudrate = config.get('baudrate', 9600)
|
|
||||||
self.device_type = config.get('device_type', 'mock')
|
|
||||||
self.use_mock = config.get('use_mock', False)
|
|
||||||
|
|
||||||
# 更新数据缓存队列大小
|
|
||||||
buffer_size = config.get('buffer_size', 100)
|
|
||||||
if buffer_size != self.data_buffer.maxlen:
|
|
||||||
# 保存当前数据
|
|
||||||
current_data = list(self.data_buffer)
|
|
||||||
# 创建新缓冲区
|
|
||||||
self.data_buffer = deque(maxlen=buffer_size)
|
|
||||||
# 恢复数据(保留最新的数据)
|
|
||||||
for data in current_data[-buffer_size:]:
|
|
||||||
self.data_buffer.append(data)
|
|
||||||
|
|
||||||
self.logger.info(f"IMU配置重新加载成功 - 端口: {self.port}, 波特率: {self.baudrate}, 设备类型: {self.device_type}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"重新加载IMU配置失败: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
清理资源
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
# 清理缓冲区
|
|
||||||
self.data_buffer.clear()
|
|
||||||
|
|
||||||
# 重置状态
|
|
||||||
self.is_calibrated = False
|
|
||||||
self.last_valid_data = None
|
|
||||||
self.head_pose_offset = {'rotation': 0, 'tilt': 0, 'pitch': 0}
|
|
||||||
|
|
||||||
super().cleanup()
|
|
||||||
self.logger.info("IMU资源清理完成")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"清理IMU资源失败: {e}")
|
|
||||||
@ -463,7 +463,186 @@ class RealPressureDevice:
|
|||||||
"""析构函数,确保资源清理"""
|
"""析构函数,确保资源清理"""
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
class MockPressureDevice:
|
||||||
|
def __init__(self, rows: int = 32, cols: int = 32, seed: Optional[int] = None):
|
||||||
|
self.rows = rows
|
||||||
|
self.cols = cols
|
||||||
|
self.is_connected = True
|
||||||
|
self._rng = np.random.RandomState(seed if seed is not None else (int(time.time()) & 0xFFFF))
|
||||||
|
self._phase = 0.0
|
||||||
|
|
||||||
|
def read_data(self) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
if not self.is_connected:
|
||||||
|
return self._get_empty_data()
|
||||||
|
raw_data = self._generate_raw_frame()
|
||||||
|
zones = self._calculate_foot_pressure_zones(raw_data)
|
||||||
|
image_base64 = self._generate_pressure_image(
|
||||||
|
zones['left_front'], zones['left_rear'], zones['right_front'], zones['right_rear'], raw_data
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'foot_pressure': {
|
||||||
|
'left_front': round(zones['left_front'], 2),
|
||||||
|
'left_rear': round(zones['left_rear'], 2),
|
||||||
|
'right_front': round(zones['right_front'], 2),
|
||||||
|
'right_rear': round(zones['right_rear'], 2),
|
||||||
|
'left_total': round(zones['left_total'], 2),
|
||||||
|
'right_total': round(zones['right_total'], 2)
|
||||||
|
},
|
||||||
|
'pressure_image': image_base64,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return self._get_empty_data()
|
||||||
|
|
||||||
|
def _generate_raw_frame(self) -> np.ndarray:
|
||||||
|
rows, cols = self.rows, self.cols
|
||||||
|
gy, gx = np.meshgrid(np.arange(rows), np.arange(cols), indexing='ij')
|
||||||
|
gy = gy.astype(np.float64)
|
||||||
|
gx = gx.astype(np.float64)
|
||||||
|
self._phase += 0.15
|
||||||
|
lf_cy = rows * 0.30 + 0.6 * np.sin(self._phase)
|
||||||
|
lf_cx = cols * 0.25 + 0.3 * np.cos(self._phase * 0.7)
|
||||||
|
lr_cy = rows * 0.75 + 0.5 * np.sin(self._phase * 0.8)
|
||||||
|
lr_cx = cols * 0.25 + 0.2 * np.sin(self._phase * 0.6)
|
||||||
|
rf_cy = rows * 0.30 + 0.6 * np.cos(self._phase * 0.9)
|
||||||
|
rf_cx = cols * 0.75 + 0.3 * np.sin(self._phase)
|
||||||
|
rr_cy = rows * 0.75 + 0.5 * np.cos(self._phase * 0.5)
|
||||||
|
rr_cx = cols * 0.75 + 0.2 * np.cos(self._phase * 0.4)
|
||||||
|
sy = rows * 0.10
|
||||||
|
sx = cols * 0.10
|
||||||
|
def gauss(cy: float, cx: float, amp: float) -> np.ndarray:
|
||||||
|
return amp * np.exp(-(((gy - cy) ** 2) / (2 * sy * sy) + ((gx - cx) ** 2) / (2 * sx * sx)))
|
||||||
|
lf = gauss(lf_cy, lf_cx, 300.0 + 120.0 * self._rng.rand())
|
||||||
|
lr = gauss(lr_cy, lr_cx, 280.0 + 120.0 * self._rng.rand())
|
||||||
|
rf = gauss(rf_cy, rf_cx, 300.0 + 120.0 * self._rng.rand())
|
||||||
|
rr = gauss(rr_cy, rr_cx, 280.0 + 120.0 * self._rng.rand())
|
||||||
|
base = lf + lr + rf + rr
|
||||||
|
noise = self._rng.normal(0.0, 5.0, size=(rows, cols))
|
||||||
|
frame = base + noise
|
||||||
|
frame = np.clip(frame, 0, 65535).astype(np.uint16)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _calculate_foot_pressure_zones(self, raw_data: np.ndarray) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
rd = np.asarray(raw_data, dtype=np.float64)
|
||||||
|
rows, cols = rd.shape if rd.ndim == 2 else (0, 0)
|
||||||
|
if rows == 0 or cols == 0:
|
||||||
|
raise ValueError
|
||||||
|
mid_r = rows // 2
|
||||||
|
mid_c = cols // 2
|
||||||
|
left_front = float(np.sum(rd[:mid_r, :mid_c], dtype=np.float64))
|
||||||
|
left_rear = float(np.sum(rd[mid_r:, :mid_c], dtype=np.float64))
|
||||||
|
right_front = float(np.sum(rd[:mid_r, mid_c:], dtype=np.float64))
|
||||||
|
right_rear = float(np.sum(rd[mid_r:, mid_c:], dtype=np.float64))
|
||||||
|
left_total_abs = left_front + left_rear
|
||||||
|
right_total_abs = right_front + right_rear
|
||||||
|
total_abs = left_total_abs + right_total_abs
|
||||||
|
left_total_pct = float((left_total_abs / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
right_total_pct = float((right_total_abs / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
left_front_pct = float((left_front / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
left_rear_pct = float((left_rear / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
right_front_pct = float((right_front / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
right_rear_pct = float((right_rear / total_abs * 100) if total_abs > 0 else 0)
|
||||||
|
return {
|
||||||
|
'left_front': round(left_front_pct),
|
||||||
|
'left_rear': round(left_rear_pct),
|
||||||
|
'right_front': round(right_front_pct),
|
||||||
|
'right_rear': round(right_rear_pct),
|
||||||
|
'left_total': round(left_total_pct),
|
||||||
|
'right_total': round(right_total_pct),
|
||||||
|
'total_pressure': round(total_abs)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
'left_front': 0, 'left_rear': 0, 'right_front': 0, 'right_rear': 0,
|
||||||
|
'left_total': 0, 'right_total': 0, 'total_pressure': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_pressure_image(self, left_front: float, left_rear: float, right_front: float, right_rear: float, raw_data: Optional[np.ndarray] = None) -> str:
|
||||||
|
try:
|
||||||
|
if MATPLOTLIB_AVAILABLE and raw_data is not None:
|
||||||
|
return self._generate_heatmap_image(raw_data)
|
||||||
|
else:
|
||||||
|
return self._generate_simple_pressure_image(left_front, left_rear, right_front, right_rear)
|
||||||
|
except Exception:
|
||||||
|
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||||
|
|
||||||
|
def _generate_heatmap_image(self, raw_data: np.ndarray) -> str:
|
||||||
|
try:
|
||||||
|
vmin = 10
|
||||||
|
dmin, dmax = np.min(raw_data), np.max(raw_data)
|
||||||
|
norm = np.clip((raw_data - dmin) / max(dmax - dmin, 1) * 255, 0, 255).astype(np.uint8)
|
||||||
|
heatmap = cv2.applyColorMap(norm, cv2.COLORMAP_JET)
|
||||||
|
heatmap[raw_data <= vmin] = (0, 0, 0)
|
||||||
|
rows, cols = raw_data.shape
|
||||||
|
heatmap = cv2.resize(heatmap, (cols * 4, rows * 4), interpolation=cv2.INTER_NEAREST)
|
||||||
|
heatmap_rgb = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
|
||||||
|
from PIL import Image
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.fromarray(heatmap_rgb).save(buffer, format="PNG")
|
||||||
|
buffer.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||||
|
return f"data:image/png;base64,{image_base64}"
|
||||||
|
except Exception:
|
||||||
|
return self._generate_simple_pressure_image(0, 0, 0, 0)
|
||||||
|
|
||||||
|
def _generate_simple_pressure_image(self, left_front: float, left_rear: float, right_front: float, right_rear: float) -> str:
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.patches as patches
|
||||||
|
fig, ax = plt.subplots(1, 1, figsize=(6, 8))
|
||||||
|
ax.set_xlim(0, 10)
|
||||||
|
ax.set_ylim(0, 12)
|
||||||
|
ax.set_aspect('equal')
|
||||||
|
ax.axis('off')
|
||||||
|
m = max(left_front, left_rear, right_front, right_rear)
|
||||||
|
if m > 0:
|
||||||
|
lf_c = plt.cm.Reds(left_front / m)
|
||||||
|
lr_c = plt.cm.Reds(left_rear / m)
|
||||||
|
rf_c = plt.cm.Reds(right_front / m)
|
||||||
|
rr_c = plt.cm.Reds(right_rear / m)
|
||||||
|
else:
|
||||||
|
lf_c = lr_c = rf_c = rr_c = 'lightgray'
|
||||||
|
ax.add_patch(patches.Rectangle((1, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=lf_c))
|
||||||
|
ax.add_patch(patches.Rectangle((1, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=lr_c))
|
||||||
|
ax.add_patch(patches.Rectangle((7, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=rf_c))
|
||||||
|
ax.add_patch(patches.Rectangle((7, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=rr_c))
|
||||||
|
ax.text(2, 8, f'{left_front:.1f}', ha='center', va='center', fontsize=10, weight='bold')
|
||||||
|
ax.text(2, 4, f'{left_rear:.1f}', ha='center', va='center', fontsize=10, weight='bold')
|
||||||
|
ax.text(8, 8, f'{right_front:.1f}', ha='center', va='center', fontsize=10, weight='bold')
|
||||||
|
ax.text(8, 4, f'{right_rear:.1f}', ha='center', va='center', fontsize=10, weight='bold')
|
||||||
|
ax.text(2, 0.5, '左足', ha='center', va='center', fontsize=12, weight='bold')
|
||||||
|
ax.text(8, 0.5, '右足', ha='center', va='center', fontsize=12, weight='bold')
|
||||||
|
fig.patch.set_facecolor('black')
|
||||||
|
ax.set_facecolor('black')
|
||||||
|
buffer = BytesIO()
|
||||||
|
plt.savefig(buffer, format='png', bbox_inches='tight', dpi=100, facecolor='black')
|
||||||
|
buffer.seek(0)
|
||||||
|
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
plt.close(fig)
|
||||||
|
return f"data:image/png;base64,{image_base64}"
|
||||||
|
except Exception:
|
||||||
|
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||||
|
|
||||||
|
def _get_empty_data(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'foot_pressure': {
|
||||||
|
'left_front': 0.0,
|
||||||
|
'left_rear': 0.0,
|
||||||
|
'right_front': 0.0,
|
||||||
|
'right_rear': 0.0,
|
||||||
|
'left_total': 0.0,
|
||||||
|
'right_total': 0.0
|
||||||
|
},
|
||||||
|
'pressure_image': "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
class PressureManager(BaseDevice):
|
class PressureManager(BaseDevice):
|
||||||
"""压力板管理器"""
|
"""压力板管理器"""
|
||||||
|
|||||||
15
frontend/src/renderer/package-lock.json
generated
15
frontend/src/renderer/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "body-balance-renderer",
|
"name": "body-balance-system",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "body-balance-renderer",
|
"name": "body-balance-system",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.1.0",
|
"@element-plus/icons-vue": "^2.1.0",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
@ -15,6 +15,7 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
|
"three": "^0.160.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-echarts": "^6.6.1",
|
"vue-echarts": "^6.6.1",
|
||||||
"vue-router": "^4.2.4"
|
"vue-router": "^4.2.4"
|
||||||
@ -5790,6 +5791,12 @@
|
|||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.160.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
|
||||||
|
"integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"element-plus": "^2.3.9",
|
"element-plus": "^2.3.9",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"three": "^0.160.0",
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
|
|||||||
BIN
frontend/src/renderer/src/assets/glb/女.glb
Normal file
BIN
frontend/src/renderer/src/assets/glb/女.glb
Normal file
Binary file not shown.
BIN
frontend/src/renderer/src/assets/glb/女人光头.glb
Normal file
BIN
frontend/src/renderer/src/assets/glb/女人光头.glb
Normal file
Binary file not shown.
BIN
frontend/src/renderer/src/assets/glb/男.glb
Normal file
BIN
frontend/src/renderer/src/assets/glb/男.glb
Normal file
Binary file not shown.
BIN
frontend/src/renderer/src/assets/glb/男人光头.glb
Normal file
BIN
frontend/src/renderer/src/assets/glb/男人光头.glb
Normal file
Binary file not shown.
@ -30,10 +30,8 @@
|
|||||||
<el-table-column prop="id" label="患者ID" min-width="120" align="center" />
|
<el-table-column prop="id" label="患者ID" min-width="120" align="center" />
|
||||||
<el-table-column prop="name" label="患者姓名" width="80" align="center" />
|
<el-table-column prop="name" label="患者姓名" width="80" align="center" />
|
||||||
<el-table-column prop="gender" label="性别" width="120" align="center">
|
<el-table-column prop="gender" label="性别" width="120" align="center">
|
||||||
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span v-if="scope.row.gender === 'male'">男</span>
|
<span>{{ scope.row.gender || '—' }}</span>
|
||||||
<span v-else >女</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="num" label="测试次数" width="100" align="center" />
|
<el-table-column prop="num" label="测试次数" width="100" align="center" />
|
||||||
@ -90,7 +88,7 @@
|
|||||||
<div class="patient-detail-contentleft">性别:</div>
|
<div class="patient-detail-contentleft">性别:</div>
|
||||||
<div class="patient-detail-contentright">
|
<div class="patient-detail-contentright">
|
||||||
<span v-if="selectedPatient && selectedPatient.gender">
|
<span v-if="selectedPatient && selectedPatient.gender">
|
||||||
{{ selectedPatient.gender =='male'?'男':'女' }}
|
{{ selectedPatient.gender }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
color: #00CC00;
|
color: #00CC00;
|
||||||
margin-left:20px">检测中...</div>
|
margin-left:20px">检测中...</div>
|
||||||
<div class="patientInfotop1">{{ patientInfo.name }}</div>
|
<div class="patientInfotop1">{{ patientInfo.name }}</div>
|
||||||
<div> {{ patientInfo.gender =='male'||patientInfo.gender =='男' ?'男':'女' }}</div>
|
<div> {{ patientInfo.gender }}</div>
|
||||||
<div class="username-line"></div>
|
<div class="username-line"></div>
|
||||||
<div>{{ calculateAge(patientInfo.birth_date) }}</div>
|
<div>{{ calculateAge(patientInfo.birth_date) }}</div>
|
||||||
<el-button type="primary" class="endbutton" @click="endClick">结束监测</el-button>
|
<el-button type="primary" class="endbutton" @click="endClick">结束监测</el-button>
|
||||||
@ -128,9 +128,7 @@
|
|||||||
<span class="currencytext3">{{ headlist.rotation }}°</span>
|
<span class="currencytext3">{{ headlist.rotation }}°</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100%;height: 80%;" alt="">
|
<div style="width: 100%;height: 80%;" alt="">
|
||||||
<!-- <img src="@/assets/new/testheader.png" > -->
|
<Model :rotation="Number(headlist.rotation)" :tilt="Number(headlist.tilt)" :pitch="Number(headlist.pitch)" :gender="patientInfo.gender || '男'" />
|
||||||
|
|
||||||
<Model />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -261,7 +259,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="userinfo-text-top">
|
<div class="userinfo-text-top">
|
||||||
<div class="userinfo-text1" style="margin-right:20px ;">{{ patientInfo.name }}</div>
|
<div class="userinfo-text1" style="margin-right:20px ;">{{ patientInfo.name }}</div>
|
||||||
<div class="userinfo-text2"> {{ patientInfo.gender =='male'||patientInfo.gender =='男' ?'男':'女' }}</div>
|
<div class="userinfo-text2"> {{ patientInfo.gender }}</div>
|
||||||
<div class="userinfo-line"></div>
|
<div class="userinfo-line"></div>
|
||||||
<div class="userinfo-text2">{{ calculateAge(patientInfo.birth_date) }}</div>
|
<div class="userinfo-text2">{{ calculateAge(patientInfo.birth_date) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -707,9 +705,9 @@ const formattedTime = computed(() => {
|
|||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
});
|
});
|
||||||
const headlist = ref({
|
const headlist = ref({
|
||||||
rotation: '0',
|
rotation: 0,
|
||||||
tilt: '0',
|
tilt: 0,
|
||||||
pitch: '0'
|
pitch: 0
|
||||||
})
|
})
|
||||||
// 开始计时器
|
// 开始计时器
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
@ -987,13 +985,14 @@ function connectWebSocket() {
|
|||||||
if (!tempInfo.value.camera_frames) {
|
if (!tempInfo.value.camera_frames) {
|
||||||
tempInfo.value.camera_frames = {}
|
tempInfo.value.camera_frames = {}
|
||||||
}
|
}
|
||||||
if (devId === 'camera2') {
|
if (devId === 'camera1') {
|
||||||
tempInfo.value.camera_frames['camera2'] = data
|
|
||||||
displayCameraFrameById('camera2', data.image)
|
|
||||||
} else {
|
|
||||||
// 默认 camera1(兼容旧逻辑 device_id 为空)
|
|
||||||
tempInfo.value.camera_frames['camera1'] = data
|
tempInfo.value.camera_frames['camera1'] = data
|
||||||
|
tempInfo.value.camera1_frame = data
|
||||||
displayCameraFrameById('camera1', data.image)
|
displayCameraFrameById('camera1', data.image)
|
||||||
|
} else if (devId === 'camera2') {
|
||||||
|
tempInfo.value.camera_frames['camera2'] = data
|
||||||
|
tempInfo.value.camera2_frame = data
|
||||||
|
displayCameraFrameById('camera2', data.image)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -241,13 +241,9 @@ const validateForm = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savePatient = async () => {
|
const savePatient = async () => {
|
||||||
// 性别值映射:中文转英文
|
|
||||||
const genderMap = { '男': 'male', '女': 'female' }
|
|
||||||
const genderValue = genderMap[patientForm.gender] || patientForm.gender
|
|
||||||
|
|
||||||
const patientData = {
|
const patientData = {
|
||||||
name: patientForm.name,
|
name: patientForm.name,
|
||||||
gender: genderValue,
|
gender: patientForm.gender,
|
||||||
birth_date: patientForm.birth_date,
|
birth_date: patientForm.birth_date,
|
||||||
height: parseFloat(patientForm.height) || null,
|
height: parseFloat(patientForm.height) || null,
|
||||||
weight: parseFloat(patientForm.weight) || null,
|
weight: parseFloat(patientForm.weight) || null,
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="containermodel" >
|
<div id="containermodel"></div>
|
||||||
<div @click="asd" style="position: absolute;top: 0;">开始动画</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import maleUrl from '@/assets/glb/男.glb?url'
|
||||||
|
import femaleUrl from '@/assets/glb/女.glb?url'
|
||||||
|
|
||||||
// const { ipcRenderer } = require('electron');
|
// const { ipcRenderer } = require('electron');
|
||||||
|
|
||||||
@ -16,22 +16,20 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|||||||
let scene, camera, renderer, model;
|
let scene, camera, renderer, model;
|
||||||
let targetQuaternion = new THREE.Quaternion();
|
let targetQuaternion = new THREE.Quaternion();
|
||||||
let currentQuaternion = new THREE.Quaternion();
|
let currentQuaternion = new THREE.Quaternion();
|
||||||
|
let animationId = 0;
|
||||||
|
let onResizeHandler = null;
|
||||||
|
|
||||||
// 调试控制参数
|
let axisMapping = { roll: 'z', pitch: 'x', yaw: 'y' };
|
||||||
let axisMapping = {
|
let manualOffsets = { x: 0, y: 0, z: 0 };
|
||||||
roll: 'z', // 默认Roll对应Z轴
|
|
||||||
pitch: 'x', // 默认Pitch对应X轴
|
|
||||||
yaw: 'y' // 默认Yaw对应Y轴
|
|
||||||
};
|
|
||||||
|
|
||||||
let manualOffsets = {
|
const props = defineProps({
|
||||||
x: 0,
|
rotation: { type: [Number, String], default: 0 },
|
||||||
y: 0,
|
tilt: { type: [Number, String], default: 0 },
|
||||||
z: 0
|
pitch: { type: [Number, String], default: 0 },
|
||||||
};
|
gender: { type: String, default: '男' }
|
||||||
|
})
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initThreeJS()
|
initThreeJS()
|
||||||
// initDebugControls();
|
|
||||||
})
|
})
|
||||||
function initThreeJS() {
|
function initThreeJS() {
|
||||||
// 创建场景
|
// 创建场景
|
||||||
@ -40,7 +38,7 @@ function initThreeJS() {
|
|||||||
let containermodel = document.getElementById('containermodel');
|
let containermodel = document.getElementById('containermodel');
|
||||||
// 创建相机
|
// 创建相机
|
||||||
camera = new THREE.PerspectiveCamera(75, containermodel.offsetWidth / containermodel.offsetHeight, 0.1, 1000);
|
camera = new THREE.PerspectiveCamera(75, containermodel.offsetWidth / containermodel.offsetHeight, 0.1, 1000);
|
||||||
camera.position.set(3, 3, 2);
|
camera.position.set(0, 0, 4);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
// 创建渲染器
|
// 创建渲染器
|
||||||
@ -55,31 +53,22 @@ function initThreeJS() {
|
|||||||
|
|
||||||
// 高强度照明系统
|
// 高强度照明系统
|
||||||
// 环境光 - 大幅提升整体亮度
|
// 环境光 - 大幅提升整体亮度
|
||||||
const ambientLight = new THREE.AmbientLight(0x606060, 2.0);
|
const ambientLight = new THREE.AmbientLight(0x606060, 10.0);
|
||||||
scene.add(ambientLight);
|
scene.add(ambientLight);
|
||||||
|
|
||||||
// 主方向光 - 从右上角照射,高强度
|
// 主方向光 - 从正前方照射,高强度
|
||||||
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.5);
|
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 3);
|
||||||
directionalLight1.position.set(10, 10, 5);
|
directionalLight1.position.set(0, 0, 6);
|
||||||
directionalLight1.castShadow = true;
|
|
||||||
directionalLight1.shadow.mapSize.width = 2048;
|
|
||||||
directionalLight1.shadow.mapSize.height = 2048;
|
|
||||||
directionalLight1.shadow.camera.near = 0.1;
|
|
||||||
directionalLight1.shadow.camera.far = 50;
|
|
||||||
directionalLight1.shadow.camera.left = -10;
|
|
||||||
directionalLight1.shadow.camera.right = 10;
|
|
||||||
directionalLight1.shadow.camera.top = 10;
|
|
||||||
directionalLight1.shadow.camera.bottom = -10;
|
|
||||||
scene.add(directionalLight1);
|
scene.add(directionalLight1);
|
||||||
|
|
||||||
// 左侧补光 - 更强的强度
|
// 左侧补光 - 更强的强度
|
||||||
const directionalLight2 = new THREE.DirectionalLight(0xbbddff, 1.0);
|
const directionalLight2 = new THREE.DirectionalLight(0xbbddff, 1.0);
|
||||||
directionalLight2.position.set(-8, 6, 4);
|
directionalLight2.position.set(-8, 6, 6);
|
||||||
scene.add(directionalLight2);
|
scene.add(directionalLight2);
|
||||||
|
|
||||||
// 右侧补光
|
// 右侧补光
|
||||||
const directionalLight3 = new THREE.DirectionalLight(0xffddbb, 0.8);
|
const directionalLight3 = new THREE.DirectionalLight(0xffddbb, 0.8);
|
||||||
directionalLight3.position.set(8, 6, -4);
|
directionalLight3.position.set(8, 6, 6);
|
||||||
scene.add(directionalLight3);
|
scene.add(directionalLight3);
|
||||||
|
|
||||||
// 顶部强光
|
// 顶部强光
|
||||||
@ -89,12 +78,9 @@ function initThreeJS() {
|
|||||||
|
|
||||||
// 底部补光(避免底部过暗)
|
// 底部补光(避免底部过暗)
|
||||||
const bottomLight = new THREE.DirectionalLight(0x8899bb, 0.5);
|
const bottomLight = new THREE.DirectionalLight(0x8899bb, 0.5);
|
||||||
bottomLight.position.set(0, -5, 0);
|
bottomLight.position.set(0, -6, 0);
|
||||||
scene.add(bottomLight);
|
scene.add(bottomLight);
|
||||||
|
|
||||||
// 添加坐标系轴线
|
|
||||||
createCoordinateAxes();
|
|
||||||
|
|
||||||
// 加载3D模型
|
// 加载3D模型
|
||||||
loadModel();
|
loadModel();
|
||||||
|
|
||||||
@ -106,19 +92,21 @@ function loadModel() {
|
|||||||
const loader = new GLTFLoader();
|
const loader = new GLTFLoader();
|
||||||
|
|
||||||
// 尝试加载Model.glb文件
|
// 尝试加载Model.glb文件
|
||||||
loader.load('Model.glb',
|
const url = (props.gender === '女') ? femaleUrl : maleUrl;
|
||||||
|
loader.load(url,
|
||||||
(gltf) => {
|
(gltf) => {
|
||||||
model = gltf.scene;
|
model = gltf.scene;
|
||||||
// 调整模型大小和位置
|
// 调整模型大小和位置
|
||||||
const box = new THREE.Box3().setFromObject(model);
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
const scale = 2 / maxDim; // 调整为合适大小
|
const scale = 5 / maxDim; // 调整为合适大小
|
||||||
model.scale.set(scale, scale, scale);
|
model.scale.set(scale, scale, scale);
|
||||||
|
|
||||||
// 将模型置于中心
|
// 将模型置于中心
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
model.position.set(-center.x * scale, -center.y * scale + 1, -center.z * scale);
|
model.position.set(-center.x * scale, -center.y * scale+0.2 , -center.z * scale);
|
||||||
|
model.rotation.set(0, 0, 0);
|
||||||
|
|
||||||
// 启用阴影
|
// 启用阴影
|
||||||
model.traverse((child) => {
|
model.traverse((child) => {
|
||||||
@ -158,7 +146,7 @@ function createFallbackModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function animate() {
|
function animate() {
|
||||||
requestAnimationFrame(animate);
|
animationId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
// 应用SLERP平滑插值到模型
|
// 应用SLERP平滑插值到模型
|
||||||
if (model) {
|
if (model) {
|
||||||
@ -170,326 +158,82 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 窗口大小调整
|
// 窗口大小调整
|
||||||
window.addEventListener('resize', () => {
|
onResizeHandler = () => {
|
||||||
let containermodel = document.getElementById('containermodel');
|
let containermodel = document.getElementById('containermodel');
|
||||||
|
|
||||||
camera.aspect = containermodel.offsetWidth / containermodel.offsetHeight;
|
camera.aspect = containermodel.offsetWidth / containermodel.offsetHeight;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setSize(containermodel.offsetWidth, containermodel.offsetHeight);
|
renderer.setSize(containermodel.offsetWidth, containermodel.offsetHeight);
|
||||||
});
|
};
|
||||||
function asd(){
|
window.addEventListener('resize', onResizeHandler);
|
||||||
setInterval(()=>{
|
|
||||||
targetQuaternion.set(
|
|
||||||
Math.random(),
|
|
||||||
Math.random(),
|
|
||||||
Math.random(),
|
|
||||||
Math.random()
|
|
||||||
);
|
|
||||||
},500)
|
|
||||||
|
|
||||||
}
|
|
||||||
// // IPC通信处理
|
|
||||||
// ipcRenderer.on('orientation-data', (event, data) => {
|
|
||||||
// if (data.quaternion && model) {
|
|
||||||
// // 显示原始四元数
|
|
||||||
// updateDebugDisplay('raw-quaternion', data.quaternion);
|
|
||||||
|
|
||||||
// // 应用轴向映射
|
|
||||||
// const mappedQuaternion = applyAxisMapping(data.quaternion);
|
|
||||||
// updateDebugDisplay('mapped-quaternion', mappedQuaternion);
|
|
||||||
|
|
||||||
// // 应用手动偏移
|
watch(
|
||||||
// const finalQuaternion = applyManualOffsets(mappedQuaternion);
|
() => [Number(props.rotation), Number(props.tilt), Number(props.pitch)],
|
||||||
// updateDebugDisplay('final-quaternion', finalQuaternion);
|
([rotation, tilt, pitch]) => {
|
||||||
|
const toRad = (deg) => (deg || 0) * Math.PI / 180;
|
||||||
// // 更新目标四元数
|
const euler = new THREE.Euler(toRad(pitch), toRad(rotation), toRad(tilt), 'XYZ');
|
||||||
// targetQuaternion.set(
|
const q = new THREE.Quaternion().setFromEuler(euler);
|
||||||
// finalQuaternion.x,
|
targetQuaternion.copy(q);
|
||||||
// finalQuaternion.y,
|
|
||||||
// finalQuaternion.z,
|
|
||||||
// finalQuaternion.w
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// ipcRenderer.on('device-connected', () => {
|
|
||||||
// updateConnectionStatus('已连接', true);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// ipcRenderer.on('device-disconnected', () => {
|
|
||||||
// updateConnectionStatus('已断开', false);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// ipcRenderer.on('server-info', (event, info) => {
|
|
||||||
// updateServerInfo(info);
|
|
||||||
// });
|
|
||||||
|
|
||||||
function updateConnectionStatus(status, connected) {
|
|
||||||
const statusElement = document.getElementById('connection-status');
|
|
||||||
statusElement.textContent = `设备状态: ${status}`;
|
|
||||||
statusElement.className = connected ? 'status connected' : 'status disconnected';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServerInfo(info) {
|
|
||||||
const serverInfoElement = document.getElementById('server-info');
|
|
||||||
|
|
||||||
serverInfoElement.innerHTML = `
|
|
||||||
服务器: ${info.ip}:${info.port}<br>
|
|
||||||
<strong>手机访问:</strong><br>
|
|
||||||
<a href="${info.mobileUrl}" target="_blank" style="color: #4CAF50;">${info.mobileUrl}</a><br>
|
|
||||||
<small>或: <a href="${info.alternativeUrl}" target="_blank" style="color: #4CAF50;">${info.alternativeUrl}</a></small>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 生成二维码
|
|
||||||
generateQRCode(info.mobileUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateQRCode(url) {
|
|
||||||
const canvas = document.getElementById('qr-canvas');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 使用qrcode库生成真正的二维码
|
|
||||||
const QRCode = require('qrcode');
|
|
||||||
|
|
||||||
// 二维码选项
|
|
||||||
const options = {
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
margin: 1,
|
|
||||||
color: {
|
|
||||||
dark: '#000000',
|
|
||||||
light: '#FFFFFF'
|
|
||||||
},
|
},
|
||||||
errorCorrectionLevel: 'M'
|
{ immediate: true }
|
||||||
};
|
)
|
||||||
|
|
||||||
// 生成二维码到canvas
|
watch(
|
||||||
await QRCode.toCanvas(canvas, url, options);
|
() => props.gender,
|
||||||
console.log('二维码生成成功:', url);
|
() => {
|
||||||
|
if (model) {
|
||||||
} catch (error) {
|
try {
|
||||||
console.error('二维码生成失败:', error);
|
scene.remove(model);
|
||||||
// 备用方案:显示文字
|
model.traverse((child) => {
|
||||||
const ctx = canvas.getContext('2d');
|
if (child.isMesh) {
|
||||||
ctx.fillStyle = '#ffffff';
|
child.geometry && child.geometry.dispose();
|
||||||
ctx.fillRect(0, 0, 120, 120);
|
if (Array.isArray(child.material)) {
|
||||||
ctx.fillStyle = '#000000';
|
child.material.forEach(m => m && m.dispose && m.dispose());
|
||||||
ctx.font = '12px Arial';
|
} else {
|
||||||
ctx.textAlign = 'center';
|
child.material && child.material.dispose && child.material.dispose();
|
||||||
ctx.fillText('QR生成失败', 60, 50);
|
|
||||||
ctx.font = '10px Arial';
|
|
||||||
ctx.fillText('请手动输入URL', 60, 70);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 轴向映射函数
|
|
||||||
function applyAxisMapping(quaternion) {
|
|
||||||
// 将四元数转换为欧拉角进行映射
|
|
||||||
const euler = new THREE.Euler().setFromQuaternion(
|
|
||||||
new THREE.Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 映射轴向
|
|
||||||
const mappedEuler = {
|
|
||||||
x: getAxisValue(euler, axisMapping.pitch),
|
|
||||||
y: getAxisValue(euler, axisMapping.yaw),
|
|
||||||
z: getAxisValue(euler, axisMapping.roll)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 转换回四元数
|
|
||||||
const resultQuaternion = new THREE.Quaternion().setFromEuler(
|
|
||||||
new THREE.Euler(mappedEuler.x, mappedEuler.y, mappedEuler.z)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
w: resultQuaternion.w,
|
|
||||||
x: resultQuaternion.x,
|
|
||||||
y: resultQuaternion.y,
|
|
||||||
z: resultQuaternion.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAxisValue(euler, mapping) {
|
|
||||||
const value = {
|
|
||||||
'x': euler.x,
|
|
||||||
'-x': -euler.x,
|
|
||||||
'y': euler.y,
|
|
||||||
'-y': -euler.y,
|
|
||||||
'z': euler.z,
|
|
||||||
'-z': -euler.z
|
|
||||||
}[mapping];
|
|
||||||
|
|
||||||
return value || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用手动偏移
|
|
||||||
function applyManualOffsets(quaternion) {
|
|
||||||
// 创建偏移四元数
|
|
||||||
const offsetQuaternion = new THREE.Quaternion().setFromEuler(
|
|
||||||
new THREE.Euler(
|
|
||||||
manualOffsets.x * Math.PI / 180,
|
|
||||||
manualOffsets.y * Math.PI / 180,
|
|
||||||
manualOffsets.z * Math.PI / 180
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 应用偏移
|
|
||||||
const originalQuaternion = new THREE.Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
|
|
||||||
const resultQuaternion = offsetQuaternion.multiply(originalQuaternion);
|
|
||||||
|
|
||||||
return {
|
|
||||||
w: resultQuaternion.w,
|
|
||||||
x: resultQuaternion.x,
|
|
||||||
y: resultQuaternion.y,
|
|
||||||
z: resultQuaternion.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新调试显示
|
|
||||||
function updateDebugDisplay(elementId, quaternion) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.textContent = `w:${quaternion.w.toFixed(3)}, x:${quaternion.x.toFixed(3)}, y:${quaternion.y.toFixed(3)}, z:${quaternion.z.toFixed(3)}`;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 创建坐标系轴线
|
|
||||||
function createCoordinateAxes() {
|
|
||||||
const axesGroup = new THREE.Group();
|
|
||||||
|
|
||||||
// 轴线长度和粗细
|
|
||||||
const axisLength = 3;
|
|
||||||
const axisRadius = 0.02;
|
|
||||||
|
|
||||||
// X轴 - 亮红色,使用自发光材质
|
|
||||||
const xGeometry = new THREE.CylinderGeometry(axisRadius, axisRadius, axisLength, 8);
|
|
||||||
const xMaterial = new THREE.MeshBasicMaterial({
|
|
||||||
color: 0xff6666,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.9
|
|
||||||
});
|
});
|
||||||
const xAxis = new THREE.Mesh(xGeometry, xMaterial);
|
} catch {}
|
||||||
xAxis.rotation.z = -Math.PI / 2;
|
model = null;
|
||||||
xAxis.position.x = axisLength / 2;
|
}
|
||||||
axesGroup.add(xAxis);
|
loadModel();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// X轴箭头
|
onUnmounted(() => {
|
||||||
const xArrowGeometry = new THREE.ConeGeometry(axisRadius * 3, axisRadius * 8, 8);
|
try { animationId && cancelAnimationFrame(animationId); } catch {}
|
||||||
const xArrow = new THREE.Mesh(xArrowGeometry, xMaterial);
|
try { window.removeEventListener('resize', onResizeHandler || (()=>{})); } catch {}
|
||||||
xArrow.rotation.z = -Math.PI / 2;
|
try {
|
||||||
xArrow.position.x = axisLength + axisRadius * 4;
|
if (scene) {
|
||||||
axesGroup.add(xArrow);
|
scene.traverse((obj) => {
|
||||||
|
if (obj.isMesh) {
|
||||||
// Y轴 - 亮绿色,使用自发光材质
|
obj.geometry && obj.geometry.dispose();
|
||||||
const yGeometry = new THREE.CylinderGeometry(axisRadius, axisRadius, axisLength, 8);
|
if (Array.isArray(obj.material)) {
|
||||||
const yMaterial = new THREE.MeshBasicMaterial({
|
obj.material.forEach(m => m && m.dispose && m.dispose());
|
||||||
color: 0x66ff66,
|
} else {
|
||||||
transparent: true,
|
obj.material && obj.material.dispose && obj.material.dispose();
|
||||||
opacity: 0.9
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const yAxis = new THREE.Mesh(yGeometry, yMaterial);
|
}
|
||||||
yAxis.position.y = axisLength / 2;
|
} catch {}
|
||||||
axesGroup.add(yAxis);
|
try { renderer && renderer.dispose && renderer.dispose(); } catch {}
|
||||||
|
try {
|
||||||
// Y轴箭头
|
const container = document.getElementById('containermodel');
|
||||||
const yArrowGeometry = new THREE.ConeGeometry(axisRadius * 3, axisRadius * 8, 8);
|
if (container) {
|
||||||
const yArrow = new THREE.Mesh(yArrowGeometry, yMaterial);
|
while (container.firstChild) container.removeChild(container.firstChild);
|
||||||
yArrow.position.y = axisLength + axisRadius * 4;
|
}
|
||||||
axesGroup.add(yArrow);
|
} catch {}
|
||||||
|
scene = null;
|
||||||
// Z轴 - 亮蓝色,使用自发光材质
|
camera = null;
|
||||||
const zGeometry = new THREE.CylinderGeometry(axisRadius, axisRadius, axisLength, 8);
|
renderer = null;
|
||||||
const zMaterial = new THREE.MeshBasicMaterial({
|
model = null;
|
||||||
color: 0x6666ff,
|
})
|
||||||
transparent: true,
|
|
||||||
opacity: 0.9
|
|
||||||
});
|
|
||||||
const zAxis = new THREE.Mesh(zGeometry, zMaterial);
|
|
||||||
zAxis.rotation.x = Math.PI / 2;
|
|
||||||
zAxis.position.z = axisLength / 2;
|
|
||||||
axesGroup.add(zAxis);
|
|
||||||
|
|
||||||
// Z轴箭头
|
|
||||||
const zArrowGeometry = new THREE.ConeGeometry(axisRadius * 3, axisRadius * 8, 8);
|
|
||||||
const zArrow = new THREE.Mesh(zArrowGeometry, zMaterial);
|
|
||||||
zArrow.rotation.x = Math.PI / 2;
|
|
||||||
zArrow.position.z = axisLength + axisRadius * 4;
|
|
||||||
axesGroup.add(zArrow);
|
|
||||||
|
|
||||||
// 原点球 - 亮白色自发光
|
|
||||||
const originGeometry = new THREE.SphereGeometry(axisRadius * 2, 16, 16);
|
|
||||||
const originMaterial = new THREE.MeshBasicMaterial({
|
|
||||||
color: 0xffffff,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.8
|
|
||||||
});
|
|
||||||
const origin = new THREE.Mesh(originGeometry, originMaterial);
|
|
||||||
axesGroup.add(origin);
|
|
||||||
|
|
||||||
scene.add(axesGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化调试控制
|
|
||||||
function initDebugControls() {
|
|
||||||
// 轴向映射下拉框事件
|
|
||||||
document.getElementById('roll-mapping').addEventListener('change', (e) => {
|
|
||||||
axisMapping.roll = e.target.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pitch-mapping').addEventListener('change', (e) => {
|
|
||||||
axisMapping.pitch = e.target.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('yaw-mapping').addEventListener('change', (e) => {
|
|
||||||
axisMapping.yaw = e.target.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 手动偏移滑动条事件
|
|
||||||
['x', 'y', 'z'].forEach(axis => {
|
|
||||||
const slider = document.getElementById(`offset-${axis}`);
|
|
||||||
const valueDisplay = document.getElementById(`offset-${axis}-value`);
|
|
||||||
|
|
||||||
slider.addEventListener('input', (e) => {
|
|
||||||
const value = parseInt(e.target.value);
|
|
||||||
manualOffsets[axis] = value;
|
|
||||||
valueDisplay.textContent = `${value}°`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 重置偏移按钮
|
|
||||||
document.getElementById('reset-offsets').addEventListener('click', () => {
|
|
||||||
['x', 'y', 'z'].forEach(axis => {
|
|
||||||
manualOffsets[axis] = 0;
|
|
||||||
document.getElementById(`offset-${axis}`).value = 0;
|
|
||||||
document.getElementById(`offset-${axis}-value`).textContent = '0°';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
initThreeJS();
|
|
||||||
initDebugControls();
|
|
||||||
|
|
||||||
// 请求服务器信息
|
|
||||||
ipcRenderer.invoke('get-server-info').then(info => {
|
|
||||||
updateServerInfo(info);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加键盘控制(可选)
|
// 添加键盘控制(可选)
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
switch(event.key) {
|
|
||||||
case 'r':
|
|
||||||
case 'R':
|
|
||||||
// 重置模型旋转
|
|
||||||
targetQuaternion.set(0, 0, 0, 1);
|
|
||||||
currentQuaternion.set(0, 0, 0, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user