Skip to content

Commit fcbf90b

Browse files
authored
Merge pull request #37 from BiAPoL/cut_with_plane
Add cut with plane function and widget
2 parents c29bd0a + de66db2 commit fcbf90b

10 files changed

Lines changed: 751 additions & 36 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ Crop regions in napari manually
1313

1414
Crop in any dimension
1515

16-
![](https://github.com/haesleinhuepf/napari-crop/blob/main/images/side_crop.gif)
16+
![](https://github.com/haesleinhuepf/napari-crop/raw/main/images/side_crop.gif)
17+
18+
Cut a volume using a plane
19+
20+
*Note: this functionality currently only works with 3D data*
21+
22+
![](https://github.com/haesleinhuepf/napari-crop/raw/main/images/napari_crop_cut_with_plane_demo.gif)
1723

1824
## Usage
1925
Create a new shapes layer to annotate the region you would like to crop:
2.01 MB
Loading

napari_crop/__init__.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11

2-
__version__ = "0.1.7"
2+
__version__ = "0.1.8"
33

44

5-
6-
7-
8-
from ._function import napari_experimental_provide_function
9-
from ._function import crop_region
10-
5+
from ._dock_widgets import napari_experimental_provide_dock_widget
6+
from ._function import crop_region, cut_with_plane

napari_crop/_dock_widgets.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import numpy as np
2+
3+
from magicgui.widgets import Container, PushButton, ComboBox, CheckBox
4+
from typing import TYPE_CHECKING
5+
from napari_plugin_engine import napari_hook_implementation
6+
from skimage.segmentation import relabel_sequential
7+
from napari_tools_menu import register_dock_widget
8+
from magicgui import magic_factory
9+
10+
from ._utils import array_allclose_in_list, find_array_allclose_position_in_list
11+
from ._function import cut_with_plane, crop_region, trim_zeros, get_nonzero_slices
12+
import napari.layers
13+
if TYPE_CHECKING:
14+
import napari.viewer
15+
16+
17+
@napari_hook_implementation
18+
def napari_experimental_provide_dock_widget():
19+
return [magic_factory(crop_region), CutWithPlane]
20+
21+
22+
@register_dock_widget(menu="Utilities > Cut volume with plane (napari-crop)")
23+
class CutWithPlane(Container):
24+
input_layer_types = (
25+
napari.layers.Image,
26+
napari.layers.Labels,)
27+
28+
def __init__(self, viewer: "napari.viewer.Viewer",
29+
plane_data_source: str = 'reference_layer_data', positive_cut: bool = True, crop: bool = False):
30+
self._viewer = viewer
31+
print(self._viewer.layers)
32+
# Create plane layer variable (needed for proper reference image combobox initialization)
33+
self._plane_layer = None
34+
# Create reference image combobox
35+
self._reference_image_combobox = ComboBox(
36+
choices=self._get_reference_layers,
37+
label='Reference Layer:',
38+
tooltip='Data source to be displayed in plane layer.\nData dimensions must be 3 and not rgb.')
39+
self._viewer.layers.events.inserted.connect(self._reference_image_combobox.reset_choices)
40+
self._viewer.layers.events.removed.connect(self._reference_image_combobox.reset_choices)
41+
# Create layer to be cut combobox
42+
self._layer_to_be_cut_combobox = ComboBox(
43+
choices=self._get_layers_to_be_cut,
44+
label='Layer to Be Cut:',
45+
tooltip='Layer containing data be cut.\nData dimensions must be 3 and not rgb.')
46+
self._viewer.layers.events.inserted.connect(self._layer_to_be_cut_combobox.reset_choices)
47+
self._viewer.layers.events.removed.connect(self._layer_to_be_cut_combobox.reset_choices)
48+
# Create plane data source combobox
49+
self._plane_data_source = plane_data_source
50+
self._plane_data_combobox = ComboBox(
51+
choices=[
52+
'blank',
53+
'reference_layer_data'],
54+
value=self._plane_data_source,
55+
label='Plane Data:')
56+
# Create positive cut checkbox
57+
self._positive_cut = positive_cut
58+
self._positive_cut_checkbox = CheckBox(value=self._positive_cut, label='Positive Cut')
59+
# Create crop checkbox
60+
self._crop = crop
61+
self._crop_checkbox = CheckBox(value=self._crop, label='Crop')
62+
# Create ortogonal plane swap button
63+
self._ortogonal_plane_swap_btn = PushButton(label="Ortogonal Planes",
64+
tooltip='Swap plane to ortogonal direction.\nShortcut: \'P\' key')
65+
# Create cut button
66+
self._run_btn = PushButton(label="Cut")
67+
# Connect callbacks
68+
self._run_btn.clicked.connect(self._on_cut_clicked)
69+
self._reference_image_combobox.changed.connect(self._on_image_layer_changed)
70+
self._plane_data_combobox.changed.connect(self._on_plane_data_source_changed)
71+
self._positive_cut_checkbox.changed.connect(self._on_positive_cut_changed)
72+
self._crop_checkbox.changed.connect(self._on_crop_changed)
73+
self._viewer.bind_key('p', self._swap_normal_direction, overwrite=True)
74+
self._ortogonal_plane_swap_btn.changed.connect(self._swap_normal_direction)
75+
# Replace plane layer variable with plane containing initial values
76+
plane_layer = self._reference_image_combobox.value
77+
if plane_layer is not None:
78+
self._add_plane_layer()
79+
80+
super().__init__(
81+
widgets=[
82+
self._reference_image_combobox,
83+
self._layer_to_be_cut_combobox,
84+
self._plane_data_combobox,
85+
self._positive_cut_checkbox,
86+
self._crop_checkbox,
87+
self._ortogonal_plane_swap_btn,
88+
self._run_btn])
89+
90+
def _add_plane_layer(self):
91+
'''Add plane layer to viewer'''
92+
plane_layer = self._reference_image_combobox.value
93+
self.plane_ortogonal_unity_vector_list = [np.array([0, 1, 0]), np.array([1, 0, 0]), np.array([0, 0, 1])]
94+
plane_parameters = {
95+
# z, y, x (intital position in the middle of the image, at the edge of the voxel)
96+
'position': (plane_layer.data.shape[0] // 2 - 0.5, plane_layer.data.shape[1] // 2 - 0.5, plane_layer.data.shape[2] // 2 - 0.5),
97+
'normal': tuple(self.plane_ortogonal_unity_vector_list[0]),
98+
'thickness': 1,
99+
}
100+
self._plane_layer = self._viewer.add_image(plane_layer.data,
101+
rendering='average',
102+
name='plane',
103+
depiction='plane',
104+
blending='additive',
105+
opacity=0.5,
106+
scale=plane_layer.scale,
107+
gamma=0.4,
108+
contrast_limits=[0, plane_layer.data.max()],
109+
plane=plane_parameters
110+
)
111+
112+
def _swap_normal_direction(self, viewer=None):
113+
'''Swap plane normal direction to ortogonal direction'''
114+
current_normal_unit_verctor = np.asarray(self._plane_layer.plane.normal)
115+
# If plane normal vector not in list (plane was changed by user), generate new ortogonal vectors
116+
if not array_allclose_in_list(current_normal_unit_verctor, self.plane_ortogonal_unity_vector_list):
117+
ortogonal_unit_vector_1 = np.random.randn(3)
118+
ortogonal_unit_vector_1 -= ortogonal_unit_vector_1.dot(current_normal_unit_verctor) * \
119+
current_normal_unit_verctor / np.linalg.norm(current_normal_unit_verctor)
120+
ortogonal_unit_vector_2 = np.cross(current_normal_unit_verctor, ortogonal_unit_vector_1)
121+
ortogonal_unit_vector_1 /= np.linalg.norm(ortogonal_unit_vector_1)
122+
ortogonal_unit_vector_2 /= np.linalg.norm(ortogonal_unit_vector_2)
123+
self.plane_ortogonal_unity_vector_list = [
124+
current_normal_unit_verctor, ortogonal_unit_vector_1, ortogonal_unit_vector_2]
125+
126+
# switch to next vector in list
127+
pos = find_array_allclose_position_in_list(current_normal_unit_verctor, self.plane_ortogonal_unity_vector_list)
128+
self._plane_layer.plane.normal = tuple(self.plane_ortogonal_unity_vector_list[(pos + 1) % 3])
129+
130+
def _get_reference_layers(self, combo_box):
131+
'''Get layers of type image or labels and excludes the plane layer'''
132+
# Currently accepts only 3D data
133+
return [layer for layer in self._viewer.layers if isinstance(
134+
layer, napari.layers.Image) and layer != self._plane_layer and layer.rgb is False and layer.ndim == 3]
135+
136+
def _get_layers_to_be_cut(self, combo_box):
137+
'''Get layers of type image or labels and excludes the plane layer'''
138+
# Currently accepts only 3D data
139+
return [layer for layer in self._viewer.layers if isinstance(
140+
layer, self.input_layer_types) and layer != self._plane_layer and layer.rgb is False and layer.ndim == 3]
141+
142+
def _on_plane_data_source_changed(self, new_value: str):
143+
'''Update plane data source and plane layer data'''
144+
self._plane_data_source = new_value
145+
self._on_image_layer_changed(self._reference_image_combobox.value)
146+
147+
def _on_image_layer_changed(self, new_layer: napari.layers.Image):
148+
'''Update plane layer data'''
149+
print('new_layer: ', new_layer)
150+
print(new_layer.data.shape)
151+
if self._plane_layer is None:
152+
self._add_plane_layer()
153+
self._update_plane_layer(new_layer)
154+
155+
def _update_plane_layer(self, layer: napari.layers.Image):
156+
'''Update plane layer data and a few layout parameters'''
157+
if self._plane_data_source == 'reference_layer_data':
158+
self._plane_layer.data = layer.data
159+
self._plane_layer.scale = layer.scale
160+
self._plane_layer.contrast_limits = layer.contrast_limits
161+
elif self._plane_data_source == 'blank':
162+
self._plane_layer.data = np.ones(layer.data.shape)
163+
self._plane_layer.scale = layer.scale
164+
self._plane_layer.contrast_limits = [0, 2]
165+
166+
def _on_positive_cut_changed(self, new_value: bool):
167+
'''Update positive cut parameter'''
168+
self._positive_cut = new_value
169+
170+
def _on_crop_changed(self, new_value: bool):
171+
'''Update crop parameter'''
172+
self._crop = new_value
173+
174+
def _on_cut_clicked(self):
175+
'''Cut image with plane and add new layer to viewer'''
176+
# Get plane parameters from plane layer
177+
plane_normal = self._plane_layer.plane.normal
178+
plane_position = self._plane_layer.plane.position
179+
# get layer to be cut from chosen value in combobox
180+
layer_to_be_cut = self._layer_to_be_cut_combobox.value
181+
output_layer_type = layer_to_be_cut._type_string
182+
# Call cut function
183+
image_cut = cut_with_plane(layer_to_be_cut.data, plane_normal, plane_position, self._positive_cut)
184+
shift = (0, 0, 0)
185+
# Calculate translation vector
186+
slices = get_nonzero_slices(image_cut)
187+
start = [slc.start for slc in slices if slc is not None]
188+
stop = [slc.stop for slc in slices if slc is not None]
189+
if self._crop:
190+
shift = tuple(start)
191+
# Crop image
192+
image_cut = trim_zeros(image_cut)
193+
194+
if output_layer_type == 'labels':
195+
image_cut = relabel_sequential(image_cut)[0]
196+
self._viewer._add_layer_from_data(
197+
image_cut,
198+
meta={
199+
'name': self._layer_to_be_cut_combobox.value.name + ' cut',
200+
'scale': layer_to_be_cut.scale,
201+
'translate': shift,
202+
'metadata': {'bbox': tuple(start + stop)},
203+
},
204+
layer_type=output_layer_type)

napari_crop/_function.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import warnings
22

33
import numpy as np
4-
from napari_plugin_engine import napari_hook_implementation
4+
from magicgui import magic_factory
55
from napari_tools_menu import register_function
66
import napari
77
from napari.types import LayerDataTuple
88
from typing import List
9-
9+
from ._utils import compute_combined_slices
1010

1111
# This is the actual plugin function, where we export our function
1212
# (The functions themselves are defined below)
13-
@napari_hook_implementation
14-
def napari_experimental_provide_function():
15-
return [crop_region]
1613

1714

18-
@register_function(menu="Utilities > Crop region(s)")
15+
@register_function(menu="Utilities > Crop region(s) (napari-crop)")
1916
def crop_region(
2017
layer: napari.layers.Layer,
2118
shapes_layer: napari.layers.Shapes,
22-
viewer: 'napari.viewer.Viewer' = None,
19+
viewer: 'napari.viewer.Viewer' = None,
2320
) -> List[LayerDataTuple]:
2421
"""Crop regions in napari defined by shapes."""
2522
if shapes_layer is None:
@@ -87,8 +84,8 @@ def crop_region(
8784
# Adjust cropped_data axes order in case axes were swapped in napari
8885
if viewer is not None:
8986
cropped_data_shape = np.moveaxis(cropped_data,
90-
viewer.dims.order,
91-
np.arange(len(cropped_data_shape))).shape
87+
viewer.dims.order,
88+
np.arange(len(layer_shape))).shape
9289
if rgb:
9390
shape_dif_2D = np.array(cropped_data_shape[-3:-1]) \
9491
- np.array(mask_2D.shape)
@@ -109,20 +106,27 @@ def crop_region(
109106
cropped_data[~mask] = 0
110107

111108
# trim zeros
112-
non_zero = np.where(cropped_data != 0)
113-
slices = [slice(min(i_nz), max(i_nz) + 1) for i_nz in non_zero]
114-
if rgb:
115-
slices[-1] = slice(None)
116-
cropped_data = cropped_data[tuple(slices)]
109+
inner_slices = get_nonzero_slices(cropped_data)
110+
cropped_data = trim_zeros(cropped_data, rgb=rgb)
111+
slices = compute_combined_slices(layer_shape, slices, inner_slices)
117112

118113
new_layer_props = layer_props.copy()
119114
# Update start and stop values for bbox
120115
start = [slc.start for slc in slices if slc is not None]
121-
stop = [slc.stop for slc in slices if slc is not None]
116+
stop = []
117+
for slc in slices:
118+
if slc is not None:
119+
if slc.stop is None:
120+
stop.append(layer_shape[slices.index(slc)])
121+
else:
122+
stop.append(slc.stop)
123+
# stop = [slc.stop for slc in slices if slc is not None]
122124
# Add cropped coordinates as metadata
123-
# bounding box: (min_row, max_row, min_col, max_col)
125+
# bounding box: ([min_z,] min_row, min_col, [max_z,] max_row, max_col)
124126
# Pixels belonging to the bounding box are in the half-open interval [min_row; max_row) and [min_col; max_col).
125-
new_layer_props['metadata'] = {'bbox': (start[0], stop[0], start[1], stop[1])}
127+
new_layer_props['metadata'] = {'bbox': tuple(start + stop)}
128+
# apply layer translation scaled by layer scaling factor
129+
new_layer_props['translate'] = tuple(np.asarray(tuple(start)) * np.asarray(layer_props['scale']))
126130

127131
# If layer name is in viewer or is about to be added,
128132
# increment layer name until it has a different name
@@ -137,3 +141,65 @@ def crop_region(
137141
names_list.append(new_name)
138142
cropped_list.append((cropped_data, new_layer_props, layer_type))
139143
return cropped_list
144+
145+
146+
def get_nonzero_slices(array):
147+
non_zero = np.where(array != 0)
148+
return [slice(min(i_nz), max(i_nz) + 1) for i_nz in non_zero]
149+
150+
151+
def trim_zeros(image, rgb=False):
152+
slices = get_nonzero_slices(image)
153+
if rgb:
154+
slices[-1] = slice(None)
155+
return image[tuple(slices)]
156+
157+
158+
def cut_with_plane(image_to_be_cut, plane_normal, plane_position, positive_cut=True, crop=False):
159+
"""Cut a 3D volume with a plane
160+
161+
Parameters
162+
----------
163+
image_to_be_cut : array_like (3D)
164+
3D image to be cut
165+
plane_normal : tuple (3)
166+
Normal vector of the plane
167+
plane_position : tuple (3)
168+
Position of the plane center
169+
positive_cut : bool, optional
170+
If True, the positive side of the plane is kept.
171+
If False, the negative side of the plane is kept. By default True
172+
173+
Returns
174+
-------
175+
array_like (3D)
176+
Cut image with the same shape as the input image
177+
"""
178+
import numpy as np
179+
if len(image_to_be_cut.shape) != 3:
180+
print('Input image to be cut must be 3D')
181+
return
182+
image_to_be_cut = np.asarray(image_to_be_cut)
183+
184+
x = np.arange(image_to_be_cut.shape[2])
185+
y = np.arange(image_to_be_cut.shape[1])
186+
z = np.arange(image_to_be_cut.shape[0])
187+
188+
zmesh, ymesh, xmesh = np.meshgrid(z, y, x, indexing='ij')
189+
190+
xm = xmesh - plane_position[2]
191+
ym = ymesh - plane_position[1]
192+
zm = zmesh - plane_position[0]
193+
194+
p = xm * plane_normal[2] + ym * plane_normal[1] + zm * plane_normal[0]
195+
196+
if positive_cut:
197+
mask = np.where(p >= 0, 1, 0).astype(bool)
198+
else:
199+
mask = np.where(p < 0, 1, 0).astype(bool)
200+
201+
image_cut = image_to_be_cut.copy()
202+
image_cut[~mask] = 0
203+
if crop:
204+
image_cut = trim_zeros(image_cut)
205+
return image_cut

0 commit comments

Comments
 (0)