Files
2026-02-28 18:58:58 +08:00

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