mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-03-07 08:59:24 +00:00
Compare commits
4 Commits
remove-cac
...
range-edit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d721bff59 | ||
|
|
de67bb0870 | ||
|
|
9d70c2626b | ||
|
|
cb64b957f2 |
@@ -1237,6 +1237,82 @@ class BoundingBox(ComfyTypeIO):
|
||||
return d
|
||||
|
||||
|
||||
@comfytype(io_type="CURVE")
|
||||
class Curve(ComfyTypeIO):
|
||||
Type = list
|
||||
|
||||
class Input(WidgetInput):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||
socketless: bool=True, default: list=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
|
||||
if default is None:
|
||||
self.default = [[0, 0], [1, 1]]
|
||||
|
||||
def as_dict(self):
|
||||
return super().as_dict()
|
||||
|
||||
|
||||
@comfytype(io_type="RANGE")
|
||||
class Range(ComfyTypeIO):
|
||||
Type = dict # {"min": float, "max": float, "midpoint"?: float}
|
||||
|
||||
class Input(WidgetInput):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||
socketless: bool=True, default: dict=None,
|
||||
display: str=None,
|
||||
gradient_stops: list=None,
|
||||
show_midpoint: bool=None,
|
||||
midpoint_scale: str=None,
|
||||
value_min: float=None,
|
||||
value_max: float=None,
|
||||
advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
|
||||
if default is None:
|
||||
self.default = {"min": 0.0, "max": 1.0}
|
||||
self.display = display
|
||||
self.gradient_stops = gradient_stops
|
||||
self.show_midpoint = show_midpoint
|
||||
self.midpoint_scale = midpoint_scale
|
||||
self.value_min = value_min
|
||||
self.value_max = value_max
|
||||
|
||||
def as_dict(self):
|
||||
return super().as_dict() | prune_dict({
|
||||
"display": self.display,
|
||||
"gradient_stops": self.gradient_stops,
|
||||
"show_midpoint": self.show_midpoint,
|
||||
"midpoint_scale": self.midpoint_scale,
|
||||
"value_min": self.value_min,
|
||||
"value_max": self.value_max,
|
||||
})
|
||||
|
||||
|
||||
@comfytype(io_type="COLOR_CURVES")
|
||||
class ColorCurves(ComfyTypeIO):
|
||||
class ColorCurvesDict(TypedDict):
|
||||
rgb: list[list[float]]
|
||||
red: list[list[float]]
|
||||
green: list[list[float]]
|
||||
blue: list[list[float]]
|
||||
|
||||
Type = ColorCurvesDict
|
||||
|
||||
class Input(WidgetInput):
|
||||
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None,
|
||||
socketless: bool=True, default: dict=None, advanced: bool=None):
|
||||
super().__init__(id, display_name, optional, tooltip, None, default, socketless, None, None, None, None, advanced)
|
||||
if default is None:
|
||||
self.default = {
|
||||
"rgb": [[0, 0], [1, 1]],
|
||||
"red": [[0, 0], [1, 1]],
|
||||
"green": [[0, 0], [1, 1]],
|
||||
"blue": [[0, 0], [1, 1]]
|
||||
}
|
||||
|
||||
def as_dict(self):
|
||||
return super().as_dict()
|
||||
|
||||
|
||||
DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]] = {}
|
||||
def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]):
|
||||
DYNAMIC_INPUT_LOOKUP[io_type] = func
|
||||
@@ -2223,5 +2299,7 @@ __all__ = [
|
||||
"PriceBadgeDepends",
|
||||
"PriceBadge",
|
||||
"BoundingBox",
|
||||
"Curve",
|
||||
"ColorCurves",
|
||||
"NodeReplace",
|
||||
]
|
||||
|
||||
137
comfy_extras/nodes_color_curves.py
Normal file
137
comfy_extras/nodes_color_curves.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from typing_extensions import override
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
from comfy_api.latest import ComfyExtension, io, ui
|
||||
|
||||
|
||||
def _monotone_cubic_hermite(xs, ys, x_query):
|
||||
"""Evaluate monotone cubic Hermite interpolation at x_query points."""
|
||||
n = len(xs)
|
||||
if n == 0:
|
||||
return np.zeros_like(x_query)
|
||||
if n == 1:
|
||||
return np.full_like(x_query, ys[0])
|
||||
|
||||
# Compute slopes
|
||||
deltas = np.diff(ys) / np.maximum(np.diff(xs), 1e-10)
|
||||
|
||||
# Compute tangents (Fritsch-Carlson)
|
||||
slopes = np.zeros(n)
|
||||
slopes[0] = deltas[0]
|
||||
slopes[-1] = deltas[-1]
|
||||
for i in range(1, n - 1):
|
||||
if deltas[i - 1] * deltas[i] <= 0:
|
||||
slopes[i] = 0
|
||||
else:
|
||||
slopes[i] = (deltas[i - 1] + deltas[i]) / 2
|
||||
|
||||
# Enforce monotonicity
|
||||
for i in range(n - 1):
|
||||
if deltas[i] == 0:
|
||||
slopes[i] = 0
|
||||
slopes[i + 1] = 0
|
||||
else:
|
||||
alpha = slopes[i] / deltas[i]
|
||||
beta = slopes[i + 1] / deltas[i]
|
||||
s = alpha ** 2 + beta ** 2
|
||||
if s > 9:
|
||||
t = 3 / np.sqrt(s)
|
||||
slopes[i] = t * alpha * deltas[i]
|
||||
slopes[i + 1] = t * beta * deltas[i]
|
||||
|
||||
# Evaluate
|
||||
result = np.zeros_like(x_query, dtype=np.float64)
|
||||
indices = np.searchsorted(xs, x_query, side='right') - 1
|
||||
indices = np.clip(indices, 0, n - 2)
|
||||
|
||||
for i in range(n - 1):
|
||||
mask = indices == i
|
||||
if not np.any(mask):
|
||||
continue
|
||||
dx = xs[i + 1] - xs[i]
|
||||
if dx == 0:
|
||||
result[mask] = ys[i]
|
||||
continue
|
||||
t = (x_query[mask] - xs[i]) / dx
|
||||
t2 = t * t
|
||||
t3 = t2 * t
|
||||
h00 = 2 * t3 - 3 * t2 + 1
|
||||
h10 = t3 - 2 * t2 + t
|
||||
h01 = -2 * t3 + 3 * t2
|
||||
h11 = t3 - t2
|
||||
result[mask] = h00 * ys[i] + h10 * dx * slopes[i] + h01 * ys[i + 1] + h11 * dx * slopes[i + 1]
|
||||
|
||||
# Clamp edges
|
||||
result[x_query <= xs[0]] = ys[0]
|
||||
result[x_query >= xs[-1]] = ys[-1]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_lut(points):
|
||||
"""Build a 256-entry LUT from curve control points in [0,1] space."""
|
||||
if not points or len(points) < 2:
|
||||
return np.arange(256, dtype=np.float64) / 255.0
|
||||
|
||||
pts = sorted(points, key=lambda p: p[0])
|
||||
xs = np.array([p[0] for p in pts], dtype=np.float64)
|
||||
ys = np.array([p[1] for p in pts], dtype=np.float64)
|
||||
|
||||
x_query = np.linspace(0, 1, 256)
|
||||
lut = _monotone_cubic_hermite(xs, ys, x_query)
|
||||
return np.clip(lut, 0, 1)
|
||||
|
||||
|
||||
class ColorCurvesNode(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ColorCurves",
|
||||
display_name="Color Curves",
|
||||
category="image/adjustment",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.ColorCurves.Input("settings"),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, image: torch.Tensor, settings: dict) -> io.NodeOutput:
|
||||
rgb_pts = settings.get("rgb", [[0, 0], [1, 1]])
|
||||
red_pts = settings.get("red", [[0, 0], [1, 1]])
|
||||
green_pts = settings.get("green", [[0, 0], [1, 1]])
|
||||
blue_pts = settings.get("blue", [[0, 0], [1, 1]])
|
||||
|
||||
rgb_lut = _build_lut(rgb_pts)
|
||||
red_lut = _build_lut(red_pts)
|
||||
green_lut = _build_lut(green_pts)
|
||||
blue_lut = _build_lut(blue_pts)
|
||||
|
||||
# Convert to numpy for LUT application
|
||||
img_np = image.cpu().numpy().copy()
|
||||
|
||||
# Apply per-channel curves then RGB master curve.
|
||||
# Index with floor(val * 256) clamped to [0, 255] to match GPU NEAREST
|
||||
# texture sampling on a 256-wide LUT texture.
|
||||
for ch, ch_lut in enumerate([red_lut, green_lut, blue_lut]):
|
||||
indices = np.clip((img_np[..., ch] * 256).astype(np.int32), 0, 255)
|
||||
img_np[..., ch] = ch_lut[indices]
|
||||
indices = np.clip((img_np[..., ch] * 256).astype(np.int32), 0, 255)
|
||||
img_np[..., ch] = rgb_lut[indices]
|
||||
|
||||
result = torch.from_numpy(np.clip(img_np, 0, 1)).to(image.device, dtype=image.dtype)
|
||||
return io.NodeOutput(result, ui=ui.PreviewImage(result))
|
||||
|
||||
|
||||
class ColorCurvesExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [ColorCurvesNode]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> ColorCurvesExtension:
|
||||
return ColorCurvesExtension()
|
||||
146
nodes.py
146
nodes.py
@@ -2035,6 +2035,144 @@ class ImagePadForOutpaint:
|
||||
return (new_image, mask.unsqueeze(0))
|
||||
|
||||
|
||||
class TestCurveWidget:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"curve": ("CURVE", {"default": [[0, 0], [1, 1]]}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("points",)
|
||||
FUNCTION = "execute"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "testing"
|
||||
|
||||
def execute(self, curve):
|
||||
import json
|
||||
result = json.dumps(curve, indent=2)
|
||||
print("Curve points:", result)
|
||||
return {"ui": {"text": [result]}, "result": (result,)}
|
||||
|
||||
|
||||
class TestRangePlain:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"range": ("RANGE", {"default": {"min": 0.0, "max": 1.0}}),
|
||||
"range_midpoint": ("RANGE", {
|
||||
"default": {"min": 0.2, "max": 0.8, "midpoint": 0.5},
|
||||
"show_midpoint": True,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "testing"
|
||||
|
||||
def execute(self, **kwargs):
|
||||
import json
|
||||
result = json.dumps(kwargs, indent=2)
|
||||
return {"ui": {"text": [result]}, "result": (result,)}
|
||||
|
||||
|
||||
class TestRangeGradient:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"range": ("RANGE", {
|
||||
"default": {"min": 0.0, "max": 1.0},
|
||||
"display": "gradient",
|
||||
"gradient_stops": [
|
||||
{"offset": 0.0, "color": [0, 0, 0]},
|
||||
{"offset": 1.0, "color": [255, 255, 255]}
|
||||
],
|
||||
}),
|
||||
"range_midpoint": ("RANGE", {
|
||||
"default": {"min": 0.0, "max": 1.0, "midpoint": 0.5},
|
||||
"display": "gradient",
|
||||
"gradient_stops": [
|
||||
{"offset": 0.0, "color": [0, 0, 0]},
|
||||
{"offset": 1.0, "color": [255, 255, 255]}
|
||||
],
|
||||
"show_midpoint": True,
|
||||
"midpoint_scale": "gamma",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "testing"
|
||||
|
||||
def execute(self, **kwargs):
|
||||
import json
|
||||
result = json.dumps(kwargs, indent=2)
|
||||
return {"ui": {"text": [result]}, "result": (result,)}
|
||||
|
||||
|
||||
class TestRangeHistogram:
|
||||
RANGE_OPTS = {
|
||||
"display": "histogram",
|
||||
"show_midpoint": True,
|
||||
"midpoint_scale": "gamma",
|
||||
"value_min": 0,
|
||||
"value_max": 255,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
default = {"min": 0, "max": 255, "midpoint": 0.5}
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"rgb": ("RANGE", {"default": {**default}, **s.RANGE_OPTS}),
|
||||
"red": ("RANGE", {"default": {**default}, **s.RANGE_OPTS}),
|
||||
"green": ("RANGE", {"default": {**default}, **s.RANGE_OPTS}),
|
||||
"blue": ("RANGE", {"default": {**default}, **s.RANGE_OPTS}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "testing"
|
||||
|
||||
def execute(self, image, rgb, red, green, blue):
|
||||
import json
|
||||
import numpy as np
|
||||
|
||||
img = image[0].cpu().numpy() # (H, W, C)
|
||||
|
||||
# Per-channel histograms
|
||||
hist_r, _ = np.histogram(img[:, :, 0].flatten(), bins=256, range=(0.0, 1.0))
|
||||
hist_g, _ = np.histogram(img[:, :, 1].flatten(), bins=256, range=(0.0, 1.0))
|
||||
hist_b, _ = np.histogram(img[:, :, 2].flatten(), bins=256, range=(0.0, 1.0))
|
||||
|
||||
# Luminance histogram (BT.709)
|
||||
luminance = 0.2126 * img[:, :, 0] + 0.7152 * img[:, :, 1] + 0.0722 * img[:, :, 2]
|
||||
hist_rgb, _ = np.histogram(luminance.flatten(), bins=256, range=(0.0, 1.0))
|
||||
|
||||
result = json.dumps({"rgb": rgb, "red": red, "green": green, "blue": blue}, indent=2)
|
||||
return {
|
||||
"ui": {
|
||||
"text": [result],
|
||||
"range_histogram_rgb": hist_rgb.astype(np.uint32).tolist(),
|
||||
"range_histogram_red": hist_r.astype(np.uint32).tolist(),
|
||||
"range_histogram_green": hist_g.astype(np.uint32).tolist(),
|
||||
"range_histogram_blue": hist_b.astype(np.uint32).tolist(),
|
||||
},
|
||||
"result": (result,)
|
||||
}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KSampler": KSampler,
|
||||
"CheckpointLoaderSimple": CheckpointLoaderSimple,
|
||||
@@ -2103,6 +2241,10 @@ NODE_CLASS_MAPPINGS = {
|
||||
"ConditioningZeroOut": ConditioningZeroOut,
|
||||
"ConditioningSetTimestepRange": ConditioningSetTimestepRange,
|
||||
"LoraLoaderModelOnly": LoraLoaderModelOnly,
|
||||
"TestCurveWidget": TestCurveWidget,
|
||||
"TestRangePlain": TestRangePlain,
|
||||
"TestRangeGradient": TestRangeGradient,
|
||||
"TestRangeHistogram": TestRangeHistogram,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -2171,6 +2313,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
# _for_testing
|
||||
"VAEDecodeTiled": "VAE Decode (Tiled)",
|
||||
"VAEEncodeTiled": "VAE Encode (Tiled)",
|
||||
"TestCurveWidget": "Test Curve Widget",
|
||||
"TestRangePlain": "Test Range (Plain)",
|
||||
"TestRangeGradient": "Test Range (Gradient)",
|
||||
"TestRangeHistogram": "Test Range (Histogram)",
|
||||
}
|
||||
|
||||
EXTENSION_WEB_DIRS = {}
|
||||
|
||||
Reference in New Issue
Block a user