マスク画像で切り取るツール

イメージ

マスク画像で切り取るだけでなくアウトラインや輪郭のぼかし バッチ処理ができます

プログラム

import tkinter as tk
from tkinter import filedialog, colorchooser
from PIL import Image, ImageTk
import cv2
import numpy as np
import os
import unicodedata
import re

DEBUG = False

def generate_checker(w, h, block=20):
    checker = np.zeros((h, w, 3), dtype=np.uint8)
    for y in range(0, h, block):
        for x in range(0, w, block):
            color = 240 if ((x//block + y//block) % 2 == 0) else 200
            checker[y:y+block, x:x+block] = color
    return checker

def compose_with_checker(img):
    if img is None:
        return np.zeros((250, 250, 3), dtype=np.uint8)
    if img.shape[2] == 4:
        bgr = img[:, :, :3]
        alpha = img[:, :, 3] / 255.0
    else:
        bgr = img
        alpha = np.ones(bgr.shape[:2])
    h, w = bgr.shape[:2]
    checker = generate_checker(w, h)
    alpha3 = np.dstack([alpha, alpha, alpha])
    composed = (bgr * alpha3 + checker * (1 - alpha3)).astype(np.uint8)
    return composed

def long_feather_alpha(alpha, strength):
    if strength <= 0:
        return alpha
    s = np.clip(strength / 100.0, 0.0, 1.0)
    blur1 = max(1.0, 6.0 * s)
    blur2 = max(1.0, 4.0 * s)
    blur3 = max(1.0, 3.0 * s)
    erode1 = max(1, int(round(4 * s)))
    erode2 = max(1, int(round(2 * s)))
    def octagon_kernel(size):
        k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size*2+1, size*2+1))
        return k
    a = alpha.astype(np.uint8).copy()
    a = cv2.GaussianBlur(a, (0, 0), blur1)
    a = cv2.erode(a, octagon_kernel(erode1))
    a = cv2.GaussianBlur(a, (0, 0), blur2)
    a = cv2.erode(a, octagon_kernel(erode2))
    a = cv2.GaussianBlur(a, (0, 0), blur3)
    return a

def apply_glow_outline_to_bgr(bgr, outline_mask, glow_color_bgr, glow_strength):
    if glow_strength <= 0:
        return bgr
    h, w = bgr.shape[:2]
    outline = (outline_mask.astype(np.uint8) * 255).astype(np.uint8)
    feather_alpha = long_feather_alpha(outline, int(np.clip(glow_strength, 0, 200) * 0.5))
    glow_alpha = (feather_alpha.astype(np.float32) / 255.0)[..., None]
    color_arr = np.zeros_like(bgr, dtype=np.float32)
    color_arr[..., 0] = glow_color_bgr[0]
    color_arr[..., 1] = glow_color_bgr[1]
    color_arr[..., 2] = glow_color_bgr[2]
    glow = (color_arr * glow_alpha).clip(0, 255).astype(np.uint8)
    out = cv2.add(bgr, glow)
    return out

