一种高性能的正方形识别系统

前言

最近完成了一个计算机视觉算法,算是一个基于角点检测的正方形识别系统。这个算法从最初的想法到最终的实现,经历了很多有趣的技术挑战和优化过程。

首先简要概述一下问题,一个白色背景上有若干黑色大小不一,角度不同且可能重叠的正方形,保证每个正方形有两个可见的角,需要识别出所有的正方形。下面是一个例子:

示例图像

1. 算法设计

1.1 图像预处理

首先,我们需要将输入图像转换成算法易于处理的格式。这里我用了OTSU二值化处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def load_image_from_file(self, image_path: str, binarize: bool = True, threshold: int = 127):
"""
为什么要二值化?
1. 简化计算:只需要区分黑白两种状态
2. 减少噪声:去除灰度变化带来的干扰
3. 提高速度:布尔运算比数值计算更快
"""
loaded_image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)

if binarize:
if threshold == -1:
# OTSU自适应阈值:让算法自己决定最佳分割点
threshold_value, binary_image = cv2.threshold(
loaded_image, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"OTSU算法选择的阈值: {threshold_value:.1f}")
else:
# 固定阈值:适用于光照条件稳定的场景
_, binary_image = cv2.threshold(loaded_image, threshold, 255, cv2.THRESH_BINARY)

self.image = binary_image

这里提供了三种二值化选项,是因为不同的应用场景需要不同的处理策略。OTSU算法特别适合光照不均匀的情况,而固定阈值则在工业环境中更稳定。

1.2 高效找角点

由于这个图中都是正方形,理论上找出所有直角,就可以尝试解决问题。这里我使用了一种专门面向这个问题的思路。思考对一个直角点进行范围取样之后的结果:

角点检测示意图

容易发现,如果在一个较大范围内,计算黑色像素占比,那么它应该比较接近25%,当然取样面积太小的话,因为边缘误差可能导致黑色像素比例相比25%偏多。

现在我们的目标就是,如何快速计算一个区域内黑色像素点的数量。

1.2.1 构建前缀和矩阵

这是整个系统最关键的优化点。让我详细解释一下为什么要用前缀和:

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
def build_prefix_sum_matrix(self):
"""
前缀和矩阵的核心思想:
如果我们要频繁计算矩形区域内的黑色像素数量,
传统方法需要O(W×H)的时间复杂度,太慢了!

前缀和的巧妙之处:
prefix_sum[y][x] = 从(0,0)到(x,y)矩形内所有黑色像素的总数
"""
height, width = self.image.shape
self.prefix_sum = np.zeros((height + 1, width + 1), dtype=np.int32)

print("构建前缀和矩阵...")
for y in range(1, height + 1):
for x in range(1, width + 1):
# 动态规划状态转移方程
is_black = 1 if self.image[y-1, x-1] == 0 else 0
self.prefix_sum[y, x] = (is_black +
self.prefix_sum[y-1, x] + # 上方的和
self.prefix_sum[y, x-1] - # 左方的和
self.prefix_sum[y-1, x-1]) # 减去重复计算的部分

def get_black_pixel_count_in_rect(self, x1: int, y1: int, x2: int, y2: int) -> int:
"""
有了前缀和矩阵,计算任意矩形区域的黑色像素数量只需要O(1)时间!
这是二维前缀和的经典应用
"""
return (self.prefix_sum[y2, x2] -
self.prefix_sum[y1, x2] -
self.prefix_sum[y2, x1] +
self.prefix_sum[y1, x1])

为什么这么设计?

我举个例子:假设我们要检测1000×1000的图像中的所有角点,每个角点都需要计算周围20×20区域的黑色像素比例。

  • 传统方法:每次计算需要400次像素检查,总计需要1000×1000×400 = 4亿次操作
  • 前缀和方法:构建一次前缀和(100万次操作),之后每次查询只需要4次运算,总计约100万次操作

效率提升了400倍。

1.3 角点多层过滤

由于现实世界的图像往往不完美,我设计了一个多层过滤系统:

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
def detect_all_outer_corners(self):
"""
通过多层过滤,逐步提高候选点的质量
"""

