Repository: DIAGNijmegen/rse-panimg (language: python)

Issue:
Error when window center and width are arrays in MHA
eg `WindowCenter = [60, 60]`, same for window width. This is perfectly valid, so should be accepted.

See https://github.com/comic/grand-challenge.org/issues/1376


Discussion / hints:
@chrisvanrun Would you be able to fix this?
Hey James! Quick important, nitpicky questions: Are you sure that this is _valid dicom_? I also am a bit confused about what it would mean? 

For reference: https://dicom.innolitics.com/ciods/ct-image/voi-lut/00281051 and (org source) http://dicom.nema.org/medical/dicom/current/output/pdf/part06.pdf The Dicom standard lists the window-data to be single DS types (Decimal Strings), which are defined here: http://dicom.nema.org/dicom/2013/output/chtml/part05/sect_6.2.html and may only contain:

~~~~~~
"0"-"9", "+", "-", "E", "e", "." of Default Character Repertoire
~~~~~~

So no brackets etc.

I found _some_ hints for what multiple WindowWidths and centers _could_ mean in "C.11.2.1.2.2 General Requirements for Window Center and Window Width" from this page: https://dicom.innolitics.com/ciods/ct-image/voi-lut/00281056. It indicates that multiple window-values will be paired up an represent multiple possible views for a given image.

So my main question for "solidifying" issue this would be:
- Is the actual DICOM string "[60, 60]" or is that just the Python-view of that data?
    - First case would point towards invalid DICOM - how much of that would we want to support of that?
- If this is the case of multiple-DICOM window levels, I assume it is fine to only read the first value? Trying to determine the actual solution here. Or do we need multi-windowlevel support in CIRRUS as well?
Yes - see https://dicom.innolitics.com/ciods/ct-image/voi-lut/00281051, it has a value multiplicity of 1-n. From the spec there

> If multiple values are present, both Attributes shall have the same number of values and shall be considered as pairs. Multiple values indicate that multiple alternative views should be presented.

These are represented as arrays in pydicom, and so added to mha like this. Maybe the bug is from mha writing in the first place, but this is what you get when taking the naive approach so we should expect this from any mha that a user has created outside of panimg. I don't know what the extent of the support in ITK is.

For CIRRUS it's another issue about what we want to support. It's "should be presented", not "must". There are already a lot of mha in the datastore with WindowCenter and WindowWidth in this format, and CIRRUS copes ok with it right now.
IMO: This is valid DICOM and more of a decision about what we want to support in MHA files. ITK doesn't seem to say anything about WindowCenter and WindowWidth representations. I would say we want to support these values being numbers or arrays of equal length in the MHA we ingest.
`has a value multiplicity of 1-n`... ah, yes. That means repeated tags, right? I think that I remember. 

Anyway, easiest way to clone (parts of) the DICOM dictionary is the way I did it for Oncology, I would say, which is to extract it as a parsed (thus normalized) JSON structure. Works really well. Not DICOM-madness left afterwards. Anyway, this is not what this issue is about. Thanks for pointing out the multiplicity! I think that makes it easier for a "DICOM-beginner" to work with this. The DICOM data dictionary is not always easy to traverse so I wanted to make sure that we are all on the same page :)


Fix patch:
diff --git a/panimg/image_builders/metaio_mhd_mha.py b/panimg/image_builders/metaio_mhd_mha.py
index 01da2ef..e88c38e 100644
--- a/panimg/image_builders/metaio_mhd_mha.py
+++ b/panimg/image_builders/metaio_mhd_mha.py
@@ -7,7 +7,7 @@
 from pathlib import Path
 from typing import DefaultDict, Dict, Iterator, List, Mapping, Set, Union
 
-from panimg.exceptions import UnconsumedFilesException
+from panimg.exceptions import UnconsumedFilesException, ValidationError
 from panimg.image_builders.metaio_utils import (
     load_sitk_image,
     parse_mh_header,
@@ -68,9 +68,9 @@ def format_error(message: str) -> str:
     for file in files:
         try:
             parsed_headers = parse_mh_header(file)
-        except ValueError as e:
+        except ValidationError as e:
             file_errors[file].append(
-                format_error(f"Could not parse ITK header. {e}")
+                format_error(f"Could not validate or parse ITK header. {e}")
             )
             continue
 
@@ -97,9 +97,13 @@ def format_error(message: str) -> str:
 
             try:
                 simple_itk_image = load_sitk_image(file.absolute())
+            except ValidationError as e:
+                file_errors[file].append(
+                    format_error(f"Could not validate file. {e}")
+                )
             except RuntimeError:
                 file_errors[file].append(
-                    format_error("SimpleITK cannot open file")
+                    format_error("SimpleITK could not open file.")
                 )
                 continue
 
diff --git a/panimg/image_builders/metaio_utils.py b/panimg/image_builders/metaio_utils.py
index e884012..2b005cf 100644
--- a/panimg/image_builders/metaio_utils.py
+++ b/panimg/image_builders/metaio_utils.py
@@ -4,6 +4,8 @@
 
 import SimpleITK
 
+from panimg.exceptions import ValidationError
+
 METAIO_IMAGE_TYPES = {
     "MET_NONE": None,
     "MET_ASCII_CHAR": None,
@@ -36,10 +38,15 @@
     "MET_OTHER": None,
 }
 
-FLOAT_MATCH_REGEXP = re.compile(r"^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$")
-FLOAT_LIST_MATCH_REGEXP = re.compile(
-    r"^([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)"
-    r"(\s[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)*$"
+
+FLOAT_REGEX = r"[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?"
+FLOAT_MATCH_REGEXP = re.compile(fr"^{FLOAT_REGEX}$")
+FLOAT_LIST_MATCH_REGEXP = re.compile(fr"^({FLOAT_REGEX})(\s{FLOAT_REGEX})*$")
+FLOAT_ARRAY_MATCH_REGEXP = re.compile(
+    fr"^\[({FLOAT_REGEX},\s?)*{FLOAT_REGEX}]$"
+)
+FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX = re.compile(
+    fr"({FLOAT_MATCH_REGEXP.pattern})|({FLOAT_ARRAY_MATCH_REGEXP.pattern})"
 )
 CONTENT_TIMES_LIST_MATCH_REGEXP = re.compile(
     r"^((2[0-3]|[0-1]\d)[0-5]\d[0-5]\d(\.\d\d\d)?)"
@@ -64,14 +71,16 @@
     "SliceThickness": FLOAT_MATCH_REGEXP,
     "Exposures": FLOAT_LIST_MATCH_REGEXP,
     "ContentTimes": CONTENT_TIMES_LIST_MATCH_REGEXP,
-    "WindowCenter": FLOAT_MATCH_REGEXP,
-    "WindowWidth": FLOAT_MATCH_REGEXP,
+    "WindowCenter": FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX,
+    "WindowWidth": FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX,
     "t0": FLOAT_MATCH_REGEXP,
     "t1": FLOAT_MATCH_REGEXP,
 }
 
 HEADERS_MATCHING_NUM_TIMEPOINTS: List[str] = ["Exposures", "ContentTimes"]
 
+HEADERS_MATCHING_WINDOW_SETTINGS: List[str] = ["WindowCenter", "WindowWidth"]
+
 HEADERS_WITH_LISTING: List[str] = [
     "TransformMatrix",
     "Offset",
@@ -115,7 +124,7 @@ def parse_mh_header(file: Path) -> Dict[str, str]:
 
     Raises
     ------
-    ValueError
+    ValidationError
         Raised when the file contains problems making it impossible to
         read.
     """
@@ -129,7 +138,7 @@ def parse_mh_header(file: Path) -> Dict[str, str]:
         while lines:
             read_line_limit -= 1
             if read_line_limit < 0:
-                raise ValueError("Files contains too many header lines")
+                raise ValidationError("Files contains too many header lines")
 
             bin_line = f.readline(10000)
 
@@ -138,12 +147,12 @@ def parse_mh_header(file: Path) -> Dict[str, str]:
                 continue
 
             if len(bin_line) >= 10000:
-                raise ValueError("Line length is too long")
+                raise ValidationError("Line length is too long")
 
             try:
                 line = bin_line.decode("utf-8")
             except UnicodeDecodeError as e:
-                raise ValueError("Header contains invalid UTF-8") from e
+                raise ValidationError("Header contains invalid UTF-8") from e
             else:
                 result.update(extract_key_value_pairs(line))
 
@@ -189,23 +198,47 @@ def validate_and_clean_additional_mh_headers(
     for key, value in headers.items():
         if key in EXPECTED_HEADERS:
             cleaned_headers[key] = value
-        else:
-            if key in ADDITIONAL_HEADERS:
-                match_pattern = ADDITIONAL_HEADERS[key]
-                if not re.match(match_pattern, value):
-                    raise ValueError(
-                        f"Invalid data type found for "
-                        f"additional header key: {key}"
-                    )
-                cleaned_headers[key] = value
-        if key in HEADERS_MATCHING_NUM_TIMEPOINTS:
-            validate_list_data_matches_num_timepoints(
-                headers=headers, key=key, value=value
-            )
+        elif key in ADDITIONAL_HEADERS:
+            match_pattern = ADDITIONAL_HEADERS[key]
+            if not re.match(match_pattern, value):
+                raise ValidationError(
+                    f"Invalid data type found for "
+                    f"additional header key: {key}"
+                )
+            if key in HEADERS_MATCHING_NUM_TIMEPOINTS:
+                validate_list_data_matches_num_timepoints(
+                    headers=headers, key=key, value=value
+                )
+            if key in HEADERS_MATCHING_WINDOW_SETTINGS:
+                validate_center_matches_width_setting(
+                    headers=headers, key=key, value=value
+                )
+            cleaned_headers[key] = value
 
     return cleaned_headers
 
 
+def validate_center_matches_width_setting(
+    headers: Dict[str, str], key: str, value: str
+):
+    window_keys = ("WindowWidth", "WindowCenter")
+    if key not in window_keys:
+        return
+    if not re.match(FLOAT_ARRAY_MATCH_REGEXP, value):
+        return
+
+    counter_key = window_keys[0] if key == window_keys[1] else window_keys[1]
+    if not re.match(FLOAT_ARRAY_MATCH_REGEXP, headers[counter_key]):
+        raise ValidationError(
+            f"Header '{key}' is of a different format than '{counter_key}'"
+        )
+    if len(value.split(",")) != len(headers[counter_key].split(",")):
+        raise ValidationError(
+            f"Headers '{key}' and '{counter_key}' should "
+            f"contain an equal number of values"
+        )
+
+
 def validate_list_data_matches_num_timepoints(
     headers: Dict[str, str], key: str, value: str
 ):
@@ -216,7 +249,7 @@ def validate_list_data_matches_num_timepoints(
         else 1
     )
     if num_timepoints != expected_timepoints:
-        raise ValueError(
+        raise ValidationError(
             f"Found {num_timepoints} values for {key}, "
             f"but expected {expected_timepoints} (1/timepoint)"
         )
diff --git a/panimg/models.py b/panimg/models.py
index eaf2b57..eebd242 100644
--- a/panimg/models.py
+++ b/panimg/models.py
@@ -115,17 +115,28 @@ def depth(self) -> Optional[int]:
 
         return depth or None
 
+    @staticmethod
+    def _extract_first_float(value: str,) -> float:
+        if value.startswith("["):
+            return float(value[1:-1].split(",")[0])
+        else:
+            return float(value)
+
     @property
     def window_center(self) -> Optional[float]:
         try:
-            return float(self.image.GetMetaData("WindowCenter"))
+            return self._extract_first_float(
+                self.image.GetMetaData("WindowCenter")
+            )
         except (RuntimeError, ValueError):
             return None
 
     @property
     def window_width(self) -> Optional[float]:
         try:
-            return float(self.image.GetMetaData("WindowWidth"))
+            return self._extract_first_float(
+                self.image.GetMetaData("WindowWidth")
+            )
         except (RuntimeError, ValueError):
             return None
 


Test patch:
diff --git a/tests/resources/dicom_window_level/1.dcm b/tests/resources/dicom_window_level/1.dcm
new file mode 100644
index 0000000..e1ae1c9
Binary files /dev/null and b/tests/resources/dicom_window_level/1.dcm differ
diff --git a/tests/resources/dicom_window_level/2.dcm b/tests/resources/dicom_window_level/2.dcm
new file mode 100644
index 0000000..a1b5ca3
Binary files /dev/null and b/tests/resources/dicom_window_level/2.dcm differ
diff --git a/tests/resources/mhd_window/errors/image3x4-center-gt-width.mhd b/tests/resources/mhd_window/errors/image3x4-center-gt-width.mhd
new file mode 100644
index 0000000..d5c8d9f
--- /dev/null
+++ b/tests/resources/mhd_window/errors/image3x4-center-gt-width.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = [10.5]
+WindowWidth = [100.5, 200.5]
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/errors/image3x4-len-0.mhd b/tests/resources/mhd_window/errors/image3x4-len-0.mhd
new file mode 100644
index 0000000..65653eb
--- /dev/null
+++ b/tests/resources/mhd_window/errors/image3x4-len-0.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = []
+WindowWidth = []
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/errors/image3x4-only-center-array.mhd b/tests/resources/mhd_window/errors/image3x4-only-center-array.mhd
new file mode 100644
index 0000000..693c236
--- /dev/null
+++ b/tests/resources/mhd_window/errors/image3x4-only-center-array.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = [10.5]
+WindowWidth = 100.5
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/errors/image3x4-only-width-array.mhd b/tests/resources/mhd_window/errors/image3x4-only-width-array.mhd
new file mode 100644
index 0000000..b2f6ca2
--- /dev/null
+++ b/tests/resources/mhd_window/errors/image3x4-only-width-array.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = 20.5
+WindowWidth = [200.5]
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/errors/image3x4-width-gt-center.mhd b/tests/resources/mhd_window/errors/image3x4-width-gt-center.mhd
new file mode 100644
index 0000000..d5c8d9f
--- /dev/null
+++ b/tests/resources/mhd_window/errors/image3x4-width-gt-center.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = [10.5]
+WindowWidth = [100.5, 200.5]
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/image3x4-len-1.mhd b/tests/resources/mhd_window/image3x4-len-1.mhd
new file mode 100644
index 0000000..01b9a87
--- /dev/null
+++ b/tests/resources/mhd_window/image3x4-len-1.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = [20.5]
+WindowWidth = [200.5]
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/image3x4-len-2.mhd b/tests/resources/mhd_window/image3x4-len-2.mhd
new file mode 100644
index 0000000..3eec796
--- /dev/null
+++ b/tests/resources/mhd_window/image3x4-len-2.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = [10.5, 20.5]
+WindowWidth = [100.5, 200.5]
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/image3x4.mhd b/tests/resources/mhd_window/image3x4.mhd
new file mode 100644
index 0000000..4e83f38
--- /dev/null
+++ b/tests/resources/mhd_window/image3x4.mhd
@@ -0,0 +1,17 @@
+ObjectType = Image
+NDims = 2
+BinaryData = True
+BinaryDataByteOrderMSB = False
+CompressedData = True
+CompressedDataSize = 11
+TransformMatrix = 1 0 0 1
+Offset = 0 0
+CenterOfRotation = 0 0
+ElementSpacing = 1 1
+DimSize = 3 4
+AnatomicalOrientation = ??
+ElementNumberOfChannels = 3
+WindowCenter = 20.5
+WindowWidth = 200.5
+ElementType = MET_UCHAR
+ElementDataFile = image3x4.zraw
diff --git a/tests/resources/mhd_window/image3x4.zraw b/tests/resources/mhd_window/image3x4.zraw
new file mode 100644
index 0000000..68cf090
Binary files /dev/null and b/tests/resources/mhd_window/image3x4.zraw differ
diff --git a/tests/test_dicom.py b/tests/test_dicom.py
index c2dbf73..73c6ca0 100644
--- a/tests/test_dicom.py
+++ b/tests/test_dicom.py
@@ -153,12 +153,33 @@ def test_dicom_rescaling(folder, element_type, tmpdir):
     assert headers["ElementType"] == element_type
 
 
-def test_dicom_window_level(tmpdir):
-    files = {
-        Path(d[0]).joinpath(f)
-        for d in os.walk(RESOURCE_PATH / "dicom")
-        for f in d[2]
-    }
+@pytest.mark.parametrize(
+    "files,center,center_ob,width,width_ob",
+    [
+        (
+            {
+                Path(d[0]).joinpath(f)
+                for d in os.walk(RESOURCE_PATH / "dicom")
+                for f in d[2]
+            },
+            "30",
+            30.0,
+            "200",
+            200.0,
+        ),
+        (
+            [
+                RESOURCE_PATH / "dicom_window_level" / "1.dcm",
+                RESOURCE_PATH / "dicom_window_level" / "2.dcm",
+            ],
+            "[10.5, 20.5, 30.5]",
+            10.5,
+            "[1.5, 2.5, 3.5]",
+            1.5,
+        ),
+    ],
+)
+def test_dicom_window_level(tmpdir, files, center, center_ob, width, width_ob):
     result = _build_files(
         builder=image_builder_dicom, files=files, output_directory=tmpdir
     )
@@ -169,10 +190,10 @@ def test_dicom_window_level(tmpdir):
     ][0]
 
     headers = parse_mh_header(mha_file_obj.file)
-    assert headers["WindowCenter"] == "30"
-    assert headers["WindowWidth"] == "200"
+    assert headers["WindowCenter"] == center
+    assert headers["WindowWidth"] == width
 
     assert len(result.new_images) == 1
     image_obj = result.new_images.pop()
-    assert image_obj.window_center == 30.0
-    assert image_obj.window_width == 200.0
+    assert image_obj.window_center == center_ob
+    assert image_obj.window_width == width_ob
diff --git a/tests/test_mhd.py b/tests/test_mhd.py
index a631527..9739583 100644
--- a/tests/test_mhd.py
+++ b/tests/test_mhd.py
@@ -1,17 +1,22 @@
 import shutil
 import zlib
 from pathlib import Path
+from typing import List, Union
 
 import SimpleITK
 import pytest
 
+from panimg.exceptions import ValidationError
 from panimg.image_builders.metaio_utils import (
     ADDITIONAL_HEADERS,
+    FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX,
     load_sitk_image,
     parse_mh_header,
 )
 from tests import RESOURCE_PATH
 
+MHD_WINDOW_DIR = RESOURCE_PATH / "mhd_window"
+
 
 def test_parse_header_valid_4d_mhd():
     headers = parse_mh_header(RESOURCE_PATH / "image10x11x12x13.mhd")
@@ -190,6 +195,44 @@ def test_load_sitk_image_with_additional_meta_data(tmpdir, test_img: str):
     assert "Bogus" not in sitk_image.GetMetaDataKeys()
 
 
+@pytest.mark.parametrize(
+    "test_img, center, width",
+    [
+        ("image3x4.mhd", "20.5", "200.5"),
+        ("image3x4-len-1.mhd", "[20.5]", "[200.5]"),
+        ("image3x4-len-2.mhd", "[10.5, 20.5]", "[100.5, 200.5]"),
+    ],
+)
+def test_load_sitk_image_with_various_window_formats(
+    test_img: str,
+    center: Union[float, List[float]],
+    width: Union[float, List[float]],
+):
+    src = MHD_WINDOW_DIR / test_img
+    sitk_image = load_sitk_image(src)
+
+    assert center == sitk_image.GetMetaData("WindowCenter")
+    assert width == sitk_image.GetMetaData("WindowWidth")
+
+
+@pytest.mark.parametrize(
+    "test_img, error_msg",
+    [
+        ("image3x4-center-gt-width.mhd", "equal number"),
+        ("image3x4-width-gt-center.mhd", "equal number"),
+        ("image3x4-only-width-array.mhd", "different format"),
+        ("image3x4-only-center-array.mhd", "different format"),
+        ("image3x4-len-0.mhd", "Invalid data type found"),
+    ],
+)
+def test_load_sitk_image_with_faulty_window_formats(
+    test_img: str, error_msg: str
+):
+    src = MHD_WINDOW_DIR / "errors" / test_img
+    with pytest.raises(ValidationError, match=error_msg):
+        load_sitk_image(src)
+
+
 @pytest.mark.parametrize("test_img", ["image10x11x12x13.mhd", "image3x4.mhd"])
 @pytest.mark.parametrize(
     ["key", "value"],
@@ -216,12 +259,12 @@ def test_load_sitk_image_with_corrupt_additional_meta_data_fails(
     lines.insert(-1, f"{key} = {value}\n")
     with open(str(dest), "w") as f:
         f.writelines(lines)
-    with pytest.raises(ValueError):
+    with pytest.raises(ValidationError):
         load_sitk_image(dest)
 
 
 def test_fail_on_invalid_utf8():
-    with pytest.raises(ValueError):
+    with pytest.raises(ValidationError):
         parse_mh_header(RESOURCE_PATH / "invalid_utf8.mhd")
 
 
@@ -232,7 +275,7 @@ def test_too_many_headers_file(tmpdir):
         for i in range(1000000):
             f.write(f"key{i} = {i}\n")
 
-    with pytest.raises(ValueError):
+    with pytest.raises(ValidationError):
         parse_mh_header(test_file_path)
 
 
@@ -244,7 +287,7 @@ def test_line_too_long(tmpdir):
         for i in range(1000000):
             f.write(f"{i}")
 
-    with pytest.raises(ValueError):
+    with pytest.raises(ValidationError):
         parse_mh_header(test_file_path)
 
 
@@ -255,3 +298,48 @@ def test_does_not_choke_on_empty_file(tmpdir):
         f.write("\n")
 
     assert parse_mh_header(test_file_path) == {}
+
+
+@pytest.mark.parametrize(
+    "string",
+    [
+        "1",
+        "1.001",
+        "-1e10",
+        "+1e10",
+        "00400",
+        "[1]",
+        "[1.1]",
+        "[1, 1]",
+        "[1.1, 0]",
+        "[0, 1.1]",
+        "[-1e10, +100, 00400]",
+        "[1, 2, 3, 4]",
+        "[1,2,3,4]",
+    ],
+)
+def test_regex_matching_float_arrays(string: str):
+    assert FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX.match(string)
+
+
+@pytest.mark.parametrize(
+    "string",
+    [
+        "",
+        ".",
+        "[]",
+        "1,",
+        "1,  ",
+        "[1  ]",
+        "[  1]",
+        "[1",
+        "1]",
+        "[1,]",
+        "[ 1]",
+        "[ , ]",
+        "[,]",
+        "[1, 2, 3, 4,]",
+    ],
+)
+def test_regex_non_matching_faulty_array_and_floats(string: str):
+    assert not FLOAT_OR_FLOAT_ARRAY_MATCH_REGEX.match(string)