Files
2026-05-11 18:08:23 +08:00

618 lines
23 KiB
Python

'''
This file is to inference arbitrary video files for Shot Cut
'''
import os, sys, shutil
import argparse
import numpy as np
import math
import subprocess
import cv2
import copy
import ffmpeg
import json
import torch
import torchvision.transforms as T
from torch.utils.data import DataLoader
from argparse import Namespace
from pathlib import Path
from PIL import Image
import warnings
warnings.filterwarnings("ignore", category=UserWarning)
# Import files from the local folder
# 使用 __file__ 定位插件根目录,而不是用 '.' (ComfyUI 启动目录)
root_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 确保插件根目录始终位于 sys.path 最前面,
# 防止其他路径中存在同名的 config.py 单文件与插件的 config 包冲突
if root_path in sys.path:
sys.path.remove(root_path)
sys.path.insert(0, root_path)
# 清理可能被错误缓存的同名系统包(config、datasets 等),强制从插件根目录重新加载
for _mod_name in list(sys.modules.keys()):
if (
_mod_name == "config" or _mod_name.startswith("config.") or
_mod_name == "datasets" or _mod_name.startswith("datasets.")
):
del sys.modules[_mod_name]
# 插件内部依赖全部延迟到函数调用时再导入,避免 ComfyUI 启动阶段因
# 环境缺包/路径未就绪而抛出异常,进而导致 NODE_CLASS_MAPPINGS 未定义。
_omni_imports_done = False
def _ensure_imports():
global _omni_imports_done
global get_args_parser, build_backbone, build_transformer, OmniShotCut
global Video_Augmentation_Transform, visualize_concated_frames
global unique_intra_label_mapping, unique_inter_label_mapping
global intra_int2string, inter_int2string
global video_transform
if _omni_imports_done:
return
from config.argument_setting import get_args_parser
from architecture.backbone import build_backbone
from architecture.transformer import build_transformer
from architecture.model import OmniShotCut
from datasets.transforms import Video_Augmentation_Transform
from util.visualization import visualize_concated_frames
from config.label_correspondence import (
unique_intra_label_mapping, unique_inter_label_mapping,
intra_int2string, inter_int2string,
)
video_transform = Video_Augmentation_Transform(set_type="val")
_omni_imports_done = True
def load_model(checkpoint_path: str):
_ensure_imports()
# 1. 检查路径
checkpoint_path = os.path.abspath(checkpoint_path)
if not os.path.exists(checkpoint_path):
raise FileNotFoundError(f"Checkpoint not found: {checkpoint_path}")
# 2. 加载 state_dict (修复 PyTorch 2.6 安全报错)
try:
# 显式设置 weights_only=False 以允许加载 argparse.Namespace 等对象
state_dict = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
except TypeError:
# 兼容不支持 weights_only 参数的旧版本 PyTorch
state_dict = torch.load(checkpoint_path, map_location="cpu")
if "args" not in state_dict or "model" not in state_dict:
raise ValueError("Checkpoint must contain keys: 'args' and 'model'.")
# 3. 初始化模型结构
model_args = state_dict["args"]
backbone = build_backbone(model_args)
transformer = build_transformer(model_args)
model = OmniShotCut(
backbone,
transformer,
num_intra_relation_classes = model_args.num_intra_relation_classes,
num_inter_relation_classes = model_args.num_inter_relation_classes,
num_frames = model_args.max_process_window_length,
num_queries = model_args.num_queries,
aux_loss = model_args.aux_loss,
)
# 4. 加载权重(保留在 CPU,由调用者负责 .to(device),避免重复移动导致显存OOM)
model.load_state_dict(state_dict["model"], strict=True)
model.eval()
return model, model_args
def get_video_fps_safe(video_path: str, default_fps: float = 24.0) -> float:
try:
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
cap.release()
if fps is None or fps <= 1e-6 or math.isnan(fps):
return default_fps
return float(fps)
except Exception:
return default_fps
def split_videos(video, chunk_size, num_context_frames):
assert video.ndim == 4, "video must be (T, H, W, C)"
total_num_frames, H, W, C = video.shape
# Padding at the beginning
black = np.zeros((num_context_frames, H, W, C), dtype=video.dtype)
video = np.concatenate([black, video], axis=0)
# Split Video to clips
stride = chunk_size - 2 * num_context_frames
cur_frame_idx = 0
return_list = []
while cur_frame_idx < total_num_frames:
# Fetch the range
cropped_videos = video[cur_frame_idx : cur_frame_idx + chunk_size]
# Add padding if needed
clip_num_adding_frames = chunk_size - len(cropped_videos)
if clip_num_adding_frames > 0:
black = np.zeros((clip_num_adding_frames, H, W, C), dtype=video.dtype)
cropped_videos = np.concatenate([cropped_videos, black], axis=0)
# Append all return info: (video_np, clip padding frames, global start frame idx)
return_list.append([cropped_videos, clip_num_adding_frames])
# Update
cur_frame_idx += stride
return return_list
def prune_non_context_ranges(pred_ranges, pred_intra_labels, pred_inter_labels, inference_window_size, num_context_frames):
# Init
new_pred_ranges, new_pred_intra_labels, new_pred_inter_labels = [], [], []
# Iterate
for shot_idx in range(len(pred_ranges)):
# Fetch
start_frame_idx, end_frame_idx = pred_ranges[shot_idx]
# Check if we should skip
if end_frame_idx <= num_context_frames: # Beginning
continue
if start_frame_idx >= inference_window_size - num_context_frames: # Ending
break
# Re align start & end
aligned_start_frame_idx = max(start_frame_idx, num_context_frames) - num_context_frames
aligned_end_frame_idx = min(end_frame_idx, inference_window_size - num_context_frames) - num_context_frames # exclusive on the right range
# Append
new_pred_ranges.append([aligned_start_frame_idx, aligned_end_frame_idx])
new_pred_intra_labels.append(pred_intra_labels[shot_idx])
new_pred_inter_labels.append(pred_inter_labels[shot_idx])
return new_pred_ranges, new_pred_intra_labels, new_pred_inter_labels
def merge_ranges(pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full, pred_ranges, pred_intra_labels, pred_inter_labels):
# Prepare
last_frame_idx = pred_ranges_full[-1][-1] if len(pred_intra_labels_full) != 0 else 0
# Merge last one of the list list
if len(pred_intra_labels_full) != 0 and pred_intra_labels_full[-1] == pred_intra_labels[0] and pred_inter_labels[0] == unique_inter_label_mapping['new_start']:
pred_ranges_full[-1][-1] = last_frame_idx + pred_ranges[0][-1]
# Crop the first one
pred_ranges = pred_ranges[1:]
pred_intra_labels = pred_intra_labels[1:]
pred_inter_labels = pred_inter_labels[1:]
# Extend the following list
for idx in range(len(pred_ranges)):
start_frame_idx, end_frame_idx = pred_ranges[idx]
pred_ranges_full.append([last_frame_idx + start_frame_idx, last_frame_idx + end_frame_idx])
pred_intra_labels_full.append(pred_intra_labels[idx])
pred_inter_labels_full.append(pred_inter_labels[idx])
return pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full
def _get_ffmpeg_major_version() -> int:
"""获取系统 FFmpeg 主版本号,失败时返回 0。"""
try:
result = subprocess.run(
["ffmpeg", "-version"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
first_line = result.stdout.decode("utf-8", errors="ignore").splitlines()[0]
# 格式: "ffmpeg version 4.4.2-..." 或 "ffmpeg version 6.0-..."
ver_str = first_line.split("version")[1].strip().split()[0] # e.g. "4.4.2-0ubuntu..."
major = int(ver_str.split(".")[0])
return major
except Exception:
return 0
_FFMPEG_MAJOR = _get_ffmpeg_major_version()
def single_video_inference(video_path, model, model_args, num_context_frames, device=None):
_ensure_imports()
# 1. 强制转换为绝对路径,并且不要手动加引号(ffmpeg-python 会处理)
video_path = os.path.abspath(video_path)
# 自动推断设备:优先使用传入的 device,其次跟随模型参数所在设备,最后回退 cpu
if device is None:
try:
device = next(model.parameters()).device
except StopIteration:
device = torch.device("cpu")
# 调试打印,方便你在控制台看完整路径
print(f"## [OmniShotCut] FFmpeg 正在打开: {video_path} (FFmpeg major={_FFMPEG_MAJOR})")
process_height, process_width = model_args.process_height, model_args.process_width
max_process_window_length = model_args.max_process_window_length
# 2. 调用 FFmpeg
# fps_mode 是 FFmpeg 5.0+ 的参数;4.x 及以下用 vsync
try:
output_kwargs = dict(
format="rawvideo",
pix_fmt="rgb24",
s=f"{process_width}x{process_height}",
)
if _FFMPEG_MAJOR >= 5:
output_kwargs["fps_mode"] = "passthrough"
else:
output_kwargs["vsync"] = "passthrough"
process = (
ffmpeg
.input(filename=video_path)
.output("pipe:", **output_kwargs)
.run_async(pipe_stdout=True, pipe_stderr=True)
)
video_stream, err = process.communicate()
if process.returncode != 0:
raise ffmpeg.Error(None, video_stream, err)
except ffmpeg.Error as e:
stderr_msg = e.stderr.decode('utf-8') if e.stderr else "FFmpeg 启动失败"
print(f"## [OmniShotCut] FFmpeg 错误详情:\n{stderr_msg}")
raise RuntimeError(f"FFmpeg 无法打开视频。详情: {stderr_msg}")
# 3. 转换 Buffer
video_np_full = np.frombuffer(video_stream, np.uint8).reshape(-1, process_height, process_width, 3)
# 获取 FPS
fps = get_video_fps_safe(video_path)
# ... 后面的推理逻辑保持不变 ...
pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full = [], [], []
# 注意:此处确保 video_transform 已经定义
for clip_idx, (video_np, num_pad_frames) in enumerate(split_videos(video_np_full, max_process_window_length, num_context_frames)):
# Transform & Move to same device as model
video_tensor = video_transform(video_np).unsqueeze(0).to(device)
# Inference
with torch.inference_mode():
outputs = model(video_tensor)
# 后处理逻辑
probas_intra = outputs['intra_clip_logits'].softmax(-1)[0, :, :-1]
probas_inter = outputs['inter_clip_logits'].softmax(-1)[0, :, :-1]
range_probas = outputs['pred_shot_logits'].softmax(-1)[0, :, :-1]
query_intra_idx = probas_intra.argmax(dim=-1)
query_inter_idx = probas_inter.argmax(dim=-1)
query_range_idx = range_probas.argmax(dim=-1)
pred_ranges, pred_intra_labels, pred_inter_labels = [], [], []
start_frame_idx = 0
for keep_idx in range(len(query_intra_idx)):
pred_intra_label = int(query_intra_idx[keep_idx].detach().cpu())
pred_inter_label = int(query_inter_idx[keep_idx].detach().cpu())
end_frame_idx = int(query_range_idx[keep_idx].detach().cpu())
pred_range = [start_frame_idx, end_frame_idx]
if start_frame_idx >= end_frame_idx:
continue
pred_ranges.append(pred_range)
pred_intra_labels.append(pred_intra_label)
pred_inter_labels.append(pred_inter_label)
start_frame_idx = end_frame_idx
if end_frame_idx >= max_process_window_length - num_pad_frames:
break
# 剪枝与合并
pred_ranges, pred_intra_labels, pred_inter_labels = prune_non_context_ranges(
pred_ranges, pred_intra_labels, pred_inter_labels, max_process_window_length, num_context_frames
)
pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full = merge_ranges(
pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full,
pred_ranges, pred_intra_labels, pred_inter_labels
)
return pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full, video_np_full, fps
def dump_list_of_dict(data, save_path, indent=4):
"""
Save list[dict] as JSON.
Convert pred_intra_labels / pred_inter_labels from int to string.
"""
def convert_item(item):
item = copy.deepcopy(item)
if "pred_intra_labels" in item:
item["pred_intra_labels"] = [
intra_int2string.get(x, f"Unknown_{x}")
for x in item["pred_intra_labels"]
]
if "pred_inter_labels" in item:
item["pred_inter_labels"] = [
inter_int2string.get(x, f"Unknown_{x}")
for x in item["pred_inter_labels"]
]
return item
def format_dict(d, level):
indent_str = " " * (indent * level)
inner_indent = " " * (indent * (level + 1))
lines = [indent_str + "{"]
items = list(d.items())
for i, (k, v) in enumerate(items):
value_str = json.dumps(v, ensure_ascii=False)
comma = "," if i < len(items) - 1 else ""
lines.append(f'{inner_indent}"{k}": {value_str}{comma}')
lines.append(f"{indent_str}}}")
return "\n".join(lines)
data = [convert_item(item) for item in data]
with open(save_path, "w", encoding="utf-8") as f:
f.write("[\n")
for i, item in enumerate(data):
dict_str = format_dict(item, level=1)
comma = "," if i < len(data) - 1 else ""
f.write(dict_str + comma + "\n")
f.write("]\n")
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"--checkpoint_path",
type = str,
default = "checkpoints/OmniShotCut_ckpt.pth",
help = "Path to checkpoint file."
)
parser.add_argument(
"--input_video_path",
type = str,
default = "__assets__/demo_video1.mp4",
help = "Path to the input video path."
)
parser.add_argument(
"--result_store_path",
type = str,
default = "results.json",
help="Path to save result json."
)
parser.add_argument(
"--num_context_frames",
type = int,
default = 0,
help = "Number of historical context frames to overlap between each clip window. The default is 100, using 5-10 should be good."
)
parser.add_argument(
"--visual_store_folder_path",
type = str,
default = "demo_video_results",
help = "Path to save the visualization results. Set to None to disable."
)
parser.add_argument(
"--mode",
type = str,
default = "default",
choices = ["default", "clean_shot"],
help = "Output Mode. 'default' means all Intra and Inter label. 'clean_shot' means only General Shot Cut without transitions. "
)
return parser.parse_args()
if __name__ == '__main__':
# Setting
inference_args = parse_args()
checkpoint_path = inference_args.checkpoint_path
input_video_path = inference_args.input_video_path
assert(os.path.exists(input_video_path))
result_store_path = inference_args.result_store_path
visual_store_folder_path = inference_args.visual_store_folder_path
mode = inference_args.mode
# Prepare the folder
if visual_store_folder_path is not None:
if os.path.exists(visual_store_folder_path):
shutil.rmtree(visual_store_folder_path)
os.makedirs(visual_store_folder_path)
# Load Checkpoint & Model Config
assert(os.path.exists(checkpoint_path))
state_dict = torch.load(checkpoint_path, map_location='cpu')
model_args = state_dict['args']
print("Checkpoint stored args are", model_args)
# Init the Model
print("Loading OmniShotCut Model!")
backbone = build_backbone(model_args)
transformer = build_transformer(model_args)
model = OmniShotCut(
backbone,
transformer,
num_intra_relation_classes = model_args.num_intra_relation_classes,
num_inter_relation_classes = model_args.num_inter_relation_classes,
num_frames = model_args.max_process_window_length,
num_queries = model_args.num_queries,
aux_loss = model_args.aux_loss,
)
model.load_state_dict(state_dict['model'], strict=True)
model.to("cuda")
model.eval()
# Do the inference
print("Do the inference!")
pred_ranges_full, pred_intra_labels_full, pred_inter_labels_full, video_np_full, fps = single_video_inference(input_video_path, model, model_args, inference_args.num_context_frames)
# Visualize
if visual_store_folder_path is not None:
print("Visualize the results!")
pred_saved_paths = visualize_concated_frames(video_np_full, visual_store_folder_path, pred_ranges_full, max_frames_per_img=264, end_range_exclusive=True, fps=24, start_index = 0)
# For Clean Shot mode, we only leave general types
if mode == "clean_shot":
# Clean shot mode (No Transitions in the middle)
general_type_idx = unique_intra_label_mapping["general"]
effective_indices = []
for idx, intra_label in enumerate(pred_intra_labels_full):
if intra_label == general_type_idx:
effective_indices.append(idx)
# Reassign the shots
pred_ranges_full = np.array(pred_ranges_full)[effective_indices].tolist()
pred_intra_labels_full = np.array(pred_intra_labels_full)[effective_indices].tolist()
pred_inter_labels_full = np.array(pred_inter_labels_full)[effective_indices].tolist()
# Collect prediction results
pred_result = {}
pred_result["video_path"] = input_video_path
pred_result["pred_ranges"] = pred_ranges_full
pred_result["pred_intra_labels"] = pred_intra_labels_full
pred_result["pred_inter_labels"] = pred_inter_labels_full
# Dump to json
dump_list_of_dict([pred_result], result_store_path)
print("Finished!")
# 包装类,方便 ComfyUI 调用
class OmniShotCutDetector:
def __init__(self, checkpoint_path, device="cuda"):
self.model, self.model_args = load_model(checkpoint_path)
# 优先使用 ComfyUI 的模型管理器获取推荐设备,
# 它会处理多卡、显存限制、CPU offload 等复杂情况
try:
import comfy.model_management as mm
target_device = mm.get_torch_device()
except Exception:
target_device = torch.device(device)
# 将模型移至目标设备,若失败则自动降级到 CPU
try:
self.model.to(target_device)
self.device = target_device
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
print(f"## [OmniShotCut] 警告:无法将模型加载到 {target_device},自动降级到 CPU。原因: {e}")
self.model.to("cpu")
self.device = torch.device("cpu")
print(f"## [OmniShotCut] 模型已加载到设备: {self.device}")
def run_inference(self, input_video_path, mode="default", num_context_frames=0):
# 调用原始推理函数,传入 device 确保张量与模型在同一设备上
pred_ranges, pred_intra_labels, pred_inter_labels, video_np, fps = single_video_inference(
input_video_path, self.model, self.model_args, num_context_frames, device=self.device
)
# ── 步骤1:先生成预览图(使用完整镜头列表,与原项目 inference.py 第427行一致)
vis_images = []
try:
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="omni_preview_")
saved_paths = visualize_concated_frames(
frames=video_np,
out_dir=tmp_dir,
highlight_ranges_closed=pred_ranges,
max_frames_per_img=264,
end_range_exclusive=True,
fps=24, # 固定24fps,与原项目保持一致
start_index=0,
)
for p in saved_paths:
vis_images.append(Image.open(p).copy()) # copy() 防止文件句柄占用
# 清理临时文件
for p in saved_paths:
try:
os.remove(p)
except Exception:
pass
try:
os.rmdir(tmp_dir)
except Exception:
pass
except Exception as e:
import traceback
print(f"## [OmniShotCut] 预览图生成失败: {e}")
traceback.print_exc()
# ── 步骤2:clean_shot 模式过滤(仅影响 JSON 输出,不影响预览图)
if mode == "clean_shot":
general_type_idx = unique_intra_label_mapping["general"]
effective_indices = [i for i, lbl in enumerate(pred_intra_labels) if lbl == general_type_idx]
pred_ranges = [pred_ranges[i] for i in effective_indices]
pred_intra_labels = [pred_intra_labels[i] for i in effective_indices]
pred_inter_labels = [pred_inter_labels[i] for i in effective_indices]
# ── 步骤3:结果打包,int 标签转字符串,列表包裹,与原项目 output.json 格式一致
results = [
{
"video_path": input_video_path,
"pred_ranges": pred_ranges,
"pred_intra_labels": [intra_int2string.get(x, f"Unknown_{x}") for x in pred_intra_labels],
"pred_inter_labels": [inter_int2string.get(x, f"Unknown_{x}") for x in pred_inter_labels],
}
]
return results, vis_images # vis_images 为 PIL Image 列表