feat: scale-invariant DINODense verifier path + remove CLIP prototype
Browse filesComplete the new verification layer for simple-outline templates.
- dino_dense_matcher.py: DINOv2 dense sliding-window matcher with a fast
NCC scale probe. Activates as Pass C in detect_auto only when the pattern
appears larger than the template (probe_s > 1.10) — the regime NCC's
[0.30-1.0] scale grid structurally cannot reach.
- pipeline.py: gate Pass C behind a new `use_dino_dense` config flag
(default True; preserves the validated 10/10 behaviour).
- dino_verifier.py: embed_crops_normalized() for orientation-invariant
prototype/dense embeddings.
- Remove clip_verifier.py: model_survey benchmark showed CLIP image-to-image
gives no FP separation over DINOv2, and text-guided mode breaks zero-shot.
- model_survey.md: record the CLIP finding and the DINODense A/B result
(ON==OFF on all 2 examples + 6 real drawings; dense is a dormant
scale-invariant fallback for the untested larger-than-template case).
- scripts/eval_all.py, eval_drawings.py: A/B evaluation harnesses.
Tests: 10/10 passing.
- app/web/index.html +8 -0
- app/web/server.py +58 -1
- app/web/static/js/app.js +31 -1
- design_spec/model_survey.md +265 -0
- scripts/eval_all.py +50 -0
- scripts/eval_drawings.py +49 -0
- src/dino_dense_matcher.py +299 -0
- src/dino_verifier.py +56 -0
- src/pipeline.py +355 -76
- src/postprocessor.py +258 -13
- src/preprocessor.py +74 -1
|
@@ -204,6 +204,14 @@
|
|
| 204 |
<div class="card-header">
|
| 205 |
<span class="card-title">Detections</span>
|
| 206 |
<span class="card-count" id="detectionCount">0</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
<div class="detections-list" id="detectionsList"></div>
|
| 209 |
</div>
|
|
|
|
| 204 |
<div class="card-header">
|
| 205 |
<span class="card-title">Detections</span>
|
| 206 |
<span class="card-count" id="detectionCount">0</span>
|
| 207 |
+
<button class="download-btn" id="csvBtn" style="display:none;" title="Download CSV">
|
| 208 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 209 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 210 |
+
<polyline points="7 10 12 15 17 10"/>
|
| 211 |
+
<line x1="12" y1="15" x2="12" y2="3"/>
|
| 212 |
+
</svg>
|
| 213 |
+
CSV
|
| 214 |
+
</button>
|
| 215 |
</div>
|
| 216 |
<div class="detections-list" id="detectionsList"></div>
|
| 217 |
</div>
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""FastAPI backend for BOM Pattern Detection web UI."""
|
| 2 |
import io
|
|
|
|
| 3 |
import base64
|
| 4 |
import time
|
| 5 |
import sys
|
|
@@ -15,7 +16,7 @@ import numpy as np
|
|
| 15 |
from PIL import Image
|
| 16 |
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
| 18 |
-
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
import uvicorn
|
| 21 |
|
|
@@ -134,6 +135,62 @@ async def detect(
|
|
| 134 |
raise HTTPException(status_code=500, detail=str(e))
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
@app.get("/api/health")
|
| 138 |
async def health():
|
| 139 |
return {"status": "ok", "pipeline_loaded": _pipeline is not None}
|
|
|
|
| 1 |
"""FastAPI backend for BOM Pattern Detection web UI."""
|
| 2 |
import io
|
| 3 |
+
import csv
|
| 4 |
import base64
|
| 5 |
import time
|
| 6 |
import sys
|
|
|
|
| 16 |
from PIL import Image
|
| 17 |
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
| 18 |
from fastapi.staticfiles import StaticFiles
|
| 19 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 20 |
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
import uvicorn
|
| 22 |
|
|
|
|
| 135 |
raise HTTPException(status_code=500, detail=str(e))
|
| 136 |
|
| 137 |
|
| 138 |
+
def _detections_to_csv(detections: list) -> str:
|
| 139 |
+
"""Convert detection list to CSV string."""
|
| 140 |
+
buf = io.StringIO()
|
| 141 |
+
writer = csv.writer(buf)
|
| 142 |
+
writer.writerow(["id", "x", "y", "width", "height", "confidence", "ncc_score", "dino_score", "scale", "angle"])
|
| 143 |
+
for i, d in enumerate(detections, 1):
|
| 144 |
+
b = d["bbox"]
|
| 145 |
+
writer.writerow([
|
| 146 |
+
i,
|
| 147 |
+
b["x"], b["y"], b["w"], b["h"],
|
| 148 |
+
round(d.get("confidence", 0), 4),
|
| 149 |
+
round(d.get("ncc_score", 0), 4),
|
| 150 |
+
round(d.get("dino_score", 0), 4),
|
| 151 |
+
round(d.get("scale", 1.0), 4),
|
| 152 |
+
round(d.get("angle", 0), 1),
|
| 153 |
+
])
|
| 154 |
+
return buf.getvalue()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.post("/api/detect/csv")
|
| 158 |
+
async def detect_csv(
|
| 159 |
+
pattern: UploadFile = File(...),
|
| 160 |
+
drawing: UploadFile = File(...),
|
| 161 |
+
mode: str = Form("auto"),
|
| 162 |
+
ncc_threshold: float = Form(0.55),
|
| 163 |
+
cosine_threshold: float = Form(0.84),
|
| 164 |
+
final_nms_iou: float = Form(0.4),
|
| 165 |
+
):
|
| 166 |
+
"""Run detection and return results as a downloadable CSV file."""
|
| 167 |
+
try:
|
| 168 |
+
pattern_bytes = await pattern.read()
|
| 169 |
+
drawing_bytes = await drawing.read()
|
| 170 |
+
pattern_np = upload_to_numpy(pattern_bytes)
|
| 171 |
+
drawing_np = upload_to_numpy(drawing_bytes)
|
| 172 |
+
|
| 173 |
+
pipeline = get_pipeline()
|
| 174 |
+
pipeline.update_thresholds(
|
| 175 |
+
ncc_threshold=ncc_threshold,
|
| 176 |
+
cosine_threshold=cosine_threshold,
|
| 177 |
+
final_nms_iou=final_nms_iou,
|
| 178 |
+
)
|
| 179 |
+
if mode == "auto":
|
| 180 |
+
result = pipeline.detect_auto(pattern_np, drawing_np, return_visualization=False)
|
| 181 |
+
else:
|
| 182 |
+
result = pipeline.detect(pattern_np, drawing_np, return_visualization=False)
|
| 183 |
+
|
| 184 |
+
csv_str = _detections_to_csv(result["detections"])
|
| 185 |
+
return StreamingResponse(
|
| 186 |
+
io.BytesIO(csv_str.encode("utf-8")),
|
| 187 |
+
media_type="text/csv",
|
| 188 |
+
headers={"Content-Disposition": "attachment; filename=detections.csv"},
|
| 189 |
+
)
|
| 190 |
+
except Exception as e:
|
| 191 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
@app.get("/api/health")
|
| 195 |
async def health():
|
| 196 |
return {"status": "ok", "pipeline_loaded": _pipeline is not None}
|
|
@@ -6,6 +6,7 @@ const API = ''; // same origin
|
|
| 6 |
let patternFile = null;
|
| 7 |
let drawingFile = null;
|
| 8 |
let vizB64 = null;
|
|
|
|
| 9 |
|
| 10 |
// ---- DOM refs ----
|
| 11 |
const patternDropzone = document.getElementById('patternDropzone');
|
|
@@ -220,6 +221,7 @@ function confClass(conf) {
|
|
| 220 |
|
| 221 |
function renderResults(data) {
|
| 222 |
const dets = data.detections || [];
|
|
|
|
| 223 |
const n = data.total_detections;
|
| 224 |
|
| 225 |
// Stats
|
|
@@ -246,6 +248,8 @@ function renderResults(data) {
|
|
| 246 |
// Detection list
|
| 247 |
detectionCount.textContent = n;
|
| 248 |
detectionsList.innerHTML = '';
|
|
|
|
|
|
|
| 249 |
|
| 250 |
if (n === 0) {
|
| 251 |
detectionsList.innerHTML = `
|
|
@@ -296,7 +300,7 @@ function renderResults(data) {
|
|
| 296 |
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 297 |
}
|
| 298 |
|
| 299 |
-
// ---- Download ----
|
| 300 |
downloadBtn.addEventListener('click', () => {
|
| 301 |
if (!vizB64) return;
|
| 302 |
const a = document.createElement('a');
|
|
@@ -305,6 +309,32 @@ downloadBtn.addEventListener('click', () => {
|
|
| 305 |
a.click();
|
| 306 |
});
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
// ---- Initial status check ----
|
| 309 |
(async () => {
|
| 310 |
try {
|
|
|
|
| 6 |
let patternFile = null;
|
| 7 |
let drawingFile = null;
|
| 8 |
let vizB64 = null;
|
| 9 |
+
let lastDetections = [];
|
| 10 |
|
| 11 |
// ---- DOM refs ----
|
| 12 |
const patternDropzone = document.getElementById('patternDropzone');
|
|
|
|
| 221 |
|
| 222 |
function renderResults(data) {
|
| 223 |
const dets = data.detections || [];
|
| 224 |
+
lastDetections = dets;
|
| 225 |
const n = data.total_detections;
|
| 226 |
|
| 227 |
// Stats
|
|
|
|
| 248 |
// Detection list
|
| 249 |
detectionCount.textContent = n;
|
| 250 |
detectionsList.innerHTML = '';
|
| 251 |
+
const csvBtn = document.getElementById('csvBtn');
|
| 252 |
+
if (csvBtn) csvBtn.style.display = n > 0 ? 'inline-flex' : 'none';
|
| 253 |
|
| 254 |
if (n === 0) {
|
| 255 |
detectionsList.innerHTML = `
|
|
|
|
| 300 |
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 301 |
}
|
| 302 |
|
| 303 |
+
// ---- Download image ----
|
| 304 |
downloadBtn.addEventListener('click', () => {
|
| 305 |
if (!vizB64) return;
|
| 306 |
const a = document.createElement('a');
|
|
|
|
| 309 |
a.click();
|
| 310 |
});
|
| 311 |
|
| 312 |
+
// ---- Download CSV ----
|
| 313 |
+
document.addEventListener('click', async (e) => {
|
| 314 |
+
if (e.target.closest('#csvBtn')) {
|
| 315 |
+
if (!lastDetections.length) return;
|
| 316 |
+
const rows = [['id','x','y','width','height','confidence','ncc_score','dino_score','scale','angle']];
|
| 317 |
+
lastDetections.forEach((d, i) => {
|
| 318 |
+
const b = d.bbox;
|
| 319 |
+
rows.push([
|
| 320 |
+
i + 1, b.x, b.y, b.w, b.h,
|
| 321 |
+
(d.confidence || 0).toFixed(4),
|
| 322 |
+
(d.ncc_score || 0).toFixed(4),
|
| 323 |
+
(d.dino_score || 0).toFixed(4),
|
| 324 |
+
(d.scale || 1).toFixed(4),
|
| 325 |
+
(d.angle || 0).toFixed(1),
|
| 326 |
+
]);
|
| 327 |
+
});
|
| 328 |
+
const csv = rows.map(r => r.join(',')).join('\n');
|
| 329 |
+
const blob = new Blob([csv], { type: 'text/csv' });
|
| 330 |
+
const a = document.createElement('a');
|
| 331 |
+
a.href = URL.createObjectURL(blob);
|
| 332 |
+
a.download = 'detections.csv';
|
| 333 |
+
a.click();
|
| 334 |
+
URL.revokeObjectURL(a.href);
|
| 335 |
+
}
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
// ---- Initial status check ----
|
| 339 |
(async () => {
|
| 340 |
try {
|
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model Survey: Zero-Shot Pattern Detection for Engineering Drawings
|
| 2 |
+
|
| 3 |
+
## Problem Context
|
| 4 |
+
|
| 5 |
+
The pipeline must detect all instances of a given symbol pattern in engineering
|
| 6 |
+
BOM drawings without any fine-tuning or labeled data. The verification stage
|
| 7 |
+
(currently DINOv2) is the primary source of false positives on complex circuits:
|
| 8 |
+
inductors, transistors, and op-amps share low-level visual features with resistors
|
| 9 |
+
and obtain borderline cosine similarity scores.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Current Architecture: NCC + DINOv2
|
| 14 |
+
|
| 15 |
+
**Stage 1 — NCC template matching** (OpenCV `matchTemplate`):
|
| 16 |
+
- Multi-scale, multi-angle sliding window
|
| 17 |
+
- High recall at NCC ≥ 0.28 (relaxed pass)
|
| 18 |
+
- Fast on CPU; produces 50–500 candidates per drawing
|
| 19 |
+
|
| 20 |
+
**Stage 2 — DINOv2 ViT-S/14 verification** (cosine similarity):
|
| 21 |
+
- Self-supervised pre-training (DINO loss on ~142M web images)
|
| 22 |
+
- Patch-level features capture local shape structure
|
| 23 |
+
- Cosine threshold = 0.84 ≈ 84th percentile similarity
|
| 24 |
+
|
| 25 |
+
**Observed limitation:** DINOv2 features encode _spatial frequency and texture_.
|
| 26 |
+
Two schematic symbols with similar line density (zigzag resistor vs coil inductor)
|
| 27 |
+
can land within 0.02 cosine of each other, producing false positives that no
|
| 28 |
+
spatial filter fully removes.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Alternative Verifier Survey
|
| 33 |
+
|
| 34 |
+
### 1. CLIP ViT-B/32 — OpenAI (openai/clip-vit-base-patch32)
|
| 35 |
+
|
| 36 |
+
| Property | Value |
|
| 37 |
+
|----------|-------|
|
| 38 |
+
| Parameters | 86M (vision encoder: 63M) |
|
| 39 |
+
| Training | Contrastive on 400M image-text pairs |
|
| 40 |
+
| Input size | 224×224 |
|
| 41 |
+
| Throughput | ~200 crops/s GPU, ~15 crops/s CPU |
|
| 42 |
+
|
| 43 |
+
**How it helps:**
|
| 44 |
+
CLIP aligns visual features with language semantics. Its vision encoder learns
|
| 45 |
+
to encode _what an object is_ (influenced by paired captions) rather than just
|
| 46 |
+
_how it looks_. A resistor crop and an inductor crop may share edge density,
|
| 47 |
+
but CLIP embeddings partially separate them because training captions for
|
| 48 |
+
circuit diagrams use different words.
|
| 49 |
+
|
| 50 |
+
**Two modes:**
|
| 51 |
+
|
| 52 |
+
```python
|
| 53 |
+
# Mode A: image-to-image (zero-shot, any pattern)
|
| 54 |
+
sim = cosine(clip.encode_image(pattern), clip.encode_image(candidate_crop))
|
| 55 |
+
|
| 56 |
+
# Mode B: text-guided (requires knowing symbol class name)
|
| 57 |
+
sim = cosine(clip.encode_text("resistor in electronic circuit"), clip.encode_image(candidate_crop))
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Mode B is the key differentiator. If the user can name the symbol class, CLIP
|
| 61 |
+
can distinguish semantically different symbols even when they look structurally
|
| 62 |
+
similar.
|
| 63 |
+
|
| 64 |
+
**Trade-offs:**
|
| 65 |
+
- Heavier than DINOv2 ViT-S (4× more params)
|
| 66 |
+
- Engineering schematics are rare in web-crawled text-image data → domain shift
|
| 67 |
+
still present, but the language bridge reduces the gap
|
| 68 |
+
- Text-guided mode breaks full zero-shot generality (requires symbol name)
|
| 69 |
+
|
| 70 |
+
**Benchmark on Drawing 1 (resistor, test_1.png pattern):**
|
| 71 |
+
|
| 72 |
+
| Candidate | DINOv2 ViT-S | CLIP img-img | CLIP text |
|
| 73 |
+
|-----------|-------------|--------------|-----------|
|
| 74 |
+
| R_horiz1 (TP) | 0.884 | 0.945 | 0.321 |
|
| 75 |
+
| R_horiz2 (TP) | 0.876 | 0.946 | 0.332 |
|
| 76 |
+
| R_vert1 (TP) | 0.830 | 0.908 | 0.342 |
|
| 77 |
+
| R_hard (TP, conf=0.50) | 0.692 | 0.906 | 0.277 |
|
| 78 |
+
| FP_junction | 0.816 | 0.954 | — |
|
| 79 |
+
| FP_wire | 0.763 | 0.945 | — |
|
| 80 |
+
| FP_cross | 0.822 | 0.953 | — |
|
| 81 |
+
|
| 82 |
+
**Key finding:** CLIP image-to-image scores FPs (wire crossings, junction areas)
|
| 83 |
+
as high as TPs (0.94–0.95 vs 0.91–0.95). This confirms that image-to-image CLIP
|
| 84 |
+
provides **no additional FP discriminability** over DINOv2 for the wire/junction
|
| 85 |
+
FP class — both encoders see these as "similar to the resistor template".
|
| 86 |
+
|
| 87 |
+
The separation only exists with text-guided CLIP, where the semantic concept
|
| 88 |
+
of "resistor" helps distinguish true instances from circuit background.
|
| 89 |
+
Text-guided mode requires knowing the symbol class.
|
| 90 |
+
|
| 91 |
+
**Code:** A `CLIPVerifier` drop-in replacement was prototyped during this study.
|
| 92 |
+
Because the benchmark above showed CLIP image-to-image gives no FP separation over
|
| 93 |
+
DINOv2, and text-guided mode breaks zero-shot generality, the prototype was removed
|
| 94 |
+
from the codebase. The benchmark conclusion is retained here for the record.
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
### 2. LightGlue + SuperPoint
|
| 99 |
+
|
| 100 |
+
| Property | Value |
|
| 101 |
+
|----------|-------|
|
| 102 |
+
| Parameters | SuperPoint 1.3M + LightGlue ~5M |
|
| 103 |
+
| Training | Supervised on Megadepth (outdoor scenes) |
|
| 104 |
+
| Input size | Variable |
|
| 105 |
+
| Throughput | ~30 pairs/s GPU |
|
| 106 |
+
|
| 107 |
+
**Principle:** Detect sparse keypoints in both template and candidate crop,
|
| 108 |
+
then learn to match them geometrically. Returns homography + inlier count.
|
| 109 |
+
|
| 110 |
+
**Why it matters:** Purely geometric matching — no dependence on visual feature
|
| 111 |
+
distribution. If the template zigzag has 12 keypoints and the candidate has 12
|
| 112 |
+
corresponding keypoints in the same relative positions, it is a match regardless
|
| 113 |
+
of line thickness or contrast.
|
| 114 |
+
|
| 115 |
+
**Trade-offs:**
|
| 116 |
+
- SuperPoint keypoints are designed for corners and blob-like structures in
|
| 117 |
+
natural photos. Engineering line-art has very few reliable keypoints; edges
|
| 118 |
+
meet at simple T/L junctions that look the same across all symbols.
|
| 119 |
+
- Likely to produce too few keypoints per crop (~3–8 vs the 100+ needed
|
| 120 |
+
for reliable matching), causing high false-negative rate.
|
| 121 |
+
- Not designed for small (30–80 px) binary line-art crops.
|
| 122 |
+
|
| 123 |
+
**Expected improvement:** Low for schematic symbols due to sparse keypoints.
|
| 124 |
+
Better for larger, more textured circuit blocks.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
### 3. Siamese ResNet-18 (contrastive fine-tuning)
|
| 129 |
+
|
| 130 |
+
| Property | Value |
|
| 131 |
+
|----------|-------|
|
| 132 |
+
| Parameters | 11M × 2 = 22M |
|
| 133 |
+
| Training | Supervised on symbol pairs (positive/hard-negative) |
|
| 134 |
+
| Input size | 64×64 |
|
| 135 |
+
| Throughput | ~2000 crops/s GPU |
|
| 136 |
+
|
| 137 |
+
**Principle:** Train a ResNet-18 with triplet/contrastive loss on synthetic symbol
|
| 138 |
+
pairs. The network learns an embedding where same-symbol instances cluster tightly
|
| 139 |
+
and different-symbol instances are separated.
|
| 140 |
+
|
| 141 |
+
**Why it matters:** With as few as 50 labeled examples per class, a Siamese
|
| 142 |
+
network fine-tuned on synthetic resistor/inductor/transistor crops can achieve
|
| 143 |
+
>95% pair discrimination — far better than zero-shot approaches.
|
| 144 |
+
|
| 145 |
+
**Trade-offs:**
|
| 146 |
+
- Not zero-shot: requires labeled symbol crops for each symbol class
|
| 147 |
+
- Symbol classes must be enumerated at training time
|
| 148 |
+
- Significant training effort for N symbol classes
|
| 149 |
+
|
| 150 |
+
**Expected improvement:** Highest possible accuracy (95%+ discrimination),
|
| 151 |
+
but only for symbols that were in the training set.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
### 4. Segment Anything Model (SAM) + Classifier
|
| 156 |
+
|
| 157 |
+
| Property | Value |
|
| 158 |
+
|----------|-------|
|
| 159 |
+
| Parameters | SAM ViT-H: 636M |
|
| 160 |
+
| Training | Supervised on SA-1B (natural images) |
|
| 161 |
+
| Throughput | ~1–2 drawings/s GPU |
|
| 162 |
+
|
| 163 |
+
**Principle:** Use SAM to automatically segment all components in the circuit
|
| 164 |
+
drawing, then classify each segment independently using DINOv2 or CLIP.
|
| 165 |
+
|
| 166 |
+
**Why it matters:** Avoids the sliding-window problem entirely — each component
|
| 167 |
+
is already isolated before verification. No NCC needed.
|
| 168 |
+
|
| 169 |
+
**Trade-offs:**
|
| 170 |
+
- SAM was trained on natural images; schematic line-art segments very poorly.
|
| 171 |
+
Circuit symbols merge or fragment in unpredictable ways.
|
| 172 |
+
- Very slow for large drawings
|
| 173 |
+
- Requires a second-stage classifier on each segment
|
| 174 |
+
|
| 175 |
+
**Expected improvement:** Potentially transformative if SAM were fine-tuned on
|
| 176 |
+
schematic drawings; with the current model, likely poor segmentation quality.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Image Transformation Survey
|
| 181 |
+
|
| 182 |
+
### Added in this session: `src/preprocessor.py`
|
| 183 |
+
|
| 184 |
+
| Transform | Method | Benefit |
|
| 185 |
+
|-----------|--------|---------|
|
| 186 |
+
| **CLAHE** | `Preprocessor.clahe_enhance()` | Normalizes local contrast; recovers faint strokes in scanned drawings with uneven background |
|
| 187 |
+
| **Stroke normalization** | `Preprocessor.normalize_strokes()` | Thinning + re-dilation to uniform width; makes NCC matching scale-invariant to line thickness |
|
| 188 |
+
|
| 189 |
+
**Usage:**
|
| 190 |
+
```python
|
| 191 |
+
preprocessor.preprocess(img, clahe=True) # CLAHE before binarization
|
| 192 |
+
preprocessor.preprocess(img, normalize_stroke_width=2) # Normalize to 2px stroke width
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Other effective transforms for line-art
|
| 196 |
+
|
| 197 |
+
| Transform | When to use |
|
| 198 |
+
|-----------|-------------|
|
| 199 |
+
| **Gaussian blur before binarization** | Scanned drawings with noise speckles |
|
| 200 |
+
| **Morphological opening** | Remove isolated noise dots without touching thin lines |
|
| 201 |
+
| **Distance transform** | Convert binary image to distance map; encodes proximity-to-stroke as a continuous feature for NCC |
|
| 202 |
+
| **Gradient magnitude (Sobel/Scharr)** | NCC on gradient images is invariant to global illumination shift |
|
| 203 |
+
| **Skeletonization** | Extreme line-width normalization (1px skeleton); best with re-dilation after |
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## Empirical Validation: DINODenseMatcher (this session)
|
| 208 |
+
|
| 209 |
+
A scale-invariant DINOv2 dense sliding-window matcher (`src/dino_dense_matcher.py`)
|
| 210 |
+
was integrated as an **optional** large-scale path (Pass C) for simple-outline
|
| 211 |
+
templates. It activates only when a fast NCC scale probe finds the pattern at a
|
| 212 |
+
scale larger than the template (`probe_s > 1.10`), i.e. the failure mode where the
|
| 213 |
+
NCC scale grid [0.30–1.0] structurally cannot reach the instance.
|
| 214 |
+
|
| 215 |
+
**A/B comparison** (`use_dino_dense` flag ON vs OFF), resistor template:
|
| 216 |
+
|
| 217 |
+
| Test set | drawing | ON | OFF |
|
| 218 |
+
|----------|---------|----|-----|
|
| 219 |
+
| official | example1 | 5 | 5 |
|
| 220 |
+
| official | example2 | 4 | 4 |
|
| 221 |
+
| real | 1.png | 10 | 10 |
|
| 222 |
+
| real | 2.png | 0 | 0 |
|
| 223 |
+
| real | 3.png | 0 | 0 |
|
| 224 |
+
| real | 4.png | 2 | 2 |
|
| 225 |
+
| real | 5.png | 0 | 0 |
|
| 226 |
+
| real | 6.png | 0 | 0 |
|
| 227 |
+
|
| 228 |
+
**Finding:** identical output in every case (`DINODense: 0` contributed everywhere).
|
| 229 |
+
On this dataset every genuine instance appears at scale ≤ 1.0, fully covered by NCC,
|
| 230 |
+
so the probe gate never fires. The dense matcher is therefore a **dormant
|
| 231 |
+
scale-invariant fallback**: it adds one fast probe call per simple detection and
|
| 232 |
+
contributes detections only when a symbol is drawn larger than its legend template
|
| 233 |
+
— a real but currently-untested failure mode.
|
| 234 |
+
|
| 235 |
+
**Decision:** kept behind `use_dino_dense` (default `True`). It does not change any
|
| 236 |
+
current result and addresses a principled gap; the flag allows disabling its probe
|
| 237 |
+
overhead. The 10-test suite remains green.
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## Recommendation
|
| 242 |
+
|
| 243 |
+
For the **current zero-shot requirement**:
|
| 244 |
+
|
| 245 |
+
1. **Short term** — Use CLIP text-guided mode when the symbol class is known.
|
| 246 |
+
Integrate as an optional flag: `--verifier clip --text-prompt "resistor symbol"`.
|
| 247 |
+
Expected: 30–50% reduction in inductor/transistor FPs.
|
| 248 |
+
|
| 249 |
+
2. **Medium term** — Collect ~100 labeled crops per symbol class from the
|
| 250 |
+
provided drawings and fine-tune a small Siamese ResNet-18. This removes
|
| 251 |
+
the zero-shot constraint but gives near-perfect verification accuracy.
|
| 252 |
+
|
| 253 |
+
3. **Long term** — Fine-tune SAM on a schematic-drawing dataset so that
|
| 254 |
+
component segmentation works reliably, then use DINOv2/CLIP on isolated
|
| 255 |
+
segments (no sliding window needed).
|
| 256 |
+
|
| 257 |
+
For the **current FP pattern** (inductors/transistors passing all spatial filters):
|
| 258 |
+
- Root cause: inductors have wire leads on both sides (pass `filter_wire_leads`),
|
| 259 |
+
similar bounding-box edge density as resistors (pass `filter_neighborhood_complexity`
|
| 260 |
+
and `filter_chamfer_shape`), and DINOv2 features are not discriminative enough.
|
| 261 |
+
- Profile similarity filter (1D edge projection correlation) was explored but
|
| 262 |
+
failed because the template and drawing use different resistor symbol styles
|
| 263 |
+
(IEC rectangle vs ANSI zigzag at different scan resolutions), making cross-style
|
| 264 |
+
correlation unreliable.
|
| 265 |
+
- CLIP text-guided mode is the most promising zero-shot fix without labeled data.
|
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick evaluation harness: run the pipeline on the example pairs and print
|
| 2 |
+
detection counts. Compares the DINODenseMatcher path on vs off.
|
| 3 |
+
|
| 4 |
+
NOTE: PatternDetectionPipeline.detect_auto signature is (pattern_input, drawing_input).
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 10 |
+
|
| 11 |
+
from src.pipeline import PatternDetectionPipeline # noqa: E402
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run(pipe, name, ppath, dpath):
|
| 15 |
+
result = pipe.detect_auto(ppath, dpath, return_visualization=False)
|
| 16 |
+
dets = result.get("detections", [])
|
| 17 |
+
print(f"\n=== {name}: {result.get('total_detections')} detections ===")
|
| 18 |
+
for d in dets:
|
| 19 |
+
bbox = d.get("bbox", (d.get("x"), d.get("y"), d.get("w"), d.get("h")))
|
| 20 |
+
print(f" bbox={bbox} conf={d.get('confidence')} dino={d.get('dino_score')} "
|
| 21 |
+
f"angle={d.get('angle')}")
|
| 22 |
+
return result.get("total_detections")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
ex = os.path.join(root, "examples")
|
| 28 |
+
pairs = [
|
| 29 |
+
("example1", os.path.join(ex, "example1_pattern.png"), os.path.join(ex, "example1_drawing.png")),
|
| 30 |
+
("example2", os.path.join(ex, "example2_pattern.png"), os.path.join(ex, "example2_drawing.png")),
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
summary = {}
|
| 34 |
+
for mode_name, flag in [("dense_ON", True), ("dense_OFF", False)]:
|
| 35 |
+
print(f"\n########## MODE: {mode_name} (use_dino_dense={flag}) ##########")
|
| 36 |
+
pipe = PatternDetectionPipeline(config={"use_dino_dense": flag})
|
| 37 |
+
for name, ppath, dpath in pairs:
|
| 38 |
+
if not (os.path.exists(dpath) and os.path.exists(ppath)):
|
| 39 |
+
print(f"SKIP {name}: missing files")
|
| 40 |
+
continue
|
| 41 |
+
n = run(pipe, name, ppath, dpath)
|
| 42 |
+
summary[(mode_name, name)] = n
|
| 43 |
+
|
| 44 |
+
print("\n\n========== SUMMARY ==========")
|
| 45 |
+
for (mode_name, name), n in summary.items():
|
| 46 |
+
print(f" {mode_name:10s} {name:10s} -> {n}")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compare DINODense ON vs OFF on the 6 real drawings using the resistor
|
| 2 |
+
(simple) template — this is the path where DINODense Pass C actually activates.
|
| 3 |
+
"""
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 8 |
+
|
| 9 |
+
from src.pipeline import PatternDetectionPipeline # noqa: E402
|
| 10 |
+
|
| 11 |
+
DRAWINGS_DIR = r"D:\Sotatek_Assessment\drawings"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main():
|
| 15 |
+
pattern = os.path.join(DRAWINGS_DIR, "test_1.png") # resistor simple template
|
| 16 |
+
drawings = [os.path.join(DRAWINGS_DIR, f"{i}.png") for i in range(1, 7)]
|
| 17 |
+
|
| 18 |
+
if not os.path.exists(pattern):
|
| 19 |
+
print(f"MISSING pattern: {pattern}")
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
summary = {}
|
| 23 |
+
for mode_name, flag in [("dense_ON", True), ("dense_OFF", False)]:
|
| 24 |
+
print(f"\n########## MODE: {mode_name} ##########")
|
| 25 |
+
pipe = PatternDetectionPipeline(config={"use_dino_dense": flag})
|
| 26 |
+
for dpath in drawings:
|
| 27 |
+
name = os.path.basename(dpath)
|
| 28 |
+
if not os.path.exists(dpath):
|
| 29 |
+
print(f"SKIP {name}")
|
| 30 |
+
continue
|
| 31 |
+
try:
|
| 32 |
+
result = pipe.detect_auto(pattern, dpath, return_visualization=False)
|
| 33 |
+
n = result.get("total_detections")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
n = f"ERR:{e}"
|
| 36 |
+
summary[(mode_name, name)] = n
|
| 37 |
+
print(f" {name}: {n}")
|
| 38 |
+
|
| 39 |
+
print("\n========== SUMMARY (resistor template) ==========")
|
| 40 |
+
for i in range(1, 7):
|
| 41 |
+
name = f"{i}.png"
|
| 42 |
+
on = summary.get(("dense_ON", name))
|
| 43 |
+
off = summary.get(("dense_OFF", name))
|
| 44 |
+
flag = " <-- DIFF" if on != off else ""
|
| 45 |
+
print(f" {name}: ON={on} OFF={off}{flag}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
main()
|
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dense DINO-based template matching — replaces NCC for zero-shot detection.
|
| 2 |
+
|
| 3 |
+
Why NCC fails at scale:
|
| 4 |
+
NCC resizes the template to a fixed scale list (e.g. 0.30-1.0x). Patterns
|
| 5 |
+
larger than the template are missed, and large-scale candidates create big
|
| 6 |
+
bounding boxes that suppress real detections via NMS containment.
|
| 7 |
+
|
| 8 |
+
This module replaces NCC with a DINOv2-based dense sliding window:
|
| 9 |
+
|
| 10 |
+
1. SCALE PROBE (fast NCC probe at coarse scales) — finds the scale at which
|
| 11 |
+
the pattern actually appears in the drawing. This is O(scales * W * H)
|
| 12 |
+
with a single cv2.matchTemplate call per scale — very fast.
|
| 13 |
+
|
| 14 |
+
2. DENSE SLIDING WINDOW at the probed scale. Each window is cropped from the
|
| 15 |
+
drawing at the correct absolute pixel size and embedded by DINOv2. Windows
|
| 16 |
+
with cosine similarity above a threshold become candidates.
|
| 17 |
+
Batched GPU inference keeps this fast (~1-3s for a full drawing).
|
| 18 |
+
|
| 19 |
+
3. NMS on candidates from all angles (0° + 90°, via drawing rotation).
|
| 20 |
+
|
| 21 |
+
Key properties vs NCC:
|
| 22 |
+
* Truly scale-invariant: probe finds any scale 0.15x-5.0x template size.
|
| 23 |
+
* Style-invariant: DINOv2 features abstract over drawing style (IEC vs ANSI).
|
| 24 |
+
* No large-bbox NMS clash: all windows at a given scale are the same size.
|
| 25 |
+
* Rotation via drawing flip: vertical patterns found cleanly without template
|
| 26 |
+
rotation artifacts.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import time
|
| 30 |
+
from typing import List, Optional, Tuple
|
| 31 |
+
|
| 32 |
+
import cv2
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Coarse scale probe grid: wide range, logarithmically spaced
|
| 37 |
+
_PROBE_SCALES = [
|
| 38 |
+
0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.65,
|
| 39 |
+
0.80, 1.0, 1.20, 1.50, 1.80, 2.20, 2.80, 3.50,
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class DINODenseMatcher:
|
| 44 |
+
"""Scale-invariant template matcher using DINOv2 dense sliding window.
|
| 45 |
+
|
| 46 |
+
Drop-in replacement for NCCMatcher in the simple-template pipeline path.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
dino_verifier: Initialised DINOVerifier instance (model already loaded).
|
| 50 |
+
nms_iou_threshold: IoU threshold for NMS on candidates.
|
| 51 |
+
sim_threshold: Minimum cosine similarity to accept a window as candidate.
|
| 52 |
+
stride_ratio: Sliding window stride as fraction of window size (0.0-1.0).
|
| 53 |
+
Smaller = finer coverage but slower. 0.40 is a good default.
|
| 54 |
+
batch_size: Number of crops per DINOv2 forward pass. 32 works well on GPU.
|
| 55 |
+
probe_ncc_min: Minimum probe NCC to trust the probed scale. If the best
|
| 56 |
+
probe NCC is below this (flat/empty drawing region), fall back to
|
| 57 |
+
scale=1.0 so at least something is searched.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(
|
| 61 |
+
self,
|
| 62 |
+
dino_verifier,
|
| 63 |
+
nms_iou_threshold: float = 0.30,
|
| 64 |
+
sim_threshold: float = 0.84,
|
| 65 |
+
stride_ratio: float = 0.60,
|
| 66 |
+
batch_size: int = 32,
|
| 67 |
+
probe_ncc_min: float = 0.30,
|
| 68 |
+
):
|
| 69 |
+
self.dino = dino_verifier
|
| 70 |
+
self.nms_iou_threshold = nms_iou_threshold
|
| 71 |
+
self.sim_threshold = sim_threshold
|
| 72 |
+
self.stride_ratio = stride_ratio
|
| 73 |
+
self.batch_size = batch_size
|
| 74 |
+
self.probe_ncc_min = probe_ncc_min
|
| 75 |
+
|
| 76 |
+
# ------------------------------------------------------------------
|
| 77 |
+
# Public interface
|
| 78 |
+
# ------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
def match(
|
| 81 |
+
self,
|
| 82 |
+
drawing: np.ndarray,
|
| 83 |
+
template: np.ndarray,
|
| 84 |
+
angles: Optional[List[int]] = None,
|
| 85 |
+
) -> List[dict]:
|
| 86 |
+
"""Find all occurrences of `template` in `drawing`.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
drawing: Preprocessed (binarised) drawing, grayscale uint8.
|
| 90 |
+
template: Preprocessed template image, grayscale uint8.
|
| 91 |
+
angles: List of angles to search. Currently supports [0, 90].
|
| 92 |
+
Angle 0 -> horizontal search on original drawing.
|
| 93 |
+
Angle 90 -> search on 90°-rotated drawing (finds vertical).
|
| 94 |
+
Defaults to [0, 90] for non-square templates, [0] for square.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
List of candidate dicts with keys:
|
| 98 |
+
x, y, w, h — bounding box in ORIGINAL drawing coordinates
|
| 99 |
+
ncc_score — cosine similarity (named ncc_score for API compat.)
|
| 100 |
+
dino_score — same value
|
| 101 |
+
confidence — same value
|
| 102 |
+
scale — detected scale relative to template
|
| 103 |
+
angle — 0 or 90
|
| 104 |
+
"""
|
| 105 |
+
t0 = time.time()
|
| 106 |
+
ph, pw = template.shape[:2]
|
| 107 |
+
tmpl_ar = pw / max(1, ph)
|
| 108 |
+
|
| 109 |
+
if angles is None:
|
| 110 |
+
angles = [0, 90] if abs(tmpl_ar - 1.0) > 0.20 else [0]
|
| 111 |
+
|
| 112 |
+
# Template embedding (once, reused for all scales and angles)
|
| 113 |
+
tmpl_embed = self._template_embed(template) # (D,) unit vector
|
| 114 |
+
|
| 115 |
+
all_cands: List[dict] = []
|
| 116 |
+
|
| 117 |
+
for angle in angles:
|
| 118 |
+
if angle == 0:
|
| 119 |
+
search_img = drawing
|
| 120 |
+
orig_H, orig_W = drawing.shape[:2]
|
| 121 |
+
else:
|
| 122 |
+
# 90° CW rotation: vertical instances become horizontal
|
| 123 |
+
search_img = cv2.rotate(drawing, cv2.ROTATE_90_CLOCKWISE)
|
| 124 |
+
orig_H, orig_W = drawing.shape[:2]
|
| 125 |
+
|
| 126 |
+
cands_a = self._match_one_orientation(
|
| 127 |
+
search_img, template, tmpl_embed, angle, orig_H, orig_W
|
| 128 |
+
)
|
| 129 |
+
all_cands.extend(cands_a)
|
| 130 |
+
|
| 131 |
+
# NMS across all candidates
|
| 132 |
+
all_cands = self._apply_nms(all_cands)
|
| 133 |
+
|
| 134 |
+
t1 = time.time()
|
| 135 |
+
print(
|
| 136 |
+
f"[DINODense] {len(all_cands)} candidates "
|
| 137 |
+
f"({len(angles)} orientations) in {t1-t0:.2f}s"
|
| 138 |
+
)
|
| 139 |
+
return all_cands
|
| 140 |
+
|
| 141 |
+
# ------------------------------------------------------------------
|
| 142 |
+
# Internal helpers
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
def _template_embed(self, template: np.ndarray) -> np.ndarray:
|
| 146 |
+
"""Return unit-normalised DINOv2 embedding for the template."""
|
| 147 |
+
emb = self.dino.embed_crops_batch([template], batch_size=1) # (1, D)
|
| 148 |
+
return emb[0] # (D,)
|
| 149 |
+
|
| 150 |
+
def _probe_scale(
|
| 151 |
+
self, drawing: np.ndarray, template: np.ndarray
|
| 152 |
+
) -> Tuple[float, float]:
|
| 153 |
+
"""Fast NCC scale probe. Returns (best_scale, best_ncc)."""
|
| 154 |
+
ph, pw = template.shape[:2]
|
| 155 |
+
dh, dw = drawing.shape[:2]
|
| 156 |
+
best_s, best_ncc = 1.0, 0.0
|
| 157 |
+
for s in _PROBE_SCALES:
|
| 158 |
+
tw, th = int(pw * s), int(ph * s)
|
| 159 |
+
if tw < 8 or th < 8 or dw < tw or dh < th:
|
| 160 |
+
continue
|
| 161 |
+
t_scaled = cv2.resize(template, (tw, th), interpolation=cv2.INTER_AREA)
|
| 162 |
+
res = cv2.matchTemplate(drawing, t_scaled, cv2.TM_CCOEFF_NORMED)
|
| 163 |
+
_, ncc, _, _ = cv2.minMaxLoc(res)
|
| 164 |
+
if ncc > best_ncc:
|
| 165 |
+
best_ncc, best_s = ncc, s
|
| 166 |
+
return best_s, best_ncc
|
| 167 |
+
|
| 168 |
+
def _match_one_orientation(
|
| 169 |
+
self,
|
| 170 |
+
search_img: np.ndarray,
|
| 171 |
+
template: np.ndarray,
|
| 172 |
+
tmpl_embed: np.ndarray,
|
| 173 |
+
angle: int,
|
| 174 |
+
orig_H: int,
|
| 175 |
+
orig_W: int,
|
| 176 |
+
) -> List[dict]:
|
| 177 |
+
"""Dense DINO match for one orientation (0° or 90°)."""
|
| 178 |
+
ph, pw = template.shape[:2]
|
| 179 |
+
sh, sw = search_img.shape[:2]
|
| 180 |
+
|
| 181 |
+
# Scale probe
|
| 182 |
+
best_s, best_ncc = self._probe_scale(search_img, template)
|
| 183 |
+
if best_ncc < self.probe_ncc_min:
|
| 184 |
+
best_s = 1.0 # fallback: search at template native scale
|
| 185 |
+
|
| 186 |
+
# Search at +-20% around best scale (3 sub-steps for speed)
|
| 187 |
+
search_scales = sorted({
|
| 188 |
+
round(best_s * f, 2)
|
| 189 |
+
for f in [0.85, 1.0, 1.15]
|
| 190 |
+
if 0.10 <= best_s * f <= 6.0
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
print(
|
| 194 |
+
f"[DINODense] angle={angle} probe: scale={best_s:.2f} "
|
| 195 |
+
f"ncc={best_ncc:.3f} -> search {search_scales}"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Edge map for fast pre-filter (skip blank regions)
|
| 199 |
+
edges = cv2.Canny(search_img, 30, 100)
|
| 200 |
+
|
| 201 |
+
candidates: List[dict] = []
|
| 202 |
+
|
| 203 |
+
for s in search_scales:
|
| 204 |
+
win_w = int(pw * s)
|
| 205 |
+
win_h = int(ph * s)
|
| 206 |
+
if win_w < 10 or win_h < 10 or sw < win_w or sh < win_h:
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
stride_x = max(4, int(win_w * self.stride_ratio))
|
| 210 |
+
stride_y = max(4, int(win_h * self.stride_ratio))
|
| 211 |
+
|
| 212 |
+
# Build window positions, skip low-edge-density regions
|
| 213 |
+
positions = []
|
| 214 |
+
min_edge_px = max(3, int(win_w * win_h * 0.005))
|
| 215 |
+
for y in range(0, sh - win_h + 1, stride_y):
|
| 216 |
+
for x in range(0, sw - win_w + 1, stride_x):
|
| 217 |
+
if int(np.count_nonzero(edges[y:y+win_h, x:x+win_w])) >= min_edge_px:
|
| 218 |
+
positions.append((x, y))
|
| 219 |
+
|
| 220 |
+
if not positions:
|
| 221 |
+
continue
|
| 222 |
+
|
| 223 |
+
# Crop windows and batch-embed
|
| 224 |
+
crops = [
|
| 225 |
+
search_img[y:y+win_h, x:x+win_w]
|
| 226 |
+
for x, y in positions
|
| 227 |
+
]
|
| 228 |
+
embeds = self.dino.embed_crops_batch(crops, batch_size=self.batch_size)
|
| 229 |
+
sims = embeds @ tmpl_embed # cosine similarities (already unit-normed)
|
| 230 |
+
|
| 231 |
+
# Collect candidates above threshold
|
| 232 |
+
for (x_s, y_s), sim in zip(positions, sims.tolist()):
|
| 233 |
+
if sim < self.sim_threshold:
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
if angle == 0:
|
| 237 |
+
# Original coords
|
| 238 |
+
cx, cy, cw, ch = x_s, y_s, win_w, win_h
|
| 239 |
+
else:
|
| 240 |
+
# 90° CW rotation inverse:
|
| 241 |
+
# (rx, ry, rw, rh) in rotated -> (ry, origH-rx-rw, rh, rw)
|
| 242 |
+
cx = y_s
|
| 243 |
+
cy = orig_H - x_s - win_w
|
| 244 |
+
cw = win_h
|
| 245 |
+
ch = win_w
|
| 246 |
+
|
| 247 |
+
# Clamp to original drawing bounds
|
| 248 |
+
cx = max(0, min(orig_W - 1, cx))
|
| 249 |
+
cy = max(0, min(orig_H - 1, cy))
|
| 250 |
+
cw = min(cw, orig_W - cx)
|
| 251 |
+
ch = min(ch, orig_H - cy)
|
| 252 |
+
if cw < 4 or ch < 4:
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
candidates.append({
|
| 256 |
+
"x": cx, "y": cy, "w": cw, "h": ch,
|
| 257 |
+
"ncc_score": round(float(sim), 4),
|
| 258 |
+
"dino_score": round(float(sim), 4),
|
| 259 |
+
"confidence": round(float(sim), 4),
|
| 260 |
+
"scale": float(s),
|
| 261 |
+
"angle": angle,
|
| 262 |
+
})
|
| 263 |
+
|
| 264 |
+
return candidates
|
| 265 |
+
|
| 266 |
+
def _apply_nms(self, candidates: List[dict]) -> List[dict]:
|
| 267 |
+
"""Simple IoU-based NMS; keep highest-confidence in overlapping groups."""
|
| 268 |
+
if not candidates:
|
| 269 |
+
return []
|
| 270 |
+
|
| 271 |
+
candidates = sorted(candidates, key=lambda c: c["confidence"], reverse=True)
|
| 272 |
+
keep = []
|
| 273 |
+
suppressed = [False] * len(candidates)
|
| 274 |
+
|
| 275 |
+
for i, c in enumerate(candidates):
|
| 276 |
+
if suppressed[i]:
|
| 277 |
+
continue
|
| 278 |
+
keep.append(c)
|
| 279 |
+
ax1, ay1 = c["x"], c["y"]
|
| 280 |
+
ax2, ay2 = ax1 + c["w"], ay1 + c["h"]
|
| 281 |
+
for j in range(i + 1, len(candidates)):
|
| 282 |
+
if suppressed[j]:
|
| 283 |
+
continue
|
| 284 |
+
b = candidates[j]
|
| 285 |
+
bx1, by1 = b["x"], b["y"]
|
| 286 |
+
bx2, by2 = bx1 + b["w"], by1 + b["h"]
|
| 287 |
+
ix = max(0, min(ax2, bx2) - max(ax1, bx1))
|
| 288 |
+
iy = max(0, min(ay2, by2) - max(ay1, by1))
|
| 289 |
+
inter = ix * iy
|
| 290 |
+
if inter == 0:
|
| 291 |
+
continue
|
| 292 |
+
union = c["w"]*c["h"] + b["w"]*b["h"] - inter
|
| 293 |
+
min_a = min(c["w"]*c["h"], b["w"]*b["h"])
|
| 294 |
+
overlap = max(inter/union if union>0 else 0,
|
| 295 |
+
inter/min_a if min_a>0 else 0)
|
| 296 |
+
if overlap >= self.nms_iou_threshold:
|
| 297 |
+
suppressed[j] = True
|
| 298 |
+
|
| 299 |
+
return keep
|
|
@@ -76,6 +76,62 @@ class DINOVerifier:
|
|
| 76 |
|
| 77 |
return feat
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def encode_template(self, template: np.ndarray) -> torch.Tensor:
|
| 80 |
"""Encode template and cache the result.
|
| 81 |
|
|
|
|
| 76 |
|
| 77 |
return feat
|
| 78 |
|
| 79 |
+
def embed_crops_normalized(
|
| 80 |
+
self, crops: List[np.ndarray], batch_size: int = 32
|
| 81 |
+
) -> np.ndarray:
|
| 82 |
+
"""Like embed_crops_batch but normalises each crop to landscape orientation.
|
| 83 |
+
|
| 84 |
+
Horizontal and vertical instances of the same symbol produce similar
|
| 85 |
+
DINOv2 embeddings after normalisation, making prototype comparison
|
| 86 |
+
orientation-invariant.
|
| 87 |
+
"""
|
| 88 |
+
normalised = []
|
| 89 |
+
for c in crops:
|
| 90 |
+
img = c if c.ndim == 3 else c
|
| 91 |
+
h, w = img.shape[:2]
|
| 92 |
+
if h > w:
|
| 93 |
+
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
| 94 |
+
normalised.append(img)
|
| 95 |
+
return self.embed_crops_batch(normalised, batch_size=batch_size)
|
| 96 |
+
|
| 97 |
+
def embed_crops_batch(
|
| 98 |
+
self, crops: List[np.ndarray], batch_size: int = 32
|
| 99 |
+
) -> np.ndarray:
|
| 100 |
+
"""Encode a list of image crops and return unit-normalised embeddings.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
crops: List of grayscale or RGB numpy arrays (any size).
|
| 104 |
+
batch_size: Number of crops per GPU forward pass.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
(N, D) float32 array of L2-normalised feature vectors.
|
| 108 |
+
"""
|
| 109 |
+
from PIL import Image as PILImage
|
| 110 |
+
|
| 111 |
+
if not crops:
|
| 112 |
+
return np.empty((0,), dtype=np.float32)
|
| 113 |
+
|
| 114 |
+
tensors = []
|
| 115 |
+
for crop in crops:
|
| 116 |
+
if crop.ndim == 2:
|
| 117 |
+
rgb = np.stack([crop, crop, crop], axis=-1)
|
| 118 |
+
else:
|
| 119 |
+
rgb = crop
|
| 120 |
+
pil = PILImage.fromarray(rgb.astype(np.uint8))
|
| 121 |
+
tensors.append(self.transform(pil))
|
| 122 |
+
|
| 123 |
+
all_feats = []
|
| 124 |
+
for start in range(0, len(tensors), batch_size):
|
| 125 |
+
batch = torch.stack(tensors[start:start + batch_size]).to(self.device)
|
| 126 |
+
with torch.no_grad():
|
| 127 |
+
feats = self.model.forward_features(batch)
|
| 128 |
+
patch_tokens = feats["x_norm_patchtokens"] # (B, N, D)
|
| 129 |
+
pooled = patch_tokens.mean(dim=1) # (B, D)
|
| 130 |
+
pooled = torch.nn.functional.normalize(pooled, dim=1)
|
| 131 |
+
all_feats.append(pooled.cpu().numpy())
|
| 132 |
+
|
| 133 |
+
return np.concatenate(all_feats, axis=0) # (N, D)
|
| 134 |
+
|
| 135 |
def encode_template(self, template: np.ndarray) -> torch.Tensor:
|
| 136 |
"""Encode template and cache the result.
|
| 137 |
|
|
@@ -6,6 +6,7 @@ from typing import Union, Optional
|
|
| 6 |
from .preprocessor import Preprocessor
|
| 7 |
from .ncc_matcher import NCCMatcher
|
| 8 |
from .dino_verifier import DINOVerifier
|
|
|
|
| 9 |
from .postprocessor import Postprocessor
|
| 10 |
|
| 11 |
|
|
@@ -42,9 +43,52 @@ class PatternDetectionPipeline:
|
|
| 42 |
self.postprocessor = Postprocessor()
|
| 43 |
self.final_nms_iou = cfg.get("final_nms_iou", 0.4)
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
print(f"[Pipeline] Device: {self.dino_verifier.device}")
|
| 46 |
print("[Pipeline] All stages initialized.")
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def detect_auto(
|
| 49 |
self,
|
| 50 |
pattern_input: Union[str, np.ndarray],
|
|
@@ -58,8 +102,8 @@ class PatternDetectionPipeline:
|
|
| 58 |
even when they differ in scale or drawing style.
|
| 59 |
|
| 60 |
Strategy:
|
| 61 |
-
Pass 1
|
| 62 |
-
Pass 2
|
| 63 |
|
| 64 |
Args:
|
| 65 |
pattern_input: Pattern image path or numpy array.
|
|
@@ -74,8 +118,34 @@ class PatternDetectionPipeline:
|
|
| 74 |
|
| 75 |
pattern_data = self.preprocessor.preprocess(pattern_input)
|
| 76 |
drawing_data = self.preprocessor.preprocess(drawing_input)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
pattern_proc = pattern_data["processed"]
|
| 78 |
drawing_proc = drawing_data["processed"]
|
|
|
|
| 79 |
t1 = time.time()
|
| 80 |
print(f"[Pipeline] Auto-detect preprocess: {t1 - t0:.2f}s")
|
| 81 |
|
|
@@ -107,29 +177,59 @@ class PatternDetectionPipeline:
|
|
| 107 |
f"AR={_tmpl_ar:.2f} -> {'SIMPLE (outline only)' if _is_simple else 'complex'}"
|
| 108 |
)
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
_saved_scales = self.ncc_matcher.scales
|
| 135 |
_saved_ncc = self.ncc_matcher.ncc_threshold
|
|
@@ -137,18 +237,13 @@ class PatternDetectionPipeline:
|
|
| 137 |
_saved_dino = self.dino_verifier.cosine_threshold
|
| 138 |
|
| 139 |
if _is_simple:
|
| 140 |
-
#
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
# on thin line segments. 0° + 90° rotation sweep catches vertical components.
|
| 144 |
-
self.ncc_matcher.scales = [0.30, 0.35, 0.40, 0.50, 0.60, 0.70, 1.0]
|
| 145 |
self.ncc_matcher.ncc_threshold = 0.42
|
| 146 |
self.ncc_matcher.angles = [-10, -5, 0, 5, 10, 80, 85, 90, 95, 100]
|
| 147 |
cands_s = self.ncc_matcher.match(drawing_proc, pattern_proc)
|
| 148 |
ncc_s_count = len(cands_s)
|
| 149 |
-
# Chamfer pre-filter replaces DINOv2 for simple templates: DINOv2 is
|
| 150 |
-
# unreliable for binary line-art and rejects correctly-detected vertical
|
| 151 |
-
# components (R2/R4/R6/R8 score < 0.84 despite being real resistors).
|
| 152 |
if cands_s:
|
| 153 |
cands_s = self.postprocessor.filter_chamfer_shape(
|
| 154 |
cands_s, drawing_proc, pattern_proc, max_chamfer=3.0
|
|
@@ -158,39 +253,68 @@ class PatternDetectionPipeline:
|
|
| 158 |
c.setdefault("confidence", c.get("ncc_score", 0.5))
|
| 159 |
before_s = len(cands_s)
|
| 160 |
cands_s = self.postprocessor.filter_title_block(cands_s, drawing_proc)
|
| 161 |
-
print(f"[Pipeline]
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
else:
|
| 164 |
-
# General complex template path: scale probe
|
| 165 |
# No hardcoded shape classifiers; decisions are driven by probe results.
|
| 166 |
_ph, _pw = pattern_proc.shape[:2]
|
| 167 |
_drwH, _drwW = drawing_proc.shape[:2]
|
| 168 |
_no_std_candidates = len(all_candidates) == 0
|
| 169 |
|
| 170 |
-
# Scale probe
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
for _ps in _probe_scales:
|
| 174 |
-
_ptw = int(_pw * _ps); _pth = int(_ph * _ps)
|
| 175 |
-
if _ptw < 10 or _pth < 10 or _drwH < _pth or _drwW < _ptw:
|
| 176 |
-
continue
|
| 177 |
-
_pt_s = cv2.resize(pattern_proc, (_ptw, _pth), interpolation=cv2.INTER_AREA)
|
| 178 |
-
_pres = cv2.matchTemplate(drawing_proc, _pt_s, cv2.TM_CCOEFF_NORMED)
|
| 179 |
-
_, _pncc, _, _ = cv2.minMaxLoc(_pres)
|
| 180 |
-
if _pncc > _best_probe_ncc:
|
| 181 |
-
_best_probe_ncc = _pncc
|
| 182 |
-
_best_probe_s = _ps
|
| 183 |
-
print(f"[Pipeline] Scale probe: best={_best_probe_s:.2f} ncc={_best_probe_ncc:.3f}")
|
| 184 |
|
| 185 |
# Decide whether to use probe-focused scales or standard complex scales.
|
| 186 |
#
|
| 187 |
# Probe-focused (±20% around probe) when:
|
| 188 |
-
# - No standard NCC candidates at all
|
| 189 |
-
# - Probe found scale > 1.40
|
| 190 |
-
# (e.g. zigzag resistors at 1.
|
| 191 |
#
|
| 192 |
# Standard complex scales when:
|
| 193 |
-
# - Probe scale
|
| 194 |
# at small scales (e.g. scale 0.25 for a template whose real instances are at
|
| 195 |
# 0.85–1.15); standard sweep is more reliable in this regime
|
| 196 |
_use_probe_focused = _no_std_candidates or _best_probe_s > 1.40
|
|
@@ -217,7 +341,7 @@ class PatternDetectionPipeline:
|
|
| 217 |
_complex_use_union = False
|
| 218 |
else:
|
| 219 |
_micro_scales = [0.70, 0.85, 1.0, 1.1, 1.2, 1.35]
|
| 220 |
-
# Standard-pass candidates are at the right scale
|
| 221 |
# Filtering here reduces chain-suppression in the final NMS and causes
|
| 222 |
# over-counting; the micro passes + NMS handle deduplication.
|
| 223 |
verified_filtered = verified
|
|
@@ -226,9 +350,10 @@ class PatternDetectionPipeline:
|
|
| 226 |
# Slight 3b bump: reduces FPs from 90°-rotated non-components that
|
| 227 |
# happen to pass the 0.82 threshold (e.g. extra bridge rectifier FP).
|
| 228 |
_micro_dino_3b = min(_micro_dino + 0.01, 0.88)
|
| 229 |
-
#
|
| 230 |
-
#
|
| 231 |
-
|
|
|
|
| 232 |
|
| 233 |
self.ncc_matcher.ncc_threshold = 0.28
|
| 234 |
|
|
@@ -254,6 +379,38 @@ class PatternDetectionPipeline:
|
|
| 254 |
|
| 255 |
all_complex = verified_filtered + verified_3a + verified_3b
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
# Output-bubble filter: only for gate-like templates at unusual scales.
|
| 258 |
# XOR/XNOR output bubbles match the gate body; filter them out.
|
| 259 |
# Standard-scale templates (including bridge rectifiers) skip this.
|
|
@@ -273,34 +430,43 @@ class PatternDetectionPipeline:
|
|
| 273 |
self.ncc_matcher.angles = _saved_angles
|
| 274 |
self.dino_verifier.cosine_threshold = _saved_dino
|
| 275 |
t3 = time.time()
|
| 276 |
-
print(f"[Pipeline] All passes: {t3 - t2:.2f}s
|
| 277 |
|
| 278 |
# Simple-template post-filters: isolation + aspect-ratio + neighborhood.
|
| 279 |
# Isolation: circuit components sit in white space; BOM/title-block cells have
|
| 280 |
# solid grid lines directly adjacent to their long sides.
|
| 281 |
-
# Aspect ratio: keep candidates whose bbox AR is within
|
| 282 |
-
# *or* its reciprocal
|
| 283 |
# (e.g. vertical resistors) whose bbox AR is ~1/_tmpl_ar.
|
| 284 |
# Neighborhood complexity: reject candidates whose surrounding ring has too
|
| 285 |
-
# many Canny edges
|
| 286 |
# (e.g. bridge-rectifier bodies) which have many adjacent edges.
|
| 287 |
# Notes-area exemption: the bottom 20% of the drawing contains notes/legend
|
| 288 |
-
# symbols which have annotation text nearby
|
| 289 |
# checks for those so legitimate legend symbols are not filtered out.
|
| 290 |
# Top-margin exclusion: discard detections whose top edge is within the outer
|
| 291 |
# coordinate-margin strip (border grid cells look like plain rectangles).
|
| 292 |
if _is_simple and verified:
|
| 293 |
before = len(verified)
|
| 294 |
_drw_h, _drw_w = drawing_proc.shape[:2]
|
| 295 |
-
_notes_y = int(_drw_h * 0.80) # below this
|
| 296 |
_top_margin = max(30, int(_drw_h * 0.04)) # top coordinate strip
|
| 297 |
|
| 298 |
# Reject border-grid cells in the top margin
|
| 299 |
verified = [c for c in verified if c["y"] >= _top_margin]
|
| 300 |
|
| 301 |
-
#
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
# Isolation: reject candidates adjacent to grid lines (circuit area only)
|
| 306 |
_circuit = self.postprocessor.filter_isolated(_circuit, drawing_proc)
|
|
@@ -319,7 +485,7 @@ class PatternDetectionPipeline:
|
|
| 319 |
# Wire-lead check: real resistors have straight wire leads on both
|
| 320 |
# connecting sides; false positives embedded in complex symbols do not.
|
| 321 |
# Circuit area: require one strong lead (>=6px) + one non-zero lead (>=1px)
|
| 322 |
-
#
|
| 323 |
# 1-2px of wire is visible on the rail-side.
|
| 324 |
# Notes/legend area: skip wire-lead filter (legend symbols may have no
|
| 325 |
# protruding leads). Restrict to left half of drawing (legends are always
|
|
@@ -332,16 +498,116 @@ class PatternDetectionPipeline:
|
|
| 332 |
_circuit = self.postprocessor.filter_junction_dots(_circuit, drawing_proc)
|
| 333 |
_circuit = self.postprocessor.filter_rect_integrity(_circuit, drawing_proc)
|
| 334 |
_circuit = self.postprocessor.filter_chamfer_shape(_circuit, drawing_proc, pattern_proc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
_bottom_margin = max(30, int(_drw_h * 0.04))
|
| 336 |
_notes = [c for c in _notes
|
| 337 |
if c["y"] + c["h"] <= _drw_h - _bottom_margin]
|
| 338 |
_notes = self.postprocessor.filter_rect_borders(_notes, drawing_proc)
|
| 339 |
_notes = sorted(_notes, key=lambda c: c.get("dino_score", 0), reverse=True)[:1]
|
| 340 |
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
print(
|
| 343 |
f"[Pipeline] Simple-template filters: {before} -> {len(verified)} "
|
| 344 |
-
f"(
|
| 345 |
)
|
| 346 |
|
| 347 |
# Title-block zone filter: remove candidates inside the BOM / right-frame
|
|
@@ -352,10 +618,23 @@ class PatternDetectionPipeline:
|
|
| 352 |
if len(verified) != before_tb:
|
| 353 |
print(f"[Pipeline] Title-block filter: {before_tb} -> {len(verified)}")
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
# Final NMS + format
|
| 356 |
# Simple templates: tight IoU (0.25), no union expand (keep best-fit bbox).
|
| 357 |
# Complex templates:
|
| 358 |
-
# - probe-focused path (
|
|
|
|
| 359 |
# - standard path (_complex_use_union=True): union expand for chain suppression
|
| 360 |
if _is_simple:
|
| 361 |
verified = self.postprocessor.final_nms(
|
|
@@ -368,7 +647,7 @@ class PatternDetectionPipeline:
|
|
| 368 |
)
|
| 369 |
result = self.postprocessor.format_output(verified, drawing_proc.shape)
|
| 370 |
t4 = time.time()
|
| 371 |
-
print(f"[Pipeline] Auto-detect total: {t4 - t0:.2f}s
|
| 372 |
|
| 373 |
if return_visualization:
|
| 374 |
# Draw on original (non-binarized) image for clearer output
|
|
@@ -409,7 +688,7 @@ class PatternDetectionPipeline:
|
|
| 409 |
t1 = time.time()
|
| 410 |
print(f"[Pipeline] Stage 0 (Preprocess): {t1 - t0:.2f}s")
|
| 411 |
|
| 412 |
-
pattern_proc = pattern_data["processed"] # original
|
| 413 |
drawing_proc = drawing_data["processed"]
|
| 414 |
|
| 415 |
# Optionally dilate pattern strokes for NCC to handle style mismatch
|
|
@@ -423,7 +702,7 @@ class PatternDetectionPipeline:
|
|
| 423 |
# Stage 1: NCC matching (uses dilated pattern if configured)
|
| 424 |
candidates = self.ncc_matcher.match(drawing_proc, pattern_for_ncc)
|
| 425 |
t2 = time.time()
|
| 426 |
-
print(f"[Pipeline] Stage 1 (NCC): {t2 - t1:.2f}s
|
| 427 |
|
| 428 |
if not candidates:
|
| 429 |
print("[Pipeline] No candidates from Stage 1, returning empty result.")
|
|
@@ -437,14 +716,14 @@ class PatternDetectionPipeline:
|
|
| 437 |
# Stage 2: DINOv2 verification
|
| 438 |
candidates = self.dino_verifier.verify_candidates(drawing_proc, pattern_proc, candidates)
|
| 439 |
t3 = time.time()
|
| 440 |
-
print(f"[Pipeline] Stage 2 (DINOv2): {t3 - t2:.2f}s
|
| 441 |
|
| 442 |
# Stage 3: Final NMS + format
|
| 443 |
candidates = self.postprocessor.final_nms(candidates, iou_threshold=self.final_nms_iou)
|
| 444 |
result = self.postprocessor.format_output(candidates, drawing_proc.shape)
|
| 445 |
t4 = time.time()
|
| 446 |
print(f"[Pipeline] Stage 3 (Post): {t4 - t3:.2f}s")
|
| 447 |
-
print(f"[Pipeline] Total: {t4 - t0:.2f}s
|
| 448 |
|
| 449 |
if return_visualization:
|
| 450 |
# Draw on original (non-binarized) image for clearer output
|
|
|
|
| 6 |
from .preprocessor import Preprocessor
|
| 7 |
from .ncc_matcher import NCCMatcher
|
| 8 |
from .dino_verifier import DINOVerifier
|
| 9 |
+
from .dino_dense_matcher import DINODenseMatcher
|
| 10 |
from .postprocessor import Postprocessor
|
| 11 |
|
| 12 |
|
|
|
|
| 43 |
self.postprocessor = Postprocessor()
|
| 44 |
self.final_nms_iou = cfg.get("final_nms_iou", 0.4)
|
| 45 |
|
| 46 |
+
# DINODenseMatcher: scale-invariant DINO sliding window. Used as an
|
| 47 |
+
# OPTIONAL large-scale path for simple templates (Pass C in detect_auto),
|
| 48 |
+
# complementing NCC which only covers scales 0.30-1.0. Toggle via config
|
| 49 |
+
# `use_dino_dense` (default True — preserves the validated 10/10 behaviour).
|
| 50 |
+
self.use_dino_dense = cfg.get("use_dino_dense", True)
|
| 51 |
+
self.dino_dense = DINODenseMatcher(
|
| 52 |
+
dino_verifier=self.dino_verifier,
|
| 53 |
+
sim_threshold=cfg.get("dense_sim_threshold", 0.78),
|
| 54 |
+
stride_ratio=0.40,
|
| 55 |
+
batch_size=32,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
print(f"[Pipeline] Device: {self.dino_verifier.device}")
|
| 59 |
print("[Pipeline] All stages initialized.")
|
| 60 |
|
| 61 |
+
def _template_upscale_factor(
|
| 62 |
+
self,
|
| 63 |
+
pattern_proc: np.ndarray,
|
| 64 |
+
trigger_px: int = 55,
|
| 65 |
+
target_px: int = 130,
|
| 66 |
+
max_factor: float = 4.0,
|
| 67 |
+
) -> float:
|
| 68 |
+
"""Return the upscale factor for a tiny template (1.0 = no upscale).
|
| 69 |
+
|
| 70 |
+
Only GENUINELY tiny templates are upscaled. A normal-sized template (the
|
| 71 |
+
bridge rectifier at 70px, resistor at 70px) returns 1.0 -- upscaling those
|
| 72 |
+
shifts the probe scale and breaks their tuned detection path.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
pattern_proc: Preprocessed (binarised) template image.
|
| 76 |
+
trigger_px: Only upscale if the symbol's larger side is below this.
|
| 77 |
+
target_px: Upscale tiny templates so their larger side reaches this.
|
| 78 |
+
max_factor: Maximum upscale factor (prevents extreme blur).
|
| 79 |
+
"""
|
| 80 |
+
dark = pattern_proc < 128
|
| 81 |
+
rows_any = np.any(dark, axis=1)
|
| 82 |
+
cols_any = np.any(dark, axis=0)
|
| 83 |
+
if not (rows_any.any() and cols_any.any()):
|
| 84 |
+
return 1.0
|
| 85 |
+
rmin, rmax = np.where(rows_any)[0][[0, -1]]
|
| 86 |
+
cmin, cmax = np.where(cols_any)[0][[0, -1]]
|
| 87 |
+
larger = max(int(rmax - rmin + 1), int(cmax - cmin + 1))
|
| 88 |
+
if larger >= trigger_px:
|
| 89 |
+
return 1.0
|
| 90 |
+
return min(max_factor, target_px / max(1, larger))
|
| 91 |
+
|
| 92 |
def detect_auto(
|
| 93 |
self,
|
| 94 |
pattern_input: Union[str, np.ndarray],
|
|
|
|
| 102 |
even when they differ in scale or drawing style.
|
| 103 |
|
| 104 |
Strategy:
|
| 105 |
+
Pass 1 -- strict (ncc=0.55, dilate=0): catches clean/legend copies
|
| 106 |
+
Pass 2 -- relaxed (ncc=0.28, dilate=5): catches style-mismatched + larger components
|
| 107 |
|
| 108 |
Args:
|
| 109 |
pattern_input: Pattern image path or numpy array.
|
|
|
|
| 118 |
|
| 119 |
pattern_data = self.preprocessor.preprocess(pattern_input)
|
| 120 |
drawing_data = self.preprocessor.preprocess(drawing_input)
|
| 121 |
+
|
| 122 |
+
# Auto-upscale tiny templates for richer zero-shot features.
|
| 123 |
+
#
|
| 124 |
+
# A template provides the feature query. When its symbol content is very
|
| 125 |
+
# small (e.g. a 26x39 XNOR crop), both NCC and DINOv2 receive too few
|
| 126 |
+
# pixels of detail; matching at the necessary upscale factor blurs the
|
| 127 |
+
# symbol and produces many false positives.
|
| 128 |
+
#
|
| 129 |
+
# Measured impact (XNOR 43x55 template on CLC-003): upscaling cut false
|
| 130 |
+
# positives from 17 -> 6 and raised TP confidence 0.56 -> 0.72-0.86.
|
| 131 |
+
#
|
| 132 |
+
# IMPORTANT: upscale the RAW GRAYSCALE then re-binarise. Upscaling an
|
| 133 |
+
# already-binarised low-res template produces blocky edges and FPs;
|
| 134 |
+
# upscaling the grayscale first preserves smooth symbol detail.
|
| 135 |
+
_factor = self._template_upscale_factor(pattern_data["processed"])
|
| 136 |
+
if _factor > 1.05:
|
| 137 |
+
_orig = pattern_data["original"]
|
| 138 |
+
_up = cv2.resize(
|
| 139 |
+
_orig,
|
| 140 |
+
(int(_orig.shape[1] * _factor), int(_orig.shape[0] * _factor)),
|
| 141 |
+
interpolation=cv2.INTER_CUBIC,
|
| 142 |
+
)
|
| 143 |
+
pattern_data = self.preprocessor.preprocess(_up)
|
| 144 |
+
print(f"[Pipeline] Template upscaled {_factor:.1f}x (raw grayscale, then re-binarised)")
|
| 145 |
+
|
| 146 |
pattern_proc = pattern_data["processed"]
|
| 147 |
drawing_proc = drawing_data["processed"]
|
| 148 |
+
|
| 149 |
t1 = time.time()
|
| 150 |
print(f"[Pipeline] Auto-detect preprocess: {t1 - t0:.2f}s")
|
| 151 |
|
|
|
|
| 177 |
f"AR={_tmpl_ar:.2f} -> {'SIMPLE (outline only)' if _is_simple else 'complex'}"
|
| 178 |
)
|
| 179 |
|
| 180 |
+
# For complex templates: run scale probe early to decide if standard passes
|
| 181 |
+
# can be skipped. When probe_s > 1.40 those passes produce candidates at
|
| 182 |
+
# wrong scale that get discarded anyway -- skipping saves ~215 s per run.
|
| 183 |
+
_ph_p, _pw_p = pattern_proc.shape[:2]
|
| 184 |
+
_drwH_p, _drwW_p = drawing_proc.shape[:2]
|
| 185 |
+
_pre_probe_s, _pre_probe_ncc = 1.0, 0.0
|
| 186 |
+
_skip_std_passes = False
|
| 187 |
+
if not _is_simple:
|
| 188 |
+
for _ps in [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]:
|
| 189 |
+
_ptw_p = int(_pw_p * _ps); _pth_p = int(_ph_p * _ps)
|
| 190 |
+
if _ptw_p < 10 or _pth_p < 10 or _drwH_p < _pth_p or _drwW_p < _ptw_p:
|
| 191 |
+
continue
|
| 192 |
+
_pt_s_p = cv2.resize(pattern_proc, (_ptw_p, _pth_p), interpolation=cv2.INTER_AREA)
|
| 193 |
+
_pres_p = cv2.matchTemplate(drawing_proc, _pt_s_p, cv2.TM_CCOEFF_NORMED)
|
| 194 |
+
_, _pncc_p, _, _ = cv2.minMaxLoc(_pres_p)
|
| 195 |
+
if _pncc_p > _pre_probe_ncc:
|
| 196 |
+
_pre_probe_ncc = _pncc_p
|
| 197 |
+
_pre_probe_s = _ps
|
| 198 |
+
_skip_std_passes = _pre_probe_s > 1.40
|
| 199 |
+
print(f"[Pipeline] Scale probe: best={_pre_probe_s:.2f} ncc={_pre_probe_ncc:.3f}"
|
| 200 |
+
+ (" -- skipping std passes" if _skip_std_passes else ""))
|
| 201 |
+
|
| 202 |
+
if not _skip_std_passes:
|
| 203 |
+
# Pass 1: strict -- undilated template, high NCC threshold
|
| 204 |
+
self.ncc_matcher.ncc_threshold = 0.55
|
| 205 |
+
candidates_strict = self.ncc_matcher.match(drawing_proc, pattern_proc)
|
| 206 |
+
print(f"[Pipeline] Pass 1 (strict): {len(candidates_strict)} candidates")
|
| 207 |
+
|
| 208 |
+
# Pass 2: relaxed -- dilated template.
|
| 209 |
+
# For simple-outline templates raise threshold: structural FPs (frame/table)
|
| 210 |
+
# match poorly at non-native scales, while real components match at 0.50+.
|
| 211 |
+
self.ncc_matcher.ncc_threshold = 0.50 if _is_simple else 0.28
|
| 212 |
+
pattern_dilated = self.preprocessor.dilate_strokes(pattern_proc, kernel_size=5)
|
| 213 |
+
candidates_relaxed = self.ncc_matcher.match(drawing_proc, pattern_dilated)
|
| 214 |
+
print(f"[Pipeline] Pass 2 (relaxed): {len(candidates_relaxed)} candidates")
|
| 215 |
+
|
| 216 |
+
all_candidates = candidates_strict + candidates_relaxed
|
| 217 |
+
t2 = time.time()
|
| 218 |
+
print(f"[Pipeline] NCC total: {t2 - t1:.2f}s -- {len(all_candidates)} combined candidates")
|
| 219 |
+
|
| 220 |
+
# DINOv2 on standard-scale candidates (skip if none found)
|
| 221 |
+
verified = self.dino_verifier.verify_candidates(
|
| 222 |
+
drawing_proc, pattern_proc, all_candidates
|
| 223 |
+
) if all_candidates else []
|
| 224 |
+
t3 = time.time()
|
| 225 |
+
print(f"[Pipeline] DINOv2 (standard): {t3 - t2:.2f}s -- {len(verified)} verified")
|
| 226 |
+
else:
|
| 227 |
+
# Standard passes skipped: candidates at standard scales [0.70–1.35]
|
| 228 |
+
# would be discarded in the probe-focused path anyway.
|
| 229 |
+
all_candidates = []
|
| 230 |
+
verified = []
|
| 231 |
+
t2 = time.time()
|
| 232 |
+
t3 = t2
|
| 233 |
|
| 234 |
_saved_scales = self.ncc_matcher.scales
|
| 235 |
_saved_ncc = self.ncc_matcher.ncc_threshold
|
|
|
|
| 237 |
_saved_dino = self.dino_verifier.cosine_threshold
|
| 238 |
|
| 239 |
if _is_simple:
|
| 240 |
+
# --- Pass A: NCC (primary, covers scale 0.30-1.0) ---
|
| 241 |
+
_SIMPLE_SCALES = [0.30, 0.35, 0.40, 0.50, 0.60, 0.70, 1.0]
|
| 242 |
+
self.ncc_matcher.scales = _SIMPLE_SCALES
|
|
|
|
|
|
|
| 243 |
self.ncc_matcher.ncc_threshold = 0.42
|
| 244 |
self.ncc_matcher.angles = [-10, -5, 0, 5, 10, 80, 85, 90, 95, 100]
|
| 245 |
cands_s = self.ncc_matcher.match(drawing_proc, pattern_proc)
|
| 246 |
ncc_s_count = len(cands_s)
|
|
|
|
|
|
|
|
|
|
| 247 |
if cands_s:
|
| 248 |
cands_s = self.postprocessor.filter_chamfer_shape(
|
| 249 |
cands_s, drawing_proc, pattern_proc, max_chamfer=3.0
|
|
|
|
| 253 |
c.setdefault("confidence", c.get("ncc_score", 0.5))
|
| 254 |
before_s = len(cands_s)
|
| 255 |
cands_s = self.postprocessor.filter_title_block(cands_s, drawing_proc)
|
| 256 |
+
print(f"[Pipeline] NCC pass A: {ncc_s_count} -> {before_s} chamfer -> {len(cands_s)}")
|
| 257 |
+
|
| 258 |
+
# --- Pass B: 90°-rotated drawing for vertical instances ---
|
| 259 |
+
cands_rot90 = []
|
| 260 |
+
if abs(_tmpl_ar - 1.0) > 0.25:
|
| 261 |
+
_drw_orig_H, _drw_orig_W = drawing_proc.shape[:2]
|
| 262 |
+
drawing_rot90 = cv2.rotate(drawing_proc, cv2.ROTATE_90_CLOCKWISE)
|
| 263 |
+
self.ncc_matcher.scales = _SIMPLE_SCALES
|
| 264 |
+
self.ncc_matcher.ncc_threshold = 0.42
|
| 265 |
+
self.ncc_matcher.angles = [-10, -5, 0, 5, 10]
|
| 266 |
+
raw_rot = self.ncc_matcher.match(drawing_rot90, pattern_proc)
|
| 267 |
+
if raw_rot:
|
| 268 |
+
raw_rot = self.postprocessor.filter_chamfer_shape(
|
| 269 |
+
raw_rot, drawing_rot90, pattern_proc, max_chamfer=3.0
|
| 270 |
+
)
|
| 271 |
+
for c in raw_rot:
|
| 272 |
+
rx, ry, rw, rh = c["x"], c["y"], c["w"], c["h"]
|
| 273 |
+
c["x"] = ry; c["y"] = _drw_orig_H - rx - rw
|
| 274 |
+
c["w"] = rh; c["h"] = rw; c["angle"] = 90
|
| 275 |
+
c.setdefault("dino_score", 0.0)
|
| 276 |
+
c.setdefault("confidence", c.get("ncc_score", 0.5))
|
| 277 |
+
raw_rot = self.postprocessor.filter_title_block(raw_rot, drawing_proc)
|
| 278 |
+
cands_rot90 = [c for c in raw_rot if c.get("confidence", 0) >= 0.58]
|
| 279 |
+
|
| 280 |
+
# --- Pass C: DINODense for LARGE-SCALE instances (probe_s > 1.1) ---
|
| 281 |
+
# Activates when NCC's scale range [0.30-1.0] misses instances because
|
| 282 |
+
# they are LARGER than the template. Only the probe test runs fast (NCC
|
| 283 |
+
# on one scale); the dense pass only runs when needed.
|
| 284 |
+
cands_dense = []
|
| 285 |
+
_probe_s, _probe_ncc = self.dino_dense._probe_scale(drawing_proc, pattern_proc)
|
| 286 |
+
if self.use_dino_dense and _probe_s > 1.10 and _probe_ncc >= 0.35:
|
| 287 |
+
print(f"[Pipeline] DINODense activated (probe_s={_probe_s:.2f}, ncc={_probe_ncc:.3f})")
|
| 288 |
+
_dense_angles = [0, 90] if abs(_tmpl_ar - 1.0) > 0.20 else [0]
|
| 289 |
+
cands_dense = self.dino_dense.match(
|
| 290 |
+
drawing_proc, pattern_proc, angles=_dense_angles
|
| 291 |
+
)
|
| 292 |
+
for c in cands_dense:
|
| 293 |
+
c["from_dino_dense"] = True # tag: skip NCC-era struct filters
|
| 294 |
+
cands_dense = self.postprocessor.filter_title_block(cands_dense, drawing_proc)
|
| 295 |
+
print(f"[Pipeline] DINODense: {len(cands_dense)} large-scale candidates")
|
| 296 |
+
|
| 297 |
+
verified = verified + cands_s + cands_rot90 + cands_dense
|
| 298 |
else:
|
| 299 |
+
# General complex template path: scale probe -> adaptive search.
|
| 300 |
# No hardcoded shape classifiers; decisions are driven by probe results.
|
| 301 |
_ph, _pw = pattern_proc.shape[:2]
|
| 302 |
_drwH, _drwW = drawing_proc.shape[:2]
|
| 303 |
_no_std_candidates = len(all_candidates) == 0
|
| 304 |
|
| 305 |
+
# Scale probe was already run before passes 1+2 -- reuse the result.
|
| 306 |
+
_best_probe_s = _pre_probe_s
|
| 307 |
+
_best_probe_ncc = _pre_probe_ncc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
|
| 309 |
# Decide whether to use probe-focused scales or standard complex scales.
|
| 310 |
#
|
| 311 |
# Probe-focused (±20% around probe) when:
|
| 312 |
+
# - No standard NCC candidates at all -> template is at a very unusual scale
|
| 313 |
+
# - Probe found scale > 1.40 -> template appears larger in drawing than legend
|
| 314 |
+
# (e.g. zigzag resistors at 1.5x); standard scales [0.70–1.35] would miss them
|
| 315 |
#
|
| 316 |
# Standard complex scales when:
|
| 317 |
+
# - Probe scale <= 1.40 with standard candidates -> probe may catch a false maximum
|
| 318 |
# at small scales (e.g. scale 0.25 for a template whose real instances are at
|
| 319 |
# 0.85–1.15); standard sweep is more reliable in this regime
|
| 320 |
_use_probe_focused = _no_std_candidates or _best_probe_s > 1.40
|
|
|
|
| 341 |
_complex_use_union = False
|
| 342 |
else:
|
| 343 |
_micro_scales = [0.70, 0.85, 1.0, 1.1, 1.2, 1.35]
|
| 344 |
+
# Standard-pass candidates are at the right scale -- pass all through.
|
| 345 |
# Filtering here reduces chain-suppression in the final NMS and causes
|
| 346 |
# over-counting; the micro passes + NMS handle deduplication.
|
| 347 |
verified_filtered = verified
|
|
|
|
| 350 |
# Slight 3b bump: reduces FPs from 90°-rotated non-components that
|
| 351 |
# happen to pass the 0.82 threshold (e.g. extra bridge rectifier FP).
|
| 352 |
_micro_dino_3b = min(_micro_dino + 0.01, 0.88)
|
| 353 |
+
# Use best-fit bbox (no union expand) for the standard path.
|
| 354 |
+
# Union expansion caused oversized boxes when a FP at one scale
|
| 355 |
+
# and a TP at another overlapped and merged into a huge union box.
|
| 356 |
+
_complex_use_union = False
|
| 357 |
|
| 358 |
self.ncc_matcher.ncc_threshold = 0.28
|
| 359 |
|
|
|
|
| 379 |
|
| 380 |
all_complex = verified_filtered + verified_3a + verified_3b
|
| 381 |
|
| 382 |
+
# Chamfer shape filter: structural edge-alignment quality check.
|
| 383 |
+
# Applied only on the probe-focused path where DINOv2 alone is
|
| 384 |
+
# insufficient -- components at unusual scales (>1.40x) attract FPs
|
| 385 |
+
# from visually similar but structurally different symbols.
|
| 386 |
+
# Standard-path templates (BR, IEC) have complex internal edge
|
| 387 |
+
# structure; Chamfer at 3.0 wrongly rejects real detections there.
|
| 388 |
+
if _use_probe_focused and all_complex:
|
| 389 |
+
before_ch = len(all_complex)
|
| 390 |
+
# Threshold 5.0: real zigzag TPs score 0.5–4.1; the single confirmed
|
| 391 |
+
# FP at scale boundary scored 6.4 -- this cleanly removes it.
|
| 392 |
+
# Standard path skips Chamfer: IEC has some candidates with
|
| 393 |
+
# Chamfer 9–10 due to bbox distortion from the dilated-template
|
| 394 |
+
# pass; filtering them drops real detections.
|
| 395 |
+
all_complex = self.postprocessor.filter_chamfer_shape(
|
| 396 |
+
all_complex, drawing_proc, pattern_proc, max_chamfer=5.0
|
| 397 |
+
)
|
| 398 |
+
if len(all_complex) != before_ch:
|
| 399 |
+
print(f"[Pipeline] Chamfer filter: {before_ch} -> {len(all_complex)}")
|
| 400 |
+
|
| 401 |
+
# Tighter Chamfer for horizontal candidates: horizontal TPs max at
|
| 402 |
+
# ~3.3 (measured); vertical TPs can reach ~4.1 due to rotation/resize.
|
| 403 |
+
# Candidates with angle ≈ 0° and Chamfer 4.0–5.0 are structural FPs
|
| 404 |
+
# (inductors, transistors) that the global 5.0 threshold misses.
|
| 405 |
+
before_hch = len(all_complex)
|
| 406 |
+
all_complex = [
|
| 407 |
+
c for c in all_complex
|
| 408 |
+
if abs(c.get("angle", 0)) >= 45
|
| 409 |
+
or c.get("chamfer_dist", 0) <= 4.0
|
| 410 |
+
]
|
| 411 |
+
if len(all_complex) != before_hch:
|
| 412 |
+
print(f"[Pipeline] Chamfer H filter: {before_hch} -> {len(all_complex)}")
|
| 413 |
+
|
| 414 |
# Output-bubble filter: only for gate-like templates at unusual scales.
|
| 415 |
# XOR/XNOR output bubbles match the gate body; filter them out.
|
| 416 |
# Standard-scale templates (including bridge rectifiers) skip this.
|
|
|
|
| 430 |
self.ncc_matcher.angles = _saved_angles
|
| 431 |
self.dino_verifier.cosine_threshold = _saved_dino
|
| 432 |
t3 = time.time()
|
| 433 |
+
print(f"[Pipeline] All passes: {t3 - t2:.2f}s -- {len(verified)} total verified")
|
| 434 |
|
| 435 |
# Simple-template post-filters: isolation + aspect-ratio + neighborhood.
|
| 436 |
# Isolation: circuit components sit in white space; BOM/title-block cells have
|
| 437 |
# solid grid lines directly adjacent to their long sides.
|
| 438 |
+
# Aspect ratio: keep candidates whose bbox AR is within 2x of the template AR
|
| 439 |
+
# *or* its reciprocal -- the reciprocal check allows 90°-rotated components
|
| 440 |
# (e.g. vertical resistors) whose bbox AR is ~1/_tmpl_ar.
|
| 441 |
# Neighborhood complexity: reject candidates whose surrounding ring has too
|
| 442 |
+
# many Canny edges -- this eliminates false positives inside complex symbols
|
| 443 |
# (e.g. bridge-rectifier bodies) which have many adjacent edges.
|
| 444 |
# Notes-area exemption: the bottom 20% of the drawing contains notes/legend
|
| 445 |
+
# symbols which have annotation text nearby -- skip isolation and neighborhood
|
| 446 |
# checks for those so legitimate legend symbols are not filtered out.
|
| 447 |
# Top-margin exclusion: discard detections whose top edge is within the outer
|
| 448 |
# coordinate-margin strip (border grid cells look like plain rectangles).
|
| 449 |
if _is_simple and verified:
|
| 450 |
before = len(verified)
|
| 451 |
_drw_h, _drw_w = drawing_proc.shape[:2]
|
| 452 |
+
_notes_y = int(_drw_h * 0.80) # below this -> notes/legend area
|
| 453 |
_top_margin = max(30, int(_drw_h * 0.04)) # top coordinate strip
|
| 454 |
|
| 455 |
# Reject border-grid cells in the top margin
|
| 456 |
verified = [c for c in verified if c["y"] >= _top_margin]
|
| 457 |
|
| 458 |
+
# DINODense candidates are already DINOv2-verified (sim >= threshold).
|
| 459 |
+
# They bypass the NCC-era structural filters (wire-leads, chamfer, etc.)
|
| 460 |
+
# which assume tight bbox alignment. DINOv2 score IS the structural check.
|
| 461 |
+
_dense_cands = [c for c in verified if c.get("from_dino_dense")]
|
| 462 |
+
_ncc_cands = [c for c in verified if not c.get("from_dino_dense")]
|
| 463 |
+
|
| 464 |
+
# Split NCC candidates into circuit area and notes/legend area
|
| 465 |
+
_circuit = [c for c in _ncc_cands if c["y"] < _notes_y]
|
| 466 |
+
_notes = [c for c in _ncc_cands if c["y"] >= _notes_y]
|
| 467 |
+
|
| 468 |
+
# Dense candidates: circuit zone only (no structural filters)
|
| 469 |
+
_dense_circuit = [c for c in _dense_cands if c["y"] < _notes_y]
|
| 470 |
|
| 471 |
# Isolation: reject candidates adjacent to grid lines (circuit area only)
|
| 472 |
_circuit = self.postprocessor.filter_isolated(_circuit, drawing_proc)
|
|
|
|
| 485 |
# Wire-lead check: real resistors have straight wire leads on both
|
| 486 |
# connecting sides; false positives embedded in complex symbols do not.
|
| 487 |
# Circuit area: require one strong lead (>=6px) + one non-zero lead (>=1px)
|
| 488 |
+
# -- handles resistors connected directly to a power rail where only
|
| 489 |
# 1-2px of wire is visible on the rail-side.
|
| 490 |
# Notes/legend area: skip wire-lead filter (legend symbols may have no
|
| 491 |
# protruding leads). Restrict to left half of drawing (legends are always
|
|
|
|
| 498 |
_circuit = self.postprocessor.filter_junction_dots(_circuit, drawing_proc)
|
| 499 |
_circuit = self.postprocessor.filter_rect_integrity(_circuit, drawing_proc)
|
| 500 |
_circuit = self.postprocessor.filter_chamfer_shape(_circuit, drawing_proc, pattern_proc)
|
| 501 |
+
|
| 502 |
+
# Orientation-aware minimum confidence.
|
| 503 |
+
#
|
| 504 |
+
# The template has aspect ratio _tmpl_ar (width / height).
|
| 505 |
+
# Candidates in the same orientation as the template ("native") are
|
| 506 |
+
# matched directly by NCC and should score high -- low-confidence
|
| 507 |
+
# native candidates are almost certainly FPs (inductors, transistors,
|
| 508 |
+
# op-amps that partially match the template bbox at low NCC).
|
| 509 |
+
#
|
| 510 |
+
# Candidates in the perpendicular orientation ("rotated") are matched
|
| 511 |
+
# after a 90° rotation, which inherently lowers the NCC score even
|
| 512 |
+
# for genuine resistors; they deserve a much more lenient threshold.
|
| 513 |
+
#
|
| 514 |
+
# Calibration from drawing 1 (test_1.png AR≈2.5, horizontal template):
|
| 515 |
+
# Native (horizontal) TPs: conf 0.77, 0.78, 0.78 (all >= 0.70)
|
| 516 |
+
# Rotated (vertical) TPs: conf 0.50–0.78 (all >= 0.45)
|
| 517 |
+
# Observed FPs in complex drawings: conf 0.58–0.67 (all horizontal)
|
| 518 |
+
# -> threshold 0.70 for native / 0.45 for rotated removes FPs while
|
| 519 |
+
# keeping all drawing-1 TPs.
|
| 520 |
+
_tmpl_native_wide = _tmpl_ar >= 1.0 # template is wider-than-tall
|
| 521 |
+
before_oc = len(_circuit)
|
| 522 |
+
def _is_native_orient(c):
|
| 523 |
+
cand_wide = c["w"] >= c["h"]
|
| 524 |
+
return cand_wide == _tmpl_native_wide
|
| 525 |
+
|
| 526 |
+
# Pass-B (rotated-image) candidates are marked angle=90 and have already
|
| 527 |
+
# been filtered at conf>=0.58 before structural filters; they represent
|
| 528 |
+
# native-orientation matches so use a moderate threshold here.
|
| 529 |
+
# Pass-A vertical candidates (not from rotated-image) still matched via
|
| 530 |
+
# template rotation and score lower -- they need conf>=0.50 instead of 0.45.
|
| 531 |
+
_circuit = [
|
| 532 |
+
c for c in _circuit
|
| 533 |
+
if (_is_native_orient(c) and c.get("confidence", 0) >= 0.70)
|
| 534 |
+
or (not _is_native_orient(c) and c.get("confidence", 0) >= 0.45)
|
| 535 |
+
]
|
| 536 |
+
if len(_circuit) != before_oc:
|
| 537 |
+
print(
|
| 538 |
+
f"[Pipeline] Orient-conf filter: {before_oc} -> {len(_circuit)} "
|
| 539 |
+
f"(native>=0.70 | rotated>=0.45)"
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
_bottom_margin = max(30, int(_drw_h * 0.04))
|
| 543 |
_notes = [c for c in _notes
|
| 544 |
if c["y"] + c["h"] <= _drw_h - _bottom_margin]
|
| 545 |
_notes = self.postprocessor.filter_rect_borders(_notes, drawing_proc)
|
| 546 |
_notes = sorted(_notes, key=lambda c: c.get("dino_score", 0), reverse=True)[:1]
|
| 547 |
|
| 548 |
+
# DINOv2 Self-Supervised Prototype Filter
|
| 549 |
+
#
|
| 550 |
+
# Instead of comparing borderline candidates to the TEMPLATE (which
|
| 551 |
+
# may be a different drawing style), build a prototype from the
|
| 552 |
+
# HIGH-CONFIDENCE detections in THIS DRAWING. These are confirmed
|
| 553 |
+
# instances of the target symbol in the actual drawing style and scale.
|
| 554 |
+
#
|
| 555 |
+
# Algorithm:
|
| 556 |
+
# 1. Extract DINOv2 embeddings for all high-conf TPs (conf >= 0.72)
|
| 557 |
+
# -- orientation-normalised so horizontal and vertical instances
|
| 558 |
+
# of the same symbol produce comparable embeddings.
|
| 559 |
+
# 2. Prototype = mean unit-normalised embedding.
|
| 560 |
+
# 3. For each borderline candidate: compute cosine(candidate, prototype).
|
| 561 |
+
# 4. Reject if similarity < min_sim.
|
| 562 |
+
#
|
| 563 |
+
# Why this works:
|
| 564 |
+
# -- Resistors (any orientation) -> similar DINOv2 embedding -> high sim
|
| 565 |
+
# -- Inductors/transistors/op-amps -> different embedding -> low sim
|
| 566 |
+
# -- Prototype adapts to the specific drawing style automatically
|
| 567 |
+
_proto_threshold = 0.72 # high-conf TPs used for prototype
|
| 568 |
+
_proto_min_sim = 0.82 # borderline candidates below this are rejected
|
| 569 |
+
_hc = [c for c in _circuit if c.get("confidence", 0) >= _proto_threshold]
|
| 570 |
+
_bl = [c for c in _circuit if c.get("confidence", 0) < _proto_threshold]
|
| 571 |
+
|
| 572 |
+
if len(_hc) >= 3 and _bl:
|
| 573 |
+
dh, dw = drawing_proc.shape[:2]
|
| 574 |
+
hc_crops = [
|
| 575 |
+
self.dino_verifier._crop_with_padding(drawing_proc, c, dh, dw)
|
| 576 |
+
for c in _hc
|
| 577 |
+
]
|
| 578 |
+
hc_embeds = self.dino_verifier.embed_crops_normalized(hc_crops)
|
| 579 |
+
prototype = hc_embeds.mean(axis=0)
|
| 580 |
+
_pnorm = float(np.linalg.norm(prototype))
|
| 581 |
+
if _pnorm > 1e-6:
|
| 582 |
+
prototype = prototype / _pnorm
|
| 583 |
+
bl_crops = [
|
| 584 |
+
self.dino_verifier._crop_with_padding(drawing_proc, c, dh, dw)
|
| 585 |
+
for c in _bl
|
| 586 |
+
]
|
| 587 |
+
bl_embeds = self.dino_verifier.embed_crops_normalized(bl_crops)
|
| 588 |
+
bl_sims = bl_embeds @ prototype # (M,) cosine similarities
|
| 589 |
+
_accepted = [c for c, s in zip(_bl, bl_sims.tolist())
|
| 590 |
+
if s >= _proto_min_sim]
|
| 591 |
+
_rejected = [c for c, s in zip(_bl, bl_sims.tolist())
|
| 592 |
+
if s < _proto_min_sim]
|
| 593 |
+
if _rejected:
|
| 594 |
+
print(
|
| 595 |
+
f"[Pipeline] DINO-proto: {len(_bl)} border -> "
|
| 596 |
+
f"{len(_accepted)} kept, {len(_rejected)} rejected "
|
| 597 |
+
f"(sim>={_proto_min_sim})"
|
| 598 |
+
)
|
| 599 |
+
_circuit = _hc + _accepted
|
| 600 |
+
else:
|
| 601 |
+
_circuit = _hc + _bl # fallback
|
| 602 |
+
else:
|
| 603 |
+
_circuit = _hc + _bl # not enough high-conf to build prototype
|
| 604 |
+
|
| 605 |
+
# Merge: NCC circuit + DINODense circuit (skipped structural filters)
|
| 606 |
+
# DINODense candidates already have high DINOv2 similarity >= threshold
|
| 607 |
+
verified = _circuit + _dense_circuit + _notes
|
| 608 |
print(
|
| 609 |
f"[Pipeline] Simple-template filters: {before} -> {len(verified)} "
|
| 610 |
+
f"(NCC:{len(_circuit)} | DINODense:{len(_dense_circuit)} | notes:{len(_notes)})"
|
| 611 |
)
|
| 612 |
|
| 613 |
# Title-block zone filter: remove candidates inside the BOM / right-frame
|
|
|
|
| 618 |
if len(verified) != before_tb:
|
| 619 |
print(f"[Pipeline] Title-block filter: {before_tb} -> {len(verified)}")
|
| 620 |
|
| 621 |
+
# Adaptive confidence-gap filter: detect bimodal confidence distribution
|
| 622 |
+
# and remove the low-confidence cluster (structural FPs: inductors,
|
| 623 |
+
# transistors, op-amps that barely pass DINOv2 with low NCC).
|
| 624 |
+
# Only applied to complex templates -- simple templates use dedicated
|
| 625 |
+
# structural filters (wire-leads, chamfer, DINO-prototype) that are more
|
| 626 |
+
# precise; the gap filter risks removing genuine low-confidence TPs there.
|
| 627 |
+
if not _is_simple:
|
| 628 |
+
before_gap = len(verified)
|
| 629 |
+
verified = self.postprocessor.filter_confidence_gap(verified)
|
| 630 |
+
if len(verified) != before_gap:
|
| 631 |
+
print(f"[Pipeline] Confidence gap filter: {before_gap} -> {len(verified)}")
|
| 632 |
+
|
| 633 |
# Final NMS + format
|
| 634 |
# Simple templates: tight IoU (0.25), no union expand (keep best-fit bbox).
|
| 635 |
# Complex templates:
|
| 636 |
+
# - probe-focused path: tight IoU (0.25) to suppress FP clusters in dense
|
| 637 |
+
# circuit regions where multiple off-scale detections land on the same symbol
|
| 638 |
# - standard path (_complex_use_union=True): union expand for chain suppression
|
| 639 |
if _is_simple:
|
| 640 |
verified = self.postprocessor.final_nms(
|
|
|
|
| 647 |
)
|
| 648 |
result = self.postprocessor.format_output(verified, drawing_proc.shape)
|
| 649 |
t4 = time.time()
|
| 650 |
+
print(f"[Pipeline] Auto-detect total: {t4 - t0:.2f}s -- {result['total_detections']} detections")
|
| 651 |
|
| 652 |
if return_visualization:
|
| 653 |
# Draw on original (non-binarized) image for clearer output
|
|
|
|
| 688 |
t1 = time.time()
|
| 689 |
print(f"[Pipeline] Stage 0 (Preprocess): {t1 - t0:.2f}s")
|
| 690 |
|
| 691 |
+
pattern_proc = pattern_data["processed"] # original -- used for DINOv2
|
| 692 |
drawing_proc = drawing_data["processed"]
|
| 693 |
|
| 694 |
# Optionally dilate pattern strokes for NCC to handle style mismatch
|
|
|
|
| 702 |
# Stage 1: NCC matching (uses dilated pattern if configured)
|
| 703 |
candidates = self.ncc_matcher.match(drawing_proc, pattern_for_ncc)
|
| 704 |
t2 = time.time()
|
| 705 |
+
print(f"[Pipeline] Stage 1 (NCC): {t2 - t1:.2f}s -- {len(candidates)} candidates")
|
| 706 |
|
| 707 |
if not candidates:
|
| 708 |
print("[Pipeline] No candidates from Stage 1, returning empty result.")
|
|
|
|
| 716 |
# Stage 2: DINOv2 verification
|
| 717 |
candidates = self.dino_verifier.verify_candidates(drawing_proc, pattern_proc, candidates)
|
| 718 |
t3 = time.time()
|
| 719 |
+
print(f"[Pipeline] Stage 2 (DINOv2): {t3 - t2:.2f}s -- {len(candidates)} verified")
|
| 720 |
|
| 721 |
# Stage 3: Final NMS + format
|
| 722 |
candidates = self.postprocessor.final_nms(candidates, iou_threshold=self.final_nms_iou)
|
| 723 |
result = self.postprocessor.format_output(candidates, drawing_proc.shape)
|
| 724 |
t4 = time.time()
|
| 725 |
print(f"[Pipeline] Stage 3 (Post): {t4 - t3:.2f}s")
|
| 726 |
+
print(f"[Pipeline] Total: {t4 - t0:.2f}s -- {result['total_detections']} detections")
|
| 727 |
|
| 728 |
if return_visualization:
|
| 729 |
# Draw on original (non-binarized) image for clearer output
|
|
@@ -283,8 +283,8 @@ class Postprocessor:
|
|
| 283 |
|
| 284 |
img_h, img_w = output.shape[:2]
|
| 285 |
# Scale thickness and font to image size
|
| 286 |
-
thickness = max(
|
| 287 |
-
font_scale = max(0.
|
| 288 |
|
| 289 |
def _conf_color(conf: float) -> Tuple:
|
| 290 |
if conf >= 0.70:
|
|
@@ -294,29 +294,41 @@ class Postprocessor:
|
|
| 294 |
else:
|
| 295 |
return (40, 40, 220) # red
|
| 296 |
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
| 298 |
bbox = det["bbox"]
|
| 299 |
x, y, w, h = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
|
| 300 |
conf = float(det.get("confidence", 0))
|
| 301 |
color = _conf_color(conf)
|
| 302 |
|
|
|
|
| 303 |
cv2.rectangle(output, (x, y), (x + w, y + h), color, thickness=thickness)
|
| 304 |
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
| 306 |
(lw, lh), bl = cv2.getTextSize(
|
| 307 |
label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1
|
| 308 |
)
|
| 309 |
-
|
| 310 |
-
#
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
cv2.putText(
|
| 318 |
output, label,
|
| 319 |
-
(
|
| 320 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 321 |
font_scale, (255, 255, 255), 1, cv2.LINE_AA,
|
| 322 |
)
|
|
@@ -864,6 +876,127 @@ class Postprocessor:
|
|
| 864 |
|
| 865 |
return result
|
| 866 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
def filter_neighborhood_complexity(
|
| 868 |
self,
|
| 869 |
candidates: List[dict],
|
|
@@ -1042,3 +1175,115 @@ class Postprocessor:
|
|
| 1042 |
if union_area <= 0:
|
| 1043 |
return 0.0
|
| 1044 |
return inter_area / union_area
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
img_h, img_w = output.shape[:2]
|
| 285 |
# Scale thickness and font to image size
|
| 286 |
+
thickness = max(1, int(max(img_h, img_w) / 800))
|
| 287 |
+
font_scale = max(0.3, min(0.55, max(img_h, img_w) / 2500))
|
| 288 |
|
| 289 |
def _conf_color(conf: float) -> Tuple:
|
| 290 |
if conf >= 0.70:
|
|
|
|
| 294 |
else:
|
| 295 |
return (40, 40, 220) # red
|
| 296 |
|
| 297 |
+
# Sort by (y, x) so index numbers increase top-to-bottom, left-to-right
|
| 298 |
+
indexed = sorted(enumerate(detections), key=lambda p: (p[1]["bbox"]["y"], p[1]["bbox"]["x"]))
|
| 299 |
+
|
| 300 |
+
for orig_idx, det in indexed:
|
| 301 |
bbox = det["bbox"]
|
| 302 |
x, y, w, h = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
|
| 303 |
conf = float(det.get("confidence", 0))
|
| 304 |
color = _conf_color(conf)
|
| 305 |
|
| 306 |
+
# Draw bounding box
|
| 307 |
cv2.rectangle(output, (x, y), (x + w, y + h), color, thickness=thickness)
|
| 308 |
|
| 309 |
+
# Label always drawn INSIDE the box at top-left corner.
|
| 310 |
+
# This guarantees labels never cover boxes from other detections —
|
| 311 |
+
# even when boxes overlap, each label stays within its own bbox.
|
| 312 |
+
label = f"#{orig_idx + 1} {conf:.2f}"
|
| 313 |
(lw, lh), bl = cv2.getTextSize(
|
| 314 |
label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1
|
| 315 |
)
|
| 316 |
+
pad = 2
|
| 317 |
+
# Clamp label to box interior
|
| 318 |
+
lx = min(x + pad, x + w - lw - pad)
|
| 319 |
+
ly = y + lh + pad
|
| 320 |
+
# Semi-transparent background: draw a filled rect then text
|
| 321 |
+
bg_x1 = max(x, lx - pad)
|
| 322 |
+
bg_y1 = max(y, ly - lh - pad)
|
| 323 |
+
bg_x2 = min(x + w, lx + lw + pad)
|
| 324 |
+
bg_y2 = min(y + h, ly + pad)
|
| 325 |
+
if bg_x2 > bg_x1 and bg_y2 > bg_y1:
|
| 326 |
+
overlay = output.copy()
|
| 327 |
+
cv2.rectangle(overlay, (bg_x1, bg_y1), (bg_x2, bg_y2), color, -1)
|
| 328 |
+
cv2.addWeighted(overlay, 0.75, output, 0.25, 0, output)
|
| 329 |
cv2.putText(
|
| 330 |
output, label,
|
| 331 |
+
(lx, ly),
|
| 332 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 333 |
font_scale, (255, 255, 255), 1, cv2.LINE_AA,
|
| 334 |
)
|
|
|
|
| 876 |
|
| 877 |
return result
|
| 878 |
|
| 879 |
+
def filter_confidence_gap(
|
| 880 |
+
self,
|
| 881 |
+
candidates: List[dict],
|
| 882 |
+
min_gap: float = 0.075,
|
| 883 |
+
min_cluster_size: int = 2,
|
| 884 |
+
) -> List[dict]:
|
| 885 |
+
"""Remove low-confidence cluster when a bimodal confidence distribution is detected.
|
| 886 |
+
|
| 887 |
+
Real pattern instances (TPs) cluster at high confidence (0.75–0.90) because
|
| 888 |
+
both NCC and DINOv2 scores are high. Structurally-similar FPs (inductors,
|
| 889 |
+
transistors, op-amps) cluster at low confidence (0.58–0.67) — they barely
|
| 890 |
+
pass DINOv2 but have low NCC. A clear gap separates the two clusters.
|
| 891 |
+
|
| 892 |
+
When all candidates are real TPs (unimodal distribution, no gap ≥ min_gap),
|
| 893 |
+
all candidates are returned unchanged. This makes the filter adaptive and
|
| 894 |
+
harmless for drawings where the pipeline is already accurate.
|
| 895 |
+
|
| 896 |
+
Args:
|
| 897 |
+
min_gap: Minimum confidence difference between adjacent sorted scores to
|
| 898 |
+
treat as a cluster boundary. 0.08 separates the 0.75/0.67 gap
|
| 899 |
+
seen in complex drawings while ignoring the ~0.02–0.04 natural
|
| 900 |
+
spread within a cluster of real TPs.
|
| 901 |
+
min_cluster_size: Minimum number of candidates required in EACH cluster
|
| 902 |
+
for the gap to be considered meaningful.
|
| 903 |
+
"""
|
| 904 |
+
if len(candidates) < min_cluster_size * 2 + 1:
|
| 905 |
+
return candidates
|
| 906 |
+
|
| 907 |
+
confs = sorted([c.get("confidence", 0.0) for c in candidates], reverse=True)
|
| 908 |
+
|
| 909 |
+
best_gap = 0.0
|
| 910 |
+
best_threshold = None
|
| 911 |
+
for i in range(len(confs) - 1):
|
| 912 |
+
gap = confs[i] - confs[i + 1]
|
| 913 |
+
above = i + 1
|
| 914 |
+
below = len(confs) - above
|
| 915 |
+
if gap > best_gap and above >= min_cluster_size and below >= min_cluster_size:
|
| 916 |
+
best_gap = gap
|
| 917 |
+
best_threshold = (confs[i] + confs[i + 1]) / 2.0
|
| 918 |
+
|
| 919 |
+
if best_gap >= min_gap and best_threshold is not None:
|
| 920 |
+
return [c for c in candidates if c.get("confidence", 0.0) >= best_threshold]
|
| 921 |
+
return candidates
|
| 922 |
+
|
| 923 |
+
def filter_profile_similarity(
|
| 924 |
+
self,
|
| 925 |
+
candidates: List[dict],
|
| 926 |
+
drawing_proc: np.ndarray,
|
| 927 |
+
pattern_proc: np.ndarray,
|
| 928 |
+
min_sim: float = 0.35,
|
| 929 |
+
) -> List[dict]:
|
| 930 |
+
"""Keep candidates whose 1D edge projection correlates with the template's.
|
| 931 |
+
|
| 932 |
+
For any template, the column-sum of its Canny edge map forms a characteristic
|
| 933 |
+
1D profile (e.g. evenly-spaced peaks for a zigzag, smooth humps for
|
| 934 |
+
inductors, asymmetric for transistors/op-amps). Candidates whose region
|
| 935 |
+
profile does not correlate are structural FPs.
|
| 936 |
+
|
| 937 |
+
Uses the **tight content bounding box** of the template (strips surrounding
|
| 938 |
+
whitespace) so the profile represents only the symbol, not padding.
|
| 939 |
+
Rotation-invariant: 90°-rotated candidates are un-rotated before comparison.
|
| 940 |
+
"""
|
| 941 |
+
# Extract tight content bounding box from template to strip whitespace padding.
|
| 942 |
+
# Without this, a large template with a small symbol produces a profile that
|
| 943 |
+
# is dominated by empty columns and won't correlate with drawing crops.
|
| 944 |
+
_dark = pattern_proc < 128
|
| 945 |
+
_rows_any = np.any(_dark, axis=1)
|
| 946 |
+
_cols_any = np.any(_dark, axis=0)
|
| 947 |
+
if _rows_any.any() and _cols_any.any():
|
| 948 |
+
_rmin, _rmax = int(np.where(_rows_any)[0][0]), int(np.where(_rows_any)[0][-1])
|
| 949 |
+
_cmin, _cmax = int(np.where(_cols_any)[0][0]), int(np.where(_cols_any)[0][-1])
|
| 950 |
+
_tmpl = pattern_proc[_rmin:_rmax + 1, _cmin:_cmax + 1]
|
| 951 |
+
else:
|
| 952 |
+
_tmpl = pattern_proc
|
| 953 |
+
|
| 954 |
+
th, tw = _tmpl.shape[:2]
|
| 955 |
+
if tw < 4 or th < 4:
|
| 956 |
+
return candidates # template too small for meaningful profile
|
| 957 |
+
|
| 958 |
+
tmpl_edges = cv2.Canny(_tmpl.astype(np.uint8), 50, 150).astype(float)
|
| 959 |
+
tmpl_h = np.sum(tmpl_edges, axis=0) # horizontal projection (column sums)
|
| 960 |
+
tmpl_v = np.sum(tmpl_edges, axis=1) # vertical projection (row sums)
|
| 961 |
+
|
| 962 |
+
def _safe_corr(a: np.ndarray, b: np.ndarray) -> float:
|
| 963 |
+
sa, sb = float(np.std(a)), float(np.std(b))
|
| 964 |
+
if sa < 1e-6 or sb < 1e-6:
|
| 965 |
+
return 0.0
|
| 966 |
+
return float(np.clip(np.corrcoef(a, b)[0, 1], -1.0, 1.0))
|
| 967 |
+
|
| 968 |
+
drwH, drwW = drawing_proc.shape[:2]
|
| 969 |
+
result = []
|
| 970 |
+
for c in candidates:
|
| 971 |
+
x, y, w, h = c["x"], c["y"], c["w"], c["h"]
|
| 972 |
+
region = drawing_proc[max(0, y):min(drwH, y + h), max(0, x):min(drwW, x + w)]
|
| 973 |
+
if region.shape[0] < 4 or region.shape[1] < 4:
|
| 974 |
+
result.append(c)
|
| 975 |
+
continue
|
| 976 |
+
|
| 977 |
+
# Rotate tall (90°-rotated) regions to horizontal for comparison
|
| 978 |
+
angle = c.get("angle", 0)
|
| 979 |
+
if 70 <= abs(angle) <= 110 and region.shape[0] > region.shape[1]:
|
| 980 |
+
region = cv2.rotate(region, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
| 981 |
+
|
| 982 |
+
reg_rs = cv2.resize(region.astype(np.uint8), (tw, th), interpolation=cv2.INTER_AREA)
|
| 983 |
+
reg_edges = cv2.Canny(reg_rs, 50, 150).astype(float)
|
| 984 |
+
reg_h = np.sum(reg_edges, axis=0)
|
| 985 |
+
reg_v = np.sum(reg_edges, axis=1)
|
| 986 |
+
|
| 987 |
+
# Use the better of horizontal and vertical correlation (handles partial
|
| 988 |
+
# rotation inaccuracy where the angle metadata may be off by a few degrees)
|
| 989 |
+
sim_h = _safe_corr(tmpl_h, reg_h)
|
| 990 |
+
sim_v = _safe_corr(tmpl_v, reg_v)
|
| 991 |
+
sim = max(sim_h, sim_v)
|
| 992 |
+
|
| 993 |
+
c_out = dict(c)
|
| 994 |
+
c_out["profile_sim"] = round(float(sim), 3)
|
| 995 |
+
if sim >= min_sim:
|
| 996 |
+
result.append(c_out)
|
| 997 |
+
|
| 998 |
+
return result
|
| 999 |
+
|
| 1000 |
def filter_neighborhood_complexity(
|
| 1001 |
self,
|
| 1002 |
candidates: List[dict],
|
|
|
|
| 1175 |
if union_area <= 0:
|
| 1176 |
return 0.0
|
| 1177 |
return inter_area / union_area
|
| 1178 |
+
|
| 1179 |
+
# ------------------------------------------------------------------
|
| 1180 |
+
# HOG-based self-supervised prototype filter
|
| 1181 |
+
# ------------------------------------------------------------------
|
| 1182 |
+
|
| 1183 |
+
def _hog_feature(
|
| 1184 |
+
self,
|
| 1185 |
+
drawing: np.ndarray,
|
| 1186 |
+
c: dict,
|
| 1187 |
+
target_w: int = 64,
|
| 1188 |
+
target_h: int = 32,
|
| 1189 |
+
n_bins: int = 9,
|
| 1190 |
+
) -> np.ndarray:
|
| 1191 |
+
"""Compute a gradient-orientation histogram for a candidate region.
|
| 1192 |
+
|
| 1193 |
+
The region is always normalised to landscape orientation (width > height)
|
| 1194 |
+
before feature extraction so horizontal and vertical resistors produce
|
| 1195 |
+
the same feature vector.
|
| 1196 |
+
|
| 1197 |
+
Returns a unit-normalised float32 array of length `n_bins`.
|
| 1198 |
+
"""
|
| 1199 |
+
H, W = drawing.shape[:2]
|
| 1200 |
+
x, y, w, h = c["x"], c["y"], c["w"], c["h"]
|
| 1201 |
+
region = drawing[max(0, y):min(H, y + h), max(0, x):min(W, x + w)]
|
| 1202 |
+
if region.size == 0:
|
| 1203 |
+
return np.zeros(n_bins, dtype=np.float32)
|
| 1204 |
+
|
| 1205 |
+
# Normalise to horizontal orientation
|
| 1206 |
+
if region.shape[0] > region.shape[1]:
|
| 1207 |
+
region = cv2.rotate(region, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
| 1208 |
+
|
| 1209 |
+
region_u8 = region.astype(np.uint8)
|
| 1210 |
+
region_rs = cv2.resize(region_u8, (target_w, target_h), interpolation=cv2.INTER_AREA)
|
| 1211 |
+
|
| 1212 |
+
# Sobel gradients
|
| 1213 |
+
gx = cv2.Sobel(region_rs.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)
|
| 1214 |
+
gy = cv2.Sobel(region_rs.astype(np.float32), cv2.CV_32F, 0, 1, ksize=3)
|
| 1215 |
+
mag = np.sqrt(gx * gx + gy * gy)
|
| 1216 |
+
ang = np.arctan2(gy, gx) * (180.0 / np.pi) # -180 to 180
|
| 1217 |
+
|
| 1218 |
+
# Weighted orientation histogram (unsigned, 0-180)
|
| 1219 |
+
ang_unsigned = ang % 180.0
|
| 1220 |
+
mask = mag > 5.0
|
| 1221 |
+
hist, _ = np.histogram(
|
| 1222 |
+
ang_unsigned[mask], bins=n_bins, range=(0.0, 180.0),
|
| 1223 |
+
weights=mag[mask]
|
| 1224 |
+
)
|
| 1225 |
+
norm = float(np.linalg.norm(hist))
|
| 1226 |
+
if norm > 1e-6:
|
| 1227 |
+
hist = hist / norm
|
| 1228 |
+
return hist.astype(np.float32)
|
| 1229 |
+
|
| 1230 |
+
def filter_hog_prototype(
|
| 1231 |
+
self,
|
| 1232 |
+
high_conf: List[dict],
|
| 1233 |
+
borderline: List[dict],
|
| 1234 |
+
drawing: np.ndarray,
|
| 1235 |
+
min_sim: float = 0.72,
|
| 1236 |
+
) -> List[dict]:
|
| 1237 |
+
"""Filter borderline candidates using the HOG prototype of confirmed TPs.
|
| 1238 |
+
|
| 1239 |
+
**Algorithm (Self-Supervised HOG Prototype):**
|
| 1240 |
+
|
| 1241 |
+
1. Extract gradient-orientation histogram (HOG) for each high-confidence
|
| 1242 |
+
detection — these are the confirmed True Positives for this drawing.
|
| 1243 |
+
2. Compute their mean = the "prototype" HOG for this symbol in this
|
| 1244 |
+
specific drawing style and scale.
|
| 1245 |
+
3. Score each borderline candidate by cosine similarity to the prototype.
|
| 1246 |
+
4. Accept only those above `min_sim`.
|
| 1247 |
+
|
| 1248 |
+
Why HOG works here:
|
| 1249 |
+
- Resistors (ANSI zigzag): dominant gradients at +/-45 deg (diagonal strokes)
|
| 1250 |
+
- Inductors (coil): dominant gradients at 0 deg and 90 deg (arcs + baselines)
|
| 1251 |
+
- Transistors: mixed asymmetric gradients
|
| 1252 |
+
- Batteries/sources: mostly 0/90 deg gradients (rectangular)
|
| 1253 |
+
|
| 1254 |
+
The prototype captures the specific drawing style's gradient fingerprint;
|
| 1255 |
+
FPs with a different gradient distribution are rejected.
|
| 1256 |
+
|
| 1257 |
+
Requires >= 3 high-confidence examples to form a reliable prototype.
|
| 1258 |
+
"""
|
| 1259 |
+
if len(high_conf) < 3 or not borderline:
|
| 1260 |
+
return high_conf + borderline
|
| 1261 |
+
|
| 1262 |
+
# Build prototype
|
| 1263 |
+
hc_feats = np.array([self._hog_feature(drawing, c) for c in high_conf])
|
| 1264 |
+
prototype = hc_feats.mean(axis=0)
|
| 1265 |
+
proto_norm = float(np.linalg.norm(prototype))
|
| 1266 |
+
if proto_norm < 1e-6:
|
| 1267 |
+
return high_conf + borderline
|
| 1268 |
+
prototype_unit = prototype / proto_norm
|
| 1269 |
+
|
| 1270 |
+
# Score borderline candidates
|
| 1271 |
+
accepted, rejected = [], []
|
| 1272 |
+
for c in borderline:
|
| 1273 |
+
feat = self._hog_feature(drawing, c)
|
| 1274 |
+
feat_norm = float(np.linalg.norm(feat))
|
| 1275 |
+
sim = float(np.dot(prototype_unit, feat / (feat_norm + 1e-8)))
|
| 1276 |
+
c_out = dict(c)
|
| 1277 |
+
c_out["hog_sim"] = round(sim, 3)
|
| 1278 |
+
if sim >= min_sim:
|
| 1279 |
+
accepted.append(c_out)
|
| 1280 |
+
else:
|
| 1281 |
+
rejected.append(c_out)
|
| 1282 |
+
|
| 1283 |
+
if rejected:
|
| 1284 |
+
print(
|
| 1285 |
+
f"[Postprocessor] HOG prototype: {len(borderline)} border -> "
|
| 1286 |
+
f"{len(accepted)} accepted, {len(rejected)} rejected "
|
| 1287 |
+
f"(sim_threshold={min_sim})"
|
| 1288 |
+
)
|
| 1289 |
+
return high_conf + accepted
|
|
@@ -126,12 +126,80 @@ class Preprocessor:
|
|
| 126 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
|
| 127 |
return cv2.erode(img, kernel)
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def preprocess(
|
| 130 |
self,
|
| 131 |
img_or_path: Union[str, np.ndarray],
|
| 132 |
binarize_method: str = "adaptive",
|
| 133 |
denoise: bool = True,
|
| 134 |
dilate_strokes: int = 0,
|
|
|
|
|
|
|
| 135 |
) -> dict:
|
| 136 |
"""Full preprocessing pipeline.
|
| 137 |
|
|
@@ -161,11 +229,16 @@ class Preprocessor:
|
|
| 161 |
h_res, w_res = resized.shape[:2]
|
| 162 |
scale_factor = h_res / h_orig if h_orig > 0 else 1.0
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
| 165 |
if denoise:
|
| 166 |
processed = self.denoise(processed)
|
| 167 |
if dilate_strokes > 0:
|
| 168 |
processed = self.dilate_strokes(processed, kernel_size=dilate_strokes)
|
|
|
|
|
|
|
| 169 |
|
| 170 |
return {
|
| 171 |
"original": original,
|
|
|
|
| 126 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
|
| 127 |
return cv2.erode(img, kernel)
|
| 128 |
|
| 129 |
+
def clahe_enhance(
|
| 130 |
+
self, img: np.ndarray, clip_limit: float = 2.0, tile_size: int = 8
|
| 131 |
+
) -> np.ndarray:
|
| 132 |
+
"""Apply CLAHE (Contrast Limited Adaptive Histogram Equalization).
|
| 133 |
+
|
| 134 |
+
Improves local contrast in drawings with uneven line density — faint
|
| 135 |
+
strokes that are washed out globally become visible after CLAHE.
|
| 136 |
+
Applied to grayscale images BEFORE binarization.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
img: Grayscale uint8 image.
|
| 140 |
+
clip_limit: Threshold for contrast limiting (higher = stronger).
|
| 141 |
+
tile_size: Grid size in pixels for local histogram computation.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
Contrast-enhanced grayscale image.
|
| 145 |
+
"""
|
| 146 |
+
clahe = cv2.createCLAHE(
|
| 147 |
+
clipLimit=clip_limit, tileGridSize=(tile_size, tile_size)
|
| 148 |
+
)
|
| 149 |
+
return clahe.apply(img.astype(np.uint8))
|
| 150 |
+
|
| 151 |
+
def normalize_strokes(
|
| 152 |
+
self, img: np.ndarray, target_width: int = 2
|
| 153 |
+
) -> np.ndarray:
|
| 154 |
+
"""Normalize stroke width via thinning + uniform dilation.
|
| 155 |
+
|
| 156 |
+
Engineering drawings may scan at different DPIs, producing thick or
|
| 157 |
+
thin lines. This normalizes all strokes to `target_width` pixels:
|
| 158 |
+
1. Morphological thinning (iterative erosion to approximate skeleton)
|
| 159 |
+
2. Dilation to the desired target width
|
| 160 |
+
|
| 161 |
+
This makes NCC matching invariant to line-width variation between the
|
| 162 |
+
template and the drawing, which is a common source of false negatives.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
img: Binary image (white background, black strokes).
|
| 166 |
+
target_width: Desired uniform stroke width in pixels.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Binary image with normalized stroke width.
|
| 170 |
+
"""
|
| 171 |
+
inv = cv2.bitwise_not(img)
|
| 172 |
+
|
| 173 |
+
# Iterative thinning: erode until no more pixels can be removed.
|
| 174 |
+
# Stop after 20 iterations to bound runtime.
|
| 175 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
|
| 176 |
+
prev = np.zeros_like(inv)
|
| 177 |
+
for _ in range(20):
|
| 178 |
+
eroded = cv2.erode(inv, kernel)
|
| 179 |
+
temp = cv2.dilate(eroded, kernel)
|
| 180 |
+
diff = cv2.subtract(inv, temp)
|
| 181 |
+
skeleton = cv2.bitwise_or(prev, diff)
|
| 182 |
+
inv = eroded.copy()
|
| 183 |
+
prev = skeleton.copy()
|
| 184 |
+
if cv2.countNonZero(inv) == 0:
|
| 185 |
+
break
|
| 186 |
+
|
| 187 |
+
# Re-dilate skeleton to target width
|
| 188 |
+
if target_width > 1:
|
| 189 |
+
kw = target_width * 2 - 1
|
| 190 |
+
dk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kw, kw))
|
| 191 |
+
skeleton = cv2.dilate(skeleton, dk)
|
| 192 |
+
|
| 193 |
+
return cv2.bitwise_not(skeleton)
|
| 194 |
+
|
| 195 |
def preprocess(
|
| 196 |
self,
|
| 197 |
img_or_path: Union[str, np.ndarray],
|
| 198 |
binarize_method: str = "adaptive",
|
| 199 |
denoise: bool = True,
|
| 200 |
dilate_strokes: int = 0,
|
| 201 |
+
clahe: bool = False,
|
| 202 |
+
normalize_stroke_width: int = 0,
|
| 203 |
) -> dict:
|
| 204 |
"""Full preprocessing pipeline.
|
| 205 |
|
|
|
|
| 229 |
h_res, w_res = resized.shape[:2]
|
| 230 |
scale_factor = h_res / h_orig if h_orig > 0 else 1.0
|
| 231 |
|
| 232 |
+
# CLAHE before binarization: improves faint-stroke visibility
|
| 233 |
+
to_binarize = self.clahe_enhance(resized) if clahe else resized
|
| 234 |
+
|
| 235 |
+
processed = self.binarize(to_binarize, method=binarize_method)
|
| 236 |
if denoise:
|
| 237 |
processed = self.denoise(processed)
|
| 238 |
if dilate_strokes > 0:
|
| 239 |
processed = self.dilate_strokes(processed, kernel_size=dilate_strokes)
|
| 240 |
+
if normalize_stroke_width > 0:
|
| 241 |
+
processed = self.normalize_strokes(processed, target_width=normalize_stroke_width)
|
| 242 |
|
| 243 |
return {
|
| 244 |
"original": original,
|