Repository: ImagingDataCommons/highdicom

Issue:
Failure to create GSPS when reference DICOM uses "MultiValue" for WindowCenter, WindowWidth
When I go to create a GSPS using hd.pr.GrayscaleSoftcopyPresentationState() I get an error for certain DICOM where WindowWidth and WindowCenter are not floats in the reference DICOM. 

Here's the error I get:
```
~/anaconda3/lib/python3.8/site-packages/highdicom/pr/content.py in _get_softcopy_voi_lut_transformations(referenced_images)
   1456 
   1457             if has_width:
-> 1458                 by_window[(
   1459                     ref_im.WindowWidth,
   1460                     ref_im.WindowCenter,

TypeError: unhashable type: 'MultiValue'
```

 I fixed it using the following code: 

``` Python
        if isinstance(image_dataset.WindowWidth, pydicom.multival.MultiValue):
            image_dataset.WindowWidth = image_dataset.WindowWidth[0]
        if isinstance(image_dataset.WindowCenter, pydicom.multival.MultiValue):
            image_dataset.WindowCenter = image_dataset.WindowCenter[0]
```

This fix worked for two offending DICOM, but there is one more where I'm still getting the error. To get rid of the error there, I created the GSPS with my own windowing settings (using the argument voi_lut_transformations=[SoftcopyVOILUTObject]). 

Discussion:

Patch:
diff --git a/src/highdicom/content.py b/src/highdicom/content.py
index 844cdee..ddbed2a 100644
--- a/src/highdicom/content.py
+++ b/src/highdicom/content.py
@@ -1867,7 +1867,7 @@ class VOILUTTransformation(Dataset):
                     )
                 for exp in window_explanation:
                     _check_long_string(exp)
