前言

ok现在是2025.2.22 0:02,来写一下使用pyqt6进行图形界面开发的代码,主要就是备份一下代码在博客里面,其他的也没有啥说的,展示:

跳转

  1. 文件夹结构
  2. gui.py
  3. detector.py
  4. main.py
  5. 遇到的问题

文件夹结构

设置的文件夹结构如下

1
2
3
4
5
6
7
-project
---datasets
---models
---src
-----gui.py
-----detector.py
-----main.py

gui.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
import sys
import os
import time
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLabel,
QFileDialog,
QComboBox,
QMessageBox,
QListWidget,
QProgressDialog,
QProgressBar,
QFrame
)
from PyQt6.QtGui import QPixmap, QImage
from PyQt6.QtCore import Qt, QThread, pyqtSignal
import cv2
from detector import YOLODetector
import json
import shutil

class CameraCapture: # 摄像头工具
def __init__(self, camera_id=0, width=640, height=480):
self.camera_id = camera_id
self.width = width
self.height = height
self.cap = None

def open(self):
self.cap = cv2.VideoCapture(self.camera_id, cv2.CAP_DSHOW)
if self.cap.isOpened():
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
return self.cap.isOpened()

def is_opened(self):
return self.cap is not None and self.cap.isOpened()

def read(self):
if self.cap and self.cap.isOpened():
return self.cap.read()
return False, None

def release(self):
if self.cap:
self.cap.release()
self.cap = None

class CameraProcessorThread(QThread): # 摄像头处理线程
update_frame = pyqtSignal(QPixmap, dict) # 定义信号用于发送更新后的帧

def __init__(self, detector):
super().__init__()
self.detector = detector # 目标检测器
self.running = True # 线程运行标志
self.camera = CameraCapture() # 使用工具类打开摄像头

def run(self): # 线程运行方法
if not self.camera.open():
return

while self.running and self.camera.is_opened(): # 当线程运行且摄像头打开时
ret, frame = self.camera.read() # 读取帧
if not ret:
break # 如果无法获取帧,退出循环

boxes, self.stats = self.detector.detect(frame) # 执行目标检测
self.detector._draw_boxes(frame, boxes) # 绘制检测框

rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 将OpenCV的BGR格式转换为RGB格式
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888) # 转换为QImage
pixmap = QPixmap.fromImage(qimg) # 转换为QPixmap
self.update_frame.emit(pixmap, self.stats) # 发送更新后的帧
self.msleep(10) # 线程休眠10毫秒

self.camera.release() # 释放摄像头资源

def stop(self):
self.running = False # 停止线程
self.wait() # 等待线程完全停止


class MainWindow(QMainWindow): # 主窗口类
def __init__(self):
super().__init__()
self.setWindowTitle("电子设备检测系统") # 设置窗口标题
self.setGeometry(100, 100, 1200, 650) # 设置窗口位置和大小

self.detector = YOLODetector("models/best.pt") # 初始化YOLO检测器
self.current_file = None # 当前处理的文件
self.processor = None # 视频处理线程
self.progress_dialog = None # 进度对话框
self.selected_files = [] # 选中的文件列表
self.custom_save_path = None # 自定义保存路径
self.video_cache = {} # 视频缓存
self.is_processing = False # 处理状态标志
self.cache_dir_delete = None # 自定义保存路径


main_widget = QWidget() # 主窗口部件
self.layout = QVBoxLayout() # 主布局

ctrl_layout = QHBoxLayout() # 控件布局
self.btn_upload = QPushButton("上传文件") # 上传文件按钮
self.btn_upload.clicked.connect(self.upload_file) # 绑定上传文件方法
self.btn_camera = QPushButton("打开摄像头") # 打开摄像头按钮
self.btn_camera.clicked.connect(self.toggle_camera) # 绑定切换摄像头方法
self.class_select = QComboBox() # 类别选择下拉框
self.class_select.addItems(["all"] + list(self.detector.class_names.values())) # 添加类别选项
self.class_select.currentTextChanged.connect(self.update_class_filter) # 绑定类别过滤更新方法
self.btn_export = QPushButton("导出结果") # 导出结果按钮
self.btn_export.clicked.connect(self.export_results) # 绑定导出结果方法
self.btn_select_export_path = QPushButton("选择导出路径") # 选择导出路径按钮
self.btn_select_export_path.clicked.connect(self.select_export_path) # 绑定选择导出路径方法


