0%

PSENet详解

PSENet实现细节的详细解读。


主要想法

  1. 主要是解决 距离较近的 文字之间的定位问题。
  2. 耗时主要用在后处理上: 渐进放大。而DB就是为了解决该问题,设计了一个端到端的架构。这和现在DETR去替代Faster Rnn这类需要生成anchor,NMS,RoI Align等处理的网络类似。现在都 朝着 端到端的网络 演变。
  3. 每个文字区域,生成7个kernel,然后从里往外放大。
  4. 文本贡献:PSENET是可以检测任意形状的文字、可分离 距离很近的文字、且效果很好的 文字检测算法。
  5. 缺点:
    1. 慢。
  6. 时效:对于生产图片,文字越多越慢:{‘平均值’: 1.258, ‘50%百分位’: 1.167, ‘90%百分位’: 1.98, ‘95%百分位’: 2.314, ‘99%百分位’: 3.181, ‘最小值’: 0.264, ‘最大值’: 7.922}

渐进放大算法

原理

广度优先搜索(要看源码)
代码:

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
import numpy as np
import cv2
import queue as Queue

def pse(kernals, min_area):
kernal_num = len(kernals) # 7,渐进放大的层数
pred = np.zeros(kernals[0].shape, dtype='int32') # shape:(1440, 1920)

label_num, label = cv2.connectedComponents(kernals[kernal_num - 1], connectivity=4) # 这里的lable对应最小的 kernel
"""
kernals[kernal_num - 1]为最小的核。
connectivity=4表示“上下左右”取联通域,connectivity=8表示“上下左右及对角线”取联通域。
这里label_num: 216 为联通区域数量,包括背景。
label: 与图像尺寸相同,背景像素为0,其他联通区域的标签值为1,2,3,...
array([[ 1, 1, 1, ..., 0, 0, 0],
[ 1, 1, 1, ..., 0, 0, 0],
[ 1, 1, 1, ..., 0, 0, 0],
...,
[ 0, 0, 0, ..., 39, 39, 39],
[ 0, 0, 0, ..., 39, 39, 39],
[ 0, 0, 0, ..., 39, 39, 39]], dtype=int32)

torch.max(torch.Tensor(label)): tensor(215.)

"""
# 联通域面积小于min_area,则设为背景
for label_idx in range(1, label_num):
if np.sum(label == label_idx) < min_area:
label[label == label_idx] = 0

# 创建了一个先进先出(FIFO)的队列, maxsize 表示可以容纳的最大元素数量。maxsize = 0表示可以无限制地添加元素
queue = Queue.Queue(maxsize = 0)
next_queue = Queue.Queue(maxsize = 0)
points = np.array(np.where(label > 0)).transpose((1, 0)) # shape: (391452, 2), 对应最小的 kernerl

# 最小kernel内的像素级操作,耗时严重
for point_idx in range(points.shape[0]):
x, y = points[point_idx, 0], points[point_idx, 1]
l = label[x, y]
queue.put((x, y, l)) # 对应最小的 kernerl
pred[x, y] = l # 与label一样,应该直接深拷贝

dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]
for kernal_idx in range(kernal_num - 2, -1, -1):
kernal = kernals[kernal_idx].copy() # 外层的 kernel, 对于 kernel,是二值化矩阵,即非 0 即 1
while not queue.empty():
(x, y, l) = queue.get()
is_edge = True
for j in range(4):
tmpx = x + dx[j]
tmpy = y + dy[j]
if tmpx < 0 or tmpx >= kernal.shape[0] or tmpy < 0 or tmpy >= kernal.shape[1]:
continue
if kernal[tmpx, tmpy] == 0 or pred[tmpx, tmpy] > 0:
continue

queue.put((tmpx, tmpy, l)) # kernal[tmpx, tmpy] == 1 and pred[tmpx, tmpy] == 0
pred[tmpx, tmpy] = l
is_edge = False
if is_edge:
next_queue.put((x, y, l))

# kernal[pred > 0] = 0
queue, next_queue = next_queue, queue

return pred

求连通域: label_num, label = cv2.connectedComponents(kernals[kernal_num - 1], connectivity=4)

