注工具設(shè)計(jì)與實(shí)現(xiàn))
簡(jiǎn)介在圖形界面開發(fā)中交互式繪圖與標(biāo)注是常見的需求而Qt提供的Graphics View框架為處理復(fù)雜圖形場(chǎng)景和用戶交互提供了優(yōu)雅的解決方案。該框架通過視圖、場(chǎng)景與圖形項(xiàng)的三層結(jié)構(gòu)將坐標(biāo)變換、碰撞檢測(cè)、選中事件等底層邏輯封裝起來極大地提升了開發(fā)效率和可擴(kuò)展性。坐標(biāo)映射是標(biāo)注工具的核心理解視圖坐標(biāo)、場(chǎng)景坐標(biāo)與圖像原始坐標(biāo)的轉(zhuǎn)換關(guān)系才能確保標(biāo)注數(shù)據(jù)的準(zhǔn)確性。Graphics View的技術(shù)價(jià)值在于它將每個(gè)標(biāo)注對(duì)象建模為獨(dú)立的圖形項(xiàng)支持靈活的樣式定制和交互響應(yīng)廣泛應(yīng)用于圖片標(biāo)注、地圖編輯、CAD等場(chǎng)景。本文基于Qt 5和C詳細(xì)講解如何使用QGraphicsView構(gòu)建一個(gè)功能完備的圖片標(biāo)注工具涵蓋坐標(biāo)變換、圖形項(xiàng)封裝、數(shù)據(jù)序列化及常見問題排查幫助開發(fā)者快速掌握這一實(shí)用技術(shù)。2. 為什么選擇 QGraphics View 而不是直接在 QWidget 上畫先說結(jié)論用 QGraphics View 做圖片標(biāo)注工具尤其是要做矩形框、多邊形、點(diǎn)選這類交互式標(biāo)注幾乎是首選架構(gòu)。為什么不直接在 QWidget 的 paintEvent 里畫原因很簡(jiǎn)單可擴(kuò)展性和交互能力差太多。直接繪制方案每個(gè)標(biāo)注框都要自己維護(hù)坐標(biāo)、自己處理鼠標(biāo)命中檢測(cè)、自己管理重繪區(qū)域標(biāo)注數(shù)量一多代碼就亂成了麻。而 QGraphics View 體系下每個(gè)標(biāo)注就是一個(gè) QGraphicsItem 對(duì)象它自帶了坐標(biāo)變換、碰撞檢測(cè)、選中事件、層級(jí)關(guān)系這些能力如果全都自己造輪子一個(gè)月都寫不完。2.1 坐標(biāo)變換是標(biāo)注工具的靈魂標(biāo)注工具里最容易翻車的點(diǎn)就是坐標(biāo)變換。圖片標(biāo)注這個(gè)場(chǎng)景里有三套坐標(biāo)體系一是視圖坐標(biāo)就是 QGraphicsView 上的像素坐標(biāo)鼠標(biāo)事件返回的 event-pos() 是這一套二是場(chǎng)景坐標(biāo)就是 QGraphicsScene 里的坐標(biāo)所有圖形項(xiàng)都生活在這一層三是圖片原始坐標(biāo)也就是要寫進(jìn)標(biāo)注文件里的像素坐標(biāo)。Qt 提供了 mapToScene 和 mapFromScene 這兩組方法作用是把鼠標(biāo)的視圖坐標(biāo)轉(zhuǎn)換到場(chǎng)景坐標(biāo)。但注意這還不能直接存盤。因?yàn)閳D片被縮放、平移之后場(chǎng)景坐標(biāo)不等于圖片坐標(biāo)。正確做法是拿標(biāo)注框的場(chǎng)景坐標(biāo)去跟圖片 item 的坐標(biāo)做一次映射。我給標(biāo)注矩形加了 getRectInImageCoords 方法返回的是相對(duì)圖片左上角的坐標(biāo)。這個(gè)值是最終要寫入 JSON 文件的數(shù)據(jù)。2.2 圖形項(xiàng)的設(shè)計(jì)直接決定后期維護(hù)成本標(biāo)注矩形我用了 QGraphicsRectItem但沒讓它直接裸奔而是做了兩層封裝第一層是 AnnotationRectItem負(fù)責(zé)繪制樣式邊的粗細(xì)、顏色、高亮效果、尺寸約束第二層是 AnnotationData負(fù)責(zé)純數(shù)據(jù)坐標(biāo)、類別標(biāo)簽。這樣 UI 和數(shù)據(jù)的邊界非常清晰。有一個(gè)經(jīng)驗(yàn)值得分享一定要給 QGraphicsRectItem 開啟 setFlags否則你會(huì)發(fā)現(xiàn)標(biāo)注框無法被鼠標(biāo)選中更別提拖動(dòng)了。需要設(shè)置的標(biāo)志位至少包括ItemIsSelectable、ItemIsMovable、ItemIsFocusable。第一個(gè)管選中第二個(gè)管拖動(dòng)第三個(gè)管接收鍵盤事件比如按 Delete 鍵刪除標(biāo)注框就是靠它。如果加了 ItemIsFocusable 之后鼠標(biāo)滾輪縮放事件會(huì)失靈——因?yàn)榻裹c(diǎn)在 item 上滾輪事件被 item 吃掉了。解決方式是在 MainWindow 的 eventFilter 里做處理或者重寫 item 的 wheelEvent 直接忽略掉。2.3 標(biāo)注數(shù)據(jù)的序列化要有遠(yuǎn)見標(biāo)注數(shù)據(jù)我選了 JSON 格式?jīng)]有用自定義的二進(jìn)制格式原因很簡(jiǎn)單JSON 可讀性高、調(diào)試方便、和其他工具對(duì)接也容易。Qt 自帶的 QJsonDocument 完全夠用不需要引入第三方庫。標(biāo)注結(jié)果的 JSON 結(jié)構(gòu)我一開始就設(shè)計(jì)得很寬每個(gè)標(biāo)注矩形包含label類別的字符串標(biāo)識(shí)className類別的顯示名比如carx、y、width、height圖片原始像素坐標(biāo)浮點(diǎn)數(shù)可選字段confidence置信度、group_id實(shí)例分組、is_hard困難樣本標(biāo)記預(yù)留這些字段的代價(jià)幾乎為零但等你要做二次開發(fā)時(shí)就知道當(dāng)初多寫了一個(gè)字段有多香了。3. 代碼實(shí)現(xiàn)環(huán)境配置、函數(shù)設(shè)計(jì)、關(guān)鍵代碼這一節(jié)直接把代碼邏輯拆開揉碎了講。環(huán)境是 Windows 11 Qt 5.15.2 MinGW 64 位編譯器用 MinGW 而不是 MSVC原因是 Qt 開源自帶的套件就是 MinGW省去單獨(dú)裝 Visual Studio 的麻煩。如果你手頭是 Qt 6.x代碼基本兼容個(gè)別 API 有小改動(dòng)。工程文件用 qmake完整 .pro 文件如下這段配置里 include 和 DEPENDPATH 是指向當(dāng)前目錄add 了 widgets 模塊。整個(gè)工程的類只有 5 個(gè)沒有用 model/view 那一套重型架構(gòu)因?yàn)闃?biāo)注工具的復(fù)雜度用不著上 MVC。3.1 MainWindow 的主界面布局主界面的布局邏輯很簡(jiǎn)單菜單欄 中央 QGraphicsView 狀態(tài)欄 右側(cè)浮動(dòng)面板。右側(cè)面板包含類別管理區(qū)可以添加、編輯類別類別的顏色自動(dòng)分配標(biāo)注列表區(qū)顯示當(dāng)前圖片的所有標(biāo)注框選中哪一項(xiàng)對(duì)應(yīng)框體高亮保存區(qū)導(dǎo)出當(dāng)前圖片的標(biāo)注 JSON3.2 圖片加載和縮放圖片加載后會(huì)把 QPixmap 塞進(jìn)一個(gè) QGraphicsPixmapItem然后 setSceneRect 讓場(chǎng)景和圖片尺寸保持一致。這部分有一個(gè)坑需要專門說圖片加載后的顯示模式選擇適應(yīng)窗口而不是原始大小第一張圖看起來是鋪滿的但一旦你縮放了圖片點(diǎn)擊自適應(yīng)窗口時(shí)應(yīng)該調(diào)用 view-fitInView(pixmapItem, Qt::KeepAspectRatio)而不是手動(dòng)去 setSceneRect。fitInView 的第二個(gè)參數(shù)選項(xiàng)KeepAspectRatio 是等比縮放IgnoreAspectRatio 會(huì)拉伸圖片導(dǎo)致變形標(biāo)注工具的標(biāo)注框坐標(biāo)是基于圖片的所以必須保持等比這一點(diǎn)不能偷懶。狀態(tài)欄顯示的縮放比例我是從 view-transform().m11() 拿到的因?yàn)樽隽说缺瓤s放m11 和 m22 相等直接取一個(gè)就行。3.3 標(biāo)注矩形繪制的完整流程鼠標(biāo)畫矩形的完整邏輯按下鼠標(biāo)左鍵時(shí)如果當(dāng)前處于畫框模式記錄場(chǎng)景坐標(biāo)作為起點(diǎn)并創(chuàng)建一個(gè)寬度和高度為 0 的 QGraphicsRectItem拖動(dòng)過程中實(shí)時(shí)更新矩形的右下角坐標(biāo)松開鼠標(biāo)時(shí)如果矩形的寬高小于 10 像素直接刪除該 item認(rèn)為是誤操作這個(gè)防誤操作設(shè)置太重要了。我最初做完功能自測(cè)時(shí)經(jīng)常因?yàn)槭髽?biāo)單擊導(dǎo)致生成了 1 像素寬高的矩形框后來加了 10 像素的最小限制這類問題再也沒出現(xiàn)過。3.4 標(biāo)注數(shù)據(jù)導(dǎo)入導(dǎo)出導(dǎo)出邏輯把每個(gè) QGraphicsRectItem 的 AnnotationData 序列化到 JSON。這里需要注意保存時(shí)標(biāo)注坐標(biāo)必須取圖片原始坐標(biāo)而不能直接存場(chǎng)景坐標(biāo)。轉(zhuǎn)換公式很簡(jiǎn)單得到一個(gè)相對(duì)圖片的坐標(biāo)imgX rect.x() - imageItem-pos().x(); imgY rect.y() - imageItem-pos().y(); 寬度和高度直接取 rect 的 width 和 height。注意這里的坐標(biāo)換算必須用 item-pos() 而不能寫成 imageItem-mapFromScene(rect.topLeft())因?yàn)?QGraphicsRectItem 的 rect() 本身就是基于 item 自身的坐標(biāo)系scence 坐標(biāo)轉(zhuǎn) item 坐標(biāo)用 mapFromScene 更準(zhǔn)但這里換算出的是相對(duì)圖片左上角的偏移量imgX 和 imgY 直接等于偏移量。4. 核心代碼全解標(biāo)注工具的骨架與五臟六腑這一部分把上面設(shè)計(jì)思路落成代碼。我分幾個(gè)文件來拆解。4.1 工程文件與入口MainWindow 的核心信號(hào)槽用 connect 連接代碼結(jié)構(gòu)上把畫框模式切換用了一個(gè)簡(jiǎn)易狀態(tài)機(jī)管理當(dāng)前模式是畫矩形、選擇圖形還是縮放圖片。這樣設(shè)計(jì)的核心好處是以后要擴(kuò)展畫多邊形標(biāo)注或畫點(diǎn)標(biāo)注只需要新增對(duì)應(yīng)的繪制函數(shù)和狀態(tài)即可。4.2 AnnotationRectItem 類的實(shí)現(xiàn)AnnotationRectItem 的核心區(qū)別在于它復(fù)寫了 paint() 函數(shù)。默認(rèn)的 QGraphicsRectItem 的繪制只能設(shè)置統(tǒng)一的畫筆顏色我需要一個(gè)選中時(shí)高亮的效果以及一個(gè)邊角處顯示縮放控制點(diǎn)的效果。判斷 item 是否被選中在 paint 函數(shù)里使用 option-state 與 QStyle::State_Selected 的按位與操作。一旦檢測(cè)到選中畫筆寬度從 2 像素加粗到 4 像素同時(shí)畫出四個(gè)角的錨點(diǎn)方塊。4.3 視圖與事件處理QGraphicsView 默認(rèn)的事件分發(fā)在某些場(chǎng)景下不夠用。比如滾輪縮放圖片時(shí)我希望以鼠標(biāo)所在位置為中心縮放這樣更符合人的操作直覺。默認(rèn)的 QGraphicsView 縮放行為是以視圖中心為錨點(diǎn)的需要用 setTransformationAnchor(QGraphicsView::AnchorUnderMouse)。我在做滾動(dòng)縮放時(shí)額外做了對(duì)比度限制縮放比例 minScale0.1maxScale10.0。小于 0.1 時(shí)圖片占的像素太小標(biāo)注已經(jīng)失去意義大于 10.0 時(shí)像素被過度放大邊緣模糊。這個(gè)限制對(duì)標(biāo)注工具的用戶體驗(yàn)提升很大。4.4 拖拽標(biāo)注框的邊界檢查圖片標(biāo)注時(shí)允許用戶拖動(dòng)已畫好的矩形框但矩形框不能拖出圖片范圍。我在 AnnotationRectItem 的 itemChange 事件里捕獲 ItemPositionChange 變化一旦發(fā)現(xiàn)新位置越界就將位置反饋為舊位置。這個(gè)邏輯看似簡(jiǎn)單但寫錯(cuò)位置會(huì)導(dǎo)致矩形抖動(dòng)或者拖不動(dòng)的詭異問題。5. 環(huán)境配置與編譯運(yùn)行全流程這個(gè)項(xiàng)目用到的依賴只有 Qt 5.15.2 的 Widgets 模塊沒有任何第三方庫。但是 Qt 環(huán)境本身配置也有一坑要說明。5.1 minGW 編譯器與 Qt 版本匹配很多人下載 Qt 后編譯不過查了半天發(fā)現(xiàn)是環(huán)境變量沒配好。Qt 5.15 之后官方不再單獨(dú)提供離線 installer要用在線安裝器而且 5.15 的在線安裝包只提供商業(yè)或開源注冊(cè)版。這一步其實(shí)很多人都卡在這里不斷試錯(cuò)。其實(shí)有更省心的方案直接從 Qt 鏡像站下載 Qt Online Installer登錄 Qt 賬號(hào)后選擇 5.15.2 版本把 MinGW 8.1.0 64 位的那個(gè)選項(xiàng)勾上。不要把 Qt 的維護(hù)工具跟編譯器混為一談MinGW 編譯器是需要單獨(dú)勾選的組件。5.2 編譯 .pro 文件進(jìn)入源碼目錄終端執(zhí)行qmake mingw32-make如果沒報(bào)錯(cuò)直接得到可執(zhí)行文件。這時(shí)候最小依賴的運(yùn)行庫有三四個(gè) DLL部署起來比較麻煩推薦用 windeployqt 工具去掃描依賴它會(huì)把 Qt 運(yùn)行時(shí)依賴的 DLL 全部拷貝到 exe 同目錄。6. 常見問題與排查技巧實(shí)錄這個(gè)項(xiàng)目我在代碼調(diào)試時(shí)遇到了幾個(gè)典型問題都是花了不少時(shí)間踩坑才解決的列出來供大家參考。問題現(xiàn)象根本原因解決方法圖片加載后一片空白QGraphicsPixmapItem 未調(diào)用 setSceneRect 或者 pixmap 為 null檢查文件路徑是否正確用 QFile::exists 驗(yàn)證路徑后再 setPixmap標(biāo)注矩形框畫不出來沒有設(shè)置 item 的 flag或者畫筆顏色太淺導(dǎo)致看不見給 item 設(shè)置 setFlags 選中和移動(dòng)標(biāo)志畫筆顏色用飽和度較高的值畫矩形時(shí)造成 view 整體移動(dòng)滾輪事件或拖拽事件被 QGraphicsView 截獲設(shè)置 view 的 dragMode 為 RubberBandDrag同時(shí)確保 annotation 模式下忽略鼠標(biāo)移動(dòng)事件矩形框旋轉(zhuǎn)后位置錯(cuò)亂QGraphicsRectItem 的 rect() 和場(chǎng)景坐標(biāo)混用所有坐標(biāo)轉(zhuǎn)換統(tǒng)一用 mapToScene / mapFromScene不要混用導(dǎo)出 JSON 時(shí)中文亂碼控制臺(tái)輸出編碼不一致用 QString::toUtf8() 或 QFile 寫文件時(shí)指定編碼 UTF-86.1 中文路徑導(dǎo)致圖片加載失敗Windows 下 Qt 的 QFile 本身就是支持中文路徑的但如果你用了 ifstream 或者 C 標(biāo)準(zhǔn)庫去讀取文件遇到中文路徑就會(huì)失敗。這個(gè)項(xiàng)目里圖片加載全部走 QPixmap 和 QFile所以沒有這個(gè)問題。但如果把代碼移植到其他平臺(tái)或者用了第三方庫要格外注意路徑編碼。6.2 圖像縮略圖與真實(shí)圖片的比例如果你最后把導(dǎo)出的標(biāo)注框映射到縮略圖上必須使用同一套坐標(biāo)縮放比例不能直接拿原圖坐標(biāo)除以某個(gè)整數(shù)必須精確計(jì)算 scaleX thumbWidth / imageWidthscaleY thumbHeight / imageHeight然后分別乘以 x、y、width、height再取整。很多人在這一步偷懶導(dǎo)致框位偏大幾個(gè)像素但實(shí)際上像素級(jí)的偏差對(duì)模型訓(xùn)練影響不大不過做可視化檢查時(shí)很不美觀。7. 后續(xù)擴(kuò)展方向與項(xiàng)目總結(jié)這個(gè)標(biāo)注工具做到能用很簡(jiǎn)單做到好用卻有不少路要走。我把整理出來的擴(kuò)展方向和優(yōu)先級(jí)列一下。最值得優(yōu)先做的擴(kuò)展是標(biāo)注類別預(yù)設(shè)管理在實(shí)際標(biāo)注時(shí)類別通常有幾十種比如目標(biāo)檢測(cè)里的車輛、行人、交通燈等每個(gè)類別的顏色應(yīng)該保持固定并且能一鍵切換。我在當(dāng)前版本里只是把顏色隨機(jī)分配但標(biāo)注工具一旦需要多人協(xié)作就需要一個(gè)統(tǒng)一的類別配置文件。其次是標(biāo)注數(shù)據(jù)檢查功能標(biāo)注完成后應(yīng)該提供查看所有標(biāo)注框的列表視圖并支持一鍵定位到某個(gè)框、刪除誤標(biāo)注、修改類別等操作。這個(gè)和導(dǎo)出 JSON 的 schema 嚴(yán)格對(duì)應(yīng)。再進(jìn)一步是支持導(dǎo)入外部標(biāo)注格式比如 Pascal VOC 的 XML 格式、YOLO 的 txt 格式。這樣標(biāo)注工具就能無縫接入常用的訓(xùn)練流程。實(shí)際上如果只是給自己用支持一種格式就夠了但如果你需要開源出去多格式導(dǎo)入導(dǎo)出幾乎是標(biāo)配。最后還有一個(gè)我實(shí)測(cè)過的小技巧定期自動(dòng)保存。標(biāo)注是個(gè)耗時(shí)活如果程序中途崩潰所有標(biāo)注數(shù)據(jù)都會(huì)丟失。我后來給項(xiàng)目加了一個(gè) QTimer 自動(dòng)保存機(jī)制每 30 秒把當(dāng)前圖片的標(biāo)注數(shù)據(jù)寫入臨時(shí)備份文件這個(gè)改動(dòng)成本極低但實(shí)用價(jià)值極大。用 Qt 和 C 實(shí)現(xiàn)圖片標(biāo)注工具本質(zhì)上練習(xí)的是三件事C 對(duì)象設(shè)計(jì)能力、Qt 事件系統(tǒng)與 View/Scene 框架的理解、以及數(shù)據(jù)格式設(shè)計(jì)能力。這個(gè)項(xiàng)目做完本文還有配套的精品資源點(diǎn)擊獲取