Files
Dobidop-ComfyStereo/GenerateStereo.py
T
2025-02-25 20:53:10 +01:00

568 lines
24 KiB
Python

import torch
import numpy as np
import os
import sys
from typing import Union, List
from PIL import Image
import cv2
import numpy as np
import torch
from typing import Union, List
from PIL import Image, ImageFilter, ImageEnhance, ImageOps, ImageDraw, ImageChops, ImageFont
file_dir = os.path.dirname(__file__)
sys.path.append(file_dir)
import stereoimage_generation as sig
from comfy.utils import ProgressBar
import socket
import json
import io
import base64
import os
import socket
import json
import hashlib
import shutil
import subprocess
import psutil
import time
class DeoVRLauncher:
def __init__(self, config_path):
self.config_path = config_path
self.deovr_path = self.load_deovr_path()
def load_deovr_path(self):
"""Loads the DeoVR installation path from a config file."""
if not os.path.exists(self.config_path):
print(f"X Config file not found: {self.config_path}")
return None
try:
with open(self.config_path, "r") as f:
config = json.load(f)
return config.get("deovr_path", None)
except Exception as e:
print(f"X Error reading config file: {e}")
return None
def is_deovr_running(self):
"""Checks if DeoVR.exe is currently running."""
for process in psutil.process_iter(attrs=['name']):
if process.info['name'].lower() == "deovr.exe":
#print("OK! DeoVR is already running.")
return True
print("! DeoVR is not running.")
return False
def create_dummy_image(self, temp_folder):
"""Creates a black dummy image in the temp folder."""
dummy_path = os.path.join(temp_folder, "dummy.png")
if not os.path.exists(temp_folder):
os.makedirs(temp_folder)
try:
img = Image.new("RGB", (1920, 1080), (0, 0, 0)) # Black image
img.save(dummy_path)
print(f"OK! Created dummy image: {dummy_path}")
return dummy_path
except Exception as e:
print(f"X Error creating dummy image: {e}")
return None
def launch_deovr(self):
"""Launches DeoVR with the dummy image if it's not already running."""
if self.is_deovr_running():
print("OK! DeoVR is already running. No need to launch.")
return
if not self.deovr_path:
print("XX DeoVR path is not set. Check the config file.")
return
if not os.path.exists(self.deovr_path):
print(" ")
print(" ")
print(f"XXX DeoVR path seems to be incorrect, file not found: {self.deovr_path}")
print(f"XXX Update the config file in ComfyUI\custom_nodes\comfystereo\config.json to point to your DeoVR.exe file to autostart it when triggering this node.")
print(f"!!! You can still use this node though. Start DeoVR manually, then open any image, then this node can pass an image/video to it.")
print(" ")
print(" ")
return None
script_directory = os.path.dirname(os.path.abspath(__file__))
loading_image_path = os.path.join(script_directory, 'loading_sbs_flat.png')
try:
subprocess.Popen([self.deovr_path, loading_image_path], shell=True)
print(f"! Launched DeoVR...")
print(f" Waiting for DeoVR to start...")
time.sleep(5)
print(f" Still waiting for DeoVR to start... heat death of the universe approaching...")
time.sleep(5)
print(f" DeoVR... Any moment now... Soon™")
time.sleep(5)
except Exception as e:
print(f"XXX Error launching DeoVR: {e}")
class DeoVRViewNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"forceInput": True}), # User-provided file or video path
"projection_type": ([
"Flat 2D plane",
"Equirectangular - 180° FOV",
"Equirectangular - 360° FOV",
"Fisheye projection - 180° FOV",
"Fisheye projection - 190° FOV",
"Fisheye projection - 200° FOV with MKX lens correction",
"Fisheye projection - 220° FOV with VRCA lens correction"
],),
"eye_location": (["Side-by-Side", "Top-Bottom"],),
}
}
RETURN_TYPES = ()
FUNCTION = "send_path_to_deovr"
CATEGORY = "Output"
OUTPUT_NODE = True
def send_path_to_deovr(self, file_path, projection_type, eye_location):
"""
Copies the file to ComfyUI's temp folder with a modified name based on the projection type and eye location.
A unique MD5 hash is computed for the file to ensure uniqueness. If the file has been copied before,
the existing copy is reused. The command is then sent to DeoVR with the new file path.
"""
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./config.json"))
launcher = DeoVRLauncher(config_path)
launcher.launch_deovr()
if not os.path.exists(file_path):
print(f"Error: The specified file does not exist: {file_path}")
return ()
# Define the temp folder relative to ComfyUI (two levels up)
temp_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../temp"))
if not os.path.exists(temp_folder):
os.makedirs(temp_folder)
# Compute a unique hash for the file (MD5 is fast for most files)
file_hash = self.compute_file_hash(file_path)
# Get the base name and extension of the file
base_name = os.path.basename(file_path)
base, ext = os.path.splitext(base_name)
# Define suffix mappings (inspired by generateJson.py)
projection_suffix_mapping = {
"Flat 2D plane": "_screen",
"Equirectangular - 180° FOV": "_180",
"Equirectangular - 360° FOV": "_360",
"Fisheye projection - 180° FOV": "_fisheye",
"Fisheye projection - 190° FOV": "_fisheye190",
"Fisheye projection - 200° FOV with MKX lens correction": "_mkx200",
"Fisheye projection - 220° FOV with VRCA lens correction": "_vrca220"
}
stereo_suffix_mapping = {
"Side-by-Side": "_SBS",
"Top-Bottom": "_TB"
}
proj_suffix = projection_suffix_mapping.get(projection_type, "")
stereo_suffix = stereo_suffix_mapping.get(eye_location, "")
# Create the new file name: [hash]_[base][proj_suffix][stereo_suffix][ext]
new_file_name = f"{file_hash}_{base}{proj_suffix}{stereo_suffix}{ext}"
new_file_path = os.path.join(temp_folder, new_file_name)
# If the file doesn't already exist in the temp folder, copy it.
if not os.path.exists(new_file_path):
try:
shutil.copy2(file_path, new_file_path)
print(f"Copied file to: {new_file_path}")
except Exception as e:
print(f"Error copying file: {e}")
return ()
# Create the JSON command to send to DeoVR.
command = {
"path": new_file_path,
"screenType": proj_suffix[1:] if proj_suffix.startswith("_") else "flat",
"stereoMode": stereo_suffix[1:].lower() if stereo_suffix.startswith("_") else "sbs",
"is3d": True
}
# Send the command.
self.send_command_to_deovr(command)
return ()
def send_command_to_deovr(self, command):
"""Handles sending the command to the DeoVR remote control API."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", 23554)) # DeoVR's remote control port
command_json = json.dumps(command)
length_prefix = len(command_json).to_bytes(4, byteorder='little')
packet = length_prefix + command_json.encode('utf-8')
s.sendall(packet)
print(f"✅ Sent command to DeoVR: {command}")
except Exception as e:
print(f"❌ Error sending command to DeoVR: {e}")
def compute_file_hash(self, file_path):
"""Computes an MD5 hash of the file for a unique identifier."""
hash_md5 = hashlib.md5()
try:
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
except Exception as e:
print(f"Error computing file hash: {e}")
return "errorhash"
def tensor2np(tensor: torch.Tensor) -> np.ndarray:
if tensor.dim() == 4: # Batch of images
tensor = tensor[0] # Assuming we take the first image in the batch
np_array = tensor.cpu().numpy()
np_array = np.clip(255.0 * np_array, 0, 255).astype(np.uint8)
if np_array.shape[0] == 3: # Convert from (3, H, W) to (H, W, 3)
np_array = np_array.transpose(1, 2, 0)
return np_array
def np2tensor(img_np: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
if isinstance(img_np, list):
return torch.cat([np2tensor(img) for img in img_np], dim=0)
return torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0)
class StereoImageNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"depth_map": ("IMAGE",),
"modes": (["left-right", "right-left", "top-bottom", "bottom-top", "red-cyan-anaglyph"],),
"fill_technique": ([
'No fill', 'No fill - Reverse projection', 'Imperfect fill - Hybrid Edge', 'Fill - Naive',
'Fill - Naive interpolating', 'Fill - Polylines Soft', 'Fill - Polylines Sharp',
'Fill - Post-fill', 'Fill - Reverse projection with Post-fill', 'Fill - Hybrid Edge with fill'
], {"default": "Fill - Polylines Soft"}),
#"return_basic_mask": ("BOOLEAN", {"default": False}),
},
"optional": {
"divergence": ("FLOAT", {"default": 3.5, "min": 0.05, "max": 15, "step": 0.01}),
"separation": ("FLOAT", {"default": 0, "min": -5, "max": 5, "step": 0.01}),
"stereo_balance": ("FLOAT", {"default": 0, "min": -0.95, "max": 0.95, "step": 0.05}),
"stereo_offset_exponent": ("FLOAT", {"default": 2, "min": 1, "max": 2, "step": 1}),
"depth_blur_sigma": ("FLOAT", {"default": 0, "min": 0, "max": 10, "step": 0.1}),
"depth_blur_edge_threshold": ("FLOAT", {"default": 40, "min": 0.1, "max": 100, "step": 0.1})
}
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "MASK")
RETURN_NAMES = ("stereoscope", "modified_depthmap_left", "modified_depthmap_right", "no_fill_imperfect_mask")
FUNCTION = "generate"
def generate(self, image, depth_map, divergence, separation, modes,
stereo_balance, stereo_offset_exponent, fill_technique, depth_blur_sigma, depth_blur_edge_threshold):# return_basic_mask):
fill_technique_mapping = {
'No fill': 'none',
'No fill - Reverse projection': 'inverse',
'Imperfect fill - Hybrid Edge': 'hybrid_edge',
'Fill - Naive': 'naive',
'Fill - Naive interpolating': 'naive_interpolating',
'Fill - Polylines Soft': 'polylines_soft',
'Fill - Polylines Sharp': 'polylines_sharp',
'Fill - Post-fill': 'none_post',
'Fill - Reverse projection with Post-fill': 'inverse_post',
'Fill - Hybrid Edge with fill': 'hybrid_edge_plus'
}
fill_technique = fill_technique_mapping.get(fill_technique, 'none')
results_final = []
modified_depthmap_final_left = []
modified_depthmap_final_right = []
mask_final = []
total_steps = len(image)
pbar = ProgressBar(total_steps)
for i in range(len(image)):
img = tensor2np(image[i:i+1])
dm = tensor2np(depth_map[i:i+1])
if len(dm.shape) == 3 and dm.shape[2] == 3:
dm = np.dot(dm[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.uint8)
if img.shape[:2] != dm.shape:
dm = np.array(Image.fromarray(dm).resize((img.shape[1], img.shape[0])))
output = sig.create_stereoimages(img, dm, divergence, separation,
[modes], stereo_balance, stereo_offset_exponent,
fill_technique, depth_blur_sigma, depth_blur_edge_threshold)
if len(output) == 3:
results, left_modified_depthmap, right_modified_depthmap = output
else:
results, modified_depthmap = output
left_modified_depthmap = modified_depthmap
right_modified_depthmap = modified_depthmap
results_final.append(convertResult(results))
modified_depthmap_final_left.append(convertResult(left_modified_depthmap))
modified_depthmap_final_right.append(convertResult(right_modified_depthmap))
mask = self.generate_mask(results)
mask_final.append(mask)
pbar.update(1)
return (torch.cat(results_final), torch.cat(modified_depthmap_final_left), torch.cat(modified_depthmap_final_right), torch.cat(mask_final))
def generate_mask(self, stereoscope_img):
np_img = np.array(stereoscope_img)
mask = (np_img.sum(axis=-1) == 0).astype(np.uint8) * 255 # Black areas become white in mask
return np2tensor(mask)
def convertResult(results):
# Save the result for debugging
if isinstance(results, list):
for idx, result in enumerate(results):
result = np.array(result)
#Image.fromarray(result).save(f"debug_result_{idx}.png")
else:
results = np.array(results)
#Image.fromarray(results).save("debug_result.png")
# Convert the result to a tensor
if isinstance(results, list):
results = np.array(results[0])
else:
results = np.array(results)
# Ensure the results are in the correct shape (H, W, C)
if len(results.shape) == 2: # Convert grayscale to RGB
results = np.stack([results]*3, axis=-1)
results_tensor = np2tensor(results)
return results_tensor
def tensor2cv2(image: torch.Tensor) -> List[tuple[np.ndarray, tuple]]:
original_shape = image.shape
images = []
if image.dim() == 4: # Batch of images
for i in range(image.shape[0]):
img = image[i]
if img.shape[0] == 3: # If in [C, H, W] format
img = img.permute(1, 2, 0)
npimage = (img * 255).byte().cpu().numpy()
if npimage.shape[-1] == 3:
images.append((cv2.cvtColor(npimage, cv2.COLOR_RGB2BGR), original_shape))
elif npimage.shape[-1] == 1:
images.append((npimage.squeeze(-1), original_shape)) # For grayscale images
else:
images.append((npimage, original_shape)) # Return as-is for other channel configurations
else: # Single image
if image.shape[0] == 3: # If in [C, H, W] format
image = image.permute(1, 2, 0)
npimage = (image * 255).byte().cpu().numpy()
if npimage.shape[-1] == 3:
images.append((cv2.cvtColor(npimage, cv2.COLOR_RGB2BGR), original_shape))
elif npimage.shape[-1] == 1:
images.append((npimage.squeeze(-1), original_shape)) # For grayscale images
else:
images.append((npimage, original_shape)) # Return as-is for other channel configurations
return images
def cv22tensor(cv2_imgs: List[tuple[np.ndarray, tuple]]) -> torch.Tensor:
tensors = []
for cv2_img, original_shape in cv2_imgs:
if cv2_img.ndim == 2: # grayscale
tensor = torch.from_numpy(cv2_img).float() / 255.0
tensor = tensor.unsqueeze(-1) # Add channel dimension
else: # color
tensor = torch.from_numpy(cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB)).float() / 255.0
if len(original_shape) == 4:
tensor = tensor.permute(2, 0, 1).unsqueeze(0) # [H, W, C] -> [1, C, H, W]
elif len(original_shape) == 3 and original_shape[0] == 3:
tensor = tensor.permute(2, 0, 1) # [H, W, C] -> [C, H, W]
tensors.append(tensor)
return torch.stack(tensors)
def merge_channels(red_channel, green_channel, blue_channel):
if red_channel.dim() == 4: # Batch of images
images = []
for i in range(red_channel.shape[0]):
image = mix_rgb_channels(tensor2pil(red_channel[i]).convert('L'), tensor2pil(
green_channel[i]).convert('L'), tensor2pil(blue_channel[i]).convert('L'))
images.append(pil2tensor(image).squeeze(0)) # Remove batch dimension added by pil2tensor
return (torch.stack(images),)
else: # Single image
image = mix_rgb_channels(tensor2pil(red_channel).convert('L'), tensor2pil(
green_channel).convert('L'), tensor2pil(blue_channel).convert('L'))
return (pil2tensor(image),)
def mix_rgb_channels(red, green, blue):
# Create an empty image with the same size as the channels
width, height = red.size
merged_img = Image.new('RGB', (width, height))
# Merge the channels into the new image
merged_img = Image.merge('RGB', (red, green, blue))
return merged_img
# PIL to Tensor
def pil2tensor(image):
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
# Tensor to PIL
def tensor2pil(image):
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
# class LazyStereo:
# @classmethod
# def INPUT_TYPES(s):
# return {"required": {
# "image": ("IMAGE",),
# "depth_map": ("IMAGE",),
# "shift_amount": ("INT", {
# "default": 10,
# "min": 5, #Minimum value
# "max": 200, #Maximum value
# "step": 1, #Slider's step
# "display": "number" # Cosmetic only: display as "number" or "slider"
# }),
# "mode": (["Cross-eyed", "Parallel"],),
# },
# }
# RETURN_TYPES = ("IMAGE",)
# FUNCTION = "generate_cross_eyed_image"
# def generate_cross_eyed_image(self, image, depth_map, shift_amount=10, mode="Cross-eyed"):
# images = tensor2cv2(image)
# depth_maps = tensor2cv2(depth_map)
# results = []
# total_steps = len(image) # Total number of images to process
# pbar = ProgressBar(total_steps)
# for (img, original_img_shape), (depth_map, original_depth_shape) in zip(images, depth_maps):
# if len(depth_map.shape) == 3 and depth_map.shape[2] > 1:
# depth_map = cv2.cvtColor(depth_map, cv2.COLOR_BGR2GRAY)
# elif len(depth_map.shape) == 2:
# # It's already grayscale, no need to convert
# pass
# else:
# raise ValueError(f"Unexpected depth map shape: {depth_map.shape}")
# if img is None or depth_map is None:
# raise ValueError("Image or depth map not found.")
# height, width = img.shape[:2]
# # Calculate gradients to determine high-contrast regions
# grad_x = cv2.Sobel(depth_map, cv2.CV_64F, 1, 0, ksize=3)
# grad_y = cv2.Sobel(depth_map, cv2.CV_64F, 0, 1, ksize=3)
# gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2)
# max_gradient = np.max(gradient_magnitude)
# gradient_magnitude = gradient_magnitude / max_gradient # Normalize to [0, 1]
# # Initialize the left and right images and masks for inpainting
# left_image = np.zeros_like(img)
# right_image = np.zeros_like(img)
# left_mask = np.zeros((height, width), dtype=np.uint8)
# right_mask = np.zeros((height, width), dtype=np.uint8)
# # Generate the left and right images by shifting pixels according to the depth map
# for y in range(height):
# for x in range(width):
# depth = depth_map[y, x]
# local_gradient = gradient_magnitude[y, x]
# adaptive_shift = shift_amount * (1 - local_gradient) # Reduce shift in high-contrast areas
# shift = int((depth / 255.0) * adaptive_shift)
# if x - shift >= 0:
# left_image[y, x] = img[y, x - shift]
# else:
# left_image[y, x] = img[y, 0]
# if x + shift < width:
# right_image[y, x] = img[y, x + shift]
# else:
# right_image[y, x] = img[y, width - 1]
# # Update masks for inpainting
# if x - shift < 0 or x + shift >= width:
# left_mask[y, x] = 255
# right_mask[y, x] = 255
# # Apply edge detection to create a more precise inpainting mask
# edges = cv2.Canny(depth_map, 100, 200)
# left_mask = cv2.bitwise_or(left_mask, edges)
# right_mask = cv2.bitwise_or(right_mask, edges)
# # Inpaint the left and right images to fill in gaps
# left_image = cv2.inpaint(left_image, left_mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA)
# right_image = cv2.inpaint(right_image, right_mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA)
# if mode == "Cross-eyed":
# # Combine the left and right images side by side
# cross_eyed_image = np.hstack((right_image, left_image))
# else:
# cross_eyed_image = np.hstack((left_image, right_image))
# # Separate the channels
# red_channel = cross_eyed_image[:, :, 0]
# green_channel = cross_eyed_image[:, :, 1]
# blue_channel = cross_eyed_image[:, :, 2]
# # Convert channels back to tensors
# red_tensor = cv22tensor([(red_channel, (1, 1, height, width))])
# green_tensor = cv22tensor([(green_channel, (1, 1, height, width))])
# blue_tensor = cv22tensor([(blue_channel, (1, 1, height, width))])
# merged_image = merge_channels(blue_tensor, green_tensor, red_tensor)
# results.append(merged_image[0])
# pbar.update(1)
# # Concatenate the results into a single tensor
# return (torch.cat(results),)
NODE_CLASS_MAPPINGS = {
# "LazyStereo": LazyStereo,
"StereoImageNode": StereoImageNode,
"DeoVRViewNode": DeoVRViewNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
# "LazyStereo": "LazyStereo",
"StereoImageNode": "Stereo Image Node",
"DeoVRViewNode": "DeoVR View"
}