COCO → IoU → OKS → AP:读懂姿态估计论文里的那些数字

读一篇动物姿态估计的数据集论文时,我发现表格里全是 AP、mAP、OKS,却说不清这些数字是怎么来的。往下追才发现,它们的源头是一份 COCO 格式的标注文件:分数怎么算,很大程度上取决于标注里的字段怎么写。

所以这篇笔记分三部分:先把 COCO 格式这个根基打牢,再从 IoU 一路讲到 AP,最后把这些指标放回数据集项目里看。文中的数值例子都是构造出来的,可以直接复制运行。

第一部分:COCO 根基

COCO 格式是什么?

COCO(Common Objects in Context)本来是微软在 2014 年发布的图像数据集,覆盖检测、实例分割、关键点等任务。它最有影响力的地方不是图片本身,而是它定下的几套约定:

  1. 一套 JSON 标注写法。后来大部分检测、分割、姿态数据集都照着它存标注,pycocotools、MMPose 等工具可以直接读取;
  2. 一套评测指标,包括检测和分割的 AP,以及关键点的 OKS 和 AP,第二部分会讲;
  3. 一个公共基准,大家的结果可以直接比较。

所以平时说的「COCO 格式」,就是一个约定:用一个 JSON 文件说清楚有哪些图、每张图里有哪些目标、每个目标是怎么标的。

一个 COCO 文件由哪几部分组成?

关键点任务的文件,顶层通常只有三个键:

键 一条记录代表
images 一张图
annotations 一个目标实例,比如一只动物
categories 一个类别

可以把它们看成数据库里的三张表,靠 id 连在一起:

images.id      <──  annotations.image_id
categories.id  <──  annotations.category_id

annotations 通常比 images 多,因为一张图里可以有好几只动物。在动物数据集里,多只动物同框的场景非常常见。

images:一张图一条

{"id": 1, "file_name": "buffalo/buffalo_0083.jpg", "width": 1920, "height": 1080,
 "species": "african_buffalo"}

id、file_name、width、height 是标准字段。species 这类字段是数据集自己加的。COCO 允许加自定义字段,工具读不懂的字段会直接跳过。

annotations:一个实例一条

这是最重要的一张表。一条关键点标注大致长这样(数值为示意):

{"id": 1, "image_id": 1, "category_id": 1,
 "bbox": [847.0, 200.0, 192.0, 143.0],
 "area": 27456.0,
 "iscrowd": 0,
 "num_keypoints": 12,
 "keypoints": [983.0, 265.3, 2,  918.8, 269.0, 2,  0, 0, 0,  "..."]}

逐个字段看。

image_id:这只动物在哪张图里,对应 images 里的 id。

bbox = [x, y, w, h]:左上角坐标加宽和高,单位是原图像素。这是最容易踩的坑:它不是 [x1, y1, x2, y2]。上面这个框的右下角是 \((847+192,\ 200+143) = (1039, 343)\)。YOLO(中心点加宽高,且归一化)和 Pascal VOC(两个对角点)用的都是别的写法,转换格式时一旦弄错,框就会整体错位。

area:目标面积。标准 COCO 里,这是分割掩码的像素面积。但有些数据集没有掩码,或者掩码另外存放,就会直接填框面积。上面的 \(27456 = 192 \times 143\) 正是框面积。这个值看着不起眼,评测时却会用到,见第二部分的「格式字段怎样改变分数」。

keypoints:一个压平的一维列表,每 3 个数表示一个点 \((x, y, v)\):

[x0, y0, v0,  x1, y1, v1,  ...,  x(K-1), y(K-1), v(K-1)]
 └─ 点 0 ─┘   └─ 点 1 ─┘         └──── 点 K-1 ────┘

\(K\) 个关键点对应 \(3K\) 个数,比如 30 个点就是 90 个数。第 \(i\) 个点是 kps[3*i : 3*i+3]。

v 是可见性标志:

v 含义
0 没标。此时 x、y 一般填 0,没有意义
1 标了,但被遮挡,看不见
2 标了,而且可见

不是每个数据集都会用满三种取值。有的数据集只有 0 和 2:标注时不区分「遮挡但能推断位置」的点,要么当可见点标出来,要么干脆不标。拿到一份数据,先统计一下 v 的分布,就知道它采用的是哪种协议。

num_keypoints:v > 0 的点有多少个。比如 30 个点里有 18 个 v = 0(从侧面看不到的部位),这个值就是 12。它应当和 keypoints 里实际的非零可见性个数一致,可以拿来做一致性检查。

iscrowd:0 表示单个目标;1 表示「一大群分不开的目标」。评测时这类真值会被忽略:漏掉它不算漏检,预测落在它上面也不算误检。

