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:
Nhìn vào các đốm trắng đó và xác định đường viền bao quanh (Contour).
Lọc bỏ các đốm nhiễu li ti, chỉ giữ lại vật thể chính.
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.
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.
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:
💡 Đ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.
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.
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ớ!
Khi anh gọi lệnh:
contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
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ử.
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:
# 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$):
[[[120 45]]
[[121 46]]
[[125 50]]]
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:
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!
Lệnh cv2.findContours() chỉ tìm và trả về danh sách tọa độ.
Chỉ khi nào anh gọi lệnh:
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.
edges dưới "kính hiển vi"Khi kiểm tra bằng 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ì.
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). |
Đ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:
[Ả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.
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.