diff --git a/luminance_stack_processor.py b/luminance_stack_processor.py index a37d5b9..1a3d5de 100644 --- a/luminance_stack_processor.py +++ b/luminance_stack_processor.py @@ -363,85 +363,64 @@ class DebevecHDRProcessor: def _radiance_fusion(self, images: List[np.ndarray], times: List[float]) -> np.ndarray: """ - Radiance Fusion - TRUE Nuke-style HDR blending algorithm + Radiance Fusion - Nuke-inspired HDR blending algorithm - Matches Nuke's exact behavior: - 1. Convert to linear light (no normalization!) - 2. Exposure compensation based on EV differences - 3. Plus operations in linear space - 4. Average operation preserving full dynamic range + 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 the same dynamic range as Nuke's merge operations. + 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 FULL dynamic range like Nuke + Radiance fusion result with excellent HDR preservation """ - logger.info("Radiance Fusion: TRUE Nuke-style HDR blending with exposure compensation") + logger.info("Radiance Fusion: Nuke-style HDR blending with plus/average operations") - # CRITICAL: Convert to linear WITHOUT normalizing to 0-1! - # Nuke works with actual light values, not normalized values - linear_images = [] - for i, img in enumerate(images): - # Convert to float but keep in 0-255 range initially - img_float = img.astype(np.float32) - - # Convert to linear light (gamma correction) - # This is CRITICAL - Nuke works in linear space - img_linear = np.where(img_float <= 10.31, # 0.04045 * 255 - img_float / (255.0 * 12.92), - np.power((img_float / 255.0 + 0.055) / 1.055, 2.4)) - - linear_images.append(img_linear) - logger.info(f"Image {i} linear range: [{img_linear.min():.6f}, {img_linear.max():.6f}]") + # Convert to float32 for HDR processing + float_images = [img.astype(np.float32) / 255.0 for img in images] - # Calculate EV differences and apply exposure compensation - # This is what Nuke's merge node does internally - base_time = times[len(times) // 2] # Middle exposure time - compensated_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] - for i, (img_linear, time) in enumerate(zip(linear_images, times)): - # Calculate exposure compensation factor - # Longer exposure time = brighter image = needs to be scaled down - exposure_compensation = np.log2(time / base_time) # EV difference - compensation_factor = 2.0 ** (-exposure_compensation) # Nuke's formula + 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 - # Apply compensation to match Nuke's behavior - compensated = img_linear * compensation_factor - compensated_images.append(compensated) - - logger.info(f"Image {i}: EV compensation {exposure_compensation:.1f} stops (factor: {compensation_factor:.3f})") - logger.info(f" Compensated range: [{compensated.min():.6f}, {compensated.max():.6f}]") - - # Now apply Nuke's exact operations - if len(compensated_images) == 5: - # 5-stop: [ev+4, ev+2, ev0, ev-2, ev-4] - ev_plus_4, ev_plus_2, ev_0, ev_minus_2, ev_minus_4 = compensated_images - - # NUKE PLUS: Add all outer exposures (preserves FULL dynamic range) + # 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: Nuke plus operation on outer exposures") + logger.info("5-stop: Added outer exposures (ev±4, ev±2) using Nuke plus operation") - elif len(compensated_images) == 3: - # 3-stop: [ev+2, ev0, ev-2] - ev_plus_2, ev_0, ev_minus_2 = compensated_images + 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: Add outer exposures + # NUKE PLUS OPERATION: Add outer exposures outer_sum = ev_plus_2 + ev_minus_2 - logger.info("3-stop: Nuke plus operation on outer exposures") + 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: (outer_sum + ev0) / 2 - preserves full range + # 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"Nuke-style average operation completed") - logger.info(f"Final HDR range: [{radiance_result.min():.6f}, {radiance_result.max():.6f}]") + 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") - logger.info(f"HDR pixels above 10.0: {np.sum(radiance_result > 10.0)} pixels") - # Return with FULL dynamic range - NO CLIPPING whatsoever! + # 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: