無標給深度學習入門者的Python快速教程 - 番外篇之Python-OpenCV題文章

來源 https://zhuanlan.zhihu.com/p/24425116

給深度學習入門者的Python快速教程 - numpy和Matplotlib篇

的番外篇,因為嚴格來說不是在講Python而是講在Python下使用OpenCV。本篇將介紹和深度學習數據處理階段最相關的基礎使用,并完成4個有趣實用的小例子:

- 延時攝影小程序

- 視頻中截屏采樣的小程序

- 圖片數據增加(data augmentation)的小工具

- 物體檢測框標注小工具

其中后兩個例子的代碼可以在下面地址直接下載:

frombeijingwithlove/dlcv_for_beginners

6.1 OpenCV簡介

OpenCV是計算機視覺領域應用最廣泛的開源工具包,基于C/C++,支持Linux/Windows/MacOS/Android/iOS,并提供了Python,Matlab和Java等語言的接口,因為其豐富的接口,優秀的性能和商業友好的使用許可,不管是學術界還是業界中都非常受歡迎。OpenCV最早源于Intel公司1998年的一個研究項目,當時在Intel從事計算機視覺的工程師蓋瑞·布拉德斯基(Gary Bradski)訪問一些大學和研究組時發現學生之間實現計算機視覺算法用的都是各自實驗室里的內部代碼或者庫,這樣新來實驗室的學生就能基于前人寫的基本函數快速上手進行研究。于是OpenCV旨在提供一個用于計算機視覺的科研和商業應用的高性能通用庫。 第一個alpha版本的OpenCV于2000年的CVPR上發布,在接下來的5年里,又陸續發布了5個beta版本,2006年發布了第一個正式版。2009年隨著蓋瑞加入了Willow Garage,OpenCV從Willow Garage得到了積極的支持,并發布了1.1版。2010年OpenCV發布了2.0版本,添加了非常完備的C++接口,從2.0開始的版本非常用戶非常龐大,至今仍在維護和更新。2015年OpenCV 3正式發布,除了架構的調整,還加入了更多算法,更多性能的優化和更加簡潔的API,另外也加強了對GPU的支持,現在已經在許多研究機構和商業公司中應用開來。

6.1.1 OpenCV的結構

和Python一樣,當前的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的主要變化是更多的功能和更細化的模塊劃分。

6.1.2 安裝和使用OpenCV

作為最流行的視覺包,在Linux中安裝OpenCV是非常方便的,大多數Linux的發行版都支持包管理器的安裝,比如在Ubuntu 16.04 LTS中,只需要在終端中輸入:

>> sudo apt install libopencv-dev python-opencv

當然也可以通過官網下載源碼編譯安裝,第一步先安裝各種依賴:

>> sudo apt install build-essential

>> sudo apt install cmake git libgtk2.0-dev pkg-config libavcodec-dev libavformat-dev libswscale-dev

>> sudo apt-get install python-dev python-numpy libtbb2 libtbb-dev libjpeg-dev libpng-dev libtiff-dev libjasper-dev libdc1394-22-dev

然后找一個clone壓縮包的文件夾,把源碼拿下來:

>> git cloneopencv/opencv

然后進入OpenCV文件夾:

>> mkdir release

>> cd release

>> cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/usr/local ..

準備完畢,直接make并安裝:

>> make

>> sudo make install

Windows下的安裝也很簡單,直接去OpenCV官網下載:

DOWNLOADS | OpenCV

執行exe安裝后,會在<安裝目錄>/build/python/2.7下發現一個叫cv2.pyd的文件,把這個文件拷貝到\Lib\site-packages下,就可以了。Windows下如果只想在Python中體驗OpenCV還有個更簡單的方法是加州大學爾灣分校(University of California, Irvine)的Christoph Gohlke制作的Windows下的Python科學計算包網頁,下載對應版本的wheel文件,然后通過pip安裝:

http://www.lfd.uci.edu/~gohlke/pythonlibs/#opencv

本書只講Python下OpenCV基本使用,Python中導入OpenCV非常簡單:

importcv2

就導入成功了。

6.2 Python-OpenCV基礎

6.2.1 圖像的表示

前面章節已經提到過了單通道的灰度圖像在計算機中的表示,就是一個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中矩陣的對應的圖像,生成圖像后,放大看就能體會到區別:

importnumpyasnpimportcv2importmatplotlib.pyplotasplt# 圖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所示。

6.2.2 基本圖像處理

存取圖像

讀圖像用cv2.imread(),可以按照不同模式讀取,一般最常用到的是讀取單通道灰度圖,或者直接默認讀取多通道。存圖像用cv2.imwrite(),注意存的時候是沒有單通道這一說的,根據保存文件名的后綴和當前的array維度,OpenCV自動判斷存的通道,另外壓縮格式還可以指定存儲質量,來看代碼例子:

importcv2# 讀取一張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分辨率的圖片,并執行一些基礎的操作:

importcv2# 讀取一張四川大錄古藏寨的照片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轉換到HSVimg_hsv=cv2.cvtColor(img,cv2.COLOR_BGR2HSV)# H空間中,綠色比黃色的值高一點,所以給每個像素+15,黃色的樹葉就會變綠turn_green_hsv=img_hsv.copy()turn_green_hsv[:,:,0]=(turn_green_hsv[:,:,0]+15)%180turn_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變換的代碼如下:

importnumpyasnp# 分通道計算每個通道的直方圖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矯正的函數defgamma_trans(img,gamma):# 具體做法是先歸一化到1,然后gamma作為指數值求出新的像素值再還原gamma_table=[np.power(x/255.0,gamma)*255.0forxinrange(256)]gamma_table=np.round(np.array(gamma_table)).astype(np.uint8)# 實現這個映射用的是OpenCV的查表函數returncv2.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])# 將直方圖進行可視化importmatplotlib.pyplotaspltfrommpl_toolkits.mplot3dimportAxes3Dfig=plt.figure()pix_hists=[[hist_b,hist_g,hist_r],[hist_b_corrected,hist_g_corrected,hist_r_corrected]]pix_vals=range(256)forsub_plt,pix_histinzip([121,122],pix_hists):ax=fig.add_subplot(sub_plt,projection='3d')forc,z,channel_histinzip(['b','g','r'],[20,10,0],pix_hist):cs=[c]*256ax.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:

importcv2importnumpyasnp# 讀取一張斯里蘭卡拍攝的大象照片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/180M_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提供了各種繪圖的函數,可以在畫面上繪制線段,圓,矩形和多邊形等,還可以在圖像上指定位置打印文字,比如下面例子:

importnumpyasnpimportcv2# 定義一塊寬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/5rotations=[[[np.cos(i*phi),-np.sin(i*phi)],[i*np.sin(phi),np.cos(i*phi)]]foriinrange(1,5)]pentagram=np.array([[[[0,-1]]+[np.dot(m,(0,-1))forminrotations]]],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空間的色調連續變化forxinrange(302,600):color_pixel=np.array([[[round(180*float(x-302)/298),255,255]]],dtype=np.uint8)line_color=[int(c)forcincv2.cvtColor(color_pixel,cv2.COLOR_HSV2BGR)[0][0]]cv2.line(canvas,(x,0),(x,147),line_color)# 如果定義圓的線寬大于半斤,則等效于畫圓點,隨機在畫面右下角的框內生成坐標np.random.seed(42)n_pts=30pts_x=np.random.randint(310,590,n_pts)pts_y=np.random.randint(160,390,n_pts)pts=zip(pts_x,pts_y)# 畫出每個點,顏色隨機forptinpts:pt_color=[int(c)forcinnp.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,用于生成視頻。還是來看例子理解這兩個功能的用法,首先是一個制作延時攝影視頻的小例子:

importcv2importtimeinterval=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)# 對于一些低畫質的攝像頭,前面的幀可能不穩定,略過foriinrange(42):cap.read()# 開始捕獲,通過read()函數獲取捕獲的幀try:foriinrange(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)exceptKeyboardInterrupt:# 提前停止捕獲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,更多編碼方式可以在下面的地址查詢:

Video Codecs by FOURCC

還有一個是KeyboardInterrupt,這是一個常用的異常,用來獲取用戶Ctrl+C的中止,捕獲這個異常后直接結束循環并釋放VideoCapture和VideoWriter的資源,使已經捕獲好的部分視頻可以順利生成。

