關於OpenCV簡介
OpenCV是一個基於BSD許可(開源)發行的跨平台計算機視覺庫,可以運行在Linux、Windows、Android和Mac OS操作系統上。
它輕量級而且高效——由一系列 C 函數和少量 C++ 類構成,同時提供了Python、Ruby、MATLAB等語言的接口,實現了圖像處理和計算機視覺方面的很多通用算法。
OpenCV用C++語言編寫,它的主要接口也是C++語言,但是依然保留了大量的C語言接口。
在計算機視覺項目的開發中,OpenCV作為較大眾的開源庫,擁有了豐富的常用圖像處理函數庫,采用C/C++語言編寫,可以運行在Linux/Windows/Mac等操作系統上,能夠快速的實現一些圖像處理和識別的任務。
此外,OpenCV還提供了Java、python、cuda等的使用接口、機器學習的基礎算法調用,從而使得圖像處理和圖像分析變得更加易於上手,讓開發人員更多的精力花在算法的設計上。
一、OpenCV應用領域
1、計算機視覺領域方向
2、人機互動
3、物體識別
4、圖像分割
5、人臉識別
6、動作識別
7、運動跟蹤
8、機器人
9、運動分析
10、機器視覺
11、結構分析
12、汽車安全駕駛
二、計算機操作底層技術
圖像數據的操作: 分配、釋放、復制、設置和轉換。 圖像是視頻的輸入輸出I/O ,文件與攝像頭的輸入、圖像和視頻文件輸出)。
矩陣和向量的操作以及線性代數的算法程序:矩陣積、解方程、特征值以及奇異值等。
各種動態數據結構:列表、隊列、集合、樹、圖等。
基本的數字圖像處理:濾波、邊緣檢測、角點檢測、采樣與差值、色彩轉換、形態操作、直方圖、圖像金字塔等。
結構分析:連接部件、輪廓處理、距離變換、各自距計算、模板匹配、Hough變換、多邊形逼近、直線擬合、橢圓擬合、Delaunay 三角划分等。
攝像頭定標:發現與跟蹤定標模式、定標、基本矩陣估計、齊次矩陣估計、立體對應。
運動分析:光流、運動分割、跟蹤。
目標識別:特征法、隱馬爾可夫模型:HMM。
基本的GUI:圖像與視頻顯示、鍵盤和鼠標事件處理、滾動條。
圖像標注:線、二次曲線、多邊形、畫文字。
三、OpenCV的結構
當前的OpenCV也有兩個大版本,OpenCV2和OpenCV3。相比OpenCV2,OpenCV3提供了更強的功能和更多方便的特性。
不過考慮到和深度學習框架的兼容性,以及上手安裝的難度,這部分先以2為主進行介紹。
根據功能和需求的不同,OpenCV中的函數接口大體可以分為如下部分:
- core:核心模塊,主要包含了OpenCV中最基本的結構(矩陣,點線和形狀等),以及相關的基礎運算/操作。
- imgproc:圖像處理模塊,包含和圖像相關的基礎功能(濾波,梯度,改變大小等),以及一些衍生的高級功能(圖像分割,直方圖,形態分析和邊緣/直線提取等)。
- highgui:提供了用戶界面和文件讀取的基本函數,比如圖像顯示窗口的生成和控制,圖像/視頻文件的IO等。
如果不考慮視頻應用,以上三個就是最核心和常用的模塊了。針對視頻和一些特別的視覺應用,OpenCV也提供了強勁的支持:
- video:用於視頻分析的常用功能,比如光流法(Optical Flow)和目標跟蹤等。
- calib3d:三維重建,立體視覺和相機標定等的相關功能。
- features2d:二維特征相關的功能,主要是一些不受專利保護的,商業友好的特征點檢測和匹配等功能,比如ORB特征。
- object:目標檢測模塊,包含級聯分類和Latent SVM
- ml:機器學習算法模塊,包含一些視覺中最常用的傳統機器學習算法。
- flann:最近鄰算法庫,Fast Library for Approximate Nearest Neighbors,用於在多維空間進行聚類和檢索,經常和關鍵點匹配搭配使用。
- gpu:包含了一些gpu加速的接口,底層的加速是CUDA實現。
- photo:計算攝像學(Computational Photography)相關的接口,當然這只是個名字,其實只有圖像修復和降噪而已。
- stitching:圖像拼接模塊,有了它可以自己生成全景照片。
- nonfree:受到專利保護的一些算法,其實就是SIFT和SURF。
- contrib:一些實驗性質的算法,考慮在未來版本中加入的。
- legacy:字面是遺產,意思就是廢棄的一些接口,保留是考慮到向下兼容。
- ocl:利用OpenCL並行加速的一些接口。
- superres:超分辨率模塊,其實就是BTV-L1(Biliteral Total Variation – L1 regularization)算法
- viz:基礎的3D渲染模塊,其實底層就是著名的3D工具包VTK(Visualization Toolkit)。
從使用的角度來看,和OpenCV2相比,OpenCV3的主要變化是更多的功能和更細化的模塊划分。
四、安裝和使用OpenCV
直接pip安裝
pip install opencv-python
注意:
1.安裝的是opencv_python,但在導入的時候是import cv2。
2.OpenCV依賴一些庫,比如Numpy,先安裝上。
五、Python-OpenCV基礎
圖像的表示
前面章節已經提到過了單通道的灰度圖像在計算機中的表示,就是一個8位無符號整形的矩陣。在OpenCV的C++代碼中,表示圖像有個專門的結構叫做cv::Mat,不過在Python-OpenCV中,因為已經有了numpy這種強大的基礎工具,所以這個矩陣就用numpy的array表示。如果是多通道情況,最常見的就是紅綠藍(RGB)三通道,則第一個維度是高度,第二個維度是高度,第三個維度是通道,比如圖6-1a是一幅3×3圖像在計算機中表示的例子:
圖6-1 RGB圖像在計算機中表示的例子
圖6-1中,右上角的矩陣里每個元素都是一個3維數組,分別代表這個像素上的三個通道的值。最常見的RGB通道中,第一個元素就是紅色(Red)的值,第二個元素是綠色(Green)的值,第三個元素是藍色(Blue),最終得到的圖像如6-1a所示。RGB是最常見的情況,然而在OpenCV中,默認的圖像的表示確實反過來的,也就是BGR,得到的圖像是6-1b。可以看到,前兩行的顏色順序都交換了,最后一行是三個通道等值的灰度圖,所以沒有影響。至於OpenCV為什么不是人民群眾喜聞樂見的RGB,這是歷史遺留問題,在OpenCV剛開始研發的年代,BGR是相機設備廠商的主流表示方法,雖然后來RGB成了主流和默認,但是這個底層的順序卻保留下來了,事實上Windows下的最常見格式之一bmp,底層字節的存儲順序還是BGR。OpenCV的這個特殊之處還是需要注意的,比如在Python中,圖像都是用numpy的array表示,但是同樣的array在OpenCV中的顯示效果和matplotlib中的顯示效果就會不一樣。下面的簡單代碼就可以生成兩種表示方式下,圖6-1中矩陣的對應的圖像,生成圖像后,放大看就能體會到區別:
import numpy as np import cv2 import matplotlib.pyplot as plt # 圖6-1中的矩陣 img = np.array([ [[255, 0, 0], [0, 255, 0], [0, 0, 255]], [[255, 255, 0], [255, 0, 255], [0, 255, 255]], [[255, 255, 255], [128, 128, 128], [0, 0, 0]], ], dtype=np.uint8) # 用matplotlib存儲 plt.imsave('img_pyplot.jpg', img) # 用OpenCV存儲 cv2.imwrite('img_cv2.jpg', img)
不管是RGB還是BGR,都是高度×寬度×通道數,H×W×C的表達方式,而在深度學習中,因為要對不同通道應用卷積,所以用的是另一種方式:C×H×W,就是把每個通道都單獨表達成一個二維矩陣,如圖6-1c所示。
基本圖像處理
存取圖像
讀圖像用cv2.imread(),可以按照不同模式讀取,一般最常用到的是讀取單通道灰度圖,或者直接默認讀取多通道。存圖像用cv2.imwrite(),注意存的時候是沒有單通道這一說的,根據保存文件名的后綴和當前的array維度,OpenCV自動判斷存的通道,另外壓縮格式還可以指定存儲質量,來看代碼例子:
import cv2 # 讀取一張400x600分辨率的圖像 color_img = cv2.imread('test_400x600.jpg') print(color_img.shape) # 直接讀取單通道 gray_img = cv2.imread('test_400x600.jpg', cv2.IMREAD_GRAYSCALE) print(gray_img.shape) # 把單通道圖片保存后,再讀取,仍然是3通道,相當於把單通道值復制到3個通道保存 cv2.imwrite('test_grayscale.jpg', gray_img) reload_grayscale = cv2.imread('test_grayscale.jpg') print(reload_grayscale.shape) # cv2.IMWRITE_JPEG_QUALITY指定jpg質量,范圍0到100,默認95,越高畫質越好,文件越大 cv2.imwrite('test_imwrite.jpg', color_img, (cv2.IMWRITE_JPEG_QUALITY, 80)) # cv2.IMWRITE_PNG_COMPRESSION指定png質量,范圍0到9,默認3,越高文件越小,畫質越差 cv2.imwrite('test_imwrite.png', color_img, (cv2.IMWRITE_PNG_COMPRESSION, 5))
縮放,裁剪和補邊
縮放通過cv2.resize()實現,裁剪則是利用array自身的下標截取實現,此外OpenCV還可以給圖像補邊,這樣能對一幅圖像的形狀和感興趣區域實現各種操作。下面的例子中讀取一幅400×600分辨率的圖片,並執行一些基礎的操作:
import cv2 # 讀取一張四川大錄古藏寨的照片 img = cv2.imread('tiger_tibet_village.jpg') # 縮放成200x200的方形圖像 img_200x200 = cv2.resize(img, (200, 200)) # 不直接指定縮放后大小,通過fx和fy指定縮放比例,0.5則長寬都為原來一半 # 等效於img_200x300 = cv2.resize(img, (300, 200)),注意指定大小的格式是(寬度,高度) # 插值方法默認是cv2.INTER_LINEAR,這里指定為最近鄰插值 img_200x300 = cv2.resize(img, (0, 0), fx=0.5, fy=0.5, interpolation=cv2.INTER_NEAREST) # 在上張圖片的基礎上,上下各貼50像素的黑邊,生成300x300的圖像 img_300x300 = cv2.copyMakeBorder(img, 50, 50, 0, 0, cv2.BORDER_CONSTANT, value=(0, 0, 0)) # 對照片中樹的部分進行剪裁 patch_tree = img[20:150, -180:-50] cv2.imwrite('cropped_tree.jpg', patch_tree) cv2.imwrite('resized_200x200.jpg', img_200x200) cv2.imwrite('resized_200x300.jpg', img_200x300) cv2.imwrite('bordered_300x300.jpg', img_300x300)
這些處理的效果見圖6-2。
色調,明暗,直方圖和Gamma曲線
除了區域,圖像本身的屬性操作也非常多,比如可以通過HSV空間對色調和明暗進行調節。HSV空間是由美國的圖形學專家A. R. Smith提出的一種顏色空間,HSV分別是色調(Hue),飽和度(Saturation)和明度(Value)。在HSV空間中進行調節就避免了直接在RGB空間中調節是還需要考慮三個通道的相關性。OpenCV中H的取值是[0, 180),其他兩個通道的取值都是[0, 256),下面例子接着上面例子代碼,通過HSV空間對圖像進行調整:
# 通過cv2.cvtColor把圖像從BGR轉換到HSV img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # H空間中,綠色比黃色的值高一點,所以給每個像素+15,黃色的樹葉就會變綠 turn_green_hsv = img_hsv.copy() turn_green_hsv[:, :, 0] = (turn_green_hsv[:, :, 0]+15) % 180 turn_green_img = cv2.cvtColor(turn_green_hsv, cv2.COLOR_HSV2BGR) cv2.imwrite('turn_green.jpg', turn_green_img) # 減小飽和度會讓圖像損失鮮艷,變得更灰 colorless_hsv = img_hsv.copy() colorless_hsv[:, :, 1] = 0.5 * colorless_hsv[:, :, 1] colorless_img = cv2.cvtColor(colorless_hsv, cv2.COLOR_HSV2BGR) cv2.imwrite('colorless.jpg', colorless_img) # 減小明度為原來一半 darker_hsv = img_hsv.copy() darker_hsv[:, :, 2] = 0.5 * darker_hsv[:, :, 2] darker_img = cv2.cvtColor(darker_hsv, cv2.COLOR_HSV2BGR) cv2.imwrite('darker.jpg', darker_img)
無論是HSV還是RGB,我們都較難一眼就對像素中值的分布有細致的了解,這時候就需要直方圖。如果直方圖中的成分過於靠近0或者255,可能就出現了暗部細節不足或者亮部細節丟失的情況。比如圖6-2中,背景里的暗部細節是非常弱的。這個時候,一個常用方法是考慮用Gamma變換來提升暗部細節。Gamma變換是矯正相機直接成像和人眼感受圖像差別的一種常用手段,簡單來說就是通過非線性變換讓圖像從對曝光強度的線性響應變得更接近人眼感受到的響應。具體的定義和實現,還是接着上面代碼中讀取的圖片,執行計算直方圖和Gamma變換的代碼如下:
import numpy as np # 分通道計算每個通道的直方圖 hist_b = cv2.calcHist([img], [0], None, [256], [0, 256]) hist_g = cv2.calcHist([img], [1], None, [256], [0, 256]) hist_r = cv2.calcHist([img], [2], None, [256], [0, 256]) # 定義Gamma矯正的函數 def gamma_trans(img, gamma): # 具體做法是先歸一化到1,然后gamma作為指數值求出新的像素值再還原 gamma_table = [np.power(x/255.0, gamma)*255.0 for x in range(256)] gamma_table = np.round(np.array(gamma_table)).astype(np.uint8) # 實現這個映射用的是OpenCV的查表函數 return cv2.LUT(img, gamma_table) # 執行Gamma矯正,小於1的值讓暗部細節大量提升,同時亮部細節少量提升 img_corrected = gamma_trans(img, 0.5) cv2.imwrite('gamma_corrected.jpg', img_corrected) # 分通道計算Gamma矯正后的直方圖 hist_b_corrected = cv2.calcHist([img_corrected], [0], None, [256], [0, 256]) hist_g_corrected = cv2.calcHist([img_corrected], [1], None, [256], [0, 256]) hist_r_corrected = cv2.calcHist([img_corrected], [2], None, [256], [0, 256]) # 將直方圖進行可視化 import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D fig = plt.figure() pix_hists = [ [hist_b, hist_g, hist_r], [hist_b_corrected, hist_g_corrected, hist_r_corrected] ] pix_vals = range(256) for sub_plt, pix_hist in zip([121, 122], pix_hists): ax = fig.add_subplot(sub_plt, projection='3d') for c, z, channel_hist in zip(['b', 'g', 'r'], [20, 10, 0], pix_hist): cs = [c] * 256 ax.bar(pix_vals, channel_hist, zs=z, zdir='y', color=cs, alpha=0.618, edgecolor='none', lw=0) ax.set_xlabel('Pixel Values') ax.set_xlim([0, 256]) ax.set_ylabel('Channels') ax.set_zlabel('Counts') plt.show()
上面三段代碼的結果統一放在下圖中:
可以看到,Gamma變換后的暗部細節比起原圖清楚了很多,並且從直方圖來看,像素值也從集中在0附近變得散開了一些。
6.2.3 圖像的仿射變換
圖像的仿射變換涉及到圖像的形狀位置角度的變化,是深度學習預處理中常到的功能,在此簡單回顧一下。仿射變換具體到圖像中的應用,主要是對圖像的縮放,旋轉,剪切,翻轉和平移的組合。在OpenCV中,仿射變換的矩陣是一個2×3的矩陣,其中左邊的2×2子矩陣是線性變換矩陣,右邊的2×1的兩項是平移項:
對於圖像上的任一位置(x,y),仿射變換執行的是如下的操作:
需要注意的是,對於圖像而言,寬度方向是x,高度方向是y,坐標的順序和圖像像素對應下標一致。所以原點的位置不是左下角而是右上角,y的方向也不是向上,而是向下。在OpenCV中實現仿射變換是通過仿射變換矩陣和cv2.warpAffine()這個函數,還是通過代碼來理解一下,例子中圖片的分辨率為600×400:
import cv2 import numpy as np # 讀取一張斯里蘭卡拍攝的大象照片 img = cv2.imread('lanka_safari.jpg') # 沿着橫縱軸放大1.6倍,然后平移(-150,-240),最后沿原圖大小截取,等效於裁剪並放大 M_crop_elephant = np.array([ [1.6, 0, -150], [0, 1.6, -240] ], dtype=np.float32) img_elephant = cv2.warpAffine(img, M_crop_elephant, (400, 600)) cv2.imwrite('lanka_elephant.jpg', img_elephant) # x軸的剪切變換,角度15° theta = 15 * np.pi / 180 M_shear = np.array([ [1, np.tan(theta), 0], [0, 1, 0] ], dtype=np.float32) img_sheared = cv2.warpAffine(img, M_shear, (400, 600)) cv2.imwrite('lanka_safari_sheared.jpg', img_sheared) # 順時針旋轉,角度15° M_rotate = np.array([ [np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0] ], dtype=np.float32) img_rotated = cv2.warpAffine(img, M_rotate, (400, 600)) cv2.imwrite('lanka_safari_rotated.jpg', img_rotated) # 某種變換,具體旋轉+縮放+旋轉組合可以通過SVD分解理解 M = np.array([ [1, 1.5, -400], [0.5, 2, -100] ], dtype=np.float32) img_transformed = cv2.warpAffine(img, M, (400, 600)) cv2.imwrite('lanka_safari_transformed.jpg', img_transformed)
代碼實現的操作示意在下圖中:
6.2.4 基本繪圖
OpenCV提供了各種繪圖的函數,可以在畫面上繪制線段,圓,矩形和多邊形等,還可以在圖像上指定位置打印文字,比如下面例子:
import numpy as np import cv2 # 定義一塊寬600,高400的畫布,初始化為白色 canvas = np.zeros((400, 600, 3), dtype=np.uint8) + 255 # 畫一條縱向的正中央的黑色分界線 cv2.line(canvas, (300, 0), (300, 399), (0, 0, 0), 2) # 畫一條右半部份畫面以150為界的橫向分界線 cv2.line(canvas, (300, 149), (599, 149), (0, 0, 0), 2) # 左半部分的右下角畫個紅色的圓 cv2.circle(canvas, (200, 300), 75, (0, 0, 255), 5) # 左半部分的左下角畫個藍色的矩形 cv2.rectangle(canvas, (20, 240), (100, 360), (255, 0, 0), thickness=3) # 定義兩個三角形,並執行內部綠色填充 triangles = np.array([ [(200, 240), (145, 333), (255, 333)], [(60, 180), (20, 237), (100, 237)]]) cv2.fillPoly(canvas, triangles, (0, 255, 0)) # 畫一個黃色五角星 # 第一步通過旋轉角度的辦法求出五個頂點 phi = 4 * np.pi / 5 rotations = [[[np.cos(i * phi), -np.sin(i * phi)], [i * np.sin(phi), np.cos(i * phi)]] for i in range(1, 5)] pentagram = np.array([[[[0, -1]] + [np.dot(m, (0, -1)) for m in rotations]]], dtype=np.float) # 定義縮放倍數和平移向量把五角星畫在左半部分畫面的上方 pentagram = np.round(pentagram * 80 + np.array([160, 120])).astype(np.int) # 將5個頂點作為多邊形頂點連線,得到五角星 cv2.polylines(canvas, pentagram, True, (0, 255, 255), 9) # 按像素為間隔從左至右在畫面右半部份的上方畫出HSV空間的色調連續變化 for x in range(302, 600): color_pixel = np.array([[[round(180*float(x-302)/298), 255, 255]]], dtype=np.uint8) line_color = [int(c) for c in cv2.cvtColor(color_pixel, cv2.COLOR_HSV2BGR)[0][0]] cv2.line(canvas, (x, 0), (x, 147), line_color) # 如果定義圓的線寬大於半斤,則等效於畫圓點,隨機在畫面右下角的框內生成坐標 np.random.seed(42) n_pts = 30 pts_x = np.random.randint(310, 590, n_pts) pts_y = np.random.randint(160, 390, n_pts) pts = zip(pts_x, pts_y) # 畫出每個點,顏色隨機 for pt in pts: pt_color = [int(c) for c in np.random.randint(0, 255, 3)] cv2.circle(canvas, pt, 3, pt_color, 5) # 在左半部分最上方打印文字 cv2.putText(canvas, 'Python-OpenCV Drawing Example', (5, 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1) cv2.imshow('Example of basic drawing functions', canvas) cv2.waitKey()
執行這段代碼得到如下的圖像:
6.2.4 視頻功能
視頻中最常用的就是從視頻設備采集圖片或者視頻,或者讀取視頻文件並從中采樣。所以比較重要的也是兩個模塊,一個是VideoCapture,用於獲取相機設備並捕獲圖像和視頻,或是從文件中捕獲。還有一個VideoWriter,用於生成視頻。還是來看例子理解這兩個功能的用法,首先是一個制作延時攝影視頻的小例子:
import cv2 import time interval = 60 # 捕獲圖像的間隔,單位:秒 num_frames = 500 # 捕獲圖像的總幀數 out_fps = 24 # 輸出文件的幀率 # VideoCapture(0)表示打開默認的相機 cap = cv2.VideoCapture(0) # 獲取捕獲的分辨率 size =(int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) # 設置要保存視頻的編碼,分辨率和幀率 video = cv2.VideoWriter( "time_lapse.avi", cv2.VideoWriter_fourcc('M','P','4','2'), out_fps, size ) # 對於一些低畫質的攝像頭,前面的幀可能不穩定,略過 for i in range(42): cap.read() # 開始捕獲,通過read()函數獲取捕獲的幀 try: for i in range(num_frames): _, frame = cap.read() video.write(frame) # 如果希望把每一幀也存成文件,比如制作GIF,則取消下面的注釋 # filename = '{:0>6d}.png'.format(i) # cv2.imwrite(filename, frame) print('Frame {} is captured.'.format(i)) time.sleep(interval) except KeyboardInterrupt: # 提前停止捕獲 print('Stopped! {}/{} frames captured!'.format(i, num_frames)) # 釋放資源並寫入視頻文件 video.release() cap.release()
這個例子實現了延時攝影的功能,把程序打開並將攝像頭對准一些緩慢變化的畫面,比如桌上緩慢蒸發的水,或者正在生長的小草,就能制作出有趣的延時攝影作品。比如下面這個鏈接中的圖片就是用這段程序生成的:
http://images.cnitblog.com/blog2015/609274/201503/251904209276278.gif
程序的結構非常清晰簡單,注釋里也寫清楚了每一步,所以流程就不解釋了。需要提一下的有兩點:一個是VideoWriter中的一個函數cv2.VideoWriter_fourcc()。這個函數指定了視頻編碼的格式,比如例子中用的是MP42,也就是MPEG-4,更多編碼方式可以在下面的地址查詢:
還有一個是KeyboardInterrupt,這是一個常用的異常,用來獲取用戶Ctrl+C的中止,捕獲這個異常后直接結束循環並釋放VideoCapture和VideoWriter的資源,使已經捕獲好的部分視頻可以順利生成。
從視頻中截取幀也是處理視頻時常見的任務,下面代碼實現的是遍歷一個指定文件夾下的所有視頻並按照指定的間隔進行截屏並保存:
import cv2 import os import sys # 第一個輸入參數是包含視頻片段的路徑 input_path = sys.argv[1] # 第二個輸入參數是設定每隔多少幀截取一幀 frame_interval = int(sys.argv[2]) # 列出文件夾下所有的視頻文件 filenames = os.listdir(input_path) # 獲取文件夾名稱 video_prefix = input_path.split(os.sep)[-1] # 建立一個新的文件夾,名稱為原文件夾名稱后加上_frames frame_path = '{}_frames'.format(input_path) if not os.path.exists(frame_path): os.mkdir(frame_path) # 初始化一個VideoCapture對象 cap = cv2.VideoCapture() # 遍歷所有文件 for filename in filenames: filepath = os.sep.join([input_path, filename]) # VideoCapture::open函數可以從文件獲取視頻 cap.open(filepath) # 獲取視頻幀數 n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 同樣為了避免視頻頭幾幀質量低下,黑屏或者無關等 for i in range(42): cap.read() for i in range(n_frames): ret, frame = cap.read() # 每隔frame_interval幀進行一次截屏操作 if i % frame_interval == 0: imagename = '{}_{}_{:0>6d}.jpg'.format(video_prefix, filename.split('.')[0], i) imagepath = os.sep.join([frame_path, imagename]) print('exported {}!'.format(imagepath)) cv2.imwrite(imagepath, frame) # 執行結束釋放資源 cap.release()
用OpenCV實現數據增加小工具
到目前我們已經熟悉了numpy中的隨機模塊,多進程調用和OpenCV的基本操作,基於這些基礎,本節將從思路到代碼一步步實現一個最基本的數據增加小工具。
第三章和第四章都提到過數據增加(data augmentation),作為一種深度學習中的常用手段,數據增加對模型的泛化性和准確性都有幫助。數據增加的具體使用方式一般有兩種,一種是實時增加,比如在Caffe中加入數據擾動層,每次圖像都先經過擾動操作,再去訓練,這樣訓練經過幾代(epoch)之后,就等效於數據增加。還有一種是更加直接簡單一些的,就是在訓練之前就通過圖像處理手段對數據樣本進行擾動和增加,也就是本節要實現的。
這個例子中將包含三種基本類型的擾動:隨機裁剪,隨機旋轉和隨機顏色/明暗。
隨機裁剪
AlexNet中已經講過了隨機裁剪的基本思路,我們的小例子中打算更進一步:在裁剪的時候考慮圖像寬高比的擾動。在絕大多數用於分類的圖片中,樣本進入網絡前都是要變為統一大小,所以寬高比擾動相當於對物體的橫向和縱向進行了縮放,這樣除了物體的位置擾動,又多出了一項擾動。只要變化范圍控制合適,目標物體始終在畫面內,這種擾動是有助於提升泛化性能的。實現這種裁剪的思路如下圖所示:
圖中最左邊是一幅需要剪裁的畫面,首先根據這幅畫面我們可以算出一個寬高比w/h。然后設定一個小的擾動范圍δ和要裁剪的畫面占原畫面的比例β,從-到
之間按均勻采樣,獲取一個隨機數
作為裁剪后畫面的寬高比擾動的比例,則裁剪后畫面的寬和高分別為:
想象一下先把這個寬為w’,高為h’的區域置於原畫面的右下角,則這個區域的左上角和原畫面的左上角框出的小區域,如圖中的虛線框所示,就是裁剪后區域左上角可以取值的范圍。所以在這個區域內隨機采一點作為裁剪區域的左上角,就實現了如圖中位置隨機,且寬高比也隨機的裁剪。
隨機旋轉
前面講到過的旋轉比起來,做數據增加時,一般希望旋轉是沿着畫面的中心。這樣除了要知道旋轉角度,還得計算平移的量才能讓仿射變換的效果等效於旋轉軸在畫面中心,好在OpenCV中有現成的函數cv2.getRotationMatrix2D()可以使用。這個函數的第一個參數是旋轉中心,第二個參數是逆時針旋轉角度,第三個參數是縮放倍數,對於只是旋轉的情況下這個值是1,返回值就是做仿射變換的矩陣。
直接用這個函數並接着使用cv2.warpAffine()會有一個潛在的問題,就是旋轉之后會出現黑邊。如果要旋轉后的畫面不包含黑邊,就得沿着原來畫面的輪廓做個內接矩形,該矩形的寬高比和原畫面相同,如下圖所示:
在圖中,可以看到,限制內接矩形大小的主要是原畫面更靠近中心的那條邊,也就是圖中比較長的一條邊AB。因此我們只要沿着中心O和內接矩形的頂點方向的直線,求出和AB的交點P,就得到了內接矩形的大小。先來看長邊的方程,考慮之前畫面和橫軸相交的點,經過角度-θ旋轉后,到了圖中的Q點所在:
因為長邊所在直線過Q點,且斜率為1/tan(θ),所以有:
這時候考慮OP這條直線:
把這個公式帶入再前邊一個公式,求解可以得到:
注意到在這個問題中,每個象限和相鄰象限都是軸對稱的,而且旋轉角度對剪裁寬度和長度的影響是周期(T=π)變化,再加上我們關心的其實並不是四個點的位置,而是旋轉后要截取的矩形的寬w’和高h’,所以復雜的分區間情況也簡化了,首先對於旋轉角度,因為周期為π,所以都可以化到0到π之間,然后因為對稱性,進一步有:
於是對於0到π/2之間的θ,有:
當然需要注意的是,對於寬高比非常大或者非常小的圖片,旋轉后如果裁剪往往得到的畫面是非常小的一部分,甚至不包含目標物體。所以是否需要旋轉,以及是否需要裁剪,如果裁剪角度多少合適,都要視情況而定。
隨機顏色和明暗
比起AlexNet論文里在PCA之后的主成分上做擾動的方法,本書用來實現隨機的顏色以及明暗的方法相對簡單很多,就是給HSV空間的每個通道,分別加上一個微小的擾動。其中對於色調,從-到
之間按均勻采樣,獲取一個隨機數
作為要擾動的值,然后新的像素值x’為原始像素值x +
;對於其他兩個空間則是新像素值x’為原始像素值x的(1+
)倍,從而實現色調,飽和度和明暗度的擾動。
因為明暗度並不會對圖像的直方圖相對分布產生大的影響,所以在HSV擾動基礎上,考慮再加入一個Gamma擾動,方法是設定一個大於1的Gamma值的上限γ,因為這個值通常會和1是一個量級,再用均勻采樣的近似未必合適,所以從-logγ到logγ之間均勻采樣一個值α,然后用
作為Gamma值進行變換。
多進程調用加速處理
做數據增加時如果樣本量本身就不小,則處理起來可能會很耗費時間,所以可以考慮利用多進程並行處理。比如我們的例子中,設定使用場景是輸入一個文件夾路徑,該文件夾下包含了所有原始的數據樣本。用戶指定輸出的文件夾和打算增加圖片的總量。執行程序的時候,通過os.listdir()獲取所有文件的路徑,然后按照上一章講過的多進程平均划分樣本的辦法,把文件盡可能均勻地分給不同進程,進行處理。
圖片數據增加小工具
按照前面4個部分的思路和方法,這節來實現這么一個圖片數據增加小工具,首先對於一些基礎的操作,我們定義在一個叫做image_augmentation.py的文件里:
import numpy as np import cv2 ''' 定義裁剪函數,四個參數分別是: 左上角橫坐標x0 左上角縱坐標y0 裁剪寬度w 裁剪高度h ''' crop_image = lambda img, x0, y0, w, h: img[y0:y0+h, x0:x0+w] ''' 隨機裁剪 area_ratio為裁剪畫面占原畫面的比例 hw_vari是擾動占原高寬比的比例范圍 ''' def random_crop(img, area_ratio, hw_vari): h, w = img.shape[:2] hw_delta = np.random.uniform(-hw_vari, hw_vari) hw_mult = 1 + hw_delta # 下標進行裁剪,寬高必須是正整數 w_crop = int(round(w*np.sqrt(area_ratio*hw_mult))) # 裁剪寬度不可超過原圖可裁剪寬度 if w_crop > w: w_crop = w h_crop = int(round(h*np.sqrt(area_ratio/hw_mult))) if h_crop > h: h_crop = h # 隨機生成左上角的位置 x0 = np.random.randint(0, w-w_crop+1) y0 = np.random.randint(0, h-h_crop+1) return crop_image(img, x0, y0, w_crop, h_crop) ''' 定義旋轉函數: angle是逆時針旋轉的角度 crop是個布爾值,表明是否要裁剪去除黑邊 ''' def rotate_image(img, angle, crop): h, w = img.shape[:2] # 旋轉角度的周期是360° angle %= 360 # 用OpenCV內置函數計算仿射矩陣 M_rotate = cv2.getRotationMatrix2D((w/2, h/2), angle, 1) # 得到旋轉后的圖像 img_rotated = cv2.warpAffine(img, M_rotate, (w, h)) # 如果需要裁剪去除黑邊 if crop: # 對於裁剪角度的等效周期是180° angle_crop = angle % 180 # 並且關於90°對稱 if angle_crop > 90: angle_crop = 180 - angle_crop # 轉化角度為弧度 theta = angle_crop * np.pi / 180.0 # 計算高寬比 hw_ratio = float(h) / float(w) # 計算裁剪邊長系數的分子項 tan_theta = np.tan(theta) numerator = np.cos(theta) + np.sin(theta) * tan_theta # 計算分母項中和寬高比相關的項 r = hw_ratio if h > w else 1 / hw_ratio # 計算分母項 denominator = r * tan_theta + 1 # 計算最終的邊長系數 crop_mult = numerator / denominator # 得到裁剪區域 w_crop = int(round(crop_mult*w)) h_crop = int(round(crop_mult*h)) x0 = int((w-w_crop)/2) y0 = int((h-h_crop)/2) img_rotated = crop_image(img_rotated, x0, y0, w_crop, h_crop) return img_rotated ''' 隨機旋轉 angle_vari是旋轉角度的范圍[-angle_vari, angle_vari) p_crop是要進行去黑邊裁剪的比例 ''' def random_rotate(img, angle_vari, p_crop): angle = np.random.uniform(-angle_vari, angle_vari) crop = False if np.random.random() > p_crop else True return rotate_image(img, angle, crop) ''' 定義hsv變換函數: hue_delta是色調變化比例 sat_delta是飽和度變化比例 val_delta是明度變化比例 ''' def hsv_transform(img, hue_delta, sat_mult, val_mult): img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float) img_hsv[:, :, 0] = (img_hsv[:, :, 0] + hue_delta) % 180 img_hsv[:, :, 1] *= sat_mult img_hsv[:, :, 2] *= val_mult img_hsv[img_hsv > 255] = 255 return cv2.cvtColor(np.round(img_hsv).astype(np.uint8), cv2.COLOR_HSV2BGR) ''' 隨機hsv變換 hue_vari是色調變化比例的范圍 sat_vari是飽和度變化比例的范圍 val_vari是明度變化比例的范圍 ''' def random_hsv_transform(img, hue_vari, sat_vari, val_vari): hue_delta = np.random.randint(-hue_vari, hue_vari) sat_mult = 1 + np.random.uniform(-sat_vari, sat_vari) val_mult = 1 + np.random.uniform(-val_vari, val_vari) return hsv_transform(img, hue_delta, sat_mult, val_mult) ''' 定義gamma變換函數: gamma就是Gamma ''' def gamma_transform(img, gamma): gamma_table = [np.power(x / 255.0, gamma) * 255.0 for x in range(256)] gamma_table = np.round(np.array(gamma_table)).astype(np.uint8) return cv2.LUT(img, gamma_table) ''' 隨機gamma變換 gamma_vari是Gamma變化的范圍[1/gamma_vari, gamma_vari) ''' def random_gamma_transform(img, gamma_vari): log_gamma_vari = np.log(gamma_vari) alpha = np.random.uniform(-log_gamma_vari, log_gamma_vari) gamma = np.exp(alpha) return gamma_transform(img, gamma)
調用這些函數需要通過一個主程序。這個主程序里首先定義三個子模塊,定義一個函數parse_arg()通過Python的argparse模塊定義了各種輸入參數和默認值。需要注意的是這里用argparse來輸入所有參數是因為參數總量並不是特別多,如果增加了更多的擾動方法,更合適的參數輸入方式可能是通過一個配置文件。然后定義一個生成待處理圖像列表的函數generate_image_list(),根據輸入中要增加圖片的數量和並行進程的數目盡可能均勻地為每個進程生成了需要處理的任務列表。執行隨機擾動的代碼定義在augment_images()中,這個函數是每個進程內進行實際處理的函數,執行順序是鏡像裁剪
旋轉
HSV
Gamma。需要注意的是鏡像
裁剪,因為只是個演示例子,這未必是一個合適的順序。最后定義一個main函數進行調用,代碼如下:
import os import argparse import random import math from multiprocessing import Process from multiprocessing import cpu_count import cv2 # 導入image_augmentation.py為一個可調用模塊 import image_augmentation as ia # 利用Python的argparse模塊讀取輸入輸出和各種擾動參數 def parse_args(): parser = argparse.ArgumentParser( description='A Simple Image Data Augmentation Tool', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('input_dir', help='Directory containing images') parser.add_argument('output_dir', help='Directory for augmented images') parser.add_argument('num', help='Number of images to be augmented', type=int) parser.add_argument('--num_procs', help='Number of processes for paralleled augmentation', type=int, default=cpu_count()) parser.add_argument('--p_mirror', help='Ratio to mirror an image', type=float, default=0.5) parser.add_argument('--p_crop', help='Ratio to randomly crop an image', type=float, default=1.0) parser.add_argument('--crop_size', help='The ratio of cropped image size to original image size, in area', type=float, default=0.8) parser.add_argument('--crop_hw_vari', help='Variation of h/w ratio', type=float, default=0.1) parser.add_argument('--p_rotate', help='Ratio to randomly rotate an image', type=float, default=1.0) parser.add_argument('--p_rotate_crop', help='Ratio to crop out the empty part in a rotated image', type=float, default=1.0) parser.add_argument('--rotate_angle_vari', help='Variation range of rotate angle', type=float, default=10.0) parser.add_argument('--p_hsv', help='Ratio to randomly change gamma of an image', type=float, default=1.0) parser.add_argument('--hue_vari', help='Variation of hue', type=int, default=10) parser.add_argument('--sat_vari', help='Variation of saturation', type=float, default=0.1) parser.add_argument('--val_vari', help='Variation of value', type=float, default=0.1) parser.add_argument('--p_gamma', help='Ratio to randomly change gamma of an image', type=float, default=1.0) parser.add_argument('--gamma_vari', help='Variation of gamma', type=float, default=2.0) args = parser.parse_args() args.input_dir = args.input_dir.rstrip('/') args.output_dir = args.output_dir.rstrip('/') return args ''' 根據進程數和要增加的目標圖片數, 生成每個進程要處理的文件列表和每個文件要增加的數目 ''' def generate_image_list(args): # 獲取所有文件名和文件總數 filenames = os.listdir(args.input_dir) num_imgs = len(filenames) # 計算平均處理的數目並向下取整 num_ave_aug = int(math.floor(args.num/num_imgs)) # 剩下的部分不足平均分配到每一個文件,所以做成一個隨機幸運列表 # 對於幸運的文件就多增加一個,湊夠指定的數目 rem = args.num - num_ave_aug*num_imgs lucky_seq = [True]*rem + [False]*(num_imgs-rem) random.shuffle(lucky_seq) # 根據平均分配和幸運表策略, # 生成每個文件的全路徑和對應要增加的數目並放到一個list里 img_list = [ (os.sep.join([args.input_dir, filename]), num_ave_aug+1 if lucky else num_ave_aug) for filename, lucky in zip(filenames, lucky_seq) ] # 文件可能大小不一,處理時間也不一樣, # 所以隨機打亂,盡可能保證處理時間均勻 random.shuffle(img_list) # 生成每個進程的文件列表, # 盡可能均勻地划分每個進程要處理的數目 length = float(num_imgs) / float(args.num_procs) indices = [int(round(i * length)) for i in range(args.num_procs + 1)] return [img_list[indices[i]:indices[i + 1]] for i in range(args.num_procs)] # 每個進程內調用圖像處理函數進行擾動的函數 def augment_images(filelist, args): # 遍歷所有列表內的文件 for filepath, n in filelist: img = cv2.imread(filepath) filename = filepath.split(os.sep)[-1] dot_pos = filename.rfind('.') # 獲取文件名和后綴名 imgname = filename[:dot_pos] ext = filename[dot_pos:] print('Augmenting {} ...'.format(filename)) for i in range(n): img_varied = img.copy() # 擾動后文件名的前綴 varied_imgname = '{}_{:0>3d}_'.format(imgname, i) # 按照比例隨機對圖像進行鏡像 if random.random() < args.p_mirror: # 利用numpy.fliplr(img_varied)也能實現 img_varied = cv2.flip(img_varied, 1) varied_imgname += 'm' # 按照比例隨機對圖像進行裁剪 if random.random() < args.p_crop: img_varied = ia.random_crop( img_varied, args.crop_size, args.crop_hw_vari) varied_imgname += 'c' # 按照比例隨機對圖像進行旋轉 if random.random() < args.p_rotate: img_varied = ia.random_rotate( img_varied, args.rotate_angle_vari, args.p_rotate_crop) varied_imgname += 'r' # 按照比例隨機對圖像進行HSV擾動 if random.random() < args.p_hsv: img_varied = ia.random_hsv_transform( img_varied, args.hue_vari, args.sat_vari, args.val_vari) varied_imgname += 'h' # 按照比例隨機對圖像進行Gamma擾動 if random.random() < args.p_gamma: img_varied = ia.random_gamma_transform( img_varied, args.gamma_vari) varied_imgname += 'g' # 生成擾動后的文件名並保存在指定的路徑 output_filepath = os.sep.join([ args.output_dir, '{}{}'.format(varied_imgname, ext)]) cv2.imwrite(output_filepath, img_varied) # 主函數 def main(): # 獲取輸入輸出和變換選項 args = parse_args() params_str = str(args)[10:-1] # 如果輸出文件夾不存在,則建立文件夾 if not os.path.exists(args.output_dir): os.mkdir(args.output_dir) print('Starting image data augmentation for {}\n' 'with\n{}\n'.format(args.input_dir, params_str)) # 生成每個進程要處理的列表 sublists = generate_image_list(args) # 創建進程 processes = [Process(target=augment_images, args=(x, args, )) for x in sublists] # 並行多進程處理 for p in processes: p.start() for p in processes: p.join() print('\nDone!') if __name__ == '__main__': main()
為了排版方便,並沒有很遵守Python的規范(PEP8)。注意到除了前面提的三種類型的變化,還增加了鏡像變化,這主要是因為這種變換太簡單了,順手就寫上了。還有默認進程數用的是cpu_count()函數,這個獲取的是cpu的核數。把這段代碼保存為run_augmentation.py,然后在命令行輸入:
>> python run_augmentation.py -h
或者
>> python run_augmentation.py --help
就能看到腳本的使用方法,每個參數的含義,還有默認值。接下里來執行一個圖片增加任務:
>> python run_augmentation.py imagenet_samples more_samples 1000 --rotate_angle_vari 180 --p_rotate_crop 0.5
其中imagenet_samples為一些從imagenet圖片url中隨機下載的一些圖片,--rotate_angle_vari設為180方便測試全方向的旋轉,--p_rotate_crop設置為0.5,讓旋轉裁剪對一半圖片生效。擾動增加后的1000張圖片在more_samples文件夾下,得到的部分結果如下:
用OpenCV實現數據標注小工具
除了對圖像的處理,OpenCV的圖形用戶界面(Graphical User Interface, GUI)和繪圖等相關功能也是很有用的功能,無論是可視化,圖像調試還是我們這節要實現的標注任務,都可以有所幫助。這節先介紹OpenCV窗口的最基本使用和交互,然后基於這些基礎和之前的知識實現一個用於物體檢測任務標注的小工具。
OpenCV窗口循環
OpenCV顯示一幅圖片的函數是cv2.imshow(),第一個參數是顯示圖片的窗口名稱,第二個參數是圖片的array。不過如果直接執行這個函數的話,什么都不會發生,因為這個函數得配合cv2.waitKey()一起使用。cv2.waitKey()指定當前的窗口顯示要持續的毫秒數,比如cv2.waitKey(1000)就是顯示一秒,然后窗口就關閉了。比較特殊的是cv2.waitKey(0),並不是顯示0毫秒的意思,而是一直顯示,直到有鍵盤上的按鍵被按下,或者鼠標點擊了窗口的小叉子才關閉。cv2.waitKey()的默認參數就是0,所以對於圖像展示的場景,cv2.waitKey()或者cv2.waitKey(0)是最常用的:
import cv2 img = cv2.imread('Aitutaki.png') cv2.imshow('Honeymoon Island', img) cv2.waitKey()
執行這段代碼得到如下窗口:
cv2.waitKey()參數不為零的時候則可以和循環結合產生動態畫面,比如在6.2.4的延時小例子中,我們把延時攝影保存下來的所有圖像放到一個叫做frames的文件夾下。下面代碼從frames的文件夾下讀取所有圖片並以24的幀率在窗口中顯示成動畫:
import os from itertools import cycle import cv2 # 列出frames文件夾下的所有圖片 filenames = os.listdir('frames') # 通過itertools.cycle生成一個無限循環的迭代器,每次迭代都輸出下一張圖像對象 img_iter = cycle([cv2.imread(os.sep.join(['frames', x])) for x in filenames]) key = 0 while key & 0xFF != 27: cv2.imshow('Animation', next(img_iter)) key = cv2.waitKey(42)
在這個例子中我們采用了Python的itertools模塊中的cycle函數,這個函數可以把一個可遍歷結構編程一個無限循環的迭代器。另外從這個例子中我們還發現,cv2.waitKey()返回的就是鍵盤上出發的按鍵。對於字母就是ascii碼,特殊按鍵比如上下左右等,則對應特殊的值,其實這就是鍵盤事件的最基本用法。
鼠標和鍵盤事件
因為GUI總是交互的,所以鼠標和鍵盤事件基本使用必不可少,上節已經提到了cv2.waitKey()就是獲取鍵盤消息的最基本方法。比如下面這段循環代碼就能夠獲取鍵盤上按下的按鍵,並在終端輸出:
while key != 27: cv2.imshow('Honeymoon Island', img) key = cv2.waitKey() # 如果獲取的鍵值小於256則作為ascii碼輸出對應字符,否則直接輸出值 msg = '{} is pressed'.format(chr(key) if key < 256 else key) print(msg)
通過這個程序我們能獲取一些常用特殊按鍵的值,比如在筆者用的機器上,四個方向的按鍵和刪除鍵對應的值如下:
- 上(↑):65362
- 下(↓):65364
- 左(←):65361
- 右(→):65363
- 刪除(Delete):65535
需要注意的是在不同的操作系統里這些值可能是不一樣的。鼠標事件比起鍵盤事件稍微復雜一點點,需要定義一個回調函數,然后把回調函數和一個指定名稱的窗口綁定,這樣只要鼠標位於畫面區域內的事件就都能捕捉到。把下面這段代碼插入到上段代碼的while之前,就能獲取當前鼠標的位置和動作並輸出:
# 定義鼠標事件回調函數 def on_mouse(event, x, y, flags, param): # 鼠標左鍵按下,抬起,雙擊 if event == cv2.EVENT_LBUTTONDOWN: print('Left button down at ({}, {})'.format(x, y)) elif event == cv2.EVENT_LBUTTONUP: print('Left button up at ({}, {})'.format(x, y)) elif event == cv2.EVENT_LBUTTONDBLCLK: print('Left button double clicked at ({}, {})'.format(x, y)) # 鼠標右鍵按下,抬起,雙擊 elif event == cv2.EVENT_RBUTTONDOWN: print('Right button down at ({}, {})'.format(x, y)) elif event == cv2.EVENT_RBUTTONUP: print('Right button up at ({}, {})'.format(x, y)) elif event == cv2.EVENT_RBUTTONDBLCLK: print('Right button double clicked at ({}, {})'.format(x, y)) # 鼠標中/滾輪鍵(如果有的話)按下,抬起,雙擊 elif event == cv2.EVENT_MBUTTONDOWN: print('Middle button down at ({}, {})'.format(x, y)) elif event == cv2.EVENT_MBUTTONUP: print('Middle button up at ({}, {})'.format(x, y)) elif event == cv2.EVENT_MBUTTONDBLCLK: print('Middle button double clicked at ({}, {})'.format(x, y)) # 鼠標移動 elif event == cv2.EVENT_MOUSEMOVE: print('Moving at ({}, {})'.format(x, y)) # 為指定的窗口綁定自定義的回調函數 cv2.namedWindow('Honeymoon Island') cv2.setMouseCallback('Honeymoon Island', on_mouse)
物體檢測標注的小工具
基於上面兩小節的基本使用,就能和OpenCV的基本繪圖功能就能實現一個超級簡單的物體框標注小工具了。基本思路是對要標注的圖像建立一個窗口循環,然后每次循環的時候對圖像進行一次拷貝。鼠標在畫面上畫框的操作,以及已經畫好的框的相關信息在全局變量中保存,並且在每個循環中根據這些信息,在拷貝的圖像上再畫一遍,然后顯示這份拷貝的圖像。
基於這種實現思路,使用上我們采用一個盡量簡化的設計:
- 輸入是一個文件夾,下面包含了所有要標注物體框的圖片。如果圖片中標注了物體,則生成一個相同名稱加額外后綴名的文件保存標注信息。
- 標注的方式是按下鼠標左鍵選擇物體框的左上角,松開鼠標左鍵選擇物體框的右下角,鼠標右鍵刪除上一個標注好的物體框。所有待標注物體的類別,和標注框顏色由用戶自定義,如果沒有定義則默認只標注一種物體,定義該物體名稱叫“Object”。
- 方向鍵的←和→用來遍歷圖片,↑和↓用來選擇當前要標注的物體,Delete鍵刪除一張圖片和對應的標注信息。
每張圖片的標注信息,以及自定義標注物體和顏色的信息,用一個元組表示,第一個元素是物體名字,第二個元素是代表BGR顏色的tuple或者是代表標注框坐標的元組。對於這種並不復雜復雜的數據結構,我們直接利用Python的repr()函數,把數據結構保存成機器可讀的字符串放到文件里,讀取的時候用eval()函數就能直接獲得數據。這樣的方便之處在於不需要單獨寫個格式解析器。如果需要可以在此基礎上再編寫一個轉換工具就能夠轉換成常見的Pascal VOC的標注格式或是其他的自定義格式。
在這些思路和設計下,我們定義標注信息文件的格式的例子如下:
('Hill', ((221, 163), (741, 291)))
('Horse', ((465, 430), (613, 570)))
元組中第一項是物體名稱,第二項是標注框左上角和右下角的坐標。這里之所以不把標注信息的數據直接用pickle保存,是因為數據本身不會很復雜,直接保存還有更好的可讀性。自定義標注物體和對應標注框顏色的格式也類似,不過更簡單些,因為括號可以不寫,具體如下:
'Horse', (255, 255, 0)
'Hill', (0, 255, 255)
'DiaoSi', (0, 0, 255)
第一項是物體名稱,第二項是物體框的顏色。使用的時候把自己定義好的內容放到一個文本里,然后保存成和待標注文件夾同名,后綴名為labels的文件。比如我們在一個叫samples的文件夾下放上一些草原的照片,然后自定義一個samples.labels的文本文件。把上段代碼的內容放進去,就定義了小山頭的框為黃色,駿馬的框為青色,以及紅色的屌絲。基於以上,標注小工具的代碼如下:
import os import cv2 # tkinter是Python內置的簡單GUI庫,實現一些比如打開文件夾,確認刪除等操作十分方便 from tkFileDialog import askdirectory from tkMessageBox import askyesno # 定義標注窗口的默認名稱 WINDOW_NAME = 'Simple Bounding Box Labeling Tool' # 定義畫面刷新的大概幀率(是否能達到取決於電腦性能) FPS = 24 # 定義支持的圖像格式 SUPPOTED_FORMATS = ['jpg', 'jpeg', 'png'] # 定義默認物體框的名字為Object,顏色藍色,當沒有用戶自定義物體時用默認物體 DEFAULT_COLOR = {'Object': (255, 0, 0)} # 定義灰色,用於信息顯示的背景和未定義物體框的顯示 COLOR_GRAY = (192, 192, 192) # 在圖像下方多出BAR_HEIGHT這么多像素的區域用於顯示文件名和當前標注物體等信息 BAR_HEIGHT = 16 # 上下左右,ESC及刪除鍵對應的cv.waitKey()的返回值 # 注意這個值根據操作系統不同有不同,可以通過6.4.2中的代碼獲取 KEY_UP = 65362 KEY_DOWN = 65364 KEY_LEFT = 65361 KEY_RIGHT = 65363 KEY_ESC = 27 KEY_DELETE = 65535 # 空鍵用於默認循環 KEY_EMPTY = 0 get_bbox_name = '{}.bbox'.format # 定義物體框標注工具類 class SimpleBBoxLabeling: def __init__(self, data_dir, fps=FPS, window_name=None): self._data_dir = data_dir self.fps = fps self.window_name = window_name if window_name else WINDOW_NAME #pt0是正在畫的左上角坐標,pt1是鼠標所在坐標 self._pt0 = None self._pt1 = None # 表明當前是否正在畫框的狀態標記 self._drawing = False # 當前標注物體的名稱 self._cur_label = None # 當前圖像對應的所有已標注框 self._bboxes = [] # 如果有用戶自定義的標注信息則讀取,否則用默認的物體和顏色 label_path = '{}.labels'.format(self._data_dir) self.label_colors = DEFAULT_COLOR if not os.path.exists(label_path) else self.load_labels(label_path) # 獲取已經標注的文件列表和還未標注的文件列表 imagefiles = [x for x in os.listdir(self._data_dir) if x[x.rfind('.') + 1:].lower() in SUPPOTED_FORMATS] labeled = [x for x in imagefiles if os.path.exists(get_bbox_name(x))] to_be_labeled = [x for x in imagefiles if x not in labeled] # 每次打開一個文件夾,都自動從還未標注的第一張開始 self._filelist = labeled + to_be_labeled self._index = len(labeled) if self._index > len(self._filelist) - 1: self._index = len(self._filelist) - 1 # 鼠標回調函數 def _mouse_ops(self, event, x, y, flags, param): # 按下左鍵時,坐標為左上角,同時表明開始畫框,改變drawing標記為True if event == cv2.EVENT_LBUTTONDOWN: self._drawing = True self._pt0 = (x, y) # 左鍵抬起,表明當前框畫完了,坐標記為右下角,並保存,同時改變drawing標記為False elif event == cv2.EVENT_LBUTTONUP: self._drawing = False self._pt1 = (x, y) self._bboxes.append((self._cur_label, (self._pt0, self._pt1))) # 實時更新右下角坐標方便畫框 elif event == cv2.EVENT_MOUSEMOVE: self._pt1 = (x, y) # 鼠標右鍵刪除最近畫好的框 elif event == cv2.EVENT_RBUTTONUP: if self._bboxes: self._bboxes.pop() # 清除所有標注框和當前狀態 def _clean_bbox(self): self._pt0 = None self._pt1 = None self._drawing = False self._bboxes = [] # 畫標注框和當前信息的函數 def _draw_bbox(self, img): # 在圖像下方多出BAR_HEIGHT這么多像素的區域用於顯示文件名和當前標注物體等信息 h, w = img.shape[:2] canvas = cv2.copyMakeBorder(img, 0, BAR_HEIGHT, 0, 0, cv2.BORDER_CONSTANT, value=COLOR_GRAY) # 正在標注的物體信息,如果鼠標左鍵已經按下,則顯示兩個點坐標,否則顯示當前待標注物體的名稱 label_msg = '{}: {}, {}'.format(self._cur_label, self._pt0, self._pt1) \ if self._drawing \ else 'Current label: {}'.format(self._cur_label) # 顯示當前文件名,文件個數信息 msg = '{}/{}: {} | {}'.format(self._index + 1, len(self._filelist), self._filelist[self._index], label_msg) cv2.putText(canvas, msg, (1, h+12), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1) # 畫出已經標好的框和對應名字 for label, (bpt0, bpt1) in self._bboxes: label_color = self.label_colors[label] if label in self.label_colors else COLOR_GRAY cv2.rectangle(canvas, bpt0, bpt1, label_color, thickness=2) cv2.putText(canvas, label, (bpt0[0]+3, bpt0[1]+15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, label_color, 2) # 畫正在標注的框和對應名字 if self._drawing: label_color = self.label_colors[self._cur_label] if self._cur_label in self.label_colors else COLOR_GRAY if self._pt1[0] >= self._pt0[0] and self._pt1[1] >= self._pt0[1]: cv2.rectangle(canvas, self._pt0, self._pt1, label_color, thickness=2) cv2.putText(canvas, self._cur_label, (self._pt0[0] + 3, self._pt0[1] + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, label_color, 2) return canvas # 利用repr()導出標注框數據到文件 @staticmethod def export_bbox(filepath, bboxes): if bboxes: with open(filepath, 'w') as f: for bbox in bboxes: line = repr(bbox) + '\n' f.write(line) elif os.path.exists(filepath): os.remove(filepath) # 利用eval()讀取標注框字符串到數據 @staticmethod def load_bbox(filepath): bboxes = [] with open(filepath, 'r') as f: line = f.readline().rstrip() while line: bboxes.append(eval(line)) line = f.readline().rstrip() return bboxes # 利用eval()讀取物體及對應顏色信息到數據 @staticmethod def load_labels(filepath): label_colors = {} with open(filepath, 'r') as f: line = f.readline().rstrip() while line: label, color = eval(line) label_colors[label] = color line = f.readline().rstrip() return label_colors # 讀取圖像文件和對應標注框信息(如果有的話) @staticmethod def load_sample(filepath): img = cv2.imread(filepath) bbox_filepath = get_bbox_name(filepath) bboxes = [] if os.path.exists(bbox_filepath): bboxes = SimpleBBoxLabeling.load_bbox(bbox_filepath) return img, bboxes # 導出當前標注框信息並清空 def _export_n_clean_bbox(self): bbox_filepath = os.sep.join([self._data_dir, get_bbox_name(self._filelist[self._index])]) self.export_bbox(bbox_filepath, self._bboxes) self._clean_bbox() # 刪除當前樣本和對應的標注框信息 def _delete_current_sample(self): filename = self._filelist[self._index] filepath = os.sep.join([self._data_dir, filename]) if os.path.exists(filepath): os.remove(filepath) filepath = get_bbox_name(filepath) if os.path.exists(filepath): os.remove(filepath) self._filelist.pop(self._index) print('{} is deleted!'.format(filename)) # 開始OpenCV窗口循環的方法,定義了程序的主邏輯 def start(self): # 之前標注的文件名,用於程序判斷是否需要執行一次圖像讀取 last_filename = '' # 標注物體在列表中的下標 label_index = 0 # 所有標注物體名稱的列表 labels = self.label_colors.keys() # 待標注物體的種類數 n_labels = len(labels) # 定義窗口和鼠標回調 cv2.namedWindow(self.window_name) cv2.setMouseCallback(self.window_name, self._mouse_ops) key = KEY_EMPTY # 定義每次循環的持續時間 delay = int(1000 / FPS) # 只要沒有按下Esc鍵,就持續循環 while key != KEY_ESC: # 上下鍵用於選擇當前標注物體 if key == KEY_UP: if label_index == 0: pass else: label_index -= 1 elif key == KEY_DOWN: if label_index == n_labels - 1: pass else: label_index += 1 # 左右鍵切換當前標注的圖片 elif key == KEY_LEFT: # 已經到了第一張圖片的話就不需要清空上一張 if self._index > 0: self._export_n_clean_bbox() self._index -= 1 if self._index < 0: self._index = 0 elif key == KEY_RIGHT: # 已經到了最后一張圖片的話就不需要清空上一張 if self._index < len(self._filelist) - 1: self._export_n_clean_bbox() self._index += 1 if self._index > len(self._filelist) - 1: self._index = len(self._filelist) - 1 # 刪除當前圖片和對應標注信息 elif key == KEY_DELETE: if askyesno('Delete Sample', 'Are you sure?'): self._delete_current_sample() key = KEY_EMPTY continue # 如果鍵盤操作執行了換圖片,則重新讀取,更新圖片 filename = self._filelist[self._index] if filename != last_filename: filepath = os.sep.join([self._data_dir, filename]) img, self._bboxes = self.load_sample(filepath) # 更新當前標注物體名稱 self._cur_label = labels[label_index] # 把標注和相關信息畫在圖片上並顯示指定的時間 canvas = self._draw_bbox(img) cv2.imshow(self.window_name, canvas) key = cv2.waitKey(delay) # 當前文件名就是下次循環的老文件名 last_filename = filename print('Finished!') cv2.destroyAllWindows() # 如果退出程序,需要對當前進行保存 self.export_bbox(os.sep.join([self._data_dir, get_bbox_name(filename)]), self._bboxes) print('Labels updated!') if __name__ == '__main__': dir_with_images = askdirectory(title='Where are the images?') labeling_task = SimpleBBoxLabeling(dir_with_images) labeling_task.start()
需要注意的是幾個比較通用且獨立的方法前加上了一句@staticmethod,表明是個靜態方法。執行這個程序,並選擇samples文件夾,標注時的畫面如下圖:
refer:https://zhuanlan.zhihu.com/p/24425116