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