"""Shape-based symbol counting via contour matching. Why not cv2.matchTemplate: CAD symbols are usually thin-line drawings on a white background (~10-20% ink). Normalized cross-correlation gives spuriously high scores for any sparse-ink region (e.g. wall edges), producing false positives everywhere. Approach used here: 1. Binarize template + drawing to "ink" maps. 2. Find external contours in both. 3. Use the template's main contour as a shape reference. 4. For every contour in the drawing, compare via cv2.matchShapes (Hu moments — invariant to scale, rotation, translation). 5. Filter by area ratio (similar size to template, allowing ±factor). 6. Keep contours below a shape-distance threshold. This is the classic approach to CAD symbol takeoff. """ import logging from pathlib import Path from typing import Iterable import cv2 import numpy as np logger = logging.getLogger(__name__) # Template matching: higher = stricter. 0.65 is permissive, 0.85 strict. DEFAULT_THRESHOLD = 0.7 def _prep(img_path: Path) -> np.ndarray: """Binarize to 'ink vs not-ink'. No dilation — for contour-based matching we need lines to stay narrow so distinct symbols don't merge into one mega-contour through CAD's dense linework. """ arr = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) if arr is None: raise RuntimeError(f"Could not load {img_path}") _, ink = cv2.threshold(arr, 245, 255, cv2.THRESH_BINARY_INV) return ink def _crop_to_content(template: np.ndarray, bg_threshold: int = 240) -> np.ndarray: """Crop a template to its non-background bounding box. After _prep the template is BINARY (ink=255, bg=0). So 'content' is everything with value > 0. The bg_threshold param is kept for the grayscale case for backward compat. """ if template.max() <= 1 or template.min() == 0 and template.max() == 255: # Binary map: any non-zero pixel is ink mask = template > 0 else: mask = template < bg_threshold if not mask.any(): return template ys, xs = np.where(mask) y0, y1 = ys.min(), ys.max() + 1 x0, x1 = xs.min(), xs.max() + 1 pad = 2 y0 = max(0, y0 - pad) x0 = max(0, x0 - pad) y1 = min(template.shape[0], y1 + pad) x1 = min(template.shape[1], x1 + pad) return template[y0:y1, x0:x1] def _nms(boxes: list[tuple], scores: list[float], overlap_thresh: float = 0.3) -> list[int]: """Non-max suppression. Returns indices of kept boxes.""" if not boxes: return [] boxes_arr = np.array(boxes, dtype=np.float32) x1 = boxes_arr[:, 0] y1 = boxes_arr[:, 1] x2 = boxes_arr[:, 2] y2 = boxes_arr[:, 3] areas = (x2 - x1) * (y2 - y1) order = np.argsort(scores)[::-1] keep = [] while order.size > 0: i = order[0] keep.append(int(i)) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) w = np.maximum(0.0, xx2 - xx1) h = np.maximum(0.0, yy2 - yy1) inter = w * h union = areas[i] + areas[order[1:]] - inter iou = inter / np.maximum(union, 1e-6) order = order[1:][iou <= overlap_thresh] return keep MAX_DRAWING_PX = 9000 # effectively no downscale for typical A1/A0 def debug_template(template_path: Path, drawing_path: Path) -> dict: """Return diagnostics for tuning a symbol template.""" template = _prep(template_path) template_cropped = _crop_to_content(template) drawing = _prep(drawing_path) info = { "template_size": list(template.shape), "template_cropped_size": list(template_cropped.shape), "template_ink_pixels": int((template_cropped > 0).sum() if template_cropped.max() == 255 and template_cropped.min() == 0 else (template_cropped < 240).sum()), "template_total_pixels": int(template_cropped.size), "drawing_size": list(drawing.shape), } if min(template_cropped.shape) < 8: info["error"] = "template too small after content crop" return info # Scale sweep — finds the size at which template matches best scale_scan = [] best_overall = -1.0 for scale in (0.15, 0.25, 0.35, 0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0): nw = max(8, int(template_cropped.shape[1] * scale)) nh = max(8, int(template_cropped.shape[0] * scale)) if nh > drawing.shape[0] or nw > drawing.shape[1]: continue tmpl = cv2.resize(template_cropped, (nw, nh), interpolation=cv2.INTER_AREA) res = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED) m = float(res.max()) scale_scan.append({ "scale": scale, "template_px": [nh, nw], "max_score": round(m, 3), "count_at_0.7": int((res >= 0.7).sum()), "count_at_0.6": int((res >= 0.6).sum()), }) if m > best_overall: best_overall = m info["max_score"] = best_overall info["scale_scan"] = scale_scan # Threshold counts at scale=1.0 for reference result = cv2.matchTemplate(drawing, template_cropped, cv2.TM_CCOEFF_NORMED) info["matches_at_threshold"] = { "0.60": int((result >= 0.60).sum()), "0.70": int((result >= 0.70).sum()), "0.75": int((result >= 0.75).sum()), "0.80": int((result >= 0.80).sum()), "0.85": int((result >= 0.85).sum()), "0.90": int((result >= 0.90).sum()), } return info def _merge_template_contour(ink: np.ndarray) -> np.ndarray | None: """Combine all strokes of a template into one contour via closing. Returns the biggest connected component. The template is small enough that closing safely merges the line + arc of symbols like '-C' into one shape without affecting matching against the drawing. """ closed = cv2.morphologyEx(ink, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) contours, _ = cv2.findContours(closed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None return max(contours, key=cv2.contourArea) def count_template( template_path: Path, drawing_path: Path, threshold: float = DEFAULT_THRESHOLD, rotations: Iterable[int] = (0, 90, 180, 270), scales: Iterable[float] = (0.6, 0.8, 1.0, 1.25, 1.5), exclude_box: tuple | None = None, area_tolerance: float = 200.0, mirror: bool = True, ) -> dict: """Multi-scale, multi-rotation template matching (TM_SQDIFF_NORMED). With mirror=True the template is also tested flipped horizontally — at each rotation — so we catch mirrored instances (e.g. a socket facing left vs facing right). """ template = _prep(template_path) template = _crop_to_content(template) drawing = _prep(drawing_path) coord_scale = 1.0 if max(drawing.shape) > MAX_DRAWING_PX: coord_scale = max(drawing.shape) / MAX_DRAWING_PX new_w = int(drawing.shape[1] / coord_scale) new_h = int(drawing.shape[0] / coord_scale) drawing = cv2.resize(drawing, (new_w, new_h), interpolation=cv2.INTER_AREA) if exclude_box is not None: ex_x, ex_y, ex_w, ex_h = exclude_box ex_x = int(ex_x / coord_scale) ex_y = int(ex_y / coord_scale) ex_w = int(ex_w / coord_scale) ex_h = int(ex_h / coord_scale) h, w = drawing.shape ex_x = max(0, min(w, ex_x)) ex_y = max(0, min(h, ex_y)) ex_x2 = max(0, min(w, ex_x + ex_w)) ex_y2 = max(0, min(h, ex_y + ex_h)) drawing[ex_y:ex_y2, ex_x:ex_x2] = 0 logger.info("Masked legend region (%d,%d,%d,%d)", ex_x, ex_y, ex_w, ex_h) if min(template.shape) < 8 or int((template > 0).sum()) < 5: logger.warning("Template too small / empty after preprocessing") return {"count": 0, "matches": [], "threshold_used": threshold} all_boxes: list[tuple] = [] all_scores: list[float] = [] # Build the variants we'll search with: rotations × (original, mirrored) variants = [(template, False)] if mirror: variants.append((cv2.flip(template, 1), True)) def _rotate(img, angle_deg): if angle_deg == 0: return img if angle_deg in (90, 180, 270): return np.rot90(img, k=angle_deg // 90) # Arbitrary angle — rotate with bbox expansion + white fill h, w = img.shape[:2] center = (w / 2, h / 2) M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) cos = abs(M[0, 0]); sin = abs(M[0, 1]) new_w = int(h * sin + w * cos) new_h = int(h * cos + w * sin) M[0, 2] += new_w / 2 - center[0] M[1, 2] += new_h / 2 - center[1] return cv2.warpAffine(img, M, (new_w, new_h), flags=cv2.INTER_NEAREST, borderValue=0) # Build the full job list (one entry per rot×scale×mirror) jobs_list = [] for variant, _ in variants: for rot in rotations: base = _rotate(variant, rot) for scale in scales: new_w = max(8, int(base.shape[1] * scale)) new_h = max(8, int(base.shape[0] * scale)) if new_h > drawing.shape[0] or new_w > drawing.shape[1]: continue tmpl = cv2.resize(base, (new_w, new_h), interpolation=cv2.INTER_AREA) jobs_list.append((tmpl, new_w, new_h)) def _run(args): tmpl, new_w, new_h = args sq = cv2.matchTemplate(drawing, tmpl, cv2.TM_SQDIFF_NORMED) cc = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED) sim = np.maximum(1.0 - sq, cc) ys, xs = np.where(sim >= threshold) out = [] for y, x in zip(ys, xs): out.append((float(x), float(y), float(x + new_w), float(y + new_h), float(sim[y, x]))) return out # Threading: cv2.matchTemplate releases the GIL, so threads give real speedup import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex: for result in ex.map(_run, jobs_list): for x0, y0, x1, y1, score in result: all_boxes.append((x0, y0, x1, y1)) all_scores.append(score) if not all_boxes: return {"count": 0, "matches": [], "threshold_used": threshold} keep = _nms(all_boxes, all_scores, overlap_thresh=0.3) matches = [] for i in keep: x0, y0, x1, y1 = all_boxes[i] matches.append({ "x": int(x0 * coord_scale), "y": int(y0 * coord_scale), "w": int((x1 - x0) * coord_scale), "h": int((y1 - y0) * coord_scale), "score": round(all_scores[i], 3), }) return {"count": len(matches), "matches": matches, "threshold_used": threshold}