diff --git a/qim3d/__init__.py b/qim3d/__init__.py index 02a844ed7d57e7fd42c0eb9ae7b48fd728e7e8c1..eca387a06b555c30c94a7ea7d7ee8a3cfbdff604 100644 --- a/qim3d/__init__.py +++ b/qim3d/__init__.py @@ -1,9 +1,10 @@ -import qim3d.io as io -import qim3d.gui as gui -import qim3d.viz as viz -import qim3d.utils as utils -import qim3d.models as models -import qim3d.processing as processing +# import qim3d.io as io +# import qim3d.gui as gui +# import qim3d.viz as viz +# import qim3d.utils as utils +# import qim3d.models as models +# import qim3d.processing as processing +from . import io, gui, viz, utils, models, processing import logging __version__ = '0.3.2' diff --git a/qim3d/processing/__init__.py b/qim3d/processing/__init__.py index 771b7222bed1aa8e05ce4481c86758123ab26de0..be9d29a22ec4c3795eea5d080c6a59482529522a 100644 --- a/qim3d/processing/__init__.py +++ b/qim3d/processing/__init__.py @@ -1,2 +1,3 @@ from .filters import * -from .local_thickness import local_thickness \ No newline at end of file +from .local_thickness import local_thickness +from .detection import * diff --git a/qim3d/processing/detection.py b/qim3d/processing/detection.py new file mode 100644 index 0000000000000000000000000000000000000000..b74cf36e9e0eacb957fb66975c9a5809dc06abe3 --- /dev/null +++ b/qim3d/processing/detection.py @@ -0,0 +1,99 @@ +import numpy as np +from qim3d.io.logger import log +from skimage.feature import blob_dog + +__all__ = ["Blob"] + + +class Blob: + def __init__( + self, + background="dark", + min_sigma=1, + max_sigma=50, + sigma_ratio=1.6, + threshold=0.5, + overlap=0.5, + threshold_rel=None, + exclude_border=False, + ): + """ + Initialize the blob detection object + Args: + background: 'dark' if background is darker than the blobs, 'bright' if background is lighter than the blobs + min_sigma: The minimum standard deviation for Gaussian kernel + max_sigma: The maximum standard deviation for Gaussian kernel + sigma_ratio: The ratio between the standard deviation of Gaussian Kernels + threshold: The absolute lower bound for scale space maxima. Reduce this to detect blobs with lower intensities. + overlap: The fraction of area of two blobs that overlap + threshold_rel: The relative lower bound for scale space maxima + exclude_border: If True, exclude blobs that are too close to the border of the image + """ + self.background = background + self.min_sigma = min_sigma + self.max_sigma = max_sigma + self.sigma_ratio = sigma_ratio + self.threshold = threshold + self.overlap = overlap + self.threshold_rel = threshold_rel + self.exclude_border = exclude_border + self.vol_shape = None + self.blobs = None + + def detect(self, vol): + """ + Detect blobs in the volume + Args: + vol: The volume to detect blobs in + Returns: + blobs: The blobs found in the volume as (p, r, c, radius) + """ + self.vol_shape = vol.shape + if self.background == "bright": + log.info("Bright background selected, volume will be inverted.") + vol = np.invert(vol) + + blobs = blob_dog( + vol, + min_sigma=self.min_sigma, + max_sigma=self.max_sigma, + sigma_ratio=self.sigma_ratio, + threshold=self.threshold, + overlap=self.overlap, + threshold_rel=self.threshold_rel, + exclude_border=self.exclude_border, + ) + blobs[:, 3] = blobs[:, 3] * np.sqrt(3) # Change sigma to radius + self.blobs = blobs + return self.blobs + + def get_mask(self): + ''' + Retrieve a binary volume with the blobs marked as True + Returns: + binary_volume: A binary volume with the blobs marked as True + ''' + binary_volume = np.zeros(self.vol_shape, dtype=bool) + + for z, y, x, radius in self.blobs: + # Calculate the bounding box around the blob + z_start = max(0, int(z - radius)) + z_end = min(self.vol_shape[0], int(z + radius) + 1) + y_start = max(0, int(y - radius)) + y_end = min(self.vol_shape[1], int(y + radius) + 1) + x_start = max(0, int(x - radius)) + x_end = min(self.vol_shape[2], int(x + radius) + 1) + + z_indices, y_indices, x_indices = np.indices((z_end - z_start, y_end - y_start, x_end - x_start)) + z_indices += z_start + y_indices += y_start + x_indices += x_start + + # Calculate distances from the center of the blob to voxels within the bounding box + dist = np.sqrt((x_indices - x)**2 + (y_indices - y)**2 + (z_indices - z)**2) + + binary_volume[z_start:z_end, y_start:y_end, x_start:x_end][dist <= radius] = True + + return binary_volume + + diff --git a/qim3d/viz/__init__.py b/qim3d/viz/__init__.py index eedcc00b20c7b8f447e47db5540ab169502cb5a1..a641174d1fba75ebc66847d328e92e956b3df8c0 100644 --- a/qim3d/viz/__init__.py +++ b/qim3d/viz/__init__.py @@ -1,3 +1,4 @@ from .visualizations import plot_metrics from .img import grid_pred, grid_overview, slices, slicer, orthogonal, plot_cc, local_thickness from .k3d import vol +from .detection import circles \ No newline at end of file diff --git a/qim3d/viz/detection.py b/qim3d/viz/detection.py new file mode 100644 index 0000000000000000000000000000000000000000..ca9c15fcff663b0d0c5e77dc6ac88000c11811ac --- /dev/null +++ b/qim3d/viz/detection.py @@ -0,0 +1,76 @@ +import matplotlib.pyplot as plt +from qim3d.viz import slices +from qim3d.io.logger import log +import numpy as np +import ipywidgets as widgets +from IPython.display import clear_output, display + + +def circles(blobs, vol, alpha=0.5, color="#ff9900", **kwargs): + """ + Plots the blobs found on a slice of the volume. + + This function takes in a 3D volume and a list of blobs (detected features) + and plots the blobs on a specified slice of the volume. If no slice is specified, + it defaults to the middle slice of the volume. + + 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() + 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. + **kwargs: Arbitrary keyword arguments for the `slices` function. + + Returns: + matplotlib.figure.Figure: The resulting figure after adding the blobs to the slice. + + """ + + def _slicer(z_slice): + clear_output(wait=True) + fig = slices( + vol, + n_slices=1, + position=z_slice, + img_height=3, + img_width=3, + cmap="gray", + show_position=False, + ) + # Add circles from deteced blobs + for detected in blobs: + z, y, x, s = detected + if abs(z - z_slice) < s: # The blob is in the slice + + # Adjust the radius based on the distance from the center of the sphere + distance_from_center = abs(z - z_slice) + angle = ( + np.pi / 2 * (distance_from_center / s) + ) # Angle varies from 0 at the center to pi/2 at the edge + adjusted_radius = s * np.cos(angle) # Radius follows a cosine curve + + if adjusted_radius > 0.5: + c = plt.Circle( + (x, y), + adjusted_radius, + color=color, + linewidth=0, + fill=True, + alpha=alpha, + ) + fig.get_axes()[0].add_patch(c) + + display(fig) + return fig + + position_slider = widgets.IntSlider( + value=vol.shape[0] // 2, + min=0, + max=vol.shape[0] - 1, + description="Slice", + continuous_update=True, + ) + slicer_obj = widgets.interactive(_slicer, z_slice=position_slider) + slicer_obj.layout = widgets.Layout(align_items="flex-start") + + return slicer_obj diff --git a/qim3d/viz/img.py b/qim3d/viz/img.py index cfa7022391e8cbc27cdd6627bab171881d3b79b8..812b2028621ff508a638b78b009d4fc69a7fd139 100644 --- a/qim3d/viz/img.py +++ b/qim3d/viz/img.py @@ -399,7 +399,7 @@ def slicer( img_height: int = 3, img_width: int = 3, show_position: bool = False, - interpolation: Optional[str] = None, + interpolation: Optional[str] = "none", ) -> widgets.interactive: """Interactive widget for visualizing slices of a 3D volume.