44import collections
55import concurrent .futures
66import threading
7+ import math
78
89import numpy as np
910
10- from controller .vdms_adapter import VDMSDatabase
11+ from controller .vdms_adapter import VDMSDatabase , COSINE_SIMILARITY_TOLERANCE
1112from controller .moving_object import ReidState , MovingObject
1213from scene_common import log
1314from scene_common .timestamp import get_epoch_time
1415
1516DEFAULT_DATABASE = "VDMS"
16- DEFAULT_SIMILARITY_THRESHOLD = 40
17+ DEFAULT_SIMILARITY_THRESHOLD_L2 = 40.0
18+ DEFAULT_SIMILARITY_THRESHOLD_COSINE = 0.5
1719DEFAULT_MINIMUM_BBOX_AREA = 5000
1820DEFAULT_MINIMUM_FEATURE_COUNT = 12
1921DEFAULT_FEATURE_SLICE_SIZE = 10
2022DEFAULT_MAX_QUERY_TIME = 4
2123DEFAULT_MAX_SIMILARITY_QUERIES_TRACKED = 10
2224DEFAULT_STALE_FEATURE_TIMEOUT_SECS = 5.0
2325DEFAULT_STALE_FEATURE_CHECK_INTERVAL_SECS = 1.0
26+ DEFAULT_SIMILARITY_METRIC = "L2"
27+ SUPPORTED_SIMILARITY_METRICS = {"COSINE" , "L2" }
28+ # Tolerance applied to the theoretical [-1, 1] IP score bounds to absorb
29+ # float32 rounding errors from VDMS normalization and inner-product computation.
2430available_databases = {
2531 "VDMS" : VDMSDatabase ,
2632}
2733
2834class UUIDManager :
35+ def _normalizeSimilarityMetric (self , metric ):
36+ normalized_metric = str (metric ).strip ().upper ()
37+ if normalized_metric not in SUPPORTED_SIMILARITY_METRICS :
38+ log .warning (
39+ f"Unsupported similarity_metric '{ metric } ', "
40+ f"supported values are { sorted (SUPPORTED_SIMILARITY_METRICS )} ; "
41+ f"falling back to { DEFAULT_SIMILARITY_METRIC } " )
42+ return DEFAULT_SIMILARITY_METRIC
43+ return normalized_metric
44+
45+ def _resolveDatabaseSimilarityMetric (self , configured_metric ):
46+ """Translate controller-facing similarity metric to the VDMS descriptor metric."""
47+ metric = self ._normalizeSimilarityMetric (configured_metric )
48+ if metric == "COSINE" :
49+ return "IP"
50+ return metric
51+
52+ def _resolveDefaultSimilarityThreshold (self , similarity_metric ):
53+ """Return the default threshold for the configured similarity metric."""
54+ if self ._normalizeSimilarityMetric (similarity_metric ) == "COSINE" :
55+ return DEFAULT_SIMILARITY_THRESHOLD_COSINE
56+ return DEFAULT_SIMILARITY_THRESHOLD_L2
57+
58+ def _validateSimilarityThreshold (self , similarity_threshold , similarity_metric ):
59+ """Normalize and validate the configured threshold for the active metric."""
60+ try :
61+ normalized_threshold = float (similarity_threshold )
62+ except (TypeError , ValueError ) as err :
63+ raise ValueError (
64+ f"similarity_threshold must be a finite numeric value, got { similarity_threshold } " ) from err
65+
66+ if not math .isfinite (normalized_threshold ):
67+ raise ValueError (
68+ f"similarity_threshold must be a finite numeric value, got { similarity_threshold } " )
69+
70+ normalized_metric = self ._normalizeSimilarityMetric (similarity_metric )
71+ if normalized_metric == "COSINE" :
72+ if normalized_threshold < - 1.0 or normalized_threshold > 1.0 :
73+ raise ValueError (
74+ "similarity_threshold for COSINE must be within [-1.0, 1.0]" )
75+ return normalized_threshold
76+
77+ if normalized_threshold < 0.0 :
78+ raise ValueError ("similarity_threshold for L2 must be non-negative" )
79+ return normalized_threshold
80+
2981 def __init__ (self , database = DEFAULT_DATABASE , reid_config_data = None ):
3082 self .active_ids = {}
3183 self .active_ids_lock = threading .Lock ()
@@ -34,6 +86,7 @@ def __init__(self, database=DEFAULT_DATABASE, reid_config_data=None):
3486 self .features_for_database_timestamps = {} # Track when features were added
3587 self .quality_features = {}
3688 self .unique_id_count = 0
89+ self .stale_feature_timer = None
3790
3891 self .unique_id_count_lock = threading .Lock ()
3992 # ReID embedding dimensions are inferred from the first observed embedding.
@@ -79,12 +132,21 @@ def _applyReidConfig(self, reid_config_data=None):
79132 'stale_feature_check_interval_secs' , DEFAULT_STALE_FEATURE_CHECK_INTERVAL_SECS )
80133 self .minimum_feature_count = reid_config_data .get (
81134 'feature_accumulation_threshold' , DEFAULT_MINIMUM_FEATURE_COUNT )
82- self .similarity_threshold = reid_config_data .get (
83- 'similarity_threshold' , DEFAULT_SIMILARITY_THRESHOLD )
135+ self .similarity_metric = self ._normalizeSimilarityMetric (reid_config_data .get (
136+ 'similarity_metric' , DEFAULT_SIMILARITY_METRIC ))
137+ configured_similarity_threshold = reid_config_data .get ('similarity_threshold' )
138+ if configured_similarity_threshold is None :
139+ configured_similarity_threshold = self ._resolveDefaultSimilarityThreshold (
140+ self .similarity_metric )
141+ self .similarity_threshold = self ._validateSimilarityThreshold (
142+ configured_similarity_threshold , self .similarity_metric )
84143 self .minimum_bbox_area = reid_config_data .get (
85144 'minimum_bbox_area' , DEFAULT_MINIMUM_BBOX_AREA )
86145 self .feature_slice_size = reid_config_data .get (
87146 'feature_slice_size' , DEFAULT_FEATURE_SLICE_SIZE )
147+ if hasattr (self , 'reid_database' ) and self .reid_database is not None :
148+ self .reid_database .similarity_metric = self ._resolveDatabaseSimilarityMetric (
149+ self .similarity_metric )
88150
89151 def _rescheduleStaleFeatureTimer (self ):
90152 """Cancel any existing stale-feature timer and start a new one."""
@@ -448,49 +510,119 @@ def parseQueryResults(self, similarity_scores, threshold=None):
448510 """
449511 Check database for any similar objects and return an ID and similarity score.
450512 Uses a majority-vote strategy: a candidate UUID must appear in at least half of the
451- per-vector best matches whose distance is below the threshold to be accepted.
452- When multiple candidates qualify, the one with the lowest distance is returned.
513+ per-vector best matches that pass the metric-specific threshold test to be accepted.
514+ When multiple candidates qualify, the one with the best metric value is returned
515+ according to descriptor semantics (highest for IP/COSINE, lowest for L2).
453516
454517 @param similarity_scores The similarity scores obtained from the database query
455- @param threshold The maximum distance between Re-ID vectors still considered
456- a valid match; defaults to self.similarity_threshold
457- @return database_id UUID of the matched entry if a majority-vote match is found;
458- otherwise None
459- @return similarity Minimum distance to the matched entry if found; otherwise None
518+ @param threshold Similarity threshold interpreted according to metric semantics:
519+ - L2-style distance: lower is better, candidate must be < threshold
520+ - IP-style score: higher is better, candidate must be > threshold
521+ @return database_id Returns the ID of the matched entry from the database if one
522+ is found; otherwise, returns None
523+ @return similarity Similarity value returned by VDMS (`_distance` field) for
524+ the matched entry if one is found; otherwise, return None
460525 """
461526 if threshold is None :
462527 threshold = self .similarity_threshold
463528
529+ if not self ._hasValidSimilarityScoreShape (similarity_scores ):
530+ log .warning (
531+ "parseQueryResults: Invalid similarity_scores shape; expected list[list[entity]]. "
532+ f"Received type={ type (similarity_scores )} " )
533+ return None , None
534+
464535 if similarity_scores :
465- minimum_distances = [self ._findMinimumDistance (entities )
536+ metric_candidates = [self ._findBestMetricCandidate (entities )
466537 for entities in similarity_scores ]
467- distances_below_threshold = [(uuid , distance ) for (uuid , distance ) in
468- minimum_distances if
469- distance is not None and distance < threshold ]
470-
471- if distances_below_threshold :
472- counter = collections .Counter (item [0 ] for item in distances_below_threshold )
538+ qualifying_candidates = [(uuid , metric_value ) for (uuid , metric_value ) in
539+ metric_candidates if
540+ metric_value is not None and
541+ self . _isSimilarityMatch ( metric_value , threshold )]
542+ if qualifying_candidates :
543+ counter = collections .Counter (item [0 ] for item in qualifying_candidates )
473544 most_common_uuid , count = counter .most_common (1 )[0 ]
474- if count >= (len (minimum_distances ) / 2 ):
475- similarity = min ( item [ 1 ] for item in distances_below_threshold
476- if item [0 ] == most_common_uuid )
545+ if count >= (len (metric_candidates ) / 2 ):
546+ similarity = self . _pickBestMetricValue (
547+ [ item [ 1 ] for item in qualifying_candidates if item [0 ] == most_common_uuid ] )
477548 return most_common_uuid , similarity
478549
479550 return None , None
480551
481- def _findMinimumDistance (self , entities ):
552+ def _hasValidSimilarityScoreShape (self , similarity_scores ):
553+ """Validate that query results follow the strict list-of-lists contract."""
554+ if not similarity_scores :
555+ return True
556+
557+ if not isinstance (similarity_scores , list ):
558+ return False
559+
560+ return all (isinstance (item , list ) for item in similarity_scores )
561+
562+ def _isHigherBetterMetric (self ):
563+ """Return True when the configured descriptor metric uses higher-is-better semantics."""
564+ metric = getattr (self .reid_database , 'similarity_metric' , None )
565+ if metric is None :
566+ return False
567+ return str (metric ).strip ().upper () == "IP"
568+
569+ def _isSimilarityMatch (self , metric_value , threshold ):
570+ """Evaluate threshold semantics according to the active descriptor metric."""
571+ if metric_value is None :
572+ return False
573+
574+ if not math .isfinite (metric_value ):
575+ return False
576+
577+ if self ._isHigherBetterMetric ():
578+ # For IP metrics, scores must lie within [-1, 1] (normalized embeddings).
579+ # Allow a small tolerance to absorb float32 rounding from VDMS computation.
580+ if metric_value < - (1.0 + COSINE_SIMILARITY_TOLERANCE ) or metric_value > (1.0 + COSINE_SIMILARITY_TOLERANCE ):
581+ return False
582+ return metric_value > threshold
583+ return metric_value < threshold
584+
585+ def _pickBestMetricValue (self , metric_values ):
586+ """Pick best metric value according to descriptor metric semantics."""
587+ if not metric_values :
588+ return None
589+ if self ._isHigherBetterMetric ():
590+ return max (metric_values )
591+ return min (metric_values )
592+
593+ def _findBestMetricCandidate (self , entities ):
482594 """
483- Find the uuid with the minimum distance and the corresponding distance value .
595+ Find the best candidate uuid and metric value according to descriptor semantics .
484596
485- VDMS returns entities sorted ascending by _distance (closest first), so entities[0]
486- is always the best match.
597+ The best match is selected from the provided entities based on the configured
598+ descriptor metric semantics: higher values are better for higher-is-better
599+ metrics, and lower values are better otherwise.
487600
488601 Structure of entities:
489602 [{'uuid': <UUID>, 'rvid': <TRACKER_ID>, '_distance': <SIMILARITY_SCORE>}, ...]
490603 """
604+ is_higher_better = self ._isHigherBetterMetric ()
491605 if entities :
492- minimum_distance_entity = entities [0 ]
493- return (minimum_distance_entity ['uuid' ], minimum_distance_entity ['_distance' ])
606+ filtered_entities = []
607+ for entity in entities :
608+ metric_value = entity .get ('_distance' )
609+ if metric_value is None or not math .isfinite (metric_value ):
610+ continue
611+ if is_higher_better and (metric_value < - (1.0 + COSINE_SIMILARITY_TOLERANCE ) or metric_value > (1.0 + COSINE_SIMILARITY_TOLERANCE )):
612+ log .warning (
613+ f"Ignoring out-of-range IP similarity score { metric_value } "
614+ f"for uuid={ entity .get ('uuid' )} " )
615+ continue
616+ filtered_entities .append (entity )
617+
618+ if not filtered_entities :
619+ return (None , None )
620+
621+ if is_higher_better :
622+ best_entity = max (filtered_entities , key = lambda x : x ['_distance' ])
623+ else :
624+ best_entity = min (filtered_entities , key = lambda x : x ['_distance' ])
625+ return (best_entity ['uuid' ], best_entity ['_distance' ])
494626 return (None , None )
495627
496628 def _activeGidIndex (self ):
0 commit comments