本篇介绍常用的视频目标分割的评估方法,即文章 CVPR2016: A Benchmark Dataset and Evaluation Methodology for Video Object Segmentation 中提出的 DAVIS 数据集和评估指标

  1. Region Similarity $\mathcal{J}$
  2. Contour Accuracy $\mathcal{F}$
  3. Temporal stability $\mathcal{T}$

在 2017 年及以后,DAVIS 竞赛保留的主要评估方法是 Region Similarity $\mathcal{J}$ 和 Contour Accuracy $\mathcal{F}$。

代码

github 官方代码:

除了 Python 版本的代码,官方还提供了 MATLAB 版本代码,这里使用 Python 2017 优化版进行介绍,注意官方代码采用 python 2.x

在监督评估框架中,给定特定帧上的真实(groundtruth)掩码 $G$ 和输出分割 $M$,任何评估措施最终都必须回答 $M$ 与 $G$ 拟合程度如何的问题。对于图像,可以使用两个互补的图像观点:基于区域和基于轮廓的措施。

环境配置

1
2
# !git clone https://github.com/fperazzi/davis-2017.git
# %pip install logger PyYAML easydict numpy scipy matplotlib opencv-python scikit-image

评估指标

1
2
import os
import sys
1
2
work_dir = "/disk0/documents/python/nb/davis-2017/python/lib/davis/measures"
sys.path.insert(0, work_dir)
1
sys.path
['/disk0/documents/python/nb/davis-2017/python/lib/davis/measures',
 '/usr/local/miniconda/envs/davis/lib/python310.zip',
 '/usr/local/miniconda/envs/davis/lib/python3.10',
 '/usr/local/miniconda/envs/davis/lib/python3.10/lib-dynload',
 '',
 '/home/jinzhongxu/.local/lib/python3.10/site-packages',
 '/home/jinzhongxu/.local/lib/python3.10/site-packages/feelvos-0.5-py3.10.egg',
 '/usr/local/miniconda/envs/davis/lib/python3.10/site-packages']

Region Similarity $\mathcal{J}$

为了测量基于区域的分割相似度,即错误标记像素的数量,使用 Jaccard 索引 $\mathcal{J}$ 定义为估计的分割和 groundtruth 掩模的交集过并。Jaccard 索引自 PASCAL VOC2008 中首次出现以来就被广泛采用,因为它提供了关于错误标记像素数量的直观、尺度不变的信息。给定一个输出分割 $M$ 和相应的 groundtruth 掩码 $G$,它被定义为:

$$ \mathcal{J} = \frac{|M \cap{G}|}{|M \cup{G}|} $$

直白的说,区域相似度就是针对某一帧,计算标注的真实掩膜与视频目标分割算法得到的掩膜,这两幅(0,1)二值图像之间的 jaccard 相似系数。

需要说明的是,官方代码使用的 numpy 版本过低,如果使用高版本的 numpy,请将代码中 np.bool 改成 np.bool_

1
2
3
4
import cv2
import jaccard
import matplotlib.pyplot as plt
import numpy as np
1
2
ann_path = "/disk0/proj-pf/track-cutie/data/davis/annotation/000.png"
seg_path = "/disk0/proj-pf/track-cutie/data/davis/result/davis-dolphins.mp4_mask_00.png"
1
2
ann = cv2.imread(ann_path)
seg = cv2.imread(seg_path)
1
2
plt.imshow(ann)
plt.show()

png

1
2
plt.imshow(seg)
plt.show()

png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 只取最后一个图层
seg_r = seg[..., -3]
ann_r = ann[..., -3]

# 遮挡部分前景,假设分割质量差
seg_r[:, 200:400] = 0

# 把像素值限制到 0,1
seg_r[seg_r > 1] = 1
ann_r[ann_r > 1] = 1

# 转成布尔型
seg_bin = seg_r.astype(np.bool_)
ann_bin = ann_r.astype(np.bool_)
1
2
plt.imshow(np.hstack([seg_r, ann_r]), cmap="gray")
plt.show()

png

1
jaccard.db_eval_iou(annotation=ann_bin, segmentation=seg_bin)
0.9267666392769104

