マスク対応したhsv 色変換 背景を透明にするツール
はじめに

HSV Tool – 完全版(コンパクトUI・ファイル名日時付き保存・マスク初期反転・自動マスク確実反映・手動ブラシ/消しゴム/塗りつぶし・Undo/Redo・バッチ処理完全対応)
プログラム
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HSV Tool - 完全版(コンパクトUI・ファイル名日時付き保存・マスク初期反転・自動マスク確実反映・手動ブラシ/消しゴム/塗りつぶし・Undo/Redo・バッチ処理完全対応)
- 動作環境: Python 3.x, Pillow, numpy. scipy は任意(あると高速化)
- 目的: 自動マスクおよび手動編集マスクが確実に Mapped / HSV Full に反映されるようにする
"""
import os
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk, ImageFilter, ImageChops
import numpy as np
import colorsys
from functools import partial
from collections import deque
from datetime import datetime
VERBOSE = False
try:
from scipy import ndimage
_HAS_SCIPY = True
except Exception:
ndimage = None
_HAS_SCIPY = False
def _vprint(*args, **kwargs):
if VERBOSE:
print(*args, **kwargs)
def rgb_to_hex(r, g, b):
return "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b))
def rgb_to_hsv_tuple(r, g, b):
return colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0)
def ensure_rgba(img):
return img.convert("RGBA") if img.mode != "RGBA" else img
def split_alpha(img):
rgba = ensure_rgba(img)
rgb = rgba.convert("RGB")
alpha = rgba.split()[-1]
return rgb, alpha
def merge_alpha(rgb_img, alpha):
rgba = rgb_img.convert("RGBA")
r, g, b, _ = rgba.split()
return Image.merge("RGBA", (r, g, b, alpha))
def make_checkerboard(size=(300, 300), tile=10, color1=(255, 255, 255), color2=(220, 220, 220)):
w, h = size
board = Image.new("RGB", (w, h), color1)
px = board.load()
for y in range(h):
for x in range(w):
tx = (x // tile) % 2
ty = (y // tile) % 2
px[x, y] = color1 if (tx ^ ty) == 0 else color2
return board
def get_skin_thresholds_from_center_span_percent(cb_center_pct, cb_span_pct,
cr_center_pct, cr_span_pct,
y_center_pct, y_span_pct):
def pct_to_range(center_pct, span_pct):
center = center_pct / 100.0 * 255.0
span = span_pct / 100.0 * 255.0
mn = int(max(0, center - span / 2.0))
mx = int(min(255, center + span / 2.0))
return mn, mx
cb_min, cb_max = pct_to_range(cb_center_pct, cb_span_pct)
cr_min, cr_max = pct_to_range(cr_center_pct, cr_span_pct)
y_min, y_max = pct_to_range(y_center_pct, y_span_pct)
return cb_min, cb_max, cr_min, cr_max, y_min, y_max
def make_skin_mask(pil_img, cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
rgb = pil_img.convert("RGB")
arr = np.array(rgb).astype(np.int32)
if arr.ndim != 3 or arr.shape[2] < 3:
return np.zeros((arr.shape[0], arr.shape[1]), dtype=bool)
R = arr[..., 0]; G = arr[..., 1]; B = arr[..., 2]
Cb = (-0.168736 * R - 0.331264 * G + 0.5 * B) + 128.0
Cr = (0.5 * R - 0.418688 * G - 0.081312 * B) + 128.0
Y = (0.299 * R + 0.587 * G + 0.114 * B)
mask = (Cb >= cb_min) & (Cb <= cb_max) & (Cr >= cr_min) & (Cr <= cr_max)
mask &= (Y >= y_min) & (Y <= y_max)
return mask.astype(bool)
def scale_mask_morph(mask, scale_pct, max_iter=30):
if mask is None:
return None
if not isinstance(mask, np.ndarray):
mask = np.array(mask) > 0
mask_bool = mask.astype(bool)
if mask_bool.sum() == 0:
return mask_bool
ratio = float(scale_pct) / 100.0
if ratio == 1.0:
return mask_bool
iters = int(min(max_iter, max(1, round(abs(ratio - 1.0) * 30))))
try:
if _HAS_SCIPY:
structure = np.ones((3, 3), dtype=bool)
if ratio < 1.0:
out = ndimage.binary_erosion(mask_bool, structure=structure, iterations=iters)
else:
out = ndimage.binary_dilation(mask_bool, structure=structure, iterations=iters)
return out
else:
pil_mask = Image.fromarray((mask_bool.astype(np.uint8) * 255).astype(np.uint8))
size = 3 + 2 * max(0, iters - 1)
if ratio < 1.0:
inv = ImageChops.invert(pil_mask)
eroded_inv = inv.filter(ImageFilter.MaxFilter(size=size))
eroded = ImageChops.invert(eroded_inv)
out_arr = np.array(eroded) > 0
else:
dil = pil_mask.filter(ImageFilter.MaxFilter(size=size))
out_arr = np.array(dil) > 0
return out_arr
except Exception:
return mask_bool
def mask_to_outline_image(mask, thickness=1, alpha=220, color=(255, 255, 255)):
if mask is None:
return None
mask_bool = mask.astype(bool)
h, w = mask_bool.shape
if mask_bool.sum() == 0:
return Image.new("RGBA", (w, h), (0, 0, 0, 0))
if _HAS_SCIPY:
structure = np.ones((3, 3), dtype=bool)
eroded = ndimage.binary_erosion(mask_bool, structure=structure, iterations=max(1, thickness))
dilated = ndimage.binary_dilation(mask_bool, structure=structure, iterations=max(1, thickness))
outline = dilated ^ eroded
out = np.zeros((h, w, 4), dtype=np.uint8)
out[..., 0] = color[0]; out[..., 1] = color[1]; out[..., 2] = color[2]
out[..., 3] = (outline.astype(np.uint8) * alpha)
return Image.fromarray(out, mode="RGBA")
else:
pil_mask = Image.fromarray((mask_bool.astype(np.uint8) * 255).astype(np.uint8))
size = 3 + 2 * max(0, thickness - 1)
dil = pil_mask.filter(ImageFilter.MaxFilter(size=size))
inv = ImageChops.invert(pil_mask)
eroded_inv = inv.filter(ImageFilter.MaxFilter(size=size))
eroded = ImageChops.invert(eroded_inv)
outline = ImageChops.difference(dil, eroded).convert("L")
white = Image.new("RGBA", (w, h), (color[0], color[1], color[2], 0))
white.putalpha(outline.point(lambda p: int(p / 255.0 * alpha)))
return white
def make_mask_overlay_display(img, mask, overlay_color=(255, 0, 128), fill_alpha=120, outline_mode=True, outline_thickness=1, outline_alpha=220):
base = img.convert("RGBA")
w, h = base.size
if mask is None:
return base.convert("RGB")
if not isinstance(mask, np.ndarray):
try:
mask = np.array(mask)
except Exception:
return base.convert("RGB")
if mask.dtype != bool:
mask = mask > 0
composed = base.copy()
if fill_alpha > 0:
overlay = Image.new("RGBA", (w, h), (overlay_color[0], overlay_color[1], overlay_color[2], 0))
mask_img = Image.fromarray((mask.astype(np.uint8) * fill_alpha).astype(np.uint8))
overlay.putalpha(mask_img)
composed = Image.alpha_composite(composed, overlay)
if outline_mode:
outline_img = mask_to_outline_image(mask, thickness=max(1, int(outline_thickness)), alpha=outline_alpha, color=(255, 255, 255))
if outline_img is not None:
composed = Image.alpha_composite(composed, outline_img)
return composed.convert("RGB")
def apply_hsv_map_to_target_numpy(img, target_hsv, strength, use_h, use_s, use_v, preserve_skin=False,
cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
h_t, s_t, v_t = target_hsv
if img.mode == "RGBA":
rgb_img, alpha = split_alpha(img)
else:
rgb_img = img.convert("RGB")
alpha = None
skin_mask = None
if preserve_skin:
skin_mask = make_skin_mask(rgb_img, cb_min, cb_max, cr_min, cr_max, y_min, y_max)
arr = np.array(rgb_img).astype(np.float32) / 255.0
maxc = np.max(arr, axis=-1); minc = np.min(arr, axis=-1); delta = maxc - minc
hue = np.zeros_like(maxc); mask = delta != 0
idx = (maxc == arr[..., 0]) & mask
hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
idx = (maxc == arr[..., 1]) & mask
hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
idx = (maxc == arr[..., 2]) & mask
hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]
hue = (hue / 6.0) % 1.0
sat = np.zeros_like(maxc); sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
val = maxc
if use_h:
dh = (h_t - hue + 0.5) % 1.0 - 0.5
hue = (hue + strength * dh) % 1.0
if use_s:
sat = np.clip((1 - strength) * sat + strength * s_t, 0.0, 1.0)
if use_v:
val = np.clip((1 - strength) * val + strength * v_t, 0.0, 1.0)
i = np.floor(hue * 6).astype(int) % 6; f = hue * 6 - np.floor(hue * 6)
p = val * (1 - sat); q = val * (1 - f * sat); t = val * (1 - (1 - f) * sat)
r2 = np.zeros_like(val); g2 = np.zeros_like(val); b2 = np.zeros_like(val)
idx = (i == 0); r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
idx = (i == 1); r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
idx = (i == 2); r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
idx = (i == 3); r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
idx = (i == 4); r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
idx = (i == 5); r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]
out = np.stack([r2, g2, b2], axis=-1)
out_img_rgb = Image.fromarray(np.clip(out * 255.0, 0, 255).astype(np.uint8))
if preserve_skin and skin_mask is not None:
out_arr = np.array(out_img_rgb); orig_arr = np.array(rgb_img)
out_arr[skin_mask] = orig_arr[skin_mask]; out_img_rgb = Image.fromarray(out_arr)
if alpha is not None:
out_img = merge_alpha(out_img_rgb, alpha)
else:
out_img = out_img_rgb
return out_img
def apply_hsv_blend_offset_numpy(img, h_offset, s_offset, v_offset, strength, use_h, use_s, use_v, preserve_skin=False,
cb_min=77, cb_max=127, cr_min=133, cr_max=173, y_min=40, y_max=240):
if img.mode == "RGBA":
rgb_img, alpha = split_alpha(img)
else:
rgb_img = img.convert("RGB")
alpha = None
skin_mask = None
if preserve_skin:
skin_mask = make_skin_mask(rgb_img, cb_min, cb_max, cr_min, cr_max, y_min, y_max)
arr = np.array(rgb_img).astype(np.float32) / 255.0
maxc = np.max(arr, axis=-1); minc = np.min(arr, axis=-1); delta = maxc - minc
hue = np.zeros_like(maxc); mask = delta != 0
idx = (maxc == arr[..., 0]) & mask
hue[idx] = (arr[..., 1][idx] - arr[..., 2][idx]) / delta[idx]
idx = (maxc == arr[..., 1]) & mask
hue[idx] = 2.0 + (arr[..., 2][idx] - arr[..., 0][idx]) / delta[idx]
idx = (maxc == arr[..., 2]) & mask
hue[idx] = 4.0 + (arr[..., 0][idx] - arr[..., 1][idx]) / delta[idx]
hue = (hue / 6.0) % 1.0
sat = np.zeros_like(maxc); sat[maxc != 0] = delta[maxc != 0] / maxc[maxc != 0]
val = maxc
if use_h:
hue = (1 - strength) * hue + strength * ((hue + h_offset) % 1.0)
if use_s:
sat = (1 - strength) * sat + strength * np.clip(sat + s_offset, 0.0, 1.0)
if use_v:
val = (1 - strength) * val + strength * np.clip(val + v_offset, 0.0, 1.0)
i = np.floor(hue * 6).astype(int) % 6; f = hue * 6 - np.floor(hue * 6)
p = val * (1 - sat); q = val * (1 - f * sat); t = val * (1 - (1 - f) * sat)
r2 = np.zeros_like(val); g2 = np.zeros_like(val); b2 = np.zeros_like(val)
idx = (i == 0); r2[idx], g2[idx], b2[idx] = val[idx], t[idx], p[idx]
idx = (i == 1); r2[idx], g2[idx], b2[idx] = q[idx], val[idx], p[idx]
idx = (i == 2); r2[idx], g2[idx], b2[idx] = p[idx], val[idx], t[idx]
idx = (i == 3); r2[idx], g2[idx], b2[idx] = p[idx], q[idx], val[idx]
idx = (i == 4); r2[idx], g2[idx], b2[idx] = t[idx], p[idx], val[idx]
idx = (i == 5); r2[idx], g2[idx], b2[idx] = val[idx], p[idx], q[idx]
out = np.stack([r2, g2, b2], axis=-1)
out_img_rgb = Image.fromarray(np.clip(out * 255.0, 0, 255).astype(np.uint8))
if preserve_skin and skin_mask is not None:
out_arr = np.array(out_img_rgb); orig_arr = np.array(rgb_img)
out_arr[skin_mask] = orig_arr[skin_mask]; out_img_rgb = Image.fromarray(out_arr)
if alpha is not None:
out_img = merge_alpha(out_img_rgb, alpha)
else:
out_img = out_img_rgb
return out_img
class ColorTool:
def __init__(self, root):
self.root = root
self.root.title("HSV Tool - Complete Version")
self.root.geometry("1340x760")
self.small_font = ("TkDefaultFont", 8)
# left thumbnails
self.frame_left = tk.Frame(root, width=200)
self.frame_left.pack(side="left", fill="y")
self.frame_left.pack_propagate(False)
self.scroll_canvas = tk.Canvas(self.frame_left, width=180, height=740)
self.scroll_canvas.pack(side="left", fill="y", expand=False)
self.scrollbar = tk.Scrollbar(self.frame_left, orient="vertical", command=self.scroll_canvas.yview)
self.scrollbar.pack(side="right", fill="y")
self.scroll_canvas.configure(yscrollcommand=self.scrollbar.set)
self.thumb_frame = tk.Frame(self.scroll_canvas)
self.thumb_window = self.scroll_canvas.create_window((0, 0), window=self.thumb_frame, anchor="nw")
self.thumb_frame.bind("<Configure>", self._on_frame_configure)
self.scroll_canvas.bind("<Enter>", lambda e: self._bind_mousewheel(True))
self.scroll_canvas.bind("<Leave>", lambda e: self._bind_mousewheel(False))
# center (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="Mapped を保存", command=self.save_transformed_image, font=self.small_font).grid(row=1, column=0, sticky="ew", padx=1, pady=1)
tk.Button(btn_frame, text="HSV Full を保存", command=self.save_full_image, font=self.small_font).grid(row=1, column=1, sticky="ew", padx=1, pady=1)
tk.Button(btn_frame, text="Transparent 保存", command=self.save_transparent_image, font=self.small_font, bg="#e8d8ff").grid(row=2, 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=3, 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=3, 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=4, 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=4, 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=5, 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=5, 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=6, column=0, columnspan=2, 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=7, 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=7, 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=8, column=0, columnspan=2, sticky="ew", padx=1, pady=1)
grid_parent = tk.Frame(ctrl)
grid_parent.pack(fill="x", padx=1, pady=0)
r = 0
self.h_scale = add_labeled_scale(grid_parent, r, "Hue (°)", -180, 180, init=0); r += 1
self.s_scale = add_labeled_scale(grid_parent, r, "Sat (%)", -100, 100, init=0); r += 1
self.v_scale = add_labeled_scale(grid_parent, r, "Val (%)", -100, 100, init=0); r += 1
self.strength_scale = add_labeled_scale(grid_parent, r, "Strength (%)", 0, 100, init=100); r += 1
self.h_var = tk.IntVar(value=1); self.s_var = tk.IntVar(value=0); self.v_var = tk.IntVar(value=0)
chk_frame = tk.Frame(grid_parent); chk_frame.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(1, 0)); r += 1
tk.Checkbutton(chk_frame, text="Apply Hue", variable=self.h_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
tk.Checkbutton(chk_frame, text="Apply Sat", variable=self.s_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
tk.Checkbutton(chk_frame, text="Apply Val", variable=self.v_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left")
self.preserve_skin_var = tk.IntVar(value=1)
self.invert_mask_var = tk.IntVar(value=1) # マスク反転状態を初期値ONに設定
self.show_preserved_var = tk.IntVar(value=1)
chk_frame2 = tk.Frame(grid_parent); chk_frame2.grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=0); r += 1
tk.Checkbutton(chk_frame2, text="Preserve Skin", variable=self.preserve_skin_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left", padx=(0, 4))
tk.Checkbutton(chk_frame2, text="Invert Mask (初期ON)", variable=self.invert_mask_var, command=self.update_hsv_from_slider, font=self.small_font).pack(side="left")
tk.Checkbutton(grid_parent, text="Show Mask on Original", variable=self.show_preserved_var, command=self.update_hsv_from_slider, font=self.small_font).grid(row=r, column=0, columnspan=2, sticky="w", padx=2, pady=(0, 1)); r += 1
self.outline_thickness_scale = add_labeled_scale(grid_parent, r, "Outline px", 1, 8, init=1); r += 1
self.fill_alpha_scale = add_labeled_scale(grid_parent, r, "Fill alpha", 0, 255, init=120); r += 1
self.mask_scale = add_labeled_scale(grid_parent, r, "Mask Scale %", 50, 200, init=100); r += 1
tk.Label(grid_parent, text="Brush Size px", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
self.brush_size = tk.Scale(grid_parent, from_=1, to=200, orient="horizontal", length=slider_length, showvalue=True,
width=8, sliderlength=14, highlightthickness=0, bd=1)
self.brush_size.set(22)
self.brush_size.grid(row=r, column=1, sticky="ew", padx=(0, 2), pady=0)
r += 1
tk.Label(grid_parent, text="Brush Mode", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
self.brush_mode_var = tk.StringVar(value="mouse")
bm_frame = tk.Frame(grid_parent)
bm_frame.grid(row=r, column=1, sticky="w", padx=(0, 2), pady=0)
tk.Radiobutton(bm_frame, text="Mouse", variable=self.brush_mode_var, value="mouse", command=self._on_brush_mode_change, font=self.small_font).pack(side="left")
tk.Radiobutton(bm_frame, text="Add", variable=self.brush_mode_var, value="add", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
tk.Radiobutton(bm_frame, text="Erase", variable=self.brush_mode_var, value="erase", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
tk.Radiobutton(bm_frame, text="Fill", variable=self.brush_mode_var, value="fill", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
tk.Radiobutton(bm_frame, text="FillE", variable=self.brush_mode_var, value="fill_erase", command=self._on_brush_mode_change, font=self.small_font).pack(side="left", padx=(2, 0))
r += 1
tk.Label(grid_parent, text="Edit Mode", font=self.small_font).grid(row=r, column=0, sticky="w", padx=(2, 1), pady=0)
self.edit_mode_var = tk.StringVar(value="mask")
mode_frame = tk.Frame(grid_parent)
mode_frame.grid(row=r, column=1, sticky="w", padx=(0, 2), pady=0)
tk.Radiobutton(mode_frame, text="Mask", variable=self.edit_mode_var, value="mask", font=self.small_font).pack(side="left")
tk.Radiobutton(mode_frame, text="ColorPick", variable=self.edit_mode_var, value="color", font=self.small_font).pack(side="left", padx=(2, 0))
tk.Radiobutton(mode_frame, text="ColorMask", variable=self.edit_mode_var, value="colormask", font=self.small_font).pack(side="left", padx=(2, 0))
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
# bind events after creation
for s in (self.h_scale, self.s_scale, self.v_scale, self.strength_scale,
self.outline_thickness_scale, self.fill_alpha_scale, self.mask_scale,
self.cb_center, self.cb_span, self.cr_center, self.cr_span, self.y_center, self.y_span,
self.brush_size):
try:
s.configure(command=lambda v, s=s: self.update_hsv_from_slider())
s.bind("<B1-Motion>", lambda e, s=s: self.update_hsv_from_slider())
s.bind("<ButtonRelease-1>", lambda e, s=s: self.update_hsv_from_slider())
except Exception:
pass
# brush state
self.user_add_mask = None
self.user_erase_mask = None
self._user_mask_image = None
self._brush_drawing = False
self._brush_mode = "add"
self.undo_stack = []
self.redo_stack = []
self._preview_photo = None
self._preview_image_id = None
self._last_preview_pos = (-1, -1)
# bindings for brush and preview
self.canvas_tl.bind("<ButtonPress-1>", lambda e: self._on_canvas_left_click_or_brush_start(e, button=1))
self.canvas_tl.bind("<B1-Motion>", lambda e: self._on_canvas_left_motion(e, button=1))
self.canvas_tl.bind("<ButtonRelease-1>", lambda e: self._on_canvas_left_release(e, button=1))
self.canvas_tl.bind("<ButtonPress-3>", lambda e: self._on_canvas_right_click_or_brush_start(e, button=3))
self.canvas_tl.bind("<B3-Motion>", lambda e: self._on_canvas_right_motion(e, button=3))
self.canvas_tl.bind("<ButtonRelease-3>", lambda e: self._on_canvas_right_release(e, button=3))
self.canvas_tl.bind("<Motion>", lambda e: self._on_canvas_mouse_move(e))
self.canvas_tl.bind("<Leave>", lambda e: self._clear_preview())
# initial state
self.main_img = None
self.ref_img = None
self.target_hsv = (0.0, 0.0, 1.0)
self.transformed_img = None
self.full_img = None
self.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()
def _bind_mousewheel(self, enable):
if enable:
self.scroll_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
self.scroll_canvas.bind_all("<Button-4>", self._on_mousewheel)
self.scroll_canvas.bind_all("<Button-5>", self._on_mousewheel)
else:
self.scroll_canvas.unbind_all("<MouseWheel>")
self.scroll_canvas.unbind_all("<Button-4>")
self.scroll_canvas.unbind_all("<Button-5>")
def _on_mousewheel(self, event):
if hasattr(event, "num") and event.num in (4, 5):
if event.num == 4:
self.scroll_canvas.yview_scroll(-1, "units")
else:
self.scroll_canvas.yview_scroll(1, "units")
else:
delta = int(-1 * (event.delta / 120))
self.scroll_canvas.yview_scroll(delta, "units")
def _bind_right_mousewheel(self, enable):
if enable:
self.right_canvas.bind_all("<MouseWheel>", self._on_right_mousewheel)
self.right_canvas.bind_all("<Button-4>", self._on_right_mousewheel)
self.right_canvas.bind_all("<Button-5>", self._on_right_mousewheel)
else:
self.right_canvas.unbind_all("<MouseWheel>")
self.right_canvas.unbind_all("<Button-4>")
self.right_canvas.unbind_all("<Button-5>")
def _on_right_mousewheel(self, event):
if hasattr(event, "num") and event.num in (4, 5):
if event.num == 4:
self.right_canvas.yview_scroll(-1, "units")
else:
self.right_canvas.yview_scroll(1, "units")
else:
delta = int(-1 * (event.delta / 120))
self.right_canvas.yview_scroll(delta, "units")
self._update_right_slider_range()
def _update_right_slider_range(self):
try:
bbox = self.right_canvas.bbox("all")
if not bbox:
self.right_scroll_slider.configure(state="disabled")
return
content_height = bbox[3] - bbox[1]
view_h = int(self.right_canvas.winfo_height())
if content_height <= view_h:
self.right_scroll_slider.configure(state="disabled")
else:
self.right_scroll_slider.configure(state="normal")
top_frac = self.right_canvas.yview()[0]
self.right_scroll_slider.set(int(top_frac * 100))
except Exception:
pass
def _on_right_slider_move(self, value):
try:
frac = float(value) / 100.0
self.right_canvas.yview_moveto(frac)
except Exception:
pass
# --- mask management ---
def _ensure_user_masks(self):
if not self.main_img:
self.user_add_mask = None
self.user_erase_mask = None
return
w, h = self.main_img.size
if getattr(self, "user_add_mask", None) is None or self.user_add_mask.shape != (h, w):
self.user_add_mask = np.zeros((h, w), dtype=bool)
if getattr(self, "user_erase_mask", None) is None or self.user_erase_mask.shape != (h, w):
self.user_erase_mask = np.zeros((h, w), dtype=bool)
self._user_mask_image = None
def push_undo(self):
if self.user_add_mask is None or self.user_erase_mask is None:
return
self.undo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
self.redo_stack.clear()
if len(self.undo_stack) > 50:
self.undo_stack.pop(0)
def undo_mask(self):
if not self.undo_stack:
return
prev_add, prev_erase = self.undo_stack.pop()
if self.user_add_mask is not None and self.user_erase_mask is not None:
self.redo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
self.user_add_mask = prev_add
self.user_erase_mask = prev_erase
self.update_hsv_from_slider()
def redo_mask(self):
if not self.redo_stack:
return
next_add, next_erase = self.redo_stack.pop()
if self.user_add_mask is not None and self.user_erase_mask is not None:
self.undo_stack.append((self.user_add_mask.copy(), self.user_erase_mask.copy()))
self.user_add_mask = next_add
self.user_erase_mask = next_erase
self.update_hsv_from_slider()
def fill_mask_add(self):
"""画像全体を一括でマスク追加にする"""
if not self.main_img:
return
self._ensure_user_masks()
self.push_undo()
self.user_add_mask[:] = True
self.user_erase_mask[:] = False
self.update_hsv_from_slider()
def fill_mask_erase(self):
"""画像全体を一括でマスク消去(クリア)にする"""
if not self.main_img:
return
self._ensure_user_masks()
self.push_undo()
self.user_add_mask[:] = False
self.user_erase_mask[:] = True
self.update_hsv_from_slider()
def invert_mask(self):
"""現在の合成マスクを反転する(Invert Checkbuttonをトグル)"""
cur = self.invert_mask_var.get()
self.invert_mask_var.set(0 if cur == 1 else 1)
self.update_hsv_from_slider()
# --- brush core (start/paint/end) ---
def _brush_start(self, event, button=1):
if self.edit_mode_var.get() == "color":
return
if not self.main_img:
return
self._ensure_user_masks()
self.push_undo()
self._brush_drawing = True
mode = self.brush_mode_var.get()
if mode == "mouse":
self._brush_mode = "add" if button == 1 else "erase"
else:
self._brush_mode = mode
self._brush_paint(event, button=button)
def _brush_paint(self, event, button=1):
if not getattr(self, "_brush_drawing", False):
return
if not self.main_img:
return
if self.edit_mode_var.get() == "color":
return
canvas = event.widget
try:
cw = canvas.winfo_width()
ch = canvas.winfo_height()
req_w = cw if cw > 1 else int(canvas.cget("width"))
req_h = ch if ch > 1 else int(canvas.cget("height"))
except Exception:
req_w, req_h = 300, 300
img_w, img_h = self.main_img.size
scale = min(req_w / img_w, req_h / img_h)
disp_w = int(img_w * scale); disp_h = int(img_h * scale)
off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2
cx = event.x; cy = event.y
if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
return
rel_x = cx - off_x; rel_y = cy - off_y
img_x = int(rel_x / scale); img_y = int(rel_y / scale)
img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))
try:
radius = int(self.brush_size.get())
except Exception:
radius = 22
y0 = max(0, img_y - radius); y1 = min(img_h, img_y + radius + 1)
x0 = max(0, img_x - radius); x1 = min(img_w, img_x + radius + 1)
yy, xx = np.ogrid[y0:y1, x0:x1]
dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
circle = dist2 <= (radius ** 2)
self._ensure_user_masks()
if self._brush_mode == "erase":
self.user_erase_mask[y0:y1, x0:x1][circle] = True
self.user_add_mask[y0:y1, x0:x1][circle] = False
else:
self.user_add_mask[y0:y1, x0:x1][circle] = True
self.user_erase_mask[y0:y1, x0:x1][circle] = False
self.update_hsv_from_slider()
def _brush_end(self, event, button=1):
self._brush_drawing = False
self.update_hsv_from_slider()
# --- wrappers bound to canvas ---
def _on_canvas_left_click_or_brush_start(self, event, button=1):
if self.edit_mode_var.get() == "color":
self._pick_color_generic(event, self.main_img, self.tl_swatch, self.tl_info, "Original HEX", return_rgb=False)
return
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()
ua = self.user_add_mask if self.user_add_mask is not None else np.zeros((img_h, img_w), dtype=bool)
ue = self.user_erase_mask if self.user_erase_mask is not None else np.zeros((img_h, img_w), dtype=bool)
base_auto = None
if auto_mask is not None:
try:
base_auto = np.array(auto_mask)
if base_auto.dtype != bool:
base_auto = base_auto > 0
if base_auto.shape != (img_h, img_w):
if base_auto.shape == (img_w, img_h):
base_auto = base_auto.T
else:
tmp = Image.fromarray((base_auto.astype(np.uint8) * 255).astype(np.uint8))
tmp = tmp.resize((img_w, img_h), resample=Image.NEAREST)
base_auto = np.array(tmp) > 0
except Exception:
base_auto = None
if base_auto is not None:
composed = base_auto.copy()
if ue is not None and ue.shape == (img_h, img_w):
composed[ue] = False
if ua is not None and ua.shape == (img_h, img_w):
composed[ua] = True
return composed.astype(bool)
# 自動検出がない場合の手動マスク合成
composed = np.zeros((img_h, img_w), dtype=bool)
if ua is not None and ua.shape == (img_h, img_w):
composed[ua] = True
if ue is not None and ue.shape == (img_h, img_w):
composed[ue] = False
return composed.astype(bool)
# --- fill at point (component fill) ---
def _fill_at_point(self, img_x, img_y, mode="add"):
if not self.main_img:
return
self._ensure_user_masks()
self.push_undo()
cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
self.cb_center.get(), self.cb_span.get(),
self.cr_center.get(), self.cr_span.get(),
self.y_center.get(), self.y_span.get()
)
auto_mask = None
if self.preserve_skin_var.get() == 1:
try:
auto_mask = make_skin_mask(self.main_img.convert("RGB"),
cb_min=cb_min, cb_max=cb_max,
cr_min=cr_min, cr_max=cr_max,
y_min=y_min, y_max=y_max)
except Exception:
auto_mask = None
composed = self._compose_mask(auto_mask)
if composed is None:
return
if self.invert_mask_var.get() == 1:
composed = ~composed
h, w = composed.shape
if not (0 <= img_x < w and 0 <= img_y < h):
return
clicked_val = bool(composed[img_y, img_x])
try:
if _HAS_SCIPY:
labeled, nlabels = ndimage.label(composed == clicked_val)
else:
raise Exception("no scipy")
except Exception:
labeled = np.zeros_like(composed, dtype=np.int32)
label = 0
visited = np.zeros_like(composed, dtype=bool)
for yy in range(h):
for xx in range(w):
if (composed[yy, xx] == clicked_val) and (not visited[yy, xx]):
label += 1
q = deque()
q.append((yy, xx))
visited[yy, xx] = True
labeled[yy, xx] = label
while q:
cy, cx = q.popleft()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and (composed[ny, nx] == clicked_val):
visited[ny, nx] = True
labeled[ny, nx] = label
q.append((ny, nx))
lbl = labeled[img_y, img_x]
if lbl == 0:
try:
radius = int(self.brush_size.get())
except Exception:
radius = 22
y0 = max(0, img_y - radius); y1 = min(h, img_y + radius + 1)
x0 = max(0, img_x - radius); x1 = min(w, img_x + radius + 1)
yy, xx = np.ogrid[y0:y1, x0:x1]
dist2 = (yy - img_y) ** 2 + (xx - img_x) ** 2
region = np.zeros((h, w), dtype=bool)
region[y0:y1, x0:x1] = dist2 <= (radius ** 2)
else:
region = (labeled == lbl)
# マスク反転がONの場合、表示上のAdd/Eraseと内部マスクの関係を考慮
actual_add = (mode == "add") if self.invert_mask_var.get() == 0 else (mode != "add")
if not actual_add:
self.user_erase_mask[region] = True
self.user_add_mask[region] = False
else:
self.user_add_mask[region] = True
self.user_erase_mask[region] = False
self.redo_stack.clear()
self.update_hsv_from_slider()
# --- preview helpers ---
def _on_canvas_mouse_move(self, event):
if self.edit_mode_var.get() != "mask":
self._clear_preview()
return
bm = self.brush_mode_var.get()
if bm not in ("fill", "fill_erase"):
self._clear_preview()
return
if not self.main_img:
self._clear_preview()
return
canvas = event.widget
try:
cw = canvas.winfo_width()
ch = canvas.winfo_height()
req_w = cw if cw > 1 else int(canvas.cget("width"))
req_h = ch if ch > 1 else int(canvas.cget("height"))
except Exception:
req_w, req_h = 300, 300
img_w, img_h = self.main_img.size
scale = min(req_w / img_w, req_h / img_h)
disp_w = int(img_w * scale); disp_h = int(img_h * scale)
off_x = (req_w - disp_w) // 2; off_y = (req_h - disp_h) // 2
cx = event.x; cy = event.y
if not (off_x <= cx < off_x + disp_w and off_y <= cy < off_y + disp_h):
self._clear_preview()
return
rel_x = cx - off_x; rel_y = cy - off_y
img_x = int(rel_x / scale); img_y = int(rel_y / scale)
img_x = max(0, min(img_w - 1, img_x)); img_y = max(0, min(img_h - 1, img_y))
if (img_x, img_y) == self._last_preview_pos:
return
self._last_preview_pos = (img_x, img_y)
region = self._compute_region_for_preview(img_x, img_y)
if region is None:
self._clear_preview()
return
self._show_preview_region(region, scale, off_x, off_y)
def _compute_region_for_preview(self, img_x, img_y):
if not self.main_img:
return None
cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
self.cb_center.get(), self.cb_span.get(),
self.cr_center.get(), self.cr_span.get(),
self.y_center.get(), self.y_span.get()
)
auto_mask = None
if self.preserve_skin_var.get() == 1:
try:
auto_mask = make_skin_mask(self.main_img.convert("RGB"),
cb_min=cb_min, cb_max=cb_max,
cr_min=cr_min, cr_max=cr_max,
y_min=y_min, y_max=y_max)
except Exception:
auto_mask = None
composed = self._compose_mask(auto_mask)
if composed is None:
return None
if self.invert_mask_var.get() == 1:
composed = ~composed
h, w = composed.shape
if not (0 <= img_x < w and 0 <= img_y < h):
return None
clicked_val = bool(composed[img_y, img_x])
try:
if _HAS_SCIPY:
labeled, nlabels = ndimage.label(composed == clicked_val)
else:
raise Exception("no scipy")
except Exception:
labeled = np.zeros_like(composed, dtype=np.int32)
label = 0
visited = np.zeros_like(composed, dtype=bool)
for yy in range(h):
for xx in range(w):
if (composed[yy, xx] == clicked_val) and (not visited[yy, xx]):
label += 1
q = deque()
q.append((yy, xx))
visited[yy, xx] = True
labeled[yy, xx] = label
while q:
cy, cx = q.popleft()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and (composed[ny, nx] == clicked_val):
visited[ny, nx] = True
labeled[ny, nx] = label
q.append((ny, nx))
lbl = labeled[img_y, img_x]
if lbl == 0:
return None
region = (labeled == lbl)
return region
def _show_preview_region(self, region, scale, off_x, off_y):
try:
outline_img = mask_to_outline_image(region, thickness=max(1, int(self.outline_thickness_scale.get())), alpha=200, color=(255, 255, 255))
if outline_img is None:
self._clear_preview()
return
img_w, img_h = self.main_img.size
disp_w = int(img_w * scale); disp_h = int(img_h * scale)
outline_resized = outline_img.resize((disp_w, disp_h), resample=Image.NEAREST)
try:
cw = self.canvas_tl.winfo_width()
ch = self.canvas_tl.winfo_height()
req_w = cw if cw > 1 else int(self.canvas_tl.cget("width"))
req_h = ch if ch > 1 else int(self.canvas_tl.cget("height"))
except Exception:
req_w, req_h = disp_w, disp_h
overlay = Image.new("RGBA", (req_w, req_h), (0, 0, 0, 0))
overlay.paste(outline_resized, (off_x, off_y), outline_resized)
tk_img = ImageTk.PhotoImage(overlay)
self._clear_preview()
self._preview_photo = tk_img
self._preview_image_id = self.canvas_tl.create_image(0, 0, anchor="nw", image=tk_img, tags=("preview",))
except Exception:
self._clear_preview()
def _clear_preview(self):
try:
if getattr(self, "_preview_image_id", None) is not None:
try:
self.canvas_tl.delete(self._preview_image_id)
except Exception:
pass
try:
self.canvas_tl.delete("preview")
except Exception:
pass
finally:
self._preview_photo = None
self._preview_image_id = None
self._last_preview_pos = (-1, -1)
# --- show image with overlay ---
def show_image(self, canvas, img, mask=None, show_mask_overlay=False, overlay_color=(255, 0, 128)):
canvas.delete("all")
tmp = img.copy()
try:
cw = canvas.winfo_width()
ch = canvas.winfo_height()
req_w = cw if cw > 1 else int(canvas.cget("width"))
req_h = ch if ch > 1 else int(canvas.cget("height"))
except Exception:
req_w, req_h = 300, 300
tmp.thumbnail((req_w, req_h))
w, h = tmp.size
bg = make_checkerboard((req_w, req_h), tile=10, color1=(255, 255, 255), color2=(220, 220, 220))
paste_x = (req_w - w) // 2
paste_y = (req_h - h) // 2
disp = bg.convert("RGBA")
if tmp.mode == "RGBA":
disp.paste(tmp, (paste_x, paste_y), tmp.split()[-1])
else:
disp.paste(tmp, (paste_x, paste_y))
if show_mask_overlay and mask is not None:
try:
mask_img = Image.fromarray((mask.astype(np.uint8) * 255).astype(np.uint8))
mask_img = mask_img.resize((w, h), Image.NEAREST)
mask_resized = np.array(mask_img) > 0
scale_pct = 100
try:
scale_pct = int(self.mask_scale.get())
except Exception:
pass
mask_scaled = scale_mask_morph(mask_resized, scale_pct)
fill_alpha = int(self.fill_alpha_scale.get()) if hasattr(self, "fill_alpha_scale") else 120
outline_thickness = int(self.outline_thickness_scale.get()) if hasattr(self, "outline_thickness_scale") else 1
overlayed = make_mask_overlay_display(tmp, mask_scaled,
overlay_color=overlay_color,
fill_alpha=fill_alpha,
outline_mode=True,
outline_thickness=outline_thickness,
outline_alpha=220)
overlayed_rgba = overlayed.convert("RGBA")
disp.paste(overlayed_rgba, (paste_x, paste_y), overlayed_rgba.split()[-1])
except Exception:
pass
final = disp.convert("RGB")
tk_img = ImageTk.PhotoImage(final)
canvas.image = tk_img
canvas.create_image(0, 0, anchor="nw", image=tk_img)
# --- color pick ---
def pick_color_main(self, event):
if not self.main_img:
return
self._pick_color_generic(event, self.main_img, self.tl_swatch, self.tl_info, "Original HEX", return_rgb=False)
def pick_color_ref(self, event):
if not self.ref_img:
return
result = self._pick_color_generic(event, self.ref_img, self.tr_swatch, self.tr_info, "Reference HEX", return_rgb=True)
if result:
r, g, b = result
h, s, v = rgb_to_hsv_tuple(r, g, b)
self.target_hsv = (h, s, v)
try:
self.h_scale.set(int(h * 360.0 - 180.0))
except Exception:
pass
try:
self.s_scale.set(int((s - 0.5) * 200.0))
self.v_scale.set(int((v - 0.5) * 200.0))
except Exception:
pass
self.update_hsv_from_slider()
def pick_color_out(self, event):
if not self.transformed_img:
return
self._pick_color_generic(event, self.transformed_img, self.bl_swatch, self.bl_info, "Mapped HEX", return_rgb=False)
def pick_color_full(self, event):
if not self.full_img:
return
self._pick_color_generic(event, self.full_img, self.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()
# --- core update (ensures auto mask applied) ---
def update_hsv_from_slider(self, event=None):
cb_min, cb_max, cr_min, cr_max, y_min, y_max = get_skin_thresholds_from_center_span_percent(
self.cb_center.get(), self.cb_span.get(),
self.cr_center.get(), self.cr_span.get(),
self.y_center.get(), self.y_span.get()
)
if not self.main_img:
if self.ref_img:
self.show_image(self.canvas_tr, self.ref_img)
return
strength_mapped = 1.0
use_h = self.h_var.get() == 1
use_s = self.s_var.get() == 1
use_v = self.v_var.get() == 1
preserve_skin = self.preserve_skin_var.get() == 1
show_mask_overlay = self.show_preserved_var.get() == 1
invert_mask = self.invert_mask_var.get() == 1
auto_mask = None
if preserve_skin:
try:
auto_mask = make_skin_mask(self.main_img.convert("RGB"),
cb_min=cb_min, cb_max=cb_max,
cr_min=cr_min, cr_max=cr_max,
y_min=y_min, y_max=y_max)
_vprint("auto_mask sum:", int(np.sum(auto_mask)))
except Exception:
auto_mask = None
_vprint("auto_mask creation failed")
mask = self._compose_mask(auto_mask)
# マスク反転(初期ON)の適用
if mask is not None and invert_mask:
mask = ~mask
if mask is not None:
try:
mask = np.array(mask)
if mask.dtype != bool:
mask = mask > 0
img_w, img_h = self.main_img.size
if mask.shape != (img_h, img_w):
if mask.shape == (img_w, img_h):
mask = mask.T
else:
mask_img = Image.fromarray((mask.astype(np.uint8) * 255).astype(np.uint8))
mask_img = mask_img.resize((img_w, img_h), resample=Image.NEAREST)
mask = np.array(mask_img) > 0
_vprint("final composed mask sum:", int(np.sum(mask)))
except Exception:
mask = None
_vprint("mask normalization failed")
try:
self.show_image(self.canvas_tl, self.main_img, mask=mask if show_mask_overlay else None, show_mask_overlay=show_mask_overlay, overlay_color=(255, 0, 128))
except Exception:
pass
if self.ref_img:
try:
self.show_image(self.canvas_tr, self.ref_img)
except Exception:
pass
# 適用マスク(Mask Scale % のモルフォロジー拡縮を適用)
scale_pct = 100
try:
scale_pct = int(self.mask_scale.get())
except Exception:
pass
applied_mask = scale_mask_morph(mask, scale_pct) if mask is not None else None
# Mapped (マスク領域のみを色変換、またはマスクなしなら全体を色変換)
try:
mapped_full = apply_hsv_map_to_target_numpy(
self.main_img, self.target_hsv, strength_mapped, use_h, use_s, use_v,
preserve_skin=False
)
if applied_mask is None or (not preserve_skin and getattr(self, "user_add_mask", None) is None):
self.transformed_img = mapped_full
else:
orig_rgba = self.main_img.convert("RGBA")
mapped_rgba = mapped_full.convert("RGBA")
orig_arr = np.array(orig_rgba, dtype=np.uint8)
mapped_arr = np.array(mapped_rgba, dtype=np.uint8)
h, w = orig_arr.shape[0], orig_arr.shape[1]
if applied_mask.shape != (h, w):
mask_img = Image.fromarray((applied_mask.astype(np.uint8) * 255).astype(np.uint8))
mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
applied_mask = np.array(mask_img) > 0
mask_exp = np.repeat(applied_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
out_arr = np.where(mask_exp, mapped_arr, orig_arr)
self.transformed_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
self.show_image(self.canvas_bl, self.transformed_img)
except Exception as e:
_vprint("Mapped generation error:", e)
self.transformed_img = None
# HSV Full (マスク領域のみを色オフセット変換、またはマスクなしなら全体を色変換)
try:
h_deg = self.h_scale.get()
h_offset = (h_deg / 360.0)
s_off = self.s_scale.get() / 100.0
v_off = self.v_scale.get() / 100.0
strength_full = self.strength_scale.get() / 100.0
full_transformed = apply_hsv_blend_offset_numpy(
self.main_img, h_offset, s_off, v_off, strength_full, use_h, use_s, use_v,
preserve_skin=False
)
if applied_mask is None or (not preserve_skin and getattr(self, "user_add_mask", None) is None):
self.full_img = full_transformed
else:
orig_rgba = self.main_img.convert("RGBA")
full_rgba = full_transformed.convert("RGBA")
orig_arr = np.array(orig_rgba, dtype=np.uint8)
full_arr = np.array(full_rgba, dtype=np.uint8)
h, w = orig_arr.shape[0], orig_arr.shape[1]
if applied_mask.shape != (h, w):
mask_img = Image.fromarray((applied_mask.astype(np.uint8) * 255).astype(np.uint8))
mask_img = mask_img.resize((w, h), resample=Image.NEAREST)
applied_mask = np.array(mask_img) > 0
mask_exp = np.repeat(applied_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
out_arr = np.where(mask_exp, full_arr, orig_arr)
self.full_img = Image.fromarray(out_arr, mode="RGBA").convert("RGB")
self.show_image(self.canvas_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
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()
# --- 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 = None
if preserve_skin:
try:
cur_mask = make_skin_mask(img.convert("RGB"), cb_min=cb_min, cb_max=cb_max, cr_min=cr_min, cr_max=cr_max, y_min=y_min, y_max=y_max)
if invert_mask:
cur_mask = ~cur_mask
cur_mask = scale_mask_morph(cur_mask, scale_pct)
except Exception:
cur_mask = None
# Mapped 処理
try:
mapped = apply_hsv_map_to_target_numpy(
img, target_hsv, strength_mapped, use_h, use_s, use_v,
preserve_skin=False
)
if cur_mask is not None and cur_mask.sum() > 0:
orig_rgba = ensure_rgba(img)
mapped_rgba = ensure_rgba(mapped)
orig_arr = np.array(orig_rgba, dtype=np.uint8)
mapped_arr = np.array(mapped_rgba, dtype=np.uint8)
mask_exp = np.repeat(cur_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
out_arr = np.where(mask_exp, mapped_arr, orig_arr)
final_mapped = Image.fromarray(out_arr, mode="RGBA")
if img.mode != "RGBA":
final_mapped = final_mapped.convert("RGB")
else:
final_mapped = mapped
out_mapped = os.path.join(out_mapped_folder, f"{base_name}_mapping_{now_str}.png")
final_mapped.save(out_mapped, format="PNG")
except Exception as e:
errors.append((fname + " (mapped)", str(e)))
# Full 処理
try:
full = apply_hsv_blend_offset_numpy(
img, h_offset_full, s_offset_full, v_offset_full, strength_full, use_h, use_s, use_v,
preserve_skin=False
)
if cur_mask is not None and cur_mask.sum() > 0:
orig_rgba = ensure_rgba(img)
full_rgba = ensure_rgba(full)
orig_arr = np.array(orig_rgba, dtype=np.uint8)
full_arr = np.array(full_rgba, dtype=np.uint8)
mask_exp = np.repeat(cur_mask[:, :, np.newaxis], orig_arr.shape[2], axis=2).astype(bool)
out_arr = np.where(mask_exp, full_arr, orig_arr)
final_full = Image.fromarray(out_arr, mode="RGBA")
if img.mode != "RGBA":
final_full = final_full.convert("RGB")
else:
final_full = full
out_full = os.path.join(out_full_folder, f"{base_name}_hsv_full_{now_str}.png")
final_full.save(out_full, format="PNG")
except Exception as e:
errors.append((fname + " (full)", str(e)))
# 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
final_trans = Image.fromarray(trans_arr, mode="RGBA")
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
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 = 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
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
final_trans = Image.fromarray(trans_arr, mode="RGBA")
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
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 = 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
# 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
)
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)
final_img = Image.fromarray(out_arr, mode="RGBA")
if img.mode != "RGBA":
final_img = final_img.convert("RGB")
else:
final_img = mapped
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
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 = 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
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
)
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)
final_img = Image.fromarray(out_arr, mode="RGBA")
if img.mode != "RGBA":
final_img = final_img.convert("RGB")
else:
final_img = mapped
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
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 = 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
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
)
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)
final_img = Image.fromarray(out_arr, mode="RGBA")
if img.mode != "RGBA":
final_img = final_img.convert("RGB")
else:
final_img = mapped
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:
self.transformed_img.save(path, format="PNG")
print("Saved:", path)
def save_full_image(self):
if not self.full_img or not self.current_filename:
return
base, ext = os.path.splitext(self.current_filename)
now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
save_name = f"{base}_hsv_full_{now_str}.png"
path = filedialog.asksaveasfilename(initialfile=save_name, defaultextension=".png")
if path:
self.full_img.save(path, format="PNG")
print("Saved HSV Full:", path)
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:
self.transparent_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)
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()










ディスカッション
コメント一覧
まだ、コメントがありません