# 第一层过滤:基础条件
for y in range(edge_threshold, self.image_size[1] - edge_threshold):
for x in range(edge_threshold, self.image_size[0] - edge_threshold):

# 过滤器1:必须是黑色像素
if not self.is_black_pixel(x, y):
continue

# 过滤器2:必须是边缘像素(周围不全是黑色)
if not self.is_edge_pixel(x, y):
continue

# 过滤器3:黑色像素比例要在合理范围内
corner_black_ratio = self.calculate_black_pixel_ratio(x, y, radius=10)
if corner_black_ratio < 0.1 or corner_black_ratio > 0.4:
continue # 太少说明不是角点,太多说明在内部

# 过滤器4:能找到合适的辅助点
try:
assist_points = self.find_two_edge_assist_points(x, y, radius=10)
if assist_points and len(assist_points) == 2:
all_corners.append(((x, y), assist_points[0], assist_points[1]))
except:
continue

设计思路解析

  1. 边缘像素检测:首先角点肯定要求是边缘上的点
  2. 黑色比例过滤:0.1-0.4的范围是经过大量实验确定的最佳区间
  3. 辅助点寻找:通过寻找90度夹角的边缘点来验证这是一个真正的角点,同时也可以记录这个角两条边的方向。

为什么这样设计? 实际图像中的角点往往不是完美的90度角,可能是圆角、磨损的角或者有噪声的角。传统方法会漏掉这些”不完美”的角点,而我的方法通过统计学的思路,只要该点具有角点的统计特征就接受它。

具体边缘检测的思路也很简单,考虑周围八个像素就可以了:

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
def is_edge_pixel(self, x: int, y: int) -> bool:
"""
我的边缘检测思路:
不追求完美的边缘,而是寻找"有意义"的边缘
一个像素如果是黑色,但周围8个邻居不全是黑色,
那它就是边缘像素
"""
if not self.is_black_pixel(x, y):
return False

neighbors = [(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1)]

black_neighbors = 0
valid_neighbors = 0

for dx, dy in neighbors:
check_x, check_y = x + dx, y + dy
if 0 <= check_x < self.image_size[0] and 0 <= check_y < self.image_size[1]:
valid_neighbors += 1
if self.is_black_pixel(check_x, check_y):
black_neighbors += 1

# 关键判断:如果所有邻居都是黑色,说明在内部,不是边缘
return valid_neighbors > black_neighbors

在实际程序中,这里也可以引入多线程优化:

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 detect_all_outer_corners(self):
"""
多线程设计思路:
将图像水平分割成两部分,两个线程并行处理
这样可以充分利用多核CPU的优势
"""
start_y = edge_threshold
end_y = self.image_size[1] - edge_threshold
mid_y = (start_y + end_y) // 2 # 分割点

corners_thread1 = []
corners_thread2 = []
lock = threading.Lock() # 保证线程安全

def scan_upper_half():
"""线程1负责上半部分"""
local_corners = []
for y in range(start_y, mid_y):
# ... 角点检测逻辑 ...

with lock: # 安全地合并结果
corners_thread1.extend(local_corners)

def scan_lower_half():
"""线程2负责下半部分"""
# 类似的处理逻辑
pass

# 启动并等待线程完成
thread1 = threading.Thread(target=scan_upper_half)
thread2 = threading.Thread(target=scan_lower_half)
thread1.start()
thread2.start()
thread1.join()
thread2.join()

为什么选择水平分割?

我尝试过垂直分割和棋盘式分割,但发现水平分割效果最好,原因是: 1. 图像的行是连续存储的,缓存友好 2. 避免了复杂的边界处理 3. 负载均衡效果好(每个线程处理相同的行数)

1.4 正方形重建

如何从检测到的角点重建出完整的正方形?我设计了一个多策略的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def reconstruct_squares(self, corners):
"""
重建策略的优先级设计:
4角点 > 3角点 > 2角点

为什么这样排序?
- 4角点:信息最完整,几乎不会出错
- 3角点:信息充足,可以通过几何推理得到第4个点
- 2角点:信息最少,但仍然可以通过共线和平行关系推理
"""