ctrl_layout.addWidget(self.btn_upload) # 添加上传文件按钮
ctrl_layout.addWidget(self.btn_camera) # 添加打开摄像头按钮
ctrl_layout.addWidget(self.class_select) # 添加类别选择下拉框
ctrl_layout.addWidget(self.btn_export) # 添加导出结果按钮
ctrl_layout.addWidget(self.btn_select_export_path) # 添加选择导出路径按钮


self.file_list = QListWidget() # 文件列表
self.file_list.itemDoubleClicked.connect(self.on_file_double_clicked) # 绑定文件双击事件

self.original_image_label = QLabel("原始图像/视频") # 原始图像显示标签
self.original_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # 设置居中对齐
self.original_image_label.setMinimumSize(600, 480) # 设置最小尺寸

self.annotated_image_label = QLabel("标注后的图像/视频/摄像头") # 标注后图像显示标签
self.annotated_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # 设置居中对齐
self.annotated_image_label.setMinimumSize(600, 480) # 设置最小尺寸

# 添加分割线
self.splitter = QFrame()
self.splitter.setObjectName("splitter")
self.splitter.setFrameShape(QFrame.Shape.VLine) # 垂直线
self.splitter.setFrameShadow(QFrame.Shadow.Sunken)
self.splitter.setStyleSheet("background-color: #F5F5F5; border: 1px dashed #333333;")


display_layout = QHBoxLayout() # 图像显示布局
display_layout.addWidget(self.original_image_label) # 添加原始图像显示标签
display_layout.addWidget(self.splitter)
display_layout.addWidget(self.annotated_image_label) # 添加标注后图像显示标签

self.progress_bar = QProgressBar() # 进度条
self.progress_bar.setRange(0, 100) # 设置进度条范围
self.progress_bar.hide() # 隐藏进度条

self.statistics_label = QLabel("目标统计:") # 统计信息标签

self.layout.addLayout(ctrl_layout) # 添加控件布局
self.layout.addWidget(self.file_list) # 添加文件列表
self.layout.addLayout(display_layout) # 添加图像显示布局
self.layout.addWidget(self.progress_bar) # 添加进度条
self.layout.addWidget(self.statistics_label) # 添加到统计信息标签
main_widget.setLayout(self.layout) # 设置主窗口布局
self.setCentralWidget(main_widget) # 设置主窗口部件


self.camera_thread = CameraProcessorThread(self.detector) # 初始化摄像头线程
self.camera_thread.update_frame.connect(self.update_camera_ui) # 绑定摄像头更新方法

# 美化样式
self.load_styles()


def load_styles(self):
# 获取当前脚本所在目录
current_dir = os.path.dirname(os.path.abspath(__file__))
style_file_path = os.path.join(current_dir, "style.qss")

# 检查样式表文件是否存在
if not os.path.exists(style_file_path):
print("样式表文件不存在")
return

# 读取样式表文件内容
with open(style_file_path, "r", encoding="utf-8") as f:
style_sheet = f.read()

# 应用样式表
self.setStyleSheet(style_sheet)
print("样式表加载成功")

def upload_file(self): # 上传文件方法
files, _ = QFileDialog.getOpenFileNames( # 打开文件对话框,可多选
self, "选择文件", "", "媒体文件 (*.jpg *.png *.mp4)"
)
if not files:
return # 如果没有选择文件,退出

for path in files: # 遍历选中的文件路径
if path not in self.selected_files:
self.selected_files.append(path) # 添加到文件列表
self.file_list.addItem(os.path.basename(path)) # 显示文件名

def on_file_double_clicked(self, item): # 文件双击事件方法
self.stop_video_threads() # 停止所有视频线程
self.image_label_clear()
self.current_file = None # 重置当前文件
if self.camera_thread.isRunning(): # 若摄像头运行,停止摄像头
self.camera_thread.stop() # 停止线程
self.camera_thread.wait() # 等待线程结束
self.btn_camera.setText("打开摄像头") # 更新按钮文本
QMessageBox.information(self, "关闭摄像头","摄像头已关闭")
file_name = item.text() # 获取双击的文件名
self.selected_class = self.class_select.currentText() # 获取当前类别选择
for path in self.selected_files: # 遍历文件列表
if os.path.basename(path) == file_name: # 找到对应的文件路径
self.current_file = path
# 根据文件类型决定是否显示进度条
if path.lower().endswith('.mp4'):
self.progress_bar.show()
else:
self.progress_bar.hide()
self.process_file(path) # 调用文件处理方法
break

