1470 lines
56 KiB
Python
1470 lines
56 KiB
Python
"""
|
|
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",
|
|
}
|
|
|