megadetector 5.0.28__py3-none-any.whl → 5.0.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of megadetector might be problematic. Click here for more details.

Files changed (176) hide show
  1. megadetector/api/batch_processing/api_core/batch_service/score.py +4 -5
  2. megadetector/api/batch_processing/api_core_support/aggregate_results_manually.py +1 -1
  3. megadetector/api/batch_processing/api_support/summarize_daily_activity.py +1 -1
  4. megadetector/api/batch_processing/integration/digiKam/xmp_integration.py +2 -2
  5. megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +1 -1
  6. megadetector/api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +1 -1
  7. megadetector/api/synchronous/api_core/tests/load_test.py +2 -3
  8. megadetector/classification/aggregate_classifier_probs.py +3 -3
  9. megadetector/classification/analyze_failed_images.py +5 -5
  10. megadetector/classification/cache_batchapi_outputs.py +5 -5
  11. megadetector/classification/create_classification_dataset.py +11 -12
  12. megadetector/classification/crop_detections.py +10 -10
  13. megadetector/classification/csv_to_json.py +8 -8
  14. megadetector/classification/detect_and_crop.py +13 -15
  15. megadetector/classification/evaluate_model.py +7 -7
  16. megadetector/classification/identify_mislabeled_candidates.py +6 -6
  17. megadetector/classification/json_to_azcopy_list.py +1 -1
  18. megadetector/classification/json_validator.py +29 -32
  19. megadetector/classification/map_classification_categories.py +9 -9
  20. megadetector/classification/merge_classification_detection_output.py +12 -9
  21. megadetector/classification/prepare_classification_script.py +19 -19
  22. megadetector/classification/prepare_classification_script_mc.py +23 -23
  23. megadetector/classification/run_classifier.py +4 -4
  24. megadetector/classification/save_mislabeled.py +6 -6
  25. megadetector/classification/train_classifier.py +1 -1
  26. megadetector/classification/train_classifier_tf.py +9 -9
  27. megadetector/classification/train_utils.py +10 -10
  28. megadetector/data_management/annotations/annotation_constants.py +1 -1
  29. megadetector/data_management/camtrap_dp_to_coco.py +45 -45
  30. megadetector/data_management/cct_json_utils.py +101 -101
  31. megadetector/data_management/cct_to_md.py +49 -49
  32. megadetector/data_management/cct_to_wi.py +33 -33
  33. megadetector/data_management/coco_to_labelme.py +75 -75
  34. megadetector/data_management/coco_to_yolo.py +189 -189
  35. megadetector/data_management/databases/add_width_and_height_to_db.py +3 -2
  36. megadetector/data_management/databases/combine_coco_camera_traps_files.py +38 -38
  37. megadetector/data_management/databases/integrity_check_json_db.py +202 -188
  38. megadetector/data_management/databases/subset_json_db.py +33 -33
  39. megadetector/data_management/generate_crops_from_cct.py +38 -38
  40. megadetector/data_management/get_image_sizes.py +54 -49
  41. megadetector/data_management/labelme_to_coco.py +130 -124
  42. megadetector/data_management/labelme_to_yolo.py +78 -72
  43. megadetector/data_management/lila/create_lila_blank_set.py +81 -83
  44. megadetector/data_management/lila/create_lila_test_set.py +32 -31
  45. megadetector/data_management/lila/create_links_to_md_results_files.py +18 -18
  46. megadetector/data_management/lila/download_lila_subset.py +21 -24
  47. megadetector/data_management/lila/generate_lila_per_image_labels.py +91 -91
  48. megadetector/data_management/lila/get_lila_annotation_counts.py +30 -30
  49. megadetector/data_management/lila/get_lila_image_counts.py +22 -22
  50. megadetector/data_management/lila/lila_common.py +70 -70
  51. megadetector/data_management/lila/test_lila_metadata_urls.py +13 -14
  52. megadetector/data_management/mewc_to_md.py +339 -340
  53. megadetector/data_management/ocr_tools.py +258 -252
  54. megadetector/data_management/read_exif.py +231 -224
  55. megadetector/data_management/remap_coco_categories.py +26 -26
  56. megadetector/data_management/remove_exif.py +31 -20
  57. megadetector/data_management/rename_images.py +187 -187
  58. megadetector/data_management/resize_coco_dataset.py +41 -41
  59. megadetector/data_management/speciesnet_to_md.py +41 -41
  60. megadetector/data_management/wi_download_csv_to_coco.py +55 -55
  61. megadetector/data_management/yolo_output_to_md_output.py +117 -120
  62. megadetector/data_management/yolo_to_coco.py +195 -188
  63. megadetector/detection/change_detection.py +831 -0
  64. megadetector/detection/process_video.py +340 -337
  65. megadetector/detection/pytorch_detector.py +304 -262
  66. megadetector/detection/run_detector.py +177 -164
  67. megadetector/detection/run_detector_batch.py +364 -363
  68. megadetector/detection/run_inference_with_yolov5_val.py +328 -325
  69. megadetector/detection/run_tiled_inference.py +256 -249
  70. megadetector/detection/tf_detector.py +24 -24
  71. megadetector/detection/video_utils.py +290 -282
  72. megadetector/postprocessing/add_max_conf.py +15 -11
  73. megadetector/postprocessing/categorize_detections_by_size.py +44 -44
  74. megadetector/postprocessing/classification_postprocessing.py +415 -415
  75. megadetector/postprocessing/combine_batch_outputs.py +20 -21
  76. megadetector/postprocessing/compare_batch_results.py +528 -517
  77. megadetector/postprocessing/convert_output_format.py +97 -97
  78. megadetector/postprocessing/create_crop_folder.py +219 -146
  79. megadetector/postprocessing/detector_calibration.py +173 -168
  80. megadetector/postprocessing/generate_csv_report.py +508 -499
  81. megadetector/postprocessing/load_api_results.py +23 -20
  82. megadetector/postprocessing/md_to_coco.py +129 -98
  83. megadetector/postprocessing/md_to_labelme.py +89 -83
  84. megadetector/postprocessing/md_to_wi.py +40 -40
  85. megadetector/postprocessing/merge_detections.py +87 -114
  86. megadetector/postprocessing/postprocess_batch_results.py +313 -298
  87. megadetector/postprocessing/remap_detection_categories.py +36 -36
  88. megadetector/postprocessing/render_detection_confusion_matrix.py +205 -199
  89. megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +57 -57
  90. megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +27 -28
  91. megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +702 -677
  92. megadetector/postprocessing/separate_detections_into_folders.py +226 -211
  93. megadetector/postprocessing/subset_json_detector_output.py +265 -262
  94. megadetector/postprocessing/top_folders_to_bottom.py +45 -45
  95. megadetector/postprocessing/validate_batch_results.py +70 -70
  96. megadetector/taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +52 -52
  97. megadetector/taxonomy_mapping/map_new_lila_datasets.py +15 -15
  98. megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +14 -14
  99. megadetector/taxonomy_mapping/preview_lila_taxonomy.py +66 -66
  100. megadetector/taxonomy_mapping/retrieve_sample_image.py +16 -16
  101. megadetector/taxonomy_mapping/simple_image_download.py +8 -8
  102. megadetector/taxonomy_mapping/species_lookup.py +33 -33
  103. megadetector/taxonomy_mapping/taxonomy_csv_checker.py +14 -14
  104. megadetector/taxonomy_mapping/taxonomy_graph.py +10 -10
  105. megadetector/taxonomy_mapping/validate_lila_category_mappings.py +13 -13
  106. megadetector/utils/azure_utils.py +22 -22
  107. megadetector/utils/ct_utils.py +1018 -200
  108. megadetector/utils/directory_listing.py +21 -77
  109. megadetector/utils/gpu_test.py +22 -22
  110. megadetector/utils/md_tests.py +541 -518
  111. megadetector/utils/path_utils.py +1457 -398
  112. megadetector/utils/process_utils.py +41 -41
  113. megadetector/utils/sas_blob_utils.py +53 -49
  114. megadetector/utils/split_locations_into_train_val.py +61 -61
  115. megadetector/utils/string_utils.py +147 -26
  116. megadetector/utils/url_utils.py +463 -173
  117. megadetector/utils/wi_utils.py +2629 -2526
  118. megadetector/utils/write_html_image_list.py +137 -137
  119. megadetector/visualization/plot_utils.py +21 -21
  120. megadetector/visualization/render_images_with_thumbnails.py +37 -73
  121. megadetector/visualization/visualization_utils.py +401 -397
  122. megadetector/visualization/visualize_db.py +197 -190
  123. megadetector/visualization/visualize_detector_output.py +79 -73
  124. {megadetector-5.0.28.dist-info → megadetector-5.0.29.dist-info}/METADATA +135 -132
  125. megadetector-5.0.29.dist-info/RECORD +163 -0
  126. {megadetector-5.0.28.dist-info → megadetector-5.0.29.dist-info}/WHEEL +1 -1
  127. {megadetector-5.0.28.dist-info → megadetector-5.0.29.dist-info}/licenses/LICENSE +0 -0
  128. {megadetector-5.0.28.dist-info → megadetector-5.0.29.dist-info}/top_level.txt +0 -0
  129. megadetector/data_management/importers/add_nacti_sizes.py +0 -52
  130. megadetector/data_management/importers/add_timestamps_to_icct.py +0 -79
  131. megadetector/data_management/importers/animl_results_to_md_results.py +0 -158
  132. megadetector/data_management/importers/auckland_doc_test_to_json.py +0 -373
  133. megadetector/data_management/importers/auckland_doc_to_json.py +0 -201
  134. megadetector/data_management/importers/awc_to_json.py +0 -191
  135. megadetector/data_management/importers/bellevue_to_json.py +0 -272
  136. megadetector/data_management/importers/cacophony-thermal-importer.py +0 -793
  137. megadetector/data_management/importers/carrizo_shrubfree_2018.py +0 -269
  138. megadetector/data_management/importers/carrizo_trail_cam_2017.py +0 -289
  139. megadetector/data_management/importers/cct_field_adjustments.py +0 -58
  140. megadetector/data_management/importers/channel_islands_to_cct.py +0 -913
  141. megadetector/data_management/importers/eMammal/copy_and_unzip_emammal.py +0 -180
  142. megadetector/data_management/importers/eMammal/eMammal_helpers.py +0 -249
  143. megadetector/data_management/importers/eMammal/make_eMammal_json.py +0 -223
  144. megadetector/data_management/importers/ena24_to_json.py +0 -276
  145. megadetector/data_management/importers/filenames_to_json.py +0 -386
  146. megadetector/data_management/importers/helena_to_cct.py +0 -283
  147. megadetector/data_management/importers/idaho-camera-traps.py +0 -1407
  148. megadetector/data_management/importers/idfg_iwildcam_lila_prep.py +0 -294
  149. megadetector/data_management/importers/import_desert_lion_conservation_camera_traps.py +0 -387
  150. megadetector/data_management/importers/jb_csv_to_json.py +0 -150
  151. megadetector/data_management/importers/mcgill_to_json.py +0 -250
  152. megadetector/data_management/importers/missouri_to_json.py +0 -490
  153. megadetector/data_management/importers/nacti_fieldname_adjustments.py +0 -79
  154. megadetector/data_management/importers/noaa_seals_2019.py +0 -181
  155. megadetector/data_management/importers/osu-small-animals-to-json.py +0 -364
  156. megadetector/data_management/importers/pc_to_json.py +0 -365
  157. megadetector/data_management/importers/plot_wni_giraffes.py +0 -123
  158. megadetector/data_management/importers/prepare_zsl_imerit.py +0 -131
  159. megadetector/data_management/importers/raic_csv_to_md_results.py +0 -416
  160. megadetector/data_management/importers/rspb_to_json.py +0 -356
  161. megadetector/data_management/importers/save_the_elephants_survey_A.py +0 -320
  162. megadetector/data_management/importers/save_the_elephants_survey_B.py +0 -329
  163. megadetector/data_management/importers/snapshot_safari_importer.py +0 -758
  164. megadetector/data_management/importers/snapshot_serengeti_lila.py +0 -1067
  165. megadetector/data_management/importers/snapshotserengeti/make_full_SS_json.py +0 -150
  166. megadetector/data_management/importers/snapshotserengeti/make_per_season_SS_json.py +0 -153
  167. megadetector/data_management/importers/sulross_get_exif.py +0 -65
  168. megadetector/data_management/importers/timelapse_csv_set_to_json.py +0 -490
  169. megadetector/data_management/importers/ubc_to_json.py +0 -399
  170. megadetector/data_management/importers/umn_to_json.py +0 -507
  171. megadetector/data_management/importers/wellington_to_json.py +0 -263
  172. megadetector/data_management/importers/wi_to_json.py +0 -442
  173. megadetector/data_management/importers/zamba_results_to_md_results.py +0 -180
  174. megadetector/data_management/lila/add_locations_to_island_camera_traps.py +0 -101
  175. megadetector/data_management/lila/add_locations_to_nacti.py +0 -151
  176. megadetector-5.0.28.dist-info/RECORD +0 -209
