diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..5ff36a2b0 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,27 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Build documentation with MkDocs +#mkdocs: +# configuration: mkdocs.yml + +# Optionally build your docs in additional formats such as PDF +formats: + - pdf + - epub + +# Optionally set the version of Python and requirements required to build your docs +python: + version: 3.7 + +# Use conda to to manage environment +conda: + environment: requirements.conda.yml diff --git a/README.rst b/README.rst index 5c4614acb..6e994bbcd 100644 --- a/README.rst +++ b/README.rst @@ -36,7 +36,7 @@ pip :: - pip install -r requirements_dev.txt + pip install -r requirements.txt conda ----- diff --git a/docs/usage.rst b/docs/usage.rst index 2c7c1b5c9..c54253bfe 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -48,4 +48,4 @@ Utils utils.misc ^^^^^^^^^^ .. automodule:: tiatoolbox.utils.misc - :members: save_yaml, split_path_name_ext, grab_files_from_dir + :members: save_yaml, split_path_name_ext, grab_files_from_dir, imwrite diff --git a/requirements.conda.yml b/requirements.conda.yml index 219efe7e0..077d12776 100644 --- a/requirements.conda.yml +++ b/requirements.conda.yml @@ -4,19 +4,20 @@ channels: - conda-forge - defaults dependencies: - - python=3.6 - - setuptools==45.1.0 - - Click==7.0 - - cython=0.29.15 - - h5py=2.8.0 - - matplotlib-base=3.1.3 - - numpy=1.18.1 - - opencv=4.2 - - pillow=7.0.0 - - pip=20.0.2 - - pyyaml=5.3.1 - - requests=2.23.0 + - python=3.7 + - setuptools<=45.1.0 + - Click + - cython + - h5py + - matplotlib + - numpy + - opencv>=4.0 + - pillow + - pip + - pyyaml + - requests - pathos==0.2.5 + - pixman<0.38.0 + - openslide - pip: - openslide-python==1.1.1 - diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..f213b159b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Click>=7.0 +numpy +pillow +matplotlib +setuptools<=45.1.0 +opencv-python>=4.0 +pathos==0.2.5 +openslide-python==1.1.1 +pyyaml diff --git a/requirements.win64.conda.yml b/requirements.win64.conda.yml new file mode 100644 index 000000000..54bedc7ac --- /dev/null +++ b/requirements.win64.conda.yml @@ -0,0 +1,22 @@ +name: tiatoolbox +channels: + - conda + - conda-forge + - defaults +dependencies: + - python=3.7 + - setuptools<=45.1.0 + - Click + - cython + - h5py + - matplotlib + - numpy + - opencv>=4.0 + - pillow + - pip + - pyyaml + - requests + - pathos==0.2.5 + - pixman<0.38.0 + - pip: + - openslide-python diff --git a/requirements_dev.txt b/requirements_dev.txt index eb180fe85..d15369ca1 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -7,11 +7,15 @@ tox==3.14.0 coverage==5.1 Sphinx==1.8.5 twine==1.14.0 -Click==7.0 -setuptools==45.1.0 +Click>=7.0 +setuptools<=45.1.0 pytest==5.4.2 pytest-runner==5.2 -opencv-python==4.2.0.34 +opencv-python>=4.0 pathos==0.2.5 openslide-python==1.1.1 pytest-cov==2.9.0 +numpy +pillow +matplotlib +pyyaml diff --git a/setup.py b/setup.py index ba156103a..858273b1a 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,14 @@ requirements = [ "Click>=7.0", + "numpy", + "pillow", + "matplotlib", + "setuptools<=45.1.0", + "opencv-python>=4.0", + "pathos==0.2.5", + "openslide-python==1.1.1", + "pyyaml", ] setup_requirements = [ diff --git a/tests/test_tiatoolbox.py b/tests/test_tiatoolbox.py index 9cf4d8d30..79cadd10a 100644 --- a/tests/test_tiatoolbox.py +++ b/tests/test_tiatoolbox.py @@ -4,6 +4,7 @@ import pytest from tiatoolbox.dataloader.slide_info import slide_info +from tiatoolbox.dataloader import wsireader from tiatoolbox import utils from tiatoolbox import cli from tiatoolbox import __version__ @@ -12,6 +13,7 @@ import requests import os import pathlib +import numpy as np @pytest.fixture @@ -47,7 +49,7 @@ def _response_svs(request): if not pathlib.Path.is_file(svs_file_path): r = requests.get( "http://openslide.cs.cmu.edu/download/openslide-testdata" - "/Hamamatsu/CMU-1.ndpi" + "/Aperio/CMU-1.svs" ) with open(svs_file_path, "wb") as f: f.write(r.content) @@ -64,7 +66,7 @@ def test_slide_info(_response_ndpi, _response_svs): """pytest for slide_info as a python function""" file_types = ("*.ndpi", "*.svs", "*.mrxs") files_all = utils.misc.grab_files_from_dir( - input_path=str(pathlib.Path(r".")), file_types=file_types, + input_path=str(pathlib.Path(__file__).parent), file_types=file_types, ) slide_params = slide_info(input_path=files_all, workers=2) @@ -72,6 +74,35 @@ def test_slide_info(_response_ndpi, _response_svs): utils.misc.save_yaml(slide_param, slide_param["file_name"] + ".yaml") +def test_wsireader_slide_info(_response_svs): + """pytest for slide_info in WSIReader class as a python function""" + file_types = ("*.svs",) + files_all = utils.misc.grab_files_from_dir( + input_path=str(pathlib.Path(__file__).parent), file_types=file_types, + ) + input_dir, file_name, ext = utils.misc.split_path_name_ext(str(files_all[0])) + wsi_obj = wsireader.WSIReader(input_dir, file_name + ext) + slide_param = wsi_obj.slide_info() + utils.misc.save_yaml(slide_param, slide_param["file_name"] + ".yaml") + + +def test_wsireader_read_region(_response_svs): + """pytest for read region as a python function""" + file_types = ("*.svs",) + files_all = utils.misc.grab_files_from_dir( + input_path=str(pathlib.Path(__file__).parent), file_types=file_types, + ) + input_dir, file_name, ext = utils.misc.split_path_name_ext(str(files_all[0])) + wsi_obj = wsireader.WSIReader(input_dir, file_name + ext) + level = 0 + region = [13000, 17000, 15000, 19000] + im_region = wsi_obj.read_region(region[0], region[1], region[2], region[3], level) + im_region = im_region[:, :, 0:3] + assert isinstance(im_region, np.ndarray) + assert im_region.dtype == "uint8" + assert im_region.shape == (2000, 2000, 3) + + def test_command_line_help_interface(): """Test the CLI help""" runner = CliRunner() @@ -106,3 +137,29 @@ def test_command_line_slide_info(_response_ndpi, _response_svs): ) assert slide_info_result.exit_code == 0 + + +def test_command_line_read_region(_response_ndpi): + """Test the Read Region CLI.""" + runner = CliRunner() + read_region_result = runner.invoke( + cli.main, + [ + "read-region", + "--wsi_input", + str(pathlib.Path(__file__).parent.joinpath("CMU-1.ndpi")), + "--level", + "0", + "--mode", + "save", + "--region", + "0", "0", "2000", "2000", + "--output_path", + str(pathlib.Path(__file__).parent.joinpath("im_region.jpg")), + ], + ) + + assert read_region_result.exit_code == 0 + assert os.path.isfile( + str(pathlib.Path(__file__).parent.joinpath("im_region.jpg")) + ) diff --git a/tiatoolbox/cli.py b/tiatoolbox/cli.py index fc532692d..14d7c0342 100644 --- a/tiatoolbox/cli.py +++ b/tiatoolbox/cli.py @@ -2,9 +2,12 @@ from tiatoolbox import __version__ from tiatoolbox import dataloader from tiatoolbox import utils + import sys import click import os +import pathlib +from PIL import Image def version_msg(): @@ -67,5 +70,50 @@ def slide_info(wsi_input, output_dir, file_types, mode, workers=None): ) +@main.command() +@click.option("--wsi_input", help="Path to WSI file") +@click.option( + "--output_path", + help="Path to output file to save the image region in save mode," + " default=wsi_input_dir/../im_region", +) +@click.option( + "--region", + type=int, + nargs=4, + help="image region in the whole slide image to read" "default=0 0 2000 2000", +) +@click.option( + "--level", + type=int, + default=0, + help="pyramid level to read the image, " "default=0", +) +@click.option( + "--mode", + default="show", + help="'show' to display image region or 'save' to save at the output path" + ", default=show", +) +def read_region(wsi_input, region, level, output_path, mode): + """Reads a region in an whole slide image as specified""" + if all(region): + region = [0, 0, 2000, 2000] + + input_dir, file_name, ext = utils.misc.split_path_name_ext(full_path=wsi_input) + if output_path is None and mode == "save": + output_path = str(pathlib.Path(input_dir).joinpath("../im_region.jpg")) + wsi_obj = dataloader.wsireader.WSIReader( + input_dir=input_dir, file_name=file_name + ext + ) + im_region = wsi_obj.read_region(region[0], region[1], region[2], region[3], level) + if mode == "show": + im_region = Image.fromarray(im_region) + im_region.show() + + if mode == "save": + utils.misc.imwrite(output_path, im_region) + + if __name__ == "__main__": sys.exit(main()) # pragma: no cover diff --git a/tiatoolbox/dataloader/slide_info.py b/tiatoolbox/dataloader/slide_info.py index dc7e7a867..2186e0c17 100644 --- a/tiatoolbox/dataloader/slide_info.py +++ b/tiatoolbox/dataloader/slide_info.py @@ -11,9 +11,9 @@ def slide_info(input_path, output_dir=None): to run slide_info in parallel Args: - input_path: Path to whole slide image - output_dir: Path to output directory to save the output - workers: num of cpu cores to use for multiprocessing + input_path (str): Path to whole slide image + output_dir (str): Path to output directory to save the output + workers (int): num of cpu cores to use for multiprocessing Returns: list: list of dictionary Whole Slide meta information diff --git a/tiatoolbox/dataloader/wsireader.py b/tiatoolbox/dataloader/wsireader.py index be4ccae27..6942907c5 100644 --- a/tiatoolbox/dataloader/wsireader.py +++ b/tiatoolbox/dataloader/wsireader.py @@ -68,7 +68,9 @@ def __init__( def slide_info(self): """WSI meta data reader + Args: + self (WSIReader): Returns: dict: dictionary containing meta information @@ -105,3 +107,35 @@ def slide_info(self): } return param + + def read_region(self, start_w, start_h, end_w, end_h, level=0): + """Read a region in whole slide image + + Args: + start_w (int): starting point in x-direction (along width) + start_h (int): starting point in y-direction (along height) + end_w (int): end point in x-direction (along width) + end_h (int): end point in y-direction (along height) + level (int): pyramid level to read the image + + Returns: + img_array : ndarray of size MxNx3 + M=end_h-start_h, N=end_w-start_w + + Examples: + >>> from tiatoolbox.dataloader import wsireader + >>> from matplotlib import pyplot as plt + >>> wsi_obj = wsireader.WSIReader(input_dir="./", file_name="CMU-1.ndpi") + >>> level = 0 + >>> region = [13000, 17000, 15000, 19000] + >>> im_region = wsi_obj.read_region( + ... region[0], region[1], region[2], region[3], level) + >>> plt.imshow(im_region) + + """ + openslide_obj = self.openslide_obj + im_region = openslide_obj.read_region( + [start_w, start_h], level, [end_w - start_w, end_h - start_h] + ) + im_region = np.asarray(im_region) + return im_region diff --git a/tiatoolbox/utils/misc.py b/tiatoolbox/utils/misc.py index c7ac0ce26..d25aadaff 100644 --- a/tiatoolbox/utils/misc.py +++ b/tiatoolbox/utils/misc.py @@ -1,5 +1,6 @@ """Miscellaneous small functions repeatedly used in tiatoolbox""" import os +import cv2 import pathlib import yaml @@ -60,6 +61,7 @@ def grab_files_from_dir(input_path, file_types=("*.jpg", "*.png", "*.tif")): def save_yaml(input_dict, output_path="output.yaml"): """Save dictionary as yaml + Args: input_dict (dict): A variable of type 'dict' output_path (str, pathlib.Path): Path to save the output file @@ -75,3 +77,24 @@ def save_yaml(input_dict, output_path="output.yaml"): """ with open(pathlib.Path(output_path), "w") as yaml_file: yaml.dump(input_dict, yaml_file) + + +def imwrite(image_path, cv_im): + """Write a numpy array to an image + + Args: + image_path (str, pathlib.Path): file path (including extension) to save image + cv_im (ndarray): image array of dtype uint8, MxNx3 + + Returns: + + Examples: + >>> from tiatoolbox import utils + >>> import numpy as np + >>> utils.misc.imwrite('BlankImage.jpg', + ... np.ones([100, 100, 3]).astype('uint8')*255) + + """ + if isinstance(image_path, pathlib.Path): + image_path = str(image_path) + cv2.imwrite(image_path, cv2.cvtColor(cv_im, cv2.COLOR_RGB2BGR))