從視頻中截取幀也是處理視頻時常見的任務,下面代碼實現的是遍歷一個指定文件夾下的所有視頻并按照指定的間隔進行截屏并保存:

importcv2importosimportsys# 第一個輸入參數是包含視頻片段的路徑input_path=sys.argv[1]# 第二個輸入參數是設定每隔多少幀截取一幀frame_interval=int(sys.argv[2])# 列出文件夾下所有的視頻文件filenames=os.listdir(input_path)# 獲取文件夾名稱video_prefix=input_path.split(os.sep)[-1]# 建立一個新的文件夾,名稱為原文件夾名稱后加上_framesframe_path='{}_frames'.format(input_path)ifnotos.path.exists(frame_path):os.mkdir(frame_path)# 初始化一個VideoCapture對象cap=cv2.VideoCapture()# 遍歷所有文件forfilenameinfilenames:filepath=os.sep.join([input_path,filename])# VideoCapture::open函數可以從文件獲取視頻cap.open(filepath)# 獲取視頻幀數n_frames=int(cap.get(cv2.CAP_PROP_FRAME_COUNT))# 同樣為了避免視頻頭幾幀質量低下,黑屏或者無關等foriinrange(42):cap.read()foriinrange(n_frames):ret,frame=cap.read()# 每隔frame_interval幀進行一次截屏操作ifi%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()

6.3 用OpenCV實現數據增加小工具

到目前我們已經熟悉了numpy中的隨機模塊,多進程調用和OpenCV的基本操作,基于這些基礎,本節將從思路到代碼一步步實現一個最基本的數據增加小工具。

第三章和第四章都提到過數據增加(data augmentation),作為一種深度學習中的常用手段,數據增加對模型的泛化性和準確性都有幫助。數據增加的具體使用方式一般有兩種,一種是實時增加,比如在Caffe中加入數據擾動層,每次圖像都先經過擾動操作,再去訓練,這樣訓練經過幾代(epoch)之后,就等效于數據增加。還有一種是更加直接簡單一些的,就是在訓練之前就通過圖像處理手段對數據樣本進行擾動和增加,也就是本節要實現的。

這個例子中將包含三種基本類型的擾動:隨機裁剪,隨機旋轉和隨機顏色/明暗。

6.3.1 隨機裁剪

AlexNet中已經講過了隨機裁剪的基本思路,我們的小例子中打算更進一步:在裁剪的時候考慮圖像寬高比的擾動。在絕大多數用于分類的圖片中,樣本進入網絡前都是要變為統一大小,所以寬高比擾動相當于對物體的橫向和縱向進行了縮放,這樣除了物體的位置擾動,又多出了一項擾動。只要變化范圍控制合適,目標物體始終在畫面內,這種擾動是有助于提升泛化性能的。實現這種裁剪的思路如下圖所示:

圖中最左邊是一幅需要剪裁的畫面,首先根據這幅畫面我們可以算出一個寬高比w/h。然后設定一個小的擾動范圍δ和要裁剪的畫面占原畫面的比例β,從-

之間按均勻采樣,獲取一個隨機數

作為裁剪后畫面的寬高比擾動的比例,則裁剪后畫面的寬和高分別為:

想象一下先把這個寬為w’,高為h’的區域置于原畫面的右下角,則這個區域的左上角和原畫面的左上角框出的小區域,如圖中的虛線框所示,就是裁剪后區域左上角可以取值的范圍。所以在這個區域內隨機采一點作為裁剪區域的左上角,就實現了如圖中位置隨機,且寬高比也隨機的裁剪。

6.3.2 隨機旋轉

前面講到過的旋轉比起來,做數據增加時,一般希望旋轉是沿著畫面的中心。這樣除了要知道旋轉角度,還得計算平移的量才能讓仿射變換的效果等效于旋轉軸在畫面中心,好在OpenCV中有現成的函數cv2.getRotationMatrix2D()可以使用。這個函數的第一個參數是旋轉中心,第二個參數是逆時針旋轉角度,第三個參數是縮放倍數,對于只是旋轉的情況下這個值是1,返回值就是做仿射變換的矩陣。

直接用這個函數并接著使用cv2.warpAffine()會有一個潛在的問題,就是旋轉之后會出現黑邊。如果要旋轉后的畫面不包含黑邊,就得沿著原來畫面的輪廓做個內接矩形,該矩形的寬高比和原畫面相同,如下圖所示:

