文章目录
1.灰度图像二值化(后面算法很多基于二值图0-255)
由于后续很多算法都是用二值图来操作的,这里再介绍下如何二值化图像
它的原理很简单,循环查询灰度图的每个像素, 若灰度值 < 阈值T → 0(黑/前景),否则 → 255(白/背景)。注意这里,
- 黑:前景
- 白:背景
适用于前景背景灰度差异明显的图像
def binary_threshold(image, threshold):
"""
参数:
image - shape=(H,W), dtype=uint8
threshold - int, 阈值 [0,255]
"""
# 这里使用了numpy库,下面表达的就是如果图像灰度值小于threshold,就把
# 对应的灰度值置为0(前景),反之置为白(255)
# 注意np.where内部会对image数组中每个元素进行遍历
result = np.where(image < threshold, np.uint8(0), np.uint8(255))
return result.astype(np.uint8)
2.连通区域标记(两遍扫描法)
这里要注意,输入的是二值图像, 黑为前景,白为背景,
检测原理
- 扫描顺序:从上到下、从左到右。
对每个黑色像素(值=0),按优先级检查4个已访问邻居:- 右上方
- 正上方
- 左上方
- 左方

- 若找到已标记邻居 → 继承其标签
- 若多个邻居有不同标签 → 合并(Union):将全图中旧标签替换为新标签
- 若无已标记邻居 → 分配新标签
如上图所示,因为扫描顺序是自上而下,自左而右,所以当检测到上图中的黑像素时,它需要从map中获取到它邻居(上图高亮部分)的标签,然后把第一个标签的值赋给自己(MAP).
再举个例子,如下图是一个心形图像的原始图像,map数据,以及flag数据,由于是自上而下,自左而右的扫描顺序,再检测到1号箭头指向的像素时,算会依次查询右上,正上,左上,左边像素的map数据,并把非0的第一个赋给1号箭头指向像素对应的map区域,下图的flag区域是标记各个标签像素数量的,这里我们图像简单,前景黑色的像素flag的数量是16.

