megadetector 5.0.11__py3-none-any.whl → 5.0.12__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 (201) hide show
  1. megadetector/api/__init__.py +0 -0
  2. megadetector/api/batch_processing/__init__.py +0 -0
  3. megadetector/api/batch_processing/api_core/__init__.py +0 -0
  4. megadetector/api/batch_processing/api_core/batch_service/__init__.py +0 -0
  5. megadetector/api/batch_processing/api_core/batch_service/score.py +439 -0
  6. megadetector/api/batch_processing/api_core/server.py +294 -0
  7. megadetector/api/batch_processing/api_core/server_api_config.py +98 -0
  8. megadetector/api/batch_processing/api_core/server_app_config.py +55 -0
  9. megadetector/api/batch_processing/api_core/server_batch_job_manager.py +220 -0
  10. megadetector/api/batch_processing/api_core/server_job_status_table.py +152 -0
  11. megadetector/api/batch_processing/api_core/server_orchestration.py +360 -0
  12. megadetector/api/batch_processing/api_core/server_utils.py +92 -0
  13. megadetector/api/batch_processing/api_core_support/__init__.py +0 -0
  14. megadetector/api/batch_processing/api_core_support/aggregate_results_manually.py +46 -0
  15. megadetector/api/batch_processing/api_support/__init__.py +0 -0
  16. megadetector/api/batch_processing/api_support/summarize_daily_activity.py +152 -0
  17. megadetector/api/batch_processing/data_preparation/__init__.py +0 -0
  18. megadetector/api/batch_processing/integration/digiKam/setup.py +6 -0
  19. megadetector/api/batch_processing/integration/digiKam/xmp_integration.py +465 -0
  20. megadetector/api/batch_processing/integration/eMammal/test_scripts/config_template.py +5 -0
  21. megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +126 -0
  22. megadetector/api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +55 -0
  23. megadetector/api/synchronous/__init__.py +0 -0
  24. megadetector/api/synchronous/api_core/animal_detection_api/__init__.py +0 -0
  25. megadetector/api/synchronous/api_core/animal_detection_api/api_backend.py +152 -0
  26. megadetector/api/synchronous/api_core/animal_detection_api/api_frontend.py +266 -0
  27. megadetector/api/synchronous/api_core/animal_detection_api/config.py +35 -0
  28. megadetector/api/synchronous/api_core/tests/__init__.py +0 -0
  29. megadetector/api/synchronous/api_core/tests/load_test.py +110 -0
  30. megadetector/classification/__init__.py +0 -0
  31. megadetector/classification/aggregate_classifier_probs.py +108 -0
  32. megadetector/classification/analyze_failed_images.py +227 -0
  33. megadetector/classification/cache_batchapi_outputs.py +198 -0
  34. megadetector/classification/create_classification_dataset.py +627 -0
  35. megadetector/classification/crop_detections.py +516 -0
  36. megadetector/classification/csv_to_json.py +226 -0
  37. megadetector/classification/detect_and_crop.py +855 -0
  38. megadetector/classification/efficientnet/__init__.py +9 -0
  39. megadetector/classification/efficientnet/model.py +415 -0
  40. megadetector/classification/efficientnet/utils.py +610 -0
  41. megadetector/classification/evaluate_model.py +520 -0
  42. megadetector/classification/identify_mislabeled_candidates.py +152 -0
  43. megadetector/classification/json_to_azcopy_list.py +63 -0
  44. megadetector/classification/json_validator.py +699 -0
  45. megadetector/classification/map_classification_categories.py +276 -0
  46. megadetector/classification/merge_classification_detection_output.py +506 -0
  47. megadetector/classification/prepare_classification_script.py +194 -0
  48. megadetector/classification/prepare_classification_script_mc.py +228 -0
  49. megadetector/classification/run_classifier.py +287 -0
  50. megadetector/classification/save_mislabeled.py +110 -0
  51. megadetector/classification/train_classifier.py +827 -0
  52. megadetector/classification/train_classifier_tf.py +725 -0
  53. megadetector/classification/train_utils.py +323 -0
  54. megadetector/data_management/__init__.py +0 -0
  55. megadetector/data_management/annotations/__init__.py +0 -0
  56. megadetector/data_management/annotations/annotation_constants.py +34 -0
  57. megadetector/data_management/camtrap_dp_to_coco.py +239 -0
  58. megadetector/data_management/cct_json_utils.py +395 -0
  59. megadetector/data_management/cct_to_md.py +176 -0
  60. megadetector/data_management/cct_to_wi.py +289 -0
  61. megadetector/data_management/coco_to_labelme.py +272 -0
  62. megadetector/data_management/coco_to_yolo.py +662 -0
  63. megadetector/data_management/databases/__init__.py +0 -0
  64. megadetector/data_management/databases/add_width_and_height_to_db.py +33 -0
  65. megadetector/data_management/databases/combine_coco_camera_traps_files.py +206 -0
  66. megadetector/data_management/databases/integrity_check_json_db.py +477 -0
  67. megadetector/data_management/databases/subset_json_db.py +115 -0
  68. megadetector/data_management/generate_crops_from_cct.py +149 -0
  69. megadetector/data_management/get_image_sizes.py +189 -0
  70. megadetector/data_management/importers/add_nacti_sizes.py +52 -0
  71. megadetector/data_management/importers/add_timestamps_to_icct.py +79 -0
  72. megadetector/data_management/importers/animl_results_to_md_results.py +158 -0
  73. megadetector/data_management/importers/auckland_doc_test_to_json.py +373 -0
  74. megadetector/data_management/importers/auckland_doc_to_json.py +201 -0
  75. megadetector/data_management/importers/awc_to_json.py +191 -0
  76. megadetector/data_management/importers/bellevue_to_json.py +273 -0
  77. megadetector/data_management/importers/cacophony-thermal-importer.py +796 -0
  78. megadetector/data_management/importers/carrizo_shrubfree_2018.py +269 -0
  79. megadetector/data_management/importers/carrizo_trail_cam_2017.py +289 -0
  80. megadetector/data_management/importers/cct_field_adjustments.py +58 -0
  81. megadetector/data_management/importers/channel_islands_to_cct.py +913 -0
  82. megadetector/data_management/importers/eMammal/copy_and_unzip_emammal.py +180 -0
  83. megadetector/data_management/importers/eMammal/eMammal_helpers.py +249 -0
  84. megadetector/data_management/importers/eMammal/make_eMammal_json.py +223 -0
  85. megadetector/data_management/importers/ena24_to_json.py +276 -0
  86. megadetector/data_management/importers/filenames_to_json.py +386 -0
  87. megadetector/data_management/importers/helena_to_cct.py +283 -0
  88. megadetector/data_management/importers/idaho-camera-traps.py +1407 -0
  89. megadetector/data_management/importers/idfg_iwildcam_lila_prep.py +294 -0
  90. megadetector/data_management/importers/jb_csv_to_json.py +150 -0
  91. megadetector/data_management/importers/mcgill_to_json.py +250 -0
  92. megadetector/data_management/importers/missouri_to_json.py +490 -0
  93. megadetector/data_management/importers/nacti_fieldname_adjustments.py +79 -0
  94. megadetector/data_management/importers/noaa_seals_2019.py +181 -0
  95. megadetector/data_management/importers/pc_to_json.py +365 -0
  96. megadetector/data_management/importers/plot_wni_giraffes.py +123 -0
  97. megadetector/data_management/importers/prepare-noaa-fish-data-for-lila.py +359 -0
  98. megadetector/data_management/importers/prepare_zsl_imerit.py +131 -0
  99. megadetector/data_management/importers/rspb_to_json.py +356 -0
  100. megadetector/data_management/importers/save_the_elephants_survey_A.py +320 -0
  101. megadetector/data_management/importers/save_the_elephants_survey_B.py +329 -0
  102. megadetector/data_management/importers/snapshot_safari_importer.py +758 -0
  103. megadetector/data_management/importers/snapshot_safari_importer_reprise.py +665 -0
  104. megadetector/data_management/importers/snapshot_serengeti_lila.py +1067 -0
  105. megadetector/data_management/importers/snapshotserengeti/make_full_SS_json.py +150 -0
  106. megadetector/data_management/importers/snapshotserengeti/make_per_season_SS_json.py +153 -0
  107. megadetector/data_management/importers/sulross_get_exif.py +65 -0
  108. megadetector/data_management/importers/timelapse_csv_set_to_json.py +490 -0
  109. megadetector/data_management/importers/ubc_to_json.py +399 -0
  110. megadetector/data_management/importers/umn_to_json.py +507 -0
  111. megadetector/data_management/importers/wellington_to_json.py +263 -0
  112. megadetector/data_management/importers/wi_to_json.py +442 -0
  113. megadetector/data_management/importers/zamba_results_to_md_results.py +181 -0
  114. megadetector/data_management/labelme_to_coco.py +547 -0
  115. megadetector/data_management/labelme_to_yolo.py +272 -0
  116. megadetector/data_management/lila/__init__.py +0 -0
  117. megadetector/data_management/lila/add_locations_to_island_camera_traps.py +97 -0
  118. megadetector/data_management/lila/add_locations_to_nacti.py +147 -0
  119. megadetector/data_management/lila/create_lila_blank_set.py +558 -0
  120. megadetector/data_management/lila/create_lila_test_set.py +152 -0
  121. megadetector/data_management/lila/create_links_to_md_results_files.py +106 -0
  122. megadetector/data_management/lila/download_lila_subset.py +178 -0
  123. megadetector/data_management/lila/generate_lila_per_image_labels.py +516 -0
  124. megadetector/data_management/lila/get_lila_annotation_counts.py +170 -0
  125. megadetector/data_management/lila/get_lila_image_counts.py +112 -0
  126. megadetector/data_management/lila/lila_common.py +300 -0
  127. megadetector/data_management/lila/test_lila_metadata_urls.py +132 -0
  128. megadetector/data_management/ocr_tools.py +874 -0
  129. megadetector/data_management/read_exif.py +681 -0
  130. megadetector/data_management/remap_coco_categories.py +84 -0
  131. megadetector/data_management/remove_exif.py +66 -0
  132. megadetector/data_management/resize_coco_dataset.py +189 -0
  133. megadetector/data_management/wi_download_csv_to_coco.py +246 -0
  134. megadetector/data_management/yolo_output_to_md_output.py +441 -0
  135. megadetector/data_management/yolo_to_coco.py +676 -0
  136. megadetector/detection/__init__.py +0 -0
  137. megadetector/detection/detector_training/__init__.py +0 -0
  138. megadetector/detection/detector_training/model_main_tf2.py +114 -0
  139. megadetector/detection/process_video.py +702 -0
  140. megadetector/detection/pytorch_detector.py +341 -0
  141. megadetector/detection/run_detector.py +779 -0
  142. megadetector/detection/run_detector_batch.py +1219 -0
  143. megadetector/detection/run_inference_with_yolov5_val.py +917 -0
  144. megadetector/detection/run_tiled_inference.py +934 -0
  145. megadetector/detection/tf_detector.py +189 -0
  146. megadetector/detection/video_utils.py +606 -0
  147. megadetector/postprocessing/__init__.py +0 -0
  148. megadetector/postprocessing/add_max_conf.py +64 -0
  149. megadetector/postprocessing/categorize_detections_by_size.py +163 -0
  150. megadetector/postprocessing/combine_api_outputs.py +249 -0
  151. megadetector/postprocessing/compare_batch_results.py +958 -0
  152. megadetector/postprocessing/convert_output_format.py +396 -0
  153. megadetector/postprocessing/load_api_results.py +195 -0
  154. megadetector/postprocessing/md_to_coco.py +310 -0
  155. megadetector/postprocessing/md_to_labelme.py +330 -0
  156. megadetector/postprocessing/merge_detections.py +401 -0
  157. megadetector/postprocessing/postprocess_batch_results.py +1902 -0
  158. megadetector/postprocessing/remap_detection_categories.py +170 -0
  159. megadetector/postprocessing/render_detection_confusion_matrix.py +660 -0
  160. megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +211 -0
  161. megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +83 -0
  162. megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +1631 -0
  163. megadetector/postprocessing/separate_detections_into_folders.py +730 -0
  164. megadetector/postprocessing/subset_json_detector_output.py +696 -0
  165. megadetector/postprocessing/top_folders_to_bottom.py +223 -0
  166. megadetector/taxonomy_mapping/__init__.py +0 -0
  167. megadetector/taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +491 -0
  168. megadetector/taxonomy_mapping/map_new_lila_datasets.py +150 -0
  169. megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +142 -0
  170. megadetector/taxonomy_mapping/preview_lila_taxonomy.py +590 -0
  171. megadetector/taxonomy_mapping/retrieve_sample_image.py +71 -0
  172. megadetector/taxonomy_mapping/simple_image_download.py +219 -0
  173. megadetector/taxonomy_mapping/species_lookup.py +834 -0
  174. megadetector/taxonomy_mapping/taxonomy_csv_checker.py +159 -0
  175. megadetector/taxonomy_mapping/taxonomy_graph.py +346 -0
  176. megadetector/taxonomy_mapping/validate_lila_category_mappings.py +83 -0
  177. megadetector/utils/__init__.py +0 -0
  178. megadetector/utils/azure_utils.py +178 -0
  179. megadetector/utils/ct_utils.py +612 -0
  180. megadetector/utils/directory_listing.py +246 -0
  181. megadetector/utils/md_tests.py +968 -0
  182. megadetector/utils/path_utils.py +1044 -0
  183. megadetector/utils/process_utils.py +157 -0
  184. megadetector/utils/sas_blob_utils.py +509 -0
  185. megadetector/utils/split_locations_into_train_val.py +228 -0
  186. megadetector/utils/string_utils.py +92 -0
  187. megadetector/utils/url_utils.py +323 -0
  188. megadetector/utils/write_html_image_list.py +225 -0
  189. megadetector/visualization/__init__.py +0 -0
  190. megadetector/visualization/plot_utils.py +293 -0
  191. megadetector/visualization/render_images_with_thumbnails.py +275 -0
  192. megadetector/visualization/visualization_utils.py +1536 -0
  193. megadetector/visualization/visualize_db.py +550 -0
  194. megadetector/visualization/visualize_detector_output.py +405 -0
  195. {megadetector-5.0.11.dist-info → megadetector-5.0.12.dist-info}/METADATA +1 -1
  196. megadetector-5.0.12.dist-info/RECORD +199 -0
  197. megadetector-5.0.12.dist-info/top_level.txt +1 -0
  198. megadetector-5.0.11.dist-info/RECORD +0 -5
  199. megadetector-5.0.11.dist-info/top_level.txt +0 -1
  200. {megadetector-5.0.11.dist-info → megadetector-5.0.12.dist-info}/LICENSE +0 -0
  201. {megadetector-5.0.11.dist-info → megadetector-5.0.12.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1536 @@
1
+ """
2
+
3
+ visualization_utils.py
4
+
5
+ Rendering functions shared across visualization scripts
6
+
7
+ """
8
+
9
+ #%% Constants and imports
10
+
11
+ import time
12
+ import numpy as np
13
+ import requests
14
+ import os
15
+ import cv2
16
+
17
+ from io import BytesIO
18
+ from PIL import Image, ImageFile, ImageFont, ImageDraw
19
+ from multiprocessing.pool import ThreadPool
20
+ from multiprocessing.pool import Pool
21
+ from tqdm import tqdm
22
+ from functools import partial
23
+
24
+ from megadetector.utils.path_utils import find_images
25
+ from megadetector.data_management.annotations import annotation_constants
26
+ from megadetector.data_management.annotations.annotation_constants import \
27
+ detector_bbox_category_id_to_name
28
+
29
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
30
+
31
+ # Maps EXIF standard rotation identifiers to degrees. The value "1" indicates no
32
+ # rotation; this will be ignored. The values 2, 4, 5, and 7 are mirrored rotations,
33
+ # which are not supported (we'll assert() on this when we apply rotations).
34
+ EXIF_IMAGE_NO_ROTATION = 1
35
+ EXIF_IMAGE_ROTATIONS = {
36
+ 3: 180,
37
+ 6: 270,
38
+ 8: 90
39
+ }
40
+
41
+ TEXTALIGN_LEFT = 0
42
+ TEXTALIGN_RIGHT = 1
43
+
44
+ # Convert category ID from int to str
45
+ DEFAULT_DETECTOR_LABEL_MAP = {
46
+ str(k): v for k, v in detector_bbox_category_id_to_name.items()
47
+ }
48
+
49
+ # Constants controlling retry behavior when fetching images from URLs
50
+ n_retries = 10
51
+ retry_sleep_time = 0.01
52
+
53
+ # If we try to open an image from a URL, and we encounter any error in this list,
54
+ # we'll retry, otherwise it's just an error.
55
+ error_names_for_retry = ['ConnectionError']
56
+
57
+ DEFAULT_BOX_THICKNESS = 4
58
+ DEFAULT_LABEL_FONT_SIZE = 16
59
+
60
+ # Default color map for mapping integer category IDs to colors when rendering bounding
61
+ # boxes
62
+ DEFAULT_COLORS = [
63
+ 'AliceBlue', 'Red', 'RoyalBlue', 'Gold', 'Chartreuse', 'Aqua', 'Azure',
64
+ 'Beige', 'Bisque', 'BlanchedAlmond', 'BlueViolet', 'BurlyWood', 'CadetBlue',
65
+ 'AntiqueWhite', 'Chocolate', 'Coral', 'CornflowerBlue', 'Cornsilk', 'Crimson',
66
+ 'Cyan', 'DarkCyan', 'DarkGoldenRod', 'DarkGrey', 'DarkKhaki', 'DarkOrange',
67
+ 'DarkOrchid', 'DarkSalmon', 'DarkSeaGreen', 'DarkTurquoise', 'DarkViolet',
68
+ 'DeepPink', 'DeepSkyBlue', 'DodgerBlue', 'FireBrick', 'FloralWhite',
69
+ 'ForestGreen', 'Fuchsia', 'Gainsboro', 'GhostWhite', 'GoldenRod',
70
+ 'Salmon', 'Tan', 'HoneyDew', 'HotPink', 'IndianRed', 'Ivory', 'Khaki',
71
+ 'Lavender', 'LavenderBlush', 'LawnGreen', 'LemonChiffon', 'LightBlue',
72
+ 'LightCoral', 'LightCyan', 'LightGoldenRodYellow', 'LightGray', 'LightGrey',
73
+ 'LightGreen', 'LightPink', 'LightSalmon', 'LightSeaGreen', 'LightSkyBlue',
74
+ 'LightSlateGray', 'LightSlateGrey', 'LightSteelBlue', 'LightYellow', 'Lime',
75
+ 'LimeGreen', 'Linen', 'Magenta', 'MediumAquaMarine', 'MediumOrchid',
76
+ 'MediumPurple', 'MediumSeaGreen', 'MediumSlateBlue', 'MediumSpringGreen',
77
+ 'MediumTurquoise', 'MediumVioletRed', 'MintCream', 'MistyRose', 'Moccasin',
78
+ 'NavajoWhite', 'OldLace', 'Olive', 'OliveDrab', 'Orange', 'OrangeRed',
79
+ 'Orchid', 'PaleGoldenRod', 'PaleGreen', 'PaleTurquoise', 'PaleVioletRed',
80
+ 'PapayaWhip', 'PeachPuff', 'Peru', 'Pink', 'Plum', 'PowderBlue', 'Purple',
81
+ 'RosyBrown', 'Aquamarine', 'SaddleBrown', 'Green', 'SandyBrown',
82
+ 'SeaGreen', 'SeaShell', 'Sienna', 'Silver', 'SkyBlue', 'SlateBlue',
83
+ 'SlateGray', 'SlateGrey', 'Snow', 'SpringGreen', 'SteelBlue', 'GreenYellow',
84
+ 'Teal', 'Thistle', 'Tomato', 'Turquoise', 'Violet', 'Wheat', 'White',
85
+ 'WhiteSmoke', 'Yellow', 'YellowGreen'
86
+ ]
87
+
88
+
89
+ #%% Functions
90
+
91
+ def open_image(input_file, ignore_exif_rotation=False):
92
+ """
93
+ Opens an image in binary format using PIL.Image and converts to RGB mode.
94
+
95
+ Supports local files or URLs.
96
+
97
+ This operation is lazy; image will not be actually loaded until the first
98
+ operation that needs to load it (for example, resizing), so file opening
99
+ errors can show up later. load_image() is the non-lazy version of this function.
100
+
101
+ Args:
102
+ input_file (str or BytesIO): can be a path to an image file (anything
103
+ that PIL can open), a URL, or an image as a stream of bytes
104
+ ignore_exif_rotation (bool, optional): don't rotate the loaded pixels,
105
+ even if we are loading a JPEG and that JPEG says it should be rotated
106
+
107
+ Returns:
108
+ PIL.Image.Image: A PIL Image object in RGB mode
109
+ """
110
+
111
+ if (isinstance(input_file, str)
112
+ and input_file.startswith(('http://', 'https://'))):
113
+ try:
114
+ response = requests.get(input_file)
115
+ except Exception as e:
116
+ print(f'Error retrieving image {input_file}: {e}')
117
+ success = False
118
+ if e.__class__.__name__ in error_names_for_retry:
119
+ for i_retry in range(0,n_retries):
120
+ try:
121
+ time.sleep(retry_sleep_time)
122
+ response = requests.get(input_file)
123
+ except Exception as e:
124
+ print(f'Error retrieving image {input_file} on retry {i_retry}: {e}')
125
+ continue
126
+ print('Succeeded on retry {}'.format(i_retry))
127
+ success = True
128
+ break
129
+ if not success:
130
+ raise
131
+ try:
132
+ image = Image.open(BytesIO(response.content))
133
+ except Exception as e:
134
+ print(f'Error opening image {input_file}: {e}')
135
+ raise
136
+
137
+ else:
138
+ image = Image.open(input_file)
139
+
140
+ # Convert to RGB if necessary
141
+ if image.mode not in ('RGBA', 'RGB', 'L', 'I;16'):
142
+ raise AttributeError(
143
+ f'Image {input_file} uses unsupported mode {image.mode}')
144
+ if image.mode == 'RGBA' or image.mode == 'L':
145
+ # PIL.Image.convert() returns a converted copy of this image
146
+ image = image.convert(mode='RGB')
147
+
148
+ if not ignore_exif_rotation:
149
+ # Alter orientation as needed according to EXIF tag 0x112 (274) for Orientation
150
+ #
151
+ # https://gist.github.com/dangtrinhnt/a577ece4cbe5364aad28
152
+ # https://www.media.mit.edu/pia/Research/deepview/exif.html
153
+ #
154
+ try:
155
+ exif = image._getexif()
156
+ orientation: int = exif.get(274, None)
157
+ if (orientation is not None) and (orientation != EXIF_IMAGE_NO_ROTATION):
158
+ assert orientation in EXIF_IMAGE_ROTATIONS, \
159
+ 'Mirrored rotations are not supported'
160
+ image = image.rotate(EXIF_IMAGE_ROTATIONS[orientation], expand=True)
161
+ except Exception:
162
+ pass
163
+
164
+ return image
165
+
166
+ # ...def open_image(...)
167
+
168
+
169
+ def exif_preserving_save(pil_image,output_file,quality='keep',default_quality=85,verbose=False):
170
+ """
171
+ Saves [pil_image] to [output_file], making a moderate attempt to preserve EXIF
172
+ data and JPEG quality. Neither is guaranteed.
173
+
174
+ Also see:
175
+
176
+ https://discuss.dizzycoding.com/determining-jpg-quality-in-python-pil/
177
+
178
+ ...for more ways to preserve jpeg quality if quality='keep' doesn't do the trick.
179
+
180
+ Args:
181
+ pil_image (Image): the PIL Image objct to save
182
+ output_file (str): the destination file
183
+ quality (str or int, optional): can be "keep" (default), or an integer from 0 to 100.
184
+ This is only used if PIL thinks the the source image is a JPEG. If you load a JPEG
185
+ and resize it in memory, for example, it's no longer a JPEG.
186
+ default_quality (int, optional): determines output quality when quality == 'keep' and we are
187
+ saving a non-JPEG source to a JPEG file
188
+ verbose (bool, optional): enable additional debug console output
189
+ """
190
+
191
+ # Read EXIF metadata
192
+ exif = pil_image.info['exif'] if ('exif' in pil_image.info) else None
193
+
194
+ # Quality preservation is only supported for JPEG sources.
195
+ if pil_image.format != "JPEG":
196
+ if quality == 'keep':
197
+ if verbose:
198
+ print('Warning: quality "keep" passed when saving a non-JPEG source (during save to {})'.format(
199
+ output_file))
200
+ quality = default_quality
201
+
202
+ # Some output formats don't support the quality parameter, so we try once with,
203
+ # and once without. This is a horrible cascade of if's, but it's a consequence of
204
+ # the fact that "None" is not supported for either "exif" or "quality".
205
+
206
+ try:
207
+
208
+ if exif is not None:
209
+ pil_image.save(output_file, exif=exif, quality=quality)
210
+ else:
211
+ pil_image.save(output_file, quality=quality)
212
+
213
+ except Exception:
214
+
215
+ if verbose:
216
+ print('Warning: failed to write {}, trying again without quality parameter'.format(output_file))
217
+ if exif is not None:
218
+ pil_image.save(output_file, exif=exif)
219
+ else:
220
+ pil_image.save(output_file)
221
+
222
+ # ...def exif_preserving_save(...)
223
+
224
+
225
+ def load_image(input_file, ignore_exif_rotation=False):
226
+ """
227
+ Loads an image file. This is the non-lazy version of open_file(); i.e.,
228
+ it forces image decoding before returning.
229
+
230
+ Args:
231
+ input_file (str or BytesIO): can be a path to an image file (anything
232
+ that PIL can open), a URL, or an image as a stream of bytes
233
+ ignore_exif_rotation (bool, optional): don't rotate the loaded pixels,
234
+ even if we are loading a JPEG and that JPEG says it should be rotated
235
+
236
+ Returns:
237
+ PIL.Image.Image: a PIL Image object in RGB mode
238
+ """
239
+
240
+ image = open_image(input_file, ignore_exif_rotation=ignore_exif_rotation)
241
+ image.load()
242
+ return image
243
+
244
+
245
+ def resize_image(image, target_width=-1, target_height=-1, output_file=None,
246
+ no_enlarge_width=False, verbose=False, quality='keep'):
247
+ """
248
+ Resizes a PIL Image object to the specified width and height; does not resize
249
+ in place. If either width or height are -1, resizes with aspect ratio preservation.
250
+
251
+ If target_width and target_height are both -1, does not modify the image, but
252
+ will write to output_file if supplied.
253
+
254
+ If no resizing is required, and an Image object is supplied, returns the original Image
255
+ object (i.e., does not copy).
256
+
257
+ Args:
258
+ image (Image or str): PIL Image object or a filename (local file or URL)
259
+ target_width (int, optional): width to which we should resize this image, or -1
260
+ to let target_height determine the size
261
+ target_height (int, optional): height to which we should resize this image, or -1
262
+ to let target_width determine the size
263
+ output_file (str, optional): file to which we should save this image; if None,
264
+ just returns the image without saving
265
+ no_enlarge_width (bool, optional): if [no_enlarge_width] is True, and
266
+ [target width] is larger than the original image width, does not modify the image,
267
+ but will write to output_file if supplied
268
+ verbose (bool, optional): enable additional debug output
269
+ quality (str or int, optional): passed to exif_preserving_save, see docs for more detail
270
+
271
+ returns:
272
+ PIL.Image.Image: the resized image, which may be the original image if no resizing is
273
+ required
274
+ """
275
+
276
+ image_fn = 'in_memory'
277
+ if isinstance(image,str):
278
+ image_fn = image
279
+ image = load_image(image)
280
+
281
+ if target_width is None:
282
+ target_width = -1
283
+
284
+ if target_height is None:
285
+ target_height = -1
286
+
287
+ resize_required = True
288
+
289
+ # No resize was requested, this is always a no-op
290
+ if target_width == -1 and target_height == -1:
291
+
292
+ resize_required = False
293
+
294
+ # Does either dimension need to scale according to the other?
295
+ elif target_width == -1 or target_height == -1:
296
+
297
+ # Aspect ratio as width over height
298
+ # ar = w / h
299
+ aspect_ratio = image.size[0] / image.size[1]
300
+
301
+ if target_width != -1:
302
+ # h = w / ar
303
+ target_height = int(target_width / aspect_ratio)
304
+ else:
305
+ # w = ar * h
306
+ target_width = int(aspect_ratio * target_height)
307
+
308
+ # If we're not enlarging images and this would be an enlarge operation
309
+ if (no_enlarge_width) and (target_width > image.size[0]):
310
+
311
+ if verbose:
312
+ print('Bypassing image enlarge for {} --> {}'.format(
313
+ image_fn,str(output_file)))
314
+ resize_required = False
315
+
316
+ # If the target size is the same as the original size
317
+ if (target_width == image.size[0]) and (target_height == image.size[1]):
318
+
319
+ resize_required = False
320
+
321
+ if not resize_required:
322
+
323
+ if output_file is not None:
324
+ if verbose:
325
+ print('No resize required for resize {} --> {}'.format(
326
+ image_fn,str(output_file)))
327
+ exif_preserving_save(image,output_file,quality=quality,verbose=verbose)
328
+ return image
329
+
330
+ assert target_width > 0 and target_height > 0, \
331
+ 'Invalid image resize target {},{}'.format(target_width,target_height)
332
+
333
+ # The antialiasing parameter changed between Pillow versions 9 and 10, and for a bit,
334
+ # I'd like to support both.
335
+ try:
336
+ resized_image = image.resize((target_width, target_height), Image.ANTIALIAS)
337
+ except:
338
+ resized_image = image.resize((target_width, target_height), Image.Resampling.LANCZOS)
339
+
340
+ if output_file is not None:
341
+ exif_preserving_save(resized_image,output_file,quality=quality,verbose=verbose)
342
+
343
+ return resized_image
344
+
345
+ # ...def resize_image(...)
346
+
347
+
348
+ def crop_image(detections, image, confidence_threshold=0.15, expansion=0):
349
+ """
350
+ Crops detections above [confidence_threshold] from the PIL image [image],
351
+ returning a list of PIL Images.
352
+
353
+ Args:
354
+ detections (list): a list of dictionaries with keys 'conf' and 'bbox';
355
+ boxes are length-four arrays formatted as [x,y,w,h], normalized,
356
+ upper-left origin (this is the standard MD detection format)
357
+ image (Image): the PIL Image object from which we should crop detections
358
+ confidence_threshold (float, optional): only crop detections above this threshold
359
+ expansion (int, optional): a number of pixels to include on each side of a cropped
360
+ detection
361
+
362
+ Returns:
363
+ list: a possibly-empty list of PIL Image objects
364
+ """
365
+
366
+ ret_images = []
367
+
368
+ for detection in detections:
369
+
370
+ score = float(detection['conf'])
371
+
372
+ if score >= confidence_threshold:
373
+
374
+ x1, y1, w_box, h_box = detection['bbox']
375
+ ymin,xmin,ymax,xmax = y1, x1, y1 + h_box, x1 + w_box
376
+
377
+ # Convert to pixels so we can use the PIL crop() function
378
+ im_width, im_height = image.size
379
+ (left, right, top, bottom) = (xmin * im_width, xmax * im_width,
380
+ ymin * im_height, ymax * im_height)
381
+
382
+ if expansion > 0:
383
+ left -= expansion
384
+ right += expansion
385
+ top -= expansion
386
+ bottom += expansion
387
+
388
+ # PIL's crop() does surprising things if you provide values outside of
389
+ # the image, clip inputs
390
+ left = max(left,0); right = max(right,0)
391
+ top = max(top,0); bottom = max(bottom,0)
392
+
393
+ left = min(left,im_width-1); right = min(right,im_width-1)
394
+ top = min(top,im_height-1); bottom = min(bottom,im_height-1)
395
+
396
+ ret_images.append(image.crop((left, top, right, bottom)))
397
+
398
+ # ...if this detection is above threshold
399
+
400
+ # ...for each detection
401
+
402
+ return ret_images
403
+
404
+
405
+ def render_detection_bounding_boxes(detections,
406
+ image,
407
+ label_map='show_categories',
408
+ classification_label_map=None,
409
+ confidence_threshold=0.15,
410
+ thickness=DEFAULT_BOX_THICKNESS,
411
+ expansion=0,
412
+ classification_confidence_threshold=0.3,
413
+ max_classifications=3,
414
+ colormap=None,
415
+ textalign=TEXTALIGN_LEFT,
416
+ label_font_size=DEFAULT_LABEL_FONT_SIZE,
417
+ custom_strings=None):
418
+ """
419
+ Renders bounding boxes (with labels and confidence values) on an image for all
420
+ detections above a threshold.
421
+
422
+ Renders classification labels if present.
423
+
424
+ [image] is modified in place.
425
+
426
+ Args:
427
+
428
+ detections (list): list of detections in the MD output format, for example:
429
+
430
+ .. code-block::none
431
+
432
+ [
433
+ {
434
+ "category": "2",
435
+ "conf": 0.996,
436
+ "bbox": [
437
+ 0.0,
438
+ 0.2762,
439
+ 0.1234,
440
+ 0.2458
441
+ ]
442
+ }
443
+ ]
444
+
445
+ ...where the bbox coordinates are [x, y, box_width, box_height].
446
+
447
+ (0, 0) is the upper-left. Coordinates are normalized.
448
+
449
+ Supports classification results, in the standard format:
450
+
451
+ .. code-block::none
452
+
453
+ [
454
+ {
455
+ "category": "2",
456
+ "conf": 0.996,
457
+ "bbox": [
458
+ 0.0,
459
+ 0.2762,
460
+ 0.1234,
461
+ 0.2458
462
+ ]
463
+ "classifications": [
464
+ ["3", 0.901],
465
+ ["1", 0.071],
466
+ ["4", 0.025]
467
+ ]
468
+ }
469
+ ]
470
+
471
+ image (PIL.Image.Image): image on which we should render detections
472
+
473
+ label_map (dict, optional): optional, mapping the numeric label to a string name. The type of the
474
+ numeric label (typically strings) needs to be consistent with the keys in label_map; no casting is
475
+ carried out. If [label_map] is None, no labels are shown (not even numbers and confidence values).
476
+ If you want category numbers and confidence values without class labels, use the default value,
477
+ the string 'show_categories'.
478
+
479
+ classification_label_map (dict, optional): optional, mapping of the string class labels to the actual
480
+ class names. The type of the numeric label (typically strings) needs to be consistent with the keys
481
+ in label_map; no casting is carried out. If [label_map] is None, no labels are shown (not even numbers
482
+ and confidence values).
483
+
484
+ confidence_threshold (float or dict, optional), threshold above which boxes are rendered. Can also be a
485
+ dictionary mapping category IDs to thresholds.
486
+
487
+ thickness (int, optional): line thickness in pixels
488
+
489
+ expansion (int, optional): number of pixels to expand bounding boxes on each side
490
+
491
+ classification_confidence_threshold (float, optional): confidence above which classification results
492
+ are displayed
493
+
494
+ max_classifications (int, optional): maximum number of classification results rendered for one image
495
+
496
+ colormap (list, optional): list of color names, used to choose colors for categories by
497
+ indexing with the values in [classes]; defaults to a reasonable set of colors
498
+
499
+ textalign (int, optional): TEXTALIGN_LEFT or TEXTALIGN_RIGHT
500
+
501
+ label_font_size (float, optional): font size for labels
502
+
503
+ custom_strings: optional set of strings to append to detection labels, should have the
504
+ same length as [detections]. Appended before any classification labels.
505
+ """
506
+
507
+ # Input validation
508
+ if (label_map is not None) and (isinstance(label_map,str)) and (label_map == 'show_categories'):
509
+ label_map = {}
510
+
511
+ if custom_strings is not None:
512
+ assert len(custom_strings) == len(detections), \
513
+ '{} custom strings provided for {} detections'.format(
514
+ len(custom_strings),len(detections))
515
+
516
+ display_boxes = []
517
+
518
+ # list of lists, one list of strings for each bounding box (to accommodate multiple labels)
519
+ display_strs = []
520
+
521
+ # for color selection
522
+ classes = []
523
+
524
+ for i_detection,detection in enumerate(detections):
525
+
526
+ score = detection['conf']
527
+
528
+ if isinstance(confidence_threshold,dict):
529
+ rendering_threshold = confidence_threshold[detection['category']]
530
+ else:
531
+ rendering_threshold = confidence_threshold
532
+
533
+ # Always render objects with a confidence of "None", this is typically used
534
+ # for ground truth data.
535
+ if score is None or score >= rendering_threshold:
536
+
537
+ x1, y1, w_box, h_box = detection['bbox']
538
+ display_boxes.append([y1, x1, y1 + h_box, x1 + w_box])
539
+ clss = detection['category']
540
+
541
+ # {} is the default, which means "show labels with no mapping", so don't use "if label_map" here
542
+ # if label_map:
543
+ if label_map is not None:
544
+ label = label_map[clss] if clss in label_map else clss
545
+ if score is not None:
546
+ displayed_label = ['{}: {}%'.format(label, round(100 * score))]
547
+ else:
548
+ displayed_label = ['{}'.format(label)]
549
+ else:
550
+ displayed_label = ''
551
+
552
+ if custom_strings is not None:
553
+ custom_string = custom_strings[i_detection]
554
+ if custom_string is not None and len(custom_string) > 0:
555
+ if isinstance(displayed_label,str):
556
+ displayed_label += ' ' + custom_string
557
+ else:
558
+ assert len(displayed_label) == 1
559
+ displayed_label[0] += ' ' + custom_string
560
+
561
+ if 'classifications' in detection:
562
+
563
+ # To avoid duplicate colors with detection-only visualization, offset
564
+ # the classification class index by the number of detection classes
565
+ clss = annotation_constants.NUM_DETECTOR_CATEGORIES + int(detection['classifications'][0][0])
566
+ classifications = detection['classifications']
567
+ if len(classifications) > max_classifications:
568
+ classifications = classifications[0:max_classifications]
569
+
570
+ for classification in classifications:
571
+
572
+ classification_conf = classification[1]
573
+ if classification_conf is not None and \
574
+ classification_conf < classification_confidence_threshold:
575
+ continue
576
+ class_key = classification[0]
577
+ if (classification_label_map is not None) and (class_key in classification_label_map):
578
+ class_name = classification_label_map[class_key]
579
+ else:
580
+ class_name = class_key
581
+ if classification_conf is not None:
582
+ displayed_label += ['{}: {:5.1%}'.format(class_name.lower(), classification_conf)]
583
+ else:
584
+ displayed_label += ['{}'.format(class_name.lower())]
585
+
586
+ # ...for each classification
587
+
588
+ # ...if we have classification results
589
+
590
+ display_strs.append(displayed_label)
591
+ classes.append(clss)
592
+
593
+ # ...if the confidence of this detection is above threshold
594
+
595
+ # ...for each detection
596
+
597
+ display_boxes = np.array(display_boxes)
598
+
599
+ draw_bounding_boxes_on_image(image, display_boxes, classes,
600
+ display_strs=display_strs, thickness=thickness,
601
+ expansion=expansion, colormap=colormap, textalign=textalign,
602
+ label_font_size=label_font_size)
603
+
604
+ # ...render_detection_bounding_boxes(...)
605
+
606
+
607
+ def draw_bounding_boxes_on_image(image,
608
+ boxes,
609
+ classes,
610
+ thickness=DEFAULT_BOX_THICKNESS,
611
+ expansion=0,
612
+ display_strs=None,
613
+ colormap=None,
614
+ textalign=TEXTALIGN_LEFT,
615
+ label_font_size=DEFAULT_LABEL_FONT_SIZE):
616
+ """
617
+ Draws bounding boxes on an image. Modifies the image in place.
618
+
619
+ Args:
620
+
621
+ image (PIL.Image): the image on which we should draw boxes
622
+ boxes (np.array): a two-dimensional numpy array of size [N, 4], where N is the
623
+ number of boxes, and each row is (ymin, xmin, ymax, xmax). Coordinates should be
624
+ normalized to image height/width.
625
+ classes (list): a list of ints or string-formatted ints corresponding to the
626
+ class labels of the boxes. This is only used for color selection. Should have the same
627
+ length as [boxes].
628
+ thickness (int, optional): line thickness in pixels
629
+ expansion (int, optional): number of pixels to expand bounding boxes on each side
630
+ display_strs (list, optional): list of list of strings (the outer list should have the
631
+ same length as [boxes]). Typically this is used to show (possibly multiple) detection
632
+ or classification categories and/or confidence values.
633
+ colormap (list, optional): list of color names, used to choose colors for categories by
634
+ indexing with the values in [classes]; defaults to a reasonable set of colors
635
+ textalign (int, optional): TEXTALIGN_LEFT or TEXTALIGN_RIGHT
636
+ label_font_size (float, optional): font size for labels
637
+ """
638
+
639
+ boxes_shape = boxes.shape
640
+ if not boxes_shape:
641
+ return
642
+ if len(boxes_shape) != 2 or boxes_shape[1] != 4:
643
+ # print('Input must be of size [N, 4], but is ' + str(boxes_shape))
644
+ return # no object detection on this image, return
645
+ for i in range(boxes_shape[0]):
646
+ if display_strs:
647
+ display_str_list = display_strs[i]
648
+ draw_bounding_box_on_image(image,
649
+ boxes[i, 0], boxes[i, 1], boxes[i, 2], boxes[i, 3],
650
+ classes[i],
651
+ thickness=thickness, expansion=expansion,
652
+ display_str_list=display_str_list,
653
+ colormap=colormap,
654
+ textalign=textalign,
655
+ label_font_size=label_font_size)
656
+
657
+ # ...draw_bounding_boxes_on_image(...)
658
+
659
+
660
+ def draw_bounding_box_on_image(image,
661
+ ymin,
662
+ xmin,
663
+ ymax,
664
+ xmax,
665
+ clss=None,
666
+ thickness=DEFAULT_BOX_THICKNESS,
667
+ expansion=0,
668
+ display_str_list=None,
669
+ use_normalized_coordinates=True,
670
+ label_font_size=DEFAULT_LABEL_FONT_SIZE,
671
+ colormap=None,
672
+ textalign=TEXTALIGN_LEFT):
673
+ """
674
+ Adds a bounding box to an image. Modifies the image in place.
675
+
676
+ Bounding box coordinates can be specified in either absolute (pixel) or
677
+ normalized coordinates by setting the use_normalized_coordinates argument.
678
+
679
+ Each string in display_str_list is displayed on a separate line above the
680
+ bounding box in black text on a rectangle filled with the input 'color'.
681
+ If the top of the bounding box extends to the edge of the image, the strings
682
+ are displayed below the bounding box.
683
+
684
+ Adapted from:
685
+
686
+ https://github.com/tensorflow/models/blob/master/research/object_detection/utils/visualization_utils.py
687
+
688
+ Args:
689
+ image (PIL.Image.Image): the image on which we should draw a box
690
+ ymin (float): ymin of bounding box
691
+ xmin (float): xmin of bounding box
692
+ ymax (float): ymax of bounding box
693
+ xmax (float): xmax of bounding box
694
+ clss (int, optional): the class index of the object in this bounding box, used for choosing
695
+ a color; should be either an integer or a string-formatted integer
696
+ thickness (int, optional): line thickness in pixels
697
+ expansion (int, optional): number of pixels to expand bounding boxes on each side
698
+ display_str_list (list, optional): list of strings to display above the box (each to be shown on its
699
+ own line)
700
+ use_normalized_coordinates (bool, optional): if True (default), treat coordinates
701
+ ymin, xmin, ymax, xmax as relative to the image, otherwise coordinates as absolute pixel values
702
+ label_font_size (float, optional): font size
703
+ colormap (list, optional): list of color names, used to choose colors for categories by
704
+ indexing with the values in [classes]; defaults to a reasonable set of colors
705
+ textalign (int, optional): TEXTALIGN_LEFT or TEXTALIGN_RIGHT
706
+ """
707
+
708
+ if colormap is None:
709
+ colormap = DEFAULT_COLORS
710
+
711
+ if display_str_list is None:
712
+ display_str_list = []
713
+
714
+ if clss is None:
715
+ # Default to the MegaDetector animal class ID (1)
716
+ color = colormap[1]
717
+ else:
718
+ color = colormap[int(clss) % len(colormap)]
719
+
720
+ draw = ImageDraw.Draw(image)
721
+ im_width, im_height = image.size
722
+ if use_normalized_coordinates:
723
+ (left, right, top, bottom) = (xmin * im_width, xmax * im_width,
724
+ ymin * im_height, ymax * im_height)
725
+ else:
726
+ (left, right, top, bottom) = (xmin, xmax, ymin, ymax)
727
+
728
+ if expansion > 0:
729
+
730
+ left -= expansion
731
+ right += expansion
732
+ top -= expansion
733
+ bottom += expansion
734
+
735
+ # Deliberately trimming to the width of the image only in the case where
736
+ # box expansion is turned on. There's not an obvious correct behavior here,
737
+ # but the thinking is that if the caller provided an out-of-range bounding
738
+ # box, they meant to do that, but at least in the eyes of the person writing
739
+ # this comment, if you expand a box for visualization reasons, you don't want
740
+ # to end up with part of a box.
741
+ #
742
+ # A slightly more sophisticated might check whether it was in fact the expansion
743
+ # that made this box larger than the image, but this is the case 99.999% of the time
744
+ # here, so that doesn't seem necessary.
745
+ left = max(left,0); right = max(right,0)
746
+ top = max(top,0); bottom = max(bottom,0)
747
+
748
+ left = min(left,im_width-1); right = min(right,im_width-1)
749
+ top = min(top,im_height-1); bottom = min(bottom,im_height-1)
750
+
751
+ # ...if we need to expand boxes
752
+
753
+ draw.line([(left, top), (left, bottom), (right, bottom),
754
+ (right, top), (left, top)], width=thickness, fill=color)
755
+
756
+ try:
757
+ font = ImageFont.truetype('arial.ttf', label_font_size)
758
+ except IOError:
759
+ font = ImageFont.load_default()
760
+
761
+ def get_text_size(font,s):
762
+
763
+ # This is what we did w/Pillow 9
764
+ # w,h = font.getsize(s)
765
+
766
+ # I would *think* this would be the equivalent for Pillow 10
767
+ # l,t,r,b = font.getbbox(s); w = r-l; h=b-t
768
+
769
+ # ...but this actually produces the most similar results to Pillow 9
770
+ # l,t,r,b = font.getbbox(s); w = r; h=b
771
+
772
+ try:
773
+ l,t,r,b = font.getbbox(s); w = r; h=b
774
+ except Exception:
775
+ w,h = font.getsize(s)
776
+
777
+ return w,h
778
+
779
+ # If the total height of the display strings added to the top of the bounding
780
+ # box exceeds the top of the image, stack the strings below the bounding box
781
+ # instead of above.
782
+ display_str_heights = [get_text_size(font,ds)[1] for ds in display_str_list]
783
+
784
+ # Each display_str has a top and bottom margin of 0.05x.
785
+ total_display_str_height = (1 + 2 * 0.05) * sum(display_str_heights)
786
+
787
+ if top > total_display_str_height:
788
+ text_bottom = top
789
+ else:
790
+ text_bottom = bottom + total_display_str_height
791
+
792
+ # Reverse list and print from bottom to top.
793
+ for display_str in display_str_list[::-1]:
794
+
795
+ # Skip empty strings
796
+ if len(display_str) == 0:
797
+ continue
798
+
799
+ text_width, text_height = get_text_size(font,display_str)
800
+
801
+ text_left = left
802
+
803
+ if textalign == TEXTALIGN_RIGHT:
804
+ text_left = right - text_width
805
+
806
+ margin = np.ceil(0.05 * text_height)
807
+
808
+ draw.rectangle(
809
+ [(text_left, text_bottom - text_height - 2 * margin), (text_left + text_width,
810
+ text_bottom)],
811
+ fill=color)
812
+
813
+ draw.text(
814
+ (text_left + margin, text_bottom - text_height - margin),
815
+ display_str,
816
+ fill='black',
817
+ font=font)
818
+
819
+ text_bottom -= (text_height + 2 * margin)
820
+
821
+ # ...def draw_bounding_box_on_image(...)
822
+
823
+
824
+ def render_megadb_bounding_boxes(boxes_info, image):
825
+ """
826
+ Render bounding boxes to an image, where those boxes are in the mostly-deprecated
827
+ MegaDB format, which looks like:
828
+
829
+ .. code-block::none
830
+
831
+ {
832
+ "category": "animal",
833
+ "bbox": [
834
+ 0.739,
835
+ 0.448,
836
+ 0.187,
837
+ 0.198
838
+ ]
839
+ }
840
+
841
+ Args:
842
+ boxes_info (list): list of dicts, each dict represents a single detection
843
+ where bbox coordinates are normalized [x_min, y_min, width, height]
844
+ image (PIL.Image.Image): image to modify
845
+
846
+ :meta private:
847
+ """
848
+
849
+ display_boxes = []
850
+ display_strs = []
851
+ classes = [] # ints, for selecting colors
852
+
853
+ for b in boxes_info:
854
+ x_min, y_min, w_rel, h_rel = b['bbox']
855
+ y_max = y_min + h_rel
856
+ x_max = x_min + w_rel
857
+ display_boxes.append([y_min, x_min, y_max, x_max])
858
+ display_strs.append([b['category']])
859
+ classes.append(annotation_constants.detector_bbox_category_name_to_id[b['category']])
860
+
861
+ display_boxes = np.array(display_boxes)
862
+ draw_bounding_boxes_on_image(image, display_boxes, classes, display_strs=display_strs)
863
+
864
+ # ...def render_iMerit_boxes(...)
865
+
866
+
867
+ def render_db_bounding_boxes(boxes,
868
+ classes,
869
+ image,
870
+ original_size=None,
871
+ label_map=None,
872
+ thickness=DEFAULT_BOX_THICKNESS,
873
+ expansion=0):
874
+ """
875
+ Render bounding boxes (with class labels) on an image. This is a wrapper for
876
+ draw_bounding_boxes_on_image, allowing the caller to operate on a resized image
877
+ by providing the original size of the image; boxes will be scaled accordingly.
878
+
879
+ This function assumes that bounding boxes are in absolute coordinates, typically
880
+ because they come from COCO camera traps .json files.
881
+
882
+ Args:
883
+ boxes (list): list of length-4 tuples, foramtted as (x,y,w,h) (in pixels)
884
+ classes (list): list of ints (or string-formatted ints), used to choose labels (either
885
+ by literally rendering the class labels, or by indexing into [label_map])
886
+ image (PIL.Image.Image): image object to modify
887
+ original_size (tuple, optional): if this is not None, and the size is different than
888
+ the size of [image], we assume that [boxes] refer to the original size, and we scale
889
+ them accordingly before rendering
890
+ label_map (dict, optional): int --> str dictionary, typically mapping category IDs to
891
+ species labels; if None, category labels are rendered verbatim (typically as numbers)
892
+ thickness (int, optional): line width
893
+ expansion (int, optional): a number of pixels to include on each side of a cropped
894
+ detection
895
+ """
896
+
897
+ display_boxes = []
898
+ display_strs = []
899
+
900
+ if original_size is not None:
901
+ image_size = original_size
902
+ else:
903
+ image_size = image.size
904
+
905
+ img_width, img_height = image_size
906
+
907
+ for box, clss in zip(boxes, classes):
908
+
909
+ x_min_abs, y_min_abs, width_abs, height_abs = box[0:4]
910
+
911
+ ymin = y_min_abs / img_height
912
+ ymax = ymin + height_abs / img_height
913
+
914
+ xmin = x_min_abs / img_width
915
+ xmax = xmin + width_abs / img_width
916
+
917
+ display_boxes.append([ymin, xmin, ymax, xmax])
918
+
919
+ if label_map:
920
+ clss = label_map[int(clss)]
921
+
922
+ # need to be a string here because PIL needs to iterate through chars
923
+ display_strs.append([str(clss)])
924
+
925
+ display_boxes = np.array(display_boxes)
926
+
927
+ draw_bounding_boxes_on_image(image,
928
+ display_boxes,
929
+ classes,
930
+ display_strs=display_strs,
931
+ thickness=thickness,
932
+ expansion=expansion)
933
+
934
+ # ...def render_db_bounding_boxes(...)
935
+
936
+
937
+ def draw_bounding_boxes_on_file(input_file,
938
+ output_file,
939
+ detections,
940
+ confidence_threshold=0.0,
941
+ detector_label_map=DEFAULT_DETECTOR_LABEL_MAP,
942
+ thickness=DEFAULT_BOX_THICKNESS,
943
+ expansion=0,
944
+ colormap=None,
945
+ label_font_size=DEFAULT_LABEL_FONT_SIZE,
946
+ custom_strings=None,
947
+ target_size=None,
948
+ ignore_exif_rotation=False):
949
+ """
950
+ Renders detection bounding boxes on an image loaded from file, optionally writing the results to
951
+ a new image file.
952
+
953
+ Args:
954
+ input_file (str): filename or URL to load
955
+ output_file (str, optional): filename to which we should write the rendered image
956
+ detections (list): a list of dictionaries with keys 'conf' and 'bbox';
957
+ boxes are length-four arrays formatted as [x,y,w,h], normalized,
958
+ upper-left origin (this is the standard MD detection format)
959
+ detector_label_map (dict, optional): a dict mapping category IDs to strings. If this
960
+ is None, no confidence values or identifiers are shown If this is {}, just category
961
+ indices and confidence values are shown.
962
+ thickness (int, optional): line width in pixels for box rendering
963
+ expansion (int, optional): box expansion in pixels
964
+ colormap (list, optional): list of color names, used to choose colors for categories by
965
+ indexing with the values in [classes]; defaults to a reasonable set of colors
966
+ label_font_size (float, optional): label font size
967
+ custom_strings (list, optional): set of strings to append to detection labels, should have the
968
+ same length as [detections]. Appended before any classification labels.
969
+ target_size (tuple, optional): tuple of (target_width,target_height). Either or both can be -1,
970
+ see resize_image() for documentation. If None or (-1,-1), uses the original image size.
971
+ ignore_exif_rotation (bool, optional): don't rotate the loaded pixels,
972
+ even if we are loading a JPEG and that JPEG says it should be rotated.
973
+
974
+ Returns:
975
+ PIL.Image.Image: loaded and modified image
976
+ """
977
+
978
+ image = open_image(input_file, ignore_exif_rotation=ignore_exif_rotation)
979
+
980
+ if target_size is not None:
981
+ image = resize_image(image,target_size[0],target_size[1])
982
+
983
+ render_detection_bounding_boxes(
984
+ detections, image, label_map=detector_label_map,
985
+ confidence_threshold=confidence_threshold,
986
+ thickness=thickness,expansion=expansion,colormap=colormap,
987
+ custom_strings=custom_strings,label_font_size=label_font_size)
988
+
989
+ if output_file is not None:
990
+ image.save(output_file)
991
+
992
+ return image
993
+
994
+
995
+ def draw_db_boxes_on_file(input_file,
996
+ output_file,
997
+ boxes,
998
+ classes=None,
999
+ label_map=None,
1000
+ thickness=DEFAULT_BOX_THICKNESS,
1001
+ expansion=0,
1002
+ ignore_exif_rotation=False):
1003
+ """
1004
+ Render COCO-formatted bounding boxes (in absolute coordinates) on an image loaded from file,
1005
+ writing the results to a new image file.
1006
+
1007
+ Args:
1008
+ input_file (str): image file to read
1009
+ output_file (str): image file to write
1010
+ boxes (list): list of length-4 tuples, foramtted as (x,y,w,h) (in pixels)
1011
+ classes (list, optional): list of ints (or string-formatted ints), used to choose
1012
+ labels (either by literally rendering the class labels, or by indexing into [label_map])
1013
+ label_map (dict, optional): int --> str dictionary, typically mapping category IDs to
1014
+ species labels; if None, category labels are rendered verbatim (typically as numbers)
1015
+ thickness (int, optional): line width
1016
+ expansion (int, optional): a number of pixels to include on each side of a cropped
1017
+ detection
1018
+ ignore_exif_rotation (bool, optional): don't rotate the loaded pixels,
1019
+ even if we are loading a JPEG and that JPEG says it should be rotated
1020
+
1021
+ Returns:
1022
+ PIL.Image.Image: the loaded and modified image
1023
+ """
1024
+
1025
+ image = open_image(input_file, ignore_exif_rotation=ignore_exif_rotation)
1026
+
1027
+ if classes is None:
1028
+ classes = [0] * len(boxes)
1029
+
1030
+ render_db_bounding_boxes(boxes, classes, image, original_size=None,
1031
+ label_map=label_map, thickness=thickness, expansion=expansion)
1032
+
1033
+ image.save(output_file)
1034
+
1035
+ return image
1036
+
1037
+ # ...def draw_bounding_boxes_on_file(...)
1038
+
1039
+
1040
+ def gray_scale_fraction(image,crop_size=(0.1,0.1)):
1041
+ """
1042
+ Computes the fraction of the pixels in [image] that appear to be grayscale (R==G==B),
1043
+ useful for approximating whether this is a night-time image when flash information is not
1044
+ available in EXIF data (or for video frames, where this information is often not available
1045
+ in structured metadata at all).
1046
+
1047
+ Args:
1048
+ image (str or PIL.Image.Image): Image, filename, or URL to analyze
1049
+ crop_size (optional): a 2-element list/tuple, representing the fraction of the
1050
+ image to crop at the top and bottom, respectively, before analyzing (to minimize
1051
+ the possibility of including color elements in the image overlay)
1052
+
1053
+ Returns:
1054
+ float: the fraction of pixels in [image] that appear to be grayscale (R==G==B)
1055
+ """
1056
+
1057
+ if isinstance(image,str):
1058
+ image = Image.open(image)
1059
+
1060
+ if image.mode == 'L':
1061
+ return 1.0
1062
+
1063
+ if len(image.getbands()) == 1:
1064
+ return 1.0
1065
+
1066
+ # Crop if necessary
1067
+ if crop_size[0] > 0 or crop_size[1] > 0:
1068
+
1069
+ assert (crop_size[0] + crop_size[1]) < 1.0, \
1070
+ print('Illegal crop size: {}'.format(str(crop_size)))
1071
+
1072
+ top_crop_pixels = int(image.height * crop_size[0])
1073
+ bottom_crop_pixels = int(image.height * crop_size[1])
1074
+
1075
+ left = 0
1076
+ right = image.width
1077
+
1078
+ # Remove pixels from the top
1079
+ first_crop_top = top_crop_pixels
1080
+ first_crop_bottom = image.height
1081
+ first_crop = image.crop((left, first_crop_top, right, first_crop_bottom))
1082
+
1083
+ # Remove pixels from the bottom
1084
+ second_crop_top = 0
1085
+ second_crop_bottom = first_crop.height - bottom_crop_pixels
1086
+ second_crop = first_crop.crop((left, second_crop_top, right, second_crop_bottom))
1087
+
1088
+ image = second_crop
1089
+
1090
+ # It doesn't matter if these are actually R/G/B, they're just names
1091
+ r = np.array(image.getchannel(0))
1092
+ g = np.array(image.getchannel(1))
1093
+ b = np.array(image.getchannel(2))
1094
+
1095
+ gray_pixels = np.logical_and(r == g, r == b)
1096
+ n_pixels = gray_pixels.size
1097
+ n_gray_pixels = gray_pixels.sum()
1098
+
1099
+ return n_gray_pixels / n_pixels
1100
+
1101
+ # Non-numpy way to do the same thing, briefly keeping this here for posterity
1102
+ if False:
1103
+
1104
+ w, h = image.size
1105
+ n_pixels = w*h
1106
+ n_gray_pixels = 0
1107
+ for i in range(w):
1108
+ for j in range(h):
1109
+ r, g, b = image.getpixel((i,j))
1110
+ if r == g and r == b and g == b:
1111
+ n_gray_pixels += 1
1112
+
1113
+
1114
+ # ...def gray_scale_fraction(...)
1115
+
1116
+
1117
+ def _resize_relative_image(fn_relative,
1118
+ input_folder,output_folder,
1119
+ target_width,target_height,no_enlarge_width,verbose,quality):
1120
+ """
1121
+ Internal function for resizing an image from one folder to another,
1122
+ maintaining relative path.
1123
+ """
1124
+
1125
+ input_fn_abs = os.path.join(input_folder,fn_relative)
1126
+ output_fn_abs = os.path.join(output_folder,fn_relative)
1127
+ os.makedirs(os.path.dirname(output_fn_abs),exist_ok=True)
1128
+ try:
1129
+ _ = resize_image(input_fn_abs,
1130
+ output_file=output_fn_abs,
1131
+ target_width=target_width, target_height=target_height,
1132
+ no_enlarge_width=no_enlarge_width, verbose=verbose, quality=quality)
1133
+ status = 'success'
1134
+ error = None
1135
+ except Exception as e:
1136
+ if verbose:
1137
+ print('Error resizing {}: {}'.format(fn_relative,str(e)))
1138
+ status = 'error'
1139
+ error = str(e)
1140
+
1141
+ return {'fn_relative':fn_relative,'status':status,'error':error}
1142
+
1143
+ # ...def _resize_relative_image(...)
1144
+
1145
+
1146
+ def _resize_absolute_image(input_output_files,
1147
+ target_width,target_height,no_enlarge_width,verbose,quality):
1148
+
1149
+ """
1150
+ Internal wrapper for resize_image used in the context of a batch resize operation.
1151
+ """
1152
+
1153
+ input_fn_abs = input_output_files[0]
1154
+ output_fn_abs = input_output_files[1]
1155
+ os.makedirs(os.path.dirname(output_fn_abs),exist_ok=True)
1156
+ try:
1157
+ _ = resize_image(input_fn_abs,
1158
+ output_file=output_fn_abs,
1159
+ target_width=target_width, target_height=target_height,
1160
+ no_enlarge_width=no_enlarge_width, verbose=verbose, quality=quality)
1161
+ status = 'success'
1162
+ error = None
1163
+ except Exception as e:
1164
+ if verbose:
1165
+ print('Error resizing {}: {}'.format(input_fn_abs,str(e)))
1166
+ status = 'error'
1167
+ error = str(e)
1168
+
1169
+ return {'input_fn':input_fn_abs,'output_fn':output_fn_abs,status:'status',
1170
+ 'error':error}
1171
+
1172
+ # ..._resize_absolute_image(...)
1173
+
1174
+
1175
+ def resize_images(input_file_to_output_file,
1176
+ target_width=-1,
1177
+ target_height=-1,
1178
+ no_enlarge_width=False,
1179
+ verbose=False,
1180
+ quality='keep',
1181
+ pool_type='process',
1182
+ n_workers=10):
1183
+ """
1184
+ Resizes all images the dictionary [input_file_to_output_file].
1185
+
1186
+ TODO: This is a little more redundant with resize_image_folder than I would like;
1187
+ refactor resize_image_folder to call resize_images. Not doing that yet because
1188
+ at the time I'm writing this comment, a lot of code depends on resize_image_folder
1189
+ and I don't want to rock the boat yet.
1190
+
1191
+ Args:
1192
+ input_file_to_output_file (dict): dict mapping images that exist to the locations
1193
+ where the resized versions should be written
1194
+ target_width (int, optional): width to which we should resize this image, or -1
1195
+ to let target_height determine the size
1196
+ target_height (int, optional): height to which we should resize this image, or -1
1197
+ to let target_width determine the size
1198
+ no_enlarge_width (bool, optional): if [no_enlarge_width] is True, and
1199
+ [target width] is larger than the original image width, does not modify the image,
1200
+ but will write to output_file if supplied
1201
+ verbose (bool, optional): enable additional debug output
1202
+ quality (str or int, optional): passed to exif_preserving_save, see docs for more detail
1203
+ pool_type (str, optional): whether use use processes ('process') or threads ('thread') for
1204
+ parallelization; ignored if n_workers <= 1
1205
+ n_workers (int, optional): number of workers to use for parallel resizing; set to <=1
1206
+ to disable parallelization
1207
+
1208
+ Returns:
1209
+ list: a list of dicts with keys 'input_fn', 'output_fn', 'status', and 'error'.
1210
+ 'status' will be 'success' or 'error'; 'error' will be None for successful cases,
1211
+ otherwise will contain the image-specific error.
1212
+ """
1213
+
1214
+ assert pool_type in ('process','thread'), 'Illegal pool type {}'.format(pool_type)
1215
+
1216
+ input_output_file_pairs = []
1217
+
1218
+ # Reformat input files as (input,output) tuples
1219
+ for input_fn in input_file_to_output_file:
1220
+ input_output_file_pairs.append((input_fn,input_file_to_output_file[input_fn]))
1221
+
1222
+ if n_workers == 1:
1223
+
1224
+ results = []
1225
+ for i_o_file_pair in tqdm(input_output_file_pairs):
1226
+ results.append(_resize_absolute_image(i_o_file_pair,
1227
+ target_width=target_width,
1228
+ target_height=target_height,
1229
+ no_enlarge_width=no_enlarge_width,
1230
+ verbose=verbose,
1231
+ quality=quality))
1232
+
1233
+ else:
1234
+
1235
+ if pool_type == 'thread':
1236
+ pool = ThreadPool(n_workers); poolstring = 'threads'
1237
+ else:
1238
+ assert pool_type == 'process'
1239
+ pool = Pool(n_workers); poolstring = 'processes'
1240
+
1241
+ if verbose:
1242
+ print('Starting resizing pool with {} {}'.format(n_workers,poolstring))
1243
+
1244
+ p = partial(_resize_absolute_image,
1245
+ target_width=target_width,
1246
+ target_height=target_height,
1247
+ no_enlarge_width=no_enlarge_width,
1248
+ verbose=verbose,
1249
+ quality=quality)
1250
+
1251
+ results = list(tqdm(pool.imap(p, input_output_file_pairs),total=len(input_output_file_pairs)))
1252
+
1253
+ return results
1254
+
1255
+ # ...def resize_images(...)
1256
+
1257
+
1258
+ def resize_image_folder(input_folder,
1259
+ output_folder=None,
1260
+ target_width=-1,
1261
+ target_height=-1,
1262
+ no_enlarge_width=False,
1263
+ verbose=False,
1264
+ quality='keep',
1265
+ pool_type='process',
1266
+ n_workers=10,
1267
+ recursive=True,
1268
+ image_files_relative=None):
1269
+ """
1270
+ Resize all images in a folder (defaults to recursive).
1271
+
1272
+ Defaults to in-place resizing (output_folder is optional).
1273
+
1274
+ Args:
1275
+ input_folder (str): folder in which we should find images to resize
1276
+ output_folder (str, optional): folder in which we should write resized images. If
1277
+ None, resizes images in place. Otherwise, maintains relative paths in the target
1278
+ folder.
1279
+ target_width (int, optional): width to which we should resize this image, or -1
1280
+ to let target_height determine the size
1281
+ target_height (int, optional): height to which we should resize this image, or -1
1282
+ to let target_width determine the size
1283
+ no_enlarge_width (bool, optional): if [no_enlarge_width] is True, and
1284
+ [target width] is larger than the original image width, does not modify the image,
1285
+ but will write to output_file if supplied
1286
+ verbose (bool, optional): enable additional debug output
1287
+ quality (str or int, optional): passed to exif_preserving_save, see docs for more detail
1288
+ pool_type (str, optional): whether use use processes ('process') or threads ('thread') for
1289
+ parallelization; ignored if n_workers <= 1
1290
+ n_workers (int, optional): number of workers to use for parallel resizing; set to <=1
1291
+ to disable parallelization
1292
+ recursive (bool, optional): whether to search [input_folder] recursively for images.
1293
+ image_files_relative (list, optional): if not None, skips any relative paths not
1294
+ in this list.
1295
+
1296
+ Returns:
1297
+ list: a list of dicts with keys 'input_fn', 'output_fn', 'status', and 'error'.
1298
+ 'status' will be 'success' or 'error'; 'error' will be None for successful cases,
1299
+ otherwise will contain the image-specific error.
1300
+ """
1301
+
1302
+ assert os.path.isdir(input_folder), '{} is not a folder'.format(input_folder)
1303
+
1304
+ if output_folder is None:
1305
+ output_folder = input_folder
1306
+ else:
1307
+ os.makedirs(output_folder,exist_ok=True)
1308
+
1309
+ assert pool_type in ('process','thread'), 'Illegal pool type {}'.format(pool_type)
1310
+
1311
+ if image_files_relative is None:
1312
+
1313
+ if verbose:
1314
+ print('Enumerating images')
1315
+
1316
+ image_files_relative = find_images(input_folder,recursive=recursive,
1317
+ return_relative_paths=True,convert_slashes=True)
1318
+ if verbose:
1319
+ print('Found {} images'.format(len(image_files_relative)))
1320
+
1321
+ if n_workers == 1:
1322
+
1323
+ if verbose:
1324
+ print('Resizing images')
1325
+
1326
+ results = []
1327
+ for fn_relative in tqdm(image_files_relative):
1328
+ results.append(_resize_relative_image(fn_relative,
1329
+ input_folder=input_folder,
1330
+ output_folder=output_folder,
1331
+ target_width=target_width,
1332
+ target_height=target_height,
1333
+ no_enlarge_width=no_enlarge_width,
1334
+ verbose=verbose,
1335
+ quality=quality))
1336
+
1337
+ else:
1338
+
1339
+ if pool_type == 'thread':
1340
+ pool = ThreadPool(n_workers); poolstring = 'threads'
1341
+ else:
1342
+ assert pool_type == 'process'
1343
+ pool = Pool(n_workers); poolstring = 'processes'
1344
+
1345
+ if verbose:
1346
+ print('Starting resizing pool with {} {}'.format(n_workers,poolstring))
1347
+
1348
+ p = partial(_resize_relative_image,
1349
+ input_folder=input_folder,
1350
+ output_folder=output_folder,
1351
+ target_width=target_width,
1352
+ target_height=target_height,
1353
+ no_enlarge_width=no_enlarge_width,
1354
+ verbose=verbose,
1355
+ quality=quality)
1356
+
1357
+ results = list(tqdm(pool.imap(p, image_files_relative),total=len(image_files_relative)))
1358
+
1359
+ return results
1360
+
1361
+ # ...def resize_image_folder(...)
1362
+
1363
+
1364
+ #%% Image integrity checking functions
1365
+
1366
+ def check_image_integrity(filename,modes=None):
1367
+ """
1368
+ Check whether we can successfully load an image via OpenCV and/or PIL.
1369
+
1370
+ Args:
1371
+ filename (str): the filename to evaluate
1372
+ modes (list, optional): a list containing one or more of:
1373
+
1374
+ - 'cv'
1375
+ - 'pil'
1376
+ - 'skimage'
1377
+ - 'jpeg_trailer'
1378
+
1379
+ 'jpeg_trailer' checks that the binary data ends with ffd9. It does not check whether
1380
+ the image is actually a jpeg, and even if it is, there are lots of reasons the image might not
1381
+ end with ffd9. It's also true the JPEGs that cause "premature end of jpeg segment" issues
1382
+ don't end with ffd9, so this may be a useful diagnostic. High precision, very low recall
1383
+ for corrupt jpegs.
1384
+
1385
+ Set to None to use all modes.
1386
+
1387
+ Returns:
1388
+ dict: a dict with a key called 'file' (the value of [filename]), one key for each string in
1389
+ [modes] (a success indicator for that mode, specifically a string starting with either
1390
+ 'success' or 'error').
1391
+ """
1392
+
1393
+ if modes is None:
1394
+ modes = ('cv','pil','skimage','jpeg_trailer')
1395
+ else:
1396
+ if isinstance(modes,str):
1397
+ modes = [modes]
1398
+ for mode in modes:
1399
+ assert mode in ('cv','pil','skimage'), 'Unrecognized mode {}'.format(mode)
1400
+
1401
+ assert os.path.isfile(filename), 'Could not find file {}'.format(filename)
1402
+
1403
+ result = {}
1404
+ result['file'] = filename
1405
+
1406
+ for mode in modes:
1407
+
1408
+ result[mode] = 'unknown'
1409
+ if mode == 'pil':
1410
+ try:
1411
+ pil_im = load_image(filename) # noqa
1412
+ assert pil_im is not None
1413
+ result[mode] = 'success'
1414
+ except Exception as e:
1415
+ result[mode] = 'error: {}'.format(str(e))
1416
+ elif mode == 'cv':
1417
+ try:
1418
+ cv_im = cv2.imread(filename)
1419
+ assert cv_im is not None, 'Unknown opencv read failure'
1420
+ numpy_im = np.asarray(cv_im) # noqa
1421
+ result[mode] = 'success'
1422
+ except Exception as e:
1423
+ result[mode] = 'error: {}'.format(str(e))
1424
+ elif mode == 'skimage':
1425
+ try:
1426
+ # This is not a standard dependency
1427
+ from skimage import io as skimage_io # noqa
1428
+ except Exception:
1429
+ result[mode] = 'could not import skimage, run pip install scikit-image'
1430
+ return result
1431
+ try:
1432
+ skimage_im = skimage_io.imread(filename) # noqa
1433
+ assert skimage_im is not None
1434
+ result[mode] = 'success'
1435
+ except Exception as e:
1436
+ result[mode] = 'error: {}'.format(str(e))
1437
+ elif mode == 'jpeg_trailer':
1438
+ # https://stackoverflow.com/a/48282863/16644970
1439
+ try:
1440
+ with open(filename, 'rb') as f:
1441
+ check_chars = f.read()[-2:]
1442
+ if check_chars != b'\xff\xd9':
1443
+ result[mode] = 'invalid jpeg trailer: {}'.format(str(check_chars))
1444
+ else:
1445
+ result[mode] = 'success'
1446
+ except Exception as e:
1447
+ result[mode] = 'error: {}'.format(str(e))
1448
+
1449
+ # ...for each mode
1450
+
1451
+ return result
1452
+
1453
+ # ...def check_image_integrity(...)
1454
+
1455
+
1456
+ def parallel_check_image_integrity(filenames,
1457
+ modes=None,
1458
+ max_workers=16,
1459
+ use_threads=True,
1460
+ recursive=True):
1461
+ """
1462
+ Check whether we can successfully load a list of images via OpenCV and/or PIL.
1463
+
1464
+ Args:
1465
+ filenames (list or str): a list of image filenames or a folder
1466
+ mode (list): see check_image_integrity() for documentation on the [modes] parameter
1467
+ max_workers (int, optional): the number of parallel workers to use; set to <=1 to disable
1468
+ parallelization
1469
+ use_threads (bool, optional): whether to use threads (True) or processes (False) for
1470
+ parallelization
1471
+ recursive (bool, optional): if [filenames] is a folder, whether to search recursively for images.
1472
+ Ignored if [filenames] is a list.
1473
+
1474
+ Returns:
1475
+ list: a list of dicts, each with a key called 'file' (the value of [filename]), one key for
1476
+ each string in [modes] (a success indicator for that mode, specifically a string starting
1477
+ with either 'success' or 'error').
1478
+ """
1479
+
1480
+ n_workers = min(max_workers,len(filenames))
1481
+
1482
+ if isinstance(filenames,str) and os.path.isdir(filenames):
1483
+ filenames = find_images(filenames,recursive=recursive,return_relative_paths=False)
1484
+
1485
+ print('Checking image integrity for {} filenames'.format(len(filenames)))
1486
+
1487
+ if n_workers <= 1:
1488
+
1489
+ results = []
1490
+ for filename in filenames:
1491
+ results.append(check_image_integrity(filename,modes=modes))
1492
+
1493
+ else:
1494
+
1495
+ if use_threads:
1496
+ pool = ThreadPool(n_workers)
1497
+ else:
1498
+ pool = Pool(n_workers)
1499
+
1500
+ results = list(tqdm(pool.imap(
1501
+ partial(check_image_integrity,modes=modes),filenames), total=len(filenames)))
1502
+
1503
+ return results
1504
+
1505
+
1506
+ #%% Test drivers
1507
+
1508
+ if False:
1509
+
1510
+ #%% Recursive resize test
1511
+
1512
+ from megadetector.visualization.visualization_utils import resize_image_folder # noqa
1513
+
1514
+ input_folder = r"C:\temp\resize-test\in"
1515
+ output_folder = r"C:\temp\resize-test\out"
1516
+
1517
+ resize_results = resize_image_folder(input_folder,output_folder,
1518
+ target_width=1280,verbose=True,quality=85,no_enlarge_width=True,
1519
+ pool_type='process',n_workers=10)
1520
+
1521
+
1522
+ #%% Integrity checking test
1523
+
1524
+ from megadetector.utils import md_tests
1525
+ options = md_tests.download_test_data()
1526
+ folder = options.scratch_dir
1527
+
1528
+ results = parallel_check_image_integrity(folder,max_workers=8)
1529
+
1530
+ modes = ['cv','pil','skimage','jpeg_trailer']
1531
+
1532
+ for r in results:
1533
+ for mode in modes:
1534
+ if r[mode] != 'success':
1535
+ s = r[mode]
1536
+ print('Mode {} failed for {}:\n{}\n'.format(mode,r['file'],s))