Máy scan tài liệu bằng Raspberry Pi và Camera

Bạn có một chiếc Raspberry Pi và camera module? Hãy biến chúng thành một chiếc máy scan tài liệu mini có giao diện trực quan, khả năng crop viền tự động, và xem trước ảnh trước khi lưu mà không cần phần cứng phức tạp. Máy scan tài liệu này cho phép xem trước hình ảnh trực tiếp từ camera, giúp căn khung dễ dàng. Chỉ với một nút bấm, bạn có thể chụp tài liệu, ảnh sẽ được xử lý bằng OpenCV để tự động cắt viền và hiển thị rõ nét. Người dùng có thể xem lại ảnh ngay trên giao diện và ảnh được lưu dưới dạng JPG với tên file kèm thời gian, tiện cho việc sắp xếp và tra cứu.

Linh kiện sử dụng

Giảm 7%
Giá từ: 1.720.000 
Sản phẩm này có nhiều biến thể. Các tùy chọn có thể được chọn trên trang sản phẩm
Giảm 14%
Giá gốc là: 350.000 ₫.Giá hiện tại là: 300.000 ₫.
Giá từ: 890.000 
Sản phẩm này có nhiều biến thể. Các tùy chọn có thể được chọn trên trang sản phẩm

Ngoài ra, bạn có thể chuẩn bị thêm màn hình, bàn phím và chuột để sử dụng Pi hoặc là sử dụng Pi trên laptop cá nhân thông qua VNC Viewer.

Giao diện người dùng

Ảnh giao diện khi chưa ấn chụp thì sẽ có thông báo “No image captured yet”.

Giao diện người dùng khá là đơn giản với hai nút ấn là “Capture” để chụp ảnh và “View Last Image” để xem ảnh mới chụp gần đây nhất.

Sau khi đã chụp ảnh thì trên giao diện sẽ có tên file ảnh mới chụp

Hình ảnh sau khi chụp sẽ được lưu vào Raspberry Pi

Ảnh gốc và ảnh scan

Viết chương trình

Đầu tiên, bạn cần cài đặt đủ thư viện để chạy chương trình:

sudo apt update
sudo apt install python3-picamera2 python3-opencv python3-pil -y

Mã Python:

import tkinter as tk
from tkinter import Label, Button
from PIL import Image, ImageTk
import cv2
import time
import numpy as np
import threading
from picamera2 import Picamera2


# ====== Function to auto-crop paper edges from the image ======
def auto_crop_document(image):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    edged = cv2.Canny(blur, 75, 200)

    # Find contours
    contours, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    contours = sorted(contours, key=cv2.contourArea, reverse=True)

    # Try to find the largest rectangular contour (assumed to be the paper)
    for c in contours:
        approx = cv2.approxPolyDP(c, 0.02 * cv2.arcLength(c, True), True)
        if len(approx) == 4:
            pts = approx.reshape(4, 2)
            rect = order_points(pts)
            return four_point_transform(image, rect)

    return image  # If not found, return original image


# Order points in consistent top-left, top-right, bottom-right, bottom-left
def order_points(pts):
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0], rect[2] = pts[np.argmin(s)], pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1], rect[3] = pts[np.argmin(diff)], pts[np.argmax(diff)]
    return rect


# Warp perspective to get top-down scanned version
def four_point_transform(image, pts):
    (tl, tr, br, bl) = pts
    width = int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))
    height = int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))
    dst = np.array([[0, 0], [width - 1, 0],
                    [width - 1, height - 1], [0, height - 1]], dtype="float32")
    M = cv2.getPerspectiveTransform(pts, dst)
    return cv2.warpPerspective(image, M, (width, height))


# ====== GUI Class ======
class DocumentScannerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Document Scanner")
        self.last_image_path = None

        # Live preview label
        self.label = Label(root)
        self.label.pack()

        # Buttons for capture and view
        btn_frame = tk.Frame(root)
        btn_frame.pack(pady=10)

        self.capture_btn = Button(btn_frame, text="Capture", command=self.capture_image_thread, width=15)
        self.capture_btn.grid(row=0, column=0, padx=10)

        self.show_btn = Button(btn_frame, text="View Last Image", command=self.show_image_thread, width=20)
        self.show_btn.grid(row=0, column=1, padx=10)

        # Status label
        self.status_label = Label(root, text="No image captured yet.")
        self.status_label.pack(pady=5)

        # Initialize camera
        self.picam2 = Picamera2()
        self.picam2.preview_configuration.main.size = (640, 480)
        self.picam2.preview_configuration.main.format = "RGB888"
        self.picam2.configure("preview")
        self.picam2.start()

        # Start live updating the preview
        self.update_preview()

        # Handle window close
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    # Continuously update camera preview
    def update_preview(self):
        try:
            frame = self.picam2.capture_array()
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            img = Image.fromarray(rgb)
            imgtk = ImageTk.PhotoImage(image=img)
            self.label.imgtk = imgtk
            self.label.configure(image=imgtk)
        except:
            pass
        self.root.after(30, self.update_preview)

    # Start image capture in a separate thread
    def capture_image_thread(self):
        threading.Thread(target=self.capture_image).start()

    # Capture, crop and save image
    def capture_image(self):
        frame = self.picam2.capture_array()
        cropped = auto_crop_document(frame)
        timestamp = time.strftime("%Y%m%d-%H%M%S")
        path = f"scan_{timestamp}.jpg"
        cv2.imwrite(path, cropped)
        self.last_image_path = path
        self.status_label.config(text=f"Saved: {path}")
        print(f"Saved image: {path}")

    # Start viewing image in a separate thread
    def show_image_thread(self):
        threading.Thread(target=self.show_last_image).start()

    # Open window to display the last captured image
    def show_last_image(self):
        if self.last_image_path:
            img = cv2.imread(self.last_image_path)
            if img is not None:
                rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
                img_pil = Image.fromarray(rgb)
                imgtk = ImageTk.PhotoImage(image=img_pil)

                top = tk.Toplevel(self.root)
                top.title("Captured Image")
                lbl = Label(top, image=imgtk)
                lbl.image = imgtk  # keep reference
                lbl.pack()
            else:
                print("Could not read image.")
        else:
            print("No image captured yet.")

    # Clean up camera and exit
    def on_close(self):
        print("Closing application...")
        self.picam2.stop()
        self.root.destroy()

# ====== Run the app ======
if __name__ == "__main__":
    root = tk.Tk()
    app = DocumentScannerApp(root)
    root.mainloop()

Kết luận

Chỉ với một chiếc Raspberry Pi và camera module, bạn đã có thể tạo ra một máy scan tài liệu nhỏ gọn, tiện dụng và thông minh. Dự án này không chỉ giúp bạn số hóa tài liệu nhanh chóng mà còn mở ra nhiều tiềm năng ứng dụng khác trong giáo dục, văn phòng hay cá nhân.