〇、一只手怎么"记住"一个姿势?
上一篇结尾留了个场景:我把软件从头到尾走了一遍——打开工程、点 T_MCP、改限位、拖 q 看拇指弯、复制右上角姿势串、切模拟模式粘贴播放。最后那两步"复制粘贴播放",就是本篇的主角。
在写姿势串之前,我的手其实没有"记忆"。你把拇指摆到一个很好看的对掌位置,关掉软件,它就忘了。想让它回来,只能凭手感把 18 个滑条再拖一遍。18 个滑条,凭手感,拖一遍,这三个词连在一起,你就知道这事不可能 Second。
所以第 5 篇要解决的问题只有一个:怎么让一只手,把一个姿势变成一行能复制、能粘贴、能存档、能播放的文字。
听起来是个小需求。做下去才发现,它牵出了四件事:
-
一套文本协议(18 个槽位怎么排、分号逗号怎么分);
-
一对序列化/反序列化函数(
buildPoseString/parsePoseString); -
一个流程编辑器(姿势卡片、动态增删、过渡时间);
-
一个插值播放器(
QTimer33ms,一帧一帧把手"喂"到目标姿势)。
以及一个计划外的插曲——为了给手势配数据,我被自己的拇指关节反复上课七次,那部分放在第八节好好忏悔。
老规矩,先做协议 → 再做序列化 → 再做界面 → 再做播放 → 最后算总账。
一、为什么不是 JSON?
动手前先做取舍。姿势数据要"存",第一反应当然是 JSON:18 个键值对,nlohmann/json 我都 vendored 好了,十分钟写完。
但我拒绝了,理由不是洁癖,是使用场景。
这个姿势串的使用方式是:在两个模式之间,用剪贴板搬运。 我在关节角区看到一串字符,Ctrl+C;切到模拟模式,Ctrl+V 进卡片;再复制下一张卡,粘进聊天软件发给同学"你把这个贴进去看看"。它要在 QQ 窗口里、在微信里、在记事本里、甚至手抄在纸上都能活下来。
JSON 做得到吗?做得到。但:
| 维度 | JSON | 自定义姿势串 | | --- | --- | --- | | 人眼可读 | 引号、逗号、缩进,视觉噪音大 | 一行纯字母数字,一眼扫完 | | 手抄成本 | 抄错一个引号全废 | 抄错一个数字只废一个关节 | | 局部容错 | 一处语法错,整体解析失败 | 坏项跳过,好关节照常生效 | | 粘贴友好 | 换行、缩进进输入框很难看 | 天然单行 | | 实现成本 | 低 | 低(两个纯函数) |
关键是第三行。姿势串是给人手抄手传的介质,它的解析器必须像人一样宽容:某个关节的数字被抄丢了,其他 17 个关节还应该动起来。JSON 解析器遇到语法错误只会给你一个 parse_error,然后什么都不给。
当然这不意味着 JSON 没用——工程文件 .hproj 本身就是 JSON。姿势串是协议层,.hproj 是存档层,两层各干各的活:协议管"人与人之间传",存档管"程序与磁盘之间存"。
定格式之前我还想清楚了一件事:这行文本有两个消费者。一个是人,要读、要抄、要在 QQ 里检查有没有粘错;另一个是我自己的播放器,要逐帧解析。两个消费者的需求冲突时,我优先了人——因为播放器的解析器我可以随便改,人的阅读习惯改不了。这个优先级后来体现在每一个细节上:全名代号(人不用查表)、度单位(人不用乘 57.3)、整数不带小数点(串短一截)、分号分指(手指边界一眼可见)。给机器看的数据格式遍地都是,给人看的数据格式反而是稀缺品,值得认真设计。
先问数据在什么介质上流动,再定格式。跨剪贴板的活,别让引号和缩进掺和。
二、18 个槽位:协议全文
姿势串长这样(这是"完全张开"的零位):
{T_MCP0,T_PIP0,T_TIP0;I_KNUCKLE0,I_MCP0,I_PIP0,I_DIP0;M_MCP0,M_PIP0,M_DIP0;R_KNUCKLE0,R_MCP0,R_PIP0,R_DIP0;L_KNUCKLE0,L_MCP0,L_PIP0,L_DIP0}
规则用三句话说完:
-
大括号包整体,内部按拇指→食指→中指→无名指→小指的顺序排五根手指;
-
指内逗号、指间分号:
,隔开同一根手指的关节,;隔开不同手指; -
每一项是
代号+角度,角度单位度,正负号照写,整数不带小数点。
五根手指的槽数不一样,这是从真实模型里长出来的,不是拍脑袋:
| 手指 | 槽位数 | 槽位 | | --- | --- | --- | | 拇指 T | 3 | T_MCP / T_PIP / T_TIP | | 食指 I | 4 | I_KNUCKLE / I_MCP / I_PIP / I_DIP | | 中指 M | 3 | M_MCP / M_PIP / M_DIP | | 无名指 R | 4 | R_KNUCKLE / R_MCP / R_PIP / R_DIP | | 小指 L | 4 | L_KNUCKLE / L_MCP / L_PIP / L_DIP |
为什么食指和无名指多一个 KNUCKLE(掌指关节基座)、拇指和中指没有?因为我的 3D 打印模型就是这么造的——某些手指的指根基座是独立可动的零件,某些是打印成一体的。协议长什么样,由硬件说了算,这一点在第 6 篇还会再撞一次。
槽位的排列顺序也值得说两句。我见过的不少手部协议(包括 MediaPipe 的 21 点)是按传感器或组件的索引顺序排的,那种顺序对程序友好、对人极不友好——你会看到"7 号点是 12 号点的父节点"这种鬼话。我的排法是拇指在最前、四指从食指到小指,完全按人数手指的习惯来。代价是桥接层需要一张"MediaPipe 索引 → 槽位代号"的映射表(第 6 篇的活),收益是:任何人在聊天窗口里看到姿势串,顺着读下来就是"拇指、食指、中指、无名指、小指",不用查任何文档。
有一个层次问题当年差点搞混:姿势串里写的角度,是"关节角"还是"检测角"? 答案是关节角——它描述"这根手指的关节现在在哪个角度",是模型的量;至于摄像头检测出来的是什么坐标系、差多少偏置,那是同步模式自己的事。协议层只描述模型状态,不掺感知的事。把这两层分开,后面七轮 T_MCP 的调试才有可能定位到"错在映射,不在协议"。
还有一个当初纠结过的决定:代号用短码 TM0 还是全名 T_MCP0?第一版用了短码,省地方。用了两天改成了全名——因为短码是我发明的语言,全名是树节点名,用户在树控件里看到什么,姿势串里就写什么,一个对照表都不用查。解析器两边都认(后面讲),但输出永远用全名。
协议里的名字,能跟系统里已有的名字对齐,就不要发明新的。每个新代号都是一份要人记的文档。
三、buildPoseString:把树压成一行字
序列化函数写在 ui/pose_string.hpp 里,130 行,纯函数,不碰 Qt——这是它能被命令行测试的前提。
先看骨架:
inline std::string buildPoseString(const hand_core::Tree& tree) {
constexpr double kRadToDeg = 180.0 / 3.14159265358979323846;
std::string out = "{";
int fingerPos = 0, inFinger = 0;
static const int kFingerLens[5] = {3, 4, 3, 4, 4}; // 分指边界
int fingerIdx = 0;
const auto& slotTab = poseSlots();
for (std::size_t i = 0; i < slotTab.size(); ++i) {
if (i > 0) out += (inFinger == 0) ? ';' : ',';
const int id = findPoseNode(tree, slotTab[i].second);
double deg = 0.0;
if (id >= 0)
if (auto st = tree.state(id)) deg = st->q * kRadToDeg;
out += slotTab[i].first;
out += formatDeg(deg);
++inFinger;
if (inFinger >= kFingerLens[fingerIdx]) { inFinger = 0; ++fingerIdx; }
}
out += "}";
return out;
}
有三个设计点值得单独说。
第一,度与弧度的边界又出现了。 树里永远存弧度,姿势串永远写度。这个规矩在第 2 篇定过(界面是给人的,度;计算是给数学的,弧度),姿势串作为"界面的一部分"自然归入度。换算只发生在 buildPoseString 的读取处和 parsePoseString 的写入处,中间层永远不碰换算——换算散落在各处,是单位 bug 的温床。
第二,找不到节点怎么办?输出 0,不报错。 findPoseNode 返回 -1 时,deg 保持 0.0,姿势串照常输出 XXX0。听起来草率,其实是刻意的:姿势串的消费者是"另一只手"(可能是别的工程文件、可能是同学改过的版本),两棵树不保证同构。缺一个关节就写 0,比让整个复制粘贴流程崩掉,体验好得多——协议宁可丢精度,不许断流程。
第三,名称匹配用前缀,容忍 _copy 后缀。 树控件支持复制节点,复制出来的叫 T_MCP_copy、T_MCP_copy2。如果用严格全名匹配,复制过工程的人会发现姿势串突然读不到拇指了。所以匹配规则是:精确名,或 前缀 + '_' 开头:
inline int findPoseNode(const hand_core::Tree& tree, const char* prefix) {
const std::string pre(prefix);
for (const auto& n : tree.aliveNodes()) {
const std::string& nm = n.joint.name;
if (nm == pre) return n.id;
if (nm.size() > pre.size() && nm.compare(0, pre.size(), pre) == 0
&& nm[pre.size()] == '_') return n.id;
}
return -1;
}
注意 nm[pre.size()] == '_' 这个下标检查——没有它,一个叫 T_MCPXXX 的节点会被 T_MCP 误伤。前缀匹配这种"宽一分则错"的活,边界条件必须钉死。
还有个小的 formatting 决策:formatDeg 的判定阈值是 0.05 度:
inline std::string formatDeg(double deg) {
char buf[32];
if (std::fabs(deg - static_cast<long>(deg)) < 0.05)
std::snprintf(buf, sizeof(buf), "%ld", static_cast<long>(deg));
else
std::snprintf(buf, sizeof(buf), "%.1f", deg);
return buf;
}
目的只有一个:让姿势串看起来干净。T_MCP-45.000002 和 T_MCP-45 给人的心理感受完全不同——前者像仪器读数,后者像人写的数。0.05 的截断在角度这个尺度上毫无精度损失(0.05° 在指尖上是零点几毫米),却让每一次复制出来的串都短一截。文本协议的受众是人,可读性也是正确性的一部分。
顺带说一个实现层面的小决策:分指边界 kFingerLens = {3, 4, 3, 4, 4} 直接硬编码在 buildPoseString 里,而不是从槽位表推导。为什么不写个算法自动按 _ 前缀分组?因为槽位表本身就是手工维护的协议定义——协议是人定的,就该以人能直接核对的形式存在:哪根手指几个槽、按什么顺序,打开代码一眼扫完。为了"消除魔法数字"搞自动分组,等于在协议定义之上又加了一层解释器,核对协议反而要跑代码。配置性的数据,宁可平铺直叙,不要层次抽象。
还有个容易忽略的坑藏在 findPoseNode 的遍历里:它按 aliveNodes() 的顺序找第一个匹配前缀的节点。如果用户的树里真的有 T_MCP 和 T_MCP_copy 两个活节点(复制过又没删),姿势串永远绑定前者——这个行为对不对?我认为对:原节点是"正身",副本是实验品;姿势串绑定正身,实验数据不该污染正式手势。但这个决定必须写进头注释(实际也写了),否则哪天有人用副本搭了个新手势,发现播放器不认,会以为是 bug。
四、parsePoseString:宽容的另一半
解析器是构造器的镜像,但态度完全相反:构造器对内严格(树的数据不可能错),解析器对外宽容(输入可能是手抄的、残缺的、格式怪异的)。
inline std::vector<std::pair<int, double>> parsePoseString(
const hand_core::Tree& tree, const std::string& text) {
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
std::vector<std::pair<int, double>> out;
std::size_t i = 0;
while (i < text.size()) {
if (!std::isalpha(static_cast<unsigned char>(text[i]))) { ++i; continue; }
std::string code;
while (i < text.size() && (std::isalpha(static_cast<unsigned char>(text[i]))
|| text[i] == '_'))
code += text[i++];
std::string num;
while (i < text.size() && (std::isdigit(static_cast<unsigned char>(text[i]))
|| text[i] == '-' || text[i] == '+' || text[i] == '.'))
num += text[i++];
if (num.empty()) continue;
for (const auto& s : poseSlots())
if (code == s.first) {
const int id = findPoseNode(tree, s.second);
if (id >= 0) out.emplace_back(id, std::stod(num) * kDegToRad);
break;
}
}
return out;
}
核心策略是逐字符扫描 + 坏项跳过:扫到一个字母开头的代号,跟着吞掉数字,代号在槽位表里查得到就换算成 (节点id, 弧度),查不到就丢掉,继续扫下一个。大括号、分号、逗号、空格、换行、用户手滑打进去的任何垃圾字符,统统被"非字母就跳过"这一行吞掉。
这带来几个免费福利:
-
{...}的外壳只是装饰,解析器根本不看它; -
一个文本框里粘多个姿势块也能全部提取(配合
extractPoseBlocks,每个{}算一张卡); -
短码时代的老姿势串(
TM0,TP0)粘进来照样能用——代号精确匹配槽位表,两代协议共存。
extractPoseBlocks 是个 12 行的小函数:从头扫到尾,找到 { 就找配对的 },截出整块,跳过去继续找下一个。它存在的意义是让"粘贴一段含多个姿势的文本"变成一次性操作——比如从文档里复制"张开 + 握拳 + OK"三行,粘进任意一张卡,解析层自动拆成三份。这个函数和 parsePoseString 共享同一种世界观:输入是流,匹配是模式,不匹配的部分静静流过。
但宽容有边界。这里我留了一个已知的小尾巴:数字段是用 std::stod 收尾的,如果用户真的手滑写出 T_MCP--5 这种连续负号,stod 会抛 std::invalid_argument,而这个函数没有 try。目前没炸是因为输入框旁边我放的是"粘贴自己的姿势串"场景,真炸的概率低;但按工程标准它该修——修法也简单,num 里数字字符以外的连续符号剥一遍再转换。写在这当个欠条,第 6 篇顺手还上。
解析外部输入,先想清楚"宽容到哪为止"。跳过坏项是宽容,吞掉异常不是——那叫没想过。
五、姿势卡片:把协议做成流程图
有了协议和一对转换函数,接下来是让它变成界面。第 4 篇删掉了滑条区,右下角的"关节角"面板空出来,正好改造成流程图式的姿势编辑器:一张卡片 = 一个目标姿势,卡片上方一行"过渡 x 秒 ▼",从上到下串成一条流程线。
卡片是纯代码动态生成的(.ui 只留骨架容器):
void JointControlPanel::onAddCard() {
PoseCard c;
c.row = new QWidget(this);
auto* rowLay = new QVBoxLayout(c.row);
rowLay->setContentsMargins(0, 0, 0, 0);
// 过渡时间行:── 过渡 [ 1.00 s ] ──▼──
c.timeSpin = new QDoubleSpinBox(c.conn);
c.timeSpin->setRange(0.05, 60.0);
c.timeSpin->setValue(1.0);
c.timeSpin->setSuffix(QStringLiteral(" s"));
c.timeSpin->setToolTip(
QStringLiteral("从上一姿势(首张=当前状态)过渡到本姿势的时长"));
// 卡片主体:标题 + 🗑 + 姿势串输入框
c.card->setStyleSheet(QStringLiteral(
"QWidget { border:1px solid #555; border-radius:4px; background:#2b2b2b; }"));
c.delBtn->setText(QStringLiteral("🗑"));
c.edit->setFixedHeight(42); // 约 2 行,不再撑大卡片
c.edit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
// 插到弹簧前面
const int insertIdx = ui_->flow_lay_->count() > 0
? ui_->flow_lay_->count() - 1 : 0;
ui_->flow_lay_->insertWidget(insertIdx, c.row);
connect(c.delBtn, &QToolButton::clicked, this, &JointControlPanel::onDelCard);
cards_.push_back(c);
renumber();
}
这段代码里有四个来之不易的细节。
"过渡时间挂在卡与卡之间"的语义。 每张卡顶部的秒数,含义是"从上一张的姿势过渡到本张的姿势花多久"——第一张卡则是"从当前姿态出发"。这个语义用户从来不用解释,看一眼 tooltip 就懂;但它决定了播放器的数据结构(下一节),一开始我把时间做成全局的"每段统一 1 秒",用了半天就改成每卡独立——做手势时"捏合要慢、张开要快"是真实需求。
删除靠 sender() 反查。 onDelCard 不传参,用 qobject_cast<QToolButton*>(sender()) 找到是哪张卡的按钮,再遍历 cards_ 匹配 delBtn 指针。闭包捕获 id 的写法当然也行,但卡片会增删、下标会漂,捕获下标是最容易野指针的写法;sender 反查在删除时永远指向"现在还活着的那张卡"。
删除前先 stopPlayback()。 注释就一行:"停播放防野指针"。正在播放时删掉中间一张卡,seg_ 可能正好指在已删除的卡片上——播放器持有 cards_ 的下标,卡片一 erase 下标全体漂移。宁可让用户点删除时播放"啪"地停下,也不留一个悬垂下标。
卡片压缩三件套。 第一版卡片丑得惊人:QPlainTextEdit 默认策略让它跟着布局撑高,三张卡就把整个面板撑出滚动条。修复是三板斧:setFixedHeight(42)(约两行)、卡片 setSizePolicy(Preferred, Fixed)、布局尾部 addStretch(1) 弹簧吸收剩余空间。三行代码,治好了"卡片越拉越高"的顽疾。滚动容器里的 Fixed 策略,是防"内容无限膨胀"的唯一解。
设计模式与仿真模式的显隐分工。 这个面板同时服务两种模式,但形态完全不同:设计模式下用户只想"看"当前姿态的快照,流程编辑是一堆干扰项;仿真模式下他只想编排姿势,滑条什么的不需要。实现上很朴素——构造函数里把播放条、添加按钮、流程区统统 hide(),再提供一个总开关:
void JointControlPanel::setPlaybackEnabled(bool on) {
ui_->play_bar_->setVisible(on);
ui_->add_btn_->setVisible(on);
ui_->flow_hint_->setVisible(on);
ui_->flow_scroll_->setVisible(on);
if (!on) stopPlayback(); // 退出仿真模式,播放必须停
}
注意最后一行:切回设计模式时顺手 stopPlayback()。如果用户播放到一半切模式,定时器还在跑,cur_pose_ 只读框会被插值结果反复刷屏——而设计模式的语义是"显示树上真实的当前姿态"。模式切换不只是显隐,还要把该模式不该有的活动停掉。 这条是第 4 篇"模式切换只改 UI 状态"原则的自然延伸,当时漏了这半句,补上。
tooltip 是这个面板的第二界面。 过渡时间的"从上一姿势(首张=当前状态)过渡到本姿势的时长"、姿势串输入框的"本步骤的目标姿势串(度)。播放时手过渡到这个姿势"、删除按钮的"删除本姿势框,后面的流程自动上移"——这三条 tooltip 写掉了三种最可能的理解歧义。面板没有空间放说明文字(dock 宽度有限),tooltip 就是唯一能塞语义的地方,每一处都当文档写,不当装饰写。
删除后的重编号也值得一提:renumber() 把所有卡的标题刷成"姿势 1 / 姿势 2 / …"。用户感知里这是"后面的卡自动上移",实现上只是编号跟数组走——UI 文案随数据结构走,就不要试图维护独立的编号状态。
六、33ms 插值:一个 60 行的播放器
播放器是我近期写得最舒服的一段代码,因为它翻译成中文只有一句话:
播放开始时拍一张快照,之后每个 33ms,把每个关节从"快照值"往"目标值"挪 a% 的路;一段走完,把终点写成新快照,进下一段。
翻译成代码:
void JointControlPanel::applyPose(int cardIdx, double a) {
const std::string text = cards_[cardIdx].edit->toPlainText().toStdString();
const auto parsed = parsePoseString(*tree_, text);
for (const auto& kv : parsed) {
double q0 = 0.0;
auto it = q_start_.find(kv.first);
if (it != q_start_.end()) q0 = it->second;
const double q = q0 + (kv.second - q0) * a; // 线性插值
tree_->applyQWithMimic(kv.first, q); // 走联动约束
}
}
void JointControlPanel::onPlayTick() {
if (!playing_ || !tree_ || seg_ >= (int)cards_.size()) { stopPlayback(); return; }
const double dur = cards_[seg_].timeSpin->value();
t_ += timer_->interval() / 1000.0;
double a = (dur > 1e-6) ? (t_ / dur) : 1.0;
if (a > 1.0) a = 1.0;
applyPose(seg_, a);
emit poseApplied(); // 主窗口 fkRefresh
if (a >= 1.0) { // 段终点写进快照
const std::string text = cards_[seg_].edit->toPlainText().toStdString();
for (const auto& kv : parsePoseString(*tree_, text))
q_start_[kv.first] = kv.second;
++seg_; t_ = 0.0;
if (seg_ >= (int)cards_.size()) { stopPlayback(); updateCurrentPose(); }
else ui_->play_status_->setText(
QStringLiteral("段 %1/%2").arg(seg_ + 1).arg((int)cards_.size()));
}
}
三个设计点。
快照拍在"当前姿态",不是第一张卡。 onPlayClicked 里把全树所有 state(id)->q 存进 q_start_。这意味着不管你把手指拖到什么鬼姿势,点播放,手都会从当前位置平滑地走向姿势 1——而不是瞬移。这个小细节让"播放"从"回放功能"变成了"编排工具"。
为什么线性插值就够,不上缓动函数? 我认真考虑过 ease-in-out(两端慢中间快)。手势运动如果给缓动,观感确实会更"自然"——真人甩手就是带加速减速的。但有两个现实约束:一是姿势卡片的语义是"编排目标",用户想精确控制的是什么时候到哪儿,线性插值让"过渡时间"这个参数的含义变得绝对诚实;二是缓动函数一旦引入,"段与段衔接处的速度连续性"就成了新问题——每段各自 ease,衔接点速度必然不为零,手会在卡片边界"顿"一下,比恒速更难看。要根治得做跨段的样条规划,那是运动规划的领域了,手势预览犯不上。插值曲线的复杂度要和编排工具的语义匹配——线性最诚实,也最不容易出鬼。
插值不在姿势串层面做,在关节层面做。 parsePoseString 每帧只解析一次目标(其实可以缓存,33ms 下不值得优化),插值发生在 (id, q) 层面。这保证姿势串里没写的关节保持上一段的终点不动——用姿势串局部描述"只弯食指"成为可能。
联动走 applyQWithMimic。 直接 state(id)->q = q 会让有 mimic 依赖的关节(比如某些联动的指节)不同步。所有运行时改 q 的入口都必须走这一个函数——第 6 篇的视觉驱动也是它,这条规矩已经救过我两次。
emit poseApplied() 是面板与主窗口的唯一通道:面板只管算 q,FK 刷新、渲染更新全部由主窗口统一调度。UI 面板不直接摸渲染——这条单向依赖从第 3 篇坚持到现在,没破过。
顺带回答一个我自己问过自己的问题:为什么不用 QVariantAnimation / QPropertyAnimation? Qt 自带的动画框架看起来正合适——给起止值、给时长,动画框架帮你插值。试了一下就放弃了,原因是对象模型不匹配:Qt 动画插值的是单个属性(一个 QVariant),而我要的是"一帧之内对十几个关节依次写树、写完统一触发一次 FK"。要么开十几个动画各管一个关节(FK 会被触发十几次,还得解决同步),要么把"整棵树"包装成一个可动画属性(扭曲又别扭)。QTimer + 三个状态数 + 一个 tick 函数,60 行,所有行为一目了然。框架的适用边界比功能清单重要——当你的"属性"其实是"一组耦合状态"时,自己写循环反而更短。
另外,timer_->setInterval(33) 配 t_ += interval/1000 是用标称帧率累加时间,不是 QTime::currentTime() 求差。系统卡顿时播放会整体变慢而不是跳帧——对手势预览来说,"慢"比"跳"舒服得多。真要精确得用 QElapsedTimer,但那是给物理仿真回放用的(第 7 篇会碰),手势编排用不着。
插值播放器的状态只有三个数——段号、段内时间、起点快照。状态一多,段间切换必出鬼。
七、三组手势数字是怎么推出来的
协议、编辑器、播放器齐了,就差内容:得有手势可播。张开、握拳、OK,三组。
这件事的正确姿势是从限位表出发倒推,而不是拿真手比划着猜。我的关节限位是对着真实 3D 打印件调的(哪些关节物理上弯不到底,模型早就知道):
| 关节 | 限位 | 说明 | | --- | --- | --- | | T_MCP | [-180°, 0°] | 拇指对掌/旋前,只能往掌心方向翻 | | T_PIP | [-90°, 0°] | 拇指第二节 | | T_TIP | [-110°, 0°] | 拇指尖节 | | 四指 MCP/PIP/DIP | 各有负限位 | 弯曲方向为负 |
于是三组手势的推导过程是:
张开——全零。所有关节 0° 就是装配零位,手自然摊开:
{T_MCP0,T_PIP0,T_TIP0;I_KNUCKLE0,I_MCP0,I_PIP0,I_DIP0;M_MCP0,M_PIP0,M_DIP0;R_KNUCKLE0,R_MCP0,R_PIP0,R_DIP0;L_KNUCKLE0,L_MCP0,L_PIP0,L_DIP0}
这一串是所有手势的"原点",也是调试播放器时的默认第一张卡。
握拳——四指各关节往负限位推到底,拇指两节也顶满。这里有个后面还要用到的数字:拇指两节全弯时,curl = (90) + (110) = 200——即 PIP 顶到 -90、TIP 顶到 -110 时,两节屈曲量之和正好 200°。这个 200 在第八节会变成关键常量。
OK——拇指尖和食指尖捏合:食指 PIP/DIP 大幅弯曲(比照真实比 OK 时大约 -60°~-80° 的量级),中指无名指小指自然半屈,拇指 T_MCP 往掌心翻、两节微屈去凑食指。OK 是三组里唯一不能"顶满限位"的——它的美感恰恰在于"接触而不过度",所以它的数字来自试播:设一个值、播放、看穿模、回调,来回几轮。
推导顺序上有个小讲究:先握拳、再张开、最后 OK。握拳是一切负方向的"参照系"——把每个关节顶到限位,你就知道每个关节的负向行程有多大;张开是零位原点;有了这两端,OK 这种"中间姿态"才有坐标系可以估。顺序反过来的话,你会在没有参照的情况下凭感觉猜 OK 的数字,猜完握拳还得全改。
过渡时间的节奏也是数据的一部分,而且比角度更依赖手感。我的默认值是每段 1 秒,实际编排时按动作性质调:张开→握拳给 1.5s(大行程,快了像抽搐)、握拳→张开给 1s(回程可以利索点)、涉及捏合的段给 1.5s 以上(捏合动作的观赏性全在"慢慢靠近"那零点几秒)。这些数字没有任何推导依据,纯试出来的——但它们被写进姿势卡片里存档,下次打开还在。节奏一旦调好就成了资产,这正是"数据问题"优于"编译期问题"的地方。
流程卡片在这时候显出价值:三张卡、过渡时间分别设好,点播放,手就"张开→握拳→OK"连续演一遍。改数字不用重编译,手势调参从编译期问题变成了数据问题——这才是姿势串协议真正的回报。
八、T_MCP 七连修:一个观测性陷阱的完整复盘
现在讲那个我在第四篇结尾埋的雷。为了让摄像头里的拇指正确驱动 3D 模型的 T_MCP,我前后返工了七轮。这一节不是流水账,是把七轮失败提炼成一个值得记住的问题模型:正面摄像头 + 绕掌轴的旋前关节,是一个观测性陷阱。
背景一句话:同步模式下,MediaPipe 给 21 个手部关键点,我把它们换算成 18 个关节角,其中四个手指的 PIP/DIP 用标准铰链内角就能算,效果一直很好。唯独拇指的 T_MCP,怎么算都不对。
第 1~5 轮,我换了五种几何量:三点内角、掌骨-掌轴夹角、掌骨偏离掌平面角、拇指尖到小指根的距离、拇指尖到四指骨架的最小距离。每一轮都是"看起来很有道理、上线立刻翻车"。翻车姿势千奇百怪:恒带 -30° 假偏置、比 OK 时反而回摆、启动姿势污染标定基准导致 2760 帧纹丝不动……
先把七轮的病历摊在桌上,方便后面复盘:
|
轮次
|
方案
|
死因
|
| --- | --- | --- |
|
1
|
三点内角 (0,1,2)
|
腕点与掌指点太近,伸直时内角也只有 130150°,detected-180 恒带 -30-60° 假偏置,对屈曲几乎不响应
|
|
2
|
掌骨-掌轴夹角
|
这两个方向近似正交——量的是另一个自由度,怎么调都不跟随
|
|
3
|
掌骨偏离掌平面角 φ
|
混进了 MediaPipe 深度 z 的噪声,z 本身不可靠
|
|
4
|
拇指尖→小指根距离
|
参考点(小指根)不随拇指动,OK 手势是"向上捏",水平距离几乎不变
|
|
5
|
拇指尖→四指尖最小距离 + 运行期标定
|
OK 有响应了,但基准 D0 靠"前 30 帧最大距离"——见下文,最大的雷
|
|
6
|
满量程与标定轮番修
|
D0 被启动姿势污染,2760 帧 T_MCP 恒 0.0°
|
|
7
|
废标定,改 curl 绝对量
|
终于稳了(但仍受第八节说的可见性天花板限制)
|
中间还夹着一条常量演进史,每一行都是一次翻车现场:
| 时间点 | CURL_FULL | THETA_MAX | 发生了什么 | | --- | --- | --- | --- | | 第 6 轮改绝对量 | 100° | 60° | 灵敏度不够,作者说"系数不太对" | | 加大系数 | 90° | 75° | 反向灾难,拇指开始"缩进屏幕" | | 最终定案 | 160° | 45° | 握拳 curl≈200 仍能到顶,正面投影保留 ~80% |
注意第 6→7 行的方向:满量程从 60° 先加到 75° 是反方向。因为 T_MCP 绕掌轴转,q 越大拇指越往视线深处翻,加大幅度等于让它消失得更快。我当时只看到"系数不够"的反馈就顺势加大,没有先问"这个关节在屏幕上到底长什么样"——这是七轮里最疼的一巴掌。
第七轮才想明白两件事。
第一件:T_MCP 根本不是我以为的那个关节。 用真实 FK 一查:T_MCP 的转轴 ≈ 掌轴方向(腕→中指根)。也就是说它是绕掌轴的旋前/对掌关节——拇指绕这根轴转,是"整根拇指翻离掌平面",而不是"在掌平面里横扫过去捏东西"。摄像头正面拍摄时,掌轴方向 ≈ 视线方向——T_MCP 转动的主要效果是把拇指往屏幕深处转。
正面投影长度 ≈ cos,q,,我实测了一组数:
| q | 掌骨正面投影 | | --- | --- | | 0° | 0.989 | | -26.6° | 0.946 | | -45° | 0.797 | | -60° | 0.614 | | -74.9° | 0.391 | | -90° | 0.138 | | -138° | -0.643(翻过小指侧) |
q=-60° 时拇指尖在三维里实际移动了 63mm——几何上完全在动,但正面看只剩几毫米的"变短",像没转。之前好几轮"T_MCP 不转/不动"的疑案,全是它:不是驱动错了,是这个自由度在正面视角下天然不可见。
第二件:所有依赖"距离基准"的方案,在真实使用里必然翻车。 无论是"到小指根的距离"还是"到指尖的最小距离",都需要一个"张开时"的基准值 D0 才能归一化。我试过运行期自动标定:启动前 30 帧取最大距离后冻结。
这个设计的漏洞值得单独拆开讲,因为它是所有"自适应"方案的经典死法。理想使用流程是:用户点"开始检测"→ 先把手完全张开 1 秒 → 系统标定 D0 → 然后正常使用。但真实世界发生了什么?用户点完按钮,手还停留在上一秒聊天时的姿势——半握。30 帧标定期内最大距离被半握姿势封顶,D0 从此焊死在一个"半握的宽度"上。之后不管手怎么动,d 都不会超过这个被低估的 D0,ratio = (d0 - d) / (d0 - d_min) 的分子恒为 0 —— 从按下按钮的那一刻起,这个关节就永远死了。作者发来的截图读数是 T_MCP=0.0° T_PIP=-49.9° T_TIP=-30.7°,已检测 2760 帧:食指中指全在动,唯独指根装死。我用同样的截图读数离线复算了一遍,确认了整条因果链。
这类 bug 最恶心的地方在于它不报错、不崩溃、可复现性依赖用户的操作习惯——你在测试时总是先张开手再点按钮,于是永远复现不出来;真实用户拿到手第一秒就是另一样子。自适应标定在 demo 里很美,在真实使用里是颗雷:你永远不知道用户按下开始的那一刻,手是什么姿势。
所以最终方案反其道而行——绝对量,零标定:
# T_MCP 由拇指两节屈曲量驱动:curl = (180-T_PIP) + (180-T_TIP)
curl = (180.0 - a["T_PIP"]) + (180.0 - a["T_TIP"]) # 伸直 0,顶满 200
r = max(0.0, min(1.0, curl / T_MCP_CURL_FULL)) # CURL_FULL = 160
sim = -r * T_MCP_THETA_MAX # THETA_MAX = 45
_T_MCP_SMOOTH = 0.65 * sim + (1 - 0.65) * _T_MCP_SMOOTH # EMA 去抖
angles["T_MCP"] = 180.0 + _T_MCP_SMOOTH
拇指两节的屈曲角(T_PIP、T_TIP 用标准铰链内角,一直很可靠)加起来,除以满量程 160°,映射到 T_MCP 的 45° 满量程。没有基准、没有标定、没有尺度依赖——手多远、转向哪边、镜像与否,结果都不变。
而满量程为什么定 45°?就是上面那张投影表:-45° 时投影还有 0.797,拇指依然清晰可见;-90° 只剩 0.138,几乎"缩进屏幕"。满量程不是"越大力越好",是在可见性和表达力之间取的平衡——我一度调到 75°,作者发来的截图里 3D 拇指直接消失了,这就是那一轮的学费。
七轮复盘浓缩成三句话:
驱动一个关节之前,先回答"它的转轴在摄像头视角下投影成什么"。转轴垂直于视线,投影可见;转轴平行于视线,天生不可见。
纪律六:任何依赖"运行期基准"的归一化,都默认输入端不可控。要么找绝对量,要么把基准做成用户可见、可重置的状态。
纪律七:系数调不出来时,先怀疑语义,再怀疑数值。我第六轮还在加大系数,其实是方向本身反了。
复盘完还应该回答一个问题:为什么收敛得这么慢? 平心而论,七轮里每一轮的单点修改都只花半小时,真正的时间都花在"确认这轮到底错在哪"上。我后来固定了一个流程,收敛速度才提上来:每轮修改前,先用上一轮留下的截图读数(检测界面显示的 T_PIP/T_TIP 原始值)离线复算新公式,直接得出 T_MCP 应该输出多少——复算结果和预期姿势对得上,才动真文件。第 6 轮修复就是靠"截图复现"一条链路实锤的:把截图里的 -49.9/-30.7 喂进新公式,算出 -48.4°,指根理应收拢——数字对上了,才敢提交。
回头看,前六轮我一直在修"算错了什么":换几何量、换参考点、换标定策略,全是在"怎么从 21 个点算出这个角"里打转。真正的问题在观测性——"正面视角下这个自由度根本看不见",以及输入端不可控——"你无法假设用户的启动姿势"。这两个问题的答案都不在算法里,在问题定义里。算法修了六轮才修到问题定义,这个学费交得不冤,但希望看这篇的人少交一轮。
(补充一句工程之外的话:即便如此,"拇指在掌平面内横扫去捏东西"这个动作,T_MCP 在硬件上就是做不到——那是真实打印件的装配结构决定的。软件的职责是忠实驱动模型实际拥有的自由度,不是伪造一个看起来像的。这条后来成了整个项目的红线之一。)
还有一个只有自己用才会想到的问题:播放器和视觉驱动,会不会抢关节? 两者都是往树上写 q 的入口——播放器在仿真模式里按卡片插值写,视觉驱动在同步模式里按摄像头帧写。答案是靠模式互斥天然隔离:播放按钮和流程卡片只在仿真模式可见,摄像头链路只在同步模式激活,两个入口不可能同时醒着。这不算我设计得多聪明,而是第 4 篇"模式切换只改 UI 状态、数据单实例"那条原则的红利——单数据源 + 单激活入口,竞争条件在结构上就不存在。如果当初贪方便搞了两个树实例,这里就得补一套加锁或者仲裁逻辑,现在一行都不用。
写完这一篇回头看,姿势串大概是整个项目里"性价比"最高的模块:130 行头文件 + 250 行面板,换来了手势的完整工作流。它的所有设计决定——宽容解析、全名代号、度单位、前缀匹配——没有一个是炫技的,全是从"这个东西要在人和人之间流动"这个场景里长出来的。
而 T_MCP 那七轮返工教会我的事,比姿势串本身值钱:驱动一个物理系统之前,先搞清楚它的自由度在你传感器视角下的样子。 这句话我打算在报考方向为机器人的学弟学妹面前,反复讲。
也想对准备动手做毕业设计的同学说一句:这一篇里出现过的所有东西,单拎出来没有一个是"高级"的,没有矩阵分解、没有优化器、没有神经网络。但把它们按"协议 → 序列化 → 界面 → 播放 → 数据"的顺序串起来,做出一个"别人拿到就能玩"的完整功能,这个过程本身就是一个毕设最该练的肌肉。好的工程不在于用了多深的技术,而在于每个决定都能回答"为什么"。
下篇预告
第 6 篇讲视觉识别与同步模式:MediaPipe 怎么塞进 Qt(QProcess + 二进制帧桥接,为什么不用直接链接)、骨架叠加层(QVideoSink + QPainter 自绘)、同步模式的布局与"检测界面",以及视觉驱动 3D 手的全链路——包括手腕俯仰/旋转跟随和"联轴器 4 个同名节点、只有 1 个在手掌链上"的查找陷阱。
再往后,第 7、8 两篇专门留给 AI 调试——怎么把 AI 当数值验证机、怎么让它读代码定位 bug、红线怎么划、以及我把它用翻车的那几次。那两篇会写得比较坦白。
咱们下篇见。