画像の色変換とマスク編集を高度かつ直感的に行える統合型ツール。

2026年9月16日

はじめに

このアプリは、画像の色変換とマスク編集を高度かつ直感的に行える統合型ツール。参照画像から色を抽出して自動的に色を合わせる Mapped モード と、Hue・Sat・Val を自由に調整できる HSV Full モード を備え、細かな色補正が可能。自動マスクは肌色検出や YCbCr 範囲に基づいて生成され、ブラシや消しゴムで手動編集でき、Undo/Redo にも対応。マスクは透明化処理や背景合成に利用でき、背景画像の読み込み・解除もワンクリックで操作できる。さらに、フォルダ内の画像を一括処理するバッチ機能や、複数色バリエーションを自動生成する機能も搭載。色取得、マスク表示、透明化、保存まで一連の作業を一つの UI で完結できる、画像色変換と背景処理に特化した高機能ツール。

使い方

🎛 1. 画像読込・保存系ボタン

  • フォルダを選択
    指定フォルダ内の画像をすべて読み込み、左側のサムネイル一覧に表示する。
  • 参照画像を読込
    「Reference」パネルに表示する画像を読み込む。
    → この画像をクリックすると 目標色(HSV)を取得できる。
  • 背景画像を読込
    「Transparent」結果に背景を合成するための画像を読み込む。
  • 背景画像を解除
    読み込んだ背景画像をクリアする。
  • Mapped を保存
    「Mapped」パネルの画像(目標色マッピング結果)を保存する。
  • HSV Full を保存
    スライダー調整後の「HSV Full」画像を保存する。
  • Transparent 保存
    マスク外を透明化した画像を保存する(背景合成にも使える)。

🧰 2. バッチ処理系ボタン

  • バッチ処理(フォルダ内全て)
    フォルダ内の全画像に対して、現在の Mapped 設定を一括適用して保存。
  • 透明化バッチ (全画像)
    全画像に対して Transparent(マスク外透明化) を一括適用。
  • 8色バッチ (±45°×8)
    Hue を ±45°ずつ 8パターン生成して保存。
  • 6色バッチ (±60°×6)
    Hue を ±60°ずつ 6パターン生成して保存。
  • 4色バッチ (±90°×4)
    Hue を ±90°ずつ 4パターン生成して保存。

✍️ 3. マスク編集系ボタン

  • Undo (戻す)
    手動マスク編集の一つ前の状態に戻す。
  • Redo (やり直し)
    Undo を取り消して、やり直す。
  • Mask Fill (全追加)
    マスクを全面「追加」状態にする(全てマスク ON)。
  • Mask Fill (全消去)
    マスクを全面「消去」状態にする(全てマスク OFF)。
  • Mask Invert (反転)
    マスクを反転する(ON ↔ OFF)。
    ※ 初期状態で ON(invert)になっている。
  • 手動マスククリア
    手動で描いたマスクだけを消去し、自動マスクは残す。

🎨 4. 色取得系(キャンバス操作)

  • Original(左上)をダブルクリック
    → 元画像の色を取得し、HEX と HSV を表示。
  • Reference(右上)をクリック
    → 参照画像から「目標色」を取得し、Mapped に反映。
  • Mapped / HSV Full / Transparent をクリック
    → 出力画像の色を取得して確認できる。

🧪 5. HSV スライダー系

  • Hue (°)
    色相を -180°〜180°で調整。
  • Sat (%)
    彩度を -100〜100% で調整。
  • Val (%)
    明度を -100〜100% で調整。
  • Strength (%)
    変化量の強さ(0〜100%)。
  • Apply Hue / Sat / Val チェック
    どの成分を適用するか選択。
  • Preserve Skin
    肌色領域を保持して色変化させない。
  • Invert Mask (初期ON)
    マスクの意味を反転(背景を対象にするなど)。
  • Show Mask on Original
    Original にマスクのオーバーレイを表示。

🖌 6. マスク描画設定

  • Outline px
    マスク境界線の太さ。
  • Fill alpha
    マスク塗りつぶしの透明度。
  • Mask Scale %
    マスクの膨張・収縮(Morph)。
  • Brush Size px
    手動マスクのブラシサイズ。

詳細な説明

🧩 1. マスク編集の仕組み(内部処理まで完全解説)

マスクは numpy の bool 配列(H×W) として管理される。

✔ マスクの種類

  • 自動マスク
    make_skin_mask() やその他の自動生成ロジックで作られる。
  • 手動マスク
    ブラシ描画・消しゴム・塗りつぶし・Undo/Redo で編集される。

両者は最終的に self.current_mask に統合される。

✔ マスク編集の流れ

  • ブラシ描画 → 指定座標周辺を True(追加) にする
  • 消しゴム → 指定座標周辺を False(削除) にする
  • 塗りつぶし → 全面 True / 全面 False
  • Undo/Redo → deque に保存されたマスク履歴を復元
  • Invert → mask = ~mask

✔ マスクはどこで使われる?

Mapped / HSV Full の合成処理で使われる。

_compose_with_user_mask() を使うと:

composed = Image.composite(transformed, base, mask_pil)

つまり:

  • mask=True → transformed(色変換後)を採用
  • mask=False → base(元画像)を採用

これにより、マスクが Mapped / HSV Full に確実に反映される

🎨 2. Mapped と HSV Full の違い(内部アルゴリズムの違い)

Mapped(目標色マッピング)

apply_hsv_map_to_target_numpy() を使用。

  • 参照画像から取得した 目標 HSV に向けて
    Hue / Sat / Val を「絶対値で」近づける
  • Strength に応じて線形補間
  • 肌色保持(Preserve Skin)対応
  • 透明ピクセル処理(ignore / fill / clear)

目的:参照画像の色に合わせたいときに使う。

HSV Full(スライダーオフセット)

apply_hsv_blend_offset_numpy() を使用。

  • Hue を「相対的に +°」
  • Sat を「相対的に +%」
  • Val を「相対的に +%」
  • Strength に応じてブレンド

目的:色調補正・色味調整・トーン調整に使う。

✔ まとめ(違いを一言で)

モード目的処理方式
Mapped参照画像の色に合わせる絶対的に目標 HSV に近づける
HSV Full色味調整・補正相対的に Hue/Sat/Val をずらす

🎯 3. 自動マスクの精度を上げる方法(コードに基づく最適解)

✔ 1. YCbCr の範囲を調整する

あなたのコードの自動マスクは:

Cb, Cr, Y の範囲判定

UI の

  • Cr center / span
  • Cb center / span
  • Y center / span
    を調整することで精度が大きく変わる。

✔ 2. Mask Scale %(Morph)を使う

scale_mask_morph() により:

  • 100% → 元のマスク
  • 200% → 膨張(dilation)
  • 50% → 収縮(erosion)

髪の毛や輪郭の取りこぼしを補正できる。

✔ 3. 手動マスクで補正する

自動マスクは完璧ではないので:

  • ブラシで追加
  • 消しゴムで削除
  • Undo/Redo で調整
  • Outline 表示で境界確認

✔ 4. Show Mask on Original を ON

元画像にマスクを重ねて表示することで、
誤差を視覚的に確認しやすくなる。

✔ 5. 透明ピクセル処理を正しく設定

透明背景の PNG の場合:

  • ignore_transparent
  • fill_transparent
  • clear_transparent

を適切に設定すると、マスク精度が上がる。

🖼 4. 背景合成の正しい手順(Transparent パネルの仕組み)

✔ 1. マスクを作る

自動マスク → 手動補正 → Invert(必要なら)

✔ 2. Transparent モードを確認

Transparent は:

  • mask=True → 元画像の色を残す
  • mask=False → alpha=0(透明)にする

