diff --git a/CHANGELOG.md b/CHANGELOG.md index 5041978..48c6420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Nothing yet +### Added +- **NEW: EV0-Based Blending Algorithm** - Now default! Preserves exact EV0 appearance with enhanced dynamic range +- **Smart luminance masking** - Seamless highlight and shadow detail recovery ### Changed - **Optimized for 8-bit input workflow** - Removed unnecessary 8-bit conversion since inputs are already 8-bit - **Enhanced 16-bit linear output** - Improved scaling algorithm for better 16-bit range utilization +- **Changed default algorithm** - EV0-Based is now default instead of Mertens for natural results ### Fixed +- **CRITICAL: Fixed contrast issues** - EV0-Based algorithm maintains original image contrast - **CRITICAL: Fixed color inversion issues** - Added proper BGR↔RGB conversion for correct color handling - **Fixed brightness problems** - Improved scaling and algorithm selection -- **Added Mertens Exposure Fusion algorithm** - Now default, produces better results like Adobe Lightroom +- **Added Mertens Exposure Fusion algorithm** - Alternative for Lightroom-style results - **Added Robertson algorithm** - Alternative HDR method for different use cases -- **Enhanced algorithm selection** - Users can choose between Mertens (default), Debevec, and Robertson +- **Enhanced algorithm selection** - Users can choose between EV0-Based (default), Mertens, Debevec, and Robertson - **CRITICAL: Fixed Debevec algorithm issues** - Added Reinhard tone mapping to fix brightness/color inversion - **Improved Debevec output** - Now produces natural results similar to Mertens - **Enhanced per-algorithm scaling** - Different scaling strategies for different algorithms diff --git a/README.md b/README.md index 8f59236..d625ceb 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ For extended dynamic range with 5 exposures: ## 🔬 How It Works -The nodes implement **multiple HDR algorithms** with **Mertens Exposure Fusion** as the default (often produces better results): +The nodes implement **multiple HDR algorithms** with **EV0-Based Blending** as the default (preserves natural appearance): 1. **Analyzes Multiple Exposures**: Takes differently exposed 8-bit images of the same scene 2. **Estimates Camera Response Function**: Determines how the camera sensor responds to light @@ -109,11 +109,17 @@ The nodes implement **multiple HDR algorithms** with **Mertens Exposure Fusion** ### 🎯 **HDR Algorithm Options:** -#### **Mertens Exposure Fusion (Default - Recommended)** -- **Best for most use cases**: Produces natural-looking results similar to Adobe Lightroom -- **No tone mapping needed**: Output looks like enhanced EV0 with extended dynamic range +#### **EV0-Based Blending (Default - Recommended)** +- **Perfect for natural look**: Uses EV0 as base appearance, adds dynamic range from other exposures +- **Zero contrast change**: Output looks exactly like EV0 with enhanced highlight/shadow detail +- **Seamless blending**: Uses luminance masks for smooth transitions +- **Best of both worlds**: Natural EV0 appearance + extended dynamic range data + +#### **Mertens Exposure Fusion** +- **Good alternative**: Produces natural-looking results similar to Adobe Lightroom +- **Automatic blending**: No tone mapping needed, enhanced EV0-like appearance - **Fastest processing**: No camera response function estimation required -- **Color accuracy**: Superior color handling, avoids common HDR artifacts +- **May add contrast**: Can make images slightly more contrasty than original #### **Debevec Algorithm (Classic HDR)** - **Industry standard**: Original HDR reconstruction method from 1997 @@ -140,13 +146,14 @@ The nodes implement **multiple HDR algorithms** with **Mertens Exposure Fusion** - Adjust `exposure_step` parameter if using different increments ### Algorithm Selection Guide: -- **Use Mertens (Default)**: For most photography - produces natural results like Lightroom -- **Use Debevec**: For scientific/research work requiring precise scene radiance values +- **Use EV0-Based (Default)**: When you want to keep the exact look of your EV0 image with enhanced dynamic range +- **Use Mertens**: For natural HDR look similar to Adobe Lightroom (may add some contrast) +- **Use Debevec**: For scientific/research work requiring precise scene radiance values - **Use Robertson**: Alternative to Debevec with different mathematical approach ## ⚙️ Technical Details -- **Algorithms**: Mertens Exposure Fusion (default), Debevec, Robertson +- **Algorithms**: EV0-Based Blending (default), Mertens Exposure Fusion, Debevec, Robertson - **Input Format**: 8-bit ComfyUI IMAGE tensors (0-1 float range from 8-bit sources) - **Output Format**: 16-bit linear colorspace images for extended dynamic range - **Processing**: OpenCV's `createCalibrateDebevec()` and `createMergeDebevec()` @@ -162,21 +169,23 @@ The nodes implement **multiple HDR algorithms** with **Mertens Exposure Fusion** - Restart ComfyUI completely 2. **Color inversion or wrong colors**: - - **Mertens algorithm** (default) - produces natural colors, no issues expected + - **EV0-Based algorithm** (default) - uses original EV0 colors, no issues expected + - **Mertens algorithm** - produces natural colors, no issues expected - **Debevec algorithm** - now includes automatic Reinhard tone mapping for proper colors - **Robertson algorithm** - also includes tone mapping for natural appearance 3. **Image too bright or too dark**: - - **All algorithms** now produce proper brightness levels automatically - - **Mertens** (default) - natural brightness similar to Adobe Lightroom + - **EV0-Based** (default) - keeps exact brightness of your EV0 image + - **All other algorithms** produce proper brightness levels automatically + - **Mertens** - natural brightness similar to Adobe Lightroom - **Debevec/Robertson** - include automatic Reinhard tone mapping for proper brightness - - Output should look like enhanced EV0 with extended dynamic range 4. **Poor HDR results**: - Ensure input images are properly exposed (not all over/under) - - Check that images are aligned (use tripod) + - Check that images are aligned (use tripod) - Verify EV differences match your capture method - - Try different algorithms: Mertens usually works best + - **For EV0-Based**: Make sure the EV0 image is your desired base appearance + - Try different algorithms: EV0-Based preserves natural look, Mertens for more artistic results 5. **Memory issues**: - Process smaller images first diff --git a/luminance_stack_processor.py b/luminance_stack_processor.py index e9c95eb..450a60b 100644 --- a/luminance_stack_processor.py +++ b/luminance_stack_processor.py @@ -49,7 +49,11 @@ def cv2_to_tensor(hdr_image: np.ndarray, output_16bit_linear: bool = True, algor # Scale to 16-bit range while preserving linear characteristics # Different scaling strategies based on algorithm - if algorithm_hint == "mertens": + if algorithm_hint == "ev0_base": + # EV0-based should have similar range to original images, use 85th percentile + scale_reference = np.percentile(hdr_image, 85.0) + target_scale = 0.85 # 85% of 16-bit range + elif algorithm_hint == "mertens": # Mertens typically produces 0-2 range, use 90th percentile scale_reference = np.percentile(hdr_image, 90.0) target_scale = 0.8 # 80% of 16-bit range @@ -73,7 +77,16 @@ def cv2_to_tensor(hdr_image: np.ndarray, output_16bit_linear: bool = True, algor logger.info(f"HDR 16-bit linear output ({algorithm_hint}):") logger.info(f" Original range: [{hdr_image.min():.6f}, {hdr_image.max():.6f}]") - logger.info(f" Scale reference ({90 if algorithm_hint == 'mertens' else 95}th percentile): {scale_reference:.6f}") + + # Choose percentile based on algorithm + if algorithm_hint == "ev0_base": + percentile_used = 85 + elif algorithm_hint == "mertens": + percentile_used = 90 + else: + percentile_used = 95 + + logger.info(f" Scale reference ({percentile_used}th percentile): {scale_reference:.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}") @@ -148,6 +161,11 @@ class DebevecHDRProcessor: # Mertens output is typically in 0-1 range, scale appropriately if hdr_radiance.max() <= 1.0: hdr_radiance = hdr_radiance * 2.0 # Boost for better dynamic range + + elif algorithm == "ev0_base": + # EV0-based exposure blending - maintains EV0 appearance with enhanced dynamic range + logger.info("Using EV0-based exposure blending...") + hdr_radiance = self._blend_ev0_based(processed_images, times) elif algorithm == "robertson": # Robertson algorithm - alternative to Debevec @@ -251,6 +269,98 @@ class DebevecHDRProcessor: normalized = hdr_image / np.percentile(hdr_image, 95) return np.clip(normalized, 0.0, 5.0).astype(np.float32) return hdr_image.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("EV0-based blending: 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 + ev0_gray = cv2.cvtColor(ev0_base, cv2.COLOR_RGB2GRAY) + + # 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)): + img = float_images[i] + + # Create highlight mask - where EV0 is bright but this image has detail + highlight_mask = self._create_highlight_mask(ev0_gray) + + # Blend highlight areas + for c in range(3): # RGB channels + result[:, :, c] = (1 - highlight_mask) * result[:, :, c] + highlight_mask * img[:, :, c] + + logger.info(f"Blended highlights from underexposed image {i}") + + # Blend shadows from overexposed images (better shadow detail) + for i in range(ev0_idx): + img = float_images[i] + + # Create shadow mask - where EV0 is dark but this image has detail + shadow_mask = self._create_shadow_mask(ev0_gray) + + # Blend shadow areas + for c in range(3): # RGB channels + result[:, :, c] = (1 - shadow_mask) * result[:, :, c] + shadow_mask * img[:, :, c] + + logger.info(f"Blended shadows from overexposed image {i}") + + # Convert back to 0-255 range for consistency with other algorithms + result_8bit = np.clip(result * 255.0, 0, 255).astype(np.uint8) + + logger.info("EV0-based blending completed - appearance preserved with enhanced dynamic range") + + return result_8bit.astype(np.float32) / 255.0 # Return as float32 in 0-1 range + + def _create_highlight_mask(self, gray_image: np.ndarray, threshold: float = 0.7) -> 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 + mask[bright_areas] = (gray_image[bright_areas] - threshold) / (1.0 - threshold) + + # Smooth the mask to avoid harsh transitions + mask = cv2.GaussianBlur(mask, (21, 21), 0) + + return np.clip(mask, 0, 1) + + def _create_shadow_mask(self, gray_image: np.ndarray, threshold: float = 0.3) -> 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 + mask[dark_areas] = (threshold - gray_image[dark_areas]) / threshold + + # Smooth the mask to avoid harsh transitions + mask = cv2.GaussianBlur(mask, (21, 21), 0) + + return np.clip(mask, 0, 1) class LuminanceStackProcessor3Stops: @@ -275,8 +385,8 @@ class LuminanceStackProcessor3Stops: "step": 0.1, "display": "number" }), - "hdr_algorithm": (["mertens", "debevec", "robertson"], { - "default": "mertens" + "hdr_algorithm": (["ev0_base", "mertens", "debevec", "robertson"], { + "default": "ev0_base" }), } } @@ -289,7 +399,7 @@ class LuminanceStackProcessor3Stops: 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="mertens"): + def process_3_stop_hdr(self, ev_plus_2, ev_0, ev_minus_2, exposure_step=2.0, hdr_algorithm="ev0_base"): """ Process 3-stop HDR merge @@ -373,7 +483,7 @@ class LuminanceStackProcessor5Stops: 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="mertens"): + 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="ev0_base"): """ Process 5-stop HDR merge