megadetector 5.0.6__py3-none-any.whl → 5.0.7__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 (62) hide show
  1. api/batch_processing/data_preparation/manage_local_batch.py +278 -197
  2. api/batch_processing/data_preparation/manage_video_batch.py +7 -2
  3. api/batch_processing/postprocessing/add_max_conf.py +1 -0
  4. api/batch_processing/postprocessing/compare_batch_results.py +110 -60
  5. api/batch_processing/postprocessing/load_api_results.py +55 -69
  6. api/batch_processing/postprocessing/md_to_labelme.py +1 -0
  7. api/batch_processing/postprocessing/postprocess_batch_results.py +158 -50
  8. api/batch_processing/postprocessing/render_detection_confusion_matrix.py +625 -0
  9. api/batch_processing/postprocessing/repeat_detection_elimination/find_repeat_detections.py +71 -23
  10. api/batch_processing/postprocessing/repeat_detection_elimination/remove_repeat_detections.py +1 -1
  11. api/batch_processing/postprocessing/repeat_detection_elimination/repeat_detections_core.py +222 -74
  12. api/batch_processing/postprocessing/subset_json_detector_output.py +132 -5
  13. api/batch_processing/postprocessing/top_folders_to_bottom.py +1 -1
  14. classification/prepare_classification_script.py +191 -191
  15. data_management/coco_to_yolo.py +65 -44
  16. data_management/databases/integrity_check_json_db.py +7 -5
  17. data_management/generate_crops_from_cct.py +1 -1
  18. data_management/importers/animl_results_to_md_results.py +2 -2
  19. data_management/importers/noaa_seals_2019.py +1 -1
  20. data_management/importers/zamba_results_to_md_results.py +2 -2
  21. data_management/labelme_to_coco.py +34 -6
  22. data_management/labelme_to_yolo.py +1 -1
  23. data_management/lila/create_lila_blank_set.py +474 -0
  24. data_management/lila/create_lila_test_set.py +2 -1
  25. data_management/lila/create_links_to_md_results_files.py +1 -1
  26. data_management/lila/download_lila_subset.py +46 -21
  27. data_management/lila/generate_lila_per_image_labels.py +23 -14
  28. data_management/lila/get_lila_annotation_counts.py +16 -10
  29. data_management/lila/lila_common.py +14 -11
  30. data_management/lila/test_lila_metadata_urls.py +116 -0
  31. data_management/resize_coco_dataset.py +12 -10
  32. data_management/yolo_output_to_md_output.py +40 -13
  33. data_management/yolo_to_coco.py +34 -21
  34. detection/process_video.py +36 -14
  35. detection/pytorch_detector.py +1 -1
  36. detection/run_detector.py +73 -18
  37. detection/run_detector_batch.py +104 -24
  38. detection/run_inference_with_yolov5_val.py +127 -26
  39. detection/run_tiled_inference.py +153 -43
  40. detection/video_utils.py +3 -1
  41. md_utils/ct_utils.py +79 -3
  42. md_utils/md_tests.py +253 -15
  43. md_utils/path_utils.py +129 -24
  44. md_utils/process_utils.py +26 -7
  45. md_utils/split_locations_into_train_val.py +215 -0
  46. md_utils/string_utils.py +10 -0
  47. md_utils/url_utils.py +0 -2
  48. md_utils/write_html_image_list.py +1 -0
  49. md_visualization/visualization_utils.py +17 -2
  50. md_visualization/visualize_db.py +8 -0
  51. md_visualization/visualize_detector_output.py +185 -104
  52. {megadetector-5.0.6.dist-info → megadetector-5.0.7.dist-info}/METADATA +2 -2
  53. {megadetector-5.0.6.dist-info → megadetector-5.0.7.dist-info}/RECORD +62 -58
  54. {megadetector-5.0.6.dist-info → megadetector-5.0.7.dist-info}/WHEEL +1 -1
  55. taxonomy_mapping/map_lila_taxonomy_to_wi_taxonomy.py +1 -1
  56. taxonomy_mapping/map_new_lila_datasets.py +43 -39
  57. taxonomy_mapping/prepare_lila_taxonomy_release.py +5 -2
  58. taxonomy_mapping/preview_lila_taxonomy.py +27 -27
  59. taxonomy_mapping/species_lookup.py +33 -13
  60. taxonomy_mapping/taxonomy_csv_checker.py +7 -5
  61. {megadetector-5.0.6.dist-info → megadetector-5.0.7.dist-info}/LICENSE +0 -0
  62. {megadetector-5.0.6.dist-info → megadetector-5.0.7.dist-info}/top_level.txt +0 -0
