diff --git a/README.md b/README.md index 88203b9eafc9139bd683ec51fde41577917eb834..c686ef9c350ce044ba363d3a66d44c3087d5f9f9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Qim3D (Quantitative Imaging in 3D) +# qim3D (Quantitative Imaging in 3D) The `qim3d` (kɪm θriː diː) 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. @@ -8,6 +8,7 @@ Whether you are working with medical imaging data, materials science data, or an Documentation available at https://platform.qim.dk/qim3d/ +For more information on the QIM center visit https://qim.dk/ # License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. diff --git a/docs/assets/screenshots/synthetic_blob_cylinder_slice.png b/docs/assets/screenshots/synthetic_blob_cylinder_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..facb7337077171227921723f33ae2bb6254b5a47 Binary files /dev/null and b/docs/assets/screenshots/synthetic_blob_cylinder_slice.png differ diff --git a/docs/assets/screenshots/synthetic_blob_tube_slice.png b/docs/assets/screenshots/synthetic_blob_tube_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..feecc5de7c84820112a9b705f55489e1577eba71 Binary files /dev/null and b/docs/assets/screenshots/synthetic_blob_tube_slice.png differ diff --git a/docs/assets/screenshots/synthetic_collection_cylinder_slices.png b/docs/assets/screenshots/synthetic_collection_cylinder_slices.png new file mode 100644 index 0000000000000000000000000000000000000000..8e64ab92626ca6c5e67f78f07e108e3c4ad237c7 Binary files /dev/null and b/docs/assets/screenshots/synthetic_collection_cylinder_slices.png differ diff --git a/docs/assets/screenshots/synthetic_collection_tube_slices.png b/docs/assets/screenshots/synthetic_collection_tube_slices.png new file mode 100644 index 0000000000000000000000000000000000000000..1e298daf17075ae3e29e5ab2554c3f583abcfcfe Binary files /dev/null and b/docs/assets/screenshots/synthetic_collection_tube_slices.png differ diff --git a/docs/assets/screenshots/viz-histogram-slice.png b/docs/assets/screenshots/viz-histogram-slice.png new file mode 100644 index 0000000000000000000000000000000000000000..0ddf7b495be7f5782e913cdd7a03ed113d972604 Binary files /dev/null and b/docs/assets/screenshots/viz-histogram-slice.png differ diff --git a/docs/assets/screenshots/viz-histogram-vol.png b/docs/assets/screenshots/viz-histogram-vol.png new file mode 100644 index 0000000000000000000000000000000000000000..332a4d24d1d85075e58eb898ee80236f0e670c69 Binary files /dev/null and b/docs/assets/screenshots/viz-histogram-vol.png differ diff --git a/docs/assets/screenshots/viz-histogram.png b/docs/assets/screenshots/viz-histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..d27cc4575424b1cdf0c5e5c9cef2d820c89a8012 Binary files /dev/null and b/docs/assets/screenshots/viz-histogram.png differ diff --git a/docs/cli.md b/docs/cli.md index 502ba65ca5b33424260890cb443f9c889948d264..9f59091ece6d02b91b407a556bae06faff6e3e60 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,7 +33,7 @@ This offers quick interactions, making it ideal for tasks that require efficienc | `--anotation-tool` | Starts the annotation tool | | `--layers` | Starts the tool for segmenting layers | | `--host` | Desired host for the server. By default runs on `0.0.0.0` | -| `--platform` | Uses the Qim platform API for a unique path and port depending on the username | +| `--platform` | Uses the QIM platform API for a unique path and port depending on the username | !!! Example diff --git a/docs/index.md b/docs/index.md index 849403a1359b377989b959b6ec0ef28093ea113d..eddb3a9b05946c1ca79cf8a294b77de142c0c7bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -137,6 +137,16 @@ The latest stable version can be simply installed using `pip`. Open your termina !!! note The base installation of `qim3d` does not include deep-learning dependencies, keeping the library lighter for scenarios where they are unnecessary. If you need to use deep-learning features, you can install the additional dependencies by running: **`pip install qim3d['deep-learning']`** +After completing the installation, you can verify its success by running one or both of the following commands: + + qim3d + +or: + + pip show qim3d + +If either command displays information about the qim3d library, the installation was successful. + ### Troubleshooting Here are some solutions for commonly found issues during installation and usage of `qim3d`. @@ -203,15 +213,17 @@ This project is licensed under the [MIT License](https://lab.compute.dtu.dk/QIM/ 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 | 195 | 2023-05-12 | -| Stefan Engelmann Jensen | 29 | 2023-06-29 | -| Oskar Kristoffersen | 15 | 2023-07-05 | -| Christian Kento Rasmussen | 22 | 2024-02-01 | -| Alessia Saccardo | 7 | 2024-02-19 | -| David Grundfest | 8 | 2024-04-12 | -| Anna Bøgevang Ekner | 5 | 2024-04-18 | +| Author | Commits | First commit | +|:----------------------------|----------:|-------------:| +| Felipe Delestro | 231 | 2023-05-12 | +| Stefan Engelmann Jensen | 29 | 2023-06-29 | +| Oskar Kristoffersen | 15 | 2023-07-05 | +| Christian Kento Rasmussen | 22 | 2024-02-01 | +| Alessia Saccardo | 13 | 2024-02-19 | +| David Grundfest | 16 | 2024-04-12 | +| Anna Bøgevang Ekner | 6 | 2024-04-18 | +| David Diamond Wang Johansen | 1 | 2024-10-31 | + ## Support diff --git a/docs/releases.md b/docs/releases.md index f4d5405c7bb46c6d291869521099bc87f6c6d36e..ff67814f84c819bfd5923c13182adb85a307cfea 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -9,6 +9,14 @@ 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/#get-the-latest-version) so that you have the latest features! +### v0.4.5 (21/11/2024) + +- Updated Layer surface segmentation GUI +- Sphericity as feature from volumes +- Colorbar for visualization functions +- Chunk visualization tool +- Histogram visualization + ### v0.4.4 (11/10/2024) - Introduction of `itk-vtk-viewer` for OME-Zarr data visualization 🎉  diff --git a/docs/viz.md b/docs/viz.md index da823a34764adf6942b8adfda4ba1b32e4498005..6e798f90181128735b93e54301547b7704159f87 100644 --- a/docs/viz.md +++ b/docs/viz.md @@ -4,6 +4,7 @@ The `qim3d` library aims to provide easy ways to explore and get insights from v ::: qim3d.viz options: members: + - histogram - slices - slicer - orthogonal diff --git a/mkdocs.yml b/mkdocs.yml index d0c7a0a2117933e039c044b549a55d4beb58a9ed..1867051945b60b313f66625102f6b6e31223d7a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: qim3d documentation site_url: https://platform.qim.dk/qim3d/ -site_author: Qim3d contributors -site_description: Documentation for the Qim3d python library +site_author: qim3d contributors +site_description: Documentation for the qim3d python library repo_url: https://lab.compute.dtu.dk/QIM/tools/qim3d repo_name: Gitlab diff --git a/qim3d/__init__.py b/qim3d/__init__.py index 4655f7a0ea51cf8c81d470683a230d5f1aea02a4..07782e4e1a6ec54d7f3f605268d707ceeab557a7 100644 --- a/qim3d/__init__.py +++ b/qim3d/__init__.py @@ -8,7 +8,7 @@ Documentation available at https://platform.qim.dk/qim3d/ """ -__version__ = "0.4.4" +__version__ = "0.4.5" import importlib as _importlib diff --git a/qim3d/cli.py b/qim3d/cli.py index 401c8b84cc3620304f4c95853316c3201237cf4d..9f6e1ae03f436ef75141f823132403c5b8ec28a7 100644 --- a/qim3d/cli.py +++ b/qim3d/cli.py @@ -18,7 +18,7 @@ def parse_tuple(arg): def main(): - parser = argparse.ArgumentParser(description="Qim3d command-line interface.") + parser = argparse.ArgumentParser(description="qim3d command-line interface.") subparsers = parser.add_subparsers(title="Subcommands", dest="subcommand") # GUIs @@ -167,7 +167,7 @@ def main(): except qim3d.viz.NotInstalledError as err: print(err) - message = "Itk-vtk-viewer is not installed or qim3d can not find it.\nYou can either:\n\to Use 'qim3d viz SOURCE -m k3d' to display data using different method\n\to Install itk-vtk-viewer yourself following https://kitware.github.io/itk-vtk-viewer/docs/cli.html#Installation\n\to Let QIM3D install itk-vtk-viewer now (it will also install node.js in qim3d library)\nDo you want QIM3D to install itk-vtk-viewer now?" + message = "Itk-vtk-viewer is not installed or qim3d can not find it.\nYou can either:\n\to Use 'qim3d viz SOURCE -m k3d' to display data using different method\n\to Install itk-vtk-viewer yourself following https://kitware.github.io/itk-vtk-viewer/docs/cli.html#Installation\n\to Let qim3D install itk-vtk-viewer now (it will also install node.js in qim3d library)\nDo you want qim3D to install itk-vtk-viewer now?" print(message) answer = input("[Y/n]:") if answer in "Yy": diff --git a/qim3d/generate/blob_.py b/qim3d/generate/blob_.py index ed7f6f6c25b4d8be64853bcba674d5d8b761c046..153bdcfc86c898f3802dc18a3450efba24fe4e10 100644 --- a/qim3d/generate/blob_.py +++ b/qim3d/generate/blob_.py @@ -2,6 +2,8 @@ import numpy as np import scipy.ndimage from noise import pnoise3 +import qim3d.processing + def blob( base_shape: tuple = (128, 128, 128), final_shape: tuple = (128, 128, 128), @@ -11,6 +13,7 @@ def blob( max_value: int = 255, threshold: float = 0.5, smooth_borders: bool = False, + object_shape: str = None, dtype: str = "uint8", ) -> np.ndarray: """ @@ -25,6 +28,7 @@ def blob( max_value (int, optional): Maximum value for the volume intensity. Defaults to 255. threshold (float, optional): Threshold value for clipping low intensity values. Defaults to 0.5. smooth_borders (bool, optional): Flag for automatic computation of the threshold value to ensure a blob with no straight edges. If True, the `threshold` parameter is ignored. Defaults to False. + object_shape (str, optional): Shape of the object to generate, either "cylinder", or "tube". Defaults to None. dtype (str, optional): Desired data type of the output volume. Defaults to "uint8". Returns: @@ -41,17 +45,64 @@ def blob( # Generate synthetic blob synthetic_blob = qim3d.generate.blob(noise_scale = 0.015) + # Visualize 3D volume + qim3d.viz.vol(synthetic_blob) + ``` + <iframe src="https://platform.qim.dk/k3d/synthetic_blob.html" width="100%" height="500" frameborder="0"></iframe> + + ```python # Visualize slices qim3d.viz.slices(synthetic_blob, vmin = 0, vmax = 255, n_slices = 15) ```  + Example: ```python - # Visualize 3D volume - qim3d.viz.vol(synthetic_blob) + import qim3d + + # Generate tubular synthetic blob + vol = qim3d.generate.blob(base_shape = (10, 300, 300), + final_shape = (100, 100, 100), + noise_scale = 0.3, + gamma = 2, + threshold = 0.0, + object_shape = "cylinder" + ) + + # Visualize synthetic blob + qim3d.viz.vol(vol) ``` - <iframe src="https://platform.qim.dk/k3d/synthetic_blob.html" width="100%" height="500" frameborder="0"></iframe> + <iframe src="https://platform.qim.dk/k3d/synthetic_blob_cylinder.html" width="100%" height="500" frameborder="0"></iframe> + ```python + # Visualize slices + qim3d.viz.slices(vol, n_slices=15, axis=1) + ``` +  + + Example: + ```python + import qim3d + + # Generate tubular synthetic blob + vol = qim3d.generate.blob(base_shape = (200, 100, 100), + final_shape = (400, 100, 100), + noise_scale = 0.03, + gamma = 0.12, + threshold = 0.85, + object_shape = "tube" + ) + + # Visualize synthetic blob + qim3d.viz.vol(vol) + ``` + <iframe src="https://platform.qim.dk/k3d/synthetic_blob_tube.html" width="100%" height="500" frameborder="0"></iframe> + + ```python + # Visualize + qim3d.viz.slices(vol, n_slices=15) + ``` +  """ if not isinstance(final_shape, tuple) or len(final_shape) != 3: @@ -93,6 +144,10 @@ def blob( # Scale the volume to the maximum value volume = volume * max_value + # If object shape is specified, smooth borders are disabled + if object_shape: + smooth_borders = False + if smooth_borders: # Maximum value among the six sides of the 3D volume max_border_value = np.max([ @@ -115,4 +170,46 @@ def blob( volume, np.array(final_shape) / np.array(base_shape), order=order ) - return volume.astype(dtype) + # Fade into a shape if specified + if object_shape == "cylinder": + + # Arguments for the fade_mask function + geometry = "cylindrical" # Fade in cylindrical geometry + axis = np.argmax(volume.shape) # Fade along the dimension where the object is the largest + target_max_normalized_distance = 1.4 # This value ensures that the object will become cylindrical + + volume = qim3d.processing.operations.fade_mask(volume, + geometry = geometry, + axis = axis, + target_max_normalized_distance = target_max_normalized_distance + ) + + elif object_shape == "tube": + + # Arguments for the fade_mask function + geometry = "cylindrical" # Fade in cylindrical geometry + axis = np.argmax(volume.shape) # Fade along the dimension where the object is the largest + decay_rate = 5 # Decay rate for the fade operation + target_max_normalized_distance = 1.4 # This value ensures that the object will become cylindrical + + # Fade once for making the object cylindrical + volume = qim3d.processing.operations.fade_mask(volume, + geometry = geometry, + axis = axis, + decay_rate = decay_rate, + target_max_normalized_distance = target_max_normalized_distance, + invert = False + ) + + # Fade again with invert = True for making the object a tube (i.e. with a hole in the middle) + volume = qim3d.processing.operations.fade_mask(volume, + geometry = geometry, + axis = axis, + decay_rate = decay_rate, + invert = True + ) + + # Convert to desired data type + volume = volume.astype(dtype) + + return volume \ No newline at end of file diff --git a/qim3d/generate/collection_.py b/qim3d/generate/collection_.py index f07e500c0d0e7bf51827e9b020528b7dfc3bcb0d..dc54da7e5efa65ee5dea5643fa7eed26fe90e475 100644 --- a/qim3d/generate/collection_.py +++ b/qim3d/generate/collection_.py @@ -139,6 +139,7 @@ def collection( min_threshold: float = 0.5, max_threshold: float = 0.6, smooth_borders: bool = False, + object_shape: str = None, seed: int = 0, verbose: bool = False, ) -> tuple[np.ndarray, object]: @@ -163,30 +164,30 @@ def collection( max_high_value (int, optional): Maximum maximum value for the volume intensity. Defaults to 255. min_threshold (float, optional): Minimum threshold value for clipping low intensity values. Defaults to 0.5. max_threshold (float, optional): Maximum threshold value for clipping low intensity values. Defaults to 0.6. - smooth_borders (bool, optional): Flag for smoothing blob borders to avoid straight edges in the objects. If True, the `min_threshold` and `max_threshold` parameters are ignored. Defaults to False. + smooth_borders (bool, optional): Flag for smoothing object borders to avoid straight edges in the objects. If True, the `min_threshold` and `max_threshold` parameters are ignored. Defaults to False. + object_shape (str, optional): Shape of the object to generate, either "cylinder", or "tube". Defaults to None. seed (int, optional): Seed for reproducibility. Defaults to 0. verbose (bool, optional): Flag to enable verbose logging. Defaults to False. - Returns: synthetic_collection (numpy.ndarray): 3D volume of the generated collection of synthetic objects with specified parameters. labels (numpy.ndarray): Array with labels for each voxel, same shape as synthetic_collection. Raises: TypeError: If `collection_shape` is not 3D. - ValueError: If blob parameters are invalid. + ValueError: If object parameters are invalid. Note: - The function places objects without overlap. - The function can either place objects at random positions in the collection (if `positions = None`) or at specific positions provided in the `positions` argument. If specific positions are provided, the number of blobs must match the number of positions (e.g. `num_objects = 2` with `positions = [(12, 8, 10), (24, 20, 18)]`). - - If not all `num_objects` can be placed, the function returns the `synthetic_collection` volume with as many blobs as possible in it, and logs an error. - - Labels for all objects are returned, even if they are not a sigle connected component. + - If not all `num_objects` can be placed, the function returns the `synthetic_collection` volume with as many objects as possible in it, and logs an error. + - Labels for all objects are returned, even if they are not a single connected component. Example: ```python import qim3d - # Generate synthetic collection of blobs + # Generate synthetic collection of objects num_objects = 15 synthetic_collection, labels = qim3d.generate.collection(num_objects = num_objects) @@ -207,12 +208,11 @@ def collection( ```  - Example: ```python import qim3d - # Generate synthetic collection of dense blobs + # Generate synthetic collection of dense objects synthetic_collection, labels = qim3d.generate.collection( min_high_value = 255, max_high_value = 255, @@ -228,34 +228,66 @@ def collection( ``` <iframe src="https://platform.qim.dk/k3d/synthetic_collection_dense.html" width="100%" height="500" frameborder="0"></iframe> - - Example: ```python import qim3d - # Generate synthetic collection of tubular structures - synthetic_collection, labels = qim3d.generate.collection( - num_objects=10, - collection_shape=(200,100,100), - min_shape = (190, 50, 50), - max_shape = (200, 60, 60), - object_shape_zoom = (1, 0.2, 0.2), - min_object_noise = 0.01, - max_object_noise = 0.02, - max_rotation_degrees=10, - min_threshold = 0.95, - max_threshold = 0.98, - min_gamma = 0.02, - max_gamma = 0.03 - ) + # Generate synthetic collection of cylindrical structures + vol, labels = qim3d.generate.collection(num_objects = 40, + collection_shape = (300, 150, 150), + min_shape = (280, 10, 10), + max_shape = (290, 15, 15), + min_object_noise = 0.08, + max_object_noise = 0.09, + max_rotation_degrees = 5, + min_threshold = 0.7, + max_threshold = 0.9, + min_gamma = 0.10, + max_gamma = 0.11, + object_shape = "cylinder" + ) # Visualize synthetic collection - qim3d.viz.vol(synthetic_collection) + qim3d.viz.vol(vol) + + ``` + <iframe src="https://platform.qim.dk/k3d/synthetic_collection_cylinder.html" width="100%" height="500" frameborder="0"></iframe> + + ```python + # Visualize slices + qim3d.viz.slices(vol, n_slices=15) ``` - <iframe src="https://platform.qim.dk/k3d/synthetic_collection_tubular.html" width="100%" height="500" frameborder="0"></iframe> +  + + Example: + ```python + import qim3d + # Generate synthetic collection of tubular (hollow) structures + vol, labels = qim3d.generate.collection(num_objects = 10, + collection_shape = (200, 200, 200), + min_shape = (180, 25, 25), + max_shape = (190, 35, 35), + min_object_noise = 0.02, + max_object_noise = 0.03, + max_rotation_degrees = 5, + min_threshold = 0.7, + max_threshold = 0.9, + min_gamma = 0.10, + max_gamma = 0.11, + object_shape = "tube" + ) + # Visualize synthetic collection + qim3d.viz.vol(vol) + ``` + <iframe src="https://platform.qim.dk/k3d/synthetic_collection_tube.html" width="100%" height="500" frameborder="0"></iframe> + + ```python + # Visualize slices + qim3d.viz.slices(vol, n_slices=15, axis=1) + ``` +  """ if verbose: original_log_level = log.getEffectiveLevel() @@ -270,10 +302,6 @@ def collection( if len(min_shape) != len(max_shape): raise ValueError("Object shapes must be tuples of the same length") - # if not isinstance(blob_shapes, list) or \ - # len(blob_shapes) != 2 or len(blob_shapes[0]) != 3 or len(blob_shapes[1]) != 3: - # raise TypeError("Blob shapes must be a list of two tuples with three dimensions (z, y, x)") - if (positions is not None) and (len(positions) != num_objects): raise ValueError( "Number of objects must match number of positions, otherwise set positions = None" @@ -301,6 +329,10 @@ def collection( ) log.debug(f"- Blob shape: {blob_shape}") + # Scale object shape + final_shape = tuple(l * r for l, r in zip(blob_shape, object_shape_zoom)) + final_shape = tuple(int(x) for x in final_shape) # NOTE: Added this + # Sample noise scale noise_scale = rng.uniform(low=min_object_noise, high=max_object_noise) log.debug(f"- Object noise scale: {noise_scale:.4f}") @@ -317,15 +349,16 @@ def collection( threshold = rng.uniform(low=min_threshold, high=max_threshold) log.debug(f"- Threshold: {threshold:.3f}") - # Generate synthetic blob + # Generate synthetic object blob = qim3d.generate.blob( base_shape=blob_shape, - final_shape=tuple(l * r for l, r in zip(blob_shape, object_shape_zoom)), + final_shape=final_shape, noise_scale=noise_scale, gamma=gamma, max_value=max_value, threshold=threshold, smooth_borders=smooth_borders, + object_shape=object_shape, ) # Rotate object @@ -336,21 +369,21 @@ def collection( axes = rng.choice(rotation_axes) # Sample the two axes to rotate around log.debug(f"- Rotation angle: {angle:.2f} at axes: {axes}") - blob = scipy.ndimage.rotate(blob, angle, axes, order=0) + blob = scipy.ndimage.rotate(blob, angle, axes, order=1) # Place synthetic object into the collection - # If positions are specified, place blob at one of the specified positions + # If positions are specified, place object at one of the specified positions collection_before = collection_array.copy() if positions: collection_array, placed, positions = specific_placement( collection_array, blob, positions ) - # Otherwise, place blob at a random available position + # Otherwise, place object at a random available position else: collection_array, placed = random_placement(collection_array, blob, rng) - # Break if blob could not be placed + # Break if object could not be placed if not placed: break diff --git a/qim3d/gui/interface.py b/qim3d/gui/interface.py index 01dbaf256485a6578a7035f0317a20f2fe295208..c559685095cfb69ecf950fcf37e961c7ad0c6bda 100644 --- a/qim3d/gui/interface.py +++ b/qim3d/gui/interface.py @@ -57,7 +57,7 @@ class BaseInterface(ABC): If defined, the interface will be launched with the image already there This argument is used especially in jupyter notebooks, where you can launch interface in loop with different picture every step - force_light_mode: The qim platform doesn't have night mode. The qim_theme thus + force_light_mode: The QIM platform doesn't have night mode. The QimTheme thus has option to display only light mode so it corresponds with the website. Preferably will be removed as we add night mode to the website. """ diff --git a/qim3d/gui/qim_theme.py b/qim3d/gui/qim_theme.py index 4dd19054b55ae0f0e54f01a9f9b41bbd59527d70..f13594e4c03bcac851d4e5416e8675cd3fd5f837 100644 --- a/qim3d/gui/qim_theme.py +++ b/qim3d/gui/qim_theme.py @@ -2,7 +2,7 @@ import gradio as gr class QimTheme(gr.themes.Default): """ - QIM3D Theme for gradio interface + Theme for qim3d gradio interfaces. The theming options are quite broad. However if there is something you can not achieve with this theme there is a possibility to add some more css if you override _get_css_theme function as shown at the bottom in comments. @@ -12,7 +12,7 @@ class QimTheme(gr.themes.Default): Parameters: ----------- - force_light_mode (bool, optional): Gradio themes have dark mode by default. - Qim platform is not ready for dark mode yet, thus the tools should also be in light mode. + QIM platform is not ready for dark mode yet, thus the tools should also be in light mode. This sets the darkmode values to be the same as light mode values. """ super().__init__() diff --git a/qim3d/io/saving.py b/qim3d/io/saving.py index 4ba7ef00dfe12fe7bbec2e44e6731529b87db75c..efc2333ad2f608cda6247c9f2b9070cf817ba828 100644 --- a/qim3d/io/saving.py +++ b/qim3d/io/saving.py @@ -413,7 +413,8 @@ def save( """Save data to a specified file path. Args: - path (str): The path to save file to + path (str): The path to save file to. File format is chosen based on the extension. + Supported extensions are: <em>'.tif', '.tiff', '.nii', '.nii.gz', '.h5', '.vol', '.vgi', '.dcm', '.DCM', '.zarr', '.jpeg', '.jpg', '.png'</em> data (numpy.ndarray): The data to be saved replace (bool, optional): Specifies if an existing file with identical path should be replaced. Default is False. @@ -425,6 +426,13 @@ def save( as several files (only relevant for TIFF stacks). Default is 0, i.e., the first dimension. **kwargs: Additional keyword arguments to be passed to the DataSaver constructor + Raises: + ValueError: If the provided path is an existing directory and self.basename is not provided <strong>OR</strong> + If the file format is not supported <strong>OR</strong> + If the provided path does not exist and self.basename is not provided <strong>OR</strong> + If a file extension is not provided <strong>OR</strong> + if a file with the specified path already exists and replace=False. + Example: ```python import qim3d diff --git a/qim3d/processing/operations.py b/qim3d/processing/operations.py index 8d2d0f83995ccb3507672a354fcb9d0b7cfd185e..e559a16d0100186e36ba96b597b6bdfa58f286fb 100644 --- a/qim3d/processing/operations.py +++ b/qim3d/processing/operations.py @@ -91,6 +91,9 @@ def watershed(bin_vol: np.ndarray, min_distance: int = 5) -> tuple[np.ndarray, i import skimage import scipy + if len(np.unique(bin_vol)) > 2: + raise ValueError("bin_vol has to be binary volume - it must contain max 2 unique values.") + # Compute distance transform of binary volume distance = scipy.ndimage.distance_transform_edt(bin_vol) @@ -125,6 +128,7 @@ def fade_mask( geometry: str = "spherical", invert: bool = False, axis: int = 0, + **kwargs, ) -> np.ndarray: """ Apply edge fading to a volume. @@ -133,8 +137,10 @@ def fade_mask( 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.5. - geometric (str, optional): The geometric shape of the fading. Can be 'spherical' or 'cylindrical'. Defaults to 'spherical'. + geometry (str, optional): The geometric shape of the fading. Can be 'spherical' or 'cylindrical'. Defaults to 'spherical'. + invert (bool, optional): Flag for inverting the fading. Defaults to False. axis (int, optional): The axis along which to apply the fading. Defaults to 0. + **kwargs: Additional keyword arguments for the edge fading. Returns: vol_faded (np.ndarray): The volume with edge fading applied. @@ -165,6 +171,9 @@ def fade_mask( shape = vol.shape z, y, x = np.indices(shape) + # Store the original maximum value of the volume + original_max_value = np.max(vol) + # Calculate the center of the array center = np.array([(s - 1) / 2 for s in shape]) @@ -177,10 +186,18 @@ def fade_mask( 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'") + raise ValueError("Geometry must be 'spherical' or 'cylindrical'") + + # Compute the maximum distance from the center + max_distance = np.linalg.norm(center) + + # Compute ratio to make synthetic blobs exactly cylindrical + # target_max_normalized_distance = 1.4 works well to make the blobs cylindrical + if "target_max_normalized_distance" in kwargs: + target_max_normalized_distance = kwargs["target_max_normalized_distance"] + ratio = np.max(distance) / (target_max_normalized_distance * max_distance) # 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 @@ -196,7 +213,10 @@ def fade_mask( # Apply the fading to the volume vol_faded = vol * fade_array - return vol_faded + # Normalize the volume to retain the original maximum value + vol_normalized = vol_faded * (original_max_value / np.max(vol_faded)) + + return vol_normalized def overlay_rgb_images( diff --git a/qim3d/utils/cli.py b/qim3d/utils/cli.py index f07135782e2cb65ea0350c53d9108c7b160dca29..3dd42a5a66ff05b0a68da916f2f44e32473aba79 100644 --- a/qim3d/utils/cli.py +++ b/qim3d/utils/cli.py @@ -2,7 +2,7 @@ import argparse from qim3d.gui import data_explorer, iso3d, annotation_tool, local_thickness, layers2d def main(): - parser = argparse.ArgumentParser(description='Qim3d command-line interface.') + parser = argparse.ArgumentParser(description='qim3d command-line interface.') subparsers = parser.add_subparsers(title='Subcommands', dest='subcommand') # subcommands diff --git a/qim3d/viz/__init__.py b/qim3d/viz/__init__.py index 0e5338c8e19c2311ba806c26785e24061dfdc043..33d94416d98cdf1af5d2c9fca548416eee65a359 100644 --- a/qim3d/viz/__init__.py +++ b/qim3d/viz/__init__.py @@ -7,7 +7,7 @@ from .explore import ( slicer, slices, chunks, - threshold, + histogram, ) from .itk_vtk_viewer import itk_vtk, Installer, NotInstalledError from .k3d import vol, mesh diff --git a/qim3d/viz/colormaps.py b/qim3d/viz/colormaps.py index ac9fb66c3721120e1738efb3cd5eb45d57ac827e..807a083885544eb2901ccb86261f1fd834b12100 100644 --- a/qim3d/viz/colormaps.py +++ b/qim3d/viz/colormaps.py @@ -86,11 +86,17 @@ def objects( ```  + Tip: + It can be easily used when calling visualization functions as + ```python + qim3d.viz.slices(segmented_volume, cmap = 'objects') + ``` + which automatically detects number of unique classes + and creates the colormap object with defualt arguments. + Tip: The `min_dist` parameter can be used to control the distance between neighboring colors.  - - """ from skimage import color diff --git a/qim3d/viz/explore.py b/qim3d/viz/explore.py index 12f49fcb7bd69ecbb3d65c23555aaa55e97baaa1..49d116305d23fa0bca22987cded859a0b471ab7f 100644 --- a/qim3d/viz/explore.py +++ b/qim3d/viz/explore.py @@ -17,10 +17,10 @@ import zarr from qim3d.utils.logger import log from ipywidgets import interact, IntSlider, FloatSlider, Dropdown from skimage.filters import threshold_otsu, threshold_isodata, threshold_li, threshold_mean, threshold_minimum, threshold_triangle, threshold_yen +import seaborn as sns import qim3d - def slices( vol: np.ndarray, axis: int = 0, @@ -34,9 +34,10 @@ def slices( img_width: int = 2, show: bool = False, show_position: bool = True, - interpolation: Optional[str] = "none", + interpolation: Optional[str] = None, img_size=None, cbar: bool = False, + cbar_style: str = "small", **imshow_kwargs, ) -> plt.Figure: """Displays one or several slices from a 3d volume. @@ -60,6 +61,7 @@ def slices( show_position (bool, optional): If True, displays the position of the slices. Defaults to True. interpolation (str, optional): Specifies the interpolation method for the image. Defaults to None. cbar (bool, optional): Adds a colorbar positioned in the top-right for the corresponding colormap and data range. Defaults to False. + cbar_style (str, optional): Determines the style of the colorbar. Option 'small' is height of one image row. Option 'large' spans full height of image grid. Defaults to 'small'. Returns: fig (matplotlib.figure.Figure): The figure with the slices from the 3d array. @@ -69,6 +71,7 @@ def slices( ValueError: If the axis to slice along is not a valid choice, i.e. not an integer between 0 and the number of dimensions of the volume minus 1. ValueError: If the file or array is not a volume with at least 3 dimensions. ValueError: If the `position` keyword argument is not a integer, list of integers or one of the following strings: "start", "mid" or "end". + ValueError: If the cbar_style keyword argument is not one of the following strings: 'small' or 'large'. Example: ```python @@ -83,6 +86,11 @@ def slices( img_height = img_size img_width = img_size + # If we pass python None to the imshow function, it will set to + # default value 'antialiased' + if interpolation is None: + interpolation = 'none' + # Numpy array or Torch tensor input if not isinstance(vol, (np.ndarray, da.core.Array)): raise ValueError("Data type not supported") @@ -92,6 +100,10 @@ def slices( "The provided object is not a volume as it has less than 3 dimensions." ) + cbar_style_options = ["small", "large"] + if cbar_style not in cbar_style_options: + raise ValueError(f"Value '{cbar_style}' is not valid for colorbar style. Please select from {cbar_style_options}.") + if isinstance(vol, da.core.Array): vol = vol.compute() @@ -101,6 +113,19 @@ def slices( f"Invalid value for 'axis'. It should be an integer between 0 and {vol.ndim - 1}." ) + if type(cmap) == matplotlib.colors.LinearSegmentedColormap or cmap == 'objects': + num_labels = len(np.unique(vol)) + + if cmap == 'objects': + cmap = qim3d.viz.colormaps.objects(num_labels) + # If vmin and vmax are not set like this, then in case the + # number of objects changes on new slice, objects might change + # colors. So when using a slider, the same object suddently + # changes color (flickers), which is confusing and annoying. + vmin = 0 + vmax = num_labels + + # Get total number of slices in the specified dimension n_total = vol.shape[axis] @@ -146,9 +171,10 @@ def slices( vol = vol.compute() if cbar: - # In this case, we want the vrange to be constant across the slices, which makes them all comparable to a single cbar. - new_vmin = vmin if vmin else np.min(vol) - new_vmax = vmax if vmax else np.max(vol) + # In this case, we want the vrange to be constant across the + # slices, which makes them all comparable to a single cbar. + new_vmin = vmin if vmin is not None else np.min(vol) + new_vmax = vmax if vmax is not None else np.max(vol) # Run through each ax of the grid for i, ax_row in enumerate(axs): @@ -158,8 +184,9 @@ def slices( slice_img = vol.take(slice_idxs[slice_idx], axis=axis) if not cbar: - # If vmin is higher than the highest value in the image ValueError is raised - # We don't want to override the values because next slices might be okay + # If vmin is higher than the highest value in the + # image ValueError is raised. We don't want to + # override the values because next slices might be okay new_vmin = ( None if (isinstance(vmin, (float, int)) and vmin > np.max(slice_img)) @@ -216,16 +243,27 @@ def slices( with warnings.catch_warnings(): warnings.simplefilter("ignore", category=UserWarning) fig.tight_layout() + norm = matplotlib.colors.Normalize(vmin=new_vmin, vmax=new_vmax, clip=True) mappable = matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap) - # Figure coordinates of top-right axis - tr_pos = np.atleast_1d(axs[0])[-1].get_position() - # The width is divided by ncols to make it the same relative size to the images - cbar_ax = fig.add_axes( - [tr_pos.x1 + 0.05 / ncols, tr_pos.y0, 0.05 / ncols, tr_pos.height] - ) - fig.colorbar(mappable=mappable, cax=cbar_ax, orientation="vertical") + if cbar_style =="small": + # Figure coordinates of top-right axis + tr_pos = np.atleast_1d(axs[0])[-1].get_position() + # The width is divided by ncols to make it the same relative size to the images + cbar_ax = fig.add_axes( + [tr_pos.x1 + 0.05 / ncols, tr_pos.y0, 0.05 / ncols, tr_pos.height] + ) + fig.colorbar(mappable=mappable, cax=cbar_ax, orientation="vertical") + elif cbar_style == "large": + # Figure coordinates of bottom- and top-right axis + br_pos = np.atleast_1d(axs[-1])[-1].get_position() + tr_pos = np.atleast_1d(axs[0])[-1].get_position() + # The width is divided by ncols to make it the same relative size to the images + cbar_ax = fig.add_axes( + [br_pos.xmax + 0.05 / ncols, br_pos.y0+0.0015, 0.05 / ncols, (tr_pos.y1 - br_pos.y0)-0.0015] + ) + fig.colorbar(mappable=mappable, cax=cbar_ax, orientation="vertical") if show: plt.show() @@ -260,7 +298,7 @@ def slicer( img_height: int = 3, img_width: int = 3, show_position: bool = False, - interpolation: Optional[str] = "none", + interpolation: Optional[str] = None, img_size=None, cbar: bool = False, **imshow_kwargs, @@ -777,6 +815,137 @@ def chunks(zarr_path: str, **kwargs): # Display the VBox display(final_layout) +def histogram( + vol: np.ndarray, + bins: Union[int, str] = "auto", + slice_idx: Union[int, str] = None, + axis: int = 0, + kde: bool = True, + log_scale: bool = False, + despine: bool = True, + show_title: bool = True, + color="qim3d", + edgecolor=None, + figsize=(8, 4.5), + element="step", + return_fig=False, + show=True, + **sns_kwargs, +): + """ + Plots a histogram of voxel intensities from a 3D volume, with options to show a specific slice or the entire volume. + + Utilizes [seaborn.histplot](https://seaborn.pydata.org/generated/seaborn.histplot.html) for visualization. + + Args: + vol (np.ndarray): A 3D NumPy array representing the volume to be visualized. + bins (Union[int, str], optional): Number of histogram bins or a binning strategy (e.g., "auto"). Default is "auto". + axis (int, optional): Axis along which to take a slice. Default is 0. + slice_idx (Union[int, str], optional): Specifies the slice to visualize. If an integer, it represents the slice index along the selected axis. + If "middle", the function uses the middle slice. If None, the entire volume is visualized. Default is None. + kde (bool, optional): Whether to overlay a kernel density estimate. Default is True. + log_scale (bool, optional): Whether to use a logarithmic scale on the y-axis. Default is False. + despine (bool, optional): If True, removes the top and right spines from the plot for cleaner appearance. Default is True. + show_title (bool, optional): If True, displays a title with slice information. Default is True. + color (str, optional): Color for the histogram bars. If "qim3d", defaults to the qim3d color. Default is "qim3d". + edgecolor (str, optional): Color for the edges of the histogram bars. Default is None. + figsize (tuple, optional): Size of the figure (width, height). Default is (8, 4.5). + element (str, optional): Type of histogram to draw ('bars', 'step', or 'poly'). Default is "step". + return_fig (bool, optional): If True, returns the figure object instead of showing it directly. Default is False. + show (bool, optional): If True, displays the plot. If False, suppresses display. Default is True. + **sns_kwargs: Additional keyword arguments for `seaborn.histplot`. + + Returns: + Optional[matplotlib.figure.Figure]: If `return_fig` is True, returns the generated figure object. Otherwise, returns None. + + Raises: + ValueError: If `axis` is not a valid axis index (0, 1, or 2). + ValueError: If `slice_idx` is an integer and is out of range for the specified axis. + + Example: + ```python + import qim3d + + vol = qim3d.examples.bone_128x128x128 + qim3d.viz.histogram(vol) + ``` +  + + ```python + import qim3d + + vol = qim3d.examples.bone_128x128x128 + qim3d.viz.histogram(vol, bins=32, slice_idx="middle", axis=1, kde=False, log_scale=True) + ``` +  + """ + + if not (0 <= axis < vol.ndim): + raise ValueError(f"Axis must be an integer between 0 and {vol.ndim - 1}.") + + if slice_idx == "middle": + slice_idx = vol.shape[axis] // 2 + + if slice_idx: + if 0 <= slice_idx < vol.shape[axis]: + img_slice = np.take(vol, indices=slice_idx, axis=axis) + data = img_slice.ravel() + title = f"Intensity histogram of slice #{slice_idx} {img_slice.shape} along axis {axis}" + else: + raise ValueError( + f"Slice index out of range. Must be between 0 and {vol.shape[axis] - 1}." + ) + else: + data = vol.ravel() + title = f"Intensity histogram for whole volume {vol.shape}" + + fig, ax = plt.subplots(figsize=figsize) + + if log_scale: + plt.yscale("log") + + if color == "qim3d": + color = qim3d.viz.colormaps.qim(1.0) + + sns.histplot( + data, + bins=bins, + kde=kde, + color=color, + element=element, + edgecolor=edgecolor, + **sns_kwargs, + ) + + if despine: + sns.despine( + fig=None, + ax=None, + top=True, + right=True, + left=False, + bottom=False, + offset={"left": 0, "bottom": 18}, + trim=True, + ) + + plt.xlabel("Voxel Intensity") + plt.ylabel("Frequency") + + if show_title: + plt.title(title, fontsize=10) + + # Handle show and return + if show: + plt.show() + else: + plt.close(fig) + + if return_fig: + return fig + + + def threshold( volume: np.ndarray, cmap_image: str = 'viridis', @@ -821,7 +990,6 @@ def threshold( in this mode. - Example: ```python import qim3d @@ -948,3 +1116,4 @@ def threshold( return slicer_obj + \ No newline at end of file diff --git a/qim3d/viz/itk_vtk_viewer/run.py b/qim3d/viz/itk_vtk_viewer/run.py index 867dabe41fbf71ef1135bd1bf0e6c7ad7e7c06b0..8ff029fc63f29df8e3dbffd81ec29c81ce74c52b 100644 --- a/qim3d/viz/itk_vtk_viewer/run.py +++ b/qim3d/viz/itk_vtk_viewer/run.py @@ -83,7 +83,7 @@ def itk_vtk( Opens a visualization window using the itk-vtk-viewer. Works both for common file types (Tiff, Nifti, etc.) and for **OME-Zarr stores**. This function starts the itk-vtk-viewer, either using a global - installation or a local installation within the QIM package. It also starts + installation or a local installation within the qim3d package. It also starts an HTTP server to serve the file to the viewer. Optionally, it can automatically open a browser window to display the viewer. If the viewer is not installed, it raises a NotInstalledError. diff --git a/qim3d/viz/k3d.py b/qim3d/viz/k3d.py index 40ed5f9ac4b1439b4861706b997a0c5535272f41..c3018e081bee7fd2179bae8ae92a3a7884f00d3f 100644 --- a/qim3d/viz/k3d.py +++ b/qim3d/viz/k3d.py @@ -8,6 +8,8 @@ Volumetric visualization using K3D """ import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import Colormap from qim3d.utils.logger import log from qim3d.utils.misc import downscale_img, scale_to_float16 @@ -19,6 +21,7 @@ def vol( save=False, grid_visible=False, cmap=None, + constant_opacity=False, vmin=None, vmax=None, samples="auto", @@ -40,7 +43,8 @@ def vol( 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. - cmap (list, optional): The color map to be used for the volume rendering. Defaults to None. + cmap (str or matplotlib.colors.Colormap or list, optional): The color map to be used for the volume rendering. If a string is passed, it should be a matplotlib colormap name. Defaults to None. + constant_opacity (bool, float): Set to True if doing an object label visualization with a corresponding cmap; otherwise, the plot may appear poorly. Defaults to False. vmin (float, optional): Together with vmax defines the data range the colormap covers. By default colormap covers the full range. Defaults to None. vmax (float, optional): Together with vmin defines the data range the colormap covers. By default colormap covers the full range. Defaults to None samples (int, optional): The number of samples to be used for the volume rendering in k3d. Defaults to 512. @@ -55,6 +59,9 @@ def vol( Raises: ValueError: If `aspectmode` is not `'data'` or `'cube'`. + Tip: + The function can be used for object label visualization using a `cmap` created with `qim3d.viz.colormaps.objects` along with setting `objects=True`. The latter ensures appropriate rendering. + Example: Display a volume inline: @@ -122,6 +129,24 @@ def vol( if vmax: color_range[1] = vmax + # Handle the different formats that cmap can take + if cmap: + if isinstance(cmap, str): + cmap = plt.get_cmap(cmap) # Convert to Colormap object + if isinstance(cmap, Colormap): + # Convert to the format of cmap required by k3d.volume + attr_vals = np.linspace(0.0, 1.0, num=cmap.N) + RGB_vals = cmap(np.arange(0, cmap.N))[:, :3] + cmap = np.column_stack((attr_vals, RGB_vals)).tolist() + + # Default k3d.volume settings + opacity_function = [] + interpolation = True + if constant_opacity: + # without these settings, the plot will look bad when cmap is created with qim3d.viz.colormaps.objects + opacity_function = [0.0, float(constant_opacity), 1.0, float(constant_opacity)] + interpolation = False + # Create the volume plot plt_volume = k3d.volume( img, @@ -133,6 +158,8 @@ def vol( color_map=cmap, samples=samples, color_range=color_range, + opacity_function=opacity_function, + interpolation=interpolation, ) plot = k3d.plot(grid_visible=grid_visible, **kwargs) plot += plt_volume @@ -144,7 +171,7 @@ def vol( if show: plot.display() else: - return plot + return plot def mesh( @@ -200,11 +227,14 @@ def mesh( raise ValueError("Vertices array must have shape (N, 3)") if faces.shape[1] != 3: raise ValueError("Faces array must have shape (M, 3)") - - # Ensure the correct data types and memory layout - verts = np.ascontiguousarray(verts.astype(np.float32)) # Cast and ensure C-contiguous layout - faces = np.ascontiguousarray(faces.astype(np.uint32)) # Cast and ensure C-contiguous layout + # Ensure the correct data types and memory layout + verts = np.ascontiguousarray( + verts.astype(np.float32) + ) # Cast and ensure C-contiguous layout + faces = np.ascontiguousarray( + faces.astype(np.uint32) + ) # Cast and ensure C-contiguous layout # Create the mesh plot plt_mesh = k3d.mesh(