☰
EllSeg椭圆分割实战:PyTorch训练全记录与调参经验
2026/10/17 4:36:13 网站建设 项目流程

EllSeg这个名字,我在第一次见到的时候愣了一下,然后反应过来是“Ellipse Segmentation”的缩写,专门用来做椭圆目标分割的。去年我一直在折腾瞳孔分割相关的工作,被传统语义分割模型折腾得够呛——通用分割模型能把眼珠轮廓画得挺好,但一旦要拿去做视线估计或者生物特征识别,就需要精确到椭圆的中心点、长短轴和角度,通用模型给不出这种结构化输出。后来找到EllSeg这个思路,自己动手复现、改结构、调损失、做实验,踩了不少坑,也摸出了一些门道。这篇就是完整的训练实验记录、代码解析和实操技巧梳理,给准备入门或者已经在调EllSeg的同学一个参考。

EllSeg说白了就是让网络不只输出分割掩码,还要同时回归椭圆的几何参数。核心价值在于把“像素级分割”和“参数化表示”打通,既保留了分割的直观性,又能直接在后续任务里用椭圆参数做计算。适合的人群包括做眼动追踪、瞳孔定位、虹膜识别、工业零件椭圆检测(比如轴承、芯片焊盘)的朋友,也适合想了解多任务分割网络怎么设计损失函数、怎么调训练策略的同学。这篇文章我尽量讲人话,每个环节都附上我实际跑实验时的记录和改动,直接可以照着抄作业。

1. 项目背景与核心痛点拆解

1.1 椭圆分割和通用语义分割的差异

大多数语义分割任务的目标是“把某个类别的像素找出来”,比如把瞳孔和背景分开。训练的时候用的标签通常是像素级的二值掩码,网络输出的也是一张同样尺寸的mask图,最后用IoU、Dice这样的指标衡量。

椭圆分割不太一样,它要求网络在知道“哪些像素属于目标”的同时,还要理解“这个目标在几何上是一个椭圆”。也就是说,最终结果不只是给你一张轮廓图,还要给你一组参数:中心坐标、长短轴长度、旋转角度。这就对网络提出了更高的要求——不仅要感知像素,还要感知形状。

我在做瞳孔分割的时候最直接的体验是:普通分割模型在小尺寸图像上表现还行,但一旦遇到遮挡、光照变化、眨眼、眼睑部分盖住瞳孔,掩码就变得断断续续,后续不管是做瞳孔中心估计还是计算瞳孔直径,都会被噪声干扰。而用椭圆建模之后,模型天然带了一个“几何先验”,就算有一部分像素被遮挡,椭圆参数的回归结果仍然可以比较稳定。

所以EllSeg的价值用一句话概括:把分割从“画图的活”变成了“画图和算数的活一起干”,而且两种输出互相补强。

1.2 为什么要自己动手复现和训练

直接跑别人的预训练权重看起来省事,但实际情况往往不理想。不同场景的光照分布、相机位置、目标大小差异极大,直接用预训练权重可能效果还可以,但距离自己的业务场景总有差距。比如我在做不同人眼图迁移实验的时候就发现,训练集里瞳孔偏大的样本多,实际使用时瞳孔偏小,模型分割结果就会出现系统性偏移——掩码往外扩了一圈,椭圆参数自然也不准。

自己动手复现训练,核心目的有三个:

  • 掌握数据到参数的完整链路,出现bad case的时候知道该往哪个环节排查;
  • 针对自己的场景做数据增强和超参数调整,缩小训练分布和实际分布的差距;
  • 把网络结构改了之后能独立实验对比,验证是不是真的有用。

1.3 这个项目适合谁、要准备什么

如果你想复现这篇文章里的实验,建议有一定的Python基础,用过PyTorch或者类似的深度学习框架,了解基本的“加载数据-前向传播-算loss-反向传播”流程。如果只是听说过分割任务但没亲手跑过模型,也行,但建议先跑通一个简单的分割demo再来折腾EllSeg,否则调试难度会叠加。

硬件方面,我全程用了一块消费级显卡,显存8GB左右,输入图像分辨率控制在256×256,batch size不超过16,训练时间控制在几个小时以内。这个项目的资源需求不算夸张,普通配置完全可以跑起来。

2. 训练数据准备与预处理

2.1 数据形态与标注格式转换