@@ -49,6 +49,7 @@ from tqdm import tqdm
49
49
 
50
50
  from md_utils import path_utils
51
51
  from md_utils import process_utils
52
+ from md_utils import string_utils
52
53
  from data_management import yolo_output_to_md_output
53
54
  from detection.run_detector import try_download_known_detector
54
55
 
@@ -68,17 +69,20 @@ class YoloInferenceOptions:
68
69
 
69
70
  ## Optional ##
70
71
 
71
- # Required for YOLOv5 models, not for YOLOv8 models
72
+ # Required for older YOLOv5 inference, not for newer ulytralytics inference
72
73
  yolo_working_folder = None
73
74
 
74
- model_type = 'yolov5' # currently 'yolov5' and 'yolov8' are supported
75
+ # Currently 'yolov5' and 'ultralytics' are supported, and really these are proxies for
76
+ # "the yolov5 repo" and "the ultralytics repo" (typically YOLOv8).
77
+ model_type = 'yolov5'
75
78
 
76
79
  image_size = default_image_size_with_augmentation
77
80
  conf_thres = '0.001'
78
81
  batch_size = 1
79
82
  device_string = '0'
80
83
  augment = True
81
-
84
+ half_precision_enabled = None
85
+
82
86
  symlink_folder = None
83
87
  use_symlinks = True
84
88
 
@@ -97,16 +101,28 @@ class YoloInferenceOptions:
97
101
  overwrite_handling = 'skip'
98
102
 
99
103
  preview_yolo_command_only = False
104
+
105
+ treat_copy_failures_as_warnings = False
106
+
107
+ save_yolo_debug_output = False
100
108
 
101
109
 
102
110
  #%% Main function
103
111
 
104
112
  def run_inference_with_yolo_val(options):
105
113
 
106
- ##%% Path handling
114
+ ##%% Input and path handling
115
+
116
+ if options.model_type == 'yolov8':
117
+
118
+ print('Warning: model type "yolov8" supplied, "ultralytics" is the preferred model type string for YOLOv8 models')
119
+ options.model_type = 'ultralytics'
120
+
121
+ if (options.model_type == 'yolov5') and ('yolov8' in options.model_filename.lower()):
122
+ print('\n\n*** Warning: model type set as "yolov5", but your model filename contains "yolov8"... did you mean to use --model_type yolov8?" ***\n\n')
107
123
 
108
124
  if options.yolo_working_folder is None:
109
- assert options.model_type == 'yolov8', \
125
+ assert options.model_type == 'ultralytics', \
110
126
  'A working folder is required to run YOLOv5 val.py'
111
127
  else:
112
128
  assert os.path.isdir(options.yolo_working_folder), \
@@ -115,6 +131,11 @@ def run_inference_with_yolo_val(options):
115
131
  assert os.path.isdir(options.input_folder) or os.path.isfile(options.input_folder), \
116
132
  'Could not find input {}'.format(options.input_folder)
117
133
 
134
+ if options.half_precision_enabled is not None:
135
+ assert options.half_precision_enabled in (0,1), \
136
+ 'Invalid value {} for --half_precision_enabled (should be 0 or 1)'.format(
137
+ options.half_precision_enabled)
138
+
118
139
  # If the model filename is a known model string (e.g. "MDv5A", download the model if necessary)
119
140
  model_filename = try_download_known_detector(options.model_filename)
120
141
 
@@ -218,10 +239,20 @@ def run_inference_with_yolo_val(options):
218
239
  else:
219
240
  shutil.copyfile(image_fn,symlink_full_path)
220
241
  except Exception as e:
221
- image_id_to_error[image_id] = str(e)
222
- print('Warning: error copying/creating link for input file {}: {}'.format(
223
- image_fn,str(e)))
224
- continue
242
+ error_string = str(e)
243
+ image_id_to_error[image_id] = error_string
244
+ # Always break if the user is trying to create symlinks on Windows without
245
+ # permission, 100% of images will always fail in this case.
246
+ if ('a required privilege is not held by the client' in error_string.lower()) or \
247
+ (not options.treat_copy_failures_as_warnings):
248
+ print('\nError copying/creating link for input file {}: {}'.format(
249
+ image_fn,error_string))
250
+
251
+ raise
252
+ else:
253
+ print('Warning: error copying/creating link for input file {}: {}'.format(
254
+ image_fn,error_string))
255
+ continue
225
256
 
