feat: XOR/XNOR gate discrimination via output-bubble filter
Browse files- Add filter_output_bubble() to Postprocessor: probes the right-center
region of template and candidates for a compact filled blob (bubble).
Bidirectional: rejects XNOR when XOR pattern given, and vice versa.
- Auto-detect scale range in complex template micro passes: small scales
[0.30-0.60] added only when standard passes find 0 NCC candidates,
indicating drawing instances are much smaller than the template (e.g.
gate symbols at ~40% template size). Prevents extra FPs for BR/resistors.
- Bubble filter gated on _no_std_candidates AND gate-like AR (0.25-2.0)
to avoid false rejection for bridge rectifiers and elongated templates.
Result: XOR gate detection finds exactly 5 (4 circuit + 1 notes icon),
XNOR gates correctly excluded. All 10 unit tests pass. Existing cases
(resistor x10, BR x4, example2 x4) unaffected.
- src/pipeline.py +36 -6
- src/postprocessor.py +68 -0
|
@@ -164,11 +164,18 @@ class PatternDetectionPipeline:
|
|
| 164 |
# Complex templates: separate near-0° (3a) and near-90° (3b) micro passes.
|
| 165 |
# Standard passes 1+2 only sweep ±10° around 0°, so pass 3b is the only
|
| 166 |
# path for 90°-rotated components (e.g. bridge rectifiers mounted vertically).
|
| 167 |
-
#
|
| 168 |
-
#
|
| 169 |
-
#
|
| 170 |
-
#
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
self.ncc_matcher.ncc_threshold = 0.28
|
| 173 |
|
| 174 |
# Sub-pass 3a: near-0° at smaller/wider scales than standard pass
|
|
@@ -191,7 +198,30 @@ class PatternDetectionPipeline:
|
|
| 191 |
) if cands_3b else []
|
| 192 |
print(f"[Pipeline] Pass 3b (micro 90°): {len(cands_3b)} cands -> {len(verified_3b)} verified")
|
| 193 |
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
self.ncc_matcher.scales = _saved_scales
|
| 197 |
self.ncc_matcher.ncc_threshold = _saved_ncc
|
|
|
|
| 164 |
# Complex templates: separate near-0° (3a) and near-90° (3b) micro passes.
|
| 165 |
# Standard passes 1+2 only sweep ±10° around 0°, so pass 3b is the only
|
| 166 |
# path for 90°-rotated components (e.g. bridge rectifiers mounted vertically).
|
| 167 |
+
#
|
| 168 |
+
# Small scales (0.30–0.60) are ONLY added when standard passes 1+2 found
|
| 169 |
+
# zero NCC candidates — this indicates the drawing instances are much
|
| 170 |
+
# smaller than the template (e.g. gate symbols at ~40% template size).
|
| 171 |
+
# When passes 1+2 already found candidates, standard scale range suffices
|
| 172 |
+
# (e.g. bridge rectifiers at 70–135% template size), and adding small
|
| 173 |
+
# scales would introduce false-positive small-feature matches.
|
| 174 |
+
_no_std_candidates = len(all_candidates) == 0
|
| 175 |
+
if _no_std_candidates:
|
| 176 |
+
_complex_scales = [0.30, 0.35, 0.40, 0.45, 0.50, 0.60, 0.70, 0.85, 1.0, 1.1, 1.2, 1.35]
|
| 177 |
+
else:
|
| 178 |
+
_complex_scales = [0.70, 0.85, 1.0, 1.1, 1.2, 1.35]
|
| 179 |
self.ncc_matcher.ncc_threshold = 0.28
|
| 180 |
|
| 181 |
# Sub-pass 3a: near-0° at smaller/wider scales than standard pass
|
|
|
|
| 198 |
) if cands_3b else []
|
| 199 |
print(f"[Pipeline] Pass 3b (micro 90°): {len(cands_3b)} cands -> {len(verified_3b)} verified")
|
| 200 |
|
| 201 |
+
all_complex = verified + verified_3a + verified_3b
|
| 202 |
+
|
| 203 |
+
# Output-bubble filter: distinguishes gates that differ only by a
|
| 204 |
+
# small filled circle at the output terminal (e.g. XOR vs XNOR).
|
| 205 |
+
#
|
| 206 |
+
# Applied only when BOTH conditions hold:
|
| 207 |
+
# 1. Small scales were used (_no_std_candidates=True): the template is
|
| 208 |
+
# a gate-like symbol much smaller than the drawing instances, meaning
|
| 209 |
+
# the output-bubble probe is geometrically meaningful.
|
| 210 |
+
# 2. Template has gate-like AR (0.25–2.0): very elongated templates
|
| 211 |
+
# (AR > 2.0, e.g. resistors) or very wide templates have no clear
|
| 212 |
+
# directional "output" side, so the right-center probe is unreliable.
|
| 213 |
+
#
|
| 214 |
+
# This avoids false rejection for bridge rectifiers (AR ≈ 1.0 but
|
| 215 |
+
# passes 1+2 find standard-scale candidates) and resistors (AR > 2.0).
|
| 216 |
+
_is_gate_like = 0.25 <= _tmpl_ar <= 2.0
|
| 217 |
+
if _no_std_candidates and _is_gate_like:
|
| 218 |
+
before_bubble = len(all_complex)
|
| 219 |
+
all_complex = self.postprocessor.filter_output_bubble(
|
| 220 |
+
all_complex, drawing_proc, pattern_proc
|
| 221 |
+
)
|
| 222 |
+
if len(all_complex) != before_bubble:
|
| 223 |
+
print(f"[Pipeline] Bubble filter: {before_bubble} -> {len(all_complex)}")
|
| 224 |
+
verified = all_complex
|
| 225 |
|
| 226 |
self.ncc_matcher.scales = _saved_scales
|
| 227 |
self.ncc_matcher.ncc_threshold = _saved_ncc
|
|
@@ -913,6 +913,74 @@ class Postprocessor:
|
|
| 913 |
result.append(c)
|
| 914 |
return result
|
| 915 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
@staticmethod
|
| 917 |
def _overlap_ratio(a: dict, b: dict) -> float:
|
| 918 |
"""Max of IoU and containment ratio (intersection / area of smaller box).
|
|
|
|
| 913 |
result.append(c)
|
| 914 |
return result
|
| 915 |
|
| 916 |
+
def filter_output_bubble(
|
| 917 |
+
self,
|
| 918 |
+
candidates: List[dict],
|
| 919 |
+
drawing_gray: np.ndarray,
|
| 920 |
+
pattern_gray: np.ndarray,
|
| 921 |
+
min_blob_area: int = 8,
|
| 922 |
+
max_blob_area: int = 800,
|
| 923 |
+
min_blob_fill: float = 0.30,
|
| 924 |
+
) -> List[dict]:
|
| 925 |
+
"""Distinguish gates that differ only by an output bubble (e.g. XOR vs XNOR).
|
| 926 |
+
|
| 927 |
+
Probes the output side (right-center) of the template and each candidate.
|
| 928 |
+
If the template has NO bubble, candidates WITH a bubble are rejected.
|
| 929 |
+
If the template HAS a bubble, candidates WITHOUT a bubble are rejected.
|
| 930 |
+
Bidirectional: works for both XOR-as-query and XNOR-as-query cases.
|
| 931 |
+
"""
|
| 932 |
+
ph, pw = pattern_gray.shape[:2]
|
| 933 |
+
t_has_bubble = self._probe_output_bubble(
|
| 934 |
+
pattern_gray, 0, 0, pw, ph, pw, ph,
|
| 935 |
+
min_blob_area, max_blob_area, min_blob_fill,
|
| 936 |
+
)
|
| 937 |
+
result = []
|
| 938 |
+
H, W = drawing_gray.shape[:2]
|
| 939 |
+
for c in candidates:
|
| 940 |
+
x, y, w, h = c["x"], c["y"], c["w"], c["h"]
|
| 941 |
+
c_has_bubble = self._probe_output_bubble(
|
| 942 |
+
drawing_gray, x, y, w, h, W, H,
|
| 943 |
+
min_blob_area, max_blob_area, min_blob_fill,
|
| 944 |
+
)
|
| 945 |
+
if t_has_bubble == c_has_bubble:
|
| 946 |
+
result.append(c)
|
| 947 |
+
return result
|
| 948 |
+
|
| 949 |
+
def _probe_output_bubble(
|
| 950 |
+
self,
|
| 951 |
+
img: np.ndarray,
|
| 952 |
+
x: int,
|
| 953 |
+
y: int,
|
| 954 |
+
w: int,
|
| 955 |
+
h: int,
|
| 956 |
+
W: int,
|
| 957 |
+
H: int,
|
| 958 |
+
min_blob_area: int = 8,
|
| 959 |
+
max_blob_area: int = 800,
|
| 960 |
+
min_blob_fill: float = 0.30,
|
| 961 |
+
) -> bool:
|
| 962 |
+
"""Return True if a compact filled blob exists at the right-center output side."""
|
| 963 |
+
px1 = x + int(w * 0.75)
|
| 964 |
+
px2 = min(W, x + w + int(w * 0.15))
|
| 965 |
+
py1 = y + int(h * 0.38)
|
| 966 |
+
py2 = y + int(h * 0.62)
|
| 967 |
+
if px2 <= px1 or py2 <= py1:
|
| 968 |
+
return False
|
| 969 |
+
probe = img[py1:py2, px1:px2]
|
| 970 |
+
inv = (probe < 128).astype(np.uint8) * 255
|
| 971 |
+
n_labels, _, stats, _ = cv2.connectedComponentsWithStats(inv)
|
| 972 |
+
for i in range(1, n_labels):
|
| 973 |
+
area = int(stats[i, cv2.CC_STAT_AREA])
|
| 974 |
+
bw = int(stats[i, cv2.CC_STAT_WIDTH])
|
| 975 |
+
bh = int(stats[i, cv2.CC_STAT_HEIGHT])
|
| 976 |
+
if bh > 0 and bw > 0:
|
| 977 |
+
ar = bw / bh
|
| 978 |
+
fill = area / (bw * bh)
|
| 979 |
+
if (min_blob_area <= area <= max_blob_area
|
| 980 |
+
and 0.25 <= ar <= 4.0 and fill >= min_blob_fill):
|
| 981 |
+
return True
|
| 982 |
+
return False
|
| 983 |
+
|
| 984 |
@staticmethod
|
| 985 |
def _overlap_ratio(a: dict, b: dict) -> float:
|
| 986 |
"""Max of IoU and containment ratio (intersection / area of smaller box).
|