Duy commited on
Commit
beec8a6
·
1 Parent(s): 87bc2df

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.

Files changed (2) hide show
  1. src/pipeline.py +36 -6
  2. src/postprocessor.py +68 -0
src/pipeline.py CHANGED
@@ -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
- # Scales cover [0.70–1.35] so rotated components at their natural drawing
168
- # size are found (pass 2 relaxed scales stop at 0.85 minimum).
169
- # Same relaxed NCC threshold as pass 2 BR components score ~0.28–0.40
170
- # even at their correct scale/rotation, so 0.45 misses them entirely.
171
- _complex_scales = [0.70, 0.85, 1.0, 1.1, 1.2, 1.35]
 
 
 
 
 
 
 
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
- verified = verified + verified_3a + verified_3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
src/postprocessor.py CHANGED
@@ -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).