流程图:

基网络:FPN


网络结构图(其中的fpn网络重点体会):

标签生成

  1. 利用Vatti裁剪算法(Vatti clipping algorithm)获得训练时的不同 kernels.
  2. kernel $p_n$和$p_i$ 间的 mergin $d_i$ 计算规则(Area(·) :计算面积的函数; Perimeter(·):计算周长的函数):

    其中:

    • $d_i$ 表示为了获得缩小的多边形 $p_i$,需要从原始多边形 $p_n$ 的边界向内缩小的距离。
    • $Area(p_n)$ 是计算多边形 $p_n$ 面积的函数。
    • $r_i$ 是缩小多边形 $p_i$ 的缩放比例。
    • $Perimeter(p_n)$ 是计算多边形 $p_n$ 周长的函数。

      公式 (1) 本身不是从其他公式推导出来的,而是根据面积和周长的几何关系定义的。

  3. 获得 线性 等差 缩放比例(m是最小的缩放比例,n是kernel个数, 其实就是在m到1之间等差求各个缩放比例, ):
    这里是过两点的直线,即(1, m) 与 (n, 1), 分别表示最小 kernel缩放比例为 m (0<m<1),最大kernel缩放比例为 1.
    对于这个函数,自变量为$i$.
    (源码中m=0.4)

理论基础

$r_i$ 公式为什么是这个样子?

解释

  1. 尺度比率 $r_i$

    • $r_i$ 表示第 $i$ 个真值掩码的尺度比率。
  2. 参数

    • $n$:总的尺度数。
    • $i$:当前尺度的索引(从 1 到 $n$)。
    • $m$:最小尺度比率,取值在 (0, 1]。

详细说明

  1. $r_i$ 的计算

    • 公式的目的是生成从最小尺度(例如核的核心部分)到最大尺度(完整的文本实例)的不同尺度的真值掩码。
    • 通过调整 $n$ 和 $m$ 的值,可以控制生成的掩码数量和每个掩码的缩小程度。
  2. 公式推导

    • 当 $i = 1$ 时,$r_1$ 对应最小尺度,即 $r_1 = m$。
    • 当 $i = n$ 时,$r_n$ 对应最大尺度,即 $r_n = 1$。
    • 中间的尺度按照线性插值计算,从最小的 $r_1$ 到最大的 $r_n$ 逐渐增加。

应用

  • 在生成不同尺度的真值掩码时,通过使用 Vatti clipping algorithm 逐步缩小多边形,得到不同尺度的多边形 $p_i$。
  • 这些多边形被转换成0/1二进制掩码,作为训练中的分割标签。

公式 $r_i$ 确保了生成的每个掩码都能够准确地表示文本实例的不同尺度,从而在训练过程中逐步学习和扩展这些实例。

$r_i$ 在公式 $d_i$ 中的作用

在公式 $d_i$ 中,尺度比率 $r_i$ 的作用是用于计算从原始多边形 $p_n$ 缩小得到的多边形 $p_i$ 的缩小边距 $d_i$。具体公式如下:

公式 $d_i$

解释

  1. 缩小边距 $d_i$

    • $d_i$ 表示原始多边形 $p_n$ 缩小到多边形 $p_i$ 所需的边距。
  2. 参数

    • $\text{Area}(p_n)$:原始多边形 $p_n$ 的面积。
    • $\text{Perimeter}(p_n)$:原始多边形 $p_n$ 的周长。
    • $r_i$:尺度比率,计算公式为 $r_i = 1 - (1 - m) \times \frac{n - i}{n - 1}$。

详细说明

  1. $r_i$ 的计算

    • 公式的目的是生成从最小尺度(例如核的核心部分)到最大尺度(完整的文本实例)的不同尺度的真值掩码。
    • 通过调整 $n$ 和 $m$ 的值,可以控制生成的掩码数量和每个掩码的缩小程度。
  2. 公式推导

    • 当 $i = 1$ 时,$r_1$ 对应最小尺度,即 $r_1 = m$。
    • 当 $i = n$ 时,$r_n$ 对应最大尺度,即 $r_n = 1$。
    • 中间的尺度按照线性插值计算,从最小的 $r_1$ 到最大的 $r_n$ 逐渐增加。

