资讯动态

nuScenes lidarseg与panoptic标注全解析:从数据读取到模型训练

发布时间:2026/9/29 7:22:55 来源:尧图企业网站定制
做自动驾驶3D感知的人大多是从nuScenes Detection入的门。跑通了CenterPoint、BEVFusion这些检测模型之后突然发现官方还有一个lidarseg、一个panoptic的benchmark。同样是这个数据集、同样是那根激光雷达点云这两份标注却和3D框完全不是一回事。更麻烦的是网上讲detection数据读取的教程一抓一大把真到lidarseg和panoptic要么是英文文档里一句“see lidarseg_explorer.py”要么是零散的API片段很难顺着一条线把数据从下载读到训练。这系列第1篇我们已经把nuScenes的基础框架梳理过了sample、sample_data、sweep、scene以及最常用的camera、lidar、radar数据怎么组织。这篇接着往下走专门把lidarseg和panoptic一次讲透。我会从标注体系的区别讲起然后给出完整的读取代码、可视化方法、训练数据管线以及评测时的mIoU和PQ指标计算口径。看到最后你应该能自己写一个能加载lidarseg/panoptic标注的DataLoader也能跟别人聊清楚nuScenes上语义分割和全景分割到底是怎么回事。1. 为什么检测已经能做还要碰lidarseg和panoptic1.1 detection只告诉你是“车”不告诉你是“路”如果你只用nuScenes做3D目标检测你拿到的标注是一个个3D包围框中心点、长宽高、朝向、速度、类别。这当然有用但有一个根本性缺陷——它只覆盖了“动态物体”和部分“静态物体”地面上大量信息是缺失的。举个很实在的例子检测框可以告诉你前方20米有一辆车但它不会告诉你这辆车右侧那一片是不是可行驶区域也不会告诉你再往前5米是马路牙子还是绿化带。对于下游的规划控制来说这些稠密的语义信息恰恰是缺不得的。lidarseg就是把激光雷达点云里的每个点都标上一个语义类别等于给整个场景做了一次像素级点级的语义理解。这也是为什么后来occupancy任务、在线高精地图、可行驶区域预测这些方向都大量复用lidarseg标签。1.2 lidarseg、panoptic和detection的关系简单说这三者是同一份原始数据在不同粒度上的标注Detection物体级每个目标一个框适合做跟踪、预测、规划。Lidarseg点级语义每个点一个类别适合做几何理解、语义场景补全。Panoptic点级语义实例每个点不仅知道“是车”还知道“是第几辆车”适合做实例级场景理解比如区分同一类别的多个目标。从信息量上看panoptic是lidarseg的超集因为它在语义标签之外额外给了things类别可数的物体类比如车、人、自行车的实例ID。反过来楼主如果只需要判断“这坨点是不是可行驶区域”用lidarseg就够了没必要上panoptic。1.3 这几年哪些任务在悄悄消费这两份标注如果你关注过2023年之后的顶会论文会发现lidarseg和panoptic很少单独出现在标题里但它们作为监督信号无处不在。最典型的是3D占位预测3D occupancy prediction。很多方法在nuScenes上做体素级的占用预测早期直接拿激光雷达点云的lidarseg标签作为监督信号把点云散到体素网格里得到semantic occupancy。还有BEV语义分割、在线高精地图生成也大量用lidarseg的地面类来监督可行驶区域和车道线。再说近一点端到端自动驾驶模型在做成本地图cost volume的时候也会把lidarseg的“driveable surface”“sidewalk”“terrain”作为不同通行代价的先验。所以你看这三者不是互斥关系而是从“稀疏目标”到“稠密语义”再到“稠密实例”的递进。想要做更接近实际驾驶决策的感知lidarseg和panoptic几乎绕不开。2. 标注体系拆解16类语义、18类全景和things/stuff划分2.1 lidarseg的16类到底有哪些nuScenes lidarseg的官方类别数是16个。这16个类分成两部分things可数物体barrier、traffic_cone、bicycle、bus、car、construction_vehicle、motorcycle、pedestrian、trailer、truckstuff不可数或无需逐实例的类driveable_surface、other_flat、sidewalk、terrain、manmade、vegetation这里有第一坑lidarseg的类别编号和detection的类别编号不是一套体系。detection的10类编号在category.json里lidarseg的类别在lidarseg.json的categories字段里。你把两者混着用十有八九会错位。最典型的是lidarseg里类别0不一定是背景可能是barrier。很多第一次跑分割模型的人拿着detection的标号去映射lidarseg一跑出来预测结果全偏了一个位置就是这个原因。2.2 从语义到全景多出来的两成信息在哪nuScenes panoptic的官方类别数是18类。注意这18类并不是简单地在16类基础上加两个新类别而是在语义分类的基础上引入了实例概念。具体划分是things类别bicycle、bus、car、construction_vehicle、motorcycle、pedestrian、trailer、truck这8类每个实例都有一个独立的实例IDstuff类别保持纯语义没有实例ID。至于ideas里剩下的barrier、traffic_cone、地面类这些在panoptic体系里通常被当作stuff处理具体以官方panoptic.json里的categories定义为准。正因为多了一层实例IDpanoptic能做的事比lidarseg多出不少。你可以从点云里直接把不同车辆实例分割出来做实例级的轨迹追踪也可以对stuff区域做语义完整度评估。2.3 别背了打印categories才是正经事不要靠记忆力硬背这16类、18类到底是哪些因为不同版本、不同mini/trainval包之间可能有细微差异。我自己每次拿到一个新环境第一件事就是把类别表打出来from nuscenes import NuScenes nusc NuScenes(versionv1.0-mini, dataroot/data/nuscenes, verboseTrue) print(lidarseg类别: ) for cat in nusc.lidarseg[categories]: print(cat[name], cat[id]) print(\npanoptic类别: ) for cat in nusc.panoptic[categories]: print(cat[name], cat[id])这样跑一遍类别、ID、顺序一眼就清楚了。后面写数据集映射也好、可视化也好都以这份打印结果为准。代码里永远不要写死一套从记忆里抄来的类别编号这是做数据集相关工作最基本的纪律。3. 获取标注数据lidarseg与panoptic包的下载、解压和目录对齐3.1 官网下载时勾哪几个包nuScenes的下载页面是可以按模块勾选的。如果你只下载了v1.0-mini或者v1.0-trainval的原始包里面是不会有lidarseg和panoptic的它们各自是独立的数据包。在下载页面需要额外勾选lidarsegv1.0-mini或v1.0-trainval对应版本panoptic对应版本这两个包独立下载、独立解压。很多新手在官网找了半天发现没有入口其实是因为页面默认只勾选了Camera、Lidar、Radar这些基础传感器数据包lidarseg和panoptic位于页面下方的“Annotations”相关区域不像基础数据包那么显眼。3.2 目录结构解压之后别放错位置解压后正确目录结构应该长这样/data/nuscenes/ ├── v1.0-mini/ │ ├── sample/ │ ├── sweeps/ │ ├── maps/ │ └── v1.0-mini/ │ ├── sample_data.json │ ├── category.json │ └── ... ├── lidarseg/ │ └── v1.0-mini/ │ ├── lidarseg.json │ └── lidarseg/ │ └── sample_token.bin └── panoptic/ └── v1.0-mini/ ├── panoptic.json └── panoptic/ └── sample_token.bin两个关键点lidarseg和panoptic目录必须跟v1.0-mini同级都放在dataroot下。里面的版本子目录v1.0-mini要和主数据版本一致。如果你下载的是v1.0-trainval的lidarseg包就别指望v1.0-mini的代码能正常读到反过来也一样。3.3 版本与devkit对应关系nuscenes-devkit的版本迭代比较频繁早期版本对lidarseg和panoptic的支持并不完善。我的建议是先升级到比较新的devkit版本然后显式验证一下初始化时能否正常加载这两张表nusc NuScenes(versionv1.0-mini, dataroot/data/nuscenes, verboseTrue) assert hasattr(nusc, lidarseg), lidarseg未加载请检查是否安装了对应数据包 assert hasattr(nusc, panoptic), panoptic未加载请检查是否安装了对应数据包如果这里断言通过就说明数据文件位置、json文件和devkit版本都没问题。这个检查可以写成一个脚本放在数据集准备阶段的最后一步省得后面训练到一半才发现数据没对齐。4. 从token到点标签用nuscenes-devkit读取lidarseg的完整流程4.1 初始化NuScenes拿到sample_token读取标注的起点永远是先拿到一个sample_token。sample是nuScenes数据组织的关键帧单位一个sample对应一个时间戳下的全套传感器数据。lidarseg和panoptic的标注都挂在sample级别而不是sample_data级别。这里有个容易搞混的点sample_data对应的是每一次具体的传感器采集而lidarseg标注只存在于sample所在的LIDAR_TOP关键帧上不是所有sweep都有标注。也就是说2Hz的关键帧有标注中间的sweep没有标注。你要训练分割模型使用的就是这些关键帧的点云和标签。import os import numpy as np from nuscenes import NuScenes from nuscenes.utils.data_classes import LidarPointCloud nusc NuScenes(versionv1.0-mini, dataroot/data/nuscenes, verboseTrue) sample_token nusc.sample[0][token] sample nusc.get(sample, sample_token) lidar_top_token sample[data][LIDAR_TOP] lidar_top nusc.get(sample_data, lidar_top_token)4.2 读点云和标签校验长度一致接下来是标准的三步走读点云、读标签、校验长度。# 读取点云points是4xN的数组[x, y, z, intensity] pc LidarPointCloud.from_file(os.path.join(nusc.dataroot, lidar_top[filename])) points pc.points.T # 转成Nx4 # 读取lidarseg标签 lidarseg_rec nusc.lidarseg[sample_token] label_filename os.path.join(nusc.dataroot, lidarseg_rec[filename]) labels np.fromfile(label_filename, dtypenp.uint8) print(f点云点数: {points.shape[0]}, 标签点数: {labels.shape[0]}) assert points.shape[0] labels.shape[0], 点云和标签长度不一致数据可能有问题这段代码的核心就是nusc.lidarseg[sample_token]它返回一条记录记录里的filename字段指向具体的bin文件。bin文件里存的就是每个点的语义类别索引dtype是uint8取值范围0到15对应16类。为什么强调长度校验因为实际使用中偶尔会遇到bin文件损坏、或者你用错了sample导致点数对不上的情况。如果直接拿去训练模型会在数据加载阶段就崩排查起来很浪费时间。加一个assert成本极低收益却很大。4.3 最常见的报错与排查思路读取lidarseg时最常见的报错无非这几种KeyError: sample_token说明nusc.lidarseg里根本没有这个sample。大概率是版本不匹配比如用v1.0-trainval的代码去访问v1.0-mini的包。文件找不到检查nusc.dataroot和filename拼接出来的绝对路径是否正确尤其注意dataroot不要写成带~的相对路径。标签文件读出来是空数组检查下载的解压包是否完整。lidarseg的bin文件往往只有几十到几百KB解压时容易被安全软件拦截。这些坑都不复杂但都是在真实项目里反复踩过的提前知道能省不少时间。5. panoptic的编码与解码一个uint32里的语义和实例5.1 panoptic在文件里长什么样lidarseg的标签文件是每个点一个uint8而panoptic的标签文件是每个点一个uint32。同样是点级标注为什么多了3个字节因为除了语义类别还要装实例ID。nuScenes官方把语义类别和实例ID打包进了一个32位整数里。你如果直接从bin文件读出来一堆很大的整数千万不要以为是噪声。这个整数需要按bit位拆开一部分bit是语义类别一部分bit是实例ID。具体哪几位是语义、哪几位是实例不同版本的官方工具里曾有调整。最稳妥的做法是永远使用官方解码函数而不是自己按记忆去位移。5.2 官方decode_panoptic怎么用nuscenes-devkit里提供了现成的解码函数from nuscenes.utils.panoptic.decode import decode_panoptic panoptic_rec nusc.panoptic[sample_token] panoptic_filename os.path.join(nusc.dataroot, panoptic_rec[filename]) panoptic_labels np.fromfile(panoptic_filename, dtypenp.uint32) # 官方解码得到逐点的语义标签和实例ID semantic_labels, instance_ids, _ decode_panoptic(panoptic_labels, n_classes18)这里返回的semantic_labels是逐点的语义类别索引instance_ids是逐点的实例ID。同属于一个实例的点它们的instance_ids相同且非零属于stuff类别的点instance_ids通常是0或某个约定值。注意一个细节decode_panoptic的第二个参数n_classes要和panoptic的类别数一致。在nuScenes这里是18。如果你读的是自定义版本的数据集这个数值要相应修改否则位宽解析会错位。5.3 实例ID能拿来干什么实例ID带来的直接好处是可以做实例级的下游任务。最常见的两个用法一是评估全景分割质量时把预测点云按实例分组计算每个实例的IoU然后做PQ指标里的匹配。如果没有实例ID就只能做语义级别的评估做不了真正的panoptic评测。二是做目标级特征聚合。比如你想统计“每一辆车”的中心点、尺寸、朝向以前只能先做聚类再统计现在直接从panoptic标签里按instance_ids分组就能拿到。这在构建一些自动驾驶场景理解baseline的时候非常实用省去了一整套聚类流程。6. 可视化验证把标注渲染到图像和BEV上6.1 一条命令渲染sample处理数据集过程中可视化是最好的验证手段。nuscenes-devkit自带的explorer可以直接渲染带lidarseg/panoptic标注的sample方便快速确认数据加载是否正确。# lidarseg渲染 nusc.explorer.render_sample_data( sample_token, channelLIDAR_TOP, with_lidarsegTrue, render_beamTrue, axNone ) # panoptic渲染 from nuscenes.utils.panoptic.panoptic_explorer import PanopticExplorer explorer PanopticExplorer(nusc) explorer.render_sample_data( sample_token, channelLIDAR_TOP, with_panopticTrue, axNone )跑完之后你应该能看到一张点云鸟瞰图每个点按语义类别上了色panoptic模式下同类不同实例会显示成不同颜色。如果所有点都是同一个颜色或者颜色和类别明显对不上先检查是不是装了旧版devkit再确认渲染参数是不是写对了。6.2 手动投影到相机图像有时候鸟瞰图不够直观你更想看看lidarseg标签投影到前视相机图像上是什么效果。这个操作的思路是先做激光雷达坐标系到相机坐标系的坐标变换再把点投影到像素平面。points_cc nusc.explorer.transform_points( points[:, :3], lidar_top[coord], camera_data[coord] )把点云从LIDAR_TOP坐标系变换到相机坐标系之后再做一次相机内参的投影就能得到每个点对应的像素坐标。然后根据语义类别给像素上色叠加到相机图像上。这个过程本质上是多传感器坐标系标定如果你只是想做简单的验证直接调render_pointcloud_in_image更省事。6.3 渲染BEV/coverage图除了常见的点云渲染官方还提供了一类“coverage”图用来统计每个类别在整张图上出现的频次分布。PanopticExplorer里有一个render_panoptic_coverage之类的方法可以把所有类别在图像/点云中的覆盖面积画出来。我第一次用这个功能时以为没什么用后来发现它是找标注bug的利器。如果某个类别在整个val集上的覆盖率低得离谱比如traffic_cone只有一个点那多半不是场景里真没有而是标签解析或类别映射出了问题。提前做一次coverage分析可以避免带着脏数据去训练。7. 训练数据管线从单帧bin文件到批量张量7.1 写一个PyTorch Dataset读取单帧毕竟只是热身真正要训练核心是把“从bin文件读数据”封装成一个高效的Dataset。下面是一个最小可跑的分割训练Dataset骨架import torch from torch.utils.data import Dataset import numpy as np import os from nuscenes import NuScenes from nuscenes.utils.data_classes import LidarPointCloud class NuScenesLidarSegDataset(Dataset): def __init__(self, nusc, sample_tokens): self.nusc nusc self.sample_tokens sample_tokens def __len__(self): return len(self.sample_tokens) def __getitem__(self, idx): sample_token self.sample_tokens[idx] sample self.nusc.get(sample, sample_token) lidar_top_token sample[data][LIDAR_TOP] lidar_top self.nusc.get(sample_data, lidar_top_token) pc LidarPointCloud.from_file( os.path.join(self.nusc.dataroot, lidar_top[filename])) points pc.points.T # Nx4 lidarseg_rec self.nusc.lidarseg[sample_token] labels np.fromfile( os.path.join(self.nusc.dataroot, lidarseg_rec[filename]), dtypenp.uint8) return { points: torch.from_numpy(points).float(), labels: torch.from_numpy(labels).long() }这个写法简单直接但有没有问题有。它每次都从磁盘读bin文件如果训练时直接这么跑IO会成为瓶颈。通常的做法是先把整个数据集的点云和标签缓存成内存数组或lmdb或者用多进程DataLoader共享内存来缓解IO压力。具体怎么优化取决于你的显存和内存大小但至少框架上要有这个意识。7.2 数据增强时标签必须同步变换Segmentation任务里最常见的数据增强是旋转、翻转、缩放、体素化。这些操作对点云坐标是几何变换对标签有没有影响大部分情况下没有——点还是那个点标签还是那个标签。只要你不做“裁剪掉一部分点然后只保留剩余点”的操作旋转翻转缩放都不会改变每个点的类别。但一旦涉及体素化voxelization事情就变了。比如你按0.1m的体素分辨率把点云离散化到一个网格里一个体素里可能混入car和pedestrian两种标签这时就得定义策略是取众数、丢多数、还是随机选一个。这个决策会直接影响训练精度。更隐蔽的一个坑是如果你做了体素裁剪只保留一定范围内的点就得同步把标签数组也裁剪到同样的索引否则点数和标签数对不上模型计算loss时会直接报错。我见过有人把旋转之后的点云存下来、标签忘了跟着索引走最后整个batch全是错位的损失函数曲线看起来正常但mIoU永远不涨。7.3 类别不均衡怎么处理nuScenes lidarseg的类别分布极度不均衡。car、vegetation、terrain、driveable_surface这些类别占据了绝大多数点traffic_cone、construction_vehicle、motorcycle这种点数很少。如果不做任何处理模型会把所有点都预测成高频类别mIoU也会很难看。常用处理手段有三种可以组合使用加权交叉熵损失逆频权重或平方根逆频权重。这是成本最低的手段先把weight设成1 / sqrt(freq)试试看。在损失函数里使用focal loss变体对大样本类别降权。数据和模型层面做类别平衡采样保证每个batch里都尽量覆盖到稀有类别。我自己的经验是先上逆频权重训练一个baseline再看混淆矩阵。如果某些稀有类别依然完全学不出来再上focal loss。不要一上来就堆复杂的采样策略容易把数据分布搞偏。8. 评估与榜单mIoU和PQ的计算口径8.1 语义分割mIoUnuScenes lidarseg的官方指标是mIoU也就是所有类别的IoU取平均。每个类别的IoU是IoU_class 该类别的预测与真实标签的交集 / 该类别的预测与真实标签的并集在实现上通常先算混淆矩阵再逐类计算IoU最后在有效类别上取平均。注意这里不要直接用sklearn的iou_score不加处理就套上去因为nuScenes官方的评估脚本会对一些类别做“忽略”处理而且要求输入的是逐点标签数组不是类别总分。如果自己写评测最小实现是这样def compute_miou(pred_labels, gt_labels, n_classes): ious [] for cls in range(n_classes): pred_mask pred_labels cls gt_mask gt_labels cls intersection (pred_mask gt_mask).sum() union (pred_mask | gt_mask).sum() if union 0: ious.append(intersection / union) return np.mean(ious) if ious else 0.0这段代码只是演示原理实际跑实验时还是以官方评测脚本为准。8.2 全景PQ、SQ、RQpanoptic任务的主指标是PQPanoptic Quality。PQ的定义可以拆成两个部分PQ SQ × RQ SQ 匹配实例的平均IoU RQ 识别质量 TP / (TP 0.5 × FP 0.5 × FN)在匹配阶段预测segment和真实segment之间做实例级匹配只有IoU大于0.5才算成功匹配TP。这个匹配规则很关键它不在乎你预测的实例边界是否和真实边界完全一致只要重叠大于一半就算对。stuff类别则按类别做区域匹配每个类别的stuff区域整体视为一个segment。所以你会发现同样的模型语义分割的mIoU可能不错但全景PQ不一定高。因为PQ对实例边界的要求非常高如果你的模型把一辆车切成了两半语义层面IoU可能还能看但实例层面直接算FPPQ会掉得很明显。8.3 提交与复现需要注意的细节最后说几个实操层面的细节提交到nuScenes官方榜单需要把预测结果导出成和官方标签同格式的bin文件并在json里写清楚每个sample的预测文件路径。提交格式要仔细看官网的“Submitting to the benchmark”页面别凭想象猜。复现论文时不同方法报告的mIoU和PQ可能用的是不同的val划分。nuScenes官方val集有6019个关键帧但有些论文会额外剔除一些有问题的sample。对比时要确认划分一致。做panoptic评估前一定要确认你的预测也带实例ID而不只是语义类别。很多人在评测时偷懒只输出语义分割结果却不输出实例PQ直接崩掉就是这个原因。我自己的习惯是每次跑表之前先用mini集跑一遍完整评测流程确认指标和官方脚本口径一致再上trainval。mini集几百个sample几分钟就跑完评测能筛掉绝大部分脚本bug。分享一个从这套数据里受益的实际经验我最早拿nuScenes跑语义分割时图省事直接用了detection的类别做过滤把不属于10类物体的点全部当成背景。结果mIoU只有不到30怎么调都上不去。后来老老实实按lidarseg的16类全套跑把地面、植被这些stuff类也纳入监督mIoU直接翻了一倍。这个道理后来我也想明白了分割任务和检测任务的最大区别就是它注定不能忽略背景stuff类不是噪声反而是让模型理解场景结构的骨架。理解到这一层lidarseg和panoptic对你来说就不再是两份需要额外下载的标注文件而是整个场景理解能力的起点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