fffiloni's picture
Migrated files batch 1
09462dc verified
Raw
History Blame Contribute Delete
14.1 kB
import os
from random import shuffle
import cv2
import numpy as np
from decord import VideoReader
# Minimum mask ratio threshold (percentage of frame). Override via env var
# SCAIL_MIN_MASK_RATIO for small-subject scenes (e.g. paper figures where the
# subject occupies <1% of the frame) without editing this file.
MIN_MASK_RATIO = float(os.environ.get('SCAIL_MIN_MASK_RATIO', '1.0'))
# Default cap on number of targets when caller does not override
DEFAULT_MAX_TARGETS = 4
# Deterministic BGR palette used when callers want stable colors across runs.
DEFAULT_PALETTE_BGR = [
(255, 0, 0), # Blue
(0, 0, 255), # Red
(0, 255, 0), # Green
(255, 0, 255), # Magenta
(255, 255, 0), # Cyan
(0, 255, 255), # Yellow
]
def remove_small_tracks_from_predictor(predictor, invalid_track_ids):
"""Remove invalid track IDs from predictor's internal tracker state."""
if not invalid_track_ids:
return
metadata = predictor.inference_state.get("tracker_metadata", {})
if not metadata:
return
obj_ids = metadata.get("obj_ids_all_gpu", np.array([]))
if len(obj_ids) == 0:
return
keep_mask = np.array([int(oid) not in invalid_track_ids for oid in obj_ids])
metadata["obj_ids_all_gpu"] = obj_ids[keep_mask]
arrays_to_filter = [
"obj_id_to_score", "obj_id_to_cls", "obj_id_to_tracker_score"
]
for key in arrays_to_filter:
if key in metadata and isinstance(metadata[key], dict):
metadata[key] = {k: v for k, v in metadata[key].items() if int(k) not in invalid_track_ids}
tracker_states = predictor.inference_state.get("tracker_inference_states", [])
if tracker_states:
for state in tracker_states:
if hasattr(state, 'obj_ids') and state.obj_ids is not None:
state_keep = np.array([int(oid) not in invalid_track_ids for oid in state.obj_ids])
state.obj_ids = state.obj_ids[state_keep]
print(f"Removed track IDs {invalid_track_ids} from tracker state")
def visualize_and_save_mask(results, width, height, predictor, new_indices, full_length,
max_targets=DEFAULT_MAX_TARGETS, shuffle_colors=True,
direct_return=False):
"""Run through SAM3 streaming results and gather per-track binary masks.
Returns (valid_track_ids_ordered, mask_arrays, track_colors) ordered by descending
mask area in the first frame; or None if no valid track is detected.
"""
colors = list(DEFAULT_PALETTE_BGR)
if shuffle_colors:
shuffle(colors)
frame_idx = 0
valid_track_ids = None
total_pixels = height * width
valid_track_ids_ordered = []
mask_arrays = {}
track_colors = {}
_color_counter = 0
for result_idx, result in enumerate(results):
index_result = new_indices[result_idx]
if result.masks is not None:
masks = result.masks.data.cpu().numpy() # (N, H, W)
track_ids = result.boxes.id.cpu().numpy() if result.boxes.id is not None else np.arange(len(masks))
if frame_idx == 0:
valid_track_ids = set()
invalid_track_ids = set()
candidates = []
for i, (mask, track_id) in enumerate(zip(masks, track_ids)):
if mask.shape[:2] != (height, width):
mask_resized = cv2.resize(mask.astype(np.float32), (width, height))
else:
mask_resized = mask
mask_bool = mask_resized > 0.5
mask_ratio = np.sum(mask_bool) / total_pixels * 100
if mask_ratio >= MIN_MASK_RATIO:
candidates.append((int(track_id), mask_ratio))
else:
invalid_track_ids.add(int(track_id))
candidates.sort(key=lambda x: x[1], reverse=True)
if len(candidates) == 0 and direct_return:
print(f" No valid candidates (all < MIN_MASK_RATIO={MIN_MASK_RATIO}%) in first frame, return")
return
if len(candidates) > max_targets:
if direct_return:
print(f" Found {len(candidates)} candidates, return")
return
print(f" Found {len(candidates)} candidates, limiting to top {max_targets}")
kept_candidates = candidates[:max_targets]
dropped_candidates = candidates[max_targets:]
for track_id, _ in kept_candidates:
valid_track_ids.add(track_id)
for track_id, _ in dropped_candidates:
invalid_track_ids.add(track_id)
else:
kept_candidates = candidates
for track_id, _ in candidates:
valid_track_ids.add(track_id)
if kept_candidates and direct_return:
max_ratio = kept_candidates[0][1]
if max_ratio < 1.5 or max_ratio > 50:
print(f" Max mask ratio {max_ratio:.2f}% out of valid range [1.5, 50], return")
return
if len(kept_candidates) >= 2 and direct_return:
max_ratio = kept_candidates[0][1]
min_ratio = kept_candidates[-1][1]
if min_ratio < max_ratio / 3:
print(f" Smallest person ({min_ratio:.2f}%) < 1/3 of largest ({max_ratio:.2f}%), return")
return
valid_track_ids_ordered = [tid for tid, _ in kept_candidates]
mask_arrays = {tid: np.zeros((full_length, height, width), dtype=bool)
for tid in valid_track_ids_ordered}
if invalid_track_ids:
remove_small_tracks_from_predictor(predictor, invalid_track_ids)
for i, (mask, track_id) in enumerate(zip(masks, track_ids)):
if valid_track_ids is not None and int(track_id) not in valid_track_ids:
continue
tid = int(track_id)
if tid not in track_colors:
track_colors[tid] = colors[_color_counter % len(colors)]
_color_counter += 1
if mask.shape[:2] != (height, width):
mask = cv2.resize(mask.astype(np.float32), (width, height))
mask_bool = mask > 0.5
if tid in mask_arrays:
mask_arrays[tid][index_result] = mask_bool
frame_idx += 1
if not valid_track_ids_ordered:
return None
return valid_track_ids_ordered, mask_arrays, track_colors
def _centroid_x(mask_2d):
"""X-coordinate of the centroid of a 2D bool mask. Returns +inf if mask is empty."""
cols = np.where(mask_2d.any(axis=0))[0]
if len(cols) == 0:
return float('inf')
rows = np.where(mask_2d.any(axis=1))[0]
# use bounding-box center (cheap and stable)
return 0.5 * (cols[0] + cols[-1])
def _reorder_and_color(valid_track_ids_ordered, mask_arrays, sort_by, fixed_colors):
"""Apply left-to-right sort and deterministic color assignment.
Returns (masks, colors) where masks is a list of (T, H, W) bool ndarray and
colors is a list of BGR tuples, both in the chosen ordering.
"""
if sort_by == 'x':
ordered = sorted(valid_track_ids_ordered,
key=lambda tid: _centroid_x(mask_arrays[tid][0]))
elif sort_by == 'area':
ordered = list(valid_track_ids_ordered)
else:
raise ValueError(f"unknown sort_by: {sort_by}")
n = len(ordered)
if fixed_colors is not None:
if len(fixed_colors) < n:
raise ValueError(f"fixed_colors has {len(fixed_colors)} entries but {n} tracks")
colors = [tuple(c) for c in fixed_colors[:n]]
else:
colors = [DEFAULT_PALETTE_BGR[i % len(DEFAULT_PALETTE_BGR)] for i in range(n)]
masks = [mask_arrays[tid] for tid in ordered]
return masks, colors
def get_mask_from_video(video_path, predictor, max_targets=DEFAULT_MAX_TARGETS,
sort_by='area', fixed_colors=None,
text=("human", "character")):
"""Run SAM3 tracking on a video file and return per-person binary masks and colors.
Args:
video_path: path to input video (str or Path).
predictor: SAM3VideoSemanticPredictor instance (state will be reset).
max_targets: cap on the number of tracked persons (kept by descending area).
sort_by: 'area' (default, descending area) or 'x' (left-to-right by first-frame
centroid x).
fixed_colors: optional list of BGR tuples assigned to ordered tracks instead of
the default palette. Must have at least len(tracks) entries.
Returns:
masks: list of (T, H, W) bool ndarray, one per tracked person.
colors: list of BGR color tuples corresponding to each person.
Both lists are empty if no valid persons are detected.
"""
video_path = str(video_path)
predictor.inference_state = {}
if hasattr(predictor, 'dataset'):
predictor.dataset = None
vr = VideoReader(video_path)
full_length = len(vr)
height, width = vr[0].asnumpy().shape[:2]
del vr
results = predictor(source=video_path, text=list(text), stream=True)
ret = visualize_and_save_mask(
results, width, height, predictor,
new_indices=np.arange(full_length), full_length=full_length,
max_targets=max_targets, shuffle_colors=fixed_colors is None,
direct_return=False,
)
if ret is None:
return [], []
valid_track_ids_ordered, mask_arrays, _ = ret
return _reorder_and_color(valid_track_ids_ordered, mask_arrays, sort_by, fixed_colors)
def get_mask_from_image_via_video(image_path, video_predictor, max_targets=DEFAULT_MAX_TARGETS,
sort_by='x', fixed_colors=None,
text=("human", "character"), n_repeat=4, fps=8):
"""Detect persons in a still image by wrapping it as a tiny mp4 and routing through
SAM3VideoSemanticPredictor. Workaround for image-mode SAM3 missing small / distant
subjects that the video pipeline picks up reliably.
Returns (masks, colors) with each mask shaped (1, H, W) bool — only the first frame
of the synthetic clip is kept.
"""
import tempfile
from NLFPoseExtract.v2_helper import imread_bgr
image_path = str(image_path)
img = imread_bgr(image_path)
H, W = img.shape[:2]
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.mp4')
os.close(tmp_fd)
try:
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
vw = cv2.VideoWriter(tmp_path, fourcc, float(fps), (W, H))
if not vw.isOpened():
raise RuntimeError(f"cv2.VideoWriter failed to open {tmp_path}")
for _ in range(n_repeat):
vw.write(img)
vw.release()
masks, colors = get_mask_from_video(
tmp_path, video_predictor,
max_targets=max_targets, sort_by=sort_by,
fixed_colors=fixed_colors, text=text,
)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
masks = [m[:1] for m in masks]
return masks, colors
def get_mask_from_image(image_path, predictor, max_targets=DEFAULT_MAX_TARGETS,
sort_by='x', fixed_colors=None,
text=("human", "character")):
"""Run SAM3SemanticPredictor (image variant) on a single image.
Args:
image_path: path to input image (str or Path).
predictor: SAM3SemanticPredictor instance.
max_targets: cap on number of persons (kept by descending area).
sort_by: 'x' (default, left-to-right) or 'area'.
fixed_colors: optional list of BGR tuples assigned in order; otherwise the
deterministic palette is used.
Returns:
masks: list of (1, H, W) bool ndarray, one per detected person.
colors: list of BGR color tuples corresponding to each person.
"""
image_path = str(image_path)
results = predictor(source=image_path, text=list(text))
if not results:
return [], []
result = results[0]
if result.masks is None or len(result.masks) == 0:
return [], []
masks_NHW = result.masks.data.cpu().numpy() # (N, H, W)
masks_NHW = masks_NHW > 0.5
return _filter_image_masks(masks_NHW, max_targets, sort_by, fixed_colors)
def _filter_image_masks(masks_NHW, max_targets, sort_by, fixed_colors):
"""Apply MIN_MASK_RATIO + max_targets filter to per-image SAM masks, then order
and color them. Returns (masks_list, colors) where each mask is (1, H, W) bool.
"""
N, H, W = masks_NHW.shape
total_pixels = H * W
candidates = [] # (idx, mask_ratio)
for i in range(N):
ratio = float(np.sum(masks_NHW[i])) / total_pixels * 100
if ratio >= MIN_MASK_RATIO:
candidates.append((i, ratio))
candidates.sort(key=lambda x: x[1], reverse=True)
candidates = candidates[:max_targets]
if not candidates:
return [], []
kept = [masks_NHW[idx] for idx, _ in candidates] # list of (H, W) bool
if sort_by == 'x':
order = sorted(range(len(kept)), key=lambda i: _centroid_x(kept[i]))
kept = [kept[i] for i in order]
elif sort_by != 'area':
raise ValueError(f"unknown sort_by: {sort_by}")
n = len(kept)
if fixed_colors is not None:
if len(fixed_colors) < n:
raise ValueError(f"fixed_colors has {len(fixed_colors)} entries but {n} masks")
colors = [tuple(c) for c in fixed_colors[:n]]
else:
colors = [DEFAULT_PALETTE_BGR[i % len(DEFAULT_PALETTE_BGR)] for i in range(n)]
masks_out = [m[None] for m in kept] # add T=1 axis
return masks_out, colors