def update_class_filter(self, selected_class): # 更新类别过滤方法
self.detector.set_class_filter(selected_class) # 设置检测器的类别过滤

def process_file(self, path): # 文件处理方法
if path.lower().endswith((".jpg", ".png")): # 如果是图像文件 # 图像文件的类别选择在detector.py内实现
frame, stats = self.detector.process_image(path) # 处理图像
if frame is None:
QMessageBox.warning(self, "错误", "无法加载检测图像,请检查文件路径或文件完整性") # 如果无法加载处理后的图像,弹出警告
return

original_frame = cv2.imread(path) # 读取原始图像
if original_frame is None:
QMessageBox.warning(self, "错误", "无法加载原始图像,请检查文件路径或文件完整性") # 如果无法加载原始图像,弹出警告
return

self._display_frame(self.original_image_label, original_frame) # 显示原始图像
self._display_frame(self.annotated_image_label, frame) # 显示标注后的图像

self.update_statistics(stats) # 更新统计信息

elif path.lower().endswith((".mp4")): # 如果是视频文件
self.process_video(path) # 调用视频处理方法

def _display_frame(self, label, frame): # 显示图像方法
if frame is None:
return # 如果图像为空,退出

rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 将OpenCV的BGR格式转换为RGB格式
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888) # 转换为QImage
pixmap = QPixmap.fromImage(qimg) # 转换为QPixmap
label.setPixmap(pixmap.scaled(600, 480, Qt.AspectRatioMode.KeepAspectRatio)) # 显示图像,保持宽高比

def process_video(self, path): # 视频处理方法
# print("Entering process_video")

if self.is_processing:
return # 正在处理时直接返回

"""防止过多次连击文件造成缓存视频时进度条错误
if self.processor and self.processor.isRunning():
self.processor.stop()
self.processor.wait()
self.processor = None
if self.progress_dialog and self.progress_dialog.isVisible():
self.progress_dialog.close()
"""

self.image_label_clear()
self.current_file = path # 设置为当前处理的视频路径
self.progress_bar.hide() # 隐藏视频播放进度条
self.selected_class = self.class_select.currentText() # 获取当前类别选择


# 根据文件源类型创建缓存文件路径
if isinstance(path, int): # 如果是摄像头
cache_dir = os.path.join(os.getcwd(), ".cache")
print(f"缓存目录为{cache_dir}")
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
cache_file = os.path.join(cache_dir, f"camera_{int(time.time())}.avi")
else:
cache_dir = os.path.join(os.path.dirname(path), ".cache")
print(f"缓存目录为{cache_dir}")
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
self.cache_dir_delete = cache_dir
cache_file = os.path.join(cache_dir, f"processed_{self.selected_class}_{os.path.basename(path)}")
stats_path = os.path.join(cache_dir, f"processed_{self.selected_class}_{os.path.splitext(os.path.basename(self.current_file))[0]}.json")

# 如果缓存文件存在且json文件存在且不是摄像头源,直接播放
if os.path.exists(cache_file) and not isinstance(path, int) and os.path.exists(stats_path): #isinstance 检查 path 是否为 int ,是则返回True,不是则为False,经过not,则表明不是摄像头
self.video_cache[path] = cache_file
self.play_video(path, cache_file)
else:
self.is_processing = True # 标记为处理中
print(f"Processing video: {path}") # 调试信息
self.file_list.setEnabled(False) # 禁用文件列表

# 如果进度对话框已经存在,先关闭它
if self.progress_dialog and self.progress_dialog.isVisible():
self.progress_dialog.close()

self.progress_dialog = QProgressDialog("视频处理中...", "取消", 0, 100, self) # 创建进度对话框
self.progress_dialog.setWindowTitle("缓存视频处理进度") # 对话框标题
self.progress_dialog.setWindowModality(Qt.WindowModality.ApplicationModal) # 当进度对话框显示时,应用程序的其他窗口将无法获得焦点
self.progress_dialog.setAutoClose(True)
self.progress_dialog.setCancelButton(None) # 无法取消视频缓存

# print("Progress dialog created") # 调试信息

self.processor = VideoProcessorThread(self.detector, path, cache_file) # 创建视频处理线程
# print(f"Processor thread created: {self.processor}") # 调试信息
if not self.current_file:
print(f"current_file is {self.current_file}") # 调试信息
exit(1)
self.processor.progress.connect(self.update_progress) # 绑定进度更新方法
# print("Connected progress signal to update_progress") # 调试信息
self.processor.finished.connect(lambda: self.on_processing_finished(path, cache_file)) # 绑定处理完成方法