在圖中,可以看到,限制內接矩形大小的主要是原畫面更靠近中心的那條邊,也就是圖中比較長的一條邊AB。因此我們只要沿著中心O和內接矩形的頂點方向的直線,求出和AB的交點P,就得到了內接矩形的大小。先來看長邊的方程,考慮之前畫面和橫軸相交的點,經過角度-θ旋轉后,到了圖中的Q點所在:

因為長邊所在直線過Q點,且斜率為1/tan(θ),所以有:

這時候考慮OP這條直線:

把這個公式帶入再前邊一個公式,求解可以得到:

注意到在這個問題中,每個象限和相鄰象限都是軸對稱的,而且旋轉角度對剪裁寬度和長度的影響是周期(T=π)變化,再加上我們關心的其實并不是四個點的位置,而是旋轉后要截取的矩形的寬w’和高h’,所以復雜的分區間情況也簡化了,首先對于旋轉角度,因為周期為π,所以都可以化到0到π之間,然后因為對稱性,進一步有:

于是對于0到π/2之間的θ,有:

當然需要注意的是,對于寬高比非常大或者非常小的圖片,旋轉后如果裁剪往往得到的畫面是非常小的一部分,甚至不包含目標物體。所以是否需要旋轉,以及是否需要裁剪,如果裁剪角度多少合適,都要視情況而定。

6.3.3 隨機顏色和明暗

比起AlexNet論文里在PCA之后的主成分上做擾動的方法,本書用來實現隨機的顏色以及明暗的方法相對簡單很多,就是給HSV空間的每個通道,分別加上一個微小的擾動。其中對于色調,從-

之間按均勻采樣,獲取一個隨機數

作為要擾動的值,然后新的像素值x’為原始像素值x +

;對于其他兩個空間則是新像素值x’為原始像素值x的(1+

)倍,從而實現色調,飽和度和明暗度的擾動。

因為明暗度并不會對圖像的直方圖相對分布產生大的影響,所以在HSV擾動基礎上,考慮再加入一個Gamma擾動,方法是設定一個大于1的Gamma值的上限γ,因為這個值通常會和1是一個量級,再用均勻采樣的近似未必合適,所以從-logγ到logγ之間均勻采樣一個值α,然后用

作為Gamma值進行變換。

6.3.4 多進程調用加速處理

做數據增加時如果樣本量本身就不小,則處理起來可能會很耗費時間,所以可以考慮利用多進程并行處理。比如我們的例子中,設定使用場景是輸入一個文件夾路徑,該文件夾下包含了所有原始的數據樣本。用戶指定輸出的文件夾和打算增加圖片的總量。執行程序的時候,通過os.listdir()獲取所有文件的路徑,然后按照上一章講過的多進程平均劃分樣本的辦法,把文件盡可能均勻地分給不同進程,進行處理。

6.3.5 代碼:圖片數據增加小工具

按照前面4個部分的思路和方法,這節來實現這么一個圖片數據增加小工具,首先對于一些基礎的操作,我們定義在一個叫做image_augmentation.py的文件里:

importnumpyasnpimportcv2'''定義裁剪函數,四個參數分別是:左上角橫坐標x0左上角縱坐標y0裁剪寬度w裁剪高度h'''crop_image=lambdaimg,x0,y0,w,h:img[y0:y0+h,x0:x0+w]'''隨機裁剪area_ratio為裁剪畫面占原畫面的比例hw_vari是擾動占原高寬比的比例范圍'''defrandom_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)))# 裁剪寬度不可超過原圖可裁剪寬度ifw_crop>w:w_crop=wh_crop=int(round(h*np.sqrt(area_ratio/hw_mult)))ifh_crop>h:h_crop=h# 隨機生成左上角的位置x0=np.random.randint(0,w-w_crop+1)y0=np.random.randint(0,h-h_crop+1)returncrop_image(img,x0,y0,w_crop,h_crop)'''定義旋轉函數:angle是逆時針旋轉的角度crop是個布爾值,表明是否要裁剪去除黑邊'''defrotate_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))# 如果需要裁剪去除黑邊ifcrop:# 對于裁剪角度的等效周期是180°angle_crop=angle%180# 并且關于90°對稱ifangle_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_ratioifh>welse1/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)returnimg_rotated'''隨機旋轉angle_vari是旋轉角度的范圍[-angle_vari, angle_vari)p_crop是要進行去黑邊裁剪的比例'''defrandom_rotate(img,angle_vari,p_crop):angle=np.random.uniform(-angle_vari,angle_vari)crop=Falseifnp.random.random()>p_cropelseTruereturnrotate_image(img,angle,crop)'''定義hsv變換函數:hue_delta是色調變化比例sat_delta是飽和度變化比例val_delta是明度變化比例'''defhsv_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)%180img_hsv[:,:,1]*=sat_multimg_hsv[:,:,2]*=val_multimg_hsv[img_hsv>255]=255returncv2.cvtColor(np.round(img_hsv).astype(np.uint8),cv2.COLOR_HSV2BGR)'''隨機hsv變換hue_vari是色調變化比例的范圍sat_vari是飽和度變化比例的范圍val_vari是明度變化比例的范圍'''defrandom_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)returnhsv_transform(img,hue_delta,sat_mult,val_mult)'''定義gamma變換函數:gamma就是Gamma'''defgamma_transform(img,gamma):gamma_table=[np.power(x/255.0,gamma)*255.0forxinrange(256)]gamma_table=np.round(np.array(gamma_table)).astype(np.uint8)returncv2.LUT(img,gamma_table)'''隨機gamma變換gamma_vari是Gamma變化的范圍[1/gamma_vari, gamma_vari)'''defrandom_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)returngamma_transform(img,gamma)

調用這些函數需要通過一個主程序。這個主程序里首先定義三個子模塊,定義一個函數parse_arg()通過Python的argparse模塊定義了各種輸入參數和默認值。需要注意的是這里用argparse來輸入所有參數是因為參數總量并不是特別多,如果增加了更多的擾動方法,更合適的參數輸入方式可能是通過一個配置文件。然后定義一個生成待處理圖像列表的函數generate_image_list(),根據輸入中要增加圖片的數量和并行進程的數目盡可能均勻地為每個進程生成了需要處理的任務列表。執行隨機擾動的代碼定義在augment_images()中,這個函數是每個進程內進行實際處理的函數,執行順序是鏡像

裁剪

旋轉

HSV

Gamma。需要注意的是鏡像

裁剪,因為只是個演示例子,這未必是一個合適的順序。最后定義一個main函數進行調用,代碼如下:

importosimportargparseimportrandomimportmathfrommultiprocessingimportProcessfrommultiprocessingimportcpu_countimportcv2# 導入image_augmentation.py為一個可調用模塊importimage_augmentationasia# 利用Python的argparse模塊讀取輸入輸出和各種擾動參數defparse_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('/')returnargs'''根據進程數和要增加的目標圖片數,生成每個進程要處理的文件列表和每個文件要增加的數目'''defgenerate_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_imgslucky_seq=[True]*rem+[False]*(num_imgs-rem)random.shuffle(lucky_seq)# 根據平均分配和幸運表策略,# 生成每個文件的全路徑和對應要增加的數目并放到一個list里img_list=[(os.sep.join([args.input_dir,filename]),num_ave_aug+1ifluckyelsenum_ave_aug)forfilename,luckyinzip(filenames,lucky_seq)]# 文件可能大小不一,處理時間也不一樣,# 所以隨機打亂,盡可能保證處理時間均勻random.shuffle(img_list)# 生成每個進程的文件列表,# 盡可能均勻地劃分每個進程要處理的數目length=float(num_imgs)/float(args.num_procs)indices=[int(round(i*length))foriinrange(args.num_procs+1)]return[img_list[indices[i]:indices[i+1]]foriinrange(args.num_procs)]# 每個進程內調用圖像處理函數進行擾動的函數defaugment_images(filelist,args):# 遍歷所有列表內的文件forfilepath,ninfilelist: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))foriinrange(n):img_varied=img.copy()# 擾動后文件名的前綴varied_imgname='{}_{:0>3d}_'.format(imgname,i)# 按照比例隨機對圖像進行鏡像ifrandom.random()

為了排版方便,并沒有很遵守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文件夾下,得到的部分結果如下:

6.4 用OpenCV實現數據標注小工具

