Minh họa xây dựng MVP thực tế với CV

Dưới đây là quy trình chi tiết từng bước xây dựng một sản phẩm MVP (Minimum Viable Product) thực tế: Hệ thống giám sát và đếm đối tượng cắt qua vạch ảo trên máy tính cá nhân (chạy trực tiếp trên môi trường local, tận dụng webcam hoặc video file, xuất giao diện Web tương tác thời gian thực).

1. THIẾT KẾ KIẾN TRÚC HỆ THỐNG

Một hệ thống thị giác máy tính tiêu chuẩn gồm 4 tầng dữ liệu vận hành theo mô hình đường ống (Pipeline):

Plaintext
┌─────────────────┐
│ INPUT LAYER     │  Camera RTSP / Webcam USB / Video File MP4 (cv2.VideoCapture)
└────────┬────────┘
         │ (Frame thô: BGR NumPy array)
         ▼
┌─────────────────┐
│ PROCESSING CORE │  YOLOv8 Inference ──> Bounding Boxes [x1, y1, x2, y2, conf, cls]
│ (AI & Tracking) │             │
│                 │  Centroid Tracker ──> Gán ID ổn định qua thời gian: {ID: (cx, cy)}
└────────┬────────┘
         │ (Tọa độ tâm + ID qua các frame)
         ▼
┌─────────────────┐
│ BACKEND LOGIC   │  Virtual Tripwire (Vector giao cắt vạch kẻ ảo)
│ & STORAGE       │  ├─ Phân loại chiều di chuyển: IN (Vào) / OUT (Ra)
│                 │  └─ Ghi log sự kiện vào SQLite Database: (id, timestamp, direction)
└────────┬────────┘
         │ (Frame đã vẽ HUD + Số liệu thống kê)
         ▼
┌─────────────────┐
│ PRESENTATION    │  Streamlit Dashboard (Hiển thị luồng video, KPI đếm, bảng log)
└─────────────────┘

2. CHUẨN BỊ MÔI TRƯỜNG TRÊN MÁY TÍNH CÁ NHÂN

Mở Terminal (macOS/Linux) hoặc PowerShell (Windows), tạo thư mục dự án và cài đặt các thư viện cần thiết:

Bash
mkdir cv_tripwire_mvp
cd cv_tripwire_mvp

# Tạo và kích hoạt môi trường ảo Python
python -m venv venv
# Trên Windows:
venv\Scripts\activate
# Trên Linux/macOS:
source venv/bin/activate

# Cài đặt các thư viện
pip install opencv-python ultralytics streamlit numpy pandas

Cấu trúc dự án sẽ tổ chức thành các module tách biệt:

Plaintext
cv_tripwire_mvp/
├── tracker.py       # Thuật toán gán ID và theo dõi vết tâm vật thể
├── db.py            # Khởi tạo và ghi log vào SQLite
├── core.py          # Lõi xử lý: Đọc video, YOLOv8, kiểm tra cắt vạch
└── app.py           # Giao diện Web Dashboard bằng Streamlit

3. BƯỚC 1: THUẬT TOÁN THEO DÕI VẬT THỂ (tracker.py)

Khi mô hình YOLO chạy trên từng frame, nó chỉ biết "ở đây có xe/sản phẩm" nhưng không biết vật thể ở frame hiện tại có phải là vật thể ở frame trước hay không. Ta cần Centroid Tracker dùng khoảng cách Euclidean để gán một ID cố định cho mỗi vật thể khi nó di chuyển.

Tạo file tracker.py:

import math

