YOLO關(guān)鍵點格式:避坑指南與腳本實現(xiàn))
關(guān)鍵點檢測這個方向做的人不少但真正踩過數(shù)據(jù)格式轉(zhuǎn)換坑的人才知道從標注工具到訓(xùn)練框架之間那條“最后一公里”有多磨人。我最近在做一個自用的姿態(tài)估計小項目標注環(huán)節(jié)用的是labelme訓(xùn)練端用的是YOLO系列的關(guān)鍵點檢測分支中間卡在格式轉(zhuǎn)換上整整折騰了兩天。標題里說的“l(fā)abelme2yolo格式改變”說白了就是把labelme那套JSON標注轉(zhuǎn)成YOLO關(guān)鍵點檢測能直接吃的TXT格式同時把關(guān)鍵點順序、可見性標志、歸一化坐標這些細節(jié)全部對齊。這件事看起來只是寫個腳本的事但實際動手你會發(fā)現(xiàn)坑集中在坐標系差異、關(guān)鍵點索引映射、以及YOLO對關(guān)鍵點可見性的特殊約定上。這篇內(nèi)容適合正在做關(guān)鍵點檢測、手里已經(jīng)有一批labelme標注、又不想重新標一遍的同行參考也適合剛接觸YOLO關(guān)鍵點任務(wù)、想搞清楚標注格式底層邏輯的朋友。下面我把自己從踩坑到跑通的全過程拆開講包括腳本怎么寫、參數(shù)怎么定、哪里最容易出錯。1. 整體思路與方案選型拆解1.1 為什么不用現(xiàn)成轉(zhuǎn)換工具而選擇自己寫腳本市面上確實有一些開源的labelme轉(zhuǎn)YOLO工具我一開始也試了兩個但很快就放棄了。原因很直接絕大多數(shù)現(xiàn)成工具是針對目標檢測寫的輸出的是類別加邊界框根本不處理關(guān)鍵點。少數(shù)支持關(guān)鍵點的腳本關(guān)鍵點順序是寫死的而我的項目里關(guān)鍵點定義有自己的順序要求比如先鼻尖再左右眼再左右耳跟腳本內(nèi)置的順序?qū)Σ簧限D(zhuǎn)出來之后訓(xùn)練時關(guān)鍵點全串位了。還有一個更隱蔽的問題現(xiàn)成工具對“可見性”的處理往往是二值的要么可見要么不可見但YOLO關(guān)鍵點格式實際上支持三種狀態(tài)可見、遮擋、未標注。這個差異在訓(xùn)練時影響很大遮擋點如果被當(dāng)成可見點參與損失計算模型會學(xué)到錯誤的定位。自己寫腳本的好處是每個環(huán)節(jié)都可控。坐標系怎么轉(zhuǎn)、關(guān)鍵點怎么排序、可見性怎么映射、歸一化用哪張圖的寬高全部由自己決定。而且腳本一旦跑通后續(xù)換數(shù)據(jù)集、加關(guān)鍵點、改類別只需要動幾行配置不用再去讀別人的源碼猜邏輯。我最終的方案是用Python寫一個單文件腳本讀labelme的JSON目錄輸出YOLO需要的images和labels兩個目錄關(guān)鍵點順序和可見性映射通過一個配置字典管理。1.2 labelme與YOLO關(guān)鍵點格式的核心差異要把這件事講清楚得先把兩邊的格式擺出來對比。labelme標注關(guān)鍵點時每個點是一個shape類型是point坐標是圖像像素坐標附帶一個label名字。一張圖的所有點放在同一個JSON的shapes數(shù)組里順序就是你點擊的順序。而YOLO關(guān)鍵點檢測的標注格式一行代表一個目標結(jié)構(gòu)是類別索引、邊界框中心x、邊界框中心y、邊界框?qū)?、邊界框高后面緊跟每個關(guān)鍵點的x、y、可見性全部歸一化到0到1之間。這里第一個大坑就出現(xiàn)了labelme只給你點不給你邊界框。YOLO格式卻強制要求前面有框。這個框怎么來有兩種做法一種是用所有關(guān)鍵點的外接矩形當(dāng)框另一種是人工再標一遍框。我選的是外接矩形因為關(guān)鍵點檢測任務(wù)里框本身不是重點它更多是給模型一個目標級的定位先驗用關(guān)鍵點外接矩形完全夠用。但外接矩形要留一點余量否則框貼得太緊訓(xùn)練時數(shù)據(jù)增強裁剪容易把邊緣關(guān)鍵點裁掉。第二個差異是歸一化基準。labelme的坐標是絕對像素YOLO要的是相對圖像寬高的比例。這里必須用每張圖自己的寬高去歸一化不能用數(shù)據(jù)集統(tǒng)一的尺寸否則不同分辨率的圖會全部錯位。我在腳本里是逐圖讀取JSON里的imageWidth和imageHeight字段來做歸一化的。第三個差異是可見性。labelme的point沒有可見性字段所有點默認都是標了的。但實際標注時有些點是被遮擋的標注者可能憑經(jīng)驗點了個大概位置。YOLO的可見性約定是0表示未標注、1表示遮擋、2表示可見。我的處理策略是在labelme的label名字里加后綴來區(qū)分比如nose_vis表示可見nose_occ表示遮擋腳本解析時根據(jù)后綴映射到1或2沒有后綴的默認按可見處理。這樣既不用改標注工具又能把可見性信息帶過去。1.3 關(guān)鍵點順序映射的設(shè)計考量關(guān)鍵點順序是整件事里最容易被忽視、又最致命的一環(huán)。YOLO訓(xùn)練時輸出層的每個關(guān)鍵點通道是固定順序的你的標注順序必須和模型定義的關(guān)鍵點順序完全一致錯一個位置整條數(shù)據(jù)就廢了。labelme里點的順序取決于標注者點擊的先后不同人標、甚至同一個人不同時間標順序都可能不一樣。所以腳本里必須有一個顯式的映射表把label名字映射到固定的索引。我的做法是維護一個有序列表比如[nose, left_eye, right_eye, left_ear, right_ear, ...]腳本讀到一個點就去列表里查它的名字對應(yīng)的索引然后按索引把坐標填到對應(yīng)位置。如果某個關(guān)鍵點在圖上沒標就填0、0、0可見性填0表示未標注。這個設(shè)計的好處是無論標注者點擊順序如何輸出永遠是固定順序。這里有個細節(jié)要注意映射表里的名字必須和labelme里寫的label完全一致大小寫、下劃線都不能差我一開始就是因為left_eye寫成了leftEye排查了半天才發(fā)現(xiàn)是名字對不上導(dǎo)致整批點被當(dāng)成未標注。2. 核心細節(jié)解析與實操要點2.1 坐標歸一化的計算過程與邊界處理歸一化的公式本身很簡單x_norm等于x_pixel除以image_widthy_norm等于y_pixel除以image_height。但實操中有幾個邊界情況必須處理。第一坐標可能因為標注時的像素取整出現(xiàn)等于image_width的情況歸一化后變成1.0而YOLO要求坐標嚴格在0到1之間等于1在某些實現(xiàn)里會越界。我的處理是統(tǒng)一做一次截斷把歸一化后的值限制在0.0001到0.9999之間避免邊界值引發(fā)訓(xùn)練時的索引異常。第二外接矩形的計算要基于歸一化前的像素坐標算完再歸一化??虻闹行狞c是(x_min x_max) / 2寬是x_max - x_min。這里要注意如果某個目標只有一個關(guān)鍵點被標了外接矩形會退化成一個點寬高為0這種樣本在訓(xùn)練時會出問題。我的策略是給框加一個最小尺寸比如寬高都不小于圖像尺寸的百分之一保證框有實際面積。這個最小尺寸不是拍腦袋定的太小起不到作用太大又會把框撐得離譜百分之一是我在幾百張圖上試出來的經(jīng)驗值既能兜住退化情況又不會明顯改變正常樣本的框。第三關(guān)于框的余量。前面提到外接矩形要留余量我的做法是把框的寬高各放大百分之十中心點不變。這個比例的依據(jù)是關(guān)鍵點檢測常用的數(shù)據(jù)增強里隨機裁剪的幅度一般在百分之十以內(nèi)留百分之十的余量能保證裁剪后關(guān)鍵點仍在框內(nèi)。放大太多會讓框失去定位意義放大太少又防不住裁剪百分之十是個平衡點。2.2 可見性映射的實操約定可見性這塊我在標注階段就做了約定而不是等到轉(zhuǎn)換時再猜。具體做法是在labelme的label命名上加后綴正??梢姷狞c直接寫名字比如nose被遮擋但位置可推斷的點寫nose_occ完全看不清、只是占位的點寫nose_inv。腳本解析時按后綴映射無后綴映射為2_occ映射為1_inv映射為0。這個約定的價值在于它把可見性判斷留在了標注現(xiàn)場而不是轉(zhuǎn)換時憑坐標猜。標注的人最清楚那個點當(dāng)時能不能看見轉(zhuǎn)換腳本沒有這個信息。我試過用坐標是否在圖像內(nèi)來推斷可見性結(jié)果完全不可靠因為遮擋點的坐標也在圖像內(nèi)只是被別的物體擋住了。所以可見性必須由標注環(huán)節(jié)帶出來這是我在返工一批數(shù)據(jù)之后得出的教訓(xùn)。注意可見性為0的點YOLO在計算關(guān)鍵點損失時會跳過但坐標仍然要填。我一開始圖省事未標注的點直接不寫導(dǎo)致那一行的關(guān)鍵點數(shù)量對不上訓(xùn)練直接報維度錯誤。正確做法是每個關(guān)鍵點位置都要占位坐標填0、0可見性填0。2.3 類別索引與多目標場景的處理labelme的JSON里一張圖可能有多個目標每個目標的點混在同一個shapes數(shù)組里。YOLO格式要求每個目標單獨一行所以腳本必須能把點按目標分組。labelme本身沒有目標分組的概念我的做法是在label命名里再加一層前綴來標識目標比如person1_nose、person1_left_eye、person2_nose。腳本按前綴分組每組生成一行。這個設(shè)計對單目標場景是冗余的但對多目標場景是必需的。我做的項目里大部分圖是單人但有一批是雙人交互的如果不分組兩個人的點會被混成一行外接矩形也會把兩個人框在一起完全沒法用。加前綴的代價是標注時多打幾個字但換來的是轉(zhuǎn)換邏輯的清晰。如果你的項目確定只有單目標可以省掉這層前綴腳本里把整張圖的點當(dāng)成一個目標處理即可。類別索引方面我的項目只有一類就是人索引填0。如果有多類需要在配置里維護一個類別名到索引的映射腳本按前綴里的類別名去查。這里要注意YOLO的類別索引是從0開始的連續(xù)整數(shù)不能跳號否則訓(xùn)練時會報類別數(shù)不匹配。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 環(huán)境準備與目錄結(jié)構(gòu)約定腳本運行環(huán)境很簡單Python 3.8以上只依賴標準庫的json、os、glob不需要裝額外的包。這一點是我刻意保持的轉(zhuǎn)換腳本越輕越好不引入依賴就少一層環(huán)境問題。目錄結(jié)構(gòu)上我約定labelme的JSON文件全部放在raw_json目錄下每張圖對應(yīng)一個同名JSON。腳本輸出到dataset目錄下面分images和labels兩個子目錄圖片從JSON同目錄復(fù)制過去標注寫成同名TXT。這里有個細節(jié)labelme保存的JSON里圖片路徑字段可能是絕對路徑也可能是相對路徑取決于標注時的打開方式。腳本不要依賴這個字段去讀圖而是直接用JSON文件名去掉后綴去raw_json同級或指定的圖片目錄里找同名圖片。我一開始用JSON里的imagePath字段拼路徑結(jié)果換了一臺機器路徑全失效后來改成按文件名匹配就穩(wěn)了。3.2 轉(zhuǎn)換腳本的核心邏輯拆解腳本主體分四步。第一步遍歷JSON目錄逐個加載。第二步解析shapes按目標前綴分組每組內(nèi)按label名字查關(guān)鍵點索引填坐標和可見性。第三步計算每組的外接矩形加余量歸一化。第四步拼成YOLO行格式寫入TXT同時復(fù)制圖片。關(guān)鍵代碼邏輯我用偽代碼說明方便你對照自己的實現(xiàn)。解析部分的核心是維護一個keypoints數(shù)組長度等于關(guān)鍵點總數(shù)初始全填[0, 0, 0]然后遍歷shapes對每個point類型的shape解析出目標前綴和關(guān)鍵點名查索引把歸一化坐標和可見性填到對應(yīng)位置。這里歸一化需要imageWidth和imageHeight從JSON頂層字段讀。外接矩形計算時只統(tǒng)計可見性大于0的點因為未標注的點坐標是0、0如果參與計算會把框拉到圖像左上角。這是個很容易忽略的點我第一版就是所有點都參與結(jié)果有未標注點的樣本框全部偏到左上角訓(xùn)練時模型完全學(xué)不到東西。改成只統(tǒng)計可見點之后框的位置就正常了。寫TXT時每行格式是類別 框中心x 框中心y 框?qū)?框高 點1x 點1y 點1v 點2x 點2y 點2v ...所有數(shù)值保留六位小數(shù)。保留六位是精度和文件大小的平衡太少會損失定位精度太多文件會變大且沒必要。3.3 參數(shù)配置與驗證方法腳本里我抽出了幾個可配置參數(shù)集中放在文件頭部。關(guān)鍵點順序列表、類別映射、框余量比例、最小框尺寸比例、可見性后綴映射這五個是必須按項目改的。改完之后先拿三五張圖跑一遍人工檢查輸出的TXT。檢查方法是把TXT里的歸一化坐標乘回圖像寬高看是否落在關(guān)鍵點的實際位置上。我一般會挑一張關(guān)鍵點齊全的、一張有遮擋的、一張多目標的三種情況都對了再跑全量。全量跑完之后還要做一次統(tǒng)計校驗。統(tǒng)計每個關(guān)鍵點被標注的次數(shù)如果某個關(guān)鍵點的標注次數(shù)明顯低于其他點說明映射表里可能有名字對不上的情況。我第一次跑全量就是靠這個統(tǒng)計發(fā)現(xiàn)right_ear的標注次數(shù)是0回頭查才發(fā)現(xiàn)labelme里寫的是rightEar。這個校驗步驟花不了幾分鐘但能省掉訓(xùn)練跑一半才發(fā)現(xiàn)數(shù)據(jù)有問題的返工。4. 常見問題與排查技巧實錄4.1 轉(zhuǎn)換后訓(xùn)練報維度錯誤的排查路徑維度錯誤是轉(zhuǎn)換后最常見的問題表現(xiàn)是訓(xùn)練啟動時報關(guān)鍵點數(shù)量不匹配。排查順序我總結(jié)成三步。第一步看TXT每行的字段數(shù)是否一致用腳本統(tǒng)計每行的空格分隔字段數(shù)正常應(yīng)該是5加3倍關(guān)鍵點數(shù)。如果某行字段數(shù)不對說明那個目標的關(guān)鍵點沒填滿多半是某個點的label名字沒在映射表里查到被跳過了。第二步看關(guān)鍵點總數(shù)配置和映射表長度是否一致這兩個數(shù)必須相等。第三步看類別索引是否從0開始連續(xù)跳號會導(dǎo)致類別數(shù)對不上。4.2 關(guān)鍵點位置整體偏移的定位方法如果訓(xùn)練能跑但關(guān)鍵點位置整體偏移問題通常出在歸一化基準上。最常見的是用了數(shù)據(jù)集統(tǒng)一的尺寸去歸一化而不是每張圖自己的寬高。排查方法是挑一張圖把TXT里的坐標乘回該圖的實際寬高看是否和標注位置吻合。如果不吻合檢查腳本里讀imageWidth和imageHeight的地方確認讀的是當(dāng)前JSON的字段而不是全局變量。還有一種可能是坐標順序?qū)懛戳藊和y對調(diào)這種偏移表現(xiàn)為關(guān)鍵點整體沿對角線翻轉(zhuǎn)比較容易識別。4.3 常見問題速查表問題現(xiàn)象可能原因排查與解決訓(xùn)練報關(guān)鍵點維度錯誤某行字段數(shù)不足統(tǒng)計每行字段數(shù)檢查映射表名字是否與label完全一致關(guān)鍵點整體偏移歸一化用了統(tǒng)一尺寸改為逐圖讀取imageWidth和imageHeight框偏到圖像左上角未標注點參與了外接矩形計算只統(tǒng)計可見性大于0的點多目標混成一行缺少目標前綴分組在label命名加目標前綴腳本按前綴分組遮擋點被當(dāng)成可見點可見性未從標注帶出在label加后綴約定可見性腳本按后綴映射坐標等于1越界像素取整導(dǎo)致歸一化后截斷到0.0001至0.99994.4 獨家避坑心得第一個心得是關(guān)于標注規(guī)范的。轉(zhuǎn)換腳本寫得再好也救不了標注不規(guī)范的數(shù)據(jù)。我在項目開始前就定了一份標注約定文檔明確關(guān)鍵點名字、后綴含義、目標前綴格式所有參與標注的人按同一份文檔來。這份文檔看起來是額外工作但它讓轉(zhuǎn)換腳本的映射表一次寫對后面零返工。第二個心得是關(guān)于增量轉(zhuǎn)換的。項目進行中會不斷有新標注進來我的腳本設(shè)計成可以重復(fù)跑每次全量覆蓋輸出。這樣不用擔(dān)心增量同步的問題反正轉(zhuǎn)換很快幾百張圖幾秒鐘的事。但要注意覆蓋前先備份上一次的輸出萬一新標注有問題還能回退。第三個心得是關(guān)于小樣本驗證的。每次改完腳本不要直接跑全量先挑三類樣本跑關(guān)鍵點齊全的、有遮擋的、多目標的。這三類過了基本就沒大問題了。我吃過直接跑全量、訓(xùn)練跑了兩天才發(fā)現(xiàn)遮擋點全錯的虧小樣本驗證花五分鐘能省兩天。最后再分享一個腳本里的小設(shè)計輸出TXT的同時生成一個轉(zhuǎn)換日志記錄每張圖的關(guān)鍵點數(shù)量、可見點數(shù)量、是否有多目標。這個日志在排查問題時特別有用比如某個關(guān)鍵點標注次數(shù)異常翻日志就能定位到具體是哪幾張圖出的問題不用再去翻原始JSON。這個日志我一開始沒加后來排查問題排煩了才補上補上之后排查效率提升明顯。