-            self.WindowCenterWidthExplanation = window_explanation
+            self.WindowCenterWidthExplanation = list(window_explanation)
         if voi_lut_function is not None:
             if window_center is None:
                 raise TypeError(
diff --git a/src/highdicom/pr/content.py b/src/highdicom/pr/content.py
index b9d000b..48809dd 100644
--- a/src/highdicom/pr/content.py
+++ b/src/highdicom/pr/content.py
@@ -1397,16 +1397,29 @@ def _get_softcopy_voi_lut_transformations(
             for frame_number, frm_grp in enumerate(perframe_grps, 1):
                 if hasattr(frm_grp, 'FrameVOILUTSequence'):
                     voi_seq = frm_grp.FrameVOILUTSequence[0]
+
                     # Create unique ID for this VOI lookup as a tuple
                     # of the contents
+                    width = voi_seq.WindowWidth
+                    center = voi_seq.WindowCenter
+                    exp = getattr(
+                        voi_seq,
+                        'WindowCenterWidthExplanation',
+                        None
+                    )
+
+                    # MultiValues are not hashable so make into tuples
+                    if isinstance(width, MultiValue):
+                        width = tuple(width)
+                    if isinstance(center, MultiValue):
+                        center = tuple(center)
+                    if isinstance(exp, MultiValue):
+                        exp = tuple(exp)
+
                     by_window[(
-                        voi_seq.WindowWidth,
-                        voi_seq.WindowCenter,
-                        getattr(
-                            voi_seq,
-                            'WindowCenterWidthExplanation',
-                            None
-                        ),
+                        width,
+                        center,
+                        exp,
                         getattr(voi_seq, 'VOILUTFunction', None),
                     )].append(frame_number)
 
@@ -1455,10 +1468,20 @@ def _get_softcopy_voi_lut_transformations(
                 )
 
             if has_width:
+                width = ref_im.WindowWidth
+                center = ref_im.WindowCenter
+                exp = getattr(ref_im, 'WindowCenterWidthExplanation', None)
+                # MultiValues are not hashable so make into tuples
+                if isinstance(width, MultiValue):
+                    width = tuple(width)
+                if isinstance(center, MultiValue):
+                    center = tuple(center)
+                if isinstance(exp, MultiValue):
+                    exp = tuple(exp)
                 by_window[(
-                    ref_im.WindowWidth,
-                    ref_im.WindowCenter,
-                    getattr(ref_im, 'WindowCenterWidthExplanation', None),
+                    width,
+                    center,
+                    exp,
                     getattr(ref_im, 'VOILUTFunction', None),
                 )].append(ref_im)
             elif has_lut:


Test patch:
diff --git a/tests/test_pr.py b/tests/test_pr.py
index 8139f1b..b77007d 100644
--- a/tests/test_pr.py
+++ b/tests/test_pr.py
@@ -1348,6 +1348,73 @@ class TestXSoftcopyPresentationState(unittest.TestCase):
         assert gsps.SoftcopyVOILUTSequence[1].WindowWidth == expected_width
         assert gsps.SoftcopyVOILUTSequence[1].WindowCenter == expected_center
 
+    def test_construction_with_copy_voi_lut_multival(self):
+        new_series = []
+        widths = [40.0, 20.0]
+        centers = [400.0, 600.0]
+        for dcm in self._ct_series:
+            new_dcm = deepcopy(dcm)
+            new_dcm.WindowCenter = centers
+            new_dcm.WindowWidth = widths
+            new_series.append(new_dcm)
+        gsps = GrayscaleSoftcopyPresentationState(
+            referenced_images=new_series,
+            series_instance_uid=self._series_uid,
+            series_number=123,
+            sop_instance_uid=self._sop_uid,
+            instance_number=456,
+            manufacturer='Foo Corp.',
+            manufacturer_model_name='Bar, Mark 2',
+            software_versions='0.0.1',
+            device_serial_number='12345',
+            content_label='DOODLE',
+            graphic_layers=[self._layer],
+            graphic_annotations=[self._ann_ct],
+            concept_name=codes.DCM.PresentationState,
+            institution_name='MGH',
+            institutional_department_name='Radiology',
+            content_creator_name='Doe^John'
+        )
+        assert len(gsps.SoftcopyVOILUTSequence) == 1
+        assert gsps.SoftcopyVOILUTSequence[0].WindowWidth == widths
+        assert gsps.SoftcopyVOILUTSequence[0].WindowCenter == centers
+
+    def test_construction_with_copy_voi_lut_multival_with_expl(self):
+        new_series = []
+        widths = [40.0, 20.0]
+        centers = [400.0, 600.0]
+        exps = ['WINDOW1', 'WINDOW2']
+        for dcm in self._ct_series:
+            new_dcm = deepcopy(dcm)
+            new_dcm.WindowCenter = centers
+            new_dcm.WindowWidth = widths
+            new_dcm.WindowCenterWidthExplanation = exps
+            new_series.append(new_dcm)
+        gsps = GrayscaleSoftcopyPresentationState(
+            referenced_images=new_series,
+            series_instance_uid=self._series_uid,
+            series_number=123,
+            sop_instance_uid=self._sop_uid,
+            instance_number=456,
+            manufacturer='Foo Corp.',
+            manufacturer_model_name='Bar, Mark 2',
+            software_versions='0.0.1',
+            device_serial_number='12345',
+            content_label='DOODLE',
+            graphic_layers=[self._layer],
+            graphic_annotations=[self._ann_ct],
+            concept_name=codes.DCM.PresentationState,
+            institution_name='MGH',
+            institutional_department_name='Radiology',
+            content_creator_name='Doe^John'
+        )
+        assert len(gsps.SoftcopyVOILUTSequence) == 1
+        assert gsps.SoftcopyVOILUTSequence[0].WindowWidth == widths
+        assert gsps.SoftcopyVOILUTSequence[0].WindowCenter == centers
+        assert (
+            gsps.SoftcopyVOILUTSequence[0].WindowCenterWidthExplanation == exps
+        )
+
     def test_construction_with_copy_voi_lut_empty(self):
         file_path = Path(__file__)
         data_dir = file_path.parent.parent.joinpath('data')