Sotatek AI/Computer Vision Engineer Assessment · 2026

Zero-Shot Pattern Detection
for Engineering BOM Drawings

Đặc tả kỹ thuật chi tiết hệ thống phát hiện ký hiệu linh kiện lặp lại trong bản vẽ kỹ thuật BOM, không cần dữ liệu huấn luyện, hoạt động hoàn toàn zero-shot ở thời điểm suy luận.

Zero-Shot Detection OpenCV · DINOv2 · Qwen2-VL FastAPI · Docker · HuggingFace Python 3.11

1. Giới thiệu

Bài toán đặt ra: cho một ảnh template (ký hiệu linh kiện cần tìm) và một ảnh drawing (bản vẽ mạch điện kỹ thuật), hệ thống phải xác định tọa độ của tất cả các vị trí mà ký hiệu đó xuất hiện trong bản vẽ — bất kể tỉ lệ, góc xoay nhỏ, hay biến thiên phong cách vẽ.

Điểm đặc biệt: hệ thống phải hoạt động zero-shot — không có dữ liệu huấn luyện, không fine-tuning, bất kỳ template nào cũng có thể nhận diện ngay tại runtime.

3
Stages chính (+ 1 optional)
Zero-shot
Không cần fine-tuning
25s
Runtime trung bình / drawing (GPU)

2. Mục tiêu & Ràng buộc

2.1 Mục tiêu chức năng

#Yêu cầuMức ưu tiên
F1Nhận template và drawing dưới dạng ảnh, trả về danh sách bounding box + scoreBắt buộc
F2Hoạt động zero-shot — không cần dữ liệu huấn luyện cho class mớiBắt buộc
F3Phát hiện ký hiệu ở nhiều tỉ lệ (0.7×–1.8×) và góc xoay nhỏ (±10°), 90°Bắt buộc
F4Web UI để upload, chạy detection, download kết quảQuan trọng
F5Chạy được trên CPU (không bắt buộc GPU)Quan trọng
F6Stage 3 VLM filter (optional) để loại FP phức tạpTùy chọn
F7Triển khai được trên HuggingFace Spaces (Docker)Tùy chọn

2.2 Ràng buộc phi chức năng

Hiệu năng
  • CPU: < 120s / drawing A3 (3508×2480px)
  • GPU (CUDA): < 30s / drawing
  • Memory: < 4GB VRAM (DINOv2 ViT-S)
  • VLM: ~4.5GB VRAM bfloat16 (lazy-load)
Độ chính xác
  • Recall > 90% trên bản vẽ chuẩn
  • FP rate thấp (chỉ giữ detection NCC & DINOv2 đều cao)
  • Bounding box IoU ≥ 0.5 với ground truth
  • Chấp nhận miss các ký hiệu ở biên ảnh

3. Tổng quan kiến trúc Pipeline

Hệ thống theo kiến trúc cascade 3 giai đoạn (+ 1 optional), mỗi giai đoạn loại bỏ các ứng viên sai dần dần. Thiết kế này đạt recall cao từ Stage 1, sau đó tăng precision dần qua Stage 2 và 2b.

0
Preprocess
Binarize · Denoise · Normalize
< 0.5s
1
NCC Matching
Multi-scale · Multi-angle · NMS
15–60s CPU
2
DINOv2 Verify
Cosine similarity · Derotate · Center crop
2–10s GPU
2b
Structural Filters
Wire-lead · Chamfer · NMS · Gap filter
< 1s
3
VLM Filter
Qwen2-VL · Open-classification · Optional
~0.4s/crop
Chiến lược thiết kế
Stage 1 (NCC) chạy nhanh, có recall cao nhưng nhiều false positive. Stage 2 (DINOv2) loại phần lớn FP bằng semantic feature. Stage 2b áp dụng geometric filter cho các FP còn lại. Stage 3 (VLM) chỉ cần thiết khi DINOv2 không thể phân biệt (ví dụ: diode vs resistor).

3.1 Luồng xử lý theo loại template

Pipeline phân nhánh tự động dựa trên đặc tính của template:

Loại templateTiêu chí phân loạiPipeline được chọnVí dụ
Simple (outline) edge_density < 0.05 AND interior_fill < 0.02 Pass A (NCC scales 0.30–1.0) + Pass B (rotate drawing 90°) + Pass C (DINODense nếu lớn hơn template) Resistor IEC (hình chữ nhật)
Complex (filled) Không thỏa mãn điều kiện Simple Scale probe → Pass 3a (góc ≈0°) + Pass 3b (góc ≈90°) với adaptive scales Resistor ANSI (zigzag), Bridge Rectifier, cổng logic

