NHẬN DIỆN 21 KHỚP XƯƠNG BÀN TAY & ĐẾM NGÓN TAY VỚI MEDIAPIPE

PHẦN 1: CÀI ĐẶT THƯ VIỆN

Hai bố con mở Terminal và chạy lệnh sau để cài đặt mediapipe:

Bash
pip install mediapipe

PHẦN 2: BẢN CHẤT CỦA BÀN TAY 21 ĐIỂM (LANDMARKS)

Khi phát hiện bàn tay, MediaPipe trả về 21 điểm mốc tọa độ (từ 0 đến 20) biểu diễn các đốt ngón tay:

Plaintext
               8 (Đầu ngón trỏ)    12 (Đầu ngón giữa)   16 (Đầu áp út)   20 (Đầu ngón út)
               |                   |                    |                |
               7                   11                   15               19
               |                   |                    |                |
               6                   10                   14               18
               |                   |                    |                |
               5                   9                    13               17
                \                  |                   /                /
      4 (Ngón cái) \               |                  /                /
       \            \--------------+-----------------+----------------
        3                          |
        |                          | (Lòng bàn tay)
        2                          |
         \                         |
          1                        |
           \                      /
            0 (Cổ tay - Wrist) --+

Các chỉ số then chốt cần nhớ:

  • Gốc cổ tay: Điểm 0

  • 4 đầu ngón dài: Ngón trỏ (8), Ngón giữa (12), Ngón áp út (16), Ngón út (20).

  • Các khớp đốt thứ hai: Ngón trỏ (6), Ngón giữa (10), Ngón áp út (14), Ngón út (18).

  • Đầu ngón cái: Điểm 4 (và khớp dưới là 3 hoặc 2).

💡 Thuật toán đếm ngón tay (Logic cực kỳ đơn giản cho bé):

Hệ tọa độ ảnh có trục $Y$ hướng từ trên xuống dưới (gốc $(0,0)$ ở góc trên cùng bên trái). Nghĩa là: Càng lên cao thì $Y$ càng NHỎ.

  1. Với 4 ngón dài (Trỏ, Giữa, Áp út, Út):

    • Ngón tay được xem là ĐANG MỞ (Giơ lên) nếu Đầu ngón tay cao hơn Khớp đốt thứ hai:

      $$Y_{\text{đầu ngón}} < Y_{\text{khớp}}$$

      (Ví dụ ngón trỏ: $Y_8 < Y_6 \implies$ Ngón trỏ đang giơ lên).

    • Ngược lại ($Y_{\text{đầu ngón}} \ge Y_{\text{khớp}}$) $\implies$ Ngón tay đang gập lại.

  2. Với Ngón cái:

    • Vì ngón cái co duỗi theo chiều ngang (trục $X$) chứ không co duỗi theo chiều dọc như 4 ngón kia:

    • Nếu úp mu bàn tay về phía camera: Ngón cái xòe ra khi $X_{\text{đầu ngón cái}} < X_{\text{khớp dưới}}$ (hoặc ngược lại tùy tay trái hay tay phải).

PHẦN 3: VIẾT CODE HƯỚNG ĐỐI TƯỢNG (OOP) CHO BỘ NHẬN DIỆN

Tạo file buoi5_hand_tracking.py:

import os
import urllib.request
import cv2
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision


