实验目的及原理:

  1.         实验目的:

        通过结合 cv2 与 PyQt6,实现带界面的面部图像采集与标注功能,包括界面交互、图像实时显示、手动框选人脸区域并保存图像及对应位置信息。

        实验原理:

(1)PyQt6部分:

  • 通过 PyQt6 的QMainWindow搭建主窗口框架,使用QVBoxLayout/QHBoxLayout实现控件(按钮、标签、文本框)的有序布局;
  • 借助QTimer定时器(30ms 间隔)触发update_frame函数,持续从摄像头读取帧数据,实现实时画面刷新;
  • 利用QFileDialog提供路径选择功能,QMessageBox实现警告、成功、错误等用户反馈,提升工具易用性。
  • 自定义FaceLabel控件,重写mousePressEvent/mouseMoveEvent/mouseReleaseEvent,实现鼠标拖拽绘制红色矩形框,记录人脸区域坐标;

(2)OpenCV部分:

  • OpenCV 读取的图像为BGR格式,而 PyQt6 的QImage仅支持RGB格式,需通过cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)完成格式转换;
  • 界面显示的图像会因QLabel固定尺寸(640×480)缩放,框选坐标需通过 “原始图像尺寸 / 界面标签尺寸” 的缩放比例(scale_x/scale_y)映射回原始图像,确保裁剪位置准确。
  • 保存阶段分三类文件:通过cv2.imwrite保存原始图像(face_XXXX.png)和裁剪的人脸区域(face_crop_XXXX.png),通过文件操作写入位置信息(face_XXXX.txt,含图像序号、路径、人脸坐标、尺寸)。

程序代码:

import sys
import cv2
import os
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton,
    QLabel, QWidget, QFileDialog, QMessageBox, QTextEdit, QDialog
)
from PyQt6.QtCore import QTimer, Qt, QRect
from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QMouseEvent


class FaceLabel(QLabel):
    """用于手动框选人脸的QLabel"""

    def __init__(self, parent=None):
        """初始化FaceLabel控件,设置框选相关的初始变量"""
        super().__init__(parent)
        self.start_point = None  # 框选起始点
        self.end_point = None  # 框选终点
        self.drawing = False  # 是否正在绘制状态标记
        self.rect = None  # 最终确定的矩形框

    def mousePressEvent(self, event: QMouseEvent):
        """处理鼠标按下事件,开始框选操作"""
        if event.button() == Qt.MouseButton.LeftButton:
            self.start_point = event.pos()  # 记录起始点
            self.end_point = self.start_point
            self.drawing = True  # 标记为正在绘制
            self.update()  # 触发重绘

    def mouseMoveEvent(self, event: QMouseEvent):
        """处理鼠标移动事件,更新框选区域"""
        if self.drawing:
            self.end_point = event.pos()  # 更新终点位置
            self.update()  # 触发重绘以实时显示框选效果

    def mouseReleaseEvent(self, event: QMouseEvent):
        """处理鼠标释放事件,完成框选操作"""
        if event.button() == Qt.MouseButton.LeftButton and self.drawing:
            self.end_point = event.pos()  # 确定最终终点
            self.drawing = False  # 结束绘制状态
            # 标准化矩形(确保宽高为正值)
            self.rect = QRect(self.start_point, self.end_point).normalized()
            self.update()  # 触发重绘

    def paintEvent(self, event):
        """重绘事件,绘制框选矩形"""
        super().paintEvent(event)  # 调用父类方法保持原有功能
        if self.start_point and self.end_point:
            painter = QPainter(self)
            # 设置画笔样式:红色、2px宽、实线
            painter.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.SolidLine))
            # 绘制标准化的矩形
            rect = QRect(self.start_point, self.end_point).normalized()
            painter.drawRect(rect)

    def get_rect(self):
        """获取最终确定的框选矩形"""
        return self.rect

    def reset(self):
        """重置框选状态,清除已绘制的矩形"""
        self.start_point = None
        self.end_point = None
        self.rect = None
        self.update()  # 触发重绘以清除矩形


