跳转到内容
新建笔记

从二值 mask 生成 YOLO 实例分割标签

这篇笔记解决一个具体任务:已有图像及二值掩码,怎样生成 Ultralytics YOLO 实例分割所需的多边形标签。COCO8-seg 是用于快速验证流程的样例数据集;自定义数据集可以沿用目录和标签格式,无需沿用其 80 个类别。

1. 先辨别掩码表示的是什么

跳转到“1. 先辨别掩码表示的是什么”
输入每个像素的含义能否直接按下面脚本处理
二值 mask背景 0、前景一个固定值可以,但须满足下面的实例假设
语义分割 mask数值代表类别不能;同类相邻目标可能已经合并
实例 ID mask数值代表不同物体不能;应逐实例提取并另外查类别映射
RGB 可视化图颜色用于展示不能按灰度阈值随意解释类别

本文脚本只适用于同一类别、互不相连、无孔洞的目标,每个连通前景区域恰好是一个实例。两个物体粘连时,二值图无法恢复它们之间已经丢失的实例边界;一个物体因遮挡分成多个不连通区域时,按连通域计数也不一定正确。这些情况需要保留实例标注信息,不能靠换一个轮廓函数自动解决。

图像与标签同名、不同扩展名,且训练集和验证集分别对应:

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.png

masks/ 是转换的输入,不是训练时必须读取的标签目录。测试集可按需要增加;不得把训练图像简单复制进验证集作为有效评估。

每个标签文件中,一行表示一个物体实例:

class_id x1 y1 x2 y2 ... xn yn

类别从 0 开始。若图像宽为 WW、高为 HH,像素坐标 (xi,yi)(x_i,y_i) 归一化为

ui=xiW,vi=yiH.u_i=\frac{x_i}{W},\qquad v_i=\frac{y_i}{H}.

每个多边形至少包含 3 个不同的点,坐标在 [0,1][0,1] 内。宽高必须来自对应的原图,不能无条件除以 640 和 480;如果 mask 做过缩放或补边,应先把坐标正确映射回原图。格式依据:Ultralytics 分割数据集说明。

例如下面两行属于同一张图的两个实例;数字仅用于说明格式:

0 0.100000 0.200000 0.300000 0.200000 0.300000 0.500000 0.100000 0.500000
0 0.600000 0.100000 0.900000 0.100000 0.900000 0.400000 0.600000 0.400000

dataset.yaml 中使用 names 字段,不是 name。单类示例为:

path: /absolute/path/to/custom-seg
train: images/train
val: images/val
names:
0: target

将 path 改为实际数据集根目录。Windows 也可以使用带盘符的正斜杠路径。若有两个类别,就同时定义 0 和 1,并确保标签里的类别号与名称对应。类别数量由数据集设计决定,不需要把 COCO 的 80 个类别名字全部复制进来。

4. 可运行的二值 mask 转换脚本

跳转到“4. 可运行的二值 mask 转换脚本”

下面使用 OpenCV 4、NumPy 和 Python 标准库。保存为 mask_to_yolo.py。示例读取 PNG 掩码,要求同名图像只有一份,拒绝尺寸不一致、彩色/多值掩码、孔洞和退化轮廓;输出文件已存在时也会拒绝覆盖。

from pathlib import Path
import argparse
import cv2
import 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 的输入约定。

先把归一化坐标乘回图像宽高,再在原图上画多边形边界;逐张或抽样确认位置、类别和实例数。特别检查目标碰边、细长区域、空 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 分割导出在输入 640×640640\times640、三个检测尺度、32 个 mask 系数且未融合 NMS 时,可能输出候选张量 [B,4+C+32,8400][B,4+C+32,8400] 和原型张量 [B,32,160,160][B,32,160,160]。这里 CC 是实际类别数:

  • C=80C=80 时,候选通道数为 4+80+32=1164+80+32=116。
  • C=2C=2 时,候选通道数为 4+2+32=384+2+32=38,不能仍写成 80 类。
  • 8400=802+402+2028400=80^2+40^2+20^2 来自这个输入尺寸与尺度组合,不是所有模型都固定为 8400。

输入尺寸、模型版本、导出选项、动态维度和后处理都会改变输出接口,应读取实际模型而非仅据笔记硬编码。数据集多边形标签的点数与推理候选数 8400 不是同一概念。

保留原作者的 mask 转多边形任务与 YOLOv8 背景,重写转换代码并修正 names、类别数和尺寸处理。旧稿中的两张重复图片实际都是“图片转存失败”占位海报,已撤下正文引用,没有据占位图臆造原始示例。历史来源:CSDN 原文,首次发布于 2024-06-24。