diff --git a/transforms/images/polus-intensity-projection-plugin/.bumpversion.cfg b/transforms/images/polus-intensity-projection-plugin/.bumpversion.cfg new file mode 100644 index 000000000..cd840e61e --- /dev/null +++ b/transforms/images/polus-intensity-projection-plugin/.bumpversion.cfg @@ -0,0 +1,16 @@ +[bumpversion] +current_version = 0.1.10 +commit = False +tag = False + +[bumpversion:file:VERSION] + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:plugin.json] + +[bumpversion:file:ict.yaml] + +[bumpversion:file:intensityprojectionplugin.cwl] diff --git a/transforms/images/polus-intensity-projection-plugin/Dockerfile b/transforms/images/polus-intensity-projection-plugin/Dockerfile index df26cd506..210cf4e88 100644 --- a/transforms/images/polus-intensity-projection-plugin/Dockerfile +++ b/transforms/images/polus-intensity-projection-plugin/Dockerfile @@ -1,19 +1,14 @@ - -FROM polusai/bfio:2.1.9 - -COPY VERSION / - -ARG EXEC_DIR="/opt/executables" -ARG DATA_DIR="/data" - -RUN mkdir -p ${EXEC_DIR} \ - && mkdir -p ${DATA_DIR}/inputs \ - && mkdir ${DATA_DIR}/outputs - -COPY src/requirements.txt ${EXEC_DIR}/ -RUN pip3 install -r ${EXEC_DIR}/requirements.txt --no-cache-dir - -COPY src ${EXEC_DIR}/ +# Build from repo root (monorepo) or from this tool directory — both work. +FROM polusai/bfio:2.5.0 +ENV EXEC_DIR="/opt/executables" POLUS_IMG_EXT=".ome.tif" POLUS_TAB_EXT=".csv" POLUS_LOG="INFO" WORKDIR ${EXEC_DIR} - -ENTRYPOINT ["python3", "/opt/executables/main.py"] +ENV TOOL_DIR="transforms/images/polus-intensity-projection-plugin" +RUN mkdir -p image-tools +COPY . ${EXEC_DIR}/image-tools +RUN pip3 install -U pip setuptools wheel \ + && python3 -c 'import sys; assert sys.version_info>=(3,11)' \ + && R="${EXEC_DIR}/image-tools" && M="$R/$TOOL_DIR" \ + && if [ -f "$M/pyproject.toml" ]; then pip3 install --no-cache-dir "$M"; \ + else pip3 install --no-cache-dir "$R"; fi +ENTRYPOINT ["python3", "-m", "main"] +CMD ["--help"] diff --git a/transforms/images/polus-intensity-projection-plugin/README.md b/transforms/images/polus-intensity-projection-plugin/README.md index f212eabda..d8bf2e5f2 100644 --- a/transforms/images/polus-intensity-projection-plugin/README.md +++ b/transforms/images/polus-intensity-projection-plugin/README.md @@ -2,16 +2,16 @@ This WIPP plugin calculates the volumetric intensity projection of a 3d image along the z-direction(depth). The following types of intensity projections have -been implemented: +been implemented: -1. Maximum: -2. Minimum -3. Mean +1. Maximum: +2. Minimum +3. Mean ``` Example: Consider an input image of size: (x,y,z). If the user chooses the option `max`, the code will calculate the value of the maximum intensity value along the z-direction for every x,y position. The output will be a 2d image of -size (x,y). +size (x,y). ``` Contact [Gauhar Bains](mailto:gauhar.bains@labshare.org) for more information. @@ -37,4 +37,3 @@ This plugin takes one input argument and one output argument: | `--inpDir` | Input image collection to be processed | Input | collection | | `--projectionType` | Type of volumetric intensity projection | Input | string | | `--outDir` | Output collection | Output | collection | - diff --git a/transforms/images/polus-intensity-projection-plugin/VERSION b/transforms/images/polus-intensity-projection-plugin/VERSION index 1a030947e..9767cc98e 100644 --- a/transforms/images/polus-intensity-projection-plugin/VERSION +++ b/transforms/images/polus-intensity-projection-plugin/VERSION @@ -1 +1 @@ -0.1.9 +0.1.10 diff --git a/transforms/images/polus-intensity-projection-plugin/build-docker.sh b/transforms/images/polus-intensity-projection-plugin/build-docker.sh index b6e5a9e8e..b018e0e09 100755 --- a/transforms/images/polus-intensity-projection-plugin/build-docker.sh +++ b/transforms/images/polus-intensity-projection-plugin/build-docker.sh @@ -1,4 +1,4 @@ #!/bin/bash version=$(=2.5.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/transforms/images/polus-intensity-projection-plugin/src/__init__.py b/transforms/images/polus-intensity-projection-plugin/src/__init__.py new file mode 100644 index 000000000..9739f9602 --- /dev/null +++ b/transforms/images/polus-intensity-projection-plugin/src/__init__.py @@ -0,0 +1 @@ +"""Intensity projection plugin.""" diff --git a/transforms/images/polus-intensity-projection-plugin/src/main.py b/transforms/images/polus-intensity-projection-plugin/src/main.py index 959633148..2cab1ea82 100644 --- a/transforms/images/polus-intensity-projection-plugin/src/main.py +++ b/transforms/images/polus-intensity-projection-plugin/src/main.py @@ -1,186 +1,258 @@ -import argparse, logging, time, sys, os, traceback -from bfio.bfio import BioReader, BioWriter +"""CLI for volumetric intensity projections (max, min, mean) on ome.tif collections.""" +from __future__ import annotations + +import argparse +import logging +import os +import sys +import traceback +from collections.abc import Callable +from concurrent.futures import ProcessPoolExecutor +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from typing import Any + import numpy as np -from preadator import ProcessManager +from bfio.bfio import BioReader +from bfio.bfio import BioWriter + +logger = logging.getLogger("main") # x,y size of the 3d image chunk to be loaded into memory -tile_size = 1024 +TILE_SIZE = 1024 # depth of the 3d image chunk -tile_size_z = 128 +TILE_SIZE_Z = 128 -def max_min_projection(br, bw, x_range, y_range, **kwargs): - """ Calculate the max or min intensity - projection of a section of the input image. - Args: - br (BioReader object): input file object - bw (BioWriter object): output file object - x_range (tuple): x-range of the img to be processed - y_range (tuple): y-range of the img to be processed +def max_min_projection( # noqa: PLR0913 + br: BioReader, + bw: BioWriter, + x_range: tuple[int, int], + y_range: tuple[int, int], + max_workers: int, + *, + method: Callable[..., Any] = np.max, +) -> None: + """Max or min intensity projection over Z for one XY tile. - Returns: - image array : Max IP of the input volume + Args: + br: Open input reader. + bw: Open output writer. + x_range: X bounds ``(x, x_max)``. + y_range: Y bounds ``(y, y_max)``. + max_workers: Worker count for bfio I/O. + method: NumPy reducer over the Z stack (e.g. :func:`numpy.max`). """ - with ProcessManager.thread(): - br.max_workers = ProcessManager._active_threads - bw.max_workers = ProcessManager._active_threads - - # set projection method - if not 'method' in kwargs: - method = np.max - else: - method = kwargs['method'] + br.max_workers = max_workers + bw.max_workers = max_workers - # x,y range of the volume - x, x_max = x_range - y, y_max = y_range + # x,y range of the volume + x, x_max = x_range + y, y_max = y_range - # iterate over depth - for ind, z in enumerate(range(0,br.Z,tile_size_z)): - z_max = min([br.Z,z+tile_size_z]) - if ind == 0: - out_image = method(br[y:y_max,x:x_max,z:z_max,0,0], axis=2) - else: - out_image = np.dstack((out_image, method(br[y:y_max,x:x_max,z:z_max,0,0], axis=2))) + # iterate over depth + out_image: np.ndarray | None = None + for z in range(0, br.Z, TILE_SIZE_Z): + z_max = min(br.Z, z + TILE_SIZE_Z) + tile = method(br[y:y_max, x:x_max, z:z_max, 0, 0], axis=2) + out_image = tile if out_image is None else np.dstack((out_image, tile)) - # output image - bw[y:y_max,x:x_max,0:1,0,0] = method(out_image, axis=2) + if out_image is None: + return + # output image + bw[y:y_max, x:x_max, 0:1, 0, 0] = method(out_image, axis=2) -def mean_projection(br, bw, x_range, y_range, **kwargs): - """ Calculate the mean intensity projection +def mean_projection( + br: BioReader, + bw: BioWriter, + x_range: tuple[int, int], + y_range: tuple[int, int], + max_workers: int, +) -> None: + """Mean intensity projection over Z for one XY tile. Args: - br (BioReader object): input file object - bw (BioWriter object): output file object - x_range (tuple): x-range of the img to be processed - y_range (tuple): y-range of the img to be processed - - Returns: - image array : Mean IP of the input volume + br: Open input reader. + bw: Open output writer. + x_range: X bounds ``(x, x_max)``. + y_range: Y bounds ``(y, y_max)``. + max_workers: Worker count for bfio I/O. """ - with ProcessManager.thread(): - br.max_workers = ProcessManager._active_threads - bw.max_workers = ProcessManager._active_threads - - # x,y range of the volume - x, x_max = x_range - y, y_max = y_range - - # iterate over depth - out_image = np.zeros((y_max-y,x_max-x),dtype=np.float64) - for ind, z in enumerate(range(0,br.Z,tile_size_z)): - z_max = min([br.Z,z+tile_size_z]) - - out_image += np.sum(br[y:y_max,x:x_max,z:z_max,...].astype(np.float64),axis=2).squeeze() - - # output image - out_image /= br.Z - bw[y:y_max,x:x_max,0:1,0,0] = out_image.astype(br.dtype) - - -def process_image(input_img_path, output_img_path, projection, method): - - # Grab a free process - with ProcessManager.process(): - - # initalize biowriter and bioreader - with BioReader(input_img_path, max_workers=ProcessManager._active_threads) as br, \ - BioWriter(output_img_path, metadata=br.metadata, max_workers=ProcessManager._active_threads) as bw: - - # output image is 2d - bw.Z = 1 - - # iterate along the x,y direction - for x in range(0,br.X,tile_size): - x_max = min([br.X,x+tile_size]) - - for y in range(0,br.Y,tile_size): - y_max = min([br.Y,y+tile_size]) - - ProcessManager.submit_thread(projection,br,bw,(x, x_max),(y, y_max),method=method) - - ProcessManager.join_threads() + br.max_workers = max_workers + bw.max_workers = max_workers + + # x,y range of the volume + x, x_max = x_range + y, y_max = y_range + + # iterate over depth + out_image = np.zeros((y_max - y, x_max - x), dtype=np.float64) + for z in range(0, br.Z, TILE_SIZE_Z): + z_max = min(br.Z, z + TILE_SIZE_Z) + out_image += np.sum( + br[y:y_max, x:x_max, z:z_max, ...].astype(np.float64), + axis=2, + ).squeeze() + + # output image + out_image /= br.Z + bw[y:y_max, x:x_max, 0:1, 0, 0] = out_image.astype(br.dtype) + + +def process_image( + input_img_path: str | Path, + output_img_path: str | Path, + projection: Callable[..., None], + method: Callable[..., Any] | None, +) -> None: + """Run the chosen projection over all XY tiles for one image pair.""" + max_workers = max(1, (os.cpu_count() or 4) // 2) + + with BioReader(input_img_path, max_workers=max_workers) as br, BioWriter( + output_img_path, + metadata=br.metadata, + max_workers=max_workers, + ) as bw: + # output image is 2d + bw.Z = 1 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [] + for x in range(0, br.X, TILE_SIZE): + x_max = min(br.X, x + TILE_SIZE) + for y in range(0, br.Y, TILE_SIZE): + y_max = min(br.Y, y + TILE_SIZE) + if method is not None: + futures.append( + executor.submit( + projection, + br, + bw, + (x, x_max), + (y, y_max), + max_workers, + method=method, + ), + ) + else: + futures.append( + executor.submit( + projection, + br, + bw, + (x, x_max), + (y, y_max), + max_workers, + ), + ) + for fut in futures: + fut.result() + + +def run_projection( + input_dir: str, + output_dir: str, + projection: Callable[..., None], + method: Callable[..., Any] | None, +) -> None: + """Process every ``.ome.tif`` in ``input_dir`` and write to ``output_dir``.""" + input_path = Path(input_dir) + output_path = Path(output_dir) + input_files = [ + f.name + for f in input_path.iterdir() + if f.is_file() and f.name.endswith(".ome.tif") + ] - -def main(inpDir, outDir, projection, method): - - # images in the input directory - inpDir_files = os.listdir(inpDir) - inpDir_files = [filename for filename in inpDir_files if filename.endswith('.ome.tif')] - - # Surround with try/finally for proper error catching try: - for image_name in inpDir_files: - - input_img_path = os.path.join(inpDir, image_name) - output_img_path = os.path.join(outDir, image_name) - - ProcessManager.submit_process(process_image, input_img_path, output_img_path, projection, method) - - ProcessManager.join_processes() - - except Exception: + with ProcessPoolExecutor() as executor: + futures = [ + executor.submit( + process_image, + str(input_path / image_name), + str(output_path / image_name), + projection, + method, + ) + for image_name in input_files + ] + for fut in futures: + fut.result() + except Exception: # noqa: BLE001 + # Workers may raise arbitrary image/bfio errors; log full traceback for + # operators. traceback.print_exc() - + sys.exit(1) finally: - # Exit the program - logger.info('Exiting the workflow..') - sys.exit() - -if __name__=="__main__": + logger.info("Exiting the workflow..") + + +if __name__ == "__main__": # Initialize the logger - logging.basicConfig(format='%(asctime)s - %(name)-8s - %(levelname)-8s - %(message)s', - datefmt='%d-%b-%y %H:%M:%S') - logger = logging.getLogger("main") + logging.basicConfig( + format="%(asctime)s - %(name)-8s - %(levelname)-8s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", + ) logger.setLevel(logging.INFO) - ''' Argument parsing ''' logger.info("Parsing arguments...") - parser = argparse.ArgumentParser(prog='main', description='Calculate volumetric intensity projections') + parser = argparse.ArgumentParser( + prog="main", + description="Calculate volumetric intensity projections", + ) # Input arguments - parser.add_argument('--inpDir', dest='inpDir', type=str, - help='Input image collection to be processed by this plugin', required=True) - parser.add_argument('--projectionType', dest='projectionType', type=str, - help='Type of volumetric intensity projection', required=True) + parser.add_argument( + "--inpDir", + dest="inpDir", + type=str, + help="Input image collection to be processed by this plugin", + required=True, + ) + parser.add_argument( + "--projectionType", + dest="projectionType", + type=str, + help="Type of volumetric intensity projection", + required=True, + ) # Output arguments - parser.add_argument('--outDir', dest='outDir', type=str, - help='Output collection', required=True) + parser.add_argument( + "--outDir", + dest="outDir", + type=str, + help="Output collection", + required=True, + ) # Parse the arguments args = parser.parse_args() - inpDir = args.inpDir - if (Path.is_dir(Path(args.inpDir).joinpath('images'))): - # switch to images folder if present - fpath = str(Path(args.inpDir).joinpath('images').absolute()) - logger.info('inpDir = {}'.format(inpDir)) - projectionType = args.projectionType - logger.info('projectionType = {}'.format(projectionType)) - outDir = args.outDir - logger.info('outDir = {}'.format(outDir)) - - # initialize projection function - if projectionType == 'max': - projection = max_min_projection - method = np.max - elif projectionType == 'min': - projection = max_min_projection - method = np.min - elif projectionType == 'mean': - projection = mean_projection - method = None - - ProcessManager.init_processes('main','intensity') - - main(inpDir, outDir, projection, method) - - - - - - - + input_dir = args.inpDir + if Path.is_dir(Path(args.inpDir).joinpath("images")): + input_dir = str(Path(args.inpDir).joinpath("images").absolute()) + logger.info("inpDir = %s", input_dir) + projection_type = args.projectionType + logger.info("projectionType = %s", projection_type) + output_dir = args.outDir + logger.info("outDir = %s", output_dir) + + # initialize projection function (single callable type for max/min vs mean) + projection_fn: Callable[..., None] + reducer: Callable[..., Any] | None + if projection_type == "max": + projection_fn = max_min_projection + reducer = np.max + elif projection_type == "min": + projection_fn = max_min_projection + reducer = np.min + elif projection_type == "mean": + projection_fn = mean_projection + reducer = None + else: + logger.error("Unknown projectionType: %s", projection_type) + sys.exit(1) + + run_projection(input_dir, output_dir, projection_fn, reducer) diff --git a/transforms/images/polus-intensity-projection-plugin/src/requirements.txt b/transforms/images/polus-intensity-projection-plugin/src/requirements.txt index 28c54f578..c7e08baed 100644 --- a/transforms/images/polus-intensity-projection-plugin/src/requirements.txt +++ b/transforms/images/polus-intensity-projection-plugin/src/requirements.txt @@ -1,2 +1 @@ -bfio==2.0.5 -preadator==0.2.0 \ No newline at end of file +bfio>=2.5.0