class CentroidTracker:
    def __init__(self, max_disappeared=15, max_distance=60):
        # Lưu trữ tọa độ tâm hiện tại: {object_id: (cx, cy)}
        self.objects = {}
        # Lưu vết tọa độ trước đó để tính hướng đi: {object_id: (prev_cx, prev_cy)}
        self.previous_positions = {}
        # Đếm số frame vật thể bị mất dấu
        self.disappeared = {}
        self.next_object_id = 0
        self.max_disappeared = max_disappeared
        self.max_distance = max_distance

    def update(self, rects):
        # rects: danh sách bounding box [[x1, y1, x2, y2], ...]
        if len(rects) == 0:
            for obj_id in list(self.disappeared.keys()):
                self.disappeared[obj_id] += 1
                if self.disappeared[obj_id] > self.max_disappeared:
                    self._deregister(obj_id)
            return self.objects

        # Tính toán trọng tâm (Centroid) của các box mới phát hiện
        input_centroids = []
        for (x1, y1, x2, y2) in rects:
            cx = int((x1 + x2) / 2.0)
            cy = int((y1 + y2) / 2.0)
            input_centroids.append((cx, cy))

        # Nếu chưa theo dõi vật thể nào, đăng ký mới toàn bộ
        if len(self.objects) == 0:
            for centroid in input_centroids:
                self._register(centroid)
        else:
            object_ids = list(self.objects.keys())
            object_centroids = list(self.objects.values())

            # Ghép cặp tâm mới với tâm cũ dựa trên khoảng cách Euclidean nhỏ nhất
            used_rows = set()
            used_cols = set()

            for r, (obj_id, old_centroid) in enumerate(zip(object_ids, object_centroids)):
                min_dist = float("inf")
                best_c = -1
                for c, new_centroid in enumerate(input_centroids):
                    if c in used_cols:
                        continue
                    dist = math.hypot(old_centroid[0] - new_centroid[0], old_centroid[1] - new_centroid[1])
                    if dist < min_dist:
                        min_dist = dist
                        best_c = c

                # Nếu khoảng cách nằm trong ngưỡng di chuyển hợp lý
                if min_dist < self.max_distance and best_c != -1:
                    self.previous_positions[obj_id] = self.objects[obj_id]
                    self.objects[obj_id] = input_centroids[best_c]
                    self.disappeared[obj_id] = 0
                    used_rows.add(r)
                    used_cols.add(best_c)

            # Các tâm mới chưa được gán -> vật thể mới xuất hiện
            for c in range(len(input_centroids)):
                if c not in used_cols:
                    self._register(input_centroids[c])

            # Các vật thể cũ không tìm thấy tâm mới tương ứng
            for r in range(len(object_ids)):
                if r not in used_rows:
                    obj_id = object_ids[r]
                    self.disappeared[obj_id] += 1
                    if self.disappeared[obj_id] > self.max_disappeared:
                        self._deregister(obj_id)

        return self.objects

    def _register(self, centroid):
        self.objects[self.next_object_id] = centroid
        self.previous_positions[self.next_object_id] = centroid
        self.disappeared[self.next_object_id] = 0
        self.next_object_id += 1

    def _deregister(self, object_id):
        del self.objects[object_id]
        del self.previous_positions[object_id]
        del self.disappeared[object_id]

4. BƯỚC 2: QUẢN TRỊ DỮ LIỆU SỰ KIỆN VỚI SQLITE (db.py)

Mỗi khi có đối tượng cắt qua vạch, thông tin phải được lưu lại tức thì để phục vụ báo cáo.

Tạo file db.py:

import sqlite3
from datetime import datetime

DB_NAME = "events.db"

def init_db():
    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS counts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            object_id INTEGER NOT NULL,
            class_name TEXT NOT NULL,
            direction TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()

def log_event(object_id, class_name, direction):
    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()
    now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    cursor.execute("""
        INSERT INTO counts (timestamp, object_id, class_name, direction)
        VALUES (?, ?, ?, ?)
    """, (now_str, object_id, class_name, direction))
    conn.commit()
    conn.close()

def get_recent_events(limit=10):
    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()
    cursor.execute("SELECT timestamp, object_id, class_name, direction FROM counts ORDER BY id DESC LIMIT ?", (limit,))
    rows = cursor.fetchall()
    conn.close()
    return rows

5. BƯỚC 3: PROCESSING CORE & BACKEND LOGIC (core.py)

