CONTOURS, TÍNH TOẠ ĐỘ TÂM $(x, y)$ & VẼ VÒNG TRÒN THEO DẤU

Buổi 3: Dùng Contours & Moments để tự động khoanh vùng và tính tọa độ tâm $(x, y)$ vật thể!

Sau Buổi 2, chúng ta đã có tấm mặt nạ (Mask) màu trắng trên nền đen. Ở buổi này, ta sẽ dạy máy tính:

  1. Nhìn vào các đốm trắng đó và xác định đường viền bao quanh (Contour).

  2. Lọc bỏ các đốm nhiễu li ti, chỉ giữ lại vật thể chính.

  3. Tính toán trọng tâm $(x, y)$ và vẽ vòng tròn bao quanh đối tượng theo thời gian thực.

PHẦN 1: BẢN CHẤT CỦA CONTOURS VÀ TRỌNG TÂM (MOMENTS)

1. Contour (Đường bao) là gì?

  • Contour là đường cong khép kín nối liền tất cả các điểm ảnh liên tục có cùng màu sắc hoặc độ sáng.

  • Trong OpenCV, khi ta đưa tấm ảnh Mask nhị phân (đen-trắng) vào hàm cv2.findContours(), máy tính sẽ tự động quét và vẽ ra các đường viền bao quanh từng mảng màu trắng.

2. Cách tính trọng tâm $(C_x, C_y)$ bằng Hình học giải tích (Image Moments)

Khi đã có đường bao quanh vật thể, làm thế nào máy tính biết tâm điểm $(x, y)$ nằm ở đâu?

OpenCV sử dụng khái niệm Moments (tương tự như công thức tính khối tâm/trọng tâm trong vật lý):

  • $M_{00}$: Tổng diện tích của hình phẳng (tổng số điểm ảnh trắng bên trong đường bao).

  • $M_{10}$: Moment cấp 1 theo trục $X$ (tổng hoành độ của tất cả các pixel).

  • $M_{01}$: Moment cấp 1 theo trục $Y$ (tổng tung độ của tất cả các pixel).

Tọa độ trọng tâm $(C_x, C_y)$ được tính bằng công thức chia trung bình:

$$C_x = \frac{M_{10}}{M_{00}}, \quad C_y = \frac{M_{01}}{M_{00}}$$

💡 Điểm kết nối thú vị cho bé: Tọa độ $(C_x, C_y)$ này chính là dữ liệu "vàng"! Về sau, khi học sang phần Socket, bé chỉ cần gửi cặp số $(x, y)$ này qua mạng để điều khiển con trỏ chuột máy tính hoặc điều khiển bánh lái xe robot tự hành.

PHẦN 2: THỰC HÀNH VIẾT CODE HƯỚNG ĐỐI TƯỢNG (OOP)

Ta sẽ tạo file buoi3_object_tracker.py. Chương trình sẽ:

  • Lọc màu của vật thể (sử dụng thông số HSV bé đã tìm được ở Buổi 2).

  • Tìm tất cả contours trên mặt nạ.

  • Tìm contour có diện tích lớn nhất (vật thể chính) và bỏ qua các đốm nhiễu nhỏ.

  • Tính bán kính nhỏ nhất bao quanh vật (cv2.minEnclosingCircle), vẽ vòng tròn và chấm đỏ ngay tâm vật.

import cv2
import numpy as np

