hsv 色変換ツール
はじめに
参照する画像を参考にして色を変更


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











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