Module này thực hiện luồng xử lý chính:

  1. Đọc frame từ camera/video.

  2. Dùng mô hình YOLOv8 Nano (yolov8n.pt) để bóc tách bounding box.

  3. Cập nhật Tracker để giữ ID.

  4. Kiểm tra sự kiện cắt vạch (Tripwire Intersection) và xác định hướng IN hoặc OUT.

Tạo file core.py:

import cv2
import numpy as np
from ultralytics import YOLO
from tracker import CentroidTracker
from db import log_event

class VideoProcessor:
    def __init__(self, source=0, line_y=350, target_classes=[2, 7]):
        """
        source: 0 (webcam), đường dẫn file MP4, hoặc link RTSP
        line_y: Tọa độ trục Y của vạch ảo nằm ngang
        target_classes: ID nhãn theo tập COCO (2: car, 7: truck, 0: person,...)
        """
        self.cap = cv2.VideoCapture(source)
        # Nạp YOLOv8 Nano pre-trained (tự động tải về máy trong lần chạy đầu)
        self.model = YOLO("yolov8n.pt")
        self.tracker = CentroidTracker(max_disappeared=10, max_distance=50)
        
        self.line_y = line_y
        self.target_classes = target_classes
        
        self.count_in = 0
        self.count_out = 0
        # Tập hợp lưu các object_id đã được đếm để tránh đếm trùng lặp
        self.counted_ids = set()

    def process_frame(self):
        ret, frame = self.cap.read()
        if not ret:
            return False, None

        h, w = frame.shape[:2]

        # 1. Suy luận YOLOv8 (Inference)
        results = self.model.predict(frame, conf=0.4, verbose=False)[0]
        
        detections = []
        for box in results.boxes:
            cls_id = int(box.cls[0])
            if cls_id in self.target_classes:
                x1, y1, x2, y2 = map(int, box.xyxy[0])
                detections.append([x1, y1, x2, y2])

        # 2. Cập nhật vị trí vật thể qua Tracker
        tracked_objects = self.tracker.update(detections)

        # 3. Vẽ vạch ảo Tripwire (Vạch ngang màu đỏ)
        cv2.line(frame, (0, self.line_y), (w, self.line_y), (0, 0, 255), 2)
        cv2.putText(frame, "TRIPWIRE LINE", (10, self.line_y - 10),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)

        # 4. Kiểm tra điều kiện cắt vạch
        for obj_id, (cx, cy) in tracked_objects.items():
            prev_cx, prev_cy = self.tracker.previous_positions.get(obj_id, (cx, cy))

            # Vẽ tâm vật thể và ID
            cv2.circle(frame, (cx, cy), 5, (0, 255, 0), -1)
            cv2.putText(frame, f"ID: {obj_id}", (cx - 10, cy - 10),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 2)

            if obj_id not in self.counted_ids:
                # Điều kiện đi từ TRÊN xuống DƯỚI (HƯỚNG IN)
                if prev_cy < self.line_y and cy >= self.line_y:
                    self.count_in += 1
                    self.counted_ids.add(obj_id)
                    log_event(obj_id, "vehicle", "IN")
                    # Hiệu ứng đổi màu vạch báo hiệu
                    cv2.line(frame, (0, self.line_y), (w, self.line_y), (0, 255, 0), 4)

                # Điều kiện đi từ DƯỚI lên TRÊN (HƯỚNG OUT)
                elif prev_cy > self.line_y and cy <= self.line_y:
                    self.count_out += 1
                    self.counted_ids.add(obj_id)
                    log_event(obj_id, "vehicle", "OUT")
                    cv2.line(frame, (0, self.line_y), (w, self.line_y), (255, 255, 0), 4)

        # 5. Vẽ HUD thông tin lên góc trên màn hình
        cv2.rectangle(frame, (10, 10), (220, 90), (0, 0, 0), -1)
        cv2.putText(frame, f"IN : {self.count_in}", (20, 45),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
        cv2.putText(frame, f"OUT: {self.count_out}", (20, 80),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 255), 2)

        return True, frame

    def release(self):
        self.cap.release()

