Skip to content

Commit 9e02ce9

Browse files
committed
fix(recognition): guard ambiguous foreground labels
1 parent 85222c5 commit 9e02ce9

3 files changed

Lines changed: 159 additions & 14 deletions

File tree

app/core/multi_object_dispatch.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,41 @@ def _foreground_objects_clearly_separate(
212212
def _cluster_foreground_boxes(
213213
boxes: tuple[tuple[int, int, int, int], ...],
214214
) -> tuple[tuple[int, int, int, int], ...]:
215-
clusters: list[list[tuple[int, int, int, int]]] = []
216-
for box in boxes:
217-
for cluster in clusters:
218-
if any(_foreground_fragments_same_object(box, item) for item in cluster):
219-
cluster.append(box)
215+
clusters: list[list[tuple[int, int, int, int]]] = [[box] for box in boxes]
216+
changed = True
217+
while changed:
218+
changed = False
219+
for first_index in range(len(clusters)):
220+
for second_index in range(first_index + 1, len(clusters)):
221+
if _foreground_clusters_same_object(
222+
clusters[first_index],
223+
clusters[second_index],
224+
):
225+
clusters[first_index].extend(clusters[second_index])
226+
del clusters[second_index]
227+
changed = True
228+
break
229+
if changed:
220230
break
221-
else:
222-
clusters.append([box])
223231
return tuple(_union_boxes(cluster) for cluster in clusters)
224232

225233

234+
def _foreground_clusters_same_object(
235+
first: list[tuple[int, int, int, int]],
236+
second: list[tuple[int, int, int, int]],
237+
) -> bool:
238+
if any(
239+
_foreground_fragments_same_object(first_box, second_box)
240+
for first_box in first
241+
for second_box in second
242+
):
243+
return True
244+
# A long object can be split into three pieces where neither endpoint
245+
# overlaps the other directly. Re-check merged cluster hulls so a pen or
246+
# utensil stays one object after its small middle fragments are joined.
247+
return _foreground_fragments_same_object(_union_boxes(first), _union_boxes(second))
248+
249+
226250
def _foreground_fragments_same_object(
227251
first: tuple[int, int, int, int],
228252
second: tuple[int, int, int, int],

app/core/pipeline.py

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -791,23 +791,39 @@ def _multi_object_split_candidates(
791791
"""Split a merged detection into physical objects, labeling matches when possible."""
792792
recognizer = self._manual_reference_recognizer
793793
ref_cfg = self.cfg.manual_reference_recognition
794-
if len(raw) > 1:
795-
return []
796794
clusters = foreground_object_clusters(
797795
frame_bgr,
798796
roi=self.cfg.roi,
799797
min_area_ratio=self.cfg.unknown_fallback.min_area_ratio,
800798
)
801-
source = raw[0] if raw else None
802-
reference_boxes = (source.xyxy,) if source is not None else ()
799+
clusters = tuple(
800+
sorted(
801+
clusters,
802+
key=lambda box: (
803+
(box[1] + box[3]) / 2.0,
804+
(box[0] + box[2]) / 2.0,
805+
),
806+
)
807+
)
808+
reference_boxes = tuple(detection.xyxy for detection in raw)
803809
decision = evaluate_foreground_multi_object_dispatch(
804810
frame_bgr,
805811
roi=self.cfg.roi,
806812
max_objects=1,
807813
min_area_ratio=self.cfg.unknown_fallback.min_area_ratio,
808814
reference_boxes=reference_boxes,
809815
)
810-
if decision.allowed or len(clusters) < 2:
816+
if decision.allowed:
817+
return []
818+
if len(clusters) < 2:
819+
if len(raw) > 1 and clusters:
820+
raw_names = {detection.cls_name for detection in raw}
821+
if (
822+
self.cfg.unknown_fallback.class_name in raw_names
823+
and len(raw_names - {self.cfg.unknown_fallback.class_name}) >= 1
824+
):
825+
return []
826+
return self._ambiguous_foreground_split_markers(clusters[0])
811827
return []
812828

813829
if len(self._multi_object_display_hold) == len(clusters):
@@ -833,6 +849,7 @@ def _multi_object_split_candidates(
833849

834850
matches: list[Detection] = []
835851
for index, box in enumerate(clusters, start=1):
852+
source = self._model_detection_for_cluster(raw, box) if len(raw) >= len(clusters) else None
836853
match = None
837854
if recognizer is not None and ref_cfg.enabled:
838855
match = recognizer.classify(
@@ -858,6 +875,18 @@ def _multi_object_split_candidates(
858875
)
859876
)
860877
continue
878+
if source is not None:
879+
matches.append(
880+
Detection(
881+
cls_id=source.cls_id,
882+
cls_name=source.cls_name,
883+
conf=max(source.conf, 0.50),
884+
xyxy=box,
885+
source="foreground_multi_object",
886+
operator_label=source.operator_label,
887+
)
888+
)
889+
continue
861890
matches.append(
862891
Detection(
863892
cls_id=-400 - index,
@@ -869,12 +898,73 @@ def _multi_object_split_candidates(
869898
)
870899
)
871900
logger.info(
872-
"foreground split loose {} box into {}",
873-
source.cls_name if source is not None else "missing YOLO",
901+
"foreground split multi-object frame into {}",
874902
[match.cls_name for match in matches],
875903
)
876904
return matches
877905

906+
def _ambiguous_foreground_split_markers(
907+
self,
908+
box: tuple[int, int, int, int],
909+
) -> list[Detection]:
910+
"""Show ambiguous overlapping YOLO labels as two safe unknown markers."""
911+
x1, y1, x2, y2 = box
912+
width = max(2, x2 - x1)
913+
midpoint = x1 + width // 2
914+
marker_boxes = ((x1, y1, midpoint, y2), (midpoint, y1, x2, y2))
915+
return [
916+
Detection(
917+
cls_id=-450 - index,
918+
cls_name=self.cfg.unknown_fallback.class_name,
919+
conf=0.50,
920+
xyxy=marker_box,
921+
source="foreground_multi_object",
922+
operator_label=f"Vật {index} - nhãn YOLO chồng lấn",
923+
)
924+
for index, marker_box in enumerate(marker_boxes, start=1)
925+
]
926+
927+
@staticmethod
928+
def _model_detection_for_cluster(
929+
detections: list[Detection],
930+
cluster: tuple[int, int, int, int],
931+
) -> Detection | None:
932+
cx1, cy1, cx2, cy2 = cluster
933+
candidates: list[tuple[float, Detection]] = []
934+
for detection in detections:
935+
dx1, dy1, dx2, dy2 = detection.xyxy
936+
center_x = (dx1 + dx2) / 2.0
937+
center_y = (dy1 + dy2) / 2.0
938+
center_inside = cx1 <= center_x <= cx2 and cy1 <= center_y <= cy2
939+
overlaps = _boxes_overlap(detection.xyxy, cluster, iou_threshold=0.05)
940+
if not center_inside and not overlaps:
941+
continue
942+
intersection = max(0, min(dx2, cx2) - max(dx1, cx1)) * max(
943+
0,
944+
min(dy2, cy2) - max(dy1, cy1),
945+
)
946+
detection_area = max(1, max(0, dx2 - dx1) * max(0, dy2 - dy1))
947+
cluster_area = max(1, max(0, cx2 - cx1) * max(0, cy2 - cy1))
948+
detection_coverage = intersection / detection_area
949+
cluster_coverage = intersection / cluster_area
950+
size_ratio = detection_area / cluster_area
951+
similarly_sized = 0.35 <= size_ratio <= 2.25
952+
if not (
953+
center_inside
954+
and similarly_sized
955+
and detection_coverage >= 0.60
956+
and cluster_coverage >= 0.45
957+
):
958+
continue
959+
score = max(
960+
detection_coverage,
961+
cluster_coverage,
962+
) + detection.conf * 0.05
963+
candidates.append((score, detection))
964+
if not candidates:
965+
return None
966+
return max(candidates, key=lambda item: item[0])[1]
967+
878968
def _stabilize_multi_object_display(
879969
self,
880970
frame_bgr: np.ndarray,
@@ -1175,6 +1265,13 @@ def process_frame(self, frame_bgr: np.ndarray, ts: datetime):
11751265
if low_detail_empty and not tracked:
11761266
self.dispatch_status = "waiting empty tray"
11771267
return detections_for_render
1268+
if (
1269+
not self._hardware_dispatch_enabled
1270+
and tracked
1271+
and all(self._is_ambiguous_foreground_marker(t.detection) for t in tracked)
1272+
):
1273+
self.dispatch_status = "TEST OFF"
1274+
return detections_for_render
11781275
multi_class = evaluate_single_class_dispatch(
11791276
tracked,
11801277
in_roi=lambda xyxy: bool(roi_ready and self._in_roi(xyxy)),
@@ -1598,6 +1695,13 @@ def _low_confidence_dispatch_blocked(self, detection: Detection) -> bool:
15981695
threshold = max(0.30, min(float(self.cfg.model.conf_threshold), 0.45) * 0.75)
15991696
return detection.conf < threshold
16001697

1698+
@staticmethod
1699+
def _is_ambiguous_foreground_marker(detection: Detection) -> bool:
1700+
return (
1701+
detection.source == "foreground_multi_object"
1702+
and "nhãn YOLO chồng lấn" in detection.operator_label
1703+
)
1704+
16011705
def on_ack(self, track_id: int, command: str, status: str, rtt_ms):
16021706
self._dispatch_guard.complete_dispatch(track_id=track_id, now=time.monotonic())
16031707
self.dispatch_status = self._dispatch_guard.last_reason

tests/unit/test_multi_object_dispatch.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from app.core.events import Detection, TrackedDetection
77
from app.core.multi_object_dispatch import (
8+
_cluster_foreground_boxes,
89
evaluate_foreground_multi_object_dispatch,
910
evaluate_single_class_dispatch,
1011
)
@@ -325,3 +326,19 @@ def test_two_yolo_boxes_stay_blocked_even_when_foreground_merges_cleanly():
325326
assert decision.allowed is False
326327
assert decision.object_count == 2
327328
assert decision.reference_count == 2
329+
330+
331+
def test_spoon_and_three_piece_pen_clusters_as_two_objects():
332+
boxes = (
333+
(0, 45, 583, 284),
334+
(410, 349, 635, 408),
335+
(122, 404, 400, 459),
336+
(34, 370, 151, 417),
337+
(0, 27, 148, 48),
338+
)
339+
340+
clusters = _cluster_foreground_boxes(boxes)
341+
342+
assert len(clusters) == 2
343+
assert (0, 27, 583, 284) in clusters
344+
assert (34, 349, 635, 459) in clusters

0 commit comments

Comments
 (0)