class HandDetector:
    """Module AI nhận diện bàn tay và phát hiện ngón mở/gập (tương thích MediaPipe Tasks API mới)"""
    def __init__(self, mode=False, max_hands=1, detection_con=0.7, track_con=0.5):
        # Đường dẫn file model hand_landmarker.task
        script_dir = os.path.dirname(os.path.abspath(__file__))
        self.model_path = os.path.join(script_dir, "hand_landmarker.task")

        # Tự động tải model nếu chưa có sẵn
        if not os.path.exists(self.model_path):
            print("⏳ Đang tự động tải mô hình MediaPipe Hand Landmarker (~7.8MB)...")
            url = "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task"
            urllib.request.urlretrieve(url, self.model_path)
            print("✅ Tải mô hình hoàn tất!")

        # Khởi tạo MediaPipe Tasks HandLandmarker
        base_options = python.BaseOptions(model_asset_path=self.model_path)
        options = vision.HandLandmarkerOptions(
            base_options=base_options,
            num_hands=max_hands,
            min_hand_detection_confidence=detection_con,
            min_tracking_confidence=track_con
        )
        self.detector = vision.HandLandmarker.create_from_options(options)
        self.results = None

        # 21 điểm mốc nối xương bàn tay chuẩn MediaPipe
        self.connections = [
            (0, 1), (1, 2), (2, 3), (3, 4),           # Ngón cái
            (0, 5), (5, 6), (6, 7), (7, 8),           # Ngón trỏ
            (5, 9), (9, 10), (10, 11), (11, 12),      # Ngón giữa
            (9, 13), (13, 14), (14, 15), (15, 16),    # Ngón áp út
            (13, 17), (17, 18), (18, 19), (19, 20),   # Ngón út
            (0, 17)                                    # Lòng bàn tay
        ]

        # Chỉ số đầu ngón và khớp ngón tương ứng (4 ngón dài)
        # Cấu trúc: [ID_Đầu_ngón, ID_Khớp_dưới]
        self.tip_ids = [8, 12, 16, 20]
        self.pip_ids = [6, 10, 14, 18]

    def find_hands(self, frame, draw=True):
        """Phát hiện bàn tay và vẽ khung xương lên ảnh"""
        rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_frame)
        self.results = self.detector.detect(mp_image)

        if draw and self.results and self.results.hand_landmarks:
            h, w, _ = frame.shape
            for hand_landmarks in self.results.hand_landmarks:
                # 1. Vẽ các đường nối xương (màu xanh lá tươi)
                for start_idx, end_idx in self.connections:
                    p1 = hand_landmarks[start_idx]
                    p2 = hand_landmarks[end_idx]
                    pt1 = (int(p1.x * w), int(p1.y * h))
                    pt2 = (int(p2.x * w), int(p2.y * h))
                    cv2.line(frame, pt1, pt2, (0, 255, 0), 2)

                # 2. Vẽ 21 khớp tròn (màu đỏ viền trắng)
                for lm in hand_landmarks:
                    cx, cy = int(lm.x * w), int(lm.y * h)
                    cv2.circle(frame, (cx, cy), 5, (0, 0, 255), cv2.FILLED)
                    cv2.circle(frame, (cx, cy), 5, (255, 255, 255), 1)

        return frame

    def find_positions(self, frame):
        """Trích xuất danh sách tọa độ (id, x, y) tính bằng đơn vị Pixel"""
        lm_list = []
        if self.results and self.results.hand_landmarks and len(self.results.hand_landmarks) > 0:
            my_hand = self.results.hand_landmarks[0]
            h, w, _ = frame.shape
            for id, lm in enumerate(my_hand):
                # Tọa độ lm.x, lm.y được chuẩn hóa từ 0.0 đến 1.0 -> nhân với W, H để ra pixel
                cx, cy = int(lm.x * w), int(lm.y * h)
                lm_list.append((id, cx, cy))
        return lm_list

    def count_fingers(self, lm_list):
        """Đếm số ngón tay đang giơ lên"""
        if len(lm_list) == 0:
            return 0

        fingers = []

        # 1. Kiểm tra Ngón Cái (Trục X)
        # Khi soi gương (cv2.flip = 1), tay phải giơ lên ngón cái hướng về bên trái (X đầu ngón < X khớp)
        if lm_list[4][1] < lm_list[3][1]:
            fingers.append(1)  # Đang mở
        else:
            fingers.append(0)  # Đang gập

        # 2. Kiểm tra 4 ngón dài (Trục Y: Điểm ngọn có Y nhỏ hơn điểm gốc không?)
        for tip, pip in zip(self.tip_ids, self.pip_ids):
            if lm_list[tip][2] < lm_list[pip][2]:
                fingers.append(1)  # Giơ lên
            else:
                fingers.append(0)  # Gập xuống

        # Trả về tổng số ngón tay đang mở (từ 0 đến 5)
        return fingers.count(1)


class FingerCounterApp:
    def __init__(self):
        self.cap = cv2.VideoCapture(0)
        self.detector = HandDetector()

    def run(self):
        if not self.cap.isOpened():
            print("❌ Không thể kết nối Webcam.")
            return

        print(" Chương trình đếm ngón tay AI đang chạy...")
        print(" -> Nhấn [Q] để thoát")

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

            # Lật gương để phản xạ tự nhiên như soi gương
            frame = cv2.flip(frame, 1)

            # 1. Phát hiện bàn tay và vẽ khung xương
            frame = self.detector.find_hands(frame, draw=True)

            # 2. Lấy tọa độ 21 điểm mốc
            lm_list = self.detector.find_positions(frame)

            # 3. Đếm ngón tay và hiển thị số lượng
            finger_count = self.detector.count_fingers(lm_list)

            # Vẽ ô hiển thị kết quả màu tím nổi bật
            cv2.rectangle(frame, (20, 20), (180, 140), (255, 0, 128), cv2.FILLED)
            cv2.putText(frame, str(finger_count), (70, 110), 
                        cv2.FONT_HERSHEY_PLAIN, 6, (255, 255, 255), 6)

            cv2.imshow("Buoi 5: MediaPipe Finger Counter", frame)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                break

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


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

    app.run()

