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