6. BƯỚC 4: GIAO DIỆN NGƯỜI DÙNG WEB DASHBOARD (app.py)

Sử dụng Streamlit để tạo một Dashboard tương tác hiển thị thời gian thực gồm luồng video trực tiếp, thẻ KPI tổng số đếm, thanh điều chỉnh vị trí vạch kẻ ảo và bảng log sự kiện từ database.

Tạo file app.py:

import streamlit as st
import cv2
import pandas as pd
from core import VideoProcessor
from db import init_db, get_recent_events

# Cấu hình giao diện Streamlit
st.set_page_config(page_title="AI Tripwire Counter", layout="wide")
st.title("🚦 Hệ Thống Giám Sát & Đếm Đối Tượng Qua Vạch Ảo (MVP)")

# Khởi tạo cơ sở dữ liệu
init_db()

# Thanh Sidebar cấu hình
st.sidebar.header("Cấu Hình Hệ Thống")
source_option = st.sidebar.radio("Nguồn video:", ("Webcam", "File Video có sẵn"))
line_position = st.sidebar.slider("Vị trí vạch cắt (Trục Y):", min_value=100, max_value=600, value=300, step=10)

if source_option == "Webcam":
    video_source = 0
else:
    video_source = "test_traffic.mp4"  # Đường dẫn tới video mẫu trên máy bạn

start_btn = st.sidebar.button("Bắt đầu giám sát")
stop_btn = st.sidebar.button("Dừng lại")

# Khu vực hiển thị kết quả chính
kpi1, kpi2, kpi3 = st.columns(3)
in_metric = kpi1.empty()
out_metric = kpi2.empty()
total_metric = kpi3.empty()

col_video, col_logs = st.columns([2, 1])
video_placeholder = col_video.empty()
log_placeholder = col_logs.empty()

if start_btn:
    processor = VideoProcessor(source=video_source, line_y=line_position)
    
    while processor.cap.isOpened():
        ret, frame = processor.process_frame()
        if not ret or stop_btn:
            break

        # Cập nhật số liệu KPI
        in_metric.metric(label="Tổng Lượt Vào (IN)", value=processor.count_in)
        out_metric.metric(label="Tổng Lượt Ra (OUT)", value=processor.count_out)
        total_metric.metric(label="Tổng Lưu Lượng", value=processor.count_in + processor.count_out)

        # Chuyển BGR sang RGB để Streamlit hiển thị đúng màu
        frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        video_placeholder.image(frame_rgb, channels="RGB", use_container_width=True)

        # Cập nhật bảng nhật ký sự kiện từ SQLite
        events = get_recent_events(limit=8)
        if events:
            df = pd.DataFrame(events, columns=["Thời gian", "ID", "Loại", "Chiều"])
            log_placeholder.dataframe(df, use_container_width=True)

    processor.release()
    st.info("Hệ thống đã dừng.")

7. CÁCH KHỞI CHẠY VÀ VẬN HÀNH

  1. Khởi chạy ứng dụng: Mở terminal tại thư mục dự án và gõ lệnh:

    Bash
    streamlit run app.py
    
  2. Tương tác trên giao diện:

    • Trình duyệt sẽ tự động mở địa chỉ: http://localhost:8501.

    • Nếu không có video mẫu, chọn chế độ Webcam (chọn target_classes=[0] trong core.py để đếm người đi qua vạch).

    • Dùng thanh trượt điều chỉnh vị trí đường thẳng vạch ảo phù hợp với góc quan sát.

    • Bấm Bắt đầu giám sát để theo dõi các đối tượng được đóng khung bounding box, gán ID liên tục và chứng kiến bảng số liệu cùng cơ sở dữ liệu SQLite tự động ghi nhận khi có lượt cắt vạch.

Bạn có muốn tối ưu hệ thống này bằng cách đưa luồng đọc camera vào một Thread riêng biệt (threading.Thread) để giao diện web không bị giảm tốc độ khung hình (drop FPS) khi mô hình AI đang suy luận không?