megadetector 5.0.27__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 +232 -223
  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 +341 -338
  65. megadetector/detection/pytorch_detector.py +308 -266
  66. megadetector/detection/run_detector.py +186 -166
  67. megadetector/detection/run_detector_batch.py +366 -364
  68. megadetector/detection/run_inference_with_yolov5_val.py +328 -325
  69. megadetector/detection/run_tiled_inference.py +312 -253
  70. megadetector/detection/tf_detector.py +24 -24
  71. megadetector/detection/video_utils.py +291 -283
  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 +808 -311
  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 +220 -147
  79. megadetector/postprocessing/detector_calibration.py +173 -168
  80. megadetector/postprocessing/generate_csv_report.py +508 -0
  81. megadetector/postprocessing/load_api_results.py +25 -22
  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 +319 -302
  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 -69
  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 +11 -11
  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 +1019 -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 +1511 -406
  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 +73 -60
  115. megadetector/utils/string_utils.py +147 -26
  116. megadetector/utils/url_utils.py +463 -173
  117. megadetector/utils/wi_utils.py +2629 -2868
  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 +424 -404
  122. megadetector/visualization/visualize_db.py +197 -190
  123. megadetector/visualization/visualize_detector_output.py +126 -98
  124. {megadetector-5.0.27.dist-info → megadetector-5.0.29.dist-info}/METADATA +6 -3
  125. megadetector-5.0.29.dist-info/RECORD +163 -0
  126. {megadetector-5.0.27.dist-info → megadetector-5.0.29.dist-info}/WHEEL +1 -1
  127. megadetector/data_management/importers/add_nacti_sizes.py +0 -52
  128. megadetector/data_management/importers/add_timestamps_to_icct.py +0 -79
  129. megadetector/data_management/importers/animl_results_to_md_results.py +0 -158
  130. megadetector/data_management/importers/auckland_doc_test_to_json.py +0 -373
  131. megadetector/data_management/importers/auckland_doc_to_json.py +0 -201
  132. megadetector/data_management/importers/awc_to_json.py +0 -191
  133. megadetector/data_management/importers/bellevue_to_json.py +0 -272
  134. megadetector/data_management/importers/cacophony-thermal-importer.py +0 -793
  135. megadetector/data_management/importers/carrizo_shrubfree_2018.py +0 -269
  136. megadetector/data_management/importers/carrizo_trail_cam_2017.py +0 -289
  137. megadetector/data_management/importers/cct_field_adjustments.py +0 -58
  138. megadetector/data_management/importers/channel_islands_to_cct.py +0 -913
  139. megadetector/data_management/importers/eMammal/copy_and_unzip_emammal.py +0 -180
  140. megadetector/data_management/importers/eMammal/eMammal_helpers.py +0 -249
  141. megadetector/data_management/importers/eMammal/make_eMammal_json.py +0 -223
  142. megadetector/data_management/importers/ena24_to_json.py +0 -276
  143. megadetector/data_management/importers/filenames_to_json.py +0 -386
  144. megadetector/data_management/importers/helena_to_cct.py +0 -283
  145. megadetector/data_management/importers/idaho-camera-traps.py +0 -1407
  146. megadetector/data_management/importers/idfg_iwildcam_lila_prep.py +0 -294
  147. megadetector/data_management/importers/import_desert_lion_conservation_camera_traps.py +0 -387
  148. megadetector/data_management/importers/jb_csv_to_json.py +0 -150
  149. megadetector/data_management/importers/mcgill_to_json.py +0 -250
  150. megadetector/data_management/importers/missouri_to_json.py +0 -490
  151. megadetector/data_management/importers/nacti_fieldname_adjustments.py +0 -79
  152. megadetector/data_management/importers/noaa_seals_2019.py +0 -181
  153. megadetector/data_management/importers/osu-small-animals-to-json.py +0 -364
  154. megadetector/data_management/importers/pc_to_json.py +0 -365
  155. megadetector/data_management/importers/plot_wni_giraffes.py +0 -123
  156. megadetector/data_management/importers/prepare_zsl_imerit.py +0 -131
  157. megadetector/data_management/importers/raic_csv_to_md_results.py +0 -416
  158. megadetector/data_management/importers/rspb_to_json.py +0 -356
  159. megadetector/data_management/importers/save_the_elephants_survey_A.py +0 -320
  160. megadetector/data_management/importers/save_the_elephants_survey_B.py +0 -329
  161. megadetector/data_management/importers/snapshot_safari_importer.py +0 -758
  162. megadetector/data_management/importers/snapshot_serengeti_lila.py +0 -1067
  163. megadetector/data_management/importers/snapshotserengeti/make_full_SS_json.py +0 -150
  164. megadetector/data_management/importers/snapshotserengeti/make_per_season_SS_json.py +0 -153
  165. megadetector/data_management/importers/sulross_get_exif.py +0 -65
  166. megadetector/data_management/importers/timelapse_csv_set_to_json.py +0 -490
  167. megadetector/data_management/importers/ubc_to_json.py +0 -399
  168. megadetector/data_management/importers/umn_to_json.py +0 -507
  169. megadetector/data_management/importers/wellington_to_json.py +0 -263
  170. megadetector/data_management/importers/wi_to_json.py +0 -442
  171. megadetector/data_management/importers/zamba_results_to_md_results.py +0 -180
  172. megadetector/data_management/lila/add_locations_to_island_camera_traps.py +0 -101
  173. megadetector/data_management/lila/add_locations_to_nacti.py +0 -151
  174. megadetector-5.0.27.dist-info/RECORD +0 -208
  175. {megadetector-5.0.27.dist-info → megadetector-5.0.29.dist-info}/licenses/LICENSE +0 -0
  176. {megadetector-5.0.27.dist-info → megadetector-5.0.29.dist-info}/top_level.txt +0 -0
