2308 lines
112 KiB
Python
2308 lines
112 KiB
Python
"""
|
|
Luminance Stack Processor - Professional ComfyUI Custom Nodes
|
|
Implements HDR processing using the Debevec Algorithm for multiple exposure fusion
|
|
|
|
Author: Sumit Chatterjee
|
|
Version: 1.1.8
|
|
Semantic Versioning: MAJOR.MINOR.PATCH
|
|
|
|
|
|
"""
|
|
|
|
import numpy as np
|
|
import torch
|
|
import cv2
|
|
from typing import Tuple, List, Optional
|
|
import logging
|
|
import os
|
|
|
|
# Try to import HDRutils for alternative HDR processing
|
|
try:
|
|
import HDRutils
|
|
HDRUTILS_AVAILABLE = True
|
|
except ImportError:
|
|
HDRUTILS_AVAILABLE = False
|
|
|
|
# Try to import imageio for HDR/EXR support
|
|
try:
|
|
import imageio.v3 as iio
|
|
IMAGEIO_AVAILABLE = True
|
|
except ImportError:
|
|
try:
|
|
import imageio as iio
|
|
IMAGEIO_AVAILABLE = True
|
|
except ImportError:
|
|
IMAGEIO_AVAILABLE = False
|
|
|
|
|
|
# Set up logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def tensor_to_cv2(tensor: torch.Tensor) -> np.ndarray:
|
|
"""Convert ComfyUI tensor to OpenCV format for HDR processing
|
|
|
|
IMPORTANT: OpenCV's Debevec and Robertson algorithms expect 8-bit sRGB images as input.
|
|
They internally recover the camera response function, so we should NOT pre-linearize the images.
|
|
"""
|
|
# ComfyUI tensors are typically [B, H, W, C] in 0-1 range
|
|
if len(tensor.shape) == 4:
|
|
tensor = tensor.squeeze(0) # Remove batch dimension
|
|
|
|
# Convert to numpy
|
|
image = tensor.cpu().numpy()
|
|
|
|
# Convert to 8-bit sRGB (no gamma correction - Debevec/Robertson expect sRGB input)
|
|
image_8bit = np.clip(image * 255.0, 0, 255).astype(np.uint8)
|
|
logger.info(f"Converted to OpenCV: shape={image_8bit.shape}, dtype={image_8bit.dtype}, range=[{image_8bit.min()}, {image_8bit.max()}]")
|
|
|
|
return image_8bit
|
|
|
|
|
|
def cv2_to_tensor(hdr_image: np.ndarray, output_16bit_linear: bool = True, algorithm_hint: str = "unknown") -> torch.Tensor:
|
|
"""Convert OpenCV HDR image to ComfyUI tensor format - VFX pipeline friendly"""
|
|
|
|
if output_16bit_linear:
|
|
logger.info(f"HDR processing ({algorithm_hint}):")
|
|
logger.info(f" Input range: [{hdr_image.min():.6f}, {hdr_image.max():.6f}]")
|
|
|
|
# VFX PIPELINE APPROACH: Different scaling philosophy per algorithm
|
|
if algorithm_hint == "radiance_fusion":
|
|
# Radiance Fusion: Perfect HDR preservation with Nuke-style operations
|
|
# The plus/average operations already create optimal HDR scaling
|
|
hdr_linear = hdr_image
|
|
logger.info(f" Radiance Fusion: Direct pass-through (Nuke-style HDR preservation)")
|
|
|
|
elif algorithm_hint == "natural_blend":
|
|
# Natural Blend: Preserve EV0 appearance exactly - NO SCALING
|
|
# The algorithm already provides the correctly scaled values
|
|
hdr_linear = hdr_image
|
|
logger.info(f" Natural Blend: Direct pass-through (preserves EV0 appearance)")
|
|
|
|
elif algorithm_hint == "detail_injection":
|
|
# Detail Injection: Preserve the exact values from the algorithm
|
|
# Already in linear space with HDR values properly mapped
|
|
hdr_linear = hdr_image
|
|
logger.info(f" Detail Injection: Direct pass-through (linear space with HDR detail)")
|
|
|
|
elif algorithm_hint == "mertens":
|
|
# Mertens: Medium HDR range, values 1-10 range
|
|
p85 = np.percentile(hdr_image, 85.0)
|
|
if p85 > 0:
|
|
hdr_linear = hdr_image * (3.0 / p85)
|
|
else:
|
|
hdr_linear = hdr_image * 3.0
|
|
hdr_linear = np.clip(hdr_linear, 0.0, 12.0)
|
|
|
|
elif algorithm_hint in ["debevec", "robertson"]:
|
|
# PURE HDR: Direct pass-through of calibrated linear radiance
|
|
# No post-processing - the calibrated exposure times handle everything
|
|
hdr_linear = hdr_image
|
|
logger.info(f" Debevec/Robertson: Direct pass-through (NO post-processing)")
|
|
logger.info(f" Pure linear radiance from calibrated exposure times")
|
|
|
|
else:
|
|
# Unknown algorithm - conservative approach
|
|
hdr_linear = np.clip(hdr_image * 2.0, 0.0, 10.0)
|
|
|
|
logger.info(f" Final HDR range: [{hdr_linear.min():.6f}, {hdr_linear.max():.6f}]")
|
|
logger.info(f" Max value: {hdr_linear.max():.2f} (VFX raw data)")
|
|
|
|
# Convert to ComfyUI format: [1, H, W, C] - NO NORMALIZATION!
|
|
if len(hdr_linear.shape) == 3:
|
|
tensor = torch.from_numpy(hdr_linear.astype(np.float32)).unsqueeze(0)
|
|
else:
|
|
tensor = torch.from_numpy(hdr_linear.astype(np.float32))
|
|
|
|
return tensor.float()
|
|
|
|
else:
|
|
# Standard 8-bit conversion (fallback)
|
|
image_8bit = np.clip(hdr_image * 255.0, 0, 255).astype(np.uint8)
|
|
normalized = image_8bit.astype(np.float32) / 255.0
|
|
|
|
if len(normalized.shape) == 3:
|
|
tensor = torch.from_numpy(normalized).unsqueeze(0)
|
|
else:
|
|
tensor = torch.from_numpy(normalized)
|
|
|
|
return tensor.float()
|
|
|
|
|
|
class DebevecHDRProcessor:
|
|
"""Core HDR processing using multiple algorithms"""
|
|
|
|
def __init__(self):
|
|
# CRITICAL: Much higher lambda for ultra-smooth response curves
|
|
# Default is ~10, we use 100+ for AI-generated images to prevent quantization
|
|
self.calibrator = cv2.createCalibrateDebevec(samples=200, lambda_=100.0)
|
|
self.merge_debevec = cv2.createMergeDebevec()
|
|
# Alternative algorithms
|
|
self.merge_mertens = cv2.createMergeMertens()
|
|
self.merge_robertson = cv2.createMergeRobertson()
|
|
self.calibrator_robertson = cv2.createCalibrateRobertson()
|
|
logger.info("Debevec calibrator configured with ultra-high smoothness (lambda=100.0, samples=200)")
|
|
|
|
def _apply_gradient_adaptive_dithering(self, hdr_image: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Apply dithering ONLY to luminance in smooth gradients - preserves color perfectly
|
|
This prevents color shifts while breaking up banding
|
|
|
|
This is the professional approach used in tools like LuminanceHDR and Photoshop
|
|
|
|
Args:
|
|
hdr_image: HDR image in linear space (float32, BGR)
|
|
|
|
Returns:
|
|
HDR image with gradient-adaptive dithering (edges preserved, color preserved)
|
|
"""
|
|
logger.info("Applying luminance-based gradient-adaptive dithering (color-preserving)...")
|
|
|
|
# Calculate luminance (Rec. 709)
|
|
# Note: hdr_image is in BGR format from OpenCV
|
|
luminance = (0.0722 * hdr_image[:, :, 0] + # B
|
|
0.7152 * hdr_image[:, :, 1] + # G
|
|
0.2126 * hdr_image[:, :, 2]) # R
|
|
|
|
# Calculate gradient magnitude on luminance
|
|
grad_x = cv2.Sobel(luminance, cv2.CV_32F, 1, 0, ksize=3)
|
|
grad_y = cv2.Sobel(luminance, cv2.CV_32F, 0, 1, ksize=3)
|
|
gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2)
|
|
|
|
# Normalize gradient
|
|
if gradient_magnitude.max() > 0:
|
|
gradient_norm = gradient_magnitude / gradient_magnitude.max()
|
|
else:
|
|
gradient_norm = gradient_magnitude
|
|
|
|
# Smooth gradients get dithering, edges don't
|
|
dither_strength_map = 1.0 - gradient_norm
|
|
dither_strength_map = cv2.GaussianBlur(dither_strength_map, (5, 5), 1.0)
|
|
|
|
# Generate dither for LUMINANCE only (single noise pattern for all channels)
|
|
# Much subtler strength to avoid visible noise
|
|
adaptive_strength = dither_strength_map * (np.abs(luminance) + 0.01) * 0.0008 # 0.08% instead of 0.3%
|
|
|
|
# Single noise pattern
|
|
noise = np.random.normal(0, 1, luminance.shape).astype(np.float32)
|
|
|
|
# Apply noise to luminance
|
|
luminance_dithered = luminance + noise * adaptive_strength
|
|
|
|
# Calculate luminance change ratio
|
|
# Avoid division by zero
|
|
safe_luminance = np.maximum(luminance, 1e-6)
|
|
luma_ratio = luminance_dithered / safe_luminance
|
|
|
|
# Apply same ratio to all color channels - preserves color perfectly!
|
|
result = hdr_image.copy()
|
|
for c in range(3):
|
|
result[:, :, c] = hdr_image[:, :, c] * luma_ratio
|
|
|
|
# Statistics
|
|
smooth_pixels = np.sum(dither_strength_map > 0.5)
|
|
total_pixels = dither_strength_map.size
|
|
logger.info(f" Luminance dithering: {smooth_pixels}/{total_pixels} smooth pixels ({100*smooth_pixels/total_pixels:.1f}%)")
|
|
logger.info(f" Color preservation: 100% (luminance-only dithering)")
|
|
logger.info(f" Dither strength: 0.08% (very subtle, imperceptible)")
|
|
|
|
return result.astype(np.float32)
|
|
|
|
def _compute_exposure_ratio_srgb(self, reference: np.ndarray, target: np.ndarray) -> float:
|
|
"""
|
|
Compute the brightness ratio between two exposures IN sRGB SPACE
|
|
This matches how Debevec sees the images (8-bit sRGB, not linear)
|
|
|
|
Args:
|
|
reference: Reference image in sRGB space (float32 0-1, BGR)
|
|
target: Target image to compare in sRGB space (float32 0-1, BGR)
|
|
|
|
Returns:
|
|
float: Brightness ratio in sRGB space (target/reference)
|
|
"""
|
|
# Convert to grayscale for analysis
|
|
ref_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY)
|
|
tgt_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
|
|
|
|
# Create mask for well-exposed regions (0.2 to 0.8 in reference)
|
|
# These regions are least likely to be clipped in either image
|
|
mask = (ref_gray > 0.2) & (ref_gray < 0.8) & (tgt_gray > 0.01) & (tgt_gray < 0.99)
|
|
|
|
if np.sum(mask) < 1000: # Not enough valid pixels
|
|
logger.warning("Insufficient well-exposed pixels for calibration, using full image")
|
|
mask = np.ones_like(ref_gray, dtype=bool)
|
|
|
|
# Compute ratio using median (robust to outliers)
|
|
ref_values = ref_gray[mask]
|
|
tgt_values = tgt_gray[mask]
|
|
|
|
# Ratio in sRGB space
|
|
ratios = tgt_values / (ref_values + 1e-8)
|
|
median_ratio = np.median(ratios)
|
|
|
|
# Log for debugging
|
|
logger.info(f" Valid pixels: {np.sum(mask)}, sRGB ratio range: [{np.percentile(ratios, 10):.3f}, {np.percentile(ratios, 90):.3f}]")
|
|
|
|
return median_ratio
|
|
|
|
def _calibrate_exposure_times(self, images: List[np.ndarray], nominal_times: List[float]) -> List[float]:
|
|
"""
|
|
Analyze AI-generated brackets and compute corrected exposure times
|
|
that match their actual brightness relationships IN sRGB SPACE
|
|
|
|
CRITICAL: Calibrates in sRGB space (NOT linear) because Debevec receives
|
|
8-bit sRGB images and estimates the response curve internally.
|
|
|
|
Args:
|
|
images: List of AI-generated exposure brackets (8-bit BGR)
|
|
nominal_times: Theoretical exposure times [t+2, t0, t-2, ...]
|
|
|
|
Returns:
|
|
Calibrated exposure times that match AI's actual behavior in sRGB space
|
|
"""
|
|
logger.info("=" * 60)
|
|
logger.info("ADAPTIVE EXPOSURE CALIBRATION (sRGB SPACE)")
|
|
logger.info("Analyzing AI-generated brackets as Debevec sees them")
|
|
logger.info("=" * 60)
|
|
|
|
# DON'T convert to linear - work in sRGB space like Debevec does!
|
|
# Just normalize to 0-1 range
|
|
srgb_images = [img.astype(np.float32) / 255.0 for img in images]
|
|
|
|
# Find EV0 (reference exposure) - should be in the middle
|
|
ev0_idx = len(images) // 2
|
|
ev0_srgb = srgb_images[ev0_idx]
|
|
|
|
logger.info(f"Reference image (EV0): Index {ev0_idx}, Nominal time: {nominal_times[ev0_idx]:.6f}s")
|
|
logger.info(f"Working in sRGB space (matching Debevec's input)")
|
|
|
|
# Compute intensity statistics for each image
|
|
calibrated_times = []
|
|
|
|
for i, img_srgb in enumerate(srgb_images):
|
|
if i == ev0_idx:
|
|
# Reference exposure - keep as is
|
|
calibrated_times.append(nominal_times[ev0_idx])
|
|
logger.info(f"Image {i} (EV0 reference): time={nominal_times[ev0_idx]:.6f}s")
|
|
continue
|
|
|
|
# Analyze the brightness relationship in sRGB space
|
|
logger.info(f"Calibrating Image {i}:")
|
|
srgb_ratio = self._compute_exposure_ratio_srgb(ev0_srgb, img_srgb)
|
|
|
|
# For AI-generated images, use a hybrid approach:
|
|
# Apply a gentler power (1.8 instead of 2.2) as AI doesn't follow perfect sRGB gamma
|
|
# This accounts for some gamma relationship without overcorrecting
|
|
time_ratio = srgb_ratio ** 1.8
|
|
calibrated_time = nominal_times[ev0_idx] * time_ratio
|
|
calibrated_times.append(calibrated_time)
|
|
|
|
expected_ratio = nominal_times[i] / nominal_times[ev0_idx]
|
|
adjustment_factor = calibrated_time / nominal_times[i]
|
|
|
|
logger.info(f" sRGB brightness ratio: {srgb_ratio:.3f}")
|
|
logger.info(f" Exposure time ratio (^1.8): {time_ratio:.3f}")
|
|
logger.info(f" Expected time ratio: {expected_ratio:.3f}")
|
|
logger.info(f" Nominal time: {nominal_times[i]:.6f}s → Calibrated time: {calibrated_time:.6f}s")
|
|
logger.info(f" Adjustment factor: {adjustment_factor:.3f}x")
|
|
|
|
logger.info("=" * 60)
|
|
logger.info(f"CALIBRATION SUMMARY (sRGB-space calibration):")
|
|
logger.info(f" Original times: {[f'{t:.6f}' for t in nominal_times]}")
|
|
logger.info(f" Calibrated times: {[f'{t:.6f}' for t in calibrated_times]}")
|
|
logger.info(f" Adjustment factors: {[f'{calibrated_times[i]/nominal_times[i]:.3f}x' for i in range(len(nominal_times))]}")
|
|
logger.info(f" EV0 anchors absolute scale at {nominal_times[ev0_idx]:.6f}s")
|
|
logger.info("=" * 60)
|
|
|
|
return calibrated_times
|
|
|
|
def process_hdr(self, images: List[np.ndarray], exposure_times: List[float],
|
|
algorithm: str = "detail_injection", auto_calibrate: bool = True,
|
|
debevec_exposure_compensation: float = -8.0,
|
|
debevec_anti_banding: bool = True) -> np.ndarray:
|
|
"""
|
|
Process multiple exposure images using various HDR algorithms
|
|
|
|
VFX PIPELINE NOTE:
|
|
- Debevec/Robertson expect 8-bit sRGB input (NOT linearized)
|
|
- They output linear radiance values (physical light intensity)
|
|
- The output will look flat/desaturated - this is CORRECT for VFX
|
|
|
|
Args:
|
|
images: List of 8-bit sRGB images (0-255 range) - DO NOT pre-linearize!
|
|
exposure_times: List of exposure times in seconds
|
|
algorithm: HDR algorithm to use:
|
|
- "radiance_fusion": Nuke-style plus/average HDR fusion (NEW DEFAULT!)
|
|
- "detail_injection": AI-aware detail injection with sRGB->linear conversion
|
|
- "natural_blend": Preserves EV0 look with enhanced range
|
|
- "mertens": Exposure fusion (display-ready)
|
|
- "debevec": True HDR recovery (flat/linear for VFX)
|
|
- "robertson": Alternative HDR recovery (flat/linear for VFX)
|
|
auto_calibrate: Enable adaptive calibration for AI-generated brackets (debevec/robertson only)
|
|
Analyzes actual brightness relationships and corrects exposure times
|
|
debevec_exposure_compensation: Exposure compensation in stops for debevec/robertson output
|
|
Default: -8.0 (optimal for AI-generated brackets with sRGB calibration)
|
|
Adjust if output is too bright/dark
|
|
debevec_anti_banding: Apply optional gradient-adaptive dithering to reduce banding
|
|
Default: True. Disable for pure lambda=100 smoothness with no color changes.
|
|
|
|
Returns:
|
|
HDR merged image in linear radiance space (float32)
|
|
- For Debevec/Robertson: Raw linear radiance (can exceed 1.0)
|
|
- For Mertens/Natural: Display-oriented fusion (0-1 range typical)
|
|
"""
|
|
try:
|
|
# Validate input format - OpenCV HDR functions require 8-bit input
|
|
processed_images = []
|
|
for i, img in enumerate(images):
|
|
if img.dtype != np.uint8:
|
|
logger.warning(f"Image {i} is not 8-bit (dtype: {img.dtype}), this may cause issues")
|
|
|
|
# Handle color channels properly
|
|
if len(img.shape) == 3 and img.shape[2] == 3: # 3-channel image
|
|
# CRITICAL FIX: ALL OpenCV functions work with BGR internally
|
|
# ComfyUI provides RGB, so we MUST convert for ALL algorithms
|
|
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
|
processed_images.append(img_bgr)
|
|
logger.info(f"Converting RGB to BGR for {algorithm} (OpenCV standard)")
|
|
else:
|
|
logger.error(f"Image {i} has invalid shape: {img.shape}")
|
|
raise ValueError(f"Image must be 3-channel RGB, got shape: {img.shape}")
|
|
|
|
# Convert exposure times to numpy array
|
|
times = np.array(exposure_times, dtype=np.float32)
|
|
|
|
# ADAPTIVE AUTO-CALIBRATION for AI-generated images
|
|
# CRITICAL: Only apply for debevec and robertson algorithms
|
|
if auto_calibrate:
|
|
if algorithm in ["debevec", "robertson"]:
|
|
logger.info("🔬 AUTO-CALIBRATION ENABLED for AI-generated brackets")
|
|
times_calibrated = np.array(self._calibrate_exposure_times(processed_images, times.tolist()), dtype=np.float32)
|
|
times = times_calibrated
|
|
else:
|
|
logger.info(f"⚠️ Auto-calibrate is enabled but has NO EFFECT on '{algorithm}' mode (only works with debevec/robertson)")
|
|
else:
|
|
logger.info("Auto-calibration DISABLED")
|
|
|
|
logger.info(f"Processing {len(processed_images)} images with {algorithm} algorithm")
|
|
logger.info(f"Exposure times: {times}")
|
|
logger.info(f"Image formats: {[img.shape for img in processed_images]}")
|
|
logger.info(f"Image dtypes: {[img.dtype for img in processed_images]}")
|
|
|
|
# Process using selected algorithm
|
|
if algorithm == "mertens":
|
|
# Mertens Exposure Fusion - uses original gamma, no correction needed
|
|
logger.info("Using Mertens Exposure Fusion algorithm...")
|
|
hdr_radiance = self.merge_mertens.process(processed_images)
|
|
# Mertens output is typically in 0-1 range, gently scale for HDR
|
|
if hdr_radiance.max() <= 1.0:
|
|
hdr_radiance = hdr_radiance * 1.5 # Gentle boost, preserve contrast
|
|
|
|
elif algorithm == "radiance_fusion":
|
|
# Radiance Fusion - Nuke-inspired HDR blending (NEW DEFAULT!)
|
|
logger.info("Using Radiance Fusion - Nuke-style plus/average HDR blending...")
|
|
hdr_radiance = self._radiance_fusion(processed_images, times)
|
|
|
|
elif algorithm == "natural_blend":
|
|
# Natural Blend - maintains EV0 appearance with enhanced dynamic range
|
|
logger.info("Using Natural Blend exposure blending...")
|
|
hdr_radiance = self._blend_ev0_preserving(processed_images, times)
|
|
|
|
elif algorithm == "detail_injection":
|
|
# Detail Injection - AI-aware HDR detail injection
|
|
logger.info("Using Detail Injection - AI-aware HDR processing...")
|
|
# Always output linear HDR for proper EXR export
|
|
hdr_radiance = self._detail_injection(processed_images, times, output_mode="linear")
|
|
|
|
elif algorithm == "hdrutils" and HDRUTILS_AVAILABLE:
|
|
# Use HDRutils library for HDR merging
|
|
logger.info("Using HDRutils library for HDR merging...")
|
|
hdr_radiance = self._merge_with_hdrutils(processed_images, times)
|
|
|
|
elif algorithm == "robertson":
|
|
# Robertson algorithm - Pure HDR recovery with exposure compensation
|
|
logger.info("Using Robertson algorithm - Pure HDR mode...")
|
|
response = self.calibrator_robertson.process(processed_images, times)
|
|
hdr_radiance = self.merge_robertson.process(processed_images, times, response)
|
|
|
|
logger.info(f"Robertson raw output: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
|
|
|
|
# Apply exposure compensation
|
|
if debevec_exposure_compensation != 0.0:
|
|
compensation_factor = 2.0 ** debevec_exposure_compensation
|
|
hdr_radiance = hdr_radiance * compensation_factor
|
|
logger.info(f"Applied exposure compensation: {debevec_exposure_compensation:+.1f} stops (factor: {compensation_factor:.6f}x)")
|
|
logger.info(f"Robertson compensated output: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
|
|
|
|
# Apply gradient-adaptive dithering if anti-banding is enabled
|
|
if debevec_anti_banding:
|
|
hdr_radiance = self._apply_gradient_adaptive_dithering(hdr_radiance)
|
|
|
|
logger.info(f"Robertson mean radiance: {hdr_radiance.mean():.6f}")
|
|
|
|
else: # Default to Debevec - Pure HDR recovery with exposure compensation
|
|
# Estimate camera response function using Debevec method
|
|
logger.info("Using Debevec algorithm - Pure HDR mode...")
|
|
|
|
# Use higher smoothness for response curve (lambda=50.0)
|
|
response = self.calibrator.process(processed_images, times)
|
|
logger.info(f"Response function shape: {response.shape}")
|
|
|
|
# Merge images into HDR using Debevec algorithm
|
|
# Debevec outputs linear radiance values (already in linear space)
|
|
hdr_radiance = self.merge_debevec.process(processed_images, times, response)
|
|
|
|
logger.info(f"Debevec raw output: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
|
|
|
|
# Apply exposure compensation
|
|
if debevec_exposure_compensation != 0.0:
|
|
compensation_factor = 2.0 ** debevec_exposure_compensation
|
|
hdr_radiance = hdr_radiance * compensation_factor
|
|
logger.info(f"Applied exposure compensation: {debevec_exposure_compensation:+.1f} stops (factor: {compensation_factor:.6f}x)")
|
|
logger.info(f"Debevec compensated output: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
|
|
|
|
# Optional gradient-adaptive dithering (controlled by debevec_anti_banding parameter)
|
|
# ONLY apply if user enables it - default relies on lambda=100 smoothness alone
|
|
if debevec_anti_banding:
|
|
logger.info("Applying optional gradient-adaptive dithering...")
|
|
hdr_radiance = self._apply_gradient_adaptive_dithering(hdr_radiance)
|
|
else:
|
|
logger.info("Dithering disabled - relying on ultra-smooth response curve (lambda=100) only")
|
|
|
|
logger.info(f"Debevec mean radiance: {hdr_radiance.mean():.6f}")
|
|
|
|
# Validate HDR output
|
|
if hdr_radiance is None or hdr_radiance.size == 0:
|
|
raise ValueError("HDR merge produced empty result")
|
|
|
|
logger.info(f"HDR merge completed with {algorithm} algorithm:")
|
|
logger.info(f" Output shape: {hdr_radiance.shape}")
|
|
logger.info(f" Output dtype: {hdr_radiance.dtype}")
|
|
logger.info(f" Value range: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
|
|
logger.info(f" Mean value: {hdr_radiance.mean():.6f}")
|
|
|
|
# Check for valid HDR data
|
|
if np.all(hdr_radiance == 0):
|
|
raise ValueError("HDR merge produced all-zero result")
|
|
|
|
# CRITICAL: Convert BGR back to RGB for ALL algorithms
|
|
# All OpenCV functions output BGR, but ComfyUI needs RGB
|
|
if len(hdr_radiance.shape) == 3 and hdr_radiance.shape[2] == 3:
|
|
hdr_radiance = cv2.cvtColor(hdr_radiance, cv2.COLOR_BGR2RGB)
|
|
logger.info("Converting BGR back to RGB for ComfyUI output")
|
|
|
|
# The result is already in linear colorspace - preserve HDR data
|
|
return hdr_radiance.astype(np.float32)
|
|
|
|
except Exception as e:
|
|
logger.error(f"HDR processing error: {str(e)}")
|
|
logger.error(f"Image count: {len(images)}")
|
|
logger.error(f"Image shapes: {[img.shape if img is not None else 'None' for img in images]}")
|
|
logger.error(f"Exposure times: {exposure_times}")
|
|
|
|
# Fallback: return the middle exposure image in linear space
|
|
if images:
|
|
middle_idx = len(images) // 2
|
|
fallback = images[middle_idx].astype(np.float32) / 255.0
|
|
# Convert back to linear space (reverse gamma correction)
|
|
fallback_linear = np.where(fallback <= 0.04045,
|
|
fallback / 12.92,
|
|
np.power((fallback + 0.055) / 1.055, 2.4))
|
|
logger.info(f"Using fallback image (index {middle_idx})")
|
|
return fallback_linear.astype(np.float32)
|
|
raise e
|
|
|
|
def _gentle_tone_map(self, hdr_image: np.ndarray, algorithm_name: str) -> np.ndarray:
|
|
"""
|
|
Apply gentle tone mapping to preserve HDR range while making output usable
|
|
|
|
Args:
|
|
hdr_image: Raw HDR output from Debevec/Robertson
|
|
algorithm_name: Name of algorithm for logging
|
|
|
|
Returns:
|
|
Gently processed HDR image with preserved dynamic range
|
|
"""
|
|
logger.info(f"{algorithm_name} raw output range: [{hdr_image.min():.6f}, {hdr_image.max():.6f}]")
|
|
|
|
try:
|
|
# Gentle processing to preserve HDR range but make it usable
|
|
|
|
# Method 1: Simple scaling based on percentiles (preserves HDR better than Reinhard)
|
|
p95 = np.percentile(hdr_image, 95)
|
|
p05 = np.percentile(hdr_image, 5)
|
|
|
|
if p95 > p05 and p95 > 0:
|
|
# Scale so 95th percentile maps to reasonable value (1.0-3.0 range)
|
|
scale_factor = 2.0 / p95
|
|
scaled = hdr_image * scale_factor
|
|
|
|
# Apply very gentle gamma correction to improve appearance
|
|
gentle_gamma = np.power(np.clip(scaled, 0, 10), 0.8)
|
|
|
|
logger.info(f"{algorithm_name} after gentle processing: [{gentle_gamma.min():.6f}, {gentle_gamma.max():.6f}]")
|
|
return gentle_gamma.astype(np.float32)
|
|
else:
|
|
# Fallback for edge cases
|
|
return np.clip(hdr_image, 0.0, 10.0).astype(np.float32)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Gentle tone mapping failed for {algorithm_name}: {e}")
|
|
# Fallback: simple clipping
|
|
return np.clip(hdr_image, 0.0, 10.0).astype(np.float32)
|
|
|
|
def _radiance_fusion(self, images: List[np.ndarray], times: List[float]) -> np.ndarray:
|
|
"""
|
|
Radiance Fusion - Nuke-inspired HDR blending algorithm
|
|
|
|
Uses Nuke's plus and average operations for perfect HDR preservation:
|
|
1. Plus all outer exposures: (ev-4 + ev-2 + ev+2 + ev+4)
|
|
2. Average with center exposure: result + ev0 / 2
|
|
|
|
This creates beautiful HDR data while maintaining natural appearance.
|
|
|
|
Args:
|
|
images: List of exposure images in BGR format (from OpenCV)
|
|
times: Exposure times
|
|
|
|
Returns:
|
|
Radiance fusion result with excellent HDR preservation
|
|
"""
|
|
logger.info("Radiance Fusion: Nuke-style HDR blending with plus/average operations")
|
|
|
|
# Convert to float32 for HDR processing
|
|
float_images = [img.astype(np.float32) / 255.0 for img in images]
|
|
|
|
# For 5-stop: [ev+4, ev+2, ev0, ev-2, ev-4] - indices [0,1,2,3,4]
|
|
# For 3-stop: [ev+2, ev0, ev-2] - indices [0,1,2]
|
|
|
|
if len(float_images) == 5:
|
|
# 5-stop processing: ev+4, ev+2, ev0, ev-2, ev-4
|
|
ev_plus_4 = float_images[0] # Most overexposed
|
|
ev_plus_2 = float_images[1] # Overexposed
|
|
ev_0 = float_images[2] # Middle exposure
|
|
ev_minus_2 = float_images[3] # Underexposed
|
|
ev_minus_4 = float_images[4] # Most underexposed
|
|
|
|
# NUKE PLUS OPERATION: Add all outer exposures
|
|
# This preserves full dynamic range from all sources
|
|
outer_sum = ev_plus_4 + ev_plus_2 + ev_minus_2 + ev_minus_4
|
|
logger.info("5-stop: Added outer exposures (ev±4, ev±2) using Nuke plus operation")
|
|
|
|
elif len(float_images) == 3:
|
|
# 3-stop processing: ev+2, ev0, ev-2
|
|
ev_plus_2 = float_images[0] # Overexposed
|
|
ev_0 = float_images[1] # Middle exposure
|
|
ev_minus_2 = float_images[2] # Underexposed
|
|
|
|
# NUKE PLUS OPERATION: Add outer exposures
|
|
outer_sum = ev_plus_2 + ev_minus_2
|
|
logger.info("3-stop: Added outer exposures (ev±2) using Nuke plus operation")
|
|
|
|
else:
|
|
raise ValueError(f"Radiance Fusion requires 3 or 5 images, got {len(float_images)}")
|
|
|
|
# NUKE AVERAGE OPERATION: (outer_sum + ev0) / 2
|
|
# This balances the combined outer detail with the natural center exposure
|
|
radiance_result = (outer_sum + ev_0) / 2.0
|
|
|
|
logger.info(f"Applied Nuke average operation: (outer_sum + ev0) / 2")
|
|
logger.info(f"Radiance Fusion result: [{radiance_result.min():.3f}, {radiance_result.max():.3f}]")
|
|
logger.info(f"HDR pixels above 1.0: {np.sum(radiance_result > 1.0)} pixels")
|
|
|
|
# Return with full HDR range preserved (no clipping!)
|
|
return radiance_result.astype(np.float32)
|
|
|
|
def _blend_ev0_preserving(self, images: List[np.ndarray], times: List[float]) -> np.ndarray:
|
|
"""
|
|
Improved exposure blending that perfectly preserves EV0 appearance
|
|
while storing HDR information in values above 1.0
|
|
|
|
This method uses the EV0 as the base and only modifies areas where
|
|
detail is lost (pure white or pure black) with information from other exposures.
|
|
|
|
Args:
|
|
images: List of exposure images in BGR format (from OpenCV)
|
|
times: Exposure times
|
|
|
|
Returns:
|
|
Enhanced image that looks identical to EV0 but with HDR values > 1.0
|
|
"""
|
|
logger.info("Natural Blend: Perfect EV0 preservation with HDR extension")
|
|
|
|
# Find the EV0 image (middle exposure)
|
|
ev0_idx = len(images) // 2
|
|
ev0_base = images[ev0_idx].astype(np.float32) / 255.0
|
|
|
|
logger.info(f"Using image {ev0_idx} as EV0 base (out of {len(images)} images)")
|
|
|
|
# Convert all images to float (no exposure compensation needed)
|
|
float_images = []
|
|
for i, img in enumerate(images):
|
|
float_img = img.astype(np.float32) / 255.0
|
|
float_images.append(float_img)
|
|
|
|
# Start with EV0 as the base - this ensures perfect appearance match
|
|
result = ev0_base.copy()
|
|
|
|
# Only blend in areas where EV0 has lost detail (highlights and shadows)
|
|
gray_ev0 = cv2.cvtColor(ev0_base, cv2.COLOR_BGR2GRAY)
|
|
|
|
# Process highlights - where EV0 is clipped (near 1.0)
|
|
highlight_threshold = 0.95 # Areas above this in EV0 need HDR data
|
|
highlight_mask = gray_ev0 > highlight_threshold
|
|
|
|
if np.any(highlight_mask):
|
|
# Use underexposed images for highlight recovery
|
|
for i in range(ev0_idx + 1, len(float_images)):
|
|
img = float_images[i]
|
|
|
|
# Use the actual exposure difference for HDR scaling
|
|
# Underexposed images have longer exposure times
|
|
exposure_ratio = times[ev0_idx] / times[i]
|
|
scale_factor = exposure_ratio # This gives proper HDR scaling
|
|
|
|
# Blend only in highlight areas
|
|
for c in range(3):
|
|
# Use the underexposed data scaled up for HDR
|
|
hdr_values = img[:, :, c] * scale_factor
|
|
|
|
# Smooth transition: gradually blend as we approach pure white
|
|
blend_weight = np.where(highlight_mask,
|
|
(gray_ev0 - highlight_threshold) / (1.0 - highlight_threshold),
|
|
0.0)
|
|
|
|
# Blend HDR values only in highlights, preserving EV0 elsewhere
|
|
result[:, :, c] = np.where(highlight_mask,
|
|
result[:, :, c] * (1 - blend_weight) + hdr_values * blend_weight,
|
|
result[:, :, c])
|
|
|
|
logger.info(f"HDR highlight recovery applied - values up to {result.max():.2f}")
|
|
|
|
# Process shadows - where EV0 is too dark (near 0.0)
|
|
shadow_threshold = 0.05 # Areas below this in EV0 need shadow detail
|
|
shadow_mask = gray_ev0 < shadow_threshold
|
|
|
|
if np.any(shadow_mask):
|
|
# Use overexposed images for shadow recovery
|
|
for i in range(ev0_idx):
|
|
img = float_images[i]
|
|
|
|
# Blend only in shadow areas
|
|
for c in range(3):
|
|
# Smooth transition: gradually blend as we approach pure black
|
|
blend_weight = np.where(shadow_mask,
|
|
(shadow_threshold - gray_ev0) / shadow_threshold,
|
|
0.0)
|
|
|
|
# Blend shadow detail, preserving EV0 elsewhere
|
|
result[:, :, c] = np.where(shadow_mask,
|
|
result[:, :, c] * (1 - blend_weight * 0.5) + img[:, :, c] * blend_weight * 0.5,
|
|
result[:, :, c])
|
|
|
|
logger.info("Shadow detail recovery applied")
|
|
|
|
# Ensure midtones exactly match EV0
|
|
midtone_mask = np.logical_and(gray_ev0 >= shadow_threshold, gray_ev0 <= highlight_threshold)
|
|
for c in range(3):
|
|
result[:, :, c] = np.where(midtone_mask, ev0_base[:, :, c], result[:, :, c])
|
|
|
|
logger.info(f"Natural Blend completed - EV0 appearance preserved")
|
|
logger.info(f" HDR range: [{result.min():.3f}, {result.max():.3f}]")
|
|
logger.info(f" Values > 1.0: {np.sum(result > 1.0)} pixels")
|
|
|
|
# NO CLIPPING - preserve HDR values above 1.0
|
|
return result.astype(np.float32)
|
|
|
|
def _merge_with_hdrutils(self, images: List[np.ndarray], times: List[float]) -> np.ndarray:
|
|
"""
|
|
Use HDRutils library for HDR merging if available
|
|
|
|
Args:
|
|
images: List of 8-bit images
|
|
times: Exposure times
|
|
|
|
Returns:
|
|
HDR merged image
|
|
"""
|
|
try:
|
|
# Convert images to the format HDRutils expects
|
|
# HDRutils typically expects RGB format
|
|
rgb_images = []
|
|
for img in images:
|
|
if len(img.shape) == 3 and img.shape[2] == 3:
|
|
# Convert BGR to RGB for HDRutils
|
|
rgb_img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
rgb_images.append(rgb_img)
|
|
else:
|
|
rgb_images.append(img)
|
|
|
|
# Stack images for HDRutils
|
|
image_stack = np.stack(rgb_images, axis=0)
|
|
|
|
# Create HDR merge using HDRutils
|
|
# Note: HDRutils.merge might have different parameters
|
|
# This is a generic implementation
|
|
hdr_image = HDRutils.merge(image_stack, times)
|
|
|
|
logger.info(f"HDRutils merge complete: range [{hdr_image.min():.6f}, {hdr_image.max():.6f}]")
|
|
|
|
return hdr_image.astype(np.float32)
|
|
|
|
except Exception as e:
|
|
logger.error(f"HDRutils merge failed: {e}")
|
|
logger.info("Falling back to Mertens algorithm")
|
|
# Fallback to Mertens
|
|
return self.merge_mertens.process(images)
|
|
|
|
def _create_highlight_mask(self, gray_image: np.ndarray, threshold: float = 0.8) -> np.ndarray:
|
|
"""Create a mask for highlight areas that need detail recovery"""
|
|
# Smooth transition for highlights
|
|
mask = np.zeros_like(gray_image, dtype=np.float32)
|
|
|
|
# Areas above threshold get progressively more blending
|
|
bright_areas = gray_image > threshold
|
|
if np.any(bright_areas):
|
|
mask[bright_areas] = (gray_image[bright_areas] - threshold) / (1.0 - threshold)
|
|
|
|
# Smooth the mask to avoid harsh transitions with larger kernel
|
|
mask = cv2.GaussianBlur(mask, (41, 41), 0)
|
|
|
|
return np.clip(mask, 0, 1)
|
|
|
|
def _create_shadow_mask(self, gray_image: np.ndarray, threshold: float = 0.2) -> np.ndarray:
|
|
"""Create a mask for shadow areas that need detail recovery"""
|
|
# Smooth transition for shadows
|
|
mask = np.zeros_like(gray_image, dtype=np.float32)
|
|
|
|
# Areas below threshold get progressively more blending
|
|
dark_areas = gray_image < threshold
|
|
if np.any(dark_areas):
|
|
mask[dark_areas] = (threshold - gray_image[dark_areas]) / threshold
|
|
|
|
# Smooth the mask to avoid harsh transitions with larger kernel
|
|
mask = cv2.GaussianBlur(mask, (41, 41), 0)
|
|
|
|
return np.clip(mask, 0, 1)
|
|
|
|
def _analyze_gamma(self, images: List[np.ndarray]) -> float:
|
|
"""
|
|
Analyze input images to detect gamma encoding
|
|
AI-generated sRGB images typically have gamma 2.2
|
|
|
|
Args:
|
|
images: List of 8-bit images
|
|
|
|
Returns:
|
|
Detected gamma value (2.2 for sRGB, 1.0 for linear)
|
|
"""
|
|
# Sample multiple images for better detection
|
|
sample_values = []
|
|
|
|
for img in images[:min(3, len(images))]:
|
|
# Convert to float and normalize
|
|
img_float = img.astype(np.float32) / 255.0
|
|
|
|
# Sample mid-gray values (0.3-0.7 range) for gamma analysis
|
|
mask = (img_float > 0.3) & (img_float < 0.7)
|
|
if np.any(mask):
|
|
sample_values.extend(img_float[mask].flatten()[:1000])
|
|
|
|
if len(sample_values) > 0:
|
|
# Analyze distribution to detect gamma curve
|
|
sample_array = np.array(sample_values)
|
|
median_val = np.median(sample_array)
|
|
|
|
# AI-generated images tend to have strong gamma curve
|
|
# Real linear images would have different distribution
|
|
# Simple heuristic: if median is in expected sRGB range, assume gamma 2.2
|
|
if 0.4 < median_val < 0.6:
|
|
logger.info("Detected sRGB gamma encoding (gamma ≈ 2.2)")
|
|
return 2.2
|
|
else:
|
|
logger.info("Images appear to be in sRGB color space (gamma 2.2)")
|
|
return 2.2 # Default to sRGB for AI-generated images
|
|
|
|
logger.info("Defaulting to sRGB gamma (2.2) for AI-generated images")
|
|
return 2.2
|
|
|
|
def _srgb_to_linear(self, srgb: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Convert sRGB (gamma-encoded) to linear light space
|
|
|
|
Standard sRGB to linear transformation:
|
|
- For values <= 0.04045: linear = srgb / 12.92
|
|
- For values > 0.04045: linear = ((srgb + 0.055) / 1.055) ^ 2.4
|
|
|
|
Args:
|
|
srgb: Image in sRGB color space (0-1 range)
|
|
|
|
Returns:
|
|
Image in linear light space
|
|
"""
|
|
linear = np.where(
|
|
srgb <= 0.04045,
|
|
srgb / 12.92,
|
|
np.power((srgb + 0.055) / 1.055, 2.4)
|
|
)
|
|
|
|
logger.info(f"sRGB to linear: input range [{srgb.min():.3f}, {srgb.max():.3f}] -> output range [{linear.min():.3f}, {linear.max():.3f}]")
|
|
return linear.astype(np.float32)
|
|
|
|
def _linear_to_srgb(self, linear: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Convert linear light space to sRGB (gamma-encoded)
|
|
|
|
Inverse of sRGB transformation - for display purposes only!
|
|
This will clip HDR values >1.0 to displayable range.
|
|
|
|
Standard linear to sRGB transformation:
|
|
- For values <= 0.0031308: srgb = linear * 12.92
|
|
- For values > 0.0031308: srgb = 1.055 * (linear ^ (1/2.4)) - 0.055
|
|
|
|
Args:
|
|
linear: Image in linear light space
|
|
|
|
Returns:
|
|
Image in sRGB color space (gamma-encoded)
|
|
"""
|
|
# Tone map HDR values first using simple Reinhard
|
|
# This compresses values >1.0 into displayable range
|
|
linear_tonemapped = linear / (1.0 + linear)
|
|
|
|
# Apply sRGB gamma encoding
|
|
srgb = np.where(
|
|
linear_tonemapped <= 0.0031308,
|
|
linear_tonemapped * 12.92,
|
|
1.055 * np.power(linear_tonemapped, 1.0 / 2.4) - 0.055
|
|
)
|
|
|
|
# Clamp to valid range
|
|
srgb = np.clip(srgb, 0.0, 1.0)
|
|
|
|
logger.info(f"Linear to sRGB (tonemapped): input range [{linear.min():.3f}, {linear.max():.3f}] -> output range [{srgb.min():.3f}, {srgb.max():.3f}]")
|
|
return srgb.astype(np.float32)
|
|
|
|
def _smooth_step(self, edge0: float, edge1: float, x: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Hermite interpolation for smooth transitions
|
|
Creates smooth S-curve for natural blending
|
|
|
|
Args:
|
|
edge0: Lower edge
|
|
edge1: Upper edge
|
|
x: Input values
|
|
|
|
Returns:
|
|
Smoothly interpolated values (0-1)
|
|
"""
|
|
t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
|
|
return t * t * (3.0 - 2.0 * t)
|
|
|
|
def _inject_detail_preserve_color(self, base_rgb: np.ndarray, detail_rgb: np.ndarray,
|
|
blend: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Inject detail while preserving hue and color ratios
|
|
|
|
Uses luminance-based scaling to maintain color appearance
|
|
while changing detail and brightness
|
|
|
|
Args:
|
|
base_rgb: Base RGB image (H, W, 3)
|
|
detail_rgb: Detail source RGB image (H, W, 3)
|
|
blend: Blend weight map (H, W)
|
|
|
|
Returns:
|
|
Result with injected detail
|
|
"""
|
|
# Calculate luminance using Rec. 709 coefficients
|
|
base_luma = (0.2126 * base_rgb[:, :, 2] +
|
|
0.7152 * base_rgb[:, :, 1] +
|
|
0.0722 * base_rgb[:, :, 0]) # Note: BGR order from OpenCV
|
|
|
|
detail_luma = (0.2126 * detail_rgb[:, :, 2] +
|
|
0.7152 * detail_rgb[:, :, 1] +
|
|
0.0722 * detail_rgb[:, :, 0])
|
|
|
|
# Calculate new luminance
|
|
new_luma = base_luma * (1 - blend) + detail_luma * blend
|
|
|
|
# Scale RGB to match new luminance while preserving color ratios
|
|
result_rgb = np.zeros_like(base_rgb)
|
|
|
|
# Avoid division by zero
|
|
safe_base_luma = np.maximum(base_luma, 1e-6)
|
|
scale = new_luma / safe_base_luma
|
|
|
|
# Apply scaling to each channel
|
|
for c in range(3):
|
|
result_rgb[:, :, c] = base_rgb[:, :, c] * scale
|
|
|
|
# In near-black areas, use detail color directly
|
|
near_black = base_luma < 0.001
|
|
if np.any(near_black):
|
|
for c in range(3):
|
|
result_rgb[:, :, c] = np.where(
|
|
near_black,
|
|
detail_rgb[:, :, c] * blend,
|
|
result_rgb[:, :, c]
|
|
)
|
|
|
|
return result_rgb
|
|
|
|
def _detail_injection(self, images: List[np.ndarray], times: List[float], output_mode: str = "linear") -> np.ndarray:
|
|
"""
|
|
Detail Injection algorithm for AI-generated HDR stacks
|
|
|
|
This algorithm is designed specifically for AI-generated exposure stacks
|
|
where images don't follow photometric relationships. It:
|
|
1. Analyzes gamma encoding and converts to linear space
|
|
2. Uses EV0 as base (preserves natural look)
|
|
3. Injects highlight detail from underexposed images (EV-2, EV-4) into >1.0 range
|
|
4. Injects shadow detail from overexposed images (EV+2, EV+4) into near-zero range
|
|
5. Preserves color ratios using luminance-based scaling
|
|
6. Applies automatic brightness compensation to target 18% gray
|
|
|
|
Args:
|
|
images: List of exposure images in BGR format (from OpenCV)
|
|
times: Exposure times
|
|
output_mode: Always "linear" for true HDR output
|
|
|
|
Returns:
|
|
Linear HDR image with detail injection (perfect for EXR export)
|
|
"""
|
|
logger.info("=" * 80)
|
|
logger.info("DETAIL INJECTION ALGORITHM - AI-Aware HDR Processing")
|
|
logger.info("=" * 80)
|
|
|
|
# Step 1: Analyze and convert to linear space
|
|
logger.info("\nStep 1: Gamma Analysis and Linear Conversion")
|
|
logger.info("-" * 40)
|
|
|
|
detected_gamma = self._analyze_gamma(images)
|
|
|
|
# Convert all images from 8-bit sRGB to linear float
|
|
linear_images = []
|
|
for i, img in enumerate(images):
|
|
# Convert to 0-1 range
|
|
img_float = img.astype(np.float32) / 255.0
|
|
# Convert sRGB to linear
|
|
img_linear = self._srgb_to_linear(img_float)
|
|
linear_images.append(img_linear)
|
|
logger.info(f" Image {i}: sRGB -> linear, range [{img_linear.min():.4f}, {img_linear.max():.4f}]")
|
|
|
|
# Identify images by exposure
|
|
num_images = len(linear_images)
|
|
ev0_idx = num_images // 2
|
|
|
|
if num_images == 5:
|
|
# 5-stop: [EV+4, EV+2, EV0, EV-2, EV-4]
|
|
ev0 = linear_images[2]
|
|
ev_plus_4 = linear_images[0]
|
|
ev_plus_2 = linear_images[1]
|
|
ev_minus_2 = linear_images[3]
|
|
ev_minus_4 = linear_images[4]
|
|
logger.info("\n5-stop stack detected: EV+4, EV+2, EV0, EV-2, EV-4")
|
|
|
|
elif num_images == 3:
|
|
# 3-stop: [EV+2, EV0, EV-2]
|
|
ev0 = linear_images[1]
|
|
ev_plus_2 = linear_images[0]
|
|
ev_minus_2 = linear_images[2]
|
|
ev_plus_4 = None
|
|
ev_minus_4 = None
|
|
logger.info("\n3-stop stack detected: EV+2, EV0, EV-2")
|
|
|
|
else:
|
|
raise ValueError(f"Detail injection requires 3 or 5 images, got {num_images}")
|
|
|
|
# Start with EV0 as base (ensures natural appearance)
|
|
result = ev0.copy()
|
|
|
|
# Step 2: Highlight Detail Injection
|
|
logger.info("\nStep 2: Highlight Detail Injection")
|
|
logger.info("-" * 40)
|
|
logger.info("Injecting detail from underexposed images into >1.0 range...")
|
|
|
|
# Convert to grayscale for masking (use Rec. 709)
|
|
gray_ev0 = (0.2126 * ev0[:, :, 2] +
|
|
0.7152 * ev0[:, :, 1] +
|
|
0.0722 * ev0[:, :, 0])
|
|
|
|
# Highlight thresholds - wider ranges for smoother transitions
|
|
highlight_threshold_moderate = 0.80 # Start blending earlier
|
|
highlight_threshold_extreme = 0.92 # Start extreme highlights earlier
|
|
|
|
# Create smooth highlight masks using Hermite interpolation with wider ranges
|
|
# Wider ranges = more gradual transitions, less sharp cutoffs
|
|
highlight_mask_moderate = self._smooth_step(0.60, 0.95, gray_ev0) # Start at 0.6 instead of 0.7
|
|
highlight_mask_extreme = self._smooth_step(0.75, 0.98, gray_ev0) # Start at 0.75 instead of 0.85
|
|
|
|
# Apply additional Gaussian smoothing to masks for even smoother transitions
|
|
# This creates very gradual feathering at blend boundaries
|
|
highlight_mask_moderate = cv2.GaussianBlur(highlight_mask_moderate, (21, 21), 7.0)
|
|
highlight_mask_extreme = cv2.GaussianBlur(highlight_mask_extreme, (21, 21), 7.0)
|
|
|
|
logger.info(" Applied Gaussian smoothing to highlight masks for gradual transitions")
|
|
|
|
pixels_moderate = np.sum(highlight_mask_moderate > 0.1)
|
|
pixels_extreme = np.sum(highlight_mask_extreme > 0.5)
|
|
logger.info(f" Moderate highlights: {pixels_moderate} pixels")
|
|
logger.info(f" Extreme highlights: {pixels_extreme} pixels")
|
|
|
|
# Inject from EV-2 for moderate highlights
|
|
if ev_minus_2 is not None:
|
|
logger.info(" Processing EV-2 (underexposed) for moderate highlights...")
|
|
|
|
# Apply bilateral filtering to reduce grain while preserving edges
|
|
ev_minus_2_smooth = cv2.bilateralFilter(
|
|
(ev_minus_2 * 255).astype(np.uint8),
|
|
d=9,
|
|
sigmaColor=75,
|
|
sigmaSpace=75
|
|
).astype(np.float32) / 255.0
|
|
logger.info(" Applied bilateral filtering to EV-2 for grain reduction")
|
|
|
|
# Map EV-2 detail into 1.0-2.0 range
|
|
for y in range(result.shape[0]):
|
|
for x in range(result.shape[1]):
|
|
blend_weight = highlight_mask_moderate[y, x]
|
|
|
|
if blend_weight > 0.01: # Only process significant contributions
|
|
base_value = gray_ev0[y, x]
|
|
|
|
if base_value > highlight_threshold_moderate:
|
|
# Get detail from smoothed underexposed image
|
|
detail_source = ev_minus_2_smooth[y, x, :]
|
|
|
|
# Map detail into 1.0-2.0 range
|
|
# Remap from 0-1 input range to 1.0-2.0 HDR range
|
|
detail_luma = np.mean(detail_source)
|
|
hdr_target = 1.0 + detail_luma * 1.0 # Maps 0-1 to 1.0-2.0
|
|
|
|
# Create HDR detail preserving color
|
|
if detail_luma > 1e-6:
|
|
detail_hdr = detail_source * (hdr_target / detail_luma)
|
|
else:
|
|
detail_hdr = detail_source
|
|
|
|
# Blend based on how clipped we are
|
|
clip_amount = (base_value - highlight_threshold_moderate) / (1.0 - highlight_threshold_moderate)
|
|
clip_amount = np.clip(clip_amount, 0, 1)
|
|
|
|
# Smooth blend
|
|
final_blend = blend_weight * clip_amount
|
|
result[y, x, :] = result[y, x, :] * (1 - final_blend) + detail_hdr * final_blend
|
|
|
|
result_max = result.max()
|
|
logger.info(f" After EV-2 injection: max value = {result_max:.3f}")
|
|
|
|
# Inject from EV-4 for extreme highlights (if available)
|
|
if ev_minus_4 is not None:
|
|
logger.info(" Processing EV-4 (very underexposed) for extreme highlights...")
|
|
|
|
# Apply bilateral filtering to reduce grain while preserving edges
|
|
ev_minus_4_smooth = cv2.bilateralFilter(
|
|
(ev_minus_4 * 255).astype(np.uint8),
|
|
d=9,
|
|
sigmaColor=75,
|
|
sigmaSpace=75
|
|
).astype(np.float32) / 255.0
|
|
logger.info(" Applied bilateral filtering to EV-4 for grain reduction")
|
|
|
|
# Map EV-4 detail into 2.0-4.0 range
|
|
for y in range(result.shape[0]):
|
|
for x in range(result.shape[1]):
|
|
blend_weight = highlight_mask_extreme[y, x]
|
|
|
|
if blend_weight > 0.1: # Only process strong highlights
|
|
base_value = gray_ev0[y, x]
|
|
|
|
if base_value > highlight_threshold_extreme:
|
|
# Get detail from smoothed very underexposed image
|
|
detail_source = ev_minus_4_smooth[y, x, :]
|
|
|
|
# Map detail into 2.0-4.0 range
|
|
detail_luma = np.mean(detail_source)
|
|
hdr_target = 2.0 + detail_luma * 2.0 # Maps 0-1 to 2.0-4.0
|
|
|
|
# Create HDR detail preserving color
|
|
if detail_luma > 1e-6:
|
|
detail_hdr = detail_source * (hdr_target / detail_luma)
|
|
else:
|
|
detail_hdr = detail_source * 2.0
|
|
|
|
# Blend for extreme highlights only
|
|
clip_amount = (base_value - highlight_threshold_extreme) / (1.0 - highlight_threshold_extreme)
|
|
clip_amount = np.clip(clip_amount, 0, 1)
|
|
|
|
final_blend = blend_weight * clip_amount * 0.7 # Reduced strength for naturalness
|
|
result[y, x, :] = result[y, x, :] * (1 - final_blend) + detail_hdr * final_blend
|
|
|
|
result_max = result.max()
|
|
logger.info(f" After EV-4 injection: max value = {result_max:.3f}")
|
|
|
|
# Step 3: Shadow Detail Injection
|
|
logger.info("\nStep 3: Shadow Detail Injection")
|
|
logger.info("-" * 40)
|
|
logger.info("Injecting detail from overexposed images into shadow regions...")
|
|
|
|
# Shadow thresholds - wider ranges for smoother transitions
|
|
shadow_threshold_moderate = 0.18 # Start blending a bit higher
|
|
shadow_threshold_extreme = 0.06 # Start extreme shadows higher
|
|
|
|
# Create smooth shadow masks with wider ranges
|
|
shadow_mask_moderate = self._smooth_step(0.30, 0.05, gray_ev0) # Wider range: 0.30 instead of 0.25
|
|
shadow_mask_extreme = self._smooth_step(0.12, 0.01, gray_ev0) # Wider range: 0.12 instead of 0.10
|
|
|
|
# Apply Gaussian smoothing to shadow masks for gradual transitions
|
|
shadow_mask_moderate = cv2.GaussianBlur(shadow_mask_moderate, (21, 21), 7.0)
|
|
shadow_mask_extreme = cv2.GaussianBlur(shadow_mask_extreme, (21, 21), 7.0)
|
|
|
|
logger.info(" Applied Gaussian smoothing to shadow masks for gradual transitions")
|
|
|
|
pixels_shadow_mod = np.sum(shadow_mask_moderate > 0.1)
|
|
pixels_shadow_ext = np.sum(shadow_mask_extreme > 0.5)
|
|
logger.info(f" Moderate shadows: {pixels_shadow_mod} pixels")
|
|
logger.info(f" Extreme shadows: {pixels_shadow_ext} pixels")
|
|
|
|
# Inject from EV+2 for moderate shadows
|
|
if ev_plus_2 is not None:
|
|
logger.info(" Processing EV+2 (overexposed) for moderate shadows...")
|
|
|
|
# Apply bilateral filtering to reduce grain while preserving edges
|
|
ev_plus_2_smooth = cv2.bilateralFilter(
|
|
(ev_plus_2 * 255).astype(np.uint8),
|
|
d=9,
|
|
sigmaColor=75,
|
|
sigmaSpace=75
|
|
).astype(np.float32) / 255.0
|
|
logger.info(" Applied bilateral filtering to EV+2 for grain reduction")
|
|
|
|
for y in range(result.shape[0]):
|
|
for x in range(result.shape[1]):
|
|
blend_weight = shadow_mask_moderate[y, x]
|
|
|
|
if blend_weight > 0.01:
|
|
base_value = gray_ev0[y, x]
|
|
|
|
if base_value < shadow_threshold_moderate:
|
|
# Get detail from smoothed overexposed image
|
|
detail_source = ev_plus_2_smooth[y, x, :]
|
|
|
|
# Scale detail into shadow range (0.5x)
|
|
shadow_detail = detail_source * 0.5 * (base_value + 0.1)
|
|
|
|
# Blend
|
|
crush_amount = (shadow_threshold_moderate - base_value) / shadow_threshold_moderate
|
|
crush_amount = np.clip(crush_amount, 0, 1)
|
|
|
|
final_blend = blend_weight * crush_amount * 0.5
|
|
result[y, x, :] = result[y, x, :] * (1 - final_blend) + shadow_detail * final_blend
|
|
|
|
logger.info(" EV+2 shadow injection complete")
|
|
|
|
# Inject from EV+4 for extreme shadows (if available)
|
|
if ev_plus_4 is not None:
|
|
logger.info(" Processing EV+4 (very overexposed) for extreme shadows...")
|
|
|
|
# Apply bilateral filtering to reduce grain while preserving edges
|
|
ev_plus_4_smooth = cv2.bilateralFilter(
|
|
(ev_plus_4 * 255).astype(np.uint8),
|
|
d=9,
|
|
sigmaColor=75,
|
|
sigmaSpace=75
|
|
).astype(np.float32) / 255.0
|
|
logger.info(" Applied bilateral filtering to EV+4 for grain reduction")
|
|
|
|
for y in range(result.shape[0]):
|
|
for x in range(result.shape[1]):
|
|
blend_weight = shadow_mask_extreme[y, x]
|
|
|
|
if blend_weight > 0.1:
|
|
base_value = gray_ev0[y, x]
|
|
|
|
if base_value < shadow_threshold_extreme:
|
|
# Get detail from smoothed very overexposed image
|
|
detail_source = ev_plus_4_smooth[y, x, :]
|
|
|
|
# Scale detail into very dark shadow range (0.2x)
|
|
shadow_detail = detail_source * 0.2 * (base_value + 0.05)
|
|
|
|
# Blend
|
|
crush_amount = (shadow_threshold_extreme - base_value) / shadow_threshold_extreme
|
|
crush_amount = np.clip(crush_amount, 0, 1)
|
|
|
|
final_blend = blend_weight * crush_amount * 0.3
|
|
result[y, x, :] = result[y, x, :] * (1 - final_blend) + shadow_detail * final_blend
|
|
|
|
logger.info(" EV+4 extreme shadow injection complete")
|
|
|
|
# Step 4: Automatic Brightness Compensation
|
|
logger.info("\nStep 4: Automatic Brightness Compensation (HDR-Preserving)")
|
|
logger.info("-" * 40)
|
|
|
|
# CRITICAL: Calculate brightness based ONLY on base range (0-1.0)
|
|
# This prevents HDR values from affecting brightness calculation
|
|
base_range_mask = result <= 1.0
|
|
base_range_pixels = result[base_range_mask]
|
|
|
|
if base_range_pixels.size > 0:
|
|
current_median = np.median(base_range_pixels)
|
|
current_mean = np.mean(base_range_pixels)
|
|
|
|
# Target: 0.18 in linear space = 18% gray = professional middle gray
|
|
target_value = 0.18
|
|
|
|
logger.info(f" Current median (base range): {current_median:.4f}")
|
|
logger.info(f" Current mean (base range): {current_mean:.4f}")
|
|
logger.info(f" Target (18% gray): {target_value:.4f}")
|
|
logger.info(f" HDR pixels (>1.0): {np.sum(result > 1.0)}")
|
|
|
|
# Use median if image is very dark (shadows dominate)
|
|
# Use mean if image has reasonable brightness distribution
|
|
if current_median < 0.05:
|
|
# Very dark image - use median for scaling
|
|
reference_value = current_median
|
|
logger.info(f" Using median-based scaling (image is very dark)")
|
|
else:
|
|
# Normal image - use mean for scaling
|
|
reference_value = current_mean
|
|
logger.info(f" Using mean-based scaling (normal brightness)")
|
|
|
|
if reference_value > 0.001: # Avoid division by zero
|
|
brightness_factor = target_value / reference_value
|
|
|
|
# More permissive range for brightness adjustment (0.3x to 8.0x)
|
|
# This allows proper recovery of very dark or very bright images
|
|
brightness_factor = np.clip(brightness_factor, 0.3, 8.0)
|
|
|
|
if abs(brightness_factor - 1.0) > 0.05: # Only adjust if significant difference
|
|
logger.info(f" Applying brightness compensation: {brightness_factor:.3f}x")
|
|
|
|
# CRITICAL: Apply compensation differently based on whether we're brightening or darkening
|
|
if brightness_factor >= 1.0:
|
|
# Brightening: Apply to ALL pixels including HDR (scales everything up)
|
|
result = result * brightness_factor
|
|
logger.info(f" Brightening mode: Applied to all pixels including HDR")
|
|
else:
|
|
# Darkening: Only darken base range (0-1.0), preserve HDR values
|
|
# HDR values represent recoverable highlight detail - we must preserve them!
|
|
hdr_mask = result > 1.0
|
|
hdr_values = result[hdr_mask].copy() # Save HDR values
|
|
|
|
# Apply darkening to base range
|
|
result = result * brightness_factor
|
|
|
|
# Restore HDR values (unscaled)
|
|
result[hdr_mask] = hdr_values
|
|
logger.info(f" Darkening mode: Applied to base range only, HDR values preserved")
|
|
logger.info(f" Preserved {np.sum(hdr_mask)} HDR pixels above 1.0")
|
|
|
|
logger.info(f" New value range: [{result.min():.4f}, {result.max():.4f}]")
|
|
logger.info(f" New mean (base range): {np.mean(result[result <= 1.0]):.4f}")
|
|
logger.info(f" New median (base range): {np.median(result[result <= 1.0]):.4f}")
|
|
logger.info(f" HDR pixels preserved: {np.sum(result > 1.0)}")
|
|
else:
|
|
logger.info(" No brightness adjustment needed (already optimal)")
|
|
else:
|
|
logger.warning(" Skipping brightness compensation (image too dark)")
|
|
else:
|
|
logger.warning(" No base range pixels found for brightness compensation")
|
|
|
|
# Final statistics
|
|
logger.info("\nStep 5: Final Result Statistics (Linear HDR)")
|
|
logger.info("-" * 40)
|
|
logger.info(f" Value range: [{result.min():.4f}, {result.max():.4f}]")
|
|
logger.info(f" Pixels > 1.0 (HDR highlights): {np.sum(result > 1.0)}")
|
|
logger.info(f" Pixels > 2.0 (super-bright HDR): {np.sum(result > 2.0)}")
|
|
logger.info(f" Mean value: {result.mean():.4f}")
|
|
logger.info(f" Median value: {np.median(result):.4f}")
|
|
|
|
logger.info("\nOutput Mode: Linear HDR (perfect for EXR export)")
|
|
logger.info(" ✓ True linear HDR values preserved")
|
|
logger.info(" ✓ Ready for professional color grading and compositing")
|
|
|
|
logger.info("\n" + "=" * 80)
|
|
logger.info("DETAIL INJECTION COMPLETE")
|
|
logger.info("=" * 80)
|
|
|
|
return result.astype(np.float32)
|
|
|
|
|
|
class LuminanceStackProcessor3Stops:
|
|
"""
|
|
Professional ComfyUI Custom Node for 3-stop HDR processing
|
|
Processes EV+2, EV+0, EV-2 exposures using multiple algorithms
|
|
|
|
VFX PIPELINE NOTE:
|
|
When using Debevec/Robertson algorithms, the output will be linear radiance
|
|
values that look flat/desaturated. This is the correct format for professional
|
|
VFX compositing and should be saved as 16-bit EXR.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"ev_plus_2": ("IMAGE",),
|
|
"ev_0": ("IMAGE",),
|
|
"ev_minus_2": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"exposure_step": ("FLOAT", {
|
|
"default": 2.0,
|
|
"min": 0.5,
|
|
"max": 5.0,
|
|
"step": 0.1,
|
|
"display": "number"
|
|
}),
|
|
"exposure_adjust": ("FLOAT", {
|
|
"default": 0.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.1
|
|
}),
|
|
"hdr_algorithm": (["detail_injection", "radiance_fusion", "natural_blend", "mertens", "debevec", "robertson"] + (["hdrutils"] if HDRUTILS_AVAILABLE else []), {
|
|
"default": "detail_injection"
|
|
}),
|
|
"auto_calibrate": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "enabled",
|
|
"label_off": "disabled",
|
|
"tooltip": "Enable adaptive calibration for AI-generated brackets (Debevec/Robertson only)"
|
|
}),
|
|
"debevec_exposure_comp": ("FLOAT", {
|
|
"default": -8.0,
|
|
"min": -15.0,
|
|
"max": 15.0,
|
|
"step": 0.1,
|
|
"tooltip": "Exposure compensation for Debevec/Robertson output (in stops). Default -8.0 optimal for AI brackets."
|
|
}),
|
|
"debevec_anti_banding": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "enabled",
|
|
"label_off": "disabled",
|
|
"tooltip": "Apply optional gradient-adaptive dithering to reduce banding (Debevec/Robertson only). Disable for pure lambda=100 smoothness with no color changes."
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("hdr_image",)
|
|
FUNCTION = "process_3_stop_hdr"
|
|
CATEGORY = "image/luminance"
|
|
|
|
def __init__(self):
|
|
self.processor = DebevecHDRProcessor()
|
|
|
|
def process_3_stop_hdr(self, ev_plus_2, ev_0, ev_minus_2, exposure_step=2.0, exposure_adjust=0.0, hdr_algorithm="detail_injection", auto_calibrate=True, debevec_exposure_comp=-8.0, debevec_anti_banding=True):
|
|
"""
|
|
Process 3-stop HDR merge
|
|
|
|
Args:
|
|
ev_plus_2: Overexposed image (+2 EV)
|
|
ev_0: Normal exposure image (0 EV)
|
|
ev_minus_2: Underexposed image (-2 EV)
|
|
exposure_step: EV step size
|
|
exposure_adjust: Final exposure adjustment in stops (Nuke-style)
|
|
|
|
Returns:
|
|
Tuple containing merged HDR image with exposure adjustment applied
|
|
"""
|
|
try:
|
|
# Convert tensors to 8-bit sRGB images (no gamma correction needed)
|
|
# Debevec/Robertson algorithms expect sRGB input and output linear radiance
|
|
img_plus_2 = tensor_to_cv2(ev_plus_2)
|
|
img_0 = tensor_to_cv2(ev_0)
|
|
img_minus_2 = tensor_to_cv2(ev_minus_2)
|
|
|
|
logger.info(f"Processing 3-stop HDR with {hdr_algorithm} algorithm")
|
|
|
|
# Calculate exposure times based on EV values
|
|
# EV difference formula: Brighter images need longer exposure times
|
|
base_time = 1.0 / 60.0 # 1/60s as base exposure
|
|
|
|
time_plus_2 = base_time * (2.0 ** exposure_step) # Longer time (overexposed/brighter)
|
|
time_0 = base_time # Normal exposure
|
|
time_minus_2 = base_time * (2.0 ** (-exposure_step)) # Shorter time (underexposed/darker)
|
|
|
|
images = [img_plus_2, img_0, img_minus_2]
|
|
times = [time_plus_2, time_0, time_minus_2]
|
|
|
|
logger.info(f"3-Stop HDR: Processing with times {times} using {hdr_algorithm} algorithm")
|
|
logger.info(f"Auto-calibration: {'ENABLED' if auto_calibrate else 'DISABLED'}")
|
|
|
|
# Process HDR using selected algorithm - each should produce DIFFERENT results
|
|
hdr_result = self.processor.process_hdr(images, times, algorithm=hdr_algorithm,
|
|
auto_calibrate=auto_calibrate,
|
|
debevec_exposure_compensation=debevec_exposure_comp,
|
|
debevec_anti_banding=debevec_anti_banding)
|
|
|
|
logger.info(f"3-Stop HDR result range before tensor conversion: [{hdr_result.min():.6f}, {hdr_result.max():.6f}]")
|
|
|
|
# Apply exposure adjustment (Nuke-style)
|
|
if exposure_adjust != 0.0:
|
|
# Exposure formula: result * (2^exposure_adjust)
|
|
# +1.0 stop = 2x brighter, -1.0 stop = 0.5x darker
|
|
adjustment_factor = 2.0 ** exposure_adjust
|
|
hdr_result = hdr_result * adjustment_factor
|
|
logger.info(f"Applied exposure adjustment: {exposure_adjust:+.1f} stops (factor: {adjustment_factor:.3f})")
|
|
logger.info(f"HDR result after adjustment: [{hdr_result.min():.6f}, {hdr_result.max():.6f}]")
|
|
|
|
# Convert back to tensor with TRUE HDR values (above 1.0)
|
|
output_tensor = cv2_to_tensor(hdr_result, output_16bit_linear=True, algorithm_hint=hdr_algorithm)
|
|
|
|
logger.info(f"3-Stop final tensor range (should be > 1.0 for HDR): [{output_tensor.min():.6f}, {output_tensor.max():.6f}]")
|
|
|
|
return (output_tensor,)
|
|
|
|
except Exception as e:
|
|
logger.error(f"3-Stop HDR processing failed: {str(e)}")
|
|
# Return middle exposure as fallback
|
|
return (ev_0,)
|
|
|
|
|
|
class LuminanceStackProcessor5Stops:
|
|
"""
|
|
Professional ComfyUI Custom Node for 5-stop HDR processing
|
|
Processes EV+4, EV+2, EV+0, EV-2, EV-4 exposures using multiple algorithms
|
|
|
|
VFX PIPELINE NOTE:
|
|
When using Debevec/Robertson algorithms, the output will be linear radiance
|
|
values that look flat/desaturated. This is the correct format for professional
|
|
VFX compositing and should be saved as 16-bit EXR.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"ev_plus_4": ("IMAGE",),
|
|
"ev_plus_2": ("IMAGE",),
|
|
"ev_0": ("IMAGE",),
|
|
"ev_minus_2": ("IMAGE",),
|
|
"ev_minus_4": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"exposure_step": ("FLOAT", {
|
|
"default": 2.0,
|
|
"min": 0.5,
|
|
"max": 5.0,
|
|
"step": 0.1,
|
|
"display": "number"
|
|
}),
|
|
"exposure_adjust": ("FLOAT", {
|
|
"default": 0.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.1
|
|
}),
|
|
"hdr_algorithm": (["detail_injection", "radiance_fusion", "natural_blend", "mertens", "debevec", "robertson"] + (["hdrutils"] if HDRUTILS_AVAILABLE else []), {
|
|
"default": "detail_injection"
|
|
}),
|
|
"auto_calibrate": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "enabled",
|
|
"label_off": "disabled",
|
|
"tooltip": "Enable adaptive calibration for AI-generated brackets (Debevec/Robertson only)"
|
|
}),
|
|
"debevec_exposure_comp": ("FLOAT", {
|
|
"default": -8.0,
|
|
"min": -15.0,
|
|
"max": 15.0,
|
|
"step": 0.1,
|
|
"tooltip": "Exposure compensation for Debevec/Robertson output (in stops). Default -8.0 optimal for AI brackets."
|
|
}),
|
|
"debevec_anti_banding": ("BOOLEAN", {
|
|
"default": True,
|
|
"label_on": "enabled",
|
|
"label_off": "disabled",
|
|
"tooltip": "Apply optional gradient-adaptive dithering to reduce banding (Debevec/Robertson only). Disable for pure lambda=100 smoothness with no color changes."
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("hdr_image",)
|
|
FUNCTION = "process_5_stop_hdr"
|
|
CATEGORY = "image/luminance"
|
|
|
|
def __init__(self):
|
|
self.processor = DebevecHDRProcessor()
|
|
|
|
def process_5_stop_hdr(self, ev_plus_4, ev_plus_2, ev_0, ev_minus_2, ev_minus_4, exposure_step=2.0, exposure_adjust=0.0, hdr_algorithm="detail_injection", auto_calibrate=True, debevec_exposure_comp=-8.0, debevec_anti_banding=True):
|
|
"""
|
|
Process 5-stop HDR merge
|
|
|
|
Args:
|
|
ev_plus_4: Most overexposed image (+4 EV)
|
|
ev_plus_2: Overexposed image (+2 EV)
|
|
ev_0: Normal exposure image (0 EV)
|
|
ev_minus_2: Underexposed image (-2 EV)
|
|
ev_minus_4: Most underexposed image (-4 EV)
|
|
exposure_step: EV step size
|
|
exposure_adjust: Final exposure adjustment in stops (Nuke-style)
|
|
hdr_algorithm: HDR merge algorithm to use
|
|
output_colorspace: "linear_hdr" (true HDR) or "srgb_display" (tone-mapped for preview)
|
|
|
|
Returns:
|
|
Tuple containing merged HDR image with exposure adjustment applied
|
|
"""
|
|
try:
|
|
# Convert tensors to 8-bit sRGB images (no gamma correction needed)
|
|
# Debevec/Robertson algorithms expect sRGB input and output linear radiance
|
|
img_plus_4 = tensor_to_cv2(ev_plus_4)
|
|
img_plus_2 = tensor_to_cv2(ev_plus_2)
|
|
img_0 = tensor_to_cv2(ev_0)
|
|
img_minus_2 = tensor_to_cv2(ev_minus_2)
|
|
img_minus_4 = tensor_to_cv2(ev_minus_4)
|
|
|
|
logger.info(f"Processing 5-stop HDR with {hdr_algorithm} algorithm")
|
|
|
|
# Calculate exposure times based on EV values
|
|
# Brighter images (EV+) need longer exposure times
|
|
base_time = 1.0 / 60.0 # 1/60s as base exposure
|
|
|
|
time_plus_4 = base_time * (2.0 ** (2 * exposure_step)) # Longest time (brightest)
|
|
time_plus_2 = base_time * (2.0 ** exposure_step) # Longer time (brighter)
|
|
time_0 = base_time # Normal exposure
|
|
time_minus_2 = base_time * (2.0 ** (-exposure_step)) # Shorter time (darker)
|
|
time_minus_4 = base_time * (2.0 ** (-2 * exposure_step)) # Shortest time (darkest)
|
|
|
|
images = [img_plus_4, img_plus_2, img_0, img_minus_2, img_minus_4]
|
|
times = [time_plus_4, time_plus_2, time_0, time_minus_2, time_minus_4]
|
|
|
|
logger.info(f"5-Stop HDR: Processing with times {times} using {hdr_algorithm} algorithm")
|
|
logger.info(f"Auto-calibration: {'ENABLED' if auto_calibrate else 'DISABLED'}")
|
|
|
|
# Process HDR using selected algorithm - each should produce DIFFERENT results
|
|
hdr_result = self.processor.process_hdr(images, times, algorithm=hdr_algorithm,
|
|
auto_calibrate=auto_calibrate,
|
|
debevec_exposure_compensation=debevec_exposure_comp,
|
|
debevec_anti_banding=debevec_anti_banding)
|
|
|
|
logger.info(f"5-Stop HDR result range before tensor conversion: [{hdr_result.min():.6f}, {hdr_result.max():.6f}]")
|
|
|
|
# Apply exposure adjustment (Nuke-style)
|
|
if exposure_adjust != 0.0:
|
|
# Exposure formula: result * (2^exposure_adjust)
|
|
# +1.0 stop = 2x brighter, -1.0 stop = 0.5x darker
|
|
adjustment_factor = 2.0 ** exposure_adjust
|
|
hdr_result = hdr_result * adjustment_factor
|
|
logger.info(f"Applied exposure adjustment: {exposure_adjust:+.1f} stops (factor: {adjustment_factor:.3f})")
|
|
logger.info(f"HDR result after adjustment: [{hdr_result.min():.6f}, {hdr_result.max():.6f}]")
|
|
|
|
# Convert back to tensor with TRUE HDR values (above 1.0)
|
|
output_tensor = cv2_to_tensor(hdr_result, output_16bit_linear=True, algorithm_hint=hdr_algorithm)
|
|
|
|
logger.info(f"5-Stop final tensor range (should be > 1.0 for HDR): [{output_tensor.min():.6f}, {output_tensor.max():.6f}]")
|
|
|
|
return (output_tensor,)
|
|
|
|
except Exception as e:
|
|
logger.error(f"5-Stop HDR processing failed: {str(e)}")
|
|
# Return middle exposure as fallback
|
|
return (ev_0,)
|
|
|
|
|
|
class LatentStackProcessor5Stops:
|
|
"""
|
|
ComfyUI Custom Node for averaging latent representations from 5 different exposures
|
|
|
|
This node performs weighted averaging of 5 latent inputs with noise reduction strategies:
|
|
- Weighted average favoring middle exposures (less noisy)
|
|
- Optional center bias for cleaner results
|
|
- Denoising strength control
|
|
|
|
Unlike the Luminance Stack Processor which processes images using HDR algorithms,
|
|
this node works directly with latent representations for faster processing.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"latent_1": ("LATENT", {"tooltip": "First exposure latent (e.g., EV+4)"}),
|
|
"latent_2": ("LATENT", {"tooltip": "Second exposure latent (e.g., EV+2)"}),
|
|
"latent_3": ("LATENT", {"tooltip": "Third exposure latent (e.g., EV0)"}),
|
|
"latent_4": ("LATENT", {"tooltip": "Fourth exposure latent (e.g., EV-2)"}),
|
|
"latent_5": ("LATENT", {"tooltip": "Fifth exposure latent (e.g., EV-4)"}),
|
|
},
|
|
"optional": {
|
|
"blend_mode": (["simple_average", "weighted_center", "strong_center", "median_blend", "variance_adaptive", "quality_aware"], {
|
|
"default": "quality_aware",
|
|
"tooltip": "Blending strategy: quality_aware (multi-scale pyramid + quality metrics), variance_adaptive (spatial smoothing), weighted_center (favor middle), strong_center (heavily favor middle), median_blend (reduce noise), simple_average (equal weights)"
|
|
}),
|
|
"center_bias": ("FLOAT", {
|
|
"default": 0.4,
|
|
"min": 0.0,
|
|
"max": 0.8,
|
|
"step": 0.05,
|
|
"tooltip": "How much to favor the center exposure (reduces noise, 0.0 = equal weights)"
|
|
}),
|
|
"detail_preservation": ("FLOAT", {
|
|
"default": 0.7,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.05,
|
|
"tooltip": "Detail preservation strength for variance_adaptive mode (higher = cleaner details like tree leaves)"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LATENT",)
|
|
RETURN_NAMES = ("merged_latent",)
|
|
FUNCTION = "process_latent_stack"
|
|
CATEGORY = "latent/luminance"
|
|
|
|
def process_latent_stack(self, latent_1, latent_2, latent_3, latent_4, latent_5, blend_mode="quality_aware", center_bias=0.4, detail_preservation=0.7):
|
|
"""
|
|
Process latent stack with noise-reducing weighted averaging
|
|
|
|
Args:
|
|
latent_1 to latent_5: Latent dictionaries from different exposure images
|
|
blend_mode: Blending strategy to use
|
|
center_bias: How much to favor center exposure (0.0 to 0.8)
|
|
detail_preservation: Detail preservation strength for variance_adaptive (0.0 to 1.0)
|
|
|
|
Returns:
|
|
Tuple containing the merged latent dictionary
|
|
"""
|
|
try:
|
|
# Extract the latent tensors from the dictionaries
|
|
samples_1 = latent_1["samples"]
|
|
samples_2 = latent_2["samples"]
|
|
samples_3 = latent_3["samples"]
|
|
samples_4 = latent_4["samples"]
|
|
samples_5 = latent_5["samples"]
|
|
|
|
logger.info(f"Processing 5 latent inputs with blend mode: {blend_mode}")
|
|
logger.info(f"Latent shapes: {samples_1.shape}, {samples_2.shape}, {samples_3.shape}, {samples_4.shape}, {samples_5.shape}")
|
|
|
|
# Verify all latents have the same shape
|
|
if not (samples_1.shape == samples_2.shape == samples_3.shape == samples_4.shape == samples_5.shape):
|
|
raise ValueError(f"All latent inputs must have the same shape. Got: {samples_1.shape}, {samples_2.shape}, {samples_3.shape}, {samples_4.shape}, {samples_5.shape}")
|
|
|
|
# Apply different blending strategies
|
|
if blend_mode == "simple_average":
|
|
# Simple average: (L1 + L2 + L3 + L4 + L5) / 5
|
|
merged_samples = (samples_1 + samples_2 + samples_3 + samples_4 + samples_5) / 5.0
|
|
logger.info("Using simple average (equal weights)")
|
|
|
|
elif blend_mode == "weighted_center":
|
|
# Weighted average favoring center exposure (reduces noise)
|
|
# Center exposure (EV0) typically has best quality and least noise
|
|
# Outer exposures have more weight for: [0.15, 0.2, 0.3, 0.2, 0.15] = 1.0
|
|
center_weight = 0.3 + center_bias # Default: 0.7
|
|
side_weight = (1.0 - center_weight) / 4 # Distribute remaining weight
|
|
|
|
merged_samples = (
|
|
samples_1 * side_weight +
|
|
samples_2 * (side_weight * 1.33) + # Slightly favor inner exposures
|
|
samples_3 * center_weight +
|
|
samples_4 * (side_weight * 1.33) +
|
|
samples_5 * side_weight
|
|
)
|
|
logger.info(f"Using weighted center blend (center: {center_weight:.2f}, sides: {side_weight:.2f})")
|
|
|
|
elif blend_mode == "strong_center":
|
|
# Heavily favor center exposure for maximum noise reduction
|
|
# Weights: [0.05, 0.15, 0.6, 0.15, 0.05] = 1.0
|
|
center_weight = 0.6 + center_bias
|
|
side_weight = (1.0 - center_weight) / 4
|
|
|
|
merged_samples = (
|
|
samples_1 * side_weight +
|
|
samples_2 * (side_weight * 3) +
|
|
samples_3 * center_weight +
|
|
samples_4 * (side_weight * 3) +
|
|
samples_5 * side_weight
|
|
)
|
|
logger.info(f"Using strong center blend (center: {center_weight:.2f}, maximum noise reduction)")
|
|
|
|
elif blend_mode == "median_blend":
|
|
# Median-like blending: average top 3 closest to median
|
|
# This reduces noise by excluding outliers
|
|
stacked = torch.stack([samples_1, samples_2, samples_3, samples_4, samples_5], dim=0)
|
|
|
|
# Sort and take middle 3 values, then average
|
|
sorted_stack, _ = torch.sort(stacked, dim=0)
|
|
# Take indices 1, 2, 3 (excluding min and max)
|
|
merged_samples = (sorted_stack[1] + sorted_stack[2] + sorted_stack[3]) / 3.0
|
|
logger.info("Using median blend (excludes outliers, reduces noise)")
|
|
|
|
elif blend_mode == "quality_aware":
|
|
# Quality-Aware Multi-Scale Blending: Laplacian Pyramid + Enhanced Quality Metrics
|
|
# Combines the best of both worlds: multi-scale decomposition + sophisticated quality analysis
|
|
# This is the ultimate solution for artifact-free latent blending
|
|
|
|
logger.info("Using quality-aware multi-scale blending (Laplacian pyramid + enhanced quality)")
|
|
|
|
# Helper functions for pyramid construction
|
|
def gaussian_pyramid(img, levels=4):
|
|
"""Build Gaussian pyramid"""
|
|
pyramid = [img]
|
|
current = img
|
|
for i in range(levels):
|
|
smoothed = torch.nn.functional.avg_pool2d(current, kernel_size=5, stride=1, padding=2)
|
|
downsampled = torch.nn.functional.avg_pool2d(smoothed, kernel_size=2, stride=2, padding=0)
|
|
pyramid.append(downsampled)
|
|
current = downsampled
|
|
return pyramid
|
|
|
|
def laplacian_pyramid(img, levels=4):
|
|
"""Build Laplacian pyramid"""
|
|
gaussian_pyr = gaussian_pyramid(img, levels)
|
|
laplacian_pyr = []
|
|
|
|
for i in range(levels):
|
|
current_level = gaussian_pyr[i]
|
|
next_level = gaussian_pyr[i + 1]
|
|
upsampled = torch.nn.functional.interpolate(
|
|
next_level, size=current_level.shape[2:],
|
|
mode='bilinear', align_corners=False
|
|
)
|
|
laplacian = current_level - upsampled
|
|
laplacian_pyr.append(laplacian)
|
|
|
|
laplacian_pyr.append(gaussian_pyr[levels])
|
|
return laplacian_pyr
|
|
|
|
def compute_enhanced_quality(latent_samples):
|
|
"""Enhanced quality metrics with better edge preservation"""
|
|
# Contrast using Laplacian
|
|
laplacian_kernel = torch.tensor([[[
|
|
[0, -1, 0],
|
|
[-1, 4, -1],
|
|
[0, -1, 0]
|
|
]]], dtype=latent_samples.dtype, device=latent_samples.device)
|
|
|
|
contrast_maps = []
|
|
for c in range(latent_samples.shape[1]):
|
|
channel = latent_samples[:, c:c+1, :, :]
|
|
contrast = torch.nn.functional.conv2d(channel, laplacian_kernel, padding=1)
|
|
contrast_maps.append(torch.abs(contrast))
|
|
contrast_quality = torch.mean(torch.cat(contrast_maps, dim=1), dim=1, keepdim=True)
|
|
|
|
# Saturation
|
|
channel_mean = torch.mean(latent_samples, dim=1, keepdim=True)
|
|
saturation_quality = torch.mean(torch.abs(latent_samples - channel_mean), dim=1, keepdim=True)
|
|
|
|
# Exposedness
|
|
median_val = torch.median(latent_samples)
|
|
exposedness = -torch.abs(latent_samples - median_val)
|
|
exposedness_quality = torch.mean(exposedness, dim=1, keepdim=True)
|
|
exposedness_quality = torch.exp(exposedness_quality)
|
|
|
|
# Weighted combination with emphasis on contrast for edges
|
|
quality = (
|
|
contrast_quality * 0.6 + # Increased: 60% - edges matter most
|
|
saturation_quality * 0.25 + # 25%
|
|
exposedness_quality * 0.15 # 15%
|
|
)
|
|
|
|
# Light smoothing to prevent harsh transitions
|
|
quality = torch.nn.functional.avg_pool2d(quality, kernel_size=3, stride=1, padding=1)
|
|
|
|
return quality
|
|
|
|
# Build Laplacian pyramids for all latents
|
|
logger.info("Building multi-scale pyramids (4 levels)...")
|
|
pyramid_levels = 4
|
|
|
|
lap_pyr_1 = laplacian_pyramid(samples_1, pyramid_levels)
|
|
lap_pyr_2 = laplacian_pyramid(samples_2, pyramid_levels)
|
|
lap_pyr_3 = laplacian_pyramid(samples_3, pyramid_levels)
|
|
lap_pyr_4 = laplacian_pyramid(samples_4, pyramid_levels)
|
|
lap_pyr_5 = laplacian_pyramid(samples_5, pyramid_levels)
|
|
|
|
# Compute enhanced quality pyramids
|
|
quality_1_pyr = gaussian_pyramid(compute_enhanced_quality(samples_1), pyramid_levels)
|
|
quality_2_pyr = gaussian_pyramid(compute_enhanced_quality(samples_2), pyramid_levels)
|
|
quality_3_pyr = gaussian_pyramid(compute_enhanced_quality(samples_3), pyramid_levels)
|
|
quality_4_pyr = gaussian_pyramid(compute_enhanced_quality(samples_4), pyramid_levels)
|
|
quality_5_pyr = gaussian_pyramid(compute_enhanced_quality(samples_5), pyramid_levels)
|
|
|
|
# Apply center bias
|
|
center_boost = 1.0 + center_bias
|
|
inner_boost = 1.0 + center_bias * 0.3
|
|
|
|
for i in range(len(quality_3_pyr)):
|
|
quality_3_pyr[i] = quality_3_pyr[i] * center_boost
|
|
quality_2_pyr[i] = quality_2_pyr[i] * inner_boost
|
|
quality_4_pyr[i] = quality_4_pyr[i] * inner_boost
|
|
|
|
# Blend each pyramid level with adaptive quality power
|
|
logger.info("Blending pyramid levels with enhanced quality metrics...")
|
|
blended_pyramid = []
|
|
|
|
for level in range(pyramid_levels + 1):
|
|
# Get Laplacian bands
|
|
lap_1 = lap_pyr_1[level]
|
|
lap_2 = lap_pyr_2[level]
|
|
lap_3 = lap_pyr_3[level]
|
|
lap_4 = lap_pyr_4[level]
|
|
lap_5 = lap_pyr_5[level]
|
|
|
|
# Get quality maps
|
|
qual_1 = quality_1_pyr[level]
|
|
qual_2 = quality_2_pyr[level]
|
|
qual_3 = quality_3_pyr[level]
|
|
qual_4 = quality_4_pyr[level]
|
|
qual_5 = quality_5_pyr[level]
|
|
|
|
# Stack qualities
|
|
quality_stack = torch.stack([qual_1, qual_2, qual_3, qual_4, qual_5], dim=0)
|
|
|
|
# Adaptive quality power per level
|
|
# High-frequency (fine details) = very selective (high power)
|
|
# Low-frequency (smooth areas) = balanced blending (lower power)
|
|
if level == 0: # Finest details
|
|
quality_power = 1.0 + detail_preservation * 4.0 # Very selective
|
|
elif level == 1:
|
|
quality_power = 1.0 + detail_preservation * 3.0
|
|
elif level == 2:
|
|
quality_power = 1.0 + detail_preservation * 2.0
|
|
else: # Coarse levels
|
|
quality_power = 1.0 + detail_preservation * 1.0 # More balanced
|
|
|
|
quality_stack = torch.pow(quality_stack + 1e-8, quality_power)
|
|
|
|
# Normalize to weights
|
|
quality_sum = torch.sum(quality_stack, dim=0, keepdim=True)
|
|
weights = quality_stack / (quality_sum + 1e-8)
|
|
|
|
# Expand and blend
|
|
weight_1 = weights[0].expand_as(lap_1)
|
|
weight_2 = weights[1].expand_as(lap_2)
|
|
weight_3 = weights[2].expand_as(lap_3)
|
|
weight_4 = weights[3].expand_as(lap_4)
|
|
weight_5 = weights[4].expand_as(lap_5)
|
|
|
|
blended_level = (
|
|
lap_1 * weight_1 +
|
|
lap_2 * weight_2 +
|
|
lap_3 * weight_3 +
|
|
lap_4 * weight_4 +
|
|
lap_5 * weight_5
|
|
)
|
|
|
|
blended_pyramid.append(blended_level)
|
|
|
|
center_usage = torch.mean(weight_3).item()
|
|
logger.info(f" Level {level}: power={quality_power:.2f}, center={center_usage*100:.1f}%")
|
|
|
|
# Reconstruct from pyramid
|
|
logger.info("Reconstructing from multi-scale pyramid...")
|
|
merged_samples = blended_pyramid[pyramid_levels]
|
|
|
|
for level in range(pyramid_levels - 1, -1, -1):
|
|
upsampled = torch.nn.functional.interpolate(
|
|
merged_samples,
|
|
size=blended_pyramid[level].shape[2:],
|
|
mode='bilinear',
|
|
align_corners=False
|
|
)
|
|
merged_samples = upsampled + blended_pyramid[level]
|
|
|
|
logger.info(f"Multi-scale quality-aware blending complete!")
|
|
logger.info(f" Laplacian pyramid with enhanced edge-preserving quality metrics")
|
|
logger.info(f" Adaptive selectivity per frequency band")
|
|
logger.info(f" Professional artifact-free results")
|
|
|
|
elif blend_mode == "variance_adaptive":
|
|
# Variance-Adaptive Blending with Spatial Smoothing
|
|
# FIXED: Added proper spatial filtering to avoid checkerboard patterns
|
|
|
|
logger.info("Using variance-adaptive blend (intelligent artifact reduction with spatial smoothing)")
|
|
|
|
# Stack all latents for analysis
|
|
stacked = torch.stack([samples_1, samples_2, samples_3, samples_4, samples_5], dim=0)
|
|
|
|
# Compute variance across exposures for each spatial location
|
|
# High variance = inconsistent detail (potential artifacts)
|
|
# Low variance = consistent detail (safe to blend)
|
|
variance_map = torch.var(stacked, dim=0, keepdim=False)
|
|
|
|
# CRITICAL FIX: Apply spatial smoothing to variance map to avoid checkerboard artifacts
|
|
# Average variance across channels first for better stability
|
|
variance_spatial = torch.mean(variance_map, dim=1, keepdim=True) # [B, 1, H, W]
|
|
|
|
# Apply Gaussian-like smoothing using average pooling with padding
|
|
# This prevents harsh per-pixel transitions that create checkerboard patterns
|
|
kernel_size = 7 # Larger kernel = smoother transitions
|
|
padding = kernel_size // 2
|
|
|
|
# Smooth the variance map using average pooling
|
|
variance_smoothed = torch.nn.functional.avg_pool2d(
|
|
variance_spatial,
|
|
kernel_size=kernel_size,
|
|
stride=1,
|
|
padding=padding
|
|
)
|
|
|
|
# Broadcast back to all channels
|
|
variance_smoothed = variance_smoothed.expand(-1, variance_map.shape[1], -1, -1)
|
|
|
|
# Normalize variance to 0-1 range for weighting
|
|
# Use percentile-based normalization for robustness
|
|
var_flat = variance_smoothed.flatten()
|
|
var_p05 = torch.quantile(var_flat, 0.05)
|
|
var_p95 = torch.quantile(var_flat, 0.95)
|
|
variance_normalized = torch.clamp((variance_smoothed - var_p05) / (var_p95 - var_p05 + 1e-8), 0, 1)
|
|
|
|
# Apply smooth non-linear mapping to create gentler transitions
|
|
# This further reduces hard edges in the weight map
|
|
variance_normalized = torch.pow(variance_normalized, 0.7) # Gamma correction for smoother curve
|
|
|
|
# Create adaptive weights based on smoothed variance
|
|
# High variance -> favor center (safe, clean)
|
|
# Low variance -> use weighted blend (more dynamic range)
|
|
|
|
# Base weights for low-variance areas (more aggressive blending)
|
|
center_weight_low = 0.3 + center_bias * 0.5 # e.g., 0.5 with default
|
|
side_weight_low = (1.0 - center_weight_low) / 4
|
|
|
|
# Weights for high-variance areas (conservative, favor center heavily)
|
|
center_weight_high = 0.7 + center_bias # e.g., 1.1 -> clamp to reasonable range
|
|
center_weight_high = min(center_weight_high, 0.95) # Cap at 0.95
|
|
side_weight_high = (1.0 - center_weight_high) / 4
|
|
|
|
# Interpolate weights based on variance and detail_preservation strength
|
|
# detail_preservation controls how aggressive we are in problem areas
|
|
variance_factor = variance_normalized * detail_preservation
|
|
|
|
# Compute adaptive weights for each latent
|
|
# Center gets more weight in high-variance areas
|
|
weight_center = center_weight_low + (center_weight_high - center_weight_low) * variance_factor
|
|
weight_side = side_weight_low + (side_weight_high - side_weight_low) * variance_factor
|
|
weight_mid = weight_side * 1.33 # Inner exposures get slightly more weight
|
|
|
|
# Apply adaptive weighted blending
|
|
merged_samples = (
|
|
samples_1 * weight_side + # EV+4 (outer)
|
|
samples_2 * weight_mid + # EV+2 (inner)
|
|
samples_3 * weight_center + # EV0 (center) - gets more weight in problem areas
|
|
samples_4 * weight_mid + # EV-2 (inner)
|
|
samples_5 * weight_side # EV-4 (outer)
|
|
)
|
|
|
|
# Normalize to ensure proper weighting (sum of weights should = 1)
|
|
total_weight = weight_side * 2 + weight_mid * 2 + weight_center
|
|
merged_samples = merged_samples / total_weight
|
|
|
|
# Log statistics
|
|
high_variance_pixels = torch.sum(variance_normalized > 0.5).item()
|
|
total_pixels = variance_normalized.numel()
|
|
logger.info(f"Variance-adaptive (spatially smoothed): {high_variance_pixels}/{total_pixels} pixels ({100*high_variance_pixels/total_pixels:.1f}%) in high-variance regions")
|
|
logger.info(f"Spatial smoothing: 7x7 kernel applied to prevent checkerboard artifacts")
|
|
logger.info(f"Detail preservation: {detail_preservation:.2f} (higher = more artifact reduction)")
|
|
logger.info(f"Adaptive weights - Center: {center_weight_low:.3f}->{center_weight_high:.3f}")
|
|
|
|
else:
|
|
# Fallback to simple average
|
|
merged_samples = (samples_1 + samples_2 + samples_3 + samples_4 + samples_5) / 5.0
|
|
logger.info("Using fallback simple average")
|
|
|
|
logger.info(f"Latent stack merged successfully")
|
|
logger.info(f"Output shape: {merged_samples.shape}")
|
|
logger.info(f"Value range: [{merged_samples.min():.6f}, {merged_samples.max():.6f}]")
|
|
|
|
# Return in the same dictionary format as ComfyUI expects
|
|
return ({"samples": merged_samples},)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Latent stack processing failed: {str(e)}")
|
|
# Return the middle latent as fallback
|
|
return (latent_3,)
|
|
|
|
|
|
class HDRExportNode:
|
|
"""
|
|
ComfyUI Custom Node for exporting HDR images to EXR format
|
|
Clean filename interface matching standard ComfyUI save nodes
|
|
Preserves full dynamic range data without normalization
|
|
|
|
VFX PIPELINE NOTE:
|
|
EXR files store linear radiance values (32-bit float per channel).
|
|
The flat/desaturated appearance of Debevec/Robertson output is correct.
|
|
Professional compositing software expects this linear format.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"hdr_image": ("IMAGE", {"tooltip": "HDR image tensor with values potentially above 1.0"}),
|
|
"filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "Base filename (without extension)"}),
|
|
},
|
|
"optional": {
|
|
"output_path": ("STRING", {"default": "", "tooltip": "Output path: Empty=default ComfyUI/output, /subfolder=output/subfolder, or full custom path"}),
|
|
"counter": ("INT", {"default": 1, "min": 0, "max": 99999, "step": 1, "tooltip": "Frame/sequence counter"}),
|
|
"format": (["exr", "hdr"], {"default": "exr", "tooltip": "HDR file format"}),
|
|
"bit_depth": (["16bit", "32bit"], {"default": "32bit", "tooltip": "EXR precision: 32bit = maximum quality, 16bit = smaller files"}),
|
|
"compression": (["none", "rle", "zip", "piz", "pxr24"], {"default": "zip", "tooltip": "EXR compression type"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("filepath",)
|
|
FUNCTION = "export_hdr"
|
|
CATEGORY = "Luminance Stack Processor"
|
|
OUTPUT_NODE = True
|
|
|
|
def export_hdr(self, hdr_image: torch.Tensor, filename_prefix: str = "ComfyUI",
|
|
output_path: str = "", counter: int = 1, format: str = "exr", bit_depth: str = "32bit", compression: str = "zip"):
|
|
"""
|
|
Export HDR image with clean filename interface (no automatic prefixes)
|
|
|
|
Args:
|
|
hdr_image: HDR image tensor (potentially with values > 1.0)
|
|
filename_prefix: Base filename (no extension)
|
|
output_path: Custom output directory
|
|
counter: Frame/sequence number
|
|
format: Output format (exr/hdr)
|
|
bit_depth: EXR precision (16bit/32bit)
|
|
compression: EXR compression type
|
|
|
|
Returns:
|
|
Tuple containing the filepath of saved HDR file
|
|
"""
|
|
try:
|
|
# Convert tensor to numpy array
|
|
if len(hdr_image.shape) == 4:
|
|
hdr_image = hdr_image.squeeze(0) # Remove batch dimension
|
|
|
|
hdr_array = hdr_image.cpu().numpy()
|
|
|
|
logger.info(f"HDR Export: Input range [{hdr_array.min():.6f}, {hdr_array.max():.6f}]")
|
|
logger.info(f"HDR Export: Shape {hdr_array.shape}, dtype {hdr_array.dtype}")
|
|
|
|
# Check for HDR data
|
|
hdr_pixels = int(np.sum(hdr_array > 1.0))
|
|
negative_pixels = int(np.sum(hdr_array < 0.0))
|
|
logger.info(f"HDR Export: HDR pixels (>1.0): {hdr_pixels}, Negative pixels: {negative_pixels}")
|
|
|
|
# Determine output path - default to ComfyUI/output/ directory
|
|
output_path_clean = output_path.strip() if output_path else ""
|
|
|
|
if not output_path_clean:
|
|
# Use default ComfyUI output directory
|
|
output_dir = self._get_comfyui_output_directory()
|
|
logger.info(f"Using default ComfyUI output directory: {output_dir}")
|
|
elif output_path_clean.startswith("/"):
|
|
# User specified a subdirectory within ComfyUI output (e.g., "/Test" -> "output/Test")
|
|
base_output_dir = self._get_comfyui_output_directory()
|
|
subdirectory = output_path_clean[1:] # Remove leading "/"
|
|
output_dir = os.path.join(base_output_dir, subdirectory)
|
|
logger.info(f"Using ComfyUI output subdirectory: {output_dir}")
|
|
else:
|
|
# User specified absolute or relative custom path
|
|
output_dir = output_path_clean
|
|
logger.info(f"Using custom absolute path: {output_dir}")
|
|
|
|
# Create output directory if it doesn't exist
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
# Clean filename generation (NO automatic timestamps or prefixes)
|
|
if counter > 0:
|
|
# Include counter if specified
|
|
filename = f"{filename_prefix}_{counter:05d}.{format}"
|
|
else:
|
|
# No counter - simple filename
|
|
filename = f"{filename_prefix}.{format}"
|
|
|
|
filepath = os.path.join(output_dir, filename)
|
|
|
|
logger.info(f"HDR Export: Saving to {filepath}")
|
|
|
|
# Convert RGB to BGR for OpenCV (ComfyUI tensors are RGB)
|
|
# Set precision based on bit_depth selection
|
|
if bit_depth == "32bit":
|
|
target_dtype = np.float32 # 32-bit single precision
|
|
logger.info("Using 32-bit float precision for maximum HDR quality")
|
|
else:
|
|
# For 16-bit, we still use float32 in processing but OpenCV will write as half-float
|
|
target_dtype = np.float32
|
|
logger.info("Using 16-bit half-float precision for smaller file size")
|
|
|
|
if len(hdr_array.shape) == 3 and hdr_array.shape[2] == 3:
|
|
hdr_bgr = cv2.cvtColor(hdr_array.astype(target_dtype), cv2.COLOR_RGB2BGR)
|
|
else:
|
|
hdr_bgr = hdr_array.astype(target_dtype)
|
|
|
|
# Save HDR file with TRUE bit depth control
|
|
if format.lower() == "exr":
|
|
# CRITICAL: OpenCV's cv2.imwrite doesn't control EXR bit depth properly
|
|
# We need to use proper EXR writing method
|
|
try:
|
|
if IMAGEIO_AVAILABLE:
|
|
# Use imageio for proper 32-bit EXR writing
|
|
if bit_depth == "32bit":
|
|
logger.info("Using imageio for TRUE 32-bit EXR writing")
|
|
# Convert BGR back to RGB for imageio
|
|
hdr_rgb = cv2.cvtColor(hdr_bgr, cv2.COLOR_BGR2RGB)
|
|
# Write as float32 for true 32-bit precision
|
|
iio.imwrite(filepath, hdr_rgb.astype(np.float32))
|
|
success = True
|
|
else:
|
|
logger.info("Using imageio for 16-bit EXR writing")
|
|
# Convert BGR back to RGB for imageio
|
|
hdr_rgb = cv2.cvtColor(hdr_bgr, cv2.COLOR_BGR2RGB)
|
|
# Write as float16 for 16-bit precision
|
|
iio.imwrite(filepath, hdr_rgb.astype(np.float16))
|
|
success = True
|
|
else:
|
|
# Fallback to OpenCV (limited bit depth control)
|
|
logger.warning("imageio not available - using OpenCV (limited 32-bit support)")
|
|
success = cv2.imwrite(filepath, hdr_bgr)
|
|
except Exception as e:
|
|
logger.error(f"imageio EXR writing failed: {e}")
|
|
logger.info("Falling back to OpenCV EXR writing")
|
|
success = cv2.imwrite(filepath, hdr_bgr)
|
|
|
|
elif format.lower() == "hdr":
|
|
# Save as Radiance HDR format (always 32-bit RGBE)
|
|
logger.info("Saving as Radiance HDR format (32-bit RGBE)")
|
|
success = cv2.imwrite(filepath, hdr_bgr)
|
|
else:
|
|
success = cv2.imwrite(filepath, hdr_bgr) # Default to EXR behavior
|
|
|
|
if not success:
|
|
raise RuntimeError(f"Failed to save HDR file: {filepath}")
|
|
|
|
# Verify the saved file preserves HDR data
|
|
if os.path.exists(filepath):
|
|
try:
|
|
# Load back and verify HDR preservation
|
|
verification_img = cv2.imread(filepath, cv2.IMREAD_UNCHANGED)
|
|
if verification_img is not None:
|
|
max_val = np.max(verification_img)
|
|
min_val = np.min(verification_img)
|
|
logger.info(f"HDR Export verification: Range in saved file: [{min_val:.6f}, {max_val:.6f}]")
|
|
|
|
if max_val > 1.0:
|
|
logger.info("✅ HDR values above 1.0 successfully preserved!")
|
|
else:
|
|
logger.warning("⚠️ No HDR values above 1.0 detected (may be LDR data)")
|
|
|
|
if min_val < 0.0:
|
|
logger.info("✅ Negative values preserved (signed HDR range)")
|
|
|
|
# Check file size as secondary verification
|
|
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
|
logger.info(f"HDR file size: {file_size_mb:.2f} MB")
|
|
|
|
# Get image stats
|
|
stats = self._get_file_stats(filepath)
|
|
logger.info(f"Image dimensions: {stats['width']}x{stats['height']}, {stats['channels']} channels")
|
|
|
|
else:
|
|
logger.warning("Could not verify saved HDR file")
|
|
except Exception as verify_e:
|
|
logger.warning(f"Could not verify HDR file: {verify_e}")
|
|
|
|
logger.info(f"✅ HDR {format.upper()} file exported: {filepath}")
|
|
return (filepath,)
|
|
else:
|
|
raise RuntimeError(f"HDR file was not created: {filepath}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"HDR export failed: {str(e)}")
|
|
import traceback
|
|
logger.error(f"HDR export traceback: {traceback.format_exc()}")
|
|
|
|
# Return error message
|
|
error_path = f"ERROR: {str(e)}"
|
|
return (error_path,)
|
|
|
|
def _get_comfyui_output_directory(self) -> str:
|
|
"""
|
|
Determine the ComfyUI output directory using multiple fallback methods
|
|
Returns the path to the ComfyUI output directory
|
|
"""
|
|
try:
|
|
import folder_paths
|
|
output_dir = folder_paths.get_output_directory()
|
|
logger.info(f"Found ComfyUI output directory via folder_paths: {output_dir}")
|
|
return output_dir
|
|
except ImportError:
|
|
# Fallback: Look for ComfyUI output directory structure
|
|
# Navigate up from custom_nodes to find ComfyUI root
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
comfyui_root = None
|
|
|
|
# Try to find ComfyUI root by looking for typical structure
|
|
search_dir = current_dir
|
|
for _ in range(5): # Search up to 5 levels up
|
|
if os.path.exists(os.path.join(search_dir, "custom_nodes")) and \
|
|
os.path.exists(os.path.join(search_dir, "models")):
|
|
comfyui_root = search_dir
|
|
break
|
|
search_dir = os.path.dirname(search_dir)
|
|
|
|
if comfyui_root:
|
|
output_dir = os.path.join(comfyui_root, "output")
|
|
logger.info(f"Found ComfyUI root, using output directory: {output_dir}")
|
|
return output_dir
|
|
else:
|
|
# Final fallback - assume we're in custom_nodes and go up 2 levels
|
|
output_dir = os.path.join(os.path.dirname(os.path.dirname(current_dir)), "output")
|
|
logger.info(f"Using fallback output directory: {output_dir}")
|
|
return output_dir
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error determining ComfyUI output directory: {e}")
|
|
# Emergency fallback - try to create output directory relative to current location
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
output_dir = os.path.join(os.path.dirname(os.path.dirname(current_dir)), "output")
|
|
logger.info(f"Using emergency fallback output directory: {output_dir}")
|
|
return output_dir
|
|
|
|
def _get_file_stats(self, filepath: str) -> dict:
|
|
"""Get statistics about the saved file"""
|
|
try:
|
|
# File size
|
|
size_bytes = os.path.getsize(filepath)
|
|
size_mb = size_bytes / (1024 * 1024)
|
|
|
|
# Image dimensions using OpenCV
|
|
img = cv2.imread(filepath, cv2.IMREAD_ANYCOLOR | cv2.IMREAD_ANYDEPTH)
|
|
if img is not None:
|
|
height, width = img.shape[:2]
|
|
channels = img.shape[2] if len(img.shape) > 2 else 1
|
|
else:
|
|
width = height = channels = 0
|
|
|
|
return {
|
|
'size_mb': size_mb,
|
|
'width': width,
|
|
'height': height,
|
|
'channels': channels
|
|
}
|
|
except Exception:
|
|
return {
|
|
'size_mb': 0,
|
|
'width': 0,
|
|
'height': 0,
|
|
'channels': 0
|
|
}
|
|
|
|
|
|
# Node class mappings for ComfyUI
|
|
NODE_CLASS_MAPPINGS = {
|
|
"LuminanceStackProcessor3Stops": LuminanceStackProcessor3Stops,
|
|
"LuminanceStackProcessor5Stops": LuminanceStackProcessor5Stops,
|
|
"LatentStackProcessor5Stops": LatentStackProcessor5Stops,
|
|
"HDRExportNode": HDRExportNode
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"LuminanceStackProcessor3Stops": "Luminance Stack Processor (3 Stops)",
|
|
"LuminanceStackProcessor5Stops": "Luminance Stack Processor (5 Stops)",
|
|
"LatentStackProcessor5Stops": "Latent Stack Processor (5 Stops)",
|
|
"HDRExportNode": "HDR Export to EXR"
|
|
}
|