3.1 Stage 0 — Tiền xử lý (Preprocessor)

Module: src/preprocessor.py

Chuẩn hóa cả template và drawing về cùng định dạng binary grayscale trước khi đưa vào pipeline.

Các bước xử lý

1
Convert to Grayscale
Ảnh RGB → Grayscale. Bản vẽ kỹ thuật không phụ thuộc màu sắc.
2
Adaptive Binarization
Adaptive threshold (block size 21, C=10) để xử lý bản vẽ có ánh sáng không đều. Kết quả: nền trắng (255), nét vẽ đen (0).
3
Noise Removal
Morphological opening (kernel 2×2) để loại bỏ pixel nhiễu nhỏ không phải nét vẽ.
4
Stroke Dilation (optional)
Khi dilate_pattern > 0: làm dày nét template để khớp với drawing có stroke khác (Pass 2 — relaxed NCC pass).

Output format

{
  "processed": np.ndarray,   # binary uint8, shape (H, W), values {0, 255}
  "original":  np.ndarray,   # ảnh gốc RGB, để visualization
  "scale_factor": 1.0        # nếu resize (hiện tại luôn = 1.0)
}

3.2 Stage 1 — NCC Multi-scale Matching

Module: src/ncc_matcher.py

Sử dụng Normalized Cross-Correlation (NCC) để nhanh chóng đề xuất các vùng ứng viên (candidate regions) trong drawing mà template có thể khớp. NCC là convolution tuyến tính, chạy rất nhanh trên CPU với cv2.matchTemplate(TM_CCOEFF_NORMED).

Thuật toán chi tiết

for scale in scales:
    # Resize template về scale đang xét
    scaled_tmpl = cv2.resize(template, (int(tw*scale), int(th*scale)))

    for angle in angles:
        # Rotate template quanh tâm
        rotated = rotate_template(scaled_tmpl, angle)

        # Chạy NCC sliding window trên toàn bộ drawing
        ncc_map = cv2.matchTemplate(drawing, rotated, cv2.TM_CCOEFF_NORMED)

        # Thu thập tất cả vị trí có NCC >= threshold
        locs = np.where(ncc_map >= ncc_threshold)
        for (y, x) in zip(*locs):
            candidates.append({x, y, rw, rh, ncc_map[y,x], scale, angle})

# NMS (Non-Maximum Suppression) để loại bỏ duplicate
final_candidates = apply_nms(candidates, iou_threshold=0.30)

Xử lý góc xoay (Rotation Handling)

Hàm rotate_template(template, angle) dùng cv2.getRotationMatrix2D. Trường hợp đặc biệt cho góc ≈90°:

# Với 75° ≤ |angle| ≤ 105°: mở rộng canvas để template không bị cắt
if 75 <= abs(angle % 180) <= 105:
    out_w, out_h = h, w   # hoán đổi chiều rộng/cao
    M[0, 2] += (out_w - w) / 2
    M[1, 2] += (out_h - h) / 2

NMS với Cross-orientation Protection

NMS sử dụng containment-aware IoU: score = max(IoU, intersection / min_area). Quan trọng: các candidate theo hướng ngang (angle ≤ 70°) và dọc (70° ≤ |angle| ≤ 110°) KHÔNG triệt tiêu lẫn nhau, tránh mất ký hiệu ở hai hướng cùng vị trí.

Tham số NCC cho từng pass

PassTemplateNCC ThresholdAnglesScales
Pass 1 (Strict)Nguyên bản0.55[-10, -5, 0, 5, 10]Mặc định NCCMatcher
Pass 2 (Relaxed)Dilated (5×5)0.28 (complex) / 0.50 (simple)[-10, -5, 0, 5, 10]Mặc định NCCMatcher
Pass 3a (Micro 0°)Nguyên bản0.28[-10, -5, 0, 5, 10]Adaptive ± probe_scale
Pass 3b (Micro 90°)Nguyên bản0.28[80, 85, 90, 95, 100]Adaptive ± probe_scale
Pass A (Simple)Nguyên bản0.42[-10,..,10, 80,..,100][0.30, 0.35, 0.40, 0.50, 0.60, 0.70, 1.0]

Scale Probe (Complex Template)