- python代码
两遍扫描法标记连通区域(8连通前景)
def connected_components(image):
"""
参数:
image - shape=(H,W), dtype=uint8 的二值图(0=前景,非0=背景)
返回:
(label_map, num_labels, flag)
label_map - shape=(H,W), dtype=np.int32, 0=背景,1~N=各区域
num_labels - 实际连通区域数
flag - 各标签的像素计数数组(索引=标签号)
"""
H, W = image.shape
# 标签图:0=背景,正整数=前景区域标签
label_map = np.zeros((H, W), dtype=np.int32)
flag = np.zeros(256, dtype=np.int32) # flag[k] = 标签k的像素数
x_sign = 0 # 已分配的最大标签号
for j in range(1, H - 1):
for i in range(1, W - 1):
if image[j, i] != 0:
continue # 跳过背景像素,这里前面已经说了,0是前景,其它非0背景
# 按优先级检查4个已访问邻居
# 邻居必须是前景(黑=0)且已分配标签(label>0)
def get_label(rr, cc):
if image[rr, cc] == 0 and label_map[rr, cc] > 0:
return label_map[rr, cc]
return 0
lbl_ru = get_label(j - 1, i + 1) if i + 1 < W else 0
lbl_u = get_label(j - 1, i)
lbl_lu = get_label(j - 1, i - 1) if i - 1 >= 0 else 0
lbl_l = get_label(j, i - 1) if i - 1 >= 0 else 0
# 收集非零邻居标签
nbrs = [lbl for lbl in [lbl_ru, lbl_u, lbl_lu, lbl_l] if lbl > 0]
if nbrs:
# 取第一个(优先级最高)邻居的标签
x_temp = nbrs[0]
label_map[j, i] = x_temp
flag[x_temp] += 1
# 合并所有其他不同标签到x_temp
for y_temp in nbrs[1:]:
if y_temp != x_temp:
_merge_labels(label_map, flag, y_temp, x_temp)
else:
# 无已访问邻居 → 分配新标签
x_sign += 1
if x_sign > 250:
raise RuntimeError("连通区域数目太多(>250),请提高二值化阈值以减少目标数")
label_map[j, i] = x_sign
flag[x_sign] = 1
# 统计有效连通区域数(flag非零的标签数)
num_labels = int(np.sum(flag[1:x_sign + 1] > 0))
return label_map, x_sign, flag
def _merge_labels(label_map, flag, old_label, new_label):
"""将label_map中所有old_label替换为new_label并更新flag。"""
if old_label == new_label or old_label == 0:
return
old_count = flag[old_label]
flag[old_label] = 0
flag[new_label] += old_count
label_map[label_map == old_label] = new_label
- 效果图
通过上面的算法,可以得到连通区域标记的数据, 连通数量,以及flag(各个连通区域对应像素的数量. 我这里本地做了后处理,根据map的信息,把各个连通区域和背景用了不同颜色标记了一下,便于区分.

3.测量连通区域面积和重心
其实这一步关键还在于连通区域的识别,下面代码没有太多逻辑,这里就不赘述.
- python代码
def measure_objects(image):
"""
标记连通区域并测量各区域面积(像素数)和重心坐标。
返回:
(objects, label_map)
objects - list of dict: {'label', 'area', 'cx'(列), 'cy'(行)}
label_map - 标签图
"""
label_map, x_sign, flag = connected_components(image)
objects = []
for t in range(1, x_sign + 1):
if flag[t] == 0:
continue
positions = np.argwhere(label_map == t)
if len(positions) == 0:
continue
# 下面计算算术平均值,就当作图像的中心坐标了.
cy = int(np.mean(positions[:, 0])) # 行均值
cx = int(np.mean(positions[:, 1])) # 列均值
objects.append({
'label': t,
'area': int(flag[t]),
'cx': cx,
'cy': cy,
})
return objects, label_map
- 效果图
下面是脚本运行时打印出来的日志,他根据flag查找到各个连通区域的像素数量当作面积, 重心则使用区域内像素的x,y的平均值.针对规则的图像这样做问题不大,对复杂的图像以及密度不同的对象就要从多方考虑了.
[连通区域标记]
发现 5 个连通区域
区域 1: 面积= 10704 px, 重心=(88,89)
区域 2: 面积= 3068 px, 重心=(208,98)
区域 3: 面积= 2759 px, 重心=(119,183)
区域 9: 面积= 99 px, 重心=(30,173)
区域 11: 面积= 268 px, 重心=(194,191)
4.删除面积小于阈值的目标
这里重点还是先做连通区域标记,找到每个区域的像素计数(即面积)。若面积 < min_area,将该区域的所有像素置为白(背景)。
# 测试时:min_area:100
def remove_small_objects(image, min_area):
"""
删除面积(像素数)小于 min_area 的连通区域。
参数:
min_area - int, 面积下限(小于此值的目标被删除)
"""
label_map, x_sign, flag = connected_components(image)
result = image.copy()
for t in range(1, x_sign + 1):
# 这里测试,我设置的最小阈值为100, 所以从flag区域找出各个区域像素数量
# 小于100的,背景置为白色,如下图所示
if 0 < flag[t] < min_area:
result[label_map == t] = 255 # 置为白(背景)
return result
- 效果图
如下图中,重心坐标(30,173)地方对应的连通图,像素为99,**小于阈值100,**则在图中被抹除了.
区域 1: 面积= 10704 px, 重心=(88,89)
区域 2: 面积= 3068 px, 重心=(208,98)
区域 3: 面积= 2759 px, 重心=(119,183)
区域 9: 面积= 99 px, 重心=(30,173)
区域 11: 面积= 268 px, 重心=(194,191)
5.轮廓跟踪并测量周长(Moore邻域法)
对每个连通区域进行Moore邻域轮廓跟踪,测量边界周长(像素数)。
检测原理
-
对每个连通区域t:
1. 找到第一个属于t的像素作为起始点P0
2. 初始扫描方向 BeginDirect=0(西北方向)
3. 按8方向循环检查邻居:若找到属于t的邻居Pn → 记录Pn为轮廓点
再将BeginDirect逆时针旋转2步(确保不遗漏轮廓像素)否则 → BeginDirect顺时针旋转1步
4. 重复直到回到起始点P0
最终统计各区域的轮廓像素数即为周长。 -
8方向顺序(从NW开始,逆时针):
NW(-1,-1), N(0,-1), NE(1,-1), E(1,0), SE(1,1), S(0,1), SW(-1,1), W(-1,0)
其中 (dc, dr) = (列变化, 行变化) -
python代码
def measure_perimeter(image):
"""
返回:
(perimeters, contour_img)
perimeters - list of {'label', 'perimeter'}
contour_img - shape=(H,W), 仅含轮廓像素(各区域编号,背景=255)
"""
label_map, x_sign, flag = connected_components(image)
H, W = image.shape
contour_img = np.full((H, W), 255, dtype=np.uint8)
# 8方向 (dc, dr):dc=列变化,dr=行变化
# 从NW开始逆时针排列(与C++的BMP坐标系等价)
DIR = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]
perimeters = []
for t in range(1, x_sign + 1):
if flag[t] == 0:
continue
# 找起始点:从上到下、从左到右第一个属于t的像素
positions = np.argwhere(label_map == t)
if len(positions) == 0:
continue
# 取行最小(最上),再取列最小(最左)
start_r = positions[np.argmin(positions[:, 0] * W + positions[:, 1]), 0]
start_c = positions[np.argmin(positions[:, 0] * W + positions[:, 1]), 1]
contour_img[start_r, start_c] = t
cur_r, cur_c = int(start_r), int(start_c)
begin_dir = 0
back_to_start = False
while not back_to_start:
found_next = False
iter_count = 0
while not found_next:
dc, dr = DIR[begin_dir]
nr, nc = cur_r + dr, cur_c + dc
if 0 <= nr < H and 0 <= nc < W and label_map[nr, nc] == t:
found_next = True
cur_r, cur_c = nr, nc
if cur_r == int(start_r) and cur_c == int(start_c):
back_to_start = True
contour_img[cur_r, cur_c] = t
# 逆时针退两步(Moore跟踪的核心:确保不绕圈跳过边界点)
begin_dir = (begin_dir - 2) % 8
else:
# 顺时针转一步继续搜索
begin_dir = (begin_dir + 1) % 8
iter_count += 1
if iter_count > 8:
break # 安全退出(孤立点保护)
peri = int(np.sum(contour_img == t))
perimeters.append({'label': t, 'perimeter': peri})
return perimeters, contour_img
- 效果图
发现 5 个区域
区域 1: 周长= 360 px
区域 2: 周长= 218 px
区域 3: 周长= 168 px
区域 9: 周长= 36 px
区域 11: 周长= 52 px
6.轮廓提取(去除内部像素法)
本方法适用域二值图像,所以在处理前需要将图像转换为二值图像)
-
检测原理:
对每个黑色像素(0),统计其8个邻居中黑色像素的数量。
若8个邻居全为黑(该像素完全被黑色包围)→ 是内部像素 → 置为白(255)。
保留的黑色像素即为边界像素(至少有一个白邻居)。 -
与上面轮廓跟踪的区别:
- 本方法保留所有边界像素,轮廓可能有多个像素宽
- Moore跟踪法只跟踪一条1像素宽的轮廓线 -
python代码
def contour_extraction(image):
"""
通过去除内部像素来提取目标轮廓。
实现:用8邻域和判断(numpy滑动窗口)
"""
# 拷贝一份原图像, 对图像阈值分割,灰度大于127的置白,小于等于127的置黑
binary = image.copy()
binary[binary > 127] = 255
binary[binary <= 127] = 0
H, W = binary.shape
# 统计8个邻居的像素值之和(不含自身)
padded = np.pad(binary.astype(np.int32), 1, constant_values=255)
nbr_sum = np.zeros((H, W), dtype=np.int32)
for dr in range(-1, 2):
for dc in range(-1, 2):
if dr == 0 and dc == 0:
continue # 跳过自身
nbr_sum += padded[1 + dr:H + 1 + dr, 1 + dc:W + 1 + dc]
result = np.full((H, W), 255, dtype=np.uint8)
# 内部点:自身为黑(0) 且 8邻居全为黑(sum=0)→ 置白
interior = (binary == 0) & (nbr_sum == 0)
# 边界点:自身为黑(0) 且 至少有一个白邻居(sum>0)→ 保留为黑
boundary = (binary == 0) & (nbr_sum > 0)
result[boundary] = 0
# interior保持白(255)
return result
- 效果图

