1031 lines
47 KiB
Python
1031 lines
47 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.4
|
|
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 == "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: Raw linear radiance values
|
|
# IMPORTANT: The flat/desaturated appearance is CORRECT for professional VFX
|
|
# This is linear radiance data meant for compositing, not direct viewing
|
|
|
|
logger.info(f" Processing VFX linear radiance - flat appearance is CORRECT for compositing")
|
|
|
|
# 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
|
|
|
|
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!)
|
|
- "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)
|
|
|
|
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)
|
|
|
|
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 == "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 - 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
|
|
# Debevec outputs linear radiance values (already in linear space)
|
|
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}]")
|
|
|
|
# NOTE: OpenCV's Debevec sometimes has issues with color
|
|
# Check if values are reasonable
|
|
if hdr_radiance.mean() < 0.001:
|
|
logger.warning("Debevec produced very dark image, applying recovery")
|
|
# Try to recover by scaling
|
|
hdr_radiance = hdr_radiance * 100.0
|
|
elif hdr_radiance.mean() > 100.0:
|
|
logger.warning("Debevec produced extremely bright image, scaling down")
|
|
hdr_radiance = hdr_radiance / 100.0
|
|
|
|
# Apply minimal scaling for VFX pipeline
|
|
if hdr_radiance.max() > 1000.0:
|
|
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 (linear radiance): [{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")
|
|
|
|
# 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)
|
|
|
|
|
|
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": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.1
|
|
}),
|
|
"hdr_algorithm": (["radiance_fusion", "natural_blend", "mertens", "debevec", "robertson"] + (["hdrutils"] if HDRUTILS_AVAILABLE else []), {
|
|
"default": "radiance_fusion"
|
|
}),
|
|
}
|
|
}
|
|
|
|
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=1.0, hdr_algorithm="radiance_fusion"):
|
|
"""
|
|
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: 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}]")
|
|
|
|
# Apply exposure adjustment (Nuke-style)
|
|
if exposure_adjust != 0.0:
|
|
# Standard exposure formula: result * (2^(-exposure_adjust))
|
|
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": 1.0,
|
|
"min": -5.0,
|
|
"max": 5.0,
|
|
"step": 0.1
|
|
}),
|
|
"hdr_algorithm": (["radiance_fusion", "natural_blend", "mertens", "debevec", "robertson"] + (["hdrutils"] if HDRUTILS_AVAILABLE else []), {
|
|
"default": "radiance_fusion"
|
|
}),
|
|
}
|
|
}
|
|
|
|
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=1.0, hdr_algorithm="radiance_fusion"):
|
|
"""
|
|
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)
|
|
|
|
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
|
|
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}]")
|
|
|
|
# Apply exposure adjustment (Nuke-style)
|
|
if exposure_adjust != 0.0:
|
|
# Standard exposure formula: result * (2^(-exposure_adjust))
|
|
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 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": "Custom output directory (leave empty for ComfyUI output folder)"}),
|
|
"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}")
|
|
|
|
# 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
|
|
output_dir = os.path.join(os.getcwd(), "output")
|
|
else:
|
|
output_dir = output_path.strip()
|
|
|
|
# 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 based on format and bit depth
|
|
if format.lower() == "exr":
|
|
# Save as EXR with specified bit depth and compression
|
|
if bit_depth == "16bit":
|
|
# For 16-bit, OpenCV automatically uses half-float when saving EXR
|
|
logger.info("Saving as 16-bit half-float EXR (smaller file, good quality)")
|
|
else:
|
|
# For 32-bit, OpenCV uses full float precision
|
|
logger.info("Saving as 32-bit full-float EXR (maximum precision, larger file)")
|
|
|
|
success = cv2.imwrite(filepath, hdr_bgr)
|
|
|
|
elif format.lower() == "hdr":
|
|
# Save as Radiance HDR format (always 32-bit)
|
|
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)
|
|
logger.info(f"HDR Export verification: Max value in saved file: {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)")
|
|
|
|
# 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")
|
|
|
|
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_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"
|
|
}
|