torch_em.data.datasets.medical.lnq

The LNQ dataset contains annotations for mediastinal lymph node segmentation in thorax CT scans.

The dataset was curated for the LNQ2023 MICCAI challenge (https://lnq2023.grand-challenge.org). It comprises 513 CT scans of cancer patients, each with a DICOM SEG object annotating the clinically relevant mediastinal lymph nodes (larger than 1 cm). The annotations are partial for most cases, i.e. not every lymph node in a scan is annotated. The data is hosted on TCIA as the 'Mediastinal-Lymph-Node-SEG' collection (https://doi.org/10.7937/QVAZ-JA09) under the CC BY 4.0 license.

The DICOM series are converted to hdf5 files with the keys 'raw' (the CT in HU) and 'labels'. The label ids are: 0 = background, 1 = lymph node. Note that each scan is annotated with a single segment that covers all annotated lymph nodes, i.e. the individual nodes are not separated.

The dataset is from the publication https://doi.org/10.59275/j.melba.2025-1gb5. Please cite it if you use this dataset in your research.

NOTE: The DICOM conversion requires 'pydicom'. Install it with 'pip install pydicom'.

  1"""The LNQ dataset contains annotations for mediastinal lymph node segmentation in thorax CT scans.
  2
  3The dataset was curated for the LNQ2023 MICCAI challenge (https://lnq2023.grand-challenge.org).
  4It comprises 513 CT scans of cancer patients, each with a DICOM SEG object annotating the clinically
  5relevant mediastinal lymph nodes (larger than 1 cm). The annotations are partial for most cases,
  6i.e. not every lymph node in a scan is annotated. The data is hosted on TCIA as the
  7'Mediastinal-Lymph-Node-SEG' collection (https://doi.org/10.7937/QVAZ-JA09) under the CC BY 4.0 license.
  8
  9The DICOM series are converted to hdf5 files with the keys 'raw' (the CT in HU) and 'labels'.
 10The label ids are: 0 = background, 1 = lymph node. Note that each scan is annotated with a single
 11segment that covers all annotated lymph nodes, i.e. the individual nodes are not separated.
 12
 13The dataset is from the publication https://doi.org/10.59275/j.melba.2025-1gb5.
 14Please cite it if you use this dataset in your research.
 15
 16NOTE: The DICOM conversion requires 'pydicom'. Install it with 'pip install pydicom'.
 17"""
 18
 19import os
 20from glob import glob
 21from tqdm import tqdm
 22from natsort import natsorted
 23from typing import Union, Tuple, List
 24
 25import numpy as np
 26import pandas as pd
 27
 28from torch.utils.data import Dataset, DataLoader
 29
 30import torch_em
 31
 32from .. import util
 33
 34
 35URL = "https://www.cancerimagingarchive.net/wp-content/uploads/Mediastinal-Lymph-Node-SEG-DA-RAD.tcia"
 36
 37LABEL_IDS = {"background": 0, "lymph_node": 1}
 38
 39
 40def _load_ct_series(series_dir):
 41    import pydicom
 42
 43    slices = [pydicom.dcmread(p) for p in glob(os.path.join(series_dir, "*.dcm"))]
 44    slices = [s for s in slices if hasattr(s, "ImagePositionPatient")]
 45    slices.sort(key=lambda s: float(s.ImagePositionPatient[2]))
 46
 47    z_positions = np.array([float(s.ImagePositionPatient[2]) for s in slices])
 48    volume = np.stack([s.pixel_array.astype(np.float32) for s in slices])  # (Z, Y, X)
 49
 50    slope = float(getattr(slices[0], "RescaleSlope", 1.0))
 51    intercept = float(getattr(slices[0], "RescaleIntercept", 0.0))
 52    volume = (volume * slope + intercept).astype(np.int16)
 53
 54    return volume, z_positions
 55
 56
 57def _load_seg_series(series_dir, shape, z_positions):
 58    import pydicom
 59
 60    seg_path = glob(os.path.join(series_dir, "*.dcm"))[0]
 61    seg = pydicom.dcmread(seg_path)
 62
 63    frames = seg.pixel_array
 64    if frames.ndim == 2:  # A single frame is returned without the frame axis.
 65        frames = frames[None]
 66
 67    labels = np.zeros(shape, dtype=np.uint8)
 68    for frame, frame_info in zip(frames, seg.PerFrameFunctionalGroupsSequence):
 69        frame_z = float(frame_info.PlanePositionSequence[0].ImagePositionPatient[2])
 70
 71        # Match the frame to the CT slice with the closest z-position.
 72        z = int(np.argmin(np.abs(z_positions - frame_z)))
 73        assert abs(z_positions[z] - frame_z) < 1.0, "Could not match the segmentation frame to a CT slice."
 74        assert frame.shape == shape[1:], f"Frame shape {frame.shape} does not match the CT shape {shape[1:]}."
 75
 76        labels[z][frame > 0] = 1
 77
 78    return labels
 79
 80
 81def _preprocess_inputs(path, dicom_dir, csv_path):
 82    import h5py
 83
 84    preprocessed_dir = os.path.join(path, "preprocessed")
 85    os.makedirs(preprocessed_dir, exist_ok=True)
 86
 87    df = pd.read_csv(csv_path)
 88    for case_id, case_df in tqdm(df.groupby("Subject ID"), desc="Convert the LNQ DICOM series to hdf5"):
 89        volume_path = os.path.join(preprocessed_dir, f"{case_id}.h5")
 90        if os.path.exists(volume_path):
 91            continue
 92
 93        ct_uid = case_df[case_df["Modality"] == "CT"]["Series UID"].iloc[0]
 94        seg_uid = case_df[case_df["Modality"] == "SEG"]["Series UID"].iloc[0]
 95
 96        raw, z_positions = _load_ct_series(os.path.join(dicom_dir, ct_uid))
 97        labels = _load_seg_series(os.path.join(dicom_dir, seg_uid), raw.shape, z_positions)
 98
 99        # The file is written to a temporary path first, so that an interrupted run does not leave a corrupt file.
100        with h5py.File(f"{volume_path}.tmp", "w") as f:
101            f.create_dataset("raw", data=raw, compression="gzip")
102            f.create_dataset("labels", data=labels, compression="gzip")
103
104        os.rename(f"{volume_path}.tmp", volume_path)
105
106    return preprocessed_dir
107
108
109def get_lnq_data(path: Union[os.PathLike, str], download: bool = False) -> str:
110    """Download the LNQ dataset.
111
112    Args:
113        path: Filepath to a folder where the data is downloaded for further processing.
114        download: Whether to download the data if it is not present.
115
116    Returns:
117        Filepath where the data is downloaded and preprocessed.
118    """
119    preprocessed_dir = os.path.join(path, "preprocessed")
120    if os.path.exists(preprocessed_dir) and len(glob(os.path.join(preprocessed_dir, "*.h5"))) == 513:
121        return preprocessed_dir
122
123    os.makedirs(path, exist_ok=True)
124
125    dicom_dir = os.path.join(path, "dicom")
126    csv_path = os.path.join(path, "metadata")
127    if not os.path.exists(f"{csv_path}.csv"):
128        util.download_source_tcia(
129            path=os.path.join(path, "Mediastinal-Lymph-Node-SEG-DA-RAD.tcia"),
130            url=URL,
131            dst=dicom_dir,
132            csv_filename=csv_path,
133            download=download,
134        )
135
136    return _preprocess_inputs(path, dicom_dir, f"{csv_path}.csv")
137
138
139def get_lnq_paths(path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
140    """Get paths to the LNQ data.
141
142    Args:
143        path: Filepath to a folder where the data is downloaded for further processing.
144        download: Whether to download the data if it is not present.
145
146    Returns:
147        List of filepaths for the image data.
148        List of filepaths for the label data.
149    """
150    data_dir = get_lnq_data(path, download)
151    volume_paths = natsorted(glob(os.path.join(data_dir, "*.h5")))
152    return volume_paths, volume_paths
153
154
155def get_lnq_dataset(
156    path: Union[os.PathLike, str],
157    patch_shape: Tuple[int, ...],
158    resize_inputs: bool = False,
159    download: bool = False,
160    **kwargs
161) -> Dataset:
162    """Get the LNQ dataset for mediastinal lymph node segmentation.
163
164    Args:
165        path: Filepath to a folder where the data is downloaded for further processing.
166        patch_shape: The patch shape to use for training.
167        resize_inputs: Whether to resize inputs to the desired patch shape.
168        download: Whether to download the data if it is not present.
169        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset`.
170
171    Returns:
172        The segmentation dataset.
173    """
174    raw_paths, label_paths = get_lnq_paths(path, download)
175
176    if resize_inputs:
177        resize_kwargs = {"patch_shape": patch_shape, "is_rgb": False}
178        kwargs, patch_shape = util.update_kwargs_for_resize_trafo(
179            kwargs=kwargs, patch_shape=patch_shape, resize_inputs=resize_inputs, resize_kwargs=resize_kwargs
180        )
181
182    return torch_em.default_segmentation_dataset(
183        raw_paths=raw_paths,
184        raw_key="raw",
185        label_paths=label_paths,
186        label_key="labels",
187        patch_shape=patch_shape,
188        is_seg_dataset=True,
189        **kwargs
190    )
191
192
193def get_lnq_loader(
194    path: Union[os.PathLike, str],
195    batch_size: int,
196    patch_shape: Tuple[int, ...],
197    resize_inputs: bool = False,
198    download: bool = False,
199    **kwargs
200) -> DataLoader:
201    """Get the LNQ dataloader for mediastinal lymph node segmentation.
202
203    Args:
204        path: Filepath to a folder where the data is downloaded for further processing.
205        batch_size: The batch size for training.
206        patch_shape: The patch shape to use for training.
207        resize_inputs: Whether to resize inputs to the desired patch shape.
208        download: Whether to download the data if it is not present.
209        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset` or for the PyTorch DataLoader.
210
211    Returns:
212        The DataLoader.
213    """
214    ds_kwargs, loader_kwargs = util.split_kwargs(torch_em.default_segmentation_dataset, **kwargs)
215    dataset = get_lnq_dataset(path, patch_shape, resize_inputs, download, **ds_kwargs)
216    return torch_em.get_data_loader(dataset, batch_size, **loader_kwargs)
URL = 'https://www.cancerimagingarchive.net/wp-content/uploads/Mediastinal-Lymph-Node-SEG-DA-RAD.tcia'
LABEL_IDS = {'background': 0, 'lymph_node': 1}
def get_lnq_data(path: Union[os.PathLike, str], download: bool = False) -> str:
110def get_lnq_data(path: Union[os.PathLike, str], download: bool = False) -> str:
111    """Download the LNQ dataset.
112
113    Args:
114        path: Filepath to a folder where the data is downloaded for further processing.
115        download: Whether to download the data if it is not present.
116
117    Returns:
118        Filepath where the data is downloaded and preprocessed.
119    """
120    preprocessed_dir = os.path.join(path, "preprocessed")
121    if os.path.exists(preprocessed_dir) and len(glob(os.path.join(preprocessed_dir, "*.h5"))) == 513:
122        return preprocessed_dir
123
124    os.makedirs(path, exist_ok=True)
125
126    dicom_dir = os.path.join(path, "dicom")
127    csv_path = os.path.join(path, "metadata")
128    if not os.path.exists(f"{csv_path}.csv"):
129        util.download_source_tcia(
130            path=os.path.join(path, "Mediastinal-Lymph-Node-SEG-DA-RAD.tcia"),
131            url=URL,
132            dst=dicom_dir,
133            csv_filename=csv_path,
134            download=download,
135        )
136
137    return _preprocess_inputs(path, dicom_dir, f"{csv_path}.csv")

Download the LNQ dataset.

Arguments:
  • path: Filepath to a folder where the data is downloaded for further processing.
  • download: Whether to download the data if it is not present.
Returns:

Filepath where the data is downloaded and preprocessed.

def get_lnq_paths( path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
140def get_lnq_paths(path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
141    """Get paths to the LNQ data.
142
143    Args:
144        path: Filepath to a folder where the data is downloaded for further processing.
145        download: Whether to download the data if it is not present.
146
147    Returns:
148        List of filepaths for the image data.
149        List of filepaths for the label data.
150    """
151    data_dir = get_lnq_data(path, download)
152    volume_paths = natsorted(glob(os.path.join(data_dir, "*.h5")))
153    return volume_paths, volume_paths

Get paths to the LNQ data.

Arguments:
  • path: Filepath to a folder where the data is downloaded for further processing.
  • download: Whether to download the data if it is not present.
Returns:

List of filepaths for the image data. List of filepaths for the label data.

def get_lnq_dataset( path: Union[os.PathLike, str], patch_shape: Tuple[int, ...], resize_inputs: bool = False, download: bool = False, **kwargs) -> torch.utils.data.dataset.Dataset:
156def get_lnq_dataset(
157    path: Union[os.PathLike, str],
158    patch_shape: Tuple[int, ...],
159    resize_inputs: bool = False,
160    download: bool = False,
161    **kwargs
162) -> Dataset:
163    """Get the LNQ dataset for mediastinal lymph node segmentation.
164
165    Args:
166        path: Filepath to a folder where the data is downloaded for further processing.
167        patch_shape: The patch shape to use for training.
168        resize_inputs: Whether to resize inputs to the desired patch shape.
169        download: Whether to download the data if it is not present.
170        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset`.
171
172    Returns:
173        The segmentation dataset.
174    """
175    raw_paths, label_paths = get_lnq_paths(path, download)
176
177    if resize_inputs:
178        resize_kwargs = {"patch_shape": patch_shape, "is_rgb": False}
179        kwargs, patch_shape = util.update_kwargs_for_resize_trafo(
180            kwargs=kwargs, patch_shape=patch_shape, resize_inputs=resize_inputs, resize_kwargs=resize_kwargs
181        )
182
183    return torch_em.default_segmentation_dataset(
184        raw_paths=raw_paths,
185        raw_key="raw",
186        label_paths=label_paths,
187        label_key="labels",
188        patch_shape=patch_shape,
189        is_seg_dataset=True,
190        **kwargs
191    )

Get the LNQ dataset for mediastinal lymph node segmentation.

Arguments:
  • path: Filepath to a folder where the data is downloaded for further processing.
  • patch_shape: The patch shape to use for training.
  • resize_inputs: Whether to resize inputs to the desired patch shape.
  • download: Whether to download the data if it is not present.
  • kwargs: Additional keyword arguments for torch_em.default_segmentation_dataset.
Returns:

The segmentation dataset.

def get_lnq_loader( path: Union[os.PathLike, str], batch_size: int, patch_shape: Tuple[int, ...], resize_inputs: bool = False, download: bool = False, **kwargs) -> torch.utils.data.dataloader.DataLoader:
194def get_lnq_loader(
195    path: Union[os.PathLike, str],
196    batch_size: int,
197    patch_shape: Tuple[int, ...],
198    resize_inputs: bool = False,
199    download: bool = False,
200    **kwargs
201) -> DataLoader:
202    """Get the LNQ dataloader for mediastinal lymph node segmentation.
203
204    Args:
205        path: Filepath to a folder where the data is downloaded for further processing.
206        batch_size: The batch size for training.
207        patch_shape: The patch shape to use for training.
208        resize_inputs: Whether to resize inputs to the desired patch shape.
209        download: Whether to download the data if it is not present.
210        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset` or for the PyTorch DataLoader.
211
212    Returns:
213        The DataLoader.
214    """
215    ds_kwargs, loader_kwargs = util.split_kwargs(torch_em.default_segmentation_dataset, **kwargs)
216    dataset = get_lnq_dataset(path, patch_shape, resize_inputs, download, **ds_kwargs)
217    return torch_em.get_data_loader(dataset, batch_size, **loader_kwargs)

Get the LNQ dataloader for mediastinal lymph node segmentation.

Arguments:
  • path: Filepath to a folder where the data is downloaded for further processing.
  • batch_size: The batch size for training.
  • patch_shape: The patch shape to use for training.
  • resize_inputs: Whether to resize inputs to the desired patch shape.
  • download: Whether to download the data if it is not present.
  • kwargs: Additional keyword arguments for torch_em.default_segmentation_dataset or for the PyTorch DataLoader.
Returns:

The DataLoader.