From b62151f64c9c842f76202c8d74faee7dff6590db Mon Sep 17 00:00:00 2001 From: CoCo Date: Mon, 2 Mar 2026 03:47:15 +0000 Subject: [PATCH] feat: add vectorscope V3 node with YCbCr/HSV/CIE xy color models Co-Authored-By: Claude Opus 4.6 --- modules/vectorscope.py | 125 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 modules/vectorscope.py diff --git a/modules/vectorscope.py b/modules/vectorscope.py new file mode 100644 index 0000000..514605b --- /dev/null +++ b/modules/vectorscope.py @@ -0,0 +1,125 @@ +import numpy as np +import logging + +try: + from ..utils.debug_utils import setup_logging + setup_logging() +except ImportError: + logging.basicConfig(level=logging.INFO) + +logger = logging.getLogger(__name__) + +try: + from ..utils.debug_utils import debug_log +except ImportError: + def debug_log(logger, level, simple_msg, verbose_msg=None, **kwargs): + getattr(logger, level.lower())(simple_msg) + +from comfy_api.latest import io + +SRGB_TO_XYZ = np.array([ + [0.4124564, 0.3575761, 0.1804375], + [0.2126729, 0.7151522, 0.0721750], + [0.0193339, 0.1191920, 0.9503041], +]) + + +def _convert_ycbcr(pixels): + """Convert RGB pixels to YCbCr Rec.709 scatter coordinates.""" + r, g, b = pixels[:, 0], pixels[:, 1], pixels[:, 2] + y = 0.2126 * r + 0.7152 * g + 0.0722 * b + cb = (b - y) / 1.8556 + cr = (r - y) / 1.5748 + return cb, -cr + + +def _convert_hsv(pixels): + """Convert RGB pixels to HSV polar scatter coordinates.""" + r, g, b = pixels[:, 0], pixels[:, 1], pixels[:, 2] + hue = np.arctan2(np.sqrt(3.0) * (g - b), 2.0 * r - g - b) + max_c = np.maximum(np.maximum(r, g), b) + min_c = np.minimum(np.minimum(r, g), b) + saturation = np.where(max_c > 0, 1.0 - min_c / max_c, 0.0) + x = saturation * np.cos(hue) + y = saturation * np.sin(hue) + return x, y + + +def _convert_cie_xy(pixels): + """Convert RGB pixels to CIE xy chromaticity scatter coordinates.""" + xyz = pixels @ SRGB_TO_XYZ.T + total = xyz[:, 0] + xyz[:, 1] + xyz[:, 2] + valid = total > 1e-10 + x_chrom = np.where(valid, xyz[:, 0] / total, 0.3127) + y_chrom = np.where(valid, xyz[:, 1] / total, 0.3290) + x_display = (x_chrom - 0.15) / 0.65 + y_display = (y_chrom - 0.05) / 0.65 + return x_display, y_display + + +class VectorscopeNode(io.ComfyNode): + """Visualizes color distribution on a circular chromaticity display.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="VectorscopeNode", + display_name="CoCo Vectorscope", + description="Displays a vectorscope visualization of image color distribution with selectable color models.", + category="COCO Tools/Analysis", + is_output_node=True, + inputs=[ + io.Image.Input("image", tooltip="Image tensor to analyze"), + io.Combo.Input("color_model", options=["YCbCr", "HSV", "CIE xy"], default="YCbCr", tooltip="Color model for vectorscope display"), + io.Int.Input("sample_count", default=50000, min=5000, max=200000, tooltip="Maximum number of scatter points"), + ], + outputs=[], + ) + + @classmethod + def execute(cls, image, color_model="YCbCr", sample_count=50000) -> io.NodeOutput: + try: + frame = image[0].cpu().numpy() + height, width, channels = frame.shape + + pixels = frame.reshape(-1, channels)[:, :3] + + total_pixels = pixels.shape[0] + if total_pixels > sample_count: + indices = np.linspace(0, total_pixels - 1, sample_count, dtype=int) + pixels = pixels[indices] + + converters = { + "YCbCr": _convert_ycbcr, + "HSV": _convert_hsv, + "CIE xy": _convert_cie_xy, + } + x_coords, y_coords = converters[color_model](pixels) + + x_coords = np.round(x_coords, 4) + y_coords = np.round(y_coords, 4) + + result = { + "color_model": color_model, + "x_coords": x_coords.tolist(), + "y_coords": y_coords.tolist(), + "point_count": len(x_coords), + "image_info": { + "width": width, + "height": height, + "channels": channels, + }, + } + + debug_log(logger, "info", + f"Vectorscope computed: {width}x{height}, {color_model}, {len(x_coords)} points") + + return io.NodeOutput(ui={"vectorscope_data": [result]}) + + except Exception as e: + debug_log(logger, "error", "Vectorscope computation failed", f"Error computing vectorscope: {str(e)}") + raise + + @classmethod + def fingerprint_inputs(cls, **kwargs): + return float("NaN")