Source code for rapidtools.datasets

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-28-2026

"""
Registry and downloader for the sample datasets shipped with rapidtools.

The :data:`DATASET_REGISTRY` maps short, descriptive dataset names to one or
more :class:`RemoteFile` entries hosted on Dropbox. :func:`download_dataset`
resolves names case-insensitively, validates them up front, and streams each
file to disk atomically via a temporary ``.tmp`` file so interrupted
downloads never leave corrupted artifacts behind.

Example:
    >>> from rapidtools.datasets import DATASET_REGISTRY, download_dataset
    >>> sorted(DATASET_REGISTRY)[:2]
    ['aerial_chs_prompts', 'altadena_sample_buildings']
    >>> paths = download_dataset('altadena_sample_buildings', output_dir='data')
    >>> paths[0].name
    'altadena_sample_buildings.geojson'
"""

import difflib
import logging
from dataclasses import dataclass
from pathlib import Path

import requests
from tqdm import tqdm

logger = logging.getLogger(__name__)


@dataclass
class RemoteFile:
    """
    Represents a single downloadable file within a dataset.

    Attributes:
        url: Public Dropbox share link for the file.
        filename: Name to use when saving the file locally.

    Example:
        >>> remote = RemoteFile(
        ...     url='https://www.dropbox.com/scl/fi/abc/tokens.txt?dl=0',
        ...     filename='tokens.txt',
        ... )
        >>> remote.filename
        'tokens.txt'
    """

    url: str
    filename: str


# Dataset registry mapping descriptive dataset names to their respective URLs
# and target filenames. A dataset can consist of a single file or a list of
# multiple files:
DATASET_REGISTRY: dict[str, list[RemoteFile]] = {
    'aerial_chs_prompts': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/wd8zd24xvgmhd0hrsw5eo/aerial_CHS_prompts.txt?rlkey=diybx8npfrlvam4wgltpw9qbe&st=wvmw5vz4&dl=0',
            filename='aerial_CHS_prompts.txt',
        )
    ],
    'street_chs_prompts': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/7x4gczzxh5xd6932si77e/street_CHS_prompts.txt?rlkey=2n01r9pxarp4wwjls3pbab2kd&st=erleg6ml&dl=0',
            filename='street_CHS_prompts.txt',
        )
    ],
    'street_recovery_prompts': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/nest9dvd0hq8erltjwt3m/street_level_recovery_prompts.txt?rlkey=f1umaoyxkj5osmbzf4f3d76dl&st=y4frb6bq&dl=0',
            filename='street_level_recovery_prompts.txt',
        )
    ],
    'altadena_sample_buildings': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/4mr3r5as4ccebqxuvooaw/altadena_sample_buildings.geojson?rlkey=cpkyamwg7hdos984a552aj8d5&st=7np0z16s&dl=0',
            filename='altadena_sample_buildings.geojson',
        )
    ],
    'eaton_patch1': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/qzehjo91mz1lrd27etcve/eaton_patch_20250214.tiff?rlkey=rcmkpdyaixgq9i18q997bnp2z&st=6mgdvhay&dl=0',
            filename='eaton_patch_20250214.tiff',
        )
    ],
    'eaton_patch2': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/02sx7l5r594jl5qg3qfhq/Eaton_Trinity38_RGBortho_20250214.tiff?rlkey=5frnvs7drsxj7s8ofj2upqdvt&st=4814ge5y&dl=0',
            filename='Eaton_Trinity38_RGBortho_20250214.tiff',
        )
    ],
    'eaton_patch1_bing_buildings': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/71f88zaczho2legblqkt3/eaton_patch1_bing_buildings.geojson?rlkey=qb2c8o695s531en28sdlhesvv&st=9vu74mk4&dl=0',
            filename='eaton_patch1_bing_buildings.geojson',
        )
    ],
    'mapillary_token': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/wkfxeyjv65rlj4zgsawh2/mapillary_token.txt?rlkey=ns1v51demkr8lm3grmonmw9py&st=vzq95fa9&dl=0',
            filename='mapillary_token.txt',
        )
    ],
    'hf_token': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/mcguhhyofca9ozefs7xts/hf_token.txt?rlkey=v2lqqzj6ug102oesdnsz8lnbn&st=ydpbswnj&dl=0',
            filename='hf_token.txt',
        )
    ],
    'synthetic_landslide_image': [
        RemoteFile(
            url='https://www.dropbox.com/scl/fi/ocrv60lphskqo9uf024nb/HurricaneInducedLandslide.png?rlkey=l2m6el6lfl6s9wc6ombjkhdyy&st=rd8usvyj&dl=0',
            filename='HurricaneInducedLandslide.png',
        )
    ],
}