7.半阈值分割和对称带通阈值分割
主要区别
- 半阈值分割: (pixel - threshold) < band_width -> 保留灰度值,否则->置白(255)
- 对称带通分割 : |pixel - threshold| < band_width → 将该像素置为threshold(标记)否则 → 置白(255)
7.1半阈值分割
保留灰度值小于 threshold + band_width 的像素(低灰度区域保留)。其余置白(255)。用于提取图像中较暗的区域。
- python代码
# threshold: 128
def band_threshold(image, threshold, band_width=30):
"""
单侧带通滤波分割:保留灰度值满足 (pixel - threshold) < band_width 的像素。
参数:
threshold - int, 基准阈值
band_width - int, 带宽(默认30)
"""
diff = image.astype(np.int32) - threshold
result = np.where(diff < band_width, image, np.uint8(255))
return result.astype(np.uint8)
7.2对称带通分割
若 |pixel - threshold| < band_width → 将该像素置为threshold(标记)否则 → 置白(255)。
适用于提取特定灰度范围内的目标(如某种颜色区域或纹理)。
def band_threshold_symmetric(image, threshold, band_width=80):
"""
对称带通分割:保留与阈值灰度差 < band_width 的像素。
参数:
threshold - int, 目标灰度中心值
band_width - int, 带宽(默认30)
"""
diff = np.abs(image.astype(np.int32) - threshold)
result = np.where(diff < band_width, np.uint8(threshold), np.uint8(255))
return result.astype(np.uint8)
- 效果图
1.半阈值分割: threshold:128, band_width:30
这里我用颜色选取器得到原始图像背景灰度值为192, 那么1(92-128) = 64,不满足小于30,所以背景被置白了.
2.对称带通分割: threshold:128, band_width:80 ,|192-128|=64 < 80, 则将背景置为128,所以看起来是灰色的.




1091

被折叠的 条评论
为什么被折叠?



