1. 为什么工地扬尘检测,最终我选了实例分割而不是普通目标检测
去年我接了一个智慧工地项目,甲方最初提的需求很直接:装上摄像头,自动识别工地上的扬尘,发现大范围尘土飞扬就报警,最好还能算出扬尘面积。乍一听,这好像就是个普通目标检测任务,框住尘土区域就行。但真正跑到现场看了一天后,我发现事情没那么简单。
先说一个很多人容易忽略的事实:扬尘不是实体,它是飘在空中的颗粒物集合,形状不规则、边界模糊、还会随着风向和施工动作快速变化。如果用普通的目标检测框(bounding box)去框它,一个矩形框里往往只有三四成是真正的尘土,剩下的是天空、塔吊、楼体,甚至阳光反光。甲方要的"扬尘面积",用矩形框去估算,误差能大到没法看。这也是我最终把方案从 YOLOv11 目标检测切成 YOLOv11 实例分割的根本原因。
施工尘土实例分割的核心价值在于:它不是给你一个框,而是给每个扬尘区域画一个像素级的轮廓掩膜(mask),这样计算面积时只统计尘土真实的像素范围,能把背景干扰剔除得干干净净。更关键的是,实例分割能把画面里同时出现的多个独立扬尘源分开——比如现场有南侧和北侧两处同时扬尘,系统能分别输出各自的面积和位置,而不是笼统地给一个大框。这对于智慧工地的扬尘治理来说,才是真正有业务意义的数据。
这篇文章我会把整套项目从数据到部署完整拆开来讲:工地扬尘数据集怎么采、怎么标,YOLOv11 实例分割怎么跑通训练,实测会遇到哪些坑,以及最后怎么落地到智慧工地的监控平台上。适合正在做智慧工地、环保监测、或想入手 YOLO 系列分割任务的朋友参考,纯小白也能跟着一步步复现。
2. 扬尘数据集的构建:没有靠谱的数据,模型再好也白搭
2.1 采集设备与视角设计
先解决一个最现实的问题:数据从哪来。项目初期我尝试过直接从网上下载"工地扬尘"图片,结果发现网图大多是新闻配图,角度单一、清晰度高、背景简单,训练出来的模型一上工地现场就"翻车"。后来我干脆蹲在合作工地的塔吊、围挡、制高点装了 3 台固定枪机,另用一台无人机每天在固定时段补拍俯视角,手机也随时抓拍补充。
这里的核心经验是,采集视角必须覆盖真实监控的角度,因为算法最终面对的是俯仰角不太大、距离几十米的固定摄像头画面,而不是近景特写。塔吊上的俯视视角和地面平视视角拍出来的扬尘形态差异非常大,一定要两者都收进数据集。我自己在标注时就发现,俯视视角的扬尘更像一团团扩散的烟雾,平视视角则经常和墙体背景混在一起,分割难度明显更高,这两类样本占比保持在 6:4 左右训练效果最稳。
采集时段方面,我强烈建议不要只在晴天中午拍。扬尘在逆光、顺光、阴天、傍晚、夜间补光下的外观差别极大,尤其是逆光时尘土颗粒会泛黄,顺光时偏灰白。如果数据里没有这些光照变化,模型部署后会在一天中的某些时段出现大面积漏检。我第一批数据清一色是上午拍的,结果模型在下午四点的逆光场景下几乎全灭,后来补了一批不同时段的数据才救回来。
2.2 数据清洗与类别定义
原始视频抽帧后,要做一轮严格的数据清洗。有些帧是摄像头被遮挡、画面过曝、或扬尘区域占比不足画面的 5%,这种样本直接删掉。清洗标准我给大家一个可量化的参照:
- 画面中扬尘区域清晰可见,且面积占比不低于画面的 3%;
- 同一场景连续抽帧之间差异明显(避免大量重复帧导致训练集高度自相关);
- 排除雾天、霾天等天气本身的"大背景污染",这类和施工作业扬尘形态相似但成因完全不同,混进去会让模型行为变得混乱。
类别定义这块,我的做法是只做一个类别dust,不要一开始就搞多分类。你可能会想加上"防尘网""渣土车""裸土"这些相关类别,但实战下来,类别越多标注成本越高,且 YOLOv11 在类别不均衡时会倾向于忽略小目标的训练。先用单一类别跑通基线,后续有需要再扩展。如果确实要加,建议加一个heavy_dust(重扬尘)类别用来区分轻微飘尘和严重扬尘,但这已经是二期优化的事了,不是第一版该操心的。
2.3 标注工具与标注精度要求
分割任务的标注比检测任务痛苦得多,要和每一个扬尘区域的轮廓死磕。我用的标注工具是 X-AnyLabeling,它内置了 SAM(Segment Anything Model)预标注能力,流程是先让 SAM 自动生成候选掩膜,人工再修边缘。对半透明的扬尘区域,SAM 经常会把边缘切到尘土和背景模糊过渡带的中间,这种地方需要人工把透明度边界和实际肉眼可见的颗粒扩散范围对齐。
这里有个标注细节很重要:不要在扬尘轮廓的每个像素边界上都追求极致精细,因为扬尘的物理边界本身就不清楚,太较真反而会让模型无所适从。经验法则是,标注的轮廓和真实可见的尘土边缘偏差控制在 3-5 个像素以内即可,把更多精力花在确保不把背景物体标进扬尘区域上——比如塔吊、楼体边缘、树木这些静止物体,如果被错误标进 mask,训练时模型会学到"建筑也是扬尘"的坏习惯。
标注数量上,第一版训练集我用了 1200 张图,其中 900 张单扬尘源、300 张多扬尘源。起步阶段建议至少保证 600-800 张,低于这个量级分割任务很难收敛出可用的边缘质量。整个标注团队花了两周完成,人均每天处理 20-30 张,这个工时要提前做好预期管理。
2.4 数据增强策略的特殊考量
YOLOv11 自带 Mosaic、翻转、HSV 扰动、随机缩放等增强,但用在扬尘分割上要动点脑子。默认的 HSV 增强会把扬尘颜色改得离谱,变成蓝色、紫色烟雾,这反而干扰模型对尘土颜色的认知。我在训练时调低了 HSV 的强度参数,hsv_h降到 0.01,hsv_s和hsv_v保留在 0.4 左右,只模拟自然光照变化,不搞夸张色彩。
另外一个必须考虑的是小目标问题。扬尘在画面中经常是中小面积区域,尤其监控视角下远处工地起尘时可能只有几十个像素。随机缩放过狠会让这些本来就小的目标变得更小,甚至缩到几个像素导致标注 mask 退化。我的做法是把scale范围控制在 0.5-1.5 之间,同时开启 Mosaic,让小目标有更多机会和不同背景组合,间接提升小目标的检测鲁棒性。
3. YOLOv11 实例分割训练的完整实操:环境、数据组织与关键参数
3.1 环境配置与版本兼容
YOLOv11 是 Ultralytics 团队在 YOLOv8 基础上更新的版本,目前使用ultralytics库就能直接训练,不需要额外安装独立的 YOLOv11 包。环境这块我踩过一次坑,就是 torch 和 CUDA 版本不匹配导致训练时 GPU 利用率上不去,后来固定成一套稳定组合:
| 组件 | 版本 |
|---|---|
| Python | 3.10 |
| PyTorch | 2.1.0 |
| CUDA | 11.8 / 12.1 |
| ultralytics | 8.3.x(需支持 yolo11 模型) |
| GPU | 显存建议 8GB 以上(训练用) |
安装就用一条命令:
pip install ultralytics如果你要用 GPU 训练,建议先装好匹配的 PyTorch 再装 ultralytics。可以在 Python 里验证一下环境是否就绪:
import torch import ultralytics print(torch.__version__, torch.cuda.is_available()) print(ultralytics.__version__)这里有一点值得说明:很多人一上来就奔着yolo11x-seg.pt最大模型去训练,其实第一版应该用yolo11n-seg.pt或者yolo11s-seg.pt打底。扬尘分割任务对实时性要求高(后面部署到 Jetson 或边缘盒子时尤其如此),n/s 级别的模型跑一轮训练只需要几个小时,能快速验证数据和标注质量。等确认数据集没问题、模型能收敛,再换大模型刷精度也不迟。
3.2 数据集组织:从标注结果到 YOLO 分割格式
实例分割训练需要的数据格式是 YOLO 分割格式,和检测格式类似,但每行标注里坐标点的数量是不固定的。每行格式为:class_id, x1, y1, x2, y2, ..., xn, yn,所有坐标值都是归一化到 0-1 的浮点数(用像素坐标除以图片宽高)。
标注工具导出后通常是 JSON 或 COCO 格式,需要写一个转换脚本。我直接给出我用的核心转换逻辑:
import json import os import cv2 import numpy as np def polygons_to_yolo_seg(json_path, img_dir, out_dir, class_names=None): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) for item in data: image_path = os.path.join(img_dir, item['image']) img = cv2.imread(image_path) h, w = img.shape[:2] txt_name = os.path.splitext(item['image'])[0] + '.txt' with open(os.path.join(out_dir, txt_name), 'w') as out: for shape in item.get('shapes', []): label = shape['label'] cls_id = class_names.index(label) if label in class_names else 0 points = shape['points'] # [[x1,y1],[x2,y2],...] norm_pts = [] for p in points: norm_pts.append(round(p[0] / w, 6)) norm_pts.append(round(p[1] / h, 6)) line = f"{cls_id} " + " ".join(map(str, norm_pts)) + "\n" out.write(line)转换完成后,目录结构要按 YOLO 标准的images/train、images/val、labels/train、labels/val放置。我习惯按 9:1 划分训练集和验证集,扬尘数据分布差异大,验证集不要留太少,至少 100 张以上才能看出稳定的评估指标。
接着写一个数据集配置文件:
# dust_seg.yaml path: /path/to/dust_dataset train: images/train val: images/val names: 0: dust这个配置文件用来传给训练脚本,names一定要和标注时的类别索引保持一致,否则训练会直接崩或者学出莫名其妙的结果。
3.3 训练参数选择与调优逻辑
启动训练的命令如下:
yolo segment train \ data=dust_seg.yaml \ model=yolo11n-seg.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ optimizer=SGD \ mask_ratio=4.0 \ project=run/dust \ name=exp1几个关键参数背后的逻辑我得展开讲讲:
imgsz=640:这是速度和精度的平衡点。扬尘在监控画面中不算特别大的目标,640 分辨率下中等面积的扬尘区域通常有 40-80 像素,模型能学到足够的边缘细节。如果你发现远处小区域扬尘漏检严重,可以尝试imgsz=1024配合batch=8(显存允许的情况下),小目标检测提升很明显,训练时间大约增加 60%。
mask_ratio=4.0:这是 YOLOv8/v11 分割任务特有的参数,控制掩膜损失在总损失中的比重。默认值就是 4.0,我建议第一版保持默认。如果发现掩膜的边缘质量差——比如 mask 形状和真实扬尘轮廓差距明显但检测框本身挺准,可以上调到 6-8,让模型更关注分割质量。
optimizer=SGD:yolov11 默认可能用 AdamW,但在分割任务上我实测 SGD 在 150 epoch 的长训练里稳定性更好,最终 mAP 通常会高 1-2 个点。如果想快速收敛看初步效果,可以先用 AdamW 跑 50 epoch,后面正式训练再切回 SGD。
训练过程中要盯的指标不是box_loss,而是mask_loss和mAP(0.5-0.95)的曲线。mask_loss 如果持续下降但 mAP 震荡厉害,通常说明验证集的扬尘区域形态过于多样,可以考虑提高iou=0.7的阈值(训练时的正样本匹配阈值)来增强监督的严格性。
3.4 训练过程中的可视化监控与模型选择
训练到 80-100 epoch 时,我会直接用验证集做一次推理可视化,把模型预测的 mask 和真实 mask 叠在图上保存下来,随机抽 20 张肉眼检查。这个步骤比看任何损失曲线都直观,能发现两类典型问题:
一是大面积扬尘的区域内部出现空洞,mask 中间被误判为背景,这和半透明尘土区域的像素值接近天空有关。处理方案是给这类样本单独加进数据增强池,或者人工在标注时把扬尘内部较淡的区域也尽量完整标出来,增强数据多样性。
二是把高空中的白云误判为扬尘。白云边缘柔和、颜色偏白,和顺光下的扬尘确实神似。遇到这种情况,单纯调参数没用,需要回来补标注负样本——把更多包含白云的镜头帧放进训练集,但标注时不标任何目标(即空标签),让模型学会"白云不是扬尘"。我额外收集了 150 张含白云、薄雾的工地帧作为负样本补充训练,误检率立刻降了一截。
模型选型上,我用同一数据集对比了n、s、m三个尺寸:
| 模型 | mAP50 | mAP50-95 | 推理耗时(GPU) | 模型大小 |
|---|---|---|---|---|
| yolo11n-seg | 0.872 | 0.613 | 3.2ms | 5.4MB |
| yolo11s-seg | 0.901 | 0.662 | 4.8ms | 18.5MB |
| yolo11m-seg | 0.918 | 0.691 | 7.1ms | 40.2MB |
如果后续要上边缘设备,s 级别是最优选,m 级别在 GPU 服务器上跑没问题但移植到 Jetson 就要考虑帧率下降了。
4. 实测中的效果评估与踩坑记录:别被 mAP 数值骗了
4.1 推理代码与阈值调整
模型训练完成后,验证效果我建议不要只看 mAP,要直接拉到实时视频流里跑,用真实摄像头画面做"验收"。推理部分用 ultralytics 的 API 很简洁:
from ultralytics import YOLO model = YOLO("run/dust/exp1/weights/best.pt") results = model.predict( source="rtsp://your_camera_ip/stream", conf=0.25, iou=0.45, imgsz=640, stream=True, classes=[0], ) for r in results: masks = r.masks # 每个目标的掩膜 boxes = r.boxes # 每个目标的像素面积 if masks is not None: areas = masks.data.sum(dim=(1, 2)).tolist()这里conf=0.25是 ultralytics 的默认值,但我在工地实测后建议扬尘场景把conf调高到0.35-0.45。原因很扎心:默认阈值下模型会输出大量置信度在 0.25-0.35 之间的"边缘预测",这些预测在验证集上可能不算误检,但在真实视频流里往往是飘絮、飞鸟、甚至镜头脏污这些干扰物。调高阈值后分割 mask 的数量下降了约 30%,但保留下来的都是真正的扬尘区域。
4.2 防尘网与真实扬尘的边界混淆问题
这是我在实战中遇到的最棘手的坑。智慧工地上几乎都有绿色或黑色的防尘网,铺在裸土表面或脚手架外侧。当防尘网边缘破损、随风抖动时,模型会频繁把它识别成扬尘——因为它们同样呈絮状、有颗粒感、边缘不规则。
我花了两周时间用三种手段联合解决:
第一,数据层面补充"破损防尘网"和"抖动的绿色网布"的专门样本,标注时不标任何目标,作为负样本压下来;
第二,在推理后处理里加了形态学约束——扬尘区域的 mask 通常呈扩散状,边缘凹凸率较高,而防尘网抖动的区域往往是长条形或带褶皱的纹理。我计算 mask 轮廓的凸包面积比,如果轮廓面积占凸包面积的比例小于 0.6,判定为"过于细碎/长条",直接过滤掉:
import cv2 import numpy as np def is_valid_dust_mask(mask, min_solidity=0.6, min_area=200): """ mask: 二值掩膜 (H, W),0/1 或 0/255 返回 True 表示可能是扬尘,False 表示是长条/细碎干扰 """ contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) == 0: return False cnt = max(contours, key=cv2.contourArea) area = cv2.contourArea(cnt) if area < min_area: return False hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) solidity = area / hull_area if hull_area > 0 else 1.0 return solidity > min_solidity第三,利用时间上下文:扬尘是动态变化的,同一区域会连续多帧出现且面积逐渐增减;防尘网抖动虽然也动,但它的 mask 形状在相邻帧之间是"翻折式"变化,面积突变明显。我在后处理里加了一条简单的帧间 IOU 关联规则:第 t 帧的候选掩膜如果和 t-1 帧的某个掩膜 IOU 小于 0.3,标记为"新出现区域",连续出现 3 帧以上才输出告警,否则丢弃。这个规则实施后,夜间树枝投影晃动导致的误报几乎归零。
4.3 小目标扬尘与夜间场景的优化尝试
监控视角下二三十米外的起尘区域,在一张 640x640 的图里往往只有 30-60 像素大小。这个尺寸下模型虽然能检测到,但分割轮廓变得粗糙,面积计算误差偏大。
针对这个,我试了两个方案:第一个是把推理分辨率从 640 提到 1024,小目标的 mAP 提升了大约 8 个百分点,但 Jetson 上的推理耗时从 35ms 涨到 80ms,帧率掉了太多;第二个是在摄像头侧做区域放大(ROI 裁剪),固定对画面中"易扬尘点"(土堆附近、装卸区、道路交叉口)做 2 倍数字放大后再跑推理。这个方案效果好且可控性强,最终我在每个监控点位配置了 2-3 个 ROI 区域,轮询检测,既保证了小目标召回率,又没把整体帧率拖垮。
夜间场景是另外一个难题。工地夜间虽然有补光灯,但扬尘被灯光照射后视觉效果很差,颗粒反光强烈但轮廓模糊,模型的 mask 置信度会集体下降。提高conf阈值会让夜间几乎全漏,降低又会让夜间各种光源光晕影响误报。我的妥协方案是:夜间单独切换一组低阈值参数(conf=0.2),同时开启更严格的时序过滤规则——因为夜间误报往往在固定区域反复出现,我加了一个"同区域误报抑制":同一像素区域如果预测置信度始终在 0.2-0.35 之间且 mask 面积波动不大,大概率是灯光固定的光晕,做缓存过滤。
4.4 面积与真实尺度的换算逻辑
分割模型输出的是扬尘的像素面积,但业务方需要的是"大概多少平方米的扬尘区域"。这里要做一个透视换算。
最简单的做法是利用已知的参考物。工地围挡高度通常是 2 米,在画面中量出围挡底边对应的像素长度,再假设该监控点附近地面近似水平,用单应性估计算出像素到实际面积的映射系数。我给出一个简化版:
def pixel_area_to_real_area(pixel_area, ref_pixel_len, ref_real_len=2.0, image_height=640, assumed_dist_m=30.0): """ 简化的线性估算:以围挡高度为参考, 近似认为 pixel_area 对应的地面区域随距离线性衰减。 更精确的做法是标定相机内参 + 单应矩阵。 """ scale = ref_real_len / ref_pixel_len # 每个像素对应的实际长度(米) # 粗糙但实用的估算:面积按像素面积 * scale^2 计算 real_area = pixel_area * scale * scale return real_area这个公式精度不算高,但用于扬尘告警的"面积分级"足够了。我给现场定义的阈值是:单处扬尘区域超过 50 平方米触发黄色预警,超过 150 平方米触发红色预警。实际使用中,甲方关心的是"有没有、有多大、持续多久",不是精确到个位数的面积,所以基于线性映射的估算完全够用。如果要做得更准,建议用棋盘格在工地现场做相机标定,再算单应矩阵,但那是另一个工程量了。
5. 部署到智慧工地监控平台:边缘推理与告警链路
5.1 推理硬件的选型与模型导出
训练好的模型要部署到工地现场,通常有两种路径:一是放在监控机房里的 GPU 服务器上统一推理,二是放一台边缘盒子就近处理。我这次选的是 NVIDIA Jetson Orin Nano,理由很实在:工地机房普遍没有强 GPU,项目方也不可能为了一个摄像头点位配一台 3080,Orin Nano 的 8GB 显存版本体型小、功耗低(7W-25W),跑 yolo11s-seg 能达到 15-20 FPS,完全满足秒级告警需求。
部署前先把 PyTorch 模型导出为适合边缘推理的格式。我的导出链是PyTorch -> ONNX -> TensorRT,因为 Jetson 上 TensorRT 引擎的推理性能是最好的,对比数据如下:
| 推理格式 | 单帧耗时(640x640,s 模型) | 说明 |
|---|---|---|
| PyTorch FP32 | 41ms | 只适合调试 |
| ONNX Runtime FP32 | 29ms | 通用但不极致 |
| TensorRT FP16 | 15ms | Jetson 首推 |
导出命令:
yolo export model=best.pt format=onnx opset=12 imgsz=640拿到 ONNX 后再在 Jetson 上用trtexec转 TensorRT:
/usr/src/tensorrt/bin/trtexec \ --onnx=best.onnx \ --saveEngine=best_fp16.engine \ --fp16 \ --workspace=4096注意导出时imgsz要和训练时一致,否则会有分辨率抖动带来的精度损失。
5.2 RTSP 取流、推流与告警逻辑设计
工地的摄像头基本都是海康或大华的 RTSP 流,推理程序直接拉流跑。我的框架是写一个常驻进程,循环处理:
import cv2 from ultralytics import YOLO model = YOLO("best_fp16.engine", task="segment") cap = cv2.VideoCapture("rtsp://user:pass@ip:554/Streaming/Channels/101") def post_process(masks, confs, areas): # 前面提到的 solidity 过滤 + 面积过滤 + 帧间时序过滤 return valid_masks while True: ret, frame = cap.read() if not ret: break results = model.predict(frame, conf=0.40, iou=0.45, imgsz=640) valid = post_process(results[0].masks, results[0].boxes.conf, results[0].boxes.cls) if len(valid) > 0: # 触发告警:根据面积分级 max_area = max(valid) if max_area >= area_threshold_red: send_alert("red", frame, valid) elif max_area >= area_threshold_yellow: save_snapshot(frame, valid) # 推送可视化叠加帧到平台 annotated = results[0].plot() cv2.imwrite("/tmp/current_frame.jpg", annotated)告警链路我用了两种方式:HTTP 推送到平台的 Webhook,以及 MQTT 推给现场班组长的手机端。HTTP 推送简单粗暴,但工地现场网络偶尔断,MQTT 的持久会话机制更可靠。我用 EMQX 做 MQTT broker,边缘盒子作为 publisher,告警消息里附带当前帧的截图 base64 和扬尘面积数据,方便甲方直接看现场。
5.3 现场部署后的效果复盘
整个系统上线后跑了约一个月,我对实际效果做了一个比训练 mAP 更贴近业务的复盘:在覆盖 8 个监控点位、每天 12 小时运行条件下,单点位平均一天触发黄色预警 7-9 次,红色预警 1-2 次,真正对应到施工单位确认的"土方作业/装卸物料瞬间"的事件占比约 85%,剩余的 15% 里大半是邻近工地飘过来的尘土,这也算真实扬尘,不算误报。
夜间误报仍然存在,主要集中在强光车灯扫过防尘网时的边缘响应,但被我的时序过滤规则拦掉了大部分,最终每周能确认的纯误报控制在 1-2 次以内。整体来看,这个项目从数据采集、模型训练到边缘部署的路径已经完整跑通,后续想要提升的话,可以往多类别扬尘分级、多摄像头联合空间过滤这些方向走。
最后说一个我踩出来的切身体会:不要一上来就追求模型的精度上限,先让整套链路在真实工地环境和真实摄像头条件下转起来。摄像头角度、光照、网络稳定性、现场干扰物这些因素对最终效果的影响,往往比模型本身大得多。先把数据闭环跑通,再回头用 badcase 反哺数据集迭代模型,这才是做工地视觉项目最靠谱的节奏。