DataFlowing / collector /core /render_engine.py
stouchinglhh
Claude Code
collector: 更新采集工程至 v1.3.11(手套链路整体换厂商 SDK v2.1.0,全栈因此锁 Python 3.10)
0901daa
Raw History Blame Contribute Delete
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)))