hsv 色変換ツール

はじめに

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

プログラム

import os
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk, ImageStat, Image
import numpy as np
import colorsys
from functools import partial

# -----------------------------
# ユーティリティ
# -----------------------------
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):
    if img.mode == "RGBA":
        return img
    return img.convert("RGBA")

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
            if (tx ^ ty) == 0:
                px[x, y] = color1
            else:
                px[x, y] = color2
    return board

# -----------------------------
# 参照色に「寄せる」マッピング(H/S/V をそれぞれターゲットへ線形に寄せる)
# アルファ対応: 入力が RGBA の場合は RGB 部分のみ処理し、アルファを再結合する
# -----------------------------
def apply_hsv_map_to_target_numpy(img, target_hsv, strength, use_h, use_s, use_v):
    h_t, s_t, v_t = target_hsv

    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
    else:
        rgb_img = img.convert("RGB")
        alpha = None

    arr = np.array(rgb_img).astype(np.float32) / 255.0

    maxc = np.max(arr, axis=-1)
    minc = np.min(arr, axis=-1)
    delta = maxc - minc

    hue = np.zeros_like(maxc)
    mask = delta != 0

    idx = (maxc == arr[..., 0]) & mask
    hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
    idx = (maxc == arr[..., 1]) & mask
    hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
    idx = (maxc == arr[..., 2]) & mask
    hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]

    hue = (hue / 6.0) % 1.0
    sat = np.zeros_like(maxc)
    sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
    val = maxc

    if use_h:
        dh = (h_t - hue + 0.5) % 1.0 - 0.5
        hue = (hue + strength * dh) % 1.0

    if use_s:
        sat = np.clip((1 - strength) * sat + strength * s_t, 0.0, 1.0)

    if use_v:
        val = np.clip((1 - strength) * val + strength * v_t, 0.0, 1.0)

    i = np.floor(hue * 6).astype(int)
    f = hue * 6 - i
    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 % 6 == 0)
    r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
    idx = (i == 1)
    r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
    idx = (i == 2)
    r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
    idx = (i == 3)
    r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
    idx = (i == 4)
    r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
    idx = (i == 5)
    r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]

    out = np.stack([r2, g2, b2], axis=-1)
    out_img = Image.fromarray((out * 255).astype(np.uint8))

    if alpha is not None:
        out_img = merge_alpha(out_img, alpha)

    return out_img

# -----------------------------
# HSV Full(オフセット + 強度ブレンド)アルファ対応
# -----------------------------
def apply_hsv_blend_offset_numpy(img, h_offset, s_offset, v_offset, strength, use_h, use_s, use_v):
    if img.mode == "RGBA":
        rgb_img, alpha = split_alpha(img)
    else:
        rgb_img = img.convert("RGB")
        alpha = None

    arr = np.array(rgb_img).astype(np.float32) / 255.0
    maxc = np.max(arr, axis=-1)
    minc = np.min(arr, axis=-1)
    delta = maxc - minc

    hue = np.zeros_like(maxc)
    mask = delta != 0

    idx = (maxc == arr[..., 0]) & mask
    hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
    idx = (maxc == arr[..., 1]) & mask
    hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
    idx = (maxc == arr[..., 2]) & mask
    hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]

    hue = (hue / 6.0) % 1.0
    sat = np.zeros_like(maxc)
    sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
    val = maxc

    if use_h:
        hue = (1 - strength) * hue + strength * ((hue + h_offset) % 1.0)
    if use_s:
        sat = (1 - strength) * sat + strength * np.clip(sat + s_offset, 0.0, 1.0)
    if use_v:
        val = (1 - strength) * val + strength * np.clip(val + v_offset, 0.0, 1.0)

    i = np.floor(hue * 6).astype(int)
    f = hue * 6 - i
    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 % 6 == 0)
    r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
    idx = (i == 1)
    r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
    idx = (i == 2)
    r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
    idx = (i == 3)
    r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
    idx = (i == 4)
    r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
    idx = (i == 5)
    r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]

    out = np.stack([r2, g2, b2], axis=-1)
    out_img = Image.fromarray((out * 255).astype(np.uint8))

    if alpha is not None:
        out_img = merge_alpha(out_img, alpha)

    return out_img

