megadetector 10.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of megadetector might be problematic. Click here for more details.
- megadetector/__init__.py +0 -0
- megadetector/api/__init__.py +0 -0
- megadetector/api/batch_processing/integration/digiKam/setup.py +6 -0
- megadetector/api/batch_processing/integration/digiKam/xmp_integration.py +465 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/config_template.py +5 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +125 -0
- megadetector/api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +55 -0
- megadetector/classification/__init__.py +0 -0
- megadetector/classification/aggregate_classifier_probs.py +108 -0
- megadetector/classification/analyze_failed_images.py +227 -0
- megadetector/classification/cache_batchapi_outputs.py +198 -0
- megadetector/classification/create_classification_dataset.py +626 -0
- megadetector/classification/crop_detections.py +516 -0
- megadetector/classification/csv_to_json.py +226 -0
- megadetector/classification/detect_and_crop.py +853 -0
- megadetector/classification/efficientnet/__init__.py +9 -0
- megadetector/classification/efficientnet/model.py +415 -0
- megadetector/classification/efficientnet/utils.py +608 -0
- megadetector/classification/evaluate_model.py +520 -0
- megadetector/classification/identify_mislabeled_candidates.py +152 -0
- megadetector/classification/json_to_azcopy_list.py +63 -0
- megadetector/classification/json_validator.py +696 -0
- megadetector/classification/map_classification_categories.py +276 -0
- megadetector/classification/merge_classification_detection_output.py +509 -0
- megadetector/classification/prepare_classification_script.py +194 -0
- megadetector/classification/prepare_classification_script_mc.py +228 -0
- megadetector/classification/run_classifier.py +287 -0
- megadetector/classification/save_mislabeled.py +110 -0
- megadetector/classification/train_classifier.py +827 -0
- megadetector/classification/train_classifier_tf.py +725 -0
- megadetector/classification/train_utils.py +323 -0
- megadetector/data_management/__init__.py +0 -0
- megadetector/data_management/animl_to_md.py +161 -0
- megadetector/data_management/annotations/__init__.py +0 -0
- megadetector/data_management/annotations/annotation_constants.py +33 -0
- megadetector/data_management/camtrap_dp_to_coco.py +270 -0
- megadetector/data_management/cct_json_utils.py +566 -0
- megadetector/data_management/cct_to_md.py +184 -0
- megadetector/data_management/cct_to_wi.py +293 -0
- megadetector/data_management/coco_to_labelme.py +284 -0
- megadetector/data_management/coco_to_yolo.py +702 -0
- megadetector/data_management/databases/__init__.py +0 -0
- megadetector/data_management/databases/add_width_and_height_to_db.py +107 -0
- megadetector/data_management/databases/combine_coco_camera_traps_files.py +210 -0
- megadetector/data_management/databases/integrity_check_json_db.py +528 -0
- megadetector/data_management/databases/subset_json_db.py +195 -0
- megadetector/data_management/generate_crops_from_cct.py +200 -0
- megadetector/data_management/get_image_sizes.py +164 -0
- megadetector/data_management/labelme_to_coco.py +559 -0
- megadetector/data_management/labelme_to_yolo.py +349 -0
- megadetector/data_management/lila/__init__.py +0 -0
- megadetector/data_management/lila/create_lila_blank_set.py +556 -0
- megadetector/data_management/lila/create_lila_test_set.py +187 -0
- megadetector/data_management/lila/create_links_to_md_results_files.py +106 -0
- megadetector/data_management/lila/download_lila_subset.py +182 -0
- megadetector/data_management/lila/generate_lila_per_image_labels.py +777 -0
- megadetector/data_management/lila/get_lila_annotation_counts.py +174 -0
- megadetector/data_management/lila/get_lila_image_counts.py +112 -0
- megadetector/data_management/lila/lila_common.py +319 -0
- megadetector/data_management/lila/test_lila_metadata_urls.py +164 -0
- megadetector/data_management/mewc_to_md.py +344 -0
- megadetector/data_management/ocr_tools.py +873 -0
- megadetector/data_management/read_exif.py +964 -0
- megadetector/data_management/remap_coco_categories.py +195 -0
- megadetector/data_management/remove_exif.py +156 -0
- megadetector/data_management/rename_images.py +194 -0
- megadetector/data_management/resize_coco_dataset.py +663 -0
- megadetector/data_management/speciesnet_to_md.py +41 -0
- megadetector/data_management/wi_download_csv_to_coco.py +247 -0
- megadetector/data_management/yolo_output_to_md_output.py +594 -0
- megadetector/data_management/yolo_to_coco.py +876 -0
- megadetector/data_management/zamba_to_md.py +188 -0
- megadetector/detection/__init__.py +0 -0
- megadetector/detection/change_detection.py +840 -0
- megadetector/detection/process_video.py +479 -0
- megadetector/detection/pytorch_detector.py +1451 -0
- megadetector/detection/run_detector.py +1267 -0
- megadetector/detection/run_detector_batch.py +2159 -0
- megadetector/detection/run_inference_with_yolov5_val.py +1314 -0
- megadetector/detection/run_md_and_speciesnet.py +1494 -0
- megadetector/detection/run_tiled_inference.py +1038 -0
- megadetector/detection/tf_detector.py +209 -0
- megadetector/detection/video_utils.py +1379 -0
- megadetector/postprocessing/__init__.py +0 -0
- megadetector/postprocessing/add_max_conf.py +72 -0
- megadetector/postprocessing/categorize_detections_by_size.py +166 -0
- megadetector/postprocessing/classification_postprocessing.py +1752 -0
- megadetector/postprocessing/combine_batch_outputs.py +249 -0
- megadetector/postprocessing/compare_batch_results.py +2110 -0
- megadetector/postprocessing/convert_output_format.py +403 -0
- megadetector/postprocessing/create_crop_folder.py +629 -0
- megadetector/postprocessing/detector_calibration.py +570 -0
- megadetector/postprocessing/generate_csv_report.py +522 -0
- megadetector/postprocessing/load_api_results.py +223 -0
- megadetector/postprocessing/md_to_coco.py +428 -0
- megadetector/postprocessing/md_to_labelme.py +351 -0
- megadetector/postprocessing/md_to_wi.py +41 -0
- megadetector/postprocessing/merge_detections.py +392 -0
- megadetector/postprocessing/postprocess_batch_results.py +2077 -0
- megadetector/postprocessing/remap_detection_categories.py +226 -0
- megadetector/postprocessing/render_detection_confusion_matrix.py +677 -0
- megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +206 -0
- megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +82 -0
- megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +1665 -0
- megadetector/postprocessing/separate_detections_into_folders.py +795 -0
- megadetector/postprocessing/subset_json_detector_output.py +964 -0
- megadetector/postprocessing/top_folders_to_bottom.py +238 -0
- megadetector/postprocessing/validate_batch_results.py +332 -0
- megadetector/taxonomy_mapping/__init__.py +0 -0
- megadetector/taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +491 -0
- megadetector/taxonomy_mapping/map_new_lila_datasets.py +213 -0
- megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +165 -0
- megadetector/taxonomy_mapping/preview_lila_taxonomy.py +543 -0
- megadetector/taxonomy_mapping/retrieve_sample_image.py +71 -0
- megadetector/taxonomy_mapping/simple_image_download.py +224 -0
- megadetector/taxonomy_mapping/species_lookup.py +1008 -0
- megadetector/taxonomy_mapping/taxonomy_csv_checker.py +159 -0
- megadetector/taxonomy_mapping/taxonomy_graph.py +346 -0
- megadetector/taxonomy_mapping/validate_lila_category_mappings.py +83 -0
- megadetector/tests/__init__.py +0 -0
- megadetector/tests/test_nms_synthetic.py +335 -0
- megadetector/utils/__init__.py +0 -0
- megadetector/utils/ct_utils.py +1857 -0
- megadetector/utils/directory_listing.py +199 -0
- megadetector/utils/extract_frames_from_video.py +307 -0
- megadetector/utils/gpu_test.py +125 -0
- megadetector/utils/md_tests.py +2072 -0
- megadetector/utils/path_utils.py +2832 -0
- megadetector/utils/process_utils.py +172 -0
- megadetector/utils/split_locations_into_train_val.py +237 -0
- megadetector/utils/string_utils.py +234 -0
- megadetector/utils/url_utils.py +825 -0
- megadetector/utils/wi_platform_utils.py +968 -0
- megadetector/utils/wi_taxonomy_utils.py +1759 -0
- megadetector/utils/write_html_image_list.py +239 -0
- megadetector/visualization/__init__.py +0 -0
- megadetector/visualization/plot_utils.py +309 -0
- megadetector/visualization/render_images_with_thumbnails.py +243 -0
- megadetector/visualization/visualization_utils.py +1940 -0
- megadetector/visualization/visualize_db.py +630 -0
- megadetector/visualization/visualize_detector_output.py +479 -0
- megadetector/visualization/visualize_video_output.py +705 -0
- megadetector-10.0.13.dist-info/METADATA +134 -0
- megadetector-10.0.13.dist-info/RECORD +147 -0
- megadetector-10.0.13.dist-info/WHEEL +5 -0
- megadetector-10.0.13.dist-info/licenses/LICENSE +19 -0
- megadetector-10.0.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
ct_utils.py
|
|
4
|
+
|
|
5
|
+
Numeric/geometry/array utility functions.
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
#%% Imports and constants
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import builtins
|
|
16
|
+
import datetime
|
|
17
|
+
import tempfile
|
|
18
|
+
import shutil
|
|
19
|
+
import platform
|
|
20
|
+
import sys
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
import jsonpickle
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from operator import itemgetter
|
|
27
|
+
|
|
28
|
+
# List of file extensions we'll consider images; comparisons will be case-insensitive
|
|
29
|
+
# (i.e., no need to include both .jpg and .JPG on this list).
|
|
30
|
+
image_extensions = ['.jpg', '.jpeg', '.gif', '.png']
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#%% Functions
|
|
34
|
+
|
|
35
|
+
def truncate_float_array(xs, precision=3):
|
|
36
|
+
"""
|
|
37
|
+
Truncates the fractional portion of each floating-point value in the array [xs]
|
|
38
|
+
to a specific number of floating-point digits.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
xs (list): list of floats to truncate
|
|
42
|
+
precision (int, optional): the number of significant digits to preserve, should be >= 1
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
list: list of truncated floats
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
return [truncate_float(x, precision=precision) for x in xs]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def round_float_array(xs, precision=3):
|
|
52
|
+
"""
|
|
53
|
+
Truncates the fractional portion of each floating-point value in the array [xs]
|
|
54
|
+
to a specific number of floating-point digits.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
xs (list): list of floats to round
|
|
58
|
+
precision (int, optional): the number of significant digits to preserve, should be >= 1
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
list: list of rounded floats
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return [round_float(x,precision) for x in xs]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def round_float(x, precision=3):
|
|
68
|
+
"""
|
|
69
|
+
Convenience wrapper for the native Python round()
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
x (float): number to truncate
|
|
73
|
+
precision (int, optional): the number of significant digits to preserve, should be >= 1
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
float: rounded value
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
return round(x,precision)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def truncate_float(x, precision=3):
|
|
83
|
+
"""
|
|
84
|
+
Truncates the fractional portion of a floating-point value to a specific number of
|
|
85
|
+
floating-point digits.
|
|
86
|
+
|
|
87
|
+
For example:
|
|
88
|
+
|
|
89
|
+
truncate_float(0.0003214884) --> 0.000321
|
|
90
|
+
truncate_float(1.0003214884) --> 1.000321
|
|
91
|
+
|
|
92
|
+
This function is primarily used to achieve a certain float representation
|
|
93
|
+
before exporting to JSON.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
x (float): scalar to truncate
|
|
97
|
+
precision (int, optional): the number of significant digits to preserve, should be >= 1
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
float: truncated version of [x]
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
return math.floor(x * (10 ** precision)) / (10 ** precision)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def args_to_object(args, obj):
|
|
107
|
+
"""
|
|
108
|
+
Copies all fields from a Namespace (typically the output from parse_args) to an
|
|
109
|
+
object. Skips fields starting with _. Does not check existence in the target
|
|
110
|
+
object.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
args (argparse.Namespace): the namespace to convert to an object
|
|
114
|
+
obj (object): object whose whose attributes will be updated
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
object: the modified object (modified in place, but also returned)
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
for n, v in inspect.getmembers(args):
|
|
121
|
+
if not n.startswith('_'):
|
|
122
|
+
setattr(obj, n, v)
|
|
123
|
+
|
|
124
|
+
return obj
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def dict_to_object(d, obj):
|
|
128
|
+
"""
|
|
129
|
+
Copies all fields from a dict to an object. Skips fields starting with _.
|
|
130
|
+
Does not check existence in the target object.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
d (dict): the dict to convert to an object
|
|
134
|
+
obj (object): object whose whose attributes will be updated
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
object: the modified object (modified in place, but also returned)
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
for k in d.keys():
|
|
141
|
+
if not k.startswith('_'):
|
|
142
|
+
setattr(obj, k, d[k])
|
|
143
|
+
|
|
144
|
+
return obj
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pretty_print_object(obj, b_print=True):
|
|
148
|
+
"""
|
|
149
|
+
Converts an arbitrary object to .json, optionally printing the .json representation.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
obj (object): object to print
|
|
153
|
+
b_print (bool, optional): whether to print the object
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
str: .json reprepresentation of [obj]
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
# _ = pretty_print_object(obj)
|
|
160
|
+
|
|
161
|
+
# TODO: it's sloppy that I'm making a module-wide change here, consider at least
|
|
162
|
+
# recording these operations and re-setting them at the end of this function.
|
|
163
|
+
jsonpickle.set_encoder_options('json', sort_keys=True, indent=2)
|
|
164
|
+
a = jsonpickle.encode(obj)
|
|
165
|
+
s = '{}'.format(a)
|
|
166
|
+
if b_print:
|
|
167
|
+
print(s)
|
|
168
|
+
return s
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_list_sorted(L, reverse=False): # noqa
|
|
172
|
+
"""
|
|
173
|
+
Returns True if the list L appears to be sorted, otherwise False.
|
|
174
|
+
|
|
175
|
+
Calling is_list_sorted(L,reverse=True) is the same as calling
|
|
176
|
+
is_list_sorted(L.reverse(),reverse=False).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
L (list): list to evaluate
|
|
180
|
+
reverse (bool, optional): whether to reverse the list before evaluating sort status
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
bool: True if the list L appears to be sorted, otherwise False
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if reverse:
|
|
187
|
+
return all(L[i] >= L[i + 1] for i in range(len(L)-1))
|
|
188
|
+
else:
|
|
189
|
+
return all(L[i] <= L[i + 1] for i in range(len(L)-1))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def json_serialize_datetime(obj):
|
|
193
|
+
"""
|
|
194
|
+
Serializes datetime.datetime and datetime.date objects to ISO format.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
obj (object): The object to serialize.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
str: The ISO format string representation of the datetime object.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
TypeError: If the object is not a datetime.datetime or datetime.date instance.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(obj, (datetime.datetime, datetime.date)):
|
|
206
|
+
return obj.isoformat()
|
|
207
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable by json_serialize_datetime")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_json(path,
|
|
211
|
+
content,
|
|
212
|
+
indent=1,
|
|
213
|
+
force_str=False,
|
|
214
|
+
serialize_datetimes=False,
|
|
215
|
+
ensure_ascii=True,
|
|
216
|
+
encoding='utf-8'):
|
|
217
|
+
"""
|
|
218
|
+
Standardized wrapper for json.dump().
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
path (str): filename to write to
|
|
222
|
+
content (object): object to dump
|
|
223
|
+
indent (int, optional): indentation depth passed to json.dump
|
|
224
|
+
force_str (bool, optional): whether to force string conversion for non-serializable objects
|
|
225
|
+
serialize_datetimes (bool, optional): whether to serialize datetime objects to ISO format
|
|
226
|
+
ensure_ascii (bool, optional): whether to ensure ASCII characters in the output
|
|
227
|
+
encoding (str, optional): string encoding to use
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
default_handler = None
|
|
231
|
+
|
|
232
|
+
if serialize_datetimes:
|
|
233
|
+
default_handler = json_serialize_datetime
|
|
234
|
+
if force_str:
|
|
235
|
+
def serialize_or_str(obj):
|
|
236
|
+
try:
|
|
237
|
+
return json_serialize_datetime(obj)
|
|
238
|
+
except TypeError:
|
|
239
|
+
return str(obj)
|
|
240
|
+
default_handler = serialize_or_str
|
|
241
|
+
elif force_str:
|
|
242
|
+
default_handler = str
|
|
243
|
+
|
|
244
|
+
# Create the parent directory if necessary
|
|
245
|
+
parent_dir = os.path.dirname(path)
|
|
246
|
+
if len(parent_dir) > 0:
|
|
247
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
248
|
+
|
|
249
|
+
with open(path, 'w', newline='\n', encoding=encoding) as f:
|
|
250
|
+
json.dump(content, f, indent=indent, default=default_handler, ensure_ascii=ensure_ascii)
|
|
251
|
+
|
|
252
|
+
# ...def write_json(...)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def convert_yolo_to_xywh(yolo_box):
|
|
256
|
+
"""
|
|
257
|
+
Converts a YOLO format bounding box [x_center, y_center, w, h] to
|
|
258
|
+
[x_min, y_min, width_of_box, height_of_box].
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
yolo_box (list): bounding box of format [x_center, y_center, width_of_box, height_of_box]
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
list: bbox with coordinates represented as [x_min, y_min, width_of_box, height_of_box]
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
x_center, y_center, width_of_box, height_of_box = yolo_box
|
|
268
|
+
x_min = x_center - width_of_box / 2.0
|
|
269
|
+
y_min = y_center - height_of_box / 2.0
|
|
270
|
+
return [x_min, y_min, width_of_box, height_of_box]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def convert_xywh_to_xyxy(api_box):
|
|
274
|
+
"""
|
|
275
|
+
Converts an xywh bounding box (the MD output format) to an xyxy bounding box (the format
|
|
276
|
+
produced by TF-based MD models).
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
api_box (list): bbox formatted as [x_min, y_min, width_of_box, height_of_box]
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
list: bbox formatted as [x_min, y_min, x_max, y_max]
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
x_min, y_min, width_of_box, height_of_box = api_box
|
|
286
|
+
x_max = x_min + width_of_box
|
|
287
|
+
y_max = y_min + height_of_box
|
|
288
|
+
return [x_min, y_min, x_max, y_max]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_iou(bb1, bb2):
|
|
292
|
+
"""
|
|
293
|
+
Calculates the intersection over union (IoU) of two bounding boxes.
|
|
294
|
+
|
|
295
|
+
Adapted from:
|
|
296
|
+
|
|
297
|
+
https://stackoverflow.com/questions/25349178/calculating-percentage-of-bounding-box-overlap-for-image-detector-evaluation
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
bb1 (list): [x_min, y_min, width_of_box, height_of_box]
|
|
301
|
+
bb2 (list): [x_min, y_min, width_of_box, height_of_box]
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
float: intersection_over_union, a float in [0, 1]
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
bb1 = convert_xywh_to_xyxy(bb1)
|
|
308
|
+
bb2 = convert_xywh_to_xyxy(bb2)
|
|
309
|
+
|
|
310
|
+
assert bb1[0] < bb1[2], 'Malformed bounding box (x2 >= x1)'
|
|
311
|
+
assert bb1[1] < bb1[3], 'Malformed bounding box (y2 >= y1)'
|
|
312
|
+
|
|
313
|
+
assert bb2[0] < bb2[2], 'Malformed bounding box (x2 >= x1)'
|
|
314
|
+
assert bb2[1] < bb2[3], 'Malformed bounding box (y2 >= y1)'
|
|
315
|
+
|
|
316
|
+
# Determine the coordinates of the intersection rectangle
|
|
317
|
+
x_left = max(bb1[0], bb2[0])
|
|
318
|
+
y_top = max(bb1[1], bb2[1])
|
|
319
|
+
x_right = min(bb1[2], bb2[2])
|
|
320
|
+
y_bottom = min(bb1[3], bb2[3])
|
|
321
|
+
|
|
322
|
+
if x_right < x_left or y_bottom < y_top:
|
|
323
|
+
return 0.0
|
|
324
|
+
|
|
325
|
+
# The intersection of two axis-aligned bounding boxes is always an
|
|
326
|
+
# axis-aligned bounding box
|
|
327
|
+
intersection_area = (x_right - x_left) * (y_bottom - y_top)
|
|
328
|
+
|
|
329
|
+
# Compute the area of both AABBs
|
|
330
|
+
bb1_area = (bb1[2] - bb1[0]) * (bb1[3] - bb1[1])
|
|
331
|
+
bb2_area = (bb2[2] - bb2[0]) * (bb2[3] - bb2[1])
|
|
332
|
+
|
|
333
|
+
# Compute the intersection over union by taking the intersection
|
|
334
|
+
# area and dividing it by the sum of prediction + ground-truth
|
|
335
|
+
# areas - the intersection area.
|
|
336
|
+
iou = intersection_area / float(bb1_area + bb2_area - intersection_area)
|
|
337
|
+
assert iou >= 0.0, 'Illegal IOU < 0'
|
|
338
|
+
assert iou <= 1.0, 'Illegal IOU > 1'
|
|
339
|
+
return iou
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _get_max_conf_from_detections(detections):
|
|
343
|
+
"""
|
|
344
|
+
Internal function used by get_max_conf(); don't call this directly.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
max_conf = 0.0
|
|
348
|
+
if detections is not None and len(detections) > 0:
|
|
349
|
+
confidences = [det['conf'] for det in detections]
|
|
350
|
+
max_conf = max(confidences)
|
|
351
|
+
return max_conf
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_max_conf(im):
|
|
355
|
+
"""
|
|
356
|
+
Given an image dict in the MD output format, computes the maximum detection confidence for any
|
|
357
|
+
class. Returns 0.0 if there were no detections, if there was a failure, or if 'detections' isn't
|
|
358
|
+
present.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
im (dict): image dictionary in the MD output format (with a 'detections' field)
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
float: the maximum detection confidence across all classes
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
max_conf = 0.0
|
|
368
|
+
if 'detections' in im and im['detections'] is not None and len(im['detections']) > 0:
|
|
369
|
+
max_conf = _get_max_conf_from_detections(im['detections'])
|
|
370
|
+
return max_conf
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def sort_results_for_image(im):
|
|
374
|
+
"""
|
|
375
|
+
Sort classification and detection results in descending order by confidence (in place).
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
im (dict): image dictionary in the MD output format (with a 'detections' field)
|
|
379
|
+
"""
|
|
380
|
+
if 'detections' not in im or im['detections'] is None:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Sort detections in descending order by confidence
|
|
384
|
+
im['detections'] = sort_list_of_dicts_by_key(im['detections'],k='conf',reverse=True)
|
|
385
|
+
|
|
386
|
+
for det in im['detections']:
|
|
387
|
+
|
|
388
|
+
# Sort classifications (which are (class,conf) tuples) in descending order by confidence
|
|
389
|
+
if 'classifications' in det and \
|
|
390
|
+
(det['classifications'] is not None) and \
|
|
391
|
+
(len(det['classifications']) > 0):
|
|
392
|
+
classifications = det['classifications']
|
|
393
|
+
det['classifications'] = \
|
|
394
|
+
sorted(classifications,key=itemgetter(1),reverse=True)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def point_dist(p1,p2):
|
|
398
|
+
"""
|
|
399
|
+
Computes the distance between two points, represented as length-two tuples.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
p1 (list or tuple): point, formatted as (x,y)
|
|
403
|
+
p2 (list or tuple): point, formatted as (x,y)
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
float: the Euclidean distance between p1 and p2
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
return math.sqrt( ((p1[0]-p2[0])**2) + ((p1[1]-p2[1])**2) )
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def rect_distance(r1, r2, format='x0y0x1y1'):
|
|
413
|
+
"""
|
|
414
|
+
Computes the minimum distance between two axis-aligned rectangles, each represented as
|
|
415
|
+
(x0,y0,x1,y1) by default.
|
|
416
|
+
|
|
417
|
+
Can also specify "format" as x0y0wh for MD-style bbox formatting (x0,y0,w,h).
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
r1 (list or tuple): rectangle, formatted as (x0,y0,x1,y1) or (x0,y0,xy,y1)
|
|
421
|
+
r2 (list or tuple): rectangle, formatted as (x0,y0,x1,y1) or (x0,y0,xy,y1)
|
|
422
|
+
format (str, optional): whether the boxes are formatted as 'x0y0x1y1' (default) or 'x0y0wh'
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
float: the minimum distance between r1 and r2
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
assert format in ('x0y0x1y1','x0y0wh'), 'Illegal rectangle format {}'.format(format)
|
|
429
|
+
|
|
430
|
+
if format == 'x0y0wh':
|
|
431
|
+
# Convert to x0y0x1y1 without modifying the original rectangles
|
|
432
|
+
r1 = [r1[0],r1[1],r1[0]+r1[2],r1[1]+r1[3]]
|
|
433
|
+
r2 = [r2[0],r2[1],r2[0]+r2[2],r2[1]+r2[3]]
|
|
434
|
+
|
|
435
|
+
# https://stackoverflow.com/a/26178015
|
|
436
|
+
x1, y1, x1b, y1b = r1
|
|
437
|
+
x2, y2, x2b, y2b = r2
|
|
438
|
+
left = x2b < x1
|
|
439
|
+
right = x1b < x2
|
|
440
|
+
bottom = y2b < y1
|
|
441
|
+
top = y1b < y2
|
|
442
|
+
if top and left:
|
|
443
|
+
return point_dist((x1, y1b), (x2b, y2))
|
|
444
|
+
elif left and bottom:
|
|
445
|
+
return point_dist((x1, y1), (x2b, y2b))
|
|
446
|
+
elif bottom and right:
|
|
447
|
+
return point_dist((x1b, y1), (x2, y2b))
|
|
448
|
+
elif right and top:
|
|
449
|
+
return point_dist((x1b, y1b), (x2, y2))
|
|
450
|
+
elif left:
|
|
451
|
+
return x1 - x2b
|
|
452
|
+
elif right:
|
|
453
|
+
return x2 - x1b
|
|
454
|
+
elif bottom:
|
|
455
|
+
return y1 - y2b
|
|
456
|
+
elif top:
|
|
457
|
+
return y2 - y1b
|
|
458
|
+
else:
|
|
459
|
+
return 0.0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def split_list_into_fixed_size_chunks(L,n): # noqa
|
|
463
|
+
"""
|
|
464
|
+
Split the list or tuple L into chunks of size n (allowing at most one chunk with size
|
|
465
|
+
less than N, i.e. len(L) does not have to be a multiple of n).
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
L (list): list to split into chunks
|
|
469
|
+
n (int): preferred chunk size
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
list: list of chunks, where each chunk is a list of length n or n-1
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
return [L[i * n:(i + 1) * n] for i in range((len(L) + n - 1) // n )]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def split_list_into_n_chunks(L, n, chunk_strategy='greedy'): # noqa
|
|
479
|
+
"""
|
|
480
|
+
Splits the list or tuple L into n equally-sized chunks (some chunks may be one
|
|
481
|
+
element smaller than others, i.e. len(L) does not have to be a multiple of n).
|
|
482
|
+
|
|
483
|
+
chunk_strategy can be "greedy" (default, if there are k samples per chunk, the first
|
|
484
|
+
k go into the first chunk) or "balanced" (alternate between chunks when pulling
|
|
485
|
+
items from the list).
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
L (list): list to split into chunks
|
|
489
|
+
n (int): number of chunks
|
|
490
|
+
chunk_strategy (str, optional): "greedy" or "balanced"; see above
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
list: list of chunks, each of which is a list
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
if chunk_strategy == 'greedy':
|
|
497
|
+
k, m = divmod(len(L), n)
|
|
498
|
+
return list(L[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(n))
|
|
499
|
+
elif chunk_strategy == 'balanced':
|
|
500
|
+
chunks = [ [] for _ in range(n) ]
|
|
501
|
+
for i_item,item in enumerate(L):
|
|
502
|
+
i_chunk = i_item % n
|
|
503
|
+
chunks[i_chunk].append(item)
|
|
504
|
+
return chunks
|
|
505
|
+
else:
|
|
506
|
+
raise ValueError('Invalid chunk strategy: {}'.format(chunk_strategy))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def sort_list_of_dicts_by_key(L, k, reverse=False, none_handling='smallest'): # noqa ("L" should be lowercase)
|
|
510
|
+
"""
|
|
511
|
+
Sorts the list of dictionaries [L] by the key [k].
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
L (list): list of dictionaries to sort
|
|
515
|
+
k (object, typically str): the sort key
|
|
516
|
+
reverse (bool, optional): whether to sort in reverse (descending) order
|
|
517
|
+
none_handling (str, optional): how to handle None values. Options:
|
|
518
|
+
"smallest" - treat None as smaller than all other values (default)
|
|
519
|
+
"largest" - treat None as larger than all other values
|
|
520
|
+
"error" - raise error when None is compared with non-None
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
list: sorted copy of [L]
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
if none_handling == 'error':
|
|
527
|
+
return sorted(L, key=lambda d: d[k], reverse=reverse)
|
|
528
|
+
elif none_handling == 'smallest':
|
|
529
|
+
# None values treated as smaller than other values: use tuple (is_not_none, value)
|
|
530
|
+
return sorted(L, key=lambda d: (d[k] is not None, d[k]), reverse=reverse)
|
|
531
|
+
elif none_handling == "largest":
|
|
532
|
+
# None values treated as larger than other values: use tuple (is_none, value)
|
|
533
|
+
return sorted(L, key=lambda d: (d[k] is None, d[k]), reverse=reverse)
|
|
534
|
+
else:
|
|
535
|
+
raise ValueError('Invalid none_handling value: {}'.format(none_handling))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def sort_dictionary_by_key(d,reverse=False):
|
|
539
|
+
"""
|
|
540
|
+
Sorts the dictionary [d] by key.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
d (dict): dictionary to sort
|
|
544
|
+
reverse (bool, optional): whether to sort in reverse (descending) order
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
dict: sorted copy of [d]
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
d = dict(sorted(d.items(),reverse=reverse))
|
|
551
|
+
return d
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def sort_dictionary_by_value(d,sort_values=None,reverse=False):
|
|
555
|
+
"""
|
|
556
|
+
Sorts the dictionary [d] by value. If sort_values is None, uses d.values(),
|
|
557
|
+
otherwise uses the dictionary sort_values as the sorting criterion. Always
|
|
558
|
+
returns a new standard dict, so if [d] is, for example, a defaultdict, the
|
|
559
|
+
returned value is not.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
d (dict): dictionary to sort
|
|
563
|
+
sort_values (dict, optional): dictionary mapping keys in [d] to sort values (defaults
|
|
564
|
+
to None, uses [d] itself for sorting)
|
|
565
|
+
reverse (bool, optional): whether to sort in reverse (descending) order
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
dict: sorted copy of [d]
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
if sort_values is None:
|
|
572
|
+
d = {k: v for k, v in sorted(d.items(), key=lambda item: item[1], reverse=reverse)}
|
|
573
|
+
else:
|
|
574
|
+
d = {k: v for k, v in sorted(d.items(), key=lambda item: sort_values[item[0]], reverse=reverse)}
|
|
575
|
+
return d
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def invert_dictionary(d):
|
|
579
|
+
"""
|
|
580
|
+
Creates a new dictionary that maps d.values() to d.keys(). Does not check
|
|
581
|
+
uniqueness.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
d (dict): dictionary to invert
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
dict: inverted copy of [d]
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
return {v: k for k, v in d.items()}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def round_floats_in_nested_dict(obj, decimal_places=5, allow_iterator_conversion=False):
|
|
594
|
+
"""
|
|
595
|
+
Recursively rounds all floating point values in a nested structure to the
|
|
596
|
+
specified number of decimal places. Handles dictionaries, lists, tuples,
|
|
597
|
+
sets, and other iterables. Modifies mutable objects in place.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
obj (obj): The object to process (can be a dict, list, set, tuple, or primitive value)
|
|
601
|
+
decimal_places (int, optional): Number of decimal places to round to
|
|
602
|
+
allow_iterator_conversion (bool, optional): for iterator types, should we convert
|
|
603
|
+
to lists? Otherwise we error.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
The processed object (useful for recursive calls)
|
|
607
|
+
"""
|
|
608
|
+
if isinstance(obj, dict):
|
|
609
|
+
for key in obj:
|
|
610
|
+
obj[key] = round_floats_in_nested_dict(obj[key], decimal_places=decimal_places,
|
|
611
|
+
allow_iterator_conversion=allow_iterator_conversion)
|
|
612
|
+
return obj
|
|
613
|
+
|
|
614
|
+
elif isinstance(obj, list):
|
|
615
|
+
for i in range(len(obj)):
|
|
616
|
+
obj[i] = round_floats_in_nested_dict(obj[i], decimal_places=decimal_places,
|
|
617
|
+
allow_iterator_conversion=allow_iterator_conversion)
|
|
618
|
+
return obj
|
|
619
|
+
|
|
620
|
+
elif isinstance(obj, tuple):
|
|
621
|
+
# Tuples are immutable, so we create a new one
|
|
622
|
+
return tuple(round_floats_in_nested_dict(item, decimal_places=decimal_places,
|
|
623
|
+
allow_iterator_conversion=allow_iterator_conversion) for item in obj)
|
|
624
|
+
|
|
625
|
+
elif isinstance(obj, set):
|
|
626
|
+
# Sets are mutable but we can't modify elements in-place
|
|
627
|
+
# Convert to list, process, and convert back to set
|
|
628
|
+
return set(round_floats_in_nested_dict(list(obj), decimal_places=decimal_places,
|
|
629
|
+
allow_iterator_conversion=allow_iterator_conversion))
|
|
630
|
+
|
|
631
|
+
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)):
|
|
632
|
+
# Handle other iterable types: convert to list, process, and convert back
|
|
633
|
+
processed_list = [round_floats_in_nested_dict(item,
|
|
634
|
+
decimal_places=decimal_places,
|
|
635
|
+
allow_iterator_conversion=allow_iterator_conversion) \
|
|
636
|
+
for item in obj]
|
|
637
|
+
|
|
638
|
+
# Try to recreate the original type, but fall back to list for iterators
|
|
639
|
+
try:
|
|
640
|
+
return type(obj)(processed_list)
|
|
641
|
+
except (TypeError, ValueError):
|
|
642
|
+
if allow_iterator_conversion:
|
|
643
|
+
# For iterators and other types that can't be reconstructed, return a list
|
|
644
|
+
return processed_list
|
|
645
|
+
else:
|
|
646
|
+
raise ValueError('Cannot process iterator types when allow_iterator_conversion is False')
|
|
647
|
+
|
|
648
|
+
elif isinstance(obj, float):
|
|
649
|
+
return round(obj, decimal_places)
|
|
650
|
+
|
|
651
|
+
else:
|
|
652
|
+
# For other types (int, str, bool, None, etc.), return as is
|
|
653
|
+
return obj
|
|
654
|
+
|
|
655
|
+
# ...def round_floats_in_nested_dict(...)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def image_file_to_camera_folder(image_fn):
|
|
659
|
+
r"""
|
|
660
|
+
Removes common overflow folders (e.g. RECNX101, RECNX102) from paths, i.e. turn:
|
|
661
|
+
|
|
662
|
+
a\b\c\RECNX101\image001.jpg
|
|
663
|
+
|
|
664
|
+
...into:
|
|
665
|
+
|
|
666
|
+
a\b\c
|
|
667
|
+
|
|
668
|
+
Returns the same thing as os.dirname() (i.e., just the folder name) if no overflow folders are
|
|
669
|
+
present.
|
|
670
|
+
|
|
671
|
+
Always converts backslashes to slashes.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
image_fn (str): the image filename from which we should remove overflow folders
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
str: a version of [image_fn] from which camera overflow folders have been removed
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
import re
|
|
681
|
+
|
|
682
|
+
# 100RECNX is the overflow folder style for Reconyx cameras
|
|
683
|
+
# 100EK113 is (for some reason) the overflow folder style for Bushnell cameras
|
|
684
|
+
# 100_BTCF is the overflow folder style for Browning cameras
|
|
685
|
+
# 100MEDIA is the overflow folder style used on a number of consumer-grade cameras
|
|
686
|
+
patterns = [r'/\d+RECNX/',r'/\d+EK\d+/',r'/\d+_BTCF/',r'/\d+MEDIA/']
|
|
687
|
+
|
|
688
|
+
image_fn = image_fn.replace('\\','/')
|
|
689
|
+
for pat in patterns:
|
|
690
|
+
image_fn = re.sub(pat,'/',image_fn)
|
|
691
|
+
camera_folder = os.path.dirname(image_fn)
|
|
692
|
+
|
|
693
|
+
return camera_folder
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def is_float(v):
|
|
697
|
+
"""
|
|
698
|
+
Determines whether v is either a float or a string representation of a float.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
v (object): object to evaluate
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
bool: True if [v] is a float or a string representation of a float, otherwise False
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
if v is None:
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
_ = float(v)
|
|
712
|
+
return True
|
|
713
|
+
except ValueError:
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def is_iterable(x):
|
|
718
|
+
"""
|
|
719
|
+
Uses duck typing to assess whether [x] is iterable (list, set, dict, etc.).
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
x (object): the object to test
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
bool: True if [x] appears to be iterable, otherwise False
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
_ = iter(x)
|
|
730
|
+
except Exception:
|
|
731
|
+
return False
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def is_empty(v):
|
|
736
|
+
"""
|
|
737
|
+
A common definition of "empty" used throughout the repo, particularly when loading
|
|
738
|
+
data from .csv files. "empty" includes None, '', and NaN.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
v (obj): the object to evaluate for emptiness
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
bool: True if [v] is None, '', or NaN, otherwise False
|
|
745
|
+
"""
|
|
746
|
+
if v is None:
|
|
747
|
+
return True
|
|
748
|
+
if isinstance(v,str) and v == '':
|
|
749
|
+
return True
|
|
750
|
+
if isinstance(v,float) and np.isnan(v):
|
|
751
|
+
return True
|
|
752
|
+
return False
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def to_bool(v):
|
|
756
|
+
"""
|
|
757
|
+
Convert an object to a bool with specific rules.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
v (object): The object to convert
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
bool or None:
|
|
764
|
+
- For strings: True if 'true' (case-insensitive), False if 'false', recursively applied if int-like
|
|
765
|
+
- For int/bytes: False if 0, True otherwise
|
|
766
|
+
- For bool: returns the bool as-is
|
|
767
|
+
- For other types: None
|
|
768
|
+
"""
|
|
769
|
+
|
|
770
|
+
if isinstance(v, bool):
|
|
771
|
+
return v
|
|
772
|
+
|
|
773
|
+
if isinstance(v, str):
|
|
774
|
+
|
|
775
|
+
try:
|
|
776
|
+
v = int(v)
|
|
777
|
+
return to_bool(v)
|
|
778
|
+
except Exception:
|
|
779
|
+
pass
|
|
780
|
+
|
|
781
|
+
v = v.lower().strip()
|
|
782
|
+
if v == 'true':
|
|
783
|
+
return True
|
|
784
|
+
elif v == 'false':
|
|
785
|
+
return False
|
|
786
|
+
else:
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
if isinstance(v, (int, bytes)):
|
|
790
|
+
return v != 0
|
|
791
|
+
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def min_none(a,b):
|
|
796
|
+
"""
|
|
797
|
+
Returns the minimum of a and b. If both are None, returns None. If one is None,
|
|
798
|
+
returns the other.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
a (numeric): the first value to compare
|
|
802
|
+
b (numeric): the second value to compare
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
numeric: the minimum of a and b, or None
|
|
806
|
+
"""
|
|
807
|
+
if a is None and b is None:
|
|
808
|
+
return None
|
|
809
|
+
elif a is None:
|
|
810
|
+
return b
|
|
811
|
+
elif b is None:
|
|
812
|
+
return a
|
|
813
|
+
else:
|
|
814
|
+
return min(a,b)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def max_none(a,b):
|
|
818
|
+
"""
|
|
819
|
+
Returns the maximum of a and b. If both are None, returns None. If one is None,
|
|
820
|
+
returns the other.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
a (numeric): the first value to compare
|
|
824
|
+
b (numeric): the second value to compare
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
numeric: the maximum of a and b, or None
|
|
828
|
+
"""
|
|
829
|
+
if a is None and b is None:
|
|
830
|
+
return None
|
|
831
|
+
elif a is None:
|
|
832
|
+
return b
|
|
833
|
+
elif b is None:
|
|
834
|
+
return a
|
|
835
|
+
else:
|
|
836
|
+
return max(a,b)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def isnan(v):
|
|
840
|
+
"""
|
|
841
|
+
Returns True if v is a nan-valued float, otherwise returns False.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
v (obj): the object to evaluate for nan-ness
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
bool: True if v is a nan-valued float, otherwise False
|
|
848
|
+
"""
|
|
849
|
+
|
|
850
|
+
try:
|
|
851
|
+
return np.isnan(v)
|
|
852
|
+
except Exception:
|
|
853
|
+
return False
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def compare_values_nan_equal(v0,v1):
|
|
857
|
+
"""
|
|
858
|
+
Utility function for comparing two values when we want to return True if both
|
|
859
|
+
values are NaN.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
v0 (object): the first value to compare
|
|
863
|
+
v1 (object): the second value to compare
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
bool: True if v0 == v1, or if both v0 and v1 are NaN
|
|
867
|
+
"""
|
|
868
|
+
|
|
869
|
+
if isinstance(v0,float) and isinstance(v1,float) and np.isnan(v0) and np.isnan(v1):
|
|
870
|
+
return True
|
|
871
|
+
return v0 == v1
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def sets_overlap(set1, set2):
|
|
875
|
+
"""
|
|
876
|
+
Determines whether two sets overlap.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
set1 (set): the first set to compare (converted to a set if it's not already)
|
|
880
|
+
set2 (set): the second set to compare (converted to a set if it's not already)
|
|
881
|
+
|
|
882
|
+
Returns:
|
|
883
|
+
bool: True if any elements are shared between set1 and set2
|
|
884
|
+
"""
|
|
885
|
+
|
|
886
|
+
return not set(set1).isdisjoint(set(set2))
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def is_function_name(s,calling_namespace):
|
|
890
|
+
"""
|
|
891
|
+
Determines whether [s] is a callable function in the global or local scope, or a
|
|
892
|
+
built-in function.
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
s (str): the string to test for function-ness
|
|
896
|
+
calling_namespace (dict): typically pass the output of locals()
|
|
897
|
+
"""
|
|
898
|
+
|
|
899
|
+
assert isinstance(s,str), 'Input is not a string'
|
|
900
|
+
|
|
901
|
+
return callable(globals().get(s)) or \
|
|
902
|
+
callable(locals().get(s)) or \
|
|
903
|
+
callable(calling_namespace.get(s)) or \
|
|
904
|
+
callable(getattr(builtins, s, None))
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# From https://gist.github.com/fralau/061a4f6c13251367ef1d9a9a99fb3e8d
|
|
908
|
+
def parse_kvp(s,kv_separator='='):
|
|
909
|
+
"""
|
|
910
|
+
Parse a key/value pair, separated by [kv_separator]. Errors if s is not
|
|
911
|
+
a valid key/value pair string. Strips leading/trailing whitespace from
|
|
912
|
+
the key and value.
|
|
913
|
+
|
|
914
|
+
Args:
|
|
915
|
+
s (str): the string to parse
|
|
916
|
+
kv_separator (str, optional): the string separating keys from values.
|
|
917
|
+
|
|
918
|
+
Returns:
|
|
919
|
+
tuple: a 2-tuple formatted as (key,value)
|
|
920
|
+
"""
|
|
921
|
+
|
|
922
|
+
items = s.split(kv_separator)
|
|
923
|
+
assert len(items) > 1, 'Illegal key-value pair'
|
|
924
|
+
key = items[0].strip()
|
|
925
|
+
if len(items) > 1:
|
|
926
|
+
value = kv_separator.join(items[1:]).strip()
|
|
927
|
+
return (key, value)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def parse_kvp_list(items,kv_separator='=',d=None):
|
|
931
|
+
"""
|
|
932
|
+
Parse a list key-value pairs into a dictionary. If items is None or [],
|
|
933
|
+
returns {}.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
items (list): the list of KVPs to parse
|
|
937
|
+
kv_separator (str, optional): the string separating keys from values.
|
|
938
|
+
d (dict, optional): the initial dictionary, defaults to {}
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
dict: a dict mapping keys to values
|
|
942
|
+
"""
|
|
943
|
+
|
|
944
|
+
if d is None:
|
|
945
|
+
d = {}
|
|
946
|
+
|
|
947
|
+
if items is None or len(items) == 0:
|
|
948
|
+
return d
|
|
949
|
+
|
|
950
|
+
for item in items:
|
|
951
|
+
key, value = parse_kvp(item,kv_separator=kv_separator)
|
|
952
|
+
d[key] = value
|
|
953
|
+
|
|
954
|
+
return d
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def dict_to_kvp_list(d,
|
|
958
|
+
item_separator=' ',
|
|
959
|
+
kv_separator='=',
|
|
960
|
+
non_string_value_handling='error'):
|
|
961
|
+
"""
|
|
962
|
+
Convert a string <--> string dict into a string containing list of list of
|
|
963
|
+
key-value pairs. I.e., converts {'a':'dog','b':'cat'} to 'a=dog b=cat'. If
|
|
964
|
+
d is None, returns None. If d is empty, returns ''.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
d (dict): the dictionary to convert, must contain only strings
|
|
968
|
+
item_separator (str, optional): the delimiter between KV pairs
|
|
969
|
+
kv_separator (str, optional): the separator betweena a key and its value
|
|
970
|
+
non_string_value_handling (str, optional): what do do with non-string values,
|
|
971
|
+
can be "omit", "error", or "convert"
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
str: the string representation of [d]
|
|
975
|
+
"""
|
|
976
|
+
|
|
977
|
+
if d is None:
|
|
978
|
+
return None
|
|
979
|
+
|
|
980
|
+
if len(d) == 0:
|
|
981
|
+
return ''
|
|
982
|
+
|
|
983
|
+
s = None
|
|
984
|
+
for k in d.keys():
|
|
985
|
+
assert isinstance(k,str), 'Input {} is not a str <--> str dict'.format(str(d))
|
|
986
|
+
v = d[k]
|
|
987
|
+
if not isinstance(v,str):
|
|
988
|
+
if non_string_value_handling == 'error':
|
|
989
|
+
raise ValueError('Input {} is not a str <--> str dict'.format(str(d)))
|
|
990
|
+
elif non_string_value_handling == 'omit':
|
|
991
|
+
continue
|
|
992
|
+
elif non_string_value_handling == 'convert':
|
|
993
|
+
v = str(v)
|
|
994
|
+
else:
|
|
995
|
+
raise ValueError('Unrecognized non_string_value_handling value: {}'.format(
|
|
996
|
+
non_string_value_handling))
|
|
997
|
+
if s is None:
|
|
998
|
+
s = ''
|
|
999
|
+
else:
|
|
1000
|
+
s += item_separator
|
|
1001
|
+
s += k + kv_separator + v
|
|
1002
|
+
|
|
1003
|
+
if s is None:
|
|
1004
|
+
s = ''
|
|
1005
|
+
|
|
1006
|
+
return s
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def parse_bool_string(s, strict=False):
|
|
1010
|
+
"""
|
|
1011
|
+
Convert the strings "true" or "false" to boolean values. Case-insensitive, discards
|
|
1012
|
+
leading and trailing whitespace. If s is already a bool, returns s.
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
s (str or bool): the string to parse, or the bool to return
|
|
1016
|
+
strict (bool, optional): only allow "true" or "false", otherwise
|
|
1017
|
+
handles "1", "0", "yes", and "no".
|
|
1018
|
+
|
|
1019
|
+
Returns:
|
|
1020
|
+
bool: the parsed value
|
|
1021
|
+
"""
|
|
1022
|
+
|
|
1023
|
+
if isinstance(s,bool):
|
|
1024
|
+
return s
|
|
1025
|
+
s = str(s).lower().strip()
|
|
1026
|
+
|
|
1027
|
+
if strict:
|
|
1028
|
+
# Fun fact: ('false') (rather than ('false,')) creates a string,
|
|
1029
|
+
# not a tuple.
|
|
1030
|
+
false_strings = ('false',)
|
|
1031
|
+
true_strings = ('true',)
|
|
1032
|
+
else:
|
|
1033
|
+
false_strings = ('no', 'false', 'f', 'n', '0')
|
|
1034
|
+
true_strings = ('yes', 'true', 't', 'y', '1')
|
|
1035
|
+
if s in true_strings:
|
|
1036
|
+
return True
|
|
1037
|
+
elif s in false_strings:
|
|
1038
|
+
return False
|
|
1039
|
+
else:
|
|
1040
|
+
raise ValueError('Cannot parse bool from string {}'.format(str(s)))
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def make_temp_folder(top_level_folder='megadetector',subfolder=None,append_guid=True):
|
|
1044
|
+
"""
|
|
1045
|
+
Creates a temporary folder within the system temp folder, by default in a subfolder
|
|
1046
|
+
called megadetector/some_guid. Used for testing without making too much of a mess.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
top_level_folder (str, optional): the top-level folder to use within the system temp folder
|
|
1050
|
+
subfolder (str, optional): the subfolder within [top_level_folder]
|
|
1051
|
+
append_guid (bool, optional): append a guid to the subfolder
|
|
1052
|
+
|
|
1053
|
+
Returns:
|
|
1054
|
+
str: the new directory
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
to_return = os.path.join(tempfile.gettempdir(),top_level_folder)
|
|
1058
|
+
if subfolder is not None:
|
|
1059
|
+
to_return = os.path.join(to_return,subfolder)
|
|
1060
|
+
if append_guid:
|
|
1061
|
+
to_return = os.path.join(to_return,str(uuid.uuid1()))
|
|
1062
|
+
to_return = os.path.normpath(to_return)
|
|
1063
|
+
os.makedirs(to_return,exist_ok=True)
|
|
1064
|
+
return to_return
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def make_test_folder(subfolder=None):
|
|
1068
|
+
"""
|
|
1069
|
+
Wrapper around make_temp_folder that creates folders within megadetector/tests
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
subfolder (str): specific subfolder to create within the default megadetector temp
|
|
1073
|
+
folder.
|
|
1074
|
+
"""
|
|
1075
|
+
|
|
1076
|
+
return make_temp_folder(top_level_folder='megadetector/tests',
|
|
1077
|
+
subfolder=subfolder,
|
|
1078
|
+
append_guid=True)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
#%% Environment utilities
|
|
1082
|
+
|
|
1083
|
+
def is_sphinx_build():
|
|
1084
|
+
"""
|
|
1085
|
+
Determine whether we are running in the context of our Sphinx build.
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
bool: True if we're running a Sphinx build
|
|
1089
|
+
"""
|
|
1090
|
+
|
|
1091
|
+
is_sphinx = hasattr(builtins, '__sphinx_build__')
|
|
1092
|
+
return is_sphinx
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def is_running_in_gha():
|
|
1096
|
+
"""
|
|
1097
|
+
Determine whether we are running on a GitHub Actions runner.
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
bool: True if we're running in a GHA runner
|
|
1101
|
+
"""
|
|
1102
|
+
|
|
1103
|
+
running_in_gha = False
|
|
1104
|
+
|
|
1105
|
+
if ('GITHUB_ACTIONS' in os.environ):
|
|
1106
|
+
# Documentation is inconsistent on how this variable presents itself
|
|
1107
|
+
if isinstance(os.environ['GITHUB_ACTIONS'],bool) and \
|
|
1108
|
+
os.environ['GITHUB_ACTIONS']:
|
|
1109
|
+
running_in_gha = True
|
|
1110
|
+
elif isinstance(os.environ['GITHUB_ACTIONS'],str) and \
|
|
1111
|
+
os.environ['GITHUB_ACTIONS'].lower() == ('true'):
|
|
1112
|
+
running_in_gha = True
|
|
1113
|
+
|
|
1114
|
+
return running_in_gha
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def environment_is_wsl():
|
|
1118
|
+
"""
|
|
1119
|
+
Determines whether we're running in WSL.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
True if we're running in WSL
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
if sys.platform not in ('linux','posix'):
|
|
1126
|
+
return False
|
|
1127
|
+
platform_string = ' '.join(platform.uname()).lower()
|
|
1128
|
+
return 'microsoft' in platform_string and 'wsl' in platform_string
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
#%% Tests
|
|
1133
|
+
|
|
1134
|
+
def test_write_json():
|
|
1135
|
+
"""
|
|
1136
|
+
Test driver for write_json.
|
|
1137
|
+
"""
|
|
1138
|
+
|
|
1139
|
+
temp_dir = make_test_folder()
|
|
1140
|
+
|
|
1141
|
+
def _verify_json_file(file_path, expected_content_str):
|
|
1142
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
1143
|
+
content = json.load(f)
|
|
1144
|
+
assert isinstance(content,dict)
|
|
1145
|
+
content = sort_dictionary_by_key(content)
|
|
1146
|
+
expected_content = json.loads(expected_content_str)
|
|
1147
|
+
expected_content = sort_dictionary_by_key(expected_content)
|
|
1148
|
+
assert content == expected_content, \
|
|
1149
|
+
f"File {file_path} content mismatch.\nExpected:\n{expected_content}\nGot:\n{content}"
|
|
1150
|
+
|
|
1151
|
+
# Test default indent (1)
|
|
1152
|
+
data_default = {'a': 1, 'b': 2}
|
|
1153
|
+
file_path_default = os.path.join(temp_dir, 'test_default_indent.json')
|
|
1154
|
+
write_json(file_path_default, data_default)
|
|
1155
|
+
# Default indent is 1
|
|
1156
|
+
_verify_json_file(file_path_default, '{\n "a": 1,\n "b": 2\n}')
|
|
1157
|
+
|
|
1158
|
+
# Test custom indent (e.g., 4)
|
|
1159
|
+
data_custom_indent = {'a': 1, 'b': 2}
|
|
1160
|
+
file_path_custom_indent = os.path.join(temp_dir, 'test_custom_indent.json')
|
|
1161
|
+
write_json(file_path_custom_indent, data_custom_indent, indent=4)
|
|
1162
|
+
_verify_json_file(file_path_custom_indent, '{\n "a": 1,\n "b": 2\n}')
|
|
1163
|
+
|
|
1164
|
+
# Test indent=None (compact)
|
|
1165
|
+
data_no_indent = {'a': 1, 'b': 2}
|
|
1166
|
+
file_path_no_indent = os.path.join(temp_dir, 'test_no_indent.json')
|
|
1167
|
+
write_json(file_path_no_indent, data_no_indent, indent=None)
|
|
1168
|
+
_verify_json_file(file_path_no_indent, '{"a": 1, "b": 2}')
|
|
1169
|
+
|
|
1170
|
+
# Test force_str=True
|
|
1171
|
+
data_force_str = {'a': 1, 's': {1, 2, 3}} # Set is not normally JSON serializable
|
|
1172
|
+
file_path_force_str = os.path.join(temp_dir, 'test_force_str.json')
|
|
1173
|
+
write_json(file_path_force_str, data_force_str, force_str=True)
|
|
1174
|
+
with open(file_path_force_str, 'r', encoding='utf-8') as f:
|
|
1175
|
+
result_force_str = json.load(f)
|
|
1176
|
+
assert isinstance(result_force_str['s'], str)
|
|
1177
|
+
assert eval(result_force_str['s']) == {1, 2, 3}
|
|
1178
|
+
|
|
1179
|
+
# Test serialize_datetimes=True
|
|
1180
|
+
dt = datetime.datetime(2023, 1, 1, 10, 30, 0)
|
|
1181
|
+
d_date = datetime.date(2023, 2, 15)
|
|
1182
|
+
data_serialize_datetimes = {'dt_obj': dt, 'd_obj': d_date}
|
|
1183
|
+
file_path_serialize_datetimes = os.path.join(temp_dir, 'test_serialize_datetimes.json')
|
|
1184
|
+
write_json(file_path_serialize_datetimes, data_serialize_datetimes, serialize_datetimes=True)
|
|
1185
|
+
_verify_json_file(file_path_serialize_datetimes, '{\n "d_obj": "2023-02-15",\n "dt_obj": "2023-01-01T10:30:00"\n}')
|
|
1186
|
+
|
|
1187
|
+
# Test serialize_datetimes=True and force_str=True
|
|
1188
|
+
dt_combo = datetime.datetime(2023, 1, 1, 12, 0, 0)
|
|
1189
|
+
data_datetime_force_str = {'dt_obj': dt_combo, 's_obj': {4, 5}}
|
|
1190
|
+
file_path_datetime_force_str = os.path.join(temp_dir, 'test_datetime_and_force_str.json')
|
|
1191
|
+
write_json(file_path_datetime_force_str, data_datetime_force_str, serialize_datetimes=True, force_str=True)
|
|
1192
|
+
with open(file_path_datetime_force_str, 'r', encoding='utf-8') as f:
|
|
1193
|
+
result_datetime_force_str = json.load(f)
|
|
1194
|
+
assert result_datetime_force_str['dt_obj'] == "2023-01-01T12:00:00"
|
|
1195
|
+
assert isinstance(result_datetime_force_str['s_obj'], str)
|
|
1196
|
+
assert eval(result_datetime_force_str['s_obj']) == {4, 5}
|
|
1197
|
+
|
|
1198
|
+
# Test ensure_ascii=False (with non-ASCII chars)
|
|
1199
|
+
data_ensure_ascii_false = {'name': 'Jules César'}
|
|
1200
|
+
file_path_ensure_ascii_false = os.path.join(temp_dir, 'test_ensure_ascii_false.json')
|
|
1201
|
+
write_json(file_path_ensure_ascii_false, data_ensure_ascii_false, ensure_ascii=False)
|
|
1202
|
+
with open(file_path_ensure_ascii_false, 'r', encoding='utf-8') as f:
|
|
1203
|
+
content_ensure_ascii_false = f.read()
|
|
1204
|
+
assert content_ensure_ascii_false == '{\n "name": "Jules César"\n}'
|
|
1205
|
+
|
|
1206
|
+
# Test ensure_ascii=True (with non-ASCII chars, default)
|
|
1207
|
+
data_ensure_ascii_true = {'name': 'Jules César'}
|
|
1208
|
+
file_path_ensure_ascii_true = os.path.join(temp_dir, 'test_ensure_ascii_true.json')
|
|
1209
|
+
write_json(file_path_ensure_ascii_true, data_ensure_ascii_true, ensure_ascii=True)
|
|
1210
|
+
with open(file_path_ensure_ascii_true, 'r', encoding='utf-8') as f:
|
|
1211
|
+
content_ensure_ascii_true = f.read()
|
|
1212
|
+
assert content_ensure_ascii_true == '{\n "name": "Jules C\\u00e9sar"\n}'
|
|
1213
|
+
|
|
1214
|
+
shutil.rmtree(temp_dir)
|
|
1215
|
+
|
|
1216
|
+
# ...def test_write_json(...)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def test_path_operations():
|
|
1220
|
+
"""
|
|
1221
|
+
Test path manipulation functions.
|
|
1222
|
+
"""
|
|
1223
|
+
|
|
1224
|
+
##%% Camera folder mapping
|
|
1225
|
+
assert image_file_to_camera_folder('a/b/c/d/100EK113/blah.jpg') == 'a/b/c/d'
|
|
1226
|
+
assert image_file_to_camera_folder('a/b/c/d/100RECNX/blah.jpg') == 'a/b/c/d'
|
|
1227
|
+
assert image_file_to_camera_folder('a/b/c/d/blah.jpg') == 'a/b/c/d'
|
|
1228
|
+
assert image_file_to_camera_folder(r'a\b\c\d\100RECNX\blah.jpg') == 'a/b/c/d'
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def test_geometric_operations():
|
|
1232
|
+
"""
|
|
1233
|
+
Test geometric calculations like distances.
|
|
1234
|
+
"""
|
|
1235
|
+
|
|
1236
|
+
##%% Test a few rectangle distances
|
|
1237
|
+
|
|
1238
|
+
r1 = [0,0,1,1]; r2 = [0,0,1,1]; assert rect_distance(r1,r2)==0
|
|
1239
|
+
r1 = [0,0,1,1]; r2 = [0,0,1,100]; assert rect_distance(r1,r2)==0
|
|
1240
|
+
r1 = [0,0,1,1]; r2 = [1,1,2,2]; assert rect_distance(r1,r2)==0
|
|
1241
|
+
r1 = [0,0,1,1]; r2 = [1.1,0,0,1.1]; assert abs(rect_distance(r1,r2)-.1) < 0.00001
|
|
1242
|
+
|
|
1243
|
+
r1 = [0.4,0.8,10,22]; r2 = [100, 101, 200, 210.4]; assert abs(rect_distance(r1,r2)-119.753) < 0.001
|
|
1244
|
+
r1 = [0.4,0.8,10,22]; r2 = [101, 101, 200, 210.4]; assert abs(rect_distance(r1,r2)-120.507) < 0.001
|
|
1245
|
+
r1 = [0.4,0.8,10,22]; r2 = [120, 120, 200, 210.4]; assert abs(rect_distance(r1,r2)-147.323) < 0.001
|
|
1246
|
+
|
|
1247
|
+
# Test with 'x0y0wh' format
|
|
1248
|
+
r1_wh = [0,0,1,1]; r2_wh = [1,0,1,1]; assert rect_distance(r1_wh, r2_wh, format='x0y0wh') == 0
|
|
1249
|
+
r1_wh = [0,0,1,1]; r2_wh = [1.5,0,1,1]; assert abs(rect_distance(r1_wh, r2_wh, format='x0y0wh') - 0.5) < 0.00001
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
##%% Test point_dist
|
|
1253
|
+
|
|
1254
|
+
assert point_dist((0,0), (3,4)) == 5.0
|
|
1255
|
+
assert point_dist((1,1), (1,1)) == 0.0
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def test_dictionary_operations():
|
|
1259
|
+
"""
|
|
1260
|
+
Test dictionary manipulation and sorting functions.
|
|
1261
|
+
"""
|
|
1262
|
+
|
|
1263
|
+
##%% Test sort_list_of_dicts_by_key
|
|
1264
|
+
|
|
1265
|
+
x = [{'a':5},{'a':0},{'a':10}]
|
|
1266
|
+
k = 'a'
|
|
1267
|
+
sorted_x = sort_list_of_dicts_by_key(x, k)
|
|
1268
|
+
assert sorted_x[0]['a'] == 0; assert sorted_x[1]['a'] == 5; assert sorted_x[2]['a'] == 10
|
|
1269
|
+
sorted_x_rev = sort_list_of_dicts_by_key(x, k, reverse=True)
|
|
1270
|
+
assert sorted_x_rev[0]['a'] == 10; assert sorted_x_rev[1]['a'] == 5; assert sorted_x_rev[2]['a'] == 0
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
##%% Test sort_dictionary_by_key
|
|
1274
|
+
|
|
1275
|
+
d_key = {'b': 2, 'a': 1, 'c': 3}
|
|
1276
|
+
sorted_d_key = sort_dictionary_by_key(d_key)
|
|
1277
|
+
assert list(sorted_d_key.keys()) == ['a', 'b', 'c']
|
|
1278
|
+
sorted_d_key_rev = sort_dictionary_by_key(d_key, reverse=True)
|
|
1279
|
+
assert list(sorted_d_key_rev.keys()) == ['c', 'b', 'a']
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
##%% Test sort_dictionary_by_value
|
|
1283
|
+
|
|
1284
|
+
d_val = {'a': 2, 'b': 1, 'c': 3}
|
|
1285
|
+
sorted_d_val = sort_dictionary_by_value(d_val)
|
|
1286
|
+
assert list(sorted_d_val.keys()) == ['b', 'a', 'c']
|
|
1287
|
+
sorted_d_val_rev = sort_dictionary_by_value(d_val, reverse=True)
|
|
1288
|
+
assert list(sorted_d_val_rev.keys()) == ['c', 'a', 'b']
|
|
1289
|
+
|
|
1290
|
+
# With sort_values
|
|
1291
|
+
sort_vals = {'a': 10, 'b': 0, 'c': 5}
|
|
1292
|
+
sorted_d_custom = sort_dictionary_by_value(d_val, sort_values=sort_vals)
|
|
1293
|
+
assert list(sorted_d_custom.keys()) == ['b', 'c', 'a']
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
##%% Test invert_dictionary
|
|
1297
|
+
|
|
1298
|
+
d_inv = {'a': 'x', 'b': 'y'}
|
|
1299
|
+
inverted_d = invert_dictionary(d_inv)
|
|
1300
|
+
assert inverted_d == {'x': 'a', 'y': 'b'}
|
|
1301
|
+
|
|
1302
|
+
# Does not check for uniqueness, last one wins
|
|
1303
|
+
d_inv_dup = {'a': 'x', 'b': 'x'}
|
|
1304
|
+
inverted_d_dup = invert_dictionary(d_inv_dup)
|
|
1305
|
+
assert inverted_d_dup == {'x': 'b'}
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def test_float_rounding_and_truncation():
|
|
1309
|
+
"""
|
|
1310
|
+
Test float rounding, truncation, and nested rounding functions.
|
|
1311
|
+
"""
|
|
1312
|
+
|
|
1313
|
+
##%% Test round_floats_in_nested_dict
|
|
1314
|
+
|
|
1315
|
+
data = {
|
|
1316
|
+
"name": "Project X",
|
|
1317
|
+
"values": [1.23456789, 2.3456789],
|
|
1318
|
+
"tuple_values": (3.45678901, 4.56789012),
|
|
1319
|
+
"set_values": {5.67890123, 6.78901234}, # Order not guaranteed in set, test min/max
|
|
1320
|
+
"metrics": {
|
|
1321
|
+
"score": 98.7654321,
|
|
1322
|
+
"components": [5.6789012, 6.7890123]
|
|
1323
|
+
},
|
|
1324
|
+
"other_iter": iter([7.89012345]) # Test other iterables
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
result = round_floats_in_nested_dict(data, decimal_places=5, allow_iterator_conversion=True)
|
|
1328
|
+
assert result['values'][0] == 1.23457
|
|
1329
|
+
assert result['tuple_values'][0] == 3.45679
|
|
1330
|
+
|
|
1331
|
+
# For sets, convert to list and sort for consistent testing
|
|
1332
|
+
assert sorted(list(result['set_values'])) == sorted([5.67890, 6.78901])
|
|
1333
|
+
assert result['metrics']['score'] == 98.76543
|
|
1334
|
+
|
|
1335
|
+
# Test other iterables by converting back to list
|
|
1336
|
+
assert list(result['other_iter'])[0] == 7.89012
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
##%% Test truncate_float_array and truncate_float
|
|
1340
|
+
|
|
1341
|
+
assert truncate_float_array([0.12345, 0.67890], precision=3) == [0.123, 0.678]
|
|
1342
|
+
assert truncate_float_array([1.0, 2.0], precision=2) == [1.0, 2.0]
|
|
1343
|
+
assert truncate_float(0.12345, precision=3) == 0.123
|
|
1344
|
+
assert truncate_float(1.999, precision=2) == 1.99
|
|
1345
|
+
assert truncate_float(0.0003214884, precision=6) == 0.000321
|
|
1346
|
+
assert truncate_float(1.0003214884, precision=6) == 1.000321
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
##%% Test round_float_array and round_float
|
|
1350
|
+
|
|
1351
|
+
assert round_float_array([0.12345, 0.67890], precision=3) == [0.123, 0.679]
|
|
1352
|
+
assert round_float_array([1.0, 2.0], precision=2) == [1.0, 2.0]
|
|
1353
|
+
assert round_float(0.12345, precision=3) == 0.123
|
|
1354
|
+
assert round_float(0.12378, precision=3) == 0.124
|
|
1355
|
+
assert round_float(1.999, precision=2) == 2.00
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def test_object_conversion_and_presentation():
|
|
1359
|
+
"""
|
|
1360
|
+
Test functions that convert or present objects.
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
##%% Test args_to_object
|
|
1364
|
+
|
|
1365
|
+
class ArgsObject:
|
|
1366
|
+
pass
|
|
1367
|
+
args_namespace = type('ArgsNameSpace', (), {'a': 1, 'b': 'test', '_c': 'ignored'})
|
|
1368
|
+
obj = ArgsObject()
|
|
1369
|
+
args_to_object(args_namespace, obj)
|
|
1370
|
+
assert obj.a == 1
|
|
1371
|
+
assert obj.b == 'test'
|
|
1372
|
+
assert not hasattr(obj, '_c')
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
##%% Test dict_to_object
|
|
1376
|
+
|
|
1377
|
+
class DictObject:
|
|
1378
|
+
pass
|
|
1379
|
+
d = {'a': 1, 'b': 'test', '_c': 'ignored'}
|
|
1380
|
+
obj = DictObject()
|
|
1381
|
+
dict_to_object(d, obj)
|
|
1382
|
+
assert obj.a == 1
|
|
1383
|
+
assert obj.b == 'test'
|
|
1384
|
+
assert not hasattr(obj, '_c')
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
##%% Test pretty_print_object
|
|
1388
|
+
|
|
1389
|
+
class PrettyPrintable:
|
|
1390
|
+
def __init__(self):
|
|
1391
|
+
self.a = 1
|
|
1392
|
+
self.b = "test"
|
|
1393
|
+
obj_to_print = PrettyPrintable()
|
|
1394
|
+
json_str = pretty_print_object(obj_to_print, b_print=False)
|
|
1395
|
+
|
|
1396
|
+
# Basic check for valid json and presence of attributes
|
|
1397
|
+
parsed_json = json.loads(json_str) # Relies on json.loads
|
|
1398
|
+
assert parsed_json['a'] == 1
|
|
1399
|
+
assert parsed_json['b'] == "test"
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def test_list_operations():
|
|
1403
|
+
"""
|
|
1404
|
+
Test list sorting and chunking functions.
|
|
1405
|
+
"""
|
|
1406
|
+
|
|
1407
|
+
##%% Test is_list_sorted
|
|
1408
|
+
|
|
1409
|
+
assert is_list_sorted([1, 2, 3])
|
|
1410
|
+
assert not is_list_sorted([1, 3, 2])
|
|
1411
|
+
assert is_list_sorted([3, 2, 1], reverse=True)
|
|
1412
|
+
assert not is_list_sorted([1, 2, 3], reverse=True)
|
|
1413
|
+
assert is_list_sorted([]) # Empty list is considered sorted
|
|
1414
|
+
assert is_list_sorted([1]) # Single element list is sorted
|
|
1415
|
+
assert is_list_sorted([1,1,1])
|
|
1416
|
+
assert is_list_sorted([1,1,1], reverse=True)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
##%% Test split_list_into_fixed_size_chunks
|
|
1420
|
+
|
|
1421
|
+
assert split_list_into_fixed_size_chunks([1,2,3,4,5,6], 2) == [[1,2],[3,4],[5,6]]
|
|
1422
|
+
assert split_list_into_fixed_size_chunks([1,2,3,4,5], 2) == [[1,2],[3,4],[5]]
|
|
1423
|
+
assert split_list_into_fixed_size_chunks([], 3) == []
|
|
1424
|
+
assert split_list_into_fixed_size_chunks([1,2,3], 5) == [[1,2,3]]
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
##%% Test split_list_into_n_chunks
|
|
1428
|
+
|
|
1429
|
+
# Greedy
|
|
1430
|
+
assert split_list_into_n_chunks([1,2,3,4,5,6], 3, chunk_strategy='greedy') == [[1,2],[3,4],[5,6]]
|
|
1431
|
+
assert split_list_into_n_chunks([1,2,3,4,5], 3, chunk_strategy='greedy') == [[1,2],[3,4],[5]]
|
|
1432
|
+
assert split_list_into_n_chunks([1,2,3,4,5,6,7], 3, chunk_strategy='greedy') == [[1,2,3],[4,5],[6,7]]
|
|
1433
|
+
assert split_list_into_n_chunks([], 3) == [[],[],[]]
|
|
1434
|
+
|
|
1435
|
+
# Balanced
|
|
1436
|
+
assert split_list_into_n_chunks([1,2,3,4,5,6], 3, chunk_strategy='balanced') == [[1,4],[2,5],[3,6]]
|
|
1437
|
+
assert split_list_into_n_chunks([1,2,3,4,5], 3, chunk_strategy='balanced') == [[1,4],[2,5],[3]]
|
|
1438
|
+
assert split_list_into_n_chunks([], 3, chunk_strategy='balanced') == [[],[],[]]
|
|
1439
|
+
try:
|
|
1440
|
+
split_list_into_n_chunks([1,2,3], 2, chunk_strategy='invalid')
|
|
1441
|
+
raise AssertionError("ValueError not raised for invalid chunk_strategy")
|
|
1442
|
+
except ValueError:
|
|
1443
|
+
pass
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def test_datetime_serialization():
|
|
1447
|
+
"""
|
|
1448
|
+
Test datetime serialization functions.
|
|
1449
|
+
"""
|
|
1450
|
+
|
|
1451
|
+
##%% Test json_serialize_datetime
|
|
1452
|
+
|
|
1453
|
+
now = datetime.datetime.now()
|
|
1454
|
+
today = datetime.date.today()
|
|
1455
|
+
assert json_serialize_datetime(now) == now.isoformat()
|
|
1456
|
+
assert json_serialize_datetime(today) == today.isoformat()
|
|
1457
|
+
try:
|
|
1458
|
+
json_serialize_datetime("not a datetime")
|
|
1459
|
+
raise AssertionError("TypeError not raised for non-datetime object")
|
|
1460
|
+
except TypeError:
|
|
1461
|
+
pass
|
|
1462
|
+
try:
|
|
1463
|
+
json_serialize_datetime(123)
|
|
1464
|
+
raise AssertionError("TypeError not raised for non-datetime object")
|
|
1465
|
+
except TypeError:
|
|
1466
|
+
pass
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def test_bounding_box_operations():
|
|
1470
|
+
"""
|
|
1471
|
+
Test bounding box conversion and IoU calculation.
|
|
1472
|
+
"""
|
|
1473
|
+
|
|
1474
|
+
##%% Test convert_yolo_to_xywh
|
|
1475
|
+
|
|
1476
|
+
# [x_center, y_center, w, h]
|
|
1477
|
+
yolo_box = [0.5, 0.5, 0.2, 0.2]
|
|
1478
|
+
# [x_min, y_min, width_of_box, height_of_box]
|
|
1479
|
+
expected_xywh = [0.4, 0.4, 0.2, 0.2]
|
|
1480
|
+
assert np.allclose(convert_yolo_to_xywh(yolo_box), expected_xywh)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
##%% Test convert_xywh_to_xyxy
|
|
1484
|
+
|
|
1485
|
+
# [x_min, y_min, width_of_box, height_of_box]
|
|
1486
|
+
xywh_box = [0.1, 0.1, 0.3, 0.3]
|
|
1487
|
+
# [x_min, y_min, x_max, y_max]
|
|
1488
|
+
expected_xyxy = [0.1, 0.1, 0.4, 0.4]
|
|
1489
|
+
assert np.allclose(convert_xywh_to_xyxy(xywh_box), expected_xyxy)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
##%% Test get_iou
|
|
1493
|
+
|
|
1494
|
+
bb1 = [0, 0, 0.5, 0.5] # x, y, w, h
|
|
1495
|
+
bb2 = [0.25, 0.25, 0.5, 0.5]
|
|
1496
|
+
assert abs(get_iou(bb1, bb2) - 0.142857) < 1e-5
|
|
1497
|
+
bb3 = [0, 0, 1, 1]
|
|
1498
|
+
bb4 = [0.5, 0.5, 1, 1]
|
|
1499
|
+
assert abs(get_iou(bb3, bb4) - (0.25 / 1.75)) < 1e-5
|
|
1500
|
+
bb5 = [0,0,1,1]
|
|
1501
|
+
bb6 = [1,1,1,1] # No overlap
|
|
1502
|
+
assert get_iou(bb5, bb6) == 0.0
|
|
1503
|
+
|
|
1504
|
+
# Test malformed boxes (should ideally raise error or handle gracefully based on spec, current impl asserts)
|
|
1505
|
+
bb_malformed1 = [0.6, 0.0, 0.5, 0.5] # x_min > x_max after conversion
|
|
1506
|
+
bb_ok = [0.0, 0.0, 0.5, 0.5]
|
|
1507
|
+
try:
|
|
1508
|
+
get_iou(bb_malformed1, bb_ok)
|
|
1509
|
+
# This assert False will only be reached if the expected AssertionError is not raised by get_iou
|
|
1510
|
+
# assert False, "AssertionError for malformed bounding box (x2 >= x1) not raised in get_iou"
|
|
1511
|
+
except AssertionError as e:
|
|
1512
|
+
assert 'Malformed bounding box' in str(e)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def test_detection_processing():
|
|
1516
|
+
"""
|
|
1517
|
+
Test functions related to processing detection results.
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
##%% Test _get_max_conf_from_detections and get_max_conf
|
|
1521
|
+
|
|
1522
|
+
detections1 = [{'conf': 0.8}, {'conf': 0.9}, {'conf': 0.75}]
|
|
1523
|
+
assert _get_max_conf_from_detections(detections1) == 0.9
|
|
1524
|
+
assert _get_max_conf_from_detections([]) == 0.0
|
|
1525
|
+
assert _get_max_conf_from_detections(None) == 0.0
|
|
1526
|
+
|
|
1527
|
+
im1 = {'detections': detections1}
|
|
1528
|
+
assert get_max_conf(im1) == 0.9
|
|
1529
|
+
im2 = {'detections': []}
|
|
1530
|
+
assert get_max_conf(im2) == 0.0
|
|
1531
|
+
im3 = {} # No 'detections' key
|
|
1532
|
+
assert get_max_conf(im3) == 0.0
|
|
1533
|
+
im4 = {'detections': None}
|
|
1534
|
+
assert get_max_conf(im4) == 0.0
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
##%% Test sort_results_for_image
|
|
1538
|
+
|
|
1539
|
+
img_data = {
|
|
1540
|
+
'detections': [
|
|
1541
|
+
{'conf': 0.7, 'classifications': [('c', 0.6), ('a', 0.9), ('b', 0.8)]},
|
|
1542
|
+
{'conf': 0.9, 'classifications': [('x', 0.95), ('y', 0.85)]},
|
|
1543
|
+
{'conf': 0.8} # No classifications field
|
|
1544
|
+
]
|
|
1545
|
+
}
|
|
1546
|
+
sort_results_for_image(img_data)
|
|
1547
|
+
|
|
1548
|
+
# Check detections sorted by conf
|
|
1549
|
+
assert img_data['detections'][0]['conf'] == 0.9
|
|
1550
|
+
assert img_data['detections'][1]['conf'] == 0.8
|
|
1551
|
+
assert img_data['detections'][2]['conf'] == 0.7
|
|
1552
|
+
|
|
1553
|
+
# Check classifications sorted by conf (only for the first original detection, now at index 0 after sort)
|
|
1554
|
+
assert img_data['detections'][0]['classifications'][0] == ('x', 0.95)
|
|
1555
|
+
assert img_data['detections'][0]['classifications'][1] == ('y', 0.85)
|
|
1556
|
+
|
|
1557
|
+
# Check classifications for the second original detection (now at index 2)
|
|
1558
|
+
assert img_data['detections'][2]['classifications'][0] == ('a', 0.9)
|
|
1559
|
+
assert img_data['detections'][2]['classifications'][1] == ('b', 0.8)
|
|
1560
|
+
assert img_data['detections'][2]['classifications'][2] == ('c', 0.6)
|
|
1561
|
+
|
|
1562
|
+
# Test with no detections or no classifications field
|
|
1563
|
+
img_data_no_det = {'detections': None}
|
|
1564
|
+
sort_results_for_image(img_data_no_det)
|
|
1565
|
+
assert img_data_no_det['detections'] is None
|
|
1566
|
+
|
|
1567
|
+
img_data_empty_det = {'detections': []}
|
|
1568
|
+
sort_results_for_image(img_data_empty_det)
|
|
1569
|
+
assert img_data_empty_det['detections'] == []
|
|
1570
|
+
|
|
1571
|
+
img_data_no_classifications_field = {'detections': [{'conf': 0.8}]}
|
|
1572
|
+
sort_results_for_image(img_data_no_classifications_field)
|
|
1573
|
+
assert 'classifications' not in img_data_no_classifications_field['detections'][0]
|
|
1574
|
+
|
|
1575
|
+
img_data_none_classifications = {'detections': [{'conf': 0.8, 'classifications':None}]}
|
|
1576
|
+
sort_results_for_image(img_data_none_classifications)
|
|
1577
|
+
assert img_data_none_classifications['detections'][0]['classifications'] is None
|
|
1578
|
+
|
|
1579
|
+
img_data_empty_classifications = {'detections': [{'conf': 0.8, 'classifications':[]}]}
|
|
1580
|
+
sort_results_for_image(img_data_empty_classifications)
|
|
1581
|
+
assert img_data_empty_classifications['detections'][0]['classifications'] == []
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def test_type_checking_and_validation():
|
|
1585
|
+
"""
|
|
1586
|
+
Test type checking and validation utility functions.
|
|
1587
|
+
"""
|
|
1588
|
+
|
|
1589
|
+
##%% Test is_float
|
|
1590
|
+
|
|
1591
|
+
assert is_float(1.23)
|
|
1592
|
+
assert is_float("1.23")
|
|
1593
|
+
assert is_float("-1.23")
|
|
1594
|
+
assert is_float(" 1.23 ")
|
|
1595
|
+
assert not is_float("abc")
|
|
1596
|
+
assert not is_float(None)
|
|
1597
|
+
assert is_float(1) # int is also a float (current behavior)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
##%% Test is_iterable
|
|
1601
|
+
|
|
1602
|
+
assert is_iterable([1,2,3])
|
|
1603
|
+
assert is_iterable("hello")
|
|
1604
|
+
assert is_iterable({'a':1})
|
|
1605
|
+
assert is_iterable(range(5))
|
|
1606
|
+
assert not is_iterable(123)
|
|
1607
|
+
assert not is_iterable(None)
|
|
1608
|
+
assert is_iterable(np.array([1,2]))
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
##%% Test is_empty
|
|
1612
|
+
|
|
1613
|
+
assert is_empty(None)
|
|
1614
|
+
assert is_empty("")
|
|
1615
|
+
assert is_empty(np.nan)
|
|
1616
|
+
assert not is_empty(0)
|
|
1617
|
+
assert not is_empty(" ")
|
|
1618
|
+
assert not is_empty([])
|
|
1619
|
+
assert not is_empty({})
|
|
1620
|
+
assert not is_empty(False) # False is not empty
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
##%% Test min_none and max_none
|
|
1624
|
+
|
|
1625
|
+
assert min_none(1, 2) == 1
|
|
1626
|
+
assert min_none(None, 2) == 2
|
|
1627
|
+
assert min_none(1, None) == 1
|
|
1628
|
+
assert min_none(None, None) is None
|
|
1629
|
+
assert max_none(1, 2) == 2
|
|
1630
|
+
assert max_none(None, 2) == 2
|
|
1631
|
+
assert max_none(1, None) == 1
|
|
1632
|
+
assert max_none(None, None) is None
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
##%% Test isnan
|
|
1636
|
+
|
|
1637
|
+
assert isnan(np.nan)
|
|
1638
|
+
assert not isnan(0.0)
|
|
1639
|
+
assert not isnan("text")
|
|
1640
|
+
assert not isnan(None)
|
|
1641
|
+
assert not isnan(float('inf'))
|
|
1642
|
+
assert not isnan(float('-inf'))
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
##%% Test sets_overlap
|
|
1646
|
+
|
|
1647
|
+
assert sets_overlap({1,2,3}, {3,4,5})
|
|
1648
|
+
assert not sets_overlap({1,2}, {3,4})
|
|
1649
|
+
assert sets_overlap([1,2,3], [3,4,5]) # Test with lists
|
|
1650
|
+
assert sets_overlap(set(), {1}) is False
|
|
1651
|
+
assert sets_overlap({1},{1})
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
##%% Test is_function_name
|
|
1655
|
+
|
|
1656
|
+
def _test_local_func(): pass
|
|
1657
|
+
assert is_function_name("is_float", locals()) # Test a function in ct_utils
|
|
1658
|
+
assert is_function_name("_test_local_func", locals()) # Test a local function
|
|
1659
|
+
assert is_function_name("print", locals()) # Test a builtin
|
|
1660
|
+
assert not is_function_name("non_existent_func", locals())
|
|
1661
|
+
global _test_global_func_ct_utils # Renamed to avoid conflict if run multiple times
|
|
1662
|
+
def _test_global_func_ct_utils(): pass
|
|
1663
|
+
assert is_function_name("_test_global_func_ct_utils", globals())
|
|
1664
|
+
# Clean up global
|
|
1665
|
+
del _test_global_func_ct_utils
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def test_string_parsing():
|
|
1669
|
+
"""
|
|
1670
|
+
Test string parsing utilities like KVP and boolean parsing.
|
|
1671
|
+
"""
|
|
1672
|
+
|
|
1673
|
+
##%% Test parse_kvp and parse_kvp_list
|
|
1674
|
+
|
|
1675
|
+
assert parse_kvp("key=value") == ("key", "value")
|
|
1676
|
+
assert parse_kvp("key = value with spaces") == ("key", "value with spaces")
|
|
1677
|
+
assert parse_kvp("key=value1=value2", kv_separator='=') == ("key", "value1=value2")
|
|
1678
|
+
try:
|
|
1679
|
+
parse_kvp("keyvalue")
|
|
1680
|
+
raise AssertionError("AssertionError not raised for invalid KVP")
|
|
1681
|
+
except AssertionError:
|
|
1682
|
+
pass
|
|
1683
|
+
|
|
1684
|
+
kvp_list = ["a=1", "b = 2", "c=foo=bar"]
|
|
1685
|
+
parsed_list = parse_kvp_list(kvp_list)
|
|
1686
|
+
assert parsed_list == {"a": "1", "b": "2", "c": "foo=bar"}
|
|
1687
|
+
assert parse_kvp_list(None) == {}
|
|
1688
|
+
assert parse_kvp_list([]) == {}
|
|
1689
|
+
d_initial = {'z': '0'}
|
|
1690
|
+
|
|
1691
|
+
# parse_kvp_list modifies d in place if provided
|
|
1692
|
+
parse_kvp_list(kvp_list, d=d_initial)
|
|
1693
|
+
assert d_initial == {"z": "0", "a": "1", "b": "2", "c": "foo=bar"}
|
|
1694
|
+
|
|
1695
|
+
# Test with a different separator
|
|
1696
|
+
assert parse_kvp("key:value", kv_separator=":") == ("key", "value")
|
|
1697
|
+
assert parse_kvp_list(["a:1","b:2"], kv_separator=":") == {"a":"1", "b":"2"}
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
##%% Test dict_to_kvp_list
|
|
1701
|
+
|
|
1702
|
+
d_kvp = {"a": "1", "b": "dog", "c": "foo=bar"}
|
|
1703
|
+
kvp_str = dict_to_kvp_list(d_kvp)
|
|
1704
|
+
|
|
1705
|
+
# Order isn't guaranteed, so check for presence of all items and length
|
|
1706
|
+
assert "a=1" in kvp_str
|
|
1707
|
+
assert "b=dog" in kvp_str
|
|
1708
|
+
assert "c=foo=bar" in kvp_str
|
|
1709
|
+
assert len(kvp_str.split(' ')) == 3
|
|
1710
|
+
|
|
1711
|
+
assert dict_to_kvp_list({}) == ""
|
|
1712
|
+
assert dict_to_kvp_list(None) is None
|
|
1713
|
+
d_kvp_int = {"a":1, "b":"text"}
|
|
1714
|
+
try:
|
|
1715
|
+
dict_to_kvp_list(d_kvp_int, non_string_value_handling='error')
|
|
1716
|
+
raise AssertionError("ValueError not raised for non-string value with 'error' handling")
|
|
1717
|
+
except ValueError:
|
|
1718
|
+
pass
|
|
1719
|
+
convert_result = dict_to_kvp_list(d_kvp_int, non_string_value_handling='convert')
|
|
1720
|
+
assert "a=1" in convert_result and "b=text" in convert_result
|
|
1721
|
+
|
|
1722
|
+
omit_result = dict_to_kvp_list({"a":1, "b":"text"}, non_string_value_handling='omit')
|
|
1723
|
+
assert "a=1" not in omit_result and "b=text" in omit_result
|
|
1724
|
+
assert omit_result == "b=text"
|
|
1725
|
+
|
|
1726
|
+
assert dict_to_kvp_list({"key":"val"}, item_separator="&", kv_separator=":") == "key:val"
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
##%% Test parse_bool_string
|
|
1730
|
+
|
|
1731
|
+
assert parse_bool_string("true")
|
|
1732
|
+
assert parse_bool_string("True")
|
|
1733
|
+
assert parse_bool_string(" TRUE ")
|
|
1734
|
+
assert not parse_bool_string("false")
|
|
1735
|
+
assert not parse_bool_string("False")
|
|
1736
|
+
assert not parse_bool_string(" FALSE ")
|
|
1737
|
+
assert parse_bool_string("1", strict=False)
|
|
1738
|
+
assert not parse_bool_string("0", strict=False)
|
|
1739
|
+
assert parse_bool_string(True) is True # Test with existing bool
|
|
1740
|
+
assert parse_bool_string(False) is False
|
|
1741
|
+
try:
|
|
1742
|
+
parse_bool_string("maybe")
|
|
1743
|
+
raise AssertionError("ValueError not raised for invalid bool string")
|
|
1744
|
+
except ValueError:
|
|
1745
|
+
pass
|
|
1746
|
+
try:
|
|
1747
|
+
parse_bool_string("1",strict=True)
|
|
1748
|
+
raise AssertionError("ValueError not raised for '1'")
|
|
1749
|
+
except ValueError:
|
|
1750
|
+
pass
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def test_temp_folder_creation():
|
|
1754
|
+
"""
|
|
1755
|
+
Test temporary folder creation and cleanup.
|
|
1756
|
+
"""
|
|
1757
|
+
|
|
1758
|
+
# Store original tempdir for restoration if modified by tests (though unlikely for make_temp_folder)
|
|
1759
|
+
original_tempdir = tempfile.gettempdir()
|
|
1760
|
+
|
|
1761
|
+
# Test make_temp_folder
|
|
1762
|
+
custom_top_level = "my_custom_temp_app_test" # Unique name for this test run
|
|
1763
|
+
custom_subfolder = "specific_test_run"
|
|
1764
|
+
|
|
1765
|
+
# Test with default subfolder (UUID)
|
|
1766
|
+
temp_folder1_base = os.path.join(tempfile.gettempdir(), custom_top_level)
|
|
1767
|
+
temp_folder1 = make_temp_folder(top_level_folder=custom_top_level)
|
|
1768
|
+
assert os.path.exists(temp_folder1)
|
|
1769
|
+
assert os.path.basename(os.path.dirname(temp_folder1)) == custom_top_level
|
|
1770
|
+
assert temp_folder1_base == os.path.dirname(temp_folder1) # Path up to UUID should match
|
|
1771
|
+
|
|
1772
|
+
# Cleanup: remove the custom_top_level which contains the UUID folder
|
|
1773
|
+
if os.path.exists(temp_folder1_base):
|
|
1774
|
+
shutil.rmtree(temp_folder1_base)
|
|
1775
|
+
assert not os.path.exists(temp_folder1_base)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
# Test with specified subfolder
|
|
1779
|
+
temp_folder2_base = os.path.join(tempfile.gettempdir(), custom_top_level)
|
|
1780
|
+
temp_folder2 = make_temp_folder(top_level_folder=custom_top_level,
|
|
1781
|
+
subfolder=custom_subfolder,
|
|
1782
|
+
append_guid=False)
|
|
1783
|
+
assert os.path.exists(temp_folder2)
|
|
1784
|
+
assert os.path.basename(temp_folder2) == custom_subfolder
|
|
1785
|
+
assert os.path.basename(os.path.dirname(temp_folder2)) == custom_top_level
|
|
1786
|
+
assert temp_folder2 == os.path.join(tempfile.gettempdir(), custom_top_level, custom_subfolder)
|
|
1787
|
+
|
|
1788
|
+
# Cleanup
|
|
1789
|
+
if os.path.exists(temp_folder2_base):
|
|
1790
|
+
shutil.rmtree(temp_folder2_base)
|
|
1791
|
+
assert not os.path.exists(temp_folder2_base)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
# Test make_test_folder (which uses 'megadetector/tests' as top_level)
|
|
1795
|
+
#
|
|
1796
|
+
# This will create tempfile.gettempdir()/megadetector/tests/some_uuid or specified_subfolder
|
|
1797
|
+
megadetector_temp_base = os.path.join(tempfile.gettempdir(), "megadetector")
|
|
1798
|
+
test_subfolder = "my_specific_module_test"
|
|
1799
|
+
|
|
1800
|
+
# Test with default subfolder for make_test_folder
|
|
1801
|
+
test_folder1 = make_test_folder() # Creates megadetector/tests/uuid_folder
|
|
1802
|
+
assert os.path.exists(test_folder1)
|
|
1803
|
+
assert os.path.basename(os.path.dirname(test_folder1)) == "tests"
|
|
1804
|
+
assert os.path.basename(os.path.dirname(os.path.dirname(test_folder1))) == "megadetector"
|
|
1805
|
+
|
|
1806
|
+
# Cleanup for make_test_folder default: remove the 'megadetector' base temp dir
|
|
1807
|
+
if os.path.exists(megadetector_temp_base):
|
|
1808
|
+
shutil.rmtree(megadetector_temp_base)
|
|
1809
|
+
assert not os.path.exists(megadetector_temp_base)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
# Test with specified subfolder for make_test_folder
|
|
1813
|
+
test_folder2 = make_test_folder(subfolder=test_subfolder) # megadetector/tests/my_specific_module_test
|
|
1814
|
+
assert os.path.exists(test_folder2)
|
|
1815
|
+
assert test_subfolder in test_folder2
|
|
1816
|
+
assert "megadetector" in test_folder2
|
|
1817
|
+
|
|
1818
|
+
# Cleanup for make_test_folder specific: remove the 'megadetector' base temp dir
|
|
1819
|
+
if os.path.exists(megadetector_temp_base):
|
|
1820
|
+
shutil.rmtree(megadetector_temp_base)
|
|
1821
|
+
assert not os.path.exists(megadetector_temp_base)
|
|
1822
|
+
|
|
1823
|
+
# Verify cleanup if top level folder was 'megadetector' (default for make_temp_folder)
|
|
1824
|
+
#
|
|
1825
|
+
# This means it creates tempfile.gettempdir()/megadetector/uuid_folder
|
|
1826
|
+
default_temp_folder = make_temp_folder()
|
|
1827
|
+
assert os.path.exists(default_temp_folder)
|
|
1828
|
+
assert os.path.basename(os.path.dirname(default_temp_folder)) == "megadetector"
|
|
1829
|
+
|
|
1830
|
+
# Cleanup: remove the 'megadetector' base temp dir created by default make_temp_folder
|
|
1831
|
+
if os.path.exists(megadetector_temp_base):
|
|
1832
|
+
shutil.rmtree(megadetector_temp_base)
|
|
1833
|
+
assert not os.path.exists(megadetector_temp_base)
|
|
1834
|
+
|
|
1835
|
+
# Restore original tempdir if it was changed (though not expected for these functions)
|
|
1836
|
+
tempfile.tempdir = original_tempdir
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def run_all_module_tests():
|
|
1840
|
+
"""
|
|
1841
|
+
Run all tests in the ct_utils module. This is not invoked by pytest; this is
|
|
1842
|
+
just a convenience wrapper for debugging the tests.
|
|
1843
|
+
"""
|
|
1844
|
+
|
|
1845
|
+
test_write_json()
|
|
1846
|
+
test_path_operations()
|
|
1847
|
+
test_geometric_operations()
|
|
1848
|
+
test_dictionary_operations()
|
|
1849
|
+
test_float_rounding_and_truncation()
|
|
1850
|
+
test_object_conversion_and_presentation()
|
|
1851
|
+
test_list_operations()
|
|
1852
|
+
test_datetime_serialization()
|
|
1853
|
+
test_bounding_box_operations()
|
|
1854
|
+
test_detection_processing()
|
|
1855
|
+
test_type_checking_and_validation()
|
|
1856
|
+
test_string_parsing()
|
|
1857
|
+
test_temp_folder_creation()
|