应用

  • 在生成不同尺度的真值掩码时,通过使用 Vatti clipping algorithm 逐步缩小多边形,得到不同尺度的多边形 $p_i$。
  • 这些多边形被转换成0/1二进制掩码,作为训练中的分割标签。

公式 $r_i$ 确保了生成的每个掩码都能够准确地表示文本实例的不同尺度,从而在训练过程中逐步学习和扩展这些实例。

为什么最后要除以$\text{Perimeter}(p_n)$呢

将缩小边距 $d_i$ 除以多边形 $p_n$ 的周长(Perimeter)是为了使缩小过程中的面积变化与多边形的大小成比例,确保缩小边距与多边形的形状和大小相适应。这一步的目的是规范缩小的程度,使得计算出的缩小边距在不同多边形形状和大小下都能合理应用。以下是更详细的解释:

公式 $d_i$

详细解释

  1. 面积变化

    • $\text{Area}(p_n) \times (1 - r_i^2)$ 表示从原始多边形 $p_n$ 到缩小多边形 $p_i$ 的面积变化量。
    • $(1 - r_i^2)$ 是一个比例因子,表示缩小后的多边形面积相对于原始多边形面积的减少比例。
  2. 规范缩小程度

    • 多边形的周长(Perimeter)表示了多边形的边界长度,它反映了多边形的形状和大小。
    • 将面积变化量除以周长可以将面积变化转换为边界上的距离变化,使得缩小的边距 $d_i$ 能够合理地应用于多边形的每个边。
    • 这种做法确保了无论多边形的形状和大小如何,缩小边距 $d_i$ 都能保持合理的缩小比例,不会因为多边形的大小或形状不同而导致不合理的缩小结果。

举例说明

假设有两个不同大小的矩形,虽然它们的面积变化比例相同,但如果直接使用面积变化来计算缩小边距,较大的矩形会缩小得更多,而较小的矩形会缩小得较少。通过将面积变化量除以周长,可以规范化这个缩小过程,使得不同大小和形状的多边形都能合理地缩小。

总结

将缩小边距 $d_i$ 除以多边形的周长是为了将面积变化合理地转换为边界上的距离变化,确保缩小边距在不同形状和大小的多边形上都能得到合理应用,从而保证缩小过程的一致性和合理性。

面积变化除以边界距离,这个有由来吗?有理论支撑吗

将面积变化除以边界长度来确定缩小边距在计算几何中是有理论支撑的,尤其是在涉及到形状缩放和多边形裁剪的算法中。这个方法主要基于以下几点考虑:

理论基础

  1. 等比例缩小

    • 对于一个多边形,如果希望其在缩小过程中保持形状不变,那么每个边的缩小应该是等比例的。
    • 将面积变化除以周长,能够计算出每个单位长度边的缩小量,从而使整个多边形均匀地缩小。
  2. 几何变换的均匀性

    • 在几何变换中,特别是多边形的缩放或缩小操作中,保持边界的均匀性和比例关系是关键。
    • 面积变化量反映了整体的缩小程度,而周长则提供了边界的总长度,将两者结合可以确保缩小边距与多边形的大小成正比。

实际应用

  1. Vatti裁剪算法

    • Vatti裁剪算法是处理复杂多边形(包括凹多边形和自相交多边形)的常用算法。它通过迭代地缩小多边形来生成不同尺度的形状。
    • 在该算法中,通过将面积变化量除以周长来计算缩小边距,确保缩小过程中的每一步都是均匀且成比例的。
  2. 图像处理和计算机视觉中的分割

    • 在图像分割中,特别是处理文本检测和实例分割时,需要对目标区域进行逐步缩小和扩展。
    • 使用面积变化和周长的关系来计算缩小边距,有助于保持目标区域的几何特性,从而提高分割的精度和鲁棒性。

数学解释

设原始多边形的面积为 $A_n$,周长为 $P_n$。缩小后的多边形面积为 $A_i = r_i^2 \times A_n$,其中 $r_i$ 是尺度比率。面积的减少量为 $A_n - A_i = A_n \times (1 - r_i^2)$。

