diff --git a/controller/src/controller/detections_builder.py b/controller/src/controller/detections_builder.py index 5368262d0..1b9ad54a1 100644 --- a/controller/src/controller/detections_builder.py +++ b/controller/src/controller/detections_builder.py @@ -7,24 +7,41 @@ from scene_common import log from scene_common.earth_lla import convertXYZToLLA, calculateHeading from scene_common.geometry import DEFAULTZ, Point, Size -from scene_common.timestamp import get_iso_time +from scene_common.timestamp import get_epoch_time, get_iso_time -def buildDetectionsDict(objects, scene, include_sensors=False): +def buildDetectionsDict(objects, scene, include_sensors=False, include_region_dwell=False, current_time=None): + if include_region_dwell and current_time is None: + current_time = get_epoch_time() result_dict = {} for obj in objects: - obj_dict = prepareObjDict(scene, obj, False, include_sensors) + obj_dict = prepareObjDict(scene, obj, False, include_sensors, include_region_dwell, current_time) result_dict[obj_dict['id']] = obj_dict return result_dict -def buildDetectionsList(objects, scene, update_visibility=False, include_sensors=False): +def buildDetectionsList(objects, scene, update_visibility=False, include_sensors=False, + include_region_dwell=False, current_time=None): + if include_region_dwell and current_time is None: + current_time = get_epoch_time() result_list = [] for obj in objects: - obj_dict = prepareObjDict(scene, obj, update_visibility, include_sensors) + obj_dict = prepareObjDict(scene, obj, update_visibility, include_sensors, + include_region_dwell, current_time) result_list.append(obj_dict) return result_list -def prepareObjDict(scene, obj, update_visibility, include_sensors=False): +def _build_region_output(regions, include_region_dwell, current_time): + serialized_regions = {} + for region_name, region_data in regions.items(): + serialized_region = dict(region_data) + if include_region_dwell and 'entered' in region_data: + entered = get_epoch_time(region_data['entered']) + serialized_region['dwell'] = current_time - entered + serialized_regions[region_name] = serialized_region + return serialized_regions + +def prepareObjDict(scene, obj, update_visibility, include_sensors=False, + include_region_dwell=False, current_time=None): aobj = obj if isinstance(obj, TripwireEvent): aobj = obj.object @@ -89,7 +106,12 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False): if hasattr(aobj, 'chain_data'): chain_data = aobj.chain_data if len(chain_data.regions): - obj_dict['regions'] = chain_data.regions + if include_region_dwell: + if current_time is None: + current_time = get_epoch_time() + obj_dict['regions'] = _build_region_output(chain_data.regions, include_region_dwell, current_time) + else: + obj_dict['regions'] = chain_data.regions if include_sensors: sensors_output = {} diff --git a/controller/src/controller/scene_controller.py b/controller/src/controller/scene_controller.py index da3231c59..5e096f773 100644 --- a/controller/src/controller/scene_controller.py +++ b/controller/src/controller/scene_controller.py @@ -276,13 +276,16 @@ def publishRegulatedDetections(self, scene_obj, msg_objects, otype, jdata, camer return def publishRegionDetections(self, scene, objects, otype, jdata): + current_time = get_epoch_time(jdata['timestamp']) for rname in scene.regions: robjects = [] for obj in objects: if rname in obj.chain_data.regions: robjects.append(obj) # Region-specific detections: include sensor data - jdata['objects'] = buildDetectionsList(robjects, scene, False, include_sensors=True) + jdata['objects'] = buildDetectionsList( + robjects, scene, False, include_sensors=True, + include_region_dwell=True, current_time=current_time) olen = len(jdata['objects']) rid = scene.name + "/" + rname + "/" + otype if olen > 0 or rid not in scene.lastPubCount or scene.lastPubCount[rid] > 0: @@ -346,7 +349,9 @@ def _buildAllRegionObjsList(self, scene, region, event_data): num_objects += counts[otype] all_objects += objects event_data['counts'] = counts - detections_dict = buildDetectionsDict(all_objects, scene, include_sensors=True) + detections_dict = buildDetectionsDict( + all_objects, scene, include_sensors=True, + include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp'])) event_data['objects'] = list(detections_dict.values()) return detections_dict, num_objects @@ -364,7 +369,9 @@ def _buildEnteredObjsList(self, scene, region, event_data, detections_dict): # Build any objects not in detections_dict (e.g., from sensor events) if missing_objs: - entered_objs = buildDetectionsList(missing_objs, scene, False, include_sensors=True) + entered_objs = buildDetectionsList( + missing_objs, scene, False, include_sensors=True, + include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp'])) event_data['entered'].extend(entered_objs) def _buildExitedObjsList(self, scene, region, event_data): @@ -377,7 +384,9 @@ def _buildExitedObjsList(self, scene, region, event_data): exited_dict[exited_obj.gid] = dwell exited_objs.extend([exited_obj]) # Exit events: include sensor data (timestamped readings and attribute events) - exited_objs = buildDetectionsList(exited_objs, scene, False, include_sensors=True) + exited_objs = buildDetectionsList( + exited_objs, scene, False, include_sensors=True, + include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp'])) exited_data = [{'object': exited_obj, 'dwell': exited_dict[exited_obj['id']]} for exited_obj in exited_objs] event_data['exited'].extend(exited_data) return diff --git a/docs/user-guide/microservices/controller/_assets/scene-controller-api.yaml b/docs/user-guide/microservices/controller/_assets/scene-controller-api.yaml index f96ce83a6..c12ce5a57 100644 --- a/docs/user-guide/microservices/controller/_assets/scene-controller-api.yaml +++ b/docs/user-guide/microservices/controller/_assets/scene-controller-api.yaml @@ -199,13 +199,16 @@ channels: type: array items: type: object + description: >- + In region-scoped payloads, objects currently inside a region include a live + regions..dwell value in seconds alongside the entered timestamp. entered: type: array items: type: object description: >- Objects that entered the region during this cycle. In region events, each element is a bare - track object. In tripwire events, this array is always empty. May be + track object and may include regions..dwell for the current region. In tripwire events, this array is always empty. May be empty in region events when no entry occurred. exited: type: array diff --git a/docs/user-guide/microservices/controller/data_formats.md b/docs/user-guide/microservices/controller/data_formats.md index 064b33f9b..052465e2c 100644 --- a/docs/user-guide/microservices/controller/data_formats.md +++ b/docs/user-guide/microservices/controller/data_formats.md @@ -196,7 +196,7 @@ tracked object contains the following fields: | `velocity` | array[3] of number | Velocity vector (`x`, `y`, `z`) in metres per second | | `rotation` | array[4] of number | Orientation quaternion | | `visibility` | array of string | Camera IDs currently observing this object | -| `regions` | object | Map of region/sensor IDs to entry timestamps (`{id: {entered: timestamp}}`) | +| `regions` | object | Map of region/sensor IDs to membership metadata. By default this is `{id: {entered: timestamp}}`. In region-scoped outputs, objects currently inside a region also include a live dwell time as `{id: {entered: timestamp, dwell: seconds}}`. | | `sensors` | object | Map of sensor IDs to timestamped readings (`{id: [[timestamp, value], ...]}`) | | `similarity` | number or null | Re-ID similarity score; `null` when not computed | | `first_seen` | string (ISO 8601) | Timestamp when the track was first created | @@ -210,6 +210,12 @@ tracked object contains the following fields: > camera input it is a base64-encoded string. `metadata` is absent when no semantic > analytics pipeline is configured. +> **Note on live region dwell**: In region data and region event payloads, objects that are +> still inside a region include `regions..dwell`, which is the current elapsed +> time in seconds since that object entered the region. Exit records continue to expose the +> final dwell time separately as `{"object": , "dwell": }` in the +> top-level `exited` array. + ## Data Scene Output Message Format Published on MQTT topic: `scenescape/data/scene/{scene_id}/{thing_type}` @@ -375,18 +381,18 @@ interest changes. The `{event_type}` segment is typically `objects`. ### Region Event Top-Level Fields -| Field | Type | Description | -| ------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `timestamp` | string (ISO 8601 UTC) | Event timestamp | -| `scene_id` | string | Scene identifier (UUID) | -| `scene_name` | string | Scene name | -| `region_id` | string | Region identifier (UUID) | -| `region_name` | string | Region name | -| `counts` | object | Map of category to object count currently inside the region (e.g. `{"person": 2}`) | -| `objects` | array | Tracked objects currently inside the region (see [Common Output Track Fields](#common-output-track-fields)) | -| `entered` | array | Objects that entered the region during this cycle; each element is a bare track object. Empty when no entry occurred | -| `exited` | array | Objects that exited the region during this cycle; each element is `{"object": , "dwell": }`. Empty when no exit occurred | -| `metadata` | object | Region geometry: `title`, `uuid`, `points` (polygon vertices in metres), `area` (`"poly"`), `fromSensor` (boolean) | +| Field | Type | Description | +| ------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `timestamp` | string (ISO 8601 UTC) | Event timestamp | +| `scene_id` | string | Scene identifier (UUID) | +| `scene_name` | string | Scene name | +| `region_id` | string | Region identifier (UUID) | +| `region_name` | string | Region name | +| `counts` | object | Map of category to object count currently inside the region (e.g. `{"person": 2}`) | +| `objects` | array | Tracked objects currently inside the region. Each object includes live `regions..dwell` in addition to [Common Output Track Fields](#common-output-track-fields) | +| `entered` | array | Objects that entered the region during this cycle; each element is a bare track object and may include live `regions..dwell`. Empty when no entry occurred | +| `exited` | array | Objects that exited the region during this cycle; each element is `{"object": , "dwell": }`. Empty when no exit occurred | +| `metadata` | object | Region geometry: `title`, `uuid`, `points` (polygon vertices in metres), `area` (`"poly"`), `fromSensor` (boolean) | ### Example Region Event Message diff --git a/tests/sscape_tests/scenescape/test_detections_builder.py b/tests/sscape_tests/scenescape/test_detections_builder.py index 9c006ab2f..92826e936 100644 --- a/tests/sscape_tests/scenescape/test_detections_builder.py +++ b/tests/sscape_tests/scenescape/test_detections_builder.py @@ -11,12 +11,12 @@ from controller.scene import TripwireEvent from controller.moving_object import ChainData from scene_common.geometry import Point -from scene_common.timestamp import get_iso_time +from scene_common.timestamp import get_epoch_time, get_iso_time def _build_object(*, velocity=None, include_sensor_payload=True): chain_data = ChainData( - regions={'region-a': {'entered': '2026-03-31T10:00:00Z'}}, + regions={'region-a': {'entered': '2026-03-31T10:00:00.000Z'}}, publishedLocations=[], persist={'asset_tag': 'forklift-7'}, ) @@ -80,13 +80,24 @@ def test_build_detections_list_serializes_metadata_sensors_and_visibility(self): assert detection['metadata']['reid']['model_name'] == 'reid-model' assert detection['sensors']['temp-1']['values'][0][1] == 21.5 assert detection['sensors']['badge-1']['values'][0][1] == 'authorized' - assert detection['regions'] == {'region-a': {'entered': '2026-03-31T10:00:00Z'}} + assert detection['regions'] == {'region-a': {'entered': '2026-03-31T10:00:00.000Z'}} assert detection['camera_bounds'] == { 'cam-1': {'x': 10, 'y': 20, 'width': 30, 'height': 40, 'projected': False} } assert detection['persistent_data'] == {'asset_tag': 'forklift-7'} assert detection['first_seen'] == get_iso_time(obj.first_seen) + def test_build_detections_list_adds_region_dwell_when_requested(self): + obj = _build_object(velocity=Point(4.0, 5.0)) + scene = SimpleNamespace(output_lla=False) + + detections = buildDetectionsList( + [obj], scene, include_sensors=True, + include_region_dwell=True, current_time=get_epoch_time('2026-03-31T10:00:05.000Z')) + + assert detections[0]['regions']['region-a']['entered'] == '2026-03-31T10:00:00.000Z' + assert detections[0]['regions']['region-a']['dwell'] == pytest.approx(5.0) + def test_build_detections_list_omits_sensor_data_when_disabled(self): obj = _build_object(velocity=Point(4.0, 5.0)) scene = SimpleNamespace(output_lla=False) @@ -95,7 +106,7 @@ def test_build_detections_list_omits_sensor_data_when_disabled(self): assert len(detections) == 1 assert 'sensors' not in detections[0] - assert detections[0]['regions'] == {'region-a': {'entered': '2026-03-31T10:00:00Z'}} + assert detections[0]['regions'] == {'region-a': {'entered': '2026-03-31T10:00:00.000Z'}} def test_build_detections_list_does_not_leak_sensors_between_calls(self): obj = _build_object(velocity=Point(4.0, 5.0)) @@ -106,6 +117,8 @@ def test_build_detections_list_does_not_leak_sensors_between_calls(self): assert 'sensors' in with_sensors[0] assert 'sensors' not in without_sensors[0] + assert 'dwell' not in with_sensors[0]['regions']['region-a'] + assert 'dwell' not in without_sensors[0]['regions']['region-a'] def test_build_detections_dict_handles_tripwire_and_defaults_missing_velocity(self): obj = _build_object(velocity=None, include_sensor_payload=False)