File size: 52,960 Bytes
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8dceee
 
7d835bb
 
 
 
 
 
 
 
 
a8dceee
 
 
 
7d835bb
 
 
 
 
a8dceee
7d835bb
 
a8dceee
 
 
 
7d835bb
 
 
a8dceee
 
 
 
 
 
 
 
 
 
 
 
 
7d835bb
 
a8dceee
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8dceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
beec8a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d835bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8dceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
import cv2
import numpy as np
from typing import List, Tuple, Callable


class Postprocessor:
    """Stage 4: Final NMS, output formatting, and visualization."""

    def final_nms(
        self,
        candidates: List[dict],
        iou_threshold: float = 0.4,
        use_union_bbox: bool = True,
    ) -> List[dict]:
        """Cluster-and-merge NMS: group overlapping boxes and keep one per cluster.

        When use_union_bbox=True (default): the output bbox is the union of all
        boxes in the cluster — good for complex templates where multi-scale
        detections should together define the component boundary.
        When use_union_bbox=False: the output bbox is taken from the highest-
        confidence candidate — better for simple templates where an offset
        duplicate should not expand the bbox beyond the best-fit box.

        Args:
            candidates: Candidate dicts with "confidence" key.
            iou_threshold: Overlap threshold for grouping (max of IoU and containment).
            use_union_bbox: Whether to expand bbox to union of cluster.

        Returns:
            List of merged detections, one per cluster.
        """
        if not candidates:
            return []

        candidates = sorted(candidates, key=lambda c: c["confidence"], reverse=True)
        n = len(candidates)
        cluster_id = list(range(n))

        def find(i):
            while cluster_id[i] != i:
                cluster_id[i] = cluster_id[cluster_id[i]]
                i = cluster_id[i]
            return i

        def union(i, j):
            cluster_id[find(i)] = find(j)

        for i in range(n):
            for j in range(i + 1, n):
                if self._overlap_ratio(candidates[i], candidates[j]) > iou_threshold:
                    union(i, j)

        clusters: dict = {}
        for i, cand in enumerate(candidates):
            root = find(i)
            clusters.setdefault(root, []).append(cand)

        merged = []
        for cluster in clusters.values():
            best = max(cluster, key=lambda c: c["confidence"])
            merged_cand = dict(best)
            if use_union_bbox:
                merged_cand["x"] = min(c["x"] for c in cluster)
                merged_cand["y"] = min(c["y"] for c in cluster)
                merged_cand["w"] = max(c["x"] + c["w"] for c in cluster) - merged_cand["x"]
                merged_cand["h"] = max(c["y"] + c["h"] for c in cluster) - merged_cand["y"]
            merged.append(merged_cand)

        return sorted(merged, key=lambda c: c["confidence"], reverse=True)

    def format_output(self, candidates: List[dict], image_shape: Tuple) -> dict:
        """Format final detections into structured output dict.

        Args:
            candidates: Filtered detection candidates.
            image_shape: Shape tuple (H, W) or (H, W, C) of the drawing image.

        Returns:
            Structured detection dict.
        """
        h, w = image_shape[:2]
        detections = []
        for cand in candidates:
            bw, bh = int(cand["w"]), int(cand["h"])
            if bw < 20 or bh < 20:
                continue
            # Skip degenerate boxes: aspect ratio outside [0.33, 3.0]
            aspect = bw / bh if bh > 0 else 0
            if aspect < 0.33 or aspect > 3.0:
                continue
            detections.append({
                "bbox": {
                    "x": int(cand["x"]),
                    "y": int(cand["y"]),
                    "w": bw,
                    "h": bh,
                },
                "confidence": round(float(cand.get("confidence", 0.0)), 2),
                "ncc_score": round(float(cand.get("ncc_score", 0.0)), 4),
                "dino_score": round(float(cand.get("dino_score", 0.0)), 4),
                "scale": round(float(cand.get("scale", 1.0)), 4),
                "angle": round(float(cand.get("angle", 0.0)), 4),
            })

        return {
            "detections": detections,
            "total_detections": len(detections),
            "image_size": {"width": int(w), "height": int(h)},
        }

    def filter_grid_clusters(
        self,
        candidates: List[dict],
        row_tol: int = 15,
        col_tol: int = 15,
        min_grid_size: int = 3,
    ) -> List[dict]:
        """Remove candidates that belong to a table row or frame line.

        When min_grid_size or more detections share the same row (y) or column (x)
        within tolerance, they are structural artifacts (BOM rows, border segments)
        rather than isolated circuit components.
        """
        if len(candidates) < min_grid_size:
            return candidates

        n = len(candidates)
        cy = [c["y"] + c["h"] // 2 for c in candidates]
        cx = [c["x"] + c["w"] // 2 for c in candidates]
        keep = [True] * n

        for i in range(n):
            row_count = sum(1 for j in range(n) if abs(cy[j] - cy[i]) <= row_tol)
            if row_count >= min_grid_size:
                keep[i] = False
                continue
            col_count = sum(1 for j in range(n) if abs(cx[j] - cx[i]) <= col_tol)
            if col_count >= min_grid_size:
                keep[i] = False

        return [c for c, k in zip(candidates, keep) if k]

    def find_table_exclusion_zones(self, drawing: np.ndarray) -> dict:
        """Detect frame border and BOM/title-block area boundaries.

        Returns:
            dict with:
              title_block_x: leftmost x of the right-frame vertical lines
                             (candidates whose right edge exceeds this are frame FPs)
              bom_start_x:   leftmost x where horizontal-line density jumps to
                             table-area levels (candidates with center beyond this
                             are BOM/title-block FPs)
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        # 1. Frame border: find rightmost long vertical line (>=40% of height)
        v_min_len = max(50, int(H * 0.40))
        v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, v_min_len))
        v_line_img = cv2.erode(binary, v_kernel)
        cols_with_vlines = np.where(v_line_img.any(axis=0))[0]
        title_block_x = W
        if len(cols_with_vlines) > 0:
            right_cols = cols_with_vlines[cols_with_vlines > int(W * 0.75)]
            if len(right_cols) > 0:
                title_block_x = int(right_cols.min())

        # 2. BOM/title-block area: scan column strips for horizontal-line density jump.
        # Circuit wires produce ~10-50 rows with density > 15%; table rows produce 60+.
        # Only run this scan when a real right-frame was detected (title_block_x < W).
        # If no vertical frame lines exist (title_block_x == W), the drawing has no
        # structured BOM table, so skip the scan entirely to avoid false positives in
        # complex circuit areas.
        strip_w = 30
        bom_threshold = 60
        bom_start_x = title_block_x  # default to frame border (safe: = W if no frame)
        if title_block_x < W:
            for x0 in range(int(W * 0.80), W - strip_w, strip_w):
                strip = binary[:, x0: x0 + strip_w]
                row_density = strip.mean(axis=1)
                n_lines = int(np.sum(row_density > 0.15))
                if n_lines >= bom_threshold:
                    bom_start_x = x0
                    break

        return {"title_block_x": title_block_x, "bom_start_x": bom_start_x}

    def filter_title_block(
        self, candidates: List[dict], drawing: np.ndarray
    ) -> List[dict]:
        """Remove candidates inside the outer frame border or BOM/title-block zone.

        Two criteria:
          1. Right-edge: bbox reaches into the outer frame column
             (catches border-corner FPs whose x+w overlaps the frame line).
          2. BOM zone: candidate centre is to the right of the structured
             table area (BOM rows, company info, drawing number cells).
        """
        zones = self.find_table_exclusion_zones(drawing)
        tx = zones["title_block_x"]   # outer right frame x
        bom_x = zones["bom_start_x"]  # BOM/title-block left boundary
        margin = 10  # small margin inward from frame line

        result = []
        for c in candidates:
            right_edge = c["x"] + c["w"]
            center_x = c["x"] + c["w"] // 2
            # Exclude if bbox right edge reaches into the outer frame
            if right_edge > tx - margin:
                continue
            # Exclude if candidate centre falls inside BOM/title-block area
            if center_x >= bom_x:
                continue
            result.append(c)
        return result

    def filter_isolated(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        probe: int = 10,
        dark_threshold: float = 0.25,
    ) -> List[dict]:
        """Remove candidates whose long sides are bordered by adjacent grid lines.

        Circuit components sit in open white space; BOM/title-block cells have
        solid lines directly above and below (or left/right for vertical).
        Rejects any candidate where the strip just outside a long side has
        dark-pixel ratio > dark_threshold.
        """
        H, W = drawing.shape[:2]
        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            margin = max(2, min(w, h) // 6)

            if w >= h:  # horizontal → check top and bottom
                strips = [
                    drawing[max(0, y - probe) : y, x + margin : x + w - margin],
                    drawing[y + h : min(H, y + h + probe), x + margin : x + w - margin],
                ]
            else:  # vertical → check left and right
                strips = [
                    drawing[y + margin : y + h - margin, max(0, x - probe) : x],
                    drawing[y + margin : y + h - margin, x + w : min(W, x + w + probe)],
                ]

            isolated = True
            for s in strips:
                if s.size == 0:
                    continue
                if float(np.sum(s < 128)) / s.size > dark_threshold:
                    isolated = False
                    break

            if isolated:
                result.append(c)

        return result

    def draw_boxes(self, drawing: np.ndarray, detections: List[dict]) -> np.ndarray:
        """Draw color-coded bounding boxes on the drawing image.

        Boxes are green (conf >= 0.70), amber (>= 0.55), or red (< 0.55).
        Labels show detection index and confidence score.

        Args:
            drawing: Grayscale, RGB, or RGBA image.
            detections: List of detection dicts from format_output.

        Returns:
            RGB numpy array (H, W, 3) with annotations.
        """
        output = drawing.copy()

        # Normalise to 3-channel BGR
        if len(output.shape) == 2:
            output = cv2.cvtColor(output, cv2.COLOR_GRAY2BGR)
        elif output.shape[2] == 4:
            output = cv2.cvtColor(output, cv2.COLOR_RGBA2BGR)
        else:
            output = cv2.cvtColor(output, cv2.COLOR_RGB2BGR)

        img_h, img_w = output.shape[:2]
        # Scale thickness and font to image size
        thickness = max(1, int(max(img_h, img_w) / 800))
        font_scale = max(0.3, min(0.55, max(img_h, img_w) / 2500))

        def _conf_color(conf: float) -> Tuple:
            if conf >= 0.70:
                return (40, 200, 60)    # green
            elif conf >= 0.55:
                return (30, 160, 245)   # amber-blue
            else:
                return (40, 40, 220)    # red

        # Sort by (y, x) so index numbers increase top-to-bottom, left-to-right
        indexed = sorted(enumerate(detections), key=lambda p: (p[1]["bbox"]["y"], p[1]["bbox"]["x"]))

        for orig_idx, det in indexed:
            bbox = det["bbox"]
            x, y, w, h = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
            conf = float(det.get("confidence", 0))
            color = _conf_color(conf)

            # Draw bounding box
            cv2.rectangle(output, (x, y), (x + w, y + h), color, thickness=thickness)

            # Label always drawn INSIDE the box at top-left corner.
            # This guarantees labels never cover boxes from other detections —
            # even when boxes overlap, each label stays within its own bbox.
            label = f"#{orig_idx + 1} {conf:.2f}"
            (lw, lh), bl = cv2.getTextSize(
                label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1
            )
            pad = 2
            # Clamp label to box interior
            lx = min(x + pad, x + w - lw - pad)
            ly = y + lh + pad
            # Semi-transparent background: draw a filled rect then text
            bg_x1 = max(x, lx - pad)
            bg_y1 = max(y, ly - lh - pad)
            bg_x2 = min(x + w, lx + lw + pad)
            bg_y2 = min(y + h, ly + pad)
            if bg_x2 > bg_x1 and bg_y2 > bg_y1:
                overlay = output.copy()
                cv2.rectangle(overlay, (bg_x1, bg_y1), (bg_x2, bg_y2), color, -1)
                cv2.addWeighted(overlay, 0.75, output, 0.25, 0, output)
            cv2.putText(
                output, label,
                (lx, ly),
                cv2.FONT_HERSHEY_SIMPLEX,
                font_scale, (255, 255, 255), 1, cv2.LINE_AA,
            )

        return cv2.cvtColor(output, cv2.COLOR_BGR2RGB)

    def filter_wire_leads(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        probe: int = 24,
        min_run: int = 2,
        min_run_weak: int = 0,
        dino_bypass_threshold: float = 0.88,
    ) -> List[dict]:
        """Keep only candidates that have wire leads on their connecting sides.

        Two acceptance paths:

        1. High-confidence bypass: candidates whose DINOv2 score exceeds
           dino_bypass_threshold are accepted unconditionally — DINOv2 at
           that level is a strong semantic match, making it almost certain
           the region IS the component and not a wire artifact.

        2. Wire-lead scan: scan ALL rows across the full bbox height (not
           just the centre ±1 row).  This finds leads even when the wire
           connects at the top or bottom edge of the bbox rather than the
           centre, which was the root cause of missed detections.  Acceptance
           requires at least one side to have a run ≥ min_run.
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        def _max_run(arr: np.ndarray) -> int:
            if not arr.any():
                return 0
            best = cur = 0
            for v in arr:
                if v:
                    cur += 1
                    best = max(best, cur)
                else:
                    cur = 0
            return best

        result = []
        for c in candidates:
            # Path 1: high-confidence DINOv2 bypass
            if c.get("dino_score", 0.0) >= dino_bypass_threshold:
                result.append(c)
                continue

            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            angle = c.get("angle", 0)
            is_vertical = 70 <= abs(angle) <= 110

            passed = False
            for shrink_frac in [0.0, 0.12, 0.25, 0.38]:
                sx = int(w * shrink_frac)
                sy = int(h * shrink_frac)
                bx = x + sx
                by = y + sy
                bw = w - 2 * sx
                bh = h - 2 * sy
                if bw < 8 or bh < 4:
                    continue

                left_best = right_best = 0
                if not is_vertical:
                    # Scan ALL rows across bbox height (not just centre ±1).
                    # Wires can connect at any point along the short edge.
                    for row in range(max(0, by), min(H, by + bh)):
                        lslice = binary[row, max(0, bx - probe) : bx]
                        rslice = binary[row, bx + bw : min(W, bx + bw + probe)]
                        left_best  = max(left_best,  _max_run(lslice))
                        right_best = max(right_best, _max_run(rslice))
                else:
                    # Scan ALL columns across bbox width.
                    for col in range(max(0, bx), min(W, bx + bw)):
                        tslice = binary[max(0, by - probe) : by, col]
                        bslice = binary[by + bh : min(H, by + bh + probe), col]
                        left_best  = max(left_best,  _max_run(tslice))
                        right_best = max(right_best, _max_run(bslice))

                strong = max(left_best, right_best)
                weak   = min(left_best, right_best)
                if strong >= min_run and weak >= min_run_weak:
                    passed = True
                    break

            if passed:
                result.append(c)

        return result

    def filter_wire_passthrough(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        passthrough_threshold: float = 0.60,
    ) -> List[dict]:
        """Remove candidates where a straight wire runs through the bbox body.

        A component body (resistor rectangle) has a white interior — the
        wire enters one terminal, the body contains the symbol, and the wire
        exits the other terminal.  A wire segment or T-junction has a
        continuous dark line running from one side straight through to the
        other without any interior gap.

        For horizontal candidates: if any row in the centre third of the
        bbox height contains a dark run spanning ≥ passthrough_threshold of
        the inner width, the candidate is rejected as a wire pass-through.
        For vertical: same logic on centre columns.
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        def _max_run(arr: np.ndarray) -> int:
            if not arr.any():
                return 0
            best = cur = 0
            for v in arr:
                if v:
                    cur += 1
                    best = max(best, cur)
                else:
                    cur = 0
            return best

        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            angle = c.get("angle", 0)
            is_vertical = 70 <= abs(angle) <= 110
            margin = max(2, min(w, h) // 6)

            passthrough = False
            if not is_vertical:
                col_from = x + margin
                col_to   = x + w - margin
                inner_w  = col_to - col_from
                row_from = y + h // 3
                row_to   = y + 2 * h // 3
                # Check 1: horizontal dark run through the center third of height.
                if inner_w >= 4 and row_from < row_to:
                    for row in range(max(0, row_from), min(H, row_to)):
                        inner = binary[row, max(0, col_from) : min(W, col_to)]
                        if inner.size == 0:
                            continue
                        if _max_run(inner) / inner.size >= passthrough_threshold:
                            passthrough = True
                            break
                # Check 2: continuous vertical dark run through the CENTRE half of the
                # inner columns (avoids the box-border vertical lines which fall within
                # margin distance of the bbox edge but outside the centre half).
                # T-junctions have wires running through the body; a plain rectangle has
                # only a white interior, so no column has a long continuous dark run.
                if not passthrough and h > 0:
                    v_col_from = x + w // 4
                    v_col_to   = x + 3 * w // 4
                    if v_col_from < v_col_to:
                        for col in range(max(0, v_col_from), min(W, v_col_to)):
                            col_slice = binary[max(0, y) : min(H, y + h), col]
                            if col_slice.size > 0 and _max_run(col_slice) / col_slice.size >= passthrough_threshold:
                                passthrough = True
                                break
            else:
                row_from = y + margin
                row_to   = y + h - margin
                inner_h  = row_to - row_from
                col_from = x + w // 3
                col_to   = x + 2 * w // 3
                if inner_h >= 4 and col_from < col_to:
                    for col in range(max(0, col_from), min(W, col_to)):
                        inner = binary[max(0, row_from) : min(H, row_to), col]
                        if inner.size == 0:
                            continue
                        if _max_run(inner) / inner.size >= passthrough_threshold:
                            passthrough = True
                            break

            if not passthrough:
                result.append(c)

        return result

    def filter_rect_borders(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        border_run_ratio: float = 0.55,
    ) -> List[dict]:
        """Keep only candidates that show symmetric rectangular borders (top + bottom).

        Real schematic component symbols (resistors, capacitors) have a rectangular
        outline with a visible horizontal border at ~20-35% and ~65-80% of the bbox
        height.  Wire T-junctions and corners have only ONE horizontal line (either
        near the top or bottom, asymmetric), so this filter removes them.

        Designed for use on notes/legend-area candidates where the wire-lead filter
        is intentionally not applied.
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        def _max_run(arr: np.ndarray) -> int:
            if not arr.any():
                return 0
            best = cur = 0
            for v in arr:
                if v:
                    cur += 1
                    best = max(best, cur)
                else:
                    cur = 0
            return best

        def _zone_has_border(x, y_start, y_end, w, threshold):
            for row in range(max(0, y_start), min(H, y_end + 1)):
                line = binary[row, max(0, x) : min(W, x + w)]
                if line.size > 0 and _max_run(line) / w >= threshold:
                    return True
            return False

        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            angle = c.get("angle", 0)
            is_vertical = 70 <= abs(angle) <= 110

            if not is_vertical:
                top_start = y + int(h * 0.15)
                top_end   = y + int(h * 0.35)
                bot_start = y + int(h * 0.65)
                bot_end   = y + int(h * 0.85)
                has_top = _zone_has_border(x, top_start, top_end, w, border_run_ratio)
                has_bot = _zone_has_border(x, bot_start, bot_end, w, border_run_ratio)
                if has_top and has_bot:
                    result.append(c)
            else:
                left_start = x + int(w * 0.15)
                left_end   = x + int(w * 0.35)
                right_start = x + int(w * 0.65)
                right_end   = x + int(w * 0.85)

                def _col_zone_has_border(col_s, col_e, threshold):
                    for col in range(max(0, col_s), min(W, col_e + 1)):
                        line = binary[max(0, y) : min(H, y + h), col]
                        if line.size > 0 and _max_run(line) / h >= threshold:
                            return True
                    return False

                has_left  = _col_zone_has_border(left_start, left_end, border_run_ratio)
                has_right = _col_zone_has_border(right_start, right_end, border_run_ratio)
                if has_left and has_right:
                    result.append(c)

        return result

    def filter_junction_dots(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        bbox_margin: int = 3,
        min_blob_area: int = 15,
        max_blob_ar: float = 2.5,
        min_blob_fill: float = 0.40,
    ) -> List[dict]:
        """Reject candidates that contain a junction dot inside their bounding box.

        Circuit junction nodes carry a small filled circle at wire crossings.
        When NCC matches a junction-node region, that dot appears as a compact
        dark blob inside the detected bbox.  Real component symbols (resistors)
        have only thin-line structure (rectangle outline, wire leads) — no
        compact filled blobs.

        Detection: find connected components inside the bbox (after skipping the
        border-line strip) and check for any component that is:
          - large enough to be a dot  (area ≥ min_blob_area)
          - roughly equidimensional   (aspect ratio ≤ max_blob_ar)
          - densely filled            (area / bounding-rect ≥ min_blob_fill)

        Args:
            bbox_margin:   Pixels to skip from each edge of the bbox before
                           looking for blobs (avoids the component border lines).
            min_blob_area: Minimum connected-component area in pixels.
            max_blob_ar:   Maximum width/height ratio (or inverse) of the blob
                           bounding rect; keeps roughly circular shapes only.
            min_blob_fill: Minimum fill ratio (area / bounding-rect area);
                           rejects elongated or sparse shapes.
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            m = bbox_margin
            y0 = max(0, y + m); y1_c = min(H, y + h - m)
            x0 = max(0, x + m); x1_c = min(W, x + w - m)
            if y1_c <= y0 or x1_c <= x0:
                result.append(c)
                continue

            crop = binary[y0:y1_c, x0:x1_c]
            n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(
                crop, connectivity=8
            )

            has_dot = False
            for i in range(1, n_labels):
                area = int(stats[i, cv2.CC_STAT_AREA])
                cw   = int(stats[i, cv2.CC_STAT_WIDTH])
                ch   = int(stats[i, cv2.CC_STAT_HEIGHT])
                if area < min_blob_area or cw < 1 or ch < 1:
                    continue
                ar   = cw / ch
                fill = area / (cw * ch)
                if (1.0 / max_blob_ar) <= ar <= max_blob_ar and fill >= min_blob_fill:
                    has_dot = True
                    break

            if not has_dot:
                result.append(c)

        return result

    def filter_rect_integrity(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        border_run_ratio: float = 0.50,
        top_zone: tuple = (0.10, 0.40),
        bot_zone: tuple = (0.60, 0.92),
        side_run_min: int = 2,
        dino_bypass_threshold: float = 0.89,
    ) -> List[dict]:
        """Reject candidates that lack the basic rectangular structure of a component.

        A well-formed component symbol (resistor) has symmetric rectangular borders:
        both a top horizontal line and a bottom horizontal line.  Two degenerate
        artefact patterns are rejected:

        1. *Only-top artifact*: a single horizontal border in the top zone with NO
           matching border in the bottom zone.  This catches L-junction FPs whose
           top-line matches the template's top border but whose bottom is absent.

        2. *Empty-bus artifact*: a single horizontal border in the bottom zone with
           ZERO dark side-line content in the rows above it.  A real component
           (even one that is partially cropped by the bbox) still shows its left and
           right vertical side lines (≥ side_run_min dark pixels in a row).  A bare
           bus-wire section has nothing above the horizontal line.

        Candidates with two visible borders, or with one border and visible side
        lines above, are kept.  Vertical candidates (rotated ~90°) are skipped.

        High-confidence DINOv2 bypass: candidates with dino_score ≥
        dino_bypass_threshold pass unconditionally — at that similarity level the
        semantic match overrides the structural heuristic.

        Args:
            border_run_ratio:       Minimum fraction of bbox width for a row to count
                                    as a "border" (significant horizontal dark run).
            top_zone:               (lo, hi) fractions of bbox height for the top zone.
            bot_zone:               (lo, hi) fractions of bbox height for the bottom zone.
            side_run_min:           Minimum dark-run length in a row above the detected
                                    bottom border for the component side-lines to be
                                    considered present.
            dino_bypass_threshold:  DINOv2 cosine score above which the structural
                                    check is skipped entirely.
        """
        H, W = drawing.shape[:2]
        binary = (drawing < 128).astype(np.uint8)

        def _max_run(arr: np.ndarray) -> int:
            if not arr.any():
                return 0
            best = cur = 0
            for v in arr:
                if v:
                    cur += 1
                    best = max(best, cur)
                else:
                    cur = 0
            return best

        result = []
        for c in candidates:
            if c.get("dino_score", 0.0) >= dino_bypass_threshold:
                result.append(c)
                continue

            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            angle = c.get("angle", 0)
            if 70 <= abs(angle) <= 110:
                result.append(c)
                continue

            top_lo = y + int(h * top_zone[0])
            top_hi = y + int(h * top_zone[1])
            bot_lo = y + int(h * bot_zone[0])
            bot_hi = y + int(h * bot_zone[1])

            has_top = False
            has_bot = False
            for row in range(max(0, top_lo), min(H, top_hi + 1)):
                line = binary[row, max(0, x) : min(W, x + w)]
                if line.size > 0 and _max_run(line) / w >= border_run_ratio:
                    has_top = True
                    break
            for row in range(max(0, bot_lo), min(H, bot_hi + 1)):
                line = binary[row, max(0, x) : min(W, x + w)]
                if line.size > 0 and _max_run(line) / w >= border_run_ratio:
                    has_bot = True
                    break

            if has_top and has_bot:
                result.append(c)
                continue

            if has_top and not has_bot:
                # Only-top artifact: top border with no matching bottom → reject
                continue

            if has_bot and not has_top:
                # Bottom border only — check for side lines above it.
                # Find the topmost row of the bottom border group.
                border_row = bot_lo
                for row in range(max(0, bot_lo), min(H, bot_hi + 1)):
                    line = binary[row, max(0, x) : min(W, x + w)]
                    if line.size > 0 and _max_run(line) / w >= border_run_ratio:
                        border_row = row
                        break
                # Check 1: side lines above the bottom border
                has_sides = False
                for row in range(max(0, y), border_row):
                    line = binary[row, max(0, x) : min(W, x + w)]
                    if _max_run(line) >= side_run_min:
                        has_sides = True
                        break
                if has_sides:
                    result.append(c)
                # else: empty-bus artifact → reject
                continue

            # No border found in either zone → likely too small or unusual → keep
            result.append(c)

        return result

    def filter_chamfer_shape(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        template: np.ndarray,
        max_chamfer: float = 3.0,
        canny_lo: int = 30,
        canny_hi: int = 100,
    ) -> List[dict]:
        """Reject candidates whose bbox region does not match the template's edge structure.

        Chamfer distance measures how well the template's edge skeleton aligns with
        the drawing region's edge skeleton.  It is specifically suited to binary
        line-art drawings because it does not depend on intensity values — only on
        edge placement.

        For each candidate:
          1. Resize (and optionally rotate) the template to match the bbox dimensions.
          2. Extract Canny edges from both the template and the drawing region.
          3. Compute the distance transform of the drawing edges (each pixel stores
             its distance to the nearest edge).
          4. Sample the distance transform at every template-edge pixel location.
          5. The mean distance is the Chamfer score.  Low score = good structural match.

        Real components (IEC rectangle) have Chamfer ≈ 0.7–2.0 at the scales used.
        Wire junctions, L-corners, and other FPs yield Chamfer > 5.0 because their
        edge structure is fundamentally different from the full rectangle + lead template.

        Args:
            max_chamfer:  Mean pixel distance threshold.  Candidates above this are
                          rejected as structural FPs.
            canny_lo/hi:  Canny edge thresholds (applied to both template and region).
        """
        H, W = drawing.shape[:2]
        draw_gray = drawing if drawing.ndim == 2 else cv2.cvtColor(drawing, cv2.COLOR_BGR2GRAY)
        draw_gray = draw_gray.astype(np.uint8)
        tmpl_gray = template if template.ndim == 2 else cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
        tmpl_gray = tmpl_gray.astype(np.uint8)

        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            angle = c.get("angle", 0)
            y1 = max(0, y); y2 = min(H, y + h)
            x1 = max(0, x); x2 = min(W, x + w)
            region = draw_gray[y1:y2, x1:x2]
            rh, rw = region.shape[:2]
            if rh < 4 or rw < 4:
                result.append(c)
                continue

            # Rotate template before resizing so the aspect ratio matches the bbox
            is_vert = 70 <= abs(angle) <= 110
            if is_vert:
                tmpl_s = cv2.resize(
                    cv2.rotate(tmpl_gray, cv2.ROTATE_90_CLOCKWISE), (rw, rh)
                )
            else:
                tmpl_s = cv2.resize(tmpl_gray, (rw, rh))

            tmpl_edges  = cv2.Canny(tmpl_s, canny_lo, canny_hi)
            region_edges = cv2.Canny(region, canny_lo, canny_hi)

            # Bidirectional Chamfer: average of template→region and region→template.
            # Using only template→region inflates the score when the bbox includes
            # wire-lead stubs that have no counterpart in the template (e.g. R5/R9).
            # The symmetric mean is more robust to minor structural asymmetries.
            dt_r = cv2.distanceTransform(
                (255 - region_edges).astype(np.uint8), cv2.DIST_L2, 5
            )
            pts = np.where(tmpl_edges > 0)
            if len(pts[0]) == 0:
                result.append(c)
                continue
            rows = np.clip(pts[0], 0, rh - 1)
            cols = np.clip(pts[1], 0, rw - 1)
            t2r = float(np.mean(dt_r[rows, cols]))

            dt_t = cv2.distanceTransform(
                (255 - tmpl_edges).astype(np.uint8), cv2.DIST_L2, 5
            )
            pts2 = np.where(region_edges > 0)
            if len(pts2[0]) == 0:
                chamfer_dist = t2r
            else:
                rows2 = np.clip(pts2[0], 0, rh - 1)
                cols2 = np.clip(pts2[1], 0, rw - 1)
                r2t = float(np.mean(dt_t[rows2, cols2]))
                chamfer_dist = (t2r + r2t) / 2

            c_out = dict(c)
            c_out["chamfer_dist"] = round(chamfer_dist, 3)
            if chamfer_dist <= max_chamfer:
                result.append(c_out)

        return result

    def filter_confidence_gap(
        self,
        candidates: List[dict],
        min_gap: float = 0.075,
        min_cluster_size: int = 2,
    ) -> List[dict]:
        """Remove low-confidence cluster when a bimodal confidence distribution is detected.

        Real pattern instances (TPs) cluster at high confidence (0.75–0.90) because
        both NCC and DINOv2 scores are high. Structurally-similar FPs (inductors,
        transistors, op-amps) cluster at low confidence (0.58–0.67) — they barely
        pass DINOv2 but have low NCC. A clear gap separates the two clusters.

        When all candidates are real TPs (unimodal distribution, no gap ≥ min_gap),
        all candidates are returned unchanged. This makes the filter adaptive and
        harmless for drawings where the pipeline is already accurate.

        Args:
            min_gap: Minimum confidence difference between adjacent sorted scores to
                     treat as a cluster boundary. 0.08 separates the 0.75/0.67 gap
                     seen in complex drawings while ignoring the ~0.02–0.04 natural
                     spread within a cluster of real TPs.
            min_cluster_size: Minimum number of candidates required in EACH cluster
                              for the gap to be considered meaningful.
        """
        if len(candidates) < min_cluster_size * 2 + 1:
            return candidates

        confs = sorted([c.get("confidence", 0.0) for c in candidates], reverse=True)

        best_gap = 0.0
        best_threshold = None
        for i in range(len(confs) - 1):
            gap = confs[i] - confs[i + 1]
            above = i + 1
            below = len(confs) - above
            if gap > best_gap and above >= min_cluster_size and below >= min_cluster_size:
                best_gap = gap
                best_threshold = (confs[i] + confs[i + 1]) / 2.0

        if best_gap >= min_gap and best_threshold is not None:
            return [c for c in candidates if c.get("confidence", 0.0) >= best_threshold]
        return candidates

    def filter_profile_similarity(
        self,
        candidates: List[dict],
        drawing_proc: np.ndarray,
        pattern_proc: np.ndarray,
        min_sim: float = 0.35,
    ) -> List[dict]:
        """Keep candidates whose 1D edge projection correlates with the template's.

        For any template, the column-sum of its Canny edge map forms a characteristic
        1D profile (e.g. evenly-spaced peaks for a zigzag, smooth humps for
        inductors, asymmetric for transistors/op-amps). Candidates whose region
        profile does not correlate are structural FPs.

        Uses the **tight content bounding box** of the template (strips surrounding
        whitespace) so the profile represents only the symbol, not padding.
        Rotation-invariant: 90°-rotated candidates are un-rotated before comparison.
        """
        # Extract tight content bounding box from template to strip whitespace padding.
        # Without this, a large template with a small symbol produces a profile that
        # is dominated by empty columns and won't correlate with drawing crops.
        _dark = pattern_proc < 128
        _rows_any = np.any(_dark, axis=1)
        _cols_any = np.any(_dark, axis=0)
        if _rows_any.any() and _cols_any.any():
            _rmin, _rmax = int(np.where(_rows_any)[0][0]), int(np.where(_rows_any)[0][-1])
            _cmin, _cmax = int(np.where(_cols_any)[0][0]), int(np.where(_cols_any)[0][-1])
            _tmpl = pattern_proc[_rmin:_rmax + 1, _cmin:_cmax + 1]
        else:
            _tmpl = pattern_proc

        th, tw = _tmpl.shape[:2]
        if tw < 4 or th < 4:
            return candidates  # template too small for meaningful profile

        tmpl_edges = cv2.Canny(_tmpl.astype(np.uint8), 50, 150).astype(float)
        tmpl_h = np.sum(tmpl_edges, axis=0)   # horizontal projection (column sums)
        tmpl_v = np.sum(tmpl_edges, axis=1)   # vertical projection (row sums)

        def _safe_corr(a: np.ndarray, b: np.ndarray) -> float:
            sa, sb = float(np.std(a)), float(np.std(b))
            if sa < 1e-6 or sb < 1e-6:
                return 0.0
            return float(np.clip(np.corrcoef(a, b)[0, 1], -1.0, 1.0))

        drwH, drwW = drawing_proc.shape[:2]
        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            region = drawing_proc[max(0, y):min(drwH, y + h), max(0, x):min(drwW, x + w)]
            if region.shape[0] < 4 or region.shape[1] < 4:
                result.append(c)
                continue

            # Rotate tall (90°-rotated) regions to horizontal for comparison
            angle = c.get("angle", 0)
            if 70 <= abs(angle) <= 110 and region.shape[0] > region.shape[1]:
                region = cv2.rotate(region, cv2.ROTATE_90_COUNTERCLOCKWISE)

            reg_rs = cv2.resize(region.astype(np.uint8), (tw, th), interpolation=cv2.INTER_AREA)
            reg_edges = cv2.Canny(reg_rs, 50, 150).astype(float)
            reg_h = np.sum(reg_edges, axis=0)
            reg_v = np.sum(reg_edges, axis=1)

            # Use the better of horizontal and vertical correlation (handles partial
            # rotation inaccuracy where the angle metadata may be off by a few degrees)
            sim_h = _safe_corr(tmpl_h, reg_h)
            sim_v = _safe_corr(tmpl_v, reg_v)
            sim = max(sim_h, sim_v)

            c_out = dict(c)
            c_out["profile_sim"] = round(float(sim), 3)
            if sim >= min_sim:
                result.append(c_out)

        return result

    def filter_neighborhood_complexity(
        self,
        candidates: List[dict],
        drawing: np.ndarray,
        expand_ratio: float = 1.0,
        max_edge_density: float = 0.05,
    ) -> List[dict]:
        """Remove candidates whose surrounding ring has too many Canny edges.

        Standalone components (resistors) sit in clean white space; components
        embedded inside complex symbols (bridge rectifiers) have many adjacent
        edges in the ring around the bounding box.

        Args:
            expand_ratio: Width of the outer ring in units of the bbox dimensions.
            max_edge_density: Canny-edge fraction in the ring above which the
                              candidate is rejected.
        """
        H, W = drawing.shape[:2]
        gray = drawing if drawing.ndim == 2 else cv2.cvtColor(drawing, cv2.COLOR_BGR2GRAY)
        edges = cv2.Canny(gray.astype(np.uint8), 30, 100)
        result = []
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            exp_x = max(5, int(w * expand_ratio))
            exp_y = max(5, int(h * expand_ratio))
            x1 = max(0, x - exp_x)
            y1 = max(0, y - exp_y)
            x2 = min(W, x + w + exp_x)
            y2 = min(H, y + h + exp_y)

            outer = edges[y1:y2, x1:x2].copy()
            # Blank the inner detection box so we measure only the surrounding ring
            inner_y1 = max(0, y - y1)
            inner_x1 = max(0, x - x1)
            inner_y2 = inner_y1 + h
            inner_x2 = inner_x1 + w
            outer[inner_y1:inner_y2, inner_x1:inner_x2] = 0

            outer_pixels = outer.size - w * h
            if outer_pixels <= 0:
                result.append(c)
                continue

            edge_density = float(np.count_nonzero(outer)) / outer_pixels
            if edge_density <= max_edge_density:
                result.append(c)
        return result

    def filter_output_bubble(
        self,
        candidates: List[dict],
        drawing_gray: np.ndarray,
        pattern_gray: np.ndarray,
        min_blob_area: int = 8,
        max_blob_area: int = 800,
        min_blob_fill: float = 0.30,
    ) -> List[dict]:
        """Distinguish gates that differ only by an output bubble (e.g. XOR vs XNOR).

        Probes the output side (right-center) of the template and each candidate.
        If the template has NO bubble, candidates WITH a bubble are rejected.
        If the template HAS a bubble, candidates WITHOUT a bubble are rejected.
        Bidirectional: works for both XOR-as-query and XNOR-as-query cases.
        """
        ph, pw = pattern_gray.shape[:2]
        t_has_bubble = self._probe_output_bubble(
            pattern_gray, 0, 0, pw, ph, pw, ph,
            min_blob_area, max_blob_area, min_blob_fill,
        )
        result = []
        H, W = drawing_gray.shape[:2]
        for c in candidates:
            x, y, w, h = c["x"], c["y"], c["w"], c["h"]
            c_has_bubble = self._probe_output_bubble(
                drawing_gray, x, y, w, h, W, H,
                min_blob_area, max_blob_area, min_blob_fill,
            )
            if t_has_bubble == c_has_bubble:
                result.append(c)
        return result

    def _probe_output_bubble(
        self,
        img: np.ndarray,
        x: int,
        y: int,
        w: int,
        h: int,
        W: int,
        H: int,
        min_blob_area: int = 8,
        max_blob_area: int = 800,
        min_blob_fill: float = 0.30,
    ) -> bool:
        """Return True if a compact filled blob exists at the right-center output side."""
        px1 = x + int(w * 0.75)
        px2 = min(W, x + w + int(w * 0.15))
        py1 = y + int(h * 0.38)
        py2 = y + int(h * 0.62)
        if px2 <= px1 or py2 <= py1:
            return False
        probe = img[py1:py2, px1:px2]
        inv = (probe < 128).astype(np.uint8) * 255
        n_labels, _, stats, _ = cv2.connectedComponentsWithStats(inv)
        for i in range(1, n_labels):
            area = int(stats[i, cv2.CC_STAT_AREA])
            bw = int(stats[i, cv2.CC_STAT_WIDTH])
            bh = int(stats[i, cv2.CC_STAT_HEIGHT])
            if bh > 0 and bw > 0:
                ar = bw / bh
                fill = area / (bw * bh)
                if (min_blob_area <= area <= max_blob_area
                        and 0.25 <= ar <= 4.0 and fill >= min_blob_fill):
                    return True
        return False

    @staticmethod
    def _overlap_ratio(a: dict, b: dict) -> float:
        """Max of IoU and containment ratio (intersection / area of smaller box).
        Handles multi-scale duplicates: a small box fully inside a large one gets merged.
        """
        ax1, ay1 = a["x"], a["y"]
        ax2, ay2 = ax1 + a["w"], ay1 + a["h"]
        bx1, by1 = b["x"], b["y"]
        bx2, by2 = bx1 + b["w"], by1 + b["h"]

        inter_x1 = max(ax1, bx1)
        inter_y1 = max(ay1, by1)
        inter_x2 = min(ax2, bx2)
        inter_y2 = min(ay2, by2)

        inter_w = max(0, inter_x2 - inter_x1)
        inter_h = max(0, inter_y2 - inter_y1)
        inter_area = inter_w * inter_h

        if inter_area == 0:
            return 0.0

        area_a = a["w"] * a["h"]
        area_b = b["w"] * b["h"]
        union_area = area_a + area_b - inter_area
        min_area = min(area_a, area_b)

        iou = inter_area / union_area if union_area > 0 else 0.0
        containment = inter_area / min_area if min_area > 0 else 0.0
        return max(iou, containment)

    @staticmethod
    def _is_rgb(img: np.ndarray) -> bool:
        """Heuristic: check if a 3-channel image is likely RGB (not BGR)."""
        # Cannot determine with certainty; assume caller passes BGR from OpenCV
        return False

    @staticmethod
    def _iou(a: dict, b: dict) -> float:
        """IoU between two candidate bbox dicts."""
        ax1, ay1 = a["x"], a["y"]
        ax2, ay2 = ax1 + a["w"], ay1 + a["h"]
        bx1, by1 = b["x"], b["y"]
        bx2, by2 = bx1 + b["w"], by1 + b["h"]

        inter_x1 = max(ax1, bx1)
        inter_y1 = max(ay1, by1)
        inter_x2 = min(ax2, bx2)
        inter_y2 = min(ay2, by2)

        inter_w = max(0, inter_x2 - inter_x1)
        inter_h = max(0, inter_y2 - inter_y1)
        inter_area = inter_w * inter_h

        area_a = a["w"] * a["h"]
        area_b = b["w"] * b["h"]
        union_area = area_a + area_b - inter_area

        if union_area <= 0:
            return 0.0
        return inter_area / union_area

    # ------------------------------------------------------------------
    # HOG-based self-supervised prototype filter
    # ------------------------------------------------------------------

    def _hog_feature(
        self,
        drawing: np.ndarray,
        c: dict,
        target_w: int = 64,
        target_h: int = 32,
        n_bins: int = 9,
    ) -> np.ndarray:
        """Compute a gradient-orientation histogram for a candidate region.

        The region is always normalised to landscape orientation (width > height)
        before feature extraction so horizontal and vertical resistors produce
        the same feature vector.

        Returns a unit-normalised float32 array of length `n_bins`.
        """
        H, W = drawing.shape[:2]
        x, y, w, h = c["x"], c["y"], c["w"], c["h"]
        region = drawing[max(0, y):min(H, y + h), max(0, x):min(W, x + w)]
        if region.size == 0:
            return np.zeros(n_bins, dtype=np.float32)

        # Normalise to horizontal orientation
        if region.shape[0] > region.shape[1]:
            region = cv2.rotate(region, cv2.ROTATE_90_COUNTERCLOCKWISE)

        region_u8 = region.astype(np.uint8)
        region_rs = cv2.resize(region_u8, (target_w, target_h), interpolation=cv2.INTER_AREA)

        # Sobel gradients
        gx = cv2.Sobel(region_rs.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3)
        gy = cv2.Sobel(region_rs.astype(np.float32), cv2.CV_32F, 0, 1, ksize=3)
        mag = np.sqrt(gx * gx + gy * gy)
        ang = np.arctan2(gy, gx) * (180.0 / np.pi)  # -180 to 180

        # Weighted orientation histogram (unsigned, 0-180)
        ang_unsigned = ang % 180.0
        mask = mag > 5.0
        hist, _ = np.histogram(
            ang_unsigned[mask], bins=n_bins, range=(0.0, 180.0),
            weights=mag[mask]
        )
        norm = float(np.linalg.norm(hist))
        if norm > 1e-6:
            hist = hist / norm
        return hist.astype(np.float32)

    def filter_hog_prototype(
        self,
        high_conf: List[dict],
        borderline: List[dict],
        drawing: np.ndarray,
        min_sim: float = 0.72,
    ) -> List[dict]:
        """Filter borderline candidates using the HOG prototype of confirmed TPs.

        **Algorithm (Self-Supervised HOG Prototype):**

        1. Extract gradient-orientation histogram (HOG) for each high-confidence
           detection — these are the confirmed True Positives for this drawing.
        2. Compute their mean = the "prototype" HOG for this symbol in this
           specific drawing style and scale.
        3. Score each borderline candidate by cosine similarity to the prototype.
        4. Accept only those above `min_sim`.

        Why HOG works here:
        - Resistors (ANSI zigzag): dominant gradients at +/-45 deg (diagonal strokes)
        - Inductors (coil): dominant gradients at 0 deg and 90 deg (arcs + baselines)
        - Transistors: mixed asymmetric gradients
        - Batteries/sources: mostly 0/90 deg gradients (rectangular)

        The prototype captures the specific drawing style's gradient fingerprint;
        FPs with a different gradient distribution are rejected.

        Requires >= 3 high-confidence examples to form a reliable prototype.
        """
        if len(high_conf) < 3 or not borderline:
            return high_conf + borderline

        # Build prototype
        hc_feats = np.array([self._hog_feature(drawing, c) for c in high_conf])
        prototype = hc_feats.mean(axis=0)
        proto_norm = float(np.linalg.norm(prototype))
        if proto_norm < 1e-6:
            return high_conf + borderline
        prototype_unit = prototype / proto_norm

        # Score borderline candidates
        accepted, rejected = [], []
        for c in borderline:
            feat = self._hog_feature(drawing, c)
            feat_norm = float(np.linalg.norm(feat))
            sim = float(np.dot(prototype_unit, feat / (feat_norm + 1e-8)))
            c_out = dict(c)
            c_out["hog_sim"] = round(sim, 3)
            if sim >= min_sim:
                accepted.append(c_out)
            else:
                rejected.append(c_out)

        if rejected:
            print(
                f"[Postprocessor] HOG prototype: {len(borderline)} border -> "
                f"{len(accepted)} accepted, {len(rejected)} rejected "
                f"(sim_threshold={min_sim})"
            )
        return high_conf + accepted