做农学场景的视觉项目,最磨人的往往不是模型,而是数据。我最近整理了一份单类茄子的目标实例分割数据集,标签就一个类别名eggplant,一共2766张图像,训练集2261张、验证集505张,图像分辨率在1280x1706左右浮动,标注是labelme导出的json格式。这篇想聊的,就是围绕这套数据的一整套实操:从labelme里怎么打多边形,到转成COCO格式开训Mask R-CNN,再到验证集评估和那些官方文档不会告诉你的坑。
我参与过好几个农业视觉方向的活儿,从大田作物到温室果蔬都有涉及。这类项目里,目标检测和实例分割的需求非常高频,尤其是“数清楚到底有几个果实”“给机器人一个可采摘的像素级位置”这类任务。但说实话,农业场景的数据比通用场景数据难搞得多:光照不统一、果实互相遮挡、叶片和果实颜色接近、目标尺寸差异大。把这些麻烦集中在一份单类茄子的实例分割数据集上,反而是一个很适合从头到尾讲一遍的样本,因为数据量不算巨大,类别单一,但该遇到的问题一个都不少。
这篇内容更适合三类人看:正在做智慧农业项目、需要自己从零标注数据集的开发者;想拿现成数据快速跑通实例分割训练的同学;以及所有对“labelme标注到模型训练之间到底发生了什么”还含糊不清的人。
1. 茄子实例分割数据集到底要解决什么问题
1.1 为什么是“实例”分割,不是“语义”分割
很多人刚接触这个概念时会混淆语义分割和实例分割。语义分割是给图像里每个像素打上类别标签,比如“这是叶子”“这是果实”“这是背景”,但同一类别的多个对象在结果里是连成一整块的,不区分你我。实例分割不一样,它要求把每一个单独的茄子都抠成一个独立掩码:左边这个茄子是一个对象,右边这个茄子是另一个对象,哪怕它们贴在一起、甚至互相遮挡,也要在像素级上区分开。
放到农学场景里,这个区别直接决定系统能不能用。举个例子,一个采摘机器人要准确夹取果实,它不光要知道“图像里有一片紫色区域是茄子”,还得知道“第一个茄子的轮廓坐标在哪、第二个茄子的重心在哪”,这样机械臂才能规划抓取路径。又比如做产量预估,你需要统计的是“这一批图像里有多少个果实”,而不是“果实像素总共占了多少面积”。所以实例分割在农业自动化里的位置,基本属于刚性需求。
这套数据集的标注方式就是基于多边形轮廓的实例分割标注,每个单独果实对应一个闭合多边形。这也是labelme这类工具最典型的用法。
1.2 只标一类eggplant,是简化还是理性选择
看到类别只有一个,有些同学可能觉得任务太简单了。但结合实际项目背景,单类标注往往是理性选择,甚至是刻意为之。
首先,农业视觉项目常用的类别设计有两种思路:一种是做“成熟度分级”,把茄子分成“可采摘”和“不可采摘”或者按颜色阶段划分;另一种是只关注“目标果实”本身,其他一律算背景。这套数据选择了后者,把所有茄子统一标成eggplant,不区分成熟度。这样做的好处很明显:标注标准容易统一,不同标注人员之间的分歧会小很多,标签噪声低。如果你做过标注管理就会知道,类别越多,边界情况越难定义,比如“这个茄子算七成熟还是八成熟”,两个标注员很可能给出不同答案,最后模型学到的全是混乱。
其次,单类模型并不等于效果一定好。类别少只是让模型不需要区分“茄子”和“番茄”,但它仍然要解决一个核心问题:把每一个茄子从复杂的叶片背景中分离出来。密集果实、重叠遮挡、高光反光、绿色果萼与叶片混淆,这些难题不会因为类别少就消失。实际上,单类模型的所有容量都集中在“学会茄子的形状”这件事上,对轮廓精度和遮挡推理的要求反而更高。
我之前和同行交流时,有人一上来就标了六个类别,结果训练集每类只有几百个实例,模型在验证集上一塌糊涂。后来砍到两个主类别,指标立刻上来了。所以千万别觉得单类丢人,能把一个类别做到稳定的像素级分割,已经能支撑很多实际应用了。
2. 数据规模、分辨率与训练验证划分的取舍逻辑
2.1 2766张、2261/505的划分合不合理
先算一笔简单的账:2261加505正好等于2766,验证集占比大约是18.3%。这个比例落在常见的“八二开”区间附近,从统计学角度看没什么毛病。
但我想多说一句:比例只是表面,划分方式才是决定验证集可信度的关键。最忌讳的做法是把所有图像文件全混在一起,然后随手按8比2随机切。因为农业图像往往存在很明显的“采集批次相关”:同一个温室、同一天、同一个摄像头连续拍的几百张图,背景高度相似,光线条件也接近。如果其中一部分进了训练集,另一部分进了验证集,模型很可能记住的是背景纹理而不是茄子本身,验证分数虚高,一到新场景立刻露馅。
所以拿到一份数据,我的习惯是先看文件命名的规律。如果图像是按拍摄时间或采集批次排序的,就应该按时间窗口来分层划分,保证同一批次的图尽量只落在训练集或验证集其中一侧。对于这套数据,最稳妥的自检办法是:随机抽一些训练集和验证集图像,人工看背景是否类似、是否有同一串茄子被同时拍进两边的图里。如果发现同一个果实簇出现在两张不同视角的图中,且一张在训练集、一张在验证集,那就要小心信息泄漏,宁可重新划分也不要带着这个隐患训模型。
2.2 1280x1706的高分辨率是把双刃剑
这里的图像分辨率在1280x1706左右浮动,长宽比接近3比4。放到实例分割任务里,这个分辨率算比较高的了,很多公开数据集默认都是512x512或者640x640。
高分辨率的好处很直观:单个果实占的像素更多,果柄、高光、边缘纹理这些细节都能看清楚,标注时可以画更精细的轮廓,模型学到的边界信息也更足。小目标问题会缓解不少。尤其是茄子这种长椭圆形的果实,如果分辨率太低,边缘很容易被压成一坨锯齿,分割质量基本无从谈起。
但高分辨率也带来非常现实的麻烦:显存装不下。1280x1706的原始图直接灌进Mask R-CNN这种两阶段模型,单张图就可能吃掉十几G显存,很多人本地一张卡根本跑不动,更别提开大batch。所以实际操作中几乎不会用原始分辨率训练,常见的做法是先做缩放,把短边缩到800到1024左右,或者训练时对原图做随机裁剪成固定大小的块再送进去。
这里有一个取舍:缩得太小会丢失小果实的细节,缩得太大又影响训练速度。我习惯分两步走,第一步先用缩放过的图像跑通baseline,验证数据流程没问题;第二步再考虑用切片训练或者SAHI推理来弥补小目标细节。后面第5节会专门讲这个。
2.3 这份数据大概要多久标完
标注耗时是很多人规划项目时容易低估的部分。我按经验大致估算一下:茄子这类果实,一张图里的有效实例通常在1到8个之间,取平均值4个左右,2766张图差不多就是11000个实例。一个熟练标注员画一个果实多边形大概需要20到40秒,算上切换图像、放大缩小、保存等操作,平均每个实例大约1分钟左右,单个人标完全部实例大约需要11000分钟,也就是183个小时左右,折合约23个工作日。
这还只是纯画轮廓的时间。如果算上制定标注规范、试标注、交叉审核、出错的返工,总耗时乘以1.5到2倍比较现实。所以如果你的项目准备用这份数据做训练,我建议预留至少一个月的人力成本,或者考虑多人并行标注。多人并行时一定要提前统一规范,不然每个人对“遮挡到什么程度不标”“果柄算不算果实”的理解都不一样,返工起来相当痛苦。这个细节在第3节会具体展开。
3. labelme标注细节:从多边形到COCO格式的关键操作
3.1 labelme的JSON结构不复杂,但坑不少
labelme是一个开源图像标注工具,标注结果保存为json文件,和同名图像放在一起。刚接触labelme的人打开json,往往会被一堆字段搞晕。我把主要结构拆开来看,其实很清晰。
一个典型的labelme json文件包含这样几部分核心内容:
{ "version": "5.0.1", "flags": {}, "shapes": [ { "label": "eggplant", "points": [[512.0, 300.0], [523.0, 302.0], [540.0, 310.0]], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "IMG_0001.jpg", "imageData": "...base64编码的图像数据...", "imageHeight": 1706, "imageWidth": 1280 }关键字段就几个:shapes是一个数组,里面每个元素是一个标注对象;label是类别名称,必须统一写成eggplant,大小写不一致会导致后面转格式时类别对不上;points是多边形顶点的像素坐标列表;shape_type固定为polygon;group_id可用于把多个轮廓归到同一组,单类实例分割里一般保持null;imagePath是图像文件名。
这里必须提醒三个容易踩的坑。
第一,转移数据集时,json和jpg一定要一起拷贝,并且保持相对路径。有些版本的labelme保存时会写相对路径,有些会写绝对路径,一旦换机器、换目录,打开json会加载不到图像,标注就白标了。
第二,imageData字段体积很大。保存时labelme默认把图像base64编码塞进json,导致json文件比图像本身还大好几倍。如果数据量大,存储和读取都很浪费。可以在启动labelme时加--nodata参数,这样json里就不会带imageData,只保留图片路径。代价是换机器时必须把图片和json同时迁移,但路径配好了其实更清爽。
第三,标注过程中不要随意改变label写法。比如这次标的是“eggplant”,下次标成“Eggplant”,转录成COCO后就成了两个类别,训练时一个新类别只有几个样本,模型基本学废。
3.2 茄子这类目标的标注规范怎么定
标注规范这种事,表面上人人都会,实际做起来才发现全是不确定性:两个茄子叠在一起怎么算边界?果柄要不要画进去?边缘因为高光看不清怎么处理?
我做得比较多的做法是提前定一套简单可执行的规则,然后先试标20张图,几个人一起过一遍,发现问题再修订。
关于遮挡问题,我的规则是:只标可见部分。如果一个茄子被叶子挡住超过一半,但轮廓的可见部分仍然能够形成合理闭合区域,就按可见部分标注;如果挡到只能看到一小条紫色,无法判断完整形态,就不标。这里关键是全组统一,不能一半人标一半人不标。
关于果柄,取决于项目目标。如果是为了采摘机器人定位,果柄和果实连接点很重要,建议把果柄包含进同一掩码;如果是为了做产量统计和体积估算,果柄会干扰体积计算,建议只画果身。这套数据集如果目标是通用实例分割,我倾向只标果实主体,因为果柄细长且常被叶子遮住,硬要把所有果柄都标准,会拖慢标注速度,还容易引入大量噪声。
关于轮廓密度,也是个经验问题。圆润的果实其实不需要密密麻麻的点,我一般控制在10到20个点。点太少,轮廓会明显内切,边缘差一截;点太多,标注慢而且模型容易去拟合注释里的锯齿。遇到形状复杂的遮挡边界,才需要额外加点。另外,标注时一定要在放大视图下操作,至少放大到能看清果实的边缘纹理,不然你凭感觉画的点和实际像素边缘可能差出好几像素。
关于高光和浅色区域,茄子表皮有反光,高光部分会泛白,和紫色果身差异很大。新手容易把高光区域当成背景漏掉,导致轮廓直接扣掉一小块。所以规范里要写清楚:高光是果实的一部分,轮廓要沿视觉上的果实边界走,不受反光干扰。同样道理,茄子果实接近成熟时可能是紫红色、紫黑色,未成熟时偏绿,这些同一类别内的颜色变化不能作为拆分类别的依据,统一标注eggplant即可。
3.3 转COCO:可复用的转换脚本与校验方法
labelme的json格式虽然直观,但主流检测框架并不直接消费它,一般都转成COCO格式。COCO格式的标注核心是三个列表:images、annotations、categories。每个annotation包含bbox、segmentation、area、iscrowd等字段,其中segmentation是用于实例分割的多边形坐标。
我自己写过一个够用的转换脚本,核心逻辑很直接:遍历每个labelme json,读取图像宽高,逐个把polygon转成COCO annotation。
import json import glob import os from PIL import Image def labelme_to_coco(json_dir, output_json, categories=[{"id": 1, "name": "eggplant"}]): images, annotations = [], [] ann_id = 1 for img_id, json_path in enumerate( glob.glob(os.path.join(json_dir, "*.json")), start=1): with open(json_path, encoding="utf-8") as f: data = json.load(f) image_path = os.path.join(os.path.dirname(json_path), data["imagePath"]) img_w, img_h = Image.open(image_path).size images.append({ "id": img_id, "width": img_w, "height": img_h, "file_name": data["imagePath"], }) for shape in data["shapes"]: if shape["shape_type"] != "polygon": continue pts = shape["points"] xs = [p[0] for p in pts] ys = [p[1] for p in pts] x_min, y_min = min(xs), min(ys) w = max(xs) - x_min h = max(ys) - y_min annotations.append({ "id": ann_id, "image_id": img_id, "category_id": 1, "bbox": [x_min, y_min, w, h], "area": w * h, # 粗略面积,严格可用多边形面积公式 "segmentation": [[coord for p in pts for coord in p]], "iscrowd": 0, }) ann_id += 1 with open(output_json, "w", encoding="utf-8") as f: json.dump({ "images": images, "annotations": annotations, "categories": categories, }, f, ensure_ascii=False) if __name__ == "__main__": labelme_to_coco("./labelme_jsons", "./instances_train.json")转换脚本本身不复杂,但转完不等于万事大吉,下一步必须做可视化校验。我的做法是在原图上把所有标注mask画出来,随机抽20到30张图人工看一遍。重点看三类问题:多边形是否整体偏移、遮挡边界是否合理、有没有明显漏标或错标。
还要统计一下bbox宽度和高度分布,看看有没有宽高为零的异常标注,这种一般发生在点坐标重复或手滑点上。出现这种情况时,训练过程中计算loss很容易产出NaN或者大梯度。
4. 用这份数据训练Mask R-CNN的完整实操记录
4.1 先把数据目录和类别配置理顺
训练之前最容易被忽略的,是目录结构和类别配置的一致性。我之前见过有人转了COCO之后,忘了在训练配置里把类别数改成1,结果模型输出两个类别,但GT里只有第一个类别有用,整个训练过程等于白跑。
推荐的数据目录组织方式很简单,训练前按这个结构摆放:
data/ eggplant/ annotations/ instances_train.json instances_val.json train/ IMG_0001.jpg IMG_0002.jpg ... val/ IMG_0001.jpg ...在mmdetection这类框架里,还要在数据配置里显式声明类别。以mmdetection 2.x为例,如果使用CocoDataset,除了在data里指定ann_file和img_prefix,还需要在classes里写明('eggplant',),并让模型的num_classes对应改成1。否则即使你的COCO json里只有category_id=1,模型仍然会按COCO默认的80类去初始化分类头,权重维度完全不匹配。
单类实例分割在模型头部的设置上有几个细节。Mask R-CNN的RPN不关心具体类别,它只负责生成候选框,所以RPN部分基本不用动。但后面的Fast R-CNN分类头和mask head要改成1类。做迁移学习时,加载COCO预训练权重后,这两个head的最后一层参数要重新初始化。很多框架会报预训练权重shape不匹配,这时不要强行跳过,正确的做法是让分类头和mask头从随机初始化开始,让骨干网络保留通用视觉特征就行。
4.2 模型选型与训练参数的参考配置
很多人一上来就想换最先进的模型,但我的习惯是先跑一个成熟稳定的baseline。Mask R-CNN加ResNet-50-FPN是实例分割领域最经典的组合,生态成熟、资料多、预训练权重好找,作为这套茄子数据集的第一个模型非常合适。你当然也可以试SOLO或者YOLOv8-seg这类新架构,但没有必要在一开始就用不熟悉的模型去排查数据和标注问题。
以mmdetection 2.x为例,训练配置里几个关键参数我是这样设的。
输入尺度方面,因为原图分辨率是1280x1706,直接整图缩放会损失细节。我常用的配置是设置多尺度训练,img_scale=(1024, 1366),实际是保持长宽比的随机缩放。显存不够就降到(800, 1066),但小目标的分割质量会明显打折。
训练轮数方面,这类数据量不算大,12到24轮之间就能收敛。如果数据集干净,12轮左右mask AP基本稳定;如果还想压一压,就开24轮配合学习率衰减。优化器一般用SGD,momentum0.9,初始学习率0.001左右。batch size按单卡2到4张图算,显存不够就开梯度累积。这两个参数要配合:batch size越大,学习率可以适当调高;batch size只有2的时候,学习率太高很容易震荡。
训练过程中,我强烈建议打开验证集评估,每1到2个epoch跑一次。不要等全部训练完才看效果,那样发现数据问题的时间太晚了。第一次跑的时候可以把测试间隔设密一点,主要是确认整个pipeline能跑通,loss能正常下降,mask head没有爆NaN。
另外一个容易忽略的点:数据增强策略。农业场景光照变化大,建议在训练pipeline里加随机亮度对比度和随机旋转。茄子是悬挂生长的,水平翻转可以放心用,不会出现文字方向之类的对称性问题。缩放增强也很重要,因为模型要同时处理近景大果实和远景小果实。
4.3 从指标到调优:如何判断模型是否真的学会
训练完先看COCO标准指标。单类数据主要关心三个数:mask AP、AP50、AP75。mask AP是所有IoU阈值下的平均值,所以它比AP50低很多是正常的。AP50只看IoU在0.5以上的检测,对分割任务来说要求不高;AP75则更严格,能反映mask边缘的精细程度。
对于这类单类农学数据,如果标注干净、划分合理,训练到收敛后mask AP50达到0.7以上并不算意外。如果AP50卡在0.4以下,我一般不会急着换网络结构,而是先查三个方面:训练集标注有没有大量漏标、验证集和训练集背景分布差太多、输入缩放后小果实目标是不是已经退化到边界不清。
还有一个很重要的判别技巧:可视化预测结果,尤其是bad case。把模型预测和GT叠加画在一起,观察预测mask比GT大一圈还是小一圈。如果普遍偏大,说明训练时增强的旋转或缩放配置有问题,导致模型对尺度过拟合;如果普遍偏小,说明标注时轮廓普遍内切,模型学到了“缩进去”的偏见。这里验证了一条铁律:模型学到的永远是标注的样子,而不是茄子的样子。任何标注上的系统偏差,最后都会变成模型输出的系统偏差。
5. 常见问题、排查技巧与高分辨率推理建议
5.1 实操中遇到的典型问题速查
我把自己和同行在类似数据集上踩过的坑做了个速查表,按“现象、原因、处理建议”三个维度整理了一下,希望对你有实际参考价值。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 训练loss不降或震荡 | 学习率过大、标注噪声高 | 调低学习率,先抽50张图人工核对标注 |
| AP50还行,AP75很低 | 标注轮廓内切或外扩,mask边界差 | 重新核对标注规范,关注高光和果柄处的边缘 |
| 小果实漏检严重 | 输入分辨率太低,小目标像素太少 | 提高输入尺度,或训练时做随机裁剪切片 |
| 模型把绿色未熟果漏掉 | 果实颜色与叶片相近,特征不明显 | 增强对比度,补充未熟果样本,或加入更多颜色增强 |
| 推理时mask出现空洞 | 图像缩放比例过狠 | 降低缩放宽高比,使用原分辨率切片推理 |
| 点开某张json发现没图 | imagePath是绝对路径或文件被移动 | 统一目录结构,json和jpg放同层目录 |
上面这些问题的排查顺序,我建议永远是先怀疑数据,再怀疑参数,最后才怀疑网络结构。深度学习框架发展到今天,结构本身出问题的概率远比你想象的低,反而是数据一致性、标注规范、路径错乱这类低级问题消耗了大量时间。
5.2 高分辨率大图推理的切片技巧
这套数据的原生分辨率是1280x1706,如果你训练时把图缩到了800或1024来跑,推理阶段直接喂原图通常问题不大,但如果换到分辨率更高的无人机影像或者全景图,就必须考虑切片推理。
切片推理的原理很朴素:把大图切成若干有重叠的小块,每块独立送入模型推理,再按坐标把预测结果拼回大图。重叠区域里同一个目标可能被检测多次,所以还要做去重融合。这个思路比较适合小目标密集场景,因为模型对小图块里的目标看得更清楚,漏检率会明显下降。
对于这个数据集,如果你想进一步提升小果实的分割效果,可以把训练输入固定到512x512,推理时用512x512的切片去做,重叠率设在20%左右。这样做的好处是训练和推理分辨率一致性更强,坏处是推理时间变长,整张图要多跑好几遍前向。如果项目对速度要求高,直接整图缩放推理就够用;如果对效果要求高,切片推理值得一试。我个人倾向先用整图缩放跑通,再用切片做优化,避免一上来就把流程搞复杂。
5.3 数据治理里容易忽略的几件事
最后想聊几个和数据本身相关、但很多人不重视的细节。
第一,多标注员一致性。如果这份数据是好几个人一起标的,务必在建完数据集后做一轮交叉检查。找一批图让两个标注员分别重标,然后计算mask之间的IoU。如果IoU低于0.85,说明标准还不够统一,需要继续对齐规范。这个步骤虽然费时间,但能省下后面无数次调参的功夫。
第二,异常果实的处理。烂果、被虫咬的果、干枯的果,这些要不要标?我的建议是,如果项目目标是“可采摘果实识别”,就把不健康果实剔除或者单独归入背景,不要混进eggplant类里。不然模型会努力把两种差异很大的外观学成同一个类,最终结果就是两个形态都没学好。如果实在绕不开异常果,也要保证它们在训练集和验证集里都有足够样本,而不是只在一边出现。
第三,病态数据不能只看平均指标。农业数据很容易出现“90%的图都很简单,10%的图特别难”的分布。如果整体AP50很高,但模型在密集遮挡的场景里一塌糊涂,那说明这10%的难样本恰恰是真实应用中最重要的场景。建议单独抽一批高难度子集做压力测试,评估模型在真正复杂条件下的表现,不要把整体指标当成唯一标准。
写在最后的个人体会
我在这套茄子上跑通实例分割之后,最深的一点感受是:农业视觉项目里,真正的天花板往往在数据这一侧,而不是模型架构那一侧。开始标注之前花半天时间写清楚规范,比训完模型再回头补数据要高效得多。训练之前永远先跑通一个最小配置,拿到bad case先怀疑标注,再怀疑参数。这套数据让我印象最深的是,只要标注和划分为题不多,Mask R-CNN这种“老”模型也能交出让人满意的结果。希望这篇从labelme到COCO再到Mask R-CNN整条链路的内容,能帮少走一点弯路。