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

画像分割ツールは、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()










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