⭐ Vision Engineer · Chặng 3Bài 8/8Dự án~120 phút

Dự án: đếm sản phẩm trên băng tải bằng OpenCV, không đếm trùng

Tạo video băng tải có đáp án, tách sản phẩm, theo dõi tâm để đếm mỗi vật đúng một lần qua vạch, thống kê theo phút, log CSV và ảnh minh chứng.

Dự án: đếm sản phẩm trên băng tải bằng OpenCV, không đếm trùng

Học xong bài này, bạn sẽ

  • Kết hợp phân ngưỡng, morphology, contour
  • Đếm vật đi qua vạch không đếm trùng
  • Xuất kết quả và ảnh minh chứng

Nên học trước: Biến đổi hình học: affine và phối cảnh

Nội dung bài viết
  1. 1. Yêu cầu
  2. 2. Chuẩn bị video mẫu có đáp án
  3. 3. Kiến thức sử dụng
  4. 4. Gợi ý thiết kế
  5. 5. Lời giải mẫu
  6. 6. Tự đánh giá
  7. 7. Mở rộng

Đây là dự án tổng kết chặng 3. Bạn ghép những gì đã học, từ phân ngưỡng (bài "Phân ngưỡng và contour: đếm, đo vật thể"), làm sạch mặt nạ (bài "Morphology: erode, dilate, opening, closing"), đặc trưng contour, đến biến đổi tọa độ (bài "Biến đổi hình học: affine và phối cảnh"), thành một ứng dụng thật: đếm sản phẩm chạy trên băng tải, mỗi sản phẩm đúng một lần, thống kê sản lượng theo từng phút, lưu log và ảnh minh chứng. Bài toán nghe đơn giản nhưng chứa đủ các vấn đề của trạm vision thật: vân băng tải, đèn không đều, sản phẩm có băng keo làm mặt nạ bị đứt, nhiều sản phẩm cùng lúc trên hai làn, và câu hỏi khó nhất: làm sao biết vật ở khung hình này là vật nào ở khung hình trước để không đếm trùng. Toàn bộ dùng thuật toán cổ điển, không cần GPU.

Không cần cameraBạn sẽ tự tạo một video băng tải 3 phút bằng OpenCV, kèm file đáp án ghi chính xác thời điểm từng sản phẩm đi qua vạch đếm. Nhờ vậy bạn chấm điểm được chương trình của mình bằng con số, không phải bằng cảm giác "trông có vẻ đúng". Cần opencv-python và numpy.

1. Yêu cầu

Hạng mụcYêu cầu
Đầu vàoVideo băng tải nhìn từ trên xuống, 640×240, 10 khung/giây, sản phẩm sáng chạy từ trái sang phải trên nền băng tải tối, 2 làn
ĐếmMỗi sản phẩm đếm đúng một lần khi tâm của nó đi qua vạch dọc x = 320
Độ chính xácTrên video mẫu: tổng số đếm và số đếm mỗi phút khớp đáp án (sai số 0)
Thống kêSố sản phẩm theo từng phút, lưu thong_ke_phut.csv và biểu đồ cột
Logket_qua.csv: số thứ tự, mã track, khung, thời điểm (s), phút, diện tích, tên ảnh minh chứng
Ảnh minh chứngMỗi sản phẩm được đếm có một ảnh cắt kèm khung và số thứ tự trong thư mục minh_chung/
Cấu hìnhMọi tham số (ROI, ngưỡng, kernel, vạch, khoảng cách ghép) nằm trong một dict cấu hình, không rải trong code
Tốc độXử lý nhanh hơn tốc độ khung hình của camera (trên 10 khung/giây)

2. Chuẩn bị video mẫu có đáp án

Hàm dưới đây tạo video 3 phút. Mỗi làn sinh sản phẩm ngẫu nhiên với nhịp khác nhau theo từng phút (20, 30, rồi 15 sản phẩm/phút mỗi làn) để thống kê theo phút có ý nghĩa. Sản phẩm là các hộp sáng kích thước ngẫu nhiên; 25% hộp có băng keo tối dán dọc giữa (sẽ làm mặt nạ bị cắt đôi). Băng tải có vân chạy theo, đèn yếu dần về phía trái, có nhiễu cảm biến. Vì biết vị trí từng hộp ở mọi khung, ta tính được chính xác khung mà tâm hộp vượt qua vạch đếm.

