diff --git a/docs/assets/screenshots/generate_volume.png b/docs/assets/screenshots/generate_volume.png new file mode 100644 index 0000000000000000000000000000000000000000..02ee3b73643630316aec6f00c8d6e00866a06ec7 Binary files /dev/null and b/docs/assets/screenshots/generate_volume.png differ diff --git a/docs/assets/screenshots/gui-annotation_tool.gif b/docs/assets/screenshots/gui-annotation_tool.gif new file mode 100644 index 0000000000000000000000000000000000000000..c106bcd09bbb0701bed2dbe127133c651f10b59a Binary files /dev/null and b/docs/assets/screenshots/gui-annotation_tool.gif differ diff --git a/docs/assets/screenshots/operations-edge_fade_after.png b/docs/assets/screenshots/operations-edge_fade_after.png new file mode 100644 index 0000000000000000000000000000000000000000..cd1977d5929188f3a78394fc666633886592656a Binary files /dev/null and b/docs/assets/screenshots/operations-edge_fade_after.png differ diff --git a/docs/assets/screenshots/operations-edge_fade_before.png b/docs/assets/screenshots/operations-edge_fade_before.png new file mode 100644 index 0000000000000000000000000000000000000000..d0ce709ef28adc5844c5179375f0d36f0097f322 Binary files /dev/null and b/docs/assets/screenshots/operations-edge_fade_before.png differ diff --git a/docs/assets/screenshots/viz-fade_mask.gif b/docs/assets/screenshots/viz-fade_mask.gif new file mode 100644 index 0000000000000000000000000000000000000000..3ef2aba7b79af9306c1917a75d92d6cd3474d6ff Binary files /dev/null and b/docs/assets/screenshots/viz-fade_mask.gif differ diff --git a/docs/index.md b/docs/index.md index 5a3ca251190a1d906061f4bd23871d19e599185e..ea6111ebbe6f4a893f5aaaf65c99f42418f43386 100644 --- a/docs/index.md +++ b/docs/index.md @@ -131,8 +131,21 @@ You can find us at Gitlab: [https://lab.compute.dtu.dk/QIM/tools/qim3d](https://lab.compute.dtu.dk/QIM/tools/qim3d ) -This project is licensed under the MIT License. +This project is licensed under the [MIT License](https://lab.compute.dtu.dk/QIM/tools/qim3d/-/blob/main/LICENSE). +### Contributors + +Below is a list of contributors to the project, arranged in chronological order of their first commit to the repository: + +| Author | Commits | First commit | +|:--------------------------|----------:|-------------:| +| Felipe Delestro | 170 | 2023-05-12 | +| Stefan Engelmann Jensen | 29 | 2023-06-29 | +| Oskar Kristoffersen | 15 | 2023-07-05 | +| Christian Kento Rasmussen | 19 | 2024-02-01 | +| Alessia Saccardo | 7 | 2024-02-19 | +| David Grundfest | 4 | 2024-04-12 | +| Anna Bøgevang Ekner | 3 | 2024-04-18 | ## Support diff --git a/docs/notebooks/Untitled.ipynb b/docs/notebooks/Untitled.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..c946f3b248ee0bf392a9239a3b744dccd6193555 --- /dev/null +++ b/docs/notebooks/Untitled.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "0b73f2d8", + "metadata": {}, + "outputs": [], + "source": [ + "import qim3d" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "73db6886", + "metadata": {}, + "outputs": [], + "source": [ + "vol = qim3d.examples.bone_128x128x128" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "22d86d4d", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "2fefeafbd89c4f9fa5a08dc1a5d503d1", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(interactive(children=(IntSlider(value=64, description='Z', max=127), Output()), layout=Layout(a…" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "qim3d.viz.orthogonal(vol)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/blob_detection.ipynb b/docs/notebooks/blob_detection.ipynb index d5490b4bac824fcd66be93af99815a94a1ed482e..86f6d94691fed612a44439db08d3382fa2d47c6b 100644 --- a/docs/notebooks/blob_detection.ipynb +++ b/docs/notebooks/blob_detection.ipynb @@ -4,15 +4,7 @@ "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:root:Could not load CuPy: No module named 'cupy'\n" - ] - } - ], + "outputs": [], "source": [ "import qim3d" ] @@ -30,7 +22,9 @@ "source": [ "This notebook shows how to do **blob detection** in a 3D volume using the `qim3d` library. \n", "\n", - "Blob detection is done by initializing a `qim3d.processing.Blob` object, and then calling the `qim3d.processing.Blob.detect` method. The `qim3d.processing.Blob.detect` method detects blobs by using the Difference of Gaussian (DoG) blob detection method, and returns an array `blobs` with the blobs found in the volume stored as `(p, r, c, radius)`. Subsequently, a binary mask of the volume can be retrieved with the `qim3d.processing.get_mask` method, in which the found blobs are marked as `True`." + "Blob detection is done by using the `qim3d.processing.blob_detection` method, which detects blobs by using the Difference of Gaussian (DoG) blob detection method, and returns two arrays:\n", + "- `blobs`: The blobs found in the volume stored as `(p, r, c, radius)`\n", + "- `binary_volume`: A binary mask of the volume with the blobs marked as `True`" ] }, { @@ -49,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 2, "metadata": {}, "outputs": [ { @@ -69,7 +63,7 @@ "<Figure size 1000x200 with 5 Axes>" ] }, - "execution_count": 4, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -97,9 +91,16 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 3, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Bright background selected, volume will be inverted.\n" + ] + }, { "name": "stdout", "output_type": "stream", @@ -109,31 +110,29 @@ } ], "source": [ - "# Initialize blob detector\n", - "blob_detector = qim3d.processing.Blob(\n", - " background = \"bright\", \n", - " min_sigma = 1, \n", - " max_sigma = 8, \n", - " threshold = 0.001, \n", - " overlap = 0.1\n", + "# Detect blobs, and get binary mask\n", + "blobs, mask = qim3d.processing.blob_detection(\n", + " cement_filtered,\n", + " min_sigma=1,\n", + " max_sigma=8,\n", + " threshold=0.001,\n", + " overlap=0.1,\n", + " background=\"bright\"\n", " )\n", "\n", - "# Detect blobs in filtered volume\n", - "blobs = blob_detector.detect(vol = cement_filtered)\n", - "\n", "# Number of blobs found\n", "print(f'Number of blobs found in the volume: {len(blobs)} blobs')" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "4d128089a7e545d7a3fd8a4607c02085", + "model_id": "54d5e4864544453695c7d87287aa7cf9", "version_major": 2, "version_minor": 0 }, @@ -141,32 +140,32 @@ "interactive(children=(IntSlider(value=64, description='Slice', max=127), Output()), layout=Layout(align_items=…" ] }, - "execution_count": 13, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Visualize blobs on slices of cement volume\n", - "qim3d.viz.detection.circles(blobs, cement, show = True)" + "qim3d.viz.detection.circles(blobs, cement, alpha = 0.8, show = True, color = 'red')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "**Get binary mask of detected blobs**" + "**Binary mask of detected blobs**" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2d8e5b955da948de8f2bea5bb19000b9", + "model_id": "11fd726e79a246c98948ec54b3c752e2", "version_major": 2, "version_minor": 0 }, @@ -174,16 +173,13 @@ "interactive(children=(IntSlider(value=64, description='Slice', max=127), Output()), layout=Layout(align_items=…" ] }, - "execution_count": 14, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Get binary mask of detected blobs\n", - "mask = blob_detector.get_mask()\n", - "\n", - "# Visualize mask\n", + "# Visualize binary mask\n", "qim3d.viz.slicer(mask)" ] } diff --git a/docs/processing.md b/docs/processing.md index 550cb1001b6a51777e4790f41a3af58e30b87046..cd68efaba653a77ae1f938ca088e17e8b0f81ae7 100644 --- a/docs/processing.md +++ b/docs/processing.md @@ -5,14 +5,26 @@ Here, we provide functionalities designed specifically for 3D image analysis and ::: qim3d.processing options: members: + - test_blob_detection + - blob_detection - structure_tensor - local_thickness - get_3d_cc - - Pipeline - - Blob + - gaussian + - median + - maximum + - minimum + - tophat + +::: qim3d.processing.Pipeline + options: + members: + - append ::: qim3d.processing.operations options: members: - remove_background - watershed + - edge_fade + - fade_mask diff --git a/docs/releases.md b/docs/releases.md index 50372a1de82bd8730db03743793883544172125a..b6e158634f9908fc2901e353a8b4d83580b0beea 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -9,9 +9,17 @@ As the library is still in its early development stages, **there may be breaking And remember to keep your pip installation [up to date](/qim3d/#upgrade) so that you have the latest features! +### v0.3.6 (30/05/2024) +- Refactoring for performance improvement +- Welcome message for the CLI +- Introduction of `qim3d.processing.fade_mask` 🎉 + + ### v0.3.5 (27/05/2024) - Added runtime and memory usage in the documentation - Introduction of `qim3d.utils.generate_volume` 🎉 +- Introduction of `preview` CLI 🎉 + ### v0.3.4 (22/05/2024) - Documentation for `qim3d.viz.plot_cc` diff --git a/docs/utils.md b/docs/utils.md index f86074f1b190446ad487521be309c3aa17515761..6425ef1ad29341cdeda23ac62c413ea604e47e1d 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -5,6 +5,7 @@ A set of tools to ease managment of the system, with the common needs for large ::: qim3d.utils.img options: members: + - generate_volume - overlay_rgb_images ::: qim3d.utils.system diff --git a/docs/viz.md b/docs/viz.md index 3ddd34a1c58617de014cf57e6479ad32fb826e72..691d31b496d472bd1725300f59ab7019d3155e4f 100644 --- a/docs/viz.md +++ b/docs/viz.md @@ -12,6 +12,7 @@ The `qim3d` library aims to provide easy ways to explore and get insights from v - vectors - plot_cc - colormaps + - interactive_fade_mask ::: qim3d.viz.colormaps options: diff --git a/mkdocs.yml b/mkdocs.yml index df02fdf5b76c459bb6035d146a84d808f00a8c5f..02e9b73b47a1dfdff8e6d6ed7831683ae8ec7a30 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ markdown_extensions: - admonition - attr_list - md_in_html + - tables - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.details @@ -84,5 +85,5 @@ plugins: show_root_full_path: true show_object_full_path: true show_symbol_type_heading: true - show_symbol_type_toc: true + show_symbol_type_toc: false separate_signature: true \ No newline at end of file diff --git a/qim3d/__init__.py b/qim3d/__init__.py index 73714bf3730e46ae2be9e91c6062acb52a66de4f..c89b6fd07af948ddd1d126632e2713dd1c6e6f0e 100644 --- a/qim3d/__init__.py +++ b/qim3d/__init__.py @@ -1,14 +1,27 @@ +"""qim3d: A Python package for 3D image processing and visualization. + +The qim3d library is designed to make it easier to work with 3D imaging data in Python. +It offers a range of features, including data loading and manipulation, + image processing and filtering, visualization of 3D data, and analysis of imaging results. + +Documentation available at https://platform.qim.dk/qim3d/ + +""" + +__version__ = "0.3.6" + import logging logging.basicConfig(level=logging.ERROR) -from qim3d import io -from qim3d import gui -from qim3d import viz -from qim3d import utils -from qim3d import models -from qim3d import processing +from . import io +from . import gui +from . import viz +from . import utils +from . import processing + +# Commenting out models because it takes too long to import +# from . import models -__version__ = "0.3.2" examples = io.ImgExamples() io.logger.set_level_info() diff --git a/qim3d/gui/annotation_tool.py b/qim3d/gui/annotation_tool.py index 571f53cce62630d280935f4086f210fe972af7ca..da66452814755d3af254a076b4c40063f4a66321 100644 --- a/qim3d/gui/annotation_tool.py +++ b/qim3d/gui/annotation_tool.py @@ -10,9 +10,14 @@ Or launched from a python script ```python import qim3d -app = qim3d.gui.annotation_tool.Interface() -app.launch() +vol = qim3d.examples.NT_128x128x128 +annotation_tool = qim3d.gui.annotation_tool.Interface() + +# We can directly pass the image we loaded to the interface +app = annotation_tool.launch(vol[0]) ``` + + """ import getpass diff --git a/qim3d/io/loading.py b/qim3d/io/loading.py index bc9688320c3662fcb87ff1263e4f2a90070c122e..28638c80995f46ce162c8f5027de4bc6eb7b3671 100644 --- a/qim3d/io/loading.py +++ b/qim3d/io/loading.py @@ -31,7 +31,7 @@ from qim3d.io.logger import log from qim3d.utils.internal_tools import get_file_size, sizeof, stringify_path from qim3d.utils.system import Memory -dask.config.set(scheduler="processes") # Dask parallel goes brrrrr +dask.config.set(scheduler="processes") class DataLoader: @@ -772,6 +772,16 @@ def load( """ Load data from the specified file or directory. + Supported formats: + + - `Tiff` (including file stacks) + - `HDF5` + - `TXRM`/`TXM`/`XRM` + - `NIfTI` + - `PIL` (including file stacks) + - `VOL`/`VGI` + - `DICOM` + Args: path (str or os.PathLike): The path to the file or directory. virtual_stack (bool, optional): Specifies whether to use virtual @@ -856,14 +866,17 @@ class ImgExamples: shell_225x128x128 (numpy.ndarray): A 3D volume of a shell. Tip: - Call `qim3d.examples.<name>` to access the image examples easily as this class is instantiated when importing `qim3d` + Simply call `qim3d.examples.<name>` to access the image examples. Example: ```python import qim3d - data = qim3d.examples.blobs_256x256 + vol = qim3d.examples.shell_225x128x128 + qim3d.viz.slices(vol, n_slices=15) ``` +  + """ diff --git a/qim3d/processing/__init__.py b/qim3d/processing/__init__.py index 34dea0c4f0c556184277de549acf22dc9ad3f59e..79d9acf5f85d48c285d04424380ac455069330c6 100644 --- a/qim3d/processing/__init__.py +++ b/qim3d/processing/__init__.py @@ -1,6 +1,8 @@ +"Testing docstring" + from .local_thickness_ import local_thickness from .structure_tensor_ import structure_tensor +from .detection import blob_detection from .filters import * -from .detection import * from .operations import * from .cc import get_3d_cc diff --git a/qim3d/processing/detection.py b/qim3d/processing/detection.py index b1397e4e36f7f5c4f49f67b9dc3f1e80fb0b6592..c5743a1501f8d2e20edda9f22c06b1eee76a48ad 100644 --- a/qim3d/processing/detection.py +++ b/qim3d/processing/detection.py @@ -1,60 +1,40 @@ +""" Blob detection using Difference of Gaussian (DoG) method """ + import numpy as np from qim3d.io.logger import log from skimage.feature import blob_dog -__all__ = ["Blob"] - -class Blob: +def blob_detection( + vol: np.ndarray, + background: str = "dark", + min_sigma: float = 1, + max_sigma: float = 50, + sigma_ratio: float = 1.6, + threshold: float = 0.5, + overlap: float = 0.5, + threshold_rel: float = None, + exclude_border: bool = False, +) -> np.ndarray: """ - Extract blobs from a volume using Difference of Gaussian (DoG) method - """ - def __init__( - self, - background="dark", - min_sigma=1, - max_sigma=50, - sigma_ratio=1.6, - threshold=0.5, - overlap=0.5, - threshold_rel=None, - exclude_border=False, - ): - """ - Initialize the blob detection object - - Args: - background: 'dark' if background is darker than the blobs, 'bright' if background is lighter than the blobs - min_sigma: The minimum standard deviation for Gaussian kernel - max_sigma: The maximum standard deviation for Gaussian kernel - sigma_ratio: The ratio between the standard deviation of Gaussian Kernels - threshold: The absolute lower bound for scale space maxima. Reduce this to detect blobs with lower intensities. - overlap: The fraction of area of two blobs that overlap - threshold_rel: The relative lower bound for scale space maxima - exclude_border: If True, exclude blobs that are too close to the border of the image - """ - self.background = background - self.min_sigma = min_sigma - self.max_sigma = max_sigma - self.sigma_ratio = sigma_ratio - self.threshold = threshold - self.overlap = overlap - self.threshold_rel = threshold_rel - self.exclude_border = exclude_border - self.vol_shape = None - self.blobs = None - - def detect(self, vol): - """ - Detect blobs in the volume - - Args: - vol: The volume to detect blobs in - - Returns: - blobs: The blobs found in the volume as (p, r, c, radius) - - Example: + Extract blobs from a volume using Difference of Gaussian (DoG) method, and retrieve a binary volume with the blobs marked as True + + Args: + vol: The volume to detect blobs in + background: 'dark' if background is darker than the blobs, 'bright' if background is lighter than the blobs + min_sigma: The minimum standard deviation for Gaussian kernel + max_sigma: The maximum standard deviation for Gaussian kernel + sigma_ratio: The ratio between the standard deviation of Gaussian Kernels + threshold: The absolute lower bound for scale space maxima. Reduce this to detect blobs with lower intensities + overlap: The fraction of area of two blobs that overlap + threshold_rel: The relative lower bound for scale space maxima + exclude_border: If True, exclude blobs that are too close to the border of the image + + Returns: + blobs: The blobs found in the volume as (p, r, c, radius) + binary_volume: A binary volume with the blobs marked as True + + Example: ```python import qim3d @@ -62,8 +42,9 @@ class Blob: vol = qim3d.examples.cement_128x128x128 vol_blurred = qim3d.processing.gaussian(vol, sigma=2) - # Initialize Blob detector - blob_detector = qim3d.processing.Blob( + # Detect blobs, and get binary mask + blobs, mask = qim3d.processing.blob_detection( + vol_blurred, min_sigma=1, max_sigma=8, threshold=0.001, @@ -71,88 +52,63 @@ class Blob: background="bright" ) - # Detect blobs - blobs = blob_detector.detect(vol_blurred) - - # Visualize results - qim3d.viz.circles(blobs,vol,alpha=0.8,color='blue') + # Visualize detected blobs + qim3d.viz.circles(blobs, vol, alpha=0.8, color='blue') ```  - """ - self.vol_shape = vol.shape - if self.background == "bright": - log.info("Bright background selected, volume will be inverted.") - vol = np.invert(vol) - - blobs = blob_dog( - vol, - min_sigma=self.min_sigma, - max_sigma=self.max_sigma, - sigma_ratio=self.sigma_ratio, - threshold=self.threshold, - overlap=self.overlap, - threshold_rel=self.threshold_rel, - exclude_border=self.exclude_border, - ) - blobs[:, 3] = blobs[:, 3] * np.sqrt(3) # Change sigma to radius - self.blobs = blobs - return self.blobs - - def get_mask(self): - ''' - Retrieve a binary volume with the blobs marked as True - - Returns: - binary_volume: A binary volume with the blobs marked as True - - Example: - ```python - import qim3d - - # Get data - vol = qim3d.examples.cement_128x128x128 - vol_blurred = qim3d.processing.gaussian(vol, sigma=2) - - # Initialize Blob detector - blob_detector = qim3d.processing.Blob( - min_sigma=1, - max_sigma=8, - threshold=0.001, - overlap=0.1, - background="bright" - ) - - - # Detect blobs - blobs = blob_detector.detect(vol_blurred) - # Get mask and visualize - mask = blob_detector.get_mask() + ```python + # Visualize binary mask qim3d.viz.slicer(mask) ```  - ''' - binary_volume = np.zeros(self.vol_shape, dtype=bool) - - for z, y, x, radius in self.blobs: - # Calculate the bounding box around the blob - z_start = max(0, int(z - radius)) - z_end = min(self.vol_shape[0], int(z + radius) + 1) - y_start = max(0, int(y - radius)) - y_end = min(self.vol_shape[1], int(y + radius) + 1) - x_start = max(0, int(x - radius)) - x_end = min(self.vol_shape[2], int(x + radius) + 1) - - z_indices, y_indices, x_indices = np.indices((z_end - z_start, y_end - y_start, x_end - x_start)) - z_indices += z_start - y_indices += y_start - x_indices += x_start - - # Calculate distances from the center of the blob to voxels within the bounding box - dist = np.sqrt((x_indices - x)**2 + (y_indices - y)**2 + (z_indices - z)**2) + """ - binary_volume[z_start:z_end, y_start:y_end, x_start:x_end][dist <= radius] = True + if background == "bright": + log.info("Bright background selected, volume will be inverted.") + vol = np.invert(vol) + + blobs = blob_dog( + vol, + min_sigma=min_sigma, + max_sigma=max_sigma, + sigma_ratio=sigma_ratio, + threshold=threshold, + overlap=overlap, + threshold_rel=threshold_rel, + exclude_border=exclude_border, + ) + + # Change sigma to radius + blobs[:, 3] = blobs[:, 3] * np.sqrt(3) + + # Create binary mask of detected blobs + vol_shape = vol.shape + binary_volume = np.zeros(vol_shape, dtype=bool) + + for z, y, x, radius in blobs: + # Calculate the bounding box around the blob + z_start = max(0, int(z - radius)) + z_end = min(vol_shape[0], int(z + radius) + 1) + y_start = max(0, int(y - radius)) + y_end = min(vol_shape[1], int(y + radius) + 1) + x_start = max(0, int(x - radius)) + x_end = min(vol_shape[2], int(x + radius) + 1) + + z_indices, y_indices, x_indices = np.indices( + (z_end - z_start, y_end - y_start, x_end - x_start) + ) + z_indices += z_start + y_indices += y_start + x_indices += x_start - return binary_volume + # Calculate distances from the center of the blob to voxels within the bounding box + dist = np.sqrt( + (x_indices - x) ** 2 + (y_indices - y) ** 2 + (z_indices - z) ** 2 + ) + binary_volume[z_start:z_end, y_start:y_end, x_start:x_end][ + dist <= radius + ] = True + return blobs, binary_volume diff --git a/qim3d/processing/filters.py b/qim3d/processing/filters.py index 354214c4d7e699de2c934cfbfed0710dd51e1c23..fc2d89134ded623e2e478f24f75a4e3735224169 100644 --- a/qim3d/processing/filters.py +++ b/qim3d/processing/filters.py @@ -265,11 +265,13 @@ def minimum(vol, **kwargs): def tophat(vol, **kwargs): """ - Remove background from the volume + Remove background from the volume. + Args: vol: The volume to remove background from radius: The radius of the structuring element (default: 3) background: color of the background, 'dark' or 'bright' (default: 'dark'). If 'bright', volume will be inverted. + Returns: vol: The volume with background removed """ diff --git a/qim3d/processing/local_thickness_.py b/qim3d/processing/local_thickness_.py index 5e7b9416b53b0180623ffd60dd7ebe5f50396c99..f0de7e9954b4c4a3e5a7a7a3218bfbb0a6df0faa 100644 --- a/qim3d/processing/local_thickness_.py +++ b/qim3d/processing/local_thickness_.py @@ -5,8 +5,8 @@ import numpy as np from typing import Optional from skimage.filters import threshold_otsu from qim3d.io.logger import log -from qim3d.viz import local_thickness as viz_local_thickness - +#from qim3d.viz import local_thickness as viz_local_thickness +import qim3d def local_thickness( image: np.ndarray, @@ -96,6 +96,6 @@ def local_thickness( # Visualize the local thickness if requested if visualize: - display(viz_local_thickness(image, local_thickness, **viz_kwargs)) + display(qim3d.viz.local_thickness(image, local_thickness, **viz_kwargs)) return local_thickness diff --git a/qim3d/processing/operations.py b/qim3d/processing/operations.py index b932c7f8d2d6cf599e04b4ac53a95005f2e16907..dfaeb42e41d2bb88f54f5d1ce5db1ecfff4a5d30 100644 --- a/qim3d/processing/operations.py +++ b/qim3d/processing/operations.py @@ -1,7 +1,8 @@ import numpy as np -import qim3d.processing.filters as filters import scipy import skimage + +import qim3d.processing.filters as filters from qim3d.io.logger import log @@ -105,4 +106,82 @@ def watershed( num_labels = len(np.unique(labeled_volume))-1 log.info(f"Total number of objects found: {num_labels}") - return labeled_volume, num_labels \ No newline at end of file + return labeled_volume, num_labels + +def fade_mask( + vol: np.ndarray, + decay_rate: float = 10, + ratio: float = 0.5, + geometry: str = "sphere", + invert=False, + axis: int = 0, + ): + """ + Apply edge fading to a volume. + + Args: + vol (np.ndarray): The volume to apply edge fading to. + decay_rate (float, optional): The decay rate of the fading. Defaults to 10. + ratio (float, optional): The ratio of the volume to fade. Defaults to 0. + geometric (str, optional): The geometric shape of the fading. Can be 'spherical' or 'cylindrical'. Defaults to 'spherical'. + axis (int, optional): The axis along which to apply the fading. Defaults to 0. + + Returns: + np.ndarray: The volume with edge fading applied. + + Example: + ```python + import qim3d + qim3d.viz.vol(vol) + ``` + Image before edge fading has visible artifacts from the support. Which obscures the object of interest. +  + + ```python + import qim3d + vol_faded = qim3d.processing.operations.edge_fade(vol, decay_rate=4, ratio=0.45, geometric='cylindrical') + qim3d.viz.vol(vol_faded) + ``` + Afterwards the artifacts are faded out, making the object of interest more visible for visualization purposes. +  + + """ + if 0 > axis or axis >= vol.ndim: + raise ValueError("Axis must be between 0 and the number of dimensions of the volume") + + # Generate the coordinates of each point in the array + shape = vol.shape + z, y, x = np.indices(shape) + + # Calculate the center of the array + center = np.array([(s - 1) / 2 for s in shape]) + + # Calculate the distance of each point from the center + if geometry == "sphere": + distance = np.linalg.norm([z - center[0], y - center[1], x - center[2]], axis=0) + elif geometry == "cilinder": + distance_list = np.array([z - center[0], y - center[1], x - center[2]]) + # remove the axis along which the fading is not applied + distance_list = np.delete(distance_list, axis, axis=0) + distance = np.linalg.norm(distance_list, axis=0) + else: + raise ValueError("geometric must be 'spherical' or 'cylindrical'") + + # Normalize the distances so that they go from 0 at the center to 1 at the farthest point + max_distance = np.linalg.norm(center) + normalized_distance = distance / (max_distance*ratio) + + # Apply the decay rate + faded_distance = normalized_distance ** decay_rate + + # Invert the distances to have 1 at the center and 0 at the edges + fade_array = 1 - faded_distance + fade_array[fade_array<=0]=0 + + if invert: + fade_array = -(fade_array-1) + + # Apply the fading to the volume + vol_faded = vol * fade_array + + return vol_faded diff --git a/qim3d/utils/cli.py b/qim3d/utils/cli.py index c48e477716322a4d4fb4050862610c2baffa482a..0e783c6dfcc5cc95c18bdf2ba0d1cc1bb2e03e25 100644 --- a/qim3d/utils/cli.py +++ b/qim3d/utils/cli.py @@ -1,40 +1,84 @@ import argparse import webbrowser -import qim3d from qim3d.gui import annotation_tool, data_explorer, iso3d, local_thickness from qim3d.io.loading import DataLoader from qim3d.utils import image_preview +from qim3d import __version__ as version +import qim3d def main(): - parser = argparse.ArgumentParser(description='Qim3d command-line interface.') - subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand') + parser = argparse.ArgumentParser(description="Qim3d command-line interface.") + subparsers = parser.add_subparsers(title="Subcommands", dest="subcommand") # GUIs - gui_parser = subparsers.add_parser('gui', help = 'Graphical User Interfaces.') - - gui_parser.add_argument('--data-explorer', action='store_true', help='Run data explorer.') - gui_parser.add_argument('--iso3d', action='store_true', help='Run iso3d.') - gui_parser.add_argument('--annotation-tool', action='store_true', help='Run annotation tool.') - gui_parser.add_argument('--local-thickness', action='store_true', help='Run local thickness tool.') - gui_parser.add_argument('--host', default='0.0.0.0', help='Desired host.') - gui_parser.add_argument('--platform', action='store_true', help='Use QIM platform address') - gui_parser.add_argument('--no-browser', action='store_true', help='Do not launch browser.') - - # K3D - viz_parser = subparsers.add_parser('viz', help = 'Volumetric visualization.') - viz_parser.add_argument('--source', default=False, help='Path to the image file') - viz_parser.add_argument('--destination', default='k3d.html', help='Path to save html file.') - viz_parser.add_argument('--no-browser', action='store_true', help='Do not launch browser.') + gui_parser = subparsers.add_parser("gui", help="Graphical User Interfaces.") + + gui_parser.add_argument( + "--data-explorer", action="store_true", help="Run data explorer." + ) + gui_parser.add_argument("--iso3d", action="store_true", help="Run iso3d.") + gui_parser.add_argument( + "--annotation-tool", action="store_true", help="Run annotation tool." + ) + gui_parser.add_argument( + "--local-thickness", action="store_true", help="Run local thickness tool." + ) + gui_parser.add_argument("--host", default="0.0.0.0", help="Desired host.") + gui_parser.add_argument( + "--platform", action="store_true", help="Use QIM platform address" + ) + gui_parser.add_argument( + "--no-browser", action="store_true", help="Do not launch browser." + ) + + # K3D + viz_parser = subparsers.add_parser("viz", help="Volumetric visualization.") + viz_parser.add_argument("--source", default=False, help="Path to the image file") + viz_parser.add_argument( + "--destination", default="k3d.html", help="Path to save html file." + ) + viz_parser.add_argument( + "--no-browser", action="store_true", help="Do not launch browser." + ) # Preview - preview_parser = subparsers.add_parser('preview', help= 'Preview of the image in CLI') - preview_parser.add_argument('filename',type = str, metavar = 'FILENAME', help = 'Path to image that will be displayed') - preview_parser.add_argument('--slice',type = int, metavar ='S', default = None, help = 'Specifies which slice of the image will be displayed.\nDefaults to middle slice. If number exceeds number of slices, last slice will be displayed.' ) - preview_parser.add_argument('--axis', type = int, metavar = 'AX', default=0, help = 'Specifies from which axis will be the slice taken. Defaults to 0.') - preview_parser.add_argument('--resolution',type = int, metavar = 'RES', default = 80, help = 'Resolution of displayed image. Defaults to 80.') - preview_parser.add_argument('--absolute_values', action='store_false', help = 'By default set the maximum value to be 255 so the contrast is strong. This turns it off.') + preview_parser = subparsers.add_parser( + "preview", help="Preview of the image in CLI" + ) + preview_parser.add_argument( + "filename", + type=str, + metavar="FILENAME", + help="Path to image that will be displayed", + ) + preview_parser.add_argument( + "--slice", + type=int, + metavar="S", + default=None, + help="Specifies which slice of the image will be displayed.\nDefaults to middle slice. If number exceeds number of slices, last slice will be displayed.", + ) + preview_parser.add_argument( + "--axis", + type=int, + metavar="AX", + default=0, + help="Specifies from which axis will be the slice taken. Defaults to 0.", + ) + preview_parser.add_argument( + "--resolution", + type=int, + metavar="RES", + default=80, + help="Resolution of displayed image. Defaults to 80.", + ) + preview_parser.add_argument( + "--absolute_values", + action="store_false", + help="By default set the maximum value to be 255 so the contrast is strong. This turns it off.", + ) # File Convert preview_parser = subparsers.add_parser('convert', help= 'Convert files to different formats without loading the entire file into memory') @@ -43,9 +87,9 @@ def main(): args = parser.parse_args() - if args.subcommand == 'gui': + if args.subcommand == "gui": arghost = args.host - inbrowser = not args.no_browser # Should automatically open in browser + inbrowser = not args.no_browser # Should automatically open in browser if args.data_explorer: if args.platform: data_explorer.run_interface(arghost) @@ -58,45 +102,77 @@ def main(): else: interface = iso3d.Interface() interface.launch(inbrowser=inbrowser) - + elif args.annotation_tool: if args.platform: annotation_tool.run_interface(arghost) else: interface = annotation_tool.Interface() - interface.launch(inbrowser=inbrowser) + interface.launch(inbrowser=inbrowser) elif args.local_thickness: if args.platform: local_thickness.run_interface(arghost) else: interface = local_thickness.Interface() interface.launch(inbrowser=inbrowser) - - - if args.subcommand == "viz": + elif args.subcommand == "viz": if not args.source: - print ("Please specify a source file using the argument --source") + print("Please specify a source file using the argument --source") return # Load the data - print (f"Loading data from {args.source}") + print(f"Loading data from {args.source}") volume = qim3d.io.load(str(args.source)) - print (f"Done, volume shape: {volume.shape}") + print(f"Done, volume shape: {volume.shape}") # Make k3d plot - print ("\nGenerating k3d plot...") + print("\nGenerating k3d plot...") qim3d.viz.vol(volume, show=False, save=str(args.destination)) - print (f"Done, plot available at <{args.destination}>") + print(f"Done, plot available at <{args.destination}>") if not args.no_browser: print("Opening in default browser...") webbrowser.open_new_tab(args.destination) - if args.subcommand == 'preview': + elif args.subcommand == "preview": image = DataLoader().load(args.filename) - image_preview(image, image_width = args.resolution, axis = args.axis, slice = args.slice, relative_intensity= args.absolute_values) - - if args.subcommand == 'convert': + image_preview( + image, + image_width=args.resolution, + axis=args.axis, + slice=args.slice, + relative_intensity=args.absolute_values, + ) + + elif args.subcommand is None: + welcome_text = ( + "\n" + " _ _____ __ \n" + " ____ _(_)___ ___ |__ /____/ / \n" + " / __ `/ / __ `__ \ /_ </ __ / \n" + "/ /_/ / / / / / / /__/ / /_/ / \n" + "\__, /_/_/ /_/ /_/____/\__,_/ \n" + " /_/ \n" + "\n" + "--- Welcome to qim3d command-line interface ---\n" + "qim3d is a Python package for 3D image processing and visualization.\n" + "For more information, please visit: https://platform.qim.dk/qim3d/\n" + f"Current version of qim3d: {version}\n" + " \n" + "The qim3d command-line interface provides the following subcommands:\n" + "- gui: Graphical User Interfaces\n" + "- viz: Volumetric visualizations of volumes\n" + "- preview: Preview of an volume directly in the terminal\n" + " \n" + "For more information on each subcommand, type 'qim3d <subcommand> --help'.\n" + ) + print(welcome_text) + print("--- Help page for qim3d command-line interface shown below ---\n") + parser.print_help() + print("\n") + + elif args.subcommand == 'convert': qim3d.io.convert(args.input_path, args.output_path) - -if __name__ == '__main__': - main() \ No newline at end of file + + +if __name__ == "__main__": + main() diff --git a/qim3d/utils/img.py b/qim3d/utils/img.py index 13f181faef80baa52dae596969d3d8a8c90e4651..6b91af384311662885897dcd6c5938fd18c67a84 100644 --- a/qim3d/utils/img.py +++ b/qim3d/utils/img.py @@ -82,9 +82,18 @@ def generate_volume( ValueError: If `dtype` is not a valid numpy number type. Example: + ```python import qim3d - vol = qim3d.utils.generate_volume() - qim3d.viz.slices(vol, vmin=0, vmax=255) + vol = qim3d.utils.generate_volume(noise_scale=0.05, threshold=0.4) + qim3d.viz.slices(vol, vmin=0, vmax=255, n_slices=15) + ``` +  + + ```python + qim3d.viz.vol(vol) + ``` + <iframe src="https://platform.qim.dk/k3d/synthetic_volume.html" width="100%" height="500" frameborder="0"></iframe> + """ if not isinstance(final_shape, tuple) or len(final_shape) != 3: diff --git a/qim3d/viz/__init__.py b/qim3d/viz/__init__.py index c11df18022667389276dfafaf077b5579f388801..080a4ebd7a812cb659c5b817d18767a3d287568a 100644 --- a/qim3d/viz/__init__.py +++ b/qim3d/viz/__init__.py @@ -1,10 +1,16 @@ -from .visualizations import plot_metrics -from .img import grid_pred, grid_overview, slices, slicer, orthogonal, vol_masked -from .k3d import vol -from .structure_tensor import vectors -from .local_thickness_ import local_thickness -from .cc import plot_cc -#from .colormaps import objects from . import colormaps - +from .cc import plot_cc from .detection import circles +from .img import ( + grid_overview, + grid_pred, + interactive_fade_mask, + orthogonal, + slicer, + slices, + vol_masked, +) +from .k3d import vol +from .local_thickness_ import local_thickness +from .structure_tensor import vectors +from .visualizations import plot_metrics diff --git a/qim3d/viz/cc.py b/qim3d/viz/cc.py index 7efee4923463ee44419b54443d06896c9cbd8436..8daee7dca7cbeb0f206ac215954521b4564b2393 100644 --- a/qim3d/viz/cc.py +++ b/qim3d/viz/cc.py @@ -3,9 +3,8 @@ import numpy as np from qim3d.io.logger import log from qim3d.processing.cc import CC -from qim3d.viz import slices from qim3d.viz.colormaps import objects as qim3dCmap - +import qim3d def plot_cc( connected_components, @@ -66,18 +65,18 @@ def plot_cc( overlay_crop = overlay[bb] # use cc as mask for overlay_crop, where all values in cc set to 0 should be masked out, cc contains integers overlay_crop = np.where(cc == 0, 0, overlay_crop) - fig = slices(overlay_crop, show=show, **kwargs) + fig = qim3d.viz.slices(overlay_crop, show=show, **kwargs) else: cc = connected_components.get_cc(component, crop=False) overlay_crop = np.where(cc == 0, 0, overlay) - fig = slices(overlay_crop, show=show, **kwargs) + fig = qim3d.viz.slices(overlay_crop, show=show, **kwargs) else: # assigns discrete color map to each connected component if not given if "cmap" not in kwargs: kwargs["cmap"] = qim3dCmap(len(component_indexs)) # Plot the connected component without overlay - fig = slices(connected_components.get_cc(component, crop=crop), show=show, **kwargs) + fig = qim3d.viz.slices(connected_components.get_cc(component, crop=crop), show=show, **kwargs) figs.append(fig) diff --git a/qim3d/viz/colormaps.py b/qim3d/viz/colormaps.py index d72c94d9ec1f104ec605f487b89dbbdf9b5cc609..547e10fb8f1afd0e70b1993d9c73fc7c902f4683 100644 --- a/qim3d/viz/colormaps.py +++ b/qim3d/viz/colormaps.py @@ -10,8 +10,6 @@ from matplotlib.colors import LinearSegmentedColormap from matplotlib import colormaps from skimage import color -from qim3d.io.logger import log - def rearrange_colors(randRGBcolors_old, min_dist = 0.5): # Create new list for re-arranged colors randRGBcolors_new = [randRGBcolors_old.pop(0)] diff --git a/qim3d/viz/detection.py b/qim3d/viz/detection.py index acfffa70c5e09ebc535a5823ffa207da099de779..f6ff1bf102c44106696b2eda97b868e83d4dff67 100644 --- a/qim3d/viz/detection.py +++ b/qim3d/viz/detection.py @@ -1,10 +1,9 @@ import matplotlib.pyplot as plt -from qim3d.viz import slices from qim3d.io.logger import log import numpy as np import ipywidgets as widgets from IPython.display import clear_output, display - +import qim3d def circles(blobs, vol, alpha=0.5, color="#ff9900", **kwargs): """ @@ -16,7 +15,7 @@ def circles(blobs, vol, alpha=0.5, color="#ff9900", **kwargs): Args: blobs (array-like): An array-like object of blobs, where each blob is represented - as a 4-tuple (p, r, c, radius). Usally the result of `qim3d.processing.Blob().detect()` + as a 4-tuple (p, r, c, radius). Usually the result of `qim3d.processing.blob_detection(vol)` vol (array-like): The 3D volume on which to plot the blobs. alpha (float, optional): The transparency of the blobs. Defaults to 0.5. color (str, optional): The color of the blobs. Defaults to "#ff9900". @@ -29,7 +28,7 @@ def circles(blobs, vol, alpha=0.5, color="#ff9900", **kwargs): def _slicer(z_slice): clear_output(wait=True) - fig = slices( + fig = qim3d.viz.slices( vol, n_slices=1, position=z_slice, diff --git a/qim3d/viz/img.py b/qim3d/viz/img.py index 769db8cdbc2832c6cd189b1b887d6a9489bf0b05..f2293565675af6b6fc7cf76b4577d6fbedd2fa22 100644 --- a/qim3d/viz/img.py +++ b/qim3d/viz/img.py @@ -5,14 +5,15 @@ Provides a collection of visualization functions. import math from typing import List, Optional, Union +import dask.array as da import ipywidgets as widgets import matplotlib.pyplot as plt import numpy as np import torch from matplotlib import colormaps from matplotlib.colors import LinearSegmentedColormap -import dask.array as da +import qim3d from qim3d.io.logger import log @@ -265,8 +266,8 @@ def slices( ```python import qim3d - img = qim3d.examples.shell_225x128x128 - qim3d.viz.slices(img, n_slices=15) + vol = qim3d.examples.shell_225x128x128 + qim3d.viz.slices(vol, n_slices=15) ```  """ @@ -557,3 +558,83 @@ def vol_masked(vol, vol_mask, viz_delta=128): vol_masked = background + foreground return vol_masked + +def interactive_fade_mask(vol: np.ndarray, axis: int = 0): + """ Interactive widget for visualizing the effect of edge fading on a 3D volume. + + This can be used to select the best parameters before applying the mask. + + Args: + vol (np.ndarray): The volume to apply edge fading to. + axis (int, optional): The axis along which to apply the fading. Defaults to 0. + + Example: + ```python + import qim3d + vol = qim3d.examples.cement_128x128x128 + qim3d.viz.interactive_fade_mask(vol) + ``` +  + + """ + + # Create the interactive widget + def _slicer(position, decay_rate, ratio, geometry, invert): + fig, axes = plt.subplots(1, 3, figsize=(9, 3)) + + axes[0].imshow(vol[position, :, :], cmap='viridis') + axes[0].set_title('Original') + axes[0].axis('off') + + mask = qim3d.processing.operations.fade_mask(np.ones_like(vol), decay_rate=decay_rate, ratio=ratio, geometry=geometry, axis=axis, invert=invert) + axes[1].imshow(mask[position, :, :], cmap='viridis') + axes[1].set_title('Mask') + axes[1].axis('off') + + masked_vol = qim3d.processing.operations.fade_mask(vol, decay_rate=decay_rate, ratio=ratio, geometry=geometry, axis=axis, invert=invert) + axes[2].imshow(masked_vol[position, :, :], cmap='viridis') + axes[2].set_title('Masked') + axes[2].axis('off') + + return fig + + shape_dropdown = widgets.Dropdown( + options=['sphere', 'cilinder'], + value='sphere', # default value + description='Geometry', + ) + + position_slider = widgets.IntSlider( + value=vol.shape[0] // 2, + min=0, + max=vol.shape[0] - 1, + description="Slice", + continuous_update=False, + ) + decay_rate_slider = widgets.FloatSlider( + value=10, + min=1, + max=50, + step=1.0, + description="Decay Rate", + continuous_update=False, + ) + ratio_slider = widgets.FloatSlider( + value=0.5, + min=0.1, + max=1, + step=0.01, + description="Ratio", + continuous_update=False, + ) + + # Create the Checkbox widget + invert_checkbox = widgets.Checkbox( + value=False, # default value + description='Invert' + ) + + slicer_obj = widgets.interactive(_slicer, position=position_slider, decay_rate=decay_rate_slider, ratio=ratio_slider, geometry=shape_dropdown, invert=invert_checkbox) + slicer_obj.layout = widgets.Layout(align_items="flex-start") + + return slicer_obj diff --git a/qim3d/viz/k3d.py b/qim3d/viz/k3d.py index 9271c56717b6f1ca95f674d16001c07ac3569dab..e612568b95e5a70b40ec99ccc2b914ff6ae71288 100644 --- a/qim3d/viz/k3d.py +++ b/qim3d/viz/k3d.py @@ -25,6 +25,7 @@ def vol( cmap=None, samples="auto", max_voxels=412**3, + data_type="scaled_float16", **kwargs, ): """ @@ -72,6 +73,7 @@ def vol( ``` """ + pixel_count = img.shape[0] * img.shape[1] * img.shape[2] # target is 60fps on m1 macbook pro, using test volume: https://data.qim.dk/pages/foam.html if samples == "auto": @@ -88,10 +90,10 @@ def vol( if aspectmode.lower() not in ["data", "cube"]: raise ValueError("aspectmode should be either 'data' or 'cube'") - # check if image should be downsampled for visualization original_shape = img.shape img = downscale_img(img, max_voxels=max_voxels) + new_shape = img.shape if original_shape != new_shape: @@ -99,6 +101,17 @@ def vol( f"Downsampled image for visualization. From {original_shape} to {new_shape}" ) + # Scale the image to float16 if needed + if save: + # When saving, we need float64 + img = img.astype(np.float64) + else: + + if data_type == "scaled_float16": + img = scale_to_float16(img) + else: + img = img.astype(data_type) + # Set color ranges color_range = [np.min(img), np.max(img)] if vmin: @@ -106,8 +119,9 @@ def vol( if vmax: color_range[1] = vmax + # Create the volume plot plt_volume = k3d.volume( - scale_to_float16(img), + img, bounds=( [0, img.shape[2], 0, img.shape[1], 0, img.shape[0]] if aspectmode.lower() == "data" @@ -119,7 +133,6 @@ def vol( ) plot = k3d.plot(grid_visible=grid_visible, **kwargs) plot += plt_volume - if save: # Save html to disk with open(str(save), "w", encoding="utf-8") as fp: diff --git a/setup.py b/setup.py index 40caf8e9001803be246912181eb9ba47a18995d1..017d277a0acb04bd4f63301c31186d0432e97d9b 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ with open("README.md", "r", encoding="utf-8") as f: setup( name="qim3d", - version="0.3.5", + version="0.3.6", author="Felipe Delestro", author_email="fima@dtu.dk", description="QIM tools and user interfaces",