hsv 色変換ツール

2026年8月20日

はじめに

参照する画像を参考にして色を変更

プログラム

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HSV Tool - 完全版(コンパクトUI・ファイル名日時付き保存・マスク初期反転・自動マスク確実反映・手動ブラシ/消しゴム/塗りつぶし・Undo/Redo・バッチ処理完全対応)
- 動作環境: Python 3.x, Pillow, numpy. scipy は任意(あると高速化)
- 目的: 自動マスクおよび手動編集マスクが確実に Mapped / HSV Full に反映されるようにする
"""
import os
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk, ImageFilter, ImageChops
import numpy as np
import colorsys
from functools import partial
from collections import deque
from datetime import datetime

VERBOSE = False
try:
    from scipy import ndimage
    _HAS_SCIPY = True
except Exception:
    ndimage = None
    _HAS_SCIPY = False

def _vprint(*args, **kwargs):
    if VERBOSE:
        print(*args, **kwargs)

def rgb_to_hex(r, g, b):
    return "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b))

def rgb_to_hsv_tuple(r, g, b):
    return colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)

def ensure_rgba(img):
    return img.convert("RGBA") if img.mode != "RGBA" else img

def split_alpha(img):
    rgba = ensure_rgba(img)
    rgb = rgba.convert("RGB")
    alpha = rgba.split()[-1]
    return rgb, alpha

def merge_alpha(rgb_img, alpha):
    rgba = rgb_img.convert("RGBA")
    r, g, b, _ = rgba.split()
    return Image.merge("RGBA", (r, g, b, alpha))

def make_checkerboard(size=(300, 300), tile=10, color1=(255, 255, 255), color2=(220, 220, 220)):
    w, h = size
    board = Image.new("RGB", (w, h), color1)
    px = board.load()
    for y in range(h):
        for x in range(w):
            tx = (x // tile) % 2
            ty = (y // tile) % 2
            px[x, y] = color1 if (tx ^ ty) == 0 else color2
    return board

def get_skin_thresholds_from_center_span_percent(cb_center_pct, cb_span_pct,
                                                 cr_center_pct, cr_span_pct,
                                                 y_center_pct, y_span_pct):
    def pct_to_range(center_pct, span_pct):
        center = center_pct / 100.0 * 255.0
        span = span_pct / 100.0 * 255.0
        mn = int(max(0, center - span / 2.0))
        mx = int(min(255, center + span / 2.0))
        return mn, mx
    cb_min, cb_max = pct_to_range(cb_center_pct, cb_span_pct)
    cr_min, cr_max = pct_to_range(cr_center_pct, cr_span_pct)
    y_min, y_max = pct_to_range(y_center_pct, y_span_pct)
    return cb_min, cb_max, cr_min, cr_max, y_min, y_max

def make_skin_mask(pil_img, cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
    rgb = pil_img.convert("RGB")
    arr = np.array(rgb).astype(np.int32)
    if arr.ndim != 3 or arr.shape[2] < 3:
        return np.zeros((arr.shape[0], arr.shape[1]), dtype=bool)
    R = arr[..., 0]; G = arr[..., 1]; B = arr[..., 2]
    Cb = (-0.168736 * R - 0.331264 * G + 0.5 * B) + 128.0
    Cr = (0.5 * R - 0.418688 * G - 0.081312 * B) + 128.0
    Y = (0.299 * R + 0.587 * G + 0.114 * B)
    mask = (Cb >= cb_min) & (Cb <= cb_max) & (Cr >= cr_min) & (Cr <= cr_max)
    mask &= (Y >= y_min) & (Y <= y_max)
    return mask.astype(bool)

def scale_mask_morph(mask, scale_pct, max_iter=30):
    if mask is None:
        return None
    if not isinstance(mask, np.ndarray):
        mask = np.array(mask) > 0
    mask_bool = mask.astype(bool)
    if mask_bool.sum() == 0:
        return mask_bool
    ratio = float(scale_pct) / 100.0
    if ratio == 1.0:
        return mask_bool
    iters = int(min(max_iter, max(1, round(abs(ratio - 1.0) * 30))))
    try:
        if _HAS_SCIPY:
            structure = np.ones((3, 3), dtype=bool)
            if ratio < 1.0:
                out = ndimage.binary_erosion(mask_bool, structure=structure, iterations=iters)
            else:
                out = ndimage.binary_dilation(mask_bool, structure=structure, iterations=iters)
            return out
        else:
            pil_mask = Image.fromarray((mask_bool.astype(np.uint8) * 255).astype(np.uint8))
            size = 3 + 2 * max(0, iters - 1)
            if ratio < 1.0:
                inv = ImageChops.invert(pil_mask)
                eroded_inv = inv.filter(ImageFilter.MaxFilter(size=size))
                eroded = ImageChops.invert(eroded_inv)
                out_arr = np.array(eroded) > 0
            else:
                dil = pil_mask.filter(ImageFilter.MaxFilter(size=size))
                out_arr = np.array(dil) > 0
            return out_arr
    except Exception:
        return mask_bool

def mask_to_outline_image(mask, thickness=1, alpha=220, color=(255, 255, 255)):
    if mask is None:
        return None
    mask_bool = mask.astype(bool)
    h, w = mask_bool.shape
    if mask_bool.sum() == 0:
        return Image.new("RGBA", (w, h), (0, 0, 0, 0))
    if _HAS_SCIPY:
        structure = np.ones((3, 3), dtype=bool)
        eroded = ndimage.binary_erosion(mask_bool, structure=structure, iterations=max(1, thickness))
        dilated = ndimage.binary_dilation(mask_bool, structure=structure, iterations=max(1, thickness))
        outline = dilated ^ eroded
        out = np.zeros((h, w, 4), dtype=np.uint8)
        out[..., 0] = color[0]; out[..., 1] = color[1]; out[..., 2] = color[2]
        out[..., 3] = (outline.astype(np.uint8) * alpha)
        return Image.fromarray(out, mode="RGBA")
    else:
        pil_mask = Image.fromarray((mask_bool.astype(np.uint8) * 255).astype(np.uint8))
        size = 3 + 2 * max(0, thickness - 1)
        dil = pil_mask.filter(ImageFilter.MaxFilter(size=size))
        inv = ImageChops.invert(pil_mask)
        eroded_inv = inv.filter(ImageFilter.MaxFilter(size=size))
        eroded = ImageChops.invert(eroded_inv)
        outline = ImageChops.difference(dil, eroded).convert("L")
        white = Image.new("RGBA", (w, h), (color[0], color[1], color[2], 0))
        white.putalpha(outline.point(lambda p: int(p / 255.0 * alpha)))
        return white

def make_mask_overlay_display(img, mask, overlay_color=(255, 0, 128), fill_alpha=120, outline_mode=True, outline_thickness=1, outline_alpha=220):
    base = img.convert("RGBA")
    w, h = base.size
    if mask is None:
        return base.convert("RGB")
    if not isinstance(mask, np.ndarray):
        try:
            mask = np.array(mask)
        except Exception:
            return base.convert("RGB")
    if mask.dtype != bool:
        mask = mask > 0
    composed = base.copy()
    if fill_alpha > 0:
        overlay = Image.new("RGBA", (w, h), (overlay_color[0], overlay_color[1], overlay_color[2], 0))
        mask_img = Image.fromarray((mask.astype(np.uint8) * fill_alpha).astype(np.uint8))
        overlay.putalpha(mask_img)
        composed = Image.alpha_composite(composed, overlay)
    if outline_mode:
        outline_img = mask_to_outline_image(mask, thickness=max(1, int(outline_thickness)), alpha=outline_alpha, color=(255, 255, 255))
        if outline_img is not None:
            composed = Image.alpha_composite(composed, outline_img)
    return composed.convert("RGB")

def apply_hsv_map_to_target_numpy(img, target_hsv, strength, use_h, use_s, use_v, preserve_skin=False,
                                  cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
    h_t, s_t, v_t = target_hsv
    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
    else:
        rgb_img = img.convert("RGB")
        alpha = None
    skin_mask = None
    if preserve_skin:
        skin_mask = make_skin_mask(rgb_img, cb_min, cb_max, cr_min, cr_max, y_min, y_max)
    arr = np.array(rgb_img).astype(np.float32) / 255.0
    maxc = np.max(arr, axis=-1); minc = np.min(arr, axis=-1); delta = maxc - minc
    hue = np.zeros_like(maxc); mask = delta != 0
    idx = (maxc == arr[..., 0]) & mask
    hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
    idx = (maxc == arr[..., 1]) & mask
    hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
    idx = (maxc == arr[..., 2]) & mask
    hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]
    hue = (hue / 6.0) % 1.0
    sat = np.zeros_like(maxc); sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
    val = maxc
    if use_h:
        dh = (h_t - hue + 0.5) % 1.0 - 0.5
        hue = (hue + strength * dh) % 1.0
    if use_s:
        sat = np.clip((1 - strength) * sat + strength * s_t, 0.0, 1.0)
    if use_v:
        val = np.clip((1 - strength) * val + strength * v_t, 0.0, 1.0)
    i = np.floor(hue * 6).astype(int) % 6; f = hue * 6 - np.floor(hue * 6)
    p = val * (1 - sat); q = val * (1 - f * sat); t = val * (1 - (1 - f) * sat)
    r2 = np.zeros_like(val); g2 = np.zeros_like(val); b2 = np.zeros_like(val)
    idx = (i == 0); r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
    idx = (i == 1); r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
    idx = (i == 2); r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
    idx = (i == 3); r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
    idx = (i == 4); r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
    idx = (i == 5); r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]
    out = np.stack([r2, g2, b2], axis=-1)
    out_img_rgb = Image.fromarray(np.clip(out * 255.0, 0, 255).astype(np.uint8))
    if preserve_skin and skin_mask is not None:
        out_arr = np.array(out_img_rgb); orig_arr = np.array(rgb_img)
        out_arr[skin_mask] = orig_arr[skin_mask]; out_img_rgb = Image.fromarray(out_arr)
    if alpha is not None:
        out_img = merge_alpha(out_img_rgb, alpha)
    else:
        out_img = out_img_rgb
    return out_img

def apply_hsv_blend_offset_numpy(img, h_offset, s_offset, v_offset, strength, use_h, use_s, use_v, preserve_skin=False,
                                 cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
    else:
        rgb_img = img.convert("RGB")
        alpha = None
    skin_mask = None
    if preserve_skin:
        skin_mask = make_skin_mask(rgb_img, cb_min, cb_max, cr_min, cr_max, y_min, y_max)
    arr = np.array(rgb_img).astype(np.float32) / 255.0
    maxc = np.max(arr, axis=-1); minc = np.min(arr, axis=-1); delta = maxc - minc
    hue = np.zeros_like(maxc); mask = delta != 0
    idx = (maxc == arr[..., 0]) & mask
    hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
    idx = (maxc == arr[..., 1]) & mask
    hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
    idx = (maxc == arr[..., 2]) & mask
    hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]
    hue = (hue / 6.0) % 1.0
    sat = np.zeros_like(maxc); sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
    val = maxc
    if use_h:
        hue = (1 - strength) * hue + strength * ((hue + h_offset) % 1.0)
    if use_s:
        sat = (1 - strength) * sat + strength * np.clip(sat + s_offset, 0.0, 1.0)
    if use_v:
        val = (1 - strength) * val + strength * np.clip(val + v_offset, 0.0, 1.0)
    i = np.floor(hue * 6).astype(int) % 6; f = hue * 6 - np.floor(hue * 6)
    p = val * (1 - sat); q = val * (1 - f * sat); t = val * (1 - (1 - f) * sat)
    r2 = np.zeros_like(val); g2 = np.zeros_like(val); b2 = np.zeros_like(val)
    idx = (i == 0); r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
    idx = (i == 1); r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
    idx = (i == 2); r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
    idx = (i == 3); r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
    idx = (i == 4); r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
    idx = (i == 5); r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]
    out = np.stack([r2, g2, b2], axis=-1)
    out_img_rgb = Image.fromarray(np.clip(out * 255.0, 0, 255).astype(np.uint8))
    if preserve_skin and skin_mask is not None:
        out_arr = np.array(out_img_rgb); orig_arr = np.array(rgb_img)
        out_arr[skin_mask] = orig_arr[skin_mask]; out_img_rgb = Image.fromarray(out_arr)
    if alpha is not None:
        out_img = merge_alpha(out_img_rgb, alpha)
    else:
        out_img = out_img_rgb
    return out_img

class ColorTool:
    def __init__(self, root):
        self.root = root
        self.root.title("HSV Tool - Complete Version")
        self.root.geometry("1340x760")
        self.small_font = ("TkDefaultFont", 8)

        # left thumbnails
        self.frame_left = tk.Frame(root, width=200)
        self.frame_left.pack(side="left", fill="y")
        self.frame_left.pack_propagate(False)
        self.scroll_canvas = tk.Canvas(self.frame_left, width=180, height=740)
        self.scroll_canvas.pack(side="left", fill="y", expand=False)
        self.scrollbar = tk.Scrollbar(self.frame_left, orient="vertical", command=self.scroll_canvas.yview)
        self.scrollbar.pack(side="right", fill="y")
        self.scroll_canvas.configure(yscrollcommand=self.scrollbar.set)
        self.thumb_frame = tk.Frame(self.scroll_canvas)
        self.thumb_window = self.scroll_canvas.create_window((0, 0), window=self.thumb_frame, anchor="nw")
        self.thumb_frame.bind("<Configure>", self._on_frame_configure)
        self.scroll_canvas.bind("<Enter>", lambda e: self._bind_mousewheel(True))
        self.scroll_canvas.bind("<Leave>", lambda e: self._bind_mousewheel(False))

        # center 2x2
        self.frame_center = tk.Frame(root)
        self.frame_center.pack(side="left", fill="both", expand=True, padx=4, pady=4)
        self.frame_center.pack_propagate(False)
        self.top_left_frame = tk.Frame(self.frame_center); self.top_left_frame.grid(row=0, column=0, padx=4, pady=4, sticky="nsew")
        self.top_right_frame = tk.Frame(self.frame_center); self.top_right_frame.grid(row=0, column=1, padx=4, pady=4, sticky="nsew")
        self.bottom_left_frame = tk.Frame(self.frame_center); self.bottom_left_frame.grid(row=1, column=0, padx=4, pady=4, sticky="nsew")
        self.bottom_right_frame = tk.Frame(self.frame_center); self.bottom_right_frame.grid(row=1, column=1, padx=4, pady=4, sticky="nsew")
        self.frame_center.grid_rowconfigure(0, weight=1); self.frame_center.grid_rowconfigure(1, weight=1)
        self.frame_center.grid_columnconfigure(0, weight=1); self.frame_center.grid_columnconfigure(1, weight=1)

        self.panel_w = 340; self.panel_h = 300

        # Original
        tk.Label(self.top_left_frame, text="Original (左上: 元画像 & マスクプレビュー)", font=self.small_font).pack(anchor="w")
        self.canvas_tl = tk.Canvas(self.top_left_frame, width=self.panel_w, height=self.panel_h, bg="gray")
        self.canvas_tl.pack(fill="both", expand=True)
        self.canvas_tl.bind("<Double-Button-1>", self.pick_color_main)
        info_frame_tl = tk.Frame(self.top_left_frame); info_frame_tl.pack(anchor="w", pady=(2, 0))
        self.tl_swatch = tk.Label(info_frame_tl, width=3, height=1, bg="#808080", bd=1, relief="solid"); self.tl_swatch.pack(side="left", padx=(0, 4))
        self.tl_info = tk.Label(info_frame_tl, text="Original HEX: ---", font=self.small_font); self.tl_info.pack(side="left")

        # Reference
        tk.Label(self.top_right_frame, text="Reference (右上: 参照画像 / クリックで目標色取得)", font=self.small_font).pack(anchor="w")
        self.canvas_tr = tk.Canvas(self.top_right_frame, width=self.panel_w, height=self.panel_h, bg="gray")
        self.canvas_tr.pack(fill="both", expand=True)
        self.canvas_tr.bind("<Button-1>", self.pick_color_ref)
        info_frame_tr = tk.Frame(self.top_right_frame); info_frame_tr.pack(anchor="w", pady=(2, 0))
        self.tr_swatch = tk.Label(info_frame_tr, width=3, height=1, bg="#808080", bd=1, relief="solid"); self.tr_swatch.pack(side="left", padx=(0, 4))
        self.tr_info = tk.Label(info_frame_tr, text="Reference HEX: ---", font=self.small_font); self.tr_info.pack(side="left")

        # Mapped
        tk.Label(self.bottom_left_frame, text="Mapped (左下: 目標色マッピング結果)", font=self.small_font).pack(anchor="w")
        self.canvas_bl = tk.Canvas(self.bottom_left_frame, width=self.panel_w, height=self.panel_h, bg="gray")
        self.canvas_bl.pack(fill="both", expand=True)
        self.canvas_bl.bind("<Button-1>", self.pick_color_out)
        info_frame_bl = tk.Frame(self.bottom_left_frame); info_frame_bl.pack(anchor="w", pady=(2, 0))
        self.bl_swatch = tk.Label(info_frame_bl, width=3, height=1, bg="#808080", bd=1, relief="solid"); self.bl_swatch.pack(side="left", padx=(0, 4))
        self.bl_info = tk.Label(info_frame_bl, text="Mapped HEX: ---", font=self.small_font); self.bl_info.pack(side="left")

        # HSV Full
        tk.Label(self.bottom_right_frame, text="HSV Full (右下: スライダーHSVオフセット調整結果)", font=self.small_font).pack(anchor="w")
        self.canvas_br = tk.Canvas(self.bottom_right_frame, width=self.panel_w, height=self.panel_h, bg="gray")
        self.canvas_br.pack(fill="both", expand=True)
        self.canvas_br.bind("<Button-1>", self.pick_color_full)
        info_frame_br = tk.Frame(self.bottom_right_frame); info_frame_br.pack(anchor="w", pady=(2, 0))
        self.br_swatch = tk.Label(info_frame_br, width=3, height=1, bg="#808080", bd=1, relief="solid"); self.br_swatch.pack(side="left", padx=(0, 4))
        self.br_info = tk.Label(info_frame_br, text="HSV Full HEX: ---", font=self.small_font); self.br_info.pack(side="left")

        # right controls
        self.frame_right = tk.Frame(root, width=310)
        self.frame_right.pack(side="right", fill="y")
        self.frame_right.pack_propagate(False)

        self.right_canvas = tk.Canvas(self.frame_right, width=290, height=760)
        self.right_scroll = tk.Scrollbar(self.frame_right, orient="vertical", command=self.right_canvas.yview)
        self.right_canvas.configure(yscrollcommand=self.right_scroll.set)
        slider_frame = tk.Frame(self.frame_right, width=20)
        self.right_canvas.pack(side="left", fill="both", expand=True)
        slider_frame.pack(side="left", fill="y", padx=(1, 1), pady=1)
        self.right_scroll.pack(side="right", fill="y")

        self.right_inner = tk.Frame(self.right_canvas)
        self.right_window = self.right_canvas.create_window((0, 0), window=self.right_inner, anchor="nw")
        self.right_inner.bind("<Configure>", lambda e: (self.right_canvas.configure(scrollregion=self.right_canvas.bbox("all")), self._update_right_slider_range()))
        def _on_right_canvas_config(e):
            try:
                self.right_canvas.itemconfig(self.right_window, width=e.width)
            except Exception:
                pass
        self.right_canvas.bind("<Configure>", _on_right_canvas_config)
        self.right_canvas.bind("<Enter>", lambda e: self._bind_right_mousewheel(True))
        self.right_canvas.bind("<Leave>", lambda e: self._bind_right_mousewheel(False))

        self.right_scroll_slider = tk.Scale(slider_frame, from_=0, to=100, orient="vertical", showvalue=False, length=300, command=self._on_right_slider_move, sliderrelief="flat", width=8)
        self.right_scroll_slider.set(0)
        self.right_scroll_slider.pack(fill="y", expand=True)

        slider_length = 120
        def add_labeled_scale(parent, row, label_text, from_, to, orient="horizontal", length=slider_length, showvalue=False, init=0, resolution=1):
            lbl = tk.Label(parent, text=label_text, font=self.small_font, anchor="w")
            lbl.grid(row=row, column=0, sticky="w", padx=(2, 1), pady=0)
            s = tk.Scale(parent, from_=from_, to=to, orient=orient, length=length, showvalue=showvalue, resolution=resolution,
                         width=8, sliderlength=14, highlightthickness=0, bd=1)
            s.grid(row=row, column=1, sticky="ew", padx=(0, 2), pady=0)
            parent.grid_columnconfigure(1, weight=1)
            s.set(init)
            return s

        ctrl = tk.Frame(self.right_inner)
        ctrl.pack(fill="both", expand=True, padx=2, pady=1)

        tk.Label(ctrl, text="ダブルクリックで色取得は両モードで使える", font=self.small_font).pack(padx=2, pady=(0, 2), anchor="w")

        # 2-column compact buttons
        btn_frame = tk.Frame(ctrl)
        btn_frame.pack(fill="x", padx=1, pady=(0, 2))
        btn_frame.grid_columnconfigure(0, weight=1)
        btn_frame.grid_columnconfigure(1, weight=1)

        tk.Button(btn_frame, text="フォルダを選択", command=self.load_folder, font=self.small_font).grid(row=0, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="参照画像を読込", command=self.load_reference_image, font=self.small_font).grid(row=0, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Mapped を保存", command=self.save_transformed_image, font=self.small_font).grid(row=1, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="HSV Full を保存", command=self.save_full_image, font=self.small_font).grid(row=1, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="バッチ処理(フォルダ内全て)", command=self.batch_process_folder, font=self.small_font).grid(row=2, column=0, columnspan=2, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Undo (戻す)", command=lambda: self.undo_mask(), font=self.small_font).grid(row=3, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Redo (やり直し)", command=lambda: self.redo_mask(), font=self.small_font).grid(row=3, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Mask Fill (全追加)", command=lambda: self.fill_mask_add(), font=self.small_font).grid(row=4, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Mask Fill (全消去)", command=lambda: self.fill_mask_erase(), font=self.small_font).grid(row=4, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Mask Invert (反転)", command=lambda: self.invert_mask(), font=self.small_font).grid(row=5, column=0, columnspan=2, sticky="ew", padx=1, pady=1)

        grid_parent = tk.Frame(ctrl)
        grid_parent.pack(fill="x", padx=1, pady=0)

        r = 0
        self.h_scale = add_labeled_scale(grid_parent, r, "Hue (°)", -180, 180, init=0); r += 1
        self.s_scale = add_labeled_scale(grid_parent, r, "Sat (%)", -100, 100, init=0); r += 1
        self.v_scale = add_labeled_scale(grid_parent, r, "Val (%)", -100, 100, init=0); r += 1
        self.strength_scale = add_labeled_scale(grid_parent, r, "Strength (%)", 0, 100, init=100); r += 1

        self.h_var = tk.IntVar(value=1); self.s_var = tk.IntVar(value=0); self.v_var = tk.IntVar(value=0)
        chk_frame = tk.Frame(grid_parent); chk_frame.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(1, 0)); r += 1
        tk.Checkbutton(chk_frame, text="Apply Hue", variable=self.h_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
        tk.Checkbutton(chk_frame, text="Apply Sat", variable=self.s_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
        tk.Checkbutton(chk_frame, text="Apply Val", variable=self.v_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left")

        self.preserve_skin_var = tk.IntVar(value=1)
        self.invert_mask_var = tk.IntVar(value=1)  # マスク反転状態を初期値ONに設定
        self.show_preserved_var = tk.IntVar(value=1)
        
        chk_frame2 = tk.Frame(grid_parent); chk_frame2.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=0); r += 1
        tk.Checkbutton(chk_frame2, text="Preserve Skin", variable=self.preserve_skin_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
        tk.Checkbutton(chk_frame2, text="Invert Mask (初期ON)", variable=self.invert_mask_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left")
        
        tk.Checkbutton(grid_parent, text="Show Mask on Original", variable=self.show_preserved_var, command=self.update_hsv_from_slider, font=self.small_font).grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(0, 1)); r += 1

        self.outline_thickness_scale = add_labeled_scale(grid_parent, r, "Outline px", 1, 8, init=1); r += 1
        self.fill_alpha_scale = add_labeled_scale(grid_parent, r, "Fill alpha", 0, 255, init=120); r += 1
        self.mask_scale = add_labeled_scale(grid_parent, r, "Mask Scale %", 50, 200, init=100); r += 1

        tk.Label(grid_parent, text="Brush Size px", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
        self.brush_size = tk.Scale(grid_parent, from_=1, to=200, orient="horizontal", length=slider_length, showvalue=True,
                                   width=8, sliderlength=14, highlightthickness=0, bd=1)
        self.brush_size.set(22)
        self.brush_size.grid(row=r, column=1, sticky="ew", padx=(0, 2), pady=0)
        r += 1

        tk.Label(grid_parent, text="Brush Mode", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
        self.brush_mode_var = tk.StringVar(value="mouse")
        bm_frame = tk.Frame(grid_parent)
        bm_frame.grid(row=r, column=1, sticky="w", padx=(0, 2), pady=0)
        tk.Radiobutton(bm_frame, text="Mouse", variable=self.brush_mode_var, value="mouse", command=self._on_brush_mode_change, font=self.small_font).pack(side="left")
        tk.Radiobutton(bm_frame, text="Add", variable=self.brush_mode_var, value="add", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        tk.Radiobutton(bm_frame, text="Erase", variable=self.brush_mode_var, value="erase", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        tk.Radiobutton(bm_frame, text="Fill", variable=self.brush_mode_var, value="fill", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        tk.Radiobutton(bm_frame, text="FillE", variable=self.brush_mode_var, value="fill_erase", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        r += 1

        tk.Label(grid_parent, text="Edit Mode", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
        self.edit_mode_var = tk.StringVar(value="mask")
        mode_frame = tk.Frame(grid_parent)
        mode_frame.grid(row=r, column=1, sticky="w", padx=(0, 2), pady=0)
        tk.Radiobutton(mode_frame, text="Mask", variable=self.edit_mode_var, value="mask", font=self.small_font).pack(side="left")
        tk.Radiobutton(mode_frame, text="ColorPick", variable=self.edit_mode_var, value="color", font=self.small_font).pack(side="left", padx=(4, 0))
        r += 1

        # Skin detection sliders (コンパクトな横並びペア構成)
        tk.Label(grid_parent, text="Skin detection (Ctr / Span)", font=self.small_font).grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(1, 0)); r += 1

        def add_pair_scales(parent, row, label_text, c_init, s_init):
            tk.Label(parent, text=label_text, font=self.small_font, anchor="w").grid(row=row, column=0, sticky="w", padx=(2, 1), pady=0)
            p_frame = tk.Frame(parent)
            p_frame.grid(row=row, column=1, sticky="ew", padx=(0, 2), pady=0)
            p_frame.grid_columnconfigure(0, weight=1)
            p_frame.grid_columnconfigure(2, weight=1)

            s_c = tk.Scale(p_frame, from_=0, to=100, orient="horizontal", showvalue=True,
                           width=8, sliderlength=12, highlightthickness=0, bd=1)
            s_c.grid(row=0, column=0, sticky="ew")
            s_c.set(c_init)

            tk.Label(p_frame, text="±", font=self.small_font).grid(row=0, column=1, padx=1)

            s_s = tk.Scale(p_frame, from_=0, to=100, orient="horizontal", showvalue=True,
                           width=8, sliderlength=12, highlightthickness=0, bd=1)
            s_s.grid(row=0, column=2, sticky="ew")
            s_s.set(s_init)
            return s_c, s_s

        self.cb_center, self.cb_span = add_pair_scales(grid_parent, r, "Cb %", 40, 20); r += 1
        self.cr_center, self.cr_span = add_pair_scales(grid_parent, r, "Cr %", 60, 16); r += 1
        self.y_center, self.y_span = add_pair_scales(grid_parent, r, "Y %", 50, 78); r += 1

        # bind events after creation
        for s in (self.h_scale, self.s_scale, self.v_scale, self.strength_scale,
                  self.outline_thickness_scale, self.fill_alpha_scale, self.mask_scale,
                  self.cb_center, self.cb_span, self.cr_center, self.cr_span, self.y_center, self.y_span,
                  self.brush_size):
            try:
                s.configure(command=lambda v, s=s: self.update_hsv_from_slider())
                s.bind("<B1-Motion>", lambda e, s=s: self.update_hsv_from_slider())
                s.bind("<ButtonRelease-1>", lambda e, s=s: self.update_hsv_from_slider())
            except Exception:
                pass

        # brush state
        self.user_add_mask = None
        self.user_erase_mask = None
        self._user_mask_image = None
        self._brush_drawing = False
        self._brush_mode = "add"
        self.undo_stack = []
        self.redo_stack = []
        self._preview_photo = None
        self._preview_image_id = None
        self._last_preview_pos = (-1, -1)

        # bindings for brush and preview
        self.canvas_tl.bind("<ButtonPress-1>", lambda e: self._on_canvas_left_click_or_brush_start(e, button=1))
        self.canvas_tl.bind("<B1-Motion>", lambda e: self._on_canvas_left_motion(e, button=1))
        self.canvas_tl.bind("<ButtonRelease-1>", lambda e: self._on_canvas_left_release(e, button=1))
        self.canvas_tl.bind("<ButtonPress-3>", lambda e: self._on_canvas_right_click_or_brush_start(e, button=3))
        self.canvas_tl.bind("<B3-Motion>", lambda e: self._on_canvas_right_motion(e, button=3))
        self.canvas_tl.bind("<ButtonRelease-3>", lambda e: self._on_canvas_right_release(e, button=3))
        self.canvas_tl.bind("<Motion>", lambda e: self._on_canvas_mouse_move(e))
        self.canvas_tl.bind("<Leave>", lambda e: self._clear_preview())

        # initial state
        self.main_img = None
        self.ref_img = None
        self.target_hsv = (0.0, 0.0, 1.0)
        self.transformed_img = None
        self.full_img = None
        self.current_filename = None
        self.thumbnails = []

    # --- basic handlers ---
    def _on_frame_configure(self, event):
        try:
            self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))
        except Exception:
            pass

    def _on_brush_mode_change(self):
        self._clear_preview()

    def _bind_mousewheel(self, enable):
        if enable:
            self.scroll_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
            self.scroll_canvas.bind_all("<Button-4>", self._on_mousewheel)
            self.scroll_canvas.bind_all("<Button-5>", self._on_mousewheel)
        else:
            self.scroll_canvas.unbind_all("<MouseWheel>")
            self.scroll_canvas.unbind_all("<Button-4>")
            self.scroll_canvas.unbind_all("<Button-5>")

    def _on_mousewheel(self, event):
        if hasattr(event, "num") and event.num in (4, 5):
            if event.num == 4:
                self.scroll_canvas.yview_scroll(-1, "units")
            else:
                self.scroll_canvas.yview_scroll(1, "units")
        else:
            delta = int(-1 * (event.delta / 120))
            self.scroll_canvas.yview_scroll(delta, "units")

    def _bind_right_mousewheel(self, enable):
        if enable:
            self.right_canvas.bind_all("<MouseWheel>", self._on_right_mousewheel)
            self.right_canvas.bind_all("<Button-4>", self._on_right_mousewheel)
            self.right_canvas.bind_all("<Button-5>", self._on_right_mousewheel)
        else:
            self.right_canvas.unbind_all("<MouseWheel>")
            self.right_canvas.unbind_all("<Button-4>")
            self.right_canvas.unbind_all("<Button-5>")

    def _on_right_mousewheel(self, event):
        if hasattr(event, "num") and event.num in (4, 5):
            if event.num == 4:
                self.right_canvas.yview_scroll(-1, "units")
            else:
                self.right_canvas.yview_scroll(1, "units")
        else:
            delta = int(-1 * (event.delta / 120))
            self.right_canvas.yview_scroll(delta, "units")
        self._update_right_slider_range()

    def _update_right_slider_range(self):
        try:
            bbox = self.right_canvas.bbox("all")
            if not bbox:
                self.right_scroll_slider.configure(state="disabled")
                return
            content_height = bbox[3] - bbox[1]
            view_h = int(self.right_canvas.winfo_height())
            if content_height <= view_h:
                self.right_scroll_slider.configure(state="disabled")
            else:
                self.right_scroll_slider.configure(state="normal")
                top_frac = self.right_canvas.yview()[0]
                self.right_scroll_slider.set(int(top_frac * 100))
        except Exception:
            pass

    def _on_right_slider_move(self, value):
        try:
            frac = float(value) / 100.0
            self.right_canvas.yview_moveto(frac)
        except Exception:
            pass

    # --- mask management ---
    def _ensure_user_masks(self):
        if not self.main_img:
            self.user_add_mask = None
            self.user_erase_mask = None
            return
        w, h = self.main_img.size
        if getattr(self, "user_add_mask", None) is None or self.user_add_mask.shape != (h, w):
            self.user_add_mask = np.zeros((h, w), dtype=bool)
        if getattr(self, "user_erase_mask", None) is None or self.user_erase_mask.shape != (h, w):
            self.user_erase_mask = np.zeros((h, w), dtype=bool)
        self._user_mask_image = None

    def push_undo(self):
        if self.user_add_mask is None or self.user_erase_mask is None:
            return
        self.undo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
        self.redo_stack.clear()
        if len(self.undo_stack) > 50:
            self.undo_stack.pop(0)

    def undo_mask(self):
        if not self.undo_stack:
            return
        prev_add, prev_erase = self.undo_stack.pop()
        if self.user_add_mask is not None and self.user_erase_mask is not None:
            self.redo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
        self.user_add_mask = prev_add
        self.user_erase_mask = prev_erase
        self.update_hsv_from_slider()

    def redo_mask(self):
        if not self.redo_stack:
            return
        next_add, next_erase = self.redo_stack.pop()
        if self.user_add_mask is not None and self.user_erase_mask is not None:
            self.undo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
        self.user_add_mask = next_add
        self.user_erase_mask = next_erase
        self.update_hsv_from_slider()

    def fill_mask_add(self):
        """画像全体を一括でマスク追加にする"""
        if not self.main_img:
            return
        self._ensure_user_masks()
        self.push_undo()
        self.user_add_mask[:] = True
        self.user_erase_mask[:] = False
        self.update_hsv_from_slider()

    def fill_mask_erase(self):
        """画像全体を一括でマスク消去(クリア)にする"""
        if not self.main_img:
            return
        self._ensure_user_masks()
        self.push_undo()
        self.user_add_mask[:] = False
        self.user_erase_mask[:] = True
        self.update_hsv_from_slider()

    def invert_mask(self):
        """現在の合成マスクを反転する(Invert Checkbuttonをトグル)"""
        cur = self.invert_mask_var.get()
        self.invert_mask_var.set(0 if cur == 1 else 1)
        self.update_hsv_from_slider()

    # --- brush core (start/paint/end) ---
    def _brush_start(self, event, button=1):
        if self.edit_mode_var.get() == "color":
            return
        if not self.main_img:
            return
        self._ensure_user_masks()
        self.push_undo()
        self._brush_drawing = True
        mode = self.brush_mode_var.get()
        if mode == "mouse":
            self._brush_mode = "add" if button == 1 else "erase"
        else:
            self._brush_mode = mode
        self._brush_paint(event, button=button)

    def _brush_paint(self, event, button=1):
        if not getattr(self, "_brush_drawing", False):
            return
        if not self.main_img:
            return
        if self.edit_mode_var.get() == "color":
            return

        canvas = event.widget
        try:
            cw = canvas.winfo_width()
            ch = canvas.winfo_height()
            req_w = cw if cw > 1 else int(canvas.cget("width"))
            req_h = ch if ch > 1 else int(canvas.cget("height"))
        except Exception:
            req_w, req_h = 300, 300
        img_w, img_h = self.main_img.size
        scale = min(req_w / img_w, req_h / img_h)
        disp_w = int(img_w * scale); disp_h = int(img_h * scale)
        off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2

        cx = event.x; cy = event.y
        if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
            return

        rel_x = cx - off_x; rel_y = cy - off_y
        img_x = int(rel_x / scale); img_y = int(rel_y / scale)
        img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))

        try:
            radius = int(self.brush_size.get())
        except Exception:
            radius = 22

        y0 = max(0, img_y - radius); y1 = min(img_h, img_y + radius + 1)
        x0 = max(0, img_x - radius); x1 = min(img_w, img_x + radius + 1)
        yy, xx = np.ogrid[y0:y1, x0:x1]
        dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
        circle = dist2 <= (radius ** 2)

        self._ensure_user_masks()
        if self._brush_mode == "erase":
            self.user_erase_mask[y0:y1, x0:x1][circle] = True
            self.user_add_mask[y0:y1, x0:x1][circle] = False
        else:
            self.user_add_mask[y0:y1, x0:x1][circle] = True
            self.user_erase_mask[y0:y1, x0:x1][circle] = False

        self.update_hsv_from_slider()

    def _brush_end(self, event, button=1):
        self._brush_drawing = False
        self.update_hsv_from_slider()

    # --- wrappers bound to canvas ---
    def _on_canvas_left_click_or_brush_start(self, event, button=1):
        if self.edit_mode_var.get() == "color":
            self._pick_color_generic(event, self.main_img, self.tl_swatch, self.tl_info, "Original HEX", return_rgb=False)
            return
        bm = self.brush_mode_var.get()
        if bm in ("fill", "fill_erase"):
            if not self.main_img:
                return
            canvas = event.widget
            try:
                cw = canvas.winfo_width()
                ch = canvas.winfo_height()
                req_w = cw if cw > 1 else int(canvas.cget("width"))
                req_h = ch if ch > 1 else int(canvas.cget("height"))
            except Exception:
                req_w, req_h = 300, 300
            img_w, img_h = self.main_img.size
            scale = min(req_w / img_w, req_h / img_h)
            disp_w = int(img_w * scale); disp_h = int(img_h * scale)
            off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2
            cx = event.x; cy = event.y
            if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
                return
            rel_x = cx - off_x; rel_y = cy - off_y
            img_x = int(rel_x / scale); img_y = int(rel_y / scale)
            img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))
            mode = "add" if bm == "fill" else "erase"
            self._fill_at_point(img_x, img_y, mode=mode)
            self._clear_preview()
            return
        self._brush_start(event, button=button)

    def _on_canvas_left_motion(self, event, button=1):
        if self.edit_mode_var.get() == "color":
            return
        self._brush_paint(event, button=button)

    def _on_canvas_left_release(self, event, button=1):
        try:
            self._brush_end(event, button=button)
        except Exception:
            pass

    def _on_canvas_right_click_or_brush_start(self, event, button=3):
        if self.edit_mode_var.get() == "color":
            return
        bm = self.brush_mode_var.get()
        if bm in ("fill", "fill_erase"):
            if not self.main_img:
                return
            canvas = event.widget
            try:
                cw = canvas.winfo_width()
                ch = canvas.winfo_height()
                req_w = cw if cw > 1 else int(canvas.cget("width"))
                req_h = ch if ch > 1 else int(canvas.cget("height"))
            except Exception:
                req_w, req_h = 300, 300
            img_w, img_h = self.main_img.size
            scale = min(req_w / img_w, req_h / img_h)
            disp_w = int(img_w * scale); disp_h = int(img_h * scale)
            off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2
            cx = event.x; cy = event.y
            if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
                return
            rel_x = cx - off_x; rel_y = cy - off_y
            img_x = int(rel_x / scale); img_y = int(rel_y / scale)
            img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))
            mode = "add" if bm == "fill" else "erase"
            self._fill_at_point(img_x, img_y, mode=mode)
            self._clear_preview()
            return
        self._brush_start(event, button=button)

    def _on_canvas_right_motion(self, event, button=3):
        if self.edit_mode_var.get() == "color":
            return
        self._brush_paint(event, button=button)

    def _on_canvas_right_release(self, event, button=3):
        try:
            self._brush_end(event, button=button)
        except Exception:
            pass

    # --- compose mask (auto prioritized) ---
    def _compose_mask(self, auto_mask):
        if not self.main_img:
            return None
        img_w, img_h = self.main_img.size
        self._ensure_user_masks()
        ua = self.user_add_mask if self.user_add_mask is not None else np.zeros((img_h, img_w), dtype=bool)
        ue = self.user_erase_mask if self.user_erase_mask is not None else np.zeros((img_h, img_w), dtype=bool)

        base_auto = None
        if auto_mask is not None:
            try:
                base_auto = np.array(auto_mask)
                if base_auto.dtype != bool:
                    base_auto = base_auto > 0
                if base_auto.shape != (img_h, img_w):
                    if base_auto.shape == (img_w, img_h):
                        base_auto = base_auto.T
                    else:
                        tmp = Image.fromarray((base_auto.astype(np.uint8) * 255).astype(np.uint8))
                        tmp = tmp.resize((img_w, img_h), resample=Image.NEAREST)
                        base_auto = np.array(tmp) > 0
            except Exception:
                base_auto = None

        if base_auto is not None:
            composed = base_auto.copy()
            if ue is not None and ue.shape == (img_h, img_w):
                composed[ue] = False
            if ua is not None and ua.shape == (img_h, img_w):
                composed[ua] = True
            return composed.astype(bool)

        # 自動検出がない場合の手動マスク合成
        composed = np.zeros((img_h, img_w), dtype=bool)
        if ua is not None and ua.shape == (img_h, img_w):
            composed[ua] = True
        if ue is not None and ue.shape == (img_h, img_w):
            composed[ue] = False
        return composed.astype(bool)

    # --- fill at point (component fill) ---
    def _fill_at_point(self, img_x, img_y, mode="add"):
        if not self.main_img:
            return
        self._ensure_user_masks()
        self.push_undo()

        cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
            self.cb_center.get(), self.cb_span.get(),
            self.cr_center.get(), self.cr_span.get(),
            self.y_center.get(), self.y_span.get()
        )
        auto_mask = None
        if self.preserve_skin_var.get() == 1:
            try:
                auto_mask = make_skin_mask(self.main_img.convert("RGB"),
                                           cb_min=cb_min, cb_max=cb_max,
                                           cr_min=cr_min, cr_max=cr_max,
                                           y_min=y_min, y_max=y_max)
            except Exception:
                auto_mask = None

        composed = self._compose_mask(auto_mask)
        if composed is None:
            return
        if self.invert_mask_var.get() == 1:
            composed = ~composed

        h, w = composed.shape
        if not (0 <= img_x < w and 0 <= img_y < h):
            return

        clicked_val = bool(composed[img_y, img_x])

        try:
            if _HAS_SCIPY:
                labeled, nlabels = ndimage.label(composed == clicked_val)
            else:
                raise Exception("no scipy")
        except Exception:
            labeled = np.zeros_like(composed, dtype=np.int32)
            label = 0
            visited = np.zeros_like(composed, dtype=bool)
            for yy in range(h):
                for xx in range(w):
                    if (composed[yy, xx] == clicked_val) and (not visited[yy, xx]):
                        label += 1
                        q = deque()
                        q.append((yy, xx))
                        visited[yy, xx] = True
                        labeled[yy, xx] = label
                        while q:
                            cy, cx = q.popleft()
                            for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                                ny, nx = cy + dy, cx + dx
                                if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and (composed[ny, nx] == clicked_val):
                                    visited[ny, nx] = True
                                    labeled[ny, nx] = label
                                    q.append((ny, nx))

        lbl = labeled[img_y, img_x]
        if lbl == 0:
            try:
                radius = int(self.brush_size.get())
            except Exception:
                radius = 22
            y0 = max(0, img_y - radius); y1 = min(h, img_y + radius + 1)
            x0 = max(0, img_x - radius); x1 = min(w, img_x + radius + 1)
            yy, xx = np.ogrid[y0:y1, x0:x1]
            dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
            region = np.zeros((h, w), dtype=bool)
            region[y0:y1, x0:x1] = dist2 <= (radius ** 2)
        else:
            region = (labeled == lbl)

        # マスク反転がONの場合、表示上のAdd/Eraseと内部マスクの関係を考慮
        actual_add = (mode == "add") if self.invert_mask_var.get() == 0 else (mode != "add")
        if not actual_add:
            self.user_erase_mask[region] = True
            self.user_add_mask[region] = False
        else:
            self.user_add_mask[region] = True
            self.user_erase_mask[region] = False

        self.redo_stack.clear()
        self.update_hsv_from_slider()

    # --- preview helpers ---
    def _on_canvas_mouse_move(self, event):
        if self.edit_mode_var.get() != "mask":
            self._clear_preview()
            return
        bm = self.brush_mode_var.get()
        if bm not in ("fill", "fill_erase"):
            self._clear_preview()
            return
        if not self.main_img:
            self._clear_preview()
            return

        canvas = event.widget
        try:
            cw = canvas.winfo_width()
            ch = canvas.winfo_height()
            req_w = cw if cw > 1 else int(canvas.cget("width"))
            req_h = ch if ch > 1 else int(canvas.cget("height"))
        except Exception:
            req_w, req_h = 300, 300
        img_w, img_h = self.main_img.size
        scale = min(req_w / img_w, req_h / img_h)
        disp_w = int(img_w * scale); disp_h = int(img_h * scale)
        off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2
        cx = event.x; cy = event.y
        if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
            self._clear_preview()
            return

        rel_x = cx - off_x; rel_y = cy - off_y
        img_x = int(rel_x / scale); img_y = int(rel_y / scale)
        img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))

        if (img_x, img_y) == self._last_preview_pos:
            return
        self._last_preview_pos = (img_x, img_y)

        region = self._compute_region_for_preview(img_x, img_y)
        if region is None:
            self._clear_preview()
            return
        self._show_preview_region(region, scale, off_x, off_y)

    def _compute_region_for_preview(self, img_x, img_y):
        if not self.main_img:
            return None
        cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
            self.cb_center.get(), self.cb_span.get(),
            self.cr_center.get(), self.cr_span.get(),
            self.y_center.get(), self.y_span.get()
        )
        auto_mask = None
        if self.preserve_skin_var.get() == 1:
            try:
                auto_mask = make_skin_mask(self.main_img.convert("RGB"),
                                           cb_min=cb_min, cb_max=cb_max,
                                           cr_min=cr_min, cr_max=cr_max,
                                           y_min=y_min, y_max=y_max)
            except Exception:
                auto_mask = None

        composed = self._compose_mask(auto_mask)
        if composed is None:
            return None
        if self.invert_mask_var.get() == 1:
            composed = ~composed

        h, w = composed.shape
        if not (0 <= img_x < w and 0 <= img_y < h):
            return None

        clicked_val = bool(composed[img_y, img_x])

        try:
            if _HAS_SCIPY:
                labeled, nlabels = ndimage.label(composed == clicked_val)
            else:
                raise Exception("no scipy")
        except Exception:
            labeled = np.zeros_like(composed, dtype=np.int32)
            label = 0
            visited = np.zeros_like(composed, dtype=bool)
            for yy in range(h):
                for xx in range(w):
                    if (composed[yy, xx] == clicked_val) and (not visited[yy, xx]):
                        label += 1
                        q = deque()
                        q.append((yy, xx))
                        visited[yy, xx] = True
                        labeled[yy, xx] = label
                        while q:
                            cy, cx = q.popleft()
                            for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                                ny, nx = cy + dy, cx + dx
                                if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and (composed[ny, nx] == clicked_val):
                                    visited[ny, nx] = True
                                    labeled[ny, nx] = label
                                    q.append((ny, nx))

        lbl = labeled[img_y, img_x]
        if lbl == 0:
            return None
        region = (labeled == lbl)
        return region

    def _show_preview_region(self, region, scale, off_x, off_y):
        try:
            outline_img = mask_to_outline_image(region, thickness=max(1, int(self.outline_thickness_scale.get())), alpha=200, color=(255, 255, 255))
            if outline_img is None:
                self._clear_preview()
                return
            img_w, img_h = self.main_img.size
            disp_w = int(img_w * scale); disp_h = int(img_h * scale)
            outline_resized = outline_img.resize((disp_w, disp_h), resample=Image.NEAREST)
            try:
                cw = self.canvas_tl.winfo_width()
                ch = self.canvas_tl.winfo_height()
                req_w = cw if cw > 1 else int(self.canvas_tl.cget("width"))
                req_h = ch if ch > 1 else int(self.canvas_tl.cget("height"))
            except Exception:
                req_w, req_h = disp_w, disp_h
            overlay = Image.new("RGBA", (req_w, req_h), (0, 0, 0, 0))
            overlay.paste(outline_resized, (off_x, off_y), outline_resized)
            tk_img = ImageTk.PhotoImage(overlay)
            self._clear_preview()
            self._preview_photo = tk_img
            self._preview_image_id = self.canvas_tl.create_image(0, 0, anchor="nw", image=tk_img, tags=("preview",))
        except Exception:
            self._clear_preview()

    def _clear_preview(self):
        try:
            if getattr(self, "_preview_image_id", None) is not None:
                try:
                    self.canvas_tl.delete(self._preview_image_id)
                except Exception:
                    pass
            try:
                self.canvas_tl.delete("preview")
            except Exception:
                pass
        finally:
            self._preview_photo = None
            self._preview_image_id = None
            self._last_preview_pos = (-1, -1)

    # --- show image with overlay ---
    def show_image(self, canvas, img, mask=None, show_mask_overlay=False, overlay_color=(255, 0, 128)):
        canvas.delete("all")
        tmp = img.copy()
        try:
            cw = canvas.winfo_width()
            ch = canvas.winfo_height()
            req_w = cw if cw > 1 else int(canvas.cget("width"))
            req_h = ch if ch > 1 else int(canvas.cget("height"))
        except Exception:
            req_w, req_h = 300, 300
        tmp.thumbnail((req_w, req_h))
        w, h = tmp.size

        bg = make_checkerboard((req_w, req_h), tile=10, color1=(255, 255, 255), color2=(220, 220, 220))
        paste_x = (req_w - w) // 2
        paste_y = (req_h - h) // 2

        disp = bg.convert("RGBA")
        if tmp.mode == "RGBA":
            disp.paste(tmp, (paste_x, paste_y), tmp.split()[-1])
        else:
            disp.paste(tmp, (paste_x, paste_y))

        if show_mask_overlay and mask is not None:
            try:
                mask_img = Image.fromarray((mask.astype(np.uint8) * 255).astype(np.uint8))
                mask_img = mask_img.resize((w, h), Image.NEAREST)
                mask_resized = np.array(mask_img) > 0

                scale_pct = 100
                try:
                    scale_pct = int(self.mask_scale.get())
                except Exception:
                    pass
                mask_scaled = scale_mask_morph(mask_resized, scale_pct)

                fill_alpha = int(self.fill_alpha_scale.get()) if hasattr(self, "fill_alpha_scale") else 120
                outline_thickness = int(self.outline_thickness_scale.get()) if hasattr(self, "outline_thickness_scale") else 1
                overlayed = make_mask_overlay_display(tmp, mask_scaled,
                                                     overlay_color=overlay_color,
                                                     fill_alpha=fill_alpha,
                                                     outline_mode=True,
                                                     outline_thickness=outline_thickness,
                                                     outline_alpha=220)
                overlayed_rgba = overlayed.convert("RGBA")
                disp.paste(overlayed_rgba, (paste_x, paste_y), overlayed_rgba.split()[-1])
            except Exception:
                pass

        final = disp.convert("RGB")
        tk_img = ImageTk.PhotoImage(final)
        canvas.image = tk_img
        canvas.create_image(0, 0, anchor="nw", image=tk_img)

    # --- color pick ---
    def pick_color_main(self, event):
        if not self.main_img:
            return
        self._pick_color_generic(event, self.main_img, self.tl_swatch, self.tl_info, "Original HEX", return_rgb=False)

    def pick_color_ref(self, event):
        if not self.ref_img:
            return
        result = self._pick_color_generic(event, self.ref_img, self.tr_swatch, self.tr_info, "Reference HEX", return_rgb=True)
        if result:
            r, g, b = result
            h, s, v = rgb_to_hsv_tuple(r, g, b)
            self.target_hsv = (h, s, v)
            try:
                self.h_scale.set(int(h * 360.0 - 180.0))
            except Exception:
                pass
            try:
                self.s_scale.set(int((s - 0.5) * 200.0))
                self.v_scale.set(int((v - 0.5) * 200.0))
            except Exception:
                pass
            self.update_hsv_from_slider()

    def pick_color_out(self, event):
        if not self.transformed_img:
            return
        self._pick_color_generic(event, self.transformed_img, self.bl_swatch, self.bl_info, "Mapped HEX", return_rgb=False)

    def pick_color_full(self, event):
        if not self.full_img:
            return
        self._pick_color_generic(event, self.full_img, self.br_swatch, self.br_info, "HSV Full HEX", return_rgb=False)

    def _pick_color_generic(self, event, img, swatch_widget, label, prefix, return_rgb=False):
        canvas = event.widget
        canvas_x = event.x; canvas_y = event.y
        img_w, img_h = img.size
        try:
            cw = canvas.winfo_width()
            ch = canvas.winfo_height()
            req_w = cw if cw > 1 else int(canvas.cget("width"))
            req_h = ch if ch > 1 else int(canvas.cget("height"))
        except Exception:
            req_w, req_h = 300, 300
        scale2 = min(req_w / img_w, req_h / img_h)
        disp_w2 = int(img_w * scale2); disp_h2 = int(img_h * scale2)
        off_x2 = (req_w - disp_w2) // 2; off_y2 = (req_h - disp_h2) // 2
        if not (off_x2 <= canvas_x < off_x2 + disp_w2 and off_y2 <= canvas_y < off_y2 + disp_h2):
            return None
        rel_x = canvas_x - off_x2; rel_y = canvas_y - off_y2
        img_x = int(rel_x / scale2); img_y = int(rel_y / scale2)
        img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))
        px = img.getpixel((img_x, img_y))
        if isinstance(px, tuple):
            r, g, b = px[0], px[1], px[2]
        else:
            r = g = b = px
        hex_color = rgb_to_hex(r, g, b)
        try:
            label.config(text=f"{prefix}: {hex_color}")
            if swatch_widget is not None:
                swatch_widget.config(bg=hex_color)
        except Exception:
            pass
        if return_rgb:
            return (r, g, b)
        return None

    # --- core update (ensures auto mask applied) ---
    def update_hsv_from_slider(self, event=None):
        cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
            self.cb_center.get(), self.cb_span.get(),
            self.cr_center.get(), self.cr_span.get(),
            self.y_center.get(), self.y_span.get()
        )

        if not self.main_img:
            if self.ref_img:
                self.show_image(self.canvas_tr, self.ref_img)
            return

        strength_mapped = 1.0
        use_h = self.h_var.get() == 1
        use_s = self.s_var.get() == 1
        use_v = self.v_var.get() == 1
        preserve_skin = self.preserve_skin_var.get() == 1
        show_mask_overlay = self.show_preserved_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1

        auto_mask = None
        if preserve_skin:
            try:
                auto_mask = make_skin_mask(self.main_img.convert("RGB"),
                                           cb_min=cb_min, cb_max=cb_max,
                                           cr_min=cr_min, cr_max=cr_max,
                                           y_min=y_min, y_max=y_max)
                _vprint("auto_mask sum:", int(np.sum(auto_mask)))
            except Exception:
                auto_mask = None
                _vprint("auto_mask creation failed")

        mask = self._compose_mask(auto_mask)

        # マスク反転(初期ON)の適用
        if mask is not None and invert_mask:
            mask = ~mask

        if mask is not None:
            try:
                mask = np.array(mask)
                if mask.dtype != bool:
                    mask = mask > 0
                img_w, img_h = self.main_img.size
                if mask.shape != (img_h, img_w):
                    if mask.shape == (img_w, img_h):
                        mask = mask.T
                    else:
                        mask_img = Image.fromarray((mask.astype(np.uint8) * 255).astype(np.uint8))
                        mask_img = mask_img.resize((img_w, img_h), resample=Image.NEAREST)
                        mask = np.array(mask_img) > 0
                _vprint("final composed mask sum:", int(np.sum(mask)))
            except Exception:
                mask = None
                _vprint("mask normalization failed")

        try:
            self.show_image(self.canvas_tl, self.main_img, mask=mask if show_mask_overlay else None, show_mask_overlay=show_mask_overlay, overlay_color=(255, 0, 128))
        except Exception:
            pass

        if self.ref_img:
            try:
                self.show_image(self.canvas_tr, self.ref_img)
            except Exception:
                pass

        # 適用マスク(Mask Scale % のモルフォロジー拡縮を適用)
        scale_pct = 100
        try:
            scale_pct = int(self.mask_scale.get())
        except Exception:
            pass
        applied_mask = scale_mask_morph(mask, scale_pct) if mask is not None else None

        # Mapped (マスク領域のみを色変換、またはマスクなしなら全体を色変換)
        try:
            mapped_full = apply_hsv_map_to_target_numpy(
                self.main_img, self.target_hsv, strength_mapped, use_h, use_s, use_v,
                preserve_skin=False
            )

            if applied_mask is None or (not preserve_skin and getattr(self, "user_add_mask", None) is None):
                self.transformed_img = mapped_full
            else:
                orig_rgba = self.main_img.convert("RGBA")
                mapped_rgba = mapped_full.convert("RGBA")
                orig_arr = np.array(orig_rgba, dtype=np.uint8)
                mapped_arr = np.array(mapped_rgba, dtype=np.uint8)
                h, w = orig_arr.shape[0], orig_arr.shape[1]
                if applied_mask.shape != (h, w):
                    mask_img = Image.fromarray((applied_mask.astype(np.uint8) * 255).astype(np.uint8))
                    mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
                    applied_mask = np.array(mask_img) > 0
                mask_exp = np.repeat(applied_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                out_arr = np.where(mask_exp, mapped_arr, orig_arr)
                self.transformed_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")

            self.show_image(self.canvas_bl, self.transformed_img)
        except Exception as e:
            _vprint("Mapped generation error:", e)
            self.transformed_img = None

        # HSV Full (マスク領域のみを色オフセット変換、またはマスクなしなら全体を色変換)
        try:
            h_deg = self.h_scale.get()
            h_offset = (h_deg / 360.0)
            s_off = self.s_scale.get() / 100.0
            v_off = self.v_scale.get() / 100.0
            strength_full = self.strength_scale.get() / 100.0

            full_transformed = apply_hsv_blend_offset_numpy(
                self.main_img, h_offset, s_off, v_off, strength_full, use_h, use_s, use_v,
                preserve_skin=False
            )

            if applied_mask is None or (not preserve_skin and getattr(self, "user_add_mask", None) is None):
                self.full_img = full_transformed
            else:
                orig_rgba = self.main_img.convert("RGBA")
                full_rgba = full_transformed.convert("RGBA")
                orig_arr = np.array(orig_rgba, dtype=np.uint8)
                full_arr = np.array(full_rgba, dtype=np.uint8)
                h, w = orig_arr.shape[0], orig_arr.shape[1]
                if applied_mask.shape != (h, w):
                    mask_img = Image.fromarray((applied_mask.astype(np.uint8) * 255).astype(np.uint8))
                    mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
                    applied_mask = np.array(mask_img) > 0
                mask_exp = np.repeat(applied_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                out_arr = np.where(mask_exp, full_arr, orig_arr)
                self.full_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")

            self.show_image(self.canvas_br, self.full_img)
        except Exception as e:
            _vprint("Full generation error:", e)
            self.full_img = None

        self._update_right_slider_range()

    # --- IO / batch / save ---
    def batch_process_folder(self):
        folder = filedialog.askdirectory(title="バッチ処理するフォルダを選択")
        if not folder:
            return

        now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        parent = os.path.dirname(folder)
        base = os.path.basename(folder.rstrip("/\\"))
        out_root = os.path.join(parent, base + "_batch_out")
        out_mapped_folder = os.path.join(out_root, "mapped")
        out_full_folder = os.path.join(out_root, "hsv_full")
        os.makedirs(out_mapped_folder, exist_ok=True)
        os.makedirs(out_full_folder, exist_ok=True)

        target_hsv = self.target_hsv
        if self.ref_img is None and (target_hsv is None or target_hsv == (0.0, 0.0, 1.0)):
            h_deg = self.h_scale.get()
            h = (h_deg / 360.0) % 1.0
            s = (self.s_scale.get() / 200.0) + 0.5
            v = (self.v_scale.get() / 200.0) + 0.5
            target_hsv = (h, max(0.0, min(1.0, s)), max(0.0, min(1.0, v)))

        use_h = self.h_var.get() == 1
        use_s = self.s_var.get() == 1
        use_v = self.v_var.get() == 1
        preserve_skin = self.preserve_skin_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1

        cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
            self.cb_center.get(), self.cb_span.get(),
            self.cr_center.get(), self.cr_span.get(),
            self.y_center.get(), self.y_span.get()
        )

        strength_mapped = 1.0
        strength_full = self.strength_scale.get() / 100.0
        h_offset_full = self.h_scale.get() / 360.0
        s_offset_full = self.s_scale.get() / 100.0
        v_offset_full = self.v_scale.get() / 100.0

        scale_pct = 100
        try:
            scale_pct = int(self.mask_scale.get())
        except Exception:
            pass

        files = sorted([f for f in os.listdir(folder) if f.lower().endswith((".png", ".jpg", ".jpeg"))])
        if not files:
            messagebox.showinfo("バッチ処理", "指定フォルダに画像ファイルが見つかりません。")
            return

        total = len(files)
        processed = 0
        errors = []

        for fname in files:
            src = os.path.join(folder, fname)
            try:
                img = Image.open(src)
            except Exception as e:
                errors.append((fname, str(e)))
                continue

            base_name, _ = os.path.splitext(fname)

            # 各画像に対するマスク計算
            cur_mask = None
            if preserve_skin:
                try:
                    cur_mask = make_skin_mask(img.convert("RGB"), cb_min=cb_min, cb_max=cb_max, cr_min=cr_min, cr_max=cr_max, y_min=y_min, y_max=y_max)
                    if invert_mask:
                        cur_mask = ~cur_mask
                    cur_mask = scale_mask_morph(cur_mask, scale_pct)
                except Exception:
                    cur_mask = None

            # Mapped 処理
            try:
                mapped = apply_hsv_map_to_target_numpy(
                    img, target_hsv, strength_mapped, use_h, use_s, use_v,
                    preserve_skin=False
                )
                if cur_mask is not None and cur_mask.sum() > 0:
                    orig_rgba = ensure_rgba(img)
                    mapped_rgba = ensure_rgba(mapped)
                    orig_arr = np.array(orig_rgba, dtype=np.uint8)
                    mapped_arr = np.array(mapped_rgba, dtype=np.uint8)
                    mask_exp = np.repeat(cur_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                    out_arr = np.where(mask_exp, mapped_arr, orig_arr)
                    final_mapped = Image.fromarray(out_arr, mode="RGBA")
                    if img.mode != "RGBA":
                        final_mapped = final_mapped.convert("RGB")
                else:
                    final_mapped = mapped

                out_mapped = os.path.join(out_mapped_folder, f"{base_name}_mapping_{now_str}.png")
                final_mapped.save(out_mapped, format="PNG")
            except Exception as e:
                errors.append((fname + " (mapped)", str(e)))

            # Full 処理
            try:
                full = apply_hsv_blend_offset_numpy(
                    img, h_offset_full, s_offset_full, v_offset_full, strength_full, use_h, use_s, use_v,
                    preserve_skin=False
                )
                if cur_mask is not None and cur_mask.sum() > 0:
                    orig_rgba = ensure_rgba(img)
                    full_rgba = ensure_rgba(full)
                    orig_arr = np.array(orig_rgba, dtype=np.uint8)
                    full_arr = np.array(full_rgba, dtype=np.uint8)
                    mask_exp = np.repeat(cur_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                    out_arr = np.where(mask_exp, full_arr, orig_arr)
                    final_full = Image.fromarray(out_arr, mode="RGBA")
                    if img.mode != "RGBA":
                        final_full = final_full.convert("RGB")
                else:
                    final_full = full

                out_full = os.path.join(out_full_folder, f"{base_name}_hsv_full_{now_str}.png")
                final_full.save(out_full, format="PNG")
            except Exception as e:
                errors.append((fname + " (full)", str(e)))

            processed += 1

        msg = f"バッチ処理が完了しました。\n処理済み: {processed}/{total}\n出力フォルダ: {out_root}\n\nmapped と hsv_full に分けて保存しました。"
        if errors:
            msg += f"\nエラー件数: {len(errors)}(詳細はコンソール参照)"
            print("Batch errors:")
            for e in errors:
                print(e)
        messagebox.showinfo("バッチ処理 完了", msg)

    def save_transformed_image(self):
        if not self.transformed_img or not self.current_filename:
            return
        base, ext = os.path.splitext(self.current_filename)
        now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        save_name = f"{base}_mapping_{now_str}.png"
        path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
        if path:
            self.transformed_img.save(path, format="PNG")
            print("Saved:", path)

    def save_full_image(self):
        if not self.full_img or not self.current_filename:
            return
        base, ext = os.path.splitext(self.current_filename)
        now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        save_name = f"{base}_hsv_full_{now_str}.png"
        path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
        if path:
            self.full_img.save(path, format="PNG")
            print("Saved HSV Full:", path)

    # --- thumbnails / IO ---
    def load_folder(self):
        folder = filedialog.askdirectory()
        if not folder:
            return
        for widget in self.thumb_frame.winfo_children():
            widget.destroy()
        self.thumbnails.clear()

        files = sorted([f for f in os.listdir(folder) if f.lower().endswith((".png", ".jpg", ".jpeg"))])
        for f in files:
            full = os.path.join(folder, f)
            try:
                img = Image.open(full)
            except Exception:
                continue

            thumb = img.copy()
            thumb.thumbnail((100, 100))
            w_t, h_t = thumb.size

            checker_thumb = make_checkerboard((120, 120), tile=6, color1=(255, 255, 255), color2=(220, 220, 220))

            if thumb.mode == "RGBA":
                bg_thumb = checker_thumb.copy()
                bg_thumb.paste(thumb, ((120 - w_t) // 2, (120 - h_t) // 2), thumb.split()[-1])
                disp_thumb = bg_thumb.convert("RGB")
            else:
                bg_thumb = checker_thumb.copy()
                bg_thumb.paste(thumb, ((120 - w_t) // 2, (120 - h_t) // 2))
                disp_thumb = bg_thumb

            tk_thumb = ImageTk.PhotoImage(disp_thumb)
            self.thumbnails.append(tk_thumb)

            item_frame = tk.Frame(self.thumb_frame, bd=1, relief="flat")
            item_frame.pack(padx=2, pady=2, fill="x")

            lbl_img = tk.Label(item_frame, image=tk_thumb)
            lbl_img.image = tk_thumb
            lbl_img.pack(side="left", padx=2)
            lbl_img.bind("<Button-1>", partial(self.on_thumbnail_click, full))

            text_frame = tk.Frame(item_frame)
            text_frame.pack(side="left", fill="both", expand=True, padx=(4, 0))

            name_label = tk.Label(text_frame, text=f, wraplength=60, anchor="w", justify="left", font=self.small_font)
            name_label.pack(anchor="w")

            sw = tk.Label(text_frame, width=3, height=1, bg="#808080", bd=1, relief="solid")
            sw.pack(anchor="w", pady=(2, 0))
            lbl_img._swatch = sw

        self.thumb_frame.update_idletasks()
        try:
            self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))
        except Exception:
            pass

    def on_thumbnail_click(self, fullpath, event):
        try:
            img = Image.open(fullpath)
        except Exception:
            return
        self.main_img = img
        self.current_filename = os.path.basename(fullpath)
        self._ensure_user_masks()
        self.undo_stack.clear()
        self.redo_stack.clear()
        try:
            lbl = event.widget
            if hasattr(lbl, "_swatch"):
                w, h = img.size
                cx, cy = w // 2, h // 2
                px = img.getpixel((cx, cy))
                if isinstance(px, tuple):
                    r, g, b = px[0], px[1], px[2]
                else:
                    r = g = b = px
                hex_color = rgb_to_hex(r, g, b)
                lbl._swatch.config(bg=hex_color)
        except Exception:
            pass

        self.update_hsv_from_slider()

    def load_reference_image(self):
        path = filedialog.askopenfilename(filetypes=[("Image files", "*.png;*.jpg;*.jpeg")])
        if not path:
            return
        try:
            self.ref_img = Image.open(path)
        except Exception:
            return
        self.show_image(self.canvas_tr, self.ref_img)

if __name__ == "__main__":
    root = tk.Tk()
    app = ColorTool(root)
    root.mainloop()

Python

Posted by eightban