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.

Files changed (147) hide show
  1. megadetector/__init__.py +0 -0
  2. megadetector/api/__init__.py +0 -0
  3. megadetector/api/batch_processing/integration/digiKam/setup.py +6 -0
  4. megadetector/api/batch_processing/integration/digiKam/xmp_integration.py +465 -0
  5. megadetector/api/batch_processing/integration/eMammal/test_scripts/config_template.py +5 -0
  6. megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +125 -0
  7. megadetector/api/batch_processing/integration/eMammal/test_scripts/select_images_for_testing.py +55 -0
  8. megadetector/classification/__init__.py +0 -0
  9. megadetector/classification/aggregate_classifier_probs.py +108 -0
  10. megadetector/classification/analyze_failed_images.py +227 -0
  11. megadetector/classification/cache_batchapi_outputs.py +198 -0
  12. megadetector/classification/create_classification_dataset.py +626 -0
  13. megadetector/classification/crop_detections.py +516 -0
  14. megadetector/classification/csv_to_json.py +226 -0
  15. megadetector/classification/detect_and_crop.py +853 -0
  16. megadetector/classification/efficientnet/__init__.py +9 -0
  17. megadetector/classification/efficientnet/model.py +415 -0
  18. megadetector/classification/efficientnet/utils.py +608 -0
  19. megadetector/classification/evaluate_model.py +520 -0
  20. megadetector/classification/identify_mislabeled_candidates.py +152 -0
  21. megadetector/classification/json_to_azcopy_list.py +63 -0
  22. megadetector/classification/json_validator.py +696 -0
  23. megadetector/classification/map_classification_categories.py +276 -0
  24. megadetector/classification/merge_classification_detection_output.py +509 -0
  25. megadetector/classification/prepare_classification_script.py +194 -0
  26. megadetector/classification/prepare_classification_script_mc.py +228 -0
  27. megadetector/classification/run_classifier.py +287 -0
  28. megadetector/classification/save_mislabeled.py +110 -0
  29. megadetector/classification/train_classifier.py +827 -0
  30. megadetector/classification/train_classifier_tf.py +725 -0
  31. megadetector/classification/train_utils.py +323 -0
  32. megadetector/data_management/__init__.py +0 -0
  33. megadetector/data_management/animl_to_md.py +161 -0
  34. megadetector/data_management/annotations/__init__.py +0 -0
  35. megadetector/data_management/annotations/annotation_constants.py +33 -0
  36. megadetector/data_management/camtrap_dp_to_coco.py +270 -0
  37. megadetector/data_management/cct_json_utils.py +566 -0
  38. megadetector/data_management/cct_to_md.py +184 -0
  39. megadetector/data_management/cct_to_wi.py +293 -0
  40. megadetector/data_management/coco_to_labelme.py +284 -0
  41. megadetector/data_management/coco_to_yolo.py +702 -0
  42. megadetector/data_management/databases/__init__.py +0 -0
  43. megadetector/data_management/databases/add_width_and_height_to_db.py +107 -0
  44. megadetector/data_management/databases/combine_coco_camera_traps_files.py +210 -0
  45. megadetector/data_management/databases/integrity_check_json_db.py +528 -0
  46. megadetector/data_management/databases/subset_json_db.py +195 -0
  47. megadetector/data_management/generate_crops_from_cct.py +200 -0
  48. megadetector/data_management/get_image_sizes.py +164 -0
  49. megadetector/data_management/labelme_to_coco.py +559 -0
  50. megadetector/data_management/labelme_to_yolo.py +349 -0
  51. megadetector/data_management/lila/__init__.py +0 -0
  52. megadetector/data_management/lila/create_lila_blank_set.py +556 -0
  53. megadetector/data_management/lila/create_lila_test_set.py +187 -0
  54. megadetector/data_management/lila/create_links_to_md_results_files.py +106 -0
  55. megadetector/data_management/lila/download_lila_subset.py +182 -0
  56. megadetector/data_management/lila/generate_lila_per_image_labels.py +777 -0
  57. megadetector/data_management/lila/get_lila_annotation_counts.py +174 -0
  58. megadetector/data_management/lila/get_lila_image_counts.py +112 -0
  59. megadetector/data_management/lila/lila_common.py +319 -0
  60. megadetector/data_management/lila/test_lila_metadata_urls.py +164 -0
  61. megadetector/data_management/mewc_to_md.py +344 -0
  62. megadetector/data_management/ocr_tools.py +873 -0
  63. megadetector/data_management/read_exif.py +964 -0
  64. megadetector/data_management/remap_coco_categories.py +195 -0
  65. megadetector/data_management/remove_exif.py +156 -0
  66. megadetector/data_management/rename_images.py +194 -0
  67. megadetector/data_management/resize_coco_dataset.py +663 -0
  68. megadetector/data_management/speciesnet_to_md.py +41 -0
  69. megadetector/data_management/wi_download_csv_to_coco.py +247 -0
  70. megadetector/data_management/yolo_output_to_md_output.py +594 -0
  71. megadetector/data_management/yolo_to_coco.py +876 -0
  72. megadetector/data_management/zamba_to_md.py +188 -0
  73. megadetector/detection/__init__.py +0 -0
  74. megadetector/detection/change_detection.py +840 -0
  75. megadetector/detection/process_video.py +479 -0
  76. megadetector/detection/pytorch_detector.py +1451 -0
  77. megadetector/detection/run_detector.py +1267 -0
  78. megadetector/detection/run_detector_batch.py +2159 -0
  79. megadetector/detection/run_inference_with_yolov5_val.py +1314 -0
  80. megadetector/detection/run_md_and_speciesnet.py +1494 -0
  81. megadetector/detection/run_tiled_inference.py +1038 -0
  82. megadetector/detection/tf_detector.py +209 -0
  83. megadetector/detection/video_utils.py +1379 -0
  84. megadetector/postprocessing/__init__.py +0 -0
  85. megadetector/postprocessing/add_max_conf.py +72 -0
  86. megadetector/postprocessing/categorize_detections_by_size.py +166 -0
  87. megadetector/postprocessing/classification_postprocessing.py +1752 -0
  88. megadetector/postprocessing/combine_batch_outputs.py +249 -0
  89. megadetector/postprocessing/compare_batch_results.py +2110 -0
  90. megadetector/postprocessing/convert_output_format.py +403 -0
  91. megadetector/postprocessing/create_crop_folder.py +629 -0
  92. megadetector/postprocessing/detector_calibration.py +570 -0
  93. megadetector/postprocessing/generate_csv_report.py +522 -0
  94. megadetector/postprocessing/load_api_results.py +223 -0
  95. megadetector/postprocessing/md_to_coco.py +428 -0
  96. megadetector/postprocessing/md_to_labelme.py +351 -0
  97. megadetector/postprocessing/md_to_wi.py +41 -0
  98. megadetector/postprocessing/merge_detections.py +392 -0
  99. megadetector/postprocessing/postprocess_batch_results.py +2077 -0
  100. megadetector/postprocessing/remap_detection_categories.py +226 -0
  101. megadetector/postprocessing/render_detection_confusion_matrix.py +677 -0
  102. megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py +206 -0
  103. megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +82 -0
  104. megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +1665 -0
  105. megadetector/postprocessing/separate_detections_into_folders.py +795 -0
  106. megadetector/postprocessing/subset_json_detector_output.py +964 -0
  107. megadetector/postprocessing/top_folders_to_bottom.py +238 -0
  108. megadetector/postprocessing/validate_batch_results.py +332 -0
  109. megadetector/taxonomy_mapping/__init__.py +0 -0
  110. megadetector/taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +491 -0
  111. megadetector/taxonomy_mapping/map_new_lila_datasets.py +213 -0
  112. megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +165 -0
  113. megadetector/taxonomy_mapping/preview_lila_taxonomy.py +543 -0
  114. megadetector/taxonomy_mapping/retrieve_sample_image.py +71 -0
  115. megadetector/taxonomy_mapping/simple_image_download.py +224 -0
  116. megadetector/taxonomy_mapping/species_lookup.py +1008 -0
  117. megadetector/taxonomy_mapping/taxonomy_csv_checker.py +159 -0
  118. megadetector/taxonomy_mapping/taxonomy_graph.py +346 -0
  119. megadetector/taxonomy_mapping/validate_lila_category_mappings.py +83 -0
  120. megadetector/tests/__init__.py +0 -0
  121. megadetector/tests/test_nms_synthetic.py +335 -0
  122. megadetector/utils/__init__.py +0 -0
  123. megadetector/utils/ct_utils.py +1857 -0
  124. megadetector/utils/directory_listing.py +199 -0
  125. megadetector/utils/extract_frames_from_video.py +307 -0
  126. megadetector/utils/gpu_test.py +125 -0
  127. megadetector/utils/md_tests.py +2072 -0
  128. megadetector/utils/path_utils.py +2832 -0
  129. megadetector/utils/process_utils.py +172 -0
  130. megadetector/utils/split_locations_into_train_val.py +237 -0
  131. megadetector/utils/string_utils.py +234 -0
  132. megadetector/utils/url_utils.py +825 -0
  133. megadetector/utils/wi_platform_utils.py +968 -0
  134. megadetector/utils/wi_taxonomy_utils.py +1759 -0
  135. megadetector/utils/write_html_image_list.py +239 -0
  136. megadetector/visualization/__init__.py +0 -0
  137. megadetector/visualization/plot_utils.py +309 -0
  138. megadetector/visualization/render_images_with_thumbnails.py +243 -0
  139. megadetector/visualization/visualization_utils.py +1940 -0
  140. megadetector/visualization/visualize_db.py +630 -0
  141. megadetector/visualization/visualize_detector_output.py +479 -0
  142. megadetector/visualization/visualize_video_output.py +705 -0
  143. megadetector-10.0.13.dist-info/METADATA +134 -0
  144. megadetector-10.0.13.dist-info/RECORD +147 -0
  145. megadetector-10.0.13.dist-info/WHEEL +5 -0
  146. megadetector-10.0.13.dist-info/licenses/LICENSE +19 -0
  147. 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()