None of the nodes showed description text in ComfyUI because ComfyUI reads obj_class.DESCRIPTION when it builds node_info - it does not read the class docstring, which is what these nodes had. Every registered node now carries a one-line DESCRIPTION, kept to 29-67 characters so it fits the limited space in ComfyUI Manager and the node search results. Image Transform Pro's four-sentence description is shortened to match. Descriptions are derived from each node's existing docstring, or from the README for the five classes that had no docstring (ImageBlendPro, ColorMatchBlend and the three Boolean switch nodes). README also updated for the recent changes: the New Nodes table now lists the real "Rotate & Scale" category, the XY placement direction is documented on both nodes (positive X right, positive Y up), and the v2.2.0 changelog records the placement convention, the node outputs and the MP_ImageTransform internal name.
233 lines
8.6 KiB
Python
233 lines
8.6 KiB
Python
import torch
|
|
import numpy as np
|
|
import cv2
|
|
from PIL import Image
|
|
import folder_paths
|
|
import os
|
|
|
|
class HistogramView:
|
|
"""
|
|
Displays RGB and luminance histogram with in-node preview.
|
|
"""
|
|
|
|
DESCRIPTION = "RGB and luminance histogram displayed in-node."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"show_rgb": ("BOOLEAN", {"default": True}),
|
|
"show_luminance": ("BOOLEAN", {"default": True}),
|
|
"line_thickness": ("INT", {"default": 1, "min": 1, "max": 5, "step": 1}),
|
|
"background": (["black", "gray", "white"], {"default": "black"}),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "create_histogram"
|
|
CATEGORY = "MachinePaintingNodes/Analysis"
|
|
|
|
def create_histogram(self, image, show_rgb=True, show_luminance=True,
|
|
line_thickness=1, background="black", unique_id=None):
|
|
|
|
img = (image[0].cpu().numpy() * 255).astype(np.uint8)
|
|
|
|
# Fixed size
|
|
width = 400
|
|
height = int(width * 0.6)
|
|
|
|
# Background color
|
|
bg_colors = {"black": (0, 0, 0), "white": (255, 255, 255), "gray": (40, 40, 40)}
|
|
bg_color = bg_colors.get(background, (0, 0, 0))
|
|
|
|
# Create histogram canvas
|
|
hist_img = np.full((height, width, 3), bg_color, dtype=np.uint8)
|
|
|
|
# Calculate histograms
|
|
colors = []
|
|
hists = []
|
|
|
|
if show_rgb:
|
|
hist_r = cv2.calcHist([img], [0], None, [256], [0, 256]).flatten()
|
|
hists.append(hist_r)
|
|
colors.append((255, 80, 80))
|
|
|
|
hist_g = cv2.calcHist([img], [1], None, [256], [0, 256]).flatten()
|
|
hists.append(hist_g)
|
|
colors.append((80, 255, 80))
|
|
|
|
hist_b = cv2.calcHist([img], [2], None, [256], [0, 256]).flatten()
|
|
hists.append(hist_b)
|
|
colors.append((80, 80, 255))
|
|
|
|
if show_luminance:
|
|
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
hist_l = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
|
|
hists.append(hist_l)
|
|
colors.append((200, 200, 200))
|
|
|
|
if hists:
|
|
max_val = max(h.max() for h in hists)
|
|
if max_val == 0:
|
|
max_val = 1
|
|
|
|
bin_width = width / 256
|
|
|
|
for hist, color in zip(hists, colors):
|
|
hist_normalized = (hist / max_val * (height - 20)).astype(np.int32)
|
|
points = []
|
|
for i in range(256):
|
|
x = int(i * bin_width)
|
|
y = height - 10 - hist_normalized[i]
|
|
points.append((x, y))
|
|
|
|
for i in range(len(points) - 1):
|
|
cv2.line(hist_img, points[i], points[i+1], color, line_thickness, cv2.LINE_AA)
|
|
|
|
# Grid lines
|
|
grid_color = (60, 60, 60) if background == "black" else (180, 180, 180)
|
|
for val in [64, 128, 192]:
|
|
x = int(val * bin_width)
|
|
cv2.line(hist_img, (x, 0), (x, height), grid_color, 1)
|
|
|
|
# Labels
|
|
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
label_color = (100, 100, 100)
|
|
cv2.putText(hist_img, "Shadows", (5, height - 3), font, 0.35, label_color, 1, cv2.LINE_AA)
|
|
cv2.putText(hist_img, "Mids", (width//2 - 15, height - 3), font, 0.35, label_color, 1, cv2.LINE_AA)
|
|
cv2.putText(hist_img, "Highlights", (width - 55, height - 3), font, 0.35, label_color, 1, cv2.LINE_AA)
|
|
|
|
# Save preview
|
|
preview_results = self.save_preview(hist_img, unique_id)
|
|
|
|
return {"ui": {"images": preview_results}}
|
|
|
|
def save_preview(self, img_np, unique_id):
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
filename = f"histogram_{unique_id}.png"
|
|
filepath = os.path.join(temp_dir, filename)
|
|
img_pil = Image.fromarray(img_np)
|
|
img_pil.save(filepath)
|
|
return [{"filename": filename, "subfolder": "", "type": "temp"}]
|
|
|
|
|
|
class ColorWheelView:
|
|
"""
|
|
Vectorscope-style color wheel showing color distribution.
|
|
"""
|
|
|
|
DESCRIPTION = "Vectorscope colour wheel showing hue and saturation spread."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"intensity": ("FLOAT", {"default": 1.0, "min": 0.2, "max": 3.0, "step": 0.1, "display": "slider"}),
|
|
"show_skin_line": ("BOOLEAN", {"default": True}),
|
|
"show_color_targets": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "create_vectorscope"
|
|
CATEGORY = "MachinePaintingNodes/Analysis"
|
|
|
|
def create_vectorscope(self, image, intensity=1.0, show_skin_line=True,
|
|
show_color_targets=True, unique_id=None):
|
|
img = (image[0].cpu().numpy() * 255).astype(np.uint8)
|
|
|
|
# Fixed size
|
|
size = 400
|
|
|
|
# Convert to YUV
|
|
yuv = cv2.cvtColor(img, cv2.COLOR_RGB2YUV)
|
|
|
|
# Create scope
|
|
scope = np.zeros((size, size, 3), dtype=np.float32)
|
|
center = size // 2
|
|
radius = size // 2 - 15
|
|
|
|
# Draw circle outlines
|
|
cv2.circle(scope, (center, center), radius, (0.2, 0.2, 0.2), 1)
|
|
cv2.circle(scope, (center, center), int(radius * 0.75), (0.15, 0.15, 0.15), 1)
|
|
cv2.circle(scope, (center, center), int(radius * 0.5), (0.12, 0.12, 0.12), 1)
|
|
cv2.circle(scope, (center, center), int(radius * 0.25), (0.1, 0.1, 0.1), 1)
|
|
|
|
# Draw crosshairs
|
|
cv2.line(scope, (center, 10), (center, size - 10), (0.15, 0.15, 0.15), 1)
|
|
cv2.line(scope, (10, center), (size - 10, center), (0.15, 0.15, 0.15), 1)
|
|
|
|
# Color targets
|
|
if show_color_targets:
|
|
targets = [
|
|
(0.5, -0.4, (0.4, 0.1, 0.1), "R"),
|
|
(0.3, 0.5, (0.1, 0.4, 0.1), "G"),
|
|
(-0.4, -0.5, (0.1, 0.1, 0.4), "B"),
|
|
(-0.5, 0.4, (0.1, 0.3, 0.3), "C"),
|
|
(-0.3, -0.5, (0.3, 0.1, 0.3), "M"),
|
|
(0.4, 0.5, (0.3, 0.3, 0.1), "Y"),
|
|
]
|
|
for u, v, color, label in targets:
|
|
tx = int(center + u * radius * 0.85)
|
|
ty = int(center - v * radius * 0.85)
|
|
cv2.rectangle(scope, (tx-4, ty-4), (tx+4, ty+4), color, 1)
|
|
|
|
# Skin tone line
|
|
if show_skin_line:
|
|
angle = np.radians(123)
|
|
x1 = int(center + radius * 0.1 * np.cos(angle))
|
|
y1 = int(center - radius * 0.1 * np.sin(angle))
|
|
x2 = int(center + radius * np.cos(angle))
|
|
y2 = int(center - radius * np.sin(angle))
|
|
cv2.line(scope, (x1, y1), (x2, y2), (0.5, 0.35, 0.25), 2)
|
|
|
|
# Sample pixels (subsample for large images)
|
|
img_h, img_w = img.shape[:2]
|
|
sample_step = max(1, int(np.sqrt(img_h * img_w / 50000)))
|
|
|
|
u_vals = yuv[::sample_step, ::sample_step, 1].flatten().astype(np.float32) - 128
|
|
v_vals = yuv[::sample_step, ::sample_step, 2].flatten().astype(np.float32) - 128
|
|
|
|
scale = radius / 128
|
|
x_coords = (center + u_vals * scale).astype(np.int32)
|
|
y_coords = (center - v_vals * scale).astype(np.int32)
|
|
|
|
# Accumulate with color
|
|
for x, y in zip(x_coords, y_coords):
|
|
if 0 <= x < size and 0 <= y < size:
|
|
hue = np.arctan2(center - y, x - center)
|
|
r = 0.5 + 0.5 * np.cos(hue)
|
|
g = 0.5 + 0.5 * np.cos(hue - 2.094)
|
|
b = 0.5 + 0.5 * np.cos(hue + 2.094)
|
|
scope[y, x] += np.array([r, g, b]) * intensity * 0.05
|
|
|
|
scope = np.clip(scope, 0, 1)
|
|
scope_uint8 = (scope * 255).astype(np.uint8)
|
|
|
|
# Save preview
|
|
preview_results = self.save_preview(scope_uint8, unique_id)
|
|
|
|
return {"ui": {"images": preview_results}}
|
|
|
|
def save_preview(self, img_np, unique_id):
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
filename = f"colorwheel_{unique_id}.png"
|
|
filepath = os.path.join(temp_dir, filename)
|
|
img_pil = Image.fromarray(img_np)
|
|
img_pil.save(filepath)
|
|
return [{"filename": filename, "subfolder": "", "type": "temp"}]
|