将这个面积变化量均匀分布到多边形的每个边上,即除以周长 $P_n$,得到缩小边距:

结论

这种计算方法通过结合面积变化和周长,确保了缩小过程中的均匀性和比例一致性,具有实际应用中的理论支撑和数学基础。这种方法广泛应用于计算几何和图像处理领域,特别是在需要保持形状一致性的缩放操作中。

Train

Dataloader

random_scale

  • 训练时 最长边为 1280.
  • 然后随机缩放至[0.5, 1.0, 2.0, 3.0]
  • 如果缩放后最小边小于 640,这以(640+10)作为 最小边的缩放比例。

填充文字区域

1
2
3
4
5
6
7
8
9
10
11
cv2.drawContours(gt_text, [bboxes[i]], -1, i + 1, -1)

# gt_text:目标图像,函数将在此图像上绘制轮廓。

# [bboxes[i]]:轮廓列表,这里是一个包含单个轮廓的列表。bboxes[i] 是一个轮廓,通常是一个点的数组。

# -1:轮廓索引。如果为负值(如 -1),则绘制所有轮廓。这里因为列表中只有一个轮廓,所以绘制该轮廓。

# i + 1:颜色或灰度值。这里使用 i + 1 作为颜色值。

# -1:轮廓厚度。如果为负值(如 -1),则填充轮廓内部。

生成 kernels

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
def dist(a, b):
return np.sqrt(np.sum((a - b) ** 2))

def perimeter(bbox):
"""get the sum of the distance of points.
"""
peri = 0.0
for i in range(bbox.shape[0]):
peri += dist(bbox[i], bbox[(i + 1) % bbox.shape[0]])
return peri

def shrink(bboxes, rate, max_shr=20):
rate = rate * rate
shrinked_bboxes = []
for bbox in bboxes:
area = plg.Polygon(bbox).area() # get the area of the polygon
peri = perimeter(bbox) # get the perimeter of the polygon

pco = pyclipper.PyclipperOffset() # create a PyclipperOffset object
pco.AddPath(bbox, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON) # add the polygon to the object
offset = min((int)(area * (1 - rate) / (peri + 0.001) + 0.5), max_shr)

shrinked_bbox = pco.Execute(-offset)
if len(shrinked_bbox) == 0:
shrinked_bboxes.append(bbox)
continue

shrinked_bbox = np.array(shrinked_bbox)[0]
if shrinked_bbox.shape[0] <= 2:
shrinked_bboxes.append(bbox)
continue

shrinked_bboxes.append(shrinked_bbox)

return np.array(shrinked_bboxes)

transform

1
2
3
4
5
6
7
8
9
10
if self.is_transform:
imgs = [img, gt_text, training_mask]
imgs.extend(gt_kernels)

imgs = random_horizontal_flip(imgs)
#imgs = random_vertical_flip(imgs)
imgs = random_rotate(imgs)
imgs = random_crop(imgs, self.img_size)

img, gt_text, training_mask, gt_kernels = imgs[0], imgs[1], imgs[2], imgs[3:]

random_crop

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
def random_crop(imgs, img_size):
# Get the height and width of the first image
h, w = imgs[0].shape[0:2]
# Set target height and width
th, tw = img_size

# If the target size is equal to the current size, return images as is
if w == tw and h == th:
return imgs

# If the random condition is met and the max value in the second image is greater than 0
# imgs built:
# imgs = [img, gt_text, training_mask]
# imgs.extend(gt_kernels)
if random.random() > 3.0 / 8.0 and np.max(imgs[1]) > 0:
# Calculate top-left corner of the bounding box with non-zero values, minus the target size
tl = np.min(np.where(imgs[1] > 0), axis=1) - img_size # img_size: (640, 640)
# Ensure no negative indices
tl[tl < 0] = 0

# Calculate bottom-right corner of the bounding box with non-zero values, minus the target size
br = np.max(np.where(imgs[1] > 0), axis=1) - img_size
# Ensure no negative indicess
br[br < 0] = 0

# Ensure the bottom-right corner does not exceed the image dimensions
br[0] = min(br[0], h - th)
br[1] = min(br[1], w - tw)

