diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 93e0b652..b639d54d 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -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_* @@ -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() diff --git a/src/video/motion_detection.c b/src/video/motion_detection.c index 60521ebe..029a2d3a 100644 --- a/src/video/motion_detection.c +++ b/src/video/motion_detection.c @@ -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 @@ -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 @@ -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; @@ -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; @@ -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); } // Add current frame to history diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 1c37f87c..bfedd131 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -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) diff --git a/tests/unit/test_motion_max_area.c b/tests/unit/test_motion_max_area.c new file mode 100644 index 00000000..438656dd --- /dev/null +++ b/tests/unit/test_motion_max_area.c @@ -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 +#include +#include +#include + +#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(); +}