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/qim3d/generate/blob_.py b/qim3d/generate/blob_.py index ed7f6f6c25b4d8be64853bcba674d5d8b761c046..bb7561b425a50b3def1b3684026d6d29d5f66f06 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,63 @@ 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 +143,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 +169,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/processing/operations.py b/qim3d/processing/operations.py index 8d2d0f83995ccb3507672a354fcb9d0b7cfd185e..79fb8fdc743fe6d30a50a6d3ddf51cf0febc199a 100644 --- a/qim3d/processing/operations.py +++ b/qim3d/processing/operations.py @@ -125,6 +125,7 @@ def fade_mask( geometry: str = "spherical", invert: bool = False, axis: int = 0, + **kwargs, ) -> np.ndarray: """ Apply edge fading to a volume. @@ -133,8 +134,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 +168,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 +183,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 +210,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(