torch_em.data.datasets.medical.cystods

The CystoDS dataset contains annotations for tumor, ureteral orifice, resection scar and air bubble segmentation in white light cystoscopy images.

The full dataset consists of 8,067 images from 160 patients, labelled with five classes and 22 subclasses for bladder cancer detection. Of these, 768 images have additional pixel-level segmentation annotations (stored as polygon shapes). This module exposes the 763 of these 768 images that are actually present in the 'images' folder of the OSF release (5 filenames are listed with a segmentation in the metadata but are missing from the images folder, see MISSING_IMAGES). The remaining, unsegmented images only have classification labels and are out of scope for segmentation.

The dataset is located at https://osf.io/xvdhy (CC BY 4.0). This dataset is from the publication https://doi.org/10.1038/s41597-026-06887-z. Please cite it if you use this dataset in your research.

  1"""The CystoDS dataset contains annotations for tumor, ureteral orifice, resection scar and air bubble
  2segmentation in white light cystoscopy images.
  3
  4The full dataset consists of 8,067 images from 160 patients, labelled with five classes and
  522 subclasses for bladder cancer detection. Of these, 768 images have additional pixel-level
  6segmentation annotations (stored as polygon shapes). This module exposes the 763 of these 768
  7images that are actually present in the 'images' folder of the OSF release (5 filenames are
  8listed with a segmentation in the metadata but are missing from the images folder, see
  9`MISSING_IMAGES`). The remaining, unsegmented images only have classification labels and are
 10out of scope for segmentation.
 11
 12The dataset is located at https://osf.io/xvdhy (CC BY 4.0).
 13This dataset is from the publication https://doi.org/10.1038/s41597-026-06887-z.
 14Please cite it if you use this dataset in your research.
 15"""
 16
 17import os
 18import csv
 19import json
 20from glob import glob
 21from warnings import warn
 22from tqdm import tqdm
 23from natsort import natsorted
 24from typing import List, Tuple, Union
 25
 26import numpy as np
 27import imageio.v3 as imageio
 28
 29from torch.utils.data import Dataset, DataLoader
 30
 31import torch_em
 32
 33from .. import util
 34
 35
 36OSF_NODE_ID = "xvdhy"
 37CSV_URL = "https://osf.io/download/n8cxz/"
 38CSV_CHECKSUM = "7988766bf2d17e7607ab2fcd9ad58d20a5fa4d7e107838a3b33b779e4e423f13"
 39
 40IMAGES_FOLDER_ID = "6883c4851f60df395a84faee"
 41SEGMENTATIONS_FOLDER_ID = "688413af2a29f9bd26e7cc33"
 42
 43LABEL_MAP = {
 44    "Tumor": 1,
 45    "Flat Tumor": 2,
 46    "Left ureteral orifice": 3,
 47    "Right ureteral orifice": 4,
 48    "Resection scar": 5,
 49    "Air bubble": 6,
 50}
 51
 52# 5 of the 768 filenames listed with 'json' == 1 in 'cystods.csv' are not actually present in the
 53# 'images' folder on OSF. This is a minor inconsistency in the released dataset, so they are excluded here.
 54MISSING_IMAGES = {"0ba96ad9.png", "23a59431.png", "4336849b.png", "9699deab.png", "d412d37a.png"}
 55
 56
 57def _osf_list_all(url):
 58    import time
 59    import requests
 60
 61    items = []
 62    expected_total = None
 63    while url:
 64        for attempt in range(5):
 65            r = requests.get(url)
 66            if r.status_code < 500:
 67                break
 68            time.sleep(2 ** attempt)
 69        r.raise_for_status()
 70        payload = r.json()
 71        items.extend(payload["data"])
 72        if expected_total is None:
 73            expected_total = payload["links"]["meta"]["total"]
 74        url = payload["links"].get("next")
 75    return items, expected_total
 76
 77
 78def _get_osf_folder_download_urls(folder_id, attempts=3):
 79    url = f"https://api.osf.io/v2/nodes/{OSF_NODE_ID}/files/osfstorage/{folder_id}/?page[size]=100"
 80    for attempt in range(attempts):
 81        items, expected_total = _osf_list_all(url)
 82        if len(items) == expected_total:
 83            return {item["attributes"]["name"]: item["links"]["download"] for item in items}
 84        warn(
 85            f"Listing the OSF folder {folder_id} returned {len(items)} items, expected {expected_total}. "
 86            "Retrying." if attempt < attempts - 1 else "Giving up."
 87        )
 88    return {item["attributes"]["name"]: item["links"]["download"] for item in items}
 89
 90
 91def _download_with_retries(path, url, download, attempts=5):
 92    import time
 93
 94    for attempt in range(attempts):
 95        try:
 96            util.download_source(path=path, url=url, download=download)
 97            return
 98        except Exception as e:
 99            if attempt == attempts - 1:
100                raise
101            warn(f"Download of {url} failed ({e}), retrying.")
102            time.sleep(2 ** attempt)
103
104
105def _get_segmented_filenames(csv_path):
106    with open(csv_path, encoding="utf-8-sig") as f:
107        rows = list(csv.DictReader(f))
108    filenames = [row["filename"] for row in rows if row.get("json") == "1"]
109    return natsorted(fname for fname in filenames if fname not in MISSING_IMAGES)
110
111
112def _rasterize_labels(segmentation_dir, filenames, label_dir):
113    if os.path.exists(label_dir) and len(glob(os.path.join(label_dir, "*.tif"))) == len(filenames):
114        return
115
116    os.makedirs(label_dir, exist_ok=True)
117
118    from skimage.draw import polygon as draw_polygon
119
120    for fname in tqdm(filenames, desc="Rasterize the CystoDS annotations"):
121        name = os.path.splitext(fname)[0]
122        label_path = os.path.join(label_dir, f"{name}.tif")
123        if os.path.exists(label_path):
124            continue
125
126        with open(os.path.join(segmentation_dir, f"{name}.json")) as f:
127            annotation = json.load(f)
128
129        image = imageio.imread(os.path.join(os.path.dirname(label_dir), "images", fname))
130        shape = image.shape[:2]
131
132        labels = np.zeros(shape, dtype="uint8")
133        for shape_annotation in annotation["shapes"]:
134            label_id = LABEL_MAP[shape_annotation["label"]]
135            points = np.array(shape_annotation["points"], dtype=float)
136            rows, columns = draw_polygon(points[:, 1], points[:, 0], shape=shape)
137            labels[rows, columns] = label_id
138
139        imageio.imwrite(label_path, labels, compression="zlib")
140
141
142def get_cystods_data(path: Union[os.PathLike, str], download: bool = False) -> str:
143    """Download the CystoDS dataset.
144
145    Args:
146        path: Filepath to a folder where the downloaded data will be saved.
147        download: Whether to download the data if it is not present.
148
149    Returns:
150        The filepath to the folder where the data is stored.
151    """
152    os.makedirs(path, exist_ok=True)
153
154    csv_path = os.path.join(path, "cystods.csv")
155    util.download_source(path=csv_path, url=CSV_URL, download=download, checksum=CSV_CHECKSUM)
156
157    filenames = _get_segmented_filenames(csv_path)
158
159    image_dir = os.path.join(path, "images")
160    segmentation_dir = os.path.join(path, "segmentations")
161    have_images = os.path.exists(image_dir) and len(glob(os.path.join(image_dir, "*.png"))) == len(filenames)
162    have_segmentations = os.path.exists(segmentation_dir) and \
163        len(glob(os.path.join(segmentation_dir, "*.json"))) == len(filenames)
164    if have_images and have_segmentations:
165        return path
166
167    if not download:
168        raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.")
169
170    os.makedirs(image_dir, exist_ok=True)
171    os.makedirs(segmentation_dir, exist_ok=True)
172
173    image_urls = _get_osf_folder_download_urls(IMAGES_FOLDER_ID)
174    segmentation_urls = _get_osf_folder_download_urls(SEGMENTATIONS_FOLDER_ID)
175
176    for fname in tqdm(filenames, desc="Download the CystoDS images and segmentations"):
177        name = os.path.splitext(fname)[0]
178        segmentation_name = f"{name}.json"
179
180        if fname not in image_urls or segmentation_name not in segmentation_urls:
181            warn(f"'{fname}' is listed as segmented in 'cystods.csv' but missing from the OSF folders.")
182            continue
183
184        image_path = os.path.join(image_dir, fname)
185        _download_with_retries(image_path, image_urls[fname], download)
186
187        segmentation_path = os.path.join(segmentation_dir, segmentation_name)
188        _download_with_retries(segmentation_path, segmentation_urls[segmentation_name], download)
189
190    return path
191
192
193def get_cystods_paths(path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
194    """Get paths to the CystoDS data.
195
196    Args:
197        path: Filepath to a folder where the downloaded data will be saved.
198        download: Whether to download the data if it is not present.
199
200    Returns:
201        List of filepaths for the image data.
202        List of filepaths for the label data.
203    """
204    data_dir = get_cystods_data(path, download)
205
206    filenames = _get_segmented_filenames(os.path.join(data_dir, "cystods.csv"))
207    # A few filenames may be missing an image or a segmentation on the OSF side (see 'get_cystods_data'),
208    # so only keep the ones that were actually downloaded.
209    filenames = [
210        fname for fname in filenames
211        if os.path.exists(os.path.join(data_dir, "images", fname))
212        and os.path.exists(os.path.join(data_dir, "segmentations", f"{os.path.splitext(fname)[0]}.json"))
213    ]
214
215    label_dir = os.path.join(data_dir, "labels")
216    _rasterize_labels(os.path.join(data_dir, "segmentations"), filenames, label_dir)
217
218    raw_paths = natsorted(os.path.join(data_dir, "images", fname) for fname in filenames)
219    label_paths = natsorted(glob(os.path.join(label_dir, "*.tif")))
220
221    assert len(raw_paths) == len(label_paths) and len(raw_paths) > 0
222    assert all(
223        os.path.splitext(os.path.basename(raw_path))[0] == os.path.splitext(os.path.basename(label_path))[0]
224        for raw_path, label_path in zip(raw_paths, label_paths)
225    )
226
227    return raw_paths, label_paths
228
229
230def get_cystods_dataset(
231    path: Union[os.PathLike, str],
232    patch_shape: Tuple[int, int],
233    resize_inputs: bool = False,
234    download: bool = False,
235    **kwargs,
236) -> Dataset:
237    """Get the CystoDS dataset for bladder lesion and landmark segmentation.
238
239    Args:
240        path: Filepath to a folder where the downloaded data will be saved.
241        patch_shape: The patch shape to use for training.
242        resize_inputs: Whether to resize the inputs.
243        download: Whether to download the data if it is not present.
244        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset`.
245
246    Returns:
247        The segmentation dataset.
248    """
249    raw_paths, label_paths = get_cystods_paths(path, download)
250
251    if resize_inputs:
252        resize_kwargs = {"patch_shape": patch_shape, "is_rgb": True}
253        kwargs, patch_shape = util.update_kwargs_for_resize_trafo(
254            kwargs=kwargs, patch_shape=patch_shape, resize_inputs=resize_inputs, resize_kwargs=resize_kwargs
255        )
256
257    return torch_em.default_segmentation_dataset(
258        raw_paths=raw_paths,
259        raw_key=None,
260        label_paths=label_paths,
261        label_key=None,
262        patch_shape=patch_shape,
263        is_seg_dataset=False,
264        **kwargs,
265    )
266
267
268def get_cystods_loader(
269    path: Union[os.PathLike, str],
270    batch_size: int,
271    patch_shape: Tuple[int, int],
272    resize_inputs: bool = False,
273    download: bool = False,
274    **kwargs,
275) -> DataLoader:
276    """Get the CystoDS dataloader for bladder lesion and landmark segmentation.
277
278    Args:
279        path: Filepath to a folder where the downloaded data will be saved.
280        batch_size: The batch size for training.
281        patch_shape: The patch shape to use for training.
282        resize_inputs: Whether to resize the inputs.
283        download: Whether to download the data if it is not present.
284        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset` or the PyTorch DataLoader.
285
286    Returns:
287        The DataLoader.
288    """
289    ds_kwargs, loader_kwargs = util.split_kwargs(torch_em.default_segmentation_dataset, **kwargs)
290    dataset = get_cystods_dataset(path, patch_shape, resize_inputs, download, **ds_kwargs)
291    return torch_em.get_data_loader(dataset, batch_size, **loader_kwargs)
OSF_NODE_ID = 'xvdhy'
CSV_URL = 'https://osf.io/download/n8cxz/'
CSV_CHECKSUM = '7988766bf2d17e7607ab2fcd9ad58d20a5fa4d7e107838a3b33b779e4e423f13'
IMAGES_FOLDER_ID = '6883c4851f60df395a84faee'
SEGMENTATIONS_FOLDER_ID = '688413af2a29f9bd26e7cc33'
LABEL_MAP = {'Tumor': 1, 'Flat Tumor': 2, 'Left ureteral orifice': 3, 'Right ureteral orifice': 4, 'Resection scar': 5, 'Air bubble': 6}
MISSING_IMAGES = {'9699deab.png', '23a59431.png', '0ba96ad9.png', '4336849b.png', 'd412d37a.png'}
def get_cystods_data(path: Union[os.PathLike, str], download: bool = False) -> str:
143def get_cystods_data(path: Union[os.PathLike, str], download: bool = False) -> str:
144    """Download the CystoDS dataset.
145
146    Args:
147        path: Filepath to a folder where the downloaded data will be saved.
148        download: Whether to download the data if it is not present.
149
150    Returns:
151        The filepath to the folder where the data is stored.
152    """
153    os.makedirs(path, exist_ok=True)
154
155    csv_path = os.path.join(path, "cystods.csv")
156    util.download_source(path=csv_path, url=CSV_URL, download=download, checksum=CSV_CHECKSUM)
157
158    filenames = _get_segmented_filenames(csv_path)
159
160    image_dir = os.path.join(path, "images")
161    segmentation_dir = os.path.join(path, "segmentations")
162    have_images = os.path.exists(image_dir) and len(glob(os.path.join(image_dir, "*.png"))) == len(filenames)
163    have_segmentations = os.path.exists(segmentation_dir) and \
164        len(glob(os.path.join(segmentation_dir, "*.json"))) == len(filenames)
165    if have_images and have_segmentations:
166        return path
167
168    if not download:
169        raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.")
170
171    os.makedirs(image_dir, exist_ok=True)
172    os.makedirs(segmentation_dir, exist_ok=True)
173
174    image_urls = _get_osf_folder_download_urls(IMAGES_FOLDER_ID)
175    segmentation_urls = _get_osf_folder_download_urls(SEGMENTATIONS_FOLDER_ID)
176
177    for fname in tqdm(filenames, desc="Download the CystoDS images and segmentations"):
178        name = os.path.splitext(fname)[0]
179        segmentation_name = f"{name}.json"
180
181        if fname not in image_urls or segmentation_name not in segmentation_urls:
182            warn(f"'{fname}' is listed as segmented in 'cystods.csv' but missing from the OSF folders.")
183            continue
184
185        image_path = os.path.join(image_dir, fname)
186        _download_with_retries(image_path, image_urls[fname], download)
187
188        segmentation_path = os.path.join(segmentation_dir, segmentation_name)
189        _download_with_retries(segmentation_path, segmentation_urls[segmentation_name], download)
190
191    return path

Download the CystoDS dataset.

Arguments:
  • path: Filepath to a folder where the downloaded data will be saved.
  • download: Whether to download the data if it is not present.
Returns:

The filepath to the folder where the data is stored.

def get_cystods_paths( path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
194def get_cystods_paths(path: Union[os.PathLike, str], download: bool = False) -> Tuple[List[str], List[str]]:
195    """Get paths to the CystoDS data.
196
197    Args:
198        path: Filepath to a folder where the downloaded data will be saved.
199        download: Whether to download the data if it is not present.
200
201    Returns:
202        List of filepaths for the image data.
203        List of filepaths for the label data.
204    """
205    data_dir = get_cystods_data(path, download)
206
207    filenames = _get_segmented_filenames(os.path.join(data_dir, "cystods.csv"))
208    # A few filenames may be missing an image or a segmentation on the OSF side (see 'get_cystods_data'),
209    # so only keep the ones that were actually downloaded.
210    filenames = [
211        fname for fname in filenames
212        if os.path.exists(os.path.join(data_dir, "images", fname))
213        and os.path.exists(os.path.join(data_dir, "segmentations", f"{os.path.splitext(fname)[0]}.json"))
214    ]
215
216    label_dir = os.path.join(data_dir, "labels")
217    _rasterize_labels(os.path.join(data_dir, "segmentations"), filenames, label_dir)
218
219    raw_paths = natsorted(os.path.join(data_dir, "images", fname) for fname in filenames)
220    label_paths = natsorted(glob(os.path.join(label_dir, "*.tif")))
221
222    assert len(raw_paths) == len(label_paths) and len(raw_paths) > 0
223    assert all(
224        os.path.splitext(os.path.basename(raw_path))[0] == os.path.splitext(os.path.basename(label_path))[0]
225        for raw_path, label_path in zip(raw_paths, label_paths)
226    )
227
228    return raw_paths, label_paths

Get paths to the CystoDS data.

Arguments:
  • path: Filepath to a folder where the downloaded data will be saved.
  • 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_cystods_dataset( path: Union[os.PathLike, str], patch_shape: Tuple[int, int], resize_inputs: bool = False, download: bool = False, **kwargs) -> torch.utils.data.dataset.Dataset:
231def get_cystods_dataset(
232    path: Union[os.PathLike, str],
233    patch_shape: Tuple[int, int],
234    resize_inputs: bool = False,
235    download: bool = False,
236    **kwargs,
237) -> Dataset:
238    """Get the CystoDS dataset for bladder lesion and landmark segmentation.
239
240    Args:
241        path: Filepath to a folder where the downloaded data will be saved.
242        patch_shape: The patch shape to use for training.
243        resize_inputs: Whether to resize the inputs.
244        download: Whether to download the data if it is not present.
245        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset`.
246
247    Returns:
248        The segmentation dataset.
249    """
250    raw_paths, label_paths = get_cystods_paths(path, download)
251
252    if resize_inputs:
253        resize_kwargs = {"patch_shape": patch_shape, "is_rgb": True}
254        kwargs, patch_shape = util.update_kwargs_for_resize_trafo(
255            kwargs=kwargs, patch_shape=patch_shape, resize_inputs=resize_inputs, resize_kwargs=resize_kwargs
256        )
257
258    return torch_em.default_segmentation_dataset(
259        raw_paths=raw_paths,
260        raw_key=None,
261        label_paths=label_paths,
262        label_key=None,
263        patch_shape=patch_shape,
264        is_seg_dataset=False,
265        **kwargs,
266    )

Get the CystoDS dataset for bladder lesion and landmark segmentation.

Arguments:
  • path: Filepath to a folder where the downloaded data will be saved.
  • patch_shape: The patch shape to use for training.
  • resize_inputs: Whether to resize the inputs.
  • 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_cystods_loader( path: Union[os.PathLike, str], batch_size: int, patch_shape: Tuple[int, int], resize_inputs: bool = False, download: bool = False, **kwargs) -> torch.utils.data.dataloader.DataLoader:
269def get_cystods_loader(
270    path: Union[os.PathLike, str],
271    batch_size: int,
272    patch_shape: Tuple[int, int],
273    resize_inputs: bool = False,
274    download: bool = False,
275    **kwargs,
276) -> DataLoader:
277    """Get the CystoDS dataloader for bladder lesion and landmark segmentation.
278
279    Args:
280        path: Filepath to a folder where the downloaded data will be saved.
281        batch_size: The batch size for training.
282        patch_shape: The patch shape to use for training.
283        resize_inputs: Whether to resize the inputs.
284        download: Whether to download the data if it is not present.
285        kwargs: Additional keyword arguments for `torch_em.default_segmentation_dataset` or the PyTorch DataLoader.
286
287    Returns:
288        The DataLoader.
289    """
290    ds_kwargs, loader_kwargs = util.split_kwargs(torch_em.default_segmentation_dataset, **kwargs)
291    dataset = get_cystods_dataset(path, patch_shape, resize_inputs, download, **ds_kwargs)
292    return torch_em.get_data_loader(dataset, batch_size, **loader_kwargs)

Get the CystoDS dataloader for bladder lesion and landmark segmentation.

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

The DataLoader.