Skip to content

Commit c720875

Browse files
authored
Merge branch 'main' into ITEP-89519/visibility-avaliable-in-analytics-only
2 parents 1b91cb3 + fb8e3d8 commit c720875

28 files changed

Lines changed: 1545 additions & 180 deletions

.github/skills/testing.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ Run this workflow before executing any test command:
7171
- Do not report completion without runtime verification for the resolved target (unless blocked).
7272
- Always report: should-run target, whether it was run, exact command, and pass/fail summary (or blocker).
7373

74+
## Test Configuration Hygiene (Mandatory)
75+
76+
- Do not add new environment variables to `tools/scenescape-start` for test-only behavior.
77+
- Treat `tools/scenescape-start` as a stable shared launcher, not a per-test configuration surface.
78+
- For test scenario inputs, prefer one of these paths:
79+
- test configuration files (for example scenario JSON, service config JSON)
80+
- test runner arguments (for example pytest options)
81+
- Makefile variables scoped to test targets
82+
- If a new environment variable is absolutely required for non-test runtime behavior, document and justify it in the related service docs; do not introduce it solely to satisfy a test matrix.
83+
7484
### Quick Mapping Examples
7585

7686
- `tests/functional/tc_sensors_send_mqtt_messages.py` -> `make -C tests sensors-send-events`

controller/config/reid-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"stale_feature_timeout_secs": 5.0,
33
"stale_feature_check_interval_secs": 1.0,
44
"feature_accumulation_threshold": 12,
5+
"minimum_bbox_area": 5000,
56
"feature_slice_size": 10,
6-
"similarity_threshold": 60
7+
"similarity_threshold": 30.0
78
}

controller/src/controller-cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def main():
9494
args.brokerauth, args.resturl,
9595
args.restauth, args.cert,
9696
args.rootcert, args.ntp, args.tracker_config_file, args.schema_file,
97-
args.visibility_topic, args.data_source)
97+
args.visibility_topic, args.data_source, args.reid_config_file)
9898

9999
# Start health check server if port is specified
100100
if args.healthcheck_port > 0:

controller/src/controller/cache_manager.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def refreshScenes(self):
6363

6464
uid = scene_data['uid']
6565
if uid not in self.cached_scenes_by_uid:
66-
# Creating new scene - check if there was an old scene with sensor cache
6766
scene = Scene.deserialize(scene_data)
6867

6968
old_scene = self._sensorNeedsRestoring(uid)