除了对于单帧的区域相似度评估,还给出了对于一个视频的区域相似度评估,分别从三个角度进行算法评估(如果是半监督 VOS,那么给出的第一帧 groundtruth 帧被忽略):

  • mean: 一个视频序列所有帧的 $\mathcal{J}$ 值加起来求算数平均,最终结果是该视频的 $\mathcal{J}$-mean 值。该值越高说明算法越好;
  • recall: 一个视频序列所有帧的 $\mathcal{J}$ 值超过一定阈值(如 0.5)的帧的个数(不是 $\mathcal{J}$ 值)加起来求算数平均(分母为总帧数),最终结果是该视频的 $\mathcal{J}$-recall 值。该值越高说明算法越好;
  • delay: 一个视频序列所有帧的前四分之一帧的 $\mathcal{J}$ 平均值减去后四分之一帧的 $\mathcal{J}$ 平均值,最终结果是该视频的 $\mathcal{J}$-delay 值。该值越低说明算法越好。一般情况下,视频前面帧的跟踪和分割效果比后面帧好,所以该值应该大于 0,越接近 0越好。

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import numpy as np

def db_eval_iou(annotation,segmentation):

""" Compute region similarity as the Jaccard Index.

Arguments:
annotation (ndarray): binary annotation map.
segmentation (ndarray): binary segmentation map.

Return:
jaccard (float): region similarity

"""

annotation = annotation.astype(np.bool_)
segmentation = segmentation.astype(np.bool_)

if np.isclose(np.sum(annotation),0) and np.isclose(np.sum(segmentation),0):
return 1
else:
return np.sum((annotation & segmentation)) / \
np.sum((annotation | segmentation),dtype=np.float32)

Contour Accuracy $\mathcal{F}$

从基于等高线的角度来看,可以将 $M$ 解释为一组封闭的等高线 $c(M)$,这些等高线划定了掩模的空间范围。因此,可以计算基于轮廓的精度,并通过二部图匹配来召回 $c(M)$ 和 $c(G)$ 的轮廓点之间的 $P_c$ 和 $R_c$,以便对小的不准确性具有鲁棒性。作者认为所谓的 F-measure $\mathcal{F}$ 是两者之间的一个很好的权衡,定义为:

$$ \mathcal{F} = \frac{2 P_c R_c}{P_c + R_c} $$

为了提高效率,在实验中,通过形态学算子近似二分匹配。

直白的说,轮廓精度就是针对某一帧,先分别计算标注的真实掩膜和视频目标分割算法得到的掩膜的轮廓,针对两幅轮廓图,分别计算精确率 $P$ 和召回率 $R$,最终计算两者的调和平均值得到 $F$-score。

需要说明的是,官方代码使用的 numpy 版本过低,如果使用高版本的 numpy,请将代码中 np.bool 改成 np.bool_

1
import f_boundary
1
2
3
ann_map = f_boundary.seg2bmap(seg=ann_bin)
plt.imshow(ann_map, cmap="gray")
plt.show()

png

1
2
# 查看边界细节
print(ann_map[390:400, 315:327])
[[False False False False False False False False False False False False]
 [False False False False False False False False False False False False]
 [False False False False False False False False False False False False]
 [False False False False False False False False False False False False]
 [False False False False False False False False False False  True  True]
 [False False False False False False False False  True  True  True False]
 [False False False False False False  True  True  True False False False]
 [False False  True  True  True  True  True False False False False False]
 [False False  True  True  True  True  True  True  True  True  True  True]
 [False False False False False False False False False False False False]]
1
seg_map = f_boundary.seg2bmap(seg=seg_bin)
1
type(seg_map), seg_map.shape
(numpy.ndarray, (480, 854))
1
2
plt.imshow(seg_map, cmap="gray")
plt.show()

png

1
f_boundary.db_eval_boundary(foreground_mask=seg_bin, gt_mask=ann_bin)
0.8924810970352192

类似于区域相似度,除了对于单帧的轮廓精度评估,还给出了对于一个视频的轮廓精度评估,分别从三个角度进行算法评估(如果是半监督 VOS,那么给出的第一帧 groundtruth 帧被忽略):

  • mean: 一个视频序列所有帧的 $\mathcal{F}$ 值加起来求算数平均,最终结果是该视频的 $\mathcal{F}$-mean 值。该值越高说明算法越好;
  • recall: 一个视频序列所有帧的 $\mathcal{F}$ 值超过一定阈值(如 0.5)的帧个数(不是 $\mathcal{F}$值)加起来求算数平均(分母是总帧数),最终结果是该视频的 $\mathcal{F}$-recall 值。该值越高说明算法越好;
  • delay: 一个视频序列所有帧的前四分之一帧的 $\mathcal{F}$ 平均值减去后四分之一帧的 $\mathcal{F}$ 平均值,最终结果是该视频的 $\mathcal{F}$-delay 值。该值越低说明算法越好。一般情况下,视频前面帧的跟踪和分割效果比后面帧好,所以该值应该大于 0,越接近 0越好。

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import numpy as np

""" Utilities for computing, reading and saving benchmark evaluation."""

def db_eval_boundary(foreground_mask,gt_mask,bound_th=0.008):
"""
Compute mean,recall and decay from per-frame evaluation.
Calculates precision/recall for boundaries between foreground_mask and
gt_mask using morphological operators to speed it up.

Arguments:
foreground_mask (ndarray): binary segmentation image.
gt_mask (ndarray): binary annotated image.

Returns:
F (float): boundaries F-measure
P (float): boundaries precision
R (float): boundaries recall
"""
assert np.atleast_3d(foreground_mask).shape[2] == 1

bound_pix = bound_th if bound_th >= 1 else \
np.ceil(bound_th*np.linalg.norm(foreground_mask.shape))

# Get the pixel boundaries of both masks
fg_boundary = seg2bmap(foreground_mask);
gt_boundary = seg2bmap(gt_mask);

from skimage.morphology import binary_dilation,disk

fg_dil = binary_dilation(fg_boundary,disk(bound_pix))
gt_dil = binary_dilation(gt_boundary,disk(bound_pix))

# Get the intersection
gt_match = gt_boundary * fg_dil
fg_match = fg_boundary * gt_dil

# Area of the intersection
n_fg = np.sum(fg_boundary)
n_gt = np.sum(gt_boundary)

#% Compute precision and recall
if n_fg == 0 and n_gt > 0:
precision = 1
recall = 0
elif n_fg > 0 and n_gt == 0:
precision = 0
recall = 1
elif n_fg == 0 and n_gt == 0:
precision = 1
recall = 1
else:
precision = np.sum(fg_match)/float(n_fg)
recall = np.sum(gt_match)/float(n_gt)

# Compute F measure
if precision + recall == 0:
F = 0
else:
F = 2*precision*recall/(precision+recall);

return F

def seg2bmap(seg,width=None,height=None):
"""
From a segmentation, compute a binary boundary map with 1 pixel wide
boundaries. The boundary pixels are offset by 1/2 pixel towards the
origin from the actual segment boundary.

Arguments:
seg : Segments labeled from 1..k.
width : Width of desired bmap <= seg.shape[1]
height : Height of desired bmap <= seg.shape[0]

Returns:
bmap (ndarray): Binary boundary map.

David Martin <dmartin@eecs.berkeley.edu>
January 2003
"""

seg = seg.astype(np.bool_)
seg[seg>0] = 1

assert np.atleast_3d(seg).shape[2] == 1

width = seg.shape[1] if width is None else width
height = seg.shape[0] if height is None else height

h,w = seg.shape[:2]

ar1 = float(width) / float(height)
ar2 = float(w) / float(h)

assert not (width>w | height>h | abs(ar1-ar2)>0.01),\
'Can''t convert %dx%d seg to %dx%d bmap.'%(w,h,width,height)

e = np.zeros_like(seg)
s = np.zeros_like(seg)
se = np.zeros_like(seg)

e[:,:-1] = seg[:,1:]
s[:-1,:] = seg[1:,:]
se[:-1,:-1] = seg[1:,1:]

b = seg^e | seg^s | seg^se
b[-1,:] = seg[-1,:]^e[-1,:]
b[:,-1] = seg[:,-1]^s[:,-1]
b[-1,-1] = 0

