megadetector 5.0.9__py3-none-any.whl → 5.0.11__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 (226) hide show
  1. {megadetector-5.0.9.dist-info → megadetector-5.0.11.dist-info}/LICENSE +0 -0
  2. {megadetector-5.0.9.dist-info → megadetector-5.0.11.dist-info}/METADATA +12 -11
  3. megadetector-5.0.11.dist-info/RECORD +5 -0
  4. megadetector-5.0.11.dist-info/top_level.txt +1 -0
  5. api/__init__.py +0 -0
  6. api/batch_processing/__init__.py +0 -0
  7. api/batch_processing/api_core/__init__.py +0 -0
  8. api/batch_processing/api_core/batch_service/__init__.py +0 -0
  9. api/batch_processing/api_core/batch_service/score.py +0 -439
  10. api/batch_processing/api_core/server.py +0 -294
  11. api/batch_processing/api_core/server_api_config.py +0 -98
  12. api/batch_processing/api_core/server_app_config.py +0 -55
  13. api/batch_processing/api_core/server_batch_job_manager.py +0 -220
  14. api/batch_processing/api_core/server_job_status_table.py +0 -152
  15. api/batch_processing/api_core/server_orchestration.py +0 -360
  16. api/batch_processing/api_core/server_utils.py +0 -92
  17. api/batch_processing/api_core_support/__init__.py +0 -0
  18. api/batch_processing/api_core_support/aggregate_results_manually.py +0 -46
  19. api/batch_processing/api_support/__init__.py +0 -0
  20. api/batch_processing/api_support/summarize_daily_activity.py +0 -152
  21. api/batch_processing/data_preparation/__init__.py +0 -0
  22. api/batch_processing/data_preparation/manage_local_batch.py +0 -2391
  23. api/batch_processing/data_preparation/manage_video_batch.py +0 -327
  24. api/batch_processing/integration/digiKam/setup.py +0 -6
  25. api/batch_processing/integration/digiKam/xmp_integration.py +0 -465
  26. api/batch_processing/integration/eMammal/test_scripts/config_template.py +0 -5
  27. api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +0 -126
  28. api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +0 -55
  29. api/batch_processing/postprocessing/__init__.py +0 -0
  30. api/batch_processing/postprocessing/add_max_conf.py +0 -64
  31. api/batch_processing/postprocessing/categorize_detections_by_size.py +0 -163
  32. api/batch_processing/postprocessing/combine_api_outputs.py +0 -249
  33. api/batch_processing/postprocessing/compare_batch_results.py +0 -958
  34. api/batch_processing/postprocessing/convert_output_format.py +0 -397
  35. api/batch_processing/postprocessing/load_api_results.py +0 -195
  36. api/batch_processing/postprocessing/md_to_coco.py +0 -310
  37. api/batch_processing/postprocessing/md_to_labelme.py +0 -330
  38. api/batch_processing/postprocessing/merge_detections.py +0 -401
  39. api/batch_processing/postprocessing/postprocess_batch_results.py +0 -1904
  40. api/batch_processing/postprocessing/remap_detection_categories.py +0 -170
  41. api/batch_processing/postprocessing/render_detection_confusion_matrix.py +0 -661
  42. api/batch_processing/postprocessing/repeat_detection_elimination/find_repeat_detections.py +0 -211
  43. api/batch_processing/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +0 -82
  44. api/batch_processing/postprocessing/repeat_detection_elimination/repeat_detections_core.py +0 -1631
  45. api/batch_processing/postprocessing/separate_detections_into_folders.py +0 -731
  46. api/batch_processing/postprocessing/subset_json_detector_output.py +0 -696
  47. api/batch_processing/postprocessing/top_folders_to_bottom.py +0 -223
  48. api/synchronous/__init__.py +0 -0
  49. api/synchronous/api_core/animal_detection_api/__init__.py +0 -0
  50. api/synchronous/api_core/animal_detection_api/api_backend.py +0 -152
  51. api/synchronous/api_core/animal_detection_api/api_frontend.py +0 -266
  52. api/synchronous/api_core/animal_detection_api/config.py +0 -35
  53. api/synchronous/api_core/animal_detection_api/data_management/annotations/annotation_constants.py +0 -47
  54. api/synchronous/api_core/animal_detection_api/detection/detector_training/copy_checkpoints.py +0 -43
  55. api/synchronous/api_core/animal_detection_api/detection/detector_training/model_main_tf2.py +0 -114
  56. api/synchronous/api_core/animal_detection_api/detection/process_video.py +0 -543
  57. api/synchronous/api_core/animal_detection_api/detection/pytorch_detector.py +0 -304
  58. api/synchronous/api_core/animal_detection_api/detection/run_detector.py +0 -627
  59. api/synchronous/api_core/animal_detection_api/detection/run_detector_batch.py +0 -1029
  60. api/synchronous/api_core/animal_detection_api/detection/run_inference_with_yolov5_val.py +0 -581
  61. api/synchronous/api_core/animal_detection_api/detection/run_tiled_inference.py +0 -754
  62. api/synchronous/api_core/animal_detection_api/detection/tf_detector.py +0 -165
  63. api/synchronous/api_core/animal_detection_api/detection/video_utils.py +0 -495
  64. api/synchronous/api_core/animal_detection_api/md_utils/azure_utils.py +0 -174
  65. api/synchronous/api_core/animal_detection_api/md_utils/ct_utils.py +0 -262
  66. api/synchronous/api_core/animal_detection_api/md_utils/directory_listing.py +0 -251
  67. api/synchronous/api_core/animal_detection_api/md_utils/matlab_porting_tools.py +0 -97
  68. api/synchronous/api_core/animal_detection_api/md_utils/path_utils.py +0 -416
  69. api/synchronous/api_core/animal_detection_api/md_utils/process_utils.py +0 -110
  70. api/synchronous/api_core/animal_detection_api/md_utils/sas_blob_utils.py +0 -509
  71. api/synchronous/api_core/animal_detection_api/md_utils/string_utils.py +0 -59
  72. api/synchronous/api_core/animal_detection_api/md_utils/url_utils.py +0 -144
  73. api/synchronous/api_core/animal_detection_api/md_utils/write_html_image_list.py +0 -226
  74. api/synchronous/api_core/animal_detection_api/md_visualization/visualization_utils.py +0 -841
  75. api/synchronous/api_core/tests/__init__.py +0 -0
  76. api/synchronous/api_core/tests/load_test.py +0 -110
  77. classification/__init__.py +0 -0
  78. classification/aggregate_classifier_probs.py +0 -108
  79. classification/analyze_failed_images.py +0 -227
  80. classification/cache_batchapi_outputs.py +0 -198
  81. classification/create_classification_dataset.py +0 -627
  82. classification/crop_detections.py +0 -516
  83. classification/csv_to_json.py +0 -226
  84. classification/detect_and_crop.py +0 -855
  85. classification/efficientnet/__init__.py +0 -9
  86. classification/efficientnet/model.py +0 -415
  87. classification/efficientnet/utils.py +0 -610
  88. classification/evaluate_model.py +0 -520
  89. classification/identify_mislabeled_candidates.py +0 -152
  90. classification/json_to_azcopy_list.py +0 -63
  91. classification/json_validator.py +0 -695
  92. classification/map_classification_categories.py +0 -276
  93. classification/merge_classification_detection_output.py +0 -506
  94. classification/prepare_classification_script.py +0 -194
  95. classification/prepare_classification_script_mc.py +0 -228
  96. classification/run_classifier.py +0 -286
  97. classification/save_mislabeled.py +0 -110
  98. classification/train_classifier.py +0 -825
  99. classification/train_classifier_tf.py +0 -724
  100. classification/train_utils.py +0 -322
  101. data_management/__init__.py +0 -0
  102. data_management/annotations/__init__.py +0 -0
  103. data_management/annotations/annotation_constants.py +0 -34
  104. data_management/camtrap_dp_to_coco.py +0 -238
  105. data_management/cct_json_utils.py +0 -395
  106. data_management/cct_to_md.py +0 -176
  107. data_management/cct_to_wi.py +0 -289
  108. data_management/coco_to_labelme.py +0 -272
  109. data_management/coco_to_yolo.py +0 -662
  110. data_management/databases/__init__.py +0 -0
  111. data_management/databases/add_width_and_height_to_db.py +0 -33
  112. data_management/databases/combine_coco_camera_traps_files.py +0 -206
  113. data_management/databases/integrity_check_json_db.py +0 -477
  114. data_management/databases/subset_json_db.py +0 -115
  115. data_management/generate_crops_from_cct.py +0 -149
  116. data_management/get_image_sizes.py +0 -188
  117. data_management/importers/add_nacti_sizes.py +0 -52
  118. data_management/importers/add_timestamps_to_icct.py +0 -79
  119. data_management/importers/animl_results_to_md_results.py +0 -158
  120. data_management/importers/auckland_doc_test_to_json.py +0 -372
  121. data_management/importers/auckland_doc_to_json.py +0 -200
  122. data_management/importers/awc_to_json.py +0 -189
  123. data_management/importers/bellevue_to_json.py +0 -273
  124. data_management/importers/cacophony-thermal-importer.py +0 -796
  125. data_management/importers/carrizo_shrubfree_2018.py +0 -268
  126. data_management/importers/carrizo_trail_cam_2017.py +0 -287
  127. data_management/importers/cct_field_adjustments.py +0 -57
  128. data_management/importers/channel_islands_to_cct.py +0 -913
  129. data_management/importers/eMammal/copy_and_unzip_emammal.py +0 -180
  130. data_management/importers/eMammal/eMammal_helpers.py +0 -249
  131. data_management/importers/eMammal/make_eMammal_json.py +0 -223
  132. data_management/importers/ena24_to_json.py +0 -275
  133. data_management/importers/filenames_to_json.py +0 -385
  134. data_management/importers/helena_to_cct.py +0 -282
  135. data_management/importers/idaho-camera-traps.py +0 -1407
  136. data_management/importers/idfg_iwildcam_lila_prep.py +0 -294
  137. data_management/importers/jb_csv_to_json.py +0 -150
  138. data_management/importers/mcgill_to_json.py +0 -250
  139. data_management/importers/missouri_to_json.py +0 -489
  140. data_management/importers/nacti_fieldname_adjustments.py +0 -79
  141. data_management/importers/noaa_seals_2019.py +0 -181
  142. data_management/importers/pc_to_json.py +0 -365
  143. data_management/importers/plot_wni_giraffes.py +0 -123
  144. data_management/importers/prepare-noaa-fish-data-for-lila.py +0 -359
  145. data_management/importers/prepare_zsl_imerit.py +0 -131
  146. data_management/importers/rspb_to_json.py +0 -356
  147. data_management/importers/save_the_elephants_survey_A.py +0 -320
  148. data_management/importers/save_the_elephants_survey_B.py +0 -332
  149. data_management/importers/snapshot_safari_importer.py +0 -758
  150. data_management/importers/snapshot_safari_importer_reprise.py +0 -665
  151. data_management/importers/snapshot_serengeti_lila.py +0 -1067
  152. data_management/importers/snapshotserengeti/make_full_SS_json.py +0 -150
  153. data_management/importers/snapshotserengeti/make_per_season_SS_json.py +0 -153
  154. data_management/importers/sulross_get_exif.py +0 -65
  155. data_management/importers/timelapse_csv_set_to_json.py +0 -490
  156. data_management/importers/ubc_to_json.py +0 -399
  157. data_management/importers/umn_to_json.py +0 -507
  158. data_management/importers/wellington_to_json.py +0 -263
  159. data_management/importers/wi_to_json.py +0 -441
  160. data_management/importers/zamba_results_to_md_results.py +0 -181
  161. data_management/labelme_to_coco.py +0 -548
  162. data_management/labelme_to_yolo.py +0 -272
  163. data_management/lila/__init__.py +0 -0
  164. data_management/lila/add_locations_to_island_camera_traps.py +0 -97
  165. data_management/lila/add_locations_to_nacti.py +0 -147
  166. data_management/lila/create_lila_blank_set.py +0 -557
  167. data_management/lila/create_lila_test_set.py +0 -151
  168. data_management/lila/create_links_to_md_results_files.py +0 -106
  169. data_management/lila/download_lila_subset.py +0 -177
  170. data_management/lila/generate_lila_per_image_labels.py +0 -515
  171. data_management/lila/get_lila_annotation_counts.py +0 -170
  172. data_management/lila/get_lila_image_counts.py +0 -111
  173. data_management/lila/lila_common.py +0 -300
  174. data_management/lila/test_lila_metadata_urls.py +0 -132
  175. data_management/ocr_tools.py +0 -874
  176. data_management/read_exif.py +0 -681
  177. data_management/remap_coco_categories.py +0 -84
  178. data_management/remove_exif.py +0 -66
  179. data_management/resize_coco_dataset.py +0 -189
  180. data_management/wi_download_csv_to_coco.py +0 -246
  181. data_management/yolo_output_to_md_output.py +0 -441
  182. data_management/yolo_to_coco.py +0 -676
  183. detection/__init__.py +0 -0
  184. detection/detector_training/__init__.py +0 -0
  185. detection/detector_training/model_main_tf2.py +0 -114
  186. detection/process_video.py +0 -703
  187. detection/pytorch_detector.py +0 -337
  188. detection/run_detector.py +0 -779
  189. detection/run_detector_batch.py +0 -1219
  190. detection/run_inference_with_yolov5_val.py +0 -917
  191. detection/run_tiled_inference.py +0 -935
  192. detection/tf_detector.py +0 -188
  193. detection/video_utils.py +0 -606
  194. docs/source/conf.py +0 -43
  195. md_utils/__init__.py +0 -0
  196. md_utils/azure_utils.py +0 -174
  197. md_utils/ct_utils.py +0 -612
  198. md_utils/directory_listing.py +0 -246
  199. md_utils/md_tests.py +0 -968
  200. md_utils/path_utils.py +0 -1044
  201. md_utils/process_utils.py +0 -157
  202. md_utils/sas_blob_utils.py +0 -509
  203. md_utils/split_locations_into_train_val.py +0 -228
  204. md_utils/string_utils.py +0 -92
  205. md_utils/url_utils.py +0 -323
  206. md_utils/write_html_image_list.py +0 -225
  207. md_visualization/__init__.py +0 -0
  208. md_visualization/plot_utils.py +0 -293
  209. md_visualization/render_images_with_thumbnails.py +0 -275
  210. md_visualization/visualization_utils.py +0 -1537
  211. md_visualization/visualize_db.py +0 -551
  212. md_visualization/visualize_detector_output.py +0 -406
  213. megadetector-5.0.9.dist-info/RECORD +0 -224
  214. megadetector-5.0.9.dist-info/top_level.txt +0 -8
  215. taxonomy_mapping/__init__.py +0 -0
  216. taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +0 -491
  217. taxonomy_mapping/map_new_lila_datasets.py +0 -154
  218. taxonomy_mapping/prepare_lila_taxonomy_release.py +0 -142
  219. taxonomy_mapping/preview_lila_taxonomy.py +0 -591
  220. taxonomy_mapping/retrieve_sample_image.py +0 -71
  221. taxonomy_mapping/simple_image_download.py +0 -218
  222. taxonomy_mapping/species_lookup.py +0 -834
  223. taxonomy_mapping/taxonomy_csv_checker.py +0 -159
  224. taxonomy_mapping/taxonomy_graph.py +0 -346
  225. taxonomy_mapping/validate_lila_category_mappings.py +0 -83
  226. {megadetector-5.0.9.dist-info → megadetector-5.0.11.dist-info}/WHEEL +0 -0
