From 0728863a02114c8f578bdb49fc47fe63282a2b3a Mon Sep 17 00:00:00 2001 From: Chris Allan Date: Thu, 27 Aug 2020 16:01:33 +0100 Subject: [PATCH 1/5] Initial Pathology SDK v2 API support --- isyntax2raw/__init__.py | 415 ++++++++++++++++++++++++++++------------ 1 file changed, 288 insertions(+), 127 deletions(-) diff --git a/isyntax2raw/__init__.py b/isyntax2raw/__init__.py index 036f745..835ffb7 100644 --- a/isyntax2raw/__init__.py +++ b/isyntax2raw/__init__.py @@ -100,6 +100,7 @@ def __init__( render_backend, render_context ) self.pixel_engine["in"].open(input_path, "ficom") + self.sdk_v1 = hasattr(self.pixel_engine["in"], "BARCODE") def __enter__(self): return self @@ -107,142 +108,299 @@ def __enter__(self): def __exit__(self, exception_type, exception_value, traceback): self.pixel_engine["in"].close() - def write_metadata(self): - '''write metadata to a JSON file''' + def get_metadata(self): + if self.sdk_v1: + return self.get_metadata_sdk_v1() + else: + return self.get_metadata_sdk_v2() + + def get_metadata_sdk_v1(self): pe_in = self.pixel_engine["in"] - metadata_file = os.path.join(self.slide_directory, "METADATA.json") + return { + "Barcode": + self.barcode(), + "DICOM acquisition date": + self.acquisition_datetime().isoformat(), + "DICOM last calibration date": + pe_in.DICOM_DATE_OF_LAST_CALIBRATION, + "DICOM time of last calibration": + pe_in.DICOM_TIME_OF_LAST_CALIBRATION, + "DICOM manufacturer": + pe_in.DICOM_MANUFACTURER, + "DICOM manufacturer model name": + pe_in.DICOM_MANUFACTURERS_MODEL_NAME, + "DICOM device serial number": + pe_in.DICOM_DEVICE_SERIAL_NUMBER, + "Color space transform": + pe_in.colorspaceTransform(), + "Block size": + pe_in.blockSize(), + "Number of tiles": + pe_in.numTiles(), + "Bits stored": + pe_in.bitsStored(), + "Derivation description": + self.derivation_description(), + "DICOM software version": + pe_in.DICOM_SOFTWARE_VERSIONS, + "Number of images": self.num_images() + } - with open(metadata_file, "w", encoding="utf-8") as f: - metadata = { - "Barcode": - pe_in.BARCODE, - "DICOM acquisition date": - pe_in.DICOM_ACQUISITION_DATETIME, - "DICOM last calibration date": - pe_in.DICOM_DATE_OF_LAST_CALIBRATION, - "DICOM time of last calibration": - pe_in.DICOM_TIME_OF_LAST_CALIBRATION, - "DICOM manufacturer": - pe_in.DICOM_MANUFACTURER, - "DICOM manufacturer model name": - pe_in.DICOM_MANUFACTURERS_MODEL_NAME, - "DICOM device serial number": - pe_in.DICOM_DEVICE_SERIAL_NUMBER, - "Color space transform": - pe_in.colorspaceTransform(), - "Block size": - pe_in.blockSize(), - "Number of tiles": - pe_in.numTiles(), - "Bits stored": - pe_in.bitsStored(), - "Derivation description": - pe_in.DICOM_DERIVATION_DESCRIPTION, - "DICOM software version": - pe_in.DICOM_SOFTWARE_VERSIONS, - "Number of images": - pe_in.numImages() - } + def get_metadata_sdk_v2(self): + pe_in = self.pixel_engine["in"] + return { + "Pixel engine version": + self.pixel_engine.version, + "Barcode": + self.barcode(), + "Acquisition datetime": + self.acquisition_datetime().isoformat(), + "Date of last calibration": + pe_in.date_of_last_calibration, + "Time of last calibration": + pe_in.time_of_last_calibration, + "Manufacturer": + pe_in.manufacturer, + "Model name": + pe_in.model_name, + "Device serial number": + pe_in.device_serial_number, + "Derivation description": + self.derivation_description(), + "Software versions": + pe_in.software_versions, + "Number of images": + self.num_images() + } + + def get_image_metadata(self, image_no): + if self.sdk_v1: + return self.get_image_metadata_sdk_v1(image_no) + else: + return self.get_image_metadata_sdk_v2(image_no) - size_x = 0 - size_y = 0 - label_x = 0 - label_y = 0 - macro_x = 0 - macro_y = 0 - pixel_size_x = 1.0 - pixel_size_y = 1.0 - - for image in range(pe_in.numImages()): - img = pe_in[image] - image_metadata = { - "Image type": img.IMAGE_TYPE, - "DICOM lossy image compression method": - img.DICOM_LOSSY_IMAGE_COMPRESSION_METHOD, - "DICOM lossy image compression ratio": - img.DICOM_LOSSY_IMAGE_COMPRESSION_RATIO, - "DICOM derivation description": - img.DICOM_DERIVATION_DESCRIPTION, - "Image dimension names": - img.IMAGE_DIMENSION_NAMES, - "Image dimension types": - img.IMAGE_DIMENSION_TYPES, - "Image dimension units": - img.IMAGE_DIMENSION_UNITS, - "Image dimension ranges": - img.IMAGE_DIMENSION_RANGES, - "Image dimension discrete values": - img.IMAGE_DIMENSION_DISCRETE_VALUES_STRING, - "Image scale factor": - img.IMAGE_SCALE_FACTOR + def get_image_metadata_sdk_v1(self, image_no): + pe_in = self.pixel_engine["in"] + img = pe_in[image_no] + image_type = self.image_type(image_no) + image_metadata = { + "Image type": + image_type, + "DICOM lossy image compression method": + img.DICOM_LOSSY_IMAGE_COMPRESSION_METHOD, + "DICOM lossy image compression ratio": + img.DICOM_LOSSY_IMAGE_COMPRESSION_RATIO, + "DICOM derivation description": + img.DICOM_DERIVATION_DESCRIPTION, + "Image dimension names": + img.IMAGE_DIMENSION_NAMES, + "Image dimension types": + img.IMAGE_DIMENSION_TYPES, + "Image dimension units": + img.IMAGE_DIMENSION_UNITS, + "Image dimension ranges": + img.IMAGE_DIMENSION_RANGES, + "Image dimension discrete values": + img.IMAGE_DIMENSION_DISCRETE_VALUES_STRING, + "Image scale factor": + img.IMAGE_SCALE_FACTOR + } + if image_type == "WSI": + self.pixel_size_x = img.IMAGE_SCALE_FACTOR[0] + self.pixel_size_y = img.IMAGE_SCALE_FACTOR[1] + + view = pe_in.SourceView() + image_metadata["Bits allocated"] = view.bitsAllocated() + image_metadata["Bits stored"] = view.bitsStored() + image_metadata["High bit"] = view.highBit() + image_metadata["Pixel representation"] = \ + view.pixelRepresentation() + image_metadata["Planar configuration"] = \ + view.planarConfiguration() + image_metadata["Samples per pixel"] = \ + view.samplesPerPixel() + image_metadata["Number of levels"] = \ + pe_in.numLevels() + + for resolution in range(pe_in.numLevels()): + dim_ranges = view.dimensionRanges(resolution) + level_size_x = self.get_size(dim_ranges[0]) + level_size_y = self.get_size(dim_ranges[1]) + image_metadata["Level sizes #%s" % resolution] = { + "X": level_size_x, + "Y": level_size_y + } + if resolution == 0: + self.size_x = level_size_x + self.size_y = level_size_y + elif image_type == "LABELIMAGE": + self.label_x = self.get_size(img.IMAGE_DIMENSION_RANGES[0]) + 1 + self.label_y = self.get_size(img.IMAGE_DIMENSION_RANGES[1]) + 1 + elif image_type == "MACROIMAGE": + self.macro_x = self.get_size(img.IMAGE_DIMENSION_RANGES[0]) + 1 + self.macro_y = self.get_size(img.IMAGE_DIMENSION_RANGES[1]) + 1 + return image_metadata + + def get_image_metadata_sdk_v2(self, image_no): + pe_in = self.pixel_engine["in"] + img = pe_in[image_no] + image_type = self.image_type(image_no) + view = img.source_view + image_scale_factor = view.scale + image_metadata = { + "Image type": + image_type, + "Lossy image compression method": + img.lossy_image_compression_method, + "Lossy image compression ratio": + img.lossy_image_compression_ratio, + "Image scale factor": + view.scale + } + if image_type == "WSI": + image_metadata["Image dimension names"] = view.dimension_names + image_metadata["Image dimension types"] = view.dimension_types + image_metadata["Image dimension units"] = view.dimension_units + image_metadata["Image dimension discrete values"] = \ + view.dimension_discrete_values + + self.pixel_size_x = image_scale_factor[0] + self.pixel_size_y = image_scale_factor[1] + + image_metadata["Bits allocated"] = view.bits_allocated + image_metadata["Bits stored"] = view.bits_stored + image_metadata["High bit"] = view.high_bit + image_metadata["Pixel representation"] = \ + view.pixel_representation + image_metadata["Planar configuration"] = \ + view.planar_configuration + image_metadata["Samples per pixel"] = \ + view.samples_per_pixel + image_metadata["Number of derived levels"] = \ + self.num_derived_levels(img) + + for resolution in range(self.num_derived_levels(img)): + dim_ranges = view.dimension_ranges(resolution) + level_size_x = self.get_size(dim_ranges[0]) + level_size_y = self.get_size(dim_ranges[1]) + image_metadata["Level sizes #%s" % resolution] = { + "X": level_size_x, + "Y": level_size_y } + if resolution == 0: + self.size_x = level_size_x + self.size_y = level_size_y + elif image_type == "LABELIMAGE": + self.label_x = self.get_size(view.dimension_ranges(0)[0]) + 1 + self.label_y = self.get_size(view.dimension_ranges(0)[1]) + 1 + elif image_type == "MACROIMAGE": + self.macro_x = self.get_size(view.dimension_ranges(0)[0]) + 1 + self.macro_y = self.get_size(view.dimension_ranges(0)[1]) + 1 + return image_metadata + + def acquisition_datetime(self): + pe_in = self.pixel_engine["in"] + if self.sdk_v1: + timestamp = str(pe_in.DICOM_ACQUISITION_DATETIME) + else: + timestamp = pe_in.acquisition_datetime + return datetime.strptime(timestamp, "%Y%m%d%H%M%S.%f") + + def barcode(self): + pe_in = self.pixel_engine["in"] + if self.sdk_v1: + return pe_in.BARCODE + else: + return pe_in.barcode + + def data_envelopes(self, image, resolution): + if self.sdk_v1: + return image.IMAGE_VALID_DATA_ENVELOPES + else: + return image.source_view.data_envelopes(resolution) - if img.IMAGE_TYPE == "WSI": - pixel_size_x = img.IMAGE_SCALE_FACTOR[0] - pixel_size_y = img.IMAGE_SCALE_FACTOR[1] - - view = pe_in.SourceView() - image_metadata["Bits allocated"] = view.bitsAllocated() - image_metadata["Bits stored"] = view.bitsStored() - image_metadata["High bit"] = view.highBit() - image_metadata["Pixel representation"] = \ - view.pixelRepresentation() - image_metadata["Planar configuration"] = \ - view.planarConfiguration() - image_metadata["Samples per pixel"] = \ - view.samplesPerPixel() - image_metadata["Number of levels"] = \ - pe_in.numLevels() - - for resolution in range(pe_in.numLevels()): - dim_ranges = view.dimensionRanges(resolution) - level_size_x = self.get_size(dim_ranges[0]) - level_size_y = self.get_size(dim_ranges[1]) - image_metadata["Level sizes #%s" % resolution] = { - "X": level_size_x, - "Y": level_size_y - } - if resolution == 0: - size_x = level_size_x - size_y = level_size_y - - elif img.IMAGE_TYPE == "LABELIMAGE": - label_x = self.get_size(img.IMAGE_DIMENSION_RANGES[0]) + 1 - label_y = self.get_size(img.IMAGE_DIMENSION_RANGES[1]) + 1 - - elif img.IMAGE_TYPE == "MACROIMAGE": - macro_x = self.get_size(img.IMAGE_DIMENSION_RANGES[0]) + 1 - macro_y = self.get_size(img.IMAGE_DIMENSION_RANGES[1]) + 1 + def derivation_description(self): + pe_in = self.pixel_engine["in"] + if self.sdk_v1: + return pe_in.DICOM_DERIVATION_DESCRIPTION + else: + return pe_in.derivation_description + + def dimension_ranges(self, image, resolution): + if self.sdk_v1: + return image.SourceView().dimensionRanges(resolution) + else: + return image.source_view.dimension_ranges(resolution) + + def image_data(self, image): + if self.sdk_v1: + return image.IMAGE_DATA + else: + return image.image_data + + def image_type(self, image_no): + pe_in = self.pixel_engine["in"] + if self.sdk_v1: + return pe_in.IMAGE_TYPE + else: + return pe_in[image_no].image_type + + def num_derived_levels(self, image): + if self.sdk_v1: + return image.numLevels() + else: + return image.source_view.num_derived_levels + + def num_images(self): + pe_in = self.pixel_engine["in"] + if self.sdk_v1: + return pe_in.numImages() + else: + return pe_in.num_images + + def wait_any(self, regions): + if self.sdk_v1: + return self.pixel_engine.waitAny(regions) + else: + return self.pixel_engine.wait_any(regions) + + def write_metadata(self): + '''write metadata to a JSON file''' + metadata_file = os.path.join(self.slide_directory, "METADATA.json") + + with open(metadata_file, "w", encoding="utf-8") as f: + metadata = self.get_metadata() + for image in range(self.num_images()): + image_metadata = self.get_image_metadata(image) metadata["Image #" + str(image)] = image_metadata json.dump(metadata, f) - timestamp = str(pe_in.DICOM_ACQUISITION_DATETIME) - ome_timestamp = datetime.strptime(timestamp, "%Y%m%d%H%M%S.%f") + ome_timestamp = self.acquisition_datetime() xml_values = { 'image': { - 'name': pe_in.BARCODE, + 'name': self.barcode(), 'acquisitionDate': ome_timestamp.isoformat(), - 'description': pe_in.DICOM_DERIVATION_DESCRIPTION, + 'description': self.derivation_description(), 'pixels': { - 'sizeX': int(size_x), - 'sizeY': int(size_y), - 'physicalSizeX': pixel_size_x, - 'physicalSizeY': pixel_size_y + 'sizeX': int(self.size_x), + 'sizeY': int(self.size_y), + 'physicalSizeX': self.pixel_size_x, + 'physicalSizeY': self.pixel_size_y } }, 'label': { 'pixels': { - 'sizeX': int(label_x), - 'sizeY': int(label_y) + 'sizeX': int(self.label_x), + 'sizeY': int(self.label_y) } }, 'macro': { 'pixels': { - 'sizeX': int(macro_x), - 'sizeY': int(macro_y) + 'sizeX': int(self.macro_x), + 'sizeY': int(self.macro_y) } } } @@ -277,16 +435,16 @@ def write_macro_image(self): def find_image_type(self, image_type): '''look up a given image type in the pixel engine''' pe_in = self.pixel_engine["in"] - for index in range(pe_in.numImages()): - if image_type == pe_in[index].IMAGE_TYPE: + for index in range(self.num_images()): + if image_type == self.image_type(index): return pe_in[index] return None def write_image_type(self, image_type, series): '''write an image of the specified type''' - image_container = self.find_image_type(image_type) - if image_container is not None: - pixels = image_container.IMAGE_DATA + image = self.find_image_type(image_type) + if image is not None: + pixels = self.image_data(image) # pixels are JPEG compressed, need to decompress first img = Image.open(BytesIO(pixels)) @@ -332,15 +490,14 @@ def make_planar(self, pixels, tile_width, tile_height): def write_pyramid(self): '''write the slide's pyramid as a set of tiles''' - pe_in = self.pixel_engine["in"] - image_container = self.find_image_type("WSI") + image = self.find_image_type("WSI") - scanned_areas = image_container.IMAGE_VALID_DATA_ENVELOPES + scanned_areas = self.data_envelopes(image, 0) if scanned_areas is None: raise RuntimeError("No valid data envelopes") if self.resolutions is None: - resolutions = range(pe_in.numLevels()) + resolutions = range(self.num_derived_levels(image)) else: resolutions = range(self.resolutions) @@ -362,11 +519,10 @@ def write_tile( ), exc_info=True ) - source_view = pe_in.SourceView() for resolution in resolutions: # assemble data envelopes (== scanned areas) to extract for # this level - dim_ranges = source_view.dimensionRanges(resolution) + dim_ranges = self.dimension_ranges(image, resolution) log.info("dimension ranges = %s" % dim_ranges) resolution_x_size = self.get_size(dim_ranges[0]) resolution_y_size = self.get_size(dim_ranges[1]) @@ -389,7 +545,7 @@ def write_tile( [self.tile_width, self.tile_height], tile_directory ) - envelopes = source_view.dataEnvelopes(resolution) + envelopes = self.data_envelopes(image, resolution) jobs = [] with MaxQueuePool(ThreadPoolExecutor, self.max_workers) as pool: for i in range(0, len(patches), self.batch_size): @@ -402,12 +558,17 @@ def write_tile( # bufferType: # pixelengine.PixelEngine.BufferType=BufferType.RGB # ) -> list - regions = source_view.requestRegions( + if self.sdk_v1: + request_regions = image.SourceView().requestRegions + else: + request_regions = image.source_view.request_regions + regions = request_regions( patches[i:i + self.batch_size], envelopes, True, [0, 0, 0] ) while regions: - regions_ready = self.pixel_engine.waitAny(regions) + + regions_ready = self.wait_any(regions) for region_index, region in enumerate(regions_ready): view_range = region.range log.debug( From acd90a0ee463257c10cb99245fe5cf61693d7757 Mon Sep 17 00:00:00 2001 From: Chris Allan Date: Thu, 27 Aug 2020 17:35:21 +0100 Subject: [PATCH 2/5] SDK API v1 compat fixes --- isyntax2raw/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/isyntax2raw/__init__.py b/isyntax2raw/__init__.py index 835ffb7..f908c2a 100644 --- a/isyntax2raw/__init__.py +++ b/isyntax2raw/__init__.py @@ -314,8 +314,9 @@ def barcode(self): return pe_in.barcode def data_envelopes(self, image, resolution): + pe_in = self.pixel_engine["in"] if self.sdk_v1: - return image.IMAGE_VALID_DATA_ENVELOPES + return pe_in.SourceView().dataEnvelopes(resolution) else: return image.source_view.data_envelopes(resolution) @@ -327,8 +328,9 @@ def derivation_description(self): return pe_in.derivation_description def dimension_ranges(self, image, resolution): + pe_in = self.pixel_engine["in"] if self.sdk_v1: - return image.SourceView().dimensionRanges(resolution) + return pe_in.SourceView().dimensionRanges(resolution) else: return image.source_view.dimension_ranges(resolution) @@ -341,13 +343,14 @@ def image_data(self, image): def image_type(self, image_no): pe_in = self.pixel_engine["in"] if self.sdk_v1: - return pe_in.IMAGE_TYPE + return pe_in[image_no].IMAGE_TYPE else: return pe_in[image_no].image_type def num_derived_levels(self, image): + pe_in = self.pixel_engine["in"] if self.sdk_v1: - return image.numLevels() + return pe_in.numLevels() else: return image.source_view.num_derived_levels @@ -490,6 +493,7 @@ def make_planar(self, pixels, tile_width, tile_height): def write_pyramid(self): '''write the slide's pyramid as a set of tiles''' + pe_in = self.pixel_engine["in"] image = self.find_image_type("WSI") scanned_areas = self.data_envelopes(image, 0) @@ -559,7 +563,7 @@ def write_tile( # pixelengine.PixelEngine.BufferType=BufferType.RGB # ) -> list if self.sdk_v1: - request_regions = image.SourceView().requestRegions + request_regions = pe_in.SourceView().requestRegions else: request_regions = image.source_view.request_regions regions = request_regions( From a005e9f236f5547a1085d6782f6cd37c888bf18e Mon Sep 17 00:00:00 2001 From: Chris Allan Date: Thu, 27 Aug 2020 17:50:12 +0100 Subject: [PATCH 3/5] Formatting --- isyntax2raw/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isyntax2raw/__init__.py b/isyntax2raw/__init__.py index f908c2a..024210f 100644 --- a/isyntax2raw/__init__.py +++ b/isyntax2raw/__init__.py @@ -571,8 +571,8 @@ def write_tile( [0, 0, 0] ) while regions: - regions_ready = self.wait_any(regions) + for region_index, region in enumerate(regions_ready): view_range = region.range log.debug( From 37980b21dab19f33cde29fb138557b0aac8e2993 Mon Sep 17 00:00:00 2001 From: Chris Allan Date: Fri, 28 Aug 2020 11:47:16 +0100 Subject: [PATCH 4/5] Add back some global metadata now at image level --- isyntax2raw/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/isyntax2raw/__init__.py b/isyntax2raw/__init__.py index 024210f..fb8a7a3 100644 --- a/isyntax2raw/__init__.py +++ b/isyntax2raw/__init__.py @@ -255,7 +255,13 @@ def get_image_metadata_sdk_v2(self, image_no): "Lossy image compression ratio": img.lossy_image_compression_ratio, "Image scale factor": - view.scale + image_scale_factor, + "Color space transform": + img.colorspace_transform, + "Block size": + img.block_size, + "Number of tiles": + img.num_tiles, } if image_type == "WSI": image_metadata["Image dimension names"] = view.dimension_names From 4075dd3357cf47879919be236ec199bfa9488b6a Mon Sep 17 00:00:00 2001 From: Chris Allan Date: Fri, 28 Aug 2020 11:56:21 +0100 Subject: [PATCH 5/5] Few more global vs image metadata changes for SDK v2 API --- isyntax2raw/__init__.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/isyntax2raw/__init__.py b/isyntax2raw/__init__.py index fb8a7a3..d146826 100644 --- a/isyntax2raw/__init__.py +++ b/isyntax2raw/__init__.py @@ -254,21 +254,23 @@ def get_image_metadata_sdk_v2(self, image_no): img.lossy_image_compression_method, "Lossy image compression ratio": img.lossy_image_compression_ratio, + "Image dimension names": + view.dimension_names, + "Image dimension types": + view.dimension_types, + "Image dimension units": + view.dimension_units, + "Image dimension discrete values": + view.dimension_discrete_values, "Image scale factor": image_scale_factor, - "Color space transform": - img.colorspace_transform, "Block size": - img.block_size, - "Number of tiles": - img.num_tiles, + img.block_size(), } if image_type == "WSI": - image_metadata["Image dimension names"] = view.dimension_names - image_metadata["Image dimension types"] = view.dimension_types - image_metadata["Image dimension units"] = view.dimension_units - image_metadata["Image dimension discrete values"] = \ - view.dimension_discrete_values + image_metadata["Color space transform"] = \ + img.colorspace_transform + image_metadata["Number of tiles"] = img.num_tiles self.pixel_size_x = image_scale_factor[0] self.pixel_size_y = image_scale_factor[1] @@ -286,7 +288,7 @@ def get_image_metadata_sdk_v2(self, image_no): self.num_derived_levels(img) for resolution in range(self.num_derived_levels(img)): - dim_ranges = view.dimension_ranges(resolution) + dim_ranges = self.dimension_ranges(img, resolution) level_size_x = self.get_size(dim_ranges[0]) level_size_y = self.get_size(dim_ranges[1]) image_metadata["Level sizes #%s" % resolution] = {