EllSeg的训练数据至少要包含两部分:原始图像和对应的椭圆参数标注。理想情况下还要有对应的像素掩码,因为网络有一个分支专门在学掩码。

我用的数据集来源比较杂:一部分是公开的眼睛区域图像,一部分是自己采集标注的。不管来源是哪,最终都要统一成同一种格式。我的做法是训练之前写了一个预处理脚本,把所有标注转成统一的JSON格式:

{ "image": "0001.png", "bbox": [x_center, y_center, major_axis, minor_axis], "angle": 30.0, "mask": "0001_mask.png" }

这里angle的单位是度,但实际训练时我会转成弧度再输入模型,相关细节后面讲损失函数的时候会细说。

这里有个很多人会踩的坑:公开数据集的标注格式五花八门,有的是中心点+半径,有的是一组轮廓点坐标。如果用轮廓点,需要先用椭圆拟合把它转化为参数形式。我是用最小二乘椭圆拟合来做的,OpenCV里有现成的fitEllipse函数,注意输入需要是至少5个点的二维坐标集合。

2.2 数据增强策略与合成数据的作用

数据增强是EllSeg训练里最关键的一环,甚至比模型结构更重要。我用到的增强策略分成两类:

第一类是几何增强,包括随机旋转(±30度)、随机缩放(0.8到1.2倍)、随机平移(±10%图像尺寸)。因为椭圆参数中的角度和中心点会随几何变换变化,这里必须同步更新标注。我的做法是把增强操作写成一个组合函数,同时作用于图像和椭圆参数,确保两者始终保持对应关系。

第二类是外观增强,包括亮度扰动、对比度扰动、高斯噪声、随机遮挡(cutout)。这一类增强对分割任务尤其重要,因为实际场景中光照变化非常剧烈,不把网络“虐”一下,它就会在真实数据上暴露出过拟合的毛病。

关于合成数据多说一句,这是我看过EllSeg相关工程实现之后觉得最有用的部分。合成数据的做法是:先生成一张纯色背景,在上面随机绘制人工椭圆,椭圆的中心、长短轴、旋转角度全部随机采样,然后和真实图像混合,或者直接作为训练样本。这样网络能学到非常干净的椭圆边界特征,后面接真实数据的微调也会更稳。合成数据和真实数据的比例我建议从1:1开始试,如果你的真实数据量不大,合成数据比例甚至可以提到2:1。

2.3 输入尺寸、归一化与DataLoader细节

输入尺寸我最终定在256×256。太大的话训练太慢,太小的话椭圆角度的预测精度会下降。做过一组对比实验,结果后面放到实验记录表里详细说。

归一化我直接用ImageNet的均值和标准差:mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]。虽然眼睛图像和ImageNet图像分布差异较大,但实测下来用ImageNet的预训练权重时,沿用这个归一化参数效果还是最稳的。如果你想训练更久、不加载预训练权重,也可以直接用自己数据集的均值和标准差,不过起步会慢一些。

DataLoader实现上有几个细节要注意:

  • 加载图像时统一做resize到256×256,不要用RandomResizedCrop之类的随机裁剪,因为椭圆参数和裁剪位置的对应关系很麻烦;
  • 如果batch里有的图没有椭圆目标(背景图),需要把椭圆参数分支的loss直接置为0,只计算分割分支loss,否则网络会被无意义的回归目标干扰;
  • 每个epoch打乱顺序的时候,要保证同一张图不会既出现在训练集又出现在验证集,尤其是做合成数据增强时,要固定随机种子。

3. 代码结构与核心模块解析

3.1 网络骨架:编码器-解码器与多任务头的设计

EllSeg的骨架本质上是一个编码器-解码器结构,编码器负责提取特征,解码器负责把特征图恢复到原图分辨率。我在实现时用的是ResNet34作为编码器主干,去掉最后的全连接层和全局池化,只保留卷积部分的输出。解码器则是简单的几层上采样卷积,每一层先把特征图尺寸翻倍,再和编码器对应层的特征做concat,然后过两个卷积。

class EllSegNet(nn.Module): def __init__(self, backbone='resnet34', num_classes=1): super().__init__() # 编码器,输出多个尺度的特征 self.encoder = build_encoder(backbone, pretrained=True) # 解码器,逐步上采样 self.decoder = DecoderBlock([512, 256, 128, 64]) # 分割掩码头 self.seg_head = nn.Conv2d(64, num_classes, kernel_size=1) # 椭圆参数头 self.ellipse_head = EllipseHead(64) def forward(self, x): features = self.encoder(x) x = self.decoder(features) seg_out = self.seg_head(x) ellipse_out = self.ellipse_head(x) return seg_out, ellipse_out

