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.
- megadetector/__init__.py +0 -0
- megadetector/api/__init__.py +0 -0
- megadetector/api/batch_processing/integration/digiKam/setup.py +6 -0
- megadetector/api/batch_processing/integration/digiKam/xmp_integration.py +465 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/config_template.py +5 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +125 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +55 -0
- megadetector/classification/__init__.py +0 -0
- megadetector/classification/aggregate_classifier_probs.py +108 -0
- megadetector/classification/analyze_failed_images.py +227 -0
- megadetector/classification/cache_batchapi_outputs.py +198 -0
- megadetector/classification/create_classification_dataset.py +626 -0
- megadetector/classification/crop_detections.py +516 -0
- megadetector/classification/csv_to_json.py +226 -0
- megadetector/classification/detect_and_crop.py +853 -0
- megadetector/classification/efficientnet/__init__.py +9 -0
- megadetector/classification/efficientnet/model.py +415 -0
- megadetector/classification/efficientnet/utils.py +608 -0
- megadetector/classification/evaluate_model.py +520 -0
- megadetector/classification/identify_mislabeled_candidates.py +152 -0
- megadetector/classification/json_to_azcopy_list.py +63 -0
- megadetector/classification/json_validator.py +696 -0
- megadetector/classification/map_classification_categories.py +276 -0
- megadetector/classification/merge_classification_detection_output.py +509 -0
- megadetector/classification/prepare_classification_script.py +194 -0
- megadetector/classification/prepare_classification_script_mc.py +228 -0
- megadetector/classification/run_classifier.py +287 -0
- megadetector/classification/save_mislabeled.py +110 -0
- megadetector/classification/train_classifier.py +827 -0
- megadetector/classification/train_classifier_tf.py +725 -0
- megadetector/classification/train_utils.py +323 -0
- megadetector/data_management/__init__.py +0 -0
- megadetector/data_management/animl_to_md.py +161 -0
- megadetector/data_management/annotations/__init__.py +0 -0
- megadetector/data_management/annotations/annotation_constants.py +33 -0
- megadetector/data_management/camtrap_dp_to_coco.py +270 -0
- megadetector/data_management/cct_json_utils.py +566 -0
- megadetector/data_management/cct_to_md.py +184 -0
- megadetector/data_management/cct_to_wi.py +293 -0
- megadetector/data_management/coco_to_labelme.py +284 -0
- megadetector/data_management/coco_to_yolo.py +701 -0
- megadetector/data_management/databases/__init__.py +0 -0
- megadetector/data_management/databases/add_width_and_height_to_db.py +107 -0
- megadetector/data_management/databases/combine_coco_camera_traps_files.py +210 -0
- megadetector/data_management/databases/integrity_check_json_db.py +563 -0
- megadetector/data_management/databases/subset_json_db.py +195 -0
- megadetector/data_management/generate_crops_from_cct.py +200 -0
- megadetector/data_management/get_image_sizes.py +164 -0
- megadetector/data_management/labelme_to_coco.py +559 -0
- megadetector/data_management/labelme_to_yolo.py +349 -0
- megadetector/data_management/lila/__init__.py +0 -0
- megadetector/data_management/lila/create_lila_blank_set.py +556 -0
- megadetector/data_management/lila/create_lila_test_set.py +192 -0
- megadetector/data_management/lila/create_links_to_md_results_files.py +106 -0
- megadetector/data_management/lila/download_lila_subset.py +182 -0
- megadetector/data_management/lila/generate_lila_per_image_labels.py +777 -0
- megadetector/data_management/lila/get_lila_annotation_counts.py +174 -0
- megadetector/data_management/lila/get_lila_image_counts.py +112 -0
- megadetector/data_management/lila/lila_common.py +319 -0
- megadetector/data_management/lila/test_lila_metadata_urls.py +164 -0
- megadetector/data_management/mewc_to_md.py +344 -0
- megadetector/data_management/ocr_tools.py +873 -0
- megadetector/data_management/read_exif.py +964 -0
- megadetector/data_management/remap_coco_categories.py +195 -0
- megadetector/data_management/remove_exif.py +156 -0
- megadetector/data_management/rename_images.py +194 -0
- megadetector/data_management/resize_coco_dataset.py +665 -0
- megadetector/data_management/speciesnet_to_md.py +41 -0
- megadetector/data_management/wi_download_csv_to_coco.py +247 -0
- megadetector/data_management/yolo_output_to_md_output.py +594 -0
- megadetector/data_management/yolo_to_coco.py +984 -0
- megadetector/data_management/zamba_to_md.py +188 -0
- megadetector/detection/__init__.py +0 -0
- megadetector/detection/change_detection.py +840 -0
- megadetector/detection/process_video.py +479 -0
- megadetector/detection/pytorch_detector.py +1451 -0
- megadetector/detection/run_detector.py +1267 -0
- megadetector/detection/run_detector_batch.py +2172 -0
- megadetector/detection/run_inference_with_yolov5_val.py +1314 -0
- megadetector/detection/run_md_and_speciesnet.py +1604 -0
- megadetector/detection/run_tiled_inference.py +1044 -0
- megadetector/detection/tf_detector.py +209 -0
- megadetector/detection/video_utils.py +1379 -0
- megadetector/postprocessing/__init__.py +0 -0
- megadetector/postprocessing/add_max_conf.py +72 -0
- megadetector/postprocessing/categorize_detections_by_size.py +166 -0
- megadetector/postprocessing/classification_postprocessing.py +1943 -0
- megadetector/postprocessing/combine_batch_outputs.py +249 -0
- megadetector/postprocessing/compare_batch_results.py +2110 -0
- megadetector/postprocessing/convert_output_format.py +403 -0
- megadetector/postprocessing/create_crop_folder.py +629 -0
- megadetector/postprocessing/detector_calibration.py +570 -0
- megadetector/postprocessing/generate_csv_report.py +522 -0
- megadetector/postprocessing/load_api_results.py +223 -0
- megadetector/postprocessing/md_to_coco.py +428 -0
- megadetector/postprocessing/md_to_labelme.py +351 -0
- megadetector/postprocessing/md_to_wi.py +41 -0
- megadetector/postprocessing/merge_detections.py +392 -0
- megadetector/postprocessing/postprocess_batch_results.py +2140 -0
- megadetector/postprocessing/remap_detection_categories.py +226 -0
- megadetector/postprocessing/render_detection_confusion_matrix.py +677 -0
- megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +206 -0
- megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +82 -0
- megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +1665 -0
- megadetector/postprocessing/separate_detections_into_folders.py +795 -0
- megadetector/postprocessing/subset_json_detector_output.py +964 -0
- megadetector/postprocessing/top_folders_to_bottom.py +238 -0
- megadetector/postprocessing/validate_batch_results.py +332 -0
- megadetector/taxonomy_mapping/__init__.py +0 -0
- megadetector/taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +491 -0
- megadetector/taxonomy_mapping/map_new_lila_datasets.py +211 -0
- megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +165 -0
- megadetector/taxonomy_mapping/preview_lila_taxonomy.py +543 -0
- megadetector/taxonomy_mapping/retrieve_sample_image.py +71 -0
- megadetector/taxonomy_mapping/simple_image_download.py +231 -0
- megadetector/taxonomy_mapping/species_lookup.py +1008 -0
- megadetector/taxonomy_mapping/taxonomy_csv_checker.py +159 -0
- megadetector/taxonomy_mapping/taxonomy_graph.py +346 -0
- megadetector/taxonomy_mapping/validate_lila_category_mappings.py +83 -0
- megadetector/tests/__init__.py +0 -0
- megadetector/tests/test_nms_synthetic.py +335 -0
- megadetector/utils/__init__.py +0 -0
- megadetector/utils/ct_utils.py +1857 -0
- megadetector/utils/directory_listing.py +199 -0
- megadetector/utils/extract_frames_from_video.py +307 -0
- megadetector/utils/gpu_test.py +125 -0
- megadetector/utils/md_tests.py +2072 -0
- megadetector/utils/path_utils.py +2872 -0
- megadetector/utils/process_utils.py +172 -0
- megadetector/utils/split_locations_into_train_val.py +237 -0
- megadetector/utils/string_utils.py +234 -0
- megadetector/utils/url_utils.py +825 -0
- megadetector/utils/wi_platform_utils.py +968 -0
- megadetector/utils/wi_taxonomy_utils.py +1766 -0
- megadetector/utils/write_html_image_list.py +239 -0
- megadetector/visualization/__init__.py +0 -0
- megadetector/visualization/plot_utils.py +309 -0
- megadetector/visualization/render_images_with_thumbnails.py +243 -0
- megadetector/visualization/visualization_utils.py +1973 -0
- megadetector/visualization/visualize_db.py +630 -0
- megadetector/visualization/visualize_detector_output.py +498 -0
- megadetector/visualization/visualize_video_output.py +705 -0
- megadetector-10.0.15.dist-info/METADATA +115 -0
- megadetector-10.0.15.dist-info/RECORD +147 -0
- megadetector-10.0.15.dist-info/WHEEL +5 -0
- megadetector-10.0.15.dist-info/licenses/LICENSE +19 -0
- 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()
|