Implemented new DepthFOVEstimatorNode that uses depth maps for more robust geometric analysis. This is the recommended method for accurate FOV and tilt estimation. New Features: - Depth map input support (compatible with MiDaS, Depth Anything, etc.) - Depth discontinuity detection using Sobel gradients - Bilateral filtering for edge-preserving smoothing - Depth-based vanishing point detection - Depth gradient analysis for fallback tilt estimation - Handles various depth map formats (1/3 channel, normalized/unnormalized) Architecture: - Modular design following ComfyUI patterns - Accepts depth map + original image inputs - Same output interface as RGB node for easy switching - Separate node registration: "FOV & Tilt Estimator (Depth)" Advantages over RGB method: - Depth discontinuities represent real 3D geometry - Independent of texture, lighting, and shadows - More robust to image artifacts and reflections - Better fallback behavior for scenes without clear lines - Cleaner edge detection from structural boundaries Both nodes now available: 1. FOV & Tilt Estimator (RGB) - Direct RGB analysis 2. FOV & Tilt Estimator (Depth) - Depth-based (recommended) Updated README with: - Complete depth-based workflow documentation - Comparison between RGB and depth methods - Tips for both estimation approaches - Recommended depth estimation nodes - Detailed technical implementation notes
931 lines
33 KiB
Python
931 lines
33 KiB
Python
"""
|
|
ComfyUI Node for estimating FOV and tilt (horizon angle) of images
|
|
"""
|
|
|
|
import numpy as np
|
|
import torch
|
|
import cv2
|
|
from typing import Tuple, Dict, Any
|
|
|
|
|
|
class FOVEstimatorNode:
|
|
"""
|
|
A ComfyUI node that estimates the Field of View (FOV) and tilt angle (horizon angle) of an image.
|
|
|
|
Uses computer vision techniques including:
|
|
- Canny edge detection
|
|
- Hough line transform for line detection
|
|
- Vanishing point analysis for FOV estimation
|
|
- Horizon line detection for tilt estimation
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"edge_threshold_low": ("INT", {
|
|
"default": 50,
|
|
"min": 0,
|
|
"max": 255,
|
|
"step": 1,
|
|
"display": "number"
|
|
}),
|
|
"edge_threshold_high": ("INT", {
|
|
"default": 150,
|
|
"min": 0,
|
|
"max": 255,
|
|
"step": 1,
|
|
"display": "number"
|
|
}),
|
|
"line_threshold": ("INT", {
|
|
"default": 100,
|
|
"min": 10,
|
|
"max": 500,
|
|
"step": 10,
|
|
"display": "number"
|
|
}),
|
|
"visualize": ("BOOLEAN", {
|
|
"default": True,
|
|
"display": "checkbox"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "FLOAT", "FLOAT", "STRING")
|
|
RETURN_NAMES = ("annotated_image", "fov_degrees", "tilt_degrees", "info")
|
|
FUNCTION = "estimate_fov_tilt"
|
|
CATEGORY = "image/analysis"
|
|
|
|
def estimate_fov_tilt(
|
|
self,
|
|
image: torch.Tensor,
|
|
edge_threshold_low: int = 50,
|
|
edge_threshold_high: int = 150,
|
|
line_threshold: int = 100,
|
|
visualize: bool = True
|
|
) -> Tuple[torch.Tensor, float, float, str]:
|
|
"""
|
|
Estimate FOV and tilt angle from an image.
|
|
|
|
Args:
|
|
image: Input image tensor in ComfyUI format [B, H, W, C]
|
|
edge_threshold_low: Lower threshold for Canny edge detection
|
|
edge_threshold_high: Upper threshold for Canny edge detection
|
|
line_threshold: Threshold for Hough line detection
|
|
visualize: Whether to draw detected lines on the output image
|
|
|
|
Returns:
|
|
Tuple of (annotated_image, fov_degrees, tilt_degrees, info_string)
|
|
"""
|
|
# Convert from ComfyUI tensor format [B, H, W, C] to numpy
|
|
batch_size = image.shape[0]
|
|
results_images = []
|
|
results_fov = []
|
|
results_tilt = []
|
|
results_info = []
|
|
|
|
for i in range(batch_size):
|
|
img_np = image[i].cpu().numpy()
|
|
|
|
# Convert from [0, 1] float to [0, 255] uint8
|
|
img_np = (img_np * 255).astype(np.uint8)
|
|
|
|
# Estimate FOV first, then use it for tilt calculation
|
|
fov_angle, vanishing_points = self._estimate_fov(
|
|
img_np, edge_threshold_low, edge_threshold_high, line_threshold
|
|
)
|
|
|
|
# Estimate tilt using the calculated FOV
|
|
tilt_angle, horizon_lines = self._estimate_tilt(
|
|
img_np, edge_threshold_low, edge_threshold_high, line_threshold, fov_angle
|
|
)
|
|
|
|
# Create visualization if requested
|
|
if visualize:
|
|
vis_img = self._visualize_results(
|
|
img_np.copy(), horizon_lines, vanishing_points, tilt_angle, fov_angle
|
|
)
|
|
else:
|
|
vis_img = img_np
|
|
|
|
# Convert back to ComfyUI tensor format
|
|
vis_tensor = torch.from_numpy(vis_img.astype(np.float32) / 255.0)
|
|
results_images.append(vis_tensor)
|
|
results_fov.append(fov_angle)
|
|
results_tilt.append(tilt_angle)
|
|
|
|
# Create info string
|
|
info = f"FOV: {fov_angle:.2f}°, Tilt: {tilt_angle:.2f}°"
|
|
results_info.append(info)
|
|
|
|
# Stack batch results
|
|
output_image = torch.stack(results_images, dim=0)
|
|
avg_fov = float(np.mean(results_fov))
|
|
avg_tilt = float(np.mean(results_tilt))
|
|
combined_info = "\n".join(results_info)
|
|
|
|
return (output_image, avg_fov, avg_tilt, combined_info)
|
|
|
|
def _estimate_tilt(
|
|
self,
|
|
img: np.ndarray,
|
|
threshold1: int,
|
|
threshold2: int,
|
|
line_threshold: int,
|
|
fov_horizontal: float
|
|
) -> Tuple[float, list]:
|
|
"""
|
|
Estimate the tilt angle (horizon angle) of the image based on horizon position.
|
|
|
|
Tilt is calculated relative to frame center:
|
|
- Tilt = 0° when horizon is at frame center
|
|
- Tilt < 0° when horizon is above center (camera looking up)
|
|
- Tilt > 0° when horizon is below center (camera looking down)
|
|
|
|
Args:
|
|
fov_horizontal: Estimated horizontal field of view in degrees
|
|
|
|
Returns:
|
|
Tuple of (tilt_angle_degrees, detected_horizon_lines)
|
|
"""
|
|
height, width = img.shape[:2]
|
|
frame_center_y = height / 2.0
|
|
|
|
# Calculate vertical FOV from horizontal FOV using aspect ratio
|
|
aspect_ratio = width / height
|
|
# tan(vfov/2) = tan(hfov/2) / aspect_ratio
|
|
fov_vertical = 2 * np.degrees(np.arctan(np.tan(np.radians(fov_horizontal / 2)) / aspect_ratio))
|
|
|
|
# Convert to grayscale
|
|
if len(img.shape) == 3:
|
|
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
else:
|
|
gray = img
|
|
|
|
# Apply Gaussian blur to reduce noise
|
|
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
|
|
# Edge detection
|
|
edges = cv2.Canny(blurred, threshold1, threshold2)
|
|
|
|
# Detect lines using Hough transform
|
|
lines = cv2.HoughLines(edges, 1, np.pi / 180, line_threshold)
|
|
|
|
horizon_lines = []
|
|
horizon_y_positions = []
|
|
|
|
if lines is not None:
|
|
for line in lines:
|
|
rho, theta = line[0]
|
|
|
|
# Convert theta to degrees
|
|
angle_deg = np.degrees(theta)
|
|
|
|
# Filter for near-horizontal lines (within 15 degrees of horizontal)
|
|
# Theta is measured from vertical, so horizontal is around 90 degrees
|
|
if 75 <= angle_deg <= 105:
|
|
# Calculate where this line crosses the center x of the frame
|
|
# Line equation: x*cos(theta) + y*sin(theta) = rho
|
|
# At x = width/2, solve for y: y = (rho - x*cos(theta)) / sin(theta)
|
|
|
|
cos_theta = np.cos(theta)
|
|
sin_theta = np.sin(theta)
|
|
|
|
if abs(sin_theta) > 0.1: # Avoid near-vertical lines
|
|
x_center = width / 2.0
|
|
y_at_center = (rho - x_center * cos_theta) / sin_theta
|
|
|
|
# Only consider lines that cross within or near the frame
|
|
if -height * 0.5 <= y_at_center <= height * 1.5:
|
|
horizon_lines.append((rho, theta))
|
|
horizon_y_positions.append(y_at_center)
|
|
|
|
# Calculate tilt based on horizon position relative to frame center
|
|
if horizon_y_positions:
|
|
# Use median horizon position to be robust to outliers
|
|
median_horizon_y = float(np.median(horizon_y_positions))
|
|
|
|
# Calculate offset from center (positive = below center)
|
|
y_offset = median_horizon_y - frame_center_y
|
|
|
|
# Calculate tilt angle using the estimated vertical FOV
|
|
# Each pixel represents: vertical_fov / height degrees
|
|
degrees_per_pixel = fov_vertical / height
|
|
|
|
# Calculate tilt: positive when horizon is below center (camera tilted up)
|
|
tilt_angle = y_offset * degrees_per_pixel
|
|
else:
|
|
tilt_angle = 0.0 # No horizon detected, assume level
|
|
|
|
return tilt_angle, horizon_lines
|
|
|
|
def _estimate_fov(
|
|
self,
|
|
img: np.ndarray,
|
|
threshold1: int,
|
|
threshold2: int,
|
|
line_threshold: int
|
|
) -> Tuple[float, list]:
|
|
"""
|
|
Estimate the Field of View using vanishing point and focal length estimation.
|
|
|
|
Uses the relationship: focal_length = width / (2 * tan(hfov/2))
|
|
The distance from image center to a vanishing point approximates the focal length.
|
|
|
|
Returns:
|
|
Tuple of (fov_angle_degrees, vanishing_points)
|
|
"""
|
|
height, width = img.shape[:2]
|
|
|
|
# Convert to grayscale
|
|
if len(img.shape) == 3:
|
|
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
else:
|
|
gray = img
|
|
|
|
# Apply Gaussian blur
|
|
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
|
|
# Edge detection
|
|
edges = cv2.Canny(blurred, threshold1, threshold2)
|
|
|
|
# Detect lines using probabilistic Hough transform for better line segments
|
|
lines = cv2.HoughLinesP(
|
|
edges,
|
|
rho=1,
|
|
theta=np.pi / 180,
|
|
threshold=line_threshold // 2,
|
|
minLineLength=min(width, height) // 10,
|
|
maxLineGap=min(width, height) // 20
|
|
)
|
|
|
|
vanishing_points = []
|
|
fov = None
|
|
|
|
if lines is not None and len(lines) > 3:
|
|
# Find the dominant vanishing point
|
|
vanishing_point, supporting_lines = self._find_dominant_vanishing_point(lines, (height, width))
|
|
|
|
if vanishing_point is not None:
|
|
vanishing_points.append(vanishing_point)
|
|
|
|
# Calculate FOV from vanishing point position
|
|
# The distance from principal point (image center) to vanishing point
|
|
# approximates the focal length in pixels
|
|
principal_point = np.array([width / 2.0, height / 2.0])
|
|
vp_array = np.array(vanishing_point)
|
|
|
|
# For horizontal FOV, use the horizontal distance
|
|
focal_length_estimate = abs(vp_array[0] - principal_point[0])
|
|
|
|
# If vanishing point is too close to center, use the Euclidean distance
|
|
if focal_length_estimate < width * 0.1:
|
|
focal_length_estimate = np.linalg.norm(vp_array - principal_point)
|
|
|
|
# Ensure reasonable focal length
|
|
focal_length_estimate = max(focal_length_estimate, width * 0.2)
|
|
|
|
# Calculate horizontal FOV from focal length
|
|
# hfov = 2 * arctan(width / (2 * focal_length))
|
|
fov = 2 * np.degrees(np.arctan(width / (2 * focal_length_estimate)))
|
|
|
|
# Clamp to reasonable range
|
|
fov = np.clip(fov, 15, 150)
|
|
|
|
# If no good vanishing point found, use a more conservative default
|
|
if fov is None:
|
|
# Default based on typical camera FOV (smartphone ~70°, DSLR ~50°, wide ~90°)
|
|
# Use aspect ratio as a hint
|
|
aspect_ratio = width / height
|
|
if aspect_ratio > 2.0: # Ultra-wide panorama
|
|
fov = 100.0
|
|
elif aspect_ratio > 1.7: # Wide aspect (16:9, etc.)
|
|
fov = 70.0
|
|
elif aspect_ratio > 1.2: # Standard aspect (4:3, 3:2)
|
|
fov = 55.0
|
|
else: # Square or portrait
|
|
fov = 50.0
|
|
|
|
return float(fov), vanishing_points
|
|
|
|
def _find_dominant_vanishing_point(
|
|
self,
|
|
lines: np.ndarray,
|
|
img_shape: Tuple[int, int]
|
|
) -> Tuple[Tuple[float, float], int]:
|
|
"""
|
|
Find the most dominant vanishing point using RANSAC-like clustering.
|
|
|
|
Returns:
|
|
Tuple of (vanishing_point, num_supporting_lines) or (None, 0) if none found
|
|
"""
|
|
height, width = img_shape
|
|
principal_point = np.array([width / 2.0, height / 2.0])
|
|
|
|
# Convert lines to point pairs and filter out near-horizontal lines
|
|
# (we want lines that converge, like roads, buildings)
|
|
line_list = []
|
|
for line in lines:
|
|
x1, y1, x2, y2 = line[0]
|
|
|
|
# Calculate line angle
|
|
dx = x2 - x1
|
|
dy = y2 - y1
|
|
angle = abs(np.degrees(np.arctan2(dy, dx)))
|
|
|
|
# Filter: keep lines that are NOT horizontal (skip 80-100 degrees from vertical)
|
|
# We want converging lines (vertical-ish or diagonal)
|
|
if not (10 <= angle <= 170): # Skip near-horizontal
|
|
continue
|
|
|
|
line_list.append(((float(x1), float(y1)), (float(x2), float(y2))))
|
|
|
|
if len(line_list) < 3:
|
|
return None, 0
|
|
|
|
# Find all pairwise intersections
|
|
intersections = []
|
|
intersection_line_pairs = []
|
|
|
|
for i in range(len(line_list)):
|
|
for j in range(i + 1, min(i + 50, len(line_list))): # Limit combinations for performance
|
|
pt = self._line_intersection(line_list[i], line_list[j])
|
|
if pt is not None:
|
|
# Only consider intersections within reasonable bounds
|
|
if (-width <= pt[0] <= 2*width) and (-height <= pt[1] <= 2*height):
|
|
intersections.append(pt)
|
|
intersection_line_pairs.append((i, j))
|
|
|
|
if len(intersections) < 3:
|
|
return None, 0
|
|
|
|
intersections = np.array(intersections)
|
|
|
|
# Cluster intersections to find dominant vanishing point
|
|
# Use a voting scheme: find the point with most nearby intersections
|
|
best_vp = None
|
|
best_support = 0
|
|
|
|
# Sample potential vanishing points
|
|
sample_indices = np.linspace(0, len(intersections)-1, min(20, len(intersections)), dtype=int)
|
|
|
|
for idx in sample_indices:
|
|
candidate = intersections[idx]
|
|
|
|
# Count how many intersections are near this candidate
|
|
distances = np.linalg.norm(intersections - candidate, axis=1)
|
|
|
|
# Dynamic threshold based on image size
|
|
threshold = max(width, height) * 0.15
|
|
support = np.sum(distances < threshold)
|
|
|
|
if support > best_support:
|
|
best_support = support
|
|
# Use median of supporting points for robustness
|
|
supporting_points = intersections[distances < threshold]
|
|
best_vp = tuple(np.median(supporting_points, axis=0))
|
|
|
|
# Require at least 3 supporting intersections
|
|
if best_support >= 3:
|
|
return best_vp, best_support
|
|
|
|
return None, 0
|
|
|
|
def _line_intersection(
|
|
self,
|
|
line1: Tuple[Tuple[int, int], Tuple[int, int]],
|
|
line2: Tuple[Tuple[int, int], Tuple[int, int]]
|
|
) -> Tuple[float, float]:
|
|
"""
|
|
Find intersection point of two lines.
|
|
"""
|
|
(x1, y1), (x2, y2) = line1
|
|
(x3, y3), (x4, y4) = line2
|
|
|
|
# Convert to float to avoid integer overflow
|
|
x1, y1, x2, y2 = float(x1), float(y1), float(x2), float(y2)
|
|
x3, y3, x4, y4 = float(x3), float(y3), float(x4), float(y4)
|
|
|
|
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
|
|
|
if abs(denom) < 1e-6: # Lines are parallel
|
|
return None
|
|
|
|
px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom
|
|
py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom
|
|
|
|
return (px, py)
|
|
|
|
def _visualize_results(
|
|
self,
|
|
img: np.ndarray,
|
|
horizon_lines: list,
|
|
vanishing_points: list,
|
|
tilt_angle: float,
|
|
fov_angle: float
|
|
) -> np.ndarray:
|
|
"""
|
|
Draw detected lines and vanishing points on the image.
|
|
"""
|
|
vis_img = img.copy()
|
|
height, width = img.shape[:2]
|
|
|
|
# Draw horizon lines in blue
|
|
for rho, theta in horizon_lines[:5]: # Draw top 5 lines
|
|
a = np.cos(theta)
|
|
b = np.sin(theta)
|
|
x0 = a * rho
|
|
y0 = b * rho
|
|
x1 = int(x0 + 2000 * (-b))
|
|
y1 = int(y0 + 2000 * (a))
|
|
x2 = int(x0 - 2000 * (-b))
|
|
y2 = int(y0 - 2000 * (a))
|
|
cv2.line(vis_img, (x1, y1), (x2, y2), (0, 0, 255), 2)
|
|
|
|
# Draw vanishing points in green
|
|
for vp in vanishing_points:
|
|
x, y = int(vp[0]), int(vp[1])
|
|
if 0 <= x < width and 0 <= y < height:
|
|
cv2.circle(vis_img, (x, y), 10, (0, 255, 0), -1)
|
|
cv2.circle(vis_img, (x, y), 15, (0, 255, 0), 2)
|
|
|
|
# Add text overlay with results
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
text_fov = f"FOV: {fov_angle:.1f} deg"
|
|
text_tilt = f"Tilt: {tilt_angle:.1f} deg"
|
|
|
|
# Draw text with background for visibility
|
|
cv2.rectangle(vis_img, (10, 10), (300, 80), (0, 0, 0), -1)
|
|
cv2.putText(vis_img, text_fov, (20, 40), font, 0.8, (255, 255, 255), 2)
|
|
cv2.putText(vis_img, text_tilt, (20, 70), font, 0.8, (255, 255, 255), 2)
|
|
|
|
return vis_img
|
|
|
|
|
|
class DepthFOVEstimatorNode:
|
|
"""
|
|
A ComfyUI node that estimates FOV and tilt from depth maps.
|
|
|
|
Uses depth information for more robust geometric analysis:
|
|
- Depth discontinuities for edge detection
|
|
- 3D structure analysis for vanishing points
|
|
- Depth-aware horizon detection
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
|
return {
|
|
"required": {
|
|
"depth_map": ("IMAGE",), # Accepts depth map from depth estimation nodes
|
|
"image": ("IMAGE",), # Original image for visualization
|
|
},
|
|
"optional": {
|
|
"depth_edge_threshold": ("FLOAT", {
|
|
"default": 0.1,
|
|
"min": 0.01,
|
|
"max": 1.0,
|
|
"step": 0.01,
|
|
"display": "number"
|
|
}),
|
|
"line_threshold": ("INT", {
|
|
"default": 50,
|
|
"min": 10,
|
|
"max": 300,
|
|
"step": 5,
|
|
"display": "number"
|
|
}),
|
|
"visualize": ("BOOLEAN", {
|
|
"default": True,
|
|
"display": "checkbox"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "FLOAT", "FLOAT", "STRING")
|
|
RETURN_NAMES = ("annotated_image", "fov_degrees", "tilt_degrees", "info")
|
|
FUNCTION = "estimate_fov_tilt_from_depth"
|
|
CATEGORY = "image/analysis"
|
|
|
|
def estimate_fov_tilt_from_depth(
|
|
self,
|
|
depth_map: torch.Tensor,
|
|
image: torch.Tensor,
|
|
depth_edge_threshold: float = 0.1,
|
|
line_threshold: int = 50,
|
|
visualize: bool = True
|
|
) -> Tuple[torch.Tensor, float, float, str]:
|
|
"""
|
|
Estimate FOV and tilt from depth map.
|
|
|
|
Args:
|
|
depth_map: Depth map tensor [B, H, W, C]
|
|
image: Original RGB image for visualization [B, H, W, C]
|
|
depth_edge_threshold: Threshold for depth discontinuities
|
|
line_threshold: Threshold for Hough line detection
|
|
visualize: Whether to draw detected features
|
|
|
|
Returns:
|
|
Tuple of (annotated_image, fov_degrees, tilt_degrees, info_string)
|
|
"""
|
|
batch_size = depth_map.shape[0]
|
|
results_images = []
|
|
results_fov = []
|
|
results_tilt = []
|
|
results_info = []
|
|
|
|
for i in range(batch_size):
|
|
# Process depth map
|
|
depth_np = depth_map[i].cpu().numpy()
|
|
img_np = image[i].cpu().numpy()
|
|
|
|
# Convert image from [0, 1] float to [0, 255] uint8
|
|
img_np = (img_np * 255).astype(np.uint8)
|
|
|
|
# Handle depth map format (could be 1 or 3 channels)
|
|
if depth_np.shape[-1] == 3:
|
|
# If 3 channels, take first channel (should all be same for grayscale depth)
|
|
depth_np = depth_np[:, :, 0]
|
|
elif depth_np.shape[-1] == 1:
|
|
depth_np = depth_np[:, :, 0]
|
|
|
|
# Normalize depth to [0, 1] if not already
|
|
if depth_np.max() > 1.0:
|
|
depth_np = depth_np / 255.0
|
|
|
|
# Estimate FOV from depth map
|
|
fov_angle, vanishing_points = self._estimate_fov_from_depth(
|
|
depth_np, depth_edge_threshold, line_threshold
|
|
)
|
|
|
|
# Estimate tilt from depth map
|
|
tilt_angle, horizon_lines = self._estimate_tilt_from_depth(
|
|
depth_np, depth_edge_threshold, fov_angle
|
|
)
|
|
|
|
# Create visualization if requested
|
|
if visualize:
|
|
vis_img = self._visualize_depth_results(
|
|
img_np.copy(), depth_np, horizon_lines, vanishing_points, tilt_angle, fov_angle
|
|
)
|
|
else:
|
|
vis_img = img_np
|
|
|
|
# Convert back to ComfyUI tensor format
|
|
vis_tensor = torch.from_numpy(vis_img.astype(np.float32) / 255.0)
|
|
results_images.append(vis_tensor)
|
|
results_fov.append(fov_angle)
|
|
results_tilt.append(tilt_angle)
|
|
|
|
# Create info string
|
|
info = f"FOV: {fov_angle:.2f}°, Tilt: {tilt_angle:.2f}° (depth-based)"
|
|
results_info.append(info)
|
|
|
|
# Stack batch results
|
|
output_image = torch.stack(results_images, dim=0)
|
|
avg_fov = float(np.mean(results_fov))
|
|
avg_tilt = float(np.mean(results_tilt))
|
|
combined_info = "\n".join(results_info)
|
|
|
|
return (output_image, avg_fov, avg_tilt, combined_info)
|
|
|
|
def _estimate_fov_from_depth(
|
|
self,
|
|
depth: np.ndarray,
|
|
edge_threshold: float,
|
|
line_threshold: int
|
|
) -> Tuple[float, list]:
|
|
"""
|
|
Estimate FOV using depth discontinuities and vanishing points.
|
|
|
|
Depth discontinuities represent real 3D edges in the scene,
|
|
providing cleaner geometric information than RGB edges.
|
|
"""
|
|
height, width = depth.shape
|
|
|
|
# Find depth discontinuities (edges in 3D structure)
|
|
depth_edges = self._detect_depth_discontinuities(depth, edge_threshold)
|
|
|
|
# Detect lines from depth edges
|
|
lines = cv2.HoughLinesP(
|
|
depth_edges,
|
|
rho=1,
|
|
theta=np.pi / 180,
|
|
threshold=line_threshold,
|
|
minLineLength=min(width, height) // 10,
|
|
maxLineGap=min(width, height) // 20
|
|
)
|
|
|
|
vanishing_points = []
|
|
fov = None
|
|
|
|
if lines is not None and len(lines) > 3:
|
|
# Find dominant vanishing point from depth-based lines
|
|
vanishing_point, supporting_lines = self._find_dominant_vanishing_point_from_lines(
|
|
lines, (height, width)
|
|
)
|
|
|
|
if vanishing_point is not None:
|
|
vanishing_points.append(vanishing_point)
|
|
|
|
# Calculate FOV from vanishing point
|
|
principal_point = np.array([width / 2.0, height / 2.0])
|
|
vp_array = np.array(vanishing_point)
|
|
|
|
# Use horizontal distance for horizontal FOV
|
|
focal_length_estimate = abs(vp_array[0] - principal_point[0])
|
|
|
|
# If VP is near center, use Euclidean distance
|
|
if focal_length_estimate < width * 0.1:
|
|
focal_length_estimate = np.linalg.norm(vp_array - principal_point)
|
|
|
|
# Ensure reasonable focal length
|
|
focal_length_estimate = max(focal_length_estimate, width * 0.3)
|
|
|
|
# Calculate horizontal FOV
|
|
fov = 2 * np.degrees(np.arctan(width / (2 * focal_length_estimate)))
|
|
fov = np.clip(fov, 15, 150)
|
|
|
|
# Fallback if no vanishing point found
|
|
if fov is None:
|
|
aspect_ratio = width / height
|
|
if aspect_ratio > 2.0:
|
|
fov = 100.0
|
|
elif aspect_ratio > 1.7:
|
|
fov = 70.0
|
|
elif aspect_ratio > 1.2:
|
|
fov = 55.0
|
|
else:
|
|
fov = 50.0
|
|
|
|
return float(fov), vanishing_points
|
|
|
|
def _estimate_tilt_from_depth(
|
|
self,
|
|
depth: np.ndarray,
|
|
edge_threshold: float,
|
|
fov_horizontal: float
|
|
) -> Tuple[float, list]:
|
|
"""
|
|
Estimate tilt using depth-based horizon detection.
|
|
|
|
The horizon in depth maps appears as a transition from near to far depths,
|
|
often more clearly visible than in RGB images.
|
|
"""
|
|
height, width = depth.shape
|
|
frame_center_y = height / 2.0
|
|
|
|
# Calculate vertical FOV
|
|
aspect_ratio = width / height
|
|
fov_vertical = 2 * np.degrees(np.arctan(np.tan(np.radians(fov_horizontal / 2)) / aspect_ratio))
|
|
|
|
# Find depth discontinuities
|
|
depth_edges = self._detect_depth_discontinuities(depth, edge_threshold)
|
|
|
|
# Detect horizontal lines (potential horizons)
|
|
lines = cv2.HoughLines(depth_edges, 1, np.pi / 180, int(width * 0.3))
|
|
|
|
horizon_lines = []
|
|
horizon_y_positions = []
|
|
|
|
if lines is not None:
|
|
for line in lines:
|
|
rho, theta = line[0]
|
|
angle_deg = np.degrees(theta)
|
|
|
|
# Filter for near-horizontal lines
|
|
if 75 <= angle_deg <= 105:
|
|
cos_theta = np.cos(theta)
|
|
sin_theta = np.sin(theta)
|
|
|
|
if abs(sin_theta) > 0.1:
|
|
x_center = width / 2.0
|
|
y_at_center = (rho - x_center * cos_theta) / sin_theta
|
|
|
|
if -height * 0.5 <= y_at_center <= height * 1.5:
|
|
horizon_lines.append((rho, theta))
|
|
horizon_y_positions.append(y_at_center)
|
|
|
|
# Calculate tilt from horizon position
|
|
if horizon_y_positions:
|
|
median_horizon_y = float(np.median(horizon_y_positions))
|
|
y_offset = median_horizon_y - frame_center_y
|
|
degrees_per_pixel = fov_vertical / height
|
|
tilt_angle = y_offset * degrees_per_pixel
|
|
else:
|
|
# Alternative: analyze depth gradient
|
|
# Lower half of image should generally have nearer depths (ground)
|
|
# Upper half should have farther depths (sky/background)
|
|
tilt_angle = self._estimate_tilt_from_depth_gradient(depth, fov_vertical)
|
|
|
|
return tilt_angle, horizon_lines
|
|
|
|
def _detect_depth_discontinuities(
|
|
self,
|
|
depth: np.ndarray,
|
|
threshold: float
|
|
) -> np.ndarray:
|
|
"""
|
|
Detect depth discontinuities (3D edges) in the depth map.
|
|
|
|
Depth discontinuities represent real boundaries between objects,
|
|
independent of texture or lighting.
|
|
"""
|
|
# Normalize depth to [0, 1]
|
|
depth_normalized = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
|
|
|
|
# Convert to uint8 for edge detection
|
|
depth_uint8 = (depth_normalized * 255).astype(np.uint8)
|
|
|
|
# Apply bilateral filter to preserve edges while smoothing
|
|
depth_filtered = cv2.bilateralFilter(depth_uint8, 5, 50, 50)
|
|
|
|
# Calculate depth gradients
|
|
grad_x = cv2.Sobel(depth_filtered, cv2.CV_64F, 1, 0, ksize=3)
|
|
grad_y = cv2.Sobel(depth_filtered, cv2.CV_64F, 0, 1, ksize=3)
|
|
gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2)
|
|
|
|
# Normalize gradient
|
|
gradient_magnitude = (gradient_magnitude / gradient_magnitude.max() * 255).astype(np.uint8)
|
|
|
|
# Threshold to get binary edge map
|
|
threshold_uint8 = int(threshold * 255)
|
|
_, edges = cv2.threshold(gradient_magnitude, threshold_uint8, 255, cv2.THRESH_BINARY)
|
|
|
|
# Morphological operations to clean up edges
|
|
kernel = np.ones((3, 3), np.uint8)
|
|
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
|
|
|
|
return edges
|
|
|
|
def _estimate_tilt_from_depth_gradient(
|
|
self,
|
|
depth: np.ndarray,
|
|
fov_vertical: float
|
|
) -> float:
|
|
"""
|
|
Estimate tilt from overall depth gradient when no clear horizon is found.
|
|
"""
|
|
height, width = depth.shape
|
|
|
|
# Divide image into top and bottom halves
|
|
top_half = depth[:height//2, :]
|
|
bottom_half = depth[height//2:, :]
|
|
|
|
# Calculate median depths
|
|
top_median = np.median(top_half)
|
|
bottom_median = np.median(bottom_half)
|
|
|
|
# If bottom is significantly nearer than top, camera is level or tilted down
|
|
# If top is nearer, camera is tilted up significantly
|
|
depth_ratio = (top_median - bottom_median) / (top_median + bottom_median + 1e-8)
|
|
|
|
# Estimate tilt angle (heuristic)
|
|
# Positive depth_ratio means top is farther (normal/level view)
|
|
# Negative means top is nearer (looking up at something)
|
|
tilt_angle = -depth_ratio * 30.0 # Scale factor is heuristic
|
|
|
|
return np.clip(tilt_angle, -45.0, 45.0)
|
|
|
|
def _find_dominant_vanishing_point_from_lines(
|
|
self,
|
|
lines: np.ndarray,
|
|
img_shape: Tuple[int, int]
|
|
) -> Tuple[Tuple[float, float], int]:
|
|
"""
|
|
Find dominant vanishing point from depth-based lines.
|
|
Similar to RGB version but optimized for depth discontinuities.
|
|
"""
|
|
height, width = img_shape
|
|
|
|
# Filter for non-horizontal lines
|
|
line_list = []
|
|
for line in lines:
|
|
x1, y1, x2, y2 = line[0]
|
|
dx = x2 - x1
|
|
dy = y2 - y1
|
|
angle = abs(np.degrees(np.arctan2(dy, dx)))
|
|
|
|
# Skip near-horizontal lines
|
|
if not (10 <= angle <= 170):
|
|
continue
|
|
|
|
line_list.append(((float(x1), float(y1)), (float(x2), float(y2))))
|
|
|
|
if len(line_list) < 3:
|
|
return None, 0
|
|
|
|
# Find intersections
|
|
intersections = []
|
|
for i in range(len(line_list)):
|
|
for j in range(i + 1, min(i + 50, len(line_list))):
|
|
pt = self._line_intersection(line_list[i], line_list[j])
|
|
if pt is not None:
|
|
if (-width <= pt[0] <= 2*width) and (-height <= pt[1] <= 2*height):
|
|
intersections.append(pt)
|
|
|
|
if len(intersections) < 3:
|
|
return None, 0
|
|
|
|
intersections = np.array(intersections)
|
|
|
|
# Find best vanishing point through clustering
|
|
best_vp = None
|
|
best_support = 0
|
|
|
|
sample_indices = np.linspace(0, len(intersections)-1, min(20, len(intersections)), dtype=int)
|
|
|
|
for idx in sample_indices:
|
|
candidate = intersections[idx]
|
|
distances = np.linalg.norm(intersections - candidate, axis=1)
|
|
threshold = max(width, height) * 0.15
|
|
support = np.sum(distances < threshold)
|
|
|
|
if support > best_support:
|
|
best_support = support
|
|
supporting_points = intersections[distances < threshold]
|
|
best_vp = tuple(np.median(supporting_points, axis=0))
|
|
|
|
if best_support >= 3:
|
|
return best_vp, best_support
|
|
|
|
return None, 0
|
|
|
|
def _line_intersection(
|
|
self,
|
|
line1: Tuple[Tuple[float, float], Tuple[float, float]],
|
|
line2: Tuple[Tuple[float, float], Tuple[float, float]]
|
|
) -> Tuple[float, float]:
|
|
"""Find intersection point of two lines."""
|
|
(x1, y1), (x2, y2) = line1
|
|
(x3, y3), (x4, y4) = line2
|
|
|
|
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
|
|
|
if abs(denom) < 1e-6:
|
|
return None
|
|
|
|
px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom
|
|
py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom
|
|
|
|
return (px, py)
|
|
|
|
def _visualize_depth_results(
|
|
self,
|
|
img: np.ndarray,
|
|
depth: np.ndarray,
|
|
horizon_lines: list,
|
|
vanishing_points: list,
|
|
tilt_angle: float,
|
|
fov_angle: float
|
|
) -> np.ndarray:
|
|
"""
|
|
Visualize depth-based analysis results on the original image.
|
|
"""
|
|
vis_img = img.copy()
|
|
height, width = img.shape[:2]
|
|
|
|
# Draw horizon lines in blue
|
|
for rho, theta in horizon_lines[:5]:
|
|
a = np.cos(theta)
|
|
b = np.sin(theta)
|
|
x0 = a * rho
|
|
y0 = b * rho
|
|
x1 = int(x0 + 2000 * (-b))
|
|
y1 = int(y0 + 2000 * (a))
|
|
x2 = int(x0 - 2000 * (-b))
|
|
y2 = int(y0 - 2000 * (a))
|
|
cv2.line(vis_img, (x1, y1), (x2, y2), (0, 0, 255), 2)
|
|
|
|
# Draw vanishing points in green
|
|
for vp in vanishing_points:
|
|
x, y = int(vp[0]), int(vp[1])
|
|
if 0 <= x < width and 0 <= y < height:
|
|
cv2.circle(vis_img, (x, y), 10, (0, 255, 0), -1)
|
|
cv2.circle(vis_img, (x, y), 15, (0, 255, 0), 2)
|
|
|
|
# Add text overlay
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
text_fov = f"FOV: {fov_angle:.1f} deg (depth)"
|
|
text_tilt = f"Tilt: {tilt_angle:.1f} deg"
|
|
|
|
cv2.rectangle(vis_img, (10, 10), (340, 80), (0, 0, 0), -1)
|
|
cv2.putText(vis_img, text_fov, (20, 40), font, 0.7, (255, 255, 255), 2)
|
|
cv2.putText(vis_img, text_tilt, (20, 70), font, 0.7, (255, 255, 255), 2)
|
|
|
|
return vis_img
|
|
|
|
|
|
# ComfyUI node registration
|
|
NODE_CLASS_MAPPINGS = {
|
|
"FOVEstimator": FOVEstimatorNode,
|
|
"DepthFOVEstimator": DepthFOVEstimatorNode
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"FOVEstimator": "FOV & Tilt Estimator (RGB)",
|
|
"DepthFOVEstimator": "FOV & Tilt Estimator (Depth)"
|
|
}
|