self.progress_dialog.show()
# print("Progress dialog shown") # 调试信息


self.processor.start() # 启动线程
# print(f"Processor thread started: {self.processor.isRunning()}") # 调试信息


def update_progress(self, processed_frames): # 更新视频处理进度

# print(f"update_progress called with processed_frames: {processed_frames}") # 调试信息

if not self.current_file:
# print("current_file is None") # 调试信息
return

if self.progress_dialog:
cap = cv2.VideoCapture(self.current_file) # 打开当前文件
if not cap.isOpened():
# print("Unable to open current_file for progress updates") # 调试信息
return

total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 获取总帧数
cap.release()

progress = (processed_frames / total_frames) * 100 # 计算进度
# print(f"Calculated progress: {progress}%") # 调试信息

if self.progress_dialog:
# print("Setting progress value") # 调试信息
self.progress_dialog.setValue(int(progress)) # 更新进度对话框的值
else:
# print("progress_dialog is None") # 调试信息
self.processor.stop()
QMessageBox.warning(self, "视频处理失败", "无法显示进度对话框") # 如果无法显示进度对话框,弹出警告

def on_processing_finished(self, path, cache_file): # 视频处理完成
if self.progress_dialog:
self.progress_dialog.setValue(100) # 设置进度为100%
self.progress_dialog.close() # 关闭进度对话框
self.progress_dialog = None


self.video_cache[path] = cache_file # 更新视频缓存

self.is_processing = False # 重置标志
# print(f"Video processing finished: {path}") # 调试信息
self.file_list.setEnabled(True) # 启用文件列表

self.play_video(path, cache_file) # 播放视频


def play_video(self, original_path, processed_path=None): # 播放视频
self.stop_video_threads() # 停止其他视频线程
self.current_file = original_path
self.progress_bar.show() # 显示播放进度条

# 根据文件源类型决定是否显示进度条
if isinstance(original_path, str) and original_path.lower().endswith('.mp4'):
self.progress_bar.show()
else:
self.progress_bar.hide()

self.original_video_thread = VideoThread(None, original_path, is_original=True) # 创建原始视频播放线程
self.original_video_thread.update_frame.connect(self.update_original_ui) # 绑定更新原始图像方法
if isinstance(original_path, str):
self.original_video_thread.progress_updated.connect(self.progress_bar.setValue) # 绑定进度更新方法
self.original_video_thread.frame_index_updated.connect(self.update_statistics_from_cache) # 绑定帧索引更新信号
self.original_video_thread.start() # 启动线程

if processed_path:
self.annotated_video_thread = VideoThread(None, processed_path) # 创建标注后视频播放线程
self.annotated_video_thread.update_frame.connect(self.update_annotated_ui) # 绑定更新标注后图像方法
self.annotated_video_thread.frame_index_updated.connect(self.update_statistics_from_cache) # 绑定帧索引更新信号
self.annotated_video_thread.start() # 启动线程

def update_original_ui(self, pixmap, stats): # 更新原始图像方法
self.original_image_label.setPixmap(
pixmap.scaled(600, 480, Qt.AspectRatioMode.KeepAspectRatio) # 显示原始图像
)

def update_annotated_ui(self, pixmap, stats): # 更新标注后图像方法
self.annotated_image_label.setPixmap(
pixmap.scaled(600, 480, Qt.AspectRatioMode.KeepAspectRatio) # 显示标注后图像
)

self.update_statistics(stats) # 更新统计信息

def toggle_camera(self): # 切换摄像头
self.stop_video_threads() # 停止所有视频线程

if not self.camera_thread.isRunning(): # 如果摄像头线程未运行
self.camera_thread = CameraProcessorThread(self.detector) # 重新初始化线程
self.camera_thread.update_frame.connect(self.update_camera_ui) # 绑定摄像头更新方法
self.camera_thread.start() # 启动线程
self.btn_camera.setText("关闭摄像头") # 更新按钮文本
self.image_label_clear() # 清空
self.progress_bar.hide() # 隐藏进度条

else:
self.camera_thread.stop() # 停止线程
self.camera_thread.wait() # 等待线程结束
self.btn_camera.setText("打开摄像头") # 更新按钮文本
QMessageBox.information(self, "关闭摄像头","摄像头已关闭")
self.image_label_clear() # 清空


def update_camera_ui(self, pixmap, stats): # 更新摄像头图像方法
self.annotated_image_label.setPixmap(
pixmap.scaled(600, 480, Qt.AspectRatioMode.KeepAspectRatio) # 显示摄像头图像 , 并且按比例缩放
)

