Skip to content

Commit 7fa6208

Browse files
authored
ITEP-23442: Include dwell time in region updates topic (#1300)
Adds “live region dwell time” to region-scoped object outputs (region data + region events) so consumers can see how long an object has been inside a region at publish time. Additionally, it updates the stability test to include memory leak checks.
1 parent 466be6e commit 7fa6208

File tree

10 files changed

+315
-32
lines changed

10 files changed

+315
-32
lines changed

controller/src/controller/detections_builder.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,45 @@
77
from scene_common import log
88
from scene_common.earth_lla import convertXYZToLLA, calculateHeading
99
from scene_common.geometry import DEFAULTZ, Point, Size
10-
from scene_common.timestamp import get_iso_time
10+
from scene_common.timestamp import get_epoch_time, get_iso_time
1111

1212

13-
def buildDetectionsDict(objects, scene, include_sensors=False):
13+
def buildDetectionsDict(objects, scene, include_sensors=False, include_region_dwell=False, current_time=None):
1414
result_dict = {}
1515
for obj in objects:
16-
obj_dict = prepareObjDict(scene, obj, False, include_sensors)
16+
obj_dict = prepareObjDict(scene, obj, False, include_sensors, include_region_dwell, current_time)
1717
result_dict[obj_dict['id']] = obj_dict
1818
return result_dict
1919

20-
def buildDetectionsList(objects, scene, update_visibility=False, include_sensors=False):
20+
def buildDetectionsList(objects, scene, update_visibility=False, include_sensors=False,
21+
include_region_dwell=False, current_time=None):
2122
result_list = []
2223
for obj in objects:
23-
obj_dict = prepareObjDict(scene, obj, update_visibility, include_sensors)
24+
obj_dict = prepareObjDict(scene, obj, update_visibility, include_sensors,
25+
include_region_dwell, current_time)
2426
result_list.append(obj_dict)
2527
return result_list
2628

27-
def prepareObjDict(scene, obj, update_visibility, include_sensors=False):
29+
def _get_region_entered_epoch(region_data):
30+
entered_epoch = region_data.get('entered_epoch')
31+
if entered_epoch is None:
32+
entered_epoch = get_epoch_time(region_data['entered'])
33+
region_data['entered_epoch'] = entered_epoch
34+
return entered_epoch
35+
36+
def _build_region_output(regions, include_region_dwell, current_time):
37+
serialized_regions = {}
38+
for region_name, region_data in regions.items():
39+
serialized_region = dict(region_data)
40+
serialized_region.pop('entered_epoch', None)
41+
if include_region_dwell and 'entered' in region_data:
42+
entered = _get_region_entered_epoch(region_data)
43+
serialized_region['dwell'] = current_time - entered
44+
serialized_regions[region_name] = serialized_region
45+
return serialized_regions
46+
47+
def prepareObjDict(scene, obj, update_visibility, include_sensors=False,
48+
include_region_dwell=False, current_time=None):
2849
aobj = obj
2950
if isinstance(obj, TripwireEvent):
3051
aobj = obj.object
@@ -89,7 +110,12 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False):
89110
if hasattr(aobj, 'chain_data'):
90111
chain_data = aobj.chain_data
91112
if len(chain_data.regions):
92-
obj_dict['regions'] = chain_data.regions
113+
if include_region_dwell:
114+
if current_time is None:
115+
current_time = get_epoch_time()
116+
obj_dict['regions'] = _build_region_output(chain_data.regions, include_region_dwell, current_time)
117+
else:
118+
obj_dict['regions'] = chain_data.regions
93119

94120
if include_sensors:
95121
sensors_output = {}