import json

import cv2
import numpy as np


def tao_video_bang_tai(duong_dan="bang_tai.mp4", fps=10, so_phut=3, seed=0):
    """Video băng tải 640x240: hộp sáng chạy trái -> phải trên 2 làn. Lưu kèm đáp án JSON."""
    rng = np.random.default_rng(seed)
    W, H, V, VACH = 640, 240, 12, 320                  # V: tốc độ băng tải (px/khung), VACH: vạch đếm
    NHIP = [20, 30, 15]                                # sản phẩm/phút mỗi làn, theo từng phút
    n_khung = fps * 60 * so_phut
    hop = []
    for y_lan in (80, 165):
        k = int(rng.integers(0, 20))
        while k < n_khung:
            w, h = int(rng.integers(50, 81)), int(rng.integers(40, 66))
            hop.append(dict(k0=k, y=y_lan + int(rng.integers(-8, 9)), w=w, h=h,
                            sang=int(rng.integers(170, 211)), keo=bool(rng.random() < 0.25)))
            phut = min(k // (fps * 60), so_phut - 1)
            tb = fps * 60 / NHIP[phut]                 # khoảng cách trung bình giữa 2 hộp (khung)
            k += max(int(np.ceil((w + 25) / V)), int(rng.exponential(tb)))   # cách nhau ít nhất 25 px
    # Đáp án: tâm hộp tại khung k là x = -w/2 + V*(k - k0); khung đầu tiên x >= VACH
    dap_an = []
    for i, b in enumerate(hop):
        kq = b["k0"] + int(np.ceil((VACH + b["w"] / 2) / V))
        if kq < n_khung:
            dap_an.append({"id": i, "khung": kq, "phut": kq // (fps * 60) + 1})
    vw = cv2.VideoWriter(duong_dan, cv2.VideoWriter_fourcc(*"mp4v"), fps, (W, H))
    den = np.tile(np.linspace(0.8, 1.0, W), (H, 1))    # đèn yếu dần về bên trái
    for k in range(n_khung):
        anh = np.full((H, W), 55, np.uint8)
        for x in range((k * V) % 40 - 40, W, 40):
            cv2.line(anh, (x, 0), (x, H - 1), 68, 2)   # vân băng tải chạy theo
        for b in hop:
            cx = -b["w"] / 2 + V * (k - b["k0"])
            if -b["w"] < cx < W + b["w"]:
                x0, y0 = int(round(cx - b["w"] / 2)), b["y"] - b["h"] // 2
                cv2.rectangle(anh, (x0, y0), (x0 + b["w"] - 1, y0 + b["h"] - 1), b["sang"], -1)
                if b["keo"]:                            # băng keo tối rộng 7 px dán dọc giữa hộp
                    xm = x0 + b["w"] // 2
                    cv2.rectangle(anh, (xm - 3, y0), (xm + 3, y0 + b["h"] - 1), 60, -1)
        anh = np.clip(anh * den + rng.normal(0, 4, (H, W)), 0, 255).astype(np.uint8)
        vw.write(cv2.cvtColor(anh, cv2.COLOR_GRAY2BGR))
    vw.release()
    with open(duong_dan.rsplit(".", 1)[0] + "_dap_an.json", "w", encoding="utf-8") as f:
        json.dump({"fps": fps, "vach_x": VACH, "san_pham": dap_an}, f)
    return dap_an


dap_an = tao_video_bang_tai()
print("đáp án:", len(dap_an), "sản phẩm qua vạch |",
      " | ".join(f"phút {p}: {sum(d['phut'] == p for d in dap_an)}" for p in (1, 2, 3)))
đáp án: 119 sản phẩm qua vạch | phút 1: 36 | phút 2: 52 | phút 3: 31

Việc tạo video mất khoảng 10–20 giây tùy máy, file khoảng 25 MB. Nếu OpenCV trên máy bạn không ghi được mp4v (file rỗng hoặc không mở được), đổi thành "bang_tai.avi" với cv2.VideoWriter_fourcc(*"MJPG"). Mở video bằng trình phát bất kỳ để xem trước: hộp có băng keo trông như hai nửa sáng tách nhau bởi một vạch tối.

3. Kiến thức sử dụng

BướcKỹ thuậtHọc ở bài
Cắt vùng băng tảiROI bằng cắt mảng NumPyROI, resize, xoay, lật (chặng 2)
Tách sản phẩmLàm mịn Gaussian, ngưỡng cố địnhLọc nhiễu và phát hiện cạnh; Phân ngưỡng và contour
Làm sạch mặt nạOpening xóa nhiễu, closing kernel ngang nối hai nửa hộp có băng keoMorphology
Tìm vật và tâmfindContours, lọc diện tích, momentsPhân ngưỡng và contour; Đặc trưng contour
Theo dõi qua khung hìnhGhép tâm gần nhất có dự đoán vận tốc (centroid tracking)Mới trong dự án này
Log, thống kê, ảnh minh chứngCSV, vẽ lên ảnh, ghi ảnhĐọc ghi file (chặng 1); Vẽ kết quả lên ảnh (chặng 2)

4. Gợi ý thiết kế

4.1. Tách sản phẩm: ngưỡng cố định, không dùng Otsu

Băng tải có lúc trống hoàn toàn. Như cảnh báo ở bài phân ngưỡng, Otsu trên ảnh không có vật vẫn "tìm" ra một ngưỡng và cắt vân băng tải thành vật giả. Ở trạm có đèn được kiểm soát, ngưỡng cố định đặt giữa độ sáng băng tải (khoảng 45–70) và sản phẩm (khoảng 135–210 sau khi tính đèn yếu bên trái) ổn định hơn nhiều. Chọn 110, nằm giữa hai nhóm.

4.2. Băng keo cắt đôi mặt nạ

Một hộp có băng keo tối rộng 7 px cho mặt nạ gồm hai nửa: đếm contour sẽ ra 2 vật. Closing với kernel ngang rộng hơn khe (15×5) nối hai nửa lại mà không làm hộp phình theo chiều dọc, đúng kỹ thuật kernel một chiều của bài Morphology. Kernel phải nhỏ hơn khoảng cách giữa hai hộp liên tiếp (ít nhất 25 px trong video mẫu), nếu không hai hộp sát nhau sẽ bị nối thành một.

4.3. Không đếm trùng: theo dõi tâm qua các khung

Một hộp xuất hiện trong khoảng 50 khung hình liên tiếp. Nếu mỗi khung chỉ hỏi "có vật nào gần vạch không" thì một hộp sẽ được đếm nhiều lần, hoặc không lần nào nếu nó "nhảy" qua vùng gần vạch giữa hai khung. Cách đúng là gán cho mỗi vật một mã track ổn định qua các khung:

  1. Dự đoán: vị trí mới của mỗi track ≈ vị trí cũ + vận tốc khung trước (băng tải chạy đều nên dự đoán rất tốt).
  2. Ghép: tính khoảng cách giữa mọi cặp (track dự đoán, vật mới), chỉ xét cặp gần hơn max_dist, ghép cặp gần nhất trước (tham lam), mỗi track và mỗi vật chỉ ghép một lần.
  3. Vật chưa ghép thành track mới. Track không được ghép được giữ thêm max_mat khung (vật có thể mất một khung vì nhiễu, lóa) rồi mới xóa.
  4. Đếm khi vị trí trước đó của track ở bên trái vạch và vị trí hiện tại ở bên phải hoặc trên vạch, và track chưa được đếm. Cờ da_dem là thứ chặn đếm trùng, kể cả khi tâm dao động qua lại quanh vạch.

Chọn max_dist: lớn hơn sai số dự đoán (vài pixel do làm mịn, hộp mới vào khung chưa có vận tốc thì sai số bằng tốc độ băng tải 12 px), nhỏ hơn nhiều so với khoảng cách giữa hai hộp gần nhau (khoảng 60 px trở lên). Giá trị 40 px nằm giữa.

4.4. Thời gian và thống kê theo phút

Với video, thời điểm của sản phẩm = số thứ tự khung / fps. Với camera chạy thật, dùng đồng hồ hệ thống (datetime.now()) tại thời điểm chụp; phút thống kê là phút theo giờ thật. Ghi log mỗi sản phẩm một dòng, rồi thống kê từ log: tách "thu thập dữ liệu" khỏi "báo cáo" giúp bạn tính lại báo cáo bất cứ lúc nào mà không cần chạy lại video.

5. Lời giải mẫu

Xem lời giải đầy đủ (dem_bang_tai.py, thống kê theo phút, kiểm tra với đáp án)

Chương trình chính. Đặt HIEN_THI = True để xem trực tiếp (nhấn q để dừng); mặc định tắt để chạy nhanh hết video. Phần cuối chạy lại hai cấu hình "sai có chủ đích" để thấy vai trò của closing và tracker.

import csv
import os
import time

import cv2
import numpy as np

CAU_HINH = {
    "video": "bang_tai.mp4",
    "roi": (0, 10, 640, 220),          # x, y, w, h của dải băng tải
    "nguong": 110,                     # ngưỡng cố định: băng tải ~45-70, sản phẩm ~135-210
    "k_mo": 5,                         # opening xóa nhiễu
    "k_dong": (15, 5),                 # closing ngang: nối hai nửa hộp có băng keo (khe 7 px)
    "dien_tich_min": 1200,             # hộp nhỏ nhất 50x40 = 2000 px²
    "vach_x": 320,                     # vạch đếm (tọa độ ảnh)
    "max_dist": 40,                    # khoảng cách ghép tối đa (px)
    "max_mat": 3,                      # số khung được phép mất dấu
    "thu_muc_minh_chung": "minh_chung",
    "file_log": "ket_qua.csv",
}
HIEN_THI = False
THI_NGHIEM = True


def tach_vat(gray, ch):
    """Trả về danh sách (cx, cy, diện tích, (x, y, w, h)) theo tọa độ ảnh đầy đủ."""
    x, y, w, h = ch["roi"]
    roi = cv2.GaussianBlur(gray[y:y + h, x:x + w], (5, 5), 0)
    _, m = cv2.threshold(roi, ch["nguong"], 255, cv2.THRESH_BINARY)
    if ch["k_mo"] > 1:
        m = cv2.morphologyEx(m, cv2.MORPH_OPEN,
                             cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ch["k_mo"], ch["k_mo"])))
    if ch["k_dong"]:
        m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, ch["k_dong"]))
    cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    ds = []
    for c in cs:
        A = cv2.contourArea(c)
        if A < ch["dien_tich_min"]:
            continue
        M = cv2.moments(c)
        bx, by, bw, bh = cv2.boundingRect(c)
        ds.append((M["m10"] / M["m00"] + x, M["m01"] / M["m00"] + y, A, (bx + x, by + y, bw, bh)))
    return ds