@@ -0,0 +1,831 @@
1
+ """
2
+
3
+ change_detection.py
4
+
5
+ This is an experimental module intended to support change detection in cases
6
+ where camera backgrounds are stable, and MegaDetector is not suitable.
7
+
8
+ """
9
+
10
+ #%% Imports and constants
11
+
12
+ import argparse
13
+ import random
14
+ import sys
15
+
16
+ import cv2
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from tqdm import tqdm
22
+ from pathlib import Path
23
+ from concurrent.futures import ProcessPoolExecutor
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum, auto
26
+ from typing import Optional
27
+
28
+ from megadetector.utils.path_utils import find_images
29
+
30
+
31
+ #%% Support classes
32
+
33
+ class DetectionMethod(Enum):
34
+ """
35
+ Enum for different motion detection methods.
36
+ """
37
+
38
+ FRAME_DIFF = auto() # Simple frame differencing
39
+ MOG2 = auto() # Mixture of Gaussians
40
+ KNN = auto() # K-nearest neighbors
41
+ MOTION_HISTORY = auto() # Motion history image
42
+
43
+
44
+ class ThresholdType(Enum):
45
+ """
46
+ Enum for different thresholding methods.
47
+ """
48
+
49
+ GLOBAL = auto() # Global thresholding
50
+ ADAPTIVE = auto() # Adaptive thresholding
51
+ OTSU = auto() # Otsu's method
52
+
53
+
54
+ @dataclass
55
+ class ChangeDetectionOptions:
56
+ """
57
+ Class to store options for change detection
58
+ """
59
+
60
+ # Core parameters
61
+ min_area: int = 500
62
+ threshold: int = 25
63
+
64
+ # Method selection
65
+ detection_method: DetectionMethod = DetectionMethod.FRAME_DIFF
66
+ threshold_type: ThresholdType = ThresholdType.GLOBAL
67
+
68
+ # Pre-processing of the raw images, before differencing
69
+ blur_kernel_size: int = 21
70
+
71
+ # Post-processing of the difference image, to fill holes
72
+ dilate_kernel_size: int = 5
73
+
74
+ # Background subtractor parameters (MOG2, KNN)
75
+ history: int = 25 # Number of frames to build background model
76
+ var_threshold: float = 16 # Threshold for MOG2
77
+ detect_shadows: bool = False # Whether to detect shadows (MOG2)
78
+
79
+ # Adaptive threshold parameters
80
+ adaptive_block_size: int = 11
81
+ adaptive_c: int = 2
82
+
83
+ # Motion history parameters
84
+ mhi_duration: float = 10.0 # Duration in frames for motion to persist
85
+ mhi_threshold: int = 30 # Threshold for motion detection
86
+ mhi_buffer_size: int = 20 # Number of frames to keep in buffer
87
+
88
+ # Region of interest parameters
89
+ ignore_fraction: Optional[float] = None # Fraction of image to ignore (-1.0 to 1.0)
90
+
91
+ # Processing parameters
92
+ dilate_iterations: int = 2
93
+
94
+ # Number of concurrent workers (for parallelizing over folders, not images)
95
+ workers: int = 4
96
+
97
+ # Enable additional debug output
98
+ verbose: bool = False
99
+
100
+ # Debugging tools
101
+ stop_at_token: str = None
102
+
103
+ # ...def ChangeDetectionOptions
104
+
105
+
106
+ @dataclass
107
+ class MotionHistoryState:
108
+ """
109
+ Class to maintain state for motion history detection across frames
110
+ """
111
+
112
+ buffer_size: int = 10
113
+ frame_buffer: list = field(default_factory=list)
114
+ mhi: Optional[np.ndarray] = None # Motion History Image
115
+ last_timestamp: float = 0.0
116
+ frame_interval: float = 1.0 # Time between frames in "seconds" (nominal)
117
+ frame_shape: Optional[tuple] = None
118
+
119
+ def initialize(self, frame):
120
+ """
121
+ Initialize the motion history state with the first frame
122
+
123
+ Args:
124
+ frame (np array): First frame to initialize the state
125
+ """
126
+
127
+ if self.mhi is None and frame is not None:
128
+ self.frame_shape = frame.shape
129
+ self.mhi = np.zeros(self.frame_shape, dtype=np.float32)
130
+
131
+
132
+ def update(self, frame, options):
133
+ """
134
+ Update the motion history with a new frame
135
+
136
+ Args:
137
+ frame (np array): New frame to update the motion history
138
+ options (ChangeDetectionOptions): detection settings
139
+
140
+ Returns:
141
+ Motion mask based on the updated motion history
142
+ """
143
+
144
+ self.initialize(frame)
145
+
146
+ # Update timestamp
147
+ curr_timestamp = self.last_timestamp + self.frame_interval
148
+
149
+ # Update buffer
150
+ self.frame_buffer.append(frame.copy())
151
+ if len(self.frame_buffer) > self.buffer_size:
152
+ self.frame_buffer.pop(0) # Remove oldest frame
153
+
154
+ # Check if we have enough frames for motion history
155
+ if len(self.frame_buffer) < 2:
156
+ return np.zeros(frame.shape, dtype=np.uint8)
157
+
158
+ # Get the previous frame (most recent in buffer before current)
159
+ prev_frame = self.frame_buffer[-2]
160
+
161
+ # Detect motion between frames
162
+ frame_diff = cv2.absdiff(prev_frame, frame)
163
+ _, motion_mask = cv2.threshold(frame_diff, options.mhi_threshold, 1, cv2.THRESH_BINARY)
164
+
165
+ # Manual implementation of motion history update (replacing cv2.updateMotionHistory)
166
+ # Decrease the existing MHI values by the time that has passed
167
+ decay_factor = self.frame_interval / options.mhi_duration
168
+ self.mhi = np.maximum(0, self.mhi - decay_factor * 255)
169
+
170
+ # Set the MHI to maximum value where motion is detected
171
+ self.mhi[motion_mask > 0] = 255.0
172
+
173
+ # Normalize MHI to 0-255 for visualization and further processing
174
+ normalized_mhi = np.uint8(self.mhi)
175
+
176
+ self.last_timestamp = curr_timestamp
177
+
178
+ return normalized_mhi
179
+
180
+ # ...def MotionHistoryState
181
+
182
+
183
+ #%% Functions
184
+
185
+ def create_background_subtractor(options=None):
186
+ """
187
+ Create a background subtractor
188
+
189
+ Args:
190
+ options (ChangeDetectionOptions, optional): detection settings
191
+
192
+ Returns:
193
+ Background subtractor object
194
+ """
195
+
196
+ if options is None:
197
+ options = ChangeDetectionOptions()
198
+
199
+ if options.detection_method == DetectionMethod.MOG2:
200
+ return cv2.createBackgroundSubtractorMOG2(
201
+ history=options.history,
202
+ varThreshold=options.var_threshold,
203
+ detectShadows=options.detect_shadows
204
+ )
205
+
206
+ elif options.detection_method == DetectionMethod.KNN:
207
+ return cv2.createBackgroundSubtractorKNN(
208
+ history=options.history,
209
+ dist2Threshold=options.var_threshold,
210
+ detectShadows=options.detect_shadows
211
+ )
212
+
213
+ return None
214
+
215
+ # ...def create_background_subtractor(...)
216
+
217
+
218
+ def detect_motion(prev_image_path,
219
+ curr_image_path,
220
+ options=None,
221
+ motion_state=None,
222
+ bg_subtractor=None):
223
+ """
224
+ Detect motion between two consecutive images.
225
+
226
+ Args:
227
+ prev_image_path (str): path to the previous image
228
+ curr_image_path (str): path to the current image
229
+ options (ChangeDetectionOptions, optional): detection settings
230
+ motion_state (MotionHistoryState, optional): state for motion history
231
+ bg_subtractor: background subtractor model for MOG2/KNN methods
232
+
233
+ Returns:
234
+ tuple: (motion_result, updated_motion_state)
235
+ motion_result: dict with keys:
236
+ motion_detected: bool indicating whether motion was detected
237
+ motion_regions: list of bounding boxes of motion regions
238
+ diff_percentage: percentage of the image that changed
239
+ debug_images: dict of intermediate images for debugging (if requested)
240
+ """
241
+
242
+ # Helpful debug line for plotting images in IPython
243
+ # im = mask; cv2.imshow('im',im); cv2.waitKey(0); cv2.destroyAllWindows()
244
+
245
+ ##%% Argument handling
246
+
247
+ if options is None:
248
+ options = ChangeDetectionOptions()
249
+
250
+ to_return = {
251
+ 'motion_detected': False,
252
+ 'motion_regions': [],
253
+ 'diff_percentage': 0.0,
254
+ 'debug_images': {}
255
+ }
256
+
257
+
258
+ ##%% Image reading
259
+
260
+ # Read images
261
+ curr_image = cv2.imread(str(curr_image_path))
262
+
263
+ if curr_image is None:
264
+ print(f"Could not read image: {curr_image_path}")
265
+ return to_return, motion_state
266
+
267
+ # Read previous image if available (used for frame diff mode)
268
+ prev_image = None
269
+ if prev_image_path is not None:
270
+ prev_image = cv2.imread(str(prev_image_path))
271
+ if prev_image is None:
272
+ print(f"Could not read image: {prev_image_path}")
273
+ return to_return, motion_state
274
+
275
+
276
+ ##%% Preprocessing
277
+
278
+ # Apply region of interest masking if specified
279
+ roi_mask = None
280
+ if options.ignore_fraction is not None:
281
+ h, w = curr_image.shape[0], curr_image.shape[1]
282
+ roi_mask = np.ones((h, w), dtype=np.uint8)
283
+
284
+ # Calculate the number of rows to ignore
285
+ ignore_rows = int(abs(options.ignore_fraction) * h)
286
+
287
+ # Negative fraction: ignore top portion
288
+ if options.ignore_fraction < 0:
289
+ roi_mask[0:ignore_rows, :] = 0
290
+ # Positive fraction: ignore bottom portion
291
+ elif options.ignore_fraction > 0:
292
+ roi_mask[h-ignore_rows:h, :] = 0
293
+
294
+ # Convert to grayscale
295
+ curr_gray = cv2.cvtColor(curr_image, cv2.COLOR_BGR2GRAY)
296
+
297
+ # Apply ROI mask if specified
298
+ if roi_mask is not None:
299
+ curr_gray = cv2.bitwise_and(curr_gray, curr_gray, mask=roi_mask)
300
+
301
+ # Apply Gaussian blur to reduce noise
302
+ curr_gray = cv2.GaussianBlur(curr_gray, (options.blur_kernel_size, options.blur_kernel_size), 0)
303
+
304
+
305
+ ##%% Differencing
306
+
307
+ # Simple frame differencing
308
+ if options.detection_method == DetectionMethod.FRAME_DIFF:
309
+
310
+ # Need previous image for frame differencing
311
+ if prev_image is None:
312
+ return to_return, motion_state
313
+
314
+ prev_gray = cv2.cvtColor(prev_image, cv2.COLOR_BGR2GRAY)
315
+
316
+ # Apply ROI mask if specified
317
+ if roi_mask is not None:
318
+ prev_gray = cv2.bitwise_and(prev_gray, prev_gray, mask=roi_mask)
319
+
320
+ prev_gray = cv2.GaussianBlur(prev_gray, (options.blur_kernel_size, options.blur_kernel_size), 0)
321
+
322
+ # Compute absolute difference between frames
323
+ mask = cv2.absdiff(prev_gray, curr_gray)
324
+
325
+ # Background subtractors (MOG2 or KNN)
326
+ elif options.detection_method in [DetectionMethod.MOG2, DetectionMethod.KNN]:
327
+
328
+ # Use the provided background subtractor
329
+ if bg_subtractor is None:
330
+ print("Warning: No background subtractor provided, creating a new one")
331
+ bg_subtractor = create_background_subtractor(options)
332
+
333
+ # Get foreground mask from current image
334
+ mask = bg_subtractor.apply(curr_gray)
335
+
336
+ # Apply ROI mask again after background subtraction if needed
337
+ if roi_mask is not None:
338
+ mask = cv2.bitwise_and(mask, mask, mask=roi_mask)
339
+
340
+ # Motion history image
341
+ elif options.detection_method == DetectionMethod.MOTION_HISTORY:
342
+
343
+ # Initialize motion state if not provided
344
+ if motion_state is None:
345
+ motion_state = MotionHistoryState(buffer_size=options.mhi_buffer_size)
346
+ motion_state.frame_interval = 0.1 # Default interval between frames
347
+
348
+ # Apply ROI mask if needed (motion state will handle the masked image)
349
+ if roi_mask is not None:
350
+ masked_curr_gray = cv2.bitwise_and(curr_gray, curr_gray, mask=roi_mask)
351
+ mask = motion_state.update(masked_curr_gray, options)
352
+ else:
353
+ mask = motion_state.update(curr_gray, options)
354
+
355
+ # Fall back to frame differencing
356
+ else:
357
+ if prev_image is None:
358
+ return to_return, motion_state
359
+
360
+ prev_gray = cv2.cvtColor(prev_image, cv2.COLOR_BGR2GRAY)
361
+
362
+ # Apply ROI mask if specified
363
+ if roi_mask is not None:
364
+ prev_gray = cv2.bitwise_and(prev_gray, prev_gray, mask=roi_mask)
365
+
366
+ prev_gray = cv2.GaussianBlur(prev_gray, (options.blur_kernel_size, options.blur_kernel_size), 0)
367
+
368
+ mask = cv2.absdiff(prev_gray, curr_gray)
369
+
370
+
371
+ ##%% Debugging
372
+
373
+ if options.stop_at_token is not None and options.stop_at_token in curr_image_path:
374
+ import IPython; IPython.embed()
375
+
376
+
377
+ ##%% Thresholding the mask
378
+
379
+ # Adaptive thresholding
380
+ if options.threshold_type == ThresholdType.ADAPTIVE:
381
+ thresh = cv2.adaptiveThreshold(
382
+ mask, 255,
383
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
384
+ cv2.THRESH_BINARY,
385
+ options.adaptive_block_size,
386
+ options.adaptive_c
387
+ )
388
+
389
+ # Otsu
390
+ elif options.threshold_type == ThresholdType.OTSU:
391
+ _, thresh = cv2.threshold(mask, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
392
+
393
+ # Global thresholding
394
+ else:
395
+ assert options.threshold_type == ThresholdType.GLOBAL
396
+ _, thresh = cv2.threshold(mask, options.threshold, 255, cv2.THRESH_BINARY)
397
+
398
+
399
+ ##%% Postprocessing the thresholded mask
400
+
401
+ # Ensure ROI mask is applied after thresholding
402
+ if roi_mask is not None:
403
+ thresh = cv2.bitwise_and(thresh, thresh, mask=roi_mask)
404
+
405
+ # Dilate the threshold image to fill in holes
406
+ kernel = np.ones((options.dilate_kernel_size, options.dilate_kernel_size), np.uint8)
407
+ dilated = cv2.dilate(thresh, kernel, iterations=options.dilate_iterations)
408
+
409
+ # Find contours
410
+ contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
411
+
412
+ # Filter contours by area
413
+ significant_contours = [c for c in contours if cv2.contourArea(c) > options.min_area]
414
+
415
+ # Calculate changed percentage (only consider the ROI area)
416
+ if roi_mask is not None:
417
+ roi_area = np.sum(roi_mask > 0)
418
+ diff_percentage = (np.sum(thresh > 0) / roi_area) * 100 if roi_area > 0 else 0
419
+ else:
420
+ diff_percentage = (np.sum(thresh > 0) / (thresh.shape[0] * thresh.shape[1])) * 100
421
+
422
+ # Get bounding boxes for significant motion regions
423
+ motion_regions = [cv2.boundingRect(c) for c in significant_contours]
424
+
425
+ # Populate return values
426
+ to_return['motion_detected'] = len(significant_contours) > 0
427
+ to_return['motion_regions'] = motion_regions
428
+ to_return['diff_percentage'] = diff_percentage
429
+
430
+ # Add debug images if verbose
431
+ if options.verbose:
432
+
433
+ to_return['debug_images'] = {
434
+ 'curr_gray': curr_gray,
435
+ 'mask': mask,
436
+ 'thresh': thresh,
437
+ 'dilated': dilated
438
+ }
439
+
440
+ if prev_image is not None:
441
+ prev_gray = cv2.cvtColor(prev_image, cv2.COLOR_BGR2GRAY)
442
+ prev_gray = cv2.GaussianBlur(prev_gray, (options.blur_kernel_size, options.blur_kernel_size), 0)
443
+ to_return['debug_images']['prev_gray'] = prev_gray
444
+
445
+ return to_return, motion_state
446
+
447
+ # ...def detect_motion(...)
448
+
449
+
450
+ def process_camera_folder(folder_path, options=None):
451
+ """
452
+ Process all images in a camera folder to detect motion.
453
+
454
+ Args:
455
+ folder_path (str): path to the folder containing images from one camera
456
+ options (ChangeDetectionOptions, optional): detection settings
457
+
458
+ Returns:
459
+ DataFrame with motion detection results for all images in the folder
460
+ """
461
+
462
+ if options is None:
463
+ options = ChangeDetectionOptions()
464
+
465
+ folder_path = Path(folder_path)
466
+ camera_name = folder_path.name
467
+
468
+ # Find images
469
+ image_files = find_images(folder_path, recursive=True, return_relative_paths=False)
470
+
471
+ if len(image_files) == 0:
472
+ print(f'"No images found in {folder_path}"')
473
+ return pd.DataFrame()
474
+
475
+ print(f"Processing {len(image_files)} images in {camera_name}")
476
+
477
+ # Initialize results
478
+ results = []
479
+
480
+ # Initialize motion state and background subtractor for this camera
481
+ motion_state = None
482
+ bg_subtractor = None
483
+
484
+ if options.detection_method == DetectionMethod.MOTION_HISTORY:
485
+ motion_state = MotionHistoryState(buffer_size=options.mhi_buffer_size)
486
+
487
+ if options.detection_method in [DetectionMethod.MOG2, DetectionMethod.KNN]:
488
+ bg_subtractor = create_background_subtractor(options)
489
+
490
+ # Add first image with no motion (no previous image to compare)
491
+ if len(image_files) > 0:
492
+ first_image = image_files[0]
493
+ results.append({
494
+ 'camera': camera_name,
495
+ 'image': str(first_image),
496
+ 'prev_image': None,
497
+ 'motion_detected': False,
498
+ 'diff_percentage': 0.0,
499
+ 'num_regions': 0,
500
+ 'regions': ''
501
+ })
502
+
503
+ # If using background subtractor, initialize it with the first image
504
+ if bg_subtractor is not None and options.detection_method in \
505
+ [DetectionMethod.MOG2, DetectionMethod.KNN]:
506
+ first_img = cv2.imread(str(first_image))
507
+ if first_img is not None:
508
+ first_gray = cv2.cvtColor(first_img, cv2.COLOR_BGR2GRAY)
509
+ first_gray = cv2.GaussianBlur(first_gray,
510
+ (options.blur_kernel_size,
511
+ options.blur_kernel_size), 0)
512
+ bg_subtractor.apply(first_gray, learningRate=1.0) # Initialize with this frame
513
+
514
+ # Process pairs of consecutive images
515
+ for i_image in tqdm(range(1, len(image_files)), total=len(image_files),
516
+ disable=(not options.verbose)):
517
+ prev_image = image_files[i_image-1]
518
+ curr_image = image_files[i_image]
519
+
520
+ motion_result, motion_state = detect_motion(
521
+ prev_image, curr_image, options, motion_state, bg_subtractor
522
+ )
523
+
524
+ motion_detected = motion_result['motion_detected']
525
+ regions = motion_result['motion_regions']
526
+ diff_percentage = motion_result['diff_percentage']
527
+
528
+ # Format regions as semicolon-separated list of "x,y,w,h"
529
+ regions_str = ';'.join([f"{x},{y},{w},{h}" for x, y, w, h in regions])
530
+
531
+ # Add result for current image
532
+ results.append({
533
+ 'camera': camera_name,
534
+ 'image': str(curr_image),
535
+ 'prev_image': str(prev_image),
536
+ 'motion_detected': motion_detected,
537
+ 'diff_percentage': diff_percentage,
538
+ 'num_regions': len(regions),
539
+ 'regions': regions_str
540
+ })
541
+
542
+ # ...for each image
543
+
544
+ return pd.DataFrame(results)
545
+
546
+ # ...def process_camera_folder(...)
547
+
548
+
549
+ def process_folders(folders, options=None, output_csv=None):
550
+ """
551
+ Process multiple folders of images.
552
+
553
+ Args:
554
+ folders (list): list of folder paths to process
555
+ options (ChangeDetectionOptions, optional): detection settings
556
+ output_csv (str, optional): path to save results as CSV
557
+
558
+ Returns:
559
+ DataFrame with motion detection results for all folders
560
+ """
561
+
562
+ if options is None:
563
+ options = ChangeDetectionOptions()
564
+
565
+ # Convert folders to list if it's a single string
566
+ if isinstance(folders, str):
567
+ folders = [folders]
568
+
569
+ # Convert to Path objects
570
+ folders = [Path(folder) for folder in folders]
571
+
572
+ all_results = []
573
+
574
+ if options.workers == 1:
575
+ for folder in folders:
576
+ folder_results = process_camera_folder(folder, options)
577
+ all_results.append(folder_results)
578
+ else:
579
+ # Process each camera folder in parallel
580
+ with ProcessPoolExecutor(max_workers=options.workers) as executor:
581
+ future_to_folder = {executor.submit(process_camera_folder, folder, options): folder
582
+ for folder in folders}
583
+
584
+ for future in future_to_folder:
585
+ folder = future_to_folder[future]
586
+ try:
587
+ folder_results = future.result()
588
+ all_results.append(folder_results)
589
+ print(f"Finished processing {folder}")
590
+ except Exception as e:
591
+ print(f"Error processing {folder}: {e}")
592
+
593
+ # Combine all results
594
+ if all_results:
595
+ combined_results = pd.concat(all_results, ignore_index=True)
596
+
597
+ # Save to CSV if requested
598
+ if output_csv:
599
+ combined_results.to_csv(output_csv, index=False)
600
+ print(f"Results saved to {output_csv}")
601
+
602
+ return combined_results
603
+ else:
604
+ return pd.DataFrame()
605
+
606
+ # ...def process_folders(...)
607
+
608
+
609
+ def create_change_previews(motion_results, output_folder, num_samples=10, random_seed=None):
610
+ """
611
+ Create side-by-side previews of images with detected motion
612
+
613
+ Args:
614
+ motion_results: DataFrame with motion detection results
615
+ output_folder: folder where preview images will be saved
616
+ num_samples (int, optional): number of random samples to create
617
+ random_seed (int, optional): seed for random sampling (for reproducibility)
618
+
619
+ Returns:
620
+ List of paths to created preview images
621
+ """
622
+
623
+ # Create output folder if it doesn't exist
624
+ output_folder = Path(output_folder)
625
+ output_folder.mkdir(parents=True, exist_ok=True)
626
+
627
+ # Filter results to only include rows with motion detected
628
+ motion_detected = motion_results[motion_results['motion_detected'] == True] # noqa
629
+
630
+ if len(motion_detected) == 0:
631
+ print("No motion detected in any images")
632
+ return []
633
+
634
+
635
+ if random_seed is not None:
636
+ random.seed(random_seed)
637
+
638
+ if num_samples is None:
639
+ samples = motion_detected
640
+ else:
641
+ # Sample rows (or take all if fewer than requested)
642
+ sample_size = min(num_samples, len(motion_detected))
643
+ sample_indices = random.sample(range(len(motion_detected)), sample_size)
644
+ samples = motion_detected.iloc[sample_indices]
645
+
646
+ preview_paths = []
647
+
648
+ for i_sample, row in samples.iterrows():
649
+
650
+ curr_image_path = row['image']
651
+ prev_image_path = row['prev_image']
652
+
653
+ # Read images
654
+ curr_image = cv2.imread(curr_image_path)
655
+ prev_image = cv2.imread(prev_image_path)
656
+
657
+ if curr_image is None or prev_image is None:
658
+ print(f"Could not read images: {prev_image_path} or {curr_image_path}")
659
+ continue
660
+
661
+ # Ensure that both images have the same dimensions
662
+ if curr_image.shape != prev_image.shape:
663
+ # Resize to match dimensions
664
+ prev_image = cv2.resize(prev_image, (curr_image.shape[1], curr_image.shape[0]))
665
+
666
+ # Create side-by-side comparison
667
+ combined = np.hstack((prev_image, curr_image))
668
+
669
+ # Add labels
670
+ font = cv2.FONT_HERSHEY_SIMPLEX
671
+ cv2.putText(combined, 'Before', (10, 30), font, 1, (0, 255, 0), 2)
672
+ cv2.putText(combined, 'After', (curr_image.shape[1] + 10, 30), font, 1, (0, 255, 0), 2)
673
+
674
+ # Add details at the bottom
675
+ camera = row['camera']
676
+ diff_pct = row['diff_percentage']
677
+ info_text = f"Camera: {camera} | Change: {diff_pct:.2f}%"
678
+ cv2.putText(combined, info_text, (10, combined.shape[0] - 10), font, 0.7, (0, 255, 0), 2)
679
+
680
+ # Draw bounding boxes on the 'after' image if regions exist
681
+ if row['regions']:
682
+ regions = row['regions'].split(';')
683
+ for region in regions:
684
+ if region:
685
+ try:
686
+ x, y, w, h = map(int, region.split(','))
687
+ cv2.rectangle(combined,
688
+ (curr_image.shape[1] + x, y),
689
+ (curr_image.shape[1] + x + w, y + h),
690
+ (0, 0, 255), 2)
691
+ except ValueError:
692
+ print(f"Invalid region format: {region}")
693
+
694
+ # Save the combined image
695
+ camera_name = Path(curr_image_path).parent.name
696
+ image_name = Path(curr_image_path).name
697
+ output_path = output_folder / f"preview_{camera_name}_{image_name}"
698
+ cv2.imwrite(str(output_path), combined)
699
+
700
+ preview_paths.append(str(output_path))
701
+
702
+ # ...for each image
703
+ return preview_paths
704
+
705
+ # ...def create_change_previews(...)
706
+
707
+
708
+ #%% Command-line driver
709
+
710
+ def main(): # noqa
711
+ parser = argparse.ArgumentParser(description='Detect motion in timelapse camera images')
712
+ parser.add_argument('--root_dir', required=True, help='Root directory containing camera folders')
713
+ parser.add_argument('--output_csv', default=None, help='Optional output CSV file')
714
+
715
+ # Core parameters
716
+ parser.add_argument('--min_area', type=int, default=500,
717
+ help='Minimum contour area to consider as significant motion')
718
+ parser.add_argument('--threshold', type=int, default=25,
719
+ help='Threshold for binary image creation')
720
+
721
+ # Method selection
722
+ parser.add_argument('--detection_method', type=str, default='frame_diff',
723
+ choices=['frame_diff', 'mog2', 'knn', 'motion_history'],
724
+ help='Method to use for change detection')
725
+ parser.add_argument('--threshold_type', type=str, default='global',
726
+ choices=['global', 'adaptive', 'otsu'],
727
+ help='Type of thresholding to apply')
728
+
729
+ # Background subtractor parameters
730
+ parser.add_argument('--history', type=int, default=500,
731
+ help='Number of frames used to build the background model')
732
+ parser.add_argument('--var_threshold', type=float, default=16,
733
+ help='Threshold for MOG2/KNN background subtraction')
734
+ parser.add_argument('--detect_shadows', action='store_true',
735
+ help='Detect and mark shadows in background subtraction')
736
+
737
+ # Adaptive threshold parameters
738
+ parser.add_argument('--adaptive_block_size', type=int, default=11,
739
+ help='Block size for adaptive thresholding')
740
+ parser.add_argument('--adaptive_c', type=int, default=2,
741
+ help='Constant subtracted from the mean for adaptive thresholding')
742
+
743
+ # Motion history parameters
744
+ parser.add_argument('--mhi_duration', type=float, default=1.0,
745
+ help='Duration in seconds for the motion history image')
746
+ parser.add_argument('--mhi_threshold', type=int, default=30,
747
+ help='Threshold for motion detection in the motion history image')
748
+ parser.add_argument('--mhi_buffer_size', type=int, default=10,
749
+ help='Number of frames to keep in motion history buffer')
750
+
751
+ # Region of interest parameters
752
+ parser.add_argument('--ignore_fraction', type=float, default=None,
753
+ help='Fraction of image to ignore: negative = top, positive = bottom, range [-1.0, 1.0]')
754
+
755
+ # Processing parameters
756
+ parser.add_argument('--workers', type=int, default=4,
757
+ help='Number of parallel workers')
758
+ parser.add_argument('--verbose', action='store_true',
759
+ help='Enable additional debug output')
760
+
761
+ # Preview generation
762
+ parser.add_argument('--create_previews', action='store_true',
763
+ help='Create side-by-side previews of detected motion')
764
+ parser.add_argument('--preview_folder', default='change_previews',
765
+ help='Folder to save preview images')
766
+ parser.add_argument('--num_previews', type=int, default=10,
767
+ help='Number of random preview images to create')
768
+
769
+ if len(sys.argv[1:]) == 0:
770
+ parser.print_help()
771
+ parser.exit()
772
+
773
+ args = parser.parse_args()
774
+
775
+ # Validate ignore_fraction
776
+ if args.ignore_fraction is not None and (args.ignore_fraction < -1.0 or args.ignore_fraction > 1.0):
777
+ print("Error: ignore_fraction must be between -1.0 and 1.0")
778
+ return
779
+
780
+ # Create options object
781
+ options = ChangeDetectionOptions(
782
+ min_area=args.min_area,
783
+ threshold=args.threshold,
784
+ detection_method=getattr(DetectionMethod, args.detection_method.upper()),
785
+ threshold_type=getattr(ThresholdType, args.threshold_type.upper()),
786
+ history=args.history,
787
+ var_threshold=args.var_threshold,
788
+ detect_shadows=args.detect_shadows,
789
+ adaptive_block_size=args.adaptive_block_size,
790
+ adaptive_c=args.adaptive_c,
791
+ mhi_duration=args.mhi_duration,
792
+ mhi_threshold=args.mhi_threshold,
793
+ mhi_buffer_size=args.mhi_buffer_size,
794
+ ignore_fraction=args.ignore_fraction,
795
+ workers=args.workers,
796
+ verbose=args.verbose
797
+ )
798
+
799
+ # Get camera folders
800
+ root_dir = Path(args.root_dir)
801
+ camera_folders = [f for f in root_dir.iterdir() if f.is_dir()]
802
+
803
+ print(f"Found {len(camera_folders)} camera folders")
804
+
805
+ # Process all folders
806
+ results = process_folders(
807
+ camera_folders,
808
+ options=options,
809
+ output_csv=args.output_csv
810
+ )
811
+
812
+ # Create previews if requested
813
+ if args.create_previews:
814
+ preview_paths = create_change_previews(
815
+ results,
816
+ args.preview_folder,
817
+ num_samples=args.num_previews
818
+ )
819
+ print(f"Created {len(preview_paths)} preview images in {args.preview_folder}")
820
+
821
+ print("Motion detection completed")
822
+
823
+ # Display summary
824
+ motion_detected_count = results['motion_detected'].sum()
825
+ total_images = len(results)
826
+ print(f"Motion detected in {motion_detected_count} out of {total_images} images "
827
+ f"({motion_detected_count/total_images*100:.2f}%)")
828
+
829
+
830
+ if __name__ == "__main__":
831
+ main()