Skip to content

Commit dd7c445

Browse files
committed
ENH: Refactor CLI scripts
Combine the `pyradiomics` and `pyradiomicsbatch` entry point into 1 joint entry point `pyradiomics`. This new entry point operates in batch mode when input to `Image|Batch` has `.csv` extension. In batch mode, `Mask` argument is ignored. In single mode `Mask` inputs is required. Additionally, enables easy multi-threaded extraction by specifying `--jobs` / `-j` argument (with integer indicating number of parallel threads to use. Will only work in batch mode, as extraction is multi-threaded at the case level (1 thread per case). Removes argument `--label` / `-l`, specifying an override label to use is now achieved through specifying `-s "label:N"`, with N being the label value. Also includes a small update to initialization of the feature extractor: - In addition to a string pointing to a parameter file, feature extractor now also accepts a dictionary (top level specifies customization types 'setting', 'imageType' and 'featureClass') as 1st positional argument - Regardless of initialization with or without a customization file/dictionary, `kwargs` passed to the constructor are used to override settings.
1 parent 8c7dd45 commit dd7c445

File tree

5 files changed

+486
-23
lines changed

5 files changed

+486
-23
lines changed

examples/batchprocessing_parallel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def run(case):
104104

105105
imageFilepath = case['Image'] # Required
106106
maskFilepath = case['Mask'] # Required
107-
label = case.get('Label', 1) # Optional
107+
label = case.get('Label', None) # Optional
108108

109109
# Instantiate Radiomics Feature extractor
110110

radiomics/featureextractor.py

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ class RadiomicsFeaturesExtractor:
2626
signature specified by these settings for the passed image and labelmap combination. This function can be called
2727
repeatedly in a batch process to calculate the radiomics signature for all image and labelmap combinations.
2828
29-
At initialization, a parameters file can be provided containing all necessary settings. This is done by passing the
30-
location of the file as the single argument in the initialization call, without specifying it as a keyword argument.
31-
If such a file location is provided, any additional kwargs are ignored.
32-
Alternatively, at initialisation, custom settings (*NOT enabled image types and/or feature classes*) can be provided
33-
as keyword arguments, with the setting name as key and its value as the argument value (e.g. ``binWidth=25``). For
34-
more information on possible settings and customization, see
29+
At initialization, a parameters file (string pointing to yaml or json structured file) or dictionary can be provided
30+
containing all necessary settings (top level containing keys "setting", "imageType" and/or "featureClass). This is
31+
done by passing it as the first positional argument. If no positional argument is supplied, or the argument is not
32+
either a dictionary or a string pointing to a valid file, defaults will be applied.
33+
Moreover, at initialisation, custom settings (*NOT enabled image types and/or feature classes*) can be provided
34+
as keyword arguments, with the setting name as key and its value as the argument value (e.g. ``binWidth=25``).
35+
Settings specified here will override those in the parameter file/dict/default settings.
36+
For more information on possible settings and customization, see
3537
:ref:`Customizing the Extraction <radiomics-customization-label>`.
3638
3739
By default, all features in all feature classes are enabled.
@@ -47,28 +49,30 @@ def __init__(self, *args, **kwargs):
4749
self._enabledImagetypes = {}
4850
self._enabledFeatures = {}
4951

50-
if len(args) == 1 and isinstance(args[0], six.string_types):
52+
if len(args) == 1 and isinstance(args[0], six.string_types) and os.path.isfile(args[0]):
5153
self.logger.info("Loading parameter file")
52-
self.loadParams(args[0])
54+
self._applyParams(paramsFile=args[0])
55+
elif len(args) == 1 and isinstance(args[0], dict):
56+
self.logger.info("Loading parameter dictionary")
57+
self._applyParams(paramsDict=args[0])
5358
else:
5459
# Set default settings and update with and changed settings contained in kwargs
5560
self.settings = self._getDefaultSettings()
56-
if len(kwargs) > 0:
57-
self.logger.info('Applying custom settings')
58-
self.settings.update(kwargs)
59-
else:
60-
self.logger.info('No customized settings, applying defaults')
61-
62-
self.logger.debug("Settings: %s", self.settings)
61+
self.logger.info('No valid config parameter, applying defaults: %s', self.settings)
6362

6463
self._enabledImagetypes = {'Original': {}}
6564
self.logger.info('Enabled image types: %s', self._enabledImagetypes)
66-
6765
self._enabledFeatures = {}
66+
6867
for featureClassName in self.getFeatureClassNames():
6968
self._enabledFeatures[featureClassName] = []
7069
self.logger.info('Enabled features: %s', self._enabledFeatures)
7170

71+
if len(kwargs) > 0:
72+
self.logger.info('Applying custom setting overrides')
73+
self.settings.update(kwargs)
74+
self.logger.debug("Settings: %s", self.settings)
75+
7276
self._setTolerance()
7377

7478
@classmethod
@@ -270,8 +274,8 @@ def enableAllFeatures(self):
270274
Enable all classes and all features.
271275
272276
.. note::
273-
Individual features that have been marked "deprecated" are not enabled by this function. They can still be enabled manually by
274-
a call to :py:func:`~radiomics.base.RadiomicsBase.enableFeatureByName()`,
277+
Individual features that have been marked "deprecated" are not enabled by this function. They can still be enabled
278+
manually by a call to :py:func:`~radiomics.base.RadiomicsBase.enableFeatureByName()`,
275279
:py:func:`~radiomics.featureextractor.RadiomicsFeaturesExtractor.enableFeaturesByName()`
276280
or in the parameter file (by specifying the feature by name, not when enabling all features).
277281
However, in most cases this will still result only in a deprecation warning.
@@ -293,8 +297,8 @@ def enableFeatureClassByName(self, featureClass, enabled=True):
293297
Enable or disable all features in given class.
294298
295299
.. note::
296-
Individual features that have been marked "deprecated" are not enabled by this function. They can still be enabled manually by
297-
a call to :py:func:`~radiomics.base.RadiomicsBase.enableFeatureByName()`,
300+
Individual features that have been marked "deprecated" are not enabled by this function. They can still be enabled
301+
manually by a call to :py:func:`~radiomics.base.RadiomicsBase.enableFeatureByName()`,
298302
:py:func:`~radiomics.featureextractor.RadiomicsFeaturesExtractor.enableFeaturesByName()`
299303
or in the parameter file (by specifying the feature by name, not when enabling all features).
300304
However, in most cases this will still result only in a deprecation warning.

radiomics/scripts/__init__.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python
2+
import argparse
3+
import csv
4+
from functools import partial
5+
import logging
6+
from multiprocessing import cpu_count, Pool
7+
import os
8+
import sys
9+
10+
from pykwalify.compat import yaml
11+
import six.moves
12+
13+
import radiomics
14+
from . import segment
15+
16+
17+
scriptlogger = logging.getLogger('radiomics.script') # holds logger for script events
18+
logging_config = {}
19+
relative_path_start = os.getcwd()
20+
21+
22+
def parse_args(custom_arguments=None):
23+
global relative_path_start
24+
parser = argparse.ArgumentParser(usage='%(prog)s image|batch [mask] [Options]',
25+
formatter_class=argparse.RawTextHelpFormatter)
26+
27+
inputGroup = parser.add_argument_group(title='Input',
28+
description='Input files and arguments defining the extraction:\n'
29+
'- image and mask files (single mode) '
30+
'or CSV-file specifying them (batch mode)\n'
31+
'- Parameter file (.yml/.yaml or .json)\n'
32+
'- Overrides for customization type 3 ("settings")\n'
33+
'- Multi-threaded batch processing')
34+
inputGroup.add_argument('input', metavar='{Image,Batch}FILE',
35+
help='Image file (single mode) or CSV batch file (batch mode)')
36+
inputGroup.add_argument('mask', nargs='?', metavar='MaskFILE', default=None,
37+
help='Mask file identifying the ROI in the Image. \n'
38+
'Only required when in single mode, ignored otherwise.')
39+
inputGroup.add_argument('--param', '-p', metavar='FILE', default=None,
40+
help='Parameter file containing the settings to be used in extraction')
41+
inputGroup.add_argument('--setting', '-s', metavar='"SETTING_NAME:VALUE"', action='append', default=[], type=str,
42+
help='Additional parameters which will override those in the\n'
43+
'parameter file and/or the default settings. Multiple\n'
44+
'settings possible. N.B. Only works for customization\n'
45+
'type 3 ("setting").')
46+
inputGroup.add_argument('--jobs', '-j', metavar='N', type=int, default=1, choices=six.moves.range(1, cpu_count() + 1),
47+
help='(Batch mode only) Specifies the number of threads to use for\n'
48+
'parallel processing. This is applied at the case level;\n'
49+
'i.e. 1 thread per case. Actual number of workers used is\n'
50+
'min(cases, jobs).')
51+
52+
outputGroup = parser.add_argument_group(title='Output', description='Arguments controlling output redirection and '
53+
'the formatting of calculated results.')
54+
outputGroup.add_argument('--out', '-o', metavar='FILE', type=argparse.FileType('a'), default=sys.stdout,
55+
help='File to append output to')
56+
outputGroup.add_argument('--skip-nans', action='store_true',
57+
help='Add this argument to skip returning features that have an\n'
58+
'invalid result (NaN)')
59+
outputGroup.add_argument('--format', '-f', choices=['csv', 'json', 'txt'], default='txt',
60+
help='Format for the output.\n'
61+
'"csv" (Default): one row of feature names, followed by one row of\n'
62+
'feature values per case.\n'
63+
'"json": Features are written in a JSON format dictionary\n'
64+
'(1 dictionary per case, 1 case per line) "{name:value}"\n'
65+
'"txt": one feature per line in format "case-N_name:value"')
66+
outputGroup.add_argument('--format-path', choices=['absolute', 'relative', 'basename'], default='absolute',
67+
help='Controls input image and mask path formatting in the output.\n'
68+
'"absolute" (Default): Absolute file paths.\n'
69+
'"relative": File paths relative to current working directory.\n'
70+
'"basename": Only stores filename.')
71+
72+
loggingGroup = parser.add_argument_group(title='Logging',
73+
description='Controls the (amount of) logging output to the '
74+
'console and the (optional) log-file.')
75+
loggingGroup.add_argument('--logging-level', metavar='LEVEL',
76+
choices=['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
77+
default='WARNING', help='Set capture level for logging')
78+
loggingGroup.add_argument('--log-file', metavar='FILE', default=None, help='File to append logger output to')
79+
loggingGroup.add_argument('--verbosity', '-v', action='store', nargs='?', default=3, const=4, type=int,
80+
choices=[1, 2, 3, 4, 5],
81+
help='Regulate output to stderr. By default [3], level\n'
82+
'WARNING and up are printed. By specifying this\n'
83+
'argument without a value, level INFO [4] is assumed.\n'
84+
'A higher value results in more verbose output.')
85+
86+
parser.add_argument('--version', action='version', help='Print version and exit',
87+
version='%(prog)s ' + radiomics.__version__)
88+
89+
args = parser.parse_args(args=custom_arguments) # Exits with code 2 if parsing fails
90+
91+
# Run the extraction
92+
try:
93+
_configureLogging(args)
94+
scriptlogger.info('Starting PyRadiomics (version: %s)', radiomics.__version__)
95+
results = _processInput(args)
96+
if results is not None:
97+
segment.processOutput(results, args.out, args.skip_nans, args.format, args.format_path, relative_path_start)
98+
scriptlogger.info('Finished extraction successfully...')
99+
else:
100+
return 1 # Feature extraction error
101+
except Exception:
102+
scriptlogger.error('Error extracting features!', exc_info=True)
103+
return 3 # Unknown error
104+
return 0 # success
105+
106+
107+
def _processInput(args):
108+
global logging_config, relative_path_start, scriptlogger
109+
scriptlogger.info('Processing input...')
110+
111+
caseCount = 1
112+
num_workers = 1
113+
114+
# Check if input represents a batch file
115+
if args.input.endswith('.csv'):
116+
scriptlogger.debug('Loading batch file "%s"', args.input)
117+
relative_path_start = os.path.dirname(args.input)
118+
with open(args.input, mode='r') as batchFile:
119+
cr = csv.DictReader(batchFile, lineterminator='\n')
120+
121+
# Check if required Image and Mask columns are present
122+
if 'Image' not in cr.fieldnames:
123+
scriptlogger.error('Required column "Image" not present in input, unable to extract features...')
124+
return None
125+
if 'Mask' not in cr.fieldnames:
126+
scriptlogger.error('Required column "Mask" not present in input, unable to extract features...')
127+
return None
128+
129+
cases = []
130+
for row_idx, row in enumerate(cr, start=2):
131+
if row['Image'] is None or row['Mask'] is None:
132+
scriptlogger.warning('Batch L%d: Missing required Image or Mask, skipping this case...', row_idx)
133+
continue
134+
imPath = row['Image']
135+
maPath = row['Mask']
136+
if not os.path.isabs(imPath):
137+
imPath = os.path.abspath(os.path.join(relative_path_start, imPath))
138+
scriptlogger.debug('Updated relative image filepath to be relative to input CSV: %s', imPath)
139+
if not os.path.isabs(maPath):
140+
maPath = os.path.abspath(os.path.join(relative_path_start, maPath))
141+
scriptlogger.debug('Updated relative mask filepath to be relative to input CSV: %s', maPath)
142+
cases.append(row)
143+
cases[-1]['Image'] = imPath
144+
cases[-1]['Mask'] = maPath
145+
146+
caseCount = len(cases)
147+
caseGenerator = _buildGenerator(args, cases)
148+
num_workers = min(caseCount, args.jobs)
149+
elif args.mask is not None:
150+
caseGenerator = _buildGenerator(args, [{'Image': args.input, 'Mask': args.mask}])
151+
else:
152+
scriptlogger.error('Input is not recognized as batch, no mask specified, cannot compute result!')
153+
return None
154+
155+
from radiomics.scripts import segment
156+
157+
if num_workers > 1: # multiple cases, parallel processing enabled
158+
scriptlogger.info('Input valid, starting parallel extraction from %d cases with %d workers...',
159+
caseCount, num_workers)
160+
pool = Pool(num_workers)
161+
results = pool.map(partial(segment.extractSegment_parallel, parallel_config=logging_config), caseGenerator)
162+
elif num_workers == 1: # single case or sequential batch processing
163+
scriptlogger.info('Input valid, starting sequential extraction from %d case(s)...',
164+
caseCount)
165+
results = []
166+
for case in caseGenerator:
167+
results.append(segment.extractSegment(*case))
168+
else:
169+
# No cases defined in the batch
170+
scriptlogger.error('No cases to process...')
171+
return None
172+
return results
173+
174+
175+
def _buildGenerator(args, cases):
176+
global scriptlogger
177+
setting_overrides = _parseOverrides(args.setting)
178+
179+
for case_idx, case in enumerate(cases, start=1):
180+
yield case_idx, case, args.param, setting_overrides
181+
182+
183+
def _parseOverrides(overrides):
184+
global scriptlogger
185+
setting_overrides = {}
186+
187+
# parse overrides
188+
if len(overrides) == 0:
189+
scriptlogger.debug('No overrides found')
190+
return setting_overrides
191+
192+
scriptlogger.debug('Reading parameter schema')
193+
schemaFile, schemaFuncs = radiomics.getParameterValidationFiles()
194+
with open(schemaFile) as schema:
195+
settingsSchema = yaml.load(schema)['mapping']['setting']['mapping']
196+
197+
# parse single value function
198+
def parse_value(value, value_type):
199+
if value_type == 'str':
200+
return value # no conversion
201+
elif value_type == 'int':
202+
return int(value)
203+
elif value_type == 'float':
204+
return float(value)
205+
elif value_type == 'bool':
206+
return value == '1' or value.lower() == 'true'
207+
else:
208+
raise ValueError('Cannot understand value_type %s' % value_type)
209+
210+
for setting in overrides: # setting = "setting_key:setting_value"
211+
if ':' not in setting:
212+
scriptlogger.warning('Incorrect format for override setting "%s", missing ":"', setting)
213+
# split into key and value
214+
setting_key, setting_value = setting.split(':', 2)
215+
216+
# Check if it is a valid PyRadiomics Setting
217+
if setting_key not in settingsSchema:
218+
scriptlogger.warning('Did not recognize override %s, skipping...', setting_key)
219+
continue
220+
221+
# Try to parse the value by looking up its type in the settingsSchema
222+
try:
223+
setting_def = settingsSchema[setting_key]
224+
setting_type = 'str' # If type is omitted in the schema, treat it as string (no conversion)
225+
if 'seq' in setting_def:
226+
# Multivalued setting
227+
if len(setting_def['seq']) > 0 and 'type' in setting_def['seq'][0]:
228+
setting_type = setting_def['seq'][0]['type']
229+
230+
setting_overrides[setting_key] = [parse_value(val, setting_type) for val in setting_value.split(',')]
231+
scriptlogger.debug('Parsed "%s" as list (element type "%s"); value: %s',
232+
setting_key, setting_type, setting_overrides[setting_key])
233+
else:
234+
if 'type' in setting_def:
235+
setting_type = setting_def['type']
236+
setting_overrides[setting_key] = parse_value(setting_value, setting_type)
237+
scriptlogger.debug('Parsed "%s" as type "%s"; value: %s', setting_key, setting_type, setting_overrides[setting_key])
238+
239+
except Exception:
240+
scriptlogger.warning('Could not parse value %s for setting %s, skipping...', setting_value, setting_key)
241+
242+
return setting_overrides
243+
244+
245+
def _configureLogging(args):
246+
global scriptlogger, logging_config
247+
248+
# Initialize Logging
249+
logLevel = getattr(logging, args.logging_level)
250+
rLogger = radiomics.logger
251+
logging_config['logLevel'] = logLevel
252+
253+
# Set up optional logging to file
254+
if args.log_file is not None:
255+
rLogger.setLevel(logLevel)
256+
handler = logging.FileHandler(filename=args.log_file, mode='a')
257+
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
258+
rLogger.addHandler(handler)
259+
logging_config['logFile'] = args.log_file
260+
261+
# Set verbosity of output (stderr)
262+
verboseLevel = (6 - args.verbosity) * 10 # convert to python logging level
263+
radiomics.setVerbosity(verboseLevel)
264+
logging_config['verbosity'] = verboseLevel
265+
266+
scriptlogger.debug('Logging initialized')

0 commit comments

Comments
 (0)