self.update_statistics(stats) # 更新统计信息

def stop_video_threads(self): # 停止视频线程
if hasattr(self, 'original_video_thread') and self.original_video_thread.isRunning(): # 检查是否存在以及是否运行
self.original_video_thread.stop()
self.original_video_thread.wait()
self.image_label_clear()

if hasattr(self, 'annotated_video_thread') and self.annotated_video_thread.isRunning():
self.annotated_video_thread.stop()
self.annotated_video_thread.wait()
self.image_label_clear()
QMessageBox.information(self,'视频播放停止',"视频手动停止播放")


def export_results(self): # 导出检测结果
if self.current_file is None:
QMessageBox.warning(self, "导出失败", "没有文件正在显示") # 如果没有正在显示的文件,弹出警告
return

if self.custom_save_path is None:
self.select_export_path() # 选择导出路径

selected_class_new = self.class_select.currentText() # 获取最新类别选择

if selected_class_new != self.selected_class: # 如果类别选择更新
QMessageBox.warning(self, "导出失败", "类别选择已更新,将重新处理播放文件") # 弹出警告
self.process_file(self.current_file)
return
else:
file_extension = os.path.splitext(self.current_file)[1].lower() # 获取文件扩展名并转化为小写形式
if file_extension in (".jpg", ".png"):

self.process_file(self.current_file) # 展示图片,如果类别更新则展示新的图片
self.export_image(self.current_file) # 导出图像
elif file_extension == ".mp4":
self.process_video(self.current_file)
self.export_video(self.current_file) # 导出视频
else:
QMessageBox.warning(self, "导出失败", "不支持的文件类型") # 如果文件类型不支持,弹出警告

QMessageBox.information(self, "导出完成", f"检测结果已导出到:\n{self.custom_save_path}") # 显示导出完成消息

def export_image(self, img_path): # 导出图像
file_name = os.path.basename(img_path) # 获取文件名
# output_path = os.path.join(self.custom_save_path, f"result_{file_name}") # 构建输出路径
output_path = os.path.join(self.custom_save_path, f"result_{self.selected_class}_{file_name}")


frame, _ = self.detector.process_image(img_path) # 处理图像
if frame is not None:
cv2.imwrite(output_path, frame) # 保存图像

def export_video(self, video_path): # 导出视频
if hasattr(self, 'annotated_video_thread') and self.annotated_video_thread.isRunning():
self.stop_video_threads() # 停止所有视频线程
self.image_label_clear()


if video_path in self.video_cache:
processed_path = self.video_cache[video_path] # 通过键值对的方式获取缓存文件路径
else:
QMessageBox.warning(self, "导出失败", "无法找到视频缓存") # 如果无法找到缓存,弹出警告
return

file_name = os.path.basename(video_path) # 获取文件名
# output_path = os.path.join(self.custom_save_path, f"result_{file_name}") # 构建输出路径
output_path = os.path.join(self.custom_save_path, f"result_{self.selected_class}_{file_name}")
os.replace(processed_path, output_path) # 将缓存文件移动到导出路径

# self.video_cache[video_path] = output_path # 更新缓存路径

def select_export_path(self): # 选择导出路径
selected_path = QFileDialog.getExistingDirectory(self, "选择导出路径") # 打开文件夹选择对话框
if selected_path: # 如果用户选择了路径
self.custom_save_path = selected_path # 更新保存路径
QMessageBox.information(self, "导出路径", f"导出路径已设置为:\n{self.custom_save_path}") # 显示路径设置消息
elif self.custom_save_path :
QMessageBox.information(self, "导出路径", f"未设置新的导出路径,\n导出路径保持为:\n{self.custom_save_path}")
else:
QMessageBox.information(self, "导出路径取消", "取消选择导出路径,已重置为 None") # 显示取消消息
self.select_export_path() # 重新导出结果

def update_statistics(self, stats): # 更新统计信息
if not stats:
self.statistics_label.setText("无目标检测结果")
return

total = sum(stats.values()) # 计算目标总数

stats_str = f"目标总数:{total}\t"
for label, count in stats.items():
stats_str += f"{label}: {count}\t"

self.statistics_label.setText(stats_str) # 更新统计信息标签

def update_statistics_from_cache(self, frame_index): # 从缓存更新统计信息
# print(f"Processing frame index: {frame_index}")

