好きな場所で分割できる画像分割ツール 自動分割バッチ処理対応

はじめ

画像分割ツールは、1枚の画像を任意のルールで細かく切り出すための高機能アプリです。フォルダを選ぶと画像が一覧表示され、サムネイルをクリックすると中央のプレビューに読み込まれます。縦線・横線・両方・自動・行列モードを切り替えながら、左クリックで線追加、右ドラッグで線の位置調整ができます。ズーム機能により細部を確認しながら正確に線を配置でき、上部には画像サイズ・クリック座標・ズーム倍率が表示されます。分割ボタンを押すと設定した線に基づいて画像が分割され、右側に結果のサムネイルが並びます。行列モードでは左上と右下の範囲を指定し、行数・列数・オフセットを使って均等なグリッド分割が可能です。自動モードでは色差を基準に区切り線を検出できます。線情報はJSONとして保存・読み込みでき、分割結果は連番PNGとして出力されます。

使い方

📘 画像分割ツール:完全使い方ガイド(ボタン別・機能別)

🟥 1. 左パネル(フォルダ選択・サムネイル一覧)

フォルダ選択

画像が入ったフォルダを選ぶ。
選ぶとフォルダ内の PNG/JPG がサムネイルとして一覧表示される。

サムネイル一覧

クリックするとその画像が中央プレビューに読み込まれる。
同時に、過去の線情報(JSON)があれば自動で読み込まれる。

🟦 2. 中央プレビュー(画像表示・線描画・クリック操作)

画像表示

ズームスライダーで拡大縮小できる。
クリックすると座標が上部バーに表示される。

線の追加(左クリック)

モードに応じて縦線・横線・両方を追加する。

  • 縦線モード → X座標に縦線
  • 横線モード → Y座標に横線
  • 縦横モード → XとY両方
  • 行列モード → 左上/右下の範囲をクリックで指定
  • 自動モード → 線は追加されず、自動検出ボタンで生成

線の移動(右ドラッグ)

右クリック+ドラッグで線をつかんで移動できる。

🟩 3. 上部バー(画像情報・分割数)

Size

画像の元サイズ(例:800×800)

Click

直近でクリックした座標

Zoom

現在のズーム倍率

分割数

分割結果の枚数をリアルタイム表示

🟧 4. 分割モード選択(ラジオボタン)

縦線モード

縦線だけ追加して縦方向に分割。

横線モード

横線だけ追加して横方向に分割。

縦横モード

縦線・横線を同時に追加。

自動モード

色差を使って自動で区切り線を検出。

行列モード

指定範囲を NxM のグリッドで分割する。

🟨 5. 線操作ボタン

取消(最後)

最後に追加した線を削除。

全てクリア

すべての線を削除。
行列モードでは範囲を初期化。

🟪 6. 分割実行ボタン

縦に分割

縦線だけ使って分割。

横に分割

横線だけ使って分割。

縦横に分割

縦線+横線で格子状に分割。

🟫 7. 分割結果保存

分割結果保存

フォルダ内に split_output を作り、
元ファイル名_0000.png のように連番で保存。

🟦 8. 線情報の保存・読み込み

線情報保存

現在の線データ(vertical/horizontal)を JSON に保存。

線情報読込

同じ JSON を読み込んで線を復元。

別名保存

好きな名前で JSON 保存。

別名読込

好きな JSON を読み込む。

🟥 9. 自動モード(色差による線検出)

自動検出

画像の色の変化を見て区切り線を自動生成。

許容色差(0〜255)

小さい → 厳密
大きい → 緩く判定

プリセット(0〜100)

ワンクリックで tolerance を変更。

🟩 10. 行列モード(範囲指定 → NxM 分割)

左上座標

クリックした位置を左上として記録。

右下座標

クリックした位置を右下として記録。

行数・列数

NxM の分割数を指定。

オフセットX/Y

範囲の内側に余白を作る。

行列で分割

指定範囲を NxM に切り出す。

🟦 11. 座標スピンボックス(微調整)

● 左上X / 左上Y

● 右下X / 右下Y

クリックで決めた範囲を数値で微調整できる。

🟧 12. プリセット(2×2〜10×10)

ワンクリックで行列モードの行数・列数をセット。

🟫 13. ズーム・微調整ボタン

● Zoom スライダー

10〜300% の拡大縮小。

● ← → ↑ ↓

最後に追加した線を1pxずつ動かす。

🟪 14. 右パネル(分割結果プレビュー)

● 分割結果がサムネイルで並ぶ

クリックすると拡大表示ウィンドウが開く。

プログラム

import os
import json
from tkinter import *
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk

class ImageSplitterGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("画像分割ツール")

        self.folder = ""
        self.image_path = ""
        self.image = None
        self.zoomed_image = None
        self.tk_image = None

        self.split_data = {"vertical": [], "horizontal": []}

        self.mode_var = StringVar(value="vertical")

        self.dragging = False
        self.drag_line_type = None
        self.drag_index = None

        self.temp_results = []

        # ★ 行列分割用の状態
        self.rect_tl = None          # 分割範囲 左上 (x, y)
        self.rect_br = None          # 分割範囲 右下 (x, y)
        self.awaiting_corner = None  # "tl" or "br"(次クリックで座標取得)
        self.grid_lines = {"vertical": [], "horizontal": []}  # 計算済みガイド線
        self.grid_rows = IntVar(value=2)
        self.grid_cols = IntVar(value=2)
        # ★ オフセット(X・Y 個別)。指定範囲の内側に余白を作る
        self.grid_offset_x = IntVar(value=0)
        self.grid_offset_y = IntVar(value=0)
        # ★ 分割範囲の座標(微調整スピンボックスに連動)
        self.tl_x_var = IntVar(value=0)
        self.tl_y_var = IntVar(value=0)
        self.br_x_var = IntVar(value=0)
        self.br_y_var = IntVar(value=0)

        # ★ ズーム倍率(初期値100%)
        self.zoom_percent = 100

        # ★ 自動分割モード用:許容色差(0=完全一致、大きいほど緩い)
        self.auto_tolerance = IntVar(value=10)
        # ★ 分割結果の小サイズ除外(N px以下の枠を除外 / 初期値: 除外する)
        self.exclude_small = BooleanVar(value=True)
        self.exclude_min_px = IntVar(value=1)

        self.setup_ui()

        # ★ モード・パラメータ変更を監視してガイド線を再計算
        self.mode_var.trace_add("write", lambda *a: self.on_mode_change())
        self.grid_rows.trace_add("write", lambda *a: self.on_grid_param_change())
        self.grid_cols.trace_add("write", lambda *a: self.on_grid_param_change())
        self.grid_offset_x.trace_add("write", lambda *a: self.on_grid_param_change())
        self.grid_offset_y.trace_add("write", lambda *a: self.on_grid_param_change())

    # ---------------- UI ----------------
    def setup_ui(self):
        self.root.configure(bg="#f0f0f0")

        main = Frame(self.root, bg="#f0f0f0")
        main.pack(fill=BOTH, expand=True)

        # 左:サムネイル
        left = Frame(main, bg="#e0e0e0", bd=2, relief=GROOVE)
        left.pack(side=LEFT, fill=Y)

        Button(left, text="フォルダ選択", command=self.select_folder,
               font=("Meiryo", 9), bg="#ffffff").pack(fill=X, padx=5, pady=5)

        thumb_container = Frame(left, bg="#e0e0e0")
        thumb_container.pack(fill=BOTH, expand=True, padx=5, pady=5)

        self.thumb_canvas = Canvas(thumb_container, bg="#d0d0d0", width=150, bd=2, relief=SOLID)
        self.thumb_canvas.pack(side=LEFT, fill=BOTH, expand=True)

        thumb_scroll = Scrollbar(thumb_container, orient=VERTICAL, command=self.thumb_canvas.yview)
        thumb_scroll.pack(side=RIGHT, fill=Y)

        self.thumb_canvas.configure(yscrollcommand=thumb_scroll.set)
        self.thumb_inner = Frame(self.thumb_canvas, bg="#d0d0d0")
        self.thumb_canvas.create_window((0, 0), window=self.thumb_inner, anchor="nw")
        self.thumb_inner.bind("<Configure>", lambda e: self.thumb_canvas.configure(
            scrollregion=self.thumb_canvas.bbox("all")
        ))

        # 中央:プレビュー
        center = Frame(main, bg="#f0f0f0")
        center.pack(side=LEFT, fill=BOTH, expand=True)

        # ★ 上部バー(左:情報表示 / 右:分割数表示)
        top_bar = Frame(center, bg="#f0f0f0")
        top_bar.pack(fill=X, pady=2)

        self.info_label = Label(
            top_bar,
            text="Size: - x -    Click: X=- Y=-    Zoom: 100%",
            bg="#f0f0f0",
            font=("Meiryo", 9)
        )
        self.info_label.pack(side=LEFT)

        # ★ 分割数を画面右上に表示
        self.count_label = Label(
            top_bar,
            text="分割数: 0",
            bg="#f0f0f0",
            font=("Meiryo", 9, "bold"),
            fg="#cc0000"
        )
        self.count_label.pack(side=RIGHT)

        preview_container = Frame(center, bg="#f0f0f0")
        preview_container.pack(fill=BOTH, expand=True)

        self.preview_canvas = Canvas(preview_container, bg="gray", bd=2, relief=SOLID)
        self.preview_canvas.pack(side=LEFT, fill=BOTH, expand=True)

        v_scroll = Scrollbar(preview_container, orient=VERTICAL, command=self.preview_canvas.yview)
        v_scroll.pack(side=RIGHT, fill=Y)

        h_scroll = Scrollbar(center, orient=HORIZONTAL, command=self.preview_canvas.xview)
        h_scroll.pack(fill=X)

        self.preview_canvas.configure(
            yscrollcommand=v_scroll.set,
            xscrollcommand=h_scroll.set
        )

        # 内部キャンバス
        self.canvas = Canvas(self.preview_canvas, bg="gray")
        self.canvas_id = self.preview_canvas.create_window((0, 0), window=self.canvas, anchor="nw")

        self.canvas.bind("<Configure>", self.update_scrollregion)

        # イベント
        self.canvas.bind("<Button-1>", self.on_click)
        self.canvas.bind("<ButtonPress-3>", self.on_drag_start)
        self.canvas.bind("<B3-Motion>", self.on_drag_move)
        self.canvas.bind("<ButtonRelease-3>", self.on_drag_end)

        # ボタン類
        btn_frame = Frame(center, bg="#f0f0f0")
        btn_frame.pack(pady=2)

        btn_style = {"font": ("Meiryo", 9), "width": 10, "height": 1}

        Radiobutton(btn_frame, text="縦線モード", variable=self.mode_var,
           value="vertical", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=0, padx=4)

        Radiobutton(btn_frame, text="横線モード", variable=self.mode_var,
           value="horizontal", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=1, padx=4)

        Radiobutton(btn_frame, text="縦横モード", variable=self.mode_var,
           value="both", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=2, padx=4)

        Button(btn_frame, text="取消(最後)", command=self.undo_last, **btn_style).grid(row=0, column=3, padx=4)
        Button(btn_frame, text="全てクリア", command=self.reset_lines, **btn_style).grid(row=0, column=4, padx=4)

        Button(btn_frame, text="縦に分割", command=self.split_vertical, **btn_style).grid(row=1, column=0, padx=4)
        Button(btn_frame, text="横に分割", command=self.split_horizontal, **btn_style).grid(row=1, column=1, padx=4)
        Button(btn_frame, text="縦横に分割", command=self.split_both, **btn_style).grid(row=1, column=2, padx=4)
        Button(btn_frame, text="分割結果保存", command=self.save_split_results, **btn_style).grid(row=1, column=3, padx=4)
        Button(btn_frame, text="線情報保存", command=self.save_split_data, **btn_style).grid(row=1, column=4, padx=4)
        # ★ 分割結果の小サイズ除外(N px以下を除外するかの選択)
        Checkbutton(btn_frame, text="除外", variable=self.exclude_small,
                    bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=5, padx=4)
        Spinbox(btn_frame, from_=1, to=1000, textvariable=self.exclude_min_px, width=4,
                font=("Meiryo", 9)).grid(row=1, column=6, padx=2)
        Label(btn_frame, text="px以下", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=7, padx=2)

        Button(btn_frame, text="線情報読込", command=self.load_split_data, **btn_style).grid(row=2, column=0, padx=4)
        # ★ バッチ処理ボタン(フォルダー内の全画像を自動分割)
        Button(btn_frame, text="バッチ処理", command=self.batch_process, **btn_style).grid(row=2, column=1, padx=4)
        # ★ 別名で保存 / ファイル選択で読み込み
        Button(btn_frame, text="別名保存", command=self.save_split_data_as, **btn_style).grid(row=2, column=2, padx=4)
        Button(btn_frame, text="別名読込", command=self.load_split_data_from, **btn_style).grid(row=2, column=3, padx=4)

        # ★ 自動分割モード
        Radiobutton(btn_frame, text="自動モード", variable=self.mode_var,
           value="auto", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=3, column=0, padx=4)
        Button(btn_frame, text="自動検出", command=self.auto_detect_lines, **btn_style).grid(row=3, column=1, padx=4)
        Label(btn_frame, text="許容色差", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=3, column=2, padx=4)
        Spinbox(btn_frame, from_=0, to=255, textvariable=self.auto_tolerance, width=5,
                font=("Meiryo", 9)).grid(row=3, column=3, padx=4)
        # ★ 許容色差プリセット(0〜100)を自動モードのすぐ下に(隙間なし・btn_frameを広げない)
        tol_row = Frame(btn_frame, bg="#f0f0f0")
        tol_row.grid(row=4, column=0, columnspan=5, sticky="w", padx=4)
        for _v in range(0, 101, 10):
            Button(tol_row, text=str(_v), width=2, font=("Meiryo", 9),
                   padx=0, pady=0, bd=0, highlightthickness=0,
                   command=lambda _v=_v: self.auto_tolerance.set(_v)).pack(side=LEFT, padx=0)

        # ★ 行列モード(行数・列数で分割)
        Radiobutton(btn_frame, text="行列モード", variable=self.mode_var,
           value="grid", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=5, column=0, padx=4)
        Button(btn_frame, text="左上座標", command=lambda: self.set_corner("tl"), **btn_style).grid(row=5, column=1, padx=4)
        Button(btn_frame, text="右下座標", command=lambda: self.set_corner("br"), **btn_style).grid(row=5, column=2, padx=4)
        Button(btn_frame, text="行列で分割", command=self.grid_split, **btn_style).grid(row=5, column=3, padx=4)

        # ★ 行列分割パラメータ(行数・列数・オフセット)
        param_frame = Frame(center, bg="#f0f0f0")
        param_frame.pack(pady=2)

        Label(param_frame, text="行数", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=0, padx=4)
        Spinbox(param_frame, from_=1, to=9999, textvariable=self.grid_rows, width=6,
                font=("Meiryo", 9)).grid(row=0, column=1, padx=4)

        Label(param_frame, text="列数", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=2, padx=4)
        Spinbox(param_frame, from_=1, to=9999, textvariable=self.grid_cols, width=6,
                font=("Meiryo", 9)).grid(row=0, column=3, padx=4)

        Label(param_frame, text="オフセットX", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=4, padx=4)
        Spinbox(param_frame, from_=-1000, to=1000, textvariable=self.grid_offset_x, width=5,
                font=("Meiryo", 9)).grid(row=0, column=5, padx=4)

        Label(param_frame, text="オフセットY", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=6, padx=4)
        Spinbox(param_frame, from_=-1000, to=1000, textvariable=self.grid_offset_y, width=5,
                font=("Meiryo", 9)).grid(row=0, column=7, padx=4)

        # ★ 左上・右下の座標(微調整スピンボックス / コンボボックス)
        # ※ textvariable を使わず、ウィジェットに直接値を書き込む方式
        #   (textvariable だと Tk 内部の巻き戻りでプログラムからの set が失われるため)
        Label(param_frame, text="左上X", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=0, padx=4)
        self.sp_tl_x = Spinbox(param_frame, from_=-100000, to=100000, width=6,
                               font=("Meiryo", 9), command=self.on_coord_change)
        self.sp_tl_x.grid(row=1, column=1, padx=4)
        Label(param_frame, text="左上Y", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=2, padx=4)
        self.sp_tl_y = Spinbox(param_frame, from_=-100000, to=100000, width=6,
                               font=("Meiryo", 9), command=self.on_coord_change)
        self.sp_tl_y.grid(row=1, column=3, padx=4)
        Label(param_frame, text="右下X", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=4, padx=4)
        self.sp_br_x = Spinbox(param_frame, from_=0, to=100000, width=6,
                               font=("Meiryo", 9), command=self.on_coord_change)
        self.sp_br_x.grid(row=1, column=5, padx=4)
        Label(param_frame, text="右下Y", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=1, column=6, padx=4)
        self.sp_br_y = Spinbox(param_frame, from_=0, to=100000, width=6,
                               font=("Meiryo", 9), command=self.on_coord_change)
        self.sp_br_y.grid(row=1, column=7, padx=4)
        # 手入力・フォーカス移動も反映
        for _sp in (self.sp_tl_x, self.sp_tl_y, self.sp_br_x, self.sp_br_y):
            _sp.bind("<KeyRelease>", self.on_coord_change)
            _sp.bind("<FocusOut>", self.on_coord_change)
        # ★ 起動時は左上X/Y(および右下)を 0 で表示(from_=-100000 の初期表示対策)
        for _sp in (self.sp_tl_x, self.sp_tl_y, self.sp_br_x, self.sp_br_y):
            self._set_spin_value(_sp, 0)

        # ★ 分割範囲の状態表示
        self.range_label = Label(param_frame, text="範囲: 未設定(行列モードで使用)",
                                 bg="#f0f0f0", font=("Meiryo", 9))
        self.range_label.grid(row=2, column=0, columnspan=8, pady=3)

        # ★ ワンクリックプリセット(2×2 ? 10×10)を横一列に整列
        preset_frame = Frame(center, bg="#f0f0f0")
        preset_frame.pack(pady=2)
        Label(preset_frame, text="プリセット:", bg="#f0f0f0", font=("Meiryo", 9)).pack(side=LEFT, padx=4)
        for _n in range(2, 11):
            Button(preset_frame, text=f"{_n}×{_n}", width=5, font=("Meiryo", 9),
                   command=lambda _n=_n: self.set_grid_preset(_n)).pack(side=LEFT, padx=0)

        # ★ ズームスライダー
        # ★ ZOOM + 微調整(1行にまとめる)
        zoom_adjust_frame = Frame(center, bg="#f0f0f0")
        zoom_adjust_frame.pack(pady=4)

        # ZOOM ラベル
        Label(zoom_adjust_frame, text="Zoom", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=0, padx=5)

        # ZOOM スライダー
        self.zoom_slider = Scale(
            zoom_adjust_frame,
            from_=10, to=300,
            orient=HORIZONTAL,
            length=170,
            command=self.on_zoom_slider
        )
        self.zoom_slider.set(100)
        self.zoom_slider.grid(row=0, column=1, padx=10)

        # 微調整ボタン(← → ↑ ↓)
        Button(zoom_adjust_frame, text="←", width=4, command=lambda: self.adjust_last_line(-1)).grid(row=0, column=2)
        Button(zoom_adjust_frame, text="→", width=4, command=lambda: self.adjust_last_line(1)).grid(row=0, column=3)
        Button(zoom_adjust_frame, text="↑", width=4, command=lambda: self.adjust_last_line(-1, vertical=False)).grid(row=0, column=4)
        Button(zoom_adjust_frame, text="↓", width=4, command=lambda: self.adjust_last_line(1, vertical=False)).grid(row=0, column=5)
        # 右:分割結果
        right = Frame(main, bg="#e0e0e0", bd=2, relief=GROOVE)
        right.pack(side=LEFT, fill=Y)

        Label(right, text="分割結果プレビュー", bg="#e0e0e0").pack(pady=5)

        result_container = Frame(right, bg="#e0e0e0")
        result_container.pack(fill=BOTH, expand=True)

        self.result_canvas = Canvas(result_container, bg="#d0d0d0", width=180, bd=2, relief=SOLID)
        self.result_canvas.pack(side=LEFT, fill=BOTH, expand=True)

        result_scroll = Scrollbar(result_container, orient=VERTICAL, command=self.result_canvas.yview)
        result_scroll.pack(side=RIGHT, fill=Y)

        self.result_canvas.configure(yscrollcommand=result_scroll.set)
        self.result_inner = Frame(self.result_canvas, bg="#d0d0d0")
        self.result_canvas.create_window((0, 0), window=self.result_inner, anchor="nw")
        self.result_inner.bind("<Configure>", lambda e: self.result_canvas.configure(
            scrollregion=self.result_canvas.bbox("all")
        ))
    # ---------------- スクロール領域更新 ----------------
    def update_scrollregion(self, event=None):
        self.preview_canvas.configure(
            scrollregion=self.preview_canvas.bbox("all")
        )

    # ---------------- フォルダ選択 ----------------
    def select_folder(self):
        self.folder = filedialog.askdirectory()
        if self.folder:
            self.load_thumbnails()

    # ---------------- サムネイル読み込み ----------------
    def load_thumbnails(self):
        for w in self.thumb_inner.winfo_children():
            w.destroy()

        for file in sorted(os.listdir(self.folder)):
            if file.lower().endswith((".png", ".jpg", ".jpeg")):
                path = os.path.join(self.folder, file)
                try:
                    img = Image.open(path)
                    img.thumbnail((120, 120))
                    tk_img = ImageTk.PhotoImage(img)

                    btn = Button(self.thumb_inner, image=tk_img,
                                 command=lambda p=path: self.load_image_from_thumb(p),
                                 bg="#d0d0d0", relief=RIDGE, bd=2)
                    btn.image = tk_img
                    btn.pack(pady=4)
                except:
                    pass

    # ---------------- サムネイルクリックで画像読み込み ----------------
    def load_image_from_thumb(self, path):
        self.image_path = path
        self.load_image()
        self.load_split_data()
        self.update_zoomed_image()
        self.draw_image_and_lines()
        self.clear_result_thumbs()
        self.temp_results = []
        # ★ 行列分割の初期値:左上(0, 0)、右下(画像サイズ-1)
        self.reset_grid_range_to_full_image()
        self.update_info_label()
        self.update_split_count()

    # ---------------- 画像読み込み ----------------
    def load_image(self):
        self.image = Image.open(self.image_path)
        self.update_zoomed_image()
        self.update_info_label()

    # ---------------- ズーム画像生成 ----------------
    def update_zoomed_image(self):
        if not self.image:
            return

        scale = self.zoom_percent / 100.0
        new_w = int(self.image.width * scale)
        new_h = int(self.image.height * scale)

        self.zoomed_image = self.image.resize((new_w, new_h), Image.LANCZOS)
        self.tk_image = ImageTk.PhotoImage(self.zoomed_image)

        self.canvas.config(width=new_w, height=new_h)
        self.preview_canvas.configure(scrollregion=(0, 0, new_w, new_h))

    # ---------------- 情報ラベル更新(1行) ----------------
    def update_info_label(self, click_x=None, click_y=None):
        if self.image:
            size_text = f"Size: {self.image.width}x{self.image.height}"
        else:
            size_text = "Size: - x -"

        if click_x is None or click_y is None:
            click_text = "Click: X=- Y=-"
        else:
            click_text = f"Click: X={click_x} Y={click_y}"

        zoom_text = f"Zoom: {self.zoom_percent}%"

        self.info_label.config(text=f"{size_text}    {click_text}    {zoom_text}")

    # ---------------- ズームスライダー変更 ----------------
    def on_zoom_slider(self, value):
        self.zoom_percent = int(float(value))
        self.update_zoomed_image()
        self.draw_image_and_lines()
        self.update_info_label()

    # ---------------- 線描画(ズーム対応) ----------------
    def draw_image_and_lines(self):
        if not self.zoomed_image:
            return

        self.canvas.delete("all")
        self.canvas.create_image(0, 0, anchor=NW, image=self.tk_image)

        scale = self.zoom_percent / 100.0
        w, h = self.zoomed_image.width, self.zoomed_image.height

        # ★ 行列モード:指定範囲(青い四角)とガイド線を描画
        if self.is_grid_mode():
            if self.rect_tl and self.rect_br:
                x1, y1 = int(self.rect_tl[0] * scale), int(self.rect_tl[1] * scale)
                x2, y2 = int(self.rect_br[0] * scale), int(self.rect_br[1] * scale)
                # 左上・右下を結ぶ青い四角(実線でわかりやすく)
                self.canvas.create_rectangle(x1, y1, x2, y2,
                                             outline="blue", width=3)

            for x in self.grid_lines["vertical"]:
                zx = int(x * scale)
                self.canvas.create_line(zx, 0, zx, h, fill="red", width=2)

            for y in self.grid_lines["horizontal"]:
                zy = int(y * scale)
                self.canvas.create_line(0, zy, w, zy, fill="red", width=2)
            return

        # ★ ズーム後の位置に変換して線を描画
        for x in self.split_data["vertical"]:
            zx = int(x * scale)
            self.canvas.create_line(zx, 0, zx, h, fill="red", width=2)

        for y in self.split_data["horizontal"]:
            zy = int(y * scale)
            self.canvas.create_line(0, zy, w, zy, fill="red", width=2)

    # ---------------- 左クリックで線追加(ズーム対応) ----------------
    def on_click(self, event):
        if not self.image:
            return

        scale = self.zoom_percent / 100.0
        orig_x = int(event.x / scale)
        orig_y = int(event.y / scale)

        self.update_info_label(orig_x, orig_y)

        # ★ 行列モード:左上/右下の座標を取得
        if self.is_grid_mode():
            if self.awaiting_corner == "tl":
                self.rect_tl = (orig_x, orig_y)
            elif self.awaiting_corner == "br":
                self.rect_br = (orig_x, orig_y)

            if self.awaiting_corner in ("tl", "br"):
                self.awaiting_corner = None
                self.sync_vars_from_rect()  # スピンボックスに反映
                self.compute_grid_lines()
                self.draw_image_and_lines()  # ★ クリック直後に青い四角を描画
                self.update_grid_state_label()
            return

        mode = self.mode_var.get()

        if mode == "vertical":
            self.split_data["vertical"].append(orig_x)

        elif mode == "horizontal":
            self.split_data["horizontal"].append(orig_y)

        elif mode == "both":
            self.split_data["vertical"].append(orig_x)
            self.split_data["horizontal"].append(orig_y)

        self.draw_image_and_lines()

    # ---------------- 最後の線微調整 ----------------
    def adjust_last_line(self, delta, vertical=True):
        if self.is_grid_mode():
            lines = self.grid_lines
        else:
            lines = self.split_data

        if vertical:
            if lines["vertical"]:
                lines["vertical"][-1] += delta
        else:
            if lines["horizontal"]:
                lines["horizontal"][-1] += delta

        self.draw_image_and_lines()

    # ---------------- 右ドラッグ開始 ----------------
    def on_drag_start(self, event):
        if not self.image:
            return

        scale = self.zoom_percent / 100.0
        x = int(event.x / scale)
        y = int(event.y / scale)
        threshold = 5

        if self.is_grid_mode():
            lines = self.grid_lines
        else:
            lines = self.split_data

        for i, vx in enumerate(lines["vertical"]):
            if abs(vx - x) <= threshold:
                self.dragging = True
                self.drag_line_type = "vertical"
                self.drag_index = i
                return

        for i, hy in enumerate(lines["horizontal"]):
            if abs(hy - y) <= threshold:
                self.dragging = True
                self.drag_line_type = "horizontal"
                self.drag_index = i
                return

    # ---------------- 右ドラッグ中 ----------------
    def on_drag_move(self, event):
        if not self.dragging:
            return

        scale = self.zoom_percent / 100.0
        x = int(event.x / scale)
        y = int(event.y / scale)

        if self.is_grid_mode():
            lines = self.grid_lines
        else:
            lines = self.split_data

        if self.drag_line_type == "vertical":
            lines["vertical"][self.drag_index] = x
        else:
            lines["horizontal"][self.drag_index] = y

        self.draw_image_and_lines()

    # ---------------- 右ドラッグ終了 ----------------
    def on_drag_end(self, event):
        self.dragging = False
        self.drag_line_type = None
        self.drag_index = None
    # ---------------- 最後の線削除 ----------------
    def undo_last(self):
        if self.mode_var.get() == "vertical" and self.split_data["vertical"]:
            self.split_data["vertical"].pop()
        elif self.mode_var.get() == "horizontal" and self.split_data["horizontal"]:
            self.split_data["horizontal"].pop()
        elif self.mode_var.get() == "both":
            if self.split_data["vertical"]:
                self.split_data["vertical"].pop()
            if self.split_data["horizontal"]:
                self.split_data["horizontal"].pop()

        self.draw_image_and_lines()

    # ---------------- 全線削除 ----------------
    def reset_lines(self):
        if self.is_grid_mode():
            # ★ 行列モード:分割範囲を初期値(画像全体)に戻す
            self.reset_grid_range_to_full_image()
        else:
            self.split_data = {"vertical": [], "horizontal": []}
        self.draw_image_and_lines()

    # ---------------- 分割ボタン ----------------
    def split_vertical(self):
        self.do_split(vertical=False, horizontal=True)

    def split_horizontal(self):
        self.do_split(vertical=True, horizontal=False)

    def split_both(self):
        self.do_split(vertical=True, horizontal=True)

    # ---------------- 分割処理(オフセット削除済み) ----------------
    def do_split(self, vertical, horizontal):
        if not self.image:
            return

        v = sorted(self.split_data["vertical"]) if vertical else []
        h = sorted(self.split_data["horizontal"]) if horizontal else []

        v = [0] + v + [self.image.width]
        h = [0] + h + [self.image.height]

        self.clear_result_thumbs()
        self.temp_results = []

        for i in range(len(v)-1):
            for j in range(len(h)-1):
                crop = self.image.crop((v[i], h[j], v[i+1], h[j+1]))
                # ★ 1px幅/高さの結果を除外(チェック時)
                if self.exclude_small.get() and (crop.width <= self.exclude_min_px.get() or crop.height <= self.exclude_min_px.get()):
                    continue
                self.temp_results.append(crop)
                self.add_result_thumb(crop)

        self.update_split_count()

    # ---------------- 分割結果プレビュー ----------------
    def clear_result_thumbs(self):
        for w in self.result_inner.winfo_children():
            w.destroy()

    def add_result_thumb(self, img):
        preview = img.copy()
        preview.thumbnail((140, 140))
        tk_img = ImageTk.PhotoImage(preview)

        lbl = Label(self.result_inner, image=tk_img, bg="#d0d0d0", relief=RIDGE, bd=2)
        lbl.image = tk_img
        lbl.pack(pady=4)

        lbl.bind("<Button-1>", lambda e, im=img: self.open_preview_window(im))

    # ---------------- 拡大表示 ----------------
    def open_preview_window(self, img):
        win = Toplevel(self.root)
        win.title("拡大プレビュー")

        tk_img = ImageTk.PhotoImage(img)
        lbl = Label(win, image=tk_img)
        lbl.image = tk_img
        lbl.pack(padx=10, pady=10)

        Button(win, text="閉じる", command=win.destroy).pack(pady=5)

    # ---------------- 分割結果保存 ----------------
    def save_split_results(self):
        if not self.temp_results:
            return

        base = os.path.splitext(os.path.basename(self.image_path))[0]
        out_dir = os.path.join(self.folder, "split_output")
        os.makedirs(out_dir, exist_ok=True)

        for idx, img in enumerate(self.temp_results):
            img.save(os.path.join(out_dir, f"{base}_{idx:04d}.png"))

    # ---------------- 線情報読み込み ----------------
    def load_split_data(self):
        program_name = os.path.splitext(os.path.basename(__file__))[0]
        json_path = program_name + ".json"

        if os.path.exists(json_path):
            with open(json_path, "r", encoding="utf-8") as f:
                self.split_data = json.load(f)
        else:
            self.split_data = {"vertical": [], "horizontal": []}

        if self.image:
            self.draw_image_and_lines()
            self.update_info_label()

    # ---------------- 線情報保存 ----------------
    def save_split_data(self):
        program_name = os.path.splitext(os.path.basename(__file__))[0]
        json_path = program_name + ".json"

        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(self.split_data, f, indent=2, ensure_ascii=False)

    # ---------------- 線情報を別名で保存 ----------------
    def save_split_data_as(self):
        path = filedialog.asksaveasfilename(
            title="線情報を別名で保存",
            defaultextension=".json",
            filetypes=[("JSONファイル", "*.json")]
        )
        if not path:
            return

        with open(path, "w", encoding="utf-8") as f:
            json.dump(self.split_data, f, indent=2, ensure_ascii=False)

    # ---------------- 線情報をファイル選択で読み込み ----------------
    def load_split_data_from(self):
        path = filedialog.askopenfilename(
            title="線情報を読み込み",
            filetypes=[("JSONファイル", "*.json")]
        )
        if not path:
            return

        with open(path, "r", encoding="utf-8") as f:
            self.split_data = json.load(f)

        if self.image:
            self.draw_image_and_lines()
            self.update_info_label()

    # ---------------- 分割数表示更新 ----------------
    def update_split_count(self):
        count = len(self.temp_results)
        self.count_label.config(text=f"分割数: {count}")

    # ---------------- 行列モード判定 ----------------
    def is_grid_mode(self):
        return self.mode_var.get() == "grid"

    # ---------------- 自動モード判定 ----------------
    def is_auto_mode(self):
        return self.mode_var.get() == "auto"

    # ---------------- モード変更(行列モード切替) ----------------
    def on_mode_change(self, *args):
        if not hasattr(self, "canvas"):
            return
        if self.is_grid_mode():
            # ★ 安全策:範囲が未設定の場合は画像全体を初期値にする
            if self.rect_tl is None and self.image:
                self.reset_grid_range_to_full_image()
            try:
                self.compute_grid_lines()
            except Exception:
                pass
        self.draw_image_and_lines()
        self.update_grid_state_label()

    # ---------------- 行数・列数・オフセット変更 ----------------
    def on_grid_param_change(self, *args):
        if self.is_grid_mode():
            try:
                self.compute_grid_lines()
                self.draw_image_and_lines()
            except Exception:
                pass

    # ---------------- 座標スピンボックス変更 → 分割範囲へ反映 ----------------
    def on_coord_change(self, *args):
        if not hasattr(self, "sp_tl_x"):
            return
        try:
            x1 = int(self.sp_tl_x.get())
            y1 = int(self.sp_tl_y.get())
            x2 = int(self.sp_br_x.get())
            y2 = int(self.sp_br_y.get())
        except Exception:
            return
        self.rect_tl = (x1, y1)
        self.rect_br = (x2, y2)
        self.tl_x_var.set(x1)
        self.tl_y_var.set(y1)
        self.br_x_var.set(x2)
        self.br_y_var.set(y2)

        if self.is_grid_mode():
            self.compute_grid_lines()
            self.draw_image_and_lines()
            self.update_grid_state_label()

    # ---------------- 分割範囲 → 座標スピンボックスへ同期 ----------------
    def sync_vars_from_rect(self):
        has_sp = hasattr(self, "sp_tl_x")
        if self.rect_tl:
            self.tl_x_var.set(self.rect_tl[0])
            self.tl_y_var.set(self.rect_tl[1])
            if has_sp:
                self._set_spin_value(self.sp_tl_x, self.rect_tl[0])
                self._set_spin_value(self.sp_tl_y, self.rect_tl[1])
        if self.rect_br:
            self.br_x_var.set(self.rect_br[0])
            self.br_y_var.set(self.rect_br[1])
            if has_sp:
                self._set_spin_value(self.sp_br_x, self.rect_br[0])
                self._set_spin_value(self.sp_br_y, self.rect_br[1])

    def _set_spin_value(self, sp, value):
        """スピンボックスの表示内容を直接書き換える(textvariable非依存)"""
        try:
            sp.delete(0, "end")
            sp.insert(0, str(value))
        except Exception:
            pass

    # ---------------- 分割範囲を初期値(画像全体)に設定 ----------------
    def reset_grid_range_to_full_image(self):
        if not self.image:
            self.rect_tl = None
            self.rect_br = None
            return
        # ★ 左上=(0, 0) / 右下=(画像の幅-1, 画像の高さ-1)
        w, h = self.image.width, self.image.height
        self.rect_tl = (0, 0)
        self.rect_br = (w - 1, h - 1)
        self.awaiting_corner = None
        self.sync_vars_from_rect()   # スピンボックスに反映
        self.compute_grid_lines()
        self.update_grid_state_label()

    # ---------------- 座標取得ボタン ----------------
    def set_corner(self, corner):
        self.awaiting_corner = corner
        self.update_grid_state_label()

    # ---------------- ガイド線計算(行数・列数・オフセット) ----------------
    def compute_grid_lines(self):
        if not (self.rect_tl and self.rect_br):
            self.grid_lines = {"vertical": [], "horizontal": []}
            return

        x1, y1 = self.rect_tl
        x2, y2 = self.rect_br

        if x2 <= x1 or y2 <= y1:
            self.grid_lines = {"vertical": [], "horizontal": []}
            return

        # ★ オフセット(X・Y):指定範囲の内側に余白を作る
        try:
            off_x = self.grid_offset_x.get()
            off_y = self.grid_offset_y.get()
            cols = max(1, self.grid_cols.get())
            rows = max(1, self.grid_rows.get())
        except Exception:
            off_x, off_y, cols, rows = 0, 0, 1, 1

        ex1, ey1 = x1 + off_x, y1 + off_y
        ex2, ey2 = x2 - off_x, y2 - off_y

        if ex2 <= ex1 or ey2 <= ey1:
            self.grid_lines = {"vertical": [], "horizontal": []}
            return

        v = [int(ex1 + (ex2 - ex1) * i / cols) for i in range(1, cols)]
        h = [int(ey1 + (ey2 - ey1) * i / rows) for i in range(1, rows)]

        self.grid_lines = {"vertical": v, "horizontal": h}

    # ---------------- ワンクリックプリセット(正方形 2×2?7×7) ----------------
    def set_grid_preset(self, n):
        self.grid_rows.set(n)
        self.grid_cols.set(n)

    # ---------------- 行列で分割(指定範囲内のみ) ----------------
    def grid_split(self):
        if not self.image:
            return

        if not (self.rect_tl and self.rect_br):
            messagebox.showinfo("行列分割", "先に「左上座標」「右下座標」ボタンで分割範囲を指定してください。")
            return

        self.compute_grid_lines()

        try:
            off_x = self.grid_offset_x.get()
            off_y = self.grid_offset_y.get()
        except Exception:
            off_x, off_y = 0, 0

        # ★ 画像範囲内にクランプ
        x1 = max(0, min(self.image.width, self.rect_tl[0] + off_x))
        y1 = max(0, min(self.image.height, self.rect_tl[1] + off_y))
        x2 = max(0, min(self.image.width, self.rect_br[0] - off_x))
        y2 = max(0, min(self.image.height, self.rect_br[1] - off_y))

        if x2 <= x1 or y2 <= y1:
            messagebox.showinfo("行列分割", "分割範囲が不正です(オフセットが大きすぎる可能性があります)。")
            return

        v = [x1] + self.grid_lines["vertical"] + [x2]
        h = [y1] + self.grid_lines["horizontal"] + [y2]

        self.clear_result_thumbs()
        self.temp_results = []

        for i in range(len(v) - 1):
            for j in range(len(h) - 1):
                crop = self.image.crop((v[i], h[j], v[i + 1], h[j + 1]))
                # ★ 1px幅/高さの結果を除外(チェック時)
                if self.exclude_small.get() and (crop.width <= self.exclude_min_px.get() or crop.height <= self.exclude_min_px.get()):
                    continue
                self.temp_results.append(crop)
                self.add_result_thumb(crop)

        self.update_split_count()

    # ---------------- 分割範囲の状態表示更新 ----------------
    def update_grid_state_label(self):
        if not hasattr(self, "range_label"):
            return

        if not self.is_grid_mode():
            if self.rect_tl and self.rect_br:
                self.range_label.config(
                    text=f"範囲: TL=({self.rect_tl[0]},{self.rect_tl[1]})  BR=({self.rect_br[0]},{self.rect_br[1]})",
                    fg="#666666")
            else:
                self.range_label.config(text="範囲: 未設定(行列モードで使用)", fg="#666666")
            return

        if self.awaiting_corner == "tl":
            self.range_label.config(text="左上の座標をクリックしてください", fg="#cc0000")
        elif self.awaiting_corner == "br":
            self.range_label.config(text="右下の座標をクリックしてください", fg="#cc0000")
        elif self.rect_tl and self.rect_br:
            # ★ オフセット適用後の分割領域(grid_split と同じクランプで計算)
            try:
                off_x = self.grid_offset_x.get()
                off_y = self.grid_offset_y.get()
                cols = max(1, self.grid_cols.get())
                rows = max(1, self.grid_rows.get())
            except Exception:
                off_x, off_y, cols, rows = 0, 0, 1, 1
            if self.image:
                x1 = max(0, min(self.image.width, self.rect_tl[0] + off_x))
                y1 = max(0, min(self.image.height, self.rect_tl[1] + off_y))
                x2 = max(0, min(self.image.width, self.rect_br[0] - off_x))
                y2 = max(0, min(self.image.height, self.rect_br[1] - off_y))
            else:
                x1, y1 = self.rect_tl[0] + off_x, self.rect_tl[1] + off_y
                x2, y2 = self.rect_br[0] - off_x, self.rect_br[1] - off_y
            cw = max(0, (x2 - x1) // cols)
            ch = max(0, (y2 - y1) // rows)
            self.range_label.config(
                text=f"範囲: TL=({self.rect_tl[0]},{self.rect_tl[1]})  BR=({self.rect_br[0]},{self.rect_br[1]})  "
                     f"1枠: {cw}×{ch}px",
                fg="#0066cc")
        elif self.rect_tl:
            self.range_label.config(text=f"左上: ({self.rect_tl[0]},{self.rect_tl[1]})", fg="#0066cc")
        else:
            self.range_label.config(text="範囲: 未設定", fg="#666666")

    # ---------------- 自動分割ライン検出 ----------------
    def auto_detect_lines(self):
        """
        X軸方向(縦線): 各列のピクセルが全て同じ色 → 縦の分割候補
        Y軸方向(横線): 各行のピクセルが全て同じ色 → 横の分割候補
        連続する候補列/行はまとめて中央1本に統合する。
        """
        if not self.image:
            return

        try:
            tol = int(self.auto_tolerance.get())
        except Exception:
            tol = 0

        img = self.image.convert("RGB")
        w, h = img.width, img.height
        pixels = img.load()

        # ---- 列スキャン(縦線候補)----
        v_candidates = []
        for x in range(w):
            col = [pixels[x, y] for y in range(h)]
            base = col[0]
            uniform = all(
                abs(int(c[0]) - int(base[0])) <= tol and
                abs(int(c[1]) - int(base[1])) <= tol and
                abs(int(c[2]) - int(base[2])) <= tol
                for c in col
            )
            if uniform:
                v_candidates.append(x)

        # ---- 行スキャン(横線候補)----
        h_candidates = []
        for y in range(h):
            row = [pixels[x, y] for x in range(w)]
            base = row[0]
            uniform = all(
                abs(int(c[0]) - int(base[0])) <= tol and
                abs(int(c[1]) - int(base[1])) <= tol and
                abs(int(c[2]) - int(base[2])) <= tol
                for c in row
            )
            if uniform:
                h_candidates.append(y)

        # ---- 連続する候補を1本(中央)に統合 ----
        def merge_runs(candidates):
            if not candidates:
                return []
            runs = []
            run = [candidates[0]]
            for c in candidates[1:]:
                if c == run[-1] + 1:
                    run.append(c)
                else:
                    runs.append(run)
                    run = [c]
            runs.append(run)
            # 各ランの中央値を採用(ただし画像端 = 0 or max は除外)
            result = []
            for run in runs:
                mid = run[len(run) // 2]
                result.append(mid)
            return result

        v_lines = merge_runs(v_candidates)
        h_lines = merge_runs(h_candidates)

        # 画像端(0, w-1 / 0, h-1)は分割線として不要なので除外
        v_lines = [x for x in v_lines if 0 < x < w - 1]
        h_lines = [y for y in h_lines if 0 < y < h - 1]

        # split_data に追加(重複を避ける)
        existing_v = set(self.split_data["vertical"])
        existing_h = set(self.split_data["horizontal"])
        for x in v_lines:
            if x not in existing_v:
                self.split_data["vertical"].append(x)
        for y in h_lines:
            if y not in existing_h:
                self.split_data["horizontal"].append(y)

        self.draw_image_and_lines()
        self.update_split_count()

        total = len(v_lines) + len(h_lines)
        messagebox.showinfo(
            "自動検出完了",
            f"縦線: {len(v_lines)} 本、横線: {len(h_lines)} 本を検出しました。\n"
            f"(許容色差: {tol})\n\n"
            "「縦に分割」「横に分割」「縦横に分割」で分割してください。"
        )

    # ---------------- バッチ処理(フォルダー内の全画像を自動分割) ----------------
    def batch_process(self):
        if not self.folder:
            return

        # 対象ファイル一覧
        files = [f for f in os.listdir(self.folder)
                 if f.lower().endswith((".png", ".jpg", ".jpeg"))]

        if not files:
            return

        out_dir = os.path.join(self.folder, "batch_output")
        os.makedirs(out_dir, exist_ok=True)

        for file in files:
            path = os.path.join(self.folder, file)
            img = Image.open(path)

            # 分割線が無い場合はスキップ
            if not self.split_data["vertical"] and not self.split_data["horizontal"]:
                continue

            v = sorted(self.split_data["vertical"])
            h = sorted(self.split_data["horizontal"])

            v = [0] + v + [img.width]
            h = [0] + h + [img.height]

            base = os.path.splitext(file)[0]

            # 分割して保存
            idx = 0
            for i in range(len(v)-1):
                for j in range(len(h)-1):
                    crop = img.crop((v[i], h[j], v[i+1], h[j+1]))
                    # ★ 1px幅/高さの結果を除外(チェック時)
                    if self.exclude_small.get() and (crop.width <= self.exclude_min_px.get() or crop.height <= self.exclude_min_px.get()):
                        continue
                    crop.save(os.path.join(out_dir, f"{base}_{idx:04d}.png"))
                    idx += 1

# ---------------- 起動 ----------------
if __name__ == "__main__":
    root = Tk()
    root.geometry("1200x800")
    app = ImageSplitterGUI(root)
    root.mainloop()

Python,画像

Posted by eightban