""" Normal Map Utilities Tools for combining, processing, and adjusting normal maps """ import torch class NormalMapCombiner: """ Combine two normal maps using proper blending techniques Supports multiple blending modes: - Reoriented Normal Mapping (RNM): Most accurate, preserves detail - Whiteout/Overlay: Fast and good quality, industry standard - Linear: Simple blend (not recommended, but included for comparison) """ @classmethod def INPUT_TYPES(cls): return { "required": { "base_normal": ("IMAGE",), "detail_normal": ("IMAGE",), "blend_mode": (["reoriented", "whiteout", "linear"], { "default": "reoriented", "tooltip": "Blending algorithm: Reoriented (best), Whiteout (fast), Linear (simple)" }), "detail_strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01, "display": "number", "tooltip": "Strength of the detail normal map" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("combined_normal",) FUNCTION = "combine" CATEGORY = "PBR/Normals" def normalize_normal(self, normal): """Normalize normal map vectors to unit length""" # Normal maps store XYZ in RGB channels # Make sure they're unit vectors length = torch.sqrt(torch.sum(normal * normal, dim=-1, keepdim=True)) length = torch.clamp(length, min=1e-6) # Avoid division by zero return normal / length def blend_linear(self, base, detail, strength): """Simple linear blend (not mathematically correct but simple)""" # Interpolate between base and detail blended = base * (1.0 - strength) + detail * strength return self.normalize_normal(blended) def blend_whiteout(self, base, detail, strength): """ Whiteout/Overlay blending (industry standard) Fast and produces good results Also called "UDN blending" or "Unreal blending" """ # Adjust detail strength # Blend detail toward neutral (0.5, 0.5, 1.0) based on strength neutral = torch.tensor([0.5, 0.5, 1.0], device=detail.device, dtype=detail.dtype) neutral = neutral.view(1, 1, 1, 3) detail_adjusted = detail * strength + neutral * (1.0 - strength) # Convert from [0,1] to [-1,1] range base_unpacked = base * 2.0 - 1.0 detail_unpacked = detail_adjusted * 2.0 - 1.0 # Whiteout blend formula # n1.xy + n2.xy, n1.z * n2.z combined = torch.zeros_like(base_unpacked) combined[..., 0:2] = base_unpacked[..., 0:2] + detail_unpacked[..., 0:2] combined[..., 2:3] = base_unpacked[..., 2:3] * detail_unpacked[..., 2:3] # Normalize combined = self.normalize_normal(combined) # Convert back to [0,1] range return combined * 0.5 + 0.5 def blend_reoriented(self, base, detail, strength): """ Reoriented Normal Mapping (RNM) Most accurate method, preserves detail and handles steep angles well Reference: http://blog.selfshadow.com/publications/blending-in-detail/ """ # Adjust detail strength neutral = torch.tensor([0.5, 0.5, 1.0], device=detail.device, dtype=detail.dtype) neutral = neutral.view(1, 1, 1, 3) detail_adjusted = detail * strength + neutral * (1.0 - strength) # Convert from [0,1] to [-1,1] range base_unpacked = base * 2.0 - 1.0 detail_unpacked = detail_adjusted * 2.0 - 1.0 # Reoriented Normal Mapping formula # t = n1.xy * n2.z + n2.xy # result = normalize(t.x, t.y, n1.z) t = base_unpacked[..., 0:2] * detail_unpacked[..., 2:3] + detail_unpacked[..., 0:2] combined = torch.zeros_like(base_unpacked) combined[..., 0:2] = t combined[..., 2:3] = base_unpacked[..., 2:3] # Normalize combined = self.normalize_normal(combined) # Convert back to [0,1] range return combined * 0.5 + 0.5 def combine(self, base_normal, detail_normal, blend_mode, detail_strength): """Combine two normal maps""" print("\n" + "="*60) print("Normal Map Combiner") print("="*60) print(f"Base shape: {base_normal.shape}") print(f"Detail shape: {detail_normal.shape}") print(f"Blend mode: {blend_mode}") print(f"Detail strength: {detail_strength}") # Ensure both normals have 3 channels if base_normal.shape[-1] != 3: print(f"\n✗ ERROR: Base normal must have 3 channels (RGB), got {base_normal.shape[-1]}") return (base_normal,) if detail_normal.shape[-1] != 3: print(f"\n✗ ERROR: Detail normal must have 3 channels (RGB), got {detail_normal.shape[-1]}") return (base_normal,) # Match batch sizes if needed if base_normal.shape[0] != detail_normal.shape[0]: # Use first batch from whichever has multiple if base_normal.shape[0] > 1: base_normal = base_normal[0:1] if detail_normal.shape[0] > 1: detail_normal = detail_normal[0:1] # Match spatial dimensions if needed (simple nearest neighbor resize) if base_normal.shape[1:3] != detail_normal.shape[1:3]: print(f"\n⚠ Warning: Resizing detail normal to match base") # Use PyTorch interpolate detail_normal = torch.nn.functional.interpolate( detail_normal.permute(0, 3, 1, 2), # BHWC -> BCHW size=base_normal.shape[1:3], mode='bilinear', align_corners=False ).permute(0, 2, 3, 1) # BCHW -> BHWC # Apply blending if blend_mode == "linear": combined = self.blend_linear(base_normal, detail_normal, detail_strength) print("\n✓ Combined using Linear blend") elif blend_mode == "whiteout": combined = self.blend_whiteout(base_normal, detail_normal, detail_strength) print("\n✓ Combined using Whiteout/Overlay blend") elif blend_mode == "reoriented": combined = self.blend_reoriented(base_normal, detail_normal, detail_strength) print("\n✓ Combined using Reoriented Normal Mapping (RNM)") else: combined = base_normal print(f"\n✗ Unknown blend mode: {blend_mode}") print(f" Output range: [{combined.min():.3f}, {combined.max():.3f}]") print("\n" + "="*60) print("✓ Normal Combination Complete") print("="*60 + "\n") return (combined,) class LotusNormalProcessor: """ Process Lotus normal map output with channel controls Connect: LotusSampler → VAEDecode → This node """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal": ("IMAGE",), "invert_red": ("BOOLEAN", { "default": False, "tooltip": "Invert red channel (X axis)" }), "invert_green": ("BOOLEAN", { "default": True, "tooltip": "Invert green channel (Y axis) - OpenGL vs DirectX" }), "invert_blue": ("BOOLEAN", { "default": False, "tooltip": "Invert blue channel (Z axis)" }), "strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01, "display": "number", "tooltip": "Normal map strength (1.0 = original)" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("normal",) FUNCTION = "process" CATEGORY = "PBR/Normals" def process(self, normal, invert_red, invert_green, invert_blue, strength): """Process normal map""" print("\n" + "="*60) print("Lotus Normal Processor") print("="*60) print(f"Input shape: {normal.shape}") print(f"Invert - R:{invert_red}, G:{invert_green}, B:{invert_blue}") print(f"Strength: {strength}") result = normal.clone() # Ensure 3 channels if result.shape[-1] < 3: print("\n✗ ERROR: Normal map must have at least 3 channels (RGB)") return (normal,) # Convert to [-1, 1] range for processing result = result * 2.0 - 1.0 # Invert channels as requested if invert_red: result[..., 0] = -result[..., 0] print("✓ Red channel inverted") if invert_green: result[..., 1] = -result[..., 1] print("✓ Green channel inverted") if invert_blue: result[..., 2] = -result[..., 2] print("✓ Blue channel inverted") # Apply strength # Blend between flat normal (0,0,1) and the processed normal if strength != 1.0: flat_normal = torch.tensor([0.0, 0.0, 1.0], device=result.device, dtype=result.dtype) flat_normal = flat_normal.view(1, 1, 1, 3) result[..., :3] = result[..., :3] * strength + flat_normal * (1.0 - strength) # Normalize length = torch.sqrt(torch.sum(result[..., :3] * result[..., :3], dim=-1, keepdim=True)) length = torch.clamp(length, min=1e-6) result[..., :3] = result[..., :3] / length print(f"✓ Strength applied: {strength}") # Convert back to [0, 1] range result = result * 0.5 + 0.5 print(f" Output range: [{result.min():.3f}, {result.max():.3f}]") print("\n" + "="*60) print("✓ Normal Processing Complete") print("="*60 + "\n") return (result,) # Node registration class NormalToDepth: """ Convert normal maps to depth/height maps Uses integration and approximation methods """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal": ("IMAGE",), "method": (["integration", "blue_channel", "hybrid"], { "default": "hybrid", "tooltip": "Conversion method: integration (accurate), blue_channel (fast), hybrid (balanced)" }), "strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01, "display": "number", "tooltip": "Depth strength multiplier" }), "iterations": ("INT", { "default": 50, "min": 1, "max": 200, "step": 1, "tooltip": "Integration iterations (higher = more accurate but slower)" }), "blur_radius": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1, "display": "number", "tooltip": "Smoothing blur radius" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("depth",) FUNCTION = "convert" CATEGORY = "Texture Alchemist/Normal" def convert(self, normal, method, strength, iterations, blur_radius): """Convert normal map to depth map""" print("\n" + "="*60) print("Normal to Depth Converter") print("="*60) print(f"Input shape: {normal.shape}, dtype: {normal.dtype}") print(f"Method: {method}") print(f"Strength: {strength}") # Ensure input is float32 normal = normal.float() batch, height, width, channels = normal.shape device = normal.device dtype = torch.float32 # Always use float32 for processing if method == "blue_channel": # Simple method: use blue channel as depth approximation print("Using blue channel method (fast)") if channels >= 3: depth = normal[:, :, :, 2:3] # Blue channel else: depth = normal[:, :, :, 0:1] depth = depth * strength elif method == "integration": # Integration method: integrate gradients from normal map print(f"Using integration method ({iterations} iterations)") depth = self._integrate_gradients(normal, iterations, strength) else: # hybrid # Combine both methods print(f"Using hybrid method ({iterations} iterations)") # Get blue channel approximation if channels >= 3: blue_depth = normal[:, :, :, 2:3] else: blue_depth = normal[:, :, :, 0:1] # Get integrated depth integrated_depth = self._integrate_gradients(normal, iterations, 1.0) # Blend them depth = (blue_depth * 0.3 + integrated_depth * 0.7) * strength # Apply smoothing if requested if blur_radius > 0: depth = self._apply_blur(depth, blur_radius) print(f"Applied blur: radius={blur_radius}") # Normalize to 0-1 range and ensure float32 depth = depth.float() # Ensure float32 depth = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8) # Ensure output is in correct format depth = torch.clamp(depth, 0.0, 1.0) # Convert to 3-channel RGB for display compatibility if depth.shape[-1] == 1: depth = depth.repeat(1, 1, 1, 3) print(f"✓ Depth map generated") print(f" Output shape: {depth.shape}, dtype: {depth.dtype}") print(f" Output range: [{depth.min():.3f}, {depth.max():.3f}]") print("="*60 + "\n") return (depth,) def _integrate_gradients(self, normal, iterations, strength): """Integrate normal map gradients to reconstruct depth""" batch, height, width, channels = normal.shape device = normal.device dtype = normal.dtype # Convert normal map from [0,1] to [-1,1] normal_scaled = normal * 2.0 - 1.0 # Extract gradients from red and green channels if channels >= 2: grad_x = normal_scaled[:, :, :, 0:1] # Red = X gradient grad_y = normal_scaled[:, :, :, 1:2] # Green = Y gradient else: grad_x = torch.zeros((batch, height, width, 1), device=device, dtype=dtype) grad_y = torch.zeros((batch, height, width, 1), device=device, dtype=dtype) # Initialize height map depth = torch.zeros((batch, height, width, 1), device=device, dtype=dtype) # Iterative integration (Jacobi method) for iteration in range(iterations): depth_old = depth.clone() # Compute neighbors depth_left = torch.cat([depth[:, :, :1, :], depth[:, :, :-1, :]], dim=2) depth_right = torch.cat([depth[:, :, 1:, :], depth[:, :, -1:, :]], dim=2) depth_up = torch.cat([depth[:, :1, :, :], depth[:, :-1, :, :]], dim=1) depth_down = torch.cat([depth[:, 1:, :, :], depth[:, -1:, :, :]], dim=1) # Integrate gradients depth = 0.25 * ( depth_left + grad_x * strength + depth_right - grad_x * strength + depth_up + grad_y * strength + depth_down - grad_y * strength ) # Check convergence every 10 iterations if iteration % 10 == 0 and iteration > 0: diff = torch.abs(depth - depth_old).mean() if diff < 1e-5: print(f" Converged at iteration {iteration}") break return depth def _apply_blur(self, image, radius): """Apply Gaussian blur""" if radius <= 0: return image batch, height, width, channels = image.shape # Create Gaussian kernel kernel_size = int(radius * 4) + 1 if kernel_size % 2 == 0: kernel_size += 1 sigma = radius kernel_range = torch.arange(kernel_size, device=image.device, dtype=image.dtype) kernel_center = kernel_size // 2 kernel = torch.exp(-0.5 * ((kernel_range - kernel_center) / sigma) ** 2) kernel = kernel / kernel.sum() # Reshape for convolution image_flat = image.permute(0, 3, 1, 2) # BHWC -> BCHW # Apply horizontal blur kernel_h = kernel.view(1, 1, 1, -1).repeat(channels, 1, 1, 1) padding_h = kernel_size // 2 image_flat = torch.nn.functional.pad(image_flat, (padding_h, padding_h, 0, 0), mode='replicate') image_flat = torch.nn.functional.conv2d(image_flat, kernel_h, groups=channels) # Apply vertical blur kernel_v = kernel.view(1, 1, -1, 1).repeat(channels, 1, 1, 1) padding_v = kernel_size // 2 image_flat = torch.nn.functional.pad(image_flat, (0, 0, padding_v, padding_v), mode='replicate') image_flat = torch.nn.functional.conv2d(image_flat, kernel_v, groups=channels) # Back to BHWC result = image_flat.permute(0, 2, 3, 1) return result class HeightToNormal: """ Convert height/depth maps to normal maps with format selection Supports Sobel, Scharr, and Prewitt operators with OpenGL/DirectX output """ @classmethod def INPUT_TYPES(cls): return { "required": { "height": ("IMAGE",), "strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1, "display": "number", "tooltip": "Normal map strength/intensity" }), "method": (["sobel", "scharr", "prewitt"], { "default": "scharr", "tooltip": "Gradient operator: Scharr (best), Sobel (standard), Prewitt (simple)" }), "output_format": (["OpenGL", "DirectX"], { "default": "DirectX", "tooltip": "OpenGL: Y+ up (Unity, Blender) | DirectX: Y- up (Unreal, Maya)" }), "blur_radius": ("FLOAT", { "default": 0.0, "min": 0.0, "max": 5.0, "step": 0.1, "display": "number", "tooltip": "Pre-blur height map to reduce noise (0 = no blur)" }), } } RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("normal", "format_info") FUNCTION = "convert" CATEGORY = "Texture Alchemist/Normal" def convert(self, height, strength, method, output_format, blur_radius): """Convert height map to normal map""" print("\n" + "="*60) print("Height to Normal Converter") print("="*60) print(f"Input shape: {height.shape}") print(f"Strength: {strength}") print(f"Method: {method}") print(f"Output format: {output_format}") print(f"Blur radius: {blur_radius}") # Convert to grayscale if needed if height.shape[-1] > 1: weights = torch.tensor([0.2989, 0.5870, 0.1140], device=height.device, dtype=height.dtype) height_gray = torch.sum(height * weights, dim=-1, keepdim=True) else: height_gray = height batch, h, w, channels = height_gray.shape # Optional pre-blur to reduce noise if blur_radius > 0.1: height_gray = self._gaussian_blur(height_gray, blur_radius) print(f"✓ Applied Gaussian blur (radius: {blur_radius})") # Get gradient kernels with proper normalization if method == "sobel": # Sobel: Good balance, standard choice kernel_x = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], device=height.device, dtype=height.dtype) kernel_y = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], device=height.device, dtype=height.dtype) kernel_scale = 1.0 / 8.0 print(f" Using Sobel operator (standard, balanced)") elif method == "scharr": # Scharr: More accurate, better rotation invariance kernel_x = torch.tensor([[-3, 0, 3], [-10, 0, 10], [-3, 0, 3]], device=height.device, dtype=height.dtype) kernel_y = torch.tensor([[-3, -10, -3], [0, 0, 0], [3, 10, 3]], device=height.device, dtype=height.dtype) kernel_scale = 1.0 / 16.0 # Better normalization for Scharr print(f" Using Scharr operator (most accurate, recommended)") else: # prewitt # Prewitt: Simplest, more susceptible to noise kernel_x = torch.tensor([[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]], device=height.device, dtype=height.dtype) kernel_y = torch.tensor([[-1, -1, -1], [0, 0, 0], [1, 1, 1]], device=height.device, dtype=height.dtype) kernel_scale = 1.0 / 6.0 print(f" Using Prewitt operator (simple, faster)") # Apply scale kernel_x = kernel_x * kernel_scale kernel_y = kernel_y * kernel_scale # Reshape for convolution kernel_x = kernel_x.view(1, 1, 3, 3) kernel_y = kernel_y.view(1, 1, 3, 3) height_bchw = height_gray.permute(0, 3, 1, 2) # Compute gradients grad_x = torch.nn.functional.conv2d(height_bchw, kernel_x, padding=1) grad_y = torch.nn.functional.conv2d(height_bchw, kernel_y, padding=1) # Convert gradients to normal # Normal = (-dz/dx, -dz/dy, 1) for OpenGL # Normal = (-dz/dx, dz/dy, 1) for DirectX (invert Y) normal_x = -grad_x * strength if output_format == "OpenGL": normal_y = -grad_y * strength format_desc = "OpenGL (Y+)" else: # DirectX normal_y = grad_y * strength # Inverted Y for DirectX format_desc = "DirectX (Y-)" normal_z = torch.ones_like(normal_x) # Normalize normal = torch.cat([normal_x, normal_y, normal_z], dim=1) length = torch.sqrt(torch.sum(normal ** 2, dim=1, keepdim=True)) + 1e-8 normal = normal / length # Convert from [-1,1] to [0,1] for display normal = (normal + 1.0) / 2.0 # Back to BHWC normal = normal.permute(0, 2, 3, 1) # Analyze output green_mean = normal[:, :, :, 1].mean().item() print(f"\n✓ Normal map generated") print(f" Output shape: {normal.shape}") print(f" Format: {format_desc}") print(f" Green channel mean: {green_mean:.3f}") if output_format == "DirectX": print(f" Compatible with: Unreal Engine, 3ds Max, Maya") else: print(f" Compatible with: Unity, Blender, Three.js") print("="*60 + "\n") format_info = f"{format_desc} | Method: {method} | Strength: {strength}" return (normal, format_info) def _gaussian_blur(self, image, radius): """Apply Gaussian blur to reduce noise""" import math # Convert to BCHW for conv image_bchw = image.permute(0, 3, 1, 2) # Create Gaussian kernel kernel_size = int(radius * 4) + 1 if kernel_size % 2 == 0: kernel_size += 1 kernel_size = max(3, min(kernel_size, 15)) sigma = radius kernel_range = torch.arange(kernel_size, dtype=image.dtype, device=image.device) kernel_range = kernel_range - (kernel_size - 1) / 2.0 kernel_1d = torch.exp(-0.5 * (kernel_range / sigma) ** 2) kernel_1d = kernel_1d / kernel_1d.sum() # Separable convolution kernel_h = kernel_1d.view(1, 1, kernel_size, 1) kernel_w = kernel_1d.view(1, 1, 1, kernel_size) padding = kernel_size // 2 # Apply horizontal blur blurred = torch.nn.functional.conv2d(image_bchw, kernel_w, padding=(0, padding)) # Apply vertical blur blurred = torch.nn.functional.conv2d(blurred, kernel_h, padding=(padding, 0)) # Back to BHWC return blurred.permute(0, 2, 3, 1) class NormalConverter: """ Convert between DirectX and OpenGL normal map formats (Flips green channel) """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal": ("IMAGE",), "conversion": (["DirectX_to_OpenGL", "OpenGL_to_DirectX", "auto_detect"], { "default": "auto_detect", "tooltip": "Conversion direction (both flip green channel)" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("normal",) FUNCTION = "convert" CATEGORY = "Texture Alchemist/Normal" def convert(self, normal, conversion): """Convert normal map between DirectX and OpenGL formats""" print("\n" + "="*60) print("Normal Map Format Converter") print("="*60) print(f"Input shape: {normal.shape}") print(f"Conversion: {conversion}") result = normal.clone() # Flip green channel (Y axis) if result.shape[-1] >= 2: result[:, :, :, 1] = 1.0 - result[:, :, :, 1] print(f"✓ Green channel flipped") else: print("⚠ Not enough channels to convert") print("="*60 + "\n") return (result,) class NormalFormatValidator: """ Detect and validate whether a normal map is OpenGL or DirectX format Analyzes green channel to determine Y-axis direction """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal_map": ("IMAGE",), } } RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("visualization", "detected_format") FUNCTION = "validate" CATEGORY = "Texture Alchemist/Normal" OUTPUT_NODE = True def validate(self, normal_map): """Validate normal map format (OpenGL vs DirectX)""" print("\n" + "="*60) print("Normal Format Validator") print("="*60) print(f"Input shape: {normal_map.shape}") batch, height, width, channels = normal_map.shape device = normal_map.device dtype = normal_map.dtype # Extract green channel (Y-axis direction differs between formats) if channels >= 3: green = normal_map[:, :, :, 1] else: print("⚠ Warning: Normal map has less than 3 channels") return (normal_map, "UNKNOWN - Not RGB") # Analyze green channel distribution green_mean = green.mean().item() green_median = green.median().item() # Count pixels above and below 0.5 (neutral) above_half = (green > 0.5).float().mean().item() below_half = (green < 0.5).float().mean().item() # Calculate histogram-based detection # Based on empirical testing with actual user maps: # Blue/purple maps (OpenGL) → Green pixels tend toward < 0.5 → bias < 0 # Green/yellow maps (DirectX) → Green pixels tend toward > 0.5 → bias > 0 # Visual rule: Blue/purple = OpenGL (Y+) | Green/yellow = DirectX (Y-) # Detect format based on bias bias = above_half - below_half if abs(bias) < 0.03: # Very close to 0.5 distribution - ambiguous or flat detected = "AMBIGUOUS" confidence = "Low" reason = "Flat or equal distribution" elif bias > 0: # More pixels above 0.5 (brighter green channel) = DirectX detected = "DirectX" confidence_value = abs(bias) * 100 if confidence_value > 20: confidence = "High" elif confidence_value > 10: confidence = "Medium" else: confidence = "Low" reason = f"Green bias: +{bias:.2%}" else: # More pixels below 0.5 (darker green channel) = OpenGL detected = "OpenGL" confidence_value = abs(bias) * 100 if confidence_value > 20: confidence = "High" elif confidence_value > 10: confidence = "Medium" else: confidence = "Low" reason = f"Green bias: {bias:.2%}" # Create visualization visualization = self._create_visualization( normal_map, green, detected, confidence, green_mean, above_half, below_half, bias, device, dtype ) # Print detailed analysis print(f"\n📊 GREEN CHANNEL ANALYSIS:") print(f" Mean: {green_mean:.3f}") print(f" Median: {green_median:.3f}") print(f" Pixels > 0.5: {above_half*100:.1f}% (up-facing)") print(f" Pixels < 0.5: {below_half*100:.1f}% (down-facing)") print(f" Bias: {bias:+.3f}") print(f"\n🎯 DETECTION RESULT:") print(f" Format: {detected}") print(f" Confidence: {confidence}") print(f" Reason: {reason}") if detected == "OpenGL": print(f"\n✓ OpenGL Format (Y+)") print(f" • Green channel: Standard Y-axis (Y+ = up)") print(f" • Appearance: Blue/purple tint (like your image!)") print(f" • Compatible with: Unity, Blender, Three.js") print(f" • Standard in most modern engines") elif detected == "DirectX": print(f"\n✓ DirectX Format (Y-)") print(f" • Green channel: Inverted Y-axis (Y- = up)") print(f" • Appearance: Green/yellow tint") print(f" • Compatible with: Unreal Engine, 3ds Max, Maya") print(f" • 💡 Use 'Normal Format Converter' to flip to OpenGL") else: print(f"\n⚠ Ambiguous Result") print(f" Possible reasons:") print(f" • Normal map is mostly flat (no vertical detail)") print(f" • Equal distribution of up/down facing normals") print(f" • Non-standard or corrupted normal map") print("="*60 + "\n") result_string = f"{detected} | Confidence: {confidence} | Bias: {bias:+.2%}" return (visualization, result_string) def _create_visualization(self, normal_map, green, detected, confidence, green_mean, above_half, below_half, bias, device, dtype): """Create visual analysis panels""" batch, height, width, channels = normal_map.shape # Panel 1: Original normal map panel1 = normal_map.clone() # Panel 2: Green channel only (as RGB) green_rgb = green.unsqueeze(-1).repeat(1, 1, 1, 3) panel2 = green_rgb # Panel 3: Above/below 0.5 visualization # White = above 0.5 (up), Black = below 0.5 (down) threshold_vis = torch.where( green.unsqueeze(-1) > 0.5, torch.ones((batch, height, width, 3), device=device, dtype=dtype), torch.zeros((batch, height, width, 3), device=device, dtype=dtype) ) panel3 = threshold_vis # Panel 4: Color-coded format indicator (matches actual appearance) if "DirectX" in detected: # Green = DirectX (greenish/yellow appearance) color = torch.tensor([0.3, 1.0, 0.3], device=device, dtype=dtype) elif "OpenGL" in detected: # Blue = OpenGL (bluish/purple appearance) color = torch.tensor([0.3, 0.3, 1.0], device=device, dtype=dtype) else: # Yellow = Ambiguous color = torch.tensor([1.0, 1.0, 0.2], device=device, dtype=dtype) # Create gradient background based on bias gradient = torch.linspace(0.3, 0.7, height, device=device, dtype=dtype) gradient = gradient.view(1, -1, 1, 1).expand(batch, -1, width, 1) panel4 = gradient.repeat(1, 1, 1, 3) * color.view(1, 1, 1, 3) # Overlay statistics text would require PIL, so just use solid indicator # Blend format color with threshold visualization panel4 = panel4 * 0.5 + threshold_vis * 0.5 # Stack panels horizontally visualization = torch.cat([panel1, panel2, panel3, panel4], dim=2) return visualization class NormalIntensity: """ Adjust normal map intensity/strength Blends between flat normal (0,0,1) and the actual normal """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal": ("IMAGE",), "intensity": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01, "display": "number", "tooltip": "Normal intensity (0.0=flat, 1.0=original, >1.0=enhanced)" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("normal",) FUNCTION = "adjust_intensity" CATEGORY = "Texture Alchemist/Normal" def adjust_intensity(self, normal, intensity): """Adjust normal map intensity""" print("\n" + "="*60) print("Normal Intensity Adjuster") print("="*60) print(f"Input shape: {normal.shape}") print(f"Intensity: {intensity}") if intensity == 1.0: print("✓ Intensity is 1.0, no change needed") print("="*60 + "\n") return (normal,) # Convert from [0,1] to [-1,1] range normal_unpacked = normal * 2.0 - 1.0 # Flat normal is (0, 0, 1) in tangent space flat = torch.tensor([0.0, 0.0, 1.0], device=normal.device, dtype=normal.dtype) flat = flat.view(1, 1, 1, 3) # Interpolate: result = flat + (normal - flat) * intensity # When intensity = 0: result = flat # When intensity = 1: result = normal # When intensity > 1: exaggerated normal normal_adjusted = flat + (normal_unpacked - flat) * intensity # Normalize the vectors length = torch.sqrt(torch.sum(normal_adjusted * normal_adjusted, dim=-1, keepdim=True)) length = torch.clamp(length, min=1e-6) normal_adjusted = normal_adjusted / length # Convert back to [0,1] range result = normal_adjusted * 0.5 + 0.5 print(f"✓ Normal intensity adjusted") print(f" Output range: [{result.min():.3f}, {result.max():.3f}]") print("="*60 + "\n") return (result,) class SharpenNormal: """ Sharpen normal maps to enhance detail Uses unsharp mask technique in tangent space """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal": ("IMAGE",), "strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1, "display": "number", "tooltip": "Sharpening strength" }), "radius": ("FLOAT", { "default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1, "display": "number", "tooltip": "Blur radius for unsharp mask" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("normal",) FUNCTION = "sharpen" CATEGORY = "Texture Alchemist/Normal" def sharpen(self, normal, strength, radius): """Sharpen normal map""" print("\n" + "="*60) print("Normal Sharpener") print("="*60) print(f"Input shape: {normal.shape}") print(f"Strength: {strength}") print(f"Radius: {radius}") if strength == 0.0: print("✓ Strength is 0.0, no sharpening applied") print("="*60 + "\n") return (normal,) # Convert to tangent space [-1,1] normal_unpacked = normal * 2.0 - 1.0 # Create Gaussian blur kernel kernel_size = int(radius * 4) + 1 if kernel_size % 2 == 0: kernel_size += 1 # Create 1D Gaussian kernel sigma = radius x = torch.arange(kernel_size, dtype=normal.dtype, device=normal.device) - kernel_size // 2 gauss = torch.exp(-(x ** 2) / (2 * sigma ** 2)) gauss = gauss / gauss.sum() # Convert to 2D kernel kernel_2d = gauss.unsqueeze(0) * gauss.unsqueeze(1) kernel_2d = kernel_2d / kernel_2d.sum() # Apply blur to each channel separately normal_bchw = normal_unpacked.permute(0, 3, 1, 2) blurred_channels = [] for i in range(3): channel = normal_bchw[:, i:i+1, :, :] kernel = kernel_2d.view(1, 1, kernel_size, kernel_size) blurred = torch.nn.functional.conv2d(channel, kernel, padding=kernel_size//2) blurred_channels.append(blurred) blurred = torch.cat(blurred_channels, dim=1) blurred = blurred.permute(0, 2, 3, 1) # Unsharp mask: sharpened = original + (original - blurred) * strength detail = normal_unpacked - blurred sharpened = normal_unpacked + detail * strength # Normalize the vectors (very important for normals!) length = torch.sqrt(torch.sum(sharpened * sharpened, dim=-1, keepdim=True)) length = torch.clamp(length, min=1e-6) sharpened = sharpened / length # Convert back to [0,1] range result = sharpened * 0.5 + 0.5 print(f"✓ Normal map sharpened") print(f" Kernel size: {kernel_size}x{kernel_size}") print(f" Output range: [{result.min():.3f}, {result.max():.3f}]") print("="*60 + "\n") return (result,) class SharpenDepth: """ Sharpen depth/height maps to enhance detail Uses unsharp mask technique """ @classmethod def INPUT_TYPES(cls): return { "required": { "depth": ("IMAGE",), "strength": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1, "display": "number", "tooltip": "Sharpening strength" }), "radius": ("FLOAT", { "default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1, "display": "number", "tooltip": "Blur radius for unsharp mask" }), "clamp_output": ("BOOLEAN", { "default": True, "tooltip": "Clamp output to 0-1 range" }), } } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("depth",) FUNCTION = "sharpen" CATEGORY = "Texture Alchemist/Height" def sharpen(self, depth, strength, radius, clamp_output): """Sharpen depth/height map""" print("\n" + "="*60) print("Depth Sharpener") print("="*60) print(f"Input shape: {depth.shape}") print(f"Strength: {strength}") print(f"Radius: {radius}") print(f"Clamp output: {clamp_output}") if strength == 0.0: print("✓ Strength is 0.0, no sharpening applied") print("="*60 + "\n") return (depth,) # Create Gaussian blur kernel kernel_size = int(radius * 4) + 1 if kernel_size % 2 == 0: kernel_size += 1 # Create 1D Gaussian kernel sigma = radius x = torch.arange(kernel_size, dtype=depth.dtype, device=depth.device) - kernel_size // 2 gauss = torch.exp(-(x ** 2) / (2 * sigma ** 2)) gauss = gauss / gauss.sum() # Convert to 2D kernel kernel_2d = gauss.unsqueeze(0) * gauss.unsqueeze(1) kernel_2d = kernel_2d / kernel_2d.sum() # Apply blur to each channel separately depth_bchw = depth.permute(0, 3, 1, 2) blurred_channels = [] for i in range(depth.shape[-1]): channel = depth_bchw[:, i:i+1, :, :] kernel = kernel_2d.view(1, 1, kernel_size, kernel_size) blurred = torch.nn.functional.conv2d(channel, kernel, padding=kernel_size//2) blurred_channels.append(blurred) blurred = torch.cat(blurred_channels, dim=1) blurred = blurred.permute(0, 2, 3, 1) # Unsharp mask: sharpened = original + (original - blurred) * strength detail = depth - blurred sharpened = depth + detail * strength # Clamp if requested if clamp_output: sharpened = torch.clamp(sharpened, 0.0, 1.0) print(f"✓ Depth map sharpened") print(f" Kernel size: {kernel_size}x{kernel_size}") print(f" Output range: [{sharpened.min():.3f}, {sharpened.max():.3f}]") print("="*60 + "\n") return (sharpened,) class NormalFormatAuto: """ Automatically detect normal map format (DirectX or OpenGL) and convert to desired format Detects current format, then converts only if needed """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal_map": ("IMAGE",), "target_format": (["DirectX", "OpenGL"], { "default": "DirectX", "tooltip": "Desired output format" }), } } RETURN_TYPES = ("IMAGE", "STRING", "STRING") RETURN_NAMES = ("normal_map", "detected_format", "conversion_info") FUNCTION = "auto_convert" CATEGORY = "Texture Alchemist/Normal" def auto_convert(self, normal_map, target_format): """Detect format and convert to target if needed""" # Use same threshold as validator for consistency detection_threshold = 0.03 print("\n" + "="*60) print("Normal Format Auto-Converter") print("="*60) print(f"Input shape: {normal_map.shape}") print(f"Target format: {target_format}") print(f"Detection threshold: {detection_threshold} (matches validator)") batch, height, width, channels = normal_map.shape device = normal_map.device dtype = normal_map.dtype # Validate input if channels < 3: print("⚠ Warning: Normal map has less than 3 channels") return (normal_map, "UNKNOWN", "Error: Not RGB - no conversion possible") # Extract green channel for analysis green = normal_map[:, :, :, 1] # Analyze green channel distribution green_mean = green.mean().item() above_half = (green > 0.5).float().mean().item() below_half = (green < 0.5).float().mean().item() # Calculate bias to detect format # Based on empirical testing with actual user maps: # Blue/purple maps (OpenGL) → Green pixels tend toward < 0.5 → bias < 0 # Green/yellow maps (DirectX) → Green pixels tend toward > 0.5 → bias > 0 bias = above_half - below_half # Detect current format if abs(bias) < detection_threshold: detected_format = "AMBIGUOUS" confidence = "Low" print(f"\n⚠ DETECTION WARNING:") print(f" Unable to determine format with confidence") print(f" Bias: {bias:+.3f} (threshold: {detection_threshold})") print(f" Green mean: {green_mean:.3f}") print(f" Pixels > 0.5: {above_half*100:.1f}%") print(f" Pixels < 0.5: {below_half*100:.1f}%") print(f"\n Possible reasons:") print(f" • Normal map is mostly flat") print(f" • Equal distribution of up/down normals") print(f" • Non-standard normal map") elif bias > 0: detected_format = "DirectX" confidence_value = abs(bias) * 100 if confidence_value > 20: confidence = "High" elif confidence_value > 10: confidence = "Medium" else: confidence = "Low" else: detected_format = "OpenGL" confidence_value = abs(bias) * 100 if confidence_value > 20: confidence = "High" elif confidence_value > 10: confidence = "Medium" else: confidence = "Low" # Print detection results print(f"\n📊 DETECTION RESULTS:") print(f" Detected Format: {detected_format}") print(f" Confidence: {confidence}") print(f" Bias: {bias:+.3f}") print(f" Green mean: {green_mean:.3f}") # Determine if conversion is needed needs_conversion = False conversion_info = "" if detected_format == "AMBIGUOUS": # Can't reliably detect - apply user's choice anyway with warning print(f"\n⚠ AMBIGUOUS DETECTION") print(f" Assuming input is opposite of target for safety") print(f" Will convert to {target_format}") needs_conversion = True conversion_info = f"AMBIGUOUS → {target_format} (forced conversion)" elif detected_format == target_format: # Already in correct format print(f"\n✓ FORMAT MATCH") print(f" Input: {detected_format}") print(f" Target: {target_format}") print(f" → No conversion needed (pass-through)") needs_conversion = False conversion_info = f"{detected_format} → {target_format} (no change)" else: # Conversion needed print(f"\n🔄 CONVERSION REQUIRED") print(f" Input: {detected_format}") print(f" Target: {target_format}") print(f" → Converting (inverting green channel)") needs_conversion = True conversion_info = f"{detected_format} → {target_format} (converted)" # Perform conversion if needed if needs_conversion: result = normal_map.clone() result[:, :, :, 1] = 1.0 - result[:, :, :, 1] print(f"\n✓ CONVERSION COMPLETE") print(f" Green channel inverted") print(f" Output format: {target_format}") else: result = normal_map print(f"\n✓ PASS-THROUGH") print(f" No changes made") print("="*60 + "\n") # Format detected string for output detected_string = f"{detected_format} ({confidence} confidence)" return (result, detected_string, conversion_info) class NormalFormatBruteForce: """ Simple brute-force normal map format detector Looks at overall color: More green = DirectX, More blue/purple = OpenGL Automatically converts to your desired format """ @classmethod def INPUT_TYPES(cls): return { "required": { "normal_map": ("IMAGE",), "target_format": (["DirectX", "OpenGL"], { "default": "DirectX", "tooltip": "Desired output format" }), } } RETURN_TYPES = ("IMAGE", "STRING", "STRING") RETURN_NAMES = ("normal_map", "detected_format", "conversion_info") FUNCTION = "brute_force_convert" CATEGORY = "Texture Alchemist/Normal" def brute_force_convert(self, normal_map, target_format): """Simple color-based detection and conversion""" print("\n" + "="*60) print("Normal Format Brute Force Checker") print("="*60) print(f"Input shape: {normal_map.shape}") print(f"Target format: {target_format}") batch, height, width, channels = normal_map.shape # Validate input if channels < 3: print("⚠ Warning: Normal map has less than 3 channels") return (normal_map, "UNKNOWN", "Error: Not RGB - no conversion possible") # Extract channels red = normal_map[:, :, :, 0] green = normal_map[:, :, :, 1] blue = normal_map[:, :, :, 2] # Calculate average intensity for each channel red_mean = red.mean().item() green_mean = green.mean().item() blue_mean = blue.mean().item() print(f"\n📊 COLOR ANALYSIS:") print(f" Red mean: {red_mean:.4f}") print(f" Green mean: {green_mean:.4f}") print(f" Blue mean: {blue_mean:.4f}") # Calculate color dominance green_dominance = green_mean - blue_mean print(f"\n🎨 COLOR DOMINANCE:") print(f" Green - Blue = {green_dominance:+.4f}") # Simple rule: If more green than blue → DirectX, else → OpenGL if green_dominance > 0.02: detected_format = "DirectX" confidence = "High" if green_dominance > 0.05 else "Medium" appearance = "Greenish/yellowish" print(f" → More GREEN than blue") print(f" → Appearance: {appearance}") elif green_dominance < -0.02: detected_format = "OpenGL" confidence = "High" if abs(green_dominance) > 0.05 else "Medium" appearance = "Bluish/purplish" print(f" → More BLUE than green") print(f" → Appearance: {appearance}") else: detected_format = "AMBIGUOUS" confidence = "Low" appearance = "Balanced" print(f" → Nearly equal green and blue") print(f" → Appearance: {appearance}") print(f"\n🎯 DETECTION RESULT:") print(f" Detected Format: {detected_format}") print(f" Confidence: {confidence}") # Determine if conversion is needed needs_conversion = False conversion_info = "" if detected_format == "AMBIGUOUS": print(f"\n⚠ AMBIGUOUS DETECTION") print(f" Green and blue are too similar") print(f" Assuming opposite of target for safety") needs_conversion = True conversion_info = f"AMBIGUOUS → {target_format} (forced)" elif detected_format == target_format: print(f"\n✓ FORMAT MATCH") print(f" Input: {detected_format}") print(f" Target: {target_format}") print(f" → No conversion needed (pass-through)") needs_conversion = False conversion_info = f"{detected_format} → {target_format} (no change)" else: print(f"\n🔄 CONVERSION REQUIRED") print(f" Input: {detected_format}") print(f" Target: {target_format}") print(f" → Converting (inverting green channel)") needs_conversion = True conversion_info = f"{detected_format} → {target_format} (converted)" # Perform conversion if needed if needs_conversion: result = normal_map.clone() result[:, :, :, 1] = 1.0 - result[:, :, :, 1] print(f"\n✓ CONVERSION COMPLETE") print(f" Green channel inverted") print(f" Output format: {target_format}") # Show color shift new_green_mean = result[:, :, :, 1].mean().item() print(f" Green mean: {green_mean:.4f} → {new_green_mean:.4f}") else: result = normal_map print(f"\n✓ PASS-THROUGH") print(f" No changes made") print("="*60 + "\n") detected_string = f"{detected_format} ({confidence} confidence)" return (result, detected_string, conversion_info) NODE_CLASS_MAPPINGS = { "NormalMapCombiner": NormalMapCombiner, "LotusNormalProcessor": LotusNormalProcessor, "NormalToDepth": NormalToDepth, "HeightToNormal": HeightToNormal, "NormalConverter": NormalConverter, "NormalFormatValidator": NormalFormatValidator, "NormalFormatAuto": NormalFormatAuto, "NormalFormatBruteForce": NormalFormatBruteForce, "NormalIntensity": NormalIntensity, "SharpenNormal": SharpenNormal, "SharpenDepth": SharpenDepth, } NODE_DISPLAY_NAME_MAPPINGS = { "NormalMapCombiner": "Normal Map Combiner", "LotusNormalProcessor": "Normal Processor (Lotus)", "NormalToDepth": "Normal to Depth Converter", "HeightToNormal": "Height to Normal Converter", "NormalConverter": "Normal Format Converter (DX↔GL)", "NormalFormatValidator": "Normal Format Validator (OGL vs DX)", "NormalFormatAuto": "Normal Format Auto-Converter", "NormalFormatBruteForce": "Normal Format Brute Force Checker", "NormalIntensity": "Normal Intensity Adjuster", "SharpenNormal": "Sharpen Normal", "SharpenDepth": "Sharpen Depth", }