程序员文章、书籍推荐和程序员创业信息与资源分享平台

网站首页 > 技术文章 正文

用Python讓電腦攝像頭實現掃二維碼

hfteth 2025-05-26 15:54:42 技术文章 3 ℃
import sys  # 系統模組,用來存取命令列參數與系統功能
import cv2  # OpenCV,處理影像與相機操作
import numpy as np  # Numpy,用來處理數值與陣列
from pyzbar import pyzbar  # pyzbar 模組,用來解碼 QR Code 與條碼

# 匯入 PyQt6 所需的元件
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QLabel,
    QPushButton, QTextEdit, QHBoxLayout, QFrame,
    QFileDialog, QSpacerItem, QSizePolicy, QSlider
)
from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen  # 圖像顯示與繪圖工具
from PyQt6.QtCore import Qt, QTimer, QRect  # 基本型別與計時器、矩形框
from PIL import Image, ImageDraw, ImageFont  # PIL 用於繪製 QR Code 邊框與文字


# 主應用程式類別:QRCodeScannerApp,繼承 QWidget
class QRCodeScannerApp(QWidget):
    def __init__(self):
        super().__init__()  # 初始化父類別
        self.setWindowTitle("二維碼掃描器")  # 設定視窗標題
        self.setGeometry(500, 100, 705, 750)  # 設定視窗位置與大小

        # 初始化攝影機與其他功能的變數
        self.camera_index = 0  # 攝影機編號(預設為 0)
        self.capture = None  # OpenCV 攝影機物件
        self.timer = QTimer(self)  # 建立 PyQt 的計時器
        self.timer.timeout.connect(self.update_frame)  # 設定定時器觸發時執行 update_frame()
        self.logged_codes = set()  # 儲存已辨識的 QR code(避免重複顯示)
        self.brightness = 0  # 初始亮度值
        self.contrast = 1.0  # 初始對比度值
        self.manual_image = None  # 手動選擇的圖片
        self.manual_image_decoded = False  # 是否已經辨識過手動圖片

        # 框選辨識的相關參數
        self.selecting = False  # 是否正在框選
        self.selection_start = None  # 框選起始點
        self.selection_end = None  # 框選結束點
        self.selection_rect = None  # 框選矩形

        # 新增反色功能相關變數
        self.invert_enabled = False

        # 新增縮放相關變數
        self.zoom_factor = 1.0
        self.min_zoom = 0.1
        self.max_zoom = 5.0

        self.init_ui()  # 建立使用者介面

    # 建立整個 UI 介面
    def init_ui(self):
        layout = QVBoxLayout()  # 主垂直排列佈局

        # 顯示標題文字
        title_label = QLabel("<h2>二維碼掃描器</h2>")
        title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(title_label)

        # 用於顯示相機畫面的 QLabel
        self.image_label = QLabel()
        self.image_label.setFixedSize(640, 480)  # 固定大小
        self.image_label.setStyleSheet("background-color: black;")  # 初始黑底
        layout.addWidget(self.image_label)

        # 按鈕群組
        button_layout = QHBoxLayout()
        self.start_button = QPushButton("開始掃描")  # 開始按鈕
        self.start_button.clicked.connect(self.start_scanning)  # 綁定點擊事件

        self.stop_button = QPushButton("停止掃描")  # 停止按鈕
        self.stop_button.clicked.connect(self.stop_scanning)
        self.stop_button.setEnabled(False)  # 初始為不可按

        self.clear_log_button = QPushButton("清除日誌")  # 清除日誌
        self.clear_log_button.clicked.connect(self.clear_log)

        self.manual_image_button = QPushButton("手動選擇圖片")  # 手動載入圖片
        self.manual_image_button.clicked.connect(self.select_image)

        self.manual_select_button = QPushButton("框選區域識別")  # 啟動框選辨識
        self.manual_select_button.clicked.connect(self.enable_manual_selection)

        # 新增反色按鈕
        self.invert_button = QPushButton("反色")
        self.invert_button.clicked.connect(self.toggle_invert)
        button_layout.addWidget(self.invert_button)

        # 將所有按鈕加入水平排版
        button_layout.addWidget(self.start_button)
        button_layout.addWidget(self.stop_button)
        button_layout.addWidget(self.clear_log_button)
        button_layout.addWidget(self.manual_image_button)
        button_layout.addWidget(self.manual_select_button)
        layout.addLayout(button_layout)

        # 亮度調整滑桿
        brightness_layout = QHBoxLayout()
        brightness_label = QLabel("亮度")
        self.brightness_slider = QSlider(Qt.Orientation.Horizontal)
        self.brightness_slider.setRange(-100, 100)  # 範圍 -100 ~ 100
        self.brightness_slider.setValue(0)
        self.brightness_slider.valueChanged.connect(self.update_brightness)
        brightness_layout.addWidget(brightness_label)
        brightness_layout.addWidget(self.brightness_slider)
        layout.addLayout(brightness_layout)

        # 對比度調整滑桿
        contrast_layout = QHBoxLayout()
        contrast_label = QLabel("對比度")
        self.contrast_slider = QSlider(Qt.Orientation.Horizontal)
        self.contrast_slider.setRange(10, 300)  # 10% 到 300%
        self.contrast_slider.setValue(100)
        self.contrast_slider.valueChanged.connect(self.update_contrast)
        contrast_layout.addWidget(contrast_label)
        contrast_layout.addWidget(self.contrast_slider)
        layout.addLayout(contrast_layout)

        # 日誌區域(顯示掃描結果)
        layout.addWidget(QLabel("<b>掃描日誌:</b>"))
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        layout.addWidget(self.log_text)

        # 增加空白區域讓排版更好看
        spacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
        layout.addItem(spacer)

        # 加入邊框樣式的外框
        main_frame = QFrame()
        main_frame.setLayout(layout)
        main_frame.setStyleSheet("border: 2px solid #4CAF50; border-radius: 5px; padding: 10px;")

        # 加到主視窗佈局中
        central_layout = QVBoxLayout(self)
        central_layout.addWidget(main_frame)
        self.setLayout(central_layout)

    # 啟動攝影機掃描
    def start_scanning(self):
        self.manual_image = None  # 清除手動載入的圖片
        self.capture = cv2.VideoCapture(self.camera_index)  # 開啟攝影機
        if not self.capture.isOpened():  # 無法打開則顯示錯誤
            self.log_message("無法開啟相機")
            return
        self.timer.start(30)  # 啟動計時器,每 30ms 更新一張畫面
        self.start_button.setEnabled(False)
        self.stop_button.setEnabled(True)
        self.log_message("開始掃描...")

    # 停止攝影機與計時器
    def stop_scanning(self):
        if self.capture and self.capture.isOpened():
            self.capture.release()  # 釋放攝影機資源
        self.timer.stop()
        self.image_label.clear()  # 清除畫面
        self.image_label.setStyleSheet("background-color: black;")
        self.start_button.setEnabled(True)
        self.stop_button.setEnabled(False)
        self.log_message("停止掃描")

    # 每次定時器觸發時執行,更新畫面
    def update_frame(self):
        if self.capture and self.capture.isOpened():
            ret, frame = self.capture.read()
            if ret:
                adjusted_frame = self.adjust_brightness_contrast(frame)
                self.decode_qr_codes(adjusted_frame)
        elif self.manual_image is not None and not self.manual_image_decoded:
            adjusted_image = self.adjust_brightness_contrast(self.manual_image)
            self.decode_qr_codes(adjusted_image)
        elif self.manual_image is not None:
            adjusted_image = self.adjust_brightness_contrast(self.manual_image)
            rgb_image = cv2.cvtColor(adjusted_image, cv2.COLOR_BGR2RGB)
            self.display_image(rgb_image)

    # 依據滑桿設定亮度與對比度,並考慮反色
    def adjust_brightness_contrast(self, frame):
        frame = cv2.convertScaleAbs(frame, alpha=self.contrast, beta=self.brightness)
        if self.invert_enabled:
            frame = cv2.bitwise_not(frame)
        return frame

    # 解碼 QR Code 並畫上邊框與結果
    def decode_qr_codes(self, frame):
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        barcodes = pyzbar.decode(gray)

        if not barcodes:
            rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            self.display_image(rgb_frame)
            return

        self.manual_image_decoded = True
        img_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
        draw = ImageDraw.Draw(img_pil)

        try:
            font = ImageFont.truetype("arial.ttf", 20)
        except IOError:
            font = ImageFont.load_default()

        for barcode in barcodes:
            barcode_data = barcode.data.decode("utf-8")
            barcode_type = barcode.type

            # 加入日誌(避免重複顯示)
            if barcode_data not in self.logged_codes:
                self.log_message(f"掃描到條碼: {barcode_data} ({barcode_type})")
                self.logged_codes.add(barcode_data)

            # 使用 polygon 畫出旋轉後的 QR code 邊框
            points = barcode.polygon
            if len(points) > 4:
                hull = cv2.convexHull(np.array([(p.x, p.y) for p in points])).squeeze()
                polygon = [(int(x), int(y)) for [x, y] in hull]
            else:
                polygon = [(point.x, point.y) for point in points]

            if len(polygon) > 1:
                draw.line(polygon + [polygon[0]], fill=(255, 0, 0), width=3)

            # 顯示 QR code 資料文字在左上角或第一個點
            if polygon:
                text_position = (polygon[0][0], polygon[0][1] - 25)
                draw.text(text_position, barcode_data, font=font, fill=(0, 255, 0))

        result_frame = cv2.cvtColor(np.asarray(img_pil), cv2.COLOR_RGB2BGR)
        rgb_frame = cv2.cvtColor(result_frame, cv2.COLOR_BGR2RGB)
        self.display_image(rgb_frame)

    # 顯示影像於 image_label
    def display_image(self, rgb_frame):
        # 應用縮放
        if self.zoom_factor != 1.0:
            h, w = rgb_frame.shape[:2]
            new_h = int(h * self.zoom_factor)
            new_w = int(w * self.zoom_factor)
            rgb_frame = cv2.resize(rgb_frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)

        self.current_display_image = rgb_frame.copy()
        h, w, ch = rgb_frame.shape
        bytes_per_line = ch * w
        qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
        pixmap = QPixmap.fromImage(qt_image)

        # 若有框選,畫出紅框
        if self.selection_rect:
            painter = QPainter(pixmap)
            pen = QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.DashLine)
            painter.setPen(pen)
            painter.drawRect(self.selection_rect)
            painter.end()

        scaled_pixmap = pixmap.scaled(self.image_label.size(), Qt.AspectRatioMode.KeepAspectRatio)
        self.image_label.setPixmap(scaled_pixmap)

    # 顯示訊息到日誌
    def log_message(self, message):
        self.log_text.append(message)
        self.log_text.ensureCursorVisible()

    # 清除日誌內容並重置已辨識條碼集合
    def clear_log(self):
        self.log_text.clear()
        self.logged_codes.clear()

    # 當視窗關閉時,自動釋放攝影機資源
    def closeEvent(self, event):
        self.stop_scanning()  # 停止掃描與釋放攝影機
        event.accept()  # 接受關閉事件

    # 讓使用者手動選擇一張圖片
    def select_image(self):
        self.stop_scanning()  # 若正在掃描則先停止
        file_dialog = QFileDialog(self)
        file_dialog.setNameFilter("Image Files (*.png *.jpg *.bmp)")  # 限定檔案類型
        file_dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        if file_dialog.exec():  # 若選擇成功
            image_path = file_dialog.selectedFiles()[0]
            self.load_manual_image(image_path)

    # 載入並顯示手動選擇的圖片
    def load_manual_image(self, image_path):
        image = cv2.imread(image_path)  # 使用 OpenCV 讀取圖片
        if image is None:  # 圖片載入失敗
            self.log_message("無法打開圖片")
            self.manual_image = None
            self.image_label.clear()
            self.image_label.setStyleSheet("background-color: black;")
            return
        # 成功載入
        self.manual_image = image
        self.manual_image_decoded = False  # 尚未辨識
        self.selection_rect = None  # 清除框選區域
        self.zoom_factor = 1.0  # 重置縮放比例
        adjusted_image = self.adjust_brightness_contrast(self.manual_image)
        rgb_image = cv2.cvtColor(adjusted_image, cv2.COLOR_BGR2RGB)
        self.display_image(rgb_image)
        self.log_message("已載入手動選擇的圖片")

    # 使用者調整亮度滑桿時觸發
    def update_brightness(self, value):
        self.brightness = value
        self.refresh_manual_image()  # 重新顯示圖片

    # 使用者調整對比度滑桿時觸發
    def update_contrast(self, value):
        self.contrast = value / 100.0  # 轉換為 0.1 ~ 3.0 間的比例
        self.refresh_manual_image()

    # 重新顯示已載入的手動圖片(依據亮度/對比度)
    def refresh_manual_image(self):
        if self.capture and self.capture.isOpened():
            return  # 若攝影機仍開啟,不處理
        elif self.manual_image is not None and not self.manual_image_decoded:
            adjusted_image = self.adjust_brightness_contrast(self.manual_image)
            self.decode_qr_codes(adjusted_image)
        elif self.manual_image is not None:
            adjusted_image = self.adjust_brightness_contrast(self.manual_image)
            rgb_image = cv2.cvtColor(adjusted_image, cv2.COLOR_BGR2RGB)
            self.display_image(rgb_image)

    # 啟用框選辨識模式
    def enable_manual_selection(self):
        if self.manual_image is not None:
            self.selecting = True
            self.selection_rect = None
            self.log_message("請用滑鼠框選區域進行識別 (按 Esc 可取消)")

    # 滑鼠按下事件 - 開始框選
    def mousePressEvent(self, event):
        if self.selecting and event.button() == Qt.MouseButton.LeftButton:
            self.selection_start = event.position().toPoint()  # 記錄起點

    # 滑鼠移動事件 - 更新選取框
    def mouseMoveEvent(self, event):
        if self.selecting and self.selection_start:
            self.selection_end = event.position().toPoint()  # 記錄終點
            self.selection_rect = QRect(self.selection_start, self.selection_end)  # 建立矩形
            self.display_image(self.current_display_image)  # 顯示帶框的畫面

    # 滑鼠放開事件 - 完成框選並執行辨識
    def mouseReleaseEvent(self, event):
        if self.selecting and event.button() == Qt.MouseButton.LeftButton and self.selection_rect:
            self.process_selected_region()  # 處理選取區域
            self.selection_start = None
            self.selection_end = None
            self.selection_rect = None  # 清除框選框(保持模式)
            self.display_image(self.current_display_image)

    # 按下鍵盤 Esc 取消框選模式
    def keyPressEvent(self, event):
        if event.key() == Qt.Key.Key_Escape:
            self.selecting = False
            self.selection_rect = None
            self.display_image(self.current_display_image)
            self.log_message("已取消框選模式")

    # 處理框選的影像區域
    def process_selected_region(self):
        if not self.selection_rect or self.manual_image is None:
            return

        # 計算實際圖片與 QLabel 的比例
        label_size = self.image_label.size()
        img_h, img_w, _ = self.manual_image.shape
        scale_x = img_w / self.image_label.width()
        scale_y = img_h / self.image_label.height()

        # 將框選的畫面轉換為對應圖片中的區域
        x = int(self.selection_rect.x() * scale_x)
        y = int(self.selection_rect.y() * scale_y)
        w = int(self.selection_rect.width() * scale_x)
        h = int(self.selection_rect.height() * scale_y)

        roi = self.manual_image[y:y + h, x:x + w]  # 擷取 ROI 區域
        if roi.size == 0:
            self.log_message("選取區域無效")
            return

        # 框選區域放大
        self.zoom_factor = min(self.max_zoom, max(self.min_zoom, 2.0))  # 固定放大2倍
        adjusted_roi = self.adjust_brightness_contrast(roi)
        self.decode_qr_codes(adjusted_roi)

    # 切換反色功能
    def toggle_invert(self):
        self.invert_enabled = not self.invert_enabled
        if self.invert_enabled:
            self.log_message("反色功能已開啟")
        else:
            self.log_message("反色功能已關閉")
        self.refresh_manual_image()

    # 鼠標滾輪事件 - 放大縮小圖像
    def wheelEvent(self, event):
        if self.manual_image is not None:
            delta = event.angleDelta().y()
            if delta > 0:
                self.zoom_factor = min(self.max_zoom, self.zoom_factor * 1.1)
            else:
                self.zoom_factor = max(self.min_zoom, self.zoom_factor / 1.1)
            self.refresh_manual_image()


# 主程式進入點,啟動應用程式
if __name__ == '__main__':
    app = QApplication(sys.argv)  # 建立 QApplication 物件
    window = QRCodeScannerApp()  # 建立主視窗
    window.show()  # 顯示視窗
    sys.exit(app.exec())  # 執行應用程式主迴圈
最近发表
标签列表