= {
bl_info "name": "Tissue Cartography",
"blender": (4, 3, 0),
"category": "Scene",
}
import bpy
from bpy.props import StringProperty, FloatProperty, FloatVectorProperty, IntProperty, IntVectorProperty, BoolProperty, EnumProperty
from bpy.types import Operator, Panel
import mathutils
import bmesh
from pathlib import Path
import os
import numpy as np
import difflib
import itertools
import subprocess
import sys
import tifffile
from scipy import interpolate, ndimage, spatial, stats, linalg
from skimage import measure
### Installing dependencies
def install_dependencies():
try:
import scipy
import skimage
except ImportError:
= sys.executable
python_executable "-m", "pip", "install", "scipy", "scikit-image", "tifffile"])
subprocess.check_call([python_executable,
### I/O and image handling
def load_png(image_path):
"""Load .png into numpy array."""
= bpy.data.images.load(image_path)
image = image.size
width, height = np.array(image.pixels[:], dtype=np.float32)
pixels return pixels.reshape((height, width, -1))
def normalize_quantiles(image, quantiles=(0.01, 0.99), channel_axis=None, clip=False,
=None):
data_type"""
Normalize a multi-dimensional image by setting given quantiles to 0 and 1.
Parameters
----------
image : np.array
Multi-dimensional image.
quantiles : tuple
Image quantile to set to 0 and 1.
channel_axis : int or None
If None, the image is assumed to have only a single channel.
If int, indicates the position of the channel axis.
Each channel is normalized separately.
clip : bool
Whether to clip image to 0-1. Automatically enabled if converting to int dtype.
data_type : None, np.unit8 or np.uint16
If not None, image is converted to give data type.
Returns
-------
image_normalized : np.array
Normalized image, the same shape as input
"""
if channel_axis is None:
= image - np.nanquantile(image, quantiles[0])
image_normalized /= np.nanquantile(image_normalized, quantiles[1])
image_normalized = np.nan_to_num(image_normalized)
image_normalized else:
= np.moveaxis(image, channel_axis, 0)
image_normalized = np.stack([ch - np.nanquantile(ch, quantiles[0]) for ch in image_normalized])
image_normalized = np.stack([ch / np.nanquantile(ch, quantiles[1]) for ch in image_normalized])
image_normalized = np.moveaxis(np.nan_to_num(image_normalized), 0, channel_axis)
image_normalized if clip or (data_type is not None):
= np.clip(image_normalized, 0, 1)
image_normalized if data_type is np.uint8:
= np.round((2**8-1)*image_normalized).astype(np.uint8)
image_normalized if data_type is np.uint16:
= np.round((2**16-1)*image_normalized).astype(np.uint16)
image_normalized return image_normalized
def axis_order_to_transpose(axis_order_string):
"""Convert string describing axis order into tuple for use in np.transpose."""
assert ''.join(sorted(axis_order_string)) in ['xyz', 'cxyz'], "Must be xyz, cxyz, or permutation thereof"
if 'c' in axis_order_string:
= [axis_order_string.index(k) for k in 'cxyz']
transpose else:
= [axis_order_string.index(k) for k in 'xyz']
transpose return transpose
### Tissue cartography - projecting 3d images to UV textures
def get_uv_layout(obj, uv_layout_path, image_resolution):
"""Get UV layout mask for obj object as a np.array. As a side effect, saves layout to disk and deselects everything except obj."""
if os.path.exists(uv_layout_path):
os.remove(uv_layout_path)
object.select_all(action='DESELECT') # Deselect all objects
bpy.ops.True) # Select the specific object
obj.select_set(= obj
bpy.context.view_layer.objects.active object.mode_set(mode='EDIT')
bpy.ops.
# Set all faces to selected for the UV layout
= bmesh.from_edit_mesh(obj.data)
mesh for face in mesh.faces:
= True
face.select
bmesh.update_edit_mesh(obj.data)
=uv_layout_path, size=(image_resolution, image_resolution), opacity=1, export_all=False, check_existing=False)
bpy.ops.uv.export_layout(filepathobject.mode_set(mode='OBJECT')
bpy.ops.= load_png(uv_layout_path)
UV_layout
return (UV_layout.sum(axis=-1) > 0)[::-1]
def get_uv_normal_world_per_loop(mesh_obj, filter_unique=False):
"""
Get UV, normals, and world and normal for each loop (half-edge) as np.array.
If filter_unique, remove "duplicate" loops (for which UV, normals and position
are identical).
"""
if not mesh_obj:
raise TypeError("No object selected")
if mesh_obj.type != 'MESH':
raise TypeError("Selected object is not a mesh")
= mesh_obj.matrix_world
world_matrix = mesh_obj.data.uv_layers.active
uv_layer if not uv_layer:
raise RuntimeError("Mesh does not have an active UV map")
= np.zeros((len(mesh_obj.data.loops), 2), dtype=np.float32)
loop_uvs = np.zeros((len(mesh_obj.data.loops), 3), dtype=np.float32)
loop_normals = np.zeros((len(mesh_obj.data.loops), 3), dtype=np.float32)
loop_world_positions for loop in mesh_obj.data.loops:
= uv_layer.data[loop.index].uv
loop_uvs[loop.index] = world_matrix.to_3x3() @ mesh_obj.data.vertices[loop.vertex_index].normal
loop_normals[loop.index] = world_matrix @ mesh_obj.data.vertices[loop.vertex_index].co
loop_world_positions[loop.index] if filter_unique:
= np.unique(np.hstack([loop_uvs, loop_normals, loop_world_positions]), axis=0)
unqiue_loops = (unqiue_loops[:,:2], unqiue_loops[:,2:5], unqiue_loops[:,5:])
loop_uvs, loop_normals, loop_world_positions = np.round((loop_normals.T/np.linalg.norm(loop_normals, axis=1)).T, decimals=4)
loop_normals return loop_uvs, loop_normals, loop_world_positions
def bake_per_loop_values_to_uv(loop_uvs, loop_values, image_resolution):
"""
Bake (interpolate) values (normals or world position) defined per loop into the UV square.
UV coordinates outside [0,1] are ignored.
Parameters
----------
loop_uvs : np.array of shape (n_loops, 2)
UV coordinates of loop.
loop_values : np.array of shape (n_loops, ...)
Input field. Can be an array with any number of axes (e.g. scalar or vector field).
image_resolution : int, default 256
Size of UV grid. Determines resolution of result.
Returns
-------
interpolated : np.array of shape (uv_grid_steps, uv_grid_steps, ...)
Field across [0,1]**2 UV grid, with a uniform step size. UV positions that don't
correspond to any value are set to np.nan.
"""
= np.meshgrid(*(2*(np.linspace(0,1, image_resolution),)))
U, V = interpolate.griddata(loop_uvs, loop_values, (U, V), method='linear')[::-1]
interpolated return interpolated
def bake_volumetric_data_to_uv(image, baked_world_positions, resolution, baked_normals, normal_offsets=(0,), affine_matrix=None):
"""
Interpolate volumetric image data onto UV coordinate grid.
Uses baked 3d world positions corresponding to each UV grid point (see bake_per_loop_values_to_UV).
3d coordinates (in microns) are converted into image coordinates via the resolution scaling factor.
The resolution of the bake (number of pixels) is determined by the shape of baked_world_positions.
normal_offsets moves the 3d positions whose volumetric voxel values will be baked inwards or outwards
along the surface normal. Providing a list of offsets results in a multi-layer pullback
Parameters
----------
image : 4d np.array
Image, axis 0 is assumed to be the channel axis
baked_world_positions : np.array of shape (image_resolution, image_resolution, uv_grid_steps, 3)
3d world positions baked to UV grid, with uniform step size. UV positions that don't correspond to
any value are set to np.nan.
resolution : np.array of shape (3,)
Resolution in pixels/microns for each of the three spatial axes.
baked_normals : np.array of shape (image_resolution, image_resolution, uv_grid_steps, 3)
3d world normals baked to UV grid, with uniform step size. UV positions that don't correspond to
any value are set to np.nan.
normal_offsets : np.array of shape (n_layers,), default (0,)
Offsets along normal direction, in same units as interpolated_3d_positions (i.e. microns).
0 corresponds to no shift.
affine_matrix : np.array of shape (4, 4) or None
If not None, transform coordinates by affine trafor before calling interpolator
Returns
-------
aked_data : np.array of shape (n_channels, n_layers, image_resolution, image_resolution)
Multi-layer 3d volumetric data baked onto UV.
"""
= [np.arange(ni) for ni in image.shape[1:]]
x, y, z = []
baked_data for o in normal_offsets:
= (baked_world_positions+o*baked_normals)
position if affine_matrix is not None:
= position @ affine_matrix[:3, :3].T + affine_matrix[:3,3]
position = position/resolution
position = np.stack([interpolate.interpn((x, y, z), channel, position,
baked_layer_data ="linear", bounds_error=False) for channel in image])
method
baked_data.append(baked_layer_data)= np.stack(baked_data, axis=1)
baked_data return baked_data
### Bounding box and orthoslices for visualizing the 3d data
def create_box(length, width, height, name="RectangularBox", hide=True):
"""
Creates a rectangular box using Blender's default cube.
One corner is positioned at the origin, and the box lies in the positive x/y/z quadrant.
Args:
length (float): Length of the box along the X-axis.
width (float): Width of the box along the Y-axis.
height (float): Height of the box along the Z-axis.
"""
# Store the current active object
= bpy.context.active_object
current_active
=2, location=(0, 0, 0))
bpy.ops.mesh.primitive_cube_add(size= bpy.context.active_object
obj = name
obj.name = (length / 2, width / 2, height / 2)
obj.scale = (length / 2, width / 2, height / 2)
obj.location object.transform_apply(location=True, scale=True)
bpy.ops.
obj.hide_set(hide)# re-select the currently active object
if current_active:
= current_active
bpy.context.view_layer.objects.active return obj
def create_slice_plane(length, width, height, axis='z', position=0.0):
"""
Creates a 2D plane as a slice of a rectangular box along a specified axis.
The plane lies within the bounds of the box.
Args:
length (float): Length of the box along the X-axis.
width (float): Width of the box along the Y-axis.
height (float): Height of the box along the Z-axis.
axis (str): Axis along which to slice ('x', 'y', or 'z').
position (float): Position along the chosen axis for the slice plane.
Should be within the range of the box dimensions.
"""
= bpy.context.active_object
current_active # Validate axis and position
if axis not in {'x', 'y', 'z'}:
raise ValueError("Axis must be 'x', 'y', or 'z'.")
= {'x': length, 'y': width, 'z': height}
axis_limits if not (0.0 <= position <= axis_limits[axis]):
raise ValueError(f"Position must be within [0, {axis_limits[axis]}] for axis {axis}.")
# Create the plane's dimensions based on the slicing axis
if axis == 'x':
= (height, width) #(width, height)
plane_size = (position, width / 2, height / 2)
location = (0, 1.5708, 0) # Rotate to align with the YZ-plane
rotation elif axis == 'y':
= (length, height)
plane_size = (length / 2, position, height / 2)
location = (1.5708, 0, 0) # Rotate to align with the XZ-plane
rotation else: # 'z'
= (length, width)
plane_size = (length / 2, width / 2, position)
location = (0, 0, 0) # No rotation needed for the XY-plane
rotation
# Add a plane
=2, location=(0, 0, 0))
bpy.ops.mesh.primitive_plane_add(size= bpy.context.active_object
plane = f"SlicePlane_{axis.upper()}_{position:.2f}"
plane.name
# Scale and position the plane
= (plane_size[0] / 2, plane_size[1] / 2, 1)
plane.scale = location
plane.location = rotation
plane.rotation_euler
# Apply transformations (scale, location, rotation)
object.transform_apply(location=True, scale=True, rotation=True)
bpy.ops.
# Restore the previously active object
if current_active:
= current_active
bpy.context.view_layer.objects.active
return plane
def get_slice_image(image_3d, resolution, axis='z', position=0.0):
"""Get slice of 3d image along axis for ortho-slice visualization.
image_3d must be a 4d array (channels, x, y, z). Position in microns."""
if axis == 'x':
= int(np.round(position / resolution[0]))
ind = image_3d[:,ind,:,::-1]
slice_img elif axis == 'y':
= int(np.round(position / resolution[1]))
ind = image_3d[:,:,ind,:].transpose((0,2,1))
slice_img elif axis == 'z':
= int(np.round(position / resolution[0]))
ind = image_3d[:,:,:,ind].transpose((0,2,1))
slice_img return slice_img
def create_material_from_array(slice_plane, array, material_name="SliceMaterial"):
"""
Creates a material for a ortho-slice plane using a 2D numpy array as a texture.
Args:
slice_plane (bpy.types.Object): The plane object to which the material will be applied.
array (numpy.ndarray): 2D array representing grayscale values (0-1), or 3D array representing RGBA values (0-1).
material_name (str): Name of the new material.
"""
# Validate input array
if not len(array.shape) in [2,3]:
raise ValueError("Input array must be 2D.")
# Normalize array to range [0, 1] and convert to a flat list
= array.shape[:2]
image_height, image_width = np.zeros((image_height, image_width, 4), dtype=np.float32) # RGBA
pixel_data if len(array.shape) == 2:
0] = pixel_data[..., 1] = pixel_data[..., 2] = array
pixel_data[..., 3] = 1.0 # Alpha
pixel_data[..., else:
= array
pixel_data[...] = pixel_data.flatten()
pixel_data
# Create a new image in Blender
= bpy.data.images.new(name="SliceTexture", width=image_width, height=image_height)
image = pixel_data.tolist()
image.pixels
# Create a new material
= bpy.data.materials.new(name=material_name)
material = True
material.use_nodes = material.node_tree.nodes
nodes = material.node_tree.links
links
# Clear default nodesx
for node in nodes:
nodes.remove(node)
# Add required nodes
= nodes.new(type="ShaderNodeTexImage")
texture_node = image
texture_node.image = nodes.new(type="ShaderNodeBsdfPrincipled")
bsdf_node = nodes.new(type="ShaderNodeOutputMaterial")
output_node
# Arrange nodes
= (-400, 0)
texture_node.location = (0, 0)
bsdf_node.location = (400, 0)
output_node.location
# Connect nodes
"Color"], bsdf_node.inputs["Base Color"])
links.new(texture_node.outputs["BSDF"], output_node.inputs["Surface"])
links.new(bsdf_node.outputs[
# Assign the material to the plane
= material
slice_plane.active_material return None
### Pullback shading
def create_material_from_multilayer_array(mesh, array, material_name="ProjectedMaterial"):
"""
Creates a material for a mesh using multi-channel, multi-layer projection.
Args:
obj (bpy.types.Object): The mesh object to which the material will be applied.
array (numpy.ndarray): 4D array of shape (channels, layers, U, V)
material_name (str): Name of the new material.
"""
# Validate and normalize input array
if not len(array.shape) == 4:
raise ValueError("Input array must have 4 axes.")
= normalize_quantiles(array, quantiles=(0.01, 0.99), channel_axis=0,
array_normalized =True, data_type=None)
clip# Create a new image in Blender for each layer and channel
= array.shape[-2:]
image_height, image_width = array.shape[:2]
n_channels, n_layers = {}
images for ic, chanel in enumerate(array_normalized):
for il, layer in enumerate(chanel):
= np.zeros((image_height, image_width, 4), dtype=np.float32)
pixel_data 0] = pixel_data[..., 1] = pixel_data[..., 2] = layer[::-1]
pixel_data[..., 3] = 1.0 # Alpha
pixel_data[..., = pixel_data.flatten()
pixel_data = bpy.data.images.new(name=f"Channel_{ic}_Layer_{il}",
images[(ic, il)] =image_width, height=image_height)
width= pixel_data.tolist()
images[(ic, il)].pixels # Create a new material
= bpy.data.materials.new(name=material_name)
material = True
material.use_nodes = material.node_tree.nodes
nodes = material.node_tree.links
links # Clear default nodesx
for node in nodes:
nodes.remove(node)# Add required nodes
= {}
texture_nodes for (ic, il), image in images.items():
= nodes.new(type="ShaderNodeTexImage")
texture_nodes[(ic, il)] = image
texture_nodes[(ic, il)].image = (-400, ic*400 + il*300)
texture_nodes[(ic, il)].location
= nodes.new(type="ShaderNodeBsdfPrincipled")
bsdf_node = nodes.new(type="ShaderNodeOutputMaterial")
output_node
# Arrange nodes
= (0, 0)
bsdf_node.location = (400, 0)
output_node.location
# Connect nodes
0,0)].outputs["Color"], bsdf_node.inputs["Base Color"])
links.new(texture_nodes[("BSDF"], output_node.inputs["Surface"])
links.new(bsdf_node.outputs[
# Assign the material to the mesh
= material
mesh.active_material return None
### Vertex shading
def compute_edge_lengths(obj):
"""
Computes the lengths of all edges in a mesh object as a numpy array.
Args:
obj (bpy.types.Object): The mesh object to compute edge lengths for.
Returns:
numpy.ndarray: A 1D array containing the lengths of all edges in the mesh.
"""
# Ensure the object is a mesh
if obj.type != 'MESH':
raise ValueError("The selected object is not a mesh.")
# Ensure the mesh is in edit mode for accurate vertex data
= obj
bpy.context.view_layer.objects.active if obj.mode != 'OBJECT':
object.mode_set(mode='OBJECT')
bpy.ops.= []
edge_lengths for edge in obj.data.edges:
= obj.data.vertices[edge.vertices[0]].co
v1 = obj.data.vertices[edge.vertices[1]].co
v2 - v2).length)
edge_lengths.append((v1 return np.array(edge_lengths)
def get_image_to_vertex_interpolator(obj, image_3d, resolution_array, quantiles=(0.01, 0.99)):
"""
Get interpolator that maps vertex position -> image intensity.
Returns a list of interpolators, one for each channel.
To avoid aliasing, the 3d image is smoothed with
sigma=median edge length /2. The image data is also normalized to
range from 0-1 using the provided quantiles.
"""
= np.median(compute_edge_lengths(obj))/2
anti_aliasing_scale = np.stack([ndimage.gaussian_filter(ch, anti_aliasing_scale/resolution_array)
image_3d_smoothed for ch in image_3d])
= normalize_quantiles(image_3d_smoothed,
image_3d_smoothed =quantiles, clip=True, data_type=None)
quantiles= [np.arange(ni)*resolution_array[i]
x, y, z for i, ni in enumerate(image_3d.shape[1:])]
return [interpolate.RegularGridInterpolator((x,y,z), ch, method='linear', bounds_error=False)
for ch in image_3d_smoothed]
def assign_vertex_colors(obj, colors):
"""
Assigns an RGB color to each vertex in the given object.
Args:
obj: The mesh object.
colors: A list or dict of (R, G, B) tuples for each vertex.
"""
if obj.type != 'MESH':
print("Object is not a mesh!")
return
if not obj.data.vertex_colors:
obj.data.vertex_colors.new()= obj.data.vertex_colors.active
color_layer # Assign colors to each loop (face corner)
for loop in obj.data.loops:
= (*colors[loop.vertex_index], 1.0) # RGBA
color_layer.data[loop.index].color return None
def create_vertex_color_material(object, material_name="VertexColorMaterial"):
"""
Creates a material for an object that uses vertex colors.
The R, G, and B channels are processed through separate "Map Range" nodes
to edit their brightness, and then combined into a Principled BSDF.
Args:
object (bpy.types.Object): The object to which the material will be applied.
material_name (str): Name of the new material.
"""
# Ensure the object has a vertex color layer
if not object.data.vertex_colors:
raise ValueError("The object has no vertex color layers.")
# Create a new material
= bpy.data.materials.new(name=material_name)
material = True
material.use_nodes = material.node_tree.nodes
nodes = material.node_tree.links
links
# Clear default nodes
for node in nodes:
nodes.remove(node)
# Add nodes
= nodes.new(type="ShaderNodeVertexColor")
vertex_color_node = object.data.vertex_colors[0].name
vertex_color_node.layer_name = (-1000, 0)
vertex_color_node.location
= nodes.new(type="ShaderNodeSeparateRGB")
separate_color_node = (-800, 0)
separate_color_node.location
= nodes.new(type="ShaderNodeMapRange")
map_range_r = "Map Range R"
map_range_r.label = (-600, 300)
map_range_r.location
= nodes.new(type="ShaderNodeMapRange")
map_range_g = "Map Range G"
map_range_g.label = (-600, 0)
map_range_g.location
= nodes.new(type="ShaderNodeMapRange")
map_range_b = "Map Range B"
map_range_b.label = (-600, -300)
map_range_b.location
= nodes.new(type="ShaderNodeCombineRGB")
combine_rgb = (-200, 0)
combine_rgb.location
= nodes.new(type="ShaderNodeBsdfPrincipled")
bsdf_node = (000, 0)
bsdf_node.location
= nodes.new(type="ShaderNodeOutputMaterial")
output_node = (400, 0)
output_node.location
# Connect nodes
"Color"], separate_color_node.inputs["Image"])
links.new(vertex_color_node.outputs["R"], map_range_r.inputs["Value"])
links.new(separate_color_node.outputs["G"], map_range_g.inputs["Value"])
links.new(separate_color_node.outputs["B"], map_range_b.inputs["Value"])
links.new(separate_color_node.outputs[
"Result"], combine_rgb.inputs["R"])
links.new(map_range_r.outputs["Result"], combine_rgb.inputs["G"])
links.new(map_range_g.outputs["Result"], combine_rgb.inputs["B"])
links.new(map_range_b.outputs[
"Image"], bsdf_node.inputs["Base Color"])
links.new(combine_rgb.outputs["BSDF"], output_node.inputs["Surface"])
links.new(bsdf_node.outputs[
# Set default map range values for each channel
for map_range_node in [map_range_r, map_range_g, map_range_b]:
"From Min"].default_value = 0.0
map_range_node.inputs["From Max"].default_value = 1.0
map_range_node.inputs["To Min"].default_value = 0.0
map_range_node.inputs["To Max"].default_value = 1.0
map_range_node.inputs[
# Assign the material to the object
object.active_material = material
return None
### Marching cubes
def create_mesh_from_numpy(name, verts, faces):
"""
Creates a Blender mesh object from NumPy arrays of vertices and faces.
:param name: Name of the new mesh object.
:param verts: NumPy array of shape (n, 3) containing vertex coordinates.
:param faces: NumPy array of shape (m, 3 or 4) containing face indices.
:return: The created mesh object.
"""
= bpy.data.meshes.new(name)
mesh = bpy.data.objects.new(name, mesh)
obj # Link the object to the scene
bpy.context.collection.objects.link(obj)
mesh.from_pydata(verts.tolist(), [], faces.tolist())
mesh.update()return obj
### Iterative closest point alignment
def package_affine_transformation(matrix, vector):
"""Package matrix transformation & translation into (d+1,d+1) matrix representation of affine transformation."""
= np.hstack([matrix, vector[:, np.newaxis]])
matrix_rep = np.pad(matrix_rep, ((0,1),(0,0)), constant_values=0)
matrix_rep -1,-1] = 1
matrix_rep[return matrix_rep
def get_inertia(pts):
"""Get inertia tensor of 3d point cloud."""
= pts - np.mean(pts, axis=0)
pts_nomean = pts_nomean.T
x, y, z = np.mean(x**2)
Ixx = np.mean(x*y)
Ixy = np.mean(x*z)
Ixz = np.mean(y**2)
Iyy = np.mean(y*z)
Iyz = np.mean(z*z)
Izz return np.array([[Ixx, Ixy, Ixz], [Ixy,Iyy, Iyz], [Ixz, Iyz, Izz]])
def align_by_centroid_and_intertia(source, target, scale=True, shear=True, improper=False, n_samples=10000):
"""
Align source point cloud to target point cloud using affine transformation.
Align by matching centroids and axes of inertia tensor. Since the inertia tensor is invariant
under reflections along its principal axes, all 2^3 reflections are tried and the one leading
to the best agreement with the target is chosen.
Parameters
----------
source : np.array of shape (n_source, 3)
Point cloud to be aligned.
target : np.array of shape (n_target, 3)
Point cloud to align to.
scale : bool, default True
Whether to allow scale transformation (True) or rotations only (False)
shear : bool, default False
Whether to allow shear transformation (True) or rotations/scale only (False)
improper : bool, default False
Whether to allow transfomations with determinant -1
n_samples : int, optional
Number of samples of source to use when estimating distances.
Returns
-------
np.array, np.array
affine_matrix_rep : np.array of shape (4, 4)
Affine transformation source -> target
aligned : np.array of shape (n_source, 3)
Aligned coordinates
"""
= np.mean(target, axis=0)
target_centroid = get_inertia(target)
target_inertia = np.linalg.eigh(target_inertia)
target_eig
= np.mean(source, axis=0)
source_centroid = get_inertia(source)
source_inertia = np.linalg.eigh(source_inertia)
source_eig
= [np.diag([i,j,k]) for i, j, k in itertools.product(*(3*[[-1,1]]))]
flips = []
trafo_matrix_candidates = spatial.cKDTree(target)
tree = source[np.random.randint(low=0, high=source.shape[0], size=min([n_samples, source.shape[0]])),:]
samples = []
distances for flip in flips:
if shear:
= (source_eig.eigenvectors
trafo_matrix @ np.diag(np.sqrt(target_eig.eigenvalues/source_eig.eigenvalues))
@ flip @ target_eig.eigenvectors.T)
elif scale and not shear:
= np.sqrt(stats.gmean(target_eig.eigenvalues)/stats.gmean(source_eig.eigenvalues))
scale_fact = scale_fact*source_eig.eigenvectors@flip@target_eig.eigenvectors.T
trafo_matrix elif not scale and not shear:
= source_eig.eigenvectors@flip@target_eig.eigenvectors.T
trafo_matrix if not improper and np.linalg.det(trafo_matrix) < 0:
continue
= trafo_matrix.T
trafo_matrix
trafo_matrix_candidates.append(trafo_matrix)= target_centroid - trafo_matrix@source_centroid
trafo_translate = samples@trafo_matrix.T + trafo_translate
aligned 0]))
distances.append(np.mean(tree.query(aligned)[= trafo_matrix_candidates[np.argmin(distances)]
trafo_matrix print('inferred rotation/scale', trafo_matrix)
= target_centroid - trafo_matrix@source_centroid
trafo_translate = source@trafo_matrix.T + trafo_translate
aligned = package_affine_transformation(trafo_matrix, trafo_translate)
affine_matrix_rep
print('inferred translation', trafo_translate)
return affine_matrix_rep, aligned
def procrustes(source, target, scale=True):
"""
Procrustes analysis, a similarity test for two data sets.
Copied from scipy.spatial.procrustes, modified to return the transform
as an affine matrix, and return the transformed source data in the original,
non-normalized coordinates.
Each input matrix is a set of points or vectors (the rows of the matrix).
The dimension of the space is the number of columns of each matrix. Given
two identically sized matrices, procrustes standardizes both such that:
- tr(AA^T) = 1.
- Both sets of points are centered around the origin.
Procrustes then applies the optimal transform to the source matrix
(including scaling/dilation, rotations, and reflections) to minimize the
sum of the squares of the pointwise differences between the two input datasets.
This function is not designed to handle datasets with different numbers of
datapoints (rows). If two data sets have different dimensionality
(different number of columns), simply add columns of zeros to the smaller
of the two.
Parameters
----------
source : array_like
Matrix, n rows represent points in k (columns) space. The data from
source will be transformed to fit the pattern in target.
target : array_like
Maxtrix, n rows represent points in k (columns) space.
target is the reference data.
scale : bool, default True
Whether to allow scaling transformations
Returns
-------
trafo_affine : array_like
(4,4) array representing the affine transformation from source to target.
aligned : array_like
The orientation of source that best fits target.
disparity : float
np.linalg.norm(aligned-target, axis=1).mean()
"""
= np.array(target, dtype=np.float64, copy=True)
mtx1 = np.array(source, dtype=np.float64, copy=True)
mtx2
if mtx1.ndim != 2 or mtx2.ndim != 2:
raise ValueError("Input matrices must be two-dimensional")
if mtx1.shape != mtx2.shape:
raise ValueError("Input matrices must be of same shape")
if mtx1.size == 0:
raise ValueError("Input matrices must be >0 rows and >0 cols")
# translate all the data to the origin
= (np.mean(mtx1, 0), np.mean(mtx2, 0))
centroid1, centroid2 -= centroid1
mtx1 -= centroid2
mtx2
# change scaling of data (in rows) such that trace(mtx*mtx') = 1
= np.linalg.norm(mtx1)
norm1 = np.linalg.norm(mtx2)
norm2 if norm1 == 0 or norm2 == 0:
raise ValueError("Input matrices must contain >1 unique points")
/= norm1
mtx1 /= norm2
mtx2 # transform mtx2 to minimize disparity
= linalg.orthogonal_procrustes(mtx1, mtx2)
R, s = np.dot(mtx2, R.T) * s
mtx2
# retranslate and scale
= norm1 * mtx2 + centroid1
aligned
# measure the dissimilarity between the two datasets
= np.mean(np.linalg.norm(aligned-target, axis=1))
disparity
# assemble the linear transformation
if scale:
= (norm1/norm2)*s*R
trafo_matrix else:
= (norm1/norm2)*R
trafo_matrix = centroid1 - trafo_matrix@centroid2
trafo_translate = package_affine_transformation(trafo_matrix, trafo_translate)
trafo_affine return trafo_affine, aligned, disparity
def icp(source, target, initial=None, threshold=1e-4, max_iterations=20, scale=True, n_samples=1000):
"""
Apply the iterative closest point algorithm to align point cloud a with
point cloud b. Will only produce reasonable results if the
initial transformation is roughly correct. Initial transformation can be
found by applying Procrustes' analysis to a suitable set of landmark
points (often picked manually), or by inertia+centroid based alignment,
implemented in align_by_centroid_and_intertia.
Parameters
----------
source : (n,3) float
Source points in space.
target : (m,3) float or Trimesh
Target points in space or mesh.
initial : (4,4) float
Initial transformation.
threshold : float
Stop when change in cost is less than threshold
max_iterations : int
Maximum number of iterations
scale : bool, optional
Whether to allow dilations. If False, orthogonal procrustes is used
n_samples : int or None
If not None, n_samples sample points are randomly chosen from source array for distance computation
Returns
----------
matrix : (4,4) float
The transformation matrix sending a to b
transformed : (n,3) float
The image of a under the transformation
cost : float
The cost of the transformation
"""
# initialize transform matrix
= np.eye(4) if initial is None else initial
total_matrix = spatial.cKDTree(target)
tree # subsample and apply initial transformation
= (source[np.random.randint(low=0, high=source.shape[0],
samples =min([n_samples, source.shape[0]])),:]
sizeif n_samples is not None else source[:])
= samples@total_matrix[:3,:3].T + total_matrix[:3,-1]
samples # start with infinite cost
= np.inf
old_cost # avoid looping forever by capping iterations
for i in range(max_iterations):
print('iteration', i, 'cost', old_cost)
# Find closest point in target to each point in sample and align
= target[tree.query(samples, 1)[1]]
closest = procrustes(samples, closest, scale=scale)
matrix, samples, cost # update a with our new transformed points
= np.dot(matrix, total_matrix)
total_matrix if old_cost - cost < threshold:
break
else:
= cost
old_cost = source@total_matrix[:3,:3].T + total_matrix[:3,-1]
aligned return total_matrix, aligned, cost
def combined_alignment(source, target, pre_align=True, shear=False, iterations=100):
"""Align source to target by combination of moment-of-intertia based aligment + ICP"""
if pre_align:
= align_by_centroid_and_intertia(source, target,
trafo_initial, _ =True, shear=shear, improper=False)
scaleelse:
= None
trafo_initial = icp(source, target, initial=trafo_initial,
trafo_icp, _, _ =1e-4, max_iterations=iterations,
threshold=True, n_samples=5000)
scalereturn trafo_icp
### Shrink-wrapping
def shrinkwrap_and_smooth(source_obj, target_obj, corrective_smooth_iter=0):
"""
Applies a shrinkwrap modifier with target_obj to source_obj,
optionally adds a corrective smooth modifier, and applies all modifiers.
Parameters:
- source_obj: The source mesh object to be modified.
- target_obj: The target mesh object for the shrinkwrap modifier.
- corrective_smooth_iter: (Optional) Number of iterations for the corrective smooth modifier.
If 0, no corrective smooth is applied.
Returns:
- bpy.types.Object: The new modified mesh object.
"""
# Ensure the objects are valid
if source_obj.type != 'MESH' or target_obj.type != 'MESH':
raise ValueError("Both source_obj and target_obj must be mesh objects.")
# Store the currently active object
= bpy.context.view_layer.objects.active
original_active_obj
# Add the first shrinkwrap modifier
= source_obj.modifiers.new(name="Shrinkwrap", type='SHRINKWRAP')
shrinkwrap_1 = target_obj
shrinkwrap_1.target = 'TARGET_PROJECT'
shrinkwrap_1.wrap_method
# Add a corrective smooth modifier if requested
for i in range(0, corrective_smooth_iter):
= source_obj.modifiers.new(name=f"Corrective Smooth {i}", type='CORRECTIVE_SMOOTH')
corrective_smooth = 5
corrective_smooth.iterations = 0
corrective_smooth.scale # Add a second shrinkwrap modifier after the corrective smooth
= source_obj.modifiers.new(name=f"Shrinkwrap {i}", type='SHRINKWRAP')
shrinkwrap_2 = target_obj
shrinkwrap_2.target = 'TARGET_PROJECT'
shrinkwrap_2.wrap_method
# Apply all modifiers
= source_obj
bpy.context.view_layer.objects.active for modifier in source_obj.modifiers:
object.modifier_apply(modifier=modifier.name)
bpy.ops.# Restore the original active object
= original_active_obj
bpy.context.view_layer.objects.active return source_obj
### Handling of mesh-associated array-data
def set_numpy_attribute(mesh, name, array):
"""Sets mesh[name] = array.
Since Blender does not support adding arbitrary objects as attributes to meshes,
the array is flattened, converted to a binary buffer, and saved as a tuple together with its shape.
All arrays are converted to np.float32.
"""
bytes, shape = (array.astype(np.float32).flatten().tobytes(), array.shape)
= (bytes, shape)
mesh[name] return None
def get_numpy_attribute(mesh, name):
"""Get array = mesh[name].
Since Blender does not support adding arbitrary objects as attributes to meshes,
the array is flattened, converted to a binary buffer, and saved as a tuple together with its shape.
All arrays are converted to np.float32.
"""
assert name in mesh, "Attribute not found"
return np.frombuffer(mesh[name][0], dtype=np.float32).reshape(mesh[name][1])
def separate_selected_into_mesh_and_box(self, context):
"""
Separate selected objects into mesh and box, representing 3D image data.
If not exactly one mesh and one box (with attribute "3D_data") are selected,
an error is raised.
"""
= len([x for x in context.selected_objects if "3D_data" in x])
n_data_selected = len([x for x in context.selected_objects if not "3D_data" in x])
n_mesh_selected if not ((n_data_selected==1) and (n_mesh_selected==1)):
self.report({'ERROR'}, "Select exactly one mesh and one 3D image (BoundingBox)!")
return None, None
= [x for x in context.selected_objects if "3D_data" in x][0]
box = [x for x in context.selected_objects if not "3D_data" in x][0]
obj if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "No mesh object selected!")
return None, None
return box, obj
### Operators defining the user interface of the add-on
class LoadTIFFOperator(Operator):
"""Load .tif file and resolution. Also creates a bounding box object."""
= "scene.load_tiff"
bl_idname = "Load TIFF File"
bl_label
def execute(self, context):
= bpy.path.abspath(context.scene.tissue_cartography_file)
file_path = np.array(context.scene.tissue_cartography_resolution)
resolution self.report({'INFO'}, f"Resolution loaded: {resolution}")
# Load TIFF file as a NumPy array
if not (file_path.lower().endswith(".tiff") or file_path.lower().endswith(".tif")):
self.report({'ERROR'}, "Selected file is not a TIFF")
return {'CANCELLED'}
try:
= tifffile.imread(file_path)
data if not len(data.shape) in [3,4]:
self.report({'ERROR'}, "Selected TIFF must have 3 or 4 axes.")
return {'CANCELLED'}
# sort out axis order
= context.scene.tissue_cartography_axis_order
axis_order_string if not ''.join(sorted(axis_order_string)) in ['', 'xyz', 'cxyz']:
self.report({'ERROR'}, "Must be empty, xyz, cxyz, or permutation thereof")
return {'CANCELLED'}
if not len(axis_order_string) in [0, len(data.shape)]:
self.report({'ERROR'}, "Number of axes in axis order does not match tiff data.")
return {'CANCELLED'}
if axis_order_string == '' and len(data.shape) == 4:
# ensure channel axis (assumed shortest axis) is 1st if no axis order provided.
= np.argmin(data.shape)
channel_axis = np.moveaxis(data, channel_axis, 0)
data if axis_order_string != '':
= data.transpose(axis_order_to_transpose(axis_order_string))
data if len(data.shape) == 3: # add singleton channel axis to single channel-data
= data[np.newaxis]
data # display image shape in add-on
= str(data.shape[1:])
context.scene.tissue_cartography_image_shape = data.shape[0]
context.scene.tissue_cartography_image_channels self.report({'INFO'}, f"TIFF file loaded with shape {data.shape}")
# create a bounding box mesh to represent the data
= create_box(*(np.array(data.shape[1:])*resolution),
box =f"{Path(file_path).stem}_BoundingBox",
name=False)
hide= 'WIRE'
box.display_type # attach the data to the box
"resolution", resolution)
set_numpy_attribute(box, "3D_data", data)
set_numpy_attribute(box,
except Exception as e:
self.report({'ERROR'}, f"Failed to load TIFF file: {e}")
return {'CANCELLED'}
return {'FINISHED'}
class LoadSegmentationTIFFOperator(Operator):
"""
Load segmentation .tif file and resolution, and create a mesh from binary segmentation.
Selecting a folder instead of a file batch processes all files in folder.
"""
= "scene.load_segmentation"
bl_idname = "Load Segmentation TIFF File"
bl_label
def execute(self, context):
# Load resolution as a NumPy array
= np.array(context.scene.tissue_cartography_segmentation_resolution)
resolution_array = Path(bpy.path.abspath(context.scene.tissue_cartography_segmentation_file))
input_path if input_path.is_dir():
= [f for f in input_path.iterdir() if f.is_file() and f.suffix in [".tif", ".tiff"]]
files_to_process elif input_path.is_file():
= [input_path]
files_to_process else:
self.report({'ERROR'}, "Select a valid file or directory")
return {'CANCELLED'}
for file_path in files_to_process:
if not file_path.suffix in [".tif", ".tiff"]:
self.report({'ERROR'}, "Selected file is not a TIFF")
return {'CANCELLED'}
try:
= tifffile.imread(file_path)
data # sort out axis order
if not len(data.shape) in [3,4]:
self.report({'ERROR'}, "Selected TIFF must have 3 or 4 axes.")
return {'CANCELLED'}
= context.scene.tissue_cartography_segmentation_axis_order
axis_order_string if not ''.join(sorted(axis_order_string)) in ['', 'xyz', 'cxyz']:
self.report({'ERROR'}, "Must be empty, xyz, cxyz, or permutation thereof")
return {'CANCELLED'}
if not len(axis_order_string) in [0, len(data.shape)]:
self.report({'ERROR'}, "Number of axes in axis order does not match tiff data.")
return {'CANCELLED'}
if axis_order_string == '' and len(data.shape) == 4:
# ensure channel axis (assumed shortest axis) is 1st if no axis order provided.
= np.argmin(data.shape)
channel_axis = np.moveaxis(data, channel_axis, 0)
data if axis_order_string != '':
= data.transpose(axis_order_to_transpose(axis_order_string))
data if len(data.shape) == 3: # add singleton channel axis to single channel-data
= data[np.newaxis]
data self.report({'INFO'}, f"TIFF file loaded with shape {data.shape}")
= str(data.shape[1:])
context.scene.tissue_cartography_segmentation_shape = data.shape[0]
context.scene.tissue_cartography_segmentation_channels # iterate over channels. each channel is one label
for ic, channel in enumerate(data):
# smooth and normalize the segmentation
= (channel-channel.min())/(channel.max()-channel.min())
channel = context.scene.tissue_cartography_segmentation_sigma
sigma = ndimage.gaussian_filter(channel, sigma=sigma/resolution_array)
channel # compute mesh using marching cubes, and convert to mesh
= measure.marching_cubes(channel, level=0.5, spacing=(1.0,1.0,1.0))
verts, faces, _, _ = verts * resolution_array
verts f"{Path(file_path).stem}_c{ic}", verts, faces)
create_mesh_from_numpy(
except Exception as e:
self.report({'ERROR'}, f"Failed to load segmentation: {e}")
return {'CANCELLED'}
return {'FINISHED'}
class CreateProjectionOperator(Operator):
"""
Create a cartographic projection.
Select one mesh and one 3d-image ([...]_BoundingBox) to project 3d image data
onto mesh surface.
"""
= "scene.create_projection"
bl_idname = "Create Projection"
bl_label
def execute(self, context):
# Validate selected object and UV map
= separate_selected_into_mesh_and_box(self, context)
box, obj if box is None or obj is None:
return {'CANCELLED'}
# Ensure the object has a UV map
if not obj.data.uv_layers:
self.report({'ERROR'}, "The selected mesh does not have a UV map!")
return {'CANCELLED'}
# Parse offsets into a NumPy array
= context.scene.tissue_cartography_offsets
offsets_str try:
= np.array([float(x) for x in offsets_str.split(",") if x.strip()])
offsets_array if offsets_array.size == 0:
= np.array([0])
offsets_array self.report({'INFO'}, f"Offsets loaded: {offsets_array}")
except ValueError as e:
self.report({'ERROR'}, f"Invalid offsets input: {e}")
return {'CANCELLED'}
# set offsets as property
"projection_offsets", offsets_array)
set_numpy_attribute(obj,
# Parse projection resolution
= context.scene.tissue_cartography_projection_resolution
projection_resolution self.report({'INFO'}, f"Using projection resolution: {projection_resolution}")
# texture bake normals and world positions
= get_uv_normal_world_per_loop(obj, filter_unique=True)
loop_uvs, loop_normals, loop_world_positions
= bake_per_loop_values_to_uv(loop_uvs, loop_normals,
baked_normals =projection_resolution)
image_resolution= (baked_normals.T/np.linalg.norm(baked_normals.T, axis=0)).T
baked_normals = bake_per_loop_values_to_uv(loop_uvs, loop_world_positions,
baked_world_positions =projection_resolution)
image_resolution# obtain UV layout and use it to get a mask
= str(Path(bpy.path.abspath("//")).joinpath(f'{obj.name}_UV_layout.png'))
uv_layout_path = get_uv_layout(obj, uv_layout_path, projection_resolution)
mask ~mask] = np.nan
baked_normals[~mask] = np.nan
baked_world_positions[
# create a pullback
= np.linalg.inv(np.array(box.matrix_world))
box_world_inv = bake_volumetric_data_to_uv(get_numpy_attribute(box, "3D_data"),
baked_data
baked_world_positions, "resolution"),
get_numpy_attribute(box, =offsets_array,
baked_normals, normal_offsets=box_world_inv)
affine_matrix# set results as attributes of the mesh
"baked_data", baked_data)
set_numpy_attribute(obj, "baked_normals", baked_normals)
set_numpy_attribute(obj, "baked_world_positions", baked_world_positions)
set_numpy_attribute(obj, # create texture
=f"ProjectedMaterial_{obj.name}")
create_material_from_multilayer_array(obj, baked_data, material_name
return {'FINISHED'}
class SaveProjectionOperator(Operator):
"""Save cartographic projection to disk"""
= "scene.save_projection"
bl_idname = "Save Projection"
bl_label
="FILE_PATH")
filepath: bpy.props.StringProperty(subtype
def invoke(self, context, event):
# Open file browser to choose the save location
self)
context.window_manager.fileselect_add(return {'RUNNING_MODAL'}
def execute(self, context):
= context.active_object
obj if not obj or "baked_data" not in obj:
self.report({'ERROR'}, "No baked data found on the active object!")
return {'CANCELLED'}
# Get the baked data
= get_numpy_attribute(obj, "baked_data")
baked_data = get_numpy_attribute(obj, "baked_normals")
baked_normals = get_numpy_attribute(obj, "baked_world_positions")
baked_world_positions # Save the data to the chosen filepath
try:
self.filepath + "_BakedNormals.tif", baked_normals)
tifffile.imwrite(self.filepath + "_BakedPositions.tif", baked_world_positions)
tifffile.imwrite(self.filepath + "_BakedData.tif", baked_data.astype(np.float32),
tifffile.imwrite(={'axes': 'ZCYX'}, imagej=True)
metadataself.report({'INFO'}, f"Cartographic projection saved to {self.filepath}")
except Exception as e:
self.report({'ERROR'}, f"Failed to save data: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class BatchProjectionOperator(Operator):
"""
Batch-process cartographic projections.
Select all meshes to process (in blender) and one 3d-image ([...]_BoundingBox)
for resolution and relative position information. Further 3d .tiff files are read from
Batch Process Input directory. Mesh names should match .tiff file names.
"""
= "scene.batch_projection"
bl_idname = "Create Projections (Batch Mode)"
bl_label
def execute(self, context):
try:
= [x for x in context.selected_objects if "3D_data" in x][0]
box except IndexError:
self.report({'ERROR'}, "Select one 3D image (BoundingBox) for resolution and position information!")
return
# get list of files
= Path(bpy.path.abspath(context.scene.tissue_cartography_batch_directory))
batch_path = Path(bpy.path.abspath(context.scene.tissue_cartography_batch_output_directory))
batch_out_path = {f.stem: f for f in list(batch_path.iterdir()) if ((f.suffix in [".tif", ".tiff"]) and not "Baked" in f.stem)}
batch_files # match files to selected meshes
= [obj for obj in context.selected_objects if obj != box]
meshes_to_process = [obj.name for obj in meshes_to_process]
mesh_names = {obj.name: difflib.get_close_matches(obj.name, batch_files.keys(), n=1, cutoff=0.1)
matched for obj in context.selected_objects if obj != box}
# parse axis order
= list(context.scene.tissue_cartography_axis_order)
axis_order if not sorted(axis_order) == [0,1,2,3]:
self.report({'ERROR'}, "Axis order must be a permutation of [0,1,2,3] (e.g. [3,0,1,2])")
return {'CANCELLED'}
# parse offsets into a NumPy array
= context.scene.tissue_cartography_offsets
offsets_str try:
= np.array([float(x) for x in offsets_str.split(",") if x.strip()])
offsets_array if offsets_array.size == 0:
= np.array([0])
offsets_array self.report({'INFO'}, f"Offsets loaded: {offsets_array}")
except ValueError as e:
self.report({'ERROR'}, f"Invalid offsets input: {e}")
return {'CANCELLED'}
# Parse projection resolution
= context.scene.tissue_cartography_projection_resolution
projection_resolution self.report({'INFO'}, f"Using projection resolution: {projection_resolution}")
# find box for position and resolution info
for iobj, obj in enumerate(meshes_to_process):
self.report({'INFO'}, f"Processing {iobj}/{len(meshes_to_process)}")
if not obj.data.uv_layers:
self.report({'ERROR'}, f"Mesh {obj.name} does not have a UV map!")
return {'CANCELLED'}
# set offsets as property
"projection_offsets", offsets_array)
set_numpy_attribute(obj, # find the matching file
if len(matched[obj.name]) == 0:
self.report({'ERROR'}, "No matching file found for {obj.name}!")
return {'CANCELLED'}
= batch_files[matched[obj.name][0]]
file_path # load the 3D data
try:
= tifffile.imread(file_path)
data if not len(data.shape) in [3,4]:
self.report({'INFO'}, f"Selected TIFF for {obj.name} must have 3 or 4 axes.")
return {'CANCELLED'}
if len(data.shape) == 3: # add singleton channel axis to single channel-data
= data[np.newaxis]
data # ensure channel axis (assumed shortest axis) is 1st
= np.argmin(data.shape)
channel_axis = np.moveaxis(data, channel_axis, 0)
data = data.transpose(axis_order)
data except:
self.report({'ERROR'}, f"Failed loading TIFF for {obj.name}")
return {'CANCELLED'}
# texture bake normals and world positions
= get_uv_normal_world_per_loop(obj, filter_unique=True)
loop_uvs, loop_normals, loop_world_positions
= bake_per_loop_values_to_uv(loop_uvs, loop_normals,
baked_normals =projection_resolution)
image_resolution= (baked_normals.T/np.linalg.norm(baked_normals.T, axis=0)).T
baked_normals = bake_per_loop_values_to_uv(loop_uvs, loop_world_positions,
baked_world_positions =projection_resolution)
image_resolution# obtain UV layout and use it to get a mask
= str(Path(batch_out_path).joinpath(f'{obj.name}_UV_layout.png'))
uv_layout_path = get_uv_layout(obj, uv_layout_path, projection_resolution)
mask ~mask] = np.nan
baked_normals[~mask] = np.nan
baked_world_positions[# create a pullback
= np.linalg.inv(np.array(box.matrix_world))
box_world_inv = bake_volumetric_data_to_uv(data,
baked_data
baked_world_positions, "resolution"),
get_numpy_attribute(box, =offsets_array,
baked_normals, normal_offsets=box_world_inv)
affine_matrix# Save the data to the chosen filepath
try:
f"{obj.name}_BakedNormals.tif"), baked_normals)
tifffile.imwrite(batch_out_path.joinpath(f"{obj.name}_BakedPositions.tif"), baked_world_positions)
tifffile.imwrite(batch_out_path.joinpath(f"{obj.name}_BakedData.tif"), baked_data.astype(np.float32),
tifffile.imwrite(batch_out_path.joinpath(={'axes': 'ZCYX'}, imagej=True)
metadataself.report({'INFO'}, f"Cartographic projection saved for {obj.name}")
except Exception as e:
self.report({'ERROR'}, f"Failed to save data for {obj.name}: {str(e)}")
return {'CANCELLED'}
if bpy.context.scene.tissue_cartography_batch_create_materials:
# set results as attributes of the mesh
"baked_data", baked_data)
set_numpy_attribute(obj, "baked_normals", baked_normals)
set_numpy_attribute(obj, "baked_world_positions", baked_world_positions)
set_numpy_attribute(obj, # create texture
=f"ProjectedMaterial_{obj.name}")
create_material_from_multilayer_array(obj, baked_data, material_namereturn {'FINISHED'}
class SlicePlaneOperator(Operator):
"""Create a slice plane along the selected axis with texture from 3D data"""
= "scene.create_slice_plane"
bl_idname = "Create Slice Plane"
bl_label = {'REGISTER', 'UNDO'}
bl_options
def execute(self, context):
# Get the 3D data array from the selected box
= context.active_object
box if not box or not "3D_data" in box:
self.report({'ERROR'}, "Select exactly a 3D image (BoundingBox)!")
return {'CANCELLED'}
= get_numpy_attribute(box, "3D_data")
data
= get_numpy_attribute(box, "resolution")
resolution if not isinstance(data, np.ndarray) or data.ndim != 4:
self.report({'ERROR'}, "Invalid 3D data array.")
return {'CANCELLED'}
if context.scene.tissue_cartography_slice_channel >= data.shape[0]:
self.report({'ERROR'}, f"Channel {context.scene.tissue_cartography_slice_channel} is out of bounds for the data array.")
return {'CANCELLED'}
= (np.array(data.shape[1:]) * resolution)
length, width, height = create_slice_plane(length, width, height, axis=context.scene.tissue_cartography_slice_axis,
slice_plane =context.scene.tissue_cartography_slice_position)
position= f"{slice_plane.name}_{box.name}"
slice_plane.name # set matrix world
= box.matrix_world
slice_plane.matrix_world
= get_slice_image(data, resolution, axis=context.scene.tissue_cartography_slice_axis,
slice_img =context.scene.tissue_cartography_slice_position)
position= normalize_quantiles(slice_img, quantiles=(0.01, 0.99),
slice_img =0, clip=True, data_type=None)
channel_axis=f"SliceMaterial_{box.name}_{context.scene.tissue_cartography_slice_axis}_{context.scene.tissue_cartography_slice_position}")
create_material_from_array(slice_plane, slice_img[context.scene.tissue_cartography_slice_channel], material_namereturn {'FINISHED'}
class VertexShaderInitializeOperator(Operator):
"""Initialize vertex shader for a selected mesh. Colors mesh vertices according to
3D image intensity from selected BoundingBox."""
= "scene.initialize_vertex_shader"
bl_idname = "Initialize Vertex Shader"
bl_label = {'REGISTER', 'UNDO'}
bl_options
def execute(self, context):
# create global dict to hold interpolator objects
if not hasattr(bpy.types.Scene, "tissue_cartography_interpolators"):
= dict()
bpy.types.Scene.tissue_cartography_interpolators # get the selected mesh and bounding box
= separate_selected_into_mesh_and_box(self, context)
box, obj if box is None or obj is None:
return {'CANCELLED'}
# Get the 3D data array from the box object
= get_numpy_attribute(box, "3D_data")
data = get_numpy_attribute(box, "resolution")
resolution
if not isinstance(data, np.ndarray) or data.ndim != 4:
self.report({'ERROR'}, "Invalid 3D data array.")
return {'CANCELLED'}
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "No mesh object selected!")
return {'CANCELLED'}
if context.scene.tissue_cartography_vertex_shader_channel >= data.shape[0]:
self.report({'ERROR'}, f"Channel {context.scene.tissue_cartography_vertex_shader_channel} is out of bounds for the data array.")
return {'CANCELLED'}
# need to compute coordinates relative to matrix_world of box I think
"box_world_inv_vertex_shader",
set_numpy_attribute(obj,
np.array(box.matrix_world.inverted()))= get_image_to_vertex_interpolator(obj, data, resolution)
bpy.types.Scene.tissue_cartography_interpolators[obj.name] = mathutils.Matrix(get_numpy_attribute(obj,
box_inv "box_world_inv_vertex_shader"))
= np.array([box_inv@obj.matrix_world@(v.co + context.scene.tissue_cartography_vertex_shader_offset*v.normal)
positions for v in obj.data.vertices])
= bpy.types.Scene.tissue_cartography_interpolators[obj.name][context.scene.tissue_cartography_vertex_shader_channel](positions)
intensities = np.stack(3*[intensities,], axis=1)
colors
assign_vertex_colors(obj, colors)=f"VertexColorMaterial_{obj.name}")
create_vertex_color_material(obj, material_name
return {'FINISHED'}
class VertexShaderRefreshOperator(Operator):
"""Refresh vertex colors for a selected mesh. Colors mesh vertices according to
3D image intensity."""
= "scene.refresh_vertex_shader"
bl_idname = "Refresh Vertex Shader"
bl_label = {'REGISTER', 'UNDO'}
bl_options
def execute(self, context):
= context.active_object
obj = getattr(context.scene, "tissue_cartography_interpolators")
interpolator_dict if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "No mesh object selected!")
return {'CANCELLED'}
if interpolator_dict is None or obj.name not in interpolator_dict:
self.report({'ERROR'}, f"Vertex shader not initialized.")
return {'CANCELLED'}
if context.scene.tissue_cartography_vertex_shader_channel >= len(interpolator_dict[obj.name]):
self.report({'ERROR'}, f"Channel {context.scene.tissue_cartography_vertex_shader_channel} is out of bounds for the data array.")
= mathutils.Matrix(get_numpy_attribute(obj, "box_world_inv_vertex_shader"))
box_inv = np.array([box_inv@obj.matrix_world@(v.co + context.scene.tissue_cartography_vertex_shader_offset*v.normal)
positions for v in obj.data.vertices])
= interpolator_dict[obj.name][context.scene.tissue_cartography_vertex_shader_channel](positions)
intensities = np.stack(3*[intensities,], axis=1)
colors
assign_vertex_colors(obj, colors)
return {'FINISHED'}
class AlignOperator(Operator):
"""Align active and selected meshes by rotation, translation, and scaling."""
= "scene.align"
bl_idname = "Align Selected To Active Mesh"
bl_label = {'REGISTER', 'UNDO'}
bl_options
def execute(self, context):
if context.scene.tissue_cartography_align_type == "selected":
= context.active_object
target_mesh for source_mesh in [x for x in context.selected_objects if not x==target_mesh]:
self.report({'INFO'}, f"Aligning: {source_mesh.name} to {target_mesh.name}")
if target_mesh.type != 'MESH' or source_mesh.type != 'MESH':
self.report({'ERROR'}, "Selected object(s) is not a mesh.")
return {'CANCELLED'}
# Get the 3D coordinates from the meshes
= np.array([target_mesh.matrix_world@v.co for v in target_mesh.data.vertices])
target = np.array([source_mesh.matrix_world@v.co for v in source_mesh.data.vertices])
source = combined_alignment(source, target,
trafo_matrix =context.scene.tissue_cartography_prealign,
pre_align=context.scene.tissue_cartography_prealign_shear,
shear=context.scene.tissue_cartography_align_iter)
iterations= mathutils.Matrix(trafo_matrix)@ source_mesh.matrix_world
source_mesh.matrix_world elif context.scene.tissue_cartography_align_type == "active":
= context.active_object
source_mesh for target_mesh in [x for x in context.selected_objects if not x==source_mesh]:
self.report({'INFO'}, f"Aligning: {source_mesh.name} to {target_mesh.name}")
if target_mesh.type != 'MESH' or source_mesh.type != 'MESH':
self.report({'ERROR'}, "Selected object(s) is not a mesh.")
return {'CANCELLED'}
# Get the 3D coordinates from the meshes and compute alignment
= np.array([target_mesh.matrix_world@v.co for v in target_mesh.data.vertices])
target = np.array([source_mesh.matrix_world@v.co for v in source_mesh.data.vertices])
source = combined_alignment(source, target,
trafo_matrix =context.scene.tissue_cartography_prealign,
pre_align=context.scene.tissue_cartography_prealign_shear,
shear=context.scene.tissue_cartography_align_iter)
iterations# copy source mesh
= source_mesh.copy()
source_mesh_copied = source_mesh.data.copy()
source_mesh_copied.data
bpy.context.collection.objects.link(source_mesh_copied)= f"{target_mesh.name}_aligned"
source_mesh_copied.name = mathutils.Matrix(trafo_matrix)@ source_mesh.matrix_world
source_mesh_copied.matrix_world return {'FINISHED'}
class ShrinkwrapOperator(Operator):
"""Copy and shrink-wrap active mesh to selected meshes."""
= "scene.shrinkwrap"
bl_idname = "Shrink-Wrap Active to Selected"
bl_label = {'REGISTER', 'UNDO'}
bl_options
def execute(self, context):
= context.scene.tissue_cartography_shrinkwarp_iterative
mode = context.active_object
source_mesh = sorted([x for x in context.selected_objects if not x==source_mesh], key=lambda x: x.name)
targets if mode == "backward":
= targets[::-1]
targets for target_mesh in targets:
self.report({'INFO'}, f"Aligning: {source_mesh.name} to {target_mesh.name}")
if target_mesh.type != 'MESH' or source_mesh.type != 'MESH':
self.report({'ERROR'}, "Selected object(s) is not a mesh.")
return {'CANCELLED'}
# rigid alignment
= np.array([target_mesh.matrix_world@v.co for v in target_mesh.data.vertices])
target = np.array([source_mesh.matrix_world@v.co for v in source_mesh.data.vertices])
source = combined_alignment(source, target,
trafo_matrix =context.scene.tissue_cartography_prealign,
pre_align=context.scene.tissue_cartography_prealign_shear,
shear=context.scene.tissue_cartography_align_iter)
iterations# copy source mesh
= source_mesh.copy()
source_mesh_copied = source_mesh.data.copy()
source_mesh_copied.data
bpy.context.collection.objects.link(source_mesh_copied)= mathutils.Matrix(trafo_matrix)@ source_mesh.matrix_world
source_mesh_copied.matrix_world = f"{target_mesh.name}_wrapped"
source_mesh_copied.name # shrink-wrap
shrinkwrap_and_smooth(source_mesh_copied, target_mesh,=context.scene.tissue_cartography_shrinkwarp_smooth)
corrective_smooth_iter# data transfer modifier to copy UV map from wrapped to target
= target_mesh.modifiers.new(name="DataTransfer", type='DATA_TRANSFER')
data_transfer object = source_mesh_copied
data_transfer.= True
data_transfer.use_loop_data = {'UV'}
data_transfer.data_types_loops = 'POLYINTERP_NEAREST'
data_transfer.loop_mapping # apply
= bpy.context.view_layer.objects.active
original_active_obj = target_mesh
bpy.context.view_layer.objects.active object.datalayout_transfer(modifier="DataTransfer")
bpy.ops.object.modifier_apply(modifier="DataTransfer")
bpy.ops.= original_active_obj
bpy.context.view_layer.objects.active
if mode in ["forward", "backward"]:
= source_mesh_copied
source_mesh return {'FINISHED'}
class HelpPopupOperator(Operator):
"""Open help window."""
= "scene.help_popup"
bl_idname = "Tissue Cartography Help"
bl_label
def execute(self, context):
= "https://nikolas-claussen.github.io/blender-tissue-cartography/Tutorials/03_blender_addon_tutorial.html"
url =url)
bpy.ops.wm.url_open(urlreturn {'FINISHED'}
class TissueCartographyPanel(Panel):
"""Class defining layout of user interface (buttons, inputs, etc.)"""
= "Tissue Cartography"
bl_label = "SCENE_PT_tissue_cartography"
bl_idname = 'PROPERTIES'
bl_space_type = 'WINDOW'
bl_region_type = "scene"
bl_context
def draw(self, context):
= self.layout
layout = context.scene
scene
"tissue_cartography_file")
layout.prop(scene, = layout.row()
row_tiff "tissue_cartography_resolution")
row_tiff.prop(scene, "tissue_cartography_axis_order")
row_tiff.prop(scene, "scene.load_tiff", text="Load .tiff file")
layout.operator(=f"Loaded Image Shape: {scene.tissue_cartography_image_shape}. Loaded Image Channels: {scene.tissue_cartography_image_channels}")
layout.label(text
layout.separator()
"tissue_cartography_segmentation_file")
layout.prop(scene, = layout.row()
row_segmentation "tissue_cartography_segmentation_resolution")
row_segmentation.prop(scene, "tissue_cartography_segmentation_axis_order")
row_segmentation.prop(scene, "tissue_cartography_segmentation_sigma")
row_segmentation.prop(scene, "scene.load_segmentation", text="Get mesh(es) from binary segmentation .tiff file(s)")
layout.operator(=f"Loaded Segmentation Shape: {scene.tissue_cartography_segmentation_shape}. Loaded Segmentation Channels: {scene.tissue_cartography_segmentation_channels}")
layout.label(text
layout.separator()
= layout.row()
row_slice "tissue_cartography_slice_axis")
row_slice.prop(scene, "tissue_cartography_slice_position")
row_slice.prop(scene, "tissue_cartography_slice_channel")
row_slice.prop(scene, "scene.create_slice_plane", text="Create slice plane")
layout.operator(
layout.separator()
= layout.row()
row_vertex "tissue_cartography_vertex_shader_offset")
row_vertex.prop(scene, "tissue_cartography_vertex_shader_channel")
row_vertex.prop(scene, = layout.row()
row_vertex2 "scene.initialize_vertex_shader", text="Initialize vertex shading")
row_vertex2.operator("scene.refresh_vertex_shader", text="Refresh vertex shading")
row_vertex2.operator(
layout.separator()
= layout.row()
row_projection "tissue_cartography_offsets")
row_projection.prop(scene, "tissue_cartography_projection_resolution")
row_projection.prop(scene, = layout.row()
row_projection2 "scene.create_projection", text="Create Projection")
row_projection2.operator("scene.save_projection", text="Save Projection")
row_projection2.operator(
layout.separator()
= layout.row()
row_batch "tissue_cartography_batch_directory")
row_batch.prop(scene, "tissue_cartography_batch_output_directory")
row_batch.prop(scene, "tissue_cartography_batch_create_materials")
row_batch.prop(scene, "scene.batch_projection", text="Batch Process And Save")
layout.operator(
layout.separator()
= layout.row()
row_align "tissue_cartography_prealign")
row_align.prop(scene, "tissue_cartography_prealign_shear")
row_align.prop(scene, "tissue_cartography_align_type")
row_align.prop(scene, "tissue_cartography_align_iter")
row_align.prop(scene, "scene.align", text="Align Meshes")
layout.operator(
layout.separator()
= layout.row()
row_shrinkwrap "tissue_cartography_shrinkwarp_smooth")
row_shrinkwrap.prop(scene, "tissue_cartography_shrinkwarp_iterative")
row_shrinkwrap.prop(scene, "scene.shrinkwrap", text="Shrinkwrap Meshes (Active To Selected)")
layout.operator(
layout.separator()
"scene.help_popup", text="Show help", icon='HELP')
layout.operator(
### Add the add-on to the user interface
def register():
"""Add the add-on to the blender user interface"""
bpy.utils.register_class(TissueCartographyPanel)
bpy.utils.register_class(LoadTIFFOperator)
bpy.utils.register_class(LoadSegmentationTIFFOperator)
bpy.utils.register_class(CreateProjectionOperator)
bpy.utils.register_class(SaveProjectionOperator)
bpy.utils.register_class(BatchProjectionOperator)
bpy.utils.register_class(SlicePlaneOperator)
bpy.utils.register_class(VertexShaderInitializeOperator)
bpy.utils.register_class(VertexShaderRefreshOperator)
bpy.utils.register_class(AlignOperator)
bpy.utils.register_class(ShrinkwrapOperator)
bpy.utils.register_class(HelpPopupOperator)
= StringProperty(
bpy.types.Scene.tissue_cartography_file ="File Path",
name="Path to the TIFF file",
description='FILE_PATH',
subtype
)= FloatVectorProperty(
bpy.types.Scene.tissue_cartography_resolution ="x/y/z Resolution (µm)",
name="Resolution in microns along x, y, z axes",
description=3,
size=(1.0, 1.0, 1.0),
default
)= StringProperty(
bpy.types.Scene.tissue_cartography_axis_order="Axis order",
name="Axis order, either xyz + permutations or xyz + permutations (multichannel data). Dynamic data should be loaded as one .tiff per timepoint. If not provided, inferred automatically.",
description="",
default
)= IntProperty(
bpy.types.Scene.tissue_cartography_image_channels ="Image Channels",
name="Channels of the loaded image (read-only)",
description=0,
defaultmin=0,
)= StringProperty(
bpy.types.Scene.tissue_cartography_image_shape ="Image Shape",
name="Shape of the loaded image (read-only)",
description="Not loaded"
default
)= StringProperty(
bpy.types.Scene.tissue_cartography_segmentation_file ="Segmentation File Path",
name="Path to the segmentation TIFF file. Should have values between 0-1. Selecting a folder instead of a single file will batch-process the full folder.",
description='FILE_PATH',
subtype
)= FloatVectorProperty(
bpy.types.Scene.tissue_cartography_segmentation_resolution ="Segmentation x/y/z Resolution (µm)",
name="Resolution of segmentation in microns along x, y, z axes",
description=3,
size=(1.0, 1.0, 1.0),
default
)= StringProperty(
bpy.types.Scene.tissue_cartography_segmentation_axis_order="Axis order segmentation",
name="Axis order of segmentation, either xyz + permutations or cxyz + permutations (multichannel data). Different channels for a segmentation mean different labels. Dynamic data should be loaded as one .tiff per timepoint. If not provided, inferred automatically.",
description="",
default
)= FloatProperty(
bpy.types.Scene.tissue_cartography_segmentation_sigma ="Smoothing (µm)",
name="Smothing kernel for extracting mesh from segmentation, in µm",
description=0,
defaultmin=0
)= IntProperty(
bpy.types.Scene.tissue_cartography_segmentation_channels ="Segmentation Channels",
name="Channels of the segmentation (read-only)",
description=0,
defaultmin=0,
)= StringProperty(
bpy.types.Scene.tissue_cartography_segmentation_shape ="Segmentation Shape",
name="Shape of the loaded segmentation (read-only)",
description="Not loaded"
default
)= EnumProperty(
bpy.types.Scene.tissue_cartography_slice_axis ="Slice Axis",
name="Choose an axis",
description=[('x', "X-Axis", "Align to the X axis"),
items'y', "Y-Axis", "Align to the Y axis"),
('z', "Z-Axis", "Align to the Z axis")],
(='x'
default
)= FloatProperty(
bpy.types.Scene.tissue_cartography_slice_position ="Slice Position (µm)",
name="Position along the selected axis in µm",
description=0
default
)= IntProperty(
bpy.types.Scene.tissue_cartography_slice_channel ="Slice Channel",
name="Channel for slice plane",
description=0,
defaultmin=0,
)= FloatProperty(
bpy.types.Scene.tissue_cartography_vertex_shader_offset ="Vertex Shader Normal Offset (µm)",
name="Normal offse for vertex shading.",
description=0,
default
)= IntProperty(
bpy.types.Scene.tissue_cartography_vertex_shader_channel ="Vertex Shader Channel",
name="Channel for vertex shading.",
description=0,
defaultmin=0,
)= StringProperty(
bpy.types.Scene.tissue_cartography_offsets ="Normal Offsets (µm)",
name="Comma-separated list of floats for multilayer projection offsets",
description="0",
default
)= IntProperty(
bpy.types.Scene.tissue_cartography_projection_resolution ="Projection Format (Pixels)",
name="Resolution for the projection (e.g., 1024 for 1024x1024 pixels)",
description=1024,
defaultmin=1,
)
= StringProperty(
bpy.types.Scene.tissue_cartography_batch_directory ="Batch Process Input Directory",
name="Path to TIFF files directory",
description='DIR_PATH',
subtype
)= StringProperty(
bpy.types.Scene.tissue_cartography_batch_output_directory ="Batch Process Output Directory",
name="Path to TIFF files directory",
description='DIR_PATH',
subtype
)= BoolProperty(
bpy.types.Scene.tissue_cartography_batch_create_materials ="Create materials",
name="Enable or disable creating materials with projected texture in batch mode. Enabling can result in large .blend files.",
description=True
default
)= BoolProperty(
bpy.types.Scene.tissue_cartography_prealign ="Pre-align?",
name="Enable or disable pre-alignment. Do not use if the two meshes are already closely aligned.",
description=True
default
)= BoolProperty(
bpy.types.Scene.tissue_cartography_prealign_shear ="Allow shear",
name="Allow shear transformation during alignment.",
description=True
default
)= EnumProperty(
bpy.types.Scene.tissue_cartography_align_type ="Align Mode",
name="Choose an axis",
description=[('selected', "Selected to Active", "Align selected meshes to active mesh."),
items'active', "Active to Selected", "Align active mesh to selected meshe (creates copies of active mesh).")],
(='selected'
default
)= IntProperty(
bpy.types.Scene.tissue_cartography_align_iter ="Iterations",
name="ICP iterations during alignment.",
description=100,
defaultmin=1,
)= IntProperty(
bpy.types.Scene.tissue_cartography_shrinkwarp_smooth ="Shrinkwrap Corrective Smooth",
name="Corrective smooth iterations during shrink-wrapping.",
description=10,
defaultmin=0,
)= EnumProperty(
bpy.types.Scene.tissue_cartography_shrinkwarp_iterative ="Shrinkwrap Mode",
name="Choose an axis",
description=[('one-to-all', "One-To-All", "Shrink-wrap active mesh to each selected individually"),
items'forward', "Iterative Forward", "Shrink-wrap active mesh to selected meshes iteratively, starting with alpha-numerically first"),
('backward', "Iterative Backward", "Shrink-wrap active mesh to selected meshes iteratively, starting with alpha-numerically last")],
(='one-to-all'
default
)
def unregister():
bpy.utils.unregister_class(TissueCartographyPanel)
bpy.utils.unregister_class(LoadTIFFOperator)
bpy.utils.unregister_class(LoadSegmentationTIFFOperator)
bpy.utils.unregister_class(CreateProjectionOperator)
bpy.utils.unregister_class(BatchProjectionOperator)
bpy.utils.unregister_class(SaveProjectionOperator)
bpy.utils.unregister_class(SlicePlaneOperator)
bpy.utils.unregister_class(VertexShaderInitializeOperator)
bpy.utils.unregister_class(VertexShaderRefreshOperator)
bpy.utils.unregister_class(AlignOperator)
bpy.utils.unregister_class(ShrinkwrapOperator)
bpy.utils.unregister_class(HelpPopupOperator)
del bpy.types.Scene.tissue_cartography_file
del bpy.types.Scene.tissue_cartography_resolution
del bpy.types.Scene.tissue_cartography_axis_order
del bpy.types.Scene.tissue_cartography_image_channels
del bpy.types.Scene.tissue_cartography_image_shape
del bpy.types.Scene.tissue_cartography_segmentation_file
del bpy.types.Scene.tissue_cartography_segmentation_resolution
del bpy.types.Scene.tissue_cartography_segmentation_axis_order
del bpy.types.Scene.tissue_cartography_segmentation_sigma
del bpy.types.Scene.tissue_cartography_segmentation_channels
del bpy.types.Scene.tissue_cartography_segmentation_shape
del bpy.types.Scene.tissue_cartography_offsets
del bpy.types.Scene.tissue_cartography_projection_resolution
del bpy.types.Scene.tissue_cartography_slice_axis
del bpy.types.Scene.tissue_cartography_slice_position
del bpy.types.Scene.tissue_cartography_slice_channel
del bpy.types.Scene.tissue_cartography_vertex_shader_offset
del bpy.types.Scene.tissue_cartography_vertex_shader_channel
del bpy.types.Scene.tissue_cartography_prealign
del bpy.types.Scene.tissue_cartography_prealign_shear
del bpy.types.Scene.tissue_cartography_align_iter
del bpy.types.Scene.tissue_cartography_align_type
del bpy.types.Scene.tissue_cartography_batch_directory
del bpy.types.Scene.tissue_cartography_batch_output_directory
del bpy.types.Scene.tissue_cartography_batch_create_materials
del bpy.types.Scene.tissue_cartography_shrinkwarp_smooth
del bpy.types.Scene.tissue_cartography_shrinkwarp_iterative
if bpy.types.Scene.tissue_cartography_interpolators in globals():
del bpy.types.Scene.tissue_cartography_interpolators
### Run the add-on
if __name__ == "__main__":
register()
Blender add-on
Blender add-on for loading and visualizing volumetric data into blender and projecting image intensities onto a mesh surface
Note This module is not for use in a standard python environment, but must be run as an add-on within Blender. For documentation of the add-on user interface, see tutorial 3. This page documents the add-on code.
Add-on design
The add-on code comprises three parts:
Functions for carrying out key tissue cartography operations. These are based on the
blender_tissue_cartography
python library, edited to reflect the constraints of python scripting in blender (e.g. theigl
library is not available).A class (inherting from
bpy.types.Operator
) defining each add-on button, with anexecute
function defining what happens when you click it.The
TissueCartographyPanel(Panel)
class and theregister
function defining all user input fields and how user input fields and buttons from part 2 are laid out in the Tissue Cartography Panel.
All functions and classes are documented below.
To allow the user to load multiple 3D datasets and meshes into the same blender file, image data is associated with mesh objects. Which data any operation is applied to is determined by the currently selected mesh.
The bpy
library allows the add-on to interact with blender. It is only available within blender’s python scripting interface, which is why you cannot run the add-on in a normal python interpreter. See this tutorial for an introduction into scripting Blender: https://docs.blender.org/manual/en/latest/advanced/scripting/addon_tutorial.html
If you want to edit/extend the add-on, be aware of the following hacks used:
Associating data with meshes: In the add-on, tissue cartography data is associated with blender meshes. For example, loaded volumetric image data (represented as a
numpy
array) is associated with aBoundingBox
rectangular cuboid showing the volume covered by the image data (see tutorial 3). Unfortunately, blender does not allow adding arbitrary attributes to meshes. The functionsset_numpy_attribute
/get_numpy_attribute
circumvent this by representing array data as binary buffer + shape. To associate functions with a mesh (e.g. interpolators), a global dictionary is used.UV layout: To obtain the part of the UV square covered by an unwrapped mesh, a
.png
of the layout is exported to disk and re-read.
The add-on makes use of the following libraries which are included with the add-ons using wheels: numpy, tifffile, scipy, skimage
.
Add-on code
The code below is shown for completeness of the documentation webpage. Please download the add-on code from GitHub here.