segmentation:标准 COCO 把分割掩码存在这里,可以是多边形顶点,也可以是 RLE 压缩格式。也有数据集不填这个字段,而是把每个实例的掩码单独存成 PNG,靠文件名和实例对应。

这样做的好处是 JSON 文件小、加载快,掩码可以直接用图像工具查看和修改,也能单独重新生成某一批掩码而不用动 JSON。代价是标注和掩码变成了两份分开维护的数据,没有机制保证它们同步:关键点或框改了,掩码可能没跟着更新;文件名对应规则一旦出错,掩码就会挂到别的实例上。所以这种存法最好配一道一致性检查,比如逐个实例比较掩码外接框和 bbox。

❌ 常见误解:annotations[0] 就是「第一张图里的第一只动物」

不一定。annotations 只是一个扁平列表,第 0 条可能属于任何一张图、任何一个类别。想知道它是哪张图,要用 image_id 去 images 里查。

a = d["annotations"][0]
img = next(i for i in d["images"] if i["id"] == a["image_id"])
points = [a["keypoints"][i:i+3] for i in range(0, len(a["keypoints"]), 3)]

categories:定义点叫什么、怎么连

{"id": 1, "name": "animal", "supercategory": "animal",
 "keypoints": ["kp_0", "kp_1", "...", "kp_29"],
 "skeleton": []}
  • keypoints 列出点的名字和顺序,这个顺序决定了 annotations.keypoints 里第 \(i\) 组数是哪个部位。人体 COCO 会写 nose、left_eye 这样有含义的名字;如果只写 kp_0 到 kp_29,每个编号对应哪个身体部位就要另查论文或文档。
  • skeleton 记录哪些点之间连线,比如肩连肘,只用于可视化时画骨架。留空不影响训练和评测。

格式讲清楚了,接下来看分数是怎么从这些字段里算出来的。

第二部分:从 IoU 到 AP

IoU 是什么?

IoU(Intersection over Union,交并比)衡量两个区域的重合程度:

\[\text{IoU}(A, B) = \frac{|A \cap B|}{|A \cup B|}\]

取值范围是 0 到 1。完全不重叠为 0,完全重合为 1。

为什么分母用并集,而不是其中一个区域的面积? 假设分母只用真值区域的面积,那么预测画得越大越好:画一个巨大的框把真值整个包住,就能拿满分。用并集做分母后,多画(并集变大)和少画(交集变小)都会被惩罚。

检测任务里常用的刻度:0.5 算「基本对上」,0.75 算「相当准」,0.9 以上接近完美。

预测比真值大了一圈,IoU 会是多少?

先猜一下:分割结果比动物大了一圈,但完全包住了动物,IoU 大概是多少?

我当时的猜测是「一定小于 1,而且大概在 0.5 以上」。前半句是对的,后半句需要具体算一算。

当预测 \(P\) 完全包含真值 \(T\) 时,交集就是 \(T\) 本身,公式退化成面积比:

\[\text{IoU} = \frac{|T|}{|P|}\]

用 numpy 在小格子上验证:

import numpy as np

a  = np.zeros((7, 7), bool); a[2:5, 2:5]  = True   # 真值:3×3,9 格
b1 = np.zeros((7, 7), bool); b1[1:5, 1:5] = True   # 4×4,完全包住 a
b2 = np.zeros((7, 7), bool); b2[0:5, 0:5] = True   # 5×5,完全包住 a

iou = lambda x, y: (x & y).sum() / (x | y).sum()
print(iou(a, b1))   # 0.5625 = 9/16
print(iou(a, b2))   # 0.36   = 9/25

边长只多了一两格,IoU 就从 1 掉到了 0.56 和 0.36。原因是面积随边长平方增长。如果预测把真值的宽和高都放大到原来的 \((1 + k)\) 倍并完全包住它,那么

\[\text{IoU} = \frac{1}{(1 + k)^2}\]

所以「大一圈」到底有多严重,取决于这一圈相对于物体有多大:

  • 200×200 px 的大型动物,四周各多 10 px:边长变成 220,\(k = 0.1\),IoU ≈ 0.83。
  • 20×20 px 的小动物,同样四周各多 10 px:边长变成 40,\(k = 1\),IoU = 0.25。

同样的绝对误差,在小物体上是严重错误,在大物体上只是毛边。这个「需要按物体尺寸归一化」的想法,正是后面 OKS 要解决的问题。

OKS:怎么给「关键点偏了多少」打分?

IoU 比较的是两个区域,而关键点是一个个点,需要另一把尺子。OKS(Object Keypoint Similarity)的作用相当于关键点版的 IoU。COCO 的定义是:

\[\text{OKS} = \frac{\sum_i \exp\!\left(-\dfrac{d_i^2}{2\, s^2 \kappa_i^2}\right)\, \delta(v_i > 0)}{\sum_i \delta(v_i > 0)}\]

