使用 Python 解决YOLO-seg分割出现接触点异常突起:从单一接触点平滑到贝塞尔曲线替换
1. 背景与需求
在目标检测与图像分割中,YOLOv5-seg 格式是一种比较方便的标注方式。其标注文件通常形如:
class_id x_center y_center w h x1 y1 x2 y2 ... xN yN
- class_id:目标类别 ID
- x_center, y_center, w, h:目标外接矩形的中心坐标与宽高(归一化或实际尺寸)
- (x1,y1), (x2,y2), …(xN,yN):目标分割多边形的顶点坐标,通常按轮廓顺序排列。
在实际应用中,我们有时需要分步对标注多边形进行处理:
- 先对多边形做一个特定的“单一接触点”平滑(例如基于高斯平滑,但又保留多边形与某框
bbox
的唯一接触点)。 - 再基于第一步的输出,对平滑后的多边形执行替换最长线段为贝塞尔曲线的操作(可将多边形变得更圆滑、自然),并可在此过程中再做一次可选的高斯平滑。
需求点:
- 处理完第一步后,结果能自动供第二步继续操作。
- 最终要输出新的 YOLOv5-seg 标注文件,包含重新计算的外接矩形。
本篇将给出整合的 Python 代码,实现一次性脚本,先执行第一阶段,后执行第二阶段,中间结果自动传递给下一阶段,轻松完成多步处理。
2. 总体思路
整个流程可以拆为以下两个阶段:
-
第一阶段(Part1):
- 从文件夹读取 YOLOv5-seg 标注:只提取多边形信息。
- 针对每个多边形,先计算外接框(bbox),然后根据 bbox 找到唯一接触点(若存在),对该多边形进行高斯平滑,但保留接触点不动。
- 生成新的多边形及其外接框,并保存到指定输出文件夹。
- 输出的文件依旧是 YOLOv5-seg 格式。
-
第二阶段(Part2):
- 从第一阶段输出的文件夹读取 YOLOv5-seg 标注(这次函数读到更多信息:class_id、bbox、polygon)。
- 针对每个多边形:
- 找到最长边;
- 用二次贝塞尔曲线替换该最长线段(可允许控制点超出原 bbox);
- (可选)再次对多边形做高斯平滑;
- 重新计算外接框;
- 结果保存到第二阶段的输出文件夹。
这样做的好处是:
- 第一阶段先完成一些对特定“接触点”平滑的定制逻辑;
- 第二阶段对“平滑后”多边形进行“贝塞尔替换”。
- 两个阶段串联,自动完成后续处理。
3. 代码实现详解
下面给出的整合脚本,包含了两部分主要逻辑(Part1 和 Part2),以及一个主函数中按先后顺序来执行。代码中有相应注释方便理解。
说明:文中出现的
input_folder_1
,inter_folder
,output_folder_2
路径可根据实际情况替换;也可直接将 Part1 的输出文件夹设置与 Part2 的输入文件夹相同,甚至只保留内存中的数据,不同场景灵活处理。
3.1 代码整体
import os
import numpy as np
from scipy.ndimage import gaussian_filter1d
######################
# 第一部分:相关函数
######################
def read_yolov5_seg_file_part1(file_path):
"""
第一阶段读取函数:
返回多边形列表 [poly1, poly2, ...],
每个 poly 为 (N,2) 的 np.array。
"""
with open(file_path, 'r') as f:
lines = f.readlines()
polygons = []
for line in lines:
parts = line.strip().split()
# 前5个字段为 class_id, x_center, y_center, w, h,后续则是多边形顶点
if len(parts) < 6:
continue
points = [float(x) for x in parts[5:]]
poly = np.array(points).reshape(-1, 2)
polygons.append(poly)
return polygons
def find_single_contact_point(polygon, bbox):
"""
给定 bbox=[xc, yc, w, h],判断 polygon 哪些点在 bbox 范围内,
并只返回第一个。
"""
min_x = bbox[0] - bbox[2]/2
max_x = bbox[0] + bbox[2]/2
min_y = bbox[1] - bbox[3]/2
max_y = bbox[1] + bbox[3]/2
contact_points = []
for point in polygon:
x, y = point
if min_x <= x <= max_x and min_y <= y <= max_y:
contact_points.append(point)
if contact_points:
return contact_points[0]
else:
return None
def smooth_polygon_points_with_single_contact(polygon, contact_point, sigma=1):
"""
对多边形的非接触点做高斯平滑,接触点保持原位。
若 contact_point=None,则整体平滑。
"""
if contact_point is None:
# 整体平滑
x_smooth = gaussian_filter1d(polygon[:, 0], sigma=sigma)
y_smooth = gaussian_filter1d(polygon[:, 1], sigma=sigma)
return np.column_stack((x_smooth, y_smooth))
# 否则,只平滑非接触点
smoothed_polygon = polygon.copy()
indices_non_contact = []
contact_idx = -1
# 找出非接触点
for i, pt in enumerate(polygon):
if np.allclose(pt, contact_point):
contact_idx = i
else:
indices_non_contact.append(i)
# 对非接触点分别平滑
poly_non_contact = polygon[indices_non_contact]
x_smooth = gaussian_filter1d(poly_non_contact[:, 0], sigma=sigma)
y_smooth = gaussian_filter1d(poly_non_contact[:, 1], sigma=sigma)
for j, idx in enumerate(indices_non_contact):
smoothed_polygon[idx, 0] = x_smooth[j]
smoothed_polygon[idx, 1] = y_smooth[j]
return smoothed_polygon
def calculate_bounding_box(polygon):
"""
计算多边形外接矩形 [x_center, y_center, w, h]
"""
min_x, min_y = np.min(polygon, axis=0)
max_x, max_y = np.max(polygon, axis=0)
x_center = (min_x + max_x) / 2.0
y_center = (min_y + max_y) / 2.0
w = max_x - min_x
h = max_y - min_y
return [x_center, y_center, w, h]
def save_smoothed_polygons_to_txt(polygons, output_path, class_id=0):
"""
将多边形写回 txt,行格式:
class_id xc yc w h x1 y1 x2 y2 ...
(仅使用一个统一的 class_id=0,如需保留原class可自行修改。)
"""
with open(output_path, 'w') as f:
for poly in polygons:
bbox = calculate_bounding_box(poly)
coords = poly.flatten()
line_parts = [
str(class_id),
str(bbox[0]), str(bbox[1]),
str(bbox[2]), str(bbox[3])
] + list(map(str, coords))
f.write(' '.join(line_parts) + '\n')
def process_batch_part1(input_folder, output_folder, sigma=1):
"""
第一部分:批量处理
1) 读txt -> 得到polygons
2) 找接触点 + 平滑
3) 保存到 output_folder
"""
if not os.path.exists(output_folder):
os.makedirs(output_folder)
for filename in os.listdir(input_folder):
if filename.endswith('.txt'):
in_path = os.path.join(input_folder, filename)
out_path = os.path.join(output_folder, filename)
polygons = read_yolov5_seg_file_part1(in_path)
smoothed_polys = []
for poly in polygons:
bbox = calculate_bounding_box(poly)
cpt = find_single_contact_point(poly, bbox)
smoothed_poly = smooth_polygon_points_with_single_contact(poly, cpt, sigma=sigma)
smoothed_polys.append(smoothed_poly)
save_smoothed_polygons_to_txt(smoothed_polys, out_path)
print(f"[Part1] 处理完成: {in_path} -> {out_path}")
######################
# 第二部分:相关函数
######################
def read_yolov5_seg_file_part2(file_path):
"""
第二阶段读取函数:
返回 [(class_id, [xc, yc, w, h], polygon), ...]
"""
polygons_info = []
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
parts = line.strip().split()
if len(parts) < 6:
continue
class_id = int(parts[0])
x_center, y_center, w, h = map(float, parts[1:5])
coords = [float(x) for x in parts[5:]]
polygon = np.array(coords).reshape(-1, 2)
polygons_info.append((class_id, [x_center, y_center, w, h], polygon))
return polygons_info
def replace_longest_line_with_curve(polygon, bbox, num_subdiv=15, offset_scale=0.1):
"""
找到 polygon 的最长边,用二次贝塞尔曲线替换。
offset_scale 控制曲线弧度大小,可超出 bbox。
"""
if len(polygon) < 3:
return polygon
# 1. 找到最长边
max_dist = -1
max_idx = -1
for i in range(len(polygon)):
j = (i + 1) % len(polygon)
dist_ij = np.linalg.norm(polygon[j] - polygon[i])
if dist_ij > max_dist:
max_dist = dist_ij
max_idx = i
i0 = max_idx
i1 = (i0 + 1) % len(polygon)
P0 = polygon[i0]
P2 = polygon[i1]
# 2. 构造控制点 P1
midpoint = (P0 + P2) / 2.0
d = P2 - P0
normal = np.array([-d[1], d[0]])
norm_len = np.linalg.norm(normal)
if norm_len < 1e-8:
return polygon
normal_unit = normal / norm_len
offset = offset_scale * max_dist
P1 = midpoint + offset * normal_unit
# 3. 生成贝塞尔曲线
new_points = []
for k in range(num_subdiv + 1):
t = k / float(num_subdiv)
B_t = (1 - t)**2 * P0 + 2 * t * (1 - t) * P1 + t**2 * P2
new_points.append(B_t)
new_points = np.array(new_points)
# 4. 替换
new_polygon = []
for idx in range(len(polygon)):
if idx == i0:
new_polygon.extend(new_points)
elif idx == i1:
continue
else:
new_polygon.append(polygon[idx])
return np.array(new_polygon)
def smooth_polygon_points_with_gaussian(polygon, sigma=1.0):
"""
对多边形做简单高斯平滑 (一维滤波)。
"""
x_smooth = gaussian_filter1d(polygon[:, 0], sigma=sigma)
y_smooth = gaussian_filter1d(polygon[:, 1], sigma=sigma)
return np.column_stack((x_smooth, y_smooth))
def save_yolov5_seg_file(polygons_info, output_path):
"""
第二阶段的保存函数,写回 YOLOv5-seg 格式:
class_id xc yc w h x1 y1 x2 y2 ...
"""
with open(output_path, 'w', encoding='utf-8') as f:
for (class_id, bbox, polygon) in polygons_info:
coords = polygon.flatten()
line_parts = [
str(class_id),
str(bbox[0]), str(bbox[1]),
str(bbox[2]), str(bbox[3])
] + list(map(str, coords))
f.write(' '.join(line_parts) + '\n')
def process_single_yolov5_seg_file(input_file,
output_file,
use_gaussian_smooth=False,
sigma=1.0):
"""
第二阶段:单个文件的处理流程
1) 读取 (class_id, bbox, polygon)
2) 替换最长边 -> 贝塞尔曲线
3) (可选) 再高斯平滑
4) 重新计算 bbox
5) 写回
"""
polygons_info = read_yolov5_seg_file_part2(input_file)
new_polygons_info = []
for (class_id, bbox, polygon) in polygons_info:
# 先替换最长线段
new_polygon = replace_longest_line_with_curve(
polygon, bbox,
num_subdiv=15,
offset_scale=0.1
)
# 再平滑
if use_gaussian_smooth:
new_polygon = smooth_polygon_points_with_gaussian(new_polygon, sigma=sigma)
# 重算 bbox
new_bbox = calculate_bounding_box(new_polygon)
new_polygons_info.append((class_id, new_bbox, new_polygon))
save_yolov5_seg_file(new_polygons_info, output_file)
def process_folder_of_yolov5_seg_files(input_folder,
output_folder,
use_gaussian_smooth=False,
sigma=1.0):
"""
第二阶段:批量处理整个文件夹
"""
if not os.path.exists(output_folder):
os.makedirs(output_folder)
for filename in os.listdir(input_folder):
if filename.endswith('.txt'):
in_file = os.path.join(input_folder, filename)
out_file = os.path.join(output_folder, filename)
process_single_yolov5_seg_file(
in_file,
out_file,
use_gaussian_smooth=use_gaussian_smooth,
sigma=sigma
)
print(f"[Part2] 处理完成: {in_file} -> {out_file}")
######################
# 主函数 - 两段串行执行
######################
if __name__ == "__main__":
# ============ 第1阶段:单一接触点平滑 ============
input_folder_1 = r"E:\path\to\labels_raw" # 原始标注文件夹
inter_folder = r"E:\path\to\labels_intermediate" # 第一阶段输出(中间结果)
process_batch_part1(input_folder_1, inter_folder, sigma=2)
# sigma=2 表示第一阶段做一次较明显的高斯平滑
# ============ 第2阶段:替换最长线段 + (可选) 高斯平滑 ============
output_folder_2 = r"E:\path\to\labels_final" # 最终输出文件夹
process_folder_of_yolov5_seg_files(
input_folder=inter_folder,
output_folder=output_folder_2,
use_gaussian_smooth=True, # 是否再次平滑
sigma=2.0
)
print("所有文件处理完成!")
3.2 核心逻辑概述
-
第一部分:
- 输入:
input_folder_1
中的.txt
文件(YOLOv5-seg 格式)。 - 读取:用
read_yolov5_seg_file_part1
,只提取多边形坐标。 - 处理:
- 计算多边形的外接框
bbox
; - 根据
bbox
找到多边形的第一个接触点; - 对除了该接触点之外的点进行高斯平滑;
- 计算多边形的外接框
- 输出:用
save_smoothed_polygons_to_txt
写到中间文件夹inter_folder
。
- 输入:
-
第二部分:
- 输入:第一阶段中间文件夹
inter_folder
里的.txt
; - 读取:用
read_yolov5_seg_file_part2
,这次返回(class_id, bbox, polygon)
列表; - 处理:
- 找多边形的最长边 -> 用二次贝塞尔曲线替换;
- 可选:再次高斯平滑(即
use_gaussian_smooth=True
); - 重算
bbox
;
- 输出:写到
output_folder_2
。
- 输入:第一阶段中间文件夹
这样,两部分就串行执行,自动化完成。
4. 使用演示
-
准备数据:
- 将原始 YOLOv5-seg 标注
.txt
文件放入E:\path\to\labels_raw
文件夹。
- 将原始 YOLOv5-seg 标注
-
运行脚本:
- 直接运行此脚本(例如在命令行
python main.py
或在 IDE 中运行),脚本会:- 先调用
process_batch_part1
,将处理结果输出到labels_intermediate
; - 再调用
process_folder_of_yolov5_seg_files
,处理完后输出到labels_final
。
- 先调用
- 直接运行此脚本(例如在命令行
-
结果查看:
- 在
labels_intermediate
可以看到第一阶段平滑后的多边形与文本; - 在
labels_final
则可以看到最终被贝塞尔替换的多边形。
- 在
5. 注意事项与改进思路
-
类 ID 的保留
- 在第一阶段中,示例是直接使用
class_id=0
写回。如果需要保留原始 class_id,则需要在第一阶段同样读取并记录class_id
,并在写回时使用原有的class_id
。
- 在第一阶段中,示例是直接使用
-
多边形顺序
- 若希望多边形首尾相连且需要做环绕平滑,可以尝试循环平滑(比如
gaussian_filter1d
的mode='wrap'
),以免首尾处断裂。
- 若希望多边形首尾相连且需要做环绕平滑,可以尝试循环平滑(比如
-
贝塞尔曲线参数
offset_scale=0.1
越大,曲线弧度越大;num_subdiv=15
越大,曲线越精细。可结合实际需求灵活调节。- 如果想限制曲线范围不出原 bbox,可以在计算
P1
时加上clip操作。
-
处理效率
- 对于大量标注文件,这些操作基本都是Numpy计算,速度尚可。但如果数据量非常大,可以考虑多线程或分批处理。
-
自定义再处理
- 如果要再做更多定制处理(如:随机抖动、旋转、缩放等),也可插入到代码流程中。例如在第一阶段处理完后就进行一些数据增强,再写到中间文件夹,供第二阶段使用。
6. 总结
通过这样一份整合脚本,我们实现了多步自动化处理 YOLOv5-seg 标注:先做“接触点”平滑,再做“替换最长边 + 可选二次平滑”。在此过程中,我们仔细区分了第一阶段与第二阶段的 I/O 细节,并在主函数的 if __name__ == "__main__":
部分进行了顺序调用。这样开发者只需一次执行脚本,自动完成两阶段的处理,不用再手动将第一阶段结果重新加载给第二阶段,可大幅提升数据处理的效率与可维护性。
希望这篇文章对你有所帮助!如需更多自定义逻辑,可在现有框架的基础上灵活拓展,比如增加更多滤波方式、引入更高级的曲线替换(如三次贝塞尔或样条曲线)、保留或修改原始类标签信息,等等。祝大家玩得开心,项目顺利!