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,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)
|