# 策略1:4角点直接验证
squares_4_corners = self.find_squares_with_4_corners(corners, used_corners)

# 策略2:3角点推理重建
squares_3_corners = self.find_squares_with_3_corners(corners, used_corners)

# 策略3:2角点智能构建
squares_2_corners = self.find_squares_with_2_corners(corners, used_corners)

让我详细解释每种策略:

策略一:4角点验证

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
def are_4_points_square(self, points, tolerance=5.0):
"""
如何验证4个点是否构成正方形?
1. 计算所有6条边(4条边+2条对角线)
2. 前4条应该相等(边长)
3. 后2条应该相等(对角线)
4. 对角线 = 边长 × √2
"""
distances = []
for i in range(4):
for j in range(i + 1, 4):
dist = np.sqrt((points[i][0] - points[j][0])**2 +
(points[i][1] - points[j][1])**2)
distances.append(dist)

distances.sort()
side_length = distances[0]
diagonal_length = distances[4]

# 验证边长是否相等
for i in range(4):
if abs(distances[i] - side_length) > tolerance:
return False

# 验证对角线长度
expected_diagonal = side_length * np.sqrt(2)
if abs(diagonal_length - expected_diagonal) > tolerance:
return False

return True

策略二:3角点推理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def calculate_fourth_corner(self, three_points):
"""
从3个点推理第4个点的数学原理:
在正方形ABCD中,如果知道A、B、C三点,
那么第四个点D = A + C - B(向量运算)

但是我们不知道哪三个点的对应关系,所以要尝试所有可能的组合
"""
for i in range(3):
for j in range(3):
if i == j:
continue

p1, p2, p3 = three_points[i], three_points[j], three_points[(set(range(3)) - {i, j}).pop()]

# 尝试不同的点作为对角点
p4 = (p1[0] + p3[0] - p2[0], p1[1] + p3[1] - p2[1])

if self.are_4_points_square([p1, p2, p3, p4]):
return p4

return None

策略三:2角点智能构建

这是最复杂的情况,需要区分相邻角点和对角角点:

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 find_squares_with_2_corners(self, corners, used_corners):
"""
2角点重建的核心思路:
1. 判断两个角点是相邻还是对角关系
2. 相邻:利用共线边和平行边的关系
3. 对角:利用直线交点计算其他两个角点
"""

for i in range(len(corners)):
corner1 = corners[i]
for j in range(i + 1, len(corners)):
corner2 = corners[j]

# 检查相邻关系:一条边共线,另一条边平行
if self.are_adjacent_corners(corner1, corner2):
square = self.build_square_from_adjacent_corners(corner1, corner2)
if square and self.validate_square(square):
squares.append(square)

# 检查对角关系:通过直线交点重建
else:
square = self.build_square_from_diagonal_corners(corner1, corner2)
if square and self.validate_square(square):
squares.append(square)

1.5 结果去重

在检测过程中,同一个正方形可能被多种策略检测到,需要智能的去重机制,这里的重叠度计算基于两个正方形中心位置的差异:

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
def remove_duplicate_squares(self, squares, overlap_threshold=0.95):
"""
去重策略的核心思想:
1. 计算几何重叠度
2. 比较黑色像素质量
3. 考虑旋转角度差异
4. 保留最优结果
"""
if len(squares) <= 1:
return squares

# 为每个正方形计算质量分数
square_ratios = []
for square in squares:
ratio = self.calculate_square_black_ratio(square)
square_ratios.append(ratio)

keep_flags = [True] * len(squares)

for i in range(len(squares)):
if not keep_flags[i]:
continue

for j in range(i + 1, len(squares)):
if not keep_flags[j]:
continue

# 计算重叠度
overlap = calculate_square_overlap(squares[i], squares[j])

if overlap > overlap_threshold:
# 关键判断:是否是不同角度的同一个正方形?
if self.are_squares_different_rotation(squares[i], squares[j]):
continue # 不同角度,保留两者

