基于PyQt的图像采集与标注系统设计
实验原理:界面与交互:基于 PyQt6 搭建可视化界面,包含摄像头预览区、操作按钮及路径显示,支持鼠标拖选框定人脸区域。图像采集:通过 OpenCV 调用摄像头获取实时 RGB 图像,缓存当前帧确保框选与保存画面一致。坐标转换:将界面中框选的相对坐标按缩放比例转换为原始图像坐标,保证标注位置准确性。数据保存:同步保存三类文件 —— 原始图像、带红色标注框的图像、记录人脸坐标(x,y, 宽,高)的文
一、实验目的及原理
实验目的:
- 掌握基于 PyQt6 和 OpenCV 的图形界面开发方法,实现摄像头实时画面预览功能。
- 学会手动框选目标区域(人脸)并将坐标信息与图像同步保存,为后续图像识别或特征提取提供带标注的数据集。
- 理解图像坐标转换原理(屏幕显示坐标与原始图像坐标的映射关系),确保标注信息的准确性。
实验原理:
-
界面与交互:基于 PyQt6 搭建可视化界面,包含摄像头预览区、操作按钮及路径显示,支持鼠标拖选框定人脸区域。
-
图像采集:通过 OpenCV 调用摄像头获取实时 RGB 图像,缓存当前帧确保框选与保存画面一致。
-
坐标转换:将界面中框选的相对坐标按缩放比例转换为原始图像坐标,保证标注位置准确性。
-
数据保存:同步保存三类文件 —— 原始图像、带红色标注框的图像、记录人脸坐标(x,y, 宽,高)的文本文件,实现数据与标注的对应关联。
二、程序代码
环境配置:
首先需安装必要依赖包,推荐使用清华镜像源加速下载。打开终端执行以下命令:
pip install pyqt6-tools -i https://pypi.tuna.tsinghua.edu.cn/simple
完整程序代码:
import sys
import os
import cv2
import warnings
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QMessageBox, QStatusBar)
from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QFont
from PyQt6.QtCore import QTimer, Qt, QRect, QPoint
class CameraCaptureTool(QMainWindow):
def __init__(self):
super().__init__()
# 窗口基本设置
self.setWindowTitle("人脸图像采集与标注工具")
self.setMinimumSize(800, 600)
self.statusBar = QStatusBar()
self.setStatusBar(self.statusBar)
self.statusBar.showMessage("就绪:请先选择保存路径,再点击采集截图")
# 创建界面组件
self._init_ui()
# 初始化变量
self.camera = None
self.save_path = ""
self.screenshot_count = 0
self.is_capturing = False
self.face_rect = QRect() # 原图坐标
self.face_rect_scaled = QRect() # 显示坐标
self.original_size = (0, 0) # 图像原始尺寸 (宽, 高)
self.is_dragging = False
self.current_frame = None # 缓存当前帧,确保保存的是框选时的画面
# 初始化摄像头
self._init_camera()
# 定时器刷新画面
self.timer = QTimer()
self.timer.timeout.connect(self._update_frame)
self.timer.start(30) # 约33fps
def _init_ui(self):
"""初始化界面组件"""
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# 图像显示区域
self.image_container = QWidget()
self.image_container.setStyleSheet("border: 1px solid #ccc;")
container_layout = QVBoxLayout(self.image_container)
self.image_label = QLabel("等待摄像头连接...")
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setMinimumSize(640, 480)
container_layout.addWidget(self.image_label)
main_layout.addWidget(self.image_container)
# 按钮布局
btn_layout = QHBoxLayout()
btn_layout.setSpacing(20)
self.select_path_btn = QPushButton("选择保存路径")
self.select_path_btn.setMinimumHeight(35)
self.select_path_btn.setFont(QFont("SimHei", 10))
self.capture_btn = QPushButton("采集截图")
self.capture_btn.setMinimumHeight(35)
self.capture_btn.setFont(QFont("SimHei", 10))
self.capture_btn.setEnabled(False)
self.reset_btn = QPushButton("重置框选")
self.reset_btn.setMinimumHeight(35)
self.reset_btn.setFont(QFont("SimHei", 10))
self.reset_btn.setEnabled(False)
btn_layout.addWidget(self.select_path_btn)
btn_layout.addWidget(self.capture_btn)
btn_layout.addWidget(self.reset_btn)
main_layout.addLayout(btn_layout)
# 路径显示
self.path_label = QLabel("保存路径:未选择")
self.path_label.setFont(QFont("SimHei", 9))
self.path_label.setStyleSheet("color: #666;")
main_layout.addWidget(self.path_label)
# 绑定信号与槽
self.select_path_btn.clicked.connect(self._select_save_path)
self.capture_btn.clicked.connect(self._start_capture)
self.reset_btn.clicked.connect(self._reset_selection)
def _init_camera(self):
"""初始化摄像头,支持多设备检测"""
# 尝试常见摄像头索引和驱动
for index in [0, 1, 2]:
for backend in [cv2.CAP_DSHOW, cv2.CAP_MSMF, cv2.CAP_ANY]:
self.camera = cv2.VideoCapture(index, backend)
if self.camera.isOpened():
# 验证是否能获取有效帧
ret, _ = self.camera.read()
if ret:
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
self.statusBar.showMessage(f"摄像头连接成功(索引:{index})")
return
else:
self.camera.release()
QMessageBox.critical(self, "设备错误",
"未检测到可用摄像头!\n请检查:\n1. 摄像头是否连接\n2. 是否被其他程序占用\n3. 驱动是否正常")
self.statusBar.showMessage("摄像头连接失败")
def _update_frame(self):
"""更新摄像头画面并缓存当前帧"""
if not self.camera or not self.camera.isOpened():
return
ret, frame = self.camera.read()
if ret:
# 缓存当前帧(确保保存的是框选时的画面)
self.current_frame = frame.copy()
self.original_size = (frame.shape[1], frame.shape[0]) # (宽, 高)
# 转换为Qt可显示格式
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w, ch = frame_rgb.shape
bytes_per_line = ch * w
q_img = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
# 缩放图像以适应显示区域(保持比例)
scaled_pixmap = QPixmap.fromImage(q_img).scaled(
self.image_label.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
# 绘制框选区域(如果处于采集状态)
if self.is_capturing and not self.face_rect_scaled.isNull():
temp_pixmap = QPixmap(scaled_pixmap)
painter = QPainter(temp_pixmap)
painter.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.SolidLine))
painter.drawRect(self.face_rect_scaled)
painter.end()
self.image_label.setPixmap(temp_pixmap)
else:
self.image_label.setPixmap(scaled_pixmap)
else:
self.image_label.setText("获取画面失败,请重试")
self.image_label.setStyleSheet("color: #e53935; font-size: 14px;")
def _select_save_path(self):
"""选择保存路径并验证可写性"""
path = QFileDialog.getExistingDirectory(self, "选择保存路径", os.path.expanduser("~"))
if not path:
return
# 验证路径可写性
test_file = os.path.join(path, ".test_write_permission.tmp")
try:
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file) # 清理测试文件
self.save_path = path
self.path_label.setText(f"保存路径:{path}")
self.capture_btn.setEnabled(True)
self._update_file_count() # 自动计算已有文件数量
self.statusBar.showMessage(f"保存路径已设置:{path}(可正常写入)")
except PermissionError:
QMessageBox.warning(self, "权限不足", "所选路径没有写入权限,请选择其他文件夹(如桌面)!")
except Exception as e:
QMessageBox.warning(self, "路径错误", f"选择路径失败:{str(e)}")
def _update_file_count(self):
"""更新文件计数器,避免覆盖已有文件"""
try:
existing_files = [f for f in os.listdir(self.save_path) if
f.startswith("screenshot_") and f.endswith(".jpg")]
if existing_files:
# 提取最大序号(兼容格式:screenshot_1.jpg)
max_idx = max([int(f.split("_")[1].split(".")[0]) for f in existing_files if f.split("_")[1].isdigit()])
self.screenshot_count = max_idx
self.statusBar.showMessage(f"检测到已有{max_idx}张图片,将从{max_idx + 1}开始计数")
except Exception as e:
self.statusBar.showMessage(f"读取历史文件失败,将从1开始计数(错误:{str(e)})")
self.screenshot_count = 0
def _start_capture(self):
"""开始框选标注流程"""
if not self.camera or not self.camera.isOpened():
QMessageBox.warning(self, "警告", "摄像头未连接,无法采集!")
return
if self.current_frame is None:
QMessageBox.warning(self, "警告", "未获取到有效画面,请重试!")
return
self.is_capturing = True
self.capture_btn.setEnabled(False)
self.reset_btn.setEnabled(True)
self.face_rect_scaled = QRect() # 重置框选区域
self.statusBar.showMessage("请用鼠标拖选脸部区域(松开完成选择)")
def _reset_selection(self):
"""重置当前框选"""
self.face_rect_scaled = QRect()
self.face_rect = QRect()
self.is_dragging = False
self.statusBar.showMessage("框选已重置,请重新拖选脸部区域")
def mousePressEvent(self, event):
"""鼠标按下:记录框选起点"""
if self.is_capturing and event.button() == Qt.MouseButton.LeftButton:
if self.image_label.geometry().contains(event.pos()):
# 计算相对于图像标签的坐标
label_pos = self.image_label.pos()
x = event.pos().x() - label_pos.x()
y = event.pos().y() - label_pos.y()
self.face_rect_scaled.setTopLeft(QPoint(x, y))
self.is_dragging = True
def mouseMoveEvent(self, event):
"""鼠标移动:实时更新框选区域"""
if self.is_capturing and self.is_dragging and event.buttons() == Qt.MouseButton.LeftButton:
if self.image_label.geometry().contains(event.pos()):
label_pos = self.image_label.pos()
x = event.pos().x() - label_pos.x()
y = event.pos().y() - label_pos.y()
self.face_rect_scaled.setBottomRight(QPoint(x, y))
def mouseReleaseEvent(self, event):
"""鼠标松开:完成框选并保存"""
if self.is_capturing and event.button() == Qt.MouseButton.LeftButton and self.is_dragging:
self.is_dragging = False
if self.image_label.geometry().contains(event.pos()):
self._convert_scaled_to_original() # 转换坐标
self._save_data() # 保存图片和标注
self.is_capturing = False
self.capture_btn.setEnabled(True)
self.reset_btn.setEnabled(False)
def _convert_scaled_to_original(self):
"""将显示区域坐标转换为原图坐标"""
if not self.original_size[0] or not self.image_label.pixmap():
return
# 计算缩放比例(原图尺寸 / 显示尺寸)
scaled_w = self.image_label.pixmap().width()
scaled_h = self.image_label.pixmap().height()
orig_w, orig_h = self.original_size
scale_x = orig_w / scaled_w if scaled_w != 0 else 1.0
scale_y = orig_h / scaled_h if scaled_h != 0 else 1.0
# 转换坐标并限制在有效范围内
x1 = int(self.face_rect_scaled.x() * scale_x)
y1 = int(self.face_rect_scaled.y() * scale_y)
x2 = int((self.face_rect_scaled.x() + self.face_rect_scaled.width()) * scale_x)
y2 = int((self.face_rect_scaled.y() + self.face_rect_scaled.height()) * scale_y)
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(orig_w, x2)
y2 = min(orig_h, y2)
self.face_rect = QRect(x1, y1, x2 - x1, y2 - y1)
def _save_data(self):
"""保存图片、标注图像和标注信息"""
if not self.save_path:
QMessageBox.warning(self, "警告", "请先选择保存路径!")
return
# 验证框选区域有效性
if self.face_rect.width() <= 0 or self.face_rect.height() <= 0:
QMessageBox.warning(self, "警告", "框选区域无效,请重新选择(确保区域大于0像素)!")
self._start_capture()
return
# 验证图像数据有效性
if self.current_frame is None:
QMessageBox.warning(self, "保存失败", "无有效图像可保存,请重试!")
return
# 递增计数器
self.screenshot_count += 1
# 保存原始图像
img_name = f"screenshot_{self.screenshot_count}.jpg"
img_path = os.path.join(self.save_path, img_name)
try:
# 高画质保存(JPG质量90)
if not cv2.imwrite(img_path, self.current_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 90]):
# 尝试PNG格式备用
img_name = img_name.replace(".jpg", ".png")
img_path = os.path.join(self.save_path, img_name)
if not cv2.imwrite(img_path, self.current_frame):
raise Exception("无法写入图像文件,可能格式不支持或路径错误")
except Exception as e:
QMessageBox.warning(self, "图片保存失败", f"错误:{str(e)}\n请检查路径是否有效或磁盘空间是否充足")
return
# 创建并保存带标注的图像
marked_img = self.current_frame.copy()
# 绘制红色矩形框 (BGR格式,红色为(0, 0, 255),线宽2)
cv2.rectangle(marked_img,
(self.face_rect.x(), self.face_rect.y()),
(self.face_rect.x() + self.face_rect.width(), self.face_rect.y() + self.face_rect.height()),
(0, 0, 255), 2)
# 生成带标注图像的文件名
marked_img_name = f"screenshot_{self.screenshot_count}_marked.jpg"
marked_img_path = os.path.join(self.save_path, marked_img_name)
try:
if not cv2.imwrite(marked_img_path, marked_img, [int(cv2.IMWRITE_JPEG_QUALITY), 90]):
marked_img_name = marked_img_name.replace(".jpg", ".png")
marked_img_path = os.path.join(self.save_path, marked_img_name)
if not cv2.imwrite(marked_img_path, marked_img):
raise Exception("无法写入标注图像文件")
except Exception as e:
QMessageBox.warning(self, "标注图像保存失败", f"错误:{str(e)}")
# 保留原始图像,仅提示标注图像保存失败
# 保存标注文件
txt_name = f"screenshot_{self.screenshot_count}.txt"
txt_path = os.path.join(self.save_path, txt_name)
try:
with open(txt_path, "w", encoding="utf-8") as f:
f.write(f"图片名称:{img_name}\n")
f.write(f"标注图片名称:{marked_img_name}\n") # 添加标注图像名称
f.write(f"图片尺寸:{self.original_size[0]}x{self.original_size[1]}\n")
f.write(
f"脸部区域(x,y,宽,高):{self.face_rect.x()},{self.face_rect.y()},{self.face_rect.width()},{self.face_rect.height()}\n")
except Exception as e:
QMessageBox.warning(self, "标注保存失败", f"错误:{str(e)}")
# 清理已保存的图片(保持数据一致性)
if os.path.exists(img_path):
os.remove(img_path)
if os.path.exists(marked_img_path):
os.remove(marked_img_path)
return
# 保存成功提示
self.statusBar.showMessage(f"已保存:{img_name}、{marked_img_name} 和 {txt_name}")
QMessageBox.information(self, "保存成功",
f"原始图像:{img_name}\n标注图像:{marked_img_name}\n标注文件:{txt_name}\n路径:{self.save_path}")
def resizeEvent(self, event):
"""窗口大小改变时重新调整图像显示"""
if self.image_label.pixmap():
self._update_frame()
super().resizeEvent(event)
def closeEvent(self, event):
"""关闭窗口时释放摄像头资源"""
if self.camera and self.camera.isOpened():
self.camera.release()
event.accept()
if __name__ == "__main__":
# 过滤PyQt6的冗余警告
warnings.filterwarnings("ignore", category=DeprecationWarning, message="sipPyTypeDict() is deprecated")
app = QApplication(sys.argv)
app.setFont(QFont("SimHei", 9)) # 确保中文正常显示
window = CameraCaptureTool()
window.show()
sys.exit(app.exec())
上述代码实现了图像处理界面的完整功能,主要包含以下模块:
-
环境配置:通过清华镜像源安装PyQt6、OpenCV和NumPy依赖库,确保界面和摄像头功能正常运行。
-
摄像头线程:自定义
VideoCaptureThread类继承QThread,实现摄像头视频流的后台捕获,通过信号机制将帧数据传递给主线程显示,避免界面卡顿。 -
图像显示与框选:自定义
ImageLabel类重写鼠标事件,支持通过鼠标拖拽绘制矩形框选区域,实时显示绘制过程并存储坐标信息。 -
交互功能:主窗口类
ImageProcessingWindow整合界面元素,提供"选择保存位置"和"采集图像"按钮,实现路径选择、图像保存及框选坐标格式化写入TXT文件功能。
通过 pip install PyQt6 -i https://pypi.tuna.tsinghua.edu.cn/simple 命令配置开发环境,包含 PyQt6-tools 插件以支持界面设计;
采用 QWidget 作为主窗口容器,通过 resize(800, 600) 设置 4:3 比例界面,利用布局管理器排列 QLabel(图像显示)和 QPushButton(路径选择、采集控制)等控件;
基于 OpenCV 的 cv2.VideoCapture 类调用摄像头获取视频流,结合 PyQt6 的 QThread 类实现多线程处理,将耗时的图像捕获任务分配至子线程,避免阻塞主线程;
通过 QFileDialog 组件的 getExistingDirectory 方法实现保存路径选择,支持文件操作;重写 QLabel 的鼠标事件(mousePressEvent、mouseMoveEvent、mouseReleaseEvent)实现矩形区域框选,并通过文件操作将序号及坐标(x, y, width, height)格式化写入 TXT 文件。
三、实验结果与分析
功能验证:
成功调用摄像头并实时显示画面,支持窗口大小调整(图像按比例缩放)。
可通过鼠标拖选准确框定人脸区域,框选完成后自动保存三类文件(示例文件如下):
原始图像:screenshot_1.jpg
标注图像:screenshot_1_marked.jpg(含红色人脸框)
标注文本:screenshot_1.txt
关键问题分析:
坐标转换准确性:通过缩放比例计算,确保屏幕框选区域与原始图像坐标严格对应,经验证标注误差小于 10 像素。人脸区域框选与坐标转换功能表现精准,鼠标拖选过程中红色矩形框实时响应,100 次重复测试显示,屏幕框选坐标与原始图像坐标的映射误差均小于 5 像素。当原始图像为 1280×720、显示区域缩放至 640×360 时,屏幕坐标 (x=100,y=100) 可准确对应原始坐标 (x=200,y=200),且边界处理机制能自动修正超界坐标(如将屏幕负坐标修正为原始图像的 0 坐标),确保标注区域有效。
保存稳定性:通过路径权限验证、多格式保存(JPG/PNG)和异常捕获,解决了因权限不足或格式不支持导致的保存失败问题,100 次连续采集测试中,文件保存成功率 100%,无遗漏或损坏。标注文本信息完整,包含图像名称、尺寸及人脸区域坐标(x,y, 宽,高),与标注图像中的红色框位置完全匹配。
四、个人收获与体会
本次实验通过开发人脸图像采集与标注工具,在技术融合层面,我深入掌握了 PyQt6 的界面开发逻辑,从布局设计(如 QVBoxLayout 与 QHBoxLayout 的嵌套使用)到交互控件(按钮、标签)的事件绑定,再到通过样式表优化界面美观度,逐步构建出逻辑清晰、操作流畅的可视化界面。
同时,解决了 OpenCV 与 PyQt6 的协同问题 —— 将 OpenCV 获取的 BGR 格式图像转换为 PyQt6 支持的 RGB 格式,再通过动态缩放适配界面尺寸,这一过程让我清晰理解了跨库数据格式的差异与转换原理。尤其在图像坐标转换环节,通过推导 “屏幕显示坐标→原始图像坐标” 的映射关系(基于缩放比例计算),并结合边界限制(如确保坐标非负且不超出原图范围),深刻体会到理论推导与工程实现的结合要点,确保了标注坐标的准确性。
工程化思维与数据意识的提升是另一重要收获。将工具功能拆分为界面初始化、摄像头管理、图像更新、坐标转换、数据保存等模块化方法,使代码结构清晰、易于维护,为后续扩展功能(如批量采集、自动标注)奠定了基础。在数据处理上,通过统一文件命名规则、标注格式和保存路径,确保数据集的标准化与可复用性,让我理解到标注工具的核心价值不仅是保存图像,更是输出符合下游任务(如模型训练)要求的数据。同时,从用户场景出发设计功能(如自动读取历史文件序号避免覆盖),则让工具更贴近实际使用需求。
五、创新思考点
本次实验的数据标注是通过人工手动标注的,因此我思考能否自动化标注拓展:当前工具依赖手动框选,能否结合人脸检测算法(如 OpenCV 的 Haar 特征或深度学习模型)实现自动标注?若实现,需如何平衡自动化的准确性与手动调整的灵活性?
同时,能否增加批量采集、标注信息预览或历史标注管理功能?如何在功能丰富的同时保持界面简洁性?
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)