解码器这块我试过直接接FPN结构,效果差别不大,还多费显存,最后还是用简单上采样更划算。

3.2 椭圆参数预测头的设计

这是一个需要仔细设计的模块。网络要从解码器输出的特征图(形状是[B, 64, H, W])预测出每个目标的椭圆参数。直接做法是把特征图做全局平均池化,展成一维向量,再通过几个全连接层输出参数。

class EllipseHead(nn.Module): def __init__(self, in_channels): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(in_channels, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 5) ) def forward(self, x): x = self.pool(x).flatten(1) return self.fc(x)

输出的5个参数分别是:中心x、中心y、长轴、短轴、角度。注意这里我故意没有加任何激活函数,长轴和短轴可能出现负数,这个问题后面在损失函数部分会专门讲怎么处理。

如果你的场景需要预测多个椭圆,可以在这个基础上加一个额外的sigmoid输出表示数量,或者改成类似目标检测的anchor设计。我的实验聚焦在单椭圆场景,暂不展开。

3.3 损失函数设计:掩码分支和椭圆参数分支如何协同

EllSeg的核心之一就是双分支损失设计。掩码分支用标准的二值交叉熵或者Dice loss,椭圆参数分支用回归损失。

关键难点在于椭圆参数的回归目标尺度差异很大:中心点的值域在0到1之间(归一化之后),长轴短轴也在0到1之间,角度却是0到π。直接把这些值拼在一起算L1 loss,角度项会在数值上占据绝对主导地位,训练会不稳定。

我最终的方案是这样的:

def ellipse_loss(pred, target, seg_pred, seg_target): # 分割分支:Dice loss seg_loss = dice_loss(seg_pred, seg_target) # 归一化中心点和轴长 pred_cx, pred_cy, pred_major, pred_minor, pred_angle = torch.split(pred, 1, dim=-1) tgt_cx, tgt_cy, tgt_major, tgt_minor, tgt_angle = torch.split(target, 1, dim=-1) # 轴长用softplus约束为正,然后和target的log尺度对齐 pred_major = F.softplus(pred_major) pred_minor = F.softplus(pred_minor) # 角度损失用正弦余弦编码,避免周期性跳变 angle_loss = angle_loss_func(pred_angle, tgt_angle) param_loss = ( F.l1_loss(pred_cx, tgt_cx) + F.l1_loss(pred_cy, tgt_cy) + F.l1_loss(pred_major, tgt_major) + F.l1_loss(pred_minor, tgt_minor) + angle_loss * 0.5 # 角度项权重调低 ) return seg_loss + param_loss * 0.5

轴长用softplus而不是exp,是因为exp在数值上容易爆炸,softplus在正数区域近似线性,梯度更平稳。角度损失则避免直接用L1或者MSE,因为角度是周期性变量,0度和360度其实是一样的,直接用L1会让网络在角度跨越周期边界时产生巨大误差。我的做法是把角度转成sin和cos两个值,分别算L1,然后相加:

def angle_loss_func(pred_angle, target_angle): pred_sin = torch.sin(pred_angle) pred_cos = torch.cos(pred_angle) tgt_sin = torch.sin(target_angle) tgt_cos = torch.cos(target_angle) return F.l1_loss(pred_sin, tgt_sin) + F.l1_loss(pred_cos, tgt_cos)

这个trick是我在整个项目里最受益的一个改动,后面实验记录表格里也能看到它的作用。

3.4 训练循环里容易被忽略的细节

训练循环本身不复杂,PyTorch标准的一套流程。但有几个容易被忽略的细节,直接影响训练结果:

第一是混合精度。如果显卡支持,建议开AMP自动混合精度,显存占用可以降不少,训练速度也能提上来。但要注意,椭圆参数分支在FP16下计算时,梯度容易变成NaN,需要在loss计算处设置scaler.scale(loss),同时把全连接层的输入保持FP32。

第二是验证集的评估指标。分割分支可以用IoU,椭圆参数分支光看loss不够直观,我会额外算一个F1误差:取预测椭圆和真实椭圆参数的差值占真实值的比例,三个参数(长轴、短轴、中心点偏移)的平均值。这个指标更贴近业务感受,也方便做模型选择。