其中:

  • \(d_i\):第 \(i\) 个关键点预测位置和真值之间的欧氏距离;
  • \(s^2\):目标的面积(COCO 用的是分割区域的面积),\(s\) 起到尺度归一化的作用;
  • \(\kappa_i = 2\sigma_i\):第 \(i\) 类关键点的容差常数;
  • \(\delta(v_i > 0)\):只统计真值里标注过的点。

每个点的得分是一个高斯核,完全重合时为 1,偏得越远越接近 0。OKS 是这些得分在标注点上的平均。

\(s\) 管的是「这只动物有多大」。 去掉 \(s\) 会怎样?同样偏了 10 px,对大个体本来是很小的误差,却会被当成和小个体一样严重。结果是大个体被冤枉,小个体被放过,两个方向一起歪。

\(\sigma_i\) 管的是「这类点本身有多难标」。 COCO 在人体关键点上让多名标注者重复标注同一批图,按每类点的标注离散程度统计出 \(\sigma\):

  • 眼睛这类位置明确的点,大家标得很一致,\(\sigma\) 小,要求严;
  • 髋部这类隔着衣服、位置模糊的点,\(\sigma\) 大,容忍度高。

用一个最小例子跑一遍。这里为了好算,令 \(s^2 = 100\)、\(\kappa = 0.1\):

import numpy as np

gt    = np.array([[10, 10], [20, 10]])
pred  = np.array([[11, 12], [20, 10]])   # 点 1 偏了 (1, 2),点 2 完全重合
kappa = np.array([0.1, 0.1])
area  = 100.0                            # s² = 100

d2  = ((gt - pred) ** 2).sum(axis=1)     # [5, 0]
oks = np.exp(-d2 / (2 * area * kappa ** 2)).mean()
print(oks)   # ≈ 0.541:点 1 得 e^-2.5 ≈ 0.082,点 2 得 1
❌ 常见误解:容差大,要求就严

方向是反的。\(\kappa_i\)(即 \(2\sigma_i\))在分母里:\(\sigma\) 越大,同样的距离扣分越少,要求越松;\(\sigma\) 越小越严。

这个方向在讨论一个问题时很关键:如果某个数据集没有逐点统计标注方差,而是给所有关键点用同一个 \(\sigma\),会发生什么?

  • 原本 \(\sigma\) 很小的点(比如眼睛)被放宽了;
  • 原本 \(\sigma\) 很大、难以精确标注的点被卡严了。

统一 \(\sigma\) 的实际效果是抹平了点与点之间的难度差异。读论文时,值得留意它的 \(\sigma\) 是怎么设的。

顺带一提,这个指标叫 OKS(Object Keypoint Similarity),不是 OKP。

格式字段怎样改变分数?

回头看 OKS 公式,它用到的东西几乎都来自第一部分讲的字段:

  • v 决定哪些点参与打分。 只有真值里 v > 0 的点进入分子和分母,v = 0 的点不管预测得多离谱都不扣分。一个点标还是不标,直接改变了这个实例的分数由哪些点决定。
  • area 决定尺度 \(s\)。 pycocotools 直接读 annotations.area 作为 \(s^2\)。标准 COCO 里它是掩码面积;如果数据集填的是框面积,而框通常比掩码大,\(s\) 就偏大,同样的像素误差扣分更少。同一个模型、同一组预测,area 的取法不同,OKS 和 AP 就会不同。
  • \(\sigma\) 不在 JSON 里。 每类点的容差写在评测代码中。换一套 \(\sigma\),分数也会变。

所以「看懂 COCO 格式」不只是能把 JSON 读出来,还要知道每个字段会怎样改变最终的分数。比较两篇论文的数字前,值得先确认它们的 area 和 \(\sigma\) 是不是同一种取法。

有了单个实例的 OKS,下一步是把它汇总成整个测试集上的一个分数。

从 OKS 到 AP:论文里的数字是怎么组装出来的?

这一步分三层。

第一层:precision 和 recall。 举个例子:图里有 10 只动物,模型给出 5 个预测,其中 4 个正确。

  • precision = 4/5 = 0.8:模型说的话有多可信;
  • recall = 4/10 = 0.4:该找到的找到了多少。

两个数缺一不可。

第二层:在给定阈值 τ 下画 PR 曲线。 每个预测都有一个置信度分数,这是模型自己给出的「我有多确定」。计算步骤如下:

  1. 把所有预测按置信度从高到低排序;
  2. 依次和尚未被匹配的真值配对。OKS ≥ τ 记为 TP,否则记为 FP;
  3. 每处理一个预测,就记录一次累计的 (recall, precision);
  4. 把这些点连成 PR 曲线,曲线下的面积就是 \(\text{AP}_\tau\)。COCO 的实现会先对 precision 做「向右取最大值」的插值,再在 101 个 recall 点上取平均。