つまり背景を抜く処理。

✔ 3. 背景画像を読込

右側の
「背景画像を読込」
で背景をセット。

✔ 4. 合成処理

内部では:

Image.alpha_composite(foreground, background)

が使われる。

✔ 5. 保存

Transparent 保存 を押すと:

  • 背景合成済み PNG
  • 透明背景 PNG
    どちらも保存可能(設定による)。

プログラム

#!/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,
                                  ignore_transparent=False, fill_transparent=False, clear_transparent=False,
                                  trans_fill_color=(0, 0, 0)):
    h_t, s_t, v_t = target_hsv
    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
        alpha_arr = np.array(alpha)  # shape (H, W), uint8
    else:
        rgb_img = img.convert("RGB")
        alpha = None
        alpha_arr = 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)
    orig_arr_rgb = np.array(rgb_img)  # 元RGB保持(透明ピクセル復元用)
    arr = orig_arr_rgb.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_arr = np.clip(out * 255.0, 0, 255).astype(np.uint8)
    # 透明(alpha=0)ピクセルに対する処理(色彩をつけない/色変更/クリア)
    if alpha_arr is not None:
        transparent_mask = (alpha_arr == 0)
        if clear_transparent:
            out_arr[transparent_mask] = 0
        elif fill_transparent:
            fr, fg, fb = trans_fill_color
            out_arr[transparent_mask, 0] = fr
            out_arr[transparent_mask, 1] = fg
            out_arr[transparent_mask, 2] = fb
        elif ignore_transparent:
            out_arr[transparent_mask] = orig_arr_rgb[transparent_mask]
    out_img_rgb = Image.fromarray(out_arr)
    if preserve_skin and skin_mask is not None:
        out_arr2 = np.array(out_img_rgb); orig_arr2 = np.array(rgb_img)
        out_arr2[skin_mask] = orig_arr2[skin_mask]; out_img_rgb = Image.fromarray(out_arr2)
    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,
                                 ignore_transparent=False, fill_transparent=False, clear_transparent=False,
                                 trans_fill_color=(0, 0, 0)):
    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
        alpha_arr = np.array(alpha)
    else:
        rgb_img = img.convert("RGB")
        alpha = None
        alpha_arr = 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)
    orig_arr_rgb = np.array(rgb_img)
    arr = orig_arr_rgb.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_arr = np.clip(out * 255.0, 0, 255).astype(np.uint8)
    # 透明(alpha=0)ピクセルに対する処理(色彩をつけない/色変更/クリア)
    if alpha_arr is not None:
        transparent_mask = (alpha_arr == 0)
        if clear_transparent:
            out_arr[transparent_mask] = 0
        elif fill_transparent:
            fr, fg, fb = trans_fill_color
            out_arr[transparent_mask, 0] = fr
            out_arr[transparent_mask, 1] = fg
            out_arr[transparent_mask, 2] = fb
        elif ignore_transparent:
            out_arr[transparent_mask] = orig_arr_rgb[transparent_mask]
    out_img_rgb = Image.fromarray(out_arr)
    if preserve_skin and skin_mask is not None:
        out_arr2 = np.array(out_img_rgb); orig_arr2 = np.array(rgb_img)
        out_arr2[skin_mask] = orig_arr2[skin_mask]; out_img_rgb = Image.fromarray(out_arr2)
    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 (top 2, bottom 3)
        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_row_frame = tk.Frame(self.frame_center)
        self.top_row_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 2))
        self.bottom_row_frame = tk.Frame(self.frame_center)
        self.bottom_row_frame.grid(row=1, column=0, sticky="nsew", pady=(2, 0))

        self.frame_center.grid_rowconfigure(0, weight=1)
        self.frame_center.grid_rowconfigure(1, weight=1)
        self.frame_center.grid_columnconfigure(0, weight=1)

        # Top row: 2 columns (Original, Reference)
        self.top_row_frame.grid_rowconfigure(0, weight=1)
        self.top_row_frame.grid_columnconfigure(0, weight=1)
        self.top_row_frame.grid_columnconfigure(1, weight=1)

        self.top_left_frame = tk.Frame(self.top_row_frame); self.top_left_frame.grid(row=0, column=0, padx=2, pady=2, sticky="nsew")
        self.top_right_frame = tk.Frame(self.top_row_frame); self.top_right_frame.grid(row=0, column=1, padx=2, pady=2, sticky="nsew")

        # Bottom row: 3 columns (Mapped, HSV Full, Transparent)
        self.bottom_row_frame.grid_rowconfigure(0, weight=1)
        self.bottom_row_frame.grid_columnconfigure(0, weight=1)
        self.bottom_row_frame.grid_columnconfigure(1, weight=1)
        self.bottom_row_frame.grid_columnconfigure(2, weight=1)

        self.bottom_left_frame = tk.Frame(self.bottom_row_frame); self.bottom_left_frame.grid(row=0, column=0, padx=2, pady=2, sticky="nsew")
        self.bottom_mid_frame = tk.Frame(self.bottom_row_frame); self.bottom_mid_frame.grid(row=0, column=1, padx=2, pady=2, sticky="nsew")
        self.bottom_right_frame = tk.Frame(self.bottom_row_frame); self.bottom_right_frame.grid(row=0, column=2, padx=2, pady=2, sticky="nsew")

        self.panel_w = 240; self.panel_h = 260

        # 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_mid_frame, text="HSV Full (スライダーオフセット結果)", font=self.small_font).pack(anchor="w")
        self.canvas_bm = tk.Canvas(self.bottom_mid_frame, width=self.panel_w, height=self.panel_h, bg="gray")
        self.canvas_bm.pack(fill="both", expand=True)
        self.canvas_bm.bind("<Button-1>", self.pick_color_full)
        info_frame_bm = tk.Frame(self.bottom_mid_frame); info_frame_bm.pack(anchor="w", pady=(2, 0))
        self.bm_swatch = tk.Label(info_frame_bm, width=3, height=1, bg="#808080", bd=1, relief="solid"); self.bm_swatch.pack(side="left", padx=(0, 4))
        self.bm_info = tk.Label(info_frame_bm, text="HSV Full HEX: ---", font=self.small_font); self.bm_info.pack(side="left")

        # Transparent (下右)
        tk.Label(self.bottom_right_frame, text="Transparent (マスク外を透明化)", 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_trans)
        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="Trans 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="背景画像を読込", command=self.load_background_image, font=self.small_font, bg="#e8f8e8").grid(row=1, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="背景画像を解除", command=self.clear_background_image, font=self.small_font).grid(row=1, 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=2, 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=2, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Transparent 保存", command=self.save_transparent_image, font=self.small_font, bg="#e8d8ff").grid(row=3, column=0, columnspan=2, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="バッチ処理(フォルダ内全て)", command=self.batch_process_folder, font=self.small_font).grid(row=4, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="透明化バッチ (全画像)", command=self.batch_process_transparent, font=self.small_font, bg="#f0e0ff").grid(row=4, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="Undo (戻す)", command=lambda: self.undo_mask(), font=self.small_font).grid(row=5, 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=5, 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=6, 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=6, 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=7, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="手動マスククリア", command=self.clear_user_masks, font=self.small_font, bg="#ffe8e8").grid(row=7, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="8色バッチ (±45°×8)", command=self.batch_process_8colors, font=self.small_font, bg="#c8e6ff").grid(row=8, column=0, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="6色バッチ (±60°×6)", command=self.batch_process_6colors, font=self.small_font, bg="#d4f0d4").grid(row=8, column=1, sticky="ew", padx=1, pady=1)
        tk.Button(btn_frame, text="4色バッチ (±90°×4)", command=self.batch_process_4colors, font=self.small_font, bg="#ffe0c8").grid(row=9, 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 Shape", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
        self.brush_shape_var = tk.StringVar(value="circle")
        bs_frame = tk.Frame(grid_parent)
        bs_frame.grid(row=r, column=1, sticky="w", padx=(0, 2), pady=0)
        tk.Radiobutton(bs_frame, text="丸 (Circle)", variable=self.brush_shape_var, value="circle", command=self._on_brush_mode_change, font=self.small_font).pack(side="left")
        tk.Radiobutton(bs_frame, text="四角 (Square)", variable=self.brush_shape_var, value="square", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(4, 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", command=self._on_edit_mode_change, font=self.small_font).pack(side="left")
        tk.Radiobutton(mode_frame, text="ColorPick", variable=self.edit_mode_var, value="color", command=self._on_edit_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        tk.Radiobutton(mode_frame, text="ColorMask", variable=self.edit_mode_var, value="colormask", command=self._on_edit_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
        r += 1

        # 手動書き込みマスクの表示・保持・クリア設定
        self.show_user_mask_var = tk.IntVar(value=1)  # 手動マスクを表示(初期ON)
        self.keep_user_mask_var = tk.IntVar(value=0)  # 手動マスクを他画像/バッチに引継ぎ(初期OFF)

        um_frame = tk.Frame(grid_parent)
        um_frame.grid(row=r, column=0, columnspan=2, sticky="ew", padx=2, pady=(1, 0))
        tk.Checkbutton(um_frame, text="手動マスクを表示 (初期ON)", variable=self.show_user_mask_var,
                       command=self.update_hsv_from_slider, font=self.small_font).pack(side="left")
        tk.Button(um_frame, text="手動マスククリア", command=self.clear_user_masks, font=self.small_font, padx=2, pady=0, bg="#ffe8e8").pack(side="right")
        r += 1

        tk.Checkbutton(grid_parent, text="手動マスクを次画像/バッチに引継ぎ (初期OFF)",
                       variable=self.keep_user_mask_var, font=self.small_font).grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(0, 1))
        r += 1

        # Skin / Color detection sliders (コンパクトな横並びペア構成)
        skin_hdr = tk.Frame(grid_parent)
        skin_hdr.grid(row=r, column=0, columnspan=2, sticky="ew", padx=2, pady=(1, 0))
        tk.Label(skin_hdr, text="Auto/Skin Mask (Ctr / Span)", font=self.small_font).pack(side="left")
        tk.Button(skin_hdr, text="肌色初期値", command=self.reset_skin_thresholds, font=self.small_font, padx=2, pady=0).pack(side="right")
        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

        # --- 透明エリア処理オプション ---
        tk.Label(grid_parent, text="── 透明エリア オプション ──", font=self.small_font, fg="#666666").grid(
            row=r, column=0, columnspan=2, sticky="ew", padx=2, pady=(4, 0)); r += 1

        self.ignore_transparent_var = tk.IntVar(value=1)
        self.fill_transparent_var = tk.IntVar(value=0)
        self.clear_transparent_var = tk.IntVar(value=0)
        self.trans_fill_color = (0, 0, 0)  # デフォルト塗りつぶし色: 黒

        trans_chk1 = tk.Frame(grid_parent)
        trans_chk1.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=0); r += 1
        tk.Checkbutton(trans_chk1, text="透明エリアに色変換しない",
                       variable=self.ignore_transparent_var,
                       command=self.update_hsv_from_slider,
                       font=self.small_font).pack(side="left")

        trans_chk2 = tk.Frame(grid_parent)
        trans_chk2.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=0); r += 1
        tk.Checkbutton(trans_chk2, text="透明RGB塗りつぶし",
                       variable=self.fill_transparent_var,
                       command=self.update_hsv_from_slider,
                       font=self.small_font).pack(side="left")
        self.trans_fill_swatch = tk.Label(trans_chk2, width=2, height=1, bg="#000000", bd=1, relief="solid")
        self.trans_fill_swatch.pack(side="left", padx=(2, 2))
        tk.Button(trans_chk2, text="黒", font=self.small_font, padx=2, pady=0,
                  command=lambda: self._set_trans_fill_color(0, 0, 0)).pack(side="left", padx=(0, 1))
        tk.Button(trans_chk2, text="白", font=self.small_font, padx=2, pady=0,
                  command=lambda: self._set_trans_fill_color(255, 255, 255)).pack(side="left", padx=(0, 1))
        tk.Button(trans_chk2, text="選択", font=self.small_font, padx=2, pady=0,
                  command=self._pick_trans_fill_color).pack(side="left")

        trans_chk3 = tk.Frame(grid_parent)
        trans_chk3.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(0, 2)); r += 1
        tk.Checkbutton(trans_chk3, text="透明エリアをクリア(RGB=0,α=0)",
                       variable=self.clear_transparent_var,
                       command=self.update_hsv_from_slider,
                       font=self.small_font).pack(side="left")

        # --- 保存・背景オプション ---
        tk.Label(grid_parent, text="── 保存・背景オプション ──", font=self.small_font, fg="#666666").grid(
            row=r, column=0, columnspan=2, sticky="ew", padx=2, pady=(4, 0)); r += 1

        self.save_as_transparent_var = tk.IntVar(value=1)  # 初期は画像を透明で保存する
        self.use_bg_image_var = tk.IntVar(value=0)         # 初期は背景設定しない

        save_trans_frame = tk.Frame(grid_parent)
        save_trans_frame.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=0); r += 1
        tk.Checkbutton(save_trans_frame, text="画像を透明で保存 (初期ON)",
                       variable=self.save_as_transparent_var,
                       font=self.small_font).pack(side="left")

        bg_opt_frame = tk.Frame(grid_parent)
        bg_opt_frame.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(0, 2)); r += 1
        tk.Checkbutton(bg_opt_frame, text="背景画像を適用 (初期OFF)",
                       variable=self.use_bg_image_var,
                       command=self.update_hsv_from_slider,
                       font=self.small_font).pack(side="left")
        self.bg_status_label = tk.Label(bg_opt_frame, text="[背景: 未設定]", font=self.small_font, fg="#666666")
        self.bg_status_label.pack(side="left", padx=(2, 0))


        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.bg_img = None
        self.bg_filename = None
        self.target_hsv = (0.0, 0.0, 1.0)
        self.transformed_img = None
        self.full_img = None
        self.transparent_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()
        self._update_canvas_cursor()

    def _on_edit_mode_change(self):
        self._clear_preview()
        self._update_canvas_cursor()

    def _update_canvas_cursor(self):
        """色取得時はスポイト/十字カーソル、それ以外は通常カーソルを設定する"""
        em = self.edit_mode_var.get() if hasattr(self, "edit_mode_var") else "mask"
        if em in ("color", "colormask"):
            # Windows Tkinterでeyedropperまたはcrosshairを試す
            cursor_name = "crosshair"
            for test_cursor in ("eyedropper", "crosshair", "tcross"):
                try:
                    self.canvas_tl.config(cursor=test_cursor)
                    cursor_name = test_cursor
                    break
                except Exception:
                    continue
            for c in (self.canvas_tr, self.canvas_bl, self.canvas_bm, self.canvas_br):
                try:
                    c.config(cursor=cursor_name)
                except Exception:
                    pass
        else:
            try:
                self.canvas_tl.config(cursor="")
            except Exception:
                pass
            for c in (self.canvas_tr, self.canvas_bl, self.canvas_bm, self.canvas_br):
                try:
                    c.config(cursor="")
                except Exception:
                    pass


    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
        keep = getattr(self, "keep_user_mask_var", None) and self.keep_user_mask_var.get() == 1

        if getattr(self, "user_add_mask", None) is None:
            self.user_add_mask = np.zeros((h, w), dtype=bool)
        elif self.user_add_mask.shape != (h, w):
            if keep and np.any(self.user_add_mask):
                tmp = Image.fromarray((self.user_add_mask.astype(np.uint8) * 255))
                tmp = tmp.resize((w, h), resample=Image.NEAREST)
                self.user_add_mask = np.array(tmp) > 0
            else:
                self.user_add_mask = np.zeros((h, w), dtype=bool)

        if getattr(self, "user_erase_mask", None) is None:
            self.user_erase_mask = np.zeros((h, w), dtype=bool)
        elif self.user_erase_mask.shape != (h, w):
            if keep and np.any(self.user_erase_mask):
                tmp = Image.fromarray((self.user_erase_mask.astype(np.uint8) * 255))
                tmp = tmp.resize((w, h), resample=Image.NEAREST)
                self.user_erase_mask = np.array(tmp) > 0
            else:
                self.user_erase_mask = np.zeros((h, w), dtype=bool)

        self._user_mask_image = None

    def clear_user_masks(self):
        """手動で書き込みしたマスク(追加・消去)をすべてクリアする"""
        if not self.main_img:
            return
        self._ensure_user_masks()
        self.push_undo()
        w, h = self.main_img.size
        self.user_add_mask = np.zeros((h, w), dtype=bool)
        self.user_erase_mask = np.zeros((h, w), dtype=bool)
        self.update_hsv_from_slider()


    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]

        shape_val = self.brush_shape_var.get() if hasattr(self, "brush_shape_var") else "circle"
        if shape_val == "square":
            brush_mask = (np.abs(yy - img_y) <= radius) & (np.abs(xx - img_x) <= radius)
        else:
            dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
            brush_mask = dist2 <= (radius ** 2)

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

        self.update_hsv_from_slider()
        self._draw_brush_cursor(canvas, cx, cy)

    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
        if self.edit_mode_var.get() == "colormask":
            self._pick_color_for_mask(event)
            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
        if self.edit_mode_var.get() == "colormask":
            self._pick_color_for_mask(event)
            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() in ("color", "colormask"):
            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() in ("color", "colormask"):
            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()
        if hasattr(self, "show_user_mask_var") and self.show_user_mask_var.get() == 0:
            ua = np.zeros((img_h, img_w), dtype=bool)
            ue = np.zeros((img_h, img_w), dtype=bool)
        else:
            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]
            shape_val = self.brush_shape_var.get() if hasattr(self, "brush_shape_var") else "circle"
            if shape_val == "square":
                brush_mask = (np.abs(yy - img_y) <= radius) & (np.abs(xx - img_x) <= radius)
            else:
                dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
                brush_mask = dist2 <= (radius ** 2)
            region = np.zeros((h, w), dtype=bool)
            region[y0:y1, x0:x1] = brush_mask
        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 _draw_brush_cursor(self, canvas, cx, cy):
        """マウス位置に現在のブラシ太さ・形状(丸/四角)のカーソルインジケータ枠を描画する"""
        try:
            canvas.delete("brush_cursor")
        except Exception:
            pass
        if not self.main_img:
            return
        if getattr(self, "edit_mode_var", None) and self.edit_mode_var.get() != "mask":
            return
        bm = getattr(self, "brush_mode_var", None)
        bm_val = bm.get() if bm else "mouse"
        if bm_val in ("fill", "fill_erase"):
            return

        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

        if not (off_x <= cx <= off_x + disp_w and off_y <= cy <= off_y + disp_h):
            return

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

        disp_r = max(1, int(round(radius * scale)))
        shape = getattr(self, "brush_shape_var", None)
        shape_val = shape.get() if shape else "circle"

        # 背景が白でも黒でも常に見えるよう黒と白の二重枠線で描画
        if shape_val == "square":
            canvas.create_rectangle(cx - disp_r - 1, cy - disp_r - 1, cx + disp_r + 1, cy + disp_r + 1,
                                    outline="#000000", width=1, tags=("brush_cursor",))
            canvas.create_rectangle(cx - disp_r, cy - disp_r, cx + disp_r, cy + disp_r,
                                    outline="#ffffff", width=1, tags=("brush_cursor",))
        else:
            canvas.create_oval(cx - disp_r - 1, cy - disp_r - 1, cx + disp_r + 1, cy + disp_r + 1,
                                outline="#000000", width=1, tags=("brush_cursor",))
            canvas.create_oval(cx - disp_r, cy - disp_r, cx + disp_r, cy + disp_r,
                                outline="#ffffff", width=1, tags=("brush_cursor",))

    def _on_canvas_mouse_move(self, event):
        canvas = event.widget
        if self.edit_mode_var.get() != "mask":
            self._clear_preview()
            return

        bm = self.brush_mode_var.get()
        # 通常ブラシモード(mouse, add, erase)のときはブラシの太さカーソル枠を描画
        if bm not in ("fill", "fill_erase"):
            self._draw_brush_cursor(canvas, event.x, event.y)
            return

        # 塗りつぶしモード(fill, fill_erase)のときは領域ハイライトプレビュー
        if not self.main_img:
            self._clear_preview()
            return

        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
            try:
                self.canvas_tl.delete("brush_cursor")
            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 getattr(self, "use_bg_image_var", None) and self.use_bg_image_var.get() == 1 and getattr(self, "bg_img", None) is not None:
            try:
                bg_resized = self.bg_img.convert("RGBA").resize((w, h), Image.Resampling.BILINEAR)
                disp.paste(bg_resized, (paste_x, paste_y))
            except Exception:
                pass

        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.bm_swatch, self.bm_info, "HSV Full HEX", return_rgb=False)

    def pick_color_trans(self, event):
        if not self.transparent_img:
            return
        self._pick_color_generic(event, self.transparent_img, self.br_swatch, self.br_info, "Trans 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

    def _pick_color_for_mask(self, event):
        if not self.main_img:
            return
        result = self._pick_color_generic(event, self.main_img, self.tl_swatch, self.tl_info, "Mask Color", return_rgb=True)
        if result:
            r, g, b = result
            R = float(r); G = float(g); B = float(b)
            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)
            cb_pct = int(round(max(0.0, min(100.0, (Cb / 255.0) * 100.0))))
            cr_pct = int(round(max(0.0, min(100.0, (Cr / 255.0) * 100.0))))
            y_pct = int(round(max(0.0, min(100.0, (Y / 255.0) * 100.0))))
            self.cb_center.set(cb_pct)
            self.cr_center.set(cr_pct)
            self.y_center.set(y_pct)
            self.preserve_skin_var.set(1)
            self.update_hsv_from_slider()

    def reset_skin_thresholds(self):
        self.cb_center.set(40)
        self.cb_span.set(20)
        self.cr_center.set(60)
        self.cr_span.set(16)
        self.y_center.set(50)
        self.y_span.set(78)
        self.preserve_skin_var.set(1)
        self.update_hsv_from_slider()

    def _set_trans_fill_color(self, r, g, b):
        """透明エリアのRGB塗りつぶし色をプリセットで設定する"""
        self.trans_fill_color = (r, g, b)
        hex_color = rgb_to_hex(r, g, b)
        try:
            self.trans_fill_swatch.config(bg=hex_color)
        except Exception:
            pass
        if self.fill_transparent_var.get():
            self.update_hsv_from_slider()

    def _pick_trans_fill_color(self):
        """カラーダイアログで透明エリアのRGB塗りつぶし色を選択する"""
        from tkinter import colorchooser
        current_hex = rgb_to_hex(*self.trans_fill_color)
        result = colorchooser.askcolor(color=current_hex, title="透明エリアの塗りつぶし色を選択")
        if result and result[0]:
            r, g, b = int(result[0][0]), int(result[0][1]), int(result[0][2])
            self._set_trans_fill_color(r, g, b)

    def load_background_image(self):
        """背景画像ファイルを読み込み、バックグラウンドに設定する"""
        path = filedialog.askopenfilename(
            title="背景画像を選択",
            filetypes=[("Image files", "*.png;*.jpg;*.jpeg;*.bmp;*.webp")]
        )
        if not path:
            return
        try:
            self.bg_img = Image.open(path)
            self.bg_filename = os.path.basename(path)
            self.use_bg_image_var.set(1)  # 読み込んだら適用をONにする
            if hasattr(self, "bg_status_label"):
                short_name = self.bg_filename if len(self.bg_filename) <= 12 else self.bg_filename[:9] + "..."
                self.bg_status_label.config(text=f"[{short_name}]", fg="#0066cc")
            self.update_hsv_from_slider()
            print("Loaded background:", path)
        except Exception as e:
            messagebox.showerror("エラー", f"背景画像の読み込みに失敗しました:\n{e}")

    def clear_background_image(self):
        """読み込んだ背景画像を解除する"""
        self.bg_img = None
        self.bg_filename = None
        self.use_bg_image_var.set(0)
        if hasattr(self, "bg_status_label"):
            self.bg_status_label.config(text="[背景: 未設定]", fg="#666666")
        self.update_hsv_from_slider()

    def prepare_image_for_save(self, img):
        """保存用画像の前処理(透明で保存するか、背景と合成して非透明RGBで保存するかを判定)"""
        if img is None:
            return None
        # 画像を透明で保存する場合 (初期ON)
        if self.save_as_transparent_var.get() == 1:
            return img

        # 画像を透明で保存しない場合 (RGB画像として保存)
        if img.mode == "RGBA":
            w, h = img.size
            # 背景画像が有効かつ読み込まれていれば、背景画像と合成
            if self.use_bg_image_var.get() == 1 and self.bg_img is not None:
                bg = self.bg_img.convert("RGBA").resize((w, h), Image.Resampling.LANCZOS)
                composed = Image.alpha_composite(bg, img)
                return composed.convert("RGB")
            else:
                # 背景画像がなければ、透明RGB塗りつぶし色(または白)と合成
                if self.fill_transparent_var.get() == 1:
                    bg_color = self.trans_fill_color
                else:
                    bg_color = (255, 255, 255)
                bg = Image.new("RGBA", (w, h), (bg_color[0], bg_color[1], bg_color[2], 255))
                composed = Image.alpha_composite(bg, img)
                return composed.convert("RGB")
        else:
            return img.convert("RGB")



    # --- 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:
            ignore_trans = self.ignore_transparent_var.get() == 1
            fill_trans = self.fill_transparent_var.get() == 1
            clear_trans = self.clear_transparent_var.get() == 1
            trans_fill_color = self.trans_fill_color

            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, ignore_transparent=ignore_trans,
                fill_transparent=fill_trans, clear_transparent=clear_trans,
                trans_fill_color=trans_fill_color
            )

            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)
                if self.main_img.mode == "RGBA":
                    # 透明ピクセルへの処理(マスク合成後も透明部を確実に維持・変更)
                    trans_mask = (out_arr[:, :, 3] == 0)
                    if clear_trans:
                        out_arr[trans_mask, :3] = 0
                    elif fill_trans:
                        out_arr[trans_mask, 0] = trans_fill_color[0]
                        out_arr[trans_mask, 1] = trans_fill_color[1]
                        out_arr[trans_mask, 2] = trans_fill_color[2]
                    self.transformed_img = Image.fromarray(out_arr, mode="RGBA")
                else:
                    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

            ignore_trans = self.ignore_transparent_var.get() == 1
            fill_trans = self.fill_transparent_var.get() == 1
            clear_trans = self.clear_transparent_var.get() == 1
            trans_fill_color = self.trans_fill_color

            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, ignore_transparent=ignore_trans,
                fill_transparent=fill_trans, clear_transparent=clear_trans,
                trans_fill_color=trans_fill_color
            )

            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)
                if self.main_img.mode == "RGBA":
                    trans_mask = (out_arr[:, :, 3] == 0)
                    if clear_trans:
                        out_arr[trans_mask, :3] = 0
                    elif fill_trans:
                        out_arr[trans_mask, 0] = trans_fill_color[0]
                        out_arr[trans_mask, 1] = trans_fill_color[1]
                        out_arr[trans_mask, 2] = trans_fill_color[2]
                    self.full_img = Image.fromarray(out_arr, mode="RGBA")
                else:
                    self.full_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")

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

        # Transparent (マスク領域のみを残し、マスク外を完全透明化 + オプション処理)
        try:
            orig_rgba = ensure_rgba(self.main_img)
            trans_arr = np.array(orig_rgba, dtype=np.uint8).copy()
            h, w = trans_arr.shape[0], trans_arr.shape[1]
            if applied_mask is not None:
                m = applied_mask
                if m.shape != (h, w):
                    m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                    m_img = m_img.resize((w, h), resample=Image.NEAREST)
                    m = np.array(m_img) > 0
                # マスク外を透明化
                trans_arr[~m, 3] = 0

            # オプション: 透明エリア(alpha=0)のRGBを塗りつぶし色に変更
            fill_trans = self.fill_transparent_var.get() == 1
            clear_trans = self.clear_transparent_var.get() == 1
            if fill_trans or clear_trans:
                transparent_pixels = (trans_arr[:, :, 3] == 0)
                if clear_trans:
                    # 完全クリア: RGB + alpha すべてゼロ(すでにalpha=0なのでRGBをゼロに)
                    trans_arr[transparent_pixels, 0] = 0
                    trans_arr[transparent_pixels, 1] = 0
                    trans_arr[transparent_pixels, 2] = 0
                elif fill_trans:
                    # RGB塗りつぶし: 透明ピクセルのRGBを指定色に変更(alphaは0のまま)
                    fr, fg, fb = self.trans_fill_color
                    trans_arr[transparent_pixels, 0] = fr
                    trans_arr[transparent_pixels, 1] = fg
                    trans_arr[transparent_pixels, 2] = fb

            self.transparent_img = Image.fromarray(trans_arr, mode="RGBA")
            self.show_image(self.canvas_br, self.transparent_img)
        except Exception as e:
            _vprint("Transparent generation error:", e)
            self.transparent_img = None


        self._update_right_slider_range()

    def _get_batch_mask(self, img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask):
        """バッチ処理用のマスクを計算(自動マスク + 手動マスク引き継ぎ設定の反映)"""
        iw, ih = img.size
        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

        # 手動マスクの引き継ぎ・バッチ適用(keep_user_mask_var == 1 かつ show_user_mask_var == 1)
        if getattr(self, "keep_user_mask_var", None) and self.keep_user_mask_var.get() == 1:
            if getattr(self, "show_user_mask_var", None) and self.show_user_mask_var.get() == 1:
                if cur_mask is None:
                    cur_mask = np.zeros((ih, iw), dtype=bool)
                if getattr(self, "user_erase_mask", None) is not None and np.any(self.user_erase_mask):
                    ue = self.user_erase_mask
                    if ue.shape != (ih, iw):
                        ue_img = Image.fromarray((ue.astype(np.uint8) * 255))
                        ue_img = ue_img.resize((iw, ih), resample=Image.NEAREST)
                        ue = np.array(ue_img) > 0
                    cur_mask[ue] = False
                if getattr(self, "user_add_mask", None) is not None and np.any(self.user_add_mask):
                    ua = self.user_add_mask
                    if ua.shape != (ih, iw):
                        ua_img = Image.fromarray((ua.astype(np.uint8) * 255))
                        ua_img = ua_img.resize((iw, ih), resample=Image.NEAREST)
                        ua = np.array(ua_img) > 0
                    cur_mask[ua] = True
        return cur_mask

    # --- 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")
        out_trans_folder = os.path.join(out_root, "transparent")
        os.makedirs(out_mapped_folder, exist_ok=True)
        os.makedirs(out_full_folder, exist_ok=True)
        os.makedirs(out_trans_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 = self._get_batch_mask(img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask)

            ignore_trans = self.ignore_transparent_var.get() == 1
            fill_trans = self.fill_transparent_var.get() == 1
            clear_trans = self.clear_transparent_var.get() == 1
            trans_fill_color = self.trans_fill_color

            # Mapped 処理
            try:
                mapped = apply_hsv_map_to_target_numpy(
                    img, target_hsv, strength_mapped, use_h, use_s, use_v,
                    preserve_skin=False, ignore_transparent=ignore_trans,
                    fill_transparent=fill_trans, clear_transparent=clear_trans,
                    trans_fill_color=trans_fill_color
                )
                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)
                    if img.mode == "RGBA":
                        trans_mask = (out_arr[:, :, 3] == 0)
                        if clear_trans:
                            out_arr[trans_mask, :3] = 0
                        elif fill_trans:
                            out_arr[trans_mask, 0] = trans_fill_color[0]
                            out_arr[trans_mask, 1] = trans_fill_color[1]
                            out_arr[trans_mask, 2] = trans_fill_color[2]
                        final_mapped = Image.fromarray(out_arr, mode="RGBA")
                    else:
                        final_mapped = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
                else:
                    final_mapped = mapped

                final_mapped = self.prepare_image_for_save(final_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, ignore_transparent=ignore_trans,
                    fill_transparent=fill_trans, clear_transparent=clear_trans,
                    trans_fill_color=trans_fill_color
                )
                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)
                    if img.mode == "RGBA":
                        trans_mask = (out_arr[:, :, 3] == 0)
                        if clear_trans:
                            out_arr[trans_mask, :3] = 0
                        elif fill_trans:
                            out_arr[trans_mask, 0] = trans_fill_color[0]
                            out_arr[trans_mask, 1] = trans_fill_color[1]
                            out_arr[trans_mask, 2] = trans_fill_color[2]
                        final_full = Image.fromarray(out_arr, mode="RGBA")
                    else:
                        final_full = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
                else:
                    final_full = full

                final_full = self.prepare_image_for_save(final_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)))

            # Transparent 処理 (マスク外を透明化 + オプション処理)
            try:
                orig_rgba = ensure_rgba(img)
                trans_arr = np.array(orig_rgba, dtype=np.uint8).copy()
                ih, iw = trans_arr.shape[0], trans_arr.shape[1]
                if cur_mask is not None and cur_mask.sum() > 0:
                    m = cur_mask
                    if m.shape != (ih, iw):
                        m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                        m_img = m_img.resize((iw, ih), resample=Image.NEAREST)
                        m = np.array(m_img) > 0
                    trans_arr[~m, 3] = 0
                if fill_trans or clear_trans:
                    transparent_pixels = (trans_arr[:, :, 3] == 0)
                    if clear_trans:
                        trans_arr[transparent_pixels, 0] = 0
                        trans_arr[transparent_pixels, 1] = 0
                        trans_arr[transparent_pixels, 2] = 0
                    elif fill_trans:
                        fr, fg, fb = trans_fill_color
                        trans_arr[transparent_pixels, 0] = fr
                        trans_arr[transparent_pixels, 1] = fg
                        trans_arr[transparent_pixels, 2] = fb
                final_trans = Image.fromarray(trans_arr, mode="RGBA")
                final_trans = self.prepare_image_for_save(final_trans)
                out_trans = os.path.join(out_trans_folder, f"{base_name}_transparent_{now_str}.png")
                final_trans.save(out_trans, format="PNG")
            except Exception as e:
                errors.append((fname + " (transparent)", str(e)))

            processed += 1


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

    def batch_process_transparent(self):
        """フォルダ内の全画像に対してマスク外を透明化したRGBA PNG画像をバッチ保存する"""
        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_folder = os.path.basename(folder.rstrip("/\\"))
        out_root = os.path.join(parent, base_folder + "_transparent_out")
        os.makedirs(out_root, exist_ok=True)

        preserve_skin = self.preserve_skin_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1
        fill_trans = self.fill_transparent_var.get() == 1
        clear_trans = self.clear_transparent_var.get() == 1
        trans_fill_color = self.trans_fill_color

        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()
        )
        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_files = 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 = self._get_batch_mask(img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask)

            try:
                orig_rgba = ensure_rgba(img)
                trans_arr = np.array(orig_rgba, dtype=np.uint8).copy()
                ih, iw = trans_arr.shape[0], trans_arr.shape[1]
                if cur_mask is not None and cur_mask.sum() > 0:
                    m = cur_mask
                    if m.shape != (ih, iw):
                        m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                        m_img = m_img.resize((iw, ih), resample=Image.NEAREST)
                        m = np.array(m_img) > 0
                    trans_arr[~m, 3] = 0
                if fill_trans or clear_trans:
                    transparent_pixels = (trans_arr[:, :, 3] == 0)
                    if clear_trans:
                        trans_arr[transparent_pixels, 0] = 0
                        trans_arr[transparent_pixels, 1] = 0
                        trans_arr[transparent_pixels, 2] = 0
                    elif fill_trans:
                        fr, fg, fb = trans_fill_color
                        trans_arr[transparent_pixels, 0] = fr
                        trans_arr[transparent_pixels, 1] = fg
                        trans_arr[transparent_pixels, 2] = fb
                final_trans = Image.fromarray(trans_arr, mode="RGBA")
                final_trans = self.prepare_image_for_save(final_trans)
                out_path = os.path.join(out_root, f"{base_name}_transparent_{now_str}.png")
                final_trans.save(out_path, format="PNG")
                processed += 1
            except Exception as e:
                errors.append((fname, str(e)))

        msg = f"透明化バッチ処理が完了しました。\n処理済み: {processed}/{total_files}\n出力フォルダ: {out_root}\n\nマスク外を透明化したPNGを保存しました。"
        if errors:
            msg += f"\nエラー件数: {len(errors)}(詳細はコンソール参照)"
            print("透明化バッチ errors:")
            for e in errors:
                print(e)
        messagebox.showinfo("透明化バッチ 完了", msg)

    def batch_process_8colors(self):
        """参照画像の色相を基準に45°ずつずらした8色でフォルダ内の全画像をバッチ変換する。
        出力ファイル名: {base_name}_1.png ~ {base_name}_8.png
        色相シフト量:    0°(1), 45°(2), 90°(3), 135°(4), 180°(5), 225°(6), 270°(7), 315°(8)
        """
        # 参照画像の色相を取得
        if self.ref_img is None and self.target_hsv == (0.0, 0.0, 1.0):
            messagebox.showwarning("8色バッチ", "参照画像が読み込まれていません。\n先に参照画像を読み込んでから実行してください。")
            return

        folder = filedialog.askdirectory(title="8色バッチ処理するフォルダを選択")
        if not folder:
            return

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

        # 参照色の色相を取得(target_hsvが設定済みならそれを使用、なければスライダー値)
        base_h, base_s, base_v = self.target_hsv
        if base_h == 0.0 and base_s == 0.0 and base_v == 1.0:
            # target_hsvが初期値のままの場合はスライダーから取得
            h_deg = self.h_scale.get()
            base_h = ((h_deg + 180) / 360.0) % 1.0
            base_s = min(1.0, max(0.0, (self.s_scale.get() / 200.0) + 0.5))
            base_v = min(1.0, max(0.0, (self.v_scale.get() / 200.0) + 0.5))

        use_h = True   # 色相シフトは常に有効
        use_s = self.s_var.get() == 1
        use_v = self.v_var.get() == 1
        strength = 1.0
        preserve_skin = self.preserve_skin_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1
        ignore_trans = self.ignore_transparent_var.get() == 1
        fill_trans = self.fill_transparent_var.get() == 1
        clear_trans = self.clear_transparent_var.get() == 1
        trans_fill_color = self.trans_fill_color

        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()
        )
        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("8色バッチ", "指定フォルダに画像ファイルが見つかりません。")
            return

        total_files = len(files)
        errors = []

        # 8色のターゲットHSVを生成(45°刻み)
        color_targets = []
        for i in range(8):
            shift = (i * 45) / 360.0
            h_new = (base_h + shift) % 1.0
            color_targets.append((h_new, base_s, base_v))

        processed = 0
        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 = self._get_batch_mask(img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask)

            # 8色それぞれ処理
            for color_idx, target_hsv in enumerate(color_targets):
                color_num = color_idx + 1  # 1〜8
                try:
                    mapped = apply_hsv_map_to_target_numpy(
                        img, target_hsv, strength, use_h, use_s, use_v,
                        preserve_skin=False, ignore_transparent=ignore_trans,
                        fill_transparent=fill_trans, clear_transparent=clear_trans,
                        trans_fill_color=trans_fill_color
                    )
                    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)
                        ih, iw = orig_arr.shape[0], orig_arr.shape[1]
                        m = cur_mask
                        if m.shape != (ih, iw):
                            m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                            m_img = m_img.resize((iw, ih), resample=Image.NEAREST)
                            m = np.array(m_img) > 0
                        mask_exp = np.repeat(m[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                        out_arr = np.where(mask_exp, mapped_arr, orig_arr)
                        if img.mode == "RGBA":
                            trans_mask = (out_arr[:, :, 3] == 0)
                            if clear_trans:
                                out_arr[trans_mask, :3] = 0
                            elif fill_trans:
                                out_arr[trans_mask, 0] = trans_fill_color[0]
                                out_arr[trans_mask, 1] = trans_fill_color[1]
                                out_arr[trans_mask, 2] = trans_fill_color[2]
                            final_img = Image.fromarray(out_arr, mode="RGBA")
                        else:
                            final_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
                    else:
                        final_img = mapped

                    final_img = self.prepare_image_for_save(final_img)
                    out_path = os.path.join(out_root, f"{base_name}_{color_num}.png")
                    final_img.save(out_path, format="PNG")
                except Exception as e:
                    errors.append((f"{fname} (color {color_num})", str(e)))

            processed += 1

        # 色見本サマリーを生成
        try:
            swatch_size = 60
            swatch_img = Image.new("RGB", (swatch_size * 8, swatch_size + 20), (240, 240, 240))
            import colorsys as _cs
            for i, (h, s, v) in enumerate(color_targets):
                r_f, g_f, b_f = _cs.hsv_to_rgb(h, s, v)
                r_i, g_i, b_i = int(r_f * 255), int(g_f * 255), int(b_f * 255)
                for py in range(swatch_size):
                    for px_s in range(swatch_size):
                        swatch_img.putpixel((i * swatch_size + px_s, py), (r_i, g_i, b_i))
            swatch_path = os.path.join(out_root, f"_color_swatch_{now_str}.png")
            swatch_img.save(swatch_path, format="PNG")
        except Exception:
            pass

        msg = (f"8色バッチ処理が完了しました。\n"
               f"処理済み画像数: {processed}/{total_files}\n"
               f"各画像×8色 = {processed * 8}ファイル出力\n"
               f"出力フォルダ: {out_root}\n\n"
               f"ファイル名: {{元ファイル名}}_1.png ~ _8.png\n"
               f"色相シフト: 0°→45°→90°→…→315°")
        if errors:
            msg += f"\nエラー件数: {len(errors)}(詳細はコンソール参照)"
            print("8色バッチ errors:")
            for e in errors:
                print(e)
        messagebox.showinfo("8色バッチ 完了", msg)

    def batch_process_6colors(self):
        """参照画像の色相を基準に60°ずつずらした6色でフォルダ内の全画像をバッチ変換する。
        出力ファイル名: {base_name}_1.png ~ {base_name}_6.png
        色相シフト量:    0°(1), 60°(2), 120°(3), 180°(4), 240°(5), 300°(6)
        """
        if self.ref_img is None and self.target_hsv == (0.0, 0.0, 1.0):
            messagebox.showwarning("6色バッチ", "参照画像が読み込まれていません。\n先に参照画像を読み込んでから実行してください。")
            return

        folder = filedialog.askdirectory(title="6色バッチ処理するフォルダを選択")
        if not folder:
            return

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

        base_h, base_s, base_v = self.target_hsv
        if base_h == 0.0 and base_s == 0.0 and base_v == 1.0:
            h_deg = self.h_scale.get()
            base_h = ((h_deg + 180) / 360.0) % 1.0
            base_s = min(1.0, max(0.0, (self.s_scale.get() / 200.0) + 0.5))
            base_v = min(1.0, max(0.0, (self.v_scale.get() / 200.0) + 0.5))

        use_h = True
        use_s = self.s_var.get() == 1
        use_v = self.v_var.get() == 1
        strength = 1.0
        preserve_skin = self.preserve_skin_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1
        ignore_trans = self.ignore_transparent_var.get() == 1
        fill_trans = self.fill_transparent_var.get() == 1
        clear_trans = self.clear_transparent_var.get() == 1
        trans_fill_color = self.trans_fill_color

        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()
        )
        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("6色バッチ", "指定フォルダに画像ファイルが見つかりません。")
            return

        total_files = len(files)
        errors = []

        color_targets = []
        for i in range(6):
            shift = (i * 60) / 360.0
            h_new = (base_h + shift) % 1.0
            color_targets.append((h_new, base_s, base_v))

        processed = 0
        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 = self._get_batch_mask(img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask)

            for color_idx, target_hsv in enumerate(color_targets):
                color_num = color_idx + 1
                try:
                    mapped = apply_hsv_map_to_target_numpy(
                        img, target_hsv, strength, use_h, use_s, use_v,
                        preserve_skin=False, ignore_transparent=ignore_trans,
                        fill_transparent=fill_trans, clear_transparent=clear_trans,
                        trans_fill_color=trans_fill_color
                    )
                    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)
                        ih, iw = orig_arr.shape[0], orig_arr.shape[1]
                        m = cur_mask
                        if m.shape != (ih, iw):
                            m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                            m_img = m_img.resize((iw, ih), resample=Image.NEAREST)
                            m = np.array(m_img) > 0
                        mask_exp = np.repeat(m[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                        out_arr = np.where(mask_exp, mapped_arr, orig_arr)
                        if img.mode == "RGBA":
                            trans_mask = (out_arr[:, :, 3] == 0)
                            if clear_trans:
                                out_arr[trans_mask, :3] = 0
                            elif fill_trans:
                                out_arr[trans_mask, 0] = trans_fill_color[0]
                                out_arr[trans_mask, 1] = trans_fill_color[1]
                                out_arr[trans_mask, 2] = trans_fill_color[2]
                            final_img = Image.fromarray(out_arr, mode="RGBA")
                        else:
                            final_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
                    else:
                        final_img = mapped

                    final_img = self.prepare_image_for_save(final_img)
                    out_path = os.path.join(out_root, f"{base_name}_{color_num}.png")
                    final_img.save(out_path, format="PNG")
                except Exception as e:
                    errors.append((f"{fname} (color {color_num})", str(e)))

            processed += 1

        try:
            swatch_size = 60
            swatch_img = Image.new("RGB", (swatch_size * 6, swatch_size + 20), (240, 240, 240))
            import colorsys as _cs
            for i, (h, s, v) in enumerate(color_targets):
                r_f, g_f, b_f = _cs.hsv_to_rgb(h, s, v)
                r_i, g_i, b_i = int(r_f * 255), int(g_f * 255), int(b_f * 255)
                for py in range(swatch_size):
                    for px_s in range(swatch_size):
                        swatch_img.putpixel((i * swatch_size + px_s, py), (r_i, g_i, b_i))
            swatch_path = os.path.join(out_root, f"_color_swatch_{now_str}.png")
            swatch_img.save(swatch_path, format="PNG")
        except Exception:
            pass

        msg = (f"6色バッチ処理が完了しました。\n"
               f"処理済み画像数: {processed}/{total_files}\n"
               f"各画像×6色 = {processed * 6}ファイル出力\n"
               f"出力フォルダ: {out_root}\n\n"
               f"ファイル名: {{元ファイル名}}_1.png ~ _6.png\n"
               f"色相シフト: 0°→60°→120°→180°→240°→300°")
        if errors:
            msg += f"\nエラー件数: {len(errors)}(詳細はコンソール参照)"
            print("6色バッチ errors:")
            for e in errors:
                print(e)
        messagebox.showinfo("6色バッチ 完了", msg)

    def batch_process_4colors(self):
        """参照画像の色相を基準に90°ずつずらした4色でフォルダ内の全画像をバッチ変換する。
        出力ファイル名: {base_name}_1.png ~ {base_name}_4.png
        色相シフト量:    0°(1), 90°(2), 180°(3), 270°(4)
        """
        if self.ref_img is None and self.target_hsv == (0.0, 0.0, 1.0):
            messagebox.showwarning("4色バッチ", "参照画像が読み込まれていません。\n先に参照画像を読み込んでから実行してください。")
            return

        folder = filedialog.askdirectory(title="4色バッチ処理するフォルダを選択")
        if not folder:
            return

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

        base_h, base_s, base_v = self.target_hsv
        if base_h == 0.0 and base_s == 0.0 and base_v == 1.0:
            h_deg = self.h_scale.get()
            base_h = ((h_deg + 180) / 360.0) % 1.0
            base_s = min(1.0, max(0.0, (self.s_scale.get() / 200.0) + 0.5))
            base_v = min(1.0, max(0.0, (self.v_scale.get() / 200.0) + 0.5))

        use_h = True
        use_s = self.s_var.get() == 1
        use_v = self.v_var.get() == 1
        strength = 1.0
        preserve_skin = self.preserve_skin_var.get() == 1
        invert_mask = self.invert_mask_var.get() == 1
        ignore_trans = self.ignore_transparent_var.get() == 1
        fill_trans = self.fill_transparent_var.get() == 1
        clear_trans = self.clear_transparent_var.get() == 1
        trans_fill_color = self.trans_fill_color

        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()
        )
        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("4色バッチ", "指定フォルダに画像ファイルが見つかりません。")
            return

        total_files = len(files)
        errors = []

        color_targets = []
        for i in range(4):
            shift = (i * 90) / 360.0
            h_new = (base_h + shift) % 1.0
            color_targets.append((h_new, base_s, base_v))

        processed = 0
        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 = self._get_batch_mask(img, cb_min, cb_max, cr_min, cr_max, y_min, y_max, scale_pct, preserve_skin, invert_mask)

            for color_idx, target_hsv in enumerate(color_targets):
                color_num = color_idx + 1
                try:
                    mapped = apply_hsv_map_to_target_numpy(
                        img, target_hsv, strength, use_h, use_s, use_v,
                        preserve_skin=False, ignore_transparent=ignore_trans,
                        fill_transparent=fill_trans, clear_transparent=clear_trans,
                        trans_fill_color=trans_fill_color
                    )
                    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)
                        ih, iw = orig_arr.shape[0], orig_arr.shape[1]
                        m = cur_mask
                        if m.shape != (ih, iw):
                            m_img = Image.fromarray((m.astype(np.uint8) * 255).astype(np.uint8))
                            m_img = m_img.resize((iw, ih), resample=Image.NEAREST)
                            m = np.array(m_img) > 0
                        mask_exp = np.repeat(m[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
                        out_arr = np.where(mask_exp, mapped_arr, orig_arr)
                        if img.mode == "RGBA":
                            trans_mask = (out_arr[:, :, 3] == 0)
                            if clear_trans:
                                out_arr[trans_mask, :3] = 0
                            elif fill_trans:
                                out_arr[trans_mask, 0] = trans_fill_color[0]
                                out_arr[trans_mask, 1] = trans_fill_color[1]
                                out_arr[trans_mask, 2] = trans_fill_color[2]
                            final_img = Image.fromarray(out_arr, mode="RGBA")
                        else:
                            final_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
                    else:
                        final_img = mapped

                    final_img = self.prepare_image_for_save(final_img)
                    out_path = os.path.join(out_root, f"{base_name}_{color_num}.png")
                    final_img.save(out_path, format="PNG")
                except Exception as e:
                    errors.append((f"{fname} (color {color_num})", str(e)))

            processed += 1

        try:
            swatch_size = 60
            swatch_img = Image.new("RGB", (swatch_size * 4, swatch_size + 20), (240, 240, 240))
            import colorsys as _cs
            for i, (h, s, v) in enumerate(color_targets):
                r_f, g_f, b_f = _cs.hsv_to_rgb(h, s, v)
                r_i, g_i, b_i = int(r_f * 255), int(g_f * 255), int(b_f * 255)
                for py in range(swatch_size):
                    for px_s in range(swatch_size):
                        swatch_img.putpixel((i * swatch_size + px_s, py), (r_i, g_i, b_i))
            swatch_path = os.path.join(out_root, f"_color_swatch_{now_str}.png")
            swatch_img.save(swatch_path, format="PNG")
        except Exception:
            pass

        msg = (f"4色バッチ処理が完了しました。\n"
               f"処理済み画像数: {processed}/{total_files}\n"
               f"各画像×4色 = {processed * 4}ファイル出力\n"
               f"出力フォルダ: {out_root}\n\n"
               f"ファイル名: {{元ファイル名}}_1.png ~ _4.png\n"
               f"色相シフト: 0°→90°→180°→270°")
        if errors:
            msg += f"\nエラー件数: {len(errors)}(詳細はコンソール参照)"
            print("4色バッチ errors:")
            for e in errors:
                print(e)
        messagebox.showinfo("4色バッチ 完了", 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:
            out_img = self.prepare_image_for_save(self.transformed_img)
            out_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:
            out_img = self.prepare_image_for_save(self.full_img)
            out_img.save(path, format="PNG")
            print("Saved HSV Full:", path)

    def save_transparent_image(self):
        if not self.transparent_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}_transparent_{now_str}.png"
        path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
        if path:
            out_img = self.prepare_image_for_save(self.transparent_img)
            out_img.save(path, format="PNG")
            print("Saved Transparent:", 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)
        if getattr(self, "keep_user_mask_var", None) and self.keep_user_mask_var.get() == 0:
            self.user_add_mask = None
            self.user_erase_mask = None
        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