Skip to content

Commit d6b0ba8

Browse files
authored
Merge pull request #56 from zoccoler/draw_fixed_shapes
Draw fixed shapes
2 parents 85c3a4f + ac7eeed commit d6b0ba8

File tree

13 files changed

+985
-97
lines changed

13 files changed

+985
-97
lines changed

.github/workflows/test_and_deploy.yml

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
strategy:
2424
matrix:
2525
platform: [windows-latest, macos-latest, ubuntu-latest]
26-
python-version: ['3.8', '3.9', '3.10']
26+
python-version: ['3.10', '3.11', '3.12']
2727

2828
steps:
2929
- uses: actions/checkout@v3
@@ -36,27 +36,32 @@ jobs:
3636
# these libraries enable testing on Qt on linux
3737
- uses: tlambert03/setup-qt-libs@v1
3838

39+
# strategy borrowed from vispy for installing opengl libs on windows
40+
- name: Install Windows OpenGL
41+
if: runner.os == 'Windows'
42+
run: |
43+
git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git
44+
powershell gl-ci-helpers/appveyor/install_opengl.ps1
45+
3946
# note: if you need dependencies from conda, considering using
4047
# setup-miniconda: https://github.com/conda-incubator/setup-miniconda
4148
# and
4249
# tox-conda: https://github.com/tox-dev/tox-conda
4350
- name: Install dependencies
4451
run: |
4552
python -m pip install --upgrade pip
46-
python -m pip install setuptools tox tox-gh-actions pytest-qt pytest-xvfb
53+
python -m pip install setuptools tox tox-gh-actions
4754
4855
# this runs the platform-specific tests declared in tox.ini
4956
- name: Test with tox
50-
uses: GabrielBB/xvfb-action@v1
57+
uses: aganders3/headless-gui@v2
5158
with:
5259
run: python -m tox
5360
env:
5461
PLATFORM: ${{ matrix.platform }}
5562

5663
- name: Coverage
57-
uses: codecov/codecov-action@v2
58-
with:
59-
token: ${{ secrets.CODECOV_TOKEN }}
64+
uses: codecov/codecov-action@v3
6065

