Skip to content

Commit

Permalink
Add a dicom source.
Browse files Browse the repository at this point in the history
This uses wsidicom to read dicom files.  So far it has only been tested
with files that were generated with the wsi2dcm docker from the 1.0.3
deb from https://github.com/GoogleCloudPlatform/wsi-to-dicom-converter,
which seems to have issues with some files.

This may be refactored to use a different base library, and internal
details should be considered provisional.

It needs to have internal metadata exposed.
  • Loading branch information
manthey committed Nov 29, 2022
1 parent af5839c commit 4bce481
Show file tree
Hide file tree
Showing 14 changed files with 361 additions and 5 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Change Log

## 1.17.4
## 1.18.0

### Features
- Add a DICOM tile source ([#1005](../../pull/1005))

### Improvements
- Better control dtype on multi sources ([#993](../../pull/993))
Expand Down
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Large Image consists of several Python modules designed to work together. These

- ``large-image-source-tifffile``: A tile source using the tifffile library that can handle a wide variety of tiff-like files.

- ``large-image-source-dicom``: A tile source for reading DICOM WSI images.

- ``large-image-source-test``: A tile source that generates test tiles, including a simple fractal pattern. Useful for testing extreme zoom levels.

- ``large-image-source-dummy``: A tile source that does nothing.
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
_build/large_image/modules
_build/large_image_source_bioformats/modules
_build/large_image_source_deepzoom/modules
_build/large_image_source_dicom/modules
_build/large_image_source_dummy/modules
_build/large_image_source_gdal/modules
_build/large_image_source_mapnik/modules
Expand Down
1 change: 1 addition & 0 deletions docs/make_docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ python -c 'import large_image_source_multi, json;print(json.dumps(large_image_so
sphinx-apidoc -f -o _build/large_image ../large_image
sphinx-apidoc -f -o _build/large_image_source_bioformats ../sources/bioformats/large_image_source_bioformats
sphinx-apidoc -f -o _build/large_image_source_deepzoom ../sources/deepzoom/large_image_source_deepzoom
sphinx-apidoc -f -o _build/large_image_source_dicom ../sources/dicom/large_image_source_dicom
sphinx-apidoc -f -o _build/large_image_source_dummy ../sources/dummy/large_image_source_dummy
sphinx-apidoc -f -o _build/large_image_source_gdal ../sources/gdal/large_image_source_gdal
sphinx-apidoc -f -o _build/large_image_source_mapnik ../sources/mapnik/large_image_source_mapnik
Expand Down
2 changes: 1 addition & 1 deletion girder/girder_large_image/rest/large_image_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ def deleteIncompleteTiles(self, params):
@describeRoute(
Description('List all Girder tile sources with associated extensions, '
'mime types, and versions. Lower values indicate a '
'higher priority for an extension of mime type with that '
'higher priority for an extension or mime type with that '
'source.')
)
@access.public(scope=TokenScope.DATA_READ)
Expand Down
1 change: 1 addition & 0 deletions requirements-dev-core.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Top level dependencies
-e sources/bioformats
-e sources/deepzoom
-e sources/dicom
-e sources/dummy
-e sources/gdal
-e sources/multi
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ girder>=3.0.13.dev6 ; python_version >= '3.8'
girder-jobs>=3.0.3
-e sources/bioformats
-e sources/deepzoom
-e sources/dicom
-e sources/dummy
-e sources/gdal
-e sources/multi
Expand Down
1 change: 1 addition & 0 deletions requirements-worker.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-e sources/bioformats
-e sources/deepzoom
-e sources/dicom
-e sources/dummy
-e sources/gdal
-e sources/multi
Expand Down
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ def prerelease_local_scheme(version):
'gdal': [f'large-image-source-gdal{limit_version}'],
'mapnik': [f'large-image-source-mapnik{limit_version}'],
'multi': [f'large-image-source-multi{limit_version}'],
'nd2': [f'large-image-source-nd2{limit_version}'],
'ometiff': [f'large-image-source-ometiff{limit_version}'],
'openjpeg': [f'large-image-source-openjpeg{limit_version}'],
'openslide': [f'large-image-source-openslide{limit_version}'],
Expand All @@ -63,6 +62,10 @@ def prerelease_local_scheme(version):
sources.update({
'nd2': [f'large-image-source-nd2{limit_version}'],
})
if sys.version_info >= (3, 8):
sources.update({
'dicom': [f'large-image-source-dicom{limit_version}'],
})
extraReqs.update(sources)
extraReqs['sources'] = list(set(itertools.chain.from_iterable(sources.values())))
extraReqs['all'] = list(set(itertools.chain.from_iterable(extraReqs.values())))
Expand Down
224 changes: 224 additions & 0 deletions sources/dicom/large_image_source_dicom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import math
import os
import warnings

import numpy

from large_image.cache_util import LruCacheMetaclass, methodcache
from large_image.constants import TILE_FORMAT_PIL, SourcePriority
from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError
from large_image.tilesource import FileTileSource
from large_image.tilesource.utilities import _imageToNumpy, _imageToPIL

wsidicom = None

try:
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _importlib_version
except ImportError:
from importlib_metadata import PackageNotFoundError
from importlib_metadata import version as _importlib_version
try:
__version__ = _importlib_version(__name__)
except PackageNotFoundError:
# package is not installed
pass


def _lazyImport():
"""
Import the wsidicom module. This is done when needed rather than in the
module initialization because it is slow.
"""
global wsidicom

if wsidicom is None:
try:
import wsidicom
except ImportError:
raise TileSourceError('nd2 module not found.')
warnings.filterwarnings('ignore', category=UserWarning, module='wsidicom')
warnings.filterwarnings('ignore', category=UserWarning, module='pydicom')


class DICOMFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
"""
Provides tile access to dicom files the dicom or dicomreader library can read.
"""

cacheName = 'tilesource'
name = 'dicom'
extensions = {
None: SourcePriority.LOW,
'dcm': SourcePriority.PREFERRED,
'dic': SourcePriority.PREFERRED,
'dicom': SourcePriority.PREFERRED,
}
mimeTypes = {
None: SourcePriority.FALLBACK,
'application/dicom': SourcePriority.PREFERRED,
}

def __init__(self, path, **kwargs):
"""
Initialize the tile class. See the base class for other available
parameters.
:param path: a filesystem path for the tile source.
"""
super().__init__(path, **kwargs)

# We want to make a list of paths of files in this item, if multiple,
# or adjacent items in the folder if the item is a single file. We
# filter files with names that have a preferred extension.
path = self._getLargeImagePath()
if not isinstance(path, list):
path = str(path)
if not os.path.isfile(path):
raise TileSourceFileNotFoundError(path) from None
root = os.path.dirname(path)
self._largeImagePath = [
os.path.join(root, entry) for entry in os.listdir(root)
if os.path.isfile(os.path.join(root, entry)) and
os.path.splitext(entry)[-1][1:] in self.extensions]
if path not in self._largeImagePath:
self._largeImagePath = [path]
# TODO: fail if this file is level-(n) and a file that is
# level-(n-1) exists
else:
self._largeImagePath = path
_lazyImport()
try:
self._dicom = wsidicom.WsiDicom.open(self._largeImagePath)
except Exception:
raise TileSourceError('File cannot be opened via dicom tile source.')
self.sizeX = int(self._dicom.image_size.width)
self.sizeY = int(self._dicom.image_size.height)
self.tileWidth = int(self._dicom.tile_size.width)
self.tileHeight = int(self._dicom.tile_size.height)
self.levels = int(max(1, math.ceil(math.log(
float(max(self.sizeX, self.sizeY)) / self.tileWidth) / math.log(2)) + 1))

def __del__(self):
if getattr(self, '_dicom', None) is not None:
try:
self._dicom.close()
finally:
self._dicom = None

def getNativeMagnification(self):
"""
Get the magnification at a particular level.
:return: magnification, width of a pixel in mm, height of a pixel in mm.
"""
mm_x = mm_y = None
try:
mm_x = self._dicom.base_level.pixel_spacing.width or None
mm_y = self._dicom.base_level.pixel_spacing.height or None
except Exception:
pass
# Estimate the magnification; we don't have a direct value
mag = 0.01 / mm_x if mm_x else None
return {
'magnification': mag,
'mm_x': mm_x,
'mm_y': mm_y,
}

def getMetadata(self):
"""
Return a dictionary of metadata containing levels, sizeX, sizeY,
tileWidth, tileHeight, magnification, mm_x, mm_y, and frames.
:returns: metadata dictionary.
"""
result = super().getMetadata()
return result

def getInternalMetadata(self, **kwargs):
"""
Return additional known metadata about the tile source. Data returned
from this method is not guaranteed to be in any particular format or
have specific values.
:returns: a dictionary of data or None.
"""
result = {}
return result

@methodcache()
def getTile(self, x, y, z, pilImageAllowed=False, numpyAllowed=False, **kwargs):
frame = self._getFrame(**kwargs)
self._xyzInRange(x, y, z, frame)
x0, y0, x1, y1, step = self._xyzToCorners(x, y, z)
bw = self.tileWidth * step
bh = self.tileHeight * step
level = 0
levelfactor = 1
basefactor = self._dicom.base_level.pixel_spacing.width
for checklevel in range(1, len(self._dicom.levels)):
factor = round(self._dicom.levels[checklevel].pixel_spacing.width / basefactor)
if factor <= step:
level = checklevel
levelfactor = factor
else:
break
x0f = int(x0 // levelfactor)
y0f = int(y0 // levelfactor)
x1f = min(int(math.ceil(x1 / levelfactor)), self._dicom.levels[level].size.width)
y1f = min(int(math.ceil(y1 / levelfactor)), self._dicom.levels[level].size.height)
bw = int(bw // levelfactor)
bh = int(bh // levelfactor)
tile = self._dicom.read_region(
(x0f, y0f), self._dicom.levels[level].level, (x1f - x0f, y1f - y0f))
format = TILE_FORMAT_PIL
if tile.width < bw or tile.height < bh:
tile = _imageToNumpy(tile)[0]
tile = numpy.pad(
tile,
((0, bh - tile.shape[0]), (0, bw - tile.shape[1]), (0, 0)),
'constant', constant_values=0)
tile = _imageToPIL(tile)
if bw > self.tileWidth or bh > self.tileHeight:
tile = tile.resize((self.tileWidth, self.tileHeight))
return self._outputTile(tile, format, x, y, z,
pilImageAllowed, numpyAllowed, **kwargs)

def getAssociatedImagesList(self):
"""
Return a list of associated images.
:return: the list of image keys.
"""
return [key for key in ['label', 'macro'] if self._getAssociatedImage(key)]

def _getAssociatedImage(self, imageKey):
"""
Get an associated image in PIL format.
:param imageKey: the key of the associated image.
:return: the image in PIL format or None.
"""
keyMap = {
'label': 'read_label',
'macro': 'read_overview',
}
try:
return getattr(self._dicom, keyMap[imageKey])()
except Exception:
return None


def open(*args, **kwargs):
"""
Create an instance of the module class.
"""
return DICOMFileTileSource(*args, **kwargs)


def canRead(*args, **kwargs):
"""
Check if an input can be read by the module class.
"""
return DICOMFileTileSource.canRead(*args, **kwargs)
38 changes: 38 additions & 0 deletions sources/dicom/large_image_source_dicom/girder_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os

from girder_large_image.girder_tilesource import GirderTileSource

from girder.models.file import File
from girder.models.folder import Folder
from girder.models.item import Item

from . import DICOMFileTileSource


class DICOMGirderTileSource(DICOMFileTileSource, GirderTileSource):
"""
Provides tile access to Girder items with an DICOM file or other files that
the dicomreader library can read.
"""

cacheName = 'tilesource'
name = 'dicom'

_mayHaveAdjacentFiles = True

def _getLargeImagePath(self):
filelist = [
File().getLocalFilePath(file) for file in Item().childFiles(self.item)
if os.path.splitext(file['name'])[-1][1:] in self.extensions]
if len(filelist) > 1:
return filelist
filelist = []
folder = Folder().load(self.item['folderId'], force=True)
for item in Folder().childItems(folder):
if len(list(Item().childFiles(item, limit=2))) == 1:
file = next(Item().childFiles(item, limit=2))
if os.path.splitext(file['name'])[-1][1:] in self.extensions:
filelist.append(File().getLocalFilePath(file))
# TODO: fail if this file is level-(n) and a file that is
# level-(n-1) exists
return filelist
Loading

0 comments on commit 4bce481

Please sign in to comment.