226
257
  # ...for each image
227
258
 
@@ -270,17 +301,34 @@ def run_inference_with_yolo_val(options):
270
301
  if options.augment:
271
302
  cmd += ' --augment'
272
303
 
273
- elif options.model_type == 'yolov8':
304
+ # --half is a store_true argument for YOLOv5's val.py
305
+ if (options.half_precision_enabled is not None) and (options.half_precision_enabled == 1):
306
+ cmd += ' --half'
307
+
308
+ # Sometimes useful for debugging
309
+ # cmd += ' --save_conf --save_txt'
274
310
 
311
+ elif options.model_type == 'ultralytics':
312
+
275
313
  if options.augment:
276
314
  augment_string = 'augment'
277
315
  else:
278
316
  augment_string = ''
279
317
 
280
- cmd = 'yolo val {} model="{}" imgsz={} batch={} data="{}" project="{}" name="{}"'.format(
281
- augment_string,model_filename,image_size_string,options.batch_size,yolo_dataset_file,
282
- yolo_results_folder,'yolo_results')
283
- cmd += ' save_hybrid save_json'
318
+ cmd = 'yolo val {} model="{}" imgsz={} batch={} data="{}" project="{}" name="{}" device="{}"'.\
319
+ format(augment_string,model_filename,image_size_string,options.batch_size,
320
+ yolo_dataset_file,yolo_results_folder,'yolo_results',options.device_string)
321
+ cmd += ' save_json exist_ok'
322
+
323
+ if (options.half_precision_enabled is not None):
324
+ if options.half_precision_enabled == 1:
325
+ cmd += ' --half=True'
326
+ else:
327
+ assert options.half_precision_enabled == 0
328
+ cmd += ' --half=False'
329
+
330
+ # Sometimes useful for debugging
331
+ # cmd += ' save_conf save_txt'
284
332
 
285
333
  else:
286
334
 
@@ -293,38 +341,84 @@ def run_inference_with_yolo_val(options):
293
341
 
294
342
  if options.yolo_working_folder is not None:
295
343
  current_dir = os.getcwd()
296
- os.chdir(options.yolo_working_folder)
344
+ os.chdir(options.yolo_working_folder)
345
+
297
346
  print('Running YOLO inference command:\n{}\n'.format(cmd))
298
347
 
299
348
  if options.preview_yolo_command_only:
349
+
300
350
  if options.remove_symlink_folder:
301
351
  try:
352
+ print('Removing YOLO symlink folder {}'.format(symlink_folder))
302
353
  shutil.rmtree(symlink_folder)
303
354
  except Exception:
304
355
  print('Warning: error removing symlink folder {}'.format(symlink_folder))
305
356
  pass
306
357
  if options.remove_yolo_results_folder:
307
358
  try:
359
+ print('Removing YOLO results folder {}'.format(yolo_results_folder))
308
360
  shutil.rmtree(yolo_results_folder)
309
361
  except Exception:
310
362
  print('Warning: error removing YOLO results folder {}'.format(yolo_results_folder))
311
363
  pass
312
364
 
313
365
  sys.exit()
314
-
315
- execution_result = process_utils.execute_and_print(cmd)
366
+
367
+ execution_result = process_utils.execute_and_print(cmd,encoding='utf-8',verbose=True)
316
368
  assert execution_result['status'] == 0, 'Error running {}'.format(options.model_type)
317
369
  yolo_console_output = execution_result['output']
370
+
371
+ if options.save_yolo_debug_output:
372
+ with open(os.path.join(yolo_results_folder,'yolo_console_output.txt'),'w') as f:
373
+ for s in yolo_console_output:
374
+ f.write(s + '\n')
375
+ with open(os.path.join(yolo_results_folder,'image_id_to_file.json'),'w') as f:
376
+ json.dump(image_id_to_file,f,indent=1)
377
+ with open(os.path.join(yolo_results_folder,'image_id_to_error.json'),'w') as f:
378
+ json.dump(image_id_to_error,f,indent=1)
379
+
380
+
381
+ # YOLO console output contains lots of ANSI escape codes, remove them for easier parsing
382
+ yolo_console_output = [string_utils.remove_ansi_codes(s) for s in yolo_console_output]
318
383
 
384
+ # Find errors that occrred during the initial corruption check; these will not be included in the
385
+ # output. Errors that occur during inference will be handled separately.
319
386
  yolo_read_failures = []
387
+
320
388
  for line in yolo_console_output:
