megadetector 10.0.4__py3-none-any.whl → 10.0.5__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.

@@ -0,0 +1,1711 @@
1
+ """
2
+
3
+ wi_taxonomy_utils.py
4
+
5
+ Functions related to working with the SpeciesNet / Wildlife Insights taxonomy.
6
+
7
+ """
8
+
9
+ #%% Imports and constants
10
+
11
+ import os
12
+ import json
13
+ import tempfile
14
+ import uuid
15
+
16
+ import pandas as pd
17
+
18
+ from copy import deepcopy
19
+ from collections import defaultdict
20
+ from tqdm import tqdm
21
+
22
+ from megadetector.utils.path_utils import \
23
+ insert_before_extension, find_images
24
+
25
+ from megadetector.utils.ct_utils import (
26
+ split_list_into_n_chunks,
27
+ round_floats_in_nested_dict,
28
+ is_list_sorted,
29
+ invert_dictionary,
30
+ sort_list_of_dicts_by_key,
31
+ sort_dictionary_by_value,
32
+ )
33
+
34
+ from megadetector.postprocessing.validate_batch_results import \
35
+ validate_batch_results, ValidateBatchResultsOptions
36
+
37
+ from megadetector.detection.run_detector import DEFAULT_DETECTOR_LABEL_MAP
38
+
39
+ md_category_id_to_name = DEFAULT_DETECTOR_LABEL_MAP
40
+ md_category_name_to_id = invert_dictionary(md_category_id_to_name)
41
+
42
+ blank_prediction_string = \
43
+ 'f1856211-cfb7-4a5b-9158-c0f72fd09ee6;;;;;;blank'
44
+ no_cv_result_prediction_string = \
45
+ 'f2efdae9-efb8-48fb-8a91-eccf79ab4ffb;no cv result;no cv result;no cv result;no cv result;no cv result;no cv result'
46
+ animal_prediction_string = \
47
+ '1f689929-883d-4dae-958c-3d57ab5b6c16;;;;;;animal'
48
+ human_prediction_string = \
49
+ '990ae9dd-7a59-4344-afcb-1b7b21368000;mammalia;primates;hominidae;homo;sapiens;human'
50
+ vehicle_prediction_string = \
51
+ 'e2895ed5-780b-48f6-8a11-9e27cb594511;;;;;;vehicle'
52
+
53
+ non_taxonomic_prediction_strings = [blank_prediction_string,
54
+ no_cv_result_prediction_string,
55
+ animal_prediction_string,
56
+ vehicle_prediction_string]
57
+
58
+ non_taxonomic_prediction_short_strings = [';'.join(s.split(';')[1:-1]) for s in \
59
+ non_taxonomic_prediction_strings]
60
+
61
+ # Ignore some files when generating instances.json from a folder
62
+ default_tokens_to_ignore = ['$RECYCLE.BIN']
63
+
64
+
65
+ #%% Miscellaneous taxonomy support functions
66
+
67
+ def is_valid_prediction_string(s):
68
+ """
69
+ Determine whether [s] is a valid WI prediction string. Prediction strings look like:
70
+
71
+ '90d950db-2106-4bd9-a4c1-777604c3eada;mammalia;rodentia;;;;rodent'
72
+
73
+ Args:
74
+ s (str): the string to be tested for validity
75
+
76
+ Returns:
77
+ bool: True if this looks more or less like a WI prediction string
78
+ """
79
+
80
+ # Note to self... don't get tempted to remove spaces here; spaces are used
81
+ # to indicate subspecies.
82
+ return isinstance(s,str) and (len(s.split(';')) == 7) and (s == s.lower())
83
+
84
+
85
+ def is_valid_taxonomy_string(s):
86
+ """
87
+ Determine whether [s] is a valid 5-token WI taxonomy string. Taxonomy strings
88
+ look like:
89
+
90
+ 'mammalia;rodentia;;;;rodent'
91
+ 'mammalia;chordata;canidae;canis;lupus dingo'
92
+
93
+ Args:
94
+ s (str): the string to be tested for validity
95
+
96
+ Returns:
97
+ bool: True if this looks more or less like a WI taxonomy string
98
+ """
99
+ return isinstance(s,str) and (len(s.split(';')) == 5) and (s == s.lower())
100
+
101
+
102
+ def clean_taxonomy_string(s):
103
+ """
104
+ If [s] is a seven-token prediction string, trim the GUID and common name to produce
105
+ a "clean" taxonomy string. Else if [s] is a five-token string, return it. Else error.
106
+
107
+ Args:
108
+ s (str): the seven- or five-token taxonomy/prediction string to clean
109
+
110
+ Returns:
111
+ str: the five-token taxonomy string
112
+ """
113
+
114
+ if is_valid_taxonomy_string(s):
115
+ return s
116
+ elif is_valid_prediction_string(s):
117
+ tokens = s.split(';')
118
+ assert len(tokens) == 7
119
+ return ';'.join(tokens[1:-1])
120
+ else:
121
+ raise ValueError('Invalid taxonomy string')
122
+
123
+
124
+ taxonomy_level_names = \
125
+ ['non-taxonomic','kingdom','phylum','class','order','family','genus','species','subspecies']
126
+
127
+
128
+ def taxonomy_level_to_string(k):
129
+ """
130
+ Maps taxonomy level indices (0 for kindgom, 1 for phylum, etc.) to strings.
131
+
132
+ Args:
133
+ k (int): taxonomy level index
134
+
135
+ Returns:
136
+ str: taxonomy level string
137
+ """
138
+
139
+ assert k >= 0 and k < len(taxonomy_level_names), \
140
+ 'Illegal taxonomy level index {}'.format(k)
141
+
142
+ return taxonomy_level_names[k]
143
+
144
+
145
+ def taxonomy_level_string_to_index(s):
146
+ """
147
+ Maps strings ('kingdom', 'species', etc.) to level indices.
148
+
149
+ Args:
150
+ s (str): taxonomy level string
151
+
152
+ Returns:
153
+ int: taxonomy level index
154
+ """
155
+
156
+ assert s in taxonomy_level_names, 'Unrecognized taxonomy level string {}'.format(s)
157
+ return taxonomy_level_names.index(s)
158
+
159
+
160
+ def taxonomy_level_index(s):
161
+ """
162
+ Returns the taxonomy level up to which [s] is defined (0 for non-taxnomic, 1 for kingdom,
163
+ 2 for phylum, etc. Empty strings and non-taxonomic strings are treated as level 0. 1 and 2
164
+ will never be returned; "animal" doesn't look like other taxonomic strings, so here we treat
165
+ it as non-taxonomic.
166
+
167
+ Args:
168
+ s (str): 5-token or 7-token taxonomy string
169
+
170
+ Returns:
171
+ int: taxonomy level
172
+ """
173
+
174
+ if s in non_taxonomic_prediction_strings or s in non_taxonomic_prediction_short_strings:
175
+ return 0
176
+
177
+ tokens = s.split(';')
178
+ assert len(tokens) in (5,7)
179
+
180
+ if len(tokens) == 7:
181
+ tokens = tokens[1:-1]
182
+
183
+ if len(tokens[0]) == 0:
184
+ return 0
185
+ # WI taxonomy strings start at class, so we'll never return 1 (kingdom) or 2 (phylum)
186
+ elif len(tokens[1]) == 0:
187
+ return 3
188
+ elif len(tokens[2]) == 0:
189
+ return 4
190
+ elif len(tokens[3]) == 0:
191
+ return 5
192
+ elif len(tokens[4]) == 0:
193
+ return 6
194
+ # Subspecies are delimited with a space
195
+ elif ' ' not in tokens[4]:
196
+ return 7
197
+ else:
198
+ return 8
199
+
200
+
201
+ def get_kingdom(prediction_string):
202
+ """
203
+ Return the kingdom field from a WI prediction string
204
+
205
+ Args:
206
+ prediction_string (str): a string in the semicolon-delimited prediction string format
207
+
208
+ Returns:
209
+ str: the kingdom field from the input string
210
+ """
211
+ tokens = prediction_string.split(';')
212
+ assert is_valid_prediction_string(prediction_string)
213
+ return tokens[1]
214
+
215
+
216
+ def is_human_classification(prediction_string):
217
+ """
218
+ Determines whether the input string represents a human classification, which includes a variety
219
+ of common names (hiker, person, etc.)
220
+
221
+ Args:
222
+ prediction_string (str): a string in the semicolon-delimited prediction string format
223
+
224
+ Returns:
225
+ bool: whether this string corresponds to a human category
226
+ """
227
+ return prediction_string == human_prediction_string or 'homo;sapiens' in prediction_string
228
+
229
+
230
+ def is_vehicle_classification(prediction_string):
231
+ """
232
+ Determines whether the input string represents a vehicle classification.
233
+
234
+ Args:
235
+ prediction_string (str): a string in the semicolon-delimited prediction string format
236
+
237
+ Returns:
238
+ bool: whether this string corresponds to the vehicle category
239
+ """
240
+ return prediction_string == vehicle_prediction_string
241
+
242
+
243
+ def is_animal_classification(prediction_string):
244
+ """
245
+ Determines whether the input string represents an animal classification, which excludes, e.g.,
246
+ humans, blanks, vehicles, unknowns
247
+
248
+ Args:
249
+ prediction_string (str): a string in the semicolon-delimited prediction string format
250
+
251
+ Returns:
252
+ bool: whether this string corresponds to an animal category
253
+ """
254
+
255
+ if prediction_string == animal_prediction_string:
256
+ return True
257
+ if prediction_string == human_prediction_string or 'homo;sapiens' in prediction_string:
258
+ return False
259
+ if prediction_string == blank_prediction_string:
260
+ return False
261
+ if prediction_string == no_cv_result_prediction_string:
262
+ return False
263
+ if len(get_kingdom(prediction_string)) == 0:
264
+ return False
265
+ return True
266
+
267
+
268
+ def taxonomy_info_to_taxonomy_string(taxonomy_info, include_taxon_id_and_common_name=False):
269
+ """
270
+ Convert a taxonomy record in dict format to a five- or seven-token semicolon-delimited string
271
+
272
+ Args:
273
+ taxonomy_info (dict): dict in the format stored in, e.g., taxonomy_string_to_taxonomy_info
274
+ include_taxon_id_and_common_name (bool, optional): by default, this function returns a
275
+ five-token string of latin names; if this argument is True, it includes the leading
276
+ (GUID) and trailing (common name) tokens
277
+
278
+ Returns:
279
+ str: string in the format used as keys in, e.g., taxonomy_string_to_taxonomy_info
280
+ """
281
+ s = taxonomy_info['class'] + ';' + \
282
+ taxonomy_info['order'] + ';' + \
283
+ taxonomy_info['family'] + ';' + \
284
+ taxonomy_info['genus'] + ';' + \
285
+ taxonomy_info['species']
286
+
287
+ if include_taxon_id_and_common_name:
288
+ s = taxonomy_info['taxon_id'] + ';' + s + ';' + taxonomy_info['common_name']
289
+
290
+ return s
291
+
292
+
293
+ #%% Functions used to manipulate results files
294
+
295
+ def generate_whole_image_detections_for_classifications(classifications_json_file,
296
+ detections_json_file,
297
+ ensemble_json_file=None,
298
+ ignore_blank_classifications=True):
299
+ """
300
+ Given a set of classification results in SpeciesNet format that were likely run on
301
+ already-cropped images, generate a file of [fake] detections in SpeciesNet format in which each
302
+ image is covered in a single whole-image detection.
303
+
304
+ Args:
305
+ classifications_json_file (str): SpeciesNet-formatted file containing classifications
306
+ detections_json_file (str): SpeciesNet-formatted file to write with detections
307
+ ensemble_json_file (str, optional): SpeciesNet-formatted file to write with detections
308
+ and classfications
309
+ ignore_blank_classifications (bool, optional): use non-top classifications when
310
+ the top classification is "blank" or "no CV result"
311
+
312
+ Returns:
313
+ dict: the contents of [detections_json_file]
314
+ """
315
+
316
+ with open(classifications_json_file,'r') as f:
317
+ classification_results = json.load(f)
318
+ predictions = classification_results['predictions']
319
+
320
+ output_predictions = []
321
+ ensemble_predictions = []
322
+
323
+ # prediction = predictions[0]
324
+ for prediction in predictions:
325
+
326
+ output_prediction = {}
327
+ output_prediction['filepath'] = prediction['filepath']
328
+ i_score = 0
329
+ if ignore_blank_classifications:
330
+ while (prediction['classifications']['classes'][i_score] in \
331
+ (blank_prediction_string,no_cv_result_prediction_string)):
332
+ i_score += 1
333
+ top_classification = prediction['classifications']['classes'][i_score]
334
+ top_classification_score = prediction['classifications']['scores'][i_score]
335
+ if is_animal_classification(top_classification):
336
+ category_name = 'animal'
337
+ elif is_human_classification(top_classification):
338
+ category_name = 'human'
339
+ else:
340
+ category_name = 'vehicle'
341
+
342
+ if category_name == 'human':
343
+ md_category_name = 'person'
344
+ else:
345
+ md_category_name = category_name
346
+
347
+ output_detection = {}
348
+ output_detection['label'] = category_name
349
+ output_detection['category'] = md_category_name_to_id[md_category_name]
350
+ output_detection['conf'] = 1.0
351
+ output_detection['bbox'] = [0.0, 0.0, 1.0, 1.0]
352
+ output_prediction['detections'] = [output_detection]
353
+ output_predictions.append(output_prediction)
354
+
355
+ ensemble_prediction = {}
356
+ ensemble_prediction['filepath'] = prediction['filepath']
357
+ ensemble_prediction['detections'] = [output_detection]
358
+ ensemble_prediction['prediction'] = top_classification
359
+ ensemble_prediction['prediction_score'] = top_classification_score
360
+ ensemble_prediction['prediction_source'] = 'fake_ensemble_file_utility'
361
+ ensemble_prediction['classifications'] = prediction['classifications']
362
+ ensemble_predictions.append(ensemble_prediction)
363
+
364
+ # ...for each image
365
+
366
+ ## Write output
367
+
368
+ if ensemble_json_file is not None:
369
+
370
+ ensemble_output_data = {'predictions':ensemble_predictions}
371
+ with open(ensemble_json_file,'w') as f:
372
+ json.dump(ensemble_output_data,f,indent=1)
373
+ _ = validate_predictions_file(ensemble_json_file)
374
+
375
+ output_data = {'predictions':output_predictions}
376
+ with open(detections_json_file,'w') as f:
377
+ json.dump(output_data,f,indent=1)
378
+ return validate_predictions_file(detections_json_file)
379
+
380
+ # ...def generate_whole_image_detections_for_classifications(...)
381
+
382
+
383
+ def generate_md_results_from_predictions_json(predictions_json_file,
384
+ md_results_file,
385
+ base_folder=None,
386
+ max_decimals=5,
387
+ convert_human_to_person=True,
388
+ convert_homo_species_to_human=True):
389
+ """
390
+ Generate an MD-formatted .json file from a predictions.json file, generated by the
391
+ SpeciesNet ensemble. Typically, MD results files use relative paths, and predictions.json
392
+ files use absolute paths, so this function optionally removes the leading string
393
+ [base_folder] from all file names.
394
+
395
+ Currently just applies the top classification category to every detection. If the top
396
+ classification is "blank", writes an empty detection list.
397
+
398
+ Uses the classification from the "prediction" field if it's available, otherwise
399
+ uses the "classifications" field.
400
+
401
+ When using the "prediction" field, records the top class in the "classifications" field to
402
+ a field in each image called "top_classification_common_name". This is often different
403
+ from the value of the "prediction" field.
404
+
405
+ speciesnet_to_md.py is a command-line driver for this function.
406
+
407
+ Args:
408
+ predictions_json_file (str): path to a predictions.json file, or a dict
409
+ md_results_file (str): path to which we should write an MD-formatted .json file
410
+ base_folder (str, optional): leading string to remove from each path in the
411
+ predictions.json file
412
+ max_decimals (int, optional): number of decimal places to which we should round
413
+ all values
414
+ convert_human_to_person (bool, optional): WI predictions.json files sometimes use the
415
+ detection category "human"; MD files usually use "person". If True, switches "human"
416
+ to "person".
417
+ convert_homo_species_to_human (bool, optional): the ensemble often rolls human predictions
418
+ up to "homo species", which isn't wrong, but looks odd. This forces these back to
419
+ "homo sapiens".
420
+ """
421
+
422
+ # Read predictions file
423
+ if isinstance(predictions_json_file,str):
424
+ with open(predictions_json_file,'r') as f:
425
+ predictions = json.load(f)
426
+ else:
427
+ assert isinstance(predictions_json_file,dict)
428
+ predictions = predictions_json_file
429
+
430
+ # Round floating-point values (confidence scores, coordinates) to a
431
+ # reasonable number of decimal places
432
+ if max_decimals is not None and max_decimals > 0:
433
+ round_floats_in_nested_dict(predictions)
434
+
435
+ predictions = predictions['predictions']
436
+ assert isinstance(predictions,list)
437
+
438
+ # Convert backslashes to forward slashes in both filenames and the base folder string
439
+ for im in predictions:
440
+ im['filepath'] = im['filepath'].replace('\\','/')
441
+ if base_folder is not None:
442
+ base_folder = base_folder.replace('\\','/')
443
+
444
+ detection_category_id_to_name = {}
445
+ classification_category_name_to_id = {}
446
+
447
+ # Keep track of detections that don't have an assigned detection category; these
448
+ # are fake detections we create for non-blank images with non-empty detection lists.
449
+ # We need to go back later and give them a legitimate detection category ID.
450
+ all_unknown_detections = []
451
+
452
+ # Create the output images list
453
+ images_out = []
454
+
455
+ base_folder_replacements = 0
456
+
457
+ # im_in = predictions[0]
458
+ for im_in in predictions:
459
+
460
+ im_out = {}
461
+
462
+ fn = im_in['filepath']
463
+ if base_folder is not None:
464
+ if fn.startswith(base_folder):
465
+ base_folder_replacements += 1
466
+ fn = fn.replace(base_folder,'',1)
467
+
468
+ im_out['file'] = fn
469
+
470
+ if 'failures' in im_in:
471
+
472
+ im_out['failure'] = str(im_in['failures'])
473
+ im_out['detections'] = None
474
+
475
+ else:
476
+
477
+ im_out['detections'] = []
478
+
479
+ if 'detections' in im_in:
480
+
481
+ if len(im_in['detections']) == 0:
482
+ im_out['detections'] = []
483
+ else:
484
+ # det_in = im_in['detections'][0]
485
+ for det_in in im_in['detections']:
486
+ det_out = {}
487
+ if det_in['category'] in detection_category_id_to_name:
488
+ assert detection_category_id_to_name[det_in['category']] == det_in['label']
489
+ else:
490
+ detection_category_id_to_name[det_in['category']] = det_in['label']
491
+ det_out = {}
492
+ for s in ['category','conf','bbox']:
493
+ det_out[s] = det_in[s]
494
+ im_out['detections'].append(det_out)
495
+
496
+ # ...if detections are present
497
+
498
+ class_to_assign = None
499
+ class_confidence = None
500
+ top_classification_common_name = None
501
+
502
+ if 'classifications' in im_in:
503
+
504
+ classifications = im_in['classifications']
505
+ assert len(classifications['scores']) == len(classifications['classes'])
506
+ assert is_list_sorted(classifications['scores'],reverse=True)
507
+ class_to_assign = classifications['classes'][0]
508
+ class_confidence = classifications['scores'][0]
509
+
510
+ tokens = class_to_assign.split(';')
511
+ assert len(tokens) == 7
512
+ top_classification_common_name = tokens[-1]
513
+ if len(top_classification_common_name) == 0:
514
+ top_classification_common_name = 'undefined'
515
+
516
+ if 'prediction' in im_in:
517
+
518
+ class_to_assign = None
519
+ im_out['top_classification_common_name'] = top_classification_common_name
520
+ class_to_assign = im_in['prediction']
521
+ if convert_homo_species_to_human and class_to_assign.endswith('homo species'):
522
+ class_to_assign = human_prediction_string
523
+ class_confidence = im_in['prediction_score']
524
+
525
+ if class_to_assign is not None:
526
+
527
+ if class_to_assign == blank_prediction_string:
528
+
529
+ # This is a scenario that's not captured well by the MD format: a blank prediction
530
+ # with detections present. But, for now, don't do anything special here, just making
531
+ # a note of this.
532
+ if len(im_out['detections']) > 0:
533
+ pass
534
+
535
+ else:
536
+
537
+ assert not class_to_assign.endswith('blank')
538
+
539
+ # This is a scenario that's not captured well by the MD format: no detections present,
540
+ # but a non-blank prediction. For now, create a fake detection to handle this prediction.
541
+ if len(im_out['detections']) == 0:
542
+
543
+ print('Warning: creating fake detection for non-blank whole-image classification')
544
+ det_out = {}
545
+ all_unknown_detections.append(det_out)
546
+
547
+ # We will change this to a string-int later
548
+ det_out['category'] = 'unknown'
549
+ det_out['conf'] = class_confidence
550
+ det_out['bbox'] = [0,0,1,1]
551
+ im_out['detections'].append(det_out)
552
+
553
+ # ...if this is/isn't a blank classification
554
+
555
+ # Attach that classification to each detection
556
+
557
+ # Create a new category ID if necessary
558
+ if class_to_assign in classification_category_name_to_id:
559
+ classification_category_id = classification_category_name_to_id[class_to_assign]
560
+ else:
561
+ classification_category_id = str(len(classification_category_name_to_id))
562
+ classification_category_name_to_id[class_to_assign] = classification_category_id
563
+
564
+ for det in im_out['detections']:
565
+ det['classifications'] = []
566
+ det['classifications'].append([classification_category_id,class_confidence])
567
+
568
+ # ...if we have some type of classification for this image
569
+
570
+ # ...if this is/isn't a failure
571
+
572
+ images_out.append(im_out)
573
+
574
+ # ...for each image
575
+
576
+ if base_folder is not None:
577
+ if base_folder_replacements == 0:
578
+ print('Warning: you supplied {} as the base folder, but I made zero replacements'.format(
579
+ base_folder))
580
+
581
+ # Fix the 'unknown' category
582
+ if len(all_unknown_detections) > 0:
583
+
584
+ max_detection_category_id = max([int(x) for x in detection_category_id_to_name.keys()])
585
+ unknown_category_id = str(max_detection_category_id + 1)
586
+ detection_category_id_to_name[unknown_category_id] = 'unknown'
587
+
588
+ for det in all_unknown_detections:
589
+ assert det['category'] == 'unknown'
590
+ det['category'] = unknown_category_id
591
+
592
+
593
+ # Sort by filename
594
+
595
+ images_out = sort_list_of_dicts_by_key(images_out,'file')
596
+
597
+ # Prepare friendly classification names
598
+
599
+ classification_category_descriptions = \
600
+ invert_dictionary(classification_category_name_to_id)
601
+ classification_categories_out = {}
602
+ for category_id in classification_category_descriptions.keys():
603
+ category_name = classification_category_descriptions[category_id].split(';')[-1]
604
+ classification_categories_out[category_id] = category_name
605
+
606
+ # Prepare the output dict
607
+
608
+ detection_categories_out = detection_category_id_to_name
609
+ info = {}
610
+ info['format_version'] = 1.4
611
+ info['detector'] = 'converted_from_predictions_json'
612
+
613
+ if convert_human_to_person:
614
+ for k in detection_categories_out.keys():
615
+ if detection_categories_out[k] == 'human':
616
+ detection_categories_out[k] = 'person'
617
+
618
+ output_dict = {}
619
+ output_dict['info'] = info
620
+ output_dict['detection_categories'] = detection_categories_out
621
+ output_dict['classification_categories'] = classification_categories_out
622
+ output_dict['classification_category_descriptions'] = classification_category_descriptions
623
+ output_dict['images'] = images_out
624
+
625
+ with open(md_results_file,'w') as f:
626
+ json.dump(output_dict,f,indent=1)
627
+
628
+ validation_options = ValidateBatchResultsOptions()
629
+ validation_options.raise_errors = True
630
+ _ = validate_batch_results(md_results_file, options=validation_options)
631
+
632
+ # ...def generate_md_results_from_predictions_json(...)
633
+
634
+
635
+ def generate_predictions_json_from_md_results(md_results_file,
636
+ predictions_json_file,
637
+ base_folder=None):
638
+ """
639
+ Generate a predictions.json file from the MD-formatted .json file [md_results_file]. Typically,
640
+ MD results files use relative paths, and predictions.json files use absolute paths, so
641
+ this function optionally prepends [base_folder]. Does not handle classification results in
642
+ MD format, since this is intended to prepare data for passing through the WI classifier.
643
+
644
+ md_to_wi.py is a command-line driver for this function.
645
+
646
+ Args:
647
+ md_results_file (str): path to an MD-formatted .json file
648
+ predictions_json_file (str): path to which we should write a predictions.json file
649
+ base_folder (str, optional): folder name to prepend to each path in md_results_file,
650
+ to convert relative paths to absolute paths.
651
+ """
652
+
653
+ # Validate the input file
654
+ validation_options = ValidateBatchResultsOptions()
655
+ validation_options.raise_errors = True
656
+ validation_options.return_data = True
657
+ md_results = validate_batch_results(md_results_file, options=validation_options)
658
+ category_id_to_name = md_results['detection_categories']
659
+
660
+ output_dict = {}
661
+ output_dict['predictions'] = []
662
+
663
+ # im = md_results['images'][0]
664
+ for im in md_results['images']:
665
+
666
+ prediction = {}
667
+ fn = im['file']
668
+ if base_folder is not None:
669
+ fn = os.path.join(base_folder,fn)
670
+ fn = fn.replace('\\','/')
671
+ prediction['filepath'] = fn
672
+ if 'failure' in im and im['failure'] is not None:
673
+ prediction['failures'] = ['DETECTOR']
674
+ else:
675
+ assert 'detections' in im and im['detections'] is not None
676
+ detections = []
677
+ for det in im['detections']:
678
+ output_det = deepcopy(det)
679
+ output_det['label'] = category_id_to_name[det['category']]
680
+ detections.append(output_det)
681
+
682
+ # detections *must* be sorted in descending order by confidence
683
+ detections = sort_list_of_dicts_by_key(detections,'conf', reverse=True)
684
+ prediction['detections'] = detections
685
+
686
+ assert len(prediction.keys()) >= 2
687
+ output_dict['predictions'].append(prediction)
688
+
689
+ # ...for each image
690
+
691
+ os.makedirs(os.path.dirname(predictions_json_file),exist_ok=True)
692
+ with open(predictions_json_file,'w') as f:
693
+ json.dump(output_dict,f,indent=1)
694
+
695
+ # ...def generate_predictions_json_from_md_results(...)
696
+
697
+
698
+ def generate_instances_json_from_folder(folder,
699
+ country=None,
700
+ admin1_region=None,
701
+ lat=None,
702
+ lon=None,
703
+ output_file=None,
704
+ filename_replacements=None,
705
+ tokens_to_ignore=default_tokens_to_ignore):
706
+ """
707
+ Generate an instances.json record that contains all images in [folder], optionally
708
+ including location information, in a format suitable for run_model.py. Optionally writes
709
+ the results to [output_file].
710
+
711
+ Args:
712
+ folder (str): the folder to recursively search for images
713
+ country (str, optional): a three-letter country code
714
+ admin1_region (str, optional): an administrative region code, typically a two-letter
715
+ US state code
716
+ lat (float, optional): latitude to associate with all images
717
+ lon (float, optional): longitude to associate with all images
718
+ output_file (str, optional): .json file to which we should write instance records
719
+ filename_replacements (dict, optional): str --> str dict indicating filename substrings
720
+ that should be replaced with other strings. Replacement occurs *after* converting
721
+ backslashes to forward slashes.
722
+ tokens_to_ignore (list, optional): ignore any images with these tokens in their
723
+ names, typically used to avoid $RECYCLE.BIN. Can be None.
724
+
725
+ Returns:
726
+ dict: dict with at least the field "instances"
727
+ """
728
+
729
+ assert os.path.isdir(folder)
730
+
731
+ image_files_abs = find_images(folder,recursive=True,return_relative_paths=False)
732
+
733
+ if tokens_to_ignore is not None:
734
+ n_images_before_ignore_tokens = len(image_files_abs)
735
+ for token in tokens_to_ignore:
736
+ image_files_abs = [fn for fn in image_files_abs if token not in fn]
737
+ print('After ignoring {} tokens, kept {} of {} images'.format(
738
+ len(tokens_to_ignore),len(image_files_abs),n_images_before_ignore_tokens))
739
+
740
+ instances = []
741
+
742
+ # image_fn_abs = image_files_abs[0]
743
+ for image_fn_abs in image_files_abs:
744
+ instance = {}
745
+ instance['filepath'] = image_fn_abs.replace('\\','/')
746
+ if filename_replacements is not None:
747
+ for s in filename_replacements:
748
+ instance['filepath'] = instance['filepath'].replace(s,filename_replacements[s])
749
+ if country is not None:
750
+ instance['country'] = country
751
+ if admin1_region is not None:
752
+ instance['admin1_region'] = admin1_region
753
+ if lat is not None:
754
+ assert lon is not None, 'Latitude provided without longitude'
755
+ instance['latitude'] = lat
756
+ if lon is not None:
757
+ assert lat is not None, 'Longitude provided without latitude'
758
+ instance['longitude'] = lon
759
+ instances.append(instance)
760
+
761
+ to_return = {'instances':instances}
762
+
763
+ if output_file is not None:
764
+ os.makedirs(os.path.dirname(output_file),exist_ok=True)
765
+ with open(output_file,'w') as f:
766
+ json.dump(to_return,f,indent=1)
767
+
768
+ return to_return
769
+
770
+ # ...def generate_instances_json_from_folder(...)
771
+
772
+
773
+ def split_instances_into_n_batches(instances_json,n_batches,output_files=None):
774
+ """
775
+ Given an instances.json file, split it into batches of equal size.
776
+
777
+ Args:
778
+ instances_json (str): input .json file in
779
+ n_batches (int): number of new files to generate
780
+ output_files (list, optional): output .json files for each
781
+ batch. If supplied, should have length [n_batches]. If not
782
+ supplied, filenames will be generated based on [instances_json].
783
+
784
+ Returns:
785
+ list: list of output files that were written; identical to [output_files]
786
+ if it was supplied as input.
787
+ """
788
+
789
+ with open(instances_json,'r') as f:
790
+ instances = json.load(f)
791
+ assert isinstance(instances,dict) and 'instances' in instances
792
+ instances = instances['instances']
793
+
794
+ if output_files is not None:
795
+ assert len(output_files) == n_batches, \
796
+ 'Expected {} output files, received {}'.format(
797
+ n_batches,len(output_files))
798
+ else:
799
+ output_files = []
800
+ for i_batch in range(0,n_batches):
801
+ batch_string = 'batch_{}'.format(str(i_batch).zfill(3))
802
+ output_files.append(insert_before_extension(instances_json,batch_string))
803
+
804
+ batches = split_list_into_n_chunks(instances, n_batches)
805
+
806
+ for i_batch,batch in enumerate(batches):
807
+ batch_dict = {'instances':batch}
808
+ with open(output_files[i_batch],'w') as f:
809
+ json.dump(batch_dict,f,indent=1)
810
+
811
+ print('Wrote {} batches to file'.format(n_batches))
812
+
813
+ return output_files
814
+
815
+ # ...def split_instances_into_n_batches(...)
816
+
817
+
818
+ def merge_prediction_json_files(input_prediction_files,output_prediction_file):
819
+ """
820
+ Merge all predictions.json files in [files] into a single .json file.
821
+
822
+ Args:
823
+ input_prediction_files (list): list of predictions.json files to merge
824
+ output_prediction_file (str): output .json file
825
+ """
826
+
827
+ predictions = []
828
+ image_filenames_processed = set()
829
+
830
+ # input_json_fn = input_prediction_files[0]
831
+ for input_json_fn in tqdm(input_prediction_files):
832
+
833
+ assert os.path.isfile(input_json_fn), \
834
+ 'Could not find prediction file {}'.format(input_json_fn)
835
+ with open(input_json_fn,'r') as f:
836
+ results_this_file = json.load(f)
837
+ assert isinstance(results_this_file,dict)
838
+ predictions_this_file = results_this_file['predictions']
839
+ for prediction in predictions_this_file:
840
+ image_fn = prediction['filepath']
841
+ assert image_fn not in image_filenames_processed
842
+ predictions.extend(predictions_this_file)
843
+
844
+ output_dict = {'predictions':predictions}
845
+
846
+ os.makedirs(os.path.dirname(output_prediction_file),exist_ok=True)
847
+ with open(output_prediction_file,'w') as f:
848
+ json.dump(output_dict,f,indent=1)
849
+
850
+ # ...def merge_prediction_json_files(...)
851
+
852
+
853
+ def load_md_or_speciesnet_file(fn,verbose=True):
854
+ """
855
+ Load a .json file that may be in MD or SpeciesNet format. Typically used so
856
+ SpeciesNet files can be supplied to functions originally written to support MD
857
+ format.
858
+
859
+ Args:
860
+ fn (str): a .json file in predictions.json (MD or SpeciesNet) format
861
+ verbose (bool, optional): enable additional debug output
862
+
863
+ Returns:
864
+ dict: the contents of [fn], in MD format.
865
+ """
866
+
867
+ with open(fn,'r') as f:
868
+ detector_output = json.load(f)
869
+
870
+ # Convert to MD format if necessary
871
+ if 'predictions' in detector_output:
872
+ if verbose:
873
+ print('This appears to be a SpeciesNet output file, converting to MD format')
874
+ md_temp_dir = os.path.join(tempfile.gettempdir(), 'megadetector_temp_files')
875
+ os.makedirs(md_temp_dir,exist_ok=True)
876
+ temp_results_file = os.path.join(md_temp_dir,str(uuid.uuid1()) + '.json')
877
+ print('Writing temporary results to {}'.format(temp_results_file))
878
+ generate_md_results_from_predictions_json(predictions_json_file=fn,
879
+ md_results_file=temp_results_file,
880
+ base_folder=None)
881
+ with open(temp_results_file,'r') as f:
882
+ detector_output = json.load(f)
883
+ try:
884
+ os.remove(temp_results_file)
885
+ except Exception:
886
+ if verbose:
887
+ print('Warning: error removing temporary .json {}'.format(temp_results_file))
888
+
889
+ assert 'images' in detector_output, \
890
+ 'Detector output file should be a json file with an "images" field.'
891
+
892
+ return detector_output
893
+
894
+ # ...def load_md_or_speciesnet_file(...)
895
+
896
+
897
+ def validate_predictions_file(fn,instances=None,verbose=True):
898
+ """
899
+ Validate the predictions.json file [fn].
900
+
901
+ Args:
902
+ fn (str): a .json file in predictions.json (SpeciesNet) format
903
+ instances (str or list, optional): a folder, instances.json file,
904
+ or dict loaded from an instances.json file. If supplied, this
905
+ function will verify that [fn] contains the same number of
906
+ images as [instances].
907
+ verbose (bool, optional): enable additional debug output
908
+
909
+ Returns:
910
+ dict: the contents of [fn]
911
+ """
912
+
913
+ with open(fn,'r') as f:
914
+ d = json.load(f)
915
+ predictions = d['predictions']
916
+
917
+ failures = []
918
+
919
+ for im in predictions:
920
+ if 'failures' in im:
921
+ failures.append(im)
922
+
923
+ if verbose:
924
+ print('Read predictions for {} images, with {} failure(s)'.format(
925
+ len(d['predictions']),len(failures)))
926
+
927
+ if instances is not None:
928
+
929
+ if isinstance(instances,str):
930
+ if os.path.isdir(instances):
931
+ instances = generate_instances_json_from_folder(folder=instances)
932
+ elif os.path.isfile(instances):
933
+ with open(instances,'r') as f:
934
+ instances = json.load(f)
935
+ else:
936
+ raise ValueError('Could not find instances file/folder {}'.format(
937
+ instances))
938
+ assert isinstance(instances,dict)
939
+ assert 'instances' in instances
940
+ instances = instances['instances']
941
+ if verbose:
942
+ print('Expected results for {} files'.format(len(instances)))
943
+ assert len(instances) == len(predictions), \
944
+ '{} instances expected, {} found'.format(
945
+ len(instances),len(predictions))
946
+
947
+ expected_files = set([instance['filepath'] for instance in instances])
948
+ found_files = set([prediction['filepath'] for prediction in predictions])
949
+ assert expected_files == found_files
950
+
951
+ # ...if a list of instances was supplied
952
+
953
+ return d
954
+
955
+ # ...def validate_predictions_file(...)
956
+
957
+
958
+ #%% Functions related to geofencing
959
+
960
+ def find_geofence_adjustments(ensemble_json_file,use_latin_names=False):
961
+ """
962
+ Count the number of instances of each unique change made by the geofence.
963
+
964
+ Args:
965
+ ensemble_json_file (str): SpeciesNet-formatted .json file produced
966
+ by the full ensemble.
967
+ use_latin_names (bool, optional): return a mapping using binomial names
968
+ rather than common names.
969
+
970
+ Returns:
971
+ dict: maps strings that look like "puma,felidae family" to integers,
972
+ where that entry would indicate the number of times that "puma" was
973
+ predicted, but mapped to family level by the geofence. Sorted in
974
+ descending order by count.
975
+ """
976
+
977
+ # Load and validate ensemble results
978
+ ensemble_results = validate_predictions_file(ensemble_json_file)
979
+
980
+ assert isinstance(ensemble_results,dict)
981
+ predictions = ensemble_results['predictions']
982
+
983
+ # Maps comma-separated pairs of common names (or binomial names) to
984
+ # the number of times that transition (first --> second) happened
985
+ rollup_pair_to_count = defaultdict(int)
986
+
987
+ # prediction = predictions[0]
988
+ for prediction in tqdm(predictions):
989
+
990
+ if 'failures' in prediction and \
991
+ prediction['failures'] is not None and \
992
+ len(prediction['failures']) > 0:
993
+ continue
994
+
995
+ assert 'prediction_source' in prediction, \
996
+ 'Prediction present without [prediction_source] field, are you sure this ' + \
997
+ 'is an ensemble output file?'
998
+
999
+ if 'geofence' in prediction['prediction_source']:
1000
+
1001
+ classification_taxonomy_string = \
1002
+ prediction['classifications']['classes'][0]
1003
+ prediction_taxonomy_string = prediction['prediction']
1004
+ assert is_valid_prediction_string(classification_taxonomy_string)
1005
+ assert is_valid_prediction_string(prediction_taxonomy_string)
1006
+
1007
+ # Typical examples:
1008
+ # '86f5b978-4f30-40cc-bd08-be9e3fba27a0;mammalia;rodentia;sciuridae;sciurus;carolinensis;eastern gray squirrel'
1009
+ # 'e4d1e892-0e4b-475a-a8ac-b5c3502e0d55;mammalia;rodentia;sciuridae;;;sciuridae family'
1010
+ classification_common_name = classification_taxonomy_string.split(';')[-1]
1011
+ prediction_common_name = prediction_taxonomy_string.split(';')[-1]
1012
+ classification_binomial_name = classification_taxonomy_string.split(';')[-2]
1013
+ prediction_binomial_name = prediction_taxonomy_string.split(';')[-2]
1014
+
1015
+ input_name = classification_binomial_name if use_latin_names else \
1016
+ classification_common_name
1017
+ output_name = prediction_binomial_name if use_latin_names else \
1018
+ prediction_common_name
1019
+
1020
+ rollup_pair = input_name.strip() + ',' + output_name.strip()
1021
+ rollup_pair_to_count[rollup_pair] += 1
1022
+
1023
+ # ...if we made a geofencing change
1024
+
1025
+ # ...for each prediction
1026
+
1027
+ rollup_pair_to_count = sort_dictionary_by_value(rollup_pair_to_count,reverse=True)
1028
+
1029
+ return rollup_pair_to_count
1030
+
1031
+ # ...def find_geofence_adjustments(...)
1032
+
1033
+
1034
+ def generate_geofence_adjustment_html_summary(rollup_pair_to_count,min_count=10):
1035
+ """
1036
+ Given a list of geofence rollups, likely generated by find_geofence_adjustments,
1037
+ generate an HTML summary of the changes made by geofencing. The resulting HTML
1038
+ is wrapped in <div>, but not, for example, in <html> or <body>.
1039
+
1040
+ Args:
1041
+ rollup_pair_to_count (dict): list of changes made by geofencing, see
1042
+ find_geofence_adjustments for details
1043
+ min_count (int, optional): minimum number of changes a pair needs in order
1044
+ to be included in the report.
1045
+ """
1046
+
1047
+ geofence_footer = ''
1048
+
1049
+ # Restrict to the list of taxa that were impacted by geofencing
1050
+ rollup_pair_to_count = \
1051
+ {key: value for key, value in rollup_pair_to_count.items() if value >= min_count}
1052
+
1053
+ # rollup_pair_to_count is sorted in descending order by count
1054
+ assert is_list_sorted(list(rollup_pair_to_count.values()),reverse=True)
1055
+
1056
+ if len(rollup_pair_to_count) > 0:
1057
+
1058
+ geofence_footer = \
1059
+ '<h3>Geofence changes that occurred more than {} times</h3>\n'.format(min_count)
1060
+ geofence_footer += '<div class="contentdiv">\n'
1061
+
1062
+ print('\nRollup changes with count > {}:'.format(min_count))
1063
+ for rollup_pair in rollup_pair_to_count.keys():
1064
+ count = rollup_pair_to_count[rollup_pair]
1065
+ rollup_pair_s = rollup_pair.replace(',',' --> ')
1066
+ print('{}: {}'.format(rollup_pair_s,count))
1067
+ rollup_pair_html = rollup_pair.replace(',',' &rarr; ')
1068
+ geofence_footer += '{} ({})<br/>\n'.format(rollup_pair_html,count)
1069
+
1070
+ geofence_footer += '</div>\n'
1071
+
1072
+ return geofence_footer
1073
+
1074
+ # ...def generate_geofence_adjustment_html_summary(...)
1075
+
1076
+
1077
+ #%% TaxonomyHandler class
1078
+
1079
+ class TaxonomyHandler:
1080
+ """
1081
+ Handler for taxonomy mapping and geofencing operations.
1082
+ """
1083
+
1084
+ def __init__(self, taxonomy_file, geofencing_file, country_code_file):
1085
+ """
1086
+ Initialize TaxonomyHandler with taxonomy information.
1087
+
1088
+ Args:
1089
+ taxonomy_file (str): .csv file containing the SpeciesNet (or WI) taxonomy,
1090
+ as seven-token taxonomic specifiers. Distributed with the SpeciesNet model.
1091
+ geofencing_file (str): .json file containing the SpeciesNet geofencing rules.
1092
+ Distributed with the SpeciesNet model.
1093
+ country_code_file: .csv file mapping country codes to names. Should include columns
1094
+ called "name" and "alpha-3". A compatible file is available at
1095
+ https://github.com/lukes/ISO-3166-Countries-with-Regional-Codes
1096
+ """
1097
+
1098
+ #: Maps a taxonomy string (e.g. mammalia;cetartiodactyla;cervidae;odocoileus;virginianus) to
1099
+ #: a dict with keys taxon_id, common_name, kingdom, phylum, class, order, family, genus, species
1100
+ self.taxonomy_string_to_taxonomy_info = None
1101
+
1102
+ #: Maps a binomial name (one, two, or three ws-delimited tokens) to the same dict described above.
1103
+ self.binomial_name_to_taxonomy_info = None
1104
+
1105
+ #: Maps a common name to the same dict described above
1106
+ self.common_name_to_taxonomy_info = None
1107
+
1108
+ #: Dict mapping 5-token semicolon-delimited taxonomy strings to geofencing rules
1109
+ self.taxonomy_string_to_geofencing_rules = None
1110
+
1111
+ #: Maps lower-case country names to upper-case country codes
1112
+ self.country_to_country_code = None
1113
+
1114
+ #: Maps upper-case country codes to lower-case country names
1115
+ self.country_code_to_country = None
1116
+
1117
+ self._load_taxonomy_info(taxonomy_file=taxonomy_file)
1118
+ self._initialize_geofencing(geofencing_file=geofencing_file,
1119
+ country_code_file=country_code_file)
1120
+
1121
+
1122
+ def _load_taxonomy_info(self, taxonomy_file):
1123
+ """
1124
+ Load WI/SpeciesNet taxonomy information from a .csv file. Stores information in the
1125
+ instance dicts [taxonomy_string_to_taxonomy_info], [binomial_name_to_taxonomy_info],
1126
+ and [common_name_to_taxonomy_info].
1127
+
1128
+ Args:
1129
+ taxonomy_file (str): .csv file containing the SpeciesNet (or WI) taxonomy,
1130
+ as seven-token taxonomic specifiers. Distributed with the SpeciesNet model.
1131
+ """
1132
+
1133
+ """
1134
+ Taxonomy keys are five-token taxonomy strings, e.g.:
1135
+
1136
+ 'mammalia;cetartiodactyla;cervidae;odocoileus;virginianus'
1137
+
1138
+ Taxonomy values are seven-token strings w/Taxon IDs and common names, e.g.:
1139
+
1140
+ '5c7ce479-8a45-40b3-ae21-7c97dfae22f5;mammalia;cetartiodactyla;cervidae;odocoileus;virginianus;white-tailed deer'
1141
+ """
1142
+
1143
+ with open(taxonomy_file,'r') as f:
1144
+ taxonomy_lines = f.readlines()
1145
+ taxonomy_lines = [s.strip() for s in taxonomy_lines]
1146
+
1147
+ self.taxonomy_string_to_taxonomy_info = {}
1148
+ self.binomial_name_to_taxonomy_info = {}
1149
+ self.common_name_to_taxonomy_info = {}
1150
+
1151
+ five_token_string_to_seven_token_string = {}
1152
+
1153
+ for line in taxonomy_lines:
1154
+ tokens = line.split(';')
1155
+ assert len(tokens) == 7, 'Illegal line {} in taxonomy file {}'.format(
1156
+ line,taxonomy_file)
1157
+ five_token_string = ';'.join(tokens[1:-1])
1158
+ assert len(five_token_string.split(';')) == 5
1159
+ five_token_string_to_seven_token_string[five_token_string] = line
1160
+
1161
+ for taxonomy_string in five_token_string_to_seven_token_string.keys():
1162
+
1163
+ taxonomy_string = taxonomy_string.lower()
1164
+
1165
+ taxon_info = {}
1166
+ extended_string = five_token_string_to_seven_token_string[taxonomy_string]
1167
+ tokens = extended_string.split(';')
1168
+ assert len(tokens) == 7
1169
+ taxon_info['taxon_id'] = tokens[0]
1170
+ assert len(taxon_info['taxon_id']) == 36
1171
+ taxon_info['kingdom'] = 'animal'
1172
+ taxon_info['phylum'] = 'chordata'
1173
+ taxon_info['class'] = tokens[1]
1174
+ taxon_info['order'] = tokens[2]
1175
+ taxon_info['family'] = tokens[3]
1176
+ taxon_info['genus'] = tokens[4]
1177
+ taxon_info['species'] = tokens[5]
1178
+ taxon_info['common_name'] = tokens[6]
1179
+
1180
+ if taxon_info['common_name'] != '':
1181
+ self.common_name_to_taxonomy_info[taxon_info['common_name']] = taxon_info
1182
+
1183
+ self.taxonomy_string_to_taxonomy_info[taxonomy_string] = taxon_info
1184
+
1185
+ binomial_name = None
1186
+ if len(tokens[4]) > 0 and len(tokens[5]) > 0:
1187
+ # strip(), but don't remove spaces from the species name;
1188
+ # subspecies are separated with a space, e.g. canis;lupus dingo
1189
+ binomial_name = tokens[4].strip() + ' ' + tokens[5].strip()
1190
+ elif len(tokens[4]) > 0:
1191
+ binomial_name = tokens[4].strip()
1192
+ elif len(tokens[3]) > 0:
1193
+ binomial_name = tokens[3].strip()
1194
+ elif len(tokens[2]) > 0:
1195
+ binomial_name = tokens[2].strip()
1196
+ elif len(tokens[1]) > 0:
1197
+ binomial_name = tokens[1].strip()
1198
+ if binomial_name is None:
1199
+ # print('Warning: no binomial name for {}'.format(taxonomy_string))
1200
+ pass
1201
+ else:
1202
+ self.binomial_name_to_taxonomy_info[binomial_name] = taxon_info
1203
+
1204
+ taxon_info['binomial_name'] = binomial_name
1205
+
1206
+ # ...for each taxonomy string in the file
1207
+
1208
+ print('Created {} records in taxonomy_string_to_taxonomy_info'.format(len(self.taxonomy_string_to_taxonomy_info)))
1209
+ print('Created {} records in common_name_to_taxonomy_info'.format(len(self.common_name_to_taxonomy_info)))
1210
+
1211
+ # ...def _load_taxonomy_info(...)
1212
+
1213
+
1214
+ def _initialize_geofencing(self, geofencing_file, country_code_file):
1215
+ """
1216
+ Load geofencing information from a .json file, and country code mappings from
1217
+ a .csv file. Stores results in the instance tables [taxonomy_string_to_geofencing_rules],
1218
+ [country_to_country_code], and [country_code_to_country].
1219
+
1220
+ Args:
1221
+ geofencing_file (str): .json file with geofencing rules
1222
+ country_code_file (str): .csv file with country code mappings, in columns
1223
+ called "name" and "alpha-3", e.g. from
1224
+ https://github.com/lukes/ISO-3166-Countries-with-Regional-Codes/blob/master/all/all.csv
1225
+ """
1226
+
1227
+ # Read country code information
1228
+ country_code_df = pd.read_csv(country_code_file)
1229
+ self.country_to_country_code = {}
1230
+ self.country_code_to_country = {}
1231
+ for i_row,row in country_code_df.iterrows():
1232
+ self.country_to_country_code[row['name'].lower()] = row['alpha-3'].upper()
1233
+ self.country_code_to_country[row['alpha-3'].upper()] = row['name'].lower()
1234
+
1235
+ # Read geofencing information
1236
+ with open(geofencing_file,'r',encoding='utf-8') as f:
1237
+ self.taxonomy_string_to_geofencing_rules = json.load(f)
1238
+
1239
+ """
1240
+ Geofencing keys are five-token taxonomy strings, e.g.:
1241
+
1242
+ 'mammalia;cetartiodactyla;cervidae;odocoileus;virginianus'
1243
+
1244
+ Geofencing values are tables mapping allow/block to country codes, optionally including region/state codes, e.g.:
1245
+
1246
+ {'allow': {
1247
+ 'ALA': [],
1248
+ 'ARG': [],
1249
+ ...
1250
+ 'SUR': [],
1251
+ 'TTO': [],
1252
+ 'USA': ['AL',
1253
+ 'AR',
1254
+ 'AZ',
1255
+ ...
1256
+ }
1257
+ """
1258
+
1259
+ # Validate
1260
+
1261
+ # species_string = next(iter(taxonomy_string_to_geofencing_rules.keys()))
1262
+ for species_string in self.taxonomy_string_to_geofencing_rules.keys():
1263
+
1264
+ species_rules = self.taxonomy_string_to_geofencing_rules[species_string]
1265
+
1266
+ if len(species_rules.keys()) > 1:
1267
+ print('Warning: taxon {} has both allow and block rules'.format(species_string))
1268
+
1269
+ for rule_type in species_rules.keys():
1270
+
1271
+ assert rule_type in ('allow','block')
1272
+ all_country_rules_this_species = species_rules[rule_type]
1273
+
1274
+ for country_code in all_country_rules_this_species.keys():
1275
+
1276
+ assert country_code in self.country_code_to_country
1277
+ region_rules = all_country_rules_this_species[country_code]
1278
+ # Right now we only have regional rules for the USA; these may be part of
1279
+ # allow or block rules.
1280
+ if len(region_rules) > 0:
1281
+ assert country_code == 'USA'
1282
+
1283
+ # ...for each country code in this rule set
1284
+
1285
+ # ...for each rule set for this species
1286
+
1287
+ # ...for each species
1288
+
1289
+ # ...def _initialize_geofencing(...)
1290
+
1291
+
1292
+ def _parse_region_code_list(self, codes):
1293
+ """
1294
+ Turn a list of country or state codes in string, delimited string, or list format
1295
+ into a list. Also does basic validity checking.
1296
+ """
1297
+
1298
+ if not isinstance(codes,list):
1299
+
1300
+ assert isinstance(codes,str)
1301
+
1302
+ codes = codes.strip()
1303
+
1304
+ # This is just a single codes
1305
+ if ',' not in codes:
1306
+ codes = [codes]
1307
+ else:
1308
+ codes = codes.split(',')
1309
+ codes = [c.strip() for c in codes]
1310
+
1311
+ assert isinstance(codes,list)
1312
+
1313
+ codes = [c.upper().strip() for c in codes]
1314
+
1315
+ for c in codes:
1316
+ assert len(c) in (2,3)
1317
+
1318
+ return codes
1319
+
1320
+ # ...def _parse_region_code_list(...)
1321
+
1322
+
1323
+ def generate_csv_rows_to_block_all_countries_except(self, species_string, block_except_list):
1324
+ """
1325
+ Generate rows in the format expected by geofence_fixes.csv, representing a list of
1326
+ allow and block rules to block all countries currently allowed for this species
1327
+ except [allow_countries], and add allow rules these countries.
1328
+
1329
+ Args:
1330
+ species_string (str): five-token taxonomy string
1331
+ block_except_list (list): list of country codes not to block
1332
+
1333
+ Returns:
1334
+ list of str: strings compatible with geofence_fixes.csv
1335
+ """
1336
+
1337
+ assert is_valid_taxonomy_string(species_string), \
1338
+ '{} is not a valid taxonomy string'.format(species_string)
1339
+
1340
+ assert self.taxonomy_string_to_geofencing_rules is not None, \
1341
+ 'Initialize geofencing prior to species lookup'
1342
+ assert self.taxonomy_string_to_taxonomy_info is not None, \
1343
+ 'Initialize taxonomy lookup prior to species lookup'
1344
+
1345
+ geofencing_rules_this_species = \
1346
+ self.taxonomy_string_to_geofencing_rules[species_string]
1347
+
1348
+ allowed_countries = []
1349
+ if 'allow' in geofencing_rules_this_species:
1350
+ allowed_countries.extend(geofencing_rules_this_species['allow'])
1351
+
1352
+ blocked_countries = []
1353
+ if 'block' in geofencing_rules_this_species:
1354
+ blocked_countries.extend(geofencing_rules_this_species['block'])
1355
+
1356
+ block_except_list = self._parse_region_code_list(block_except_list)
1357
+
1358
+ countries_to_block = []
1359
+ countries_to_allow = []
1360
+
1361
+ # country = allowed_countries[0]
1362
+ for country in allowed_countries:
1363
+ if country not in block_except_list and country not in blocked_countries:
1364
+ countries_to_block.append(country)
1365
+
1366
+ for country in block_except_list:
1367
+ if country in blocked_countries:
1368
+ raise ValueError("I can't allow a country that has already been blocked")
1369
+ if country not in allowed_countries:
1370
+ countries_to_allow.append(country)
1371
+
1372
+ rows = self.generate_csv_rows_for_species(species_string,
1373
+ allow_countries=countries_to_allow,
1374
+ block_countries=countries_to_block)
1375
+
1376
+ return rows
1377
+
1378
+ # ...def generate_csv_rows_to_block_all_countries_except(...)
1379
+
1380
+
1381
+ def generate_csv_rows_for_species(self, species_string,
1382
+ allow_countries=None,
1383
+ block_countries=None,
1384
+ allow_states=None,
1385
+ block_states=None):
1386
+ """
1387
+ Generate rows in the format expected by geofence_fixes.csv, representing a list of
1388
+ allow and/or block rules for the specified species and countries/states. Does not check
1389
+ that the rules make sense; e.g. nothing will stop you in this function from both allowing
1390
+ and blocking a country.
1391
+
1392
+ Args:
1393
+ species_string (str): five-token string in semicolon-delimited WI taxonomy format
1394
+ allow_countries (list or str, optional): three-letter country codes, list of
1395
+ country codes, or comma-separated list of country codes to allow
1396
+ block_countries (list or str, optional): three-letter country codes, list of
1397
+ country codes, or comma-separated list of country codes to block
1398
+ allow_states (list or str, optional): two-letter state codes, list of
1399
+ state codes, or comma-separated list of state codes to allow
1400
+ block_states (list or str, optional): two-letter state code, list of
1401
+ state codes, or comma-separated list of state codes to block
1402
+
1403
+ Returns:
1404
+ list of str: lines ready to be pasted into geofence_fixes.csv
1405
+ """
1406
+
1407
+ assert is_valid_taxonomy_string(species_string), \
1408
+ '{} is not a valid taxonomy string'.format(species_string)
1409
+
1410
+ lines = []
1411
+
1412
+ if allow_countries is not None:
1413
+ allow_countries = self._parse_region_code_list(allow_countries)
1414
+ for country in allow_countries:
1415
+ lines.append(species_string + ',allow,' + country + ',')
1416
+
1417
+ if block_countries is not None:
1418
+ block_countries = self._parse_region_code_list(block_countries)
1419
+ for country in block_countries:
1420
+ lines.append(species_string + ',block,' + country + ',')
1421
+
1422
+ if allow_states is not None:
1423
+ allow_states = self._parse_region_code_list(allow_states)
1424
+ for state in allow_states:
1425
+ lines.append(species_string + ',allow,USA,' + state)
1426
+
1427
+ if block_states is not None:
1428
+ block_states = self._parse_region_code_list(block_states)
1429
+ for state in block_states:
1430
+ lines.append(species_string + ',block,USA,' + state)
1431
+
1432
+ return lines
1433
+
1434
+ # ...def generate_csv_rows_for_species(...)
1435
+
1436
+
1437
+ def species_string_to_canonical_species_string(self, species):
1438
+ """
1439
+ Convert a string that may be a 5-token species string, a binomial name,
1440
+ or a common name into a 5-token species string, using taxonomic lookup.
1441
+
1442
+ Args:
1443
+ species (str): 5-token species string, binomial name, or common name
1444
+
1445
+ Returns:
1446
+ str: common name
1447
+
1448
+ Raises:
1449
+ ValueError: if [species] is not in our dictionary
1450
+ """
1451
+
1452
+ species = species.lower().strip()
1453
+
1454
+ # Turn "species" into a taxonomy string
1455
+
1456
+ # If this is already a taxonomy string...
1457
+ if len(species.split(';')) == 5:
1458
+ taxonomy_string = species
1459
+ # If this is a common name...
1460
+ elif species in self.common_name_to_taxonomy_info:
1461
+ taxonomy_info = self.common_name_to_taxonomy_info[species]
1462
+ taxonomy_string = taxonomy_info_to_taxonomy_string(taxonomy_info)
1463
+ # If this is a binomial name...
1464
+ elif (species in self.binomial_name_to_taxonomy_info):
1465
+ taxonomy_info = self.binomial_name_to_taxonomy_info[species]
1466
+ taxonomy_string = taxonomy_info_to_taxonomy_string(taxonomy_info)
1467
+ else:
1468
+ raise ValueError('Could not find taxonomic information for {}'.format(species))
1469
+
1470
+ return taxonomy_string
1471
+
1472
+ # ...def species_string_to_canonical_species_string(...)
1473
+
1474
+
1475
+ def species_string_to_taxonomy_info(self,species):
1476
+ """
1477
+ Convert a string that may be a 5-token species string, a binomial name,
1478
+ or a common name into a taxonomic info dictionary, using taxonomic lookup.
1479
+
1480
+ Args:
1481
+ species (str): 5-token species string, binomial name, or common name
1482
+
1483
+ Returns:
1484
+ dict: taxonomy information
1485
+
1486
+ Raises:
1487
+ ValueError: if [species] is not in our dictionary
1488
+ """
1489
+
1490
+ species = species.lower().strip()
1491
+ canonical_string = self.species_string_to_canonical_species_string(species)
1492
+ return self.taxonomy_string_to_taxonomy_info[canonical_string]
1493
+
1494
+
1495
+ def species_allowed_in_country(self, species, country, state=None, return_status=False):
1496
+ """
1497
+ Determines whether [species] is allowed in [country], according to
1498
+ already-initialized geofencing rules.
1499
+
1500
+ Args:
1501
+ species (str): can be a common name, a binomial name, or a species string
1502
+ country (str): country name or three-letter code
1503
+ state (str, optional): two-letter US state code
1504
+ return_status (bool, optional): by default, this function returns a bool;
1505
+ if you want to know *why* [species] is allowed/not allowed, settings
1506
+ return_status to True will return additional information.
1507
+
1508
+ Returns:
1509
+ bool or str: typically returns True if [species] is allowed in [country], else
1510
+ False. Returns a more detailed string if return_status is set.
1511
+ """
1512
+
1513
+ assert self.taxonomy_string_to_geofencing_rules is not None, \
1514
+ 'Initialize geofencing prior to species lookup'
1515
+ assert self.taxonomy_string_to_taxonomy_info is not None, \
1516
+ 'Initialize taxonomy lookup prior to species lookup'
1517
+
1518
+ taxonomy_string = self.species_string_to_canonical_species_string(species)
1519
+
1520
+ # Normalize [state]
1521
+
1522
+ if state is not None:
1523
+ state = state.upper()
1524
+ assert len(state) == 2
1525
+
1526
+ # Turn "country" into a country code
1527
+
1528
+ if len(country) == 3:
1529
+ assert country.upper() in self.country_code_to_country
1530
+ country = country.upper()
1531
+ else:
1532
+ assert country.lower() in self.country_to_country_code
1533
+ country = self.country_to_country_code[country.lower()]
1534
+
1535
+ country_code = country.upper()
1536
+
1537
+ # Species with no rules are allowed everywhere
1538
+ if taxonomy_string not in self.taxonomy_string_to_geofencing_rules:
1539
+ status = 'allow_by_default'
1540
+ if return_status:
1541
+ return status
1542
+ else:
1543
+ return True
1544
+
1545
+ geofencing_rules_this_species = self.taxonomy_string_to_geofencing_rules[taxonomy_string]
1546
+ allowed_countries = []
1547
+ blocked_countries = []
1548
+
1549
+ rule_types_this_species = list(geofencing_rules_this_species.keys())
1550
+ for rule_type in rule_types_this_species:
1551
+ assert rule_type in ('allow','block')
1552
+
1553
+ if 'block' in rule_types_this_species:
1554
+ blocked_countries = list(geofencing_rules_this_species['block'])
1555
+ if 'allow' in rule_types_this_species:
1556
+ allowed_countries = list(geofencing_rules_this_species['allow'])
1557
+
1558
+ status = None
1559
+
1560
+ # The convention is that block rules win over allow rules
1561
+ if country_code in blocked_countries:
1562
+ if country_code in allowed_countries:
1563
+ status = 'blocked_over_allow'
1564
+ else:
1565
+ status = 'blocked'
1566
+ elif country_code in allowed_countries:
1567
+ status = 'allowed'
1568
+ elif len(allowed_countries) > 0:
1569
+ # The convention is that if allow rules exist, any country not on that list
1570
+ # is blocked.
1571
+ status = 'block_not_on_country_allow_list'
1572
+ else:
1573
+ # Only block rules exist for this species, and they don't include this country
1574
+ assert len(blocked_countries) > 0
1575
+ status = 'allow_not_on_block_list'
1576
+
1577
+ # Now let's see whether we have to deal with any regional rules.
1578
+ #
1579
+ # Right now regional rules only exist for the US.
1580
+ if (country_code == 'USA') and ('USA' in geofencing_rules_this_species[rule_type]):
1581
+
1582
+ if state is None:
1583
+
1584
+ state_list = geofencing_rules_this_species[rule_type][country_code]
1585
+ if len(state_list) > 0:
1586
+ assert status.startswith('allow')
1587
+ status = 'allow_no_state'
1588
+
1589
+ else:
1590
+
1591
+ state_list = geofencing_rules_this_species[rule_type][country_code]
1592
+
1593
+ if state in state_list:
1594
+ # If the state is on the list, do what the list says
1595
+ if rule_type == 'allow':
1596
+ status = 'allow_on_state_allow_list'
1597
+ else:
1598
+ status = 'block_on_state_block_list'
1599
+ else:
1600
+ # If the state is not on the list, do the opposite of what the list says
1601
+ if rule_type == 'allow':
1602
+ status = 'block_not_on_state_allow_list'
1603
+ else:
1604
+ status = 'allow_not_on_state_block_list'
1605
+
1606
+ if return_status:
1607
+ return status
1608
+ else:
1609
+ if status.startswith('allow'):
1610
+ return True
1611
+ else:
1612
+ assert status.startswith('block')
1613
+ return False
1614
+
1615
+ # ...def species_allowed_in_country(...)
1616
+
1617
+
1618
+ def export_geofence_data_to_csv(self, csv_fn=None, include_common_names=True):
1619
+ """
1620
+ Converts the geofence .json representation into an equivalent .csv representation,
1621
+ with one taxon per row and one region per column. Empty values indicate non-allowed
1622
+ combinations, positive numbers indicate allowed combinations. Negative values
1623
+ are reserved for specific non-allowed combinations.
1624
+
1625
+ Args:
1626
+ csv_fn (str): output .csv file
1627
+ include_common_names (bool, optional): include a column for common names
1628
+
1629
+ Returns:
1630
+ dataframe: the pandas representation of the csv output file
1631
+ """
1632
+
1633
+ all_taxa = sorted(list(self.taxonomy_string_to_geofencing_rules.keys()))
1634
+ print('Preparing geofencing export for {} taxa'.format(len(all_taxa)))
1635
+
1636
+ all_regions = set()
1637
+
1638
+ # taxon = all_taxa[0]
1639
+ for taxon in all_taxa:
1640
+
1641
+ taxon_rules = self.taxonomy_string_to_geofencing_rules[taxon]
1642
+ for rule_type in taxon_rules.keys():
1643
+
1644
+ assert rule_type in ('allow','block')
1645
+ all_country_rules_this_species = taxon_rules[rule_type]
1646
+
1647
+ for country_code in all_country_rules_this_species.keys():
1648
+ all_regions.add(country_code)
1649
+ assert country_code in self.country_code_to_country
1650
+ assert len(country_code) == 3
1651
+ region_rules = all_country_rules_this_species[country_code]
1652
+ if len(region_rules) > 0:
1653
+ assert country_code == 'USA'
1654
+ for region_name in region_rules:
1655
+ assert len(region_name) == 2
1656
+ assert isinstance(region_name,str)
1657
+ all_regions.add(country_code + ':' + region_name)
1658
+
1659
+ all_regions = sorted(list(all_regions))
1660
+
1661
+ print('Found {} regions'.format(len(all_regions)))
1662
+
1663
+ n_allowed = 0
1664
+ df = pd.DataFrame(index=all_taxa,columns=all_regions)
1665
+ # df = df.fillna(np.nan)
1666
+
1667
+ for taxon in tqdm(all_taxa):
1668
+ for region in all_regions:
1669
+ tokens = region.split(':')
1670
+ country_code = tokens[0]
1671
+ state_code = None
1672
+ if len(tokens) > 1:
1673
+ state_code = tokens[1]
1674
+ allowed = self.species_allowed_in_country(species=taxon,
1675
+ country=country_code,
1676
+ state=state_code,
1677
+ return_status=False)
1678
+ if allowed:
1679
+ n_allowed += 1
1680
+ df.loc[taxon,region] = 1
1681
+
1682
+ # ...for each region
1683
+
1684
+ # ...for each taxon
1685
+
1686
+ print('Allowed {} of {} combinations'.format(n_allowed,len(all_taxa)*len(all_regions)))
1687
+
1688
+ # Before saving, convert columns with numeric values to integers
1689
+ for col in df.columns:
1690
+ # Check whether each column has any non-NaN values that could be integers
1691
+ if df[col].notna().any() and pd.to_numeric(df[col], errors='coerce').notna().any():
1692
+ # Convert column to Int64 type (pandas nullable integer type)
1693
+ df[col] = pd.to_numeric(df[col], errors='coerce').astype('Int64')
1694
+
1695
+ if include_common_names:
1696
+ df.insert(loc=0,column='common_name',value='')
1697
+ for taxon in all_taxa:
1698
+ if taxon in self.taxonomy_string_to_taxonomy_info:
1699
+ taxonomy_info = self.taxonomy_string_to_taxonomy_info[taxon]
1700
+ common_name = taxonomy_info['common_name']
1701
+ assert isinstance(common_name,str) and len(common_name) < 50
1702
+ df.loc[taxon,'common_name'] = common_name
1703
+
1704
+ if csv_fn is not None:
1705
+ df.to_csv(csv_fn,index=True,header=True)
1706
+
1707
+ return df
1708
+
1709
+ # ...def export_geofence_data_to_csv(...)
1710
+
1711
+ # ...class TaxonomyHandler