除了對圖像的處理,OpenCV的圖形用戶界面(GraphicalUserInterface,GUI)和繪圖等相關功能也是很有用的功能,無論是可視化,圖像調試還是我們這節要實現的標注任務,都可以有所幫助。這節先介紹OpenCV窗口的最基本使用和交互,然后基于這些基礎和之前的知識實現一個用于物體檢測任務標注的小工具。

6.4.1 OpenCV窗口循環

OpenCV顯示一幅圖片的函數是cv2.imshow(),第一個參數是顯示圖片的窗口名稱,第二個參數是圖片的array。不過如果直接執行這個函數的話,什么都不會發生,因為這個函數得配合cv2.waitKey()一起使用。cv2.waitKey()指定當前的窗口顯示要持續的毫秒數,比如cv2.waitKey(1000)就是顯示一秒,然后窗口就關閉了。比較特殊的是cv2.waitKey(0),并不是顯示0毫秒的意思,而是一直顯示,直到有鍵盤上的按鍵被按下,或者鼠標點擊了窗口的小叉子才關閉。cv2.waitKey()的默認參數就是0,所以對于圖像展示的場景,cv2.waitKey()或者cv2.waitKey(0)是最常用的:

importcv2img=cv2.imread('Aitutaki.png')cv2.imshow('Honeymoon Island',img)cv2.waitKey()

執行這段代碼得到如下窗口:

cv2.waitKey()參數不為零的時候則可以和循環結合產生動態畫面,比如在6.2.4的延時小例子中,我們把延時攝影保存下來的所有圖像放到一個叫做frames的文件夾下。下面代碼從frames的文件夾下讀取所有圖片并以24的幀率在窗口中顯示成動畫:

importosfromitertoolsimportcycleimportcv2# 列出frames文件夾下的所有圖片filenames=os.listdir('frames')# 通過itertools.cycle生成一個無限循環的迭代器,每次迭代都輸出下一張圖像對象img_iter=cycle([cv2.imread(os.sep.join(['frames',x]))forxinfilenames])key=0whilekey&0xFF!=27:cv2.imshow('Animation',next(img_iter))key=cv2.waitKey(42)

在這個例子中我們采用了Python的itertools模塊中的cycle函數,這個函數可以把一個可遍歷結構編程一個無限循環的迭代器。另外從這個例子中我們還發現,cv2.waitKey()返回的就是鍵盤上出發的按鍵。對于字母就是ascii碼,特殊按鍵比如上下左右等,則對應特殊的值,其實這就是鍵盤事件的最基本用法。

6.4.2 鼠標和鍵盤事件

因為GUI總是交互的,所以鼠標和鍵盤事件基本使用必不可少,上節已經提到了cv2.waitKey()就是獲取鍵盤消息的最基本方法。比如下面這段循環代碼就能夠獲取鍵盤上按下的按鍵,并在終端輸出:

whilekey!=27:cv2.imshow('Honeymoon Island',img)key=cv2.waitKey()# 如果獲取的鍵值小于256則作為ascii碼輸出對應字符,否則直接輸出值msg='{} is pressed'.format(chr(key)ifkey<256elsekey)print(msg)

通過這個程序我們能獲取一些常用特殊按鍵的值,比如在筆者用的機器上,四個方向的按鍵和刪除鍵對應的值如下:

- 上(↑):65362

- 下(↓):65364

- 左(←):65361

- 右(→):65363

- 刪除(Delete):65535

需要注意的是在不同的操作系統里這些值可能是不一樣的。鼠標事件比起鍵盤事件稍微復雜一點點,需要定義一個回調函數,然后把回調函數和一個指定名稱的窗口綁定,這樣只要鼠標位于畫面區域內的事件就都能捕捉到。把下面這段代碼插入到上段代碼的while之前,就能獲取當前鼠標的位置和動作并輸出:

