Files
yichengup-ComfyUI_SwiftCut/py/YCImageColorTrackBindpro.py
2026-01-23 23:46:43 +08:00

420 lines
16 KiB
Python

import torch
import numpy as np
from PIL import Image, ImageFilter
import cv2
import math
from typing import Tuple, Optional, List, Dict, Any
# --- 基础工具函数 ---
def pil2tensor(image):
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
def tensor2pil(image):
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
def tensor2cv2(image_tensor):
if len(image_tensor.shape) == 4:
image_tensor = image_tensor[0]
img_np = image_tensor.detach().cpu().numpy()
img_np = np.clip(img_np, 0.0, 1.0)
# 转换 HWC/CHW
if img_np.ndim == 3 and img_np.shape[-1] in (3, 4):
img_hwc = img_np
elif img_np.ndim == 3 and img_np.shape[0] in (3, 4):
img_hwc = np.transpose(img_np, (1, 2, 0))
else:
raise ValueError(f"Unsupported image layout")
img_u8 = (img_hwc * 255.0).astype(np.uint8)
# 处理RGBA转BGR (OpenCV默认处理)
if img_u8.shape[2] == 4:
# 如果是RGBA,我们这里只取RGB做追踪,但后续处理要保留Alpha
pass
# 返回RGB转BGR的结果用于cv2处理
if img_u8.shape[2] == 4:
return cv2.cvtColor(img_u8, cv2.COLOR_RGBA2BGR)
else:
return cv2.cvtColor(img_u8, cv2.COLOR_RGB2BGR)
def parse_color(color_str: str) -> Tuple[int, int, int]:
color_str = color_str.strip().lstrip('#')
if ',' in color_str:
parts = color_str.split(',')
elif ' ' in color_str:
parts = color_str.split()
elif len(color_str) == 6:
return tuple(int(color_str[i:i+2], 16) for i in (0, 2, 4))
else:
parts = [color_str]
if len(parts) >= 3:
return (int(parts[0]), int(parts[1]), int(parts[2]))
return (255, 0, 0) # Default Red
# --- 核心算法 ---
def apply_motion_blur(image_pil: Image.Image, velocity_x: float, velocity_y: float, strength: float) -> Image.Image:
"""应用基于速度的方向性运动模糊"""
if strength <= 0 or (abs(velocity_x) < 1 and abs(velocity_y) < 1):
return image_pil
# 计算模糊核大小和角度
magnitude = math.sqrt(velocity_x**2 + velocity_y**2)
kernel_size = int(magnitude * strength)
# 限制核大小,避免过大导致崩溃或极度卡顿
if kernel_size < 2: return image_pil
if kernel_size > 50: kernel_size = 50
# 确保核大小为奇数
if kernel_size % 2 == 0: kernel_size += 1
# 转换PIL到CV2 (保留Alpha通道)
img_np = np.array(image_pil)
# 检查图像尺寸,避免对过小图像应用模糊
if img_np.shape[0] < kernel_size or img_np.shape[1] < kernel_size:
return image_pil
# 创建方向性模糊核
kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
center = kernel_size // 2
# 归一化向量
vec_x = velocity_x / (magnitude + 1e-5)
vec_y = velocity_y / (magnitude + 1e-5)
# 画线生成核
# 线的起点和终点
p1 = (int(center - vec_x * (kernel_size/2)), int(center - vec_y * (kernel_size/2)))
p2 = (int(center + vec_x * (kernel_size/2)), int(center + vec_y * (kernel_size/2)))
# 确保坐标在有效范围内
p1 = (max(0, min(kernel_size-1, p1[0])), max(0, min(kernel_size-1, p1[1])))
p2 = (max(0, min(kernel_size-1, p2[0])), max(0, min(kernel_size-1, p2[1])))
cv2.line(kernel, p1, p2, 1.0, 1)
# 归一化核
kernel_sum = np.sum(kernel)
if kernel_sum > 0:
kernel /= kernel_sum
else:
# 如果核为空,创建一个简单的中心点核
kernel[center, center] = 1.0
# 应用滤波
try:
blurred = cv2.filter2D(img_np, -1, kernel)
return Image.fromarray(blurred)
except Exception as e:
# 如果滤波失败,返回原图像
print(f"Motion blur failed: {e}")
return image_pil
def find_contour_metrics(frame_cv2: np.ndarray, target_color_rgb: Tuple[int, int, int],
tolerance: int, processing_width: int,
prev_center: Optional[Tuple[int, int]]) -> Dict[str, Any]:
"""
在降采样后的图像上查找:中心点、旋转角度、面积
"""
# 1. 降采样 (Downsampling)
h, w = frame_cv2.shape[:2]
scale_ratio = 1.0
work_frame = frame_cv2
if processing_width > 0 and w > processing_width:
scale_ratio = processing_width / w
new_h = int(h * scale_ratio)
work_frame = cv2.resize(frame_cv2, (processing_width, new_h), interpolation=cv2.INTER_AREA)
# 2. HSV Masking
hsv = cv2.cvtColor(work_frame, cv2.COLOR_BGR2HSV)
target_bgr = np.uint8([[target_color_rgb[::-1]]])
target_hsv = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2HSV)[0][0]
th, ts, tv = int(target_hsv[0]), int(target_hsv[1]), int(target_hsv[2])
# 动态容差策略
h_tol = max(5, int(tolerance * 0.5))
sv_tol = max(40, int(tolerance * 1.5))
lower = np.array([max(0, th - h_tol), max(20, ts - sv_tol), max(20, tv - sv_tol)], dtype=np.uint8)
upper = np.array([min(180, th + h_tol), min(255, ts + sv_tol), min(255, tv + sv_tol)], dtype=np.uint8)
# 环绕色相处理
if lower[0] > upper[0]:
# 这种情况通常不需要特殊处理,除非跨越0/180,这里简化逻辑,通常直接clip就好
pass
mask = cv2.inRange(hsv, lower, upper)
# 形态学去噪
kernel_small = np.ones((3, 3), np.uint8)
kernel_large = np.ones((5, 5), np.uint8)
mask = cv2.erode(mask, kernel_small, iterations=1)
mask = cv2.dilate(mask, kernel_large, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
valid_candidates = []
# 将上一帧的中心点映射到缩小后的坐标系
prev_center_scaled = None
if prev_center:
prev_center_scaled = (prev_center[0] * scale_ratio, prev_center[1] * scale_ratio)
for cnt in contours:
area = cv2.contourArea(cnt)
if area < 10: continue
# 最小外接矩形 (Center(x,y), (w,h), angle)
rect = cv2.minAreaRect(cnt)
(cx, cy), (rw, rh), angle = rect
# 简单的评分机制
score = area
if prev_center_scaled:
dist = math.sqrt((cx - prev_center_scaled[0])**2 + (cy - prev_center_scaled[1])**2)
# 距离惩罚:距离越远,分数扣得越多
score = area - (dist * dist * 0.5)
valid_candidates.append({
'center': (cx / scale_ratio, cy / scale_ratio), # 还原回原分辨率
'angle': angle,
'area': area / (scale_ratio * scale_ratio), # 还原回原分辨率
'score': score,
'rect_size': (rw, rh)
})
if not valid_candidates:
return None
# 选分最高的
best = max(valid_candidates, key=lambda x: x['score'])
return best
def smooth_value(current, previous, factor):
"""通用的一阶平滑"""
if previous is None: return current
return previous * (1 - factor) + current * factor
def interpolate_missing(data_list):
"""线性插值填补None"""
n = len(data_list)
for i in range(n):
if data_list[i] is None:
# Find prev
prev_idx = i - 1
while prev_idx >= 0 and data_list[prev_idx] is None: prev_idx -= 1
# Find next
next_idx = i + 1
while next_idx < n and data_list[next_idx] is None: next_idx += 1
if prev_idx >= 0 and next_idx < n:
start = data_list[prev_idx]
end = data_list[next_idx]
ratio = (i - prev_idx) / (next_idx - prev_idx)
data_list[i] = start + (end - start) * ratio
elif prev_idx >= 0:
data_list[i] = data_list[prev_idx]
elif next_idx < n:
data_list[i] = data_list[next_idx]
else:
data_list[i] = 0.0 # Default
return data_list
# --- 节点定义 ---
class YCImageColorTrackBindPro:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"video_frames": ("IMAGE",),
"target_color": ("STRING", {"default": "#FF0000", "multiline": False}),
"color_tolerance": ("INT", {"default": 30, "min": 0, "max": 255}),
"smoothing_factor": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 0.99, "step": 0.01}),
"processing_width": ("INT", {"default": 640, "min": 100, "max": 4096, "step": 10, "label": "Tracking Resolution (px)"}),
# 位置微调
"anchor_offset_x": ("INT", {"default": 0}),
"anchor_offset_y": ("INT", {"default": 0}),
"base_scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0}),
# 高级特性开关
"enable_rotation": ("BOOLEAN", {"default": False}),
"rotation_offset": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0}),
"enable_auto_scale": ("BOOLEAN", {"default": False, "label": "Auto Z-Depth Scale"}),
"motion_blur_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 5.0, "step": 0.1}),
},
"optional": {
"mask": ("MASK",),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("output_frames",)
FUNCTION = "process"
CATEGORY = "YC_VideoCutHelper/Image"
def process(self, image, video_frames, target_color, color_tolerance, smoothing_factor, processing_width,
anchor_offset_x, anchor_offset_y, base_scale,
enable_rotation, rotation_offset, enable_auto_scale, motion_blur_strength, mask=None):
# 1. 准备输入
target_color_rgb = parse_color(target_color)
if image.shape[0] > 1: image = image[0:1]
input_pil_base = tensor2pil(image)
if mask is not None:
if mask.shape[0] > 1: mask = mask[0:1]
mask_np = mask.cpu().numpy()
if mask_np.ndim == 3: mask_np = mask_np[0]
# Resize mask to image size if needed
if mask_np.shape != (input_pil_base.height, input_pil_base.width):
m_img = Image.fromarray((mask_np * 255).astype(np.uint8)).resize(input_pil_base.size, Image.Resampling.LANCZOS)
mask_np = np.array(m_img).astype(np.float32) / 255.0
# Apply Mask to Alpha
if input_pil_base.mode != 'RGBA': input_pil_base = input_pil_base.convert('RGBA')
r, g, b, a = input_pil_base.split()
a_np = np.array(a).astype(np.float32) / 255.0
new_a = (a_np * mask_np * 255).astype(np.uint8)
input_pil_base.putalpha(Image.fromarray(new_a))
else:
if input_pil_base.mode != 'RGBA': input_pil_base = input_pil_base.convert('RGBA')
num_frames = video_frames.shape[0]
# 2. 追踪阶段 (收集原始数据)
raw_centers = []
raw_angles = []
raw_areas = []
last_center = None
for i in range(num_frames):
frame = tensor2cv2(video_frames[i:i+1])
metrics = find_contour_metrics(frame, target_color_rgb, color_tolerance, processing_width, last_center)
if metrics:
raw_centers.append(metrics['center'])
raw_angles.append(metrics['angle'])
raw_areas.append(metrics['area'])
last_center = metrics['center']
else:
raw_centers.append(None)
raw_angles.append(None)
raw_areas.append(None)
# 3. 数据清洗与插值
# 分离XY以便插值
xs = [p[0] if p else None for p in raw_centers]
ys = [p[1] if p else None for p in raw_centers]
fixed_xs = interpolate_missing(xs)
fixed_ys = interpolate_missing(ys)
fixed_angles = interpolate_missing(raw_angles)
fixed_areas = interpolate_missing(raw_areas)
# 确定基准面积 (取所有有效面积的中位数,避免初始噪点)
valid_areas = [a for a in fixed_areas if a > 0]
base_area_ref = np.median(valid_areas) if valid_areas else 100.0
# 4. 平滑与生成
output_tensors = []
# 状态变量
curr_x, curr_y = fixed_xs[0], fixed_ys[0]
curr_angle = fixed_angles[0]
curr_area = fixed_areas[0]
# 运动模糊用的前一帧位置
prev_x, prev_y = curr_x, curr_y
# 计算图像初始中心
img_w_orig, img_h_orig = input_pil_base.size
for i in range(num_frames):
# --- 平滑逻辑 ---
# 动态调整平滑系数:如果有剧烈运动(距离变化大),减少平滑以防滞后
# 这里简化处理,直接应用 EMA
alpha = 1.0 - smoothing_factor
# 保存当前位置用于运动模糊计算
prev_x, prev_y = curr_x, curr_y
curr_x = curr_x * (1-alpha) + fixed_xs[i] * alpha
curr_y = curr_y * (1-alpha) + fixed_ys[i] * alpha
# 角度处理 (注意 OpenCV minAreaRect 角度可能是 0-90 或 0-180,这里直接平滑,假设不会突变)
curr_angle = curr_angle * (1-alpha) + fixed_angles[i] * alpha
# 面积处理
curr_area = curr_area * (1-alpha) + fixed_areas[i] * alpha
# --- 变换计算 ---
# 1. 缩放
final_scale = base_scale
if enable_auto_scale and base_area_ref > 0:
# 面积比的平方根 = 边长比
scale_mult = math.sqrt(curr_area / base_area_ref)
# 限制动态缩放范围,防止无限大或消失
scale_mult = max(0.2, min(5.0, scale_mult))
final_scale *= scale_mult
new_w = int(img_w_orig * final_scale)
new_h = int(img_h_orig * final_scale)
if new_w < 1 or new_h < 1: new_w, new_h = 1, 1
img_transformed = input_pil_base.resize((new_w, new_h), Image.Resampling.LANCZOS)
# 2. 旋转
if enable_rotation:
# 注意:OpenCV的角度通常是顺时针或逆时针,需根据实际情况调整符号
# rotation_offset 用于修正初始方向
total_angle = curr_angle + rotation_offset
img_transformed = img_transformed.rotate(total_angle, expand=True, resample=Image.Resampling.BICUBIC)
# 3. 运动模糊
if motion_blur_strength > 0 and i > 0:
# 计算速度向量(当前位置 - 前一帧位置)
vx = curr_x - prev_x
vy = curr_y - prev_y
img_transformed = apply_motion_blur(img_transformed, vx, vy, motion_blur_strength)
# 4. 合成
frame_tensor = video_frames[i:i+1]
frame_pil = tensor2pil(frame_tensor)
bg_w, bg_h = frame_pil.size
# 计算粘贴坐标 (中心对齐)
paste_w, paste_h = img_transformed.size
paste_x = int(curr_x - paste_w / 2 + anchor_offset_x)
paste_y = int(curr_y - paste_h / 2 + anchor_offset_y)
# PIL粘贴处理透明度
comp = frame_pil.copy().convert('RGBA')
comp.paste(img_transformed, (paste_x, paste_y), img_transformed)
output_tensors.append(pil2tensor(comp.convert('RGB')))
return (torch.cat(output_tensors, dim=0),)
NODE_CLASS_MAPPINGS = {
"YCImageColorTrackBindPro": YCImageColorTrackBindPro,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"YCImageColorTrackBindPro": "Image Color Track Bind (Pro)",
}