if w == width and h == height:
bmap = b
else:
bmap = np.zeros((height,width))
for x in range(w):
for y in range(h):
if b[y,x]:
j = 1+floor((y-1)+height / h)
i = 1+floor((x-1)+width / h)
bmap[j,i] = 1;

return bmap

mean, delay, recall 核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import numpy as np
import warnings

def mean(X):
"""
Compute average ignoring NaN values.
"""

return np.nanmean(X)

def recall(X,threshold=0.5):
"""
Fraction of values of X scoring higher than 'threshold'
np.array(X)>threshold 返回是 np.array([True, True, False])
"""
return mean(np.array(X)>threshold)

def decay(X,n_bins=4):
"""
Performance loss over time.
一般下一个 bin 的第一个元素和上一个 bin 的最后一个元素相同
"""

ids = np.round(np.linspace(1,len(X),n_bins+1)+1e-10)-1;
ids = ids.astype(np.uint8)

D_bins = [X[ids[i]:ids[i+1]+1] for i in range(0,4)]

with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
D = np.nanmean(D_bins[0])-np.mean(D_bins[3])
return D

def std(X):
"""
Compute standard deviation.
"""
return np.std(X)

_statistics = {
'decay' : decay,
'mean' : mean,
'recall': recall,
'std' : std
}

def get(name):
return __statistics[name]

Temporal stability $\mathcal{T}$

直观上,$\mathcal{J}$ 衡量两个掩码的像素匹配程度,而 $\mathcal{F}$ 衡量轮廓的准确性。然而,结果的时间稳定性是视频对象分割的一个相关方面,因为对象形状的演变是识别和抖动的重要线索,不稳定的边界在视频编辑应用中是不可接受的。因此,作者 2016 最初提出评估指标时另外引入了一个时间稳定性措施来惩罚这种不希望的影响。

关键的挑战是区分物体的可接受运动和不希望的不稳定和抖动。为了做到这一点,估计从一帧到下一帧转换掩码所需的变形。从直观上看,如果变换是平滑和精确的,则可以认为结果是稳定的。

形式上,将帧 $t$ 的掩模 $M_t$ 转换成代表其轮廓 $P(M_t)$ 的多边形。然后使用形状上下文描述符(SCD)来描述每个点 $p_t^i \in p(M_t)$。接下来,将匹配作为一个动态时间扭曲(DTW)问题,寻找 $p_t^i$ 和 $p_{t+1}^j$ 之间的匹配,以最小化匹配点之间的 SCD 距离,同时保持点在形状中存在的顺序。

得到的每个匹配点的平均代价被用作时间稳定性 $\mathcal{T}$ 的度量。直观地说,匹配将补偿运动和小的变形,但它不会补偿轮廓的振荡和不准确性,这是想要测量的。咬合和非常强烈的变形会被误解为轮廓不稳定,因此在没有这种影响的序列子集上计算测量。

但是,DAVIS 2017 开始,不再把该指标作为竞赛的评判依据。

MATLAB 版的时间稳定性调用非常方便,但是 Python 版的因为是使用 C++ 开发,需要编译后才能使用,一种编译方式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
git clone https://github.com/fperazzi/davis-2017.git
cd davis-2017
wget https://boostorg.jfrog.io/artifactory/main/release/1.78.0/source/boost_1_78_0.tar.gz
tar -xzvf boost_1_78_0
mv boost_1_78_0 boost
cd boost
# 这里 python 环境已经设置为运行 davis 的版本,如 conda activate davis
sudo ./bootstrap.sh --with-libraries=python
sudo ./b2
sudo ./b2 install
sudo ldconfig
cd ..

./configure.sh && make -C build/release

此时,在 davis-2017/build/release 下面就可以看到编译好的 tstab.so

参考文献

  1. 视频目标分割数据集DAVIS(Denly-Annotated VIdeo Segmentation)解读
  2. 视频目标分割VOS的评价指标J&F
  3. 用于视频对象分割的深度学习:Ch03 数据集
  4. DAVIS 目标分割数据集
  5. data_VOS