@@ -1,1904 +0,0 @@
1
- """
2
-
3
- postprocess_batch_results.py
4
-
5
- Given a .json or .csv file containing MD results, do one or more of the following:
6
-
7
- * Sample detections/non-detections and render to HTML (when ground truth isn't
8
- available) (this is 99.9% of what this module is for)
9
- * Evaluate detector precision/recall, optionally rendering results (requires
10
- ground truth)
11
- * Sample true/false positives/negatives and render to HTML (requires ground
12
- truth)
13
-
14
- Ground truth, if available, must be in COCO Camera Traps format:
15
-
16
- https://github.com/agentmorris/MegaDetector/blob/main/data_management/README.md#coco-camera-traps-format
17
-
18
- """
19
-
20
- #%% Constants and imports
21
-
22
- import argparse
23
- import collections
24
- import copy
25
- import errno
26
- import io
27
- import os
28
- import sys
29
- import time
30
- import uuid
31
- import warnings
32
- import random
33
-
34
- from enum import IntEnum
35
- from multiprocessing.pool import ThreadPool
36
- from multiprocessing.pool import Pool
37
- from functools import partial
38
-
39
- import matplotlib.pyplot as plt
40
- import numpy as np
41
- import humanfriendly
42
- import pandas as pd
43
-
44
- from sklearn.metrics import precision_recall_curve, confusion_matrix, average_precision_score
45
- from tqdm import tqdm
46
-
47
- import md_visualization.visualization_utils as vis_utils
48
- import md_visualization.plot_utils as plot_utils
49
-
50
- from md_utils.write_html_image_list import write_html_image_list
51
- from md_utils import path_utils
52
- from data_management.cct_json_utils import (CameraTrapJsonUtils, IndexedJsonDb)
53
- from api.batch_processing.postprocessing.load_api_results import load_api_results
54
- from md_utils.ct_utils import args_to_object, sets_overlap
55
-
56
- from detection.run_detector import get_typical_confidence_threshold_from_results
57
-
58
- warnings.filterwarnings('ignore', '(Possibly )?corrupt EXIF data', UserWarning)
59
-
60
-
61
- #%% Options
62
-
63
- DEFAULT_NEGATIVE_CLASSES = ['empty']
64
- DEFAULT_UNKNOWN_CLASSES = ['unknown', 'unlabeled', 'ambiguous']
65
-
66
- # Make sure there is no overlap between the two sets, because this will cause
67
- # issues in the code
68
- assert not sets_overlap(DEFAULT_NEGATIVE_CLASSES, DEFAULT_UNKNOWN_CLASSES), (
69
- 'Default negative and unknown classes cannot overlap.')
70
-
71
-
72
- class PostProcessingOptions:
73
- """
74
- Options used to parameterize process_batch_results().
75
- """
76
-
77
- ### Required inputs
78
-
79
- #: MD results .json file to process
80
- md_results_file = ''
81
-
82
- #: Folder to which we should write HTML output
83
- output_dir = ''
84
-
85
- ### Options
86
-
87
- #: Folder where images live (filenames in [md_results_file] should be relative to this folder)
88
- image_base_dir = '.'
89
-
90
- ## These apply only when we're doing ground-truth comparisons
91
-
92
- #: Optional .json file containing ground truth information
93
- ground_truth_json_file = ''
94
-
95
- #: Classes we'll treat as negative
96
- #:
97
- #: Include the token "#NO_LABELS#" to indicate that an image with no annotations
98
- #: should be considered empty.
99
- negative_classes = DEFAULT_NEGATIVE_CLASSES
100
-
101
- #: Classes we'll treat as neither positive nor negative
102
- unlabeled_classes = DEFAULT_UNKNOWN_CLASSES
103
-
104
- #: A list of output sets that we should count, but not render images for.
105
- #:
106
- #: Typically used to preview sets with lots of empties, where you don't want to
107
- #: subset but also don't want to render 100,000 empty images.
108
- #:
109
- #: detections, non_detections
110
- #: detections_animal, detections_person, detections_vehicle
111
- rendering_bypass_sets = []
112
-
113
- #: If this is None, choose a confidence threshold based on the detector version.
114
- #:
115
- #: This can either be a float or a dictionary mapping category names (not IDs) to
116
- #: thresholds. The category "default" can be used to specify thresholds for
117
- #: other categories. Currently the use of a dict here is not supported when
118
- #: ground truth is supplied.
119
- confidence_threshold = None
120
-
121
- #: Confidence threshold to apply to classification (not detection) results
122
- #:
123
- #: Only a float is supported here (unlike the "confidence_threshold" parameter, which
124
- #: can be a dict).
125
- classification_confidence_threshold = 0.5
126
-
127
- #: Used for summary statistics only
128
- target_recall = 0.9
129
-
130
- #: Number of images to sample, -1 for "all images"
131
- num_images_to_sample = 500
132
-
133
- #: Random seed for sampling, or None
134
- sample_seed = 0 # None
135
-
136
- #: Image width for images in the HTML output
137
- viz_target_width = 800
138
-
139
- #: Line width (in pixels) for rendering detections
140
- line_thickness = 4
141
-
142
- #: Box expansion (in pixels) for rendering detections
143
- box_expansion = 0
144
-
145
- #: Job name to include in big letters in the output HTML
146
- job_name_string = None
147
-
148
- #: Model version string to include in the output HTML
149
- model_version_string = None
150
-
151
- #: Sort order for the output, should be one of "filename", "confidence", or "random"
152
- html_sort_order = 'filename'
153
-
154
- #: If True, images in the output HTML will be links back to the original images
155
- link_images_to_originals = True
156
-
157
- #: Optionally separate detections into categories (animal/vehicle/human)
158
- #:
159
- #: Currently only supported when ground truth is unavailable
160
- separate_detections_by_category = True
161
-
162
- #: Optionally replace one or more strings in filenames with other strings;
163
- #: useful for taking a set of results generated for one folder structure
164
- #: and applying them to a slightly different folder structure.
165
- api_output_filename_replacements = {}
166
-
167
- #: Optionally replace one or more strings in filenames with other strings;
168
- #: useful for taking a set of results generated for one folder structure
169
- #: and applying them to a slightly different folder structure.
170
- ground_truth_filename_replacements = {}
171
-
172
- #: Allow bypassing API output loading when operating on previously-loaded
173
- #: results. If present, this is a Pandas DataFrame. Almost never useful.
174
- api_detection_results = None
175
-
176
- #: Allow bypassing API output loading when operating on previously-loaded
177
- #: results. If present, this is a str --> obj dict. Almost never useful.
178
- api_other_fields = None
179
-
180
- #: Should we also split out a separate report about the detections that were
181
- #: just below our main confidence threshold?
182
- #:
183
- #: Currently only supported when ground truth is unavailable.
184
- include_almost_detections = False
185
-
186
- #: Only a float is supported here (unlike the "confidence_threshold" parameter, which
187
- #: can be a dict).
188
- almost_detection_confidence_threshold = None
189
-
190
- #: Enable/disable rendering parallelization
191
- parallelize_rendering = False
192
-
193
- #: Number of threads/processes to use for rendering parallelization
194
- parallelize_rendering_n_cores = 25
195
-
196
- #: Whether to use threads (True) or processes (False) for rendering parallelization
197
- parallelize_rendering_with_threads = True
198
-
199
- #: When classification results are present, should be sort alphabetically by class name (False)
200
- #: or in descending order by frequency (True)?
201
- sort_classification_results_by_count = False
202
-
203
- #: Should we split individual pages up into smaller pages if there are more than
204
- #: N images?
205
- max_figures_per_html_file = None
206
-
207
- # ...PostProcessingOptions
208
-
209
-
210
- class PostProcessingResults:
211
- """
212
- Return format from process_batch_results
213
- """
214
-
215
- #: HTML file to which preview information was written
216
- output_html_file = ''
217
-
218
- #: Pandas Dataframe containing detection results
219
- api_detection_results = None
220
-
221
- #: str --> obj dictionary containing other information loaded from the results file
222
- api_other_fields = None
223
-
224
-
225
- ##%% Helper classes and functions
226
-
227
- class DetectionStatus(IntEnum):
228
- """
229
- Flags used to mark images as positive or negative for P/R analysis
230
- (according to ground truth and/or detector output)
231
-
232
- :meta private:
233
- """
234
-
235
- DS_NEGATIVE = 0
236
- DS_POSITIVE = 1
237
-
238
- # Anything greater than this isn't clearly positive or negative
239
- DS_MAX_DEFINITIVE_VALUE = DS_POSITIVE
240
-
241
- # image has annotations suggesting both negative and positive
242
- DS_AMBIGUOUS = 2
243
-
244
- # image is not annotated or is annotated with 'unknown', 'unlabeled', ETC.
245
- DS_UNKNOWN = 3
246
-
247
- # image has not yet been assigned a state
248
- DS_UNASSIGNED = 4
249
-
250
- # In some analyses, we add an additional class that lets us look at
251
- # detections just below our main confidence threshold
252
- DS_ALMOST = 5
253
-
254
-
255
- def _mark_detection_status(indexed_db,
256
- negative_classes=DEFAULT_NEGATIVE_CLASSES,
257
- unknown_classes=DEFAULT_UNKNOWN_CLASSES):
258
- """
259
- For each image in indexed_db.db['images'], add a '_detection_status' field
260
- to indicate whether to treat this image as positive, negative, ambiguous,
261
- or unknown.
262
-
263
- Makes modifications in-place.
264
-
265
- returns (n_negative, n_positive, n_unknown, n_ambiguous)
266
- """
267
-
268
- negative_classes = set(negative_classes)
269
- unknown_classes = set(unknown_classes)
270
-
271
- # count the # of images with each type of DetectionStatus
272
- n_unknown = 0
273
- n_ambiguous = 0
274
- n_positive = 0
275
- n_negative = 0
276
-
277
- print('Preparing ground-truth annotations')
278
- for im in tqdm(indexed_db.db['images']):
279
-
280
- image_id = im['id']
281
- annotations = indexed_db.image_id_to_annotations[image_id]
282
- categories = [ann['category_id'] for ann in annotations]
283
- category_names = set(indexed_db.cat_id_to_name[cat] for cat in categories)
284
-
285
- # Check whether this image has:
286
- # - unknown / unassigned-type labels
287
- # - negative-type labels
288
- # - positive labels (i.e., labels that are neither unknown nor negative)
289
- has_unknown_labels = sets_overlap(category_names, unknown_classes)
290
- has_negative_labels = sets_overlap(category_names, negative_classes)
291
- has_positive_labels = 0 < len(category_names - (unknown_classes | negative_classes))
292
- # assert has_unknown_labels is False, '{} has unknown labels'.format(annotations)
293
-
294
- # If there are no image annotations...
295
- if len(categories) == 0:
296
-
297
- if '#NO_LABELS#' in negative_classes:
298
- n_negative += 1
299
- im['_detection_status'] = DetectionStatus.DS_NEGATIVE
300
- else:
301
- n_unknown += 1
302
- im['_detection_status'] = DetectionStatus.DS_UNKNOWN
303
-
304
- # n_negative += 1
305
- # im['_detection_status'] = DetectionStatus.DS_NEGATIVE
306
-
307
- # If the image has more than one type of labels, it's ambiguous
308
- # note: bools are automatically converted to 0/1, so we can sum
309
- elif (has_unknown_labels + has_negative_labels + has_positive_labels) > 1:
310
- n_ambiguous += 1
311
- im['_detection_status'] = DetectionStatus.DS_AMBIGUOUS
312
-
313
- # After the check above, we can be sure it's only one of positive,
314
- # negative, or unknown.
315
- #
316
- # Important: do not merge the following 'unknown' branch with the first
317
- # 'unknown' branch above, where we tested 'if len(categories) == 0'
318
- #
319
- # If the image has only unknown labels
320
- elif has_unknown_labels:
321
- n_unknown += 1
322
- im['_detection_status'] = DetectionStatus.DS_UNKNOWN
323
-
324
- # If the image has only negative labels
325
- elif has_negative_labels:
326
- n_negative += 1
327
- im['_detection_status'] = DetectionStatus.DS_NEGATIVE
328
-
329
- # If the images has only positive labels
330
- elif has_positive_labels:
331
- n_positive += 1
332
- im['_detection_status'] = DetectionStatus.DS_POSITIVE
333
-
334
- # Annotate the category, if it is unambiguous
335
- if len(category_names) == 1:
336
- im['_unambiguous_category'] = list(category_names)[0]
337
-
338
- else:
339
- raise Exception('Invalid detection state')
340
-
341
- # ...for each image
342
-
343
- return n_negative, n_positive, n_unknown, n_ambiguous
344
-
345
- # ..._mark_detection_status()
346
-
347
-
348
- def is_sas_url(s) -> bool:
349
- """
350
- Placeholder for a more robust way to verify that a link is a SAS URL.
351
- 99.999% of the time this will suffice for what we're using it for right now.
352
-
353
- :meta private:
354
- """
355
-
356
- return (s.startswith(('http://', 'https://')) and ('core.windows.net' in s)
357
- and ('?' in s))
358
-
359
-
360
- def relative_sas_url(folder_url, relative_path):
361
- """
362
- Given a container-level or folder-level SAS URL, create a SAS URL to the
363
- specified relative path.
364
-
365
- :meta private:
366
- """
367
-
368
- relative_path = relative_path.replace('%','%25')
369
- relative_path = relative_path.replace('#','%23')
370
- relative_path = relative_path.replace(' ','%20')
371
-
372
- if not is_sas_url(folder_url):
373
- return None
374
- tokens = folder_url.split('?')
375
- assert len(tokens) == 2
376
- if not tokens[0].endswith('/'):
377
- tokens[0] = tokens[0] + '/'
378
- if relative_path.startswith('/'):
379
- relative_path = relative_path[1:]
380
- return tokens[0] + relative_path + '?' + tokens[1]
381
-
382
-
383
- def _render_bounding_boxes(
384
- image_base_dir,
385
- image_relative_path,
386
- display_name,
387
- detections,
388
- res,
389
- ground_truth_boxes=None,
390
- detection_categories=None,
391
- classification_categories=None,
392
- options=None):
393
- """
394
- Renders detection bounding boxes on a single image.
395
-
396
- This is an internal function; if you want tools for rendering boxes on images, see
397
- md_visualization.visualization_utils.
398
-
399
- The source image is:
400
-
401
- image_base_dir / image_relative_path
402
-
403
- The target image is, for example:
404
-
405
- [options.output_dir] /
406
- ['detections' or 'non_detections'] /
407
- [filename with slashes turned into tildes]
408
-
409
- "res" is a result type, e.g. "detections", "non-detections"; this determines the
410
- output folder for the rendered image.
411
-
412
- Only very preliminary support is provided for ground truth box rendering.
413
-
414
- Returns the html info struct for this image in the format that's used for
415
- write_html_image_list.
416
-
417
- :meta private:
418
- """
419
-
420
- if options is None:
421
- options = PostProcessingOptions()
422
-
423
- # Leaving code in place for reading from blob storage, may support this
424
- # in the future.
425
- """
426
- stream = io.BytesIO()
427
- _ = blob_service.get_blob_to_stream(container_name, image_id, stream)
428
- # resize is to display them in this notebook or in the HTML more quickly
429
- image = Image.open(stream).resize(viz_size)
430
- """
431
-
432
- image_full_path = None
433
-
434
- if res in options.rendering_bypass_sets:
435
-
436
- sample_name = res + '_' + path_utils.flatten_path(image_relative_path)
437
-
438
- else:
439
-
440
- if is_sas_url(image_base_dir):
441
- image_full_path = relative_sas_url(image_base_dir, image_relative_path)
442
- else:
443
- image_full_path = os.path.join(image_base_dir, image_relative_path)
444
-
445
- # os.path.isfile() is slow when mounting remote directories; much faster
446
- # to just try/except on the image open.
447
- try:
448
- image = vis_utils.open_image(image_full_path)
449
- except:
450
- print('Warning: could not open image file {}'.format(image_full_path))
451
- image = None
452
- # return ''
453
-
454
- # Render images to a flat folder
455
- sample_name = res + '_' + path_utils.flatten_path(image_relative_path)
456
- fullpath = os.path.join(options.output_dir, res, sample_name)
457
-
458
- if image is not None:
459
-
460
- original_size = image.size
461
-
462
- if options.viz_target_width is not None:
463
- image = vis_utils.resize_image(image, options.viz_target_width)
464
-
465
- if ground_truth_boxes is not None and len(ground_truth_boxes) > 0:
466
-
467
- # Create class labels like "gt_1" or "gt_27"
468
- gt_classes = [0] * len(ground_truth_boxes)
469
- label_map = {0:'ground truth'}
470
- # for i_box,box in enumerate(ground_truth_boxes):
471
- # gt_classes.append('_' + str(box[-1]))
472
- vis_utils.render_db_bounding_boxes(ground_truth_boxes, gt_classes, image,
473
- original_size=original_size,label_map=label_map,
474
- thickness=4,expansion=4)
475
-
476
- # render_detection_bounding_boxes expects either a float or a dict mapping
477
- # category IDs to names.
478
- if isinstance(options.confidence_threshold,float):
479
- rendering_confidence_threshold = options.confidence_threshold
480
- else:
481
- category_ids = set()
482
- for d in detections:
483
- category_ids.add(d['category'])
484
- rendering_confidence_threshold = {}
485
- for category_id in category_ids:
486
- rendering_confidence_threshold[category_id] = \
487
- _get_threshold_for_category_id(category_id, options, detection_categories)
488
-
489
- vis_utils.render_detection_bounding_boxes(
490
- detections, image,
491
- label_map=detection_categories,
492
- classification_label_map=classification_categories,
493
- confidence_threshold=rendering_confidence_threshold,
494
- thickness=options.line_thickness,
495
- expansion=options.box_expansion)
496
-
497
- try:
498
- image.save(fullpath)
499
- except OSError as e:
500
- # errno.ENAMETOOLONG doesn't get thrown properly on Windows, so
501
- # we awkwardly check against a hard-coded limit
502
- if (e.errno == errno.ENAMETOOLONG) or (len(fullpath) >= 259):
503
- extension = os.path.splitext(sample_name)[1]
504
- sample_name = res + '_' + str(uuid.uuid4()) + extension
505
- image.save(os.path.join(options.output_dir, res, sample_name))
506
- else:
507
- raise
508
-
509
- # Use slashes regardless of os
510
- file_name = '{}/{}'.format(res,sample_name)
511
-
512
- info = {
513
- 'filename': file_name,
514
- 'title': display_name,
515
- 'textStyle':\
516
- 'font-family:verdana,arial,calibri;font-size:80%;text-align:left;margin-top:20;margin-bottom:5'
517
- }
518
-
519
- # Optionally add links back to the original images
520
- if options.link_images_to_originals and (image_full_path is not None):
521
-
522
- # Handling special characters in links has been pushed down into
523
- # write_html_image_list
524
- #
525
- # link_target = image_full_path.replace('\\','/')
526
- # link_target = urllib.parse.quote(link_target)
527
- link_target = image_full_path
528
- info['linkTarget'] = link_target
529
-
530
- return info
531
-
532
- # ..._render_bounding_boxes
533
-
534
-
535
- def _prepare_html_subpages(images_html, output_dir, options=None):
536
- """
537
- Write out a series of html image lists, e.g. the "detections" or "non-detections"
538
- pages.
539
-
540
- image_html is a dictionary mapping an html page name (e.g. "detections_animal") to
541
- a list of image structs friendly to write_html_image_list.
542
-
543
- Returns a dictionary mapping category names to image counts.
544
- """
545
-
546
- if options is None:
547
- options = PostProcessingOptions()
548
-
549
- # Count items in each category
550
- image_counts = {}
551
- for res, array in images_html.items():
552
- image_counts[res] = len(array)
553
-
554
- # Optionally sort by filename before writing to html
555
- if options.html_sort_order == 'filename':
556
- images_html_sorted = {}
557
- for res, array in images_html.items():
558
- sorted_array = sorted(array, key=lambda x: x['filename'])
559
- images_html_sorted[res] = sorted_array
560
- images_html = images_html_sorted
561
-
562
- # Optionally sort by confidence before writing to html
563
- elif options.html_sort_order == 'confidence':
564
- images_html_sorted = {}
565
- for res, array in images_html.items():
566
-
567
- if not all(['max_conf' in d for d in array]):
568
- print("Warning: some elements in the {} page don't have confidence values, can't sort by confidence".format(res))
569
- else:
570
- sorted_array = sorted(array, key=lambda x: x['max_conf'], reverse=True)
571
- images_html_sorted[res] = sorted_array
572
- images_html = images_html_sorted
573
-
574
- else:
575
- assert options.html_sort_order == 'random',\
576
- 'Unrecognized sort order {}'.format(options.html_sort_order)
577
- images_html_sorted = {}
578
- for res, array in images_html.items():
579
- sorted_array = random.sample(array,len(array))
580
- images_html_sorted[res] = sorted_array
581
- images_html = images_html_sorted
582
-
583
- # Write the individual HTML files
584
- for res, array in images_html.items():
585
-
586
- html_image_list_options = {}
587
- html_image_list_options['maxFiguresPerHtmlFile'] = options.max_figures_per_html_file
588
- html_image_list_options['headerHtml'] = '<h1>{}</h1>'.format(res.upper())
589
-
590
- # Don't write empty pages
591
- if len(array) == 0:
592
- continue
593
- else:
594
- write_html_image_list(
595
- filename=os.path.join(output_dir, '{}.html'.format(res)),
596
- images=array,
597
- options=html_image_list_options)
598
-
599
- return image_counts
600
-
601
- # ..._prepare_html_subpages()
602
-
603
-
604
- def _get_threshold_for_category_name(category_name,options):
605
- """
606
- Determines the confidence threshold we should use for a specific category name.
607
- """
608
-
609
- if isinstance(options.confidence_threshold,float):
610
- return options.confidence_threshold
611
- else:
612
- assert isinstance(options.confidence_threshold,dict), \
613
- 'confidence_threshold must either be a float or a dict'
614
-
615
- if category_name in options.confidence_threshold:
616
-
617
- return options.confidence_threshold[category_name]
618
-
619
- else:
620
- assert 'default' in options.confidence_threshold, \
621
- 'category {} not in confidence_threshold dict, and no default supplied'.format(
622
- category_name)
623
- return options.confidence_threshold['default']
624
-
625
-
626
- def _get_threshold_for_category_id(category_id,options,detection_categories):
627
- """
628
- Determines the confidence threshold we should use for a specific category ID.
629
-
630
- [detection_categories] is a dict mapping category IDs to names.
631
- """
632
-
633
- if isinstance(options.confidence_threshold,float):
634
- return options.confidence_threshold
635
-
636
- assert category_id in detection_categories, \
637
- 'Invalid category ID {}'.format(category_id)
638
-
639
- category_name = detection_categories[category_id]
640
-
641
- return _get_threshold_for_category_name(category_name,options)
642
-
643
-
644
- def _get_positive_categories(detections,options,detection_categories):
645
- """
646
- Gets a sorted list of unique categories (as string IDs) above the threshold for this image
647
-
648
- [detection_categories] is a dict mapping category IDs to names.
649
- """
650
-
651
- positive_categories = set()
652
- for d in detections:
653
- threshold = _get_threshold_for_category_id(d['category'], options, detection_categories)
654
- if d['conf'] >= threshold:
655
- positive_categories.add(d['category'])
656
- return sorted(positive_categories)
657
-
658
-
659
- def _has_positive_detection(detections,options,detection_categories):
660
- """
661
- Determines whether any positive detections are present in the detection list
662
- [detections].
663
- """
664
-
665
- found_positive_detection = False
666
- for d in detections:
667
- threshold = _get_threshold_for_category_id(d['category'], options, detection_categories)
668
- if d['conf'] >= threshold:
669
- found_positive_detection = True
670
- break
671
- return found_positive_detection
672
-
673
-
674
- def _render_image_no_gt(file_info,detection_categories_to_results_name,
675
- detection_categories,classification_categories,
676
- options):
677
- """
678
- Renders an image (with no ground truth information)
679
-
680
- Returns a list of rendering structs, where the first item is a category (e.g. "detections_animal"),
681
- and the second is a dict of information needed for rendering. E.g.:
682
-
683
- [['detections_animal',
684
- {
685
- 'filename': 'detections_animal/detections_animal_blah~01060415.JPG',
686
- 'title': '<b>Result type</b>: detections_animal,
687
- <b>Image</b>: blah\\01060415.JPG,
688
- <b>Max conf</b>: 0.897',
689
- 'textStyle': 'font-family:verdana,arial,calibri;font-size:80%;text-align:left;margin-top:20;margin-bottom:5',
690
- 'linkTarget': 'full_path_to_%5C01060415.JPG'
691
- }]]
692
-
693
- When no classification data is present, this list will always be length-1. When
694
- classification data is present, an image may appear in multiple categories.
695
-
696
- Populates the 'max_conf' field of the first element of the list.
697
-
698
- Returns None if there are any errors.
699
- """
700
-
701
- image_relative_path = file_info[0]
702
- max_conf = file_info[1]
703
- detections = file_info[2]
704
-
705
- # Determine whether any positive detections are present (using a threshold that
706
- # may vary by category)
707
- found_positive_detection = _has_positive_detection(detections,options,detection_categories)
708
-
709
- detection_status = DetectionStatus.DS_UNASSIGNED
710
- if found_positive_detection:
711
- detection_status = DetectionStatus.DS_POSITIVE
712
- else:
713
- if options.include_almost_detections:
714
- if max_conf >= options.almost_detection_confidence_threshold:
715
- detection_status = DetectionStatus.DS_ALMOST
716
- else:
717
- detection_status = DetectionStatus.DS_NEGATIVE
718
- else:
719
- detection_status = DetectionStatus.DS_NEGATIVE
720
-
721
- if detection_status == DetectionStatus.DS_POSITIVE:
722
- if options.separate_detections_by_category:
723
- positive_categories = tuple(_get_positive_categories(detections,options,detection_categories))
724
- if positive_categories not in detection_categories_to_results_name:
725
- raise ValueError('Error: {} not in category mapping (file {})'.format(
726
- str(positive_categories),image_relative_path))
727
- res = detection_categories_to_results_name[positive_categories]
728
- else:
729
- res = 'detections'
730
-
731
- elif detection_status == DetectionStatus.DS_NEGATIVE:
732
- res = 'non_detections'
733
- else:
734
- assert detection_status == DetectionStatus.DS_ALMOST
735
- res = 'almost_detections'
736
-
737
- display_name = '<b>Result type</b>: {}, <b>Image</b>: {}, <b>Max conf</b>: {:0.3f}'.format(
738
- res, image_relative_path, max_conf)
739
-
740
- rendering_options = copy.copy(options)
741
- if detection_status == DetectionStatus.DS_ALMOST:
742
- rendering_options.confidence_threshold = \
743
- rendering_options.almost_detection_confidence_threshold
744
-
745
- rendered_image_html_info = _render_bounding_boxes(
746
- image_base_dir=options.image_base_dir,
747
- image_relative_path=image_relative_path,
748
- display_name=display_name,
749
- detections=detections,
750
- res=res,
751
- ground_truth_boxes=None,
752
- detection_categories=detection_categories,
753
- classification_categories=classification_categories,
754
- options=rendering_options)
755
-
756
- image_result = None
757
-
758
- if len(rendered_image_html_info) > 0:
759
-
760
- image_result = [[res, rendered_image_html_info]]
761
-
762
- max_conf = 0
763
-
764
- for det in detections:
765
-
766
- if det['conf'] > max_conf:
767
- max_conf = det['conf']
768
-
769
- if ('classifications' in det):
770
-
771
- # This is a list of [class,confidence] pairs, sorted by confidence
772
- classifications = det['classifications']
773
- top1_class_id = classifications[0][0]
774
- top1_class_name = classification_categories[top1_class_id]
775
- top1_class_score = classifications[0][1]
776
-
777
- # If we either don't have a confidence threshold, or we've met our
778
- # confidence threshold
779
- if (options.classification_confidence_threshold < 0) or \
780
- (top1_class_score >= options.classification_confidence_threshold):
781
- image_result.append(['class_{}'.format(top1_class_name),
782
- rendered_image_html_info])
783
- else:
784
- image_result.append(['class_unreliable',
785
- rendered_image_html_info])
786
-
787
- # ...if this detection has classification info
788
-
789
- # ...for each detection
790
-
791
- image_result[0][1]['max_conf'] = max_conf
792
-
793
- # ...if we got valid rendering info back from _render_bounding_boxes()
794
-
795
- return image_result
796
-
797
- # ...def _render_image_no_gt()
798
-
799
-
800
- def _render_image_with_gt(file_info,ground_truth_indexed_db,
801
- detection_categories,classification_categories,options):
802
- """
803
- Render an image with ground truth information. See _render_image_no_gt for return
804
- data format.
805
- """
806
-
807
- image_relative_path = file_info[0]
808
- max_conf = file_info[1]
809
- detections = file_info[2]
810
-
811
- # This should already have been normalized to either '/' or '\'
812
-
813
- image_id = ground_truth_indexed_db.filename_to_id.get(image_relative_path, None)
814
- if image_id is None:
815
- print('Warning: couldn''t find ground truth for image {}'.format(image_relative_path))
816
- return None
817
-
818
- image = ground_truth_indexed_db.image_id_to_image[image_id]
819
- annotations = ground_truth_indexed_db.image_id_to_annotations[image_id]
820
-
821
- ground_truth_boxes = []
822
- for ann in annotations:
823
- if 'bbox' in ann:
824
- ground_truth_box = [x for x in ann['bbox']]
825
- ground_truth_box.append(ann['category_id'])
826
- ground_truth_boxes.append(ground_truth_box)
827
-
828
- gt_status = image['_detection_status']
829
-
830
- gt_presence = bool(gt_status)
831
-
832
- gt_classes = CameraTrapJsonUtils.annotations_to_class_names(
833
- annotations, ground_truth_indexed_db.cat_id_to_name)
834
- gt_class_summary = ','.join(gt_classes)
835
-
836
- if gt_status > DetectionStatus.DS_MAX_DEFINITIVE_VALUE:
837
- print(f'Skipping image {image_id}, does not have a definitive '
838
- f'ground truth status (status: {gt_status}, classes: {gt_class_summary})')
839
- return None
840
-
841
- detected = _has_positive_detection(detections, options, detection_categories)
842
-
843
- if gt_presence and detected:
844
- if '_classification_accuracy' not in image.keys():
845
- res = 'tp'
846
- elif np.isclose(1, image['_classification_accuracy']):
847
- res = 'tpc'
848
- else:
849
- res = 'tpi'
850
- elif not gt_presence and detected:
851
- res = 'fp'
852
- elif gt_presence and not detected:
853
- res = 'fn'
854
- else:
855
- res = 'tn'
856
-
857
- display_name = '<b>Result type</b>: {}, <b>Presence</b>: {}, <b>Class</b>: {}, <b>Max conf</b>: {:0.3f}%, <b>Image</b>: {}'.format(
858
- res.upper(), str(gt_presence), gt_class_summary,
859
- max_conf * 100, image_relative_path)
860
-
861
- rendered_image_html_info = _render_bounding_boxes(
862
- image_base_dir=options.image_base_dir,
863
- image_relative_path=image_relative_path,
864
- display_name=display_name,
865
- detections=detections,
866
- res=res,
867
- ground_truth_boxes=ground_truth_boxes,
868
- detection_categories=detection_categories,
869
- classification_categories=classification_categories,
870
- options=options)
871
-
872
- image_result = None
873
- if len(rendered_image_html_info) > 0:
874
- image_result = [[res, rendered_image_html_info]]
875
- for gt_class in gt_classes:
876
- image_result.append(['class_{}'.format(gt_class), rendered_image_html_info])
877
-
878
- return image_result
879
-
880
- # ...def _render_image_with_gt()
881
-
882
-
883
- #%% Main function
884
-
885
- def process_batch_results(options):
886
-
887
- """
888
- Given a .json or .csv file containing MD results, do one or more of the following:
889
-
890
- * Sample detections/non-detections and render to HTML (when ground truth isn't
891
- available) (this is 99.9% of what this module is for)
892
- * Evaluate detector precision/recall, optionally rendering results (requires
893
- ground truth)
894
- * Sample true/false positives/negatives and render to HTML (requires ground
895
- truth)
896
-
897
- Ground truth, if available, must be in COCO Camera Traps format:
898
-
899
- https://github.com/agentmorris/MegaDetector/blob/main/data_management/README.md#coco-camera-traps-format
900
-
901
- Args:
902
- options (PostProcessingOptions): everything we need to render a preview/analysis for
903
- this set of results; see the PostProcessingOptions class for details.
904
-
905
- Returns:
906
- PostProcessingResults: information about the results/preview, most importantly the HTML filename
907
- of the output. See the PostProcessingResults class for details.
908
- """
909
- ppresults = PostProcessingResults()
910
-
911
- ##%% Expand some options for convenience
912
-
913
- output_dir = options.output_dir
914
-
915
-
916
- ##%% Prepare output dir
917
-
918
- os.makedirs(output_dir, exist_ok=True)
919
-
920
-
921
- ##%% Load ground truth if available
922
-
923
- ground_truth_indexed_db = None
924
-
925
- if (options.ground_truth_json_file is not None) and (len(options.ground_truth_json_file) > 0):
926
- assert (options.confidence_threshold is None) or (isinstance(options.confidence_threshold,float)), \
927
- 'Variable confidence thresholds are not supported when supplying ground truth'
928
-
929
- if (options.ground_truth_json_file is not None) and (len(options.ground_truth_json_file) > 0):
930
-
931
- if options.separate_detections_by_category:
932
- print("Warning: I don't know how to separate categories yet when doing " + \
933
- "a P/R analysis, disabling category separation")
934
- options.separate_detections_by_category = False
935
-
936
- ground_truth_indexed_db = IndexedJsonDb(
937
- options.ground_truth_json_file, b_normalize_paths=True,
938
- filename_replacements=options.ground_truth_filename_replacements)
939
-
940
- # Mark images in the ground truth as positive or negative
941
- n_negative, n_positive, n_unknown, n_ambiguous = _mark_detection_status(
942
- ground_truth_indexed_db, negative_classes=options.negative_classes,
943
- unknown_classes=options.unlabeled_classes)
944
- print(f'Finished loading and indexing ground truth: {n_negative} '
945
- f'negative, {n_positive} positive, {n_unknown} unknown, '
946
- f'{n_ambiguous} ambiguous')
947
-
948
-
949
- ##%% Load detection (and possibly classification) results
950
-
951
- # If the caller hasn't supplied results, load them
952
- if options.api_detection_results is None:
953
- detections_df, other_fields = load_api_results(
954
- options.api_output_file, force_forward_slashes=True,
955
- filename_replacements=options.api_output_filename_replacements)
956
- ppresults.api_detection_results = detections_df
957
- ppresults.api_other_fields = other_fields
958
-
959
- else:
960
- print('Bypassing detection results loading...')
961
- assert options.api_other_fields is not None
962
- detections_df = options.api_detection_results
963
- other_fields = options.api_other_fields
964
-
965
- # Determine confidence thresholds if necessary
966
-
967
- if options.confidence_threshold is None:
968
- options.confidence_threshold = \
969
- get_typical_confidence_threshold_from_results(other_fields)
970
- print('Choosing default confidence threshold of {} based on MD version'.format(
971
- options.confidence_threshold))
972
-
973
- if options.almost_detection_confidence_threshold is None and options.include_almost_detections:
974
- assert isinstance(options.confidence_threshold,float), \
975
- 'If you are using a dictionary of confidence thresholds and almost-detections are enabled, ' + \
976
- 'you need to supply a threshold for almost detections.'
977
- options.almost_detection_confidence_threshold = options.confidence_threshold - 0.05
978
- if options.almost_detection_confidence_threshold < 0:
979
- options.almost_detection_confidence_threshold = 0
980
-
981
- # Remove rows with inference failures (typically due to corrupt images)
982
- n_failures = 0
983
- if 'failure' in detections_df.columns:
984
- n_failures = detections_df['failure'].count()
985
- print('Ignoring {} failed images'.format(n_failures))
986
- # Explicitly forcing a copy() operation here to suppress "trying to be set
987
- # on a copy" warnings (and associated risks) below.
988
- detections_df = detections_df[detections_df['failure'].isna()].copy()
989
-
990
- assert other_fields is not None
991
-
992
- detection_categories = other_fields['detection_categories']
993
-
994
- # Convert keys and values to lowercase
995
- classification_categories = other_fields.get('classification_categories', {})
996
- if classification_categories is not None:
997
- classification_categories = {
998
- k.lower(): v.lower()
999
- for k, v in classification_categories.items()
1000
- }
1001
-
1002
- # Count detections and almost-detections for reporting purposes
1003
- n_positives = 0
1004
- n_almosts = 0
1005
-
1006
- for i_row,row in tqdm(detections_df.iterrows(),total=len(detections_df)):
1007
-
1008
- detections = row['detections']
1009
- max_conf = row['max_detection_conf']
1010
- if _has_positive_detection(detections, options, detection_categories):
1011
- n_positives += 1
1012
- elif (options.almost_detection_confidence_threshold is not None) and \
1013
- (max_conf >= options.almost_detection_confidence_threshold):
1014
- n_almosts += 1
1015
-
1016
- print(f'Finished loading and preprocessing {len(detections_df)} rows '
1017
- f'from detector output, predicted {n_positives} positives.')
1018
-
1019
- if options.include_almost_detections:
1020
- print('...and {} almost-positives'.format(n_almosts))
1021
-
1022
-
1023
- ##%% Find descriptive metadata to include at the top of the page
1024
-
1025
- if options.job_name_string is not None:
1026
- job_name_string = options.job_name_string
1027
- else:
1028
- # This is rare; it only happens during debugging when the caller
1029
- # is supplying already-loaded API results.
1030
- if options.api_output_file is None:
1031
- job_name_string = 'unknown'
1032
- else:
1033
- job_name_string = os.path.basename(options.api_output_file)
1034
-
1035
- if options.model_version_string is not None:
1036
- model_version_string = options.model_version_string
1037
- else:
1038
-
1039
- if 'info' not in other_fields or 'detector' not in other_fields['info']:
1040
- print('No model metadata supplied, assuming MDv4')
1041
- model_version_string = 'MDv4 (assumed)'
1042
- else:
1043
- model_version_string = other_fields['info']['detector']
1044
-
1045
-
1046
- ##%% If we have ground truth, remove images we can't match to ground truth
1047
-
1048
- if ground_truth_indexed_db is not None:
1049
-
1050
- b_match = detections_df['file'].isin(
1051
- ground_truth_indexed_db.filename_to_id)
1052
- print(f'Confirmed filename matches to ground truth for {sum(b_match)} '
1053
- f'of {len(detections_df)} files')
1054
-
1055
- detections_df = detections_df[b_match]
1056
- detector_files = detections_df['file'].tolist()
1057
-
1058
- assert len(detector_files) > 0, (
1059
- 'No detection files available, possible path issue?')
1060
-
1061
- print('Trimmed detection results to {} files'.format(len(detector_files)))
1062
-
1063
-
1064
- ##%% (Optionally) sample from the full set of images
1065
-
1066
- images_to_visualize = detections_df
1067
-
1068
- if options.num_images_to_sample is not None and options.num_images_to_sample > 0:
1069
- images_to_visualize = images_to_visualize.sample(
1070
- n=min(options.num_images_to_sample, len(images_to_visualize)),
1071
- random_state=options.sample_seed)
1072
-
1073
- output_html_file = ''
1074
-
1075
- style_header = """<head>
1076
- <style type="text/css">
1077
- a { text-decoration: none; }
1078
- body { font-family: segoe ui, calibri, "trebuchet ms", verdana, arial, sans-serif; }
1079
- div.contentdiv { margin-left: 20px; }
1080
- </style>
1081
- </head>"""
1082
-
1083
-
1084
- ##%% Fork here depending on whether or not ground truth is available
1085
-
1086
- # If we have ground truth, we'll compute precision/recall and sample tp/fp/tn/fn.
1087
- #
1088
- # Otherwise we'll just visualize detections/non-detections.
1089
-
1090
- if ground_truth_indexed_db is not None:
1091
-
1092
- ##%% Detection evaluation: compute precision/recall
1093
-
1094
- # numpy array of detection probabilities
1095
- p_detection = detections_df['max_detection_conf'].values
1096
- n_detections = len(p_detection)
1097
-
1098
- # numpy array of bools (0.0/1.0), and -1 as null value
1099
- gt_detections = np.zeros(n_detections, dtype=float)
1100
-
1101
- for i_detection, fn in enumerate(detector_files):
1102
- image_id = ground_truth_indexed_db.filename_to_id[fn]
1103
- image = ground_truth_indexed_db.image_id_to_image[image_id]
1104
- detection_status = image['_detection_status']
1105
-
1106
- if detection_status == DetectionStatus.DS_NEGATIVE:
1107
- gt_detections[i_detection] = 0.0
1108
- elif detection_status == DetectionStatus.DS_POSITIVE:
1109
- gt_detections[i_detection] = 1.0
1110
- else:
1111
- gt_detections[i_detection] = -1.0
1112
-
1113
- # Don't include ambiguous/unknown ground truth in precision/recall analysis
1114
- b_valid_ground_truth = gt_detections >= 0.0
1115
-
1116
- p_detection_pr = p_detection[b_valid_ground_truth]
1117
- gt_detections_pr = (gt_detections[b_valid_ground_truth] == 1.)
1118
-
1119
- print('Including {} of {} values in p/r analysis'.format(np.sum(b_valid_ground_truth),
1120
- len(b_valid_ground_truth)))
1121
-
1122
- precisions, recalls, thresholds = precision_recall_curve(gt_detections_pr, p_detection_pr)
1123
-
1124
- # For completeness, include the result at a confidence threshold of 1.0
1125
- thresholds = np.append(thresholds, [1.0])
1126
-
1127
- precisions_recalls = pd.DataFrame(data={
1128
- 'confidence_threshold': thresholds,
1129
- 'precision': precisions,
1130
- 'recall': recalls
1131
- })
1132
-
1133
- # Compute and print summary statistics
1134
- average_precision = average_precision_score(gt_detections_pr, p_detection_pr)
1135
- print('Average precision: {:.1%}'.format(average_precision))
1136
-
1137
- # Thresholds go up throughout precisions/recalls/thresholds; find the last
1138
- # value where recall is at or above target. That's our precision @ target recall.
1139
-
1140
- i_above_target_recall = (np.where(recalls >= options.target_recall))
1141
-
1142
- # np.where returns a tuple of arrays, but in this syntax where we're
1143
- # comparing an array with a scalar, there will only be one element.
1144
- assert len (i_above_target_recall) == 1
1145
-
1146
- # Convert back to a list
1147
- i_above_target_recall = i_above_target_recall[0].tolist()
1148
-
1149
- if len(i_above_target_recall) == 0:
1150
- precision_at_target_recall = 0.0
1151
- else:
1152
- precision_at_target_recall = precisions[i_above_target_recall[-1]]
1153
- print('Precision at {:.1%} recall: {:.1%}'.format(options.target_recall,
1154
- precision_at_target_recall))
1155
-
1156
- cm_predictions = np.array(p_detection_pr) > options.confidence_threshold
1157
- cm = confusion_matrix(gt_detections_pr, cm_predictions, labels=[False,True])
1158
-
1159
- # Flatten the confusion matrix
1160
- tn, fp, fn, tp = cm.ravel()
1161
-
1162
- precision_at_confidence_threshold = tp / (tp + fp)
1163
- recall_at_confidence_threshold = tp / (tp + fn)
1164
- f1 = 2.0 * (precision_at_confidence_threshold * recall_at_confidence_threshold) / \
1165
- (precision_at_confidence_threshold + recall_at_confidence_threshold)
1166
-
1167
- print('At a confidence threshold of {:.1%}, precision={:.1%}, recall={:.1%}, f1={:.1%}'.format(
1168
- options.confidence_threshold, precision_at_confidence_threshold,
1169
- recall_at_confidence_threshold, f1))
1170
-
1171
- ##%% Collect classification results, if they exist
1172
-
1173
- classifier_accuracies = []
1174
-
1175
- # Mapping of classnames to idx for the confusion matrix.
1176
- #
1177
- # The lambda is actually kind of a hack, because we use assume that
1178
- # the following code does not reassign classname_to_idx
1179
- classname_to_idx = collections.defaultdict(lambda: len(classname_to_idx))
1180
-
1181
- # Confusion matrix as defaultdict of defaultdict
1182
- #
1183
- # Rows / first index is ground truth, columns / second index is predicted category
1184
- classifier_cm = collections.defaultdict(lambda: collections.defaultdict(lambda: 0))
1185
-
1186
- # iDetection = 0; fn = detector_files[iDetection]; print(fn)
1187
- assert len(detector_files) == len(detections_df)
1188
- for iDetection, fn in enumerate(detector_files):
1189
-
1190
- image_id = ground_truth_indexed_db.filename_to_id[fn]
1191
- image = ground_truth_indexed_db.image_id_to_image[image_id]
1192
- detections = detections_df['detections'].iloc[iDetection]
1193
- pred_class_ids = [det['classifications'][0][0] \
1194
- for det in detections if 'classifications' in det.keys()]
1195
- pred_classnames = [classification_categories[pd] for pd in pred_class_ids]
1196
-
1197
- # If this image has classification predictions, and an unambiguous class
1198
- # annotated, and is a positive image...
1199
- if len(pred_classnames) > 0 \
1200
- and '_unambiguous_category' in image.keys() \
1201
- and image['_detection_status'] == DetectionStatus.DS_POSITIVE:
1202
-
1203
- # The unambiguous category, we make this a set for easier handling afterward
1204
- gt_categories = set([image['_unambiguous_category']])
1205
- pred_categories = set(pred_classnames)
1206
-
1207
- # Compute the accuracy as intersection of union,
1208
- # i.e. (# of categories in both prediction and GT)
1209
- # divided by (# of categories in either prediction or GT
1210
- #
1211
- # In case of only one GT category, the result will be 1.0, if
1212
- # prediction is one category and this category matches GT
1213
- #
1214
- # It is 1.0/(# of predicted top-1 categories), if the GT is
1215
- # one of the predicted top-1 categories.
1216
- #
1217
- # It is 0.0, if none of the predicted categories is correct
1218
-
1219
- classifier_accuracies.append(
1220
- len(gt_categories & pred_categories)
1221
- / len(gt_categories | pred_categories)
1222
- )
1223
- image['_classification_accuracy'] = classifier_accuracies[-1]
1224
-
1225
- # Distribute this accuracy across all predicted categories in the
1226
- # confusion matrix
1227
- assert len(gt_categories) == 1
1228
- gt_class_idx = classname_to_idx[list(gt_categories)[0]]
1229
- for pred_category in pred_categories:
1230
- pred_class_idx = classname_to_idx[pred_category]
1231
- classifier_cm[gt_class_idx][pred_class_idx] += 1
1232
-
1233
- # ...for each file in the detection results
1234
-
1235
- # If we have classification results
1236
- if len(classifier_accuracies) > 0:
1237
-
1238
- # Build confusion matrix as array from classifier_cm
1239
- all_class_ids = sorted(classname_to_idx.values())
1240
- classifier_cm_array = np.array(
1241
- [[classifier_cm[r_idx][c_idx] for c_idx in all_class_ids] for \
1242
- r_idx in all_class_ids], dtype=float)
1243
- classifier_cm_array /= (classifier_cm_array.sum(axis=1, keepdims=True) + 1e-7)
1244
-
1245
- # Print some statistics
1246
- print('Finished computation of {} classification results'.format(
1247
- len(classifier_accuracies)))
1248
- print('Mean accuracy: {}'.format(np.mean(classifier_accuracies)))
1249
-
1250
- # Prepare confusion matrix output
1251
-
1252
- # Get confusion matrix as string
1253
- sio = io.StringIO()
1254
- np.savetxt(sio, classifier_cm_array * 100, fmt='%5.1f')
1255
- cm_str = sio.getvalue()
1256
- # Get fixed-size classname for each idx
1257
- idx_to_classname = {v:k for k,v in classname_to_idx.items()}
1258
- classname_list = [idx_to_classname[idx] for idx in sorted(classname_to_idx.values())]
1259
- classname_headers = ['{:<5}'.format(cname[:5]) for cname in classname_list]
1260
-
1261
- # Prepend class name on each line and add to the top
1262
- cm_str_lines = [' ' * 16 + ' '.join(classname_headers)]
1263
- cm_str_lines += ['{:>15}'.format(cn[:15]) + ' ' + cm_line for cn, cm_line in \
1264
- zip(classname_list, cm_str.splitlines())]
1265
-
1266
- # Print formatted confusion matrix
1267
- if False:
1268
- # Actually don't, this gets really messy in all but the widest consoles
1269
- print('Confusion matrix: ')
1270
- print(*cm_str_lines, sep='\n')
1271
-
1272
- # Plot confusion matrix
1273
-
1274
- # To manually add more space at bottom: plt.rcParams['figure.subplot.bottom'] = 0.1
1275
- #
1276
- # Add 0.5 to figsize for every class. For two classes, this will result in
1277
- # fig = plt.figure(figsize=[4,4])
1278
- fig = plot_utils.plot_confusion_matrix(
1279
- classifier_cm_array,
1280
- classname_list,
1281
- normalize=False,
1282
- title='Confusion matrix',
1283
- cmap=plt.cm.Blues,
1284
- vmax=1.0,
1285
- use_colorbar=True,
1286
- y_label=True)
1287
- cm_figure_relative_filename = 'confusion_matrix.png'
1288
- cm_figure_filename = os.path.join(output_dir, cm_figure_relative_filename)
1289
- plt.savefig(cm_figure_filename)
1290
- plt.close(fig)
1291
-
1292
- # ...if we have classification results
1293
-
1294
-
1295
- ##%% Render output
1296
-
1297
- # Write p/r table to .csv file in output directory
1298
- pr_table_filename = os.path.join(output_dir, 'prec_recall.csv')
1299
- precisions_recalls.to_csv(pr_table_filename, index=False)
1300
-
1301
- # Write precision/recall plot to .png file in output directory
1302
- t = 'Precision-Recall curve: AP={:0.1%}, P@{:0.1%}={:0.1%}'.format(
1303
- average_precision, options.target_recall, precision_at_target_recall)
1304
- fig = plot_utils.plot_precision_recall_curve(precisions, recalls, t)
1305
-
1306
- pr_figure_relative_filename = 'prec_recall.png'
1307
- pr_figure_filename = os.path.join(output_dir, pr_figure_relative_filename)
1308
- fig.savefig(pr_figure_filename)
1309
- plt.close(fig)
1310
-
1311
-
1312
- ##%% Sampling
1313
-
1314
- # Sample true/false positives/negatives with correct/incorrect top-1
1315
- # classification and render to html
1316
-
1317
- # Accumulate html image structs (in the format expected by write_html_image_lists)
1318
- # for each category, e.g. 'tp', 'fp', ..., 'class_bird', ...
1319
- images_html = collections.defaultdict(list)
1320
-
1321
- # Add default entries by accessing them for the first time
1322
- [images_html[res] for res in ['tp', 'tpc', 'tpi', 'fp', 'tn', 'fn']]
1323
- for res in images_html.keys():
1324
- os.makedirs(os.path.join(output_dir, res), exist_ok=True)
1325
-
1326
- image_count = len(images_to_visualize)
1327
-
1328
- # Each element will be a list of 2-tuples, with elements [collection name,html info struct]
1329
- rendering_results = []
1330
-
1331
- # Each element will be a three-tuple with elements file,max_conf,detections
1332
- files_to_render = []
1333
-
1334
- # Assemble the information we need for rendering, so we can parallelize without
1335
- # dealing with Pandas
1336
- # i_row = 0; row = images_to_visualize.iloc[0]
1337
- for _, row in images_to_visualize.iterrows():
1338
-
1339
- # Filenames should already have been normalized to either '/' or '\'
1340
- files_to_render.append([row['file'], row['max_detection_conf'], row['detections']])
1341
-
1342
- start_time = time.time()
1343
- if options.parallelize_rendering:
1344
- if options.parallelize_rendering_n_cores is None:
1345
- if options.parallelize_rendering_with_threads:
1346
- pool = ThreadPool()
1347
- else:
1348
- pool = Pool()
1349
- else:
1350
- if options.parallelize_rendering_with_threads:
1351
- pool = ThreadPool(options.parallelize_rendering_n_cores)
1352
- worker_string = 'threads'
1353
- else:
1354
- pool = Pool(options.parallelize_rendering_n_cores)
1355
- worker_string = 'processes'
1356
- print('Rendering images with {} {}'.format(options.parallelize_rendering_n_cores,
1357
- worker_string))
1358
-
1359
- rendering_results = list(tqdm(pool.imap(
1360
- partial(_render_image_with_gt,
1361
- ground_truth_indexed_db=ground_truth_indexed_db,
1362
- detection_categories=detection_categories,
1363
- classification_categories=classification_categories,
1364
- options=options),
1365
- files_to_render), total=len(files_to_render)))
1366
- else:
1367
- for file_info in tqdm(files_to_render):
1368
- rendering_results.append(_render_image_with_gt(
1369
- file_info,ground_truth_indexed_db,
1370
- detection_categories,classification_categories,
1371
- options=options))
1372
- elapsed = time.time() - start_time
1373
-
1374
- # Map all the rendering results in the list rendering_results into the
1375
- # dictionary images_html, which maps category names to lists of results
1376
- image_rendered_count = 0
1377
- for rendering_result in rendering_results:
1378
- if rendering_result is None:
1379
- continue
1380
- image_rendered_count += 1
1381
- for assignment in rendering_result:
1382
- images_html[assignment[0]].append(assignment[1])
1383
-
1384
- # Prepare the individual html image files
1385
- image_counts = _prepare_html_subpages(images_html, output_dir, options)
1386
-
1387
- print('{} images rendered (of {})'.format(image_rendered_count,image_count))
1388
-
1389
- # Write index.html
1390
- all_tp_count = image_counts['tp'] + image_counts['tpc'] + image_counts['tpi']
1391
- total_count = all_tp_count + image_counts['tn'] + image_counts['fp'] + image_counts['fn']
1392
-
1393
- classification_detection_results = """&nbsp;&nbsp;&nbsp;&nbsp;<a href="tpc.html">with all correct top-1 predictions (TPC)</a> ({})<br/>
1394
- &nbsp;&nbsp;&nbsp;&nbsp;<a href="tpi.html">with one or more incorrect top-1 prediction (TPI)</a> ({})<br/>
1395
- &nbsp;&nbsp;&nbsp;&nbsp;<a href="tp.html">without classification evaluation</a><sup>*</sup> ({})<br/>""".format(
1396
- image_counts['tpc'],
1397
- image_counts['tpi'],
1398
- image_counts['tp']
1399
- )
1400
-
1401
- confidence_threshold_string = ''
1402
- if isinstance(options.confidence_threshold,float):
1403
- confidence_threshold_string = '{:.2%}'.format(options.confidence_threshold)
1404
- else:
1405
- confidence_threshold_string = str(options.confidence_threshold)
1406
-
1407
- index_page = """<html>
1408
- {}
1409
- <body>
1410
- <h2>Evaluation</h2>
1411
-
1412
- <h3>Job metadata</h3>
1413
-
1414
- <div class="contentdiv">
1415
- <p>Job name: {}<br/>
1416
- <p>Model version: {}</p>
1417
- </div>
1418
-
1419
- <h3>Sample images</h3>
1420
- <div class="contentdiv">
1421
- <p>A sample of {} images, annotated with detections above confidence {}.</p>
1422
- <a href="tp.html">True positives (TP)</a> ({}) ({:0.1%})<br/>
1423
- CLASSIFICATION_PLACEHOLDER_1
1424
- <a href="tn.html">True negatives (TN)</a> ({}) ({:0.1%})<br/>
1425
- <a href="fp.html">False positives (FP)</a> ({}) ({:0.1%})<br/>
1426
- <a href="fn.html">False negatives (FN)</a> ({}) ({:0.1%})<br/>
1427
- CLASSIFICATION_PLACEHOLDER_2
1428
- </div>
1429
- """.format(
1430
- style_header,job_name_string,model_version_string,
1431
- image_count, confidence_threshold_string,
1432
- all_tp_count, all_tp_count/total_count,
1433
- image_counts['tn'], image_counts['tn']/total_count,
1434
- image_counts['fp'], image_counts['fp']/total_count,
1435
- image_counts['fn'], image_counts['fn']/total_count
1436
- )
1437
-
1438
- index_page += """
1439
- <h3>Detection results</h3>
1440
- <div class="contentdiv">
1441
- <p>At a confidence threshold of {}, precision={:0.1%}, recall={:0.1%}</p>
1442
- <p><strong>Precision/recall summary for all {} images</strong></p><img src="{}"><br/>
1443
- </div>
1444
- """.format(
1445
- confidence_threshold_string, precision_at_confidence_threshold, recall_at_confidence_threshold,
1446
- len(detections_df), pr_figure_relative_filename
1447
- )
1448
-
1449
- if len(classifier_accuracies) > 0:
1450
- index_page = index_page.replace('CLASSIFICATION_PLACEHOLDER_1',classification_detection_results)
1451
- index_page = index_page.replace('CLASSIFICATION_PLACEHOLDER_2',"""<p><sup>*</sup>We do not evaluate the classification result of images
1452
- if the classification information is missing, if the image contains
1453
- categories like &lsquo;empty&rsquo; or &lsquo;human&rsquo;, or if the image has multiple
1454
- classification labels.</p>""")
1455
- else:
1456
- index_page = index_page.replace('CLASSIFICATION_PLACEHOLDER_1','')
1457
- index_page = index_page.replace('CLASSIFICATION_PLACEHOLDER_2','')
1458
-
1459
- if len(classifier_accuracies) > 0:
1460
- index_page += """
1461
- <h3>Classification results</h3>
1462
- <div class="contentdiv">
1463
- <p>Classification accuracy: {:.2%}<br>
1464
- The accuracy is computed only for images with exactly one classification label.
1465
- The accuracy of an image is computed as 1/(number of unique detected top-1 classes),
1466
- i.e. if the model detects multiple boxes with different top-1 classes, then the accuracy
1467
- decreases and the image is put into 'TPI'.</p>
1468
- <p>Confusion matrix:</p>
1469
- <p><img src="{}"></p>
1470
- <div style='font-family:monospace;display:block;'>{}</div>
1471
- </div>
1472
- """.format(
1473
- np.mean(classifier_accuracies),
1474
- cm_figure_relative_filename,
1475
- "<br>".join(cm_str_lines).replace(' ', '&nbsp;')
1476
- )
1477
-
1478
- # Show links to each GT class
1479
- #
1480
- # We could do this without classification results; currently we don't.
1481
- if len(classname_to_idx) > 0:
1482
-
1483
- index_page += '<h3>Images of specific classes</h3><br/><div class="contentdiv">'
1484
- # Add links to all available classes
1485
- for cname in sorted(classname_to_idx.keys()):
1486
- index_page += '<a href="class_{0}.html">{0}</a> ({1})<br>'.format(
1487
- cname,
1488
- len(images_html['class_{}'.format(cname)]))
1489
- index_page += '</div>'
1490
-
1491
- # Close body and html tags
1492
- index_page += '</body></html>'
1493
- output_html_file = os.path.join(output_dir, 'index.html')
1494
- with open(output_html_file, 'w') as f:
1495
- f.write(index_page)
1496
-
1497
- print('Finished writing html to {}'.format(output_html_file))
1498
-
1499
- # ...for each image
1500
-
1501
-
1502
- ##%% Otherwise, if we don't have ground truth...
1503
-
1504
- else:
1505
-
1506
- ##%% Sample detections/non-detections
1507
-
1508
- # Accumulate html image structs (in the format expected by write_html_image_list)
1509
- # for each category
1510
- images_html = collections.defaultdict(list)
1511
-
1512
-
1513
- # Add default entries by accessing them for the first time
1514
-
1515
- # Maps sorted tuples of detection category IDs (string ints) - e.g. ("1"), ("1", "4", "7") - to
1516
- # result set names, e.g. "detections_human", "detections_cat_truck".
1517
- detection_categories_to_results_name = {}
1518
-
1519
- # Keep track of which categories are single-class (e.g. "animal") and which are
1520
- # combinations (e.g. "animal_vehicle")
1521
- detection_categories_to_category_count = {}
1522
-
1523
- # For the creation of a "non-detections" category
1524
- images_html['non_detections']
1525
- detection_categories_to_category_count['non_detections'] = 0
1526
-
1527
-
1528
- if not options.separate_detections_by_category:
1529
- # For the creation of a "detections" category
1530
- images_html['detections']
1531
- detection_categories_to_category_count['detections'] = 0
1532
- else:
1533
- # Add a set of results for each category and combination of categories, e.g.
1534
- # "detections_animal_vehicle". When we're using this script for non-MegaDetector
1535
- # results, this can generate lots of categories, e.g. detections_bear_bird_cat_dog_pig.
1536
- # We'll keep that huge set of combinations in this map, but we'll only write
1537
- # out links for the ones that are non-empty.
1538
- used_combinations = set()
1539
-
1540
- # row = images_to_visualize.iloc[0]
1541
- for i_row, row in images_to_visualize.iterrows():
1542
- detections_this_row = row['detections']
1543
- above_threshold_category_ids_this_row = set()
1544
- for detection in detections_this_row:
1545
- threshold = _get_threshold_for_category_id(detection['category'], options, detection_categories)
1546
- if detection['conf'] >= threshold:
1547
- above_threshold_category_ids_this_row.add(detection['category'])
1548
- if len(above_threshold_category_ids_this_row) == 0:
1549
- continue
1550
- sorted_categories_this_row = tuple(sorted(above_threshold_category_ids_this_row))
1551
- used_combinations.add(sorted_categories_this_row)
1552
-
1553
- for sorted_subset in used_combinations:
1554
- assert len(sorted_subset) > 0
1555
- results_name = 'detections'
1556
- for category_id in sorted_subset:
1557
- results_name = results_name + '_' + detection_categories[category_id]
1558
- images_html[results_name]
1559
- detection_categories_to_results_name[sorted_subset] = results_name
1560
- detection_categories_to_category_count[results_name] = len(sorted_subset)
1561
-
1562
- if options.include_almost_detections:
1563
- images_html['almost_detections']
1564
- detection_categories_to_category_count['almost_detections'] = 0
1565
-
1566
- # Create output directories
1567
- for res in images_html.keys():
1568
- os.makedirs(os.path.join(output_dir, res), exist_ok=True)
1569
-
1570
- image_count = len(images_to_visualize)
1571
-
1572
- # Each element will be a list of 2-tuples, with elements [collection name,html info struct]
1573
- rendering_results = []
1574
-
1575
- # list of 3-tuples with elements (file, max_conf, detections)
1576
- files_to_render = []
1577
-
1578
- # Assemble the information we need for rendering, so we can parallelize without
1579
- # dealing with Pandas
1580
- # i_row = 0; row = images_to_visualize.iloc[0]
1581
- for _, row in images_to_visualize.iterrows():
1582
-
1583
- assert isinstance(row['detections'],list)
1584
-
1585
- # Filenames should already have been normalized to either '/' or '\'
1586
- files_to_render.append([row['file'],
1587
- row['max_detection_conf'],
1588
- row['detections']])
1589
-
1590
- start_time = time.time()
1591
- if options.parallelize_rendering:
1592
-
1593
- if options.parallelize_rendering_n_cores is None:
1594
- if options.parallelize_rendering_with_threads:
1595
- pool = ThreadPool()
1596
- else:
1597
- pool = Pool()
1598
- else:
1599
- if options.parallelize_rendering_with_threads:
1600
- pool = ThreadPool(options.parallelize_rendering_n_cores)
1601
- worker_string = 'threads'
1602
- else:
1603
- pool = Pool(options.parallelize_rendering_n_cores)
1604
- worker_string = 'processes'
1605
- print('Rendering images with {} {}'.format(options.parallelize_rendering_n_cores,
1606
- worker_string))
1607
-
1608
- # _render_image_no_gt(file_info,detection_categories_to_results_name,
1609
- # detection_categories,classification_categories)
1610
-
1611
- rendering_results = list(tqdm(pool.imap(
1612
- partial(_render_image_no_gt,
1613
- detection_categories_to_results_name=detection_categories_to_results_name,
1614
- detection_categories=detection_categories,
1615
- classification_categories=classification_categories,
1616
- options=options),
1617
- files_to_render), total=len(files_to_render)))
1618
- else:
1619
- for file_info in tqdm(files_to_render):
1620
- rendering_results.append(_render_image_no_gt(file_info,
1621
- detection_categories_to_results_name,
1622
- detection_categories,
1623
- classification_categories,
1624
- options=options))
1625
-
1626
- elapsed = time.time() - start_time
1627
-
1628
- # Do we have classification results in addition to detection results?
1629
- has_classification_info = False
1630
-
1631
- # Map all the rendering results in the list rendering_results into the
1632
- # dictionary images_html
1633
- image_rendered_count = 0
1634
- for rendering_result in rendering_results:
1635
- if rendering_result is None:
1636
- continue
1637
- image_rendered_count += 1
1638
- for assignment in rendering_result:
1639
- if 'class' in assignment[0]:
1640
- has_classification_info = True
1641
- images_html[assignment[0]].append(assignment[1])
1642
-
1643
- # Prepare the individual html image files
1644
- image_counts = _prepare_html_subpages(images_html, output_dir, options)
1645
-
1646
- if image_rendered_count == 0:
1647
- seconds_per_image = 0.0
1648
- else:
1649
- seconds_per_image = elapsed/image_rendered_count
1650
-
1651
- print('Rendered {} images (of {}) in {} ({} per image)'.format(image_rendered_count,
1652
- image_count,humanfriendly.format_timespan(elapsed),
1653
- humanfriendly.format_timespan(seconds_per_image)))
1654
-
1655
- # Write index.html
1656
-
1657
- # We can't just sum these, because image_counts includes images in both their
1658
- # detection and classification classes
1659
- # total_images = sum(image_counts.values())
1660
- total_images = 0
1661
- for k in image_counts.keys():
1662
- v = image_counts[k]
1663
- if has_classification_info and k.startswith('class_'):
1664
- continue
1665
- total_images += v
1666
-
1667
- if total_images != image_count:
1668
- print('Warning, missing images: image_count is {}, total_images is {}'.format(total_images,image_count))
1669
-
1670
- almost_detection_string = ''
1671
- if options.include_almost_detections:
1672
- almost_detection_string = ' (&ldquo;almost detection&rdquo; threshold at {:.1%})'.format(
1673
- options.almost_detection_confidence_threshold)
1674
-
1675
- confidence_threshold_string = ''
1676
- if isinstance(options.confidence_threshold,float):
1677
- confidence_threshold_string = '{:.2%}'.format(options.confidence_threshold)
1678
- else:
1679
- confidence_threshold_string = str(options.confidence_threshold)
1680
-
1681
- index_page = """<html>\n{}\n<body>\n
1682
- <h2>Visualization of results for {}</h2>\n
1683
- <p>A sample of {} images (of {} total)FAILURE_PLACEHOLDER, annotated with detections above confidence {}{}.</p>\n
1684
-
1685
- <div class="contentdiv">
1686
- <p>Model version: {}</p>
1687
- </div>
1688
-
1689
- <h3>Sample images</h3>\n
1690
- <div class="contentdiv">\n""".format(
1691
- style_header, job_name_string, image_count, len(detections_df), confidence_threshold_string,
1692
- almost_detection_string, model_version_string)
1693
-
1694
- failure_string = ''
1695
- if n_failures is not None:
1696
- failure_string = ' ({} failures)'.format(n_failures)
1697
- index_page = index_page.replace('FAILURE_PLACEHOLDER',failure_string)
1698
-
1699
- def result_set_name_to_friendly_name(result_set_name):
1700
- friendly_name = ''
1701
- friendly_name = result_set_name.replace('_','-')
1702
- if friendly_name.startswith('detections-'):
1703
- friendly_name = friendly_name.replace('detections-', 'detections: ')
1704
- friendly_name = friendly_name.capitalize()
1705
- return friendly_name
1706
-
1707
- sorted_result_set_names = sorted(list(images_html.keys()))
1708
-
1709
- result_set_name_to_count = {}
1710
- for result_set_name in sorted_result_set_names:
1711
- image_count = image_counts[result_set_name]
1712
- result_set_name_to_count[result_set_name] = image_count
1713
- sorted_result_set_names = sorted(sorted_result_set_names,
1714
- key=lambda x: result_set_name_to_count[x],
1715
- reverse=True)
1716
-
1717
- for result_set_name in sorted_result_set_names:
1718
-
1719
- # Don't print classification classes here; we'll do that later with a slightly
1720
- # different structure
1721
- if has_classification_info and result_set_name.lower().startswith('class_'):
1722
- continue
1723
-
1724
- filename = result_set_name + '.html'
1725
- label = result_set_name_to_friendly_name(result_set_name)
1726
- image_count = image_counts[result_set_name]
1727
-
1728
- # Don't include line items for empty multi-category pages
1729
- if image_count == 0 and \
1730
- detection_categories_to_category_count[result_set_name] > 1:
1731
- continue
1732
-
1733
- if total_images == 0:
1734
- image_fraction = -1
1735
- else:
1736
- image_fraction = image_count / total_images
1737
-
1738
- # Write the line item for this category, including a link only if the
1739
- # category is non-empty
1740
- if image_count == 0:
1741
- index_page += '{} ({}, {:.1%})<br/>\n'.format(
1742
- label,image_count,image_fraction)
1743
- else:
1744
- index_page += '<a href="{}">{}</a> ({}, {:.1%})<br/>\n'.format(
1745
- filename,label,image_count,image_fraction)
1746
-
1747
- index_page += '</div>\n'
1748
-
1749
- if has_classification_info:
1750
- index_page += '<h3>Images of detected classes</h3>'
1751
- index_page += '<p>The same image might appear under multiple classes ' + \
1752
- 'if multiple species were detected.</p>\n'
1753
- index_page += '<p>Classifications with confidence less than {:.1%} confidence are considered "unreliable".</p>\n'.format(
1754
- options.classification_confidence_threshold)
1755
- index_page += '<div class="contentdiv">\n'
1756
-
1757
- # Add links to all available classes
1758
- class_names = sorted(classification_categories.values())
1759
- if 'class_unreliable' in images_html.keys():
1760
- class_names.append('unreliable')
1761
-
1762
- if options.sort_classification_results_by_count:
1763
- class_name_to_count = {}
1764
- for cname in class_names:
1765
- ccount = len(images_html['class_{}'.format(cname)])
1766
- class_name_to_count[cname] = ccount
1767
- class_names = sorted(class_names,key=lambda x: class_name_to_count[x],reverse=True)
1768
-
1769
- for cname in class_names:
1770
- ccount = len(images_html['class_{}'.format(cname)])
1771
- if ccount > 0:
1772
- index_page += '<a href="class_{}.html">{}</a> ({})<br/>\n'.format(
1773
- cname, cname.lower(), ccount)
1774
- index_page += '</div>\n'
1775
-
1776
- index_page += '</body></html>'
1777
- output_html_file = os.path.join(output_dir, 'index.html')
1778
- with open(output_html_file, 'w') as f:
1779
- f.write(index_page)
1780
-
1781
- print('Finished writing html to {}'.format(output_html_file))
1782
-
1783
- # os.startfile(output_html_file)
1784
-
1785
- # ...if we do/don't have ground truth
1786
-
1787
- ppresults.output_html_file = output_html_file
1788
- return ppresults
1789
-
1790
- # ...process_batch_results
1791
-
1792
-
1793
- #%% Interactive driver(s)
1794
-
1795
- if False:
1796
-
1797
- #%%
1798
-
1799
- base_dir = r'g:\temp'
1800
- options = PostProcessingOptions()
1801
- options.image_base_dir = base_dir
1802
- options.output_dir = os.path.join(base_dir, 'preview')
1803
- options.api_output_file = os.path.join(base_dir, 'results.json')
1804
- options.confidence_threshold = {'person':0.5,'animal':0.5,'vehicle':0.01}
1805
- options.include_almost_detections = True
1806
- options.almost_detection_confidence_threshold = 0.001
1807
-
1808
- ppresults = process_batch_results(options)
1809
- # from md_utils.path_utils import open_file; open_file(ppresults.output_html_file)
1810
-
1811
-
1812
- #%% Command-line driver
1813
-
1814
- def main():
1815
-
1816
- options = PostProcessingOptions()
1817
-
1818
- parser = argparse.ArgumentParser()
1819
- parser.add_argument(
1820
- 'api_output_file',
1821
- help='path to .json file produced by the batch inference API')
1822
- parser.add_argument(
1823
- 'output_dir',
1824
- help='base directory for output')
1825
- parser.add_argument(
1826
- '--image_base_dir', default=options.image_base_dir,
1827
- help='base directory for images (optional, can compute statistics '
1828
- 'without images)')
1829
- parser.add_argument(
1830
- '--ground_truth_json_file', default=options.ground_truth_json_file,
1831
- help='ground truth labels (optional, can render detections without '
1832
- 'ground truth), in the COCO Camera Traps format')
1833
- parser.add_argument(
1834
- '--confidence_threshold', type=float,
1835
- default=options.confidence_threshold,
1836
- help='Confidence threshold for statistics and visualization')
1837
- parser.add_argument(
1838
- '--almost_detection_confidence_threshold', type=float,
1839
- default=options.almost_detection_confidence_threshold,
1840
- help='Almost-detection confidence threshold for statistics and visualization')
1841
- parser.add_argument(
1842
- '--target_recall', type=float, default=options.target_recall,
1843
- help='Target recall (for statistics only)')
1844
- parser.add_argument(
1845
- '--num_images_to_sample', type=int,
1846
- default=options.num_images_to_sample,
1847
- help='number of images to visualize, -1 for all images (default: 500)')
1848
- parser.add_argument(
1849
- '--viz_target_width', type=int, default=options.viz_target_width,
1850
- help='Output image width')
1851
- parser.add_argument(
1852
- '--include_almost_detections', action='store_true',
1853
- help='Include a separate category for images just above a second confidence threshold')
1854
- parser.add_argument(
1855
- '--html_sort_order', type=str, default='filename',
1856
- help='Sort order for output pages, should be one of [filename,confidence,random] (defaults to filename)')
1857
- parser.add_argument(
1858
- '--sort_by_confidence', action='store_true',
1859
- help='Sort output in decreasing order by confidence (defaults to sorting by filename)')
1860
- parser.add_argument(
1861
- '--n_cores', type=int, default=1,
1862
- help='Number of threads to use for rendering (default: 1)')
1863
- parser.add_argument(
1864
- '--parallelize_rendering_with_processes',
1865
- action='store_true',
1866
- help='Should we use processes (instead of threads) for parallelization?')
1867
- parser.add_argument(
1868
- '--no_separate_detections_by_category',
1869
- action='store_true',
1870
- help='Collapse all categories into just "detections" and "non-detections"')
1871
- parser.add_argument(
1872
- '--open_output_file',
1873
- action='store_true',
1874
- help='Open the HTML output file when finished')
1875
- parser.add_argument(
1876
- '--max_figures_per_html_file',
1877
- type=int, default=None,
1878
- help='Maximum number of images to put on a single HTML page')
1879
-
1880
- if len(sys.argv[1:]) == 0:
1881
- parser.print_help()
1882
- parser.exit()
1883
-
1884
- args = parser.parse_args()
1885
-
1886
- if args.n_cores != 1:
1887
- assert (args.n_cores > 1), 'Illegal number of cores: {}'.format(args.n_cores)
1888
- if args.parallelize_rendering_with_processes:
1889
- args.parallelize_rendering_with_threads = False
1890
- args.parallelize_rendering = True
1891
- args.parallelize_rendering_n_cores = args.n_cores
1892
-
1893
- args_to_object(args, options)
1894
-
1895
- if args.no_separate_detections_by_category:
1896
- options.separate_detections_by_category = False
1897
-
1898
- ppresults = process_batch_results(options)
1899
-
1900
- if options.open_output_file:
1901
- path_utils.open_file(ppresults.output_html_file)
1902
-
1903
- if __name__ == '__main__':
1904
- main()