nelikCode's picture
Make fast mode about twice as fast
32fb829 verified
Raw History Blame Contribute Delete
11.6 kB
import queue
import shutil
import subprocess
import sys
import threading
import cv2
import numpy as np
import torch
from transformers import (
AutoConfig,
AutoImageProcessor,
AutoModel,
AutoModelForObjectDetection,
Sam2VideoProcessor,
)
TRACKER = "yonigozlan/EdgeTAM-hf"
DETECTOR = "PekingU/rtdetr_v2_r18vd"
SIZE = 512 # must be a multiple of 256
LOST_AFTER, RETRY_EVERY = 3, 5 # frames
class Cancelled(Exception):
pass
def get_device():
if torch.cuda.is_available():
return "cuda"
if torch.backends.mps.is_available():
return "mps"
return "cpu"
def hex_to_rgb(color):
color = color.lstrip("#")
return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))
def first_frame(video):
cap = cv2.VideoCapture(video)
ok, bgr = cap.read()
cap.release()
if not ok:
raise ValueError(f"Cannot read video: {video}")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
def rank_subjects(boxes, scores, width, height):
"""Order detections by importance: confidence x relative size x closeness to the center."""
boxes, scores = np.asarray(boxes, dtype=float).reshape(-1, 4), np.asarray(
scores, dtype=float
)
area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) / (width * height)
cx = (boxes[:, 0] + boxes[:, 2]) / 2 / width
cy = (boxes[:, 1] + boxes[:, 3]) / 2 / height
centrality = 1 - np.hypot(cx - 0.5, cy - 0.5) / np.hypot(0.5, 0.5)
return np.argsort(-(scores * np.sqrt(area) * centrality)).tolist()
def box_iou(a, b):
w = max(0, min(a[2], b[2]) - max(a[0], b[0]))
h = max(0, min(a[3], b[3]) - max(a[1], b[1]))
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - w * h
return w * h / union if union > 0 else 0.0
def box_distance(b, box):
"""Center offset in units of `box`'s diagonal, plus the log size ratio."""
dx = (b[0] + b[2] - box[0] - box[2]) / 2
dy = (b[1] + b[3] - box[1] - box[3]) / 2
size = np.log(
((b[2] - b[0]) * (b[3] - b[1]) + 1)
/ ((box[2] - box[0]) * (box[3] - box[1]) + 1)
)
return np.hypot(dx, dy) / np.hypot(box[2] - box[0], box[3] - box[1]) + abs(size)
def closest(subjects, box):
return min(subjects, key=lambda s: box_distance(s["box"], box))
def mask_box(alpha):
ys, xs = np.nonzero(alpha > 127)
return (
None
if len(xs) == 0
else [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
)
def ffmpeg_exe():
if path := shutil.which("ffmpeg"):
return path
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def video_codec(width, height, fps):
bitrate = str(max(2_000_000, int(width * height * fps * 0.15)))
if sys.platform == "darwin":
return ["-c:v", "h264_videotoolbox", "-b:v", bitrate]
if torch.cuda.is_available():
return ["-c:v", "h264_nvenc", "-b:v", bitrate]
return ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18"]
class Segmenter:
def __init__(self, device=None):
self.device = device or get_device()
self.dtype = torch.float32 if self.device == "cpu" else torch.float16
config = AutoConfig.from_pretrained(TRACKER)
s = SIZE // 16
config.image_size = config.prompt_encoder_config.image_size = SIZE
config.vision_config.backbone_feature_sizes = [
[4 * s, 4 * s],
[2 * s, 2 * s],
[s, s],
]
config.memory_attention_rope_feat_sizes = [s, s]
self.tracker = (
AutoModel.from_pretrained(TRACKER, config=config)
.to(self.device, self.dtype)
.eval()
)
self.processor = Sam2VideoProcessor.from_pretrained(TRACKER)
self.processor.image_processor.size = {"height": SIZE, "width": SIZE}
self.processor.video_processor.size = {"height": SIZE, "width": SIZE}
self.processor.target_size = SIZE
self.detector_processor = AutoImageProcessor.from_pretrained(DETECTOR)
# on CPU: it runs once per video, and MPS is not safe to share across threads
self.detector = AutoModelForObjectDetection.from_pretrained(DETECTOR).eval()
@torch.inference_mode()
def detect(self, frame, threshold=0.5):
"""Return the subjects found in an RGB frame, most important first."""
height, width = frame.shape[:2]
inputs = self.detector_processor(images=frame, return_tensors="pt")
result = self.detector_processor.post_process_object_detection(
self.detector(**inputs), threshold=threshold, target_sizes=[(height, width)]
)[0]
boxes, scores = result["boxes"].numpy(), result["scores"].numpy()
labels = [self.detector.config.id2label[int(i)] for i in result["labels"]]
return [
{
"box": [round(float(v)) for v in boxes[i]],
"label": labels[i],
"score": round(float(scores[i]), 3),
}
for i in rank_subjects(boxes, scores, width, height)
]
@torch.inference_mode()
def segment(
self,
video,
output,
box=None,
color="#009000",
fast=False,
progress=None,
cancel=None,
):
"""Track the subject inside `box` (x0, y0, x1, y1 on the first frame) and write it on `color`."""
subjects = self.detect(first_frame(video))
if box is None:
if not subjects:
raise ValueError(
"No subject found on the first frame. Give a bounding box."
)
box = subjects[0]["box"]
# when the subject is lost, look for it again, but only if the detector knows what it is
label = next(
(s["label"] for s in subjects if box_iou(s["box"], box) > 0.5), None
)
cap = cv2.VideoCapture(video)
fps = cap.get(cv2.CAP_PROP_FPS) or 25
width, height = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(
cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
background = np.array(hex_to_rgb(color), dtype=np.uint16)
ffmpeg = subprocess.Popen(
[
ffmpeg_exe(),
*f"-y -loglevel error -f rawvideo -pix_fmt rgb24 -s {width}x{height}".split(),
*f"-r {fps} -i -".split(),
*["-i", video, "-map", "0:v", "-map", "1:a:0?", "-shortest"],
*["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"],
*video_codec(width, height, fps),
*"-pix_fmt yuv420p -c:a aac -movflags +faststart".split(),
output,
],
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
)
frames, masks, errors, stop = (
queue.Queue(8),
queue.Queue(8),
[],
threading.Event(),
)
def read():
try:
while not stop.is_set():
ok, bgr = cap.read()
if not ok:
break
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
frames.put((rgb, self.processor(images=rgb, return_tensors="pt")))
except Exception as e:
errors.append(e)
finally:
frames.put(None)
def write():
while (item := masks.get()) is not None:
if errors:
continue
rgb, alpha = item
try:
out = (rgb * alpha + background * (255 - alpha)) // 255
ffmpeg.stdin.write(out.astype(np.uint8).tobytes())
except Exception as e:
errors.append(e)
reader, writer = threading.Thread(target=read), threading.Thread(target=write)
reader.start()
writer.start()
# fast: shorter memory, and the model skips every other frame while it tracks
self.tracker.num_maskmem = 3 if fast else 7
stride = 2 if fast else 1
session = self.processor.init_video_session(
inference_device=self.device, dtype=self.dtype
)
keep = max(
self.tracker.num_maskmem, self.tracker.config.max_object_pointers_in_encoder
)
done, item, lost, last_box, t = 0, True, 0, box, 0
try:
while not errors and (item := frames.get()) is not None:
if cancel is not None and cancel.is_set():
raise Cancelled()
rgb, inputs = item
if done % stride and not lost:
masks.put((rgb, alpha))
done += 1
if progress:
progress(done, max(total, done))
continue
prompt = box if done == 0 else None
if (
label
and lost >= LOST_AFTER
and (lost - LOST_AFTER) % RETRY_EVERY == 0
):
# the detector often renames an object seen from a new angle,
# so a different label is fine if it is where the subject was
found = self.detect(rgb, 0.3)
same = [s for s in found if s["label"] == label]
near = [s for s in found if box_distance(s["box"], last_box) < 1]
if same or near:
prompt = closest(same or near, last_box)["box"]
if prompt:
self.processor.add_inputs_to_inference_session(
inference_session=session,
frame_idx=t,
obj_ids=1,
input_boxes=[[[float(v) for v in prompt]]],
original_size=inputs.original_sizes[0],
)
result = self.tracker(
inference_session=session,
frame_idx=t,
frame=inputs.pixel_values[0].to(self.device, self.dtype),
)
session.processed_frames.pop(t - 1, None)
for outputs in session.output_dict_per_obj.values():
outputs["non_cond_frame_outputs"].pop(t - keep, None)
t += 1
logits = self.processor.post_process_masks(
[result.pred_masks],
original_sizes=inputs.original_sizes,
binarize=False,
)[0][0, 0]
alpha = (
(torch.sigmoid(logits.float()) * 255).to(torch.uint8).cpu().numpy()
)
found_box = mask_box(alpha)
alpha = alpha[..., None].astype(np.uint16)
masks.put((rgb, alpha))
lost, last_box = (0, found_box) if found_box else (lost + 1, last_box)
done += 1
if progress:
progress(done, max(total, done))
finally:
stop.set()
while item is not None:
item = frames.get()
masks.put(None)
reader.join()
writer.join()
cap.release()
ffmpeg.stdin.close()
stderr = ffmpeg.stderr.read().decode()
ffmpeg.wait()
if errors:
raise errors[0]
if ffmpeg.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr}")
return box