Add files via upload
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
# ==============================================================================
|
||||
# 本节点由 心寸 开发
|
||||
# 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)"
|
||||
}
|
||||
Reference in New Issue
Block a user