From 124e88127b019ecaf01762a1c54d9654b17c09ec Mon Sep 17 00:00:00 2001 From: Bryn Lloyd <12702862+dyollb@users.noreply.github.com> Date: Sat, 14 Sep 2024 20:10:51 +0200 Subject: [PATCH] add pre-commit with ruff and typos, run --all-files --- .pre-commit-config.yaml | 24 +++ README.md | 4 +- UsingStandardTerminology.md | 4 +- setup.py | 2 +- slicerio/__init__.py | 28 +-- slicerio/_version.py | 4 +- slicerio/data_helper.py | 5 +- slicerio/segmentation.py | 256 ++++++++++++++++++---------- slicerio/server.py | 92 ++++++---- slicerio/tests/test_segmentation.py | 210 +++++++++++++++-------- 10 files changed, 421 insertions(+), 208 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..eb52209 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + args: [--allow-multiple-documents] + - id: end-of-file-fixer + exclude: '.*\.nrrd$' + - id: trailing-whitespace + exclude: '.*\.nrrd$' + - id: check-executables-have-shebangs + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.5 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/crate-ci/typos + rev: v1.24.5 + hooks: + - id: typos + exclude: '.*\.nrrd$' diff --git a/README.md b/README.md index 79f5b6b..5821d2e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ print("First segment info:\n" + json.dumps(segment0, sort_keys=False, indent=4)) #### Extract segments by terminology Example for getting a 3D [NRRD](SegmentationFileFormat.md) file that has label values assigned based on [standard terminology codes](UsingStandardTerminology.md). -Terminology is a `dict` that must specify `category` and `type` codes and may optionally also specify `typeModifier`, `anatomicRegion`, and `anatomicRegionModifier`. Each code is specifed by a triplet of "coding scheme designator", "code value", "code meaning" in a list. +Terminology is a `dict` that must specify `category` and `type` codes and may optionally also specify `typeModifier`, `anatomicRegion`, and `anatomicRegionModifier`. Each code is specified by a triplet of "coding scheme designator", "code value", "code meaning" in a list. Coding scheme designator is typically `SCT` (SNOMED-CT) for clinical images. You can find codes in the [SNOMED-CT browser](https://browser.ihtsdotools.org/). When code exists for "entire X" and "structure of X" then always use the "structure" code ("entire" code has a very strict meaning that is rarely applicable in practice). @@ -227,7 +227,7 @@ for segmentId in segments: List of available nodes can be retrieved using `node_names` and `node_ids`functions: ```python -# Retreve node names of all images +# Retrieve node names of all images slicerio.server.node_names(class_name="vtkMRMLVolumeNode") # Retrieve all node IDs diff --git a/UsingStandardTerminology.md b/UsingStandardTerminology.md index 2db0221..966d4c8 100644 --- a/UsingStandardTerminology.md +++ b/UsingStandardTerminology.md @@ -6,10 +6,10 @@ Segmentation tools usually provide a labelmap volume as output, with each label To avoid these issues, standard terms can be used to specify what each segment represents. A standard term has well-defined meaning, thus allowing aggregating data from many sources, and since they have to be selected from a predefined list, there is no risk of typos or inconsistencies that often occur when typing free text. -Terminology is a `dict` that must specify `category` and `type` codes and may optionally also specify `typeModifier`, `anatomicRegion`, and `anatomicRegionModifier`. Each code is specifed by a triplet of "coding scheme designator", "code value", "code meaning" in a list. +Terminology is a `dict` that must specify `category` and `type` codes and may optionally also specify `typeModifier`, `anatomicRegion`, and `anatomicRegionModifier`. Each code is specified by a triplet of "coding scheme designator", "code value", "code meaning" in a list. `Coding scheme designator` is typically `SCT` (SNOMED-CT) for clinical images. You can find codes in the [SNOMED-CT browser](https://browser.ihtsdotools.org/). `Code value` is a unique string within the coding scheme, most commonly an integer number. `Code meaning` (third component of codes, such as "Anatomical Structure", "Ribs", "Right") is informational only, it can be used for troubleshooting or displayed to the user, but it is ignored in information processing (e.g., two codes match if their coding scheme designator and code value are the same even if code meaning is different). -According to the DICOM standard, content of a segment is specified by its `category` (such as "Anatomical structure", "Physical object", "Body substance", ...) and `type` (such as "Liver" or "Cardiac Pacemaker", ...). Optionally a modifier (`typeModifier`, such as "left" or "right") and anatomical location (`anatomicRegion` and `anatomicRegionModifier`) can be specified as well. Each of these are specifed by a standard code, which is a triplet of "coding scheme designator", "code value", and "code meaning": +According to the DICOM standard, content of a segment is specified by its `category` (such as "Anatomical structure", "Physical object", "Body substance", ...) and `type` (such as "Liver" or "Cardiac Pacemaker", ...). Optionally a modifier (`typeModifier`, such as "left" or "right") and anatomical location (`anatomicRegion` and `anatomicRegionModifier`) can be specified as well. Each of these are specified by a standard code, which is a triplet of "coding scheme designator", "code value", and "code meaning": - `Coding scheme designator` is a string identifying the terminology. It is typically set to `SCT` (SNOMED-CT) for clinical images. However, Uberon (`UBERON`), Foundational Model of Anatomy (`FMA`) and [others](https://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_8.html) may be used, too. - `Code value` is a unique string within the coding scheme, most commonly an integer number. - `Code meaning` is a human-readable meaning of the term (such as "Anatomical Structure", "Ribs", "Right"). It is informational only: it can be used for troubleshooting or displayed to the user, but it is ignored in information processing. For example, two codes match if and only if their coding scheme designator and code value are the same; it does not matter the if code meaning values are the same. diff --git a/setup.py b/setup.py index 0ae4555..26e08e4 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ from setuptools import setup -if __name__ == '__main__': +if __name__ == "__main__": setup() diff --git a/slicerio/__init__.py b/slicerio/__init__.py index c35f0c9..0bba327 100644 --- a/slicerio/__init__.py +++ b/slicerio/__init__.py @@ -1,4 +1,4 @@ -"""slicerio package -- Python utilities for readin and writing 3D Slicer supporte data sets. +"""slicerio package -- Python utilities for readin and writing 3D Slicer supported data sets. ----------- Quick Start @@ -12,17 +12,23 @@ """ -from .segmentation import extract_segments, read_segmentation, write_segmentation, segment_from_name, segment_names +from .segmentation import ( + extract_segments, + read_segmentation, + write_segmentation, + segment_from_name, + segment_names, +) from .data_helper import get_testdata_file from ._version import __version__, __version_info__ __all__ = [ - 'extract_segments', - 'get_testdata_file', - 'read_segmentation', - 'write_segmentation', - 'segment_from_name', - 'segment_names', - '__version__', - '__version_info__' - ] + "extract_segments", + "get_testdata_file", + "read_segmentation", + "write_segmentation", + "segment_from_name", + "segment_names", + "__version__", + "__version_info__", +] diff --git a/slicerio/_version.py b/slicerio/_version.py index 3668a57..3e86dcf 100644 --- a/slicerio/_version.py +++ b/slicerio/_version.py @@ -1,8 +1,8 @@ import re from typing import Tuple -__version__: str = '1.1.1' +__version__: str = "1.1.1" __version_info__: Tuple[str, str, str] = tuple( - re.match(r'(\d+\.\d+\.\d+).*', __version__).group(1).split('.') + re.match(r"(\d+\.\d+\.\d+).*", __version__).group(1).split(".") ) diff --git a/slicerio/data_helper.py b/slicerio/data_helper.py index 305aa64..899e75c 100644 --- a/slicerio/data_helper.py +++ b/slicerio/data_helper.py @@ -1,11 +1,10 @@ # Copyright 2008-2020 pydicom authors. See LICENSE file for details. -"""Helper functions for accessing test data. -""" +"""Helper functions for accessing test data.""" import os from pathlib import Path -DATA_ROOT = Path(__file__).parent.joinpath('data') +DATA_ROOT = Path(__file__).parent.joinpath("data") """The absolute path to the data_files directory.""" diff --git a/slicerio/segmentation.py b/slicerio/segmentation.py index c30f60b..c7d9921 100644 --- a/slicerio/segmentation.py +++ b/slicerio/segmentation.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - def terminology_entry_from_string(terminology_str): """Converts a terminology string to a dict. @@ -28,60 +26,60 @@ def terminology_entry_from_string(terminology_str): https://slicer.readthedocs.io/en/latest/developer_guide/modules/segmentations.html#terminologyentry-tag """ - terminology_items = terminology_str.split('~') + terminology_items = terminology_str.split("~") terminology = {} - terminology['contextName'] = terminology_items[0] + terminology["contextName"] = terminology_items[0] - terminology['category'] = terminology_items[1].split("^") - terminology['type'] = terminology_items[2].split("^") + terminology["category"] = terminology_items[1].split("^") + terminology["type"] = terminology_items[2].split("^") typeModifier = terminology_items[3].split("^") - if any(item != '' for item in typeModifier): - terminology['typeModifier'] = typeModifier + if any(item != "" for item in typeModifier): + terminology["typeModifier"] = typeModifier anatomicContextName = terminology_items[4] if anatomicContextName: - terminology['anatomicContextName'] = anatomicContextName + terminology["anatomicContextName"] = anatomicContextName anatomicRegion = terminology_items[5].split("^") - if any(item != '' for item in anatomicRegion): - terminology['anatomicRegion'] = anatomicRegion + if any(item != "" for item in anatomicRegion): + terminology["anatomicRegion"] = anatomicRegion anatomicRegionModifier = terminology_items[6].split("^") - if any(item != '' for item in anatomicRegionModifier): - terminology['anatomicRegionModifier'] = anatomicRegionModifier + if any(item != "" for item in anatomicRegionModifier): + terminology["anatomicRegionModifier"] = anatomicRegionModifier return terminology + def terminology_entry_to_string(terminology): - """Converts a terminology dict to string. - """ + """Converts a terminology dict to string.""" terminology_str = "" - if 'contextName' in terminology: - terminology_str += terminology['contextName'] + if "contextName" in terminology: + terminology_str += terminology["contextName"] else: terminology_str += "" - terminology_str += '~' + '^'.join(terminology['category']) - terminology_str += '~' + '^'.join(terminology['type']) - if 'typeModifier' in terminology: - typeModifier = terminology['typeModifier'] + terminology_str += "~" + "^".join(terminology["category"]) + terminology_str += "~" + "^".join(terminology["type"]) + if "typeModifier" in terminology: + typeModifier = terminology["typeModifier"] else: - typeModifier = ['', '', ''] - terminology_str += '~' + '^'.join(typeModifier) + typeModifier = ["", "", ""] + terminology_str += "~" + "^".join(typeModifier) - if 'anatomicContextName' in terminology: - terminology_str += "~" + terminology['anatomicContextName'] + if "anatomicContextName" in terminology: + terminology_str += "~" + terminology["anatomicContextName"] else: terminology_str += "~" - if 'anatomicRegion' in terminology: - anatomic_region = terminology['anatomicRegion'] + if "anatomicRegion" in terminology: + anatomic_region = terminology["anatomicRegion"] else: - anatomic_region = ['', '', ''] - terminology_str += '~' + '^'.join(anatomic_region) - if 'anatomicRegionModifier' in terminology: - anatomic_region_modifier = terminology['anatomicRegionModifier'] + anatomic_region = ["", "", ""] + terminology_str += "~" + "^".join(anatomic_region) + if "anatomicRegionModifier" in terminology: + anatomic_region_modifier = terminology["anatomicRegionModifier"] else: - anatomic_region_modifier = ['', '', ''] - terminology_str += '~' + '^'.join(anatomic_region_modifier) + anatomic_region_modifier = ["", "", ""] + terminology_str += "~" + "^".join(anatomic_region_modifier) return terminology_str @@ -90,6 +88,7 @@ def generate_unique_segment_id(existing_segment_ids): """Generate a unique segment ID, i.e., an ID that is not among existing_segment_ids. It follows DICOM convention to allow using this ID in DICOM Segmentation objects.""" import uuid + while True: segment_id = f"2.25.{uuid.uuid4().int}" if segment_id not in existing_segment_ids: @@ -143,7 +142,7 @@ def read_segmentation(filename, skip_voxels=False): Segmentation_ConversionParameters:=Collapse labelmaps|1|Merge the labelmaps into as few shared labelmaps as possible 1 = created labelmaps will be shared if possible without overwriting each other.&Compute surface normals|1|Compute surface normals. 1 (default) = surface normals are computed. 0 = surface normals are not computed (slightly faster but produces less smooth surface display).&Crop to reference image geometry|0|Crop the model to the extent of reference geometry. 0 (default) = created labelmap will contain the entire model. 1 = created labelmap extent will be within reference image extent.&Decimation factor|0.0|Desired reduction in the total number of polygons. Range: 0.0 (no decimation) to 1.0 (as much simplification as possible). Value of 0.8 typically reduces data set size by 80% without losing too much details.&Default slice thickness|0.0|Default thickness for contours if slice spacing cannot be calculated.&End capping|1|Create end cap to close surface inside contours on the top and bottom of the structure.\n0 = leave contours open on surface exterior.\n1 (default) = close surface by generating smooth end caps.\n2 = close surface by generating straight end caps.&Fractional labelmap oversampling factor|1|Determines the oversampling of the reference image geometry. All segments are oversampled with the same value (value of 1 means no oversampling).&Joint smoothing|0|Perform joint smoothing.&Oversampling factor|1|Determines the oversampling of the reference image geometry. If it's a number, then all segments are oversampled with the same value (value of 1 means no oversampling). If it has the value "A", then automatic oversampling is calculated.&Reference image geometry|3.0468759536743195;0;0;-193.0959930419922;0;3.0468759536743195;0;-216.39599609374994;0;0;9.999999999999998;-340.24999999999994;0;0;0;1;0;127;0;127;0;33;|Image geometry description string determining the geometry of the labelmap that is created in course of conversion. Can be copied from a volume, using the button.&Smoothing factor|-0.5|Smoothing factor. Range: 0.0 (no smoothing) to 1.0 (strong smoothing).&Threshold fraction|0.5|Determines the threshold that the closed surface is created at as a fractional value between 0 and 1.& Segmentation_MasterRepresentation:=Binary labelmap Segmentation_ReferenceImageExtentOffset:=0 0 0 - + Example header in case of overlapping segments: NRRD0004 @@ -184,7 +183,7 @@ def read_segmentation(filename, skip_voxels=False): {"name": "Threshold fraction", "value": "0.5", "description": "Determines the threshold that the closed surface is created at as a fractional value between 0 and 1."} ], "masterRepresentation": "Binary labelmap", - "referenceImageExtentOffset": [0, 0, 0] + "referenceImageExtentOffset": [0, 0, 0] "segments": [ { "color": [0.992157, 0.909804, 0.619608], @@ -235,7 +234,6 @@ def read_segmentation(filename, skip_voxels=False): else: voxels, header = nrrd.read(filename) except nrrd.errors.NRRDError as e: - # Not a NRRD file, maybe it is a NIFTI file that contains label image. # In that case, read just the voxels and IJK to LPS matrix. if filename.endswith(".nii") or filename.endswith(".nii.gz"): @@ -256,7 +254,9 @@ def read_segmentation(filename, skip_voxels=False): # NIFTI affine is IJK to RAS, we convert it to IJK to LPS segmentation["ijkToLPS"] = np.diag([-1, -1, 1, 1]).dot(nifti_image.affine) - segmentation["voxels"] = None if skip_voxels else nifti_image.get_fdata().astype(np.int16) + segmentation["voxels"] = ( + None if skip_voxels else nifti_image.get_fdata().astype(np.int16) + ) # Add segment for each label value label_values = np.unique(segmentation["voxels"]) @@ -298,15 +298,19 @@ def read_segmentation(filename, skip_voxels=False): spaceToLps = np.diag([-1.0, -1.0, 1.0, 1.0]) else: # LPS and RAS are the most commonly used image orientations, for now we only support these - raise IOError("space field must be 'left-posterior-superior' or 'right-anterior-superior'") + raise IOError( + "space field must be 'left-posterior-superior' or 'right-anterior-superior'" + ) continue - elif header_key == 'kinds': - if header[header_key] == ['domain', 'domain', 'domain']: - multiple_layers = False - elif header[header_key] == ['list', 'domain', 'domain', 'domain']: - multiple_layers = True + elif header_key == "kinds": + if header[header_key] == ["domain", "domain", "domain"]: + multiple_layers = False # noqa: F841 + elif header[header_key] == ["list", "domain", "domain", "domain"]: + multiple_layers = True # noqa: F841 else: - raise IOError("kinds field must be 'domain domain domain' or 'list domain domain domain'") + raise IOError( + "kinds field must be 'domain domain domain' or 'list domain domain domain'" + ) continue elif header_key == "space origin": ijkToSpace[0:3, 3] = header[header_key] @@ -322,7 +326,9 @@ def read_segmentation(filename, skip_voxels=False): elif header_key == "Segmentation_ContainedRepresentationNames": # Segmentation_ContainedRepresentationNames:=Binary labelmap|Closed surface| representations = header[header_key].split("|") - representations[:] = [item for item in representations if item != ''] # Remove empty elements + representations[:] = [ + item for item in representations if item != "" + ] # Remove empty elements segmentation["containedRepresentationNames"] = representations continue elif header_key == "Segmentation_ConversionParameters": @@ -335,8 +341,16 @@ def read_segmentation(filename, skip_voxels=False): continue parameter_info = parameter_str.split("|") if len(parameter_info) != 3: - raise IOError("Segmentation_ConversionParameters field value is invalid (each parameter must be defined by 3 strings)") - parameters.append({"name": parameter_info[0], "value": parameter_info[1], "description": parameter_info[2]}) + raise IOError( + "Segmentation_ConversionParameters field value is invalid (each parameter must be defined by 3 strings)" + ) + parameters.append( + { + "name": parameter_info[0], + "value": parameter_info[1], + "description": parameter_info[2], + } + ) if parameters: segmentation["conversionParameters"] = parameters continue @@ -346,9 +360,11 @@ def read_segmentation(filename, skip_voxels=False): continue elif header_key == "Segmentation_ReferenceImageExtentOffset": # Segmentation_ReferenceImageExtentOffset:=0 0 0 - segmentation["referenceImageExtentOffset"] = [int(i) for i in header[header_key].split(" ")] + segmentation["referenceImageExtentOffset"] = [ + int(i) for i in header[header_key].split(" ") + ] continue - + segment_match = re.match("^Segment([0-9]+)_(.+)", header_key) if segment_match: # Store in segment_fields (segmentation field) @@ -358,7 +374,7 @@ def read_segmentation(filename, skip_voxels=False): segments_fields[segment_index] = {} segments_fields[segment_index][segment_key] = header[header_key] continue - + segmentation[header_key] = header[header_key] # Compute voxel to physical transformation matrix @@ -384,24 +400,36 @@ def read_segmentation(filename, skip_voxels=False): else: segment_id = generate_unique_segment_id(segment_ids) segment_ids.add(segment_id) - logging.debug(f"Segment ID was not found for index {segment_index}, use automatically generated ID: {segment_id}") + logging.debug( + f"Segment ID was not found for index {segment_index}, use automatically generated ID: {segment_id}" + ) segment_info = {} segment_info["id"] = segment_id if "Color" in segment_fields: - segment_info["color"] = [float(i) for i in segment_fields["Color"].split(" ")] # Segment0_Color:=0.501961 0.682353 0.501961 + segment_info["color"] = [ + float(i) for i in segment_fields["Color"].split(" ") + ] # Segment0_Color:=0.501961 0.682353 0.501961 if "ColorAutoGenerated" in segment_fields: - segment_info["colorAutoGenerated"] = int(segment_fields["ColorAutoGenerated"]) != 0 # Segment0_ColorAutoGenerated:=1 + segment_info["colorAutoGenerated"] = ( + int(segment_fields["ColorAutoGenerated"]) != 0 + ) # Segment0_ColorAutoGenerated:=1 if "Extent" in segment_fields: - segment_info["extent"] = [int(i) for i in segment_fields["Extent"].split(" ")] # Segment0_Extent:=68 203 53 211 24 118 + segment_info["extent"] = [ + int(i) for i in segment_fields["Extent"].split(" ") + ] # Segment0_Extent:=68 203 53 211 24 118 if "LabelValue" in segment_fields: - segment_info["labelValue"] = int(segment_fields["LabelValue"]) # Segment0_LabelValue:=1 + segment_info["labelValue"] = int( + segment_fields["LabelValue"] + ) # Segment0_LabelValue:=1 if "Layer" in segment_fields: segment_info["layer"] = int(segment_fields["Layer"]) # Segment0_Layer:=0 if "Name" in segment_fields: segment_info["name"] = segment_fields["Name"] # Segment0_Name:=Segment_1 if "NameAutoGenerated" in segment_fields: - segment_info["nameAutoGenerated"] = int(segment_fields["NameAutoGenerated"]) != 0 # Segment0_NameAutoGenerated:=1 + segment_info["nameAutoGenerated"] = ( + int(segment_fields["NameAutoGenerated"]) != 0 + ) # Segment0_NameAutoGenerated:=1 # Segment0_Tags:=Segmentation.Status:inprogress|TerminologyEntry:Segmentation category and type - 3D Slicer General Anatomy list # ~SCT^85756007^Tissue~SCT^85756007^Tissue~^^~Anatomic codes - DICOM master list~^^~^^| if "Tags" in segment_fields: @@ -473,7 +501,9 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None elif key == "referenceImageExtentOffset": # Segmentation_ReferenceImageExtentOffset:=0 0 0 offset = segmentation[key] - output_header["Segmentation_ReferenceImageExtentOffset"] = " ".join([str(i) for i in offset]) + output_header["Segmentation_ReferenceImageExtentOffset"] = " ".join( + [str(i) for i in offset] + ) else: output_header[key] = segmentation[key] @@ -483,9 +513,9 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None # space directions: (0,1,0) (0,0,-1) (-1.2999954223632812,0,0) # 'space directions', array([ - # [ 0. , 1. , 0. ], - # [ 0. , 0. , -1. ], - # [-1.29999542, 0. , 0. ]])) + # [ 0. , 1. , 0. ], + # [ 0. , 0. , -1. ], + # [-1.29999542, 0. , 0. ]])) space_directions = np.array(ijkToLPS)[0:3, 0:3].T # Add 4th dimension metadata if array is 4-dimensional (there are overlapping segments) @@ -500,7 +530,6 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None # [ 0. , 1. , 0. ], # [ 0. , 0. , -1. ], # [-1.29999542, 0. , 0. ]])) - nan_column = np.array() space_directions = np.row_stack(([np.nan, np.nan, np.nan], space_directions)) elif dims != 3: raise ValueError("Unsupported number of dimensions: " + str(dims)) @@ -508,7 +537,9 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None output_header["kinds"] = kinds output_header["space directions"] = space_directions output_header["space origin"] = np.array(ijkToLPS)[0:3, 3] - output_header["space"] = "left-posterior-superior" # DICOM uses LPS coordinate system + output_header["space"] = ( + "left-posterior-superior" # DICOM uses LPS coordinate system + ) # Set defaults if "encoding" not in segmentation: @@ -520,14 +551,13 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None # Add segments fields to the header - # Get list of segment IDs (needed if we need to genereate new ID) + # Get list of segment IDs (needed if we need to generate new ID) segment_ids = set() for output_segment_index, segment in enumerate(segmentation["segments"]): if "id" in segment: segment_ids.add(segment["id"]) for output_segment_index, segment in enumerate(segmentation["segments"]): - # Copy all segment fields corresponding to this segment output_tags = [] for segment_key in segment: @@ -543,14 +573,14 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None # Segment0_Name:=Segment_1 field_name = "Name" value = segment[segment_key] - elif segment_key == "id": + elif segment_key == "id": # Segment0_ID:=Segment_1 field_name = "ID" value = segment[segment_key] elif segment_key == "color": # Segment0_Color:=0.501961 0.682353 0.501961 field_name = "Color" - value = ' '.join([str(i) for i in segment[segment_key]]) + value = " ".join([str(i) for i in segment[segment_key]]) elif segment_key == "nameAutoGenerated": # Segment0_NameAutoGenerated:=1 field_name = "NameAutoGenerated" @@ -583,13 +613,13 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None elif segment_key == "extent": # Segment0_Extent:=68 203 53 211 24 118 field_name = "Extent" - value = ' '.join([str(i) for i in segment[segment_key]]) + value = " ".join([str(i) for i in segment[segment_key]]) else: field_name = segment_key value = segment[segment_key] output_header[f"Segment{output_segment_index}_{field_name}"] = value - + if "id" not in segment: # If user has not specified ID, generate a unique one new_segment_id = generate_unique_segment_id(segment_ids) @@ -603,17 +633,28 @@ def write_segmentation(file, segmentation, compression_level=9, index_order=None if "extent" not in segment: # If user has not specified extent, set it to the full extent output_shape = voxels.shape[-3:] - output_header[f"Segment{output_segment_index}_Extent"] = f"0 {output_shape[0]-1} 0 {output_shape[1]-1} 0 {output_shape[2]-1}" + output_header[f"Segment{output_segment_index}_Extent"] = ( + f"0 {output_shape[0]-1} 0 {output_shape[1]-1} 0 {output_shape[2]-1}" + ) # Add tags # Need to end with "|" as earlier Slicer versions require this - output_header[f"Segment{output_segment_index}_Tags"] = "|".join(output_tags) + "|" + output_header[f"Segment{output_segment_index}_Tags"] = ( + "|".join(output_tags) + "|" + ) # Write segmentation to file if index_order is None: - index_order = 'F' + index_order = "F" import nrrd - nrrd.write(file, voxels, output_header, compression_level=compression_level, index_order=index_order) + + nrrd.write( + file, + voxels, + output_header, + compression_level=compression_level, + index_order=index_order, + ) def segment_from_name(segmentation, segment_name): @@ -642,6 +683,7 @@ def segments_from_terminology(segmentation, terminology): found_segments.append(segment) return found_segments + def terminology_code_matches(code1, code2): # Coding scheme designator if code1[0] != code2[0]: @@ -651,17 +693,20 @@ def terminology_code_matches(code1, code2): return False return True + def terminology_entry_matches(terminology1, terminology2): # Category - if not terminology_code_matches(terminology1['category'], terminology2['category']): + if not terminology_code_matches(terminology1["category"], terminology2["category"]): return False # Type - if not terminology_code_matches(terminology1['type'], terminology2['type']): + if not terminology_code_matches(terminology1["type"], terminology2["type"]): return False # Optional type modifier if "typeModifier" in terminology1 and "typeModifier" in terminology2: # Both have type modifier - if not terminology_code_matches(terminology1['typeModifier'], terminology2['typeModifier']): + if not terminology_code_matches( + terminology1["typeModifier"], terminology2["typeModifier"] + ): return False elif "typeModifier" in terminology1 or "typeModifier" in terminology2: # Only one of the two has type modifier @@ -669,14 +714,25 @@ def terminology_entry_matches(terminology1, terminology2): # Optional anatomic region if "anatomicRegion" in terminology1 and "anatomicRegion" in terminology2: # Both have anatomic region - if not terminology_code_matches(terminology1["anatomicRegion"], terminology2["anatomicRegion"]): + if not terminology_code_matches( + terminology1["anatomicRegion"], terminology2["anatomicRegion"] + ): return False # Optional anatomic region modifier - if "anatomicRegionModifier" in terminology1 and "anatomicRegionModifier" in terminology2: + if ( + "anatomicRegionModifier" in terminology1 + and "anatomicRegionModifier" in terminology2 + ): # Both have anatomic region modifier - if not terminology_code_matches(terminology1["anatomicRegionModifier"][0], terminology2["anatomicRegionModifier"][0]): + if not terminology_code_matches( + terminology1["anatomicRegionModifier"][0], + terminology2["anatomicRegionModifier"][0], + ): return False - elif "anatomicRegionModifier" in terminology1 or "anatomicRegionModifier" in terminology2: + elif ( + "anatomicRegionModifier" in terminology1 + or "anatomicRegionModifier" in terminology2 + ): # Only one of the two has anatomic region modifier return False elif "anatomicRegion" in terminology1 or "anatomicRegion" in terminology2: @@ -689,7 +745,7 @@ def terminology_entry_matches(terminology1, terminology2): def segment_id_from_name(segmentation, segment_name): segments = segmentation["segments"] for segment in segments: - if segment_name == segments["name"]: + if segment_name == segment["name"]: return segments["id"] raise KeyError("Segment not found by name " + segment_name) @@ -730,7 +786,7 @@ def extract_segments(segmentation, segment_names_to_label_values, minimalExtent= output_segmentation = OrderedDict() output_segmentation["voxels"] = output_voxels - + for key in segmentation: if key == "voxels": continue @@ -744,19 +800,22 @@ def extract_segments(segmentation, segment_names_to_label_values, minimalExtent= # Copy extracted segments dims = len(voxels.shape) - for output_segment_index, segment_name_to_label_value in enumerate(segment_names_to_label_values): + for output_segment_index, segment_name_to_label_value in enumerate( + segment_names_to_label_values + ): if type(segment_name_to_label_value[0]) is str: # Find segment from terminology segments = segments_from_name(segmentation, segment_name_to_label_value[0]) else: - segments = segments_from_terminology(segmentation, segment_name_to_label_value[0]) + segments = segments_from_terminology( + segmentation, segment_name_to_label_value[0] + ) if not segments: raise ValueError(f"Segment not found: {segment_name_to_label_value[0]}") - segment_id = segments[0]["id"] output_segment = copy.deepcopy(segments[0]) output_label_value = segment_name_to_label_value[1] output_segment["labelValue"] = output_label_value - output_segment["layer"] = 0 # Output is a single layer (3D volume) + output_segment["layer"] = 0 # Output is a single layer (3D volume) unionOfAllExtents = [0, -1, 0, -1, 0, -1] for segment in segments: @@ -766,7 +825,9 @@ def extract_segments(segmentation, segment_names_to_label_values, minimalExtent= segment_voxel_positions = np.where(voxels[:, :, :] == input_label_value) elif dims == 4: inputLayer = segment["layer"] - segment_voxel_positions = np.where(voxels[inputLayer, :, :, :] == input_label_value) + segment_voxel_positions = np.where( + voxels[inputLayer, :, :, :] == input_label_value + ) else: raise ValueError("Voxel array dimension is invalid") output_voxels[segment_voxel_positions] = output_label_value @@ -777,10 +838,15 @@ def extract_segments(segmentation, segment_names_to_label_values, minimalExtent= # Valid extent, expand the current extent if _isValidExtent(unionOfAllExtents): for axis in range(3): - if extent[axis*2] < unionOfAllExtents[axis*2]: - unionOfAllExtents[axis*2] = extent[axis*2] - if extent[axis*2+1] > unionOfAllExtents[axis*2+1]: - unionOfAllExtents[axis*2+1] = extent[axis*2+1] + if extent[axis * 2] < unionOfAllExtents[axis * 2]: + unionOfAllExtents[axis * 2] = extent[axis * 2] + if ( + extent[axis * 2 + 1] + > unionOfAllExtents[axis * 2 + 1] + ): + unionOfAllExtents[axis * 2 + 1] = extent[ + axis * 2 + 1 + ] else: unionOfAllExtents = extent.copy() @@ -790,11 +856,19 @@ def extract_segments(segmentation, segment_names_to_label_values, minimalExtent= # Use the full extent as segment extent. This is a workaround for a Slicer bug # that used the first segment's extent when reading a layer, cropping all other segments # that had larger extent than the first segment. - output_segment["extent"] = [0, output_shape[0]-1, 0, output_shape[1]-1, 0, output_shape[2]-1] + output_segment["extent"] = [ + 0, + output_shape[0] - 1, + 0, + output_shape[1] - 1, + 0, + output_shape[2] - 1, + ] output_segments.append(output_segment) return output_segmentation + def _isValidExtent(extent): return extent[0] <= extent[1] and extent[2] <= extent[3] and extent[4] <= extent[5] diff --git a/slicerio/server.py b/slicerio/server.py index 3175e0b..5c841c9 100644 --- a/slicerio/server.py +++ b/slicerio/server.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import logging import requests @@ -7,6 +5,7 @@ # It can be modified before starting the server if the default port number is not desirable. SERVER_PORT = 2016 + def start_server(slicer_executable=None, timeoutSec=60): """Starts local Slicer server. Requires slicer_executable argument or `SLICER_EXECUTABLE` environment variable to be set to a Slicer executable (version 5.2 or later). @@ -15,57 +14,73 @@ def start_server(slicer_executable=None, timeoutSec=60): import os import subprocess import time + if not slicer_executable: - if 'SLICER_EXECUTABLE' not in os.environ: - raise ValueError('SLICER_EXECUTABLE environment variable is not specified') - slicer_executable = os.environ['SLICER_EXECUTABLE'] - p = subprocess.Popen([slicer_executable, "--python-code", f"wslogic = getModuleLogic('WebServer'); wslogic.port={SERVER_PORT}; wslogic.addDefaultRequestHandlers(); wslogic.start()"]) + if "SLICER_EXECUTABLE" not in os.environ: + raise ValueError("SLICER_EXECUTABLE environment variable is not specified") + slicer_executable = os.environ["SLICER_EXECUTABLE"] + p = subprocess.Popen( + [ + slicer_executable, + "--python-code", + f"wslogic = getModuleLogic('WebServer'); wslogic.port={SERVER_PORT}; wslogic.addDefaultRequestHandlers(); wslogic.start()", + ] + ) start = time.time() connected = False while not connected: connected = is_server_running() if time.time() - start > timeoutSec: - raise requests.exceptions.ConnectTimeout("Timeout while waiting for application to start") + raise requests.exceptions.ConnectTimeout( + "Timeout while waiting for application to start" + ) return p + def stop_server(): - """Stop local Slicer server. - """ + """Stop local Slicer server.""" response = requests.delete(f"http://127.0.0.1:{SERVER_PORT}/system") return response.json() + def is_server_running(): """Check if a local Slicer server is running. Returns true if a responsive Slicer instance is found with Web Server and Slicer API enabled. """ try: - response = requests.get(f"http://127.0.0.1:{SERVER_PORT}/slicer/system/version", timeout=3) - if 'applicationName' in response.json(): + response = requests.get( + f"http://127.0.0.1:{SERVER_PORT}/slicer/system/version", timeout=3 + ) + if "applicationName" in response.json(): # Found a responsive Slicer return True except Exception as e: - logging.debug("Application is not available: "+str(e)) + logging.debug("Application is not available: " + str(e)) return False + def _node_query_parameters(name, id, class_name): param_list = [] import urllib + if name: param_list.append(f"name={urllib.request.quote(name, safe='')}") if id: param_list.append(f"id={urllib.request.quote(id, safe='')}") if class_name: param_list.append(f"class={urllib.request.quote(class_name, safe='')}") - return '&'.join(param_list) + return "&".join(param_list) + def _report_error(response): if response.ok: return - if response.headers['Content-Type'] == 'application/json': + if response.headers["Content-Type"] == "application/json": if "message" in response.json(): raise RuntimeError(response.json()["message"]) raise RuntimeError("Request failed") + def node_remove(name=None, id=None, class_name=None): """Remove data nodes from the local Slicer server. Nodes can be selected using name, id, and/or class_name. @@ -77,6 +92,7 @@ def node_remove(name=None, id=None, class_name=None): response = requests.delete(api_url) _report_error(response) + def node_reload(name=None, id=None, class_name=None): """Reload the node from that file it was originally loaded from. This can be used for updating a node that was loaded using `file_load()`, @@ -90,6 +106,7 @@ def node_reload(name=None, id=None, class_name=None): response = requests.put(api_url) _report_error(response) + def node_properties(name=None, id=None, class_name=None): """Get properties of data nodes on the local Slicer server. Nodes can be selected using name, id, and/or class_name. @@ -104,8 +121,9 @@ def node_properties(name=None, id=None, class_name=None): properties = [response_json[key] for key in response_json] return properties + def node_ids(name=None, id=None, class_name=None): - """Get list of ids of nodes availalbe on the local Slicer server. + """Get list of ids of nodes available on the local Slicer server. Nodes can be selected using name, id, and/or class_name. """ api_url = f"http://127.0.0.1:{SERVER_PORT}/slicer/mrml/ids" @@ -116,8 +134,9 @@ def node_ids(name=None, id=None, class_name=None): _report_error(response) return response.json() + def node_names(name=None, id=None, class_name=None): - """Get list of names of nodes availalbe on the local Slicer server. + """Get list of names of nodes available on the local Slicer server. Nodes can be selected using name, id, and/or class_name. """ api_url = f"http://127.0.0.1:{SERVER_PORT}/slicer/mrml/names" @@ -128,6 +147,7 @@ def node_names(name=None, id=None, class_name=None): _report_error(response) return response.json() + def file_save(file_path, name=None, id=None, class_name=None, properties=None): """Save node into file on the local Slicer server. :param path: local filename or URL of the file to write @@ -138,20 +158,32 @@ def file_save(file_path, name=None, id=None, class_name=None, properties=None): if file_path is not None: file_path = str(file_path) - url_encoded_path = urllib.request.quote(file_path, safe='') - api_url = f"http://127.0.0.1:{SERVER_PORT}/slicer/mrml/file?localfile={url_encoded_path}" + url_encoded_path = urllib.request.quote(file_path, safe="") + api_url = ( + f"http://127.0.0.1:{SERVER_PORT}/slicer/mrml/file?localfile={url_encoded_path}" + ) node_query = _node_query_parameters(name, id, "") if node_query: api_url += "&" + node_query if properties: for key in properties: - url_encoded_key = urllib.request.quote(key.encode(), safe='') - url_encoded_value = urllib.request.quote(str(properties[key]).encode(), safe='') + url_encoded_key = urllib.request.quote(key.encode(), safe="") + url_encoded_value = urllib.request.quote( + str(properties[key]).encode(), safe="" + ) api_url += f"&{url_encoded_key}={url_encoded_value}" response = requests.get(api_url) _report_error(response) -def file_load(file_path, file_type=None, properties=None, auto_start=True, timeout_sec=60, slicer_executable=None): + +def file_load( + file_path, + file_type=None, + properties=None, + auto_start=True, + timeout_sec=60, + slicer_executable=None, +): """Load a file into the local Slicer server. :param path: local filename or URL of the file to open :param type: type of the file to open `VolumeFile` (nrrd, nii, ... files; this is the default), `SegmentationFile` (nrrd, nii, ... files), @@ -173,33 +205,35 @@ def file_load(file_path, file_type=None, properties=None, auto_start=True, timeo if file_type is None: file_type = "VolumeFile" p = urllib.parse.urlparse(file_path) - if p.scheme == 'slicer': + if p.scheme == "slicer": # Slicer URL - use it as is. For example: # slicer://viewer/?studyUID=1.2.826.0.1.3680043.8.498.77209180964150541470378654317482622226&dicomweb_endpoint=http%3A%2F%2F130.15.7.119:2016%2Fdicom&bulk_retrieve=0 - url_encoded_path = urllib.request.quote(file_path, safe='') + url_encoded_path = urllib.request.quote(file_path, safe="") api_url = f"http://127.0.0.1:{SERVER_PORT}/slicer/open?url={url_encoded_path}" else: # Local file path or remote download path - path_type = 'url' if p.scheme in ['http', 'https'] else 'localfile' - url_encoded_path = urllib.request.quote(file_path, safe='') + path_type = "url" if p.scheme in ["http", "https"] else "localfile" + url_encoded_path = urllib.request.quote(file_path, safe="") api_url = f"http://127.0.0.1:{SERVER_PORT}/slicer/mrml?{path_type}={url_encoded_path}&filetype={file_type}" if properties: for key in properties: - url_encoded_key = urllib.request.quote(key.encode(), safe='') - url_encoded_value = urllib.request.quote(str(properties[key]).encode(), safe='') + url_encoded_key = urllib.request.quote(key.encode(), safe="") + url_encoded_value = urllib.request.quote( + str(properties[key]).encode(), safe="" + ) api_url += f"&{url_encoded_key}={url_encoded_value}" retry_after_starting_server = True try: response = requests.post(api_url) retry_after_starting_server = False - except requests.exceptions.ConnectionError as e: + except requests.exceptions.ConnectionError: if not auto_start: raise if retry_after_starting_server: # Try again, with starting a server first - server_process = start_server(slicer_executable) + server_process = start_server(slicer_executable) # noqa: F841 response = requests.post(api_url) _report_error(response) diff --git a/slicerio/tests/test_segmentation.py b/slicerio/tests/test_segmentation.py index eef82e2..5d50485 100644 --- a/slicerio/tests/test_segmentation.py +++ b/slicerio/tests/test_segmentation.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- - -import nrrd import slicerio import unittest @@ -19,96 +16,159 @@ def tearDown(self): def test_segmentation_read(self): """Test segmentation reading""" - input_segmentation_filepath = slicerio.get_testdata_file('Segmentation.seg.nrrd') + input_segmentation_filepath = slicerio.get_testdata_file( + "Segmentation.seg.nrrd" + ) - segmentation = slicerio.read_segmentation(input_segmentation_filepath, skip_voxels=True) + segmentation = slicerio.read_segmentation( + input_segmentation_filepath, skip_voxels=True + ) number_of_segments = len(segmentation["segments"]) self.assertEqual(number_of_segments, 7) segment_names = slicerio.segment_names(segmentation) - self.assertEqual(segment_names[0], 'ribs') - self.assertEqual(segment_names[1], 'cervical vertebral column') - self.assertEqual(segment_names[2], 'thoracic vertebral column') - self.assertEqual(segment_names[3], 'lumbar vertebral column') - self.assertEqual(segment_names[4], 'right lung') - self.assertEqual(segment_names[5], 'left lung') - self.assertEqual(segment_names[6], 'tissue') + self.assertEqual(segment_names[0], "ribs") + self.assertEqual(segment_names[1], "cervical vertebral column") + self.assertEqual(segment_names[2], "thoracic vertebral column") + self.assertEqual(segment_names[3], "lumbar vertebral column") + self.assertEqual(segment_names[4], "right lung") + self.assertEqual(segment_names[5], "left lung") + self.assertEqual(segment_names[6], "tissue") segment = slicerio.segment_from_name(segmentation, segment_names[4]) - self.assertEqual(segment['id'], 'Segment_5') - self.assertEqual(segment['name'], 'right lung') - self.assertEqual(segment['nameAutoGenerated'], True) - self.assertEqual(segment['color'], [0.0862745, 0.772549, 0.278431]) - self.assertEqual(segment['colorAutoGenerated'], False) - self.assertEqual(segment['labelValue'], 5) - self.assertEqual(segment['layer'], 0) - self.assertEqual(segment['extent'], [0, 124, 0, 127, 0, 33]) - self.assertEqual(segment['status'], 'inprogress') + self.assertEqual(segment["id"], "Segment_5") + self.assertEqual(segment["name"], "right lung") + self.assertEqual(segment["nameAutoGenerated"], True) + self.assertEqual(segment["color"], [0.0862745, 0.772549, 0.278431]) + self.assertEqual(segment["colorAutoGenerated"], False) + self.assertEqual(segment["labelValue"], 5) + self.assertEqual(segment["layer"], 0) + self.assertEqual(segment["extent"], [0, 124, 0, 127, 0, 33]) + self.assertEqual(segment["status"], "inprogress") terminology = segment["terminology"] - self.assertEqual(terminology['contextName'], 'Segmentation category and type - 3D Slicer General Anatomy list') - self.assertEqual(terminology['category'], ['SCT', '123037004', 'Anatomical Structure']) - self.assertEqual(terminology['type'], ['SCT', '39607008', 'Lung']) - self.assertEqual(terminology['typeModifier'], ['SCT', '24028007', 'Right']) - self.assertEqual(terminology['anatomicContextName'], 'Anatomic codes - DICOM master list') - self.assertEqual('anatomicRegion' in terminology, False) - self.assertEqual('anatomicRegionModifier' in terminology, False) + self.assertEqual( + terminology["contextName"], + "Segmentation category and type - 3D Slicer General Anatomy list", + ) + self.assertEqual( + terminology["category"], ["SCT", "123037004", "Anatomical Structure"] + ) + self.assertEqual(terminology["type"], ["SCT", "39607008", "Lung"]) + self.assertEqual(terminology["typeModifier"], ["SCT", "24028007", "Right"]) + self.assertEqual( + terminology["anatomicContextName"], "Anatomic codes - DICOM master list" + ) + self.assertEqual("anatomicRegion" in terminology, False) + self.assertEqual("anatomicRegionModifier" in terminology, False) def test_extract_segments(self): - input_segmentation_filenames = ['Segmentation.seg.nrrd', 'SegmentationOverlapping.seg.nrrd'] + input_segmentation_filenames = [ + "Segmentation.seg.nrrd", + "SegmentationOverlapping.seg.nrrd", + ] for input_segmentation_filename in input_segmentation_filenames: - - input_segmentation_filepath = slicerio.get_testdata_file(input_segmentation_filename) + input_segmentation_filepath = slicerio.get_testdata_file( + input_segmentation_filename + ) segmentation = slicerio.read_segmentation(input_segmentation_filepath) # Extract segments by name extracted_segmentation_by_name = slicerio.extract_segments( - segmentation, [('ribs', 1), ('right lung', 3)] + segmentation, [("ribs", 1), ("right lung", 3)] ) # Verify pixel type of new segmentation - self.assertEqual(extracted_segmentation_by_name["voxels"].dtype, segmentation["voxels"].dtype) + self.assertEqual( + extracted_segmentation_by_name["voxels"].dtype, + segmentation["voxels"].dtype, + ) # Verify that the extracted segmentation contains the requested label values # SegmentationOverlapping.seg.nrrd contains an additional segment overlapping with ribs and right lung, # but the sphere is not in the extracted segmentation, so it should not affect the extracted output. import numpy as np - self.assertEqual(len(np.where(extracted_segmentation_by_name["voxels"] == 0)[0]), 514119) # background - self.assertEqual(len(np.where(extracted_segmentation_by_name["voxels"] == 1)[0]), 8487) # ribs - self.assertEqual(len(np.where(extracted_segmentation_by_name["voxels"] == 2)[0]), 0) # unused label - self.assertEqual(len(np.where(extracted_segmentation_by_name["voxels"] == 3)[0]), 34450) # right lung - self.assertEqual(len(np.where(extracted_segmentation_by_name["voxels"] == 4)[0]), 0) # unused label + + self.assertEqual( + len(np.where(extracted_segmentation_by_name["voxels"] == 0)[0]), 514119 + ) # background + self.assertEqual( + len(np.where(extracted_segmentation_by_name["voxels"] == 1)[0]), 8487 + ) # ribs + self.assertEqual( + len(np.where(extracted_segmentation_by_name["voxels"] == 2)[0]), 0 + ) # unused label + self.assertEqual( + len(np.where(extracted_segmentation_by_name["voxels"] == 3)[0]), 34450 + ) # right lung + self.assertEqual( + len(np.where(extracted_segmentation_by_name["voxels"] == 4)[0]), 0 + ) # unused label # Extract segments by terminology extracted_segmentation_by_terminology = slicerio.extract_segments( - segmentation, [ + segmentation, + [ # Note: intentionally using "ribs" instead of "Rib" (terminology value meaning) or "ribs" (segment name) # to test that matching is based on terminology code value. - ({"category": ["SCT", "123037004", "Anatomical Structure"], "type": ["SCT", "113197003", "Ribs"]}, 1), - ({"category": ["SCT", "123037004", "Anatomical Structure"], "type": ["SCT", "39607008", "Lung"], "typeModifier": ["SCT", "24028007", "Right"]}, 3) - ]) - + ( + { + "category": ["SCT", "123037004", "Anatomical Structure"], + "type": ["SCT", "113197003", "Ribs"], + }, + 1, + ), + ( + { + "category": ["SCT", "123037004", "Anatomical Structure"], + "type": ["SCT", "39607008", "Lung"], + "typeModifier": ["SCT", "24028007", "Right"], + }, + 3, + ), + ], + ) + # Compare the two segmentations - self._assert_segmentations_equal(extracted_segmentation_by_name, extracted_segmentation_by_terminology) + self._assert_segmentations_equal( + extracted_segmentation_by_name, extracted_segmentation_by_terminology + ) # Verify that the extracted segmentation contains the requested label values import numpy as np - self.assertEqual(len(np.where(extracted_segmentation_by_terminology["voxels"] == 0)[0]), 514119) # background - self.assertEqual(len(np.where(extracted_segmentation_by_terminology["voxels"] == 1)[0]), 8487) # ribs - self.assertEqual(len(np.where(extracted_segmentation_by_terminology["voxels"] == 2)[0]), 0) # unused label - self.assertEqual(len(np.where(extracted_segmentation_by_terminology["voxels"] == 3)[0]), 34450) # right lung - self.assertEqual(len(np.where(extracted_segmentation_by_terminology["voxels"] == 4)[0]), 0) # unused label + + self.assertEqual( + len(np.where(extracted_segmentation_by_terminology["voxels"] == 0)[0]), + 514119, + ) # background + self.assertEqual( + len(np.where(extracted_segmentation_by_terminology["voxels"] == 1)[0]), + 8487, + ) # ribs + self.assertEqual( + len(np.where(extracted_segmentation_by_terminology["voxels"] == 2)[0]), + 0, + ) # unused label + self.assertEqual( + len(np.where(extracted_segmentation_by_terminology["voxels"] == 3)[0]), + 34450, + ) # right lung + self.assertEqual( + len(np.where(extracted_segmentation_by_terminology["voxels"] == 4)[0]), + 0, + ) # unused label def test_segmentation_write(self): - import numpy as np import tempfile - input_segmentation_filepath = slicerio.get_testdata_file('Segmentation.seg.nrrd') + input_segmentation_filepath = slicerio.get_testdata_file( + "Segmentation.seg.nrrd" + ) segmentation = slicerio.read_segmentation(input_segmentation_filepath) - + # Get a temporary filename - output_segmentation_filepath = tempfile.mktemp() + '.seg.nrrd' + output_segmentation_filepath = tempfile.mktemp() + ".seg.nrrd" # Write and re-read the segmentation slicerio.write_segmentation(output_segmentation_filepath, segmentation) @@ -119,6 +179,7 @@ def test_segmentation_write(self): # Clean up temporary file import os + os.remove(output_segmentation_filepath) def test_segmentation_create(self): @@ -133,10 +194,11 @@ def test_segmentation_create(self): segmentation = { "voxels": voxels, "ijkToLPS": [ - [ 0.5, 0., 0., 10], - [ 0., 0.5, 0., 30], - [ 0., 0., 0.8, 15], - [ 0., 0., 0., 1. ]], + [0.5, 0.0, 0.0, 10], + [0.0, 0.5, 0.0, 30], + [0.0, 0.0, 0.8, 15], + [0.0, 0.0, 0.0, 1.0], + ], "containedRepresentationNames": ["Binary labelmap", "Closed surface"], "segments": [ { @@ -144,7 +206,8 @@ def test_segmentation_create(self): "terminology": { "contextName": "Segmentation category and type - 3D Slicer General Anatomy list", "category": ["SCT", "123037004", "Anatomical Structure"], - "type": ["SCT", "10200004", "Liver"] } + "type": ["SCT", "10200004", "Liver"], + }, }, { "labelValue": 3, @@ -152,13 +215,14 @@ def test_segmentation_create(self): "contextName": "Segmentation category and type - 3D Slicer General Anatomy list", "category": ["SCT", "123037004", "Anatomical Structure"], "type": ["SCT", "39607008", "Lung"], - "typeModifier": ["SCT", "24028007", "Right"] } + "typeModifier": ["SCT", "24028007", "Right"], + }, }, - ] + ], } # Get a temporary filename - output_segmentation_filepath = tempfile.mktemp() + '.seg.nrrd' + output_segmentation_filepath = tempfile.mktemp() + ".seg.nrrd" # Write and re-read the segmentation slicerio.write_segmentation(output_segmentation_filepath, segmentation) @@ -169,6 +233,7 @@ def test_segmentation_create(self): # Clean up temporary file import os + os.remove(output_segmentation_filepath) def _assert_segmentations_equal(self, segmentation1, segmentation2): @@ -178,19 +243,30 @@ def _assert_segmentations_equal(self, segmentation1, segmentation2): may be added to the segments (such as extent). """ import numpy as np + for key in segmentation1: - if type(segmentation1[key]) == np.ndarray or type(segmentation2[key]) == np.ndarray: - self.assertTrue(np.allclose(segmentation1[key], segmentation2[key]), f"Failed for key {key}") + if isinstance(segmentation1[key], np.ndarray) or isinstance( + segmentation2[key], np.ndarray + ): + self.assertTrue( + np.allclose(segmentation1[key], segmentation2[key]), + f"Failed for key {key}", + ) elif key == "segments": for segment1, segment2 in zip(segmentation1[key], segmentation2[key]): for segmentAttribute in segment1: - self.assertEqual(segment1[segmentAttribute], segment2[segmentAttribute], f"Failed for key {key}[{segmentAttribute}]") + self.assertEqual( + segment1[segmentAttribute], + segment2[segmentAttribute], + f"Failed for key {key}[{segmentAttribute}]", + ) else: - equal = (segmentation1[key] == segmentation2[key]) - if type(equal) == list: + equal = segmentation1[key] == segmentation2[key] + if isinstance(equal, list): self.assertTrue(all(equal), f"Failed for key {key}") else: self.assertTrue(equal, f"Failed for key {key}") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main()