Files
2025-07-06 12:34:40 +08:00

273 lines
13 KiB
Python

import torch
import torch.nn.functional as F
import math
MAX_RESOLUTION = 8192
class PT_KeyframeCamera:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"start_frame": ("INT", {"default": 0, "min": 0, "max": 9999999, "step": 1}),
"end_frame": ("INT", {"default": 24, "min": 0, "max": 9999999, "step": 1}),
"start_horizontal_shift": ("INT", {"default": 0, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1,
"tooltip": "Horizontal shift at start frame (positive=right, negative=left)"}),
"start_vertical_shift": ("INT", {"default": 0, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1,
"tooltip": "Vertical shift at start frame (positive=down, negative=up)"}),
"start_rotation": ("INT", {"default": 0, "min": -360, "max": 360, "step": 1,
"tooltip": "Rotation angle at start frame (degrees)"}),
"start_zoom": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"end_horizontal_shift": ("INT", {"default": 100, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1,
"tooltip": "Horizontal shift at end frame (positive=right, negative=left)"}),
"end_vertical_shift": ("INT", {"default": 0, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1,
"tooltip": "Vertical shift at end frame (positive=down, negative=up)"}),
"end_rotation": ("INT", {"default": 0, "min": -360, "max": 360, "step": 1,
"tooltip": "Rotation angle at end frame (degrees)"}),
"end_zoom": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"zoom_origin": (["center", "top-left", "top-right", "bottom-left", "bottom-right"], {"default": "center"}),
"pad_mode": (["color", "edge"], {"default": "color"}),
"bg_color": ("STRING", {"default": "0, 0, 0", "tooltip": "RGB values (0-255) separated by commas"}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("images", "masks")
FUNCTION = "apply_camera_movement"
CATEGORY = "KJNodes/animation"
DESCRIPTION = "Keyframe-based camera movement (pan, zoom & rotation) for image sequences"
def apply_rotation(self, image, angle_degrees, bg_color):
"""Apply rotation to an image around its center point"""
if angle_degrees % 360 == 0:
return image # 无旋转
# 转换为弧度
angle_rad = math.radians(angle_degrees)
# 获取图像尺寸
B, H, W, C = image.shape
# 创建旋转矩阵
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# 计算旋转后的边界框
new_W = int(math.ceil(abs(W * cos_a) + abs(H * sin_a)))
new_H = int(math.ceil(abs(W * sin_a) + abs(H * cos_a)))
# 创建旋转后的画布
bg_tensor = torch.tensor(bg_color, dtype=image.dtype, device=image.device).view(1, 1, 1, 3)
rotated_canvas = bg_tensor.expand(B, new_H, new_W, 3).clone()
# 计算中心点偏移
center_x = W / 2
center_y = H / 2
new_center_x = new_W / 2
new_center_y = new_H / 2
# 生成网格坐标
y_grid, x_grid = torch.meshgrid(
torch.arange(new_H, device=image.device, dtype=torch.float32),
torch.arange(new_W, device=image.device, dtype=torch.float32),
indexing='ij'
)
# 计算反向映射坐标
x_offset = x_grid - new_center_x
y_offset = y_grid - new_center_y
# 应用反向旋转
x_original = center_x + x_offset * cos_a + y_offset * sin_a
y_original = center_y - x_offset * sin_a + y_offset * cos_a
# 归一化坐标到 [-1, 1]
x_normalized = (x_original / (W - 1)) * 2 - 1
y_normalized = (y_original / (H - 1)) * 2 - 1
# 创建采样网格
grid = torch.stack([x_normalized, y_normalized], dim=-1).unsqueeze(0).repeat(B, 1, 1, 1)
# 使用双线性插值采样
rotated_image = F.grid_sample(
image.permute(0, 3, 1, 2), # [B, C, H, W]
grid,
mode='bilinear',
padding_mode='zeros',
align_corners=True
)
# 转回原始格式 [B, H, W, C]
rotated_image = rotated_image.permute(0, 2, 3, 1)
# 将旋转后的图像放置在画布上
rotated_canvas = rotated_image
return rotated_canvas
def apply_camera_movement(self, images, start_frame, end_frame,
start_horizontal_shift, start_vertical_shift, start_rotation, start_zoom,
end_horizontal_shift, end_vertical_shift, end_rotation, end_zoom,
zoom_origin, pad_mode, bg_color):
# 确保输入是图像序列
if len(images.shape) != 4:
raise ValueError("Input must be a 4D tensor [batch, height, width, channels]")
num_frames, H, W, C = images.shape
# 解析背景颜色
try:
bg_rgb = [float(x.strip()) / 255.0 for x in bg_color.split(",")]
if len(bg_rgb) == 1: # 灰度转RGB
bg_rgb = bg_rgb * 3
elif len(bg_rgb) != 3:
raise ValueError("Invalid color format")
except:
bg_rgb = [0, 0, 0] # 默认为黑色
# 转换为tensor
bg_tensor = torch.tensor(bg_rgb, dtype=images.dtype, device=images.device).view(1, 1, 1, 3)
# 创建输出序列
output_images = []
output_masks = []
# 处理序列中的每一帧
for frame_idx in range(num_frames):
# 计算当前绝对帧号
current_frame = frame_idx
# 计算动画进度 (0到1)
if current_frame < start_frame:
progress = 0.0
elif current_frame > end_frame:
progress = 1.0
else:
total_frames = max(1, (end_frame - start_frame))
progress = (current_frame - start_frame) / total_frames
# 插值计算当前帧的参数
current_h_shift = int(round(start_horizontal_shift + (end_horizontal_shift - start_horizontal_shift) * progress))
current_v_shift = int(round(start_vertical_shift + (end_vertical_shift - start_vertical_shift) * progress))
current_rotation = int(round(start_rotation + (end_rotation - start_rotation) * progress))
current_zoom = start_zoom + (end_zoom - start_zoom) * progress
# 计算缩放后的尺寸
new_H = max(1, int(H * current_zoom))
new_W = max(1, int(W * current_zoom))
# 缩放当前帧
img = images[frame_idx].unsqueeze(0).permute(0, 3, 1, 2) # [1, C, H, W]
scaled = F.interpolate(img, size=(new_H, new_W), mode='bilinear', align_corners=False)
scaled_tensor = scaled.permute(0, 2, 3, 1) # [1, new_H, new_W, C]
# 应用旋转
if current_rotation % 360 != 0:
scaled_tensor = self.apply_rotation(scaled_tensor, current_rotation, bg_rgb)
# 更新旋转后尺寸
_, new_H, new_W, _ = scaled_tensor.shape
# 计算缩放后的偏移
origin_offset_x, origin_offset_y = 0, 0
if zoom_origin == "center":
origin_offset_x = int((W - new_W) // 2)
origin_offset_y = int((H - new_H) // 2)
elif zoom_origin == "top-right":
origin_offset_x = int(W - new_W)
elif zoom_origin == "bottom-left":
origin_offset_y = int(H - new_H)
elif zoom_origin == "bottom-right":
origin_offset_x = int(W - new_W)
origin_offset_y = int(H - new_H)
# 计算最终偏移(支持负值位移)
final_offset_x = origin_offset_x + current_h_shift
final_offset_y = origin_offset_y + current_v_shift
# 创建输出画布
canvas = bg_tensor.expand(1, H, W, 3).clone()
mask = torch.ones((1, H, W), dtype=torch.float32, device=images.device)
# 计算粘贴区域(支持负值位移)
paste_x1 = int(max(0, final_offset_x))
paste_y1 = int(max(0, final_offset_y))
paste_x2 = int(min(W, final_offset_x + new_W))
paste_y2 = int(min(H, final_offset_y + new_H))
# 计算源图像裁剪区域(支持负值位移)
src_x1 = int(max(0, -final_offset_x))
src_y1 = int(max(0, -final_offset_y))
src_x2 = int(min(new_W, W - final_offset_x))
src_y2 = int(min(new_H, H - final_offset_y))
# 确保源图像裁剪区域有效
src_x1 = min(src_x1, new_W - 1)
src_y1 = min(src_y1, new_H - 1)
src_x2 = max(src_x2, src_x1 + 1)
src_y2 = max(src_y2, src_y1 + 1)
# 处理边缘填充模式
if pad_mode == "edge" and (src_x1 > 0 or src_y1 > 0 or src_x2 < new_W or src_y2 < new_H):
# 创建扩展的图像 (增加边缘填充)
pad_size = max(
abs(end_horizontal_shift),
abs(end_vertical_shift),
int(max(new_W, new_H) * 0.1), # 根据图像尺寸动态计算
50 # 最小安全边界
)
padded_scaled = torch.zeros((1, new_H + pad_size*2, new_W + pad_size*2, C),
dtype=scaled_tensor.dtype, device=scaled_tensor.device)
# 用边缘像素填充
# 中心区域
padded_scaled[0, pad_size:pad_size+new_H, pad_size:pad_size+new_W] = scaled_tensor[0]
# 填充边缘
# 上边缘
if new_W > 0:
padded_scaled[0, :pad_size, pad_size:pad_size+new_W] = scaled_tensor[0, 0:1].expand(pad_size, new_W, C)
# 下边缘
if new_W > 0:
padded_scaled[0, pad_size+new_H:, pad_size:pad_size+new_W] = scaled_tensor[0, -1:].expand(pad_size, new_W, C)
# 左边缘
if new_H > 0:
padded_scaled[0, pad_size:pad_size+new_H, :pad_size] = scaled_tensor[0, :, 0:1].expand(new_H, pad_size, C)
# 右边缘
if new_H > 0:
padded_scaled[0, pad_size:pad_size+new_H, pad_size+new_W:] = scaled_tensor[0, :, -1:].expand(new_H, pad_size, C)
# 四个角
if new_W > 0 and new_H > 0:
padded_scaled[0, :pad_size, :pad_size] = scaled_tensor[0, 0, 0] # 左上
padded_scaled[0, :pad_size, pad_size+new_W:] = scaled_tensor[0, 0, -1] # 右上
padded_scaled[0, pad_size+new_H:, :pad_size] = scaled_tensor[0, -1, 0] # 左下
padded_scaled[0, pad_size+new_H:, pad_size+new_W:] = scaled_tensor[0, -1, -1] # 右下
# 调整源图像裁剪区域
src_x1 += pad_size
src_y1 += pad_size
src_x2 += pad_size
src_y2 += pad_size
scaled_tensor = padded_scaled
# 粘贴缩放后的图像到画布
if paste_x2 > paste_x1 and paste_y2 > paste_y1 and src_x2 > src_x1 and src_y2 > src_y1:
# 从缩放图像中裁剪有效区域
cropped_scaled = scaled_tensor[0, src_y1:src_y2, src_x1:src_x2, :]
# 粘贴到画布
canvas[0, paste_y1:paste_y2, paste_x1:paste_x2, :] = cropped_scaled
# 更新蒙版 (0=图像区域)
mask[0, paste_y1:paste_y2, paste_x1:paste_x2] = 0
output_images.append(canvas)
output_masks.append(mask)
# 组合结果
output_images = torch.cat(output_images, dim=0)
output_masks = torch.cat(output_masks, dim=0)
return (output_images, output_masks)