6166
deploy:
6267
# this will run when you have tagged a commit, starting with "v*"
@@ -66,20 +71,20 @@ jobs:
6671
runs-on: ubuntu-latest
6772
if: contains(github.ref, 'tags')
6873
steps:
69-
- uses: actions/checkout@v2
74+
- uses: actions/checkout@v3
7075
- name: Set up Python
71-
uses: actions/setup-python@v2
76+
uses: actions/setup-python@v4
7277
with:
7378
python-version: "3.x"
7479
- name: Install dependencies
7580
run: |
7681
python -m pip install --upgrade pip
77-
pip install -U setuptools setuptools_scm wheel twine
82+
pip install -U setuptools setuptools_scm wheel twine build
7883
- name: Build and publish
7984
env:
8085
TWINE_USERNAME: __token__
8186
TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
8287
run: |
8388
git tag
84-
python setup.py sdist bdist_wheel
89+
python -m build .
8590
twine upload dist/*

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
[![codecov](https://codecov.io/gh/BiAPoL/napari-crop/branch/main/graph/badge.svg)](https://codecov.io/gh/BiAPoL/napari-crop)
88
[![DOI](https://zenodo.org/badge/419822240.svg)](https://zenodo.org/badge/latestdoi/419822240)
99

10-
## Currently, this repository is not guaranteed to be actively maintained.
11-
If you are interested in further developing it, feel free to fork it.
10+
🚨🚨🚨 **Call for maintainers** 🚨🚨🚨
11+
12+
If you are interested in further developing it, please get in touch or create a fork.
1213

1314
Crop regions in napari manually
1415

@@ -28,12 +29,16 @@ Cut a volume using a plane
2829

2930
![](https://github.com/BiAPoL/napari-crop/raw/main/images/napari_crop_cut_with_plane_demo.gif)
3031

32+
Draw shapes of fixed size at given coordinates for later cropping
33+
34+
![](https://github.com/BiAPoL/napari-crop/raw/main/images/napari_crop_draw_shapes.gif)
35+
3136
## Usage
3237
Create a new shapes layer to annotate the region you would like to crop:
3338

3439
![](https://github.com/BiAPoL/napari-crop/raw/main/images/shapes.png)
3540

36-
Use the rectangle tool to annotate a region. Start the `crop` tool from the `Tools > Utilities > Crop region` menu.
41+
Use the rectangle tool to annotate a region. Start the `crop` tool from the `Plugins > napari-crop > Crop region` or, if available, `Tools > Utilities > Crop region` menu.
3742
Click the `Run` button to crop the region.
3843

3944
![](https://github.com/BiAPoL/napari-crop/raw/main/images/draw_rectangle.png)

images/napari_crop_draw_shapes.gif

1.15 MB
Loading

napari_crop/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
try:
2+
from ._version import version as __version__
3+
except ImportError:
4+
__version__ = "unknown"
15

2-
__version__ = "0.1.9"
3-
4-
5-
from ._dock_widgets import napari_experimental_provide_dock_widget
6-
from ._function import crop_region, cut_with_plane
6+
from ._dock_widgets import CutWithPlane
7+
from ._function import crop_region, cut_with_plane, draw_fixed_shapes

napari_crop/_dock_widgets.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,16 @@
22

33
from magicgui.widgets import Container, PushButton, ComboBox, CheckBox
44
from typing import TYPE_CHECKING
5-
from napari_plugin_engine import napari_hook_implementation
65
from skimage.segmentation import relabel_sequential
76
from napari_tools_menu import register_dock_widget
8-
from magicgui import magic_factory
97

108
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
9+
from ._function import cut_with_plane, trim_zeros, get_nonzero_slices
1210
import napari.layers
1311
if TYPE_CHECKING:
1412
import napari.viewer
1513

1614

17-
@napari_hook_implementation
18-
def napari_experimental_provide_dock_widget():
19-
return [magic_factory(crop_region), CutWithPlane]
20-
21-
2215
@register_dock_widget(menu="Utilities > Cut volume with plane (napari-crop)")
2316
class CutWithPlane(Container):
2417
input_layer_types = (
@@ -136,7 +129,8 @@ def _get_layers_to_be_cut(self, combo_box):
136129
'''Get layers of type image or labels and excludes the plane layer'''
137130
# Currently accepts only 3D data
138131
return [layer for layer in self._viewer.layers if isinstance(
139-
layer, self.input_layer_types) and layer != self._plane_layer and layer.rgb is False and layer.ndim == 3]
132+
layer, self.input_layer_types) and layer != self._plane_layer and
133+
(not isinstance(layer, napari.layers.Image) or layer.rgb is False) and layer.ndim == 3]
140134

141135
def _on_plane_data_source_changed(self, new_value: str):
142136
'''Update plane data source and plane layer data'''

napari_crop/_function.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import warnings
22

33
import numpy as np
4-
from magicgui import magic_factory
54
from napari_tools_menu import register_function
65
import napari
76
from napari.types import LayerDataTuple
87
from typing import List
98
from ._utils import compute_combined_slices
9+
from magicgui import magic_factory
1010

1111
# This is the actual plugin function, where we export our function
1212
# (The functions themselves are defined below)
@@ -16,9 +16,29 @@
1616
def crop_region(
1717
layer: napari.layers.Layer,
1818
shapes_layer: napari.layers.Shapes,
19+
as_numpy: bool = False,
20+
translate: bool = True,
1921
viewer: 'napari.viewer.Viewer' = None,
2022
) -> List[LayerDataTuple]:
21-
"""Crop regions in napari defined by shapes."""
23+
"""Crop regions in napari defined by shapes.
24+
25+
Parameters
26+
----------
27+
layer : napari.layers.Layer
28+
Layer to crop. Can be an image or labels layer.
29+
shapes_layer : napari.layers.Shapes
30+
Shapes layer defining the regions to crop.
31+
as_numpy : bool, optional
32+
If True, return the cropped data as numpy arrays. Default is False.
33+
translate : bool, optional
34+
If True, apply translation to the cropped data. Default is True.
35+
viewer : napari.viewer.Viewer, optional
36+
Viewer instance to use for the dimensions order.
37+
38+
Returns
39+
-------
40+
41+
"""
2242
if shapes_layer is None:
2343
shapes_layer.mode = "add_rectangle"
2444
warnings.warn("Please annotate a region to crop.")
@@ -126,7 +146,8 @@ def crop_region(
126146
# Pixels belonging to the bounding box are in the half-open interval [min_row; max_row) and [min_col; max_col).
127147
new_layer_props['metadata'] = {'bbox': tuple(start + stop)}
128148
# apply layer translation scaled by layer scaling factor
129-
new_layer_props['translate'] = tuple(np.asarray(tuple(start)) * np.asarray(layer_props['scale']))
149+
if translate:
150+
new_layer_props['translate'] = tuple(np.asarray(tuple(start)) * np.asarray(layer_props['scale']))
130151

131152
# If layer name is in viewer or is about to be added,
132153
# increment layer name until it has a different name
@@ -139,6 +160,8 @@ def crop_region(
139160
new_layer_index += 1
140161
new_layer_props["name"] = new_name
141162
names_list.append(new_name)
163+
if as_numpy:
164+
cropped_data = np.asarray(cropped_data)
142165
cropped_list.append((cropped_data, new_layer_props, layer_type))
143166
return cropped_list
144167

@@ -203,3 +226,67 @@ def cut_with_plane(image_to_be_cut, plane_normal, plane_position, positive_cut=T
203226
if crop:
204227
image_cut = trim_zeros(image_cut)
205228
return image_cut
229+
230+
@magic_factory(
231+
call_button="Draw",
232+
shape_type={"choices": ["rectangle", "ellipse"]}, # Dropdown for shape type
233+
shape_size_x={"widget_type": "SpinBox", "min": 1, "max": 5000, "step": 1},
234+
shape_size_y={"widget_type": "SpinBox", "min": 1, "max": 5000, "step": 1},
235+
)
236+
def draw_fixed_shapes(
237+
points: napari.types.PointsData,
238+
shape_type: str = "rectangle",
239+
shape_size_x: int = 256,
240+
shape_size_y: int = 256,
241+
viewer: napari.Viewer = None,
242+
) -> napari.layers.Shapes:
243+
"""Create shapes of fixed size at points layer coordinates.
244+
245+
Parameters
246+
----------
247+
points : napari.types.PointsData
248+
Coordinates of the points layer.
249+
shape_type : str
250+
Type of shape to create. Can be 'rectangle' or 'ellipse'.
251+
shape_size_x : int
252+
Width of the shape.
253+
shape_size_y : int
254+
Height of the shape.
255+
viewer : napari.Viewer, optional
256+
Viewer instance to use for the dimensions order.
257+
258+
Returns
259+
-------
260+
Shapes
261+
Shapes layer with the created shapes."""
262+
if points is None:
263+
raise ValueError("No points provided. Please select a points layer.")
264+
dims_order = tuple(range(points.ndim))
265+
if viewer is not None:
266+
dims_order = viewer.dims.order
267+
shape_size = (shape_size_y, shape_size_x)
268+
odd_shape = [size % 2 for size in shape_size]
269+
270+
shapes_data = []
271+
for coord in points:
272+
shape_data = np.array([
273+
[coord[dims_order[-2]] - (shape_size[-2] // 2), coord[dims_order[-1]] - (shape_size[-1] // 2)], # Top-left
274+
[coord[dims_order[-2]] - (shape_size[-2] // 2), coord[dims_order[-1]] + (shape_size[-1] // 2) + odd_shape[-1]], # Bottom-left
275+
[coord[dims_order[-2]] + (shape_size[-2] // 2) + odd_shape[-2], coord[dims_order[-1]] + (shape_size[-1] // 2) + odd_shape[-1]], # Bottom-right
276+
[coord[dims_order[-2]] + (shape_size[-2] // 2) + odd_shape[-2], coord[dims_order[-1]] - (shape_size[-1] // 2)], # Top-right
277+
])
278+
# Insert extra coordinates for higher dimensions
279+
# For example, if the shape is 3D, we need to add the z-coordinates
280+
extra_coords = np.take(coord, indices=dims_order[:-2], axis=0)
281+
for i, ec in enumerate(extra_coords):
282+
shape_data = np.insert(shape_data, 0, round(ec), axis=-1)
283+
shape_data = shape_data[:, np.argsort(dims_order)]
284+
shapes_data.append(shape_data)
285+
286+
return napari.layers.Shapes(
287+
data=shapes_data,
288+
shape_type=[shape_type for _ in points],
289+
edge_color='magenta',
290+
face_color='#ffff0080', # semi-transparent yellow
291+
edge_width=2,
292+
)

napari_crop/_tests/test_function.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from napari_crop._function import crop_region, cut_with_plane
1+
from napari_crop._function import crop_region, cut_with_plane, draw_fixed_shapes
22
import pytest
33
import numpy as np
44

@@ -258,3 +258,66 @@ def test_cut_with_plane_position(plane_position, output_expected_position):
258258
def test_cut_with_plane_negative():
259259
image_cut = cut_with_plane(volume_data, plane_normal_z, plane_position_1, positive_cut=False)
260260
assert np.array_equal(output_expected_normal_z_position_1_negative, image_cut)
261+
262+
263+
# Tests for draw_fixed_shapes function
264+
points_2d = np.array([[2, 2], [1, 4]]) # 2D points
265+
points_3d = np.array([[1, 2, 2], [1, 1, 4]]) # 3D points with z=1
266+
shape_types_fixed = ["rectangle", "ellipse"]
267+
shape_sizes = [(100, 50), (256, 256)] # (x, y) sizes
268+
269+
270+
@pytest.mark.parametrize("shape_type", shape_types_fixed)
271+
@pytest.mark.parametrize("shape_size_x,shape_size_y", shape_sizes)
272+
def test_draw_fixed_shapes_2d(make_napari_viewer, shape_type, shape_size_x, shape_size_y):
273+
"""Test drawing fixed shapes in 2D with different types and sizes."""
274+
viewer = make_napari_viewer()
275+
points_layer = viewer.add_points(points_2d)
276+
277+
widget = draw_fixed_shapes()
278+
widget.shape_type.value = shape_type
279+
widget.shape_size_x.value = shape_size_x
280+
widget.shape_size_y.value = shape_size_y
281+
282+
shapes_layer = widget()
283+
284+
# Check that we get the correct number of shapes
285+
assert len(shapes_layer.data) == len(points_2d)
286+
287+
# Check that all shapes have the correct type
288+
assert all(st == shape_type for st in shapes_layer.shape_type)
289+
290+
# Check that shapes are rectangles with 4 vertices each
291+
for shape_data in shapes_layer.data:
292+
assert shape_data.shape == (4, 2) # 4 vertices, 2D coordinates
293+
294+
295+
def test_draw_fixed_shapes_3d(make_napari_viewer):
296+
"""Test drawing fixed shapes in 3D."""
297+
viewer = make_napari_viewer()
298+
points_layer = viewer.add_points(points_3d)
299+
300+
widget = draw_fixed_shapes()
301+
widget.shape_type.value = "rectangle"
302+
widget.shape_size_x.value = 100
303+
widget.shape_size_y.value = 100
304+
305+
shapes_layer = widget()
306+
307+
# Check that we get the correct number of shapes
308+
assert len(shapes_layer.data) == len(points_3d)
309+
310+
# Check that shapes have 3D coordinates (z, y, x)
311+
for shape_data in shapes_layer.data:
312+
assert shape_data.shape == (4, 3) # 4 vertices, 3D coordinates
313+
shape_size_y=100,
314+
viewer=viewer
315+
316+
317+
# Check that we get the correct number of shapes
318+
assert len(shapes_layer.data) == len(points_3d)
319+
320+
# Check that shapes have 3D coordinates (z, y, x)
321+
for shape_data in shapes_layer.data:
322+
assert shape_data.shape == (4, 3) # 4 vertices, 3D coordinates
323+

napari_crop/napari.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: napari-crop
2+
schema_version: 0.2.1
3+
contributions:
4+
commands:
5+
- id: napari-crop.crop_region
6+
title: Crop Region(s) with Shapes layer
7+
python_name: napari_crop._function:crop_region
8+
- id: napari-crop.CutWithPlane
9+
title: Create Cut With Plane
10+
python_name: napari_crop._dock_widgets:CutWithPlane
11+
- id: napari-crop.draw_fixed_shapes
12+
title: Draw shapes of fixed dimensions
13+
python_name: napari_crop._function:draw_fixed_shapes
14+
widgets:
15+
- command: napari-crop.CutWithPlane
16+
display_name: Cut With Plane
17+
- command: napari-crop.crop_region
18+
display_name: Crop Region(s)
19+
autogenerate: true
20+
- command: napari-crop.draw_fixed_shapes
21+
display_name: Draw Fixed Shapes

0 commit comments

Comments
 (0)