controller/src/controller/detections_builder.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,38 @@ def buildDetectionsList(objects, scene, update_visibility=False, include_sensors
2626
result_list.append(obj_dict)
2727
return result_list
2828

29-
def _get_region_entered_epoch(region_data):
29+
def _getRegionEnteredEpoch(region_data):
3030
entered_epoch = region_data.get('entered_epoch')
3131
if entered_epoch is None:
3232
entered_epoch = get_epoch_time(region_data['entered'])
3333
region_data['entered_epoch'] = entered_epoch
3434
return entered_epoch
3535

36-
def _build_region_output(regions, include_region_dwell, current_time):
36+
def _buildRegionOutput(regions, include_region_dwell, current_time):
3737
serialized_regions = {}
3838
for region_name, region_data in regions.items():
3939
serialized_region = dict(region_data)
4040
serialized_region.pop('entered_epoch', None)
4141
if include_region_dwell and 'entered' in region_data:
42-
entered = _get_region_entered_epoch(region_data)
42+
entered = _getRegionEnteredEpoch(region_data)
4343
serialized_region['dwell'] = current_time - entered
4444
serialized_regions[region_name] = serialized_region
4545
return serialized_regions
4646

47+
def _serializePreviousIdsChain(previous_ids_chain):
48+
serialized_chain = []
49+
for entry in previous_ids_chain:
50+
serialized_entry = dict(entry)
51+
timestamp = serialized_entry.get('timestamp')
52+
53+
# UUIDManager records chain timestamps as epoch floats; normalize to ISO 8601 in output.
54+
if isinstance(timestamp, (int, float)):
55+
serialized_entry['timestamp'] = get_iso_time(float(timestamp))
56+
57+
serialized_chain.append(serialized_entry)
58+
59+
return serialized_chain
60+
4761
def prepareObjDict(scene, obj, update_visibility, include_sensors=False,
4862
include_region_dwell=False, current_time=None):
4963
aobj = obj
@@ -114,7 +128,7 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False,
114128
if include_region_dwell:
115129
if current_time is None:
116130
current_time = get_epoch_time()
117-
obj_dict['regions'] = _build_region_output(chain_data.regions, include_region_dwell, current_time)
131+
obj_dict['regions'] = _buildRegionOutput(chain_data.regions, include_region_dwell, current_time)
118132
else:
119133
obj_dict['regions'] = chain_data.regions
120134

@@ -150,6 +164,15 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False,
150164
obj_dict['similarity'] = aobj.similarity
151165
if hasattr(aobj, 'first_seen'):
152166
obj_dict['first_seen'] = get_iso_time(aobj.first_seen)
167+
168+
# Add reid state for downstream business logic to distinguish "never queried" from "query made"
169+
if hasattr(aobj, 'reid_state'):
170+
obj_dict['reid_state'] = aobj.reid_state.value # Convert enum to string
171+
172+
# Add previous IDs chain for post-mortem object stitching analysis
173+
if hasattr(aobj, 'previous_ids_chain') and aobj.previous_ids_chain:
174+
obj_dict['previous_ids_chain'] = _serializePreviousIdsChain(aobj.previous_ids_chain)
175+
153176
if isinstance(obj, TripwireEvent):
154177
obj_dict['direction'] = obj.direction
155178
if hasattr(aobj, 'asset_scale'):

controller/src/controller/moving_object.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import base64
55
import binascii
66
import datetime
7+
import uuid
78
import warnings
89
from dataclasses import dataclass, field
10+
from enum import Enum
911
from threading import Lock
1012
from typing import Dict, List
1113

@@ -16,6 +18,7 @@
1618

1719
from scene_common.geometry import DEFAULTZ, Line, Point, Rectangle
1820
from scene_common.options import TYPE_1, TYPE_2
21+
from scene_common.timestamp import get_epoch_time
1922
from scene_common.transform import normalize, rotationToTarget
2023
from scene_common import log
2124

@@ -110,6 +113,19 @@ def serializeReIDPayload(reid):
110113

111114
return reid
112115

116+
class ReidState(Enum):
117+
"""State of ReID query and matching for an object.
118+
119+
PENDING_COLLECTION: Collecting embeddings, query not yet made
120+
QUERY_NO_MATCH: Query made but no match found (new object)
121+
MATCHED: Successfully matched to previous object (reID)
122+
REID_DISABLED: ReID system is disabled, no query will be made
123+
"""
124+
PENDING_COLLECTION = "pending_collection"
125+
QUERY_NO_MATCH = "query_no_match"
126+
MATCHED = "matched"
127+
REID_DISABLED = "reid_disabled"
128+
113129
@dataclass
114130
class ChainData:
115131
regions: Dict
@@ -196,6 +212,9 @@ def __init__(self, info, when, camera):
196212
self.intersected = False
197213
self.reid = {} # Initialize reid as empty dict
198214
self.metadata = {} # Initialize metadata as empty dict
215+
self.reid_state = ReidState.PENDING_COLLECTION # Track reID state
216+
self.similarity = None # Similarity score from last reID match
217+
self.previous_ids_chain = [] # Track object ID history: [{'id': gid, 'timestamp': ts, 'similarity_score': score}, ...]
199218
# Extract reid from metadata if present and preserve metadata attribute
200219
metadata_from_info = self.info.get('metadata', {})
201220
if metadata_from_info and isinstance(metadata_from_info, dict):
@@ -294,6 +313,9 @@ def setPrevious(self, otherObj):
294313
self.gid = otherObj.gid
295314
self.first_seen = otherObj.first_seen
296315
self.frameCount = otherObj.frameCount + 1
316+
self.reid_state = otherObj.reid_state
317+
self.similarity = otherObj.similarity
318+
self.previous_ids_chain = otherObj.get_previous_ids()
297319

298320
del self.chain_data.publishedLocations[LOCATION_LIMIT:]
299321

@@ -379,6 +401,43 @@ def _projectBounds(self):
379401
def when(self):
380402
return self.location[0].when
381403

404+
def save_previous_object_id(self, previous_id, similarity_score=None, timestamp=None):
405+
"""Save the previous object ID for post-mortem analysis.
406+
407+
@param previous_id: The previous global ID assigned to this object
408+
@param similarity_score: Similarity score from reID matching (if matched), or None if new object
409+
@param timestamp: When the change occurred (epoch time), defaults to current time
410+
"""
411+
try:
412+
uuid.UUID(previous_id)
413+
except (TypeError, ValueError, AttributeError) as err:
414+
raise ValueError("previous_id must be a valid UUID") from err
415+
416+
if timestamp is None:
417+
timestamp = get_epoch_time()
418+
419+
self.previous_ids_chain.append({
420+
'id': previous_id,
421+
'timestamp': timestamp,
422+
'similarity_score': similarity_score
423+
})
424+
log.debug(f"MovingObject.save_previous_object_id: rv_id={getattr(self, 'rv_id', 'unknown')}, "
425+
f"previous_id={previous_id}, similarity={similarity_score}, state={self.reid_state.value}")
426+
427+
def is_reidentified(self):
428+
"""Check if this object resulted from successful reID matching.
429+
430+
@return: True if object was matched to a previous object, False otherwise
431+
"""
432+
return self.reid_state == ReidState.MATCHED
433+
434+
def get_previous_ids(self):
435+
"""Get chain of previous IDs for this object.
436+
437+
@return: List of dicts with 'id', 'timestamp', 'similarity_score' for post-mortem analysis
438+
"""
439+
return self.previous_ids_chain.copy()
440+
382441
def __repr__(self):
383442
return "%s: %s/%s %s %s vectors: %s" % \
384443
(self.__class__.__name__,

controller/src/controller/scene.py

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(self, name, map_file, scale=None,
5858
self.non_measurement_time_static = non_measurement_time_static
5959
self.suspended_track_timeout_secs = suspended_track_timeout_secs
6060
self.reid_config_data = reid_config_data if reid_config_data else {}
61+
6162
self.tracker = None
6263
self.trackerType = None
6364
self.persist_attributes = {}
@@ -89,6 +90,9 @@ def _setTracker(self, trackerType):
8990
self.trackerType = trackerType
9091
log.info("SETTING TRACKER TYPE", trackerType)
9192

93+
if self.tracker is not None:
94+
self.tracker.join()
95+
9296
args = (self.max_unreliable_time,
9397
self.non_measurement_time_dynamic,
9498
self.non_measurement_time_static)
@@ -99,25 +103,39 @@ def _setTracker(self, trackerType):
99103
self.tracker = self.available_trackers[self.trackerType](*args)
100104
return
101105

102-
def updateScene(self, scene_data):
106+
def _hydrateFromSceneData(self, scene_data, reid_runtime_update=True):
107+
reid_config_changed = False
108+
if 'reid_config_data' in scene_data:
109+
new_reid_config_data = scene_data['reid_config_data']
110+
reid_config_changed = new_reid_config_data != self.reid_config_data
111+
if reid_config_changed:
112+
self.reid_config_data = new_reid_config_data
113+
103114
self.parent = scene_data.get('parent', None)
104115
self.cameraPose = None
105116
if 'transform' in scene_data:
106117
self.cameraPose = CameraPose(scene_data['transform'], None)
107-
self.use_tracker = scene_data.get('use_tracker', True)
118+
self.use_tracker = scene_data.get('use_tracker', True) and not ControllerMode.isAnalyticsOnly()
108119
self.output_lla = scene_data.get('output_lla', False)
109120
self.map_corners_lla = scene_data.get('map_corners_lla', None)
121+
self.retrack = scene_data.get('retrack', True)
122+
self.persist_attributes = scene_data.get('persist_attributes', {})
110123
self._updateChildren(scene_data.get('children', []))
111124
self.updateCameras(scene_data.get('cameras', []))
112125
self._updateRegions(self.regions, scene_data.get('regions', []))
113126
self._updateTripwires(scene_data.get('tripwires', []))
114127
self._updateRegions(self.sensors, scene_data.get('sensors', []))
115-
# Update reid config if provided
116-
if 'reid_config_data' in scene_data:
117-
self.reid_config_data = scene_data['reid_config_data']
128+
118129
tracker_config = scene_data.get('tracker_config', None)
119130
if tracker_config:
120131
self.updateTracker(tracker_config[0], tracker_config[1], tracker_config[2])
132+
133+
# Apply ReID config changes in-place to preserve active tracks while
134+
# updating UUID manager thresholds and timers.
135+
if reid_runtime_update and reid_config_changed and self.trackerType and not ControllerMode.isAnalyticsOnly():
136+
log.info(f"ReID config changed for scene={self.uid}; updating tracker ReID runtime config")
137+
self.tracker.updateReidConfig(self.reid_config_data)
138+
121139
self.name = scene_data['name']
122140
if 'scale' in scene_data:
123141
self.scale = scene_data['scale']
@@ -130,6 +148,10 @@ def updateScene(self, scene_data):
130148
_ = self.trs_xyz_to_lla
131149
return
132150

151+
def updateScene(self, scene_data):
152+
self._hydrateFromSceneData(scene_data, reid_runtime_update=True)
153+
return
154+
133155
def updateTracker(self, max_unreliable_time, non_measurement_time_dynamic,
134156
non_measurement_time_static):
135157
# Only update tracker if the values have changed to avoid losing tracking data
@@ -757,38 +779,14 @@ def _updateVisible(self, curObjects):
757779
@classmethod
758780
def deserialize(cls, data):
759781
tracker_config = data.get('tracker_config', [])
782+
reid_config_data = data.get('reid_config_data', None)
760783
scale_from_data = data.get('scale', None)
761784
scene = cls(data['name'], data.get('map', None), scale_from_data,
762-
*tracker_config)
785+
*tracker_config, reid_config_data=reid_config_data)
763786
scene.uid = data['uid']
764787
scene.mesh_translation = data.get('mesh_translation', None)
765788
scene.mesh_rotation = data.get('mesh_rotation', None)
766-
scene.use_tracker = data.get('use_tracker', True) and not ControllerMode.isAnalyticsOnly()
767-
scene.output_lla = data.get('output_lla', None)
768-
scene.map_corners_lla = data.get('map_corners_lla', None)
769-
scene.retrack = data.get('retrack', True)
770-
scene.regulated_rate = data.get('regulated_rate', None)
771-
scene.external_update_rate = data.get('external_update_rate', None)
772-
scene.persist_attributes = data.get('persist_attributes', {})
773-
if 'cameras' in data:
774-
scene.updateCameras(data['cameras'])
775-
if 'regions' in data:
776-
scene._updateRegions(scene.regions, data['regions'])
777-
if 'tripwires' in data:
778-
scene._updateTripwires(data['tripwires'])
779-
if 'sensors' in data:
780-
scene._updateRegions(scene.sensors, data['sensors'])
781-
if 'children' in data:
782-
scene.children = [x['name'] for x in data['children']]
783-
if 'parent' in data:
784-
scene.parent = data['parent']
785-
if 'transform' in data:
786-
scene.cameraPose = CameraPose(data['transform'], None)
787-
if 'tracker_config' in data:
788-
tracker_config = data['tracker_config']
789-
scene.updateTracker(tracker_config[0], tracker_config[1], tracker_config[2])
790-
# Access the property to trigger initialization
791-
_ = scene.trs_xyz_to_lla
789+
scene._hydrateFromSceneData(data, reid_runtime_update=False)
792790
return scene
793791

794792
def _updateChildren(self, newChildren):

controller/src/controller/tracking.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ def getUniqueIDCount(self, category):
4646
log.warning("No tracker for category", category)
4747
return 0
4848

49+
def updateReidConfig(self, reid_config_data=None):
50+
"""Update ReID behavior in-place for this tracker and all child trackers."""
51+
self.reid_config_data = reid_config_data if reid_config_data else {}
52+
self.uuid_manager.updateReidConfig(self.reid_config_data)
53+
for tracker in self.trackers.values():
54+
tracker.updateReidConfig(self.reid_config_data)
55+
return
56+
4957
def trackObjects(self, objects, already_tracked_objects, when, categories, \
5058
ref_camera_frame_rate, \
5159
max_unreliable_time, \
@@ -90,7 +98,13 @@ def _createTrackers(self, categories, max_unreliable_time, non_measurement_time_
9098
"""Create a tracker object for each category"""
9199
for category in categories:
92100
if category not in self.trackers:
93-
tracker = self.__class__(max_unreliable_time, non_measurement_time_dynamic, non_measurement_time_static, ref_camera_frame_rate)
101+
tracker = self.__class__(
102+
max_unreliable_time,
103+
non_measurement_time_dynamic,
104+
non_measurement_time_static,
105+
ref_camera_frame_rate,
106+
reid_config_data=self.reid_config_data,
107+
)
94108
self.trackers[category] = tracker
95109
tracker.start()
96110
return
@@ -199,6 +213,8 @@ def join(self):
199213
tracker.waitForComplete()
200214
log.debug(f"Joining tracker thread category {category}")
201215
tracker.join()
216+
tracker.uuid_manager.shutdown()
217+
self.uuid_manager.shutdown()
202218
return
203219

204220
@staticmethod

0 commit comments

Comments
 (0)