Fix hue wheel angle alignment and edge snap, add ColorPalette node with 8-color presets

This commit is contained in:
TJ16th
2025-11-10 02:16:07 +09:00
parent fece42dfcc
commit 8996f24649
10 changed files with 433 additions and 10 deletions
+56 -1
View File
@@ -5,7 +5,10 @@
## リポジトリ
- GitHub: https://github.com/TJ16th/TJ_ComfyUI_ColorUtility
## 特長
## ノード一覧
### RGB Color Picker
高精度な RGB/HSV/Alpha カラーピッカー
- 色相環 + 中央 SV 正方形 (ピクセル単位 HSV→RGB 変換により端の純白/純黒を保証)
- アルファバー + RGBA プレビュー (チェック柄背景)
- #RRGGBB と #RRGGBBAA の 2 出力 (互換性維持のため従来 hex_color も提供)
@@ -13,6 +16,14 @@
- HiDPI / スケール対応 (devicePixelRatio 利用)
- 端スナップ (左上=白 / 右下=黒) により色の再現性を確保
### Color Palette
8色のプリセットカラーパレット
- 入力: `preset` (primary, pastel, gansai, monochrome など)
- 出力: 8個の #RRGGBB 文字列 (`color_1` ~ `color_8`)
- ノード下部に8色のカラーチップを表示
- プリセット変更で即座にチップ更新
- カスタムパレット対応 (`palettes/` に JSON 配置)
## インストール
1. ComfyUI の `custom_nodes` ディレクトリへ本リポジトリを配置 (もしくは git clone)。
```powershell
@@ -44,10 +55,54 @@ git pull --ff-only
> メモ: 上記画像は `docs/overview.png` を参照しています。お手元の環境で画像を差し替える場合は、`docs/overview.png` にスクリーンショットを配置してください。
## カスタムパレットの作成
`palettes/` フォルダに新しい JSON ファイルを配置することで、独自のカラーパレットを追加できます。
**フォーマット**
```json
{
"name": "My Custom Palette",
"colors": [
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFFFFF",
"#000000"
]
}
```
- `colors` 配列は **8色** を含める必要があります。
- 8色未満の場合は `#000000` で埋められます。
- ファイル名 (拡張子を除く) がプリセット名として表示されます。
**例: `palettes/sunset.json`**
```json
{
"name": "Sunset Gradient",
"colors": [
"#FFD700",
"#FFB347",
"#FF8C00",
"#FF4500",
"#DC143C",
"#8B0000",
"#4B0000",
"#000000"
]
}
```
ComfyUI を再起動すると、ColorPalette ノードの `preset` ドロップダウンに反映されます。
## アップデート計画 (例)
- キーボード操作対応
- 可変リング太さ/サイズ設定
- HSV / HSL 数値ウィジェット追加
- カラーパレットのグラデーション生成機能
## ライセンス
本リポジトリは MIT License です。詳細は `LICENSE` を参照してください。
+3
View File
@@ -1,11 +1,14 @@
from .rgb_color_picker import RGBColorPicker
from .color_palette import ColorPalette
NODE_CLASS_MAPPINGS = {
"RGBColorPicker": RGBColorPicker,
"ColorPalette": ColorPalette,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RGBColorPicker": "RGB Color Picker",
"ColorPalette": "Color Palette",
}
# Serve frontend JS from the js/ folder
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Tuple
class ColorPalette:
"""
ComfyUI custom node: ColorPalette
- Input: preset (選択式: primary, pastel, gansai, monochrome, またはカスタム)
- Output: color_1 ~ color_16 (各 #RRGGBB)
- Category: TJnodes/color
パレットは palettes/ フォルダの JSON から読み込み。
ユーザーは独自 JSON を追加可能。
"""
def __init__(self):
self.palettes_dir = Path(__file__).parent / "palettes"
self._palette_cache = {}
@classmethod
def INPUT_TYPES(cls):
# palettes/ から利用可能なプリセットを列挙
instance = cls()
presets = instance.list_presets()
return {
"required": {
"preset": (presets, {"default": presets[0] if presets else "primary"}),
}
}
RETURN_TYPES = tuple(["STRING"] * 8)
RETURN_NAMES = tuple([f"color_{i+1}" for i in range(8)])
FUNCTION = "get_palette"
CATEGORY = "TJnodes/color"
def list_presets(self) -> list[str]:
"""palettes/ 内の .json ファイルから利用可能なプリセット名を返す"""
if not self.palettes_dir.exists():
return ["primary"]
presets = []
for file in self.palettes_dir.glob("*.json"):
presets.append(file.stem)
return sorted(presets) if presets else ["primary"]
def load_palette(self, preset_name: str) -> list[str]:
"""指定プリセットの色リストを返す。キャッシュ利用。"""
if preset_name in self._palette_cache:
return self._palette_cache[preset_name]
palette_file = self.palettes_dir / f"{preset_name}.json"
if not palette_file.exists():
# フォールバック: 基本8色 (モノクログラデーション)
fallback = [f"#{i*32:02X}{i*32:02X}{i*32:02X}" for i in range(8)]
return fallback
try:
with open(palette_file, "r", encoding="utf-8") as f:
data = json.load(f)
colors = data.get("colors", [])
# 8色に満たない場合は #000000 で埋める
while len(colors) < 8:
colors.append("#000000")
# 8色超過は切り捨て
colors = colors[:8]
self._palette_cache[preset_name] = colors
return colors
except Exception as e:
print(f"[ColorPalette] Error loading {preset_name}: {e}")
# エラー時フォールバック
return [f"#{i*32:02X}{i*32:02X}{i*32:02X}" for i in range(8)]
def get_palette(self, preset: str) -> Tuple[str, ...]:
"""プリセットから16色を取得して返す"""
colors = self.load_palette(preset)
return tuple(colors)
+149
View File
@@ -0,0 +1,149 @@
// ColorPalette extension for ComfyUI
// Displays 16 color chips below the node based on selected preset
import { app } from "/scripts/app.js";
const EXT_NAME = "Comfy.TJnodes.ColorPalette";
console.log(`[${EXT_NAME}] loading`);
function createChipsContainer() {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.zIndex = "100";
container.style.display = "grid";
container.style.gridTemplateColumns = "repeat(8, 24px)";
container.style.gap = "4px";
container.style.pointerEvents = "none";
container.style.transformOrigin = "top left";
// 8 chips
const chips = [];
for (let i = 0; i < 8; i++) {
const chip = document.createElement("div");
chip.style.width = "24px";
chip.style.height = "24px";
chip.style.borderRadius = "4px";
chip.style.border = "1px solid #fff";
chip.style.background = "#000";
container.appendChild(chip);
chips.push(chip);
}
const canvas = app.canvas?.canvas;
const canvasContainer = canvas?.parentElement || document.body;
canvasContainer.appendChild(container);
return { container, chips };
}
function positionChips(node, container) {
const ds = app.canvas?.ds;
const canvas = app.canvas?.canvas;
if (!ds || !canvas) return;
const rect = canvas.getBoundingClientRect();
const scale = ds.scale || 1;
const [offX, offY] = ds.offset || [0, 0];
const nodeX = (node.pos[0] + offX) * scale + rect.left;
const nodeY = (node.pos[1] + offY) * scale + rect.top;
const gap = 8;
const x = nodeX;
const y = nodeY + node.size[1] * scale + gap;
container.style.left = `${x}px`;
container.style.top = `${y}px`;
container.style.transform = `scale(${scale})`;
}
function updateChips(node, chips) {
// node.widgets から各 color_N の値を取得して chip に反映
// ColorPalette ノードは preset widget のみなので、出力値を widgets に保持する必要あり
// → onExecuted で outputs から色を取得
if (!node.__palette_colors__) {
// デフォルト: グレー
for (let i = 0; i < 8; i++) {
chips[i].style.background = `#${(i * 32).toString(16).padStart(2, '0').repeat(3)}`;
}
return;
}
const colors = node.__palette_colors__;
for (let i = 0; i < 8 && i < colors.length; i++) {
chips[i].style.background = colors[i];
}
}
function attachPaletteUI(node) {
if (node.__palette_ui__) return node.__palette_ui__;
const { container, chips } = createChipsContainer();
updateChips(node, chips);
node.__palette_ui__ = { container, chips };
return node.__palette_ui__;
}
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name !== "ColorPalette") return;
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx) {
const r = onDrawForeground?.apply(this, arguments);
const ui = attachPaletteUI(this);
positionChips(this, ui.container);
updateChips(this, ui.chips);
return r;
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated?.apply(this, arguments);
attachPaletteUI(this);
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
const r = onExecuted?.apply(this, arguments);
// outputs から8色を取得してキャッシュ
if (message && Array.isArray(message)) {
// message は [color_1, color_2, ..., color_8] 形式
this.__palette_colors__ = message.slice(0, 8);
if (this.__palette_ui__) {
updateChips(this, this.__palette_ui__.chips);
}
}
return r;
};
// プリセット widget 変更時にも色をリロード (即座に反映)
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function() {
const r = onConfigure?.apply(this, arguments);
// preset widget から現在値を取得してサーバーに問い合わせずローカル再描画
// (実際はサーバー実行後に onExecuted で更新されるが、ここでは初期表示をクリア)
const presetWidget = this.widgets?.find(w => w.name === "preset");
if (presetWidget && presetWidget.value) {
// プリセット変更時は一旦リセット (実行後に正しい色が反映される)
this.__palette_colors__ = null;
if (this.__palette_ui__) {
updateChips(this, this.__palette_ui__.chips);
}
}
return r;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
const r = onRemoved?.apply(this, arguments);
if (this.__palette_ui__) {
try { this.__palette_ui__.container.remove(); } catch {}
this.__palette_ui__ = null;
}
return r;
};
}
});
+21 -9
View File
@@ -134,7 +134,7 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) {
ctx.closePath();
ctx.fill('evenodd');
} else {
// Fallback: coarse pixel method
// Fallback: coarse pixel method (also align with -90° offset)
const image = ctx.createImageData(w, h);
for (let yy = 0; yy < h; yy++) {
for (let xx = 0; xx < w; xx++) {
@@ -143,7 +143,8 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) {
const idx = (yy*w + xx) * 4;
if (dist <= outer && dist >= inner) {
let ang = Math.atan2(dy, dx);
let hdeg = ang * 180 / Math.PI; if (hdeg < 0) hdeg += 360;
let hdeg = ang * 180 / Math.PI - 90; // align with conic gradient start
if (hdeg < 0) hdeg += 360;
const [rr, gg, bb] = hsvToRgb(hdeg, 1, 1);
image.data[idx] = rr; image.data[idx+1] = gg; image.data[idx+2] = bb; image.data[idx+3] = 255;
} else {
@@ -194,9 +195,11 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) {
// Markers
const H = markers?.h ?? hueDeg; const S = markers?.s ?? s; const V = markers?.v ?? v;
// hue marker in ring centerline
// H is already the canvas angle (from atan2 + 90), so subtract 90 to get back to standard angle
const hr = inner + ringThickness / 2;
const hx = cx + hr * Math.cos(H * Math.PI / 180);
const hy = cy + hr * Math.sin(H * Math.PI / 180);
const hAngleRad = (H - 90) * Math.PI / 180;
const hx = cx + hr * Math.cos(hAngleRad);
const hy = cy + hr * Math.sin(hAngleRad);
ctx.save(); ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(hx, hy, 6, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
// sv marker
const svX = topLeftX + S * side;
@@ -307,7 +310,11 @@ function attachPicker(node) {
const dist = Math.sqrt(dx * dx + dy * dy);
// Hue ring region
if (dist <= geometry.outer && dist >= geometry.inner) {
let ang = Math.atan2(dy, dx) * 180 / Math.PI; if (ang < 0) ang += 360;
// atan2 gives angle from right (0°), conic gradient starts at top (-90°)
// Canvas coord: right=0°, down=90°, left=180°, up=270°
// Color wheel: up=0° (red), so add 90° to convert canvas angle to hue
let ang = Math.atan2(dy, dx) * 180 / Math.PI + 90;
if (ang < 0) ang += 360;
h = ang;
updatePreview();
return true;
@@ -317,22 +324,27 @@ function attachPicker(node) {
x >= geometry.topLeftX && x <= geometry.topLeftX + geometry.side &&
y >= geometry.topLeftY && y <= geometry.topLeftY + geometry.side
) {
// relX, relY: 0..1 の相対座標
const relX = (x - geometry.topLeftX) / geometry.side;
const relY = (y - geometry.topLeftY) / geometry.side;
// ピクセル基準の端スナップ(拡大縮小やHiDPIでも安定)
const snapPx = Math.max(2, geometry.side * 0.01); // 最小2px or 1%
// ピクセル基準の端スナップ(scale正規化済み座標系で判定)
const snapPx = Math.max(2 / scale, geometry.side * 0.01); // 最小2px(画面座標)or 1%
const leftDist = x - geometry.topLeftX;
const rightDist = (geometry.topLeftX + geometry.side) - x;
const topDist = y - geometry.topLeftY;
const bottomDist = (geometry.topLeftY + geometry.side) - y;
// s (0..1)
// s (0..1): 左端=0, 右端=1
if (leftDist <= snapPx) s = 0;
else if (rightDist <= snapPx) s = 1;
else s = clamp(relX, 0, 1);
// v (0..1) 上=1, 下=0
// v (0..1): 上=1, 下=0
if (topDist <= snapPx) v = 1;
else if (bottomDist <= snapPx) v = 0;
else v = 1 - clamp(relY, 0, 1);
updatePreview();
return true;
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Gansai (Japanese Watercolor)",
"colors": [
"#D32F2F",
"#F57C00",
"#FBC02D",
"#689F38",
"#00ACC1",
"#1976D2",
"#512DA8",
"#5D4037"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Monochrome Tones",
"colors": [
"#FFFFFF",
"#D0D0D0",
"#A0A0A0",
"#808080",
"#606060",
"#404040",
"#202020",
"#000000"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Pastel Colors",
"colors": [
"#FFB3BA",
"#FFDFBA",
"#FFFFBA",
"#BAFFC9",
"#BAE1FF",
"#D4BAFF",
"#FFBAF3",
"#FFE5E5"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Primary Colors",
"colors": [
"#FF0000",
"#FFFF00",
"#00FF00",
"#00FFFF",
"#0000FF",
"#FF00FF",
"#FFFFFF",
"#000000"
]
}
+72
View File
@@ -0,0 +1,72 @@
import unittest
import json
from pathlib import Path
from color_palette import ColorPalette
class TestColorPalette(unittest.TestCase):
def setUp(self):
self.node = ColorPalette()
def test_list_presets(self):
"""palettes/ に存在するプリセット一覧を取得"""
presets = self.node.list_presets()
self.assertIsInstance(presets, list)
self.assertIn("primary", presets)
self.assertIn("pastel", presets)
self.assertIn("gansai", presets)
self.assertIn("monochrome", presets)
def test_load_primary(self):
"""primary.json の8色が正しく読み込まれる"""
colors = self.node.load_palette("primary")
self.assertEqual(len(colors), 8)
self.assertTrue(all(c.startswith("#") for c in colors))
self.assertEqual(colors[0], "#FF0000") # red
self.assertEqual(colors[6], "#FFFFFF") # white
def test_load_monochrome(self):
"""monochrome.json のグラデーションが正しい"""
colors = self.node.load_palette("monochrome")
self.assertEqual(len(colors), 8)
self.assertEqual(colors[0], "#FFFFFF") # white start
self.assertEqual(colors[7], "#000000") # black end
def test_get_palette_output(self):
"""get_palette が8要素のタプルを返す"""
result = self.node.get_palette("primary")
self.assertIsInstance(result, tuple)
self.assertEqual(len(result), 8)
self.assertTrue(all(isinstance(c, str) for c in result))
def test_nonexistent_preset_fallback(self):
"""存在しないプリセットはフォールバックで8色を返す"""
colors = self.node.load_palette("nonexistent")
self.assertEqual(len(colors), 8)
self.assertTrue(all(c.startswith("#") for c in colors))
def test_custom_palette(self):
"""カスタムパレット (custom.json) を動的作成して検証"""
custom_path = self.node.palettes_dir / "custom.json"
custom_data = {
"name": "Custom Test",
"colors": ["#AAAAAA"] * 8
}
try:
with open(custom_path, "w", encoding="utf-8") as f:
json.dump(custom_data, f)
# キャッシュをクリア
self.node._palette_cache.clear()
colors = self.node.load_palette("custom")
self.assertEqual(len(colors), 8)
self.assertEqual(colors[0], "#AAAAAA")
finally:
if custom_path.exists():
custom_path.unlink()
if __name__ == "__main__":
unittest.main()