@@ -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
0 commit comments