if not self.current_file:
# print("No current file")
return
if isinstance(self.current_file, str) and self.current_file.lower().endswith('.mp4'):
video_path = self.current_file
cache_dir = os.path.join(os.path.dirname(video_path), ".cache")
stats_path = os.path.join(cache_dir, f"processed_{self.selected_class}_{os.path.splitext(os.path.basename(video_path))[0]}.json")

# print(f"Stats path: {stats_path}") # 调试信息3

if os.path.exists(stats_path):
# print("Stats file exists") # 调试信息4

# 读取统计文件
with open(stats_path, "r") as f:
stats_data = json.load(f)
# print(f"Loaded stats data: {stats_data}") # 调试信息5

# 获取当前帧的统计信息
if "frames" in stats_data and frame_index < len(stats_data["frames"]):
stats = stats_data["frames"][frame_index]
self.update_statistics(stats)
return

# else:
# print("No stats found for current frame") # 调试信息6
# else:
# print("Stats file not found") # 调试信息8

# 如果没有缓存文件或者读取失败,则更新为默认值
else:
# print("无")
# QMessageBox.warning(self, "统计信息读取失败", "视频缓存json文件读取失败")
self.stop_video_threads() # 停止所有视频线程
self.image_label_clear()
self.current_file = video_path
self.process_video(self.current_file) # 调用视频处理方法
self.statistics_label.setText("视频缓存json文件读取失败,无目标检测结果")


def closeEvent(self, event): # 关闭窗口事件
self.stop_video_threads() # 停止所有视频线程
self.image_label_clear()
if self.cache_dir_delete and os.path.exists(self.cache_dir_delete):
print(f"缓存目录存在,位置为{self.cache_dir_delete}")
shutil.rmtree(self.cache_dir_delete)
print("已删除缓存目录")
else:
print("未设置缓存目录或缓存目录不存在")
event.accept() # 告诉 Qt 事件系统接受这个关闭事件,允许窗口正常关闭
print("窗口正常关闭")

def image_label_clear(self): # 清空事件
self.stop_video_threads() # 停止所有视频线程
self.original_image_label.clear() # 清空图像
self.annotated_image_label.clear() # 清空图像
self.original_image_label.setText("原始图像/视频")
self.annotated_image_label.setText("标注后的图像/视频/摄像头")
self.statistics_label.setText("无目标检测结果")
self.progress_bar.hide() # 隐藏进度条
self.current_file = None # 重置当前文件



class VideoProcessorThread(QThread): # 视频处理线程
progress = pyqtSignal(int) # 定义信号用于发送进度

def __init__(self, detector, video_path, output_path):
super().__init__()
self.detector = detector # 目标检测器
self.video_path = video_path # 视频文件路径
self.output_path = output_path # 输出文件路径
self.running = True # 线程运行标志
self.stats_cache = [] # 统计信息缓存
self.stats_path = os.path.splitext(output_path)[0] + ".json" # 统计信息文件路径
self.camera = None # 用于摄像头场景

def run(self): # 线程运行方法
if isinstance(self.video_path, int): # 如果输入是摄像头
self.camera = CameraCapture(self.video_path)
if not self.camera.open():
return
cap = self.camera.cap
fps = 30
else:
cap = cv2.VideoCapture(self.video_path) # 打开视频文件

if not cap.isOpened():
QMessageBox.warning(self, "错误", "无法处理视频") # 如果无法加载处理后的图像,弹出警告
self.stop() # 停止所有视频线程
return # 如果无法打开视频,则退出

fps = cap.get(cv2.CAP_PROP_FPS) if not isinstance(self.video_path, int) else 30 # 获取帧率
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # 获取宽度
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # 获取高度
# total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if not isinstance(self.video_path, int) else 0 # 获取总帧数

fourcc = cv2.VideoWriter_fourcc(*"mp4v") # 设置视频编码器
out = cv2.VideoWriter(self.output_path, fourcc, fps, (width, height)) # 创建VideoWriter

processed_frames = 0 # 处理的帧数计数器

while self.running and cap.isOpened(): # 当线程运行且视频/摄像头打开时
ret, frame = cap.read() # 读取帧
if not ret:
break # 如果无法获取帧,退出循环

boxes, stats = self.detector.detect(frame) # 执行目标检测
self.detector._draw_boxes(frame, boxes) # 绘制检测框


out.write(frame) # 写入帧到输出文件
self.stats_cache.append(stats) # 将统计信息缓存
processed_frames += 1 # 增加已处理帧数
# self.progress_dialog.show()
self.progress.emit(processed_frames) # 发送进度信号
self.msleep(10) # 线程休眠10毫秒

