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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ jobs:
test_packet_buffer \
test_timestamp_manager \
test_motion_trigger_parse \
test_motion_max_area \
test_url_utils
echo "Unity test binaries built:"
ls -la bin/test_*
Expand All @@ -117,7 +118,7 @@ jobs:
run: |
cd build
ctest --output-on-failure -V \
-R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_url_utils"
-R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils"

- name: Generate coverage report
if: always()
Expand Down
16 changes: 14 additions & 2 deletions src/video/motion_detection.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
#define MAX_MOTION_STREAMS MAX_STREAMS
#define DEFAULT_SENSITIVITY 0.15f // Lower sensitivity threshold (was 0.25)
#define DEFAULT_MIN_MOTION_AREA 0.005f // Lower min area (was 0.01)
// A real subject (person, vehicle, package) never fills the entire scene;
// an event that does is a global brightness/colour-balance shift -- most
// commonly a camera's IR-cut filter flipping between day and night mode,
// which can happen at any time of day, not just at dawn/dusk twilight --
// not a localized object. Caps the false-positive flood that produced.
#define DEFAULT_MAX_MOTION_AREA 0.90f
#define DEFAULT_COOLDOWN_TIME 3
#define DEFAULT_MOTION_HISTORY 2 // Reduced from 3 to save memory
#define DEFAULT_BLUR_RADIUS 1 // Radius for simple box blur
Expand Down Expand Up @@ -57,6 +63,8 @@ typedef struct {
int channels;
float sensitivity; // Sensitivity threshold
float min_motion_area; // Minimum area to trigger detection
float max_motion_area; // Maximum area before a change is treated as a
// global lighting/exposure shift, not a subject
int cooldown_time; // Time between detections
int blur_radius; // Blur radius for noise reduction
int noise_threshold; // Threshold for noise filtering
Expand Down Expand Up @@ -247,6 +255,7 @@ static motion_stream_t *get_motion_stream(const char *stream_name) {
// Initialize default values
motion_streams[i]->sensitivity = DEFAULT_SENSITIVITY;
motion_streams[i]->min_motion_area = DEFAULT_MIN_MOTION_AREA;
motion_streams[i]->max_motion_area = DEFAULT_MAX_MOTION_AREA;
motion_streams[i]->cooldown_time = DEFAULT_COOLDOWN_TIME;
motion_streams[i]->history_size = DEFAULT_MOTION_HISTORY;
motion_streams[i]->blur_radius = DEFAULT_BLUR_RADIUS;
Expand Down Expand Up @@ -1215,7 +1224,9 @@ int detect_motion(const char *stream_name, const unsigned char *frame_data,
);

// Determine if motion is detected based on area threshold
motion_detected = (motion_area >= stream->min_motion_area) && (motion_score > 0.01f);
motion_detected = (motion_area >= stream->min_motion_area) &&
(motion_area <= stream->max_motion_area) &&
(motion_score > 0.01f);
} else {
// Simple frame differencing (original approach with improvements)
int changed_pixels = 0;
Expand Down Expand Up @@ -1275,7 +1286,8 @@ int detect_motion(const char *stream_name, const unsigned char *frame_data,
motion_score = motion_area;

// Determine if motion is detected based on area threshold
motion_detected = (motion_area >= stream->min_motion_area);
motion_detected = (motion_area >= stream->min_motion_area) &&
(motion_area <= stream->max_motion_area);
Comment on lines +1289 to +1290
}

// Add current frame to history
Expand Down
1 change: 1 addition & 0 deletions tests/unit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ add_layer3_test(test_timestamp_manager)
add_layer3_test(test_api_handlers_system)
add_layer1_test(test_external_motion_trigger) # Layer 1: external_motion_trigger state-machine (PR #356)
add_layer2_test(test_motion_trigger_parse) # Layer 2: motion trigger body parsing (#466)
add_layer2_test(test_motion_max_area) # Layer 2: motion detector max-area sanity cap
add_layer3_test(test_cross_stream_motion_trigger)
add_layer3_test(test_annotation_writer_registry) # Layer 3: continuous-writer probe behind annotation mode (#547)

Expand Down
173 changes: 173 additions & 0 deletions tests/unit/test_motion_max_area.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* @file test_motion_max_area.c
* @brief Layer 2 unit tests — motion detector max-area sanity cap
*
* The frame-differencing motion detector had a minimum area to trigger
* detection but no maximum, so an event where the *entire* frame changes at
* once (e.g. a camera's IR-cut filter flipping between day/night mode) reads
* as maximal-confidence motion identical to a real, localized subject.
* Production evidence: on the reference install, ~40% of all "motion"
* events over 24h were the exact ceiling reading (area=100%, single
* cluster spanning the whole frame), spread evenly across every hour of
* the day and night rather than clustered at dawn/dusk light transitions.
*
* These tests use uniform synthetic frames to isolate the area computation
* from real image content: a whole-frame brightness step (no real subject
* could ever produce this) must not be flagged, while a localized change
* confined to part of the frame (what a real subject looks like) must
* still be detected normally.
*/

#define _POSIX_C_SOURCE 200809L

#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#include "unity.h"
#include "core/logger.h"
#include "video/motion_detection.h"
#include "video/detection_result.h"

#define FRAME_W 64
#define FRAME_H 64
#define FRAME_BYTES (FRAME_W * FRAME_H)

void setUp(void) {
init_logger();
TEST_ASSERT_EQUAL_INT(0, init_motion_detection_system());
}

void tearDown(void) {
shutdown_motion_detection_system();
}

static unsigned char *uniform_frame(unsigned char value) {
unsigned char *frame = malloc(FRAME_BYTES);
TEST_ASSERT_NOT_NULL(frame);
memset(frame, value, FRAME_BYTES);
return frame;
}

/* Establishes a calm baseline: frame 1 initializes background/prev_frame
* (always returns 0, "skip on first frame" per the detector's own contract);
* frame 2 is identical, confirming a settled zero-motion baseline before
* the test's real frame is introduced. `use_grid_detection` selects which
* of the two detect_motion() code paths the cap is exercised against --
* both were patched with the same max-area check. */
static void establish_calm_baseline_ex(const char *stream_name, unsigned char value,
bool use_grid_detection) {
/* Motion streams default to disabled on creation; detect_motion() is a
* silent no-op (returns 0, result untouched) until this is set. */
TEST_ASSERT_EQUAL_INT(0, set_motion_detection_enabled(stream_name, true));
/* Matches this module's own DEFAULT_BLUR_RADIUS/NOISE_THRESHOLD/
* GRID_SIZE/MOTION_HISTORY -- only use_grid_detection is under test. */
TEST_ASSERT_EQUAL_INT(0, configure_advanced_motion_detection(
stream_name, 1, 10, use_grid_detection, 6, 2));

unsigned char *frame = uniform_frame(value);
detection_result_t result;

memset(&result, 0, sizeof(result));
TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, frame, FRAME_W, FRAME_H, 1, 1000, &result));
TEST_ASSERT_EQUAL_INT(0, result.count);

memset(&result, 0, sizeof(result));
TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, frame, FRAME_W, FRAME_H, 1, 1001, &result));
TEST_ASSERT_EQUAL_INT(0, result.count);

