@@ -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
392434def 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
569651def 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