據(jù)集代碼拆解:從數(shù)據(jù)表關(guān)聯(lián)到3D框投影與點(diǎn)云變換)
簡(jiǎn)介面向自動(dòng)駕駛算法研究者的nuScenes數(shù)據(jù)集配套代碼包聚焦復(fù)雜駕駛場(chǎng)景下多傳感器數(shù)據(jù)的獲取、解析與可視化。資源圍繞nuScenes數(shù)據(jù)集的1000個(gè)20秒場(chǎng)景、40萬關(guān)鍵幀及1400萬3D標(biāo)注框等核心內(nèi)容給出基于nuscenes-devkit庫的操作示例幫助開發(fā)者快速上手傳感器數(shù)據(jù)、樣本注釋和地圖等模塊的讀取與分析。壓縮包共5個(gè)文件包含代碼入口、依賴清單、環(huán)境配置說明與使用文檔整體僅8KB輕量易用。已有118人學(xué)習(xí)下載適合正在學(xué)習(xí)自動(dòng)駕駛數(shù)據(jù)集處理、需要快速理解nuScenes結(jié)構(gòu)與devkit用法的初中級(jí)開發(fā)者。通過源碼可直觀掌握?qǐng)鼍皹颖?、校?zhǔn)傳感器、車輛姿態(tài)等對(duì)象的調(diào)用方式并以此為起點(diǎn)擴(kuò)展自己的數(shù)據(jù)挖掘與可視化腳本降低入門成本。 做自動(dòng)駕駛感知的同學(xué)對(duì)nuScenes數(shù)據(jù)集應(yīng)該不陌生但很多人卡在“數(shù)據(jù)下載好了卻不知道代碼怎么讀”這一步。這個(gè)數(shù)據(jù)集包含了6個(gè)相機(jī)、5個(gè)毫米波雷達(dá)和1個(gè)激光雷達(dá)的同步數(shù)據(jù)加上地圖信息和23類3D標(biāo)注框是當(dāng)前多模態(tài)3D檢測(cè)和BEV感知繞不開的benchmark。這篇文章我拿實(shí)際跑過的代碼來拆一遍從數(shù)據(jù)目錄結(jié)構(gòu)、標(biāo)注表關(guān)聯(lián)、坐標(biāo)系變換到3D框投影到圖像、雷達(dá)點(diǎn)云讀取全程帶可運(yùn)行示例適合剛開始接觸nuScenes、準(zhǔn)備復(fù)現(xiàn)模型或者想把數(shù)據(jù)鏈路搞明白的工程師參考。我最早也被一堆JSON文件名勸退過什么sample、sample_data、sample_annotation、calibrated_sensor、ego_pose光看文檔完全理不清。后來耐著性子把數(shù)據(jù)表的關(guān)聯(lián)一個(gè)個(gè)打出來才意識(shí)到這套設(shè)計(jì)的邏輯其實(shí)很清晰。下面我按自己項(xiàng)目里的推進(jìn)順序來寫盡量把踩過的坑和驗(yàn)證方法都帶出來。1. 項(xiàng)目定位與代碼拆解思路1.1 為什么選擇nuScenes做多模態(tài)感知做自動(dòng)駕駛感知候選數(shù)據(jù)集其實(shí)不少KITTI是單目64線激光雷達(dá)數(shù)據(jù)量小適合快速驗(yàn)證Waymo Open Dataset規(guī)模大但傳感器配置較重?cái)?shù)據(jù)格式和工具鏈上手成本高。nuScenes的定位比較居中傳感器配置完整關(guān)鍵幀是2Hz每個(gè)20秒的scene有40個(gè)關(guān)鍵幀加上中間的sweeps覆蓋了城市、郊區(qū)、夜晚、雨天多種場(chǎng)景。它的另一個(gè)優(yōu)勢(shì)是官方提供了nuScenes-devkit這個(gè)Python包雖然不是十全十美但至少把數(shù)據(jù)讀取和坐標(biāo)系變換的臟活做了大半。訓(xùn)練一個(gè)3D檢測(cè)模型之前首先要確保訓(xùn)練樣本能正確對(duì)齊。nuScenes的相機(jī)、激光雷達(dá)、毫米波雷達(dá)都做了時(shí)間同步和標(biāo)定每一條sample_data都有對(duì)應(yīng)的calibrated_sensor和ego_pose這意味著代碼層面可以比較輕松地把不同模態(tài)的數(shù)據(jù)變換到同一坐標(biāo)系下。這也是我選擇它來做多模態(tài)感知項(xiàng)目的原因數(shù)據(jù)底子干凈代碼側(cè)能少操很多心。1.2 代碼層面的三個(gè)推進(jìn)階段我在項(xiàng)目里把nuScenes的代碼工作分成三個(gè)階段。第一階段是“能讀”把數(shù)據(jù)目錄和JSON標(biāo)注表的結(jié)構(gòu)摸清楚知道哪張表記錄什么信息第二階段是“能算”把坐標(biāo)系變換鏈弄明白不管是把3D框投影到圖像還是把點(diǎn)云從傳感器坐標(biāo)轉(zhuǎn)到自車坐標(biāo)都要手寫出來并驗(yàn)證第三階段是“能用”把這些讀取邏輯封裝成Dataset類供PyTorch訓(xùn)練循環(huán)調(diào)用。這篇文章會(huì)覆蓋前兩個(gè)階段第三個(gè)階段涉及具體模型訓(xùn)練不同模型差異比較大不太適合一概而論。但前兩步做扎實(shí)了第三步基本就是套模板的事。2. 數(shù)據(jù)底層結(jié)構(gòu)目錄、標(biāo)注表與坐標(biāo)系2.1 samples和sweeps的區(qū)別nuScenes的數(shù)據(jù)目錄里有兩個(gè)容易混淆的文件夾samples和sweeps。簡(jiǎn)單說samples是每個(gè)關(guān)鍵幀時(shí)刻的所有傳感器數(shù)據(jù)一個(gè)sample對(duì)應(yīng)一次完整的“同時(shí)刻采集”頻率是2Hzsweeps則是兩個(gè)關(guān)鍵幀之間其他時(shí)刻的數(shù)據(jù)主要用于tracking或者利用歷史信息增強(qiáng)當(dāng)前幀。理解這個(gè)區(qū)別對(duì)代碼調(diào)試很重要。我在早期寫代碼時(shí)直接用sweeps里的點(diǎn)云去和samples里的標(biāo)注框?qū)R結(jié)果發(fā)現(xiàn)時(shí)間戳對(duì)不上框和點(diǎn)云總是差一段距離。后來才意識(shí)到標(biāo)注框只標(biāo)在關(guān)鍵幀時(shí)刻的傳感器數(shù)據(jù)上sweeps里的數(shù)據(jù)只是輔助信息不能直接與samples里的標(biāo)注一一對(duì)應(yīng)。2.2 核心標(biāo)注表的關(guān)聯(lián)關(guān)系nuScenes的標(biāo)注數(shù)據(jù)分布在多張JSON表里最核心的幾張是scene、sample、sample_data、sample_annotation、instance、category。它們之間的關(guān)系可以這樣理解一個(gè)scene是整個(gè)視頻片段包含多個(gè)sample每個(gè)sample是一個(gè)關(guān)鍵幀時(shí)刻通過sample[data]字段可以拿到該時(shí)刻所有傳感器的sample_data token每個(gè)sample_data對(duì)應(yīng)一個(gè)具體的傳感器文件比如相機(jī)圖片或雷達(dá)點(diǎn)云。sample_annotation記錄的是某個(gè)sample時(shí)刻某個(gè)目標(biāo)實(shí)例的3D標(biāo)注框它通過instance關(guān)聯(lián)到類別通過sample關(guān)聯(lián)到具體時(shí)刻。用代碼查這張關(guān)聯(lián)鏈最直觀。比如要找到某個(gè)scene第一個(gè)sample的相機(jī)前視圖標(biāo)注框代碼路徑是scene → first_sample_token → sample → data.CAM_FRONT → sample_data → sample_annotation。這種鏈?zhǔn)讲樵円婚_始不太習(xí)慣但好處是結(jié)構(gòu)清晰任何一環(huán)都可以用token精確定位不會(huì)出現(xiàn)跨表字段冗余的問題。2.3 坐標(biāo)系與位姿變換鏈坐標(biāo)系是nuScenes代碼里最容易出錯(cuò)的地方。整個(gè)數(shù)據(jù)集涉及三套坐標(biāo)系global坐標(biāo)系是全局地圖坐標(biāo)ego坐標(biāo)系以自車為中心x向前、y向左、z向上sensor坐標(biāo)系以傳感器自身為原點(diǎn)。每一幀sample_data都記錄了calibrated_sensor_token和ego_pose_token。calibrated_sensor存的是傳感器相對(duì)于ego的平移和旋轉(zhuǎn)ego_pose存的是ego相對(duì)于global的平移和旋轉(zhuǎn)。所以要把傳感器坐標(biāo)系下的點(diǎn)變換到global坐標(biāo)系變換鏈?zhǔn)莝ensor → ego → global反變換就是global → ego → sensor。很多教程直接調(diào)用nusc.get_sample_data接口拿投影結(jié)果這當(dāng)然快但如果不理解背后的變換鏈一旦結(jié)果不對(duì)就不知道怎么排查。我在實(shí)際項(xiàng)目中是先把這條鏈路手寫出來跑通了再換官方接口這樣出了問題能一眼定位到是哪一步變換寫反了。3. 核心代碼實(shí)操加載、投影、點(diǎn)云變換3.1 環(huán)境配置與數(shù)據(jù)準(zhǔn)備環(huán)境方面的坑我先說結(jié)論實(shí)測(cè)最穩(wěn)的組合是Python 3.8到3.10加上nuscenes-devkit 1.1.10。官方包依賴pyquaternion、numpy等庫如果你環(huán)境里已經(jīng)裝了新版numpy直接pip安裝nuscenes-devkit可能會(huì)因?yàn)閚umpy API變動(dòng)報(bào)一些奇怪的錯(cuò)誤建議用虛擬環(huán)境單獨(dú)建一個(gè)不要放在全局環(huán)境里折騰。python -m venv nuscenes_env source nuscenes_env/bin/activate pip install nuscenes-devkit數(shù)據(jù)準(zhǔn)備方面如果只是學(xué)習(xí)和調(diào)試代碼不需要把整個(gè)trainval下載下來。官方提供了v1.0-mini版本大約4GB包含了10個(gè)scene足夠把數(shù)據(jù)讀取流程跑通。下載好解壓后目錄結(jié)構(gòu)要保持與dataroot設(shè)置一致。比如你解壓到./data/nuscenes里面有samples、sweeps、maps和v1.0-mini這幾個(gè)子目錄初始化時(shí)dataroot填./data/nuscenes就行。3.2 初始化NuScenes并遍歷場(chǎng)景初始化NuScenes對(duì)象時(shí)它會(huì)一次性把所有JSON標(biāo)注表加載進(jìn)內(nèi)存并建立token索引。這個(gè)過程需要一點(diǎn)時(shí)間第一次跑看到卡頓不要慌不是死鎖是在建表。from nuscenes.nuscenes import NuScenes nusc NuScenes( versionv1.0-mini, dataroot./data/nuscenes, verboseTrue ) # 查看第一個(gè)場(chǎng)景 my_scene nusc.scene[0] print(my_scene[token], my_scene[nbr_samples]) print(my_scene[first_sample_token]) # 拿第一個(gè)關(guān)鍵幀 sample nusc.get(sample, my_scene[first_sample_token]) print(sample[timestamp]) print(list(sample[data].keys()))sample[data]是一個(gè)字典key是傳感器名稱包括CAM_FRONT、CAM_FRONT_LEFT、CAM_BACK_LEFT、LIDAR_TOP等value是該傳感器在sample_data表中的token。拿到token后再通過nusc.get(sample_data, token)就能拿到文件路徑、時(shí)間戳、位姿token等完整信息。這段代碼雖然簡(jiǎn)單但我建議認(rèn)真看一遍print輸出。很多初學(xué)者直接跳到模型復(fù)現(xiàn)連sample和sample_data都分不清后面寫數(shù)據(jù)加載器時(shí)就會(huì)到處碰壁。3.3 3D框投影到相機(jī)圖像的手寫實(shí)現(xiàn)這一節(jié)是重點(diǎn)。3D框投影到相機(jī)圖像官方提供了get_sample_data接口但我先給出手寫實(shí)現(xiàn)因?yàn)檫@里面每個(gè)矩陣的來龍去脈才是真正值錢的知識(shí)。投影鏈路分三步先把global坐標(biāo)的3D框中心點(diǎn)變到ego坐標(biāo)再由ego變到相機(jī)sensor坐標(biāo)最后用相機(jī)內(nèi)參投影到像素平面。import numpy as np from pyquaternion import Quaternion # 找到前視相機(jī)的sample_data cam_data nusc.get(sample_data, sample[data][CAM_FRONT]) # 獲取標(biāo)定參數(shù) calib nusc.get(calibrated_sensor, cam_data[calibrated_sensor_token]) ego nusc.get(ego_pose, cam_data[ego_pose_token]) # sensor - ego sensor_to_ego np.eye(4) sensor_to_ego[:3, :3] Quaternion(calib[rotation]).rotation_matrix sensor_to_ego[:3, 3] np.array(calib[translation]) # ego - global ego_to_global np.eye(4) ego_to_global[:3, :3] Quaternion(ego[rotation]).rotation_matrix ego_to_global[:3, 3] np.array(ego[translation]) # 反變換global - ego - sensor global_to_ego np.linalg.inv(ego_to_global) ego_to_sensor np.linalg.inv(sensor_to_ego) global_to_sensor ego_to_sensor global_to_ego # 取一個(gè)3D標(biāo)注框box.center是global坐標(biāo) box nusc.get(sample_annotation, sample[data][CAM_FRONT])等等這里有個(gè)細(xì)節(jié)要說明一下sample_annotation的token不是從sample[data]直接拿的正確做法是獲取該sample下的所有標(biāo)注框再取第一個(gè)。修正代碼如下ann_tokens sample[anns] box nusc.get(sample_annotation, ann_tokens[0]) # box.center 是 global 坐標(biāo)系的中心點(diǎn) center_global np.array(box[translation]) center_sensor global_to_sensor[:3, :3] center_global global_to_sensor[:3, 3] # 相機(jī)內(nèi)參 intrinsic np.array(calib[camera_intrinsic]) p intrinsic center_sensor p p / p[2] print(像素坐標(biāo):, p[:2])代碼跑通后可以把所有標(biāo)注框的中心點(diǎn)投影到圖上用matplotlib畫出來驗(yàn)證。如果所有點(diǎn)都落在相機(jī)視野內(nèi)且位置大致合理說明變換鏈正確如果發(fā)現(xiàn)點(diǎn)嚴(yán)重偏移甚至跑到圖像外面九成是旋轉(zhuǎn)矩陣方向?qū)懛椿蛘咂揭葡蛄繘]加。3.4 激光雷達(dá)點(diǎn)云讀取與全局坐標(biāo)變換點(diǎn)云文件的讀取用官方提供的LidarPointCloud類最省事。激光雷達(dá)點(diǎn)云文件里存的是sensor坐標(biāo)系下的坐標(biāo)要變到global坐標(biāo)系需要經(jīng)過sensor → ego → global這樣一條正變換鏈路。from nuscenes.utils.data_classes import LidarPointCloud import os lidar_data nusc.get(sample_data, sample[data][LIDAR_TOP]) pc LidarPointCloud.from_file(os.path.join(nusc.dataroot, lidar_data[filename])) # 點(diǎn)云形狀為 (4, N)前三行是xyz第四行是強(qiáng)度 print(pc.points.shape) # 激光雷達(dá)的標(biāo)定參數(shù) calib_lidar nusc.get(calibrated_sensor, lidar_data[calibrated_sensor_token]) ego_lidar nusc.get(ego_pose, lidar_data[ego_pose_token]) lidar_to_ego np.eye(4) lidar_to_ego[:3, :3] Quaternion(calib_lidar[rotation]).rotation_matrix lidar_to_ego[:3, 3] np.array(calib_lidar[translation]) ego_to_global np.eye(4) ego_to_global[:3, :3] Quaternion(ego_lidar[rotation]).rotation_matrix ego_to_global[:3, 3] np.array(ego_lidar[translation]) # sensor - ego - global lidar_to_global ego_to_global lidar_to_ego points_global lidar_to_global[:3, :3] pc.points[:3, :] lidar_to_global[:3, 3:4]把點(diǎn)云變換到global坐標(biāo)系后再配合全局地圖信息可以驗(yàn)證點(diǎn)云是否與道路結(jié)構(gòu)對(duì)齊。這個(gè)驗(yàn)證我在項(xiàng)目里做過很多次結(jié)果基本一致說明這套變換邏輯是可靠的。如果你只是想快速把點(diǎn)云畫出來看個(gè)大概也可以直接用pc.points的前三行畫3D散點(diǎn)圖不需要任何變換。但如果你想把雷達(dá)點(diǎn)云和相機(jī)圖像做融合那就必須做坐標(biāo)系變換不能偷懶。4. 常見問題與排查技巧實(shí)錄4.1 安裝和版本兼容問題nuscenes-devkit的版本兼容性是個(gè)實(shí)實(shí)在在的坑。我用Python 3.11裝最新版時(shí)遇到過numpy.ndarray沒有float屬性這類報(bào)錯(cuò)原因是numpy把np.float、np.int這類別名刪掉了而舊版devkit代碼里還在用。后來我固定用Python 3.9加nuscenes-devkit 1.1.10問題就消失了。如果你在import階段遇到pyquaternion報(bào)錯(cuò)直接重裝pyquaternion。這個(gè)包本身比較輕量一般重裝后能解決。另外不要在Windows系統(tǒng)上直接解壓數(shù)據(jù)包到含中文的路徑nuscenes的代碼內(nèi)部對(duì)路徑做了字符串拼接中文路徑偶爾會(huì)出現(xiàn)編碼報(bào)錯(cuò)把數(shù)據(jù)放在純英文路徑下是最省心的做法。4.2 坐標(biāo)變換輸出異常怎么查投影結(jié)果不對(duì)時(shí)我建議按這個(gè)順序排查。第一步檢查ego_pose和calibrated_sensor是否取錯(cuò)比如拿激光雷達(dá)的標(biāo)定去投影相機(jī)數(shù)據(jù)第二步檢查旋轉(zhuǎn)矩陣是否轉(zhuǎn)置Quaternion的rotation_matrix得到的矩陣是從sensor系到ego系的旋轉(zhuǎn)如果你把矩陣用反了結(jié)果自然不對(duì)第三步檢查平移向量要不要加很多人在做反變換時(shí)只做了旋轉(zhuǎn)逆變換忘了平移也需要變換這個(gè)細(xì)節(jié)最容易漏。另外一個(gè)有效的驗(yàn)證方式是拿官方get_sample_data的結(jié)果和手寫結(jié)果對(duì)比。如果官方接口輸出正確而手寫代碼輸出不對(duì)就把中間每一步的矩陣打印出來逐個(gè)對(duì)比。我遇到過好幾次手滑把矩陣乘法的順序?qū)懛醋詈蠖际强窟@種對(duì)比定位出來的。4.3 數(shù)據(jù)加載慢與內(nèi)存占用nuScenes數(shù)據(jù)集如果不加節(jié)制地讀取內(nèi)存會(huì)漲得很快。一個(gè)關(guān)鍵幀的點(diǎn)云文件大小還好但如果你把所有sweeps的點(diǎn)云都加載進(jìn)內(nèi)存幾個(gè)scene下來就是幾十GB很容易把電腦拖垮。我的經(jīng)驗(yàn)是兩個(gè)原則按需加載用完釋放。在訓(xùn)練循環(huán)里每次只讀取當(dāng)前sample需要的傳感器數(shù)據(jù)處理完就扔掉不要用list把所有場(chǎng)景的數(shù)據(jù)先預(yù)加載一遍。另外get_sample_data接口有一個(gè)參數(shù)use_flat_vehicle_coordinates默認(rèn)是True意思是返回的box坐標(biāo)已經(jīng)投影到以自車為原點(diǎn)的平面坐標(biāo)系。如果你要的是global坐標(biāo)記得把這個(gè)參數(shù)設(shè)為False否則后續(xù)做全局變換會(huì)對(duì)不上。4.4 標(biāo)注文件JSON的實(shí)操經(jīng)驗(yàn)最后說一個(gè)不太起眼但很實(shí)用的經(jīng)驗(yàn)nuScenes的標(biāo)注JSON文件在運(yùn)行時(shí)是可以被修改的。如果你只想做數(shù)據(jù)子集采樣比如只保留車輛和行人兩類不需要重新造數(shù)據(jù)集直接在dataroot下新建一個(gè)version文件夾復(fù)制一份并縮小category表的字段就行。我在項(xiàng)目里還遇到過一個(gè)和JSON解析有關(guān)的問題標(biāo)注文件里的category_name是帶層級(jí)結(jié)構(gòu)的比如vehicle.car、vehicle.truck在寫分類映射時(shí)不能直接比對(duì)字符串是否相等要用startswith或者split(.)[-1]來取葉節(jié)點(diǎn)名稱否則新版本數(shù)據(jù)集的類別層級(jí)一變你的代碼就默默失效了。nuScenes這套數(shù)據(jù)鏈路里最值錢的不是那幾個(gè)調(diào)用接口而是理解每一張表、每一個(gè)坐標(biāo)系之間的映射關(guān)系。我個(gè)人的體會(huì)是花一個(gè)下午把3D框投影到圖像這個(gè)流程手寫一遍比看十遍文檔都有用。你可以在跑通之后試著做一個(gè)可視化腳本把相機(jī)圖像、投影框、點(diǎn)云投影結(jié)果疊在一張圖上看到框穩(wěn)穩(wěn)貼在車身上、點(diǎn)云輪廓和圖像邊緣對(duì)齊的那一刻整個(gè)數(shù)據(jù)集的邏輯基本就通透了大半。后續(xù)做BEV感知、多模態(tài)融合都是在吃透這一層之后水到渠成的事。本文還有配套的精品資源點(diǎn)擊獲取