diff --git a/__init__.py b/__init__.py index 522bd7d..372dd01 100644 --- a/__init__.py +++ b/__init__.py @@ -31,6 +31,8 @@ from .dao_move import DAOMove from .dao_blur import DAOBlur from .svg_load import SVGLoad from .folder_file_pro import FolderFilePro +from .path_to_image import PathToImage +from .load_image_pro import LoadImagePro # Dictionnaires de mapping NODE_CLASS_MAPPINGS = { @@ -62,6 +64,8 @@ NODE_CLASS_MAPPINGS = { "DAO Blur": DAOBlur, "SVG Load": SVGLoad, "Folder File Pro": FolderFilePro, + "Path To Image": PathToImage, + "Load Image Pro": LoadImagePro, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -93,6 +97,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DAO Blur": "Blur (Gaussian)", "SVG Load": "SVG Load (fichier → SVG_TEXT)", "Folder File Pro": "Folder File Pro (dir → file_path)", + "Path To Image": "Path → Image (+RGBA/Mask/Meta)", + "Load Image Pro": "Load Image Pro (Path/Image → RGB/RGBA/Mask/Upscale)", } WEB_DIRECTORY = "./web" diff --git a/load_image_pro.py b/load_image_pro.py new file mode 100644 index 0000000..f147e1a --- /dev/null +++ b/load_image_pro.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# ComfyUI_DAO_master / load_image_pro.py +# +# Merged version with stable mask tools and functional model upscaling. +# FIX 2: Corrected absolute/relative path handling logic, which caused FileNotFoundError. + +import os +from typing import Optional, Tuple, List + +import numpy as np +import cv2 +from PIL import Image +import torch + +# --- ComfyUI imports (with fallbacks for standalone analysis) --- +try: + from folder_paths import get_full_path, get_filename_list + import comfy.utils +except ImportError: + # Dummy classes for when running outside ComfyUI + class MockFolderPaths: + def get_full_path(self, dir_type, filename): return filename + def get_filename_list(self, dir_type): return ["(no models found)"] + folder_paths = MockFolderPaths() + get_full_path = folder_paths.get_full_path + get_filename_list = folder_paths.get_filename_list + + +# ---------- conversions tensor/np (from v1 - stable) ---------- + +def _img_tensor_to_uint8(img: torch.Tensor) -> np.ndarray: + if img is None: return None + if not isinstance(img, torch.Tensor) or img.ndim != 4: raise ValueError("IMAGE tensor must be [B,H,W,C] float32 0..1") + arr = img[0].detach().cpu().numpy() + return (np.clip(arr, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) + +def _img_uint8_to_tensor(arr: np.ndarray) -> torch.Tensor: + f = (arr.astype(np.float32) / 255.0)[None, ...] + return torch.from_numpy(f) + +def _mask_tensor_to_float(mask: torch.Tensor, size_hw: Optional[Tuple[int, int]] = None) -> np.ndarray: + if mask is None: return None + m = mask.detach().cpu().numpy() + if m.ndim == 3: m = m[0] + m = m.astype(np.float32) + if size_hw is not None and (m.shape[0], m.shape[1]) != size_hw: + m = cv2.resize(m, (size_hw[1], size_hw[0]), interpolation=cv2.INTER_LINEAR) + return np.clip(m, 0.0, 1.0) + +def _mask_float_to_tensor(m: np.ndarray) -> torch.Tensor: + m = np.clip(m.astype(np.float32), 0.0, 1.0) + return torch.from_numpy(m)[None, ...] + +def _read_rgba_from_path(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]: + with Image.open(path) as im: + im.load() + if im.mode in ("RGBA", "LA"): + im = im.convert("RGBA") + rgba = np.array(im, dtype=np.uint8) + return rgba[..., :3], rgba[..., 3].astype(np.float32) / 255.0 + im = im.convert("RGB") + return np.array(im, dtype=np.uint8), None + +# ----------------------- Outils de masque (from v1 - stable) ---------------------------- + +def _gaussian_blur_mask(m: np.ndarray, sigma_px: float) -> np.ndarray: + if sigma_px <= 0: return m + return cv2.GaussianBlur(m, (0, 0), sigmaX=float(sigma_px), sigmaY=float(sigma_px)) + +def _offset_mask_bin(bin8: np.ndarray, pixels: int) -> np.ndarray: + if pixels == 0: return bin8 + steps = max(1, min(512, abs(int(pixels)))) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + return cv2.dilate(bin8, kernel, iterations=steps) if pixels > 0 else cv2.erode(bin8, kernel, iterations=steps) + +def _smooth_mask_bin(bin8: np.ndarray, strength_px: float) -> np.ndarray: + if strength_px <= 0: return bin8 + r = int(max(1, min(128, round(strength_px)))) + k = max(3, 2 * r + 1) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + sm = cv2.morphologyEx(bin8, cv2.MORPH_OPEN, kernel) + return cv2.morphologyEx(sm, cv2.MORPH_CLOSE, kernel) + +def _fill_holes_bin(bin8: np.ndarray) -> np.ndarray: + h, w = bin8.shape[:2] + flood = bin8.copy() + mask_ff = np.zeros((h + 2, w + 2), np.uint8) + cv2.floodFill(flood, mask_ff, (0, 0), 255) + return cv2.bitwise_or(bin8, cv2.bitwise_not(flood)) + +# ------------------------------ Upscale (from v2, fixed) -------------------------------------- + +def _opencv_upscale(img: np.ndarray, factor: float, method: str) -> np.ndarray: + if factor <= 0 or abs(factor - 1.0) < 1e-6: return img + h, w = img.shape[:2] + nh, nw = int(round(h * factor)), int(round(w * factor)) + interp_methods = { + "nearest-exact": cv2.INTER_NEAREST_EXACT if hasattr(cv, "INTER_NEAREST_EXACT") else cv2.INTER_NEAREST, + "bilinear": cv2.INTER_LINEAR, "area": cv2.INTER_AREA, "lanczos": cv2.INTER_LANCZOS4, + } + return cv2.resize(img, (nw, nh), interpolation=interp_methods.get(method, cv2.INTER_LANCZOS4)) + +def _upscale_with_model(rgb_u8: np.ndarray, factor: float, model_name: str) -> Optional[np.ndarray]: + try: + from comfy_extras.nodes_upscale_model import ImageUpscaleWithModel, UpscaleModelLoader + except Exception as e: + print(f"[Load Image Pro] Upscale model nodes not available: {e}") + return None + + print(f"[Load Image Pro] Attempting to upscale with model: {model_name}") + try: + loader, upscaler = UpscaleModelLoader(), ImageUpscaleWithModel() + upscale_model = loader.load_model(model_name)[0] + img_tensor = _img_uint8_to_tensor(rgb_u8) + upscaled_tensor = upscaler.upscale(upscale_model, img_tensor)[0] + out_np = _img_tensor_to_uint8(upscaled_tensor) + + h, w = rgb_u8.shape[:2] + target_h, target_w = int(round(h * factor)), int(round(w * factor)) + if out_np.shape[0] != target_h or out_np.shape[1] != target_w: + print(f"[Load Image Pro] Model output size {out_np.shape[:2]} differs from target {(target_h, target_w)}. Resizing...") + out_np = cv2.resize(out_np, (target_w, target_h), interpolation=cv2.INTER_AREA) + + print(f"[Load Image Pro] Upscale with model successful.") + return out_np + except Exception as e: + import traceback + print(f"[Load Image Pro] Upscaling with model '{model_name}' FAILED. Falling back to OpenCV.") + print(traceback.format_exc()) + return None + +# --------------------------------- Node -------------------------------------- + +class LoadImagePro: + @classmethod + def INPUT_TYPES(cls): + try: + models = get_filename_list("upscale_models") + if not models: models = ["(no models found)"] + except Exception: models = ["(no models found)"] + + return { + "required": { + "path": ("STRING", {"multiline": False, "default": ""}), + "enable_mask_tools": ("BOOLEAN", {"default": False}), "mask_blur": ("INT", {"default": 0, "min": 0, "max": 256}), + "mask_offset": ("INT", {"default": 0, "min": -256, "max": 256}), "smooth": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.5}), + "fill_holes": ("BOOLEAN", {"default": False}), "invert_mask": ("BOOLEAN", {"default": False}), + "enable_upscale": ("BOOLEAN", {"default": False}), "upscale_factor": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 8.0, "step": 0.05}), + "upscale_model": (models,), "upscale_method": (["lanczos", "nearest-exact", "bilinear", "area"], {"default": "lanczos"}), + }, "optional": {"image": ("IMAGE",), "mask": ("MASK",),}, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "IMAGE", "INT", "INT") + RETURN_NAMES = ("image", "image_rgba", "mask", "mask_image", "width", "height") + FUNCTION = "run" + CATEGORY = "DAO_master" + + def _load_from_path(self, path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]: + if not path: raise ValueError("Path is empty.") + + # --- CRITICAL FIX for path handling --- + if os.path.isabs(path): + full_path = path + else: + full_path = get_full_path("input", path) + + if not full_path or not os.path.isfile(full_path): + raise FileNotFoundError(f"Image not found at path: {path} (resolved to: {full_path})") + + return _read_rgba_from_path(full_path) + + def _compose_rgba(self, rgb: np.ndarray, alpha_f: Optional[np.ndarray]) -> np.ndarray: + H, W = rgb.shape[:2] + if alpha_f is not None and alpha_f.shape[:2] != (H, W): + alpha_f = cv2.resize(alpha_f, (W, H), interpolation=cv2.INTER_LINEAR) + if alpha_f is None: alpha_f = np.ones((H, W), dtype=np.float32) + a8 = (np.clip(alpha_f * 255.0, 0, 255) + 0.5).astype(np.uint8) + return np.dstack((rgb, a8)) + + def _apply_mask_tools(self, base_mask_f: np.ndarray, mask_blur: int, mask_offset: int, smooth: float, fill_holes: bool, invert_mask: bool) -> np.ndarray: + m = np.clip(base_mask_f, 0.0, 1.0) + bin8 = (m >= 0.5).astype(np.uint8) * 255 + if mask_offset != 0: bin8 = _offset_mask_bin(bin8, mask_offset) + if fill_holes: bin8 = _fill_holes_bin(bin8) + if smooth > 0: bin8 = _smooth_mask_bin(bin8, smooth) + m = bin8.astype(np.float32) / 255.0 + if mask_blur > 0: m = _gaussian_blur_mask(m, float(mask_blur)) + if invert_mask: m = 1.0 - m + return np.clip(m, 0.0, 1.0) + + def run(self, path: str = "", image=None, mask=None, **kwargs): + if image is None and not path: raise ValueError("An 'image' input or a 'path' is required.") + rgb_u8, alpha_from_png = self._load_from_path(path) if image is None else (_img_tensor_to_uint8(image), None) + H, W = rgb_u8.shape[:2] + + mask_f = _mask_tensor_to_float(mask, (H, W)) if mask is not None else alpha_from_png + + if kwargs.get('enable_mask_tools', False): + base_mask = mask_f if mask_f is not None else np.ones((H, W), dtype=np.float32) + mask_f = self._apply_mask_tools(base_mask, **{k: v for k, v in kwargs.items() if k in ['mask_blur', 'mask_offset', 'smooth', 'fill_holes', 'invert_mask']}) + + if kwargs.get('enable_upscale', False) and abs(kwargs.get('upscale_factor', 1.0) - 1.0) > 1e-6: + factor, model, method = kwargs['upscale_factor'], kwargs['upscale_model'], kwargs['upscale_method'] + upscaled = _upscale_with_model(rgb_u8, factor, model) if model and "(no models" not in model else None + rgb_u8 = upscaled if upscaled is not None else _opencv_upscale(rgb_u8, factor, method) + if mask_f is not None: + mask_f = cv2.resize(mask_f, (rgb_u8.shape[1], rgb_u8.shape[0]), interpolation=cv2.INTER_LINEAR) + + alpha_for_rgba = mask_f if mask_f is not None else alpha_from_png + final_mask_f = mask_f if mask_f is not None else np.ones(rgb_u8.shape[:2], dtype=np.float32) + + mask_u8_3ch = np.repeat((np.clip(final_mask_f * 255.0, 0, 255).astype(np.uint8))[..., None], 3, axis=2) + h, w = rgb_u8.shape[:2] + + return (_img_uint8_to_tensor(rgb_u8), _img_uint8_to_tensor(self._compose_rgba(rgb_u8, alpha_for_rgba)), + _mask_float_to_tensor(final_mask_f), _img_uint8_to_tensor(mask_u8_3ch), w, h) + +NODE_CLASS_MAPPINGS = { "Load Image Pro": LoadImagePro } +NODE_DISPLAY_NAME_MAPPINGS = { "Load Image Pro": "Load Image Pro (Path/Image → RGB/RGBA/Mask)" } \ No newline at end of file diff --git a/path_to_image.py b/path_to_image.py new file mode 100644 index 0000000..4de80fb --- /dev/null +++ b/path_to_image.py @@ -0,0 +1,123 @@ +# ComfyUI_DAO_master/path_to_image.py +# -*- coding: utf-8 -*- +import os, json +from typing import Tuple +from PIL import Image, PngImagePlugin, ExifTags +import numpy as np +import torch + +def _to_image_tensor(arr: np.ndarray) -> torch.Tensor: + """ + arr: H x W x C (uint8 or float) -> 1 x H x W x C (float32 0..1) + """ + if arr.dtype != np.float32: + arr = arr.astype(np.float32) / 255.0 + if arr.ndim != 3: + raise ValueError("Expected HxWxC array for IMAGE") + return torch.from_numpy(np.ascontiguousarray(arr)).unsqueeze(0) + +def _to_mask_tensor(alpha: np.ndarray | None, hw: Tuple[int,int]) -> torch.Tensor: + """ + alpha: H x W (0..255 or 0..1) or None -> 1 x H x W (float32 0..1) + """ + if alpha is None: + h, w = hw + return torch.ones((1, h, w), dtype=torch.float32) + if alpha.dtype != np.float32: + alpha = alpha.astype(np.float32) / 255.0 + alpha = np.clip(alpha, 0.0, 1.0) + return torch.from_numpy(np.ascontiguousarray(alpha)).unsqueeze(0) + +def _mask_to_image(mask: torch.Tensor) -> torch.Tensor: + """ + mask: 1 x H x W -> 1 x H x W x 3 + """ + return mask.unsqueeze(-1).repeat(1, 1, 1, 3) + +def _read_png_text(img: Image.Image) -> dict: + out = {} + if hasattr(img, "text") and isinstance(img.text, dict): + for k, v in img.text.items(): + out[k] = v if isinstance(v, str) else str(v) + if hasattr(img, "info") and isinstance(img.info, dict): + for k, v in img.info.items(): + if k not in out: + out[k] = v if isinstance(v, str) else (v.decode("utf-8", "ignore") if isinstance(v, bytes) else str(v)) + return out + +def _read_exif_text(img: Image.Image) -> str: + try: + exif = img.getexif() + if not exif: + return "" + rev = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()} + pairs = [] + for k, v in rev.items(): + if isinstance(v, (str, int, float)): + pairs.append(f"{k}={v}") + return "\n".join(pairs) + except Exception: + return "" + +class PathToImage: + """ + Path → Image (RGB & RGBA / Mask / Meta) + Aucune preview intégrée (on évite les erreurs de dtype). + """ + CATEGORY = "DAO_master/IO" + RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "IMAGE", "STRING", "STRING", "INT", "INT") + RETURN_NAMES = ("image", "image_rgba", "mask", "mask_image", "json", "metadata", "width", "height") + FUNCTION = "load" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "path": ("STRING", {"multiline": False, "placeholder": r"D:\images\foo.png"}), + } + } + + def load(self, path: str): + p = os.path.expanduser(str(path or "")).strip().strip('"') + if not p or not os.path.isfile(p): + # Valeurs neutres pour éviter tout plantage + rgb = _to_image_tensor(np.zeros((1, 1, 3), dtype=np.uint8)) + rgba = _to_image_tensor(np.zeros((1, 1, 4), dtype=np.uint8)) + mask = _to_mask_tensor(None, (1, 1)) + mask_img = _mask_to_image(mask) + return (rgb, rgba, mask, mask_img, "", "", 1, 1) + + with Image.open(p) as im: + im_rgba = im.convert("RGBA") + w, h = im_rgba.size + arr_rgba = np.array(im_rgba, dtype=np.uint8) # H x W x 4 + + arr_rgb = arr_rgba[:, :, :3] + alpha_np = arr_rgba[:, :, 3] if arr_rgba.shape[2] == 4 else None + + t_rgba = _to_image_tensor(arr_rgba) # 1 x H x W x 4 + t_rgb = _to_image_tensor(arr_rgb) # 1 x H x W x 3 + t_mask = _to_mask_tensor(alpha_np, (h, w)) # 1 x H x W + t_mask_img = _mask_to_image(t_mask) # 1 x H x W x 3 + + json_txt = "" + meta_txt = "" + try: + with Image.open(p) as im2: + info = _read_png_text(im2) if isinstance(im2, PngImagePlugin.PngImageFile) else {} + for k in ("workflow", "json"): + if k in info and isinstance(info[k], str): + json_txt = info[k] + break + for k in ("parameters", "Description", "comment"): + if k in info and isinstance(info[k], str): + meta_txt = info[k] + break + if not meta_txt: + exif_text = _read_exif_text(im2) + if exif_text: + meta_txt = exif_text + except Exception: + pass + + return (t_rgb, t_rgba, t_mask, t_mask_img, json_txt, meta_txt, w, h)