From 2809ea2f5c2bb9a57063d5a7870da93d95d6aba0 Mon Sep 17 00:00:00 2001 From: hackermd Date: Mon, 26 Mar 2018 11:35:24 -0400 Subject: [PATCH] Improve retrieval of frames Add support for additional media types to retrieve frames as images. Remove support for retrieval of rendered frames. --- docs/conformance.rst | 4 +- src/dicomweb_client/__init__.py | 2 +- src/dicomweb_client/api.py | 138 +++++++------------------- src/dicomweb_client/tests/test_api.py | 12 +-- 4 files changed, 47 insertions(+), 109 deletions(-) diff --git a/docs/conformance.rst b/docs/conformance.rst index e988602..635f8df 100644 --- a/docs/conformance.rst +++ b/docs/conformance.rst @@ -5,7 +5,7 @@ Conformance statement *Metadata* resource representations are requested in JSON format according to the `DICOM JSON model `_ using ``application/dicom+json`` media type. -*Rendered* image resource representations are requested in either JPEG or PNG format using ``image/jpeg`` or ``image/png`` media types, respectively. +*Rendered* resource representations are requested in either JPEG or PNG format using ``image/jpeg`` or ``image/png`` media types, respectively. QIDO-RS ------- @@ -39,7 +39,7 @@ WADO-RS +--------+-----------------------------------------------+---------------+ | GET | RetrieveFrames | Y | +--------+-----------------------------------------------+---------------+ -| GET | RetrieveRenderedTransaction | Y\* | +| GET | RetrieveRenderedTransaction | N | +--------+-----------------------------------------------+---------------+ \* not all options for retrieving rendered resource representations are implemented diff --git a/src/dicomweb_client/__init__.py b/src/dicomweb_client/__init__.py index 7a6cb49..33c132b 100644 --- a/src/dicomweb_client/__init__.py +++ b/src/dicomweb_client/__init__.py @@ -1,3 +1,3 @@ -__version__ = '0.1.1' +__version__ = '0.1.2' from dicomweb_client.api import DICOMWebClient diff --git a/src/dicomweb_client/api.py b/src/dicomweb_client/api.py index 22813ac..8ded39f 100644 --- a/src/dicomweb_client/api.py +++ b/src/dicomweb_client/api.py @@ -121,36 +121,6 @@ def _create_dataelement(tag, vr, value): return pydicom.dataelem.DataElement(tag=tag, value=elem_value, VR=vr) -def _map_color_mode(photometic_interpretation): - '''Maps a DICOM *photometric interpretation* to Python Pillow color *mode*. - - Parameters - ---------- - photometic_interpretation: str - photometric interpretation - - Returns - ------- - str - color mode - - ''' - color_map = { - 'RGB': 'RGB', - 'MONOCHROME2': 'L', - 'YBR_FULL_422': 'YCbCr' - } - try: - mode = color_map[photometic_interpretation] - except IndexError: - raise ValueError( - 'Photometric interpretation "{}" is not supported.'.format( - photometic_interpretation - ) - ) - return mode - - def load_json_dataset(dataset): '''Loads DICOM Data Set in DICOM JSON format. @@ -436,8 +406,8 @@ def _decode_multipart_message(body, headers): elements = list() for part in message.walk(): if part.get_content_maintype() == 'multipart': - # NOTE for http://wg26.pathcore.com/wado - # If only one frame number is provided, returns a normal + # Some servers don't handle this correctly. + # If only one frame number is provided, return a normal # message body instead of a multipart message body. if part.is_multipart(): continue @@ -520,14 +490,16 @@ def _http_get_multipart_application_octet_stream(self, url, **params): resp = self._http_get(url, params, {'Accept': content_type}) return self._decode_multipart_message(resp.content, resp.headers) - def _http_get_multipart_image(self, url, compression, **params): + def _http_get_multipart_image(self, url, image_format, **params): '''Performs a HTTP GET request that accepts a multipart message with - "image/{compression}" media type. + "image/{image_format}" media type. Parameters ---------- url: str unique resource locator + image_format: str + image format params: Dict[str] query parameters @@ -537,8 +509,8 @@ def _http_get_multipart_image(self, url, compression, **params): content of HTTP message body parts ''' - content_type = 'multipart/related; type="image/{compression}"'.format( - compression=compression + content_type = 'multipart/related; type="image/{image_format}"'.format( + image_format=image_format ) resp = self._http_get(url, params, {'Accept': content_type}) return self._decode_multipart_message(resp.content, resp.headers) @@ -887,60 +859,10 @@ def retrieve_instance_metadata(self, study_instance_uid, url += '/metadata' return self._http_get_application_json(url) - def retrieve_instance_frames_rendered(self, study_instance_uid, - series_instance_uid, - sop_instance_uid, frame_numbers, - compression='jpeg'): - '''Retrieves compressed frame items of pixel data element of an - individual DICOM instance. - - Parameters - ---------- - study_instance_uid: str - unique study identifier - series_instance_uid: str - unique series identifier - sop_instance_uid: str - unique instance identifier - frame_numbers: List[int] - one-based positional indices of the frames within the instance - compression: str, optional - name of the image compression format - (default:``"jpeg"``, options: ``{"jpeg", "png"}``) - - Returns - ------- - PIL.Image.Image - image - - ''' - if study_instance_uid is None: - raise ValueError( - 'Study UID is required for retrieval of instance frames.' - ) - if series_instance_uid is None: - raise ValueError( - 'Series UID is required for retrieval of instance frames.' - ) - if sop_instance_uid is None: - raise ValueError( - 'Instance UID is required for retrieval of instance frames.' - ) - if compression not in {'jpeg', 'png'}: - raise ValueError( - 'Compression format "{}" is not supported.'.format(compression) - ) - url = self._get_instances_url( - study_instance_uid, series_instance_uid, sop_instance_uid - ) - params = {'quality': 95} # TODO: viewport, window - frame_list = ','.join([str(n) for n in frame_numbers]) - url += '/frames/{frame_list}/rendered'.format(frame_list=frame_list) - pixeldata = self._http_get_multipart_image(url, compression, **params) - return [Image.open(BytesIO(d)) for d in pixeldata] - def retrieve_instance_frames(self, study_instance_uid, series_instance_uid, - sop_instance_uid, frame_numbers): + sop_instance_uid, frame_numbers, + image_format=None, + image_params={'quality': 95}): '''Retrieves uncompressed frame items of a pixel data element of an individual DICOM image instance. @@ -954,11 +876,20 @@ def retrieve_instance_frames(self, study_instance_uid, series_instance_uid, unique instance identifier frame_numbers: List[int] one-based positional indices of the frames within the instance + image_format: str, optional + name of the image format; if ``None`` pixel data will be requested + uncompressed as ``"application/octet-stream"`` + (default:``None``, options: ``{"jpeg", "png"}``) + image_params: Dict[str], optional + additional parameters relevant for a given `image_format` Returns ------- - PIL.Image.Image - image + List[Union[PIL.Image.Image, bytes]] + pixel data for each frame; type depends on `image_format` + (returned as ``PIL.Image.Image`` if frames are requested as + compressed image and as ``bytes`` if requested as uncompressed + byte stream) ''' if study_instance_uid is None: @@ -973,20 +904,27 @@ def retrieve_instance_frames(self, study_instance_uid, series_instance_uid, raise ValueError( 'Instance UID is required for retrieval of instance frames.' ) - # First retrieve metadata to determine dimensions of image - metadata = self.retrieve_instance_metadata( - study_instance_uid, series_instance_uid, sop_instance_uid - ) - dataset = load_json_dataset(metadata) - mode = _map_color_mode(dataset.PhotometricInterpretation) - size = (dataset.Columns, dataset.Rows) + if image_format is not None: + if image_format not in {'jpeg', 'png'}: + raise ValueError( + 'Image format "{}" is not supported.'.format(image_format) + ) url = self._get_instances_url( study_instance_uid, series_instance_uid, sop_instance_uid ) frame_list = ','.join([str(n) for n in frame_numbers]) url += '/frames/{frame_list}'.format(frame_list=frame_list) - pixeldata = self._http_get_multipart_application_octet_stream(url) - return [Image.frombytes(mode, size, d) for d in pixeldata] + if image_format is None: + pixeldata = self._http_get_multipart_application_octet_stream(url) + # To interpret the raw pixel data, one would need additional + # metadata, such as the dimensions of the image and its + # photometric interpretation. + return pixeldata + else: + pixeldata = self._http_get_multipart_image( + url, image_format, **image_params + ) + return [Image.open(BytesIO(d)) for d in pixeldata] @staticmethod def lookup_keyword(tag): diff --git a/src/dicomweb_client/tests/test_api.py b/src/dicomweb_client/tests/test_api.py index 616790b..cdea196 100644 --- a/src/dicomweb_client/tests/test_api.py +++ b/src/dicomweb_client/tests/test_api.py @@ -164,15 +164,15 @@ def test_retrieve_instance_pixeldata_jpeg(httpserver, client, cache_dir): sop_instance_uid = '1.2.5' frame_numbers = [114] frame_list = ','.join([str(n) for n in frame_numbers]) - result = client.retrieve_instance_frames_rendered( + result = client.retrieve_instance_frames( study_instance_uid, series_instance_uid, sop_instance_uid, - frame_numbers + frame_numbers, image_format='jpeg' ) assert result == [parsed_content] request = httpserver.requests[0] expected_path = ( '/studies/{study_instance_uid}/series/{series_instance_uid}/instances' - '/{sop_instance_uid}/frames/{frame_list}/rendered'.format(**locals()) + '/{sop_instance_uid}/frames/{frame_list}'.format(**locals()) ) assert request.path == expected_path assert request.accept_mimetypes == [(headers['content-type'], 1)] @@ -190,15 +190,15 @@ def test_retrieve_instance_pixeldata_png(httpserver, client, cache_dir): sop_instance_uid = '1.2.5' frame_numbers = [114] frame_list = ','.join([str(n) for n in frame_numbers]) - result = client.retrieve_instance_frames_rendered( + result = client.retrieve_instance_frames( study_instance_uid, series_instance_uid, sop_instance_uid, - frame_numbers, compression='png' + frame_numbers, image_format='png' ) assert result == [parsed_content] request = httpserver.requests[0] expected_path = ( '/studies/{study_instance_uid}/series/{series_instance_uid}/instances' - '/{sop_instance_uid}/frames/{frame_list}/rendered'.format(**locals()) + '/{sop_instance_uid}/frames/{frame_list}'.format(**locals()) ) assert request.path == expected_path assert request.accept_mimetypes == [(headers['content-type'], 1)]