2026/10/24 1:23:05

深度学习目标检测中的先验框解码:从偏移量到真实坐标

深度学习目标检测中的先验框解码:从偏移量到真实坐标 1. 先想清楚一件事网络输出的预测框为什么不能直接用做目标检测做到先验框解码这一步说明你已经跑通了一个检测网络至少是模型能够正常前向传播了。但这时候你大概率会遇到一个让人头疼的问题网络输出了一堆张量形状可能是[batch, num_anchors, 4]看起来是预测框的坐标可你把它们画到原图上要么框的位置完全对不上目标要么框的尺寸离谱要么整个图密密麻麻全是乱框。先别急着怀疑模型训练出了问题十有八九是你忽略了解码这一步。这也是很多新手从分类任务转到检测任务时最容易卡住的地方。分类网络输出的就是最终结果——一个 softmax 后的概率分布你直接取 argmax 就能得到类别。但检测网络不是这么回事它输出的通常是相对于先验框的偏移量而不是最终的绝对坐标。换句话说网络告诉你的不是这个框左上角在 (x1, y1)右下角在 (x2, y2)而是在预设好的一堆参考框的基础上往右移动多少、往下移动多少、宽高放大或缩小多少倍。这些参考框就是标题里说的先验框英文叫 anchors 或 default boxes。你必须把偏移量加到先验框上才能还原出真正的预测框坐标。这就像你问路别人不告诉你目的地的经纬度而是告诉你从你当前位置往东走 200 米再往南拐然后爬一层楼。如果你不知道出发点在哪里光听后面的方向和距离根本没法定位。先验框就是这个出发点。没有解码你拿着一堆相对位移去画框当然全乱。所以解码这件事本质上就是完成一次坐标系的换算——把模型预测的相对变化映射回输入图像的绝对位置。这一步做错了后面所有的置信度过滤、NMS非极大值抑制、mAP 评估全都会跟着错而且错得毫无头绪。我先把这个核心问题说清楚下面再展开公式和代码。2. 解码的核心数学从偏移量到真实坐标的四行公式先验框解码的数学原理不复杂但如果只看别人博客里的公式很容易被各种符号绕晕。我把话说直白一点你只需要记住四个变量的含义prior_boxes先验框通常是中心点坐标加宽高的形式记作(cx, cy, w, h)。pred_delta网络的回归输出是解码前的偏移量记作(dx, dy, dw, dh)。img_w, img_h输入图像的宽和高。decoded_boxes最终解码后的预测框通常转换成(xmin, ymin, xmax, ymax)的角点形式方便画框和计算 IoU。解码公式分为两部分。第一部分是中心点的修正pred_cx prior_cx dx * prior_w pred_cy prior_cy dy * prior_h第二部分是宽高的修正pred_w prior_w * exp(dw) pred_h prior_h * exp(dh)然后按照角点形式组织xmin pred_cx - pred_w / 2 ymin pred_cy - pred_h / 2 xmax pred_cx pred_w / 2 ymax pred_cy pred_h / 2为什么要用exp而不是直接加因为dw是网络输出的原始数值它可以是正的也可以是负的范围没有边界。直接用prior_w dw会导致预测框宽度可能变成负数——这是物理上不可能的。而exp函数可以保证输出永远大于零且当dw0时exp(0)1网络输出零偏移就意味着宽度不变语义恰好对齐。你把这个想成橡皮筋拉伸就行exp把任意实数映射到正数区间拉得越长值越大但永远不会变成负的。中心点部分为什么是dx * prior_w而不是直接加dx这取决于训练时对 ground truth 的编码方式。大部分检测框架在训练阶段是这样编码的dx_target (gt_cx - prior_cx) / prior_w dy_target (gt_cy - prior_cy) / prior_h dw_target log(gt_w / prior_w) dh_target log(gt_h / prior_h)解码公式就是编码过程的逆运算。所以你要记住一个至关重要的原则解码方式必须和训练时的编码方式严格对应。如果你的训练脚本里在计算 loss 之前用的是别的编码方式比如没有除以prior_w或者没有取log那么你推理时也必须照这样做反向操作否则哪怕代码写得再漂亮出来的框也全是错的。我见过不止一次有人把开源模型下载下来直接推理框画出来是歪的最后发现是不同框架之间编码方式不一致。一个项目里训练阶段用的是中心坐标除以先验框宽高的方式而推理代码却是按另一个仓库的实现写的两者对不上结果自然就崩了。这个编码-解码对称性是全文最重要的概念没有之一。还有一种解码方式也值得提一下有些网络不输出偏移量而是直接回归中心点和宽高的绝对坐标比如某些版本的 YOLO 会直接输出(x, y, w, h)其中坐标可能经过了归一化。这种情况下解码就更简单了基本只需要做坐标范围裁剪。但主流的基于 anchor 的检测器——从 Faster R-CNN、SSD 到 RetinaNet——几乎都是偏移量回归的方式所以上面这套公式是使用频率最高的你无论如何都要先把它吃透。3. 完整代码拆解一个可直接复用的先验框解码函数公式理清楚了接下来就是动手写代码。下面我给出一个用 TensorFlow 实现的完整解码函数。这个函数我故意写得偏容易看懂没有做各种晦涩的维度压缩技巧因为解码这种操作本质上就是个逐元素的算术运算搞太多黑魔法反而容易出错。你先跑通再谈优化。3.1 输入与输出函数签名设计我先定义一个用于生成先验框的工具函数因为解码时需要用到先验框的中心点坐标和宽高。假设我们约定先验框以归一化的方式存储——所有坐标都除以了图像宽高取值范围在 0 到 1 之间——这样后续不管输入图像尺寸怎么变都不需要重新生成先验框。解码时再把归一化坐标乘以真实的图像宽高就行了。代码如下。import tensorflow as tf def generate_default_boxes( img_h, img_w, feat_h_list(32, 16, 8, 4), feat_w_list(32, 16, 8, 4), scales(0.1, 0.2, 0.375, 0.5), ratios(1.0, 2.0, 0.5, 3.0, 0.333), ): 为简单起见这里只实现一个网格状铺满的先验框生成。 参数说明 img_h, img_w: 输入图像的原始高宽 feat_h_list, feat_w_list: 每个特征层的网格数量 scales: 每个特征层对应的基础尺度相对图像宽度的比例 ratios: 宽高比列表 返回 Tensor形状为 [num_anchors, 4]每行是 (cx, cy, w, h) default_boxes [] for layer_idx, (fh, fw) in enumerate(zip(feat_h_list, feat_w_list)): scale scales[layer_idx] for i in range(fh): for j in range(fw): cx (j 0.5) / fw cy (i 0.5) / fh w scale h scale default_boxes.append([cx, cy, w, h]) for ratio in ratios: # 保持面积大致相等只调整宽高比 sqrt_r tf.sqrt(ratio) w_ratio scale * sqrt_r h_ratio scale / sqrt_r default_boxes.append([cx, cy, w_ratio, h_ratio]) return tf.convert_to_tensor(default_boxes, dtypetf.float32)这里我先不要急着追求多尺度、多纵横比的复杂策略先保证先验框能覆盖整个图像平面。每一个特征图上的网格点中心对应一个或多个先验框这已经够用了。实际项目中你可能需要用到更精细的先验框配置比如 SSD 论文里那样按特征层逐层设置不同的 scale 和 aspect ratio但解码部分完全不受影响——你只需要把生成好的default_boxes喂给解码函数就行。3.2 关键实现细节逐段说明下面是解码函数本体。def decode_boxes(pred_offsets, default_boxes, img_h512, img_w512): 先验框解码函数。 参数说明 pred_offsets : Tensor形状 [batch_size, num_anchors, 4] 模型输出的原始偏移量最后一维顺序为 (dx, dy, dw, dh) default_boxes : Tensor形状 [num_anchors, 4] 先验框最后一维顺序为 (cx, cy, w, h)归一化坐标 img_h, img_w : 原始图像的尺寸用于把归一化坐标映射回像素坐标 返回 decoded_boxes : Tensor形状 [batch_size, num_anchors, 4] 解码后的预测框最后一维顺序为 (xmin, ymin, xmax, ymax) 注意这里返回的是像素坐标不是归一化坐标 # 1. 分离偏移量的各个分量 dx pred_offsets[..., 0] dy pred_offsets[..., 1] dw pred_offsets[..., 2] dh pred_offsets[..., 3] # 2. 分离先验框的各个分量 prior_cx default_boxes[..., 0] prior_cy default_boxes[..., 1] prior_w default_boxes[..., 2] prior_h default_boxes[..., 3] # 3. 按照公式解码中心点坐标归一化空间 pred_cx prior_cx dx * prior_w pred_cy prior_cy dy * prior_h # 4. 解码宽高归一化空间 pred_w prior_w * tf.exp(dw) pred_h prior_h * tf.exp(dh) # 5. 转成角点形式归一化空间 pred_xmin pred_cx - pred_w / 2.0 pred_ymin pred_cy - pred_h / 2.0 pred_xmax pred_cx pred_w / 2.0 pred_ymax pred_cy pred_h / 2.0 # 6. 把归一化坐标映射回像素坐标 xmin pred_xmin * img_w ymin pred_ymin * img_h xmax pred_xmax * img_w ymax pred_ymax * img_h # 7. 堆叠起来保持 [batch, num_anchors, 4] 的结构 decoded_boxes tf.stack([xmin, ymin, xmax, ymax], axis-1) return decoded_boxes值得解释的细节有这么几个。第一我用的是pred_offsets[..., 0]这种切片方式而不是pred_offsets[:, :, 0]。前者是 TensorFlow 的可变维度切片写法前面的...代表所有前置维度无论你的输入是三维[batch, num, 4]还是二维[num, 4]都能正确取出最后一个维度的某个分量。这样做的好处是函数泛用性更强测试时你甚至可以只传单个样本的二维张量进去不用刻意补 batch 维。第二先验框的宽高在这里是归一化的相对于图像尺寸。这样一来解码公式里的dx * prior_w实际上是在归一化空间里算的最后一步再乘回图像宽高。我刻意把乘回像素坐标的操作放在最后面而不是一开始就用像素级先验框。原因很简单如果你想换用不同分辨率的图像做测试只要归一化先验框不变解码函数就完全不用改只改传入的 img_h 和 img_w 即可。如果你的先验框本身已经是像素坐标那第 6 步就应该省略或者只对超出边界的部分做裁剪。第三堆叠时用了tf.stack(..., axis-1)注意axis-1意味着在最后一个维度把四个分量拼接起来得到[..., 4]。很多新手容易在这里写成tf.concat(..., axis-1)。两者有本质区别tf.stack会新增加一个维度比如把 4 个形状为[batch, num]的张量拼成[batch, num, 4]而tf.concat要求所有张量在这一个维度上已经有值只会把长度叠加比如 4 个[batch, num, 1]拼成[batch, num, 4]。如果你在变量初始化时用了[batch, num]的形状且没有扩维直接 concat 会报错或者产生意料之外的广播行为。我建议你写完之后可以故意测试一下这两种方式的差异这对你理解 TensorFlow 的维度系统很有帮助。3.3 在完整前向推理链路中的正确位置光有解码函数还不够你还要知道它该放在模型推理流程的哪个位置。一个标准的推理流程是这样的def predict(model, input_tensor, default_boxes, img_h, img_w): # 1. 通过主干网络提取特征 # 2. 通过检测头得到分类输出和回归输出 pred_logits, pred_offsets model(input_tensor, trainingFalse) # 3. 对分类输出做 sigmoid得到置信度 pred_scores tf.sigmoid(pred_logits) # 4. 对回归输出做先验框解码 decoded_boxes decode_boxes(pred_offsets, default_boxes, img_h, img_w) # 5. 把解码后的框和置信度一起交到后处理流程比如 NMS final_boxes nms(decoded_boxes, pred_scores) return final_boxes在这里最容易被忽略的是顺序解码一定要在计算分类置信度之后、做 NMS 之前。NMS 需要用到预测框之间的 IoU而 IoU 是在真实坐标空间里计算的。如果你拿着未解码的偏移量去算 IoU得到的结果毫无意义。我见过有人图省事想直接在归一化空间里做 NMS然后输出的时候再转换这也不是不行但一定要保证所有的框都在同一个坐标空间里比较。最稳妥的做法还是一上来就解码成像素坐标后面所有的操作都基于像素坐标进行不做来回切换。另外提醒一点此处pred_scores的形状一般是[batch, num_anchors, num_classes]而decoded_boxes的形状是[batch, num_anchors, 4]。在进入 NMS 时你需要对每个类别分别筛选置信度高于阈值的框。多数开源实现会先用一个min_score_thresh做一个粗筛把大量低置信度的框快速过滤掉只保留少量候选框参与 NMS。这一步对推理速度的优化非常明显因为 NMS 的复杂度跟候选框数量有关成千上万个框如果全都送去排序耗时难以接受。4. 验证解码结果把先验框和预测框画在一起看差异代码写完之后你一定会问一个问题我怎么知道我解码对没对最简单也最直观的验证方法是画图。画图不是为了好看而是为了让你能直观地看到每个预测框相对于先验框是怎么移动、怎么缩放的。我通常会这样做取一张测试图片用训练好的模型跑前向拿到解码后的预测框。然后把预测框和对应的先验框画在同一张图上。具体来说是让每个先验框的中心和偏移后的预测框中心用一条线连起来这样你可以看到位移的方向和幅度是否符合直觉。如果你发现先验框的中心和预测框的中心完全重合、没有任何移动只有框的大小在变那有可能是你的网络只学会了调整尺度而没有学好定位。反过来如果预测框全部冲向图像的某个角落那多半是中心解码公式的符号错了或者训练标签编码时中心坐标没有减半处理。更严谨一点你可以在一个极小的测试集上计算解码框与 ground truth 的 IoU。如果绝大多数样本的最高 IoU 都大于 0.5说明解码基本正确。如果最高 IoU 低于 0.3那么大概率是解码环节出的问题而不是模型收敛问题。我用这个方法排查过很多次十次里有八次能定位到 bug。还有一个我特别推荐的小技巧人工构造一个极端简单的输入。比如你把模型的回归输出全部强制为 0即pred_offsets tf.zeros_like(pred_offsets)。此时解码结果应该就是先验框本身的位置和大小。如果你的解码函数输出和先验框画出来完全重合说明你的解码流程至少在零偏移情况下是自洽的。然后再把某一个分量改成固定值比如让dx1.0那预测框的中心应该相对于先验框中心向右移动一个先验框宽度的大小。用这种方式逐分量验证本质上是在做单元测试能快速定位到底是哪一步算坏了。用 TensorFlow 写这种验证代码一点都不复杂核心就是把解码函数单独拎出来喂假数据。这个习惯我建议你从一开始就养成不要等到训完模型再来查解码问题那会非常痛苦。5. 实际使用中的坑尺度、批量、归一化还有模块间约定接下来这部分是我觉得最有价值的内容因为全是实际调试中踩出来的经验。解码公式就四行但真正落地到项目里各种隐蔽问题接踵而至。5.1 坐标尺度不统一最常见的隐形杀手这是我认为最隐蔽的坑。很多人训练完模型loss 降得挺低验证集 mAP 也能看但一到画框就发现框偏了——不是偏一点而是整个框画在了完全错误的位置。排查到最后发现问题出在模型输出层和损失函数代码的坐标尺度约定不一致。我遇到过一个情况模型输出的回归量是归一化到图像尺寸的偏移量但训练时的 ground truth 是在resize之后的图上标注的比如 300x300而推理时却用了完全不同的尺度标准。有人可能会说偏移量是相对的跟绝对尺度无关呀——这话对了一半。中心点的偏移量确实是跟图像尺寸无关的因为它是相对量但宽高的 log 偏移量同样也是相对量。问题往往出在归一化操作是否真的执行了。比如某个参考代码在数据预处理时把 512x512 的图缩放到 300x300 之后再归一化坐标到一个 0-1 区间。而你为了图方便直接用 512 作为img_w和img_h去解码。如果此时网络内部并没有归一化所有坐标都是 300 量级的像素值你把它当成 512 量级的去处理最后画出来的框比例自然不对。处理方式其实很简单写一个统一的坐标变换工具类所有模块都从同一个地方获取img_h和img_w不要在每个环节都自己写一遍乘除法。5.2 批量维度处理容易翻车的隐式广播TensorFlow 的广播机制很强大但也容易在批量维度上出问题。假设你有batch_size4的输入pred_offsets形状是[4, num_anchors, 4]而default_boxes形状是[num_anchors, 4]。直接做prior_cx default_boxes[..., 0]会得到一个[num_anchors]的一维张量和dx pred_offsets[..., 0]这个[4, num_anchors]的二维张量做乘法时你会得到一个[4, num_anchors]的结果吗答案是可以的TensorFlow 会把[num_anchors]广播成[4, num_anchors]。但如果你不小心把default_boxes的形状搞成了[1, num_anchors, 4]然后取[..., 0]得到的是[1, num_anchors]再和[4, num_anchors]做乘法也能广播成功因为末尾维度对齐。真正的坑在于如果你的先验框生成逻辑里有某个reshape操作不小心把维度交换了一下比如变成了[num_anchors, 1, 4]那么取[..., 0]得到[num_anchors, 1]和[4, num_anchors]相乘时这两种形状是无法广播的会直接报错。虽然报错比静默出错要好但更隐蔽的情况是先验框形状是[num_anchors, 1]偏移量是[4, num_anchors]因为num_anchors恰好等于 4 的倍数有可能以一种非常扭曲但合法的方式对齐结果算出来的框每个位置都错。所以我建议在解码之前先加一行断言tf.debugging.assert_equal(tf.shape(dx)[-1], tf.shape(prior_cx)[-1])这个操作不费多少性能但能帮你尽早发现维度错位。我一直认为调试维度问题最大的成本是发现不对的过程而不是改代码的过程。提前加断言、加形状打印能让调试效率翻倍。5.3 先验框数量的一致性模型结构改了解码忘了更新这个问题我在做实际项目时踩过不止一次你换了一个更强的 backbone或者调整了特征金字塔的层数网络输出的num_anchors数量变了。但是你的先验框生成代码还停留在旧的配置上导致解码时pred_offsets和default_boxes第一维不同。TensorFlow 会直接崩溃这还好。怕的是你只改了主干网络特征图尺寸没变但每个位置的 anchor 数量变了比如从 5 个改成了 9 个此时num_anchors恰好可以整除于是乘法能通过维度检查但语义上每个位置对应的先验框已经错乱了画出来的框自然也是乱飘。解决这个问题的最好方法是把先验框生成也纳入模型定义的一部分让模型结构变化时先验框数量自动跟着变。举个例子你用特征金字塔的话每一层的输出特征图长宽是确定的你可以根据特征图的尺寸动态生成先验框而不是在模型外硬编码一套数值。这样至少能保证先验框数量和模型输出的数量天然一致。我通常会在模型初始化后打印一行日志例如 Total number of anchors: 8732如果这个数值和预期不符说明某个环节的配置同步出了问题。这个日志有时能救命。5.4 与后处理模块的通信协议谁来裁剪、谁来归一化最后再说一个我在团队协作中发现的问题解码函数输出的是像素坐标但 NMS 模块内部很多实现为了计算方便会先把坐标归一化到 0-1 区间再算 IoU。如果解码模块输出之前自己裁剪了一遍超出图像边界的框而 NMS 模块又在归一化后在 0-1 区间外做裁剪两边标准不一致结果就可能在边界框的处理上产生细微偏差让 mAP 掉那么零点几个点。我的建议是解码函数只做坐标变换不做任何裁剪操作。裁剪边界、过滤超小框这些逻辑统一放在后处理流程里做。这样每个模块的职责单一出了问题也容易定位。你甚至可以给整个检测 pipeline 定义一个明确的数据约定解码之后的数据结构是一个字典包含boxes和scores和classes三个字段。所有人都按这个约定来对接检查代码时一眼就能看出哪个环节越权了。6. 配合训练阶段的编码逻辑保证前后端对称前面反复强调过编码-解码对称性这一节我想更系统地讲一下因为这是很多半路出家的开发者最容易忽略的地方。你在训练脚本里写损失函数通常需要把 ground truth 编码成和网络输出一致的偏移量形式才能算 loss。编码代码往往长这样def encode_boxes(gt_boxes, default_boxes): gt_boxes : 形状 [num_gt, 4]格式为 (xmin, ymin, xmax, ymax)像素坐标 default_boxes : 形状 [num_anchors, 4]格式为 (cx, cy, w, h)归一化坐标 # 把 gt 转成中心点形式 gt_cx (gt_boxes[:, 0] gt_boxes[:, 2]) / 2.0 gt_cy (gt_boxes[:, 1] gt_boxes[:, 3]) / 2.0 gt_w gt_boxes[:, 2] - gt_boxes[:, 0] gt_h gt_boxes[:, 3] - gt_boxes[:, 1] # 只保留与 gt IoU 最大的那些先验框此处省略匹配逻辑 # 假设已经拿到了匹配结果 matched_priors, matched_idxs dx (gt_cx - prior_cx) / prior_w dy (gt_cy - prior_cy) / prior_h dw tf.math.log(gt_w / prior_w) dh tf.math.log(gt_h / prior_h) return dx, dy, dw, dh解码公式和编码公式必须是一对逆运算。如果你在训练时用了(gt_cx - prior_cx) / prior_w那解码时就必须是prior_cx dx * prior_w。如果你不小心在解码时写成了prior_cx dx * 0.1或者别的缩放系数除非你训练时也用了同样的 0.1 系数否则结果必然对不上。这个对称性的含义用一句话概括就是解码是编码的逆函数同增同减一一对应。为了确保对称有一个非常取巧的做法把编码和解码函数放在同一个 Python 文件里并且互相对着写。每次改编码逻辑时就强迫自己看一眼前面对应的解码逻辑。我甚至会写一个roundtrip测试——随机生成一些框先编码再解码看还原出来的坐标跟原始坐标的误差是否接近于 0。这种测试能覆盖大部分坐标变换 bug写一次能用很久。7. 进阶思路为什么解码要和 NMS 联动以及怎么提速讲完了基础解码和避坑最后聊聊一个稍微进阶的话题。在实际的检测推理流程中解码往往不是整个 pipeline 的瓶颈但它被调用的频率很高尤其是num_anchors特别多的模型比如 SSD 在 300x300 输入下有 8732 个先验框RetinaNet 甚至更多。如果你在循环里对每张图都调用一次解码而解码过程中包含了大量的tf.exp和tf.stack堆叠操作在某些计算图上会产生不少额外开销。一个常用的提速小技巧是把解码函数用tf.function包起来decode_boxes_tf tf.function(decode_boxes, input_signature[ tf.TensorSpec(shape[None, None, 4], dtypetf.float32), tf.TensorSpec(shape[None, 4], dtypetf.float32), tf.TensorSpec(shape[], dtypetf.int32), tf.TensorSpec(shape[], dtypetf.int32), ])用input_signature显式声明输入张量形状可以让 TensorFlow 在第一次调用时生成一个优化计算图后续调用不再走 Python 的调度开销。这个方法可以在模型推理的批量场景中省掉不少微秒级别的延迟。需要注意的是如果你在解码之后马上接tf.image.non_max_suppression有时候可以把tf.exp、tf.stack的中间结果在 GPU 上保留减少一次 CPU-GPU 之间的数据拷贝。这种优化效果在小 batch 上最明显大 batch 反而影响不大原因在于数据拷贝的开销在总量中占比会下降。另外一个和 NMS 联动的角度是解码之后你可能需要对成千上万个框做置信度阈值过滤。如果把过滤操作也放到 GPU 上先执行一遍只把少量候选框拷贝回 CPU 做 NMS速度会快很多。现在的 TensorFlow 有tf.image.combined_non_max_suppression可以直接把解码、置信度过滤、多类别 NMS 一次性封装起来。但我不建议你一开始就用这么高级的封装等基础版流程跑通了再考虑逐步替换这样每一步都可以用肉眼验证输出结果是否合理。最后提醒一个容易忽略的实际问题当 batch size 大于 1 时先验框的解码结果数量是固定的但每张图经过过滤后保留下来的框数量可能不一样。NMS 对这种变长结果的处理很麻烦通常需要 padding 到统一长度。如果你在 Decode 并过滤之后直接把结果送入tf.image.non_max_suppressionTensorFlow 要求框的数量是固定的这时你需要给过滤操作设置一个max_output_size并额外生成一个valid_detections掩码标记哪些位置的框是真正有效的。我在实际项目里就是踩过这个坑才意识到一个简单的解码函数一路走到后处理中间有这么多细节要处理。先验框解码本身不难但它和前后模块的对接非常微妙。我的经验是一次性把编码-解码-NMS-评估整条链路跑通并验证清楚比单独把解码函数做得再花哨都重要。以后你在自己的项目中无论换什么模型结构、什么输入尺寸只要这条链路的坐标约定不变解码逻辑就永远不用动。真到了需要动的那一天记得看一眼对应的编码代码——把两个逆操作放在一起对比着改比对着公式记忆要可靠得多。