Fix LatLongOutpaintSetup perspective mode issues

- Fixed tensor dimension error in mask slicing (unsqueeze for 2D mask)
- Fixed perspective coordinate system to center image at equator with yaw=0, pitch=0
- Simplified FOV calculation: eff_fov = fov * scale (prevents extreme barrel distortion)
- Increased scale max from 10.0 to 100.0
- Updated Stitch node with matching FOV calculation
This commit is contained in:
Cedar
2026-02-05 15:13:41 -08:00
parent 2a03ca1dbf
commit 617cb06332
+41 -54
View File
@@ -2215,6 +2215,7 @@ def _perspective_insert(
lat = (math.pi / 2) - (yg / canvas_h) * math.pi
# Spherical -> Cartesian (world direction vectors)
# Convention: +X = lon 0 (front), +Y = lon 90 (right), +Z = lat 90 (up/north pole)
cos_lat = torch.cos(lat)
x_world = cos_lat * torch.cos(lon)
y_world = cos_lat * torch.sin(lon)
@@ -2224,24 +2225,28 @@ def _perspective_insert(
xyz_world = torch.stack([x_world, y_world, z_world], dim=-1)
# 2. Rotate World -> Camera (Inverse Rotation)
# We use the existing rotation matrix function but need to apply it inversely.
# The processor's matrix transforms P_camera -> P_world.
# So P_camera = P_world * R (since it uses row vectors x matrix_T)
# Wait, check EquirectangularProcessor.create_rotation_matrix usage:
# rotated_points = points_flat @ rotation_matrix.T
# This implies rotation_matrix maps source -> dest.
# For perspective extract: Camera Ray (canonical) -> Rotated Ray (world)
# So R maps Camera -> World.
# Here we want World -> Camera. So we need inverse of R (which is R.T).
# First apply user rotation, then we'll reinterpret axes so camera looks at equator by default.
#
# The user's yaw/pitch should match intuitive behavior:
# - yaw=0, pitch=0: look at center/equator (world +X)
# - yaw rotates horizontally, pitch rotates vertically
#
# To achieve this, we need the camera's +Z (look direction) to map to world +X at identity.
# Standard camera: looks down +Z with +X right, +Y down.
# We want: camera +Z -> world +X, camera +X -> world +Y, camera +Y -> world -Z
#
# After applying user rotation R to world points, we remap axes:
# cam_x = world_y, cam_y = -world_z, cam_z = world_x
R = EquirectangularProcessor._torch_rotation_matrix(yaw, pitch, roll, device, torch.float32)
# xyz_cam = xyz_world @ R (since R maps Cam->World, R.T maps World->Cam)
# Note: P @ R.T is "apply rotation vector".
# If R is Cam->World, then P_world = P_cam @ R.T (standard row-vector multiplication)
# So P_cam = P_world @ (R.T)^-1 = P_world @ R
xyz_cam = torch.tensordot(xyz_world, R, dims=1) # (H, W, 3)
xyz_rotated = torch.tensordot(xyz_world, R, dims=1) # (H, W, 3)
x_c, y_c, z_c = xyz_cam[..., 0], xyz_cam[..., 1], xyz_cam[..., 2]
# Remap axes so camera looks at equator (+X) by default
# cam_z = world_x (look direction), cam_x = world_y (right), cam_y = -world_z (down)
x_c = xyz_rotated[..., 1] # world_y -> cam_x
y_c = -xyz_rotated[..., 2] # -world_z -> cam_y (down direction)
z_c = xyz_rotated[..., 0] # world_x -> cam_z (look direction)
# 3. Project Camera -> Flat Image Plane
# Canonical camera looks usually down +Z or +X.
@@ -2256,7 +2261,7 @@ def _perspective_insert(
# Perspective projection
# u_norm = x_c / z_c, v_norm = y_c / z_c
# But we need to handle numerical stability.
# y_c already accounts for down direction from axis remapping
z_c = torch.clamp(z_c, min=1e-6)
u_norm = x_c / z_c
v_norm = y_c / z_c
@@ -2348,12 +2353,12 @@ class LatLongOutpaintSetup:
"canvas_width": ("INT", {"default": 2048, "min": 512, "max": 8192, "step": 8, "tooltip": "Width of the equirectangular canvas."}),
"canvas_height": ("INT", {"default": 1024, "min": 256, "max": 4096, "step": 8, "tooltip": "Height of the equirectangular canvas."}),
"placement_mode": (["2d_composite", "perspective"], {"default": "2d_composite"}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.01, "tooltip": "2D: Pixel scale. Perspective: FOV multiplier."}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.01, "tooltip": "Image size multiplier. 1.0 = original size relative to canvas."}),
"translate_x": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1, "tooltip": "2D only: Horizontal pixel offset."}),
"translate_y": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1, "tooltip": "2D only: Vertical pixel offset."}),
"yaw": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0, "step": 1.0, "tooltip": "Perspective only: Horizontal view angle."}),
"pitch": ("FLOAT", {"default": 0.0, "min": -90.0, "max": 90.0, "step": 1.0, "tooltip": "Perspective only: Vertical view angle."}),
"fov": ("FLOAT", {"default": 90.0, "min": 10.0, "max": 170.0, "step": 1.0, "tooltip": "Perspective only: Base Field of View."}),
"fov": ("FLOAT", {"default": 90.0, "min": 10.0, "max": 170.0, "step": 1.0, "tooltip": "Perspective only: Field of View at scale=1.0."}),
"feather_size": ("INT", {"default": 50, "min": 0, "max": 500, "step": 1, "tooltip": "Inner feathering in pixels."}),
}
}
@@ -2433,7 +2438,7 @@ class LatLongOutpaintSetup:
# We use simple copy for 2D composite on empty canvas
# But handle feather alpha
canvas[:, y_start:y_end, x_start:x_end] = scaled_img[:, src_y:src_y+h_slice, src_x:src_x+w_slice]
alpha[:, y_start:y_end, x_start:x_end] = mask_small[:, src_y:src_y+h_slice, src_x:src_x+w_slice]
alpha[:, y_start:y_end, x_start:x_end] = mask_small[src_y:src_y+h_slice, src_x:src_x+w_slice].unsqueeze(0)
# For output, we want RGB + Alpha channel if possible?
# User asked for "transparent canvas". ComfyUI IMAGE is usually RGB.
@@ -2452,10 +2457,22 @@ class LatLongOutpaintSetup:
out_mask = (1.0 - alpha).squeeze(0) # BHW
else: # perspective
# "perspective constrained scale" -> scale affects FOV
# larger scale = larger image = larger FOV coverage
# Perspective projection with proper scale normalization:
#
# The `fov` parameter directly controls the field of view for the projection.
# A typical camera has 50-90 degree FOV. Default is 90.
#
# The `scale` parameter affects the angular coverage on the panorama:
# - scale=1.0 with fov=90 means the image covers 90 degrees of the panorama
# - scale=0.5 with fov=90 means the image covers 45 degrees (appears smaller)
# - scale=2.0 with fov=90 means the image covers 180 degrees (appears larger)
#
# This keeps distortion controlled (based on fov) while scale adjusts size.
eff_fov = fov * scale
# Clamp FOV to reasonable range to prevent extreme distortion
eff_fov = max(5.0, min(150.0, eff_fov))
# Project
proj_img, proj_mask = _perspective_insert(
img,
@@ -2588,44 +2605,14 @@ class LatLongOutpaintStitch:
if w_slice > 0 and h_slice > 0:
canvas[:, y_start:y_end, x_start:x_end] = scaled_src[:, src_y:src_y+h_slice, src_x:src_x+w_slice]
alpha[:, y_start:y_end, x_start:x_end] = mask_small[:, src_y:src_y+h_slice, src_x:src_x+w_slice]
alpha[:, y_start:y_end, x_start:x_end] = mask_small[src_y:src_y+h_slice, src_x:src_x+w_slice].unsqueeze(0)
else: # perspective
# FOV Logic:
# "effective fov" depends on scale.
# In setup: eff_fov = fov * scale.
# Use the same FOV calculation as Setup: fov * scale
eff_fov = fov * scale
# In stitch, the FOV is angular, so it shouldn't change with resolution!
# If we scale the canvas, the angular coverage is the same.
# So we just re-project with the SAME angular parameters,
# but onto a larger/smaller canvas.
# However, feather is in pixels relative to SOURCE image in _perspective_insert.
# "feather: Inner feather pixels (relative to flat image source coords)"
# So feather param should stay same? Yes, relative to source image.
# But wait, my _perspective_insert feather logic:
# "feather_ndc_x = (feather / W_src) * 2.0"
# If we use original source image, W_src is the high-res width.
# The user specified feather size in pixels during Setup.
# Setup used 'flat_image' (could be key reduced).
# Stitch uses 'original_image' (high res).
# If Setup used a downscaled image, 50px feather is large.
# If Stitch uses 4k image, 50px feather is small.
# We usually want visual consistency.
# If user saw 50px feather on 512px image (10%), they expect 10% feather on 4k image (400px).
# So we should scale feather by (W_highres / W_setup_src).
# Check W_src in stitch (high res) vs W in setup.
# Context saved 'original_image' which IS the high res.
# Wait, Setup takes 'flat_image'. Is that high res?
# User workflow:
# 1. LoadImage (Huge)
# 2. Setup (Huge) -> resize to Canvas (Medium)
# No, Setup takes Flat Image and puts it on Canvas.
# If user feeds Huge image to Setup, Setup uses Huge image.
# So Setup has the high res image already.
# So 'flat_image' in Setup IS 'original_image'.
# So W_src is constant.
# So feather pixels is constant.
# Clamp FOV to reasonable range to prevent extreme distortion
eff_fov = max(5.0, min(150.0, eff_fov))
proj_img, proj_mask = _perspective_insert(
orig_src,