# 重叠度高且角度相似,保留质量更好的
if square_ratios[i] >= square_ratios[j]:
keep_flags[j] = False
else:
keep_flags[i] = False
break

return [squares[i] for i in range(len(squares)) if keep_flags[i]]

在考虑旋转差异的时候,必须考虑到正方形的对称性:

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
def calculate_square_rotation_angle(square):
"""
由于正方形的90度对称性,映射到[0, 90)
"""
if len(square) != 4:
return 0.0

p1, p2 = square[0], square[1]
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]

angle = math.degrees(math.atan2(dy, dx))

# 利用对称性映射到[0,90)
angle = angle % 90

return angle

def are_squares_different_rotation(self, square1, square2, angle_threshold=10.0):
"""
角度比较也需要考虑90度边界:
角度89度和角度1度在正方形中实际上很相近(差2度)
而不是88度的差距
"""
angle1 = calculate_square_rotation_angle(square1)
angle2 = calculate_square_rotation_angle(square2)

angle_diff = abs(angle1 - angle2)

# 处理边界情况:考虑90度边界的情况
boundary_diff = min(angle_diff, 90 - angle_diff)

return boundary_diff > angle_threshold

1.6 性能优化

像素统计的并行化

在验证正方形质量时,我们需要计算区域内的黑色像素比例。这个操作也可以并行化:

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
def calculate_square_black_ratio(self, corners):
"""
为什么要并行化像素统计?
对于大的正方形区域,串行计算会很慢
将区域分成上下两部分,并行计算可以提速近一倍
"""
height = self.image_size[1]
mid_y = height // 2

# 局部变量避免锁竞争
total_pixels_thread1 = 0
black_pixels_thread1 = 0
total_pixels_thread2 = 0
black_pixels_thread2 = 0

def scan_upper_half():
nonlocal total_pixels_thread1, black_pixels_thread1
local_total = 0
local_black = 0

for y in range(0, mid_y):
for x in range(self.image_size[0]):
if mask[y, x] > 0: # 在正方形区域内
local_total += 1
if self.is_black_pixel(x, y):
local_black += 1

# 最后统一更新,减少锁的使用
with lock:
total_pixels_thread1 = local_total
black_pixels_thread1 = local_black

缓存优化策略

在去重过程中,我发现距离计算被重复执行很多次,于是加入了缓存机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def remove_duplicate_corners(self, corners):
"""
缓存设计思路:
距离计算是对称的,distance(A,B) = distance(B,A)
用一个字典缓存计算结果,避免重复计算
"""
distance_cache = {}

def get_distance_cached(p1, p2):
# 确保键的唯一性和对称性
key = (min(p1, p2), max(p1, p2))
if key not in distance_cache:
distance_cache[key] = np.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)
return distance_cache[key]

2. 算法性能深度分析

时间复杂度分析

让我们来详细分析一下各个步骤的时间复杂度:

步骤 复杂度 说明 优化前 优化后
前缀和构建 O(W×H) 只需构建一次 - -
单次区域查询 O(1) 前缀和的核心优势 O(W×H) O(1)
角点检测 O(W×H) 每个像素检查一次 O(W×H×R²) O(W×H)
正方形验证 O(1) 固定数量的几何计算 O(R²) O(1)
总体复杂度 O(W×H) 线性增长 O(W×H×R²) O(W×H)

其中W是图像宽度,H是高度,R是检测半径。

空间复杂度分析

1
2
3
4
5
6
7
# 主要的内存消耗来源:
self.image: O(W×H) # 原始图像
self.prefix_sum: O(W×H) # 前缀和矩阵
corners: O(N) # 检测到的角点,N通常很小
distance_cache: O(N²) # 距离缓存,N²通常也很小

# 总空间复杂度:O(W×H)

3. 效果展示

算法在各种测试图像上的识别效果:

测试效果1

测试效果2

测试效果3

测试效果4

4. 开源和交流

这个项目已经在GitHub上开源:square_recognizer


技术是为了解决实际问题而存在的。希望这个项目和这篇分享能对大家有所帮助。让我们一起在技术的道路上不断前进!