free(frame);
}

static void establish_calm_baseline(const char *stream_name, unsigned char value) {
establish_calm_baseline_ex(stream_name, value, true);
}

void test_whole_frame_brightness_step_is_not_flagged_as_motion(void) {
const char *stream_name = "test_max_area_whole_frame";
establish_calm_baseline(stream_name, 50);

/* Every pixel jumps at once -- exactly what an IR-cut filter flip looks
* like to a frame-differencing algorithm, never what a real subject
* confined to part of the scene looks like. */
unsigned char *shifted = uniform_frame(200);
detection_result_t result;
memset(&result, 0, sizeof(result));

TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, shifted, FRAME_W, FRAME_H, 1, 1002, &result));
TEST_ASSERT_EQUAL_INT(0, result.count);

free(shifted);
}

void test_localized_change_is_still_detected(void) {
const char *stream_name = "test_max_area_localized";
establish_calm_baseline(stream_name, 50);

/* Only the top-left quarter of the frame changes -- a real subject
* occupying part of the scene -- must still trigger detection. */
unsigned char *partial = uniform_frame(50);
for (int y = 0; y < FRAME_H / 2; y++) {
for (int x = 0; x < FRAME_W / 2; x++) {
partial[y * FRAME_W + x] = 220;
}
}
detection_result_t result;
memset(&result, 0, sizeof(result));

TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, partial, FRAME_W, FRAME_H, 1, 1002, &result));
TEST_ASSERT_GREATER_THAN_INT(0, result.count);

free(partial);
}

/* Same two scenarios, but against the simple frame-differencing path
* (use_grid_detection=false) rather than the default grid-based one --
* both were patched with the same max_motion_area check, and a regression
* that removed or misapplied the cap in only one of the two would
* otherwise pass every other test in this file. */

void test_whole_frame_brightness_step_is_not_flagged_non_grid(void) {
const char *stream_name = "test_max_area_whole_frame_non_grid";
establish_calm_baseline_ex(stream_name, 50, false);

unsigned char *shifted = uniform_frame(200);
detection_result_t result;
memset(&result, 0, sizeof(result));

TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, shifted, FRAME_W, FRAME_H, 1, 1002, &result));
TEST_ASSERT_EQUAL_INT(0, result.count);

free(shifted);
}

void test_localized_change_is_still_detected_non_grid(void) {
const char *stream_name = "test_max_area_localized_non_grid";
establish_calm_baseline_ex(stream_name, 50, false);

unsigned char *partial = uniform_frame(50);
for (int y = 0; y < FRAME_H / 2; y++) {
for (int x = 0; x < FRAME_W / 2; x++) {
partial[y * FRAME_W + x] = 220;
}
}
detection_result_t result;
memset(&result, 0, sizeof(result));

TEST_ASSERT_EQUAL_INT(0, detect_motion(stream_name, partial, FRAME_W, FRAME_H, 1, 1002, &result));
TEST_ASSERT_GREATER_THAN_INT(0, result.count);

free(partial);
}

int main(void) {
UNITY_BEGIN();

RUN_TEST(test_whole_frame_brightness_step_is_not_flagged_as_motion);
RUN_TEST(test_localized_change_is_still_detected);
RUN_TEST(test_whole_frame_brightness_step_is_not_flagged_non_grid);
RUN_TEST(test_localized_change_is_still_detected_non_grid);

return UNITY_END();
}
Loading