[docs] def download_dataset( dataset_names: str | list[str], output_dir: str | Path = '.' ) -> list[Path]: """ Download all files associated with one or more datasets from the registry. Uses atomic writing (downloading to a temporary file first) to ensure that interrupted downloads do not result in corrupted files. Files that already exist in ``output_dir`` are not re-downloaded. Network or I/O failures for an individual file are logged and skipped so the remaining files can still be fetched. Args: dataset_names (str | list[str]): A single descriptive name of the dataset, or a list of names (e.g., 'eaton_patch1' OR ['eaton_patch1', 'eaton_patch2']). Input is case-insensitive and ignores leading/trailing whitespace. output_dir (str | Path, optional): Directory where files should be saved. Defaults to the current working directory ('.'). Returns: list[Path]: A flat list of absolute paths to all successfully downloaded (or already present) files, in registry order. Raises: ValueError: If any of the requested dataset names do not exist in the registry. The message includes a "Did you mean" suggestion when a close match exists. Example: >>> from rapidtools.datasets import download_dataset >>> paths = download_dataset( ... ['Eaton_Patch1', ' mapillary_token '], output_dir='data' ... ) >>> [p.name for p in paths] ['eaton_patch_20250214.tiff', 'mapillary_token.txt'] """ # Normalize input into a list so we can process it uniformly: if isinstance(dataset_names, str): dataset_names = [dataset_names] available_datasets = list(DATASET_REGISTRY.keys()) clean_names = [] # Normalize and validate ALL requested datasets before downloading: for name in dataset_names: clean_name = name.strip().lower() if clean_name not in DATASET_REGISTRY: error_message = f"Dataset '{name}' not found in the registry." # Use difflib to find the closest matching dataset name: suggestions = difflib.get_close_matches( clean_name, available_datasets, n=1, cutoff=0.5 ) if suggestions: error_message += f" Did you mean '{suggestions[0]}'?" else: error_message += f' Available datasets: {available_datasets}' logger.error(error_message) raise ValueError(error_message) clean_names.append(clean_name) # Deduplicate the list in case the user passed the same dataset twice # using a dictionary preserves the original order: clean_names = list(dict.fromkeys(clean_names)) # Resolve output directory and create it if it does not exist: out_dir = Path(output_dir).resolve() out_dir.mkdir(parents=True, exist_ok=True) # Aggregate all individual files from the requested datasets: files_to_download: list[RemoteFile] = [] for clean_name in clean_names: files_to_download.extend(DATASET_REGISTRY[clean_name]) logger.info( f'Preparing to download {len(files_to_download)} file(s) ' f'across {len(clean_names)} dataset(s)...' ) downloaded_paths: list[Path] = [] for remote_file in files_to_download: final_path = out_dir / remote_file.filename temp_path = final_path.with_suffix('.tmp') # Skip if the fully downloaded file already exists: if final_path.exists(): logger.info(f'File already exists at: {final_path}. Skipping.') downloaded_paths.append(final_path) continue # Bypass the Dropbox web view to force a direct file stream: direct_link = remote_file.url.replace( 'www.dropbox.com', 'dl.dropboxusercontent.com' ) try: # Always include a timeout to prevent indefinite hanging: response = requests.get(direct_link, stream=True, timeout=15) response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) chunk_size = 8192 # Download to a temporary file first: with ( open(temp_path, 'wb') as file, tqdm( total=total_size, unit='iB', unit_scale=True, desc=f'Downloading {remote_file.filename}', ) as bar, ): for chunk in response.iter_content(chunk_size=chunk_size): if chunk: file.write(chunk) bar.update(len(chunk)) # Rename the temp file to the final filename upon successful # completion: temp_path.rename(final_path) downloaded_paths.append(final_path) except requests.exceptions.RequestException as req_err: logger.error( f'Network Error while downloading {remote_file.filename}: {req_err}' ) except Exception as err: logger.error(f'Unexpected error downloading {remote_file.filename}: {err}') finally: # Cleanup: Remove corrupted temporary file if download # failed/cancelled: if temp_path.exists(): temp_path.unlink() logger.debug(f'Cleaned up incomplete temporary file: {temp_path}') if downloaded_paths: logger.info( f'Successfully secured {len(downloaded_paths)}/' f'{len(files_to_download)} files.' ) return downloaded_paths