Skip to content

Commit be7e12d

Browse files
committed
Move ID-encoding to postprocessing. Apply cap after ID-encoding. Relabel final IDs of masks to avoid gaps. Add logs for masks per frames metrics
1 parent 51ffe5a commit be7e12d

1 file changed

Lines changed: 140 additions & 57 deletions

File tree

scripts/burrows/segment_burrows_sam3.py

Lines changed: 140 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def _initialise_mask_zarr(
130130
# Create a timestamped masks zarr store in the output directory
131131
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
132132
output_dir.mkdir(parents=True, exist_ok=True)
133-
output_masks_zarr = output_dir / f"masks_{timestamp}.zarr"
133+
output_masks_zarr = output_dir / f"masks_pass_1_{timestamp}.zarr"
134134

135135
n_images, image_h, image_w = image_shape
136136

@@ -315,17 +315,18 @@ def _postprocess_masks(
315315
316316
Regions are filtered based on area range and solidity.
317317
318-
``masks`` is (N, H, W) boolean. Returns ``(kept, list_kept_scores,
319-
drop_counts)`` where ``kept`` is a list of (H, W) boolean masks,
320-
``list_kept_scores`` carries the source object's score onto each kept mask,
318+
``masks`` is (N, H, W) boolean. Returns ``(id_encoded_mask, surviving_ids,
319+
surviving_scores, drop_counts)`` where ``id_encoded_mask`` is an
320+
ID-encoded mask, ``surviving_scores`` carries the source object's score
321+
onto each kept mask,
321322
and ``drop_counts`` is a dict counting how many connected regions were
322323
dropped per reason.
323324
324325
The score mapping is needed because the function splits masks into
325326
connected components (regions), so a single SAM3 object can yield several
326327
kept masks (or none), and each must inherit the right score.
327328
"""
328-
list_kept_bool_regions = []
329+
kept_regions_bool_masks = []
329330
list_mask_idcs = []
330331
drop_counts = {"area_low": 0, "area_high": 0, "solidity": 0}
331332

@@ -359,34 +360,75 @@ def _postprocess_masks(
359360
continue
360361

361362
# If all pass: retain that region within the mask
362-
list_kept_bool_regions.append(label_mask == region.label)
363+
kept_regions_bool_masks.append(label_mask == region.label)
363364
# Keep track of the mask ID associated to this region too
364365
list_mask_idcs.append(mask_idx)
365366

366367
# Get list of scores for kept regions
367-
list_kept_scores = scores[list_mask_idcs]
368+
kept_regions_scores = scores[list_mask_idcs]
368369

369-
return list_kept_bool_regions, list_kept_scores, drop_counts
370+
# -------------------------------
371+
# Compute id-encoded mask per region
372+
# boolean masks (N, H, W) -> ID-encoded (H, W); higher ID wins
373+
id_encoded_mask, list_surviving_ids = _convert_bool_to_id_mask(
374+
kept_regions_bool_masks, masks.shape[1:]
375+
)
376+
surviving_scores = kept_regions_scores[list_surviving_ids - 1]
377+
378+
# Log
379+
drop_counts["id_overlap"] = len(list_mask_idcs) - len(list_surviving_ids)
370380

381+
return id_encoded_mask, list_surviving_ids, surviving_scores, drop_counts
371382

372-
def _convert_bool_to_id_mask(list_kept_region_masks, img_h, img_w):
373-
"""Express boolean masks array as ID-encoded mask.
374383

375-
Higher ID wins on overlap.
384+
def _convert_bool_to_id_mask(list_kept_region_masks, img_h_w):
385+
"""Express list of boolean mask arrays as a single ID-encoded mask.
386+
387+
Higher ID wins on overlap. Returns the ID-encoded mask and the
388+
``surviving_ids``: the non-background IDs that still have at least one
389+
pixel in the final mask.
376390
"""
377391
# initialise id-encoded mask with all zeros
378-
id_mask = np.zeros((img_h, img_w), dtype=np.int16)
392+
id_encoded_mask = np.zeros(img_h_w, dtype=np.int16)
393+
394+
# Paint each region in, assigning IDs from 1 upward (0 = background)
395+
# Note that regions with higher ID will win in an overlap
396+
for mask_idx, bool_mask in enumerate(list_kept_region_masks, start=1):
397+
id_encoded_mask[bool_mask] = mask_idx
398+
399+
# Compute the final IDs that survived the "higher ID wins" overlap collapse
400+
surviving_ids = np.unique(id_encoded_mask)
401+
surviving_ids = surviving_ids[surviving_ids != 0]
402+
403+
return id_encoded_mask, surviving_ids
404+
379405

380-
# loop thru region IDs
381-
region_ids = np.arange(1, len(list_kept_region_masks) + 1, dtype=np.int16)
382-
for region_id, bool_mask in zip(
383-
region_ids,
384-
list_kept_region_masks,
385-
strict=True,
386-
):
387-
id_mask[bool_mask] = region_id
406+
def _relabel_id_encoded_mask_to_dense(
407+
id_encoded_mask,
408+
):
409+
"""Relabel old IDs -> dense 1..M.
410+
411+
This is so they fit the scores width and have no gaps.
412+
"""
413+
# get old mask ids
414+
old_mask_ids = np.asarray(
415+
[id for id in np.unique(id_encoded_mask) if id != 0]
416+
)
417+
n_old_mask_ids = len(old_mask_ids)
418+
419+
# map old (array index) to new (array value) IDs
420+
old_to_new_ids = np.zeros(id_encoded_mask.max() + 1, dtype=np.int16)
421+
old_to_new_ids[old_mask_ids] = np.arange(
422+
1, n_old_mask_ids + 1, dtype=np.int16
423+
)
388424

389-
return id_mask, region_ids
425+
# apply map to id_encoded_mask
426+
new_id_encoded_mask = old_to_new_ids[
427+
id_encoded_mask
428+
] # background (0) -> 0
429+
new_ids = np.arange(1, n_old_mask_ids + 1)
430+
431+
return new_id_encoded_mask, new_ids
390432

391433

392434
def main(args: argparse.Namespace) -> None:
@@ -463,11 +505,13 @@ def main(args: argparse.Namespace) -> None:
463505
# Run inference on every frame and write ID-encoded masks to zarr
464506
count_postproc_frames_empty = 0
465507
postproc_frames_w_masks = []
508+
# (frame_idx, n_masks) for every frame run through inference, including
509+
# empty ones (0 masks); excludes frames skipped for having no prompts.
510+
n_masks_per_frame = []
466511

467512
for frame_idx in range(len(image_array)):
468513
# Load image
469514
image = Image.fromarray(image_array[frame_idx])
470-
img_w, img_h = image.size
471515

472516
# Get point prompts normalised for the corresponding video
473517
video_str = list_video_per_img[frame_idx]
@@ -491,14 +535,20 @@ def main(args: argparse.Namespace) -> None:
491535
del inference_state
492536
torch.cuda.empty_cache()
493537

538+
# Log if no detections
494539
if n_objects == 0:
495540
print(f"Frame {frame_idx} ({video_str}): no detections")
541+
n_masks_per_frame.append((frame_idx, 0))
496542
continue
497543

498-
# Split masks into "regions" and postprocess
544+
# Postprocess SAM3-predicted masks:
545+
# - Split masks into "regions",
546+
# - Filter out masks whose area is out of bounds,
547+
# - Flatten overlaps via ID-encoding
499548
(
500-
list_kept_region_masks,
501-
list_kept_scores,
549+
id_encoded_mask,
550+
surviving_ids,
551+
surviving_scores,
502552
drop_counts,
503553
) = _postprocess_masks(
504554
masks,
@@ -508,62 +558,94 @@ def main(args: argparse.Namespace) -> None:
508558
args.min_solidity,
509559
)
510560

561+
# -------------------------
511562
# Log postprocessing results for this frame
512-
if not list_kept_region_masks:
563+
n_surviving_regions = len(surviving_ids)
564+
if n_surviving_regions == 0:
513565
print(
514566
f"Frame {frame_idx} ({video_str}): "
515567
"no masks after postprocessing"
516568
)
517569
count_postproc_frames_empty += 1
570+
n_masks_per_frame.append((frame_idx, 0))
518571
continue
519572

520-
n_kept_regions = len(list_kept_region_masks)
521-
n_total_regions = n_kept_regions + sum(drop_counts.values())
573+
n_total_regions = n_surviving_regions + sum(drop_counts.values())
522574
print(
523575
f"Frame {frame_idx} ({video_str}): postprocessing kept "
524-
f"{n_kept_regions}/{n_total_regions} regions, "
525-
f"dropped {drop_counts}"
576+
f"{n_surviving_regions}/{n_total_regions} regions, "
577+
f"dropped {drop_counts} (all before capping)."
526578
)
579+
# -------------------------
527580

528-
# Clip to the per-frame region cap before encoding: region IDs
529-
# index into the scores array, whose width is set by the cap, so
530-
# any region beyond it would overflow the store. Keep the first
531-
# ``max_regions_per_image`` regions (highest-ID regions win on
532-
# overlap, so this drops the lowest IDs first).
533-
if n_kept_regions > args.max_regions_per_image:
534-
print(
535-
f"Frame {frame_idx}: {n_kept_regions} regions exceeds cap "
536-
f"({args.max_regions_per_image}), clipping"
537-
)
538-
list_kept_region_masks = list_kept_region_masks[
581+
# Enforce the per-frame cap on max number of regions,
582+
if n_surviving_regions > args.max_regions_per_image:
583+
# we select the top M scoring ones
584+
capped_sorted_idcs = np.argsort(surviving_scores)[::-1][
539585
: args.max_regions_per_image
540586
]
541-
list_kept_scores = list_kept_scores[
542-
: args.max_regions_per_image
543-
]
544-
545-
# Compute id-encoded mask per region
546-
# boolean masks (N, H, W) -> ID-encoded (H, W); higher ID wins
547-
id_mask, region_ids = _convert_bool_to_id_mask(
548-
list_kept_region_masks, img_h, img_w
587+
# Get corresponding "M" IDs and scores in score-order!
588+
selected_ids = surviving_ids[capped_sorted_idcs]
589+
selected_scores = surviving_scores[capped_sorted_idcs]
590+
591+
# Drop non-selected IDs from the mask
592+
id_encoded_mask[~np.isin(id_encoded_mask, selected_ids)] = 0
593+
594+
# Re-sort the scores into ascending ID order from the selected
595+
# IDs; this is required for the relabel step
596+
order = np.argsort(selected_ids)
597+
surviving_scores = selected_scores[order]
598+
599+
# ----------------
600+
# Relabel old IDs -> dense 1..M so zarr array has no gaps
601+
new_id_encoded_mask, new_ids = _relabel_id_encoded_mask_to_dense(
602+
id_encoded_mask
549603
)
550604

551605
# Save results to zarr
552-
root["masks"][frame_idx] = id_mask
553-
root["scores"][frame_idx, region_ids] = list_kept_scores
606+
root["masks"][frame_idx] = new_id_encoded_mask
607+
root["scores"][frame_idx, new_ids] = surviving_scores
608+
609+
# -------------------------
610+
# Log final number of masks
611+
n_masks_saved = len(new_ids)
612+
n_masks_per_frame.append((frame_idx, n_masks_saved))
613+
print(f"Frame {frame_idx} ({video_str}): {n_masks_saved} masks")
554614

555-
# Log frames with masks that survived
556615
postproc_frames_w_masks.append(frame_idx)
557-
print(f"Frame {frame_idx} ({video_str}): {n_kept_regions} masks")
558616

559-
# Log frames with final masks
617+
# Add extra metrics to zarr array
560618
root.attrs["frames_with_masks"] = postproc_frames_w_masks
561-
619+
root.attrs["n_masks_per_frame"] = n_masks_per_frame
562620
print(f"Saved ID-encoded mask zarr to {output_masks_zarr}")
621+
622+
# ----------------------------
623+
# Summarise masks-per-frame statistics over every frame run through
624+
# inference, including empty frames (0 masks).
563625
print(
564-
f"Frames with no masks after postprocessing: "
626+
f"N frames with no masks after postprocessing: "
565627
f"{count_postproc_frames_empty}"
566628
)
629+
if n_masks_per_frame:
630+
counts = np.array([n_masks for _, n_masks in n_masks_per_frame])
631+
mean_masks = counts.mean()
632+
print(
633+
f"Stats for masks per frame (n={len(counts)} frames)"
634+
f"mean={mean_masks:.2f}, "
635+
f"median={np.median(counts):.1f}, "
636+
f"min={counts.min()}, max={counts.max()}"
637+
)
638+
639+
# Frame indices with fewer than the mean number of masks per frame
640+
frames_below_mean = [
641+
frame_idx
642+
for frame_idx, n_masks in n_masks_per_frame
643+
if n_masks < mean_masks
644+
]
645+
print(
646+
f"Frames with fewer than mean ({mean_masks:.2f}) masks per frame "
647+
f"({len(frames_below_mean)} frames): {frames_below_mean}"
648+
)
567649

568650

569651
def parse_args(list_args: list[str]) -> argparse.Namespace:
@@ -592,7 +674,8 @@ def parse_args(list_args: list[str]) -> argparse.Namespace:
592674
"output_dir",
593675
type=Path,
594676
help=(
595-
"Output directory. A timestamped 'masks_<YYYYMMDD_HHMMSS>.zarr' "
677+
"Output directory. A timestamped "
678+
"'masks_pass_1_<YYYYMMDD_HHMMSS>.zarr' "
596679
"store is created inside it, so multiple runs never collide."
597680
),
598681
)

0 commit comments

Comments
 (0)