controller/src/controller/scene_controller.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,16 @@ def publishRegulatedDetections(self, scene_obj, msg_objects, otype, jdata, camer
276276
return
277277

278278
def publishRegionDetections(self, scene, objects, otype, jdata):
279+
current_time = get_epoch_time(jdata['timestamp'])
279280
for rname in scene.regions:
280281
robjects = []
281282
for obj in objects:
282283
if rname in obj.chain_data.regions:
283284
robjects.append(obj)
284285
# Region-specific detections: include sensor data
285-
jdata['objects'] = buildDetectionsList(robjects, scene, False, include_sensors=True)
286+
jdata['objects'] = buildDetectionsList(
287+
robjects, scene, False, include_sensors=True,
288+
include_region_dwell=True, current_time=current_time)
286289
olen = len(jdata['objects'])
287290
rid = scene.name + "/" + rname + "/" + otype
288291
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):
346349
num_objects += counts[otype]
347350
all_objects += objects
348351
event_data['counts'] = counts
349-
detections_dict = buildDetectionsDict(all_objects, scene, include_sensors=True)
352+
detections_dict = buildDetectionsDict(
353+
all_objects, scene, include_sensors=True,
354+
include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp']))
350355
event_data['objects'] = list(detections_dict.values())
351356
return detections_dict, num_objects
352357

@@ -364,7 +369,9 @@ def _buildEnteredObjsList(self, scene, region, event_data, detections_dict):
364369

365370
# Build any objects not in detections_dict (e.g., from sensor events)
366371
if missing_objs:
367-
entered_objs = buildDetectionsList(missing_objs, scene, False, include_sensors=True)
372+
entered_objs = buildDetectionsList(
373+
missing_objs, scene, False, include_sensors=True,
374+
include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp']))
368375
event_data['entered'].extend(entered_objs)
369376

370377
def _buildExitedObjsList(self, scene, region, event_data):
@@ -377,7 +384,9 @@ def _buildExitedObjsList(self, scene, region, event_data):
377384
exited_dict[exited_obj.gid] = dwell
378385
exited_objs.extend([exited_obj])
379386
# Exit events: include sensor data (timestamped readings and attribute events)
380-
exited_objs = buildDetectionsList(exited_objs, scene, False, include_sensors=True)
387+
exited_objs = buildDetectionsList(
388+
exited_objs, scene, False, include_sensors=True,
389+
include_region_dwell=True, current_time=get_epoch_time(event_data['timestamp']))
381390
exited_data = [{'object': exited_obj, 'dwell': exited_dict[exited_obj['id']]} for exited_obj in exited_objs]
382391
event_data['exited'].extend(exited_data)
383392
return

docs/user-guide/microservices/controller/_assets/scene-controller-api.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,16 @@ channels:
199199
type: array
200200
items:
201201
type: object
202+
description: >-
203+
In region-scoped payloads, objects currently inside a region include a live
204+
regions.<region_id>.dwell value in seconds alongside the entered timestamp.
202205
entered:
203206
type: array
204207
items:
205208
type: object
206209
description: >-
207210
Objects that entered the region during this cycle. In region events, each element is a bare
208-
track object. In tripwire events, this array is always empty. May be
211+
track object and may include regions.<region_id>.dwell for the current region. In tripwire events, this array is always empty. May be
209212
empty in region events when no entry occurred.
210213
exited:
211214
type: array

docs/user-guide/microservices/controller/data_formats.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ tracked object contains the following fields:
196196
| `velocity` | array[3] of number | Velocity vector (`x`, `y`, `z`) in metres per second |
197197
| `rotation` | array[4] of number | Orientation quaternion |
198198
| `visibility` | array of string | Camera IDs currently observing this object |
199-
| `regions` | object | Map of region/sensor IDs to entry timestamps (`{id: {entered: timestamp}}`) |
199+
| `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}}`. |
200200
| `sensors` | object | Map of sensor IDs to timestamped readings (`{id: [[timestamp, value], ...]}`) |
201201
| `similarity` | number or null | Re-ID similarity score; `null` when not computed |
202202
| `first_seen` | string (ISO 8601) | Timestamp when the track was first created |
@@ -210,6 +210,12 @@ tracked object contains the following fields:
210210
> camera input it is a base64-encoded string. `metadata` is absent when no semantic
211211
> analytics pipeline is configured.
212212
213+
> **Note on live region dwell**: In region data and region event payloads, objects that are
214+
> still inside a region include `regions.<region_id>.dwell`, which is the current elapsed
215+
> time in seconds since that object entered the region. Exit records continue to expose the
216+
> final dwell time separately as `{"object": <track>, "dwell": <seconds>}` in the
217+
> top-level `exited` array.
218+
213219
## Data Scene Output Message Format
214220

215221
Published on MQTT topic: `scenescape/data/scene/{scene_id}/{thing_type}`
@@ -375,18 +381,18 @@ interest changes. The `{event_type}` segment is typically `objects`.
375381

376382
### Region Event Top-Level Fields
377383

378-
| Field | Type | Description |
379-
| ------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
380-
| `timestamp` | string (ISO 8601 UTC) | Event timestamp |
381-
| `scene_id` | string | Scene identifier (UUID) |
382-
| `scene_name` | string | Scene name |
383-
| `region_id` | string | Region identifier (UUID) |
384-
| `region_name` | string | Region name |
385-
| `counts` | object | Map of category to object count currently inside the region (e.g. `{"person": 2}`) |
386-
| `objects` | array | Tracked objects currently inside the region (see [Common Output Track Fields](#common-output-track-fields)) |
387-
| `entered` | array | Objects that entered the region during this cycle; each element is a bare track object. Empty when no entry occurred |
388-
| `exited` | array | Objects that exited the region during this cycle; each element is `{"object": <track>, "dwell": <seconds>}`. Empty when no exit occurred |
389-
| `metadata` | object | Region geometry: `title`, `uuid`, `points` (polygon vertices in metres), `area` (`"poly"`), `fromSensor` (boolean) |
384+
| Field | Type | Description |
385+
| ------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
386+
| `timestamp` | string (ISO 8601 UTC) | Event timestamp |
387+
| `scene_id` | string | Scene identifier (UUID) |
388+
| `scene_name` | string | Scene name |
389+
| `region_id` | string | Region identifier (UUID) |
390+
| `region_name` | string | Region name |
391+
| `counts` | object | Map of category to object count currently inside the region (e.g. `{"person": 2}`) |
392+
| `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) |
393+
| `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 |
394+
| `exited` | array | Objects that exited the region during this cycle; each element is `{"object": <track>, "dwell": <seconds>}`. Empty when no exit occurred |
395+
| `metadata` | object | Region geometry: `title`, `uuid`, `points` (polygon vertices in metres), `area` (`"poly"`), `fromSensor` (boolean) |
390396

391397
### Example Region Event Message
392398

tests/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,10 @@ build-time: # NEX-T12520
270270

271271
system-stability: # NEX-T10411
272272
$(eval HOURS ?= 24)
273+
$(eval OLDSECRETSDIR := $(SECRETSDIR))
274+
$(eval SECRETSDIR := $(PWD)/manager/secrets)
273275
$(call common-recipe, $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/dlstreamer/retail_video.yml:$(COMPOSE)/scene.yml:$(COMPOSE)/web.yml:$(COMPOSE)/cams.yml, tests/system/stability/tc_sscape_stability.py --hours=$(HOURS),'pgserver web scene',true)
276+
$(eval SECRETSDIR := $(OLDSECRETSDIR))
274277

275278
python-indent-check: # NEX-T10448
276279
$(eval LOGFILE=$(TEST_DATA)/code_check/$@-$(shell date -u +"%F-%T").log)

tests/functional/common_scene_obj.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ def verifyRegionEvent(self, regionEvent):
7070
self.expectedExit.append(event['id'])
7171
self.expectedEnter.remove(event['id'])
7272
self.entered = True
73+
# Track entry time for dwell verification
74+
if event['id'] not in self.objectEntryTimes:
75+
self.objectEntryTimes[event['id']] = get_epoch_time()
7376
print("object with id {} entered region\n".format(event['id']))
7477

7578
if len(regionEvent['exited']) > 0:
@@ -129,6 +132,8 @@ def runSceneObjMqttInitialize(self):
129132
self.roiPoints = ((0.9, 4.0), (0.9, 2.4),
130133
(8.1, 2.4), (8.1, 4.0))
131134
self.message_received_after_delete = False
135+
self.objectEntryTimes = {} # Track when each object entered region for dwell verification
136+
self.previousDwellTimes = {} # Track previous dwell times to verify monotonic increase
132137
if self.testName and self.recordXMLAttribute:
133138
self.recordXMLAttribute("name", self.testName)
134139

@@ -152,6 +157,40 @@ def sceneReady(self, max_attempts, waitTopic, publishTopic, objData):
152157
def regulatedReceived(self, pahoClient, userdata, message):
153158
data = message.payload.decode("utf-8")
154159
self.sceneData = json.loads(data)
160+
# Track that dwell data appears in scene messages when expected
161+
self.verifyDwellPresenceInSceneData(self.sceneData)
162+
return
163+
164+
def verifyDwellPresenceInSceneData(self, sceneData):
165+
"""Verify dwell values appear in scene object data when objects are in regions.
166+
167+
Unlike unit tests which validate calculation formulas, this checks integration:
168+
that dwell data is present and available in the scene data stream.
169+
"""
170+
if 'objects' not in sceneData or not sceneData['objects']:
171+
return
172+
173+
for obj in sceneData['objects']:
174+
obj_id = obj.get('id')
175+
if not obj_id or obj_id not in self.objectEntryTimes:
176+
continue
177+
178+
# Check that dwell data is present for objects known to be in regions
179+
if 'regions' in obj and obj['regions']:
180+
for region_name, region_data in obj['regions'].items():
181+
if 'dwell' in region_data:
182+
dwell = region_data['dwell']
183+
# Basic sanity check: dwell should be non-negative
184+
assert dwell >= 0, f"Object {obj_id} has negative dwell: {dwell}"
185+
186+
# Verify monotonic increase (no dwell decrease over time)
187+
key = (obj_id, region_name)
188+
if key in self.previousDwellTimes:
189+
prev_dwell = self.previousDwellTimes[key]
190+
assert dwell >= prev_dwell - 0.2, \
191+
f"Object {obj_id} dwell in {region_name} decreased from {prev_dwell:.2f} to {dwell:.2f}"
192+
193+
self.previousDwellTimes[key] = dwell
155194
return
156195

157196
def runSceneObjMqttPrepare(self):

tests/functional/tc_mqtt_sensor_roi.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,27 @@ def runSceneObjMqttVerifyPassedExtra(self):
7171
assert self.checkedEntered > 0
7272
assert self.checkedExited > 0
7373
assert self.checkedValues > 0
74+
# Verify dwell window calculation is sensible
75+
self.verifyDwellWindowExists()
7476
return True
7577

78+
def verifyDwellWindowExists(self):
79+
"""Verify dwell window metadata exists for integration validation.
80+
81+
This validates integration aspects only - that entry/exit times were properly
82+
captured. The actual dwell calculation formulas are already tested by unit tests.
83+
"""
84+
if self.enteredTimestamp is None:
85+
return # No entry detected, nothing to verify
86+
87+
if self.exitedTimestamp is not None:
88+
dwell_window = self.exitedTimestamp - self.enteredTimestamp
89+
assert dwell_window >= 0, f"Invalid dwell window: exit before entry"
90+
print(f"Dwell window validated: {dwell_window:.2f}s from entry to exit")
91+
else:
92+
print("Object still in region - no exit timestamp to validate")
93+
return
94+
7695
def sendDetections(self, objLocation, frame_rate):
7796
jdata = self.objData()
7897
start_time = get_epoch_time()

0 commit comments

Comments
 (0)