megadetector 5.0.12__py3-none-any.whl → 5.0.14__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.
- megadetector/api/batch_processing/api_core/server.py +1 -1
- megadetector/api/batch_processing/api_core/server_api_config.py +0 -1
- megadetector/api/batch_processing/api_core/server_job_status_table.py +0 -3
- megadetector/api/batch_processing/api_core/server_utils.py +0 -4
- megadetector/api/batch_processing/integration/eMammal/test_scripts/push_annotations_to_emammal.py +0 -1
- megadetector/api/synchronous/api_core/animal_detection_api/api_frontend.py +0 -3
- megadetector/classification/efficientnet/utils.py +0 -3
- megadetector/data_management/camtrap_dp_to_coco.py +0 -2
- megadetector/data_management/cct_json_utils.py +15 -6
- megadetector/data_management/coco_to_labelme.py +12 -1
- megadetector/data_management/databases/integrity_check_json_db.py +43 -27
- megadetector/data_management/importers/cacophony-thermal-importer.py +1 -4
- megadetector/data_management/ocr_tools.py +0 -4
- megadetector/data_management/read_exif.py +178 -44
- megadetector/data_management/rename_images.py +187 -0
- megadetector/data_management/wi_download_csv_to_coco.py +3 -2
- megadetector/data_management/yolo_output_to_md_output.py +7 -2
- megadetector/detection/process_video.py +548 -244
- megadetector/detection/pytorch_detector.py +33 -14
- megadetector/detection/run_detector.py +17 -5
- megadetector/detection/run_detector_batch.py +179 -65
- megadetector/detection/run_inference_with_yolov5_val.py +527 -357
- megadetector/detection/tf_detector.py +14 -3
- megadetector/detection/video_utils.py +284 -61
- megadetector/postprocessing/categorize_detections_by_size.py +16 -14
- megadetector/postprocessing/classification_postprocessing.py +716 -0
- megadetector/postprocessing/compare_batch_results.py +101 -93
- megadetector/postprocessing/convert_output_format.py +12 -5
- megadetector/postprocessing/merge_detections.py +18 -7
- megadetector/postprocessing/postprocess_batch_results.py +133 -127
- megadetector/postprocessing/repeat_detection_elimination/repeat_detections_core.py +236 -232
- megadetector/postprocessing/subset_json_detector_output.py +66 -62
- megadetector/taxonomy_mapping/preview_lila_taxonomy.py +0 -2
- megadetector/utils/ct_utils.py +5 -4
- megadetector/utils/md_tests.py +380 -128
- megadetector/utils/path_utils.py +39 -6
- megadetector/utils/process_utils.py +13 -4
- megadetector/visualization/visualization_utils.py +7 -2
- megadetector/visualization/visualize_db.py +79 -77
- megadetector/visualization/visualize_detector_output.py +0 -1
- {megadetector-5.0.12.dist-info → megadetector-5.0.14.dist-info}/LICENSE +0 -0
- {megadetector-5.0.12.dist-info → megadetector-5.0.14.dist-info}/METADATA +2 -2
- {megadetector-5.0.12.dist-info → megadetector-5.0.14.dist-info}/RECORD +45 -43
- {megadetector-5.0.12.dist-info → megadetector-5.0.14.dist-info}/top_level.txt +0 -0
- {megadetector-5.0.12.dist-info → megadetector-5.0.14.dist-info}/WHEEL +0 -0
megadetector/utils/md_tests.py
CHANGED
|
@@ -25,70 +25,90 @@ import urllib.request
|
|
|
25
25
|
import zipfile
|
|
26
26
|
import subprocess
|
|
27
27
|
import argparse
|
|
28
|
+
import inspect
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
#%% Classes
|
|
31
32
|
|
|
32
33
|
class MDTestOptions:
|
|
33
34
|
"""
|
|
34
|
-
Options controlling test behavior
|
|
35
|
+
Options controlling test behavior
|
|
35
36
|
"""
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
def __init__(self):
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
disable_gpu = False
|
|
40
|
+
## Required ##
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
42
|
+
#: Force CPU execution
|
|
43
|
+
self.disable_gpu = False
|
|
44
|
+
|
|
45
|
+
#: If GPU execution is requested, but a GPU is not available, should we error?
|
|
46
|
+
self.cpu_execution_is_error = False
|
|
47
|
+
|
|
48
|
+
#: Skip tests related to video processing
|
|
49
|
+
self.skip_video_tests = False
|
|
50
|
+
|
|
51
|
+
#: Skip tests launched via Python functions (as opposed to CLIs)
|
|
52
|
+
self.skip_python_tests = False
|
|
53
|
+
|
|
54
|
+
#: Skip CLI tests
|
|
55
|
+
self.skip_cli_tests = False
|
|
56
|
+
|
|
57
|
+
#: Force a specific folder for temporary input/output
|
|
58
|
+
self.scratch_dir = None
|
|
59
|
+
|
|
60
|
+
#: Where does the test data live?
|
|
61
|
+
self.test_data_url = 'https://lila.science/public/md-test-package.zip'
|
|
62
|
+
|
|
63
|
+
#: Download test data even if it appears to have already been downloaded
|
|
64
|
+
self.force_data_download = False
|
|
65
|
+
|
|
66
|
+
#: Unzip test data even if it appears to have already been unzipped
|
|
67
|
+
self.force_data_unzip = False
|
|
68
|
+
|
|
69
|
+
#: By default, any unexpected behavior is an error; this forces most errors to
|
|
70
|
+
#: be treated as warnings.
|
|
71
|
+
self.warning_mode = False
|
|
72
|
+
|
|
73
|
+
#: How much deviation from the expected detection coordinates should we allow before
|
|
74
|
+
#: a disrepancy becomes an error?
|
|
75
|
+
self.max_coord_error = 0.001
|
|
76
|
+
|
|
77
|
+
#: How much deviation from the expected confidence values should we allow before
|
|
78
|
+
#: a disrepancy becomes an error?
|
|
79
|
+
self.max_conf_error = 0.005
|
|
80
|
+
|
|
81
|
+
#: Current working directory when running CLI tests
|
|
82
|
+
#:
|
|
83
|
+
#: If this is None, we won't mess with the inherited working directory.
|
|
84
|
+
self.cli_working_dir = None
|
|
85
|
+
|
|
86
|
+
#: YOLOv5 installation, only relevant if we're testing run_inference_with_yolov5_val.
|
|
87
|
+
#:
|
|
88
|
+
#: If this is None, we'll skip that test.
|
|
89
|
+
self.yolo_working_dir = None
|
|
90
|
+
|
|
91
|
+
#: fourcc code to use for video tests that involve rendering video
|
|
92
|
+
self.video_fourcc = 'mp4v'
|
|
93
|
+
|
|
94
|
+
#: Default model to use for testing (filename, URL, or well-known model string)
|
|
95
|
+
self.default_model = 'MDV5A'
|
|
96
|
+
|
|
97
|
+
#: For comparison tests, use a model that produces slightly different output
|
|
98
|
+
self.alt_model = 'MDV5B'
|
|
99
|
+
|
|
100
|
+
#: PYTHONPATH to set for CLI tests; if None, inherits from the parent process. Only
|
|
101
|
+
#: impacts the called functions, not the parent process.
|
|
102
|
+
self.cli_test_pythonpath = None
|
|
85
103
|
|
|
86
104
|
# ...class MDTestOptions()
|
|
87
105
|
|
|
88
106
|
|
|
89
107
|
#%% Support functions
|
|
90
108
|
|
|
91
|
-
def get_expected_results_filename(gpu_is_available
|
|
109
|
+
def get_expected_results_filename(gpu_is_available,
|
|
110
|
+
model_string='mdv5a',
|
|
111
|
+
test_type='images'):
|
|
92
112
|
"""
|
|
93
113
|
Expected results vary just a little across inference environments, particularly
|
|
94
114
|
between PT 1.x and 2.x, so when making sure things are working acceptably, we
|
|
@@ -124,12 +144,13 @@ def get_expected_results_filename(gpu_is_available):
|
|
|
124
144
|
import torch
|
|
125
145
|
m1_inference = torch.backends.mps.is_built and torch.backends.mps.is_available()
|
|
126
146
|
if m1_inference:
|
|
147
|
+
print('I appear to be running on M1/M2 hardware, using pt1/cpu as the reference results')
|
|
127
148
|
hw_string = 'cpu'
|
|
128
|
-
|
|
149
|
+
pt_string = 'pt1.10.1'
|
|
129
150
|
except Exception:
|
|
130
151
|
pass
|
|
131
152
|
|
|
132
|
-
return '
|
|
153
|
+
return '{}-{}-results-{}-{}.json'.format(model_string,test_type,hw_string,pt_string)
|
|
133
154
|
|
|
134
155
|
|
|
135
156
|
def download_test_data(options=None):
|
|
@@ -215,6 +236,8 @@ def download_test_data(options=None):
|
|
|
215
236
|
options.test_images = [fn for fn in test_files if os.path.splitext(fn.lower())[1] in ('.jpg','.jpeg','.png')]
|
|
216
237
|
options.test_videos = [fn for fn in test_files if os.path.splitext(fn.lower())[1] in ('.mp4','.avi')]
|
|
217
238
|
options.test_videos = [fn for fn in options.test_videos if 'rendered' not in fn]
|
|
239
|
+
options.test_videos = [fn for fn in options.test_videos if \
|
|
240
|
+
os.path.isfile(os.path.join(scratch_dir,fn))]
|
|
218
241
|
|
|
219
242
|
print('Finished unzipping and enumerating test data')
|
|
220
243
|
|
|
@@ -257,7 +280,82 @@ def is_gpu_available(verbose=True):
|
|
|
257
280
|
print('No GPU available')
|
|
258
281
|
|
|
259
282
|
return gpu_available
|
|
283
|
+
|
|
284
|
+
# ...def is_gpu_available(...)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def output_files_are_identical(fn1,fn2,verbose=False):
|
|
288
|
+
"""
|
|
289
|
+
Checks whether two MD-formatted output files are identical other than file sorting.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
fn1 (str): the first filename to compare
|
|
293
|
+
fn2 (str): the second filename to compare
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
bool: whether [fn1] and [fn2] are identical other than file sorting.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
if verbose:
|
|
300
|
+
print('Comparing {} to {}'.format(fn1,fn2))
|
|
301
|
+
|
|
302
|
+
with open(fn1,'r') as f:
|
|
303
|
+
fn1_results = json.load(f)
|
|
304
|
+
fn1_results['images'] = \
|
|
305
|
+
sorted(fn1_results['images'], key=lambda d: d['file'])
|
|
306
|
+
|
|
307
|
+
with open(fn2,'r') as f:
|
|
308
|
+
fn2_results = json.load(f)
|
|
309
|
+
fn2_results['images'] = \
|
|
310
|
+
sorted(fn2_results['images'], key=lambda d: d['file'])
|
|
311
|
+
|
|
312
|
+
if len(fn1_results['images']) != len(fn1_results['images']):
|
|
313
|
+
if verbose:
|
|
314
|
+
print('{} images in {}, {} images in {}'.format(
|
|
315
|
+
len(fn1_results['images']),fn1,
|
|
316
|
+
len(fn2_results['images']),fn2))
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
# i_image = 0; fn1_image = fn1_results['images'][i_image]
|
|
320
|
+
for i_image,fn1_image in enumerate(fn1_results['images']):
|
|
321
|
+
|
|
322
|
+
fn2_image = fn2_results['images'][i_image]
|
|
323
|
+
|
|
324
|
+
if fn1_image['file'] != fn2_image['file']:
|
|
325
|
+
if verbose:
|
|
326
|
+
print('Filename difference at {}: {} vs {} '.format(i_image,fn1_image['file'],fn1_image['file']))
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
if fn1_image != fn2_image:
|
|
330
|
+
if verbose:
|
|
331
|
+
print('Image-level difference in image {}: {}'.format(i_image,fn1_image['file']))
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
# ...def output_files_are_identical(...)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _args_to_object(args, obj):
|
|
340
|
+
"""
|
|
341
|
+
Copies all fields from a Namespace (typically the output from parse_args) to an
|
|
342
|
+
object. Skips fields starting with _. Does not check existence in the target
|
|
343
|
+
object.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
args (argparse.Namespace): the namespace to convert to an object
|
|
347
|
+
obj (object): object whose whose attributes will be updated
|
|
260
348
|
|
|
349
|
+
Returns:
|
|
350
|
+
object: the modified object (modified in place, but also returned)
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
for n, v in inspect.getmembers(args):
|
|
354
|
+
if not n.startswith('_'):
|
|
355
|
+
setattr(obj, n, v)
|
|
356
|
+
|
|
357
|
+
return obj
|
|
358
|
+
|
|
261
359
|
|
|
262
360
|
#%% CLI functions
|
|
263
361
|
|
|
@@ -289,7 +387,7 @@ def execute(cmd):
|
|
|
289
387
|
return return_code
|
|
290
388
|
|
|
291
389
|
|
|
292
|
-
def execute_and_print(cmd,print_output=True):
|
|
390
|
+
def execute_and_print(cmd,print_output=True,catch_exceptions=False,echo_command=True):
|
|
293
391
|
"""
|
|
294
392
|
Runs [cmd] (a single string) in a shell, capturing (and optionally printing) output.
|
|
295
393
|
|
|
@@ -302,8 +400,11 @@ def execute_and_print(cmd,print_output=True):
|
|
|
302
400
|
(the content of stdout)
|
|
303
401
|
"""
|
|
304
402
|
|
|
403
|
+
if echo_command:
|
|
404
|
+
print('Running command:\n{}\n'.format(cmd))
|
|
405
|
+
|
|
305
406
|
to_return = {'status':'unknown','output':''}
|
|
306
|
-
output=[]
|
|
407
|
+
output = []
|
|
307
408
|
try:
|
|
308
409
|
for s in execute(cmd):
|
|
309
410
|
output.append(s)
|
|
@@ -311,6 +412,8 @@ def execute_and_print(cmd,print_output=True):
|
|
|
311
412
|
print(s,end='',flush=True)
|
|
312
413
|
to_return['status'] = 0
|
|
313
414
|
except subprocess.CalledProcessError as cpe:
|
|
415
|
+
if not catch_exceptions:
|
|
416
|
+
raise
|
|
314
417
|
print('execute_and_print caught error: {}'.format(cpe.output))
|
|
315
418
|
to_return['status'] = cpe.returncode
|
|
316
419
|
to_return['output'] = output
|
|
@@ -339,9 +442,8 @@ def run_python_tests(options):
|
|
|
339
442
|
|
|
340
443
|
from megadetector.detection import run_detector
|
|
341
444
|
from megadetector.visualization import visualization_utils as vis_utils
|
|
342
|
-
model_file = 'MDV5A'
|
|
343
445
|
image_fn = os.path.join(options.scratch_dir,options.test_images[0])
|
|
344
|
-
model = run_detector.load_detector(
|
|
446
|
+
model = run_detector.load_detector(options.default_model)
|
|
345
447
|
pil_im = vis_utils.load_image(image_fn)
|
|
346
448
|
result = model.generate_detections_one_image(pil_im) # noqa
|
|
347
449
|
|
|
@@ -355,9 +457,9 @@ def run_python_tests(options):
|
|
|
355
457
|
assert os.path.isdir(image_folder), 'Test image folder {} is not available'.format(image_folder)
|
|
356
458
|
inference_output_file = os.path.join(options.scratch_dir,'folder_inference_output.json')
|
|
357
459
|
image_file_names = path_utils.find_images(image_folder,recursive=True)
|
|
358
|
-
results = load_and_run_detector_batch(
|
|
460
|
+
results = load_and_run_detector_batch(options.default_model, image_file_names, quiet=True)
|
|
359
461
|
_ = write_results_to_file(results,inference_output_file,
|
|
360
|
-
relative_path_base=image_folder,detector_file=
|
|
462
|
+
relative_path_base=image_folder,detector_file=options.default_model)
|
|
361
463
|
|
|
362
464
|
# Read results
|
|
363
465
|
with open(inference_output_file,'r') as f:
|
|
@@ -459,11 +561,11 @@ def run_python_tests(options):
|
|
|
459
561
|
assert os.path.isfile(postprocessing_results.output_html_file), \
|
|
460
562
|
'Postprocessing output file {} not found'.format(postprocessing_results.output_html_file)
|
|
461
563
|
|
|
462
|
-
|
|
564
|
+
|
|
463
565
|
## Partial RDE test
|
|
464
566
|
|
|
465
567
|
from megadetector.postprocessing.repeat_detection_elimination.repeat_detections_core import \
|
|
466
|
-
RepeatDetectionOptions,find_repeat_detections
|
|
568
|
+
RepeatDetectionOptions, find_repeat_detections
|
|
467
569
|
|
|
468
570
|
rde_options = RepeatDetectionOptions()
|
|
469
571
|
rde_options.occurrenceThreshold = 2
|
|
@@ -477,9 +579,69 @@ def run_python_tests(options):
|
|
|
477
579
|
'Could not find RDE output file {}'.format(rde_results.filterFile)
|
|
478
580
|
|
|
479
581
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
582
|
+
## Run inference on a folder (with YOLOv5 val script)
|
|
583
|
+
|
|
584
|
+
if options.yolo_working_dir is None:
|
|
585
|
+
|
|
586
|
+
print('Skipping YOLO val inference tests, no YOLO folder supplied')
|
|
587
|
+
|
|
588
|
+
else:
|
|
589
|
+
|
|
590
|
+
from megadetector.detection.run_inference_with_yolov5_val import \
|
|
591
|
+
YoloInferenceOptions, run_inference_with_yolo_val
|
|
592
|
+
|
|
593
|
+
inference_output_file_yolo_val = os.path.join(options.scratch_dir,'folder_inference_output_yolo_val.json')
|
|
594
|
+
|
|
595
|
+
yolo_inference_options = YoloInferenceOptions()
|
|
596
|
+
yolo_inference_options.input_folder = os.path.join(options.scratch_dir,'md-test-images')
|
|
597
|
+
yolo_inference_options.output_file = inference_output_file_yolo_val
|
|
598
|
+
yolo_inference_options.yolo_working_folder = options.yolo_working_dir
|
|
599
|
+
yolo_inference_options.model_filename = options.default_model
|
|
600
|
+
yolo_inference_options.augment = False
|
|
601
|
+
yolo_inference_options.overwrite_handling = 'overwrite'
|
|
602
|
+
|
|
603
|
+
run_inference_with_yolo_val(yolo_inference_options)
|
|
604
|
+
|
|
605
|
+
# Run again, without symlinks this time
|
|
606
|
+
|
|
607
|
+
from megadetector.utils.path_utils import insert_before_extension
|
|
608
|
+
inference_output_file_yolo_val_no_links = insert_before_extension(inference_output_file_yolo_val,
|
|
609
|
+
'no-links')
|
|
610
|
+
yolo_inference_options.output_file = inference_output_file_yolo_val_no_links
|
|
611
|
+
yolo_inference_options.use_symlinks = False
|
|
612
|
+
run_inference_with_yolo_val(yolo_inference_options)
|
|
613
|
+
|
|
614
|
+
# Run again, with chunked inference and symlinks
|
|
615
|
+
|
|
616
|
+
inference_output_file_yolo_val_checkpoints = insert_before_extension(inference_output_file_yolo_val,
|
|
617
|
+
'checkpoints')
|
|
618
|
+
yolo_inference_options.output_file = inference_output_file_yolo_val_checkpoints
|
|
619
|
+
yolo_inference_options.use_symlinks = True
|
|
620
|
+
yolo_inference_options.checkpoint_frequency = 5
|
|
621
|
+
run_inference_with_yolo_val(yolo_inference_options)
|
|
622
|
+
|
|
623
|
+
# Run again, with chunked inference and no symlinks
|
|
624
|
+
|
|
625
|
+
inference_output_file_yolo_val_checkpoints_no_links = \
|
|
626
|
+
insert_before_extension(inference_output_file_yolo_val,'checkpoints-no-links')
|
|
627
|
+
yolo_inference_options.output_file = inference_output_file_yolo_val_checkpoints_no_links
|
|
628
|
+
yolo_inference_options.use_symlinks = False
|
|
629
|
+
yolo_inference_options.checkpoint_frequency = 5
|
|
630
|
+
run_inference_with_yolo_val(yolo_inference_options)
|
|
631
|
+
|
|
632
|
+
fn1 = inference_output_file_yolo_val
|
|
633
|
+
|
|
634
|
+
output_files_to_compare = [
|
|
635
|
+
inference_output_file_yolo_val_no_links,
|
|
636
|
+
inference_output_file_yolo_val_checkpoints,
|
|
637
|
+
inference_output_file_yolo_val_checkpoints_no_links
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
for fn2 in output_files_to_compare:
|
|
641
|
+
assert output_files_are_identical(fn1, fn2, verbose=True)
|
|
642
|
+
|
|
643
|
+
# ...if we need to run the YOLO val inference tests
|
|
644
|
+
|
|
483
645
|
|
|
484
646
|
if not options.skip_video_tests:
|
|
485
647
|
|
|
@@ -488,7 +650,7 @@ def run_python_tests(options):
|
|
|
488
650
|
from megadetector.detection.process_video import ProcessVideoOptions, process_video
|
|
489
651
|
|
|
490
652
|
video_options = ProcessVideoOptions()
|
|
491
|
-
video_options.model_file =
|
|
653
|
+
video_options.model_file = options.default_model
|
|
492
654
|
video_options.input_video_file = os.path.join(options.scratch_dir,options.test_videos[0])
|
|
493
655
|
video_options.output_json_file = os.path.join(options.scratch_dir,'single_video_output.json')
|
|
494
656
|
video_options.output_video_file = os.path.join(options.scratch_dir,'video_scratch/rendered_video.mp4')
|
|
@@ -503,7 +665,7 @@ def run_python_tests(options):
|
|
|
503
665
|
# video_options.reuse_frames_if_available = False
|
|
504
666
|
video_options.recursive = True
|
|
505
667
|
video_options.verbose = False
|
|
506
|
-
video_options.fourcc =
|
|
668
|
+
video_options.fourcc = options.video_fourcc
|
|
507
669
|
# video_options.rendering_confidence_threshold = None
|
|
508
670
|
# video_options.json_confidence_threshold = 0.005
|
|
509
671
|
video_options.frame_sample = 5
|
|
@@ -524,7 +686,7 @@ def run_python_tests(options):
|
|
|
524
686
|
from megadetector.detection.process_video import ProcessVideoOptions, process_video_folder
|
|
525
687
|
|
|
526
688
|
video_options = ProcessVideoOptions()
|
|
527
|
-
video_options.model_file =
|
|
689
|
+
video_options.model_file = options.default_model
|
|
528
690
|
video_options.input_video_file = os.path.join(options.scratch_dir,
|
|
529
691
|
os.path.dirname(options.test_videos[0]))
|
|
530
692
|
video_options.output_json_file = os.path.join(options.scratch_dir,'video_folder_output.json')
|
|
@@ -539,8 +701,8 @@ def run_python_tests(options):
|
|
|
539
701
|
# video_options.reuse_results_if_available = False
|
|
540
702
|
# video_options.reuse_frames_if_available = False
|
|
541
703
|
video_options.recursive = True
|
|
542
|
-
video_options.verbose =
|
|
543
|
-
|
|
704
|
+
video_options.verbose = True
|
|
705
|
+
video_options.fourcc = options.video_fourcc
|
|
544
706
|
# video_options.rendering_confidence_threshold = None
|
|
545
707
|
# video_options.json_confidence_threshold = 0.005
|
|
546
708
|
video_options.frame_sample = 5
|
|
@@ -572,6 +734,13 @@ def run_cli_tests(options):
|
|
|
572
734
|
|
|
573
735
|
print('\n*** Starting CLI tests ***\n')
|
|
574
736
|
|
|
737
|
+
|
|
738
|
+
## Environment management
|
|
739
|
+
|
|
740
|
+
if options.cli_test_pythonpath is not None:
|
|
741
|
+
os.environ['PYTHONPATH'] = options.cli_test_pythonpath
|
|
742
|
+
|
|
743
|
+
|
|
575
744
|
## chdir if necessary
|
|
576
745
|
|
|
577
746
|
if options.cli_working_dir is not None:
|
|
@@ -585,16 +754,14 @@ def run_cli_tests(options):
|
|
|
585
754
|
|
|
586
755
|
## Run inference on an image
|
|
587
756
|
|
|
588
|
-
model_file = 'MDV5A'
|
|
589
757
|
image_fn = os.path.join(options.scratch_dir,options.test_images[0])
|
|
590
758
|
output_dir = os.path.join(options.scratch_dir,'single_image_test')
|
|
591
759
|
if options.cli_working_dir is None:
|
|
592
760
|
cmd = 'python -m megadetector.detection.run_detector'
|
|
593
761
|
else:
|
|
594
762
|
cmd = 'python megadetector/detection/run_detector.py'
|
|
595
|
-
cmd += ' {} --image_file {} --output_dir {}'.format(
|
|
596
|
-
|
|
597
|
-
print('Running: {}'.format(cmd))
|
|
763
|
+
cmd += ' "{}" --image_file "{}" --output_dir "{}"'.format(
|
|
764
|
+
options.default_model,image_fn,output_dir)
|
|
598
765
|
cmd_results = execute_and_print(cmd)
|
|
599
766
|
|
|
600
767
|
if options.cpu_execution_is_error:
|
|
@@ -609,6 +776,7 @@ def run_cli_tests(options):
|
|
|
609
776
|
|
|
610
777
|
## Run inference on a folder
|
|
611
778
|
|
|
779
|
+
|
|
612
780
|
image_folder = os.path.join(options.scratch_dir,'md-test-images')
|
|
613
781
|
assert os.path.isdir(image_folder), 'Test image folder {} is not available'.format(image_folder)
|
|
614
782
|
inference_output_file = os.path.join(options.scratch_dir,'folder_inference_output.json')
|
|
@@ -616,19 +784,79 @@ def run_cli_tests(options):
|
|
|
616
784
|
cmd = 'python -m megadetector.detection.run_detector_batch'
|
|
617
785
|
else:
|
|
618
786
|
cmd = 'python megadetector/detection/run_detector_batch.py'
|
|
619
|
-
cmd += ' {} {} {} --recursive'.format(
|
|
620
|
-
|
|
787
|
+
cmd += ' "{}" "{}" "{}" --recursive'.format(
|
|
788
|
+
options.default_model,image_folder,inference_output_file)
|
|
621
789
|
cmd += ' --output_relative_filenames --quiet --include_image_size'
|
|
622
790
|
cmd += ' --include_image_timestamp --include_exif_data'
|
|
623
|
-
print('Running: {}'.format(cmd))
|
|
624
791
|
cmd_results = execute_and_print(cmd)
|
|
625
792
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
793
|
+
base_cmd = cmd
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
## Run again with checkpointing enabled, make sure the results are the same
|
|
797
|
+
|
|
798
|
+
from megadetector.utils.path_utils import insert_before_extension
|
|
799
|
+
|
|
800
|
+
checkpoint_string = ' --checkpoint_frequency 5'
|
|
801
|
+
cmd = base_cmd + checkpoint_string
|
|
802
|
+
inference_output_file_checkpoint = insert_before_extension(inference_output_file,'_checkpoint')
|
|
803
|
+
cmd = cmd.replace(inference_output_file,inference_output_file_checkpoint)
|
|
804
|
+
cmd_results = execute_and_print(cmd)
|
|
805
|
+
|
|
806
|
+
assert output_files_are_identical(fn1=inference_output_file,
|
|
807
|
+
fn2=inference_output_file_checkpoint,verbose=True)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
## Run again with the image queue enabled, make sure the results are the same
|
|
811
|
+
|
|
812
|
+
cmd = base_cmd + ' --use_image_queue'
|
|
813
|
+
from megadetector.utils.path_utils import insert_before_extension
|
|
814
|
+
inference_output_file_queue = insert_before_extension(inference_output_file,'_queue')
|
|
815
|
+
cmd = cmd.replace(inference_output_file,inference_output_file_queue)
|
|
816
|
+
cmd_results = execute_and_print(cmd)
|
|
817
|
+
|
|
818
|
+
assert output_files_are_identical(fn1=inference_output_file,
|
|
819
|
+
fn2=inference_output_file_queue,verbose=True)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
## Run again on multiple cores, make sure the results are the same
|
|
823
|
+
|
|
824
|
+
# First run again on the CPU on a single thread if necessary, so we get a file that
|
|
825
|
+
# *should* be identical to the multicore version.
|
|
826
|
+
|
|
827
|
+
gpu_available = is_gpu_available(verbose=False)
|
|
828
|
+
|
|
829
|
+
cuda_visible_devices = None
|
|
830
|
+
if 'CUDA_VISIBLE_DEVICES' in os.environ:
|
|
831
|
+
cuda_visible_devices = os.environ['CUDA_VISIBLE_DEVICES']
|
|
832
|
+
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
|
|
833
|
+
|
|
834
|
+
# If we already ran on the CPU, no need to run again
|
|
835
|
+
if not gpu_available:
|
|
836
|
+
inference_output_file_cpu = inference_output_file
|
|
837
|
+
else:
|
|
838
|
+
inference_output_file_cpu = insert_before_extension(inference_output_file,'cpu')
|
|
839
|
+
cmd = base_cmd
|
|
840
|
+
cmd = cmd.replace(inference_output_file,inference_output_file_cpu)
|
|
841
|
+
cmd_results = execute_and_print(cmd)
|
|
842
|
+
|
|
843
|
+
cpu_string = ' --ncores 4'
|
|
844
|
+
cmd = base_cmd + cpu_string
|
|
845
|
+
from megadetector.utils.path_utils import insert_before_extension
|
|
846
|
+
inference_output_file_cpu_multicore = insert_before_extension(inference_output_file,'multicore')
|
|
847
|
+
cmd = cmd.replace(inference_output_file,inference_output_file_cpu_multicore)
|
|
848
|
+
cmd_results = execute_and_print(cmd)
|
|
849
|
+
|
|
850
|
+
if cuda_visible_devices is not None:
|
|
851
|
+
print('Restoring CUDA_VISIBLE_DEVICES')
|
|
852
|
+
os.environ['CUDA_VISIBLE_DEVICES'] = cuda_visible_devices
|
|
853
|
+
else:
|
|
854
|
+
del os.environ['CUDA_VISIBLE_DEVICES']
|
|
855
|
+
|
|
856
|
+
assert output_files_are_identical(fn1=inference_output_file_cpu,
|
|
857
|
+
fn2=inference_output_file_cpu_multicore,verbose=True)
|
|
858
|
+
|
|
630
859
|
|
|
631
|
-
|
|
632
860
|
## Postprocessing
|
|
633
861
|
|
|
634
862
|
postprocessing_output_dir = os.path.join(options.scratch_dir,'postprocessing_output_cli')
|
|
@@ -637,10 +865,9 @@ def run_cli_tests(options):
|
|
|
637
865
|
cmd = 'python -m megadetector.postprocessing.postprocess_batch_results'
|
|
638
866
|
else:
|
|
639
867
|
cmd = 'python megadetector/postprocessing/postprocess_batch_results.py'
|
|
640
|
-
cmd += ' {} {}'.format(
|
|
868
|
+
cmd += ' "{}" "{}"'.format(
|
|
641
869
|
inference_output_file,postprocessing_output_dir)
|
|
642
|
-
cmd += ' --image_base_dir {}'.format(image_folder)
|
|
643
|
-
print('Running: {}'.format(cmd))
|
|
870
|
+
cmd += ' --image_base_dir "{}"'.format(image_folder)
|
|
644
871
|
cmd_results = execute_and_print(cmd)
|
|
645
872
|
|
|
646
873
|
|
|
@@ -652,11 +879,10 @@ def run_cli_tests(options):
|
|
|
652
879
|
cmd = 'python -m megadetector.postprocessing.repeat_detection_elimination.find_repeat_detections'
|
|
653
880
|
else:
|
|
654
881
|
cmd = 'python megadetector/postprocessing/repeat_detection_elimination/find_repeat_detections.py'
|
|
655
|
-
cmd += ' {}'.format(inference_output_file)
|
|
656
|
-
cmd += ' --imageBase {}'.format(image_folder)
|
|
657
|
-
cmd += ' --outputBase {}'.format(rde_output_dir)
|
|
882
|
+
cmd += ' "{}"'.format(inference_output_file)
|
|
883
|
+
cmd += ' --imageBase "{}"'.format(image_folder)
|
|
884
|
+
cmd += ' --outputBase "{}"'.format(rde_output_dir)
|
|
658
885
|
cmd += ' --occurrenceThreshold 1' # Use an absurd number here to make sure we get some suspicious detections
|
|
659
|
-
print('Running: {}'.format(cmd))
|
|
660
886
|
cmd_results = execute_and_print(cmd)
|
|
661
887
|
|
|
662
888
|
# Find the latest filtering folder
|
|
@@ -674,8 +900,7 @@ def run_cli_tests(options):
|
|
|
674
900
|
cmd = 'python -m megadetector.postprocessing.repeat_detection_elimination.remove_repeat_detections'
|
|
675
901
|
else:
|
|
676
902
|
cmd = 'python megadetector/postprocessing/repeat_detection_elimination/remove_repeat_detections.py'
|
|
677
|
-
cmd += ' {} {} {}'.format(inference_output_file,filtered_output_file,filtering_output_dir)
|
|
678
|
-
print('Running: {}'.format(cmd))
|
|
903
|
+
cmd += ' "{}" "{}" "{}"'.format(inference_output_file,filtered_output_file,filtering_output_dir)
|
|
679
904
|
cmd_results = execute_and_print(cmd)
|
|
680
905
|
|
|
681
906
|
assert os.path.isfile(filtered_output_file), \
|
|
@@ -691,10 +916,9 @@ def run_cli_tests(options):
|
|
|
691
916
|
cmd = 'python -m megadetector.detection.run_tiled_inference'
|
|
692
917
|
else:
|
|
693
918
|
cmd = 'python megadetector/detection/run_tiled_inference.py'
|
|
694
|
-
cmd += ' {} {} {} {}'.format(
|
|
695
|
-
|
|
919
|
+
cmd += ' "{}" "{}" "{}" "{}"'.format(
|
|
920
|
+
options.default_model,image_folder,tiling_folder,inference_output_file_tiled)
|
|
696
921
|
cmd += ' --overwrite_handling overwrite'
|
|
697
|
-
print('Running: {}'.format(cmd))
|
|
698
922
|
cmd_results = execute_and_print(cmd)
|
|
699
923
|
|
|
700
924
|
with open(inference_output_file_tiled,'r') as f:
|
|
@@ -703,7 +927,7 @@ def run_cli_tests(options):
|
|
|
703
927
|
|
|
704
928
|
## Run inference on a folder (augmented)
|
|
705
929
|
|
|
706
|
-
if options.
|
|
930
|
+
if options.yolo_working_dir is None:
|
|
707
931
|
|
|
708
932
|
print('Bypassing YOLOv5 val tests, no yolo folder supplied')
|
|
709
933
|
|
|
@@ -717,62 +941,67 @@ def run_cli_tests(options):
|
|
|
717
941
|
cmd = 'python -m megadetector.detection.run_inference_with_yolov5_val'
|
|
718
942
|
else:
|
|
719
943
|
cmd = 'python megadetector/detection/run_inference_with_yolov5_val.py'
|
|
720
|
-
cmd += ' {} {} {}'.format(
|
|
721
|
-
|
|
722
|
-
cmd += ' --yolo_working_folder {}'.format(options.
|
|
723
|
-
cmd += ' --yolo_results_folder {}'.format(yolo_results_folder)
|
|
724
|
-
cmd += ' --symlink_folder {}'.format(yolo_symlink_folder)
|
|
944
|
+
cmd += ' "{}" "{}" "{}"'.format(
|
|
945
|
+
options.default_model,image_folder,inference_output_file_yolo_val)
|
|
946
|
+
cmd += ' --yolo_working_folder "{}"'.format(options.yolo_working_dir)
|
|
947
|
+
cmd += ' --yolo_results_folder "{}"'.format(yolo_results_folder)
|
|
948
|
+
cmd += ' --symlink_folder "{}"'.format(yolo_symlink_folder)
|
|
725
949
|
cmd += ' --augment_enabled 1'
|
|
726
950
|
# cmd += ' --no_use_symlinks'
|
|
727
951
|
cmd += ' --overwrite_handling overwrite'
|
|
728
|
-
print('Running: {}'.format(cmd))
|
|
729
952
|
cmd_results = execute_and_print(cmd)
|
|
730
953
|
|
|
731
|
-
with
|
|
732
|
-
|
|
954
|
+
# Run again with checkpointing, make sure the output are identical
|
|
955
|
+
cmd += ' --checkpoint_frequency 5'
|
|
956
|
+
inference_output_file_yolo_val_checkpoint = \
|
|
957
|
+
os.path.join(options.scratch_dir,'folder_inference_output_yolo_val_checkpoint.json')
|
|
958
|
+
assert inference_output_file_yolo_val_checkpoint != inference_output_file_yolo_val
|
|
959
|
+
cmd = cmd.replace(inference_output_file_yolo_val,inference_output_file_yolo_val_checkpoint)
|
|
960
|
+
cmd_results = execute_and_print(cmd)
|
|
733
961
|
|
|
962
|
+
assert output_files_are_identical(fn1=inference_output_file_yolo_val,
|
|
963
|
+
fn2=inference_output_file_yolo_val_checkpoint)
|
|
734
964
|
|
|
735
965
|
if not options.skip_video_tests:
|
|
736
966
|
|
|
737
967
|
## Video test
|
|
738
968
|
|
|
739
|
-
model_file = 'MDV5A'
|
|
740
969
|
video_inference_output_file = os.path.join(options.scratch_dir,'video_inference_output.json')
|
|
741
970
|
output_video_file = os.path.join(options.scratch_dir,'video_scratch/cli_rendered_video.mp4')
|
|
742
971
|
frame_folder = os.path.join(options.scratch_dir,'video_scratch/frame_folder_cli')
|
|
743
972
|
frame_rendering_folder = os.path.join(options.scratch_dir,'video_scratch/rendered_frame_folder_cli')
|
|
744
973
|
|
|
745
|
-
video_fn = os.path.join(options.scratch_dir,options.test_videos[-1])
|
|
974
|
+
video_fn = os.path.join(options.scratch_dir,options.test_videos[-1])
|
|
975
|
+
assert os.path.isfile(video_fn), 'Could not find video file {}'.format(video_fn)
|
|
976
|
+
|
|
746
977
|
output_dir = os.path.join(options.scratch_dir,'single_video_test_cli')
|
|
747
978
|
if options.cli_working_dir is None:
|
|
748
979
|
cmd = 'python -m megadetector.detection.process_video'
|
|
749
980
|
else:
|
|
750
981
|
cmd = 'python megadetector/detection/process_video.py'
|
|
751
|
-
cmd += ' {} {}'.format(
|
|
752
|
-
cmd += ' --frame_folder {} --frame_rendering_folder {} --output_json_file {} --output_video_file {}'.format(
|
|
982
|
+
cmd += ' "{}" "{}"'.format(options.default_model,video_fn)
|
|
983
|
+
cmd += ' --frame_folder "{}" --frame_rendering_folder "{}" --output_json_file "{}" --output_video_file "{}"'.format(
|
|
753
984
|
frame_folder,frame_rendering_folder,video_inference_output_file,output_video_file)
|
|
754
|
-
cmd += ' --render_output_video --fourcc
|
|
985
|
+
cmd += ' --render_output_video --fourcc {}'.format(options.video_fourcc)
|
|
755
986
|
cmd += ' --force_extracted_frame_folder_deletion --force_rendered_frame_folder_deletion --n_cores 5 --frame_sample 3'
|
|
756
|
-
|
|
987
|
+
cmd += ' --verbose'
|
|
757
988
|
cmd_results = execute_and_print(cmd)
|
|
758
989
|
|
|
759
990
|
# ...if we're not skipping video tests
|
|
760
991
|
|
|
761
992
|
|
|
762
|
-
## Run inference on a folder (
|
|
993
|
+
## Run inference on a folder (with MDV5B, so we can do a comparison)
|
|
763
994
|
|
|
764
995
|
image_folder = os.path.join(options.scratch_dir,'md-test-images')
|
|
765
|
-
model_file = 'MDV5B'
|
|
766
996
|
inference_output_file_alt = os.path.join(options.scratch_dir,'folder_inference_output_alt.json')
|
|
767
997
|
if options.cli_working_dir is None:
|
|
768
998
|
cmd = 'python -m megadetector.detection.run_detector_batch'
|
|
769
999
|
else:
|
|
770
1000
|
cmd = 'python megadetector/detection/run_detector_batch.py'
|
|
771
|
-
cmd += ' {} {} {} --recursive'.format(
|
|
772
|
-
|
|
1001
|
+
cmd += ' "{}" "{}" "{}" --recursive'.format(
|
|
1002
|
+
options.alt_model,image_folder,inference_output_file_alt)
|
|
773
1003
|
cmd += ' --output_relative_filenames --quiet --include_image_size'
|
|
774
1004
|
cmd += ' --include_image_timestamp --include_exif_data'
|
|
775
|
-
print('Running: {}'.format(cmd))
|
|
776
1005
|
cmd_results = execute_and_print(cmd)
|
|
777
1006
|
|
|
778
1007
|
with open(inference_output_file_alt,'r') as f:
|
|
@@ -789,8 +1018,7 @@ def run_cli_tests(options):
|
|
|
789
1018
|
cmd = 'python -m megadetector.postprocessing.compare_batch_results'
|
|
790
1019
|
else:
|
|
791
1020
|
cmd = 'python megadetector/postprocessing/compare_batch_results.py'
|
|
792
|
-
cmd += ' {} {} {}'.format(comparison_output_folder,image_folder,results_files_string)
|
|
793
|
-
print('Running: {}'.format(cmd))
|
|
1021
|
+
cmd += ' "{}" "{}" {}'.format(comparison_output_folder,image_folder,results_files_string)
|
|
794
1022
|
cmd_results = execute_and_print(cmd)
|
|
795
1023
|
|
|
796
1024
|
assert cmd_results['status'] == 0, 'Error generating comparison HTML'
|
|
@@ -813,7 +1041,7 @@ def run_tests(options):
|
|
|
813
1041
|
"""
|
|
814
1042
|
|
|
815
1043
|
# Prepare data folder
|
|
816
|
-
download_test_data(options)
|
|
1044
|
+
download_test_data(options)
|
|
817
1045
|
|
|
818
1046
|
if options.disable_gpu:
|
|
819
1047
|
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
|
|
@@ -861,8 +1089,12 @@ if False:
|
|
|
861
1089
|
options.max_coord_error = 0.001
|
|
862
1090
|
options.max_conf_error = 0.005
|
|
863
1091
|
options.cli_working_dir = r'c:\git\MegaDetector'
|
|
864
|
-
options.
|
|
1092
|
+
options.yolo_working_dir = r'c:\git\yolov5-md'
|
|
865
1093
|
|
|
1094
|
+
import os
|
|
1095
|
+
|
|
1096
|
+
if 'PYTHONPATH' not in os.environ or options.yolo_working_dir not in os.environ['PYTHONPATH']:
|
|
1097
|
+
os.environ['PYTHONPATH'] += ';' + options.yolo_working_dir
|
|
866
1098
|
|
|
867
1099
|
#%%
|
|
868
1100
|
|
|
@@ -943,26 +1175,46 @@ def main():
|
|
|
943
1175
|
type=str,
|
|
944
1176
|
default=None,
|
|
945
1177
|
help='Working directory for CLI tests')
|
|
1178
|
+
|
|
1179
|
+
parser.add_argument(
|
|
1180
|
+
'--yolo_working_dir',
|
|
1181
|
+
type=str,
|
|
1182
|
+
default=None,
|
|
1183
|
+
help='Working directory for yolo inference tests')
|
|
946
1184
|
|
|
1185
|
+
parser.add_argument(
|
|
1186
|
+
'--cli_test_pythonpath',
|
|
1187
|
+
type=str,
|
|
1188
|
+
default=None,
|
|
1189
|
+
help='PYTHONPATH to set for CLI tests; if None, inherits from the parent process'
|
|
1190
|
+
)
|
|
1191
|
+
|
|
947
1192
|
# token used for linting
|
|
948
1193
|
#
|
|
949
1194
|
# no_arguments_required
|
|
950
1195
|
|
|
951
1196
|
args = parser.parse_args()
|
|
952
|
-
|
|
953
|
-
options
|
|
954
|
-
|
|
955
|
-
options.skip_video_tests = args.skip_video_tests
|
|
956
|
-
options.skip_python_tests = args.skip_python_tests
|
|
957
|
-
options.skip_cli_tests = args.skip_cli_tests
|
|
958
|
-
options.scratch_dir = args.scratch_dir
|
|
959
|
-
options.warning_mode = args.warning_mode
|
|
960
|
-
options.force_data_download = args.force_data_download
|
|
961
|
-
options.max_conf_error = args.max_conf_error
|
|
962
|
-
options.max_coord_error = args.max_coord_error
|
|
963
|
-
options.cli_working_dir = args.cli_working_dir
|
|
964
|
-
|
|
1197
|
+
|
|
1198
|
+
_args_to_object(args,options)
|
|
1199
|
+
|
|
965
1200
|
run_tests(options)
|
|
966
1201
|
|
|
967
|
-
if __name__ == '__main__':
|
|
1202
|
+
if __name__ == '__main__':
|
|
968
1203
|
main()
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
#%% Sample invocations
|
|
1207
|
+
|
|
1208
|
+
r"""
|
|
1209
|
+
# Windows
|
|
1210
|
+
set PYTHONPATH=c:\git\MegaDetector;c:\git\yolov5-md
|
|
1211
|
+
cd c:\git\MegaDetector\megadetector\utils
|
|
1212
|
+
python md_tests.py --cli_working_dir "c:\git\MegaDetector" --yolo_working_dir "c:\git\yolov5-md" --cli_test_pythonpath "c:\git\MegaDetector;c:\git\yolov5-md"
|
|
1213
|
+
|
|
1214
|
+
# Linux
|
|
1215
|
+
export PYTHONPATH=/mnt/c/git/MegaDetector:/mnt/c/git/yolov5-md
|
|
1216
|
+
cd /mnt/c/git/MegaDetector/megadetector/utils
|
|
1217
|
+
python md_tests.py --cli_working_dir "/mnt/c/git/MegaDetector" --yolo_working_dir "/mnt/c/git/yolov5-md" --cli_test_pythonpath "/mnt/c/git/MegaDetector:/mnt/c/git/yolov5-md"
|
|
1218
|
+
|
|
1219
|
+
python -c "import md_tests; print(md_tests.get_expected_results_filename(True))"
|
|
1220
|
+
"""
|