纠结皮肤检测有不少时日了,在上一次采用YOLOv8n 成功训练出毛孔检测权重文件应用后,这次色素斑块检测也发现了传统CV方法的不足。
一本正经反复调试迭代以后,CV处理色素图像也不是说完全不行,就是当光照、肤色变化,再混合反光、模糊、暗角等条件,基于各种特征及阈值的围捕得到得色块检测显得非常力不从心。下面还是来记录一下这次用yolov8n-seg做肤色检测的过程吧。
(1)为什么要用yolov8n-seg
要论到分割模型,好用的非常多。U-NET早就小试过牛刀,但是真的很慢。之前跑的yolov8n 其实就是最小的模型,在没有GPU而且cpu非常低等的设备上验证勉强够用,我就没有打算折腾了。听说RF-DETR才是检测的精度登顶、快速推理和无NMS设计的诸多优点之最,有时间我一定要去体验一把。感觉使用模型拯救传统CV的不足会上瘾啊~
因跑过了yolov8n, yolov8n-seg用起来非常快,都不用额外下载啥包裹, 唯一的差异就是task是segment。还有一些参数差异.
(2)训练记录
1)色块标注
以前用Labelme习惯了,这次却发现标注色块不太够用。因为色素块都是絮状不规则边缘(图像有高倍放大),如果直接标注矩形、圆形或者规则多边形都不是那么贴合,于是乎想到用不规则多边形标注,总之就是随着手动自由画圈出色块。
没错, 就是用X-AnyLabeling 做的标注. 是一种相见恨晚的感觉。

