diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfba94ced..44c70d059 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -88,8 +88,8 @@ Sensors → MQTT (broker) → Scene Controller → Manager/Web UI **Key Targets** (from root `Makefile`): ```bash -make build-core # Default: core services (autocalibration, controller, manager, model_installer) -make build-all # Includes experimental (mapping + cluster_analytics) +make build-core # Default: core services +make build-all # Includes experimental make build-experimental # Mapping + cluster_analytics only make rebuild-core # Clean + build (useful after code changes) ``` @@ -105,15 +105,24 @@ make rebuild-core # Clean + build (useful after code changes) **For comprehensive test creation guidance, see `.github/skills/testing.md`** - detailed instructions on creating unit, functional, integration, UI, and smoke tests with both positive and negative cases. -**Running Tests** (must have containers running via docker-compose): +**Test Prerequisites** (required before running any tests): ```bash -SUPASS= make setup_tests # Build test images -make run_basic_acceptance_tests # Quick acceptance tests -make -C tests unit-tests # Unit tests only -make -C tests geometry-unit # Specific test (e.g., geometry) +make setup_tests SUPASS= # Rebuilds ALL images (runtime + test), initializes secrets & .env + # MUST run after code changes to pick them up ``` +**Running Tests**: + +```bash +make run_unit_tests # All unit tests (requires setup_tests first) +make -C tests reid-unique-count # Specific functional test (requires setup_tests first) +make -C tests geometry-unit # Specific unit test (requires setup_tests first) +make run_basic_acceptance_tests # Quick acceptance/smoke tests (requires setup_tests first) +``` + +**Key Point**: Always run `make setup_tests` after code changes - it rebuilds Docker images to pick up your modifications. + ### Completion Gate For Test Tasks (Critical) For runtime test verification requirements, use @@ -167,21 +176,23 @@ pubsub.publish(topic, json_payload) **Modifying a Microservice** (e.g., controller): 1. Edit source in `controller/src/` -2. Rebuild: `make rebuild-controller` (cleans old image, rebuilds) -3. Restart containers: `docker compose up -d scene` (or full `docker compose up`) +2. Rebuild: `make rebuild-core` (from root) or per-service builds (see service's Agents.md for commands) +3. For testing: `make setup_tests SUPASS=` - Rebuilds ALL runtime + test images 4. Check logs: `docker compose logs scene -f` **Adding Dependencies**: -- Python: Update `requirements-runtime.txt`, rebuild image +- Python: Update `requirements-runtime.txt` in service folder - System: Add to `Dockerfile` RUN section (apt packages) -- Shared lib changes: Rebuild `scene_common`, then dependent services +- Shared lib (scene_common): Use `make rebuild-core` to propagate changes + +**Running Tests After Code Changes**: -**Debugging Tests**: +1. `make setup_tests SUPASS=` (required before any tests) +2. Run specific test targets (e.g., `make -C tests reid-unique-count`) +3. See `.github/skills/testing.md` for detailed test creation and debugging -- Use `debugtest.py` for running tests without pytest harness (useful in containers) -- View test output: `docker compose exec cat ` -- Specific test: `pytest tests/sscape_tests/geometry/test_point.py::TestPoint::test_constructor -v` +**Service-Specific Commands**: Check each service's `Agents.md` file for build and test details. ## Integration Points & Dependencies diff --git a/autocalibration/Agents.md b/autocalibration/Agents.md index 291039bc2..0cf359b86 100644 --- a/autocalibration/Agents.md +++ b/autocalibration/Agents.md @@ -74,22 +74,30 @@ The **Auto Camera Calibration** service (formerly `camcalibration`) computes cam ### Building the Service ```bash -# From root directory -make autocalibration # Build image -make rebuild-autocalibration # Clean + rebuild - -# Build with dependencies -make build-core # Includes autocalibration +# From repo root +make -C autocalibration # Build autocalibration +make -C autocalibration test-build # Build autocalibration + test image + +# OR from autocalibration/ directory +cd autocalibration && make # Build autocalibration +cd autocalibration && make test-build # Build autocalibration + test image + +# Root-level builds (handles all dependencies) +make rebuild-core # Rebuild all core services with dependencies +make build-core # Build all core services +make setup_tests SUPASS= # Full test environment setup ``` ### Testing ```bash +# Setup test images first +make setup_tests SUPASS= + # Unit tests make -C tests autocalibration-unit -# Functional tests (requires running containers) -SUPASS= make setup_tests +# Functional tests make -C tests autocalibration-functional ``` diff --git a/cluster_analytics/Agents.md b/cluster_analytics/Agents.md index 945236b77..a88bcbfc4 100644 --- a/cluster_analytics/Agents.md +++ b/cluster_analytics/Agents.md @@ -132,21 +132,31 @@ The **Cluster Analytics** service provides advanced object clustering, tracking, ### Building the Service ```bash -# From root directory -make cluster_analytics # Build image -make rebuild-cluster_analytics # Clean + rebuild +# From repo root +make -C cluster_analytics # Build cluster_analytics +make -C cluster_analytics test-build # Build cluster_analytics + test image + +# OR from cluster_analytics/ directory +cd cluster_analytics && make # Build cluster_analytics +cd cluster_analytics && make test-build # Build cluster_analytics + test image + +# Root-level builds (handles all dependencies) +make rebuild-core # Rebuild core services make build-experimental # Build experimental services make build-all # All services including experimental +make setup_tests SUPASS= # Full test environment setup ``` ### Testing ```bash +# Setup test images first +make setup_tests SUPASS= + # Unit tests make -C tests cluster-analytics-unit -# Functional tests (requires running containers) -SUPASS= make setup_tests +# Functional tests make -C tests cluster-analytics-functional # Specific test module diff --git a/controller/Agents.md b/controller/Agents.md index 9f1a829c1..61def3072 100644 --- a/controller/Agents.md +++ b/controller/Agents.md @@ -135,24 +135,35 @@ The **Scene Controller** is the central runtime state management service for Int ### Building the Service ```bash -# From root directory -make controller # Build image (alias: scene) -make rebuild-controller # Clean + rebuild +# From repo root +make -C controller # Build controller +make -C controller test-build # Build controller + test image + +# OR from controller/ directory +cd controller && make # Build controller +cd controller && make test-build # Build controller + test image + +# Root-level builds (handles all dependencies) +make rebuild-core # Rebuild all core services with dependencies make build-core # Build all core services +make setup_tests SUPASS= # Full test environment setup ``` ### Testing ```bash -# Unit tests -make -C tests controller-unit -make -C tests geometry-unit # Test fast_geometry +# Setup test images first (required before running any tests) +make setup_tests SUPASS= -# Functional tests (requires running containers) -SUPASS= make setup_tests -make -C tests controller-functional +# Run tests +make -C tests controller-unit # Controller unit tests +make -C tests geometry-unit # Test fast_geometry +make -C tests controller-functional # Functional tests +``` # Specific test module + +```bash pytest tests/sscape_tests/controller/test_tracking.py -v ``` diff --git a/controller/src/controller/detections_builder.py b/controller/src/controller/detections_builder.py index 5368262d0..e9db1e003 100644 --- a/controller/src/controller/detections_builder.py +++ b/controller/src/controller/detections_builder.py @@ -123,6 +123,15 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False): obj_dict['similarity'] = aobj.similarity if hasattr(aobj, 'first_seen'): obj_dict['first_seen'] = get_iso_time(aobj.first_seen) + + # Add reid state for downstream business logic to distinguish "never queried" from "query made" + if hasattr(aobj, 'reid_state'): + obj_dict['reid_state'] = aobj.reid_state.value # Convert enum to string + + # Add previous IDs chain for post-mortem object stitching analysis + if hasattr(aobj, 'previous_ids_chain') and aobj.previous_ids_chain: + obj_dict['previous_ids_chain'] = aobj.previous_ids_chain + if isinstance(obj, TripwireEvent): obj_dict['direction'] = obj.direction if hasattr(aobj, 'asset_scale'): diff --git a/controller/src/controller/moving_object.py b/controller/src/controller/moving_object.py index 727d626da..7b317f730 100644 --- a/controller/src/controller/moving_object.py +++ b/controller/src/controller/moving_object.py @@ -6,6 +6,7 @@ import struct import warnings from dataclasses import dataclass, field +from enum import Enum from threading import Lock from typing import Dict, List @@ -27,6 +28,19 @@ LOCATION_LIMIT = 20 SPEED_THRESHOLD = 0.1 +class ReidState(Enum): + """State of ReID query and matching for an object. + + PENDING_COLLECTION: Collecting embeddings, query not yet made + QUERY_NO_MATCH: Query made but no match found (new object) + MATCHED: Successfully matched to previous object (reID) + REID_DISABLED: ReID system is disabled, no query will be made + """ + PENDING_COLLECTION = "pending_collection" + QUERY_NO_MATCH = "query_no_match" + MATCHED = "matched" + REID_DISABLED = "reid_disabled" + @dataclass class ChainData: regions: Dict @@ -113,6 +127,9 @@ def __init__(self, info, when, camera): self.intersected = False self.reid = {} # Initialize reid as empty dict self.metadata = {} # Initialize metadata as empty dict + self.reid_state = ReidState.PENDING_COLLECTION # Track reID state + self.similarity = None # Similarity score from last reID match + self.previous_ids_chain = [] # Track object ID history: [{'id': gid, 'timestamp': ts, 'similarity_score': score}, ...] # Extract reid from metadata if present and preserve metadata attribute metadata_from_info = self.info.get('metadata', {}) if metadata_from_info and isinstance(metadata_from_info, dict): @@ -217,6 +234,9 @@ def setPrevious(self, otherObj): self.gid = otherObj.gid self.first_seen = otherObj.first_seen self.frameCount = otherObj.frameCount + 1 + self.reid_state = otherObj.reid_state + self.similarity = otherObj.similarity + self.previous_ids_chain = otherObj.previous_ids_chain.copy() del self.chain_data.publishedLocations[LOCATION_LIMIT:] @@ -302,6 +322,39 @@ def _projectBounds(self): def when(self): return self.location[0].when + def record_id_change(self, new_id, similarity_score=None, timestamp=None): + """Record a change in object ID (for post-mortem stitching analysis). + + @param new_id: The new global ID assigned to this object + @param similarity_score: Similarity score from reID matching (if matched), or None if new object + @param timestamp: When the change occurred (epoch time), defaults to current time + """ + if timestamp is None: + import time + timestamp = time.time() + + self.previous_ids_chain.append({ + 'id': new_id, + 'timestamp': timestamp, + 'similarity_score': similarity_score + }) + log.debug(f"MovingObject.record_id_change: rv_id={getattr(self, 'rv_id', 'unknown')}, " + f"new_id={new_id}, similarity={similarity_score}, state={self.reid_state.value}") + + def is_reided(self): + """Check if this object resulted from successful reID matching. + + @return: True if object was matched to a previous object, False otherwise + """ + return self.reid_state == ReidState.MATCHED + + def get_previous_ids(self): + """Get chain of previous IDs for this object. + + @return: List of dicts with 'id', 'timestamp', 'similarity_score' for post-mortem analysis + """ + return self.previous_ids_chain.copy() + def __repr__(self): return "%s: %s/%s %s %s vectors: %s" % \ (self.__class__.__name__, diff --git a/controller/src/controller/uuid_manager.py b/controller/src/controller/uuid_manager.py index 37adf258c..0fba5775d 100644 --- a/controller/src/controller/uuid_manager.py +++ b/controller/src/controller/uuid_manager.py @@ -3,12 +3,11 @@ import collections import concurrent.futures -import json import threading import time -from unittest import result from controller.vdms_adapter import VDMSDatabase +from controller.moving_object import ReidState, MovingObject from scene_common import log from scene_common.timestamp import get_epoch_time @@ -180,9 +179,6 @@ def pruneInactiveTracks(self, tracked_objects): self.active_query.pop(track_id, None) self.quality_features.pop(track_id, None) self.features_for_database_timestamps.pop(track_id, None) - # Increment the unique id counter for tracks where no match was found (similarity=None) - if data[1] is None: - self.unique_id_count += 1 self._addNewFeaturesToDatabase(track_id) return @@ -248,8 +244,8 @@ def pickBestID(self, sscape_object): """ Checks if there is a value for the database ID corresponding to the active track for a Scenescape object in the active tracks dictionary. If one does exist, we set the gid and - similarity of the object to the values in the dictionary. Otherwise, we keep the gid from - the tracker. + similarity of the object to the values in the dictionary. Also updates reid_state if a + query has been made. Also stores semantic metadata for future database storage. @@ -257,17 +253,28 @@ def pickBestID(self, sscape_object): """ # LOOKUP ID IN DICT result = self.active_ids.get(sscape_object.rv_id, None) - # DATABASE ID IS NOT NULL + # DATABASE ID IS NOT NULL (query has been made and completed) if result and result[0] is not None: sscape_object.gid = result[0] sscape_object.similarity = result[1] + + # Update reid_state based on similarity (whether it was a match or not) + if sscape_object.reid_state == ReidState.PENDING_COLLECTION: + # Only update if query has been made (indicated by non-None result[0]) + if result[1] is not None: + # result[1] has a similarity score, so this was a match + sscape_object.reid_state = ReidState.MATCHED + else: + # result[1] is None, so no match found + sscape_object.reid_state = ReidState.QUERY_NO_MATCH + reid_embedding = self._extractReidEmbedding(sscape_object) if reid_embedding is not None: if sscape_object.rv_id in self.features_for_database: self.features_for_database[sscape_object.rv_id]['reid_vectors'].append( reid_embedding) - # DATABASE ID IS NULL + # DATABASE ID IS NULL (query not yet made or active_ids not yet initialized) else: sscape_object.similarity = None return @@ -293,13 +300,18 @@ def querySimilarity(self, sscape_object): @param sscape_object The current Scenescape object """ + # Mark that we're about to attempt a query (transition from PENDING_COLLECTION) + # This allows downstream logic to distinguish "never queried" from "query made" + start_time = get_epoch_time() + similarity_scores = self.sendSimilarityQuery(sscape_object) database_id, similarity = self.parseQueryResults(similarity_scores) + with self.active_ids_lock: # Make sure object is still in active_ids before updating since there is a chance # that the similiarity search does not complete until after the object leaves if sscape_object.rv_id in self.active_ids: - self.updateActiveDict(sscape_object, database_id, similarity) + self.updateActiveDict(sscape_object, database_id, similarity, query_timestamp=start_time) else: log.warning( f"Track {sscape_object.rv_id} left scene before ID query finished") @@ -385,32 +397,74 @@ def _findMinimumDistance(self, entities): return (minimum_distance_entity['uuid'], minimum_distance_entity['_distance']) return (None, None) - def updateActiveDict(self, sscape_object, database_id, similarity): + def updateActiveDict(self, sscape_object, database_id, similarity, query_timestamp=None): """ Updates the dictionary tracking the active tracker IDs and their corresponding database IDs. Also creates an entry in the features_for_database dictionary with semantic metadata to be added to the database when the track leaves the scene. - @param sscape_object The current Scenescape object - @param database_id The ID from the database - @param similarity The similarity score from the database + Updates object's reid_state and records ID changes in previous_ids_chain for post-mortem + stitching analysis. + + @param sscape_object The current Scenescape object + @param database_id The ID from the database (or newly generated if no match) + @param similarity The similarity score from the database (None if no match) + @param query_timestamp When the query was initiated (for chain recording) """ + if query_timestamp is None: + query_timestamp = get_epoch_time() + matched_new_id = database_id is not None and self.isNewID(database_id) + database_id_collision = database_id is not None and not matched_new_id + # MATCH FOUND - YES + DB ID ALREADY IN DICT - NO - if database_id and self.isNewID(database_id): - self.active_ids[sscape_object.rv_id] = [database_id, similarity] + if matched_new_id: + # Query succeeded and found a match -> update state to MATCHED + sscape_object.reid_state = ReidState.MATCHED + sscape_object.gid = database_id + sscape_object.similarity = similarity + # Record the matched ID in chain with similarity score + sscape_object.record_id_change(database_id, similarity_score=similarity, timestamp=query_timestamp) + log.debug( - f"updateActiveDict: Match found for {sscape_object.rv_id}: {database_id},{similarity}") - # MATCH FOUND - NO / DB ID ALREADY IN DICT - YES + f"updateActiveDict: Match found for {sscape_object.rv_id}: {database_id}, similarity={similarity}, state={ReidState.MATCHED.value}") + self.active_ids[sscape_object.rv_id] = [database_id, similarity] + + reid_embedding = self._extractReidEmbedding(sscape_object) + if reid_embedding is not None: + if sscape_object.rv_id in self.features_for_database: + self.features_for_database[sscape_object.rv_id]['reid_vectors'].append( + reid_embedding) + + # MATCH FOUND - NO / NEW OBJECT else: + if database_id_collision: + log.warning( + f"updateActiveDict: Database ID collision for track {sscape_object.rv_id}: " + f"{database_id} is already assigned to another active track; treating as no-match") + # Query made but no match -> state is now QUERY_NO_MATCH (distinguishes from PENDING_COLLECTION) + sscape_object.reid_state = ReidState.QUERY_NO_MATCH + # Keep a unique gid if one already exists for this object, otherwise generate one. + if sscape_object.gid is not None and self.isNewID(sscape_object.gid): + database_id = sscape_object.gid + else: + with MovingObject.gid_lock: + database_id = MovingObject.gid_counter + MovingObject.gid_counter += 1 + sscape_object.gid = database_id + sscape_object.similarity = None + # Record the new ID in chain with no match (None similarity indicates no match) + sscape_object.record_id_change(database_id, similarity_score=None, timestamp=query_timestamp) + + # Increment counter for unique objects with actual query attempts that found no match + self.unique_id_count += 1 + log.debug(f"updateActiveDict: No match, assigned new gid={database_id} for track {sscape_object.rv_id}, state={ReidState.QUERY_NO_MATCH.value}") self.active_ids[sscape_object.rv_id] = [sscape_object.gid, None] - database_id = sscape_object.gid - log.debug(f"updateActiveDict: No match, using gid={database_id} for track {sscape_object.rv_id}") # Store features with semantic metadata for TIER 1 filtering in future queries num_features = len(self.quality_features.get(sscape_object.rv_id, [])) log.debug(f"updateActiveDict: Storing {num_features} features for track {sscape_object.rv_id} to features_for_database") self.features_for_database[sscape_object.rv_id] = { - 'gid': database_id, + 'gid': sscape_object.gid, 'category': sscape_object.category, 'reid_vectors': self.quality_features[sscape_object.rv_id], 'metadata': self._extractSemanticMetadata(sscape_object) @@ -444,6 +498,10 @@ def assignID(self, sscape_object): with self.active_ids_lock: self.active_ids.setdefault(sscape_object.rv_id, [None, None]) + # If reid is disabled, mark object state immediately (no query will be made) + if not self.reid_enabled: + sscape_object.reid_state = ReidState.REID_DISABLED + # Continue gathering features until we have enough or query is already submitted if sscape_object.rv_id not in self.active_query and self.reid_enabled: self.gatherQualityVisualFeatures(sscape_object) diff --git a/controller/tests/test_reid_state_tracking.py b/controller/tests/test_reid_state_tracking.py new file mode 100644 index 000000000..a5ffea9a0 --- /dev/null +++ b/controller/tests/test_reid_state_tracking.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for ReID state tracking and ID chaining functionality in MovingObject. + +Tests cover: +- ReidState enum definition and transitions +- previous_ids_chain recording and retrieval +- Similarity score tracking +- State transition logic (PENDING_COLLECTION → MATCHED/QUERY_NO_MATCH) +""" + +import pytest +import time +from unittest.mock import Mock, MagicMock +from pathlib import Path + +# Add controller/src to path +import sys +controller_src = Path(__file__).resolve().parents[1] / 'src' +sys.path.insert(0, str(controller_src)) + +from controller.moving_object import MovingObject, ReidState + + +class TestReidStateEnum: + """Test ReidState enum definition and values.""" + + def test_reid_state_enum_has_four_states(self): + """Verify ReidState enum has all required states.""" + states = [state.value for state in ReidState] + assert len(states) == 4 + assert "pending_collection" in states + assert "query_no_match" in states + assert "matched" in states + assert "reid_disabled" in states + + def test_reid_state_enum_values_are_strings(self): + """Verify ReidState enum values are properly formatted strings.""" + assert ReidState.PENDING_COLLECTION.value == "pending_collection" + assert ReidState.QUERY_NO_MATCH.value == "query_no_match" + assert ReidState.MATCHED.value == "matched" + assert ReidState.REID_DISABLED.value == "reid_disabled" + + def test_reid_state_enum_equality(self): + """Verify ReidState enum comparison works correctly.""" + state1 = ReidState.MATCHED + state2 = ReidState.MATCHED + state3 = ReidState.PENDING_COLLECTION + + assert state1 == state2 + assert state1 != state3 + assert state1.value == state2.value + + +class TestMovingObjectReidStateInitialization: + """Test MovingObject initialization with reid state tracking.""" + + def setup_method(self): + """Set up mock camera for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + def test_moving_object_initializes_with_pending_collection_state(self): + """Verify new MovingObject starts in PENDING_COLLECTION state.""" + info = {'id': '1', 'confidence': 0.95} + timestamp = time.time() + + obj = MovingObject(info, timestamp, self.mock_camera) + + assert obj.reid_state == ReidState.PENDING_COLLECTION + assert obj.reid_state.value == "pending_collection" + + def test_moving_object_initializes_with_none_similarity(self): + """Verify similarity score is None at initialization.""" + info = {'id': '1', 'confidence': 0.95} + timestamp = time.time() + + obj = MovingObject(info, timestamp, self.mock_camera) + + assert obj.similarity is None + + def test_moving_object_initializes_with_empty_chain(self): + """Verify previous_ids_chain is empty list at initialization.""" + info = {'id': '1', 'confidence': 0.95} + timestamp = time.time() + + obj = MovingObject(info, timestamp, self.mock_camera) + + assert isinstance(obj.previous_ids_chain, list) + assert len(obj.previous_ids_chain) == 0 + + +class TestRecordIdChange: + """Test record_id_change() method for ID chain tracking.""" + + def setup_method(self): + """Set up mock camera and object for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + self.info = {'id': '1', 'confidence': 0.95} + self.timestamp = time.time() + self.obj = MovingObject(self.info, self.timestamp, self.mock_camera) + + def test_record_id_change_adds_entry_to_chain(self): + """Verify record_id_change() adds entry to previous_ids_chain.""" + new_id = "gid_123" + similarity = 0.87 + ts = time.time() + + self.obj.record_id_change(new_id, similarity_score=similarity, timestamp=ts) + + assert len(self.obj.previous_ids_chain) == 1 + assert self.obj.previous_ids_chain[0]['id'] == new_id + assert self.obj.previous_ids_chain[0]['similarity_score'] == similarity + assert self.obj.previous_ids_chain[0]['timestamp'] == ts + + def test_record_id_change_with_none_similarity(self): + """Verify record_id_change() handles None similarity (new object case).""" + new_id = "gid_456" + ts = time.time() + + self.obj.record_id_change(new_id, similarity_score=None, timestamp=ts) + + assert len(self.obj.previous_ids_chain) == 1 + assert self.obj.previous_ids_chain[0]['id'] == new_id + assert self.obj.previous_ids_chain[0]['similarity_score'] is None + assert self.obj.previous_ids_chain[0]['timestamp'] == ts + + def test_record_id_change_uses_current_time_when_timestamp_not_provided(self): + """Verify record_id_change() uses current time if timestamp is None.""" + new_id = "gid_789" + before = time.time() + + self.obj.record_id_change(new_id, similarity_score=0.92, timestamp=None) + + after = time.time() + recorded_time = self.obj.previous_ids_chain[0]['timestamp'] + + assert before <= recorded_time <= after + + def test_record_id_change_appends_multiple_entries(self): + """Verify multiple record_id_change() calls build chain correctly.""" + ts1 = time.time() + self.obj.record_id_change("gid_1", similarity_score=0.85, timestamp=ts1) + + ts2 = time.time() + 1.0 + self.obj.record_id_change("gid_2", similarity_score=0.90, timestamp=ts2) + + ts3 = time.time() + 2.0 + self.obj.record_id_change("gid_3", similarity_score=0.88, timestamp=ts3) + + assert len(self.obj.previous_ids_chain) == 3 + assert self.obj.previous_ids_chain[0]['id'] == "gid_1" + assert self.obj.previous_ids_chain[1]['id'] == "gid_2" + assert self.obj.previous_ids_chain[2]['id'] == "gid_3" + + def test_record_id_change_maintains_chronological_order(self): + """Verify entries in chain maintain insertion order (chronological).""" + timestamps = [] + for i in range(5): + ts = time.time() + i * 0.1 + timestamps.append(ts) + self.obj.record_id_change(f"gid_{i}", similarity_score=0.80 + i * 0.02, timestamp=ts) + + for i, entry in enumerate(self.obj.previous_ids_chain): + assert entry['timestamp'] == timestamps[i] + + +class TestIsReided: + """Test is_reided() helper method.""" + + def setup_method(self): + """Set up mock camera and object for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + self.info = {'id': '1', 'confidence': 0.95} + self.obj = MovingObject(self.info, time.time(), self.mock_camera) + + def test_is_reided_returns_false_for_pending_collection(self): + """Verify is_reided() returns False when state is PENDING_COLLECTION.""" + self.obj.reid_state = ReidState.PENDING_COLLECTION + + assert self.obj.is_reided() is False + + def test_is_reided_returns_false_for_query_no_match(self): + """Verify is_reided() returns False when state is QUERY_NO_MATCH.""" + self.obj.reid_state = ReidState.QUERY_NO_MATCH + + assert self.obj.is_reided() is False + + def test_is_reided_returns_true_for_matched(self): + """Verify is_reided() returns True when state is MATCHED.""" + self.obj.reid_state = ReidState.MATCHED + + assert self.obj.is_reided() is True + + def test_is_reided_returns_false_for_reid_disabled(self): + """Verify is_reided() returns False when state is REID_DISABLED.""" + self.obj.reid_state = ReidState.REID_DISABLED + + assert self.obj.is_reided() is False + + def test_is_reided_reflects_state_changes(self): + """Verify is_reided() reflects dynamic state changes.""" + assert self.obj.is_reided() is False + + self.obj.reid_state = ReidState.MATCHED + assert self.obj.is_reided() is True + + self.obj.reid_state = ReidState.QUERY_NO_MATCH + assert self.obj.is_reided() is False + + self.obj.reid_state = ReidState.REID_DISABLED + assert self.obj.is_reided() is False + + +class TestGetPreviousIds: + """Test get_previous_ids() method for chain retrieval.""" + + def setup_method(self): + """Set up mock camera and object for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + self.info = {'id': '1', 'confidence': 0.95} + self.obj = MovingObject(self.info, time.time(), self.mock_camera) + + def test_get_previous_ids_returns_empty_list_on_new_object(self): + """Verify get_previous_ids() returns empty list for new object.""" + ids = self.obj.get_previous_ids() + + assert isinstance(ids, list) + assert len(ids) == 0 + + def test_get_previous_ids_returns_copy_not_reference(self): + """Verify get_previous_ids() returns copy, not direct reference.""" + ts = time.time() + self.obj.record_id_change("gid_1", similarity_score=0.85, timestamp=ts) + + ids1 = self.obj.get_previous_ids() + ids2 = self.obj.get_previous_ids() + + # Modify returned list (should not affect internal state) + ids1.append({'id': 'fake_gid', 'timestamp': ts, 'similarity_score': 0.5}) + + # Second retrieval should not include the fake entry + assert len(ids2) == 1 + assert ids2[0]['id'] == "gid_1" + + def test_get_previous_ids_returns_all_chain_entries(self): + """Verify get_previous_ids() returns all entries in chain.""" + ts_base = time.time() + for i in range(5): + ts = ts_base + i * 0.1 + self.obj.record_id_change(f"gid_{i}", similarity_score=0.80 + i * 0.02, timestamp=ts) + + ids = self.obj.get_previous_ids() + + assert len(ids) == 5 + assert ids[0]['id'] == "gid_0" + assert ids[4]['id'] == "gid_4" + + def test_get_previous_ids_preserves_entry_structure(self): + """Verify get_previous_ids() preserves complete entry structure.""" + ts = time.time() + self.obj.record_id_change("gid_test", similarity_score=0.92, timestamp=ts) + + ids = self.obj.get_previous_ids() + entry = ids[0] + + assert 'id' in entry + assert 'timestamp' in entry + assert 'similarity_score' in entry + assert entry['id'] == "gid_test" + assert entry['similarity_score'] == 0.92 + assert entry['timestamp'] == ts + + +class TestStateTransitions: + """Test state transitions in realistic scenarios.""" + + def setup_method(self): + """Set up mock camera and object for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + self.info = {'id': '1', 'confidence': 0.95} + self.obj = MovingObject(self.info, time.time(), self.mock_camera) + + def test_transition_pending_to_matched(self): + """Simulate state transition: PENDING_COLLECTION → MATCHED.""" + assert self.obj.reid_state == ReidState.PENDING_COLLECTION + assert self.obj.is_reided() is False + + # Simulate successful reid match + self.obj.reid_state = ReidState.MATCHED + self.obj.similarity = 0.95 + ts = time.time() + self.obj.record_id_change("matched_gid_123", similarity_score=0.95, timestamp=ts) + + assert self.obj.reid_state == ReidState.MATCHED + assert self.obj.is_reided() is True + assert self.obj.similarity == 0.95 + assert len(self.obj.previous_ids_chain) == 1 + assert self.obj.previous_ids_chain[0]['id'] == "matched_gid_123" + + def test_transition_pending_to_query_no_match(self): + """Simulate state transition: PENDING_COLLECTION → QUERY_NO_MATCH.""" + assert self.obj.reid_state == ReidState.PENDING_COLLECTION + + # Simulate query with no match (new object) + self.obj.reid_state = ReidState.QUERY_NO_MATCH + self.obj.similarity = None + ts = time.time() + self.obj.record_id_change("new_gid_456", similarity_score=None, timestamp=ts) + + assert self.obj.reid_state == ReidState.QUERY_NO_MATCH + assert self.obj.is_reided() is False + assert self.obj.similarity is None + assert len(self.obj.previous_ids_chain) == 1 + assert self.obj.previous_ids_chain[0]['id'] == "new_gid_456" + assert self.obj.previous_ids_chain[0]['similarity_score'] is None + + def test_multi_frame_tracking_with_state_persistence(self): + """Test realistic scenario: object tracked across multiple frames with state persistence.""" + # Frame 1: New detection, pending reid collection + assert self.obj.reid_state == ReidState.PENDING_COLLECTION + + # Frame 2: Query made, matched to previous object + self.obj.reid_state = ReidState.MATCHED + self.obj.similarity = 0.92 + ts1 = time.time() + self.obj.record_id_change("gid_1", similarity_score=0.92, timestamp=ts1) + + # Frame 3: Still same object, state persists + assert self.obj.reid_state == ReidState.MATCHED + assert self.obj.gid == None # gid set via uuid_manager, not in this test + + # Frame 4: Object re-identified in different camera (hypothetical scenario) + ts2 = time.time() + 1.0 + self.obj.record_id_change("gid_2", similarity_score=0.88, timestamp=ts2) + + chain = self.obj.get_previous_ids() + assert len(chain) == 2 + assert chain[0]['id'] == "gid_1" + assert chain[0]['similarity_score'] == 0.92 + assert chain[1]['id'] == "gid_2" + assert chain[1]['similarity_score'] == 0.88 + + def test_transition_pending_to_reid_disabled(self): + """Simulate state transition: PENDING_COLLECTION → REID_DISABLED (when VDMS disabled).""" + assert self.obj.reid_state == ReidState.PENDING_COLLECTION + assert len(self.obj.previous_ids_chain) == 0 + + # Simulate case where reid system is disabled (e.g., VDMS not available) + self.obj.reid_state = ReidState.REID_DISABLED + self.obj.similarity = None + # No record_id_change() - no query happened + + assert self.obj.reid_state == ReidState.REID_DISABLED + assert self.obj.is_reided() is False + assert self.obj.similarity is None + assert len(self.obj.previous_ids_chain) == 0 # No chain entry - no query/match occurred + + +class TestChainDataIntegrity: + """Test chain data integrity under various conditions.""" + + def setup_method(self): + """Set up mock camera and object for each test.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock( + return_value=Mock() + ) + + self.info = {'id': '1', 'confidence': 0.95} + self.obj = MovingObject(self.info, time.time(), self.mock_camera) + + def test_chain_with_mixed_similarity_scores(self): + """Test chain tracking with varying similarity scores.""" + ts_base = time.time() + + # High similarity match + self.obj.record_id_change("gid_1", similarity_score=0.99, timestamp=ts_base) + # Low but valid similarity match + self.obj.record_id_change("gid_2", similarity_score=0.51, timestamp=ts_base + 1.0) + # No match (new object) + self.obj.record_id_change("gid_3", similarity_score=None, timestamp=ts_base + 2.0) + + chain = self.obj.get_previous_ids() + + assert chain[0]['similarity_score'] == 0.99 + assert chain[1]['similarity_score'] == 0.51 + assert chain[2]['similarity_score'] is None + + def test_chain_with_float_similarity_precision(self): + """Test that similarity scores maintain floating-point precision.""" + ts = time.time() + precision_value = 0.8675309 + + self.obj.record_id_change("gid_precise", similarity_score=precision_value, timestamp=ts) + + chain = self.obj.get_previous_ids() + assert chain[0]['similarity_score'] == precision_value + + def test_chain_with_boundary_similarity_values(self): + """Test chain with boundary similarity values (0.0 and 1.0).""" + ts_base = time.time() + + # Perfect match + self.obj.record_id_change("gid_perfect", similarity_score=1.0, timestamp=ts_base) + # Worst possible match (still valid) + self.obj.record_id_change("gid_worst", similarity_score=0.0, timestamp=ts_base + 1.0) + + chain = self.obj.get_previous_ids() + + assert chain[0]['similarity_score'] == 1.0 + assert chain[1]['similarity_score'] == 0.0 + + def test_large_chain_integrity(self): + """Test chain integrity with large number of entries.""" + ts_base = time.time() + chain_size = 1000 + + for i in range(chain_size): + ts = ts_base + i * 0.01 + similarity = 0.5 + (i % 50) * 0.01 # Varying similarities + self.obj.record_id_change(f"gid_{i}", similarity_score=similarity, timestamp=ts) + + chain = self.obj.get_previous_ids() + + assert len(chain) == chain_size + assert chain[0]['id'] == "gid_0" + assert chain[chain_size - 1]['id'] == f"gid_{chain_size - 1}" + # Verify chronological order is maintained + for i in range(len(chain) - 1): + assert chain[i]['timestamp'] <= chain[i + 1]['timestamp'] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/docs/user-guide/microservices/controller/Extended-ReID.md b/docs/user-guide/microservices/controller/Extended-ReID.md index ec9b2d8b3..f455fafe1 100644 --- a/docs/user-guide/microservices/controller/Extended-ReID.md +++ b/docs/user-guide/microservices/controller/Extended-ReID.md @@ -114,6 +114,22 @@ Result: "Find strong age-gender matches, refined by vector similarity" - Valid range: 0.0 to 1.0 - Example: Set to `0.7` to include more metadata filters, `0.9` for stricter filtering +## ReID Object States + +Each tracked object carries a `reid_state` field in scene output, indicating where it is in the ReID lifecycle: + +- `pending_collection`: ReID features are still being accumulated; query not submitted yet. +- `query_no_match`: Query was submitted and no candidate passed matching threshold. +- `matched`: Query succeeded and object was matched to an existing database identity. +- `reid_disabled`: ReID is disabled and query is skipped. + +Typical transitions: + +- ReID enabled: `pending_collection` → `matched` or `query_no_match` +- ReID disabled: `reid_disabled` + +For payload-level field formatting in published messages, see [Scene Controller Data Formats](data_formats.md#common-output-track-fields). + ## Configuring Confidence Threshold The confidence threshold determines which metadata constraints are applied in TIER 1 filtering. Only constraints meeting or exceeding the threshold are used. Constraints below the threshold are skipped, allowing vector similarity in TIER 2 to handle the matching: diff --git a/docs/user-guide/microservices/controller/data_formats.md b/docs/user-guide/microservices/controller/data_formats.md index 064b33f9b..7d2950def 100644 --- a/docs/user-guide/microservices/controller/data_formats.md +++ b/docs/user-guide/microservices/controller/data_formats.md @@ -199,6 +199,7 @@ tracked object contains the following fields: | `regions` | object | Map of region/sensor IDs to entry timestamps (`{id: {entered: timestamp}}`) | | `sensors` | object | Map of sensor IDs to timestamped readings (`{id: [[timestamp, value], ...]}`) | | `similarity` | number or null | Re-ID similarity score; `null` when not computed | +| `reid_state` | string | Re-ID processing state for the object. One of: `pending_collection`, `query_no_match`, `matched`, `reid_disabled` | | `first_seen` | string (ISO 8601) | Timestamp when the track was first created | | `metadata` | object | Semantic attributes propagated from camera detections; present when visual analytics (e.g. age, gender, Re-ID) are configured. Same attribute structure as camera input. See note below. | | `camera_bounds` | object | Per-camera pixel bounding boxes (`{camera_id: {x, y, width, height, projected}}`) where `projected=false` means detector-provided pixel bbox and `projected=true` means computed projection; may be empty (`{}`) when no camera currently observes the track | @@ -210,6 +211,13 @@ 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 `reid_state` values**: +> +> - `pending_collection`: Re-ID embedding collection is in progress; query has not been submitted yet. +> - `query_no_match`: Query was submitted but no database match was found. +> - `matched`: Query found a database match and the object was re-identified. +> - `reid_disabled`: Re-ID is disabled for this object lifecycle (for example due to runtime disablement). + ## Data Scene Output Message Format Published on MQTT topic: `scenescape/data/scene/{scene_id}/{thing_type}` @@ -279,6 +287,7 @@ objects of that category. "temperature_1": [["2026-03-26T20:49:53.661Z", 70]] }, "similarity": null, + "reid_state": "pending_collection", "first_seen": "2026-03-26T20:49:49.339Z" } ] diff --git a/docs/user-guide/other-topics/how-to-enable-reidentification.md b/docs/user-guide/other-topics/how-to-enable-reidentification.md index d50bebeb5..8343ccb1d 100644 --- a/docs/user-guide/other-topics/how-to-enable-reidentification.md +++ b/docs/user-guide/other-topics/how-to-enable-reidentification.md @@ -141,6 +141,8 @@ When an object is first detected, it is assigned a UUID and no similarity score. - **Match Found**: The object is reassigned a matching UUID and given a similarity score. - **No Match**: The object retains its original UUID. +The scene output includes `reid_state` for each tracked object. For canonical state definitions and lifecycle transitions, see [2-Tier Hybrid Search Implementation](../microservices/controller/Extended-ReID.md#reid-object-states). For output field contract details, see [Scene Controller Data Formats](../microservices/controller/data_formats.md#common-output-track-fields). + > **Known Issue**: Current VDMS implementation does not support feature expiration, leading to degraded performance over time. This will be addressed in a future release. --- diff --git a/manager/Agents.md b/manager/Agents.md index ba3d0b76d..47fdfb8b7 100644 --- a/manager/Agents.md +++ b/manager/Agents.md @@ -95,12 +95,16 @@ The **Manager** service is the Django-based web UI and REST API gateway for Inte ### Building the Service ```bash -# From root directory -make manager # Build image -make rebuild-manager # Clean + rebuild +# From repo root +make -C manager # Build manager -# Build with dependencies -make build-core # Includes manager +# OR from manager/ directory +cd manager && make # Build manager + +# Root-level builds (handles all dependencies) +make rebuild-core # Rebuild all core services with dependencies +make build-core # Build all core services +make setup_tests SUPASS= # Full test environment setup ``` ### Database Migrations @@ -119,11 +123,13 @@ docker compose exec manager python manage.py showmigrations ### Testing ```bash +# Setup test images first +make setup_tests SUPASS= + # Django unit tests docker compose exec manager python manage.py test # External acceptance tests -SUPASS= make setup_tests make -C tests manager-functional ``` diff --git a/mapping/Agents.md b/mapping/Agents.md index 257ef2e2a..25b0e65df 100644 --- a/mapping/Agents.md +++ b/mapping/Agents.md @@ -102,21 +102,31 @@ Object Query → Visual Grounding → 3D Coordinates → Scene Controller ### Building the Service ```bash -# From root directory (experimental build) -make mapping # Build image -make rebuild-mapping # Clean + rebuild +# From repo root +make -C mapping # Build mapping +make -C mapping test-build # Build mapping + test image + +# OR from mapping/ directory +cd mapping && make # Build mapping +cd mapping && make test-build # Build mapping + test image + +# Root-level builds (handles all dependencies) +make rebuild-core # Rebuild core services make build-experimental # Build mapping + cluster_analytics make build-all # All services including experimental +make setup_tests SUPASS= # Full test environment setup ``` ### Testing ```bash +# Setup test images first +make setup_tests SUPASS= + # Unit tests make -C tests mapping-unit -# Functional tests (requires running containers) -SUPASS= make setup_tests +# Functional tests make -C tests mapping-functional # Manual API testing diff --git a/tests/Makefile.functional b/tests/Makefile.functional index b4c95ca4f..7d5a7761a 100644 --- a/tests/Makefile.functional +++ b/tests/Makefile.functional @@ -259,7 +259,9 @@ reid-performance-degradation: # NEX-T10541 $(eval SECRETSDIR := $(OLDSECRETSDIR)) $(eval override BASE_IMAGE := $(IMAGE_OLD)) -reid-unique-count: # NEX-T10539 +reid-unique-count: reid-unique-count-default reid-unique-count-time-chunking + +reid-unique-count-default: # NEX-T10539 $(eval SERVICES := $(strip pgserver web queuing-video retail-video scene)) $(eval COMPOSE_FILES := $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/vdms.yml:$(COMPOSE)/dlstreamer/retail_video_reid.yml:$(COMPOSE)/dlstreamer/queuing_video_reid.yml:$(COMPOSE)/scene_reid.yml:$(COMPOSE)/web_default.yml:$(COMPOSE)/cams.yml) $(eval override IMAGE_OLD := $(BASE_IMAGE)) @@ -270,6 +272,17 @@ reid-unique-count: # NEX-T10539 $(eval SECRETSDIR := $(OLDSECRETSDIR)) $(eval override BASE_IMAGE := $(IMAGE_OLD)) +reid-unique-count-time-chunking: # NEX-T10539 + $(eval SERVICES := $(strip pgserver web queuing-video retail-video scene)) + $(eval COMPOSE_FILES := $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/vdms.yml:$(COMPOSE)/dlstreamer/retail_video_reid.yml:$(COMPOSE)/dlstreamer/queuing_video_reid.yml:$(COMPOSE)/scene_reid_time_chunking.yml:$(COMPOSE)/web_default.yml:$(COMPOSE)/cams.yml) + $(eval override IMAGE_OLD := $(BASE_IMAGE)) + $(eval BASE_IMAGE := $(IMAGE)-controller-test) + $(eval OLDSECRETSDIR := $(SECRETSDIR)) + $(eval SECRETSDIR := $(PWD)/manager/secrets) + $(call common-recipe, $(COMPOSE_FILES), tests/functional/tc_reid_unique_count.py, '$(SERVICES)', true) + $(eval SECRETSDIR := $(OLDSECRETSDIR)) + $(eval override BASE_IMAGE := $(IMAGE_OLD)) + reid-data-flow: # NEX-T19883 $(eval SERVICES := $(strip broker ntpserv pgserver vdms web scene)) $(eval COMPOSE_FILES := $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/vdms.yml:$(COMPOSE)/scene_reid.yml:$(COMPOSE)/web_default.yml:$(COMPOSE)/cams.yml) @@ -281,7 +294,9 @@ reid-data-flow: # NEX-T19883 $(eval SECRETSDIR := $(OLDSECRETSDIR)) $(eval override BASE_IMAGE := $(IMAGE_OLD)) -reid-semantic-unique-count: # NEX-T19882 +reid-semantic-unique-count: reid-semantic-unique-count-default reid-semantic-unique-count-time-chunking + +reid-semantic-unique-count-default: # NEX-T19882 $(eval SERVICES := $(strip pgserver web queuing-video scene)) $(eval COMPOSE_FILES := $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/vdms.yml:$(COMPOSE)/dlstreamer/queuing_video_reid_semantic.yml:$(COMPOSE)/scene_reid.yml:$(COMPOSE)/web_default.yml:$(COMPOSE)/cams.yml) $(eval override IMAGE_OLD := $(BASE_IMAGE)) @@ -293,6 +308,18 @@ reid-semantic-unique-count: # NEX-T19882 $(eval SECRETSDIR := $(OLDSECRETSDIR)) $(eval override BASE_IMAGE := $(IMAGE_OLD)) +reid-semantic-unique-count-time-chunking: # NEX-T19882 + $(eval SERVICES := $(strip pgserver web queuing-video scene)) + $(eval COMPOSE_FILES := $(COMPOSE)/dlstreamer/broker.yml:$(COMPOSE)/ntp.yml:$(COMPOSE)/pgserver.yml:$(COMPOSE)/vdms.yml:$(COMPOSE)/dlstreamer/queuing_video_reid_semantic.yml:$(COMPOSE)/scene_reid_time_chunking.yml:$(COMPOSE)/web_default.yml:$(COMPOSE)/cams.yml) + $(eval override IMAGE_OLD := $(BASE_IMAGE)) + $(eval BASE_IMAGE := $(IMAGE)-controller-test) + $(eval OLDSECRETSDIR := $(SECRETSDIR)) + $(eval SECRETSDIR := $(PWD)/manager/secrets) + $(eval export MODELS := all) + $(call common-recipe, $(COMPOSE_FILES), tests/functional/tc_reid_semantic_unique_count.py, '$(SERVICES)', true) + $(eval SECRETSDIR := $(OLDSECRETSDIR)) + $(eval override BASE_IMAGE := $(IMAGE_OLD)) + rest-test: # NEX-T10464 $(eval COMPOSE_FILES := $(COMPOSE)/pgserver.yml:$(COMPOSE)/web.yml) $(call common-recipe, $(COMPOSE_FILES), manager/tests/tc_rest_test.py, 'pgserver web', true, /run/secrets/controller.auth) diff --git a/tests/compose/scene_reid_time_chunking.yml b/tests/compose/scene_reid_time_chunking.yml new file mode 100644 index 000000000..2b13d73d8 --- /dev/null +++ b/tests/compose/scene_reid_time_chunking.yml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +networks: + scenescape-test: + +secrets: + root-cert: + file: ${SECRETSDIR}/certs/scenescape-ca.pem + vdms-client-cert: + file: ${SECRETSDIR}/certs/scenescape-vdms-c.crt + vdms-client-key: + file: ${SECRETSDIR}/certs/scenescape-vdms-c.key + django: + file: ${SECRETSDIR}/django + controller.auth: + environment: CONTROLLER_AUTH + +services: + scene: + image: scenescape-controller + init: true + networks: + scenescape-test: + depends_on: + web: + condition: service_healthy + broker: + condition: service_started + ntpserv: + condition: service_started + vdms: + condition: service_started + command: > + --restauth /run/secrets/controller.auth + --brokerauth /run/secrets/controller.auth + --broker broker.scenescape.intel.com + --ntp ntpserv + volumes: + - ./:/workspace + - ./${DBROOT}/media:/workspace/media + - ./controller/config/tracker-config-time-chunking.json:/home/scenescape/SceneScape/tracker-config.json + - ./controller/config/reid-config.json:/home/scenescape/SceneScape/reid-config.json + secrets: + - source: root-cert + target: certs/scenescape-ca.pem + - source: vdms-client-key + target: certs/scenescape-vdms-c.key + - source: vdms-client-cert + target: certs/scenescape-vdms-c.crt + - django + - controller.auth + restart: on-failure diff --git a/tests/functional/tc_reid_semantic_unique_count.py b/tests/functional/tc_reid_semantic_unique_count.py index bde356cb2..14e55a9a4 100644 --- a/tests/functional/tc_reid_semantic_unique_count.py +++ b/tests/functional/tc_reid_semantic_unique_count.py @@ -23,7 +23,8 @@ def test_reid_semantic_unique_count(params, record_xml_attribute): "302cf49a-97ec-402d-a324-c5077b280b7b": { "error": False, "current": 0, - "maximum": 10 + "minimum": 3, + "maximum": 6 } } diff --git a/tests/functional/tc_reid_unique_count.py b/tests/functional/tc_reid_unique_count.py index 353af68ee..f3adc43a9 100755 --- a/tests/functional/tc_reid_unique_count.py +++ b/tests/functional/tc_reid_unique_count.py @@ -66,7 +66,17 @@ def check_unique_detections(): log.error(f"The unique detection counter for {scene} somehow got decremented!") return False + minimum = detection_count[scene].get("minimum", 1) + for scene in detection_count: + minimum = detection_count[scene].get("minimum", 1) + if detection_count[scene]["current"] < minimum: + log.error( + f"The unique detection counter for {scene} is below minimum: " + f"{detection_count[scene]['current']} (min: {minimum})!" + ) + return False + if detection_count[scene]["current"] <= 0: log.error(f"The unique detection counter for {scene} shouldn't be 0!") return False @@ -123,12 +133,14 @@ def test_reid_unique_count(params, record_xml_attribute): "3bc091c7-e449-46a0-9540-29c499bca18c": { "error": False, "current": 0, - "maximum": 20 + "minimum": 2, + "maximum": 10 }, "302cf49a-97ec-402d-a324-c5077b280b7b": { "error": False, "current": 0, - "maximum": 10 + "minimum": 3, + "maximum": 6 } } diff --git a/tests/sscape_tests/scenescape/test_detections_builder.py b/tests/sscape_tests/scenescape/test_detections_builder.py index 9c006ab2f..0720ff4a3 100644 --- a/tests/sscape_tests/scenescape/test_detections_builder.py +++ b/tests/sscape_tests/scenescape/test_detections_builder.py @@ -9,7 +9,7 @@ from controller.detections_builder import buildDetectionsDict, buildDetectionsList, prepareObjDict from controller.scene import TripwireEvent -from controller.moving_object import ChainData +from controller.moving_object import ChainData, ReidState from scene_common.geometry import Point from scene_common.timestamp import get_iso_time @@ -164,3 +164,66 @@ def test_prepare_obj_dict_adds_lla_output_when_enabled(self, mock_convert_xyz_to assert detection['heading'] == [180.0] mock_convert_xyz_to_lla.assert_called_once_with('trs-transform', [1.0, 2.0, 3.0]) mock_calculate_heading.assert_called_once_with('trs-transform', [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]) + + def test_prepare_obj_dict_serializes_reid_state_enum_to_string(self): + """Verify that reid_state enum is converted to its string value in serialization.""" + obj = _build_object(velocity=Point(4.0, 5.0), include_sensor_payload=False) + obj.reid_state = ReidState.MATCHED + scene = SimpleNamespace(output_lla=False) + + detection = prepareObjDict(scene, obj, update_visibility=False) + + # Verify reid_state is present and is a string, not an enum + assert 'reid_state' in detection + assert detection['reid_state'] == 'matched' + assert isinstance(detection['reid_state'], str) + + def test_prepare_obj_dict_serializes_reid_state_with_all_enum_values(self): + """Verify that all ReidState enum values serialize correctly to their string representations.""" + obj = _build_object(velocity=Point(4.0, 5.0), include_sensor_payload=False) + scene = SimpleNamespace(output_lla=False) + + # Test each ReidState enum value + test_cases = [ + (ReidState.PENDING_COLLECTION, 'pending_collection'), + (ReidState.QUERY_NO_MATCH, 'query_no_match'), + (ReidState.MATCHED, 'matched'), + (ReidState.REID_DISABLED, 'reid_disabled'), + ] + + for reid_state_enum, expected_string in test_cases: + obj.reid_state = reid_state_enum + detection = prepareObjDict(scene, obj, update_visibility=False) + assert detection['reid_state'] == expected_string + + def test_prepare_obj_dict_includes_previous_ids_chain_when_present(self): + """Verify that previous_ids_chain is included when it has content.""" + obj = _build_object(velocity=Point(4.0, 5.0), include_sensor_payload=False) + obj.previous_ids_chain = ['old-id-1', 'old-id-2'] + scene = SimpleNamespace(output_lla=False) + + detection = prepareObjDict(scene, obj, update_visibility=False) + + assert 'previous_ids_chain' in detection + assert detection['previous_ids_chain'] == ['old-id-1', 'old-id-2'] + + def test_prepare_obj_dict_omits_previous_ids_chain_when_empty(self): + """Verify that previous_ids_chain is excluded from output when not set.""" + obj = _build_object(velocity=Point(4.0, 5.0), include_sensor_payload=False) + # Don't set previous_ids_chain at all + scene = SimpleNamespace(output_lla=False) + + detection = prepareObjDict(scene, obj, update_visibility=False) + + assert 'previous_ids_chain' not in detection + + def test_prepare_obj_dict_omits_previous_ids_chain_when_explicitly_empty(self): + """Verify that explicitly empty previous_ids_chain is excluded from output.""" + obj = _build_object(velocity=Point(4.0, 5.0), include_sensor_payload=False) + obj.previous_ids_chain = [] + scene = SimpleNamespace(output_lla=False) + + detection = prepareObjDict(scene, obj, update_visibility=False) + + assert 'previous_ids_chain' not in detection + diff --git a/tests/sscape_tests/uuid_manager/test_uuid_manager.py b/tests/sscape_tests/uuid_manager/test_uuid_manager.py index 3abca2f22..e3e039699 100644 --- a/tests/sscape_tests/uuid_manager/test_uuid_manager.py +++ b/tests/sscape_tests/uuid_manager/test_uuid_manager.py @@ -330,48 +330,6 @@ def test_is_new_tracker_id_when_seen_before(self, mock_vdms_class): class TestAssignID: """Test ID assignment logic.""" - @patch('controller.uuid_manager.VDMSDatabase') - def test_assign_id_increments_counter_when_no_reid(self, mock_vdms_class): - """Verify unique_id_count increments when tracker has no reid vector.""" - mock_vdms_instance = MagicMock() - mock_vdms_class.return_value = mock_vdms_instance - - manager = UUIDManager() - initial_count = manager.unique_id_count - - obj = MagicMock() - obj.rv_id = "tracker_no_reid" - obj.reid = None - obj.category = "Person" - obj.gid = "auto_gid_1" - obj.metadata = {} - - manager.assignID(obj) - - assert manager.unique_id_count == initial_count + 1, "Should increment counter when assigning ID to tracker with no reid" - - @patch('controller.uuid_manager.VDMSDatabase') - def test_assign_id_does_not_increment_counter_when_reid_present(self, mock_vdms_class): - """Verify unique_id_count is not incremented when tracker has reid vector.""" - mock_vdms_instance = MagicMock() - mock_vdms_class.return_value = mock_vdms_instance - - manager = UUIDManager() - initial_count = manager.unique_id_count - - obj = MagicMock() - obj.rv_id = "tracker_with_reid" - obj.reid = {"embedding_vector": np.array([0.1, 0.2, 0.3, 0.4]).astype(np.float32).tolist()} - obj.category = "Person" - obj.gid = "auto_gid_1" - obj.boundingBoxPixels = MagicMock() - obj.boundingBoxPixels.area = 10000 - obj.metadata = {} - - manager.assignID(obj) - - assert manager.unique_id_count == initial_count, "Should not increment counter when reid is present" - @patch('controller.uuid_manager.VDMSDatabase') def test_assign_id_initializes_tracking_for_new_tracker(self, mock_vdms_class): """Verify assignID initializes tracking for new tracker IDs.""" @@ -586,3 +544,909 @@ def test_metadata_with_special_characters(self, mock_vdms_class): "model_name": "desc", "confidence": 0.9 } + + +class TestAssignIDUniqueCountNoReid: + """Test assignID() unique_count increment when object has no reid vector.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + # Mock camera for moving objects + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_assign_id_increments_count_when_object_has_no_reid_vector(self): + """Verify unique_count increments immediately when object has no reid vector.""" + from controller.moving_object import MovingObject, ReidState + import time + + # Create object with no reid vector + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = None # No reid vector + + initial_count = self.manager.unique_id_count + self.manager.assignID(obj) + + # Should increment since no reid vector means instant unique object + assert self.manager.unique_id_count == initial_count + 1 + + def test_assign_id_does_not_double_count_same_track(self): + """Verify assignID() doesn't increment for same track on subsequent calls.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = None + + self.manager.assignID(obj) + count_after_first = self.manager.unique_id_count + + # Call assignID() again with same object (same rv_id) + self.manager.assignID(obj) + count_after_second = self.manager.unique_id_count + + # Should NOT increment again (already tracked) + assert count_after_second == count_after_first + + def test_multiple_objects_without_reid_each_increment_count(self): + """Verify each new object without reid increments counter.""" + from controller.moving_object import MovingObject + import time + + initial_count = self.manager.unique_id_count + + for i in range(3): + info = {'id': str(i), 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = i + obj.reid = None + + self.manager.assignID(obj) + + # Each object should have incremented counter + assert self.manager.unique_id_count == initial_count + 3 + + +class TestAssignIDWithReidVector: + """Test assignID() behavior when object has reid vector.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_assign_id_does_not_increment_when_has_reid_vector(self): + """Verify assignID() does NOT increment when object has reid vector (pending query).""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] # Has reid vector + obj.boundingBoxPixels = Mock(area=10000) # Large enough bbox + + initial_count = self.manager.unique_id_count + self.manager.assignID(obj) + + # Should NOT increment (waiting for query result) + assert self.manager.unique_id_count == initial_count + + def test_assign_id_state_remains_pending_with_reid_vector(self): + """Verify object state remains PENDING_COLLECTION when gathering features.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.boundingBoxPixels = Mock(area=10000) + obj.category = 'person' + + self.manager.assignID(obj) + + # State should still be PENDING_COLLECTION (query not submitted yet, insufficient features) + assert obj.reid_state == ReidState.PENDING_COLLECTION + + +class TestUpdateActiveDictQueryNoMatch: + """Test updateActiveDict() unique_count increment for QUERY_NO_MATCH.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_update_active_dict_increments_for_query_no_match(self): + """Verify unique_count increments when query is made but no match found.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + obj.boundingBoxPixels = Mock(area=10000) + + # Initialize active_ids entry + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + # Add quality features to simulate gathered features + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + initial_count = self.manager.unique_id_count + + # Simulate query that found no match + self.manager.updateActiveDict(obj, database_id=None, similarity=None) + + # Should increment (new unique object, no match in database) + assert self.manager.unique_id_count == initial_count + 1 + # State should be QUERY_NO_MATCH + assert obj.reid_state == ReidState.QUERY_NO_MATCH + + def test_update_active_dict_assigns_new_gid_on_no_match(self): + """Verify new GID is assigned when query finds no match.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + # No match found + self.manager.updateActiveDict(obj, database_id=None, similarity=None) + + # GID should be assigned + assert obj.gid is not None + # Similarity should be None (no match) + assert obj.similarity is None + # State should be QUERY_NO_MATCH + from controller.moving_object import ReidState + assert obj.reid_state == ReidState.QUERY_NO_MATCH + + def test_multiple_no_match_objects_each_increment_count(self): + """Verify multiple QUERY_NO_MATCH objects each increment counter.""" + from controller.moving_object import MovingObject + import time + + initial_count = self.manager.unique_id_count + + for i in range(3): + info = {'id': str(i), 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = i + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + # Query found no match + self.manager.updateActiveDict(obj, database_id=None, similarity=None) + + # Each no-match should increment counter + assert self.manager.unique_id_count == initial_count + 3 + + +class TestUpdateActiveDictMatched: + """Test updateActiveDict() does NOT increment for MATCHED objects.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_update_active_dict_does_not_increment_for_matched(self): + """Verify unique_count does NOT increment when query finds a match.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + # Initialize active_ids + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + initial_count = self.manager.unique_id_count + + # Simulate query that found a match in database + matched_gid = "database_gid_existing_123" + similarity_score = 0.92 + self.manager.updateActiveDict(obj, database_id=matched_gid, similarity=similarity_score) + + # Should NOT increment (object already existed in database) + assert self.manager.unique_id_count == initial_count + # State should be MATCHED + assert obj.reid_state == ReidState.MATCHED + # GID should be from database + assert obj.gid == matched_gid + # Similarity should be the match score + assert obj.similarity == similarity_score + + def test_matched_object_does_not_contribute_to_unique_count(self): + """Verify matched objects don't change unique_count.""" + from controller.moving_object import MovingObject + import time + + initial_count = self.manager.unique_id_count + + # Simulate multiple matched objects + for i in range(3): + info = {'id': str(i), 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = i + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + # All found matches in database + self.manager.updateActiveDict(obj, database_id=f"existing_gid_{i}", similarity=0.85 + i*0.01) + + # Matched objects should NOT increase counter + assert self.manager.unique_id_count == initial_count + + +class TestReidDisabledScenario: + """Test unique_count behavior when reid is disabled.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_reid_disabled_sets_state_without_incrementing_count(self): + """Verify REID_DISABLED state set without incrementing unique_count.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] # Has reid vector + obj.boundingBoxPixels = Mock(area=10000) + + # Disable reid + self.manager.reid_enabled = False + + initial_count = self.manager.unique_id_count + self.manager.assignID(obj) + + # State should be REID_DISABLED + assert obj.reid_state == ReidState.REID_DISABLED + # Count should NOT increment (system disabled, no query attempted) + assert self.manager.unique_id_count == initial_count + + def test_reid_disabled_object_with_no_reid_vector_still_increments(self): + """Verify object without reid vector increments even when reid disabled.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = None # No reid vector + + # Disable reid + self.manager.reid_enabled = False + + initial_count = self.manager.unique_id_count + self.manager.assignID(obj) + + # State should be REID_DISABLED + assert obj.reid_state == ReidState.REID_DISABLED + # Should still increment (no reid vector = instant unique) + assert self.manager.unique_id_count == initial_count + 1 + + +class TestMixedScenarioIntegration: + """Test realistic scenarios combining multiple object types.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_three_objects_scenario_count_matches_expected(self): + """ + Integration test: Simulate test scenario with 3 people in queuing scene. + Expected unique_count should be 3 (all are new unique objects). + + Scenario: + - Person A: Has reid vector, query finds no match → count = 1 + - Person B: Has reid vector, query finds no match → count = 2 + - Person C: No reid vector → count = 3 + """ + from controller.moving_object import MovingObject + import time + + initial_count = self.manager.unique_id_count + + # Person A: Query with no match + obj_a = MovingObject({'id': 'A', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_a.rv_id = 'A' + obj_a.reid = [0.1, 0.2, 0.3] + obj_a.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj_a.rv_id] = [None, None] + self.manager.quality_features[obj_a.rv_id] = [[0.1, 0.2, 0.3]] + self.manager.updateActiveDict(obj_a, database_id=None, similarity=None) + + # Person B: Query with no match + obj_b = MovingObject({'id': 'B', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_b.rv_id = 'B' + obj_b.reid = [0.2, 0.3, 0.4] + obj_b.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj_b.rv_id] = [None, None] + self.manager.quality_features[obj_b.rv_id] = [[0.2, 0.3, 0.4]] + self.manager.updateActiveDict(obj_b, database_id=None, similarity=None) + + # Person C: No reid vector + obj_c = MovingObject({'id': 'C', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_c.rv_id = 'C' + obj_c.reid = None + self.manager.assignID(obj_c) + + # Should have 3 unique objects + assert self.manager.unique_id_count == initial_count + 3 + + def test_mixed_matched_and_new_objects_count_only_new(self): + """ + Verify count only includes new unique objects, not matched ones. + - Person A: Matched to database → does NOT increment + - Person B: No match → increments + - Person C: No reid → increments + - Person D: Matched to database → does NOT increment + Expected count = 2 (only B and C) + """ + from controller.moving_object import MovingObject + import time + + initial_count = self.manager.unique_id_count + + # Person A: Matched + obj_a = MovingObject({'id': 'A', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_a.rv_id = 'A' + obj_a.reid = [0.1, 0.2, 0.3] + obj_a.category = 'person' + with self.manager.active_ids_lock: + self.manager.active_ids[obj_a.rv_id] = [None, None] + self.manager.quality_features[obj_a.rv_id] = [[0.1, 0.2, 0.3]] + self.manager.updateActiveDict(obj_a, database_id="existing_A", similarity=0.95) + + # Person B: No match + obj_b = MovingObject({'id': 'B', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_b.rv_id = 'B' + obj_b.reid = [0.2, 0.3, 0.4] + obj_b.category = 'person' + with self.manager.active_ids_lock: + self.manager.active_ids[obj_b.rv_id] = [None, None] + self.manager.quality_features[obj_b.rv_id] = [[0.2, 0.3, 0.4]] + self.manager.updateActiveDict(obj_b, database_id=None, similarity=None) + + # Person C: No reid + obj_c = MovingObject({'id': 'C', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_c.rv_id = 'C' + obj_c.reid = None + self.manager.assignID(obj_c) + + # Person D: Matched + obj_d = MovingObject({'id': 'D', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_d.rv_id = 'D' + obj_d.reid = [0.4, 0.5, 0.6] + obj_d.category = 'person' + with self.manager.active_ids_lock: + self.manager.active_ids[obj_d.rv_id] = [None, None] + self.manager.quality_features[obj_d.rv_id] = [[0.4, 0.5, 0.6]] + self.manager.updateActiveDict(obj_d, database_id="existing_D", similarity=0.90) + + # Only B and C should increment (2 new unique objects) + assert self.manager.unique_id_count == initial_count + 2 + + +class TestUniqueCountEdgeCases: + """Test edge cases and boundary conditions.""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_similarity_zero_still_matches(self): + """Verify object with 0.0 similarity is still counted as MATCHED (not incremented).""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + initial_count = self.manager.unique_id_count + + # Edge case: similarity = 0.0 (worst match, but still a match) + self.manager.updateActiveDict(obj, database_id="existing_gid", similarity=0.0) + + # Should NOT increment (is_matched because similarity is not None) + assert self.manager.unique_id_count == initial_count + assert obj.similarity == 0.0 + assert obj.reid_state == ReidState.MATCHED + + def test_similarity_high_value_still_just_one_count(self): + """Verify high similarity score doesn't cause multiple increments.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + initial_count = self.manager.unique_id_count + + # Perfect match: similarity = 1.0 + self.manager.updateActiveDict(obj, database_id="existing_gid", similarity=1.0) + + # Should NOT increment + assert self.manager.unique_id_count == initial_count + assert obj.similarity == 1.0 + + +class TestUpdateActiveDictStateTransitionsAndChaining: + """Test state transitions and previous_ids_chain recording in updateActiveDict().""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_match_found_records_id_change_with_similarity(self): + """Verify that matched objects record ID change with similarity score in previous_ids_chain.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + # Match found with high similarity + matched_id = "database_gid_match_001" + similarity_score = 0.92 + query_time = time.time() + + self.manager.updateActiveDict(obj, database_id=matched_id, similarity=similarity_score, query_timestamp=query_time) + + # Verify state + assert obj.reid_state == ReidState.MATCHED + assert obj.gid == matched_id + assert obj.similarity == similarity_score + + # Verify previous_ids_chain was populated + assert len(obj.previous_ids_chain) == 1 + chain_entry = obj.previous_ids_chain[0] + assert chain_entry['id'] == matched_id + assert chain_entry['similarity_score'] == similarity_score + assert chain_entry['timestamp'] == query_time + + def test_no_match_records_id_change_with_none_similarity(self): + """Verify that no-match objects record ID change with None similarity in previous_ids_chain.""" + from controller.moving_object import MovingObject, ReidState + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + # Query made but no match found + query_time = time.time() + self.manager.updateActiveDict(obj, database_id=None, similarity=None, query_timestamp=query_time) + + # Verify state + assert obj.reid_state == ReidState.QUERY_NO_MATCH + assert obj.gid is not None + assert obj.similarity is None + + # Verify previous_ids_chain recorded with None similarity + assert len(obj.previous_ids_chain) == 1 + chain_entry = obj.previous_ids_chain[0] + assert chain_entry['id'] == obj.gid + assert chain_entry['similarity_score'] is None + assert chain_entry['timestamp'] == query_time + + def test_match_updates_active_ids_and_similarity(self): + """Verify active_ids dict and similarity are properly updated on match.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + matched_id = "db_match_789" + similarity = 0.87 + self.manager.updateActiveDict(obj, database_id=matched_id, similarity=similarity) + + # Verify active_ids was updated + assert obj.rv_id in self.manager.active_ids + assert self.manager.active_ids[obj.rv_id][0] == matched_id + assert self.manager.active_ids[obj.rv_id][1] == similarity + + def test_no_match_generates_new_gid_and_updates_active_ids(self): + """Verify new GID is generated and active_ids is updated on no match.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + old_gid_counter = MovingObject.gid_counter + + # Query found no match + self.manager.updateActiveDict(obj, database_id=None, similarity=None) + + # Verify new GID was generated + assert obj.gid == old_gid_counter + assert obj.gid is not None + + # Verify active_ids updated with generated GID + assert obj.rv_id in self.manager.active_ids + assert self.manager.active_ids[obj.rv_id][0] == obj.gid + assert self.manager.active_ids[obj.rv_id][1] is None + + def test_features_for_database_populated_on_match(self): + """Verify features_for_database is populated with metadata on match.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95, 'age': 'adult', 'gender': 'male'} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + obj.metadata = {'age': 'adult', 'gender': 'male'} + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3], [0.15, 0.25, 0.35]] + + self.manager.updateActiveDict(obj, database_id="db_match_456", similarity=0.91) + + # Verify features_for_database populated + assert obj.rv_id in self.manager.features_for_database + features_entry = self.manager.features_for_database[obj.rv_id] + assert features_entry['gid'] == "db_match_456" + assert features_entry['category'] == 'person' + assert len(features_entry['reid_vectors']) == 2 + assert features_entry['metadata'] is not None + + def test_features_for_database_populated_on_no_match(self): + """Verify features_for_database is populated on QUERY_NO_MATCH.""" + from controller.moving_object import MovingObject + import time + + info = {'id': '1', 'confidence': 0.95} + obj = MovingObject(info, time.time(), self.mock_camera) + obj.rv_id = 1 + obj.reid = [0.1, 0.2, 0.3] + obj.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj.rv_id] = [None, None] + + self.manager.quality_features[obj.rv_id] = [[0.1, 0.2, 0.3]] + + self.manager.updateActiveDict(obj, database_id=None, similarity=None) + + # Verify features_for_database populated even for no match + assert obj.rv_id in self.manager.features_for_database + features_entry = self.manager.features_for_database[obj.rv_id] + assert features_entry['gid'] == obj.gid + assert features_entry['category'] == 'person' + assert len(features_entry['reid_vectors']) == 1 + + +class TestUpdateActiveDictIDCollisionHandling: + """Test ID-collision handling in updateActiveDict().""" + + def setup_method(self): + """Set up mock database and UUIDManager.""" + self.mock_db = Mock() + self.mock_db.connect = Mock() + with patch('controller.uuid_manager.available_databases', {'VDMS': Mock(return_value=self.mock_db)}): + self.manager = UUIDManager(database='VDMS') + + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def test_isNewID_returns_true_for_unused_id(self): + """Verify isNewID returns True for IDs not yet in active_ids.""" + with self.manager.active_ids_lock: + self.manager.active_ids['rv_1'] = ['db_gid_100', 0.85] + self.manager.active_ids['rv_2'] = ['db_gid_200', 0.90] + + # New ID not in active_ids + assert self.manager.isNewID('db_gid_300') is True + + def test_isNewID_returns_false_for_existing_id(self): + """Verify isNewID returns False for IDs already in active_ids (collision).""" + with self.manager.active_ids_lock: + self.manager.active_ids['rv_1'] = ['db_gid_100', 0.85] + self.manager.active_ids['rv_2'] = ['db_gid_200', 0.90] + + # ID already assigned to rv_1 + assert self.manager.isNewID('db_gid_100') is False + + def test_match_with_existing_id_not_reassigned(self): + """Verify collided database IDs are treated as no-match with a fresh unique gid.""" + from controller.moving_object import MovingObject, ReidState + import time + + # Set up first object already matched to a database ID + info1 = {'id': '1', 'confidence': 0.95} + obj1 = MovingObject(info1, time.time(), self.mock_camera) + obj1.rv_id = 1 + obj1.reid = [0.1, 0.2, 0.3] + obj1.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj1.rv_id] = [None, None] + + self.manager.quality_features[obj1.rv_id] = [[0.1, 0.2, 0.3]] + + # First object matches to database_gid_X + self.manager.updateActiveDict(obj1, database_id='database_gid_X', similarity=0.95) + assert obj1.reid_state == ReidState.MATCHED + assert obj1.gid == 'database_gid_X' + + # Now try to process second object with same database_gid_X (collision scenario) + info2 = {'id': '2', 'confidence': 0.95} + obj2 = MovingObject(info2, time.time(), self.mock_camera) + obj2.rv_id = 2 + obj2.reid = [0.15, 0.25, 0.35] + obj2.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj2.rv_id] = [None, None] + + self.manager.quality_features[obj2.rv_id] = [[0.15, 0.25, 0.35]] + initial_count = self.manager.unique_id_count + + # Collision should be handled as no-match without reusing obj1's active gid. + self.manager.updateActiveDict(obj2, database_id='database_gid_X', similarity=None) + + # obj2 gets QUERY_NO_MATCH state and a new unique gid. + assert obj2.reid_state == ReidState.QUERY_NO_MATCH + assert obj2.gid != 'database_gid_X' + assert obj2.gid != obj1.gid + assert obj2.similarity is None + assert self.manager.active_ids[obj2.rv_id][0] == obj2.gid + assert self.manager.unique_id_count == initial_count + 1 + assert len(obj2.previous_ids_chain) == 1 + assert obj2.previous_ids_chain[0]['id'] == obj2.gid + assert obj2.previous_ids_chain[0]['similarity_score'] is None + + def test_multiple_objects_same_new_match_only_first_wins(self): + """Test scenario where only the first track keeps a shared matched database ID.""" + from controller.moving_object import MovingObject, ReidState + import time + + # Object A: Gets matched first + obj_a = MovingObject({'id': 'A', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_a.rv_id = 'A' + obj_a.reid = [0.1, 0.2, 0.3] + obj_a.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj_a.rv_id] = [None, None] + self.manager.quality_features[obj_a.rv_id] = [[0.1, 0.2, 0.3]] + + # A matches to shared_gid + self.manager.updateActiveDict(obj_a, database_id='shared_gid', similarity=0.92) + assert obj_a.reid_state == ReidState.MATCHED + assert obj_a.gid == 'shared_gid' + + # Object B: Also gets match to same shared_gid (collision) + obj_b = MovingObject({'id': 'B', 'confidence': 0.95}, time.time(), self.mock_camera) + obj_b.rv_id = 'B' + obj_b.reid = [0.15, 0.25, 0.35] + obj_b.category = 'person' + + with self.manager.active_ids_lock: + self.manager.active_ids[obj_b.rv_id] = [None, None] + self.manager.quality_features[obj_b.rv_id] = [[0.15, 0.25, 0.35]] + initial_count = self.manager.unique_id_count + + # B's match to shared_gid collides with A's active assignment and must not be reused. + self.manager.updateActiveDict(obj_b, database_id='shared_gid', similarity=0.88) + + # B gets QUERY_NO_MATCH state and a distinct generated gid. + assert obj_b.reid_state == ReidState.QUERY_NO_MATCH + assert obj_b.gid != 'shared_gid' + assert obj_a.gid == 'shared_gid' # A still has the matched ID + assert obj_b.gid != obj_a.gid + assert obj_b.similarity is None # B has no similarity (treated as no-match) + assert self.manager.active_ids[obj_b.rv_id][0] == obj_b.gid + assert self.manager.unique_id_count == initial_count + 1 + + +class TestMovingObjectSetPreviousPersistence: + """Regression tests for setPrevious() persistence across frame recreation.""" + + def setup_method(self): + """Set up a mock camera compatible with MovingObject initialization.""" + self.mock_camera = Mock() + self.mock_camera.pose = Mock() + self.mock_camera.pose.intrinsics = Mock() + self.mock_camera.pose.intrinsics.mapPixelToNormalizedImagePlane = Mock(return_value=Mock()) + + def _create_moving_object(self, obj_id): + """Create a minimal MovingObject with required state for setPrevious().""" + from controller.moving_object import MovingObject, ChainData + import time + + obj = MovingObject({'id': obj_id, 'confidence': 0.95}, time.time(), self.mock_camera) + obj.chain_data = ChainData(regions={}, publishedLocations=[], persist={}) + obj.location = [Mock()] + return obj + + def test_set_previous_persists_reid_state_similarity_and_chain(self): + """Verify reid_state, similarity, and previous_ids_chain are copied from previous frame object.""" + from controller.moving_object import ReidState + + old_obj = self._create_moving_object('1') + new_obj = self._create_moving_object('2') + + old_obj.reid_state = ReidState.MATCHED + old_obj.similarity = 0.91 + old_obj.previous_ids_chain = [ + {'id': 'gid_10', 'timestamp': 1000.0, 'similarity_score': 0.91} + ] + + new_obj.setPrevious(old_obj) + + assert new_obj.reid_state == ReidState.MATCHED + assert new_obj.similarity == 0.91 + assert new_obj.previous_ids_chain == old_obj.previous_ids_chain + + def test_set_previous_copies_chain_without_list_aliasing(self): + """Verify previous_ids_chain list is copied by value, not aliased.""" + old_obj = self._create_moving_object('1') + new_obj = self._create_moving_object('2') + + old_obj.previous_ids_chain = [ + {'id': 'gid_10', 'timestamp': 1000.0, 'similarity_score': 0.91} + ] + + new_obj.setPrevious(old_obj) + new_obj.previous_ids_chain.append( + {'id': 'gid_11', 'timestamp': 1001.0, 'similarity_score': None} + ) + + assert len(new_obj.previous_ids_chain) == 2 + assert len(old_obj.previous_ids_chain) == 1