第三层:对多个阈值求平均。 COCO 的主指标 AP 是 τ 取 0.50, 0.55, …, 0.95 共 10 档的 \(\text{AP}_\tau\) 的平均值,同时单独报告 AP50 和 AP75。

注意:不同论文对「AP」和「mAP」的命名并不统一。有的把 AP50 叫 AP、把 10 档平均叫 mAP。读表格之前要先找到论文对指标的定义。

❌ 常见误解:按 OKS 从高到低排序来画 PR 曲线

我学的时候做过一道练习,是把预测按 OKS 排序再累计。这样排的话,所有 TP 一定排在 FP 前面,precision 永远是先保持 1 再下降,曲线被画得过于乐观。

真实评测按模型的置信度排序。OKS 只用来判定每个预测是 TP 还是 FP。AP 衡量的其中一件事,正是「模型给高分的预测是否真的更准」。

一个对比例子:6 个真值,6 个预测,τ = 0.5。

置信度排名 1 2 3 4 5 6
OKS 0.90 0.30 0.82 0.70 0.12 0.55
判定 TP FP TP TP FP TP
precision 1.00 0.50 0.67 0.75 0.60 0.67
recall 1/6 1/6 2/6 3/6 3/6 4/6

先对 precision 做插值,即每个位置取它右侧的最大值。在 recall 为 1/6、2/6、3/6、4/6 处,插值后的 precision 分别是 1、0.75、0.75、0.67,因此

\[\text{AP}_{0.5} \approx \tfrac{1}{6}(1 + 0.75 + 0.75 + 0.67) \approx 0.53\]

如果按 OKS 排序,4 个 TP 全排在前面,会得到 4/6 ≈ 0.67。两者的差距,就是「置信度排序不够好」被扣掉的分数。另外,recall 最多到 4/6,因为有两个真值始终没有被任何预测以 OKS ≥ 0.5 覆盖。

一句话总结这条链:IoU 和 OKS 是尺子,AP 是成绩单。 检测和分割任务用 IoU 判断命中,关键点任务用 OKS 判断命中,后面的 AP 组装流程是同一套。

第三部分:放回数据集项目里看

这些指标到底在拿谁和谁比?

学到这里我产生了一个疑问:项目里的「真值」本身也是模型生成后再人工检查的,那 IoU 和 OKS 比的到底是什么?

关键在于区分两个阶段。

阶段一:生产数据集。 关键点由人工标注;分割掩码之类的辅助标注可能由模型先生成候选,再经过人工核查才转为真值。这个阶段的工作是检查数据集本身的质量,并不涉及训练任何姿态模型。

阶段二:用数据集评测模型。 训练好的姿态估计模型在测试集上做预测,用 OKS 和真值比较,汇总成 AP。论文表格里的数字基本都来自这个阶段。

同一个公式,因此可能出现在三个不同的场合:

比较 谁和谁比 目的
方案对比 新方案生成的掩码 vs 旧方案生成的掩码 看差异有多大,挑方案,不是打分
质量核查 候选标注 vs 图上真实的动物 决定候选能否转为真值
模型评测 模型预测 vs 真值 打分,即论文里的 AP

两个阶段也有关联。数据集论文里的模型评测分数,常被间接当作标注质量的证据:如果标注很差,很难训练出高分模型。而人工核查得出的错误率,是对数据质量更直接的测量。

这个数据集最终在训练什么?

以 2D 动物姿态估计为例,最常见的是 top-down 流程:

  1. 检测器先找出图里的每只动物,给出 bbox;
  2. 按 bbox 裁出单只动物的图像;
  3. 关键点模型对裁剪图输出 \(K\) 个关键点。常见做法是对每个关键点输出一张热力图,取峰值位置作为坐标;
  4. 把坐标映射回原图。

所以模型的输入是图像(加上框),输出是每只动物的 \(K\) 个关键点坐标。很多论文在报告关键点精度时会直接用真值 bbox 裁图,这样就绕开了检测这一步。读论文时要注意这一点:这类数字衡量的是「框给定的情况下关键点定得准不准」,不是完整的端到端能力。

参考

  • T.-Y. Lin et al. Microsoft COCO: Common Objects in Context. ECCV, 2014.
  • COCO 官方说明:Keypoint Evaluation
  • pycocotools 源码 cocoeval.py:OKS 与 AP 的参考实现



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • World Model 与 VLA:具身智能的两条路线,以及从 3D 视觉出发的视角
  • SAM 速览:为什么图像只编码一次
  • 用 SIFT + RANSAC 做尺寸校准,以及怎么判断「对得准不准」
  • SIFT 自学笔记:尺度不变到底保证了什么