# Randomly select a starting point within the bounding box
i = random.randint(tl[0], br[0])
j = random.randint(tl[1], br[1])
else:
# Randomly select a starting point anywhere in the image
i = random.randint(0, h - th)
j = random.randint(0, w - tw)

# Crop each image in the list
for idx in range(len(imgs)):
# If the image has 3 channels (e.g., RGB), keep the channel dimension intact
if len(imgs[idx].shape) == 3:
imgs[idx] = imgs[idx][i:i + th, j:j + tw, :]
else:
# For single-channel images, crop directly
imgs[idx] = imgs[idx][i:i + th, j:j + tw]

# Return the cropped images
return imgs

Loss

在线难样本挖掘

函数处理流程解释

ohem_single 函数的主要目的是在训练过程中选择困难样本,以提高模型的泛化能力。具体处理流程如下:

  1. 计算正样本数量

    • 函数首先计算正样本的数量,即 $gt_text$ 中大于 $0.5$ 的元素数量。
    • 然后减去被训练掩码屏蔽的正样本数量。
  2. 处理没有正样本的情况

    • 如果正样本数量为 $0$,函数直接返回训练掩码 $training_mask$,并将其形状调整为 $(1, height, width)$。
  3. 计算负样本数量

    • 函数计算负样本的数量,即 $gt_text$ 中小于等于 $0.5$ 的元素数量。
    • 然后将负样本数量限制为正样本数量的三倍。
  4. 处理没有负样本的情况

    • 如果负样本数量为 $0$,函数直接返回训练掩码 $training_mask$,并将其形状调整为 $(1, height, width)$。
  5. 选择负样本

    • 函数获取负样本的得分,并按降序排序。
    • 选择负样本的阈值,即排序后第 $neg_num$ 个负样本的得分。
  6. 选择最终样本

    • 函数选择得分大于等于阈值的负样本和所有正样本,并且这些样本没有被训练掩码屏蔽。
    • 最终选择的样本掩码 $selected_mask$ 被调整为 $(1, height, width)$ 的形状,并返回。

通过上述步骤,ohem_single 函数实现了在线困难样本挖掘(OHEM),从而选择出对模型训练最有帮助的样本。

代码

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
def ohem_single(score, gt_text, training_mask):

# 计算正样本数量,排除掉被训练掩码屏蔽的部分
pos_num = (int)(np.sum(gt_text > 0.5)) - (int)(np.sum((gt_text > 0.5) & (training_mask <= 0.5)))

# 如果没有正样本,直接返回训练掩码
if pos_num == 0:
# selected_mask = gt_text.copy() * 0 # 可能不太好
selected_mask = training_mask
selected_mask = selected_mask.reshape(1, selected_mask.shape[0], selected_mask.shape[1]).astype('float32')
return selected_mask

# 计算负样本数量,并限制其数量为正样本数量的三倍
neg_num = (int)(np.sum(gt_text <= 0.5))
neg_num = (int)(min(pos_num * 3, neg_num))

# 如果没有负样本,直接返回训练掩码
if neg_num == 0:
selected_mask = training_mask
selected_mask = selected_mask.reshape(1, selected_mask.shape[0], selected_mask.shape[1]).astype('float32')
return selected_mask

# 获取负样本的得分,并按降序排序
neg_score = score[gt_text <= 0.5]
neg_score_sorted = np.sort(-neg_score)
# 选择负样本的阈值
threshold = -neg_score_sorted[neg_num - 1]

# 选择得分大于等于阈值的负样本和所有正样本,并且这些样本没有被训练掩码屏蔽
selected_mask = ((score >= threshold) | (gt_text > 0.5)) & (training_mask > 0.5)
selected_mask = selected_mask.reshape(1, selected_mask.shape[0], selected_mask.shape[1]).astype('float32')
return selected_mask

Dice loss

Dice 损失(Dice Loss)是一种常用于图像分割任务的损失函数,旨在衡量两个样本之间的相似性。它源自 Dice 系数(Dice Coefficient),这是一个用于评估两个集合相似性的统计工具。

在代码中的应用

