修改了代码和bug

This commit is contained in:
root 2025-12-02 08:53:04 +08:00
parent 900ca4dd2c
commit 22e6a3f48a
16 changed files with 410 additions and 1237 deletions

View File

@ -19,7 +19,7 @@ path = D:/BodyCheck/file/
[CAMERA1]
enabled = True
device_index = 0
device_index = 2
width = 1280
height = 720
fps = 30
@ -29,7 +29,7 @@ backend = directshow
[CAMERA2]
enabled = True
device_index = 1
device_index = 0
width = 1280
height = 720
fps = 30
@ -50,12 +50,12 @@ synchronized_images_only = False
[DEVICES]
imu_enabled = True
imu_device_type = ble
imu_device_type = mock
imu_port = COM9
imu_mac_address = ef:3c:1a:0a:fe:02
imu_baudrate = 9600
pressure_enabled = True
pressure_device_type = real
pressure_device_type = mock
pressure_use_mock = False
pressure_port = COM5
pressure_baudrate = 115200

View File

@ -311,7 +311,7 @@ class BaseDevice(ABC):
try:
# 检查硬件连接状态
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:
self.logger.warning(f"检测到设备 {self.device_name} 硬件连接断开")

View File

@ -4,201 +4,25 @@
IMU传感器管理器
负责IMU传感器的连接校准和头部姿态数据采集
"""
import serial
import threading
import time
import json
import numpy as np
from typing import Optional, Dict, Any, List, Tuple
from typing import Optional, Dict, Any
import logging
from collections import deque
import struct
from datetime import datetime
import asyncio
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 # 串口端口号如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:
"""蓝牙IMU设备基于bleak实现解析逻辑参考tests/testblueimu.py"""
def __init__(self, mac_address: str):
@ -445,6 +269,101 @@ class BleIMUDevice:
def connected(self) -> bool:
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):
"""IMU传感器管理器"""
@ -470,7 +389,6 @@ class IMUManager(BaseDevice):
self.port = config.get('port', 'COM7')
self.baudrate = config.get('baudrate', 9600)
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', '')
# IMU设备实例
self.imu_device = None
@ -517,28 +435,16 @@ class IMUManager(BaseDevice):
self.imu_device.start()
# 使用set_connected方法来正确启动连接监控线程
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:
self.logger.info("使用模拟IMU设备")
self.imu_device = MockIMUDevice()
self.imu_device.start()
# 使用set_connected方法来正确启动连接监控线程
self.set_connected(True)
self._device_info.update({
'port': self.port,
'baudrate': self.baudrate,
'use_mock': self.use_mock,
'mac_address': self.mac_address,
})
@ -696,13 +602,6 @@ class IMUManager(BaseDevice):
data = self.imu_device.read_data(apply_calibration=True)
if data:
# 缓存数据
# self.data_buffer.append(data)
# self.last_valid_data = data
# 更新心跳时间,防止连接监控线程判定为超时
# self.update_heartbeat()
# 发送数据到前端
if self._socketio:
self._socketio.emit('imu_data', data, namespace='/devices')
@ -792,7 +691,6 @@ class IMUManager(BaseDevice):
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)
self.mac_address = config.get('mac_address', '')
# 更新数据缓存队列大小

View File

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

View File

@ -463,7 +463,186 @@ class RealPressureDevice:
"""析构函数,确保资源清理"""
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):
"""压力板管理器"""

View File

@ -1,12 +1,12 @@
{
"name": "body-balance-renderer",
"version": "1.0.0",
"name": "body-balance-system",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "body-balance-renderer",
"version": "1.0.0",
"name": "body-balance-system",
"version": "1.5.0",
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.0",
@ -15,6 +15,7 @@
"html2canvas": "^1.4.1",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"three": "^0.160.0",
"vue": "^3.3.4",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.4"
@ -5790,6 +5791,12 @@
"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": {
"version": "0.2.5",
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",

View File

@ -20,6 +20,7 @@
"echarts": "^5.4.3",
"element-plus": "^2.3.9",
"html2canvas": "^1.4.1",
"three": "^0.160.0",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"vue": "^3.3.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -30,10 +30,8 @@
<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="gender" label="性别" width="120" align="center">
<template #default="scope">
<span v-if="scope.row.gender === 'male'"></span>
<span v-else ></span>
<span>{{ scope.row.gender || '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="num" label="测试次数" width="100" align="center" />
@ -90,7 +88,7 @@
<div class="patient-detail-contentleft">性别</div>
<div class="patient-detail-contentright">
<span v-if="selectedPatient && selectedPatient.gender">
{{ selectedPatient.gender =='male'?'男':'女' }}
{{ selectedPatient.gender }}
</span>
<span v-else></span>
</div>

View File

@ -10,7 +10,7 @@
color: #00CC00;
margin-left:20px">检测中...</div>
<div class="patientInfotop1">{{ patientInfo.name }}</div>
<div> {{ patientInfo.gender =='male'||patientInfo.gender =='男' ?'男':'女' }}</div>
<div> {{ patientInfo.gender }}</div>
<div class="username-line"></div>
<div>{{ calculateAge(patientInfo.birth_date) }}</div>
<el-button type="primary" class="endbutton" @click="endClick">结束监测</el-button>
@ -128,9 +128,7 @@
<span class="currencytext3">{{ headlist.rotation }}°</span>
</div>
<div style="width: 100%;height: 80%;" alt="">
<!-- <img src="@/assets/new/testheader.png" > -->
<Model />
<Model :rotation="Number(headlist.rotation)" :tilt="Number(headlist.tilt)" :pitch="Number(headlist.pitch)" :gender="patientInfo.gender || '男'" />
</div>
</div>
@ -261,7 +259,7 @@
<div>
<div class="userinfo-text-top">
<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-text2">{{ calculateAge(patientInfo.birth_date) }}</div>
</div>
@ -707,9 +705,9 @@ const formattedTime = computed(() => {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
});
const headlist = ref({
rotation: '0',
tilt: '0',
pitch: '0'
rotation: 0,
tilt: 0,
pitch: 0
})
//
const startTimer = () => {
@ -987,13 +985,14 @@ function connectWebSocket() {
if (!tempInfo.value.camera_frames) {
tempInfo.value.camera_frames = {}
}
if (devId === 'camera2') {
tempInfo.value.camera_frames['camera2'] = data
displayCameraFrameById('camera2', data.image)
} else {
// camera1 device_id
if (devId === 'camera1') {
tempInfo.value.camera_frames['camera1'] = data
tempInfo.value.camera1_frame = data
displayCameraFrameById('camera1', data.image)
} else if (devId === 'camera2') {
tempInfo.value.camera_frames['camera2'] = data
tempInfo.value.camera2_frame = data
displayCameraFrameById('camera2', data.image)
}
})

View File

@ -241,13 +241,9 @@ const validateForm = async () => {
}
const savePatient = async () => {
//
const genderMap = { '男': 'male', '女': 'female' }
const genderValue = genderMap[patientForm.gender] || patientForm.gender
const patientData = {
name: patientForm.name,
gender: genderValue,
gender: patientForm.gender,
birth_date: patientForm.birth_date,
height: parseFloat(patientForm.height) || null,
weight: parseFloat(patientForm.weight) || null,

View File

@ -1,14 +1,14 @@
<template>
<div id="containermodel" >
<div @click="asd" style="position: absolute;top: 0;">开始动画</div>
</div>
<div id="containermodel"></div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import * as THREE from 'three';
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');
@ -16,22 +16,20 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
let scene, camera, renderer, model;
let targetQuaternion = new THREE.Quaternion();
let currentQuaternion = new THREE.Quaternion();
let animationId = 0;
let onResizeHandler = null;
//
let axisMapping = {
roll: 'z', // RollZ
pitch: 'x', // PitchX
yaw: 'y' // YawY
};
let axisMapping = { roll: 'z', pitch: 'x', yaw: 'y' };
let manualOffsets = { x: 0, y: 0, z: 0 };
let manualOffsets = {
x: 0,
y: 0,
z: 0
};
const props = defineProps({
rotation: { type: [Number, String], default: 0 },
tilt: { type: [Number, String], default: 0 },
pitch: { type: [Number, String], default: 0 },
gender: { type: String, default: '男' }
})
onMounted(() => {
initThreeJS()
// initDebugControls();
})
function initThreeJS() {
//
@ -40,7 +38,7 @@ function initThreeJS() {
let containermodel = document.getElementById('containermodel');
//
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);
//
@ -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);
// -
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight1.position.set(10, 10, 5);
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;
// -
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 3);
directionalLight1.position.set(0, 0, 6);
scene.add(directionalLight1);
// -
const directionalLight2 = new THREE.DirectionalLight(0xbbddff, 1.0);
directionalLight2.position.set(-8, 6, 4);
directionalLight2.position.set(-8, 6, 6);
scene.add(directionalLight2);
//
const directionalLight3 = new THREE.DirectionalLight(0xffddbb, 0.8);
directionalLight3.position.set(8, 6, -4);
directionalLight3.position.set(8, 6, 6);
scene.add(directionalLight3);
//
@ -89,12 +78,9 @@ function initThreeJS() {
//
const bottomLight = new THREE.DirectionalLight(0x8899bb, 0.5);
bottomLight.position.set(0, -5, 0);
bottomLight.position.set(0, -6, 0);
scene.add(bottomLight);
// 线
createCoordinateAxes();
// 3D
loadModel();
@ -106,19 +92,21 @@ function loadModel() {
const loader = new GLTFLoader();
// Model.glb
loader.load('Model.glb',
const url = (props.gender === '女') ? femaleUrl : maleUrl;
loader.load(url,
(gltf) => {
model = gltf.scene;
//
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim; //
const scale = 5 / maxDim; //
model.scale.set(scale, scale, scale);
//
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) => {
@ -158,7 +146,7 @@ function createFallbackModel() {
}
function animate() {
requestAnimationFrame(animate);
animationId = requestAnimationFrame(animate);
// SLERP
if (model) {
@ -170,326 +158,82 @@ function animate() {
}
//
window.addEventListener('resize', () => {
onResizeHandler = () => {
let containermodel = document.getElementById('containermodel');
camera.aspect = containermodel.offsetWidth / containermodel.offsetHeight;
camera.updateProjectionMatrix();
renderer.setSize(containermodel.offsetWidth, containermodel.offsetHeight);
});
function asd(){
setInterval(()=>{
targetQuaternion.set(
Math.random(),
Math.random(),
Math.random(),
Math.random()
);
},500)
};
window.addEventListener('resize', onResizeHandler);
}
// // 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);
// //
// const finalQuaternion = applyManualOffsets(mappedQuaternion);
// updateDebugDisplay('final-quaternion', finalQuaternion);
// //
// targetQuaternion.set(
// finalQuaternion.x,
// 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'
watch(
() => [Number(props.rotation), Number(props.tilt), Number(props.pitch)],
([rotation, tilt, pitch]) => {
const toRad = (deg) => (deg || 0) * Math.PI / 180;
const euler = new THREE.Euler(toRad(pitch), toRad(rotation), toRad(tilt), 'XYZ');
const q = new THREE.Quaternion().setFromEuler(euler);
targetQuaternion.copy(q);
},
errorCorrectionLevel: 'M'
};
{ immediate: true }
)
// canvas
await QRCode.toCanvas(canvas, url, options);
console.log('二维码生成成功:', url);
} catch (error) {
console.error('二维码生成失败:', error);
//
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 120, 120);
ctx.fillStyle = '#000000';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText('QR生成失败', 60, 50);
ctx.font = '10px Arial';
ctx.fillText('请手动输入URL', 60, 70);
watch(
() => props.gender,
() => {
if (model) {
try {
scene.remove(model);
model.traverse((child) => {
if (child.isMesh) {
child.geometry && child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach(m => m && m.dispose && m.dispose());
} else {
child.material && child.material.dispose && child.material.dispose();
}
}
//
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);
xAxis.rotation.z = -Math.PI / 2;
xAxis.position.x = axisLength / 2;
axesGroup.add(xAxis);
} catch {}
model = null;
}
loadModel();
}
)
// X
const xArrowGeometry = new THREE.ConeGeometry(axisRadius * 3, axisRadius * 8, 8);
const xArrow = new THREE.Mesh(xArrowGeometry, xMaterial);
xArrow.rotation.z = -Math.PI / 2;
xArrow.position.x = axisLength + axisRadius * 4;
axesGroup.add(xArrow);
// Y - 绿使
const yGeometry = new THREE.CylinderGeometry(axisRadius, axisRadius, axisLength, 8);
const yMaterial = new THREE.MeshBasicMaterial({
color: 0x66ff66,
transparent: true,
opacity: 0.9
onUnmounted(() => {
try { animationId && cancelAnimationFrame(animationId); } catch {}
try { window.removeEventListener('resize', onResizeHandler || (()=>{})); } catch {}
try {
if (scene) {
scene.traverse((obj) => {
if (obj.isMesh) {
obj.geometry && obj.geometry.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m && m.dispose && m.dispose());
} else {
obj.material && obj.material.dispose && obj.material.dispose();
}
}
});
const yAxis = new THREE.Mesh(yGeometry, yMaterial);
yAxis.position.y = axisLength / 2;
axesGroup.add(yAxis);
// Y
const yArrowGeometry = new THREE.ConeGeometry(axisRadius * 3, axisRadius * 8, 8);
const yArrow = new THREE.Mesh(yArrowGeometry, yMaterial);
yArrow.position.y = axisLength + axisRadius * 4;
axesGroup.add(yArrow);
// Z - 使
const zGeometry = new THREE.CylinderGeometry(axisRadius, axisRadius, axisLength, 8);
const zMaterial = new THREE.MeshBasicMaterial({
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);
});
});
}
} catch {}
try { renderer && renderer.dispose && renderer.dispose(); } catch {}
try {
const container = document.getElementById('containermodel');
if (container) {
while (container.firstChild) container.removeChild(container.firstChild);
}
} catch {}
scene = null;
camera = null;
renderer = null;
model = null;
})
//
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>
<style scoped>