解决YOLO-seg分割出现接触点异常突起:从单一接触点平滑到贝塞尔曲线替换


使用 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):目标分割多边形的顶点坐标,通常按轮廓顺序排列。

在实际应用中,我们有时需要分步对标注多边形进行处理:

  1. 对多边形做一个特定的“单一接触点”平滑(例如基于高斯平滑,但又保留多边形与某框 bbox 的唯一接触点)。
  2. 基于第一步的输出,对平滑后的多边形执行替换最长线段为贝塞尔曲线的操作(可将多边形变得更圆滑、自然),并可在此过程中再做一次可选的高斯平滑。

需求点

  • 处理完第一步后,结果能自动供第二步继续操作。
  • 最终要输出新的 YOLOv5-seg 标注文件,包含重新计算的外接矩形。

本篇将给出整合的 Python 代码,实现一次性脚本,先执行第一阶段,后执行第二阶段,中间结果自动传递给下一阶段,轻松完成多步处理。


2. 总体思路

整个流程可以拆为以下两个阶段:

  1. 第一阶段(Part1):

    • 从文件夹读取 YOLOv5-seg 标注:只提取多边形信息。
    • 针对每个多边形,先计算外接框(bbox),然后根据 bbox 找到唯一接触点(若存在),对该多边形进行高斯平滑,但保留接触点不动。
    • 生成新的多边形及其外接框,并保存到指定输出文件夹。
    • 输出的文件依旧是 YOLOv5-seg 格式。
  2. 第二阶段(Part2):

    • 第一阶段输出的文件夹读取 YOLOv5-seg 标注(这次函数读到更多信息:class_id、bbox、polygon)。
    • 针对每个多边形:
      1. 找到最长边
      2. 二次贝塞尔曲线替换该最长线段(可允许控制点超出原 bbox);
      3. (可选)再次对多边形做高斯平滑;
      4. 重新计算外接框;
    • 结果保存到第二阶段的输出文件夹。

这样做的好处是:

  • 第一阶段先完成一些对特定“接触点”平滑的定制逻辑;
  • 第二阶段对“平滑后”多边形进行“贝塞尔替换”。
  • 两个阶段串联,自动完成后续处理。

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 核心逻辑概述

  1. 第一部分

    • 输入input_folder_1 中的 .txt 文件(YOLOv5-seg 格式)。
    • 读取:用 read_yolov5_seg_file_part1,只提取多边形坐标。
    • 处理
      • 计算多边形的外接框 bbox
      • 根据 bbox 找到多边形的第一个接触点
      • 对除了该接触点之外的点进行高斯平滑
    • 输出:用 save_smoothed_polygons_to_txt 写到中间文件夹 inter_folder
  2. 第二部分

    • 输入:第一阶段中间文件夹 inter_folder 里的 .txt
    • 读取:用 read_yolov5_seg_file_part2,这次返回 (class_id, bbox, polygon) 列表;
    • 处理
      • 找多边形的最长边 -> 用二次贝塞尔曲线替换;
      • 可选:再次高斯平滑(即 use_gaussian_smooth=True);
      • 重算 bbox
    • 输出:写到 output_folder_2

这样,两部分就串行执行,自动化完成。


4. 使用演示

  1. 准备数据

    • 将原始 YOLOv5-seg 标注 .txt 文件放入 E:\path\to\labels_raw 文件夹。
  2. 运行脚本

    • 直接运行此脚本(例如在命令行 python main.py 或在 IDE 中运行),脚本会:
      • 先调用 process_batch_part1,将处理结果输出到 labels_intermediate
      • 再调用 process_folder_of_yolov5_seg_files,处理完后输出到 labels_final
  3. 结果查看

    • labels_intermediate 可以看到第一阶段平滑后的多边形与文本;
    • labels_final 则可以看到最终被贝塞尔替换的多边形。

5. 注意事项与改进思路

  1. 类 ID 的保留

    • 在第一阶段中,示例是直接使用 class_id=0 写回。如果需要保留原始 class_id,则需要在第一阶段同样读取并记录 class_id,并在写回时使用原有的 class_id
  2. 多边形顺序

    • 若希望多边形首尾相连且需要做环绕平滑,可以尝试循环平滑(比如 gaussian_filter1dmode='wrap'),以免首尾处断裂。
  3. 贝塞尔曲线参数

    • offset_scale=0.1 越大,曲线弧度越大;num_subdiv=15 越大,曲线越精细。可结合实际需求灵活调节。
    • 如果想限制曲线范围不出原 bbox,可以在计算 P1 时加上clip操作。
  4. 处理效率

    • 对于大量标注文件,这些操作基本都是Numpy计算,速度尚可。但如果数据量非常大,可以考虑多线程或分批处理。
  5. 自定义再处理

    • 如果要再做更多定制处理(如:随机抖动、旋转、缩放等),也可插入到代码流程中。例如在第一阶段处理完后就进行一些数据增强,再写到中间文件夹,供第二阶段使用。

6. 总结

通过这样一份整合脚本,我们实现了多步自动化处理 YOLOv5-seg 标注:先做“接触点”平滑,再做“替换最长边 + 可选二次平滑”。在此过程中,我们仔细区分了第一阶段第二阶段的 I/O 细节,并在主函数的 if __name__ == "__main__": 部分进行了顺序调用。这样开发者只需一次执行脚本,自动完成两阶段的处理,不用再手动将第一阶段结果重新加载给第二阶段,可大幅提升数据处理的效率与可维护性。

希望这篇文章对你有所帮助!如需更多自定义逻辑,可在现有框架的基础上灵活拓展,比如增加更多滤波方式、引入更高级的曲线替换(如三次贝塞尔或样条曲线)、保留或修改原始类标签信息,等等。祝大家玩得开心,项目顺利!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值