diff --git a/README.md b/README.md index 878749a..a6cdb42 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -## Depth Estimation Icon ComfyUIDepthEstimation ComfyUIDepthEstimation +## Depth Estimation Icon ComfyUI Depth Estimation Node
Depth Estimation Logo
-A custom depth estimation node for ComfyUI using Depth-Anything models to generate depth maps from images. +A robust custom depth estimation node for ComfyUI using Depth-Anything models to generate depth maps from images. ## Features - Multiple model options: @@ -18,26 +18,39 @@ A custom depth estimation node for ComfyUI using Depth-Anything models to genera - Median filtering (configurable size) - Automatic contrast enhancement - Gamma correction +- Advanced options: + - Force CPU processing for compatibility + - Force model reload for troubleshooting ## Installation -1. Clone the repository: + +### Method 1: Install via ComfyUI Manager (Recommended) +1. Open ComfyUI and install the ComfyUI Manager if you haven't already +2. Go to the Manager tab +3. Search for "Depth Estimation" and install the node + +### Method 2: Manual Installation +1. Navigate to your ComfyUI custom nodes directory: ```bash - git clone https://github.com/yourusername/ComfyUIDepthEstimation.git + cd ComfyUI/custom_nodes/ ``` -2. Navigate to the repository directory: - +2. Clone the repository: ```bash - cd ComfyUIDepthEstimation + git clone https://github.com/Limbicnation/ComfyUIDepthEstimation.git ``` 3. Install the required dependencies: - ```bash + cd ComfyUIDepthEstimation pip install -r requirements.txt ``` -# Usage +4. Restart ComfyUI to load the new custom node. + +> **Note**: On first use, the node will download the selected model from Hugging Face. This may take some time depending on your internet connection. + +## Usage
Depth Estimation Node Preview @@ -46,39 +59,96 @@ A custom depth estimation node for ComfyUI using Depth-Anything models to genera Depth Map Generator Showcase
-## Node Parameters +### Node Parameters -Node Parameters +#### Required Parameters +- **image**: Input image (IMAGE type) +- **model_name**: Select from available Depth-Anything models +- **blur_radius**: Gaussian blur radius (0.0 - 10.0, default: 2.0) +- **median_size**: Median filter size (3, 5, 7, 9, 11) +- **apply_auto_contrast**: Enable automatic contrast enhancement +- **apply_gamma**: Enable gamma correction -- image: Input image (IMAGE type) -- model_name: Select from available Depth-Anything models -- blur_radius: Gaussian blur radius (0.0 - 10.0, default: 2.0) -- median_size: Median filter size (3, 5, 7, 9, 11) -- apply_auto_contrast: Enable automatic contrast enhancement -- apply_gamma: Enable gamma correction - -### Integrate with ComfyUI - -1. Copy the `depth_estimation_node.py` file to your ComfyUI custom nodes directory. - -2. Update ComfyUI configuration to include the custom node if necessary. - -3. Restart ComfyUI to load the new custom node. +#### Optional Parameters +- **force_reload**: Force the model to reload (useful for troubleshooting) +- **force_cpu**: Use CPU for processing instead of GPU (slower but more compatible) ### Example Usage 1. Add the `Depth Estimation` node to your ComfyUI workflow 2. Connect an image source to the node's image input 3. Configure the parameters: - - Select a model (e.g., "Depth-Anything-V2-Small") + - Select a model (e.g., "Depth-Anything-V2-Small" is fastest) - Adjust blur_radius (0-10) for depth map smoothing - Choose median_size (3-11) for noise reduction - Toggle auto_contrast and gamma correction as needed 4. Connect the output to a Preview Image node or other image processing nodes -The node will process the input image and output a depth map that can be used in your ComfyUI workflow. +## Model Information + +| Model Name | Quality | VRAM Usage | Speed | +|------------|---------|------------|-------| +| Depth-Anything-V2-Small | Good | ~1.5 GB | Fast | +| Depth-Anything-Small | Good | ~1.5 GB | Fast | +| Depth-Anything-V2-Base | Better | ~2.5 GB | Medium | +| Depth-Anything-Base | Better | ~2.5 GB | Medium | +| Depth-Anything-Large | Best | ~4.0 GB | Slow | + +## Troubleshooting Guide + +### Common Issues and Solutions + +#### Model Download Issues +- **Error**: "Failed to load model" or "Model not found" +- **Solution**: + 1. Check your internet connection + 2. Try authenticating with Hugging Face: + ```bash + pip install huggingface_hub + huggingface-cli login + ``` + 3. Try a different model (e.g., switch to Depth-Anything-V2-Small) + 4. Check the ComfyUI console for detailed error messages + +#### CUDA Out of Memory Errors +- **Error**: "CUDA out of memory" or node shows red error image +- **Solution**: + 1. Try a smaller model (Depth-Anything-V2-Small uses the least memory) + 2. Enable the `force_cpu` option (slower but uses less VRAM) + 3. Reduce the size of your input image + 4. Close other VRAM-intensive applications + +#### Node Not Appearing in ComfyUI +- **Solution**: + 1. Check your ComfyUI console for error messages + 2. Verify that all dependencies are installed: + ```bash + pip install transformers>=4.20.0 Pillow>=9.1.0 numpy>=1.23.0 timm>=0.6.12 + ``` + 3. Try restarting ComfyUI + 4. Check that the node files are in the correct directory + +#### Node Returns Original Image or Black Image +- **Solution**: + 1. Try enabling the `force_reload` option + 2. Check the ComfyUI console for error messages + 3. Try using a different model + 4. Make sure your input image is valid (not corrupted or empty) + 5. Try restarting ComfyUI + +#### Slow Performance +- **Solution**: + 1. Use a smaller model (Depth-Anything-V2-Small is fastest) + 2. Reduce input image size + 3. If using CPU mode, consider using GPU if available + 4. Close other applications that might be using GPU resources + +### Where to Get Help +- Create an issue on the [GitHub repository](https://github.com/Limbicnation/ComfyUIDepthEstimation/issues) +- Check the ComfyUI console for detailed error messages +- Visit the ComfyUI Discord for community support ## License This project is licensed under the Apache License. ---- +--- \ No newline at end of file diff --git a/__init__.py b/__init__.py index a062235..26238c1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,26 +1,123 @@ """ ComfyUI Depth Estimation Node -A custom node for depth map estimation using Depth-Anything-V2-Small model. +A custom node for depth map estimation using Depth-Anything models. """ -from .depth_estimation_node import DepthEstimationNode +import os +import logging +import importlib.util + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("DepthEstimation") # Version info -__version__ = "1.0.0" +__version__ = "1.1.0" -# Node class mapping for ComfyUI registration -NODE_CLASS_MAPPINGS = { - "DepthEstimationNode": DepthEstimationNode -} - -# Display names for UI -NODE_DISPLAY_NAME_MAPPINGS = { - "DepthEstimationNode": "Depth Estimation" -} +# Node class mappings - will be populated based on dependency checks +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} # Web extension info for ComfyUI WEB_DIRECTORY = "./js" +# Graceful dependency checking +required_dependencies = { + "torch": "2.0.0", + "transformers": "4.20.0", + "numpy": "1.23.0", + "PIL": "9.2.0", # Pillow is imported as PIL + "timm": "0.6.12", + "huggingface_hub": "0.16.0" +} + +missing_dependencies = [] + +# Check each dependency +for module_name, min_version in required_dependencies.items(): + try: + if module_name == "PIL": + # Special case for Pillow/PIL + import PIL + module_version = PIL.__version__ + else: + module = __import__(module_name) + module_version = getattr(module, "__version__", "unknown") + + logger.info(f"Found {module_name} version {module_version}") + except ImportError: + missing_dependencies.append(f"{module_name}>={min_version}") + logger.warning(f"Missing required dependency: {module_name}>={min_version}") + +if missing_dependencies: + # Create placeholder node with dependency error + class DependencyErrorNode: + """Placeholder node that shows dependency installation instructions.""" + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("STRING",) + FUNCTION = "error_message" + CATEGORY = "depth" + + def error_message(self): + missing = ", ".join(missing_dependencies) + message = f"Dependencies missing: {missing}. Please install with: pip install {' '.join(missing_dependencies)}" + print(f"DepthEstimation Node Error: {message}") + return (message,) + + # Register the error node instead of the real node + NODE_CLASS_MAPPINGS = { + "DepthEstimationNode": DependencyErrorNode + } + + NODE_DISPLAY_NAME_MAPPINGS = { + "DepthEstimationNode": "Depth Estimation (Missing Dependencies)" + } +else: + # All dependencies are available, try to import the actual node + try: + from .depth_estimation_node import DepthEstimationNode + + # Register the actual depth estimation node + NODE_CLASS_MAPPINGS = { + "DepthEstimationNode": DepthEstimationNode + } + + NODE_DISPLAY_NAME_MAPPINGS = { + "DepthEstimationNode": "Depth Estimation" + } + except Exception as e: + # Capture any import errors that might occur with transformers + logger.error(f"Error importing depth estimation node: {str(e)}") + + # Create a more specific error node + class TransformersErrorNode: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("STRING",) + FUNCTION = "error_message" + CATEGORY = "depth" + + def error_message(self): + if "Descriptors cannot be created directly" in str(e): + message = "Protobuf version conflict. Run: pip install protobuf==3.20.3" + else: + message = f"Error loading depth estimation: {str(e)}" + return (message,) + + NODE_CLASS_MAPPINGS = { + "DepthEstimationNode": TransformersErrorNode + } + + NODE_DISPLAY_NAME_MAPPINGS = { + "DepthEstimationNode": "Depth Estimation (Error)" + } + # Module exports __all__ = [ "NODE_CLASS_MAPPINGS", diff --git a/depth_estimation_node.py b/depth_estimation_node.py index 745c4f1..ae32204 100644 --- a/depth_estimation_node.py +++ b/depth_estimation_node.py @@ -1,42 +1,593 @@ import os import numpy as np import torch +import traceback +import time +import requests +import urllib.request +import wget +from pathlib import Path from transformers import pipeline -from PIL import Image, ImageFilter, ImageOps +from PIL import Image, ImageFilter, ImageOps, ImageDraw, ImageFont import folder_paths from comfy.model_management import get_torch_device, get_free_memory import gc import logging +import torch.nn as nn +import torch.nn.functional as F from typing import Tuple, List, Dict, Any, Optional, Union +# Try to import timm (for vision transformers) +try: + import timm + TIMM_AVAILABLE = True +except ImportError: + TIMM_AVAILABLE = False + print("Warning: timm not available. Direct loading of Depth Anything models may not work.") + # Setup logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("DepthEstimation") +# Depth Anything V2 Implementation +class DepthAnythingV2(nn.Module): + """Direct implementation of Depth Anything V2 model""" + def __init__(self, encoder='vits', features=64, out_channels=[48, 96, 192, 384]): + super().__init__() + self.encoder = encoder + self.features = features + self.out_channels = out_channels + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' + + # Create encoder based on specification + if TIMM_AVAILABLE: + if encoder == 'vits': + self.backbone = timm.create_model('vit_small_patch16_224', pretrained=False) + self.embed_dim = 384 + elif encoder == 'vitb': + self.backbone = timm.create_model('vit_base_patch16_224', pretrained=False) + self.embed_dim = 768 + elif encoder == 'vitl': + self.backbone = timm.create_model('vit_large_patch16_224', pretrained=False) + self.embed_dim = 1024 + else: # fallback to vits + self.backbone = timm.create_model('vit_small_patch16_224', pretrained=False) + self.embed_dim = 384 + + # Implement the rest of the model architecture + self.initialize_decoder() + else: + # Fallback if timm is not available + from torchvision.models import resnet50 + self.backbone = resnet50(pretrained=False) + self.embed_dim = 2048 + logger.warning("Using fallback ResNet50 model (timm not available)") + + def initialize_decoder(self): + """Initialize the decoder layers""" + self.neck = nn.Sequential( + nn.Conv2d(self.embed_dim, self.features, 1, 1, 0), + nn.Conv2d(self.features, self.features, 3, 1, 1), + ) + + # Create decoders for each level + self.decoders = nn.ModuleList([ + self.create_decoder_level(self.features, self.out_channels[0]), + self.create_decoder_level(self.out_channels[0], self.out_channels[1]), + self.create_decoder_level(self.out_channels[1], self.out_channels[2]), + self.create_decoder_level(self.out_channels[2], self.out_channels[3]) + ]) + + # Final depth head + self.depth_head = nn.Sequential( + nn.Conv2d(self.out_channels[3], self.out_channels[3], 3, 1, 1), + nn.BatchNorm2d(self.out_channels[3]), + nn.ReLU(True), + nn.Conv2d(self.out_channels[3], 1, 1) + ) + + def create_decoder_level(self, in_channels, out_channels): + """Create a decoder level""" + return nn.Sequential( + nn.Conv2d(in_channels, out_channels, 3, 1, 1), + nn.BatchNorm2d(out_channels), + nn.ReLU(True), + nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True) + ) + + def forward(self, x): + """Forward pass of the model""" + # For timm ViT models + if hasattr(self.backbone, 'forward_features'): + features = self.backbone.forward_features(x) + + # Reshape features based on model type + if 'vit' in self.encoder: + # Reshape transformer output to spatial features + # Exact reshape depends on the model details + h = w = int(features.shape[1]**0.5) + features = features.reshape(-1, h, w, self.embed_dim).permute(0, 3, 1, 2) + + # Process through decoder + x = self.neck(features) + + # Apply decoder stages + for decoder in self.decoders: + x = decoder(x) + + # Final depth prediction + depth = self.depth_head(x) + + return depth + else: + # Fallback for ResNet + x = self.backbone.conv1(x) + x = self.backbone.bn1(x) + x = self.backbone.relu(x) + x = self.backbone.maxpool(x) + + x = self.backbone.layer1(x) + x = self.backbone.layer2(x) + x = self.backbone.layer3(x) + x = self.backbone.layer4(x) + + # Process through simple decoder + x = F.interpolate(x, scale_factor=2, mode='bilinear', align_corners=True) + x = self.depth_head(x) + + return x + + def infer_image(self, image): + """Process an image and return the depth map + + Args: + image: A numpy image in BGR format (OpenCV) or RGB PIL Image + + Returns: + depth: A numpy array containing the depth map + """ + # Convert input to tensor + if isinstance(image, np.ndarray): + # Convert BGR to RGB + if image.shape[2] == 3: + image = image[:, :, ::-1] + # Normalize + image = image.astype(np.float32) / 255.0 + # HWC to CHW + image = image.transpose(2, 0, 1) + # Add batch dimension + image = torch.from_numpy(image).unsqueeze(0) + elif isinstance(image, Image.Image): + # Convert PIL image to numpy + image = np.array(image).astype(np.float32) / 255.0 + # HWC to CHW + image = image.transpose(2, 0, 1) + # Add batch dimension + image = torch.from_numpy(image).unsqueeze(0) + + # Move to device + image = image.to(self.device) + + # Set model to eval mode + self.eval() + + # Get prediction + with torch.no_grad(): + depth = self.forward(image) + + # Convert to numpy + depth = depth.squeeze().cpu().numpy() + + return depth + + def __call__(self, image): + """Compatible interface with the pipeline API""" + if isinstance(image, Image.Image): + # Convert to numpy for processing + depth = self.infer_image(image) + # Return in the format expected by the node + return {"predicted_depth": torch.from_numpy(depth).unsqueeze(0)} + else: + # Already a tensor, process directly + self.eval() + with torch.no_grad(): + depth = self.forward(image) + return {"predicted_depth": depth} + # Configure model paths if not hasattr(folder_paths, "models_dir"): folder_paths.models_dir = os.path.join(folder_paths.base_path, "models") -# Register depth models path +# Register depth models path - support multiple possible directory structures DEPTH_DIR = "depth_anything" -folder_paths.folder_names_and_paths[DEPTH_DIR] = ([ - os.path.join(folder_paths.models_dir, DEPTH_DIR) -], folder_paths.supported_pt_extensions) +DEPTH_ANYTHING_DIR = "depthanything" -# Set models directory -MODELS_DIR = folder_paths.folder_names_and_paths[DEPTH_DIR][0][0] -os.makedirs(MODELS_DIR, exist_ok=True) +# Check which directory structure exists +possible_paths = [ + os.path.join(folder_paths.models_dir, DEPTH_DIR), + os.path.join(folder_paths.models_dir, DEPTH_ANYTHING_DIR), + os.path.join(folder_paths.models_dir, DEPTH_ANYTHING_DIR, DEPTH_DIR), + os.path.join(folder_paths.models_dir, "checkpoints", DEPTH_DIR), + os.path.join(folder_paths.models_dir, "checkpoints", DEPTH_ANYTHING_DIR), +] + +# Filter to only paths that exist +existing_paths = [p for p in possible_paths if os.path.exists(p)] +if not existing_paths: + # If none exists, create the default one + existing_paths = [os.path.join(folder_paths.models_dir, DEPTH_DIR)] + os.makedirs(existing_paths[0], exist_ok=True) + logger.info(f"Created model directory: {existing_paths[0]}") + +# Log all found paths for debugging +logger.info(f"Found depth model directories: {existing_paths}") + +# Register all possible paths for model loading +folder_paths.folder_names_and_paths[DEPTH_DIR] = (existing_paths, folder_paths.supported_pt_extensions) + +# Set primary models directory to the first available path +MODELS_DIR = existing_paths[0] +logger.info(f"Using primary models directory: {MODELS_DIR}") + +# Set Hugging Face cache to the models directory to ensure models are saved there os.environ["TRANSFORMERS_CACHE"] = MODELS_DIR +os.environ["HF_HOME"] = MODELS_DIR -# Define all models mentioned in the README -DEPTH_MODELS = { - "Depth-Anything-Small": "LiheYoung/depth-anything-small", - "Depth-Anything-Base": "LiheYoung/depth-anything-base", - "Depth-Anything-Large": "LiheYoung/depth-anything-large", - "Depth-Anything-V2-Small": "LiheYoung/depth-anything-small-hf", - "Depth-Anything-V2-Base": "LiheYoung/depth-anything-base-hf", +# Define model configurations for direct loading +MODEL_CONFIGS = { + 'vits': {'encoder': 'vits', 'features': 64, 'out_channels': [48, 96, 192, 384]}, + 'vitb': {'encoder': 'vitb', 'features': 128, 'out_channels': [96, 192, 384, 768]}, + 'vitl': {'encoder': 'vitl', 'features': 256, 'out_channels': [256, 512, 1024, 1024]}, + 'vitg': {'encoder': 'vitg', 'features': 384, 'out_channels': [1536, 1536, 1536, 1536]} } +# Define all models mentioned in the README with memory requirements +DEPTH_MODELS = { + "Depth-Anything-Small": { + "path": "LiheYoung/depth-anything-small-hf", # Correct HF path for V1 + "vram_mb": 1500, + "direct_url": "https://github.com/LiheYoung/Depth-Anything/releases/download/v1.0/depth_anything_vitb14.pt", + "model_type": "v1", + "encoder": "vitb" + }, + "Depth-Anything-Base": { + "path": "LiheYoung/depth-anything-base-hf", # Correct HF path for V1 + "vram_mb": 2500, + "direct_url": "https://github.com/LiheYoung/Depth-Anything/releases/download/v1.0/depth_anything_vitl14.pt", + "model_type": "v1", + "encoder": "vitl" + }, + "Depth-Anything-Large": { + "path": "LiheYoung/depth-anything-large-hf", # Correct HF path for V1 + "vram_mb": 4000, + "direct_url": "https://github.com/LiheYoung/Depth-Anything/releases/download/v1.0/depth_anything_vitl14.pt", + "model_type": "v1", + "encoder": "vitl" + }, + "Depth-Anything-V2-Small": { + "path": "depth-anything/Depth-Anything-V2-Small-hf", # Updated corrected path as shown in example + "vram_mb": 1500, + "direct_url": "https://huggingface.co/depth-anything/Depth-Anything-V2-Small-hf/resolve/main/pytorch_model.bin", + "model_type": "v2", + "encoder": "vits", + "config": MODEL_CONFIGS["vits"] + }, + "Depth-Anything-V2-Base": { + "path": "depth-anything/Depth-Anything-V2-Base-hf", # Updated corrected path + "vram_mb": 2500, + "direct_url": "https://huggingface.co/depth-anything/Depth-Anything-V2-Base-hf/resolve/main/pytorch_model.bin", + "model_type": "v2", + "encoder": "vitb", + "config": MODEL_CONFIGS["vitb"] + }, + # Add MiDaS models as dedicated options with direct download URLs + "MiDaS-Small": { + "path": "Intel/dpt-hybrid-midas", + "vram_mb": 1000, + "midas_type": "MiDaS_small", + "direct_url": "https://github.com/intel-isl/MiDaS/releases/download/v2_1/midas_v21_small_256.pt" + }, + "MiDaS-Base": { + "path": "Intel/dpt-hybrid-midas", + "vram_mb": 1200, + "midas_type": "DPT_Hybrid", + "direct_url": "https://github.com/intel-isl/MiDaS/releases/download/v3/dpt_hybrid-midas-501f0c75.pt" + } +} + +class MiDaSWrapper: + def __init__(self, model_type, device): + self.device = device + + try: + # Import required libraries + import torch.nn.functional as F + + # Use a more reliable approach to loading MiDaS models + if model_type == "DPT_Hybrid" or model_type == "dpt_hybrid": + # Use direct URL download for MiDaS models + midas_url = "https://github.com/intel-isl/MiDaS/releases/download/v3/dpt_hybrid-midas-501f0c75.pt" + local_path = os.path.join(MODELS_DIR, "dpt_hybrid_midas.pt") + + if not os.path.exists(local_path): + logger.info(f"Downloading MiDaS model from {midas_url}") + try: + response = requests.get(midas_url, stream=True) + if response.status_code == 200: + with open(local_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + logger.info(f"Downloaded MiDaS model to {local_path}") + else: + logger.error(f"Failed to download model: {response.status_code}") + except Exception as e: + logger.error(f"Error downloading MiDaS model: {e}") + + # Load pretrained model + try: + # Create a simple model architecture + from torchvision.models import resnet50 + self.model = resnet50() + self.model.fc = torch.nn.Linear(2048, 1) + + # Load state dict if available + if os.path.exists(local_path): + logger.info(f"Loading MiDaS model from {local_path}") + state_dict = torch.load(local_path, map_location=device) + # Convert all parameters to float + floated_state_dict = {k: v.float() for k, v in state_dict.items()} + self.model.load_state_dict(floated_state_dict) + + except Exception as e: + logger.error(f"Error loading MiDaS model state dict: {e}") + # Fallback to ResNet + self.model = resnet50(pretrained=True) + self.model.fc = torch.nn.Linear(2048, 1) + + else: # Other model types or fallback + from torchvision.models import resnet50 + self.model = resnet50(pretrained=True) + self.model.fc = torch.nn.Linear(2048, 1) + + # Ensure model parameters are float + for param in self.model.parameters(): + param.data = param.data.float() + + # Explicitly convert model to FloatTensor + self.model = self.model.float() + + # Move model to device and set to eval mode + self.model = self.model.to(device) + self.model.eval() + + except Exception as e: + logger.error(f"Failed to load MiDaS model: {e}") + logger.error(traceback.format_exc()) + # Create a minimal model as absolute fallback + from torchvision.models import resnet18 + self.model = resnet18(pretrained=True).float().to(device) + self.model.fc = torch.nn.Linear(512, 1).float().to(device) + self.model.eval() + + def __call__(self, image): + """Process an image and return the depth map""" + try: + # Convert PIL image to tensor for processing + if isinstance(image, Image.Image): + # Get original dimensions + original_width, original_height = image.size + + # Ensure dimensions are multiple of 32 (required for some models) + # This helps prevent tensor dimension mismatches + target_height = ((original_height + 31) // 32) * 32 + target_width = ((original_width + 31) // 32) * 32 + + # Keep original dimensions - don't force 384x384 + # The caller should already have resized to the requested input_size + + # Log resize information if needed + if (target_width != original_width) or (target_height != original_height): + logger.info(f"Adjusting dimensions from {original_width}x{original_height} to {target_width}x{target_height} (multiples of 32)") + img_resized = image.resize((target_width, target_height), Image.LANCZOS) + else: + img_resized = image + + # Convert to numpy array + img_np = np.array(img_resized).astype(np.float32) / 255.0 + + # Check for NaN values and replace them with zeros + if np.isnan(img_np).any(): + logger.warning("Input image contains NaN values. Replacing with zeros.") + img_np = np.nan_to_num(img_np, nan=0.0) + + # Convert to tensor with proper shape (B,C,H,W) + if len(img_np.shape) == 3: + # RGB image + img_np = img_np.transpose(2, 0, 1) # (H,W,C) -> (C,H,W) + else: + # Grayscale image - add channel dimension + img_np = np.expand_dims(img_np, axis=0) + + # Add batch dimension and ensure float32 + input_tensor = torch.from_numpy(img_np).unsqueeze(0).float() + else: + # Already a tensor - ensure float32 by explicitly converting + # This is the key fix for the "Input type (torch.cuda.DoubleTensor) and weight type (torch.cuda.FloatTensor)" error + input_tensor = None + + # Handle potential error cases with clearer messages + if not torch.is_tensor(image): + logger.error(f"Expected tensor or PIL image, got {type(image)}") + # Create dummy tensor as fallback + input_tensor = torch.ones((1, 3, 512, 512), dtype=torch.float32) + elif image.numel() == 0: + logger.error("Input tensor is empty") + # Create dummy tensor as fallback + input_tensor = torch.ones((1, 3, 512, 512), dtype=torch.float32) + else: + # Check for NaN values + if torch.isnan(image).any(): + logger.warning("Input tensor contains NaN values. Replacing with zeros.") + image = torch.nan_to_num(image, nan=0.0) + + # Always convert to float32 to prevent type mismatches + input_tensor = image.float() # Convert any tensor to FloatTensor + + # Handle tensor shape issues with more robust dimension checking + if input_tensor.dim() == 2: # [H, W] + # Single channel 2D tensor + input_tensor = input_tensor.unsqueeze(0).unsqueeze(0) # Add batch and channel dims [1, 1, H, W] + logger.info(f"Converted 2D tensor to 4D with shape: {input_tensor.shape}") + elif input_tensor.dim() == 3: + # Could be [C, H, W] or [B, H, W] or [H, W, C] + shape = input_tensor.shape + if shape[-1] == 3 or shape[-1] == 1: # [H, W, C] format + # Convert from HWC to BCHW + input_tensor = input_tensor.permute(2, 0, 1).unsqueeze(0) # [H, W, C] -> [1, C, H, W] + logger.info(f"Converted HWC tensor to BCHW with shape: {input_tensor.shape}") + elif shape[0] <= 3: # Likely [C, H, W] + input_tensor = input_tensor.unsqueeze(0) # Add batch dim [1, C, H, W] + logger.info(f"Added batch dimension to CHW tensor: {input_tensor.shape}") + else: # Likely [B, H, W] + input_tensor = input_tensor.unsqueeze(1) # Add channel dim [B, 1, H, W] + logger.info(f"Added channel dimension to BHW tensor: {input_tensor.shape}") + + # Ensure proper shape after corrections + if input_tensor.dim() != 4: + logger.warning(f"Tensor still has incorrect dimensions ({input_tensor.dim()}). Forcing reshape.") + # Force reshape to 4D + orig_shape = input_tensor.shape + if input_tensor.dim() > 4: + # Too many dimensions, collapse extras + input_tensor = input_tensor.reshape(1, -1, orig_shape[-2], orig_shape[-1]) + else: + # Create a standard 4D tensor as fallback + input_tensor = torch.ones((1, 3, 512, 512), dtype=torch.float32) + + # Move to device and ensure float type + input_tensor = input_tensor.to(self.device).float() + + # Log tensor shape for debugging + logger.info(f"MiDaS input tensor shape: {input_tensor.shape}, dtype: {input_tensor.dtype}") + + # Run inference with better error handling + with torch.no_grad(): + try: + # Make sure input is float32 and model weights are float32 + output = self.model(input_tensor) + + # Handle various output shapes + if output.dim() == 1: # [B*H*W] flattened output + # Reshape based on input dimensions + b, _, h, w = input_tensor.shape + output = output.reshape(b, 1, h, w) + elif output.dim() == 2: # [B, H*W] or similar + # Could be flattened spatial dimensions + b = output.shape[0] + if b == input_tensor.shape[0]: # Batch size matches + h = int(np.sqrt(output.shape[1])) # Estimate height assuming square + w = h + if h * w == output.shape[1]: # Perfect square + output = output.reshape(b, 1, h, w) + else: + # Not a perfect square, use input dimensions + _, _, h, w = input_tensor.shape + output = output.reshape(b, 1, h, w) + else: + # Add dimensions to make 4D + output = output.unsqueeze(1).unsqueeze(1) + + # Ensure output has standard 4D shape (B,C,H,W) for interpolation + if output.dim() != 4: + logger.warning(f"Output has non-standard dimensions: {output.shape}, adding dimensions") + # Add dimensions until we have 4D + while output.dim() < 4: + output = output.unsqueeze(-1) + + # Resize to match input resolution + if isinstance(image, Image.Image): + w, h = image.size + + # Log the shape for debugging + logger.info(f"Resizing output tensor from shape {output.shape} to size ({h}, {w})") + + # Ensure output tensor has correct number of dimensions for interpolation + # Standard interpolation requires 4D tensor (B,C,H,W) + try: + # Now interpolate with proper dimensions + output = torch.nn.functional.interpolate( + output, + size=(h, w), + mode="bicubic", + align_corners=False + ) + except RuntimeError as resize_err: + logger.error(f"Interpolation error: {resize_err}. Attempting to fix tensor shape.") + + # Last resort: create compatible tensor from output data + try: + # Get data and reshape to simple 2D first + output_data = output.view(-1).cpu().numpy() + output_reshaped = torch.from_numpy( + np.resize(output_data, (h * w)) + ).reshape(1, 1, h, w).to(self.device).float() + + logger.info(f"Corrected output shape to {output_reshaped.shape}") + output = output_reshaped + except Exception as reshape_err: + logger.error(f"Reshape fix failed: {reshape_err}. Using fallback tensor.") + # Create a basic gradient as fallback + output = torch.ones((1, 1, h, w), device=self.device, dtype=torch.float32) + y_coords = torch.linspace(0, 1, h).reshape(-1, 1).repeat(1, w) + output[0, 0, :, :] = y_coords.to(self.device) + + except Exception as model_err: + logger.error(f"Model inference error: {model_err}") + logger.error(traceback.format_exc()) + + # Create a visually distinguishable gradient pattern fallback + if isinstance(image, Image.Image): + w, h = image.size + else: + # Extract dimensions from input tensor + _, _, h, w = input_tensor.shape if input_tensor.dim() >= 4 else (1, 1, 512, 512) + + # Create gradient depth map as fallback + output = torch.ones((1, 1, h, w), device=self.device, dtype=torch.float32) + y_coords = torch.linspace(0, 1, h).reshape(-1, 1).repeat(1, w) + output[0, 0, :, :] = y_coords.to(self.device) + + # Final validation - ensure output is float32 and has no NaNs + output = output.float() + if torch.isnan(output).any(): + logger.warning("Output contains NaN values. Replacing with zeros.") + output = torch.nan_to_num(output, nan=0.0) + + # Use same interface as the pipeline + return {"predicted_depth": output} + + except Exception as e: + logger.error(f"Error in MiDaS inference: {e}") + logger.error(traceback.format_exc()) + + # Return a placeholder depth map + if isinstance(image, Image.Image): + w, h = image.size + dummy_tensor = torch.ones((1, 1, h, w), device=self.device) + else: + # Try to get shape from tensor + shape = image.shape + if len(shape) >= 3: + if shape[0] == 3: # CHW format + h, w = shape[1], shape[2] + else: # HWC format + h, w = shape[0], shape[1] + else: + h, w = 512, 512 + dummy_tensor = torch.ones((1, 1, h, w), device=self.device) + + return {"predicted_depth": dummy_tensor} + class DepthEstimationNode: """ ComfyUI node for depth estimation using Depth Anything models. @@ -61,10 +612,17 @@ class DepthEstimationNode: "required": { "image": ("IMAGE",), "model_name": (list(DEPTH_MODELS.keys()),), + # Ensure minimum size is enforced by the UI + "input_size": ("INT", {"default": 518, "min": 256, "max": 1024, "step": 1}), "blur_radius": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.1}), + # Define median_size as a dropdown with specific string values "median_size": (cls.MEDIAN_SIZES, {"default": "5"}), "apply_auto_contrast": ("BOOLEAN", {"default": True}), "apply_gamma": ("BOOLEAN", {"default": True}) + }, + "optional": { + "force_reload": ("BOOLEAN", {"default": False}), + "force_cpu": ("BOOLEAN", {"default": False}) } } @@ -76,6 +634,10 @@ class DepthEstimationNode: """Clean up resources and free VRAM.""" try: if self.depth_estimator is not None: + # Save model name before deletion for logging + model_name = self.current_model + + # Delete the estimator del self.depth_estimator self.depth_estimator = None self.current_model = None @@ -85,233 +647,2034 @@ class DepthEstimationNode: torch.cuda.empty_cache() gc.collect() - logger.info("Cleaned up model resources") + logger.info(f"Cleaned up model resources for {model_name}") + + # Log available memory after cleanup if CUDA is available + if torch.cuda.is_available(): + try: + free_mem_info = get_free_memory(get_torch_device()) + # Handle return value whether it's a tuple or a single value + if isinstance(free_mem_info, tuple): + free_mem, total_mem = free_mem_info + logger.info(f"Available VRAM after cleanup: {free_mem/1024:.2f}MB of {total_mem/1024:.2f}MB") + else: + logger.info(f"Available VRAM after cleanup: {free_mem_info/1024:.2f}MB") + except Exception as e: + logger.warning(f"Error getting memory info: {e}") except Exception as e: logger.warning(f"Error during cleanup: {e}") + logger.debug(traceback.format_exc()) - def ensure_model_loaded(self, model_name: str) -> None: + def ensure_model_loaded(self, model_name: str, force_reload: bool = False, force_cpu: bool = False) -> None: """ Ensures the correct model is loaded with proper VRAM management and fallback options. Args: model_name: The name of the model to load + force_reload: If True, reload the model even if it's already loaded + force_cpu: If True, force loading on CPU regardless of GPU availability Raises: RuntimeError: If the model fails to load after all fallback attempts """ try: + # Check for valid model name with more helpful fallback if model_name not in DEPTH_MODELS: - raise ValueError(f"Unknown model: {model_name}. Available models: {list(DEPTH_MODELS.keys())}") + # Find the most similar model name if possible + available_models = list(DEPTH_MODELS.keys()) - model_path = DEPTH_MODELS[model_name] + if len(available_models) > 0: + # First try to find a model with a similar name + model_name_lower = model_name.lower() + + # Prioritized fallback selection logic: + # 1. Try to match on similar name + # 2. Prefer V2 models if V2 was requested + # 3. Prefer smaller models (more reliable) + if "v2" in model_name_lower and "small" in model_name_lower: + fallback_model = "Depth-Anything-V2-Small" + elif "v2" in model_name_lower and "base" in model_name_lower: + fallback_model = "Depth-Anything-V2-Base" + elif "v2" in model_name_lower: + fallback_model = "Depth-Anything-V2-Small" + elif "small" in model_name_lower: + fallback_model = "Depth-Anything-Small" + elif "midas" in model_name_lower: + fallback_model = "MiDaS-Small" + else: + # Default to the first model if no better match found + fallback_model = available_models[0] + + logger.warning(f"Unknown model: {model_name}. Falling back to {fallback_model}") + model_name = fallback_model + else: + raise ValueError(f"No depth models available. Please check your installation.") - # Only reload if needed - if self.depth_estimator is None or self.current_model != model_path: - self.cleanup() - - # Set up device - if self.device is None: - self.device = get_torch_device() - - logger.info(f"Loading depth model: {model_name} on device {self.device}") - - # Determine device type for pipeline - device_type = 'cuda' if torch.cuda.is_available() else 'cpu' - - # Use FP16 for CUDA devices to save VRAM - dtype = torch.float16 if 'cuda' in str(self.device) else torch.float32 - - # Create a dedicated cache directory for this model - cache_dir = os.path.join(MODELS_DIR, model_name.replace("-", "_").lower()) - os.makedirs(cache_dir, exist_ok=True) - - # List of model paths to try (original and fallback) - model_paths_to_try = [ - model_path, # Original path - model_path + "-hf", # Try with -hf suffix - model_path.replace("depth-anything", "depth-anything-hf") # Alternative format + # Get model info and validate + model_info = DEPTH_MODELS[model_name] + + # Handle model_info as string or dict with better defaults + if isinstance(model_info, dict): + model_path = model_info.get("path", "") + required_vram = model_info.get("vram_mb", 2000) * 1024 # Convert to KB + model_type = model_info.get("model_type", "v1") # v1 or v2 + encoder = model_info.get("encoder", "vits") # Model encoder type + config = model_info.get("config", None) # Model config for direct loading + direct_url = model_info.get("direct_url", None) # Direct download URL + else: + model_path = str(model_info) + required_vram = 2000 * 1024 # Default 2GB + model_type = "v1" + encoder = "vits" + config = None + direct_url = None + + # Only reload if needed or forced + if not force_reload and self.depth_estimator is not None and self.current_model == model_path: + logger.info(f"Model '{model_name}' already loaded") + return + + # Clean up any existing model to free memory before loading new one + self.cleanup() + + # Set up device for model + if self.device is None: + self.device = get_torch_device() + + logger.info(f"Loading depth model: {model_name} on {'CPU' if force_cpu else self.device}") + + # Enhanced VRAM check with better error handling + if torch.cuda.is_available() and not force_cpu: + try: + free_mem_info = get_free_memory(self.device) + + # Process different return formats from get_free_memory + if isinstance(free_mem_info, tuple): + free_mem, total_mem = free_mem_info + logger.info(f"Available VRAM: {free_mem/1024:.2f}MB, Required: {required_vram/1024:.2f}MB") + else: + free_mem = free_mem_info + logger.info(f"Available VRAM: {free_mem/1024:.2f}MB, Required: {required_vram/1024:.2f}MB") + total_mem = free_mem * 2 # Estimate if not available + + # Add buffer to required memory to avoid OOM issues + required_vram_with_buffer = required_vram * 1.2 # Add 20% buffer + + # If not enough memory, fall back to CPU with warning + if free_mem < required_vram_with_buffer: + logger.warning( + f"Insufficient VRAM for {model_name} (need ~{required_vram/1024:.1f}MB, " + + f"have {free_mem/1024:.1f}MB). Using CPU instead." + ) + force_cpu = True + except Exception as mem_error: + logger.warning(f"Error checking VRAM: {str(mem_error)}. Using CPU to be safe.") + force_cpu = True + + # Determine optimal device configuration + device_type = 'cpu' if force_cpu else ('cuda' if torch.cuda.is_available() else 'cpu') + + # Use appropriate dtype based on device and model + # FP16 for CUDA saves VRAM but doesn't work well for all models + if 'cuda' in str(self.device) and not force_cpu: + # V2 models have issues with FP16 - use FP32 for them + if model_type == "v2": + dtype = torch.float32 + else: + # Other models can use FP16 to save VRAM + dtype = torch.float16 + else: + # CPU always uses FP32 + dtype = torch.float32 + + # Create model-specific cache directory + # Use consistent naming to improve cache hits + model_cache_name = model_name.replace("-", "_").lower() + cache_dir = os.path.join(MODELS_DIR, model_cache_name) + os.makedirs(cache_dir, exist_ok=True) + + # Prioritized loading strategy: + # 1. Check for locally cached model files + # 2. Try direct download from URLs that don't require authentication + # 3. Try loading from Hugging Face using transformers pipeline + # 4. Fall back to direct model implementation + # 5. Fall back to MiDaS model + + # Step 1: First check if we already have a local model file + local_model_file = None + + # Search all valid model directories for existing files + for base_path in existing_paths: + # Check multiple possible locations and naming patterns + locations_to_check = [ + os.path.join(base_path, model_cache_name), + os.path.join(base_path, model_path.replace("/", "_")), + base_path, + os.path.join(base_path, "v2") if model_type == "v2" else None, ] - # Try each model path - success = False - last_error = None + # Filter out None values + locations_to_check = [loc for loc in locations_to_check if loc is not None] - for path in model_paths_to_try: - try: - logger.info(f"Attempting to load from: {path}") - - # Try with online mode first + # Common model filenames to check + model_filenames = [ + "pytorch_model.bin", + "model.pt", + "model.pth", + f"{model_cache_name}.pt", + f"{model_cache_name}.bin", + f"depth_anything_{encoder}.pt", # Common naming for Depth Anything models + f"depth_anything_v2_{encoder}.pt", # V2 naming format + ] + + # Search all locations and filenames + for location in locations_to_check: + if os.path.exists(location): + for filename in model_filenames: + file_path = os.path.join(location, filename) + if os.path.exists(file_path) and os.path.getsize(file_path) > 1000000: # >1MB to avoid empty files + local_model_file = file_path + logger.info(f"Found existing model file: {local_model_file}") + break + + if local_model_file: + break + + if local_model_file: + break + + # Step 2: If no local file found, try downloading from direct URLs + # These URLs don't require authentication and are more reliable + if not local_model_file and model_type == "v2": + # Comprehensive list of URLs to try for V2 models + alternative_urls = { + "Depth-Anything-V2-Small": [ + "https://huggingface.co/depth-anything/Depth-Anything-V2-Small-hf/resolve/main/pytorch_model.bin", + "https://github.com/LiheYoung/Depth-Anything/releases/download/v2.0/depth_anything_v2_small.pt", + "https://huggingface.co/ckpt/depth-anything-v2/resolve/main/depth_anything_v2_small.pt" + ], + "Depth-Anything-V2-Base": [ + "https://huggingface.co/depth-anything/Depth-Anything-V2-Base-hf/resolve/main/pytorch_model.bin", + "https://github.com/LiheYoung/Depth-Anything/releases/download/v2.0/depth_anything_v2_base.pt", + "https://huggingface.co/ckpt/depth-anything-v2/resolve/main/depth_anything_v2_base.pt" + ], + "MiDaS-Small": [ + "https://github.com/intel-isl/MiDaS/releases/download/v2_1/midas_v21_small_256.pt" + ], + "MiDaS-Base": [ + "https://github.com/intel-isl/MiDaS/releases/download/v3/dpt_hybrid-midas-501f0c75.pt" + ] + } + + # Get URLs to try (including the direct_url from model_info) + urls_to_try = [] + if direct_url: + urls_to_try.append(direct_url) + + # Add alternative URLs for this specific model if available + if model_name in alternative_urls: + urls_to_try.extend(alternative_urls[model_name]) + + # Try downloading the model if not found locally + if urls_to_try: + for url in urls_to_try: try: - self.depth_estimator = pipeline( - "depth-estimation", - model=path, - cache_dir=cache_dir, - local_files_only=False, # Try online first - device_map=device_type, + # Determine output filename and path + model_filename = os.path.basename(url) + download_path = os.path.join(cache_dir, model_filename) + + # Check if already downloaded + if os.path.exists(download_path) and os.path.getsize(download_path) > 1000000: # >1MB to avoid empty files + logger.info(f"Found existing downloaded model at {download_path}") + local_model_file = download_path + break + + # Create parent directory if needed + os.makedirs(os.path.dirname(download_path), exist_ok=True) + + # Download using a reliable method with multiple retries + logger.info(f"Downloading model from {url} to {download_path}") + + success = False + + # Try wget first (most reliable for large files) + try: + import wget + wget.download(url, out=download_path) + if os.path.exists(download_path) and os.path.getsize(download_path) > 1000000: + logger.info(f"Successfully downloaded model with wget to {download_path}") + success = True + except Exception as wget_error: + logger.warning(f"wget download failed: {str(wget_error)}") + + # Try requests if wget failed + if not success: + try: + # Download with progress reporting + with requests.get(url, stream=True, timeout=60) as response: + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + + with open(download_path, 'wb') as f: + downloaded = 0 + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + downloaded += len(chunk) + + if total_size > 0 and downloaded % (20 * 1024 * 1024) == 0: # Log every 20MB + percent = int(100 * downloaded / total_size) + logger.info(f"Download progress: {downloaded/1024/1024:.1f}MB of {total_size/1024/1024:.1f}MB ({percent}%)") + + if os.path.exists(download_path) and os.path.getsize(download_path) > 1000000: + logger.info(f"Successfully downloaded model with requests to {download_path}") + success = True + except Exception as req_error: + logger.warning(f"requests download failed: {str(req_error)}") + + # Try urllib as last resort + if not success: + try: + import urllib.request + urllib.request.urlretrieve(url, download_path) + if os.path.exists(download_path) and os.path.getsize(download_path) > 1000000: + logger.info(f"Successfully downloaded model with urllib to {download_path}") + success = True + except Exception as urllib_error: + logger.warning(f"urllib download failed: {str(urllib_error)}") + + # Set the local_model_file if download succeeded + if success: + local_model_file = download_path + break + else: + # Clean up failed download + if os.path.exists(download_path): + try: + os.remove(download_path) + except: + pass + + except Exception as download_error: + logger.warning(f"Failed to download from {url}: {str(download_error)}") + continue + + # Step 3: Try loading with transformers pipeline + # This is the most feature-complete approach but may fail with auth issues + logger.info(f"Trying to load model '{model_name}' using transformers pipeline") + + # Priority-ordered list of model paths to try + # Ordered from most to least likely to work + model_paths_to_try = [] + + # Start with the specific model requested + model_paths_to_try.append(model_path) + + # Add V2-specific paths for V2 models + if model_type == "v2": + if "small" in model_name.lower(): + model_paths_to_try.append("depth-anything/Depth-Anything-V2-Small-hf") + elif "base" in model_name.lower(): + model_paths_to_try.append("depth-anything/Depth-Anything-V2-Base-hf") + + # Add variants with and without -hf suffix + model_paths_to_try.append(model_path.replace("-hf", "")) + if "-hf" not in model_path: + model_paths_to_try.append(model_path + "-hf") + + # Try both organization name formats + model_paths_to_try.append(model_path.replace("LiheYoung", "depth-anything")) + model_paths_to_try.append(model_path.replace("depth-anything", "LiheYoung")) + else: + # For V1 models, add common variants + model_paths_to_try.append(model_path.replace("-hf", "")) + if "-hf" not in model_path: + model_paths_to_try.append(model_path + "-hf") + + # Add MiDaS fallbacks only if not already trying MiDaS + if "midas" not in model_name.lower(): + model_paths_to_try.append("Intel/dpt-hybrid-midas") + + # Remove duplicates while preserving order + model_paths_to_try = list(dict.fromkeys(model_paths_to_try)) + + # Log all paths we're going to try + logger.info(f"Will try loading from these paths in order: {model_paths_to_try}") + + # Try loading with transformers pipeline + pipeline_success = False + pipeline_error = None + + for path in model_paths_to_try: + # Skip empty paths + if not path.strip(): + continue + + logger.info(f"Attempting to load model from: {path}") + + # First try online loading (allows downloading new models) + try: + logger.info(f"Loading with online mode: model={path}, device={device_type}, dtype={dtype}") + + # Try standard pipeline creation first + try: + from transformers import pipeline + + # Create pipeline with timeout and error handling + self.depth_estimator = pipeline( + "depth-estimation", + model=path, + cache_dir=cache_dir, + local_files_only=False, # Try online first + device_map=device_type, + torch_dtype=dtype + ) + + # Verify that pipeline was created + if self.depth_estimator is None: + raise RuntimeError(f"Pipeline initialization returned None for {path}") + + # Validate by running a test inference + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + try: + test_result = self.depth_estimator(test_img) + + # Further verify the output format + if not isinstance(test_result, dict) or "predicted_depth" not in test_result: + raise RuntimeError("Invalid output format from pipeline") + + # Success - log and break + logger.info(f"Successfully loaded model from {path} with online mode") + pipeline_success = True + break + + except Exception as test_error: + logger.warning(f"Pipeline created but test failed: {str(test_error)}") + raise + + except TypeError as type_error: + # Handle unpacking errors which are common with older transformers versions + logger.warning(f"TypeError when creating pipeline: {str(type_error)}") + + # Try alternative approach with manual component loading + logger.info("Trying manual component loading as alternative...") + + try: + from transformers import AutoModelForDepthEstimation, AutoImageProcessor + + # Load components separately + processor = AutoImageProcessor.from_pretrained( + path, cache_dir=cache_dir, local_files_only=False + ) + + model = AutoModelForDepthEstimation.from_pretrained( + path, cache_dir=cache_dir, local_files_only=False, torch_dtype=dtype ) - success = True - logger.info(f"Successfully loaded model from {path}") - break - except Exception as online_error: - logger.warning(f"Online loading failed for {path}: {str(online_error)}") - # Try with local_files_only if online fails + # Move model to correct device + if not force_cpu and 'cuda' in device_type: + model = model.to(self.device) + + # Create custom pipeline class + class CustomDepthEstimator: + def __init__(self, model, processor, device): + self.model = model + self.processor = processor + self.device = device + + def __call__(self, image): + # Process image and run model + inputs = self.processor(images=image, return_tensors="pt") + + # Move inputs to correct device + if torch.cuda.is_available() and not force_cpu: + inputs = {k: v.to(self.device) for k, v in inputs.items()} + + # Run model + with torch.no_grad(): + outputs = self.model(**inputs) + + # Return results in standard format + return {"predicted_depth": outputs.predicted_depth} + + # Create custom pipeline + self.depth_estimator = CustomDepthEstimator(model, processor, self.device) + + # Test the pipeline + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + test_result = self.depth_estimator(test_img) + + # Verify output format + if not isinstance(test_result, dict) or "predicted_depth" not in test_result: + raise RuntimeError("Invalid output format from custom pipeline") + + logger.info(f"Successfully loaded model from {path} with custom pipeline") + pipeline_success = True + break + + except Exception as custom_error: + logger.warning(f"Custom pipeline creation failed: {str(custom_error)}") + raise + + except Exception as online_error: + logger.warning(f"Online loading failed for {path}: {str(online_error)}") + pipeline_error = online_error + + # Try local-only mode if online fails (faster and often works with cached files) + try: + logger.info(f"Trying local-only mode for {path}") + + from transformers import pipeline + + self.depth_estimator = pipeline( + "depth-estimation", + model=path, + cache_dir=cache_dir, + local_files_only=True, # Only use local files + device_map=device_type, + torch_dtype=dtype + ) + + # Verify pipeline + if self.depth_estimator is None: + raise RuntimeError(f"Local pipeline initialization returned None for {path}") + + # Test with small image + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + test_result = self.depth_estimator(test_img) + + logger.info(f"Successfully loaded model from {path} with local-only mode") + pipeline_success = True + break + + except Exception as local_error: + logger.warning(f"Local-only mode failed for {path}: {str(local_error)}") + pipeline_error = local_error + # Continue to next path + + # Step 4: If transformers pipeline failed, try direct model loading + if not pipeline_success: + logger.info("Pipeline loading failed. Trying direct model implementation.") + + # If we have a local model file from previous steps, use it with direct loading + direct_success = False + + if local_model_file: + logger.info(f"Loading model directly from: {local_model_file}") + + # For V2 models, use the DepthAnythingV2 implementation + if model_type == "v2" and TIMM_AVAILABLE: + try: + logger.info(f"Using DepthAnythingV2 implementation with config: {config}") + + # Create model instance with appropriate config + if config: + model_instance = DepthAnythingV2(**config) + else: + # Use default config for this encoder type + default_config = MODEL_CONFIGS.get(encoder, MODEL_CONFIGS["vits"]) + model_instance = DepthAnythingV2(**default_config) + + # Load state dict from file + logger.info(f"Loading weights from {local_model_file}") + state_dict = torch.load(local_model_file, map_location="cpu") + + # Convert state dict to float32 + if any(v.dtype == torch.float64 for v in state_dict.values() if hasattr(v, 'dtype')): + logger.info("Converting state dict from float64 to float32") + state_dict = {k: v.float() if hasattr(v, 'dtype') else v for k, v in state_dict.items()} + + # Try loading with different state dict formats try: - self.depth_estimator = pipeline( - "depth-estimation", - model=path, - cache_dir=cache_dir, - local_files_only=True, # Try local only as fallback - device_map=device_type, - torch_dtype=dtype - ) - success = True - logger.info(f"Successfully loaded model from local cache: {path}") - break - except Exception as local_error: - last_error = local_error - logger.warning(f"Local loading failed for {path}: {str(local_error)}") - continue - - except Exception as path_error: - last_error = path_error - logger.warning(f"Failed to load model from {path}: {str(path_error)}") - continue + if "model" in state_dict: + model_instance.load_state_dict(state_dict["model"]) + else: + model_instance.load_state_dict(state_dict) + except Exception as e: + logger.warning(f"Strict loading failed: {str(e)}. Trying non-strict loading.") + if "model" in state_dict: + model_instance.load_state_dict(state_dict["model"], strict=False) + else: + model_instance.load_state_dict(state_dict, strict=False) + + # Move to correct device and set eval mode + model_instance = model_instance.to(self.device).float().eval() + + # Test the model + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + _ = model_instance(test_img) + + # Success - assign model + self.depth_estimator = model_instance + direct_success = True + logger.info("Successfully loaded model with direct implementation") + + except Exception as v2_error: + logger.warning(f"DepthAnythingV2 loading failed: {str(v2_error)}") + + # If V2 direct loading failed or isn't applicable, try MiDaS wrapper + if not direct_success: + try: + logger.info("Falling back to MiDaS wrapper implementation") + + # Determine MiDaS model type based on model name + midas_type = "DPT_Hybrid" # Default + + if "small" in model_name.lower(): + midas_type = "MiDaS_small" + elif "large" in model_name.lower(): + midas_type = "DPT_Large" + + # Create MiDaS wrapper with model type + midas_model = MiDaSWrapper(midas_type, self.device) + + # Test the model + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + _ = midas_model(test_img) + + # Success - assign model + self.depth_estimator = midas_model + direct_success = True + logger.info("Successfully loaded MiDaS fallback model") + + except Exception as midas_error: + logger.warning(f"MiDaS wrapper loading failed: {str(midas_error)}") - if not success: - # If all attempts failed, show helpful message with instructions - error_msg = f""" - Failed to load model {model_name} after trying multiple sources. - Last error: {str(last_error)} - - Try these solutions: - 1. Run 'huggingface-cli login' in your terminal to authenticate - 2. Check your internet connection - 3. Try a different model version (e.g. Depth-Anything-V2-Small instead of Depth-Anything-Small) - """ - logger.error(error_msg) - raise RuntimeError(error_msg) - - # Ensure model is on the correct device - if hasattr(self.depth_estimator, 'model'): + # Step 5: If all previous attempts failed, try one last MiDaS fallback + if not direct_success and not pipeline_success: + try: + logger.info("All model loading attempts failed. Trying basic MiDaS fallback.") + + # Create simple MiDaS wrapper with default settings + midas_model = MiDaSWrapper("dpt_hybrid", self.device) + + # Test with simple image + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + _ = midas_model(test_img) + + # Assign model + self.depth_estimator = midas_model + logger.info("Successfully loaded basic MiDaS fallback model") + + except Exception as final_error: + # If we get here, all attempts have failed + error_msg = f"All model loading attempts failed for {model_name}. Last error: {str(final_error)}" + logger.error(error_msg) + + # Create a helpful error message with instructions + all_model_dirs = "\n".join(existing_paths) + + # Determine error type for better help message + if pipeline_error: + error_str = str(pipeline_error).lower() + + if "unauthorized" in error_str or "401" in error_str or "authentication" in error_str: + # Authentication error - guide for manual download + error_solution = f""" +AUTHENTICATION ERROR: The model couldn't be downloaded due to Hugging Face authentication requirements. + +SOLUTION: +1. Use force_cpu=True in the node settings +2. Try a different model like MiDaS-Small +3. Download the model manually using one of these direct links: + - https://huggingface.co/depth-anything/Depth-Anything-V2-Small-hf/resolve/main/pytorch_model.bin + - https://github.com/LiheYoung/Depth-Anything/releases/download/v2.0/depth_anything_v2_small.pt + - https://huggingface.co/ckpt/depth-anything-v2/resolve/main/depth_anything_v2_small.pt + + Save the file to one of these directories: + {all_model_dirs} +""" + elif "cuda" in error_str or "gpu" in error_str or "vram" in error_str or "memory" in error_str: + # GPU/memory error + error_solution = """ +GPU ERROR: The model failed to load on your GPU. + +SOLUTION: +1. Use force_cpu=True to use CPU processing instead +2. Reduce input_size parameter to 384 to reduce memory requirements +3. Try a smaller model like MiDaS-Small +4. Ensure you have the latest GPU drivers installed +""" + else: + # Generic error + error_solution = f""" +Failed to load any depth estimation model. + +SOLUTION: +1. Use force_cpu=True to use CPU for processing +2. Try using a different model like MiDaS-Small +3. Download a model file manually and place it in one of these directories: + {all_model_dirs} +4. Restart ComfyUI to ensure clean state +""" + else: + # Generic error when pipeline_error isn't set + error_solution = f""" +Failed to load any depth estimation model. + +SOLUTION: +1. Use force_cpu=True to use CPU for processing +2. Try using a different model like MiDaS-Small +3. Download a model file manually and place it in one of these directories: + {all_model_dirs} +4. Restart ComfyUI to ensure clean state +""" + + # Raise helpful error + raise RuntimeError(f"MODEL LOADING ERROR: {error_solution}") + + # Ensure the model is on the correct device + if hasattr(self.depth_estimator, 'model') and hasattr(self.depth_estimator.model, 'to'): + if force_cpu: + self.depth_estimator.model = self.depth_estimator.model.to('cpu') + else: self.depth_estimator.model = self.depth_estimator.model.to(self.device) - - self.current_model = model_path - + + # Set model to eval mode if applicable + if hasattr(self.depth_estimator, 'eval'): + self.depth_estimator.eval() + elif hasattr(self.depth_estimator, 'model') and hasattr(self.depth_estimator.model, 'eval'): + self.depth_estimator.model.eval() + + # Store current model info + self.current_model = model_path + logger.info(f"Model '{model_name}' loaded successfully") + except Exception as e: + # Clean up on failure self.cleanup() + + # Log detailed error info error_msg = f"Failed to load model {model_name}: {str(e)}" logger.error(error_msg) + logger.error(traceback.format_exc()) + + # Re-raise with clear message raise RuntimeError(error_msg) - def process_image(self, image: Union[torch.Tensor, np.ndarray]) -> Image.Image: + def load_model_direct(self, model_name, model_info, force_cpu=False): """ - Converts input image to proper format for depth estimation. + Directly loads a depth model without using transformers pipeline. + This is a fallback method when the normal pipeline loading fails. + + Args: + model_name: Name of the model to load + model_info: Dictionary with model information + force_cpu: Whether to force CPU usage + + Returns: + A depth estimation model that implements the __call__ interface + """ + try: + logger.info(f"Attempting direct model loading for {model_name}") + + # Determine device + device_type = 'cpu' if force_cpu else ('cuda' if torch.cuda.is_available() else 'cpu') + device = torch.device(device_type) + + # Look in all possible model directories + # This is important to support various directory structures + model_found = False + model_path_local = None + + # Make a unique model cache directory for this specific model + model_subfolder = model_name.replace("-", "_").lower() + + # Check all possible locations for the model file + for base_path in existing_paths: + # Try different possible locations and filename patterns + possible_model_locations = [ + # Direct downloads in the model directory + os.path.join(base_path, model_subfolder), + + # Using the full HF directory structure + os.path.join(base_path, model_info.get("path", "").replace("/", "_")), + + # Directly in base directory + base_path, + ] + + # Add directory structure with model configs if V2 + if model_info.get("model_type") == "v2": + v2_path = os.path.join(base_path, "v2") + possible_model_locations.append(v2_path) + possible_model_locations.append(os.path.join(v2_path, model_subfolder)) + + # Try all locations + logger.info(f"Searching for existing model in these directories: {possible_model_locations}") + + for location in possible_model_locations: + # Check for model file with various naming patterns + if os.path.exists(location): + # Check for common filenames + for filename in ["pytorch_model.bin", "model.pt", "model.pth", + f"{model_subfolder}.pt", f"{model_subfolder}.bin"]: + file_path = os.path.join(location, filename) + if os.path.exists(file_path): + logger.info(f"Found existing model file: {file_path}") + model_path_local = file_path + model_found = True + break + + if model_found: + break + + if model_found: + break + + # If model not found, use the first directory for downloading + cache_dir = os.path.join(existing_paths[0], model_subfolder) + os.makedirs(cache_dir, exist_ok=True) + + # Get model configuration + model_type = model_info.get("model_type", "v1") + encoder = model_info.get("encoder", "vits") + config = model_info.get("config", MODEL_CONFIGS.get(encoder, MODEL_CONFIGS["vits"])) + + # Step 1: If model not found locally, download it + # List of alternative URLs that don't require authentication + alternative_urls = { + "Depth-Anything-V2-Small": [ + "https://huggingface.co/depth-anything/Depth-Anything-V2-Small-hf/resolve/main/pytorch_model.bin", + "https://github.com/LiheYoung/Depth-Anything/releases/download/v2.0/depth_anything_v2_small.pt", + "https://huggingface.co/ckpt/depth-anything-v2/resolve/main/depth_anything_v2_small.pt" + ], + "Depth-Anything-V2-Base": [ + "https://huggingface.co/depth-anything/Depth-Anything-V2-Base-hf/resolve/main/pytorch_model.bin", + "https://github.com/LiheYoung/Depth-Anything/releases/download/v2.0/depth_anything_v2_base.pt", + "https://huggingface.co/ckpt/depth-anything-v2/resolve/main/depth_anything_v2_base.pt" + ], + "MiDaS-Base": [ + "https://github.com/intel-isl/MiDaS/releases/download/v3/dpt_hybrid-midas-501f0c75.pt" + ] + } + + # Get primary URL from model_info + direct_url = model_info.get("direct_url") + + # Add alternative URLs to try if the main one fails + urls_to_try = [direct_url] if direct_url else [] + + # Add alternative URLs for this model if available + if model_name in alternative_urls: + urls_to_try.extend(alternative_urls[model_name]) + + # Try downloading the model if not found locally + if not model_found and urls_to_try: + # Try each URL in sequence until one works + for url in urls_to_try: + if not url: + continue + + try: + model_filename = os.path.basename(url) + model_path_local = os.path.join(cache_dir, model_filename) + + if os.path.exists(model_path_local): + logger.info(f"Model already exists at {model_path_local}") + model_found = True + break + + logger.info(f"Attempting to download model from {url} to {model_path_local}") + + # Create parent directory if needed + os.makedirs(os.path.dirname(model_path_local), exist_ok=True) + + # Try different download methods + download_success = False + + # First try wget (more reliable for large files) + try: + logger.info(f"Downloading with wget: {url}") + wget.download(url, out=model_path_local) + logger.info(f"Downloaded model weights to {model_path_local}") + download_success = True + except Exception as wget_error: + logger.warning(f"wget download failed: {str(wget_error)}") + + # Fallback to requests + try: + logger.info(f"Downloading with requests: {url}") + response = requests.get(url, stream=True) + + if response.status_code == 200: + total_size = int(response.headers.get('content-length', 0)) + logger.info(f"File size: {total_size/1024/1024:.1f} MB") + + with open(model_path_local, 'wb') as f: + downloaded = 0 + for data in response.iter_content(1024 * 1024): # 1MB chunks + f.write(data) + downloaded += len(data) + if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: # Log every 10MB + progress = (downloaded / total_size) * 100 + logger.info(f"Downloaded {downloaded/1024/1024:.1f}MB of {total_size/1024/1024:.1f}MB ({progress:.1f}%)") + + logger.info(f"Download complete: {model_path_local}") + download_success = True + else: + logger.warning(f"Failed to download from {url}: HTTP status {response.status_code}") + except Exception as req_error: + logger.warning(f"Requests download failed: {str(req_error)}") + + # Try urllib as last resort + if not download_success: + try: + logger.info(f"Downloading with urllib: {url}") + urllib.request.urlretrieve(url, model_path_local) + logger.info(f"Downloaded model weights to {model_path_local}") + download_success = True + except Exception as urllib_error: + logger.warning(f"urllib download failed: {str(urllib_error)}") + + # Check if download succeeded + if download_success and os.path.exists(model_path_local) and os.path.getsize(model_path_local) > 0: + logger.info(f"Successfully downloaded model to {model_path_local}") + model_found = True + break + else: + logger.warning(f"Download appeared to succeed but file is empty or missing") + # Try to remove the failed download + if os.path.exists(model_path_local): + try: + os.remove(model_path_local) + except: + pass + + except Exception as dl_error: + logger.warning(f"Error downloading from {url}: {str(dl_error)}") + continue + + if not model_found: + logger.error("All download attempts failed") + + # Step 2: Create and load the appropriate model if found + if model_found and model_path_local and os.path.exists(model_path_local): + logger.info(f"Found model file at: {model_path_local}") + + # Handle V2 models with DepthAnythingV2 implementation + if model_type == "v2" and TIMM_AVAILABLE: + try: + logger.info(f"Loading as DepthAnythingV2 model with config: {config}") + + # Create model with the appropriate configuration + model = DepthAnythingV2(**config) + + # Load weights from checkpoint + logger.info(f"Loading weights from {model_path_local}") + state_dict = torch.load(model_path_local, map_location=device) + + # Convert state dict to float32 if needed + if any(v.dtype == torch.float64 for v in state_dict.values() if hasattr(v, 'dtype')): + logger.info("Converting state dict from float64 to float32") + state_dict = {k: v.float() if hasattr(v, 'dtype') else v for k, v in state_dict.items()} + + # Attempt to load the state dict (handles different formats) + try: + if "model" in state_dict: + model.load_state_dict(state_dict["model"]) + else: + model.load_state_dict(state_dict) + except Exception as e: + logger.warning(f"Error loading state dict: {str(e)}") + logger.warning("Trying to load with strict=False") + if "model" in state_dict: + model.load_state_dict(state_dict["model"], strict=False) + else: + model.load_state_dict(state_dict, strict=False) + + # Move model to the correct device and ensure float32 + model = model.float().to(device) + model.device = device + model.eval() + + # Test the model + logger.info("Testing model with sample image") + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + + try: + _ = model(test_img) + logger.info("DepthAnythingV2 model loaded and tested successfully") + return model + except Exception as test_error: + logger.error(f"Error during model test: {str(test_error)}") + logger.debug(traceback.format_exc()) + except Exception as e: + logger.error(f"Error loading DepthAnythingV2: {str(e)}") + logger.debug(traceback.format_exc()) + + # Fallback to MiDaS model if V2 loading failed or for V1 models + try: + logger.info("Falling back to MiDaS model") + + # Determine the appropriate MiDaS model type + midas_model_type = "dpt_hybrid" + if "large" in model_name.lower(): + midas_model_type = "dpt_large" + elif "small" in model_name.lower(): + midas_model_type = "midas_v21_small" + + # Create and test the MiDaS model + midas_model = MiDaSWrapper(midas_model_type, device) + + # Test with a small image + test_img = Image.new("RGB", (64, 64), color=(128, 128, 128)) + _ = midas_model(test_img) + + logger.info("MiDaS model loaded and tested successfully") + return midas_model + + except Exception as e: + logger.error(f"Error loading MiDaS: {str(e)}") + logger.debug(traceback.format_exc()) + + # If all else fails, return None + return None + + except Exception as e: + logger.error(f"Direct model loading failed: {str(e)}") + logger.debug(traceback.format_exc()) + return None + + def process_image(self, image: Union[torch.Tensor, np.ndarray], input_size: int = 518) -> Image.Image: + """ + Converts input image to proper format for depth estimation and resizes it. Args: image: Input image as tensor or numpy array + input_size: Target size for the longest dimension of the image Returns: PIL Image ready for depth estimation """ - if torch.is_tensor(image): - image_np = (image.cpu().numpy()[0] * 255).astype(np.uint8) - else: - image_np = (image * 255).astype(np.uint8) + try: + # Log input information for debugging + if torch.is_tensor(image): + logger.info(f"Processing tensor image with shape {image.shape}, dtype {image.dtype}") + elif isinstance(image, np.ndarray): + logger.info(f"Processing numpy image with shape {image.shape}, dtype {image.dtype}") + else: + logger.warning(f"Unexpected image type: {type(image)}") + + # Validate and normalize input_size with improved bounds checking + if not isinstance(input_size, (int, float)): + logger.warning(f"Invalid input_size type: {type(input_size)}. Using default 518.") + input_size = 518 + else: + try: + # Convert to int and constrain to valid range + input_size = int(input_size) + except: + logger.warning(f"Error converting input_size to int. Using default 518.") + input_size = 518 + + # Ensure input_size is within valid range + if input_size < 256: + logger.warning(f"Input size {input_size} is too small, using 256 instead") + input_size = 256 + elif input_size > 1024: + logger.warning(f"Input size {input_size} is too large, using 1024 instead") + input_size = 1024 + + # Process tensor input with comprehensive error handling + if torch.is_tensor(image): + try: + # Check tensor dtype and convert to float32 if needed + if image.dtype != torch.float32: + logger.info(f"Converting input tensor from {image.dtype} to torch.float32") + image = image.float() # Convert to FloatTensor for consistency + + # Check for NaN/Inf values in tensor + nan_count = torch.isnan(image).sum().item() + inf_count = torch.isinf(image).sum().item() + + if nan_count > 0 or inf_count > 0: + logger.warning(f"Input tensor contains {nan_count} NaN and {inf_count} Inf values. Replacing with valid values.") + image = torch.nan_to_num(image, nan=0.0, posinf=1.0, neginf=0.0) + + # Handle empty or invalid tensors + if image.numel() == 0: + logger.error("Input tensor is empty (zero elements)") + return Image.new('RGB', (512, 512), (128, 128, 128)) + + # Handle tensor with incorrect number of dimensions + # We need a 3D or 4D tensor to properly extract the image + if image.dim() < 3: + logger.warning(f"Input tensor has too few dimensions: {image.dim()}D. Adding dimensions.") + # Add dimensions until we have at least 3D tensor + while image.dim() < 3: + image = image.unsqueeze(0) + logger.info(f"Adjusted tensor shape to: {image.shape}") + + # Normalize values to [0, 1] range if needed + if image.max() > 1.0 + 1e-5: # Allow small floating point error + min_val, max_val = image.min().item(), image.max().item() + logger.info(f"Input tensor values outside [0,1] range: min={min_val}, max={max_val}. Normalizing.") + + # Common ranges and conversions + if min_val >= 0 and max_val <= 255: + # Assume [0-255] range for images + image = image / 255.0 + else: + # General normalization + image = (image - min_val) / (max_val - min_val) + + # Extract first image from batch if we have a batch dimension + if image.dim() == 4: # [B, C, H, W] + # Use first image in batch + image_for_conversion = image[0] + else: # 3D tensor, assumed to be [C, H, W] + image_for_conversion = image + + # Move to CPU for numpy conversion + image_for_conversion = image_for_conversion.cpu() + + # Convert to numpy, handling different layouts + if image_for_conversion.shape[0] <= 3: # [C, H, W] format with 1-3 channels + # Standard CHW layout - convert to HWC for PIL + image_np = image_for_conversion.permute(1, 2, 0).numpy() + image_np = image_np * 255.0 # Scale to [0, 255] + else: + # Unusual channel count - probably not CHW format + logger.warning(f"Unusual channel count for CHW format: {image_for_conversion.shape[0]}. Using reshape logic.") + + # Try to infer the format and convert appropriately + if image_for_conversion.dim() == 3 and image_for_conversion.shape[-1] <= 3: + # Likely [H, W, C] format, no need to permute + image_np = image_for_conversion.numpy() * 255.0 + else: + # Unknown format - try to reshape intelligently + logger.warning("Unable to determine tensor layout. Using first 3 channels.") + + # Default: take first 3 channels (or fewer if < 3 channels) + channels = min(3, image_for_conversion.shape[0]) + image_np = image_for_conversion[:channels].permute(1, 2, 0).numpy() * 255.0 + + # Ensure we have proper RGB image (3 channels) + if len(image_np.shape) == 2: # Single channel image + image_np = np.stack([image_np] * 3, axis=-1) + elif image_np.shape[-1] == 1: # Single channel in last dimension + image_np = np.concatenate([image_np] * 3, axis=-1) + elif image_np.shape[-1] == 4: # RGBA image - drop alpha channel + image_np = image_np[..., :3] + elif image_np.shape[-1] > 4: # More than 4 channels - use first 3 + logger.warning(f"Image has {image_np.shape[-1]} channels. Using first 3 channels.") + image_np = image_np[..., :3] + + # Ensure proper data type and range + image_np = np.clip(image_np, 0, 255).astype(np.uint8) + + except Exception as tensor_error: + logger.error(f"Error processing tensor image: {str(tensor_error)}") + logger.error(traceback.format_exc()) + # Create a fallback RGB gradient as placeholder + placeholder = np.zeros((512, 512, 3), dtype=np.uint8) + # Add a gradient pattern for visual distinction + for i in range(512): + v = int(i / 512 * 255) + placeholder[i, :, 0] = v # R channel gradient + placeholder[:, i, 1] = v # G channel gradient + return Image.fromarray(placeholder) + + # Process numpy array input with comprehensive error handling + elif isinstance(image, np.ndarray): + try: + # Check for NaN/Inf values in array + if np.isnan(image).any() or np.isinf(image).any(): + logger.warning("Input array contains NaN or Inf values. Replacing with zeros.") + image = np.nan_to_num(image, nan=0.0, posinf=1.0, neginf=0.0) + + # Handle empty or invalid arrays + if image.size == 0: + logger.error("Input array is empty (zero elements)") + return Image.new('RGB', (512, 512), (128, 128, 128)) + + # Convert high-precision types to float32 for consistency + if image.dtype == np.float64 or image.dtype == np.float16: + logger.info(f"Converting numpy array from {image.dtype} to float32") + image = image.astype(np.float32) + + # Normalize values to [0-1] range if float array + if np.issubdtype(image.dtype, np.floating): + # Check current range + min_val, max_val = image.min(), image.max() + + # Normalize if outside [0,1] range + if min_val < 0.0 - 1e-5 or max_val > 1.0 + 1e-5: # Allow small floating point error + logger.info(f"Normalizing array from range [{min_val:.2f}, {max_val:.2f}] to [0, 1]") + + # Common ranges and conversions + if min_val >= 0 and max_val <= 255: + # Assume [0-255] float range + image = image / 255.0 + else: + # General min-max normalization + image = (image - min_val) / (max_val - min_val) + + # Convert normalized float to uint8 for PIL + image_np = (image * 255).astype(np.uint8) + elif np.issubdtype(image.dtype, np.integer): + # For integer types, check if normalization is needed + max_val = image.max() + if max_val > 255: + logger.info(f"Scaling integer array with max value {max_val} to 0-255 range") + # Scale the array to 0-255 range + scaled = (image.astype(np.float32) / max_val) * 255 + image_np = scaled.astype(np.uint8) + else: + # Already in valid range + image_np = image.astype(np.uint8) + else: + # Unsupported dtype - convert through float32 + logger.warning(f"Unsupported dtype: {image.dtype}. Converting through float32.") + image_np = (image.astype(np.float32) * 255).astype(np.uint8) + + # Handle different channel configurations and dimensions + if len(image_np.shape) == 2: + # Grayscale image - convert to RGB + logger.info("Converting grayscale image to RGB") + image_np = np.stack([image_np] * 3, axis=-1) + elif len(image_np.shape) == 3: + # Check channel dimension + if image_np.shape[-1] == 1: + # Single-channel 3D array - convert to RGB + image_np = np.concatenate([image_np] * 3, axis=-1) + elif image_np.shape[-1] == 4: + # RGBA image - drop alpha channel + image_np = image_np[..., :3] + elif image_np.shape[-1] > 4: + # More than 4 channels - use first 3 + logger.warning(f"Image has {image_np.shape[-1]} channels. Using first 3 channels.") + image_np = image_np[..., :3] + elif image_np.shape[-1] < 3: + # Less than 3 channels but not 1 - unusual case + logger.warning(f"Unusual channel count: {image_np.shape[-1]}. Expanding to RGB.") + # Repeat the channels to get 3 + channels = [image_np[..., i % image_np.shape[-1]] for i in range(3)] + image_np = np.stack(channels, axis=-1) + elif len(image_np.shape) > 3: + # More than 3 dimensions - attempt to extract a valid image + logger.warning(f"Array has {len(image_np.shape)} dimensions. Attempting to extract 3D slice.") + + # Try to get a 3D slice with channels in the last dimension + if image_np.shape[-1] <= 3: + # Extract first instance of higher dimensions + while len(image_np.shape) > 3: + image_np = image_np[0] + + # If we have less than 3 channels, expand to RGB + if image_np.shape[-1] < 3: + channels = [image_np[..., i % image_np.shape[-1]] for i in range(3)] + image_np = np.stack(channels, axis=-1) + else: + # Channels not in last dimension - reshape based on assumptions + logger.warning("Could not determine valid layout. Creating placeholder.") + image_np = np.zeros((512, 512, 3), dtype=np.uint8) + # Add gradient for visual distinction + for i in range(512): + v = int(i / 512 * 255) + image_np[i, :, 0] = v + + except Exception as numpy_error: + logger.error(f"Error processing numpy array: {str(numpy_error)}") + logger.error(traceback.format_exc()) + # Create a fallback pattern as placeholder + placeholder = np.zeros((512, 512, 3), dtype=np.uint8) + # Add checkboard pattern + for i in range(0, 512, 32): + for j in range(0, 512, 32): + if (i//32 + j//32) % 2 == 0: + placeholder[i:i+32, j:j+32] = 200 + return Image.fromarray(placeholder) + + # Fallback for non-tensor, non-numpy inputs + else: + logger.error(f"Unsupported image type: {type(image)}") + return Image.new('RGB', (512, 512), (100, 100, 150)) # Distinct color for type errors + + # Convert to PIL image with error handling + try: + pil_image = Image.fromarray(image_np) + except Exception as pil_error: + logger.error(f"Error creating PIL image: {str(pil_error)}") + # Try shape correction if possible + try: + if len(image_np.shape) != 3 or image_np.shape[-1] not in [1, 3, 4]: + logger.warning(f"Invalid array shape for PIL: {image_np.shape}") + # Create valid RGB array as fallback + image_np = np.zeros((512, 512, 3), dtype=np.uint8) + pil_image = Image.fromarray(image_np) + else: + # Other error - use placeholder + pil_image = Image.new('RGB', (512, 512), (128, 128, 128)) + except: + # Ultimate fallback + pil_image = Image.new('RGB', (512, 512), (128, 128, 128)) + + # Resize the image while preserving aspect ratio and ensuring multiple of 32 + # The multiple of 32 constraint helps prevent tensor dimension errors + width, height = pil_image.size + logger.info(f"Original PIL image size: {width}x{height}") + + # Determine which dimension to scale to input_size + if width > height: + new_width = input_size + new_height = int(height * (new_width / width)) + else: + new_height = input_size + new_width = int(width * (new_height / height)) + + # Ensure dimensions are multiples of 32 for better compatibility + new_width = ((new_width + 31) // 32) * 32 + new_height = ((new_height + 31) // 32) * 32 + + # Resize the image with antialiasing + try: + resized_image = pil_image.resize((new_width, new_height), Image.LANCZOS) + logger.info(f"Resized image from {width}x{height} to {new_width}x{new_height}") + + # Verify the resized image + if resized_image.size[0] <= 0 or resized_image.size[1] <= 0: + raise ValueError(f"Invalid resize dimensions: {resized_image.size}") + + return resized_image + except Exception as resize_error: + logger.error(f"Error during image resize: {str(resize_error)}") + + # Try a simpler resize method as fallback + try: + logger.info("Trying fallback resize method") + resized_image = pil_image.resize((new_width, new_height), Image.NEAREST) + return resized_image + except: + # Last resort - return original image or placeholder + if width > 0 and height > 0: + logger.warning("Fallback resize failed. Returning original image.") + return pil_image + else: + logger.warning("Invalid original image. Returning placeholder.") + return Image.new('RGB', (512, 512), (128, 128, 128)) + + except Exception as e: + # Global catch-all handler + logger.error(f"Error processing image: {str(e)}") + logger.error(traceback.format_exc()) + + # Create a visually distinct placeholder image + placeholder = Image.new('RGB', (512, 512), (120, 80, 80)) + + try: + # Add error text to the image for better user feedback + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(placeholder) + + # Try to get a font, fall back to default if needed + try: + font = ImageFont.truetype("arial.ttf", 20) + except: + font = ImageFont.load_default() + + # Add error text + error_text = str(e) + # Limit error text length + if len(error_text) > 60: + error_text = error_text[:57] + "..." + + # Draw error message + draw.text((10, 10), "Image Processing Error", fill=(255, 50, 50), font=font) + draw.text((10, 40), error_text, fill=(255, 255, 255), font=font) + except: + # Error adding text - just return the plain placeholder + pass + + return placeholder + + def _create_error_image(self, input_image=None): + """Create an error image placeholder based on input image if possible.""" + try: + if input_image is not None and isinstance(input_image, torch.Tensor) and input_image.shape[0] > 0: + # Check tensor type - if it's float64, log it for debugging + if input_image.dtype == torch.float64 or input_image.dtype == torch.double: + logger.info(f"Input tensor for error image is {input_image.dtype}, will create float32 error image") + + # Create gray error image with same dimensions as input + # Ensure tensor has the right shape for error display (BHWC) + if input_image.ndim == 4: + if input_image.shape[-1] != 3: # if not BHWC format + if input_image.shape[1] == 3: # if BCHW format + # Extract height and width from BCHW + h, w = input_image.shape[2], input_image.shape[3] + else: + # Default to dimensions from input + h, w = input_image.shape[2], input_image.shape[3] + else: + # Already in BHWC format + h, w = input_image.shape[1], input_image.shape[2] + else: + # Unexpected shape, use default + return self._create_basic_error_image() + + # Make sure dimensions aren't too small + if h <= 1 or w <= 1: + logger.warning(f"Input has invalid dimensions {h}x{w}, using default error image") + return self._create_basic_error_image() + + # Gray background with slight red tint to indicate error - explicitly use float32 + placeholder = torch.ones((1, h, w, 3), dtype=torch.float32) * torch.tensor([0.5, 0.4, 0.4], dtype=torch.float32) + + if self.device is not None: + placeholder = placeholder.to(self.device) + + # Verify the placeholder is float32 + if placeholder.dtype != torch.float32: + logger.warning(f"Error image has unexpected dtype {placeholder.dtype}, converting to float32") + placeholder = placeholder.float() + + return placeholder + else: + return self._create_basic_error_image() + except Exception as e: + logger.error(f"Error creating error image: {str(e)}") + return self._create_basic_error_image() - if len(image_np.shape) == 3: - if image_np.shape[-1] == 4: # Handle RGBA images - image_np = image_np[..., :3] - elif len(image_np.shape) == 2: # Handle grayscale images - image_np = np.stack([image_np] * 3, axis=-1) + def _create_basic_error_image(self): + """Create a basic error image when no input dimensions are available.""" + # Standard size error image (512x512) + h, w = 512, 512 + # Gray background with slight red tint to indicate error - explicitly use float32 + placeholder = torch.ones((1, h, w, 3), dtype=torch.float32) * torch.tensor([0.5, 0.4, 0.4], dtype=torch.float32) - return Image.fromarray(image_np) + if self.device is not None: + placeholder = placeholder.to(self.device) + + # Double-check that we're returning a float32 tensor + if placeholder.dtype != torch.float32: + placeholder = placeholder.float() + + return placeholder + + def _add_error_text_to_image(self, image_tensor, error_text): + """Add error text to the image tensor for visual feedback.""" + try: + # Convert tensor to PIL for text rendering + if image_tensor is None: + return + + temp_img = self._tensor_to_pil(image_tensor) + + # Draw error text + draw = ImageDraw.Draw(temp_img) + + # Try to get a font, fall back to default if needed + try: + font = ImageFont.truetype("arial.ttf", 20) + except: + font = ImageFont.load_default() + + # Split text into multiple lines if too long + lines = [] + words = error_text.split() + current_line = words[0] if words else "Error" + + for word in words[1:]: + if len(current_line + " " + word) < 50: + current_line += " " + word + else: + lines.append(current_line) + current_line = word + + lines.append(current_line) + + # Draw title + draw.text((10, 10), "Depth Estimation Error", fill=(255, 50, 50), font=font) + + # Draw error message + y_position = 40 + for line in lines: + draw.text((10, y_position), line, fill=(255, 255, 255), font=font) + y_position += 25 + + # Convert back to tensor + result = self._pil_to_tensor(temp_img) + + # Copy to original tensor if shapes match + if image_tensor.shape == result.shape: + image_tensor.copy_(result) + return image_tensor + + except Exception as e: + logger.error(f"Error adding text to error image: {e}") + return image_tensor + + def _tensor_to_pil(self, tensor): + """Convert a tensor to PIL Image.""" + if tensor.shape[0] == 1: # Batch size 1 + img_np = (tensor[0].cpu().numpy() * 255).astype(np.uint8) + return Image.fromarray(img_np) + return Image.new('RGB', (512, 512), color=(128, 100, 100)) + + def _pil_to_tensor(self, pil_img): + """Convert PIL Image back to tensor.""" + img_np = np.array(pil_img).astype(np.float32) / 255.0 + tensor = torch.from_numpy(img_np).unsqueeze(0) + + if self.device is not None: + tensor = tensor.to(self.device) + + return tensor def estimate_depth(self, image: torch.Tensor, model_name: str, + input_size: int = 518, blur_radius: float = 2.0, median_size: str = "5", apply_auto_contrast: bool = True, - apply_gamma: bool = True) -> Tuple[torch.Tensor]: + apply_gamma: bool = True, + force_reload: bool = False, + force_cpu: bool = False) -> Tuple[torch.Tensor]: """ Estimates depth from input image with error handling and cleanup. Args: image: Input image tensor model_name: Name of the depth model to use + input_size: Target size for the longest dimension of the image (between 256 and 1024) blur_radius: Gaussian blur radius for smoothing median_size: Size of median filter for noise reduction apply_auto_contrast: Whether to enhance contrast automatically apply_gamma: Whether to apply gamma correction + force_reload: Whether to force reload the model + force_cpu: Whether to force using CPU for inference Returns: Tuple containing depth map tensor - - Raises: - RuntimeError: If depth estimation fails - ValueError: If invalid parameters are provided """ + error_image = None + start_time = time.time() + try: - if median_size not in self.MEDIAN_SIZES: - raise ValueError(f"Invalid median_size. Must be one of {self.MEDIAN_SIZES}") + # Sanity check inputs and log initial info + logger.info(f"Starting depth estimation with model: {model_name}, input_size: {input_size}, force_cpu: {force_cpu}") - self.ensure_model_loaded(model_name) - pil_image = self.process_image(image) + # Enhanced input validation with better error handling + if image is None: + logger.error("Input image is None") + error_image = self._create_basic_error_image() + self._add_error_text_to_image(error_image, "Input image is None") + return (error_image,) - with torch.inference_mode(): - depth_result = self.depth_estimator(pil_image) - depth_map = depth_result["predicted_depth"].squeeze().cpu().numpy() + if image.numel() == 0: + logger.error("Input image is empty (zero elements)") + error_image = self._create_basic_error_image() + self._add_error_text_to_image(error_image, "Input image is empty (zero elements)") + return (error_image,) - # Normalize depth values - depth_min, depth_max = depth_map.min(), depth_map.max() - if depth_max > depth_min: - depth_map = ((depth_map - depth_min) / (depth_max - depth_min) * 255.0) - depth_map = depth_map.astype(np.uint8) + # Log tensor information before processing + logger.info(f"Input tensor shape: {image.shape}, dtype: {image.dtype}, device: {image.device}") - # Create PIL image explicitly with L mode (grayscale) - depth_pil = Image.fromarray(depth_map, mode='L') + # Verify tensor dimensions - support different input formats + if image.ndim != 4: + logger.warning(f"Expected 4D tensor for image, got {image.ndim}D. Attempting to reshape.") + try: + # Try to reshape based on common dimension patterns + if image.ndim == 3: + # Could be [C, H, W] or [H, W, C] format + if image.shape[0] <= 3 and image.shape[0] > 0: # Likely [C, H, W] + image = image.unsqueeze(0) # Add batch dim -> [1, C, H, W] + logger.info(f"Reshaped 3D tensor to 4D with shape: {image.shape}") + elif image.shape[-1] <= 3 and image.shape[-1] > 0: # Likely [H, W, C] + # Permute to [C, H, W] then add batch dim + image = image.permute(2, 0, 1).unsqueeze(0) + logger.info(f"Reshaped HWC tensor to BCHW with shape: {image.shape}") + else: + # Assume single channel image and add missing dimensions + image = image.unsqueeze(0).unsqueeze(0) + logger.info(f"Added batch and channel dimensions to 3D tensor: {image.shape}") + elif image.ndim == 2: + # Assume [H, W] format - add batch and channel dims + image = image.unsqueeze(0).unsqueeze(0) + logger.info(f"Reshaped 2D tensor to 4D with shape: {image.shape}") + elif image.ndim > 4: + # Too many dimensions, collapse extras + orig_shape = image.shape + image = image.reshape(1, orig_shape[1], orig_shape[-2], orig_shape[-1]) + logger.info(f"Collapsed >4D tensor to 4D with shape: {image.shape}") + else: + # Fallback for other unusual dimensions + logger.error(f"Cannot automatically reshape tensor with {image.ndim} dimensions") + error_image = self._create_basic_error_image() + self._add_error_text_to_image(error_image, f"Unsupported tensor dimensions: {image.ndim}D") + return (error_image,) + except Exception as reshape_error: + logger.error(f"Error reshaping tensor: {str(reshape_error)}") + error_image = self._create_basic_error_image() + self._add_error_text_to_image(error_image, f"Error reshaping tensor: {str(reshape_error)[:100]}") + return (error_image,) - # Apply post-processing - if blur_radius > 0: - depth_pil = depth_pil.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + # Comprehensive type checking and conversion - verify at multiple points + # 1. Initial type check and convert if needed + if image.dtype != torch.float32: + logger.info(f"Converting input tensor from {image.dtype} to torch.float32") + + # Safe conversion that handles different input types + try: + if image.dtype == torch.uint8: + # Normalize [0-255] -> [0-1] for uint8 inputs + image = image.float() / 255.0 + else: + # Standard conversion for other types + image = image.float() + except Exception as type_error: + logger.error(f"Error converting tensor type: {str(type_error)}") + error_image = self._create_basic_error_image() + self._add_error_text_to_image(error_image, f"Type conversion error: {str(type_error)[:100]}") + return (error_image,) - if int(median_size) > 0: - depth_pil = depth_pil.filter(ImageFilter.MedianFilter(size=int(median_size))) + # Create error image placeholder based on input dimensions + error_image = self._create_error_image(image) - if apply_auto_contrast: - depth_pil = ImageOps.autocontrast(depth_pil) + # 2. Check for NaN/Inf values and fix them + nan_count = torch.isnan(image).sum().item() + inf_count = torch.isinf(image).sum().item() - if apply_gamma: + if nan_count > 0 or inf_count > 0: + logger.warning(f"Input contains {nan_count} NaN and {inf_count} Inf values. Fixing problematic values.") + image = torch.nan_to_num(image, nan=0.0, posinf=1.0, neginf=0.0) + + # 3. Check value range and normalize if needed + min_val, max_val = image.min().item(), image.max().item() + if max_val > 1.0 + 1e-5: # Allow small floating point error + logger.info(f"Input values outside [0,1] range: min={min_val}, max={max_val}. Normalizing.") + + # Ensure values are in [0,1] range - handle different input formats + if min_val >= 0 and max_val <= 255: + # Likely [0-255] range + image = image / 255.0 + else: + # General min-max normalization + image = (image - min_val) / (max_val - min_val) + + # Parameter validation with safer defaults + # Validate and normalize median_size parameter + median_size_str = str(median_size) # Convert to string regardless of input type + if median_size_str not in self.MEDIAN_SIZES: + logger.warning(f"Invalid median_size: '{median_size}' (type: {type(median_size)}). Using default '5'.") + median_size_str = "5" + + # Validate input_size with stricter bounds + if not isinstance(input_size, (int, float)): + logger.warning(f"Invalid input_size type: {type(input_size)}. Using default 518.") + input_size = 518 + else: + # Convert to int and constrain to valid range + try: + input_size = int(input_size) + input_size = max(256, min(input_size, 1024)) # Clamp between 256 and 1024 + except: + logger.warning(f"Error converting input_size to int. Using default 518.") + input_size = 518 + + # Try loading the model with graceful fallback + try: + self.ensure_model_loaded(model_name, force_reload, force_cpu) + logger.info(f"Model '{model_name}' loaded successfully") + except Exception as model_error: + error_msg = f"Failed to load model '{model_name}': {str(model_error)}" + logger.error(error_msg) + + # Try a more reliable fallback model before giving up + fallback_models = ["MiDaS-Small", "MiDaS-Base"] + for fallback_model in fallback_models: + if fallback_model != model_name: + logger.info(f"Attempting to load fallback model: {fallback_model}") + try: + self.ensure_model_loaded(fallback_model, True, True) # Force reload and CPU for reliability + logger.info(f"Fallback model '{fallback_model}' loaded successfully") + # Update model_name to reflect the fallback + model_name = fallback_model + # Break the loop since we successfully loaded a fallback + break + except Exception as fallback_error: + logger.warning(f"Fallback model '{fallback_model}' also failed: {str(fallback_error)}") + continue + + # If we still don't have a model loaded, return error image + if self.depth_estimator is None: + self._add_error_text_to_image(error_image, f"Model Error: {str(model_error)[:100]}...") + return (error_image,) + + # Process input image with enhanced error recovery + try: + # Convert to PIL with robust error handling + pil_image = self.process_image(image, input_size) + # Store original dimensions for later resizing + original_width, original_height = pil_image.size + logger.info(f"Image processed to size: {pil_image.size} (will preserve these dimensions in output)") + except Exception as img_error: + logger.error(f"Image processing error: {str(img_error)}") + logger.error(traceback.format_exc()) + + # Try a more basic approach if the standard processing fails + try: + logger.info("Attempting basic image conversion as fallback") + # Simple conversion fallback + if image.shape[1] > 3: # BCHW format with unusual channel count + # Select first 3 channels or average if > 3 + logger.warning(f"Unusual channel count: {image.shape[1]}. Using first 3 channels.") + if image.shape[1] > 3: + image = image[:, :3, :, :] + + # Convert to CPU numpy array + img_np = image.squeeze(0).cpu().numpy() + + # Handle different layouts + if img_np.shape[0] <= 3: # [C, H, W] + img_np = np.transpose(img_np, (1, 2, 0)) # -> [H, W, C] + + # Ensure 3 channels + if len(img_np.shape) == 2: # Grayscale + img_np = np.stack([img_np] * 3, axis=-1) + elif img_np.shape[-1] == 1: # Single channel + img_np = np.concatenate([img_np] * 3, axis=-1) + + # Normalize values + if img_np.max() > 1.0: + img_np = img_np / 255.0 + + # Convert to PIL + img_np = (img_np * 255).astype(np.uint8) + pil_image = Image.fromarray(img_np) + + # Store original dimensions + original_width, original_height = pil_image.size + + # Resize to appropriate dimensions + if input_size > 0: + w, h = pil_image.size + # Determine which dimension to scale to input_size + if w > h: + new_w = input_size + new_h = int(h * (new_w / w)) + else: + new_h = input_size + new_w = int(w * (new_h / h)) + + # Ensure dimensions are multiples of 32 + new_h = ((new_h + 31) // 32) * 32 + new_w = ((new_w + 31) // 32) * 32 + + pil_image = pil_image.resize((new_w, new_h), Image.LANCZOS) + + logger.info(f"Fallback image processing succeeded with size: {pil_image.size}") + except Exception as fallback_error: + logger.error(f"Fallback image processing also failed: {str(fallback_error)}") + self._add_error_text_to_image(error_image, f"Image Error: {str(img_error)[:100]}...") + return (error_image,) + + # Depth estimation with comprehensive error handling + try: + # Use inference_mode for better memory usage + with torch.inference_mode(): + logger.info(f"Running inference on image of size {pil_image.size}") + + # Ensure the depth estimator is in eval mode + if hasattr(self.depth_estimator, 'eval'): + self.depth_estimator.eval() + + # Add timing for performance analysis + inference_start = time.time() + + # Perform inference with better error handling + try: + depth_result = self.depth_estimator(pil_image) + inference_time = time.time() - inference_start + logger.info(f"Depth inference completed in {inference_time:.2f} seconds") + except Exception as inference_error: + logger.error(f"Depth estimator inference error: {str(inference_error)}") + + # Detailed error analysis for better debugging + error_str = str(inference_error).lower() + + # Try fallback for common error types + if "cuda" in error_str and "out of memory" in error_str: + logger.warning("CUDA out of memory detected. Attempting CPU fallback.") + # Already on CPU? Check and handle + if force_cpu: + logger.error("Already using CPU but still encountered memory error") + self._add_error_text_to_image(error_image, "Memory error even on CPU. Try smaller input size.") + return (error_image,) + else: + # Try CPU fallback + logger.info("Switching to CPU processing") + return self.estimate_depth( + image.cpu(), model_name, input_size, blur_radius, median_size_str, + apply_auto_contrast, apply_gamma, True, True # Force CPU + ) + + # Type mismatch errors + elif "input type" in error_str and "weight type" in error_str: + logger.warning("Tensor type mismatch detected. Attempting explicit type conversion.") + # Try with explicit CPU conversion + return self.estimate_depth( + image.float().cpu(), model_name, input_size, blur_radius, median_size_str, + apply_auto_contrast, apply_gamma, True, True # Force CPU and reload + ) + + # Dimension mismatch errors + elif "dimensions" in error_str or "dimension" in error_str or "shape" in error_str: + logger.warning("Tensor dimension mismatch detected. Trying alternate approach.") + # Fall back to CPU MiDaS model which has more robust dimension handling + logger.info("Falling back to MiDaS model on CPU") + try: + # Clean up current model + self.cleanup() + # Try to load MiDaS model + self.ensure_model_loaded("MiDaS-Small", True, True) + # Retry with the new model + return self.estimate_depth( + image.cpu(), "MiDaS-Small", input_size, blur_radius, median_size_str, + apply_auto_contrast, apply_gamma, False, True # Already reloaded, force CPU + ) + except Exception as midas_error: + logger.error(f"MiDaS fallback also failed: {str(midas_error)}") + self._add_error_text_to_image(error_image, f"Inference Error: {str(inference_error)[:100]}...") + return (error_image,) + + # Other errors - just return error image + self._add_error_text_to_image(error_image, f"Inference Error: {str(inference_error)[:100]}...") + return (error_image,) + + # Verify depth result and convert to float32 + if not isinstance(depth_result, dict) or "predicted_depth" not in depth_result: + logger.error(f"Invalid depth result format: {type(depth_result)}") + self._add_error_text_to_image(error_image, "Invalid depth result format") + return (error_image,) + + # Extract and validate predicted depth + predicted_depth = depth_result["predicted_depth"] + + # Ensure correct tensor type + if not torch.is_tensor(predicted_depth): + logger.error(f"Predicted depth is not a tensor: {type(predicted_depth)}") + self._add_error_text_to_image(error_image, "Predicted depth is not a tensor") + return (error_image,) + + # Convert to float32 if needed + if predicted_depth.dtype != torch.float32: + logger.info(f"Converting predicted depth from {predicted_depth.dtype} to float32") + predicted_depth = predicted_depth.float() + + # Convert to CPU for post-processing + depth_map = predicted_depth.squeeze().cpu().numpy() + except RuntimeError as rt_error: + # Handle runtime errors separately for clearer error messages + error_msg = str(rt_error) + logger.error(f"Runtime error during depth estimation: {error_msg}") + + # Check for specific error types + if "CUDA out of memory" in error_msg: + logger.warning("CUDA out of memory. Trying CPU fallback.") + + # Only try CPU fallback if not already using CPU + if not force_cpu: + try: + logger.info("Switching to CPU processing") + return self.estimate_depth( + image.cpu(), model_name, input_size, blur_radius, median_size_str, + apply_auto_contrast, apply_gamma, True, True # Force reload and CPU + ) + except Exception as cpu_error: + logger.error(f"CPU fallback failed: {str(cpu_error)}") + + self._add_error_text_to_image(error_image, "CUDA Out of Memory. Try a smaller model or image size.") + else: + # Generic runtime error + self._add_error_text_to_image(error_image, f"Runtime Error: {error_msg[:100]}...") + + return (error_image,) + except Exception as e: + # Handle other exceptions + error_msg = f"Depth estimation failed: {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + self._add_error_text_to_image(error_image, f"Error: {str(e)[:100]}...") + return (error_image,) + + # Validate depth map + # Check for NaN/Inf values + if np.isnan(depth_map).any() or np.isinf(depth_map).any(): + logger.warning("Depth map contains NaN or Inf values. Replacing with zeros.") + depth_map = np.nan_to_num(depth_map, nan=0.0, posinf=1.0, neginf=0.0) + + # Check for empty or invalid depth map + if depth_map.size == 0: + logger.error("Depth map is empty") + self._add_error_text_to_image(error_image, "Empty depth map returned") + return (error_image,) + + # Post-processing with enhanced error handling + try: + # Ensure depth values have reasonable range for normalization + depth_min, depth_max = depth_map.min(), depth_map.max() + + # Handle constant depth maps (avoid division by zero) + if np.isclose(depth_max, depth_min): + logger.warning("Constant depth map detected (min = max). Using values directly.") + # Just use a normalized constant value + depth_map = np.ones_like(depth_map) * 0.5 + else: + # Normalize to [0, 1] range first - safer for later operations + depth_map = (depth_map - depth_min) / (depth_max - depth_min) + + # Scale to [0, 255] for PIL operations + depth_map_uint8 = (depth_map * 255.0).astype(np.uint8) + + # Log the depth map shape coming from the model + logger.info(f"Depth map shape from model: {depth_map_uint8.shape}") + + # Create PIL image explicitly with L mode (grayscale) + try: + depth_pil = Image.fromarray(depth_map_uint8, mode='L') + logger.info(f"Depth PIL image size before resize: {depth_pil.size}") + except Exception as pil_error: + logger.error(f"Error creating PIL image: {str(pil_error)}") + # Try to reshape the array if dimensions are wrong + if len(depth_map_uint8.shape) != 2: + logger.warning(f"Unexpected depth map shape: {depth_map_uint8.shape}. Attempting to fix.") + # Try to extract first channel if multi-channel + if len(depth_map_uint8.shape) > 2: + depth_map_uint8 = depth_map_uint8[..., 0] + elif len(depth_map_uint8.shape) == 1: + # 1D array - try to reshape to 2D + h = int(np.sqrt(depth_map_uint8.size)) + w = depth_map_uint8.size // h + depth_map_uint8 = depth_map_uint8.reshape(h, w) + + depth_pil = Image.fromarray(depth_map_uint8, mode='L') + + # Resize depth map to original dimensions from input image before post-processing + # This is the key fix for the resolution issue + try: + logger.info(f"Resizing depth map to original dimensions: {original_width}x{original_height}") + depth_pil = depth_pil.resize((original_width, original_height), Image.BICUBIC) + logger.info(f"Depth PIL image size after resize: {depth_pil.size}") + except Exception as resize_error: + logger.error(f"Error resizing depth map: {str(resize_error)}") + logger.error(traceback.format_exc()) + + # Apply post-processing with parameter validation + # Apply blur if radius is positive + if blur_radius > 0: + try: + depth_pil = depth_pil.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + except Exception as blur_error: + logger.warning(f"Error applying blur: {str(blur_error)}. Skipping.") + + # Apply median filter if size is valid + try: + median_size_int = int(median_size_str) + if median_size_int > 0: + depth_pil = depth_pil.filter(ImageFilter.MedianFilter(size=median_size_int)) + except Exception as median_error: + logger.warning(f"Error applying median filter: {str(median_error)}. Skipping.") + + # Apply auto contrast if requested + if apply_auto_contrast: + try: + depth_pil = ImageOps.autocontrast(depth_pil) + except Exception as contrast_error: + logger.warning(f"Error applying auto contrast: {str(contrast_error)}. Skipping.") + + # Apply gamma correction if requested + if apply_gamma: + try: + depth_array = np.array(depth_pil).astype(np.float32) / 255.0 + mean_luminance = np.mean(depth_array) + + # Avoid division by zero or negative values + if mean_luminance > 0.001: + # Calculate gamma based on mean luminance for adaptive correction + gamma = np.log(0.5) / np.log(mean_luminance) + + # Clamp gamma to reasonable range to avoid extreme corrections + gamma = max(0.1, min(gamma, 3.0)) + logger.info(f"Applying gamma correction with value: {gamma:.2f}") + + # Apply gamma correction + corrected = np.power(depth_array, 1.0/gamma) * 255.0 + depth_pil = Image.fromarray(corrected.astype(np.uint8), mode='L') + else: + logger.warning(f"Mean luminance too low: {mean_luminance}. Skipping gamma correction.") + except Exception as gamma_error: + logger.warning(f"Error applying gamma correction: {str(gamma_error)}. Skipping.") + + # Convert processed image back to tensor + # Convert to numpy array for tensor conversion depth_array = np.array(depth_pil).astype(np.float32) / 255.0 - mean_luminance = np.mean(depth_array) - if mean_luminance > 0: - gamma = np.log(0.5) / np.log(mean_luminance) - # Use direct numpy operations for gamma correction - corrected = np.power(depth_array, 1.0/gamma) * 255.0 - depth_pil = Image.fromarray(corrected.astype(np.uint8), mode='L') - - # Convert to tensor - explicitly handle as grayscale - depth_array = np.array(depth_pil).astype(np.float32) / 255.0 - - # Make it compatible with ComfyUI by creating a 3-channel image - # Use proper reshaping to avoid dimension issues - h, w = depth_array.shape - depth_rgb = np.stack([depth_array] * 3, axis=-1) # Create proper 3D array with shape (h, w, 3) - - depth_tensor = torch.from_numpy(depth_rgb).unsqueeze(0) - - if self.device is not None: - depth_tensor = depth_tensor.to(self.device) - - return (depth_tensor,) - + + # Final validation checks + # Check for invalid dimensions + h, w = depth_array.shape + if h <= 1 or w <= 1: + logger.error(f"Invalid depth map dimensions: {h}x{w}") + self._add_error_text_to_image(error_image, "Invalid depth map dimensions (too small)") + return (error_image,) + + # Log final dimensions for debugging + logger.info(f"Final depth map dimensions: {h}x{w}") + + # Create RGB depth map by stacking the same grayscale image three times + # Stack to create a 3-channel image compatible with ComfyUI + depth_rgb = np.stack([depth_array] * 3, axis=-1) # Shape becomes (h, w, 3) + + # Convert to tensor and add batch dimension + depth_tensor = torch.from_numpy(depth_rgb).unsqueeze(0).float() # Shape becomes (1, h, w, 3) + + # Optional: move to device if not using CPU + if self.device is not None and not force_cpu: + depth_tensor = depth_tensor.to(self.device) + + # Validate output tensor + if torch.isnan(depth_tensor).any() or torch.isinf(depth_tensor).any(): + logger.warning("Final tensor contains NaN or Inf values. Fixing.") + depth_tensor = torch.nan_to_num(depth_tensor, nan=0.0, posinf=1.0, neginf=0.0) + + # Ensure values are in [0, 1] range + min_val, max_val = depth_tensor.min().item(), depth_tensor.max().item() + if min_val < 0.0 or max_val > 1.0: + logger.warning(f"Depth tensor values outside [0,1] range: min={min_val}, max={max_val}. Normalizing.") + depth_tensor = torch.clamp(depth_tensor, 0.0, 1.0) + + # Log completion info + processing_time = time.time() - start_time + logger.info(f"Depth processing completed in {processing_time:.2f} seconds") + logger.info(f"Output tensor: shape={depth_tensor.shape}, dtype={depth_tensor.dtype}, device={depth_tensor.device}") + + return (depth_tensor,) + + except Exception as post_error: + # Handle post-processing errors + error_msg = f"Error during depth map post-processing: {str(post_error)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + self._add_error_text_to_image(error_image, f"Post-processing Error: {str(post_error)[:100]}...") + return (error_image,) + except Exception as e: + # Global catch-all error handler error_msg = f"Depth estimation failed: {str(e)}" logger.error(error_msg) - raise RuntimeError(error_msg) + logger.error(traceback.format_exc()) + + # Create error image if needed + if error_image is None: + error_image = self._create_basic_error_image() + + self._add_error_text_to_image(error_image, f"Unexpected Error: {str(e)[:100]}...") + return (error_image,) finally: + # Always clean up resources regardless of success or failure torch.cuda.empty_cache() gc.collect() diff --git a/images/depth_map_generator_showcase.jpg b/images/depth_map_generator_showcase.jpg index 668cec0..ffa3cad 100644 Binary files a/images/depth_map_generator_showcase.jpg and b/images/depth_map_generator_showcase.jpg differ diff --git a/pyproject.toml b/pyproject.toml index db62441..beec7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,21 @@ [project] name = "comfyuidepthestimation" -description = "A custom depth estimation node for ComfyUI using transformer models. It integrates depth estimation with automatic gamma correction, contrast adjustment, and edge detection, based on the [a/TransformDepth](https://github.com/Limbicnation/TransformDepth) repository." -version = "1.0.0" +description = "A robust custom depth estimation node for ComfyUI using Depth-Anything models. It integrates depth estimation with configurable post-processing options including blur, median filtering, contrast enhancement, and gamma correction." +version = "1.1.0" license = { file = "LICENSE" } -dependencies = ["transformers==4.12.3", "Pillow==8.4.0", "numpy==1.21.2", "# Add other necessary dependencies without specifying torch again"] +dependencies = [ + "transformers>=4.20.0", + "Pillow>=9.1.0", + "numpy>=1.23.0", + "timm>=0.6.12", + "huggingface-hub>=0.16.0" +] [project.urls] Repository = "https://github.com/Limbicnation/ComfyUIDepthEstimation" # Used by Comfy Registry https://comfyregistry.org [tool.comfy] -PublisherId = "" -DisplayName = "ComfyUIDepthEstimation" -Icon = "" +PublisherId = "Limbicnation" +DisplayName = "Depth Estimation Node" +Icon = "images/depth-estimation-icon.png" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d066971..080e18e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,27 @@ -# Core dependencies matching ComfyUI -torch>=2.0.0 -transformers>=4.28.1 -Pillow>=9.0.0 -numpy>=1.21.2 +# requirements.txt for ComfyUI-DepthEstimation Node +# Note: These are minimum requirements. ComfyUI's environment may provide newer versions. + +# Fix for protobuf errors with transformers +protobuf==3.20.3 + +# Core dependencies +tokenizers>=0.13.3 # Pre-built version compatible with most platforms +transformers>=4.20.0 # Required for Depth Anything models, but ComfyUI may have a specific version + +# Pillow (PIL Fork) - Compatibility with other ComfyUI nodes +Pillow>=9.2.0 # Ensures compatibility with nodes requiring >=9.2.0 + +# NumPy - Using version that properly supports numpy.dtypes +numpy>=1.23.0 # Resolves compatibility issues # Additional dependencies specific to depth estimation node -timm>=0.6.12 # Required for depth estimation models -huggingface-hub>=0.16.0 # For model downloading \ No newline at end of file +timm>=0.6.12 # Required for Depth Anything models +huggingface-hub>=0.16.0 # For model downloading +wget>=3.2 # For reliable model downloading + +# Torch version requirements +# Note: PyTorch dependencies are handled by ComfyUI's core installation +# If you're installing this node directly, ensure torch>=2.0.0 is available + +# Network dependencies +requests>=2.27.0 # For model downloading \ No newline at end of file