ĐIỀU KHIỂN CỬ CHỈ (GESTURE CONTROL) & BẢNG VẼ MA THUẬT TRÊN KHÔNG

Ở buổi này, chúng ta sẽ nâng cấp từ việc "đếm số ngón tay tĩnh" sang tương tác động theo thời gian thực: xây dựng một Bảng vẽ ma thuật (Virtual Painter).

PHẦN 1: TƯ DUY THUẬT TOÁN BẢNG VẼ

Làm thế nào để phân biệt khi nào người dùng muốn Di chuyển con trỏ (chọn màu) và khi nào muốn Đặt bút vẽ?

Ta thiết lập hệ thống 2 chế độ (Mode):

  1. Chế độ Chọn Màu / Tẩy (Selection Mode):

    • Quy ước: Giơ đồng thời cả Ngón Trỏ và Ngón Giữa ($2$ ngón).

    • Hành vi: Không vẽ gì lên màn hình. Đầu ngón trỏ sẽ là con trỏ để di chuột lên bảng chọn màu ở mép trên cùng màn hình (chọn màu Đỏ, Xanh lá, Xanh dương hoặc Cục tẩy).

  2. Chế độ Vẽ (Drawing Mode):

    • Quy ước: Chỉ giơ duy nhất Ngón Trỏ (ngón giữa gập lại).

    • Hành vi: Tọa độ đầu ngón trỏ (Landmark 8) bắt đầu kẻ các đoạn thẳng liên tiếp theo nét tay di chuyển.

PHẦN 2: KỸ THUẬT VẼ TRÊN KHUNG TOÁN HỌC (CANVAS OVERLAY)

Nếu vẽ trực tiếp các nét mực lên bức ảnh camera frame, mỗi khung hình mới tải từ camera sẽ xóa mất nét vẽ trước đó!

  • Giải pháp chuẩn:

    1. Tạo một tấm bảng đen phụ bằng NumPy: img_canvas = np.zeros_like(frame).

    2. Khi di ngón tay, ta vẽ các đường mực cv2.line() lên img_canvas. Bảng img_canvas này sẽ giữ nguyên toàn bộ các nét vẽ từ đầu đến cuối.

    3. Cuối cùng, dùng phép trộn ảnh hòa trộn frame (ảnh người) và img_canvas (các nét vẽ) lại với nhau.

PHẦN 3: CODE THỰC HÀNH buoi6_virtual_painter.py

import cv2
import numpy as np
from mediapipe.python.solutions import hands as mp_hands
from mediapipe.python.solutions import drawing_utils as mp_draw

class HandTracker:
    def __init__(self):
        self.hands = mp_hands.Hands(
            max_num_hands=1,
            min_detection_confidence=0.8,
            min_tracking_confidence=0.7
        )

    def process(self, frame):
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        res = self.hands.process(rgb)
        lm_list = []
        if res.multi_hand_landmarks:
            h, w, _ = frame.shape
            for lm in res.multi_hand_landmarks[0].landmark:
                lm_list.append((int(lm.x * w), int(lm.y * h)))
        return lm_list


