diff --git a/docs/ann.rst b/docs/ann.rst index 72309ce9..8b0ca9d7 100644 --- a/docs/ann.rst +++ b/docs/ann.rst @@ -35,7 +35,7 @@ contains. The required metadata elements include: * A ``uid`` (``str`` or :class:`highdicom.UID`) uniquely identifying the group. Usually, you will want to generate a UID for this. * An ``annotated_property_category`` and ``annotated_property_type`` - (:class:`highdicom.sr.CodedConcept`) coded values (see :ref:`coding`) + (:class:`highdicom.sr.CodedConcept`), coded values (see :ref:`coding`) describing the category and specific structure that has been annotated. * A ``graphic_type`` (:class:`highdicom.ann.GraphicTypeValues`) indicating the "form" of the annotations. Permissible values are ``"ELLIPSE"``, ``"POINT"``, @@ -45,8 +45,23 @@ contains. The required metadata elements include: algorithm used to generate the annotations (``"MANUAL"``, ``"SEMIAUTOMATIC"``, or ``"AUTOMATIC"``). -Further optional metadata may optionally be provided, see the API documentation -for more information. +Further optional metadata may optionally be provided, including: + +* An ``algorithm_identification`` + (:class:`highdicom.AlgorithmIdentificationSequence`), specifying information + about an algorithm that generated the annotations. +* A list of ``anatomic_regions`` (a sequence of + :class:`highdicom.sr.CodedConcept` objects), giving coded values (see + :ref:`coding`) describing regions containing the annotations. +* A list of ``primary_anatomic_structures`` (a sequence of + :class:`highdicom.sr.CodedConcept` objects) giving coded values (see + :ref:`coding`) describing anatomic structures of interest. +* A free-text ``description`` (``str``) of the annotation group. +* A ``display_color`` (:class:`highdicom.color.CIELabColor`) giving a + recommended value for viewers to use to render these annotations. This is in + CIE-Lab color space, but alternative constructors of the + :class:`highdicom.color.CIELabColor` class allow conversion from RGB values + or well-known color names. The actual annotation data is passed to the group as a list of ``numpy.ndarray`` objects, each of shape (*N* x *D*). *N* is the number of @@ -88,6 +103,7 @@ Here is a simple example of constructing an annotation group: algorithm_type=hd.ann.AnnotationGroupGenerationTypeValues.MANUAL, graphic_type=hd.ann.GraphicTypeValues.POINT, graphic_data=graphic_data, + display_color=hd.color.CIELabColor.from_string('turquoise'), ) Note that including two nuclei would be very unusual in practice: annotations @@ -141,6 +157,7 @@ Here is the above example with an area measurement included: graphic_type=hd.ann.GraphicTypeValues.POINT, graphic_data=graphic_data, measurements=[area_measurement], + display_color=hd.color.CIELabColor.from_string('lawngreen'), ) Constructing MicroscopyBulkSimpleAnnotation Objects @@ -256,7 +273,7 @@ of matching groups, since the filters may match multiple groups. # If there are no matches, an empty list is returned groups = ann.get_annotation_groups( - annotated_property_type=Code('53982002', "SCT", "Cell membrane"), + annotated_property_type=Code('53982002', 'SCT', 'Cell membrane'), ) assert len(groups) == 0 diff --git a/docs/seg.rst b/docs/seg.rst index fe992393..c1a8558e 100644 --- a/docs/seg.rst +++ b/docs/seg.rst @@ -85,6 +85,13 @@ description includes the following information: a human readable ID and unique ID to a specific segment. This can be used, for example, to uniquely identify particular lesions over multiple imaging studies. These are passed as strings. +- **Display Color**: (Optional) You can provide a recommended color as a + :class:`highdicom.color.CIELabColor` to use when displaying this segment. + Some viewers will use this information to decide what color to render the + segment by default. This color should be provided in CIE-Lab color space, but + alternative constructors of the :class:`highdicom.color.CIELabColor` class + allow conversion from RGB values or well-known color names. + Notice that the segment description makes use of coded concepts to ensure that the way a particular anatomical structure is described is standardized and @@ -108,6 +115,7 @@ representing a liver that has been manually segmented. segmented_property_category=codes.SCT.Organ, segmented_property_type=codes.SCT.Liver, algorithm_type=hd.seg.SegmentAlgorithmTypeValues.MANUAL, + display_color=hd.color.CIELabColor.from_string('red'), ) In this second example, we describe a segment representing a tumor that has @@ -134,6 +142,7 @@ we must first provide more information about the algorithm used in an algorithm_type=hd.seg.SegmentAlgorithmTypeValues.AUTOMATIC, algorithm_identification=algorithm_identification, anatomic_regions=[codes.SCT.Kidney] + display_color=hd.color.CIELabColor.from_rgb(0, 0, 255), ) For a description of how to access segment metadata in existing segmentations, diff --git a/pyproject.toml b/pyproject.toml index e40339a4..80bd4fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "highdicom" -version = "0.26.1" +version = "0.27.0" description = "High-level DICOM abstractions." readme = "README.md" requires-python = ">=3.10" diff --git a/src/highdicom/ann/content.py b/src/highdicom/ann/content.py index 99f15ca8..4e88ff09 100644 --- a/src/highdicom/ann/content.py +++ b/src/highdicom/ann/content.py @@ -13,6 +13,7 @@ AnnotationGroupGenerationTypeValues, GraphicTypeValues, ) +from highdicom.color import CIELabColor from highdicom.content import AlgorithmIdentificationSequence from highdicom.sr.coding import CodedConcept from highdicom.uid import UID @@ -194,7 +195,8 @@ def __init__( ) = None, primary_anatomic_structures: None | ( Sequence[Code | CodedConcept] - ) = None + ) = None, + display_color: CIELabColor | None = None, ): """ Parameters @@ -238,6 +240,8 @@ def __init__( primary_anatomic_structures: Union[Sequence[Union[highdicom.sr.Code, highdicom.sr.CodedConcept]], None], optional Anatomic structure(s) the annotations represent (see CIDs for domain-specific primary anatomic structures) + display_color: Union[highdicom.color.CIELabColor, None], optional + A recommended color to render this annotation group. """ # noqa: E501 super().__init__() @@ -293,6 +297,16 @@ def __init__( graphic_type = GraphicTypeValues(graphic_type) self.GraphicType = graphic_type.value + if display_color is not None: + if not isinstance(display_color, CIELabColor): + raise TypeError( + '"display_color" must be of type ' + 'highdicom.color.CIELabColor.' + ) + self.RecommendedDisplayCIELabValue = list( + display_color.value + ) + for i in range(len(graphic_data)): num_coords = graphic_data[i].shape[0] if graphic_type == GraphicTypeValues.POINT: diff --git a/src/highdicom/base.py b/src/highdicom/base.py index 6008bd09..6f355dd0 100644 --- a/src/highdicom/base.py +++ b/src/highdicom/base.py @@ -60,6 +60,10 @@ def __init__( device_serial_number: str | None = None, institution_name: str | None = None, institutional_department_name: str | None = None, + content_date: str | datetime.date | None = None, + content_time: str | datetime.time | None = None, + series_date: str | datetime.date | None = None, + series_time: str | datetime.time | None = None, ): """ Parameters @@ -121,6 +125,18 @@ def __init__( institutional_department_name: Union[str, None], optional Name of the department of the person or device that creates the SR document instance. + content_date: str | datetime.date | None, optional + Date the content of this instance was created. If not specified, + the current date will be used. + content_time: str | datetime.time | None, optional + Time the content of this instance was created. If not specified, + the current time will be used. + series_date: str | datetime.date | None, optional + Date the series was started. This should be the same for all + instances in a series. + series_time: str | datetime.time | None, optional + Time the series was started. This should be the same for all + instances in a series. Note ---- @@ -207,7 +223,11 @@ def __init__( _check_long_string(device_serial_number) self.DeviceSerialNumber = device_serial_number if software_versions is not None: - _check_long_string(software_versions) + if not isinstance(software_versions, str): + for v in software_versions: + _check_long_string(v) + else: + _check_long_string(software_versions) self.SoftwareVersions = software_versions if institution_name is not None: _check_long_string(institution_name) @@ -227,11 +247,59 @@ def __init__( ) self.InstanceNumber = instance_number + now = datetime.datetime.now() + self.InstanceCreationDate = DA(now.date()) + self.InstanceCreationTime = TM(now.time()) + # Content Date and Content Time are not present in all IODs if is_attribute_in_iod('ContentDate', sop_class_uid): - self.ContentDate = DA(datetime.datetime.now().date()) + if content_date is None: + if content_time is not None: + raise TypeError( + "'content_time' may not be specified without " + "'content_date'." + ) + content_date = now.date() + content_date = DA(content_date) + self.ContentDate = content_date + elif content_date is not None: + raise TypeError( + f"'content_date' should not be specified for SOP Class UID: " + f"{sop_class_uid}" + ) if is_attribute_in_iod('ContentTime', sop_class_uid): - self.ContentTime = TM(datetime.datetime.now().time()) + if content_time is None: + content_time = now.time() + content_time = TM(content_time) + self.ContentTime = content_time + elif content_time is not None: + raise TypeError( + f"'content_time' should not be specified for SOP Class UID: " + f"{sop_class_uid}" + ) + + if series_date is not None: + series_date = DA(series_date) + if content_date is not None: + if series_date > content_date: + raise ValueError( + "'series_date' must not be later than 'content_date'." + ) + self.SeriesDate = series_date + if series_time is not None: + series_time = TM(series_time) + if series_date is None: + raise TypeError( + "'series_time' may not be specified without " + "'series_date'." + ) + if content_time is not None: + if series_time > content_time: + raise ValueError( + "'series_time' must not be later than content time." + ) + self.SeriesTime = series_time + if content_qualification is not None: content_qualification = ContentQualificationValues( content_qualification diff --git a/src/highdicom/color.py b/src/highdicom/color.py index 4666820e..364b5d3c 100644 --- a/src/highdicom/color.py +++ b/src/highdicom/color.py @@ -1,8 +1,10 @@ +"""Utiliies for working with colors.""" import logging from io import BytesIO +from collections.abc import Sequence import numpy as np -from PIL import Image, ImageCms +from PIL import Image, ImageCms, ImageColor from PIL.ImageCms import ( applyTransform, getProfileDescription, @@ -11,14 +13,318 @@ ImageCmsTransform, isIntentSupported, ) +from typing_extensions import Self logger = logging.getLogger(__name__) +def _rgb_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]: + """Convert an RGB color to CIE XYZ representation. + + Outputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). + As a private function, no checks are performed that input values are valid, + and output values are not clipped. + + Parameters + ---------- + r: float + Red component between 0 and 255 (inclusive). + g: float + Green component between 0 and 255 (inclusive). + b: float + Blue component between 0 and 255 (inclusive). + + Returns + ------- + x: float + X component as a float between 0.0 and 95.05. + y: float + Y component as a float between 0.0 and 100.0. + z: float + Z component as a float between 0.0 and 108.89. + + """ + # Adapted from ColorUtilities module of pixelmed: + # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html + def convert_component(c: float) -> float: + c = c / 255.0 + if c > 0.04045: + return ((c + 0.055) / 1.055) ** 2.4 + return c / 12.92 + + r = convert_component(r) * 100 + g = convert_component(g) * 100 + b = convert_component(b) * 100 + + x = r * 0.4124 + g * 0.3576 + b * 0.1805 + y = r * 0.2126 + g * 0.7152 + b * 0.0722 + z = r * 0.0193 + g * 0.1192 + b * 0.9505 + + return x, y, z + + +def _xyz_to_rgb(x: float, y: float, z: float) -> tuple[float, float, float]: + """Convert a CIE XYZ color to RGB representation. + + Inputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). + As a private function, no checks are performed that input values are valid, + and output values are not clipped. + + Parameters + ---------- + x: float + X component as a float between 0.0 and 95.05. + y: float + Y component as a float between 0.0 and 100.0. + z: float + Z component as a float between 0.0 and 108.89. + + Returns + ------- + r: float + Red component between 0.0 and 255.0 (inclusive). + g: float + Green component between 0.0 and 255.0 (inclusive). + b: float + Blue component between 0.0 and 255.0 (inclusive). + + """ + # Adapted from ColorUtilities module of pixelmed: + # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html + x = x / 100 + y = y / 100 + z = z / 100 + + r = x * 3.2406 + y * -1.5372 + z * -0.4986 + g = x * -0.9689 + y * 1.8758 + z * 0.0415 + b = x * 0.0557 + y * -0.2040 + z * 1.0570 + + def convert_component(c: float) -> float: + if c > 0.0031308: + return 1.055 * (c ** (1 / 2.4)) - 0.055 + return 12.92 * c + + r = convert_component(r) * 255 + g = convert_component(g) * 255 + b = convert_component(b) * 255 + + return r, g, b + + +def _xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]: + """Convert a CIE XYZ color to CIE Lab representation. + + As a private function, no checks are performed that input values are valid, + and output values are not clipped. + + Parameters + ---------- + x: float + X component. + y: float + Y component. + z: float + Z component. + + Returns + ------- + l_star: float + Lightness value in the range 0.0 (black) to 100.0 (white). + a_star: float + Red-green value from -128.0 (red) to 127.0 (green). + b_star: float + Blue-yellow value from -128.0 (blue) to 127.0 (yellow). + + """ + # Adapted from ColorUtilities module of pixelmed: + # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html + x = x / 95.047 + y = y / 100.0 + z = z / 108.883 + + def convert_component(c: float) -> float: + if c >= 8.85645167903563082e-3: + return c ** (1.0 / 3) + return (841.0 / 108.0) * c + (4.0 / 29.0) + + x = convert_component(x) + y = convert_component(y) + z = convert_component(z) + + lightness = 116 * y - 16 + a = 500 * (x - y) + b = 200 * (y - z) + + return lightness, a, b + + +def _lab_to_xyz( + l_star: float, + a_star: float, + b_star: float, +) -> tuple[float, float, float]: + """Convert a CIE Lab color to CIE XYZ representation. + + Outputs are scaled between 0.0 and the white point (95.05, 100.0, 108.89). + As a private function, no checks are performed that input values are valid, + and output values are not clipped. + + Parameters + ---------- + l_star: float + Lightness value in the range 0.0 (black) to 100.0 (white). + a_star: float + Red-green value from -128.0 (red) to 127.0 (green). + b_star: float + Blue-yellow value from -128.0 (blue) to 127.0 (yellow). + + Returns + ------- + x: float + X component. + y: float + Y component. + z: float + Z component. + + """ + # Adapted from ColorUtilities module of pixelmed: + # https://www.dclunie.com/pixelmed/software/javadoc/com/pixelmed/utils/ColorUtilities.html + y = (l_star + 16) / 116 + x = a_star / 500 + y + z = y - b_star / 200 + + def convert_component(c: float) -> float: + c3 = c ** 3 + + if c3 > 0.008856: + return c3 + return (c - 16.0 / 116) / 7.787 + + x = convert_component(x) + y = convert_component(y) + z = convert_component(z) + + x = 95.047 * x + y = 100.0 * y + z = 108.883 * z + + return x, y, z + + +def _rgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]: + """Convert an RGB color to CIE Lab representation. + + As a private function, no checks are performed that input values are valid, + and output values are not clipped. + + Parameters + ---------- + r: float + Red component between 0 and 255 (inclusive). + g: float + Green component between 0 and 255 (inclusive). + b: float + Blue component between 0 and 255 (inclusive). + + Returns + ------- + l_star: float + Lightness value in the range 0.0 (black) to 100.0 (white). + a_star: float + Red-green value from -128.0 (red) to 127.0 (green). + b_star: float + Blue-yellow value from -128.0 (blue) to 127.0 (yellow). + + """ + return _xyz_to_lab(*_rgb_to_xyz(r, g, b)) + + +def _lab_to_rgb( + l_star: float, + a_star: float, + b_star: float, +) -> tuple[float, float, float]: + """Convert a CIE Lab color to RGB representation. + + As a private function, no checks are performed that input values are valid, + and output values are not clipped. Lab colors that cannot be represented in + RGB will have values outside to 0.0 to 255.0 range. + + Parameters + ---------- + l_star: float + Lightness value in the range 0.0 (black) to 100.0 (white). + a_star: float + Red-green value from -128.0 (red) to 127.0 (green). + b_star: float + Blue-yellow value from -128.0 (blue) to 127.0 (yellow). + + Returns + ------- + r: float + Red component. + g: float + Green component. + b: float + Blue component. + + """ + return _xyz_to_rgb(*_lab_to_xyz(l_star, a_star, b_star)) + + class CIELabColor: - """Class to represent a color value in CIELab color space.""" + """Class to represent a color value in CIELab color space. + + Various places in DICOM use the CIE-Lab color space to represent colors. + This class is used to pass these colors around and convert them to and from + RGB representation. + + Examples + -------- + + Construct a CIE-Lab color directly and convert it to RGB: + + >>> import highdicom as hd + >>> + >>> color = hd.color.CIELabColor(50.0, 34.0, 12.4) + >>> print(color.to_rgb()) + (177, 95, 99) + + Construct a CIE-Lab color from an RGB color and examine the Lab components: + + >>> import highdicom as hd + >>> + >>> color = hd.color.CIELabColor.from_rgb(0, 255, 0) + >>> print(color.l_star, color.a_star, color.b_star) + 87.73632410162509 -86.1828793774319 83.1828793774319 + + Construct a CIE-Lab color from the name of a well-known color: + + >>> import highdicom as hd + >>> + >>> color = hd.color.CIELabColor.from_string('turquoise') + >>> print(color.l_star, color.a_star, color.b_star) + 81.2664988174258 -44.07782101167315 -4.035019455252922 + + Within DICOM files, the three components are represented using scaled and + shifted unsigned 16 bit integer values. You can move between these + representations like this: + + >>> import highdicom as hd + >>> + >>> color = hd.color.CIELabColor.from_string('orange') + >>> # Print the values that would actually be stored in a DICOM file + >>> print(color.value) + (49107, 39048, 53188) + >>> # Create a color directly from these values + >>> color2 = hd.color.CIELabColor.from_dicom_value((49107, 39048, 53188)) + >>>> print(color2.to_rgb()) + (255, 165, 0) + + """ def __init__( self, @@ -53,18 +359,201 @@ def __init__( 'Value for "b_star" must lie between -128.0 (blue) and 127.0' ' (yellow).' ) - l_val = int(l_star * 0xFFFF / 100.0) - a_val = int((a_star + 128.0) * 0xFFFF / 255.0) - b_val = int((b_star + 128.0) * 0xFFFF / 255.0) + l_val = round(l_star * 0xFFFF / 100.0) + a_val = round((a_star + 128.0) * 0xFFFF / 255.0) + b_val = round((b_star + 128.0) * 0xFFFF / 255.0) self._value = (l_val, a_val, b_val) @property def value(self) -> tuple[int, int, int]: - """Tuple[int]: - Value formatted as a triplet of 16 bit unsigned integers. + """ + + Tuple[int]: + Value formatted as a triplet of 16 bit unsigned integers (as stored + within DICOM). This consists of a triplet of 16-bit unsigned + integers for the L*, a*, and b* components in that order. The L* + component is linearly scaled from the typical range of 0 to 100.0 + to the 16 bit integer range (0 to 65535, or ``0xFFFF``) and rounded + to the nearest integer. The a* and b* components are mapped from + their typical range (-128.0 to 127.0) by shifting to an unsigned + integer range by adding 128.0, then linearly scaling this to the 16 + bit integer range and rounding to the nearest integer. Thus, -128.0 + is represented as 0 (``0x0000``), 0.0 as 32896 (``0x8080``), and + 127.0 as 65535 (``0xFFFF``). + """ return self._value + @property + def l_star(self) -> float: + """float: L* component as value between 0 and 100.0.""" + return self._value[0] * (100.0 / 0xFFFF) + + @property + def a_star(self) -> float: + """float: a* component as value between -128.0 and 127.0.""" + return self._value[1] * (255.0 / 0xFFFF) - 128.0 + + @property + def b_star(self) -> float: + """float: b* component as value between -128.0 and 127.0.""" + return self._value[2] * (255.0 / 0xFFFF) - 128.0 + + @property + def lab(self) -> tuple[float, float, float]: + """ + + float: + L* component as value between 0 and 100.0. + float: + a* component as value between -128.0 and 127.0. + float: + b* component as value between -128.0 and 127.0. + + """ + return (self.l_star, self.a_star, self.b_star) + + @classmethod + def from_dicom_value(cls, value: Sequence[int]) -> Self: + """Create a color from the DICOM integer representation. + + Parameters + ---------- + value: Sequence[int] + The DICOM representation of a CIELab color. This consists of a + triplet of 16-bit unsigned integers for the L*, a*, and b* + components in that order. The L* component should be linearly + scaled from the typical range of 0 to 100.0 to the 16 bit integer + range (0 to 65535, or ``0xFFFF``) and rounded to the nearest + integer. The a* and b* components should be mapped from their + typical range (-128.0 to 127.0) by shifting to an unsigned integer + range by adding 128.0, then linearly scaling this to the 16 bit + integer range and rounding to the nearest integer. Thus, -128.0 + should be represented as 0 (``0x0000``), 0.0 as 32896 (``0x8080``), + and 127.0 as 65535 (``0xFFFF``). + + Returns + ------- + Self + Color constructed from the supplied DICOM values. + + """ + if len(value) != 3: + raise ValueError("Argument 'value' must have length 3.") + + for v in value: + if not isinstance(v, int): + raise TypeError('Elements must be integers.') + + if v < 0 or v > 0xFFFF: + raise ValueError( + "All values must lie in range 0 to 0xFFFF" + ) + + c = cls.__new__(cls) + c._value = tuple(value) + return c + + @classmethod + def from_rgb(cls, r: float, g: float, b: float) -> Self: + """Create the color from RGB values. + + Parameters + ---------- + r: int | float + Red component value in range 0 to 255 (inclusive). + g: float + Green component value in range 0 to 255 (inclusive). + b: float + Blue component value in range 0 to 255 (inclusive). + + Returns + ------- + Self + Color constructed from the supplied RGB values. + + Note + ---- + + Some valid CIELab colors lie outside the valid RGB range, and therefore + cannot be created with this method. + + """ + for c in [r, g, b]: + if not (0 <= c <= 255): + raise ValueError( + 'Each RGB component must lie in the range 0 to 255.' + ) + + return cls(*_rgb_to_lab(r, g, b)) + + @classmethod + def from_string(cls, color: str) -> Self: + """Construct from a string representing a color. + + Parameters + ---------- + color: str + Should be a string understood by PIL's ``getrgb()`` function (see + `here + `_ + for the documentation of that function or `here + `_ for the + original list of colors). This includes many case-insensitive color + names (e.g. ``"red"``, ``"Crimson"``, or ``"INDIGO"``), hex codes + (e.g. ``"#ff7733"``) or decimal integers in the format of this + example: ``"RGB(255, 255, 0)"``. + + Returns + ------- + Self + Color constructed from the supplied string. + + """ + return cls.from_rgb(*ImageColor.getrgb(color)) + + def to_rgb(self, clip: bool = False) -> tuple[int, int, int]: + """Get an RGB representation of this color. + + Note that the full gamut of representable CIE-Lab colors is a super-set + of those representable with RGB. By default, if the color is not + representable as an RGB color, a ``ValueError`` will be raised. + + Parameters + ---------- + clip: bool, optional + If the color cannot be represented in RGB, clip the values to the + range 0 to 255 to give the closest representable RGB color. If + ``False``, colors that cannot be represented in RGB will raise a + ``ValueError``. + + Returns + ------- + int: + Red component, between 0 and 255 (inclusive). + int: + Green component, between 0 and 255 (inclusive). + int: + Blue component, between 0 and 255 (inclusive). + + """ + r, g, b = _lab_to_rgb(self.l_star, self.a_star, self.b_star) + + def _check_component(c): + if 0 <= round(c) <= 255: + return round(c) + else: + if clip: + return max(min(c, 255), 0) + else: + raise ValueError( + 'This color is not representable in RGB color space. ' + "Use 'clip=True' to clip to the nearest representable " + 'value.' + ) + + return _check_component(r), _check_component(g), _check_component(b) + class ColorManager: diff --git a/src/highdicom/version.py b/src/highdicom/version.py index f96e1c6b..cf7b6d65 100644 --- a/src/highdicom/version.py +++ b/src/highdicom/version.py @@ -1 +1 @@ -__version__ = '0.26.1' +__version__ = '0.27.0' diff --git a/tests/test_ann.py b/tests/test_ann.py index a3ee31c6..b5a5bdd7 100644 --- a/tests/test_ann.py +++ b/tests/test_ann.py @@ -17,6 +17,7 @@ ) from highdicom.ann.sop import MicroscopyBulkSimpleAnnotations, annread from highdicom.content import AlgorithmIdentificationSequence +from highdicom.color import CIELabColor from highdicom.sr.coding import CodedConcept from highdicom.uid import UID @@ -161,6 +162,8 @@ def test_construction(self): ), ] + color = CIELabColor(40.0, 50.0, 6.0) + group = AnnotationGroup( number=number, uid=uid, @@ -174,7 +177,8 @@ def test_construction(self): measurements=measurements, description='annotation', anatomic_regions=[self._anatomic_region], - primary_anatomic_structures=[self._anatomic_structure] + primary_anatomic_structures=[self._anatomic_structure], + display_color=color, ) assert group.graphic_type == graphic_type @@ -232,6 +236,7 @@ def test_construction(self): assert values.size == 0 assert values.dtype == np.float32 assert values.shape == (2, 0) + assert group.RecommendedDisplayCIELabValue == list(color.value) def test_construction_2d(self): number = 1 diff --git a/tests/test_base.py b/tests/test_base.py index 8fe51504..3f400a92 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,3 +1,4 @@ +import datetime import re import pytest import unittest @@ -59,6 +60,92 @@ def test_type_3_attributes(self): ) assert instance.SoftwareVersions is not None assert instance.ManufacturerModelName is not None + assert hasattr(instance, 'ContentDate') + assert hasattr(instance, 'ContentTime') + assert not hasattr(instance, 'SeriesDate') + assert not hasattr(instance, 'SeriesTime') + + def test_content_time_without_date(self): + msg = ( + "'content_time' may not be specified without " + "'content_date'." + ) + with pytest.raises(TypeError, match=msg): + SOPClass( + study_instance_uid=UID(), + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', + instance_number=1, + modality='SR', + manufacturer='highdicom', + manufacturer_model_name='foo-bar', + software_versions='v1.0.0', + transfer_syntax_uid=ExplicitVRLittleEndian, + content_time=datetime.time(12, 34, 56), + ) + + def test_series_datetime(self): + instance = SOPClass( + study_instance_uid=UID(), + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', + instance_number=1, + modality='SR', + manufacturer='highdicom', + manufacturer_model_name='foo-bar', + software_versions='v1.0.0', + transfer_syntax_uid=ExplicitVRLittleEndian, + series_date=datetime.date(2000, 12, 1), + series_time=datetime.time(12, 34, 56), + ) + assert hasattr(instance, 'SeriesDate') + assert hasattr(instance, 'SeriesTime') + + def test_series_date_without_time(self): + msg = ( + "'series_time' may not be specified without " + "'series_date'." + ) + with pytest.raises(TypeError, match=msg): + SOPClass( + study_instance_uid=UID(), + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', + instance_number=1, + modality='SR', + manufacturer='highdicom', + manufacturer_model_name='foo-bar', + software_versions='v1.0.0', + transfer_syntax_uid=ExplicitVRLittleEndian, + series_time=datetime.time(12, 34, 56), + ) + + def test_series_date_after_content(self): + msg = ( + "'series_date' must not be later than 'content_date'." + ) + with pytest.raises(ValueError, match=msg): + SOPClass( + study_instance_uid=UID(), + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', + instance_number=1, + modality='SR', + manufacturer='highdicom', + manufacturer_model_name='foo-bar', + software_versions='v1.0.0', + transfer_syntax_uid=ExplicitVRLittleEndian, + content_date=datetime.date(2000, 12, 1), + series_date=datetime.date(2000, 12, 2), + ) def test_big_endian(self): with pytest.raises(ValueError): diff --git a/tests/test_color.py b/tests/test_color.py index e86dfa72..acff5644 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -23,6 +23,82 @@ def test_cielab(l_in, a_in, b_in, out): assert color.value == out +@pytest.mark.parametrize( + # Examples generated from colormine.org + 'r,g,b,l_out,a_out,b_out', + [ + [0, 0, 0, 0.0, 0.0, 0.0], + [255, 0, 0, 53.23, 80.11, 67.22], + [0, 255, 0, 87.74, -86.18, 83.18], + [0, 0, 255, 32.30, 79.20, -107.86], + [0, 255, 255, 91.11, -48.08, -14.14], + [255, 255, 0, 97.14, -21.56, 94.48], + [255, 0, 255, 60.32, 98.25, -60.84], + [255, 255, 255, 100.0, 0.0, -0.01], + [45, 123, 198, 50.45, 2.59, -45.75], + ] +) +def test_from_rgb(r, g, b, l_out, a_out, b_out): + color = CIELabColor.from_rgb(r, g, b) + + assert abs(color.l_star - l_out) < 0.1 + assert abs(color.a_star - a_out) < 0.1 + assert abs(color.b_star - b_out) < 0.1 + + l_star, a_star, b_star = color.lab + assert abs(l_star - l_out) < 0.1 + assert abs(a_star - a_out) < 0.1 + assert abs(b_star - b_out) < 0.1 + + assert color.to_rgb() == (r, g, b) + + +def test_to_rgb_invalid(): + # A color that cannot be represented with RGB + color = CIELabColor(93.21, 117.12, -100.7) + + with pytest.raises(ValueError): + color.to_rgb() + + # With clip=True, will clip to closest representable value + r, g, b = color.to_rgb(clip=True) + assert r == 255 + assert g == 125 + assert b == 255 + + +@pytest.mark.parametrize( + 'color,r_out,g_out,b_out', + [ + ['black', 0, 0, 0], + ['white', 255, 255, 255], + ['red', 255, 0, 0], + ['green', 0, 128, 0], + ['blue', 0, 0, 255], + ['yellow', 255, 255, 0], + ['orange', 255, 165, 0], + ['DARKORCHID', 153, 50, 204], + ['LawnGreen', 124, 252, 0], + ['#232489', 0x23, 0x24, 0x89], + ['#567832', 0x56, 0x78, 0x32], + ['#a6e83c', 0xa6, 0xe8, 0x3c], + ] +) +def test_from_string(color, r_out, g_out, b_out): + color = CIELabColor.from_string(color) + r, g, b = color.to_rgb() + + assert r == r_out + assert g == g_out + assert b == b_out + + +def test_from_dicom(): + v = (1000, 3456, 4218) + color = CIELabColor.from_dicom_value(v) + assert color.value == v + + @pytest.mark.parametrize( 'l_in,a_in,b_in', [