class FaceSelectionDialog(QDialog):
    """人脸框选对话框,用于显示采集的图像并进行人脸框选"""

    def __init__(self, frame, img_path, img_count, parent=None):
        """初始化对话框,接收图像数据、保存路径和图像计数"""
        super().__init__(parent)
        self.frame = frame  # 待处理的图像帧
        self.img_path = img_path  # 图像保存路径
        self.img_count = img_count  # 图像序号

        # 创建人脸裁剪保存目录
        self.face_crop_dir = os.path.join(os.path.dirname(img_path), "face_crops")
        os.makedirs(self.face_crop_dir, exist_ok=True)

        self.setWindowTitle("框选人脸区域")
        self.setFixedSize(800, 600)

        self.init_ui()  # 初始化界面

    def init_ui(self):
        """初始化对话框界面,显示图像并添加操作按钮"""
        # 转换OpenCV图像格式为PyQt可显示的格式
        rgb_image = cv2.cvtColor(self.frame, cv2.COLOR_BGR2RGB)
        h, w, ch = rgb_image.shape
        bytes_per_line = ch * w
        qt_image = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)

        # 创建自定义的FaceLabel控件用于显示图像和框选
        self.face_label = FaceLabel()
        self.face_label.setPixmap(QPixmap.fromImage(qt_image))
        self.face_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # 创建确认按钮并绑定事件
        btn_confirm = QPushButton("确认框选")
        btn_confirm.clicked.connect(self.confirm_selection)

        # 创建重新拍摄按钮并绑定事件
        btn_retake = QPushButton("重新拍摄")
        btn_retake.clicked.connect(self.reject)

        # 布局管理
        layout = QVBoxLayout()
        layout.addWidget(self.face_label)

        btn_layout = QHBoxLayout()
        btn_layout.addWidget(btn_confirm)
        btn_layout.addWidget(btn_retake)
        layout.addLayout(btn_layout)

        self.setLayout(layout)

    def confirm_selection(self):
        """确认框选结果,保存原始图像、裁剪的人脸区域和框选信息"""
        rect = self.face_label.get_rect()
        if rect is None:
            QMessageBox.warning(self, "警告", "请先框选人脸区域")
            return

        try:
            # 检查保存目录是否存在,不存在则创建
            save_dir = os.path.dirname(self.img_path)
            if not os.path.exists(save_dir):
                os.makedirs(save_dir, exist_ok=True)

            # 验证图像数据有效性
            if self.frame is None or self.frame.size == 0:
                raise ValueError("无效的图像数据,无法保存")

            # 保存原始图像文件
            success = cv2.imwrite(self.img_path, self.frame)
            if not success:
                raise IOError(f"无法写入原始图像文件到 {self.img_path}")

            # 裁剪并保存框选的人脸区域
            # 注意:需要将Qt的坐标转换为OpenCV的坐标(因为图像可能被缩放)
            img_height, img_width = self.frame.shape[:2]
            label_width = self.face_label.width()
            label_height = self.face_label.height()

            # 计算缩放比例
            scale_x = img_width / label_width
            scale_y = img_height / label_height

            # 转换框选坐标到原始图像尺寸
            x = int(rect.x() * scale_x)
            y = int(rect.y() * scale_y)
            width = int(rect.width() * scale_x)
            height = int(rect.height() * scale_y)

            # 确保坐标在有效范围内
            x = max(0, min(x, img_width - 1))
            y = max(0, min(y, img_height - 1))
            width = max(1, min(width, img_width - x))
            height = max(1, min(height, img_height - y))

            # 裁剪人脸区域
            face_crop = self.frame[y:y + height, x:x + width]

            # 保存裁剪的人脸区域
            crop_img_name = f"face_crop_{self.img_count:04d}.png"
            crop_img_path = os.path.join(self.face_crop_dir, crop_img_name)
            success_crop = cv2.imwrite(crop_img_path, face_crop)
            if not success_crop:
                raise IOError(f"无法写入人脸裁剪图像到 {crop_img_path}")

            # 保存人脸位置信息到文本文件
            txt_path = self.img_path.replace('.png', '.txt')
            with open(txt_path, 'w', encoding='utf-8') as f:
                f.write(f"图像序号: {self.img_count}\n")
                f.write(f"原始图像文件: {os.path.basename(self.img_path)}\n")
                f.write(f"人脸裁剪文件: {os.path.basename(crop_img_path)}\n")
                f.write(f"人脸位置(原始图像): x={x}, y={y}, width={width}, height={height}\n")
                f.write(f"图像尺寸: {img_width}x{img_height}\n")

            # 显示成功信息并关闭对话框
            QMessageBox.information(self, "成功",
                                    f"已保存以下文件:\n"
                                    f"原始图像: {os.path.basename(self.img_path)}\n"
                                    f"人脸裁剪: {os.path.basename(crop_img_path)}\n"
                                    f"位置信息: {os.path.basename(txt_path)}")
            self.accept()
        except Exception as e:
            QMessageBox.critical(self, "错误", f"保存文件时出错:{str(e)}")