def process_single_image(image_cv, mask_cv, settings):
    h, w = image_cv.shape[:2]
    if mask_cv is None:
        mask_alpha = np.ones((h, w), dtype=np.uint8) * 255
    else:
        mask_resized = cv2.resize(mask_cv, (w, h))
        if mask_resized.shape[2] == 4:
            mask_alpha = mask_resized[:, :, 3]
        else:
            mask_alpha = cv2.cvtColor(mask_resized, cv2.COLOR_BGR2GRAY)
    if image_cv.shape[2] == 4:
        image_rgb = image_cv[:, :, :3].astype(np.float32)
        image_alpha = image_cv[:, :, 3].astype(np.float32)
        alpha_norm = image_alpha / 255.0
        alpha_safe = alpha_norm.copy()
        alpha_safe[alpha_safe == 0] = 1.0
        image_rgb = (image_rgb / alpha_safe[..., None]).clip(0, 255).astype(np.uint8)
    else:
        image_rgb = image_cv
        image_alpha = np.ones((h, w), dtype=np.uint8) * 255
    mask_bin = cv2.threshold(mask_alpha, 10, 255, cv2.THRESH_BINARY)[1]
    final_alpha = cv2.bitwise_and(image_alpha.astype(np.uint8), mask_bin)
    cut_rgb = cv2.bitwise_and(image_rgb, cv2.cvtColor(mask_bin, cv2.COLOR_GRAY2BGR))
    if settings.get("enable_feather", False):
        feather_strength = int(settings.get("feather_strength", 50))
        feather_alpha = long_feather_alpha(final_alpha, feather_strength)
    else:
        feather_alpha = final_alpha
    result = np.dstack([cut_rgb, feather_alpha])
    if settings.get("enable_outline", False) or settings.get("enable_glow", False):
        outline_size = max(1, int(settings.get("outline_size", 4)))
        grad_strength = int(settings.get("grad_strength", 0))
        glow_strength = int(settings.get("glow_strength", 80))
        oc_b, oc_g, oc_r = settings.get("outline_color_bgr", (0, 170, 255))
        glow_color = settings.get("glow_color_bgr", (0, 170, 255))
        bgr = result[:, :, :3].copy()
        alpha = result[:, :, 3]
        _, mask_bin2 = cv2.threshold(alpha, 1, 255, cv2.THRESH_BINARY)
        edges = cv2.Canny(mask_bin2, 50, 150)
        kernel = np.ones((outline_size, outline_size), np.uint8)
        outline = cv2.dilate(edges, kernel)
        outline_mask = outline > 0
        if settings.get("enable_outline", False):
            if settings.get("enable_gradient", False) and grad_strength > 0:
                grad = np.linspace(0, grad_strength, w).astype(np.uint8)
                grad = np.tile(grad, (h, 1))
                grad_norm = (grad.astype(np.float32) / 255.0)[..., None]
                color_arr = np.zeros((h, w, 3), dtype=np.float32)
                color_arr[..., 0] = oc_b
                color_arr[..., 1] = oc_g
                color_arr[..., 2] = oc_r
                grad_color = (color_arr * grad_norm).clip(0, 255).astype(np.uint8)
                bgr[outline_mask] = grad_color[outline_mask]
            else:
                bgr[outline_mask] = (oc_b, oc_g, oc_r)
        if settings.get("enable_glow", False) and glow_strength > 0:
            bgr = apply_glow_outline_to_bgr(bgr, outline_mask, glow_color, glow_strength)
        result = np.dstack([bgr, alpha])
    return result

def sanitize_to_ascii(name):
    if not name:
        return "mask"
    n = unicodedata.normalize('NFKD', name)
    n = n.encode('ascii', 'ignore').decode('ascii')
    n = n.strip()
    n = re.sub(r'\s+', '_', n)
    n = re.sub(r'[^A-Za-z0-9_\-]', '', n)
    if n == "":
        return "mask"
    return n

class MaskCutApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Mask Cut - Auto Save by original+mask")

        # Left thumbnails
        left = tk.Frame(root, width=220)
        left.pack(side="left", fill="y")
        tk.Label(left, text="Thumbnails").pack(anchor="nw")
        self.thumb_canvas = tk.Canvas(left, width=220)
        self.thumb_scroll = tk.Scrollbar(left, orient="vertical", command=self.thumb_canvas.yview)
        self.thumb_frame = tk.Frame(self.thumb_canvas)
        self.thumb_frame.bind("<Configure>", lambda e: self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox("all")))
        self.thumb_canvas.create_window((0,0), window=self.thumb_frame, anchor="nw")
        self.thumb_canvas.configure(yscrollcommand=self.thumb_scroll.set)
        self.thumb_canvas.pack(side="left", fill="y", expand=False)
        self.thumb_scroll.pack(side="right", fill="y")
        self.thumbnail_refs = []

        # Center layout: Orig TL / Mask TR small / Result BL
        center = tk.Frame(root); center.pack(side="left", fill="both", expand=True)
        top_row = tk.Frame(center); top_row.pack(fill="both", expand=True)
        orig_frame = tk.Frame(top_row); orig_frame.pack(side="left", fill="both", expand=True, padx=4, pady=4)
        tk.Label(orig_frame, text="元画像 (左上)").pack(); self.orig_label = tk.Label(orig_frame); self.orig_label.pack(expand=True)
        mask_frame = tk.Frame(top_row, width=220); mask_frame.pack(side="right", fill="y", padx=4, pady=4)
        tk.Label(mask_frame, text="マスク (右上・小)").pack(); self.mask_label_small = tk.Label(mask_frame); self.mask_label_small.pack(expand=False)
        bottom_row = tk.Frame(center); bottom_row.pack(fill="both", expand=True)
        res_frame = tk.Frame(bottom_row); res_frame.pack(side="left", fill="both", expand=True, padx=4, pady=4)
        tk.Label(res_frame, text="結果 (左下)").pack(); self.result_label = tk.Label(res_frame); self.result_label.pack(expand=True)

        # Right: controls
        right = tk.Frame(root); right.pack(side="right", fill="y")
        tk.Button(right, text="フォルダを読み込む (サムネイル)", command=self.load_folder).pack(padx=4, pady=4, fill="x")
        tk.Button(right, text="マスクを読み込む (single)", command=self.load_mask).pack(padx=4, pady=4, fill="x")
        # マスクフォルダボタンは削除(ユーザー要望)

        # Cut button + Auto-save under it
        self.cut_btn = tk.Button(right, text="切り抜き実行 (single)", command=self.apply_mask)
        self.cut_btn.pack(padx=4, pady=(8,4), fill="x")
        self.save_under_cut_btn = tk.Button(right, text="保存 (自動: original_mask.png)", command=self.auto_save_image)
        self.save_under_cut_btn.pack(padx=4, pady=(0,8), fill="x")

        tk.Button(right, text="バッチ処理 (loaded folder)", command=self.batch_process_from_loaded).pack(padx=4, pady=6, fill="x")

        # Controls area: checkboxes left, sliders right
        controls_frame = tk.Frame(right)
        controls_frame.pack(padx=4, pady=(8,4), fill="x")
        cb_frame = tk.Frame(controls_frame)
        cb_frame.grid(row=0, column=0, sticky="nw", padx=(0,6))
        tk.Label(cb_frame, text="効果の選択").pack(anchor="w")
        # Default OFF as requested
        self.enable_outline_var = tk.IntVar(value=0)
        self.enable_gradient_var = tk.IntVar(value=0)
        self.enable_feather_var = tk.IntVar(value=0)
        self.enable_glow_var = tk.IntVar(value=0)
        tk.Checkbutton(cb_frame, text="アウトライン", variable=self.enable_outline_var).pack(anchor="w")
        tk.Checkbutton(cb_frame, text="グラデーション", variable=self.enable_gradient_var).pack(anchor="w")
        tk.Checkbutton(cb_frame, text="フェザー", variable=self.enable_feather_var).pack(anchor="w")
        tk.Checkbutton(cb_frame, text="Glow", variable=self.enable_glow_var).pack(anchor="w")

        slider_frame = tk.Frame(controls_frame)
        slider_frame.grid(row=0, column=1, sticky="ne")
        tk.Label(slider_frame, text="縁取り太さ").pack(anchor="w")
        self.outline_scale = tk.Scale(slider_frame, from_=1, to=60, orient="horizontal"); self.outline_scale.set(4); self.outline_scale.pack(fill="x")
        tk.Label(slider_frame, text="グラデーション強さ").pack(anchor="w")
        self.grad_scale = tk.Scale(slider_frame, from_=0, to=255, orient="horizontal"); self.grad_scale.set(0); self.grad_scale.pack(fill="x")
        tk.Label(slider_frame, text="フェザー強さ").pack(anchor="w")
        self.feather_scale = tk.Scale(slider_frame, from_=0, to=100, orient="horizontal"); self.feather_scale.set(50); self.feather_scale.pack(fill="x")
        tk.Label(slider_frame, text="Glow強さ").pack(anchor="w")
        self.glow_scale = tk.Scale(slider_frame, from_=0, to=200, orient="horizontal"); self.glow_scale.set(80); self.glow_scale.pack(fill="x")

        tk.Label(right, text="アウトライン色").pack(padx=4, pady=(8,0))
        color_frame = tk.Frame(right); color_frame.pack(padx=4, pady=4, fill="x")
        self.color_preview = tk.Label(color_frame, text="      ", bg="#ffaa00", relief="sunken"); self.color_preview.pack(side="left", padx=(0,6))
        tk.Button(color_frame, text="色を選択", command=self.choose_outline_color).pack(side="left", fill="x")
        self.outline_color_bgr = (0x00, 0xaa, 0xff); self.outline_color_hex = "#ffaa00"; self.color_preview.config(bg=self.outline_color_hex)

        tk.Label(right, text="Glow色").pack(padx=4, pady=(8,0))
        glow_frame = tk.Frame(right); glow_frame.pack(padx=4, pady=4, fill="x")
        self.glow_preview = tk.Label(glow_frame, text="      ", bg="#ffaa00", relief="sunken"); self.glow_preview.pack(side="left", padx=(0,6))
        tk.Button(glow_frame, text="色を選択", command=self.choose_glow_color).pack(side="left", fill="x")
        self.glow_color_bgr = (0x00, 0xaa, 0xff); self.glow_color_hex = "#ffaa00"; self.glow_preview.config(bg=self.glow_color_hex)

        tk.Button(right, text="Outline + Gradient + Glow", command=self.apply_outline_gradient).pack(padx=4, pady=(12,4), fill="x")
        tk.Button(right, text="保存 (別名で保存)", command=self.save_image_as).pack(padx=4, pady=4, fill="x")

        # Internal state
        self.image = None
        self.image_path = None
        self.mask = None
        self.mask_path = None
        self.current_folder = None
        self.result = None

    def load_folder(self):
        folder = filedialog.askdirectory()
        if not folder:
            return
        self.current_folder = folder
        for widget in self.thumb_frame.winfo_children():
            widget.destroy()
        self.thumbnail_refs.clear()
        exts = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp")
        files = [os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith(exts)]
        files.sort()
        for path in files:
            try:
                img = Image.open(path).convert("RGBA")
                thumb = img.copy()
                thumb.thumbnail((180, 180))
                tk_img = ImageTk.PhotoImage(thumb)
                btn = tk.Button(self.thumb_frame, image=tk_img, text=os.path.basename(path), compound="top",
                                command=lambda p=path: self.on_thumbnail_click(p))
                btn.image = tk_img
                btn.pack(padx=4, pady=4)
                self.thumbnail_refs.append(tk_img)
            except Exception as e:
                print("thumb load error:", path, e)

    def on_thumbnail_click(self, path):
        img_cv = cv2.imread(path, cv2.IMREAD_UNCHANGED)
        if img_cv is None:
            print("Failed to load:", path)
            return
        self.image = img_cv
        self.image_path = path
        self.result = None
        self.show_preview(compose_with_checker(self.image), self.orig_label, max_size=420)

    def load_mask(self):
        path = filedialog.askopenfilename(filetypes=[("Image files","*.png;*.jpg;*.jpeg;*.bmp;*.tiff;*.webp")])
        if not path: return
        self.mask = cv2.imread(path, cv2.IMREAD_UNCHANGED)
        self.mask_path = path
        self.show_preview(compose_with_checker(self.mask), self.mask_label_small, max_size=180)

    def apply_mask(self):
        if self.image is None:
            print("image missing")
            return
        settings = self._gather_settings()
        # マスクフォルダは廃止。単一マスクがあればそれを使い、なければ全体切り取り
        mask_to_use = self.mask if getattr(self, "mask", None) is not None else None
        self.result = process_single_image(self.image, mask_to_use, settings)
        self.show_preview(compose_with_checker(self.image), self.orig_label, max_size=420)
        if mask_to_use is not None:
            self.show_preview(compose_with_checker(mask_to_use), self.mask_label_small, max_size=180)
        self.show_preview(compose_with_checker(self.result), self.result_label, max_size=420)

    def batch_process_from_loaded(self):
        if not self.current_folder:
            print("No folder loaded")
            return
        out_parent = filedialog.askdirectory(title="Select output parent folder (processed_output will be created)")
        if not out_parent:
            return
        out_folder = os.path.join(out_parent, "processed_output")
        os.makedirs(out_folder, exist_ok=True)
        settings = self._gather_settings()
        exts = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp")
        files = [f for f in os.listdir(self.current_folder) if f.lower().endswith(exts)]
        files.sort()
        total = len(files)
        if total == 0:
            print("No images found in folder")
            return
        print(f"Batch start: {total} files from {self.current_folder} -> {out_folder}")
        for idx, fname in enumerate(files, 1):
            in_path = os.path.join(self.current_folder, fname)
            img = cv2.imread(in_path, cv2.IMREAD_UNCHANGED)
            if img is None:
                print(f"[{idx}/{total}] Failed to load: {fname}")
                continue
            # マスクフォルダは使わない。単一マスクがあればそれを全画像に適用
            mask_cv = self.mask if getattr(self, "mask", None) is not None else None
            result = process_single_image(img, mask_cv, settings)
            out_path = os.path.join(out_folder, fname)
            cv2.imwrite(out_path, result)
            print(f"[{idx}/{total}] Done: {fname} -> {out_path}")
        print("Batch finished.")

    def apply_outline_gradient(self):
        if self.result is None:
            return
        settings = self._gather_settings()
        mask_to_use = self.mask if getattr(self, "mask", None) is not None else None
        self.result = process_single_image(self.image, mask_to_use, settings)
        self.show_preview(compose_with_checker(self.result), self.result_label, max_size=420)

    def auto_save_image(self):
        if self.result is None:
            print("No result to save")
            return
        if getattr(self, "image_path", None):
            base = os.path.splitext(os.path.basename(self.image_path))[0]
        else:
            base = "image"
        mask_name = "nomask"
        if getattr(self, "mask_path", None):
            mask_name = os.path.splitext(os.path.basename(self.mask_path))[0]
        mask_name_ascii = sanitize_to_ascii(mask_name)
        out_name = f"{base}_{mask_name_ascii}.png"
        if self.current_folder:
            out_path = os.path.join(self.current_folder, out_name)
        else:
            out_path = os.path.join(os.getcwd(), out_name)
        cv2.imwrite(out_path, self.result)
        print(f"Auto-saved to: {out_path}")

    def save_image_as(self):
        if self.result is None:
            print("No result to save")
            return
        path = filedialog.asksaveasfilename(defaultextension=".png")
        if path:
            cv2.imwrite(path, self.result)
            print(f"Saved to: {path}")

    def choose_outline_color(self):
        col = colorchooser.askcolor(title="アウトライン色を選択", initialcolor=getattr(self, "outline_color_hex", "#ffaa00"))
        if col and col[0] is not None:
            r, g, b = [int(round(v)) for v in col[0]]
            self.outline_color_bgr = (b, g, r)
            self.outline_color_hex = col[1]
            self.color_preview.config(bg=self.outline_color_hex)

    def choose_glow_color(self):
        col = colorchooser.askcolor(title="Glow色を選択", initialcolor=getattr(self, "glow_color_hex", "#ffaa00"))
        if col and col[0] is not None:
            r, g, b = [int(round(v)) for v in col[0]]
            self.glow_color_bgr = (b, g, r)
            self.glow_color_hex = col[1]
            self.glow_preview.config(bg=self.glow_color_hex)

    def _gather_settings(self):
        return {
            "enable_outline": bool(self.enable_outline_var.get()),
            "enable_gradient": bool(self.enable_gradient_var.get()),
            "enable_feather": bool(self.enable_feather_var.get()),
            "enable_glow": bool(self.enable_glow_var.get()),
            "feather_strength": int(self.feather_scale.get()),
            "outline_size": int(self.outline_scale.get()),
            "grad_strength": int(self.grad_scale.get()),
            "glow_strength": int(self.glow_scale.get()),
            "outline_color_bgr": getattr(self, "outline_color_bgr", (0,170,255)),
            "glow_color_bgr": getattr(self, "glow_color_bgr", (0,170,255)),
        }

    def show_preview(self, img, label, max_size=250):
        if img is None:
            return
        if img.shape[2] == 4:
            disp = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
        else:
            disp = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        disp = Image.fromarray(disp)
        w, h = disp.size
        scale = min(max_size / w, max_size / h, 1.0)
        new_size = (int(w * scale), int(h * scale))
        disp = disp.resize(new_size)
        tk_img = ImageTk.PhotoImage(disp)
        label.configure(image=tk_img)
        label.image = tk_img

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

Python

Posted by eightban