從事影像分析系統研究與開發,隨著接觸的案子越來越多,自然接觸的演算法也越來越多元、豐富,但即便如此,還是會遇到相似的案例,這時候就會出現『代碼重用』的需求。如果有做好文件工程的話,很容易就找到自己過往執行案件的參考資料;如果再厲害一點,還可以透過 Git/ GitLab 等工具方便檢索;再進一步的就是有著自己撰寫的影像分析函式庫,你不需要去找哪段程式碼,只需要向引用第三方函式庫的方法一樣,一條指令就能實做許多複雜的演算法。
以上的需求情境正是這篇文章撰寫的最大動機。
以影像分析型的演算法開發為例,幾乎九成以上的演算法不外乎:讀入影像、旋轉影像(Optional)、影像灰階化、核心演算法,接著就是分析結果可式化。其中 讀入影像、旋轉影像(Optional)、影像灰階化 這三個幾乎都是標準公式,有沒有方法不用每個演算法都做重複的動作?甚至是可以彈性擴充多個演算法?
這時就需要來瞭解物件導向程式設計中的繼承(Inheritance)和多型(Polymorphism)是兩個核心概念。
到這邊文字已經夠多,我們來用程式碼說明:
class FrameProcessor:
"""基礎類別,用於處理影像框架。
Attributes:
ALLOWED_TYPES (FrameType): 列出所有允許的框架類型。
"""
ALLOWED_TYPES = FrameType
@staticmethod
def CreateProcessor(_type):
"""根據給定的類型創建 FrameProcessor 實例。
Args:
_type (str): 處理器的類型。
Returns:
FrameProcessor: 創建的 FrameProcessor 實例。
Raises:
ValueError: 如果提供了不支持的處理器類型。
"""
if _type == "NORMAL":
return NormalProcessor()
elif _type == "GRAY":
return GrayProcessor()
else:
raise ValueError(f"Unsupported processor type: {_type}")
def Process(self, frame, flip=None, returnByteImage=True):
"""處理影像並將結果作為位元組返回。
Args:
frame: 原始影像。
flip (int, optional): 是否翻轉影像,翻轉類型由此參數決定。
returnByteImage (bool, optional): 是否將處理過的影像作為位元組返回。
Returns:
bytes or np.array: 處理過的框架,格式取決於 returnByteImage 參數。
"""
if flip is not None:
frame = cv2.flip(frame, flip)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return self._process_frame(frame, gray, returnByteImage)
def _process_frame(self, frame, gray, returnByteImage=True):
"""由子類別實現的處理邏輯。
Args:
frame (np.array): 原始的影像。
gray (np.array): 灰階影像。
Returns:
bytes or np.array or None: 處理過的框架,具體返回類型取決於子類別的實現。
"""
pass
我想直接使用一個實際的影像分析案例來做說明會更好理解。我先創建一個類別- FrameProcessor 裡頭可以有多個處理單元,我以為處理影像的處理單元- NormalProcessor 與 GrayProcessor 為例。
首先映射的公開方法是為了讓用戶方便選擇要使用的處理單元,我們先暫時跳過。接著是兩個核心的函式:Process() 與 _process_frame()這兩個函式命名很接近,但細看你會發現兩者是有關連的。前面提到的影像分析流程記錄在Process 函式中,透過參數讓用戶選擇是否要將輸入影像進行翻轉,甚至輸出的結果是否轉換成位元組(提供網頁前端使用),這樣還沒有完,有沒有留意到:
return self._process_frame(frame, gray, returnByteImage)
這裡非常關鍵,目的是要讓子類別來去處理進一步的影像分析算法實做。那具體要怎麼做呢?我們來上範例代碼:
class NormalProcessor(FrameProcessor):
"""用於處理普通影像框的處理器類別。
Methods:
_process_frame: 實作父類別 FrameProcessor 的方法以處理影像。
"""
def _process_frame(self, frame, gray, returnByteImage=True):
"""實作影像處理。
Args:
frame (np.array): 原始影像框。
gray (np.array): 灰度影像框。
Returns:
bytes or np.array: 處理後的影像框,格式取決於 returnByteImage 參數。
"""
if returnByteImage:
_, jpeg = cv2.imencode('.jpg', frame)
return jpeg.tobytes()
else:
return frame
子類別就這麼簡單,透過塞入 FrameProcessor 宣告這個是子類別且繼承FrameProcessor 。這時又跑出第一個疑惑,既然是繼承,為什麼跟父類別一樣宣告_process_frame()?這是因為我們要複寫父類別的方法,展開細部的演算法細節。
如果用上面這個範例,可能會覺得何苦『多此一舉』,因此我們來試著加入更複雜一點的影像分析前處理。
class Landmark5Processor(FrameProcessor):
"""用於在影像中標示5點臉部特徵點的處理器類別。
Attributes:
predictor (object): 用於預測5點臉部特徵點的預測器。
detector (object): 用於臉部偵測的偵測器。
Methods:
_process_frame: 實作父類別 FrameProcessor 的方法以處理影像。
"""
def __init__(self):
# Initialize dlib's face detector model
self.predictor = dlib.shape_predictor('./shape_predictor_5_face_landmarks.dat')
self.detector = dlib.get_frontal_face_detector()
def _process_frame(self, frame, gray, returnByteImage=True):
"""實作影像處理。
Args:
frame (np.array): 原始影像
gray (np.array): 灰度影像
Returns:
bytes: 處理後,含有5點臉部特徵點的影像框(位元組)。
"""
rects = self.detector(gray, 0)
for rect in rects:
shape = self.predictor(gray, rect)
for i in range(0, 5):
cv2.circle(frame, (shape.part(i).x, shape.part(i).y), 3, (0, 0, 255), -1)
if returnByteImage:
_, jpeg = cv2.imencode('.jpg', frame)
return jpeg.tobytes()
else:
return frame
遵照這個格式你可以擴充很多複雜的算法分析進來,就像這個 Dlib 提供的五點臉部特徵點分析。
那既然都包成這樣,後續應該怎麼使用?
from frame_processor import FrameProcessor
# 使用 create_processor 方法創建實例
normalProcessor = FrameProcessor.CreateProcessor(FrameProcessor.ALLOWED_TYPES.NORMAL)
grayProcessor = FrameProcessor.CreateProcessor(FrameProcessor.ALLOWED_TYPES.GRAY)
fivePointsProcessor = FrameProcessor.CreateProcessor(FrameProcessor.ALLOWED_TYPES.FIVE_POINT_LANDMARK)
... 略
normalProcessor.Process(frame)
grayProcessor.Process(frame)
fivePointsProcessor .Process(frame)
就是這麼簡單。
代碼的維護也瞬間就變得很單純。如果說想要擴充更多的前置影像處理,就像詢問是否需要翻轉影像或是決定輸出的影像格式與種類,只要在 FrameProcess 這個基類(Base Class)中的 Process()定義跟宣告即可,記得是情況要反映到 _process_frame()。