Skip to content

Commit 61d46f2

Browse files
Dockerfile: Bump python from 420310d to ee710af in /scene_common (#1364)
1 parent 4cc9057 commit 61d46f2

23 files changed

Lines changed: 2571 additions & 140 deletions

controller/config/reid-config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"similarity_metric": "L2",
23
"stale_feature_timeout_secs": 5.0,
34
"stale_feature_check_interval_secs": 1.0,
45
"feature_accumulation_threshold": 12,

controller/src/controller/data_source.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from abc import ABC, abstractmethod
55
from pathlib import Path
66
import json
7+
78
from scene_common import log
89
from scene_common.rest_client import RESTClient
910

controller/src/controller/reid.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,59 @@
33

44
from abc import ABC, abstractmethod
55

6+
import numpy as np
7+
8+
from scene_common import log
9+
610
class ReIDDatabase(ABC):
11+
def prepareReidDict(self, embedding_vector, dimensions=None,
12+
normalize_embeddings=False):
13+
"""Prepare a normalized/validated ReID payload from arbitrary vector shapes.
14+
15+
Supports vectors shaped as (N,), (1, N), or any array-like object by
16+
flattening to 1D. If dimensions is None, dimensions are inferred from the
17+
flattened vector length.
18+
"""
19+
if embedding_vector is None:
20+
log.warning("prepareReidDict: Empty embedding vector, skipping this vector")
21+
return None
22+
23+
vec_array = np.asarray(embedding_vector, dtype="float32").reshape(-1)
24+
inferred_dimensions = int(vec_array.shape[0])
25+
expected_dimensions = inferred_dimensions if dimensions is None else int(dimensions)
26+
27+
if inferred_dimensions != expected_dimensions:
28+
log.warning(
29+
f"prepareReidDict: Expected vector shape ({expected_dimensions},) but got {vec_array.shape}, skipping this vector")
30+
return None
31+
32+
if not np.all(np.isfinite(vec_array)):
33+
log.warning("prepareReidDict: Vector contains non-finite values, skipping this vector")
34+
return None
35+
36+
if normalize_embeddings:
37+
norm = np.linalg.norm(vec_array)
38+
if not np.isfinite(norm) or norm == 0.0:
39+
log.warning(f"prepareReidDict: Invalid vector norm ({norm}), skipping this vector")
40+
return None
41+
vec_array = vec_array / norm
42+
43+
return {
44+
"embedded_vector": vec_array.astype("float32", copy=False),
45+
"dimensions": expected_dimensions,
46+
}
47+
48+
def prepareReidVector(self, reid_vector, dimensions,
49+
normalize_embeddings=False):
50+
"""Backward-compatible wrapper returning only the prepared vector."""
51+
prepared_reid = self.prepareReidDict(
52+
reid_vector,
53+
dimensions,
54+
normalize_embeddings=normalize_embeddings)
55+
if prepared_reid is None:
56+
return None
57+
return prepared_reid["embedded_vector"]
58+
759
@abstractmethod
860
def connect(self, hostname):
961
"""

controller/src/controller/scene.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33

44
from types import SimpleNamespace
55
from typing import Optional
6+
67
import numpy as np
8+
79
import robot_vision as rv
8-
from controller.controller_mode import ControllerMode
9-
from controller.moving_object import ChainData
1010
from scene_common import log
1111
from scene_common.camera import Camera
1212
from scene_common.earth_lla import convertLLAToECEF, calculateTRSLocal2LLAFromSurfacePoints
13-
from scene_common.geometry import Line, Point, Region, Tripwire, getRegionEvents, getTripwireEvents
13+
from scene_common.geometry import Point, Region, Tripwire, getRegionEvents, getTripwireEvents
1414
from scene_common.scene_model import SceneModel
1515
from scene_common.timestamp import get_epoch_time, get_iso_time
1616
from scene_common.transform import CameraPose
1717
from scene_common.mesh_util import getMeshAxisAlignedProjectionToXY, createRegionMesh, createObjectMesh
1818

19+
from controller.controller_mode import ControllerMode
20+
from controller.moving_object import ChainData
1921
from controller.ilabs_tracking import IntelLabsTracking
2022
from controller.time_chunking import TimeChunkedIntelLabsTracking, DEFAULT_CHUNKING_RATE_FPS
2123
from controller.tracking import (MAX_UNRELIABLE_TIME,

controller/src/controller/uuid_manager.py

Lines changed: 159 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,80 @@
44
import collections
55
import concurrent.futures
66
import threading
7+
import math
78

89
import numpy as np
910

10-
from controller.vdms_adapter import VDMSDatabase
11+
from controller.vdms_adapter import VDMSDatabase, COSINE_SIMILARITY_TOLERANCE
1112
from controller.moving_object import ReidState, MovingObject
1213
from scene_common import log
1314
from scene_common.timestamp import get_epoch_time
1415

1516
DEFAULT_DATABASE = "VDMS"
16-
DEFAULT_SIMILARITY_THRESHOLD = 40
17+
DEFAULT_SIMILARITY_THRESHOLD_L2 = 40.0
18+
DEFAULT_SIMILARITY_THRESHOLD_COSINE = 0.5
1719
DEFAULT_MINIMUM_BBOX_AREA = 5000
1820
DEFAULT_MINIMUM_FEATURE_COUNT = 12
1921
DEFAULT_FEATURE_SLICE_SIZE = 10
2022
DEFAULT_MAX_QUERY_TIME = 4
2123
DEFAULT_MAX_SIMILARITY_QUERIES_TRACKED = 10
2224
DEFAULT_STALE_FEATURE_TIMEOUT_SECS = 5.0
2325
DEFAULT_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.
2430
available_databases = {
2531
"VDMS": VDMSDatabase,
2632
}
2733

2834
class 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

Comments
 (0)