在你提供的代码中:

  • 输入的张量通过 sigmoid 函数进行了归一化,以便输出值介于 0 和 1 之间。

  • 输入、目标和掩码都被展平为二维张量,以便于计算。

  • 掩码用于只计算感兴趣区域的损失。

  • 通过计算预测和真实目标的交集以及各自平方和来计算 Dice 系数。

  • 最终通过 1 - dice_coefficient 得到 Dice 损失。这个损失值越小,说明模型的预测越接近真实值。

这种方法在医学图像分割等领域尤其常用,因为它能够有效处理数据不平衡问题,并对模型的分割性能进行合理的评估。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def dice_loss(input, target, mask):
# 对输入应用 sigmoid 函数
input = torch.sigmoid(input)

# 将输入、目标和掩码展平为二维张量
input = input.contiguous().view(input.size()[0], -1)
target = target.contiguous().view(target.size()[0], -1)
mask = mask.contiguous().view(mask.size()[0], -1)

# 应用掩码
input = input * mask
target = target * mask

# 计算 Dice 系数的分子
a = torch.sum(input * target, 1)
# 计算 Dice 系数的分母的一部分,并添加一个小常数以避免除零错误
b = torch.sum(input * input, 1) + 0.001
c = torch.sum(target * target, 1) + 0.001
# 计算 Dice 系数
d = (2 * a) / (b + c)
# 计算平均 Dice 系数
dice_loss = torch.mean(d)
# 返回 Dice 损失
return 1 - dice_loss

Dice 系数

Dice 系数(也称为 Sørensen–Dice 指数)的计算公式是:

其中,$X$ 和 $Y$ 是两个集合。在图像分割的背景下,$X$ 表示预测的分割结果,$Y$ 表示真实的分割结果。

对于两个二进制向量(例如,二值分割掩码),Dice 系数可以表示为:

Dice 系数的取值范围是 0 到 1,其中 1 表示完全重合,0 表示没有重合。

Dice 损失

Dice 损失是从 Dice 系数导出的,其定义为:

使用 Dice 损失的目的是最大化 Dice 系数,从而最小化损失。

为什么使用 Dice 损失

处理类别不平衡:

  • 在许多分割任务中,尤其是医学图像分割中,目标(如病灶)通常只占很小的一部分,而背景占据了图像的大部分。这种不平衡会导致传统的损失函数(如交叉熵)在优化过程中偏向于背景,因为大部分像素属于背景类别。

  • Dice Loss 的计算方式:

    • 这里,Dice 系数直接衡量了预测结果和真实结果之间的重叠程度。它关注的是两个集合(预测和真实)的交集与各自规模的和。

    • Dice Loss 优化的是重叠区域的比例,而不是绝对数量。它计算的是正样本(即目标区域)的精确覆盖程度,因此即使正样本较少,它也能够给予足够的关注。

  • 关注目标区域:由于 Dice Loss 关注的是预测和真实目标之间的重叠部分,即使正样本很小,它仍然在计算中起到重要作用,从而在训练过程中推动模型更好地学习少数类。

对小结构敏感:

  • 在图像分割任务中,小结构(如病灶、微小物体等)可能在整体图像中占比很小,但通常具有重要的意义。传统的损失函数可能忽视这些小结构,因为它们在整体像素中占比过低。

  • Dice Loss 的优势:

    • 增强小目标的影响: Dice Loss 中的交集计算通过平方和的分母抵消了小目标的绝对数量不足的问题,因此即便是微小的预测区域也能够显著影响损失值。

    • 重视每个像素: 由于 Dice Loss 是基于重叠率的计算,任何漏检或错误预测的小目标都会影响损失值,使得模型在更新过程中更关注这些小结构的检测。

总loss

  • 对原始文字区域,使用 Dice Loss,得到 loss_text
  • 对剩余的 6 个 kernel,分别使用 Dice Loss,然后求平均,得到 loss_kernel.
  • 计算总 loss = 0.7 * loss_text + 0.3 * loss_kernel .

求精度

原始代码

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
import numpy as np

class runningScore(object):
"""
计算多分类任务中的评价指标,包括总体精度、平均精度、平均交并比(IoU),
和频率加权交并比(fwavacc)。
"""