@@ -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') 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,16 +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 (rather than None) if there was a failure or 'detections' isn't present.
303
-
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
348
+ present.
349
+
304
350
  Args:
305
351
  im (dict): image dictionary in the MD output format (with a 'detections' field)
306
-
352
+
307
353
  Returns:
308
354
  float: the maximum detection confidence across all classes
309
355
  """
310
-
356
+
311
357
  max_conf = 0.0
312
358
  if 'detections' in im and im['detections'] is not None and len(im['detections']) > 0:
313
359
  max_conf = _get_max_conf_from_detections(im['detections'])
@@ -317,7 +363,7 @@ def get_max_conf(im):
317
363
  def sort_results_for_image(im):
318
364
  """
319
365
  Sort classification and detection results in descending order by confidence (in place).
320
-
366
+
321
367
  Args:
322
368
  im (dict): image dictionary in the MD output format (with a 'detections' field)
323
369
  """
@@ -326,55 +372,56 @@ def sort_results_for_image(im):
326
372
 
327
373
  # Sort detections in descending order by confidence
328
374
  im['detections'] = sort_list_of_dicts_by_key(im['detections'],k='conf',reverse=True)
329
-
375
+
330
376
  for det in im['detections']:
331
-
377
+
332
378
  # Sort classifications (which are (class,conf) tuples) in descending order by confidence
333
379
  if 'classifications' in det and \
334
380
  (det['classifications'] is not None) and \
335
381
  (len(det['classifications']) > 0):
336
- L = det['classifications']
337
- det['classifications'] = sorted(L,key=itemgetter(1),reverse=True)
382
+ classifications = det['classifications']
383
+ det['classifications'] = \
384
+ sorted(classifications,key=itemgetter(1),reverse=True)
338
385
 
339
386
 
340
387
  def point_dist(p1,p2):
341
388
  """
342
389
  Computes the distance between two points, represented as length-two tuples.
343
-
390
+
344
391
  Args:
345
392
  p1: point, formatted as (x,y)
346
393
  p2: point, formatted as (x,y)
347
-
394
+
348
395
  Returns:
349
396
  float: the Euclidean distance between p1 and p2
350
397
  """
351
-
398
+
352
399
  return math.sqrt( ((p1[0]-p2[0])**2) + ((p1[1]-p2[1])**2) )
353
400
 
354
401
 
355
402
  def rect_distance(r1, r2, format='x0y0x1y1'):
356
403
  """
357
- 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
358
405
  (x0,y0,x1,y1) by default.