class ObjectTracker:
    def __init__(self, camera_index=0):
        self.cap = cv2.VideoCapture(camera_index)
        
        # Dải màu HSV cần theo dõi (Ví dụ mặc định: màu cam / vàng)
        # Hai bố con có thể thay bằng bộ số đã tìm được từ Buổi 2
        self.lower_color = np.array([5, 100, 100])
        self.upper_color = np.array([25, 255, 255])
        
        # Ngưỡng diện tích tối thiểu để tránh nhận nhầm hạt bụi/nhiễu sáng
        self.min_area = 500

    def process_frame(self, frame):
        """Xử lý tìm kiếm và định vị vật thể trên khung hình"""
        # 1. Lật gương
        frame = cv2.flip(frame, 1)
        
        # 2. Làm mờ nhẹ ảnh để giảm bớt nhiễu hạt trước khi lọc màu
        blurred = cv2.GaussianBlur(frame, (11, 11), 0)
        
        # 3. Chuyển sang HSV và tạo Mask
        hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)
        mask = cv2.inRange(hsv, self.lower_color, self.upper_color)
        
        # 4. Tẩy nhiễu mặt nạ (Erosion & Dilation)
        # Giúp lấp các lỗ rỗng bên trong và xóa các đốm li ti bên ngoài
        mask = cv2.erode(mask, None, iterations=2)
        mask = cv2.dilate(mask, None, iterations=2)
        
        # 5. Tìm các đường bao (Contours)
        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        
        center = None
        
        # Nếu tìm thấy ít nhất 1 đường bao
        if len(contours) > 0:
            # Chọn contour có diện tích lớn nhất (loại bỏ các vật nhỏ trùng màu ở nền)
            largest_contour = max(contours, key=cv2.contourArea)
            area = cv2.contourArea(largest_contour)
            
            if area > self.min_area:
                # Tìm đường tròn nhỏ nhất bao quanh vật thể
                ((x, y), radius) = cv2.minEnclosingCircle(largest_contour)
                
                # Tính trọng tâm chính xác bằng Moments
                M = cv2.moments(largest_contour)
                if M["m00"] != 0:
                    center_x = int(M["m10"] / M["m00"])
                    center_y = int(M["m01"] / M["m00"])
                    center = (center_x, center_y)
                    
                    # Vẽ vòng tròn vàng bao quanh vật thể
                    cv2.circle(frame, (int(x), int(y)), int(radius), (0, 255, 255), 2)
                    
                    # Vẽ chấm đỏ đánh dấu trọng tâm
                    cv2.circle(frame, center, 5, (0, 0, 255), -1)
                    
                    # Hiển thị tọa độ (x, y) lên màn hình
                    coord_text = f"Tam: ({center_x}, {center_y})"
                    cv2.putText(frame, coord_text, (center_x + 10, center_y - 10),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)

        return frame, mask, center

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

        print(" Chương trình theo dõi vật thể đang chạy...")
        print(" -> Nhấn [Q] để thoát")

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

            processed_frame, mask, center = self.process_frame(frame)

            # In tọa độ ra Terminal nếu tìm thấy tâm
            if center is not None:
                print(f"📍 Tọa độ hiện tại: X = {center[0]:4d} | Y = {center[1]:4d}")

            cv2.imshow("Camera Tracking", processed_frame)
            cv2.imshow("Mask Nhi Phan", mask)

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

        self.cleanup()

    def cleanup(self):
        self.cap.release()
        cv2.destroyAllWindows()
        print(" Đã đóng chương trình.")

if __name__ == "__main__":
    app = ObjectTracker(camera_index=0)
    app.run()

PHÂN BIỆT ĐỐI TƯỢNG MASK AND CONTOURS

đây là một điểm rất nhiều người mới học xử lý ảnh hay nhầm lẫn giữa Mask và Contour!

Nói ngắn gọn:

  • Mask (Mặt nạ) mới là một ma trận (Matrix) NumPy 2 chiều chứa các điểm ảnh màu trắng (255) và đen (0).

  • Còn Contour (Đường viền bao) KHÔNG PHẢI là một bức ảnh hay ma trận điểm ảnh, mà nó là MỘT DANH SÁCH CÁC TỌA ĐỘ HÌNH HỌC $(x, y)$ nối tiếp nhau.

1. Phân biệt trực quan: Mask vs Contour

Hãy tưởng tượng bài toán bé vẽ hình một quả táo:

  • Mask (Ảnh ma trận):

    • Giống như việc bé dùng bút lông tô kín toàn bộ ruột quả táo bằng màu trắng, còn xung quanh là nền đen.

    • Bản chất nó là một bảng ô vuông $H \times W$ điểm ảnh: ô nào thuộc quả táo thì mang số 255, ô nào ngoài quả táo mang số 0.

  • Contour (Danh sách tọa độ):

    • Không quan tâm phần ruột bên trong.

    • Nó giống như việc bé lấy sợi chỉ viền quanh mép ngoài của quả táo, rồi chấm các mốc tọa độ: "Điểm 1 ở $(10, 20)$, Điểm 2 ở $(11, 22)$, Điểm 3 ở $(15, 30)$...".

    • Máy tính chỉ lưu danh sách các đỉnh mốc này để tiết kiệm bộ nhớ!

