megadetector 5.0.15__py3-none-any.whl → 5.0.17__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 (34) hide show
  1. megadetector/data_management/importers/import_desert_lion_conservation_camera_traps.py +387 -0
  2. megadetector/data_management/importers/snapshot_safari_importer_reprise.py +28 -16
  3. megadetector/data_management/lila/generate_lila_per_image_labels.py +3 -3
  4. megadetector/data_management/lila/test_lila_metadata_urls.py +2 -2
  5. megadetector/data_management/remove_exif.py +61 -36
  6. megadetector/data_management/yolo_to_coco.py +25 -6
  7. megadetector/detection/process_video.py +270 -127
  8. megadetector/detection/pytorch_detector.py +13 -11
  9. megadetector/detection/run_detector.py +9 -2
  10. megadetector/detection/run_detector_batch.py +8 -1
  11. megadetector/detection/run_inference_with_yolov5_val.py +58 -10
  12. megadetector/detection/tf_detector.py +8 -2
  13. megadetector/detection/video_utils.py +214 -18
  14. megadetector/postprocessing/md_to_coco.py +31 -9
  15. megadetector/postprocessing/postprocess_batch_results.py +23 -7
  16. megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +5 -2
  17. megadetector/postprocessing/subset_json_detector_output.py +22 -12
  18. megadetector/taxonomy_mapping/map_new_lila_datasets.py +3 -3
  19. megadetector/taxonomy_mapping/prepare_lila_taxonomy_release.py +2 -1
  20. megadetector/taxonomy_mapping/preview_lila_taxonomy.py +1 -1
  21. megadetector/taxonomy_mapping/simple_image_download.py +5 -0
  22. megadetector/taxonomy_mapping/species_lookup.py +1 -1
  23. megadetector/utils/ct_utils.py +48 -0
  24. megadetector/utils/md_tests.py +231 -56
  25. megadetector/utils/path_utils.py +2 -2
  26. megadetector/utils/torch_test.py +32 -0
  27. megadetector/utils/url_utils.py +101 -4
  28. megadetector/visualization/visualization_utils.py +21 -6
  29. megadetector/visualization/visualize_db.py +16 -0
  30. {megadetector-5.0.15.dist-info → megadetector-5.0.17.dist-info}/LICENSE +0 -0
  31. {megadetector-5.0.15.dist-info → megadetector-5.0.17.dist-info}/METADATA +5 -7
  32. {megadetector-5.0.15.dist-info → megadetector-5.0.17.dist-info}/RECORD +34 -32
  33. {megadetector-5.0.15.dist-info → megadetector-5.0.17.dist-info}/WHEEL +1 -1
  34. {megadetector-5.0.15.dist-info → megadetector-5.0.17.dist-info}/top_level.txt +0 -0
@@ -114,6 +114,8 @@ DETECTION_FILENAME_INSERT = '_detections'
114
114
  # automatic model download to the system temp folder, or they will use the paths specified in the
115
115
  # $MDV4, $MDV5A, or $MDV5B environment variables if they exist.
