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
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

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.

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

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.