359
-
406
+
360
407
  Can also specify "format" as x0y0wh for MD-style bbox formatting (x0,y0,w,h).
361
-
408
+
362
409
  Args:
363
410
  r1: rectangle, formatted as (x0,y0,x1,y1) or (x0,y0,xy,y1)
364
411
  r2: rectangle, formatted as (x0,y0,x1,y1) or (x0,y0,xy,y1)
365
412
  format (str, optional): whether the boxes are formatted as 'x0y0x1y1' (default) or 'x0y0wh'
366
-
413
+
367
414
  Returns:
368
415
  float: the minimum distance between r1 and r2
369
416
  """
370
-
417
+
371
418
  assert format in ('x0y0x1y1','x0y0wh'), 'Illegal rectangle format {}'.format(format)
372
-
419
+
373
420
  if format == 'x0y0wh':
374
421
  # Convert to x0y0x1y1 without modifying the original rectangles
375
422
  r1 = [r1[0],r1[1],r1[0]+r1[2],r1[1]+r1[3]]
376
423
  r2 = [r2[0],r2[1],r2[0]+r2[2],r2[1]+r2[3]]
377
-
424
+
378
425
  # https://stackoverflow.com/a/26178015
379
426
  x1, y1, x1b, y1b = r1
380
427
  x2, y2, x2b, y2b = r2
@@ -402,40 +449,40 @@ def rect_distance(r1, r2, format='x0y0x1y1'):
402
449
  return 0.0
403
450
 
404
451
 
405
- def split_list_into_fixed_size_chunks(L,n):
452
+ def split_list_into_fixed_size_chunks(L,n): # noqa
406
453
  """
407
- 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
408
455
  less than N, i.e. len(L) does not have to be a multiple of n).
409
-
456
+
410
457
  Args:
411
458
  L (list): list to split into chunks
412
459
  n (int): preferred chunk size
413
-
460
+
414
461
  Returns:
415
462
  list: list of chunks, where each chunk is a list of length n or n-1
416
463
  """
417
-
464
+
418
465
  return [L[i * n:(i + 1) * n] for i in range((len(L) + n - 1) // n )]
419
466
 
420
467
 
421
- def split_list_into_n_chunks(L, n, chunk_strategy='greedy'):
468
+ def split_list_into_n_chunks(L, n, chunk_strategy='greedy'): # noqa
422
469
  """
423
- 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
424
471
  element smaller than others, i.e. len(L) does not have to be a multiple of n).
425
-
472
+
426
473
  chunk_strategy can be "greedy" (default, if there are k samples per chunk, the first
427
474
  k go into the first chunk) or "balanced" (alternate between chunks when pulling
428
475
  items from the list).
429
-
476
+
430
477
  Args:
431
478
  L (list): list to split into chunks
432
479
  n (int): number of chunks
433
480
  chunk_strategy (str, optiopnal): "greedy" or "balanced"; see above
434
-
481
+
435
482
  Returns:
436
483
  list: list of chunks, each of which is a list
437
484
  """
438
-
485
+
439
486
  if chunk_strategy == 'greedy':
440
487
  k, m = divmod(len(L), n)
441
488
  return list(L[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(n))
@@ -449,54 +496,54 @@ def split_list_into_n_chunks(L, n, chunk_strategy='greedy'):
449
496
  raise ValueError('Invalid chunk strategy: {}'.format(chunk_strategy))
450
497
 
451
498
 
452
- def sort_list_of_dicts_by_key(L,k,reverse=False):
499
+ def sort_list_of_dicts_by_key(L,k,reverse=False): # noqa
453
500
  """
454
501
  Sorts the list of dictionaries [L] by the key [k].
455
-
502
+
456
503
  Args:
457
504
  L (list): list of dictionaries to sort
458
505
  k (object, typically str): the sort key
459
506
  reverse (bool, optional): whether to sort in reverse (descending) order
460
-
507
+
461
508
  Returns:
462
509
  dict: sorted copy of [d]
463
510
  """
464
511
  return sorted(L, key=lambda d: d[k], reverse=reverse)
465
-
466
-
512
+
513
+
467
514
  def sort_dictionary_by_key(d,reverse=False):
468
515
  """
469
516
  Sorts the dictionary [d] by key.