if isinstance(self.video_path, int):
self.camera.release()
else:
cap.release() # 释放资源
out.release() # 释放资源

# 保存统计信息到JSON文件
print(f"Saving stats to: {self.stats_path}")
with open(self.stats_path, "w") as f:
json.dump({"frames": self.stats_cache}, f)

def stop(self):
self.running = False
self.wait()
if os.path.exists(self.output_path):
os.remove(self.output_path)
os.remove(self.stats_path)
self.video_path = None



class VideoThread(QThread): # 视频播放线程
update_frame = pyqtSignal(QPixmap, dict) # 定义信号用于发送更新后的帧
progress_updated = pyqtSignal(int) # 定义信号用于发送进度
frame_index_updated = pyqtSignal(int) # 定义信号用于发送帧索引

def __init__(self, detector, source, is_original=False):
super().__init__()
self.detector = detector # 目标检测器
self.source = source # 视频源路径
self.is_original = is_original # 是否是原始视频
self.running = True # 线程运行标志
self.frame_index = 0 # 帧索引
self.camera = None # 用于摄像头场景

def run(self):
if isinstance(self.source, int): # 摄像头模式
self.camera = CameraCapture(self.source)
if not self.camera.open():
return
cap = self.camera.cap
total_frames = 0
else:
cap = cv2.VideoCapture(self.source) # 打开视频文件
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 获取总帧数

while self.running and cap.isOpened():
if isinstance(self.source, int):
ret, frame = self.camera.read() # 使用工具类
else:
ret, frame = cap.read()

if not ret:
break


# 发送进度信号
if total_frames > 0:
current_pos = cap.get(cv2.CAP_PROP_POS_FRAMES) # 获取当前帧数
progress = int((current_pos / total_frames) * 100) # 计算进度
self.progress_updated.emit(progress) # 发送进度信号
self.frame_index = int(current_pos) # 更新帧索引
self.frame_index_updated.emit(self.frame_index) # 发送帧索引信号
# print(f"Current frame index: {self.frame_index}")

if not self.is_original and self.detector: # 如果不是原始视频且有检测器
boxes, stats = self.detector.detect(frame) # 执行目标检测
self.detector._draw_boxes(frame, boxes) # 绘制检测框
else:
stats = {} # 空统计结果

rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # 将OpenCV的BGR格式转换为RGB格式
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888) # 转换为QImage
pixmap = QPixmap.fromImage(qimg) # 转换为QPixmap
self.update_frame.emit(pixmap, stats) # 发送更新后的帧
self.msleep(10) # 线程休眠10毫秒

if isinstance(self.source, int):
self.camera.release()
else:
cap.release() # 释放资源

def stop(self):
self.running = False # 停止线程


if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

附带的style.qss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
QWidget {
background-color: #F5F5F5;
color: #333333;
font-family: Arial, sans-serif;
}

QPushButton {
background-color: #3498DB;
border: 1px solid #2980B9;
border-radius: 5px;
padding: 5px;
color: #FFFFFF;
}

QPushButton:hover {
background-color: #2980B9;
}

QLabel {
color: #333333;
}

QListWidget {
background-color: #FFFFFF;
color: #333333;
border: 1px solid #CCCCCC;
border-radius: 5px;
padding: 5px;
}

QComboBox {
background-color: #FFFFFF;
color: #333333;
border: 1px solid #CCCCCC;
border-radius: 5px;
padding: 5px;
}

QProgressBar {
background-color: #F5F5F5;
border: 1px solid #CCCCCC;
border-radius: 5px;
text-align: center;
}

QProgressBar::chunk {
background-color: #3498DB;
border-radius: 5px;
}

/* 分割线样式 */
QFrame#splitter {
background-color: #F5F5F5;
border: 1px dashed #000000; /* 虚线样式,颜色为深灰色 */
}

detector.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
from ultralytics import YOLO
import cv2
from pathlib import Path

class YOLODetector:
def __init__(self, model_path): # 初始化YOLO检测器
self.model = YOLO(model_path) # 加载YOLO模型
self.class_names = self.model.names # 获取类别名称
print(self.model.names)
self.class_filter = None # 初始化类别过滤列表

def set_class_filter(self, selected_classes): # 设置类别过滤
if selected_classes == "all": # 如果选择“所有类别”
self.class_filter = None # 不进行过滤
else:
self.class_filter = [k for k, v in self.class_names.items() if v in selected_classes] # 根据用户选择的类别生成过滤列表