Trước khi chạy Pass 3a/3b, pipeline thực hiện scale probe nhanh để xác định tỉ lệ tốt nhất:

probe_scales = [0.25, 0.30, 0.35, 0.40, 0.50, 0.65, 0.85, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5]
for ps in probe_scales:
    # Resize template và chạy NCC trên drawing, lấy max
    best_probe_s = ps with highest max_NCC

# Nếu best_probe_s > 1.40: bỏ qua Pass 1+2, chuyển sang probe-focused mode
_use_probe_focused = (no_std_candidates) or (best_probe_s > 1.40)

if _use_probe_focused:
    # Scales = best_probe_s × [0.80, 0.85, 0.90, 0.95, 1.0, 1.05, 1.10, 1.15, 1.20]
    micro_scales = [round(best_probe_s * f, 2) for f in fracs]

3.3 Stage 2 — DINOv2 Verification

Module: src/dino_verifier.py · Model: dinov2_vits14 (ViT-S/14, 21M params)

Sử dụng embedding từ DINOv2 để kiểm tra xem vùng ảnh trong drawing có thực sự chứa ký hiệu giống template không, bằng cách so sánh cosine similarity giữa các vector feature.

Tại sao DINOv2?

Lý do chọn DINOv2
DINOv2 được huấn luyện self-supervised trên 142M ảnh tự nhiên, các feature của nó capture cấu trúc hình học (shape primitives) thay vì texture hay màu sắc. Bản vẽ kỹ thuật (line-art trắng đen) là domain rất khác với ảnh tự nhiên, nhưng DINOv2 vẫn generalize tốt vì các feature mức patch encode đặc trưng hình học cơ bản (đường thẳng, góc, đường cong) — đây chính xác là những gì bản vẽ chứa.

Embedding pipeline

# 1. Encode template (cached sau lần đầu)
template_feat = model.forward_features(template_tensor)["x_norm_patchtokens"].mean(dim=1)
template_feat = F.normalize(template_feat, dim=1)

# 2. Với mỗi candidate:
crop = drawing[y:y+h, x:x+w]            # cắt vùng ảnh

# Derotate nếu cần (Pass 3a/3b với derotate=True)
if derotate and abs(angle) > 1.0:
    crop = rotate_crop(crop, -angle)     # xoay ngược để align với template

# Encode full crop
crop_feat = model.forward_features(crop_tensor)["x_norm_patchtokens"].mean(dim=1)
crop_feat = F.normalize(crop_feat, dim=1)

