Spaces:
Running
Running
Download collector/core/render_engine.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 51.9 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/core/render_engine.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/collector/core/render_engine.py
-
curl -L -o render_engine.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/core/render_engine.py
51.9 kB
| """ | |
| 传感器数据渲染引擎 —— 6 种可视化模式。 | |
| 所有渲染函数接收 (processed_data, max_signal, config, window_size) | |
| 返回 BGR 格式的 numpy 帧数组,由 UI 层转为 QPixmap 显示。 | |
| 末节的 render_tactile_grid 是厂商分区网格(Glove-test V1.4 移植): | |
| 录制页与回放页的默认触觉画面,实现与 | |
| tools/demos/pooled_viewer_demo 里的同名函数**逐像素一致**, | |
| 由 tools/tests/test_tactile_grid_render.py 锁住。 | |
| """ | |
| import json | |
| import os | |
| from collections import OrderedDict | |
| import numpy as np | |
| import cv2 | |
| from config import settings | |
| # ── 常量 ────────────────────────────────────────────── | |
| MATRIX_ROWS = 16 | |
| MATRIX_COLS = 16 | |
| VIRIDIS_LUT: np.ndarray = None | |
| # ── 配置路径 ────────────────────────────────────────── | |
| # 仿生手掌(左/右手)映射配置。原定义在 sensors.sensor_panel, | |
| # 迁到这里:glove_widget / playback_dialog 需要路径但不应引入 bleak。 | |
| CONFIG_DIR = os.path.join(settings.BASE_DIR, "config", "sensors") | |
| CONFIG_FILE = os.path.join(CONFIG_DIR, "hand_ble_config.json") | |
| CONFIG_FILE_LEFT = os.path.join(CONFIG_DIR, "hand_ble_config_left.json") | |
| def _get_viridis_lut() -> np.ndarray: | |
| """延迟初始化 Viridis 颜色查找表。""" | |
| global VIRIDIS_LUT | |
| if VIRIDIS_LUT is None: | |
| lut = np.zeros((256, 1, 3), dtype=np.uint8) | |
| for i in range(256): | |
| lut[i, 0, 0] = i | |
| VIRIDIS_LUT = cv2.applyColorMap(lut, cv2.COLORMAP_VIRIDIS) | |
| return VIRIDIS_LUT | |
| # ── HUD 绘制 ────────────────────────────────────────── | |
| def _draw_hud(frame: np.ndarray, lines: list, color=(255, 255, 255)): | |
| """在帧左上角绘制半透明 HUD 信息栏。""" | |
| font, scale, thick, lh, pad = cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1, 22, 10 | |
| max_w = ( | |
| max(cv2.getTextSize(l, font, scale, thick)[0][0] for l in lines) | |
| if lines else 0 | |
| ) | |
| overlay = frame.copy() | |
| cv2.rectangle( | |
| overlay, (0, 0), (max_w + pad * 2, len(lines) * lh + pad * 2), | |
| (0, 0, 0), -1, | |
| ) | |
| cv2.addWeighted(overlay, 0.5, frame, 0.5, 0, frame) | |
| for i, line in enumerate(lines): | |
| cv2.putText( | |
| frame, line, (pad, pad + lh - 5 + i * lh), | |
| font, scale, color, thick, cv2.LINE_AA, | |
| ) | |
| # ── 配置加载 ────────────────────────────────────────── | |
| def _load_json(path, default): | |
| if os.path.exists(path): | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| loaded = json.load(f) | |
| if isinstance(default, dict): | |
| default.update(loaded) | |
| return default | |
| return loaded | |
| except Exception: | |
| pass | |
| return default | |
| # ═══════════════════════════════════════════════════════ | |
| # 模式 1: 热力图 | |
| # ═══════════════════════════════════════════════════════ | |
| def render_heatmap( | |
| processed: np.ndarray, | |
| max_signal: float, | |
| config: dict, | |
| window_size: tuple, | |
| current_vmax: float, | |
| fps: float, | |
| noise_gate: int, | |
| dyn_ratio: float, | |
| spatial_on: bool, | |
| ) -> tuple: | |
| """返回 (frame, new_vmax)。""" | |
| ww, wh = window_size | |
| rows = config.get("rows", list(range(16))) | |
| cols = config.get("cols", list(range(16))) | |
| order = config.get("axis_order", "row_col") | |
| # 增强参数(来自 config,向后兼容) | |
| subpixel = config.get("subpixel", True) | |
| gamma = config.get("gamma", 1.0) | |
| blur_ksize = config.get("blur", 5) | |
| nr, nc = len(rows), len(cols) | |
| if nr == 0 or nc == 0: | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| _draw_hud(frame, ["Press [M] to select Heatmap Rows/Cols!"], (0, 0, 255)) | |
| return frame, current_vmax | |
| # 提取子矩阵 | |
| sub = processed[np.ix_(rows, cols)] | |
| if order != "row_col": | |
| sub = sub.T | |
| # ── Subpixel 超分 ────────────────────────────── | |
| if subpixel and sub.shape[0] >= 2 and sub.shape[1] >= 2: | |
| H0, W0 = sub.shape | |
| shrink = 1.5 | |
| padded = np.pad(sub, 1, mode='constant') | |
| top = padded[0:H0, 1:W0 + 1] | |
| bottom = padded[2:H0 + 2, 1:W0 + 1] | |
| left = padded[1:H0 + 1, 0:W0] | |
| right = padded[1:H0 + 1, 2:W0 + 2] | |
| sum_x = left + right + sub + 1e-6 | |
| sum_y = top + bottom + sub + 1e-6 | |
| dx = np.clip((right - left) / sum_x * shrink, -1, 1) | |
| dy = np.clip((bottom - top) / sum_y * shrink, -1, 1) | |
| super_res = np.zeros((H0 * 2, W0 * 2), dtype=np.float32) | |
| super_res[0::2, 0::2] = sub * np.maximum(0, 1.0 - dx - dy) | |
| super_res[0::2, 1::2] = sub * np.maximum(0, 1.0 + dx - dy) | |
| super_res[1::2, 0::2] = sub * np.maximum(0, 1.0 - dx + dy) | |
| super_res[1::2, 1::2] = sub * np.maximum(0, 1.0 + dx + dy) | |
| sub = super_res | |
| H, W = sub.shape | |
| scale_w = (ww - 200) / W | |
| scale_h = (wh - 120) / H | |
| scale = min(scale_w, scale_h) | |
| target_w = int(W * scale) | |
| target_h = int(H * scale) | |
| resized = cv2.resize(sub, (target_w, target_h), interpolation=cv2.INTER_CUBIC) | |
| if blur_ksize > 0: | |
| k = blur_ksize if blur_ksize % 2 == 1 else blur_ksize + 1 | |
| resized = cv2.GaussianBlur(resized, (k, k), 0) | |
| # 平滑 vmax | |
| vmax_cand = max(5000, np.percentile(resized, 99.7)) | |
| new_vmax = current_vmax * 0.95 + vmax_cand * 0.05 if vmax_cand > current_vmax else vmax_cand | |
| # ── Gamma 校正 ──────────────────────────────── | |
| norm = np.clip(resized / new_vmax, 0, 1) if new_vmax > 0 else resized | |
| if gamma != 1.0: | |
| norm = np.power(norm, gamma) | |
| img_8u = (norm * 255).astype(np.uint8) | |
| color = cv2.applyColorMap(img_8u, cv2.COLORMAP_VIRIDIS) | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| sx = (ww - target_w) // 2 | |
| sy = (wh - target_h) // 2 + 20 | |
| frame[sy:sy + target_h, sx:sx + target_w] = color | |
| _draw_hud(frame, [ | |
| f"Heatmap | FPS:{fps:.1f} | Max:{int(max_signal)}", | |
| f"Gate:{noise_gate} | Dyn:{dyn_ratio:.2f} | Filter:{'ON' if spatial_on else 'OFF'}", | |
| f"SP:{'ON' if subpixel else 'OFF'} G:{gamma:.1f} B:{blur_ksize} | [M] Config", | |
| ]) | |
| return frame, new_vmax | |
| # ═══════════════════════════════════════════════════════ | |
| # 模式 2: 轨迹模式 | |
| # ═══════════════════════════════════════════════════════ | |
| _trace_canvas = None | |
| def render_trace( | |
| processed: np.ndarray, | |
| max_signal: float, | |
| config: dict, | |
| window_size: tuple, | |
| current_vmax: float, | |
| fps: float, | |
| noise_gate: int, | |
| dyn_ratio: float, | |
| spatial_on: bool, | |
| ) -> tuple: | |
| """轨迹模式:叠加历史数据。""" | |
| global _trace_canvas | |
| ww, wh = window_size | |
| rows = config.get("rows", list(range(16))) | |
| cols = config.get("cols", list(range(16))) | |
| order = config.get("axis_order", "row_col") | |
| # 增强参数 | |
| subpixel = config.get("subpixel", True) | |
| gamma = config.get("gamma", 1.0) | |
| blur_ksize = config.get("blur", 5) | |
| nr, nc = len(rows), len(cols) | |
| if nr == 0 or nc == 0: | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| _draw_hud(frame, ["Press hotkey to config"], (0, 0, 255)) | |
| return frame, current_vmax, False | |
| sub = processed[np.ix_(rows, cols)] | |
| if order != "row_col": | |
| sub = sub.T | |
| # ── Subpixel 超分 ────────────────────────────── | |
| if subpixel and sub.shape[0] >= 2 and sub.shape[1] >= 2: | |
| H0, W0 = sub.shape | |
| shrink = 1.5 | |
| padded = np.pad(sub, 1, mode='constant') | |
| top = padded[0:H0, 1:W0 + 1] | |
| bottom = padded[2:H0 + 2, 1:W0 + 1] | |
| left = padded[1:H0 + 1, 0:W0] | |
| right = padded[1:H0 + 1, 2:W0 + 2] | |
| sum_x = left + right + sub + 1e-6 | |
| sum_y = top + bottom + sub + 1e-6 | |
| dx = np.clip((right - left) / sum_x * shrink, -1, 1) | |
| dy = np.clip((bottom - top) / sum_y * shrink, -1, 1) | |
| super_res = np.zeros((H0 * 2, W0 * 2), dtype=np.float32) | |
| super_res[0::2, 0::2] = sub * np.maximum(0, 1.0 - dx - dy) | |
| super_res[0::2, 1::2] = sub * np.maximum(0, 1.0 + dx - dy) | |
| super_res[1::2, 0::2] = sub * np.maximum(0, 1.0 - dx + dy) | |
| super_res[1::2, 1::2] = sub * np.maximum(0, 1.0 + dx + dy) | |
| sub = super_res | |
| if _trace_canvas is None or _trace_canvas.shape != sub.shape: | |
| _trace_canvas = np.zeros_like(sub) | |
| _trace_canvas = np.maximum(_trace_canvas, sub) | |
| display = _trace_canvas | |
| H, W = display.shape | |
| scale_w = (ww - 200) / W | |
| scale_h = (wh - 120) / H | |
| scale = min(scale_w, scale_h) | |
| tw, th = int(W * scale), int(H * scale) | |
| resized = cv2.resize(display, (tw, th), interpolation=cv2.INTER_CUBIC) | |
| if blur_ksize > 0: | |
| k = blur_ksize if blur_ksize % 2 == 1 else blur_ksize + 1 | |
| resized = cv2.GaussianBlur(resized, (k, k), 0) | |
| vmax_cand = max(5000, np.percentile(resized, 99.7)) | |
| new_vmax = current_vmax * 0.95 + vmax_cand * 0.05 if vmax_cand > current_vmax else vmax_cand | |
| norm = np.clip(resized / new_vmax, 0, 1) if new_vmax > 0 else resized | |
| if gamma != 1.0: | |
| norm = np.power(norm, gamma) | |
| img_8u = (norm * 255).astype(np.uint8) | |
| color = cv2.applyColorMap(img_8u, cv2.COLORMAP_VIRIDIS) | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| sx, sy = (ww - tw) // 2, (wh - th) // 2 + 20 | |
| frame[sy:sy + th, sx:sx + tw] = color | |
| _draw_hud(frame, [ | |
| f"Trace | FPS:{fps:.1f} | Max:{int(max_signal)}", | |
| f"Gate:{noise_gate} | Dyn:{dyn_ratio:.2f} | Filter:{'ON' if spatial_on else 'OFF'}", | |
| f"SP:{'ON' if subpixel else 'OFF'} G:{gamma:.1f} B:{blur_ksize} | [X] Clear | [M] Config", | |
| ]) | |
| return frame, new_vmax, True | |
| def clear_trace_canvas(): | |
| global _trace_canvas | |
| _trace_canvas = None | |
| # ═══════════════════════════════════════════════════════ | |
| # 模式 3: 网格数据 | |
| # ═══════════════════════════════════════════════════════ | |
| def render_grid( | |
| processed: np.ndarray, | |
| max_signal: float, | |
| config: dict, | |
| window_size: tuple, | |
| fps: float, | |
| ) -> np.ndarray: | |
| """网格模式:每个单元格显示数值。""" | |
| ww, wh = window_size | |
| rows = config.get("rows", list(range(16))) | |
| cols = config.get("cols", list(range(16))) | |
| order = config.get("axis_order", "row_col") | |
| nr, nc = len(rows), len(cols) | |
| visual_rows = nr if order == "row_col" else nc | |
| visual_cols = nc if order == "row_col" else nr | |
| if nr == 0 or nc == 0: | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| _draw_hud(frame, ["Press hotkey to config"], (0, 0, 255)) | |
| return frame | |
| cell_size = min((ww - 100) / max(1, visual_cols), (wh - 120) / max(1, visual_rows)) | |
| cell_size = max(5.0, min(80.0, cell_size)) | |
| grid_w = int(visual_cols * cell_size) | |
| grid_h = int(visual_rows * cell_size) | |
| sx = (ww - grid_w) // 2 | |
| sy = (wh - grid_h) // 2 + 30 | |
| frame = np.zeros((wh, ww, 3), dtype=np.uint8) | |
| grid_vmax = max(5000.0, max_signal) | |
| for vi in range(visual_rows): | |
| for vj in range(visual_cols): | |
| if order == "row_col": | |
| r_idx, c_idx = rows[vi], cols[vj] | |
| else: | |
| r_idx, c_idx = rows[vj], cols[vi] | |
| if r_idx >= MATRIX_ROWS or c_idx >= MATRIX_COLS: | |
| continue | |
| val = int(processed[r_idx, c_idx]) | |
| x1 = int(sx + vj * cell_size) | |
| y1 = int(sy + vi * cell_size) | |
| x2 = int(sx + (vj + 1) * cell_size) | |
| y2 = int(sy + (vi + 1) * cell_size) | |
| if val > 0: | |
| blue = min(255, int((val / grid_vmax) * 255)) | |
| cv2.rectangle(frame, (x1, y1), (x2, y2), (blue, 0, 0), -1) | |
| cv2.rectangle(frame, (x1, y1), (x2, y2), (50, 50, 50), 1) | |
| if val > 0 and cell_size > 15: | |
| fs = max(0.3, cell_size / 60.0) | |
| text = str(val) | |
| (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_PLAIN, fs, 1) | |
| if tw > cell_size * 0.9: | |
| fs *= (cell_size * 0.9 / tw) | |
| (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_PLAIN, fs, 1) | |
| cv2.putText( | |
| frame, text, | |
| (x1 + (int(cell_size) - tw) // 2, y1 + (int(cell_size) + th) // 2), | |
| cv2.FONT_HERSHEY_PLAIN, fs, (255, 255, 255), 1, cv2.LINE_AA, | |
| ) | |
| _draw_hud(frame, [ | |
| f"Grid | FPS:{fps:.1f} | Max:{int(max_signal)}", | |
| "[M] Config Matrix", | |
| ]) | |
| return frame | |
| # ═══════════════════════════════════════════════════════ | |
| # 模式 4: 仿生手掌 | |
| # ═══════════════════════════════════════════════════════ | |
| # 手部锚点坐标(相对于窗口) | |
| HAND_ANCHORS = { | |
| "thumb_tip": (350, 280), "thumb_joint": (420, 360), "thumb_base": (480, 420), | |
| "index_tip": (510, 120), "index_joint": (540, 240), "index_base": (560, 340), | |
| "middle_tip": (640, 80), "middle_joint": (640, 200), "middle_base": (640, 320), | |
| "ring_tip": (770, 120), "ring_joint": (740, 240), "ring_base": (720, 340), | |
| "pinky_tip": (930, 280), "pinky_joint": (860, 360), "pinky_base": (800, 420), | |
| "palm": (640, 480), | |
| } | |
| WRIST_ANCHOR = (640, 600) | |
| # 默认手部配置(16×16 传感器映射到手指区域) | |
| # 右手实测(2026-09-04 录制逐格核对):行 = 手指,拇指 1-3 … 小指 | |
| # 13-15(行 0 为空),即旧配置 [0-2…12-14] 整体偏高一行的修正; | |
| # 列 12-15 = 指骨 [14,12,13,15] 根→尖;掌心行 1-15 x 传感列 [10,9,8,6,4]。 | |
| # | |
| # 左手(config/sensors/hand_ble_config_left.json)**不是**这份配置的左右镜像: | |
| # 左手帧 = 右手帧做 values[::-1, ::-1].T(转置 + 180°,固件层就这样接的线), | |
| # 所以左手的「沿手指方向」是它的**行**轴、「跨手指方向」是它的**列**轴—— | |
| # 五指横排在行 0-3(列 0-2 小指 … 12-14 拇指;行 3 = 指根、行 0 = 指尖), | |
| # 掌心行 4-12 x 列 0-14(列 15 与行 13-15 全死,共 61 格)。 | |
| # 左手里那份配置就是把这份**沿该变换原样拉回**:rows/cols 互换角色、每项取 | |
| # 15-x、axis_order 由 col_row 改 row_col。这样两只手渲染逐像素一致 | |
| # (tools/tests/test_glove_hand_mapping.py 锁住这条等价)。 | |
| # 只做行镜像(2026-09-21 之前的旧版)会让左手五指去读全死的行 4-15, | |
| # 画面里只有小指/掌心偶尔有反应——即用户报的「左手触觉点映射是错的」。 | |
| DEFAULT_HAND = { | |
| "thumb_joint": {"rows": [1, 2, 3], "cols": [14, 12, 13, 15], "axis_order": "col_row"}, | |
| "index_joint": {"rows": [4, 5, 6], "cols": [14, 12, 13, 15], "axis_order": "col_row"}, | |
| "middle_joint": {"rows": [7, 8, 9], "cols": [14, 12, 13, 15], "axis_order": "col_row"}, | |
| "ring_joint": {"rows": [10, 11, 12], "cols": [14, 12, 13, 15], "axis_order": "col_row"}, | |
| "pinky_joint": {"rows": [13, 14, 15], "cols": [14, 12, 13, 15], "axis_order": "col_row"}, | |
| "palm": {"rows": list(range(1, 16)), "cols": [10, 9, 8, 6, 4], "axis_order": "col_row"}, | |
| } | |
| for k in DEFAULT_HAND: | |
| if k != "palm": | |
| DEFAULT_HAND[k]["name"] = k | |
| def _scale_point(x: float, y: float, sx: float, sy: float) -> tuple: | |
| """按缩放因子缩放坐标点。""" | |
| return (int(x * sx), int(y * sy)) | |
| def render_hand( | |
| processed: np.ndarray, | |
| max_signal: float, | |
| config: dict, | |
| window_size: tuple, | |
| current_vmax: float, | |
| fps: float, | |
| noise_gate: int, | |
| dyn_ratio: float, | |
| spatial_on: bool, | |
| drift: float, | |
| ) -> tuple: | |
| """仿生手掌映射模式。 | |
| 硬编码锚点坐标基于 1280×720 画布设计,运行时根据实际 window_size | |
| 等比缩放,适配任意尺寸(如回放面板的 640×400)。 | |
| """ | |
| ww, wh = window_size | |
| # ── 计算缩放因子(锚点基于 1280×720 设计) ────────── | |
| NOM_W, NOM_H = 1280.0, 720.0 | |
| sx = ww / NOM_W | |
| sy = wh / NOM_H | |
| frame = np.full((wh, ww, 3), 15, dtype=np.uint8) | |
| lut = _get_viridis_lut() | |
| # ── 缩放后的锚点 ──────────────────────────────────── | |
| def _sp(x, y): | |
| return _scale_point(x, y, sx, sy) | |
| wrist = _sp(*WRIST_ANCHOR) | |
| scaled_anchors = {k: _sp(*v) for k, v in HAND_ANCHORS.items()} | |
| # ── 缩放线宽和圆半径 ────────────────────────────── | |
| lw_scale = min(sx, sy) | |
| _lw = lambda v: max(1, int(v * lw_scale)) | |
| _cr = lambda v: max(1, int(v * lw_scale)) | |
| # 画骨骼框架 | |
| line_c, joint_c = (60, 60, 60), (80, 80, 80) | |
| cv2.line(frame, wrist, scaled_anchors["palm"], line_c, _lw(6), cv2.LINE_AA) | |
| for fg in ["thumb", "index", "middle", "ring", "pinky"]: | |
| b = scaled_anchors[f"{fg}_base"] | |
| j = scaled_anchors[f"{fg}_joint"] | |
| t = scaled_anchors[f"{fg}_tip"] | |
| cv2.line(frame, scaled_anchors["palm"], b, line_c, _lw(5), cv2.LINE_AA) | |
| cv2.line(frame, b, j, line_c, _lw(4), cv2.LINE_AA) | |
| cv2.line(frame, j, t, line_c, _lw(3), cv2.LINE_AA) | |
| cv2.circle(frame, b, _cr(10), joint_c, -1, cv2.LINE_AA) | |
| cv2.circle(frame, j, _cr(8), joint_c, -1, cv2.LINE_AA) | |
| cv2.circle(frame, t, _cr(6), joint_c, -1, cv2.LINE_AA) | |
| cv2.circle(frame, scaled_anchors["palm"], _cr(16), joint_c, -1, cv2.LINE_AA) | |
| # 平滑 vmax | |
| vmax = max(max_signal, 5000) | |
| new_vmax = current_vmax * 0.95 + vmax * 0.05 if vmax < current_vmax else vmax | |
| CELL = max(1, int(14 * min(sx, sy))) | |
| for part_key, cfg in config.items(): | |
| if part_key not in scaled_anchors: | |
| continue | |
| ax, ay = scaled_anchors[part_key] | |
| rows = cfg.get("rows", []) | |
| cols = cfg.get("cols", []) | |
| order = cfg.get("axis_order", "row_col") | |
| if not rows or not cols: | |
| continue | |
| nrr, ncc = len(rows), len(cols) | |
| w = ncc * CELL if order == "row_col" else nrr * CELL | |
| h = nrr * CELL if order == "row_col" else ncc * CELL | |
| cx, cy = ax - w // 2, ay - h // 2 | |
| for i, r in enumerate(rows): | |
| for j, c in enumerate(cols): | |
| if r >= MATRIX_ROWS or c >= MATRIX_COLS: | |
| continue | |
| val = processed[r, c] | |
| x1 = cx + (j if order == "row_col" else i) * CELL | |
| y1 = cy + (i if order == "row_col" else j) * CELL | |
| x2, y2 = x1 + CELL, y1 + CELL | |
| if val > 0: | |
| idx = int(min(255, (val / new_vmax) * 255)) | |
| b, g, rr = lut[idx, 0] | |
| cv2.rectangle(frame, (x1, y1), (x2, y2), (int(b), int(g), int(rr)), -1) | |
| # 单元格内显示数据值 | |
| if CELL > 14: | |
| vtext = str(int(val)) | |
| fs = 0.4 | |
| (tw, th), _ = cv2.getTextSize(vtext, cv2.FONT_HERSHEY_PLAIN, fs, 1) | |
| tc = (0, 0, 0) if (0.299 * rr + 0.587 * g + 0.114 * b) > 140 else (255, 255, 255) | |
| cv2.putText(frame, vtext, | |
| (x1 + (CELL - tw) // 2, y1 + (CELL + th) // 2), | |
| cv2.FONT_HERSHEY_PLAIN, fs, tc, 1, cv2.LINE_AA) | |
| cv2.rectangle(frame, (x1, y1), (x2, y2), (100, 100, 100), 1) | |
| _draw_hud(frame, [ | |
| f"Bionic Hand | FPS:{fps:.1f} | Max:{int(max_signal)} | Drift:{int(drift)}", | |
| f"Gate:{noise_gate} | Dyn:{dyn_ratio:.2f} | Filter:{'ON' if spatial_on else 'OFF'}", | |
| "[M] Config | [C] Calibrate", | |
| ]) | |
| return frame, new_vmax | |
| # ═══════════════════════════════════════════════════════ | |
| # 模式 5: 拓扑形变 | |
| # ═══════════════════════════════════════════════════════ | |
| class DeformMeshState: | |
| """模式5 的交互状态。""" | |
| def __init__(self): | |
| self.holes = [] | |
| self.flip_x = False | |
| self.flip_y = False | |
| self.deform_strength = 1.0 | |
| self.cached_x = None | |
| self.cached_y = None | |
| self.cache_valid = False | |
| # 绘制中 | |
| self.drawing = False | |
| self.draw_start = (0, 0) | |
| self.draw_end = (0, 0) | |
| def _update_mesh_cache(state: DeformMeshState, rows, cols, order, ww, wh): | |
| """更新形变网格缓存。""" | |
| nr, nc = len(rows), len(cols) | |
| if nr == 0 or nc == 0: | |
| state.cached_x = state.cached_y = None | |
| return | |
| CELL = 26 | |
| tw = nc * CELL if order == "row_col" else nr * CELL | |
| th = nr * CELL if order == "row_col" else nc * CELL | |
| sx = (ww - tw) // 2 | |
| sy = (wh - th) // 2 + 30 | |
| jj, ii = np.meshgrid(np.arange(nc), np.arange(nr)) | |
| if order == "row_col": | |
| mx = sx + jj * CELL | |
| my = sy + ii * CELL | |
| else: | |
| mx = sx + ii * CELL | |
| my = sy + jj * CELL | |
| if state.flip_x: | |
| mx, my = np.fliplr(mx), np.fliplr(my) | |
| if state.flip_y: | |
| mx, my = np.flipud(mx), np.flipud(my) | |
| # 应用孔洞形变 | |
| holes = list(state.holes) | |
| if state.drawing: | |
| cx1, cy1 = state.draw_start | |
| cx2, cy2 = state.draw_end | |
| rx, ry = max(1, abs(cx2 - cx1)), max(1, abs(cy2 - cy1)) | |
| if rx > 5 and ry > 5: | |
| holes.append({"cx": cx1, "cy": cy1, "rx": rx, "ry": ry}) | |
| for h in holes: | |
| cx, cy, rx, ry = h["cx"], h["cy"], h["rx"], h["ry"] | |
| vx, vy = mx - cx, my - cy | |
| d = np.sqrt((vx / rx) ** 2 + (vy / ry) ** 2) + 1e-6 | |
| target_d = d + state.deform_strength * np.exp(-d * 1.5) | |
| scale = target_d / d | |
| mx = cx + vx * scale | |
| my = cy + vy * scale | |
| state.cached_x, state.cached_y = mx, my | |
| state.cache_valid = True | |
| def render_deform_mesh( | |
| processed: np.ndarray, | |
| max_signal: float, | |
| config: dict, | |
| window_size: tuple, | |
| current_vmax: float, | |
| fps: float, | |
| state: DeformMeshState, | |
| ) -> tuple: | |
| """拓扑形变模式。""" | |
| ww, wh = window_size | |
| frame = np.full((wh, ww, 3), 15, dtype=np.uint8) | |
| lut = _get_viridis_lut() | |
| vmax = max(5000, max_signal) | |
| new_vmax = current_vmax * 0.95 + vmax * 0.05 if vmax < current_vmax else vmax | |
| rows = config.get("rows", list(range(16))) | |
| cols = config.get("cols", list(range(16))) | |
| order = config.get("axis_order", "row_col") | |
| if not state.cache_valid: | |
| _update_mesh_cache(state, rows, cols, order, ww, wh) | |
| mx, my = state.cached_x, state.cached_y | |
| if mx is None: | |
| _draw_hud(frame, ["Press [M] to select Matrix Rows/Cols!"], (0, 0, 255)) | |
| return frame, new_vmax | |
| nr, nc = len(rows), len(cols) | |
| # 画网格线 | |
| pts = np.stack([mx, my], axis=-1).astype(np.int32) | |
| cv2.polylines(frame, pts, False, (60, 60, 60), 1, cv2.LINE_AA) | |
| pts_t = np.ascontiguousarray(np.transpose(pts, (1, 0, 2))) | |
| cv2.polylines(frame, pts_t, False, (60, 60, 60), 1, cv2.LINE_AA) | |
| # 画孔洞 | |
| holes = list(state.holes) | |
| if state.drawing: | |
| cx1, cy1 = state.draw_start | |
| cx2, cy2 = state.draw_end | |
| rx, ry = max(1, abs(cx2 - cx1)), max(1, abs(cy2 - cy1)) | |
| if rx > 5 and ry > 5: | |
| holes.append({"cx": cx1, "cy": cy1, "rx": rx, "ry": ry}) | |
| for h in holes: | |
| cv2.ellipse(frame, (h["cx"], h["cy"]), (h["rx"], h["ry"]), | |
| 0, 0, 360, (90, 90, 90), 2, cv2.LINE_AA) | |
| # 画数据点 | |
| for i in range(nr): | |
| for j in range(nc): | |
| ri, cj = rows[i], cols[j] | |
| if ri >= MATRIX_ROWS or cj >= MATRIX_COLS: | |
| continue | |
| val = processed[ri, cj] | |
| px, py = int(mx[i, j]), int(my[i, j]) | |
| if val > 0: | |
| idx = int(min(255, (val / new_vmax) * 255)) | |
| b, g, rr = lut[idx, 0] | |
| cv2.circle(frame, (px, py), 10, (int(b), int(g), int(rr)), -1, cv2.LINE_AA) | |
| else: | |
| cv2.circle(frame, (px, py), 3, (80, 80, 80), -1, cv2.LINE_AA) | |
| s = state.deform_strength | |
| _draw_hud(frame, [ | |
| f"Deform Mesh | FPS:{fps:.1f} | Max:{int(max_signal)} | Strength:{s:.1f}", | |
| f"Flip X:{state.flip_x} | Flip Y:{state.flip_y}", | |
| "[Drag] Draw Hole | [R] Clear | [U/I] Flip | [O/P] Strength | [M] Config", | |
| ]) | |
| return frame, new_vmax | |
| # ═══════════════════════════════════════════════════════ | |
| # 手部骨架渲染(MANO 21 关键点 → 3/4 视角透视画面) | |
| # | |
| # 移植自手套工具包 apps/rendering/replay.py 的 Camera/_grid/ | |
| # _draw_hand(画布尺寸参数化)。tools/demos/pooled_viewer_demo | |
| # 另有同口径自包含副本(demo 不导入 core,两边保持同步即可)。 | |
| # ═══════════════════════════════════════════════════════ | |
| # MANO 21 关键点骨骼连接 + 分组: 0 拇指 1 食指 2 中指 3 无名指 4 小指 5 掌骨 | |
| SKELETON_BONES = [ | |
| (0, 1, 0), (1, 2, 0), (2, 3, 0), (3, 4, 0), # thumb | |
| (0, 5, 1), (5, 6, 1), (6, 7, 1), (7, 8, 1), # index | |
| (0, 9, 2), (9, 10, 2), (10, 11, 2), (11, 12, 2), # middle | |
| (0, 13, 3), (13, 14, 3), (14, 15, 3), (15, 16, 3), # ring | |
| (0, 17, 4), (17, 18, 4), (18, 19, 4), (19, 20, 4), # pinky | |
| (5, 9, 5), (9, 13, 5), (13, 17, 5), # metacarpal | |
| ] | |
| # 每根手指分组颜色(BGR) | |
| SKELETON_FINGER_BGR = [ | |
| (60, 80, 255), # 0 拇指 红 | |
| (60, 255, 255), # 1 食指 黄 | |
| (60, 220, 60), # 2 中指 绿 | |
| (255, 210, 60), # 3 无名指 青 | |
| (255, 120, 255), # 4 小指 紫 | |
| (210, 210, 210), # 5 掌骨 白 | |
| ] | |
| _SKELETON_FOV_DEG = 50.0 | |
| class SkeletonCamera: | |
| """球坐标相机 + 透视投影(照工具包 replay.py 的 Camera,画布尺寸参数化)。""" | |
| def __init__(self, yaw_deg, elev_deg, dist, img_w, img_h, roll_deg=0.0): | |
| yaw, elev = np.deg2rad(yaw_deg), np.deg2rad(elev_deg) | |
| roll = np.deg2rad(roll_deg) | |
| cp = np.array([dist * np.cos(elev) * np.sin(yaw), | |
| dist * np.sin(elev), | |
| dist * np.cos(elev) * np.cos(yaw)]) | |
| self.pos = cp | |
| self.fwd = -cp / np.linalg.norm(cp) | |
| self.right = np.cross(self.fwd, [0, 1, 0]) | |
| self.right /= np.linalg.norm(self.right) | |
| self.up = np.cross(self.right, self.fwd) | |
| cos_r, sin_r = np.cos(roll), np.sin(roll) | |
| r0, u0 = self.right, self.up | |
| self.right = r0 * cos_r + u0 * sin_r | |
| self.up = -r0 * sin_r + u0 * cos_r | |
| self.w, self.h = img_w, img_h | |
| self.f = (self.h / 2) / np.tan(np.deg2rad(_SKELETON_FOV_DEG) / 2) | |
| def project(self, pts3d): | |
| """(N,3) → (N,2) 图像坐标 + (N,) 深度(相机前方为正)。""" | |
| v = pts3d - self.pos | |
| z = v @ self.fwd | |
| x = v @ self.right | |
| y = v @ self.up | |
| u = self.f * x / z + self.w / 2 | |
| vv = self.h / 2 - self.f * y / z | |
| return np.stack([u, vv], -1), z | |
| def fit_skeleton_dist(kpts): | |
| """按手部空间尺度定相机距离(照工具包 replay.py)。""" | |
| v = kpts[np.isfinite(kpts).all(axis=-1)] | |
| if len(v) == 0: | |
| return 0.5 | |
| r = float(np.abs(v).max()) | |
| return max(0.25, r * 2.6 + 0.1) | |
| def _skeleton_bg(w, h): | |
| img = np.full((h, w, 3), 12, np.uint8) | |
| for y in range(h): | |
| img[y] = np.full(3, 10 + int(10 * y / h), np.uint8) | |
| return img | |
| def _skeleton_grid(img, cam, r): | |
| """z=0 平面网格 + 三色坐标轴(照工具包 replay.py 的 _grid)。""" | |
| x = [(i / 6) * r for i in range(-6, 7)] | |
| lines = ([([v, -r, 0.0], [v, r, 0.0]) for v in x] | |
| + [([-r, v, 0.0], [r, v, 0.0]) for v in x]) | |
| for p0, p1 in lines: | |
| pts, z = cam.project(np.array([p0, p1], np.float32)) | |
| if (z > 0).all(): | |
| cv2.line(img, tuple(pts[0].astype(int)), tuple(pts[1].astype(int)), | |
| (40, 44, 52), 1, cv2.LINE_AA) | |
| origin = np.zeros((3,), np.float32) | |
| for axis, col in [(np.array([r, 0.0, 0.0]), (40, 60, 255)), # X 红 | |
| (np.array([0.0, r, 0.0]), (40, 255, 60)), # Y 绿 | |
| (np.array([0.0, 0.0, r]), (255, 60, 40))]: # Z 蓝 | |
| pts, z = cam.project(np.stack([origin, axis])) | |
| if (z > 0).all(): | |
| cv2.line(img, tuple(pts[0].astype(int)), tuple(pts[1].astype(int)), | |
| col, 2, cv2.LINE_AA) | |
| def _draw_skeleton_hand(img, cam, kpts): | |
| """深度着色的骨骼连线 + 白色关节点(照工具包 replay.py 的 _draw_hand)。""" | |
| pts, z = cam.project(kpts) | |
| vis = np.isfinite(kpts).all(axis=1) & (z > 0) | |
| # 近亮远暗的深度着色;手指比掌骨略亮 | |
| zmin, zmax = 0.05, 0.6 | |
| lum = np.clip((zmax - z) / (zmax - zmin), 0.35, 1.0) | |
| for a, b, g in SKELETON_BONES: | |
| if vis[a] and vis[b]: | |
| k = 0.5 * (lum[a] + lum[b]) | |
| col = tuple(int(c * k) for c in SKELETON_FINGER_BGR[g]) | |
| cv2.line(img, tuple(pts[a].astype(int)), tuple(pts[b].astype(int)), | |
| col, 3, cv2.LINE_AA) | |
| for p, ok in zip(pts, vis): | |
| if ok: | |
| cv2.circle(img, tuple(p.astype(int)), 2, (255, 255, 255), -1, | |
| cv2.LINE_AA) | |
| def render_skeleton(kpts, w=640, h=420, dist=None, label=""): | |
| """单手骨架面板(固定 3/4 视角;dist 传入为帧间防抖后的距离)。 | |
| Args: | |
| kpts: (21,3) float 关键点(米) | |
| dist: 相机距离(None → 按当前帧 fit_skeleton_dist) | |
| Returns: | |
| BGR 画布 | |
| """ | |
| if dist is None: | |
| dist = fit_skeleton_dist(kpts) | |
| img = _skeleton_bg(w, h) | |
| cam = SkeletonCamera(yaw_deg=-25.0, elev_deg=15.0, dist=dist, | |
| img_w=w, img_h=h) | |
| _skeleton_grid(img, cam, fit_skeleton_dist(kpts) * 0.5) | |
| _draw_skeleton_hand(img, cam, kpts) | |
| if label: | |
| cv2.putText(img, label, (12, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, | |
| (240, 240, 240), 2, cv2.LINE_AA) | |
| return img | |
| # ══════════════════════════════════════════════════════════════════ | |
| # 触觉分区网格(录制页 / 回放页默认触觉画面) | |
| # ══════════════════════════════════════════════════════════════════ | |
| # 移植自厂商 Glove-test V1.4 工具 glove_qt_visualizer.py 的 | |
| # PressureMatrixCanvas(QPainter → OpenCV,QColor → BGR,中文字串 → | |
| # 英文,布局/配色照搬): | |
| # render_tactile_grid —— 16x16 分区网格(手指区 y=12…15 / 手掌区 y=3…11 / | |
| # 空值区 y≤2;行列号 + 逐格数值 + 图例。两只手同一 | |
| # 段:左手原始帧先归到规范系,面板内不翻转) | |
| # tactile_canvas_for —— 显示区 → 该渲染多大的画布(主程序专用:回放页的 | |
| # 传感器格比画布小得多,按显示区反推尺寸再交给显示层 | |
| # 缩放,数值才不会被裁;demo 有自己的窗口尺寸,不用它) | |
| # (2026-09-04 起按用户要求移除手形热图 PressureHandCanvas 移植,只留矩阵) | |
| # 触觉矩阵映射(2026-09-21 定案,与厂商 SDK 的规范系逐项对齐): | |
| # **规范系 (canonical)** —— 行 = 跨手指方向、列 = 沿手指/掌长方向: | |
| # 行 1-3 拇指 / 4-6 食指 / 7-9 中指 / 10-12 无名 / 13-15 小指, | |
| # 行递增 = 拇指侧 → 小指侧(同一根手指的 3 行也按这个方向排); | |
| # 列 12-15 = 手指,12 → 15 = 指根 → 指尖(列 12 约在远端指节); | |
| # 列 3-11 = 掌心,3 → 11 = 掌根 → 指根; | |
| # 行 0 与列 0-2 为空。有效格 195 = 掌 15×9 + 五指 5×(3×4)。 | |
| # **右手帧就是规范系**;左手帧是规范系的 `[::-1, ::-1].T`(转置 + 180°), | |
| # 由 `canonical_pressure_matrix()` 反变换回来 —— 所以本节的每一处 | |
| # (坐标表、图例、分区框、空值/Spare 判据)**两只手走同一段代码**, | |
| # 没有任何按手分支。这是 2026-09-21 之前那版的核心错误:它假设左手是 | |
| # 右手的「整块行镜像」(拇指 15-13),并给左手加了一次列翻转去凑 —— | |
| # 而真实数据是转置关系,于是左手画面整体转了 90°(手指横躺在画面上半 | |
| # 的 4 行里),分区框/图例则落进空值区,而日志、录制、帧率一切正常。 | |
| # 面板内拇指统一朝左:网格 x 轴 = 规范行,两只手都不翻(厂商 SDK 的 | |
| # gui/rendering/tactile.py 把这条变换预先烘进左手表,同样不翻)。 | |
| # | |
| # ⚠️ 本节的实现与 tools/demos/pooled_viewer_demo/pooled_viewer_demo.py | |
| # 里的同名副本**逐像素一致**(该 demo 单文件自包含、不 import 主程序, | |
| # 与 render_skeleton 同属"demo 保副本、主程序放实现"的既定模式); | |
| # 改这里必须同步改那份,tools/tests/test_tactile_grid_render.py 会逐像素 | |
| # 比对两份实现在同一输入下的输出。 | |
| _TACTILE_FINGER_NAMES = ("Thumb", "Index", "Middle", "Ring", "Pinky") | |
| # 手指区分区框颜色(厂商 _FINGER_COLORS,BGR) | |
| _TACTILE_FINGER_BGR = ((68, 68, 239), (11, 158, 245), (94, 197, 34), | |
| (246, 130, 59), (247, 85, 168)) | |
| # 压力色带(厂商 PRESSURE_COLOR_BANDS_CORRECTED / _RAW,BGR) | |
| _TACTILE_BANDS_CORRECTED = ((333, (95, 58, 30)), (666, (199, 134, 22)), | |
| (999, (94, 197, 34)), (1333, (21, 204, 250)), | |
| (1666, (22, 115, 249)), (float("inf"), (68, 68, 239))) | |
| _TACTILE_BANDS_RAW = ((2000, (95, 58, 30)), (2400, (199, 134, 22)), | |
| (2800, (94, 197, 34)), (3200, (21, 204, 250)), | |
| (3600, (22, 115, 249)), (float("inf"), (68, 68, 239))) | |
| _TACTILE_BG = (25, 16, 8) # #081019 | |
| _TACTILE_CELL_IDLE = (49, 33, 18) # #122131(无数据格底色) | |
| _TACTILE_EMPTY_OVERLAY = (48, 38, 30) # (30,38,48) 斜线覆盖 | |
| _TACTILE_GRID_BORDER = (239, 225, 210) # (210,225,239,105) 简化实线 | |
| _TACTILE_TEXT = (245, 232, 220) # #dce8f5 | |
| _TACTILE_TEXT_DIM = (210, 189, 169) # #a9bdd2 | |
| _TACTILE_TEXT_FAINT = (185, 148, 148) # #94a3b8 | |
| _TACTILE_EMPTY_LINE = (184, 163, 148) # #94a3b8 | |
| _TACTILE_SPARE_LINE = (219, 213, 209) # #d1d5db | |
| _TACTILE_PALM_BLUE = (248, 189, 56) # #38bdf8 | |
| _TACTILE_WHITE = (252, 250, 248) # #f8fafc | |
| def glove_side_of(sensor: str) -> str: | |
| """传感器列名 → 左右手(默认右手)。""" | |
| low = (sensor or "").lower() | |
| return "left" if "left" in low else "right" | |
| def canonical_pressure_matrix(matrix, side: str): | |
| """原始触觉帧 → **规范系**(16×16 float32);按 `side` 决定是否反变换。 | |
| 契约出处:厂商 SDK `tools/glove_sdk/gui/rendering/tactile_pressure_hand.py` | |
| 的 `canonical_pressure_matrix()` —— 逐字一致的一份移植: | |
| values = np.asarray(matrix, np.float32).reshape(16, 16) | |
| if str(side).lower() == "right": | |
| return values | |
| return np.ascontiguousarray(values[::-1, ::-1].T) | |
| 规范系的坐标含义见本节顶部注释。要点:**右手帧本来就是规范系**, | |
| 左手帧是规范系的转置 + 180°(固件把左手接成了右手帧的 | |
| `values[::-1, ::-1].T`),所以只有左手要反变换。 | |
| ⚠️ **刻意偏离交付码一处**:判据用「名字里有没有 left」(与 | |
| `glove_side_of` 同口径),而不是 SDK 的 `== "right"`。SDK 那个写法在 | |
| 传进完整传感器名(`"right_glove"`)时会**不等于** `"right"`,于是给 | |
| 右手套套上左手变换 —— 是静默错半边。我们这边的 `side` 目前恒为 | |
| `glove_side_of()` 的返回值,但这条判据不该依赖调用方的纪律。 | |
| 反变换是**自逆**的(`[::-1, ::-1].T` 连做两次等于原样),所以拿不准 | |
| 帧的来历时不要用它"试一下":两边都会得到一个像模像样的矩阵。 | |
| """ | |
| values = np.asarray(matrix, dtype=np.float32).reshape(16, 16) | |
| if "left" not in str(side).lower(): | |
| return values | |
| return np.ascontiguousarray(values[::-1, ::-1].T) | |
| def _tactile_heat_color(value, use_baseline): | |
| bands = _TACTILE_BANDS_CORRECTED if use_baseline else _TACTILE_BANDS_RAW | |
| for upper, bgr in bands: | |
| if value <= upper: | |
| return bgr | |
| return bands[-1][1] | |
| _TACTILE_TEXT_DARK = (7, 16, 25) | |
| _TACTILE_TEXT_LIGHT = (255, 246, 238) | |
| _TACTILE_MARGIN = 54.0 | |
| _TACTILE_BOTTOM_MARGIN = 22.0 | |
| _TACTILE_LEGEND_W = 250.0 | |
| def _tactile_lut(use_baseline): | |
| """值 → BGR 查表(_tactile_heat_color 的等价向量化)。 | |
| 上界取色带里最后一个有限上界;再补一格给"超出上界"(原实现落 | |
| bands[-1],与 ≤ 上界的那一格未必同色,所以要多留一格再 clip)。 | |
| """ | |
| bands = _TACTILE_BANDS_CORRECTED if use_baseline else _TACTILE_BANDS_RAW | |
| top = int(bands[-2][0]) | |
| lut = np.empty((top + 2, 3), np.uint8) | |
| for i in range(top + 2): | |
| lut[i] = _tactile_heat_color(i, use_baseline) | |
| return lut | |
| _TACTILE_LUTS = {True: _tactile_lut(True), False: _tactile_lut(False)} | |
| # 面板画布缓存:key = (w, h, side),最多留 _PANEL_CACHE_MAX 块 | |
| _PANEL_CACHE_MAX = 6 | |
| _TACTILE_PANELS = OrderedDict() | |
| class _TactilePanel: | |
| """一块 (w, h, side) 触觉面板的可复用画布 + 逐格脏标记。 | |
| 旧实现每帧把整块面板重画一遍:16x16 格 ×(填充 + 斜线/边框 + 数值 | |
| putText)连同坐标标签、分区框、图例,单面板上千次 OpenCV 调用 —— | |
| 实测 1.4~2.5 ms/面板、双手 3~5 ms(Windows 打包机上更高),30fps 下 | |
| 光触觉就吃掉一成以上预算,而且**每一帧画的像素几乎和上一帧一样**。 | |
| 现在:底色/坐标/图例这些静态部分画一次存在 base 里;每帧只重画 | |
| **值或配色变了的格**(先擦回 base 再画,避免新旧数字叠字),实测基线 | |
| 校正后每帧只有 ~28/256 格变化。分区框/掌心框那 8 笔照旧每帧补画 —— | |
| 纯色无抗锯齿,重画幂等,但会被上面"擦回 base"抹掉,必须补。 | |
| """ | |
| def __init__(self, w, h, side): | |
| self.w, self.h, self.side = int(w), int(h), side | |
| self.cell = max(10.0, min( | |
| (w - _TACTILE_MARGIN - _TACTILE_LEGEND_W) / 16.0, | |
| (h - _TACTILE_MARGIN - _TACTILE_BOTTOM_MARGIN) / 16.0)) | |
| self.grid_left = self.grid_top = _TACTILE_MARGIN | |
| self.base = self._draw_chrome() | |
| self.canvas = self.base.copy() | |
| self.vals = None # 上次画上去的值(None = 全脏) | |
| self.lut_key = None # 上次用的配色档(值相同但档不同也要重画) | |
| # ── 静态底图:底色 + 坐标 + 图例(与格区不重叠)── | |
| def _draw_chrome(self): | |
| w, h = self.w, self.h | |
| cell, grid_left, grid_top = self.cell, self.grid_left, self.grid_top | |
| img = np.full((h, w, 3), _TACTILE_BG, np.uint8) | |
| cv2.putText(img, "X -> 0..15", | |
| (int(grid_left) + 16 * int(cell) // 2 - 70, 16), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.55, _TACTILE_TEXT_DIM, | |
| 1, cv2.LINE_AA) | |
| for display_x in range(16): | |
| cv2.putText(img, str(display_x), | |
| (int(grid_left + display_x * cell) + int(cell) // 2 - 5, | |
| int(grid_top) - 26), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT_DIM, | |
| 1, cv2.LINE_AA) | |
| for display_row in range(16): | |
| cv2.putText(img, f"y={15 - display_row}", | |
| (6, int(grid_top + display_row * cell + cell / 2 + 4)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT_DIM, | |
| 1, cv2.LINE_AA) | |
| legend_left = int(grid_left + 16 * cell + 18.0) | |
| if legend_left < w - 10: | |
| cv2.putText(img, "Fingers y=12..15", | |
| (legend_left, int(grid_top) + 14), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.45, _TACTILE_TEXT, | |
| 1, cv2.LINE_AA) | |
| for group in range(5): | |
| top = int(grid_top + 28.0 + group * 24.0) | |
| cv2.rectangle(img, (legend_left, top), | |
| (legend_left + 15, top + 15), | |
| _TACTILE_FINGER_BGR[group], -1) | |
| row_lo = 1 + 3 * group | |
| cv2.putText(img, | |
| f"{_TACTILE_FINGER_NAMES[group]}: " | |
| f"x={row_lo}..{row_lo+2}", | |
| (legend_left + 23, top + 13), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT, | |
| 1, cv2.LINE_AA) | |
| cv2.putText(img, "Spare col: x=0, y=3..15", | |
| (legend_left, int(grid_top + 166.0)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT_FAINT, | |
| 1, cv2.LINE_AA) | |
| cv2.putText(img, "Palm x=1..15, y=3..11", | |
| (legend_left, int(grid_top + 208.0)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_PALM_BLUE, | |
| 1, cv2.LINE_AA) | |
| cv2.putText(img, "Empty zone: y=0..2", | |
| (legend_left, int(grid_top + 232.0)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT, | |
| 1, cv2.LINE_AA) | |
| cv2.putText(img, "Top of view = y=15..0", | |
| (legend_left, int(grid_top + 258.0)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT_FAINT, | |
| 1, cv2.LINE_AA) | |
| cv2.putText(img, "fingers point upward", | |
| (legend_left, int(grid_top + 280.0)), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, _TACTILE_TEXT_FAINT, | |
| 1, cv2.LINE_AA) | |
| return img | |
| # ── 每帧补画的分区框/掌心框(压在格子上,会被"擦回 base"抹掉)── | |
| def _draw_overlay(self, img): | |
| cell, grid_left, grid_top = self.cell, self.grid_left, self.grid_top | |
| finger_height = 4.0 * cell | |
| for group in range(5): | |
| # 框要扣在该手指的行落点上:规范系里第 g 组恒为 1+3g(拇指 1-3 | |
| # → 小指 13-15),而显示列 = 规范行,所以框恒落在显示列 | |
| # 1+3g..3+3g,拇指向来在画面左侧。与下面 Spare 框(行 0)、 | |
| # 掌心框(行 1..15)同一口径 —— 三者现在无一处按手分支。 | |
| display_start_x = 1 + group * 3 | |
| x0 = int(grid_left + display_start_x * cell) | |
| y0 = int(grid_top) | |
| cv2.rectangle(img, (x0 + 2, y0 + 2), | |
| (x0 + int(3 * cell) - 2, | |
| y0 + int(finger_height) - 2), | |
| _TACTILE_FINGER_BGR[group], 2) | |
| spare_x = 0 | |
| x0 = int(grid_left + spare_x * cell) | |
| cv2.rectangle(img, (x0 + 1, int(grid_top) + 1), | |
| (x0 + int(cell) - 1, int(grid_top + finger_height) - 1), | |
| _TACTILE_SPARE_LINE, 1) | |
| x0 = int(grid_left + cell) | |
| y0 = int(grid_top + 4.0 * cell) | |
| cv2.rectangle(img, (x0 + 2, y0 + 2), | |
| (x0 + int(15 * cell) - 2, y0 + int(9 * cell) - 2), | |
| _TACTILE_PALM_BLUE, 2) | |
| cv2.line(img, (x0, y0), (x0 + int(15 * cell), y0), _TACTILE_WHITE, 2) | |
| # ── 逐格脏重画 ── | |
| def paint(self, matrix, baseline, use_baseline): | |
| m = np.asarray(matrix, np.float32).reshape(16, 16) | |
| # 值:原实现是 max(0, m - base) 再 round —— 配色档位用的是入参 | |
| # use_baseline(不是"baseline is not None"),这里照抄。 | |
| # 基线在**原始帧**里扣:基线(回放页的逐格中位)本来就是按原始列算 | |
| # 的,在原始帧里扣就不必给基线也加一道变换。 | |
| if use_baseline and baseline is not None: | |
| m = np.maximum( | |
| 0.0, m - np.asarray(baseline, np.float32).reshape(16, 16)) | |
| # 归到规范系(左手帧是规范系的 [::-1,::-1].T)—— 纯下标置换,与上面 | |
| # 的减法、与下面的 rint 都可交换,放哪一步都逐位等价;放最前面, | |
| # 底下就全在规范系里算了。 | |
| # ⚠️ 必须在**取整前**过这一步:canonical_pressure_matrix 收 float32, | |
| # 喂 int32 会被静默升位,回头 `lut[view]` 当场 IndexError。 | |
| vals = np.rint(canonical_pressure_matrix(m, self.side)).astype(np.int32) | |
| # 规范 (行 x, 列 y) → 显示 (行 = 15-y 在顶, 列 = x)。**两只手同一段**。 | |
| view = np.ascontiguousarray(vals.T[::-1]) | |
| # 颜色 = LUT[值],所以"值没变"只在**同一档 LUT**下才等价于"没变": | |
| # 切基线开关时,基线为 0 的格值不变、颜色却要换一档,必须整块重画。 | |
| lut_key = bool(use_baseline) | |
| if (self.vals is not None and self.lut_key == lut_key | |
| and np.array_equal(view, self.vals)): | |
| return self.canvas # 一格没变 → 整块复用(含分区框) | |
| img = self.canvas | |
| cell, grid_left, grid_top = self.cell, self.grid_left, self.grid_top | |
| lut = _TACTILE_LUTS[lut_key] | |
| colors = lut[np.clip(view, 0, len(lut) - 1)] | |
| # 亮度判字色:原式 0.299*R + 0.587*G + 0.114*B(BGR 存的是 R 在 [2]) | |
| lum = (0.299 * colors[..., 2].astype(np.float32) | |
| + 0.587 * colors[..., 1] + 0.114 * colors[..., 0]) | |
| dark = lum > 145 | |
| draw_text = cell >= 11 | |
| stale = self.vals if self.lut_key == lut_key else None # 换档 ⇒ 全脏 | |
| for display_row in range(16): | |
| y = 15 - display_row | |
| row_top = grid_top + display_row * cell | |
| y0, y1 = int(row_top), int(row_top + cell) | |
| is_empty = y <= 2 | |
| for display_x in range(16): | |
| if stale is not None and stale[display_row, display_x] == \ | |
| view[display_row, display_x]: | |
| continue | |
| x = display_x | |
| x0 = int(grid_left + display_x * cell) | |
| x1 = int(grid_left + (display_x + 1) * cell) | |
| fill = (int(colors[display_row, display_x, 0]), | |
| int(colors[display_row, display_x, 1]), | |
| int(colors[display_row, display_x, 2])) | |
| img[y0:y1, x0:x1] = self.base[y0:y1, x0:x1] # 擦掉旧内容 | |
| cv2.rectangle(img, (x0, y0), (x1, y1), fill, -1) | |
| if is_empty or (y >= 3 and x == 0): | |
| cv2.rectangle(img, (x0, y0), (x1, y1), | |
| _TACTILE_EMPTY_OVERLAY, -1) | |
| cv2.line(img, (x0, y0), (x1, y1), _TACTILE_EMPTY_LINE, 1) | |
| cv2.line(img, (x1, y0), (x0, y1), _TACTILE_EMPTY_LINE, 1) | |
| cv2.rectangle(img, (x0, y0), (x1, y1), | |
| _TACTILE_GRID_BORDER, 1) | |
| if draw_text: | |
| # 数字比格子宽(cell 15.5px 时 "500" 有 16px),会溢到右 | |
| # 邻格上。旧实现每帧把右邻格也重画一遍,溢出被邻格的填充+ | |
| # 边框盖掉;只重画脏格就盖不住了 —— 所以按格裁掉溢出, | |
| # 与旧版落到的像素完全一致。 | |
| cv2.putText(img[y0:y1, x0:x1], | |
| str(int(view[display_row, display_x])), | |
| (2, int(cell) - 4), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.28, | |
| _TACTILE_TEXT_DARK if dark[display_row, display_x] | |
| else _TACTILE_TEXT_LIGHT, 1, cv2.LINE_AA) | |
| self.vals = view | |
| self.lut_key = lut_key | |
| self._draw_overlay(img) | |
| return img | |
| def render_tactile_grid(matrix, side="right", baseline=None, | |
| use_baseline=True, w=780, h=560): | |
| """16x16 压力矩阵 → 分区确认网格(厂商 PressureMatrixCanvas 移植)。 | |
| 入参是**原始帧**(左手那位先由 canonical_pressure_matrix 反变换到规范 | |
| 系)。网格行 = 规范列(y=15 在顶 = 指尖),网格列 = 规范行(拇指恒朝 | |
| 左,两只手都不翻);baseline 为 16x16 中位基线(可 None,原始帧口径)。 | |
| 同一 (w, h, side) 复用同一块画布,只重画变了的格 —— 返回值是该画布 | |
| 本身,**下一次同尺寸调用会原地改写它**,需要的调用方自己拷贝。 | |
| """ | |
| key = (int(w), int(h), side) | |
| panel = _TACTILE_PANELS.get(key) | |
| if panel is None: | |
| panel = _TactilePanel(w, h, side) | |
| _TACTILE_PANELS[key] = panel | |
| while len(_TACTILE_PANELS) > _PANEL_CACHE_MAX: | |
| _TACTILE_PANELS.popitem(last=False) # 拖窗口边缘会连续出新尺寸 | |
| else: | |
| _TACTILE_PANELS.move_to_end(key) | |
| return panel.paint(matrix, baseline, use_baseline) | |
| def tactile_canvas_for(w, h, min_cell=28.0): | |
| """显示区 (w, h) → 该渲染多大的画布(同宽高比,格子不小于 min_cell)。 | |
| 数值文字是**固定 0.28 字号**(四位 "2015" 宽 21px),格子小于 ~26px 时 | |
| 数字铺满整格被裁掉、看着糊成一团 —— 而显示区常常比画布小得多(回放页 | |
| 宽扁的传感器格按高度等比缩到一半),所以按显示区反推一块"够大"的画布 | |
| 交给显示层缩放:既不裁数字,也不白填宽出来的黑边。 | |
| 返回 (w', h'),宽高比与入参一致(束缚维度不会因为放大而换人);显示区 | |
| 太小(扣掉边距后格区非正)或异常值时回退 demo 默认画布 780x560。 | |
| """ | |
| w, h = int(w), int(h) | |
| cell = min((w - _TACTILE_MARGIN - _TACTILE_LEGEND_W) / 16.0, | |
| (h - _TACTILE_MARGIN - _TACTILE_BOTTOM_MARGIN) / 16.0) | |
| if w < 1 or h < 1 or cell <= 0: | |
| return 780, 560 | |
| k = max(1.0, float(min_cell) / cell) | |
| return max(1, int(round(w * k))), max(1, int(round(h * k))) | |