Skip to content
Snippets Groups Projects
Commit cc05686f authored by s193396's avatar s193396 Committed by fima
Browse files

Structure tensor visualizations

parent 851d5719
No related branches found
No related tags found
1 merge request!107Structure tensor visualizations
Image diff could not be displayed: it is too large. Options to address this: view the blob.
%% Cell type:code id: tags: %% Cell type:code id: tags:
``` python ``` python
import qim3d import qim3d
``` ```
%% Output
WARNING:root:Could not load CuPy: No module named 'cupy'
%% Cell type:markdown id: tags: %% Cell type:markdown id: tags:
### Structure tensor notebook ### Structure tensor notebook
%% Cell type:markdown id: tags: %% Cell type:markdown id: tags:
This notebook shows how to compute eigenvalues and eigenvectors of the **structure tensor** of a 3D volume using the `qim3d` library. The structure tensor (matrix) represents information about the local gradient directions in the volume, such that the eigenvectors represent the orientation of the structure in the volume, and the corresponding eigenvaleus indicate the magnitude. This notebook shows how to compute eigenvalues and eigenvectors of the **structure tensor** of a 3D volume using the `qim3d` library. The structure tensor (matrix) represents information about the local gradient directions in the volume, such that the eigenvectors represent the orientation of the structure in the volume, and the corresponding eigenvaleus indicate the magnitude.
The function `qim3d.processing.structure_tensor` returns two arrays `val` and `vec` for the eigenvalues and eigenvectors, respectively.\ The function `qim3d.processing.structure_tensor` returns two arrays `val` and `vec` for the eigenvalues and eigenvectors, respectively.\
By having the argument `visulize = True`, the function displays two figures: By having the argument `visulize = True`, the function displays a figure of three subplots:
* Slice of volume with vector field of the eigenvectors * Slice of volume with vector field of the eigenvectors
* Orientation histogram of the eigenvectors * Orientation histogram of the eigenvectors
* Slice of volume with overlaying colors of the orientation
For all three subplots, the colors used to visualize the orientation within the volume are from the HSV colorspace. In these visualizations, the saturation of the color corresponds to the vector component of the slicing direction (i.e. $z$-component when choosing visualization along `axis = 0`). Hence, if an orientation in the volume is orthogonal to the slicing direction, the corresponding color of the visualization will be gray.
%% Cell type:markdown id: tags: %% Cell type:markdown id: tags:
### **Example 1**: Structure tensor of bone volume ### **Example:** Structure tensor of brain tissue volume
%% Cell type:code id: tags: %% Cell type:code id: tags:
``` python ``` python
# Import 3D volume of bone # Import 3D volume of brain tissue
bone = qim3d.examples.bone_128x128x128 NT = qim3d.examples.NT_128x128x128
# Compute eigenvalues and eigenvectors of the structure tensor # Visuaize the 3D volume
val, vec = qim3d.processing.structure_tensor(bone, visualize = True, axis = 1) qim3d.viz.vol(NT)
``` ```
%% Output %% Output
%% Cell type:markdown id: tags: %% Cell type:markdown id: tags:
### **Example 2:** Structure tensor of brain tissue volume From the visualization of the full volume, it can be seen that the circular structures of the brain tissue are aligned orthogonal to the $z$-axis (`axis = 0`). By choosing to slice the volume in this direction, the structure tensor visualizations will largely be gray, since the $z$-component of the eigenvectors are close to $0$, meaning the saturation of the coloring will be close to $0$ (i.e. gray). This can be seen below.
%% Cell type:code id: tags: %% Cell type:code id: tags:
``` python ``` python
# Import 3D volume of brain tissue # Compute eigenvalues and eigenvectors of the structure tensor
NT = qim3d.examples.NT_128x128x128 val, vec = qim3d.processing.structure_tensor(NT, visualize = True, axis = 0) # Slicing in z-direction
```
%% Output
%% Cell type:markdown id: tags:
By slicing the volume in the $x$-direction (`axis = 2`) instead, the orientation along the length of the structures in the brain tissue can be seen instead. Then the structure tensor visualizations will be largely blue, corresponding to eigenvectors along the $x$-direction with angles of $\approx \frac{\pi}{2}$ radians ($90$ degrees).
%% Cell type:code id: tags:
``` python
# Compute eigenvalues and eigenvectors of the structure tensor # Compute eigenvalues and eigenvectors of the structure tensor
val, vec = qim3d.processing.structure_tensor(NT, visualize = True, axis = 2) val, vec = qim3d.processing.structure_tensor(NT, visualize = True, axis = 2) # Slicing in x-direction
``` ```
%% Output %% Output
......
...@@ -9,11 +9,12 @@ def structure_tensor( ...@@ -9,11 +9,12 @@ def structure_tensor(
vol: np.ndarray, vol: np.ndarray,
sigma: float = 1.0, sigma: float = 1.0,
rho: float = 6.0, rho: float = 6.0,
base_noise: bool = True,
full: bool = False, full: bool = False,
visualize = False, visualize = False,
**viz_kwargs **viz_kwargs
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
"""Wrapper for the 3D structure tensor implementation from the [structure_tensor package](https://github.com/Skielex/structure-tensor/) """Wrapper for the 3D structure tensor implementation from the [structure_tensor package](https://github.com/Skielex/structure-tensor/).
The structure tensor algorithm is a method for analyzing the orientation of fiber-like structures in 3D images. The structure tensor algorithm is a method for analyzing the orientation of fiber-like structures in 3D images.
...@@ -26,6 +27,7 @@ def structure_tensor( ...@@ -26,6 +27,7 @@ def structure_tensor(
vol (np.ndarray): 3D NumPy array representing the volume. vol (np.ndarray): 3D NumPy array representing the volume.
sigma (float, optional): A noise scale, structures smaller than sigma will be removed by smoothing. 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. rho (float, optional): An integration scale giving the size over the neighborhood in which the orientation is to be analysed.
base_noise (bool, optional): A flag indicating whether to add a small noise to the volume. Default is True.
full (bool, optional): A flag indicating that all three eigenvalues should be returned. Default is False. 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. visualize (bool, optional): Whether to visualize the structure tensor. Default is False.
**viz_kwargs: Additional keyword arguments for passed to `qim3d.viz.vectors`. Only used if `visualize=True`. **viz_kwargs: Additional keyword arguments for passed to `qim3d.viz.vectors`. Only used if `visualize=True`.
...@@ -44,7 +46,7 @@ def structure_tensor( ...@@ -44,7 +46,7 @@ def structure_tensor(
vol = qim3d.examples.NT_128x128x128 vol = qim3d.examples.NT_128x128x128
val, vec = qim3d.processing.structure_tensor(vol, visualize = True, axis = 2) val, vec = qim3d.processing.structure_tensor(vol, visualize = True, axis = 2)
``` ```
![structure tensor](assets/screenshots/structure_tensor.gif) ![structure tensor](assets/screenshots/structure_tensor_visualization.gif)
!!! info "Runtime and memory usage of the structure tensor method for different volume sizes" !!! info "Runtime and memory usage of the structure tensor method for different volume sizes"
...@@ -82,8 +84,20 @@ def structure_tensor( ...@@ -82,8 +84,20 @@ def structure_tensor(
if vol.dtype != np.float32 and vol.dtype != np.float64: if vol.dtype != np.float32 and vol.dtype != np.float64:
vol = vol.astype(np.float32) vol = vol.astype(np.float32)
if base_noise:
# Add small noise to the volume
# FIXME: This is a temporary solution to avoid uniform regions with constant values
# in the volume, which lead to numerical issues in the structure tensor computation
vol_noisy = vol + np.random.default_rng(seed = 0).uniform(0, 1e-10, size=vol.shape)
# Compute the structure tensor (of volume with noise)
s_vol = st.structure_tensor_3d(vol_noisy, sigma, rho)
else:
# Compute the structure tensor (of volume without noise)
s_vol = st.structure_tensor_3d(vol, sigma, rho) s_vol = st.structure_tensor_3d(vol, sigma, rho)
# Compute the eigenvalues and eigenvectors of the structure tensor
val, vec = st.eig_special_3d(s_vol, full=full) val, vec = st.eig_special_3d(s_vol, full=full)
if visualize: if visualize:
......
import numpy as np import numpy as np
from typing import Optional, Union, Tuple from typing import Optional, Union, Tuple
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import structure_tensor as st
from matplotlib.gridspec import GridSpec from matplotlib.gridspec import GridSpec
import ipywidgets as widgets import ipywidgets as widgets
import logging as log import logging as log
def vectors( def vectors(
volume: np.ndarray, volume: np.ndarray,
vec: np.ndarray, vec: np.ndarray,
axis: int = 0, axis: int = 0,
slice_idx: Optional[Union[int, float]] = None, slice_idx: Optional[Union[int, float]] = None,
grid_size: int = 10,
interactive: bool = True, interactive: bool = True,
figsize: Tuple[int, int] = (10, 5), figsize: Tuple[int, int] = (10, 5),
show: bool = False, show: bool = False,
) -> Union[plt.Figure, widgets.interactive]: ) -> Union[plt.Figure, widgets.interactive]:
""" """
Displays a grid of eigenvectors from the structure tensor to visualize the orientation of the structures in the volume. Visualizes the orientation of the structures in a 3D volume using the eigenvectors of the structure tensor.
Args: Args:
volume (np.ndarray): The 3D volume to be sliced. volume (np.ndarray): The 3D volume to be sliced.
vec (np.ndarray): The eigenvectors of the structure tensor. vec (np.ndarray): The eigenvectors of the structure tensor.
axis (int, optional): The axis along which to visualize the local thickness. Defaults to 0. axis (int, optional): The axis along which to visualize the orientation. Defaults to 0.
slice_idx (int or float, optional): The initial slice to be visualized. The slice index 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 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 to be visualized. If value is a float between 0 and 1, it will be multiplied by the
...@@ -39,29 +40,43 @@ def vectors( ...@@ -39,29 +40,43 @@ def vectors(
Returns: Returns:
fig (Union[plt.Figure, widgets.interactive]): If `interactive` is True, returns an interactive widget. Otherwise, returns a matplotlib figure. fig (Union[plt.Figure, widgets.interactive]): If `interactive` is True, returns an interactive widget. Otherwise, returns a matplotlib figure.
Note:
The orientation of the vectors is visualized using an HSV color map, where the saturation corresponds to the vector component
of the slicing direction (i.e. z-component when choosing visualization along `axis = 0`). Hence, if an orientation in the volume
is orthogonal to the slicing direction, the corresponding color of the visualization will be gray.
Example: Example:
```python ```python
import qim3d import qim3d
vol = qim3d.examples.NT_128x128x128 vol = qim3d.examples.NT_128x128x128
val, vec = qim3d.processing.structure_tensor(vol, visualize=True, axis=2) val, vec = qim3d.processing.structure_tensor(vol)
# Visualize the structure tensor # Visualize the structure tensor
qim3d.viz.vectors(vol, vec, axis=2, slice_idx=0.5, interactive=True) qim3d.viz.vectors(vol, vec, axis = 2, interactive = True)
``` ```
![structure tensor](assets/screenshots/structure_tensor.gif) ![structure tensor](assets/screenshots/structure_tensor_visualization.gif)
""" """
# Define Grid size limits # Ensure volume is a float
if volume.dtype != np.float32 and volume.dtype != np.float64:
volume = volume.astype(np.float32)
# Normalize the volume if needed (i.e. if values are in [0, 255])
if volume.max() > 1.0:
volume = volume / 255.0
# Define grid size limits
min_grid_size = max(1, volume.shape[axis] // 50) min_grid_size = max(1, volume.shape[axis] // 50)
max_grid_size = max(1, volume.shape[axis] // 10) max_grid_size = max(1, volume.shape[axis] // 10)
if max_grid_size <= min_grid_size: if max_grid_size <= min_grid_size:
max_grid_size = min_grid_size * 5 max_grid_size = min_grid_size * 5
# Testing if not grid_size:
grid_size = (min_grid_size + max_grid_size) // 2 grid_size = (min_grid_size + max_grid_size) // 2
# Testing
if grid_size < min_grid_size or grid_size > max_grid_size: if grid_size < min_grid_size or grid_size > max_grid_size:
# Adjust grid size as little as possible to be within the limits # Adjust grid size as little as possible to be within the limits
grid_size = min(max(min_grid_size, grid_size), max_grid_size) grid_size = min(max(min_grid_size, grid_size), max_grid_size)
...@@ -69,40 +84,62 @@ def vectors( ...@@ -69,40 +84,62 @@ def vectors(
def _structure_tensor(volume, vec, axis, slice_idx, grid_size, figsize, show): def _structure_tensor(volume, vec, axis, slice_idx, grid_size, figsize, show):
# Create subplots
fig, ax = plt.subplots(1, 2, figsize=figsize, layout="constrained")
# Choose the appropriate slice based on the specified dimension # Choose the appropriate slice based on the specified dimension
if axis == 0: if axis == 0:
data_slice = volume[slice_idx, :, :] data_slice = volume[slice_idx, :, :]
vectors_slice_x = vec[0, slice_idx, :, :] vectors_slice_x = vec[0, slice_idx, :, :]
vectors_slice_y = vec[1, slice_idx, :, :] vectors_slice_y = vec[1, slice_idx, :, :]
vectors_slice_z = vec[2, slice_idx, :, :]
elif axis == 1: elif axis == 1:
data_slice = volume[:, slice_idx, :] data_slice = volume[:, slice_idx, :]
vectors_slice_x = vec[0, :, slice_idx, :] vectors_slice_x = vec[0, :, slice_idx, :]
vectors_slice_y = vec[2, :, slice_idx, :] vectors_slice_y = vec[2, :, slice_idx, :]
vectors_slice_z = vec[1, :, slice_idx, :]
elif axis == 2: elif axis == 2:
data_slice = volume[:, :, slice_idx] data_slice = volume[:, :, slice_idx]
vectors_slice_x = vec[1, :, :, slice_idx] vectors_slice_x = vec[1, :, :, slice_idx]
vectors_slice_y = vec[2, :, :, slice_idx] vectors_slice_y = vec[2, :, :, slice_idx]
vectors_slice_z = vec[0, :, :, slice_idx]
else: else:
raise ValueError("Invalid dimension. Use 0 for Z, 1 for Y, or 2 for X.") raise ValueError("Invalid dimension. Use 0 for Z, 1 for Y, or 2 for X.")
ax[0].imshow(data_slice, cmap=plt.cm.gray) # Create three subplots
fig, ax = plt.subplots(1, 3, figsize=figsize, layout="constrained")
blend_hue_saturation = lambda hue, sat : hue * (1 - sat) + 0.5 * sat # Function for blending hue and saturation
blend_slice_colors = lambda slice, colors : 0.5 * (slice + colors) # Function for blending image slice with orientation colors
# ----- Subplot 1: Image slice with orientation vectors ----- #
# Create meshgrid with the correct dimensions # Create meshgrid with the correct dimensions
xmesh, ymesh = np.mgrid[0 : data_slice.shape[0], 0 : data_slice.shape[1]] xmesh, ymesh = np.mgrid[0 : data_slice.shape[0], 0 : data_slice.shape[1]]
# Create a slice object for selecting the grid points # Create a slice object for selecting the grid points
g = slice(grid_size // 2, None, grid_size) g = slice(grid_size // 2, None, grid_size)
# Angles from 0 to pi
angles_quiver = np.mod(np.arctan2(vectors_slice_y[g, g], vectors_slice_x[g, g]), np.pi)
# Calculate z-component (saturation)
saturation_quiver = (vectors_slice_z[g, g]**2)[:, :, np.newaxis]
# Calculate hue
hue_quiver = plt.cm.hsv(angles_quiver / np.pi)
# Blend hue and saturation
rgba_quiver = blend_hue_saturation(hue_quiver, saturation_quiver)
rgba_quiver = np.clip(rgba_quiver, 0, 1) # Ensure rgba values are values within [0, 1]
rgba_quiver_flat = rgba_quiver.reshape((rgba_quiver.shape[0]*rgba_quiver.shape[1], 4)) # Flatten array for quiver plot
# Plot vectors # Plot vectors
ax[0].quiver( ax[0].quiver(
ymesh[g, g], ymesh[g, g],
xmesh[g, g], xmesh[g, g],
vectors_slice_x[g, g], vectors_slice_x[g, g],
vectors_slice_y[g, g], vectors_slice_y[g, g],
color="orange", color=rgba_quiver_flat,
angles="xy", angles="xy",
) )
ax[0].quiver( ax[0].quiver(
...@@ -110,35 +147,64 @@ def vectors( ...@@ -110,35 +147,64 @@ def vectors(
xmesh[g, g], xmesh[g, g],
-vectors_slice_x[g, g], -vectors_slice_x[g, g],
-vectors_slice_y[g, g], -vectors_slice_y[g, g],
color="orange", color=rgba_quiver_flat,
angles="xy", angles="xy",
) )
# Set title and turn off axis ax[0].imshow(data_slice, cmap = plt.cm.gray)
ax[0].set_title(f"Slice {slice_idx}" if not interactive else None) ax[0].set_title(f"Orientation vectors (slice {slice_idx})" if not interactive else "Orientation vectors")
ax[0].set_axis_off() ax[0].set_axis_off()
# Orientations histogram # ----- Subplot 2: Orientation histogram ----- #
nbins = 36 nbins = 36
angles = np.arctan2(vectors_slice_y, vectors_slice_x) # angles from 0 to pi
distribution, bin_edges = np.histogram(angles, bins=nbins, range=(0, np.pi)) # Angles from 0 to pi
angles = np.mod(np.arctan2(vectors_slice_y, vectors_slice_x), np.pi)
# Find the bin with the maximum count
peak_bin_idx = np.argmax(abs(distribution)) # Orientation histogram over angles
# Calculate the center of the peak bin distribution, bin_edges = np.histogram(angles, bins=nbins, range=(0.0, np.pi))
peak_angle_rad = (bin_edges[peak_bin_idx] + bin_edges[peak_bin_idx + 1]) / 2
# Convert the peak angle to degrees # Half circle (180 deg)
peak_angle_deg = np.degrees(peak_angle_rad) bin_centers = (np.arange(nbins) + 0.5) * np.pi / nbins
bin_centers = (np.arange(nbins) + 0.5) * np.pi / nbins # half circle (180 deg)
colors = plt.cm.hsv(bin_centers / np.pi) # Calculate z-component (saturation) for each bin
ax[1].bar(bin_centers, distribution, width=np.pi / nbins, color=colors) bins = np.digitize(angles.ravel(), bin_edges)
saturation_bin = np.array([np.mean((vectors_slice_z**2).ravel()[bins == i]) \
if np.sum(bins == i) > 0 else 0 for i in range(1, len(bin_edges))])
# Calculate hue for each bin
hue_bin = plt.cm.hsv(bin_centers / np.pi)
# Blend hue and saturation
rgba_bin = hue_bin.copy()
rgba_bin[:, :3] = blend_hue_saturation(hue_bin[:, :3], saturation_bin[:, np.newaxis])
ax[1].bar(bin_centers, distribution, width=np.pi/nbins, color=rgba_bin)
ax[1].set_xlabel("Angle [radians]") ax[1].set_xlabel("Angle [radians]")
ax[1].set_xlim([0, np.pi]) ax[1].set_xlim([0, np.pi])
ax[1].set_aspect(np.pi / ax[1].get_ylim()[1]) ax[1].set_aspect(np.pi / ax[1].get_ylim()[1])
ax[1].set_xticks([0, np.pi / 2, np.pi]) ax[1].set_xticks([0, np.pi / 2, np.pi])
ax[1].set_xticklabels(["0", "$\\frac{\\pi}{2}$", "$\\pi$"]) ax[1].set_xticklabels(["0", "$\\frac{\\pi}{2}$", "$\\pi$"])
ax[1].set_ylabel("Count") ax[1].set_yticks([])
ax[1].set_title(f"Histogram over angles (peak at {round(peak_angle_deg)}°)") ax[1].set_ylabel("Frequency")
ax[1].set_title(f"Histogram over orientation angles")
# ----- Subplot 3: Image slice colored according to orientation ----- #
# Calculate z-component (saturation)
saturation = (vectors_slice_z**2)[:, :, np.newaxis]
# Calculate hue
hue = plt.cm.hsv(angles / np.pi)
# Blend hue and saturation
rgba = blend_hue_saturation(hue, saturation)
# Grayscale image slice blended with orientation colors
data_slice_orientation_colored = (blend_slice_colors(plt.cm.gray(data_slice), rgba) * 255).astype('uint8')
ax[2].imshow(data_slice_orientation_colored)
ax[2].set_title(f"Colored orientations (slice {slice_idx})" if not interactive else "Colored orientations")
ax[2].set_axis_off()
if show: if show:
plt.show() plt.show()
...@@ -155,6 +221,7 @@ def vectors( ...@@ -155,6 +221,7 @@ def vectors(
if slice_idx is None: if slice_idx is None:
slice_idx = volume.shape[axis] // 2 slice_idx = volume.shape[axis] // 2
elif isinstance(slice_idx, float): elif isinstance(slice_idx, float):
if slice_idx < 0 or slice_idx > 1: if slice_idx < 0 or slice_idx > 1:
raise ValueError( raise ValueError(
...@@ -195,8 +262,11 @@ def vectors( ...@@ -195,8 +262,11 @@ def vectors(
sliders_box = widgets.HBox([slide_idx_slider, grid_size_slider]) sliders_box = widgets.HBox([slide_idx_slider, grid_size_slider])
widget_obj = widgets.VBox([sliders_box, widget_obj.children[-1]]) widget_obj = widgets.VBox([sliders_box, widget_obj.children[-1]])
widget_obj.layout.align_items = "center" widget_obj.layout.align_items = "center"
if show: if show:
display(widget_obj) display(widget_obj)
return widget_obj return widget_obj
else: else:
return _structure_tensor(volume, vec, axis, slice_idx, figsize, show) return _structure_tensor(volume, vec, axis, slice_idx, grid_size, figsize, show)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment