Skip to content

Commit 6fe2c6e

Browse files
DPWebstersfmigpre-commit-ci[bot]
authored
Visualize bounding boxes as Napari shapes layer (#590)
* Basic implementation of bboxes visualization as shapes * Basic implementation of bboxes visualization as shapes * Added test coverage and polish * Removed redundant set_text method for shapes style * Finalized docs * Revised based on review * Apply suggestions from code review Committed all review changes not subject to deeper revision Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Added test coverage for bboxes set_text_by * Small edits to user guide * Use ds.ds_type == "bboxes" to identify bbox datasets * Small edits * Refactor and split bloated test. Clarify test on bboxes style. Parametrise test over bboxes and poses data. * Fix docstring rendering --------- Co-authored-by: sfmig <33267254+sfmig@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6b6a4d8 commit 6fe2c6e

File tree

9 files changed

+587
-117
lines changed

9 files changed

+587
-117
lines changed
486 KB
Loading

docs/source/user_guide/gui.md

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ The `movement` graphical user interface (GUI), powered by our custom plugin for
55
[napari](napari:), makes it easy to view and explore `movement`
66
motion tracks. Currently, you can use it to
77
visualise 2D [movement datasets](target-poses-and-bboxes-dataset)
8-
as points and tracks overlaid on video frames.
8+
as points, tracks, and rectangular bounding boxes (if defined) overlaid on video frames.
99

1010
:::{warning}
1111
The GUI is still in early stages of development but we are working on ironing
@@ -138,17 +138,21 @@ an expanded `Load tracked data` menu. To load tracked data in napari:
138138

139139
The data will be loaded into the viewer as a
140140
[points layer](napari:howtos/layers/points.html) and as a [tracks layer](napari:howtos/layers/tracks.html).
141+
If the input file is a bounding boxes dataset, an additional napari [shapes layer](napari:howtos/layers/shapes.html) is loaded.
141142
By default, the data is added at the top of the layer list and the points layer is selected.
142143

144+
For a poses dataset, you will see a view similar to this:
145+
143146
(target-widget-screenshot)=
144147

145148
![napari widget with poses dataset loaded](../_static/napari_plugin_data_tracks.png)
146149

150+
And for a bounding boxes dataset, you will see a view more like the one below:
151+
152+
![napari widget with shapes loaded](../_static/napari_bboxes_layer.png)
147153

148-
You will see a view similar to the one above. Notice the three
149-
layers on the left-hand side list: the
150-
image layer, that holds the background information (i.e., the loaded video or image), the points layer and the tracks layer.
151-
You can toggle the visibility of each of these layers by clicking on the eye icon.
154+
155+
Note the additional bounding boxes layer that is loaded for bounding boxes datasets. For both poses and bounding boxes datasets, you can toggle the visibility of any of these layers by clicking on the eye icon.
152156

153157

154158
### The points layer
@@ -205,8 +209,7 @@ Remember that the current frame is determined by the position of the frame slide
205209
The trajectory made up of all positions of a keypoint on all frames before the current frame is called _tail_.
206210
Similarly, its trajectory on all frames after the current frame is called _head_.
207211

208-
Both tail and head tracks are represented as lines connecting the keypoints
209-
of the same individual across frames. The colour of the tracks follows
212+
Both tail and head tracks are represented as lines connecting a single keypoint across frames. The colour of the tracks follows
210213
the colour of the markers, and the length of the tracks can be adjusted in the
211214
[tracks layer](napari:howtos/layers/tracks.html) controls panel, with the `tail length` and `head length` sliders.
212215

@@ -243,3 +246,27 @@ working on a workaround, stay tuned!
243246
- Also note that currently the `show ID` checkbox in the [tracks layer](napari:howtos/layers/tracks.html) controls panel refers to
244247
an internal napari track ID, rather than the individual or the keypoint ID. This is a known issue and we are working on a fix or workaround.
245248
:::
249+
250+
### The boxes layer
251+
252+
The boxes layer is loaded for bounding boxes datasets only. It shows the bounding boxes for the current frame, as rectangles color-coded by individual.
253+
254+
The name of the individual is shown in the lower left of the bounding box by default. This can be toggled by selecting the [shapes layer](napari:howtos/layers/shapes.html) in the layer list and clicking the `display text` checkbox in the layer controls panel.
255+
256+
As with tracks and points, you can use the frame slider at the bottom of the viewer to move through the frames of the dataset. This will update the bounding boxes and the rest of the loaded data in sync.
257+
258+
:::{admonition} Changing bounding box size, colour and shape
259+
:class: tip
260+
261+
You can change edge and face colour as well as the positions of a bounding box's corner vertices using the
262+
[shapes layer](napari:howtos/layers/shapes.html) controls panel.
263+
264+
You can use the following keyboard shortcuts to toggle the bounding boxes selection:
265+
- To select all the bounding boxes in the current frame, enable the select shapes tool (`5` or `S`) and press `A`.
266+
- To deselect, click off of the bounding boxes.
267+
- Individual vertices can be selected instead of the entire rectangle using the select vertex tool: `4` or `D`.
268+
269+
You can find all the [keyboard shortcuts](napari:guides/preferences.html#shortcuts) in the top menu of the
270+
`napari` window, under `Preferences > Shortcuts`.
271+
272+
:::

movement/napari/convert.py

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import pandas as pd
55
import xarray as xr
66

7-
# get logger
8-
97

108
def _construct_properties_dataframe(ds: xr.Dataset) -> pd.DataFrame:
119
"""Construct a properties DataFrame from a ``movement`` dataset."""
@@ -23,9 +21,25 @@ def _construct_properties_dataframe(ds: xr.Dataset) -> pd.DataFrame:
2321
return pd.DataFrame(data).reindex(columns=desired_order)
2422

2523

26-
def ds_to_napari_tracks(
24+
def _construct_track_and_time_cols(
25+
ds: xr.Dataset,
26+
) -> tuple[np.ndarray, np.ndarray]:
27+
"""Compute napari track_id and time columns from a ``movement`` dataset."""
28+
n_frames = ds.sizes["time"]
29+
n_individuals = ds.sizes["individuals"]
30+
n_keypoints = ds.sizes.get("keypoints", 1)
31+
n_tracks = n_individuals * n_keypoints
32+
33+
# Each keypoint of each individual is a separate track
34+
track_id_col = np.repeat(np.arange(n_tracks), n_frames).reshape(-1, 1)
35+
time_col = np.tile(np.arange(n_frames), (n_tracks)).reshape(-1, 1)
36+
37+
return track_id_col, time_col
38+
39+
40+
def ds_to_napari_layers(
2741
ds: xr.Dataset,
28-
) -> tuple[np.ndarray, pd.DataFrame]:
42+
) -> tuple[np.ndarray, np.ndarray | None, pd.DataFrame]:
2943
"""Convert ``movement`` dataset to napari Tracks array and properties.
3044
3145
Parameters
@@ -36,12 +50,20 @@ def ds_to_napari_tracks(
3650
3751
Returns
3852
-------
39-
data : np.ndarray
40-
napari Tracks array with shape (N, 4),
53+
points_as_napari : np.ndarray
54+
position data as a napari Tracks array with shape (N, 4),
4155
where N is n_keypoints * n_individuals * n_frames
4256
and the 4 columns are (track_id, frame_idx, y, x).
57+
bboxes_as_napari : np.ndarray | None
58+
bounding box data as a napari Shapes array with shape (N, 4, 4),
59+
where N is n_individuals * n_frames and each (4, 4) entry is
60+
a matrix of 4 rows (1 per corner vertex, starting from upper left
61+
and progressing in counterclockwise order) with the columns
62+
(track_id, frame, y, x). Returns None when the input dataset doesn't
63+
have a "shape" variable.
4364
properties : pd.DataFrame
44-
DataFrame with properties (individual, keypoint, time, confidence).
65+
DataFrame with properties (individual, keypoint, time, confidence)
66+
for use with napari layers.
4567
4668
Notes
4769
-----
@@ -55,12 +77,9 @@ def ds_to_napari_tracks(
5577
.. [2] https://napari.org/stable/howtos/layers/points.html
5678
5779
"""
58-
n_frames = ds.sizes["time"]
59-
n_individuals = ds.sizes["individuals"]
60-
n_keypoints = ds.sizes.get("keypoints", 1)
61-
n_tracks = n_individuals * n_keypoints
80+
# Construct the track_ID and time columns for the napari Tracks array
81+
track_id_col, time_col = _construct_track_and_time_cols(ds)
6282

63-
# Construct the napari Tracks array
6483
# Reorder axes to (individuals, keypoints, frames, xy)
6584
axes_reordering: tuple[int, ...] = (2, 0, 1)
6685
if "keypoints" in ds.coords:
@@ -70,10 +89,50 @@ def ds_to_napari_tracks(
7089
axes_reordering, # to: individuals, keypoints, frames, xy
7190
).reshape(-1, 2)[:, [1, 0]] # swap x and y columns
7291

73-
# Each keypoint of each individual is a separate track
74-
track_id_col = np.repeat(np.arange(n_tracks), n_frames).reshape(-1, 1)
75-
time_col = np.tile(np.arange(n_frames), (n_tracks)).reshape(-1, 1)
76-
data = np.hstack((track_id_col, time_col, yx_cols))
92+
points_as_napari = np.hstack((track_id_col, time_col, yx_cols))
93+
bboxes_as_napari = None
94+
95+
# Construct the napari Shapes array if the input dataset is a
96+
# bounding boxes one
97+
if ds.ds_type == "bboxes":
98+
# Compute bbox corners
99+
xmin_ymin = ds.position - (ds.shape / 2)
100+
xmax_ymax = ds.position + (ds.shape / 2)
101+
102+
# initialise xmax, ymin corner as xmin, ymin
103+
xmax_ymin = xmin_ymin.copy()
104+
# overwrite its x coordinate to xmax
105+
xmax_ymin.loc[{"space": "x"}] = xmax_ymax.loc[{"space": "x"}]
106+
107+
# initialise xmin, ymin corner as xmin, ymin
108+
xmin_ymax = xmin_ymin.copy()
109+
# overwrite its y coordinate to ymax
110+
xmin_ymax.loc[{"space": "y"}] = xmax_ymax.loc[{"space": "y"}]
111+
112+
# Add track_id and time columns to each corner array
113+
corner_arrays_with_track_id_and_time = [
114+
np.c_[
115+
track_id_col,
116+
time_col,
117+
np.transpose(corner.values, axes_reordering).reshape(-1, 2),
118+
]
119+
for corner in [xmin_ymin, xmin_ymax, xmax_ymax, xmax_ymin]
120+
]
121+
122+
# Concatenate corner arrays along columns
123+
corners_array = np.concatenate(
124+
corner_arrays_with_track_id_and_time, axis=1
125+
)
126+
127+
# Reshape to napari expected format
128+
# goes through corners counterclockwise from xmin_ymin
129+
# in image coordinates
130+
corners_array = corners_array.reshape(
131+
-1, 4, 4
132+
) # last dimension: track_id, time, x, y
133+
bboxes_as_napari = corners_array[
134+
:, :, [0, 1, 3, 2]
135+
] # swap x and y columns
77136

78137
# Construct the properties DataFrame
79138
# Stack individuals, time and keypoints (if present) dimensions
@@ -85,4 +144,4 @@ def ds_to_napari_tracks(
85144

86145
properties = _construct_properties_dataframe(ds_)
87146

88-
return data, properties
147+
return points_as_napari, bboxes_as_napari, properties

movement/napari/layer_styles.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,75 @@ def set_color_by(self, property: str, cmap: str | None = None) -> None:
121121
self.colormap = cmap
122122

123123

124+
@dataclass
125+
class BoxesStyle(LayerStyle):
126+
"""Style properties for a napari Shapes layer."""
127+
128+
edge_width: int = 3
129+
opacity: float = 1.0
130+
shape_type: str = "rectangle"
131+
face_color: str = "#FFFFFF00" # transparent face
132+
edge_colormap: str = DEFAULT_COLORMAP
133+
text: dict = field(
134+
default_factory=lambda: {
135+
"visible": True, # default visible text for bboxes
136+
"anchor": "lower_left",
137+
"translation": 5, # pixels
138+
}
139+
)
140+
141+
def set_color_by(
142+
self,
143+
property: str,
144+
properties_df: pd.DataFrame,
145+
cmap: str | None = None,
146+
) -> None:
147+
"""Color boxes and text by chosen column in the properties DataFrame.
148+
149+
Parameters
150+
----------
151+
property : str
152+
The column name in the properties DataFrame to color shape edges
153+
and associated text by.
154+
properties_df : pd.DataFrame
155+
The properties DataFrame containing the data for generating the
156+
colormap.
157+
cmap : str, optional
158+
The name of the colormap to use, otherwise use the edge_colormap.
159+
160+
Notes
161+
-----
162+
The input property is expected to be a column in the properties
163+
dataframe and it is used to define the color of the text. A factorized
164+
version of the property ("<property>_factorized") is used to define the
165+
edges color, and is also expected to be present in the properties
166+
dataframe.
167+
168+
"""
169+
# Compute color cycle based on property
170+
if cmap is None:
171+
cmap = self.edge_colormap
172+
n_colors = len(properties_df[property].unique())
173+
color_cycle = _sample_colormap(n_colors, cmap)
174+
175+
# Set color for edges and text
176+
self.edge_color = property + "_factorized"
177+
self.edge_color_cycle = color_cycle
178+
self.text["color"] = {"feature": property}
179+
self.text["color"].update({"colormap": color_cycle})
180+
181+
def set_text_by(self, property: str) -> None:
182+
"""Set the text property for the boxes layer.
183+
184+
Parameters
185+
----------
186+
property : str
187+
The column name in the properties DataFrame to use for text.
188+
189+
"""
190+
self.text["string"] = property
191+
192+
124193
def _sample_colormap(n: int, cmap_name: str) -> list[tuple]:
125194
"""Sample n equally-spaced colors from a napari colormap.
126195

0 commit comments

Comments
 (0)