# -----------------------------
# GUI
# -----------------------------
class ColorTool:
    def __init__(self, root):
        self.root = root
        self.root.title("HSV Tool - 参照色で左下を寄せる(アルファ・チェッカーボード対応)")
        self.root.geometry("1100x700")

        # 左:サムネイル領域
        self.frame_left = tk.Frame(root, width=220)
        self.frame_left.pack(side="left", fill="y")
        self.frame_left.pack_propagate(False)

        self.scroll_canvas = tk.Canvas(self.frame_left, width=200, height=700)
        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))

        # 中央
        self.frame_center = tk.Frame(root, width=620)
        self.frame_center.pack(side="left", fill="y")
        self.frame_center.pack_propagate(False)

        # Original / Reference
        self.frame_top = tk.Frame(self.frame_center)
        self.frame_top.pack(pady=5)

        tk.Label(self.frame_top, text="Original Image").grid(row=0, column=0, padx=5)
        self.canvas_main = tk.Canvas(self.frame_top, width=300, height=300, bg="gray")
        self.canvas_main.grid(row=1, column=0, padx=5, pady=2)
        self.canvas_main.bind("<Button-1>", self.pick_color_main)

        self.original_info_frame = tk.Frame(self.frame_top)
        self.original_info_frame.grid(row=2, column=0, pady=4)
        self.original_swatch = tk.Label(self.original_info_frame, width=6, height=1, bg="#808080", bd=1, relief="solid")
        self.original_swatch.pack(side="left", padx=6)
        self.hex_main_label = tk.Label(self.original_info_frame, text="Original HEX: ---")
        self.hex_main_label.pack(side="left", padx=6)

        tk.Label(self.frame_top, text="Reference Image").grid(row=0, column=1, padx=5)
        self.canvas_ref = tk.Canvas(self.frame_top, width=300, height=300, bg="gray")
        self.canvas_ref.grid(row=1, column=1, padx=5, pady=2)
        self.canvas_ref.bind("<Button-1>", self.pick_color_ref)

        self.ref_info_frame = tk.Frame(self.frame_top)
        self.ref_info_frame.grid(row=2, column=1, pady=4)
        self.ref_swatch = tk.Label(self.ref_info_frame, width=6, height=1, bg="#808080", bd=1, relief="solid")
        self.ref_swatch.pack(side="left", padx=6)
        self.hex_ref_label = tk.Label(self.ref_info_frame, text="Reference HEX: ---")
        self.hex_ref_label.pack(side="left", padx=6)

        # Mapped / HSV Full
        self.frame_bottom = tk.Frame(self.frame_center)
        self.frame_bottom.pack(pady=5)

        mapped_frame = tk.Frame(self.frame_bottom)
        mapped_frame.grid(row=0, column=0, padx=10)
        tk.Label(mapped_frame, text="Mapped (参照色に寄せる)").pack()
        self.canvas_out = tk.Canvas(mapped_frame, width=300, height=300, bg="gray")
        self.canvas_out.pack()
        self.canvas_out.bind("<Button-1>", self.pick_color_out)
        self.hex_out_label = tk.Label(mapped_frame, text="Mapped HEX: ---")
        self.hex_out_label.pack(pady=2)

        full_frame = tk.Frame(self.frame_bottom)
        full_frame.grid(row=0, column=1, padx=10)
        tk.Label(full_frame, text="HSV Full (Offset Blend)").pack()
        self.canvas_full = tk.Canvas(full_frame, width=300, height=300, bg="gray")
        self.canvas_full.pack()
        self.canvas_full.bind("<Button-1>", self.pick_color_full)
        self.hex_full_label = tk.Label(full_frame, text="HSV Full HEX: ---")
        self.hex_full_label.pack(pady=2)

        # 右:コントロール
        self.frame_right = tk.Frame(root, width=250)
        self.frame_right.pack(side="right", fill="y")
        self.frame_right.pack_propagate(False)

        tk.Button(self.frame_right, text="フォルダを選択", command=self.load_folder).pack(pady=8, fill="x", padx=8)
        tk.Button(self.frame_right, text="参照画像を読み込み", command=self.load_reference_image).pack(pady=8, fill="x", padx=8)
        tk.Button(self.frame_right, text="Mapped を保存", command=self.save_transformed_image).pack(pady=8, fill="x", padx=8)
        tk.Button(self.frame_right, text="HSV Full を保存", command=self.save_full_image).pack(pady=8, fill="x", padx=8)
        tk.Button(self.frame_right, text="バッチ処理(フォルダ内全て)", command=self.batch_process_folder).pack(pady=8, fill="x", padx=8)

        self.h_scale = tk.Scale(self.frame_right, from_=-180, to=180, orient="horizontal", label="Hue Offset (°)", command=self._on_slider_change)
        self.h_scale.set(0)
        self.h_scale.pack(fill="x", padx=8)

        self.s_scale = tk.Scale(self.frame_right, from_=-100, to=100, orient="horizontal", label="Saturation Offset (%)", command=self._on_slider_change)
        self.s_scale.set(0)
        self.s_scale.pack(fill="x", padx=8)

        self.v_scale = tk.Scale(self.frame_right, from_=-100, to=100, orient="horizontal", label="Value Offset (%)", command=self._on_slider_change)
        self.v_scale.set(0)
        self.v_scale.pack(fill="x", padx=8)

        self.strength_scale = tk.Scale(self.frame_right, from_=0, to=100, orient="horizontal", label="Strength (%)", command=self._on_slider_change)
        self.strength_scale.set(100)
        self.strength_scale.pack(fill="x", padx=8)

        # 初期値: Apply Hue のみ ON
        self.h_var = tk.IntVar(value=1)
        self.s_var = tk.IntVar(value=0)
        self.v_var = tk.IntVar(value=0)
        tk.Checkbutton(self.frame_right, text="Apply Hue", variable=self.h_var, command=self._on_slider_change).pack(anchor="w", padx=8)
        tk.Checkbutton(self.frame_right, text="Apply Saturation", variable=self.s_var, command=self._on_slider_change).pack(anchor="w", padx=8)
        tk.Checkbutton(self.frame_right, text="Apply Value", variable=self.v_var, command=self._on_slider_change).pack(anchor="w", padx=8)

        # 初期化
        self.main_img = None
        self.ref_img = None
        self.target_hsv = (0.0, 0.0, 1.0)
        self.transformed_img = None
        self.full_img = None
        self.current_filename = None
        self.thumbnails = []

    # -----------------------------
    # スクロール領域更新
    # -----------------------------
    def _on_frame_configure(self, event):
        self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))

    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 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((150, 150))
            w_t, h_t = thumb.size

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

            if thumb.mode == "RGBA":
                bg_thumb = checker_thumb.copy()
                bg_thumb.paste(thumb, ((150 - w_t) // 2, (150 - h_t) // 2), thumb.split()[-1])
                disp_thumb = bg_thumb.convert("RGB")
            else:
                bg_thumb = checker_thumb.copy()
                bg_thumb.paste(thumb, ((150 - w_t) // 2, (150 - 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=4, pady=6, fill="x")

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

            name_label = tk.Label(item_frame, text=f, wraplength=200, anchor="w", justify="left")
            name_label.pack(side="top", padx=6, pady=(4,0))

        self.thumb_frame.update_idletasks()
        self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))

    def on_thumbnail_click(self, fullpath, event):
        try:
            img = Image.open(fullpath)
        except Exception:
            return
        self.main_img = img
        self.current_filename = os.path.basename(fullpath)
        self.show_image(self.canvas_main, self.main_img)
        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_ref, self.ref_img)

    # -----------------------------
    # show_image をチェッカーボード表示対応に置き換え
    # -----------------------------
    def show_image(self, canvas, img):
        canvas.delete("all")
        tmp = img.copy()
        tmp.thumbnail((300, 300))
        w, h = tmp.size

        checker = make_checkerboard((300, 300), tile=10, color1=(255,255,255), color2=(220,220,220))

        if tmp.mode == "RGBA":
            bg = checker.copy()
            bg.paste(tmp, ((300 - w) // 2, (300 - h) // 2), tmp.split()[-1])
            disp = bg
        else:
            bg = checker.copy()
            bg.paste(tmp, ((300 - w) // 2, (300 - h) // 2))
            disp = bg

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

    # -----------------------------
    # クリックで色を選択(オリジナル)
    # -----------------------------
    def pick_color_main(self, event):
        if not self.main_img:
            return
        self._pick_color_generic(event, self.main_img, self.original_swatch, self.hex_main_label, "Original HEX")

    # -----------------------------
    # 参照クリックで参照色を設定 → 左下(Mapped)を参照色に寄せる
    # -----------------------------
    def pick_color_ref(self, event):
        if not self.ref_img:
            return
        result = self._pick_color_generic(event, self.ref_img, self.ref_swatch, self.hex_ref_label, "Reference HEX", return_rgb=True)
        if not result:
            return
        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

        try:
            self.strength_scale.set(100)
        except Exception:
            pass

        self.update_hsv_from_slider()

    # -----------------------------
    # 汎用クリック色取得(キャンバス上の座標を画像座標に変換)
    # -----------------------------
    def _pick_color_generic(self, event, img, swatch, label, prefix, return_rgb=False):
        canvas = event.widget
        canvas_x = event.x
        canvas_y = event.y
        img_w, img_h = img.size
        scale = min(300 / img_w, 300 / img_h)
        disp_w = int(img_w * scale)
        disp_h = int(img_h * scale)
        offset_x = (300 - disp_w) // 2
        offset_y = (300 - disp_h) // 2

        if not (offset_x <= canvas_x < offset_x + disp_w and offset_y <= canvas_y < offset_y + disp_h):
            return None

        rel_x = canvas_x - offset_x
        rel_y = canvas_y - offset_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))

        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:
            swatch.config(bg=hex_color)
            label.config(text=f"{prefix}: {hex_color}")
        except Exception:
            pass

        if return_rgb:
            return (r, g, b)
        return None

    # -----------------------------
    # Mapped / Full のクリックで色取得
    # -----------------------------
    def pick_color_out(self, event):
        if not self.transformed_img:
            return
        self._pick_color_generic(event, self.transformed_img, None, self.hex_out_label, "Mapped HEX")

    def pick_color_full(self, event):
        if not self.full_img:
            return
        self._pick_color_generic(event, self.full_img, None, self.hex_full_label, "HSV Full HEX")

    # -----------------------------
    # スライダーで変換を更新
    # -----------------------------
    def _on_slider_change(self, event=None):
        self.update_hsv_from_slider()

    def update_hsv_from_slider(self, event=None):
        if not self.main_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

        try:
            self.transformed_img = apply_hsv_map_to_target_numpy(self.main_img, self.target_hsv, strength_mapped, use_h, use_s, use_v)
            self.show_image(self.canvas_out, self.transformed_img)
        except Exception:
            self.transformed_img = None

        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
            use_h_full = self.h_var.get() == 1
            use_s_full = self.s_var.get() == 1
            use_v_full = self.v_var.get() == 1

            self.full_img = apply_hsv_blend_offset_numpy(self.main_img, h_offset, s_off, v_off, strength_full, use_h_full, use_s_full, use_v_full)
            self.show_image(self.canvas_full, self.full_img)
        except Exception:
            self.full_img = None

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

        parent = os.path.dirname(folder)
        base = os.path.basename(folder.rstrip("/\\"))
        out_folder = os.path.join(parent, base + "_batch_out")
        os.makedirs(out_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

        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

        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)

            try:
                mapped = apply_hsv_map_to_target_numpy(img, target_hsv, strength_mapped, use_h, use_s, use_v)
                out_mapped = os.path.join(out_folder, base_name + "_mapping.png")
                mapped.save(out_mapped, format="PNG")
            except Exception as e:
                errors.append((fname + " (mapped)", str(e)))

            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)
                out_full = os.path.join(out_folder, base_name + "_hsv_full.png")
                full.save(out_full, format="PNG")
            except Exception as e:
                errors.append((fname + " (full)", str(e)))

            processed += 1

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

    # -----------------------------
    # 保存
    # -----------------------------
    def save_transformed_image(self):
        if not self.transformed_img or not self.current_filename:
            return
        base, ext = os.path.splitext(self.current_filename)
        save_name = base + "_mapping.png"
        path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
        if path:
            self.transformed_img.save(path, format="PNG")
            print("Saved:", path)

    def save_full_image(self):
        if not self.full_img or not self.current_filename:
            return
        base, ext = os.path.splitext(self.current_filename)
        save_name = base + "_hsv_full.png"
        path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
        if path:
            self.full_img.save(path, format="PNG")
            print("Saved HSV Full:", path)

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

Python

Posted by eightban