Skip to content

Commit d381729

Browse files
authored
Visualise ellipses on annotated data (#297)
* Make notebook runnable with current data * Make mask int16 rather than bool. Skip frames with no prompts. Rename vars * Clarify * Update notebook * Update mask path * Load frames too
1 parent 5400081 commit d381729

2 files changed

Lines changed: 117 additions & 65 deletions

File tree

‎notebooks/notebook_visualise_masks_from_zarr.py‎

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77
import matplotlib.pyplot as plt
88
import napari
99
import numpy as np
10-
import sparse
1110
import xarray as xr
12-
import zarr
1311
from ethology.io.annotations import load_bboxes
1412
from PIL import Image
13+
from skimage.measure import regionprops
1514

1615
# %%
1716
# %matplotlib widget
@@ -27,26 +26,40 @@
2726
# %%%%%%%%%%%%%%%%%%%%%%%%%%
2827
# Helpers
2928

29+
3030
class ImageArrayLazy:
3131
"""A lazy array for images in a list."""
3232

3333
def __init__(self, img_paths):
3434
self.img_paths = sorted(img_paths)
35-
# add image shape, assuming all have same as
35+
# add image shape and dtype, assuming all have same as
3636
# first sample
37-
sample = np.array(Image.open(img_paths[0])) # H, W, C
37+
sample = np.array(Image.open(self.img_paths[0])) # H, W, C
3838
self.img_h, self.img_w, self.img_c = sample.shape
39+
self.dtype = sample.dtype
3940

4041
def __len__(self):
4142
return len(self.img_paths)
4243

4344
def __getitem__(self, idx):
44-
return np.array(Image.open(self.img_paths[idx]))
45+
# napari indexes with a tuple, e.g. (frame, slice, slice)
46+
frame_idx, *rest = idx if isinstance(idx, tuple) else (idx,)
47+
if isinstance(frame_idx, slice):
48+
frames = np.stack(
49+
[np.array(Image.open(p)) for p in self.img_paths[frame_idx]]
50+
)
51+
return frames[(slice(None), *rest)]
52+
return np.array(Image.open(self.img_paths[frame_idx]))[tuple(rest)]
4553

4654
@property
4755
def shape(self):
56+
"""Shape of array (B, H, W, C)."""
4857
return (len(self.img_paths), self.img_h, self.img_w, self.img_c)
49-
# B, H, W, C
58+
59+
@property
60+
def ndim(self):
61+
"""Number of dimensions in array."""
62+
return len(self.shape)
5063

5164

5265
def ellipses_from_labels(label_image):
@@ -63,6 +76,7 @@ def ellipses_from_labels(label_image):
6376
Endpoint pairs for the major axis (y, x).
6477
minor_axes : list of (2, 2) arrays
6578
Endpoint pairs for the minor axis (y, x).
79+
6680
"""
6781
ellipse_corners = []
6882
major_axes = []
@@ -124,12 +138,12 @@ def ellipses_from_labels(label_image):
124138

125139
# %%%%%%%%%%%%%%%%%%%%%%%
126140
# Load masks
127-
zarr_root = zarr.open(
128-
DATA_DIR / f"{LABEL_NAME} masks.zarr",
129-
mode="r",
130-
)
141+
# ID-encoded masks (int16): 0 = background, k + 1 = the k-th box
142+
# in the frame. Set the store name to the output of
143+
# scripts/generate_masks_from_bboxes.py
144+
MASKS_ZARR = DATA_DIR / "annotations" / "masks_20261009_133849.zarr"
131145

132-
mask_da_array = da.from_zarr(zarr_root["masks"])
146+
mask_da_array = da.from_zarr(MASKS_ZARR, mode="r") # (image_id, H, W)
133147

134148

135149
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
@@ -138,14 +152,20 @@ def ellipses_from_labels(label_image):
138152
# CONS: can be slow to compute
139153
n_ids = ds_bboxes.sizes["id"]
140154

155+
141156
# for one task per chunk:
142157
def _apply_one_hot_encoding_for_IDs(block, n_ids):
143-
"""Transform a block of shape (chunk_frames, H, W)
158+
"""Apply one hot encoding to ID-encoded block.
159+
160+
Transform an ID-encoded block of shape (chunk_frames, H, W)
144161
into a boolean array of shape (chunk_frames, n_ids, H, W).
145162
163+
Label k + 1 in the block is the mask of the box at position k
164+
along the `id` dimension.
165+
146166
This is most efficient if chunk_frames=1.
147167
"""
148-
labels = np.arange(1, n_ids + 1)[None, :, None, None] # (1, n_ids, 1, 1)
168+
labels = np.arange(1, n_ids + 1)[None, :, None, None] # (1, n_ids, 1, 1)
149169
# compare every pixel against every label simultaneously
150170
# via broadcasting (i.e. for each pixel, we have a 1-hot encoding
151171
# vector indicate which label/id it is)
@@ -154,16 +174,16 @@ def _apply_one_hot_encoding_for_IDs(block, n_ids):
154174

155175
# Apply expand labels chunk by chunk
156176
# we define one chunk per image
157-
mask_da_array = mask_da_array.rechunk({0: 1})
177+
mask_da_array = mask_da_array.rechunk({0: 1})
158178
mask_4d = mask_da_array.map_blocks(
159179
_apply_one_hot_encoding_for_IDs,
160180
n_ids=n_ids,
161-
new_axis=1, # position of new_axis id
162-
chunks=( # sizes of output chunks (if diff from input)
163-
mask_da_array.chunks[0], # same as input chunk axis=0
164-
(n_ids,), # ids
165-
mask_da_array.chunks[1], # same as input chunk axis=1
166-
mask_da_array.chunks[2], # same as input chunk axis=2
181+
new_axis=1, # position of new_axis id
182+
chunks=( # sizes of output chunks (if diff from input)
183+
mask_da_array.chunks[0], # same as input chunk axis=0
184+
(n_ids,), # ids
185+
mask_da_array.chunks[1], # same as input chunk axis=1
186+
mask_da_array.chunks[2], # same as input chunk axis=2
167187
),
168188
dtype=bool,
169189
) # (image_id, id, img_h, img_w)
@@ -201,32 +221,32 @@ def _apply_one_hot_encoding_for_IDs(block, n_ids):
201221
)
202222
ax.contour(single_mask, levels=[0.5], colors="red", linewidths=0.5)
203223

204-
# plot all masks in one frame (boolean masks, all same color!)
205-
all_masks = ds_bboxes.masks_bool.sel(image_id=image_id).any(dim="id")
224+
# plot all masks in one frame (one color per ID)
225+
label_frame = mask_da_array[image_id].compute()
206226
ax.imshow(
207-
all_masks,
227+
label_frame,
208228
cmap="turbo",
209-
alpha=all_masks.astype(float) * 0.5,
229+
alpha=(label_frame > 0) * 0.5,
210230
)
211231

212232
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
213233
# Visualise frames and masks in napari viewer
214234
mask_array = mask_da_array
215235
viewer = napari.Viewer()
216236

217-
# viewer.add_image(np.asarray(image_array).moveaxis(0, -1, 1, 2), name="image")
218-
219-
viewer.add_labels(np.asarray(mask_array), name=f"{LABEL_NAME} masks")
237+
# both layers are lazy, so napari only loads the frame displayed
238+
viewer.add_image(image_array, name="frames", rgb=True)
239+
viewer.add_labels(mask_array, name=f"{LABEL_NAME} masks")
220240

221241
# %%
222242
# build ellipses and axis lines for napari Shapes layers
223243
all_ellipses = []
224244
all_major_axes = []
225245
all_minor_axes = []
226246
for frame_idx in range(mask_array.shape[0]):
227-
ellipse_corners, major_axes, minor_axes = ellipses_from_labels(
228-
np.asarray(mask_array[frame_idx])
229-
)
247+
# masks are already ID-encoded, so each ID is one region
248+
label_frame = np.asarray(mask_array[frame_idx])
249+
ellipse_corners, major_axes, minor_axes = ellipses_from_labels(label_frame)
230250
for corners in ellipse_corners:
231251
# Prepend frame index as first column for nD shapes
232252
nd = np.column_stack(
@@ -266,3 +286,5 @@ def _apply_one_hot_encoding_for_IDs(block, n_ids):
266286
edge_width=4,
267287
name="major axes",
268288
)
289+
290+
# %%

‎scripts/generate_masks_from_bboxes.py‎

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
Pass bounding boxes as prompts to SAM2, predict masks in those regions,
44
and save as an ID-encoded zarr store.
55
6+
This script is a one-off tool to run on annotated frames whose mask labels
7+
are only meaningful within a frame.
8+
69
Expected data directory structure::
710
811
<data_dir>/
@@ -117,8 +120,8 @@ def create_mask_zarr(
117120
path_to_zarr,
118121
mode="w",
119122
shape=(n_images, image_h, image_w),
120-
dtype="bool",
121-
fill_value=False,
123+
dtype="int16",
124+
fill_value=0,
122125
chunks=(1, image_h, image_w),
123126
)
124127

@@ -140,59 +143,86 @@ def predict_masks_across_images(
140143
1:3
141144
] # ds_bboxes.attrs["image_array"].shape[1:3]
142145

143-
for idx in range(0, n_frames, img_batch_size):
144-
actual_batch_size = min(img_batch_size, n_frames - idx)
146+
for batch_start in range(0, n_frames, img_batch_size):
147+
actual_batch_size = min(img_batch_size, n_frames - batch_start)
145148

146149
# Integer array (B, H, W):
147150
# 0 = background, 1+ = object instance IDs
148-
id_mask_batch = np.zeros(
151+
label_mask_batch = np.zeros(
149152
(actual_batch_size, img_h, img_w), dtype=np.int16
150153
)
151154

152-
# Compute embeddings for image batch
153-
image_batch = [image_array[idx + i] for i in range(actual_batch_size)]
154-
model.set_image_batch(image_batch)
155-
156155
# List of (N_i, 4) arrays, one per image
157156
boxes_batch = [
158157
bbox_prompts_per_frame_idx[f_i]
159-
for f_i in range(idx, idx + actual_batch_size)
158+
for f_i in range(batch_start, batch_start + actual_batch_size)
160159
]
161160

162-
# Predict batch of masks — list of (N, 1, H, W) arrays
163-
masks_batch, _scores_batch, _ = model.predict_batch(
164-
box_batch=boxes_batch,
165-
multimask_output=False,
166-
)
161+
# Only pass frames with at least one box to SAM2;
162+
# frames without boxes are left as background (0)
163+
# idcs_rel = indices within the batch
164+
idcs_rel_with_boxes = []
165+
for i, boxes in enumerate(boxes_batch):
166+
if len(boxes):
167+
idcs_rel_with_boxes.append(i)
168+
else:
169+
print(f"Frame {batch_start + i}: no boxes, skipped")
170+
171+
if idcs_rel_with_boxes:
172+
# Compute embeddings for images with boxes
173+
image_batch = [
174+
image_array[batch_start + i] for i in idcs_rel_with_boxes
175+
]
176+
model.set_image_batch(image_batch)
167177

168-
# Convert boolean masks to ID-encoded masks
169-
# (higher ID wins in overlap)
170-
for idx_rel_batch in range(actual_batch_size):
171-
masks_one_frame = masks_batch[idx_rel_batch].squeeze(
172-
axis=1
173-
) # (N, H, W)
178+
# List of (N_i, 4) arrays, one per image with boxes
179+
box_batch = [boxes_batch[i] for i in idcs_rel_with_boxes]
174180

175-
n_objects = masks_one_frame.shape[0]
176-
obj_ids = np.arange(1, n_objects + 1, dtype=np.int16)[
177-
:, None, None
178-
]
179-
id_mask_batch[idx_rel_batch] = (masks_one_frame * obj_ids).max(
180-
axis=0
181+
# Predict batch of masks — list of (N, 1, H, W) arrays,
182+
# it has one per mask image for each frame **with boxes**
183+
masks_batch, _scores_batch, _ = model.predict_batch(
184+
box_batch=box_batch,
185+
multimask_output=False,
181186
)
182187

183-
print(
184-
f"Frame {idx + idx_rel_batch}: "
185-
f"{n_objects} masks / "
186-
f"{boxes_batch[idx_rel_batch].shape[0]}"
187-
" boxes"
188-
)
188+
# Convert boolean masks to ID-encoded masks
189+
# (higher ID wins in overlap)
190+
# idcs_rel = indices within the batch
191+
for idx_rel_batch, masks in zip(
192+
idcs_rel_with_boxes,
193+
masks_batch,
194+
strict=True,
195+
):
196+
# SAM2 squeezes the box axis when N == 1, returning
197+
# (1, H, W) instead of (N, 1, H, W); we use reshape
198+
# to handle both
199+
masks_one_frame = masks.reshape(-1, img_h, img_w) # (N, H, W)
200+
n_objects = masks_one_frame.shape[0]
201+
obj_ids = np.arange(1, n_objects + 1, dtype=np.int16)[
202+
:, None, None
203+
]
204+
205+
# Add results to batch array (B,H,W)
206+
label_mask_batch[idx_rel_batch] = (
207+
masks_one_frame * obj_ids
208+
).max(axis=0)
209+
210+
# log
211+
print(
212+
f"Frame {batch_start + idx_rel_batch}: "
213+
f"{n_objects} masks / "
214+
f"{boxes_batch[idx_rel_batch].shape[0]}"
215+
" boxes"
216+
)
189217

190218
# Save id_mask to zarr store
191-
output_zarr[idx : idx + actual_batch_size] = id_mask_batch
219+
output_zarr[batch_start : batch_start + actual_batch_size] = (
220+
label_mask_batch
221+
)
192222

193223
# Mark frames as annotated in zarr store attributes
194224
annotated = set(output_zarr.attrs.get("annotated_frames", []))
195-
annotated.update(range(idx, idx + actual_batch_size))
225+
annotated.update(range(batch_start, batch_start + actual_batch_size))
196226
output_zarr.attrs["annotated_frames"] = sorted(annotated)
197227

198228

0 commit comments

Comments
 (0)