做室内场景理解相关的项目,最头疼的不是模型结构选哪个,而是你有没有一批能直接用的数据。不是没有公开数据集,而是公开集往往跟你的业务场景对不上;自己标注,又绕不开格式、质量、标注规范这一堆问题。最近我整理并实际使用了一套室内要素识别分割数据集:1031张建筑物内部真实图像,labelme格式,固定3个类别——地面、墙壁、天花板。这套数据在机器人导航、AR平面检测、室内装修量房、建筑逆向建模这些任务里都能当底座用,尤其适合做语义分割方向验证和模型落地的第一版训练集。
我先把结论放在前面:3类要素看起来简单,但把地面、墙面、天花板这三类结构性表面标干净,比标十来个家具类别更考验规范。因为这三类往往互相接壤、边界很长,一条线上的错标会直接影响整个分割结果。后面我会把数据设计逻辑、labelme格式细节、标注实操、训练结果和踩坑过程全部拆开讲。
1. 数据集面面观:为什么是1031张和3个类别
1.1 地面、墙壁、天花板:室内场景的三个基本空间面
任何一个建筑物的室内空间,无论户型怎么变,都共享三个结构性表面:地面(ground/floor)、墙壁(wall)、天花板(ceiling)。这三个表面把三维空间围合成一个盒子,其余所有的室内对象——桌椅、沙发、灯具、门、窗——都是挂在这个盒子之上或之内的。
做室内要素识别,恰恰不需要先去管那些挂载物,而是先把支撑整个空间的三块基本面识别出来。这是很多下游算法的基础:
- 机器人导航:地面就是可通行区域,墙壁是不可穿透边界,天花板提供高度参考。
- AR平面检测:地面和墙面是虚拟物放置、光照估计的物理锚点。
- 室内装修量房:地面面积、墙面面积、天花面积直接对应施工量。
- 建筑逆向建模:三个面组合起来就是室内布局的结构壳。
所以在分割任务里,我把类别控制为这3类是合理的。类别越多,标注成本和标注歧义越高,而在这个阶段,先解决“空间壳”的识别,是性价比最高的路径。3个类别也天然适合做多分类分割训练中的类别权重调优实验,类别少时更容易把单个类别的IoU指标做高。
1.2 1031张的样本量:从成本和效果两个维度算一笔账
很多人一上来会质疑:做深度学习分割,上千张是不是少了?诚实地讲,如果从零训练一个大模型,1031张确实不够看;但如果使用在ImageNet或大型场景数据上预训练的编码器,并配合合适的数据增强,这个数量级用于3类别分割是完全可行的。
我实测过一组配置:用ResNet50作为U-Net的编码器,输入分辨率在768x768附近,经过合理的数据增强和约80轮的训练,验证集mIoU能稳定在0.87左右。这一方面得益于类别少、类别语义明确,另一方面是因为地面、墙壁、天花板在图像中的面积占比高,监督信号非常充足。
另一个不可忽视的角度是标注成本。据我估算,一张室内图像用多边形仔细标注3个类别,平均需要15到20分钟。1031张意味着大约250到300小时的标注工时。如果你在安排人力,这个规模刚好是一个一到两人、一个标注周期内可以收口的工作量。张数太多,比如说5000张以上,标注规范和一致性问题会急剧放大,返工成本非常恐怖。
所以1031张这个规模,是“能训练出一个可用模型”和“能控制标注质量”之间的一个平衡点。实际使用下来,这个量的数据足以支持算法预研、Demo验证,也足以作为更大规模数据集的起点。
1.3 图像构成与场景覆盖:数据不是越多越好,而是越全越好
数据集的真正价值并不完全在于张数,而在于它覆盖了多少种建筑空间类型、多少种拍摄姿态、多少种光照情况。我自己在使用这套数据时特意做了场景梳理,发现它覆盖了住宅、办公室、商业店铺、走廊、楼梯间等几类常见室内空间。
从拍摄视角看,图像里既有接近正对墙面拍摄的图,也有大量带透视效果的图,后者在分割任务里远比正视图有挑战性。透视场景中,地面和墙壁的交界是一条斜线,墙壁和天花板的交界也是斜线,这直接考验模型对于结构边界的理解能力。
光照方面,数据里包含自然光、混合光、灯光偏暗的室内环境,甚至有局部过曝和阴影较强的图像。对于分割任务,过曝和阴影带来的边界误判是最常见的失败原因,数据里提前引入这些情况,比后期用图像增强去模拟真实光照变化要高效得多。
提示:如果你计划在其他场景复用这套数据,最好先按图像来源、光照条件、拍摄姿态做一次分布统计,确认覆盖情况是否符合自己的部署环境。模型对数据分布的敏感性,很多时候比模型结构本身更重要。
2. labelme格式拆解:一份标注文件是怎么描述一张图的
2.1 逐字段读一遍JSON:points到底存的是什么
labelme是标注工具,也是一种存储协议。它把一张图的所有标注信息保存在一个同名JSON文件里。我先贴一段实际文件的简化结构:
{ "version": "4.5.6", "flags": {}, "shapes": [ { "label": "ground", "points": [ [865.0, 501.0], [987.0, 548.0], [1120.0, 612.0], [1040.0, 689.0], [876.0, 653.0] ], "group_id": null, "shape_type": "polygon", "flags": {} }, { "label": "wall", "points": [[0.0, 620.0], [1280.0, 620.0], [1280.0, 300.0], [0.0, 300.0]], "group_id": null, "shape_type": "polygon", "flags": {} } ], "imagePath": "img_0001.jpg", "imageData": null, "imageHeight": 720, "imageWidth": 1280 }逐个字段拆开说:
- version:标注工具版本号。如果多人协作使用的labelme版本不一致,version会不同,但不影响读取。
- flags:全图级别的布尔标记,一般闲置。
- shapes:核心数组。一个shape对应一个闭合多边形标注。
- label:类别名。这里必须严格统一,比如全用ground,不要ground和floor混用。
- points:多边形顶点的像素坐标数组,每个元素是[x, y]。它是整个分割信息的核心。
- group_id:分组ID,常用于把同一个物体的多个部分归为一组,结构面分割一般不用。
- shape_type:形状类型,常见的有polygon、rectangle、circle、line。这里全部是polygon。
- imagePath:关联图像文件名。
- imageData:如果为null,表示图像不内嵌在JSON里;如果非空,则是一大段Base64编码的图像数据。当图像文件单独存在时,保持null即可,否则JSON文件会撑得很大。
- imageHeight / imageWidth:图像尺寸。这两个值在训练前必须和实际图像宽高一致,否则转mask时会错位。
我在第一次使用这套数据时,专门写了一个小脚本扫描全部JSON,检查“imageHeight/imageWidth是否跟图像实际宽高匹配”“shapes是否为空”“label是否只包含三类”这三件事。扫描完再进训练,能省掉大量debug时间。
2.2 为什么是polygon而不是矩形框:分割标注的必然选择
初次接触分割标注的人可能会问:地面不就是一个矩形框吗?实际上不是。摄影机视角下,地面在图像里通常呈现为一个不规则的梯形或斜四边形,墙角线条相交、家具遮挡都会让边界变得很不规则。rectangle形状只能记录一个矩形范围,完全无法表达这类几何结构。
如果用实例分割里常见的像素级mask标注,对每个对象画掩膜,又太繁琐。polygon恰好处于中间位置:顶点数量可以自由控制,边缘既要贴合结构线,又不必精细到像素级。对于地面、墙壁、天花板这类大面积、长边界、边缘平直的对象,十来个顶点就已经能达到很好的边界精度。
这里补一个经验:polygon顶点并不是越多越好。顶点过多,一来标注时间成倍增加,二来容易引入顶点抖动,导致边界出现锯齿状噪声。我的经验是,平直边界用2到5个顶点,转角处按实际弧度增加顶点,一个墙面多边形控制在10到20个顶点内比较合适。
2.3 从labelme格式到训练格式:三种常见转换路径
labelme的JSON不能直接作为PyTorch或TensorFlow训练的输入,一般需要先转成mask图像,或者转成其他语义分割格式。我试过三条路径:
第一种,用labelme自带工具直接导出。labelme_json_to_dataset命令会生成一个以原文件名命名的文件夹,里面有img.png、label.png、label_viz.png等文件。label.png就是语义分割训练要用的单通道mask,像素值对应类别索引。这个方式最省事,缺点是文件结构散乱。
第二种,在训练脚本里实时读取JSON并画mask。用Python的json库读出points,然后用OpenCV的fillPoly填充多边形,得到与图像等大的mask。这种方式不需要提前转换,我在调试阶段用得最多,因为可以随时抽看某张图的标注是否正确。
第三种,转成COCO格式。COCO的annotation是RLE或多边形表达,而且官方pycocotools能直接处理。社区有一些现成的labelme2coco脚本,转完以后可以直接接检测框架或者自己写的dataloader。如果后续你打算把3类结构面分割扩展到实例级分割或目标检测任务,这条路早晚要走通。
3. 构建高质量标注数据的实操全流程
3.1 采集阶段先把规范定住:拍摄高度、角度和光照
标注质量的上限在采集阶段就决定了。如果图像本身拍得不行,标得再仔细也白搭。这套数据集里我总结了几条采集规则,可以照搬:
- 拍摄高度控制在1.2米到1.7米之间,模拟人眼视野。过高或过低的机位都会让地面、天花板在图像中的比例失衡。
- 主体拍摄保持水平视角或略带俯仰。特别强调不要用无人机视角垂直俯拍,那种图像里墙壁几乎不可见,标注墙壁类别就失去意义。
- 光照选择尽量接近真实使用场景。比如做室内机器人项目,就覆盖白天、傍晚、开灯、关灯多种条件;做AR应用,就多拍明亮均匀的环境。
- 同一个空间可以从多个角度、多个位置拍摄多张。角度差异能显著提升模型的泛化能力。
如果你拿到的原始图像质量参差不齐,至少要做一轮人工筛选:删除过曝、严重模糊、纯白墙占画面90%以上的“无效图”。我在筛选时还发现,有些图拍的是室内某个角落的特写,三块结构面缺了天花板或者地面,这类图不是不能用,而是要在划分训练集时额外注意,别让它们集中在某一个验证子集里。
3.2 边界判定标准:哪些线算边界,哪些不算
标注结构面,最核心的问题是边界怎么画。不同标注者凭直觉画出来的边界差异非常大,必须提前定好标准。
我跟团队定的默认规则如下:
- 地面与墙壁的边界:以踢脚线为界。踢脚线以上属于墙壁,踢脚线以下属于地面。如果图像中没有踢脚线,就以墙壁与地面在视觉上的折角线为界。
- 墙壁与天花板的边界:以天花板与墙体之间的阴角线为界。注意有些房间有石膏线或吊顶,石膏线以下仍算墙壁。
- 被家具遮挡的区域:一律标注可见区域,不做补全推断。比如沙发挡住了一部分地面,那地面多边形就把沙发露出来的区域挖掉。
- 墙上的门窗:如果任务是结构面识别,门窗可以视为墙壁的一部分,不必单独抠出。3类别数据结构下,保持墙面区域完整是最一致的策略。
- 玻璃、镜面墙面:按物理墙面标注,不做特殊处理。
上面这些规则看起来简单,一旦多人协作,每个人对“踢脚线以上”的理解还是会偏差。最好的做法是,第一轮先让所有标注者各自标5张相同的图,然后逐张对比,把不一致的地方一条条列出来,协商出补充细则。这个过程非常有必要,比写10页规范文档都管用。
3.3 labelme标注实操:从打开工具到保存一个完整多边形
labelme是开源标注工具,通过Python包管理工具安装后,在终端里输入labelme就可以启动。界面操作逻辑很直观,我简要说一下关键步骤:
- 打开图片后,用Create Polygons按钮开始画地面。先在边界上依次单击放置顶点,最后一个点靠近起点,多边形会自动闭合。
- 弹出窗口里输入类别名。这里建议预先统一好类别名的拼写,比如ground、wall、ceiling,不要中途改成floor或者ceiling_01。
- 画墙壁和天花板,步骤相同。要注意三个多边形的边界必须连成一条线,不能出现互相交叠覆盖。如果两个多边形相交,转mask时后画的会盖掉先画的,类别归属就会错乱。
- 保存时,labelme自动生成同名JSON文件。图像文件和JSON文件保持同名且在同一目录下,是最稳妥的存放方式。
顺便提醒一个我踩过的坑:labelme对中文路径和带空格路径的处理不完善,在Windows下尤其容易出现文件写不出来的问题。建议整个标注目录使用纯英文命名,图片文件名统一用数字加下划线,比如img_0001.jpg这种格式。
3.4 标注后的自动化校验:用脚本把错标揪出来
人工标注迟早会出错,校验必须做成自动化。我建议至少跑以下几项检查:
- 空标注检查:shapes数组为空,或者某个类别在图中完全没有出现,但这张图明明包含该类表面。
- 标签名检查:统计所有JSON文件里出现过的label值,确保只有ground、wall、ceiling。
- 坐标越界检查:polygon的任意顶点如果超出图像宽高范围,转mask时必然出问题。
- 多边形自相交检查:顶点顺序错乱会导致多边形转换成mask时出现异常的凹形或空洞。
- 面积占比统计:把每张图的三个类别按像素面积统计出来,如果某些图地面占比几乎为0,检查是不是漏标了。
我自己比较喜欢用“可视化抽查+自动脚本扫描”的组合:脚本先把所有可疑文件列出,然后我逐张打开这些图的JSON叠加显示,人工确认是错标还是规则上的合理例外。整个过程大概花一个晚上,能发现不少肉眼直接看会忽略的问题。
4. 从数据集到模型:一次真实的训练与评估复盘
4.1 数据划分与扩增:别把同一间房的两张图拆到train和val里
拿到一套分割数据集后,第一个动作就是划分train/val/test。这里有一个值得专门强调的原则:如果数据集里有同一场景、同一机位的多张图,划分时必须按“场景来源”进行分组,而不是随机打乱。
为什么?因为随机打乱会让同一间房间的图像同时出现在训练集和验证集。模型可能在训练时“见过”这个房间的墙面纹理和布局,验证集mIoU会虚高。等部署到没见过的房间,性能立刻打回原形。我在某次实验里对比过:随机划分时mIoU是0.91,按场景划分后立刻掉到0.87。差距不是模型带来的,完全是数据泄漏。
分配比例上,我建议8:1:1作为起点。如果数据量再大一些,可以调整到9:0.5:0.5,但验证集和测试集至少要各保留80到100张,否则评估指标的置信度太低。
数据增强方面,我用的是随机水平翻转、随机缩放(0.8到1.2倍)、随机亮度抖动、随机裁剪,外加少量高斯模糊。颜色抖动幅度要控制,不然容易把本来就有的光照差异放大成训练噪声。这里提醒一点:如果做了缩放,mask也必须同步缩放,并且要保持相同的插值方式,实践中mask统一用最近邻插值。
4.2 模型选型与训练参数:我最后选了哪套配置
针对1031张、3类别的分割任务,我实验下来最有性价比的组合是:DeepLabV3+架构,带ResNet50编码器,输入尺寸768x768,用ImageNet预训练权重初始化。这个配置在显存和训练时间上都比较友好,一张主流显卡的训练时间大约在40到60分钟内完成80个epoch。
下面是一段核心训练代码的关键逻辑,Feed给dataloader的数据包括图像、mask和类别索引:
import torch import torch.nn as nn import torch.optim as optim # mask像素值为类别索引(0,1,2),255表示忽略区域 criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) for epoch in range(80): model.train() for images, masks in train_loader: images = images.cuda() masks = masks.cuda() outputs = model(images)["out"] loss = criterion(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step()损失函数方面,我最初直接用了交叉熵,但发现天花板这类类别在大量图像中占比很大,而地面在有些角度下占比偏小,模型的recall指标失衡。后来在损失里加了类别权重,地面取1.2,墙壁取1.0,天花板取0.8,mIoU有了明显提升。如果你遇到类似的不平衡问题,可以先用像素占比的倒数做权重,再根据验证集指标微调。
学习率我建议先用线性预热加余弦退火,初始1e-4,预热5个epoch,最终退火到1e-6。这个策略在分割任务上比固定学习率稳定,尤其是编码器已经从预训练权重迁移的情况。
4.3 评估指标与典型失败场景
训练之后,我用mIoU、pixel accuracy、每类别IoU三个指标来评估。3类结构面分割在大多数图像上可以达到0.85到0.9的mIoU。需要特别关注的是如下几类失败场景:
- 强烈光影交界:当一束阳光从窗户打到墙上,墙面中间出现一条明显的明暗分界线,模型有时会把这条阴影边界误认为墙壁和地面的分界。
- 大面积反光地面:抛光砖和木地板反射出天花板或窗框的倒影,模型偶尔会把倒影区域错分成墙壁或天花板。
- 家具紧贴墙壁密集摆放:柜体、冰箱、柱子与墙面颜色接近,模型会把家具的一部分并进墙壁区域,导致边界向外扩张。
- 天花板吊顶或异形顶:跌级吊顶、中央空调出风口区域,模型会误判天花板内部的高低差为类别切换。
这些失败场景大多数都能从数据侧找到原因。比如反光地面,如果训练集中充足包含了抛光砖、瓷砖地面的样例,模型通常能学出“地面区域即使有倒影也是地面”的上下文关系。我后来专门在数据增强里增加了一种“局部亮度扰动”,把这些反光区域的对比度压低,模型在这类图像上的表现才稳下来。
4.4 长边界“毛边”问题:一个需要接受的事实
结构面分割和一般物体分割有一个明显差异:类别之间的边界长度特别长。一张图像里,地面与墙壁的交界、墙壁与天花板的交界可以占到图像高度的一大半,这类长边界在预测时最难稳定,经常出现局部锯齿状毛边。
缓解毛边问题有几个可用方法:
- 训练时使用边界感知损失,比如在CrossEntropy之外叠加一个Dice Loss,能够显著提升边界区域的像素召回。
- 预测后加一个轻量的CRF后处理,但缺点是推理时间增加,实际落地时很少用。
- 更推荐在解码端加一层可学习的边界细化模块,或在dropout之后增加一点随机擦除,让模型适应遮挡和边界缺失。
我在实际项目里选择放弃了对毛边的过度“磨皮”,因为在机器人导航这类任务中,几像素的边界抖动对路径规划影响不大,而一旦引入复杂的后处理反而会增加延迟。这个判断要和你的落地场景对齐。
5. 常见问题排查与后续扩展
5.1 高频问题速查表:拿到这类数据后绕不开的坑
我把使用labelme格式室内分割数据过程中常见的问题整理成一张表,基本覆盖了从数据清洗到训练结束的各个阶段。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| mask和原图不对齐 | JSON里imageWidth与实际图像宽高不一致 | 打印JSON中的宽高和图像真实尺寸 | 统一修正JSON字段,或统一缩放图像 |
| 某些类别IoU异常低 | 标签拼写不一致,导致类别索引错乱 | 统计所有label值 | 将标签名统一为ground/wall/ceiling |
| 训练loss正常但mIoU不高 | 数据划分存在场景泄漏或验证集合过小 | 按空间来源分组重新划分 | 保证train/val/test互不包含同一场景 |
| 白色墙面对比度低导致漏检 | 数据光照和纹理单一 | 检查数据集中纯色墙占比 | 增加局部对比度扰动或多添加含纹理墙面图 |
| polygon自相交导致mask畸形 | 标注时顶点顺序错乱 | 用脚本检查多边形自相交 | 重新标注,或自动修复顶点排序 |
| JSON里imageData太大 | 保存时把图像Base64嵌入了 | 检查文件大小 | 用工具去嵌入,只保留imagePath引用 |
这里特别提醒一条:labelme的JSON文件和图像文件一定不要用中文命名。系统环境、工具链和训练框架对中文路径的支持参差不齐,我见过因为路径解析失败导致整个dataloader崩溃的情况,纯英文命名能从源头上排除这个隐患。
5.2 如何基于这套数据扩展出更多类别:从3类到十几类的升级路径
这套3类别数据集完全可以作为更大标注计划的底座。在室内场景理解任务里,通常下一步就是加入门、窗、柱子、家具、洁具等类目。
升级路径我建议分两步走。第一步,用现有3类模型对新增图像做预分割,生成伪标注。第二步,人工修正伪标注,只补充新类别。这样做能把新增类别的标注效率提升一倍以上,因为地面、墙壁、天花板这些背景类别已经由模型自动打底,标注员只需要修改局部边界和增补新类别多边形。
不过要提醒一句:新增类别需要逐步来,一次加太多类别,类别之间容易混淆。比如门和墙壁都是大面积、垂直线条丰富的对象,如果一次性加入,模型的类间区分压力会很大。比较稳妥的顺序是:先加门窗,再加家具,最后加地面上的小物体。
5.3 从2D分割走向3D语义:数据集的延展方向
如果你做的事是建筑室内建模或机器人全局规划,地面墙壁天花板分割往往只是第一层。接下来可以考虑两个方向。
第一个方向是深度与分割的联合预测。多任务学习里,让模型同时输出语义类别和深度值,能互相增强。比如地面和天花板在几何上一远一近,这种几何先验可以帮助模型更稳定地分辨相似纹理的类别。
第二个方向是从单张图像扩展到全景图分割。全景图能覆盖整个房间的天花板、地面和一圈墙壁,一张图就是一个完整房间空间。把现有的2D分割数据转换成等距柱状投影的标注,再在全景图上训练,就能直接为三维场景重建和VR漫游提供输入。
我自己目前正在把这套3类别数据作为校验基准,测试一个新的边界细化模块,尝试在阴影和反光场景下进一步压低长边界的误判率。后续有了新的实验结果,我会再单独写一篇,把训练配置和推断性能一起贴出来。
最后分享一句个人体会:拿到任何分割数据集,都别急着直接丢进训练脚本。先花一晚上扫一遍JSON、看一眼mask可视化、查一遍类别分布,这些前置工作在后续调试里能帮你省出数倍时间。一套好的分割数据,核心价值不在数量,而在于标得干不干净、边界准不准、类别统不统一。