# 定義鼠標事件回調函數defon_mouse(event,x,y,flags,param):# 鼠標左鍵按下,抬起,雙擊ifevent==cv2.EVENT_LBUTTONDOWN:print('Left button down at ({}, {})'.format(x,y))elifevent==cv2.EVENT_LBUTTONUP:print('Left button up at ({}, {})'.format(x,y))elifevent==cv2.EVENT_LBUTTONDBLCLK:print('Left button double clicked at ({}, {})'.format(x,y))# 鼠標右鍵按下,抬起,雙擊elifevent==cv2.EVENT_RBUTTONDOWN:print('Right button down at ({}, {})'.format(x,y))elifevent==cv2.EVENT_RBUTTONUP:print('Right button up at ({}, {})'.format(x,y))elifevent==cv2.EVENT_RBUTTONDBLCLK:print('Right button double clicked at ({}, {})'.format(x,y))# 鼠標中/滾輪鍵(如果有的話)按下,抬起,雙擊elifevent==cv2.EVENT_MBUTTONDOWN:print('Middle button down at ({}, {})'.format(x,y))elifevent==cv2.EVENT_MBUTTONUP:print('Middle button up at ({}, {})'.format(x,y))elifevent==cv2.EVENT_MBUTTONDBLCLK:print('Middle button double clicked at ({}, {})'.format(x,y))# 鼠標移動elifevent==cv2.EVENT_MOUSEMOVE:print('Moving at ({}, {})'.format(x,y))# 為指定的窗口綁定自定義的回調函數cv2.namedWindow('Honeymoon Island')cv2.setMouseCallback('Honeymoon Island',on_mouse)

6.4.3 代碼:物體檢測標注的小工具

基于上面兩小節的基本使用,就能和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的文本文件。把上段代碼的內容放進去,就定義了小山頭的框為黃色,駿馬的框為青色,以及紅色的屌絲。基于以上,標注小工具的代碼如下:

