Skip to content

Commit

Permalink
Add initial DICOMweb support
Browse files Browse the repository at this point in the history
This creates a new DICOMweb assetstore that is used to track resources on
a DICOMweb server. The user provides the DICOMweb server URL and optionally
QIDO and WADO prefixes (if the server requires them). The user is then able
to import references to the objects on the DICOMweb server.

The files that are created via this assetstore are able to be viewed with the
dicom tile source, which has been extended to handle files on a DICOMweb
server.

A test was also added that utilizes the public DICOMweb server located
[here](https://imagingdatacommons.github.io/slim/).

There is still more work to do in future PRs, including:

* Adding a search/filter capability
* Adding authentication for non-public servers
* Query and store metadata from the DICOMweb objects

Fixes: #1204

Signed-off-by: Patrick Avery <patrick.avery@kitware.com>
  • Loading branch information
psavery committed Sep 18, 2023
1 parent 662b53e commit 2b50860
Show file tree
Hide file tree
Showing 26 changed files with 1,097 additions and 5 deletions.
94 changes: 90 additions & 4 deletions sources/dicom/large_image_source_dicom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import numpy as np

from large_image import config
from large_image.cache_util import LruCacheMetaclass, methodcache
from large_image.constants import TILE_FORMAT_PIL, SourcePriority
from large_image.exceptions import TileSourceError, TileSourceFileNotFoundError
Expand Down Expand Up @@ -110,11 +111,14 @@ def __init__(self, path, **kwargs):
"""
super().__init__(path, **kwargs)

self.logger = config.getConfig('logger')

# 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.
# If the path is a dict, that likely means it is a DICOMweb asset.
path = self._getLargeImagePath()
if not isinstance(path, list):
if not isinstance(path, (dict, list)):
path = str(path)
if not os.path.isfile(path):
raise TileSourceFileNotFoundError(path) from None
Expand All @@ -129,10 +133,11 @@ def __init__(self, path, **kwargs):
self._largeImagePath = path
_lazyImport()
try:
self._dicom = wsidicom.WsiDicom.open(self._largeImagePath)
except Exception:
msg = 'File cannot be opened via dicom tile source.'
self._dicom = self._open_wsi_dicom(self._largeImagePath)
except Exception as exc:
msg = f'File cannot be opened via dicom tile source ({exc}).'
raise TileSourceError(msg)

self.sizeX = int(self._dicom.size.width)
self.sizeY = int(self._dicom.size.height)
self.tileWidth = int(self._dicom.tile_size.width)
Expand All @@ -143,6 +148,87 @@ def __init__(self, path, **kwargs):
max(self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1))
self._populatedLevels = len(self._dicom.levels)

def _open_wsi_dicom(self, path):
if isinstance(path, dict):
# Use the DICOMweb open method
return self._open_wsi_dicomweb(path)
else:
# Use the regular open method
return wsidicom.WsiDicom.open(path)

def _open_wsi_dicomweb(self, info):
# These are the required keys in the info dict
url = info['url']
study_uid = info['study_uid']
series_uid = info['series_uid']

# These are optional keys
qido_prefix = info.get('qido_prefix')
wado_prefix = info.get('wado_prefix')
auth = info.get('auth')

# Create the client
client = wsidicom.WsiDicomWebClient(
url,
qido_prefix=qido_prefix,
wado_prefix=wado_prefix,
auth=auth,
)

# Identify the transfer syntax
transfer_syntax = self._identify_dicomweb_transfer_syntax(client,
study_uid,
series_uid)

# Open the WSI DICOMweb file
return wsidicom.WsiDicom.open_web(client, study_uid, series_uid,
requested_transfer_syntax=transfer_syntax)

def _identify_dicomweb_transfer_syntax(self, client, study_uid, series_uid):
# "client" is a wsidicom.WsiDicomWebClient

# This is how we select the JPEG type to return
# The available transfer syntaxes used by wsidicom may be found here:
# https://github.com/imi-bigpicture/wsidicom/blob/a2716cd6a443f4102e66e35bbce32b0e2ae72dab/wsidicom/web/wsidicom_web_client.py#L97-L109
# (we may need to update this if they add more options)
# FIXME: maybe this function better belongs upstream in `wsidicom`?
from pydicom.uid import JPEG2000, JPEG2000Lossless, JPEGBaseline8Bit, JPEGExtended12Bit

# Prefer the transfer syntaxes in this order.
transfer_syntax_preferred_order = [
JPEGBaseline8Bit,
JPEGExtended12Bit,
JPEG2000,
JPEG2000Lossless,
]
available_transfer_syntax_tag = '00083002'

# Access the dicom web client, and search for one instance for the given
# study and series. Check the available transfer syntaxes.
result, = client._client.search_for_instances(
study_uid, series_uid,
fields=[available_transfer_syntax_tag], limit=1)

if available_transfer_syntax_tag in result:
available_transfer_syntaxes = result[available_transfer_syntax_tag]['Value']
for syntax in transfer_syntax_preferred_order:
if syntax in available_transfer_syntaxes:
return syntax
else:
# The server is not telling us which transfer syntaxes are available.
# Print a warning, default to JPEG2000, and hope for the best.
self.logger.warning(
'DICOMweb server is not communicating the available '
'transfer syntaxes. Assuming JPEG2000...',
)
return JPEG2000

msg = (
'Could not find an appropriate transfer syntax. '
f'Available transfer syntaxes are: {available_transfer_syntaxes}'
)
raise TileSourceError(msg)

def __del__(self):
# If we have an _unstyledInstance attribute, this is not the owner of
# the _docim handle, so we can't close it. Otherwise, we need to close
Expand Down
85 changes: 85 additions & 0 deletions sources/dicom/large_image_source_dicom/assetstore/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from girder import events
from girder.api import access
from girder.api.v1.assetstore import Assetstore as AssetstoreResource
from girder.constants import AssetstoreType
from girder.models.assetstore import Assetstore
from girder.utility.assetstore_utilities import setAssetstoreAdapter

from .dicomweb_assetstore_adapter import DICOMWEB_META_KEY, DICOMwebAssetstoreAdapter
from .rest import DICOMwebAssetstoreResource

__all__ = [
'DICOMWEB_META_KEY',
'DICOMwebAssetstoreAdapter',
'load',
]


@access.admin
def createAssetstore(event):
"""
When an assetstore is created, make sure it has a well-formed DICOMweb
information record.
:param event: Girder rest.post.assetstore.before event.
"""
params = event.info['params']

if params.get('type') == AssetstoreType.DICOMWEB:
event.addResponse(Assetstore().save({
'type': AssetstoreType.DICOMWEB,
'name': params.get('name'),
DICOMWEB_META_KEY: {
'url': params['url'],
'qido_prefix': params.get('qido_prefix'),
'wado_prefix': params.get('wado_prefix'),
'auth_type': params.get('auth_type'),
},
}))
event.preventDefault()


def updateAssetstore(event):
"""
When an assetstore is updated, make sure the result has a well-formed set
of DICOMweb information.
:param event: Girder assetstore.update event.
"""
params = event.info['params']
store = event.info['assetstore']

if store['type'] == AssetstoreType.DICOMWEB:
store[DICOMWEB_META_KEY] = {
'url': params['url'],
'qido_prefix': params.get('qido_prefix'),
'wado_prefix': params.get('wado_prefix'),
'auth_type': params.get('auth_type'),
}


def load(info):
"""
Load the plugin into Girder.
:param info: a dictionary of plugin information. The name key contains the
name of the plugin according to Girder.
"""
AssetstoreType.DICOMWEB = 'dicomweb'
setAssetstoreAdapter(AssetstoreType.DICOMWEB, DICOMwebAssetstoreAdapter)
events.bind('assetstore.update', 'dicomweb_assetstore', updateAssetstore)
events.bind('rest.post.assetstore.before', 'dicomweb_assetstore',
createAssetstore)

(AssetstoreResource.createAssetstore.description
.param('url', 'The base URL for the DICOMweb server (for DICOMweb)',
required=False)
.param('qido_prefix', 'The QIDO URL prefix for the server, if needed (for DICOMweb)',
required=False)
.param('wado_prefix', 'The WADO URL prefix for the server, if needed (for DICOMweb)',
required=False)
.param('auth_type',
'The authentication type required for the server, if needed (for DICOMweb)',
required=False))

info['apiRoot'].dicomweb_assetstore = DICOMwebAssetstoreResource()
Loading

0 comments on commit 2b50860

Please sign in to comment.