megadetector 5.0.11__py3-none-any.whl → 5.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of megadetector might be problematic. Click here for more details.
- megadetector/api/__init__.py +0 -0
- megadetector/api/batch_processing/__init__.py +0 -0
- megadetector/api/batch_processing/api_core/__init__.py +0 -0
- megadetector/api/batch_processing/api_core/batch_service/__init__.py +0 -0
- megadetector/api/batch_processing/api_core/batch_service/score.py +439 -0
- megadetector/api/batch_processing/api_core/server.py +294 -0
- megadetector/api/batch_processing/api_core/server_api_config.py +97 -0
- megadetector/api/batch_processing/api_core/server_app_config.py +55 -0
- megadetector/api/batch_processing/api_core/server_batch_job_manager.py +220 -0
- megadetector/api/batch_processing/api_core/server_job_status_table.py +149 -0
- megadetector/api/batch_processing/api_core/server_orchestration.py +360 -0
- megadetector/api/batch_processing/api_core/server_utils.py +88 -0
- megadetector/api/batch_processing/api_core_support/__init__.py +0 -0
- megadetector/api/batch_processing/api_core_support/aggregate_results_manually.py +46 -0
- megadetector/api/batch_processing/api_support/__init__.py +0 -0
- megadetector/api/batch_processing/api_support/summarize_daily_activity.py +152 -0
- megadetector/api/batch_processing/data_preparation/__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/api/synchronous/__init__.py +0 -0
- megadetector/api/synchronous/api_core/animal_detection_api/__init__.py +0 -0
- megadetector/api/synchronous/api_core/animal_detection_api/api_backend.py +152 -0
- megadetector/api/synchronous/api_core/animal_detection_api/api_frontend.py +263 -0
- megadetector/api/synchronous/api_core/animal_detection_api/config.py +35 -0
- megadetector/api/synchronous/api_core/tests/__init__.py +0 -0
- megadetector/api/synchronous/api_core/tests/load_test.py +110 -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 +627 -0
- megadetector/classification/crop_detections.py +516 -0
- megadetector/classification/csv_to_json.py +226 -0
- megadetector/classification/detect_and_crop.py +855 -0
- megadetector/classification/efficientnet/__init__.py +9 -0
- megadetector/classification/efficientnet/model.py +415 -0
- megadetector/classification/efficientnet/utils.py +607 -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 +699 -0
- megadetector/classification/map_classification_categories.py +276 -0
- megadetector/classification/merge_classification_detection_output.py +506 -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/annotations/__init__.py +0 -0
- megadetector/data_management/annotations/annotation_constants.py +34 -0
- megadetector/data_management/camtrap_dp_to_coco.py +237 -0
- megadetector/data_management/cct_json_utils.py +404 -0
- megadetector/data_management/cct_to_md.py +176 -0
- megadetector/data_management/cct_to_wi.py +289 -0
- megadetector/data_management/coco_to_labelme.py +283 -0
- megadetector/data_management/coco_to_yolo.py +662 -0
- megadetector/data_management/databases/__init__.py +0 -0
- megadetector/data_management/databases/add_width_and_height_to_db.py +33 -0
- megadetector/data_management/databases/combine_coco_camera_traps_files.py +206 -0
- megadetector/data_management/databases/integrity_check_json_db.py +493 -0
- megadetector/data_management/databases/subset_json_db.py +115 -0
- megadetector/data_management/generate_crops_from_cct.py +149 -0
- megadetector/data_management/get_image_sizes.py +189 -0
- megadetector/data_management/importers/add_nacti_sizes.py +52 -0
- megadetector/data_management/importers/add_timestamps_to_icct.py +79 -0
- megadetector/data_management/importers/animl_results_to_md_results.py +158 -0
- megadetector/data_management/importers/auckland_doc_test_to_json.py +373 -0
- megadetector/data_management/importers/auckland_doc_to_json.py +201 -0
- megadetector/data_management/importers/awc_to_json.py +191 -0
- megadetector/data_management/importers/bellevue_to_json.py +273 -0
- megadetector/data_management/importers/cacophony-thermal-importer.py +793 -0
- megadetector/data_management/importers/carrizo_shrubfree_2018.py +269 -0
- megadetector/data_management/importers/carrizo_trail_cam_2017.py +289 -0
- megadetector/data_management/importers/cct_field_adjustments.py +58 -0
- megadetector/data_management/importers/channel_islands_to_cct.py +913 -0
- megadetector/data_management/importers/eMammal/copy_and_unzip_emammal.py +180 -0
- megadetector/data_management/importers/eMammal/eMammal_helpers.py +249 -0
- megadetector/data_management/importers/eMammal/make_eMammal_json.py +223 -0
- megadetector/data_management/importers/ena24_to_json.py +276 -0
- megadetector/data_management/importers/filenames_to_json.py +386 -0
- megadetector/data_management/importers/helena_to_cct.py +283 -0
- megadetector/data_management/importers/idaho-camera-traps.py +1407 -0
- megadetector/data_management/importers/idfg_iwildcam_lila_prep.py +294 -0
- megadetector/data_management/importers/jb_csv_to_json.py +150 -0
- megadetector/data_management/importers/mcgill_to_json.py +250 -0
- megadetector/data_management/importers/missouri_to_json.py +490 -0
- megadetector/data_management/importers/nacti_fieldname_adjustments.py +79 -0
- megadetector/data_management/importers/noaa_seals_2019.py +181 -0
- megadetector/data_management/importers/pc_to_json.py +365 -0
- megadetector/data_management/importers/plot_wni_giraffes.py +123 -0
- megadetector/data_management/importers/prepare-noaa-fish-data-for-lila.py +359 -0
- megadetector/data_management/importers/prepare_zsl_imerit.py +131 -0
- megadetector/data_management/importers/rspb_to_json.py +356 -0
- megadetector/data_management/importers/save_the_elephants_survey_A.py +320 -0
- megadetector/data_management/importers/save_the_elephants_survey_B.py +329 -0
- megadetector/data_management/importers/snapshot_safari_importer.py +758 -0
- megadetector/data_management/importers/snapshot_safari_importer_reprise.py +665 -0
- megadetector/data_management/importers/snapshot_serengeti_lila.py +1067 -0
- megadetector/data_management/importers/snapshotserengeti/make_full_SS_json.py +150 -0
- megadetector/data_management/importers/snapshotserengeti/make_per_season_SS_json.py +153 -0
- megadetector/data_management/importers/sulross_get_exif.py +65 -0
- megadetector/data_management/importers/timelapse_csv_set_to_json.py +490 -0
- megadetector/data_management/importers/ubc_to_json.py +399 -0
- megadetector/data_management/importers/umn_to_json.py +507 -0
- megadetector/data_management/importers/wellington_to_json.py +263 -0
- megadetector/data_management/importers/wi_to_json.py +442 -0
- megadetector/data_management/importers/zamba_results_to_md_results.py +181 -0
- megadetector/data_management/labelme_to_coco.py +547 -0
- megadetector/data_management/labelme_to_yolo.py +272 -0
- megadetector/data_management/lila/__init__.py +0 -0
- megadetector/data_management/lila/add_locations_to_island_camera_traps.py +97 -0
- megadetector/data_management/lila/add_locations_to_nacti.py +147 -0
- megadetector/data_management/lila/create_lila_blank_set.py +558 -0
- megadetector/data_management/lila/create_lila_test_set.py +152 -0
- megadetector/data_management/lila/create_links_to_md_results_files.py +106 -0
- megadetector/data_management/lila/download_lila_subset.py +178 -0
- megadetector/data_management/lila/generate_lila_per_image_labels.py +516 -0
- megadetector/data_management/lila/get_lila_annotation_counts.py +170 -0
- megadetector/data_management/lila/get_lila_image_counts.py +112 -0
- megadetector/data_management/lila/lila_common.py +300 -0
- megadetector/data_management/lila/test_lila_metadata_urls.py +132 -0
- megadetector/data_management/ocr_tools.py +870 -0
- megadetector/data_management/read_exif.py +809 -0
- megadetector/data_management/remap_coco_categories.py +84 -0
- megadetector/data_management/remove_exif.py +66 -0
- megadetector/data_management/rename_images.py +187 -0
- megadetector/data_management/resize_coco_dataset.py +189 -0
- megadetector/data_management/wi_download_csv_to_coco.py +247 -0
- megadetector/data_management/yolo_output_to_md_output.py +446 -0
- megadetector/data_management/yolo_to_coco.py +676 -0
- megadetector/detection/__init__.py +0 -0
- megadetector/detection/detector_training/__init__.py +0 -0
- megadetector/detection/detector_training/model_main_tf2.py +114 -0
- megadetector/detection/process_video.py +846 -0
- megadetector/detection/pytorch_detector.py +355 -0
- megadetector/detection/run_detector.py +779 -0
- megadetector/detection/run_detector_batch.py +1219 -0
- megadetector/detection/run_inference_with_yolov5_val.py +1087 -0
- megadetector/detection/run_tiled_inference.py +934 -0
- megadetector/detection/tf_detector.py +192 -0
- megadetector/detection/video_utils.py +698 -0
- megadetector/postprocessing/__init__.py +0 -0
- megadetector/postprocessing/add_max_conf.py +64 -0
- megadetector/postprocessing/categorize_detections_by_size.py +165 -0
- megadetector/postprocessing/classification_postprocessing.py +716 -0
- megadetector/postprocessing/combine_api_outputs.py +249 -0
- megadetector/postprocessing/compare_batch_results.py +966 -0
- megadetector/postprocessing/convert_output_format.py +396 -0
- megadetector/postprocessing/load_api_results.py +195 -0
- megadetector/postprocessing/md_to_coco.py +310 -0
- megadetector/postprocessing/md_to_labelme.py +330 -0
- megadetector/postprocessing/merge_detections.py +412 -0
- megadetector/postprocessing/postprocess_batch_results.py +1908 -0
- megadetector/postprocessing/remap_detection_categories.py +170 -0
- megadetector/postprocessing/render_detection_confusion_matrix.py +660 -0
- megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +211 -0
- megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +83 -0
- megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +1635 -0
- megadetector/postprocessing/separate_detections_into_folders.py +730 -0
- megadetector/postprocessing/subset_json_detector_output.py +700 -0
- megadetector/postprocessing/top_folders_to_bottom.py +223 -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 +150 -0
- megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +142 -0
- megadetector/taxonomy_mapping/preview_lila_taxonomy.py +588 -0
- megadetector/taxonomy_mapping/retrieve_sample_image.py +71 -0
- megadetector/taxonomy_mapping/simple_image_download.py +219 -0
- megadetector/taxonomy_mapping/species_lookup.py +834 -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/utils/__init__.py +0 -0
- megadetector/utils/azure_utils.py +178 -0
- megadetector/utils/ct_utils.py +613 -0
- megadetector/utils/directory_listing.py +246 -0
- megadetector/utils/md_tests.py +1164 -0
- megadetector/utils/path_utils.py +1045 -0
- megadetector/utils/process_utils.py +160 -0
- megadetector/utils/sas_blob_utils.py +509 -0
- megadetector/utils/split_locations_into_train_val.py +228 -0
- megadetector/utils/string_utils.py +92 -0
- megadetector/utils/url_utils.py +323 -0
- megadetector/utils/write_html_image_list.py +225 -0
- megadetector/visualization/__init__.py +0 -0
- megadetector/visualization/plot_utils.py +293 -0
- megadetector/visualization/render_images_with_thumbnails.py +275 -0
- megadetector/visualization/visualization_utils.py +1536 -0
- megadetector/visualization/visualize_db.py +552 -0
- megadetector/visualization/visualize_detector_output.py +405 -0
- {megadetector-5.0.11.dist-info → megadetector-5.0.13.dist-info}/LICENSE +0 -0
- {megadetector-5.0.11.dist-info → megadetector-5.0.13.dist-info}/METADATA +2 -2
- megadetector-5.0.13.dist-info/RECORD +201 -0
- megadetector-5.0.13.dist-info/top_level.txt +1 -0
- megadetector-5.0.11.dist-info/RECORD +0 -5
- megadetector-5.0.11.dist-info/top_level.txt +0 -1
- {megadetector-5.0.11.dist-info → megadetector-5.0.13.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))
|