Files
sumitchatterjee13-Luminance…/luminance_stack_processor.py
T

777 lines
35 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.0.1
Semantic Versioning: MAJOR.MINOR.PATCH
"""
import numpy as np
import torch
import cv2
from typing import Tuple, List
import logging
import os
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def tensor_to_cv2(tensor: torch.Tensor, apply_gamma_correction: bool = False) -> np.ndarray:
"""Convert ComfyUI tensor to OpenCV format for HDR processing - VFX Pipeline Fixed"""
# 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()
if apply_gamma_correction:
# CRITICAL VFX FIX: Proper sRGB to Linear conversion for Debevec/Robertson
# This is ESSENTIAL for camera response function recovery
logger.info("Applying sRGB to Linear conversion (CRITICAL for Debevec accuracy)")
# Proper sRGB to Linear gamma correction (based on research)
image_linear = np.where(image <= 0.04045,
image / 12.92,
np.power((image + 0.055) / 1.055, 2.4))
# Convert to 8-bit for OpenCV HDR functions
image_8bit = np.clip(image_linear * 255.0, 0, 255).astype(np.uint8)
logger.info(f"sRGB->Linear conversion applied - critical for VFX flat log profile")
else:
# For Mertens and Natural Blend - keep original gamma
image_8bit = np.clip(image * 255.0, 0, 255).astype(np.uint8)
logger.info(f"Original gamma preserved - no conversion applied")
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 == "natural_blend":
# Natural Blend: Conservative scaling, values 1-5 range
p90 = np.percentile(hdr_image, 90.0)
if p90 > 0:
hdr_linear = hdr_image * (2.0 / p90)
else:
hdr_linear = hdr_image * 2.0
hdr_linear = np.clip(hdr_linear, 0.0, 8.0)
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"]:
# VFX PIPELINE: PERFECT FLAT LOG PROFILE - Research-based implementation
# This should look FLAT and DESATURATED - that's CORRECT for professional VFX!
logger.info(f" Processing VFX flat log profile - this will look flat/desaturated (CORRECT)")
# VFX STANDARD: Minimal scaling for professional flat log appearance
if hdr_image.max() > 0:
# Research-based scaling: Use median (50th percentile) as reference
# This creates the proper flat log appearance VFX artists expect
p50 = np.percentile(hdr_image, 50.0) # Middle gray reference
if p50 > 0:
# Scale to VFX standard: 18% gray = 0.18 (professional standard)
# This creates the flat, log-like appearance
scale_factor = 0.18 / p50
hdr_linear = hdr_image * scale_factor
# VFX RANGE: Allow wide dynamic range (no aggressive clipping)
# Professional VFX needs values up to 100+ for bright sources
hdr_linear = np.clip(hdr_linear, 0.0, 2000.0) # Wide range for VFX work
logger.info(f" VFX scaling applied: middle gray -> 0.18 (scale: {scale_factor:.3f})")
else:
hdr_linear = hdr_image
logger.info(f" No scaling needed - preserving original values")
else:
hdr_linear = hdr_image
logger.info(f" Zero image detected - no processing applied")
logger.info(f" VFX FLAT LOG: Appearance will be flat/desaturated - this is PROFESSIONAL STANDARD")
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):
self.calibrator = cv2.createCalibrateDebevec()
self.merge_debevec = cv2.createMergeDebevec()
# Alternative algorithms
self.merge_mertens = cv2.createMergeMertens()
self.merge_robertson = cv2.createMergeRobertson()
self.calibrator_robertson = cv2.createCalibrateRobertson()
def process_hdr(self, images: List[np.ndarray], exposure_times: List[float], algorithm: str = "natural_blend") -> np.ndarray:
"""
Process multiple exposure images using various HDR algorithms
Args:
images: List of 8-bit images (OpenCV format) - CRITICAL: Must be 8-bit for OpenCV HDR
exposure_times: List of exposure times in seconds
algorithm: HDR algorithm to use ("mertens", "debevec", "robertson")
Returns:
HDR merged image in linear colorspace (float32)
"""
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")
# CRITICAL VFX COLOR FIX: Handle color channels properly
if len(img.shape) == 3 and img.shape[2] == 3: # 3-channel image
if algorithm in ["debevec", "robertson"]:
# VFX PIPELINE COLOR FIX: ComfyUI tensors are RGB, OpenCV expects BGR
# This is THE cause of color inversion in Debevec!
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
processed_images.append(img_bgr)
logger.info(f"CRITICAL FIX: RGB->BGR conversion for {algorithm} (fixes color inversion)")
else:
# For Mertens and Natural Blend, keep RGB format
processed_images.append(img)
logger.info(f"Keeping RGB format for {algorithm}")
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)
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 == "natural_blend":
# Natural Blend - maintains EV0 appearance with enhanced dynamic range
logger.info("Using Natural Blend exposure blending...")
hdr_radiance = self._blend_ev0_based(processed_images, times)
elif algorithm == "robertson":
# Robertson algorithm - VFX pipeline style (raw linear radiance)
logger.info("Using Robertson algorithm - VFX pipeline mode...")
response = self.calibrator_robertson.process(processed_images, times)
hdr_radiance = self.merge_robertson.process(processed_images, times, response)
logger.info(f"Robertson raw radiance range: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
# VFX PIPELINE: NO TONE MAPPING! Keep raw linear radiance
if hdr_radiance.max() > 1000.0: # Only if extremely bright values
scale_factor = 100.0 / np.percentile(hdr_radiance, 99.5)
hdr_radiance = hdr_radiance * scale_factor
logger.info(f"Applied minimal scaling for numerical stability: {scale_factor:.3f}")
logger.info(f"Robertson VFX output range: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
else: # Default to Debevec - VFX pipeline style (raw linear radiance)
# Estimate camera response function using Debevec method
logger.info("Using Debevec algorithm - VFX pipeline mode (raw linear radiance)...")
response = self.calibrator.process(processed_images, times)
logger.info(f"Response function shape: {response.shape}")
# Merge images into HDR using Debevec algorithm
hdr_radiance = self.merge_debevec.process(processed_images, times, response)
logger.info(f"Debevec raw radiance range: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.6f}]")
# VFX PIPELINE: NO TONE MAPPING! Keep raw linear radiance for professional workflow
# This should look flat/log-like - that's correct for VFX
# Only apply minimal scaling if needed for numerical stability
if hdr_radiance.max() > 1000.0: # Only if extremely bright values
scale_factor = 100.0 / np.percentile(hdr_radiance, 99.5)
hdr_radiance = hdr_radiance * scale_factor
logger.info(f"Applied minimal scaling for numerical stability: {scale_factor:.3f}")
logger.info(f"Debevec VFX output range: [{hdr_radiance.min():.6f}, {hdr_radiance.max():.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")
# VFX PIPELINE COLOR FIX: Convert back to RGB for ComfyUI
# Since we converted to BGR for processing, convert back to RGB for output
if len(hdr_radiance.shape) == 3 and hdr_radiance.shape[2] == 3 and algorithm in ["debevec", "robertson"]:
hdr_radiance = cv2.cvtColor(hdr_radiance.astype(np.float32), cv2.COLOR_BGR2RGB)
logger.info(f"CRITICAL FIX: BGR->RGB conversion for output (maintains color consistency)")
# The result is 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 _blend_ev0_based(self, images: List[np.ndarray], times: List[float]) -> np.ndarray:
"""
Blend exposures using EV0 as base appearance with enhanced dynamic range
This method preserves the exact look of the EV0 image while adding
highlight and shadow detail from other exposures.
Args:
images: List of exposure images (should be ordered from overexposed to underexposed)
times: Exposure times
Returns:
Enhanced image that looks like EV0 but with extended dynamic range
"""
logger.info("Natural Blend: Preserving EV0 appearance with enhanced dynamic range")
# Find the EV0 image (middle exposure - should be the normal exposure)
ev0_idx = len(images) // 2 # Middle image is typically EV0
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 for processing
float_images = [img.astype(np.float32) / 255.0 for img in images]
# Create luminance masks for blending - use BGR format (as images come in BGR)
if ev0_base.shape[2] == 3:
# Convert BGR to grayscale for luminance calculation
ev0_gray = cv2.cvtColor(ev0_base, cv2.COLOR_BGR2GRAY)
else:
ev0_gray = ev0_base[:, :, 0] # Fallback
# Start with EV0 as the base result
result = ev0_base.copy()
# Blend highlights from underexposed images (better highlight detail)
for i in range(ev0_idx + 1, len(float_images)):
if i >= len(float_images):
continue
img = float_images[i]
# Create highlight mask - where EV0 is bright but this image has detail
highlight_mask = self._create_highlight_mask(ev0_gray, threshold=0.8) # Higher threshold
# Gentle blending with reduced strength
blend_strength = 0.3 # Reduce blend strength to 30%
# Blend highlight areas with reduced strength
for c in range(min(3, result.shape[2])): # RGB/BGR channels
result[:, :, c] = (1 - highlight_mask * blend_strength) * result[:, :, c] + (highlight_mask * blend_strength) * img[:, :, c]
logger.info(f"Gently blended highlights from underexposed image {i} (strength: {blend_strength})")
# Blend shadows from overexposed images (better shadow detail)
for i in range(ev0_idx):
if i < 0:
continue
img = float_images[i]
# Create shadow mask - where EV0 is dark but this image has detail
shadow_mask = self._create_shadow_mask(ev0_gray, threshold=0.2) # Lower threshold
# Gentle blending with reduced strength
blend_strength = 0.3 # Reduce blend strength to 30%
# Blend shadow areas with reduced strength
for c in range(min(3, result.shape[2])): # RGB/BGR channels
result[:, :, c] = (1 - shadow_mask * blend_strength) * result[:, :, c] + (shadow_mask * blend_strength) * img[:, :, c]
logger.info(f"Gently blended shadows from overexposed image {i} (strength: {blend_strength})")
logger.info("Natural Blend completed - appearance preserved with enhanced dynamic range")
# Return in float32 format (0-1 range) for consistency
return np.clip(result, 0.0, 1.0).astype(np.float32)
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)
class LuminanceStackProcessor3Stops:
"""
Professional ComfyUI Custom Node for 3-stop HDR processing
Processes EV+2, EV+0, EV-2 exposures using Debevec Algorithm
"""
@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"
}),
"hdr_algorithm": (["natural_blend", "mertens", "debevec", "robertson"], {
"default": "natural_blend"
}),
}
}
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, hdr_algorithm="natural_blend"):
"""
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
Returns:
Tuple containing merged HDR image
"""
try:
# VFX CRITICAL: Apply proper gamma correction for Debevec/Robertson
# This is ESSENTIAL for flat log profile and color accuracy
apply_gamma = hdr_algorithm in ["debevec", "robertson"]
img_plus_2 = tensor_to_cv2(ev_plus_2, apply_gamma_correction=apply_gamma)
img_0 = tensor_to_cv2(ev_0, apply_gamma_correction=apply_gamma)
img_minus_2 = tensor_to_cv2(ev_minus_2, apply_gamma_correction=apply_gamma)
if apply_gamma:
logger.info("VFX PIPELINE: sRGB->Linear conversion applied - CRITICAL for flat log profile")
# Calculate exposure times based on EV values
# EV difference formula: time = base_time * 2^(-EV_difference)
base_time = 1.0 / 60.0 # 1/60s as base exposure
time_plus_2 = base_time * (2.0 ** (-exposure_step)) # Shorter time (overexposed)
time_0 = base_time # Normal exposure
time_minus_2 = base_time * (2.0 ** exposure_step) # Longer time (underexposed)
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")
# Process HDR using selected algorithm - each should produce DIFFERENT results
hdr_result = self.processor.process_hdr(images, times, algorithm=hdr_algorithm)
logger.info(f"3-Stop HDR result range before tensor conversion: [{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 Debevec Algorithm
"""
@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"
}),
"hdr_algorithm": (["natural_blend", "mertens", "debevec", "robertson"], {
"default": "natural_blend"
}),
}
}
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, hdr_algorithm="natural_blend"):
"""
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
Returns:
Tuple containing merged HDR image
"""
try:
# VFX CRITICAL: Apply proper gamma correction for Debevec/Robertson
# This is ESSENTIAL for flat log profile and color accuracy
apply_gamma = hdr_algorithm in ["debevec", "robertson"]
img_plus_4 = tensor_to_cv2(ev_plus_4, apply_gamma_correction=apply_gamma)
img_plus_2 = tensor_to_cv2(ev_plus_2, apply_gamma_correction=apply_gamma)
img_0 = tensor_to_cv2(ev_0, apply_gamma_correction=apply_gamma)
img_minus_2 = tensor_to_cv2(ev_minus_2, apply_gamma_correction=apply_gamma)
img_minus_4 = tensor_to_cv2(ev_minus_4, apply_gamma_correction=apply_gamma)
if apply_gamma:
logger.info("VFX PIPELINE: sRGB->Linear conversion applied - CRITICAL for flat log profile")
# Calculate exposure times based on EV values
base_time = 1.0 / 60.0 # 1/60s as base exposure
time_plus_4 = base_time * (2.0 ** (-2 * exposure_step))
time_plus_2 = base_time * (2.0 ** (-exposure_step))
time_0 = base_time
time_minus_2 = base_time * (2.0 ** exposure_step)
time_minus_4 = base_time * (2.0 ** (2 * exposure_step))
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")
# Process HDR using selected algorithm - each should produce DIFFERENT results
hdr_result = self.processor.process_hdr(images, times, algorithm=hdr_algorithm)
logger.info(f"5-Stop HDR result range before tensor conversion: [{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 HDRExportNode:
"""
ComfyUI Custom Node for exporting HDR images to EXR format
Preserves full dynamic range data without normalization
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"hdr_image": ("IMAGE", {"tooltip": "HDR image tensor with values potentially above 1.0"}),
"filename_prefix": ("STRING", {"default": "HDR_Export", "tooltip": "Filename prefix for the EXR file"}),
"output_path": ("STRING", {"default": "", "tooltip": "Output directory (leave empty for ComfyUI output folder)"}),
}
}
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 = "HDR_Export", output_path: str = ""):
"""
Export HDR image to EXR format preserving full dynamic range
Args:
hdr_image: HDR image tensor (potentially with values > 1.0)
filename_prefix: Base filename for the output
output_path: Output directory path
Returns:
Tuple containing the filepath of saved EXR
"""
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}")
# Determine output path
if not output_path or output_path.strip() == "":
# Use ComfyUI's output directory
try:
import folder_paths
output_dir = folder_paths.get_output_directory()
except ImportError:
# Fallback if folder_paths module doesn't exist
output_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "output")
except Exception:
# Secondary fallback
import os as os_module
output_dir = os_module.path.join(os_module.getcwd(), "output")
else:
output_dir = output_path.strip()
# Create output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
# Generate unique filename with timestamp
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{filename_prefix}_{timestamp}.exr"
filepath = os.path.join(output_dir, filename)
logger.info(f"HDR Export: Saving to {filepath}")
# Convert BGR to RGB if needed (OpenCV uses BGR)
if len(hdr_array.shape) == 3 and hdr_array.shape[2] == 3:
# Assume ComfyUI tensors are RGB, but OpenCV expects BGR for writing
hdr_bgr = cv2.cvtColor(hdr_array.astype(np.float32), cv2.COLOR_RGB2BGR)
else:
hdr_bgr = hdr_array.astype(np.float32)
# Save as EXR using OpenCV - this preserves HDR data!
success = cv2.imwrite(filepath, hdr_bgr)
if success:
# Verify the saved file
saved_stats = self._get_file_stats(filepath)
logger.info(f"HDR Export: Successfully saved EXR")
logger.info(f" File: {filepath}")
logger.info(f" Size: {saved_stats['size_mb']:.2f} MB")
logger.info(f" Dimensions: {saved_stats['width']}x{saved_stats['height']}")
logger.info(f" Channels: {saved_stats['channels']}")
# Verify HDR values are preserved
test_read = cv2.imread(filepath, cv2.IMREAD_ANYCOLOR | cv2.IMREAD_ANYDEPTH)
if test_read is not None:
logger.info(f" Verification: Saved range [{test_read.min():.6f}, {test_read.max():.6f}]")
if test_read.max() > 1.0:
logger.info(f" ✅ HDR data preserved (max value: {test_read.max():.2f})")
else:
logger.warning(f" ⚠️ HDR data may be clipped (max value: {test_read.max():.2f})")
return (filepath,)
else:
error_msg = f"Failed to save EXR file to {filepath}"
logger.error(f"HDR Export: {error_msg}")
raise RuntimeError(error_msg)
except Exception as e:
error_msg = f"HDR Export failed: {str(e)}"
logger.error(error_msg)
raise RuntimeError(error_msg)
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,
"HDRExportNode": HDRExportNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LuminanceStackProcessor3Stops": "Luminance Stack Processor (3 Stops)",
"LuminanceStackProcessor5Stops": "Luminance Stack Processor (5 Stops)",
"HDRExportNode": "HDR Export to EXR"
}