def __init__(self, n_classes):
"""
初始化runningScore对象。

参数:
n_classes (int): 分类的类别数。
"""
self.n_classes = n_classes
# 初始化一个空的混淆矩阵,大小为(n_classes, n_classes)
self.confusion_matrix = np.zeros((n_classes, n_classes))

def _fast_hist(self, label_true, label_pred, n_class):
"""
计算单一图像的混淆矩阵。

参数:
label_true (array-like): 真实的标签。
label_pred (array-like): 预测的标签。
n_class (int): 分类的类别数。

返回:
np.array: 当前图像的混淆矩阵。
"""
# 创建一个mask,仅选择有效标签的像素(标签值在[0, n_class)之间)
mask = (label_true >= 0) & (label_true < n_class)

# 检查预测标签中是否有无效值
if np.sum((label_pred[mask] < 0)) > 0:
print(label_pred[label_pred < 0])

# 计算混淆矩阵,使用bincount统计像素的类别对(真实 vs 预测)
hist = np.bincount(
n_class * label_true[mask].astype(int) +
label_pred[mask], minlength=n_class**2).reshape(n_class, n_class)
return hist

def update(self, label_trues, label_preds):
"""
更新混淆矩阵。

参数:
label_trues (list of arrays): 一组真实标签。
label_preds (list of arrays): 一组预测标签。
"""
# 遍历所有的真实和预测标签对
for lt, lp in zip(label_trues, label_preds):
# 更新总的混淆矩阵
self.confusion_matrix += self._fast_hist(lt.flatten(), lp.flatten(), self.n_classes)

def get_scores(self):
"""
计算并返回精度评估结果。

返回:
dict: 字典,包含 总体精度、平均精度、频率加权IoU精度、平均IoU
dict: 每个类别的IoU值。
"""
hist = self.confusion_matrix
# 计算总体精度
acc = np.diag(hist).sum() / (hist.sum() + 0.0001)
# 计算每个类别的精度,并取平均
acc_cls = np.diag(hist) / (hist.sum(axis=1) + 0.0001)
acc_cls = np.nanmean(acc_cls)
# 计算交并比(IoU)并取平均
iu = np.diag(hist) / (hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist) + 0.0001)
mean_iu = np.nanmean(iu)
# 计算每个类别的频率
freq = hist.sum(axis=1) / (hist.sum() + 0.0001)
# 计算频率加权的交并比
fwavacc = (freq[freq > 0] * iu[freq > 0]).sum()
# 生成类别到IoU的映射
cls_iu = dict(zip(range(self.n_classes), iu))

return {'Overall Acc': acc,
'Mean Acc': acc_cls,
'FreqW Acc': fwavacc,
'Mean IoU': mean_iu,}, cls_iu

def reset(self):
"""
重置混淆矩阵,将其清零。
"""
self.confusion_matrix = np.zeros((self.n_classes, self.n_classes))

hist矩阵

其中 得到hist的代码:

1
2
3
4
5
6
7

# 计算混淆矩阵,使用bincount统计像素的类别对(真实 vs 预测)

# 源码中n_class=2
hist = np.bincount(
n_class * label_true[mask].astype(int) +
label_pred[mask], minlength=n_class**2).reshape(n_class, n_class)

结果为:

1
2
array([[341029,  17725],
[ 42211, 8635]])

代表含义:
(0, 0): 341029, label为 0,predict 为 0.
(0, 1): 17725, label为 0,predict 为 1.
(1, 0): 42211,label为 1,predict 为 0.
(1, 1): 8635,label为 1,predict 为 1.

分类中的 IoU

IoU: Intersection over Union
某类别 IoU = 该类别分类正确数量 / (该类别真实数量 + 错误预测为该类别数量)
该概念与目标检测中的 IoU类似:

分类中的加权 IoU

1
2
3
4
5
6

# 计算每个类别的频率
freq = hist.sum(axis=1) / (hist.sum() + 0.0001)

# 计算频率加权的交并比
fwavacc = (freq[freq > 0] * iu[freq > 0]).sum()

fwavacc 即为 FWIoU。
FWIoU: Frequency Weighted Intersection over Union