第三是学习率的设置。我后面专门有一节讲超参数,这里先说结论:初始学习率3e-4配余弦退火,比固定学习率效果的提升非常明显,肉眼可见地稳。

4. 训练实验记录与超参数调优

4.1 我的实验记录表

这一节我直接放自己的实验记录,按时间顺序排列,结果用验证集上的指标来对比。数据集固定,模型都训练到收敛(用早停机制,连续10个epoch验证loss不降就停止)。

实验编号输入尺寸角度损失类型分割loss权重参数loss权重学习率策略掩码IoU椭圆参数F1误差备注
1128×128L11.01.0固定1e-30.8518.3%角度震荡明显
2128×128sin/cos1.01.0固定1e-30.8714.2%角度稳定了一些
3256×256sin/cos1.01.0固定1e-30.9012.1%输入尺寸提升明显
4256×256sin/cos1.00.5固定1e-30.9110.9%参数loss降权后更均衡
5256×256sin/cos1.00.5余弦退火3e-40.928.7%收敛更稳,最终指标最好
6256×256sin/cos1.00.5余弦退火3e-4,加合成数据0.936.8%最终方案

讲下实验结论:输入尺寸从128提到256是最直接的收益来源,128下角度预测误差接近9度,256下降到了5度左右,对后续的几何计算影响很大。参数loss权重从1.0调到0.5之后,分割分支没有被回归分支带偏,两者平衡得更好。合成数据加入后,长轴和短轴的误差下降最明显,说明网络对“完整椭圆”的几何形状认知强化了。

4.2 影响收敛的几个关键超参数

第一个是batch size。我用8GB显存,256×256输入,batch size设为16刚好能跑,再大就要用梯度累积。实测batch size从8提到16,收敛速度有小幅提升,但最终精度相差不大。所以如果你显存紧张,batch size设为8完全可行,不需要硬挤。

第二个是优化器。AdamW在多数情况下优于Adam,关键是weight decay不要太大,我设置为1e-4。设置过大的weight decay会导致椭圆参数分支的长轴短轴偏小,因为参数被正则约束向0靠拢。

第三个是warmup。我用的还是比较简单的线性warmup,前5个epoch从1e-5线性升到3e-4。不做warmup直接上3e-4,前期loss偶尔会跳到极高值,虽然不是每次都会炸,但一旦炸一次就要重来。

4.3 预热和退火的经验心得

学习率退火这块,我的理解是:早期阶段网络需要快速探索参数空间,用一个相对较大的学习率;后期阶段参数已经接近局部最优,用小学习率慢慢磨。直接用固定学习率很容易在后期出现loss反复横跳。

我试过余弦退火和StepLR两种策略,余弦退火效果明显更好。原因可能是StepLR断崖式降学习率会让模型在切换点产生短暂的性能回退,而余弦退火是连续平滑下降,训练过程稳定得多。

关于预热再提一句:如果你加载了ImageNet预训练权重,网络一开始的特征不是随机状态,预热的意义主要是让新加的头部参数先喘口气。如果你是从零开始训练,预热就更重要了,相当于先让模型学会“看”,再让模型学会“画”。

5. 常见问题与排查技巧实录

5.1 典型问题速查表

这个问题表是我整个实验过程中反复翻看的,几乎每个问题都真实踩过。

问题现象可能原因排查顺序解决方法
loss变成NaN学习率过高 / FP16精度问题先看学习率,再看AMP降低学习率;关闭混合精度或对参数头保持FP32
椭圆长轴或短轴为负数回归头输出未加约束直接检查模型输出用softplus或exp激活
角度预测在某个范围内反复跳变角度损失直接用了L1/MSE检查损失函数类型改用sin/cos编码
分割掩码很好但椭圆参数不准参数loss权重过高查看两项loss的数值量级调低参数loss权重
训练集loss很低,验证集表现差过拟合检查增强策略增加合成数据比例、加大dropout
验证集IoU高,但业务效果差训练分布和目标分布差异大抽样bad case看针对实际场景补充数据或调整增强
加载预训练权重后loss下不去归一化参数不匹配检查预处理统一使用ImageNet均值方差

5.2 展开讲讲:loss不降怎么办

