Optical flow với OpenCV: đo vận tốc băng tải, phát hiện chuyển động
Lucas-Kanade và Farneback trong OpenCV: đo dịch chuyển từng khung hình, quy đổi ra vận tốc băng tải theo m/s và tìm vùng đang chuyển động, có code kiểm chứng.
Nội dung bài viết
Làm sao biết băng tải đang chạy nhanh hay chậm mà không gắn encoder? Làm sao phát hiện một vật đang trượt trên máng, hay một cánh tay robot chuyển động bất thường? Câu trả lời là optical flow (dòng quang học): ước lượng mỗi điểm trong ảnh đã di chuyển bao nhiêu pixel giữa hai khung hình liên tiếp. Bài này giới thiệu hai thuật toán có sẵn trong OpenCV, Lucas-Kanade (thưa, theo dõi một số điểm) và Farneback (dày, tính cho mọi pixel), và áp dụng vào bài toán đo vận tốc băng tải.
Code chạy với OpenCV-Python và NumPy, dùng ảnh tạo bằng code với độ dịch chuyển biết trước để bạn kiểm chứng kết quả.
Giả định nền tảng của optical flow
- Độ sáng không đổi: một điểm trên vật giữ nguyên độ sáng khi di chuyển giữa hai khung.
- Chuyển động nhỏ: mỗi điểm chỉ dời vài pixel giữa hai khung (kim tự tháp ảnh giúp nới lỏng điều này).
- Lân cận chuyển động giống nhau: các pixel gần nhau di chuyển gần như cùng một vectơ.
Hệ quả quan trọng: vùng không có vân (một mảng màu đồng nhất) thì không đo được chuyển động, vì không có gì để "bám". Bề mặt băng tải cao su, vải, gỗ thường có đủ vân nhỏ để optical flow hoạt động tốt.
1. Tạo hai khung hình
Ta tạo một dải bề mặt có vân ngẫu nhiên rồi cắt hai khung hình lệch nhau đúng 4 px, giống băng tải chạy sang phải giữa hai lần chụp:
import cv2
import numpy as np
rng = np.random.default_rng(0)
# Bề mặt băng tải có vân ngẫu nhiên (lớn hơn khung hình để có thể "trượt")
be_mat = cv2.GaussianBlur(rng.integers(0, 256, (240, 600), dtype=np.uint8), (0, 0), 2)
be_mat = cv2.normalize(be_mat, None, 0, 255, cv2.NORM_MINMAX)
khung_1 = be_mat[:, 100:420].copy() # khung hình 240x320
khung_2 = be_mat[:, 96:416].copy() # băng tải chạy sang phải 4 px giữa 2 khung
print(khung_1.shape, khung_2.shape)
(240, 320) (240, 320)
2. Lucas-Kanade: theo dõi các điểm đặc trưng
Bước 1: chọn những điểm "dễ theo dõi" (góc, chỗ có vân hai chiều) bằng goodFeaturesToTrack. Bước 2: calcOpticalFlowPyrLK tìm vị trí mới của từng điểm ở khung sau.
diem = cv2.goodFeaturesToTrack(khung_1, maxCorners=200, qualityLevel=0.01, minDistance=8)
diem_moi, trang_thai, _ = cv2.calcOpticalFlowPyrLK(khung_1, khung_2, diem, None,
winSize=(21, 21), maxLevel=3)
tot = trang_thai.ravel() == 1
dich = (diem_moi[tot] - diem[tot]).reshape(-1, 2)
print(f"Lucas-Kanade: {tot.sum()}/{len(diem)} điểm theo dõi được")
print(f" dịch chuyển trung vị: dx = {np.median(dich[:, 0]):.2f}, dy = {np.median(dich[:, 1]):.2f} px")
Lucas-Kanade: 200/200 điểm theo dõi được
dịch chuyển trung vị: dx = 4.00, dy = -0.00 px
Đúng 4 px theo phương ngang (giá trị −0,00 chỉ là số âm rất nhỏ, làm tròn về 0). Các tham số chính:
| Tham số | Ý nghĩa |
|---|---|
winSize | Cửa sổ quanh mỗi điểm dùng để so khớp. Lớn hơn thì ổn định hơn với nhiễu nhưng kém chính xác ở chỗ chuyển động không đồng nhất |
maxLevel | Số tầng kim tự tháp. Mỗi tầng giảm nửa kích thước ảnh, nên với 3 tầng bắt được chuyển động lớn hơn nhiều so với cửa sổ |
trang_thai (status) | 1 nếu tìm thấy điểm ở khung sau. Luôn lọc các điểm có status 0 |
3. Farneback: dòng quang học dày
Farneback tính vectơ chuyển động cho mọi pixel. Kết quả là mảng (cao, rộng, 2): kênh 0 là dx, kênh 1 là dy.
dong = cv2.calcOpticalFlowFarneback(khung_1, khung_2, None, pyr_scale=0.5, levels=3,
winsize=15, iterations=3, poly_n=5, poly_sigma=1.2, flags=0)
print("Farneback: kích thước trường dòng =", dong.shape)
vung_giua = dong[20:-20, 20:-20] # bỏ mép, nơi thiếu thông tin
print(f" dịch chuyển trung vị: dx = {np.median(vung_giua[..., 0]):.2f}, dy = {np.median(vung_giua[..., 1]):.2f} px")
Farneback: kích thước trường dòng = (240, 320, 2)
dịch chuyển trung vị: dx = 4.00, dy = -0.00 px
Ý nghĩa các tham số theo tài liệu OpenCV: pyr_scale=0.5 là kim tự tháp cổ điển (mỗi tầng nhỏ đi một nửa), levels là số tầng kể cả ảnh gốc, winsize là cửa sổ lấy trung bình (lớn thì chống nhiễu tốt và bắt chuyển động nhanh tốt hơn nhưng trường dòng mờ hơn), poly_n và poly_sigma điều khiển việc xấp xỉ đa thức quanh mỗi pixel. Mép ảnh bị bỏ khi tính trung vị vì ở đó có phần nội dung mới đi vào khung hình, không có tương ứng ở khung trước.
4. Quy đổi ra vận tốc thật
Có dịch chuyển theo pixel, chỉ cần thêm hai thông số: tỉ lệ mm/pixel (từ quy đổi pixel sang mm) và tốc độ khung hình.
MM_MOI_PX = 0.25 # từ hiệu chuẩn: 1 px = 0,25 mm trên mặt băng tải
FPS = 60 # tốc độ khung hình camera
dx_px = float(np.median(dich[:, 0]))
van_toc = dx_px * MM_MOI_PX * FPS / 1000 # m/s
print(f"Vận tốc băng tải ≈ {van_toc:.3f} m/s ({van_toc * 60:.1f} m/phút)")
Vận tốc băng tải ≈ 0.060 m/s (3.6 m/phút)
4 px × 0,25 mm × 60 khung/giây = 60 mm/s. Trong ứng dụng thật, hãy lấy thời điểm chụp (timestamp) từ camera thay vì giả định FPS cố định, vì camera công nghiệp có thể rớt khung hoặc chạy theo trigger không đều (xem trigger, encoder và cycle time).
5. Phát hiện vùng đang chuyển động
Trường dòng dày cũng cho biết vùng nào đang chuyển động. Ví dụ: băng tải đứng yên, một hộp trượt xuống 5 px:
# Khung 3: băng tải đứng yên, nhưng có một hộp di chuyển xuống dưới 5 px
khung_3 = khung_1.copy()
hop = khung_1[60:120, 140:200].copy()
cv2.rectangle(khung_1, (140, 60), (199, 119), 255, 2) # viền hộp ở khung 1
khung_3[65:125, 140:200] = hop
cv2.rectangle(khung_3, (140, 65), (199, 124), 255, 2) # hộp ở khung 3, thấp hơn 5 px
dong = cv2.calcOpticalFlowFarneback(khung_1, khung_3, None, 0.5, 3, 15, 3, 5, 1.2, 0)
do_lon = np.linalg.norm(dong, axis=2)
mat_na = (do_lon > 2).astype(np.uint8)
x, y, w, h = cv2.boundingRect(mat_na)
print(f"Vùng chuyển động: x={x}, y={y}, w={w}, h={h}")
print(f" dịch chuyển trung vị trong vùng: dy = {np.median(dong[..., 1][mat_na == 1]):.1f} px")
Vùng chuyển động: x=133, y=49, w=74, h=82
dịch chuyển trung vị trong vùng: dy = 5.0 px
Thuật toán tìm đúng hộp và đo đúng dịch chuyển 5 px. Khung bao (74×82) rộng hơn hộp thật (60×65) vì Farneback lấy trung bình trong cửa sổ, nên chuyển động "lan" ra vài pixel quanh biên vật. Nếu cần đường biên chính xác, hãy dùng optical flow để định vị sơ bộ rồi phân đoạn vật bằng phương pháp khác.
Optical flow trong nhà máy: khi nào nên dùng?
| Bài toán | Gợi ý |
|---|---|
| Đo vận tốc băng tải, phát hiện trượt, kẹt | Lucas-Kanade trên một ROI mặt băng tải, lấy trung vị. Nhanh, chạy tốt trên CPU |
| Phát hiện vật rơi, chuyển động bất thường trong vùng an toàn | Farneback trên ảnh thu nhỏ, ngưỡng độ lớn vectơ |
| Ổn định ảnh khi camera rung | Lucas-Kanade + cv2.estimateAffinePartial2D để tìm chuyển động của cả khung hình |
| Đếm, theo dõi từng sản phẩm | Thường đơn giản hơn với phát hiện + theo dõi, như dự án đếm sản phẩm trên băng tải |
| Cần độ chính xác vị trí tuyệt đối cao | Encoder vẫn là chuẩn mực; optical flow phù hợp để giám sát hoặc khi không lắp được encoder |
Bài tập
Thêm nhiễu Gauss (độ lệch chuẩn 10) vào khung_2 và một độ lệch dọc 1 px (dùng be_mat[1:, 96:416] và cắt khung_1 tương ứng cho cùng kích thước). Lucas-Kanade với winSize=(21, 21) và (7, 7) cho kết quả trung vị khác nhau thế nào? Thử thêm độ dịch 30 px với maxLevel=0 và maxLevel=3.
Nguồn tham khảo: OpenCV Tutorials: Optical Flow, OpenCV: calcOpticalFlowPyrLK, calcOpticalFlowFarneback.


