From bd7eea360254a446c3f82eb549093b0755ad717b Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Sun, 10 Mar 2024 15:42:13 +0100 Subject: [PATCH 01/12] Migration to more recent django. --- .../migrations/0023_auto_20180428_1756.py | 3 +- exact/requirements.txt | 49 ++++++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/exact/exact/annotations/migrations/0023_auto_20180428_1756.py b/exact/exact/annotations/migrations/0023_auto_20180428_1756.py index 115158bd..234173fc 100644 --- a/exact/exact/annotations/migrations/0023_auto_20180428_1756.py +++ b/exact/exact/annotations/migrations/0023_auto_20180428_1756.py @@ -4,8 +4,7 @@ import datetime from django.db import migrations, models -from django.utils.timezone import utc - +from datetime import UTC as utc class Migration(migrations.Migration): diff --git a/exact/requirements.txt b/exact/requirements.txt index 566a62c2..7985016b 100644 --- a/exact/requirements.txt +++ b/exact/requirements.txt @@ -5,39 +5,40 @@ # pip-compile --output-file requirements.txt requirements.in # -confusable-homoglyphs==3.2.0 +confusable-homoglyphs==3.3.1 drf-flex-fields==1.0.2 -Django==3.2.11 -django-appconf==1.0.5 +django-appconf==1.0.6 django-extensions==3.2.3 -django-filter==23.2 +django-filter==23.5 django-friendly-tag-loader==1.3.1 -django-project-version==0.18.1 -django-redis==5.3.0 +django-project-version==0.19.0 +django-redis==5.4.0 django-registration==3.4 django-templated-mail==1.1.1 -django-widget-tweaks==1.4.12 +django-widget-tweaks==1.5.0 djangorestframework==3.14.0 -djangorestframework-simplejwt==5.2.2 -djoser==2.2.0 -uritemplate==3.0.1 +djangorestframework-simplejwt==5.3.1 +djoser==2.2.2 +zarr==0.12.1 +xmltodict==0.13.0 +uritemplate==4.1.1 fasteners==0.15 -monotonic==1.5 -Pillow==10.0.0 -psycopg2-binary==2.9.6 -pytz==2023.3 +monotonic==1.6 +pillow==10.2.0 +psycopg2-binary==2.9.9 +pytz==2024.1 gunicorn==21.2.0 -opencv-python==4.8.0.74 -openslide-python==1.3.0 -tifffile>=2023.7.0 -pyvips==2.2.1 -pydicom==2.4.2 -ptvsd==4.3.2 +opencv-python>=4.9.0.80 +openslide-python==1.3.1 +tifffile==2023.2.28 +pyvips>=2.2.1 +pydicom>=2.4.4 +ptvsd>=4.3.2 czifile==2019.7.2 gitpython==3.1.11 -gdown==4.7.1 -pandas==2.0.3 -qt-wsi-registration>=0.0.6 +gdown==5.1.0 +pandas==2.2.1 +qt-wsi-registration==0.0.11 EXCAT-Sync>=0.0.38 pyyaml>=6.0.1 -aicsimageio==4.11.0 +aicsimageio==4.14.0 From f8c07752e7fe72efcc3fe5701867b4eff1d96388 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Sun, 10 Mar 2024 15:43:33 +0100 Subject: [PATCH 02/12] First version of TIFF zStack support. --- exact/exact/images/api_views.py | 4 +- exact/exact/images/models.py | 58 +++--- exact/exact/images/views.py | 10 +- exact/util/cellvizio.py | 110 ++++++++-- exact/util/enums.py | 5 + exact/util/slide_server.py | 280 ++++++++++++++++++++++++- exact/util/tiffzstack.py | 349 ++++++++++++++++++++++++++++++++ 7 files changed, 762 insertions(+), 54 deletions(-) create mode 100644 exact/util/enums.py create mode 100644 exact/util/tiffzstack.py diff --git a/exact/exact/images/api_views.py b/exact/exact/images/api_views.py index 4c747c0a..71ef94b3 100644 --- a/exact/exact/images/api_views.py +++ b/exact/exact/images/api_views.py @@ -148,7 +148,7 @@ def view_image_tile(self, request, pk, z_dimension, frame, level, tile_path): try: slide = image_cache.get(file_path) - tile = slide.get_tile(level, (col, row)) + tile = slide.get_tile(level, (col, row), frame=frame) buf = PILBytesIO() tile.save(buf, format, quality=90) @@ -210,7 +210,7 @@ def update_image_cache(self, request, pk=None): cache_key = f"{pk}/{z_dimension}/{frame}/{level}/{col}/{row}" if tiles_cache.has_key(cache_key) == False: - tile = slide.get_tile(level, (col, row)) + tile = slide.get_tile(level=level, address=(col, row), frame=frame) buf = PILBytesIO() tile.save(buf, "jpeg", quality=90) diff --git a/exact/exact/images/models.py b/exact/exact/images/models.py index 10f6725d..218e9175 100644 --- a/exact/exact/images/models.py +++ b/exact/exact/images/models.py @@ -12,7 +12,7 @@ from django.db.models.signals import post_delete, post_save, m2m_changed from django.dispatch import receiver from django.utils.functional import cached_property - +from util.slide_server import getSlideHandler import logging import math @@ -39,12 +39,9 @@ logger = logging.getLogger('django') +from util.enums import FrameType class FrameDescription(models.Model): - class FrameType: - ZSTACK = 0 - TIMESERIES = 1 - UNDEFINED = 255 FRAME_TYPES = ( (FrameType.ZSTACK, 'z Stack'), @@ -93,7 +90,7 @@ class ImageSourceTypes: image_type = models.IntegerField(choices=SOURCE_TYPES, default=ImageSourceTypes.DEFAULT) def get_file_name(self, depth=1, frame=1): - if depth > 1 or frame > 1 or self.frames > 1 or self.depth > 1: + if depth > 1 or self.depth > 1: return str(Path(Path(self.name).stem) / "{}_{}_{}".format(depth, frame, self.name)) else: return self.filename @@ -127,12 +124,26 @@ def save(self, *args, **kwargs): def save_file(self, path:Path): try: - # check if the file can be opened by OpenSlide if not convert it + # check if the file can be opened natively, if not convert it try: - osr = OpenSlide(str(path)) + osr = getSlideHandler(str(path)) self.filename = path.name - except: - print('Unable to open image with OpenSlide') + self.save() + if (osr.nFrames>1): + for frame_id in range(osr.nFrames): + # save FrameDescription object for each frame + FrameDescription.objects.create( + Image=self, + frame_id=frame_id, + file_path=self.filename, + description=osr.frame_descriptors[frame_id], + frame_type=osr.frame_type, + ) + print('Added',osr.nFrames,'frames') + self.frames=osr.nFrames + + except Exception as e: + print('Unable to open image with OpenSlide',e) import pyvips old_path = path @@ -149,7 +160,7 @@ def save_file(self, path:Path): self.save() # initially save for frame_id in range(self.frames): height, width = reader.dimensions - np_image = np.array(reader.read_region(location=(0,0), size=(reader.dimensions), level=0, zLevel=frame_id))[:,:,0] + np_image = np.array(reader.read_region(location=(0,0), size=(reader.dimensions), level=0, frame=frame_id))[:,:,0] linear = np_image.reshape(height * width * self.channels) vi = pyvips.Image.new_from_memory(np.ascontiguousarray(linear.data), height, width, self.channels, 'uchar') @@ -387,7 +398,7 @@ def save_file(self, path:Path): vi.tiffsave(str(path), tile=True, compression='lzw', bigtiff=True, pyramid=True, tile_width=256, tile_height=256) self.filename = path.name - osr = OpenSlide(self.path()) + osr = getSlideHandler(self.path()) self.width, self.height = osr.level_dimensions[0] try: mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] @@ -747,22 +758,6 @@ def rotation_angle(self): return - math.atan2(self.transformation_matrix["t_01"], self.transformation_matrix["t_00"]) * 180 / math.pi - def fixedCvInvert(self, H): - """[If the determinate is zero, OpenCV returns a wrong inverted matrix. ] - - Args: - H ([type]): [3x3 Matrix] - - Returns: - [type]: [Inverted matrix] - """ - if (cv2.determinant(H) != 0.0): - return cv2.invert(H)[1] - else: - return np.array([[1., 0., -H[0,-1]], - [0., 1., -H[1,-1]], - [0., 0., 0.]]) - @cached_property def inv_matrix(self): @@ -772,7 +767,8 @@ def inv_matrix(self): [t["t_20"], t["t_21"], t["t_22"]]]) - M = self.fixedCvInvert(H) + # Using numpys pseudo-inverse as this is the generalization for singular matrices + M = np.linalg.pinv(H) return { "t_00": M [0,0], @@ -801,7 +797,7 @@ def get_matrix_without_rotation(self): [0. , 0, 1]]) - inv_rot = self.fixedCvInvert(H) + inv_rot = np.linalg.pinv(rot) M = H@inv_rot return M @@ -817,7 +813,7 @@ def get_scale(self): def get_inv_scale(self): M = self.get_matrix_without_rotation - M = self.fixedCvInvert(M) + M = np.linalg.pinv(M) return M[0][0], M[1][1] def __str__(self): diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index c43f8c2e..5d6ebe15 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -487,8 +487,8 @@ def view_image_navigator_overlay_tile(request, image_id, z_dimension, frame, lev file_path = os.path.join(settings.IMAGE_PATH, image.path()) slide = image_cache.get(file_path) - tile = slide.get_tile(level, (col, row)) - + tile = slide.get_tile(level, (col, row), frame=frame) + # replace with databse call to imageset.product for product in image.image_set.product_set.all(): for plugin in plugin_finder.filter_plugins(product_name=product.name, navigation_view_policy=ViewPolicy.RGB_IMAGE): @@ -501,7 +501,7 @@ def view_image_navigator_overlay_tile(request, image_id, z_dimension, frame, lev return response @login_required -@cache_page(60 * 60 * 24 * 30) +#@cache_page(60 * 60 * 24 * 30) def view_image_tile(request, image_id, z_dimension, frame, level, tile_path): """ This view is to authenticate direct access to the images via nginx auth_request directive @@ -531,7 +531,7 @@ def view_image_tile(request, image_id, z_dimension, frame, level, tile_path): try: slide = image_cache.get(file_path) - tile = slide.get_tile(level, (col, row)) + tile = slide.get_tile(level, (col, row),frame=frame) buf = PILBytesIO() tile.save(buf, format, quality=90) @@ -539,7 +539,7 @@ def view_image_tile(request, image_id, z_dimension, frame, level, tile_path): load_from_drive_time = timer() - start - logger.info(f"{load_from_drive_time:.4f};{request.path}") + logger.info(f"{load_from_drive_time:.4f};{request.path};NC") if hasattr(cache, "delete_pattern"): tiles_cache.set(cache_key, buffer, 7*24*60*60) diff --git a/exact/util/cellvizio.py b/exact/util/cellvizio.py index 7241de74..246bd24e 100644 --- a/exact/util/cellvizio.py +++ b/exact/util/cellvizio.py @@ -13,14 +13,15 @@ """ import numpy as np -import pydicom from pydicom.encaps import decode_data_sequence from PIL import Image import io import os import struct from os import stat +import openslide +from util.enums import FrameType class fileinfo: offset = 16 # fixed: 16 byte header @@ -45,9 +46,53 @@ class ReadableCellVizioMKTDataset(): [type]: [description] """ - fi = fileinfo() + def __init__(self,filename): + #print('Opening:',filename) + self.fileName = filename; + self.fi = fileinfo() + self.fileHandle = open(filename, 'rb'); + self.fileHandle.seek(5) # we find the FPS at position 05 + fFPSByte = self.fileHandle.read(4) + self.fps = struct.unpack('>f', fFPSByte)[0] + + + self.fileHandle.seek(10) # we find the image size at position 10 + fSizeByte = self.fileHandle.read(4) + self.fi.size = int.from_bytes(fSizeByte, byteorder='big', signed=True) + self.fi.nImages=1000 + + self.fi.width = 576 + if ((self.fi.size/(2*self.fi.width))%2!=0): + self.fi.width=512 + self.fi.height=int(self.fi.size/(2*self.fi.width)) + else: + self.fi.height=int(self.fi.size/(2*self.fi.width)) + + self.filestats = stat(self.fileName) + self.fi.nImages = int((self.filestats.st_size-self.fi.offset) / (self.fi.size+self.fi.gapBetweenImages)) + + self.numberOfFrames = self.fi.nImages + + self.geometry_imsize = [self.fi.height, self.fi.width] + self.imsize = [self.fi.height, self.fi.width] + self.geometry_tilesize = [(self.fi.height, self.fi.width)] + self.geometry_rows = [1] + self.geometry_columns = [1] + self.levels = [1] + self.channels = 1 + self.mpp_x = 250/576 # approximate number for gastroflex, 250 ym field of view, 576 px + self.mpp_y = 250/576 + + # generate circular mask for this file + self.circMask = circularMask(self.fi.width,self.fi.height, self.fi.width-2).mask + #print('Circular mask shape:',self.circMask.shape) + self.properties = { openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', + openslide.PROPERTY_NAME_MPP_X: self.mpp_x, + openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, + openslide.PROPERTY_NAME_OBJECTIVE_POWER:20, + openslide.PROPERTY_NAME_VENDOR: 'MKT'} - def __init__(self, filename): + def _2_init__(self, filename): self.fileName = filename self.fileHandle = open(filename, 'rb') @@ -55,7 +100,7 @@ def __init__(self, filename): fFPSByte = self.fileHandle.read(4) self.fps = struct.unpack('>f', fFPSByte)[0] - + self.fi = fileinfo() self.fileHandle.seek(10) # we find the image size at position 10 fSizeByte = self.fileHandle.read(4) self.fi.size = int.from_bytes(fSizeByte, byteorder='big', signed=True) @@ -81,8 +126,14 @@ def __init__(self, filename): self.geometry_columns = [1] self.levels = [1] self.channels = 1 - self.mpp_x = 1e-6 - self.mpp_y = 1e-6 + self.mpp_x = 250/576 # approximate number for gastroflex, 250 ym field of view, 576 px + self.mpp_y = 250/576 + + self.properties = { openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', + openslide.PROPERTY_NAME_MPP_X: self.mpp_x, + openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, + openslide.PROPERTY_NAME_OBJECTIVE_POWER:20, + openslide.PROPERTY_NAME_VENDOR: 'MKT'} self.circMask = circularMask(self.fi.width,self.fi.height, self.fi.width-2).mask @@ -100,17 +151,48 @@ def level_downsamples(self): def level_dimensions(self): return [self.geometry_imsize] + @property + def nFrames(self): + return self.numberOfFrames + + @property + def frame_descriptors(self) -> list[str]: + """ returns a list of strings, used as descriptor for each frame + """ + return ['%.2f s (%d)' % (float(frame_id)/float(self.fps), frame_id) for frame_id in range(self.nFrames)] + + @property + def nLayers(self): + return 1 + + @property + def layer_descriptors(self) -> list[str]: + """ returns a list of strings, used as descriptor for each layer + """ + return [''] + + @property + def frame_type(self): + return FrameType.TIMESERIES + + + def get_thumbnail(self, size): + return self.read_region((0,0),0, self.dimensions).resize(size) + def readImage(self, position=0): - self.fileHandle.seek(self.fi.offset + self.fi.size*position + self.fi.gapBetweenImages*position) + seekpos=self.fi.offset + self.fi.size*position + self.fi.gapBetweenImages*position + image = np.fromfile(self.fileName, offset=seekpos, dtype=np.int16, count=int(self.fi.size/2)) - image = np.fromfile(self.fileHandle, dtype=np.int16, count=int(self.fi.size/2)) - image = np.clip(image, 0, np.max(image)) + if (image.size > 0): + image = np.clip(image, 0, np.max(image)) - image=np.reshape(image, newshape=(self.fi.height, self.fi.width)) + if (image.shape[0]!=self.fi.height* self.fi.width): + image = np.zeros((self.fi.height, self.fi.width)) - image = np.ma.masked_array(image, 1-self.circMask) # apply mask to image - return image + image=np.reshape(image, newshape=(self.fi.height, self.fi.width)) + + return image def scaleImageUINT8(self, image, mask = None): # read image and scale to uint8 [0;255] format @@ -142,7 +224,7 @@ def readImageUINT8(self, position=0): image = self.scaleImageUINT8(image) return image - def read_region(self, location: tuple, level:int, size:tuple, zLevel:int): + def read_region(self, location: tuple, level:int, size:tuple, frame:int=0): img = np.zeros((size[1],size[0],4), np.uint8) img[:,:,3]=255 offset=[0,0] @@ -153,7 +235,7 @@ def read_region(self, location: tuple, level:int, size:tuple, zLevel:int): offset[1] = -location[0] location = (0,location[1]) - pixel_array = self.readImageUINT8(position=zLevel) + pixel_array = self.readImageUINT8(position=frame) imgcut = pixel_array[location[1]:location[1]+size[1]-offset[0],location[0]:location[0]+size[0]-offset[1]] imgcut = np.uint8(np.clip(np.float32(imgcut),0,255)) diff --git a/exact/util/enums.py b/exact/util/enums.py new file mode 100644 index 00000000..b4cffcd9 --- /dev/null +++ b/exact/util/enums.py @@ -0,0 +1,5 @@ +class FrameType: + ZSTACK = 0 + TIMESERIES = 1 + UNDEFINED = 255 + diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 92271bf3..c41b1136 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -6,6 +6,7 @@ import os from optparse import OptionParser from threading import Lock +from PIL import Image SLIDE_CACHE_SIZE = 10 DEEPZOOM_FORMAT = 'jpeg' @@ -15,6 +16,250 @@ DEEPZOOM_TILE_QUALITY = 75 +import openslide +from PIL import Image +import numpy as np +from util.cellvizio import ReadableCellVizioMKTDataset +from openslide import OpenSlideError +import tifffile +from util.tiffzstack import PyramidalZStack + +from util.enums import FrameType + + +class zDeepZoomGenerator(DeepZoomGenerator): + def get_tile(self, level, address, frame=0): + """Return an RGB PIL.Image for a tile. + + level: the Deep Zoom level. + address: the address of the tile within the level as a (col, row) + tuple.""" + + # Read tile + args, z_size = self._get_tile_info(level, address) + tile = self._osr.read_region(*args, frame=frame) + profile = tile.info.get('icc_profile') + + # Apply on solid background + bg = Image.new('RGB', tile.size, self._bg_color) + tile = Image.composite(tile, bg, tile) + + # Scale to the correct size + if tile.size != z_size: + # Image.Resampling added in Pillow 9.1.0 + # Image.LANCZOS removed in Pillow 10 + tile.thumbnail(z_size, getattr(Image, 'Resampling', Image).LANCZOS) + + # Reference ICC profile + if profile is not None: + tile.info['icc_profile'] = profile + + return tile + +class OpenSlideWrapper(openslide.OpenSlide): + """ + Wraps an openslide.OpenSlide object. The rationale here is that OpenSlide.read_region does not support z Stacks / frames as arguments, hence we have to encapsulate it + + """ + def read_region(self, location, level, size, frame=0): + return openslide.OpenSlide.read_region(self, location, level, size) + +class ImageSlideWrapper(openslide.ImageSlide): + """ + Wraps an openslide.ImageSlide object. The rationale here is that OpenSlide.read_region does not support z Stacks / frames as arguments, hence we have to encapsulate it + + """ + def read_region(self, location, level, size, zLevel=0, frame=0): + return openslide.ImageSlide.read_region(self, location, level, size) + + +class ImageSlide3D(openslide.ImageSlide): + + @property + def nFrames(self): + return self.numberOfLayers + + @property + def frame_descriptors(self) -> list[str]: + """ returns a list of strings, used as descriptor for each frame + """ + return [str(x) for x in range(self.nFrames)] + + @property + def frame_type(self): + return FrameType.ZSTACK + + def __init__(self, file): + """Open an image file. + + file can be a filename or a PIL.Image.""" + openslide.ImageSlide.__init__(self, file) + + try: + self.numberOfLayers = self._image.n_frames + except: + self.numberOfLayers = 1 + + + def read_region(self, location, level, size, frame=0): + """Return a PIL.Image containing the contents of the region. + + location: (x, y) tuple giving the top left pixel in the level 0 + reference frame. + level: the level number. + size: (width, height) tuple giving the region size.""" + + if level != 0: + raise OpenSlideError("Invalid level") + if ['fail' for s in size if s < 0]: + raise OpenSlideError("Size %s must be non-negative" % (size,)) + # Any corner of the requested region may be outside the bounds of + # the image. Create a transparent tile of the correct size and + # paste the valid part of the region into the correct location. + image_topleft = [max(0, min(l, limit - 1)) + for l, limit in zip(location, self._image.size)] + image_bottomright = [max(0, min(l + s - 1, limit - 1)) + for l, s, limit in zip(location, size, self._image.size)] + tile = Image.new("RGBA", size, (0,) * 4) + + if not ['fail' for tl, br in zip(image_topleft, image_bottomright) + if br - tl < 0]: # "< 0" not a typo + # Crop size is greater than zero in both dimensions. + # PIL thinks the bottom right is the first *excluded* pixel + self._image.seek(frame) + crop = self._image.crop(image_topleft + + [d + 1 for d in image_bottomright]) + tile_offset = tuple(il - l for il, l in + zip(image_topleft, location)) + tile.paste(crop, tile_offset) + return tile + +vendor_handlers = {'Aperio': OpenSlideWrapper, + 'dicom' : OpenSlideWrapper, + 'mirax' : OpenSlideWrapper, + 'philips' : OpenSlideWrapper, + 'sakura' : OpenSlideWrapper, + 'synthetic' : OpenSlideWrapper, + 'trestle' :OpenSlideWrapper, + 'ventana' : OpenSlideWrapper, + 'leica' : OpenSlideWrapper, + 'hamamatsu' : OpenSlideWrapper, + 'generic-tiff' : OpenSlideWrapper} + + +class OpenSlideWrapper(openslide.OpenSlide): + """ + Wraps an openslide.OpenSlide object. The rationale here is that OpenSlide.read_region does not support z Stacks / frames as arguments, hence we have to encapsulate it + + """ + + @property + def nFrames(self): + return 1 + + @property + def frame_descriptors(self) -> list[str]: + """ returns a list of strings, used as descriptor for each frame + """ + return [''] + + @property + def frame_type(self): + return FrameType.UNDEFINED + + def read_region(self, location, level, size, frame=0): + return openslide.OpenSlide.read_region(self, location, level, size) + + +class GenericTiffHandler: + """ + A generic TIFF handler that uses OpenSlide whereever possible and needed but allows for the use of more sophisticated drop-ins + """ + def __new__(self, file): + + # Check if it's an OME TIFF + f = tifffile.TiffFile(file) + if f.ome_metadata and len(f.ome_metadata)>0: + return type('PyramidalZStack', (PyramidalZStack, openslide.OpenSlide), {})(file) + + # else let OpenSlide handle it for us. + vendor = openslide.lowlevel.detect_vendor(file) + if vendor in vendor_handlers: + return type("GenericTiffHandler", (vendor_handlers[vendor],openslide.OpenSlide), {})(file) + + +class TiffHandler(openslide.OpenSlide): + + def __new__(self, file): + vendor = openslide.lowlevel.detect_vendor(file) + if vendor in vendor_handlers: + return type("GenericTiffHandler", (vendor_handlers[vendor],openslide.OpenSlide), {})(file) + else: + return type("GenericTiffHandler", (ImageSlide3D, openslide.ImageSlide), {})(file) + +class FileType: + magic_number = b'\x00\x00\x00\x00' # primary critereon to identify file + extensions = [] # List of known file extensions as secondary critereon + handler = openslide.open_slide # handler returning an OpenSlide-compatible object + magic_number_offset = 0 + +class BigTiffFileType(FileType): + magic_number = b'\x49\x49\x2b\x00' + extensions = ['tiff', 'tif', 'svs'] + handler = GenericTiffHandler + +class NormalTiffFileType(FileType): + magic_number = b'\x49\x49\x2a\x00' + extensions = ['tiff', 'tif', 'ndpi'] + handler = TiffHandler + +class OlympusVSIFileType(FileType): + magic_number = b'\x49\x49\x2a\x00' + extensions = ['vsi'] + handler = TiffHandler + +class JPEGJFIFFileType(FileType): + magic_number = b'\xff\xd8\xff\xe0' + extensions = ['jpg','jpeg'] + handler = ImageSlideWrapper + +class JPEGEXIFFileType(FileType): + magic_number = b'\xff\xd8\xff\xe1' + extensions = ['jpg','jpeg'] + handler = ImageSlideWrapper + + +class PNGFileType(FileType): + magic_number = b'\x89\x50\x4e\x47' + extensions = ['png'] + handler = ImageSlideWrapper + +class PhilipsISyntaxFileType(FileType): + magic_number = b'\x3c\x44\x61\x74' + extensions = 'isyntax' + +class MiraxFileType(FileType): + magic_number = b'\xff\xd8\xff\xe0' + extensions = 'mrxs' + handler = OpenSlideWrapper + +class DicomFileType(FileType): + magic_number = b'DICM' + extensions = 'dcm' + magic_number_offset=128 + handler = OpenSlideWrapper + +class MKTFileType(FileType): + magic_number = b'\x0a\x3c\x43\x53' + extensions = 'mkt' + handler = ReadableCellVizioMKTDataset + + + + +SupportedFileTypes = [MKTFileType, DicomFileType, MiraxFileType, PhilipsISyntaxFileType, PNGFileType, JPEGEXIFFileType, JPEGJFIFFileType, OlympusVSIFileType, NormalTiffFileType, BigTiffFileType] + + class PILBytesIO(BytesIO): def fileno(self): @@ -23,6 +268,36 @@ def fileno(self): raise AttributeError('Not supported') +def getSlideHandler(path): + # Determine format of slide to see how to handle it. + f = open(path,'rb') + magic_number = {0: f.read(4)} + candidates=[] + print('Magic number:',[hex(x) for x in magic_number[0]]) + for ftype in SupportedFileTypes: + mnum = magic_number + if ftype.magic_number_offset not in magic_number: + f.seek(ftype.magic_number_offset) + magic_number[ftype.magic_number_offset] = f.read(4) + if (magic_number[ftype.magic_number_offset]==ftype.magic_number): + candidates.append(ftype) + + if (len(candidates)>0): + for ftype in candidates: + if (path.split('.')[-1].lower() in ftype.extensions): + filehandler = ftype.handler + print('Found file handler: ',filehandler(path)) + return filehandler(path) + + # as last resort, try openSlide: + try: + return OpenSlideWrapper(path) + except: + print('Found no file handler',path) + + return None + + class SlideCache(object): def __init__(self, cache_size): self.cache_size = cache_size @@ -37,8 +312,8 @@ def get(self, path): self._cache[path] = slide return slide - osr = open_slide(path) - slide = DeepZoomGenerator(osr) + osr = getSlideHandler(path) + slide = zDeepZoomGenerator(osr) try: mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y] @@ -51,6 +326,7 @@ def get(self, path): if len(self._cache) == self.cache_size: self._cache.popitem(last=False) self._cache[path] = slide + print('Added to cache') return slide class SlideFile(object): diff --git a/exact/util/tiffzstack.py b/exact/util/tiffzstack.py new file mode 100644 index 00000000..80898d53 --- /dev/null +++ b/exact/util/tiffzstack.py @@ -0,0 +1,349 @@ +from typing import Dict, List, Tuple, Union +import tifffile +import numpy as np +import zarr +import xmltodict + +from PIL import Image +from dataclasses import dataclass, field +from tifffile import TiffWriter, TiffFile, imread +from tifffile.tifffile import ZarrTiffStore +from zarr.hierarchy import Group +from zarr.core import Array +from util.enums import FrameType +import openslide + +@dataclass +class Properties: + mppx: float + mppy: float + zpos: float = None + zpos_unit: str = None + compression: str = None + objective_power: int = None + manufacturer: str = None + model: str = None + image_name: str = None + dtype: str = None + + + +@dataclass +class LabelImage: + filename: str + series: int + data: Array = field(init=False, repr=False) + + def __post_init__(self): + # TODO: validate filename + # TODO: validate series + self.data = zarr.open( + imread(self.filename, series=self.series, aszarr=True), + mode='r' + ) + + @property + def dimensions(self): + return self.data.shape + + @property + def get_data(self) -> np.ndarray: + return self.data[:] + + +@dataclass +class ThumbNail: + filename: str + series: int + data: Array = field(init=False, repr=False) + + def __post_init__(self): + # TODO: validate filename + # TODO: validate series + self.data = zarr.open( + imread(self.filename, series=self.series, aszarr=True), + mode='r' + ) + + @property + def dimensions(self): + return self.data.shape + + @property + def get_data(self) -> np.ndarray: + return self.data[:] + + +@dataclass +class PyramidalTIFF: + filename: str + series: int + data: Group = field(init=False, repr=False) + + def __post_init__(self): + # TODO: validate filename + # TODO: validate series + self.data = zarr.open( + imread(self.filename, series=self.series, aszarr=True), + mode='r' + ) + + + @property + def level_count(self) -> int: + """The number of levels in the image.""" + return len(self.data) + + + @property + def level_dimensions(self) -> List[Tuple[int, int]]: + """A list of (width, height) tuples, one for each level of the image.""" + return [arr.shape[:2] for _, arr in self.data.arrays()] + + + @property + def dimensions(self): + """A (width, height) tuple for level 0 of the image.""" + return self.level_dimensions[0] + + + @property + def level_downsamples(self) -> List[float]: + """A list of downsampling factors for each level of the image.""" + down_factors = [] + reference_size = self.dimensions[0] + for dim in self.level_dimensions: + factor = reference_size / dim[0] + down_factors.append(factor) + return down_factors + + + + + def read_region( + self, + location: Tuple[int, int], + level: int, + size: Tuple[int, int] + ) -> Image: + """Return a PIL.Image containing the contents of the region. + + TODO: x, y are currently not with respect to the level 0 reference frame + + Args: + location (Tuple[int, int]): (x, y) tuple giving the top left pixel. + level (int): The level number. + size (Tuple[int, int]): (width, height) tuple giving the region size. + + Returns: + Image: Pil.Image with the contents of the region. + """ + # correct location for level + location = [round(x/self.level_downsamples[level]) for x in location] + + # create transparancy + img = np.zeros((size[1],size[0],4), np.uint8) + img[:,:,3]=255 + offset=[0,0] + if (location[1]<0): + offset[0] = -location[1] + location = (location[0],0) + if (location[0]<0): + offset[1] = -location[0] + location = (0,location[1]) + + if level > self.level_count: + raise ValueError(f'Invalid level.') + + if size[0] < 0 or size[1] < 0: + raise ValueError(f'Negative width {size[0]} or negative height {size[1]} not allowed.') + + x, y = location + width, height = size + imgcut = self.data[level][y:y+height, x:x+width, :] + + img[offset[0]:imgcut.shape[0]+offset[0],offset[1]:offset[1]+imgcut.shape[1],0:3] = imgcut + return Image.fromarray(img) + + + + +@dataclass +class PyramidalZStack: + filename: str + zstack: Dict[str, PyramidalTIFF] = field(init=False, repr=False) + labelimage: LabelImage = field(init=False, repr=False) + thumbnail: ThumbNail = field(init=False, repr=False) + + + def __post_init__(self): + self.tif = TiffFile(self.filename) + + self._init_metadata() + self._init_z_stack() + + + @property + def level_count(self) -> int: + """The number of levels in the image.""" + return self.zstack[0].level_count + + @property + def level_dimensions(self) -> List[Tuple[int, int]]: + """A list of (width, height) tuples, one for each level of the image.""" + return self.zstack[0].level_dimensions + + @property + def level_downsamples(self) -> List[float]: + """A list of downsampling factors for each level of the image.""" + return self.zstack[0].level_downsamples + + @property + def dimensions(self): + """A (width, height) tuple for level 0 of the image.""" + return self.zstack[0].dimensions + + + def __len__(self) -> int: + """Returns the number of z-levels.""" + return len(self.zstack) + + @property + def nFrames(self) -> int: + """ returns the number of frames (identical to number of z levels) """ + return len(self.zstack) + + @property + def frame_type(self) -> FrameType: + return FrameType.ZSTACK + + @property + def frame_descriptors(self) -> list[str]: + """ returns a list of strings, used as descriptor for each frame + """ + + return ['z=%s %s' % (str(self.metadata[m]['PositionZ']), self.metadata[m]['PositionZUnit']) for m in self.metadata if 'PositionZ' in self.metadata[m]] + + @property + def properties(self) -> Dict[str, Union[str, float, int]]: + """ returns a list of properties that all OpenSlide objects have + """ + return {openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', + openslide.PROPERTY_NAME_MPP_X: '0.12', + openslide.PROPERTY_NAME_MPP_Y: '0.12', + openslide.PROPERTY_NAME_OBJECTIVE_POWER:20, + openslide.PROPERTY_NAME_VENDOR: ''} + + def _init_metadata(self) -> None: + # TODO: add Vendor, Objective Power, Compression per level + # TODO: implement property dataclass + metadata = {} + + # metadata is stored in page 0 + xml_string = self.tif.pages[0].tags['ImageDescription'].value + + # each image metadata is stored as dict in a list + image_list = xmltodict.parse(xml_string)['OME']['Image'] + + for idx, image_dict in enumerate(image_list): + name = image_dict['@Name'] + + metadata[idx] = dict( + name = name, + dtype = image_dict['Pixels']['@Type'], + SizeX = image_dict['Pixels']['@SizeX'], + SizeY = image_dict['Pixels']['@SizeY'], + SizeC = image_dict['Pixels']['@SizeC'], + ) + + # additional information not stored for Label Image and Thumbnail + if 'Label' not in name and 'Thumbnail' not in name: + + metadata[idx].update(dict( + PhysicalSizeX = image_dict['Pixels']['@PhysicalSizeX'], + PhysicalSizeY = image_dict['Pixels']['@PhysicalSizeX'], + PhysicalSizeXUnit = image_dict['Pixels']['@PhysicalSizeXUnit'], + PhysicalSizeYUnit = image_dict['Pixels']['@PhysicalSizeYUnit'], + PositionZ = image_dict['Pixels']['Plane']['@PositionZ'], + PositionZUnit = image_dict['Pixels']['Plane']['@PositionZUnit'] + )) + + # store metadata + self.metadata = metadata + + def get_best_level_for_downsample(self,downsample): + return np.argmin(np.abs(np.asarray(self.level_downsamples)-downsample)) + + + def _init_z_stack(self) -> None: + """Initialize Z-Stack, LabelImage and Thumbnail. + + TODO: Add true ZStack positions, define default plane + """ + zstack = {} + + for idx, series in enumerate(self.tif.series): + name = series.name + + if 'Label' in name: + self.labelimage = LabelImage(self.filename, series=idx) + + elif 'Thumbnail' in name: + self.thumbnail = ThumbNail(self.filename, series=idx) + + else: + zstack[idx] = PyramidalTIFF(self.filename, series=idx) + + + self.zstack = zstack + + def get_thumbnail(self, size): + return self.read_region((0,0),self.level_count-1, self.level_dimensions[-1]).resize(size) + + + def read_region( + self, + location: Tuple[int, int], + level: int, + size: Tuple[int, int], + frame: int = 0) -> Image: + """Return a PIL.Image containing the contents of the region. + + TODO: x, y are currently not with respect to the level 0 reference frame + TODO: Add support to load regions from multiple zlevels. + + Args: + location (Tuple[int, int]): (x, y) tuple giving the top left pixel. + level (int): The level number. + size (Tuple[int, int]): (width, height) tuple giving the region size. + frame (int): Index of z-stack plane. + + Returns: + Image: Pil.Image with the contents of the region. + """ + + if frame not in self.zstack.keys(): + raise KeyError(f'Invalid zlevel: {frame}.') + + patch = self.zstack[frame].read_region(location, level, size) + + return patch + + + + + + + + + + + + + + + + + + + From 07a370eddf6716d83f1873565231c14892eead6b Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Mon, 11 Mar 2024 16:25:25 +0100 Subject: [PATCH 03/12] Fixed requirements (hopefully) --- exact/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/requirements.txt b/exact/requirements.txt index 7985016b..21a4553e 100644 --- a/exact/requirements.txt +++ b/exact/requirements.txt @@ -19,7 +19,7 @@ django-widget-tweaks==1.5.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.1 djoser==2.2.2 -zarr==0.12.1 +zarr==2.15.0 xmltodict==0.13.0 uritemplate==4.1.1 fasteners==0.15 From 5bd5173502d46122ba4cf0dbd0beef7ba7b741aa Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Mon, 11 Mar 2024 16:59:07 +0100 Subject: [PATCH 04/12] fixed UTC dependency. --- exact/exact/annotations/migrations/0023_auto_20180428_1756.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exact/exact/annotations/migrations/0023_auto_20180428_1756.py b/exact/exact/annotations/migrations/0023_auto_20180428_1756.py index 234173fc..e0fca538 100644 --- a/exact/exact/annotations/migrations/0023_auto_20180428_1756.py +++ b/exact/exact/annotations/migrations/0023_auto_20180428_1756.py @@ -4,7 +4,8 @@ import datetime from django.db import migrations, models -from datetime import UTC as utc +import datetime +utc = datetime.timezone.utc class Migration(migrations.Migration): From 00f48b9966d8a9ec73eeb5919b29ee0fde9c3116 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 12 Mar 2024 19:27:23 +0100 Subject: [PATCH 05/12] Added a try except for handling corrupt slides. --- exact/util/slide_server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index c41b1136..8e5e0533 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -286,8 +286,12 @@ def getSlideHandler(path): for ftype in candidates: if (path.split('.')[-1].lower() in ftype.extensions): filehandler = ftype.handler - print('Found file handler: ',filehandler(path)) - return filehandler(path) + print('Found file handler: ',filehandler) + try: + return filehandler(path) + except Exception as e: + print('But unable to open. :-()') + pass # as last resort, try openSlide: try: From c422cd94ea72d0c23e23d548582dbe69ac5c64d1 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 12 Mar 2024 19:27:49 +0100 Subject: [PATCH 06/12] Added migration to delete all slides that we can natively open now. --- .../0033_image_stack_setfilename.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 exact/exact/images/migrations/0033_image_stack_setfilename.py diff --git a/exact/exact/images/migrations/0033_image_stack_setfilename.py b/exact/exact/images/migrations/0033_image_stack_setfilename.py new file mode 100644 index 00000000..ad027774 --- /dev/null +++ b/exact/exact/images/migrations/0033_image_stack_setfilename.py @@ -0,0 +1,52 @@ +# Manual migration to set filename field of images correctly, since EXACT now opens many z Stacks natively and does not require the filename change any more. +from django.db import migrations, models +from openslide import OpenSlide +import openslide +from pathlib import Path + +from exact.images.models import Image +from django.conf import settings +import os +from util.slide_server import getSlideHandler + +def remove_natively_handled_files_auxfiles(apps, schema_editor): + db_alias = schema_editor.connection.alias + + for image in Image.objects.all(): + try: + path = os.path.join(settings.IMAGE_PATH, image.image_set.path, image.name) + if image.frames > 1: + print('Path: ',path) + if not os.path.exists(path): + continue + slide = getSlideHandler(path) + if slide is None: + continue + if image.depth==1: + if not os.path.exists(image.get_file_name()): + print('Setting image filename from: ',image.filename,'to:',image.name) + image.filename = image.name + image.save() + for frame in range(image.frames): + rp = image.image_set.root_path() + filepath = rp / Path(Path(image.name).stem) / "{}_{}_{}".format(1, frame+1, image.name) + if os.path.exists(filepath): + print('Removing: ', filepath) + os.unlink(filepath) + + except Exception as e: + print('Exception: ',e,image) + raise + continue + + + +class Migration(migrations.Migration): + + dependencies = [ + ('images', '0032_rename_descreption_framedescription_description_and_more'), + ] + + operations = [ + migrations.RunPython(remove_natively_handled_files_auxfiles) + ] From 013732ef7607dd9423348ca4ca8702f5d6d303c6 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Wed, 13 Mar 2024 07:05:04 +0100 Subject: [PATCH 07/12] Fixed pathnames for proper migration. --- exact/exact/images/migrations/0033_image_stack_setfilename.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exact/exact/images/migrations/0033_image_stack_setfilename.py b/exact/exact/images/migrations/0033_image_stack_setfilename.py index ad027774..5f2a50ac 100644 --- a/exact/exact/images/migrations/0033_image_stack_setfilename.py +++ b/exact/exact/images/migrations/0033_image_stack_setfilename.py @@ -23,12 +23,12 @@ def remove_natively_handled_files_auxfiles(apps, schema_editor): if slide is None: continue if image.depth==1: - if not os.path.exists(image.get_file_name()): + rp = image.image_set.root_path() + if not os.path.exists(rp / Path(image.filename)): print('Setting image filename from: ',image.filename,'to:',image.name) image.filename = image.name image.save() for frame in range(image.frames): - rp = image.image_set.root_path() filepath = rp / Path(Path(image.name).stem) / "{}_{}_{}".format(1, frame+1, image.name) if os.path.exists(filepath): print('Removing: ', filepath) From 188063a91045d57ff26526033539321915725512 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Wed, 13 Mar 2024 10:48:05 +0100 Subject: [PATCH 08/12] Set interval of frames to 0 to max-1 (full range). Additionally added magnification slider if info is available. --- .../static/annotations/js/exact-image-viewer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js index dca3e9ff..b1abadb4 100644 --- a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js +++ b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js @@ -556,15 +556,15 @@ class EXACTViewer { } this.frameSlider = new Slider("#frameSlider", { ticks_snap_bounds: 1, - value: 1, + value: 0, formatter: function(val){ return value_formatter (labels,val) }, - min: 1, + min: 0, tooltip: 'always', - max: frames + max: frames-1 }); this.frameSlider.on('change', this.onFrameSliderChanged.bind(this)); } - else if (objectivePower > 1) { + if (objectivePower > 1) { const default_ticks = [0, 1, 2, 5, 10, 20, 40, 80, 160]; const default_names = ["0x", "1x", "2x", "5x", "10x", "20x", "40x", "80x", "160x"]; From 65b032ef87acd68a92f3e6c69be8ba61370e384c Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Wed, 13 Mar 2024 10:53:36 +0100 Subject: [PATCH 09/12] Added handling of objective power and mpp from reader. --- exact/exact/images/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/exact/exact/images/models.py b/exact/exact/images/models.py index 218e9175..d3d89782 100644 --- a/exact/exact/images/models.py +++ b/exact/exact/images/models.py @@ -141,6 +141,10 @@ def save_file(self, path:Path): ) print('Added',osr.nFrames,'frames') self.frames=osr.nFrames + if openslide.PROPERTY_NAME_OBJECTIVE_POWER in osr.properties: + self.objectivePower = osr.properties[openslide.PROPERTY_NAME_OBJECTIVE_POWER] + if openslide.PROPERTY_NAME_MPP_X in osr.properties: + self.mpp = osr.properties[openslide.PROPERTY_NAME_MPP_X] except Exception as e: print('Unable to open image with OpenSlide',e) From 5b7c21a8c05a51fc3c4f71b2248c0cd1ee060539 Mon Sep 17 00:00:00 2001 From: jonas-amme Date: Wed, 13 Mar 2024 15:04:39 +0100 Subject: [PATCH 10/12] Fixed level_dimensions --- exact/util/tiffzstack.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/exact/util/tiffzstack.py b/exact/util/tiffzstack.py index 80898d53..7206b665 100644 --- a/exact/util/tiffzstack.py +++ b/exact/util/tiffzstack.py @@ -43,11 +43,13 @@ def __post_init__(self): ) @property - def dimensions(self): - return self.data.shape + def dimensions(self) -> Tuple[int, int]: + """Returns (width, height) tuple of the image.""" + return self.data.shape[:2][::-1] @property def get_data(self) -> np.ndarray: + """Returns the content of the image as numpy array.""" return self.data[:] @@ -64,13 +66,15 @@ def __post_init__(self): imread(self.filename, series=self.series, aszarr=True), mode='r' ) - + @property - def dimensions(self): - return self.data.shape + def dimensions(self) -> Tuple[int, int]: + """Returns (width, height) tuple of the image.""" + return self.data.shape[:2][::-1] @property def get_data(self) -> np.ndarray: + """Returns the content of the image as numpy array.""" return self.data[:] @@ -98,7 +102,7 @@ def level_count(self) -> int: @property def level_dimensions(self) -> List[Tuple[int, int]]: """A list of (width, height) tuples, one for each level of the image.""" - return [arr.shape[:2] for _, arr in self.data.arrays()] + return [arr.shape[:2][::-1] for _, arr in self.data.arrays()] @property @@ -128,8 +132,6 @@ def read_region( ) -> Image: """Return a PIL.Image containing the contents of the region. - TODO: x, y are currently not with respect to the level 0 reference frame - Args: location (Tuple[int, int]): (x, y) tuple giving the top left pixel. level (int): The level number. @@ -309,7 +311,6 @@ def read_region( frame: int = 0) -> Image: """Return a PIL.Image containing the contents of the region. - TODO: x, y are currently not with respect to the level 0 reference frame TODO: Add support to load regions from multiple zlevels. Args: From 0884add40041915871b2a7c7bccc34a1f0b52f2b Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Mon, 18 Mar 2024 15:02:14 +0100 Subject: [PATCH 11/12] Removed obsolete message that caused a raisecondition --- exact/exact/images/api_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/exact/images/api_views.py b/exact/exact/images/api_views.py index 71ef94b3..8a16650f 100644 --- a/exact/exact/images/api_views.py +++ b/exact/exact/images/api_views.py @@ -383,7 +383,7 @@ def create(self, request): f.seek(0) # reset file cursor to the beginning of the file file_list = {} - print('Magic number: ',hex(magic_number)) +# print('Magic number: ',hex(magic_number)) if magic_number == b'PK\x03\x04': zipname = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + From 35258a2c99a85dde7554731d8a2076103b477770 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Mon, 18 Mar 2024 18:40:48 +0100 Subject: [PATCH 12/12] Fixed spelling of aperio in known vendor handles. --- exact/util/slide_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 8e5e0533..30fa54a8 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -134,7 +134,7 @@ def read_region(self, location, level, size, frame=0): tile.paste(crop, tile_offset) return tile -vendor_handlers = {'Aperio': OpenSlideWrapper, +vendor_handlers = {'aperio': OpenSlideWrapper, 'dicom' : OpenSlideWrapper, 'mirax' : OpenSlideWrapper, 'philips' : OpenSlideWrapper, @@ -186,6 +186,9 @@ def __new__(self, file): vendor = openslide.lowlevel.detect_vendor(file) if vendor in vendor_handlers: return type("GenericTiffHandler", (vendor_handlers[vendor],openslide.OpenSlide), {})(file) + else: + return type("GenericTiffHandler", (ImageSlide3D, openslide.ImageSlide), {})(file) + class TiffHandler(openslide.OpenSlide):