From d91af11b7d807c09766f1b6d5455591398d511e9 Mon Sep 17 00:00:00 2001 From: zhengsl <13910913995@163.com> Date: Tue, 11 Aug 2026 18:17:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=95=E5=B1=8F0=E5=AD=97?= =?UTF-8?q?=E8=8A=82=E9=97=AE=E9=A2=98=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8E=8B?= =?UTF-8?q?=E5=8A=9B=E6=9D=BF=E5=A4=9A=E5=8E=82=E5=AE=B6=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 录屏: 高DPI缩放导致奇数尺寸触发libx264编码失败,宽高取偶; ffmpeg stderr写入日志 - 压力板: 新增 pressure_manager_small.py 原厂家小压力板,与现有大压力板并存 - 打包: 新增 build_backend.ps1,更新 requirements_build.txt / config.ini - 前端: Electron 本地化配置 --- .vscode/launch.json | 1 + backend/_test_rec.py | 40 + backend/build_app.py | 81 +- backend/build_backend.ps1 | 67 + backend/config.ini | 29 +- ..._SharedMemory_Reader_For_Visuallazation.py | 237 ++++ backend/devices/pressure_manager.py | 590 ++++++++- backend/devices/pressure_manager_small.py | 1106 +++++++++++++++++ backend/devices/remote_control_manager.py | 15 +- backend/devices/screen_recorder.py | 22 +- backend/main.py | 2 +- backend/requirements_build.txt | 11 +- document/多厂家足底压力板适配开发方案.md | 350 ++++++ frontend/package-lock.json | 6 + frontend/src/renderer/electron-dev.js | 2 +- frontend/src/renderer/package-lock.json | 24 +- frontend/src/renderer/package.json | 6 +- frontend/src/renderer/src/views/Detection.vue | 23 +- frontend/src/renderer/vite.config.js | 2 +- 19 files changed, 2550 insertions(+), 64 deletions(-) create mode 100644 backend/_test_rec.py create mode 100644 backend/build_backend.ps1 create mode 100644 backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py create mode 100644 backend/devices/pressure_manager_small.py create mode 100644 document/多厂家足底压力板适配开发方案.md create mode 100644 frontend/package-lock.json diff --git a/.vscode/launch.json b/.vscode/launch.json index fccb5d4d..e89f72c5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}", diff --git a/backend/_test_rec.py b/backend/_test_rec.py new file mode 100644 index 00000000..6986a14e --- /dev/null +++ b/backend/_test_rec.py @@ -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) diff --git a/backend/build_app.py b/backend/build_app.py index 59157426..d0dac6c6 100644 --- a/backend/build_app.py +++ b/backend/build_app.py @@ -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() diff --git a/backend/build_backend.ps1 b/backend/build_backend.ps1 new file mode 100644 index 00000000..14d8a768 --- /dev/null +++ b/backend/build_backend.ps1 @@ -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 diff --git a/backend/config.ini b/backend/config.ini index 83ba47db..373032b6 100644 --- a/backend/config.ini +++ b/backend/config.ini @@ -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 diff --git a/backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py b/backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py new file mode 100644 index 00000000..a041a87e --- /dev/null +++ b/backend/devices/X2_SharedMemory_Reader_For_Visuallazation.py @@ -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更新 + diff --git a/backend/devices/pressure_manager.py b/backend/devices/pressure_manager.py index 1d81f4d8..f0a027bf 100644 --- a/backend/devices/pressure_manager.py +++ b/backend/devices/pressure_manager.py @@ -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: diff --git a/backend/devices/pressure_manager_small.py b/backend/devices/pressure_manager_small.py new file mode 100644 index 00000000..1d81f4d8 --- /dev/null +++ b/backend/devices/pressure_manager_small.py @@ -0,0 +1,1106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +压力板管理器 +负责压力传感器的连接、校准和足部压力数据采集 +""" + +import os +import ctypes +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 cv2 +import matplotlib.pyplot as plt +import matplotlib.cm as cm +from io import BytesIO +import base64 +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__) + +# 检查matplotlib可用性 +try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import matplotlib.patches as patches + MATPLOTLIB_AVAILABLE = True +except ImportError: + MATPLOTLIB_AVAILABLE = False + logger.warning("matplotlib不可用,将使用简化的压力图像生成") + + +# 定义 C 结构体 +class FPMS_DEVICE_INFO(ctypes.Structure): + _fields_ = [ + ("mn", ctypes.c_uint16), + ("sn", ctypes.c_char * 64), + ("fwVersion", ctypes.c_uint16), + ("protoVer", ctypes.c_uint8), + ("pid", ctypes.c_uint16), + ("vid", ctypes.c_uint16), + ("rows", ctypes.c_uint16), + ("cols", ctypes.c_uint16), + ] + + +class RealPressureDevice: + """真实SMiTSense压力传感器设备""" + + # 类级别的USB初始化状态跟踪 + _usb_initialized = False + _usb_init_lock = threading.Lock() + + def __init__(self, dll_path=None): + """ + 初始化SMiTSense压力传感器 + + Args: + dll_path: DLL文件路径,如果为None则使用默认路径 + """ + self.dll = None + self.device_handle = None + self.is_connected = False + self.rows = 0 + self.cols = 0 + self.frame_size = 0 + self.buf = None + + # 设置DLL路径 - 使用Wrapper.dll + if dll_path is None: + # 尝试多个可能的DLL文件名 + dll_candidates = [ + os.path.join(os.path.dirname(__file__), '..', 'dll', 'smitsense', 'Wrapper.dll'), + os.path.join(os.path.dirname(__file__), '..', 'dll', 'smitsense', 'SMiTSenseUsb-F3.0.dll') + ] + dll_path = None + for candidate in dll_candidates: + if os.path.exists(candidate): + dll_path = candidate + break + + if dll_path is None: + raise FileNotFoundError(f"未找到SMiTSense DLL文件,检查路径: {dll_candidates}") + + self.dll_path = dll_path + logger.info(f'初始化真实压力传感器设备,DLL路径: {dll_path}') + + try: + self._load_dll() + self._initialize_device() + except Exception as e: + logger.error(f'压力传感器初始化失败: {e}') + # 如果真实设备初始化失败,可以选择降级为模拟设备 + raise + + def _load_dll(self): + """加载SMiTSense DLL并设置函数签名""" + try: + if not os.path.exists(self.dll_path): + raise FileNotFoundError(f"DLL文件未找到: {self.dll_path}") + + # 加载DLL + self.dll = ctypes.CDLL(self.dll_path) + logger.info(f"成功加载DLL: {self.dll_path}") + + # 设置函数签名(基于test22new.py的工作代码) + self.dll.fpms_usb_init_wrap.argtypes = [ctypes.c_int] + self.dll.fpms_usb_init_wrap.restype = ctypes.c_int + + self.dll.fpms_usb_get_device_list_wrap.argtypes = [ctypes.POINTER(FPMS_DEVICE_INFO), ctypes.c_int, ctypes.POINTER(ctypes.c_int)] + self.dll.fpms_usb_get_device_list_wrap.restype = ctypes.c_int + + self.dll.fpms_usb_open_wrap.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint64)] + self.dll.fpms_usb_open_wrap.restype = ctypes.c_int + + self.dll.fpms_usb_read_frame_wrap.argtypes = [ctypes.c_uint64, ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t] + self.dll.fpms_usb_read_frame_wrap.restype = ctypes.c_int + + self.dll.fpms_usb_close_wrap.argtypes = [ctypes.c_uint64] + self.dll.fpms_usb_close_wrap.restype = ctypes.c_int + + logger.info("DLL函数签名设置完成") + + except Exception as e: + logger.error(f"加载DLL失败: {e}") + raise + + def _initialize_device(self): + """初始化设备连接""" + try: + # 使用类级别锁确保USB子系统只初始化一次 + with RealPressureDevice._usb_init_lock: + if not RealPressureDevice._usb_initialized: + # 初始化USB连接 + if self.dll.fpms_usb_init_wrap(0) != 0: + raise RuntimeError("USB子系统初始化失败") + RealPressureDevice._usb_initialized = True + logger.info("USB子系统初始化成功") + else: + logger.info("USB子系统已初始化,跳过重复初始化") + + # 获取设备列表 + count = ctypes.c_int() + devs = (FPMS_DEVICE_INFO * 10)() + r = self.dll.fpms_usb_get_device_list_wrap(devs, 10, ctypes.byref(count)) + if r != 0 or count.value == 0: + raise RuntimeError(f"未检测到设备: {r}, count: {count.value}") + + logger.info(f"检测到设备数量: {count.value}") + dev = devs[0] + self.rows, self.cols = dev.rows, dev.cols + logger.info(f"使用设备 SN={dev.sn.decode(errors='ignore')} {self.rows}x{self.cols}") + + # 打开设备 + self.device_handle = ctypes.c_uint64() + r = self.dll.fpms_usb_open_wrap(0, ctypes.byref(self.device_handle)) + if r != 0: + raise RuntimeError("设备打开失败") + + logger.info(f"设备已打开, 句柄 = {self.device_handle.value}") + + # 准备数据缓冲区 + self.frame_size = self.rows * self.cols + self.buf_type = ctypes.c_uint16 * self.frame_size + self.buf = self.buf_type() + # 设置连接状态 + self.is_connected = True + + logger.info(f"SMiTSense压力传感器初始化成功: {self.rows}行 x {self.cols}列") + + except Exception as e: + logger.error(f"设备初始化失败: {e}") + raise + + def read_data(self) -> Dict[str, Any]: + """读取压力数据并转换为与MockPressureDevice兼容的格式""" + try: + if not self.is_connected or not self.dll: + return self._get_empty_data() + # 检查device_handle是否有效 + if not self.device_handle: + return self._get_empty_data() + + # 读取原始压力数据 + r = self.dll.fpms_usb_read_frame_wrap(self.device_handle.value, self.buf, self.frame_size) + if r != 0: + logger.warning(f"读取帧失败, code= {r}") + # 如果返回负数,多半表示物理断开或严重错误,标记断连并关闭句柄,触发上层重连 + if r < 0: + try: + if self.device_handle: + try: + self.dll.fpms_usb_close_wrap(self.device_handle.value) + except Exception: + pass + finally: + self.device_handle = None + except Exception: + pass + return self._get_empty_data() + + # 转换为numpy数组 + raw_data = np.frombuffer(self.buf, dtype=np.uint16).reshape((self.rows, self.cols)) + + # 计算足部区域压力 (基于传感器的实际布局) + foot_zones = self._calculate_foot_pressure_zones(raw_data) + + # 生成压力图像 + pressure_image_base64 = self._generate_pressure_image( + foot_zones['left_front'], + foot_zones['left_rear'], + foot_zones['right_front'], + foot_zones['right_rear'], + raw_data + ) + + return { + 'foot_pressure': { + 'left_front': round(foot_zones['left_front'], 2), + 'left_rear': round(foot_zones['left_rear'], 2), + 'right_front': round(foot_zones['right_front'], 2), + 'right_rear': round(foot_zones['right_rear'], 2), + 'left_total': round(foot_zones['left_total'], 2), + 'right_total': round(foot_zones['right_total'], 2) + }, + 'pressure_image': pressure_image_base64, + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"读取压力数据异常: {e}") + return self._get_empty_data() + + def _calculate_foot_pressure_zones(self, raw_data): + """计算足部区域压力,返回百分比: + - 左足、右足:相对于双足总压的百分比 + - 左前、左后:相对于左足总压的百分比 + - 右前、右后:相对于右足总压的百分比 + 基于原始矩阵按行列各等分为四象限(上半部为前、下半部为后,左半部为左、右半部为右)。 + """ + try: + # 防护:空数据 + if raw_data is None: + raise ValueError("raw_data is None") + + # 转为浮点以避免 uint16 溢出 + 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"计算足部区域压力异常: {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_pressure_image(self, left_front, left_rear, right_front, right_rear, raw_data=None) -> str: + """生成足部压力图片的base64数据""" + 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 as e: + logger.warning(f"生成压力图片失败: {e}") + return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + + def _generate_heatmap_image(self, raw_data) -> str: + """生成基于原始数据的热力图(OpenCV实现,自适应归一化,黑色背景)""" + try: + import cv2 + import numpy as np + import base64 + from io import BytesIO + from PIL import Image + + # 自适应归一化(基于test22new.py的方法2) + vmin = 10 # 最小阈值,低于此值显示为黑色 + dmin, dmax = np.min(raw_data), np.max(raw_data) + norm_data = np.clip((raw_data - dmin) / max(dmax - dmin, 1) * 255, 0, 255).astype(np.uint8) + + # 应用 jet 颜色映射 + heatmap = cv2.applyColorMap(norm_data, 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) + + # OpenCV 生成的是 BGR,转成 RGB + heatmap_rgb = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) + + # 转成 Pillow Image + img = Image.fromarray(heatmap_rgb) + + # 输出为 Base64 PNG + buffer = BytesIO() + img.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 as e: + logger.warning(f"生成热力图失败: {e}") + return self._generate_simple_pressure_image(0, 0, 0, 0) + + def _generate_simple_pressure_image(self, left_front, left_rear, right_front, right_rear) -> str: + """生成简单的足部压力区域图""" + try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import matplotlib.patches as patches + from io import BytesIO + + # 创建图形 + 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') + + # 定义颜色映射 + max_pressure = max(left_front, left_rear, right_front, right_rear) + if max_pressure > 0: + left_front_color = plt.cm.Reds(left_front / max_pressure) + left_rear_color = plt.cm.Reds(left_rear / max_pressure) + right_front_color = plt.cm.Reds(right_front / max_pressure) + right_rear_color = plt.cm.Reds(right_rear / max_pressure) + else: + left_front_color = left_rear_color = right_front_color = right_rear_color = 'lightgray' + + # 绘制足部区域 + left_front_rect = patches.Rectangle((1, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=left_front_color) + left_rear_rect = patches.Rectangle((1, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=left_rear_color) + right_front_rect = patches.Rectangle((7, 6), 2, 4, linewidth=1, edgecolor='black', facecolor=right_front_color) + right_rear_rect = patches.Rectangle((7, 2), 2, 4, linewidth=1, edgecolor='black', facecolor=right_rear_color) + + ax.add_patch(left_front_rect) + ax.add_patch(left_rear_rect) + ax.add_patch(right_front_rect) + ax.add_patch(right_rear_rect) + + # 添加标签 + 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') + + # 保存为base64 + 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 as e: + logger.warning(f"生成简单压力图片失败: {e}") + return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + + def _get_empty_data(self): + """返回空的压力数据""" + 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.is_connected and self.dll and self.device_handle: + self.dll.fpms_usb_close_wrap(self.device_handle.value) + # 重置设备句柄 + self.device_handle = None + # 设置连接状态为断开 + self.is_connected = False + logger.info('SMiTSense压力传感器连接已关闭') + except Exception as e: + logger.error(f'关闭压力传感器连接异常: {e}') + + @classmethod + def reset_usb_state(cls): + """重置USB初始化状态(用于设备完全断开后的重新初始化)""" + with cls._usb_init_lock: + cls._usb_initialized = False + logger.info("USB子系统状态已重置") + + def __del__(self): + """析构函数,确保资源清理""" + 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 + # 归一化到 [0,255],避免 dmax==dmin 时除零 + 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) + # 应用伪彩色(JET)以增强对比 + heatmap = cv2.applyColorMap(norm, cv2.COLORMAP_JET) + # 将低值区域设置为背景色 #263040;OpenCV 使用 BGR 通道顺序 -> (64, 48, 38) + heatmap[raw_data <= vmin] = (64, 48, 38) + # 放大显示,保持像素边界清晰 + rows, cols = raw_data.shape + heatmap = cv2.resize(heatmap, (cols * 4, rows * 4), interpolation=cv2.INTER_NEAREST) + # 转换为 RGB 交给 PIL 编码 + 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) + # 输出 data URL 便于前端直接显示 + 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): + """压力板管理器""" + + def __init__(self, socketio, config_manager: Optional[ConfigManager] = None): + """ + 初始化压力板管理器 + + Args: + socketio: SocketIO实例 + config_manager: 配置管理器实例 + """ + # 配置管理 + self.config_manager = config_manager or ConfigManager() + self.config = self.config_manager.get_device_config('pressure') + + super().__init__("pressure", self.config) + + # 保存socketio实例 + self._socketio = socketio + + # 设备实例 + self.device = None + self.use_mock = bool(self.config.get('use_mock', False)) + + # 数据流相关 + self.streaming_thread = None + self.is_streaming = False + self.stream_interval = self.config.get('stream_interval', 0.1) # 100ms间隔 + + # 校准相关 + self.is_calibrated = False + self.calibration_data = None + + # 性能统计 + self.packet_count = 0 + self.error_count = 0 + self.last_data_time = None + + # 重连相关配置(与camera_manager保持一致的键名和默认值) + self.max_reconnect_attempts = int(self.config.get('max_reconnect_attempts', -1)) # -1 表示无限重连 + self.reconnect_delay = float(self.config.get('reconnect_delay', 2.0)) + self.read_fail_threshold = int(self.config.get('read_fail_threshold', 30)) + self._last_connected_state = None # 去抖动状态广播 + + self.logger.info(f"压力板管理器初始化完成 - use_mock: {self.use_mock}") + + def initialize(self) -> bool: + """ + 初始化压力板设备 + + Returns: + bool: 初始化是否成功 + """ + self._initializing = True + try: + self.logger.info(f"正在初始化压力板设备...") + + # 使用构造函数中已加载的配置,避免并发读取配置文件 + 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() + + connected = False + try: + if self.use_mock: + connected = True + elif hasattr(self.device, 'is_connected'): + connected = bool(self.device.is_connected) + else: + connected = bool(self.check_hardware_connection()) + except Exception: + connected = False + + # 使用set_connected方法启动连接监控线程 + self.set_connected(bool(connected)) + self._device_info.update({ + 'device_type': 'mock' if self.use_mock else 'real', + 'matrix_size': '4x4' if hasattr(self.device, 'rows') else 'unknown' + }) + + if not connected: + self.logger.warning("压力板初始化完成但硬件未连接") + return False + + self.logger.info(f"压力板初始化成功 - use_mock: {self.use_mock}") + return True + + except Exception as e: + self.logger.error(f"压力板初始化失败: {e}") + # 使用set_connected方法停止连接监控线程 + self.set_connected(False) + self.device = None + return False + finally: + self._initializing = False + + def start_streaming(self) -> bool: + + """ + 开始压力数据流 + + Args: + socketio: SocketIO实例 + + Returns: + bool: 启动是否成功 + """ + try: + if not self.is_connected or not self.device: + self.logger.error("设备未连接,无法启动数据流") + return False + + if self.is_streaming: + self.logger.warning("压力数据流已在运行") + return True + + self.is_streaming = True + self.streaming_thread = threading.Thread(target=self._pressure_streaming_thread, daemon=True) + self.streaming_thread.start() + + self.logger.info("压力数据流启动成功") + return True + + except Exception as e: + self.logger.error(f"启动压力数据流失败: {e}") + self.is_streaming = False + return False + + def stop_streaming(self) -> bool: + """ + 停止压力数据流 + + Returns: + bool: 停止是否成功 + """ + try: + if not self.is_streaming: + return True + + self.is_streaming = False + + if self.streaming_thread and self.streaming_thread.is_alive(): + self.streaming_thread.join(timeout=2.0) + + self.logger.info("压力数据流已停止") + return True + + except Exception as e: + self.logger.error(f"停止压力数据流失败: {e}") + return False + + def _pressure_streaming_thread(self): + """ + 压力数据流处理线程 + """ + self.logger.info("压力数据流线程启动") + + try: + while self.is_streaming: + try: + # 从设备读取数据 + pressure_data = None + if self.device: + pressure_data = self.device.read_data() + # 如果底层设备在读取时标记了断开,则在此处进入下一轮以触发重连 + if hasattr(self.device, 'is_connected') and not self.device.is_connected: + self.is_connected = False + time.sleep(self.reconnect_delay) + continue + + # 读数成功,立即更新心跳和连接状态 + self.is_connected = True + self.update_heartbeat() + + foot_pressure = pressure_data['foot_pressure'] + # 获取各区域压力值 + left_front = foot_pressure['left_front'] + left_rear = foot_pressure['left_rear'] + right_front = foot_pressure['right_front'] + right_rear = foot_pressure['right_rear'] + left_total = foot_pressure['left_total'] + right_total = foot_pressure['right_total'] + + # 计算总压力 + total_pressure = left_total + right_total + + # 计算平衡比例(左脚压力占总压力的比例) + balance_ratio = left_total / total_pressure if total_pressure > 0 else 0.5 + + # 计算压力中心偏移 + pressure_center_offset = (balance_ratio - 0.5) * 100 # 转换为百分比 + + # 计算前后足压力分布 + left_front_ratio = left_front / left_total if left_total > 0 else 0.5 + right_front_ratio = right_front / right_total if right_total > 0 else 0.5 + + # 构建完整的足部压力数据 + complete_pressure_data = { + 'pressure_zones': { + 'left_front': left_front, + 'left_rear': left_rear, + 'right_front': right_front, + 'right_rear': right_rear, + 'left_total': left_total, + 'right_total': right_total, + 'total_pressure': total_pressure + }, + 'balance_analysis': { + 'balance_ratio': round(balance_ratio, 3), + 'pressure_center_offset': round(pressure_center_offset, 2), + 'balance_status': 'balanced' if abs(pressure_center_offset) < 10 else 'unbalanced', + 'left_front_ratio': round(left_front_ratio, 3), + 'right_front_ratio': round(right_front_ratio, 3) + }, + 'pressure_image': pressure_data.get('pressure_image', ''), + 'timestamp': pressure_data['timestamp'] + } + + # 更新统计信息 + self.packet_count += 1 + self.last_data_time = time.time() + + # 发送数据到前端 + if self._socketio: + self._socketio.emit('pressure_data', { + 'foot_pressure': complete_pressure_data, + 'timestamp': datetime.now().isoformat() + }, namespace='/devices') + else: + self.logger.warning("SocketIO实例为空,无法发送压力数据") + + time.sleep(self.stream_interval) + + except Exception as e: + self.error_count += 1 + # self.logger.error(f"压力数据流处理异常: {e}") + time.sleep(0.1) + + except Exception as e: + self.logger.error(f"压力数据流线程异常: {e}") + finally: + self.logger.info("压力数据流线程结束") + + + + def get_status(self) -> Dict[str, Any]: + """ + 获取设备状态 + + Returns: + Dict[str, Any]: 设备状态信息 + """ + return { + 'device_type': 'mock' if self.use_mock else 'real', + 'is_connected': self.is_connected, + 'is_streaming': self.is_streaming, + 'is_calibrated': self.is_calibrated, + 'packet_count': self.packet_count, + 'error_count': self.error_count, + 'last_data_time': self.last_data_time, + 'device_info': self.get_device_info() + } + + def calibrate(self) -> bool: + """ + 校准压力传感器 + + Returns: + bool: 校准是否成功 + """ + try: + self.logger.info("开始压力传感器校准...") + + # 这里可以添加具体的校准逻辑 + # 目前简单设置为已校准状态 + self.is_calibrated = True + self.calibration_data = { + 'timestamp': datetime.now().isoformat(), + 'baseline': 'calibrated' + } + + self.logger.info("压力传感器校准完成") + return True + + except Exception as e: + self.logger.error(f"压力传感器校准失败: {e}") + return False + + def disconnect(self) -> bool: + """ + 断开设备连接 + + Returns: + bool: 断开是否成功 + """ + try: + # 停止数据流 + self.stop_streaming() + + # 关闭设备连接 + if self.device and hasattr(self.device, 'close'): + self.device.close() + + self.device = None + # 使用set_connected方法停止连接监控线程 + self.set_connected(False) + + self.logger.info("压力板设备连接已断开") + return True + + except Exception as e: + self.logger.error(f"断开压力板设备连接失败: {e}") + return False + + def reload_config(self) -> bool: + """ + 重新加载压力板配置 + + Returns: + bool: 配置重新加载是否成功 + """ + try: + self.logger.info("正在重新加载压力板配置...") + + # 重新获取配置 + new_config = self.config_manager.get_device_config('pressure') + + # 更新配置属性 + 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.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)) + self.read_fail_threshold = int(new_config.get('read_fail_threshold', self.read_fail_threshold)) + + self.logger.info(f"压力板配置重新加载成功 - use_mock: {self.use_mock}, 流间隔: {self.stream_interval}") + return True + + except Exception as e: + self.logger.error(f"重新加载压力板配置失败: {e}") + return False + + def check_hardware_connection(self) -> bool: + """ + 检查压力板硬件连接状态 + + Returns: + bool: 硬件连接是否正常 + """ + try: + if not self.device: + # 如果设备实例不存在,返回False表示硬件未连接 + return self._attempt_device_reconnection() + + # 对于真实设备,检查DLL和设备句柄状态 + if hasattr(self.device, 'dll') and hasattr(self.device, 'device_handle'): + if not self.device.dll or not self.device.device_handle: + # DLL或句柄无效,返回False表示硬件未连接 + return self._attempt_device_reconnection() + + # 直接检查设备句柄的有效性 + try: + # 检查设备句柄是否有效 + if not self.device.device_handle or not hasattr(self.device.device_handle, 'value'): + return self._attempt_device_reconnection() + + # 检查句柄值是否为0(无效句柄) + if self.device.device_handle.value == 0: + return self._attempt_device_reconnection() + + # 尝试实际的设备通信来验证硬件连接 + # 使用DLL函数检查设备列表,验证设备是否真实存在 + count = ctypes.c_int() + devs = (FPMS_DEVICE_INFO * 10)() + r = self.device.dll.fpms_usb_get_device_list_wrap(devs, 10, ctypes.byref(count)) + + # 如果获取设备列表失败或设备数量为0,说明硬件已断开 + if r != 0 or count.value == 0: + self.logger.debug(f"设备列表检查失败: r={r}, count={count.value}") + return self._attempt_device_reconnection() + + # 设备列表正常,硬件连接正常 + return True + + except Exception as e: + self.logger.debug(f"硬件连接检查异常: {e}") + return self._attempt_device_reconnection() + + # 对于Mock设备,直接返回True + return True + + except Exception as e: + self.logger.debug(f"检查压力板硬件连接时出错: {e}") + return False + + def _attempt_device_reconnection(self) -> bool: + """ + 尝试重新连接压力板设备 + + Returns: + bool: 重连是否成功 + """ + try: + self.logger.info("检测到压力板设备断开,尝试重新连接...") + + # 清理旧的设备实例 + if self.device and hasattr(self.device, 'close'): + try: + self.device.close() + except Exception as e: + self.logger.debug(f"清理旧设备实例时出错: {e}") + + self.device = None + + # 重置USB状态,为重新插入的设备做准备 + RealPressureDevice.reset_usb_state() + + # 根据设备类型重新创建设备实例 + self.device = RealPressureDevice() + + # 检查新设备是否连接成功 + if hasattr(self.device, 'is_connected') and self.device.is_connected: + self._notify_status_change(True) + # 重连成功后,确保数据流正在运行 + self.logger.info("重连成功,启动压力数据流") + self.start_streaming() + + return True + else: + self.logger.warning("压力板设备重连失败") + return False + + except Exception as e: + self.logger.error(f"压力板设备重连过程中出错: {e}") + self.device = None + return False + + def cleanup(self) -> None: + """清理资源""" + try: + # 停止连接监控 + self._cleanup_monitoring() + + self.stop_streaming() + self.disconnect() + self.logger.info("压力板设备资源清理完成") + except Exception as e: + self.logger.error(f"压力板设备资源清理失败: {e}") diff --git a/backend/devices/remote_control_manager.py b/backend/devices/remote_control_manager.py index cf0ff7cb..b07282fc 100644 --- a/backend/devices/remote_control_manager.py +++ b/backend/devices/remote_control_manager.py @@ -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 = { diff --git a/backend/devices/screen_recorder.py b/backend/devices/screen_recorder.py index a19d2821..2c9900e4 100644 --- a/backend/devices/screen_recorder.py +++ b/backend/devices/screen_recorder.py @@ -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: diff --git a/backend/main.py b/backend/main.py index e5da5118..9cd54fb0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/backend/requirements_build.txt b/backend/requirements_build.txt index 738235a6..1397e441 100644 --- a/backend/requirements_build.txt +++ b/backend/requirements_build.txt @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/document/多厂家足底压力板适配开发方案.md b/document/多厂家足底压力板适配开发方案.md new file mode 100644 index 00000000..2b5967f6 --- /dev/null +++ b/document/多厂家足底压力板适配开发方案.md @@ -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` 是"选哪个管理器文件"的更上层开关,两者不冲突、各司其职。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..aba25f73 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/frontend/src/renderer/electron-dev.js b/frontend/src/renderer/electron-dev.js index 1615f229..b07edb91 100644 --- a/frontend/src/renderer/electron-dev.js +++ b/frontend/src/renderer/electron-dev.js @@ -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 diff --git a/frontend/src/renderer/package-lock.json b/frontend/src/renderer/package-lock.json index a4ca3e3f..d9c24626 100644 --- a/frontend/src/renderer/package-lock.json +++ b/frontend/src/renderer/package-lock.json @@ -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" }, diff --git a/frontend/src/renderer/package.json b/frontend/src/renderer/package.json index fb7eabaf..368fb7c6 100644 --- a/frontend/src/renderer/package.json +++ b/frontend/src/renderer/package.json @@ -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", diff --git a/frontend/src/renderer/src/views/Detection.vue b/frontend/src/renderer/src/views/Detection.vue index 29b46c16..fc6c602c 100644 --- a/frontend/src/renderer/src/views/Detection.vue +++ b/frontend/src/renderer/src/views/Detection.vue @@ -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('清零失败') } diff --git a/frontend/src/renderer/vite.config.js b/frontend/src/renderer/vite.config.js index c6b9c5d7..affd6fe8 100644 --- a/frontend/src/renderer/vite.config.js +++ b/frontend/src/renderer/vite.config.js @@ -23,7 +23,7 @@ export default defineConfig({ } }, server: { - port: 3000, + port: 5000, host: '0.0.0.0', // 开发服务器配置 cors: true,