Files
Dobidop-ComfyStereo/stereo_utils.py
T
DobidopandClaude c2b3bb5a50 Merge ComfyStereoViewer and StereoscopeGenerator into unified ComfyStereo
Combines three powerful stereoscopic tools into one comprehensive package:

1. Core Stereo Generation
   - Depth-based stereo conversion with GPU acceleration
   - Advanced fill techniques (Polylines, GPU Warp, Hybrid Edge, etc.)
   - Edge-aware depth blurring for artifact reduction
   - Side-by-Side, Top-Bottom, and Red-Cyan Anaglyph output formats

2. Native VR Viewing (Optional - requires PyOpenXR)
   - Auto-launch directly into VR headset without browser
   - Support for all VR headsets (Quest, Vive, Index, WMR)
   - Multiple projection types (Flat, Curved, 180° Dome, 360° Sphere)
   - Image and video playback support

3. StereoDiffusion AI (Optional - requires diffusers)
   - AI-powered stereo generation using diffusion models
   - DDIM inversion with null-text optimization
   - Bilateral Neighbor Attention for stereo consistency
   - Works with ComfyUI MODEL/CLIP/VAE or HuggingFace models

Changes:
- Added native_viewer/ module with PyOpenXR implementation
- Added native_nodes.py with 3 VR viewer nodes
- Added stereodiffusion_nodes.py with AI stereo generation
- Added supporting modules: model_wrappers, model_loader, inversion,
  stereo_utils, diffusion_utils
- Updated __init__.py with graceful optional dependency handling
- Consolidated all requirements into single requirements.txt
- Comprehensive README with setup guides and troubleshooting
- Added NOTICE and licenses/ for proper attribution

The package now offers 6+ nodes total with modular installation options.
Users can install just the base, or add VR viewing and/or AI generation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 22:10:56 +01:00

378 lines
14 KiB
Python

