diff --git a/docs/assets/screenshots/blob_detection.gif b/docs/assets/screenshots/blob_detection.gif new file mode 100644 index 0000000000000000000000000000000000000000..3f001f7ef1db6a4143961a11ecf03316bb0215f9 Binary files /dev/null and b/docs/assets/screenshots/blob_detection.gif differ diff --git a/docs/assets/screenshots/blob_get_mask.gif b/docs/assets/screenshots/blob_get_mask.gif new file mode 100644 index 0000000000000000000000000000000000000000..2377a9055871f81d5503a622317c8087c4add159 Binary files /dev/null and b/docs/assets/screenshots/blob_get_mask.gif differ diff --git a/docs/assets/screenshots/filter_original.png b/docs/assets/screenshots/filter_original.png new file mode 100644 index 0000000000000000000000000000000000000000..81532fac3317ba9faa89554d3b10bf1c84759a21 Binary files /dev/null and b/docs/assets/screenshots/filter_original.png differ diff --git a/docs/assets/screenshots/filter_processed.png b/docs/assets/screenshots/filter_processed.png new file mode 100644 index 0000000000000000000000000000000000000000..699c29def9340bfef18f256acb7ceb3025205f22 Binary files /dev/null and b/docs/assets/screenshots/filter_processed.png differ diff --git a/docs/assets/screenshots/local_thickness_2d.png b/docs/assets/screenshots/local_thickness_2d.png new file mode 100644 index 0000000000000000000000000000000000000000..de3b9bc38c230efca50a6fd2d6743ffa583837af Binary files /dev/null and b/docs/assets/screenshots/local_thickness_2d.png differ diff --git a/docs/assets/screenshots/local_thickness_3d.gif b/docs/assets/screenshots/local_thickness_3d.gif new file mode 100644 index 0000000000000000000000000000000000000000..fb55580eeb1a6bdd036b4ae472e1821d47e6e7d8 Binary files /dev/null and b/docs/assets/screenshots/local_thickness_3d.gif differ diff --git a/docs/assets/screenshots/structure_tensor.gif b/docs/assets/screenshots/structure_tensor.gif new file mode 100644 index 0000000000000000000000000000000000000000..1195d2084b9bdde5616fafc44442005dd54c22c9 Binary files /dev/null and b/docs/assets/screenshots/structure_tensor.gif differ diff --git a/docs/io.md b/docs/io.md index 3d15f97b206949d26a314edc9a8ec065a4c18b09..da1364ecf4492c2337a2d44af0111e24fd5b3b4e 100644 --- a/docs/io.md +++ b/docs/io.md @@ -3,32 +3,10 @@ Dealing with volumetric data can be done by `qim3d` for the most common image fo Currently, it is possible to directly load `tiff`, `h5`, `nii`,`txm`, `vol` and common `PIL` formats using one single function. - -!!! Example - ```python - import qim3d - - # Get some data from examples - vol = qim3d.examples.blobs_256x256x256 - - # Save in a local file - qim3d.io.save("blobs.tif", vol) - - # Load data from file - loaded_vol = qim3d.io.load("blobs.tif") - ``` - -::: qim3d.io.load +::: qim3d.io options: members: - load - -::: qim3d.io.save - options: - members: - save - -::: qim3d.io.downloader - options: - members: - - Downloader \ No newline at end of file + - Downloader + - ImgExamples \ No newline at end of file diff --git a/docs/processing.md b/docs/processing.md index 114e38d8da59d227f30058670f479e22dce0601d..b469a21c6bf8cf1c9115e9efa0f9404dd62c9b53 100644 --- a/docs/processing.md +++ b/docs/processing.md @@ -1,16 +1,12 @@ # Processing data -::: qim3d.processing.filters - options: - members: - - - +`qim3d` provides various tools for 3D image processing. Here, we provide a suite of powerful functionalities designed specifically for 3D image analysis and processing. From filter pipelines to structure tensor computation and blob detection, `qim3d` equips you with the tools you need to extract meaningful insights from your data. -::: qim3d.processing.filters.Pipeline +::: qim3d.processing options: members: - - append - - - -::: qim3d.processing.structure_tensor \ No newline at end of file + - structure_tensor + - local_thickness + - get_3d_cc + - Pipeline + - Blob \ No newline at end of file diff --git a/docs/releases.md b/docs/releases.md index e029f4ad88c5c56fd7db4a8b199a51b57e6316cc..3f611b8c1992b6f31560b58bb54f1e59725a2bca 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,6 +12,12 @@ And remember to keep your pip installation [up to date](/qim3d/#upgrade) so that ### v0.3.3 (coming soon!) - Introduction of `qim3d.viz.slicer` (and also `qim3d.viz.orthogonal` ) 🎉 - Introduction of `qim3d.gui.annotation_tool` 🎉 +- Introduction of `qim3d.processing.Blob` for blob detection 🎉 +- Introduction of `qim3d.processing.local_thickness` 🎉 +- Introduction of `qim3d.processing.structure_tensor` 🎉 +- Support for loading DICOM files with `qim3d.io.load`🎉 +- Introduction of `qim3d.processing.get_3d_cc` for 3D connected components and `qim3d.viz.plot_cc` for associated visualization 🎉 +- Introduction of `qim3d.viz.colormaps` for easy visualization of e.g. multi-label segmentation results 🎉 ### v0.3.2 (23/02/2024) @@ -20,9 +26,9 @@ This version focus on the increased usability of the `qim3d` library - Online documentation available at [https://platform.qim.dk/qim3d](https://platform.qim.dk/qim3d) - Virtual stacks also available for `txm` files - Updated GUI launch pipeline -- New functionalities for `qim3d.vix.slices` +- New functionalities for `qim3d.viz.slices` - Introduction of `qim3d.processing.filters` 🎉 -- Introduction of `qim3d.viz.k3d` 🎉 +- Introduction of `qim3d.viz.vol` 🎉 ### v0.3.1 (01/02/2024) diff --git a/docs/viz.md b/docs/viz.md index 042b73314d652d66b5a004cfb65f4fa3b28dc945..bc729bc1505713f463a682ed6b9701ef5dc0f98f 100644 --- a/docs/viz.md +++ b/docs/viz.md @@ -1,54 +1,14 @@ # Data visualization -The `qim3d`libray aims to provide easy ways to explore and get insights from volumetric data. +The `qim3d` library aims to provide easy ways to explore and get insights from volumetric data. -!!! Example - ```python - import qim3d - - img = qim3d.examples.shell_225x128x128 - qim3d.viz.slices(img, n_slices=15) - ``` - -  - - -!!! Example - ```python - import qim3d - - vol = qim3d.examples.bone_128x128x128 - qim3d.viz.slicer(vol) - ``` -  - - -!!! Example - ```python - import qim3d - - vol = qim3d.examples.fly_150x256x256 - qim3d.viz.orthogonal(vol, cmap="magma") - ``` -  - - -!!! Example - ```python - import qim3d - - vol = qim3d.examples.bone_128x128x128 - qim3d.viz.vol(vol) - ``` - - <iframe src="https://platform.qim.dk/k3d/fima-bone_128x128x128-20240221113459.html" width="100%" height="500" frameborder="0"></iframe> -::: qim3d.viz.img +::: qim3d.viz options: members: - slices - slicer - orthogonal - -::: qim3d.viz.k3d - options: - members: - vol + - local_thickness + - vectors + - plot_cc + - objects diff --git a/qim3d/io/__init__.py b/qim3d/io/__init__.py index 3384ec71aa400a9ff34debdd75d6b0a1c5dc8bb9..f97cb7d4df45d2d32243a99b9449b749c8c24868 100644 --- a/qim3d/io/__init__.py +++ b/qim3d/io/__init__.py @@ -1,5 +1,5 @@ +from .loading import DataLoader, load, ImgExamples from .downloader import Downloader -from .load import DataLoader, load, ImgExamples -from .save import DataSaver, save +from .saving import DataSaver, save from .sync import Sync from . import logger \ No newline at end of file diff --git a/qim3d/io/downloader.py b/qim3d/io/downloader.py index a57871dd294e51977bc52266c3d97f26130aa312..32ea5489c099f5a6566864696fbeda5c83afe03d 100644 --- a/qim3d/io/downloader.py +++ b/qim3d/io/downloader.py @@ -7,16 +7,16 @@ from urllib.parse import quote from tqdm import tqdm from pathlib import Path -from qim3d.io.load import load +from qim3d.io import load from qim3d.io.logger import log import outputformat as ouf class Downloader: - """Class for downloading large data files available on the [QIM data repository](https://data.qim.dk/data-repository/). + """Class for downloading large data files available on the [QIM data repository](https://data.qim.dk/). Attributes: - [folder_name] (str): folder class with the name of the folder in https://data.qim.dk/data-repository/ + [folder_name] (str): folder class with the name of the folder in <https://data.qim.dk/> Example: ```python diff --git a/qim3d/io/load.py b/qim3d/io/loading.py similarity index 93% rename from qim3d/io/load.py rename to qim3d/io/loading.py index 2a48536aabb8ddba00560389695d42391d448bae..83b4c3d50415d32c576689a3ce8955ba25049fb0 100644 --- a/qim3d/io/load.py +++ b/qim3d/io/loading.py @@ -480,9 +480,11 @@ class DataLoader: path (str or os.PathLike): The path to the file or directory. Returns: - numpy.ndarray, numpy.memmap, h5py._hl.dataset.Dataset, nibabel.arrayproxy.ArrayProxy or tuple: The loaded volume. - If 'self.virtual_stack' is True, returns numpy.memmap, h5py._hl.dataset.Dataset or nibabel.arrayproxy.ArrayProxy depending on file format - If 'self.return_metadata' is True and file format is either HDF5, NIfTI or TXRM/TXM/XRM, returns a tuple (volume, metadata). + vol (numpy.ndarray, numpy.memmap, h5py._hl.dataset.Dataset, nibabel.arrayproxy.ArrayProxy or tuple): The loaded volume + + If `virtual_stack=True`, returns `numpy.memmap`, `h5py._hl.dataset.Dataset` or `nibabel.arrayproxy.ArrayProxy` depending on file format + If `return_metadata=True` and file format is either HDF5, NIfTI or TXRM/TXM/XRM, returns `tuple` (volume, metadata). + Raises: ValueError: If the format is not supported ValueError: If the file or directory does not exist. @@ -585,11 +587,10 @@ def load( to the DataLoader constructor. Returns: - numpy.ndarray, numpy.memmap, h5py._hl.dataset.Dataset, nibabel.arrayproxy.ArrayProxy or tuple: The loaded volume. - - If 'virtual_stack' is True, returns numpy.memmap, h5py._hl.dataset.Dataset or nibabel.arrayproxy.ArrayProxy depending on file format - - If 'return_metadata' is True and file format is either HDF5, NIfTI or TXRM/TXM/XRM, returns a tuple (volume, metadata). + vol (numpy.ndarray, numpy.memmap, h5py._hl.dataset.Dataset, nibabel.arrayproxy.ArrayProxy or tuple): The loaded volume + + If `virtual_stack=True`, returns `numpy.memmap`, `h5py._hl.dataset.Dataset` or `nibabel.arrayproxy.ArrayProxy` depending on file format + If `return_metadata=True` and file format is either HDF5, NIfTI or TXRM/TXM/XRM, returns `tuple` (volume, metadata). Example: ```python @@ -635,7 +636,30 @@ def load( class ImgExamples: - """Image examples""" + """Image examples + + Attributes: + blobs_256x256 (numpy.ndarray): A 2D image of blobs. + blobs_256x256x256 (numpy.ndarray): A 3D volume of blobs. + bone_128x128x128 (numpy.ndarray): A 3D volume of bone. + cement_128x128x128 (numpy.ndarray): A 3D volume of cement. + fly_150x256x256 (numpy.ndarray): A 3D volume of a fly. + NT_10x200x100 (numpy.ndarray): A 3D volume of a neuron. + NT_128x128x128 (numpy.ndarray): A 3D volume of a neuron. + 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` + + Example: + ```python + import qim3d + + data = qim3d.examples.blobs_256x256 + ``` + + + """ def __init__(self): img_examples_path = Path(qim3d.__file__).parents[0] / "img_examples" @@ -647,4 +671,4 @@ class ImgExamples: # Generate loader for each image found for idx, name in enumerate(img_names): - exec(f"self.{name} = qim3d.io.load(path = img_paths[idx])") \ No newline at end of file + exec(f"self.{name} = load(path = img_paths[idx])") \ No newline at end of file diff --git a/qim3d/io/save.py b/qim3d/io/saving.py similarity index 100% rename from qim3d/io/save.py rename to qim3d/io/saving.py diff --git a/qim3d/processing/__init__.py b/qim3d/processing/__init__.py index 7d251a6b1b976e33ee5d2ada0d1fa0308428756a..18b959f02996a87d8e4a2c46c0577c02419ecae7 100644 --- a/qim3d/processing/__init__.py +++ b/qim3d/processing/__init__.py @@ -1,4 +1,5 @@ +from .local_thickness_ import local_thickness +from .structure_tensor_ import structure_tensor from .filters import * -from .local_thickness import local_thickness -from .structure_tensor import structure_tensor from .detection import * +from .cc import get_3d_cc diff --git a/qim3d/utils/cc.py b/qim3d/processing/cc.py similarity index 100% rename from qim3d/utils/cc.py rename to qim3d/processing/cc.py diff --git a/qim3d/processing/detection.py b/qim3d/processing/detection.py index b74cf36e9e0eacb957fb66975c9a5809dc06abe3..94136e090ad41f40cc2ec617db6cb08abf4a7a11 100644 --- a/qim3d/processing/detection.py +++ b/qim3d/processing/detection.py @@ -6,6 +6,9 @@ __all__ = ["Blob"] class Blob: + """ + Extract blobs from a volume using Difference of Gaussian (DoG) method + """ def __init__( self, background="dark", @@ -19,6 +22,7 @@ class Blob: ): """ 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 @@ -43,10 +47,37 @@ class Blob: 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: + ```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) + + # Visualize results + qim3d.viz.circles(blobs,vol,alpha=0.8,color='blue') + ``` +  """ self.vol_shape = vol.shape if self.background == "bright": @@ -70,8 +101,32 @@ class Blob: 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" + ) + + # Get mask and visualize + mask = blob_detector.get_mask() + qim3d.viz.slicer(mask) + ``` +  ''' binary_volume = np.zeros(self.vol_shape, dtype=bool) diff --git a/qim3d/processing/filters.py b/qim3d/processing/filters.py index 4390ba69aa41a9281c36662ceea30eb5d5eed72e..c7d39ef901f834aa12828c7f9cad014dd416b73a 100644 --- a/qim3d/processing/filters.py +++ b/qim3d/processing/filters.py @@ -87,6 +87,37 @@ class Minimum(FilterBase): class Pipeline: + """ + Example: + ```python + import qim3d + from qim3d.processing import Pipeline, Median, Gaussian, Maximum, Minimum + + # Get data + vol = qim3d.examples.fly_150x256x256 + + # Show original + qim3d.viz.slices(vol, axis=0, show=True) + + # Create filter pipeline + pipeline = Pipeline( + Median(size=5), + Gaussian(sigma=3) + ) + + # Append a third filter to the pipeline + pipeline.append(Maximum(size=3)) + + # Apply filter pipeline + vol_filtered = pipeline(vol) + + # Show filtered + qim3d.viz.slices(vol_filtered, axis=0) + ``` +  +  + + """ def __init__(self, *args: Type[FilterBase]): """ Represents a sequence of image filters. @@ -125,6 +156,20 @@ class Pipeline: Args: fn: An instance of a FilterBase subclass to be appended. + + Example: + ```python + import qim3d + from qim3d.processing import Pipeline, Maximum, Median + + # Create filter pipeline + pipeline = Pipeline( + Maximum(size=3) + ) + + # Append a second filter to the pipeline + pipeline.append(Median(size=5)) + ``` """ self._add_filter(str(len(self.filters)), fn) diff --git a/qim3d/processing/local_thickness.py b/qim3d/processing/local_thickness_.py similarity index 77% rename from qim3d/processing/local_thickness.py rename to qim3d/processing/local_thickness_.py index e581c01376d96c428dcb1b39b2fc47a56f9898f2..8090ad5be561027819c4f84b877d08b6a1df1ec0 100644 --- a/qim3d/processing/local_thickness.py +++ b/qim3d/processing/local_thickness_.py @@ -15,7 +15,7 @@ def local_thickness( visualize=False, **viz_kwargs ) -> np.ndarray: - """Wrapper for the local thickness function from the localthickness package (https://github.com/vedranaa/local-thickness) + """Wrapper for the local thickness function from the [local thickness package](https://github.com/vedranaa/local-thickness) Args: image (np.ndarray): 2D or 3D NumPy array representing the image/volume. @@ -26,11 +26,27 @@ def local_thickness( mask (np.ndarray, optional): binary mask of the same size of the image defining parts of the image to be included in the computation of the local thickness. Default is None. visualize (bool, optional): Whether to visualize the local thickness. Default is False. - **viz_kwargs: Additional keyword arguments for the visualization function. Only used if visualize=True. + **viz_kwargs: Additional keyword arguments passed to `qim3d.viz.local_thickness`. Only used if `visualize=True`. Returns: local_thickness (np.ndarray): 2D or 3D NumPy array representing the local thickness of the input image/volume. + + Example: + ```python + import qim3d + fly = qim3d.examples.fly_150x256x256 # 3D volume + lt_fly = qim3d.processing.local_thickness(fly, visualize=True, axis=0) + ``` +  + + ```python + import qim3d + + blobs = qim3d.examples.blobs_256x256 # 2D image + lt_blobs = qim3d.processing.local_thickness(blobs, visualize=True) + ``` +  !!! quote "Reference" Dahl, V. A., & Dahl, A. B. (2023, June). Fast Local Thickness. 2023 IEEE/CVF Conference on Computer Vision and Pattern Recognition Workshops (CVPRW). diff --git a/qim3d/processing/structure_tensor.py b/qim3d/processing/structure_tensor_.py similarity index 76% rename from qim3d/processing/structure_tensor.py rename to qim3d/processing/structure_tensor_.py index abd37cc6c6828a0fd9fd86e321be439a37c640c5..2699fa70c5427d848e45fa28466f3c5b8a35773b 100644 --- a/qim3d/processing/structure_tensor.py +++ b/qim3d/processing/structure_tensor_.py @@ -18,11 +18,12 @@ def structure_tensor( Args: vol (np.ndarray): 3D NumPy array representing the volume. - sigma (float): A noise scale, structures smaller than sigma will be removed by smoothing. - rho (float): An integration scale giving the size over the neighborhood in which the orientation is to be analysed. - full: A flag indicating that all three eigenvalues should be returned. Default is False. + sigma (float, optional): A noise scale, structures smaller than sigma will be removed by smoothing. + rho (float, optional): An integration scale giving the size over the neighborhood in which the orientation is to be analysed. + full (bool, optional): A flag indicating that all three eigenvalues should be returned. Default is False. visualize (bool, optional): Whether to visualize the structure tensor. Default is False. - **viz_kwargs: Additional keyword arguments for the visualization function. Only used if visualize=True. + **viz_kwargs: Additional keyword arguments for passed to `qim3d.viz.vectors`. Only used if `visualize=True`. + Raises: ValueError: If the input volume is not 3D. @@ -30,6 +31,15 @@ def structure_tensor( val: An array with shape `(3, *vol.shape)` containing the eigenvalues of the structure tensor. vec: An array with shape `(3, *vol.shape)` if `full` is `False`, otherwise `(3, 3, *vol.shape)` containing eigenvectors. + Example: + ```python + import qim3d + + vol = qim3d.examples.NT_128x128x128 + val, vec = qim3d.processing.structure_tensor(vol, visualize=True, axis=2) + ``` +  + !!! quote "Reference" Jeppesen, N., et al. "Quantifying effects of manufacturing methods on fiber orientation in unidirectional composites using structure tensor analysis." Composites Part A: Applied Science and Manufacturing 149 (2021): 106541. <https://doi.org/10.1016/j.compositesa.2021.106541> diff --git a/qim3d/tests/utils/test_connected_components.py b/qim3d/tests/processing/test_connected_components.py similarity index 97% rename from qim3d/tests/utils/test_connected_components.py rename to qim3d/tests/processing/test_connected_components.py index cb2b21089b1db5b2517a58524aa5df264809ce06..0e972123f11b4b9f163127afc110872155e84e95 100644 --- a/qim3d/tests/utils/test_connected_components.py +++ b/qim3d/tests/processing/test_connected_components.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from qim3d.utils.cc import get_3d_cc +from qim3d.processing.cc import get_3d_cc @pytest.fixture(scope="module") diff --git a/qim3d/tests/viz/test_img.py b/qim3d/tests/viz/test_img.py index 2ac9eed9bb09418f888b9ec9bf0e3a0bf54d8979..1179a15f5c03f002632c097e1c58fad019b2321b 100644 --- a/qim3d/tests/viz/test_img.py +++ b/qim3d/tests/viz/test_img.py @@ -241,5 +241,3 @@ def test_local_thickness_3d_max_projection(): # Assert that returned object is an interactive widget assert isinstance(fig, plt.Figure) - - diff --git a/qim3d/utils/__init__.py b/qim3d/utils/__init__.py index 9b08365be27631d622fbf6edfb29014330885fc9..3459794553284d61b6a79dbca693bcf4aeb52ea6 100644 --- a/qim3d/utils/__init__.py +++ b/qim3d/utils/__init__.py @@ -1,7 +1,6 @@ #from .doi import get_bibtex, get_reference from . import doi, internal_tools from .augmentations import Augmentation -from .cc import get_3d_cc from .data import Dataset, prepare_dataloaders, prepare_datasets from .img import overlay_rgb_images from .models import inference, model_summary, train_model diff --git a/qim3d/viz/__init__.py b/qim3d/viz/__init__.py index 2057c9d48476f37b0dc05e3daa098018d3a72c1e..54a1b11bfef542fcb2b0f4b5f40fc25517c2aaec 100644 --- a/qim3d/viz/__init__.py +++ b/qim3d/viz/__init__.py @@ -1,6 +1,8 @@ from .visualizations import plot_metrics -from .img import grid_pred, grid_overview, slices, slicer, orthogonal, plot_cc, local_thickness +from .img import grid_pred, grid_overview, slices, slicer, orthogonal 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 .detection import circles diff --git a/qim3d/viz/cc.py b/qim3d/viz/cc.py new file mode 100644 index 0000000000000000000000000000000000000000..0f3ac8fdf0f84240088ab8c9b5eb76da9624aaa3 --- /dev/null +++ b/qim3d/viz/cc.py @@ -0,0 +1,72 @@ +import numpy as np +import matplotlib.pyplot as plt +from qim3d.viz import slices +from qim3d.viz.colormaps import objects as qim3dCmap +from qim3d.processing.cc import CC + +def plot_cc( + connected_components, + component_indexs: list | tuple = None, + max_cc_to_plot=32, + overlay=None, + crop=False, + show=True, + **kwargs, +) -> list[plt.Figure]: + """ + Plot the connected components of an image. + + Parameters: + connected_components (CC): The connected components object. + components (list | tuple, optional): The components to plot. If None the first max_cc_to_plot=32 components will be plotted. Defaults to None. + max_cc_to_plot (int, optional): The maximum number of connected components to plot. Defaults to 32. + overlay (optional): Overlay image. Defaults to None. + crop (bool, optional): Whether to crop the image to the cc. Defaults to False. + show (bool, optional): Whether to show the figure. Defaults to True. + **kwargs: Additional keyword arguments to pass to `qim3d.viz.slices`. + + Returns: + figs (list[plt.Figure]): List of figures, if `show=False`. + """ + # if no components are given, plot the first max_cc_to_plot=32 components + if component_indexs is None: + if len(connected_components) > max_cc_to_plot: + log.warning( + f"More than {max_cc_to_plot} connected components found. Only the first {max_cc_to_plot} will be plotted. Change max_cc_to_plot to plot more components." + ) + component_indexs = range( + 1, min(max_cc_to_plot + 1, len(connected_components) + 1) + ) + + figs = [] + for component in component_indexs: + if overlay is not None: + assert (overlay.shape == connected_components.shape), f"Overlay image must have the same shape as the connected components. overlay.shape=={overlay.shape} != connected_components.shape={connected_components.shape}." + + # plots overlay masked to connected component + if crop: + # Crop the overlay image based on the bounding box of the component + bb = connected_components.get_bounding_box(component)[0] + cc = connected_components.get_cc(component, crop=True) + 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) + else: + cc = connected_components.get_cc(component, crop=False) + overlay_crop = np.where(cc == 0, 0, overlay) + fig = 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) + + figs.append(fig) + + if not show: + return figs + + return \ No newline at end of file diff --git a/qim3d/viz/colormaps.py b/qim3d/viz/colormaps.py index c6ee1b5f7f2708ee376574b896758dff8d8f1939..a32b778444c2fb8d4ddf69338598ab9b19b1c687 100644 --- a/qim3d/viz/colormaps.py +++ b/qim3d/viz/colormaps.py @@ -25,7 +25,7 @@ def objects( seed (int, optional): Seed for random number generator. Defaults to 19. Returns: - matplotlib.colors.LinearSegmentedColormap: Colormap for matplotlib + cmap (matplotlib.colors.LinearSegmentedColormap): Colormap for matplotlib """ # Check style if style not in ("bright", "soft"): diff --git a/qim3d/viz/detection.py b/qim3d/viz/detection.py index ca9c15fcff663b0d0c5e77dc6ac88000c11811ac..acfffa70c5e09ebc535a5823ffa207da099de779 100644 --- a/qim3d/viz/detection.py +++ b/qim3d/viz/detection.py @@ -16,13 +16,14 @@ 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.detection.Blob() + as a 4-tuple (p, r, c, radius). Usally the result of `qim3d.processing.Blob().detect()` vol (array-like): The 3D volume on which to plot the blobs. - z_slice (int, optional): The index of the slice to plot. If not provided, the middle slice is used. + alpha (float, optional): The transparency of the blobs. Defaults to 0.5. + color (str, optional): The color of the blobs. Defaults to "#ff9900". **kwargs: Arbitrary keyword arguments for the `slices` function. Returns: - matplotlib.figure.Figure: The resulting figure after adding the blobs to the slice. + slicer_obj (ipywidgets.interactive): An interactive widget for visualizing the blobs. """ diff --git a/qim3d/viz/img.py b/qim3d/viz/img.py index cc5c417f6d5e5686415316c81064b124778ffea5..4acefda157683a86b6f59447d2e2cfb3d106c159 100644 --- a/qim3d/viz/img.py +++ b/qim3d/viz/img.py @@ -3,7 +3,7 @@ Provides a collection of visualization functions. """ import math -from typing import List, Optional, Union, Tuple +from typing import List, Optional, Union import ipywidgets as widgets import matplotlib.pyplot as plt @@ -12,10 +12,8 @@ import torch from matplotlib import colormaps from matplotlib.colors import LinearSegmentedColormap -import qim3d.io from qim3d.io.logger import log -from qim3d.utils.cc import CC -from qim3d.viz.colormaps import objects + def grid_overview( @@ -270,7 +268,7 @@ def slices( img = qim3d.examples.shell_225x128x128 qim3d.viz.slices(img, n_slices=15) ``` - +  """ # Numpy array or Torch tensor input @@ -411,8 +409,8 @@ def slicer( vol (np.ndarray or torch.Tensor): The 3D volume to be sliced. axis (int, optional): Specifies the axis, or dimension, along which to slice. Defaults to 0. cmap (str, optional): Specifies the color map for the image. Defaults to "viridis". - img_height(int, optional): Height of the figure. Defaults to 3. - img_width(int, optional): Width of the figure. Defaults to 3. + img_height (int, optional): Height of the figure. Defaults to 3. + img_width (int, optional): Width of the figure. Defaults to 3. show_position (bool, optional): If True, displays the position of the slices. Defaults to False. interpolation (str, optional): Specifies the interpolation method for the image. Defaults to None. @@ -424,8 +422,9 @@ def slicer( import qim3d vol = qim3d.examples.bone_128x128x128 - qim3d.viz.slicer(vol, cmap="magma") + qim3d.viz.slicer(vol) ``` +  """ # Create the interactive widget @@ -483,9 +482,10 @@ def orthogonal( ```python import qim3d - vol = qim3d.examples.bone_128x128x128 - qim3d.viz.orthogonal(vol) + vol = qim3d.examples.fly_150x256x256 + qim3d.viz.orthogonal(vol, cmap="magma") ``` +  """ z_slicer = slicer( @@ -520,194 +520,4 @@ def orthogonal( y_slicer.children[0].description = "Y" x_slicer.children[0].description = "X" - return widgets.HBox([z_slicer, y_slicer, x_slicer]) - - -def plot_cc( - connected_components: CC, - component_indexs: list | tuple = None, - max_cc_to_plot=32, - overlay=None, - crop=False, - show=True, - **kwargs, -) -> list[plt.Figure]: - """ - Plot the connected components of an image. - - Parameters: - connected_components (CC): The connected components object. - components (list | tuple, optional): The components to plot. If None the first max_cc_to_plot=32 components will be plotted. Defaults to None. - max_cc_to_plot (int, optional): The maximum number of connected components to plot. Defaults to 32. - overlay (optional): Overlay image. Defaults to None. - crop (bool, optional): Whether to crop the image to the cc. Defaults to False. - show (bool, optional): Whether to show the figure. Defaults to True. - **kwargs: Additional keyword arguments to pass to `qim3d.viz.slices`. - - Returns: - figs (list[plt.Figure]): List of figures, if `show=False`. - """ - # if no components are given, plot the first max_cc_to_plot=32 components - if component_indexs is None: - if len(connected_components) > max_cc_to_plot: - log.warning( - f"More than {max_cc_to_plot} connected components found. Only the first {max_cc_to_plot} will be plotted. Change max_cc_to_plot to plot more components." - ) - component_indexs = range( - 1, min(max_cc_to_plot + 1, len(connected_components) + 1) - ) - - figs = [] - for component in component_indexs: - if overlay is not None: - assert (overlay.shape == connected_components.shape), f"Overlay image must have the same shape as the connected components. overlay.shape=={overlay.shape} != connected_components.shape={connected_components.shape}." - - # plots overlay masked to connected component - if crop: - # Crop the overlay image based on the bounding box of the component - bb = connected_components.get_bounding_box(component)[0] - cc = connected_components.get_cc(component, crop=True) - 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) - else: - cc = connected_components.get_cc(component, crop=False) - overlay_crop = np.where(cc == 0, 0, overlay) - fig = 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) - - figs.append(fig) - - if not show: - return figs - - return - - -def local_thickness( - image: np.ndarray, - image_lt: np.ndarray, - max_projection: bool = False, - axis: int = 0, - slice_idx: Optional[Union[int, float]] = None, - show: bool = False, - figsize: Tuple[int, int] = (15, 5), -) -> Union[plt.Figure, widgets.interactive]: - """Visualizes the local thickness of a 2D or 3D image. - - Args: - image (np.ndarray): 2D or 3D NumPy array representing the image/volume. - image_lt (np.ndarray): 2D or 3D NumPy array representing the local thickness of the input - image/volume. - max_projection (bool, optional): If True, displays the maximum projection of the local - thickness. Only used for 3D images. Defaults to False. - axis (int, optional): The axis along which to visualize the local thickness. - Unused for 2D images. - Defaults to 0. - slice_idx (int or float, optional): The initial slice to be visualized. The slice index - can afterwards be changed. If value is an integer, it will be the index of the slice - to be visualized. If value is a float between 0 and 1, it will be multiplied by the - number of slices and rounded to the nearest integer. If None, the middle slice will - be used for 3D images. Unused for 2D images. Defaults to None. - show (bool, optional): If True, displays the plot (i.e. calls plt.show()). Defaults to False. - figsize (Tuple[int, int], optional): The size of the figure. Defaults to (15, 5). - - Raises: - ValueError: If the slice index is not an integer or a float between 0 and 1. - - Returns: - If the input is 3D, returns an interactive widget. Otherwise, returns a matplotlib figure. - - Example: - image_lt = qim3d.processing.local_thickness(image) - qim3d.viz.local_thickness(image, image_lt, slice_idx=10) - """ - - def _local_thickness(image, image_lt, show, figsize, axis=None, slice_idx=None): - if slice_idx is not None: - image = image.take(slice_idx, axis=axis) - image_lt = image_lt.take(slice_idx, axis=axis) - - fig, axs = plt.subplots(1, 3, figsize=figsize, layout="constrained") - - axs[0].imshow(image, cmap="gray") - axs[0].set_title("Original image") - axs[0].axis("off") - - axs[1].imshow(image_lt, cmap="viridis") - axs[1].set_title("Local thickness") - axs[1].axis("off") - - plt.colorbar( - axs[1].imshow(image_lt, cmap="viridis"), ax=axs[1], orientation="vertical" - ) - - axs[2].hist(image_lt[image_lt > 0].ravel(), bins=32, edgecolor="black") - axs[2].set_title("Local thickness histogram") - axs[2].set_xlabel("Local thickness") - axs[2].set_ylabel("Count") - - if show: - plt.show() - - plt.close() - - return fig - - # Get the middle slice if the input is 3D - if len(image.shape) == 3: - if max_projection: - if slice_idx is not None: - log.warning( - "slice_idx is not used for max_projection. It will be ignored." - ) - image = image.max(axis=axis) - image_lt = image_lt.max(axis=axis) - return _local_thickness(image, image_lt, show, figsize) - else: - if slice_idx is None: - slice_idx = image.shape[axis] // 2 - elif isinstance(slice_idx, float): - if slice_idx < 0 or slice_idx > 1: - raise ValueError( - "Values of slice_idx of float type must be between 0 and 1." - ) - slice_idx = int(slice_idx * image.shape[0]) - 1 - slide_idx_slider = widgets.IntSlider( - min=0, - max=image.shape[axis] - 1, - step=1, - value=slice_idx, - description="Slice index", - layout=widgets.Layout(width="450px"), - ) - widget_obj = widgets.interactive( - _local_thickness, - image=widgets.fixed(image), - image_lt=widgets.fixed(image_lt), - show=widgets.fixed(True), - figsize=widgets.fixed(figsize), - axis=widgets.fixed(axis), - slice_idx=slide_idx_slider, - ) - widget_obj.layout = widgets.Layout(align_items="center") - if show: - display(widget_obj) - return widget_obj - else: - if max_projection: - log.warning( - "max_projection is only used for 3D images. It will be ignored." - ) - if slice_idx is not None: - log.warning("slice_idx is only used for 3D images. It will be ignored.") - return _local_thickness(image, image_lt, show, figsize) - - + return widgets.HBox([z_slicer, y_slicer, x_slicer]) \ No newline at end of file diff --git a/qim3d/viz/k3d.py b/qim3d/viz/k3d.py index d5f3ebbd6c9e236459df1348fa6bbe03ad1a5d56..a6e45878e7240df81f99d28b6538a49d6d45679a 100644 --- a/qim3d/viz/k3d.py +++ b/qim3d/viz/k3d.py @@ -17,31 +17,33 @@ def vol(img, aspectmode="data", show=True, save=False, grid_visible=False, cmap= Args: img (numpy.ndarray): The input 3D image data. It should be a 3D numpy array. - aspectmode (str, optional): Determines the proportions of the scene's axes. - If "data", the axes are drawn in proportion with the axes' ranges. - If "cube", the axes are drawn as a cube, regardless of the axes' ranges. - Defaults to "data". + aspectmode (str, optional): Determines the proportions of the scene's axes. Defaults to "data". + + If `'data'`, the axes are drawn in proportion with the axes' ranges. + If `'cube'`, the axes are drawn as a cube, regardless of the axes' ranges. show (bool, optional): If True, displays the visualization inline. Defaults to True. save (bool or str, optional): If True, saves the visualization as an HTML file. If a string is provided, it's interpreted as the file path where the HTML file will be saved. Defaults to False. grid_visible (bool, optional): If True, the grid is visible in the plot. Defaults to False. - **kwargs: Additional keyword arguments to be passed to the k3d.plot function. + **kwargs: Additional keyword arguments to be passed to the `k3d.plot` function. Returns: - k3d.plot: If show is False, returns the K3D plot object. + plot (k3d.plot): If `show=False`, returns the K3D plot object. Raises: - ValueError: If aspectmode is not "data" or "cube". + ValueError: If `aspectmode` is not `'data'` or `'cube'`. - Examples: + Example: Display a volume inline: ```python import qim3d + vol = qim3d.examples.bone_128x128x128 - qim3d.viz.vol(vol) + qim3d.viz.vol(vol) ``` + <iframe src="https://platform.qim.dk/k3d/fima-bone_128x128x128-20240221113459.html" width="100%" height="500" frameborder="0"></iframe> Save a plot to an HTML file: @@ -50,6 +52,7 @@ def vol(img, aspectmode="data", show=True, save=False, grid_visible=False, cmap= vol = qim3d.examples.bone_128x128x128 plot = qim3d.viz.vol(vol, show=False, save="plot.html") ``` + """ if aspectmode.lower() not in ["data", "cube"]: diff --git a/qim3d/viz/local_thickness_.py b/qim3d/viz/local_thickness_.py new file mode 100644 index 0000000000000000000000000000000000000000..86326e8ae6bdba1273dbf0bb642d77e4102219ee --- /dev/null +++ b/qim3d/viz/local_thickness_.py @@ -0,0 +1,132 @@ +from qim3d.io.logger import log +import numpy as np +import matplotlib.pyplot as plt +from typing import Optional, Union, Tuple +import ipywidgets as widgets + +def local_thickness( + image: np.ndarray, + image_lt: np.ndarray, + max_projection: bool = False, + axis: int = 0, + slice_idx: Optional[Union[int, float]] = None, + show: bool = False, + figsize: Tuple[int, int] = (15, 5), +) -> Union[plt.Figure, widgets.interactive]: + """Visualizes the local thickness of a 2D or 3D image. + + Args: + image (np.ndarray): 2D or 3D NumPy array representing the image/volume. + image_lt (np.ndarray): 2D or 3D NumPy array representing the local thickness of the input + image/volume. + max_projection (bool, optional): If True, displays the maximum projection of the local + thickness. Only used for 3D images. Defaults to False. + axis (int, optional): The axis along which to visualize the local thickness. + Unused for 2D images. + Defaults to 0. + slice_idx (int or float, optional): The initial slice to be visualized. The slice index + can afterwards be changed. If value is an integer, it will be the index of the slice + to be visualized. If value is a float between 0 and 1, it will be multiplied by the + number of slices and rounded to the nearest integer. If None, the middle slice will + be used for 3D images. Unused for 2D images. Defaults to None. + show (bool, optional): If True, displays the plot (i.e. calls plt.show()). Defaults to False. + figsize (Tuple[int, int], optional): The size of the figure. Defaults to (15, 5). + + Raises: + ValueError: If the slice index is not an integer or a float between 0 and 1. + + Returns: + If the input is 3D, returns an interactive widget. Otherwise, returns a matplotlib figure. + + Example: + ```python + import qim3d + + fly = qim3d.examples.fly_150x256x256 # 3D volume + lt_fly = qim3d.processing.local_thickness(fly) + qim3d.viz.local_thickness(fly, lt_fly, axis=0) + ``` +  + + + """ + + def _local_thickness(image, image_lt, show, figsize, axis=None, slice_idx=None): + if slice_idx is not None: + image = image.take(slice_idx, axis=axis) + image_lt = image_lt.take(slice_idx, axis=axis) + + fig, axs = plt.subplots(1, 3, figsize=figsize, layout="constrained") + + axs[0].imshow(image, cmap="gray") + axs[0].set_title("Original image") + axs[0].axis("off") + + axs[1].imshow(image_lt, cmap="viridis") + axs[1].set_title("Local thickness") + axs[1].axis("off") + + plt.colorbar( + axs[1].imshow(image_lt, cmap="viridis"), ax=axs[1], orientation="vertical" + ) + + axs[2].hist(image_lt[image_lt > 0].ravel(), bins=32, edgecolor="black") + axs[2].set_title("Local thickness histogram") + axs[2].set_xlabel("Local thickness") + axs[2].set_ylabel("Count") + + if show: + plt.show() + + plt.close() + + return fig + + # Get the middle slice if the input is 3D + if len(image.shape) == 3: + if max_projection: + if slice_idx is not None: + log.warning( + "slice_idx is not used for max_projection. It will be ignored." + ) + image = image.max(axis=axis) + image_lt = image_lt.max(axis=axis) + return _local_thickness(image, image_lt, show, figsize) + else: + if slice_idx is None: + slice_idx = image.shape[axis] // 2 + elif isinstance(slice_idx, float): + if slice_idx < 0 or slice_idx > 1: + raise ValueError( + "Values of slice_idx of float type must be between 0 and 1." + ) + slice_idx = int(slice_idx * image.shape[0]) - 1 + slide_idx_slider = widgets.IntSlider( + min=0, + max=image.shape[axis] - 1, + step=1, + value=slice_idx, + description="Slice index", + layout=widgets.Layout(width="450px"), + ) + widget_obj = widgets.interactive( + _local_thickness, + image=widgets.fixed(image), + image_lt=widgets.fixed(image_lt), + show=widgets.fixed(True), + figsize=widgets.fixed(figsize), + axis=widgets.fixed(axis), + slice_idx=slide_idx_slider, + ) + widget_obj.layout = widgets.Layout(align_items="center") + if show: + display(widget_obj) + return widget_obj + else: + if max_projection: + log.warning( + "max_projection is only used for 3D images. It will be ignored." + ) + if slice_idx is not None: + log.warning("slice_idx is only used for 3D images. It will be ignored.") + return _local_thickness(image, image_lt, show, figsize) \ No newline at end of file diff --git a/qim3d/viz/structure_tensor.py b/qim3d/viz/structure_tensor.py index e009bfea78890063509b88371ecec81802aba40b..77265aa6713216bf8e698741e693d7a1651494f6 100644 --- a/qim3d/viz/structure_tensor.py +++ b/qim3d/viz/structure_tensor.py @@ -35,6 +35,21 @@ def vectors( ValueError: If the axis to slice along is not 0, 1, or 2. ValueError: If the slice index is not an integer or a float between 0 and 1. + Returns: + fig (Union[plt.Figure, widgets.interactive]): If `interactive` is True, returns an interactive widget. Otherwise, returns a matplotlib figure. + + Example: + ```python + import qim3d + + vol = qim3d.examples.NT_128x128x128 + val, vec = qim3d.processing.structure_tensor(vol, visualize=True, axis=2) + + # Visualize the structure tensor + qim3d.viz.vectors(vol, vec, axis=2, slice_idx=0.5, interactive=True) + ``` +  + """ # Define Grid size limits