class TrackerTam:
    """Theo dõi tâm vật: dự đoán theo vận tốc, ghép tham lam theo khoảng cách."""

    def __init__(self, max_dist, max_mat):
        self.tracks, self.id_tiep, self.max_dist, self.max_mat = {}, 1, max_dist, max_mat

    def cap_nhat(self, ds):
        cap = []
        for tid, t in self.tracks.items():
            px, py = t["x"] + t["vx"], t["y"]                     # vị trí dự đoán
            for j, v in enumerate(ds):
                d = float(np.hypot(v[0] - px, v[1] - py))
                if d < self.max_dist:
                    cap.append((d, tid, j))
        cap.sort()
        track_da_ghep, vat_da_ghep = set(), set()
        for d, tid, j in cap:                                     # cặp gần nhất được ghép trước
            if tid in track_da_ghep or j in vat_da_ghep:
                continue
            track_da_ghep.add(tid)
            vat_da_ghep.add(j)
            t = self.tracks[tid]
            t.update(x_truoc=t["x"], vx=ds[j][0] - t["x"], x=ds[j][0], y=ds[j][1], mat=0, vat=ds[j])
        for tid in list(self.tracks):
            if tid not in track_da_ghep:
                self.tracks[tid]["mat"] += 1
                if self.tracks[tid]["mat"] > self.max_mat:
                    del self.tracks[tid]
        for j, v in enumerate(ds):
            if j not in vat_da_ghep:                              # vật mới vào khung
                self.tracks[self.id_tiep] = dict(x=v[0], y=v[1], vx=0.0, x_truoc=None,
                                                 mat=0, da_dem=False, vat=v)
                self.id_tiep += 1
        return self.tracks


