torch_em.data.datasets.util
1import os 2import hashlib 3import inspect 4import zipfile 5import requests 6from tqdm import tqdm 7from warnings import warn 8from subprocess import run 9from xml.dom import minidom 10from packaging import version 11from shutil import copyfileobj, which 12 13from typing import Optional, Tuple, Literal, List, Dict, Union, Callable 14 15import numpy as np 16from skimage.draw import polygon 17 18import torch 19 20import torch_em 21from torch_em.transform import get_raw_transform 22from torch_em.transform.generic import ResizeLongestSideInputs, Compose 23 24try: 25 import gdown 26except ImportError: 27 gdown = None 28 29try: 30 from tcia_utils import nbia 31except ModuleNotFoundError: 32 nbia = None 33 34try: 35 from cryoet_data_portal import Client, Dataset 36except ImportError: 37 Client, Dataset = None, None 38 39try: 40 import synapseclient 41 import synapseutils 42except ImportError: 43 synapseclient, synapseutils = None, None 44 45 46# fsspec's HTTP filesystem (aiohttp) ignores the http(s)_proxy environment variables unless 'trust_env' is set, 47# so remote data behind a proxy needs these storage options. 48HTTP_STORAGE_OPTIONS = {"client_kwargs": {"trust_env": True}} 49 50 51BIOIMAGEIO_IDS = { 52 "covid_if": "ilastik/covid_if_training_data", 53 "cremi": "ilastik/cremi_training_data", 54 "dsb": "ilastik/stardist_dsb_training_data", 55 "hpa": "", # not on bioimageio yet 56 "isbi2012": "ilastik/isbi2012_neuron_segmentation_challenge", 57 "kasthuri": "", # not on bioimageio yet: 58 "livecell": "ilastik/livecell_dataset", 59 "lucchi": "", # not on bioimageio yet: 60 "mitoem": "ilastik/mitoem_segmentation_challenge", 61 "monuseg": "deepimagej/monuseg_digital_pathology_miccai2018", 62 "ovules": "", # not on bioimageio yet 63 "plantseg_root": "ilastik/plantseg_root", 64 "plantseg_ovules": "ilastik/plantseg_ovules", 65 "platynereis": "ilastik/platynereis_em_training_data", 66 "snemi": "", # not on bioimagegio yet 67 "uro_cell": "", # not on bioimageio yet: https://doi.org/10.1016/j.compbiomed.2020.103693 68 "vnc": "ilastik/vnc", 69} 70"""@private 71""" 72 73 74def get_bioimageio_dataset_id(dataset_name): 75 """@private 76 """ 77 assert dataset_name in BIOIMAGEIO_IDS 78 return BIOIMAGEIO_IDS[dataset_name] 79 80 81def get_checksum(filename: str) -> str: 82 """Get the SHA256 checksum of a file. 83 84 Args: 85 filename: The filepath. 86 87 Returns: 88 The checksum. 89 """ 90 # The file is hashed in chunks, so that datasets with multi-GB archives do not run out of memory. 91 hasher = hashlib.sha256() 92 with open(filename, "rb") as f: 93 for chunk in iter(lambda: f.read(64 * 1024 * 1024), b""): 94 hasher.update(chunk) 95 return hasher.hexdigest() 96 97 98def _check_checksum(path, checksum): 99 if checksum is not None: 100 this_checksum = get_checksum(path) 101 if this_checksum != checksum: 102 raise RuntimeError( 103 "The checksum of the download does not match the expected checksum." 104 f"Expected: {checksum}, got: {this_checksum}" 105 ) 106 print("Download successful and checksums agree.") 107 else: 108 warn("The file was downloaded, but no checksum was provided, so the file may be corrupted.") 109 110 111# this needs to be extended to support download from s3 via boto, 112# if we get a resource that is available via s3 without support for http 113def download_source(path: str, url: str, download: bool, checksum: Optional[str] = None, verify: bool = True) -> None: 114 """Download data via https. 115 116 Args: 117 path: The path for saving the data. 118 url: The url of the data. 119 download: Whether to download the data if it is not saved at `path` yet. 120 checksum: The expected checksum of the data. 121 verify: Whether to verify the https address. 122 """ 123 if os.path.exists(path): 124 return 125 if not download: 126 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 127 128 # The data is downloaded to a temporary path and only moved to `path` once it is complete and verified. 129 # Otherwise an interrupted download would be mistaken for a complete one by the check above. 130 tmp_path = f"{path}.incomplete" 131 with requests.get(url, stream=True, allow_redirects=True, verify=verify) as r: 132 r.raise_for_status() # check for error 133 # Compute checksums on the file content rather than its HTTP transfer encoding. 134 r.raw.decode_content = True 135 file_size = int(r.headers.get("Content-Length", 0)) 136 desc = f"Download {url} to {path}" 137 if file_size == 0: 138 desc += " (unknown file size)" 139 with tqdm.wrapattr(r.raw, "read", total=file_size, desc=desc) as r_raw, open(tmp_path, "wb") as f: 140 copyfileobj(r_raw, f) 141 142 _check_checksum(tmp_path, checksum) 143 os.replace(tmp_path, path) 144 145 146def download_source_gdrive( 147 path: str, 148 url: str, 149 download: bool, 150 checksum: Optional[str] = None, 151 download_type: Literal["zip", "folder"] = "zip", 152 expected_samples: int = 10000, 153 quiet: bool = True, 154) -> None: 155 """Download data from google drive. 156 157 Args: 158 path: The path for saving the data. 159 url: The url of the data. 160 download: Whether to download the data if it is not saved at `path` yet. 161 checksum: The expected checksum of the data. 162 download_type: The download type, either 'zip' or 'folder'. 163 expected_samples: The maximal number of samples in the folder. 164 quiet: Whether to download quietly. 165 """ 166 if os.path.exists(path): 167 return 168 169 if not download: 170 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 171 172 if gdown is None: 173 raise RuntimeError( 174 "Need gdown library to download data from google drive. " 175 "Please install gdown: 'conda install -c conda-forge gdown==4.6.3'." 176 ) 177 178 print("Downloading the files. Might take a few minutes...") 179 180 if download_type == "zip": 181 gdown.download(url, path, quiet=quiet) 182 _check_checksum(path, checksum) 183 elif download_type == "folder": 184 assert version.parse(gdown.__version__) == version.parse("4.6.3"), "Please install 'gdown==4.6.3'." 185 gdown.download_folder.__globals__["MAX_NUMBER_FILES"] = expected_samples 186 gdown.download_folder(url=url, output=path, quiet=quiet, remaining_ok=True) 187 else: 188 raise ValueError("`download_path` argument expects either `zip`/`folder`") 189 190 print("Download completed.") 191 192 193def download_source_empiar(path: str, access_id: str, download: bool) -> str: 194 """Download data from EMPIAR. 195 196 Requires the ascp command from the aspera CLI. 197 198 Args: 199 path: The path for saving the data. 200 access_id: The EMPIAR accession id of the data to download. 201 download: Whether to download the data if it is not saved at `path` yet. 202 203 Returns: 204 The path to the downloaded data. 205 """ 206 download_path = os.path.join(path, access_id) 207 208 if os.path.exists(download_path): 209 return download_path 210 if not download: 211 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 212 213 if which("ascp") is None: 214 raise RuntimeError( 215 "Need aspera-cli to download data from empiar. You can install it via 'conda install -c hcc aspera-cli'." 216 ) 217 218 key_file = os.path.expanduser("~/.aspera/cli/etc/asperaweb_id_dsa.openssh") 219 if not os.path.exists(key_file): 220 conda_root = os.environ["CONDA_PREFIX"] 221 key_file = os.path.join(conda_root, "etc/asperaweb_id_dsa.openssh") 222 223 if not os.path.exists(key_file): 224 raise RuntimeError("Could not find the aspera ssh keyfile") 225 226 cmd = ["ascp", "-QT", "-l", "200M", "-P33001", "-i", key_file, f"emp_ext2@fasp.ebi.ac.uk:/{access_id}", path] 227 run(cmd) 228 229 return download_path 230 231 232def download_source_kaggle(path: str, dataset_name: str, download: bool, competition: bool = False): 233 """Download data from Kaggle. 234 235 Requires the Kaggle API. 236 237 Args: 238 path: The path for saving the data. 239 dataset_name: The name of the dataset to download. 240 download: Whether to download the data if it is not saved at `path` yet. 241 competition: Whether this data is from a competition and requires the kaggle.competition API. 242 """ 243 if not download: 244 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 245 246 try: 247 from kaggle.api.kaggle_api_extended import KaggleApi 248 except ModuleNotFoundError: 249 msg = "Please install the Kaggle API. You can do this using 'pip install kaggle'. " 250 msg += "After you have installed kaggle, you would need an API token. " 251 msg += "Follow the instructions at https://www.kaggle.com/docs/api." 252 raise ModuleNotFoundError(msg) 253 254 api = KaggleApi() 255 api.authenticate() 256 257 if competition: 258 api.competition_download_files(competition=dataset_name, path=path, quiet=False) 259 else: 260 api.dataset_download_files(dataset=dataset_name, path=path, quiet=False) 261 262 263NBIA_API_URL = "https://services.cancerimagingarchive.net/nbia-api/services/v1/" 264 265 266def _download_tcia_series_with_rest(series_uids, dst, csv_filename): 267 """Download DICOM series from TCIA via the NBIA REST API. 268 269 This is the fallback for missing 'tcia_utils'. It mimics the on-disk layout of 'nbia.downloadSeries': 270 each series is extracted to '<dst>/<SeriesInstanceUID>/' and the series metadata are written to 271 '<csv_filename>.csv' (including the 'Series UID', 'Subject ID' and 'Modality' columns). 272 The metadata of each series is cached in '<csv_filename>.partial.json' while the download is running, 273 so that an interrupted download does not have to query the metadata of all series again. 274 See https://wiki.cancerimagingarchive.net/x/fILTB for the API documentation. 275 """ 276 import csv 277 import json 278 import time 279 import tempfile 280 281 def get_with_retries(endpoint, n_retries=5, **kwargs): 282 # The NBIA API occasionally returns server errors, so the requests are retried. 283 for attempt in range(n_retries): 284 response = requests.get(NBIA_API_URL + endpoint, **kwargs) 285 if response.status_code < 500 or attempt == n_retries - 1: 286 response.raise_for_status() 287 return response 288 response.close() 289 time.sleep(10 * (attempt + 1)) 290 291 os.makedirs(dst, exist_ok=True) 292 cache_path = f"{csv_filename}.partial.json" 293 cache = {} 294 if os.path.exists(cache_path): 295 with open(cache_path, "r") as f: 296 cache = json.load(f) 297 298 metadata = [] 299 for i, uid in enumerate(tqdm(series_uids, desc=f"Download {len(series_uids)} series from TCIA to {dst}")): 300 series_dir = os.path.join(dst, uid) 301 if uid in cache and os.path.exists(series_dir): 302 metadata.append(cache[uid]) 303 continue 304 305 response = get_with_retries("getSeriesMetaData", params={"SeriesInstanceUID": uid}) 306 # A small number of series have no indexed metadata and the endpoint returns an empty body for 307 # them, even though the image data itself downloads without issue. A transient proxy or gateway 308 # error can also return a non-JSON body with a 200 status, which is handled the same way. 309 try: 310 rows = response.json() if response.content else [] 311 except ValueError: 312 rows = [] 313 metadata.append(rows[0] if rows else {"Series UID": uid}) 314 cache[uid] = metadata[-1] 315 if i % 50 == 0: 316 with open(cache_path, "w") as f: 317 json.dump(cache, f) 318 319 if os.path.exists(series_dir): # This series has been downloaded already. 320 continue 321 322 # The series is downloaded as a zip archive, which is extracted to a temporary folder 323 # and only moved to the final location once it is complete. 324 try: 325 with tempfile.TemporaryDirectory(dir=dst) as tmp_dir: 326 zip_path = os.path.join(tmp_dir, "series.zip") 327 with get_with_retries("getImage", params={"SeriesInstanceUID": uid}, stream=True) as r: 328 with open(zip_path, "wb") as f: 329 copyfileobj(r.raw, f) 330 tmp_series_dir = os.path.join(tmp_dir, "series") 331 unzip(zip_path, tmp_series_dir) 332 os.rename(tmp_series_dir, series_dir) 333 except FileExistsError: 334 # A resumed download may have already extracted this series: on some network filesystems 335 # the 'os.path.exists' check above can be stale, so this is not caught earlier. 336 pass 337 except requests.exceptions.HTTPError as e: 338 # A small number of series are indexed but no longer resolvable through this endpoint (e.g. a 339 # stale UID), which should not abort downloading the rest of a possibly multi-hour batch. 340 if e.response is not None and e.response.status_code < 500: 341 tqdm.write(f"Skipping {uid}, which failed to download: {e}") 342 else: 343 raise 344 345 # The metadata keys differ between series (e.g. 'Series Date' is only reported for some), so the header 346 # has to be the union of all keys. 347 fieldnames = list(dict.fromkeys(key for row in metadata for key in row)) 348 csv_path = f"{csv_filename}.csv" 349 with open(csv_path, "w", newline="") as f: 350 writer = csv.DictWriter(f, fieldnames=fieldnames) 351 writer.writeheader() 352 writer.writerows(metadata) 353 354 if os.path.exists(cache_path): # The download is complete, so the metadata cache is not needed anymore. 355 os.remove(cache_path) 356 357 358def _download_tcia_manifest_with_rest(manifest_path, dst, csv_filename): 359 """Download all series listed in a TCIA manifest via the NBIA REST API.""" 360 with open(manifest_path, "r") as f: 361 lines = [line.strip() for line in f.readlines()] 362 series_uids = lines[lines.index("ListOfSeriesToDownload=") + 1:] 363 series_uids = [uid for uid in series_uids if uid] 364 _download_tcia_series_with_rest(series_uids, dst, csv_filename) 365 366 367def download_tcia_series(series_uids: List[str], dst: str, csv_filename: str) -> str: 368 """Download individual DICOM series from TCIA by their series instance UIDs. 369 370 Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise. 371 Each series is stored in '<dst>/<SeriesInstanceUID>/', series that exist there already are skipped. 372 373 Args: 374 series_uids: The UIDs of the series to download. 375 dst: The folder for saving the DICOM series. 376 csv_filename: The path for saving the series metadata (without the '.csv' extension). 377 378 Returns: 379 The path to the csv file with the series metadata. 380 """ 381 if nbia is None: 382 _download_tcia_series_with_rest(series_uids, dst, csv_filename) 383 else: 384 nbia.downloadSeries(series_data=series_uids, input_type="list", path=dst, csv_filename=csv_filename) 385 return f"{csv_filename}.csv" 386 387 388def download_source_tcia(path, url, dst, csv_filename, download): 389 """Download data from TCIA. 390 391 Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise. 392 393 Args: 394 path: The path for saving the manifest file. If `url` is None, this must point to an existing manifest, 395 e.g. one that was written by the caller to download only a subset of the series of a collection. 396 url: The URL to the TCIA manifest of the dataset. Set to None to use the manifest at `path`. 397 dst: The folder for saving the DICOM series. Each series is stored in a sub-folder named after its UID. 398 csv_filename: The path for saving the series metadata (without the '.csv' extension). 399 download: Whether to download the data if it is not saved at `path` yet. 400 """ 401 if not download: 402 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 403 404 if url is None: 405 assert os.path.exists(path), f"The manifest {path} does not exist." 406 else: 407 assert url.endswith(".tcia"), f"{url} is not a TCIA Manifest." 408 # Downloads the manifest file from the collection page. 409 manifest = requests.get(url=url) 410 manifest.raise_for_status() 411 with open(path, "wb") as f: 412 f.write(manifest.content) 413 414 # This part extracts the UIDs from the manifests and downloads them. 415 if nbia is None: 416 _download_tcia_manifest_with_rest(path, dst, csv_filename) 417 else: 418 nbia.downloadSeries(series_data=path, input_type="manifest", path=dst, csv_filename=csv_filename) 419 420 421def download_source_synapse(path: str, entity: str, download: bool) -> None: 422 """Download data from synapse. 423 424 Requires the synapseclient python library. 425 426 Args: 427 path: The path for saving the data. 428 entity: The name of the data to download from synapse. 429 download: Whether to download the data if it is not saved at `path` yet. 430 """ 431 if not download: 432 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 433 434 if synapseclient is None: 435 raise RuntimeError( 436 "You must install 'synapseclient' to download files from 'synapse'. " 437 "Remember to create an account and generate an authentication code for your account. " 438 "Please follow the documentation for details on creating the '~/.synapseConfig' file here: " 439 "https://python-docs.synapse.org/tutorials/authentication/." 440 ) 441 442 assert entity.startswith("syn"), "The entity name does not look as expected. It should be something like 'syn123'." 443 444 # Download all files in the folder. 445 syn = synapseclient.Synapse() 446 syn.login() # Since we do not pass any credentials here, it fetches all details from '~/.synapseConfig'. 447 synapseutils.syncFromSynapse(syn=syn, entity=entity, path=path) 448 449 450def update_kwargs(kwargs, key, value, msg=None): 451 """@private 452 """ 453 if key in kwargs: 454 msg = f"{key} will be over-ridden in loader kwargs." if msg is None else msg 455 warn(msg) 456 kwargs[key] = value 457 return kwargs 458 459 460def unzip_tarfile(tar_path: str, dst: str, remove: bool = True) -> None: 461 """Unpack a tar archive. 462 463 Args: 464 tar_path: Path to the tar file. 465 dst: Where to unpack the archive. 466 remove: Whether to remove the tar file after unpacking. 467 """ 468 import tarfile 469 470 if tar_path.endswith(".tar.gz") or tar_path.endswith(".tgz"): 471 access_mode = "r:gz" 472 elif tar_path.endswith(".tar"): 473 access_mode = "r:" 474 else: 475 raise ValueError(f"The provided file isn't a supported archive to unpack. Please check the file: {tar_path}.") 476 477 tar = tarfile.open(tar_path, access_mode) 478 tar.extractall(dst) 479 tar.close() 480 481 if remove: 482 os.remove(tar_path) 483 484 485def unzip_rarfile(rar_path: str, dst: str, remove: bool = True, use_rarfile: bool = True) -> None: 486 """Unpack a rar archive. 487 488 Args: 489 rar_path: Path to the rar file. 490 dst: Where to unpack the archive. 491 remove: Whether to remove the tar file after unpacking. 492 use_rarfile: Whether to use the rarfile library or aspose.zip. 493 """ 494 def _extract_with_rarfile(): 495 import rarfile 496 with rarfile.RarFile(rar_path) as archive: 497 archive.extractall(path=dst) 498 499 def _extract_with_aspose(): 500 import aspose.zip as az 501 with az.rar.RarArchive(rar_path) as archive: 502 archive.extract_to_directory(dst) 503 504 def _extract_with_7z(): 505 if which("7z") is None: 506 raise RuntimeError("The 'p7zip' CLI is not available.") 507 run(["7z", "x", f"-o{dst}", "-y", rar_path], check=True) 508 509 extractors = [ 510 ('rarfile', _extract_with_rarfile), ('aspose.zip', _extract_with_aspose), ('7z', _extract_with_7z), 511 ] if use_rarfile else [('aspose.zip', _extract_with_aspose), ('7z', _extract_with_7z)] 512 513 errors = [] 514 for name, extractor in extractors: 515 try: 516 extractor() 517 break 518 except Exception as err: 519 errors.append((name, err)) 520 if len(errors) < len(extractors): 521 next_name = extractors[len(errors)][0] 522 warn(f"Extraction with '{name}' failed for {rar_path} ({err}). Falling back to '{next_name}'.") 523 else: 524 backends = ', '.join(f"'{name}'" for name, _ in extractors) 525 raise RuntimeError( 526 f"Failed to extract rar archive {rar_path} with {backends}. " 527 "Please ensure one of the supported backends is installed and can read this archive." 528 ) from errors[-1][1] 529 530 if remove: 531 os.remove(rar_path) 532 533 534def unzip(zip_path: str, dst: str, remove: bool = True) -> None: 535 """Unpack a zip archive. 536 537 Args: 538 zip_path: Path to the zip file. 539 dst: Where to unpack the archive. 540 remove: Whether to remove the tar file after unpacking. 541 """ 542 with zipfile.ZipFile(zip_path, "r") as f: 543 f.extractall(dst) 544 if remove: 545 os.remove(zip_path) 546 547 548def unzip_7z(path_7z: str, dst: str, remove: bool = True) -> None: 549 """Unpack a 7z archive. 550 551 Args: 552 path_7z: Path to the 7z file. 553 dst: Where to unpack the archive. 554 remove: Whether to remove the 7z file after unpacking. 555 """ 556 if which("7z") is None: 557 raise RuntimeError("Need the 'p7zip' CLI to extract 7z archives. You can install it via 'conda install -c conda-forge p7zip'.") # noqa 558 559 run(["7z", "x", f"-o{dst}", "-y", path_7z]) 560 561 if remove: 562 os.remove(path_7z) 563 564 565def split_kwargs(function, **kwargs): 566 """@private 567 """ 568 function_parameters = inspect.signature(function).parameters 569 parameter_names = list(function_parameters.keys()) 570 other_kwargs = {k: v for k, v in kwargs.items() if k not in parameter_names} 571 kwargs = {k: v for k, v in kwargs.items() if k in parameter_names} 572 return kwargs, other_kwargs 573 574 575# this adds the default transforms for 'raw_transform' and 'transform' 576# in case these were not specified in the kwargs 577# this is NOT necessary if 'default_segmentation_dataset' is used, only if a dataset class 578# is used directly, e.g. in the LiveCell Loader 579def ensure_transforms(ndim, **kwargs): 580 """@private 581 """ 582 if "raw_transform" not in kwargs: 583 kwargs = update_kwargs(kwargs, "raw_transform", torch_em.transform.get_raw_transform()) 584 if "transform" not in kwargs: 585 kwargs = update_kwargs(kwargs, "transform", torch_em.transform.get_augmentations(ndim=ndim)) 586 return kwargs 587 588 589def add_instance_label_transform( 590 kwargs, add_binary_target, label_dtype=None, binary=False, boundaries=False, offsets=None, binary_is_exclusive=True, 591): 592 """@private 593 """ 594 if binary_is_exclusive: 595 assert sum((offsets is not None, boundaries, binary)) <= 1 596 else: 597 assert sum((offsets is not None, boundaries)) <= 1 598 if offsets is not None: 599 label_transform2 = torch_em.transform.label.AffinityTransform(offsets=offsets, 600 add_binary_target=add_binary_target, 601 add_mask=True) 602 msg = "Offsets are passed, but 'label_transform2' is in the kwargs. It will be over-ridden." 603 kwargs = update_kwargs(kwargs, "label_transform2", label_transform2, msg=msg) 604 label_dtype = torch.float32 605 elif boundaries: 606 label_transform = torch_em.transform.label.BoundaryTransform(add_binary_target=add_binary_target) 607 msg = "Boundaries is set to true, but 'label_transform' is in the kwargs. It will be over-ridden." 608 kwargs = update_kwargs(kwargs, "label_transform", label_transform, msg=msg) 609 label_dtype = torch.float32 610 elif binary: 611 label_transform = torch_em.transform.label.labels_to_binary 612 msg = "Binary is set to true, but 'label_transform' is in the kwargs. It will be over-ridden." 613 kwargs = update_kwargs(kwargs, "label_transform", label_transform, msg=msg) 614 label_dtype = torch.float32 615 return kwargs, label_dtype 616 617 618def update_kwargs_for_resize_trafo(kwargs, patch_shape, resize_inputs, resize_kwargs=None, ensure_rgb=None): 619 """@private 620 """ 621 # Checks for raw_transform and label_transform incoming values. 622 # If yes, it will automatically merge these two transforms to apply them together. 623 if resize_inputs: 624 assert isinstance(resize_kwargs, dict) 625 626 target_shape = resize_kwargs.get("patch_shape") 627 if len(resize_kwargs["patch_shape"]) == 3: 628 # we only need the XY dimensions to reshape the inputs along them. 629 target_shape = target_shape[1:] 630 # we provide the Z dimension value to return the desired number of slices and not the whole volume 631 kwargs["z_ext"] = resize_kwargs["patch_shape"][0] 632 633 raw_trafo = ResizeLongestSideInputs(target_shape=target_shape, is_rgb=resize_kwargs["is_rgb"]) 634 label_trafo = ResizeLongestSideInputs(target_shape=target_shape, is_label=True) 635 636 # The patch shape provided to the dataset. Here, "None" means that the entire volume will be loaded. 637 patch_shape = None 638 639 if ensure_rgb is None: 640 raw_trafos = [] 641 else: 642 assert not isinstance(ensure_rgb, bool), "'ensure_rgb' is expected to be a function." 643 raw_trafos = [ensure_rgb] 644 645 if "raw_transform" in kwargs: 646 raw_trafos.extend([raw_trafo, kwargs["raw_transform"]]) 647 else: 648 raw_trafos.extend([raw_trafo, get_raw_transform()]) 649 650 kwargs["raw_transform"] = Compose(*raw_trafos, is_multi_tensor=False) 651 652 if "label_transform" in kwargs: 653 trafo = Compose(label_trafo, kwargs["label_transform"], is_multi_tensor=False) 654 kwargs["label_transform"] = trafo 655 else: 656 kwargs["label_transform"] = label_trafo 657 658 return kwargs, patch_shape 659 660 661def generate_labeled_array_from_xml(shape: Tuple[int, ...], xml_file: str) -> np.ndarray: 662 """Generate a label mask from a contour defined in a xml annotation file. 663 664 Function taken from: https://github.com/rshwndsz/hover-net/blob/master/lightning_hovernet.ipynb 665 666 Args: 667 shape: The image shape. 668 xml_file: The path to the xml file with contour annotations. 669 670 Returns: 671 The label mask. 672 """ 673 # DOM object created by the minidom parser 674 xDoc = minidom.parse(xml_file) 675 676 # List of all Region tags 677 regions = xDoc.getElementsByTagName('Region') 678 679 # List which will store the vertices for each region 680 xy = [] 681 for region in regions: 682 # Loading all the vertices in the region 683 vertices = region.getElementsByTagName('Vertex') 684 685 # The vertices of a region will be stored in a array 686 vw = np.zeros((len(vertices), 2)) 687 688 for index, vertex in enumerate(vertices): 689 # Storing the values of x and y coordinate after conversion 690 vw[index][0] = float(vertex.getAttribute('X')) 691 vw[index][1] = float(vertex.getAttribute('Y')) 692 693 # Append the vertices of a region 694 xy.append(np.int32(vw)) 695 696 # Creating a completely black image 697 mask = np.zeros(shape, np.uint32) # Integer instance ids; float labels break connected-component ops. 698 699 # Start the instance ids at 1: id 0 is background, so enumerating from 0 silently drops the first region. 700 for i, contour in enumerate(xy, start=1): 701 r, c = polygon(np.array(contour)[:, 1], np.array(contour)[:, 0], shape=shape) 702 mask[r, c] = i 703 return mask 704 705 706def load_dicom_series(series_dir: str) -> Tuple[np.ndarray, Dict[str, np.ndarray]]: 707 """Stack a single-frame DICOM image series (CT, MR, PET) into a volume with axes (z, y, x). 708 709 The slices are sorted by their position along the slice normal (the cross product of the row and column 710 direction in 'ImageOrientationPatient'), so the volume is stacked consistently for any acquisition plane. 711 'RescaleSlope' and 'RescaleIntercept' are applied per slice, i.e. CT volumes are returned in Hounsfield units. 712 713 NOTE: This requires the pydicom python package. 714 715 Args: 716 series_dir: The folder with the DICOM files of the series. 717 718 Returns: 719 The volume with axes (z, y, x) as float32. 720 The geometry of the volume, which is needed by `rasterize_rtstruct`. A dictionary with the keys 721 'origin' (the 'ImagePositionPatient' of each slice, n_slices x 3), 'row_direction' and 'column_direction' 722 (the unit vectors along which the column and the row index increase, from 'ImageOrientationPatient'), 723 'spacing' (the row and column spacing from 'PixelSpacing') and 'sop_uids' (the 'SOPInstanceUID' per slice). 724 """ 725 import pydicom 726 727 dcm_paths = [os.path.join(series_dir, fname) for fname in sorted(os.listdir(series_dir)) if fname.endswith(".dcm")] 728 slices = [pydicom.dcmread(dcm_path) for dcm_path in dcm_paths] 729 730 row_direction = np.array([float(v) for v in slices[0].ImageOrientationPatient[:3]]) 731 column_direction = np.array([float(v) for v in slices[0].ImageOrientationPatient[3:]]) 732 normal = np.cross(row_direction, column_direction) 733 slices.sort(key=lambda dcm: np.dot([float(v) for v in dcm.ImagePositionPatient], normal)) 734 735 volume = [] 736 for dcm in slices: 737 frame = dcm.pixel_array.astype("float32") 738 volume.append(frame * float(dcm.get("RescaleSlope", 1.0)) + float(dcm.get("RescaleIntercept", 0.0))) 739 volume = np.stack(volume) 740 741 geometry = { 742 "origin": np.array([[float(v) for v in dcm.ImagePositionPatient] for dcm in slices]), 743 "row_direction": row_direction, 744 "column_direction": column_direction, 745 "spacing": np.array([float(v) for v in slices[0].PixelSpacing]), 746 "sop_uids": np.array([str(dcm.SOPInstanceUID) for dcm in slices]), 747 } 748 return volume, geometry 749 750 751def rasterize_rtstruct( 752 rtstruct_path: str, 753 geometry: Dict[str, np.ndarray], 754 shape: Tuple[int, int, int], 755 roi_labels: Union[Dict[str, int], Callable[[int, str], Optional[int]]], 756) -> np.ndarray: 757 """Rasterize the contours of a DICOM RTSTRUCT file onto the voxel grid of the referenced image series. 758 759 Each 'CLOSED_PLANAR' contour is assigned to the slice it references (via 'ReferencedSOPInstanceUID', with a 760 fallback to the closest slice along the slice normal). Its points are projected onto the row and column 761 direction of that slice to obtain pixel coordinates and the polygon is filled with `skimage.draw.polygon`. 762 Multiple contours of the same ROI on the same slice are combined with XOR, so that inner contours form holes. 763 Where different ROIs overlap, the ROI with the lower label id takes precedence. 764 765 NOTE: This requires the pydicom python package. 766 767 Args: 768 rtstruct_path: The path to the RTSTRUCT DICOM file. 769 geometry: The geometry of the referenced image series, as returned by `load_dicom_series`. 770 shape: The shape of the image volume (z, y, x). 771 roi_labels: The mapping from ROIs to label ids. Either a dictionary that maps the ROI names to label ids, 772 or a function that maps the ROI number and ROI name to a label id. ROIs that are not in the dictionary 773 or for which the function returns None are ignored. 774 775 Returns: 776 The label volume (uint8) with axes (z, y, x). 777 """ 778 import pydicom 779 780 rtstruct = pydicom.dcmread(rtstruct_path) 781 roi_names = {int(roi.ROINumber): str(roi.ROIName) for roi in rtstruct.StructureSetROISequence} 782 slice_ids = {uid: z for z, uid in enumerate(geometry["sop_uids"])} 783 normal = np.cross(geometry["row_direction"], geometry["column_direction"]) 784 slice_positions = geometry["origin"] @ normal 785 786 masks = {} 787 for roi_contour in rtstruct.ROIContourSequence: 788 roi_number = int(roi_contour.ReferencedROINumber) 789 roi_name = roi_names[roi_number] 790 if isinstance(roi_labels, dict): 791 label_id = roi_labels.get(roi_name) 792 else: 793 label_id = roi_labels(roi_number, roi_name) 794 if label_id is None: 795 continue 796 797 mask = masks.setdefault(label_id, np.zeros(shape, dtype="bool")) 798 for contour in roi_contour.get("ContourSequence", []): 799 if contour.ContourGeometricType != "CLOSED_PLANAR": 800 continue 801 points = np.array([float(v) for v in contour.ContourData]).reshape(-1, 3) 802 if len(points) < 3: 803 continue 804 805 z = None 806 for ref in contour.get("ContourImageSequence", []): 807 z = slice_ids.get(str(ref.ReferencedSOPInstanceUID)) 808 if z is None: 809 z = int(np.argmin(np.abs(slice_positions - np.dot(points[0], normal)))) 810 811 offsets = points - geometry["origin"][z] 812 cols = offsets @ geometry["row_direction"] / geometry["spacing"][1] 813 rows = offsets @ geometry["column_direction"] / geometry["spacing"][0] 814 rr, cc = polygon(rows, cols, shape=shape[1:]) 815 mask[z, rr, cc] = ~mask[z, rr, cc] 816 817 labels = np.zeros(shape, dtype="uint8") 818 for label_id in sorted(masks, reverse=True): 819 labels[masks[label_id]] = label_id 820 return labels 821 822 823# This function could be extended to convert WSIs (or modalities with multiple resolutions). 824def convert_svs_to_array( 825 path: str, location: Tuple[int, int] = (0, 0), level: int = 0, img_size: Tuple[int, int] = None, 826) -> np.ndarray: 827 """Convert a .svs file for WSI imagging to a numpy array. 828 829 Requires the tiffslide python library. 830 The function can load multi-resolution images. You can specify the resolution level via `level`. 831 832 Args: 833 path: File path ath to the svs file. 834 location: Pixel location (x, y) in level 0 of the image. 835 level: Target level used to read the image. 836 img_size: Size of the image. If None, the shape of the image at `level` is used. 837 838 Returns: 839 The image as numpy array. 840 """ 841 assert path.endswith(".svs"), f"The provided file ({path}) isn't in svs format" 842 843 try: 844 from tiffslide import TiffSlide 845 except ImportError: 846 # svs is a pyramidal TIFF variant, so tifffile can read it without the tiffslide dependency. 847 import tifffile 848 with tifffile.TiffFile(path) as f: 849 image = f.series[0].levels[level].asarray() 850 x, y = location 851 if img_size is not None: 852 image = image[y:y + img_size[1], x:x + img_size[0]] 853 else: 854 image = image[y:, x:] 855 return image 856 857 _slide = TiffSlide(path) 858 if img_size is None: 859 img_size = _slide.level_dimensions[0] 860 return _slide.read_region(location=location, level=level, size=img_size, as_array=True) 861 862 863def download_from_cryo_et_portal(path: str, dataset_id: int, download: bool) -> str: 864 """Download data from the CryoET Data Portal. 865 866 Requires the cryoet-data-portal python library. 867 868 Args: 869 path: The path for saving the data. 870 dataset_id: The id of the data to download from the portal. 871 download: Whether to download the data if it is not saved at `path` yet. 872 873 Returns: 874 The file path to the downloaded data. 875 """ 876 if Client is None or Dataset is None: 877 raise RuntimeError("Please install CryoETDataPortal via 'pip install cryoet-data-portal'") 878 879 output_path = os.path.join(path, str(dataset_id)) 880 if os.path.exists(output_path): 881 return output_path 882 883 if not download: 884 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 885 886 client = Client() 887 dataset = Dataset.get_by_id(client, dataset_id) 888 dataset.download_everything(dest_path=path) 889 890 return output_path 891 892 893CRYO_ET_PORTAL_BUCKET = "cryoet-data-portal-public" 894 895 896def _open_cryo_et_portal_zarr(s3_path): 897 import zarr 898 from zarr.storage import FsspecStore 899 900 store = FsspecStore.from_url(s3_path, storage_options={"anon": True}, read_only=True) 901 return zarr.open(store, mode="r") 902 903 904def stream_from_cryo_et_portal( 905 zarr_path: str, 906 dataset_id: int, 907 run: str, 908 voxel_spacing: str, 909 tomogram_folder: str, 910 annotations: Dict[str, str], 911 download: bool, 912) -> str: 913 """Stream a tomogram and its segmentation masks of a single run from the CryoET Data Portal. 914 915 In contrast to `download_from_cryo_et_portal` this only fetches the tomogram and the requested masks 916 (and not the frames and tilt series). The data is cached in a zarr group with the tomogram stored as 'raw' 917 and one array per annotation. Already cached arrays are not downloaded again. 918 919 Requires zarr (v3) and s3fs. 920 921 Args: 922 zarr_path: The filepath for the cached zarr group. 923 dataset_id: The id of the dataset on the portal. 924 run: The name of the run. 925 voxel_spacing: The voxel spacing folder of the run, e.g. 'VoxelSpacing13.480'. 926 tomogram_folder: The folder of the tomogram within 'Tomograms', e.g. '100'. 927 annotations: Mapping from the annotation name (e.g. 'nucleus') to the portal annotation folder (e.g. '108'). 928 download: Whether to download the data if it is not cached at `zarr_path` yet. 929 930 Returns: 931 The filepath to the cached zarr group. 932 """ 933 import zarr 934 from zarr.codecs import BloscCodec 935 936 root = zarr.open_group(zarr_path, mode="a") 937 if "raw" in root and all(name in root for name in annotations): 938 return zarr_path 939 940 if not download: 941 raise RuntimeError(f"Cannot find the data at {zarr_path}, but download was set to False.") 942 943 print(f"Streaming run {run} of dataset {dataset_id} from the CryoET Data Portal ...") 944 prefix = f"s3://{CRYO_ET_PORTAL_BUCKET}/{dataset_id}/{run}/Reconstructions/{voxel_spacing}" 945 946 def _make_array(name, data, shuffle): 947 arr = root.create_array( 948 name, shape=data.shape, chunks=tuple(min(128, s) for s in data.shape), dtype=data.dtype, 949 compressors=BloscCodec(cname="zstd", clevel=6, shuffle=shuffle), 950 ) 951 arr[:] = data 952 953 raw = _open_cryo_et_portal_zarr(f"{prefix}/Tomograms/{tomogram_folder}/{run}.zarr/0")[:] 954 if "raw" not in root: 955 _make_array("raw", raw, shuffle="shuffle") 956 957 for name, folder in annotations.items(): 958 if name in root: 959 continue 960 labels = _open_cryo_et_portal_zarr(f"{prefix}/Annotations/{folder}/{name}-1.0_segmentationmask.zarr/0")[:] 961 assert labels.shape == raw.shape, f"Shape mismatch for {run}, '{name}': raw {raw.shape} vs label {labels.shape}" 962 _make_array(name, labels, shuffle="bitshuffle") 963 964 root.attrs["dataset_id"] = dataset_id 965 root.attrs["run"] = run 966 return zarr_path
82def get_checksum(filename: str) -> str: 83 """Get the SHA256 checksum of a file. 84 85 Args: 86 filename: The filepath. 87 88 Returns: 89 The checksum. 90 """ 91 # The file is hashed in chunks, so that datasets with multi-GB archives do not run out of memory. 92 hasher = hashlib.sha256() 93 with open(filename, "rb") as f: 94 for chunk in iter(lambda: f.read(64 * 1024 * 1024), b""): 95 hasher.update(chunk) 96 return hasher.hexdigest()
Get the SHA256 checksum of a file.
Arguments:
- filename: The filepath.
Returns:
The checksum.
114def download_source(path: str, url: str, download: bool, checksum: Optional[str] = None, verify: bool = True) -> None: 115 """Download data via https. 116 117 Args: 118 path: The path for saving the data. 119 url: The url of the data. 120 download: Whether to download the data if it is not saved at `path` yet. 121 checksum: The expected checksum of the data. 122 verify: Whether to verify the https address. 123 """ 124 if os.path.exists(path): 125 return 126 if not download: 127 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 128 129 # The data is downloaded to a temporary path and only moved to `path` once it is complete and verified. 130 # Otherwise an interrupted download would be mistaken for a complete one by the check above. 131 tmp_path = f"{path}.incomplete" 132 with requests.get(url, stream=True, allow_redirects=True, verify=verify) as r: 133 r.raise_for_status() # check for error 134 # Compute checksums on the file content rather than its HTTP transfer encoding. 135 r.raw.decode_content = True 136 file_size = int(r.headers.get("Content-Length", 0)) 137 desc = f"Download {url} to {path}" 138 if file_size == 0: 139 desc += " (unknown file size)" 140 with tqdm.wrapattr(r.raw, "read", total=file_size, desc=desc) as r_raw, open(tmp_path, "wb") as f: 141 copyfileobj(r_raw, f) 142 143 _check_checksum(tmp_path, checksum) 144 os.replace(tmp_path, path)
Download data via https.
Arguments:
- path: The path for saving the data.
- url: The url of the data.
- download: Whether to download the data if it is not saved at
pathyet. - checksum: The expected checksum of the data.
- verify: Whether to verify the https address.
147def download_source_gdrive( 148 path: str, 149 url: str, 150 download: bool, 151 checksum: Optional[str] = None, 152 download_type: Literal["zip", "folder"] = "zip", 153 expected_samples: int = 10000, 154 quiet: bool = True, 155) -> None: 156 """Download data from google drive. 157 158 Args: 159 path: The path for saving the data. 160 url: The url of the data. 161 download: Whether to download the data if it is not saved at `path` yet. 162 checksum: The expected checksum of the data. 163 download_type: The download type, either 'zip' or 'folder'. 164 expected_samples: The maximal number of samples in the folder. 165 quiet: Whether to download quietly. 166 """ 167 if os.path.exists(path): 168 return 169 170 if not download: 171 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 172 173 if gdown is None: 174 raise RuntimeError( 175 "Need gdown library to download data from google drive. " 176 "Please install gdown: 'conda install -c conda-forge gdown==4.6.3'." 177 ) 178 179 print("Downloading the files. Might take a few minutes...") 180 181 if download_type == "zip": 182 gdown.download(url, path, quiet=quiet) 183 _check_checksum(path, checksum) 184 elif download_type == "folder": 185 assert version.parse(gdown.__version__) == version.parse("4.6.3"), "Please install 'gdown==4.6.3'." 186 gdown.download_folder.__globals__["MAX_NUMBER_FILES"] = expected_samples 187 gdown.download_folder(url=url, output=path, quiet=quiet, remaining_ok=True) 188 else: 189 raise ValueError("`download_path` argument expects either `zip`/`folder`") 190 191 print("Download completed.")
Download data from google drive.
Arguments:
- path: The path for saving the data.
- url: The url of the data.
- download: Whether to download the data if it is not saved at
pathyet. - checksum: The expected checksum of the data.
- download_type: The download type, either 'zip' or 'folder'.
- expected_samples: The maximal number of samples in the folder.
- quiet: Whether to download quietly.
194def download_source_empiar(path: str, access_id: str, download: bool) -> str: 195 """Download data from EMPIAR. 196 197 Requires the ascp command from the aspera CLI. 198 199 Args: 200 path: The path for saving the data. 201 access_id: The EMPIAR accession id of the data to download. 202 download: Whether to download the data if it is not saved at `path` yet. 203 204 Returns: 205 The path to the downloaded data. 206 """ 207 download_path = os.path.join(path, access_id) 208 209 if os.path.exists(download_path): 210 return download_path 211 if not download: 212 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False") 213 214 if which("ascp") is None: 215 raise RuntimeError( 216 "Need aspera-cli to download data from empiar. You can install it via 'conda install -c hcc aspera-cli'." 217 ) 218 219 key_file = os.path.expanduser("~/.aspera/cli/etc/asperaweb_id_dsa.openssh") 220 if not os.path.exists(key_file): 221 conda_root = os.environ["CONDA_PREFIX"] 222 key_file = os.path.join(conda_root, "etc/asperaweb_id_dsa.openssh") 223 224 if not os.path.exists(key_file): 225 raise RuntimeError("Could not find the aspera ssh keyfile") 226 227 cmd = ["ascp", "-QT", "-l", "200M", "-P33001", "-i", key_file, f"emp_ext2@fasp.ebi.ac.uk:/{access_id}", path] 228 run(cmd) 229 230 return download_path
Download data from EMPIAR.
Requires the ascp command from the aspera CLI.
Arguments:
- path: The path for saving the data.
- access_id: The EMPIAR accession id of the data to download.
- download: Whether to download the data if it is not saved at
pathyet.
Returns:
The path to the downloaded data.
233def download_source_kaggle(path: str, dataset_name: str, download: bool, competition: bool = False): 234 """Download data from Kaggle. 235 236 Requires the Kaggle API. 237 238 Args: 239 path: The path for saving the data. 240 dataset_name: The name of the dataset to download. 241 download: Whether to download the data if it is not saved at `path` yet. 242 competition: Whether this data is from a competition and requires the kaggle.competition API. 243 """ 244 if not download: 245 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 246 247 try: 248 from kaggle.api.kaggle_api_extended import KaggleApi 249 except ModuleNotFoundError: 250 msg = "Please install the Kaggle API. You can do this using 'pip install kaggle'. " 251 msg += "After you have installed kaggle, you would need an API token. " 252 msg += "Follow the instructions at https://www.kaggle.com/docs/api." 253 raise ModuleNotFoundError(msg) 254 255 api = KaggleApi() 256 api.authenticate() 257 258 if competition: 259 api.competition_download_files(competition=dataset_name, path=path, quiet=False) 260 else: 261 api.dataset_download_files(dataset=dataset_name, path=path, quiet=False)
Download data from Kaggle.
Requires the Kaggle API.
Arguments:
- path: The path for saving the data.
- dataset_name: The name of the dataset to download.
- download: Whether to download the data if it is not saved at
pathyet. - competition: Whether this data is from a competition and requires the kaggle.competition API.
368def download_tcia_series(series_uids: List[str], dst: str, csv_filename: str) -> str: 369 """Download individual DICOM series from TCIA by their series instance UIDs. 370 371 Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise. 372 Each series is stored in '<dst>/<SeriesInstanceUID>/', series that exist there already are skipped. 373 374 Args: 375 series_uids: The UIDs of the series to download. 376 dst: The folder for saving the DICOM series. 377 csv_filename: The path for saving the series metadata (without the '.csv' extension). 378 379 Returns: 380 The path to the csv file with the series metadata. 381 """ 382 if nbia is None: 383 _download_tcia_series_with_rest(series_uids, dst, csv_filename) 384 else: 385 nbia.downloadSeries(series_data=series_uids, input_type="list", path=dst, csv_filename=csv_filename) 386 return f"{csv_filename}.csv"
Download individual DICOM series from TCIA by their series instance UIDs.
Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise.
Each series is stored in '
Arguments:
- series_uids: The UIDs of the series to download.
- dst: The folder for saving the DICOM series.
- csv_filename: The path for saving the series metadata (without the '.csv' extension).
Returns:
The path to the csv file with the series metadata.
389def download_source_tcia(path, url, dst, csv_filename, download): 390 """Download data from TCIA. 391 392 Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise. 393 394 Args: 395 path: The path for saving the manifest file. If `url` is None, this must point to an existing manifest, 396 e.g. one that was written by the caller to download only a subset of the series of a collection. 397 url: The URL to the TCIA manifest of the dataset. Set to None to use the manifest at `path`. 398 dst: The folder for saving the DICOM series. Each series is stored in a sub-folder named after its UID. 399 csv_filename: The path for saving the series metadata (without the '.csv' extension). 400 download: Whether to download the data if it is not saved at `path` yet. 401 """ 402 if not download: 403 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 404 405 if url is None: 406 assert os.path.exists(path), f"The manifest {path} does not exist." 407 else: 408 assert url.endswith(".tcia"), f"{url} is not a TCIA Manifest." 409 # Downloads the manifest file from the collection page. 410 manifest = requests.get(url=url) 411 manifest.raise_for_status() 412 with open(path, "wb") as f: 413 f.write(manifest.content) 414 415 # This part extracts the UIDs from the manifests and downloads them. 416 if nbia is None: 417 _download_tcia_manifest_with_rest(path, dst, csv_filename) 418 else: 419 nbia.downloadSeries(series_data=path, input_type="manifest", path=dst, csv_filename=csv_filename)
Download data from TCIA.
Uses the tcia_utils python package if it is installed and falls back to the NBIA REST API otherwise.
Arguments:
- path: The path for saving the manifest file. If
urlis None, this must point to an existing manifest, e.g. one that was written by the caller to download only a subset of the series of a collection. - url: The URL to the TCIA manifest of the dataset. Set to None to use the manifest at
path. - dst: The folder for saving the DICOM series. Each series is stored in a sub-folder named after its UID.
- csv_filename: The path for saving the series metadata (without the '.csv' extension).
- download: Whether to download the data if it is not saved at
pathyet.
422def download_source_synapse(path: str, entity: str, download: bool) -> None: 423 """Download data from synapse. 424 425 Requires the synapseclient python library. 426 427 Args: 428 path: The path for saving the data. 429 entity: The name of the data to download from synapse. 430 download: Whether to download the data if it is not saved at `path` yet. 431 """ 432 if not download: 433 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 434 435 if synapseclient is None: 436 raise RuntimeError( 437 "You must install 'synapseclient' to download files from 'synapse'. " 438 "Remember to create an account and generate an authentication code for your account. " 439 "Please follow the documentation for details on creating the '~/.synapseConfig' file here: " 440 "https://python-docs.synapse.org/tutorials/authentication/." 441 ) 442 443 assert entity.startswith("syn"), "The entity name does not look as expected. It should be something like 'syn123'." 444 445 # Download all files in the folder. 446 syn = synapseclient.Synapse() 447 syn.login() # Since we do not pass any credentials here, it fetches all details from '~/.synapseConfig'. 448 synapseutils.syncFromSynapse(syn=syn, entity=entity, path=path)
Download data from synapse.
Requires the synapseclient python library.
Arguments:
- path: The path for saving the data.
- entity: The name of the data to download from synapse.
- download: Whether to download the data if it is not saved at
pathyet.
461def unzip_tarfile(tar_path: str, dst: str, remove: bool = True) -> None: 462 """Unpack a tar archive. 463 464 Args: 465 tar_path: Path to the tar file. 466 dst: Where to unpack the archive. 467 remove: Whether to remove the tar file after unpacking. 468 """ 469 import tarfile 470 471 if tar_path.endswith(".tar.gz") or tar_path.endswith(".tgz"): 472 access_mode = "r:gz" 473 elif tar_path.endswith(".tar"): 474 access_mode = "r:" 475 else: 476 raise ValueError(f"The provided file isn't a supported archive to unpack. Please check the file: {tar_path}.") 477 478 tar = tarfile.open(tar_path, access_mode) 479 tar.extractall(dst) 480 tar.close() 481 482 if remove: 483 os.remove(tar_path)
Unpack a tar archive.
Arguments:
- tar_path: Path to the tar file.
- dst: Where to unpack the archive.
- remove: Whether to remove the tar file after unpacking.
486def unzip_rarfile(rar_path: str, dst: str, remove: bool = True, use_rarfile: bool = True) -> None: 487 """Unpack a rar archive. 488 489 Args: 490 rar_path: Path to the rar file. 491 dst: Where to unpack the archive. 492 remove: Whether to remove the tar file after unpacking. 493 use_rarfile: Whether to use the rarfile library or aspose.zip. 494 """ 495 def _extract_with_rarfile(): 496 import rarfile 497 with rarfile.RarFile(rar_path) as archive: 498 archive.extractall(path=dst) 499 500 def _extract_with_aspose(): 501 import aspose.zip as az 502 with az.rar.RarArchive(rar_path) as archive: 503 archive.extract_to_directory(dst) 504 505 def _extract_with_7z(): 506 if which("7z") is None: 507 raise RuntimeError("The 'p7zip' CLI is not available.") 508 run(["7z", "x", f"-o{dst}", "-y", rar_path], check=True) 509 510 extractors = [ 511 ('rarfile', _extract_with_rarfile), ('aspose.zip', _extract_with_aspose), ('7z', _extract_with_7z), 512 ] if use_rarfile else [('aspose.zip', _extract_with_aspose), ('7z', _extract_with_7z)] 513 514 errors = [] 515 for name, extractor in extractors: 516 try: 517 extractor() 518 break 519 except Exception as err: 520 errors.append((name, err)) 521 if len(errors) < len(extractors): 522 next_name = extractors[len(errors)][0] 523 warn(f"Extraction with '{name}' failed for {rar_path} ({err}). Falling back to '{next_name}'.") 524 else: 525 backends = ', '.join(f"'{name}'" for name, _ in extractors) 526 raise RuntimeError( 527 f"Failed to extract rar archive {rar_path} with {backends}. " 528 "Please ensure one of the supported backends is installed and can read this archive." 529 ) from errors[-1][1] 530 531 if remove: 532 os.remove(rar_path)
Unpack a rar archive.
Arguments:
- rar_path: Path to the rar file.
- dst: Where to unpack the archive.
- remove: Whether to remove the tar file after unpacking.
- use_rarfile: Whether to use the rarfile library or aspose.zip.
535def unzip(zip_path: str, dst: str, remove: bool = True) -> None: 536 """Unpack a zip archive. 537 538 Args: 539 zip_path: Path to the zip file. 540 dst: Where to unpack the archive. 541 remove: Whether to remove the tar file after unpacking. 542 """ 543 with zipfile.ZipFile(zip_path, "r") as f: 544 f.extractall(dst) 545 if remove: 546 os.remove(zip_path)
Unpack a zip archive.
Arguments:
- zip_path: Path to the zip file.
- dst: Where to unpack the archive.
- remove: Whether to remove the tar file after unpacking.
549def unzip_7z(path_7z: str, dst: str, remove: bool = True) -> None: 550 """Unpack a 7z archive. 551 552 Args: 553 path_7z: Path to the 7z file. 554 dst: Where to unpack the archive. 555 remove: Whether to remove the 7z file after unpacking. 556 """ 557 if which("7z") is None: 558 raise RuntimeError("Need the 'p7zip' CLI to extract 7z archives. You can install it via 'conda install -c conda-forge p7zip'.") # noqa 559 560 run(["7z", "x", f"-o{dst}", "-y", path_7z]) 561 562 if remove: 563 os.remove(path_7z)
Unpack a 7z archive.
Arguments:
- path_7z: Path to the 7z file.
- dst: Where to unpack the archive.
- remove: Whether to remove the 7z file after unpacking.
662def generate_labeled_array_from_xml(shape: Tuple[int, ...], xml_file: str) -> np.ndarray: 663 """Generate a label mask from a contour defined in a xml annotation file. 664 665 Function taken from: https://github.com/rshwndsz/hover-net/blob/master/lightning_hovernet.ipynb 666 667 Args: 668 shape: The image shape. 669 xml_file: The path to the xml file with contour annotations. 670 671 Returns: 672 The label mask. 673 """ 674 # DOM object created by the minidom parser 675 xDoc = minidom.parse(xml_file) 676 677 # List of all Region tags 678 regions = xDoc.getElementsByTagName('Region') 679 680 # List which will store the vertices for each region 681 xy = [] 682 for region in regions: 683 # Loading all the vertices in the region 684 vertices = region.getElementsByTagName('Vertex') 685 686 # The vertices of a region will be stored in a array 687 vw = np.zeros((len(vertices), 2)) 688 689 for index, vertex in enumerate(vertices): 690 # Storing the values of x and y coordinate after conversion 691 vw[index][0] = float(vertex.getAttribute('X')) 692 vw[index][1] = float(vertex.getAttribute('Y')) 693 694 # Append the vertices of a region 695 xy.append(np.int32(vw)) 696 697 # Creating a completely black image 698 mask = np.zeros(shape, np.uint32) # Integer instance ids; float labels break connected-component ops. 699 700 # Start the instance ids at 1: id 0 is background, so enumerating from 0 silently drops the first region. 701 for i, contour in enumerate(xy, start=1): 702 r, c = polygon(np.array(contour)[:, 1], np.array(contour)[:, 0], shape=shape) 703 mask[r, c] = i 704 return mask
Generate a label mask from a contour defined in a xml annotation file.
Function taken from: https://github.com/rshwndsz/hover-net/blob/master/lightning_hovernet.ipynb
Arguments:
- shape: The image shape.
- xml_file: The path to the xml file with contour annotations.
Returns:
The label mask.
707def load_dicom_series(series_dir: str) -> Tuple[np.ndarray, Dict[str, np.ndarray]]: 708 """Stack a single-frame DICOM image series (CT, MR, PET) into a volume with axes (z, y, x). 709 710 The slices are sorted by their position along the slice normal (the cross product of the row and column 711 direction in 'ImageOrientationPatient'), so the volume is stacked consistently for any acquisition plane. 712 'RescaleSlope' and 'RescaleIntercept' are applied per slice, i.e. CT volumes are returned in Hounsfield units. 713 714 NOTE: This requires the pydicom python package. 715 716 Args: 717 series_dir: The folder with the DICOM files of the series. 718 719 Returns: 720 The volume with axes (z, y, x) as float32. 721 The geometry of the volume, which is needed by `rasterize_rtstruct`. A dictionary with the keys 722 'origin' (the 'ImagePositionPatient' of each slice, n_slices x 3), 'row_direction' and 'column_direction' 723 (the unit vectors along which the column and the row index increase, from 'ImageOrientationPatient'), 724 'spacing' (the row and column spacing from 'PixelSpacing') and 'sop_uids' (the 'SOPInstanceUID' per slice). 725 """ 726 import pydicom 727 728 dcm_paths = [os.path.join(series_dir, fname) for fname in sorted(os.listdir(series_dir)) if fname.endswith(".dcm")] 729 slices = [pydicom.dcmread(dcm_path) for dcm_path in dcm_paths] 730 731 row_direction = np.array([float(v) for v in slices[0].ImageOrientationPatient[:3]]) 732 column_direction = np.array([float(v) for v in slices[0].ImageOrientationPatient[3:]]) 733 normal = np.cross(row_direction, column_direction) 734 slices.sort(key=lambda dcm: np.dot([float(v) for v in dcm.ImagePositionPatient], normal)) 735 736 volume = [] 737 for dcm in slices: 738 frame = dcm.pixel_array.astype("float32") 739 volume.append(frame * float(dcm.get("RescaleSlope", 1.0)) + float(dcm.get("RescaleIntercept", 0.0))) 740 volume = np.stack(volume) 741 742 geometry = { 743 "origin": np.array([[float(v) for v in dcm.ImagePositionPatient] for dcm in slices]), 744 "row_direction": row_direction, 745 "column_direction": column_direction, 746 "spacing": np.array([float(v) for v in slices[0].PixelSpacing]), 747 "sop_uids": np.array([str(dcm.SOPInstanceUID) for dcm in slices]), 748 } 749 return volume, geometry
Stack a single-frame DICOM image series (CT, MR, PET) into a volume with axes (z, y, x).
The slices are sorted by their position along the slice normal (the cross product of the row and column direction in 'ImageOrientationPatient'), so the volume is stacked consistently for any acquisition plane. 'RescaleSlope' and 'RescaleIntercept' are applied per slice, i.e. CT volumes are returned in Hounsfield units.
NOTE: This requires the pydicom python package.
Arguments:
- series_dir: The folder with the DICOM files of the series.
Returns:
The volume with axes (z, y, x) as float32. The geometry of the volume, which is needed by
rasterize_rtstruct. A dictionary with the keys 'origin' (the 'ImagePositionPatient' of each slice, n_slices x 3), 'row_direction' and 'column_direction' (the unit vectors along which the column and the row index increase, from 'ImageOrientationPatient'), 'spacing' (the row and column spacing from 'PixelSpacing') and 'sop_uids' (the 'SOPInstanceUID' per slice).
752def rasterize_rtstruct( 753 rtstruct_path: str, 754 geometry: Dict[str, np.ndarray], 755 shape: Tuple[int, int, int], 756 roi_labels: Union[Dict[str, int], Callable[[int, str], Optional[int]]], 757) -> np.ndarray: 758 """Rasterize the contours of a DICOM RTSTRUCT file onto the voxel grid of the referenced image series. 759 760 Each 'CLOSED_PLANAR' contour is assigned to the slice it references (via 'ReferencedSOPInstanceUID', with a 761 fallback to the closest slice along the slice normal). Its points are projected onto the row and column 762 direction of that slice to obtain pixel coordinates and the polygon is filled with `skimage.draw.polygon`. 763 Multiple contours of the same ROI on the same slice are combined with XOR, so that inner contours form holes. 764 Where different ROIs overlap, the ROI with the lower label id takes precedence. 765 766 NOTE: This requires the pydicom python package. 767 768 Args: 769 rtstruct_path: The path to the RTSTRUCT DICOM file. 770 geometry: The geometry of the referenced image series, as returned by `load_dicom_series`. 771 shape: The shape of the image volume (z, y, x). 772 roi_labels: The mapping from ROIs to label ids. Either a dictionary that maps the ROI names to label ids, 773 or a function that maps the ROI number and ROI name to a label id. ROIs that are not in the dictionary 774 or for which the function returns None are ignored. 775 776 Returns: 777 The label volume (uint8) with axes (z, y, x). 778 """ 779 import pydicom 780 781 rtstruct = pydicom.dcmread(rtstruct_path) 782 roi_names = {int(roi.ROINumber): str(roi.ROIName) for roi in rtstruct.StructureSetROISequence} 783 slice_ids = {uid: z for z, uid in enumerate(geometry["sop_uids"])} 784 normal = np.cross(geometry["row_direction"], geometry["column_direction"]) 785 slice_positions = geometry["origin"] @ normal 786 787 masks = {} 788 for roi_contour in rtstruct.ROIContourSequence: 789 roi_number = int(roi_contour.ReferencedROINumber) 790 roi_name = roi_names[roi_number] 791 if isinstance(roi_labels, dict): 792 label_id = roi_labels.get(roi_name) 793 else: 794 label_id = roi_labels(roi_number, roi_name) 795 if label_id is None: 796 continue 797 798 mask = masks.setdefault(label_id, np.zeros(shape, dtype="bool")) 799 for contour in roi_contour.get("ContourSequence", []): 800 if contour.ContourGeometricType != "CLOSED_PLANAR": 801 continue 802 points = np.array([float(v) for v in contour.ContourData]).reshape(-1, 3) 803 if len(points) < 3: 804 continue 805 806 z = None 807 for ref in contour.get("ContourImageSequence", []): 808 z = slice_ids.get(str(ref.ReferencedSOPInstanceUID)) 809 if z is None: 810 z = int(np.argmin(np.abs(slice_positions - np.dot(points[0], normal)))) 811 812 offsets = points - geometry["origin"][z] 813 cols = offsets @ geometry["row_direction"] / geometry["spacing"][1] 814 rows = offsets @ geometry["column_direction"] / geometry["spacing"][0] 815 rr, cc = polygon(rows, cols, shape=shape[1:]) 816 mask[z, rr, cc] = ~mask[z, rr, cc] 817 818 labels = np.zeros(shape, dtype="uint8") 819 for label_id in sorted(masks, reverse=True): 820 labels[masks[label_id]] = label_id 821 return labels
Rasterize the contours of a DICOM RTSTRUCT file onto the voxel grid of the referenced image series.
Each 'CLOSED_PLANAR' contour is assigned to the slice it references (via 'ReferencedSOPInstanceUID', with a
fallback to the closest slice along the slice normal). Its points are projected onto the row and column
direction of that slice to obtain pixel coordinates and the polygon is filled with skimage.draw.polygon.
Multiple contours of the same ROI on the same slice are combined with XOR, so that inner contours form holes.
Where different ROIs overlap, the ROI with the lower label id takes precedence.
NOTE: This requires the pydicom python package.
Arguments:
- rtstruct_path: The path to the RTSTRUCT DICOM file.
- geometry: The geometry of the referenced image series, as returned by
load_dicom_series. - shape: The shape of the image volume (z, y, x).
- roi_labels: The mapping from ROIs to label ids. Either a dictionary that maps the ROI names to label ids, or a function that maps the ROI number and ROI name to a label id. ROIs that are not in the dictionary or for which the function returns None are ignored.
Returns:
The label volume (uint8) with axes (z, y, x).
825def convert_svs_to_array( 826 path: str, location: Tuple[int, int] = (0, 0), level: int = 0, img_size: Tuple[int, int] = None, 827) -> np.ndarray: 828 """Convert a .svs file for WSI imagging to a numpy array. 829 830 Requires the tiffslide python library. 831 The function can load multi-resolution images. You can specify the resolution level via `level`. 832 833 Args: 834 path: File path ath to the svs file. 835 location: Pixel location (x, y) in level 0 of the image. 836 level: Target level used to read the image. 837 img_size: Size of the image. If None, the shape of the image at `level` is used. 838 839 Returns: 840 The image as numpy array. 841 """ 842 assert path.endswith(".svs"), f"The provided file ({path}) isn't in svs format" 843 844 try: 845 from tiffslide import TiffSlide 846 except ImportError: 847 # svs is a pyramidal TIFF variant, so tifffile can read it without the tiffslide dependency. 848 import tifffile 849 with tifffile.TiffFile(path) as f: 850 image = f.series[0].levels[level].asarray() 851 x, y = location 852 if img_size is not None: 853 image = image[y:y + img_size[1], x:x + img_size[0]] 854 else: 855 image = image[y:, x:] 856 return image 857 858 _slide = TiffSlide(path) 859 if img_size is None: 860 img_size = _slide.level_dimensions[0] 861 return _slide.read_region(location=location, level=level, size=img_size, as_array=True)
Convert a .svs file for WSI imagging to a numpy array.
Requires the tiffslide python library.
The function can load multi-resolution images. You can specify the resolution level via level.
Arguments:
- path: File path ath to the svs file.
- location: Pixel location (x, y) in level 0 of the image.
- level: Target level used to read the image.
- img_size: Size of the image. If None, the shape of the image at
levelis used.
Returns:
The image as numpy array.
864def download_from_cryo_et_portal(path: str, dataset_id: int, download: bool) -> str: 865 """Download data from the CryoET Data Portal. 866 867 Requires the cryoet-data-portal python library. 868 869 Args: 870 path: The path for saving the data. 871 dataset_id: The id of the data to download from the portal. 872 download: Whether to download the data if it is not saved at `path` yet. 873 874 Returns: 875 The file path to the downloaded data. 876 """ 877 if Client is None or Dataset is None: 878 raise RuntimeError("Please install CryoETDataPortal via 'pip install cryoet-data-portal'") 879 880 output_path = os.path.join(path, str(dataset_id)) 881 if os.path.exists(output_path): 882 return output_path 883 884 if not download: 885 raise RuntimeError(f"Cannot find the data at {path}, but download was set to False.") 886 887 client = Client() 888 dataset = Dataset.get_by_id(client, dataset_id) 889 dataset.download_everything(dest_path=path) 890 891 return output_path
Download data from the CryoET Data Portal.
Requires the cryoet-data-portal python library.
Arguments:
- path: The path for saving the data.
- dataset_id: The id of the data to download from the portal.
- download: Whether to download the data if it is not saved at
pathyet.
Returns:
The file path to the downloaded data.
905def stream_from_cryo_et_portal( 906 zarr_path: str, 907 dataset_id: int, 908 run: str, 909 voxel_spacing: str, 910 tomogram_folder: str, 911 annotations: Dict[str, str], 912 download: bool, 913) -> str: 914 """Stream a tomogram and its segmentation masks of a single run from the CryoET Data Portal. 915 916 In contrast to `download_from_cryo_et_portal` this only fetches the tomogram and the requested masks 917 (and not the frames and tilt series). The data is cached in a zarr group with the tomogram stored as 'raw' 918 and one array per annotation. Already cached arrays are not downloaded again. 919 920 Requires zarr (v3) and s3fs. 921 922 Args: 923 zarr_path: The filepath for the cached zarr group. 924 dataset_id: The id of the dataset on the portal. 925 run: The name of the run. 926 voxel_spacing: The voxel spacing folder of the run, e.g. 'VoxelSpacing13.480'. 927 tomogram_folder: The folder of the tomogram within 'Tomograms', e.g. '100'. 928 annotations: Mapping from the annotation name (e.g. 'nucleus') to the portal annotation folder (e.g. '108'). 929 download: Whether to download the data if it is not cached at `zarr_path` yet. 930 931 Returns: 932 The filepath to the cached zarr group. 933 """ 934 import zarr 935 from zarr.codecs import BloscCodec 936 937 root = zarr.open_group(zarr_path, mode="a") 938 if "raw" in root and all(name in root for name in annotations): 939 return zarr_path 940 941 if not download: 942 raise RuntimeError(f"Cannot find the data at {zarr_path}, but download was set to False.") 943 944 print(f"Streaming run {run} of dataset {dataset_id} from the CryoET Data Portal ...") 945 prefix = f"s3://{CRYO_ET_PORTAL_BUCKET}/{dataset_id}/{run}/Reconstructions/{voxel_spacing}" 946 947 def _make_array(name, data, shuffle): 948 arr = root.create_array( 949 name, shape=data.shape, chunks=tuple(min(128, s) for s in data.shape), dtype=data.dtype, 950 compressors=BloscCodec(cname="zstd", clevel=6, shuffle=shuffle), 951 ) 952 arr[:] = data 953 954 raw = _open_cryo_et_portal_zarr(f"{prefix}/Tomograms/{tomogram_folder}/{run}.zarr/0")[:] 955 if "raw" not in root: 956 _make_array("raw", raw, shuffle="shuffle") 957 958 for name, folder in annotations.items(): 959 if name in root: 960 continue 961 labels = _open_cryo_et_portal_zarr(f"{prefix}/Annotations/{folder}/{name}-1.0_segmentationmask.zarr/0")[:] 962 assert labels.shape == raw.shape, f"Shape mismatch for {run}, '{name}': raw {raw.shape} vs label {labels.shape}" 963 _make_array(name, labels, shuffle="bitshuffle") 964 965 root.attrs["dataset_id"] = dataset_id 966 root.attrs["run"] = run 967 return zarr_path
Stream a tomogram and its segmentation masks of a single run from the CryoET Data Portal.
In contrast to download_from_cryo_et_portal this only fetches the tomogram and the requested masks
(and not the frames and tilt series). The data is cached in a zarr group with the tomogram stored as 'raw'
and one array per annotation. Already cached arrays are not downloaded again.
Requires zarr (v3) and s3fs.
Arguments:
- zarr_path: The filepath for the cached zarr group.
- dataset_id: The id of the dataset on the portal.
- run: The name of the run.
- voxel_spacing: The voxel spacing folder of the run, e.g. 'VoxelSpacing13.480'.
- tomogram_folder: The folder of the tomogram within 'Tomograms', e.g. '100'.
- annotations: Mapping from the annotation name (e.g. 'nucleus') to the portal annotation folder (e.g. '108').
- download: Whether to download the data if it is not cached at
zarr_pathyet.
Returns:
The filepath to the cached zarr group.