class VirtualPainterApp:
    def __init__(self):
        self.cap = cv2.VideoCapture(0)
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
        
        self.tracker = HandTracker()
        self.canvas = None  # Sẽ khởi tạo theo kích thước camera
        
        # Cấu hình màu sắc mặc định: (B, G, R)
        self.draw_color = (0, 0, 255) # Mặc định là màu Đỏ
        self.brush_thickness = 12
        self.eraser_thickness = 45
        
        # Tọa độ điểm vẽ trước đó (để nối đoạn thẳng)
        self.xp, self.yp = 0, 0

    def draw_header_palette(self, frame):
        """Vẽ thanh công cụ chọn màu ở cạnh trên cùng"""
        # Hộp màu Đỏ (100 -> 350)
        cv2.rectangle(frame, (100, 10), (350, 90), (0, 0, 255), cv2.FILLED)
        cv2.putText(frame, "RED", (190, 60), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
        
        # Hộp màu Xanh Lá (400 -> 650)
        cv2.rectangle(frame, (400, 10), (650, 90), (0, 255, 0), cv2.FILLED)
        cv2.putText(frame, "GREEN", (480, 60), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)

        # Hộp màu Xanh Dương (700 -> 950)
        cv2.rectangle(frame, (700, 10), (950, 90), (255, 0, 0), cv2.FILLED)
        cv2.putText(frame, "BLUE", (790, 60), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)

        # Hộp Cục Tẩy (1000 -> 1200)
        cv2.rectangle(frame, (1000, 10), (1200, 90), (50, 50, 50), cv2.FILLED)
        cv2.putText(frame, "ERASER", (1030, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)

    def run(self):
        if not self.cap.isOpened():
            print("❌ Không mở được camera.")
            return

        print("--- BẢNG VẼ MA THUẬT (VIRTUAL PAINTER) ---")
        print(" -> Giơ 2 ngón (Trỏ + Giữa): Chế độ CHỌN MÀU ở menu trên cùng")
        print(" -> Giơ 1 ngón (Chỉ ngón Trỏ): Chế độ VẼ NÉT")
        print(" -> Nhấn [C]: Xóa sạch bảng vẽ")
        print(" -> Nhấn [Q]: Thoát")

        while True:
            ret, frame = self.cap.read()
            if not ret:
                break

            frame = cv2.flip(frame, 1)
            
            # Khởi tạo bảng vẽ phụ nếu chưa có
            if self.canvas is None:
                self.canvas = np.zeros_like(frame)

            # Lấy tọa độ bàn tay
            lm_list = self.tracker.process(frame)

            if len(lm_list) != 0:
                # Tọa độ đầu ngón trỏ (8) và ngón giữa (12)
                x1, y1 = lm_list[8]
                x2, y2 = lm_list[12]

                # Kiểm tra ngón nào đang giơ: Y đỉnh < Y đốt dưới
                index_up = lm_list[8][1] < lm_list[6][1]
                middle_up = lm_list[12][1] < lm_list[10][1]

                # TRƯỜNG HỢP 1: Giơ cả 2 ngón -> CHỌN MÀU
                if index_up and middle_up:
                    self.xp, self.yp = 0, 0 # Reset điểm vẽ trước
                    cv2.rectangle(frame, (x1 - 15, y1 - 15), (x2 + 15, y2 + 15), 
                                  self.draw_color, 2)
                    
                    # Nếu ngón tay chạm vào thanh Menu (Y < 100)
                    if y1 < 100:
                        if 100 < x1 < 350:
                            self.draw_color = (0, 0, 255) # Đỏ
                        elif 400 < x1 < 650:
                            self.draw_color = (0, 255, 0) # Xanh lá
                        elif 700 < x1 < 950:
                            self.draw_color = (255, 0, 0) # Xanh dương
                        elif 1000 < x1 < 1200:
                            self.draw_color = (0, 0, 0)   # Tẩy (màu đen)

                # TRƯỜNG HỢP 2: Chỉ giơ 1 ngón trỏ -> VẼ NÉT
                elif index_up and not middle_up:
                    cv2.circle(frame, (x1, y1), 10, self.draw_color, cv2.FILLED)
                    
                    if self.xp == 0 and self.yp == 0:
                        self.xp, self.yp = x1, y1

                    thick = self.eraser_thickness if self.draw_color == (0, 0, 0) else self.brush_thickness
                    cv2.line(self.canvas, (self.xp, self.yp), (x1, y1), self.draw_color, thick)
                    
                    self.xp, self.yp = x1, y1
                else:
                    self.xp, self.yp = 0, 0

            # Vẽ thanh Menu lên trên cùng của khung hình
            self.draw_header_palette(frame)

            # Hòa trộn nét vẽ từ Canvas vào khung hình Camera
            img_gray = cv2.cvtColor(self.canvas, cv2.COLOR_BGR2GRAY)
            _, img_inv = cv2.threshold(img_gray, 20, 255, cv2.THRESH_BINARY_INV)
            frame_bg = cv2.bitwise_and(frame, frame, mask=img_inv)
            final_frame = cv2.bitwise_or(frame_bg, self.canvas)

            cv2.imshow("Virtual Painter", final_frame)

            key = cv2.waitKey(1) & 0xFF
            if key == ord('q'):
                break
            elif key == ord('c'):
                self.canvas = np.zeros_like(frame) # Xóa sạch bảng

        self.cap.release()
        cv2.destroyAllWindows()

if __name__ == "__main__":
    app = VirtualPainterApp()
    app.run()

Hãy cùng chạy thử và trải nghiệm bảng vẽ tương tác này nhé! C