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