From 15b4fb8b42a0d922908b961ec6647fbdeda59b86 Mon Sep 17 00:00:00 2001 From: Michael Perry Date: Tue, 14 Jul 2020 23:34:42 -0700 Subject: [PATCH] Session timestamp fix (#50) * FIX: use study_date/time variable when generating session timestamp * MAINT: blackened * MAINT: bump version + remove redundant docker-image key --- dicom-mr-classifier.py | 330 ++++++++++++++++++++++++----------------- manifest.json | 7 +- 2 files changed, 193 insertions(+), 144 deletions(-) diff --git a/dicom-mr-classifier.py b/dicom-mr-classifier.py index 3d358ba..08959d8 100755 --- a/dicom-mr-classifier.py +++ b/dicom-mr-classifier.py @@ -15,17 +15,25 @@ from pprint import pprint logging.basicConfig() -log = logging.getLogger('dicom-mr-classifier') +log = logging.getLogger("dicom-mr-classifier") + def get_session_label(dcm): """ Switch on manufacturer and either pull out the StudyID or the StudyInstanceUID """ - session_label = '' - if ( dcm.get('Manufacturer') and (dcm.get('Manufacturer').find('GE') != -1 or dcm.get('Manufacturer').find('Philips') != -1 ) and dcm.get('StudyID')): - session_label = dcm.get('StudyID') + session_label = "" + if ( + dcm.get("Manufacturer") + and ( + dcm.get("Manufacturer").find("GE") != -1 + or dcm.get("Manufacturer").find("Philips") != -1 + ) + and dcm.get("StudyID") + ): + session_label = dcm.get("StudyID") else: - session_label = dcm.get('StudyInstanceUID') + session_label = dcm.get("StudyInstanceUID") return session_label @@ -48,23 +56,25 @@ def parse_patient_age(age): convert from 70d, 10w, 2m, 1y to datetime.timedelta object. Returns age as duration in seconds. """ - if age == 'None' or not age: + if age == "None" or not age: return None conversion = { # conversion to days - 'Y': 365.25, - 'M': 30, - 'W': 7, - 'D': 1, + "Y": 365.25, + "M": 30, + "W": 7, + "D": 1, } scale = age[-1:] value = age[:-1] if scale not in conversion.keys(): # Assume years - scale = 'Y' + scale = "Y" value = age - age_in_seconds = datetime.timedelta(int(value) * conversion.get(scale)).total_seconds() + age_in_seconds = datetime.timedelta( + int(value) * conversion.get(scale) + ).total_seconds() # Make sure that the age is reasonable if not age_in_seconds or age_in_seconds <= 0: @@ -80,9 +90,11 @@ def timestamp(date, time, timezone): if date and time and timezone: # return datetime.datetime.strptime(date + time[:6], '%Y%m%d%H%M%S') try: - return timezone.localize(datetime.datetime.strptime(date + time[:6], '%Y%m%d%H%M%S'), timezone) + return timezone.localize( + datetime.datetime.strptime(date + time[:6], "%Y%m%d%H%M%S"), timezone + ) except: - log.warning('Failed to create timestamp!') + log.warning("Failed to create timestamp!") log.info(date) log.info(time) log.info(timezone) @@ -94,47 +106,47 @@ def get_timestamp(dcm, timezone): """ Parse Study Date and Time, return acquisition and session timestamps """ - if hasattr(dcm, 'StudyDate') and hasattr(dcm, 'StudyTime'): + if hasattr(dcm, "StudyDate") and hasattr(dcm, "StudyTime"): study_date = dcm.StudyDate study_time = dcm.StudyTime - elif hasattr(dcm, 'StudyDateTime'): + elif hasattr(dcm, "StudyDateTime"): study_date = dcm.StudyDateTime[0:8] study_time = dcm.StudyDateTime[8:] else: study_date = None study_time = None - if hasattr(dcm, 'AcquisitionDate') and hasattr(dcm, 'AcquisitionTime'): + if hasattr(dcm, "AcquisitionDate") and hasattr(dcm, "AcquisitionTime"): acquitision_date = dcm.AcquisitionDate acquisition_time = dcm.AcquisitionTime - elif hasattr(dcm, 'AcquisitionDateTime'): + elif hasattr(dcm, "AcquisitionDateTime"): acquitision_date = dcm.AcquisitionDateTime[0:8] acquisition_time = dcm.AcquisitionDateTime[8:] # The following allows the timestamps to be set for ScreenSaves - elif hasattr(dcm, 'ContentDate') and hasattr(dcm, 'ContentTime'): + elif hasattr(dcm, "ContentDate") and hasattr(dcm, "ContentTime"): acquitision_date = dcm.ContentDate acquisition_time = dcm.ContentTime else: acquitision_date = None acquisition_time = None - session_timestamp = timestamp(dcm.StudyDate, dcm.StudyTime, timezone) + session_timestamp = timestamp(study_date, study_time, timezone) acquisition_timestamp = timestamp(acquitision_date, acquisition_time, timezone) if session_timestamp: if session_timestamp.tzinfo is None: - log.info('no tzinfo found, using UTC...') - session_timestamp = pytz.timezone('UTC').localize(session_timestamp) + log.info("no tzinfo found, using UTC...") + session_timestamp = pytz.timezone("UTC").localize(session_timestamp) session_timestamp = session_timestamp.isoformat() else: - session_timestamp = '' + session_timestamp = "" if acquisition_timestamp: if acquisition_timestamp.tzinfo is None: - log.info('no tzinfo found, using UTC') - acquisition_timestamp = pytz.timezone('UTC').localize(acquisition_timestamp) + log.info("no tzinfo found, using UTC") + acquisition_timestamp = pytz.timezone("UTC").localize(acquisition_timestamp) acquisition_timestamp = acquisition_timestamp.isoformat() else: - acquisition_timestamp = '' + acquisition_timestamp = "" return session_timestamp, acquisition_timestamp @@ -142,12 +154,12 @@ def get_sex_string(sex_str): """ Return male or female string. """ - if sex_str == 'M': - sex = 'male' - elif sex_str == 'F': - sex = 'female' + if sex_str == "M": + sex = "male" + elif sex_str == "F": + sex = "female" else: - sex = '' + sex = "" return sex @@ -155,13 +167,17 @@ def assign_type(s): """ Sets the type of a given input. """ - if type(s) == dicom.valuerep.PersonName or type(s) == dicom.valuerep.PersonName3 or type(s) == dicom.valuerep.PersonNameBase: + if ( + type(s) == dicom.valuerep.PersonName + or type(s) == dicom.valuerep.PersonName3 + or type(s) == dicom.valuerep.PersonNameBase + ): return format_string(s) if type(s) == list or type(s) == dicom.multival.MultiValue: try: - return [ int(x) if type(x) == int else float(x) for x in s ] + return [int(x) if type(x) == int else float(x) for x in s] except ValueError: - return [ format_string(x) for x in s if len(x) > 0 ] + return [format_string(x) for x in s if len(x) > 0] elif type(s) == float or type(s) == int: return s else: @@ -176,18 +192,20 @@ def assign_type(s): def format_string(in_string): - formatted = re.sub(r'[^\x00-\x7f]',r'', str(in_string)) # Remove non-ascii characters + formatted = re.sub( + r"[^\x00-\x7f]", r"", str(in_string) + ) # Remove non-ascii characters formatted = filter(lambda x: x in string.printable, formatted) - if len(formatted) == 1 and formatted == '?': + if len(formatted) == 1 and formatted == "?": formatted = None - return formatted#.encode('utf-8').strip() + return formatted # .encode('utf-8').strip() def get_seq_data(sequence, ignore_keys): seq_dict = {} for seq in sequence: for s_key in seq.dir(): - s_val = getattr(seq, s_key, '') + s_val = getattr(seq, s_key, "") if type(s_val) is dicom.uid.UID or s_key in ignore_keys: continue @@ -206,23 +224,36 @@ def get_seq_data(sequence, ignore_keys): return seq_dict + def get_dicom_header(dcm): # Extract the header values header = {} - exclude_tags = ['[Unknown]', 'PixelData', 'Pixel Data', '[User defined data]', '[Protocol Data Block (compressed)]', '[Histogram tables]', '[Unique image iden]'] + exclude_tags = [ + "[Unknown]", + "PixelData", + "Pixel Data", + "[User defined data]", + "[Protocol Data Block (compressed)]", + "[Histogram tables]", + "[Unique image iden]", + ] tags = dcm.dir() for tag in tags: try: - if (tag not in exclude_tags) and ( type(dcm.get(tag)) != dicom.sequence.Sequence ): + if (tag not in exclude_tags) and ( + type(dcm.get(tag)) != dicom.sequence.Sequence + ): value = dcm.get(tag) - if value or value == 0: # Some values are zero + if value or value == 0: # Some values are zero # Put the value in the header - if type(value) == str and len(value) < 10240: # Max dicom field length + if ( + type(value) == str and len(value) < 10240 + ): # Max dicom field length header[tag] = format_string(value) else: header[tag] = assign_type(value) else: - log.debug('No value found for tag: ' + tag) + log.debug("No value found for tag: " + tag) if type(dcm.get(tag)) == dicom.sequence.Sequence: seq_data = get_seq_data(dcm.get(tag), exclude_tags) @@ -230,31 +261,33 @@ def get_dicom_header(dcm): if seq_data: header[tag] = seq_data except: - log.debug('Failed to get ' + tag) + log.debug("Failed to get " + tag) pass return header + def get_csa_header(dcm): import dicom import nibabel.nicom.dicomwrappers - exclude_tags = ['PhoenixZIP', 'SrMsgBuffer'] + + exclude_tags = ["PhoenixZIP", "SrMsgBuffer"] header = {} try: raw_csa_header = nibabel.nicom.dicomwrappers.SiemensWrapper(dcm).csa_header - tags = raw_csa_header['tags'] + tags = raw_csa_header["tags"] except: - log.warning('Failed to parse csa header!') + log.warning("Failed to parse csa header!") return header for tag in tags: - if not raw_csa_header['tags'][tag]['items'] or tag in exclude_tags: - log.debug('Skipping : %s' % tag) + if not raw_csa_header["tags"][tag]["items"] or tag in exclude_tags: + log.debug("Skipping : %s" % tag) pass else: - value = raw_csa_header['tags'][tag]['items'] + value = raw_csa_header["tags"][tag]["items"] if len(value) == 1: value = value[0] - if type(value) == str and ( len(value) > 0 and len(value) < 1024 ): + if type(value) == str and (len(value) > 0 and len(value) < 1024): header[format_string(tag)] = format_string(value) else: header[format_string(tag)] = assign_type(value) @@ -263,13 +296,14 @@ def get_csa_header(dcm): return header + def get_classification_from_string(value): result = {} - parts = re.split(r'\s*,\s*', value) + parts = re.split(r"\s*,\s*", value) last_key = None for part in parts: - key_value = re.split(r'\s*:\s*', part) + key_value = re.split(r"\s*:\s*", part) if len(key_value) == 2: last_key = key = key_value[0] @@ -278,8 +312,8 @@ def get_classification_from_string(value): if last_key: key = last_key else: - log.warn('Unknown classification format: {0}'.format(part)) - key = 'Custom' + log.warn("Unknown classification format: {0}".format(part)) + key = "Custom" value = part if key not in result: @@ -289,37 +323,38 @@ def get_classification_from_string(value): return result + def get_custom_classification(label, config=None): if config is None: return None # Check custom classifiers - classifications = config['inputs'].get('classifications', {}).get('value', {}) + classifications = config["inputs"].get("classifications", {}).get("value", {}) if not classifications: - log.debug('No custom classifications found in config') + log.debug("No custom classifications found in config") return None if not isinstance(classifications, dict): - log.warning('classifications must be an object!') + log.warning("classifications must be an object!") return None for k in classifications.keys(): val = classifications[k] if not isinstance(val, basestring): - log.warn('Expected string value for classification key %s', k) + log.warn("Expected string value for classification key %s", k) continue - if len(k) > 2 and k[0] == '/' and k[-1] == '/': + if len(k) > 2 and k[0] == "/" and k[-1] == "/": # Regex try: if re.search(k[1:-1], label, re.I): - log.debug('Matched custom classification for key: %s', k) + log.debug("Matched custom classification for key: %s", k) return get_classification_from_string(val) except re.error: - log.exception('Invalid regular expression: %s', k) + log.exception("Invalid regular expression: %s", k) elif fnmatch(label.lower(), k.lower()): - log.debug('Matched custom classification for key: %s', k) + log.debug("Matched custom classification for key: %s", k) return get_classification_from_string(val) return None @@ -333,156 +368,166 @@ def dicom_classify(zip_file_path, outbase, timezone, config=None): # Parse config for options if config: - config_force = config['config'].get('force') + config_force = config["config"].get("force") if config_force: - log.warning('Attempting to force DICOM read. Input DICOM may not be valid.') + log.warning("Attempting to force DICOM read. Input DICOM may not be valid.") else: config_force = False - # Check for input file path if not os.path.exists(zip_file_path): - log.debug('could not find %s' % zip_file_path) - log.debug('checking input directory ...') - if os.path.exists(os.path.join('/input', zip_file_path)): - zip_file_path = os.path.join('/input', zip_file_path) - log.debug('found %s' % zip_file_path) + log.debug("could not find %s" % zip_file_path) + log.debug("checking input directory ...") + if os.path.exists(os.path.join("/input", zip_file_path)): + zip_file_path = os.path.join("/input", zip_file_path) + log.debug("found %s" % zip_file_path) if not outbase: - outbase = '/flywheel/v0/output' - log.info('setting outbase to %s' % outbase) + outbase = "/flywheel/v0/output" + log.info("setting outbase to %s" % outbase) # Extract the last file in the zip to /tmp/ and read it dcm = [] if zipfile.is_zipfile(zip_file_path): zip = zipfile.ZipFile(zip_file_path) num_files = len(zip.namelist()) - for n in range((num_files -1), -1, -1): - dcm_path = zip.extract(zip.namelist()[n], '/tmp') + for n in range((num_files - 1), -1, -1): + dcm_path = zip.extract(zip.namelist()[n], "/tmp") if os.path.isfile(dcm_path): try: - log.info('reading %s' % dcm_path) + log.info("reading %s" % dcm_path) dcm = dicom.read_file(dcm_path, force=config_force) # Here we check for the Raw Data Storage SOP Class, if there # are other DICOM files in the zip then we read the next one, # if this is the only class of DICOM in the file, we accept # our fate and move on. - if dcm.get('SOPClassUID') == 'Raw Data Storage' and n != range((num_files -1), -1, -1)[-1]: + if ( + dcm.get("SOPClassUID") == "Raw Data Storage" + and n != range((num_files - 1), -1, -1)[-1] + ): continue else: break except: pass else: - log.warning('%s does not exist!' % dcm_path) + log.warning("%s does not exist!" % dcm_path) else: - log.info('Not a zip. Attempting to read %s directly' % os.path.basename(zip_file_path)) + log.info( + "Not a zip. Attempting to read %s directly" + % os.path.basename(zip_file_path) + ) dcm = dicom.read_file(zip_file_path) - if not dcm: - log.warning('DICOM could not be read! Is this a valid DICOM file? To force parsing the file, run again setting "force" configuration option to "true"') + log.warning( + 'DICOM could not be read! Is this a valid DICOM file? To force parsing the file, run again setting "force" configuration option to "true"' + ) os.sys.exit(1) # Build metadata metadata = {} # Session metadata - metadata['session'] = {} - session_timestamp, acquisition_timestamp = get_timestamp(dcm, timezone); + metadata["session"] = {} + session_timestamp, acquisition_timestamp = get_timestamp(dcm, timezone) if session_timestamp: - metadata['session']['timestamp'] = session_timestamp - if hasattr(dcm, 'OperatorsName') and dcm.get('OperatorsName'): - metadata['session']['operator'] = format_string(dcm.get('OperatorsName')) + metadata["session"]["timestamp"] = session_timestamp + if hasattr(dcm, "OperatorsName") and dcm.get("OperatorsName"): + metadata["session"]["operator"] = format_string(dcm.get("OperatorsName")) session_label = get_session_label(dcm) if session_label: - metadata['session']['label'] = session_label + metadata["session"]["label"] = session_label - if hasattr(dcm, 'PatientWeight') and dcm.get('PatientWeight'): - metadata['session']['weight'] = assign_type(dcm.get('PatientWeight')) + if hasattr(dcm, "PatientWeight") and dcm.get("PatientWeight"): + metadata["session"]["weight"] = assign_type(dcm.get("PatientWeight")) # Subject Metadata - metadata['session']['subject'] = {} - if hasattr(dcm, 'PatientSex') and get_sex_string(dcm.get('PatientSex')): - metadata['session']['subject']['sex'] = get_sex_string(dcm.get('PatientSex')) - if hasattr(dcm, 'PatientAge') and dcm.get('PatientAge'): + metadata["session"]["subject"] = {} + if hasattr(dcm, "PatientSex") and get_sex_string(dcm.get("PatientSex")): + metadata["session"]["subject"]["sex"] = get_sex_string(dcm.get("PatientSex")) + if hasattr(dcm, "PatientAge") and dcm.get("PatientAge"): try: - age = parse_patient_age(dcm.get('PatientAge')) + age = parse_patient_age(dcm.get("PatientAge")) if age: - metadata['session']['subject']['age'] = int(age) + metadata["session"]["subject"]["age"] = int(age) except: pass - if hasattr(dcm, 'PatientName') and dcm.get('PatientName').given_name: + if hasattr(dcm, "PatientName") and dcm.get("PatientName").given_name: # If the first name or last name field has a space-separated string, and one or the other field is not # present, then we assume that the operator put both first and last names in that one field. We then # parse that field to populate first and last name. - metadata['session']['subject']['firstname'] = str(format_string(dcm.get('PatientName').given_name)) - if not dcm.get('PatientName').family_name: - name = format_string(dcm.get('PatientName').given_name.split(' ')) + metadata["session"]["subject"]["firstname"] = str( + format_string(dcm.get("PatientName").given_name) + ) + if not dcm.get("PatientName").family_name: + name = format_string(dcm.get("PatientName").given_name.split(" ")) if len(name) == 2: first = name[0] last = name[1] - metadata['session']['subject']['lastname'] = str(last) - metadata['session']['subject']['firstname'] = str(first) - if hasattr(dcm, 'PatientName') and dcm.get('PatientName').family_name: - metadata['session']['subject']['lastname'] = str(format_string(dcm.get('PatientName').family_name)) - if not dcm.get('PatientName').given_name: - name = format_string(dcm.get('PatientName').family_name.split(' ')) + metadata["session"]["subject"]["lastname"] = str(last) + metadata["session"]["subject"]["firstname"] = str(first) + if hasattr(dcm, "PatientName") and dcm.get("PatientName").family_name: + metadata["session"]["subject"]["lastname"] = str( + format_string(dcm.get("PatientName").family_name) + ) + if not dcm.get("PatientName").given_name: + name = format_string(dcm.get("PatientName").family_name.split(" ")) if len(name) == 2: first = name[0] last = name[1] - metadata['session']['subject']['lastname'] = str(last) - metadata['session']['subject']['firstname'] = str(first) + metadata["session"]["subject"]["lastname"] = str(last) + metadata["session"]["subject"]["firstname"] = str(first) # File classification dicom_file = {} - dicom_file['name'] = os.path.basename(zip_file_path) - dicom_file['modality'] = format_string(dcm.get('Modality', 'MR')) - dicom_file['classification'] = {} + dicom_file["name"] = os.path.basename(zip_file_path) + dicom_file["modality"] = format_string(dcm.get("Modality", "MR")) + dicom_file["classification"] = {} # Acquisition metadata - metadata['acquisition'] = {} + metadata["acquisition"] = {} if acquisition_timestamp: - metadata['acquisition']['timestamp'] = acquisition_timestamp + metadata["acquisition"]["timestamp"] = acquisition_timestamp - if hasattr(dcm, 'Modality') and dcm.get('Modality'): - metadata['acquisition']['instrument'] = format_string(dcm.get('Modality')) + if hasattr(dcm, "Modality") and dcm.get("Modality"): + metadata["acquisition"]["instrument"] = format_string(dcm.get("Modality")) - series_desc = format_string(dcm.get('SeriesDescription', '')) + series_desc = format_string(dcm.get("SeriesDescription", "")) if series_desc: - metadata['acquisition']['label'] = series_desc + metadata["acquisition"]["label"] = series_desc classification = get_custom_classification(series_desc, config) - log.info('Custom classification from config: %s', classification) + log.info("Custom classification from config: %s", classification) if not classification: classification = classification_from_label.infer_classification(series_desc) - log.info('Inferred classification from label: %s', classification) - dicom_file['classification'] = classification + log.info("Inferred classification from label: %s", classification) + dicom_file["classification"] = classification # If no pixel data present, make classification intent "Non-Image" - if not hasattr(dcm, 'PixelData'): - nonimage_intent = {'Intent': ['Non-Image']} + if not hasattr(dcm, "PixelData"): + nonimage_intent = {"Intent": ["Non-Image"]} # If classification is a dict, update dict with intent - if isinstance(dicom_file['classification'], dict): - dicom_file['classification'].update(nonimage_intent) + if isinstance(dicom_file["classification"], dict): + dicom_file["classification"].update(nonimage_intent) # Else classification is a list, assign dict with intent else: - dicom_file['classification'] = nonimage_intent + dicom_file["classification"] = nonimage_intent # File info from dicom header - dicom_file['info'] = get_dicom_header(dcm) + dicom_file["info"] = get_dicom_header(dcm) # Grab CSA header for Siemens data - if dcm.get('Manufacturer') == 'SIEMENS': + if dcm.get("Manufacturer") == "SIEMENS": csa_header = get_csa_header(dcm) if csa_header: - dicom_file['info']['CSAHeader'] = csa_header + dicom_file["info"]["CSAHeader"] = csa_header # Append the dicom_file to the files array - metadata['acquisition']['files'] = [dicom_file] + metadata["acquisition"]["files"] = [dicom_file] # Write out the metadata to file (.metadata.json) - metafile_outname = os.path.join(os.path.dirname(outbase),'.metadata.json') - with open(metafile_outname, 'w') as metafile: + metafile_outname = os.path.join(os.path.dirname(outbase), ".metadata.json") + with open(metafile_outname, "w") as metafile: json.dump(metadata, metafile) # Show the metadata @@ -491,21 +536,26 @@ def dicom_classify(zip_file_path, outbase, timezone, config=None): return metafile_outname -if __name__ == '__main__': +if __name__ == "__main__": """ Generate session, subject, and acquisition metatada by parsing the dicom header, using pydicom. """ import argparse + ap = argparse.ArgumentParser() - ap.add_argument('dcmzip', help='path to dicom zip') - ap.add_argument('outbase', nargs='?', help='outfile name prefix') - ap.add_argument('--log_level', help='logging level', default='info') - ap.add_argument('--config-file', default='/flywheel/v0/config.json', help='Configuration file with custom classifications in context') + ap.add_argument("dcmzip", help="path to dicom zip") + ap.add_argument("outbase", nargs="?", help="outfile name prefix") + ap.add_argument("--log_level", help="logging level", default="info") + ap.add_argument( + "--config-file", + default="/flywheel/v0/config.json", + help="Configuration file with custom classifications in context", + ) args = ap.parse_args() log.setLevel(getattr(logging, args.log_level.upper())) - logging.getLogger('sctran.data').setLevel(logging.INFO) - log.info('start: %s' % datetime.datetime.utcnow()) + logging.getLogger("sctran.data").setLevel(logging.INFO) + log.info("start: %s" % datetime.datetime.utcnow()) args.timezone = validate_timezone(tzlocal.get_localzone()) @@ -519,8 +569,8 @@ def dicom_classify(zip_file_path, outbase, timezone, config=None): metadatafile = dicom_classify(args.dcmzip, args.outbase, args.timezone, config) if os.path.exists(metadatafile): - log.info('generated %s' % metadatafile) + log.info("generated %s" % metadatafile) else: - log.info('failure! %s was not generated!' % metadatafile) + log.info("failure! %s was not generated!" % metadatafile) - log.info('stop: %s' % datetime.datetime.utcnow()) + log.info("stop: %s" % datetime.datetime.utcnow()) diff --git a/manifest.json b/manifest.json index 882a71f..410ce64 100644 --- a/manifest.json +++ b/manifest.json @@ -5,14 +5,13 @@ "maintainer": "Michael Perry ", "author": "Michael Perry ", "url": "https://github.com/scitran-apps/dicom-mr-classifier", - "source": "https://github.com/scitran-apps/dicom-mr-classifier/releases/tag/1.3.0", + "source": "https://github.com/scitran-apps/dicom-mr-classifier/releases", "license": "Apache-2.0", "flywheel": "0", - "version": "1.3.0", + "version": "1.3.1", "custom": { - "docker-image": "scitran/dicom-mr-classifier:1.3.0", "gear-builder": { - "image": "scitran/dicom-mr-classifier:1.3.0", + "image": "scitran/dicom-mr-classifier:1.3.1", "category": "converter" }, "flywheel": {