loss不降大概是我遇到最多的问题,而且每次原因不同。第一次是学习率设置太低,从0.03一路降到0.003,loss基本横着走。后来调到3e-4才动起来。第二次是角度损失和参数回归互相干扰,看起来总loss没降,但分割分支的IoU其实在涨,只是参数分支把loss拉住了。这种时候就要打印每个分支的loss分量,看清楚到底是谁在拖后腿,不要只看总loss。

还有一种常见情况是数据问题——标签和图像不对齐。我之前从公开数据转格式时,有一批图像的标注是水平翻转过的,但椭圆参数没有同步翻转,导致网络在反复学习一个自相矛盾的目标。肉眼检查了20张图才发现。后来我写了一个可视化工具,在训练开始前把图像、掩码、椭圆都画在同一张图里抽查一遍,这步看起来简单,但能省掉后面好几天的debug时间。

5.3 部署阶段的小坑与性能优化

训练完成之后,要把模型导出做推理。我试过直接加载PyTorch权重在Python环境里推理,也试过导出ONNX。建议导出ONNX,部署灵活度更高,速度也有提升。

导出ONNX时最常遇到的问题有两个。第一个是插值操作在ONNX里需要指定坐标变换模式。使用F.interpolate时默认的align_corners行为在PyTorch和ONNX runtime里不完全一致,统一设置align_corners=False可以避免推理结果和训练结果出现偏差。第二个是动态尺寸支持,ONNX默认输入尺寸是固定的,我用的256×256很方便,但如果你的场景需要变尺寸输入,需要在导出时设置dynamic_axes。

推理性能方面,去掉mask分支只保留椭圆参数分支,可以省掉一部分计算。如果业务只需要椭圆的参数结果,可以只在推理时做一个轻量解码器输出参数,这样速度更快。我实测256×256输入在普通推理设备上单帧大约能到30到40毫秒,加上前后处理可以达到实时性要求,前提是输入图像本身不大。

6. 关键经验总结和扩展方向

6.1 我认为最关键的3个习惯

整个项目跑下来,我最大的体会有三点。

第一,可视化一定要趁早。不要等到训练结束了才开始看结果,数据准备阶段就要把图像、真值椭圆、预测椭圆画出来看。很多数据标注的问题在第一个epoch就能发现,拖到最后会浪费大量时间。

第二,不要只看一张测试图的效果。我以前经常被单张效果超好的图鼓舞,多测几张才发现那个是运气。后来固定了一个样例集,里面有各种光照、遮挡、大小组合,每次训练完都在这个固定集上评测,对比才有意义。

第三,改动一次只动一个变量。这是老生常谈,但我每次贪心一起就想同时换损失、换学习率、换数据增强,最后出问题都不知道是哪个改动导致的。后来我给自己立了规矩,每一轮实验只能动一个东西,记录表也会写清楚,实验日志习惯养成后排查问题速度快很多。

6.2 怎么把EllSeg扩展到其他椭圆目标检测

EllSeg不限于眼睛相关的任务。任何具有椭圆结构的目标都可以尝试:工业场景中的圆形零件检测、细胞图像中的细胞核椭圆拟合、遥感图像中的椭圆形地表目标等等。核心只需要改一下标注格式和数据的类别数,网络结构基本不用动。

我在一个模拟的工业零件检测Demo里试过直接把EllSeg拿过去用:输入的图像是传送带上的圆形金属片,标注是中心点、半径和角度(圆的话角度设为0)。训练十几轮之后,分割IoU和参数精度都达到了可用的水平。这个跨场景迁移的实验让我确信,EllSeg的设计思路对椭圆类目标是有一定通用性的,关键还是数据形态要规范。

如果你也想在非眼睛数据集上试,给你两个小提示:一是先确认你的物体真的可以用椭圆近似,如果形状太不规则(比如某些异形件),椭圆先验不但不能帮忙,反而会限制模型;二是角度定义要在数据集内部统一,有些场景下角度从水平轴算,有些从垂直轴算,标注不一致会让回归任务直接失去意义。

最后分享一个小经验:如果你在训练过程中觉得模型总能快速收敛,但精度卡在一个瓶颈上不去,可以检查一下是不是softplus对轴长的约束太松。可以试试把约束改得紧一些,比如用sigmoid映射到固定范围,牺牲一点参数上限,换取更稳定的收敛。这种调法适合目标轴长变化范围不大的场景,如果你的目标大小变化很大,还是用softplus更稳。这个细节我是在实验后期才意识到的,改完之后参数误差又降了一截,希望你不需要踩同样的坑就能拿到这波收益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询