importosimportcv2# tkinter是Python內置的簡單GUI庫,實現一些比如打開文件夾,確認刪除等操作十分方便fromtkFileDialogimportaskdirectoryfromtkMessageBoximportaskyesno# 定義標注窗口的默認名稱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=65362KEY_DOWN=65364KEY_LEFT=65361KEY_RIGHT=65363KEY_ESC=27KEY_DELETE=65535# 空鍵用于默認循環KEY_EMPTY=0get_bbox_name='{}.bbox'.format# 定義物體框標注工具類classSimpleBBoxLabeling:def__init__(self,data_dir,fps=FPS,window_name=None):self._data_dir=data_dirself.fps=fpsself.window_name=window_nameifwindow_nameelseWINDOW_NAME#pt0是正在畫的左上角坐標,pt1是鼠標所在坐標self._pt0=Noneself._pt1=None# 表明當前是否正在畫框的狀態標記self._drawing=False# 當前標注物體的名稱self._cur_label=None# 當前圖像對應的所有已標注框self._bboxes=[]# 如果有用戶自定義的標注信息則讀取,否則用默認的物體和顏色label_path='{}.labels'.format(self._data_dir)self.label_colors=DEFAULT_COLORifnotos.path.exists(label_path)elseself.load_labels(label_path)# 獲取已經標注的文件列表和還未標注的文件列表imagefiles=[xforxinos.listdir(self._data_dir)ifx[x.rfind('.')+1:].lower()inSUPPOTED_FORMATS]labeled=[xforxinimagefilesifos.path.exists(get_bbox_name(x))]to_be_labeled=[xforxinimagefilesifxnotinlabeled]# 每次打開一個文件夾,都自動從還未標注的第一張開始self._filelist=labeled+to_be_labeledself._index=len(labeled)ifself._index>len(self._filelist)-1:self._index=len(self._filelist)-1# 鼠標回調函數def_mouse_ops(self,event,x,y,flags,param):# 按下左鍵時,坐標為左上角,同時表明開始畫框,改變drawing標記為Trueifevent==cv2.EVENT_LBUTTONDOWN:self._drawing=Trueself._pt0=(x,y)# 左鍵抬起,表明當前框畫完了,坐標記為右下角,并保存,同時改變drawing標記為Falseelifevent==cv2.EVENT_LBUTTONUP:self._drawing=Falseself._pt1=(x,y)self._bboxes.append((self._cur_label,(self._pt0,self._pt1)))# 實時更新右下角坐標方便畫框elifevent==cv2.EVENT_MOUSEMOVE:self._pt1=(x,y)# 鼠標右鍵刪除最近畫好的框elifevent==cv2.EVENT_RBUTTONUP:ifself._bboxes:self._bboxes.pop()# 清除所有標注框和當前狀態def_clean_bbox(self):self._pt0=Noneself._pt1=Noneself._drawing=Falseself._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)\ifself._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)# 畫出已經標好的框和對應名字forlabel,(bpt0,bpt1)inself._bboxes:label_color=self.label_colors[label]iflabelinself.label_colorselseCOLOR_GRAYcv2.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)# 畫正在標注的框和對應名字ifself._drawing:label_color=self.label_colors[self._cur_label]ifself._cur_labelinself.label_colorselseCOLOR_GRAYifself._pt1[0]>=self._pt0[0]andself._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)returncanvas# 利用repr()導出標注框數據到文件@staticmethoddefexport_bbox(filepath,bboxes):ifbboxes:withopen(filepath,'w')asf:forbboxinbboxes:line=repr(bbox)+'\n'f.write(line)elifos.path.exists(filepath):os.remove(filepath)# 利用eval()讀取標注框字符串到數據@staticmethoddefload_bbox(filepath):bboxes=[]withopen(filepath,'r')asf:line=f.readline().rstrip()whileline:bboxes.append(eval(line))line=f.readline().rstrip()returnbboxes# 利用eval()讀取物體及對應顏色信息到數據@staticmethoddefload_labels(filepath):label_colors={}withopen(filepath,'r')asf:line=f.readline().rstrip()whileline:label,color=eval(line)label_colors[label]=colorline=f.readline().rstrip()returnlabel_colors# 讀取圖像文件和對應標注框信息(如果有的話)@staticmethoddefload_sample(filepath):img=cv2.imread(filepath)bbox_filepath=get_bbox_name(filepath)bboxes=[]ifos.path.exists(bbox_filepath):bboxes=SimpleBBoxLabeling.load_bbox(bbox_filepath)returnimg,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])ifos.path.exists(filepath):os.remove(filepath)filepath=get_bbox_name(filepath)ifos.path.exists(filepath):os.remove(filepath)self._filelist.pop(self._index)print('{} is deleted!'.format(filename))# 開始OpenCV窗口循環的方法,定義了程序的主邏輯defstart(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鍵,就持續循環whilekey!=KEY_ESC:# 上下鍵用于選擇當前標注物體ifkey==KEY_UP:iflabel_index==0:passelse:label_index-=1elifkey==KEY_DOWN:iflabel_index==n_labels-1:passelse:label_index+=1# 左右鍵切換當前標注的圖片elifkey==KEY_LEFT:# 已經到了第一張圖片的話就不需要清空上一張ifself._index>0:self._export_n_clean_bbox()self._index-=1ifself._index<0:self._index=0elifkey==KEY_RIGHT:# 已經到了最后一張圖片的話就不需要清空上一張ifself._indexlen(self._filelist)-1:self._index=len(self._filelist)-1# 刪除當前圖片和對應標注信息elifkey==KEY_DELETE:ifaskyesno('Delete Sample','Are you sure?'):self._delete_current_sample()key=KEY_EMPTYcontinue# 如果鍵盤操作執行了換圖片,則重新讀取,更新圖片filename=self._filelist[self._index]iffilename!=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=filenameprint('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文件夾,標注時的畫面如下圖:

「真誠贊賞,手留余香」

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 介紹 在第一節里,我們計算和繪制了一維的histogram,它被叫做一維histogram是因為我們只拿了一個屬性...
    xxxss閱讀 3,052評論 0 51
  • 理論 傅里葉變換用來分析多種過濾器的頻率特征。對于圖片,2D離散傅里葉變換(DFT)用來找頻率范圍。一個快速算法叫...
    xxxss閱讀 6,490評論 0 52
  • 基礎 今天的針孔攝像頭對圖像做了很多扭曲,兩個主要的扭曲是徑向畸變和切向畸變。 由于徑向畸變,直線會顯示成曲線,當...
    xxxss閱讀 16,954評論 0 52
  • meanshift meanshift 背后的直覺很簡單,設想你有一個點集。(可以是一個像素分布,像直方圖向后投影...
    xxxss閱讀 6,421評論 4 50
  • 簡單閾值 這里,問題很簡單,如果像素值超過閾值,就給分配一個值(可能是白色),否則給分配另一個值(可能是黑色)。用...
    xxxss閱讀 4,648評論 1 52