这个工具使用非常顺手,编辑很方便,做完都是自动存储。还具备自动标注的功能. 为了安全起见,第一次我都是靠手工标注了200+张图。
2)将标注好的图片及json文件整理成YOLO可以训练的数据集
AI时代,手动盘好像很不合时宜,当然要靠代码帮忙敲定。
如下是整理成yolo训练模型的格式, 其实就是训练模型权重文件前的预处理
preprocess_data.py
bash
"""
将 x-anything-labeling (labelme格式) 的 JSON 标注
转换为 YOLOv8-seg 训练格式
输出结构:
output_dir/
├── images/ # 图片文件(软链接或复制)
├── labels/ # 对应的 .txt 标注文件
└── data.yaml # 数据集配置文件
YOLOv8-seg 标注格式 (每个 .txt 文件):
class_id x1 y1 x2 y2 x3 y3 ... xn yn
坐标归一化到 [0, 1]
"""
import json
import os
import shutil
import glob
from pathlib import Path
# ============ 配置 ============
# 标注数据目录(包含 .json 和 .png/.jpg 文件)
INPUT_DIR = r"D:\data\Skin_Img\pigment_IMG\preprocessed_20260728"#这是参与标注的图片和标注输出的json的文件夹
# 输出目录(YOLO 格式数据集)
OUTPUT_DIR = r"D:\data\Skin_Img\pigment_IMG\yolo_seg_dataset"#这是我希望输出的文件父目录
# 类别名称(按顺序,索引从0开始)
# 注意:这里按字母顺序排序,确保一致性
CLASS_NAMES = ["pigmeng", "red"] #这里是我在标注时的label
# 训练/验证集划分比例
TRAIN_RATIO = 0.85 #这里可以设置为70%~85%
# =============================
def convert_labelme_to_yolo_seg(json_path, output_label_dir, class_to_idx):
"""
将单个 labelme JSON 文件转换为 YOLOv8-seg 格式的 .txt 文件
"""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
img_width = data.get("imageWidth", 0)
img_height = data.get("imageHeight", 0)
if img_width == 0 or img_height == 0:
print(f" [警告] 图片尺寸无效: {json_path}")
return False
# 生成输出文件名(与 JSON 同名,扩展名为 .txt)
base_name = Path(json_path).stem
txt_path = os.path.join(output_label_dir, base_name + ".txt")
lines = []
for shape in data.get("shapes", []):
label = shape.get("label", "")
points = shape.get("points", [])
shape_type = shape.get("shape_type", "polygon")
# 只处理多边形
if shape_type != "polygon" or len(points) < 3:
continue
# 获取类别索引
if label not in class_to_idx:
print(f" [警告] 未知标签 '{label}',跳过")
continue
class_id = class_to_idx[label]
# 归一化坐标 (x/width, y/height)
normalized_points = []
for x, y in points:
nx = x / img_width
ny = y / img_height
# 裁剪到 [0, 1] 范围
nx = max(0.0, min(1.0, nx))
ny = max(0.0, min(1.0, ny))
normalized_points.append(f"{nx:.6f}")
normalized_points.append(f"{ny:.6f}")
# YOLO 格式: class_id x1 y1 x2 y2 ...
line = f"{class_id} " + " ".join(normalized_points)
lines.append(line)
if not lines:
print(f" [警告] 无有效标注: {json_path}")
return False
# 写入 .txt 文件
with open(txt_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
return True
def copy_image(json_path, output_image_dir):
"""
复制对应的图片文件到输出目录
"""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
img_rel_path = data.get("imagePath", "")
if not img_rel_path:
return None
# 图片路径:相对于 JSON 文件所在目录
img_path = os.path.join(os.path.dirname(json_path), img_rel_path)
if not os.path.exists(img_path):
# 尝试在同目录下找同名图片
base_name = Path(json_path).stem
for ext in [".png", ".jpg", ".jpeg", ".bmp"]:
candidate = os.path.join(os.path.dirname(json_path), base_name + ext)
if os.path.exists(candidate):
img_path = candidate
break
else:
print(f" [错误] 找不到图片: {img_rel_path}")
return None
# 复制图片
dst_path = os.path.join(output_image_dir, os.path.basename(img_path))
shutil.copy2(img_path, dst_path)
return dst_path
def main():
# 创建输出目录结构
image_dir = os.path.join(OUTPUT_DIR, "images")
label_dir = os.path.join(OUTPUT_DIR, "labels")
os.makedirs(image_dir, exist_ok=True)
os.makedirs(label_dir, exist_ok=True)
# 类别名称到索引的映射
class_to_idx = {name: idx for idx, name in enumerate(CLASS_NAMES)}
print(f"类别映射: {class_to_idx}")
# 获取所有 JSON 文件
json_files = sorted(glob.glob(os.path.join(INPUT_DIR, "*.json")))
print(f"找到 {len(json_files)} 个 JSON 文件")
# 转换所有文件
converted = 0
failed = 0
for json_file in json_files:
# 转换标注
success = convert_labelme_to_yolo_seg(json_file, label_dir, class_to_idx)
if not success:
failed += 1
continue
# 复制图片
img_dst = copy_image(json_file, image_dir)
if img_dst is None:
failed += 1
continue
converted += 1
print(f"\n转换完成: {converted} 成功, {failed} 失败")
# 生成 data.yaml
yaml_path = os.path.join(OUTPUT_DIR, "data.yaml")
yaml_content = f"""# YOLOv8-seg 数据集配置文件
# 由 convert_to_yolo_seg.py 自动生成
path: {OUTPUT_DIR.replace(os.sep, '/')} # 数据集根目录
train: images # 训练集图片目录
val: images # 验证集图片目录(暂用同一目录)
# 类别
nc: {len(CLASS_NAMES)}
names: {CLASS_NAMES}
"""
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(yaml_content)
print(f"配置文件已生成: {yaml_path}")
# 生成训练/验证集划分
print("\n生成训练/验证集划分...")
all_images = sorted(glob.glob(os.path.join(image_dir, "*.*g"))) # png, jpg, jpeg
all_images += sorted(glob.glob(os.path.join(image_dir, "*.bmp")))
import random
random.seed(42)
random.shuffle(all_images)
split_idx = int(len(all_images) * TRAIN_RATIO)
train_files = all_images[:split_idx]
val_files = all_images[split_idx:]
# 写入 train.txt 和 val.txt
train_txt = os.path.join(OUTPUT_DIR, "train.txt")
val_txt = os.path.join(OUTPUT_DIR, "val.txt")
with open(train_txt, "w", encoding="utf-8") as f:
f.write("\n".join(train_files))
with open(val_txt, "w", encoding="utf-8") as f:
f.write("\n".join(val_files))
print(f"训练集: {len(train_files)} 张")
print(f"验证集: {len(val_files)} 张")
print(f"\n所有文件已保存到: {OUTPUT_DIR}")
print("可以直接用 YOLOv8 训练: yolo segment train data=data.yaml model=yolov8n-seg.pt")
if __name__ == "__main__":
main()
看看脚本运行以后的输出

非常有条理,所有参与标注的图片在images文件夹,json文件处理输出的txt文件在labels文件夹,train.txt 和val.txt记录的是用作训练和测试的具体图片名,都具体到了绝对路径啊.
3)训练模型权重文件
bash
"""
YOLOv8-seg 色素检测/分割训练脚本 (修正版)
===================================
功能:
1. 基于 yolov8n-seg,保证 CPU 推理速度
2. 针对"色素斑块"特性优化数据增强 (锁死颜色、防止切断)
3. 自动导出 ONNX 模型
使用方法:
python train_yolo_seg.py
"""
import argparse
import os
from pathlib import Path
from ultralytics import YOLO
# ==================== 配置 ====================
# 数据集 YAML 路径 (请确保路径正确)
DATA_YAML = r"D:\data\Skin_Img\pigment_IMG\yolo_seg_dataset\data.yaml"
# 预训练模型:使用 nano (n) 保证推理速度
PRETRAINED_MODEL = "yolov8n-seg.pt"
# 项目保存根目录
PROJECT_DIR = r"D:\data\Skin_Img\pigment_IMG\runs"
# 本次实验名称
EXPERIMENT_NAME = "pigment_seg_fast"
# 训练参数
DEFAULT_IMGSZ = 640 # 速度优先,不提升分辨率
DEFAULT_BATCH = 8 # 这是我开发PC的GPU可以达到的性能,训练RTX 4060 8GB 显存,seg 任务用 8 比较稳
DEFAULT_EPOCHS = 300 # 我一开始确实样本量不够大,色素样本通常不多,多跑几轮
DEFAULT_LR0 = 0.001 # 初始学习率
DEFAULT_PATIENCE = 50 # 早停 patience
# 设备
DEVICE = 0 # 用GPU训练,0 = 第一块 GPU
# ===============================================
def parse_args():
parser = argparse.ArgumentParser(description="YOLOv8-seg 色素分割训练")
parser.add_argument("--data", type=str, default=DATA_YAML,
help="数据集 YAML 路径")
parser.add_argument("--model", type=str, default=PRETRAINED_MODEL,
help="预训练模型")
parser.add_argument("--epochs", type=int, default=DEFAULT_EPOCHS,
help="训练轮数")
parser.add_argument("--batch", type=int, default=DEFAULT_BATCH,
help="批次大小")
parser.add_argument("--imgsz", type=int, default=DEFAULT_IMGSZ,
help="训练图像尺寸")
parser.add_argument("--lr0", type=float, default=DEFAULT_LR0,
help="初始学习率")
parser.add_argument("--patience", type=int, default=DEFAULT_PATIENCE,
help="早停 patience")
parser.add_argument("--device", type=str, default=str(DEVICE),
help="设备 (0=cuda:0, cpu)")
parser.add_argument("--project", type=str, default=PROJECT_DIR,
help="项目保存目录")
parser.add_argument("--name", type=str, default=EXPERIMENT_NAME,
help="实验名称")
parser.add_argument("--export_only", type=str, default=None,
help="仅导出已有模型 (提供 .pt 路径)")
return parser.parse_args()
def get_best_weight(run_dir: str) -> str:
"""获取 run 目录下的 best.pt"""
weights_dir = os.path.join(run_dir, "weights")
best_pt = os.path.join(weights_dir, "best.pt")
if os.path.exists(best_pt):
return best_pt
# 尝试找 last.pt
last_pt = os.path.join(weights_dir, "last.pt")
if os.path.exists(last_pt):
return last_pt
raise FileNotFoundError(f"未在 {weights_dir} 找到 best.pt 或 last.pt")
def export_onnx(model_path: str, imgsz: int = 640):
"""
导出 ONNX 模型,针对无 GPU 部署优化
"""
print(f"\n{'='*60}")
print(f"导出 ONNX 模型...")
print(f"{'='*60}")
model = YOLO(model_path)
# 导出 ONNX
export_path = model.export(
format="onnx",
imgsz=imgsz,
opset=12,
simplify=True,
dynamic=False,
half=False,
# task="segment" # 移除该参数,YOLO会自动根据模型类型处理
)
print(f"\nONNX 模型导出完成: {export_path}")
return export_path
def train(args):
"""主训练函数"""
print(f"\n{'='*60}")
print(f"YOLOv8-seg 色素检测/分割训练 (速度优化版)")
print(f"{'='*60}")
print(f"模型: {args.model}")
print(f"数据集: {args.data}")
print(f"图像尺寸: {args.imgsz}x{args.imgsz} (锁定 640 保速度)")
print(f"批次大小: {args.batch}")
print(f"训练轮数: {args.epochs}")
print(f"设备: {'GPU (CUDA)' if args.device != 'cpu' else 'CPU'}")
print(f"{'='*60}\n")
# 加载预训练模型
model = YOLO(args.model)
# ============ 训练 (针对色素斑块优化) ============
results = model.train(
# ---- 数据 ----
data=args.data,
# ---- 训练参数 ----
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
device=args.device,
workers=0, # Windows 环境必须设为 0
# ---- 优化器 ----
lr0=args.lr0,
lrf=0.01,
optimizer="AdamW",
momentum=0.937,
weight_decay=0.0005,
# ---- 数据增强 (核心修改区域) ----
# 1. 颜色增强:必须锁死!
hsv_h=0.0,
hsv_s=0.0,
hsv_v=0.1,
# 2. 几何变换
degrees=90.0,
translate=0.1,
scale=0.5,
shear=0.0,
perspective=0.0,
flipud=0.5,
fliplr=0.5,
# 3. 混合增强:必须关闭!
mosaic=0.0,
mixup=0.0,
# 4. 小目标增强
copy_paste=0.1,
# ---- 训练控制 ----
patience=args.patience,
save=True,
save_period=20,
val=True,
plots=True,
# ---- 输出 ----
project=args.project,
name=args.name,
exist_ok=True,
verbose=True,
# ---- 预训练 ----
pretrained=True,
# ---- 损失权重 ----
box=7.5,
cls=0.5,
dfl=1.5,
)
# 获取训练输出目录
run_dir = str(Path(results.save_dir))
# ============ 导出 ONNX ============
best_pt = get_best_weight(run_dir)
onnx_path = export_onnx(best_pt, imgsz=args.imgsz)
# ============ 打印最终结果 ============
print(f"\n{'='*60}")
print(f"训练完成!")
print(f"{'='*60}")
print(f"最佳权重: {best_pt}")
print(f"ONNX 模型: {onnx_path}")
print(f"训练日志: {run_dir}")
# 修复了之前的语法错误
print(f"\n验证模型")
print(f" yolo segment predict model={best_pt} source=your_image.png")
print(f"\n验证模型 (ONNX, CPU):")
print(f" yolo segment predict model={onnx_path} source=your_image.png device=cpu")
print(f"{'='*60}")
return best_pt, onnx_path
def main():
args = parse_args()
# 如果只导出已有模型
if args.export_only:
export_onnx(args.export_only, imgsz=args.imgsz)
return
# 正常训练 + 导出
train(args)
if __name__ == "__main__":
main()
看训练以后的结果,是深刻体会到不同了,不仅有框,框中还有了mask.


