350 lines
12 KiB
Python
Executable File
350 lines
12 KiB
Python
Executable File
# Copyright 2008-2021 pydicom authors. See LICENSE file for details.
|
|
"""Methods for converting Datasets and DataElements to/from json"""
|
|
|
|
import base64
|
|
from inspect import signature
|
|
from typing import TypeAlias, Any, cast, TYPE_CHECKING
|
|
from collections.abc import Callable
|
|
|
|
from pydicom.misc import warn_and_log
|
|
from pydicom.valuerep import FLOAT_VR, INT_VR, VR
|
|
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
from pydicom.dataset import Dataset
|
|
|
|
|
|
JSON_VALUE_KEYS = ("Value", "BulkDataURI", "InlineBinary")
|
|
|
|
|
|
def convert_to_python_number(value: Any, vr: str) -> Any:
|
|
"""When possible convert numeric-like values to either ints or floats
|
|
based on their value representation.
|
|
|
|
Parameters
|
|
----------
|
|
value : Any
|
|
Value of the data element.
|
|
vr : str
|
|
Value representation of the data element.
|
|
|
|
Returns
|
|
-------
|
|
Any
|
|
|
|
* If `value` is empty then returns the `value` unchanged.
|
|
* If `vr` is an integer-like VR type then returns ``int`` or
|
|
``List[int]``
|
|
* If `vr` is a float-like VR type then returns ``float`` or
|
|
``List[float]``
|
|
* Otherwise returns `value` unchanged
|
|
|
|
"""
|
|
from pydicom.dataelem import empty_value_for_VR
|
|
|
|
if value is None or "":
|
|
return value
|
|
|
|
number_type: type[int] | type[float] | None = None
|
|
if vr in (INT_VR - {VR.AT}) | {VR.US_SS}:
|
|
number_type = int
|
|
if vr in FLOAT_VR:
|
|
number_type = float
|
|
|
|
if number_type is None:
|
|
return value
|
|
|
|
if isinstance(value, list | tuple):
|
|
return [
|
|
number_type(v) if v is not None else empty_value_for_VR(vr) for v in value
|
|
]
|
|
|
|
return number_type(value)
|
|
|
|
|
|
OtherValueType = None | str | int | float
|
|
PNValueType = None | str | dict[str, str]
|
|
SQValueType = dict[str, Any] | None # Recursive
|
|
|
|
ValueType: TypeAlias = PNValueType | SQValueType | OtherValueType
|
|
InlineBinaryType: TypeAlias = str | list[str]
|
|
BulkDataURIType: TypeAlias = str | list[str]
|
|
|
|
JSONValueType = list[ValueType] | InlineBinaryType | BulkDataURIType
|
|
|
|
BulkDataType = None | str | int | float | bytes
|
|
BulkDataHandlerType = Callable[[str, str, str], BulkDataType] | None
|
|
|
|
|
|
class JsonDataElementConverter:
|
|
"""Convert from a JSON struct to a :class:`DataElement`.
|
|
|
|
References
|
|
----------
|
|
|
|
* :dcm:`Annex F of Part 18 of the DICOM Standard<part18/chapter_F.html>`
|
|
* `JSON to Python object conversion table
|
|
<https://docs.python.org/3/library/json.html#json-to-py-table>`_
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
dataset_class: type["Dataset"],
|
|
tag: str,
|
|
vr: str,
|
|
value: JSONValueType,
|
|
value_key: str | None,
|
|
bulk_data_uri_handler: (
|
|
BulkDataHandlerType | Callable[[str], BulkDataType] | None
|
|
) = None,
|
|
) -> None:
|
|
"""Create a new converter instance.
|
|
|
|
Parameters
|
|
----------
|
|
dataset_class : dataset.Dataset derived class
|
|
The class object to use for **SQ** element items.
|
|
tag : str
|
|
The data element's tag in uppercase hex format like ``"7FE00010"``.
|
|
vr : str
|
|
The data element value representation.
|
|
value : str or List[None | str | int | float | dict]
|
|
The attribute value for the JSON object's "Value", "InlineBinary"
|
|
or "BulkDataURI" field. If there's no such attribute then `value`
|
|
will be ``[""]``.
|
|
value_key : str or None
|
|
The attribute name for `value`, should be one of:
|
|
``{"Value", "InlineBinary", "BulkDataURI"}``. If the element's VM
|
|
is ``0`` and none of the keys are used then will be ``None``.
|
|
bulk_data_uri_handler: callable, optional
|
|
Callable function that accepts either the `tag`, `vr` and the
|
|
"BulkDataURI" `value`, or just the "BulkDataURI" `value` of the
|
|
JSON representation of a data element and returns the actual
|
|
value of that data element (retrieved via DICOMweb WADO-RS). If
|
|
no `bulk_data_uri_handler` is specified (default) then the
|
|
corresponding element will have an "empty" value such as
|
|
``""``, ``b""`` or ``None`` depending on the
|
|
`vr` (i.e. the Value Multiplicity will be 0).
|
|
"""
|
|
self.dataset_class = dataset_class
|
|
self.tag = tag
|
|
self.vr = vr
|
|
self.value = value
|
|
self.value_key = value_key
|
|
self.bulk_data_element_handler: BulkDataHandlerType
|
|
|
|
handler = bulk_data_uri_handler
|
|
if handler and len(signature(handler).parameters) == 1:
|
|
# `handler` is Callable[[str], BulkDataType]
|
|
def wrapper(tag: str, vr: str, value: str) -> BulkDataType:
|
|
x = cast(Callable[[str], BulkDataType], handler)
|
|
return x(value)
|
|
|
|
self.bulk_data_element_handler = wrapper
|
|
else:
|
|
self.bulk_data_element_handler = cast(BulkDataHandlerType, handler)
|
|
|
|
def get_element_values(self) -> Any:
|
|
"""Return a the data element value or list of values.
|
|
|
|
Returns
|
|
-------
|
|
None, str, float, int, bytes, dataset_class or a list of these
|
|
The value or value list of the newly created data element.
|
|
"""
|
|
from pydicom.dataelem import empty_value_for_VR
|
|
|
|
# An attribute with an empty value should have no "Value",
|
|
# "BulkDataURI" or "InlineBinary"
|
|
if self.value_key is None:
|
|
return empty_value_for_VR(self.vr)
|
|
|
|
if self.value_key == "Value":
|
|
if not isinstance(self.value, list):
|
|
raise TypeError(
|
|
f"'{self.value_key}' of data element '{self.tag}' must be a list"
|
|
)
|
|
|
|
if not self.value:
|
|
return empty_value_for_VR(self.vr)
|
|
|
|
val = cast(list[ValueType], self.value)
|
|
element_value = [self.get_regular_element_value(v) for v in val]
|
|
if len(element_value) == 1 and self.vr != VR.SQ:
|
|
element_value = element_value[0]
|
|
|
|
return convert_to_python_number(element_value, self.vr)
|
|
|
|
# The value for "InlineBinary" shall be encoded as a base64 encoded
|
|
# string, as shown in PS3.18, Table F.3.1-1, but the example in
|
|
# PS3.18, Annex F.4 shows the string enclosed in a list.
|
|
# We support both variants, as the standard is ambiguous here,
|
|
# and do the same for "BulkDataURI".
|
|
value = cast(str | list[str], self.value)
|
|
if isinstance(value, list):
|
|
value = value[0]
|
|
|
|
if self.value_key == "InlineBinary":
|
|
# The `value` should be a base64 encoded str
|
|
if not isinstance(value, str):
|
|
raise TypeError(
|
|
f"Invalid attribute value for data element '{self.tag}' - "
|
|
"the value for 'InlineBinary' must be str, not "
|
|
f"{type(value).__name__}"
|
|
)
|
|
|
|
return base64.b64decode(value) # bytes
|
|
|
|
if self.value_key == "BulkDataURI":
|
|
# The `value` should be a URI as a str
|
|
if not isinstance(value, str):
|
|
raise TypeError(
|
|
f"Invalid attribute value for data element '{self.tag}' - "
|
|
"the value for 'BulkDataURI' must be str, not "
|
|
f"{type(value).__name__}"
|
|
)
|
|
|
|
if self.bulk_data_element_handler is None:
|
|
warn_and_log(
|
|
"No bulk data URI handler provided for retrieval "
|
|
f'of value of data element "{self.tag}"'
|
|
)
|
|
return empty_value_for_VR(self.vr)
|
|
|
|
return self.bulk_data_element_handler(self.tag, self.vr, value)
|
|
|
|
raise ValueError(
|
|
f"Unknown attribute name '{self.value_key}' for tag {self.tag}"
|
|
)
|
|
|
|
def get_regular_element_value(self, value: ValueType) -> Any:
|
|
"""Return a the data element value created from a json "Value" entry.
|
|
|
|
Parameters
|
|
----------
|
|
value : None, str, int, float or dict
|
|
The data element's value from the json entry.
|
|
|
|
Returns
|
|
-------
|
|
None, str, int, float or Dataset
|
|
A single value of the corresponding :class:`DataElement`.
|
|
"""
|
|
from pydicom.dataelem import empty_value_for_VR
|
|
|
|
# Table F.2.3-1 has JSON type mappings
|
|
if self.vr == VR.SQ: # Dataset
|
|
# May be an empty dict
|
|
value = cast(dict[str, Any], value)
|
|
return self.get_sequence_item(value)
|
|
|
|
if value is None:
|
|
return empty_value_for_VR(self.vr)
|
|
|
|
if self.vr == VR.PN: # str
|
|
value = cast(dict[str, str], value)
|
|
return self.get_pn_element_value(value)
|
|
|
|
if self.vr == VR.AT: # Optional[int]
|
|
# May be an empty str
|
|
value = cast(str, value)
|
|
try:
|
|
return int(value, 16)
|
|
except ValueError:
|
|
warn_and_log(f"Invalid value '{value}' for AT element - ignoring it")
|
|
|
|
return None
|
|
|
|
return value
|
|
|
|
def get_sequence_item(self, value: SQValueType) -> "Dataset":
|
|
"""Return a sequence item for the JSON dict `value`.
|
|
|
|
Parameters
|
|
----------
|
|
value : dict or None
|
|
The sequence item from the JSON entry.
|
|
|
|
Returns
|
|
-------
|
|
dataset_class
|
|
The decoded dataset item.
|
|
|
|
Raises
|
|
------
|
|
KeyError
|
|
If the "vr" key is missing for a contained element
|
|
"""
|
|
from pydicom import DataElement
|
|
from pydicom.dataelem import empty_value_for_VR
|
|
|
|
ds = self.dataset_class()
|
|
|
|
value = {} if value is None else value
|
|
for key, val in value.items():
|
|
if "vr" not in val:
|
|
raise KeyError(f"Data element '{self.tag}' must have key 'vr'")
|
|
|
|
vr = val["vr"]
|
|
unique_value_keys = tuple(set(val.keys()) & set(JSON_VALUE_KEYS))
|
|
|
|
if not unique_value_keys:
|
|
# data element with no value
|
|
elem = DataElement(
|
|
tag=int(key, 16), value=empty_value_for_VR(vr), VR=vr
|
|
)
|
|
else:
|
|
value_key = unique_value_keys[0]
|
|
elem = DataElement.from_json(
|
|
self.dataset_class,
|
|
key,
|
|
vr,
|
|
val[value_key],
|
|
value_key,
|
|
self.bulk_data_element_handler,
|
|
)
|
|
ds.add(elem)
|
|
|
|
return ds
|
|
|
|
def get_pn_element_value(self, value: str | dict[str, str]) -> str:
|
|
"""Return a person name from JSON **PN** value as str.
|
|
|
|
Values with VR PN have a special JSON encoding, see the DICOM Standard,
|
|
Part 18, :dcm:`Annex F.2.2<part18/sect_F.2.2.html>`.
|
|
|
|
Parameters
|
|
----------
|
|
value : Dict[str, str]
|
|
The person name components in the JSON entry.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The decoded PersonName object or an empty string.
|
|
"""
|
|
if not isinstance(value, dict):
|
|
# Some DICOMweb services get this wrong, so we
|
|
# workaround the issue and warn the user
|
|
# rather than raising an error.
|
|
warn_and_log(
|
|
f"Value of data element '{self.tag}' with VR Person Name (PN) "
|
|
"is not formatted correctly"
|
|
)
|
|
return value
|
|
|
|
if "Phonetic" in value:
|
|
comps = ["", "", ""]
|
|
elif "Ideographic" in value:
|
|
comps = ["", ""]
|
|
else:
|
|
comps = [""]
|
|
|
|
if "Alphabetic" in value:
|
|
comps[0] = value["Alphabetic"]
|
|
if "Ideographic" in value:
|
|
comps[1] = value["Ideographic"]
|
|
if "Phonetic" in value:
|
|
comps[2] = value["Phonetic"]
|
|
|
|
return "=".join(comps)
|