From 3f7eed022cde291c7f3a96fba43c56278933d598 Mon Sep 17 00:00:00 2001 From: Hakoniwa <852wa.hakoniwa@gmail.com> Date: Sat, 26 Sep 2026 00:29:28 +0000 Subject: [PATCH 1/2] Normalize nodes.py line endings to LF The file mixed CRLF and LF lines. No content change (git diff -w is empty). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5PqyHqy9udCb9y3WWmeTx --- nodes.py | 436 +++++++++++++++++++++++++++---------------------------- 1 file changed, 218 insertions(+), 218 deletions(-) diff --git a/nodes.py b/nodes.py index 41bc2fd..384092d 100644 --- a/nodes.py +++ b/nodes.py @@ -1,9 +1,9 @@ -import json -import random -import numpy as np -import torch -import torch.nn.functional as F -from PIL import Image, ImageDraw, ImageFont +import json +import random +import numpy as np +import torch +import torch.nn.functional as F +from PIL import Image, ImageDraw, ImageFont from sklearn.cluster import KMeans @@ -69,28 +69,28 @@ def _color_triplet(value, palette, name): class ColorshiftColorNode: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "images": ("IMAGE",), - "color_count": ("INT", {"default": 8, "min": 2, "max": 64}), - }, - "optional": { - "lock_masks": ("MASK", {"default": None}), - "palette_override": ("PALETTE", {"default": None}), - "font_size": ("INT", {"default": 20, "min": 10, "max": 50}), - "sampling_rate": ("FLOAT", {"default": 0.25, "min": 0.01, "max": 0.5}), + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "color_count": ("INT", {"default": 8, "min": 2, "max": 64}), + }, + "optional": { + "lock_masks": ("MASK", {"default": None}), + "palette_override": ("PALETTE", {"default": None}), + "font_size": ("INT", {"default": 20, "min": 10, "max": 50}), + "sampling_rate": ("FLOAT", {"default": 0.25, "min": 0.01, "max": 0.5}), "n_init": ("INT", {"default": 3, "min": 1, "max": 10}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), - }, - } - - RETURN_TYPES = ("IMAGE", "PALETTE", "MASK", "IMAGE") - RETURN_NAMES = ("images", "palette", "index_maps", "palette_preview") - FUNCTION = "process" - CATEGORY = "Image/Color" - + }, + } + + RETURN_TYPES = ("IMAGE", "PALETTE", "MASK", "IMAGE") + RETURN_NAMES = ("images", "palette", "index_maps", "palette_preview") + FUNCTION = "process" + CATEGORY = "Image/Color" + @torch.no_grad() def process(self, images, color_count, lock_masks=None, palette_override=None, font_size=20, sampling_rate=0.25, n_init=3, seed=0): _validate_images(images) @@ -125,21 +125,21 @@ def process(self, images, color_count, lock_masks=None, palette_override=None, f centers = kmeans.cluster_centers_ palette = torch.as_tensor(centers, device=images.device, dtype=torch.float32).clamp(0, 1) labels = self._match_palette_batch(pixels, palette) - - # ----- パレットの並び順を各クラスタの占有率が高い順に変更 ----- - # 全ラベルを1次元にまとめ、各クラスタの出現回数を計算 - all_labels = labels.flatten() + + # ----- パレットの並び順を各クラスタの占有率が高い順に変更 ----- + # 全ラベルを1次元にまとめ、各クラスタの出現回数を計算 + all_labels = labels.flatten() counts = torch.bincount(all_labels, minlength=len(palette)) - # 出現回数の降順に並べ替えたときの各クラスタの元のインデックス + # 出現回数の降順に並べ替えたときの各クラスタの元のインデックス sorted_order = torch.argsort(counts, descending=True, stable=True) - # mapping: 旧インデックス -> 新インデックス を作成 - mapping = torch.empty_like(sorted_order) - mapping[sorted_order] = torch.arange(sorted_order.size(0), device=sorted_order.device) - # パレットを並び替え、各画素のラベルを新しい順序に置換 - palette = palette[sorted_order] - labels = mapping[labels] - # --------------------------------------------------------------- - + # mapping: 旧インデックス -> 新インデックス を作成 + mapping = torch.empty_like(sorted_order) + mapping[sorted_order] = torch.arange(sorted_order.size(0), device=sorted_order.device) + # パレットを並び替え、各画素のラベルを新しい順序に置換 + palette = palette[sorted_order] + labels = mapping[labels] + # --------------------------------------------------------------- + preview_img = self.generate_palette_preview(palette, font_size) index_maps = labels.reshape(batch_size, height, width) processed_images_tensor = palette[labels].reshape(images.shape).to(images.dtype) @@ -157,40 +157,40 @@ def process(self, images, color_count, lock_masks=None, palette_override=None, f masks = masks[batch_indices].clamp(0, 1).unsqueeze(-1).to(images.dtype) processed_images_tensor = images * masks + processed_images_tensor * (1 - masks) return (processed_images_tensor, palette, index_maps, preview_img) - - def generate_palette_preview(self, palette, font_size): + + def generate_palette_preview(self, palette, font_size): if isinstance(palette, torch.Tensor): colors = palette.detach().float().cpu().numpy() - else: - colors = palette - - patch_size = 100 - cols = 8 - rows = (len(colors) + cols - 1) // cols - - img = Image.new("RGB", (cols * patch_size, rows * patch_size), (40, 40, 40)) - draw = ImageDraw.Draw(img) - - try: - font = ImageFont.truetype("arial.ttf", font_size) - except OSError: - font = ImageFont.load_default() - - for i, color in enumerate(colors): - x = (i % cols) * patch_size - y = (i // cols) * patch_size - + else: + colors = palette + + patch_size = 100 + cols = 8 + rows = (len(colors) + cols - 1) // cols + + img = Image.new("RGB", (cols * patch_size, rows * patch_size), (40, 40, 40)) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("arial.ttf", font_size) + except OSError: + font = ImageFont.load_default() + + for i, color in enumerate(colors): + x = (i % cols) * patch_size + y = (i // cols) * patch_size + draw.rectangle([x, y, x + patch_size - 1, y + patch_size - 1], fill=tuple((color * 255).clip(0, 255).astype(int))) - text = str(i) - bbox = draw.textbbox((x, y), text, font=font) - text_w = bbox[2] - bbox[0] - text_h = bbox[3] - bbox[1] + text = str(i) + bbox = draw.textbbox((x, y), text, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] text_color = "black" if np.dot(color, [0.2126, 0.7152, 0.0722]) > 0.5 else "white" draw.text((x + (patch_size - text_w) // 2 - bbox[0], y + (patch_size - text_h) // 2 - bbox[1]), text, fill=text_color, font=font) - - img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0) - return img_tensor - + + img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0) + return img_tensor + def _match_palette_batch(self, pixels, palette): # Bound the temporary distance matrix, including for large override palettes. flat_pixels = pixels.reshape(-1, 3) @@ -202,91 +202,91 @@ def _match_palette_batch(self, pixels, palette): distances = torch.cdist(chunk, palette, compute_mode="donot_use_mm_for_euclid_dist") labels[start:start + chunk_size] = distances.argmin(dim=-1) return labels.reshape(pixels.shape[:-1]) - -class PaletteEditorNode: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "images": ("IMAGE",), - "palette": ("PALETTE",), - "index_maps": ("MASK",), - }, - "optional": { - "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), - "hue_random_enable": ("BOOLEAN", {"default": False}), - "hue_shift": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), - "saturation_random_enable": ("BOOLEAN", {"default": False}), - "saturation_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0}), - "value_random_enable": ("BOOLEAN", {"default": False}), - "value_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0}), - "lock_color_num": ("STRING", {"default": "0,", "placeholder": "例: 0,1,2,3,4"}), - "mask_enable": ("BOOLEAN", {"default": False}), - "invert_mask": ("BOOLEAN", {"default": False}), - "operations": ("STRING", {"default": "[]", "multiline": True}), - }, - } - - RETURN_TYPES = ("IMAGE", "PALETTE", "MASK") - RETURN_NAMES = ("images", "palette", "mask") - FUNCTION = "process_palette" - CATEGORY = "Image/Color" - - def rgb_to_hsv(self, rgb): - r, g, b = rgb.unbind(-1) - max_rgb, argmax_rgb = rgb.max(-1) - min_rgb, _ = rgb.min(-1) - delta = max_rgb - min_rgb - - h = torch.zeros_like(r) - s = torch.zeros_like(r) - v = max_rgb - - non_zero_delta = delta != 0 - h[non_zero_delta] = torch.where( - argmax_rgb[non_zero_delta] == 0, - (g[non_zero_delta] - b[non_zero_delta]) / delta[non_zero_delta], - torch.where( - argmax_rgb[non_zero_delta] == 1, - 2 + (b[non_zero_delta] - r[non_zero_delta]) / delta[non_zero_delta], - 4 + (r[non_zero_delta] - g[non_zero_delta]) / delta[non_zero_delta], - ), - ) - h = (h / 6.0) % 1.0 - s[non_zero_delta] = delta[non_zero_delta] / max_rgb[non_zero_delta] - - return torch.stack((h, s, v), dim=-1) - - def hsv_to_rgb(self, hsv): - h, s, v = hsv.unbind(-1) - - c = v * s - x = c * (1 - torch.abs((h * 6) % 2 - 1)) - m = v - c - - rgb_prime = torch.zeros_like(hsv) - - h_category = (h * 6).long() % 6 - - rgb_prime[..., 0] = torch.where( - (h_category == 0) | (h_category == 5), - c, - torch.where((h_category == 1) | (h_category == 4), x, 0), - ) - rgb_prime[..., 1] = torch.where( - (h_category == 1) | (h_category == 2), - c, - torch.where((h_category == 0) | (h_category == 3), x, 0), - ) - rgb_prime[..., 2] = torch.where( - (h_category == 3) | (h_category == 4), - c, - torch.where((h_category == 2) | (h_category == 5), x, 0), - ) - - rgb = rgb_prime + m[..., None] - return rgb - + +class PaletteEditorNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "palette": ("PALETTE",), + "index_maps": ("MASK",), + }, + "optional": { + "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), + "hue_random_enable": ("BOOLEAN", {"default": False}), + "hue_shift": ("FLOAT", {"default": 0.0, "min": -180.0, "max": 180.0}), + "saturation_random_enable": ("BOOLEAN", {"default": False}), + "saturation_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0}), + "value_random_enable": ("BOOLEAN", {"default": False}), + "value_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0}), + "lock_color_num": ("STRING", {"default": "0,", "placeholder": "例: 0,1,2,3,4"}), + "mask_enable": ("BOOLEAN", {"default": False}), + "invert_mask": ("BOOLEAN", {"default": False}), + "operations": ("STRING", {"default": "[]", "multiline": True}), + }, + } + + RETURN_TYPES = ("IMAGE", "PALETTE", "MASK") + RETURN_NAMES = ("images", "palette", "mask") + FUNCTION = "process_palette" + CATEGORY = "Image/Color" + + def rgb_to_hsv(self, rgb): + r, g, b = rgb.unbind(-1) + max_rgb, argmax_rgb = rgb.max(-1) + min_rgb, _ = rgb.min(-1) + delta = max_rgb - min_rgb + + h = torch.zeros_like(r) + s = torch.zeros_like(r) + v = max_rgb + + non_zero_delta = delta != 0 + h[non_zero_delta] = torch.where( + argmax_rgb[non_zero_delta] == 0, + (g[non_zero_delta] - b[non_zero_delta]) / delta[non_zero_delta], + torch.where( + argmax_rgb[non_zero_delta] == 1, + 2 + (b[non_zero_delta] - r[non_zero_delta]) / delta[non_zero_delta], + 4 + (r[non_zero_delta] - g[non_zero_delta]) / delta[non_zero_delta], + ), + ) + h = (h / 6.0) % 1.0 + s[non_zero_delta] = delta[non_zero_delta] / max_rgb[non_zero_delta] + + return torch.stack((h, s, v), dim=-1) + + def hsv_to_rgb(self, hsv): + h, s, v = hsv.unbind(-1) + + c = v * s + x = c * (1 - torch.abs((h * 6) % 2 - 1)) + m = v - c + + rgb_prime = torch.zeros_like(hsv) + + h_category = (h * 6).long() % 6 + + rgb_prime[..., 0] = torch.where( + (h_category == 0) | (h_category == 5), + c, + torch.where((h_category == 1) | (h_category == 4), x, 0), + ) + rgb_prime[..., 1] = torch.where( + (h_category == 1) | (h_category == 2), + c, + torch.where((h_category == 0) | (h_category == 3), x, 0), + ) + rgb_prime[..., 2] = torch.where( + (h_category == 3) | (h_category == 4), + c, + torch.where((h_category == 2) | (h_category == 5), x, 0), + ) + + rgb = rgb_prime + m[..., None] + return rgb + @torch.no_grad() def process_palette(self, images, palette, index_maps, seed=0, hue_random_enable=False, hue_shift=0.0, saturation_random_enable=False, saturation_scale=1.0, value_random_enable=False, value_scale=1.0, lock_color_num="0,", mask_enable=False, invert_mask=False, operations="[]"): palette, index_maps = _prepare_indexed_inputs(images, palette, index_maps) @@ -305,22 +305,22 @@ def process_palette(self, images, palette, index_maps, seed=0, hue_random_enable current_hue = rng.uniform(-180, 180) if hue_random_enable else hue_shift current_saturation = rng.uniform(0, 5) if saturation_random_enable else saturation_scale current_value = rng.uniform(0, 5) if value_random_enable else value_scale - - modified_palette = self.edit_palette(palette, operations, color_mask, current_hue, current_saturation, current_value) - + + modified_palette = self.edit_palette(palette, operations, color_mask, current_hue, current_saturation, current_value) + pixel_mask = color_mask[index_maps] new_pixels = modified_palette[index_maps].to(images.dtype) processed_images = torch.where(pixel_mask.unsqueeze(-1), images, new_pixels) return (processed_images, modified_palette, pixel_mask.float()) - + def edit_palette(self, palette, operations, mask, hue_shift, saturation_scale, value_scale): _validate_palette(palette) palette = palette.clone() mask = mask.to(device=palette.device, dtype=torch.bool) hsv_palette = self.rgb_to_hsv(palette) - - hsv_palette[~mask, 0] = (hsv_palette[~mask, 0] + hue_shift / 360.0) % 1.0 - hsv_palette[~mask, 1] = torch.clamp(hsv_palette[~mask, 1] * saturation_scale, 0, 1) + + hsv_palette[~mask, 0] = (hsv_palette[~mask, 0] + hue_shift / 360.0) % 1.0 + hsv_palette[~mask, 1] = torch.clamp(hsv_palette[~mask, 1] * saturation_scale, 0, 1) hsv_palette[~mask, 2] = torch.clamp(hsv_palette[~mask, 2] * value_scale, 0, 1) modified_palette = self.hsv_to_rgb(hsv_palette).clamp(0, 1) @@ -337,26 +337,26 @@ def edit_palette(self, palette, operations, mask, hue_shift, saturation_scale, v modified_palette[idx] = self.hsv_to_rgb(hsv.reshape(1, 3))[0] return modified_palette - -class CsCFill: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "images": ("IMAGE",), - "palette": ("PALETTE",), - "index_maps": ("MASK",), - }, - "optional": { - "operations": ("STRING", {"default": "[]", "multiline": True}), - }, - } - - RETURN_TYPES = ("IMAGE", "IMAGE") - RETURN_NAMES = ("filled_image", "mask_image") - FUNCTION = "process" - CATEGORY = "Image/Color" - + +class CsCFill: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "palette": ("PALETTE",), + "index_maps": ("MASK",), + }, + "optional": { + "operations": ("STRING", {"default": "[]", "multiline": True}), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("filled_image", "mask_image") + FUNCTION = "process" + CATEGORY = "Image/Color" + @torch.no_grad() def process(self, images, palette, index_maps, operations="[]"): palette, index_maps = _prepare_indexed_inputs(images, palette, index_maps) @@ -380,41 +380,41 @@ def process(self, images, palette, index_maps, operations="[]"): mask_image[..., :3] = torch.where(selected.unsqueeze(-1), palette[index_maps].to(images.dtype), 0) mask_image[..., 3] = selected.to(images.dtype) return (filled_image, mask_image) - - def _compute_auto_pairs(self, palette): - palette_np = palette.detach().cpu().numpy() - n = palette_np.shape[0] - pair_list = [] - for i in range(n): - for j in range(i + 1, n): - dist = np.linalg.norm(palette_np[i] - palette_np[j]) - pair_list.append((i, j, dist)) - pair_list.sort(key=lambda x: x[2]) - - paired = set() - auto_pairs = [] - for i, j, dist in pair_list: - if i in paired or j in paired: - continue - brightness_i = palette_np[i].sum() - brightness_j = palette_np[j].sum() - if brightness_i >= brightness_j: - auto_pairs.append((i, j)) - else: - auto_pairs.append((j, i)) - paired.add(i) - paired.add(j) - return auto_pairs - - -NODE_CLASS_MAPPINGS = { - "ColorshiftColor": ColorshiftColorNode, - "CsCPaletteEditor": PaletteEditorNode, - "CsCFill": CsCFill, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "ColorshiftColor": "ColorshiftColor", - "CsCPaletteEditor": "CsCPaletteEditor", - "CsCFill": "CsCFill", -} + + def _compute_auto_pairs(self, palette): + palette_np = palette.detach().cpu().numpy() + n = palette_np.shape[0] + pair_list = [] + for i in range(n): + for j in range(i + 1, n): + dist = np.linalg.norm(palette_np[i] - palette_np[j]) + pair_list.append((i, j, dist)) + pair_list.sort(key=lambda x: x[2]) + + paired = set() + auto_pairs = [] + for i, j, dist in pair_list: + if i in paired or j in paired: + continue + brightness_i = palette_np[i].sum() + brightness_j = palette_np[j].sum() + if brightness_i >= brightness_j: + auto_pairs.append((i, j)) + else: + auto_pairs.append((j, i)) + paired.add(i) + paired.add(j) + return auto_pairs + + +NODE_CLASS_MAPPINGS = { + "ColorshiftColor": ColorshiftColorNode, + "CsCPaletteEditor": PaletteEditorNode, + "CsCFill": CsCFill, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ColorshiftColor": "ColorshiftColor", + "CsCPaletteEditor": "CsCPaletteEditor", + "CsCFill": "CsCFill", +} From 90e1371b7617478a968e4ff1a872fa9d094c9ad2 Mon Sep 17 00:00:00 2001 From: Hakoniwa <852wa.hakoniwa@gmail.com> Date: Sat, 26 Sep 2026 00:30:02 +0000 Subject: [PATCH 2/2] Fix palette preview labels stacking on patch 0; honor font_size on macOS/Linux - textbbox was measured at the patch position and that position was then subtracted again, so every number was drawn inside patch 0. Measure at the origin and subtract only the glyph offset. - Fall back to DejaVu Sans / Helvetica, then Pillow's scalable default font, so font_size works where Arial is not installed. - Add regression tests for label placement and font size. - Add the Linux/Windows CPU test workflow that the README already describes. - Add .gitignore for __pycache__, bump version to 1.2.1, update README and VALIDATION.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5PqyHqy9udCb9y3WWmeTx --- .github/workflows/tests.yml | 33 +++++++++++++++++++++++++++++++++ .gitignore | 2 ++ README.md | 2 ++ VALIDATION.md | 19 +++++++++++++++++++ nodes.py | 28 ++++++++++++++++++++-------- pyproject.toml | 2 +- tests/test_nodes.py | 16 ++++++++++++++++ 7 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1d177b9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: CPU tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Install dependencies + run: | + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + python -m pip install numpy scikit-learn Pillow + - name: Run tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 5acf49b..56b8f65 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ python -m pip install -r requirements.txt - seedの無視、他の処理への乱数状態の干渉、空間マスクの実行エラー、少数画素・少数色での減色失敗、GPUとCPUの混在によるエラーを修正しました。 - 最近傍色の検索を分割し、通常のパレットでは一時的な距離行列を最大約16MiBに抑えました。塗りつぶしも色の対応表を使って一括処理します。 - 明るいパレット色の番号を黒文字にし、番号の位置を調整しました。 +- パレットプレビューで全色の番号が左上の1マスに重なって描画される不具合を修正しました。Arialがない環境(macOS / Linux)でも `font_size` が反映されます。 - 不正なJSON、色番号、色成分、番号マップは理由付きエラーで知らせます。以前のような黙った無視や、無効な手動指定から意図しない自動塗りつぶしへの切り替えは行いません。 ノード名、既存の入出力端子、既存ウィジェットの順序は維持しています。新しい減色seedを省略した既存の呼び出しでは0を使います。抽出方法と乱数処理の修正により、旧版と減色結果・パレット番号が変わることがあります。プレビューを確認し、必要なら保護色・手動ペアの番号を更新してください。 @@ -81,5 +82,6 @@ python -m unittest discover -s tests -v ComfyUI本体なしで実行できます。CUDAが利用できる環境ではGPU・float16・bfloat16も検証し、それ以外ではGPUテストのみスキップします。GitHub ActionsにLinux / WindowsのCPUテストを追加しています。 ## update +26.09.26 - パレットプレビューの番号表示を修正(全番号が0番のマスに重なっていた)、macOS/Linuxでのフォントサイズ対応、テスト用GitHub Actionsを追加 25.02.01 - mask機能の修正、CsCノードの高速化、exampleデータの見直し 25.02.01 - パレットインデックス順を画面占有率でソート、塗りつぶしと影分離のノードを追加、exampleの見直し diff --git a/VALIDATION.md b/VALIDATION.md index 4cf2162..1f22696 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -44,3 +44,22 @@ ComfyUI本体の画面操作、動画デコード・エンコードを含むワ 導入用ZIPには `ComfyUI-ColorshiftColor` フォルダーが入っています。既存版をバックアップしたうえでComfyUIの `custom_nodes` 配下へ配置し、READMEの依存パッケージ導入手順に従って再起動してください。 この変更はローカルで実装・検証したものです。GitHubへのpush、PR作成、Comfy Registryへの公開は実施していません。 + +--- + +## 2026-09-26 追加修正(v1.2.1) + +基準コミット: `62bcdc9`(上記の改善を取り込んだ master) + +作業ブランチ: `fix/palette-preview-labels` + +1. パレットプレビューの番号が全て0番のマスに重なって描画される不具合を修正しました。`textbbox` を各マスの座標で測った値から、さらにその座標を引いていたため、全番号が左上へ寄っていました。原点で測った字形のオフセットだけを差し引くように変更しています。 +2. Arialがない環境(macOS / Linux)では `font_size` が無視され、10px固定の既定フォントになっていました。DejaVu Sans などの代替フォント、Pillow 10.1以降のサイズ指定付き既定フォントの順に使うようにしました。 +3. READMEに記載していたLinux / WindowsのCPUテスト用GitHub Actions(`.github/workflows/tests.yml`)を追加しました。 +4. `nodes.py` のCRLF/LF混在をLFに統一しました(内容変更なし、別コミット)。 + +上記1・2の回帰テストを追加し、修正前のコードで失敗・修正後に成功することを確認しました。 + +`python -m unittest discover -s tests -v`: **29テスト成功、CUDAテスト1件スキップ**(Linux / Python 3.11 / PyTorch 2.14 CPU / Pillow 12.2 / scikit-learn 1.8)。 + +48フレーム×512×768のバッチで、減色約2.0秒・パレット編集約1.7秒・塗りつぶし約2.2秒(CPU)でした。 diff --git a/nodes.py b/nodes.py index 384092d..12606a8 100644 --- a/nodes.py +++ b/nodes.py @@ -68,6 +68,20 @@ def _color_triplet(value, palette, name): return color +def _load_font(font_size): + # Arial exists on Windows only; fall back to other common fonts, then Pillow's + # scalable default (Pillow >= 10.1) so font_size is honored on macOS/Linux too. + for name in ("arial.ttf", "Arial.ttf", "DejaVuSans.ttf", "Helvetica.ttc"): + try: + return ImageFont.truetype(name, font_size) + except OSError: + pass + try: + return ImageFont.load_default(size=font_size) + except TypeError: + return ImageFont.load_default() + + class ColorshiftColorNode: @classmethod def INPUT_TYPES(cls): @@ -171,10 +185,7 @@ def generate_palette_preview(self, palette, font_size): img = Image.new("RGB", (cols * patch_size, rows * patch_size), (40, 40, 40)) draw = ImageDraw.Draw(img) - try: - font = ImageFont.truetype("arial.ttf", font_size) - except OSError: - font = ImageFont.load_default() + font = _load_font(font_size) for i, color in enumerate(colors): x = (i % cols) * patch_size @@ -182,11 +193,12 @@ def generate_palette_preview(self, palette, font_size): draw.rectangle([x, y, x + patch_size - 1, y + patch_size - 1], fill=tuple((color * 255).clip(0, 255).astype(int))) text = str(i) - bbox = draw.textbbox((x, y), text, font=font) - text_w = bbox[2] - bbox[0] - text_h = bbox[3] - bbox[1] + # Measure at the origin so the bbox offset is relative to the glyph, not the patch. + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + text_x = x + (patch_size - (right - left)) // 2 - left + text_y = y + (patch_size - (bottom - top)) // 2 - top text_color = "black" if np.dot(color, [0.2126, 0.7152, 0.0722]) > 0.5 else "white" - draw.text((x + (patch_size - text_w) // 2 - bbox[0], y + (patch_size - text_h) // 2 - bbox[1]), text, fill=text_color, font=font) + draw.text((text_x, text_y), text, fill=text_color, font=font) img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0) return img_tensor diff --git a/pyproject.toml b/pyproject.toml index 60984e4..310268c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "colorshiftcolor" description = "This is a custom node for ComfyUI.\nIt reduces colors based on a specified number and allows for adjustments to hue, saturation, and brightness.\nFeatures:Each parameter can be set to random, You can toggle masking (not changing colors) using color numbers, Mask inversion can also be toggled on or off." -version = "1.2.0" +version = "1.2.1" license = {file = "LICENSE"} dependencies = ["torch", "numpy", "scikit-learn", "Pillow"] diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 62bf1a1..bd3b3db 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -224,6 +224,22 @@ def test_preview_has_readable_text_on_white(self): self.assertEqual(preview.shape, (1, 100, 800, 3)) self.assertLess(preview[0, 25:75, 25:75].min().item(), .5) + def test_preview_labels_every_patch_in_place(self): + # Every patch must carry its own number; previously all labels stacked on patch 0. + preview = ColorshiftColorNode().generate_palette_preview(torch.ones(12, 3), 30) + self.assertEqual(preview.shape, (1, 200, 800, 3)) + for i in range(12): + x, y = (i % 8) * 100, (i // 8) * 100 + with self.subTest(patch=i): + self.assertLess(preview[0, y + 20:y + 80, x + 20:x + 80].min().item(), .5) + self.assertEqual(preview[0, y:y + 10, x:x + 100].min().item(), 1.) + + def test_preview_font_size_is_honored(self): + def ink(size): + preview = ColorshiftColorNode().generate_palette_preview(torch.ones(1, 3), size) + return (preview[0, :, :100] < .5).any(-1).sum().item() + self.assertGreater(ink(50), ink(10) * 4) + @unittest.skipUnless(torch.cuda.is_available(), 'CUDA is not available') def test_all_nodes_on_cuda_with_cpu_auxiliary_inputs(self): for dtype in (torch.float32, torch.float16, torch.bfloat16):