# Encode center crop (70% bbox, loại bỏ wire leads)
center_crop = crop[h*15//100 : h*85//100, w*15//100 : w*85//100]
center_feat = ... (tương tự)

# 3. Cosine similarity — lấy max của full và center crop
sim = max(cosine(crop_feat, template_feat),
          cosine(center_feat, template_feat))

# 4. Lọc theo threshold
if sim >= cosine_threshold:
    candidate["dino_score"] = sim
    candidate["confidence"] = (ncc_score + sim) / 2
    verified.append(candidate)

DINOv2 Threshold theo pass

PassTemplate loạiThresholdLý do
Standard (Pass 1+2)All0.84Threshold mặc định, cân bằng precision/recall
Pass 3a (micro 0°)Complex, probe-focused0.878Loại FP (diode/cap dino=0.843–0.877) trong khi giữ TP (dino≥0.884)
Pass 3b (micro 90°)Complex, probe-focused0.78 (cố định)Vertical crops mất ~0.04 score do warpAffine; TP dọc đo được 0.80–0.83
Pass A (Simple, Chamfer)Simple outlineN/ASimple templates dùng Chamfer thay DINOv2
Pass C (DINODense)Simple, large instance0.78Dense sliding window, threshold thoáng hơn

Pass 3b NCC Gate (bổ sung)

Sau DINOv2 verification cho Pass 3b, áp dụng thêm NCC gate:

# Genuine vertical resistors: NCC 0.73–0.78 với rotated template
# Structural FP (capacitors nhìn giống vertical): NCC 0.35–0.45
# → Gap rõ ràng tại NCC = 0.55
verified_3b = [c for c in verified_3b if c["ncc_score"] >= 0.55]

3.4 Stage 2b — Structural Filters

Module: src/postprocessor.py

Bộ lọc hình học chạy sau DINOv2 để loại các FP mà DINOv2 không phân biệt được (vì có feature tương đồng mức cao). Các filter này không phụ thuộc model — chỉ dùng phân tích pixel.

Lưu ý về Simple vs Complex template
Bộ lọc wire-lead, junction-dot, rect-integrity chỉ áp dụng cho Simple template. Complex template (zigzag resistor) dùng Chamfer filter + Confidence Gap filter.

Danh sách filter (Simple Template)

F1
filter_wire_leads
Kiểm tra dây dẫn vào/ra linh kiện. Quét toàn bộ các hàng của bbox, probe 24px sang trái và phải (top/bottom cho vertical). Genuine component phải có dark run ít nhất một phía. Bypass nếu dino_score ≥ 0.88.
F2
filter_wire_passthrough
Loại vùng mà đường dây chạy xuyên qua phần thân component. Check: (1) horizontal dark run trong center 1/3 chiều cao → horizontal wire; (2) vertical dark run trong center 1/2 chiều rộng → vertical wire through body.
F3
filter_junction_dots
Loại vùng chứa junction dot (chấm nối dây). Phân tích connected components: area ≥ 15px, AR ≤ 2.5, fill ≥ 0.40 → là junction dot → reject candidate.
F4
filter_rect_integrity
Loại L-junction và "only-top artifacts". Kiểm tra: phải có cả top border VÀ bottom border đủ dài (ratio ≥ 0.50). Bypass nếu dino ≥ 0.89.
F5
filter_neighborhood_complexity
Genuine component nằm trong vùng thưa (ít edge). Mở rộng bbox 50%, tính edge density của ring xung quanh. max_edge_density = 0.022 cho circuit area.
F6
filter_isolated
Loại candidate quá nhỏ hoặc không có context dây nối. Kết hợp aspect ratio check với expected component shape.

Chamfer Distance Filter (Complex Template)

Đo sự khớp cạnh giữa vùng ảnh trong drawing và template ở cùng kích thước:

# 1. Resize (và rotate nếu vertical) template về kích thước bbox
tmpl_scaled = cv2.resize(rotate_if_vert(template), (bbox_w, bbox_h))

# 2. Canny edges cho cả hai
tmpl_edges   = cv2.Canny(tmpl_scaled, 30, 100)
region_edges = cv2.Canny(drawing_region, 30, 100)

# 3. Distance transform từ region edges
dt = cv2.distanceTransform(255 - region_edges, cv2.DIST_L2, 5)

# 4. Chamfer = mean distance từ template edges đến region edges (bidirectional)
t2r = mean(dt[tmpl_edge_pixels])       # template → region
r2t = mean(dt_tmpl[region_edge_pixels]) # region → template (symmetric)
chamfer = (t2r + r2t) / 2

# Threshold: max_chamfer = 5.0 (probe-focused), 3.0 (simple template)
if chamfer > max_chamfer:
    reject(candidate)

Confidence Gap Filter

# Tìm gap lớn nhất trong phân bố confidence (sorted descending)
confs = sorted([c["confidence"] for c in candidates], reverse=True)
best_gap = max(confs[i] - confs[i+1] for i in range(len(confs)-1))

# Nếu gap >= 0.075: loại cluster thấp hơn
if best_gap >= 0.075:
    threshold = midpoint_of_gap
    candidates = [c for c in candidates if c["confidence"] >= threshold]

Final NMS (Containment-aware)

def containment_iou(a, b):
    inter = intersection_area(a, b)
    min_area = min(a.area, b.area)
    return max(standard_iou(a, b),
               inter / min_area)  # ← key: loại candidate bị "chứa" trong cái khác

# Sort by confidence desc, greedy NMS
final_nms(candidates, iou_threshold=0.40, use_union_bbox=False)

3.5 Stage 3 — VLM Semantic Filter (Optional)

Module: src/vlm_verifier.py · Model: Qwen/Qwen2-VL-2B-Instruct

Stage 3 chỉ kích hoạt khi use_vlm=True. Model Qwen2-VL-2B được lazy-load (~4.5GB bf16) và chỉ xử lý các candidate "borderline" (conf < vlm_keep_min_conf).

Open-classification approach

Vấn đề với yes/no prompting
Thử nghiệm cho thấy small VLM (2B) có "agreement bias" — trả lời "yes" cho 40/40 crops khi hỏi "Is this a resistor?". Giải pháp: dùng open-classification thay vì yes/no confirmation.
# Prompt template (open-classification)
prompt = """You are an electronics expert. Look at this crop from a schematic.
Choose exactly one label from:
resistor | inductor | capacitor | diode | crystal | transistor | op-amp | logic-gate | wire-junction | other

Reply with only the label, nothing else."""

# Classify template để biết target class
target_class = classify_template(template)  # → "resistor"

# Với mỗi borderline candidate:
label = classify_crop(drawing_crop)  # → "diode" | "resistor" | ...

# Reject-only mode (default):
reject_labels = ALL_CLASSES - {target_class} - UNTRUSTED_LABELS
# UNTRUSTED_LABELS = {"transistor", "wire-junction", "other"}
# → Model 2B hay gọi nhầm resistor là "transistor" → không dùng làm lý do reject

if label in reject_labels:  # e.g., "diode", "capacitor"
    reject(candidate)
else:
    keep(candidate)  # "resistor" hoặc nhãn không đáng tin

Recall-boost mode

Khi vlm_recall_boost=True (theo mặc định khi enable VLM), pipeline mở rộng scale sweep và nới lỏng Chamfer/DINOv2 threshold để tìm thêm candidate, rồi để VLM làm bộ lọc precision:

Tham sốNormalVLM Recall-boost
Scale fracs (probe-focused)[0.80–1.20][0.55–1.55]
DINOv2 threshold (3a)0.8780.80
Chamfer max5.07.0
Confidence gap filterBậtTắt

4. NCC Matcher — Chi tiết kỹ thuật

File: src/ncc_matcher.py · class NCCMatcher

API

class NCCMatcher:
    def __init__(self,
                 scales: List[float] = None,      # default: [0.85, 0.90, ..., 1.15]
                 angles: List[float] = None,      # default: [-10, -5, 0, 5, 10]
                 ncc_threshold: float = 0.55,
                 nms_iou_threshold: float = 0.30):

    def match(self, drawing: np.ndarray, template: np.ndarray) -> List[dict]:
        """Returns: [{x, y, w, h, ncc_score, scale, angle}, ...]"""

    def rotate_template(self, template: np.ndarray, angle: float) -> np.ndarray:

Candidate format

{
  "x": int,          # tọa độ top-left trong drawing
  "y": int,
  "w": int,          # kích thước bounding box (= rotated template size)
  "h": int,
  "ncc_score": float, # NCC score [0, 1]
  "scale": float,     # tỉ lệ scale so với template gốc
  "angle": float      # góc xoay tính bằng độ
}

NMS cross-orientation protection

def _apply_nms(candidates, iou_threshold):
    for i, cand in enumerate(sorted_candidates):
        for j, other in enumerate(remaining):
            # KHÔNG suppress nếu 2 candidate khác orientation group
            a_vert = 70 <= abs(cand["angle"]) <= 110
            b_vert = 70 <= abs(other["angle"]) <= 110
            if a_vert != b_vert:
                continue  # ← bỏ qua so sánh giữa H và V

            if containment_iou(cand, other) > iou_threshold:
                suppress(lower_ncc_candidate)

5. DINOv2 Verifier — Chi tiết kỹ thuật

File: src/dino_verifier.py · class DINOVerifier

Model architecture

Thuộc tínhdinov2_vits14
ArchitectureVision Transformer, patch size 14×14
Parameters21M (Small variant)
TrainingDINO self-supervised, 142M web images
Output dim384 (patch tokens pooled → mean)
Input sizeBất kỳ (resize về bội số 14 trước khi encode)
VRAM~0.5GB fp32 / ~0.25GB fp16

Feature extraction

features = model.forward_features(image_tensor)
# x_norm_patchtokens: (B, N_patches, D) — patch-level features
pooled = features["x_norm_patchtokens"].mean(dim=1)  # (B, D)
unit_norm = F.normalize(pooled, dim=1)               # L2-normalized

Orientation normalization

def embed_crops_normalized(crops):
    """Normalize tất cả crops về landscape orientation trước khi encode."""
    for crop in crops:
        h, w = crop.shape[:2]
        if h > w:  # portrait → rotate CCW để thành landscape
            crop = cv2.rotate(crop, cv2.ROTATE_90_COUNTERCLOCKWISE)
    return embed_crops_batch(normalized_crops)

Confidence computation

confidence = (ncc_score + dino_score) / 2
# Cân bằng giữa NCC (recall signal) và DINOv2 (precision signal)
# Candidates được sort by confidence giảm dần

6. Structural Filters — Chi tiết kỹ thuật

File: src/postprocessor.py · class Postprocessor

filter_wire_leads — thông số đo

def filter_wire_leads(candidates, drawing, pattern):
    for cand in candidates:
        if cand["dino_score"] >= 0.88:
            keep(cand)  # ← DINOv2 bypass: high-confidence không cần filter
            continue

        # Probe left và right (horizontal component)
        probe = 24  # pixel
        min_run = 2  # số pixel tối thiểu của dark run (mạnh)
        min_run_weak = 0  # một phía OK nếu phía kia weak

        has_left_lead = any_dark_run_left(bbox, probe, min_run)
        has_right_lead = any_dark_run_right(bbox, probe, min_run)

        if (has_left_lead + has_right_lead) == 0:
            reject(cand)  # cả hai phía đều không có lead

Title-block filter

# Phát hiện khung tên bản vẽ (title block) bằng vertical frame lines
# Chỉ chạy khi title_block_x < drawing_width (có vertical lines)
if title_block_x < W:
    bom_start_x = title_block_x
    candidates = [c for c in candidates if c["x"] + c["w"] <= bom_start_x]
# Không có frame lines → open circuit, bom_start_x = W (không filter)

7. VLM Verifier — Chi tiết kỹ thuật

File: src/vlm_verifier.py · class VLMVerifier

Model loading (lazy)

class VLMVerifier:
    def __init__(self, model_name="Qwen/Qwen2-VL-2B-Instruct", ...):
        self._model = None  # lazy: chỉ load khi cần

    def _ensure_loaded(self):
        if self._model is None:
            # Giải phóng VRAM cache trước khi load VLM
            torch.cuda.empty_cache()
            self._model = Qwen2VLForConditionalGeneration.from_pretrained(
                model_name, torch_dtype=torch.bfloat16,
                low_cpu_mem_usage=True
            ).to(device)

Classification vocabulary

CLASSES = [
    "resistor", "inductor", "capacitor", "diode", "crystal",
    "transistor", "op-amp", "logic-gate", "wire-junction", "other"
]

UNTRUSTED_LABELS = {"transistor", "wire-junction", "other"}
# Các nhãn 2B model hay gọi sai → không dùng làm lý do reject

# Reject labels khi target = "resistor":
reject_labels = CLASSES - {"resistor"} - UNTRUSTED_LABELS
#             = {"inductor", "capacitor", "diode", "crystal", "op-amp", "logic-gate"}

OOM protection

# Trong server.py: VLM failure không crash request
try:
    verified = vlm.filter_by_template_class(drawing, template, candidates, ...)
except Exception as vlm_err:
    traceback.print_exc()
    print(f"[VLM] Falling back to NCC+DINOv2 result: {vlm_err}")
    # verified giữ nguyên từ Stage 2b

8. Web UI — FastAPI Backend

File: app/web/server.py

API Endpoints

MethodEndpointMô tả
GET/Trả về index.html (SPA)
POST/api/detectNhận pattern + drawing, trả về detections + visualization
GET/api/export/csvExport kết quả cuối cùng dạng CSV
GET/static/*CSS, JS, favicon

POST /api/detect — Request/Response

# Request (multipart/form-data)
pattern:       UploadFile  # ảnh template
drawing:       UploadFile  # ảnh bản vẽ
mode:          str = "auto"    # "auto" | "manual"
ncc_threshold: float = 0.55
cosine_threshold: float = 0.84
final_nms_iou: float = 0.40
use_vlm:       str = "false"

# Response (JSON)
{
  "success": true,
  "total_detections": 22,
  "detections": [
    {
      "bbox": {"x": 69, "y": 9, "w": 108, "h": 63},
      "confidence": 0.880,
      "ncc_score": 0.822,
      "dino_score": 0.939,
      "scale": 1.5,
      "angle": 0.0
    }, ...
  ],
  "elapsed": 24.5,
  "visualization": "base64_png_string",
  "drawing_preview": "base64_png_string"
}

Pipeline singleton

# Pipeline được khởi tạo một lần, cache trong suốt server lifetime
_pipeline: Optional[PatternDetectionPipeline] = None

def get_pipeline() -> PatternDetectionPipeline:
    global _pipeline
    if _pipeline is None:
        _pipeline = PatternDetectionPipeline()
    return _pipeline

# VLM toggle tại runtime (không restart server)
pipeline.use_vlm = coerce_bool(use_vlm)  # lazy-loads VLM on first enable

9. Cấu hình hệ thống

Tất cả tham số config

KeyDefaultMô tả
ncc_threshold0.55NCC threshold cho Pass 1 (strict)
nms_iou_threshold0.30IoU threshold cho NMS nội trong NCC matcher
dino_model"dinov2_vits14"Model DINOv2 (vits14 hoặc vitb14)
cosine_threshold0.84DINOv2 cosine similarity threshold (Pass 1+2)
deviceauto"cuda" hoặc "cpu" — auto detect nếu None
final_nms_iou0.40IoU threshold cho Final NMS
dilate_pattern0Kernel size để dilate template stroke (0 = off)
use_dino_denseTrueKích hoạt Pass C (DINODense) cho large-scale simple templates
dense_sim_threshold0.78Similarity threshold cho DINODense
use_vlmFalseKích hoạt Stage 3 VLM filter
vlm_model"Qwen/Qwen2-VL-2B-Instruct"HuggingFace model ID cho VLM
vlm_keep_min_conf0.75Candidate trên threshold này bỏ qua VLM (trusted)
vlm_reject_onlyTrueBlacklist mode: chỉ reject khi VLM gọi đúng tên FP
vlm_recall_boostfollows use_vlmMở rộng scale sweep khi VLM on
vlm_symbol_nameNoneGợi ý class name cho VLM prompt

Ví dụ khởi tạo

from src.pipeline import PatternDetectionPipeline

# Cấu hình cơ bản
pipe = PatternDetectionPipeline()

# Cấu hình tùy chỉnh
pipe = PatternDetectionPipeline(config={
    "cosine_threshold": 0.84,
    "final_nms_iou": 0.4,
    "use_vlm": True,
    "vlm_symbol_name": "a zigzag resistor",
})

# Chạy detection
result = pipe.detect_auto("template.png", "drawing.png",
                           return_visualization=True)
print(result["total_detections"])
for d in result["detections"]:
    print(d["bbox"], d["confidence"])

10. Docker & HuggingFace Spaces

Dockerfile strategy

FROM python:3.11-slim

# CPU-only PyTorch (HF free tier không có GPU)
RUN pip install "torch==2.1.0+cpu" "torchvision==0.16.0+cpu" \
    --extra-index-url https://download.pytorch.org/whl/cpu

RUN pip install -r requirements.txt

COPY . .

# Pre-download DINOv2 weights tại build time (~86MB)
# → tránh cold-start delay khi request đầu tiên
RUN python -c "import torch; torch.hub.load('facebookresearch/dinov2', 'dinov2_vits14')" || true

EXPOSE 7860
CMD ["python", "-m", "uvicorn", "app.web.server:app", "--host", "0.0.0.0", "--port", "7860"]

HuggingFace Spaces YAML frontmatter

---
title: BOM Pattern Detection
sdk: docker
app_port: 7860
pinned: false
---
Lưu ý triển khai
VLM (Qwen2-VL-2B, ~5GB) không được pre-download tại build time vì vượt quá giới hạn HF free tier. VLM được lazy-load khi người dùng bật checkbox "Use VLM" lần đầu. Trên CPU, VLM sẽ rất chậm (~10-20s/crop); chỉ thực tế trên GPU ≥ 8GB.

11. Kết quả thực nghiệm

Drawing 4 + test_2.png (zigzag resistor template, 1536×1024px)

22/24
GT boxes detected (91.7% recall)
0
False Positives
~25s
Runtime (RTX 3060 12GB)

Các ký hiệu được phát hiện thành công

LoạiSố lượngĐặc điểm khóKết quả
Horizontal resistor (0°)15Kích thước 91–129px, NCC 0.66–0.84Tất cả detected
Vertical resistor (90°)7DINOv2 score thấp hơn ~0.04 so với horizontalTất cả detected
Resistor trong hộp kép (GT22)1DINOv2=0.844, trùng range với diode FPMissed (irreducible)
Resistor ở biên ảnh dưới (GT24)1NCC bị suppressed trong NCC NMSMissed (boundary)

So sánh trước/sau các cải tiến chính

VersionDetectionsGT CoveredFPsThay đổi chính
Baseline (Session 5)259/2416NCC only, no DINOv2 derotate
+ DINOv2 derotate + 3b threshold2822/2443b DINOv2=0.78, NCC gate 0.55
+ 3a threshold 0.878 (FP removal)2222/240DINOv2=0.878 loại diode/cap FP

Example pairs (synthetic test)

PairDetectionsExpectedStatus
example1 (bridge rectifier)55Exact
example2 (simple component)44Exact

Unit tests

$ python -m pytest tests/ -q
..........  [100%]
10 passed in 0.56s

12. Quyết định thiết kế

Tại sao NCC + DINOv2 thay vì end-to-end detector?

NCC alone: Nhanh nhưng brittle — thất bại khi có noise, scale variation nhỏ, hay domain shift nhẹ giữa template và drawing.

DINOv2 dense sliding window: Chính xác nhưng cực kỳ chậm. Với drawing 1536×1024 và window 63×108: ~(1536-63)×(1024-108) ≈ 1.35M vị trí × encode cost → không thực tế.

Hybrid NCC → DINOv2: NCC propose 200–400 candidates với recall cao. DINOv2 verify với accuracy neural. Tổng cost = NCC (fast) + DINOv2 trên vài trăm crops (manageable). Đây là kiến trúc cascade hiệu quả nhất cho bài toán này.

Tại sao ViT-S thay vì ViT-B?

ViT-S/14 (21M params) được chọn vì: (1) đủ feature quality cho bản vẽ kỹ thuật binary, (2) 2× nhanh hơn ViT-B trên CPU, (3) 0.5GB VRAM thay vì 1.5GB. Swap sang dinov2_vitb14 nếu cần accuracy cao hơn.

Tại sao không dùng CLIP?

Thực nghiệm trong design_spec/model_survey.md: CLIP image-to-image similarity không cho separation FP tốt hơn DINOv2 trên bản vẽ. CLIP text-guided phá vỡ zero-shot vì cần biết tên class trước.

Tại sao VLM dùng open-classification thay vì yes/no?

Model 2B có "agreement bias" — trả lời "yes" cho 40/40 crops khi prompt dạng yes/no. Open-classification bắt model phải chọn từ vocabulary cụ thể, loại bỏ bias này. Chi tiết trong design_spec/model_survey.md §VLM.

Tại sao DINOv2 threshold 3a khác 3b?

Pass 3b (vertical) sử dụng warpAffine để de-rotate crop về horizontal trước khi so sánh với template. Phép biến đổi affine gây ra interpolation artifacts, làm cosine similarity giảm ~0.03–0.05. Đo thực nghiệm: genuine vertical resistors score 0.80–0.83, trong khi horizontal TPs score 0.85–0.94. Dùng chung threshold sẽ miss tất cả vertical instances.

13. Hạn chế & Hướng cải thiện

Hạn chế hiện tại

Boundary Resistors
Ký hiệu nằm tại biên ảnh (bottom/right edge) có thể bị suppress trong NCC NMS bởi candidate khác ở kích thước template lớn hơn có IoU containment cao.
Resistor trong Framing Box
Khi ký hiệu nằm trong một khung chữ nhật bao quanh (như GT22), DINOv2 score giảm do context clutter. Score có thể trùng với FP diode, không phân biệt được bằng feature level.
Heavy Rotation (>15°)
Chỉ hỗ trợ ±10° (standard) và 90° (riêng Pass 3b). Ký hiệu bị xoay 45° sẽ không được phát hiện.
VLM Accuracy
Model 2B hay gọi nhầm line-art mơ hồ là "transistor". Reject-only mode xử lý được phần nào nhưng không hoàn hảo.

Hướng cải thiện

Boundary Aware NMS
Giảm NMS threshold cho candidates gần biên ảnh (bottom 10%, right 5%) để tránh over-suppress.
Prototype-based DINOv2
Sau khi detect xong, dùng high-confidence TPs làm prototype cluster. So sánh borderline candidates với cluster thay vì single template.
VLM 7B 4-bit
Thử Qwen2-VL-7B với quantization 4-bit (~4.5GB) để classification chính xác hơn, giảm FP mà không ảnh hưởng TP.
Batch Processing
Hiện tại xử lý 1 drawing/request. Có thể thêm batch mode với ONNX export + TensorRT để tăng throughput.

Các thành phần đã được loại bỏ

Thành phầnLý do loạiCommit
CLIP ViT-B/32 verifierBenchmark cho thấy không cải thiện FP separation so với DINOv2. Text-guided phá vỡ zero-shot.e5f1c38
Borderline DINOv2 rescue (Chamfer+DINOv2)Candidate được rescue tại (928,731) sau đó xác nhận là FP (wire corner).d15fb7a
LightGlue keypoint verificationPhụ thuộc keypoint descriptor — không hoạt động tốt với binary line-art ít texture.N/A (không implement)