class MainWindow(QMainWindow):
    def __init__(self):
        """初始化主窗口,设置界面布局和初始化变量"""
        super().__init__()
        self.setWindowTitle("人脸采集工具")
        self.setFixedSize(800, 600)  # 设置窗口大小为4:3比例

        # 初始化摄像头
        self.cap = cv2.VideoCapture(0)
        if not self.cap.isOpened():
            QMessageBox.critical(self, "错误", "无法打开摄像头")
            sys.exit(1)

        # 初始化定时器用于更新摄像头画面
        self.timer = QTimer()
        self.timer.timeout.connect(self.update_frame)

        # 创建界面组件
        self.video_label = QLabel()
        self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.video_label.setStyleSheet("border: 1px solid black;")
        self.video_label.setFixedSize(640, 480)  # 4:3比例的显示区域

        self.path_text = QTextEdit()
        self.path_text.setMaximumHeight(60)
        self.path_text.setReadOnly(True)  # 路径显示区域设为只读

        self.btn_select_path = QPushButton("选择保存路径")
        self.btn_capture = QPushButton("采集截图")

        # 设置布局
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QVBoxLayout(central_widget)

        layout.addWidget(QLabel("实时摄像头画面:"), alignment=Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(self.video_label, alignment=Qt.AlignmentFlag.AlignCenter)

        btn_layout = QHBoxLayout()
        btn_layout.addWidget(self.btn_select_path)
        btn_layout.addWidget(self.btn_capture)
        layout.addLayout(btn_layout)

        layout.addWidget(QLabel("当前保存路径:"))
        layout.addWidget(self.path_text)

        # 初始化变量
        self.save_path = ""  # 保存路径
        self.img_count = 0  # 图像计数器

        # 连接信号与槽函数
        self.btn_select_path.clicked.connect(self.select_path)
        self.btn_capture.clicked.connect(self.capture_image)

        # 启动定时器,开始刷新摄像头画面
        self.timer.start(30)

    def select_path(self):
        """处理选择保存路径的操作"""
        path = QFileDialog.getExistingDirectory(self, "选择保存路径")
        if path:
            self.save_path = path
            # 计算已存在的图片数量并更新计数
            self.img_count = self.count_existing_images()
            # 更新路径显示
            self.path_text.setPlainText(f"保存路径: {path}\n已保存图片数量: {self.img_count}")

    def count_existing_images(self):
        """计算指定路径下已存在的符合命名规则的图像数量"""
        if not self.save_path or not os.path.exists(self.save_path):
            return 0

        # 只统计符合"face_XXXX.png"命名规则的图片
        count = 0
        for f in os.listdir(self.save_path):
            if f.startswith("face_") and f.endswith(".png"):
                try:
                    # 提取文件名中的数字部分(如"face_0001.png"中的"0001")
                    num = int(f[5:-4])
                    if num > count:
                        count = num
                except ValueError:
                    continue  # 忽略命名不符合规则的文件
        return count

    def capture_image(self):
        """处理采集截图操作,获取当前摄像头帧并打开框选对话框"""
        if not self.save_path:
            QMessageBox.warning(self, "警告", "请先选择保存路径")
            return

        # 获取当前摄像头帧
        ret, frame = self.cap.read()
        if not ret:
            QMessageBox.critical(self, "错误", "无法获取摄像头画面")
            return

        if frame is None or frame.size == 0:
            QMessageBox.critical(self, "错误", "获取的画面为空")
            return

        try:
            # 增加计数并生成文件名
            self.img_count += 1
            img_name = f"face_{self.img_count:04d}.png"
            img_path = os.path.join(self.save_path, img_name)

            # 弹出框选窗口
            dialog = FaceSelectionDialog(frame, img_path, self.img_count, self)
            result = dialog.exec()

            if result == QDialog.DialogCode.Accepted:
                # 更新路径显示
                self.path_text.setPlainText(
                    f"保存路径: {self.save_path}\n已保存图片数量: {self.count_existing_images()}")
                QMessageBox.information(self, "成功", f"已保存第{self.img_count}张人脸图像和位置信息")
            else:
                # 用户取消了框选,恢复计数
                self.img_count -= 1

        except Exception as e:
            QMessageBox.critical(self, "错误", f"处理图像时出错:{str(e)}")
            if self.img_count > 0:
                self.img_count -= 1  # 出错时恢复计数

    def update_frame(self):
        """定时更新摄像头画面到界面"""
        ret, frame = self.cap.read()
        if ret:
            # 转换OpenCV图像格式为PyQt可显示的格式
            rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            h, w, ch = rgb_image.shape
            bytes_per_line = ch * w
            qt_image = QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
            # 缩放图像并显示在界面上
            self.video_label.setPixmap(QPixmap.fromImage(qt_image).scaled(
                640, 480, Qt.AspectRatioMode.KeepAspectRatio))

    def closeEvent(self, event):
        """窗口关闭事件处理,释放资源"""
        self.cap.release()  # 释放摄像头资源
        self.timer.stop()  # 停止定时器
        event.accept()  # 接受关闭事件


if __name__ == "__main__":
    """程序入口点,创建应用实例并运行"""
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

实验结果与分析:

        结果:

        成功运行程序后,可在界面看到实时摄像头画面;点击 “选择保存路径” 能指定图像保存目录;点击 “采集截图” 会弹出框选人脸的对话框,框选后确认可将原图像face_0001.png、框选图像(储存在face_crops内)和对应位置信息(保存于同路径下的 .txt 文件)保存(建议保存到C盘以外的其他硬盘),界面会提示保存成功并更新已保存图片数量。(多次运行以总和为准)

        分析:

        程序实现了任务要求的界面交互、图像采集与标注功能。cv2 与 PyQt6 结合顺畅,手动框选功能准确,文件保存逻辑正常,但在高频率采集或复杂环境下,需进一步测试性能与稳定性,比如摄像头画面延迟、大数量图像保存时的效率等。

个人收获与体会:

  • 掌握了 PyQt6 与 cv2 结合进行图像采集与界面开发的方法,理解了图形界面与图像处理模块协同工作的流程。
  • 深入学习了 PyQt6 中自定义控件(如 FaceLabel)、对话框(FaceSelectionDialog)的创建与事件处理,以及 cv2 图像格式与 PyQt6 图像格式的转换技巧。
  • 体会到模块化编程的优势,将界面、图像采集、标注等功能分模块实现,使代码结构更清晰,便于维护与扩展。

思考:

  • 能否引入自动人脸检测算法(如 dlib 或 OpenCV 的人脸检测器),替代手动框选,提高人脸标注的效率与准确性?

  • 当需要采集大量面部图像时,程序在性能(如内存占用、画面刷新率)方面能否满足需求,是否需要进行优化?

 

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