修复录屏0字节问题,支持压力板多厂家切换
- 录屏: 高DPI缩放导致奇数尺寸触发libx264编码失败,宽高取偶; ffmpeg stderr写入日志 - 压力板: 新增 pressure_manager_small.py 原厂家小压力板,与现有大压力板并存 - 打包: 新增 build_backend.ps1,更新 requirements_build.txt / config.ini - 前端: Electron 本地化配置
This commit is contained in:
parent
c55f128c22
commit
d91af11b7d
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@ -5,6 +5,7 @@
|
||||
"name": "Debug Main.py",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${workspaceFolder}/backend/venv/Scripts/python.exe",
|
||||
"program": "${workspaceFolder}/backend/main.py",
|
||||
"console": "integratedTerminal",
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
40
backend/_test_rec.py
Normal file
40
backend/_test_rec.py
Normal file
@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""临时诊断脚本:以奇数尺寸 1707x1067 验证取偶修复"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from devices.screen_recorder import RecordingManager
|
||||
|
||||
mgr = RecordingManager()
|
||||
print('screen_size:', mgr.screen_size, flush=True)
|
||||
|
||||
r = mgr.start_recording_ffmpeg('TEST_SESSION', 'TEST_PATIENT', [0, 0, 1707, 1067])
|
||||
print('start result:', r, flush=True)
|
||||
|
||||
for i in range(5):
|
||||
time.sleep(2)
|
||||
proc = mgr._ffmpeg_processes.get('screen')
|
||||
alive = proc.poll() if proc else 'no-proc'
|
||||
print(f'[{i}] proc.poll={alive}', flush=True)
|
||||
|
||||
rr = mgr.stop_recording_ffmpeg('TEST_SESSION')
|
||||
print('stop result:', rr, flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
base = mgr._ffmpeg_meta.get('screen') or {}
|
||||
base_path = base.get('base_path')
|
||||
if base_path:
|
||||
for f in sorted(os.listdir(base_path)):
|
||||
p = os.path.join(base_path, f)
|
||||
print(f'FILE: {f} size={os.path.getsize(p)}', flush=True)
|
||||
log = os.path.join(base_path, 'ffmpeg_screen.log')
|
||||
if os.path.exists(log):
|
||||
print('--- ffmpeg_screen.log ---', flush=True)
|
||||
with open(log, 'r', encoding='utf-8', errors='ignore') as fh:
|
||||
print(fh.read()[-2000:], flush=True)
|
||||
print('DONE', flush=True)
|
||||
@ -9,6 +9,8 @@ import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def clean_build_dirs():
|
||||
@ -151,24 +153,72 @@ coll = COLLECT(
|
||||
|
||||
print("✓ 已创建 app.spec 文件")
|
||||
|
||||
def build_exe():
|
||||
def run_command_realtime(cmd, timeout_seconds=1800):
|
||||
"""实时打印子进程日志,并支持超时保护。"""
|
||||
print(f"执行命令: {' '.join(cmd)}")
|
||||
print(f"超时保护: {timeout_seconds}s")
|
||||
start_time = time.time()
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='ignore',
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
while True:
|
||||
if process.stdout is not None:
|
||||
line = process.stdout.readline()
|
||||
else:
|
||||
line = ''
|
||||
if line:
|
||||
print(line, end='')
|
||||
|
||||
if process.poll() is not None:
|
||||
if process.stdout is not None:
|
||||
remaining = process.stdout.read()
|
||||
if remaining:
|
||||
print(remaining, end='')
|
||||
return process.returncode == 0
|
||||
|
||||
if (time.time() - start_time) > timeout_seconds:
|
||||
print(f"\n✗ 构建超时(>{timeout_seconds}s),正在终止 PyInstaller 进程...")
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ 命令执行异常: {e}")
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def build_exe(use_clean=False, timeout_seconds=1800):
|
||||
"""构建exe文件"""
|
||||
print("\n开始构建exe文件...")
|
||||
|
||||
try:
|
||||
# 使用PyInstaller构建
|
||||
cmd = [sys.executable, '-m', 'PyInstaller', 'app.spec', '--clean', '--noconfirm']
|
||||
print(f"执行命令: {' '.join(cmd)}")
|
||||
|
||||
# 修改编码处理,避免UTF-8错误
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
|
||||
|
||||
if result.returncode == 0:
|
||||
# 使用PyInstaller构建(实时输出日志)
|
||||
cmd = [sys.executable, '-m', 'PyInstaller', 'app.spec', '--noconfirm']
|
||||
if use_clean:
|
||||
cmd.append('--clean')
|
||||
|
||||
ok = run_command_realtime(cmd, timeout_seconds=timeout_seconds)
|
||||
if ok:
|
||||
print("✓ 构建成功!")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ 构建失败")
|
||||
print(f"错误输出: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
@ -313,6 +363,12 @@ def install_build_dependencies():
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='BodyBalanceBackend 打包工具')
|
||||
parser.add_argument('--clean', action='store_true', help='传递 --clean 给 PyInstaller(更慢但更彻底)')
|
||||
parser.add_argument('--timeout', type=int, default=1800, help='PyInstaller 超时秒数(默认 1800)')
|
||||
parser.add_argument('--no-pause', action='store_true', help='执行结束后不等待回车(适合CI)')
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("身体平衡评估系统 - main.py 完整版打包工具")
|
||||
print("=" * 60)
|
||||
@ -340,7 +396,7 @@ def main():
|
||||
print()
|
||||
|
||||
# 构建exe
|
||||
if build_exe():
|
||||
if build_exe(use_clean=args.clean, timeout_seconds=args.timeout):
|
||||
print()
|
||||
print("后处理...")
|
||||
|
||||
@ -398,7 +454,8 @@ def main():
|
||||
print(f"\n✗ 打包过程出错: {e}")
|
||||
|
||||
print()
|
||||
input("按回车键退出...")
|
||||
if not args.no_pause:
|
||||
input("按回车键退出...")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
67
backend/build_backend.ps1
Normal file
67
backend/build_backend.ps1
Normal file
@ -0,0 +1,67 @@
|
||||
# ============================================================
|
||||
# 后端快速打包命令 (PowerShell)
|
||||
# 调用 build_app.py (PyInstaller) 一键打包后端为 exe
|
||||
#
|
||||
# 用法:
|
||||
# .\build_backend.ps1 # 一键打包
|
||||
# .\build_backend.ps1 -Clean # 彻底重建(更慢更稳)
|
||||
# .\build_backend.ps1 -Timeout 3600 # 自定义构建超时(秒)
|
||||
# ============================================================
|
||||
|
||||
param(
|
||||
[switch]$Clean,
|
||||
[int]$Timeout = 1800
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# 兼容中文输出编码
|
||||
try {
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
} catch {}
|
||||
|
||||
# 切换到脚本所在目录
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
$pythonExe = Join-Path $PSScriptRoot 'venv\Scripts\python.exe'
|
||||
|
||||
# 检查虚拟环境
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
Write-Host "[错误] 未找到虚拟环境: $pythonExe" -ForegroundColor Red
|
||||
Write-Host "请先创建虚拟环境并安装依赖:"
|
||||
Write-Host " python -m venv venv"
|
||||
Write-Host " venv\Scripts\pip install -r requirements.txt"
|
||||
Write-Host " venv\Scripts\pip install -r requirements_build.txt"
|
||||
Read-Host "`n按回车键退出"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "============================================================"
|
||||
Write-Host " 后端快速打包 (PyInstaller)"
|
||||
Write-Host " 虚拟环境: $pythonExe"
|
||||
Write-Host " 附加参数: Clean=$Clean Timeout=$Timeout"
|
||||
Write-Host " 输出目录: dist\BodyBalanceBackend\"
|
||||
Write-Host "============================================================"
|
||||
Write-Host ""
|
||||
|
||||
# 组装 build_app.py 参数
|
||||
$buildArgs = @('build_app.py', '--no-pause')
|
||||
if ($Clean) {
|
||||
$buildArgs += '--clean'
|
||||
}
|
||||
$buildArgs += '--timeout', "$Timeout"
|
||||
|
||||
& $pythonExe @buildArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($null -eq $exitCode) {
|
||||
$exitCode = 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "[成功] 后端打包完成,产物位于 dist\BodyBalanceBackend\" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[失败] 后端打包失败,退出码 $exitCode,请查看上方日志" -ForegroundColor Red
|
||||
}
|
||||
Read-Host "`n按回车键退出"
|
||||
exit $exitCode
|
||||
@ -29,7 +29,7 @@ fourcc = MJPG
|
||||
backend = directshow
|
||||
|
||||
[CAMERA2]
|
||||
enable = True
|
||||
enable = False
|
||||
device_index = 2
|
||||
width = 1280
|
||||
height = 720
|
||||
@ -39,7 +39,7 @@ fourcc = MJPG
|
||||
backend = directshow
|
||||
|
||||
[FEMTOBOLT]
|
||||
enable = True
|
||||
enable = False
|
||||
algorithm_type = plt
|
||||
color_resolution = 1080P
|
||||
depth_mode = NFOV_2X2BINNED
|
||||
@ -50,18 +50,31 @@ fps = 15
|
||||
synchronized_images_only = False
|
||||
|
||||
[DEVICES]
|
||||
imu_enable = True
|
||||
imu_use_mock = False
|
||||
imu_enable = False
|
||||
imu_use_mock = True
|
||||
imu_ble_name = WT901BLE67
|
||||
imu_mac_address = FA:E8:88:06:FE:F3
|
||||
pressure_enable = True
|
||||
; pressure_enable = True
|
||||
; pressure_use_mock = True
|
||||
; pressure_port = COM3
|
||||
; pressure_baudrate = 115200
|
||||
pressure_enable = False
|
||||
pressure_use_mock = True
|
||||
pressure_port = COM3
|
||||
pressure_baudrate = 115200
|
||||
pressure_source = shared_memory
|
||||
|
||||
pressure_shared_memory_name = x2_pressure
|
||||
pressure_shared_memory_header_bytes = 72
|
||||
pressure_shared_memory_rows = 288
|
||||
pressure_shared_memory_cols = 64
|
||||
pressure_shared_memory_dtype = float32
|
||||
pressure_shared_memory_crop_rows = 0
|
||||
pressure_shared_memory_low_percentile = 5
|
||||
pressure_shared_memory_high_percentile = 98
|
||||
pressure_shared_memory_gamma = 1.0
|
||||
pressure_shared_memory_ema_alpha = 0.1
|
||||
[REMOTE]
|
||||
enable = False
|
||||
port = COM6
|
||||
port = COM16
|
||||
baudrate = 115200
|
||||
timeout = 0.1
|
||||
strict_crc = False
|
||||
|
||||
237
backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py
Normal file
237
backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py
Normal file
@ -0,0 +1,237 @@
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import multiprocessing.shared_memory as shared_memory
|
||||
import time
|
||||
import zlib
|
||||
from matplotlib.animation import FuncAnimation
|
||||
import logging
|
||||
|
||||
# 本文件用于演示如何从厂家提供的共享内存中持续读取压力矩阵,
|
||||
# 并实时可视化为热力图。代码仅做读取与显示,不负责写入共享内存。
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
class SharedMemoryContext:
|
||||
"""共享内存上下文管理器。
|
||||
|
||||
作用:
|
||||
- 进入 with 语句时连接共享内存。
|
||||
- 退出 with 语句时自动关闭连接,避免句柄泄漏。
|
||||
"""
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.shm = None
|
||||
|
||||
def __enter__(self):
|
||||
# 按名字连接已存在的共享内存(由厂家驱动/进程创建)
|
||||
self.shm = shared_memory.SharedMemory(name=self.name)
|
||||
logging.info(f"Connected to shared memory: {self.name}")
|
||||
return self.shm
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
# 只关闭连接,不 unlink(不能销毁由外部创建的共享内存)
|
||||
if self.shm is not None:
|
||||
self.shm.close()
|
||||
logging.info(f"Closed shared memory: {self.name}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def read_shared_memory_stream(name="x2_pressure"):
|
||||
"""生成器函数:持续从共享内存读取数据流。
|
||||
|
||||
共享内存协议(按当前厂家示例):
|
||||
- 前 72 字节:头部(元数据,当前示例未解析)
|
||||
- 后续数据区:288 * 64 个 float32(4 字节),总计 73728 字节
|
||||
- 数据矩阵形状:reshape 为 (288, 64)
|
||||
|
||||
Yields:
|
||||
tuple(counter, event_tick, datas)
|
||||
- counter: 帧序号(从 0 开始)
|
||||
- event_tick: 相对启动时间(毫秒)
|
||||
- datas: ndarray,shape=(288,64),dtype=float32
|
||||
"""
|
||||
counter = 0 # 帧计数器
|
||||
start_tick = int(time.time() * 1000) # 记录开始时间戳
|
||||
while True:
|
||||
try:
|
||||
# 每次循环使用 with 连接共享内存,确保异常时也能释放句柄
|
||||
with SharedMemoryContext(name) as shm:
|
||||
# 建立连接后,持续读取同一块共享内存
|
||||
while True:
|
||||
# 以 uint8 视图读取原始字节流(零拷贝)
|
||||
buffer = np.frombuffer(shm.buf, dtype=np.uint8)
|
||||
# 验证缓冲区长度是否满足“头部 + 数据区”最小要求
|
||||
if buffer.nbytes < 72 + 288 * 64 * 4:
|
||||
logging.warning("Data size is insufficient. Waiting for more data...")
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# 跳过 72 字节头部,仅解析数据区并转换为 float32 矩阵
|
||||
header = bytes(buffer[:72])
|
||||
datas = buffer[72:72 + 288 * 64 * 4].view(np.float32).reshape((288, 64))
|
||||
event_tick = int(time.time() * 1000) - start_tick # 计算相对时间戳
|
||||
# 通过生成器把一帧数据交给上层消费者(可视化或业务处理)
|
||||
yield counter, event_tick, datas, header
|
||||
counter += 1
|
||||
|
||||
except FileNotFoundError:
|
||||
# 共享内存尚未创建:通常是写端程序未启动
|
||||
logging.warning("Waiting for shared memory...")
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
# 手动中断(Ctrl+C)时优雅退出
|
||||
break
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def visualize_data_stream(
|
||||
data_stream,
|
||||
update_interval=50,
|
||||
low_percentile=5,
|
||||
high_percentile=98,
|
||||
gamma=1.0,
|
||||
ema_alpha=0.1,
|
||||
):
|
||||
"""实时可视化数据流。
|
||||
|
||||
Args:
|
||||
data_stream: 来自 read_shared_memory_stream 的生成器
|
||||
update_interval: 动画刷新间隔(毫秒)
|
||||
low_percentile: 低端分位数(用于抑制噪声地板)
|
||||
high_percentile: 高端分位数(用于抑制少量极值,提升主体对比度)
|
||||
gamma: 非线性增强系数,>1 时高压区域颜色更“深”更突出
|
||||
ema_alpha: 分位数平滑系数,越小越稳定、越大越灵敏
|
||||
"""
|
||||
|
||||
fig, ax = plt.subplots()
|
||||
# 初始化一个 64x64 的空图,后续每帧覆盖更新
|
||||
init_data = np.zeros((64, 64))
|
||||
im = ax.imshow(init_data, cmap="jet", aspect='equal') #,interpolation='bicubic'
|
||||
plt.colorbar(im) # 添加颜色条
|
||||
|
||||
# 使用“分位数 + 指数平滑”的动态范围,兼顾灵敏度与稳定性
|
||||
vmin, vmax = None, None
|
||||
last_crc = None
|
||||
last_change_ts = time.time()
|
||||
last_diag_ts = 0.0
|
||||
frame_counter = 0
|
||||
fps_window_start = time.time()
|
||||
prev_u64 = {}
|
||||
prev_u32 = {}
|
||||
|
||||
def probe_header_fields(header: bytes):
|
||||
"""解析72字节头部的候选计数器字段并返回变化摘要。"""
|
||||
candidates_u64 = {}
|
||||
candidates_u32 = {}
|
||||
for off in range(0, min(len(header), 72) - 7, 8):
|
||||
candidates_u64[off] = int.from_bytes(header[off:off + 8], byteorder="little", signed=False)
|
||||
for off in range(0, min(len(header), 72) - 3, 4):
|
||||
candidates_u32[off] = int.from_bytes(header[off:off + 4], byteorder="little", signed=False)
|
||||
|
||||
changed_u64 = []
|
||||
changed_u32 = []
|
||||
for off, val in candidates_u64.items():
|
||||
prev = prev_u64.get(off)
|
||||
if prev is not None and val != prev:
|
||||
delta = val - prev
|
||||
changed_u64.append((off, val, delta))
|
||||
prev_u64[off] = val
|
||||
for off, val in candidates_u32.items():
|
||||
prev = prev_u32.get(off)
|
||||
if prev is not None and val != prev:
|
||||
delta = val - prev
|
||||
changed_u32.append((off, val, delta))
|
||||
prev_u32[off] = val
|
||||
return changed_u64, changed_u32
|
||||
|
||||
|
||||
|
||||
def update(frame):
|
||||
nonlocal vmin, vmax, last_crc, last_change_ts, last_diag_ts, frame_counter, fps_window_start
|
||||
try:
|
||||
counter, event_tick, data, header = next(data_stream) # 从生成器获取最新数据
|
||||
# 示例展示策略:取前 64 行并转置,得到 64x64 画面
|
||||
# 说明:这只是可视化截取方式,不代表业务计算必须这样切片
|
||||
data = data[:64, :].T.astype(np.float64) # 取前 64 行,再转置 → 64×64
|
||||
|
||||
# 1) 使用分位数而不是绝对 min/max,避免少量尖峰值“拉扁”整体颜色层次
|
||||
p_low = np.percentile(data, low_percentile)
|
||||
p_high = np.percentile(data, high_percentile)
|
||||
if p_high <= p_low:
|
||||
p_low, p_high = float(np.min(data)), float(np.max(data))
|
||||
if p_high <= p_low:
|
||||
p_high = p_low + 1e-9
|
||||
|
||||
# 2) 用 EMA 平滑动态范围,减少每帧抖动导致的闪烁
|
||||
if vmin is None or vmax is None:
|
||||
vmin, vmax = p_low, p_high
|
||||
else:
|
||||
vmin = (1 - ema_alpha) * vmin + ema_alpha * p_low
|
||||
vmax = (1 - ema_alpha) * vmax + ema_alpha * p_high
|
||||
if vmax <= vmin:
|
||||
vmax = vmin + 1e-9
|
||||
|
||||
# 3) 归一化后做 gamma 增强:gamma>1 可让高压区域更快进入深色高亮区
|
||||
norm = np.clip((data - vmin) / (vmax - vmin), 0.0, 1.0)
|
||||
enhanced = np.power(norm, gamma)
|
||||
|
||||
# 颜色范围固定在 [0,1],增强后高值会更“深”更突出
|
||||
im.set_data(enhanced)
|
||||
im.set_clim(vmin=0.0, vmax=1.0)
|
||||
|
||||
# 每秒打印一次“帧新鲜度诊断”,用于判断写端是否在持续更新
|
||||
crc = int(zlib.crc32(np.ascontiguousarray(data).tobytes()))
|
||||
now = time.time()
|
||||
if last_crc is None or crc != last_crc:
|
||||
last_crc = crc
|
||||
last_change_ts = now
|
||||
|
||||
frame_counter += 1
|
||||
elapsed = now - fps_window_start
|
||||
fps = (frame_counter / elapsed) if elapsed > 0 else 0.0
|
||||
if now - last_diag_ts >= 1.0:
|
||||
frame_age_ms = int(max(0.0, now - last_change_ts) * 1000.0)
|
||||
changed_u64, changed_u32 = probe_header_fields(header)
|
||||
u64_msg = "none"
|
||||
u32_msg = "none"
|
||||
if changed_u64:
|
||||
# 取前3个变化字段,格式 off:value(delta)
|
||||
u64_msg = ", ".join([f"{off}:{val}({delta:+d})" for off, val, delta in changed_u64[:3]])
|
||||
if changed_u32:
|
||||
u32_msg = ", ".join([f"{off}:{val}({delta:+d})" for off, val, delta in changed_u32[:3]])
|
||||
logging.info(
|
||||
"VIS诊断 frame_age_ms=%d crc=%s fps=%.1f tick_ms=%d | hdr_u64_changed=%s | hdr_u32_changed=%s",
|
||||
frame_age_ms,
|
||||
crc,
|
||||
fps,
|
||||
event_tick,
|
||||
u64_msg,
|
||||
u32_msg
|
||||
)
|
||||
last_diag_ts = now
|
||||
frame_counter = 0
|
||||
fps_window_start = now
|
||||
return [im]
|
||||
except StopIteration:
|
||||
plt.close()
|
||||
|
||||
ani = FuncAnimation(fig, update, interval=update_interval, blit=True, cache_frame_data=False)
|
||||
plt.show()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# 读取内存数据
|
||||
data_stream = read_shared_memory_stream()
|
||||
|
||||
# 启动可视化(20ms 间隔约等于 50Hz 刷新)
|
||||
visualize_data_stream(data_stream, update_interval=20) # 20Hz更新
|
||||
|
||||
@ -10,6 +10,8 @@ import ctypes
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import zlib
|
||||
import multiprocessing.shared_memory as shared_memory
|
||||
import numpy as np
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
import logging
|
||||
@ -651,6 +653,387 @@ class MockPressureDevice:
|
||||
def close(self):
|
||||
self.is_connected = False
|
||||
|
||||
|
||||
class SharedMemoryPressureDevice:
|
||||
"""X2 共享内存压力板设备读取器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shared_memory_name: str = "x2_pressure",
|
||||
rows: int = 288,
|
||||
cols: int = 64,
|
||||
header_bytes: int = 72,
|
||||
dtype_name: str = "float32",
|
||||
crop_rows: int = 0,
|
||||
low_percentile: float = 5.0,
|
||||
high_percentile: float = 98.0,
|
||||
gamma: float = 1.0,
|
||||
ema_alpha: float = 0.1,
|
||||
display_equalize_aspect: bool = True,
|
||||
display_scale: int = 4,
|
||||
display_max_side: int = 384,
|
||||
image_emit_interval_s: float = 0.2,
|
||||
rotate_90_cw: bool = False,
|
||||
stale_frame_timeout_s: float = 1.2,
|
||||
stale_reconnect_interval_s: float = 2.0,
|
||||
sync_retry_times: int = 2,
|
||||
sync_retry_sleep_s: float = 0.001,
|
||||
):
|
||||
self.shared_memory_name = shared_memory_name
|
||||
self.rows = int(rows)
|
||||
self.cols = int(cols)
|
||||
self.header_bytes = int(header_bytes)
|
||||
self.crop_rows = int(crop_rows) if int(crop_rows) > 0 else 0
|
||||
self.dtype_name = str(dtype_name).lower()
|
||||
self.dtype = np.float32 if self.dtype_name == "float32" else np.uint16
|
||||
self.low_percentile = float(low_percentile)
|
||||
self.high_percentile = float(high_percentile)
|
||||
self.gamma = float(gamma)
|
||||
self.ema_alpha = float(ema_alpha)
|
||||
self.display_equalize_aspect = bool(display_equalize_aspect)
|
||||
self.display_scale = int(display_scale) if int(display_scale) > 0 else 1
|
||||
self.display_max_side = int(display_max_side) if int(display_max_side) > 0 else 384
|
||||
self.image_emit_interval_s = max(0.05, float(image_emit_interval_s))
|
||||
self.rotate_90_cw = bool(rotate_90_cw)
|
||||
self.stale_frame_timeout_s = max(0.3, float(stale_frame_timeout_s))
|
||||
self.stale_reconnect_interval_s = max(0.5, float(stale_reconnect_interval_s))
|
||||
self.sync_retry_times = max(1, int(sync_retry_times))
|
||||
self.sync_retry_sleep_s = max(0.0, float(sync_retry_sleep_s))
|
||||
self.is_connected = False
|
||||
self.shm = None
|
||||
self._last_warn_ts = 0.0
|
||||
self._viz_vmin = None
|
||||
self._viz_vmax = None
|
||||
self._last_image_emit_ts = 0.0
|
||||
self._last_frame_fingerprint = None
|
||||
self._last_frame_change_ts = time.time()
|
||||
self._last_stale_warn_ts = 0.0
|
||||
self._last_stale_reconnect_ts = 0.0
|
||||
self._last_header_tick = None
|
||||
self._last_crc = None
|
||||
self._diag_last_log_ts = 0.0
|
||||
|
||||
self.connect()
|
||||
|
||||
def connect(self) -> bool:
|
||||
try:
|
||||
if self.shm is not None:
|
||||
return True
|
||||
self.shm = shared_memory.SharedMemory(name=self.shared_memory_name)
|
||||
self.is_connected = True
|
||||
logger.info(f"已连接X2共享内存: {self.shared_memory_name}")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
self.is_connected = False
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"连接X2共享内存失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
def read_data(self) -> Dict[str, Any]:
|
||||
try:
|
||||
if self.shm is None and not self.connect():
|
||||
return self._get_empty_data()
|
||||
if self.shm is None:
|
||||
return self._get_empty_data()
|
||||
|
||||
expected_bytes = self.header_bytes + self.rows * self.cols * np.dtype(self.dtype).itemsize
|
||||
buffer = np.frombuffer(self.shm.buf, dtype=np.uint8)
|
||||
if buffer.nbytes < expected_bytes:
|
||||
now = time.time()
|
||||
if now - self._last_warn_ts > 2.0:
|
||||
logger.warning(
|
||||
f"X2共享内存数据长度不足: got={buffer.nbytes}, expect>={expected_bytes}, name={self.shared_memory_name}"
|
||||
)
|
||||
self._last_warn_ts = now
|
||||
return self._get_empty_data()
|
||||
|
||||
raw_data = self._read_consistent_matrix()
|
||||
if raw_data is None:
|
||||
return self._get_empty_data()
|
||||
|
||||
if self.crop_rows > 0:
|
||||
raw_data = raw_data[: self.crop_rows, :]
|
||||
|
||||
raw_data = np.asarray(raw_data, dtype=np.float64)
|
||||
self._check_frame_fresh(raw_data)
|
||||
|
||||
# 与厂家示例保持一致的可视化预处理:
|
||||
# 1) 优先取前 N 行(示例为 64 行)
|
||||
# 2) 再转置,得到更符合足底朝向与比例的显示矩阵
|
||||
vis_data = raw_data
|
||||
if self.crop_rows <= 0 and self.rows >= 128 and self.cols <= 128:
|
||||
# 对 288x64 这类“长矩阵”默认取前 64 行,避免有效区域仅挤在顶部
|
||||
vis_rows = min(self.cols, vis_data.shape[0])
|
||||
vis_data = vis_data[:vis_rows, :]
|
||||
vis_data = vis_data.T
|
||||
# 旋转应作用在数据矩阵层,确保“压力分区计算”和“图片显示”使用同一坐标系
|
||||
if self.rotate_90_cw:
|
||||
vis_data = np.rot90(vis_data, k=3)
|
||||
|
||||
zones = self._calculate_foot_pressure_zones(vis_data)
|
||||
now = time.time()
|
||||
should_emit_image = (
|
||||
(now - self._last_image_emit_ts) >= self.image_emit_interval_s
|
||||
)
|
||||
image_base64 = ""
|
||||
if should_emit_image:
|
||||
image_base64 = self._generate_heatmap_image(vis_data)
|
||||
self._last_image_emit_ts = now
|
||||
|
||||
self.is_connected = True
|
||||
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 FileNotFoundError:
|
||||
self.is_connected = False
|
||||
self.close()
|
||||
return self._get_empty_data()
|
||||
except Exception as e:
|
||||
logger.error(f"读取X2共享内存压力数据异常: {e}")
|
||||
self.is_connected = False
|
||||
return self._get_empty_data()
|
||||
|
||||
def _read_consistent_matrix(self) -> Optional[np.ndarray]:
|
||||
if self.shm is None:
|
||||
return None
|
||||
raw_data = None
|
||||
for _ in range(self.sync_retry_times):
|
||||
try:
|
||||
pre_header = bytes(self.shm.buf[: self.header_bytes]) if self.header_bytes > 0 else b""
|
||||
raw_data = np.ndarray(
|
||||
shape=(self.rows, self.cols),
|
||||
dtype=self.dtype,
|
||||
buffer=self.shm.buf,
|
||||
offset=self.header_bytes,
|
||||
).copy()
|
||||
post_header = bytes(self.shm.buf[: self.header_bytes]) if self.header_bytes > 0 else b""
|
||||
if pre_header == post_header:
|
||||
return raw_data
|
||||
if self.sync_retry_sleep_s > 0:
|
||||
time.sleep(self.sync_retry_sleep_s)
|
||||
except Exception:
|
||||
return raw_data
|
||||
return raw_data
|
||||
|
||||
def _frame_fingerprint(self, raw_data: np.ndarray) -> int:
|
||||
rows, cols = raw_data.shape
|
||||
r_step = max(1, rows // 16)
|
||||
c_step = max(1, cols // 16)
|
||||
sample = np.ascontiguousarray(raw_data[::r_step, ::c_step])
|
||||
return int(zlib.crc32(sample.tobytes()))
|
||||
|
||||
def _read_header_tick(self) -> Optional[int]:
|
||||
"""尝试从共享内存头部读取一个可单调变化的tick(若协议支持)。"""
|
||||
if self.shm is None or self.header_bytes < 8:
|
||||
return None
|
||||
try:
|
||||
# 约定优先按 little-endian uint64 读取前8字节;若厂商协议不同会自动退化到指纹法
|
||||
return int.from_bytes(bytes(self.shm.buf[:8]), byteorder='little', signed=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _check_frame_fresh(self, raw_data: np.ndarray) -> bool:
|
||||
now = time.time()
|
||||
header_tick = self._read_header_tick()
|
||||
crc = self._frame_fingerprint(raw_data)
|
||||
self._last_crc = crc
|
||||
changed = False
|
||||
if header_tick is not None:
|
||||
if self._last_header_tick is None or header_tick != self._last_header_tick:
|
||||
changed = True
|
||||
self._last_header_tick = header_tick
|
||||
else:
|
||||
if self._last_frame_fingerprint is None or crc != self._last_frame_fingerprint:
|
||||
changed = True
|
||||
self._last_frame_fingerprint = crc
|
||||
|
||||
if changed:
|
||||
self._last_frame_change_ts = now
|
||||
self._log_frame_diagnostics(now, header_tick, crc, changed=True, stale=False)
|
||||
return True
|
||||
|
||||
if (now - self._last_frame_change_ts) <= self.stale_frame_timeout_s:
|
||||
self._log_frame_diagnostics(now, header_tick, crc, changed=False, stale=False)
|
||||
return True
|
||||
|
||||
if now - self._last_stale_warn_ts > 2.0:
|
||||
logger.warning(
|
||||
f"X2共享内存帧疑似陈旧,{now - self._last_frame_change_ts:.2f}s 无变化: {self.shared_memory_name}"
|
||||
)
|
||||
self._last_stale_warn_ts = now
|
||||
|
||||
# 帧长期不变化时尝试重连共享内存映射,避免读取端卡在旧映射/陈旧帧
|
||||
if (now - self._last_stale_reconnect_ts) >= self.stale_reconnect_interval_s:
|
||||
self._last_stale_reconnect_ts = now
|
||||
self.close()
|
||||
time.sleep(0.01)
|
||||
self.connect()
|
||||
self._log_frame_diagnostics(now, header_tick, crc, changed=False, stale=True)
|
||||
# 注意:即使判定为陈旧帧,也不丢弃当前帧,避免前端出现“被动卡帧”体感
|
||||
return False
|
||||
|
||||
def _log_frame_diagnostics(
|
||||
self,
|
||||
now_ts: float,
|
||||
header_tick: Optional[int],
|
||||
crc: int,
|
||||
changed: bool,
|
||||
stale: bool
|
||||
):
|
||||
# 每秒打印一次,避免刷屏
|
||||
if (now_ts - self._diag_last_log_ts) < 1.0:
|
||||
return
|
||||
self._diag_last_log_ts = now_ts
|
||||
frame_age_ms = int(max(0.0, now_ts - self._last_frame_change_ts) * 1000.0)
|
||||
tick_str = str(header_tick) if header_tick is not None else "None"
|
||||
logger.info(
|
||||
"X2帧诊断 name=%s frame_age_ms=%d header_tick=%s crc=%s changed=%s stale=%s",
|
||||
self.shared_memory_name,
|
||||
frame_age_ms,
|
||||
tick_str,
|
||||
crc,
|
||||
int(bool(changed)),
|
||||
int(bool(stale)),
|
||||
)
|
||||
|
||||
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("raw_data has invalid shape")
|
||||
|
||||
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 as e:
|
||||
logger.error(f"计算X2足部区域压力异常: {e}")
|
||||
return {
|
||||
"left_front": 0,
|
||||
"left_rear": 0,
|
||||
"right_front": 0,
|
||||
"right_rear": 0,
|
||||
"left_total": 0,
|
||||
"right_total": 0,
|
||||
"total_pressure": 0,
|
||||
}
|
||||
|
||||
def _generate_heatmap_image(self, raw_data: np.ndarray) -> str:
|
||||
try:
|
||||
if raw_data.size == 0:
|
||||
return self._get_empty_data()["pressure_image"]
|
||||
|
||||
# 1) 按分位数估计当帧动态范围,避免极值拉平色阶
|
||||
p_low = float(np.percentile(raw_data, self.low_percentile))
|
||||
p_high = float(np.percentile(raw_data, self.high_percentile))
|
||||
if p_high <= p_low:
|
||||
p_low, p_high = float(np.min(raw_data)), float(np.max(raw_data))
|
||||
if p_high <= p_low:
|
||||
p_high = p_low + 1e-9
|
||||
|
||||
# 2) 使用EMA平滑动态范围,减小闪烁
|
||||
alpha = float(np.clip(self.ema_alpha, 0.0, 1.0))
|
||||
if self._viz_vmin is None or self._viz_vmax is None:
|
||||
self._viz_vmin, self._viz_vmax = p_low, p_high
|
||||
else:
|
||||
self._viz_vmin = (1.0 - alpha) * self._viz_vmin + alpha * p_low
|
||||
self._viz_vmax = (1.0 - alpha) * self._viz_vmax + alpha * p_high
|
||||
if self._viz_vmax <= self._viz_vmin:
|
||||
self._viz_vmax = self._viz_vmin + 1e-9
|
||||
|
||||
# 3) 归一化后做gamma增强(>1 高压更显著)
|
||||
norm = np.clip((raw_data - self._viz_vmin) / (self._viz_vmax - self._viz_vmin), 0.0, 1.0)
|
||||
gamma = max(0.05, float(self.gamma))
|
||||
enhanced = np.power(norm, gamma)
|
||||
norm_u8 = np.clip(enhanced * 255.0, 0, 255).astype(np.uint8)
|
||||
|
||||
heatmap = cv2.applyColorMap(norm_u8, cv2.COLORMAP_JET)
|
||||
heatmap[norm_u8 <= 2] = (64, 48, 38)
|
||||
rows, cols = raw_data.shape
|
||||
target_w = max(1, int(cols * self.display_scale))
|
||||
target_h = max(1, int(rows * self.display_scale))
|
||||
# 对 288x64 这类强非方阵做显示补偿,避免前端看起来“压扁”
|
||||
if self.display_equalize_aspect and rows > 0 and cols > 0:
|
||||
if rows > cols:
|
||||
target_w = max(target_w, int(target_w * (rows / cols)))
|
||||
elif cols > rows:
|
||||
target_h = max(target_h, int(target_h * (cols / rows)))
|
||||
# 限制最长边,防止base64过大导致SocketIO排队和前端渲染延迟
|
||||
max_side = max(target_w, target_h)
|
||||
if max_side > self.display_max_side:
|
||||
shrink = self.display_max_side / float(max_side)
|
||||
target_w = max(1, int(target_w * shrink))
|
||||
target_h = max(1, int(target_h * shrink))
|
||||
heatmap = cv2.resize(heatmap, (target_w, target_h), 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._get_empty_data()["pressure_image"]
|
||||
|
||||
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):
|
||||
try:
|
||||
if self.shm is not None:
|
||||
self.shm.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.shm = None
|
||||
self.is_connected = False
|
||||
|
||||
class PressureManager(BaseDevice):
|
||||
"""压力板管理器"""
|
||||
|
||||
@ -674,6 +1057,66 @@ class PressureManager(BaseDevice):
|
||||
# 设备实例
|
||||
self.device = None
|
||||
self.use_mock = bool(self.config.get('use_mock', False))
|
||||
self.pressure_source = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_source', fallback='smitsense')
|
||||
).lower()
|
||||
self.shared_memory_name = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_name', fallback='x2_pressure')
|
||||
)
|
||||
self.shared_memory_rows = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_rows', fallback=288)
|
||||
)
|
||||
self.shared_memory_cols = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_cols', fallback=64)
|
||||
)
|
||||
self.shared_memory_header_bytes = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_header_bytes', fallback=72)
|
||||
)
|
||||
self.shared_memory_dtype = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_dtype', fallback='float32')
|
||||
)
|
||||
self.shared_memory_crop_rows = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_crop_rows', fallback=0)
|
||||
)
|
||||
self.shared_memory_low_percentile = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_low_percentile', fallback=5.0)
|
||||
)
|
||||
self.shared_memory_high_percentile = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_high_percentile', fallback=98.0)
|
||||
)
|
||||
self.shared_memory_gamma = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_gamma', fallback=1.0)
|
||||
)
|
||||
self.shared_memory_ema_alpha = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_ema_alpha', fallback=0.1)
|
||||
)
|
||||
self.shared_memory_display_equalize_aspect = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_display_equalize_aspect', fallback='True')
|
||||
).lower() in ('1', 'true', 'yes', 'on')
|
||||
self.shared_memory_display_scale = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_display_scale', fallback=4)
|
||||
)
|
||||
self.shared_memory_display_max_side = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_display_max_side', fallback=384)
|
||||
)
|
||||
self.shared_memory_image_emit_interval_s = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_image_emit_interval_s', fallback=0.2)
|
||||
)
|
||||
self.shared_memory_rotate_90_cw = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_rotate_90_cw', fallback='True')
|
||||
).lower() in ('1', 'true', 'yes', 'on')
|
||||
self.shared_memory_stale_frame_timeout_s = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_stale_frame_timeout_s', fallback=1.2)
|
||||
)
|
||||
self.shared_memory_stale_reconnect_interval_s = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_stale_reconnect_interval_s', fallback=2.0)
|
||||
)
|
||||
self.shared_memory_sync_retry_times = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_sync_retry_times', fallback=2)
|
||||
)
|
||||
self.shared_memory_sync_retry_sleep_s = float(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_sync_retry_sleep_s', fallback=0.001)
|
||||
)
|
||||
|
||||
# 数据流相关
|
||||
self.streaming_thread = None
|
||||
@ -696,6 +1139,33 @@ class PressureManager(BaseDevice):
|
||||
self._last_connected_state = None # 去抖动状态广播
|
||||
|
||||
self.logger.info(f"压力板管理器初始化完成 - use_mock: {self.use_mock}")
|
||||
|
||||
def _create_pressure_device(self):
|
||||
if self.use_mock:
|
||||
return MockPressureDevice()
|
||||
if self.pressure_source == 'shared_memory':
|
||||
return SharedMemoryPressureDevice(
|
||||
shared_memory_name=self.shared_memory_name,
|
||||
rows=self.shared_memory_rows,
|
||||
cols=self.shared_memory_cols,
|
||||
header_bytes=self.shared_memory_header_bytes,
|
||||
dtype_name=self.shared_memory_dtype,
|
||||
crop_rows=self.shared_memory_crop_rows,
|
||||
low_percentile=self.shared_memory_low_percentile,
|
||||
high_percentile=self.shared_memory_high_percentile,
|
||||
gamma=self.shared_memory_gamma,
|
||||
ema_alpha=self.shared_memory_ema_alpha,
|
||||
display_equalize_aspect=self.shared_memory_display_equalize_aspect,
|
||||
display_scale=self.shared_memory_display_scale,
|
||||
display_max_side=self.shared_memory_display_max_side,
|
||||
image_emit_interval_s=self.shared_memory_image_emit_interval_s,
|
||||
rotate_90_cw=self.shared_memory_rotate_90_cw,
|
||||
stale_frame_timeout_s=self.shared_memory_stale_frame_timeout_s,
|
||||
stale_reconnect_interval_s=self.shared_memory_stale_reconnect_interval_s,
|
||||
sync_retry_times=self.shared_memory_sync_retry_times,
|
||||
sync_retry_sleep_s=self.shared_memory_sync_retry_sleep_s,
|
||||
)
|
||||
return RealPressureDevice()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
@ -712,10 +1182,7 @@ class PressureManager(BaseDevice):
|
||||
self.logger.info(f"使用已加载配置: use_mock={self.use_mock}, stream_interval={self.stream_interval}")
|
||||
|
||||
# 根据设备类型创建设备实例
|
||||
if not self.use_mock:
|
||||
self.device = RealPressureDevice()
|
||||
else:
|
||||
self.device = MockPressureDevice()
|
||||
self.device = self._create_pressure_device()
|
||||
|
||||
connected = False
|
||||
try:
|
||||
@ -731,7 +1198,7 @@ class PressureManager(BaseDevice):
|
||||
# 使用set_connected方法启动连接监控线程
|
||||
self.set_connected(bool(connected))
|
||||
self._device_info.update({
|
||||
'device_type': 'mock' if self.use_mock else 'real',
|
||||
'device_type': 'mock' if self.use_mock else self.pressure_source,
|
||||
'matrix_size': '4x4' if hasattr(self.device, 'rows') else 'unknown'
|
||||
})
|
||||
|
||||
@ -986,6 +1453,108 @@ class PressureManager(BaseDevice):
|
||||
self.config = new_config
|
||||
self.use_mock = bool(new_config.get('use_mock', False))
|
||||
self.stream_interval = new_config.get('stream_interval', 0.1)
|
||||
self.pressure_source = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_source', fallback=self.pressure_source)
|
||||
).lower()
|
||||
self.shared_memory_name = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_name', fallback=self.shared_memory_name)
|
||||
)
|
||||
self.shared_memory_rows = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_rows', fallback=self.shared_memory_rows)
|
||||
)
|
||||
self.shared_memory_cols = int(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_cols', fallback=self.shared_memory_cols)
|
||||
)
|
||||
self.shared_memory_header_bytes = int(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_header_bytes', fallback=self.shared_memory_header_bytes
|
||||
)
|
||||
)
|
||||
self.shared_memory_dtype = str(
|
||||
self.config_manager.get_config_value('DEVICES', 'pressure_shared_memory_dtype', fallback=self.shared_memory_dtype)
|
||||
)
|
||||
self.shared_memory_crop_rows = int(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_crop_rows', fallback=self.shared_memory_crop_rows
|
||||
)
|
||||
)
|
||||
self.shared_memory_low_percentile = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_low_percentile', fallback=self.shared_memory_low_percentile
|
||||
)
|
||||
)
|
||||
self.shared_memory_high_percentile = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_high_percentile', fallback=self.shared_memory_high_percentile
|
||||
)
|
||||
)
|
||||
self.shared_memory_gamma = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_gamma', fallback=self.shared_memory_gamma
|
||||
)
|
||||
)
|
||||
self.shared_memory_ema_alpha = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_ema_alpha', fallback=self.shared_memory_ema_alpha
|
||||
)
|
||||
)
|
||||
self.shared_memory_display_equalize_aspect = str(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_display_equalize_aspect',
|
||||
fallback=self.shared_memory_display_equalize_aspect
|
||||
)
|
||||
).lower() in ('1', 'true', 'yes', 'on')
|
||||
self.shared_memory_display_scale = int(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_display_scale', fallback=self.shared_memory_display_scale
|
||||
)
|
||||
)
|
||||
self.shared_memory_display_max_side = int(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_display_max_side', fallback=self.shared_memory_display_max_side
|
||||
)
|
||||
)
|
||||
self.shared_memory_image_emit_interval_s = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_image_emit_interval_s',
|
||||
fallback=self.shared_memory_image_emit_interval_s
|
||||
)
|
||||
)
|
||||
self.shared_memory_rotate_90_cw = str(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES', 'pressure_shared_memory_rotate_90_cw', fallback=self.shared_memory_rotate_90_cw
|
||||
)
|
||||
).lower() in ('1', 'true', 'yes', 'on')
|
||||
self.shared_memory_stale_frame_timeout_s = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_stale_frame_timeout_s',
|
||||
fallback=self.shared_memory_stale_frame_timeout_s
|
||||
)
|
||||
)
|
||||
self.shared_memory_stale_reconnect_interval_s = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_stale_reconnect_interval_s',
|
||||
fallback=self.shared_memory_stale_reconnect_interval_s
|
||||
)
|
||||
)
|
||||
self.shared_memory_sync_retry_times = int(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_sync_retry_times',
|
||||
fallback=self.shared_memory_sync_retry_times
|
||||
)
|
||||
)
|
||||
self.shared_memory_sync_retry_sleep_s = float(
|
||||
self.config_manager.get_config_value(
|
||||
'DEVICES',
|
||||
'pressure_shared_memory_sync_retry_sleep_s',
|
||||
fallback=self.shared_memory_sync_retry_sleep_s
|
||||
)
|
||||
)
|
||||
# 动态更新重连参数
|
||||
self.max_reconnect_attempts = int(new_config.get('max_reconnect_attempts', self.max_reconnect_attempts))
|
||||
self.reconnect_delay = float(new_config.get('reconnect_delay', self.reconnect_delay))
|
||||
@ -1009,6 +1578,12 @@ class PressureManager(BaseDevice):
|
||||
if not self.device:
|
||||
# 如果设备实例不存在,返回False表示硬件未连接
|
||||
return self._attempt_device_reconnection()
|
||||
|
||||
# 对于共享内存设备,依据连接状态判断
|
||||
if hasattr(self.device, 'shared_memory_name'):
|
||||
if bool(getattr(self.device, 'is_connected', False)):
|
||||
return True
|
||||
return self._attempt_device_reconnection()
|
||||
|
||||
# 对于真实设备,检查DLL和设备句柄状态
|
||||
if hasattr(self.device, 'dll') and hasattr(self.device, 'device_handle'):
|
||||
@ -1071,10 +1646,11 @@ class PressureManager(BaseDevice):
|
||||
self.device = None
|
||||
|
||||
# 重置USB状态,为重新插入的设备做准备
|
||||
RealPressureDevice.reset_usb_state()
|
||||
if self.pressure_source != 'shared_memory':
|
||||
RealPressureDevice.reset_usb_state()
|
||||
|
||||
# 根据设备类型重新创建设备实例
|
||||
self.device = RealPressureDevice()
|
||||
self.device = self._create_pressure_device()
|
||||
|
||||
# 检查新设备是否连接成功
|
||||
if hasattr(self.device, 'is_connected') and self.device.is_connected:
|
||||
|
||||
1106
backend/devices/pressure_manager_small.py
Normal file
1106
backend/devices/pressure_manager_small.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -175,13 +175,14 @@ class RemoteControlManager(BaseDevice):
|
||||
def _emit_code(self, key_code: int):
|
||||
code_hex = f"{key_code:02X}"
|
||||
name_map = {
|
||||
0x11: "start",
|
||||
0x14: "stop",
|
||||
0x13: "up",
|
||||
0x15: "down",
|
||||
0x12: "center",
|
||||
0x0E: "power",
|
||||
0x0F: "screenshot",
|
||||
0x11: "IMU校准",
|
||||
0x12: "电脑开/关机",
|
||||
0x13: "截图",
|
||||
0x14: "IMU清零",
|
||||
0x15: "开始录像/停止录像",
|
||||
0x10: "升降杆-下降",
|
||||
0x0E: "升降杆-上升",
|
||||
0x0F: "升降杆-停止"
|
||||
}
|
||||
name = name_map.get(key_code, 'unknown')
|
||||
payload = {
|
||||
|
||||
@ -101,6 +101,10 @@ class RecordingManager:
|
||||
max_h = (bounds['y'] + bounds['height']) - y_clamped
|
||||
w_clamped = max(1, min(int(w), int(max_w)))
|
||||
h_clamped = max(1, min(int(h), int(max_h)))
|
||||
# libx264/h264 编码器要求宽高为偶数;高DPI缩放(如2560x1600@150%→1707x1067)
|
||||
# 会产生奇数尺寸导致编码器初始化失败,输出0字节文件
|
||||
w_clamped -= w_clamped % 2
|
||||
h_clamped -= h_clamped % 2
|
||||
off_x = x_clamped - bounds['x']
|
||||
off_y = y_clamped - bounds['y']
|
||||
|
||||
@ -146,15 +150,21 @@ class RecordingManager:
|
||||
cmd += ['-threads', str(threads)]
|
||||
cmd += ['-r', str(target_fps)]
|
||||
cmd += [screen_video_path]
|
||||
# ffmpeg stderr 写入日志文件,便于排查录制失败原因(不能用 DEVNULL 吞掉)
|
||||
ffmpeg_log_path = os.path.join(base_path, 'ffmpeg_screen.log')
|
||||
try:
|
||||
stderr_file = open(ffmpeg_log_path, 'w', encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
stderr_file = subprocess.DEVNULL
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=stderr_file,
|
||||
creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
|
||||
)
|
||||
self._ffmpeg_processes['screen'] = proc
|
||||
self._ffmpeg_meta['screen'] = {'base_path': base_path, 'file_dir': file_dir, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path}
|
||||
self._ffmpeg_meta['screen'] = {'base_path': base_path, 'file_dir': file_dir, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path, 'stderr_file': stderr_file}
|
||||
result['success'] = True
|
||||
result['message'] = 'ffmpeg录制已启动'
|
||||
result['database_updates'] = {
|
||||
@ -278,6 +288,14 @@ class RecordingManager:
|
||||
pass
|
||||
finally:
|
||||
self._ffmpeg_processes.pop('screen', None)
|
||||
# 关闭ffmpeg stderr日志文件
|
||||
if meta:
|
||||
stderr_file = meta.get('stderr_file')
|
||||
if stderr_file and stderr_file is not subprocess.DEVNULL:
|
||||
try:
|
||||
stderr_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
result['success'] = True
|
||||
result['message'] = 'ffmpeg录制已停止'
|
||||
if meta:
|
||||
|
||||
@ -2375,7 +2375,7 @@ class AppServer:
|
||||
self.logger.error(f'服务启动失败: {e}')
|
||||
sys.exit(1)
|
||||
finally:
|
||||
self.logger.info('AppServer已停止')
|
||||
self.logger.info('ppServer.run() 退出')
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@ -22,4 +22,13 @@ pykinect_azure # Azure Kinect SDK for Python
|
||||
# System utilities
|
||||
colorama==0.4.6
|
||||
click==8.1.7
|
||||
cryptography>=41.0.0
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Screen recording & screenshots (must be installed for PyInstaller to bundle it)
|
||||
pyautogui>=0.9.54
|
||||
pyscreeze>=0.1.30
|
||||
mouseinfo>=0.1.3
|
||||
pygetwindow>=0.0.9
|
||||
pyrect>=0.2.0
|
||||
pytweening>=1.0.4
|
||||
Pillow>=9.0.0
|
||||
350
document/多厂家足底压力板适配开发方案.md
Normal file
350
document/多厂家足底压力板适配开发方案.md
Normal file
@ -0,0 +1,350 @@
|
||||
# 多厂家足底压力板适配开发方案
|
||||
|
||||
## 1. 需求背景与目标
|
||||
|
||||
当前系统使用足底压力板采集足底压力数据。项目经历了一次设备更换:
|
||||
|
||||
| 设备 | 程序文件 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 原厂家(小一号压力板,SMiTSense USB) | `backend/devices/pressure_manager_small.py` | 已停用 |
|
||||
| 新厂家(大一号压力板,X2 共享内存) | `backend/devices/pressure_manager.py` | 当前使用 |
|
||||
|
||||
**需求目标**:程序同时支持两个厂家的设备,通过**配置文件参数**切换使用哪个厂家,无需修改代码、重新打包即可在两种设备间切换。
|
||||
|
||||
具体拆解为三点:
|
||||
1. 新增配置文件参数,用 `small` 代表原厂家设备(对应 `pressure_manager_small.py`),用 `large` 代表新厂家设备(对应 `pressure_manager.py`);
|
||||
2. 修改配置与初始化程序,根据配置文件**动态初始化**对应厂家的设备管理器;
|
||||
3. 检测时,根据配置文件**加载对应厂家**的设备进行数据采集。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状分析
|
||||
|
||||
### 2.1 两个压力板管理器的异同
|
||||
|
||||
两个文件均定义了 `class PressureManager(BaseDevice)`,对外接口**完全一致**:
|
||||
|
||||
- 生命周期:`initialize()` / `start_streaming()` / `stop_streaming()` / `disconnect()` / `cleanup()` / `restart` 相关
|
||||
- 配置:`reload_config()` / `get_status()` / `calibrate()`
|
||||
- 数据上报:均通过 Socket.IO 以 `pressure_data` 事件推送到 `/devices` 命名空间,数据结构相同(`foot_pressure` + `pressure_image` + `timestamp`)
|
||||
|
||||
因此**前端无需任何改动**,只需要保证后端"按配置加载正确的管理器类"即可。
|
||||
|
||||
底层设备实现差异:
|
||||
|
||||
| 项目 | pressure_manager_small.py(原厂家) | pressure_manager.py(新厂家) |
|
||||
| --- | --- | --- |
|
||||
| 真实设备 | `RealPressureDevice`(SMiTSense `Wrapper.dll` USB 读取) | `SharedMemoryPressureDevice`(X2 共享内存)为主,同时保留 `RealPressureDevice` |
|
||||
| 模拟设备 | `MockPressureDevice` | `MockPressureDevice` |
|
||||
| 数据源切换方式 | 仅 `use_mock` | `use_mock` + `pressure_source`(`smitsense` / `shared_memory`) |
|
||||
| 与硬件耦合 | DLL 文件 `dll/smitsense/Wrapper.dll` | 共享内存名 `x2_pressure` 等一批 `pressure_shared_memory_*` 参数 |
|
||||
|
||||
### 2.2 当前硬编码点(需要改造的位置)
|
||||
|
||||
现有代码在多处**直接 import 并实例化 `pressure_manager.PressureManager`**,这是需要改造的核心:
|
||||
|
||||
| 文件 | 行号 | 现状 |
|
||||
| --- | --- | --- |
|
||||
| `backend/devices/device_coordinator.py` | L19 / L26 | 模块顶部 `from .pressure_manager import PressureManager` |
|
||||
| `backend/devices/device_coordinator.py` | L421-436 | `_init_pressure()` 直接 `PressureManager(self.socketio, self.config_manager)` |
|
||||
| `backend/devices/device_coordinator.py` | L837-842 | `restart_device('pressure')` 分支再次直接 import + 实例化 |
|
||||
| `backend/main.py` | L33 | 模块顶部 `from devices.pressure_manager import PressureManager` |
|
||||
| `backend/devices/__init__.py` | L11 / L19 | `from .pressure_manager import PressureManager` 并导出 |
|
||||
|
||||
### 2.3 配置现状
|
||||
|
||||
`[DEVICES]` 段已有压力板相关参数(`backend/config.ini` 与打包产物 `backend/dist/BodyBalanceBackend/config.ini` 内容一致):
|
||||
|
||||
```ini
|
||||
[DEVICES]
|
||||
; pressure_enable = True
|
||||
; pressure_use_mock = True
|
||||
pressure_enable = False
|
||||
pressure_use_mock = True
|
||||
pressure_source = shared_memory
|
||||
pressure_shared_memory_name = x2_pressure
|
||||
pressure_shared_memory_header_bytes = 72
|
||||
pressure_shared_memory_rows = 288
|
||||
pressure_shared_memory_cols = 64
|
||||
pressure_shared_memory_dtype = float32
|
||||
pressure_shared_memory_crop_rows = 0
|
||||
pressure_shared_memory_low_percentile = 5
|
||||
pressure_shared_memory_high_percentile = 98
|
||||
pressure_shared_memory_gamma = 1.0
|
||||
pressure_shared_memory_ema_alpha = 0.1
|
||||
```
|
||||
|
||||
`ConfigManager._get_pressure_config()` 目前只返回 `enable / use_mock / port / baudrate` 四个字段;`pressure_source` 及 `pressure_shared_memory_*` 由 `pressure_manager.py` 内部通过 `get_config_value('DEVICES', ...)` 直接读取。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计方案
|
||||
|
||||
### 3.1 新增配置参数:`pressure_vendor`
|
||||
|
||||
在 `[DEVICES]` 段新增参数,作为**厂家选择的总开关**:
|
||||
|
||||
```ini
|
||||
[DEVICES]
|
||||
pressure_enable = True
|
||||
pressure_vendor = large ; small = 原厂家(小压力板,pressure_manager_small.py)
|
||||
; large = 新厂家(大压力板,pressure_manager.py)
|
||||
pressure_use_mock = False
|
||||
```
|
||||
|
||||
**取值约定**(严格按需求):
|
||||
- `small` → 加载 `pressure_manager_small.py` 中的 `PressureManager`
|
||||
- `large` → 加载 `pressure_manager.py` 中的 `PressureManager`
|
||||
- 未配置或非法值 → **默认回退为 `large`**(与当前线上部署行为一致,保证向后兼容)
|
||||
|
||||
**厂商专用参数共存策略**:`small` 与 `large` 各自的参数(`pressure_source`、`pressure_shared_memory_*`、`pressure_dll_path` 等)全部保留在 `[DEVICES]` 段中,互不冲突。加载哪个管理器,就由哪个管理器读取自己关心的参数,其余参数被忽略,无需拆分配置段。
|
||||
|
||||
### 3.2 新增设备工厂(适配器):`pressure_device_factory.py`
|
||||
|
||||
新建 `backend/devices/pressure_device_factory.py`,作为**唯一的压力板管理器入口**:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""压力板设备工厂
|
||||
根据配置 [DEVICES] pressure_vendor 动态选择加载对应厂家的压力板管理器。
|
||||
small = 原厂家(pressure_manager_small.py);large = 新厂家(pressure_manager.py)。
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 静态导入两个厂家的管理器(静态导入可保证 PyInstaller 打包时两个模块都被收集)
|
||||
try:
|
||||
from .pressure_manager_small import PressureManager as SmallPressureManager
|
||||
from .pressure_manager import PressureManager as LargePressureManager
|
||||
except ImportError:
|
||||
from pressure_manager_small import PressureManager as SmallPressureManager
|
||||
from pressure_manager import PressureManager as LargePressureManager
|
||||
|
||||
# 厂家注册表:配置值 -> 管理器类
|
||||
VENDOR_MAP = {
|
||||
'small': SmallPressureManager, # 原厂家(小压力板)
|
||||
'large': LargePressureManager, # 新厂家(大压力板)
|
||||
}
|
||||
|
||||
DEFAULT_VENDOR = 'large' # 向后兼容:默认新厂家
|
||||
|
||||
|
||||
def resolve_pressure_vendor(config_manager) -> str:
|
||||
"""读取配置中的厂家值,非法值回退为默认厂家"""
|
||||
try:
|
||||
vendor = str(
|
||||
config_manager.get_config_value('DEVICES', 'pressure_vendor', fallback=DEFAULT_VENDOR)
|
||||
).strip().lower()
|
||||
except Exception as e:
|
||||
logger.warning(f'读取 pressure_vendor 失败,使用默认厂家 {DEFAULT_VENDOR}: {e}')
|
||||
vendor = DEFAULT_VENDOR
|
||||
if vendor not in VENDOR_MAP:
|
||||
logger.warning(f'未知 pressure_vendor: {vendor},回退为 {DEFAULT_VENDOR}')
|
||||
vendor = DEFAULT_VENDOR
|
||||
return vendor
|
||||
|
||||
|
||||
def get_pressure_manager_class(config_manager):
|
||||
"""根据配置返回对应的压力板管理器类"""
|
||||
vendor = resolve_pressure_vendor(config_manager)
|
||||
cls = VENDOR_MAP[vendor]
|
||||
logger.info(f'压力板厂家选择: {vendor} -> {cls.__module__}.{cls.__name__}')
|
||||
return cls
|
||||
|
||||
|
||||
def create_pressure_manager(socketio, config_manager):
|
||||
"""创建压力板管理器实例(对外统一入口)"""
|
||||
cls = get_pressure_manager_class(config_manager)
|
||||
return cls(socketio, config_manager)
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- 采用**静态 import 两个模块**而非 `importlib` 动态导入——因为 PyInstaller 的静态分析能识别 `from X import Y`,确保打包时两个厂家模块都被打进 exe,避免打包后切厂家时报"模块不存在"。
|
||||
- 对外只暴露 `create_pressure_manager(socketio, config_manager)` 一个入口,调用方不关心具体厂家。
|
||||
|
||||
### 3.3 初始化流程改造(动态初始化)
|
||||
|
||||
#### (1) `backend/devices/device_coordinator.py`
|
||||
|
||||
- 顶部 import(L19/L26):不再直接导入 `PressureManager`,改为导入工厂:
|
||||
|
||||
```python
|
||||
try:
|
||||
from .pressure_device_factory import create_pressure_manager
|
||||
except ImportError:
|
||||
from pressure_device_factory import create_pressure_manager
|
||||
```
|
||||
|
||||
- `_init_pressure()`(L421-436)改为:
|
||||
|
||||
```python
|
||||
def _init_pressure(self) -> bool:
|
||||
try:
|
||||
pressure = create_pressure_manager(self.socketio, self.config_manager)
|
||||
self.devices['pressure'] = pressure
|
||||
if pressure.initialize():
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化压力传感器失败: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
- `restart_device()` 的 `pressure` 分支(L837-842)同步改为:
|
||||
|
||||
```python
|
||||
elif device_name == 'pressure':
|
||||
new_device = create_pressure_manager(self.socketio, self.config_manager)
|
||||
```
|
||||
|
||||
这样**在运行中修改 `pressure_vendor` 后调用"设备重启"接口即可热切换厂家**,无需重启整个后端。
|
||||
|
||||
#### (2) `backend/main.py`
|
||||
|
||||
- L33 `from devices.pressure_manager import PressureManager`:该导入在 main.py 中并未被实际调用(压力板实例统一由协调器持有),建议**删除该行**,避免与工厂逻辑冲突;若确需引用,改为从工厂获取类。
|
||||
|
||||
#### (3) `backend/devices/__init__.py`
|
||||
|
||||
- L11 / L19:`PressureManager` 导出改为通过工厂间接暴露,建议调整为:
|
||||
|
||||
```python
|
||||
from .pressure_device_factory import create_pressure_manager, get_pressure_manager_class
|
||||
```
|
||||
|
||||
对外不再直接导出某个厂家的 `PressureManager` 类,统一走工厂。
|
||||
|
||||
### 3.4 检测时加载对应设备(需求点 3)
|
||||
|
||||
检测流程(`main.py` 的检测启动、数据采集)**均通过 `DeviceCoordinator` 获取压力板实例**(`self.device_coordinator.get_device('pressure')` / `device_managers`),而协调器在初始化时已经按 `pressure_vendor` 创建了正确厂家的实例,因此检测时天然加载的就是配置指定厂家的设备,**无需改动检测主流程**。
|
||||
|
||||
补充两个健壮性措施(建议纳入实现):
|
||||
1. **检测启动前校验**:在检测开始接口中读取 `pressure_vendor`,与当前压力板实例的 `__class__.__module__` 对比,若不一致则记录告警日志(提示"配置已切换但设备未重启"),引导用户通过设备重启接口热切换。
|
||||
2. **配置变更热切换**:前端"设备设置"保存 `pressure_vendor` 后,后端提示"重启软件系统生效"(沿用现有 `set_all_device_configs` 的提示语);同时支持在设备管理页调用现有 `restart_device('pressure')` 立即切换。
|
||||
|
||||
### 3.5 配置管理 / API 支持
|
||||
|
||||
`backend/devices/utils/config_manager.py`:
|
||||
|
||||
- `_get_pressure_config()`(L243-255)新增返回 `vendor` 字段:
|
||||
|
||||
```python
|
||||
return {
|
||||
'enable': self.config.getboolean('DEVICES', 'pressure_enable', fallback=False),
|
||||
'use_mock': self.config.getboolean('DEVICES', 'pressure_use_mock', fallback=False),
|
||||
'vendor': self.config.get('DEVICES', 'pressure_vendor', fallback='large'),
|
||||
'port': self.config.get('DEVICES', 'pressure_port', fallback='COM8'),
|
||||
'baudrate': self.config.getint('DEVICES', 'pressure_baudrate', fallback=115200),
|
||||
}
|
||||
```
|
||||
|
||||
- `_batch_update_device_configs()` 的 `pressure` 分支(L453-475)新增:
|
||||
|
||||
```python
|
||||
if 'vendor' in config_data:
|
||||
self.set_config_value('DEVICES', 'pressure_vendor', str(config_data['vendor']))
|
||||
```
|
||||
|
||||
这样前端设备设置页(如果暴露压力板配置)可读写 `pressure_vendor`,走既有的 `set_all_device_configs` 接口即可。
|
||||
|
||||
### 3.6 打包注意事项(PyInstaller)
|
||||
|
||||
- 工厂采用**静态导入**两个厂家模块,PyInstaller 会自动收集 `pressure_manager_small.py` 与 `pressure_manager.py`,一般无需额外配置。
|
||||
- 稳妥起见,在 `backend/build_app.py` 生成的 `app.spec` 的 `hiddenimports` 中追加:
|
||||
|
||||
```python
|
||||
hiddenimports=['devices.pressure_manager_small', 'devices.pressure_manager', 'devices.pressure_device_factory'],
|
||||
```
|
||||
|
||||
- 若某厂家依赖额外 DLL(`dll/smitsense/Wrapper.dll`),需确认打包时该 DLL 已被收集(`build_app.py` 后处理逻辑中已包含 dll 目录拷贝,需核对 smitsense 子目录是否在内)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置文件示例
|
||||
|
||||
### 4.1 使用新厂家(大压力板,当前部署)
|
||||
|
||||
```ini
|
||||
[DEVICES]
|
||||
pressure_enable = True
|
||||
pressure_vendor = large
|
||||
pressure_use_mock = False
|
||||
pressure_source = shared_memory
|
||||
pressure_shared_memory_name = x2_pressure
|
||||
pressure_shared_memory_header_bytes = 72
|
||||
pressure_shared_memory_rows = 288
|
||||
pressure_shared_memory_cols = 64
|
||||
pressure_shared_memory_dtype = float32
|
||||
; ... 其余 pressure_shared_memory_* 参数保持不变 ...
|
||||
```
|
||||
|
||||
### 4.2 使用原厂家(小压力板)
|
||||
|
||||
```ini
|
||||
[DEVICES]
|
||||
pressure_enable = True
|
||||
pressure_vendor = small
|
||||
pressure_use_mock = False
|
||||
; pressure_source / pressure_shared_memory_* 无需改动,small 管理器会忽略它们
|
||||
```
|
||||
|
||||
### 4.3 无硬件联调(模拟数据)
|
||||
|
||||
```ini
|
||||
pressure_enable = True
|
||||
pressure_vendor = small ; 或 large,均可
|
||||
pressure_use_mock = True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 兼容性与迁移
|
||||
|
||||
| 场景 | 说明 |
|
||||
| --- | --- |
|
||||
| 旧配置无 `pressure_vendor` | 工厂回退为 `large`,与当前线上行为一致,**无感知迁移** |
|
||||
| 前端无改动 | 两个管理器数据上报协议一致(`pressure_data` / `/devices`),前端不用改 |
|
||||
| 运行中切换厂家 | 通过既有 `restart_device('pressure')` 接口热切换;或改配置后重启后端 |
|
||||
| 模拟/真实切换 | 沿用各管理器内部 `pressure_use_mock` 逻辑,不受厂家切换影响 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试计划
|
||||
|
||||
1. **工厂单元验证**(venv 下直接调用):
|
||||
- `pressure_vendor=small` → `get_pressure_manager_class()` 返回 `pressure_manager_small.PressureManager`
|
||||
- `pressure_vendor=large` → 返回 `pressure_manager.PressureManager`
|
||||
- `pressure_vendor=非法值/缺省` → 回退 `large`,日志有告警
|
||||
2. **集成验证**(`pressure_use_mock=True`,不接硬件):
|
||||
- `large`:协调器初始化 → 压力板连接成功 → `start_streaming` → 收到 `pressure_data` 事件
|
||||
- `small`:同上流程验证
|
||||
3. **热切换验证**:`set_all_device_configs` 修改 `pressure_vendor` → 调用 `restart_device('pressure')` → 日志确认切换为另一厂家、数据流恢复
|
||||
4. **检测回归**:登录 → 创建检测 → 检测过程压力数据正常采集与保存
|
||||
5. **打包回归**:`build_backend.ps1` 打包后,分别在两种配置下运行 exe 验证初始化与数据流
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件修改清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `backend/devices/pressure_device_factory.py` | **新增** | 厂家注册表 + `create_pressure_manager` 工厂入口 |
|
||||
| `backend/devices/device_coordinator.py` | 修改 | 顶部 import、`_init_pressure()`、`restart_device()` pressure 分支改用工厂 |
|
||||
| `backend/main.py` | 修改 | 移除/替换 L33 直接导入 |
|
||||
| `backend/devices/__init__.py` | 修改 | 导出改为工厂接口 |
|
||||
| `backend/devices/utils/config_manager.py` | 修改 | `_get_pressure_config()` 增加 `vendor`;批量更新支持 `vendor` |
|
||||
| `backend/config.ini` | 修改 | `[DEVICES]` 增加 `pressure_vendor = large` |
|
||||
| `backend/dist/BodyBalanceBackend/config.ini` | 修改 | 同步增加 `pressure_vendor = large` |
|
||||
| `backend/build_app.py` | 修改 | `app.spec` 增加 `hiddenimports`(稳妥项) |
|
||||
| `document/多厂家足底压力板适配开发方案.md` | 新增 | 本文档 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与注意事项
|
||||
|
||||
1. **DLL 与硬件耦合**:`small` 厂家依赖 `dll/smitsense/Wrapper.dll`,切换回 `small` 时需确保该 DLL 存在于打包产物中;`large` 厂家依赖 X2 上位机软件创建的共享内存 `x2_pressure`,需先启动厂家采集程序。
|
||||
2. **打包收集**:若日后将工厂改为 `importlib` 动态导入,必须同步在 `app.spec` 的 `hiddenimports` 中显式声明两个厂家模块,否则打包后切换厂家会 ImportError。
|
||||
3. **两个模块内的 `RealPressureDevice` 重名**:`small` 与 `large` 模块内部都有同名类,工厂按模块隔离引用,不产生冲突,但**禁止**在外部直接 `from xxx import RealPressureDevice` 混用。
|
||||
4. **检测中切换厂家**:检测进行中不建议切换(会导致数据源中断),热切换应在非检测时段执行;检测启动前的"配置校验告警"可辅助提醒。
|
||||
5. **`pressure_source` 语义**:`large` 管理器内部仍以 `pressure_source`(`smitsense`/`shared_memory`)选择底层设备实现;本次方案中的 `pressure_vendor` 是"选哪个管理器文件"的更上层开关,两者不冲突、各司其职。
|
||||
6
frontend/package-lock.json
generated
Normal file
6
frontend/package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@ -43,7 +43,7 @@ const viteProcess = spawn('npm', ['run', 'dev'], {
|
||||
// 等待Vite服务器启动后启动Electron
|
||||
setTimeout(() => {
|
||||
console.log('\n⚡ 启动Electron应用...');
|
||||
const electronProcess = spawn('\"d:\\\\electron-v36.4.0-win32-x64\\\\electron.exe\"', [mainPath], {
|
||||
const electronProcess = spawn('\"D:\\\\01_Env\\\\Node\\\\electron-v39.2.7-win32-x64\\\\electron.exe\"', [mainPath], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: __dirname
|
||||
|
||||
24
frontend/src/renderer/package-lock.json
generated
24
frontend/src/renderer/package-lock.json
generated
@ -25,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^27.3.11",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-packager": "^17.1.2",
|
||||
"vite": "^4.4.9",
|
||||
@ -1160,13 +1160,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.19.122",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.122.tgz",
|
||||
"integrity": "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==",
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
@ -2743,15 +2743,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "27.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/electron/-/electron-27.3.11.tgz",
|
||||
"integrity": "sha512-E1SiyEoI8iW5LW/MigCr7tJuQe7+0105UjqY7FkmCD12e2O6vtUbQ0j05HaBh2YgvkcEVgvQ2A8suIq5b5m6Gw==",
|
||||
"version": "39.2.7",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz",
|
||||
"integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node": "^22.7.7",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
@ -6073,9 +6073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:3000 && npm run dev:electron\"",
|
||||
"dev:renderer": "vite",
|
||||
"dev:electron": "electron .",
|
||||
"dev:electron": "set NODE_ENV=development&&\"D:\\01_Env\\Node\\electron-v39.2.7-win32-x64\\electron.exe\" .",
|
||||
"build": "npm run build:renderer && npm run build:electron",
|
||||
"build:renderer": "vite build",
|
||||
"build:electron": "set HTTP_PROXY=&& set HTTPS_PROXY=&&electron-builder --config ./build/electron-builder.install.json",
|
||||
"build:electron": "set HTTP_PROXY=&& set HTTPS_PROXY=&&set \"ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/\"&&electron-builder --config ./build/electron-builder.install.json",
|
||||
"pack": "electron-packager . electron-browser --platform=win32 --arch=x64 --out=dist --overwrite",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^27.3.11",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-packager": "^17.1.2",
|
||||
"vite": "^4.4.9",
|
||||
|
||||
@ -1679,18 +1679,22 @@ function connectWebSocket() {
|
||||
devicesSocket.on('remote_control', (data) => {
|
||||
const code = String((data && data.code) || '').toUpperCase()
|
||||
switch (code) {
|
||||
case '11':
|
||||
startVideoClick()
|
||||
case '11':// IMU校准
|
||||
calibrationClick()
|
||||
break
|
||||
case '14':
|
||||
stopVideoClick()
|
||||
break
|
||||
case '12':
|
||||
clearAndStartTracking()
|
||||
break
|
||||
case '0F':
|
||||
case '13':// 截图
|
||||
saveDetectionData()
|
||||
break
|
||||
case '14':// IMU清零
|
||||
clearAndStartTracking()
|
||||
break
|
||||
case '15'://开始录像/停止录像
|
||||
if(isStartVideo.value === false){
|
||||
startVideoClick()
|
||||
}else{
|
||||
stopVideoClick()
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
@ -2055,6 +2059,7 @@ function clearAndStartTracking() {
|
||||
pitchUpMax: 0,
|
||||
pitchDownMax: 0
|
||||
}
|
||||
ElMessage.success('清零成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('清零失败')
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 5000,
|
||||
host: '0.0.0.0',
|
||||
// 开发服务器配置
|
||||
cors: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user