389
+ # Lines look like:
390
+ #
391
+ # For ultralytics val:
392
+ #
393
+ # val: WARNING ⚠️ /a/b/c/d.jpg: ignoring corrupt image/label: [Errno 13] Permission denied: '/a/b/c/d.jpg'
394
+ # line = "val: WARNING ⚠️ /a/b/c/d.jpg: ignoring corrupt image/label: [Errno 13] Permission denied: '/a/b/c/d.jpg'"
395
+ #
396
+ # For yolov5 val.py:
397
+ #
398
+ # test: WARNING: a/b/c/d.jpg: ignoring corrupt image/label: cannot identify image file '/a/b/c/d.jpg'
399
+ # line = "test: WARNING: a/b/c/d.jpg: ignoring corrupt image/label: cannot identify image file '/a/b/c/d.jpg'"
321
400
  if 'cannot identify image file' in line:
322
401
  tokens = line.split('cannot identify image file')
323
402
  image_name = tokens[-1].strip()
324
403
  assert image_name[0] == "'" and image_name [-1] == "'"
325
404
  image_name = image_name[1:-1]
326
405
  yolo_read_failures.append(image_name)
327
-
406
+ elif 'ignoring corrupt image/label' in line:
407
+ assert 'WARNING' in line
408
+ if '⚠️' in line:
409
+ assert line.startswith('val'), \
410
+ 'Unrecognized line in YOLO output: {}'.format(line)
411
+ tokens = line.split('ignoring corrupt image/label')
412
+ image_name = tokens[0].split('⚠️')[-1].strip()
413
+ else:
414
+ assert line.startswith('test'), \
415
+ 'Unrecognized line in YOLO output: {}'.format(line)
416
+ tokens = line.split('ignoring corrupt image/label')
417
+ image_name = tokens[0].split('WARNING:')[-1].strip()
418
+ assert image_name.endswith(':')
419
+ image_name = image_name[0:-1]
420
+ yolo_read_failures.append(image_name)
421
+
328
422
  # image_file = yolo_read_failures[0]
329
423
  for image_file in yolo_read_failures:
330
424
  image_id = os.path.splitext(os.path.basename(image_file))[0]
@@ -338,7 +432,7 @@ def run_inference_with_yolo_val(options):
338
432
 
339
433
  ##%% Convert results to MD format
340
434
 
341
- json_files = glob.glob(yolo_results_folder+ '/yolo_results/*.json')
435
+ json_files = glob.glob(yolo_results_folder + '/yolo_results/*.json')
342
436
  assert len(json_files) == 1
343
437
  yolo_json_file = json_files[0]
344
438
 
@@ -390,7 +484,7 @@ def run_inference_with_yolo_val(options):
390
484
 
391
485
  #%% Command-line driver
392
486
 
393
- import argparse,sys
487
+ import argparse
394
488
  from md_utils.ct_utils import args_to_object
395
489
 
396
490
  def main():
@@ -422,9 +516,12 @@ def main():
422
516
  parser.add_argument(
423
517
  '--batch_size', default=options.batch_size, type=int,
424
518
  help='inference batch size (default {})'.format(options.batch_size))
519
+ parser.add_argument(
520
+ '--half_precision_enabled', default=None, type=int,
521
+ help='use half-precision-inference (1 or 0) (default is the underlying model\'s default, probably half for YOLOv8 and full for YOLOv8')
425
522
  parser.add_argument(
426
523
  '--device_string', default=options.device_string, type=str,
427
- help='CUDA device specifier, e.g. "0" or "cpu" (default {})'.format(options.device_string))
524
+ help='CUDA device specifier, typically "0" or "1" for CUDA devices, "mps" for M1/M2 devices, or "cpu" (default {})'.format(options.device_string))
428
525
  parser.add_argument(
429
526
  '--overwrite_handling', default=options.overwrite_handling, type=str,
430
527
  help='action to take if the output file exists (skip, error, overwrite) (default {})'.format(
@@ -435,7 +532,7 @@ def main():
435
532
  '(otherwise defaults to MD categories)')
436
533
  parser.add_argument(
437
534
  '--model_type', default=options.model_type, type=str,
438
- help='Model type (yolov5 or yolov8) (default {})'.format(options.model_type))
535
+ help='Model type ("yolov5" or "ultralytics" ("yolov8" behaves the same as "ultralytics")) (default {})'.format(options.model_type))
439
536
 
