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

2026年9月18日

はじめ

画像分割ツールは、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
import subprocess
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=10)

        # ★ 行列の線を他モードでも使用するか(チェック時:分割で行列の線を使う)
        self.use_grid_elsewhere = BooleanVar(value=False)

        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())
        self.use_grid_elsewhere.trace_add("write", lambda *a: self.on_use_grid_elsewhere_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)
        # ★ 行列の線を他モードでも使用するか
        Checkbutton(btn_frame, text="行列線を他モードで使用", variable=self.use_grid_elsewhere,
                    bg="#f0f0f0", font=("Meiryo", 9)).grid(row=2, column=4, padx=4)
        # ★ 線情報ファイルをメモ帳で編集
        Button(btn_frame, text="線情報を編集", command=self.edit_lines_in_notepad, **btn_style).grid(row=2, column=5, 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=3, 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)
        # ★ 行列設定の保存/読込(行列で分割と同じ行)
        Button(btn_frame, text="行列設定保存", command=self.save_grid_params, **btn_style).grid(row=5, column=4, padx=4)
        Button(btn_frame, text="行列設定読込", command=self.load_grid_params, **btn_style).grid(row=5, column=5, padx=4)
        Button(btn_frame, text="行列別名保存", command=self.save_grid_params_as, **btn_style).grid(row=5, column=6, padx=4)
        Button(btn_frame, text="行列別名読込", command=self.load_grid_params_from, **btn_style).grid(row=5, column=7, 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)
        Label(zoom_adjust_frame, text="微調整", bg="#f0f0f0", font=("Meiryo", 9)).grid(row=0, column=2, padx=5)

        # 微調整ボタン(← → ↑ ↓)
        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)).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)
        Button(zoom_adjust_frame, text="↓", width=4, command=lambda: self.adjust_last_line(1, vertical=False)).grid(row=0, column=6)
        # 右:分割結果
        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)

        # ★ 透明画像は RGBA のまま拡大し、表示は白/グレーのチェッカー背景に合成
        if self._has_alpha(self.image):
            self.zoomed_image = self.image.convert("RGBA").resize((new_w, new_h), Image.LANCZOS)
        else:
            self.zoomed_image = self.image.resize((new_w, new_h), Image.LANCZOS)
        self.tk_image = ImageTk.PhotoImage(self._composite_checker(self.zoomed_image, cell=30))

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

    # ---------------- 透明背景のチェッカー表示ヘルパー ----------------
    @staticmethod
    def _has_alpha(img):
        """画像が透明(アルファ)情報を持つか判定する。"""
        if img is None:
            return False
        if img.mode in ("RGBA", "LA"):
            return True
        if img.mode == "P" and "transparency" in img.info:
            return True
        return False

    @staticmethod
    def _make_checker(w, h, cell=30):
        """白とグレーのチェッカー背景(RGB)を作成する。
        マス目は cell px 固定で、画像サイズに関係なく敷き詰める。"""
        c1 = (255, 255, 255)
        c2 = (180, 180, 180)
        tile = Image.new("RGB", (cell * 2, cell * 2), c1)
        for yy in range(2):
            for xx in range(2):
                if (xx + yy) % 2 == 1:
                    tile.paste(c2, (xx * cell, yy * cell, (xx + 1) * cell, (yy + 1) * cell))
        # ★ タイルを繰り返し貼り付けて敷き詰める(resizeで引き伸ばすとマス目が崩れるため)
        result = Image.new("RGB", (w, h))
        tw, th = tile.size
        for row in range(0, h, th):
            for col in range(0, w, tw):
                result.paste(tile, (col, row))
        return result

    @staticmethod
    def _composite_checker(img, cell=8):
        """透明部分を白/グレーのチェッカーに合成したRGB画像を返す。"""
        if not ImageSplitterGUI._has_alpha(img):
            return img.convert("RGB")
        rgba = img.convert("RGBA")
        checker = ImageSplitterGUI._make_checker(rgba.width, rgba.height, cell)
        return Image.alpha_composite(checker.convert("RGBA"), rgba).convert("RGB")

    # ---------------- 情報ラベル更新(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

        # ★ ズーム後の位置に変換して線を描画
        # ★ 行列の線を他モードでも使用(チェック時):青い四角+緑の行列線を表示
        if not self.is_grid_mode() and self.use_grid_elsewhere.get():
            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="green", width=2)
            for y in self.grid_lines["horizontal"]:
                zy = int(y * scale)
                self.canvas.create_line(0, zy, w, zy, fill="green", width=2)

        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=True, horizontal=False)

    def split_horizontal(self):
        # ★ 横に分割=横の線(行方向)に沿って分割(従来の左右入れ違いを修正)
        self.do_split(vertical=False, horizontal=True)

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

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

        # ★ 行列の線を他モードでも使用(チェック時):行列の線+自分の追加線の両方で分割
        use_grid = self.use_grid_elsewhere.get()
        g = self._grid_geometry() if use_grid else None
        if g is not None:
            _, _, _, _, gv, gh = g
            if vertical:
                v = sorted(set(gv) | set(self.split_data["vertical"]))
            else:
                v = [0, self.image.width]
            if horizontal:
                h = sorted(set(gh) | set(self.split_data["horizontal"]))
            else:
                h = [0, self.image.height]
        else:
            v = [0] + sorted(self.split_data["vertical"]) + [self.image.width] if vertical else [0, self.image.width]
            h = [0] + sorted(self.split_data["horizontal"]) + [self.image.height] if horizontal else [0, 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))
        # ★ 透明画像はチェッカー背景に合成して表示
        preview = self._composite_checker(preview, cell=30)
        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(self._composite_checker(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 not self._load_split_data_path(json_path):
            self.split_data = {"vertical": [], "horizontal": []}
            if self.image:
                self.draw_image_and_lines()
                self.update_info_label()

    def _load_split_data_path(self, path):
        """指定JSONファイルから線情報を読み込み、プレビューへ反映する。成功なら True。"""
        try:
            with open(path, "r", encoding="utf-8") as f:
                self.split_data = json.load(f)
        except Exception:
            return False
        if self.image:
            try:
                self.draw_image_and_lines()
                self.update_info_label()
            except Exception:
                pass
        return True

    # ---------------- 線情報保存 ----------------
    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 edit_lines_in_notepad(self):
        """現在の線情報をJSONに保存してメモ帳で開く。
        保存されたら自動で再読込、閉じられたら再読込して監視終了。"""
        self.save_split_data()
        program_name = os.path.splitext(os.path.basename(__file__))[0]
        json_path = os.path.abspath(program_name + ".json")

        try:
            proc = subprocess.Popen(["notepad.exe", json_path], shell=False)
        except Exception as e:
            messagebox.showerror("エラー", f"メモ帳を開けませんでした: {e}")
            return

        try:
            last_mtime = os.path.getmtime(json_path)
        except Exception:
            last_mtime = 0.0

        if self.image:
            self._watch_line_file(json_path, proc, last_mtime)

    def _watch_line_file(self, path, proc, last_mtime):
        """メモ帳の保存・終了を監視して線情報を自動再読込する。"""
        try:
            mtime = os.path.getmtime(path)
        except Exception:
            mtime = last_mtime
        try:
            closed = proc.poll() is not None
        except Exception:
            closed = False

        if closed:
            # メモ帳が閉じられた → 再読込して監視終了
            if self._load_split_data_path(path):
                messagebox.showinfo("線情報", "メモ帳を閉じたため線情報を再読み込みしました。")
            return

        if mtime != last_mtime:
            # 保存された → 再読込(監視は続行)
            self._load_split_data_path(path)
            last_mtime = mtime

        self.root.after(1000, lambda: self._watch_line_file(path, proc, last_mtime))

    # ---------------- 線情報を別名で保存 ----------------
    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

        self._load_split_data_path(path)

    # ---------------- 分割数表示更新 ----------------
    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_use_grid_elsewhere_change(self, *args):
        if not hasattr(self, "canvas"):
            return
        # チェック時:他モードでも行列のガイド線を表示できるよう計算しておく
        if self.use_grid_elsewhere.get() and not self.is_grid_mode() and self.image:
            try:
                self.compute_grid_lines()
            except Exception:
                pass
        self.draw_image_and_lines()

    # ---------------- 座標スピンボックス変更 → 分割範囲へ反映 ----------------
    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}

    # ---------------- 行列分割の範囲+境界込みライン計算(共通) ----------------
    def _grid_geometry(self, img=None, rect=None):
        """分割範囲(クランプ済み)と境界込みの行列ラインを返す。
        戻り値: (x1, y1, x2, y2, v, h)  / 範囲が不正なら None
        img  : 対象画像(None なら self.image)
        rect : (左上, 右下) タプル(None なら self.rect_tl/rect_br)
        """
        if img is None:
            img = self.image
        if img is None:
            return None
        if rect is None:
            tl, br = self.rect_tl, self.rect_br
        else:
            tl, br = rect
        if not (tl and br):
            return None

        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

        x1 = max(0, min(img.width, tl[0] + off_x))
        y1 = max(0, min(img.height, tl[1] + off_y))
        x2 = max(0, min(img.width, br[0] - off_x))
        y2 = max(0, min(img.height, br[1] - off_y))

        if x2 <= x1 or y2 <= y1:
            return None

        v = [x1] + [int(x1 + (x2 - x1) * i / cols) for i in range(1, cols)] + [x2]
        h = [y1] + [int(y1 + (y2 - y1) * i / rows) for i in range(1, rows)] + [y2]
        return x1, y1, x2, y2, v, h

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

    # ---------------- 行列パラメータの保存 / 読込 ----------------
    @staticmethod
    def _grid_params_path():
        program_name = os.path.splitext(os.path.basename(__file__))[0]
        return program_name + "_grid.json"

    def _grid_data_dict(self):
        """現在の行列設定を辞書にまとめる。"""
        data = {
            "rows": self.grid_rows.get(),
            "cols": self.grid_cols.get(),
            "offset_x": self.grid_offset_x.get(),
            "offset_y": self.grid_offset_y.get(),
            "use_grid_elsewhere": bool(self.use_grid_elsewhere.get()),
        }
        if self.rect_tl:
            data["tl_x"], data["tl_y"] = self.rect_tl
        if self.rect_br:
            data["br_x"], data["br_y"] = self.rect_br
        return data

    def save_grid_params(self, path=None):
        """行数・列数・オフセット・分割範囲などをJSONに保存する。"""
        if path is None:
            path = self._grid_params_path()
        data = self._grid_data_dict()
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)

    def load_grid_params(self, path=None):
        """保存した行列パラメータを読み込み、画面へ反映する。"""
        if path is None:
            path = self._grid_params_path()
        if not os.path.exists(path):
            messagebox.showinfo("行列設定読込", "設定ファイルが見つかりません。\n先に「行列設定保存」してください。")
            return

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

        self.grid_rows.set(int(data.get("rows", self.grid_rows.get())))
        self.grid_cols.set(int(data.get("cols", self.grid_cols.get())))
        self.grid_offset_x.set(int(data.get("offset_x", 0)))
        self.grid_offset_y.set(int(data.get("offset_y", 0)))
        self.use_grid_elsewhere.set(bool(data.get("use_grid_elsewhere", False)))

        if "tl_x" in data and "tl_y" in data:
            self.rect_tl = (int(data["tl_x"]), int(data["tl_y"]))
        if "br_x" in data and "br_y" in data:
            self.rect_br = (int(data["br_x"]), int(data["br_y"]))
        self.sync_vars_from_rect()

        if self.image:
            try:
                self.compute_grid_lines()
                self.draw_image_and_lines()
                self.update_grid_state_label()
            except Exception:
                pass

    # ---------------- 行列設定を別名で保存 / 読込 ----------------
    def save_grid_params_as(self):
        path = filedialog.asksaveasfilename(
            title="行列設定を別名で保存",
            defaultextension=".json",
            filetypes=[("JSONファイル", "*.json")]
        )
        if not path:
            return
        self.save_grid_params(path)

    def load_grid_params_from(self):
        path = filedialog.askopenfilename(
            title="行列設定を別名で読込",
            filetypes=[("JSONファイル", "*.json")]
        )
        if not path:
            return
        self.load_grid_params(path)

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

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

        x1, y1, x2, y2, v, h = g

        # ガイド線表示用に更新
        self.compute_grid_lines()

        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)

        mode = self.mode_var.get()

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

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

            # ★ 行列モード:各画像を画像全体範囲で行×列に分割
            if mode == "grid":
                g = self._grid_geometry(img, rect=((0, 0), (img.width - 1, img.height - 1)))
                if g is None:
                    continue
                x1, y1, x2, y2, v, h = g
                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]))
                        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
                continue

            # 分割線が無い場合はスキップ
            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]

            # 分割して保存
            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