Repository: Yanick112/ComfyUI-ToSVG Branch: main Commit: 1022ba05318d Files: 9 Total size: 49.4 KB Directory structure: gitextract_cfygdqbr/ ├── .github/ │ └── workflows/ │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── examples/ │ └── to_svg.json ├── pyproject.toml ├── requirements.txt └── svgnode.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish to Comfy registry on: workflow_dispatch: push: branches: - main - master paths: - "pyproject.toml" permissions: issues: write jobs: publish-node: name: Publish Custom Node to registry runs-on: ubuntu-latest if: ${{ github.repository_owner == 'Yanick112' }} steps: - name: Check out code uses: actions/checkout@v4 - name: Publish Custom Node uses: Comfy-Org/publish-node-action@v1 with: ## Add your own personal access token to your Github Repository secrets and reference it here. personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyderworkspace # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Yanick112 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ComfyUI-ToSVG Huge thanks to visioncortex and potracer for this amazing thing! Original repository: https://github.com/visioncortex/vtracer and https://github.com/tatarize/potrace ![截图_20240613204507](examples/workflow_20250618_000738.png) ## Update ### 06-17 - This update is a destructive update. Please check carefully in the production environment! - Rename nodes to avoid conflicts - Add new nodes - `Image Quantize` - `SVG String to SVG BytesIO` - `SVG BytesIO to SVG String` - `SVG String Path Simplify` - `Image to SVG String BW_Potracer`(thanks@ImagineerNL, Optimized integration based on his work) ## VTracer ComfyUI Non-Official Implementation Welcome to the unofficial implementation of the ComfyUI for VTracer. This project converts raster images into SVG format using the VTracer library. It's a handy tool for designers and developers who need to work with vector graphics programmatically. ### Installation 1. Navigate to your `/ComfyUI/custom_nodes/` folder. 2. Run the following command to clone the repository: ```shell git clone https://github.com/Yanick112/ComfyUI-ToSVG/ ``` 4. Navigate to your `ComfyUI-ToSVG` folder. - For Portable/venv: - Run the following command: ```shell path/to/ComfUI/python_embeded/python.exe -s -m pip install -r requirements.txt ``` - With system Python: - Run the following command: ```shell pip install -r requirements.txt ``` Enjoy setting up your ComfyUI-ToSVG tool! If you encounter any issues or need further help, feel free to reach out. ### Partial Parameter Description - Filter Speckle (Cleaner) - Color Precision (More accurate) - Gradient Step (Less layers) - Corner Threshold (Smoother) - Segment Length (More coarse) - Splice Threshold (Less accurate) ### Features - Converts images to RGBA format if necessary - Support batch conversion - node `ConvertRasterToVector` to handle the conversion of raster images to SVG format with various parameters for customization. - node `SaveSVG` to save the resulting SVG data into files. ### What's next? - [x] Add SVG preview node - [x] Color and BW mode split --- Enjoy converting your raster images to SVG with this handy tool! If you have any questions or need further assistance, don't hesitate to reach out. ================================================ FILE: __init__.py ================================================ from .svgnode import * __all__ = [ "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS" ] ================================================ FILE: examples/to_svg.json ================================================ { "id": "fd87519e-4b4d-4a9a-9516-9e1ba29ea4a9", "revision": 0, "last_node_id": 14, "last_link_id": 12, "nodes": [ { "id": 8, "type": "TS_ImageToSVGStringBW_Vtracer", "pos": [ 1550, 425 ], "size": [ 307.6441345214844, 154 ], "flags": {}, "order": 3, "mode": 0, "inputs": [ { "label": "image", "name": "image", "type": "IMAGE", "link": 6 } ], "outputs": [ { "label": "STRING", "name": "STRING", "shape": 6, "type": "STRING", "links": [ 1 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_ImageToSVGStringBW_Vtracer" }, "widgets_values": [ "spline", 4, 60, 4, 45 ] }, { "id": 7, "type": "TS_ImageToSVGStringColor_Vtracer", "pos": [ 1550, 100 ], "size": [ 318.5474548339844, 274 ], "flags": {}, "order": 2, "mode": 0, "inputs": [ { "label": "image", "name": "image", "type": "IMAGE", "link": 5 } ], "outputs": [ { "label": "STRING", "name": "STRING", "shape": 6, "type": "STRING", "links": [ 3 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_ImageToSVGStringColor_Vtracer" }, "widgets_values": [ "stacked", "spline", 4, 6, 16, 60, 4, 10, 45, 3 ] }, { "id": 5, "type": "TS_SVGPathSimplify", "pos": [ 1900, 100 ], "size": [ 270, 82 ], "flags": {}, "order": 5, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 3 } ], "outputs": [ { "label": "STRING", "name": "STRING", "type": "STRING", "links": [ 4 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGPathSimplify" }, "widgets_values": [ 5, false ] }, { "id": 9, "type": "TS_SVGStringToImage", "pos": [ 1925, 425 ], "size": [ 168.29257202148438, 26 ], "flags": {}, "order": 6, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 1 } ], "outputs": [ { "label": "IMAGE", "name": "IMAGE", "type": "IMAGE", "links": [ 10 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGStringToImage" } }, { "id": 6, "type": "TS_ImageQuantize", "pos": [ 1150, 450 ], "size": [ 270, 82 ], "flags": {}, "order": 1, "mode": 0, "inputs": [ { "label": "image", "name": "image", "type": "IMAGE", "link": 11 } ], "outputs": [ { "label": "IMAGE", "name": "IMAGE", "type": "IMAGE", "links": [ 5, 6, 7 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_ImageQuantize" }, "widgets_values": [ 16, "Clear" ] }, { "id": 13, "type": "LoadImage", "pos": [ 825, 450 ], "size": [ 270, 314 ], "flags": {}, "order": 0, "mode": 0, "inputs": [], "outputs": [ { "label": "IMAGE", "name": "IMAGE", "type": "IMAGE", "links": [ 11 ] }, { "label": "MASK", "name": "MASK", "type": "MASK", "links": null } ], "properties": { "cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "LoadImage" }, "widgets_values": [ "0_1 (1).jpg", "image" ] }, { "id": 3, "type": "TS_SVGStringToSVGBytesIO", "pos": [ 1900, 725 ], "size": [ 212.63046264648438, 26 ], "flags": {}, "order": 7, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 2 } ], "outputs": [ { "label": "SVG", "name": "SVG", "type": "SVG", "links": [ 8 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGStringToSVGBytesIO" } }, { "id": 11, "type": "TS_ImageToSVGStringBW_Potracer", "pos": [ 1550, 725 ], "size": [ 315.4302673339844, 322 ], "flags": {}, "order": 4, "mode": 0, "inputs": [ { "label": "image", "name": "image", "type": "IMAGE", "link": 7 } ], "outputs": [ { "label": "STRING", "name": "STRING", "type": "STRING", "links": [ 2 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_ImageToSVGStringBW_Potracer" }, "widgets_values": [ 128, "Black on White", "minority", 2, 1, false, 0.2, true, {}, {}, {}, 0 ] }, { "id": 1, "type": "TS_SaveSVGString", "pos": [ 2425, 725 ], "size": [ 272.744140625, 106 ], "flags": {}, "order": 11, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 9 } ], "outputs": [], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SaveSVGString" }, "widgets_values": [ {}, true, {} ] }, { "id": 2, "type": "TS_SVGStringPreview", "pos": [ 2200, 100 ], "size": [ 210, 258 ], "flags": {}, "order": 8, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 4 } ], "outputs": [], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGStringPreview" }, "widgets_values": [] }, { "id": 12, "type": "PreviewImage", "pos": [ 2175, 400 ], "size": [ 210, 258 ], "flags": {}, "order": 9, "mode": 0, "inputs": [ { "label": "images", "name": "images", "type": "IMAGE", "link": 10 } ], "outputs": [], "properties": { "cnr_id": "comfy-core", "ver": "0.3.41", "Node name for S&R": "PreviewImage" }, "widgets_values": [] }, { "id": 4, "type": "TS_SVGBytesIOToString", "pos": [ 2150, 725 ], "size": [ 212.63046264648438, 26 ], "flags": {}, "order": 10, "mode": 0, "inputs": [ { "label": "SVG_BytesIO", "name": "SVG_BytesIO", "type": "SVG", "link": 8 } ], "outputs": [ { "label": "STRING", "name": "STRING", "type": "STRING", "links": [ 9, 12 ] } ], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGBytesIOToString" } }, { "id": 14, "type": "TS_SVGStringPreview", "pos": [ 2425, 900 ], "size": [ 210, 258 ], "flags": {}, "order": 12, "mode": 0, "inputs": [ { "label": "SVG_String", "name": "SVG_String", "type": "STRING", "link": 12 } ], "outputs": [], "properties": { "cnr_id": "ComfyUI-ToSVG", "ver": "2626a6cc885a23e973715b5b5baf37799a7d3d41", "Node name for S&R": "TS_SVGStringPreview" }, "widgets_values": [] } ], "links": [ [ 1, 8, 0, 9, 0, "STRING" ], [ 2, 11, 0, 3, 0, "STRING" ], [ 3, 7, 0, 5, 0, "STRING" ], [ 4, 5, 0, 2, 0, "STRING" ], [ 5, 6, 0, 7, 0, "IMAGE" ], [ 6, 6, 0, 8, 0, "IMAGE" ], [ 7, 6, 0, 11, 0, "IMAGE" ], [ 8, 3, 0, 4, 0, "SVG" ], [ 9, 4, 0, 1, 0, "STRING" ], [ 10, 9, 0, 12, 0, "IMAGE" ], [ 11, 13, 0, 6, 0, "IMAGE" ], [ 12, 4, 0, 14, 0, "STRING" ] ], "groups": [], "config": {}, "extra": { "ds": { "scale": 0.6934334949441344, "offset": [ 468.60443938760835, 382.7150165816723 ] }, "frontendVersion": "1.21.7", "VHS_latentpreview": false, "VHS_latentpreviewrate": 0, "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, "version": 0.4 } ================================================ FILE: pyproject.toml ================================================ [project] name = "comfyui-tosvg" description = "This project converts raster images into SVG format using the [a/VTracer](https://github.com/visioncortex/vtracer) library and [a/Potracer](https://github.com/tatarize/potrace). It's a handy tool for designers and developers who need to work with vector graphics programmatically." version = "1.0.1" license = { file = "LICENSE" } dependencies = ["numpy", "Pillow", "torch", "vtracer", "potracer", "PyMuPDF"] [project.urls] Repository = "https://github.com/Yanick112/ComfyUI-ToSVG" # Used by Comfy Registry https://comfyregistry.org [tool.comfy] PublisherId = "yanick" DisplayName = "ComfyUI-ToSVG" Icon = "" ================================================ FILE: requirements.txt ================================================ vtracer numpy Pillow torch PyMuPDF potracer ================================================ FILE: svgnode.py ================================================ import vtracer import os import time import folder_paths import numpy as np import torch import fitz import random import folder_paths import potrace from io import BytesIO from PIL import Image from comfy_extras.nodes_images import SVG from nodes import SaveImage import re import xml.etree.ElementTree as ET def tensor2pil(image): """Tensor转PIL图像""" return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) def pil2tensor(image): """PIL图像转Tensor""" return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) class TS_ImageQuantize: """ 图像量化:通过减少图像中的颜色数量来优化矢量转换过程。 """ @classmethod def INPUT_TYPES(cls): """ 定义节点的输入参数。 """ return { "required": { "image": ("IMAGE",), "colors": ("INT", {"default": 16, "min": 2, "max": 256, "step": 1}), "dither": (["Clear", "Smooth"], {"default": "Clear"}), } } RETURN_TYPES = ("IMAGE",) FUNCTION = "quantize_image" CATEGORY = "💎TOSVG/Tools" def quantize_image(self, image, colors, dither): """ 执行图像量化处理。 """ quantized_images = [] dither_method = Image.Dither.NONE if dither == "Smooth": dither_method = Image.Dither.FLOYDSTEINBERG for i in image: pil_image = tensor2pil(torch.unsqueeze(i, 0)) quantized_pil = pil_image.convert('RGB').quantize(colors=colors, dither=dither_method) quantized_pil_rgb = quantized_pil.convert('RGB') tensor_image = pil2tensor(quantized_pil_rgb) quantized_images.append(tensor_image.squeeze(0)) if not quantized_images: return (image,) return (torch.stack(quantized_images),) class TS_ImageToSVGStringColor_Vtracer: """图像转彩色SVG字符串""" @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "hierarchical": (["stacked", "cutout"], {"default": "stacked"}), "mode": (["spline", "polygon", "none"], {"default": "spline"}), "filter_speckle": ("INT", {"default": 4, "min": 0, "max": 100, "step": 1}), "color_precision": ("INT", {"default": 6, "min": 0, "max": 10, "step": 1}), "layer_difference": ("INT", {"default": 16, "min": 0, "max": 256, "step": 1}), "corner_threshold": ("INT", {"default": 60, "min": 0, "max": 180, "step": 1}), "length_threshold": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 10.0, "step": 0.1}), "max_iterations": ("INT", {"default": 10, "min": 1, "max": 70, "step": 1}), "splice_threshold": ("INT", {"default": 45, "min": 0, "max": 180, "step": 1}), "path_precision": ("INT", {"default": 3, "min": 0, "max": 10, "step": 1}), } } RETURN_TYPES = ("STRING",) OUTPUT_IS_LIST = (True,) FUNCTION = "convert_to_svg" CATEGORY = "💎TOSVG/Convert" def convert_to_svg(self, image, hierarchical, mode, filter_speckle, color_precision, layer_difference, corner_threshold, length_threshold, max_iterations, splice_threshold, path_precision): svg_strings = [] for i in image: i = torch.unsqueeze(i, 0) _image = tensor2pil(i) if _image.mode != 'RGBA': alpha = Image.new('L', _image.size, 255) _image.putalpha(alpha) pixels = list(_image.getdata()) size = _image.size svg_str = vtracer.convert_pixels_to_svg( pixels, size=size, colormode="color", hierarchical=hierarchical, mode=mode, filter_speckle=filter_speckle, color_precision=color_precision, layer_difference=layer_difference, corner_threshold=corner_threshold, length_threshold=length_threshold, max_iterations=max_iterations, splice_threshold=splice_threshold, path_precision=path_precision, ) svg_strings.append(svg_str) return (svg_strings,) class TS_ImageToSVGStringBW_Vtracer: """图像转黑白SVG字符串""" @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "mode": (["spline", "polygon", "none"], {"default": "spline"}), "filter_speckle": ("INT", {"default": 4, "min": 0, "max": 100, "step": 1}), "corner_threshold": ("INT", {"default": 60, "min": 0, "max": 180, "step": 1}), "length_threshold": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 10.0, "step": 0.1}), "splice_threshold": ("INT", {"default": 45, "min": 0, "max": 180, "step": 1}), } } RETURN_TYPES = ("STRING",) OUTPUT_IS_LIST = (True,) FUNCTION = "convert_to_svg" CATEGORY = "💎TOSVG/Convert" def convert_to_svg(self, image, mode, filter_speckle, corner_threshold, length_threshold, splice_threshold): svg_strings = [] for i in image: i = torch.unsqueeze(i, 0) _image = tensor2pil(i) if _image.mode != 'RGBA': alpha = Image.new('L', _image.size, 255) _image.putalpha(alpha) pixels = list(_image.getdata()) size = _image.size svg_str = vtracer.convert_pixels_to_svg( pixels, size=size, colormode="binary", mode=mode, filter_speckle=filter_speckle, corner_threshold=corner_threshold, length_threshold=length_threshold, splice_threshold=splice_threshold, ) svg_strings.append(svg_str) return (svg_strings,) class TS_SVGStringToImage: """SVG字符串转图像""" @classmethod def INPUT_TYPES(cls): return { "required": { "SVG_String": ("STRING", {"forceInput": True}) } } RETURN_TYPES = ("IMAGE",) FUNCTION = "convert_svg_to_image" CATEGORY = "💎TOSVG/Convert" def convert_svg_to_image(self, SVG_String): doc = fitz.open(stream=SVG_String.encode('utf-8'), filetype="svg") page = doc.load_page(0) pix = page.get_pixmap() image_data = pix.tobytes("png") pil_image = Image.open(BytesIO(image_data)).convert("RGB") return (pil2tensor(pil_image),) class TS_SaveSVGString: """保存SVG字符串到文件""" def __init__(self): self.output_dir = folder_paths.get_output_directory() @classmethod def INPUT_TYPES(cls): return { "required": { "SVG_String": ("STRING", {"forceInput": True}), "filename_prefix": ("STRING", {"default": "ComfyUI_SVG"}), }, "optional": { "append_timestamp": ("BOOLEAN", {"default": True}), "custom_output_path": ("STRING", {"default": "", "multiline": False}), } } CATEGORY = "💎TOSVG/Tools" RETURN_TYPES = () OUTPUT_NODE = True FUNCTION = "save_svg_file" def generate_unique_filename(self, prefix, timestamp=False): if timestamp: timestamp_str = time.strftime("%Y%m%d%H%M%S") return f"{prefix}_{timestamp_str}.svg" else: return f"{prefix}.svg" def save_svg_file(self, SVG_String, filename_prefix="ComfyUI_SVG", append_timestamp=True, custom_output_path=""): output_path = custom_output_path if custom_output_path else self.output_dir os.makedirs(output_path, exist_ok=True) unique_filename = self.generate_unique_filename(f"{filename_prefix}", append_timestamp) final_filepath = os.path.join(output_path, unique_filename) with open(final_filepath, "w") as svg_file: svg_file.write(SVG_String) ui_info = {"ui": {"saved_svg": unique_filename, "path": final_filepath}} return ui_info class TS_SVGStringPreview(SaveImage): """SVG字符串预览""" @classmethod def INPUT_TYPES(s): return { "required": { "SVG_String": ("STRING", {"forceInput": True}) } } FUNCTION = "svg_preview" CATEGORY = "💎TOSVG/Tools" OUTPUT_NODE = True def __init__(self): self.output_dir = folder_paths.get_temp_directory() self.type = "temp" self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz1234567890") for x in range(5)) self.compress_level = 4 def svg_preview(self, SVG_String): doc = fitz.open(stream=SVG_String.encode('utf-8'), filetype="svg") page = doc.load_page(0) pix = page.get_pixmap() image_data = pix.tobytes("png") pil_image = Image.open(BytesIO(image_data)).convert("RGB") preview = pil2tensor(pil_image) return self.save_images(preview, "PointPreview") class TS_SVGStringToSVGBytesIO: """SVG字符串转BytesIO""" @classmethod def INPUT_TYPES(cls): return { "required": { "SVG_String": ("STRING", {"forceInput": True}), } } RETURN_TYPES = ("SVG",) FUNCTION = "convert_string_to_svg" CATEGORY = "💎TOSVG/Tools" def convert_string_to_svg(self, SVG_String): svg_bytes = BytesIO(SVG_String.encode('utf-8')) return (SVG([svg_bytes]),) class TS_SVGBytesIOToString: """BytesIO转SVG字符串""" @classmethod def INPUT_TYPES(cls): return { "required": { "SVG_BytesIO": ("SVG", {"forceInput": True}), } } RETURN_TYPES = ("STRING",) FUNCTION = "convert_svg_to_string" CATEGORY = "💎TOSVG/Tools" def convert_svg_to_string(self, SVG_BytesIO): if not SVG_BytesIO.data: return ("",) svg_bytes = SVG_BytesIO.data[0].getvalue() svg_string = svg_bytes.decode('utf-8') return (svg_string,) class TS_SVGPathSimplify: """SVG路径简化""" @classmethod def INPUT_TYPES(cls): return { "required": { "SVG_String": ("STRING", {"forceInput": True}), "tolerance": ("FLOAT", {"default": 5.0, "min": 0.1, "max": 50.0, "step": 0.1}), "preserve_curves": ("BOOLEAN", {"default": False}), } } RETURN_TYPES = ("STRING",) FUNCTION = "simplify_svg_paths" CATEGORY = "💎TOSVG/Tools" def douglas_peucker(self, points, tolerance): """Douglas-Peucker算法简化路径点""" if len(points) <= 2: return points # 找到距离起点和终点连线最远的点 max_distance = 0 max_index = 0 start = points[0] end = points[-1] if abs(start[0] - end[0]) < 0.01 and abs(start[1] - end[1]) < 0.01: for i in range(1, len(points) - 1): distance = ((points[i][0] - start[0]) ** 2 + (points[i][1] - start[1]) ** 2) ** 0.5 if distance > max_distance: max_distance = distance max_index = i else: for i in range(1, len(points) - 1): distance = self.point_to_line_distance(points[i], start, end) if distance > max_distance: max_distance = distance max_index = i if max_distance > tolerance and max_index > 0: left_part = self.douglas_peucker(points[:max_index + 1], tolerance) right_part = self.douglas_peucker(points[max_index:], tolerance) return left_part[:-1] + right_part else: return [start, end] def point_to_line_distance(self, point, line_start, line_end): """计算点到直线距离""" x0, y0 = point x1, y1 = line_start x2, y2 = line_end if x1 == x2 and y1 == y2: return ((x0 - x1) ** 2 + (y0 - y1) ** 2) ** 0.5 numerator = abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1) denominator = ((y2 - y1) ** 2 + (x2 - x1) ** 2) ** 0.5 return numerator / denominator if denominator > 0 else 0 def parse_path_commands(self, path_data): """解析SVG路径命令""" commands = re.findall(r'[MmLlHhVvCcSsQqTtAaZz][^MmLlHhVvCcSsQqTtAaZz]*', path_data) return commands def extract_points_from_path(self, path_data): """提取路径中的坐标点""" points = [] commands = self.parse_path_commands(path_data) current_pos = (0, 0) for command in commands: cmd_type = command[0] params = re.findall(r'-?\d*\.?\d+', command[1:]) params = [float(p) for p in params] if cmd_type in 'Mm': if len(params) >= 2: if cmd_type == 'M': current_pos = (params[0], params[1]) else: current_pos = (current_pos[0] + params[0], current_pos[1] + params[1]) points.append(current_pos) elif cmd_type in 'LlHhVv': if cmd_type in 'Ll': for i in range(0, len(params), 2): if i + 1 < len(params): if cmd_type == 'L': current_pos = (params[i], params[i + 1]) else: current_pos = (current_pos[0] + params[i], current_pos[1] + params[i + 1]) points.append(current_pos) elif cmd_type in 'Hh': for param in params: if cmd_type == 'H': current_pos = (param, current_pos[1]) else: current_pos = (current_pos[0] + param, current_pos[1]) points.append(current_pos) elif cmd_type in 'Vv': for param in params: if cmd_type == 'V': current_pos = (current_pos[0], param) else: current_pos = (current_pos[0], current_pos[1] + param) points.append(current_pos) elif cmd_type in 'CcSsQqTt': if cmd_type in 'Cc': for i in range(0, len(params), 6): if i + 5 < len(params): if cmd_type == 'C': control1 = (params[i], params[i + 1]) control2 = (params[i + 2], params[i + 3]) end_point = (params[i + 4], params[i + 5]) points.extend([control1, control2, end_point]) current_pos = end_point else: control1 = (current_pos[0] + params[i], current_pos[1] + params[i + 1]) control2 = (current_pos[0] + params[i + 2], current_pos[1] + params[i + 3]) end_point = (current_pos[0] + params[i + 4], current_pos[1] + params[i + 5]) points.extend([control1, control2, end_point]) current_pos = end_point elif cmd_type in 'Ss': for i in range(0, len(params), 4): if i + 3 < len(params): if cmd_type == 'S': control2 = (params[i], params[i + 1]) end_point = (params[i + 2], params[i + 3]) points.extend([control2, end_point]) current_pos = end_point else: control2 = (current_pos[0] + params[i], current_pos[1] + params[i + 1]) end_point = (current_pos[0] + params[i + 2], current_pos[1] + params[i + 3]) points.extend([control2, end_point]) current_pos = end_point elif cmd_type in 'Qq': for i in range(0, len(params), 4): if i + 3 < len(params): if cmd_type == 'Q': control = (params[i], params[i + 1]) end_point = (params[i + 2], params[i + 3]) points.extend([control, end_point]) current_pos = end_point else: control = (current_pos[0] + params[i], current_pos[1] + params[i + 1]) end_point = (current_pos[0] + params[i + 2], current_pos[1] + params[i + 3]) points.extend([control, end_point]) current_pos = end_point elif cmd_type in 'Tt': for i in range(0, len(params), 2): if i + 1 < len(params): if cmd_type == 'T': end_point = (params[i], params[i + 1]) else: end_point = (current_pos[0] + params[i], current_pos[1] + params[i + 1]) points.append(end_point) current_pos = end_point return points def simplify_path_data(self, path_data, tolerance, preserve_curves, stats): """简化路径数据""" original_points = self.extract_points_from_path(path_data) stats['original_points'] += len(original_points) if len(original_points) < 3: stats['simplified_points'] += len(original_points) return path_data if not preserve_curves: filtered_points = [original_points[0]] for i in range(1, len(original_points)): if (abs(original_points[i][0] - filtered_points[-1][0]) > 0.1 or abs(original_points[i][1] - filtered_points[-1][1]) > 0.1): filtered_points.append(original_points[i]) original_points = filtered_points simplified_points = self.douglas_peucker(original_points, tolerance) stats['simplified_points'] += len(simplified_points) if preserve_curves and len(simplified_points) >= len(original_points) * 0.9: stats['simplified_points'] = stats['simplified_points'] - len(simplified_points) + len(original_points) return path_data if len(simplified_points) < 2: return path_data path_parts = [f"M{simplified_points[0][0]:.1f},{simplified_points[0][1]:.1f}"] for i in range(1, len(simplified_points)): x, y = simplified_points[i] path_parts.append(f"L{x:.1f},{y:.1f}") if path_data.strip().endswith('Z') or path_data.strip().endswith('z'): path_parts.append('Z') return ' '.join(path_parts) def simplify_svg_paths(self, SVG_String, tolerance, preserve_curves): """简化SVG路径""" effective_tolerance = tolerance stats = { 'original_points': 0, 'simplified_points': 0, 'paths_processed': 0, 'original_size': len(SVG_String), 'simplified_size': 0 } try: root = ET.fromstring(SVG_String) paths = root.findall('.//{http://www.w3.org/2000/svg}path') if not paths: paths = root.findall('.//path') for path in paths: if 'd' in path.attrib: original_data = path.attrib['d'] simplified_data = self.simplify_path_data(original_data, effective_tolerance, preserve_curves, stats) path.attrib['d'] = simplified_data stats['paths_processed'] += 1 ET.register_namespace('', 'http://www.w3.org/2000/svg') simplified_svg = ET.tostring(root, encoding='unicode') stats['simplified_size'] = len(simplified_svg) except Exception as e: simplified_svg = self.simplify_svg_regex(SVG_String, effective_tolerance, preserve_curves, stats) stats['simplified_size'] = len(simplified_svg) reduction_ratio = (stats['original_points'] - stats['simplified_points']) / max(stats['original_points'], 1) * 100 print(f"SVG Path Simplified: {reduction_ratio:.1f}% points reduced") return (simplified_svg,) def simplify_svg_regex(self, SVG_String, tolerance, preserve_curves, stats): """ 使用正则表达式方法简化SVG路径(备选方案) """ def replace_path(match): path_data = match.group(1) simplified = self.simplify_path_data(path_data, tolerance, preserve_curves, stats) stats['paths_processed'] += 1 return f'd="{simplified}"' simplified_svg = re.sub(r'd="([^"]*)"', replace_path, SVG_String) return simplified_svg class TS_ImageToSVGStringBW_Potracer: """Potracer矢量化为SVG""" turnpolicy_map = { "minority": potrace.POTRACE_TURNPOLICY_MINORITY, "black": potrace.POTRACE_TURNPOLICY_BLACK, "white": potrace.POTRACE_TURNPOLICY_WHITE, "left": potrace.POTRACE_TURNPOLICY_LEFT, "right": potrace.POTRACE_TURNPOLICY_RIGHT, "majority": potrace.POTRACE_TURNPOLICY_MAJORITY, } @classmethod def INPUT_TYPES(cls): policy_options = list(cls.turnpolicy_map.keys()) return { "required": { "image": ("IMAGE",), "threshold": ("INT", {"default": 128, "min": 0, "max": 255}), }, "optional": { "input_foreground": (["White on Black", "Black on White"], {"default": "Black on White"}), "turnpolicy": (policy_options, {"default": "minority"}), "turdsize": ("INT", {"default": 2, "min": 0}), "corner_threshold": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.34, "step": 0.01}), "zero_sharp_corners": ("BOOLEAN", {"default": False}), "opttolerance": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}), "optimize_curve": ("BOOLEAN", {"default": True}), "foreground_color": ("STRING", {"widget": "color", "default": "#000000"}), "background_color": ("STRING", {"widget": "color", "default": "#FFFFFF"}), "stroke_color": ("STRING", {"widget": "color", "default": "#FF0000"}), "stroke_width": ("FLOAT", {"default": 0.0, "min": 0.0, "step": 0.5}), } } RETURN_TYPES = ("STRING",) FUNCTION = "vectorize" CATEGORY = "💎TOSVG/Convert" def vectorize(self, image, threshold, turnpolicy, turdsize, corner_threshold, opttolerance, input_foreground="Black on White", optimize_curve=True, zero_sharp_corners=False, foreground_color="#000000", background_color="#FFFFFF", stroke_color="#FF0000", stroke_width=0.0): image_np = image.cpu().numpy() batch_svg_strings = [] for i, single_image_np in enumerate(image_np): orig_width_temp, orig_height_temp = (single_image_np.shape[1], single_image_np.shape[0]) if single_image_np.ndim >= 2 else (100,100) svg_data_for_current_image = f'Error: Processing failed before SVG generation for image {i}' try: pil_img = Image.fromarray((single_image_np * 255).astype(np.uint8)) orig_width, orig_height = pil_img.size if orig_width <= 0 or orig_height <= 0: error_svg = f'Error: Invalid image dimensions for image {i}' batch_svg_strings.append(error_svg) continue threshold_norm = threshold / 255.0 if single_image_np.ndim == 3: binary_np = single_image_np[:, :, 0] < threshold_norm if single_image_np.shape[2] > 1 else single_image_np[:,:,0] < threshold_norm elif single_image_np.ndim == 2: binary_np = single_image_np < threshold_norm else: error_svg = f'Error: Unexpected image dimensions for image {i}' batch_svg_strings.append(error_svg) continue if input_foreground == "Black on White": binary_np = ~binary_np if np.all(binary_np) or not np.any(binary_np): skipped_svg = f'Potracer: Skipped blank image {i}' batch_svg_strings.append(skipped_svg) continue turdsize_int = int(turdsize) if turdsize is not None else 0 policy_arg = self.turnpolicy_map.get(turnpolicy, turnpolicy) alphamax_value_to_use = 1.34 if zero_sharp_corners else corner_threshold scale = 1.0 bm = potrace.Bitmap(binary_np) plist = bm.trace( turdsize=turdsize_int, turnpolicy=policy_arg, alphamax=alphamax_value_to_use, opticurve=optimize_curve, opttolerance=opttolerance ) scaled_width = max(1, round(orig_width * scale)) scaled_height = max(1, round(orig_height * scale)) svg_header = f'' svg_footer = "" background_rect = "" bg_color_lower = background_color.lower() if bg_color_lower != "none" and bg_color_lower != "": background_rect = f'' scaled_stroke_width = stroke_width * scale stroke_attr = f'stroke="{stroke_color}" stroke-width="{scaled_stroke_width}"' if scaled_stroke_width > 0 and stroke_color.lower() != "none" else 'stroke="none"' fill_attr = f'fill="{foreground_color}"' if foreground_color.lower() != "none" else 'fill="none"' if fill_attr == 'fill="none"' and stroke_attr == 'stroke="none"': fill_attr = 'fill="black"' all_paths_svg_parts = [] if plist: fill_rule_to_use = "evenodd" for curve in plist: if not (hasattr(curve, 'start_point') and hasattr(curve.start_point, 'x') and hasattr(curve.start_point, 'y')): continue fs = curve.start_point all_paths_svg_parts.append(f"M{fs.x * scale:.2f},{fs.y * scale:.2f}") if not hasattr(curve, 'segments'): continue for segment in curve.segments: valid_segment = True if not (hasattr(segment, 'is_corner') and hasattr(segment, 'end_point') and hasattr(segment.end_point, 'x') and hasattr(segment.end_point, 'y')): valid_segment = False if valid_segment and segment.is_corner: if not (hasattr(segment, 'c') and hasattr(segment.c, 'x') and hasattr(segment.c, 'y')): valid_segment = False else: c_x = segment.c.x * scale c_y = segment.c.y * scale ep_x = segment.end_point.x * scale ep_y = segment.end_point.y * scale all_paths_svg_parts.append(f"L{c_x:.2f},{c_y:.2f}L{ep_x:.2f},{ep_y:.2f}") elif valid_segment: if not (hasattr(segment, 'c1') and hasattr(segment.c1, 'x') and hasattr(segment.c1, 'y') and \ hasattr(segment, 'c2') and hasattr(segment.c2, 'x') and hasattr(segment.c2, 'y')): valid_segment = False else: c1_x = segment.c1.x * scale; c1_y = segment.c1.y * scale c2_x = segment.c2.x * scale; c2_y = segment.c2.y * scale ep_x = segment.end_point.x * scale; ep_y = segment.end_point.y * scale all_paths_svg_parts.append(f"C{c1_x:.2f},{c1_y:.2f} {c2_x:.2f},{c2_y:.2f} {ep_x:.2f},{ep_y:.2f}") all_paths_svg_parts.append("Z") if all_paths_svg_parts: path_d_attribute = "".join(all_paths_svg_parts) path_element = f'' svg_data_for_current_image = svg_header + background_rect + path_element + svg_footer else: svg_data_for_current_image = f'{svg_header}Potracer: Path data generation failed for image {i}{svg_footer}' else: svg_data_for_current_image = f'{svg_header}Potracer: No paths found for image {i}{svg_footer}' batch_svg_strings.append(svg_data_for_current_image) except Exception as e: error_svg_content = f'Error processing image {i}: {type(e).__name__} - {str(e).replace("<", "<").replace(">", ">")}' batch_svg_strings.append(error_svg_content) continue output_string_joined = "\n".join(batch_svg_strings) return (output_string_joined,) NODE_CLASS_MAPPINGS = { "TS_ImageQuantize": TS_ImageQuantize, "TS_ImageToSVGStringColor_Vtracer": TS_ImageToSVGStringColor_Vtracer, "TS_ImageToSVGStringBW_Vtracer": TS_ImageToSVGStringBW_Vtracer, "TS_SVGStringToImage": TS_SVGStringToImage, "TS_SaveSVGString": TS_SaveSVGString, "TS_SVGStringPreview": TS_SVGStringPreview, "TS_SVGStringToSVGBytesIO": TS_SVGStringToSVGBytesIO, "TS_SVGBytesIOToString": TS_SVGBytesIOToString, "TS_SVGPathSimplify": TS_SVGPathSimplify, "TS_ImageToSVGStringBW_Potracer": TS_ImageToSVGStringBW_Potracer, } NODE_DISPLAY_NAME_MAPPINGS = { "TS_ImageQuantize": "Image Quantize", "TS_ImageToSVGStringColor_Vtracer": "Image to SVG String Color_Vtracer", "TS_ImageToSVGStringBW_Vtracer": "Image to SVG String BW_Vtracer", "TS_SVGStringToImage": "SVG String to Image", "TS_SaveSVGString": "Save SVG String", "TS_SVGStringPreview": "SVG String Preview", "TS_SVGStringToSVGBytesIO": "SVG String to SVG BytesIO", "TS_SVGBytesIOToString": "SVG BytesIO to SVG String", "TS_SVGPathSimplify": "SVG String Path Simplify", "TS_ImageToSVGStringBW_Potracer": "Image to SVG String BW_Potracer", }