def luu_minh_chung(frame, vat, stt, ch):
    bx, by, bw, bh = vat[3]
    x0, y0 = max(bx - 20, 0), max(by - 20, 0)
    cat = frame[y0:by + bh + 20, x0:bx + bw + 20].copy()
    cv2.rectangle(cat, (bx - x0, by - y0), (bx - x0 + bw - 1, by - y0 + bh - 1), (0, 255, 0), 1)
    cv2.putText(cat, f"#{stt}", (3, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 255), 1)
    ten = f"sp_{stt:04d}.jpg"
    cv2.imwrite(os.path.join(ch["thu_muc_minh_chung"], ten), cat)
    return ten


def chay(ch, ghi_log=True):
    cap = cv2.VideoCapture(ch["video"])
    if not cap.isOpened():
        raise FileNotFoundError(f"không mở được video {ch['video']}")
    fps = cap.get(cv2.CAP_PROP_FPS) or 10.0
    tracker = TrackerTam(ch["max_dist"], ch["max_mat"])
    dem, k, frame = [], -1, None
    if ghi_log:
        os.makedirs(ch["thu_muc_minh_chung"], exist_ok=True)
    while True:
        ok, frame_moi = cap.read()
        if not ok:
            break
        frame, k = frame_moi, k + 1
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        for tid, t in tracker.cap_nhat(tach_vat(gray, ch)).items():
            if (not t["da_dem"] and t["mat"] == 0 and t["x_truoc"] is not None
                    and t["x_truoc"] < ch["vach_x"] <= t["x"]):
                t["da_dem"] = True
                stt = len(dem) + 1
                ten_anh = luu_minh_chung(frame, t["vat"], stt, ch) if ghi_log else ""
                dem.append({"stt": stt, "id_track": tid, "khung": k, "thoi_gian_s": round(k / fps, 1),
                            "phut": int(k / fps // 60) + 1, "dien_tich": int(t["vat"][2]), "anh": ten_anh})
        if HIEN_THI:
            ve = frame.copy()
            cv2.line(ve, (ch["vach_x"], 0), (ch["vach_x"], ve.shape[0] - 1), (0, 255, 255), 2)
            for tid, t in tracker.tracks.items():
                mau = (0, 255, 0) if t["da_dem"] else (0, 0, 255)
                cv2.circle(ve, (int(t["x"]), int(t["y"])), 4, mau, -1)
                cv2.putText(ve, str(tid), (int(t["x"]) + 6, int(t["y"])), cv2.FONT_HERSHEY_SIMPLEX, 0.4, mau, 1)
            cv2.putText(ve, f"Dem: {len(dem)}", (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
            cv2.imshow("Dem san pham", ve)
            if cv2.waitKey(1) & 0xFF == ord("q"):
                break
    cap.release()
    if HIEN_THI:
        cv2.destroyAllWindows()
    if ghi_log:
        with open(ch["file_log"], "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=["stt", "id_track", "khung", "thoi_gian_s", "phut", "dien_tich", "anh"])
            w.writeheader()
            w.writerows(dem)
    return dem, k + 1, fps


bat_dau = time.perf_counter()
dem, so_khung, fps = chay(CAU_HINH)
giay = time.perf_counter() - bat_dau
print(f"đã xử lý {so_khung} khung ({so_khung / fps:.0f} s video), đếm được {len(dem)} sản phẩm")
print(f"tốc độ xử lý: {so_khung / giay:.0f} khung/s (thay đổi theo máy)")
print("5 dòng log đầu:", [(d["stt"], d["thoi_gian_s"], d["dien_tich"]) for d in dem[:5]])

if THI_NGHIEM:
    for ten, thay_doi in [("không closing", {"k_dong": None}), ("max_dist = 8", {"max_dist": 8})]:
        d2, _, _ = chay(dict(CAU_HINH, **thay_doi), ghi_log=False)
        print(f"thí nghiệm {ten:14s}: đếm được {len(d2)}")
đã xử lý 1800 khung (180 s video), đếm được 119 sản phẩm
tốc độ xử lý: 1333 khung/s (thay đổi theo máy)
5 dòng log đầu: [(1, 4.7, 3530), (2, 4.8, 2388), (3, 6.2, 2744), (4, 10.2, 3789), (5, 11.0, 2814)]
thí nghiệm không closing : đếm được 139
thí nghiệm max_dist = 8  : đếm được 72

Đọc kết quả:

  • Cấu hình đúng đếm đúng bằng đáp án ở mục 2. Tốc độ xử lý trên máy thông thường cao hơn nhiều lần mức 10 khung/s cần thiết, còn dư cho camera nhanh hơn hoặc ảnh lớn hơn.
  • Không closing: mỗi hộp có băng keo thành hai vật, hai track, hai lần đếm: đếm thừa. Đây là lỗi kinh điển "một vật đếm hai lần do phản xạ hoặc nhãn tối".
  • max_dist quá nhỏ (8 px): khi hộp mới vào khung chưa có vận tốc, dự đoán lệch 12 px nên không ghép được; track cũ bị xóa, track mới sinh ra không có vị trí trước đúng lúc qua vạch, nên đếm thiếu. Đếm trùng và đếm sót đều đến từ việc chọn tham số tracker không dựa trên số liệu (tốc độ băng tải, khoảng cách giữa vật).
  • Điều kiện t["mat"] == 0 đảm bảo chỉ đếm khi vật thật sự được nhìn thấy ở khung hiện tại, và x_truoc luôn là vị trí ghép được gần nhất.

Thống kê theo từng phút (bài thực hành của dự án). Khối này chỉ đọc log CSV, nên chạy lại được bất cứ lúc nào:

import csv
from collections import Counter

import cv2
import numpy as np

with open("ket_qua.csv", encoding="utf-8") as f:
    log = list(csv.DictReader(f))
theo_phut = Counter(int(d["phut"]) for d in log)
cac_phut = list(range(1, max(theo_phut, default=0) + 1))

with open("thong_ke_phut.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["phut", "so_san_pham"])
    for p in cac_phut:
        w.writerow([p, theo_phut[p]])

print("phút | sản phẩm")
for p in cac_phut:
    print(f"{p:4d} | {theo_phut[p]:4d} " + "#" * (theo_phut[p] // 2))
if cac_phut:
    print(f"tổng {len(log)} | trung bình {len(log) / len(cac_phut):.1f} sp/phút | "
          f"cao nhất phút {max(cac_phut, key=lambda p: theo_phut[p])}")

# Biểu đồ cột bằng OpenCV để đính kèm báo cáo ca
bd = np.full((220, 60 + 80 * max(len(cac_phut), 1), 3), 255, np.uint8)
cao_max = max(theo_phut.values(), default=1)
for i, p in enumerate(cac_phut):
    h = int(150 * theo_phut[p] / cao_max)
    cv2.rectangle(bd, (40 + 80 * i, 180 - h), (90 + 80 * i, 180), (200, 120, 40), -1)
    cv2.putText(bd, str(theo_phut[p]), (48 + 80 * i, 172 - h), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
    cv2.putText(bd, f"P{p}", (52 + 80 * i, 200), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
cv2.imwrite("bieu_do_phut.png", bd)
phút | sản phẩm
   1 |   36 ##################
   2 |   52 ##########################
   3 |   31 ###############
tổng 119 | trung bình 39.7 sp/phút | cao nhất phút 2

Kiểm tra với đáp án. So tổng, so từng phút, và so khung đếm của từng sản phẩm (ghép theo thứ tự thời gian) để biết chương trình đếm sớm hay muộn bao nhiêu:

import csv
import json
from collections import Counter

with open("ket_qua.csv", encoding="utf-8") as f:
    log = list(csv.DictReader(f))
dap_an = json.load(open("bang_tai_dap_an.json", encoding="utf-8"))["san_pham"]

that = Counter(d["phut"] for d in dap_an)
do = Counter(int(d["phut"]) for d in log)
print("phút | đáp án | đếm được | lệch")
for p in sorted(set(that) | set(do)):
    print(f"{p:4d} | {that[p]:6d} | {do[p]:8d} | {do[p] - that[p]:+4d}")
print(f"tổng | {len(dap_an):6d} | {len(log):8d} | {len(log) - len(dap_an):+4d}")

if len(log) == len(dap_an):
    lech = Counter(int(a["khung"]) - b for a, b in zip(log, sorted(d["khung"] for d in dap_an)))
    print("độ lệch khung đếm so với đáp án:", dict(sorted(lech.items())))
phút | đáp án | đếm được | lệch
   1 |     36 |       36 |   +0
   2 |     52 |       52 |   +0
   3 |     31 |       31 |   +0
tổng |    119 |      119 |   +0
độ lệch khung đếm so với đáp án: {0: 111, 1: 8}

Số đếm khớp tuyệt đối theo phút. Một số sản phẩm được đếm lệch một khung so với đáp án: đáp án dùng tâm hình học của hộp, còn chương trình dùng trọng tâm mặt nạ sau làm mịn và morphology, có thể lệch vài phần pixel, đủ để vượt vạch sớm hoặc muộn một khung (0,1 s). Với thống kê theo phút, lệch này chỉ có ảnh hưởng khi sản phẩm qua vạch đúng ranh giới phút; với tín hiệu gửi PLC để gạt sản phẩm, 0,1 s ở 120 px/s tương đương 12 px, phải tính đến khi thiết kế vị trí cơ cấu gạt (chặng 5, bài trigger, encoder và cycle time).

6. Tự đánh giá

Tiêu chíĐạt khiTự kiểm tra
Đếm đúng tổngKhớp đáp án trên video mẫuKhối kiểm tra với đáp án, dòng "tổng" lệch +0
Đúng theo phútCả 3 phút lệch 0Bảng so sánh theo phút
Không đếm trùngHộp có băng keo đếm 1 lần; tâm dao động quanh vạch không đếm thêmTắt closing phải thấy đếm thừa; bật lại phải khớp
Không đếm sótVật mất dấu 1–3 khung vẫn được đếmThử xóa ngẫu nhiên 5% khung (bỏ qua khung khi đọc) và kiểm tra lại
Tham số có căn cứMỗi tham số giải thích được bằng số đo (độ sáng, tốc độ, khoảng cách)Viết một dòng chú thích lý do cho từng khóa trong cấu hình
Log và minh chứngCSV đủ cột; số ảnh trong minh_chung/ bằng số dòng logĐếm file, mở ngẫu nhiên vài ảnh
Tốc độTrên 10 khung/s với video 640×240Dòng "tốc độ xử lý"
Bền vữngKhông lỗi khi video không mở được, khi băng tải trống, khi hết videoĐổi tên file video, chạy video toàn khung đen

7. Mở rộng

  • Đổi ngưỡng cố định thành ngưỡng có giám sát: theo dõi độ sáng trung bình của vùng băng tải trống như bài histogram, cảnh báo khi đèn yếu thay vì để số đếm sai dần.
  • Phân loại khi đếm: dùng diện tích và khung xoay (bài Đặc trưng contour) để đếm riêng hộp nhỏ, hộp lớn, hoặc báo NG khi kích thước ngoài dung sai; dùng minAreaRect để báo hộp nằm lệch góc.
  • Đổi đơn vị sang mm: với homography từ bài Biến đổi hình học, đổi tâm và kích thước hộp sang mm trên mặt băng tải.
  • Theo encoder thay vì thời gian: băng tải thật thay đổi tốc độ; dùng xung encoder để biết quãng đường, và gửi vị trí sản phẩm cho cơ cấu gạt (chặng 5).
  • Gửi số đếm cho PLC qua Modbus TCP và lưu log vào SQLite (chặng 9).
  • Vật dính nhau: tạo video có hộp chạm nhau, thêm bước tách bằng diện tích hoặc watershed (bài Watershed), và đo xem đếm thiếu bao nhiêu.
  • Sản phẩm đa dạng, nền phức tạp: thay bước tách vật bằng mô hình object detection (chặng 12) và giữ nguyên phần tracker, đếm qua vạch, log.

Hoàn thành dự án này, bạn đã có một pipeline xử lý ảnh cổ điển hoàn chỉnh. Chặng 4 sẽ nhìn bức tranh lớn hơn: một hệ thống Machine Vision gồm những thành phần gì ngoài phần mềm xử lý ảnh.

Bài tập thực hành

  1. Chạy mã nguồn dự án với video mẫu, sau đó thêm thống kê số sản phẩm theo từng phút.

Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.