这篇笔记解决一个具体任务:已有图像及二值掩码,怎样生成 Ultralytics YOLO 实例分割所需的多边形标签。COCO8-seg 是用于快速验证流程的样例数据集;自定义数据集可以沿用目录和标签格式,无需沿用其 80 个类别。
1. 先辨别掩码表示的是什么
跳转到“1. 先辨别掩码表示的是什么”| 输入 | 每个像素的含义 | 能否直接按下面脚本处理 |
|---|---|---|
| 二值 mask | 背景 0、前景一个固定值 | 可以,但须满足下面的实例假设 |
| 语义分割 mask | 数值代表类别 | 不能;同类相邻目标可能已经合并 |
| 实例 ID mask | 数值代表不同物体 | 不能;应逐实例提取并另外查类别映射 |
| RGB 可视化图 | 颜色用于展示 | 不能按灰度阈值随意解释类别 |
本文脚本只适用于同一类别、互不相连、无孔洞的目标,每个连通前景区域恰好是一个实例。两个物体粘连时,二值图无法恢复它们之间已经丢失的实例边界;一个物体因遮挡分成多个不连通区域时,按连通域计数也不一定正确。这些情况需要保留实例标注信息,不能靠换一个轮廓函数自动解决。
2. 目录和标签格式
跳转到“2. 目录和标签格式”图像与标签同名、不同扩展名,且训练集和验证集分别对应:
custom-seg/├── dataset.yaml├── images/│ ├── train/sample_001.jpg│ └── val/sample_101.jpg├── labels/│ ├── train/sample_001.txt│ └── val/sample_101.txt└── masks/ ├── train/sample_001.png └── val/sample_101.pngmasks/ 是转换的输入,不是训练时必须读取的标签目录。测试集可按需要增加;不得把训练图像简单复制进验证集作为有效评估。
每个标签文件中,一行表示一个物体实例:
class_id x1 y1 x2 y2 ... xn yn类别从 0 开始。若图像宽为 、高为 ,像素坐标 归一化为
每个多边形至少包含 3 个不同的点,坐标在 内。宽高必须来自对应的原图,不能无条件除以 640 和 480;如果 mask 做过缩放或补边,应先把坐标正确映射回原图。格式依据:Ultralytics 分割数据集说明。
例如下面两行属于同一张图的两个实例;数字仅用于说明格式:
0 0.100000 0.200000 0.300000 0.200000 0.300000 0.500000 0.100000 0.5000000 0.600000 0.100000 0.900000 0.100000 0.900000 0.400000 0.600000 0.4000003. 最小自定义配置
跳转到“3. 最小自定义配置”dataset.yaml 中使用 names 字段,不是 name。单类示例为:
path: /absolute/path/to/custom-segtrain: images/trainval: images/valnames: 0: target将 path 改为实际数据集根目录。Windows 也可以使用带盘符的正斜杠路径。若有两个类别,就同时定义 0 和 1,并确保标签里的类别号与名称对应。类别数量由数据集设计决定,不需要把 COCO 的 80 个类别名字全部复制进来。
4. 可运行的二值 mask 转换脚本
跳转到“4. 可运行的二值 mask 转换脚本”下面使用 OpenCV 4、NumPy 和 Python 标准库。保存为 mask_to_yolo.py。示例读取 PNG 掩码,要求同名图像只有一份,拒绝尺寸不一致、彩色/多值掩码、孔洞和退化轮廓;输出文件已存在时也会拒绝覆盖。
from pathlib import Pathimport argparseimport cv2import numpy as np
def mask_to_rows(mask, class_id): if isinstance(class_id, bool) or not isinstance(class_id, int) or class_id < 0: raise ValueError("class_id must be a non-negative integer") if mask is None or mask.ndim != 2 or mask.size == 0: raise ValueError("mask must be a non-empty single-channel image") if not np.issubdtype(mask.dtype, np.integer): raise ValueError("mask must use integer pixel values") values = np.unique(mask) if np.any(values < 0) or np.count_nonzero(values) > 1: raise ValueError("expected background 0 and one fixed foreground value")
height, width = mask.shape binary = (mask != 0).astype(np.uint8) * 255 contours, hierarchy = cv2.findContours( binary, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE ) if hierarchy is not None and np.any(hierarchy[0, :, 3] >= 0): raise ValueError("mask contains holes; this example does not flatten holes")
rows = [] for contour in sorted(contours, key=lambda c: cv2.boundingRect(c)[:2]): points = contour.reshape(-1, 2) if len(np.unique(points, axis=0)) < 3 or cv2.contourArea(contour) <= 0: raise ValueError("degenerate foreground region cannot form a polygon") normalized = points.astype(np.float64) / np.array([width, height]) coordinates = " ".join(f"{value:.8f}" for value in normalized.ravel()) rows.append(f"{class_id} {coordinates}") return rows
def convert_directory(mask_dir, image_dir, output_dir, class_id): masks = sorted(mask_dir.glob("*.png")) if not masks: raise ValueError("no PNG masks found") image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"} images = [p for p in image_dir.iterdir() if p.suffix.lower() in image_extensions] pending = [] for mask_path in masks: matches = [p for p in images if p.stem == mask_path.stem] if len(matches) != 1: raise ValueError(f"expected exactly one image for {mask_path.name}") image = cv2.imread(str(matches[0]), cv2.IMREAD_UNCHANGED) mask = cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) if image is None or mask is None or image.shape[:2] != mask.shape[:2]: raise ValueError(f"unreadable image or size mismatch: {mask_path.name}") rows = mask_to_rows(mask, class_id) destination = output_dir / f"{mask_path.stem}.txt" if destination.exists(): raise FileExistsError(destination) pending.append((destination, "\n".join(rows) + ("\n" if rows else "")))
output_dir.mkdir(parents=True, exist_ok=True) for destination, text in pending: with destination.open("x", encoding="utf-8", newline="\n") as stream: stream.write(text) return len(pending)
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--masks", required=True, type=Path) parser.add_argument("--images", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--class-id", type=int, default=0) args = parser.parse_args() count = convert_directory(args.masks, args.images, args.output, args.class_id) print(f"Converted {count} mask files")运行示例:
python mask_to_yolo.py --masks custom-seg/masks/train --images custom-seg/images/train --output custom-seg/labels/train --class-id 0对验证集再运行一次并换成对应目录。脚本先完成整批读取与格式检查,再创建输出;磁盘写入仍可能失败,所以它不是文件系统事务。若写入中断,应检查已生成文件,使用新的空输出目录重试。
代码中所有有效外轮廓都会转换,不再只取 contours[0]。无前景的合法二值 mask 生成空标签文件,表示该图没有目标。孔洞和退化区域会明确报错,避免悄悄改变标注含义。OpenCV 的轮廓与层级说明解释了 RETR_CCOMP 的两级层次与 findContours 的输入约定。
5. 生成后怎样检查
跳转到“5. 生成后怎样检查”先把归一化坐标乘回图像宽高,再在原图上画多边形边界;逐张或抽样确认位置、类别和实例数。特别检查目标碰边、细长区域、空 mask、多目标与小目标,不能只看“成功写出文件”的数量。
轮廓点位于像素网格上,多边形连续面积与前景像素数一般不完全相同。CHAIN_APPROX_SIMPLE 会移除直线段中的冗余点;是否保留细节应通过叠图检查。多边形标签也不能无条件表达带孔洞或多个离散部分的任意 mask。
确认数据后,若使用历史 YOLOv8 分割模型,可先做小规模验证:
yolo segment train model=yolov8n-seg.pt data=custom-seg/dataset.yaml epochs=1 imgsz=640训练前记录实际 Ultralytics 版本、模型与数据集划分;一个 epoch 只用于检查流程能否跑通,不是模型质量验证。
6. 类别数与导出张量不要混淆
跳转到“6. 类别数与导出张量不要混淆”某些 YOLOv8 分割导出在输入 、三个检测尺度、32 个 mask 系数且未融合 NMS 时,可能输出候选张量 和原型张量 。这里 是实际类别数:
- 时,候选通道数为 。
- 时,候选通道数为 ,不能仍写成 80 类。
- 来自这个输入尺寸与尺度组合,不是所有模型都固定为 8400。
输入尺寸、模型版本、导出选项、动态维度和后处理都会改变输出接口,应读取实际模型而非仅据笔记硬编码。数据集多边形标签的点数与推理候选数 8400 不是同一概念。
来源与修订说明
跳转到“来源与修订说明”保留原作者的 mask 转多边形任务与 YOLOv8 背景,重写转换代码并修正 names、类别数和尺寸处理。旧稿中的两张重复图片实际都是“图片转存失败”占位海报,已撤下正文引用,没有据占位图臆造原始示例。历史来源:CSDN 原文,首次发布于 2024-06-24。