diff --git a/formats/pyramid-generator-3d-tool/.bumpversion.cfg b/formats/pyramid-generator-3d-tool/.bumpversion.cfg new file mode 100644 index 000000000..005c971a8 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/.bumpversion.cfg @@ -0,0 +1,33 @@ +[bumpversion] +current_version = 0.1.1-dev0 +commit = True +tag = False +parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? +serialize = + {major}.{minor}.{patch}-{release}{dev} + {major}.{minor}.{patch} + +[bumpversion:part:release] +optional_value = _ +first_value = dev +values = + dev + _ + +[bumpversion:part:dev] + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:VERSION] + +[bumpversion:file:README.md] + +[bumpversion:file:plugin.json] + +[bumpversion:file:src/polus/images/formats/pyramid_generator_3d/__init__.py] + +[bumpversion:file:pyramidgenerator3d.cwl] + +[bumpversion:ict.yml] diff --git a/formats/pyramid-generator-3d-tool/.gitignore b/formats/pyramid-generator-3d-tool/.gitignore new file mode 100644 index 000000000..db9ae5e04 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/formats/pyramid-generator-3d-tool/.python-version b/formats/pyramid-generator-3d-tool/.python-version new file mode 100644 index 000000000..bd28b9c5c --- /dev/null +++ b/formats/pyramid-generator-3d-tool/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/formats/pyramid-generator-3d-tool/Dockerfile b/formats/pyramid-generator-3d-tool/Dockerfile new file mode 100644 index 000000000..fc2cf372f --- /dev/null +++ b/formats/pyramid-generator-3d-tool/Dockerfile @@ -0,0 +1,25 @@ +FROM polusai/bfio:2.4.7 + +# environment variables defined in polusai/bfio +ENV EXEC_DIR="/opt/executables" +ENV POLUS_IMG_EXT=".ome.tif" +ENV POLUS_TAB_EXT=".csv" +ENV POLUS_LOG="INFO" + +# Work directory defined in the base container +WORKDIR ${EXEC_DIR} + +# TODO: Change the tool_dir to the tool directory +ENV TOOL_DIR="formats/pyramid-generator-3d-tool" + +# Copy the repository into the container +RUN mkdir image-tools +COPY . ${EXEC_DIR}/image-tools + +# Install the tool +RUN pip3 install "${EXEC_DIR}/image-tools/${TOOL_DIR}" --no-cache-dir + +# Set the entrypoint +# TODO: Change the entrypoint to the tool entrypoint +ENTRYPOINT ["python3", "-m", "polus.images.formats.pyramid_generator_3d"] +CMD ["--help"] diff --git a/formats/pyramid-generator-3d-tool/README.md b/formats/pyramid-generator-3d-tool/README.md new file mode 100644 index 000000000..224399b4e --- /dev/null +++ b/formats/pyramid-generator-3d-tool/README.md @@ -0,0 +1,75 @@ +# pyramid_generator_3d (0.1.1-dev0) + +Generate 3D Image Pyramid from an image collection or Zarr directory. This plugin is a wrapper for argolid. +This tool offers 2 subcommands: `Vol` and `Py3D`, for volume generation and 3D pyramid generation respectively. See [Usage](##usage) section for details. + +## Options +| Name | Description | I/O | Type | +|-------------|-----------------------------------------------------------------------------|-----|--------| +|`--subCmd` | Subcommand to invoke. Options are `Vol` and `Py3D`. |Input|string | +|`--zarrDir` | Directory to Zarr arrays for generating 3D pyramid. |Input|collection| +|`--inpDir` | Directory to input image collection. Required if `--zarrDir` is unspecified.|Input|collection| +|`--filePattern` | File pattern for discovering images in `--inpDir`. |Input|collection| +|`--groupBy` | Grouping variable for images. Options are `t`, `z`, `c`. |Input|string| +|`--outDir` | Path of output directory. |Output|collection| +|`--outImgName` | Output name for Zarr arrays when using volume generation. |Input|string| +|`--baseScaleKey`| Base scale key for 3D pyramid generation. Default to 0. |Input|integer| +|`--numLevels` | Number of levels for 3D pyramid. |Input|integer| + +## Usage +### Volume Generation +Use `Vol` subcommand to generate Zarr arrays from image stacks. It reads images from the input directory, groups them by specific dimension, and writes Zarr array into the output directory. +The ***required*** options for `Vol` subcommand are `--inpDir`, `--filePattern`, `--groupBy`, `outDir`, `--outImgName` +Example usage: +``` +python3 -m polus.images.formats.pyramid_generator_3d --subCmd Vol --inpDir /path/to/input/images --filePattern img_r{r:ddd}_c{c:ddd}.ome.tif --groupBy c --outDir /path/to/output --outImgName output_image +``` + +### 3D Pyramid +Use `Py3D` subcommand to generate 3D pyramid from either (1) a directory with Zarr array or (2) a directory of images. +#### From Zarr directory +When generating from a Zarr directory, the ***required*** options are `--zarrDir`, `--outDir`, and `--numLevels`. `--baseScaleKey` defaults to 0. Since the output will be written into the Zarr directory, use the same directory for `--zarrDir` and `--outDir`. +Example usage: +``` +python -m polus.images.formats.pyramid_generator_3d --subCmd Py3D --zarrDir /path/to/zarr/array --outDir /path/to/zarr/array --baseScaleKey 0 --numLevels 2 +``` + +#### From image collection +When generating directly from an image collection, the current tool firsts calls the volume generation routine first to generate Zarr array, from which 3D pyramid is subsequently generated. Thus, all options required for `Vol` subcommand are required in addition to the required options of `Py3D` (excluding `--zarrDir`). +Together, the ***required*** options are `--inpDir`, `--filePattern`, `--groupBy`, `--outDir`, `--outImgName`, `--numLevels`. `--baseScaleKey` defaults to 0. +Example usage: +``` +python -m polus.images.formats.pyramid_generator_3d --subCmd Py3D --inpDir /path/to/input/images --filePattern img_r{r:ddd}_c{c:ddd}.ome.tif --groupBy c --outDir /path/to/output --outImgName test_output --baseScaleKey 0 --numLevels 2 +``` + +## Testing with real data + +1. install ome-zarr +``` +pip install ome-zarr +``` + +2. download an example dataset +``` +cd +ome_zarr download https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/1884807.zarr +``` + +More dataset can be found [here](https://www.openmicroscopy.org/2020/11/04/zarr-data.html). To download other dataset, replace the link in the command above with other links copied from the "S3-entrypoint" column from the website. + +3. modify .zarray file under `/1884807.zarr/0`, i.e., delete the first element in "chunks" and "shape", which correspond to the T dimension. + +4. run the following command: +``` +python -m polus.images.formats.pyramid_generator_3d --subCmd Py3D --zarrDir /1884807.zarr --outDir /1884807.zarr --baseScaleKey 0 --numLevels 2 +``` +Pyramid data will be written into the same folder. + +## Building + +To build the Docker image for the tool, run `./build-docker.sh`. + +## Install WIPP Plugin + +If WIPP is running, navigate to the plugins page and add a new plugin. Paste the +contents of `plugin.json` into the pop-up window and submit. diff --git a/formats/pyramid-generator-3d-tool/VERSION b/formats/pyramid-generator-3d-tool/VERSION new file mode 100644 index 000000000..44bf4db83 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/VERSION @@ -0,0 +1 @@ +0.1.1-dev0 diff --git a/formats/pyramid-generator-3d-tool/build-docker.sh b/formats/pyramid-generator-3d-tool/build-docker.sh new file mode 100755 index 000000000..d38722be1 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/build-docker.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Change the name of the tool here +tool_dir="formats/" +tool_name="pyramid-generator-3d-tool" + +# The version is read from the VERSION file +version=$("] +readme = "README.md" +packages = [{include = "polus", from = "src"}] + +[tool.poetry.dependencies] +python = ">=3.9,<3.12" +bfio = {version = ">=2.3.3,<3.0", extras = ["all"]} +filepattern = ">=2.0.4,<3.0" +typer = "0.7.0" +argolid = "^0.0.6" +lxml = "^5.3.0" + +[tool.poetry.group.dev.dependencies] +bump2version = "^1.0.1" +pytest = "^8.3" +pytest-sugar = "^1.0" +pytest-xdist = "^3.6" +pre-commit = "^3.8" +requests = "^2.32.3" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/formats/pyramid-generator-3d-tool/pyramidgenerator3d.cwl b/formats/pyramid-generator-3d-tool/pyramidgenerator3d.cwl new file mode 100644 index 000000000..8c924cdbf --- /dev/null +++ b/formats/pyramid-generator-3d-tool/pyramidgenerator3d.cwl @@ -0,0 +1,55 @@ +class: CommandLineTool +cwlVersion: v1.2 + +inputs: + subCmd: + inputBinding: + prefix: --subCmd + type: string + zarrDir: + inputBinding: + prefix: --zarrDir + type: Directory? + inpDir: + inputBinding: + prefix: --inpDir + type: Directory + filePattern: + inputBinding: + prefix: --filePattern + type: string? + groupBy: + inputBinding: + prefix: --groupBy + type: string? + outDir: + inputBinding: + prefix: --outDir + type: Directory + outImgName: + inputBinding: + prefix: --outImgName + type: string? + baseScaleKey: + inputBinding: + prefix: --baseScaleKey + type: int? + numLevels: + inputBinding: + prefix: --numLevels + type: int? + +outputs: + outDir: + outputBinding: + glob: $(inputs.outDir.basename) + type: Directory + +requirements: + DockerRequirement: + dockerPull: polusai/pyramid-generator-3d-tool:0.1.1-dev0 + InitialWorkDirRequirement: + listing: + - entry: $(inputs.outDir) + writable: true + InlineJavascriptRequirement: {} diff --git a/formats/pyramid-generator-3d-tool/run-plugin.sh b/formats/pyramid-generator-3d-tool/run-plugin.sh new file mode 100755 index 000000000..5676c3d02 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/run-plugin.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +version=$( SubCommand: + """Parse cmd type and set custom context object. + + set ctx.obj["sub_cmd"] according to the subcommand value, such that we can + check the validity of the parameters for the subcommand. + Args: + ctx (typer.Context): typer context object + value (str): passed in parameter value + """ + ctx.ensure_object(dict) + ctx.obj["sub_cmd"] = "Py3D" if value == SubCommand.Py3D else "Vol" + return value + + +def _camal_case(s: str) -> str: + """Convert string to camel case. + + Args: + s (str): input string + + Returns: + str: camel case string + """ + s_ = s.split("_") + return s_[0] + "".join(word.capitalize() for word in s_[1:]) + + +def vol_option_callback( + ctx: typer.Context, param: typer.CallbackParam, value: typing.Any +): + """Determine validity of Vol options if using Vol subcommand. + + Args: + ctx (typer.Context): typer context object + value (str): passed in parameter value + """ + if ctx.obj["sub_cmd"].startswith("Vol"): + if not value: + raise typer.BadParameter( + f"--{_camal_case(param.name)} are required for volume generation." + ) + if param.name == "group_by": + ctx.obj["group_by"] = value + return value + + +def inp_dir_callback(ctx: typer.Context, value: typing.Union[pathlib.Path, None]): + """Determine validity of input directory if using Vol subcommand. + + Args: + ctx (typer.Context): typer context object + value (pathlib.Path): passed in parameter value + """ + if ctx.obj["sub_cmd"].startswith("Vol"): + if not value: + raise typer.BadParameter( + "Input directory is required for volume generation." + ) + if not value.exists(): + raise typer.BadParameter("Input directory does not exist.") + if not value.is_dir(): + raise typer.BadParameter("Input directory is not a directory.") + if not os.access(value, os.R_OK): + raise typer.BadParameter("Input directory is not readable.") + + return value.resolve() if value else value + + +def py3d_option_callback( + ctx: typer.Context, param: typer.CallbackParam, value: typing.Any +): + """Determine validity of parameter if using Py3D subcommand. + + Args: + ctx (typer.Context): typer context object + value (int): passed in parameter value + """ + if ctx.obj["sub_cmd"] == "Py3D": + if not value: + raise typer.BadParameter( + f"--{_camal_case(param.name)} is required for 3D pyramid generation." + ) + return value + + +def zarr_dir_callback(ctx: typer.Context, value: typing.Union[pathlib.Path, None]): + """Determine validity of zarr directory if using Py3D subcommand. + + Args: + ctx (typer.Context): typer context object + value (pathlib.Path): passed in parameter value + """ + if ctx.obj["sub_cmd"] == "Py3D": + if value: # use zarr directory + if not value.exists(): + raise typer.BadParameter("Zarr directory does not exist.") + if not value.is_dir(): + raise typer.BadParameter("Zarr directory is not a directory.") + if not os.access(value, os.R_OK): + raise typer.BadParameter("Zarr directory is not readable.") + else: # None value, use inpDir instead + # change context label + ctx.obj["sub_cmd"] = "Vol_Py3D" + logger.info( + "Zarr dir not provided, using inpDir to perform volume " + "generation first, and then 3D pyramid generation." + ) + + # fully resolve path + return value.resolve() if value else value + + +@app.command() +def main( + ctx: typer.Context, + sub_cmd: SubCommand = typer.Option( + ..., + "--subCmd", + help="Subcommand to run. Choose 'Py3D' for 3D pyramid generation or 'Vol' for volume generation.", + callback=sub_cmd_callback, + ), + zarr_dir: pathlib.Path = typer.Option( + None, + "--zarrDir", + help=( + "Directory containing the input zarr files for 3D pyramid generation. " + "If not provided, inpDir needs to be provided." + ), + callback=zarr_dir_callback, + ), + inp_dir: pathlib.Path = typer.Option( + None, + "--inpDir", + help="Directory containing the input images for 3D pyramid generation.", + callback=inp_dir_callback, + ), + file_pattern: str = typer.Option( + None, + "--filePattern", + help="File pattern for selecting images for 3D pyramid generation.", + callback=vol_option_callback, + ), + group_by: GroupBy = typer.Option( + None, + "--groupBy", + help="Image dimension, e.g., 'z', to group images for 3D pyramid generation.", + callback=vol_option_callback, + ), + out_dir: pathlib.Path = typer.Option( + ..., + "--outDir", + help="Output directory.", + exists=True, + file_okay=False, + writable=True, + resolve_path=True, + ), + out_img_name: str = typer.Option( + None, + "--outImgName", + help="Name of the output image name for 3D pyramid generation.", + callback=vol_option_callback, + ), + base_scale_key: int = typer.Option( + 0, + "--baseScaleKey", + help="Base scale key for volume generation.", + ), + num_levels: int = typer.Option( + None, + "--numLevels", + help="Number of levels for volume generation.", + callback=py3d_option_callback, + ), +): + """CLI for the pyramid_generator_3d tool.""" + # for some reason after the callback the sub_cmd only receives None value. + # need to determine type based on stored context custom object. nevertheless + # other param checks are correct based on the sub_cmd value + # seems to be problem specific to using typer callback and enum + sub_cmd = ctx.obj["sub_cmd"] + group_by = ctx.obj.get("group_by", None) + logger.info("Starting pyramid_generator_3d...") + logger.info("subCmd: %s", sub_cmd) + logger.info("zarrDir: %s", zarr_dir) + logger.info("inpDir: %s", inp_dir) + logger.info("groupBy: %s", group_by) + logger.info("filePattern: %s", file_pattern) + logger.info("outDir: %s", out_dir) + logger.info("outImgName: %s", out_img_name) + logger.info("baseScaleKey: %d", base_scale_key) + logger.info("numLevels: %s", num_levels) + + # call argolid + if sub_cmd.startswith("Vol"): + logger.info("Starting Volume Generation...") + gen_volume(inp_dir, group_by, file_pattern, out_dir, out_img_name) + logger.info("Volume generation completed.") + + if sub_cmd.endswith("Py3D"): + logger.info("Starting 3D Pyramid Generation...") + # use out_dir if zarr_dir is None. volume generation outputs zarr to out_dir + zarr_dir = zarr_dir if zarr_dir else out_dir / out_img_name + gen_py3d(zarr_dir, base_scale_key, num_levels) + logger.info("3D pyramid generation completed.") + + +if __name__ == "__main__": + app() diff --git a/formats/pyramid-generator-3d-tool/src/polus/images/formats/pyramid_generator_3d/pyramid_generator_3d.py b/formats/pyramid-generator-3d-tool/src/polus/images/formats/pyramid_generator_3d/pyramid_generator_3d.py new file mode 100644 index 000000000..0ffef7252 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/src/polus/images/formats/pyramid_generator_3d/pyramid_generator_3d.py @@ -0,0 +1,58 @@ +"""pyramid_generator_3d.""" +from enum import Enum +from pathlib import Path + +from argolid import PyramidGenerator3D, VolumeGenerator + + +class SubCommand(str, Enum): + """SubCommand.""" + + Py3D = "Py3D" # Perform 3D pyramid generation from zarr arrays or from image collection + Vol = "Vol" # Perform volume generation from image collection + + +class GroupBy(str, Enum): + """GroupBy.""" + + t = "t" + z = "z" + c = "c" + + +def gen_volume( + inp_dir: Path, + group_by: str, + file_pattern: str, + out_dir: Path, + out_img_name: str, +): + """Generate volume.using argolid. + + Args: + inp_dir (Path): input directory + group_by (str): image dimension to group by + file_pattern (str): file pattern to search for images + out_dir (Path): output directory + out_img_name (str): output image name + """ + volume_gen = VolumeGenerator( + str(inp_dir), group_by, file_pattern, str(out_dir), out_img_name + ) + volume_gen.generate_volume() + + +def gen_py3d( + zarr_dir: Path, + base_scale_key: int, + num_levels: int, +): + """Generate 3d pyramid using argolid. + + Args: + zarr_dir (Path): path to zarr arrays + base_scale_key (int): base scale key + num_levels (int): number of levels for pyramid + """ + pyramid_gen = PyramidGenerator3D(zarr_dir, base_scale_key) + pyramid_gen.generate_pyramid(num_levels) diff --git a/formats/pyramid-generator-3d-tool/tests/__init__.py b/formats/pyramid-generator-3d-tool/tests/__init__.py new file mode 100644 index 000000000..fa09603d8 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for pyramid_generator_3d.""" diff --git a/formats/pyramid-generator-3d-tool/tests/test_tool.py b/formats/pyramid-generator-3d-tool/tests/test_tool.py new file mode 100644 index 000000000..370ca1d28 --- /dev/null +++ b/formats/pyramid-generator-3d-tool/tests/test_tool.py @@ -0,0 +1,262 @@ +"""Testing the Command Line Tool.""" + +import faulthandler +import logging +import shutil +import typing +from pathlib import Path + +import pytest +import requests +from polus.images.formats.pyramid_generator_3d.__main__ import app +from typer.testing import CliRunner + +faulthandler.enable() +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +_OPTION_NAMES_VOL = [ + "--subCmd", + "--inpDir", + "--filePattern", + "--groupBy", + "--outDir", + "--outImgName", +] +_OPTION_NAMES_PY3D = [ + "--subCmd", + "--zarrDir", + "--outDir", + "--baseScaleKey", + "--numLevels", +] +_OPTION_NAMES_VOL_PY3D = _OPTION_NAMES_VOL + _OPTION_NAMES_PY3D[-2:] + +OPTION_NAMES_VOL = tuple(_OPTION_NAMES_VOL) +OPTION_NAMES_PY3D = tuple(_OPTION_NAMES_PY3D) +OPTION_NAMES_VOL_PY3D = tuple(_OPTION_NAMES_VOL_PY3D) +BAD_PARAM_TEST_COUNTER = 0 + + +def _get_real_img(path2save: Path, filename=None) -> Path: + """Download a real image from the internet. + + Args: + path2save (Path): path to save the image + + Returns: + Path : path to the downloaded image + """ + # Download the data if it doesn't exist + URL = "https://github.com/usnistgov/WIPP/raw/master/data/PyramidBuilding/inputCollection/" + filename = "img_r001_c001.ome.tif" if filename is None else filename + if not (path2save / filename).exists(): + content = requests.get(URL + filename, timeout=10.0).content + (path2save / filename).open("wb").write(content) + + return path2save / filename + + +@pytest.fixture +def gen_data_path() -> typing.Generator[Path, None, None]: + """Generate a temporary path holding test data.""" + data_path = Path("data") + data_path.mkdir(parents=True, exist_ok=True) + + yield data_path + + # delete the temporary path + shutil.rmtree(data_path) + + +@pytest.fixture +def gen_image_collection_path( + gen_data_path, +) -> typing.Generator[typing.Tuple[Path, Path], None, None]: + """Generate input and output path for image collection test.""" + data_path = gen_data_path + inp_dir = data_path / "input/image_collection" + inp_dir.mkdir(parents=True, exist_ok=True) + + out_dir = data_path / "output/image_collection" + out_dir.mkdir(parents=True, exist_ok=True) + + yield inp_dir, out_dir + + # delete the input and output path + shutil.rmtree(inp_dir) + shutil.rmtree(out_dir) + + +@pytest.fixture +def gen_image_collection( + gen_image_collection_path, +) -> typing.Generator[typing.Tuple[Path, Path, str, str], None, None]: + """Create an image collection.""" + inp_dir, out_dir = gen_image_collection_path + + img_paths = [] + for r in range(1, 5): # r = 1,2,3,4 + for c in range(1, 5): # c = 1,2,3,4 + img_path = _get_real_img(inp_dir, f"img_r{r:03d}_c{c:03d}.ome.tif") + img_paths.append(img_path) + + file_pattern = "img_r001_c{c:ddd}.ome.tif" + out_img_name = "output_img" + yield inp_dir, out_dir, file_pattern, out_img_name + + # delete the image + for img_path in img_paths: + img_path.unlink() + + +@pytest.fixture +def default_params() -> typing.Generator[typing.Tuple[str, int, int], None, None]: + """Return default params for group_by, base_scale_key, num_levels.""" + yield "c", 0, 2 + + +def test_cli(gen_image_collection, default_params): + """Test the command line.""" + inp_dir, out_dir, file_pattern, out_img_name = gen_image_collection + group_by, base_scale_key, num_levels = default_params + runner = CliRunner() + + # do test with Vol + param_list = [ + "--subCmd", + "Vol", + "--inpDir", + inp_dir, + "--filePattern", + file_pattern, + "--groupBy", + group_by, + "--outDir", + out_dir, + "--outImgName", + out_img_name, + ] + result = runner.invoke(app, param_list) + assert result.exit_code == 0 + # check presence of .zarray file + assert Path(out_dir / out_img_name / "0" / ".zarray").exists() + + # do test with Py3D, using previous output as input + param_list = [ + "--subCmd", + "Py3D", + "--zarrDir", + out_dir / out_img_name, + "--outDir", + out_dir / out_img_name, + "--baseScaleKey", + base_scale_key, + "--numLevels", + num_levels, + ] + result = runner.invoke(app, param_list) + assert result.exit_code == 0 + + # check presence of .zarray file + for level in range(1, num_levels + 1): + assert Path(out_dir / out_img_name / f"{level}" / ".zarray").exists() + + # remove all file and folders in out_dir + for item in out_dir.iterdir(): + if item.is_file(): + item.unlink() + else: + shutil.rmtree(item) + + # test for Py3D with no zarrDir but inpDir provided + param_list = [ + "--subCmd", + "Py3D", + "--inpDir", + inp_dir, + "--filePattern", + file_pattern, + "--groupBy", + group_by, + "--outDir", + out_dir, + "--outImgName", + out_img_name, + "--baseScaleKey", + base_scale_key, + "--numLevels", + num_levels, + ] + assert result.exit_code == 0 + + +@pytest.fixture() +def complete_param(gen_image_collection, default_params): + """Generate complete params.""" + inp_dir, out_dir, file_pattern, out_img_name = gen_image_collection + group_by, base_scale_key, num_levels = default_params + OPTIONS = [ + "--zarrDir", + "--inpDir", + "--filePattern", + "--groupBy", + "--outDir", + "--outImgName", + "--baseScaleKey", + "--numLevels", + ] + option_values = [ + out_dir, + inp_dir, + file_pattern, + group_by, + out_dir, + out_img_name, + base_scale_key, + num_levels, + ] + return dict(zip(OPTIONS, option_values)) + + +def gen_bad_params(sub_cmd, option_names): + """Generate bad params for Vol subcommand.""" + lst_tmp = [] + lst = [] + key_lst = option_names + missing_names_lst = [] + for i, key in enumerate(key_lst): + lst.append((lst_tmp, sub_cmd)) + # create a string that formats the current time + missing_names_lst.append(f"subCmd={sub_cmd}, missing {key} ") + if i == len(key_lst) - 1: # skip the last key + break + lst_tmp.append(key) + return lst, missing_names_lst + + +def combine_bad_params(): + """Combine bad params into a single set of tests.""" + v1, n1 = gen_bad_params("Vol", OPTION_NAMES_VOL) + v2, n2 = gen_bad_params("Py3D", OPTION_NAMES_PY3D) + v3, n3 = gen_bad_params("Py3D", OPTION_NAMES_VOL_PY3D) + return v1 + v2 + v3, n1 + n2 + n3 + + +@pytest.mark.parametrize( + "bad_params, sub_cmd", + argvalues=combine_bad_params()[0], + ids=combine_bad_params()[1], +) +def test_bad_params(bad_params, sub_cmd, complete_param): + """Test the command line with bad params for Vol subcommand.""" + runner = CliRunner() + param_name_list = bad_params + param_list = [] + for param_name in param_name_list: + if param_name == "--subCmd": + param_list += [param_name, sub_cmd] + else: + param_list += [param_name, complete_param[param_name]] + result = runner.invoke(app, param_list) + assert result.exit_code != 0