143 lines
5.4 KiB
Python
143 lines
5.4 KiB
Python
# ==============================================================================
|
|
# 本节点由 心寸 开发
|
|
# Author: 心寸
|
|
# Description: 基于 Lissajous 曲线的 ComfyUI 手持镜头运镜模拟节点
|
|
# ==============================================================================
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
import numpy as np
|
|
import json
|
|
|
|
def ease_out_quadratic(t: float) -> float:
|
|
return 1 - (1 - t) ** 2
|
|
|
|
def generate_lissajous_trajectory(
|
|
frame_count: int,
|
|
fps: float,
|
|
width: int,
|
|
height: int,
|
|
intensity: float = 1.0,
|
|
seed: int = 42
|
|
) -> dict:
|
|
np_random = np.random.RandomState(seed)
|
|
dx_total = np.zeros(frame_count)
|
|
dy_total = np.zeros(frame_count)
|
|
frequencies =[0.033, 0.1, 0.2, 0.3]
|
|
|
|
for freq in frequencies:
|
|
t = np.arange(frame_count) / fps
|
|
phase = 2 * np.pi * t * freq
|
|
amp = np_random.uniform(5.2, 15.6)
|
|
|
|
if np_random.random() < 0.64:
|
|
angle_deg = np_random.uniform(0, 45)
|
|
else:
|
|
angle_deg = np_random.uniform(45, 90)
|
|
|
|
if np_random.random() < 0.5:
|
|
angle_deg = -angle_deg
|
|
if np_random.random() < 0.5:
|
|
angle_deg = 180 - angle_deg
|
|
|
|
angle_rad = np.deg2rad(angle_deg)
|
|
phase_normalized = (phase % (2 * np.pi)) / (2 * np.pi)
|
|
envelope = np.array([ease_out_quadratic(min(p, 1.0)) for p in phase_normalized])
|
|
|
|
base_motion = np.sin(phase) * envelope * amp
|
|
arc_offset = np.sin(phase) * np.deg2rad(np_random.uniform(15, 30))
|
|
|
|
dx_total += base_motion * np.cos(angle_rad + arc_offset)
|
|
dy_total += base_motion * np.sin(angle_rad + arc_offset)
|
|
|
|
dx_total = dx_total * intensity
|
|
dy_total = dy_total * intensity
|
|
dtheta = np.zeros(frame_count)
|
|
|
|
return {
|
|
"dx": dx_total.tolist(),
|
|
"dy": dy_total.tolist(),
|
|
"dtheta": dtheta.tolist(),
|
|
"frame_count": frame_count,
|
|
"fps": fps,
|
|
"width": width,
|
|
"height": height,
|
|
"intensity": intensity
|
|
}
|
|
|
|
|
|
class DNode_CameraShakeLissajous:
|
|
DESCRIPTION = """🎥 Lissajous Camera Shake (V1)
|
|
|
|
【核心特性】
|
|
基于利萨茹曲线(Lissajous)算法,通过叠加多组低频正弦波,确保镜头始终围绕画面绝对中心点进行平滑的呼吸感往复漂移,从根本上解决传统噪波抖动带来的画面偏离问题。
|
|
|
|
【参数说明】
|
|
- intensity (强度): 控制晃动剧烈程度。0.40为最佳手持平衡点。
|
|
- zoom_factor (缩放): 边缘防穿帮保护。1.05为默认安全区。
|
|
- fps (帧速率): 用于计算正确的物理运动周期。
|
|
- seed (随机种子): 决定频率叠加的初始形态。
|
|
|
|
--------------------------------------------------[Core Features]
|
|
Based on the Lissajous curve algorithm, this node superimposes multiple low-frequency sine waves to ensure the camera smoothly and rhythmically drifts around the absolute center of the frame. This fundamentally solves the frame deviation problem caused by traditional noise-based shakes.
|
|
|
|
[Parameters]
|
|
- intensity: Controls the intensity of the shake. 0.40 is the optimal balance for handheld camera simulation.
|
|
- zoom_factor: Edge protection to prevent clipping. 1.05 is the default safe zone.
|
|
- fps: Frame rate, used to calculate the correct physical motion cycle.
|
|
- seed: Determines the initial state of the frequency superposition.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"intensity": ("FLOAT", {"default": 0.40, "min": 0.0, "max": 5.0, "step": 0.01}),
|
|
"zoom_factor": ("FLOAT", {"default": 1.05, "min": 1.0, "max": 2.0, "step": 0.01}),
|
|
"fps": ("FLOAT", {"default": 30.0, "min": 1.0, "max": 120.0, "step": 1.0}),
|
|
"seed": ("INT", {"default": 42, "min": 0, "max": 0xffffffff}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("IMAGE", "trajectory_json")
|
|
FUNCTION = "apply_shake"
|
|
CATEGORY = "DNode"
|
|
|
|
def apply_shake(self, images, intensity, zoom_factor, fps, seed):
|
|
B, H, W, C = images.shape
|
|
trajectory = generate_lissajous_trajectory(B, fps, W, H, intensity, seed)
|
|
dx_arr = trajectory["dx"]
|
|
dy_arr = trajectory["dy"]
|
|
|
|
images_pt = images.permute(0, 3, 1, 2)
|
|
device = images_pt.device
|
|
theta = torch.zeros((B, 2, 3), dtype=torch.float32, device=device)
|
|
z = 1.0 / zoom_factor
|
|
|
|
for i in range(B):
|
|
tx = (dx_arr[i]) / (W / 2)
|
|
ty = (dy_arr[i]) / (H / 2)
|
|
theta[i, 0, 0] = z
|
|
theta[i, 1, 1] = z
|
|
theta[i, 0, 2] = tx
|
|
theta[i, 1, 2] = ty
|
|
|
|
grid = F.affine_grid(theta, images_pt.size(), align_corners=False)
|
|
shaken_images = F.grid_sample(
|
|
images_pt, grid, mode='bilinear', padding_mode='border', align_corners=False
|
|
)
|
|
|
|
shaken_images_out = shaken_images.permute(0, 2, 3, 1)
|
|
json_output = json.dumps(trajectory, indent=2, ensure_ascii=False)
|
|
|
|
return (shaken_images_out, json_output)
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"DNode_CameraShakeLissajous": DNode_CameraShakeLissajous
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"DNode_CameraShakeLissajous": "🎥 Lissajous Camera Shake (V1)"
|
|
} |