PHẦN 4: THỬ THÁCH SUY LUẬN CHO BÉ

Sau khi chạy thử chương trình, giơ từ $1, 2, 3, 4, 5$ ngón tay trước camera và thấy số nhảy chính xác, hãy hỏi bé:

  1. Thử thách 1 (Bắt bài ngón cái):

    • Cho bé thử đổi giữa Tay Phải và Tay Trái.

    • Bé sẽ phát hiện ra: Ngón cái của một trong hai tay đếm rất chuẩn, nhưng đổi sang tay kia thì ngón cái bị tính ngược lại!

    • Câu hỏi cho bé: Tại sao lại như vậy? (Vì khi đổi tay, ngón cái xòe sang hướng đối diện của trục $X$). MediaPipe có thuộc tính nào cho biết bàn tay đang nhận là Tay Trái (Left) hay Tay Phải (Right) không?

  2. Thử thách 2 (Ký hiệu chiến thắng - Victory sign):

    • Làm sao để viết một điều kiện kiểm tra xem người dùng có đang làm cử chỉ Peace/Victory (chỉ giơ 2 ngón: ngón trỏ và ngón giữa, 3 ngón còn lại gập) hay không?

Dưới đây là lời giải chi tiết cho 2 thử thách của Buổi 5 trước khi chúng ta bước tiếp sang Buổi 6.

PHẦN 1: GIẢI THỬ THÁCH BUỔI 5

1. Thử thách 1: Xử lý ngón cái cho cả Tay Trái và Tay Phải (Handedness)

Nguyên nhân hiện tượng:

  • Trục $X$ chạy từ trái sang phải ($0 \rightarrow W$).

  • Khi ngửa bàn tay hướng về phía camera:

    • Với tay phải: Ngón cái xòe sang bên trái màn hình $\implies X_{\text{đầu ngón cái (4)}} < X_{\text{khớp (3)}}$.

    • Với tay trái: Ngón cái xòe sang bên phải màn hình $\implies X_{\text{đầu ngón cái (4)}} > X_{\text{khớp (3)}}$.

  • Vì vậy, nếu chỉ dùng 1 điều kiện so sánh cố định, ngón cái của một trong hai tay chắc chắn sẽ bị tính ngược.

Giải pháp với MediaPipe:

MediaPipe cung cấp thuộc tính results.multi_handedness chứa thông tin nhãn bàn tay (Left hoặc Right).

def get_hand_type(self):
    """Xác định bàn tay đang nhận diện là Tay Trái hay Tay Phải"""
    if self.results.multi_handedness:
        # Lấy nhãn của bàn tay đầu tiên
        hand_type = self.results.multi_handedness[0].classification[0].label
        return hand_type # Trả về chuỗi 'Left' hoặc 'Right'
    return None

Khi kết hợp với cv2.flip(frame, 1) (chế độ soi gương), nhãn Left/Right của MediaPipe đã tương ứng trực quan với góc nhìn của bé:

  • Nếu là Tay Phải (Right): Ngón cái mở khi lm_list[4][1] < lm_list[3][1].

  • Nếu là Tay Trái (Left): Ngón cái mở khi lm_list[4][1] > lm_list[3][1].

2. Thử thách 2: Nhận diện cử chỉ Ký hiệu chiến thắng (Victory / Peace Sign)

  • Điều kiện logic:

    • Ngón trỏ (Tip 8) MỞ: $Y_8 < Y_6$

    • Ngón giữa (Tip 12) MỞ: $Y_{12} < Y_{10}$

    • Ngón áp út (Tip 16) GẬP: $Y_{16} > Y_{14}$

    • Ngón út (Tip 20) GẬP: $Y_{20} > Y_{18}$

    • Ngón cái GẬP vào lòng bàn tay.

  • Khi thỏa mãn đồng thời, ta có thể hiển thị chữ "VICTORY / PEACE" màu xanh lá nổi bật trên màn hình.