From f2d1501a9d393d649faa47e734b0c290eae679c1 Mon Sep 17 00:00:00 2001 From: tj_comfyui Date: Sat, 8 Nov 2025 13:20:29 +0900 Subject: [PATCH] Add stroke/fill override options to SVG To Image node --- README.md | 100 +++++++++++++++++++- __init__.py | 7 +- python_info.py | 57 ++++++++++++ requirements.txt | 4 - svg_to_raster.py | 235 +++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 354 insertions(+), 49 deletions(-) create mode 100644 python_info.py diff --git a/README.md b/README.md index 83b59e2..8156bba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ ラインアート画像から中心線を抽出し、SVGパスとして出力するComfyUIカスタムノード -## ノード +## 機能概要 + +ラインアート画像 → 線領域検出 → 中心線抽出 → SVGパス出力 → ラスター画像化までの完全なベクトル変換パイプラインを提供します。 + +## ノード一覧 ### 1. Line Region Detector ラインアート画像から線領域を検出 @@ -33,14 +37,102 @@ - `centerline_image`: 中心線プレビュー - `statistics`: 統計情報 (JSON) +### 3. Mask Line Cleanup +マスク内の重複線や近接線を除去・統合 + +**入力:** +- `mask`: 線マスク +- `mode`: クリーンアップモード (merge_close_lines/remove_duplicates/thin_only/distance_based) + +**出力:** +- `cleaned_mask`: クリーンアップ後のマスク + +### 4. SVG Path Cleanup +SVGパスの後処理(重複削除・簡略化・近接頂点マージ) + +**入力:** +- `svg_string`: SVGパス文字列 +- `min_path_length`: 最小パス長(これより短いパスを削除) +- `simplify_tolerance`: 簡略化許容誤差 +- `near_duplicate_distance`: 近接重複判定距離 + +**出力:** +- `cleaned_svg`: クリーンアップ後のSVG +- `cleanup_stats`: 削除統計 (JSON) + +### 5. SVG File Saver / SVG Batch Saver +SVGファイルとメタデータJSONを保存 + +**入力:** +- `svg_string`: 保存するSVG +- `output_dir`: 出力ディレクトリ +- `filename_prefix`: ファイル名プレフィックス + +**出力:** +- `saved_path`: 保存先パス + +### 6. SVG To Image +SVG文字列をラスター画像(PNG)に変換 + +**入力:** +- `svg_string`: SVG文字列 +- `width` / `height`: 出力サイズ (0=自動) +- `scale`: スケール係数 +- `background`: 背景色モード (transparent/white/black/custom) +- `dpi`: レンダリングDPI + +**出力:** +- `image`: ラスター画像 (ComfyUI IMAGE tensor) +- `meta`: レンダリング情報 (JSON) + +### 7. Python Runtime Info (診断用) +ComfyUIのPython実行環境とパッケージ情報を出力 + +**出力:** +- `info`: 環境情報 (JSON形式) + ## インストール +### 基本インストール 1. このフォルダを `ComfyUI/custom_nodes/` にコピー -2. 依存関係をインストール: -```bash -pip install -r requirements.txt +2. ComfyUIを再起動(通常は自動的に依存関係がインストールされます) + +すべての依存関係は標準的なライブラリのみで、追加セットアップ不要です。 + +### SVG To Image ノードについて + +`SVG To Image` ノードは**基本的なSVGパスレンダリング**を提供します: +- ✅ **追加依存不要**: Pillow単体で動作(既にComfyUIに含まれています) +- ✅ **Windows完全対応**: ネイティブDLL不要 +- ⚠️ **制限事項**: 複雑なSVG機能(グラデーション、フィルター、高度なパス)は未サポート + +**サポート範囲:** +- 基本パスコマンド(M, L, H, V, C, Z) +- stroke/fill 色指定 +- 単純なライン・ポリゴン描画 + +**高品質レンダリングが必要な場合:** +- `SVG File Saver` で保存後、Inkscape/Illustrator/ブラウザで画像化を推奨 +- Linux/macOS環境ではCairoSVGなど高機能レンダラーの利用も検討可 + +### 手動インストール(自動インストールが動作しない場合) + +#### 依存パッケージの手動インストール + +自動インストールが動作しない場合、以下を実行: + +1. ComfyUI内で `Python Runtime Info` ノードを実行し、`sys_executable` のパスを確認 +2. そのPythonパスを使ってインストール: + +```powershell +# PowerShellの例(パスは環境に合わせて変更) +& "C:\path\to\ComfyUI\venv\Scripts\python.exe" -m pip install -r requirements.txt ``` +**トラブルシュート:** +- `アクセスが拒否されました` エラー → ComfyUIを一時停止してから実行 +- 複数Python環境がある場合 → 必ずComfyUIが使用している実行ファイルで `pip install` すること + ## 使い方 1. ComfyUIを起動 diff --git a/__init__.py b/__init__.py index 2a9fdbb..5fd4594 100644 --- a/__init__.py +++ b/__init__.py @@ -9,6 +9,7 @@ from .svg_file_saver import NODE_CLASS_MAPPINGS as SAVER_NODES, NODE_DISPLAY_NAM from .mask_cleanup import NODE_CLASS_MAPPINGS as CLEANUP_NODES, NODE_DISPLAY_NAME_MAPPINGS as CLEANUP_NAMES from .svg_path_cleanup import NODE_CLASS_MAPPINGS as SVG_CLEANUP_NODES, NODE_DISPLAY_NAME_MAPPINGS as SVG_CLEANUP_NAMES from .svg_to_raster import NODE_CLASS_MAPPINGS as SVG_TO_IMAGE_NODES, NODE_DISPLAY_NAME_MAPPINGS as SVG_TO_IMAGE_NAMES +from .python_info import NODE_CLASS_MAPPINGS as PY_INFO_NODES, NODE_DISPLAY_NAME_MAPPINGS as PY_INFO_NAMES # Combine all node mappings NODE_CLASS_MAPPINGS = { @@ -17,7 +18,8 @@ NODE_CLASS_MAPPINGS = { **SAVER_NODES, **CLEANUP_NODES, **SVG_CLEANUP_NODES, - **SVG_TO_IMAGE_NODES + **SVG_TO_IMAGE_NODES, + **PY_INFO_NODES } NODE_DISPLAY_NAME_MAPPINGS = { @@ -26,7 +28,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { **SAVER_NAMES, **CLEANUP_NAMES, **SVG_CLEANUP_NAMES, - **SVG_TO_IMAGE_NAMES + **SVG_TO_IMAGE_NAMES, + **PY_INFO_NAMES } __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/python_info.py b/python_info.py new file mode 100644 index 0000000..fa54f53 --- /dev/null +++ b/python_info.py @@ -0,0 +1,57 @@ +""" +Diagnostic node to report Python runtime information inside ComfyUI. +Helps determine which interpreter is executing the custom nodes so that +dependencies (e.g. cairosvg) can be installed in the correct environment. +""" + +import sys +import json +import importlib.util +import platform +from typing import Dict, Any + +try: + import site # type: ignore +except Exception: + site = None # type: ignore + + +class VectorPythonInfo: + """ComfyUI node: outputs JSON string with interpreter diagnostics.""" + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("info",) + FUNCTION = "inspect" + CATEGORY = "TJ_Vector" + + def inspect(self) -> tuple: + info: Dict[str, Any] = {} + info["sys_executable"] = sys.executable + info["python_version"] = sys.version + info["platform"] = platform.platform() + info["implementation"] = platform.python_implementation() + if site: + try: + info["site_packages"] = site.getsitepackages() + except Exception: + info["site_packages"] = [] + # Selected packages presence + for pkg in ["cairosvg", "cairocffi", "tinycss2", "cssselect2", "PIL", "skimage", "cv2"]: + spec = importlib.util.find_spec(pkg) + info[f"has_{pkg}"] = bool(spec) + if spec and spec.origin: + info[f"{pkg}_location"] = spec.origin + return (json.dumps(info, indent=2),) + + +NODE_CLASS_MAPPINGS = { + "VectorPythonInfo": VectorPythonInfo +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "VectorPythonInfo": "Python Runtime Info" +} diff --git a/requirements.txt b/requirements.txt index 4770de9..3ab7817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,3 @@ scikit-image>=0.21.0 scipy>=1.11.0 Pillow>=10.0.0 torch>=2.0.0 -cssselect2>=0.7.0 -tinycss2>=1.2.1 -cairocffi>=1.7.0 -cairosvg>=2.7.1 diff --git a/svg_to_raster.py b/svg_to_raster.py index ff10c6f..65b2e65 100644 --- a/svg_to_raster.py +++ b/svg_to_raster.py @@ -1,21 +1,18 @@ """ SVG To Raster Node for ComfyUI -Converts SVG string to ComfyUI IMAGE tensor using CairoSVG +Converts SVG string to ComfyUI IMAGE tensor using Pillow (fallback renderer) +Note: Uses basic SVG path rendering; complex features may not render perfectly. """ import io import json -from typing import Tuple, Optional +import re +from typing import Tuple, Optional, List import numpy as np import torch -from PIL import Image, ImageOps +from PIL import Image, ImageOps, ImageDraw from xml.etree import ElementTree as ET -try: - import cairosvg # type: ignore -except Exception as e: - cairosvg = None - class SVGToImage: """ @@ -36,6 +33,9 @@ class SVGToImage: "padding": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}), }, "optional": { + "override_stroke_color": ("STRING", {"default": "", "tooltip": "Override all stroke colors (hex: #RRGGBB or empty=use original)"}), + "override_stroke_width": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 50.0, "step": 0.1, "tooltip": "Override stroke width (0=use original)"}), + "override_fill_color": ("STRING", {"default": "", "tooltip": "Override all fill colors (hex: #RRGGBB or empty=use original)"}), "antialias": ("BOOLEAN", {"default": True}), "clip_to_viewbox": ("BOOLEAN", {"default": True}), }, @@ -48,65 +48,222 @@ class SVGToImage: def render(self, svg_string: str, width: int = 0, height: int = 0, scale: float = 1.0, background: str = "transparent", background_color: str = "#00000000", - dpi: int = 96, padding: int = 0, antialias: bool = True, clip_to_viewbox: bool = True): - # Lazy import check - if cairosvg is None: - raise RuntimeError("cairosvg is not installed. Please install cairosvg and cairocffi.") - + dpi: int = 96, padding: int = 0, + override_stroke_color: str = "", override_stroke_width: float = 0.0, override_fill_color: str = "", + antialias: bool = True, clip_to_viewbox: bool = True): # Compute base size from SVG base_w, base_h = self._infer_svg_size(svg_string) # Resolve target size tgt_w, tgt_h = self._resolve_size(base_w, base_h, width, height, scale) - # Background handling - bg_arg: Optional[str] = None - if background == "white": - bg_arg = "#FFFFFFFF" - elif background == "black": - bg_arg = "#000000FF" - elif background == "custom": - bg_arg = self._normalize_color(background_color) - else: - # transparent - bg_arg = None - - # Render with CairoSVG to PNG bytes - png_bytes = cairosvg.svg2png( - bytestring=svg_string.encode("utf-8"), - output_width=int(tgt_w), - output_height=int(tgt_h), - dpi=dpi, - background_color=bg_arg, - unsafe=True # allow filters/fonts; assumes trusted input in workflow - ) - - # Load into PIL - img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + # Render SVG using basic PIL-based path renderer + try: + img = self._render_svg_basic(svg_string, int(tgt_w), int(tgt_h), background, background_color, + override_stroke_color, override_stroke_width, override_fill_color) + except Exception as e: + # Fallback: create error placeholder + img = Image.new("RGBA", (int(tgt_w), int(tgt_h)), (255, 0, 0, 128)) + draw = ImageDraw.Draw(img) + draw.text((10, 10), f"SVG render error:\n{str(e)}", fill=(255, 255, 255, 255)) + print(f"SVG rendering error: {e}") # Optional padding if padding > 0: - pad_color = (0, 0, 0, 0) if background == "transparent" else self._parse_rgba(bg_arg or "#00000000") + if background == "transparent": + pad_color = (0, 0, 0, 0) + elif background == "white": + pad_color = (255, 255, 255, 255) + elif background == "black": + pad_color = (0, 0, 0, 255) + else: + pad_color = self._parse_rgba(self._normalize_color(background_color)) img = ImageOps.expand(img, border=padding, fill=pad_color) # Convert to tensor IMAGE (B,H,W,C) in 0..1 arr = np.array(img).astype(np.float32) / 255.0 tensor = torch.from_numpy(arr).unsqueeze(0) + bg_str = background if background != "custom" else background_color meta = { "final_size": [int(img.width), int(img.height)], "base_size": [int(base_w), int(base_h)], "requested": {"width": int(width), "height": int(height), "scale": float(scale)}, "dpi": int(dpi), "background": background, - "background_color": bg_arg or "transparent", + "background_color": bg_str, "padding": int(padding), "antialias": bool(antialias), "clip_to_viewbox": bool(clip_to_viewbox), + "overrides": { + "stroke_color": override_stroke_color if override_stroke_color else "original", + "stroke_width": float(override_stroke_width) if override_stroke_width > 0 else "original", + "fill_color": override_fill_color if override_fill_color else "original" + } } return (tensor, json.dumps(meta, indent=2)) # ----- helpers ----- + def _render_svg_basic(self, svg_string: str, width: int, height: int, background: str, bg_color: str, + override_stroke_color: str, override_stroke_width: float, override_fill_color: str) -> Image.Image: + """ + Basic SVG path renderer using PIL ImageDraw. + Limitations: Only supports simple path commands (M, L, C, Z); no complex styles/filters. + """ + # Create canvas + if background == "transparent": + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + elif background == "white": + img = Image.new("RGBA", (width, height), (255, 255, 255, 255)) + elif background == "black": + img = Image.new("RGBA", (width, height), (0, 0, 0, 255)) + else: + rgba = self._parse_rgba(self._normalize_color(bg_color)) + img = Image.new("RGBA", (width, height), rgba) + + draw = ImageDraw.Draw(img, "RGBA") + + # Parse SVG + try: + root = ET.fromstring(svg_string) + except: + return img # return blank canvas on parse error + + # Get viewBox for coordinate transformation + viewbox = root.get("viewBox") or root.get("viewbox") + if viewbox: + parts = [float(p) for p in viewbox.replace(",", " ").split()] + if len(parts) == 4: + vb_x, vb_y, vb_w, vb_h = parts + scale_x = width / vb_w if vb_w > 0 else 1 + scale_y = height / vb_h if vb_h > 0 else 1 + else: + scale_x = scale_y = 1 + vb_x = vb_y = 0 + else: + scale_x = scale_y = 1 + vb_x = vb_y = 0 + + # Extract and render elements + for path_elem in root.iter("{http://www.w3.org/2000/svg}path"): + d = path_elem.get("d") + if not d: + continue + + # Parse stroke/fill + stroke_color = path_elem.get("stroke", "black") + fill_color = path_elem.get("fill", "none") + stroke_width = float(path_elem.get("stroke-width", 1)) + + # Apply overrides + if override_stroke_color and override_stroke_color.strip(): + stroke_color = override_stroke_color.strip() + if override_stroke_width > 0: + stroke_width = override_stroke_width + if override_fill_color and override_fill_color.strip(): + fill_color = override_fill_color.strip() + + # Convert path commands to coordinates + coords = self._parse_svg_path(d, vb_x, vb_y, scale_x, scale_y) + + if not coords or len(coords) < 2: + continue + + # Draw path + if fill_color and fill_color.lower() != "none": + fill_rgba = self._color_to_rgba(fill_color) + if fill_rgba: + draw.polygon(coords, fill=fill_rgba) + + if stroke_color and stroke_color.lower() != "none": + stroke_rgba = self._color_to_rgba(stroke_color) + if stroke_rgba: + draw.line(coords, fill=stroke_rgba, width=int(stroke_width * scale_x)) + + return img + + def _parse_svg_path(self, d: str, offset_x: float, offset_y: float, scale_x: float, scale_y: float) -> List[Tuple[float, float]]: + """ + Parse SVG path 'd' attribute into coordinate list. + Supports: M (moveto), L (lineto), H/V (horizontal/vertical), C (cubic bezier), Z (closepath). + """ + coords = [] + # Simple regex tokenizer + tokens = re.findall(r'[MLHVCSQTAZmlhvcsqtaz]|[-+]?[0-9]*\.?[0-9]+', d) + + i = 0 + current_x = current_y = 0 + start_x = start_y = 0 + + while i < len(tokens): + cmd = tokens[i] + i += 1 + + if cmd.upper() == 'M': # Move to + if i < len(tokens): + x = float(tokens[i]) + i += 1 + if i < len(tokens): + y = float(tokens[i]) + i += 1 + current_x, current_y = x, y + start_x, start_y = x, y + coords.append(((current_x - offset_x) * scale_x, (current_y - offset_y) * scale_y)) + + elif cmd.upper() == 'L': # Line to + if i < len(tokens): + x = float(tokens[i]) + i += 1 + if i < len(tokens): + y = float(tokens[i]) + i += 1 + current_x, current_y = x, y + coords.append(((current_x - offset_x) * scale_x, (current_y - offset_y) * scale_y)) + + elif cmd.upper() == 'H': # Horizontal line + if i < len(tokens): + x = float(tokens[i]) + i += 1 + current_x = x + coords.append(((current_x - offset_x) * scale_x, (current_y - offset_y) * scale_y)) + + elif cmd.upper() == 'V': # Vertical line + if i < len(tokens): + y = float(tokens[i]) + i += 1 + current_y = y + coords.append(((current_x - offset_x) * scale_x, (current_y - offset_y) * scale_y)) + + elif cmd.upper() == 'C': # Cubic bezier (simplified: just use end point) + if i + 5 < len(tokens): + i += 4 # skip control points + x = float(tokens[i]) + i += 1 + y = float(tokens[i]) + i += 1 + current_x, current_y = x, y + coords.append(((current_x - offset_x) * scale_x, (current_y - offset_y) * scale_y)) + + elif cmd.upper() == 'Z': # Close path + if start_x != current_x or start_y != current_y: + coords.append(((start_x - offset_x) * scale_x, (start_y - offset_y) * scale_y)) + current_x, current_y = start_x, start_y + + return coords + + def _color_to_rgba(self, color: str) -> Optional[Tuple[int, int, int, int]]: + """Convert CSS color string to RGBA tuple.""" + if not color or color.lower() == "none": + return None + if color.startswith("#"): + return self._parse_rgba(self._normalize_color(color)) + # Named colors (basic set) + named = { + "black": (0, 0, 0, 255), "white": (255, 255, 255, 255), + "red": (255, 0, 0, 255), "green": (0, 128, 0, 255), "blue": (0, 0, 255, 255), + } + return named.get(color.lower(), (0, 0, 0, 255)) + def _infer_svg_size(self, svg: str) -> Tuple[int, int]: try: root = ET.fromstring(svg)