def detect(self, frame): # 执行目标检测
results = self.model.predict(frame) # 使用YOLO模型进行预测
stats = {} # 统计检测结果
filtered_boxes = [] # 初始化筛选后的检测框

for result in results: # 遍历所有检测结果
for box in result.boxes: # 遍历每个检测框
cls_idx = int(box.cls[0].item()) # 获取类别索引
if self.class_filter and cls_idx not in self.class_filter: # 如果类别不在过滤列表中
continue # 跳过当前检测框

label = self.class_names[cls_idx] # 获取类别名称
stats[label] = stats.get(label, 0) + 1 # 统计类别数量
filtered_boxes.append((box, label)) # 添加检测框和类别名称到筛选列表

return filtered_boxes, stats # 返回筛选后的检测框和统计结果

def process_image(self, img_path): # 处理图像并返回检测结果
frame = cv2.imread(img_path) # 读取图像
boxes, stats = self.detect(frame) # 执行目标检测
self._draw_boxes(frame, boxes) # 在图像上绘制检测框
return frame, stats # 返回绘制后的图像和统计结果

def _draw_boxes(self, frame, boxes): # 在图像上绘制检测框和标签
for box, label in boxes: # 遍历每个检测框及其类别名称
x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) # 获取检测框的坐标
conf = box.conf[0].item() # 获取置信度

cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 4) # 绘制检测框

text = f"{label} {conf:.2f}" # 构造标签文本


# 动态调整标签位置以避免超出图像边界
text_size, _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2) # 获取文本大小
text_w, text_h = text_size
img_h, img_w, _ = frame.shape # 获取图像大小

if y1 - text_h - 30 >= 0: #为什么减10?
cv2.putText(frame, text, (x1, y1 - 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) # 顶部有足够的空间时,将标签绘制在检测框上方
elif y2 + text_h + 30 <= img_h:
cv2.putText(frame, text, (x1, y2 + text_h + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) # 否则,将标签绘制在检测框下方
else:
cv2.putText(frame, text, (x1, y1 + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import sys
import os
from PyQt6.QtWidgets import QApplication, QMessageBox
from gui import MainWindow

if __name__ == "__main__":

app = QApplication(sys.argv)

try:
# 获取当前脚本文件所在的路径
current_script_path = os.path.abspath(__file__)

# 获取当前脚本文件所在目录的上一层路径
project_parent_directory = os.path.dirname(os.path.dirname(current_script_path))

# 检查是否已经是根目录
if os.path.samefile(project_parent_directory, os.path.sep):
QMessageBox.warning(None, "文件路径错误", "请完整运行在project目录下的main.py文件。")
else:
# 修改工作目录为上一层
os.chdir(project_parent_directory)
print("工作目录设置成功", f"工作目录已切换到: {os.getcwd()}")

except FileNotFoundError:
QMessageBox.warning(None, "文件路径错误", "请完整运行在project目录下的main.py文件。")
except PermissionError:
QMessageBox.warning(None, "权限错误", "权限错误,请完整运行在project目录下的main.py文件。")
except Exception as error:
QMessageBox.warning(None, "未知错误", f"未知错误:{error}")


window = MainWindow()
window.show()

sys.exit(app.exec())

遇到的问题

由于问题没有全部记录,这里仅仅展示记录下来的问题,以及解决方案

  1. 摄像头只能开启一次,第二次没反应
    toggle_camera方法每次重新初始化线程

  2. 视频识别识别还没有绑定物品信息统计(缓存机制该如何解决)
    建立缓存json文件,每次播放视频读取文件

  3. 视频缓存进度条没了
    ->新问题:第一次双击无缓存的视频文件时,弹出弹窗,但不显示进度,再次双击该文件,可以正常弹出弹出且显示进度
    在视频处理前面加上了清理当前显示,所以currentfile被置为None,改一下顺序就好
    很多问题都是currentfile先被置为None导致的异常

  4. 连续4次点击一个文件(执行了两次双击文件),造成缓存视频时进度条bug
    每次缓存视频时禁用文件列表
    self.file_list.setEnabled(False) # 禁用文件列表

  5. 只有视频缓存没有json文件
    判断后重新进入处理视频的方法

  6. 工作目录的修改
    main.py中使用os工具

  7. 中途结束处理视频任务
    失败
    分析:

  • 调用视频处理线程的stop方法,虽然进入了stop函数,但依然继续缓存,running置为False等等操作都没有用