2. Dưới "kính hiển vi" của Python: Cấu trúc dữ liệu của Contour

Khi anh gọi lệnh:

Python
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

  1. contours là một Python List (danh sách). Nếu trên ảnh có 3 đốm trắng tách rời nhau, contours sẽ có 3 phần tử.

  2. Mỗi phần tử trong danh sách đó (tức là 1 đường contour) là một mảng NumPy có kích thước (N, 1, 2):

    • $N$: Số lượng điểm mốc nằm trên đường viền.

    • $1$: Chiều bao (OpenCV cấu trúc để tương thích với C++).

    • $2$: Chính là cặp tọa độ $[x, y]$ của điểm mốc đó.

Anh có thể cho bé in thử trực tiếp ra màn hình Terminal bằng lệnh này để thấy rõ bản chất:

Python
# Giả sử lấy đường contour đầu tiên
c = contours[0]

print("Kiểu dữ liệu:", type(c))          # Sẽ in ra: <class 'numpy.ndarray'>
print("Hình dạng (Shape):", c.shape)       # Ví dụ: (45, 1, 2) -> có 45 điểm mốc
print("Tọa độ 3 điểm đầu tiên:\n", c[:3])

Kết quả in ra sẽ trông như thế này (chỉ toàn là số tọa độ $x, y$):

Plaintext
[[[120  45]]
 [[121  46]]
 [[125  50]]]

3. Ý nghĩa tham số CHAIN_APPROX_SIMPLE (Mẹo tối ưu dung lượng)

Tại sao lại lưu tọa độ thay vì lưu ảnh viền trắng? Vì nó nhẹ hơn hàng trăm lần!

Hãy giải thích cho bé bằng ví dụ về một Hình chữ nhật:

  • Nếu lưu dạng viền ảnh (hoặc dùng cv2.CHAIN_APPROX_NONE): Máy tính phải lưu tọa độ của tất cả các điểm ảnh nằm dọc theo 4 cạnh (có thể lên tới hàng nghìn điểm).

  • Nếu dùng cv2.CHAIN_APPROX_SIMPLE (Nén hình học): OpenCV rất thông minh, nó nhận ra 4 cạnh là các đoạn thẳng nên nó chỉ lưu đúng 4 điểm góc (đỉnh) của hình chữ nhật:

    $$\text{Contour lúc này chỉ gồm 4 cặp: } [(x_1, y_1), (x_2, y_2), (x_3, y_3), (x_4, y_4)]$$

    Nhưng từ 4 điểm này, OpenCV vẫn tính được chu vi, diện tích và trọng tâm của cả hình!

4. Vậy tại sao ta lại thấy nó "vẽ thành đường viền trắng/xanh"?

Lệnh cv2.findContours() chỉ tìm và trả về danh sách tọa độ.

Chỉ khi nào anh gọi lệnh:

Python
cv2.drawContours(frame, contours, -1, (0, 255, 0), 2)

thì OpenCV mới dùng danh sách tọa độ đó, lấy "bút màu xanh lá" (0, 255, 0) nối các điểm mốc lại với nhau và vẽ đè các pixel màu lên bức ảnh để mắt con người chúng ta nhìn thấy được.

Khác với contours, edges KHÔNG PHẢI là một danh sách tọa độ.

Đối tượng edges trả về từ thuật toán cv2.Canny() thực chất là một bức ảnh ma trận nhị phân 2 chiều (NumPy array), hoàn toàn tương tự như tấm mask mà hai bố con đã làm ở Buổi 2.

1. Bản chất dữ liệu của edges dưới "kính hiển vi"

Khi kiểm tra bằng Python:

Python
print(type(edges))   # <class 'numpy.ndarray'>
print(edges.shape)    # (Chiều cao, Chiều rộng) - ví dụ: (480, 640)
print(edges.dtype)    # uint8 (mỗi pixel là số nguyên từ 0 đến 255)

Nó là một bức ảnh đen - trắng có đúng kích thước bằng ảnh gốc:

  • Điểm ảnh màu đen (0): Những vùng phẳng, đồng màu, không có sự thay đổi đột ngột về độ sáng (không phải mép/viền).

  • Điểm ảnh màu trắng (255): Các điểm xảy ra sự thay đổi độ sáng đột ngột, được thuật toán Canny xác định là nằm trên đường viền/mép cạnh.

Anh có thể dùng lệnh cv2.imshow("Canh bien Canny", edges) để hiển thị cửa sổ lên màn hình. Nó sẽ hiện ra một bức ảnh nền đen kịt với các sợi dây viền màu trắng mảnh như tranh vẽ chì.

2. Sự khác biệt cốt lõi: edges vs contours

Đặc điểm edges (từ cv2.Canny) contours (từ cv2.findContours)
Bản chất Là một Bức Ảnh (Ma trận 2D) Là Cấu Trúc Dữ Liệu Hình Học (List các tọa độ)
Dung lượng lưu trữ Bằng đúng kích thước ảnh gốc ($H \times W$ bytes). Chỉ gồm vài chục đến vài trăm cặp số $(x, y)$ của các điểm mốc.
Mức độ hiểu của máy tính Rất sơ cấp: Máy chỉ biết pixel tại $(100, 200)$ có giá trị $255$ (màu trắng). Máy không biết điểm đó thuộc vật thể nào. Có ngữ nghĩa (Semantic): Máy hiểu một đường viền là một chuỗi điểm khép kín thuộc về 1 vật thể cụ thể.
Khả năng tính toán Không thể trực tiếp tính diện tích, chu vi hay trọng tâm. Dễ dàng tính chu vi (arcLength), diện tích (contourArea), tâm xoay (moments).

3. Mối liên hệ logic giữa Bước 3 và Bước 4 trong đoạn code

Đoạn code của anh thể hiện một đường ống xử lý dữ liệu (pipeline) chuẩn mực trong Computer Vision:

Plaintext
[Ảnh xám blurred] 
       ↓ 
 (Lọc đạo hàm độ sáng)
       ↓
[edges]: Bức ảnh ma trận pixel, chỗ nào là mép thì tô trắng (255)
       ↓
 (findContours quét qua các pixel trắng để gom cụm và lấy tọa độ đỉnh)
       ↓
[contours]: Danh sách các hình học [ [ (x1, y1), (x2, y2), ... ], [ ... ] ]

  • Ở Bước 3 (Canny): Máy tính đóng vai trò như một người thợ tô màu: rà soát từng pixel, thấy chỗ nào màu sắc bị lệch mạnh thì chấm một chấm trắng. Kết quả thu được vẫn chỉ là một bức tranh điểm ảnh rời rạc.

  • Ở Bước 4 (findContours): Nhận bức tranh chấm trắng (edges) đó vào, dò theo các vệt trắng liên tục để gom chúng lại thành từng đường cong, rồi ghi lại danh sách tọa độ các đỉnh mốc để trả về contours.

PHẦN 3: THỬ THÁCH NÂNG CẤP DÀNH CHO BÉ

Sau khi chương trình chạy được và hiển thị tọa độ $(x, y)$ nhấp nháy theo tay cầm vật thể, anh hãy giao cho bé thử thách này:

  • Thử thách "Vẽ vệt sáng dẫn đường" (Motion Trail):

    • Hiện tượng mong muốn: Khi di chuyển quả bóng/bút màu trên không trung, màn hình sẽ lưu lại đường cong quỹ đạo di chuyển của vật giống như một cây bút vẽ phát sáng.

    • Gợi ý tư duy cho con:

      • Dùng một danh sách (List) hoặc hàng đợi collections.deque(maxlen=32) để lưu lại các tọa độ tâm $(x, y)$ của các khung hình trước đó.

      • Trong vòng lặp, dùng lệnh cv2.line() để nối các điểm liên tiếp trong danh sách lại với nhau.