要说车牌识别项目的最后一公里,很多人会以为就是把模型训练好、推理跑通就完事了。但真正落到实际使用场景——不管是停车场进出口、园区道闸,还是给客户演示——你会发现缺一个能看得见、点得动、结果清晰展示的界面,整个项目就始终停留在“命令行玩具”的阶段。这就是我写这第三篇的原因:基于YOLOV11的车牌识别模型已经在前两篇里完成了环境配置、模型训练和推理验证,这篇专门把UI前端页面这部分补完整。
这篇内容聚焦在UI层的完整实现,我会把页面功能拆分、界面布局设计、推理结果可视化、识别结果保存与历史记录管理这几块逐一讲清楚,并给出可以直接改造使用的代码结构。涉及的场景包括单张图片识别、视频文件识别、结果台账导出,以及针对小目标车牌识别结果做的界面优化。不论你是纯小白刚配好YOLOV11环境,还是已经在用YOLOV11训练自己的车牌数据集、想在推理结果展示上做得更完整,这篇都会有用。
1. 从模型到界面:车牌识别项目UI层到底在解决什么问题
1.1 前两篇做了哪些铺垫,UI层补上什么缺口
这个系列的前半部分,核心是把YOLOV11车牌识别模型的链路打通。从ultralytics环境配置开始,到数据集准备、模型训练、预测脚本执行,最终能拿到一张带边界框的标注图,或者在终端里打印出识别到的车牌号码。这一套流程跑通之后,模型本身确实已经能用了。
但问题出在“能用”和“好用”之间。命令行推理有四个明显的短板:
- 普通人不会用,也不愿意用黑框框去敲python命令
- 识别结果没有上下文,单张图只显示一个框,不知道置信度、位置、识别时间
- 图片、视频的批量处理没有统一入口,每次都要改脚本参数
- 结果无法沉淀,识别完就完了,没有台账,没有历史记录,没法追溯
UI层要解决的,就是这四件事。把模型推理能力封装成可视化操作界面,让一个非技术用户也能导入图片、点击识别、看到结果、保存记录。这是车牌识别项目从实验走向实际部署的必经一环。
1.2 UI技术选型:PySide6桌面端、Web前端还是混搭
车牌识别项目的UI实现方案,主流有三条路。
| 方案 | 技术栈 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 桌面GUI | PySide6 / PyQt + Qt Designer | 开发速度快,与Python推理代码同构,离线运行 | 界面风格偏传统,跨设备部署麻烦 | 单机版管理工具、演示系统 |
| Web前端 | Flask / FastAPI + HTML/JS + ECharts | 浏览器访问,支持远程访问,形态灵活 | 推理和前端跨语言,需要接口封装,开发量稍大 | 道闸系统管理端、多客户端访问 |
| 混合架构 | 桌面端做推理服务,Web端做展示 | 两者优势结合 | 架构复杂,通信调试成本高 | 中大型项目、团队协作 |
我这次选的是PySide6方案,原因很实际:整个项目已经是Python技术栈,YOLOV11推理代码直接import进来就能用,不需要额外起HTTP服务、不需要处理跨域、不需要考虑浏览器兼容性。对于单机版车牌识别工具来说,PySide6是性价比最高的选择。
不过UI结构我会按“前后端分离”的思路来写——界面层、业务控制层、模型推理层各自独立。这样以后哪怕要把界面换成Web,底层的推理接口也能原封不动复用。
1.3 前端页面功能清单与使用场景定义
先明确UI到底要做哪些页面、每个页面的使用场景是什么。我把整体规划为三页面结构:
- 主页面(识别工作台):图像/视频导入、识别触发、结果可视化、ROI放大显示、保存操作
- 历史记录页:识别记录表格、详情查看、CSV导出、记录清空
- 设置页:模型路径选择、置信度阈值、IoU阈值、保存目录设置
其中主页面是核心,覆盖95%的日常操作。使用场景很典型:一张车辆图片进来,点击识别,左侧显示原图,右侧显示识别结果卡片(车牌号、置信度、车牌坐标、抓拍时间),下方是当前识别任务的详细画布,框出车牌位置。整个过程不需要碰一行代码。
2. 搭建主窗口框架:环境准备、布局设计与界面风格
2.1 前端依赖安装与ultralytics环境兼容处理
PySide6的安装本身很简单,但和YOLOV11的ultralytics环境放在一起时,要注意几个隐藏的坑。
pip install PySide6 pip install opencv-python pip install pillow第一个坑是OpenCV版本冲突。 ultralytics框架自带opencv-python依赖,但如果你之前手动装过其他版本的opencv-contrib-python,两者可能会打架,导致imshow或图像编码时崩溃。建议统一用opencv-python,避免混装contrib版本。
第二个坑是numpy版本。PySide6的QImage像素操作依赖numpy数组的底层内存布局,YOLOV11推理输出也是numpy格式。如果numpy版本过旧,QImage转换会出现奇怪的错位或颜色通道反转。保险做法是把numpy升级到1.26以上。
第三个坑是Qt插件平台问题。在Linux服务器上跑PySide6,如果缺libegl1、libxkbcommon等系统库,启动时会报could not load the Qt platform plugin "xcb"。这个在Windows上问题不大,但如果你用的是WSL或者无桌面环境,必须提前装好:
sudo apt install libxkbcommon-x11-0 libegl1 libdbus-1-3 libgl12.2 主窗口布局:三区域结构的职责划分
主窗口我用的是QSplitter分区布局,总体分成三块:
- 左侧控制面板:图片选择、视频选择、识别按钮、保存按钮、阈值滑条
- 中间预览画布:显示原始图片或者视频帧,以及标注框
- 右侧结果面板:车牌号码展示卡片、置信度信息、识别时间、历史记录摘要
核心代码结构如下:
from PySide6.QtWidgets import QMainWindow, QSplitter, QWidget, QVBoxLayout, QHBoxLayout from PySide6.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("YOLOV11车牌识别系统") self.resize(1280, 800) # 主分割器:左侧控制区 + 中间画布区 + 右侧结果区 main_splitter = QSplitter(Qt.Horizontal) control_panel = self._build_control_panel() canvas_panel = self._build_canvas_panel() result_panel = self._build_result_panel() main_splitter.addWidget(control_panel) main_splitter.addWidget(canvas_panel) main_splitter.addWidget(result_panel) main_splitter.setStretchFactor(0, 1) main_splitter.setStretchFactor(1, 3) main_splitter.setStretchFactor(2, 2) self.setCentralWidget(main_splitter)这个三区域结构的核心设计理念是:操作入口固定在最左侧,识别主视图居中,结果信息聚合在右侧。用户在操作时视线呈Z字形流动,从左到右,操作效率很高。实际测试中,完整识别一辆车的操作路径只用两步:选图、点识别,结果3秒内出现在右侧。
2.3 用QSS快速实现“不算丑”的界面
很多技术人写的桌面工具界面都比较“原生”,按钮是灰底方块,字体是默认宋体,看着就没有想用的欲望。PySide6支持QSS(Qt样式表)自定义样式,和CSS语法几乎一致,花30分钟就能让界面质感上一个台阶。
style_sheet = """ QMainWindow { background-color: #f5f7fa; } QLabel#titleLabel { font-size: 20px; font-weight: bold; color: #2c3e50; padding: 12px; } QPushButton#primaryBtn { background-color: #3498db; color: white; border-radius: 6px; padding: 10px 20px; font-size: 14px; border: none; } QPushButton#primaryBtn:hover { background-color: #2980b9; } QPushButton#primaryBtn:pressed { background-color: #1f6da0; } QTableWidget { background-color: white; alternate-background-color: #f8f9fa; border: 1px solid #dce1e5; border-radius: 6px; } """ app.setStyleSheet(style_sheet)QSS有几个容易踩的坑:
- 选择器名称区分大小写,
#titlelabel和#titleLabel是两个完全不同的选择器 border: none不能省略,否则默认的凸起边框会破坏圆角效果- 属性写错不会报错,只是静默不生效。排查界面样式没变化时,先检查属性名拼写,比如
backgroud-color这种经典错误
界面看起来专业与否,往往细节决定一切。一个统一的圆角、一致的主色、清晰的间距,就能让用户觉得这个系统是“成品”而不是“半成品”。
3. 识别结果可视化:车牌号码、置信度与ROI放大显示
3.1 推理线程与界面线程分离,避免界面卡死
这是UI开发里最重要的一道坎。如果直接在按钮点击事件里调用模型推理,图片进来后界面会整个冻结,Windows上甚至会显示“未响应”,用户的第一反应就是程序死掉了。
原因在于Qt的GUI线程(主线程)负责所有界面绘制和事件响应,一旦主线程里跑了耗时计算(YOLOV11单张图片推理通常需要100-500ms,视频帧更多),事件循环就被阻塞了。
正确做法是使用QThread把推理放到子线程:
from PySide6.QtCore import QThread, Signal class InferenceThread(QThread): result_ready = Signal(dict) progress_update = Signal(int, int) # 当前帧, 总帧数 def __init__(self, model, image_path): super().__init__() self.model = model self.image_path = image_path def run(self): # 在子线程执行YOLOV11推理 results = self.model.predict( source=self.image_path, conf=0.5, verbose=False ) # 解析结果并发射信号 boxes = results[0].boxes if boxes is not None and len(boxes) > 0: for box in boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() conf = float(box.conf[0]) cls_id = int(box.cls[0]) # 这里车牌识别通常用自定义类别索引0 self.result_ready.emit({ "bbox": (x1, y1, x2, y2), "confidence": conf, "class_id": cls_id, "class_name": self.model.names[cls_id] })调用侧在主线程创建线程,连接信号,然后启动:
def on_start_inference(self): self.infer_thread = InferenceThread(self.model, self.current_image_path) self.infer_thread.result_ready.connect(self.display_result) self.infer_thread.progress_update.connect(self.update_progress) self.infer_thread.finished.connect(self.on_infer_finished) self.infer_thread.start()线程分离之后,推理过程中界面依然可以操作,按钮可以显示“识别中…”状态,用户也能看到实时进度。这是桌面UI最基本的体验保证。
3.2 结果卡片的信息组织:车牌号、置信度、坐标、抓取时间
识别结果不是只有一个车牌号。一张车辆图片经过YOLOV11推理,会返回边界框坐标、置信度、类别索引,这些信息在UI上都要有归宿。我设计了右侧的结果卡片,从上到下依次展示:
- 车牌号码:OCR识别出的纯文本形式,如“京A12345”,字体放大加粗
- 置信度:模型对检测框内存在目标的置信概率,用百分比展示
- 车牌位置:归一化坐标或像素坐标,便于追溯
- 识别时间:来自系统时间戳
其中OCR识别这一步,严格来说在YOLOV11检测出车牌区域之后还需要一个识别网络或者OCR库(比如PaddleOCR),但很多车牌数据集在标注时会把车牌字符作为类别标签来训练,这时模型输出本身就能映射到车牌号码。我在UI层面做了一个兼容设计:
- 如果模型类别名称就是车牌字符,直接拼接类别名称为车牌号
- 如果模型只输出“license_plate”类,则预留OCR接口,将裁剪的车牌区域传给OCR模块
class CarPlateResult: def __init__(self, bbox, confidence, plate_text): self.bbox = bbox self.confidence = confidence self.plate_text = plate_text self.timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")结果卡片的刷新逻辑用信号槽完成,emit一次,UI更新一次,主线程不会阻塞,也不会出现数据竞争。
3.3 针对小目标车牌:ROI放大显示功能
车牌识别中小目标是个高频痛点。一张广角拍摄的车辆图片,车牌可能只有几十个像素宽,即便模型能检测到,人眼在预览画布上也很难看清车牌区域,导致“检测出来了但看不出对不对”的尴尬。
我的解决方案是:在识别结果出现后,自动截取检测框对应的图像区域,放入右侧面板的ROI放大视图中。
from PySide6.QtGui import QImage, QPixmap import numpy as np def crop_plate_region(self, image, bbox): x1, y1, x2, y2 = [int(v) for v in bbox] # 边缘裁剪保护 h, w = image.shape[:2] x1 = max(0, x1) y1 = max(0, y1) x2 = min(w, x2) y2 = min(h, y2) crop = image[y1:y2, x1:x2] # 将OpenCV BGR格式转为RGB crop_rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB) h_img, w_img, _ = crop_rgb.shape bytes_per_line = 3 * w_img q_img = QImage(crop_rgb.data, w_img, h_img, bytes_per_line, QImage.Format_RGB888) # 固定宽度缩放并保持比例 target_width = 200 target_height = int(h_img * target_width / w_img) pixmap = QPixmap.fromImage(q_img) self.roi_label.setPixmap( pixmap.scaled(target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation) )ROI放大视图是UI细节里最能提升感知质量的设计。用户一眼就能看清车牌细节,不需要在整幅图里眯着眼睛找。
4. 图片/视频输入与结果保存:从导入到落盘全流程
4.1 图片选择、预览与单张识别流程
图片导入的交互标准是:点击选择按钮,弹出文件对话框,选定图片后立即在主画布区预览,同时载入基础信息(文件名、分辨率、大小)。
def on_select_image(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "图片文件 (*.jpg *.jpeg *.png *.bmp)" ) if not file_path: return self.current_image_path = file_path self.current_image = cv2.imread(file_path) # 显示预览 self.display_image(self.current_image) self.status_label.setText(f"已加载: {os.path.basename(file_path)}") # 清空上一次识别结果 self.clear_result_panel()识别按钮的处理逻辑也很直接:先校验模型是否已加载,再创建推理线程,启动识别。识别完成后,把标注框绘制到原图上,并在画布上更新。绘制边界框用OpenCV即可:
def draw_plate_annotation(self, image, bbox, plate_text): x1, y1, x2, y2 = [int(v) for v in bbox] color = (0, 255, 0) thickness = 2 cv2.rectangle(image, (x1, y1), (x2, y2), color, thickness) # 背景标签 label_bg = (0, 255, 0) font = cv2.FONT_HERSHEY_SIMPLEX (text_w, text_h), baseline = cv2.getTextSize(plate_text, font, 0.7, 2) cv2.rectangle(image, (x1, y1 - text_h - 10), (x1 + text_w + 6, y1), label_bg, -1) cv2.putText(image, plate_text, (x1 + 3, y1 - 5), font, 0.7, (0, 0, 0), 2)注意绘制中文车牌号时,OpenCV的putText不支持中文字符,会显示成乱码或问号。这里有两个方案:
- 方案A:只画英文/数字部分,中文单独放到图外的文本标签里显示
- 方案B:用PIL的ImageDraw绘制中文,再转回OpenCV格式
如果车牌号码是纯字母数字(很多省份车牌第一位是汉字省份简称),实战中推荐方案A最稳妥,UI结果卡片里正常显示中文,画布上的标注用ASCII字符或者直接省略中文。
4.2 视频文件识别与逐帧结果的状态管理
视频识别的复杂度和图片完全不同。单张图片只需要处理一次,视频是连续的帧序列,每一帧都要推理、标注、展示。UI上要有进度条,识别过程中可以暂停或取消。
视频推理线程的核心逻辑:
class VideoInferenceThread(QThread): frame_ready = Signal(int, np.ndarray) # 帧索引, 处理后的帧 progress = Signal(int, int) # 当前帧, 总帧数 finished = Signal(int) # 识别完成,返回检测到车辆的车牌数 def __init__(self, model, video_path, conf_threshold): super().__init__() self.model = model self.video_path = video_path self.conf_threshold = conf_threshold self._is_paused = False self._is_cancelled = False def run(self): cap = cv2.VideoCapture(self.video_path) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) current_frame = 0 detected_count = 0 while True: ret, frame = cap.read() if not ret: break # 检查暂停/取消 if self._is_cancelled: break while self._is_paused: self.msleep(50) # 推理 results = self.model.predict(frame, conf=self.conf_threshold, verbose=False) # 绘制 annotated_frame = results[0].plot() boxes = results[0].boxes if boxes is not None and len(boxes) > 0: detected_count += len(boxes) self.frame_ready.emit(current_frame, annotated_frame) current_frame += 1 self.progress.emit(current_frame, total_frames) cap.release() self.finished.emit(detected_count)视频识别的UI体验关键点:
- 进度条不能只显示百分比,还要显示“第 120 / 300 帧”这样的具体信息
- 识别完成的帧画面继续停留在画布上,方便用户拖拽查看任意一帧的结果
- 暂停/取消按钮要在推理线程设置标志位,不能用terminate强制杀线程,否则OpenCV的VideoCapture会留下未释放的资源
4.3 结果保存:原图、标注图与识别数据三位一体
识别完之后,用户最常做的事情就是把结果留下来。我在UI上设计了一个“保存识别结果”按钮,一次点击完成三类文件的落盘:
| 文件类型 | 文件名格式 | 内容 |
|---|---|---|
| 原图 | original_{timestamp}.jpg | 未经处理的输入图片 |
| 标注图 | annotated_{timestamp}.jpg | 绘制了车牌边界框的结果图 |
| 识别数据 | result_{timestamp}.json | 车牌号、置信度、坐标、识别时间等结构化数据 |
保存逻辑:
def save_all_results(self, plate_result, annotated_image, original_image): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") save_dir = self.save_dir # 1. 保存原图 original_path = os.path.join(save_dir, f"original_{timestamp}.jpg") cv2.imwrite(original_path, original_image) # 2. 保存标注图 annotated_path = os.path.join(save_dir, f"annotated_{timestamp}.jpg") cv2.imwrite(annotated_path, annotated_image) # 3. 保存识别数据 result_data = { "timestamp": plate_result.timestamp, "plate_text": plate_result.plate_text, "confidence": plate_result.confidence, "bbox": [int(v) for v in plate_result.bbox], "original_image": original_path, "annotated_image": annotated_path } result_path = os.path.join(save_dir, f"result_{timestamp}.json") with open(result_path, "w", encoding="utf-8") as f: json.dump(result_data, f, ensure_ascii=False, indent=2) QMessageBox.information(self, "保存成功", f"结果已保存至:\n{save_dir}")这里有个细节值得说:JSON文件写入务必加ensure_ascii=False,否则中文车牌号会保存成\u4eac这种Unicode转义序列,可读性很差。
5. 历史记录管理:识别台账、检索与数据导出
5.1 历史记录表结构设计与内存管理
一个车牌识别工具如果每次识别完就丢失数据,那它的价值就大打折扣。道闸场景需要知道“今天哪几个车牌过了闸”,停车场需要回溯“这个车牌几点进的场”。历史记录功能不是可选项,而是刚需。
我选用QTableWidget实现记录列表,表结构设计如下:
| 列 | 类型 | 说明 |
|---|---|---|
| 序号 | int | 自增ID |
| 车牌号码 | str | 识别出的车牌文本 |
| 置信度 | float | 模型置信度,保留两位小数 |
| 识别时间 | datetime | 精确到秒 |
| 图片文件 | str | 原图路径 |
| 备注 | str | 手动添加的额外信息 |
一个容易忽略的问题是内存管理。 QTableWidget每增加一行,就会保存一份单元格数据的引用。长时间运行后,表格数据越积越多,内存占用会不断上涨。我的处理策略有两条:
- 限制表格显示行数最多500行,超出部分滚动到独立JSON归档文件
- 行数据尽量只保存路径和文本索引,不要持有QPixmap的引用
def add_record_to_table(self, plate_result): row = self.table_widget.rowCount() self.table_widget.insertRow(row) self.table_widget.setItem(row, 0, QTableWidgetItem(str(row + 1))) self.table_widget.setItem(row, 1, QTableWidgetItem(plate_result.plate_text)) self.table_widget.setItem(row, 2, QTableWidgetItem(f"{plate_result.confidence:.2%}")) self.table_widget.setItem(row, 3, QTableWidgetItem(plate_result.timestamp)) self.table_widget.setItem(row, 4, QTableWidgetItem(os.path.basename(self.current_image_path))) # 行数限制 if self.table_widget.rowCount() > 500: self.archive_records()这段代码里我故意没有保存完整文件路径,只保存basename。完整路径信息写到了记录的JSON里,表格里只保留可读信息。这样界面轻快,数据又不丢。
5.2 CSV导出与数据复用
历史记录最终要能离开软件,交给Excel或者数据库使用。 CSV导出是兼容性最好的格式。
def export_to_csv(self): file_path, _ = QFileDialog.getSaveFileName( self, "导出历史记录", f"车牌识别记录_{datetime.now().strftime('%Y%m%d')}.csv", "CSV文件 (*.csv)" ) if not file_path: return with open(file_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["序号", "车牌号码", "置信度", "识别时间", "图片文件"]) for row in range(self.table_widget.rowCount()): row_data = [] for col in range(5): item = self.table_widget.item(row, col) row_data.append(item.text() if item else "") writer.writerow(row_data) QMessageBox.information(self, "导出成功", f"已导出 {self.table_widget.rowCount()} 条记录到:\n{file_path}")这里有个重点:CSV编码必须用utf-8-sig,而不是普通的utf-8。区别在于utf-8-sig会在文件开头写入BOM(字节序标记),Excel打开时才不会把车牌里的汉字识别成乱码。这个坑我踩过,不走BOM导出的CSV在Excel里中文全变成“鍖椾含”之类的乱码。
5.3 历史记录里的时间对齐与唯一性校验
实际场景里还有一个隐蔽的问题:同一张图片被多次识别时,会在表格里产生多条重复记录。这不是bug,但会给台账清洗带来麻烦。
我在表单里引入了图片Hash去重逻辑:
import hashlib def get_image_hash(self, image_path): with open(image_path, "rb") as f: img_hash = hashlib.md5(f.read()).hexdigest() return img_hash每次识别前计算图片MD5,如果在本次运行的历史记录里已经存在相同Hash且识别结果相同,就弹窗提醒“该图片已识别过,是否仍然保存”。这样一方面防止重复入库,另一方面也保留了用户的灵活性(比如调整阈值想重新识别)。
6. 界面实测过程:踩过的坑和针对性优化
6.1 界面卡顿:问题定位与解决链路
第一次完整跑通UI之后,我在视频识别的过程中遇到了明显的界面掉帧问题。视频推理标注后的帧在signal里直接传给主线程更新QLabel,结果主线程被大量图片显示操作占据,按钮点击响应变得很迟钝,进度条更新也不流畅。
排查思路是这样的:
- 先怀疑推理线程问题——但CPU占用核验后发现推理时间正常,不是瓶颈
- 然后用性能分析工具(Python的cProfile)定位到主线程的
setPixmap调用占用过高 - 定位真凶:每帧都把整幅图缩放显示到QLabel,高分辨率视频(1920x1080)频繁缩放非常耗时
解决方案是加入帧率节流与显示缓存:只做识别,标注图默认只更新到画布上;右侧结果面板只在检测到车牌时刷新;视频播放区域用固定输出尺寸(比如960x540)缓存显示,避免高频大图缩放。
def display_frame(self, frame_idx, annotated_frame): # 缩放显示缓存 display_height = 540 scale_ratio = display_height / annotated_frame.shape[0] if scale_ratio < 1.0: new_w = int(annotated_frame.shape[1] * scale_ratio) display_frame = cv2.resize(annotated_frame, (new_w, display_height)) else: display_frame = annotated_frame rgb_image = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb_image.shape q_image = QImage(rgb_image.data, w, h, 3 * w, QImage.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(q_image))优化之后,视频识别的UI交互恢复流畅,进度条平滑更新,暂停响应速度控制在100ms以内。
6.2 高DPI显示下的模糊问题
在Windows系统上,默认情况下Qt程序的DPI感知是关闭的。如果你的屏幕缩放是125%或150%,PySide6界面会出现字体发虚、图像边缘模糊的问题。
解决办法是在程序入口处启用高DPI缩放:
import os os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" # 或者在QApplication创建之前 from PySide6.QtCore import Qt QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough )注意这个设置必须在QApplication实例化之前完成,否则运行时不生效。在4K屏或者高分屏笔记本上,这一步能让界面清晰度提升一个档次。
6.3 中文显示相关的字体与编码整理
车牌号里的汉字(省份简称)在UI里有三处容易出问题:OpenCV绘制标注、CSV导出、QTableWidget显示。
QTableWidget显示中文默认没有问题,PySide6会跟随系统字体。但OpenCV的putText不支持中文,CSV导出需要utf-8-sig,这两处在前面已经提到。另外,如果程序打包成exe分发,还需要注意中文字体是否包含在打包资源里,否则在精简环境里中文会显示为方块。
一个稳妥的策略是:界面内所有中文文本统一走Qt的字体设置,标注图像内不要依赖OpenCV绘制中文,需要叠加中文信息时使用PIL的ImageDraw配合系统中文字体(如微软雅黑、文泉驿正黑)。
7. 基于这个UI继续扩展的方向
7.1 从单机界面到Web管理端
PySide6的界面适合单机管理和演示,但如果车牌识别系统要嵌入到停车场管理、门禁系统这类场景,多客户端同时访问的需求就会出现。此时可以将本项目的推理接口封装为Flask或FastAPI服务,前端用Vue或原生HTML实现,识别结果通过HTTP或WebSocket实时推送。
我在项目里预留了一个抽象层,把推理模块、识别解析模块、结果存储模块从UI中解耦出来。换成Web前端时,只需将result_ready信号替换为WebSocket消息,后端逻辑几乎可以原样复用。
7.2 摄像头实时流接入
现在的UI支持图片和视频文件,下一步可以扩展到实时摄像头流。接入方式不复杂:用OpenCV的VideoCapture打开摄像头或RTSP流,推理线程循环读取每一帧,界面只负责显示。
需要注意实时流的性能策略和视频文件不同:
- 建议将推理帧率控制在15FPS以下,避免模型推理跟不上输入
- 显示端做双缓冲,当前帧显示的同时后台线程继续推理下一帧
- 检测到车牌后至少连续跟踪3帧再上报结果,减少误报
从UI角度,实时流页面还需要新增“识别计数看板”:今日识别车辆数、平均置信度、最近识别列表。这些数据可以直接复用历史记录表。
7.3 多线程任务队列与批量识图
如果用户要一次导入50张车牌图片批量识别,单线程逐张跑会让用户等到崩溃。可以在现有UI基础上引入任务队列,按批次调度推理,并允许取消剩余任务。界面上增加批量识别按钮,导入多张图片后自动排队,进度条显示“第 12 / 50 张”,识别结束后统一在历史记录表中生成台账。
批量识图对UI的主要挑战在结果展示侧:不能把50张识别图全部堆在预览区,我目前的处理是只展示当前正在识别的图片,识别结果全部落盘并写进历史记录,用户后续在历史记录表格中根据文件名和结果筛选。
这次把UI前端完整做下来,我最大的体会是:UI层不是“给模型套个壳”那么简单,它决定了整个YOLOV11车牌识别项目的落地形态。模型推理再好,如果没有一个清晰的交互入口、结果展示和记录沉淀,项目永远只能停留在实验阶段。实际部署时,我把这套UI直接搭载到一个演示环境中,非技术背景的同事也能独立完成从图片导入到结果导出全流程。如果你正在做类似的车牌识别项目,建议先把识别链路跑通,再按上面这个页面结构去填充自己的功能,遇到看不清车牌的小目标问题,优先做ROI放大显示,那个功能在实测中帮用户解决了不少困惑。