pathology/dicom_proxy/dicom_url_util.py (246 lines of code) (raw):
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Utility functions for creating DICOM Proxy urls."""
import dataclasses
from typing import List, Mapping, NewType, Optional, Union
import dataclasses_json
from pathology.dicom_proxy import cache_enabled_type
from pathology.dicom_proxy import dicom_proxy_flags
from pathology.dicom_proxy import enum_types
from pathology.dicom_proxy import render_frame_params
from pathology.dicom_proxy import user_auth_util
_HEALTHCARE_API_URL = 'https://healthcare.googleapis.com'
_ACCEPT = 'Accept'
_AuthSession = user_auth_util.AuthSession
_UNTRANSCODED_FRAME_ACCEPT_VALUE = (
'multipart/related; type="application/octet-stream"; transfer-syntax=*'
)
DICOM_WEB_BASE_URL_DEFAULT_VERSION = (
'/projects/<string:projectid>/locations/<string:location>/datasets/'
'<string:datasetid>/dicomStores/<string:dicomstore>/dicomWeb'
)
DICOM_WEB_STUDY_URL_DEFAULT_VERSION = (
f'{DICOM_WEB_BASE_URL_DEFAULT_VERSION}/studies/<string:study_instance_uid>'
)
DICOM_WEB_SERIES_URL_DEFAULT_VERSION = (
f'{DICOM_WEB_STUDY_URL_DEFAULT_VERSION}/series/<string:series_instance_uid>'
)
DICOM_WEB_INSTANCE_URL_DEFAULT_VERSION = (
f'{DICOM_WEB_SERIES_URL_DEFAULT_VERSION}'
'/instances/<string:sop_instance_uid>'
)
DICOM_WEB_BASE_URL = (
f'/<string:store_api_version>{DICOM_WEB_BASE_URL_DEFAULT_VERSION}'
)
DICOM_WEB_STUDY_URL = (
f'{DICOM_WEB_BASE_URL}/studies/<string:study_instance_uid>'
)
DICOM_WEB_SERIES_URL = (
f'{DICOM_WEB_STUDY_URL}/series/<string:series_instance_uid>'
)
DICOM_WEB_INSTANCE_URL = (
f'{DICOM_WEB_SERIES_URL}/instances/<string:sop_instance_uid>'
)
@dataclasses.dataclass(frozen=True)
class DicomWebBaseURL:
"""Defines target DICOM Store."""
dicom_store_api_version: str
gcp_project_id: str
location: str
dataset_id: str
dicom_store: str
def __str__(self) -> str:
"""Returns URL to target defined DICOM Store."""
return (
f'{self.dicom_store_api_version}/projects/'
f'{self.gcp_project_id}/locations/{self.location}/datasets/'
f'{self.dataset_id}/dicomStores/{self.dicom_store}/dicomWeb'
)
@property
def full_url(self) -> str:
return f'{_HEALTHCARE_API_URL}/{self.__str__()}'
@property
def root_url(self) -> str:
return _HEALTHCARE_API_URL
@dataclasses.dataclass(frozen=True)
class StudyInstanceUID:
"""Defines DICOM StudyInstanceUID."""
study_instance_uid: str
def __str__(self) -> str:
"""Returns DICOMweb URL component identifying UID."""
return f'studies/{self.study_instance_uid}'
@dataclasses.dataclass(frozen=True)
class SeriesInstanceUID:
"""Defines DICOM SeriesInstanceUID."""
series_instance_uid: str
def __str__(self) -> str:
"""Returns DICOMweb URL component identifying UID."""
return f'series/{self.series_instance_uid}'
@dataclasses_json.dataclass_json
@dataclasses.dataclass(frozen=True)
class SOPInstanceUID:
"""Defines DICOM SOPInstanceUID."""
sop_instance_uid: str
def __str__(self) -> str:
"""Returns DICOMweb URL component identifying UID."""
return f'instances/{self.sop_instance_uid}'
DicomStudyUrl = NewType('DicomStudyUrl', str)
DicomSeriesUrl = NewType('DicomSeriesUrl', str)
DicomSopInstanceUrl = NewType('DicomSopInstanceUrl', str)
def base_dicom_study_url(
baseurl: DicomWebBaseURL, study: StudyInstanceUID
) -> DicomStudyUrl:
"""Returns URL identifying DICOM study from components."""
return DicomStudyUrl(f'{baseurl.full_url}/{study}')
def base_dicom_series_url(
baseurl: DicomWebBaseURL, study: StudyInstanceUID, series: SeriesInstanceUID
) -> DicomSeriesUrl:
"""Returns URL identifying DICOM series from components."""
return DicomSeriesUrl(f'{baseurl.full_url}/{study}/{series}')
def base_dicom_instance_url(
baseurl: DicomWebBaseURL,
study: StudyInstanceUID,
series: SeriesInstanceUID,
instance: SOPInstanceUID,
) -> DicomSopInstanceUrl:
"""Returns URL identifying DICOM instance from components."""
return DicomSopInstanceUrl(f'{baseurl.full_url}/{study}/{series}/{instance}')
def series_dicom_instance_url(
series_url: DicomSeriesUrl, instance: SOPInstanceUID
) -> DicomSopInstanceUrl:
"""Returns URL identifying DICOM instance from components."""
return DicomSopInstanceUrl(f'{series_url}/{instance}')
def get_dicom_store_url(url: str) -> str:
url = url.lstrip('/')
if url.lower().startswith('projects'):
url = f'{dicom_proxy_flags.DEFAULT_DICOM_STORE_API_VERSION}/{url}'
return f'{_HEALTHCARE_API_URL}/{url}'
@dataclasses.dataclass(kw_only=True, frozen=True)
class DicomStoreTransaction:
"""Wraps URL and Headers to use and pass to execute DICOMweb transaction."""
url: str
headers: Mapping[str, str]
@dataclasses.dataclass(kw_only=True, frozen=True)
class DicomStoreFrameTransaction(DicomStoreTransaction):
"""Wraps URL and Headers to use and pass to execute DICOMweb transaction."""
frame_numbers: List[int]
enable_caching: cache_enabled_type.CachingEnabled
def dicom_get_study_series_metadata_query(
user_auth: _AuthSession,
dicom_web_base_url: DicomWebBaseURL,
study_instance_uid: StudyInstanceUID,
) -> DicomStoreTransaction:
"""Request queries DICOM series instances for JSON formatted metadata.
Args:
user_auth: User header authentication.
dicom_web_base_url: DICOM web base URL.
study_instance_uid: Study instance UID.
Returns:
DicomStoreTransaction
"""
study_url = base_dicom_study_url(dicom_web_base_url, study_instance_uid)
return DicomStoreTransaction(
url=f'{study_url}/series',
headers=user_auth.add_to_header({_ACCEPT: 'application/dicom+json'}),
)
def dicom_get_study_metrics_query(
user_auth: _AuthSession,
dicom_web_base_url: DicomWebBaseURL,
study_instance_uid: StudyInstanceUID,
) -> DicomStoreTransaction:
"""Request queries DICOM series instances for JSON formatted metadata.
Args:
user_auth: User header authentication.
dicom_web_base_url: DICOM web base URL.
study_instance_uid: Study instance UID.
Returns:
DicomStoreTransaction
"""
study_url = base_dicom_study_url(dicom_web_base_url, study_instance_uid)
return DicomStoreTransaction(
url=f'{study_url}:getStudyMetrics',
headers=user_auth.add_to_header({_ACCEPT: 'application/dicom+json'}),
)
def dicom_get_series_metrics_query(
user_auth: _AuthSession,
dicom_web_base_url: DicomWebBaseURL,
study_instance_uid: StudyInstanceUID,
series_instance_uid: SeriesInstanceUID,
) -> DicomStoreTransaction:
"""Request queries DICOM series instances for JSON formatted metadata.
Args:
user_auth: User header authentication.
dicom_web_base_url: DICOM web base URL.
study_instance_uid: Study instance UID.
series_instance_uid: Study instance UID.
Returns:
DicomStoreTransaction
"""
series_url = base_dicom_series_url(
dicom_web_base_url, study_instance_uid, series_instance_uid
)
return DicomStoreTransaction(
url=f'{series_url}:getSeriesMetrics',
headers=user_auth.add_to_header({_ACCEPT: 'application/dicom+json'}),
)
def dicom_instance_tag_query(
user_auth: _AuthSession,
study_or_series_url: Union[DicomSeriesUrl, DicomStudyUrl],
instance: Optional[SOPInstanceUID] = None,
additional_tags: Optional[List[str]] = None,
) -> DicomStoreTransaction:
"""Request queries DICOM series instances for JSON formatted metadata.
Args:
user_auth: User header authentication.
study_or_series_url: DICOM study or series to query.
instance: Optional SOPInstanceUID to query in series.
additional_tags: Optional list of additional tags(keywords) to return
metadata for.
Returns:
DicomStoreTransaction
"""
query = f'{study_or_series_url}/instances'
if instance is None or not instance:
parameters = []
else:
parameters = [f'SOPInstanceUID={instance.sop_instance_uid}']
if additional_tags is not None:
parameters.extend([f'includefield={tag}' for tag in additional_tags])
if parameters:
parameters = '&'.join(parameters)
query = f'{query}?{parameters}'
return DicomStoreTransaction(
url=query,
headers=user_auth.add_to_header({_ACCEPT: 'application/dicom+json'}),
)
def dicom_instance_metadata_query(
user_auth: _AuthSession,
series_url: DicomSeriesUrl,
instance: Optional[SOPInstanceUID] = None,
) -> DicomStoreTransaction:
"""Request queries DICOM series instances for JSON formatted metadata.
Args:
user_auth: User header authentication.
series_url: DICOM series to query.
instance: Optional SOPInstanceUID to query in series.
Returns:
DicomStoreTransaction
"""
if instance is None or not instance:
query = f'{series_url}/metadata'
else:
query = f'{series_url}/instances/{instance.sop_instance_uid}/metadata'
return DicomStoreTransaction(
url=query,
headers=user_auth.add_to_header({_ACCEPT: 'application/dicom+json'}),
)
def download_dicom_instance_not_transcoded(
user_auth: _AuthSession, instance_url: DicomSopInstanceUrl
) -> DicomStoreTransaction:
"""Request to downloads untranscoded DICOM instance from store.
Args:
user_auth: User header authentication.
instance_url: DICOM URL identifying SOP Instance UID.
Returns:
DicomStoreTransaction
"""
return DicomStoreTransaction(
url=str(instance_url),
headers=user_auth.add_to_header(
{_ACCEPT: 'application/dicom; transfer-syntax=*'}
),
)
def download_bulkdata(
user_auth: _AuthSession, bulkdata_url: str
) -> DicomStoreTransaction:
"""Request to downloads untranscoded DICOM instance from store.
Args:
user_auth: User header authentication.
bulkdata_url: DICOM URL identifying SOP Instance UID.
Returns:
DicomStoreTransaction
"""
return DicomStoreTransaction(
url=bulkdata_url,
headers=user_auth.add_to_header(
{_ACCEPT: 'application/octet-stream; transfer-syntax=*'}
),
)
def download_dicom_raw_frame(
user_auth: _AuthSession,
instance_url: DicomSopInstanceUrl,
frame_numbers: List[int],
params: render_frame_params.RenderFrameParams,
) -> DicomStoreFrameTransaction:
"""Downloads raw DICOM frame from store.
Args:
user_auth: User header authentication.
instance_url: DICOM URL identifying SOP Instance UID.
frame_numbers: List of frame numbers to return.
params: render_frame_params.RenderFrameParams
Returns:
DicomStoreFrameTransaction
"""
frame_str = ','.join([str(num) for num in frame_numbers])
return DicomStoreFrameTransaction(
url=f'{instance_url}/frames/{frame_str}',
# Accept header defines the format of what the tile-server is requesting
# from the server. “application/octet-stream transfer-syntax=*” indicates
# that the tile-server wants the server to return imaging from the DICOM
# store as stored, unaltered (no transcoding).
headers=user_auth.add_to_header(
{_ACCEPT: _UNTRANSCODED_FRAME_ACCEPT_VALUE}
),
frame_numbers=frame_numbers,
enable_caching=params.enable_caching,
)
def get_rendered_frame_compression(
compression: enum_types.Compression,
) -> enum_types.Compression:
"""Converts client requested render frame compression into server request.
Not all exposed formats are supported by the DICOM server.
Args:
compression: Client requested frame pixel compression.
Returns:
Compression format which will be requested from the server. Some formats
will require transcoding by the DICOM Proxy.
"""
if compression == enum_types.Compression.JPEG:
return enum_types.Compression.JPEG
if compression == enum_types.Compression.PNG:
return enum_types.Compression.PNG
if compression in (
enum_types.Compression.WEBP,
enum_types.Compression.GIF,
enum_types.Compression.RAW,
):
return enum_types.Compression.PNG # PNG is a lossless compression return
# PNG to maximize quality.
raise ValueError('Unhandled rendered frame compression')
def _rendered_frame_accept(
compression: enum_types.Compression,
) -> Mapping[str, str]:
"""Returns MIME Accept type for DICOM Store rendered frame request.
Args:
compression: Compression encoding requested by the client may not be a
format supported by the server.
"""
compression = get_rendered_frame_compression(compression)
if compression == enum_types.Compression.JPEG:
return {_ACCEPT: 'image/jpeg'}
if compression == enum_types.Compression.PNG:
return {_ACCEPT: 'image/png'}
raise ValueError('Unhandled rendered frame compression')
def download_rendered_dicom_frame(
user_auth: _AuthSession,
instance_url: DicomSopInstanceUrl,
frame_number: int,
params: render_frame_params.RenderFrameParams,
) -> DicomStoreFrameTransaction:
"""Downloads rendered DICOM frame from store.
Args:
user_auth: User header authentication.
instance_url: DICOM URL identifying SOP Instance UID.
frame_number: Frame number to request.
params: RenderedFrameParams
Returns:
DicomStoreFrameTransaction
"""
return DicomStoreFrameTransaction(
url=f'{instance_url}/frames/{frame_number}/rendered',
headers=user_auth.add_to_header(
_rendered_frame_accept(params.compression)
),
frame_numbers=[frame_number],
enable_caching=params.enable_caching,
)