Files
sumitchatterjee13-Luminance…/luminance_stack_processor.py
T

351 lines
14 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
# 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"""
# 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 and scale to 8-bit (input images are already 8-bit)
image = tensor.cpu().numpy()
# Apply gamma correction (sRGB to linear) before HDR processing
# This is crucial for proper camera response function recovery
image_linear = np.where(image <= 0.04045,
image / 12.92,
np.power((image + 0.055) / 1.055, 2.4))
# Convert back to 8-bit for OpenCV HDR functions (since inputs are 8-bit)
image_8bit = np.clip(image_linear * 255.0, 0, 255).astype(np.uint8)
logger.info(f"Converted tensor to CV2: 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) -> torch.Tensor:
"""Convert OpenCV HDR image to ComfyUI tensor format"""
if output_16bit_linear:
# Convert HDR linear data to 16-bit linear format
# Scale to 16-bit range while preserving linear characteristics
# Find a reasonable scaling factor based on HDR content
# Use 95th percentile to avoid extreme outliers affecting scaling
scale_reference = np.percentile(hdr_image, 95.0)
if scale_reference > 0:
# Scale so that the 95th percentile maps to ~80% of 16-bit range
# This preserves highlights while utilizing most of the 16-bit range
scale_factor = (0.8 * 65535.0) / scale_reference
scaled_linear = hdr_image * scale_factor
else:
scaled_linear = hdr_image * 65535.0
# Clamp to 16-bit range but preserve linear values above 1.0
hdr_16bit_linear = np.clip(scaled_linear, 0.0, 65535.0)
# Convert to ComfyUI tensor format (0-1 range) while preserving 16-bit precision
normalized_16bit = hdr_16bit_linear / 65535.0
logger.info(f"HDR 16-bit linear output:")
logger.info(f" Original range: [{hdr_image.min():.6f}, {hdr_image.max():.6f}]")
logger.info(f" 16-bit range: [{hdr_16bit_linear.min():.1f}, {hdr_16bit_linear.max():.1f}]")
logger.info(f" Final range: [{normalized_16bit.min():.6f}, {normalized_16bit.max():.6f}]")
logger.info(f" Scale factor: {scale_factor:.2f}")
else:
# Legacy floating point output (not recommended for 16-bit workflow)
max_val = np.percentile(hdr_image, 99.9)
if max_val > 0:
normalized_16bit = hdr_image / max_val
else:
normalized_16bit = hdr_image
normalized_16bit = np.clip(normalized_16bit, 0.0, 10.0)
# Add batch dimension and convert to tensor
tensor = torch.from_numpy(normalized_16bit.astype(np.float32)).unsqueeze(0)
return tensor
class DebevecHDRProcessor:
"""Core HDR processing using Debevec Algorithm"""
def __init__(self):
self.calibrator = cv2.createCalibrateDebevec()
self.merge_debevec = cv2.createMergeDebevec()
def process_hdr(self, images: List[np.ndarray], exposure_times: List[float]) -> np.ndarray:
"""
Process multiple exposure images using Debevec algorithm with proper linear colorspace handling
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
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")
# Ensure proper format
if len(img.shape) == 3 and img.shape[2] == 3: # RGB
processed_images.append(img)
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 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]}")
# Estimate camera response function using Debevec method
logger.info("Estimating camera response function...")
response = self.calibrator.process(processed_images, times)
logger.info(f"Response function shape: {response.shape}")
# Merge images into HDR using Debevec algorithm
logger.info("Merging images using Debevec algorithm...")
hdr_radiance = self.merge_debevec.process(processed_images, times, response)
# 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:")
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")
# The result is already in linear colorspace (scene radiance values)
# Don't apply tone mapping - preserve the HDR data as-is
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
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"
}),
}
}
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):
"""
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:
# Convert tensors to OpenCV format
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)
# 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}")
# Process HDR using improved Debevec algorithm
hdr_result = self.processor.process_hdr(images, times)
# Convert back to tensor - output 16-bit linear data
output_tensor = cv2_to_tensor(hdr_result, output_16bit_linear=True)
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"
}),
}
}
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):
"""
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:
# Convert tensors to OpenCV format
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)
# 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}")
# Process HDR using improved Debevec algorithm
hdr_result = self.processor.process_hdr(images, times)
# Convert back to tensor - output 16-bit linear data
output_tensor = cv2_to_tensor(hdr_result, output_16bit_linear=True)
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,)
# Node class mappings for ComfyUI
NODE_CLASS_MAPPINGS = {
"LuminanceStackProcessor3Stops": LuminanceStackProcessor3Stops,
"LuminanceStackProcessor5Stops": LuminanceStackProcessor5Stops,
}
# Display names for nodes in ComfyUI interface
NODE_DISPLAY_NAME_MAPPINGS = {
"LuminanceStackProcessor3Stops": "Luminance Stack Processor (3 Stops)",
"LuminanceStackProcessor5Stops": "Luminance Stack Processor (5 Stops)",
}