116
116
  downloadable_models = {
117
+ 'MDV2':'https://lila.science/public/models/megadetector/megadetector_v2.pb',
118
+ 'MDV3':'https://lila.science/public/models/megadetector/megadetector_v3.pb',
117
119
  'MDV4':'https://github.com/agentmorris/MegaDetector/releases/download/v4.1/md_v4.1.0.pb',
118
120
  'MDV5A':'https://github.com/agentmorris/MegaDetector/releases/download/v5.0/md_v5a.0.0.pt',
119
121
  'MDV5B':'https://github.com/agentmorris/MegaDetector/releases/download/v5.0/md_v5b.0.0.pt'
@@ -197,7 +199,7 @@ def get_detector_metadata_from_version_string(detector_version):
197
199
  return DETECTOR_METADATA[detector_version]
198
200
 
199
201
 
200
- def get_detector_version_from_filename(detector_filename):
202
+ def get_detector_version_from_filename(detector_filename,accept_first_match=True):
201
203
  r"""
202
204
  Gets the version number component of the detector from the model filename.
203
205
 
@@ -215,6 +217,8 @@ def get_detector_version_from_filename(detector_filename):
215
217
 
216
218
  Args:
217
219
  detector_filename (str): model filename, e.g. c:/x/z/md_v5a.0.0.pt
220
+ accept_first_match (bool, optional): if multiple candidates match the filename, choose the
221
+ first one, otherwise returns the string "multiple"
218
222
 
219
223
  Returns:
220
224
  str: a detector version string, e.g. "v5a.0.0", or "multiple" if I'm confused
@@ -230,7 +234,10 @@ def get_detector_version_from_filename(detector_filename):
230
234
  return 'unknown'
231
235
  elif len(matches) > 1:
232
236
  print('Warning: multiple MegaDetector versions for model file {}'.format(detector_filename))
233
- return 'multiple'
237
+ if accept_first_match:
238
+ return model_string_to_model_version[matches[0]]
239
+ else:
240
+ return 'multiple'
234
241
  else:
235
242
  return model_string_to_model_version[matches[0]]
236
243
 
@@ -846,7 +846,7 @@ def write_results_to_file(results,
846
846
  https://github.com/agentmorris/MegaDetector/tree/main/megadetector/api/batch_processing#batch-processing-api-output-format
847
847
 
848
848
  Args:
849
- results (list): list of dict, each dict represents detections on one image
849
+ results (list): list of dict, each dict represents detections on one image
850
850
  output_file (str): path to JSON output file, should end in '.json'
851
851
  relative_path_base (str, optional): path to a directory as the base for relative paths, can
852
852
  be None if the paths in [results] are absolute
@@ -923,6 +923,13 @@ def write_results_to_file(results,
923
923
  'info': info
924
924
  }
925
925
 
926
+ # Create the folder where the output file belongs; this will fail if
927
+ # this is a relative path with no folder component
928
+ try:
929
+ os.makedirs(os.path.dirname(output_file),exist_ok=True)
930
+ except Exception:
931
+ pass
932
+
926
933
  with open(output_file, 'w') as f:
927
934
  json.dump(final_output, f, indent=1, default=str)
928
935
  print('Output file saved at {}'.format(output_file))
@@ -106,7 +106,9 @@ class YoloInferenceOptions:
106
106
 
107
107
  #: Image size to use; this is a single int, which in ultralytics's terminology means
108
108
  #: "scale the long side of the image to this size, and preserve aspect ratio".
109
- self.image_size = default_image_size_with_augmentation
109
+ #:
110
+ #: If None, will choose based on whether augmentation is enabled.
111
+ self.image_size = None
110
112
 
111
113
  #: Detections below this threshold will not be included in the output file
112
114
  self.conf_thres = '0.001'
@@ -276,10 +278,10 @@ def run_inference_with_yolo_val(options):
276
278
 
277
279
  if options.input_folder is not None:
278
280
  options.input_folder = options.input_folder.replace('\\','/')
281
+
279
282
 
280
-
281
283
  ##%% Other input handling
282
-
284
+
283
285
  if isinstance(options.yolo_category_id_to_name,str):
284
286
 
285
287
  assert os.path.isfile(options.yolo_category_id_to_name)
@@ -328,7 +330,9 @@ def run_inference_with_yolo_val(options):
328
330
  image_files_relative = None
329
331
  image_files_absolute = None
330
332
 
333
+ # If the caller just provided a folder, not a list of files...
331
334
  if options.image_filename_list is None:
335
+
332
336
  assert options.input_folder is not None and os.path.isdir(options.input_folder), \
333
337
  'Could not find input folder {}'.format(options.input_folder)
334
338
  image_files_relative = path_utils.find_images(options.input_folder,
@@ -337,18 +341,23 @@ def run_inference_with_yolo_val(options):
337
341
  convert_slashes=True)
338
342
  image_files_absolute = [os.path.join(options.input_folder,fn) for \
339
343
  fn in image_files_relative]
344
+
340
345
  else:
341
346
 
342
- if is_iterable(options.image_filename_list):
347
+ # If the caller provided a list of image files (rather than a filename pointing
348
+ # to a list of image files)...
349
+ if is_iterable(options.image_filename_list) and not isinstance(options.image_filename_list,str):
343
350
 
344
351
  image_files_relative = options.image_filename_list
345
352
 
353
+ # If the caller provided a filename pointing to a list of image files...
346
354
  else:
355
+
347
356
  assert isinstance(options.image_filename_list,str), \
348
357
  'Unrecognized image filename list object type: {}'.format(options.image_filename_list)
349
358
  assert os.path.isfile(options.image_filename_list), \
350
359
  'Could not find image filename list file: {}'.format(options.image_filename_list)
351
- ext = os.path.splitext(options.image_filename_list).lower()
360
+ ext = os.path.splitext(options.image_filename_list)[-1].lower()
352
361
  assert ext in ('.json','.txt'), \
353
362
  'Unrecognized image filename list file extension: {}'.format(options.image_filename_list)
354
363
  if ext == '.json':
@@ -364,8 +373,11 @@ def run_inference_with_yolo_val(options):
364
373
  # ...whether the image filename list was supplied as list vs. a filename
365
374
 
366
375
  if options.input_folder is None:
376
+
367
377
  image_files_absolute = image_files_relative
378
+
368
379
  else:
380
+
369
381
  # The list should be relative filenames
370
382
  for fn in image_files_relative:
371
383
  assert not path_is_abs(fn), \
@@ -373,12 +385,14 @@ def run_inference_with_yolo_val(options):
373
385
 
374
386
  image_files_absolute = \
375
387
  [os.path.join(options.input_folder,fn) for fn in image_files_relative]
388
+
376
389
  for fn in image_files_absolute:
377
390
  assert os.path.isfile(fn), 'Could not find image file {}'.format(fn)
378
391
 
379
392
  # ...whether the caller supplied a list of filenames
380
393
 
381
394
  image_files_absolute = [fn.replace('\\','/') for fn in image_files_absolute]
395
+
382
396
  del image_files_relative
383
397
 
384
398
 
@@ -549,6 +563,7 @@ def run_inference_with_yolo_val(options):
549
563
  for i_image,image_fn in tqdm(enumerate(image_files_absolute),total=len(image_files_absolute)):
550
564
 
551
565
  ext = os.path.splitext(image_fn)[1]
566
+ image_fn_without_extension = os.path.splitext(image_fn)[0]
552
567
 
553
568
  # YOLO .json output identifies images by the base filename without the extension
554
569
  image_id = str(i_image).zfill(10)
@@ -557,12 +572,25 @@ def run_inference_with_yolo_val(options):
557
572
  symlink_full_path = os.path.join(symlink_folder_inner,symlink_name)
558
573
  link_full_paths.append(symlink_full_path)
559
574
 
575
+ # If annotation files exist, link those too; only useful if we're reading the computed
576
+ # mAP value, but it doesn't hurt.
577
+ annotation_fn = image_fn_without_extension + '.txt'
578
+ annotation_file_exists = False
579
+ if os.path.isfile(annotation_fn):
580
+ annotation_file_exists = True
581
+ annotation_symlink_name = image_id + '.txt'
582
+ annotation_symlink_full_path = os.path.join(symlink_folder_inner,annotation_symlink_name)
583
+
560
584
  try:
561
585
 
562
586
  if options.use_symlinks:
563
587
  path_utils.safe_create_link(image_fn,symlink_full_path)
588
+ if annotation_file_exists:
589
+ path_utils.safe_create_link(annotation_fn,annotation_symlink_full_path)
564
590
  else:
565
591
  shutil.copyfile(image_fn,symlink_full_path)
592
+ if annotation_file_exists:
593
+ shutil.copyfile(annotation_fn,annotation_symlink_full_path)
566
594
 
567
595
  except Exception as e:
568
596
 
@@ -648,7 +676,15 @@ def run_inference_with_yolo_val(options):
648
676
 
649
677
  ##%% Prepare Python command or YOLO CLI command
650
678
 
651
- image_size_string = str(round(options.image_size))
679
+ if options.image_size is None:
680
+ if options.augment:
681
+ image_size = default_image_size_with_augmentation
682
+ else:
683
+ image_size = default_image_size_with_no_augmentation
684
+ else:
685
+ image_size = options.image_size
686
+
687
+ image_size_string = str(round(image_size))
652
688
 
653
689
  if options.model_type == 'yolov5':
654
690
 
@@ -659,6 +695,9 @@ def run_inference_with_yolo_val(options):
659
695
  cmd += ' --device "{}" --save-json'.format(options.device_string)
660
696
  cmd += ' --project "{}" --name "{}" --exist-ok'.format(yolo_results_folder,'yolo_results')
661
697
 
698
+ # This is the NMS IoU threshold
699
+ # cmd += ' --iou-thres 0.6'
700
+
662
701
  if options.augment:
663
702
  cmd += ' --augment'
664
703
 
@@ -837,7 +876,7 @@ def run_inference_with_yolo_val(options):
837
876
  _clean_up_temporary_folders(options,
838
877
  symlink_folder,yolo_results_folder,
839
878
  symlink_folder_is_temp_folder,yolo_folder_is_temp_folder)
840
-
879
+
841
880
  # ...def run_inference_with_yolo_val()
842
881
 
843
882
 
@@ -856,7 +895,7 @@ def main():
856
895
  help='model file name')
857
896
  parser.add_argument(
858
897
  'input_folder',type=str,
859
- help='folder on which to recursively run the model')
898
+ help='folder on which to recursively run the model, or a .json or .txt file containing a list of absolute image paths')
860
899
  parser.add_argument(
861
900
  'output_file',type=str,
862
901
  help='.json file where output will be written')
@@ -967,7 +1006,15 @@ def main():
967
1006
 
968
1007
  if args.yolo_dataset_file is not None:
969
1008
  options.yolo_category_id_to_name = args.yolo_dataset_file
970
- del options.yolo_dataset_file
1009
+
1010
+ # The function convention is that input_folder should be None when we want to use a list of
1011
+ # absolute paths, but the CLI convention is that the required argument is always valid, whether
1012
+ # it's a folder or a list of absolute paths.
1013
+ if os.path.isfile(options.input_folder):
1014
+ assert options.image_filename_list is None, \
1015
+ 'image_filename_list should not be specified when input_folder is a file'
1016
+ options.image_filename_list = options.input_folder
1017
+ options.input_folder = None
971
1018
 
972
1019
  options.recursive = (not options.nonrecursive)
973
1020
  options.remove_symlink_folder = (not options.no_remove_symlink_folder)
@@ -980,6 +1027,7 @@ def main():
980
1027
  del options.no_remove_yolo_results_folder
981
1028
  del options.no_use_symlinks
982
1029
  del options.augment_enabled
1030
+ del options.yolo_dataset_file
983
1031
 
984
1032
  print(options.__dict__)
985
1033
 
@@ -1001,7 +1049,7 @@ if False:
1001
1049
  yolo_working_folder = r'c:\git\yolov5-tegus'
1002
1050
  dataset_file = r'g:\temp\dataset.yaml'
1003
1051
 
1004
- # This only impacts the output file name, it's not passed to the inference functio
1052
+ # This only impacts the output file name, it's not passed to the inference function
1005
1053
  job_name = 'yolo-inference-test'
1006
1054
 
1007
1055
  model_name = os.path.splitext(os.path.basename(model_filename))[0]
@@ -110,7 +110,10 @@ class TFDetector:
110
110
  Runs the detector on a single image.
111
111
  """
112
112
 
113
- np_im = np.asarray(image, np.uint8)
113
+ if isinstance(image,np.ndarray):
114
+ np_im = image
115
+ else:
116
+ np_im = np.asarray(image, np.uint8)
114
117
  im_w_batch_dim = np.expand_dims(np_im, axis=0)
115
118
 
116
119
  # need to change the above line to the following if supporting a batch size > 1 and resizing to the same size
@@ -136,7 +139,8 @@ class TFDetector:
136
139
  Runs the detector on an image.
137
140
 
138
141
  Args:
139
- image (Image): the PIL Image object on which we should run the detector
142
+ image (Image): the PIL Image object (or numpy array) on which we should run the detector, with
143
+ EXIF rotation already handled.
140
144
  image_id (str): a path to identify the image; will be in the "file" field of the output object
141
145
  detection_threshold (float): only detections above this threshold will be included in the return
142
146
  value
@@ -166,6 +170,7 @@ class TFDetector:
166
170
  result = { 'file': image_id }
167
171
 
168
172
  try:
173
+
169
174
  b_box, b_score, b_class = self._generate_detections_one_image(image)
170
175
 
171
176
  # our batch size is 1; need to loop the batch dim if supporting batch size > 1
@@ -190,6 +195,7 @@ class TFDetector:
190
195
  result['detections'] = detections_cur_image
191
196
 
192
197
  except Exception as e:
198
+
193
199
  result['failure'] = FAILURE_INFER
194
200
  print('TFDetector: image {} failed during inference: {}'.format(image_id, str(e)))
195
201
 
@@ -22,6 +22,7 @@ from functools import partial
22
22
  from inspect import signature
23
23
 
24
24
  from megadetector.utils import path_utils
25
+ from megadetector.utils.ct_utils import sort_list_of_dicts_by_key
25
26
  from megadetector.visualization import visualization_utils as vis_utils
26
27
 
27
28
  default_fourcc = 'h264'
@@ -88,14 +89,14 @@ def find_videos(dirname,
88
89
  else:
89
90
  files = glob.glob(os.path.join(dirname, '*.*'))
90
91
 
92
+ files = [fn for fn in files if os.path.isfile(fn)]
93
+
91
94
  if return_relative_paths:
92
95
  files = [os.path.relpath(fn,dirname) for fn in files]
93
96
 
94
97
  if convert_slashes:
95
98
  files = [fn.replace('\\', '/') for fn in files]
96
99
 
97
- files = [fn for fn in files if os.path.isfile(fn)]
98
-
99
100
  return find_video_strings(files)
100
101
 
101
102
 
@@ -197,7 +198,7 @@ def _filename_to_frame_number(filename):
197
198
  def _add_frame_numbers_to_results(results):
198
199
  """
199
200
  Given the 'images' list from a set of MD results that was generated on video frames,
200
- add a 'frame_number' field to each image.
201
+ add a 'frame_number' field to each image, and return the list, sorted by frame number.
201
202
 
202
203
  Args:
203
204
  results (list): list of image dicts
@@ -208,8 +209,186 @@ def _add_frame_numbers_to_results(results):
208
209
  fn = im['file']
209
210
  frame_number = _filename_to_frame_number(fn)
210
211
  im['frame_number'] = frame_number
212
+
213
+ results = sort_list_of_dicts_by_key(results,'frame_number')
214
+ return results
215
+
216
+
217
+ def run_callback_on_frames(input_video_file,
218
+ frame_callback,
219
+ every_n_frames=None,
220
+ verbose=False,
221
+ frames_to_process=None,
222
+ allow_empty_videos=False):
223
+ """
224
+ Calls the function frame_callback(np.array,image_id) on all (or selected) frames in
225
+ [input_video_file].
226
+
227
+ Args:
228
+ input_video_file (str): video file to process
229
+ frame_callback (function): callback to run on frames, should take an np.array and a string and
230
+ return a single value. callback should expect PIL-formatted (RGB) images.
231
+ every_n_frames (int, optional): sample every Nth frame starting from the first frame;
232
+ if this is None or 1, every frame is processed. Mutually exclusive with
233
+ frames_to_process.
234
+ verbose (bool, optional): enable additional debug console output
235
+ frames_to_process (list of int, optional): process this specific set of frames;
236
+ mutually exclusive with every_n_frames. If all values are beyond the length
237
+ of the video, no frames are extracted. Can also be a single int, specifying
238
+ a single frame number.
239
+ allow_empty_videos (bool, optional): Just print a warning if a video appears to have no
240
+ frames (by default, this is an error).
241
+
242
+ Returns:
243
+ dict: dict with keys 'frame_filenames' (list), 'frame_rate' (float), 'results' (list).
244
+ 'frame_filenames' are synthetic filenames (e.g. frame000000.jpg); 'results' are
245
+ in the same format used in the 'images' array in the MD results format.
246
+ """
247
+
248
+ assert os.path.isfile(input_video_file), 'File {} not found'.format(input_video_file)
249
+
250
+ if isinstance(frames_to_process,int):
251
+ frames_to_process = [frames_to_process]
252
+
253
+ if (frames_to_process is not None) and (every_n_frames is not None):
254
+ raise ValueError('frames_to_process and every_n_frames are mutually exclusive')
255
+
256
+ vidcap = cv2.VideoCapture(input_video_file)
257
+ n_frames = int(vidcap.get(cv2.CAP_PROP_FRAME_COUNT))
258
+ frame_rate = vidcap.get(cv2.CAP_PROP_FPS)
259
+
260
+ if verbose:
261
+ print('Video {} contains {} frames at {} Hz'.format(input_video_file,n_frames,frame_rate))
262
+
263
+ frame_filenames = []
264
+ results = []
265
+
266
+ # frame_number = 0
267
+ for frame_number in range(0,n_frames):
268
+
269
+ success,image = vidcap.read()
270
+
271
+ if not success:
272
+ assert image is None
273
+ if verbose:
274
+ print('Read terminating at frame {} of {}'.format(frame_number,n_frames))
275
+ break
276
+
277
+ if every_n_frames is not None:
278
+ if frame_number % every_n_frames != 0:
279
+ continue
280
+
281
+ if frames_to_process is not None:
282
+ if frame_number > max(frames_to_process):
283
+ break
284
+ if frame_number not in frames_to_process:
285
+ continue
286
+
287
+ frame_filename_relative = _frame_number_to_filename(frame_number)
288
+ frame_filenames.append(frame_filename_relative)
289
+
290
+ image_np = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
291
+ frame_results = frame_callback(image_np,frame_filename_relative)
292
+ results.append(frame_results)
293
+
294
+ # ...for each frame
295
+
296
+ if len(frame_filenames) == 0:
297
+ if allow_empty_videos:
298
+ print('Warning: found no frames in file {}'.format(input_video_file))
299
+ else:
300
+ raise Exception('Error: found no frames in file {}'.format(input_video_file))
301
+
302
+ if verbose:
303
+ print('\nProcessed {} of {} frames for {}'.format(
304
+ len(frame_filenames),n_frames,input_video_file))
305
+
306
+ vidcap.release()
307
+ to_return = {}
308
+ to_return['frame_filenames'] = frame_filenames
309
+ to_return['frame_rate'] = frame_rate
310
+ to_return['results'] = results
311
+
312
+ return to_return
313
+
314
+ # ...def run_callback_on_frames(...)
315
+
316
+
317
+ def run_callback_on_frames_for_folder(input_video_folder,
318
+ frame_callback,
319
+ every_n_frames=None,
320
+ verbose=False,
321
+ allow_empty_videos=False,
322
+ recursive=True):
323
+ """
324
+ Calls the function frame_callback(np.array,image_id) on all (or selected) frames in
325
+ all videos in [input_video_folder].
326
+
327
+ Args:
328
+ input_video_folder (str): video folder to process
329
+ frame_callback (function): callback to run on frames, should take an np.array and a string and
330
+ return a single value. callback should expect PIL-formatted (RGB) images.
331
+ every_n_frames (int, optional): sample every Nth frame starting from the first frame;
332
+ if this is None or 1, every frame is processed.
333
+ verbose (bool, optional): enable additional debug console output
334
+ allow_empty_videos (bool, optional): Just print a warning if a video appears to have no
335
+ frames (by default, this is an error).
336
+ recursive (bool, optional): recurse into [input_video_folder]
337
+
338
+ Returns:
339
+ dict: dict with keys 'video_filenames' (list of str), 'frame_rates' (list of floats),
340
+ 'results' (list of list of dicts). 'video_filenames' will contain *relative* filenames.
341
+ """
342
+
343
+ to_return = {'video_filenames':[],'frame_rates':[],'results':[]}
344
+
345
+ # Recursively enumerate video files
346
+ input_files_full_paths = find_videos(input_video_folder,
347
+ recursive=recursive,
348
+ convert_slashes=True,
349
+ return_relative_paths=False)
350
+ print('Found {} videos in folder {}'.format(len(input_files_full_paths),input_video_folder))
351
+
352
+ if len(input_files_full_paths) == 0:
353
+ return to_return
354
+
355
+ # Process each video
356
+
357
+ # video_fn_abs = input_files_full_paths[0]
358
+ for video_fn_abs in tqdm(input_files_full_paths):
359
+ video_results = run_callback_on_frames(input_video_file=video_fn_abs,
360
+ frame_callback=frame_callback,
361
+ every_n_frames=every_n_frames,
362
+ verbose=verbose,
363
+ frames_to_process=None,
364
+ allow_empty_videos=allow_empty_videos)
365
+
366
+ """
367
+ dict: dict with keys 'frame_filenames' (list), 'frame_rate' (float), 'results' (list).
368
+ 'frame_filenames' are synthetic filenames (e.g. frame000000.jpg); 'results' are
369
+ in the same format used in the 'images' array in the MD results format.
370
+ """
371
+ video_filename_relative = os.path.relpath(video_fn_abs,input_video_folder)
372
+ video_filename_relative = video_filename_relative.replace('\\','/')
373
+ to_return['video_filenames'].append(video_filename_relative)
374
+ to_return['frame_rates'].append(video_results['frame_rate'])
375
+ for r in video_results['results']:
376
+ assert r['file'].startswith('frame')
377
+ r['file'] = video_filename_relative + '/' + r['file']
378
+ to_return['results'].append(video_results['results'])
379
+
380
+ # ...for each video
381
+
382
+ n_videos = len(input_files_full_paths)
383
+ assert len(to_return['video_filenames']) == n_videos
384
+ assert len(to_return['frame_rates']) == n_videos
385
+ assert len(to_return['results']) == n_videos
211
386
 
387
+ return to_return
212
388
 
389
+ # ...def run_callback_on_frames_for_folder(...)
390
+
391
+
213
392
  def video_to_frames(input_video_file,
214
393
  output_folder,
215
394
  overwrite=True,
@@ -220,7 +399,7 @@ def video_to_frames(input_video_file,
220
399
  frames_to_extract=None,
221
400
  allow_empty_videos=False):
222
401
  """
223
- Renders frames from [input_video_file] to a .jpg in [output_folder].
402
+ Renders frames from [input_video_file] to .jpg files in [output_folder].
224
403
 
225
404
  With help from:
226
405
 
@@ -341,7 +520,7 @@ def video_to_frames(input_video_file,
341
520
  # ...if we need to check whether to skip this video entirely
342
521
 
343
522
  if verbose:
344
- print('Reading {} frames at {} Hz from {}'.format(n_frames,Fs,input_video_file))
523
+ print('Video {} contains {} frames at {} Hz'.format(input_video_file,n_frames,Fs))
345
524
 
346
525
  frame_filenames = []
347
526
 
@@ -410,8 +589,8 @@ def video_to_frames(input_video_file,
410
589
 
411
590
  # ...if we need to deal with resizing
412
591
 
413
- frame_filename = _frame_number_to_filename(frame_number)
414
- frame_filename = os.path.join(output_folder,frame_filename)
592
+ frame_filename_relative = _frame_number_to_filename(frame_number)
593
+ frame_filename = os.path.join(output_folder,frame_filename_relative)
415
594
  frame_filenames.append(frame_filename)
416
595
 
417
596
  if overwrite == False and os.path.isfile(frame_filename):
@@ -441,9 +620,13 @@ def video_to_frames(input_video_file,
441
620
  except Exception as e:
442
621
  print('Error on frame {} of {}: {}'.format(frame_number,n_frames,str(e)))
443
622
 
623
+ # ...for each frame
624
+
444
625
  if len(frame_filenames) == 0:
445
- raise Exception('Error: found no frames in file {}'.format(
446
- input_video_file))
626
+ if allow_empty_videos:
627
+ print('Warning: found no frames in file {}'.format(input_video_file))
628
+ else:
629
+ raise Exception('Error: found no frames in file {}'.format(input_video_file))
447
630
 
448
631
  if verbose:
449
632
  print('\nExtracted {} of {} frames for {}'.format(
@@ -457,7 +640,7 @@ def video_to_frames(input_video_file,
457
640
 
458
641
  def _video_to_frames_for_folder(relative_fn,input_folder,output_folder_base,
459
642
  every_n_frames,overwrite,verbose,quality,max_width,
460
- frames_to_extract):
643
+ frames_to_extract,allow_empty_videos):
461
644
  """
462
645
  Internal function to call video_to_frames for a single video in the context of
463
646
  video_folder_to_frames; makes sure the right output folder exists, then calls
@@ -474,10 +657,15 @@ def _video_to_frames_for_folder(relative_fn,input_folder,output_folder_base,
474
657
 
475
658
  # Render frames
476
659
  # input_video_file = input_fn_absolute; output_folder = output_folder_video
477
- frame_filenames,fs = video_to_frames(input_fn_absolute,output_folder_video,
478
- overwrite=overwrite,every_n_frames=every_n_frames,
479
- verbose=verbose,quality=quality,max_width=max_width,
480
- frames_to_extract=frames_to_extract)
660
+ frame_filenames,fs = video_to_frames(input_fn_absolute,
661
+ output_folder_video,
662
+ overwrite=overwrite,
663
+ every_n_frames=every_n_frames,
664
+ verbose=verbose,
665
+ quality=quality,
666
+ max_width=max_width,
667
+ frames_to_extract=frames_to_extract,
668
+ allow_empty_videos=allow_empty_videos)
481
669
 
482
670
  return frame_filenames,fs
483
671
 
@@ -487,7 +675,7 @@ def video_folder_to_frames(input_folder, output_folder_base,
487
675
  n_threads=1, every_n_frames=None,
488
676
  verbose=False, parallelization_uses_threads=True,
489
677
  quality=None, max_width=None,
490
- frames_to_extract=None):
678
+ frames_to_extract=None, allow_empty_videos=False):
491
679
  """
492
680
  For every video file in input_folder, creates a folder within output_folder_base, and
493
681
  renders frame of that video to images in that folder.
@@ -545,7 +733,7 @@ def video_folder_to_frames(input_folder, output_folder_base,
545
733
  frame_filenames,fs = \
546
734
  _video_to_frames_for_folder(input_fn_relative,input_folder,output_folder_base,
547
735
  every_n_frames,overwrite,verbose,quality,max_width,
548
- frames_to_extract)
736
+ frames_to_extract,allow_empty_videos)
549
737
  frame_filenames_by_video.append(frame_filenames)
550
738
  fs_by_video.append(fs)
551
739
  else:
@@ -563,7 +751,8 @@ def video_folder_to_frames(input_folder, output_folder_base,
563
751
  verbose=verbose,
564
752
  quality=quality,
565
753
  max_width=max_width,
566
- frames_to_extract=frames_to_extract)
754
+ frames_to_extract=frames_to_extract,
755
+ allow_empty_videos=allow_empty_videos)
567
756
  results = list(tqdm(pool.imap(
568
757
  partial(process_video_with_options),input_files_relative_paths),
569
758
  total=len(input_files_relative_paths)))
@@ -592,7 +781,8 @@ class FrameToVideoOptions:
592
781
  self.non_video_behavior = 'error'
593
782
 
594
783
 
595
- def frame_results_to_video_results(input_file,output_file,options=None):
784
+ def frame_results_to_video_results(input_file,output_file,options=None,
785
+ video_filename_to_frame_rate=None):
596
786
  """
597
787
  Given an MD results file produced at the *frame* level, corresponding to a directory
598
788
  created with video_folder_to_frames, maps those frame-level results back to the
@@ -605,6 +795,8 @@ def frame_results_to_video_results(input_file,output_file,options=None):
605
795
  output_file (str): the .json file to which we should write video-level results
606
796
  options (FrameToVideoOptions, optional): parameters for converting frame-level results
607
797
  to video-level results, see FrameToVideoOptions for details
798
+ video_filename_to_frame_rate (dict): maps (relative) video path names to frame rates,
799
+ used only to populate the output file
608
800
  """
609
801
 
610
802
  if options is None:
@@ -693,6 +885,10 @@ def frame_results_to_video_results(input_file,output_file,options=None):
693
885
  im_out['file'] = video_name
694
886
  im_out['detections'] = canonical_detections
695
887
 
888
+ if (video_filename_to_frame_rate is not None) and \
889
+ (video_name in video_filename_to_frame_rate):
890
+ im_out['frame_rate'] = video_filename_to_frame_rate[video_name]
891
+
696
892
  # 'max_detection_conf' is no longer included in output files by default
697
893
  if False:
698
894
  im_out['max_detection_conf'] = 0