Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions controller/src/controller/detections_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +33 to +39
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computing dwell currently calls get_epoch_time() (which uses datetime.strptime) for every region on every serialized object. In region-scoped outputs this can become a hot path with many objects/regions per frame. Consider caching an epoch form of entered in chain_data.regions at entry time (or storing entered_epoch alongside the ISO string) so dwell can be computed without repeated string parsing.

Suggested change
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
def _get_region_entered_epoch(region_data):
entered_epoch = region_data.get('entered_epoch')
if entered_epoch is None and 'entered' in region_data:
entered_epoch = get_epoch_time(region_data['entered'])
region_data['entered_epoch'] = entered_epoch
return entered_epoch
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)
serialized_region.pop('entered_epoch', None)
if include_region_dwell and 'entered' in region_data:
entered_epoch = _get_region_entered_epoch(region_data)
serialized_region['dwell'] = current_time - entered_epoch

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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)
Comment thread
saratpoluri marked this conversation as resolved.
else:
obj_dict['regions'] = chain_data.regions

if include_sensors:
sensors_output = {}
Expand Down
17 changes: 13 additions & 4 deletions controller/src/controller/scene_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,16 @@ channels:
type: array
items:
type: object
description: >-
In region-scoped payloads, objects currently inside a region include a live
regions.<region_id>.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.<region_id>.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
Expand Down
32 changes: 19 additions & 13 deletions docs/user-guide/microservices/controller/data_formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.<region_id>.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": <track>, "dwell": <seconds>}` in the
> top-level `exited` array.

## Data Scene Output Message Format

Published on MQTT topic: `scenescape/data/scene/{scene_id}/{thing_type}`
Expand Down Expand Up @@ -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": <track>, "dwell": <seconds>}`. 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.<region_id>.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.<region_id>.dwell`. Empty when no entry occurred |
| `exited` | array | Objects that exited the region during this cycle; each element is `{"object": <track>, "dwell": <seconds>}`. Empty when no exit occurred |
| `metadata` | object | Region geometry: `title`, `uuid`, `points` (polygon vertices in metres), `area` (`"poly"`), `fromSensor` (boolean) |

### Example Region Event Message

Expand Down
21 changes: 17 additions & 4 deletions tests/sscape_tests/scenescape/test_detections_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
)
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand All @@ -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)
Expand Down
Loading