train/box_loss, seg_loss, cls_loss, dfl_loss: 所有损失函数都从高值在稳定下降,最终收敛到较低水平。这表明模型在学习过程中不断优化,没有出现震荡或发散;
val/box_loss, seg_loss, cls_loss, dfl_loss: 验证损失同样持续下降,并且和训练损失的趋势一致。这非常关键,说明模型没有过拟合,泛化能力良好;
精确率 (precision) & 召回率 (recall): 两者都从低值稳步提升,最终都接近 1.0,说明模型几乎能完美地找到所有目标,并且很少误报;
mAP50: 最终都达到 接近1.0,这是非常高的指标,意味着在IoU=0.5的阈值下,模型几乎完美。
mAP50-95: 最终达到 0.8左右,这个指标更严格(考虑了0.5-0.95的IoU),说明模型在不同阈值下都有很好的表现。
等后面属于加大越来越多了再比较看看~
4)模型推理验证

都说推理好才是真的好. 这个推理结果有些让人一言难尽. 真的是大量的框重叠, 这就是NMS干的不好啊,或许是yolo的通病. 还有我不希望输出的色块检测出现方框+mask+confidence的表征结果.
为了达到一个更好的结果,改动之处不少, 除了调节iou参数, 还做了凸包处理避免重叠交叉. 好几次怀疑是不是不能整好.
还是看看正好的测试脚本吧
bash
"""
色素斑块分割 - 过滤 Red 版
===================================
功能:
1. 只绘制 'pigment' (色素) 类别,过滤 'red' (红区)
2. 合并重叠框,只画外轮廓
"""
import cv2
import numpy as np
from ultralytics import YOLO
import os
# ==================== 配置 ====================
MODEL_PATH = r"D:\data\Skin_Img\pigment_IMG\runs\pigment_seg_fast\weights\best.pt"#训练的文件
SOURCE_PATH = r"D:\data\Skin_Img\pigment_IMG\test_images"#推理图像
SAVE_DIR = "runs/pigment_filter_result"#结果输出
# 请确认 data.yaml 里 pigment 对应的 ID =0
# 假设: 0=pigment, 1=red
TARGET_CLASS_ID = 0 # 我们只画 ID 为 0 的类别
CONF_THRES = 0.35
MIN_AREA = 500
# =============================================
def merge_and_draw(image, results, target_class=0, min_area=500):
"""
只绘制指定类别的 Mask,并合并去重
"""
if results[0].masks is None:
return image
# 1. 准备数据
masks_xy = results[0].masks.xy # 所有 Mask 的坐标
classes = results[0].boxes.cls.cpu() # 所有 Mask 对应的类别 ID
h, w = image.shape[:2]
mask_canvas = np.zeros((h, w), dtype=np.uint8)
# 2. 遍历并筛选
for i, seg in enumerate(masks_xy):
# 获取当前 Mask 的类别 ID
cls_id = int(classes[i])
if cls_id != target_class:
continue
# 转换坐标
seg_int = seg.astype(np.int32)
# 填充到底版上 (合并)
cv2.fillPoly(mask_canvas, [seg_int], 255)
# 3. 在底版上找轮廓 (此时只有 pigment,且已合并)
contours, _ = cv2.findContours(mask_canvas, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 4. 画凸包
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area:
continue
hull = cv2.convexHull(cnt)
# 画绿色线
cv2.polylines(image, [hull], True, (0, 255, 0), thickness=2)
return image
def main():
os.makedirs(SAVE_DIR, exist_ok=True)
print(f"正在加载模型: {MODEL_PATH}")
model = YOLO(MODEL_PATH)
# 打印一下模型的类别名称,方便核对 ID
print(f"模型类别: {model.names}")
print(f"提示: 当前设定只绘制 ID={TARGET_CLASS_ID} ({model.names.get(TARGET_CLASS_ID, 'Unknown')})")
if os.path.isdir(SOURCE_PATH):
files = [os.path.join(SOURCE_PATH, f) for f in os.listdir(SOURCE_PATH)
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
else:
files = [SOURCE_PATH]
print(f"共 {len(files)} 张图片,开始处理...")
for img_path in files:
original_img = cv2.imread(img_path)
if original_img is None: continue
# 预测
results = model.predict(
source=original_img,
imgsz=640,
conf=CONF_THRES,
iou=0.3,
retina_masks=True,
verbose=False
)
# 绘图 (传入 target_class)
result_img = merge_and_draw(original_img, results, target_class=TARGET_CLASS_ID, min_area=MIN_AREA)
# 保存
save_path = os.path.join(SAVE_DIR, f"final_{os.path.basename(img_path)}")
cv2.imwrite(save_path, result_img)
print(f"已保存: {save_path}")
print(f"\n完成!结果保存在: {SAVE_DIR}")
if __name__ == "__main__":
main()
看看调整以后的处理结果吧, 整体感觉还不错,比传统的方法降低了漏检和误检.
