From 31819d52d8905062205387c22247b43a454f6f79 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Tue, 30 Jan 2024 12:55:14 -0600 Subject: [PATCH 1/4] Catch and fix Google Healthcare API errors This change adds support for Google Healthcare API DICOMweb servers, such as the NCI's [Imaging Data Commons](https://datacommons.cancer.gov/repository/imaging-data-commons). The problem: Google Healthcare API raises an error if `AvailableTransferSyntaxUID` is a field, or if `SOPClassUID` is used as a search filter. The `SOPClassUID` should definitely be allowed as an instance-level search filter, as documented in [Table 10.6.1-5. Required Matching Attributes](https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_10.6.html). However, this has apparently been a long-standing problem of nearly four years (see [here](https://github.com/GoogleCloudPlatform/healthcare-dicom-dicomweb-adapter/pull/30#discussion_r312954232)), so it may not be fixed anytime soon. And even if it is fixed, the Imaging Data Commons may not update their software anytime soon. It would be highly advantageous to support such a large DICOMweb repository by working around the issue. The fix in this PR is as follows: 1. The two `search_for_instances()` calls are still performed identically as before, as long as there are no HTTP errors. 2. If there is an HTTP error with a 400 status_code, and a message is present matching the errors from Google Healthcare API, then the `search_for_instances()` arguments are patched to work for Google Healthcare API, as follows: a) `AvailableTransferSyntaxUID` is simply removed, if present. b) `SOPClassUID` is manually filtered, if present (meaning it is not supplied in the `search_filters`, but only instances with a matching `SOPClassUID` are returned). These changes shouldn't have any impact on any situations except where an error occurs from a Google Healthcare API server. And in that case, the function calls are patched and then work properly. The following example works after this fix: ```python from wsidicom import WsiDicom, WsiDicomWebClient url = 'https://proxy.imaging.datacommons.cancer.gov/current/viewer-only-no-downloads-see-tinyurl-dot-com-slash-3j3d9jyp/dicomWeb' study_uid = '2.25.227261840503961430496812955999336758586' series_uid = '1.3.6.1.4.1.5962.99.1.1334438926.1589741711.1637717011470.2.0' client = WsiDicomWebClient.create_client(url) slide = WsiDicom.open_web(client, study_uid, series_uid) ``` Fixes: #141 Signed-off-by: Patrick Avery --- wsidicom/web/wsidicom_web_client.py | 59 ++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/wsidicom/web/wsidicom_web_client.py b/wsidicom/web/wsidicom_web_client.py index f7696bfb..85336e18 100644 --- a/wsidicom/web/wsidicom_web_client.py +++ b/wsidicom/web/wsidicom_web_client.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy from http import HTTPStatus import logging from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union @@ -253,7 +254,7 @@ def _get_intances( return ( self._get_uids_from_response(instance, series_uid) for series_uid in series_uids - for instance in self._client.search_for_instances( + for instance in self._search_for_instances( study_uid, series_uid, search_filters={SOP_CLASS_UID: sop_class_uid}, @@ -261,7 +262,7 @@ def _get_intances( ) return ( self._get_uids_from_response(instance) - for instance in self._client.search_for_instances( + for instance in self._search_for_instances( study_uid, fields=["AvailableTransferSyntaxUID"], search_filters={ @@ -271,6 +272,60 @@ def _get_intances( ) ) + def _search_for_instances(self, *args, **kwargs): + # Try performing a regular search_for_instances(). If there is an error, + # check if it is a Google Healthcare API error that we can fix. If so, + # fix it and make the request again. + try: + yield from self._client.search_for_instances(*args, **kwargs) + except HTTPError as e: + if e.response.status_code != 400: + # Not a Google Healthcare API error. Propagate the exception + raise + + # Check if it was a google healthcare API error + google_healthcare_api_errors = ( + 'unknown/unsupported QIDO attribute: AvailableTransferSyntaxUID', + # Sometimes, this says "SOPClassUID is not a supported instance or study...", + # and sometimes, it says "SOPClassUID is not a supported instance or series..." + # Just catch the first part with "instance" + 'SOPClassUID is not a supported instance', + ) + if not any(x in e.response.text for x in google_healthcare_api_errors): + # Not a Google Healthcare API error. Propagate the exception + raise + + # It was a Google Healthcare API error. + # Fix the request and perform it again. + + # Perform a deepcopy so that the caller's arguments are not modified. + # We assume that `fields` and `search_filters` are kwargs, not args. + kwargs = copy.deepcopy(kwargs) + + # Remove the AvailableTransferSyntaxUID, if present, as google + # healthcare API does not support this. + if 'AvailableTransferSyntaxUID' in kwargs.get('fields', []): + kwargs['fields'].remove('AvailableTransferSyntaxUID') + + # Perform manual filtering for SOP_CLASS_UID, if present. + # Google Healthcare API doesn't support this as a search filter + # (even though it definitely should). + if SOP_CLASS_UID not in kwargs.get('search_filters', {}): + # We only needed to remove the AvailableTransferSyntaxUID. + # Try the search again. + yield from self._client.search_for_instances(*args, **kwargs) + return + + # Perform the manual filtering for SOP_CLASS_UID + sop_class_uid = kwargs['search_filters'].pop(SOP_CLASS_UID) + if SOP_CLASS_UID not in kwargs.get('fields', []): + # Make sure we get the SOP_CLASS_UID so we can manually filter + kwargs.setdefault('fields', []).append(SOP_CLASS_UID) + + for result in self._client.search_for_instances(*args, **kwargs): + if result[SOP_CLASS_UID]['Value'][0] == sop_class_uid: + yield result + @staticmethod def _get_uids_from_response( response: Dict[str, Dict[Any, Any]], series_uid: Optional[UID] = None From 6e7533b8309b4bdf71f516e76e760d679ee89f2f Mon Sep 17 00:00:00 2001 From: Erik O Gabrielsson <83275777+erikogabrielsson@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:19:26 +0100 Subject: [PATCH 2/4] Google healthcare api (#150) * Generalize DICOMWeb error handling * Include modality as search filter * Test dicom web error handling * Additional dicom web test * Update packages * Update changelog --- CHANGELOG.md | 4 + poetry.lock | 182 +++++++++++++----------- pyproject.toml | 1 + tests/web/test_wsidicom_webclient.py | 205 +++++++++++++++++++++++++++ wsidicom/web/wsidicom_web_client.py | 188 +++++++++++++++--------- wsidicom/web/wsidicom_web_source.py | 6 +- 6 files changed, 435 insertions(+), 151 deletions(-) create mode 100644 tests/web/test_wsidicom_webclient.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 06407b76..7892828e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Handling of non-conformat DICOM Web responses. + ## [0.18.3] - 2024-01-22 ### Fixed diff --git a/poetry.lock b/poetry.lock index 95e0cc2a..2b4c712c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -48,13 +48,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -368,47 +368,47 @@ files = [ [[package]] name = "numpy" -version = "1.26.3" +version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" files = [ - {file = "numpy-1.26.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:806dd64230dbbfaca8a27faa64e2f414bf1c6622ab78cc4264f7f5f028fee3bf"}, - {file = "numpy-1.26.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f98011ba4ab17f46f80f7f8f1c291ee7d855fcef0a5a98db80767a468c85cd"}, - {file = "numpy-1.26.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d45b3ec2faed4baca41c76617fcdcfa4f684ff7a151ce6fc78ad3b6e85af0a6"}, - {file = "numpy-1.26.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdd2b45bf079d9ad90377048e2747a0c82351989a2165821f0c96831b4a2a54b"}, - {file = "numpy-1.26.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:211ddd1e94817ed2d175b60b6374120244a4dd2287f4ece45d49228b4d529178"}, - {file = "numpy-1.26.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b1240f767f69d7c4c8a29adde2310b871153df9b26b5cb2b54a561ac85146485"}, - {file = "numpy-1.26.3-cp310-cp310-win32.whl", hash = "sha256:21a9484e75ad018974a2fdaa216524d64ed4212e418e0a551a2d83403b0531d3"}, - {file = "numpy-1.26.3-cp310-cp310-win_amd64.whl", hash = "sha256:9e1591f6ae98bcfac2a4bbf9221c0b92ab49762228f38287f6eeb5f3f55905ce"}, - {file = "numpy-1.26.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b831295e5472954104ecb46cd98c08b98b49c69fdb7040483aff799a755a7374"}, - {file = "numpy-1.26.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e87562b91f68dd8b1c39149d0323b42e0082db7ddb8e934ab4c292094d575d6"}, - {file = "numpy-1.26.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c66d6fec467e8c0f975818c1796d25c53521124b7cfb760114be0abad53a0a2"}, - {file = "numpy-1.26.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f25e2811a9c932e43943a2615e65fc487a0b6b49218899e62e426e7f0a57eeda"}, - {file = "numpy-1.26.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:af36e0aa45e25c9f57bf684b1175e59ea05d9a7d3e8e87b7ae1a1da246f2767e"}, - {file = "numpy-1.26.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:51c7f1b344f302067b02e0f5b5d2daa9ed4a721cf49f070280ac202738ea7f00"}, - {file = "numpy-1.26.3-cp311-cp311-win32.whl", hash = "sha256:7ca4f24341df071877849eb2034948459ce3a07915c2734f1abb4018d9c49d7b"}, - {file = "numpy-1.26.3-cp311-cp311-win_amd64.whl", hash = "sha256:39763aee6dfdd4878032361b30b2b12593fb445ddb66bbac802e2113eb8a6ac4"}, - {file = "numpy-1.26.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a7081fd19a6d573e1a05e600c82a1c421011db7935ed0d5c483e9dd96b99cf13"}, - {file = "numpy-1.26.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12c70ac274b32bc00c7f61b515126c9205323703abb99cd41836e8125ea0043e"}, - {file = "numpy-1.26.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f784e13e598e9594750b2ef6729bcd5a47f6cfe4a12cca13def35e06d8163e3"}, - {file = "numpy-1.26.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f24750ef94d56ce6e33e4019a8a4d68cfdb1ef661a52cdaee628a56d2437419"}, - {file = "numpy-1.26.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:77810ef29e0fb1d289d225cabb9ee6cf4d11978a00bb99f7f8ec2132a84e0166"}, - {file = "numpy-1.26.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8ed07a90f5450d99dad60d3799f9c03c6566709bd53b497eb9ccad9a55867f36"}, - {file = "numpy-1.26.3-cp312-cp312-win32.whl", hash = "sha256:f73497e8c38295aaa4741bdfa4fda1a5aedda5473074369eca10626835445511"}, - {file = "numpy-1.26.3-cp312-cp312-win_amd64.whl", hash = "sha256:da4b0c6c699a0ad73c810736303f7fbae483bcb012e38d7eb06a5e3b432c981b"}, - {file = "numpy-1.26.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1666f634cb3c80ccbd77ec97bc17337718f56d6658acf5d3b906ca03e90ce87f"}, - {file = "numpy-1.26.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18c3319a7d39b2c6a9e3bb75aab2304ab79a811ac0168a671a62e6346c29b03f"}, - {file = "numpy-1.26.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b7e807d6888da0db6e7e75838444d62495e2b588b99e90dd80c3459594e857b"}, - {file = "numpy-1.26.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4d362e17bcb0011738c2d83e0a65ea8ce627057b2fdda37678f4374a382a137"}, - {file = "numpy-1.26.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b8c275f0ae90069496068c714387b4a0eba5d531aace269559ff2b43655edd58"}, - {file = "numpy-1.26.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cc0743f0302b94f397a4a65a660d4cd24267439eb16493fb3caad2e4389bccbb"}, - {file = "numpy-1.26.3-cp39-cp39-win32.whl", hash = "sha256:9bc6d1a7f8cedd519c4b7b1156d98e051b726bf160715b769106661d567b3f03"}, - {file = "numpy-1.26.3-cp39-cp39-win_amd64.whl", hash = "sha256:867e3644e208c8922a3be26fc6bbf112a035f50f0a86497f98f228c50c607bb2"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3c67423b3703f8fbd90f5adaa37f85b5794d3366948efe9a5190a5f3a83fc34e"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46f47ee566d98849323f01b349d58f2557f02167ee301e5e28809a8c0e27a2d0"}, - {file = "numpy-1.26.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a8474703bffc65ca15853d5fd4d06b18138ae90c17c8d12169968e998e448bb5"}, - {file = "numpy-1.26.3.tar.gz", hash = "sha256:697df43e2b6310ecc9d95f05d5ef20eacc09c7c4ecc9da3f235d39e71b7da1e4"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, ] [[package]] @@ -520,28 +520,28 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -646,6 +646,23 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-mock" +version = "3.12.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, + {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-watch" version = "4.2.0" @@ -789,54 +806,57 @@ files = [ [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, + {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "watchdog" -version = "3.0.0" +version = "4.0.0" description = "Filesystem events monitoring" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, ] [package.extras] @@ -878,4 +898,4 @@ rle = ["pylibjpeg-rle"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "356ab676a077e6cf8f769762d9692761caf20a7b8de2f950dac56d47d28cf2f9" +content-hash = "7f4eec1a9e21d4f7815121ebb3795dc91cac5822292796c493e4c701766050ba" diff --git a/pyproject.toml b/pyproject.toml index 1011b4d8..35a8732d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ black = "^23.1.0" flake8 = "^4.0.1" codespell = "^2.2.5" wsidicom-data = "^0.3.0" +pytest-mock = "^3.12.0" [build-system] requires = ["poetry-core>=1.2.0"] diff --git a/tests/web/test_wsidicom_webclient.py b/tests/web/test_wsidicom_webclient.py new file mode 100644 index 00000000..47d6ca65 --- /dev/null +++ b/tests/web/test_wsidicom_webclient.py @@ -0,0 +1,205 @@ +from http import HTTPStatus +from typing import Any, Dict, List, Optional, Sequence + +import pytest +from dicomweb_client import DICOMwebClient +from pydicom import Dataset +from pydicom.uid import UID, generate_uid +from pytest_mock import MockerFixture +from requests import HTTPError, Response + +from tests.data_gen import create_main_dataset +from wsidicom.uid import WSI_SOP_CLASS_UID +from wsidicom.web.wsidicom_web_client import ( + SERIES_INSTANCE_UID, + SOP_CLASS_UID, + SOP_INSTANCE_UID, + WsiDicomWebClient, +) + +STUDY_INSTANCE_UID = "0020000D" + + +@pytest.fixture() +def study_instance_uid(): + yield generate_uid() + + +@pytest.fixture() +def series_instance_uid(): + yield generate_uid() + + +@pytest.fixture() +def sop_instance_uid(): + yield generate_uid() + + +@pytest.fixture() +def throws_on_include_field(): + yield None + + +@pytest.fixture() +def throws_on_search_filter(): + yield None + + +@pytest.fixture() +def include_other_sop_classes(): + yield False + + +@pytest.fixture() +def instance_metadata(): + yield create_main_dataset() + + +@pytest.fixture() +def dicom_web_client( + study_instance_uid: UID, + series_instance_uid: UID, + sop_instance_uid: UID, + throws_on_include_field: Optional[Dict[str, str]], + throws_on_search_filter: Optional[Dict[str, str]], + include_other_sop_classes: bool, + instance_metadata: Dataset, + mocker: MockerFixture, +): + fixture_study_instance_uid = study_instance_uid + fixture_series_instance_uid = series_instance_uid + client = DICOMwebClient("http://localhost") + + def raise_error(status_code: HTTPStatus, message: str): + response = Response() + response.status_code = status_code + response._content = message.encode() + raise HTTPError("", response=response) + + def search_for_instances( + study_instance_uid: Optional[str] = None, + series_instance_uid: Optional[str] = None, + fuzzymatching: Optional[bool] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + fields: Optional[Sequence[str]] = None, + search_filters: Optional[Dict[str, Any]] = None, + get_remaining: bool = False, + ) -> List[Dict[str, dict]]: + if throws_on_include_field is not None and fields is not None: + for field_key, error in throws_on_include_field.items(): + if field_key in fields: + raise_error(HTTPStatus.BAD_REQUEST, error) + if throws_on_search_filter is not None and search_filters is not None: + for search_filter_key, error in throws_on_search_filter.items(): + if search_filter_key in search_filters: + raise_error(HTTPStatus.BAD_REQUEST, error) + wsi_instance = { + STUDY_INSTANCE_UID: {"vr": "UI", "Value": [fixture_study_instance_uid]}, + SERIES_INSTANCE_UID: { + "vr": "UI", + "Value": [fixture_series_instance_uid], + }, + SOP_INSTANCE_UID: {"vr": "UI", "Value": [sop_instance_uid]}, + SOP_CLASS_UID: {"vr": "UI", "Value": [WSI_SOP_CLASS_UID]}, + } + if not include_other_sop_classes: + return [wsi_instance] + other_instance = { + STUDY_INSTANCE_UID: {"vr": "UI", "Value": [fixture_study_instance_uid]}, + SERIES_INSTANCE_UID: { + "vr": "UI", + "Value": [fixture_series_instance_uid], + }, + SOP_INSTANCE_UID: {"vr": "UI", "Value": [generate_uid()]}, + SOP_CLASS_UID: {"vr": "UI", "Value": [generate_uid()]}, + } + return [wsi_instance, other_instance] + + def retrieve_instance_metadata( + study_instance_uid: str, + series_instance_uid: str, + sop_instance_uid: str, + ) -> Dict[str, dict]: + return instance_metadata.to_json_dict() + + client.search_for_instances = mocker.MagicMock( + client.search_for_instances, search_for_instances + ) + client.retrieve_instance_metadata = mocker.MagicMock( + client.retrieve_instance_metadata, retrieve_instance_metadata + ) + yield client + + +class TestWsiDicomWebClient: + @pytest.mark.parametrize( + "throws_on_include_field", + [ + { + "AvailableTransferSyntaxUID": "unknown/unsupported QIDO attribute: AvailableTransferSyntaxUID" + }, + None, + ], + ) + @pytest.mark.parametrize( + "throws_on_search_filter", + [{SOP_CLASS_UID: "SOPClassUID is not a supported instance"}, None], + ) + def test_get_wsi_instances( + self, + dicom_web_client: DICOMwebClient, + study_instance_uid: UID, + series_instance_uid: UID, + ): + # Arrange + client = WsiDicomWebClient(dicom_web_client) + + # Act + instances = client.get_wsi_instances(study_instance_uid, [series_instance_uid]) + + # Assert + assert len(list(instances)) == 1 + assert dicom_web_client.search_for_instances.called + + @pytest.mark.parametrize( + "throws_on_search_filter", + [{SERIES_INSTANCE_UID: "Some other error message"}], + ) + @pytest.mark.parametrize("include_other_sop_classes", [False, True]) + def test_get_wsi_instances_raise_on_search_filter_is_not_handled( + self, + dicom_web_client: DICOMwebClient, + study_instance_uid: UID, + series_instance_uid: UID, + ): + # Arrange + client = WsiDicomWebClient(dicom_web_client) + + # Act and Assert + with pytest.raises(HTTPError): + list(client.get_wsi_instances(study_instance_uid, [series_instance_uid])) + + # Assert + assert dicom_web_client.search_for_instances.called + + def test_get_instance( + self, + dicom_web_client: DICOMwebClient, + study_instance_uid: UID, + series_instance_uid: UID, + sop_instance_uid: UID, + instance_metadata: Dataset, + ): + # Arrange + client = WsiDicomWebClient(dicom_web_client) + + # Act + instance = client.get_instance( + study_instance_uid, series_instance_uid, sop_instance_uid + ) + + # Assert + assert instance == instance_metadata + assert dicom_web_client.retrieve_instance_metadata.called + assert dicom_web_client.retrieve_instance_metadata.call_count == 1 diff --git a/wsidicom/web/wsidicom_web_client.py b/wsidicom/web/wsidicom_web_client.py index 85336e18..5aa21ce1 100644 --- a/wsidicom/web/wsidicom_web_client.py +++ b/wsidicom/web/wsidicom_web_client.py @@ -12,17 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy -from http import HTTPStatus import logging +from http import HTTPStatus from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union from dicomweb_client.api import DICOMfileClient, DICOMwebClient from dicomweb_client.session_utils import create_session_from_auth from pydicom import Dataset -from pydicom.uid import ( - UID, -) +from pydicom.uid import UID from requests import HTTPError, Session from requests.auth import AuthBase @@ -32,7 +29,10 @@ SOP_CLASS_UID = "00080016" SOP_INSTANCE_UID = "00080018" SERIES_INSTANCE_UID = "0020000E" +MODALITY = "00080060" AVAILABLE_SOP_TRANSFER_SYNTAX_UID = "00083002" +WSI_MODALITY = "SM" +ANNOTATION_MODALITY = "ANN" class WsiDicomWebClient: @@ -103,7 +103,9 @@ def get_wsi_instances( Iterator of series and instance uid and optionally available transfer syntax uids for WSI instances in the study and series. """ - return self._get_intances(study_uid, series_uids, WSI_SOP_CLASS_UID) + return self._get_intances( + study_uid, series_uids, WSI_SOP_CLASS_UID, WSI_MODALITY + ) def get_annotation_instances( self, study_uid: UID, series_uids: Iterable[UID] @@ -124,7 +126,9 @@ def get_annotation_instances( Iterator of series and instance uid and optionally available transfer syntax uids for Annotation instances in the study and series. """ - return self._get_intances(study_uid, series_uids, ANN_SOP_CLASS_UID) + return self._get_intances( + study_uid, series_uids, ANN_SOP_CLASS_UID, ANNOTATION_MODALITY + ) def get_instance( self, study_uid: UID, series_uid: UID, instance_uid: UID @@ -228,7 +232,11 @@ def is_transfer_syntax_supported( return True def _get_intances( - self, study_uid: UID, series_uids: Iterable[UID], sop_class_uid + self, + study_uid: UID, + series_uids: Iterable[UID], + sop_class_uid: UID, + modality: str, ) -> Iterator[Tuple[UID, UID, Optional[Set[UID]]]]: """Get series, instance, and optionally available transfer syntax uids for instances of SOP class in study. @@ -254,10 +262,10 @@ def _get_intances( return ( self._get_uids_from_response(instance, series_uid) for series_uid in series_uids - for instance in self._search_for_instances( + for instance in self._client.search_for_instances( study_uid, series_uid, - search_filters={SOP_CLASS_UID: sop_class_uid}, + search_filters={SOP_CLASS_UID: sop_class_uid, MODALITY: modality}, ) ) return ( @@ -268,63 +276,105 @@ def _get_intances( search_filters={ SOP_CLASS_UID: sop_class_uid, SERIES_INSTANCE_UID: series_uids, + MODALITY: modality, }, ) ) - def _search_for_instances(self, *args, **kwargs): - # Try performing a regular search_for_instances(). If there is an error, - # check if it is a Google Healthcare API error that we can fix. If so, - # fix it and make the request again. + def _search_for_instances( + self, study_uid: UID, fields: List[str], search_filters: Dict[str, Any] + ) -> Iterator[Dict[str, Dict[Any, Any]]]: + """Search for instances in study. + + Catches known errors in server DICOMweb implementation and tries to fix them. + + Parameters + ---------- + study_uid: UID + Study UID of the study. + fields: List[str] + Fields to include in the response. + search_filters: Dict[str, Any] + Search filters to use. + + Returns + ------- + Iterator[Dict[str, Dict[Any, Any]]] + Iterator of instance metadata. + """ + # Errors that can be fixed by removing the offending filter and filtering + # the results. + known_search_filter_errors = { + HTTPStatus.BAD_REQUEST: { + "SOPClassUID is not a supported instance": SOP_CLASS_UID + } + } + # Errors that can be fixed by removing the offending field. Should only + # be used if the offending field is not required. + known_field_errors = { + HTTPStatus.BAD_REQUEST: { + "unknown/unsupported QIDO attribute: AvailableTransferSyntaxUID": "AvailableTransferSyntaxUID" + } + } try: - yield from self._client.search_for_instances(*args, **kwargs) - except HTTPError as e: - if e.response.status_code != 400: - # Not a Google Healthcare API error. Propagate the exception - raise - - # Check if it was a google healthcare API error - google_healthcare_api_errors = ( - 'unknown/unsupported QIDO attribute: AvailableTransferSyntaxUID', - # Sometimes, this says "SOPClassUID is not a supported instance or study...", - # and sometimes, it says "SOPClassUID is not a supported instance or series..." - # Just catch the first part with "instance" - 'SOPClassUID is not a supported instance', + return iter( + self._client.search_for_instances( + study_uid, + fields=fields, + search_filters=search_filters, + ) ) - if not any(x in e.response.text for x in google_healthcare_api_errors): - # Not a Google Healthcare API error. Propagate the exception - raise - - # It was a Google Healthcare API error. - # Fix the request and perform it again. - - # Perform a deepcopy so that the caller's arguments are not modified. - # We assume that `fields` and `search_filters` are kwargs, not args. - kwargs = copy.deepcopy(kwargs) - - # Remove the AvailableTransferSyntaxUID, if present, as google - # healthcare API does not support this. - if 'AvailableTransferSyntaxUID' in kwargs.get('fields', []): - kwargs['fields'].remove('AvailableTransferSyntaxUID') - - # Perform manual filtering for SOP_CLASS_UID, if present. - # Google Healthcare API doesn't support this as a search filter - # (even though it definitely should). - if SOP_CLASS_UID not in kwargs.get('search_filters', {}): - # We only needed to remove the AvailableTransferSyntaxUID. - # Try the search again. - yield from self._client.search_for_instances(*args, **kwargs) - return - - # Perform the manual filtering for SOP_CLASS_UID - sop_class_uid = kwargs['search_filters'].pop(SOP_CLASS_UID) - if SOP_CLASS_UID not in kwargs.get('fields', []): - # Make sure we get the SOP_CLASS_UID so we can manually filter - kwargs.setdefault('fields', []).append(SOP_CLASS_UID) - - for result in self._client.search_for_instances(*args, **kwargs): - if result[SOP_CLASS_UID]['Value'][0] == sop_class_uid: - yield result + except HTTPError as exception: + status_code = HTTPStatus(exception.response.status_code) + error_message = exception.response.text + logging.debug( + f"Got error code: {status_code} message: {error_message} when searching for instances." + ) + # If search filter error remove offending filter and filter the results. + try: + filter_key = next( + filter_key + for error_key, filter_key in known_search_filter_errors[ + status_code + ].items() + if status_code in known_search_filter_errors + if error_key in error_message + ) + logging.debug(f"Removing filter {filter_key} from search filters.") + filter_value = search_filters.pop(filter_key) + instances = self._search_for_instances( + study_uid, + fields=fields, + search_filters=search_filters, + ) + # Filter out instances with the removed search filter. + return ( + instance + for instance in instances + if instance[filter_key]["Value"][0] == filter_value + ) + except StopIteration: + pass + + # If a field error remove offending field. + try: + field_key = next( + field_key + for error_key, field_key in known_field_errors[status_code].items() + if status_code in known_search_filter_errors + if error_key in error_message + ) + logging.debug(f"Removing field {field_key} from fields.") + fields.remove(field_key) + return self._search_for_instances( + study_uid, + fields=fields, + search_filters=search_filters, + ) + except StopIteration: + pass + # Not a known error. Propagate the exception. + raise @staticmethod def _get_uids_from_response( @@ -347,13 +397,17 @@ def _get_uids_from_response( AVAILABLE_SOP_TRANSFER_SYNTAX_UID, None ) return ( - series_uid - if series_uid is not None - else UID(response[SERIES_INSTANCE_UID]["Value"][0]), + ( + series_uid + if series_uid is not None + else UID(response[SERIES_INSTANCE_UID]["Value"][0]) + ), UID(response[SOP_INSTANCE_UID]["Value"][0]), - set(available_transfer_syntaxes["Value"]) - if available_transfer_syntaxes - else None, + ( + set(available_transfer_syntaxes["Value"]) + if available_transfer_syntaxes + else None + ), ) @staticmethod diff --git a/wsidicom/web/wsidicom_web_source.py b/wsidicom/web/wsidicom_web_source.py index e7090eba..2175c760 100644 --- a/wsidicom/web/wsidicom_web_source.py +++ b/wsidicom/web/wsidicom_web_source.py @@ -85,9 +85,9 @@ def __init__( self._label_instances: List[WsiInstance] = [] self._overview_instances: List[WsiInstance] = [] self._annotation_instances: List[AnnotationInstance] = [] - detected_transfer_syntaxes_by_image_type: Dict[ - ImageType, Set[UID] - ] = defaultdict(set) + detected_transfer_syntaxes_by_image_type: Dict[ImageType, Set[UID]] = ( + defaultdict(set) + ) def create_instance( uids: Tuple[UID, UID, UID, Optional[Set[UID]]] From 4645777a0b7370b72d7d528ef47731fca303d9c3 Mon Sep 17 00:00:00 2001 From: Erik O Gabrielsson <83275777+erikogabrielsson@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:35:33 +0100 Subject: [PATCH 3/4] Remove action field from `StainingJsonSchema` (#151) --- .../metadata/schema/json/sample/schema.py | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/wsidicom/metadata/schema/json/sample/schema.py b/wsidicom/metadata/schema/json/sample/schema.py index f65a15e4..75bc9fab 100644 --- a/wsidicom/metadata/schema/json/sample/schema.py +++ b/wsidicom/metadata/schema/json/sample/schema.py @@ -33,44 +33,44 @@ SpecimenSamplingProcedureCode, SpecimenStainsCode, ) -from wsidicom.metadata.schema.common import DataclassLoadingSchema, LoadingSchema -from wsidicom.metadata.schema.json.fields import ( - CodeJsonField, - JsonFieldFactory, - MeasurementJsonField, - SpecimenIdentifierJsonField, - UidJsonField, -) -from wsidicom.metadata.schema.json.sample.model import ( - SpecimenJsonModel, - PreparationAction, - SampleJsonModel, - SamplingConstraintJsonModel, - SamplingJsonModel, - SlideSampleJsonModel, - BaseSpecimenJsonModel, -) -from wsidicom.metadata.schema.json.sample.parser import SpecimenJsonParser from wsidicom.metadata.sample import ( + BaseSpecimen, Collection, Embedding, - Specimen, Fixation, PreparationStep, Processing, Receiving, Sample, SampledSpecimen, + SampleLocalization, Sampling, SamplingLocation, SlideSample, - BaseSpecimen, + Specimen, SpecimenIdentifier, - SampleLocalization, Staining, Storage, UnknownSampling, ) +from wsidicom.metadata.schema.common import DataclassLoadingSchema, LoadingSchema +from wsidicom.metadata.schema.json.fields import ( + CodeJsonField, + JsonFieldFactory, + MeasurementJsonField, + SpecimenIdentifierJsonField, + UidJsonField, +) +from wsidicom.metadata.schema.json.sample.model import ( + BaseSpecimenJsonModel, + PreparationAction, + SampleJsonModel, + SamplingConstraintJsonModel, + SamplingJsonModel, + SlideSampleJsonModel, + SpecimenJsonModel, +) +from wsidicom.metadata.schema.json.sample.parser import SpecimenJsonParser class SamplingLocationJsonSchema(LoadingSchema[SamplingLocation]): @@ -173,19 +173,6 @@ def load_type(self) -> Type[Fixation]: return Fixation -class StainingJsonSchema(DataclassLoadingSchema[Staining]): - action = fields.Constant(PreparationAction.STAINING.value, dump_only=True) - substances = fields.List( - JsonFieldFactory.concept_code(SpecimenStainsCode)(), allow_none=True - ) - date_time = fields.DateTime(allow_none=True) - description = fields.String(allow_none=True) - - @property - def load_type(self) -> Type[Staining]: - return Staining - - class ReceivingJsonSchema(DataclassLoadingSchema[Receiving]): action = fields.Constant(PreparationAction.RECEIVING.value, dump_only=True) date_time = fields.DateTime(allow_none=True) @@ -228,7 +215,6 @@ class PreparationStepJsonSchema(Schema): PreparationAction.PROCESSING: ProcessingJsonSchema, PreparationAction.EMBEDDING: EmbeddingJsonSchema, PreparationAction.FIXATION: FixationJsonSchema, - PreparationAction.STAINING: StainingJsonSchema, PreparationAction.RECEIVING: ReceivingJsonSchema, PreparationAction.STORAGE: StorageJsonSchema, } @@ -270,6 +256,18 @@ def _subschema_dump(self, step: PreparationStep): return schema().dump(step, many=False) +class StainingJsonSchema(DataclassLoadingSchema[Staining]): + substances = fields.List( + JsonFieldFactory.concept_code(SpecimenStainsCode)(), allow_none=True + ) + date_time = fields.DateTime(allow_none=True) + description = fields.String(allow_none=True) + + @property + def load_type(self) -> Type[Staining]: + return Staining + + class SpecimenJsonSchema(LoadingSchema[Specimen]): """Schema for extracted specimen that has not been sampled from other specimen.""" From 55403c718cfcee6ffc5db0e9dbd9d4abf4f58688 Mon Sep 17 00:00:00 2001 From: Erik O Gabrielsson <83275777+erikogabrielsson@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:36:00 +0100 Subject: [PATCH 4/4] More codecs (#152) * Use pyjpegls and pylibjpeg-openjpeg * Add support for ht jpeg 2000 * Update changelog --- CHANGELOG.md | 5 ++ README.md | 6 ++ poetry.lock | 87 ++++++++++++++++++- pyproject.toml | 6 +- tests/codec/test_decoder.py | 161 +++++++++++++++++++++++++++++++++--- tests/codec/test_encoder.py | 16 ++++ wsidicom/codec/decoder.py | 80 ++++++++++++++++-- wsidicom/codec/encoder.py | 132 ++++++++++++++++++++++++++--- wsidicom/codec/optionals.py | 29 ++++++- wsidicom/codec/settings.py | 32 ++++--- wsidicom/uid.py | 3 + 11 files changed, 509 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7892828e..eaa642f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for decoding HT-JPEG2000 using Pillow, imagecodecs and/or pylibjpeg-openjpeg . +- Optional codec pyjpegls for JPEG-LS support. + ### Fixed - Handling of non-conformat DICOM Web responses. diff --git a/README.md b/README.md index f6215d59..66efbfec 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ Please note that this is an early release and the API is not frozen yet. Functio - JPEGBaseline8Bit - JPEG2000 - JPEG2000Lossless + - HTJPEG2000 + - HTJPEG2000Lossless + - HTJPEG2000RPCLLossless - ImplicitVRLittleEndian - ExplicitVRLittleEndian - ExplicitVRBigEndian @@ -50,9 +53,12 @@ Please note that this is an early release and the API is not frozen yet. Functio - JPEGLosslessSV1 - JPEGLSLossless - JPEGLSNearLossless + - RLELossless - With pylibjpeg-rle RLELossless is additionally supported. +- With pyjpegls JPEGLSLossless and JPEGLSNearLossless is additionally supported. + - Optical path identifiers needs to be unique across instances. - Only one pyramid (i.e. offset from slide corner) per frame of reference is supported. diff --git a/poetry.lock b/poetry.lock index 2b4c712c..22de7020 100644 --- a/poetry.lock +++ b/poetry.lock @@ -584,6 +584,87 @@ files = [ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, ] +[[package]] +name = "pyjpegls" +version = "1.2.0" +description = "JPEG-LS for Python via CharLS C++ Library" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pyjpegls-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a20fd77377c1b6c6e8cc12edec525b70f6f35a6555d3ce7aa01d8fb149b40f0b"}, + {file = "pyjpegls-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:132e4009b5e4ee2185904ed1d659a47d9e54c785c74a7397b3ba191110fbb667"}, + {file = "pyjpegls-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c596c446bfe37a793d3458e1e604b1dd5355cc245f139186db3c114d67fc7e9b"}, + {file = "pyjpegls-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20580c65e0e54fe666ccb6fcc5eb8cefdff780480ec1ebb2b9f9af98be971303"}, + {file = "pyjpegls-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:5aec745227d9f536e63ea75ffe97f0f6fae8cbb51f43b7c8d0c7833d35106db9"}, + {file = "pyjpegls-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:359a13f1288eb42eecd782f3734959ef7da1f42fc8a1e16bdc02a70d52bb560b"}, + {file = "pyjpegls-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fa72761f1bf7b96560fc907eb87ec2972cc366e0ce3559605e07d6527d5eb7f"}, + {file = "pyjpegls-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eef3a71aa64ff5f7d08973bde739ef2095e93dfcb9c7f5f9dc20cbb8991500e"}, + {file = "pyjpegls-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f0ff3c635bb84595e5096451f3e1fe1d5b734a66ddb7cefcc925d476bc1cac9"}, + {file = "pyjpegls-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:2adb885edea0c42ed9f05b79bb612b8846513ce1e8d3ed0d8022528478acdfc6"}, + {file = "pyjpegls-1.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e509bc0f983fcdee79dc4912f9e07ecc993dd4f8c064d1186a6a7e12daa965b"}, + {file = "pyjpegls-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df5b7474c05db9f31dcc16f67469e45ecee731e288343af05bfb46cc5612baa"}, + {file = "pyjpegls-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c690e359ba4714bf506dd9276c8f8d7bdb8a8899cda2e639f76f5153653c72"}, + {file = "pyjpegls-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e15e441be82374302759d2e15859d0dce0ecc7887f4a731d8b975410ceb69d8"}, + {file = "pyjpegls-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:27487c283f21a4f2c995d78b4a2a2eb814a105a5aabcc7b9a89396ddd3340608"}, + {file = "pyjpegls-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0a7046f050db1d5c9d45020c71b966eef3c0bd84b4a8d5eb6390bd1a278c21"}, + {file = "pyjpegls-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ef59136972426836f7ddc8322f68e7284dce476eb81e91face4becec4998c21e"}, + {file = "pyjpegls-1.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41a8048bb96c832c8ab9661067d3d5e47974b37b7dc9f92d8f365e0bfdc69080"}, + {file = "pyjpegls-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07bcd524ed52d2a4b07a5d133d94cd47ccf8b6454b7a0abf9c0a74bfa8de7d39"}, + {file = "pyjpegls-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:3bf11a5a421b58a1b965bbe246081ca8a1b5951739cd21a84176b40b64058769"}, + {file = "pyjpegls-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d29ae31f821f51b2cdc06b5a911dce988eefbd79996590eb08b9003d1657cf35"}, + {file = "pyjpegls-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:44eb69d1c884c6003923125ada983f2bfaa18cea73da4a589e288a197f2f23bb"}, + {file = "pyjpegls-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c3b43e64841b1ef4fbaffa94226b0fe690684e72e5d45b30afdff82f5027e2a"}, + {file = "pyjpegls-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af2c3975a15a282ffed0b012aef02f710ee72180cb4a272574f540b1dcd7b9d9"}, + {file = "pyjpegls-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c6a7bd1744a54c79c4c5895003ff7522c5fe9793f490d5c5bb9ae9583170649e"}, + {file = "pyjpegls-1.2.0.tar.gz", hash = "sha256:ae63bbe825ba655150555510f18adedea6a4d006df35dd69d125f883d8015d8f"}, +] + +[package.dependencies] +numpy = ">=1.24" + +[[package]] +name = "pylibjpeg-openjpeg" +version = "2.1.1" +description = "A Python wrapper for openjpeg, with a focus on use as a plugin for for pylibjpeg" +optional = true +python-versions = ">=3.8,<4.0" +files = [ + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d4483f1ca4d41311bcfaf451dd377590fc03e8243dbe6b6e79525c48ed9947af"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:49490d788e61732cf2b506dc92226091648a85b21f34a0a9359d73279f2a07d7"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:495a8e01806eeb41c54ecb2c8fbe56b2c0a5e7a971b61efce481c7e63ed10134"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee5817a3769524641f33d792bdf63a0543b2d218ca9035bf94722650df3741a"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-win32.whl", hash = "sha256:68b9e6ac6fa8ef0ff2b7c5a1ea2d44ee01517d575b9d318d260ba9be9e78beeb"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c94e9f89440ef2ab4ca88eed790c7d0a6c9a37ab4764b0d9b238d52cc1442322"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8dc6a2ebe472143ed97b0b3a5d5c74c850298310bf1cef7bc0f6a9d097ca3c77"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1d90cd994bde1268527a1e15d80479876815ec25a3c43f316cb62515f801dd72"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0dbb1907b9c4f2d2d5c62fef6fdd87984735f69cb97550d3d28bfbc184c1417"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022258a548f6b9105441e1e47b04c03fc740e2f1079b9edf7a0a280bba64c9c1"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-win32.whl", hash = "sha256:d2e98bca8eb98ae98271852267f7cd04f1898ecde714428380a593cfea98bf4a"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:69a65ac65042ff08013e1d5d3671fab47cdfa75ffd37cb8d92546133a6e65ca2"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9e3f57f0910a525c920b1f8da79e7cf1c20d1130a37f39bfae50cdbd49ca3ec4"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0804daabfc4c75919763d8b39aa88a80854c4517c4d06d70fb6f678d25998d56"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84b1782ca23fb016f25c7e9bffe043461739bce9764647203539acea5b358d66"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7a91d8bb05581059a43c19736b84b752f86dc685bbe3058dc2982ccc788a5f"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-win32.whl", hash = "sha256:57d1fe3e713e2c46c9d6268cd30ab934659a08cbe105056834607d7b19c9f95f"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:4e25a94741a7ac0b763adcdfd612342ff9eab82c20f6e8a9ba3a43055860d725"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:06dac38fee9f569b94c34b2361745fa7ecdc6d965c22a6e4bd7563dc55a0c59f"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:8513fca7b5c02ae0b9f14f7f4a5a4a913d480eed75fca67a438ca86e9f76a2b2"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f424fff4d61886b7d656c1402f7c6b8aed6d3836d73bc3c39f2f73fccb7a3c8"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f200e14e95857092da3c5b554be51aff810f8b6869eb3f453d7325cddca72e4"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-win32.whl", hash = "sha256:7edd1b990bba0957d509bfe4f71e32fa47568b1db42a8790ee7923370eeed06a"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:30583f2744d8f97c4b3b6d3cd31fcbc5ff62be49f2fe34d58a6d2d4ba4018297"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:4a4c87e4be0e614339da9fdc8c31f005d84db4b7979459cc301ecf2bdb80b1fd"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2262526ed6df5a0f00abb29ee1ff4334d466c2d1111b5a4e776247c61b74f180"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cc22d743744f031ef47be8a898bf1bf5b4ea30eb178f845743919a7348db8fa"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b7a8a3004b31914c7a7201bcef305a502c3aa791e6d436426c915aa6a9771c7"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-win32.whl", hash = "sha256:e4e190fa6b8fe211eb3df13be6213933a2537c3cc6539bd982a579757142b6b5"}, + {file = "pylibjpeg_openjpeg-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:909a1f661ada1a394b10527f44ac739cf7578f3238ea00a52f8ceede3468dfdd"}, + {file = "pylibjpeg_openjpeg-2.1.1.tar.gz", hash = "sha256:7374ada3a3c93ccfb7d70565b5f72acd5a7d2a3b20467ef9906e443795b2fd2a"}, +] + +[package.dependencies] +numpy = ">=1.24,<2.0" + [[package]] name = "pylibjpeg-rle" version = "1.3.0" @@ -893,9 +974,11 @@ files = [ [extras] imagecodecs = ["imagecodecs"] -rle = ["pylibjpeg-rle"] +pyjpegls = ["pyjpegls"] +pylibjpeg-openjpeg = ["pylibjpeg-openjpeg"] +pylibjpeg-rle = ["pylibjpeg-rle"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "7f4eec1a9e21d4f7815121ebb3795dc91cac5822292796c493e4c701766050ba" +content-hash = "a54d9581f004756342311581e17b7863d46603fc6454ebf6ab3808da76e4e415" diff --git a/pyproject.toml b/pyproject.toml index 35a8732d..a5946aab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,14 @@ dicomweb-client = "^0.59.1" marshmallow = "^3.20.1" imagecodecs = { version = "^2024.1.1", optional = true } pylibjpeg-rle = { version = "^1.3.0", optional = true } +pyjpegls = { version = "^1.2.0", optional = true } +pylibjpeg-openjpeg = { version = "^2.1.1", optional = true } [tool.poetry.extras] imagecodecs = ["imagecodecs"] -rle = ["pylibjpeg-rle"] +pylibjpeg-rle = ["pylibjpeg-rle"] +pyjpegls = ["pyjpegls"] +pylibjpeg-openjpeg = ["pylibjpeg-openjpeg"] [tool.poetry.group.dev.dependencies] pytest = "^7.2.0" diff --git a/tests/codec/test_decoder.py b/tests/codec/test_decoder.py index 43f147ba..dbbcf6f5 100644 --- a/tests/codec/test_decoder.py +++ b/tests/codec/test_decoder.py @@ -48,9 +48,17 @@ ImageCodecsRleDecoder, PillowDecoder, PydicomDecoder, + PyJpegLsDecoder, + PyLibJpegOpenJpegDecoder, PylibjpegRleDecoder, ) +from wsidicom.codec.optionals import ( + IMAGE_CODECS_AVAILABLE, + PYLIBJPEGLS_AVAILABLE, + PYLIBJPEGOPENJPEG_AVAILABLE, +) from wsidicom.geometry import Size +from wsidicom.uid import HTJPEG2000, HTJPEG2000Lossless, HTJPEG2000RPCLLossless @pytest.mark.unittest @@ -71,6 +79,9 @@ class TestPillowDecoder: (JPEGLSNearLossless, False), (JPEG2000Lossless, True), (JPEG2000, True), + (HTJPEG2000Lossless, True), + (HTJPEG2000, True), + (HTJPEG2000RPCLLossless, True), ], ) def test_is_supported(self, transfer_syntax: UID, expected_result: bool): @@ -140,10 +151,13 @@ class TestPydicomDecoder: (JPEGExtended12Bit, True), (JPEGLosslessP14, False), (JPEGLosslessSV1, False), - (JPEGLSLossless, False), - (JPEGLSNearLossless, False), + (JPEGLSLossless, PYLIBJPEGLS_AVAILABLE), + (JPEGLSNearLossless, PYLIBJPEGLS_AVAILABLE), (JPEG2000Lossless, True), (JPEG2000, True), + (HTJPEG2000Lossless, False), + (HTJPEG2000, False), + (HTJPEG2000RPCLLossless, False), ], ) def test_is_supported(self, transfer_syntax: UID, expected_result: bool): @@ -203,14 +217,17 @@ class TestImageCodecsDecoder: (ExplicitVRLittleEndian, False), (DeflatedExplicitVRLittleEndian, False), (RLELossless, False), - (JPEGBaseline8Bit, ImageCodecsDecoder.is_available()), - (JPEGExtended12Bit, ImageCodecsDecoder.is_available()), - (JPEGLosslessP14, ImageCodecsDecoder.is_available()), - (JPEGLosslessSV1, ImageCodecsDecoder.is_available()), - (JPEGLSLossless, ImageCodecsDecoder.is_available()), - (JPEGLSNearLossless, ImageCodecsDecoder.is_available()), - (JPEG2000Lossless, ImageCodecsDecoder.is_available()), - (JPEG2000, ImageCodecsDecoder.is_available()), + (JPEGBaseline8Bit, IMAGE_CODECS_AVAILABLE), + (JPEGExtended12Bit, IMAGE_CODECS_AVAILABLE), + (JPEGLosslessP14, IMAGE_CODECS_AVAILABLE), + (JPEGLosslessSV1, IMAGE_CODECS_AVAILABLE), + (JPEGLSLossless, IMAGE_CODECS_AVAILABLE), + (JPEGLSNearLossless, IMAGE_CODECS_AVAILABLE), + (JPEG2000Lossless, IMAGE_CODECS_AVAILABLE), + (JPEG2000, IMAGE_CODECS_AVAILABLE), + (HTJPEG2000Lossless, IMAGE_CODECS_AVAILABLE), + (HTJPEG2000, IMAGE_CODECS_AVAILABLE), + (HTJPEG2000RPCLLossless, IMAGE_CODECS_AVAILABLE), ], ) def test_is_supported(self, transfer_syntax: UID, expected_result: bool): @@ -298,6 +315,9 @@ class TestPylibjpegRleDecoder: (JPEGLSNearLossless, False), (JPEG2000Lossless, False), (JPEG2000, False), + (HTJPEG2000Lossless, False), + (HTJPEG2000, False), + (HTJPEG2000RPCLLossless, False), ], ) def test_is_supported(self, transfer_syntax: UID, expected_result: bool): @@ -358,6 +378,9 @@ class TestImagecodecsRleDecoder: (JPEGLSNearLossless, False), (JPEG2000Lossless, False), (JPEG2000, False), + (HTJPEG2000Lossless, False), + (HTJPEG2000, False), + (HTJPEG2000RPCLLossless, False), ], ) def test_is_supported(self, transfer_syntax: UID, expected_result: bool): @@ -395,3 +418,121 @@ def test_decode(self, image: Image, encoded: bytes, settings: Settings): diff = ImageChops.difference(decoded, image) for band_rms in ImageStat.Stat(diff).rms: assert band_rms == 0 + + +@pytest.mark.unittest +class TestPyJpegLsDecoder: + @pytest.mark.parametrize( + ["transfer_syntax", "expected_result"], + [ + (JPEGLSLossless, PyJpegLsDecoder.is_available()), + (JPEGLSNearLossless, PyJpegLsDecoder.is_available()), + ], + ) + def test_is_supported(self, transfer_syntax: UID, expected_result: bool): + # Arrange + + # Act + is_supported = PyJpegLsDecoder.is_supported(transfer_syntax, 3, 8) + + # Assert + assert is_supported == expected_result + + @pytest.mark.skipif( + not PyJpegLsDecoder.is_available(), reason="PyJpegLs codecs not available" + ) + @pytest.mark.parametrize( + ["settings", "allowed_rms"], + [ + (JpegLsSettings(0, 8, Channels.GRAYSCALE), 0), + (JpegLsSettings(0, 8, Channels.RGB), 0), + (JpegLsSettings(0, 16, Channels.GRAYSCALE), 0), + (JpegLsSettings(1, 8, Channels.GRAYSCALE), 1), + (JpegLsSettings(1, 8, Channels.RGB), 1), + (JpegLsSettings(1, 16, Channels.GRAYSCALE), 1), + ], + ) + def test_decode( + self, + image: Image, + encoded: bytes, + settings: Settings, + allowed_rms: float, + ): + # Arrange + decoder = PyJpegLsDecoder() + if not decoder.is_available(): + pytest.skip("PypegLs is not available") + + # Act + decoded = decoder.decode(encoded) + + # Assert + if settings.channels == Channels.GRAYSCALE: + image = image.convert("L") + decoded = decoded.convert("L") + diff = ImageChops.difference(decoded, image) + for band_rms in ImageStat.Stat(diff).rms: + assert band_rms <= allowed_rms + + +@pytest.mark.unittest +class TestPyLibJpegOpenJpegDecoder: + @pytest.mark.parametrize( + ["transfer_syntax", "expected_result"], + [ + (JPEG2000Lossless, PYLIBJPEGOPENJPEG_AVAILABLE), + (JPEG2000, PYLIBJPEGOPENJPEG_AVAILABLE), + (HTJPEG2000Lossless, PYLIBJPEGOPENJPEG_AVAILABLE), + (HTJPEG2000, PYLIBJPEGOPENJPEG_AVAILABLE), + (HTJPEG2000RPCLLossless, PYLIBJPEGOPENJPEG_AVAILABLE), + ], + ) + def test_is_supported(self, transfer_syntax: UID, expected_result: bool): + # Arrange + + # Act + is_supported = PyLibJpegOpenJpegDecoder.is_supported(transfer_syntax, 3, 8) + + # Assert + assert is_supported == expected_result + + @pytest.mark.skipif( + not PyLibJpegOpenJpegDecoder.is_available(), + reason="OpenJpeg codecs not available", + ) + @pytest.mark.parametrize( + ["settings", "allowed_rms"], + [ + (Jpeg2kSettings(80, 8, Channels.GRAYSCALE), 1), + (Jpeg2kSettings(80, 8, Channels.YBR), 1), + (Jpeg2kSettings(80, 8, Channels.RGB), 1), + (Jpeg2kSettings(80, 16, Channels.GRAYSCALE), 1), + (Jpeg2kSettings(0, 8, Channels.GRAYSCALE), 0), + (Jpeg2kSettings(0, 8, Channels.YBR), 0), + (Jpeg2kSettings(0, 8, Channels.RGB), 0), + (Jpeg2kSettings(0, 16, Channels.GRAYSCALE), 0), + ], + ) + def test_decode( + self, + image: Image, + encoded: bytes, + settings: Settings, + allowed_rms: float, + ): + # Arrange + decoder = PyLibJpegOpenJpegDecoder() + if not decoder.is_available(): + pytest.skip("OpenJpeg is not available") + + # Act + decoded = decoder.decode(encoded) + + # Assert + if settings.channels == Channels.GRAYSCALE: + image = image.convert("L") + decoded = decoded.convert("L") + diff = ImageChops.difference(decoded, image) + for band_rms in ImageStat.Stat(diff).rms: + assert band_rms <= allowed_rms diff --git a/tests/codec/test_encoder.py b/tests/codec/test_encoder.py index a242a6cb..dc2a15de 100644 --- a/tests/codec/test_encoder.py +++ b/tests/codec/test_encoder.py @@ -39,6 +39,8 @@ JpegLsEncoder, NumpyEncoder, PillowEncoder, + PyJpegLsEncoder, + PyLibJpegOpenJpegEncoder, PylibjpegRleEncoder, ) from wsidicom.instance.dataset import WsiDataset @@ -101,6 +103,12 @@ class TestEncoder: (JpegLsEncoder, JpegLsSettings(1, 8, Channels.GRAYSCALE), 1), (JpegLsEncoder, JpegLsSettings(1, 8, Channels.RGB), 1), (JpegLsEncoder, JpegLsSettings(1, 16, Channels.GRAYSCALE), 1), + (PyJpegLsEncoder, JpegLsSettings(0, 8, Channels.GRAYSCALE), 0), + (PyJpegLsEncoder, JpegLsSettings(0, 8, Channels.RGB), 0), + (PyJpegLsEncoder, JpegLsSettings(0, 16, Channels.GRAYSCALE), 0), + (PyJpegLsEncoder, JpegLsSettings(1, 8, Channels.GRAYSCALE), 1), + (PyJpegLsEncoder, JpegLsSettings(1, 8, Channels.RGB), 1), + (PyJpegLsEncoder, JpegLsSettings(1, 16, Channels.GRAYSCALE), 1), (Jpeg2kEncoder, Jpeg2kSettings(80, 8, Channels.GRAYSCALE), 1), (Jpeg2kEncoder, Jpeg2kSettings(80, 8, Channels.YBR), 1), (Jpeg2kEncoder, Jpeg2kSettings(80, 8, Channels.RGB), 1), @@ -115,6 +123,14 @@ class TestEncoder: (PillowEncoder, Jpeg2kSettings(0, 8, Channels.GRAYSCALE), 0), (PillowEncoder, Jpeg2kSettings(0, 8, Channels.YBR), 0), (PillowEncoder, Jpeg2kSettings(0, 8, Channels.RGB), 0), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(80, 8, Channels.GRAYSCALE), 1), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(80, 8, Channels.YBR), 1), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(80, 8, Channels.RGB), 1), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(80, 16, Channels.GRAYSCALE), 1), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(0, 8, Channels.GRAYSCALE), 0), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(0, 8, Channels.YBR), 0), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(0, 8, Channels.RGB), 0), + (PyLibJpegOpenJpegEncoder, Jpeg2kSettings(0, 16, Channels.GRAYSCALE), 0), (PylibjpegRleEncoder, RleSettings(8, Channels.GRAYSCALE), 0), (PylibjpegRleEncoder, RleSettings(8, Channels.RGB), 0), (PylibjpegRleEncoder, RleSettings(16, Channels.GRAYSCALE), 0), diff --git a/wsidicom/codec/decoder.py b/wsidicom/codec/decoder.py index 9e2ca7e9..01fad22e 100644 --- a/wsidicom/codec/decoder.py +++ b/wsidicom/codec/decoder.py @@ -38,20 +38,24 @@ ) from wsidicom import config -from wsidicom.codec.rle import RleCodec -from wsidicom.geometry import Size - from wsidicom.codec.optionals import ( + IMAGE_CODECS_AVAILABLE, JPEG2K, JPEG8, JPEGLS, + PYLIBJPEGLS_AVAILABLE, + PYLIBJPEGOPENJPEG_AVAILABLE, + PYLIBJPEGRLE_AVAILABLE, jpeg2k_decode, jpeg8_decode, jpegls_decode, - IMAGE_CODECS_AVAILABLE, - PYLIBJPEGRLE_AVAILABLE, + pylibjpeg_ls_decode, + pylibjpeg_openjpeg_decode, rle_decode_frame, ) +from wsidicom.codec.rle import RleCodec +from wsidicom.geometry import Size +from wsidicom.uid import HTJPEG2000, HTJPEG2000Lossless, HTJPEG2000RPCLLossless class Decoder(metaclass=ABCMeta): @@ -163,6 +167,10 @@ def create( bits_stored=bits, photometric_interpretation=photometric_interpretation, ) + elif decoder == PyJpegLsDecoder: + return PyJpegLsDecoder() + elif decoder == PyLibJpegOpenJpegDecoder: + return PyLibJpegOpenJpegDecoder() raise ValueError(f"Unsupported transfer syntax: {transfer_syntax}") @classmethod @@ -194,6 +202,8 @@ def select_decoder( "imagecodecs": ImageCodecsDecoder, "pylibjpeg_rle": PylibjpegRleDecoder, "imagecodecs_rle": ImageCodecsRleDecoder, + "pylibjpeg_ls": PyJpegLsDecoder, + "pylibjpeg_openjpeg": PyLibJpegOpenJpegDecoder, "pydicom": PydicomDecoder, } if config.settings.prefered_decoder is not None: @@ -229,7 +239,14 @@ def _set_mode(image: Image) -> Image: class PillowDecoder(Decoder): """Decoder that uses Pillow to decode images.""" - _supported_transfer_syntaxes = [JPEGBaseline8Bit, JPEG2000, JPEG2000Lossless] + _supported_transfer_syntaxes = [ + JPEGBaseline8Bit, + JPEG2000, + JPEG2000Lossless, + HTJPEG2000, + HTJPEG2000Lossless, + HTJPEG2000RPCLLossless, + ] def decode(self, frame: bytes) -> Image: image = Pillow.open(io.BytesIO(frame)) @@ -384,6 +401,9 @@ class ImageCodecsDecoder(NumpyBasedDecoder): JPEGLSNearLossless: (jpegls_decode, JPEGLS), JPEG2000Lossless: (jpeg2k_decode, JPEG2K), JPEG2000: (jpeg2k_decode, JPEG2K), + HTJPEG2000: (jpeg2k_decode, JPEG2K), + HTJPEG2000Lossless: (jpeg2k_decode, JPEG2K), + HTJPEG2000RPCLLossless: (jpeg2k_decode, JPEG2K), } def __init__(self, transfer_syntax: UID) -> None: @@ -502,3 +522,51 @@ def _decode(self, frame: bytes) -> np.ndarray: if self._samples_per_pixel == 3: decoded = decoded.transpose((1, 2, 0)) return decoded + + +class PyJpegLsDecoder(NumpyBasedDecoder): + """Decoder that uses pyjpegls to decode images.""" + + @classmethod + def is_available(cls) -> bool: + return PYLIBJPEGLS_AVAILABLE + + def _decode(self, frame: bytes) -> np.ndarray: + if not self.is_available(): + raise RuntimeError("Pylibjpeg not available.") + return pylibjpeg_ls_decode(np.frombuffer(frame, dtype=np.uint8)) + + @classmethod + def is_supported( + cls, transfer_syntax: UID, samples_per_pixel: int, bits: int + ) -> bool: + if not cls.is_available(): + return False + return transfer_syntax in [JPEGLSNearLossless, JPEGLSLossless] + + +class PyLibJpegOpenJpegDecoder(NumpyBasedDecoder): + """Decoder that uses pylibjpeg-openjpeg to decode images.""" + + @classmethod + def is_available(cls) -> bool: + return PYLIBJPEGOPENJPEG_AVAILABLE + + def _decode(self, frame: bytes) -> np.ndarray: + if not self.is_available(): + raise RuntimeError("Pylibjpeg-openjpeg not available.") + return pylibjpeg_openjpeg_decode(frame) + + @classmethod + def is_supported( + cls, transfer_syntax: UID, samples_per_pixel: int, bits: int + ) -> bool: + if not cls.is_available(): + return False + return transfer_syntax in [ + JPEG2000, + JPEG2000Lossless, + HTJPEG2000, + HTJPEG2000Lossless, + HTJPEG2000RPCLLossless, + ] diff --git a/wsidicom/codec/encoder.py b/wsidicom/codec/encoder.py index 8a2cba0a..0d3f443f 100644 --- a/wsidicom/codec/encoder.py +++ b/wsidicom/codec/encoder.py @@ -14,9 +14,9 @@ """Module with encoders for image data.""" +import io from abc import ABCMeta, abstractmethod from enum import Enum -import io from typing import Dict, Generic, Optional, Tuple, Type, TypeVar, Union import numpy as np @@ -25,23 +25,27 @@ from pydicom import Dataset from pydicom.pixel_data_handlers.util import pixel_dtype from pydicom.uid import ( + JPEG2000, UID, JPEGBaseline8Bit, JPEGExtended12Bit, - JPEG2000, JPEGLSNearLossless, ) + from wsidicom.codec.optionals import ( IMAGE_CODECS_AVAILABLE, - PYLIBJPEGRLE_AVAILABLE, - rle_encode_frame, JPEG8, + PYLIBJPEGLS_AVAILABLE, + PYLIBJPEGOPENJPEG_AVAILABLE, + PYLIBJPEGRLE_AVAILABLE, jpeg2k_encode, jpeg8_encode, jpegls_encode, + pylibjpeg_ls_encode, + pylibjpeg_openjpeg_encode, + rle_encode_frame, ) from wsidicom.codec.rle import RleCodec - from wsidicom.codec.settings import ( Channels, Jpeg2kSettings, @@ -53,6 +57,7 @@ Settings, Subsampling, ) +from wsidicom.uid import HTJPEG2000 SettingsType = TypeVar("SettingsType", bound=Settings) @@ -61,6 +66,7 @@ class LossyCompressionIsoStandard(Enum): JPEG_LOSSY = "ISO_10918_1" JPEG_LS_NEAR_LOSSLESS = "ISO_14495_1" JPEG_2000_IRREVERSIBLE = "ISO_15444_1" + HT_JPEG_2000_IRREVERSIBLE = "ISO_15444_15" @classmethod def transfer_syntax_to_iso( @@ -72,6 +78,8 @@ def transfer_syntax_to_iso( return cls.JPEG_LS_NEAR_LOSSLESS elif transfer_syntax == JPEG2000: return cls.JPEG_2000_IRREVERSIBLE + elif transfer_syntax == HTJPEG2000: + return cls.HT_JPEG_2000_IRREVERSIBLE return None @@ -229,8 +237,8 @@ def _select_encoder( encoders_by_settings: Dict[Type[Settings], Tuple[Type[Encoder], ...]] = { JpegSettings: (JpegEncoder, PillowEncoder), JpegLosslessSettings: (JpegEncoder,), - JpegLsSettings: (JpegLsEncoder,), - Jpeg2kSettings: (Jpeg2kEncoder, PillowEncoder), + JpegLsSettings: (JpegLsEncoder, PyJpegLsEncoder), + Jpeg2kSettings: (Jpeg2kEncoder, PyLibJpegOpenJpegEncoder, PillowEncoder), NumpySettings: (NumpyEncoder,), RleSettings: (PylibjpegRleEncoder, ImageCodecsRleEncoder), } @@ -264,9 +272,11 @@ def __init__(self, settings: Union[JpegSettings, Jpeg2kSettings]): } self._format = "jpeg" elif isinstance(settings, Jpeg2kSettings): + if len(settings.levels) != 1: + raise ValueError("Only one level is supported.") self._pillow_settings = { "quality_mode": "dB", - "quality_layers": [settings.level], + "quality_layers": [settings.levels[0]], "irreversible": not settings.lossless, "mct": settings.channels == Channels.YBR, "no_jp2": True, @@ -300,7 +310,11 @@ def supports_settings(cls, settings: Settings) -> bool: settings.bits == 8 and settings.subsampling in cls._supported_subsamplings ) - return isinstance(settings, Jpeg2kSettings) and settings.bits == 8 + return ( + isinstance(settings, Jpeg2kSettings) + and settings.bits == 8 + and len(settings.levels) == 1 + ) class JpegEncoder(Encoder[Union[JpegSettings, JpegLosslessSettings]]): @@ -427,6 +441,8 @@ def __init__(self, settings: Jpeg2kSettings) -> None: settings: Jpeg2kSettings Settings for the encoder. """ + if len(settings.levels) != 1: + raise ValueError("Only one level is supported.") self._bits = settings.bits if settings.channels == Channels.YBR: self._multiple_component_transform = True @@ -436,7 +452,7 @@ def __init__(self, settings: Jpeg2kSettings) -> None: self._level = 0 self._reversible = True else: - self._level = settings.level + self._level = settings.levels[0] self._reversible = False super().__init__(settings) @@ -464,9 +480,7 @@ def is_available(cls) -> bool: @classmethod def supports_settings(cls, settings: Settings) -> bool: - if not isinstance(settings, Jpeg2kSettings): - return False - return True + return isinstance(settings, Jpeg2kSettings) and len(settings.levels) == 1 class NumpyEncoder(Encoder[NumpySettings]): @@ -581,3 +595,95 @@ def _encode(self, image: np.ndarray) -> bytes: @classmethod def is_available(cls) -> bool: return PYLIBJPEGRLE_AVAILABLE + + +class PyJpegLsEncoder(Encoder[JpegLsSettings]): + """Encoder that uses pylibjpeg-ls to encode image.""" + + def __init__( + self, + settings: JpegLsSettings, + ) -> None: + """Initialize PyJpegLs (JPEG LS) encoder. + + Parameters + ---------- + settings: JpegLsSettings + Settings for the encoder. + """ + if settings.channels == Channels.GRAYSCALE: + self._interleave_mode = 0 + else: + self._interleave_mode = 2 + super().__init__(settings) + + @property + def lossy(self) -> bool: + return False + + def encode(self, image: Union[Image, np.ndarray]) -> bytes: + if not self.is_available(): + raise RuntimeError("Pylibjpeg-ls not available.") + return pylibjpeg_ls_encode( + np.asarray(image), + lossy_error=self.settings.level, + interleave_mode=self._interleave_mode, + ) + + @classmethod + def is_available(cls) -> bool: + return PYLIBJPEGLS_AVAILABLE + + @classmethod + def supports_settings(cls, settings: Settings) -> bool: + return isinstance(settings, JpegLsSettings) + + +class PyLibJpegOpenJpegEncoder(Encoder[Jpeg2kSettings]): + """Encoder that uses pylibjpeg-openjpeg to encode image.""" + + def __init__(self, settings: Jpeg2kSettings) -> None: + """Initialize PyLibjpeg OpenJpeg (JPEG 2000) encoder. + + Parameters + ---------- + settings: Jpeg2kSettings + Settings for the encoder. + """ + self._bits = settings.bits + if settings.channels == Channels.YBR: + self._multiple_component_transform = True + else: + self._multiple_component_transform = False + if settings.channels == Channels.GRAYSCALE: + self._color_space = 2 + else: + self._color_space = 1 + if settings.lossless: + self._levels = None + else: + self._levels = settings.levels + super().__init__(settings) + + @property + def lossy(self) -> bool: + return not self.settings.lossless + + def encode(self, image: Union[Image, np.ndarray]) -> bytes: + if not self.is_available(): + raise RuntimeError("Pylibjpeg-openjpeg not available.") + return pylibjpeg_openjpeg_encode( + np.asarray(image), + self.bits, + self._color_space, + self._multiple_component_transform, + signal_noise_ratios=list(self._levels) if self._levels else None, + ) + + @classmethod + def is_available(cls) -> bool: + return PYLIBJPEGOPENJPEG_AVAILABLE + + @classmethod + def supports_settings(cls, settings: Settings) -> bool: + return isinstance(settings, Jpeg2kSettings) diff --git a/wsidicom/codec/optionals.py b/wsidicom/codec/optionals.py index 184e70d3..89cb15af 100644 --- a/wsidicom/codec/optionals.py +++ b/wsidicom/codec/optionals.py @@ -30,14 +30,14 @@ JPEG2K, JPEG8, JPEGLS, + dicomrle_decode, jpeg2k_decode, - jpeg8_decode, - jpegls_decode, jpeg2k_encode, + jpeg8_decode, jpeg8_encode, + jpegls_decode, jpegls_encode, packbits_encode, - dicomrle_decode, ) IMAGE_CODECS_AVAILABLE = True @@ -55,3 +55,26 @@ jpegls_encode = None packbits_encode = None dicomrle_decode = None + +try: + from jpeg_ls import decode as pylibjpeg_ls_decode + from jpeg_ls import encode_array as pylibjpeg_ls_encode + + PYLIBJPEGLS_AVAILABLE = True + +except ImportError: + pylibjpeg_ls_decode = None + pylibjpeg_ls_encode = None + PYLIBJPEGLS_AVAILABLE = False + + +try: + from openjpeg.utils import decode as pylibjpeg_openjpeg_decode + from openjpeg.utils import encode as pylibjpeg_openjpeg_encode + + PYLIBJPEGOPENJPEG_AVAILABLE = True + +except ImportError: + pylibjpeg_openjpeg_decode = None + pylibjpeg_openjpeg_encode = None + PYLIBJPEGOPENJPEG_AVAILABLE = False diff --git a/wsidicom/codec/settings.py b/wsidicom/codec/settings.py index 5059df73..cab78654 100644 --- a/wsidicom/codec/settings.py +++ b/wsidicom/codec/settings.py @@ -16,10 +16,14 @@ from abc import ABCMeta, abstractmethod from enum import Enum -from typing import Literal, Optional, Union +from typing import Literal, Optional, Sequence, Union + from pydicom.uid import ( JPEG2000, UID, + ExplicitVRBigEndian, + ExplicitVRLittleEndian, + ImplicitVRLittleEndian, JPEG2000Lossless, JPEGBaseline8Bit, JPEGExtended12Bit, @@ -27,9 +31,6 @@ JPEGLosslessSV1, JPEGLSLossless, JPEGLSNearLossless, - ExplicitVRLittleEndian, - ExplicitVRBigEndian, - ImplicitVRLittleEndian, RLELossless, ) @@ -205,7 +206,7 @@ def create( if transfer_syntax in jpeg_2000_transfer_syntaxes: if transfer_syntax == JPEG2000: return Jpeg2kSettings(bits=bits, channels=channels) - return Jpeg2kSettings(level=0, bits=bits, channels=channels) + return Jpeg2kSettings(levels=0, bits=bits, channels=channels) if transfer_syntax == RLELossless: return RleSettings(bits=bits, channels=channels) if transfer_syntax in uncompressed_transfer_syntaxes: @@ -382,38 +383,43 @@ def extension(self) -> str: class Jpeg2kSettings(Settings): def __init__( - self, level: int = 80, bits: int = 8, channels: Channels = Channels.YBR + self, + levels: Union[int, Sequence[int]] = 80, + bits: int = 8, + channels: Channels = Channels.YBR, ): """ Initialize JPEG 2000 encoding settings. Parameters: ---------- - level: int = 80 - JPEG 2000 compression level in dB. Set to < 1 or > 1000 for lossless. + levels: Union[float, Sequence[float]] = 80.0 + JPEG 2000 compression levels in dB. Set to 0. bits: int = 8 Number of bits per pixel. channels: Channels = Channels.YBR Color channels in encoded image. """ - self._level = level + if isinstance(levels, int): + levels = [levels] + self._levels = levels super().__init__(bits, channels) def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(level={self.level}, bits={self.bits}, " + f"{self.__class__.__name__}(levels={self.levels}, bits={self.bits}, " f"channels={self.channels})" ) @property - def level(self) -> int: + def levels(self) -> Sequence[int]: """Return level.""" - return self._level + return self._levels @property def lossless(self) -> bool: """Return True if lossless, else False.""" - return self.level < 1 or self.level > 1000 + return self.levels[-1] == 0 @property def transfer_syntax(self) -> UID: diff --git a/wsidicom/uid.py b/wsidicom/uid.py index e91ba305..2701e0fe 100644 --- a/wsidicom/uid.py +++ b/wsidicom/uid.py @@ -22,6 +22,9 @@ WSI_SOP_CLASS_UID = UID("1.2.840.10008.5.1.4.1.1.77.1.6") ANN_SOP_CLASS_UID = UID("1.2.840.10008.5.1.4.1.1.91.1") +HTJPEG2000Lossless = UID("1.2.840.10008.1.2.4.201") +HTJPEG2000RPCLLossless = UID("1.2.840.10008.1.2.4.202") +HTJPEG2000 = UID("1.2.840.10008.1.2.4.203") @dataclass(frozen=True)