diff --git a/lib/galaxy/config/sample/datatypes_conf.xml.sample b/lib/galaxy/config/sample/datatypes_conf.xml.sample index 42f728dd1d17..4008d53befd7 100644 --- a/lib/galaxy/config/sample/datatypes_conf.xml.sample +++ b/lib/galaxy/config/sample/datatypes_conf.xml.sample @@ -313,6 +313,7 @@ + @@ -1445,6 +1446,7 @@ + diff --git a/lib/galaxy/datatypes/images.py b/lib/galaxy/datatypes/images.py index 5b3d5832a2cc..515860a66b9d 100644 --- a/lib/galaxy/datatypes/images.py +++ b/lib/galaxy/datatypes/images.py @@ -3,6 +3,7 @@ """ import base64 +import io import json import logging import math @@ -17,6 +18,7 @@ import mrcfile import numpy as np import png +import pydicom import tifffile from typing_extensions import Literal @@ -225,10 +227,12 @@ def set_meta( dataset.metadata.num_unique_values = len(unique_values) +@build_sniff_from_prefix class Tiff(Image): edam_format = "format_3591" file_ext = "tiff" display_behavior = "download" # TIFF files trigger browser downloads + MetadataElement( name="offsets", desc="Offsets File", @@ -239,6 +243,27 @@ class Tiff(Image): optional=True, ) + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + """ + Determine if the file is in TIFF format by checking the file header. + + For a successful check, the first 4 bytes must be the TIFF magic number. See [1] for a list of magic numbers. + + Manual checking of the file header, as opposed to trying to read the file with tifffile, is required due to an + ambiguity with DICOM files. This is because the DICOM standard allows *any content* for the first 128 bytes of + the file, followed by the DICOM prefix (see §7.1 in [2] for details). + + [1] https://gist.github.com/leommoore/f9e57ba2aa4bf197ebc5 + [2] https://dicom.nema.org/medical/dicom/current/output/html/part10.html + """ + return file_prefix.contents_header_bytes[:4] in ( + b"\x4d\x4d\x00\x2a", # TIFF format (Motorola - big endian) + b"\x49\x49\x2a\x00", # TIFF format (Intel - little endian) + ) and ( + len(file_prefix.contents_header_bytes) < 132 # file is too short to be a DICOM + or file_prefix.contents_header_bytes[128:132] != b"DICM" # file does not contain the DICOM prefix + ) + def set_meta( self, dataset: DatasetProtocol, overwrite: bool = True, metadata_tmp_files_dir: Optional[str] = None, **kwd ) -> None: @@ -385,19 +410,14 @@ def _read_segments(page: Union[tifffile.TiffPage, tifffile.TiffFrame]) -> Iterat yield segment - def sniff(self, filename: str) -> bool: - with tifffile.TiffFile(filename): - return True - class OMETiff(Tiff): file_ext = "ome.tiff" - def sniff(self, filename: str) -> bool: - with tifffile.TiffFile(filename) as tif: - if tif.is_ome: - return True - return False + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + buf = io.BytesIO(file_prefix.contents_header_bytes) + with tifffile.TiffFile(buf) as tif: + return tif.is_ome class OMEZarr(data.ZarrDirectory): @@ -519,6 +539,117 @@ def sniff(self, filename: str) -> bool: return fh.read(4) == b"%PDF" +@build_sniff_from_prefix +class Dicom(Image): + """ + DICOM medical imaging format (.dcm) + + >>> from galaxy.datatypes.sniff import get_test_fname + >>> fname = get_test_fname('ct_image.dcm') + >>> Dicom().sniff(fname) + True + """ + + MetadataElement( + name="is_tiled", + desc="Is this a WSI DICOM?", + readonly=True, + visible=True, + optional=True, + ) + + edam_format = "format_3548" + file_ext = "dcm" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + """ + Determine if the file is in DICOM format according to §7.1 in [1]. + + [1] https://dicom.nema.org/medical/dicom/current/output/html/part10.html + """ + return len(file_prefix.contents_header_bytes) >= 132 and file_prefix.contents_header_bytes[128:132] == b"DICM" + + def get_mime(self) -> str: + """ + Returns the mime type of the datatype. + """ + return "application/dicom" + + def set_meta( + self, dataset: DatasetProtocol, overwrite: bool = True, metadata_tmp_files_dir: Optional[str] = None, **kwd + ) -> None: + """ + Populate the metadata of the DICOM file using the pydicom library. + + The following metadata fields are populated, if possible: + - `width` + - `height` + - `channels` + - `dtype` + - `num_unique_values` in some cases + - `is_tiled` + + Currently, `frames` and `depth` are not populated. This is because "frames" in DICOM are a generic entity, + that can be used for different purposes, including slices in 3-D images, frames in temporal sequences, and + tiles of a mosaic or pyramid (WSI DICOM). Distinguishing these cases is not straight-forward (and, as a + consequence, neither is determining the `axes` of the image). This can be implemented in the future. + """ + try: + dcm = pydicom.dcmread(dataset.get_file_name(), stop_before_pixels=True) + except pydicom.errors.InvalidDicomError: + return # Ignore errors if metadata cannot be read + + # Determine the number of channels (0 if no channel info is present) + dataset.metadata.channels = dcm.get("SamplesPerPixel", 0) + + # Determine if the DICOM file is tiled (likely WSI DICOM) + dataset.metadata.is_tiled = hasattr(dcm, "TotalPixelMatrixColumns") and hasattr(dcm, "TotalPixelMatrixRows") + + # Determine the width and height of the dataset. If the DICOM file is not tiled, the width and height + # directly. For tiled DICOM, these values correspond to the size of the tiles. + if dataset.metadata.is_tiled: + dataset.metadata.width = dcm.TotalPixelMatrixColumns + dataset.metadata.height = dcm.TotalPixelMatrixRows + else: + dataset.metadata.width = dcm.get("Columns") + dataset.metadata.height = dcm.get("Rows") + + # Try to infer the `dtype` from metadata + if dcm.BitsAllocated == 1: + dataset.metadata.dtype = "bool" # 1bit + else: + dtype_lut = [ + ["uint8", "int8"], + ["uint16", "int16"], + ["uint32", "int32"], + ] + dtype_lut_pos = ( + round(math.log2(dcm.BitsAllocated) - 3), # 8bit -> 0, 16bit -> 1, 32bit -> 2 + dcm.PixelRepresentation, + ) + if 0 <= dtype_lut_pos[0] < len(dtype_lut): + dataset.metadata.dtype = dtype_lut[dtype_lut_pos[0]][dtype_lut_pos[1]] + else: + dataset.metadata.dtype = None # unknown `dtype` + + # Try to infer `num_unique_values` from metadata + try: + if dcm.SOPClassUID == "1.2.840.10008.5.1.4.1.1.66.4": # https://www.dicomlibrary.com/dicom/sop + + # The DICOM file contains segmentation, count +1 for the image background + dataset.metadata.num_unique_values = 1 + len(dcm.SegmentSequence) + + else: + + # Otherwise, `num_unique_values` is not available from metadata + dataset.metadata.num_unique_values = None + + except AttributeError: + + # Ignore errors if metadata cannot be read + dataset.metadata.num_unique_values = None + + @build_sniff_from_prefix class Tck(Binary): """ diff --git a/lib/galaxy/datatypes/test/ct_image.dcm b/lib/galaxy/datatypes/test/ct_image.dcm new file mode 120000 index 000000000000..cfc43f6acb27 --- /dev/null +++ b/lib/galaxy/datatypes/test/ct_image.dcm @@ -0,0 +1 @@ +../../../../test-data/highdicom/ct_image.dcm \ No newline at end of file diff --git a/lib/galaxy/datatypes/test/seg_image_ct_binary.dcm b/lib/galaxy/datatypes/test/seg_image_ct_binary.dcm new file mode 120000 index 000000000000..5121b39a3e69 --- /dev/null +++ b/lib/galaxy/datatypes/test/seg_image_ct_binary.dcm @@ -0,0 +1 @@ +../../../../test-data/highdicom/seg_image_ct_binary.dcm \ No newline at end of file diff --git a/lib/galaxy/datatypes/test/sm_image.dcm b/lib/galaxy/datatypes/test/sm_image.dcm new file mode 120000 index 000000000000..f5fb19c2f36d --- /dev/null +++ b/lib/galaxy/datatypes/test/sm_image.dcm @@ -0,0 +1 @@ +../../../../test-data/highdicom/sm_image.dcm \ No newline at end of file diff --git a/lib/galaxy/dependencies/pinned-requirements.txt b/lib/galaxy/dependencies/pinned-requirements.txt index d0ce8a15cd03..2d474ab9e063 100644 --- a/lib/galaxy/dependencies/pinned-requirements.txt +++ b/lib/galaxy/dependencies/pinned-requirements.txt @@ -163,6 +163,8 @@ pycryptodome==3.23.0 pydantic==2.12.5 pydantic-core==2.41.5 pydantic-tes==0.2.0 +pydicom==2.4.4 ; python_full_version < '3.10' +pydicom==3.0.1 ; python_full_version >= '3.10' pydot==4.0.1 pyeventsystem==0.1.0 pyfaidx==0.9.0.3 diff --git a/packages/data/setup.cfg b/packages/data/setup.cfg index a0d4d27acc66..c78a2f632867 100644 --- a/packages/data/setup.cfg +++ b/packages/data/setup.cfg @@ -55,6 +55,7 @@ install_requires = parsley pycryptodome pydantic[email]>=2.7.4 + pydicom pylibmagic pypng python-magic diff --git a/pyproject.toml b/pyproject.toml index b67c4eebcf9d..59679959242f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ dependencies = [ "pulsar-galaxy-lib>=0.15.10", "pycryptodome", "pydantic[email]>=2.7.4", # https://github.com/pydantic/pydantic/pull/9639 + "pydicom", "PyJWT", "pykwalify", "pylibmagic", diff --git a/test-data/highdicom/LICENSE b/test-data/highdicom/LICENSE new file mode 100644 index 000000000000..e2420f10fb81 --- /dev/null +++ b/test-data/highdicom/LICENSE @@ -0,0 +1,7 @@ +Copyright 2020 MGH Computational Pathology + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/test-data/highdicom/ct_image.dcm b/test-data/highdicom/ct_image.dcm new file mode 100644 index 000000000000..39516763e1d7 Binary files /dev/null and b/test-data/highdicom/ct_image.dcm differ diff --git a/test-data/highdicom/seg_image_ct_binary.dcm b/test-data/highdicom/seg_image_ct_binary.dcm new file mode 100644 index 000000000000..04a19ae00988 Binary files /dev/null and b/test-data/highdicom/seg_image_ct_binary.dcm differ diff --git a/test-data/highdicom/sm_image.dcm b/test-data/highdicom/sm_image.dcm new file mode 100644 index 000000000000..a17764bb06d4 Binary files /dev/null and b/test-data/highdicom/sm_image.dcm differ diff --git a/test/unit/data/datatypes/test_images.py b/test/unit/data/datatypes/test_images.py index 929c754b363b..3b3e4a1f06a3 100644 --- a/test/unit/data/datatypes/test_images.py +++ b/test/unit/data/datatypes/test_images.py @@ -1,13 +1,14 @@ -from typing import ( - Any, -) +from typing import Any from galaxy.datatypes.images import ( + Dicom, Image, + OMETiff, Pdf, Png, Tiff, ) +from galaxy.datatypes.sniff import get_test_fname from .util import ( get_dataset, MockDatasetDataset, @@ -35,11 +36,18 @@ def test(): # Define test factory -def __create_test(image_cls: type[Image], input_filename: str, metadata_key: str, expected_value: Any): +def __create_test(image_cls: type[Image], input_filename: str, **expected_metadata: Any): @__test(image_cls, input_filename) def test(metadata): - assert getattr(metadata, metadata_key) == expected_value + for metadata_key, expected_value in expected_metadata.items(): + metadata_value = getattr(metadata, metadata_key) + cond = ( + (metadata_value is expected_value) + if expected_value is None or type(expected_value) is bool + else (metadata_value == expected_value) + ) + assert cond, f"expected: {repr(expected_value)}, actual: {repr(metadata_value)}" return test @@ -63,24 +71,24 @@ def __assert_empty_metadata(metadata): # Tests for `Tiff` class -test_tiff_axes_yx = __create_test(Tiff, "im1_uint8.tif", "axes", "YX") -test_tiff_axes_zcyx = __create_test(Tiff, "im6_uint8.tif", "axes", "ZCYX") -test_tiff_dtype_uint8 = __create_test(Tiff, "im6_uint8.tif", "dtype", "uint8") -test_tiff_dtype_uint16 = __create_test(Tiff, "im8_uint16.tif", "dtype", "uint16") -test_tiff_dtype_float64 = __create_test(Tiff, "im4_float.tif", "dtype", "float64") -test_tiff_num_unique_values_2 = __create_test(Tiff, "im3_b.tif", "num_unique_values", 2) -test_tiff_num_unique_values_618 = __create_test(Tiff, "im4_float.tif", "num_unique_values", 618) -test_tiff_width_16 = __create_test(Tiff, "im7_uint8.tif", "width", 16) # axes: ZYX -test_tiff_width_32 = __create_test(Tiff, "im3_b.tif", "width", 32) # axes: YXS -test_tiff_height_8 = __create_test(Tiff, "im7_uint8.tif", "height", 8) # axes: ZYX -test_tiff_height_32 = __create_test(Tiff, "im3_b.tif", "height", 32) # axes: YXS -test_tiff_channels_0 = __create_test(Tiff, "im1_uint8.tif", "channels", 0) -test_tiff_channels_2 = __create_test(Tiff, "im5_uint8.tif", "channels", 2) # axes: CYX -test_tiff_channels_3 = __create_test(Tiff, "im3_b.tif", "channels", 3) # axes: YXS -test_tiff_depth_0 = __create_test(Tiff, "im1_uint8.tif", "depth", 0) # axes: YXS -test_tiff_depth_25 = __create_test(Tiff, "im7_uint8.tif", "depth", 25) # axes: ZYX -test_tiff_frames_0 = __create_test(Tiff, "im1_uint8.tif", "frames", 0) # axes: YXS -test_tiff_frames_5 = __create_test(Tiff, "im8_uint16.tif", "frames", 5) # axes: TYX +test_tiff_axes_yx = __create_test(Tiff, "im1_uint8.tif", axes="YX") +test_tiff_axes_zcyx = __create_test(Tiff, "im6_uint8.tif", axes="ZCYX") +test_tiff_dtype_uint8 = __create_test(Tiff, "im6_uint8.tif", dtype="uint8") +test_tiff_dtype_uint16 = __create_test(Tiff, "im8_uint16.tif", dtype="uint16") +test_tiff_dtype_float64 = __create_test(Tiff, "im4_float.tif", dtype="float64") +test_tiff_num_unique_values_2 = __create_test(Tiff, "im3_b.tif", num_unique_values=2) +test_tiff_num_unique_values_618 = __create_test(Tiff, "im4_float.tif", num_unique_values=618) +test_tiff_width_16 = __create_test(Tiff, "im7_uint8.tif", width=16) # axes: ZYX +test_tiff_width_32 = __create_test(Tiff, "im3_b.tif", width=32) # axes: YXS +test_tiff_height_8 = __create_test(Tiff, "im7_uint8.tif", height=8) # axes: ZYX +test_tiff_height_32 = __create_test(Tiff, "im3_b.tif", height=32) # axes: YXS +test_tiff_channels_0 = __create_test(Tiff, "im1_uint8.tif", channels=0) +test_tiff_channels_2 = __create_test(Tiff, "im5_uint8.tif", channels=2) # axes: CYX +test_tiff_channels_3 = __create_test(Tiff, "im3_b.tif", channels=3) # axes: YXS +test_tiff_depth_0 = __create_test(Tiff, "im1_uint8.tif", depth=0) # axes: YXS +test_tiff_depth_25 = __create_test(Tiff, "im7_uint8.tif", depth=25) # axes: ZYX +test_tiff_frames_0 = __create_test(Tiff, "im1_uint8.tif", frames=0) # axes: YXS +test_tiff_frames_5 = __create_test(Tiff, "im8_uint16.tif", frames=5) # axes: TYX @__test(Tiff, "im_empty.tif") @@ -88,64 +96,144 @@ def test_tiff_empty(metadata): __assert_empty_metadata(metadata) -@__test(Tiff, "1.tiff") -def test_tiff_unsupported_compression(metadata): +test_tiff_unsupported_compression = __create_test( + Tiff, + "1.tiff", # If the compression of a TIFF is unsupported, some fields should still be there - assert metadata.axes == "YX" - assert metadata.dtype == "bool" - assert metadata.width == 1728 - assert metadata.height == 2376 - assert metadata.channels == 0 - assert metadata.depth == 0 - assert metadata.frames == 0 - + axes="YX", + dtype="bool", + width=1728, + height=2376, + channels=0, + depth=0, + frames=0, # The other fields should be missing - assert getattr(metadata, "num_unique_values", None) is None + num_unique_values=None, +) + + +test_tiff_unsupported_multiseries = __create_test( + Tiff, + "im9_multiseries.tif", # TODO: rename to .tiff + axes=["YXS", "YX"], + dtype=["uint8", "uint16"], + num_unique_values=[2, 255], + width=[32, 256], + height=[32, 256], + channels=[3, 0], + depth=[0, 0], + frames=[0, 0], +) + + +def test_tiff_sniff(): + for filename in ( + "im4_float.tif", + "im1_uint8.tif", + "im_empty.tif", + "im7_uint8.tif", + "im5_uint8.tif", + "1.tiff", + "im9_multiseries.tif", + "im8_uint16.tif", + "im6_uint8.tif", + "im3_b.tif", + ): + fname = get_test_fname(filename) + assert not Dicom().sniff(fname), f"filename: {filename}" + assert not Png().sniff(fname), f"filename: {filename}" + assert not OMETiff().sniff(fname), f"filename: {filename}" + assert Tiff().sniff(fname), f"filename: {filename}" -@__test(Tiff, "im9_multiseries.tif") -def test_tiff_multiseries(metadata): - assert metadata.axes == ["YXS", "YX"] - assert metadata.dtype == ["uint8", "uint16"] - assert metadata.num_unique_values == [2, 255] - assert metadata.width == [32, 256] - assert metadata.height == [32, 256] - assert metadata.channels == [3, 0] - assert metadata.depth == [0, 0] - assert metadata.frames == [0, 0] +# Tests for `OMETiff` class + + +def test_ome_tiff_sniff(): + fname = get_test_fname("1.ome.tiff") + assert not Dicom().sniff(fname) + assert not Png().sniff(fname) + assert Tiff().sniff(fname) + assert OMETiff().sniff(fname) # Tests for `Image` class -test_png_axes_yx = __create_test(Image, "im1_uint8.png", "axes", "YX") -test_png_axes_yxc = __create_test(Image, "im3_a.png", "axes", "YXC") -test_png_dtype_uint8 = __create_test(Image, "im1_uint8.png", "dtype", "uint8") -test_png_num_unique_values_1 = __create_test(Image, "im2_a.png", "num_unique_values", None) -test_png_num_unique_values_2 = __create_test(Image, "im2_b.png", "num_unique_values", None) -test_png_width_32 = __create_test(Image, "im2_b.png", "width", 32) -test_png_height_32 = __create_test(Image, "im2_b.png", "height", 32) -test_png_channels_0 = __create_test(Image, "im1_uint8.png", "channels", 0) -test_png_channels_3 = __create_test(Image, "im3_a.png", "channels", 3) -test_png_depth_0 = __create_test(Image, "im1_uint8.png", "depth", 0) -test_png_frames_1 = __create_test(Image, "im1_uint8.png", "frames", 1) +test_png_axes_yx = __create_test(Image, "im1_uint8.png", axes="YX") +test_png_axes_yxc = __create_test(Image, "im3_a.png", axes="YXC") +test_png_dtype_uint8 = __create_test(Image, "im1_uint8.png", dtype="uint8") +test_png_num_unique_values_1 = __create_test(Image, "im2_a.png", num_unique_values=None) +test_png_num_unique_values_2 = __create_test(Image, "im2_b.png", num_unique_values=None) +test_png_width_32 = __create_test(Image, "im2_b.png", width=32) +test_png_height_32 = __create_test(Image, "im2_b.png", height=32) +test_png_channels_0 = __create_test(Image, "im1_uint8.png", channels=0) +test_png_channels_3 = __create_test(Image, "im3_a.png", channels=3) +test_png_depth_0 = __create_test(Image, "im1_uint8.png", depth=0) +test_png_frames_1 = __create_test(Image, "im1_uint8.png", frames=1) # Tests for `Png` class -test_png_axes_yx = __create_test(Png, "im1_uint8.png", "axes", "YX") -test_png_axes_yxc = __create_test(Png, "im3_a.png", "axes", "YXC") -test_png_dtype_uint8 = __create_test(Png, "im1_uint8.png", "dtype", "uint8") -test_png_num_unique_values_1 = __create_test(Png, "im2_a.png", "num_unique_values", 1) -test_png_num_unique_values_2 = __create_test(Png, "im2_b.png", "num_unique_values", 2) -test_png_width_32 = __create_test(Png, "im2_b.png", "width", 32) -test_png_height_32 = __create_test(Png, "im2_b.png", "height", 32) -test_png_channels_0 = __create_test(Png, "im1_uint8.png", "channels", 0) -test_png_channels_3 = __create_test(Png, "im3_a.png", "channels", 3) -test_png_depth_0 = __create_test(Png, "im1_uint8.png", "depth", 0) -test_png_frames_1 = __create_test(Png, "im1_uint8.png", "frames", 1) +test_png_axes_yx = __create_test(Png, "im1_uint8.png", axes="YX") +test_png_axes_yxc = __create_test(Png, "im3_a.png", axes="YXC") +test_png_dtype_uint8 = __create_test(Png, "im1_uint8.png", dtype="uint8") +test_png_num_unique_values_1 = __create_test(Png, "im2_a.png", num_unique_values=1) +test_png_num_unique_values_2 = __create_test(Png, "im2_b.png", num_unique_values=2) +test_png_width_32 = __create_test(Png, "im2_b.png", width=32) +test_png_height_32 = __create_test(Png, "im2_b.png", height=32) +test_png_channels_0 = __create_test(Png, "im1_uint8.png", channels=0) +test_png_channels_3 = __create_test(Png, "im3_a.png", channels=3) +test_png_depth_0 = __create_test(Png, "im1_uint8.png", depth=0) +test_png_frames_1 = __create_test(Png, "im1_uint8.png", frames=1) + + +# Tests for `Dicom` class + +test_2d_singlechannel = __create_test( + Dicom, + "ct_image.dcm", + width=128, + height=128, + channels=1, + dtype="int16", + num_unique_values=None, + is_tiled=False, +) + + +test_tiled_multichannel = __create_test( + Dicom, + "sm_image.dcm", + width=50, + height=50, + channels=3, + dtype="uint8", + num_unique_values=None, + is_tiled=True, +) + + +test_3d_binary = __create_test( + Dicom, + "seg_image_ct_binary.dcm", + width=16, + height=16, + channels=1, + dtype="bool", + num_unique_values=2, + is_tiled=False, +) + + +def test_dicom_sniff(): + fname = get_test_fname("ct_image.dcm") + assert Dicom().sniff(fname) + assert not OMETiff().sniff(fname) + assert not Tiff().sniff(fname) + assert not Png().sniff(fname) -# Test with files that neither Pillow nor tifffile can open +# Test with files that neither Pillow, tifffile, nor pydicom can open @__test(Pdf, "454Score.pdf")