"""
Stereo image generation utilities.
This module contains functions for generating stereoscopic images using
depth-based pixel shifting and attention manipulation for diffusion models.
Based on the StereoDiffusion method (https://github.com/lez-s/StereoDiffusion).
"""
import torch
import torch.nn as nn
from einops import rearrange, repeat
def stereo_shift_torch(
input_images: torch.Tensor,
depthmaps: torch.Tensor,
scale_factor: float = 8.0,
shift_both: bool = False,
stereo_offset_exponent: float = 1.0
) -> torch.Tensor:
"""
Shift pixels based on depth map to create stereo image pairs.
Args:
input_images: Input tensor of shape [B, C, H, W]
depthmaps: Depth map tensor of shape [B, H, W]
scale_factor: Controls the strength of the stereo effect (disparity)
shift_both: If True, shift both left and right images; if False, only shift right
stereo_offset_exponent: Exponent applied to depth values before shifting
Returns:
Tensor of shape [2*B, C, H, W] containing left and right images concatenated
"""
def _norm_depth(depth: torch.Tensor, max_val: float = 1.0) -> torch.Tensor:
"""Normalize depth map to [0, max_val] range."""
depth_min = depth.min()
depth_max = depth.max()
if depth_max - depth_min > torch.finfo(depth.dtype).eps:
out = max_val * (depth - depth_min) / (depth_max - depth_min)
else:
out = torch.zeros(depth.shape, dtype=depth.dtype, device=depth.device)
return out
def _create_stereo(
input_images: torch.Tensor,
depthmaps: torch.Tensor,
scale_factor: float,
stereo_offset_exponent: float
) -> torch.Tensor:
"""Create a shifted stereo view based on depth."""
b, c, h, w = input_images.shape
derived_image = torch.zeros_like(input_images)
scale_factor_px = (scale_factor / 100.0) * input_images.shape[-1]
for batch in range(b):
for row in range(h):
# Swipe order ensures closer pixels overwrite farther ones
if scale_factor_px < 0:
col_range = range(w)
else:
col_range = range(w - 1, -1, -1)
for col in col_range:
depth_val = depthmaps[batch, row, col] ** stereo_offset_exponent
col_d = col + int(depth_val * scale_factor_px)
if 0 <= col_d < w:
derived_image[batch, :, row, col_d] = input_images[batch, :, row, col]
return derived_image
depthmaps = _norm_depth(depthmaps)
if not shift_both:
left = input_images
balance = 0
else:
balance = 0.5
left = _create_stereo(
input_images, depthmaps, +1 * scale_factor * balance, stereo_offset_exponent
)
right = _create_stereo(
input_images, depthmaps, -1 * scale_factor * (1 - balance), stereo_offset_exponent
)
return torch.cat([left, right], dim=0)
class BNAttention:
"""
Bilateral Neighbor Attention editor for stereo-consistent diffusion.
This class modifies the attention mechanism to share information between
left and right stereo views during the diffusion process.
"""
def __init__(self, start_step: int = 4, total_steps: int = 50, direction: str = 'uni'):
"""
Initialize the BNAttention editor.
Args:
start_step: Step at which to start applying stereo attention
total_steps: Total number of diffusion steps
direction: 'uni' for unidirectional or 'bi' for bidirectional attention
"""
self.total_steps = total_steps
self.start_step = start_step
self.cur_step = 0
self.cur_att_layer = 0
self.direction = direction
def attn_batch(
self, q, k, v, sim, attn, is_cross, place_in_unet, num_heads, **kwargs
):
"""Process attention in batched stereo mode."""
n_samples = attn.shape[0] // num_heads // 2
q = rearrange(q, "(s b h) n d -> (b h) (s n) d", h=num_heads, b=n_samples)
k = rearrange(k, "(s b h) n d -> (b h) (s n) d", h=num_heads, b=n_samples)
v = rearrange(v, "(s b h) n d -> (b h) (s n) d", h=num_heads, b=n_samples)
sim = torch.einsum("h i d, h j d -> h i j", q, k) * kwargs.get("scale")
del q, k
attn = sim.softmax(-1)
out = torch.einsum("h i j, h j d -> h i d", attn, v)
out = rearrange(out, "(b h) (s n) d -> (s b) n (h d)", b=n_samples, s=2, h=num_heads)
return out
def forward(self, q, k, v, sim, attn, is_cross, place_in_unet, num_heads, **kwargs):
"""Forward pass through the attention editor."""
if is_cross or (self.cur_step < self.start_step):
out = torch.einsum('b i j, b j d -> b i d', attn, v)
out = rearrange(out, '(b h) n d -> b n (h d)', h=num_heads)
return out
n_samples = attn.shape[0] // num_heads // 4
qu, qc = q.chunk(2)
ku, kc = k.chunk(2)
vu, vc = v.chunk(2)
attnu, attnc = attn.chunk(2)
_num_heads = num_heads * n_samples
if self.direction == 'bi':
out_u = self.attn_batch(
qu, ku, vu, sim, attnu, is_cross, place_in_unet, num_heads, **kwargs
)
out_c = self.attn_batch(
qc, kc, vc, sim, attnc, is_cross, place_in_unet, num_heads, **kwargs
)
elif self.direction == 'uni':
out_u = self.attn_batch(
qu, ku[:_num_heads], vu[:_num_heads], sim[:_num_heads],
attnu, is_cross, place_in_unet, num_heads, **kwargs
)
out_c = self.attn_batch(
qc, kc[:_num_heads], vc[:_num_heads], sim[:_num_heads],
attnc, is_cross, place_in_unet, num_heads, **kwargs
)
else:
raise ValueError(f"Unknown direction: {self.direction}")
out = torch.cat([out_u, out_c], dim=0)
return out
def __call__(self, q, k, v, sim, attn, is_cross, place_in_unet, num_heads, **kwargs):
"""Call the attention editor."""
out = self.forward(q, k, v, sim, attn, is_cross, place_in_unet, num_heads, **kwargs)
self.cur_att_layer += 1
self.cur_step = self.cur_att_layer // 32
return out
def register_attention_editor_diffusers(model, editor: BNAttention):
"""
Register a custom attention editor on a diffusers model.
This modifies the attention layers of the UNet to use the provided editor
for stereo-consistent attention computation.
Args:
model: A diffusers StableDiffusionPipeline, ComfyUIModelWrapper, or similar model
editor: A BNAttention instance to handle attention
"""
# Check if this is a ComfyUI model
is_comfyui = hasattr(model, 'comfy_model')
def ca_forward(self, place_in_unet):
# ComfyUI uses dim_head instead of scale, compute scale here
# For diffusers models, self.scale exists; for ComfyUI, compute from dim_head
if hasattr(self, 'scale'):
scale = self.scale
else:
scale = self.dim_head ** -0.5
# ComfyUI models pass value and transformer_options
def forward(x, encoder_hidden_states=None, attention_mask=None, context=None, mask=None,
value=None, transformer_options=None, **kwargs):
if encoder_hidden_states is not None:
context = encoder_hidden_states
if attention_mask is not None:
mask = attention_mask
to_out = self.to_out
if isinstance(to_out, nn.modules.container.ModuleList):
to_out = self.to_out[0]
else:
to_out = self.to_out
h = self.heads
q = self.to_q(x)
is_cross = context is not None
context = context if is_cross else x
k = self.to_k(context)
# Use provided value or compute from context
if value is not None:
v = self.to_v(value)
else:
v = self.to_v(context)
q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> (b h) n d', h=h), (q, k, v))
sim = torch.einsum('b i d, b j d -> b i j', q, k) * scale
if mask is not None:
mask = rearrange(mask, 'b ... -> b (...)')
max_neg_value = -torch.finfo(sim.dtype).max
mask = repeat(mask, 'b j -> (b h) () j', h=h)
mask = mask[:, None, :].repeat(h, 1, 1)
sim.masked_fill_(~mask, max_neg_value)
attn = sim.softmax(dim=-1)
# Use the editor to process attention
out = editor(
q, k, v, sim, attn, is_cross, place_in_unet,
self.heads, scale=scale
)
return to_out(out)
return forward
def register_editor(net, count, place_in_unet):
for name, subnet in net.named_children():
if 'Attention' in net.__class__.__name__:
net.forward = ca_forward(net, place_in_unet)
return count + 1
elif hasattr(net, 'children'):
count = register_editor(subnet, count, place_in_unet)
return count
cross_att_count = 0
# Get the UNet - handle different model structures
unet = _get_unet(model)
sub_nets = unet.named_children()
for net_name, net in sub_nets:
if "down" in net_name or "input" in net_name:
cross_att_count += register_editor(net, 0, "down")
elif "mid" in net_name:
cross_att_count += register_editor(net, 0, "mid")
elif "up" in net_name or "output" in net_name:
cross_att_count += register_editor(net, 0, "up")
editor.num_att_layers = cross_att_count
def _get_unet(model):
"""
Extract the UNet from various model wrapper types.
Args:
model: A diffusers pipeline, ComfyUIModelWrapper, or raw UNet
Returns:
The UNet module (the actual nn.Module, not a wrapper)
"""
# ComfyUIModelWrapper - get the actual diffusion model, not the wrapper
if hasattr(model, 'comfy_model'):
return model.comfy_model.model.diffusion_model
# Try diffusers pipeline style
if hasattr(model, 'unet'):
return model.unet
# Try ComfyUI raw model style
if hasattr(model, 'model') and hasattr(model.model, 'diffusion_model'):
return model.model.diffusion_model
# Assume it's already a UNet
return model
def restore_attention(model):
"""
Restore the default attention mechanism on a diffusers model.
This removes any custom attention hooks and restores standard attention
computation, allowing the model to be reused for other tasks.
Args:
model: A diffusers StableDiffusionPipeline, ComfyUIModelWrapper, or similar model
"""
def ca_forward(self, place_in_unet):
# ComfyUI uses dim_head instead of scale, compute scale here
# For diffusers models, self.scale exists; for ComfyUI, compute from dim_head
if hasattr(self, 'scale'):
scale = self.scale
else:
scale = self.dim_head ** -0.5
# ComfyUI models pass value and transformer_options
def forward(x, encoder_hidden_states=None, attention_mask=None, context=None, mask=None,
value=None, transformer_options=None, **kwargs):
if encoder_hidden_states is not None:
context = encoder_hidden_states
if attention_mask is not None:
mask = attention_mask
to_out = self.to_out
if isinstance(to_out, nn.modules.container.ModuleList):
to_out = self.to_out[0]
else:
to_out = self.to_out
h = self.heads
q = self.to_q(x)
is_cross = context is not None
context = context if is_cross else x
k = self.to_k(context)
# Use provided value or compute from context
if value is not None:
v = self.to_v(value)
else:
v = self.to_v(context)
q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> (b h) n d', h=h), (q, k, v))
sim = torch.einsum('b i d, b j d -> b i j', q, k) * scale
if mask is not None:
mask = rearrange(mask, 'b ... -> b (...)')
max_neg_value = -torch.finfo(sim.dtype).max
mask = repeat(mask, 'b j -> (b h) () j', h=h)
mask = mask[:, None, :].repeat(h, 1, 1)
sim.masked_fill_(~mask, max_neg_value)
attn = sim.softmax(dim=-1)
# Standard attention output
out = torch.einsum('b i j, b j d -> b i d', attn, v)
out = rearrange(out, '(b h) n d -> b n (h d)', h=h)
return to_out(out)
return forward
def register_editor(net, count, place_in_unet):
for name, subnet in net.named_children():
if 'Attention' in net.__class__.__name__:
net.forward = ca_forward(net, place_in_unet)
return count + 1
elif hasattr(net, 'children'):
count = register_editor(subnet, count, place_in_unet)
return count
cross_att_count = 0
# Get the UNet - handle different model structures
unet = _get_unet(model)
sub_nets = unet.named_children()
for net_name, net in sub_nets:
if "down" in net_name or "input" in net_name:
cross_att_count += register_editor(net, 0, "down")
elif "mid" in net_name:
cross_att_count += register_editor(net, 0, "mid")
elif "up" in net_name or "output" in net_name:
cross_att_count += register_editor(net, 0, "up")