介紹
本項(xiàng)目的目標(biāo)是設(shè)計(jì)一個(gè)基于樹莓派微電腦的自動(dòng)車牌識(shí)別系統(tǒng),用于控制停車場(chǎng)的道閘。
為什么?
我有一臺(tái)閑置的樹莓派,沒有參與任何項(xiàng)目,還有一臺(tái)攝像頭,以及一個(gè)潛在的問題點(diǎn)——辦公室停車場(chǎng)沒有自動(dòng)化的道閘控制系統(tǒng)。那么,為什么不利用這些設(shè)備來做一個(gè)有趣的項(xiàng)目呢?
本項(xiàng)目的目的并不是要?jiǎng)?chuàng)建一個(gè)生產(chǎn)就緒、穩(wěn)定且具有競(jìng)爭(zhēng)力的解決方案,而是要在使用有限設(shè)備解決實(shí)際問題的過程中享受樂趣,并創(chuàng)建一個(gè)可工作的產(chǎn)品。之后,還可以進(jìn)一步優(yōu)化這個(gè)解決方案,使其在輕量級(jí)邊緣設(shè)備上運(yùn)行得更快。
總體思路是使用樹莓派攝像頭以一定頻率拍照,處理圖像,檢測(cè)車牌,識(shí)別字符,并與數(shù)據(jù)庫(kù)中的允許車牌列表進(jìn)行比較。如果匹配,道閘將打開。
在基礎(chǔ)階段,我們將使用以下工具:
圖像源:樹莓派攝像頭模塊v2
車牌檢測(cè)器:使用PyTorch的YOLO v7
光學(xué)字符識(shí)別(OCR):EasyOCR
“數(shù)據(jù)庫(kù)”:Google表格中的表格
所有處理任務(wù)和計(jì)算都應(yīng)在樹莓派4B上本地執(zhí)行,解決方案必須能夠自主運(yùn)行。

基礎(chǔ)版本的簡(jiǎn)化流程圖
樹莓派將“近乎實(shí)時(shí)”地從攝像頭連續(xù)讀取幀。然后,使用在自定義數(shù)據(jù)集上微調(diào)的YOLOv7模型檢測(cè)車牌區(qū)域。之后,如果需要,對(duì)圖像進(jìn)行預(yù)處理,然后EasyOCR模型將從裁剪后的幀中檢測(cè)車牌號(hào)碼。然后檢查車牌字符串是否與“數(shù)據(jù)庫(kù)”中存儲(chǔ)的任何車牌匹配,并執(zhí)行相應(yīng)的操作。使用樹莓派的GPIO(通用輸入輸出)控制的繼電器開關(guān),我們可以連接停車道閘和任何附加負(fù)載,如燈光等。
GPIO引腳還允許連接輸入傳感器(如紅外、被動(dòng)紅外傳感器),并在檢測(cè)到車輛時(shí)觸發(fā)攝像頭。
再次強(qiáng)調(diào),這個(gè)問題可以通過多種方式解決,也許其中一些方式在某些要求和使用場(chǎng)景下會(huì)更高效、更簡(jiǎn)單。例如,所有繁重的處理都可以在云端進(jìn)行;我們可以使用基于GPU的邊緣設(shè)備;可以使用其他模型;使用ONNX、TFLite等進(jìn)行部署。但這個(gè)項(xiàng)目是作為一個(gè)實(shí)驗(yàn)來完成的,使用的是我目前擁有的設(shè)備,而且我并沒有尋找簡(jiǎn)單的方法。
環(huán)境設(shè)置
硬件設(shè)計(jì)
必要的硬件:
攝像頭:樹莓派攝像頭模塊v2
邊緣設(shè)備:樹莓派4 Model B 4GB
SD卡(>8GB)

開始時(shí)的設(shè)備:帶攝像頭模塊的樹莓派
附加設(shè)備:
散熱片、散熱風(fēng)扇
UPS
顯示器
繼電器/樹莓派HAT:用于控制外部設(shè)備(道閘)
攝像頭支架(“獨(dú)特的金屬線支架” )
*最好使用具有合適刷新時(shí)間的TFT或OLED屏幕,但當(dāng)時(shí)我只有這個(gè)。
進(jìn)行中的設(shè)備:帶散熱外殼的樹莓派 + 攝像頭模塊V2 + UPS + 電子墨水屏

設(shè)置步驟
由于我決定使用PyTorch構(gòu)建解決方案,而PyTorch只提供Arm 64位(aarch64)的pip包,因此我們需要安裝64位的操作系統(tǒng)(Debian版本:11——“Bullseye”)。最新的arm64樹莓派操作系統(tǒng)可以從官方網(wǎng)站下載,并通過rpi-imager安裝。
完成安裝后,應(yīng)該如下所示:

將SD卡插入樹莓派并啟動(dòng)后,應(yīng)進(jìn)行以下調(diào)整:
編輯/boot/config.txt文件以啟用攝像頭。
# This enables the extended features such as the camera.start_x=1# This needs to be at least 128M for the camera processing, if it's bigger you can just leave it as is.gpu_mem=128# You need to commment/remove the existing camera_auto_detect line since this causes issues with OpenCV/V4L2 capture.#camera_auto_detect=1
此外,你可能還需要通過raspi-config或GUI啟用I2C、SSH和VNC。
樹莓派配置設(shè)置如下:

安裝依賴
我使用了Python 3.9和3.10版本,據(jù)報(bào)道,在某些情況下3.11版本的速度明顯更快,但目前還沒有穩(wěn)定的PyTorch 3.11版本。
通過pip包管理器使用requirements.txt文件安裝所有必要的庫(kù)和模塊:
matplotlib>=3.2.2numpy>=1.18.5opencv-python==4.5.4.60opencv-contrib-python==4.5.4.60Pillow>=7.1.2PyYAML>=5.3.1requests>=2.23.0scipy>=1.4.1torch>=1.7.0,!=1.12.0torchvision>=0.8.1,!=0.13.0tqdm>=4.41.0protobuf<4.21.3tensorboard>=2.4.1pandas>=1.1.4seaborn>=0.11.0easyocr>=1.6.2
如果你是手動(dòng)安裝或在現(xiàn)有環(huán)境中實(shí)現(xiàn)(請(qǐng)不要這樣做 :) ),請(qǐng)注意當(dāng)前OpenCV版本存在一些問題,為了正常工作,我們需要安裝精確版本4.5.4.60。
你可以使用pip list檢查是否已正確安裝所有包:
好了,我們已經(jīng)設(shè)置了硬件和環(huán)境,現(xiàn)在可以開始編碼了。
軟件設(shè)計(jì)
圖像捕獲
對(duì)于圖像捕獲,我們將使用OpenCV來流式傳輸視頻幀,而不是使用標(biāo)準(zhǔn)的picamera庫(kù),因?yàn)樗?4位操作系統(tǒng)上不可用,而且速度較慢。OpenCV直接訪問/dev/video0設(shè)備來捕獲幀。
自定義的OpenCV攝像頭讀取簡(jiǎn)單包裝器:
classPiCamera(): def__init__(self, src=0, img_size=(640,480), fps=36, rotate_180=False): self.img_size = img_size self.fps = fps self.cap = cv2.VideoCapture(src) #self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) #self.cap.set(cv2.CAP_PROP_FPS, self.fps) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.img_size[0]) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.img_size[1]) self.rotate_180 = rotate_180 defrun(self): # read the frame ret, image = self.cap.read() ifself.rotate_180: image = cv2.rotate(image, cv2.ROTATE_180) ifnotret: raiseRuntimeError("failed to read frame") returnimage
這里我使用image = cv2.rotate(image, cv2.ROTATE_180)是因?yàn)閿z像頭是倒置安裝的。
緩沖區(qū)大小和FPS設(shè)置可以用于修復(fù)延遲并正確對(duì)齊幀流。但在我的情況下,它們不起作用,因?yàn)檫@取決于攝像頭制造商和用于讀取幀的后端。
一旦攝像頭捕獲到圖像,我們就需要處理它,從車牌檢測(cè)開始。
車牌檢測(cè)模塊
對(duì)于這個(gè)任務(wù),我將使用YOLOv7的預(yù)訓(xùn)練模型,并在自定義車牌數(shù)據(jù)集上進(jìn)行微調(diào)。
YOLOv7是目前在準(zhǔn)確性和速度方面最先進(jìn)的實(shí)時(shí)物體檢測(cè)算法。它在COCO數(shù)據(jù)集上進(jìn)行了預(yù)訓(xùn)練。
你可以在論文中閱讀有關(guān)該算法的詳細(xì)信息:YOLOv7:可訓(xùn)練的自由目標(biāo)集為實(shí)時(shí)目標(biāo)檢測(cè)器樹立了新的行業(yè)標(biāo)準(zhǔn)。https://arxiv.org/abs/2207.02696
YOLOv7基準(zhǔn)測(cè)試如下:
從官方倉(cāng)庫(kù)克隆YOLOv7倉(cāng)庫(kù)。
gitclonehttps://github.com/WongKinYiu/yolov7.gitcdyolov7
YOLO的要求已經(jīng)包含在我們之前安裝的項(xiàng)目要求中。
對(duì)于微調(diào),我將使用預(yù)訓(xùn)練的YOLOv7 tiny版本,圖像大小為640。
#Download pre-trained weights!wget https://github.com/WongKinYiu/yolov7/releases/download/v0.1/yolov7-tiny.pt
默認(rèn)預(yù)訓(xùn)練物體檢測(cè):默認(rèn)yolov7-tiny檢測(cè)到的物體,標(biāo)準(zhǔn)COCO數(shù)據(jù)集類別
車牌檢測(cè)模型訓(xùn)練
在自定義數(shù)據(jù)集上訓(xùn)練模型非常簡(jiǎn)單直接。
我將在Google Colab上使用一些不錯(cuò)的GPU進(jìn)行模型微調(diào)。
在開始之前,我們需要?jiǎng)?chuàng)建并標(biāo)注一個(gè)只包含一個(gè)車牌類別的適當(dāng)數(shù)據(jù)集。
我的數(shù)據(jù)集部分基于我自己的照片,部分來自AUTO.RIA車牌數(shù)據(jù)集(向這些了不起的家伙致敬?。?,總共約2000張圖像。https://nomeroff.net.ua/datasets/
使用roboflow服務(wù)以Yolo格式進(jìn)行標(biāo)注。
創(chuàng)建數(shù)據(jù)集.yaml文件:
train: dataset/trainval: dataset/valid# Classesnc: 1 # number of classesnames: ['numberplate'] # class names
訓(xùn)練模型
pythontrain.py --epochs25--workers8--device0--batch-size32--data data/numberplates.yaml --img640640--cfg cfg/training/yolov7.yaml --weights 'yolov7-tiny.pt' --name yolov7_tiny_numberplates --hyp data/hyp.scratch.tiny.yaml
對(duì)于基礎(chǔ)版本,我決定25個(gè)epoch應(yīng)該足夠了。25個(gè)epoch的模型訓(xùn)練結(jié)果:
推理:微調(diào)后的yolov7-tiny檢測(cè)到的物體,單一類別
對(duì)于項(xiàng)目的第一版來說似乎足夠了,以后可以根據(jù)實(shí)際應(yīng)用中發(fā)現(xiàn)的邊緣情況進(jìn)行更新。
為YOLOv7檢測(cè)器創(chuàng)建一個(gè)抽象的簡(jiǎn)單包裝器類:
classDetector(): def__init__(self, model_weights, img_size=640, device='cpu', half=False, trace=True, log_level='INFO', log_dir ='./logs/'): # Initialize self.model_weights = model_weights self.img_size = img_size self.device = torch.device(device) self.half = half # half = device.type != 'cpu' # half precision only supported on CUDA self.trace = trace # Convert model to Traced-model self.log_level = log_level ifself.log_level: self.num_log_level =getattr(logging, self.log_level.upper(),20)##Translate the log_level input string to one of the accepted values of the logging module, if no 20 - INFO self.log_dir = log_dir log_formatter = logging.Formatter("%(asctime)s %(message)s") logFile = self.log_dir +'detection.log' my_handler = RotatingFileHandler(logFile, mode='a', maxBytes=25*1024*1024, backupCount=10, encoding='utf-8', delay=False) my_handler.setFormatter(log_formatter) my_handler.setLevel(self.num_log_level) self.logger = logging.getLogger(__name__) self.logger.setLevel(self.num_log_level) self.logger.addHandler(my_handler) # Add path to yolo model as whenever load('weights.pt') is called, pytorch looks for model config in path enviornment variable (models/yolo) yolo_folder_dir =str(Path(__file__).parent.absolute()) +"\yolov7"# models folder path sys.path.insert(0, yolo_folder_dir) # Load model self.model = attempt_load(self.model_weights, map_location=self.device) # load FP32 model # Convert model to Traced-model ifself.trace: self.model = TracedModel(self.model, self.device, self.img_size) # if half: # model.half() # to FP16 # Get names and colors self.names = self.model.module.namesifhasattr(self.model,'module')elseself.model.names iflen(self.names) >1: self.colors = [[0,255,127]] + [[random.randint(0,255)for_inrange(3)]for_inself.names[1:]] else: self.colors = [[0,255,127]] sys.path.remove(yolo_folder_dir) defrun(self, inp_image, conf_thres=0.25): # Run Inference # Load data dataset = LoadImage(inp_image, device=self.device, half=self.half) t0 = time.time() self.file_name, self.img, self.im0 = dataset.preprocess() # Inference t1 = time.time() withtorch.no_grad(): # Calculating gradients would cause a GPU memory leak self.pred = self.model(self.img)[0] t2 = time.time() # Apply NMS self.pred = non_max_suppression(self.pred, conf_thres=conf_thres) t3 = time.time() # Process detections bbox =None # bounding boxe of detected object with max conf cropped_img =None # cropped detected object with max conf det_conf =None # confidence level for detected object with max conf self.det = self.pred[0] # pred[0] - NMX suppr returns list with 1 tensor per image; iflen(self.det): # Rescale boxes from img_size to im0 size self.det[:, :4] = scale_coords(self.img.shape[2:], self.det[:, :4], self.im0.shape).round() # Print results print_strng ="" forcinself.det[:, -1].unique(): n = (self.det[:, -1] == c).sum() # detections per class print_strng +=f"{n}{self.names[int(c)]}{'s'* (n >1)}" # add to string # Print time (inference + NMS) print( f'{print_strng}detected. ({(1E3* (t1 - t0)):.1f}ms)-Load data, ({(1E3* (t2 - t1)):.1f}ms)-Inference, ({(1E3* (t3 - t2)):.1f}ms)-NMS') # Write results to file if debug mode ifself.log_level: self.logger.debug( f'{self.file_name}{print_strng}detected. ({(1E3* (t1 - t0)):.1f}ms)-Load data, ({(1E3* (t2 - t1)):.1f}ms)-Inference, ({(1E3* (t3 - t2)):.1f}ms)-NMS') ifself.logger.getEffectiveLevel() ==10: # level 10 = debug gn = torch.tensor(self.im0.shape)[[1,0,1,0]] # normalization gain whwh for*xyxy, conf, clsinreversed(self.det): # save detections with bbox in xywh format xywh = (xyxy2xywh(torch.tensor(xyxy).view(1,4)) / gn).view(-1).tolist() # normalized xywh line = (int(cls), np.round(conf,3), *xywh) # label format self.logger.debug(f"{self.file_name}{('%g '*len(line)).rstrip() % line}") # Find detection with max confidence: indx = self.pred[0].argmax(0)[ 4] # pred[0] - NMX suppr returns list with 1 tensor per image; argmax(0)[4] - conf has indx 4 in [x1,y1,x2,y2,conf,cls] max_det = self.pred[0][indx] # Collect detected bounding boxe and corresponding cropped img bbox = max_det[:4] cropped_img = save_crop(max_det[:4], self.im0) cropped_img = cropped_img[:, :, ::-1]# # BGR to RGB det_conf = max_det[4:5] print(f'Detection total time:{time.time() - t0:.3f}s') return{'file_name': self.file_name,'orig_img': self.im0,'cropped_img': cropped_img,'bbox': bbox, 'det_conf': det_conf}
這里為了調(diào)試目的,我添加了將檢測(cè)數(shù)據(jù)記錄到文件的可能性,最多10個(gè)文件,每個(gè)文件25Mb,然后重寫。
對(duì)于當(dāng)前任務(wù),我需要檢測(cè)器只返回一個(gè)置信度最高的檢測(cè)結(jié)果。此外,檢測(cè)器輸出原始圖像、裁剪后的檢測(cè)區(qū)域及其對(duì)應(yīng)的邊界框、置信度分?jǐn)?shù),以及為每個(gè)圖像生成一個(gè)唯一名稱以便于調(diào)試。
車牌區(qū)域圖像預(yù)處理
一般來說,下一步是對(duì)圖像進(jìn)行特定的預(yù)處理(如RGB轉(zhuǎn)灰度、去噪、腐蝕+膨脹、閾值處理、直方圖均衡化等),以便進(jìn)行下一步的OCR。預(yù)處理在很大程度上取決于并針對(duì)具體的OCR解決方案和拍攝條件進(jìn)行調(diào)整。但由于我正在使用EasyOCR構(gòu)建基礎(chǔ)版本(之后應(yīng)該替換為自定義解決方案),我決定不深入進(jìn)行預(yù)處理,只進(jìn)行兩個(gè)通用的步驟——灰度轉(zhuǎn)換和使用投影輪廓法進(jìn)行傾斜校正。
這里我使用的是平面角度校正,但之后應(yīng)該更新為使用真實(shí)車牌角點(diǎn)檢測(cè)器進(jìn)行單應(yīng)性計(jì)算和透視變換的校正。
# Skew Correction (projection profile)def_find_score(arr, angle): data = rotate(arr, angle, reshape=False, order=0) hist = np.sum(data, axis=1) score = np.sum((hist[1:] - hist[:-1]) **2) returnhist, scoredef_find_angle(img, delta =0.5, limit =10): angles = np.arange(-limit, limit+delta, delta) scores = [] forangleinangles: hist, score = _find_score(img, angle) scores.append(score) best_score =max(scores) best_angle = angles[scores.index(best_score)] print(f'Best angle:{best_angle}') returnbest_angledefcorrect_skew(img): # correctskew best_angle =_find_angle(img) data = rotate(img, best_angle, reshape=False, order=0) returndata
即使對(duì)于這樣扭曲的圖像,僅進(jìn)行傾斜校正就足以讓EasyOCR以高置信度正確讀取車牌號(hào)碼。
經(jīng)過上述圖像處理步驟后,我們可以認(rèn)為圖像已經(jīng)足夠好,可以進(jìn)行識(shí)別了。
車牌識(shí)別(OCR)
對(duì)于基礎(chǔ)版本,我決定使用EasyOCR解決方案,因?yàn)樗子谑褂?、識(shí)別準(zhǔn)確,而且可能是我所知道的唯一比無聊的tesseract更好的替代方案。
使用EasyOCR進(jìn)行車牌識(shí)別的簡(jiǎn)單包裝器類:
classEasyOcr(): def__init__(self, lang = ['en'], allow_list ='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', min_size=50, log_level='INFO', log_dir ='./logs/'): self.reader = easyocr.Reader(lang, gpu=False) self.allow_list = allow_list self.min_size = min_size self.log_level = log_level ifself.log_level: self.num_log_level =getattr(logging, log_level.upper(), 20) ##Translate the log_level input string to one of the accepted values of the logging module, if no 20 - INFO self.log_dir = log_dir # Set logger log_formatter = logging.Formatter("%(asctime)s %(message)s") logFile = self.log_dir +'ocr.log' my_handler = RotatingFileHandler(logFile, mode='a', maxBytes=25*1024*1024, backupCount=10, encoding='utf-8', delay=False) my_handler.setFormatter(log_formatter) my_handler.setLevel(self.num_log_level) self.logger = logging.getLogger(__name__) self.logger.setLevel(self.num_log_level) self.logger.addHandler(my_handler) defrun(self, detect_result_dict): ifdetect_result_dict['cropped_img']isnotNone: t0 = time.time() img = detect_result_dict['cropped_img'] img = ocr_img_preprocess(img) file_name = detect_result_dict.get('file_name') ocr_result = self.reader.readtext(img, allowlist = self.allow_list, min_size=self.min_size) text = [x[1]forxinocr_result] confid = [x[2]forxinocr_result] text ="".join(text)iflen(text) >0elseNone confid = np.round(np.mean(confid),2)iflen(confid) >0elseNone t1 = time.time() print(f'Recognized number:{text}, conf.:{confid}.\nOCR total time:{(t1 - t0):.3f}s') ifself.log_level: # Write results to file if debug mode self.logger.debug(f'{file_name}Recognized number:{text}, conf.:{confid}, OCR total time:{(t1 - t0):.3f}s.') return{'text': text,'confid': confid} else: return{'text':None,'confid':None}
與檢測(cè)器類似,這里為了調(diào)試目的,也添加了將OCR數(shù)據(jù)記錄到文件的可能性。
識(shí)別模塊返回檢測(cè)到的字符串和置信度分?jǐn)?shù)。
驗(yàn)證與操作
在我們成功從檢測(cè)到的車牌中獲取到識(shí)別文本后,是時(shí)候進(jìn)行驗(yàn)證并采取一些行動(dòng)了。對(duì)于車牌驗(yàn)證步驟,最合乎邏輯的做法是使用一個(gè)由客戶更新的數(shù)據(jù)庫(kù),我們每次或每天讀取一次,并將列表本地存儲(chǔ)。對(duì)于當(dāng)前的基礎(chǔ)版本,我決定不設(shè)置數(shù)據(jù)庫(kù),以節(jié)省時(shí)間和金錢,因?yàn)檫@不是重點(diǎn)。我將使用Google表格作為示例。
“數(shù)據(jù)庫(kù)”
截至目前,還沒有配置操作步驟,只是顯示在允許列表中的車牌號(hào)碼檢查結(jié)果。但對(duì)于樹莓派來說,通過GPIO控制的繼電器開關(guān)操作任何負(fù)載都非常容易。
可視化
為了能夠舒適地監(jiān)控和調(diào)試解決方案,我添加了一個(gè)可視化模塊,用于處理車牌識(shí)別過程的顯示、保存輸入圖像、裁剪后的車牌區(qū)域和輸出結(jié)果圖像。此外,我還添加了一個(gè)函數(shù),用于在電子墨水屏上顯示車牌區(qū)域和識(shí)別文本。
目前,為了方便起見,圖像以壓縮的JPG格式存儲(chǔ)在日志文件夾中,數(shù)量限制為10800張,隨后進(jìn)行覆蓋(文件夾最大大小約為500Mb)。在生產(chǎn)解決方案中,可視化并不是必需的,用于調(diào)試的圖像最好存儲(chǔ)在NumPy ndarrays或二進(jìn)制字符串中。
classVisualize(): def__init__(self, im0, file_name, cropped_img=None, bbox=None, det_conf=None, ocr_num=None, ocr_conf=None, num_check_response=None, out_img_size=(720,1280), outp_orig_img_size =640, log_dir ='./logs/', save_jpg_qual =65, log_img_qnt_limit =10800): self.im0 = im0 self.input_img = im0.copy() self.file_name = file_name self.cropped_img = cropped_img self.bbox = bbox self.det_conf = det_conf self.ocr_num = ocr_num self.ocr_conf = ocr_conf self.num_check_response = num_check_response self.out_img_size = out_img_size self.save_jpg_qual = save_jpg_qual self.log_dir = log_dir self.imgs_log_dir = self.log_dir +'imgs/' os.makedirs(os.path.dirname(self.imgs_log_dir), exist_ok=True) self.crop_imgs_log_dir = self.log_dir +'imgs/crop/' os.makedirs(os.path.dirname(self.crop_imgs_log_dir), exist_ok=True) self.orig_imgs_log_dir = self.log_dir +'imgs/inp/' os.makedirs(os.path.dirname(self.orig_imgs_log_dir), exist_ok=True) self.log_img_qnt_limit = log_img_qnt_limit # Create blank image h, w = self.out_img_size self.img = np.zeros((h, w,3), np.uint8) self.img[:, :] = (255,255,255) # Draw bounding box on top the image if(self.bboxisnotNone)and(self.det_confisnotNone): label =f'{self.det_conf.item():.2f}' color = [0,255,127] plot_one_box(self.bbox, self.im0, label=label, color=color, line_thickness=3) # Resize img width to fit the plot, keep origin aspect ratio h0, w0 = im0.shape[:2] aspect = w0 / h0 ifaspect >1: # horizontal image new_w = outp_orig_img_size new_h = np.round(new_w / aspect).astype(int) elifaspect 1: ?# vertical image? ? ? ? ? ? new_h = outp_orig_img_size? ? ? ? ? ? new_w = np.round(new_h * aspect).astype(int)? ? ? ? else: ?# square image? ? ? ? ? ? new_h, new_w = outp_orig_img_size, outp_orig_img_size? ? ? ? self.im0 = cv2.resize(self.im0, (new_w, new_h), interpolation=cv2.INTER_AREA)? ? ? ? im0_h, im0_w = self.im0.shape[:2]? ? ? ? # Add original full image? ? ? ? im0_offset =?0? ? ? ? self.img[im0_offset:im0_h + im0_offset, im0_offset:im0_w + im0_offset] = self.im0? ? ? ? # Add cropped image with detected number bbox? ? ? ? if?self.cropped_img?is?not?None:? ? ? ? ? ? # Resize cropped img? ? ? ? ? ? target_width =?int((w - (im0_w + im0_offset)) /?3)? ? ? ? ? ? r = target_width / self.cropped_img.shape[1]? ? ? ? ? ? dim = (target_width,?int(self.cropped_img.shape[0] * r))? ? ? ? ? ? self.cropped_img = cv2.resize(self.cropped_img, dim, interpolation=cv2.INTER_AREA)? ? ? ? ? ? crop_h, crop_w = self.cropped_img.shape[:2]? ? ? ? ? ? # Add cropped img? ? ? ? ? ? crop_h_y1 =?int(h/7)? ? ? ? ? ? crop_w_x1 = im0_w + im0_offset +?int((w - (im0_w + im0_offset) - crop_w) /?2)? ? ? ? ? ? self.img[crop_h_y1:crop_h + crop_h_y1, crop_w_x1:crop_w + crop_w_x1] = self.cropped_img? ? ? ? ? ? # Add `_det` to filename? ? ? ? ? ? self.file_name = Path(self.file_name).stem +?"_det"?+ Path(self.file_name).suffix? ? ? ? # Add ocr recognized number? ? ? ? if?self.ocr_num?is?not?None:? ? ? ? ? ? label =?f"{self.ocr_num}?({self.ocr_conf})"? ? ? ? ? ? t_thickn =?2? # text font thickness in px? ? ? ? ? ? font = cv2.FONT_HERSHEY_SIMPLEX ?# font? ? ? ? ? ? fontScale =?1.05? ? ? ? ? ? # calculate position? ? ? ? ? ? text_size = cv2.getTextSize(label, font, fontScale=fontScale, thickness=t_thickn)[0]? ? ? ? ? ? w_center =?int((im0_w + im0_offset + w)/2)? ? ? ? ? ? ocr_w_x1 =?int(w_center - text_size[0]/2)? ? ? ? ? ? ocr_h_y1 =?int(crop_h_y1 + crop_h +?55)? ? ? ? ? ? org = (ocr_w_x1, ocr_h_y1) ?# position? ? ? ? ? ? # Plot text on img? ? ? ? ? ? cv2.putText(self.img, label, org, font, fontScale, ?color=(0,?0,?0), thickness=t_thickn, lineType=cv2.LINE_AA)? ? ? ? # Add number check response if in allowed list? ? ? ? if?self.num_check_response ==?'Allowed':? ? ? ? ? ? label =?"-=Allowed=-"? ? ? ? ? ? fontColor = (0,255,0)? ? ? ? else:? ? ? ? ? ? label =?"-=Prohibited!=-"? ? ? ? ? ? fontColor = (0,0,255)? ? ? ? t_thickn =?2? # text font thickness in px? ? ? ? font = cv2.FONT_HERSHEY_SIMPLEX ?# font? ? ? ? fontScale =?1.05? ? ? ? # calculate position? ? ? ? text_size = cv2.getTextSize(label, font, fontScale=fontScale, thickness=t_thickn)[0]? ? ? ? w_center =?int((im0_w + im0_offset + w) /?2)? ? ? ? response_w_x1 =?int(w_center - text_size[0] /?2)? ? ? ? response_h_y1 =?int(h*3/7)?#TBD? ? ? ? org = (response_w_x1, response_h_y1) ?# position? ? ? ? # Plot text on img? ? ? ? cv2.putText(self.img, label, org, font, fontScale, color=fontColor, thickness=t_thickn, lineType=cv2.LINE_AA)? ? def?show(self):? ? ? ? # Show the image? ? ? ? cv2.imshow('image', self.img)? ? def?save(self):? ? ? ? # Remove oldest file if reach quantity limit? ? ? ? if?self.get_dir_file_quantity(self.imgs_log_dir) > self.log_img_qnt_limit: oldest_file =sorted([self.imgs_log_dir+fforfinos.listdir(self.imgs_log_dir)])[ 0] # , key=os.path.getctime os.remove(oldest_file) # Write compressed jpeg with results cv2.imwrite(f"{self.imgs_log_dir}{self.file_name}", self.img, [int(cv2.IMWRITE_JPEG_QUALITY), self.save_jpg_qual]) # TBD Write in byte string format defsave_input(self): ifself.input_imgisnotNone: # Remove oldest file if reach quantity limit ifself.get_dir_file_quantity(self.orig_imgs_log_dir) > self.log_img_qnt_limit: oldest_file =sorted([self.orig_imgs_log_dir+fforfinos.listdir(self.orig_imgs_log_dir)])[ 0] # , key=os.path.getctime os.remove(oldest_file) # Write compressed jpeg with results cv2.imwrite(f"{self.orig_imgs_log_dir}orig_inp_{self.file_name}", self.input_img) # TBD Write in byte string format defsave_crop(self): ifself.cropped_imgisnotNone: # Remove oldest file if reach quantity limit ifself.get_dir_file_quantity(self.crop_imgs_log_dir) > self.log_img_qnt_limit: oldest_file =sorted([self.crop_imgs_log_dir+fforfinos.listdir(self.crop_imgs_log_dir)])[ 0] # , key=os.path.getctime os.remove(oldest_file) # Write compressed jpeg with results cv2.imwrite(f"{self.crop_imgs_log_dir}crop_{self.file_name}", self.cropped_img) # TBD Write in byte string format # Display img on e-ink display 176*264. defdisplay(self): # Create blank image disp_img = np.zeros((epd2in7.EPD_WIDTH, epd2in7.EPD_HEIGHT,3), np.uint8) disp_img[:, :] = (255,255,255) ifself.cropped_imgisnotNone: # Add cropped number crop_resized = cv2.resize(self.cropped_img, (epd2in7.EPD_HEIGHT-4,85), interpolation=cv2.INTER_AREA) crop_resized_h, crop_resized_w = crop_resized.shape[:2] crop_w_x1 =int(epd2in7.EPD_HEIGHT/2- crop_resized_w/2) disp_img[2:crop_resized_h+2, crop_w_x1:crop_resized_w+crop_w_x1] = crop_resized ifself.ocr_numisnotNone: # Add recognized label label =f"{self.ocr_num}({self.ocr_conf})" t_thickn =2 # text font thickness in px font = cv2.FONT_HERSHEY_SIMPLEX # font fontScale =0.8 text_size = cv2.getTextSize(label, font, fontScale=fontScale, thickness=t_thickn)[0] ocr_w_x1 =int(epd2in7.EPD_HEIGHT /2- text_size[0] /2) ocr_h_y1 =int(crop_resized_h/2+2+ epd2in7.EPD_WIDTH/2) # Plot text on img cv2.putText(disp_img, label, (ocr_w_x1, ocr_h_y1), font, fontScale, color=(0,0,0), thickness=t_thickn, lineType=cv2.LINE_AA) Himage = cv2.resize(disp_img, (epd2in7.EPD_HEIGHT, epd2in7.EPD_WIDTH), interpolation=cv2.INTER_AREA) print(f"###Himage:{Himage.shape}") # convert to PIL format Himage = Image.fromarray(Himage) tic = time.perf_counter() epd = epd2in7.EPD()# get the display epd.init() # initialize the display epd.Clear(0xFF) # clear the display toc = time.perf_counter() print(f"Init, clean display -{toc - tic:0.4f}seconds") tic = time.perf_counter() epd.display(epd.getbuffer(Himage)) toc = time.perf_counter() print(f"Display image -{toc - tic:0.4f}seconds") epd.sleep()# Power off display @staticmethod defget_dir_file_quantity(dir_path): list_of_files = os.listdir(dir_path) returnlen(list_of_files)
演示
測(cè)試解決方案
讓我們測(cè)試一下我們現(xiàn)在已經(jīng)完成的內(nèi)容。在靜態(tài)圖像上的檢測(cè)和識(shí)別流程:
從手機(jī)上傳的圖像結(jié)果。
使用設(shè)備攝像頭在街道上進(jìn)行端到端解決方案測(cè)試:
如我們所見,這里傾斜校正派上了用場(chǎng)。
性能
在當(dāng)前配置下,檢測(cè)大約需要700..800ms,OCR步驟大約需要900..1200ms,平均FPS約為0.4..0.5
雖然這樣的幀率值對(duì)于當(dāng)前的停車道閘自動(dòng)化項(xiàng)目來說并不關(guān)鍵,但顯然還有很大的改進(jìn)空間。
從htop我們可以看到,CPU利用率接近滿負(fù)荷:
所有測(cè)試都是在樹莓派操作系統(tǒng)的默認(rèn)設(shè)置下進(jìn)行的。如果你禁用UI和所有其他默認(rèn)啟用的后臺(tái)服務(wù),性能將更加穩(wěn)定和高效。
額外收獲
事實(shí)證明,我們的檢測(cè)器模塊即使沒有任何額外的調(diào)整,也能完美地檢測(cè)樂高汽車的車牌。
因此,有了樹莓派Build Hat和我從兒子那里借來的樂高積木,我決定搭建自己的停車道閘,并在“真實(shí)”條件下進(jìn)行完整的端到端測(cè)試。
基于樂高Build Hat專有庫(kù)的簡(jiǎn)單操作模塊包裝器:
classAction(): def__init__(self): self.motor = Motor('A') self.motor.set_default_speed(25) self.matrix = Matrix('B') self.ok_color = [[(6,10)forxinrange(3)]foryinrange(3)] self.nok_color = [[(9,10)forxinrange(3)]foryinrange(3)] self.matrix.set_transition(2)#fade-in/out self.matrix.set_pixel((1,1), ("blue",10)) def_handle_motor(self, speed, pos, apos): print("Motor:", speed, pos, apos) defrun(self, action_status): whileTrue: ifaction_status[0] =='Allowed': self.matrix.set_pixels(self.ok_color) time.sleep(1) self.motor.run_for_degrees(-90, blocking=False) time.sleep(5) self.motor.run_for_degrees(90, blocking=False) time.sleep(1) elifaction_status[0] =='Prohibited': self.matrix.set_pixels(self.nok_color) time.sleep(3) else: self.matrix.clear() self.matrix.set_pixel((1,1), ("blue",10)) time.sleep(1) self.matrix.set_pixel((1,1), (0,10)) time.sleep(1)
我在一個(gè)并行線程中運(yùn)行這個(gè)模塊,當(dāng)檢測(cè)到車牌且action_status發(fā)生變化時(shí),從主程序中觸發(fā)操作。

