Merge pull request #21385 from kostrykin/feat/dicom

Add initial support for the DICOM format
This commit is contained in:
Aysam Guerler
2026-01-20 20:12:20 +03:00
committed by GitHub
13 changed files with 311 additions and 76 deletions
@@ -316,6 +316,7 @@
<datatype extension="ome.tiff" type="galaxy.datatypes.images:OMETiff" display_in_upload="true">
<display file="image/avivator.xml"/>
</datatype>
<datatype extension="dcm" type="galaxy.datatypes.images:Dicom" mimetype="application/dicom" subclass="true" display_in_upload="true" description_url="https://formats.kaitai.io/dicom"/>
<datatype extension="vms" type="galaxy.datatypes.images:Hamamatsu" mimetype="image/hamamatsu"/>
<datatype extension="vmu" type="galaxy.datatypes.images:Hamamatsu" subclass="true" display_in_upload="false"/>
<datatype extension="ndpi" type="galaxy.datatypes.images:Hamamatsu" subclass="true" display_in_upload="false"/>
@@ -1453,6 +1454,7 @@
<sniffer type="galaxy.datatypes.images:Png"/>
<sniffer type="galaxy.datatypes.images:OMETiff"/>
<sniffer type="galaxy.datatypes.images:Tiff"/>
<sniffer type="galaxy.datatypes.images:Dicom"/>
<sniffer type="galaxy.datatypes.images:Bmp"/>
<sniffer type="galaxy.datatypes.images:Gif"/>
<sniffer type="galaxy.datatypes.images:Im"/>
+140 -9
View File
@@ -3,6 +3,7 @@ Image classes
"""
import base64
import io
import json
import logging
import math
@@ -18,6 +19,7 @@ from typing import (
import mrcfile
import numpy as np
import png
import pydicom
import tifffile
try:
@@ -225,10 +227,12 @@ class Png(Image):
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 @@ class Tiff(Image):
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 @@ class Pdf(Image):
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):
"""
+1
View File
@@ -0,0 +1 @@
../../../../test-data/highdicom/ct_image.dcm
+1
View File
@@ -0,0 +1 @@
../../../../test-data/highdicom/seg_image_ct_binary.dcm
+1
View File
@@ -0,0 +1 @@
../../../../test-data/highdicom/sm_image.dcm
@@ -215,6 +215,8 @@ pydantic-graph==1.44.0
pydantic-settings==2.12.0
pydantic-tes==0.2.0
pydocket==0.16.6
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
+1
View File
@@ -55,6 +55,7 @@ install_requires =
parsley
pycryptodome
pydantic[email]>=2.7.4
pydicom
pylibmagic
pypng
python-magic
+1
View File
@@ -77,6 +77,7 @@ dependencies = [
"pycryptodome",
"pydantic[email]>=2.7.4", # https://github.com/pydantic/pydantic/pull/9639
"pydantic-ai>=0.1.16", # AI agent framework
"pydicom; python_version >= '3.10'",
"PyJWT",
"pykwalify",
"pylibmagic",
+7
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+155 -67
View File
@@ -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(image_cls: type[Image], input_filename: str):
# 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, "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]
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}"
# 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)
# Test with files that neither Pillow nor tifffile can open
# 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, tifffile, nor pydicom can open
@__test(Pdf, "454Score.pdf")