440
537
  parser.add_argument(
441
538
  '--symlink_folder', type=str,
@@ -452,6 +549,9 @@ def main():
452
549
  parser.add_argument(
453
550
  '--no_remove_yolo_results_folder', action='store_true',
454
551
  help='don\'t remove the temporary folder full of YOLO intermediate files')
552
+ parser.add_argument(
553
+ '--save_yolo_debug_output', action='store_true',
554
+ help='write yolo console output to a text file in the results folder, along with additional debug files')
455
555
 
456
556
  parser.add_argument(
457
557
  '--preview_yolo_command_only', action='store_true',
@@ -474,14 +574,15 @@ def main():
474
574
 
475
575
  # If the caller hasn't specified an image size, choose one based on whether augmentation
476
576
  # is enabled.
477
- if args.image_size is None:
478
- assert options.augment in (0,1)
479
- if options.augment == 1:
577
+ if args.image_size is None:
578
+ assert args.augment_enabled in (0,1), \
579
+ 'Illegal augment_enabled value {}'.format(args.augment_enabled)
580
+ if args.augment_enabled == 1:
480
581
  args.image_size = default_image_size_with_augmentation
481
582
  else:
482
583
  args.image_size = default_image_size_with_no_augmentation
483
584
  augment_enabled_string = 'enabled'
484
- if not options.augment:
585
+ if not args.augment_enabled:
485
586
  augment_enabled_string = 'disabled'
486
587
  print('Augmentation is {}, using default image size {}'.format(
487
588
  augment_enabled_string,args.image_size))
@@ -29,6 +29,7 @@ from tqdm import tqdm
29
29
 
30
30
  from detection.run_inference_with_yolov5_val import YoloInferenceOptions,run_inference_with_yolo_val
31
31
  from detection.run_detector_batch import load_and_run_detector_batch,write_results_to_file
32
+ from detection.run_detector import try_download_known_detector
32
33
 
33
34
  import torch
34
35
  from torchvision import ops
@@ -234,7 +235,7 @@ def in_place_nms(md_results, iou_thres=0.45, verbose=True):
234
235
  # i_image = 18; im = md_results['images'][i_image]
235
236
  for i_image,im in tqdm(enumerate(md_results['images']),total=len(md_results['images'])):
236
237
 
237
- if len(im['detections']) == 0:
238
+ if (im['detections'] is None) or (len(im['detections']) == 0):
238
239
  continue
239
240
 
240
241
  boxes = []
@@ -282,40 +283,52 @@ def in_place_nms(md_results, iou_thres=0.45, verbose=True):
282
283
 
283
284
  def _extract_tiles_for_image(fn_relative,image_folder,tiling_folder,patch_size,patch_stride,overwrite):
284
285
  """
285
- Extract tiles for a single image
286
+ Private function to extract tiles for a single image.
286
287
 
287
- Not really a standalone function; isolated from the main function to simplify
288
- multiprocessing.
288
+ Returns a dict with fields 'patches' (see extract_patch_from_image) and 'image_fn'.
289
+
290
+ If there is an error, 'patches' will be None and the 'error' field will contain
291
+ failure details. In that case, some tiles may still be generated.
289
292
  """
290
293
 
291
294
  fn_abs = os.path.join(image_folder,fn_relative)
295
+ error = None
296
+ patches = []
292
297
 
293
298
  image_name = path_utils.clean_filename(fn_relative,char_limit=None,force_lower=True)
294
299
 
295
- # Open the image
296
- im = vis_utils.open_image(fn_abs)
297
- image_size = [im.width,im.height]
300
+ try:
301
+
302
+ # Open the image
303
+ im = vis_utils.open_image(fn_abs)
304
+ image_size = [im.width,im.height]
305
+
306
+ # Generate patch boundaries (a list of [x,y] starting points)
307
+ patch_boundaries = get_patch_boundaries(image_size,patch_size,patch_stride)
308
+
309
+ # Extract patches
310
+ #
311
+ # patch_xy = patch_boundaries[0]
312
+ for patch_xy in patch_boundaries:
298
313
 
299
- # Generate patch boundaries (a list of [x,y] starting points)
300
- patch_boundaries = get_patch_boundaries(image_size,patch_size,patch_stride)
301
-
302
- # Extract patches
303
- #
304
- # patch_xy = patch_boundaries[0]
305
- patches = []
306
-
307
- for patch_xy in patch_boundaries:
314
+ patch_info = extract_patch_from_image(im,patch_xy,patch_size,
315
+ patch_folder=tiling_folder,
316
+ image_name=image_name,
317
+ overwrite=overwrite)
318
+ patch_info['source_fn'] = fn_relative
319
+ patches.append(patch_info)
308
320
 
309
- patch_info = extract_patch_from_image(im,patch_xy,patch_size,
310
- patch_folder=tiling_folder,
311
- image_name=image_name,
312
- overwrite=overwrite)
313
- patch_info['source_fn'] = fn_relative
314
- patches.append(patch_info)
321
+ except Exception as e:
322
+
323
+ s = 'Patch generation error for {}: \n{}'.format(fn_relative,str(e))
324
+ print(s)
325
+ # patches = None
326
+ error = s
315
327
 
316
328
  image_patch_info = {}
317
329
  image_patch_info['patches'] = patches
318
330
  image_patch_info['image_fn'] = fn_relative
331
+ image_patch_info['error'] = error
319
332
 
320
333
  return image_patch_info
321
334
 
@@ -327,7 +340,8 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
327
340
  checkpoint_path=None, checkpoint_frequency=-1, remove_tiles=False,
328
341
  yolo_inference_options=None,
329
342
  n_patch_extraction_workers=default_n_patch_extraction_workers,
330
- overwrite_tiles=True):
343
+ overwrite_tiles=True,
344
+ image_list=None):
331
345
  """
332
346
  Run inference using [model_file] on the images in [image_folder], fist splitting each image up
333
347
  into tiles of size [tile_size_x] x [tile_size_y], writing those tiles to [tiling_folder],
@@ -337,7 +351,8 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
337
351
  [tiling_folder] can be any folder, but this function reserves the right to do whatever it wants
338
352
  within that folder, including deleting everything, so it's best if it's a new folder.
339
353
  Conceptually this folder is temporary, it's just helpful in this case to not actually
340
- use the system temp folder, because the tile cache may be very large,
354
+ use the system temp folder, because the tile cache may be very large, so the caller may
355
+ want it to be on a specific drive.
341
356
 
342
357
  tile_overlap is the fraction of overlap between tiles.
343
358
 
@@ -346,25 +361,54 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
346
361
  if yolo_inference_options is supplied, it should be an instance of YoloInferenceOptions; in
347
362
  this case the model will be run with run_inference_with_yolov5_val. This is typically used to
348
363
  run the model with test-time augmentation.
349
- """
364
+ """
350
365
 
351
366
  ##%% Validate arguments
352
367
 
353
368
  assert tile_overlap < 1 and tile_overlap >= 0, \
354
369
  'Illegal tile overlap value {}'.format(tile_overlap)
355
370
 
371
+ if tile_size_x == -1:
372
+ tile_size_x = default_tile_size[0]
373
+ if tile_size_y == -1:
374
+ tile_size_y = default_tile_size[1]
375
+
356
376
  patch_size = [tile_size_x,tile_size_y]
357
377
  patch_stride = (round(patch_size[0]*(1.0-tile_overlap)),
358
378
  round(patch_size[1]*(1.0-tile_overlap)))
359
379
 
360
380
  os.makedirs(tiling_folder,exist_ok=True)
361
381
 
362
-
363
382
  ##%% List files
364
383
 
365
- image_files_relative = path_utils.find_images(image_folder, recursive=True, return_relative_paths=True)
366
- assert len(image_files_relative) > 0, 'No images found in folder {}'.format(image_folder)
367
-
384
+ if image_list is None:
385
+
386
+ print('Enumerating images in {}'.format(image_folder))
387
+ image_files_relative = path_utils.find_images(image_folder, recursive=True, return_relative_paths=True)
388
+ assert len(image_files_relative) > 0, 'No images found in folder {}'.format(image_folder)
389
+
390
+ else:
391
+
392
+ print('Loading image list from {}'.format(image_list))
393
+ with open(image_list,'r') as f:
394
+ image_files_relative = json.load(f)
395
+ n_absolute_paths = 0
396
+ for i_fn,fn in enumerate(image_files_relative):
397
+ if os.path.isabs(fn):
398
+ n_absolute_paths += 1
399
+ try:
400
+ fn_relative = os.path.relpath(fn,image_folder)
401
+ except ValueError:
402
+ 'Illegal absolute path supplied to run_tiled_inference, {} is outside of {}'.format(
403
+ fn,image_folder)
404
+ raise
405
+ assert not fn_relative.startswith('..'), \
406
+ 'Illegal absolute path supplied to run_tiled_inference, {} is outside of {}'.format(
407
+ fn,image_folder)
408
+ image_files_relative[i_fn] = fn_relative
409
+ if (n_absolute_paths != 0) and (n_absolute_paths != len(image_files_relative)):
410
+ raise ValueError('Illegal file list: converted {} of {} paths to relative'.format(
411
+ n_absolute_paths,len(image_files_relative)))
368
412
 
369
413
  ##%% Generate tiles
370
414
 
@@ -414,7 +458,7 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
414
458
  image_files_relative),total=len(image_files_relative)))
415
459
 
416
460
  # ...for each image
417
-
461
+
418
462
  # Write tile information to file; this is just a debugging convenience
419
463
  folder_name = path_utils.clean_filename(image_folder,force_lower=True)
420
464
  if folder_name.startswith('_'):
@@ -424,9 +468,16 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
424
468
  with open(tile_cache_file,'w') as f:
425
469
  json.dump(all_image_patch_info,f,indent=1)
426
470
 
471
+ # Keep track of patches that failed
472
+ images_with_patch_errors = {}
473
+ for patch_info in all_image_patch_info:
474
+ if patch_info['error'] is not None:
475
+ images_with_patch_errors[patch_info['image_fn']] = patch_info
476
+
427
477
 
428
478
  ##%% Run inference on tiles
429
479
 
480
+ # When running with run_inference_with_yolov5_val, we'll pass the folder
430
481
  if yolo_inference_options is not None:
431
482
 
432
483
  patch_level_output_file = os.path.join(tiling_folder,folder_name + '_patch_level_results.json')
@@ -444,11 +495,16 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
444
495
  run_inference_with_yolo_val(yolo_inference_options)
445
496
  with open(patch_level_output_file,'r') as f:
446
497
  patch_level_results = json.load(f)
447
-
498
+
499
+ # For standard inference, we'll pass a list of files
448
500
  else:
449
501
 
450
502
  patch_file_names = []
451
503
  for im in all_image_patch_info:
504
+ # If there was a patch generation error, don't run inference
505
+ if patch_info['error'] is not None:
506
+ assert im['image_fn'] in images_with_patch_errors
507
+ continue
452
508
  for patch in im['patches']:
453
509
  patch_file_names.append(patch['patch_fn'])
454
510
 
@@ -481,18 +537,44 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
481
537
  image_fn_relative_to_patch_info = { x['image_fn']:x for x in all_image_patch_info }
482
538
 
483
539
  # i_image = 0; image_fn_relative = image_files_relative[i_image]
484
- for i_image,image_fn_relative in tqdm(enumerate(image_files_relative),total=len(image_files_relative)):
540
+ for i_image,image_fn_relative in tqdm(enumerate(image_files_relative),
541
+ total=len(image_files_relative)):
485
542
 
486
543
  image_fn_abs = os.path.join(image_folder,image_fn_relative)
487
544
  assert os.path.isfile(image_fn_abs)
488
545
 
489
546
  output_im = {}
490
547
  output_im['file'] = image_fn_relative
491
- output_im['detections'] = []
548
+
549
+ # If we had a patch generation error
550
+ if image_fn_relative in images_with_patch_errors:
492
551
 
493
- pil_im = vis_utils.open_image(image_fn_abs)
494
- image_w = pil_im.size[0]
495
- image_h = pil_im.size[1]
552
+ patch_info = image_fn_relative_to_patch_info[image_fn_relative]
553
+ assert patch_info['error'] is not None
554
+
555
+ output_im['detections'] = None
556
+ output_im['failure'] = 'Patch generation error'
557
+ output_im['failure_details'] = patch_info['error']
558
+ image_level_results['images'].append(output_im)
559
+ continue
560
+
561
+ try:
562
+ pil_im = vis_utils.open_image(image_fn_abs)
563
+ image_w = pil_im.size[0]
564
+ image_h = pil_im.size[1]
565
+
566
+ # This would be a very unusual situation; we're reading back an image here that we already
567
+ # (successfully) read once during patch generation.
568
+ except Exception as e:
569
+ print('Warning: image read error after successful patch generation for {}:\n{}'.format(
570
+ image_fn_relative,str(e)))
571
+ output_im['detections'] = None
572
+ output_im['failure'] = 'Patch processing error'
573
+ output_im['failure_details'] = str(e)
574
+ image_level_results['images'].append(output_im)
575
+ continue
576
+
577
+ output_im['detections'] = []
496
578
 
497
579
  image_patch_info = image_fn_relative_to_patch_info[image_fn_relative]
498
580
  assert image_patch_info['patches'][0]['source_fn'] == image_fn_relative
@@ -520,6 +602,14 @@ def run_tiled_inference(model_file, image_folder, tiling_folder, output_file,
520
602
  assert patch_w == patch_size[0]
521
603
  assert patch_h == patch_size[1]
522
604
 
605
+ # If there was an inference failure on one patch, report the image
606
+ # as an inference failure
607
+ if 'detections' not in patch_results:
608
+ assert 'failure' in patch_results
609
+ output_im['detections'] = None
610
+ output_im['failure'] = patch_results['failure']
611
+ break
612
+
523
613
  # det = patch_results['detections'][0]
524
614
  for det in patch_results['detections']:
525
615
 
@@ -703,7 +793,7 @@ def main():
703
793
  help='Path to detector model file (.pb or .pt)')
704
794
  parser.add_argument(
705
795
  'image_folder',
706
- help='Folder containing images for inference (always recursive)')
796
+ help='Folder containing images for inference (always recursive, unless image_list is supplied)')
707
797
  parser.add_argument(
708
798
  'tiling_folder',
709
799
  help='Temporary folder where tiles and intermediate results will be stored')
@@ -729,6 +819,16 @@ def main():
729
819
  type=float,
730
820
  default=default_patch_overlap,
731
821
  help=('Overlap between tiles [0,1] (defaults to {})'.format(default_patch_overlap)))
822
+ parser.add_argument(
823
+ '--overwrite_handling',
824
+ type=str,
825
+ default='skip',
826
+ help=('behavior when the targt file exists (skip/overwrite/error) (default skip)'))
827
+ parser.add_argument(
828
+ '--image_list',
829
+ type=str,
830
+ default=None,
831
+ help=('a .json list of relative filenames (or absolute paths contained within image_folder) to include'))
732
832
 
733
833
  if len(sys.argv[1:]) == 0:
734
834
  parser.print_help()
@@ -736,19 +836,29 @@ def main():
736
836
 
737
837
  args = parser.parse_args()
738
838
 
739
- assert os.path.exists(args.model_file), \
839
+ model_file = try_download_known_detector(args.model_file)
840
+ assert os.path.exists(model_file), \
740
841
  'detector file {} does not exist'.format(args.model_file)
741
-
842
+
742
843
  if os.path.exists(args.output_file):
743
- print('Warning: output_file {} already exists and will be overwritten'.format(
744
- args.output_file))
844
+ if args.overwrite_handling == 'skip':
845
+ print('Warning: output file {} exists, skipping'.format(args.output_file))
846
+ return
847
+ elif args.overwrite_handling == 'overwrite':
848
+ print('Warning: output file {} exists, overwriting'.format(args.output_file))
849
+ elif args.overwrite_handling == 'error':
850
+ raise ValueError('Output file {} exists'.format(args.output_file))
851
+ else:
852
+ raise ValueError('Unknown output handling method {}'.format(args.overwrite_handling))
853
+
745
854
 
746
855
  remove_tiles = (not args.no_remove_tiles)
747
856
 
748
- run_tiled_inference(args.model_file, args.image_folder, args.tiling_folder, args.output_file,
857
+ run_tiled_inference(model_file, args.image_folder, args.tiling_folder, args.output_file,
749
858
  tile_size_x=args.tile_size_x, tile_size_y=args.tile_size_y,
750
859
  tile_overlap=args.tile_overlap,
751
- remove_tiles=remove_tiles)
860
+ remove_tiles=remove_tiles,
861
+ image_list=args.image_list)
752
862
 
753
863
  if __name__ == '__main__':
754
864
  main()
detection/video_utils.py CHANGED
@@ -24,6 +24,8 @@ from md_utils import path_utils
24
24
 
25
25
  from md_visualization import visualization_utils as vis_utils
26
26
 
27
+ default_fourcc = 'h264'
28
+
27
29
 
28
30
  #%% Path utilities
29
31
 
@@ -76,7 +78,7 @@ def find_videos(dirname: str, recursive: bool = False,
76
78
 
77
79
  # http://tsaith.github.io/combine-images-into-a-video-with-python-3-and-opencv-3.html
78
80
 
79
- def frames_to_video(images, Fs, output_file_name, codec_spec='h264'):
81
+ def frames_to_video(images, Fs, output_file_name, codec_spec=default_fourcc):
80
82
  """
81
83
  Given a list of image files and a sample rate, concatenate those images into
82
84
  a video and write to [output_file_name].