diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 551742e..c9d8efc 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -71,10 +71,26 @@ reviews:
AXIS OS 9.80), it should be avoided unless the AXIS OS limitations are explicitly
mentioned. If not using `local`, it can be good to use a function specific prefix
for the variable names to avoid conflicts with other variables.
+
Clearly document in the head of the file which environment variables they expect
and if they are optional or required.
+
It's normally good to use `set -eu` for stricter error handling, but `-o pipefail`
is not supported in the Axis devices' shell.
+
+ When an error occurs in a helper script that is used with the exec or execd plugin of
+ Telegraf, then the script should at minimum log an descriptive error message to stderr
+ and exit with a non-zero error code. The exit code should be unique enough to identify
+ the problem type and the error codes should be documented in the top of the script.
+ When errors are logged to stderr, Telegraf will only show the first line that was logged
+ followed by three dots. Therefore, a longer line might make more sense than multiple
+ shorter lines. The error logs should not end with a newline.
+
+ It is also good if the script respects the `TELEGRAF_DEBUG` variable, and if this is set
+ to "true", then the script should log debug messages to a file in the `HELPER_FILES_DIR`
+ with the .debug suffix. This log can be more verbose and make use of multiple lines.
+ When TELEGRAF_DEBUG is not set, logging to file should not be enabled since it can fill
+ the flash storage or wear out the storage chip.
- path: "**/*.md"
instructions: >-
Documentation files should clearly communicate the dual audience: (1) server-side
diff --git a/.github/actions/python-quality-check/action.yml b/.github/actions/python-quality-check/action.yml
new file mode 100644
index 0000000..fb2b926
--- /dev/null
+++ b/.github/actions/python-quality-check/action.yml
@@ -0,0 +1,79 @@
+name: "Python Quality Check"
+description: "Run Python linting and type checking tools"
+inputs:
+ working-directory:
+ description: "Working directory for the Python project"
+ required: true
+ additional-types:
+ description: "Additional type packages to install (space-separated)"
+ required: false
+ default: ""
+ pylint-options:
+ description: "Additional options for pylint"
+ required: false
+ default: ""
+
+runs:
+ using: "composite"
+ steps:
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install test dependencies
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip
+ pip install \
+ black==25.1.0 \
+ flake8==7.3.0 \
+ isort==6.0.1 \
+ mypy==1.17.1 \
+ pylint==3.3.8 \
+ types-click==7.1.8 \
+ ${{ inputs.additional-types }}
+
+ - name: Install project requirements
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ pip install -r requirements.txt
+
+ - name: Run flake8
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ # E203: whitespace before ':' (conflicts with black)
+ # W503: line break before binary operator (conflicts with black)
+ flake8 . --max-line-length=100 --extend-ignore=E203,W503
+
+ - name: Run mypy
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ mypy .
+
+ - name: Run pylint
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ pylint *.py ${{ inputs.pylint-options }}
+
+ - name: Run isort
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ isort --check-only --diff --profile black .
+
+ - name: Run black
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ black --check --diff .
+
+ - name: Run doctests
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ run: |
+ python -m doctest *.py -v
diff --git a/.github/workflows/project-time-in-area-analytics-python-quality.yml b/.github/workflows/project-time-in-area-analytics-python-quality.yml
new file mode 100644
index 0000000..d8147c2
--- /dev/null
+++ b/.github/workflows/project-time-in-area-analytics-python-quality.yml
@@ -0,0 +1,30 @@
+name: Python Code Quality Check for the time-in-area-analytics project
+
+on:
+ push:
+ paths:
+ - "project-time-in-area-analytics/test_scripts/**"
+ pull_request:
+ paths:
+ - "project-time-in-area-analytics/test_scripts/**"
+
+permissions:
+ contents: read
+
+jobs:
+ python-quality:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: project-time-in-area-analytics/test_scripts
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Run Python Quality Check
+ uses: ./.github/actions/python-quality-check
+ with:
+ working-directory: project-time-in-area-analytics/test_scripts
+ # Disable E1101 for OpenCV false positives (no-member errors for cv2)
+ pylint-options: --disable=E1101
diff --git a/.github/workflows/project-time-in-area-test-analytics.yml b/.github/workflows/project-time-in-area-test-analytics.yml
new file mode 100644
index 0000000..ffaa413
--- /dev/null
+++ b/.github/workflows/project-time-in-area-test-analytics.yml
@@ -0,0 +1,696 @@
+name: Test Time-in-Area Analytics
+
+on:
+ push:
+ branches: [main, feature/*]
+ paths:
+ - "project-time-in-area-analytics/**"
+ - ".github/workflows/project-time-in-area-test-analytics.yml"
+ pull_request:
+ branches: [main]
+ paths:
+ - "project-time-in-area-analytics/**"
+ - ".github/workflows/project-time-in-area-test-analytics.yml"
+
+jobs:
+ test-visualization-script:
+ name: Test Track Heatmap Viewer
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python 3.13
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.13"
+
+ - name: Install Python dependencies
+ run: |
+ cd project-time-in-area-analytics
+ pip install -r test_scripts/requirements.txt
+
+ - name: Test visualization script - No alarms (threshold 200s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing heatmap viewer with threshold: 200s (expecting no alarms)"
+
+ # Run heatmap viewer with high threshold
+ HEATMAP_OUTPUT=$(python test_scripts/track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 200 --no-ui 2>&1)
+ echo "Heatmap output: $HEATMAP_OUTPUT"
+
+ # Expected: 0 alarms
+ EXPECTED_ALARMS=0
+
+ if echo "$HEATMAP_OUTPUT" | grep -q "No tracks exceeded alarm threshold"; then
+ ACTUAL_ALARMS=0
+ else
+ ACTUAL_ALARMS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | wc -l)
+ fi
+
+ echo "Expected alarms: $EXPECTED_ALARMS"
+ echo "Actual alarms: $ACTUAL_ALARMS"
+
+ if [ "$ACTUAL_ALARMS" -eq "$EXPECTED_ALARMS" ]; then
+ echo "✅ PASS: Heatmap viewer correctly found $ACTUAL_ALARMS alarms for threshold 200s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_ALARMS alarms but found $ACTUAL_ALARMS for threshold 200s"
+ exit 1
+ fi
+
+ - name: Test visualization script - Some alarms (threshold 2s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing heatmap viewer with threshold: 2s (expecting 4 alarms)"
+
+ # Run heatmap viewer with moderate threshold
+ HEATMAP_OUTPUT=$(python test_scripts/track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 2 --no-ui 2>&1)
+ echo "Heatmap output: $HEATMAP_OUTPUT"
+
+ # Expected: 4 alarms (track_001, track_002, track_003, track_005)
+ EXPECTED_ALARMS=4
+ EXPECTED_TRACKS="track_001 track_002 track_003 track_005"
+
+ if echo "$HEATMAP_OUTPUT" | grep -q "No tracks exceeded alarm threshold"; then
+ ACTUAL_ALARMS=0
+ ACTUAL_TRACKS=""
+ else
+ ACTUAL_ALARMS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | wc -l)
+ ACTUAL_TRACKS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | sed 's/^\s*//' | sort | tr '\n' ' ' | sed 's/ $//')
+ fi
+
+ echo "Expected alarms: $EXPECTED_ALARMS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual alarms: $ACTUAL_ALARMS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_ALARMS" -eq "$EXPECTED_ALARMS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Heatmap viewer correctly found $ACTUAL_ALARMS alarms with correct track IDs for threshold 2s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_ALARMS alarms ($EXPECTED_TRACKS) but found $ACTUAL_ALARMS alarms ($ACTUAL_TRACKS) for threshold 2s"
+ exit 1
+ fi
+
+ - name: Test visualization script - All alarms (threshold 0s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing heatmap viewer with threshold: 0s (expecting 5 alarms)"
+
+ # Run heatmap viewer with zero threshold
+ HEATMAP_OUTPUT=$(python test_scripts/track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 0 --no-ui 2>&1)
+ echo "Heatmap output: $HEATMAP_OUTPUT"
+
+ # Expected: 5 alarms (all tracks: track_001, track_002, track_003, track_004, track_005)
+ EXPECTED_ALARMS=5
+ EXPECTED_TRACKS="track_001 track_002 track_003 track_004 track_005"
+
+ if echo "$HEATMAP_OUTPUT" | grep -q "No tracks exceeded alarm threshold"; then
+ ACTUAL_ALARMS=0
+ ACTUAL_TRACKS=""
+ else
+ ACTUAL_ALARMS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | wc -l)
+ ACTUAL_TRACKS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | sed 's/^\s*//' | sort | tr '\n' ' ' | sed 's/ $//')
+ fi
+
+ echo "Expected alarms: $EXPECTED_ALARMS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual alarms: $ACTUAL_ALARMS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_ALARMS" -eq "$EXPECTED_ALARMS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Heatmap viewer correctly found $ACTUAL_ALARMS alarms with correct track IDs for threshold 0s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_ALARMS alarms ($EXPECTED_TRACKS) but found $ACTUAL_ALARMS alarms ($ACTUAL_TRACKS) for threshold 0s"
+ exit 1
+ fi
+
+ - name: Test visualization script - Long duration track (threshold 120s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing heatmap viewer with threshold: 120s (expecting 1 alarm - long track)"
+
+ # Run heatmap viewer with 120s threshold
+ HEATMAP_OUTPUT=$(python test_scripts/track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 120 --no-ui 2>&1)
+ echo "Heatmap output: $HEATMAP_OUTPUT"
+
+ # Expected: 1 alarm (track_005 with 150s duration)
+ EXPECTED_ALARMS=1
+ EXPECTED_TRACKS="track_005"
+
+ if echo "$HEATMAP_OUTPUT" | grep -q "No tracks exceeded alarm threshold"; then
+ ACTUAL_ALARMS=0
+ ACTUAL_TRACKS=""
+ else
+ ACTUAL_ALARMS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | wc -l)
+ ACTUAL_TRACKS=$(echo "$HEATMAP_OUTPUT" | grep -A 10 "Tracks with alarms" | grep -E "^\s+track_" | sed 's/^\s*//' | sort | tr '\n' ' ' | sed 's/ $//')
+ fi
+
+ echo "Expected alarms: $EXPECTED_ALARMS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual alarms: $ACTUAL_ALARMS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_ALARMS" -eq "$EXPECTED_ALARMS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Heatmap viewer correctly found $ACTUAL_ALARMS alarms with correct track IDs for threshold 120s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_ALARMS alarms ($EXPECTED_TRACKS) but found $ACTUAL_ALARMS alarms ($ACTUAL_TRACKS) for threshold 120s"
+ exit 1
+ fi
+
+ test-telegraf-pipeline:
+ name: Test Telegraf Pipeline
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Install Telegraf
+ run: |
+ # Install jq (needed for parsing JSON output)
+ sudo apt-get update
+ sudo apt-get install -y jq wget gnupg
+
+ # Add InfluxDB repository using jammy (22.04) for compatibility with ubuntu-24
+ wget -qO- https://repos.influxdata.com/influxdata-archive_compat.key | sudo gpg --dearmor -o /usr/share/keyrings/influxdata-archive-keyring.gpg
+ echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] https://repos.influxdata.com/ubuntu jammy stable" | sudo tee /etc/apt/sources.list.d/influxdb.list
+
+ # Install Telegraf and bc (for floating point math)
+ sudo apt-get update
+ sudo apt-get install -y telegraf bc
+
+ # Verify installation
+ telegraf --version
+
+ - name: Test Zone Filter Only (Rectangular Zone)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing zone filter in isolation"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/test_zone_filter_simple.jsonl"
+
+ # Set the zone polygon from the test file (simple rectangular zone)
+ export INCLUDE_ZONE_POLYGON='[[[-0.6, -0.4], [0.2, -0.4], [0.2, 0.2], [-0.6, 0.2]]]'
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf with class filter and zone filter
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Checking zone filter output..."
+
+ # Expected: Three detections (inside_zone_a, inside_zone_b, crossing_edge_g)
+ EXPECTED_TRACKS="crossing_edge_g inside_zone_a inside_zone_b"
+ EXPECTED_COUNT=3
+
+ # Count unique track_ids that passed through the filter (sorted alphabetically)
+ ACTUAL_TRACKS=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r 'select(.name == "detection_frame_in_zone") | .fields.track_id' 2>/dev/null | \
+ sort -u | tr '\n' ' ' | sed 's/ $//')
+
+ ACTUAL_COUNT=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r 'select(.name == "detection_frame_in_zone") | .fields.track_id' 2>/dev/null | \
+ sort -u | wc -l)
+
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Expected count: $EXPECTED_COUNT"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+ echo "Actual count: $ACTUAL_COUNT"
+
+ if [ "$ACTUAL_COUNT" -eq "$EXPECTED_COUNT" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Zone filter correctly filtered to $ACTUAL_COUNT detections with correct track IDs"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_COUNT detections ($EXPECTED_TRACKS) but found $ACTUAL_COUNT ($ACTUAL_TRACKS)"
+ exit 1
+ fi
+
+ - name: Test Zone Filter (Complex Concave Polygon)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing zone filter with complex concave polygon"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/test_zone_filter_complex.jsonl"
+
+ # Set the complex concave zone polygon from the test file
+ export INCLUDE_ZONE_POLYGON='[[[-0.97,-0.97],[-0.97,0.97],[-0.1209,0.9616],[-0.7562,0.6008],[-0.7652,0.05951],[0.05851,0.5204],[0.04617,-0.9691]]]'
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf with class filter and zone filter only
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Checking complex zone filter output..."
+
+ # Expected: Only two detections (inside_c, inside_d) should pass through
+ EXPECTED_TRACKS="inside_c inside_d"
+ EXPECTED_COUNT=2
+
+ # Count unique track_ids that passed through the filter (sorted alphabetically)
+ ACTUAL_TRACKS=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r 'select(.name == "detection_frame_in_zone") | .fields.track_id' 2>/dev/null | \
+ sort -u | tr '\n' ' ' | sed 's/ $//')
+
+ ACTUAL_COUNT=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r 'select(.name == "detection_frame_in_zone") | .fields.track_id' 2>/dev/null | \
+ sort -u | wc -l)
+
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Expected count: $EXPECTED_COUNT"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+ echo "Actual count: $ACTUAL_COUNT"
+
+ if [ "$ACTUAL_COUNT" -eq "$EXPECTED_COUNT" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Complex zone filter correctly filtered to $ACTUAL_COUNT detections with correct track IDs"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_COUNT detections ($EXPECTED_TRACKS) but found $ACTUAL_COUNT ($ACTUAL_TRACKS)"
+ exit 1
+ fi
+
+ - name: Test Telegraf pipeline - Time-in-area calculation only
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing Telegraf pipeline time-in-area calculation"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf pipeline with ONLY time-in-area calculation (no threshold filtering)
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Telegraf output: $TELEGRAF_OUTPUT"
+
+ # Check that time_in_area_seconds fields are present
+ echo "Checking time_in_area_seconds field values..."
+
+ # Verify all detections have time_in_area_seconds field
+ MISSING_FIELD_COUNT=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r 'select(.name == "detection_frame_with_duration" and (.fields.time_in_area_seconds == null or .fields.time_in_area_seconds == "")) | .fields.track_id' 2>/dev/null | wc -l)
+
+ if [ "$MISSING_FIELD_COUNT" -gt 0 ]; then
+ echo "❌ FAIL: Found $MISSING_FIELD_COUNT detections without time_in_area_seconds field"
+ exit 1
+ fi
+
+ # Check specific time_in_area_seconds values against hard-coded expectations
+ echo "Verifying time_in_area_seconds values against expected calculations..."
+
+ # Hard-coded expected values for key detections from simple_tracks.jsonl
+ # Format: track_id:timestamp:expected_time_in_area_seconds
+ declare -A EXPECTED_VALUES=(
+ # track_001: starts at 10:00:01, increases over time
+ ["track_001:2024-01-15T10:00:01.123456Z"]="0.0"
+ ["track_001:2024-01-15T10:00:02.789012Z"]="1.7"
+ ["track_001:2024-01-15T10:00:03.345678Z"]="2.2"
+ ["track_001:2024-01-15T10:00:04.901234Z"]="3.8"
+
+ # track_002: starts at 10:00:03, increases over time
+ ["track_002:2024-01-15T10:00:03.345678Z"]="0.0"
+ ["track_002:2024-01-15T10:00:05.567890Z"]="2.2"
+
+ # track_003: starts at 10:00:10, increases over time
+ ["track_003:2024-01-15T10:00:10.234567Z"]="0.0"
+ ["track_003:2024-01-15T10:00:11.890123Z"]="1.7"
+ ["track_003:2024-01-15T10:00:12.456789Z"]="2.2"
+
+ # track_004: starts at 10:00:12.456789, only 2 detections
+ ["track_004:2024-01-15T10:00:12.456789Z"]="0.0"
+ ["track_004:2024-01-15T10:00:13.012345Z"]="0.6"
+
+ # track_005: starts at 10:00:30, increases every 30s
+ ["track_005:2024-01-15T10:00:30.000000Z"]="0.0"
+ ["track_005:2024-01-15T10:01:00.000000Z"]="30.0"
+ ["track_005:2024-01-15T10:01:30.000000Z"]="60.0"
+ ["track_005:2024-01-15T10:02:00.000000Z"]="90.0"
+ ["track_005:2024-01-15T10:02:30.000000Z"]="120.0"
+ ["track_005:2024-01-15T10:03:00.000000Z"]="150.0"
+ )
+
+ # Count mismatches
+ MISMATCH_COUNT=0
+ for key in "${!EXPECTED_VALUES[@]}"; do
+ IFS=':' read -r track_id timestamp <<< "$key"
+ expected_time="${EXPECTED_VALUES[$key]}"
+
+ # Find corresponding actual value
+ actual_time=$(echo "$TELEGRAF_OUTPUT" | \
+ jq -r "select(.name == \"detection_frame_with_duration\" and .fields.track_id == \"$track_id\" and .fields.timestamp == \"$timestamp\") | .fields.time_in_area_seconds" 2>/dev/null)
+
+ if [ -n "$actual_time" ] && [ "$actual_time" != "null" ]; then
+ # Simple string comparison for expected values (within tolerance)
+ # For 0.0, expect exactly 0
+ if [ "$expected_time" = "0.0" ] || [ "$expected_time" = "0" ]; then
+ if [ "$actual_time" != "0" ] && [ "$(echo "$actual_time > 0.1" | bc -l)" -eq 1 ]; then
+ echo " Mismatch: $track_id at $timestamp"
+ echo " Expected: $expected_time, Actual: $actual_time"
+ MISMATCH_COUNT=$((MISMATCH_COUNT + 1))
+ fi
+ # For other values, check if they're in reasonable range
+ else
+ # Convert to integers for comparison (multiply by 10 to handle 1 decimal)
+ # Use rounding to handle floating point precision issues
+ expected_int=$(echo "$expected_time * 10 + 0.5" | bc -l | cut -d. -f1)
+ actual_int=$(echo "$actual_time * 10 + 0.5" | bc -l | cut -d. -f1)
+
+ if [ "$expected_int" != "$actual_int" ]; then
+ echo " Mismatch: $track_id at $timestamp"
+ echo " Expected: $expected_time (~$expected_int), Actual: $actual_time (~$actual_int)"
+ MISMATCH_COUNT=$((MISMATCH_COUNT + 1))
+ fi
+ fi
+ else
+ echo " Missing: $track_id at $timestamp (expected: $expected_time)"
+ MISMATCH_COUNT=$((MISMATCH_COUNT + 1))
+ fi
+ done
+
+ if [ "$MISMATCH_COUNT" -gt 0 ]; then
+ echo "❌ FAIL: Found $MISMATCH_COUNT time_in_area_seconds value mismatches"
+ exit 1
+ fi
+
+ echo "✅ PASS: All time_in_area_seconds values match expected calculations"
+
+ - name: Test Telegraf pipeline - No alarms (threshold 200s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing Telegraf pipeline with threshold: 200s (expecting no alarms)"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+ export ALERT_THRESHOLD_SECONDS="200"
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf pipeline
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Telegraf output: $TELEGRAF_OUTPUT"
+
+ # Expected: 0 unique tracks, 0 alarm detections
+ EXPECTED_UNIQUE_TRACKS=0
+ EXPECTED_ALARM_DETECTIONS=0
+
+ # Count unique track IDs and alarm detections in alerting_frame outputs
+ ACTUAL_UNIQUE_TRACKS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | sort -u | wc -l)
+ ACTUAL_ALARM_DETECTIONS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | wc -l)
+
+ echo "Expected unique tracks: $EXPECTED_UNIQUE_TRACKS"
+ echo "Expected alarm detections: $EXPECTED_ALARM_DETECTIONS"
+ echo "Actual unique tracks: $ACTUAL_UNIQUE_TRACKS"
+ echo "Actual alarm detections: $ACTUAL_ALARM_DETECTIONS"
+
+ if [ "$ACTUAL_UNIQUE_TRACKS" -eq "$EXPECTED_UNIQUE_TRACKS" ] && [ "$ACTUAL_ALARM_DETECTIONS" -eq "$EXPECTED_ALARM_DETECTIONS" ]; then
+ echo "✅ PASS: Telegraf pipeline correctly found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections for threshold 200s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_UNIQUE_TRACKS unique tracks and $EXPECTED_ALARM_DETECTIONS alarm detections, but found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections for threshold 200s"
+ exit 1
+ fi
+
+ - name: Test Telegraf pipeline - Some alarms (threshold 2s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing Telegraf pipeline with threshold: 2s (expecting 3 alarms)"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+ export ALERT_THRESHOLD_SECONDS="2"
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf pipeline
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Telegraf output: $TELEGRAF_OUTPUT"
+
+ # Expected: 4 unique tracks, 9 alarm detections
+ # Note: Once a track exceeds the threshold, ALL subsequent detections for that track
+ # trigger alarms (i.e., get output by the threshold filter)
+ EXPECTED_UNIQUE_TRACKS=4
+ EXPECTED_ALARM_DETECTIONS=9
+ EXPECTED_TRACKS="track_001 track_002 track_003 track_005"
+
+ # Extract unique track IDs and count alarm detections from alerting_frame outputs
+ ACTUAL_TRACKS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
+ ACTUAL_UNIQUE_TRACKS=$(echo "$ACTUAL_TRACKS" | wc -w)
+ ACTUAL_ALARM_DETECTIONS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | wc -l)
+
+ echo "Expected unique tracks: $EXPECTED_UNIQUE_TRACKS"
+ echo "Expected alarm detections: $EXPECTED_ALARM_DETECTIONS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual unique tracks: $ACTUAL_UNIQUE_TRACKS"
+ echo "Actual alarm detections: $ACTUAL_ALARM_DETECTIONS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_UNIQUE_TRACKS" -eq "$EXPECTED_UNIQUE_TRACKS" ] && [ "$ACTUAL_ALARM_DETECTIONS" -eq "$EXPECTED_ALARM_DETECTIONS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Telegraf pipeline correctly found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections with correct track IDs for threshold 2s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_UNIQUE_TRACKS unique tracks and $EXPECTED_ALARM_DETECTIONS alarm detections ($EXPECTED_TRACKS) but found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections ($ACTUAL_TRACKS) for threshold 2s"
+ exit 1
+ fi
+
+ - name: Test Telegraf pipeline - All alarms (threshold 0s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing Telegraf pipeline with threshold: 0s (expecting 5 alarms)"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+ export ALERT_THRESHOLD_SECONDS="0"
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf pipeline
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Telegraf output: $TELEGRAF_OUTPUT"
+
+ # Expected: 5 unique tracks, 17 alarm detections (all tracks: track_001, track_002, track_003, track_004, track_005)
+ # Note: Once a track exceeds the threshold, ALL subsequent detections for that track
+ # trigger alarms (i.e., get output by the threshold filter)
+ EXPECTED_UNIQUE_TRACKS=5
+ EXPECTED_ALARM_DETECTIONS=17
+ EXPECTED_TRACKS="track_001 track_002 track_003 track_004 track_005"
+
+ # Extract unique track IDs and count alarm detections from alerting_frame outputs
+ ACTUAL_TRACKS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
+ ACTUAL_UNIQUE_TRACKS=$(echo "$ACTUAL_TRACKS" | wc -w)
+ ACTUAL_ALARM_DETECTIONS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | wc -l)
+
+ echo "Expected unique tracks: $EXPECTED_UNIQUE_TRACKS"
+ echo "Expected alarm detections: $EXPECTED_ALARM_DETECTIONS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual unique tracks: $ACTUAL_UNIQUE_TRACKS"
+ echo "Actual alarm detections: $ACTUAL_ALARM_DETECTIONS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_UNIQUE_TRACKS" -eq "$EXPECTED_UNIQUE_TRACKS" ] && [ "$ACTUAL_ALARM_DETECTIONS" -eq "$EXPECTED_ALARM_DETECTIONS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Telegraf pipeline correctly found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections with correct track IDs for threshold 0s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_UNIQUE_TRACKS unique tracks and $EXPECTED_ALARM_DETECTIONS alarm detections ($EXPECTED_TRACKS) but found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections ($ACTUAL_TRACKS) for threshold 0s"
+ exit 1
+ fi
+
+ - name: Test Telegraf pipeline - Long duration track (threshold 120s)
+ run: |
+ cd project-time-in-area-analytics
+
+ echo "Testing Telegraf pipeline with threshold: 120s (expecting 1 alarm - long track)"
+
+ # Set up environment variables
+ export HELPER_FILES_DIR="$(pwd)"
+ export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+ export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+ export ALERT_THRESHOLD_SECONDS="120"
+ export OBJECT_TYPE_FILTER="ALL_UNVERIFIED"
+
+ # Run Telegraf pipeline
+ TELEGRAF_OUTPUT=$(telegraf --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once 2>/dev/null)
+
+ echo "Telegraf output: $TELEGRAF_OUTPUT"
+
+ # Expected: 1 unique track, 2 alarm detections (track_005 with 150s duration)
+ # Note: Once a track exceeds the threshold, ALL subsequent detections for that track
+ # trigger alarms (i.e., get output by the threshold filter)
+ EXPECTED_UNIQUE_TRACKS=1
+ EXPECTED_ALARM_DETECTIONS=2
+ EXPECTED_TRACKS="track_005"
+
+ # Extract unique track IDs and count alarm detections from alerting_frame outputs
+ ACTUAL_TRACKS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
+ ACTUAL_UNIQUE_TRACKS=$(echo "$ACTUAL_TRACKS" | wc -w)
+ ACTUAL_ALARM_DETECTIONS=$(echo "$TELEGRAF_OUTPUT" | jq -r 'select(.name == "alerting_frame") | .fields.track_id' 2>/dev/null | wc -l)
+
+ echo "Expected unique tracks: $EXPECTED_UNIQUE_TRACKS"
+ echo "Expected alarm detections: $EXPECTED_ALARM_DETECTIONS"
+ echo "Expected tracks: $EXPECTED_TRACKS"
+ echo "Actual unique tracks: $ACTUAL_UNIQUE_TRACKS"
+ echo "Actual alarm detections: $ACTUAL_ALARM_DETECTIONS"
+ echo "Actual tracks: $ACTUAL_TRACKS"
+
+ if [ "$ACTUAL_UNIQUE_TRACKS" -eq "$EXPECTED_UNIQUE_TRACKS" ] && [ "$ACTUAL_ALARM_DETECTIONS" -eq "$EXPECTED_ALARM_DETECTIONS" ] && [ "$ACTUAL_TRACKS" = "$EXPECTED_TRACKS" ]; then
+ echo "✅ PASS: Telegraf pipeline correctly found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections with correct track IDs for threshold 120s"
+ else
+ echo "❌ FAIL: Expected $EXPECTED_UNIQUE_TRACKS unique tracks and $EXPECTED_ALARM_DETECTIONS alarm detections ($EXPECTED_TRACKS) but found $ACTUAL_UNIQUE_TRACKS unique tracks and $ACTUAL_ALARM_DETECTIONS alarm detections ($ACTUAL_TRACKS) for threshold 120s"
+ exit 1
+ fi
+
+ summary:
+ name: Test Summary
+ runs-on: ubuntu-latest
+ needs: [test-visualization-script, test-telegraf-pipeline]
+ if: always()
+
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Check test results
+ id: test_results
+ run: |
+ VIS_RESULT="${{ needs.test-visualization-script.result }}"
+ TELEGRAF_RESULT="${{ needs.test-telegraf-pipeline.result }}"
+
+ echo "vis_result=$VIS_RESULT" >> $GITHUB_OUTPUT
+ echo "telegraf_result=$TELEGRAF_RESULT" >> $GITHUB_OUTPUT
+
+ if [ "$VIS_RESULT" = "success" ] && [ "$TELEGRAF_RESULT" = "success" ]; then
+ echo "🎉 All time-in-area analytics tests passed!"
+ echo ""
+ echo "✅ Track Heatmap Viewer tests passed"
+ echo "✅ Telegraf Pipeline tests passed"
+ echo ""
+ echo "Both tools correctly identify tracks that exceed time-in-area thresholds."
+ echo "all_passed=true" >> $GITHUB_OUTPUT
+ else
+ echo "❌ Some tests failed:"
+ echo " - Track Heatmap Viewer: $VIS_RESULT"
+ echo " - Telegraf Pipeline: $TELEGRAF_RESULT"
+ echo "all_passed=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Comment on PR (success)
+ continue-on-error: true
+ if: github.event_name == 'pull_request' && steps.test_results.outputs.all_passed == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const comment = `## 🎉 Time-in-Area Analytics Tests Passed!
+
+ All tests completed successfully:
+
+ ✅ **Track Heatmap Viewer** - All alarm detection scenarios passed
+ ✅ **Telegraf Pipeline** - All time-in-area calculations verified`;
+
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+
+ - name: Comment on PR (failure)
+ continue-on-error: true
+ if: github.event_name == 'pull_request' && steps.test_results.outputs.all_passed == 'false'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const visResult = '${{ steps.test_results.outputs.vis_result }}';
+ const telegrafResult = '${{ steps.test_results.outputs.telegraf_result }}';
+
+ let comment = `## ❌ Time-in-Area Analytics Tests Failed
+
+ Some tests did not pass:
+
+ ${visResult === 'success' ? '✅' : '❌'} **Track Heatmap Viewer**: ${visResult}
+ ${telegrafResult === 'success' ? '✅' : '❌'} **Telegraf Pipeline**: ${telegrafResult}
+
+ ### What This Means
+ `;
+
+ if (visResult !== 'success') {
+ comment += `
+ **Track Heatmap Viewer Issues:**
+ - The visualization script may not be correctly identifying tracks that exceed time-in-area thresholds
+ - Check the alarm detection logic in \`track_heatmap_viewer.py\`
+ `;
+ }
+
+ if (telegrafResult !== 'success') {
+ comment += `
+ **Telegraf Pipeline Issues:**
+ - The time-in-area calculation or threshold filtering may not be working correctly
+ - Check the Starlark processor in \`track_duration_calculator.star\` and threshold filter configuration
+ `;
+ }
+
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+
+ - name: Fail if tests failed
+ if: steps.test_results.outputs.all_passed == 'false'
+ run: |
+ echo "❌ Some tests failed"
+ exit 1
diff --git a/.github/workflows/project-timelapse-s3-python-quality.yml b/.github/workflows/project-timelapse-s3-python-quality.yml
index 287c8de..58ff7e3 100644
--- a/.github/workflows/project-timelapse-s3-python-quality.yml
+++ b/.github/workflows/project-timelapse-s3-python-quality.yml
@@ -22,47 +22,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Setup Python
- uses: actions/setup-python@v5
+ - name: Run Python Quality Check
+ uses: ./.github/actions/python-quality-check
with:
- python-version: "3.13"
-
- - name: Install test dependencies
- run: |
- python -m pip install --upgrade pip
- pip install \
- black==25.1.0 \
- flake8==7.3.0 \
- isort==6.0.1 \
- mypy==1.17.1 \
- pylint==3.3.7 \
- types-tqdm==4.67.0.20250516 \
- types-boto3==1.40.4 \
- types-Pillow==10.2.0.20240822
-
- - name: Install project requirements
- run: |
- pip install -r requirements.txt
-
- - name: Run flake8
- run: |
- # E203: whitespace before ':' (conflicts with black)
- # W503: line break before binary operator (conflicts with black)
- flake8 . --max-line-length=100 --extend-ignore=E203,W503
-
- - name: Run mypy
- run: |
- mypy .
-
- - name: Run pylint
- run: |
+ working-directory: project-timelapse-s3/test_scripts
+ additional-types: types-tqdm==4.67.0.20250516 types-boto3==1.40.4 types-Pillow==10.2.0.20240822
# Disable E1101 for OpenCV false positives (no-member errors for cv2)
- pylint *.py --disable=E1101
-
- - name: Run isort
- run: |
- isort --check-only --diff .
-
- - name: Run black
- run: |
- black --check --diff .
+ pylint-options: --disable=E1101
diff --git a/.gitignore b/.gitignore
index ca153d5..3561170 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
env.*
vars.*
.env*
-*.debug
\ No newline at end of file
+*.debug
+*.log
+__pycache__
diff --git a/project-time-in-area-analytics/.gitignore b/project-time-in-area-analytics/.gitignore
new file mode 100644
index 0000000..1e58491
--- /dev/null
+++ b/project-time-in-area-analytics/.gitignore
@@ -0,0 +1 @@
+combined.conf
diff --git a/project-time-in-area-analytics/.images/camera-detections-config.png b/project-time-in-area-analytics/.images/camera-detections-config.png
new file mode 100644
index 0000000..fc2d15a
Binary files /dev/null and b/project-time-in-area-analytics/.images/camera-detections-config.png differ
diff --git a/project-time-in-area-analytics/.images/camera-detections.png b/project-time-in-area-analytics/.images/camera-detections.png
new file mode 100644
index 0000000..357880f
Binary files /dev/null and b/project-time-in-area-analytics/.images/camera-detections.png differ
diff --git a/project-time-in-area-analytics/.images/complex-zone-test.png b/project-time-in-area-analytics/.images/complex-zone-test.png
new file mode 100644
index 0000000..e284f54
Binary files /dev/null and b/project-time-in-area-analytics/.images/complex-zone-test.png differ
diff --git a/project-time-in-area-analytics/.images/simple-zone-test.png b/project-time-in-area-analytics/.images/simple-zone-test.png
new file mode 100644
index 0000000..271c099
Binary files /dev/null and b/project-time-in-area-analytics/.images/simple-zone-test.png differ
diff --git a/project-time-in-area-analytics/.images/track-heatmap-120s.png b/project-time-in-area-analytics/.images/track-heatmap-120s.png
new file mode 100644
index 0000000..3da1238
Binary files /dev/null and b/project-time-in-area-analytics/.images/track-heatmap-120s.png differ
diff --git a/project-time-in-area-analytics/.images/track-heatmap-simple.png b/project-time-in-area-analytics/.images/track-heatmap-simple.png
new file mode 100644
index 0000000..1e495d7
Binary files /dev/null and b/project-time-in-area-analytics/.images/track-heatmap-simple.png differ
diff --git a/project-time-in-area-analytics/.images/zone_auth.png b/project-time-in-area-analytics/.images/zone_auth.png
new file mode 100644
index 0000000..6671511
Binary files /dev/null and b/project-time-in-area-analytics/.images/zone_auth.png differ
diff --git a/project-time-in-area-analytics/.images/zone_post.png b/project-time-in-area-analytics/.images/zone_post.png
new file mode 100644
index 0000000..cfdc959
Binary files /dev/null and b/project-time-in-area-analytics/.images/zone_post.png differ
diff --git a/project-time-in-area-analytics/README.md b/project-time-in-area-analytics/README.md
new file mode 100644
index 0000000..f3bb96e
--- /dev/null
+++ b/project-time-in-area-analytics/README.md
@@ -0,0 +1,772 @@
+# Time-in-Area Analytics
+
+This project demonstrates how to implement time-in-area analytics for Axis fisheye cameras using the [FixedIT Data Agent](https://fixedit.ai/products-data-agent/). While AXIS Object Analytics natively supports time-in-area detection for traditional cameras, fisheye cameras lack this capability. This solution bridges that gap by consuming real-time object detection metadata from fisheye cameras and implementing custom time-in-area logic using Telegraf's Starlark processor. The system uses object tracking IDs from [AXIS Scene Metadata](https://developer.axis.com/analytics/axis-scene-metadata/reference/concepts/) to track objects within a defined rectangular area, measures time in area, and triggers alert notifications via events when objects remain in the monitored zone beyond configured thresholds.
+
+## How It Works
+
+The system consumes real-time object detection data from Axis fisheye cameras and implements custom time-in-area analytics logic to track object time in area and trigger appropriate responses.
+
+```mermaid
+flowchart TD
+ A["📹 config_input_scene_detections.conf:
Consume analytics scene description from the camera using the inputs.execd plugin and axis_scene_detection_consumer.sh"] -->|detection_frame| B1["config_process_class_filter.conf:
Filter by class name"]
+ X0["Configuration variables: HELPER_FILES_DIR"] --> A
+ X1a["Configuration variables: OBJECT_TYPE_FILTER"] --> B1
+ B1 -->|detection_frame_class_filtered| B2["config_process_zone_filter.conf:
Filter by include zone polygon"]
+ X1b["Configuration variables: INCLUDE_ZONE_POLYGON"] --> B2
+ B2 -->|detection_frame_in_zone| C1
+
+ subgraph TimeLogic ["config_process_track_duration.conf:
Time-in-area Logic Details"]
+ C1{"First time seeing
this object ID?"}
+ C1 -->|Yes| C2["Save first timestamp
object_id → now()"] --> C4
+ C1 -->|No| C3["Get saved timestamp"]
+ C3 --> C4["Calculate time diff
now() - first_timestamp"]
+ C4 --> C5["Append time in area
to metric"]
+
+ C2 --> CX["💾 Persistent state"]
+ CX --> C3
+ end
+
+ C5 -->|detection_frame_with_duration| D["config_process_threshold_filter.conf:
Filter for
time in area > ALERT_THRESHOLD_SECONDS"]
+ X2["Configuration variables: ALERT_THRESHOLD_SECONDS"] --> D
+
+ D -->|alerting_frame_two| E0["config_process_alarming_state.conf:
Check if any alerting detections have happened during the last second"]
+ E0 -->|alerting_state_metric| E01["config_output_events.conf:
Run the event handler binary with information about the detection status"]
+ E01 --> E["🚨 Event Output
Alert messages"]
+
+ D -->|alerting_frame| E1["config_process_rate_limit.conf:
Rate limit to 1 message per second
using Starlark state"]
+ E1 -->|rate_limited_alert_frame| F["config_process_overlay_transform.conf:
Recalculate coordinates for overlay visualization"]
+ F -->|overlay_frame| G["📺 config_output_overlay.conf:
Overlay Manager with the outputs.exec plugin and overlay_manager.sh"]
+ X4["Configuration variables:
VAPIX_USERNAME
VAPIX_PASSWORD
HELPER_FILES_DIR
VAPIX_IP
TELEGRAF_DEBUG
FONT_SIZE"] --> G
+ G --> H["📺 VAPIX Overlay API"]
+
+ style A fill:#e8f5e9,stroke:#43a047
+ style B1 fill:#f3e5f5,stroke:#8e24aa
+ style B2 fill:#f3e5f5,stroke:#8e24aa
+ style TimeLogic fill:#f3e5f5,stroke:#8e24aa
+ style C1 fill:#ffffff,stroke:#673ab7
+ style C2 fill:#ffffff,stroke:#673ab7
+ style C3 fill:#ffffff,stroke:#673ab7
+ style C4 fill:#ffffff,stroke:#673ab7
+ style C5 fill:#ffffff,stroke:#673ab7
+ style CX fill:#fff3e0,stroke:#fb8c00
+ style D fill:#f3e5f5,stroke:#8e24aa
+ style E0 fill:#f3e5f5,stroke:#8e24aa
+ style E01 fill:#f3e5f5,stroke:#8e24aa
+ style E fill:#ffebee,stroke:#e53935
+ style E1 fill:#f3e5f5,stroke:#8e24aa
+ style F fill:#f3e5f5,stroke:#8e24aa
+ style G fill:#ffebee,stroke:#e53935
+ style H fill:#ffebee,stroke:#e53935
+ style X0 fill:#f5f5f5,stroke:#9e9e9e
+ style X1a fill:#f5f5f5,stroke:#9e9e9e
+ style X1b fill:#f5f5f5,stroke:#9e9e9e
+ style X2 fill:#f5f5f5,stroke:#9e9e9e
+ style X4 fill:#f5f5f5,stroke:#9e9e9e
+```
+
+Color scheme:
+
+- Light green: Input nodes / data ingestion
+- Purple: Processing nodes / data processing and logic
+- Orange: Storage nodes / persistent data
+- Red: Output nodes / notifications
+- Light gray: Configuration data
+- White: Logical operations
+
+## Why Choose This Approach?
+
+**No C/C++ development required!** This project demonstrates how to implement advanced analytics that would typically require custom ACAP development using the [FixedIT Data Agent](https://fixedit.ai/products-data-agent/) instead. Rather than writing complex embedded C++ code for fisheye camera analytics, system integrators and IT professionals can implement sophisticated time-in-area logic using familiar configuration files and simple scripting. The solution leverages existing object detection capabilities from AXIS Object Analytics and adds the missing time-in-area functionality through data processing pipelines, making it accessible to teams without embedded development expertise.
+
+## Table of Contents
+
+
+
+- [Compatibility](#compatibility)
+ - [AXIS OS Compatibility](#axis-os-compatibility)
+ - [FixedIT Data Agent Compatibility](#fixedit-data-agent-compatibility)
+- [Quick Setup](#quick-setup)
+ - [TODO](#todo)
+ - [Troubleshooting](#troubleshooting)
+ - [Make sure AXIS Object Analytics is enabled](#make-sure-axis-object-analytics-is-enabled)
+ - [Verbose Logging](#verbose-logging)
+ - [Gradual Testing](#gradual-testing)
+ - [Unresolved variable errors](#unresolved-variable-errors)
+ - ["Text area is too big!" in overlay](#text-area-is-too-big-in-overlay)
+- [Configuration Files](#configuration-files)
+ - [config_input_scene_detections.conf and axis_scene_detection_consumer.sh](#config_input_scene_detectionsconf-and-axis_scene_detection_consumersh)
+ - [config_process_class_filter.conf](#config_process_class_filterconf)
+ - [config_process_zone_filter.conf and zone_filter.star](#config_process_zone_filterconf-and-zone_filterstar)
+ - [config_process_track_duration.conf and track_duration_calculator.star](#config_process_track_durationconf-and-track_duration_calculatorstar)
+ - [config_process_threshold_filter.conf](#config_process_threshold_filterconf)
+ - [config_process_rate_limit.conf](#config_process_rate_limitconf)
+ - [config_process_overlay_transform.conf](#config_process_overlay_transformconf)
+ - [config_output_overlay.conf and overlay_manager.sh](#config_output_overlayconf-and-overlay_managersh)
+ - [test_files/config_output_stdout.conf](#test_filesconfig_output_stdoutconf)
+ - [test_files/sample_data_feeder.sh](#test_filessample_data_feedersh)
+- [Future Enhancements](#future-enhancements)
+- [Local Testing on Host](#local-testing-on-host)
+ - [Prerequisites](#prerequisites)
+ - [Host Testing Limitations](#host-testing-limitations)
+ - [Test Commands](#test-commands)
+ - [Test Class Filter Only](#test-class-filter-only)
+ - [Test Zone Filter Only](#test-zone-filter-only)
+ - [Test Time in Area Calculation Only](#test-time-in-area-calculation-only)
+ - [Test Alert Pipeline](#test-alert-pipeline)
+ - [Test Alert Pipeline with Rate Limit](#test-alert-pipeline-with-rate-limit)
+ - [Test with Real Device Data](#test-with-real-device-data)
+ - [Test Overlay Functionality Only](#test-overlay-functionality-only)
+- [Analytics Data Structure](#analytics-data-structure)
+ - [Raw Analytics Data (from camera)](#raw-analytics-data-from-camera)
+ - [Data Transformed for Telegraf](#data-transformed-for-telegraf)
+ - [Data Transformed for Overlay](#data-transformed-for-overlay)
+- [Recording Real Device Data](#recording-real-device-data)
+- [Track Activity Visualization](#track-activity-visualization)
+- [Automated Testing](#automated-testing)
+ - [GitHub Workflow](#github-workflow)
+ - [Test Data](#test-data)
+ - [PR Comments](#pr-comments)
+
+
+
+## Compatibility
+
+### AXIS OS Compatibility
+
+- **Minimum AXIS OS version**: AXIS OS 12+
+- **Required tools**: Uses `message-broker-cli` which was not stable before AXIS OS 12. Uses `jq` for JSON processing which was not available in older AXIS OS versions, `sed` for text filtering, and standard Unix utilities (`sh`). Uses the analytics scene description message broker topic `com.axis.analytics_scene_description.v0.beta` which is available in AXIS OS 12.
+
+### FixedIT Data Agent Compatibility
+
+- **Minimum Data Agent version**:
+- **Required features**: Uses the `inputs.execd`, `processors.starlark` plugins and the `HELPER_FILES_DIR` environment variable set by the FixedIT Data Agent. Uses the `output_event` binary packaged with versions of the application and above.
+
+## Quick Setup
+
+### TODO
+
+Create a combined file by running:
+
+```bash
+cat config_agent.conf \
+ config_input_scene_detections.conf \
+ config_process_class_filter.conf \
+ config_process_zone_filter.conf \
+ config_process_track_duration.conf \
+ config_process_threshold_filter.conf \
+ config_process_rate_limit.conf \
+ config_process_overlay_transform.conf \
+ config_output_overlay.conf \
+ config_process_alarming_state.conf \
+ config_output_events.conf > combined.conf
+```
+
+Then upload `combined.conf` as a config file and `overlay_manager.sh`, `axis_scene_detection_consumer.sh`, `zone_filter.star` and `track_duration_calculator.star` as helper files.
+
+Set `Extra Env` to:
+
+- `ALERT_THRESHOLD_SECONDS=30`
+- `INCLUDE_ZONE_POLYGON=[[[-1,-1],[-1,1],[1,1],[1,-1]]]` (configure for your zone polygon)
+
+Set valid credentials in the parameters `Vapix username` and `Vapix password`.
+
+To export the zone from AXIS Object Analytics, see [README_INCLUDE_ZONE.md](README_INCLUDE_ZONE.md).
+
+### Troubleshooting
+
+#### Make sure AXIS Object Analytics is enabled
+
+This project is making use of scene detection data from AXIS Object Analytics. Make sure the AXIS Object Analytics app is running in the camera. You can go to the `Analytics` -> `Metadata visualization` page and verify that there are actual detections.
+
+#### Verbose Logging
+
+Enable the `Debug` option in the FixedIT Data Agent for detailed logs. Debug files will appear in the `Uploaded helper files` section (refresh page to see updates).
+
+**Note**: Don't leave debug enabled long-term as it creates large log files.
+
+#### Gradual Testing
+
+You can test the logic gradually in the camera by adding more and more complexity:
+
+1. **Basic Detection**: Upload `config_input_scene_detections.conf`, `axis_scene_detection_consumer.sh` and `config_output_stdout.conf` to see if the camera is sending out detection messages
+
+ 
+ _Configuration files uploaded to the camera_
+
+ 
+ _Log messages showing detection data from the camera_
+
+2. **Time Calculation**: Upload `config_process_track_duration.conf` and `track_duration_calculator.star` to see if the time in area is calculated correctly
+3. **Threshold Filtering**: Upload `config_process_threshold_filter.conf` to see if the threshold filter is working correctly
+4. **Rate Limiting**: Upload `config_process_rate_limit.conf` to protect the overlay API from being overloaded
+5. **Overlay Display**: Finally, upload `config_process_overlay_transform.conf` and `config_output_overlay.conf` to draw the overlays on the live video
+
+#### Unresolved variable errors
+
+If you see an error like this:
+
+```
+[2025-08-20 11:43:40] 2025-08-20T09:43:40Z E! [telegraf] Error running agent: could not initialize processor processors.starlark: :6:23: unexpected input character '$'
+```
+
+It usually means an environment variable (like `ALERT_THRESHOLD_SECONDS`) is not set correctly as an `Extra Env` variable.
+
+#### "Text area is too big!" in overlay
+
+If the only text you see in the overlay is "Text area is too big!", it means that too much text is being rendered or that the text size is too big. Try reducing the font size by setting `FONT_SIZE=32` in the `Extra Env` variable.
+
+## Configuration Files
+
+This project uses several configuration files that work together to create a time-in-area analytics pipeline:
+
+### config_input_scene_detections.conf and axis_scene_detection_consumer.sh
+
+Configuration and script pair that work together to consume real-time object detection data from the camera's analytics scene description stream. The configuration file (`config_input_scene_detections.conf`) uses the consumer script (`axis_scene_detection_consumer.sh`) to connect directly to the camera's internal message broker and transform the raw analytics data into individual detection messages.
+
+Can also be used for reproducible testing on host systems by setting `CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"` to use a file reader that simulates the camera's detection data output. This allows you to test the processing pipeline using pre-recorded sample data without needing live camera hardware.
+
+**Environment Variables:**
+
+- `HELPER_FILES_DIR`: Directory containing project files (required)
+- `CONSUMER_SCRIPT`: Path to consumer script (defaults to `axis_scene_detection_consumer.sh`)
+- `SAMPLE_FILE`: Path to sample data file (required when using `sample_data_feeder.sh`)
+
+### config_process_class_filter.conf
+
+Filters the incoming detection frames based on the configured object type. This processor:
+
+- Reads the `OBJECT_TYPE_FILTER` env var to get the filtering mode or target class
+- Uses inline Starlark logic to determine if the detected object's class matches the filter
+- Only passes detections that match the filter to the next stage
+- Outputs debug messages when detections are filtered
+
+**OBJECT_TYPE_FILTER Values:**
+
+The `object_type` field in incoming detections comes from the camera's `.class.type` field or JSON `null` when the camera has not yet verified/classified the object.
+
+| Value | Behavior | Use Case |
+| ------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| `ALL` | Pass only verified detections where `object_type != null` | Include all object classes, but exclude objects still being classified |
+| `ALL_UNVERIFIED` | Pass all detections (both verified and unverified null values) | Include all objects including those still being classified (less predictable but more sensitive) |
+| `"Human"` (or other class name) | Pass only detections where `object_type` exactly matches the given class (case-insensitive) | Filter by specific object type (e.g., "Human", "Vehicle" or "Face") |
+| Not set or unresolved | Defaults to `ALL` and logs a warning | Not recommended |
+
+### config_process_zone_filter.conf and zone_filter.star
+
+Filters the incoming detection frames based on the configured include zone polygon. This processor:
+
+- Read the `INCLUDE_ZONE_POLYGON` env var to get the zone polygon
+- Uses the `zone_filter.star` script to determine if the detected object's bounding box center is within the polygon
+- Only passes detections that are within the polygon to the next stage
+- Outputs debug messages when detections are filtered
+
+### config_process_track_duration.conf and track_duration_calculator.star
+
+Calculates time in area for each detected object using the external Starlark script `track_duration_calculator.star`. This processor:
+
+- Tracks first seen and last seen timestamps for each `track_id`
+- Calculates `time_in_area_seconds` for each detection
+- Automatically cleans up stale tracks (not seen for 60+ seconds)
+- Outputs debug messages when tracks are removed
+
+### config_process_threshold_filter.conf
+
+Filters detection frames based on the configured alert threshold. Only detections where time in area (`time_in_area_seconds`) exceeds `ALERT_THRESHOLD_SECONDS` are passed through to the output stage.
+
+### config_process_rate_limit.conf
+
+Rate limits messages to protect the overlay API from being overloaded. This processor:
+
+- Uses system time (not message timestamps) to enforce rate limiting
+- Only allows one message per second to pass through
+- Drops messages that arrive too soon after the last one
+- Maintains state using Starlark to track the last update time
+
+### config_process_overlay_transform.conf
+
+Transforms analytics data into the format expected by the overlay manager (e.g. center coordinates in -1 ... 1 range and box size).
+
+### config_output_overlay.conf and overlay_manager.sh
+
+Displays text overlays on the video. This configuration:
+
+- Uses Telegraf's exec output plugin to trigger the `overlay_manager.sh` script
+- Shows overlay text at the center of detected objects using the VAPIX overlay API
+- Displays time in area, object class, and size information
+- Positions overlays using pre-calculated coordinates from the Starlark processor
+- Automatically removes overlays after 1 second for clean video display
+
+### test_files/config_output_stdout.conf
+
+Outputs processed metrics to stdout in JSON format for testing and debugging.
+
+### test_files/sample_data_feeder.sh
+
+Helper script that simulates camera metadata stream by reading sample JSON files line by line. This script is used for host testing to simulate the output of the live camera's message broker without requiring actual camera hardware.
+
+## Future Enhancements
+
+This example should implement a minimal viable solution and can be easily extended:
+
+- **Warning Threshold**: Add a warning level before the main alert threshold
+- **Deactivation Messages**: Send alerts when objects leave the area after being alerted
+- **Time-of-Day Rules**: Apply different thresholds based on time of day
+- **Multiple Areas**: Monitor multiple rectangular areas with different configurations
+- **Advanced Shapes**: Implement polygon-based areas instead of simple rectangles
+
+## Local Testing on Host
+
+You can test the processing logic locally using Telegraf before deploying to your Axis device.
+
+### Prerequisites
+
+- Install Telegraf on your development machine
+- Local MQTT broker (mosquitto) for testing output
+- Sample object detection JSON data for testing
+
+### Host Testing Limitations
+
+**Works on Host:**
+
+- Starlark processor logic testing with sample data
+- MQTT output configuration validation (TODO)
+- Alert threshold configuration testing
+
+**Only works in the Axis Device:**
+
+- Real object detection metadata consumption (camera-specific message broker) - in host testing, you can use the `sample_data_feeder.sh` script to simulate the camera metadata stream using pre-recorded data in the `test_files/simple_tracks.jsonl` or `test_files/real_device_data.jsonl` files.
+- The VAPIX overlay API requires direct access to the Axis device and cannot be tested on host systems.
+
+### Test Commands
+
+#### Test Class Filter Only
+
+Test the class filter to ensure it correctly filters detections by object type:
+
+```bash
+# Set up test environment
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+export TELEGRAF_DEBUG=true
+
+# Filter for only verified detections (exclude unclassified)
+export OBJECT_TYPE_FILTER="Human"
+
+# Test class filter only
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**Expected Output:**
+When set to `Human`, only detections with a class name of `Human` are passed through. You can try to change to `ALL` which should show all detections that has a class, or `ALL_UNVERIFIED` which should show all detections even if the class data is not available yet.
+
+#### Test Zone Filter Only
+
+We have two files with fake detections that are good for testing the zone filter:
+
+- `test_files/simple_tracks.jsonl` - simple tracks that are good for testing the zone filter
+- `test_files/test_zone_filter_complex.jsonl` - complex tracks that are good for testing the zone filter with a complex polygon
+
+You can use the [visualize_zone_tests.py](test_scripts/visualize_zone_tests.py) script to visualize the zone and detections:
+
+```bash
+python test_scripts/visualize_zone_tests.py test_files/test_zone_filter_simple.jsonl
+```
+
+This will show an image like the following where you can see the zone the test is intended to be used with (parsed from a comment in the `.jsonl` file) and the sample detections. That way, it is easy to see which points are inside and which are outside and therefore to know what you should expect from the Telegraf pipeline test using this test file.
+
+
+
+Test the zone filter to ensure it correctly filters detections based on the polygon:
+
+```bash
+# Set up test environment
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/test_zone_filter_simple.jsonl"
+export TELEGRAF_DEBUG=true
+
+# Set the zone polygon from the test file (example for simple rectangular zone)
+export INCLUDE_ZONE_POLYGON='[[[-0.6, -0.4], [0.2, -0.4], [0.2, 0.2], [-0.6, 0.2]]]'
+
+# Test zone filter only
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**How it works:** By setting `CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"`, we override the default live camera script with a file reader that simulates the camera's message broker output by reading from the file specified in `SAMPLE_FILE`. This allows us to test the zone filter on the host using pre-recorded sample data instead of connecting to the live camera infrastructure.
+
+**Expected Output:**
+Three detections should be outputted by the filter. These can be identified by their `track_id`:
+
+- `inside_zone_a`
+- `inside_zone_b`
+- `crossing_edge_g`
+
+#### Test Time in Area Calculation Only
+
+Test the time in area calculator without threshold filtering to see all detections with their calculated time in area:
+
+```bash
+# Set up test environment
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+export TELEGRAF_DEBUG=true
+
+# Set zone to cover entire view (so all detections pass through)
+export INCLUDE_ZONE_POLYGON='[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]'
+
+# Test time in area calculation only (shows all detections + debug messages)
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**How it works:** By setting `CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"`, we override the default live camera script with a file reader that simulates the camera's message broker output by reading from the file specified in `SAMPLE_FILE`. This allows us to test the processing pipeline on the host using pre-recorded sample data instead of connecting to the live camera infrastructure.
+
+**Expected Output:**
+All detections with `time_in_area_seconds` field and `name` set to `detection_frame_with_duration`.
+
+Example output:
+
+```json
+{
+ "fields": {
+ "bounding_box_bottom": 0.62,
+ "bounding_box_left": 0.22,
+ "bounding_box_right": 0.32,
+ "bounding_box_top": 0.42,
+ "frame": "2024-01-15T10:00:02.789012Z",
+ "object_type": "Human",
+ "timestamp": "2024-01-15T10:00:02.789012Z",
+ "track_id": "track_001",
+ "time_in_area_seconds": 1.67
+ },
+ "name": "detection_frame_with_duration",
+ "tags": { "host": "test-host" },
+ "timestamp": 1755677033
+}
+```
+
+The `time_in_area_seconds` field is added by the time-in-area processor, showing how long this object has been tracked in the monitored area. The metric name changes from `detection_frame` → `detection_frame_in_zone` → `detection_frame_with_duration` as it flows through the pipeline.
+
+#### Test Alert Pipeline
+
+Test the alert generation pipeline with threshold filtering:
+
+```bash
+# Set up test environment
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+export TELEGRAF_DEBUG=true
+export ALERT_THRESHOLD_SECONDS="2" # Alert threshold in seconds
+
+# Set zone to cover entire view (so all detections pass through)
+export INCLUDE_ZONE_POLYGON='[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]'
+
+# Test time in area calculation + threshold filtering
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**How it works:** Same as above - we use the file reader script to simulate camera data on the host by reading from the file specified in `SAMPLE_FILE`, allowing us to test the complete pipeline including threshold filtering without needing live camera hardware.
+
+**Expected Output:**
+Only detections with time in area (`time_in_area_seconds`) > `ALERT_THRESHOLD_SECONDS`.
+
+#### Test Alert Pipeline with Rate Limit
+
+Test the complete pipeline including threshold filtering and rate limiting for overlay protection:
+
+```bash
+# Set up test environment
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/simple_tracks.jsonl"
+export TELEGRAF_DEBUG=true
+export ALERT_THRESHOLD_SECONDS="2" # Alert threshold in seconds
+
+# Set zone to cover entire view (so all detections pass through)
+export INCLUDE_ZONE_POLYGON='[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]'
+
+# Test complete pipeline with rate limiting
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config config_process_threshold_filter.conf \
+ --config config_process_rate_limit.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**How it works:** Same as the alert pipeline test, but adds the rate limiting processor that ensures no more than one message per second is passed through to protect the overlay API from being overloaded.
+
+**Expected Output:**
+Only the first message with a `time_in_area_seconds` > `ALERT_THRESHOLD_SECONDS` is passed through, the other are suppressed.
+
+#### Test with Real Device Data
+
+You can also test with real analytics scene description data recorded from an Axis device:
+
+```bash
+# Set up test environment with real device data
+export HELPER_FILES_DIR="$(pwd)"
+export CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"
+export SAMPLE_FILE="test_files/real_device_data.jsonl"
+export TELEGRAF_DEBUG=true
+
+# Set zone to cover entire view (so all detections pass through)
+export INCLUDE_ZONE_POLYGON='[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]'
+
+# Test time in area calculation with real data
+telegraf --config config_agent.conf \
+ --config config_input_scene_detections.conf \
+ --config config_process_class_filter.conf \
+ --config config_process_zone_filter.conf \
+ --config config_process_track_duration.conf \
+ --config test_files/config_output_stdout.conf \
+ --once
+```
+
+**How it works:** We set `CONSUMER_SCRIPT="test_files/sample_data_feeder.sh"` to use a file reader that simulates the camera's message broker output. This allows us to test on the host using pre-recorded real device data instead of connecting to the live camera infrastructure. The `real_device_data.jsonl` file contains actual analytics scene description data recorded from an Axis device, providing realistic testing with real track IDs, timestamps, and object detection patterns.
+
+#### Test Overlay Functionality Only
+
+Test just the overlay functionality with a single detection from a static file. To run this, you need to have access to an Axis device which will show the overlay on the video.
+
+```bash
+# Set up camera info
+export VAPIX_USERNAME="your-username"
+export VAPIX_PASSWORD="your-password"
+export VAPIX_IP="your-device-ip"
+
+# Set up test environment for overlay testing only
+export HELPER_FILES_DIR="$(pwd)"
+export SAMPLE_FILE="test_files/single_overlay_test.jsonl"
+export TELEGRAF_DEBUG="true"
+
+# Test overlay functionality with single detection
+telegraf --config test_files/config_input_overlay_test.conf \
+ --config config_process_overlay_transform.conf \
+ --config config_output_overlay.conf \
+ --once
+```
+
+**How it works:**
+
+1. `config_input_overlay_test.conf` reads a single detection from `single_overlay_test.jsonl`
+2. `config_process_overlay_transform.conf` transforms coordinates and standardizes fields
+3. `config_output_overlay.conf` receives the transformed data and executes the overlay manager
+4. Creates an overlay on the video showing the object information
+5. Removes the overlay after 1 second
+
+**Expected Result:** A red text overlay will appear on the video showing:
+
+```
+← ID: b1718a5c-0...
+ Type: Human
+ Time in area: 00:00:52
+ Last seen at: 2025-10-13 16:36:55 UTC
+```
+
+The overlay text will point at 55% from the left of the video and 76% from the top to the bottom of the video.
+
+## Analytics Data Structure
+
+The analytics data goes through three formats:
+
+### Raw Analytics Data (from camera)
+
+Each line contains a JSON object with this structure:
+
+```json
+{
+ "frame": {
+ "observations": [
+ {
+ "bounding_box": {
+ "bottom": 0.6,
+ "left": 0.2,
+ "right": 0.3,
+ "top": 0.4
+ },
+ "class": { "type": "Human" },
+ "timestamp": "2024-01-15T10:00:01Z",
+ "track_id": "track_001"
+ }
+ ],
+ "operations": [],
+ "timestamp": "2024-01-15T10:00:01Z"
+ }
+}
+```
+
+- **Sparse Output**: Frames are primarily output when objects are detected, with occasional empty frames
+- **Time Gaps**: Periods with no activity result in no output (creating gaps in timestamps)
+- **Occasional Empty Frames**: Sporadically output with `"observations": []`, usually for cleanup operations or periodic heartbeats
+- **Optional Classification**: The `class` field may be missing from observations, especially for short-lived tracks where classification hasn't completed yet
+
+### Data Transformed for Telegraf
+
+The raw analytics data needs transformation for Telegraf's JSON parser because metrics must be flat - the contained list of detections would cause strange concatenations if parsed directly. Both the `sample_data_feeder.sh` script and the real `axis_metadata_consumer.sh` running on the camera perform this transformation.
+
+**From:** Frame-based format (multiple observations per frame)
+
+```json
+{
+ "frame": {
+ "observations": [
+ {"track_id": "track_001", "class": {"type": "Human"}, ...},
+ {"track_id": "track_002", "class": {"type": "Human"}, ...}
+ ],
+ "timestamp": "2024-01-15T10:00:01Z"
+ }
+}
+```
+
+**To:** Individual detection messages (one observation per message, multiple messages per frame)
+
+```json
+{
+ "name": "detection_frame",
+ "fields": {
+ "frame": "2024-01-15T10:00:01Z",
+ "timestamp": "2024-01-15T10:00:01Z",
+ "track_id": "track_001",
+ "object_type": "Human",
+ "bounding_box_bottom": 0.6,
+ "bounding_box_left": 0.2,
+ "bounding_box_right": 0.3,
+ "bounding_box_top": 0.4
+ }
+}
+{
+ "name": "detection_frame",
+ "fields": {
+ "frame": "2024-01-15T10:00:01Z",
+ "timestamp": "2024-01-15T10:00:01Z",
+ "track_id": "track_002",
+ "object_type": "Human",
+ "bounding_box_bottom": 0.58,
+ "bounding_box_left": 0.14,
+ "bounding_box_right": 0.20,
+ "bounding_box_top": 0.38
+ }
+}
+```
+
+This transformation:
+
+- **Splits** nested observations into individual messages
+- **Flattens** nested objects automatically (Telegraf's JSON parser adds the parent field name as prefix, e.g., `bounding_box_left`)
+- **Simplifies** object classification to just the type
+- **Skips** frames with no observations entirely
+- **Adds** metric name and fields structure for Telegraf
+- **Preserves** string fields like timestamps and IDs
+
+### Data Transformed for Overlay
+
+The `config_process_overlay_transform.conf` processor transforms the `detection_frame` into an `overlay_frame` intended to be more suitable for use with the VAPIX overlay API:
+
+1. **Coordinate Transformation**:
+ - Input: `bounding_box_left`, `bounding_box_right`, `bounding_box_top`, `bounding_box_bottom` (range 0.0 to 1.0)
+ - Output: `center_x`, `center_y` (range -1.0 to 1.0)
+
+2. **Field Preservation**:
+ - Copies `object_type`, `track_id`, `time_in_area_seconds`, and `timestamp`
+ - These fields are used for overlay text content
+
+Note that when we draw the overlay text in `overlay_manager.sh`, the coordinate for the text indicates the top-left corner of the text box. This is also where we place the arrow pointing to the object center.
+
+## Recording Real Device Data
+
+You can record real analytics scene description data from your Axis camera for deterministic testing and analysis. This allows you to run the analytics pipeline on your host machine with reproducible results.
+
+```bash
+python test_scripts/record_real_data.py --host --username
+```
+
+The recorded data works with the track heatmap visualization and other analysis tools. For detailed usage instructions, see the [test_scripts README](test_scripts/README.md).
+
+## Track Activity Visualization
+
+This project includes a track heatmap visualization script that shows when different track IDs are active over time, helping you analyze track patterns and activity density in your data.
+
+```bash
+python test_scripts/track_heatmap_viewer.py test_files/simple_tracks.jsonl
+```
+
+For installation, usage details, and examples, see the [test_scripts README](test_scripts/README.md).
+
+
+_Example heatmap showing track activity over time with labeled components (10s alarm threshold)_
+
+## Automated Testing
+
+This project includes comprehensive automated testing to ensure both the visualization script and Telegraf pipeline work correctly and produce consistent results.
+
+### GitHub Workflow
+
+The automated tests run on every push and pull request via the `project-time-in-area-test-analytics.yml` workflow, which includes:
+
+**Three Independent Test Jobs:**
+
+In file `project-time-in-area-test-analytics.yml`:
+
+- `test-telegraf-pipeline`: Validates time-in-area algorithms and workflows in Telegraf
+- `test-visualization-script`: Validates alarm detection in the test visualization script
+
+In file `project-time-in-area-analytics-python-quality.yml`:
+
+- `python-quality`: Validates Python code quality in the test scripts
+
+If you have [Act](https://github.com/nektos/act) installed, you can run the tests locally from the terminal. Note that you need to run them from the root of the repo. Run e.g. the following:
+
+```bash
+cd ..
+act -j test-telegraf-pipeline -W .github/workflows/project-time-in-area-test-analytics.yml -P ubuntu-24.04=catthehacker/ubuntu:act-24.04
+```
+
+If you have the [VS Code plugin "GitHub Local Actions"](https://marketplace.visualstudio.com/items?itemName=SanjulaGanepola.github-local-actions) installed, then you can just open the plugin and press play on any of the test jobs to run them locally.
+
+### Test Data
+
+The tests use `test_files/simple_tracks.jsonl` which contains simplified track data with:
+
+- `track_001`: Appears twice with 8s gap (total time: 11.33s)
+- `track_002`: Continuous presence for 2.22s
+- `track_003`: Continuous presence for 2.22s
+- `track_004`: Single appearance (0s)
+- `track_005`: Long duration track for 2.5 minutes (150s)
+
+
+
+### PR Comments
+
+The workflow automatically posts detailed comments to pull requests with:
+
+- ✅ Success confirmation when all tests pass
+- ❌ Specific failure diagnostics and troubleshooting steps when tests fail
+
+This ensures both tools maintain consistent alarm detection behavior and helps catch regressions early in the development process.
diff --git a/project-time-in-area-analytics/README_INCLUDE_ZONE.md b/project-time-in-area-analytics/README_INCLUDE_ZONE.md
new file mode 100644
index 0000000..1a93372
--- /dev/null
+++ b/project-time-in-area-analytics/README_INCLUDE_ZONE.md
@@ -0,0 +1,96 @@
+# Include Zone for Analytics
+
+We make use of the same format for the include zone as the AXIS Object Analytics ACAP app does. That way, we can export the zone from there and use the same zone for this analytics too.
+
+## Exporting the Include Zone
+
+The [AOA VAPIX API is documented here](https://developer.axis.com/vapix/applications/axis-object-analytics-api/).
+
+You can run the following command:
+
+```bash
+curl -X POST \
+ --digest -u root:ACAPdev \
+ -H "Content-Type: application/json" \
+ -d '{"apiVersion": "1.2", "context": "zone_export", "method": "getConfiguration"}' \
+ "http://${CAMERA_IP}/local/objectanalytics/control.cgi"
+```
+
+This will contain information about the configuration, including one or multiple include zones for a scenario:
+
+```json
+"vertices":[[-0.97,-0.97],[-0.97,0.97],[-0.1209,0.9616],[-0.7562,0.6008],[-0.7652,0.05951],[0.05851,0.5204],[0.04617,-0.9691]]
+```
+
+The example above is from the `data.scenarios[0].triggers[0].vertices` field.
+
+You can also use [Postman](https://www.postman.com/downloads/) to get the zone from the camera.
+
+First:
+
+- Set method to `POST`
+- Set the URL (including the IP of the camera)
+- Press `Authorization` and select `Digest Auth`
+- Specify the username and password for the camera
+
+
+
+Then:
+
+- Press `Body`
+- Specify `raw` format and select `JSON` in the drop-down
+- Specify the following JSON:
+ ```json
+ {
+ "apiVersion": "1.2",
+ "context": "zone_export",
+ "method": "getConfiguration"
+ }
+ ```
+- Press the `Send` button
+
+
+
+You can see the zone in the `vertices` field. To use the zone in the application configuration, you need to have it with no new lines. Press the `Raw` button to remove the new lines.
+
+Note that when you use the zone in the application configuration, you need to enclose it in an extra pair of square brackets. The format of it should look like this: `[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]`.
+
+## The coordinate system
+
+The AXIS Object Analytics API uses a normalized coordinate system where all coordinates are in the range **[-1, 1]**:
+
+- **X-axis**: `-1` (left edge) to `+1` (right edge)
+- **Y-axis**: `-1` (bottom edge) to `+1` (top edge)
+
+The center of the image is at coordinates `(0, 0)`.
+
+## Visualizing the Zone
+
+To visualize the zone on a camera snapshot, use the `visualize_zone.py` script in the `test_scripts` directory:
+
+```bash
+# Display the zone
+python test_scripts/visualize_zone.py \
+ -v '[[-0.97,-0.97],[-0.97,0.97],[-0.1209,0.9616],[-0.7562,0.6008],[-0.7652,0.05951],[0.05851,0.5204],[0.04617,-0.9691]]' \
+ -i test_files/snapshot.jpg
+```
+
+This will overlay the zone polygon on the image with a semi-transparent green fill, showing exactly which area is being monitored.
+
+### Testing the Point-in-Zone Algorithm
+
+The script includes an `is_in_zone()` function that uses the ray tracing algorithm to determine if a point is inside the zone. You can test it by adding random points:
+
+```bash
+python test_scripts/visualize_zone.py \
+ -v '[[-0.97,-0.97],[-0.97,0.97],[-0.1209,0.9616],[-0.7562,0.6008],[-0.7652,0.05951],[0.05851,0.5204],[0.04617,-0.9691]]' \
+ -i test_files/snapshot.jpg \
+ --add-random-points 500
+```
+
+Points will be drawn in:
+
+- **Red**: Inside the zone
+- **Yellow**: Outside the zone
+
+The algorithm uses basic Python only (no numpy) for easy porting to Starlark.
diff --git a/project-time-in-area-analytics/axis_scene_detection_consumer.sh b/project-time-in-area-analytics/axis_scene_detection_consumer.sh
new file mode 100755
index 0000000..12e2d45
--- /dev/null
+++ b/project-time-in-area-analytics/axis_scene_detection_consumer.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+
+# Axis Camera Metadata Consumer Script
+#
+# This script consumes analytics scene description data from an Axis camera's
+# internal message broker and outputs JSON for Telegraf processing.
+#
+# IMPORTANT: This script only works when deployed to the camera itself.
+# It cannot be tested on a host system because it depends on camera-specific
+# commands and internal message broker infrastructure.
+#
+# Key Features:
+# - Consumes analytics scene description data from camera's message broker
+# - Unpacks frame events to individual detection messages
+# - Filters output to extract only JSON data with detections
+# - Outputs structured data for MQTT transmission
+# - Camera-specific implementation (requires deployment)
+#
+# Technical Details:
+# - Uses message-broker-cli (camera-specific command)
+# - Consumes topic: com.axis.analytics_scene_description.v0.beta
+# - Transforms frame-based format to individual detection messages
+# - Uses jq to parse and restructure JSON data
+# - Outputs one detection per line for Telegraf processing
+
+# Analytics scene description topic (camera-specific)
+TOPIC="com.axis.analytics_scene_description.v0.beta"
+SOURCE="1"
+
+# Check if jq is available on the camera
+if ! command -v jq >/dev/null 2>&1; then
+ echo "ERROR: jq is required but not available on this camera" >&2
+ exit 1
+fi
+
+# Run the metadata broker, filter JSON, and transform to individual detections
+# Example transformations:
+# 1. sed removes prefix: "INFO: {json}" -> "{json}"
+# 2. jq unpacks:
+# stdin: {"frame":{"observations":[{"track_id":"123"},{"track_id":"456"}]}}
+# stdout: {"frame":"2024-01-15T10:00:01Z","track_id":"123"}
+# stdout: {"frame":"2024-01-15T10:00:01Z","track_id":"456"}
+message-broker-cli consume "$TOPIC" "$SOURCE" | \
+sed -n 's/^[^{]*//p' | \
+jq -c '
+.frame as $frame |
+if ($frame.observations | length) > 0 then
+ $frame.observations[] |
+ {
+ "frame": $frame.timestamp,
+ "timestamp": .timestamp,
+ "track_id": .track_id,
+ "object_type": .class.type,
+ "bounding_box": .bounding_box
+ }
+else
+ empty
+end'
\ No newline at end of file
diff --git a/project-time-in-area-analytics/config_agent.conf b/project-time-in-area-analytics/config_agent.conf
new file mode 100644
index 0000000..b76f73a
--- /dev/null
+++ b/project-time-in-area-analytics/config_agent.conf
@@ -0,0 +1,7 @@
+# Global Telegraf Agent Configuration
+
+[agent]
+ # Enable debug logging if the environment variable
+ # is set by the FixedIT Data Agent.
+ debug = ${TELEGRAF_DEBUG}
+
diff --git a/project-time-in-area-analytics/config_input_scene_detections.conf b/project-time-in-area-analytics/config_input_scene_detections.conf
new file mode 100644
index 0000000..8736cde
--- /dev/null
+++ b/project-time-in-area-analytics/config_input_scene_detections.conf
@@ -0,0 +1,44 @@
+# Metadata Input Configuration for Analytics Scene Description
+#
+# This configuration uses Telegraf's execd input plugin to continuously
+# consume analytics metadata from either the camera's internal message broker
+# or a sample data file for testing.
+#
+# Key Features:
+# - Runs the metadata consumer script continuously (not on intervals)
+# - Processes JSON messages from the camera's analytics stream
+# - Can be overridden to use mock data for testing
+# - Handles real-time scene description data
+#
+# Environment Variables:
+# - HELPER_FILES_DIR: Directory containing project files (required)
+# - CONSUMER_SCRIPT: Path to consumer script (defaults to axis_metadata_consumer.sh)
+# - SAMPLE_FILE: Path to sample data file (required when using sample_data_feeder.sh)
+#
+# Technical Details:
+# - execd plugin runs the command continuously and reads stdout stream
+# - Expects one JSON message per line from the script
+# - Parses each line as a separate JSON object
+# - No signal handling (script runs until Telegraf stops)
+
+[[inputs.execd]]
+ # Command to execute - uses CONSUMER_SCRIPT if set, otherwise defaults to live camera script
+ # Default: axis_metadata_consumer.sh (live camera)
+ # Override: Set CONSUMER_SCRIPT to use different script (e.g., test_files/sample_data_feeder.sh)
+ command = [
+ "${HELPER_FILES_DIR}/${CONSUMER_SCRIPT:-axis_scene_detection_consumer.sh}"
+ ]
+
+ # No signal handling - let the script run continuously
+ # The script will keep running and outputting JSON until Telegraf stops
+ signal = "none"
+
+ # Parse each line of output as JSON format
+ # Each line from the script should contain one complete JSON object
+ data_format = "json"
+
+ # Override the metric name for clarity in MQTT topics
+ name_override = "detection_frame"
+
+ # String fields that should be preserved as strings during JSON parsing
+ json_string_fields = ["timestamp", "track_id", "object_type", "frame"]
diff --git a/project-time-in-area-analytics/config_output_events.conf b/project-time-in-area-analytics/config_output_events.conf
new file mode 100644
index 0000000..dfeaa24
--- /dev/null
+++ b/project-time-in-area-analytics/config_output_events.conf
@@ -0,0 +1,73 @@
+# Output to send all 'alerting_state_metric' metrics to the event producer binary.
+# The event is configured through the GKeyFile content specified when
+# running the binary, using the following format:
+# [topics]
+# namespace =
+# nice_name =
+# topic_0 =
+# topic_1 =
+# topic_2 =
+#
+# [settings]
+# # true if event should be stateful
+# # false if event should be stateless
+# stateful =
+#
+# [item.]
+# kind =
+# data_type =
+# value =
+#
+# The [topics] and [settings] groups and all their key-value pairs
+# are mandatory for successfully declaring the event.
+# Items are optional, and multiple can be defined.
+#
+# The binary expects the passed json metrics to have the following
+# format:
+#
+# {
+# "fields": {: (...)},
+# "name":"",
+# "tags":{},
+# "timestamp":
+# }
+#
+# Only the "fields" value matters, since it is the only part
+# that is used by the binary. It will parse every key-value
+# pair in "fields" and use those as the values to update
+# in the event's items before sending it. If any item present
+# during event declaration isn't specified in "fields", the
+# the event will simply send the event with that key's
+# previous value.
+[[outputs.execd]]
+ # Only consume the 'alerting_state_metric' metrics
+ namepass = ["alerting_state_metric"]
+
+ # The binary expects JSON formatted metrics
+ data_format = "json"
+
+ # Command to run the binary, event structure is
+ # provided through GKeyFile-formatted input.
+ command = [
+ "${EXECUTABLES_DIR}/event_handler", "--config-inline",
+ """[topics]
+ namespace = tnsaxis
+ nice_name = FixedIT Time-in-Area Event
+ topic_0 = CameraApplicationPlatform
+ topic_1 = FixedITDataAgent
+ topic_2 = TimeInArea
+
+ [settings]
+ stateful = true
+
+ [item.active]
+ kind = data
+ data_type = bool
+ default_value = false
+ """
+ ]
+
+ # Process one metric at a time for immediate response.
+ # This ensures each event gets sent immediately.
+ # Higher values would batch multiple changes (undesirable).
+ metric_batch_size = 1
\ No newline at end of file
diff --git a/project-time-in-area-analytics/config_output_overlay.conf b/project-time-in-area-analytics/config_output_overlay.conf
new file mode 100644
index 0000000..914e022
--- /dev/null
+++ b/project-time-in-area-analytics/config_output_overlay.conf
@@ -0,0 +1,60 @@
+# Axis Overlay Output Configuration
+#
+# This configuration uses Telegraf's exec output plugin to trigger the
+# overlay_manager.sh script whenever objects exceed the time-in-area threshold.
+#
+# Key Features:
+# - Executes shell script for each overlay event
+# - Immediate execution with no batching for real-time response
+# - JSON data format for structured communication with script
+# - Timeout protection for API call sequences
+# - Exec plugin runs external commands with metric data as stdin
+# - Script receives JSON with overlay information including coordinates, class, and time
+#
+# Environment Variables Required:
+# - HELPER_FILES_DIR: Directory containing overlay_manager.sh script (set automatically by the FixedIt Data Agent)
+# - VAPIX_USERNAME: Axis device username
+# - VAPIX_PASSWORD: Axis device password
+# - VAPIX_IP: IP address of the Axis device, should be 127.0.0.1 when running in the FixedIT Data Agent on an Axis device.
+
+[[outputs.exec]]
+ # Filter to process only overlay metrics from the Starlark processor
+ # This ensures the script only runs when overlay data is ready
+ namepass = ["overlay_frame"]
+
+ # Shell script command to execute for each overlay event
+ # The script receives JSON data via stdin and controls the overlay display
+ # HELPER_FILES_DIR should point to the directory containing the script,
+ # which is set automatically by the FixedIt Data Agent to the directory where
+ # helper files are uploaded.
+ command = ["${HELPER_FILES_DIR}/overlay_manager.sh"]
+
+ # Data format sent to the script via stdin
+ # JSON format provides structured data that the script can parse easily
+ # Format: {"fields":{"track_id":"123","time_in_area_seconds":45.2,"class":"Human",...},"name":"detection_frame",...}
+ data_format = "json"
+
+ # Disable batch format to send individual metrics to the script.
+ # When false: sends single metric JSON object per execution.
+ # When true: would send array of metrics (not needed for this use case).
+ use_batch_format = false
+
+ # Immediate execution settings for real-time response.
+ # These settings minimize delay between threshold detection and overlay display.
+
+ # Process one metric at a time for immediate response.
+ # This ensures each overlay event triggers script execution immediately.
+ # Higher values would batch multiple changes (undesirable for visual feedback).
+ metric_batch_size = 1
+
+ # Minimal buffer size. 2*metric_batch_size is the minimum value allowed in the
+ # configuration according to the documentation.
+ metric_buffer_limit = 2
+
+ # Script execution timeout to prevent hanging.
+ # The script makes VAPIX API calls to manage overlays.
+ # 15 seconds allows sufficient time for network round trips and error handling.
+ # Increase if experiencing timeout issues on slow networks.
+ # Setting this makes the application more robust since it can recover from
+ # a hanging script.
+ timeout = "15s"
diff --git a/project-time-in-area-analytics/config_process_alarming_state.conf b/project-time-in-area-analytics/config_process_alarming_state.conf
new file mode 100644
index 0000000..05967d6
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_alarming_state.conf
@@ -0,0 +1,79 @@
+# This configuration file sets up a heartbeat metric
+# and applies a Starlark processor for inactivity monitoring.
+# The Starlark processor checks if there have been any alarming
+# detections since the last heartbeat, and if not, sets
+# the alarming state to "false". Note that it only monitors
+# for alarming state metrics in general, it does not keep
+# track of what or how many objects triggered the alarm.
+
+# ---- Heartbeat (1 metric/second) ----
+[[inputs.exec]]
+ # This is a static heartbeat to trigger the inactivity monitor,
+ # so it does not matter what data it includes.
+ commands = ["sh -c 'echo heartbeat value=1i'"]
+ data_format = "influx"
+ # TODO: we set this to 2 seconds since we seem to get
+ # the alerting_frame_two metrics batched every second,
+ # so we want to have enough time for those metrics to
+ # have arrived by the time we check the heartbeat.
+ # We should investigate this further to verify this.
+ interval = "2s"
+ name_override = "alarming_state_heartbeat"
+
+# ---- Starlark processor ----
+# This gets triggered by both the heartbeat and any alarming state metrics.
+# This makes sure the code is run at least once per second even if there
+# are no alarming state metrics.
+[[processors.starlark]]
+ namepass = ["alarming_state_heartbeat", "alerting_frame_two"]
+ source = '''
+"""
+Monitor if an alerting_frame_two metric has not been sent
+since the last alarming_state_heartbeat metric.
+When we have an alerting object in the monitored zone,
+we will get a metric every time we observe that object.
+Once the object leaves the area, we stop receiving
+alerting_frame_two metrics. This function makes sure that
+we send a metric every time we go from no alerting objects
+to at least one alerting object, and from at least one
+alerting object to no alerting object.
+"""
+load("logging.star", "log")
+
+"""
+We initialize the state to keep it as a persistent
+state between calls. We can use it to store information
+such as the "has_alarm_since_last_heartbeat" value.
+"""
+state = {
+ # This variable is used to count how many "alerting_frame_two"
+ # metrics have been received since the last
+ # "alarming_state_heartbeat" metric.
+ "alert_count_since_last_heartbeat": 0
+}
+
+def apply(metric):
+ # If we got an alerting frame, increment the counter and return without producing any metric.
+ if metric.name == "alerting_frame_two":
+ state["alert_count_since_last_heartbeat"] += 1
+ return
+
+ # Validate that the metric is a heartbeat
+ if metric.name != "alarming_state_heartbeat":
+ log.debug("Error: received metric with unexpected name: " + metric.name)
+ return
+
+ # Log the number of alerting frames received since last heartbeat
+ log.debug("Alerting frames since last heartbeat: " + str(state.get("alert_count_since_last_heartbeat", 0)))
+
+ alert_count = state.get("alert_count_since_last_heartbeat", 0)
+ alert_state = alert_count > 0
+
+ # Always reset the counter to 0 at each heartbeat so we can start monitoring again.
+ state["alert_count_since_last_heartbeat"] = 0
+
+ alarming_state_metric = Metric("alerting_state_metric")
+ alarming_state_metric.time = metric.time
+ alarming_state_metric.fields["active"] = alert_state
+ return alarming_state_metric
+'''
\ No newline at end of file
diff --git a/project-time-in-area-analytics/config_process_class_filter.conf b/project-time-in-area-analytics/config_process_class_filter.conf
new file mode 100644
index 0000000..8afcf00
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_class_filter.conf
@@ -0,0 +1,128 @@
+# Class Filter Processor
+#
+# This processor filters detection frames based on object class/type classification.
+# Only detections matching the configured class filter are passed through.
+#
+# The OBJECT_TYPE_FILTER variable controls which classes pass:
+# - ALL: pass only verified detections (object_type != null) - DEFAULT
+# - ALL_UNVERIFIED: pass all detections including unverified (object_type == null or any verified class)
+# - Specific class name (e.g., "Human"): pass only exact matches (case-insensitive)
+#
+# If OBJECT_TYPE_FILTER is not set, defaults to ALL and logs a warning.
+#
+# Input: detection_frame metrics from input processor
+# Output: detection_frame_class_filtered metrics (filtered by class)
+#
+# Environment Variables:
+# - OBJECT_TYPE_FILTER: Class filter mode or specific class name (optional)
+# Defaults to ALL if not set.
+
+[[processors.starlark]]
+ # Only consume metrics with name "detection_frame"
+ namepass = ["detection_frame"]
+
+ # Inline Starlark script for class filtering
+ source = '''
+load("logging.star", "log")
+
+# Initialize mode and target (will be set during first apply)
+state = {}
+
+def init_mode(object_type_str):
+ """Initialize filtering mode based on object_type_str parameter.
+
+ Args:
+ object_type_str: Filter setting string (ALL, ALL_UNVERIFIED, or specific class name),
+ this is case-insensitive.
+ """
+ if "mode" in state:
+ return # Already initialized
+
+ object_type_str = object_type_str.lower()
+
+ if object_type_str == "" or object_type_str.startswith("${"):
+ log.warn(
+ "OBJECT_TYPE_FILTER not set; defaulting to ALL (pass only verified detections). " +
+ "If this is intended: set OBJECT_TYPE_FILTER=ALL"
+ )
+ state["mode"] = "all"
+ elif object_type_str == "all" or object_type_str == "all_unverified":
+ state["mode"] = object_type_str
+ else:
+ # Exact class match mode (case-insensitive)
+ state["mode"] = "exact"
+ state["target"] = object_type_str
+
+def canonicalize_object_type(value):
+ """Validate and canonicalize the object_type field value.
+
+ Converts to lowercase for case-insensitive comparison and normalizes
+ non-string/missing values to None to represent unverified objects.
+
+ Returns canonical_value: None for unclassified, or lowercase class name string.
+ """
+ if value == None:
+ # None represents unclassified/unverified object
+ return None
+ # Check if value is a string
+ if type(value) != type(""):
+ log.warn("object_type not a string: " + str(value) + "; treating as unverified")
+ return None
+ if value == "":
+ log.warn("object_type empty string; treating as unverified")
+ return None
+ # Normalize to lowercase for case-insensitive comparison
+ return value.lower()
+
+
+def should_keep(mode, obj):
+ """Determine if a detection should pass the filter.
+
+ Args:
+ mode: Filter mode (all, all_unverified, or exact)
+ obj: Canonical object_type (None for unverified, or lowercase class name)
+
+ Returns: True if detection should pass, False if it should be filtered out
+ """
+ if mode == "all":
+ # Pass only verified detections
+ return obj != None
+ elif mode == "all_unverified":
+ # Pass all detections
+ return True
+ else:
+ # exact mode: match target class
+ target = state.get("target")
+ return obj == target
+
+
+def apply(metric):
+ """Filter detection_frame metrics by object class type.
+
+ Renames passing metrics to detection_frame_class_filtered.
+ Returns None to drop metrics that don't match the filter.
+ """
+ # Initialize mode based on the environment variable
+ init_mode(filter_env_var)
+ mode = state["mode"]
+
+ # Get and canonicalize the object_type field
+ obj = canonicalize_object_type(metric.fields.get("object_type"))
+
+ track_id = metric.fields.get("track_id", "unknown")
+
+ # Determine if this detection should pass the filter
+ obj_str = obj if obj else "None"
+ mode_str = "exact:" + state.get("target") if mode == "exact" else mode
+ if should_keep(mode, obj):
+ log.debug("apply: track_id=" + track_id + " object_type=" + obj_str + " mode=" + mode_str + " - PASS")
+ filtered = deepcopy(metric)
+ filtered.name = "detection_frame_class_filtered"
+ return filtered
+ else:
+ log.debug("apply: track_id=" + track_id + " object_type=" + obj_str + " mode=" + mode_str + " - FILTER OUT")
+ return None
+'''
+
+ [processors.starlark.constants]
+ filter_env_var = "${OBJECT_TYPE_FILTER}"
\ No newline at end of file
diff --git a/project-time-in-area-analytics/config_process_overlay_transform.conf b/project-time-in-area-analytics/config_process_overlay_transform.conf
new file mode 100644
index 0000000..734a3ca
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_overlay_transform.conf
@@ -0,0 +1,62 @@
+# Overlay Data Transformation Processor
+#
+# This processor transforms analytics detection data into the format expected
+# by the overlay_manager.sh script, including coordinate transformation and
+# field formatting.
+
+[[processors.starlark]]
+ namepass = ["rate_limited_alert_frame"]
+ # Source code for the transformation logic
+ source = '''
+load("logging.star", "log")
+
+def apply(metric):
+ # Only process rate-limited metrics
+ if metric.name != "rate_limited_alert_frame":
+ return None
+
+ track_id = metric.fields.get("track_id", "unknown")
+
+ # Extract bounding box coordinates (flat format from device)
+ if ("bounding_box_left" in metric.fields and
+ "bounding_box_right" in metric.fields and
+ "bounding_box_top" in metric.fields and
+ "bounding_box_bottom" in metric.fields):
+
+ left = metric.fields["bounding_box_left"]
+ right = metric.fields["bounding_box_right"]
+ top = metric.fields["bounding_box_top"]
+ bottom = metric.fields["bounding_box_bottom"]
+
+ # Calculate center coordinates in analytics system (0.0 to 1.0)
+ center_x = (left + right) / 2.0
+ center_y = (top + bottom) / 2.0
+
+ # Transform to VAPIX API coordinate system (-1.0 to 1.0)
+ # Formula: vapix_coord = (analytics_coord - 0.5) * 2
+ vapix_x = (center_x - 0.5) * 2.0
+ vapix_y = (center_y - 0.5) * 2.0
+
+ log.debug("apply: track_id=" + track_id + " transformed coords from analytics (" + str(center_x) + "," + str(center_y) + ") to VAPIX (" + str(vapix_x) + "," + str(vapix_y) + ")")
+
+ # Create new overlay metric by copying the original
+ overlay_frame = deepcopy(metric)
+ overlay_frame.name = "overlay_frame"
+
+ # Set transformed coordinates
+ overlay_frame.fields["center_x"] = vapix_x
+ overlay_frame.fields["center_y"] = vapix_y
+
+ # Remove original bounding box fields since we now have center coordinates
+ overlay_frame.fields.pop("bounding_box_left", None)
+ overlay_frame.fields.pop("bounding_box_right", None)
+ overlay_frame.fields.pop("bounding_box_top", None)
+ overlay_frame.fields.pop("bounding_box_bottom", None)
+
+ # Return the overlay metric
+ return overlay_frame
+
+ # If no bounding box, drop the metric (can't create overlay without position)
+ log.warn("apply: track_id=" + track_id + " missing bounding box fields - dropping metric")
+ return None
+'''
diff --git a/project-time-in-area-analytics/config_process_rate_limit.conf b/project-time-in-area-analytics/config_process_rate_limit.conf
new file mode 100644
index 0000000..21f10b6
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_rate_limit.conf
@@ -0,0 +1,40 @@
+# Rate limit messages to 1 per second to protect the overlay API
+[[processors.starlark]]
+ namepass = ["alerting_frame"]
+ source = '''
+load("time.star", "time")
+load("logging.star", "log")
+
+state = {}
+
+def apply(metric):
+ # Only process alerting_frame metrics
+ if metric.name != "alerting_frame":
+ return None
+
+ # Get the current state or initialize if not exists.
+ # We use system time, not the message timestamp, since we
+ # want to only let one message per second hit the overlay API.
+ last_update = state.get("last_update") or 0
+ current_time = time.now().unix
+
+ # Calculate time since last update in seconds
+ time_since_last = current_time - last_update
+
+ track_id = metric.fields.get("track_id", "unknown")
+
+ # If less than 1 second has passed, drop the message
+ if time_since_last < 1.0:
+ log.debug("apply: track_id=" + track_id + " rate limited - only " + str(time_since_last) + "s since last alert (< 1s)")
+ return None
+
+ # Update the last update time
+ state["last_update"] = current_time
+
+ log.debug("apply: track_id=" + track_id + " passed rate limit - " + str(time_since_last) + "s since last alert")
+
+ # Create a new metric with the rate-limited name
+ rate_limited_metric = deepcopy(metric)
+ rate_limited_metric.name = "rate_limited_alert_frame"
+ return rate_limited_metric
+'''
diff --git a/project-time-in-area-analytics/config_process_threshold_filter.conf b/project-time-in-area-analytics/config_process_threshold_filter.conf
new file mode 100644
index 0000000..8c51917
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_threshold_filter.conf
@@ -0,0 +1,43 @@
+# Threshold Filter Processor
+#
+# This processor filters detection frames based on time in area threshold.
+# Only detections that exceed the configured threshold are passed through.
+#
+# Input: detection_frame_with_duration metrics with time_in_area_seconds field
+# Output: alerting_frame metrics where time_in_area_seconds > ALERT_THRESHOLD_SECONDS
+#
+# Environment Variables:
+# - ALERT_THRESHOLD_SECONDS: Minimum time in area in seconds to trigger alerts (required)
+
+[[processors.starlark]]
+ # Process only detection frames with duration calculated
+ namepass = ["detection_frame_with_duration"]
+
+ source = '''
+load("logging.star", "log")
+
+def apply(metric):
+ # Get time in area and track_id from the metric
+ time_in_area = metric.fields.get("time_in_area_seconds", 0)
+ track_id = metric.fields.get("track_id", "unknown")
+
+ # Parse threshold as float to ensure proper comparison with float time values
+ threshold = float(${ALERT_THRESHOLD_SECONDS})
+
+ # Only return metrics that exceed or equal the threshold
+ if time_in_area >= threshold:
+ log.debug("apply: track_id=" + track_id + " duration=" + str(time_in_area) + "s >= threshold=" + str(threshold) + "s - PASS")
+ # Create a new metric with the alerting name
+ alerting_metric = deepcopy(metric)
+ alerting_metric.name = "alerting_frame"
+
+ # Duplicate the metric, since it needs to get
+ # to two processors
+ alerting_metric_two = deepcopy(metric)
+ alerting_metric_two.name = "alerting_frame_two"
+ return [alerting_metric, alerting_metric_two]
+
+ # Track doesn't exceed threshold - don't output
+ log.debug("apply: track_id=" + track_id + " duration=" + str(time_in_area) + "s < threshold=" + str(threshold) + "s - FILTER OUT")
+ return None
+'''
diff --git a/project-time-in-area-analytics/config_process_track_duration.conf b/project-time-in-area-analytics/config_process_track_duration.conf
new file mode 100644
index 0000000..c2f8a75
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_track_duration.conf
@@ -0,0 +1,17 @@
+# Track Duration Calculator Processor
+#
+# This processor calculates time in area for object tracking and manages state cleanup.
+# It processes detection frames (filtered by zone) and outputs:
+# 1. Original detection metrics with added time_in_area_seconds field
+# 2. Debug metrics when stale tracks are cleaned up
+#
+# Input: detection_frame_in_zone metrics with track_id, timestamp, coordinates
+# Output: detection_frame_with_duration metrics with time_in_area_seconds field + optional debug metrics
+
+[[processors.starlark]]
+ # Only process detection frames that passed zone filtering
+ namepass = ["detection_frame_in_zone"]
+
+ # Run the apply function in the starlark script to process all
+ # detections and add the extra time_in_area_seconds field to them.
+ script = "${HELPER_FILES_DIR}/track_duration_calculator.star"
diff --git a/project-time-in-area-analytics/config_process_zone_filter.conf b/project-time-in-area-analytics/config_process_zone_filter.conf
new file mode 100644
index 0000000..fb5c7af
--- /dev/null
+++ b/project-time-in-area-analytics/config_process_zone_filter.conf
@@ -0,0 +1,28 @@
+# Zone Filter Processor
+#
+# This processor filters detection frames based on a configured zone polygon.
+# Only detections with bounding box centers inside the zone are passed through.
+# If INCLUDE_ZONE_POLYGON is not set, all detections pass through unchanged.
+#
+# The zone polygon is defined in normalized coordinates [-1, 1]:
+# - X-axis: -1 (left) to +1 (right)
+# - Y-axis: -1 (bottom) to +1 (top)
+#
+# Input: detection_frame_class_filtered metrics from class filter processor
+# Output: detection_frame_in_zone metrics (only those with centers inside the zone)
+#
+# Environment Variables:
+# - INCLUDE_ZONE_POLYGON: JSON array of zones (required)
+# Format: [[vertex1, vertex2, vertex3, ...]]
+# Example: [[[-0.5,-0.5],[-0.5,0.5],[0.5,0.5],[0.5,-0.5]]]
+# Currently only supports a single zone (multi-zone planned for future)
+
+[[processors.starlark]]
+ # Only consume metrics from the class filter
+ namepass = ["detection_frame_class_filtered"]
+
+ # Path to Starlark script
+ script = "${HELPER_FILES_DIR}/zone_filter.star"
+
+ [processors.starlark.constants]
+ zone_polygon_json = "${INCLUDE_ZONE_POLYGON}"
diff --git a/project-time-in-area-analytics/overlay_manager.sh b/project-time-in-area-analytics/overlay_manager.sh
new file mode 100755
index 0000000..9e5858d
--- /dev/null
+++ b/project-time-in-area-analytics/overlay_manager.sh
@@ -0,0 +1,419 @@
+#!/bin/sh
+
+# Set strict error handling, but note that the Axis cameras
+# does not support "-o pipefail".
+set -eu
+
+# Axis Overlay Manager Script
+#
+# This script manages text overlays on Axis cameras based on time-in-area analytics.
+# It receives detection metrics from Telegraf and displays overlay text showing
+# object information when objects exceed the configured threshold.
+#
+# Key Features:
+# - Receives detection metrics via JSON from Telegraf exec output plugin
+# - Manages overlays for objects that exceed time-in-area threshold
+# - Shows only one object at a time (most recent detection)
+# - Displays object ID, time in area and object type
+# - Positions overlay at the center of the detected object
+# - Provides comprehensive error handling and validation
+#
+# Environment Variables:
+# - VAPIX_USERNAME: Device username (required)
+# - VAPIX_PASSWORD: Device password (required)
+# - HELPER_FILES_DIR: Directory for storing overlay identity and debug log files (required)
+# - VAPIX_IP: IP address of the Axis device (defaults to 127.0.0.1 for localhost)
+# - TELEGRAF_DEBUG: Enable debug logging when set to "true" (defaults to false)
+# - FONT_SIZE: Font size for the overlay text (defaults to 32)
+#
+# Error Codes:
+# - 10: Missing required environment variables
+# - 11: Empty input received from stdin
+# - 12: Invalid JSON input or missing required fields
+# - 13: Overlay API call failed
+
+# Set default VAPIX_IP to localhost if not specified
+VAPIX_IP="${VAPIX_IP:-127.0.0.1}"
+
+# Set a font size
+FONT_SIZE="${FONT_SIZE:-45}"
+
+# Fixed context name for all overlays to ensure only one is active
+OVERLAY_CONTEXT="time_in_area_overlay"
+
+# We will use a persistent file in the flash to remember the ID of the
+# overlay which we are currently using. This allows us to update the
+# text and position of the existing overlay instead of creating a new one.
+# We must be aware that there might be other overlay texts that the user
+# has created manually in the camera, therefore the identity is important.
+IDENTITY_FILE="${HELPER_FILES_DIR}/.overlay_identity_${OVERLAY_CONTEXT}"
+
+# Strings that we will match from API responses. These should never
+# be changed!
+API_ERROR_STRING="error"
+
+# Validate required environment variables
+if [ -z "$VAPIX_USERNAME" ] || [ -z "$VAPIX_PASSWORD" ]; then
+ printf "VAPIX_USERNAME and VAPIX_PASSWORD must be set" >&2
+ exit 10
+fi
+
+if [ -z "$HELPER_FILES_DIR" ]; then
+ printf "HELPER_FILES_DIR must be set" >&2
+ exit 10
+fi
+
+# Debug mode - use TELEGRAF_DEBUG environment variable
+DEBUG="${TELEGRAF_DEBUG:-false}"
+
+# Function to log debug messages to a file
+debug_log_file() {
+ _debug_message=$1
+ if [ "$DEBUG" = "true" ]; then
+ echo "DEBUG: $_debug_message" >> "${HELPER_FILES_DIR}/overlay_manager.debug" 2>/dev/null || true
+ fi
+ return 0
+}
+
+# Function to log an error message and exit with the specified code
+error_exit() {
+ _exit_code=$1
+ _error_message=$2
+ debug_log_file "ERROR: $_error_message"
+ printf "%s" "$_error_message" >&2
+ exit "$_exit_code"
+ return 1 # Should never get here...
+}
+
+# Function to get stored overlay identity or return an error
+# code if the file does not exist or is empty.
+get_stored_identity() {
+ if [ -f "$IDENTITY_FILE" ]; then
+ # Read identity from file
+ _identity=$(cat "$IDENTITY_FILE" 2>/dev/null | tr -d '\n')
+
+ # Validate that we got a non-empty identity
+ if [ -n "$_identity" ]; then
+ echo "$_identity"
+ return 0
+ fi
+ fi
+
+ # File doesn't exist or is empty - both cases should create new overlay
+ echo ""
+ return 1
+}
+
+# Function to store overlay identity in a persistent file.
+# Returns 1 on failure to save the file.
+store_identity() {
+ _identity=$1
+ if ! echo "$_identity" > "$IDENTITY_FILE"; then
+ debug_log_file "ERROR: Failed to write identity file: $IDENTITY_FILE"
+ return 1
+ fi
+ debug_log_file "Stored overlay identity: $_identity"
+ return 0
+}
+
+# Helper function to add a new overlay
+add_overlay() {
+ text=$1 # Text content
+ x=$2 # X coordinate
+ y=$3 # Y coordinate
+
+ debug_log_file "Adding new overlay - Text: $text, Pos: ($x, $y)"
+ json_payload=$(jq -n \
+ --arg text "$text" \
+ --arg context "$OVERLAY_CONTEXT" \
+ --arg font_size "$FONT_SIZE" \
+ --arg x "$x" \
+ --arg y "$y" \
+ '{
+ apiVersion: "1.8",
+ method: "addText",
+ context: $context,
+ params: {
+ camera: 1,
+ text: $text,
+ textColor: "red",
+ textBGColor: "white",
+ textOLColor: "black",
+ fontSize: ($font_size|tonumber),
+ position: [($x|tonumber),($y|tonumber)]
+ }
+ }')
+
+ debug_log_file "Sending JSON payload: $json_payload"
+ api_response=$(curl --silent --fail --digest --user "${VAPIX_USERNAME}:${VAPIX_PASSWORD}" \
+ "http://${VAPIX_IP}/axis-cgi/dynamicoverlay/dynamicoverlay.cgi" \
+ -X POST \
+ -H "Content-Type: application/json" \
+ -d "$json_payload" 2>&1)
+
+ api_exit=$?
+ debug_log_file "API call exit code: $api_exit"
+ debug_log_file "API response: $api_response"
+
+ if [ $api_exit -ne 0 ] || echo "$api_response" | grep -q "\"$API_ERROR_STRING\""; then
+ debug_log_file "ERROR: Failed to create overlay"
+ return 1
+ fi
+
+ # Extract and store the overlay identity from the response
+ overlay_identity=$(echo "$api_response" | jq -r '.data.identity // empty' 2>/dev/null)
+ if [ -n "$overlay_identity" ] && [ "$overlay_identity" != "null" ]; then
+ debug_log_file "Overlay added successfully with identity: $overlay_identity"
+
+ # Store the identity - if this fails, propagate the error
+ if ! store_identity "$overlay_identity"; then
+ debug_log_file "ERROR: Failed to store overlay identity"
+ printf "Failed to store overlay identity to file %s" "$IDENTITY_FILE" >&2
+ return 1
+ fi
+
+ return 0
+ else
+ debug_log_file "ERROR: Overlay added but could not extract identity from API response"
+ return 1
+ fi
+}
+
+# Helper function to update an existing overlay
+#
+# Expected API behavior:
+# - Success: Returns {"data": {}, ...} without error field
+# - Invalid identity: Returns {"error": {"code": 302, "message": "Invalid value for parameter identity"}, ...}
+#
+# Return codes:
+# - 0: Success
+# - 1: General failure (curl error or API error)
+# - 2: Invalid identity (error code 302) - overlay was deleted, caller should remove identity file and create new overlay
+#
+# Note: We have observed cases where the API does not reliably report errors when the overlay
+# doesn't exist. Therefore, the caller should use a file-based identity tracking system and
+# check the return code to detect when overlays have been deleted (behavior not guaranteed).
+update_overlay() {
+ text=$1 # Text content
+ x=$2 # X coordinate
+ y=$3 # Y coordinate
+ overlay_identity=$4 # Overlay identity to update
+
+ debug_log_file "Updating overlay - Text: $text, Pos: ($x, $y), Identity: $overlay_identity"
+ json_payload=$(jq -n \
+ --arg text "$text" \
+ --arg context "$OVERLAY_CONTEXT" \
+ --arg identity "$overlay_identity" \
+ --arg x "$x" \
+ --arg y "$y" \
+ '{
+ apiVersion: "1.8",
+ method: "setText",
+ context: $context,
+ params: {
+ identity: ($identity|tonumber),
+ text: $text,
+ position: [($x|tonumber),($y|tonumber)]
+ }
+ }')
+
+ debug_log_file "Sending JSON payload: $json_payload"
+ api_response=$(curl --silent --fail --digest --user "${VAPIX_USERNAME}:${VAPIX_PASSWORD}" \
+ "http://${VAPIX_IP}/axis-cgi/dynamicoverlay/dynamicoverlay.cgi" \
+ -X POST \
+ -H "Content-Type: application/json" \
+ -d "$json_payload" 2>&1)
+
+ api_exit=$?
+ debug_log_file "API call exit code: $api_exit"
+ debug_log_file "API response: $api_response"
+
+ # Check for curl failure
+ if [ $api_exit -ne 0 ]; then
+ debug_log_file "ERROR: Failed to update overlay (curl exit: $api_exit)"
+ return 1
+ fi
+
+ # Check for API error response
+ if echo "$api_response" | grep -q "\"$API_ERROR_STRING\""; then
+ debug_log_file "ERROR: API returned error in response"
+
+ # Check specifically for error code 302 (invalid identity parameter)
+ # This indicates the overlay no longer exists - return special code for caller to handle
+ error_code=$(echo "$api_response" | jq -r '.error.code // empty' 2>/dev/null)
+ if [ "$error_code" = "302" ]; then
+ debug_log_file "ERROR: Invalid identity parameter (code 302) - overlay was likely deleted externally"
+ return 2
+ fi
+
+ return 1
+ fi
+
+ return 0
+}
+
+# Helper function to delete an overlay.
+# Currently not used, but we will add an automatic deletion of stale overlays
+# in the future, so we keep the function here.
+delete_overlay() {
+ overlay_identity=$1 # Overlay identity to delete
+
+ debug_log_file "Deleting overlay with identity: $overlay_identity"
+
+ json_payload=$(jq -n \
+ --arg context "$OVERLAY_CONTEXT" \
+ --arg identity "$overlay_identity" \
+ '{
+ apiVersion: "1.8",
+ method: "remove",
+ context: $context,
+ params: {
+ identity: ($identity|tonumber)
+ }
+ }')
+
+ debug_log_file "Sending JSON payload: $json_payload"
+ api_response=$(curl --silent --fail --digest --user "${VAPIX_USERNAME}:${VAPIX_PASSWORD}" \
+ "http://${VAPIX_IP}/axis-cgi/dynamicoverlay/dynamicoverlay.cgi" \
+ -X POST \
+ -H "Content-Type: application/json" \
+ -d "$json_payload" 2>&1)
+
+ api_exit=$?
+ debug_log_file "API call exit code: $api_exit"
+ debug_log_file "API response: $api_response"
+
+ if [ $api_exit -ne 0 ] || echo "$api_response" | grep -q "\"$API_ERROR_STRING\""; then
+ debug_log_file "ERROR: Failed to delete overlay"
+ return 1
+ fi
+
+ return 0
+}
+
+# Helper function to update or create overlay
+# If identity exists, update the existing overlay. Otherwise, create a new one.
+# If the update fails due to invalid identity (code 302), removes the identity file
+# and creates a new overlay since this means the overlay was deleted externally.
+update_or_create_overlay() {
+ text=$1 # Text content
+ x=$2 # X coordinate
+ y=$3 # Y coordinate
+
+ debug_log_file "Attempting to update or create overlay - Text: $text, Pos: ($x, $y)"
+
+ # Try to get stored identity
+ if overlay_identity=$(get_stored_identity); then
+ # Found stored identity, update the existing overlay
+ debug_log_file "Found stored identity: $overlay_identity, updating existing overlay"
+ update_overlay "$text" "$x" "$y" "$overlay_identity"
+ update_exit=$?
+
+ # Check if update failed due to invalid identity (return code 2), if so
+ # remove the identity file and create a new overlay.
+ if [ $update_exit -eq 2 ]; then
+ debug_log_file "Invalid identity detected (overlay was deleted), removing identity file and creating new overlay"
+ rm -f "$IDENTITY_FILE" 2>/dev/null || true
+ add_overlay "$text" "$x" "$y"
+ return $?
+ fi
+
+ return $update_exit
+ else
+ # No stored identity found, create a new overlay
+ debug_log_file "No stored identity found, creating new overlay"
+ add_overlay "$text" "$x" "$y"
+ return $?
+ fi
+}
+
+
+debug_log_file "Starting overlay_manager.sh script"
+debug_log_file "Environment variables - VAPIX_USERNAME: $VAPIX_USERNAME, VAPIX_IP: $VAPIX_IP, DEBUG: $DEBUG"
+
+# Read JSON input from Telegraf via stdin
+# Expected format:
+# {
+# "fields":{
+# "center_x":0.22100000000000009,
+# "center_y":0.5509999999999999,
+# "object_type":"Face",
+# "size":0.04262232000000001,
+# "time_in_area_seconds":211.09837293624878,
+# "timestamp":"2025-10-13T14:47:13.252519Z",
+# "track_id":"2923e6a2-920f-40a7-a7a8-f856b1a136cc"
+# },
+# "name":"overlay_frame",
+# "tags":{},
+# "timestamp":1760366877
+# }
+json_input=$(cat)
+
+debug_log_file "Received JSON input: $json_input"
+
+# Validate that we received input data
+if [ -z "$json_input" ]; then
+ error_exit 11 "Empty input received from Telegraf"
+fi
+
+# Extract required fields from JSON using jq
+track_id=$(echo "$json_input" | jq -r '.fields.track_id // empty')
+time_in_area=$(echo "$json_input" | jq -r '.fields.time_in_area_seconds // empty')
+object_type=$(echo "$json_input" | jq -r '.fields.object_type // empty')
+timestamp=$(echo "$json_input" | jq -r '.timestamp // empty')
+
+# Extract pre-calculated coordinates from Starlark processor
+center_x=$(echo "$json_input" | jq -r '.fields.center_x // empty')
+center_y=$(echo "$json_input" | jq -r '.fields.center_y // empty')
+
+debug_log_file "Extracted fields - track_id: $track_id, time_in_area_seconds: $time_in_area, object_type: $object_type"
+
+# Validate required fields. We allow object_type to be empty (null) since
+# this happens before the video object detection has been able to classify
+# the object.
+if [ -z "$track_id" ] || [ "$track_id" = "null" ] || \
+ [ -z "$time_in_area" ] || [ "$time_in_area" = "null" ] || \
+ [ -z "$timestamp" ] || [ "$timestamp" = "null" ]; then
+ error_exit 12 "Missing required track info fields in JSON input. Required: track_id, time_in_area_seconds, timestamp. Received: track_id='$track_id', time_in_area_seconds='$time_in_area', timestamp='$timestamp'"
+fi
+
+# Use pre-calculated coordinates
+if [ -z "$center_x" ] || [ "$center_x" = "null" ] || \
+ [ -z "$center_y" ] || [ "$center_y" = "null" ]; then
+ error_exit 12 "Missing required coordinate fields in JSON input. Required: center_x, center_y. Received: center_x='$center_x', center_y='$center_y'"
+fi
+
+# Get first 10 chars of track_id and add dots if it's longer than 13 chars.
+# This makes the overlay text look better if the IDs are very long.
+if [ ${#track_id} -gt 13 ]; then
+ short_track_id="$(echo "$track_id" | cut -c1-10)..."
+else
+ short_track_id=$track_id
+fi
+
+# Convert the time in fractions of seconds to more readable format
+time_in_area_whole_seconds=$(printf "%.0f" "$time_in_area")
+time_in_area_readable=$(date -u -d "@$time_in_area_whole_seconds" +%H:%M:%S)
+
+# Convert the detection timestamp (epoch time with no fractions) to
+# a more readable format
+timestamp_readable=$(date -u -d "@$timestamp" "+%Y-%m-%d %H:%M:%S")
+
+# Create overlay text with arrow pointing to the object
+# Each piece of information on its own line for better readability
+overlay_text="← ID: $short_track_id
+ Type: $object_type
+ Time in area: $time_in_area_readable
+ Last seen at: $timestamp_readable UTC"
+
+debug_log_file "Overlay text: $overlay_text"
+
+# Update or create overlay for current detection
+debug_log_file "Updating/creating overlay for track: $track_id"
+if update_or_create_overlay "$overlay_text" "$center_x" "$center_y"; then
+ debug_log_file "Overlay updated/created successfully for track: $track_id"
+else
+ error_exit 13 "Failed to update/create overlay for track $track_id"
+fi
+
+debug_log_file "Script completed successfully for track: $track_id"
diff --git a/project-time-in-area-analytics/test_files/config_input_overlay_test.conf b/project-time-in-area-analytics/test_files/config_input_overlay_test.conf
new file mode 100644
index 0000000..4706747
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/config_input_overlay_test.conf
@@ -0,0 +1,20 @@
+# Test Configuration for Overlay Input Only
+#
+# This configuration provides test data input for the overlay system.
+# It reads from a static file and outputs to the same overlay configuration
+# used in production, ensuring test and production use identical logic.
+
+[agent]
+ interval = "1s"
+ flush_interval = "1s"
+
+# Input: Single detection from test file
+[[inputs.file]]
+ files = ["${HELPER_FILES_DIR}/${SAMPLE_FILE}"]
+ data_format = "json"
+ name_override = "detection_frame"
+ # String fields that should be preserved as strings during JSON parsing
+ json_string_fields = ["timestamp", "track_id", "object_type", "frame"]
+
+# Output: Use the same overlay configuration as production
+# This ensures test and production use identical overlay logic
diff --git a/project-time-in-area-analytics/test_files/config_output_stdout.conf b/project-time-in-area-analytics/test_files/config_output_stdout.conf
new file mode 100644
index 0000000..ffbe648
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/config_output_stdout.conf
@@ -0,0 +1,11 @@
+# Standard Output Configuration for Testing
+#
+# This output configuration writes processed metrics to stdout in JSON format.
+# Useful for testing and debugging processor logic.
+
+[[outputs.file]]
+ # Output to stdout
+ files = ["stdout"]
+
+ # Use JSON format for readable output
+ data_format = "json"
\ No newline at end of file
diff --git a/project-time-in-area-analytics/test_files/real_device_data.jsonl b/project-time-in-area-analytics/test_files/real_device_data.jsonl
new file mode 100644
index 0000000..9366c9a
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/real_device_data.jsonl
@@ -0,0 +1,284 @@
+
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2767,"right":0.3225,"top":0.3575},"timestamp":"2025-08-10T20:51:50.454206Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:50.454206Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.454206Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2777,"right":0.3204,"top":0.3575},"timestamp":"2025-08-10T20:51:50.554104Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:50.554104Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.554104Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:50.654001Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:50.654001Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.654001Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:50.753898Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:50.753898Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.753898Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:50.853796Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3079,"top":0.7241},"timestamp":"2025-08-10T20:51:50.853796Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.853796Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:50.953694Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.309,"top":0.7241},"timestamp":"2025-08-10T20:51:50.953694Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:50.953694Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.053609Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3086,"top":0.7241},"timestamp":"2025-08-10T20:51:51.053609Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.053609Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.153489Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3076,"top":0.7241},"timestamp":"2025-08-10T20:51:51.153489Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.153489Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.253386Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.253386Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.253386Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.353284Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.353284Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.353284Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.453180Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.453180Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.453180Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.553079Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.553079Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.553079Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.652976Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.652976Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.652976Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.752874Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:51.752874Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.752874Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.852772Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3079,"top":0.7241},"timestamp":"2025-08-10T20:51:51.852772Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.852772Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:51.952669Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.309,"top":0.7241},"timestamp":"2025-08-10T20:51:51.952669Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:51.952669Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:52.052566Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3086,"top":0.7241},"timestamp":"2025-08-10T20:51:52.052566Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.052566Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.3197,"top":0.3575},"timestamp":"2025-08-10T20:51:52.152464Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3076,"top":0.7241},"timestamp":"2025-08-10T20:51:52.152464Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.152464Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.319,"top":0.3575},"timestamp":"2025-08-10T20:51:52.252362Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3079,"top":0.7241},"timestamp":"2025-08-10T20:51:52.252362Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.252362Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2781,"right":0.318,"top":0.3575},"timestamp":"2025-08-10T20:51:52.352258Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.309,"top":0.7241},"timestamp":"2025-08-10T20:51:52.352258Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.352258Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4377,"left":0.2781,"right":0.3176,"top":0.3575},"timestamp":"2025-08-10T20:51:52.452156Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3086,"top":0.7241},"timestamp":"2025-08-10T20:51:52.452156Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.452156Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4359,"left":0.2781,"right":0.3176,"top":0.3575},"timestamp":"2025-08-10T20:51:52.552054Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3076,"top":0.7241},"timestamp":"2025-08-10T20:51:52.552054Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.552054Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4341,"left":0.2774,"right":0.3183,"top":0.3588},"timestamp":"2025-08-10T20:51:52.651952Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:52.651952Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.651952Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4323,"left":0.2763,"right":0.3194,"top":0.3606},"timestamp":"2025-08-10T20:51:52.751850Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:52.751850Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.751850Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.276,"right":0.3197,"top":0.36},"timestamp":"2025-08-10T20:51:52.851747Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:52.851747Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.851747Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.276,"right":0.3197,"top":0.3582},"timestamp":"2025-08-10T20:51:52.951644Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:52.951644Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:52.951644Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.276,"right":0.3183,"top":0.3551},"timestamp":"2025-08-10T20:51:53.051542Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3086,"top":0.7241},"timestamp":"2025-08-10T20:51:53.051542Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.051542Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.276,"right":0.3162,"top":0.3513},"timestamp":"2025-08-10T20:51:53.151440Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3107,"top":0.7241},"timestamp":"2025-08-10T20:51:53.151440Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.151440Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.2746,"right":0.3169,"top":0.3501},"timestamp":"2025-08-10T20:51:53.251337Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.31,"top":0.7241},"timestamp":"2025-08-10T20:51:53.251337Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.251337Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.2725,"right":0.319,"top":0.3501},"timestamp":"2025-08-10T20:51:53.351235Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3079,"top":0.7241},"timestamp":"2025-08-10T20:51:53.351235Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.351235Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4316,"left":0.2719,"right":0.3169,"top":0.3477},"timestamp":"2025-08-10T20:51:53.451131Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3073,"top":0.7241},"timestamp":"2025-08-10T20:51:53.451131Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.451131Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4304,"left":0.2719,"right":0.3176,"top":0.3464},"timestamp":"2025-08-10T20:51:53.551030Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3079,"top":0.7241},"timestamp":"2025-08-10T20:51:53.551030Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.551030Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4285,"left":0.2719,"right":0.3207,"top":0.3464},"timestamp":"2025-08-10T20:51:53.650928Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.309,"top":0.7241},"timestamp":"2025-08-10T20:51:53.650928Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.650928Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4289,"left":0.2724,"right":0.3236,"top":0.3433},"timestamp":"2025-08-10T20:51:53.750825Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3099,"top":0.6977},"timestamp":"2025-08-10T20:51:53.750825Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.750825Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4306,"left":0.2733,"right":0.3262,"top":0.3385},"timestamp":"2025-08-10T20:51:53.850723Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8131,"left":0.2531,"right":0.3108,"top":0.658},"timestamp":"2025-08-10T20:51:53.850723Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.850723Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4403,"left":0.2729,"right":0.3294,"top":0.3322},"timestamp":"2025-08-10T20:51:53.950620Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8106,"left":0.2517,"right":0.3159,"top":0.6328},"timestamp":"2025-08-10T20:51:53.950620Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:53.950620Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4661,"left":0.2698,"right":0.3336,"top":0.323},"timestamp":"2025-08-10T20:51:54.050518Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.8032,"left":0.2475,"right":0.3295,"top":0.6366},"timestamp":"2025-08-10T20:51:54.050518Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:54.050518Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4846,"left":0.2677,"right":0.3371,"top":0.3143},"timestamp":"2025-08-10T20:51:54.150415Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7945,"left":0.252,"right":0.3479,"top":0.6353},"timestamp":"2025-08-10T20:51:54.150415Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:54.150415Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4884,"left":0.2677,"right":0.3392,"top":0.3069},"timestamp":"2025-08-10T20:51:54.250312Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7834,"left":0.2739,"right":0.376,"top":0.6242},"timestamp":"2025-08-10T20:51:54.250312Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"}],"operations":[],"timestamp":"2025-08-10T20:51:54.250312Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4933,"left":0.2691,"right":0.3433,"top":0.3008},"timestamp":"2025-08-10T20:51:54.350209Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.776,"left":0.2961,"right":0.3975,"top":0.6106},"timestamp":"2025-08-10T20:51:54.350209Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"},{"bounding_box":{"bottom":0.7414,"left":0.2368,"right":0.2802,"top":0.5797},"timestamp":"2025-08-10T20:51:54.350209Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.6495,"left":0.3492,"right":0.4354,"top":0.3976},"timestamp":"2025-08-10T20:51:54.350209Z","track_id":"590ff929-2964-42c4-a3cf-0e2d280604df"},{"bounding_box":{"bottom":0.9396,"left":0.4295,"right":0.4687,"top":0.7952},"timestamp":"2025-08-10T20:51:54.350209Z","track_id":"481e0d8b-f090-40d3-8e66-12a3600e27e4"}],"operations":[],"timestamp":"2025-08-10T20:51:54.350209Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.5007,"left":0.2732,"right":0.3516,"top":0.297},"timestamp":"2025-08-10T20:51:54.450108Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.776,"left":0.3191,"right":0.4058,"top":0.5921},"timestamp":"2025-08-10T20:51:54.450108Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"},{"bounding_box":{"bottom":0.7377,"left":0.2191,"right":0.2802,"top":0.5686},"timestamp":"2025-08-10T20:51:54.450108Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.6699,"left":0.3503,"right":0.4385,"top":0.4069},"timestamp":"2025-08-10T20:51:54.450108Z","track_id":"590ff929-2964-42c4-a3cf-0e2d280604df"},{"bounding_box":{"bottom":0.9415,"left":0.4274,"right":0.4718,"top":0.797},"timestamp":"2025-08-10T20:51:54.450108Z","track_id":"481e0d8b-f090-40d3-8e66-12a3600e27e4"}],"operations":[],"timestamp":"2025-08-10T20:51:54.450108Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.5056,"left":0.2774,"right":0.3593,"top":0.2946},"timestamp":"2025-08-10T20:51:54.550004Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.776,"left":0.335,"right":0.4114,"top":0.5797},"timestamp":"2025-08-10T20:51:54.550004Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"},{"bounding_box":{"bottom":0.7365,"left":0.2073,"right":0.2871,"top":0.5612},"timestamp":"2025-08-10T20:51:54.550004Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.6835,"left":0.3499,"right":0.4416,"top":0.4057},"timestamp":"2025-08-10T20:51:54.550004Z","track_id":"590ff929-2964-42c4-a3cf-0e2d280604df"}],"operations":[],"timestamp":"2025-08-10T20:51:54.550004Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.5056,"left":0.2815,"right":0.3656,"top":0.2946},"timestamp":"2025-08-10T20:51:54.649903Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.776,"left":0.3371,"right":0.4114,"top":0.5797},"timestamp":"2025-08-10T20:51:54.649903Z","track_id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c"},{"bounding_box":{"bottom":0.7402,"left":0.2073,"right":0.3079,"top":0.5612},"timestamp":"2025-08-10T20:51:54.649903Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.6835,"left":0.3468,"right":0.4447,"top":0.3835},"timestamp":"2025-08-10T20:51:54.649903Z","track_id":"590ff929-2964-42c4-a3cf-0e2d280604df"}],"operations":[],"timestamp":"2025-08-10T20:51:54.649903Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9531,"left":0.2246,"right":0.4756,"top":0.2942},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.2}],"score":0.77,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.25}]},"timestamp":"2025-08-10T20:51:54.749800Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[{"id":"481e0d8b-f090-40d3-8e66-12a3600e27e4","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:51:54.749800Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9552,"left":0.2293,"right":0.4769,"top":0.2995},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.2}],"score":0.78,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.24}]},"timestamp":"2025-08-10T20:51:54.849698Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[],"timestamp":"2025-08-10T20:51:54.849698Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9622,"left":0.2353,"right":0.4838,"top":0.3078},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.19}],"score":0.78,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.23}]},"timestamp":"2025-08-10T20:51:54.949595Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[],"timestamp":"2025-08-10T20:51:54.949595Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9635,"left":0.2381,"right":0.489,"top":0.3093},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.19}],"score":0.79,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.049493Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[],"timestamp":"2025-08-10T20:51:55.049493Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9527,"left":0.2423,"right":0.4916,"top":0.3071},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.19}],"score":0.79,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.149390Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4445,"left":0.3258,"right":0.3939,"top":0.2834},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:51:55.149390Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.149390Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9391,"left":0.2488,"right":0.4953,"top":0.3041},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.8,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.249288Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4471,"left":0.3343,"right":0.4033,"top":0.2823},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:51:55.249288Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.5612,"left":0.228,"right":0.2551,"top":0.4983},"timestamp":"2025-08-10T20:51:55.249288Z","track_id":"dd5091b2-ce28-4d88-aac3-1ef81a2f4512"}],"operations":[],"timestamp":"2025-08-10T20:51:55.249288Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9349,"left":0.2544,"right":0.4999,"top":0.3025},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.8,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.349186Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4512,"left":0.3419,"right":0.411,"top":0.2823},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:51:55.349186Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.349186Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9386,"left":0.2583,"right":0.5012,"top":0.2976},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.17}],"score":0.8,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.449083Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4538,"left":0.3497,"right":0.4184,"top":0.2831},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:51:55.449083Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.449083Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9419,"left":0.2652,"right":0.5045,"top":0.2959},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.17}],"score":0.81,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.548980Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4564,"left":0.3553,"right":0.4242,"top":0.2853},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:51:55.548980Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.548980Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.946,"left":0.2688,"right":0.5035,"top":0.2925},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.17}],"score":0.81,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.648879Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4581,"left":0.3583,"right":0.429,"top":0.2867},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:51:55.648879Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.648879Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9524,"left":0.2718,"right":0.501,"top":0.2891},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.82,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:55.748776Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4589,"left":0.3612,"right":0.4326,"top":0.289},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:51:55.748776Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.748776Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9588,"left":0.2733,"right":0.5015,"top":0.2866},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.82,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.22}]},"timestamp":"2025-08-10T20:51:55.848673Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4597,"left":0.3621,"right":0.4342,"top":0.2903},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:51:55.848673Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[{"id":"dd5091b2-ce28-4d88-aac3-1ef81a2f4512","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:51:55.848673Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9603,"left":0.2749,"right":0.5007,"top":0.2834},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.82,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:55.948571Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4604,"left":0.3629,"right":0.4352,"top":0.2912},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:51:55.948571Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:55.948571Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9647,"left":0.2744,"right":0.4983,"top":0.2808},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.83,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.048470Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4612,"left":0.3613,"right":0.4342,"top":0.2917},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.048470Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.048470Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9703,"left":0.2743,"right":0.497,"top":0.2807},"class":{"lower_clothing_colors":[{"name":"Beige","score":0.18}],"score":0.83,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.148366Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.46,"left":0.3601,"right":0.4326,"top":0.2916},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.148366Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.148366Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9744,"left":0.2736,"right":0.4951,"top":0.2759},"class":{"lower_clothing_colors":[{"name":"White","score":0.18}],"score":0.83,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.248264Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4607,"left":0.3582,"right":0.4311,"top":0.2919},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.248264Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.248264Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9799,"left":0.2736,"right":0.4929,"top":0.2745},"class":{"lower_clothing_colors":[{"name":"White","score":0.19}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.348162Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4616,"left":0.3563,"right":0.4291,"top":0.2913},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.348162Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.348162Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9881,"left":0.2734,"right":0.4942,"top":0.2717},"class":{"lower_clothing_colors":[{"name":"White","score":0.2}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.448059Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4622,"left":0.3549,"right":0.4267,"top":0.2907},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.448059Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.448059Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9935,"left":0.2693,"right":0.4927,"top":0.2716},"class":{"lower_clothing_colors":[{"name":"White","score":0.2}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.547955Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4631,"left":0.3534,"right":0.4246,"top":0.2898},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.547955Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.547955Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9976,"left":0.266,"right":0.4917,"top":0.2692},"class":{"lower_clothing_colors":[{"name":"White","score":0.21}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.647857Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4636,"left":0.3515,"right":0.4223,"top":0.2877},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.647857Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.647857Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2643,"right":0.4919,"top":0.2673},"class":{"lower_clothing_colors":[{"name":"White","score":0.21}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.747752Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4613,"left":0.3495,"right":0.4204,"top":0.2849},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.747752Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.747752Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2629,"right":0.4923,"top":0.2681},"class":{"lower_clothing_colors":[{"name":"White","score":0.21}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.847650Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4618,"left":0.3464,"right":0.4179,"top":0.2839},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.847650Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.847650Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2605,"right":0.4928,"top":0.2646},"class":{"lower_clothing_colors":[{"name":"White","score":0.21}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:56.947547Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4626,"left":0.3441,"right":0.416,"top":0.2828},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:56.947547Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:56.947547Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2586,"right":0.492,"top":0.2681},"class":{"lower_clothing_colors":[{"name":"White","score":0.21}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:57.047445Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4633,"left":0.3417,"right":0.414,"top":0.2819},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.047445Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.047445Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2579,"right":0.4909,"top":0.2692},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:57.147342Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4642,"left":0.34,"right":0.4127,"top":0.2825},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.147342Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.147342Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2576,"right":0.4916,"top":0.2679},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:57.247240Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4644,"left":0.3385,"right":0.4115,"top":0.2821},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.247240Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.247240Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2568,"right":0.4915,"top":0.2693},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.21}]},"timestamp":"2025-08-10T20:51:57.347137Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4645,"left":0.3379,"right":0.411,"top":0.2819},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.347137Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.347137Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2577,"right":0.493,"top":0.2706},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"White","score":0.2}]},"timestamp":"2025-08-10T20:51:57.447035Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4646,"left":0.3376,"right":0.4109,"top":0.2817},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.447035Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.447035Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2578,"right":0.4921,"top":0.2694},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:57.546933Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.465,"left":0.3375,"right":0.411,"top":0.2813},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.546933Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.546933Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2572,"right":0.4909,"top":0.2699},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.21}]},"timestamp":"2025-08-10T20:51:57.646832Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4649,"left":0.3376,"right":0.4112,"top":0.2813},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.646832Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.646832Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2568,"right":0.4912,"top":0.2703},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.86,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.21}]},"timestamp":"2025-08-10T20:51:57.746726Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4655,"left":0.3379,"right":0.4115,"top":0.2823},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.746726Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.746726Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9983,"left":0.2574,"right":0.4921,"top":0.2736},"class":{"lower_clothing_colors":[{"name":"White","score":0.22}],"score":0.86,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.21}]},"timestamp":"2025-08-10T20:51:57.846627Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.465,"left":0.3387,"right":0.4118,"top":0.283},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.846627Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:57.846627Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9093,"left":0.3197,"right":0.4843,"top":0.2909},"timestamp":"2025-08-10T20:51:57.946521Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4655,"left":0.3386,"right":0.4118,"top":0.2838},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:57.946521Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3197,"top":0.5316},"timestamp":"2025-08-10T20:51:57.946521Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.7352,"left":0.3176,"right":0.328,"top":0.7094},"timestamp":"2025-08-10T20:51:57.946521Z","track_id":"591486b3-f329-4082-a977-2a167fb2ebcd"}],"operations":[],"timestamp":"2025-08-10T20:51:57.946521Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9093,"left":0.3197,"right":0.4843,"top":0.2909},"timestamp":"2025-08-10T20:51:58.046420Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4658,"left":0.3383,"right":0.412,"top":0.2845},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.046420Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3197,"top":0.5316},"timestamp":"2025-08-10T20:51:58.046420Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[{"id":"591486b3-f329-4082-a977-2a167fb2ebcd","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:51:58.046420Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9093,"left":0.3197,"right":0.4843,"top":0.2909},"timestamp":"2025-08-10T20:51:58.146317Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4654,"left":0.3391,"right":0.4115,"top":0.2841},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.146317Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3197,"top":0.5316},"timestamp":"2025-08-10T20:51:58.146317Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:51:58.146317Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9093,"left":0.3197,"right":0.4843,"top":0.2909},"timestamp":"2025-08-10T20:51:58.246214Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4656,"left":0.3372,"right":0.4108,"top":0.2847},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.246214Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.319,"top":0.5316},"timestamp":"2025-08-10T20:51:58.246214Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:51:58.246214Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9093,"left":0.3197,"right":0.4843,"top":0.2909},"timestamp":"2025-08-10T20:51:58.346111Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4652,"left":0.3344,"right":0.4075,"top":0.2843},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.346111Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3169,"top":0.5316},"timestamp":"2025-08-10T20:51:58.346111Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:51:58.346111Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9081,"left":0.3187,"right":0.4832,"top":0.2921},"timestamp":"2025-08-10T20:51:58.446011Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4637,"left":0.3316,"right":0.404,"top":0.2871},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.446011Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3149,"top":0.5316},"timestamp":"2025-08-10T20:51:58.446011Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:51:58.446011Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9044,"left":0.3156,"right":0.4801,"top":0.2958},"timestamp":"2025-08-10T20:51:58.545908Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.462,"left":0.3268,"right":0.3983,"top":0.2877},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.545908Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.226,"right":0.3128,"top":0.5316},"timestamp":"2025-08-10T20:51:58.545908Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:51:58.545908Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9758,"left":0.2559,"right":0.4845,"top":0.3048},"class":{"lower_clothing_colors":[{"name":"White","score":0.2}],"score":0.87,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.21}]},"timestamp":"2025-08-10T20:51:58.645805Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4605,"left":0.3191,"right":0.3892,"top":0.2883},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.645805Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:58.645805Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9731,"left":0.25,"right":0.4804,"top":0.311},"class":{"lower_clothing_colors":[{"name":"White","score":0.19}],"score":0.87,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:58.745702Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4602,"left":0.3078,"right":0.3771,"top":0.2916},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.745702Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:58.745702Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9687,"left":0.2422,"right":0.4768,"top":0.3144},"class":{"lower_clothing_colors":[{"name":"White","score":0.19}],"score":0.86,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:58.845603Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.461,"left":0.294,"right":0.3627,"top":0.2958},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.845603Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[{"id":"590ff929-2964-42c4-a3cf-0e2d280604df","type":"DeleteOperation"},{"id":"d6b54a80-dbd1-41fe-a470-5ccb4baef76c","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:51:58.845603Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9648,"left":0.233,"right":0.4776,"top":0.3179},"class":{"lower_clothing_colors":[{"name":"White","score":0.18}],"score":0.86,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:58.945498Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4654,"left":0.2771,"right":0.3456,"top":0.3018},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:58.945498Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:58.945498Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9598,"left":0.2259,"right":0.4754,"top":0.3252},"class":{"lower_clothing_colors":[{"name":"White","score":0.17}],"score":0.85,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:59.045396Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4718,"left":0.2576,"right":0.326,"top":0.3101},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:59.045396Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.045396Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9575,"left":0.2184,"right":0.4706,"top":0.336},"class":{"lower_clothing_colors":[{"name":"White","score":0.17}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:59.145293Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.48,"left":0.2368,"right":0.3039,"top":0.3194},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:59.145293Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.145293Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9554,"left":0.2095,"right":0.4648,"top":0.3504},"class":{"lower_clothing_colors":[{"name":"White","score":0.17}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:59.245191Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.4954,"left":0.2114,"right":0.2813,"top":0.3336},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:51:59.245191Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.245191Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9575,"left":0.1925,"right":0.4596,"top":0.3674},"class":{"lower_clothing_colors":[{"name":"White","score":0.16}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.2}]},"timestamp":"2025-08-10T20:51:59.345088Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.5164,"left":0.1875,"right":0.2566,"top":0.357},"class":{"score":0.83,"type":"Face"},"timestamp":"2025-08-10T20:51:59.345088Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.345088Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9579,"left":0.1731,"right":0.458,"top":0.3819},"class":{"lower_clothing_colors":[{"name":"White","score":0.15}],"score":0.84,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.19}]},"timestamp":"2025-08-10T20:51:59.444985Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.5402,"left":0.1647,"right":0.2323,"top":0.3868},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:51:59.444985Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.444985Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.951,"left":0.1554,"right":0.4589,"top":0.3838},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.15}],"score":0.82,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.19}]},"timestamp":"2025-08-10T20:51:59.544882Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.5664,"left":0.145,"right":0.2121,"top":0.4182},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:51:59.544882Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.544882Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9519,"left":0.1393,"right":0.457,"top":0.3982},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.15}],"score":0.82,"type":"Human","upper_clothing_colors":[{"name":"Blue","score":0.19}]},"timestamp":"2025-08-10T20:51:59.644781Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.592,"left":0.1286,"right":0.1954,"top":0.4496},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:51:59.644781Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.644781Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9631,"left":0.1183,"right":0.4565,"top":0.43},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.15}],"score":0.8,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.19}]},"timestamp":"2025-08-10T20:51:59.744677Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.614,"left":0.1166,"right":0.1834,"top":0.4776},"class":{"score":0.81,"type":"Face"},"timestamp":"2025-08-10T20:51:59.744677Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.744677Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9676,"left":0.1038,"right":0.4481,"top":0.4596},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.15}],"score":0.79,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.18}]},"timestamp":"2025-08-10T20:51:59.844576Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.6319,"left":0.1074,"right":0.1751,"top":0.5002},"class":{"score":0.8,"type":"Face"},"timestamp":"2025-08-10T20:51:59.844576Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.844576Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9584,"left":0.0976,"right":0.4504,"top":0.455},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.15}],"score":0.78,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.18}]},"timestamp":"2025-08-10T20:51:59.944473Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.6468,"left":0.1026,"right":0.1703,"top":0.5187},"class":{"score":0.79,"type":"Face"},"timestamp":"2025-08-10T20:51:59.944473Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:51:59.944473Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9502,"left":0.0758,"right":0.4453,"top":0.4516},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.14}],"score":0.77,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.19}]},"timestamp":"2025-08-10T20:52:00.044370Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[],"timestamp":"2025-08-10T20:52:00.044370Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.952,"left":0.0729,"right":0.4412,"top":0.4773},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.14}],"score":0.75,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.19}]},"timestamp":"2025-08-10T20:52:00.144268Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"},{"bounding_box":{"bottom":0.9007,"left":0.435,"right":0.4614,"top":0.7908},"timestamp":"2025-08-10T20:52:00.144268Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:00.144268Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.9365,"left":0.0706,"right":0.4474,"top":0.4615},"class":{"lower_clothing_colors":[{"name":"Blue","score":0.14}],"score":0.75,"type":"Human","upper_clothing_colors":[{"name":"Black","score":0.19}]},"timestamp":"2025-08-10T20:52:00.244166Z","track_id":"3a1817d7-66ea-4224-9f3f-54f62e89c2d0"}],"operations":[],"timestamp":"2025-08-10T20:52:00.244166Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6908,"left":0.1072,"right":0.2218,"top":0.5267},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2406,"right":0.3073,"top":0.6576},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9155,"left":0.4316,"right":0.46,"top":0.7822},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5526,"left":0.2308,"right":0.2558,"top":0.5242},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7649,"left":0.3135,"right":0.3899,"top":0.6649},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9464,"left":0.3989,"right":0.4309,"top":0.8427},"timestamp":"2025-08-10T20:52:00.344063Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"}],"operations":[],"timestamp":"2025-08-10T20:52:00.344063Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6908,"left":0.1034,"right":0.2215,"top":0.5353},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2406,"right":0.3121,"top":0.6514},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9236,"left":0.4302,"right":0.4594,"top":0.7792},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5606,"left":0.2277,"right":0.2589,"top":0.5254},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7612,"left":0.309,"right":0.386,"top":0.6649},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9495,"left":0.3989,"right":0.4326,"top":0.8445},"timestamp":"2025-08-10T20:52:00.443961Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"}],"operations":[],"timestamp":"2025-08-10T20:52:00.443961Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6908,"left":0.0982,"right":0.2142,"top":0.5464},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2406,"right":0.3142,"top":0.655},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9218,"left":0.4302,"right":0.4594,"top":0.7773},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5699,"left":0.2266,"right":0.2579,"top":0.5291},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7612,"left":0.3079,"right":0.385,"top":0.6649},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9588,"left":0.3989,"right":0.4336,"top":0.8501},"timestamp":"2025-08-10T20:52:00.543859Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"}],"operations":[],"timestamp":"2025-08-10T20:52:00.543859Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6914,"left":0.093,"right":0.2111,"top":0.5557},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2406,"right":0.3156,"top":0.6563},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4597,"top":0.7779},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5785,"left":0.2253,"right":0.2575,"top":0.5341},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7625,"left":0.3069,"right":0.3839,"top":0.6649},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9674,"left":0.3996,"right":0.4357,"top":0.8544},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6069,"left":0.2843,"right":0.2971,"top":0.5693},"timestamp":"2025-08-10T20:52:00.643757Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:00.643757Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6933,"left":0.0878,"right":0.2163,"top":0.5612},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2406,"right":0.3156,"top":0.6526},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4607,"top":0.7834},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5859,"left":0.2232,"right":0.2586,"top":0.5415},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7662,"left":0.3059,"right":0.3829,"top":0.6649},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9748,"left":0.4017,"right":0.4399,"top":0.8563},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6217,"left":0.2843,"right":0.2982,"top":0.5822},"timestamp":"2025-08-10T20:52:00.743654Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:00.743654Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6958,"left":0.0837,"right":0.2177,"top":0.5662},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7452,"left":0.2406,"right":0.3159,"top":0.6501},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4621,"top":0.7853},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5915,"left":0.2218,"right":0.2593,"top":0.5476},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7693,"left":0.3027,"right":0.3805,"top":0.6649},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4031,"right":0.443,"top":0.8563},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6328,"left":0.2843,"right":0.299,"top":0.5908},"timestamp":"2025-08-10T20:52:00.843552Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:00.843552Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6995,"left":0.0816,"right":0.2114,"top":0.5698},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7526,"left":0.2406,"right":0.3169,"top":0.6501},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4642,"top":0.7797},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5933,"left":0.2218,"right":0.2593,"top":0.5514},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7711,"left":0.2954,"right":0.3753,"top":0.6649},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4031,"right":0.444,"top":0.8526},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6366,"left":0.2843,"right":0.299,"top":0.5908},"timestamp":"2025-08-10T20:52:00.943449Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:00.943449Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7031,"left":0.0802,"right":0.2073,"top":0.5723},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4659,"top":0.776},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.594,"left":0.2218,"right":0.2593,"top":0.5544},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2902,"right":0.3715,"top":0.6649},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4058,"right":0.445,"top":0.8507},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2843,"right":0.299,"top":0.5921},"timestamp":"2025-08-10T20:52:01.043346Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.043346Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7069,"left":0.0802,"right":0.2073,"top":0.5723},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.467,"top":0.776},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5921,"left":0.2218,"right":0.2593,"top":0.5563},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2891,"right":0.3704,"top":0.6649},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4141,"right":0.4461,"top":0.8525},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2843,"right":0.299,"top":0.5958},"timestamp":"2025-08-10T20:52:01.143243Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.143243Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7094,"left":0.0798,"right":0.2069,"top":0.5735},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4677,"top":0.7779},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5575},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2881,"right":0.3694,"top":0.6649},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4186,"right":0.4461,"top":0.8531},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.284,"right":0.299,"top":0.5983},"timestamp":"2025-08-10T20:52:01.243141Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.243141Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7094,"left":0.0787,"right":0.2059,"top":0.5772},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4302,"right":0.4677,"top":0.7834},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5575},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2871,"right":0.3684,"top":0.6649},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4155,"right":0.444,"top":0.8513},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2829,"right":0.299,"top":0.5983},"timestamp":"2025-08-10T20:52:01.343039Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.343039Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7094,"left":0.0777,"right":0.2055,"top":0.5797},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4305,"right":0.4683,"top":0.7871},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5575},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4135,"right":0.4426,"top":0.8501},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2822,"right":0.299,"top":0.5983},"timestamp":"2025-08-10T20:52:01.442938Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.442938Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7094,"left":0.0766,"right":0.2066,"top":0.5797},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9205,"left":0.4316,"right":0.4704,"top":0.7871},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5575},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4135,"right":0.4426,"top":0.8501},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2822,"right":0.299,"top":0.5983},"timestamp":"2025-08-10T20:52:01.542834Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"}],"operations":[],"timestamp":"2025-08-10T20:52:01.542834Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7081,"left":0.0763,"right":0.2069,"top":0.5797},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9187,"left":0.4319,"right":0.4718,"top":0.7853},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5581},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4141,"right":0.4433,"top":0.8501},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2822,"right":0.299,"top":0.5989},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"},{"bounding_box":{"bottom":0.984,"left":0.4656,"right":0.4916,"top":0.9495},"timestamp":"2025-08-10T20:52:01.642732Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:01.642732Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7044,"left":0.0773,"right":0.2059,"top":0.5797},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.913,"left":0.4309,"right":0.4718,"top":0.7797},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5908,"left":0.2218,"right":0.2593,"top":0.5599},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4162,"right":0.4454,"top":0.8501},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.639,"left":0.2822,"right":0.299,"top":0.6007},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"},{"bounding_box":{"bottom":0.9859,"left":0.4656,"right":0.4948,"top":0.9476},"timestamp":"2025-08-10T20:52:01.742629Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:01.742629Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.7007,"left":0.0794,"right":0.2059,"top":0.576},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9093,"left":0.4312,"right":0.4715,"top":0.7816},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5896,"left":0.2239,"right":0.2593,"top":0.5612},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4176,"right":0.4468,"top":0.8532},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6341,"left":0.2826,"right":0.2986,"top":0.6007},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"},{"bounding_box":{"bottom":0.989,"left":0.4659,"right":0.4986,"top":0.9464},"timestamp":"2025-08-10T20:52:01.842526Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:01.842526Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.697,"left":0.0836,"right":0.208,"top":0.5649},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9093,"left":0.4343,"right":0.4704,"top":0.7982},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5859,"left":0.2301,"right":0.2593,"top":0.5612},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7723,"left":0.2864,"right":0.3677,"top":0.6649},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4176,"right":0.4468,"top":0.8625},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.6193,"left":0.2836,"right":0.2975,"top":0.597},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"c93329eb-5bb1-4a4a-9f2b-297583983fde"},{"bounding_box":{"bottom":0.9946,"left":0.467,"right":0.5037,"top":0.9464},"timestamp":"2025-08-10T20:52:01.942424Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:01.942424Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6933,"left":0.0902,"right":0.2097,"top":0.5476},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9093,"left":0.4357,"right":0.4701,"top":0.8063},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7717,"left":0.2864,"right":0.3677,"top":0.6637},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4169,"right":0.4468,"top":0.8693},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4673,"right":0.5069,"top":0.9489},"timestamp":"2025-08-10T20:52:02.042322Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.042322Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6896,"left":0.1016,"right":0.2107,"top":0.518},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7576,"left":0.2406,"right":0.3176,"top":0.6501},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9093,"left":0.4336,"right":0.4711,"top":0.797},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7699,"left":0.2864,"right":0.3677,"top":0.66},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9797,"left":0.4148,"right":0.4468,"top":0.8711},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4663,"right":0.5058,"top":0.9563},"timestamp":"2025-08-10T20:52:02.142218Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.142218Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6834,"left":0.1141,"right":0.2128,"top":0.4884},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7551,"left":0.2406,"right":0.3138,"top":0.6563},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9081,"left":0.4316,"right":0.4735,"top":0.7866},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7643,"left":0.2909,"right":0.3701,"top":0.6501},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.968,"left":0.4135,"right":0.4457,"top":0.8692},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4649,"right":0.4996,"top":0.9643},"timestamp":"2025-08-10T20:52:02.242117Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.242117Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6723,"left":0.1287,"right":0.217,"top":0.4587},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7476,"left":0.2406,"right":0.3023,"top":0.6748},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9044,"left":0.4295,"right":0.4787,"top":0.7736},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7513,"left":0.3044,"right":0.3774,"top":0.6278},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9328,"left":0.4135,"right":0.4426,"top":0.8599},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4628,"right":0.4829,"top":0.9736},"timestamp":"2025-08-10T20:52:02.342014Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.342014Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6563,"left":0.1457,"right":0.2249,"top":0.4316},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7414,"left":0.2406,"right":0.2933,"top":0.6847},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9051,"left":0.4278,"right":0.4836,"top":0.763},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7458,"left":0.3166,"right":0.3902,"top":0.6038},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4131,"right":0.4416,"top":0.8538},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9964,"left":0.4611,"right":0.4735,"top":0.9748},"timestamp":"2025-08-10T20:52:02.441912Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[{"id":"c93329eb-5bb1-4a4a-9f2b-297583983fde","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:02.441912Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.6303,"left":0.1676,"right":0.2406,"top":0.4093},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7377,"left":0.2406,"right":0.2891,"top":0.6773},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9143,"left":0.4267,"right":0.4878,"top":0.7575},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.755,"left":0.326,"right":0.4142,"top":0.576},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4121,"right":0.4447,"top":0.8538},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9909,"left":0.46,"right":0.4787,"top":0.96},"timestamp":"2025-08-10T20:52:02.541810Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.541810Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.5252,"left":0.1784,"right":0.2494,"top":0.3652},"class":{"score":0.51,"type":"Face"},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7352,"left":0.2406,"right":0.2826,"top":0.6748},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9199,"left":0.426,"right":0.4927,"top":0.7495},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5927,"left":0.2434,"right":0.2496,"top":0.5599},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7594,"left":0.3368,"right":0.4333,"top":0.5519},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4124,"right":0.4468,"top":0.8538},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.989,"left":0.4604,"right":0.4832,"top":0.9482},"timestamp":"2025-08-10T20:52:02.641708Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.641708Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4987,"left":0.2028,"right":0.2726,"top":0.3362},"class":{"score":0.53,"type":"Face"},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7352,"left":0.2406,"right":0.2711,"top":0.6822},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.918,"left":0.426,"right":0.4989,"top":0.7365},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5982,"left":0.2454,"right":0.2517,"top":0.5563},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.7538,"left":0.3503,"right":0.4426,"top":0.5353},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4155,"right":0.4468,"top":0.8538},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9946,"left":0.4635,"right":0.4864,"top":0.9427},"timestamp":"2025-08-10T20:52:02.741604Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.741604Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4758,"left":0.2264,"right":0.2955,"top":0.313},"class":{"score":0.55,"type":"Face"},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7365,"left":0.2409,"right":0.2662,"top":0.6803},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9161,"left":0.426,"right":0.5031,"top":0.7273},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7538,"left":0.3558,"right":0.4516,"top":0.518},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4173,"right":0.4468,"top":0.8538},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4663,"right":0.4892,"top":0.939},"timestamp":"2025-08-10T20:52:02.841504Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.841504Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4599,"left":0.2485,"right":0.3164,"top":0.2944},"class":{"score":0.57,"type":"Face"},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7402,"left":0.242,"right":0.2746,"top":0.66},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9143,"left":0.426,"right":0.5031,"top":0.7254},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7649,"left":0.3454,"right":0.46,"top":0.4995},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9093,"left":0.4162,"right":0.4468,"top":0.8538},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"40911fb9-6226-4481-8eb9-8696bea5dfd5"},{"bounding_box":{"bottom":0.9983,"left":0.4683,"right":0.4913,"top":0.939},"timestamp":"2025-08-10T20:52:02.941399Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:02.941399Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4484,"left":0.2708,"right":0.3383,"top":0.2811},"class":{"score":0.58,"type":"Face"},"timestamp":"2025-08-10T20:52:03.041298Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.243,"right":0.2812,"top":0.6464},"timestamp":"2025-08-10T20:52:03.041298Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4236,"right":0.5027,"top":0.7241},"timestamp":"2025-08-10T20:52:03.041298Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7729,"left":0.3406,"right":0.4656,"top":0.4816},"timestamp":"2025-08-10T20:52:03.041298Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9983,"left":0.4701,"right":0.493,"top":0.939},"timestamp":"2025-08-10T20:52:03.041298Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:03.041298Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.439,"left":0.2901,"right":0.3582,"top":0.2715},"class":{"score":0.6,"type":"Face"},"timestamp":"2025-08-10T20:52:03.141195Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7427,"left":0.2441,"right":0.2843,"top":0.6464},"timestamp":"2025-08-10T20:52:03.141195Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4163,"right":0.5017,"top":0.7241},"timestamp":"2025-08-10T20:52:03.141195Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.7748,"left":0.3468,"right":0.4656,"top":0.465},"timestamp":"2025-08-10T20:52:03.141195Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9983,"left":0.4711,"right":0.4941,"top":0.939},"timestamp":"2025-08-10T20:52:03.141195Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"}],"operations":[],"timestamp":"2025-08-10T20:52:03.141195Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4325,"left":0.3054,"right":0.3742,"top":0.264},"class":{"score":0.61,"type":"Face"},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7365,"left":0.2454,"right":0.2881,"top":0.647},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4117,"right":0.5013,"top":0.7241},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5989,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.776,"left":0.3503,"right":0.4656,"top":0.4539},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9958,"left":0.4721,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5118,"left":0.3169,"right":0.4065,"top":0.4081},"timestamp":"2025-08-10T20:52:03.241097Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:03.241097Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4295,"left":0.3181,"right":0.3866,"top":0.2618},"class":{"score":0.63,"type":"Face"},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.718,"left":0.2475,"right":0.2933,"top":0.6489},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4128,"right":0.5024,"top":0.7241},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6007,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.776,"left":0.3482,"right":0.4656,"top":0.4539},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9884,"left":0.4732,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5081,"left":0.3149,"right":0.4045,"top":0.4044},"timestamp":"2025-08-10T20:52:03.340990Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:03.340990Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4298,"left":0.3288,"right":0.3967,"top":0.2612},"class":{"score":0.64,"type":"Face"},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7106,"left":0.2489,"right":0.2971,"top":0.6495},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4141,"right":0.5037,"top":0.7241},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.776,"left":0.3468,"right":0.4663,"top":0.4539},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5223,"left":0.3124,"right":0.4145,"top":0.4007},"timestamp":"2025-08-10T20:52:03.440889Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:03.440889Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4312,"left":0.3361,"right":0.4051,"top":0.2615},"class":{"score":0.66,"type":"Face"},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7254,"left":0.2489,"right":0.2982,"top":0.6476},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4162,"right":0.5058,"top":0.7241},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.776,"left":0.3468,"right":0.4683,"top":0.4539},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5723,"left":0.3093,"right":0.4489,"top":0.397},"timestamp":"2025-08-10T20:52:03.540784Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:03.540784Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4328,"left":0.341,"right":0.4112,"top":0.2632},"class":{"score":0.67,"type":"Face"},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7328,"left":0.2489,"right":0.299,"top":0.6458},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.6045,"left":0.3079,"right":0.4711,"top":0.3896},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2843,"right":0.3038,"top":0.4551},"timestamp":"2025-08-10T20:52:03.640689Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[{"id":"7289aaf6-6ca7-400b-ac8b-be5a7ac5f158","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:03.640689Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4348,"left":0.3449,"right":0.4147,"top":0.2653},"class":{"score":0.68,"type":"Face"},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7254,"left":0.2489,"right":0.299,"top":0.6439},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.6007,"left":0.31,"right":0.469,"top":0.3748},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2843,"right":0.2996,"top":0.4587},"timestamp":"2025-08-10T20:52:03.740579Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:03.740579Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4374,"left":0.3468,"right":0.416,"top":0.2681},"class":{"score":0.69,"type":"Face"},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.2489,"right":0.299,"top":0.6433},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5946,"left":0.334,"right":0.4677,"top":0.3785},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2846,"right":0.2968,"top":0.463},"timestamp":"2025-08-10T20:52:03.840479Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:03.840479Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4402,"left":0.347,"right":0.4156,"top":0.2712},"class":{"score":0.7,"type":"Face"},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.2489,"right":0.299,"top":0.6451},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5835,"left":0.4017,"right":0.4677,"top":0.4192},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2857,"right":0.2968,"top":0.4686},"timestamp":"2025-08-10T20:52:03.940374Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:03.940374Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4425,"left":0.3456,"right":0.414,"top":0.2739},"class":{"score":0.71,"type":"Face"},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.2492,"right":0.299,"top":0.6464},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5773,"left":0.4461,"right":0.4677,"top":0.4452},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2864,"right":0.2968,"top":0.4723},"timestamp":"2025-08-10T20:52:04.040275Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:04.040275Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4453,"left":0.3439,"right":0.4118,"top":0.2771},"class":{"score":0.72,"type":"Face"},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.2503,"right":0.299,"top":0.6464},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5809,"left":0.444,"right":0.4677,"top":0.4414},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2864,"right":0.2968,"top":0.4723},"timestamp":"2025-08-10T20:52:04.140170Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:04.140170Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4473,"left":0.3417,"right":0.4093,"top":0.2796},"class":{"score":0.73,"type":"Face"},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.299,"top":0.6464},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4399,"right":0.467,"top":0.439},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2864,"right":0.2968,"top":0.4723},"timestamp":"2025-08-10T20:52:04.240069Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:04.240069Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4496,"left":0.3395,"right":0.4067,"top":0.2823},"class":{"score":0.73,"type":"Face"},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.299,"top":0.6464},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.6019,"left":0.2406,"right":0.2448,"top":0.5538},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"e7d536b3-2cb0-494e-8b1d-697988d8f236"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4316,"right":0.4649,"top":0.439},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"},{"bounding_box":{"bottom":0.4908,"left":0.2864,"right":0.2968,"top":0.4723},"timestamp":"2025-08-10T20:52:04.339965Z","track_id":"90edf393-65a4-45c0-969f-a06acee42fac"}],"operations":[],"timestamp":"2025-08-10T20:52:04.339965Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4515,"left":0.3374,"right":0.4043,"top":0.2847},"class":{"score":0.74,"type":"Face"},"timestamp":"2025-08-10T20:52:04.439864Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2986,"top":0.6464},"timestamp":"2025-08-10T20:52:04.439864Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.439864Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.984,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.439864Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4257,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.439864Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[{"id":"e7d536b3-2cb0-494e-8b1d-697988d8f236","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:04.439864Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4531,"left":0.3349,"right":0.4015,"top":0.2867},"class":{"score":0.75,"type":"Face"},"timestamp":"2025-08-10T20:52:04.539761Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2975,"top":0.6464},"timestamp":"2025-08-10T20:52:04.539761Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.539761Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9859,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.539761Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4246,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.539761Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:04.539761Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4543,"left":0.3325,"right":0.3997,"top":0.2883},"class":{"score":0.75,"type":"Face"},"timestamp":"2025-08-10T20:52:04.639659Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2968,"top":0.6464},"timestamp":"2025-08-10T20:52:04.639659Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.639659Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9866,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.639659Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4236,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.639659Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:04.639659Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4552,"left":0.3306,"right":0.3983,"top":0.2896},"class":{"score":0.76,"type":"Face"},"timestamp":"2025-08-10T20:52:04.739555Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2968,"top":0.6464},"timestamp":"2025-08-10T20:52:04.739555Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.739555Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9847,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.739555Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4225,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.739555Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:04.739555Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4558,"left":0.3288,"right":0.3975,"top":0.2906},"class":{"score":0.76,"type":"Face"},"timestamp":"2025-08-10T20:52:04.839454Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2964,"top":0.6464},"timestamp":"2025-08-10T20:52:04.839454Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.839454Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.839454Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.839454Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[{"id":"90edf393-65a4-45c0-969f-a06acee42fac","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:04.839454Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4562,"left":0.3277,"right":0.3967,"top":0.2913},"class":{"score":0.77,"type":"Face"},"timestamp":"2025-08-10T20:52:04.939350Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2954,"top":0.6464},"timestamp":"2025-08-10T20:52:04.939350Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:04.939350Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:04.939350Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.439},"timestamp":"2025-08-10T20:52:04.939350Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:04.939350Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4565,"left":0.3269,"right":0.3961,"top":0.2919},"class":{"score":0.77,"type":"Face"},"timestamp":"2025-08-10T20:52:05.039249Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2943,"top":0.6464},"timestamp":"2025-08-10T20:52:05.039249Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.039249Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:05.039249Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4638,"top":0.439},"timestamp":"2025-08-10T20:52:05.039249Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.039249Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4565,"left":0.3264,"right":0.3958,"top":0.2922},"class":{"score":0.78,"type":"Face"},"timestamp":"2025-08-10T20:52:05.139146Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2933,"top":0.6464},"timestamp":"2025-08-10T20:52:05.139146Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.139146Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:05.139146Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4649,"top":0.439},"timestamp":"2025-08-10T20:52:05.139146Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.139146Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.457,"left":0.3259,"right":0.3959,"top":0.293},"class":{"score":0.78,"type":"Face"},"timestamp":"2025-08-10T20:52:05.239044Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:05.239044Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.239044Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:05.239044Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.439},"timestamp":"2025-08-10T20:52:05.239044Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.239044Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4568,"left":0.3258,"right":0.3958,"top":0.293},"class":{"score":0.79,"type":"Face"},"timestamp":"2025-08-10T20:52:05.338942Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:05.338942Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.338942Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.9834,"left":0.4739,"right":0.4948,"top":0.939},"timestamp":"2025-08-10T20:52:05.338942Z","track_id":"75746fe5-7fee-497f-b369-39eb98445d09"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.439},"timestamp":"2025-08-10T20:52:05.338942Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.338942Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.3242,"right":0.3948,"top":0.2925},"class":{"score":0.79,"type":"Face"},"timestamp":"2025-08-10T20:52:05.438840Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:05.438840Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.438840Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4396},"timestamp":"2025-08-10T20:52:05.438840Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[{"id":"75746fe5-7fee-497f-b369-39eb98445d09","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:05.438840Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3226,"right":0.3945,"top":0.2945},"class":{"score":0.8,"type":"Face"},"timestamp":"2025-08-10T20:52:05.538736Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:05.538736Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.538736Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4415},"timestamp":"2025-08-10T20:52:05.538736Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.538736Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4585,"left":0.3222,"right":0.3948,"top":0.2942},"class":{"score":0.8,"type":"Face"},"timestamp":"2025-08-10T20:52:05.638635Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2929,"top":0.6464},"timestamp":"2025-08-10T20:52:05.638635Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.638635Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:05.638635Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.638635Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3223,"right":0.3949,"top":0.2943},"class":{"score":0.8,"type":"Face"},"timestamp":"2025-08-10T20:52:05.738531Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.294,"top":0.6464},"timestamp":"2025-08-10T20:52:05.738531Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.738531Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:05.738531Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.738531Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4581,"left":0.3218,"right":0.3946,"top":0.2942},"class":{"score":0.81,"type":"Face"},"timestamp":"2025-08-10T20:52:05.838429Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2943,"top":0.6464},"timestamp":"2025-08-10T20:52:05.838429Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.838429Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:05.838429Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.838429Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.458,"left":0.3212,"right":0.3944,"top":0.2946},"class":{"score":0.81,"type":"Face"},"timestamp":"2025-08-10T20:52:05.938326Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2933,"top":0.6464},"timestamp":"2025-08-10T20:52:05.938326Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:05.938326Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:05.938326Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:05.938326Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.458,"left":0.3215,"right":0.395,"top":0.2941},"class":{"score":0.81,"type":"Face"},"timestamp":"2025-08-10T20:52:06.038225Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.038225Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.038225Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:06.038225Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.038225Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4589,"left":0.3209,"right":0.395,"top":0.2955},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:52:06.138122Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.138122Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.138122Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:06.138122Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.138122Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3209,"right":0.3949,"top":0.2942},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:52:06.238019Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.238019Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.238019Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:06.238019Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.238019Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4582,"left":0.3211,"right":0.3948,"top":0.2937},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:52:06.337917Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.337917Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.337917Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4656,"top":0.4427},"timestamp":"2025-08-10T20:52:06.337917Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.337917Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4593,"left":0.3207,"right":0.395,"top":0.2947},"class":{"score":0.82,"type":"Face"},"timestamp":"2025-08-10T20:52:06.437814Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.437814Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.437814Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4652,"top":0.4427},"timestamp":"2025-08-10T20:52:06.437814Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.437814Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4597,"left":0.3204,"right":0.3951,"top":0.2945},"class":{"score":0.83,"type":"Face"},"timestamp":"2025-08-10T20:52:06.537711Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.537711Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.537711Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4642,"top":0.4427},"timestamp":"2025-08-10T20:52:06.537711Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.537711Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4601,"left":0.3202,"right":0.3953,"top":0.2957},"class":{"score":0.83,"type":"Face"},"timestamp":"2025-08-10T20:52:06.637609Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.637609Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.637609Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:06.637609Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.637609Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4594,"left":0.3206,"right":0.3952,"top":0.2951},"class":{"score":0.83,"type":"Face"},"timestamp":"2025-08-10T20:52:06.737507Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.737507Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.737507Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:06.737507Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.737507Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4587,"left":0.3204,"right":0.3953,"top":0.2951},"class":{"score":0.83,"type":"Face"},"timestamp":"2025-08-10T20:52:06.837405Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.837405Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.837405Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4638,"top":0.4427},"timestamp":"2025-08-10T20:52:06.837405Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.837405Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.459,"left":0.3203,"right":0.3954,"top":0.2948},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:52:06.937302Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:06.937302Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:06.937302Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4649,"top":0.4427},"timestamp":"2025-08-10T20:52:06.937302Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:06.937302Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4587,"left":0.3207,"right":0.3953,"top":0.294},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:52:07.037200Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.037200Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.037200Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4652,"top":0.4427},"timestamp":"2025-08-10T20:52:07.037200Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.037200Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4581,"left":0.3215,"right":0.3958,"top":0.2937},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:52:07.137097Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.137097Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.137097Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4642,"top":0.4427},"timestamp":"2025-08-10T20:52:07.137097Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.137097Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.458,"left":0.3221,"right":0.3961,"top":0.2931},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:52:07.236994Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.236994Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.236994Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:07.236994Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.236994Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4576,"left":0.3222,"right":0.3958,"top":0.2929},"class":{"score":0.84,"type":"Face"},"timestamp":"2025-08-10T20:52:07.336892Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.336892Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.336892Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:07.336892Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.336892Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4572,"left":0.3223,"right":0.3955,"top":0.2929},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:52:07.436792Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.436792Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.436792Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4638,"top":0.4427},"timestamp":"2025-08-10T20:52:07.436792Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.436792Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4573,"left":0.3224,"right":0.3952,"top":0.2925},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:52:07.536678Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:07.536678Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.536678Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4649,"top":0.4427},"timestamp":"2025-08-10T20:52:07.536678Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.536678Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4571,"left":0.3228,"right":0.3956,"top":0.2925},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:52:07.636588Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2929,"top":0.6464},"timestamp":"2025-08-10T20:52:07.636588Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.636588Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4652,"top":0.4427},"timestamp":"2025-08-10T20:52:07.636588Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.636588Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4572,"left":0.3231,"right":0.3959,"top":0.2922},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:52:07.736482Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.294,"top":0.6464},"timestamp":"2025-08-10T20:52:07.736482Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.736482Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4642,"top":0.4427},"timestamp":"2025-08-10T20:52:07.736482Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.736482Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.3233,"right":0.3962,"top":0.2915},"class":{"score":0.85,"type":"Face"},"timestamp":"2025-08-10T20:52:07.836381Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2943,"top":0.6464},"timestamp":"2025-08-10T20:52:07.836381Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.836381Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:07.836381Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.836381Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4568,"left":0.3231,"right":0.3957,"top":0.2918},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:07.936278Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2933,"top":0.6464},"timestamp":"2025-08-10T20:52:07.936278Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:07.936278Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"},{"bounding_box":{"bottom":0.5834,"left":0.4218,"right":0.4635,"top":0.4427},"timestamp":"2025-08-10T20:52:07.936278Z","track_id":"10ed2cbd-008d-4789-8aba-bf266b31697a"}],"operations":[],"timestamp":"2025-08-10T20:52:07.936278Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3229,"right":0.3953,"top":0.292},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.036176Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.036176Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.036176Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[{"id":"10ed2cbd-008d-4789-8aba-bf266b31697a","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:08.036176Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3231,"right":0.3956,"top":0.2923},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.136073Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.136073Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.136073Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.136073Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4578,"left":0.3226,"right":0.3958,"top":0.294},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.235975Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.235975Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.235975Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.235975Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3228,"right":0.3961,"top":0.294},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.335867Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.335867Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.335867Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.335867Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4573,"left":0.323,"right":0.3962,"top":0.2939},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.435771Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.435771Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.435771Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.435771Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.324,"right":0.3968,"top":0.2933},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.535663Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.535663Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.535663Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.535663Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4565,"left":0.3236,"right":0.3962,"top":0.2934},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.635572Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.635572Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.635572Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.635572Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3232,"right":0.3957,"top":0.2925},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.735458Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.735458Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.735458Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.735458Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3233,"right":0.3958,"top":0.2927},"class":{"score":0.86,"type":"Face"},"timestamp":"2025-08-10T20:52:08.835356Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.835356Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.835356Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.835356Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3229,"right":0.3954,"top":0.2928},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:08.935253Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:08.935253Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:08.935253Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:08.935253Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4558,"left":0.323,"right":0.3957,"top":0.2924},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.035152Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.035152Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.035152Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.035152Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4554,"left":0.3228,"right":0.3953,"top":0.2921},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.135048Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.135048Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.135048Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.135048Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4551,"left":0.3225,"right":0.395,"top":0.2919},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.234950Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.234950Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.234950Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.234950Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4554,"left":0.3227,"right":0.3953,"top":0.2922},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.334842Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.334842Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.334842Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.334842Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4552,"left":0.3225,"right":0.395,"top":0.292},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.434742Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2936,"top":0.6464},"timestamp":"2025-08-10T20:52:09.434742Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.434742Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.434742Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.455,"left":0.3228,"right":0.3954,"top":0.2919},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.534638Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2947,"top":0.6464},"timestamp":"2025-08-10T20:52:09.534638Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.534638Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.534638Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4562,"left":0.322,"right":0.3954,"top":0.2924},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.634537Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2936,"top":0.6464},"timestamp":"2025-08-10T20:52:09.634537Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.634537Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.634537Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4564,"left":0.3223,"right":0.3957,"top":0.2927},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.734434Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.734434Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.734434Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.734434Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3217,"right":0.3956,"top":0.2945},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.834332Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.834332Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.834332Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.834332Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3215,"right":0.3959,"top":0.2948},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:09.934229Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:09.934229Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:09.934229Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:09.934229Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.457,"left":0.3215,"right":0.3952,"top":0.2941},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.034131Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.034131Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:10.034131Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:10.034131Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.3214,"right":0.3956,"top":0.2949},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.134024Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.134024Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:10.134024Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:10.134024Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.3215,"right":0.3953,"top":0.2943},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.233924Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.233924Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"},{"bounding_box":{"bottom":0.9131,"left":0.4176,"right":0.5072,"top":0.7241},"timestamp":"2025-08-10T20:52:10.233924Z","track_id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821"}],"operations":[],"timestamp":"2025-08-10T20:52:10.233924Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3211,"right":0.3953,"top":0.2943},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.333819Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.333819Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[{"id":"10884a74-0bf4-4a34-9a49-cfa2d6c6d821","type":"DeleteOperation"},{"id":"40911fb9-6226-4481-8eb9-8696bea5dfd5","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:10.333819Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3213,"right":0.3951,"top":0.2941},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.433721Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.433721Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.433721Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4582,"left":0.321,"right":0.3951,"top":0.2941},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.533614Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.533614Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.533614Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.458,"left":0.3206,"right":0.394,"top":0.2943},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.633512Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.633512Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.633512Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3205,"right":0.3932,"top":0.2941},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.733409Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.733409Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.733409Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3203,"right":0.3925,"top":0.2941},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.833307Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.833307Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.833307Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4591,"left":0.3203,"right":0.3931,"top":0.2953},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:10.933204Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:10.933204Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:10.933204Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4586,"left":0.3201,"right":0.3925,"top":0.2953},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.033102Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.033102Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.033102Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4576,"left":0.3203,"right":0.3921,"top":0.2943},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.132999Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.132999Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.132999Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4584,"left":0.3203,"right":0.3917,"top":0.2955},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.232899Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.232899Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.232899Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4579,"left":0.3205,"right":0.3915,"top":0.295},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.332794Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.332794Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.332794Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4576,"left":0.3204,"right":0.3911,"top":0.295},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.432692Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.432692Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.432692Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4572,"left":0.3207,"right":0.391,"top":0.2946},"class":{"score":0.87,"type":"Face"},"timestamp":"2025-08-10T20:52:11.532590Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.532590Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.532590Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.457,"left":0.3206,"right":0.391,"top":0.2947},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:11.632486Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.632486Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.632486Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4579,"left":0.3207,"right":0.3922,"top":0.2958},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:11.732384Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.732384Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.732384Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4576,"left":0.3206,"right":0.392,"top":0.2957},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:11.832282Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.832282Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.832282Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3207,"right":0.393,"top":0.2967},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:11.932179Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:11.932179Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:11.932179Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4578,"left":0.3208,"right":0.3927,"top":0.2959},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.032076Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.032076Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.032076Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.321,"right":0.3925,"top":0.2948},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.131974Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.131974Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.131974Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4561,"left":0.3215,"right":0.3932,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.231874Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.231874Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.231874Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4555,"left":0.3219,"right":0.3937,"top":0.293},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.331770Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.331770Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.331770Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4555,"left":0.3218,"right":0.3932,"top":0.2929},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.431667Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.431667Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.431667Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4556,"left":0.3221,"right":0.3937,"top":0.2929},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.531565Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.531565Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.531565Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4557,"left":0.3219,"right":0.3932,"top":0.2929},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.631463Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.631463Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.631463Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4553,"left":0.322,"right":0.3927,"top":0.2924},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.731360Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.731360Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.731360Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4557,"left":0.3215,"right":0.3923,"top":0.293},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.831258Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.831258Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.831258Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4559,"left":0.3211,"right":0.3919,"top":0.2935},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:12.931155Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:12.931155Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:12.931155Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4562,"left":0.3208,"right":0.3917,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.031052Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.031052Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.031052Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3209,"right":0.3916,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.130950Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.130950Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.130950Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3212,"right":0.3915,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.230847Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.230847Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.230847Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4573,"left":0.3209,"right":0.3914,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.330746Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.330746Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.330746Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4581,"left":0.3207,"right":0.3913,"top":0.294},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.430643Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.430643Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.430643Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4579,"left":0.3208,"right":0.3913,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.530541Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.530541Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.530541Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4578,"left":0.3206,"right":0.391,"top":0.2941},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.630438Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.630438Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.630438Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4576,"left":0.3205,"right":0.3911,"top":0.2944},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.730335Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.730335Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.730335Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.321,"right":0.3912,"top":0.2936},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.830233Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.830233Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.830233Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4571,"left":0.3211,"right":0.3913,"top":0.2931},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:13.930130Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:13.930130Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:13.930130Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4571,"left":0.3209,"right":0.3914,"top":0.2935},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.030028Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.030028Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.030028Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.457,"left":0.3209,"right":0.3925,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.129926Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.129926Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.129926Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4564,"left":0.321,"right":0.3924,"top":0.2932},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.229823Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.229823Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.229823Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3208,"right":0.3922,"top":0.2947},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.329721Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.329721Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.329721Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4568,"left":0.3212,"right":0.3922,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.429618Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.429618Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.429618Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3212,"right":0.392,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.529515Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.529515Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.529515Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3211,"right":0.3931,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.629413Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.629413Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.629413Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4573,"left":0.3211,"right":0.3928,"top":0.2937},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.729311Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.729311Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.729311Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.3214,"right":0.3925,"top":0.293},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.829209Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.829209Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.829209Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.3208,"right":0.3919,"top":0.2935},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:14.929105Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:14.929105Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:14.929105Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3206,"right":0.3918,"top":0.2939},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.029004Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.029004Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.029004Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.3211,"right":0.3926,"top":0.2938},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.128901Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.128901Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.128901Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4566,"left":0.3209,"right":0.3923,"top":0.2941},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.228799Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.228799Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.228799Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3207,"right":0.3921,"top":0.2944},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.328696Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.328696Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.328696Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3205,"right":0.3919,"top":0.2947},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.428594Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.428594Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.428594Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3204,"right":0.3918,"top":0.2949},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.528492Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.528492Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.528492Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3203,"right":0.3917,"top":0.295},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.628389Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.628389Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.628389Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4567,"left":0.3204,"right":0.3928,"top":0.2952},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.728286Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.728286Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.728286Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3203,"right":0.3925,"top":0.2963},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.828184Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.828184Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.828184Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3204,"right":0.3934,"top":0.2962},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:15.928082Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"},{"bounding_box":{"bottom":0.7205,"left":0.251,"right":0.2926,"top":0.6464},"timestamp":"2025-08-10T20:52:15.928082Z","track_id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67"}],"operations":[],"timestamp":"2025-08-10T20:52:15.928082Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4573,"left":0.3208,"right":0.3945,"top":0.296},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.027979Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[{"id":"d4086ecb-bf8f-404f-b992-b0b863ab3e67","type":"DeleteOperation"}],"timestamp":"2025-08-10T20:52:16.027979Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4565,"left":0.3211,"right":0.394,"top":0.2949},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.127877Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.127877Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4564,"left":0.3208,"right":0.3931,"top":0.295},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.227774Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.227774Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4564,"left":0.3206,"right":0.3927,"top":0.295},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.327672Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.327672Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3208,"right":0.3923,"top":0.2946},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.427570Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.427570Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4563,"left":0.3209,"right":0.3921,"top":0.2942},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.527467Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.527467Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4571,"left":0.3207,"right":0.3918,"top":0.2941},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.627365Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.627365Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4581,"left":0.3206,"right":0.3916,"top":0.2954},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.727261Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.727261Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4587,"left":0.3205,"right":0.3915,"top":0.295},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.827160Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.827160Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4594,"left":0.3204,"right":0.3914,"top":0.2961},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:16.927057Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:16.927057Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4589,"left":0.3208,"right":0.3928,"top":0.2959},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.026955Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.026955Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4584,"left":0.3207,"right":0.3926,"top":0.2957},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.126852Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.126852Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4588,"left":0.3205,"right":0.3923,"top":0.2952},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.226750Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.226750Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4583,"left":0.3212,"right":0.3933,"top":0.2951},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.326647Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.326647Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4578,"left":0.3214,"right":0.3941,"top":0.2945},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.426545Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.426545Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4574,"left":0.3217,"right":0.3945,"top":0.2941},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.526442Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.526442Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4582,"left":0.3218,"right":0.3953,"top":0.2952},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.626339Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.626339Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4593,"left":0.3215,"right":0.3956,"top":0.2959},"class":{"score":0.88,"type":"Face"},"timestamp":"2025-08-10T20:52:17.726238Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.726238Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4587,"left":0.321,"right":0.3943,"top":0.2957},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:17.826135Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.826135Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4581,"left":0.3213,"right":0.3945,"top":0.295},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:17.926033Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:17.926033Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4585,"left":0.3211,"right":0.3948,"top":0.2947},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.025930Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.025930Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4577,"left":0.3207,"right":0.3939,"top":0.295},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.125828Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.125828Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4584,"left":0.3204,"right":0.3932,"top":0.296},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.225726Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.225726Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.458,"left":0.3202,"right":0.3926,"top":0.2958},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.325623Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.325623Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4575,"left":0.3208,"right":0.3931,"top":0.2952},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.425520Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.425520Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4572,"left":0.3206,"right":0.3925,"top":0.2951},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.525418Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.525418Z"}}
+{"frame":{"observations":[{"bounding_box":{"bottom":0.4569,"left":0.3204,"right":0.3921,"top":0.295},"class":{"score":0.89,"type":"Face"},"timestamp":"2025-08-10T20:52:18.625316Z","track_id":"030deaa3-a5d4-4dcf-9561-b12b6433effc"}],"operations":[],"timestamp":"2025-08-10T20:52:18.625316Z"}}
\ No newline at end of file
diff --git a/project-time-in-area-analytics/test_files/sample_data_feeder.sh b/project-time-in-area-analytics/test_files/sample_data_feeder.sh
new file mode 100755
index 0000000..0ff1a72
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/sample_data_feeder.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+
+# Sample Data Feeder Script
+#
+# This script simulates the axis_metadata_consumer.sh behavior for testing.
+# It reads the sample JSON file and outputs one detection per message,
+# thus making it easier for the json parser in the execd input plugin.
+#
+# Usage: Called by Telegraf execd input, just like axis_metadata_consumer.sh
+
+# Check if sample file is specified
+if [ -z "$SAMPLE_FILE" ]; then
+ echo "ERROR: SAMPLE_FILE environment variable not set" >&2
+ exit 1
+fi
+
+# Check if sample file exists
+if [ ! -f "$HELPER_FILES_DIR/$SAMPLE_FILE" ]; then
+ echo "ERROR: Sample file not found: $HELPER_FILES_DIR/$SAMPLE_FILE" >&2
+ exit 1
+fi
+
+# Check if jq is available
+if ! command -v jq >/dev/null 2>&1; then
+ echo "ERROR: jq is required but not available" >&2
+ exit 1
+fi
+
+# Process data line by line and output it as pure json.
+while IFS= read -r line; do
+ # Skip empty lines and comment lines (starting with #)
+ if [ -n "$line" ] && [ "${line#\#}" = "$line" ]; then
+ # Process this frame with jq
+ echo "$line" | jq -c '
+ .frame as $frame |
+ if ($frame.observations | length) > 0 then
+ $frame.observations[] |
+ {
+ "frame": $frame.timestamp,
+ "timestamp": .timestamp,
+ "track_id": .track_id,
+ "object_type": .class.type,
+ "bounding_box": .bounding_box
+ }
+ else
+ empty
+ end'
+ fi
+done < "$HELPER_FILES_DIR/$SAMPLE_FILE"
diff --git a/project-time-in-area-analytics/test_files/simple_tracks.jsonl b/project-time-in-area-analytics/test_files/simple_tracks.jsonl
new file mode 100644
index 0000000..ea616e7
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/simple_tracks.jsonl
@@ -0,0 +1,72 @@
+# Simple (fake) example data on the same format at the Axis metadata from the camera.
+#
+# track_001:
+# - Detections: 4 total (3 unverified/null, 1 verified as Human)
+# - Time span: 3.78s (from first detection to last)
+# - First verified: at 3.78s into track (4th detection)
+# - One `operation` to delete track_001 after track_002 appears.
+#
+# track_002:
+# - Detections: 2 total (2 unverified/null, 0 verified)
+# - Time span: 2.22s (from first to last detection)
+# - First verified: N/A (never verified)
+# - Notes: Completely unverified track. Appears only in frames 3-6.
+#
+# track_003:
+# - Detections: 3 total (0 unverified, 3 verified as Face)
+# - Time span: 2.22s (from first detection to last)
+# - First verified: 0.00s (verified from first detection)
+# - Note: Last frame (11) shared with track_004.
+#
+# track_004:
+# - Detections: 2 total (0 unverified, 2 verified as Human)
+# - Time span: 0.56s (from first detection to last)
+# - First verified: 0.00s (verified from first detection)
+# - Note: First detection shared with track_003's last.
+# - One frame contains one `operation` field that is empty.
+#
+# track_005:
+# - Detections: 6 total (0 unverified, 6 verified as Human)
+# - Time span: 150.00s (from first detection to last)
+# - First verified: 0.00s (verified from first detection)
+
+# Frame 1: track_001 det 1 (unverified) - duration_from_first=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.6, "left": 0.2, "right": 0.3, "top": 0.4}, "timestamp": "2024-01-15T10:00:01.123456Z", "track_id": "track_001"}], "timestamp": "2024-01-15T10:00:01.123456Z"}}
+# Frame 2: track_001 det 2 (unverified) - duration_from_first=1.67s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.62, "left": 0.22, "right": 0.32, "top": 0.42}, "timestamp": "2024-01-15T10:00:02.789012Z", "track_id": "track_001"}], "timestamp": "2024-01-15T10:00:02.789012Z"}}
+# Frame 3:
+# - track_001 det 3 (unverified) - duration_from_first=2.22s
+# - track_002 det 1 (unverified) - duration_from_first=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.64, "left": 0.24, "right": 0.34, "top": 0.44}, "timestamp": "2024-01-15T10:00:03.345678Z", "track_id": "track_001"}, {"bounding_box": {"bottom": 0.56, "left": 0.12, "right": 0.18, "top": 0.36}, "timestamp": "2024-01-15T10:00:03.345678Z", "track_id": "track_002"}], "timestamp": "2024-01-15T10:00:03.345678Z"}}
+# Frame 4: track_001 det 4 (verified) - duration_from_first=3.78s, duration_from_verified=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.66, "left": 0.26, "right": 0.36, "top": 0.46}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:04.901234Z", "track_id": "track_001"}], "timestamp": "2024-01-15T10:00:04.901234Z"}}
+
+# Frame 6: track_002 det 2 (unverified) - duration_from_first=2.22s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.60, "left": 0.16, "right": 0.22, "top": 0.40}, "timestamp": "2024-01-15T10:00:05.567890Z", "track_id": "track_002"}], "timestamp": "2024-01-15T10:00:05.567890Z"}}
+{"frame": {"observations": [], "operations": [{"id": "track_001", "type": "DeleteOperation"}], "timestamp": "2024-01-15T10:00:06.000000Z"}}
+
+# Frame 9: track_003 det 1 (verified) - duration_from_first=0.00s, duration_from_verified=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.75, "left": 0.25, "right": 0.35, "top": 0.50}, "class": {"type": "Face"}, "timestamp": "2024-01-15T10:00:10.234567Z", "track_id": "track_003"}], "timestamp": "2024-01-15T10:00:10.234567Z"}}
+# Frame 10: track_003 det 2 (verified) - duration_from_first=1.66s, duration_from_verified=1.66s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.77, "left": 0.27, "right": 0.37, "top": 0.52}, "class": {"type": "Face"}, "timestamp": "2024-01-15T10:00:11.890123Z", "track_id": "track_003"}], "timestamp": "2024-01-15T10:00:11.890123Z"}}
+# Frame 11:
+# - track_003 det 3 (verified) - duration_from_first=2.22s, duration_from_verified=2.22s
+# - track_004 det 1 (verified) - duration_from_first=0.00s, duration_from_verified=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.79, "left": 0.29, "right": 0.39, "top": 0.54}, "class": {"type": "Face"}, "timestamp": "2024-01-15T10:00:12.456789Z", "track_id": "track_003"}, {"bounding_box": {"bottom": 0.68, "left": 0.28, "right": 0.38, "top": 0.48}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:12.456789Z", "track_id": "track_004"}], "timestamp": "2024-01-15T10:00:12.456789Z"}}
+# Frame 12: track_004 det 2 (verified) - duration_from_first=0.56s, duration_from_verified=0.56s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.80, "left": 0.35, "right": 0.45, "top": 0.60}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:13.012345Z", "track_id": "track_004"}], "timestamp": "2024-01-15T10:00:13.012345Z"}}
+{"frame": {"observations": [], "operations": [], "timestamp": "2024-01-15T10:00:25.000000Z"}}
+
+# Frame 15: track_005 det 1 (verified) - duration_from_first=0.00s, duration_from_verified=0.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.85, "left": 0.10, "right": 0.25, "top": 0.65}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:30.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:00:30.000000Z"}}
+# Frame 16: track_005 det 2 (verified) - duration_from_first=30.00s, duration_from_verified=30.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.86, "left": 0.11, "right": 0.26, "top": 0.66}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:01:00.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:01:00.000000Z"}}
+# Frame 17: track_005 det 3 (verified) - duration_from_first=60.00s, duration_from_verified=60.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.87, "left": 0.12, "right": 0.27, "top": 0.67}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:01:30.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:01:30.000000Z"}}
+# Frame 18: track_005 det 4 (verified) - duration_from_first=90.00s, duration_from_verified=90.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.88, "left": 0.13, "right": 0.28, "top": 0.68}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:02:00.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:02:00.000000Z"}}
+# Frame 19: track_005 det 5 (verified) - duration_from_first=120.00s, duration_from_verified=120.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.89, "left": 0.14, "right": 0.29, "top": 0.69}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:02:30.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:02:30.000000Z"}}
+# Frame 20: track_005 det 6 (verified) - duration_from_first=150.00s, duration_from_verified=150.00s
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.90, "left": 0.15, "right": 0.30, "top": 0.70}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:03:00.000000Z", "track_id": "track_005"}], "timestamp": "2024-01-15T10:03:00.000000Z"}}
+{"frame": {"observations": [], "operations": [], "timestamp": "2024-01-15T10:03:30.000000Z"}}
\ No newline at end of file
diff --git a/project-time-in-area-analytics/test_files/test_zone_filter_complex.jsonl b/project-time-in-area-analytics/test_files/test_zone_filter_complex.jsonl
new file mode 100644
index 0000000..37383a9
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/test_zone_filter_complex.jsonl
@@ -0,0 +1,30 @@
+# Test data for zone filter with complex concave zone
+# Use zone: [[[-0.97,-0.97],[-0.97,0.97],[-0.1209,0.9616],[-0.7562,0.6008],[-0.7652,0.05951],[0.05851,0.5204],[0.04617,-0.9691]]]
+#
+# This is a complex concave polygon that tests the zone filter algorithm.
+#
+# Test coverage with 7 detections:
+#
+# CONCAVE OUTSIDE (2 points):
+# In the concave section of the polygon, i.e. outside the zone.
+#
+# INSIDE (2 points):
+# In the zone.
+#
+# RIGHT OUTSIDE (2 points):
+# Outside the zone on the right side of the zone.
+#
+# LEFT OUTSIDE (2 points):
+# Outside the zone on the left side of the zone.
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.20, "left": 0.35, "right": 0.40, "top": 0.15}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:01.000000Z", "track_id": "concave_outside_a"}], "timestamp": "2024-01-15T10:00:01.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.35, "left": 0.35, "right": 0.40, "top": 0.25}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:01.000000Z", "track_id": "concave_outside_b"}], "timestamp": "2024-01-15T10:00:01.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": 0.15, "right": 0.25, "top": 0.45}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:02.000000Z", "track_id": "inside_c"}], "timestamp": "2024-01-15T10:00:02.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.45, "left": 0.05, "right": 0.15, "top": 0.35}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:06.000000Z", "track_id": "inside_d"}], "timestamp": "2024-01-15T10:00:06.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": 0.85, "right": 0.95, "top": 0.45}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:03.000000Z", "track_id": "right_outside_e"}], "timestamp": "2024-01-15T10:00:03.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.15, "left": 0.75, "right": 0.85, "top": 0.05}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:04.000000Z", "track_id": "right_outside_f"}], "timestamp": "2024-01-15T10:00:04.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": -0.05, "right": 0.05, "top": 0.45}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:05.000000Z", "track_id": "left_outside_g"}], "timestamp": "2024-01-15T10:00:05.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.45, "left": -0.05, "right": 0.05, "top": 0.35}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:06.000000Z", "track_id": "left_outside_h"}], "timestamp": "2024-01-15T10:00:06.000000Z"}}
diff --git a/project-time-in-area-analytics/test_files/test_zone_filter_simple.jsonl b/project-time-in-area-analytics/test_files/test_zone_filter_simple.jsonl
new file mode 100644
index 0000000..a867ebb
--- /dev/null
+++ b/project-time-in-area-analytics/test_files/test_zone_filter_simple.jsonl
@@ -0,0 +1,37 @@
+# Test data for zone filter with asymmetric rectangular zone
+# Use zone: [[[-0.6, -0.4], [0.2, -0.4], [0.2, 0.2], [-0.6, 0.2]]]
+#
+# Test coverage with 9 detections:
+#
+# INSIDE zone:
+# a: inside zone
+# b: inside zone
+#
+# BOUNDARY:
+# c: on zone boundary (x=0.2, right edge)
+#
+# OUTSIDE zone:
+# d: outside zone (above, y > 0.2)
+# e: outside zone (above, y > 0.2)
+#
+# CROSSING edge (movement pattern):
+# f: on zone boundary (y=-0.4, bottom edge)
+# g: inside zone
+# h: outside zone (below, y < -0.4)
+#
+# OUTSIDE zone:
+# i: outside zone (left, x < -0.6)
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": 0.25, "right": 0.65, "top": 0.35}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:01.000000Z", "track_id": "inside_zone_a"}], "timestamp": "2024-01-15T10:00:01.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": 0.25, "right": 0.75, "top": 0.35}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:02.000000Z", "track_id": "inside_zone_b"}], "timestamp": "2024-01-15T10:00:02.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.6, "left": 0.6, "right": 0.6, "top": 0.5}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:03.000000Z", "track_id": "on_boundary_c"}], "timestamp": "2024-01-15T10:00:03.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.3, "left": 0.2, "right": 0.7, "top": 0.1}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:04.000000Z", "track_id": "above_zone_d"}], "timestamp": "2024-01-15T10:00:04.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.2, "left": 0.3, "right": 0.8, "top": 0.0}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:05.000000Z", "track_id": "above_zone_e"}], "timestamp": "2024-01-15T10:00:05.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.8, "left": 0.0, "right": 0.5, "top": 0.6}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:06.000000Z", "track_id": "crossing_edge_f"}], "timestamp": "2024-01-15T10:00:06.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.55, "left": 0.2, "right": 0.6, "top": 0.35}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:07.000000Z", "track_id": "crossing_edge_g"}], "timestamp": "2024-01-15T10:00:07.000000Z"}}
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.2, "left": 0.2, "right": 0.6, "top": 0.0}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:08.000000Z", "track_id": "crossing_edge_h"}], "timestamp": "2024-01-15T10:00:08.000000Z"}}
+
+{"frame": {"observations": [{"bounding_box": {"bottom": 0.5, "left": -0.2, "right": 0.2, "top": 0.3}, "class": {"type": "Human"}, "timestamp": "2024-01-15T10:00:09.000000Z", "track_id": "left_of_zone_i"}], "timestamp": "2024-01-15T10:00:09.000000Z"}}
diff --git a/project-time-in-area-analytics/test_scripts/README.md b/project-time-in-area-analytics/test_scripts/README.md
new file mode 100644
index 0000000..5b31a21
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/README.md
@@ -0,0 +1,158 @@
+# Test Scripts
+
+This directory contains helper scripts for testing and visualizing the time-in-area analytics functionality. All scripts work seamlessly together with the same JSONL data format used throughout this project.
+
+## Table of Contents
+
+
+
+- [Installation](#installation)
+- [Recording Real Device Data](#recording-real-device-data)
+ - [Basic Usage](#basic-usage)
+ - [Advanced Usage](#advanced-usage)
+ - [Parameters](#parameters)
+ - [Authentication](#authentication)
+ - [AXIS OS Compatibility](#axis-os-compatibility)
+ - [Use Cases](#use-cases)
+- [Track Heatmap Visualization](#track-heatmap-visualization)
+ - [Basic Usage](#basic-usage-1)
+ - [Advanced Usage](#advanced-usage-1)
+ - [Features & Example Output](#features--example-output)
+ - [Activity Percentage Calculation](#activity-percentage-calculation)
+- [Prerequisites](#prerequisites)
+
+
+
+## Installation
+
+Install the required dependencies for all scripts:
+
+```bash
+pip install -r requirements.txt
+```
+
+## Recording Real Device Data
+
+The `record_real_data.py` script allows you to record live analytics scene description data from Axis cameras for testing and analysis purposes.
+
+### Basic Usage
+
+```bash
+python record_real_data.py --host --username
+```
+
+This will record 30 seconds of data by default and save it to `test_files/real_device_data.jsonl`.
+
+### Advanced Usage
+
+```bash
+python record_real_data.py \
+ --host \
+ --username \
+ --duration 60 \
+ --topic "com.axis.analytics_scene_description.v0.beta" \
+ --source "1" \
+ --output-file "my_recording.jsonl"
+```
+
+### Parameters
+
+- `--host, -h`: Device IP address or hostname (required)
+- `--username, -u`: SSH username (default: acap-fixeditdataagent)
+- `--password, -p`: SSH password (optional, will prompt if needed)
+- `--duration, -d`: Recording duration in seconds (default: 30)
+- `--topic`: Message broker topic to consume (default: com.axis.analytics_scene_description.v0.beta)
+- `--source`: Message broker source (default: 1)
+- `--output-file, -o`: Output file path (default: test_files/real_device_data.jsonl)
+
+### Authentication
+
+The script supports multiple authentication methods in this order:
+
+1. **CLI Password**: If `--password` is provided, uses that directly
+2. **SSH Key Authentication**: Tries key authentication if no password specified
+3. **Password Prompt**: Falls back to prompting for password if key auth fails
+
+### AXIS OS Compatibility
+
+- **AXIS OS < 12**: You can SSH as root without restrictions:
+
+ ```bash
+ python record_real_data.py --host --username root
+ ```
+
+- **AXIS OS 12+**: SSH as root is disabled. Regular SSH users cannot access the message broker. If you are a [Technology Integration Partner](https://www.axis.com/partner/technology-integration-partner-program), you can enable dev mode in the camera (instructions included in [this e-learning course](https://learning.fixedit.ai/spaces/11778313/content)) and use the FixedIT Data Agent user:
+ ```bash
+ python record_real_data.py --host --username acap-fixeditdataagent
+ ```
+
+### Use Cases
+
+- **Deterministic Testing**: Record real data to test analytics pipelines with reproducible results
+- **Algorithm Development**: Capture edge cases and specific scenarios for algorithm tuning
+- **Data Analysis**: Use recorded data with visualization tools like `track_heatmap_viewer.py`
+- **Debugging**: Capture problematic scenarios for investigation
+
+### Troubleshooting
+
+**Message Broker Connection Errors**: If you encounter errors like `❌ Command error: Failed to create data connection` when running the recording script, this typically indicates that the SSH user does not have sufficient privileges to subscribe to the message broker. This is a known limitation in **AXIS OS 12+** where SSH access as root is disabled and regular users may not have the necessary permissions to access the message broker service. See the [AXIS OS Compatibility](#axis-os-compatibility) section above for more details.
+
+## Track Heatmap Visualization
+
+The `track_heatmap_viewer.py` script creates heatmap visualizations showing track activity over time. This helps visualize when different track IDs are active across frames, making it easy to understand track lifecycles and identify patterns in object detection data.
+
+### Basic Usage
+
+Display heatmap interactively:
+
+```bash
+python track_heatmap_viewer.py ../test_files/simple_tracks.jsonl
+```
+
+### Advanced Usage
+
+Enable verbose output with detailed statistics:
+
+```bash
+python track_heatmap_viewer.py ../test_files/simple_tracks.jsonl --verbose
+```
+
+Show alarm visualization (tracks exceeding threshold appear in red):
+
+```bash
+python track_heatmap_viewer.py ../test_files/simple_tracks.jsonl --alarm-threshold 2.0
+```
+
+Get help:
+
+```bash
+python track_heatmap_viewer.py --help
+```
+
+### Features & Example Output
+
+The following image is an example of the output when running with `--alarm-threshold 10`, i.e. 10 seconds before a track turns red.
+
+
+_Example heatmap showing track activity over time with labeled components_
+
+**Understanding the Heatmap Elements:**
+
+- **Statistics Overlay (top-left white box)**: Shows key dataset metrics
+ - **Tracks: 42** - Total unique track IDs detected across all frames
+ - **Frames: 1180** - Number of frames containing at least one detection (not total elapsed frames)
+ - **Activity: 99.8%** - Percentage of frames with detections present
+ - **Alarms: 5** - Number of tracks exceeding the alarm threshold duration
+- **X-axis (Time)**: Timestamps of frames with observations only - time gaps without detections are not shown
+- **Y-axis (Track IDs)**: Individual object identifiers (e.g., `3effc`, `58cef`) sorted alphabetically
+- **Color Legend (right side)**: Visual scale showing track states from Absent (gray) to Present (green) to Alarm (red)
+
+#### Activity Percentage Calculation
+
+The **Activity** percentage shows what portion of the time period had detections present. It is calculated as:
+
+```
+Activity = (Frames with ≥1 detection / Total frames) × 100
+```
+
+This metric answers: "What percentage of the time period had activity?" A higher percentage means activity was present during most of the monitored time, while a lower percentage indicates activity was sporadic or brief.
diff --git a/project-time-in-area-analytics/test_scripts/record_real_data.py b/project-time-in-area-analytics/test_scripts/record_real_data.py
new file mode 100644
index 0000000..bc73e2e
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/record_real_data.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+"""
+Script to record real analytics scene description data from Axis device.
+
+This creates a test file with actual device data for more realistic testing.
+Uses SSH to connect to the device and record analytics scene description data.
+"""
+
+import getpass
+import json
+import threading
+import time
+from pathlib import Path
+from typing import Any, Dict, Iterator, Optional, Protocol
+
+import click
+import paramiko
+
+
+class CommandRunner(Protocol): # pylint: disable=too-few-public-methods
+ """Protocol for running commands and returning output."""
+
+ def run_command(self, command: str, timeout_seconds: int) -> Iterator[str]:
+ """Run a command and yield output lines.
+
+ Args:
+ command: The command to execute
+ timeout_seconds: Maximum time to wait for command completion
+
+ """
+
+
+class SSHCommandRunner:
+ """SSH implementation of CommandRunner."""
+
+ def __init__(self, host: str, username: str, password: Optional[str] = None):
+ """Initialize SSH connection parameters.
+
+ Args:
+ host: SSH host to connect to
+ username: SSH username
+ password: SSH password (optional, will prompt if needed)
+ """
+ self.host = host
+ self.username = username
+ self.password = password
+ self.client = None
+
+ def connect(self):
+ """Establish SSH connection to the device."""
+ self.client = paramiko.SSHClient()
+ self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+ # Try connecting with key first, then password if provided
+ if self.password:
+ self.client.connect(
+ hostname=self.host,
+ username=self.username,
+ password=self.password,
+ timeout=10,
+ )
+ else:
+ self.client.connect(hostname=self.host, username=self.username, timeout=10)
+
+ def run_command(self, command: str, timeout_seconds: int) -> Iterator[str]:
+ """Run command via SSH and yield output lines with timeout.
+
+ Args:
+ command: Command to execute on remote host
+ timeout_seconds: Maximum time to wait for command completion
+
+ Yields:
+ str: Lines of output from the command
+
+ Raises:
+ RuntimeError: If SSH connection fails
+ """
+ if not self.client:
+ raise RuntimeError("Not connected to device")
+
+ # Execute the command and capture both stdout and stderr
+ _, stdout, stderr = self.client.exec_command(command)
+
+ # Use threading for timeout instead of signals
+ lines = []
+ finished = threading.Event()
+
+ def read_output():
+ try:
+ for line in stdout:
+ if finished.is_set():
+ break
+ lines.append(line.strip())
+ except (paramiko.SSHException, OSError, EOFError):
+ pass # Connection closed or network error
+ finally:
+ finished.set()
+
+ def read_errors():
+ try:
+ for line in stderr:
+ if finished.is_set():
+ break
+ if line.strip(): # Only log non-empty error lines
+ print(f"\033[31m❌ Command error: {line.strip()}\033[0m")
+ except (paramiko.SSHException, OSError, EOFError):
+ pass
+ finally:
+ finished.set()
+
+ reader_thread = threading.Thread(target=read_output)
+ error_thread = threading.Thread(target=read_errors)
+ reader_thread.start()
+ error_thread.start()
+
+ # Wait for the timeout or completion
+ start_time = time.time()
+ while time.time() - start_time < timeout_seconds and not finished.is_set():
+ time.sleep(0.1)
+
+ finished.set() # Signal threads to stop
+ reader_thread.join(timeout=1)
+ error_thread.join(timeout=1)
+
+ yield from lines
+
+ def close(self):
+ """Close the SSH connection."""
+ if self.client:
+ self.client.close()
+
+
+class DataRecorder:
+ """Records data from a command runner, agnostic to transport method."""
+
+ def __init__(self, runner: CommandRunner):
+ """Initialize with a command runner.
+
+ Args:
+ runner: CommandRunner instance for executing commands
+ """
+ self.runner = runner
+
+ def extract_json_from_line(self, line: str) -> Optional[Dict[str, Any]]:
+ """
+ Extract and parse JSON from a line that may have a prefix.
+
+ This handles cases where message-broker-cli output has prefixes like:
+ "2024-01-01 12:00:00 {"key": "value", ...}"
+ We want just the JSON part parsed as a Python object.
+
+ Args:
+ line: Input line that may contain JSON with optional prefix
+
+ Returns:
+ Parsed JSON object if found and valid, None otherwise
+
+ Examples:
+ >>> recorder = DataRecorder(None)
+ >>> recorder.extract_json_from_line('{"test": "value"}')
+ {'test': 'value'}
+
+ >>> recorder.extract_json_from_line(
+ ... '2024-01-01 12:00:00 {"key": "value"}'
+ ... )
+ {'key': 'value'}
+
+ >>> recorder.extract_json_from_line('no json here')
+
+ >>> recorder.extract_json_from_line('prefix {"invalid": json}')
+
+ >>> recorder.extract_json_from_line('')
+
+ >>> recorder.extract_json_from_line(
+ ... 'ts {"nested": {"data": [1, 2, 3]}}'
+ ... )
+ {'nested': {'data': [1, 2, 3]}}
+ """
+ # Find the first '{' character to locate potential JSON
+ json_start = line.find("{")
+ if json_start == -1:
+ # No JSON found in this line
+ return None
+
+ json_line = line[json_start:]
+
+ # Parse and return the JSON object
+ try:
+ return json.loads(json_line)
+ except json.JSONDecodeError:
+ # The extracted part isn't valid JSON
+ return None
+
+ def record_data(
+ self, topic: str, source: str, output_file: Path, timeout_seconds: int
+ ) -> int:
+ """
+ Record data from message broker.
+
+ Args:
+ topic: Message broker topic to consume.
+ source: Message broker source.
+ output_file: Path to save the recorded data.
+ timeout_seconds: Maximum recording duration in seconds.
+
+ Returns:
+ int: Number of valid JSON lines recorded.
+ """
+ command = f'message-broker-cli consume "{topic}" "{source}"'
+
+ line_count = 0
+ with output_file.open("w") as f:
+ for line in self.runner.run_command(command, timeout_seconds):
+ json_obj = self.extract_json_from_line(line)
+ if json_obj is not None:
+ # Convert back to compact JSON string for file output
+ # separators=(',', ':') removes spaces after commas and
+ # colons
+ # for smaller file size and consistent JSONL format
+ f.write(json.dumps(json_obj, separators=(",", ":")) + "\n")
+ f.flush() # Ensure data is written immediately
+ line_count += 1
+
+ return line_count
+
+
+@click.command()
+@click.option("--host", "-h", required=True, help="Device IP address or hostname")
+@click.option("--username", "-u", default="acap-fixeditdataagent", help="SSH username")
+@click.option(
+ "--password",
+ "-p",
+ default=None,
+ help="SSH password (if not provided, will try key auth first, then prompt)",
+)
+@click.option(
+ "--topic",
+ default="com.axis.analytics_scene_description.v0.beta",
+ help="Message broker topic to consume",
+)
+@click.option("--source", default="1", help="Message broker source")
+@click.option(
+ "--output-file",
+ "-o",
+ default="test_files/real_device_data.jsonl",
+ help="Output file path",
+)
+@click.option("--duration", "-d", default=30, help="Recording duration in seconds")
+def main( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-statements
+ host: str,
+ username: str,
+ password: Optional[str],
+ topic: str,
+ source: str,
+ output_file: str,
+ duration: int,
+):
+ r"""
+ Record real analytics scene description data from Axis device.
+
+ This script connects to an Axis device via SSH and records analytics scene
+ description data for testing purposes. The recorded data can be used with
+ the time-in-area analytics pipeline for more realistic testing.
+
+ Args:
+ host: SSH host/IP address to connect to
+ username: SSH username for authentication
+ password: SSH password (optional, will prompt if needed)
+ topic: Message broker topic to consume from
+ source: Message broker source identifier
+ output_file: Path to output JSONL file
+ duration: Recording duration in seconds
+
+ Raises:
+ Abort: If user cancels password prompt
+ ClickException: If output directory doesn't exist
+
+ \b
+ Authentication:
+ - First tries SSH key authentication
+ - Falls back to password authentication if key auth fails
+ - Prompts for password if not provided via --password option
+
+ \b
+ AXIS OS Compatibility:
+ - AXIS OS < 12: Can SSH as root without password restrictions
+ - AXIS OS 12+: SSH as root is disabled, use FixedIT Data Agent user in
+ dev mode
+ """
+ click.echo("Recording real analytics scene description data from device...")
+ click.echo(f"Device: {username}@{host}")
+ click.echo(f"Topic: {topic}")
+ click.echo(f"Source: {source}")
+ click.echo(f"Duration: {duration} seconds")
+ click.echo(f"Output file: {output_file}")
+ click.echo("")
+
+ # Validate that output directory exists
+ output_path = Path(output_file)
+ if not output_path.parent.exists():
+ raise click.ClickException(
+ f"Output directory does not exist: {output_path.parent}"
+ )
+
+ if not password:
+ try:
+ # Try to connect without password first (key auth)
+ ssh_runner = SSHCommandRunner(host, username)
+ ssh_runner.connect()
+ click.echo("✅ Connected using SSH key authentication")
+ except (paramiko.AuthenticationException, paramiko.SSHException):
+ # Key auth failed, prompt for password
+ try:
+ password = getpass.getpass(f"Password for {username}@{host}: ")
+ ssh_runner = SSHCommandRunner(host, username, password)
+ ssh_runner.connect()
+ click.echo("✅ Connected using password authentication")
+ except KeyboardInterrupt as exc:
+ click.echo("\n❌ Cancelled by user")
+ raise click.Abort() from exc
+ except KeyboardInterrupt as exc:
+ click.echo("\n❌ Cancelled by user")
+ raise click.Abort() from exc
+ else:
+ ssh_runner = SSHCommandRunner(host, username, password)
+ ssh_runner.connect()
+ click.echo("✅ Connected to device")
+
+ click.echo("Starting data recording...")
+
+ try:
+ # Record data
+ recorder = DataRecorder(ssh_runner)
+ line_count = recorder.record_data(topic, source, output_path, duration)
+
+ if line_count > 0:
+ click.echo(
+ f"✅ Successfully recorded {line_count} lines of real device data"
+ )
+ click.echo(f"📁 Saved to: {output_file}")
+ click.echo("")
+ click.echo("Sample of recorded data (first 3 lines):")
+ else:
+ click.echo("ℹ️ No data was recorded during the timeout period.")
+ click.echo("This is normal if:")
+ click.echo(" - No motion or objects were detected by the camera")
+ click.echo(" - No analytics events occurred during recording")
+ click.echo(
+ f" - The specified topic/source '{topic}/{source}' had no "
+ f"activity"
+ )
+ click.echo("")
+ click.echo("The connection and command executed successfully.")
+ finally:
+ ssh_runner.close()
+
+ click.echo("")
+ click.echo("🧪 To test with this real data, use:")
+ click.echo(f'export SAMPLE_FILE="{output_file}"')
+ click.echo("Then run your telegraf test commands as documented in README.md")
+
+
+if __name__ == "__main__":
+ main() # pylint: disable=no-value-for-parameter
diff --git a/project-time-in-area-analytics/test_scripts/requirements.txt b/project-time-in-area-analytics/test_scripts/requirements.txt
new file mode 100644
index 0000000..da827d7
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/requirements.txt
@@ -0,0 +1,7 @@
+click==8.2.1
+matplotlib==3.9.4
+numpy==2.2.1
+opencv-python==4.12.0.88
+paramiko==3.5.0
+prettytable==3.16.0
+types-paramiko==4.0.0.20250809
\ No newline at end of file
diff --git a/project-time-in-area-analytics/test_scripts/track_heatmap_viewer.py b/project-time-in-area-analytics/test_scripts/track_heatmap_viewer.py
new file mode 100755
index 0000000..421e025
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/track_heatmap_viewer.py
@@ -0,0 +1,1067 @@
+#!/usr/bin/env python3
+# pylint: disable=too-many-lines
+"""
+Track Heatmap Viewer.
+
+This script creates a heatmap visualization showing when different track IDs are
+active over time.
+The visualization displays:
+- X-axis: Time (timestamps of frames with observations)
+- Y-axis: Track IDs
+- Green cells: Track is present in that frame
+- Gray cells: Track is not present in that frame
+
+Note: Only frames with observations are shown. Gaps in time are not represented.
+This helps visualize track lifecycles and identify patterns in object detection
+data.
+"""
+
+import json
+import sys
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional, Set, Union
+
+import click
+import matplotlib.pyplot as plt
+import numpy as np
+
+# Color constants for heatmap visualization
+COLOR_ABSENT = "#CCCCCC" # Gray - track is absent
+COLOR_PRESENT = "#4CAF50" # Green - track is present (classified)
+COLOR_UNCLASSIFIED = "#000000" # Black - track is present but unclassified
+COLOR_ALARM = "#F44336" # Red - track exceeds alarm threshold
+
+
+@dataclass
+class BoundingBox:
+ """Bounding box coordinates."""
+
+ left: float
+ top: float
+ right: float
+ bottom: float
+
+
+@dataclass
+class ObjectClass:
+ """Object classification."""
+
+ type: str
+
+
+@dataclass
+class Detection:
+ """A single object detection with tracking information."""
+
+ track_id: str
+ timestamp: str
+ bounding_box: BoundingBox
+ class_info: ObjectClass
+
+
+@dataclass
+class Frame:
+ """A single frame containing multiple detections."""
+
+ frame_number: int
+ timestamp: str
+ detections: List[Detection]
+
+ @property
+ def track_ids(self) -> Set[str]:
+ """
+ Get all unique track IDs present in this frame.
+
+ Returns:
+ Set[str]: Set of unique track IDs in this frame.
+
+ Examples:
+ >>> bbox1 = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>> bbox2 = BoundingBox(0.2, 0.3, 0.4, 0.5)
+ >>> detection1 = Detection(
+ ... "track_001", "2024-01-15T10:00:01Z", bbox1, ObjectClass("Human")
+ ... )
+ >>> detection2 = Detection(
+ ... "track_002", "2024-01-15T10:00:01Z", bbox2, ObjectClass("Vehicle")
+ ... )
+ >>> frame = Frame(1, "2024-01-15T10:00:01Z", [detection1, detection2])
+ >>> sorted(frame.track_ids)
+ ['track_001', 'track_002']
+ """
+ return {detection.track_id for detection in self.detections}
+
+ @property
+ def class_names(self) -> Dict[str, str]:
+ """
+ Get mapping of track IDs to class names for this frame.
+
+ Returns:
+ Dict[str, str]: Mapping from track_id to class name. Keys are ordered
+ to match `track_ids` for deterministic representation.
+
+ Examples:
+ >>> bbox1 = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>> bbox2 = BoundingBox(0.2, 0.3, 0.4, 0.5)
+ >>> detection1 = Detection(
+ ... "track_001", "2024-01-15T10:00:01Z", bbox1, ObjectClass("Human")
+ ... )
+ >>> detection2 = Detection(
+ ... "track_002", "2024-01-15T10:00:02Z", bbox2, ObjectClass("Vehicle")
+ ... )
+ >>> frame = Frame(1, "2024-01-15T10:00:01Z", [detection1, detection2])
+ >>> frame.class_names
+ {'track_001': 'Human', 'track_002': 'Vehicle'}
+ """
+ seen: Dict[str, str] = {}
+ for detection in self.detections:
+ track_id = detection.track_id
+ class_type = detection.class_info.type
+
+ # Only update if we don't have this track_id yet, or if the new
+ # class_type is not "Unknown"
+ if track_id not in seen or (
+ class_type != "Unknown" and seen[track_id] == "Unknown"
+ ):
+ seen[track_id] = class_type
+
+ return {track_id: seen[track_id] for track_id in sorted(seen.keys())}
+
+
+@dataclass
+class TrackData:
+ """Container for parsed track data from JSONL file."""
+
+ frames: List[Frame]
+
+ @property
+ def all_track_ids(self) -> Set[str]:
+ """
+ Get all unique track IDs found across all frames.
+
+ Returns:
+ Set of all unique track IDs.
+
+ Examples:
+ >>> # Create test data - 2 tracks
+ >>> bbox = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>> human_class = ObjectClass("Human")
+ >>> vehicle_class = ObjectClass("Vehicle")
+ >>>
+ >>> # Frame 1: track_001 (Human)
+ >>> det1 = Detection("track_001", "2024-01-01T00:00:01Z", bbox, human_class)
+ >>> frame1 = Frame(1, "2024-01-01T00:00:01Z", [det1])
+ >>>
+ >>> # Frame 2: track_002 (Vehicle)
+ >>> det2 = Detection("track_002", "2024-01-01T00:00:02Z", bbox, vehicle_class)
+ >>> frame2 = Frame(2, "2024-01-01T00:00:02Z", [det2])
+ >>>
+ >>> frames = [frame1, frame2]
+ >>> track_data = TrackData(frames=frames)
+ >>> sorted(track_data.all_track_ids)
+ ['track_001', 'track_002']
+ """
+ all_ids = set()
+ for frame in self.frames:
+ all_ids.update(frame.track_ids)
+ return all_ids
+
+ @property
+ def track_class_map(self) -> Dict[str, str]:
+ """
+ Get mapping of all track IDs to their class types.
+
+ Returns:
+ Dict mapping track_id to class type for all tracks found in frames.
+
+ Examples:
+ >>> # Create test data - 2 tracks with different classes
+ >>> bbox = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>> human_class = ObjectClass("Human")
+ >>> vehicle_class = ObjectClass("Vehicle")
+ >>>
+ >>> # Frame 1: track_001 (Human)
+ >>> det1 = Detection("track_001", "2024-01-01T00:00:01Z", bbox, human_class)
+ >>> frame1 = Frame(1, "2024-01-01T00:00:01Z", [det1])
+ >>>
+ >>> # Frame 2: track_002 (Vehicle)
+ >>> det2 = Detection("track_002", "2024-01-01T00:00:02Z", bbox, vehicle_class)
+ >>> frame2 = Frame(2, "2024-01-01T00:00:02Z", [det2])
+ >>>
+ >>> frames = [frame1, frame2]
+ >>> track_data = TrackData(frames=frames)
+ >>> track_data.track_class_map
+ {'track_001': 'Human', 'track_002': 'Vehicle'}
+ """
+ track_class_map: Dict[str, str] = {}
+
+ # Collect class information from all frames, prioritizing non-"Unknown" values
+ for frame in self.frames:
+ for track_id, class_name in frame.class_names.items():
+ # Only update if we don't have this track_id yet, or if the new
+ # class_name is not "Unknown"
+ if track_id not in track_class_map or (
+ class_name != "Unknown" and track_class_map[track_id] == "Unknown"
+ ):
+ track_class_map[track_id] = class_name
+
+ return track_class_map
+
+
+def _parse_observation_to_detection(obs: Dict) -> Detection:
+ """
+ Parse a single observation dictionary into a Detection object.
+
+ Args:
+ obs: Observation dictionary from JSONL data
+
+ Returns:
+ Detection object with parsed data
+
+ Raises:
+ ValueError: If required fields are missing
+
+ Examples:
+ >>> obs = {
+ ... "track_id": "track_001",
+ ... "timestamp": "2024-01-15T10:00:01Z",
+ ... "bounding_box": {"left": 0.2, "top": 0.4, "right": 0.3, "bottom": 0.6},
+ ... "class": {"type": "Human"}
+ ... }
+ >>> detection = _parse_observation_to_detection(obs)
+ >>> detection.track_id
+ 'track_001'
+ >>> detection.timestamp
+ '2024-01-15T10:00:01Z'
+ >>> detection.bounding_box.left
+ 0.2
+ >>> detection.class_info.type
+ 'Human'
+ """
+ # Validate required fields
+ if "track_id" not in obs:
+ raise ValueError("Missing required 'track_id' field in observation")
+ if "timestamp" not in obs:
+ raise ValueError("Missing required 'timestamp' field in observation")
+ if "bounding_box" not in obs:
+ raise ValueError("Missing required 'bounding_box' field in observation")
+ # Create BoundingBox
+ bbox_data = obs["bounding_box"]
+ if not all(coord in bbox_data for coord in ["left", "top", "right", "bottom"]):
+ raise ValueError(
+ "Missing required bounding box coordinates (left, top, right, bottom)"
+ )
+
+ bounding_box = BoundingBox(
+ left=bbox_data["left"],
+ top=bbox_data["top"],
+ right=bbox_data["right"],
+ bottom=bbox_data["bottom"],
+ )
+
+ # Create ObjectClass (optional - some tracks may not have classification yet)
+ if "class" in obs and "type" in obs["class"]:
+ class_info = ObjectClass(type=obs["class"]["type"])
+ else:
+ # Use "Unknown" for tracks without classification
+ class_info = ObjectClass(type="Unknown")
+
+ # Create Detection
+ return Detection(
+ track_id=obs["track_id"],
+ timestamp=obs["timestamp"],
+ bounding_box=bounding_box,
+ class_info=class_info,
+ )
+
+
+def _parse_frame_data(frame_data: Dict, line_num: int) -> Frame:
+ """
+ Parse a single frame data dictionary into a Frame object.
+
+ Args:
+ frame_data: Frame dictionary from JSONL data
+ line_num: Line number to use as frame number
+
+ Returns:
+ Frame object
+
+ Raises:
+ ValueError: If required frame fields are missing
+ """
+ # Validate required frame fields
+ if "timestamp" not in frame_data:
+ raise ValueError("Missing required 'timestamp' field in frame data")
+ if "observations" not in frame_data:
+ raise ValueError("Missing required 'observations' field in frame data")
+
+ observations = frame_data["observations"]
+ frame_timestamp = frame_data["timestamp"]
+
+ # Parse observations into Detection objects
+ detections = []
+
+ for obs in observations:
+ if "track_id" in obs:
+ detection = _parse_observation_to_detection(obs)
+ detections.append(detection)
+
+ # Create Frame object
+ frame = Frame(
+ frame_number=line_num,
+ timestamp=frame_timestamp,
+ detections=detections,
+ )
+
+ return frame
+
+
+def _parse_jsonl_line(line: str, line_num: int) -> Frame:
+ """
+ Parse a single line from a JSONL file into a Frame object.
+
+ Args:
+ line: JSON string from JSONL file
+ line_num: Line number for error reporting and frame numbering
+
+ Returns:
+ Tuple of (Frame object, set of track IDs found in this frame)
+
+ Raises:
+ ValueError: If JSON is invalid or doesn't contain expected 'frame' key
+
+ Examples:
+ >>> line = '''{
+ ... "frame": {
+ ... "timestamp": "2024-01-15T10:00:01Z",
+ ... "observations": [{
+ ... "track_id": "track_001",
+ ... "timestamp": "2024-01-15T10:00:01Z",
+ ... "bounding_box": {"left": 0.2, "top": 0.4, "right": 0.3, "bottom": 0.6},
+ ... "class": {"type": "Human"}
+ ... }]
+ ... }
+ ... }'''
+ >>> frame = _parse_jsonl_line(line, 1)
+ >>> frame.frame_number
+ 1
+ >>> frame.timestamp
+ '2024-01-15T10:00:01Z'
+ >>> len(frame.detections)
+ 1
+ """
+ try:
+ data = json.loads(line)
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Invalid JSON on line {line_num}: {e}. "
+ f"Expected JSONL format with one JSON object per line."
+ ) from e
+
+ # All valid lines must contain frame data
+ if "frame" not in data:
+ raise ValueError(
+ f"Missing 'frame' key on line {line_num}. "
+ f'Expected format: {{"frame": {{...}}}} but got keys: {list(data.keys())}'
+ )
+
+ frame = _parse_frame_data(data["frame"], line_num)
+ return frame
+
+
+def parse_jsonl_file(file_path: Path) -> TrackData:
+ """
+ Parse JSONL file and extract frame data.
+
+ Args:
+ file_path: Path to the JSONL file
+
+ Returns:
+ TrackData containing all frames
+
+ Raises:
+ FileNotFoundError: If the input file doesn't exist
+ OSError: If there's an error reading the file
+ ValueError: If JSON is invalid or missing expected 'frame' key
+ """
+ frames = []
+
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ for line_num, line in enumerate(f, 1):
+ line = line.strip()
+ if not line:
+ continue
+
+ try:
+ frame = _parse_jsonl_line(line, line_num)
+ frames.append(frame)
+
+ except ValueError as e:
+ raise ValueError(f"Error parsing line {line_num}: {e}") from e
+
+ except FileNotFoundError as e:
+ raise FileNotFoundError(f"File not found: {file_path}") from e
+ except OSError as e:
+ raise OSError(f"Error reading file {file_path}: {e}") from e
+
+ return TrackData(frames=frames)
+
+
+def _combine_heatmap_and_alarm_matrices(
+ heatmap_matrix: np.ndarray, alarm_matrix: np.ndarray
+) -> np.ndarray:
+ """
+ Combine heatmap and alarm matrices, ensuring alarms override classification status.
+
+ Args:
+ heatmap_matrix: Matrix with values 0=absent, 1=unclassified, 2=classified
+ alarm_matrix: Matrix with values 0=no alarm, 1=alarm
+
+ Returns:
+ Combined matrix with values 0=absent, 1=unclassified, 2=classified, 3=alarm
+
+ Examples:
+ >>> # Test data: 2 tracks, 3 frames
+ >>> # track1: unclassified, unclassified, classified
+ >>> # track2: absent, unclassified, unclassified
+ >>> heatmap = np.array([[1, 1, 2], [0, 1, 1]])
+ >>> # track1: no alarm, no alarm, alarm
+ >>> # track2: no alarm, no alarm, alarm
+ >>> alarm = np.array([[0, 0, 1], [0, 0, 1]])
+ >>> combined = _combine_heatmap_and_alarm_matrices(heatmap, alarm)
+ >>> combined.tolist()
+ [[1, 1, 3], [0, 1, 3]]
+ """
+ combined_matrix = heatmap_matrix.copy()
+ # Any track with an alarm gets value 3 (red), regardless of classification
+ combined_matrix[alarm_matrix > 0] = 3
+ return combined_matrix
+
+
+def _create_heatmap_matrix(
+ frames: List[Frame], sorted_track_ids: List[str]
+) -> np.ndarray:
+ """
+ Create the heatmap matrix from frame data, distinguishing classified vs unclassified tracks.
+
+ Args:
+ frames: List of Frame objects
+ sorted_track_ids: Sorted list of track IDs. The order determines the row positions
+ in the returned matrix - track_ids[0] maps to row 0, track_ids[1] to row 1, etc.
+ This ensures consistent visual ordering in the heatmap.
+
+ Returns:
+ 2D numpy array: 0=absent, 1=unclassified, 2=classified. Rows correspond to
+ track IDs in the same order as sorted_track_ids.
+
+ Examples:
+ >>> # Create test data - 3 frames, 2 tracks
+ >>> bbox = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>>
+ >>> # Frame 1: only track_001 (unclassified)
+ >>> det1 = Detection("track_001", "2024-01-01T00:00:01Z", bbox, ObjectClass("Unknown"))
+ >>> frame1 = Frame(1, "2024-01-01T00:00:01Z", [det1])
+ >>>
+ >>> # Frame 2: both tracks (track_001 now classified, track_002 unclassified)
+ >>> det2a = Detection("track_001", "2024-01-01T00:00:02Z", bbox, ObjectClass("Human"))
+ >>> det2b = Detection("track_002", "2024-01-01T00:00:02Z", bbox, ObjectClass("Unknown"))
+ >>> frame2 = Frame(2, "2024-01-01T00:00:02Z", [det2a, det2b])
+ >>>
+ >>> # Frame 3: only track_002 (still unclassified)
+ >>> det3 = Detection("track_002", "2024-01-01T00:00:03Z", bbox, ObjectClass("Unknown"))
+ >>> frame3 = Frame(3, "2024-01-01T00:00:03Z", [det3])
+ >>>
+ >>> frames = [frame1, frame2, frame3]
+ >>> track_ids = ["track_001", "track_002"]
+ >>> matrix = _create_heatmap_matrix(frames, track_ids)
+ >>> matrix.shape
+ (2, 3)
+ >>> matrix.tolist()
+ [[1.0, 2.0, 0.0], [0.0, 1.0, 1.0]]
+ """
+ num_tracks = len(sorted_track_ids)
+ num_frames = len(frames)
+ heatmap_matrix = np.zeros((num_tracks, num_frames))
+
+ for frame_idx, frame in enumerate(frames):
+ frame_track_ids = set(frame.track_ids)
+ for track_idx, track_id in enumerate(sorted_track_ids):
+ if track_id in frame_track_ids:
+ # Check if track has class info in this specific frame
+ class_type = frame.class_names.get(track_id, "Unknown")
+ if class_type != "Unknown":
+ heatmap_matrix[track_idx, frame_idx] = 2 # Classified
+ else:
+ heatmap_matrix[track_idx, frame_idx] = 1 # Unclassified
+
+ return heatmap_matrix
+
+
+def _create_alarm_matrix( # pylint: disable=too-many-locals
+ frames: List[Frame], sorted_track_ids: List[str], alarm_threshold: float
+) -> np.ndarray:
+ """
+ Create a matrix indicating which tracks exceed the alarm threshold.
+
+ This function implements time-in-area calculation by tracking when each track first
+ appears and calculating how long it has been present. When a track's time in area
+ exceeds the alarm_threshold, it gets marked as an alarm condition.
+
+ The algorithm:
+ 1. Track first appearance time for each track ID
+ 2. For each subsequent frame, calculate time elapsed since first appearance
+ 3. Mark frames where time_in_area >= alarm_threshold as alarm conditions
+
+ This creates a separate "alarm layer" that gets combined with the basic presence
+ heatmap to show alarm conditions in red while normal presence remains green.
+
+ Args:
+ frames: List of Frame objects with timestamps and detections
+ sorted_track_ids: Sorted list of track IDs for consistent matrix ordering. The order
+ determines the row positions in the returned matrix - track_ids[0] maps to row 0,
+ track_ids[1] to row 1, etc. This ensures the alarm matrix rows align with the
+ heatmap matrix rows for proper combination.
+ alarm_threshold: Time threshold in seconds for alarm conditions
+
+ Returns:
+ 2D numpy array: 1 where track exceeds threshold, 0 otherwise. Rows correspond to
+ track IDs in the same order as sorted_track_ids, ensuring alignment with the heatmap matrix.
+
+ Raises:
+ ValueError: If timestamp parsing fails or data is invalid
+
+ Examples:
+ >>> # Create test data - track_001 exceeds 2-second threshold
+ >>> bbox = BoundingBox(0.1, 0.2, 0.3, 0.4)
+ >>> obj_class = ObjectClass("Human")
+ >>>
+ >>> # Frame 1: track_001 appears (0 seconds elapsed)
+ >>> det1 = Detection("track_001", "2024-01-01T00:00:01Z", bbox, obj_class)
+ >>> frame1 = Frame(1, "2024-01-01T00:00:01Z", [det1])
+ >>>
+ >>> # Frame 2: track_001 still present (1 second elapsed)
+ >>> det2 = Detection("track_001", "2024-01-01T00:00:02Z", bbox, obj_class)
+ >>> frame2 = Frame(2, "2024-01-01T00:00:02Z", [det2])
+ >>>
+ >>> # Frame 3: track_001 still present (3 seconds elapsed - exceeds threshold!)
+ >>> det3 = Detection("track_001", "2024-01-01T00:00:04Z", bbox, obj_class)
+ >>> frame3 = Frame(3, "2024-01-01T00:00:04Z", [det3])
+ >>>
+ >>> # Frame 4: track_001 still present (5 seconds elapsed - still in alarm)
+ >>> det4 = Detection("track_001", "2024-01-01T00:00:06Z", bbox, obj_class)
+ >>> frame4 = Frame(4, "2024-01-01T00:00:06Z", [det4])
+ >>>
+ >>> frames = [frame1, frame2, frame3, frame4]
+ >>> track_ids = ["track_001"]
+ >>> alarm_matrix = _create_alarm_matrix(frames, track_ids, 2.0)
+ >>> alarm_matrix.shape
+ (1, 4)
+ >>> alarm_matrix.tolist()
+ [[0.0, 0.0, 1.0, 1.0]]
+ """
+ num_tracks = len(sorted_track_ids)
+ num_frames = len(frames)
+
+ # Create alarm matrix: same dimensions as heatmap, but tracks alarm conditions
+ alarm_matrix = np.zeros((num_tracks, num_frames))
+
+ # Track first appearance times as seconds - this is our "state" for time-in-area calculation
+ track_first_seen: Dict[str, float] = {}
+
+ # Reference time for converting timestamps to seconds
+ reference_time: Union[datetime, None] = None
+
+ # Process each frame chronologically to calculate cumulative time in area
+ for frame_idx, frame in enumerate(frames):
+ frame_track_ids = frame.track_ids
+ frame_timestamp = frame.timestamp
+
+ # All frames must have timestamps for time-in-area calculation
+ if not frame_timestamp:
+ raise ValueError(
+ f"Missing timestamp in frame {frame_idx}. "
+ "Time-in-area calculation requires timestamps in all frames."
+ )
+
+ # Convert timestamp to seconds relative to first frame
+ try:
+ current_datetime = datetime.fromisoformat(
+ frame_timestamp.replace("Z", "+00:00")
+ )
+ if reference_time is None:
+ reference_time = current_datetime
+ current_time_seconds = 0.0
+ else:
+ time_diff = current_datetime - reference_time
+ current_time_seconds = time_diff.total_seconds()
+ except ValueError as e:
+ raise ValueError(
+ f"Invalid timestamp format '{frame_timestamp}' in frame {frame_idx}: {e}. "
+ "Expected ISO format like '2024-01-15T10:00:01Z'"
+ ) from e
+
+ # Check each track to see if it's present and calculate time in area
+ for track_idx, track_id in enumerate(sorted_track_ids):
+ if track_id in frame_track_ids:
+ # Record when we first see this track (start of time-in-area measurement)
+ if track_id not in track_first_seen:
+ track_first_seen[track_id] = current_time_seconds
+
+ # Calculate how long this track has been in the area
+ first_time_seconds = track_first_seen[track_id]
+ time_in_area = current_time_seconds - first_time_seconds
+
+ # Mark this frame as alarm condition if track exceeds threshold
+ if time_in_area >= alarm_threshold:
+ alarm_matrix[track_idx, frame_idx] = 1
+
+ return alarm_matrix
+
+
+def _create_heatmap_imshow(
+ ax: plt.Axes, combined_matrix: np.ndarray, show_alarm_colors: bool
+) -> tuple[plt.matplotlib.image.AxesImage, str, list, list]:
+ """
+ Create imshow plot with appropriate colormap configuration and return title.
+
+ This function handles different color schemes:
+ 1. Alarm colors (4 discrete values): 0=absent, 1=unclassified, 2=classified, 3=alarm
+ 2. Basic colors (3 discrete values): 0=absent, 1=unclassified, 2=classified
+
+ Both use BoundaryNorm for consistent discrete color boundaries, ensuring exact
+ value-to-color mapping.
+
+ Args:
+ ax: Matplotlib axes to plot on
+ combined_matrix: 2D numpy array with track presence/alarm data
+ show_alarm_colors: Whether to include alarm colors (4-color scheme) or basic colors
+ (3-color scheme)
+
+ Returns:
+ Tuple of (matplotlib image object from imshow, title string, tick positions, tick labels)
+
+ Examples:
+ >>> import matplotlib.pyplot as plt
+ >>> fig, ax = plt.subplots()
+ >>> matrix = np.array([[0, 1], [2, 0]])
+ >>> im, title, ticks, labels = _create_heatmap_imshow(ax, matrix, False)
+ >>> im is not None
+ True
+ >>> "Classified" in title
+ True
+ >>> len(ticks) == 3
+ True
+
+ >>> im, title, ticks, labels = _create_heatmap_imshow(ax, matrix, True)
+ >>> "Alarm" in title
+ True
+ >>> len(ticks) == 4
+ True
+ """
+ if show_alarm_colors:
+ # 4-class case: 0=absent, 1=unclassified, 2=classified, 3=alarm
+ colors = [COLOR_ABSENT, COLOR_UNCLASSIFIED, COLOR_PRESENT, COLOR_ALARM]
+ title = (
+ "Track Activity Heatmap\n"
+ "(Gray = Absent, Black = Unclassified, Green = Classified, Red = Alarm)"
+ )
+ bounds = [0, 1, 2, 3, 4]
+ num_colors = 4
+ tick_positions = [0.5, 1.5, 2.5, 3.5]
+ tick_labels = ["Absent", "Unclassified", "Classified", "Alarm"]
+ else:
+ # 3-class case: 0=absent, 1=unclassified, 2=classified
+ colors = [COLOR_ABSENT, COLOR_UNCLASSIFIED, COLOR_PRESENT]
+ title = (
+ "Track Activity Heatmap\n"
+ "(Gray = Absent, Black = Unclassified, Green = Classified)"
+ )
+ bounds = [0, 1, 2, 3]
+ num_colors = 3
+ tick_positions = [0.5, 1.5, 2.5]
+ tick_labels = ["Absent", "Unclassified", "Classified"]
+
+ # Create colormap and imshow
+ cmap = plt.matplotlib.colors.ListedColormap(colors, N=num_colors)
+ norm = plt.matplotlib.colors.BoundaryNorm(bounds, cmap.N)
+
+ im = ax.imshow(
+ combined_matrix,
+ cmap=cmap,
+ aspect="auto",
+ interpolation="nearest",
+ norm=norm,
+ )
+
+ return im, title, tick_positions, tick_labels
+
+
+def _setup_heatmap_plot(
+ heatmap_matrix: np.ndarray,
+ num_tracks: int,
+ num_frames: int,
+ alarm_matrix: Optional[np.ndarray] = None,
+):
+ """
+ Set up the matplotlib plot for the heatmap with appropriate colors.
+
+ Args:
+ heatmap_matrix: 2D numpy array with track presence data
+ num_tracks: Number of unique tracks
+ num_frames: Number of frames
+ alarm_matrix: Optional 2D numpy array with alarm data. If provided, uses 3-color scheme.
+
+ Returns:
+ Tuple of (axes, image, tick positions, tick labels) from matplotlib
+ """
+ height = max(8, num_tracks * 0.4)
+ width = max(12, num_frames * 0.1)
+ _, ax = plt.subplots(figsize=(width, height))
+
+ # Create combined matrix: 0=absent, 1=present&unclassified, 2=present&classified, 3=alarm
+ # (if alarm_matrix provided)
+ if alarm_matrix is not None:
+ combined_matrix = _combine_heatmap_and_alarm_matrices(
+ heatmap_matrix, alarm_matrix
+ )
+ show_alarm_colors = True
+ else:
+ combined_matrix = heatmap_matrix
+ show_alarm_colors = False
+
+ # Create the imshow plot with appropriate colormap settings and get title + tick info
+ im, title, tick_positions, tick_labels = _create_heatmap_imshow(
+ ax, combined_matrix, show_alarm_colors
+ )
+
+ ax.set_xlabel("Time (Frames with Observations)")
+ ax.set_ylabel("Track ID")
+ ax.set_title(title)
+
+ return ax, im, tick_positions, tick_labels
+
+
+@dataclass
+class HeatmapData: # pylint: disable=too-many-instance-attributes
+ """Container for processed heatmap data and statistics."""
+
+ track_data: TrackData
+ heatmap_matrix: np.ndarray
+ alarm_matrix: Optional[np.ndarray]
+ alarm_tracks: Set[str]
+ alarm_threshold: float
+ num_tracks: int
+ num_frames: int
+ frames_with_activity: int
+ activity_percentage: float
+
+
+def process_heatmap_data(
+ track_data: TrackData,
+ alarm_threshold: float = float("inf"),
+) -> Optional[HeatmapData]:
+ """
+ Process track data and calculate heatmap matrices and statistics.
+
+ This function processes track data to create:
+ 1. Base heatmap matrix: Track presence over time
+ 2. Alarm matrix: Tracks that exceed time-in-area threshold (optional)
+ 3. Statistics: Activity percentages and alarm counts
+
+ Args:
+ track_data: TrackData object containing frames and track information
+ alarm_threshold: Time threshold in seconds for alarm calculation (default: inf = no alarms)
+
+ Returns:
+ HeatmapData object containing processed matrices and statistics, or None if no data
+ """
+ if not track_data.frames:
+ return None
+
+ if not track_data.all_track_ids:
+ return None
+
+ sorted_track_ids = sorted(track_data.all_track_ids)
+ num_tracks = len(sorted_track_ids)
+ num_frames = len(track_data.frames)
+
+ heatmap_matrix = _create_heatmap_matrix(track_data.frames, sorted_track_ids)
+
+ # Only create alarm matrix if user requested alarm calculation
+ alarm_matrix = None
+ alarm_tracks = set()
+ if alarm_threshold != float("inf"):
+ alarm_matrix = _create_alarm_matrix(
+ track_data.frames, sorted_track_ids, alarm_threshold
+ )
+
+ # Find tracks that have at least one alarm
+ for track_idx, track_id in enumerate(sorted_track_ids):
+ if np.any(alarm_matrix[track_idx, :] > 0):
+ alarm_tracks.add(track_id)
+
+ # Print alarm tracks to stdout for Telegraf comparison with class info and observation count
+ if alarm_tracks:
+ print(f"\nTracks with alarms (>= {alarm_threshold}s):")
+ for track_id in sorted(alarm_tracks):
+ class_type = track_data.track_class_map.get(track_id, "Unknown")
+ # Count alarm occurrences for this track
+ track_idx = sorted_track_ids.index(track_id)
+ alarm_count = int(np.sum(alarm_matrix[track_idx, :] > 0))
+ # Print track ID on its own line
+ print(f" {track_id}")
+ # Print additional info on next line
+ print(f" Class: {class_type}, Alarms: {alarm_count}")
+ else:
+ print(f"\nNo tracks exceeded alarm threshold of {alarm_threshold}s")
+
+ # Calculate statistics
+ frames_with_activity = np.sum(np.sum(heatmap_matrix, axis=0) >= 1)
+ activity_percentage = (
+ (frames_with_activity / num_frames) * 100 if num_frames > 0 else 0
+ )
+
+ return HeatmapData(
+ track_data=track_data,
+ heatmap_matrix=heatmap_matrix,
+ alarm_matrix=alarm_matrix,
+ alarm_tracks=alarm_tracks,
+ alarm_threshold=alarm_threshold,
+ num_tracks=num_tracks,
+ num_frames=num_frames,
+ frames_with_activity=frames_with_activity,
+ activity_percentage=activity_percentage,
+ )
+
+
+def _format_track_labels_for_yaxis(
+ track_ids: List[str], track_class_map: Dict[str, str]
+) -> List[str]:
+ """
+ Format track IDs with shortened class names for y-axis labels.
+
+ Args:
+ track_ids: List of track IDs to format
+ track_class_map: Dictionary mapping track IDs to class names
+
+ Returns:
+ List of formatted track labels in "track_id (class)" format
+
+ Examples:
+ >>> track_ids = ["track_001", "track_002", "track_003"]
+ >>> class_map = {"track_001": "Human", "track_002": "Vehicle", "track_003": "Unknown"}
+ >>> _format_track_labels_for_yaxis(track_ids, class_map)
+ ['track_001 (Huma.)', 'track_002 (Vehi.)', 'track_003 (Unkn.)']
+
+ >>> _format_track_labels_for_yaxis(["track_001"], {"track_001": "Cat"})
+ ['track_001 (Cat)']
+
+ >>> _format_track_labels_for_yaxis(["track_001"], {})
+ ['track_001 (Unkn.)']
+ """
+ y_labels = []
+ for track_id in track_ids:
+ class_type = track_class_map.get(track_id, "Unknown")
+ if len(class_type) > 4:
+ class_short = class_type[:4] + "."
+ else:
+ class_short = class_type
+ y_labels.append(f"{track_id} ({class_short})")
+ return y_labels
+
+
+def _format_timestamps_for_xaxis(frames: List[Frame], x_ticks: range) -> List[str]:
+ """
+ Format timestamps from frames to show just time (HH:MM:SS) for x-axis labels.
+
+ Args:
+ frames: List of Frame objects with timestamp data
+ x_ticks: Range of frame indices to format
+
+ Returns:
+ List of formatted time strings in HH:MM:SS format
+
+ Examples:
+ >>> from datetime import datetime
+ >>> frames = [
+ ... Frame(1, "2024-01-01T10:30:00.123Z", []),
+ ... Frame(2, "2024-01-01T10:31:00.456Z", []),
+ ... Frame(3, "2024-01-01T10:32:00.789Z", [])
+ ... ]
+ >>> _format_timestamps_for_xaxis(frames, range(0, 3))
+ ['10:30:00', '10:31:00', '10:32:00']
+
+ >>> _format_timestamps_for_xaxis(frames, range(0, 3, 2))
+ ['10:30:00', '10:32:00']
+
+ >>> _format_timestamps_for_xaxis(frames, range(1, 2))
+ ['10:31:00']
+ """
+ x_labels = []
+ for i in x_ticks:
+ timestamp_str = frames[i].timestamp
+ # Parse ISO timestamp and format as HH:MM:SS
+ dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ x_labels.append(dt.strftime("%H:%M:%S"))
+ return x_labels
+
+
+def render_heatmap(
+ heatmap_data: HeatmapData,
+) -> None:
+ """
+ Render the heatmap visualization using matplotlib.
+
+ Args:
+ heatmap_data: Processed heatmap data and statistics
+ """
+ # Set up heatmap image the plot
+ ax, im, tick_positions, tick_labels = _setup_heatmap_plot(
+ heatmap_data.heatmap_matrix,
+ heatmap_data.num_tracks,
+ heatmap_data.num_frames,
+ heatmap_data.alarm_matrix,
+ )
+
+ # Set y-axis labels (track IDs with class information)
+ ax.set_yticks(range(heatmap_data.num_tracks))
+ sorted_track_ids = sorted(heatmap_data.track_data.all_track_ids)
+ y_labels = _format_track_labels_for_yaxis(
+ sorted_track_ids, heatmap_data.track_data.track_class_map
+ )
+ ax.set_yticklabels(y_labels)
+
+ ax.tick_params(axis="y", labelsize=9) # Smaller font size for better fit
+ plt.setp(ax.get_yticklabels(), ha="right") # Right-align labels
+
+ # Set x-axis labels (timestamps)
+ step = max(1, heatmap_data.num_frames // 20) # Show ~20 labels max
+ x_ticks = range(0, heatmap_data.num_frames, step)
+ x_labels = _format_timestamps_for_xaxis(heatmap_data.track_data.frames, x_ticks)
+ ax.set_xticks(x_ticks)
+ ax.set_xticklabels(x_labels, rotation=45)
+
+ # Add grid for better readability
+ ax.set_xticks(np.arange(-0.5, heatmap_data.num_frames, 1), minor=True)
+ ax.set_yticks(np.arange(-0.5, heatmap_data.num_tracks, 1), minor=True)
+ ax.grid(which="minor", color="white", linestyle="-", linewidth=0.5)
+
+ # Add colorbar legend using tick information from setup function
+ cbar = plt.colorbar(im, ax=ax, shrink=0.6)
+ cbar.set_ticks(tick_positions)
+ cbar.set_ticklabels(tick_labels)
+
+ # Render statistics text overlay
+ stats_text = (
+ f"Tracks: {heatmap_data.num_tracks} | Frames: {heatmap_data.num_frames} | "
+ f"Activity: {heatmap_data.activity_percentage:.1f}%"
+ + (
+ f" | Alarms: {len(heatmap_data.alarm_tracks)}"
+ if heatmap_data.alarm_threshold != float("inf")
+ else ""
+ )
+ )
+ ax.text(
+ 0.02,
+ 0.98,
+ stats_text,
+ transform=ax.transAxes,
+ bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8},
+ verticalalignment="top",
+ fontsize=10,
+ )
+
+ # Adjust layout to ensure Y-axis labels are fully visible with more padding
+ plt.subplots_adjust(left=0.15, right=0.85, top=0.92, bottom=0.15)
+ plt.show()
+
+
+@click.command()
+@click.argument("input_file", type=click.Path(exists=True))
+@click.option(
+ "--verbose",
+ "-v",
+ is_flag=True,
+ help="Enable verbose output with detailed statistics.",
+)
+@click.option(
+ "--alarm-threshold",
+ "-a",
+ type=float,
+ default=float("inf"),
+ help="Time threshold in seconds for alarm visualization "
+ "(tracks exceeding this show in red).",
+)
+@click.option(
+ "--no-ui",
+ is_flag=True,
+ help="Disable matplotlib GUI display (useful for CI/CD and headless environments).",
+)
+def main(input_file: str, verbose: bool, alarm_threshold: float, no_ui: bool):
+ """
+ Create a heatmap visualization of track activity over time.
+
+ Args:
+ input_file: Path to JSONL file containing frame data with track IDs
+ verbose: Enable verbose output with detailed statistics
+ alarm_threshold: Time threshold in seconds for alarm visualization
+ no_ui: Disable matplotlib GUI display (useful for CI/CD and headless environments)
+
+ INPUT_FILE should be a JSONL file containing frame data with track IDs,
+ such as the output from FixedIT Data Agent analytics or test data files.
+
+ Examples:
+ # Display heatmap interactively
+ python track_heatmap_viewer.py test_files/simple_tracks.jsonl
+
+ # Verbose output with statistics
+ python track_heatmap_viewer.py test_files/simple_tracks.jsonl --verbose
+
+ # Show alarms for tracks exceeding 5 seconds
+ python track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 5.0
+
+ # Run in headless mode (no GUI display)
+ python track_heatmap_viewer.py test_files/simple_tracks.jsonl --alarm-threshold 2.0 --no-ui
+ """
+ try:
+ click.echo(f"Loading track data from: {input_file}")
+
+ # Parse the input file
+ track_data = parse_jsonl_file(Path(input_file))
+ frames = track_data.frames
+ all_track_ids = track_data.all_track_ids
+
+ if verbose:
+ click.echo("\nDataset Statistics:")
+ click.echo(f" Total frames: {len(frames)}")
+ click.echo(f" Unique tracks: {len(all_track_ids)}")
+
+ if frames:
+ first_timestamp = frames[0].timestamp
+ last_timestamp = frames[-1].timestamp
+ click.echo(f" Time range: {first_timestamp} to {last_timestamp}")
+
+ if all_track_ids:
+ click.echo(f" Track IDs: {', '.join(sorted(all_track_ids))}")
+
+ # Process the heatmap data
+ heatmap_data = process_heatmap_data(track_data, alarm_threshold)
+
+ if heatmap_data is None:
+ click.echo("No data available for visualization.")
+ return
+
+ # Render the heatmap if UI is enabled
+ if not no_ui:
+ render_heatmap(heatmap_data)
+ click.echo("\nClose the plot window to exit.")
+
+ except (OSError, ValueError) as e:
+ click.echo(f"Error: {e}", err=True)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main() # pylint: disable=no-value-for-parameter
diff --git a/project-time-in-area-analytics/test_scripts/visualize_zone.py b/project-time-in-area-analytics/test_scripts/visualize_zone.py
new file mode 100755
index 0000000..7cd37e0
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/visualize_zone.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+"""
+Visualize AXIS Object Analytics zones on camera images.
+
+This script takes zone vertices in normalized coordinates [-1, 1] and draws
+them on a camera image for visualization.
+"""
+
+import json
+import random
+
+import click
+import cv2
+import numpy as np
+
+
+def edge_crosses_horizontal_line(y, y1, y2):
+ """Check if an edge crosses the horizontal line at y."""
+ return (y1 > y) != (y2 > y)
+
+
+def calculate_edge_x_at_y(x1, y1, x2, y2, y):
+ """Calculate the x-coordinate where the edge intersects the horizontal line at y."""
+ return (x2 - x1) * (y - y1) / (y2 - y1) + x1
+
+
+def ray_intersects_edge(
+ x, y, x1, y1, x2, y2
+): # pylint: disable=too-many-arguments,too-many-positional-arguments
+ """
+ Check if a ray cast from point (x,y) to the right intersects the edge.
+
+ Args:
+ x, y: Point coordinates
+ x1, y1: First vertex of edge
+ x2, y2: Second vertex of edge
+
+ Returns:
+ True if the rightward ray from (x,y) intersects the edge
+ """
+ if not edge_crosses_horizontal_line(y, y1, y2):
+ return False
+
+ edge_x = calculate_edge_x_at_y(x1, y1, x2, y2, y)
+ return x < edge_x
+
+
+def is_in_zone(x, y, vertices):
+ """
+ Check if a point (x, y) is inside a polygon defined by vertices.
+ Uses ray tracing algorithm: cast a ray to the right and count intersections.
+
+ This implementation uses only basic Python for easy porting to Starlark.
+
+ Args:
+ x: X coordinate of the point (normalized, -1 to 1)
+ y: Y coordinate of the point (normalized, -1 to 1)
+ vertices: List of [x, y] vertices defining the polygon
+
+ Returns:
+ True if point is inside the polygon, False otherwise
+ """
+ num_vertices = len(vertices)
+ inside = False
+
+ # Check each edge of the polygon
+ j = num_vertices - 1 # Start with the last vertex
+ for i in range(num_vertices):
+ xi, yi = vertices[i]
+ xj, yj = vertices[j]
+
+ if ray_intersects_edge(x, y, xi, yi, xj, yj):
+ inside = not inside
+
+ j = i
+
+ return inside
+
+
+def normalize_to_pixel(x, y, width, height):
+ """
+ Convert normalized coordinates [-1, 1] to pixel coordinates.
+
+ In AXIS coordinate system:
+ - x: -1 (left) to 1 (right)
+ - y: -1 (bottom) to 1 (top)
+
+ In image pixel coordinates:
+ - x: 0 (left) to width (right)
+ - y: 0 (top) to height (bottom)
+
+ Args:
+ x: Normalized x coordinate in range [-1, 1]
+ y: Normalized y coordinate in range [-1, 1]
+ width: Image width in pixels
+ height: Image height in pixels
+
+ Returns:
+ Tuple of (pixel_x, pixel_y)
+ """
+ # Convert from [-1, 1] to [0, width] and [0, height]
+ # Note: Y axis is inverted (y=-1 is bottom, but pixel y=0 is top)
+ pixel_x = int((x + 1) * width / 2)
+ pixel_y = int((1 - y) * height / 2)
+ return pixel_x, pixel_y
+
+
+def draw_zone_on_image(
+ img, vertices_list, color_bgr, thickness, fill_alpha
+): # pylint: disable=too-many-locals
+ """
+ Draw a zone polygon on the image with semi-transparent fill.
+
+ Args:
+ img: OpenCV image (modified in place)
+ vertices_list: List of [x, y] normalized vertices
+ color_bgr: Tuple of (B, G, R) color values
+ thickness: Line thickness for polygon outline
+ fill_alpha: Transparency for fill (0.0-1.0)
+
+ Returns:
+ List of pixel coordinates for the vertices
+ """
+ height, width = img.shape[:2]
+
+ # Convert normalized coordinates to pixel coordinates
+ pixel_points = []
+ for vertex in vertices_list:
+ x, y = vertex
+ px, py = normalize_to_pixel(x, y, width, height)
+ pixel_points.append([px, py])
+
+ # Convert to numpy array for OpenCV
+ pts = np.array(pixel_points, dtype=np.int32)
+
+ # Create overlay for semi-transparent fill
+ overlay = img.copy()
+ cv2.fillPoly(overlay, [pts], color_bgr)
+
+ # Blend overlay with original image
+ cv2.addWeighted(overlay, fill_alpha, img, 1 - fill_alpha, 0, img)
+
+ # Draw polygon outline
+ cv2.polylines(img, [pts], isClosed=True, color=color_bgr, thickness=thickness)
+
+ # Draw vertices as circles
+ for point in pixel_points:
+ cv2.circle(img, tuple(point), radius=5, color=color_bgr, thickness=-1)
+
+ return pixel_points
+
+
+def draw_test_points_on_image(img, vertices_list, num_points):
+ """
+ Draw random test points on the image to visualize the is_in_zone algorithm.
+
+ Args:
+ img: OpenCV image (modified in place)
+ vertices_list: List of [x, y] normalized vertices defining the zone
+ num_points: Number of random points to generate
+ """
+ height, width = img.shape[:2]
+ random.seed(42) # For reproducible results
+
+ inside_count = 0
+ outside_count = 0
+
+ for _ in range(num_points):
+ # Generate random normalized coordinates
+ test_x = random.uniform(-1.0, 1.0)
+ test_y = random.uniform(-1.0, 1.0)
+
+ # Check if point is inside zone
+ is_inside = is_in_zone(test_x, test_y, vertices_list)
+
+ # Convert to pixel coordinates
+ test_px, test_py = normalize_to_pixel(test_x, test_y, width, height)
+
+ # Choose color: red for inside, yellow for outside
+ if is_inside:
+ point_color = (0, 0, 255) # Red (BGR)
+ inside_count += 1
+ else:
+ point_color = (0, 255, 255) # Yellow (BGR)
+ outside_count += 1
+
+ # Draw the point
+ cv2.circle(img, (test_px, test_py), radius=8, color=point_color, thickness=-1)
+ cv2.circle(img, (test_px, test_py), radius=8, color=(0, 0, 0), thickness=1)
+
+ # Add legend
+ legend_y = 60
+ cv2.putText(
+ img,
+ f"Test points: {num_points}",
+ (10, legend_y),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.6,
+ (255, 255, 255),
+ 2,
+ cv2.LINE_AA,
+ )
+ cv2.circle(img, (20, legend_y + 25), radius=8, color=(0, 0, 255), thickness=-1)
+ cv2.putText(
+ img,
+ f"Inside: {inside_count}",
+ (35, legend_y + 30),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.5,
+ (255, 255, 255),
+ 1,
+ cv2.LINE_AA,
+ )
+ cv2.circle(img, (20, legend_y + 50), radius=8, color=(0, 255, 255), thickness=-1)
+ cv2.putText(
+ img,
+ f"Outside: {outside_count}",
+ (35, legend_y + 55),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.5,
+ (255, 255, 255),
+ 1,
+ cv2.LINE_AA,
+ )
+
+
+@click.command()
+@click.option(
+ "--vertices",
+ "-v",
+ required=True,
+ help="Zone vertices as JSON array, e.g., '[[-0.97,-0.97],[-0.97,0.97],[0.12,0.96]]'",
+)
+@click.option(
+ "--image",
+ "-i",
+ required=True,
+ type=click.Path(exists=True),
+ help="Path to the image file",
+)
+@click.option(
+ "--save-to", "-s", type=click.Path(), help="Save the output image to this path"
+)
+@click.option(
+ "--no-show", is_flag=True, help="Do not display the image (useful with --save-to)"
+)
+@click.option(
+ "--color",
+ "-c",
+ default="0,255,0",
+ help="Polygon color in BGR format (default: 0,255,0 for green)",
+)
+@click.option(
+ "--thickness",
+ "-t",
+ default=3,
+ type=int,
+ help="Line thickness in pixels (default: 3)",
+)
+@click.option(
+ "--fill-alpha",
+ "-a",
+ default=0.3,
+ type=float,
+ help="Fill transparency (0.0-1.0, default: 0.3)",
+)
+@click.option(
+ "--add-random-points",
+ "-r",
+ type=int,
+ help="Add N random test points (red=inside zone, yellow=outside zone)",
+)
+def visualize_zone(
+ vertices, image, save_to, no_show, color, thickness, fill_alpha, add_random_points
+):
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
+ """
+ Visualize AXIS Object Analytics zone on a camera image.
+
+ By default, the visualization is displayed on screen. Use --save-to to save
+ and --no-show to skip the display.
+
+ Example usage:
+
+ # Display only (default)
+ python visualize_zone.py -v '[[-0.97,-0.97],[-0.97,0.97],[-0.12,0.96]]' -i snapshot.jpg
+
+ # Display and save
+ python visualize_zone.py -v '[...]' -i snapshot.jpg --save-to output.jpg
+
+ # Save only (no display)
+ python visualize_zone.py -v '[...]' -i snapshot.jpg --save-to output.jpg --no-show
+
+ # Test the is_in_zone algorithm with random points
+ python visualize_zone.py -v '[...]' -i snapshot.jpg --add-random-points 50
+ """
+ try:
+ # Parse vertices JSON
+ vertices_list = json.loads(vertices)
+ if not isinstance(vertices_list, list) or len(vertices_list) < 3:
+ raise ValueError("Vertices must be a list with at least 3 points")
+
+ # Validate vertex format
+ for vertex in vertices_list:
+ if len(vertex) != 2:
+ raise ValueError(f"Invalid vertex format: {vertex}")
+
+ # Parse color
+ color_bgr = tuple(map(int, color.split(",")))
+ if len(color_bgr) != 3:
+ raise ValueError("Color must be in BGR format: B,G,R")
+
+ # Load image
+ img = cv2.imread(image)
+ if img is None:
+ raise ValueError(f"Failed to load image: {image}")
+
+ height, width = img.shape[:2]
+
+ # Draw the zone polygon on the image
+ pixel_points = draw_zone_on_image(
+ img, vertices_list, color_bgr, thickness, fill_alpha
+ )
+
+ # Add zone info text
+ text = f"Zone: {len(pixel_points)} vertices"
+ cv2.putText(
+ img,
+ text,
+ (10, 30),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.7,
+ color_bgr,
+ 2,
+ cv2.LINE_AA,
+ )
+
+ # Draw random test points if requested
+ if add_random_points:
+ draw_test_points_on_image(img, vertices_list, add_random_points)
+
+ # Save if requested
+ if save_to:
+ cv2.imwrite(save_to, img)
+ click.echo(f"✓ Saved visualization to: {save_to}")
+
+ # Display unless --no-show is specified
+ if not no_show:
+ window_name = "Zone Visualization (press any key to close)"
+ cv2.imshow(window_name, img)
+ click.echo("✓ Displaying image. Press any key to close...")
+ cv2.waitKey(0)
+ cv2.destroyAllWindows()
+
+ # Print zone info
+ click.echo("\n📊 Zone Information:")
+ click.echo(f" Image size: {width}x{height}")
+ click.echo(f" Vertices: {len(pixel_points)}")
+ click.echo("\n Normalized → Pixel coordinates:")
+ for i, (norm_pt, pixel_pt) in enumerate(zip(vertices_list, pixel_points)):
+ click.echo(
+ f" [{i}] ({norm_pt[0]:+.4f}, {norm_pt[1]:+.4f}) → ({pixel_pt[0]}, {pixel_pt[1]})"
+ )
+
+ except json.JSONDecodeError as e:
+ click.echo(f"✗ Error parsing vertices JSON: {e}", err=True)
+ raise click.Abort()
+
+
+if __name__ == "__main__":
+ visualize_zone() # pylint: disable=no-value-for-parameter
diff --git a/project-time-in-area-analytics/test_scripts/visualize_zone_tests.py b/project-time-in-area-analytics/test_scripts/visualize_zone_tests.py
new file mode 100644
index 0000000..c212384
--- /dev/null
+++ b/project-time-in-area-analytics/test_scripts/visualize_zone_tests.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+Extract center coordinates from bounding boxes in detection data.
+
+Parses JSON detection frames from a JSONL file and outputs the center point (cx, cy)
+for each detection in both coordinate systems:
+- [0, 1]: Detection bounding box coordinate system
+- [-1, 1]: AXIS normalized coordinate system (zone polygon system)
+
+Dependencies:
+- click: Command-line interface framework
+- prettytable: ASCII table formatting
+- matplotlib: For zone and detection visualization
+- numpy: For numerical operations
+"""
+
+import json
+import re
+import sys
+from pathlib import Path
+
+import click
+import cv2
+import numpy as np
+from prettytable import PrettyTable
+
+# Import visualization functions from visualize_zone
+sys.path.insert(0, str(Path(__file__).parent))
+from visualize_zone import ( # noqa: E402 pylint: disable=wrong-import-position
+ draw_zone_on_image,
+ normalize_to_pixel,
+)
+
+
+def parse_json(data_str):
+ """Parse JSONL data and return list of observation dicts."""
+ observations = []
+ for line in data_str.strip().split("\n"):
+ line = line.strip()
+ if line and not line.startswith("#"):
+ frame_data = json.loads(line)
+ observations.extend(frame_data["frame"]["observations"])
+
+ return observations
+
+
+def parse_zone_from_file(filepath):
+ """Extract zone polygons from JSONL file comments.
+
+ Expected format: # Use zone: [[[x1,y1],[x2,y2],...]] (array of zones)
+
+ Returns:
+ Tuple of (zones_list, bbox_tuple) where:
+ - zones_list: List of zones, where each zone is a list of [x, y] coordinates
+ - bbox_tuple: (x_min, x_max, y_min, y_max) for visualization (covering all zones)
+
+ Raises:
+ ValueError: If zone definition comment is not found in file.
+ """
+ # pylint: disable=too-many-nested-blocks
+ with open(filepath, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line.startswith("# Use zone:"):
+ # Parse the JSON array of zones format: [[[x1,y1],[x2,y2],...]]
+ match = re.search(r"# Use zone:\s*(\[\[\[.*\]\]\])", line)
+
+ if match:
+ try:
+ zone_str = match.group(1)
+ # Parse JSON array of zones
+ zones = json.loads(zone_str)
+
+ # Validate zones format
+ if not isinstance(zones, list) or len(zones) == 0:
+ raise ValueError(
+ "Expected array of zones with at least one zone"
+ )
+
+ # Validate each zone
+ for i, zone in enumerate(zones):
+ if not isinstance(zone, list) or len(zone) < 3:
+ raise ValueError(
+ f"Zone {i} must have at least 3 vertices"
+ )
+
+ # Calculate bounding box covering all zones
+ all_x_coords = []
+ all_y_coords = []
+ for zone in zones:
+ all_x_coords.extend([v[0] for v in zone])
+ all_y_coords.extend([v[1] for v in zone])
+
+ bbox = (
+ min(all_x_coords),
+ max(all_x_coords),
+ min(all_y_coords),
+ max(all_y_coords),
+ )
+
+ return zones, bbox
+ except (
+ ValueError, # parent to json.JSONDecodeError
+ TypeError,
+ IndexError,
+ ) as e:
+ raise ValueError(
+ "Failed to parse zone polygon.\n"
+ "Expected format: '# Use zone: [[[x1,y1],[x2,y2],...]]' "
+ "(array of zones)\n"
+ f"Got: '{line}'\n"
+ f"Error: {e}"
+ ) from e
+ else:
+ raise ValueError(
+ "Zone definition format mismatch.\n"
+ "Expected format: '# Use zone: [[[x1,y1],[x2,y2],...]]' (array of zones)\n"
+ f"Got: '{line}'"
+ )
+
+ raise ValueError(
+ f"Zone definition not found in {filepath}.\n"
+ "File must contain a comment line with format:\n"
+ "# Use zone: [[x1,y1],[x2,y2],...] in [-1, 1] range"
+ )
+
+
+def draw_detections_on_image(
+ img, observations, detection_labels
+): # pylint: disable=too-many-locals
+ """Draw detection centers on image with labels.
+
+ Args:
+ img: OpenCV image (modified in place)
+ observations: List of observation dictionaries with bounding_box
+ detection_labels: List of detection labels (a, b, c, etc.)
+ """
+ height, width = img.shape[:2]
+
+ for _, (obs, label) in enumerate(zip(observations[:26], detection_labels)):
+ bbox = obs["bounding_box"]
+ # Convert [0,1] bounding box to [-1,1] for consistency
+ cx_0_1 = (bbox["left"] + bbox["right"]) / 2
+ cy_0_1 = (bbox["top"] + bbox["bottom"]) / 2
+ cx_minus1_1 = cx_0_1 * 2 - 1
+ cy_minus1_1 = 1 - cy_0_1 * 2
+
+ # Convert to pixel coordinates
+ px, py = normalize_to_pixel(cx_minus1_1, cy_minus1_1, width, height)
+
+ # Draw center point
+ color = (0, 255, 0) # Green in BGR
+ cv2.circle(img, (px, py), radius=8, color=color, thickness=-1)
+ cv2.circle(img, (px, py), radius=8, color=(0, 0, 0), thickness=2)
+
+ # Draw label
+ cv2.putText(
+ img,
+ label,
+ (px + 12, py - 5),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.6,
+ color,
+ 2,
+ cv2.LINE_AA,
+ )
+
+
+def visualize_zone_and_detections(observations, zones, output_file=None, display=False):
+ """Create visualization of zone(s) and detections.
+
+ Args:
+ observations: List of observation dictionaries
+ zones: List of zones, where each zone is a list of [x, y] coordinates in [-1,1] range
+ output_file: Optional path to save visualization
+ display: Whether to display the image (requires output_file to be None or file saved first)
+ """
+ # Create blank image (normalized coordinate space visualization)
+ img_size = 600
+ img = (
+ np.ones((img_size, img_size, 3), dtype=np.uint8) * 240
+ ) # Light gray background
+
+ # Draw all zones using different colors
+ zone_colors = [
+ (200, 100, 100), # Blue-ish (BGR)
+ (100, 200, 100), # Green-ish
+ (100, 100, 200), # Red-ish
+ (200, 200, 100), # Cyan-ish
+ (200, 100, 200), # Magenta-ish
+ ]
+
+ for i, zone_vertices in enumerate(zones):
+ color = zone_colors[i % len(zone_colors)]
+ draw_zone_on_image(img, zone_vertices, color, 2, 0.2)
+
+ # Draw detections
+ detection_labels = [chr(ord("a") + i) for i in range(len(observations[:26]))]
+ draw_detections_on_image(img, observations, detection_labels)
+
+ # Add coordinate system labels
+ cv2.putText(img, "-1.0", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
+ cv2.putText(
+ img, "+1.0", (img_size - 50, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1
+ )
+ cv2.putText(
+ img, "-1.0", (10, img_size - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1
+ )
+ cv2.putText(img, "+1.0", (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
+
+ # Save if requested
+ if output_file:
+ cv2.imwrite(output_file, img)
+ click.echo(f"✓ Saved visualization to: {output_file}")
+
+ # Display if requested
+ if display:
+ window_name = "Zone and Detections (press any key to close)"
+ cv2.imshow(window_name, img)
+ click.echo("✓ Displaying visualization. Press any key to close...")
+ cv2.waitKey(0)
+ cv2.destroyAllWindows()
+
+ return img
+
+
+@click.command()
+@click.argument("jsonl_file", type=click.Path(exists=True))
+@click.option(
+ "--no-visualize", is_flag=True, help="Disable visualization (only print table)"
+)
+@click.option(
+ "--save-to",
+ "-s",
+ type=click.Path(),
+ help="Save visualization image to this path instead of displaying",
+)
+def main(jsonl_file, no_visualize, save_to): # pylint: disable=too-many-locals
+ """Extract center coordinates from detections in JSONL_FILE."""
+ with open(jsonl_file, "r", encoding="utf-8") as f:
+ data = f.read()
+
+ observations = parse_json(data)
+
+ if len(observations) > 26:
+ click.echo(
+ f"Warning: Only first 26 detections will be shown (found {len(observations)})",
+ err=True,
+ )
+
+ # Parse zones from file
+ try:
+ zones, _ = parse_zone_from_file(jsonl_file)
+ except ValueError as e:
+ click.echo(f"Error: {e}", err=True)
+ zones = None
+
+ # Create table first
+ table = PrettyTable()
+ table.field_names = [
+ "Detection",
+ "Track ID",
+ "Center X (0...1)",
+ "Center Y (0...1)",
+ "Center X (-1...1)",
+ "Center Y (-1...1)",
+ ]
+ table.align["Track ID"] = "l"
+
+ for idx, obs in enumerate(observations[:26]):
+ bbox = obs["bounding_box"]
+ track_id = obs["track_id"]
+
+ cx_0_1 = (bbox["left"] + bbox["right"]) / 2
+ cy_0_1 = (bbox["top"] + bbox["bottom"]) / 2
+
+ # Convert to [-1, 1] coordinate system
+ cx_minus1_1 = cx_0_1 * 2 - 1
+ cy_minus1_1 = 1 - cy_0_1 * 2
+
+ detection_label = chr(ord("a") + idx)
+
+ table.add_row(
+ [
+ detection_label,
+ track_id,
+ f"{cx_0_1:.2g}",
+ f"{cy_0_1:.2g}",
+ f"{cx_minus1_1:.2g}",
+ f"{cy_minus1_1:.2g}",
+ ]
+ )
+
+ click.echo(table)
+
+ # Create visualization unless disabled
+ if not no_visualize and zones:
+ # Display live by default, save to file if --save-to provided
+ display = not save_to
+ visualize_zone_and_detections(
+ observations, zones, output_file=save_to, display=display
+ )
+
+
+if __name__ == "__main__":
+ main() # pylint: disable=no-value-for-parameter
diff --git a/project-time-in-area-analytics/track_duration_calculator.star b/project-time-in-area-analytics/track_duration_calculator.star
new file mode 100644
index 0000000..a13f007
--- /dev/null
+++ b/project-time-in-area-analytics/track_duration_calculator.star
@@ -0,0 +1,130 @@
+load("time.star", "time")
+load("logging.star", "log")
+
+def parse_timestamp_to_float_seconds(timestamp_str):
+ """Parse ISO 8601 timestamp string to Unix seconds as float
+
+ Returns the timestamp as seconds since Unix epoch (float with microsecond precision).
+
+ Args:
+ timestamp_str: ISO 8601 timestamp string (e.g., "2024-01-15T10:00:03.345678Z")
+
+ Returns:
+ Float representing seconds since Unix epoch with microsecond precision
+ """
+ if "." in timestamp_str:
+ # Split timestamp into seconds and fractional parts
+ parts = timestamp_str.split(".")
+ seconds_part = parts[0]
+ fractional_part = parts[1].rstrip("Z")
+
+ # Parse microseconds (pad to at least 6 digits, then truncate to exactly 6)
+ fractional_part = (fractional_part + "000000")[:6]
+
+ microseconds_fraction = int(fractional_part)
+ timestamp_str = seconds_part + "Z"
+ else:
+ microseconds_fraction = 0
+
+ time_format = "2006-01-02T15:04:05Z"
+ time_obj = time.parse_time(timestamp_str, format=time_format)
+
+ # Convert to float seconds: seconds + (microseconds / 1,000,000)
+ unix_float_seconds = float(time_obj.unix) + float(microseconds_fraction) / 1000000.0
+
+ return unix_float_seconds
+
+def get_time_in_area_seconds(track_id, current_seconds, track_state):
+ """Get the time in area for a track ID and update its last seen time
+
+ Args:
+ track_id: The object tracking ID
+ current_seconds: Current timestamp in Unix seconds
+ track_state: State dictionary for tracking objects
+
+ Returns:
+ Time in area as a float (seconds with microsecond precision)
+ """
+ if track_id not in track_state:
+ # First time seeing this track ID - initialize with current timestamp
+ track_state[track_id] = {
+ "first_seen_seconds": current_seconds,
+ "last_seen_seconds": current_seconds
+ }
+ log.debug("get_time_in_area_seconds: track_id=" + track_id + " first detection at " + str(current_seconds))
+ return 0 # Time in area is 0 on first detection
+
+ # Update last seen time
+ track_state[track_id]["last_seen_seconds"] = current_seconds
+
+ # Calculate time in area - simple subtraction since both are in seconds
+ first_seen_seconds = track_state[track_id]["first_seen_seconds"]
+ time_in_area = current_seconds - first_seen_seconds
+ log.debug("get_time_in_area_seconds: track_id=" + track_id + " duration=" + str(time_in_area) + "s (first_seen=" + str(first_seen_seconds) + ", current=" + str(current_seconds) + ")")
+ return time_in_area
+
+def cleanup_stale_tracks(current_seconds, track_state, max_stale_seconds):
+ """Remove tracks that haven't been seen for too long
+
+ Args:
+ current_seconds: Current timestamp in Unix seconds
+ track_state: State dictionary for tracking objects
+ max_stale_seconds: Maximum time since last seen before removing track
+ """
+ # Find tracks to remove (can't modify dict while iterating)
+ tracks_to_remove = []
+
+ for track_id, track_data in track_state.items():
+ last_seen_seconds = track_data["last_seen_seconds"]
+ time_since_seen = current_seconds - last_seen_seconds
+
+ if time_since_seen > max_stale_seconds:
+ tracks_to_remove.append(track_id)
+
+ # Remove stale tracks
+ if len(tracks_to_remove) > 0:
+ log.debug("cleanup_stale_tracks: Removing " + str(len(tracks_to_remove)) + " stale track(s): " + str(tracks_to_remove))
+ for track_id in tracks_to_remove:
+ track_state.pop(track_id)
+
+def apply(metric):
+ """Calculate the time in area for each metric.
+
+ This function will be called for each metric in the pipeline,
+ the function will keep a state of all the track IDs and their
+ first and last seen times. The function will calculate the time
+ in area for each track ID and add it to the metric.
+
+ Returns:
+ The input metric but with the time in area added.
+ """
+ # Get track_id and timestamp from the metric
+ track_id = metric.fields.get("track_id", "")
+ timestamp = metric.fields.get("timestamp", "")
+
+ # Skip messages without track_id
+ if track_id == "" or timestamp == "":
+ return metric
+
+ # Parse timestamp to float seconds since Unix epoch
+ current_seconds = parse_timestamp_to_float_seconds(timestamp)
+
+ # Initialize track state subdict if it doesn't exist
+ if "track_state" not in state:
+ state["track_state"] = {}
+
+ # Clean up stale tracks (not seen for 60 seconds)
+ cleanup_stale_tracks(current_seconds, state["track_state"], 60)
+
+ # Get the time in area for this track ID
+ time_in_area = get_time_in_area_seconds(track_id, current_seconds, state["track_state"])
+
+ # Create a new metric with duration calculated name (before adding fields)
+ duration_metric = deepcopy(metric)
+ duration_metric.name = "detection_frame_with_duration"
+
+ # Add the time in area to the new metric (filtering happens in next processor)
+ duration_metric.fields["time_in_area_seconds"] = time_in_area
+
+ # Return the new metric with the time in area added
+ return duration_metric
diff --git a/project-time-in-area-analytics/zone_filter.star b/project-time-in-area-analytics/zone_filter.star
new file mode 100644
index 0000000..e2b736e
--- /dev/null
+++ b/project-time-in-area-analytics/zone_filter.star
@@ -0,0 +1,272 @@
+load("logging.star", "log")
+load("json.star", "json")
+
+def edge_crosses_horizontal_line(y, y1, y2):
+ """Check if an edge crosses the horizontal line at y."""
+ return (y1 > y) != (y2 > y)
+
+
+def calculate_edge_x_at_y(x1, y1, x2, y2, y):
+ """Calculate the x-coordinate where the edge intersects the horizontal line at y."""
+ return (x2 - x1) * (y - y1) / (y2 - y1) + x1
+
+
+def ray_intersects_edge(x, y, x1, y1, x2, y2):
+ """
+ Check if a ray cast from point (x,y) to the right intersects the edge.
+
+ Args:
+ x, y: Point coordinates
+ x1, y1: First vertex of edge
+ x2, y2: Second vertex of edge
+
+ Returns:
+ True if the rightward ray from (x,y) intersects the edge
+ """
+ if not edge_crosses_horizontal_line(y, y1, y2):
+ return False
+
+ edge_x = calculate_edge_x_at_y(x1, y1, x2, y2, y)
+ return x < edge_x
+
+
+def is_point_in_polygon(x, y, vertices):
+ """
+ Check if a point (x, y) is inside a polygon defined by vertices.
+ Uses ray tracing algorithm: cast a ray to the right and count intersections.
+
+ This implementation uses only basic operations for easy porting to Starlark.
+
+ Args:
+ x: X coordinate of the point (normalized, 0 to 1 range for detection coords)
+ y: Y coordinate of the point (normalized, 0 to 1 range for detection coords)
+ vertices: List of [x, y] vertices defining the polygon (in detection coords)
+
+ Returns:
+ True if point is inside the polygon, False otherwise
+ """
+ num_vertices = len(vertices)
+ inside = False
+
+ # Check each edge of the polygon
+ j = num_vertices - 1 # Start with the last vertex
+ for i in range(num_vertices):
+ xi, yi = vertices[i]
+ xj, yj = vertices[j]
+
+ if ray_intersects_edge(x, y, xi, yi, xj, yj):
+ inside = not inside
+
+ j = i
+
+ return inside
+
+
+def normalize_vertex_to_detection_coords(vertex):
+ """
+ Convert zone vertices from normalized AXIS coordinates [-1, 1] to detection coords [0, 1].
+
+ In AXIS coordinate system:
+ - x: -1 (left) to 1 (right)
+ - y: -1 (bottom) to 1 (top)
+
+ In detection bounding box coordinates:
+ - x: 0 (left) to 1 (right)
+ - y: 0 (top) to 1 (bottom)
+
+ Args:
+ vertex: [x, y] in AXIS normalized coordinates [-1, 1]
+
+ Returns:
+ [x, y] in detection coordinates [0, 1]
+ """
+ x, y = vertex
+ x_normalized = (x + 1) / 2.0
+ y_normalized = (1 - y) / 2.0
+ return [x_normalized, y_normalized]
+
+
+def get_bounding_box_center(bbox_left, bbox_right, bbox_top, bbox_bottom):
+ """
+ Calculate the center point of a bounding box.
+
+ Args:
+ bbox_left: Left edge of bounding box (0 to 1)
+ bbox_right: Right edge of bounding box (0 to 1)
+ bbox_top: Top edge of bounding box (0 to 1)
+ bbox_bottom: Bottom edge of bounding box (0 to 1)
+
+ Returns:
+ [center_x, center_y] in detection coordinates [0, 1]
+ """
+ center_x = (bbox_left + bbox_right) / 2.0
+ center_y = (bbox_top + bbox_bottom) / 2.0
+ return [center_x, center_y]
+
+
+def parse_zone_polygon_json(json_str):
+ """
+ Parse zone polygon from JSON string using json.decode().
+
+ Supports both formats:
+ - Single polygon: [[-0.6, -0.4], [0.2, -0.4], ...]
+ - Array of zones: [[...], [...], ...]
+
+ Args:
+ json_str: JSON string containing zone polygon(s)
+
+ Returns:
+ List of zones, where each zone is a list of [x, y] vertices in AXIS coords [-1, 1].
+ Returns None if parsing fails.
+ """
+ log.debug("parse_zone_polygon_json: input = " + repr(json_str))
+
+ # Use json.decode with default parameter (None if parsing fails)
+ parsed = json.decode(json_str, default=None)
+
+ if parsed == None:
+ log.error("parse_zone_polygon_json: Failed to decode JSON. Input was: " + repr(json_str) + ".")
+ return None
+
+ if not parsed:
+ log.error("parse_zone_polygon_json: Decoded JSON is empty. Input was: " + repr(json_str))
+ return None
+
+ # Expected format: Array of zones [[[-0.6, -0.4], [0.2, -0.4], ...], [[...], ...]]
+ # Each zone is an array of [x, y] vertices
+
+ # Check if parsed is a list
+ if type(parsed) != "list":
+ log.error("parse_zone_polygon_json: Expected array of zones. Got type: " + str(type(parsed)))
+ return None
+
+ # Check if it's an array of zones (each element should be a list of vertices)
+ if len(parsed) == 0:
+ log.error("parse_zone_polygon_json: Array of zones is empty")
+ return None
+
+ # Validate that first element is a list (zone)
+ if type(parsed[0]) != "list":
+ log.error("parse_zone_polygon_json: Expected array of zones (array of arrays). First element is not a list. Got: " + repr(parsed))
+ return None
+
+ zones = parsed
+ log.debug("parse_zone_polygon_json: Parsed " + str(len(zones)) + " zone(s)")
+ return zones
+
+
+def get_or_parse_zone(state, zone_polygon_json):
+ """
+ Get cached zone or parse and cache a new one from JSON string.
+
+ This function implements lazy parsing and caching to avoid re-parsing
+ the zone on every metric. The zone is cached in Telegraf's shared state dict.
+
+ Args:
+ state: Telegraf's shared state dictionary (persists across apply calls)
+ zone_polygon_json: JSON string containing zone polygon(s)
+
+ Returns:
+ Tuple of (zone_vertices, zone_vertices_normalized) or (None, None) if not configured
+ - zone_vertices: List of [x, y] in AXIS coords [-1, 1]
+ - zone_vertices_normalized: List of [x, y] in detection coords [0, 1]
+ """
+ # Check if zone is already cached
+ if state.get("zone_cached"):
+ log.debug("get_or_parse_zone: Using cached zone")
+ return state.get("zone_vertices"), state.get("zone_vertices_normalized")
+
+ # If no zone JSON provided, return None
+ if zone_polygon_json == "" or zone_polygon_json == None or zone_polygon_json[:2] == "${":
+ # Don't log any error here, instead let the caller use a default (or complain)...
+ return None, None
+
+ # Parse the zone
+ log.debug("get_or_parse_zone: Parsing zone_polygon_json = " + repr(zone_polygon_json))
+ zones = parse_zone_polygon_json(zone_polygon_json)
+
+ if zones == None or len(zones) == 0:
+ log.warn("get_or_parse_zone: Failed to parse zone polygon")
+ return None, None
+
+ if len(zones) > 1:
+ log.error("get_or_parse_zone: Only one zone supported, but got " + str(len(zones)))
+ return None, None
+
+ zone = zones[0]
+
+ if len(zone) < 3:
+ log.error("get_or_parse_zone: Zone must have at least 3 vertices, but got " + str(len(zone)))
+ return None, None
+
+ log.info("get_or_parse_zone: Successfully parsed zone with " + str(len(zone)) + " vertices")
+
+ # Normalize vertices from AXIS coords [-1, 1] to detection coords [0, 1]
+ normalized_vertices = []
+ for vertex in zone:
+ normalized_vertices.append(normalize_vertex_to_detection_coords(vertex))
+
+ # Cache the zone in Telegraf's shared state
+ state["zone_cached"] = True
+ state["zone_vertices"] = zone
+ state["zone_vertices_normalized"] = normalized_vertices
+
+ return zone, normalized_vertices
+
+
+def apply(metric):
+ """
+ Filter detection metrics based on whether their center is inside the configured zone.
+
+ The zone is defined by zone_polygon_json constant (passed via Telegraf config).
+ Only one zone is currently supported.
+
+ Telegraf provides a special 'state' dictionary that persists across apply() calls,
+ which we use to cache the parsed zone and avoid re-parsing on every metric.
+
+ Args:
+ metric: Telegraf metric object
+
+ Returns:
+ The metric renamed to detection_frame_in_zone. If no zone is configured, all metrics
+ pass through. If a zone is configured, only metrics with centers inside the zone pass through.
+ """
+ # Get or parse the zone (uses Telegraf's state dict for caching)
+ zone_vertices, zone_vertices_normalized = get_or_parse_zone(state, zone_polygon_json)
+
+ # If no zone is configured, pass all metrics through with renamed metric name,
+ # but show a warning since it might be unintended.
+ if zone_vertices == None:
+ log.warn("No INCLUDE_ZONE_POLYGON configured - passing all detections through. " +
+ "To explicitly include the entire frame, set INCLUDE_ZONE_POLYGON=" +
+ "[[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]]")
+ pass_through_metric = deepcopy(metric)
+ pass_through_metric.name = "detection_frame_in_zone"
+ return pass_through_metric
+
+ # Extract bounding box coordinates from metric
+ bbox_left = metric.fields.get("bounding_box_left")
+ bbox_right = metric.fields.get("bounding_box_right")
+ bbox_top = metric.fields.get("bounding_box_top")
+ bbox_bottom = metric.fields.get("bounding_box_bottom")
+
+ if bbox_left == None or bbox_right == None or bbox_top == None or bbox_bottom == None:
+ log.warning("apply: Metric missing bounding box fields")
+ return metric
+
+ # Calculate center point of bounding box
+ center_x, center_y = get_bounding_box_center(bbox_left, bbox_right, bbox_top, bbox_bottom)
+
+ # Get track_id for logging
+ track_id = metric.fields.get("track_id", "unknown")
+
+ # Check if center is inside the zone
+ if is_point_in_polygon(center_x, center_y, zone_vertices_normalized):
+ log.debug("apply: track_id=" + str(track_id) + " center=(" + str(center_x) + "," + str(center_y) + ") INSIDE zone")
+ # Create a new metric with the zone-filtered name
+ filtered_metric = deepcopy(metric)
+ filtered_metric.name = "detection_frame_in_zone"
+ return filtered_metric
+ else:
+ log.debug("apply: track_id=" + str(track_id) + " center=(" + str(center_x) + "," + str(center_y) + ") OUTSIDE zone - filtering out")
+ return None