470
-
517
+
471
518
  Args:
472
519
  d (dict): dictionary to sort
473
520
  reverse (bool, optional): whether to sort in reverse (descending) order
474
-
521
+
475
522
  Returns:
476
523
  dict: sorted copy of [d]
477
524
  """
478
-
525
+
479
526
  d = dict(sorted(d.items(),reverse=reverse))
480
527
  return d
481
-
528
+
482
529
 
483
530
  def sort_dictionary_by_value(d,sort_values=None,reverse=False):
484
531
  """
485
532
  Sorts the dictionary [d] by value. If sort_values is None, uses d.values(),
486
- otherwise uses the dictionary sort_values as the sorting criterion. Always
487
- 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
488
535
  returned value is not.
489
-
536
+
490
537
  Args:
491
538
  d (dict): dictionary to sort
492
- 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
493
540
  to None, uses [d] itself for sorting)
494
541
  reverse (bool, optional): whether to sort in reverse (descending) order
495
-
542
+
496
543
  Returns:
497
544
  dict: sorted copy of [d
498
545
  """
499
-
546
+
500
547
  if sort_values is None:
501
548
  d = {k: v for k, v in sorted(d.items(), key=lambda item: item[1], reverse=reverse)}
502
549
  else:
@@ -508,111 +555,133 @@ def invert_dictionary(d):
508
555
  """
509
556
  Creates a new dictionary that maps d.values() to d.keys(). Does not check
510
557
  uniqueness.
511
-
558
+
512
559
  Args:
513
560
  d (dict): dictionary to invert
514
-
561
+
515
562
  Returns:
516
563
  dict: inverted copy of [d]
517
564
  """
518
-
565
+
519
566
  return {v: k for k, v in d.items()}
520
567
 
521
568
 
522
- 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):
523
570
  """
524
- Recursively rounds all floating point values in a nested structure to the
525
- 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,
526
573
  sets, and other iterables. Modifies mutable objects in place.
527
-
574
+
528
575
  Args:
529
576
  obj: The object to process (can be a dict, list, set, tuple, or primitive value)
530
- decimal_places: Number of decimal places to round to (default: 5)
531
-
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
+
532
581
  Returns:
533
582
  The processed object (useful for recursive calls)
534
583
  """
535
584
  if isinstance(obj, dict):
536
585
  for key in obj:
537
- 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)
538
588
  return obj
539
-
589
+
540
590
  elif isinstance(obj, list):
541
591
  for i in range(len(obj)):
542
- 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)
543
594
  return obj
544
-
595
+
545
596
  elif isinstance(obj, tuple):
546
597
  # Tuples are immutable, so we create a new one
547
- return tuple(round_floats_in_nested_dict(item, decimal_places) for item in obj)
548
-
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
+
549
601
  elif isinstance(obj, set):
550
602
  # Sets are mutable but we can't modify elements in-place
551
603
  # Convert to list, process, and convert back to set
552
- return set(round_floats_in_nested_dict(list(obj), decimal_places))
553
-
604
+ return set(round_floats_in_nested_dict(list(obj), decimal_places=decimal_places,
605
+ allow_iterator_conversion=allow_iterator_conversion))
606
+
554
607
  elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)):
555
- # Handle other iterable types - convert to list, process, and convert back
556
- return type(obj)(round_floats_in_nested_dict(item, decimal_places) for item in obj)
557
-
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
+
558
624
  elif isinstance(obj, float):
559
625
  return round(obj, decimal_places)
560
-
626
+
561
627
  else:
562
628
  # For other types (int, str, bool, None, etc.), return as is
563
629
  return obj
564
630
 
565
- # ...def round_floats_in_nested_dict(...)
631
+ # ...def round_floats_in_nested_dict(...)
566
632
 
567
633
 
568
634
  def image_file_to_camera_folder(image_fn):
569
635
  r"""
570
636
  Removes common overflow folders (e.g. RECNX101, RECNX102) from paths, i.e. turn:
571
-
637
+
572
638
  a\b\c\RECNX101\image001.jpg
573
-
639
+
574
640
  ...into:
575
-
641
+
576
642
  a\b\c
577
643
 
578
- 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
579
645
  present.
580
646
 
581
647
  Always converts backslashes to slashes.
582
-
648
+
583
649
  Args:
584
650
  image_fn (str): the image filename from which we should remove overflow folders
585
-
651
+
586
652
  Returns:
587
653
  str: a version of [image_fn] from which camera overflow folders have been removed
588
654
  """
589
-
655
+
590
656
  import re
591
-
657
+
592
658
  # 100RECNX is the overflow folder style for Reconyx cameras
593
659
  # 100EK113 is (for some reason) the overflow folder style for Bushnell cameras
594
660
  # 100_BTCF is the overflow folder style for Browning cameras
595
661
  # 100MEDIA is the overflow folder style used on a number of consumer-grade cameras
596
662
  patterns = [r'/\d+RECNX/',r'/\d+EK\d+/',r'/\d+_BTCF/',r'/\d+MEDIA/']
597
-
598
- image_fn = image_fn.replace('\\','/')
663
+
664
+ image_fn = image_fn.replace('\\','/')
599
665
  for pat in patterns:
600
666
  image_fn = re.sub(pat,'/',image_fn)
601
667
  camera_folder = os.path.dirname(image_fn)
602
-
668
+
603
669
  return camera_folder
604
-
670
+
605
671
 
606
672
  def is_float(v):
607
673
  """
608
674
  Determines whether v is either a float or a string representation of a float.
609
-
675
+
610
676
  Args:
611
677
  v (object): object to evaluate
612
-
678
+
613
679
  Returns:
614
680
  bool: True if [v] is a float or a string representation of a float, otherwise False
615
681
  """
682
+
683
+ if v is None:
684
+ return False
616
685
 
617
686
  try:
618
687
  _ = float(v)
@@ -624,17 +693,17 @@ def is_float(v):
624
693
  def is_iterable(x):
625
694
  """
626
695
  Uses duck typing to assess whether [x] is iterable (list, set, dict, etc.).
627
-
696
+
628
697
  Args:
629
698
  x (object): the object to test
630
-
699
+
631
700
  Returns:
632
701
  bool: True if [x] appears to be iterable, otherwise False
633
702
  """
634
-
703
+
635
704
  try:
636
705
  _ = iter(x)
637
- except:
706
+ except Exception:
638
707
  return False
639
708
  return True
640
709
 
@@ -643,10 +712,10 @@ def is_empty(v):
643
712
  """
644
713
  A common definition of "empty" used throughout the repo, particularly when loading
645
714
  data from .csv files. "empty" includes None, '', and NaN.
646
-
715
+
647
716
  Args:
648
717
  v: the object to evaluate for emptiness
649
-
718
+
650
719
  Returns:
651
720
  bool: True if [v] is None, '', or NaN, otherwise False
652
721
  """
@@ -659,15 +728,55 @@ def is_empty(v):
659
728
  return False
660
729
 
661
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
+
662
771
  def min_none(a,b):
663
772
  """
664
- 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,
665
774
  returns the other.
666
-
775
+
667
776
  Args:
668
777
  a (numeric): the first value to compare
669
778
  b (numeric): the second value to compare
670
-
779
+
671
780
  Returns:
672
781
  numeric: the minimum of a and b, or None
673
782
  """
@@ -679,17 +788,17 @@ def min_none(a,b):
679
788
  return a
680
789
  else:
681
790
  return min(a,b)
682
-
791
+
683
792
 
684
793
  def max_none(a,b):
685
794
  """
686
- 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,
687
796
  returns the other.
688
-
797
+
689
798
  Args:
690
799
  a (numeric): the first value to compare
691
800
  b (numeric): the second value to compare
692
-
801
+
693
802
  Returns:
694
803
  numeric: the maximum of a and b, or None
695
804
  """
@@ -702,19 +811,19 @@ def max_none(a,b):
702
811
  else:
703
812
  return max(a,b)
704
813
 
705
-
814
+
706
815
  def isnan(v):
707
816
  """
708
817
  Returns True if v is a nan-valued float, otherwise returns False.
709
-
818
+
710
819
  Args:
711
820
  v: the object to evaluate for nan-ness
712
-
821
+
713
822
  Returns:
714
823
  bool: True if v is a nan-valued float, otherwise False
715
824
  """
716
-
717
- try:
825
+
826
+ try:
718
827
  return np.isnan(v)
719
828
  except Exception:
720
829
  return False
@@ -723,55 +832,56 @@ def isnan(v):
723
832
  def sets_overlap(set1, set2):
724
833
  """
725
834
  Determines whether two sets overlap.
726
-
835
+
727
836
  Args:
728
837
  set1 (set): the first set to compare (converted to a set if it's not already)
729
838
  set2 (set): the second set to compare (converted to a set if it's not already)
730
-
839
+
731
840
  Returns:
732
841
  bool: True if any elements are shared between set1 and set2
733
842
  """
734
-
843
+
735
844
  return not set(set1).isdisjoint(set(set2))
736
845
 
737
846
 
738
847
  def is_function_name(s,calling_namespace):
739
848
  """
740
- 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
741
850
  built-in function.
742
-
851
+
743
852
  Args:
744
853
  s (str): the string to test for function-ness
745
854
  calling_namespace (dict): typically pass the output of locals()
746
855
  """
747
-
856
+
748
857
  assert isinstance(s,str), 'Input is not a string'
749
-
858
+
750
859
  return callable(globals().get(s)) or \
751
860
  callable(locals().get(s)) or \
752
861
  callable(calling_namespace.get(s)) or \
753
862
  callable(getattr(builtins, s, None))
754
863
 
755
-
864
+
756
865
  # From https://gist.github.com/fralau/061a4f6c13251367ef1d9a9a99fb3e8d
757
866
  def parse_kvp(s,kv_separator='='):
758
867
  """
759
868
  Parse a key/value pair, separated by [kv_separator]. Errors if s is not
760
- a valid key/value pair string.
761
-
869
+ a valid key/value pair string. Strips leading/trailing whitespace from
870
+ the key and value.
871
+
762
872
  Args:
763
873
  s (str): the string to parse
764
874
  kv_separator (str, optional): the string separating keys from values.
765
-
875
+
766
876
  Returns:
767
877
  tuple: a 2-tuple formatted as (key,value)
768
878
  """
769
-
879
+
770
880
  items = s.split(kv_separator)
771
881
  assert len(items) > 1, 'Illegal key-value pair'
772
882
  key = items[0].strip()
773
883
  if len(items) > 1:
774
- value = kv_separator.join(items[1:])
884
+ value = kv_separator.join(items[1:]).strip()
775
885
  return (key, value)
776
886
 
777
887
 
@@ -779,26 +889,26 @@ def parse_kvp_list(items,kv_separator='=',d=None):
779
889
  """
780
890
  Parse a list key-value pairs into a dictionary. If items is None or [],
781
891
  returns {}.
782
-
892
+
783
893
  Args:
784
894
  items (list): the list of KVPs to parse
785
895
  kv_separator (str, optional): the string separating keys from values.
786
896
  d (dict, optional): the initial dictionary, defaults to {}
787
-
897
+
788
898
  Returns:
789
899
  dict: a dict mapping keys to values
790
900
  """
791
-
901
+
792
902
  if d is None:
793
903
  d = {}
794
904
 
795
905
  if items is None or len(items) == 0:
796
906
  return d
797
-
907
+
798
908
  for item in items:
799
- key, value = parse_kvp(item)
909
+ key, value = parse_kvp(item,kv_separator=kv_separator)
800
910
  d[key] = value
801
-
911
+
802
912
  return d
803
913
 
804
914
 
@@ -810,24 +920,24 @@ def dict_to_kvp_list(d,
810
920
  Convert a string <--> string dict into a string containing list of list of
811
921
  key-value pairs. I.e., converts {'a':'dog','b':'cat'} to 'a=dog b=cat'. If
812
922
  d is None, returns None. If d is empty, returns ''.
813
-
923
+
814
924
  Args:
815
925
  d (dict): the dictionary to convert, must contain only strings
816
926
  item_separator (str, optional): the delimiter between KV pairs
817
927
  kv_separator (str, optional): the separator betweena a key and its value
818
928
  non_string_value_handling (str, optional): what do do with non-string values,
819
929
  can be "omit", "error", or "convert"
820
-
930
+
821
931
  Returns:
822
932
  str: the string representation of [d]
823
933
  """
824
-
934
+
825
935
  if d is None:
826
936
  return None
827
-
937
+
828
938
  if len(d) == 0:
829
939
  return ''
830
-
940
+
831
941
  s = None
832
942
  for k in d.keys():
833
943
  assert isinstance(k,str), 'Input {} is not a str <--> str dict'.format(str(d))
@@ -847,25 +957,25 @@ def dict_to_kvp_list(d,
847
957
  else:
848
958
  s += item_separator
849
959
  s += k + kv_separator + v
850
-
960
+
851
961
  if s is None:
852
962
  s = ''
853
-
963
+
854
964
  return s
855
-
965
+
856
966
 
857
967
  def parse_bool_string(s):
858
968
  """
859
969
  Convert the strings "true" or "false" to boolean values. Case-insensitive, discards
860
970
  leading and trailing whitespace. If s is already a bool, returns s.
861
-
971
+
862
972
  Args:
863
973
  s (str or bool): the string to parse, or the bool to return
864
-
974
+
865
975
  Returns:
866
976
  bool: the parsed value
867
977
  """
868
-
978
+
869
979
  if isinstance(s,bool):
870
980
  return s
871
981
  s = s.lower().strip()
@@ -875,57 +985,766 @@ def parse_bool_string(s):
875
985
  return False
876
986
  else:
877
987
  raise ValueError('Cannot parse bool from string {}'.format(str(s)))
878
-
879
988
 
880
- #%% Test driver
881
989
 
882
- def __module_test__():
990
+ def make_temp_folder(top_level_folder='megadetector',subfolder=None,append_guid=True):
883
991
  """
884
- Module test driver
885
- """
886
-
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
+
887
1116
  ##%% Camera folder mapping
888
-
889
- 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'
890
1118
  assert image_file_to_camera_folder('a/b/c/d/100RECNX/blah.jpg') == 'a/b/c/d'
891
-
892
-
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
+
893
1128
  ##%% Test a few rectangle distances
894
-
1129
+
895
1130
  r1 = [0,0,1,1]; r2 = [0,0,1,1]; assert rect_distance(r1,r2)==0
896
1131
  r1 = [0,0,1,1]; r2 = [0,0,1,100]; assert rect_distance(r1,r2)==0
897
1132
  r1 = [0,0,1,1]; r2 = [1,1,2,2]; assert rect_distance(r1,r2)==0
898
1133
  r1 = [0,0,1,1]; r2 = [1.1,0,0,1.1]; assert abs(rect_distance(r1,r2)-.1) < 0.00001
899
-
1134
+
900
1135
  r1 = [0.4,0.8,10,22]; r2 = [100, 101, 200, 210.4]; assert abs(rect_distance(r1,r2)-119.753) < 0.001
901
- 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
902
1137
  r1 = [0.4,0.8,10,22]; r2 = [120, 120, 200, 210.4]; assert abs(rect_distance(r1,r2)-147.323) < 0.001
903
-
904
1138
 
905
- ##%% Test dictionary sorting
906
-
907
- 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}]
908
1158
  k = 'a'
909
- 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
910
1163
 
911
1164
 
912
- ##%% Test float rounding
913
-
914
- # 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
+
915
1207
  data = {
916
1208
  "name": "Project X",
917
1209
  "values": [1.23456789, 2.3456789],
918
1210
  "tuple_values": (3.45678901, 4.56789012),
919
- "set_values": {5.67890123, 6.78901234},
1211
+ "set_values": {5.67890123, 6.78901234}, # Order not guaranteed in set, test min/max
920
1212
  "metrics": {
921
1213
  "score": 98.7654321,
922
1214
  "components": [5.6789012, 6.7890123]
923
- }
1215
+ },
1216
+ "other_iter": iter([7.89012345]) # Test other iterables
924
1217
  }
925
-
926
- result = round_floats_in_nested_dict(data)
1218
+
1219
+ result = round_floats_in_nested_dict(data, decimal_places=5, allow_iterator_conversion=True)
927
1220
  assert result['values'][0] == 1.23457
928
1221
  assert result['tuple_values'][0] == 3.45679
929
- assert min(list(result['set_values'])) == 5.6789
930
-
931
-
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()