megadetector 10.0.13__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 (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 +702 -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 +528 -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 +187 -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 +663 -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 +876 -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 +2159 -0
  79. megadetector/detection/run_inference_with_yolov5_val.py +1314 -0
  80. megadetector/detection/run_md_and_speciesnet.py +1494 -0
  81. megadetector/detection/run_tiled_inference.py +1038 -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 +1752 -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 +2077 -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 +213 -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 +224 -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 +2832 -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 +1759 -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 +1940 -0
  140. megadetector/visualization/visualize_db.py +630 -0
  141. megadetector/visualization/visualize_detector_output.py +479 -0
  142. megadetector/visualization/visualize_video_output.py +705 -0
  143. megadetector-10.0.13.dist-info/METADATA +134 -0
  144. megadetector-10.0.13.dist-info/RECORD +147 -0
  145. megadetector-10.0.13.dist-info/WHEEL +5 -0
  146. megadetector-10.0.13.dist-info/licenses/LICENSE +19 -0
  147. megadetector-10.0.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,516 @@
1
+ """
2
+
3
+ crop_detections.py
4
+
5
+ Given a detections JSON file from MegaDetector, crops the bounding boxes above
6
+ a certain confidence threshold.
7
+
8
+ This script takes as input a detections JSON file, usually the output of
9
+ detection/run_tf_detector_batch.py or the output of the Batch API in the
10
+ "Batch processing API output format".
11
+
12
+ See https://github.com/agentmorris/MegaDetector/tree/main/megadetector/api/batch_processing.
13
+
14
+ The script can crop images that are either available locally or that need to be
15
+ downloaded from an Azure Blob Storage container.
16
+
17
+ We assume that no image contains over 100 bounding boxes, and we always save
18
+ crops as RGB .jpg files for consistency. For each image, each bounding box is
19
+ cropped and saved to a file with a suffix "___cropXX_mdvY.Y.jpg" added to the
20
+ filename as the original image. "XX" ranges from "00" to "99" and "Y.Y"
21
+ ndicates the MegaDetector version. Based on the given confidence threshold, we
22
+ may skip saving certain bounding box crops, but we still increment the bounding
23
+ box number for skipped boxes.
24
+
25
+ Example cropped image path (with MegaDetector bbox):
26
+
27
+ "path/to/image.jpg___crop00_mdv4.1.jpg"
28
+
29
+ By default, the images are cropped exactly per the given bounding box
30
+ coordinates. However, if square crops are desired, pass the --square-crops
31
+ flag. This will always generate a square crop whose size is the larger of the
32
+ bounding box width or height. In the case that the square crop boundaries exceed
33
+ the original image size, the crop is padded with 0s.
34
+
35
+ This script outputs a log file to:
36
+
37
+ <output_dir>/crop_detections_log_{timestamp}.json
38
+
39
+ ...which contains images that failed to download and crop properly.
40
+
41
+ """
42
+
43
+ #%% Imports
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ from collections.abc import Iterable, Mapping, Sequence
49
+ from concurrent import futures
50
+ from datetime import datetime
51
+ import io
52
+ import json
53
+ import os
54
+ from typing import Any, BinaryIO, Optional
55
+
56
+ from azure.storage.blob import ContainerClient
57
+ from PIL import Image, ImageOps
58
+ from tqdm import tqdm
59
+
60
+
61
+ #%% Example usage
62
+
63
+ """
64
+ python crop_detections.py \
65
+ detections.json \
66
+ /path/to/crops \
67
+ --images-dir /path/to/images \
68
+ --container-url "https://account.blob.core.windows.net/container?sastoken" \
69
+ --detector-version "4.1" \
70
+ --threshold 0.8 \
71
+ --save-full-images --square-crops \
72
+ --threads 50 \
73
+ --logdir "."
74
+ """
75
+
76
+
77
+ #%% Main function
78
+
79
+ def main(detections_json_path: str,
80
+ cropped_images_dir: str,
81
+ images_dir: Optional[str],
82
+ container_url: Optional[str],
83
+ detector_version: Optional[str],
84
+ save_full_images: bool,
85
+ square_crops: bool,
86
+ check_crops_valid: bool,
87
+ confidence_threshold: float,
88
+ threads: int,
89
+ logdir: str) -> None:
90
+ """
91
+ Args:
92
+ detections_json_path: str, path to detections JSON file
93
+ cropped_images_dir: str, path to local directory for saving crops of
94
+ bounding boxes
95
+ images_dir: optional str, path to local directory where images are saved
96
+ container_url: optional str, URL (with SAS token, if necessary) of Azure
97
+ Blob Storage container to download images from, if images are not
98
+ all already locally available in <images_dir>
99
+ detector_version: str, detector version string, e.g., '4.1',
100
+ see {batch_detection_api_url}/supported_model_versions
101
+ save_full_images: bool, whether to save downloaded images to images_dir,
102
+ images_dir must be given if save_full_images=True
103
+ square_crops: bool, whether to crop bounding boxes as squares
104
+ check_crops_valid: bool, whether to load each crop to ensure the file is
105
+ valid (i.e., not truncated)
106
+ confidence_threshold: float, only crop bounding boxes above this value
107
+ threads: int, number of threads to use for downloading images
108
+ logdir: str, path to directory to save log file
109
+ """
110
+
111
+ # error checking
112
+ assert 0 <= confidence_threshold <= 1, \
113
+ 'Invalid confidence threshold {}'.format(confidence_threshold)
114
+ if save_full_images:
115
+ assert images_dir is not None, \
116
+ 'save_full_images specified but no images_dir provided'
117
+ if not os.path.exists(images_dir):
118
+ os.makedirs(images_dir, exist_ok=True)
119
+ print(f'Created images_dir at {images_dir}')
120
+
121
+ # load detections JSON
122
+ with open(detections_json_path, 'r') as f:
123
+ js = json.load(f)
124
+ detections = {img['file']: img for img in js['images']}
125
+ detection_categories = js['detection_categories']
126
+
127
+ # get detector version
128
+ if 'info' in js and 'detector' in js['info']:
129
+ api_det_version = js['info']['detector'] # .rsplit('v', maxsplit=1)[1]
130
+ if detector_version is not None:
131
+ assert api_det_version == detector_version,\
132
+ '.json file specifies a detector version of {}, but the caller has specified {}'.format(
133
+ api_det_version,detector_version)
134
+ else:
135
+ detector_version = api_det_version
136
+ if detector_version is None:
137
+ detector_version = 'unknown'
138
+
139
+ # convert from category ID to category name
140
+ images_missing_detections = []
141
+
142
+ # copy keys to modify dict in-place
143
+ for img_path in list(detections.keys()):
144
+ info_dict = detections[img_path]
145
+ if 'detections' not in info_dict or info_dict['detections'] is None:
146
+ del detections[img_path]
147
+ images_missing_detections.append(img_path)
148
+ continue
149
+ for d in info_dict['detections']:
150
+ if d['category'] not in detection_categories:
151
+ print('Warning: ignoring detection with category {} for image {}'.format(
152
+ d['category'],img_path))
153
+ # This will be removed later when we filter for animals
154
+ d['category'] = 'unsupported'
155
+ else:
156
+ d['category'] = detection_categories[d['category']]
157
+
158
+ images_failed_dload_crop, num_downloads, num_crops = download_and_crop(
159
+ detections=detections,
160
+ cropped_images_dir=cropped_images_dir,
161
+ images_dir=images_dir,
162
+ container_url=container_url,
163
+ detector_version=detector_version,
164
+ confidence_threshold=confidence_threshold,
165
+ save_full_images=save_full_images,
166
+ square_crops=square_crops,
167
+ check_crops_valid=check_crops_valid,
168
+ threads=threads)
169
+ print(f'{len(images_failed_dload_crop)} images failed to download or crop.')
170
+
171
+ # save log of bad images
172
+ log = {
173
+ 'images_missing_detections': images_missing_detections,
174
+ 'images_failed_download_or_crop': images_failed_dload_crop,
175
+ 'num_new_downloads': num_downloads,
176
+ 'num_new_crops': num_crops
177
+ }
178
+ os.makedirs(logdir, exist_ok=True)
179
+ date = datetime.now().strftime('%Y%m%d_%H%M%S') # e.g., '20200722_110816'
180
+ log_path = os.path.join(logdir, f'crop_detections_log_{date}.json')
181
+ with open(log_path, 'w') as f:
182
+ json.dump(log, f, indent=1)
183
+
184
+
185
+ #%% Support functions
186
+
187
+ def download_and_crop(
188
+ detections: Mapping[str, Mapping[str, Any]],
189
+ cropped_images_dir: str,
190
+ images_dir: Optional[str],
191
+ container_url: Optional[str],
192
+ detector_version: str,
193
+ confidence_threshold: float,
194
+ save_full_images: bool,
195
+ square_crops: bool,
196
+ check_crops_valid: bool,
197
+ threads: int = 1
198
+ ) -> tuple[list[str], int, int]:
199
+ """
200
+ Saves crops to a file with the same name as the original image with an
201
+ additional suffix appended, starting with 3 underscores:
202
+ - if image has ground truth bboxes: "___cropXX.jpg", where "XX" indicates
203
+ the bounding box index
204
+ - if image has bboxes from MegaDetector: "___cropXX_mdvY.Y.jpg", where
205
+ "Y.Y" indicates the MegaDetector version
206
+ See module docstring for more info and examples.
207
+
208
+ Args:
209
+ detections: dict, maps image paths to info dict
210
+ {
211
+ "detections": [{
212
+ "category": "animal", # must be name, not "1" or "2"
213
+ "conf": 0.926,
214
+ "bbox": [0.0, 0.2762, 0.1539, 0.2825],
215
+ }],
216
+ "is_ground_truth": True # whether bboxes are ground truth
217
+ }
218
+ cropped_images_dir: str, path to folder where cropped images are saved
219
+ images_dir: optional str, path to folder where full images are saved
220
+ container_url: optional str, URL (with SAS token, if necessary) of Azure
221
+ Blob Storage container to download images from, if images are not
222
+ all already locally available in <images_dir>
223
+ detector_version: str, detector version string, e.g., '4.1'
224
+ confidence_threshold: float, only crop bounding boxes above this value
225
+ save_full_images: bool, whether to save downloaded images to images_dir,
226
+ images_dir must be given and must exist if save_full_images=True
227
+ square_crops: bool, whether to crop bounding boxes as squares
228
+ check_crops_valid: bool, whether to load each crop to ensure the file is
229
+ valid (i.e., not truncated)
230
+ threads: int, number of threads to use for downloading images
231
+
232
+ Returns:
233
+ images_failed_download: list of str, images with bounding boxes that
234
+ failed to download or crop properly
235
+ total_downloads: int, number of images downloaded
236
+ total_new_crops: int, number of new crops saved to cropped_images_dir
237
+ """
238
+
239
+ # True for ground truth, False for MegaDetector
240
+ # always save as .jpg for consistency
241
+ crop_path_template = {
242
+ True: os.path.join(cropped_images_dir, '{img_path}___crop{n:>02d}.jpg'),
243
+ False: os.path.join(
244
+ cropped_images_dir,
245
+ '{img_path}___crop{n:>02d}_' + f'{detector_version}.jpg')
246
+ }
247
+
248
+ pool = futures.ThreadPoolExecutor(max_workers=threads)
249
+ future_to_img_path = {}
250
+ images_failed_download = []
251
+
252
+ container_client = None
253
+ if container_url is not None:
254
+ container_client = ContainerClient.from_container_url(container_url)
255
+
256
+ print(f'Getting bbox info for {len(detections)} images...')
257
+ for img_path in tqdm(sorted(detections.keys())):
258
+ # we already did all error checking above, so we don't do any here
259
+ info_dict = detections[img_path]
260
+ bbox_dicts = info_dict['detections']
261
+ is_ground_truth = info_dict.get('is_ground_truth', False)
262
+
263
+ # get the image, either from disk or from Blob Storage
264
+ future = pool.submit(
265
+ load_and_crop, img_path, images_dir, container_client, bbox_dicts,
266
+ confidence_threshold, crop_path_template[is_ground_truth],
267
+ save_full_images, square_crops, check_crops_valid)
268
+ future_to_img_path[future] = img_path
269
+
270
+ total = len(future_to_img_path)
271
+ total_downloads = 0
272
+ total_new_crops = 0
273
+ print(f'Reading/downloading {total} images and cropping...')
274
+ for future in tqdm(futures.as_completed(future_to_img_path), total=total):
275
+ img_path = future_to_img_path[future]
276
+ try:
277
+ did_download, num_new_crops = future.result()
278
+ total_downloads += did_download
279
+ total_new_crops += num_new_crops
280
+ except Exception as e: # pylint: disable=broad-except
281
+ exception_type = type(e).__name__
282
+ tqdm.write(f'{img_path} - generated {exception_type}: {e}')
283
+ images_failed_download.append(img_path)
284
+
285
+ pool.shutdown()
286
+ if container_client is not None:
287
+ # inelegant way to close the container_client
288
+ with container_client:
289
+ pass
290
+
291
+ print(f'Downloaded {total_downloads} images.')
292
+ print(f'Made {total_new_crops} new crops.')
293
+ return images_failed_download, total_downloads, total_new_crops
294
+
295
+
296
+ def load_local_image(img_path: str | BinaryIO) -> Optional[Image.Image]:
297
+ """
298
+ Attempts to load an image from a local path.
299
+ """
300
+
301
+ try:
302
+ with Image.open(img_path) as img:
303
+ img.load()
304
+ return img
305
+ except OSError as e: # PIL.UnidentifiedImageError is a subclass of OSError
306
+ exception_type = type(e).__name__
307
+ tqdm.write(f'Unable to load {img_path}. {exception_type}: {e}.')
308
+ return None
309
+
310
+
311
+ def load_and_crop(img_path: str,
312
+ images_dir: Optional[str],
313
+ container_client: Optional[ContainerClient],
314
+ bbox_dicts: Iterable[Mapping[str, Any]],
315
+ confidence_threshold: float,
316
+ crop_path_template: str,
317
+ save_full_image: bool,
318
+ square_crops: bool,
319
+ check_crops_valid: bool) -> tuple[bool, int]:
320
+ """
321
+ Given an image and a list of bounding boxes, checks if the crops already
322
+ exist. If not, loads the image locally or Azure Blob Storage, then crops it.
323
+
324
+ local image path: <images_dir>/<img_path>
325
+ Azure storage: <img_path> as the blob name inside the container
326
+
327
+ An image is only downloaded from Azure Blob Storage if it does not already
328
+ exist locally and if it has at least 1 bounding box with confidence greater
329
+ than the confidence threshold.
330
+
331
+ Args:
332
+ img_path: str, image path
333
+ images_dir: optional str, path to local directory of images, and where
334
+ full images are saved if save_full_images=True
335
+ container_client: optional ContainerClient, this function does not
336
+ use container_client in any context manager
337
+ bbox_dicts: list of dicts, each dict contains info on a bounding box
338
+ confidence_threshold: float, only crop bounding boxes above this value
339
+ crop_path_template: str, contains placeholders {img_path} and {n}
340
+ save_full_images: bool, whether to save downloaded images to images_dir,
341
+ images_dir must be given and must exist if save_full_images=True
342
+ square_crops: bool, whether to crop bounding boxes as squares
343
+ check_crops_valid: bool, whether to load each crop to ensure the file is
344
+ valid (i.e., not truncated)
345
+
346
+ Returns:
347
+ did_download: bool, whether image was downloaded from Azure Blob Storage
348
+ num_new_crops: int, number of new crops successfully saved
349
+ """
350
+
351
+ did_download = False
352
+ num_new_crops = 0
353
+
354
+ # crop_path => normalized bbox coordinates [xmin, ymin, width, height]
355
+ bboxes_tocrop: dict[str, list[float]] = {}
356
+ for i, bbox_dict in enumerate(bbox_dicts):
357
+ # only ground-truth bboxes do not have a "confidence" value
358
+ if 'conf' in bbox_dict and bbox_dict['conf'] < confidence_threshold:
359
+ continue
360
+ if bbox_dict['category'] != 'animal':
361
+ continue
362
+ crop_path = crop_path_template.format(img_path=img_path, n=i)
363
+ if not os.path.exists(crop_path) or (
364
+ check_crops_valid and load_local_image(crop_path) is None):
365
+ bboxes_tocrop[crop_path] = bbox_dict['bbox']
366
+ if len(bboxes_tocrop) == 0:
367
+ return did_download, num_new_crops
368
+
369
+ img = None
370
+
371
+ # try loading image from local directory
372
+ if images_dir is not None:
373
+ full_img_path = os.path.join(images_dir, img_path)
374
+ debug_path = full_img_path
375
+ if os.path.exists(full_img_path):
376
+ img = load_local_image(full_img_path)
377
+
378
+ # try to download image from Blob Storage
379
+ if img is None and container_client is not None:
380
+ debug_path = img_path
381
+ with io.BytesIO() as stream:
382
+ container_client.download_blob(img_path).readinto(stream)
383
+ stream.seek(0)
384
+
385
+ if save_full_image:
386
+ os.makedirs(os.path.dirname(full_img_path), exist_ok=True)
387
+ with open(full_img_path, 'wb') as f:
388
+ f.write(stream.read())
389
+ stream.seek(0)
390
+
391
+ img = load_local_image(stream)
392
+ did_download = True
393
+
394
+ assert img is not None, 'image "{}" failed to load or download properly'.format(
395
+ debug_path)
396
+
397
+ if img.mode != 'RGB':
398
+ img = img.convert(mode='RGB') # always save as RGB for consistency
399
+
400
+ # crop the image
401
+ for crop_path, bbox in bboxes_tocrop.items():
402
+ num_new_crops += save_crop(
403
+ img, bbox_norm=bbox, square_crop=square_crops, save=crop_path)
404
+ return did_download, num_new_crops
405
+
406
+
407
+ def save_crop(img: Image.Image, bbox_norm: Sequence[float], square_crop: bool,
408
+ save: str) -> bool:
409
+ """
410
+ Crops an image and saves the crop to file.
411
+
412
+ Args:
413
+ img: PIL.Image.Image object, already loaded
414
+ bbox_norm: list or tuple of float, [xmin, ymin, width, height] all in
415
+ normalized coordinates
416
+ square_crop: bool, whether to crop bounding boxes as a square
417
+ save: str, path to save cropped image
418
+
419
+ Returns: bool, True if a crop was saved, False otherwise
420
+ """
421
+
422
+ img_w, img_h = img.size
423
+ xmin = int(bbox_norm[0] * img_w)
424
+ ymin = int(bbox_norm[1] * img_h)
425
+ box_w = int(bbox_norm[2] * img_w)
426
+ box_h = int(bbox_norm[3] * img_h)
427
+
428
+ if square_crop:
429
+ # expand box width or height to be square, but limit to img size
430
+ box_size = max(box_w, box_h)
431
+ xmin = max(0, min(
432
+ xmin - int((box_size - box_w) / 2),
433
+ img_w - box_w))
434
+ ymin = max(0, min(
435
+ ymin - int((box_size - box_h) / 2),
436
+ img_h - box_h))
437
+ box_w = min(img_w, box_size)
438
+ box_h = min(img_h, box_size)
439
+
440
+ if box_w == 0 or box_h == 0:
441
+ tqdm.write(f'Skipping size-0 crop (w={box_w}, h={box_h}) at {save}')
442
+ return False
443
+
444
+ # Image.crop() takes box=[left, upper, right, lower]
445
+ crop = img.crop(box=[xmin, ymin, xmin + box_w, ymin + box_h])
446
+
447
+ if square_crop and (box_w != box_h):
448
+ # pad to square using 0s
449
+ crop = ImageOps.pad(crop, size=(box_size, box_size), color=0)
450
+
451
+ os.makedirs(os.path.dirname(save), exist_ok=True)
452
+ crop.save(save)
453
+ return True
454
+
455
+
456
+ #%% Command-line driver
457
+
458
+ def _parse_args() -> argparse.Namespace:
459
+
460
+ parser = argparse.ArgumentParser(
461
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
462
+ description='Crop detections from MegaDetector.')
463
+ parser.add_argument(
464
+ 'detections_json',
465
+ help='path to detections JSON file')
466
+ parser.add_argument(
467
+ 'cropped_images_dir',
468
+ help='path to local directory for saving crops of bounding boxes')
469
+ parser.add_argument(
470
+ '-i', '--images-dir',
471
+ help='path to directory where full images are already available, '
472
+ 'or where images will be written if --save-full-images is set')
473
+ parser.add_argument(
474
+ '-c', '--container-url',
475
+ help='URL (including SAS token, if necessary) of Azure Blob Storage '
476
+ 'container to download images from, if images are not all already '
477
+ 'locally available in <images_dir>')
478
+ parser.add_argument(
479
+ '-v', '--detector-version',
480
+ help='detector version string, e.g., "4.1", used if detector version '
481
+ 'cannot be inferred from detections JSON')
482
+ parser.add_argument(
483
+ '--save-full-images', action='store_true',
484
+ help='forces downloading of full images to --images-dir')
485
+ parser.add_argument(
486
+ '--square-crops', action='store_true',
487
+ help='crop bounding boxes as squares')
488
+ parser.add_argument(
489
+ '--check-crops-valid', action='store_true',
490
+ help='load each crop to ensure file is valid (i.e., not truncated)')
491
+ parser.add_argument(
492
+ '-t', '--threshold', type=float, default=0.0,
493
+ help='confidence threshold above which to crop bounding boxes')
494
+ parser.add_argument(
495
+ '-n', '--threads', type=int, default=1,
496
+ help='number of threads to use for downloading and cropping images')
497
+ parser.add_argument(
498
+ '--logdir', default='.',
499
+ help='path to directory to save log file')
500
+ return parser.parse_args()
501
+
502
+
503
+ if __name__ == '__main__':
504
+
505
+ args = _parse_args()
506
+ main(detections_json_path=args.detections_json,
507
+ cropped_images_dir=args.cropped_images_dir,
508
+ images_dir=args.images_dir,
509
+ container_url=args.container_url,
510
+ detector_version=args.detector_version,
511
+ save_full_images=args.save_full_images,
512
+ square_crops=args.square_crops,
513
+ check_crops_valid=args.check_crops_valid,
514
+ confidence_threshold=args.threshold,
515
+ threads=args.threads,
516
+ logdir=args.logdir)