“弗蘭肯斯坦的怪物”——樹莓派 + UPS + 攝像頭v2 + 電子墨水屏 + 帶有連接的樂高電機(jī)和LED矩陣的Build HAT。
我將其中一個(gè)樂高車牌號(hào)碼添加到了Google表格“數(shù)據(jù)庫(kù)”中,現(xiàn)在我們可以將所有部分組合在一起并運(yùn)行它:
“真實(shí)”自動(dòng)化停車道閘控制系統(tǒng)的端到端演示

最終思考
總的來說,我們已經(jīng)成功實(shí)現(xiàn)了使用樹莓派進(jìn)行自動(dòng)車牌識(shí)別以控制停車道閘的完全功能系統(tǒng)。
需要強(qiáng)調(diào)的問題之一是——由于處理速度較慢,我們可能會(huì)遇到圖像延遲,因?yàn)閿z像頭有自己的緩沖區(qū),而我們以較慢的速度抓取圖像,即使場(chǎng)景已經(jīng)改變,一段時(shí)間內(nèi)我們?nèi)匀粡木彌_區(qū)中讀取“舊”幀。對(duì)于當(dāng)前的使用案例來說,這并不是非常關(guān)鍵,但為了改進(jìn)它,我添加了幀跳過功能,間隔大約等于我們的總處理時(shí)間。這樣可以更快地讀取幀并清理緩沖區(qū),同時(shí)也減輕了CPU的負(fù)載,因?yàn)槲覀儾粫?huì)處理每一幀。但是,如果我們需要近乎實(shí)時(shí)的流暢圖像流而不出現(xiàn)延遲,最好的選擇是將攝像頭讀取設(shè)置為一個(gè)單獨(dú)的并行線程,該線程將以最大速度從緩沖區(qū)中讀取幀,而我們的主程序只在需要時(shí)從該進(jìn)程中抓取幀。然而,需要注意的是,在Python中,多線程并不是真正的多進(jìn)程,而是一種模擬,它有助于從架構(gòu)的角度更清晰地組織和運(yùn)行你的代碼。
后續(xù)步驟
OCR:加速OCR,因?yàn)樗钱?dāng)前的瓶頸。我傾向于開發(fā)一個(gè)自定義的小型基于RNN的模型。如果時(shí)間不是問題,而你只需要準(zhǔn)確性——你可以嘗試在EasyOCR中使用不同的模型并進(jìn)行微調(diào)。或者你可以嘗試其他解決方案,如WPOD-NET。此外,提高識(shí)別質(zhì)量的一個(gè)重要點(diǎn)是——針對(duì)具體的使用案例(攝像頭位置、光照條件等)調(diào)整圖像預(yù)處理。
檢測(cè)器:為了加速,我們可以使用更小的幀大小——如果攝像頭應(yīng)該只對(duì)近處的車輛工作,就不需要高分辨率的圖像。另一個(gè)選項(xiàng)是,如果攝像頭和車輛的可能位置大致固定,我們可以只抓取車牌預(yù)期出現(xiàn)的區(qū)域,而不是整個(gè)幀。
對(duì)于這兩個(gè)模型,我們之后可以使用遷移學(xué)習(xí)、量化、剪枝和其他方法,使其在邊緣設(shè)備上更輕量、更快。
但無論如何,如果實(shí)時(shí)處理是關(guān)鍵(顯然對(duì)于自動(dòng)化停車道閘案例來說不是),沒有配備張量核心的設(shè)備是無法實(shí)現(xiàn)的。在僅配備CPU的設(shè)備上,速度和質(zhì)量之間總是需要權(quán)衡。
另一個(gè)改進(jìn)選項(xiàng)是——對(duì)于當(dāng)前案例來說,沒有必要24/7讓CPU全速運(yùn)行,攝像頭可以在車輛接近時(shí)通過PIR或紅外傳感器觸發(fā)。
我將在下一次迭代中嘗試實(shí)現(xiàn)的最后一點(diǎn)是——將解決方案切換到微服務(wù),并實(shí)現(xiàn)生產(chǎn)者-消費(fèi)者數(shù)據(jù)流模式。
好了,感謝你閱讀這篇關(guān)于項(xiàng)目實(shí)施經(jīng)驗(yàn)的冗長(zhǎng)而枯燥的描述。
原文地址:
https://medium.com/@alexey.yeryomenko/automatic-number-plate-recognition-with-raspberry-pi-e1ac8a804c79
-
車牌識(shí)別系統(tǒng)
+關(guān)注
關(guān)注
0文章
16瀏覽量
9630 -
樹莓派
+關(guān)注
關(guān)注
121文章
2009瀏覽量
107482
發(fā)布評(píng)論請(qǐng)先 登錄
【TL6748 DSP申請(qǐng)】基于DSP的車牌識(shí)別系統(tǒng)
怎么用FPGA做車牌識(shí)別系統(tǒng)?
基于labview vision的機(jī)動(dòng)車車牌識(shí)別系統(tǒng)
【Rico Board申請(qǐng)】基于SoC的車牌識(shí)別系統(tǒng)
【MediaTek X20開發(fā)板申請(qǐng)】小區(qū)車牌自動(dòng)識(shí)別系統(tǒng)
【HarmonyOS HiSpark AI Camera】車牌識(shí)別系統(tǒng)
怎么實(shí)現(xiàn)基于MATLAB的車牌識(shí)別系統(tǒng)的設(shè)計(jì)?
車牌識(shí)別系統(tǒng)的設(shè)計(jì)與實(shí)現(xiàn)
基于MATLAB的車牌識(shí)別系統(tǒng)的研究

車牌識(shí)別技術(shù)的發(fā)展及意義_車牌識(shí)別系統(tǒng)原理介紹

項(xiàng)目分享|基于ELF 1開發(fā)板的車牌識(shí)別系統(tǒng)

車牌識(shí)別新花樣:樹莓派打造智能車牌監(jiān)控系統(tǒng)!

評(píng)論