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)", }