图片任意切割工具(Python 3.8 实现)

图片任意切割工具(Python 3.8 实现)

在日常工作或个人创作中,我们经常会遇到需要把一张图片按比例切割的情况,比如:

  • 将长截图拆分成若干小段,方便排版展示
  • 把一张大图切割成网格,用于拼接、打印或艺术化处理
  • 测试图像处理算法时,快速得到分块数据

为此,我开发了一个 图片任意切割工具(基于 Python 3.8 + Pillow + Tkinter),支持命令行和图形界面两种模式,简单易用。


功能特点

  • 多种切割模式

    • 横向切割(按行)
    • 纵向切割(按列)
    • 网格切割(行 × 列)
  • 灵活的保存顺序

    • 从上到下(tb)、从下到上(bt)
    • 从左到右(lr)、从右到左(rl)
    • 网格顺序:lr-tb / rl-tb / lr-bt / rl-bt / snake(蛇形) / reverse
    • 中心向外:按照与图片中心的距离由近到远保存
  • 双模式支持

    • 命令行模式:适合批量处理和自动化脚本
    • GUI 图形界面模式:如果不输入参数,自动弹出 Windows 界面,选择图片、份数、方向等信息即可
  • 自动输出目录

    • 默认在原图目录下生成 <图片名>_slices 文件夹,自动保存切割结果

使用方法

命令行模式

# 显示帮助(支持 ? 或 --help)
python image_slicer.py --help
python image_slicer.py ?

# 例1:水平切成 4 份(自上而下)
python image_slicer.py input.jpg --mode horizontal --parts 4 --direction tb

# 例2:垂直切成 3 份(右→左)
python image_slicer.py input.jpg --mode vertical --parts 3 --direction rl

# 例3:网格切割 3x5,蛇形顺序保存
python image_slicer.py input.jpg --mode grid --grid 3x5 --direction snake

# 例4:网格切割 4x4,按“中心→外围”距离排序保存
python image_slicer.py input.jpg --mode grid --grid 4x4 --center-out

GUI 模式

如果你直接运行而不带参数:

python image_slicer.py

会自动弹出一个 Windows 图形界面,在界面中可以:

  • 选择切割模式(水平 / 垂直 / 网格)
  • 输入份数或行列数
  • 设置切割顺序
  • 勾选是否“中心向外”
  • 点击“选择图片并执行切割”即可

无需记命令行参数,更适合普通用户。


代码:


# -*- coding: utf-8 -*-
"""
Image Slicer Tool (Python 3.8)
Author: ChatGPT
"""
import os
import math
import argparse
from typing import List, Tuple, Optional

from PIL import Image

# ——— For file dialog when no input path ———
def pick_image_by_dialog() -> Optional[str]:
    try:
        import tkinter as tk
        from tkinter import filedialog
        root = tk.Tk()
        root.withdraw()
        path = filedialog.askopenfilename(
            title="选择要切割的图片",
            filetypes=[
                ("Image files", "*.png;*.jpg;*.jpeg;*.bmp;*.tiff;*.webp"),
                ("All files", "*.*"),
            ],
        )
        root.update()
        root.destroy()
        return path or None
    except Exception as e:
        print("无法打开文件选择器:", e)
        return None


def parse_grid(s: str) -> Tuple[int, int]:
    """
    Parse 'rowsxcols' (e.g. 3x5 or 3×5) into (rows, cols)
    """
    s = s.lower().replace("×", "x")
    if "x" not in s:
        raise argparse.ArgumentTypeError("网格格式应为 rowsxcols,例如 3x5 或 3×5")
    r, c = s.split("x", 1)
    rows = int(r.strip())
    cols = int(c.strip())
    if rows <= 0 or cols <= 0:
        raise argparse.ArgumentTypeError("rows/cols 必须为正整数")
    return rows, cols


def make_outdir(img_path: str, outdir: Optional[str]) -> str:
    if outdir:
        os.makedirs(outdir, exist_ok=True)
        return outdir
    base = os.path.splitext(os.path.basename(img_path))[0]
    parent = os.path.dirname(img_path) or os.getcwd()
    out = os.path.join(parent, f"{base}_slices")
    os.makedirs(out, exist_ok=True)
    return out


def slice_boxes_horizontal(w: int, h: int, parts: int) -> List[Tuple[int, int, int, int]]:
    """
    Horizontal cutting → split along horizontal lines (top->bottom bands).
    Each box: (left, top, right, bottom)
    """
    band_heights = []
    for i in range(parts):
        bh = (h // parts) + (1 if i < (h % parts) else 0)
        band_heights.append(bh)
    boxes = []
    y = 0
    for bh in band_heights:
        boxes.append((0, y, w, y + bh))
        y += bh
    return boxes


def slice_boxes_vertical(w: int, h: int, parts: int) -> List[Tuple[int, int, int, int]]:
    """
    Vertical cutting → split along vertical lines (left->right slices).
    """
    col_widths = []
    for i in range(parts):
        cw = (w // parts) + (1 if i < (w % parts) else 0)
        col_widths.append(cw)
    boxes = []
    x = 0
    for cw in col_widths:
        boxes.append((x, 0, x + cw, h))
        x += cw
    return boxes


def slice_boxes_grid(w: int, h: int, rows: int, cols: int) -> List[Tuple[int, int, int, int]]:
    # distribute remainder pixels to front rows/cols
    row_heights = [(h // rows) + (1 if i < (h % rows) else 0) for i in range(rows)]
    col_widths = [(w // cols) + (1 if j < (w % cols) else 0) for j in range(cols)]
    boxes = []
    y = 0
    for i, rh in enumerate(row_heights):
        x = 0
        for j, cw in enumerate(col_widths):
            boxes.append((x, y, x + cw, y + rh))
            x += cw
        y += rh
    return boxes  # row-major order (top→bottom, left→right)


def sort_indices_for_direction(
    boxes: List[Tuple[int, int, int, int]],
    w: int,
    h: int,
    mode: str,
    direction: str,
    center_out: bool,
    rows_cols: Tuple[int, int] = (0, 0),
) -> List[int]:
    idxs = list(range(len(boxes)))

    if center_out:
        cx_img, cy_img = w / 2.0, h / 2.0
        centers = []
        for i, (l, t, r, b) in enumerate(boxes):
            cx = (l + r) / 2.0
            cy = (t + b) / 2.0
            d = (cx - cx_img) ** 2 + (cy - cy_img) ** 2
            centers.append((d, i))
        centers.sort(key=lambda x: x[0])
        return [i for _, i in centers]

    if mode == "horizontal":
        if direction in ("tb", "t2b", "top-bottom"):
            return idxs
        elif direction in ("bt", "b2t", "bottom-top", "reverse"):
            return list(reversed(idxs))
    elif mode == "vertical":
        if direction in ("lr", "l2r", "left-right"):
            return idxs
        elif direction in ("rl", "r2l", "right-left", "reverse"):
            return list(reversed(idxs))
    elif mode == "grid":
        rows, cols = rows_cols
        def rc_positions():
            pos = []
            k = 0
            for r in range(rows):
                for c in range(cols):
                    pos.append((r, c, k))
                    k += 1
            return pos

        pos = rc_positions()
        if direction == "lr-tb":
            order = sorted(pos, key=lambda x: (x[0], x[1]))
        elif direction == "rl-tb":
            order = sorted(pos, key=lambda x: (x[0], -x[1]))
        elif direction == "lr-bt":
            order = sorted(pos, key=lambda x: (-x[0], x[1]))
        elif direction == "rl-bt":
            order = sorted(pos, key=lambda x: (-x[0], -x[1]))
        elif direction == "snake":
            order = []
            for r in range(rows):
                row_items = [p for p in pos if p[0] == r]
                row_items = sorted(row_items, key=lambda x: x[1])
                if r % 2 == 1:
                    row_items = list(reversed(row_items))
                order.extend(row_items)
        elif direction in ("reverse", "bt-lr", "rb-lt"):
            order = list(reversed(pos))
        else:
            order = sorted(pos, key=lambda x: (x[0], x[1]))
        return [k for _, __, k in order]

    return idxs


def save_tiles(
    img: Image.Image,
    boxes: List[Tuple[int, int, int, int]],
    outdir: str,
    prefix: str,
    order: List[int],
) -> None:
    digits = max(2, int(math.ceil(math.log10(len(boxes) + 1))))
    for out_idx, i in enumerate(order, 1):
        box = boxes[i]
        tile = img.crop(box)
        filename = f"{prefix}_{str(out_idx).zfill(digits)}.png"
        tile.save(os.path.join(outdir, filename))


def run(
    image_path: Optional[str],
    outdir: Optional[str],
    mode: str,
    parts: Optional[int],
    grid: Optional[Tuple[int, int]],
    direction: str,
    center_out: bool,
) -> str:
    if not image_path:
        image_path = pick_image_by_dialog()
        if not image_path:
            raise SystemExit("未选择图片,已退出。")

    img = Image.open(image_path)
    w, h = img.size
    out_dir = make_outdir(image_path, outdir)
    base = os.path.splitext(os.path.basename(image_path))[0]
    prefix = f"{base}_{mode}"

    if mode == "horizontal":
        if not parts or parts < 1:
            raise SystemExit("--parts 必须是正整数(水平切割需要指定份数)")
        boxes = slice_boxes_horizontal(w, h, parts)
        order = sort_indices_for_direction(boxes, w, h, mode, direction, center_out)
    elif mode == "vertical":
        if not parts or parts < 1:
            raise SystemExit("--parts 必须是正整数(垂直切割需要指定份数)")
        boxes = slice_boxes_vertical(w, h, parts)
        order = sort_indices_for_direction(boxes, w, h, mode, direction, center_out)
    elif mode == "grid":
        if grid is None:
            raise SystemExit("--grid 需要指定,格式 rowsxcols 例如 3x5")
        rows, cols = grid
        boxes = slice_boxes_grid(w, h, rows, cols)
        order = sort_indices_for_direction(boxes, w, h, mode, direction, center_out, (rows, cols))
    else:
        raise SystemExit("未知的 mode")

    save_tiles(img, boxes, out_dir, prefix, order)
    return out_dir


def build_argparser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="图片任意切割工具:支持水平/垂直/网格切割、方向控制、中心向外排序。Python 3.8",
        epilog=(
            "示例:\n"
            "  1) 水平切成 4 份,自上而下保存:\n"
            "     python image_slicer.py input.jpg --mode horizontal --parts 4 --direction tb\n"
            "  2) 垂直切成 3 份,自右向左保存:\n"
            "     python image_slicer.py input.jpg --mode vertical --parts 3 --direction rl\n"
            "  3) 网格切割 3x5,按蛇形顺序保存:\n"
            "     python image_slicer.py input.jpg --mode grid --grid 3x5 --direction snake\n"
            "  4) 网格切割 4×4,按“中心 → 外围”距离排序保存:\n"
            "     python image_slicer.py input.jpg --mode grid --grid 4x4 --center-out\n"
            "  5) 不带任何参数运行,将弹出文件选择器:\n"
            "     python image_slicer.py"
        ),
        formatter_class=argparse.RawTextHelpFormatter,
    )
    p.add_argument("image", nargs="?", help="输入图片路径;省略则弹出文件选择器")
    p.add_argument("--outdir", help="输出文件夹;默认为与图片同目录的 <name>_slices")
    p.add_argument(
        "--mode",
        choices=["horizontal", "vertical", "grid"],
        default="grid",
        help="切割模式:horizontal(横向/按行)、vertical(纵向/按列)、grid(网格);默认 grid",
    )
    p.add_argument("--parts", type=int, help="水平/垂直模式下切成几份(正整数)")
    p.add_argument("--grid", type=parse_grid, help="网格模式下的行×列,例如 3x5 或 3×5")
    p.add_argument(
        "--direction",
        default="lr-tb",
        help=(
            "保存顺序(非中心模式):\n"
            "  horizontal: tb(上→下) | bt(下→上)\n"
            "  vertical  : lr(左→右) | rl(右→左)\n"
            "  grid      : lr-tb | rl-tb | lr-bt | rl-bt | snake | reverse\n"
            "默认 lr-tb"
        ),
    )
    p.add_argument(
        "--center-out",
        action="store_true",
        help="按与图像中心的距离由近到远排序保存(适用于任意模式)",
    )
    return p


def main():
    parser = build_argparser()
    # 如果命令里带 "?",直接显示帮助
    import sys
    if "?" in sys.argv:
        parser.print_help()
        return

    args = parser.parse_args()

    # 如果没有任何输入图片参数,进入 GUI 模式
    if not args.image and len(sys.argv) == 1:
        try:
            import tkinter as tk
            from tkinter import ttk, filedialog, messagebox
        except ImportError:
            print("缺少 tkinter,无法打开 GUI,请安装 tkinter 或传入参数")
            return

        root = tk.Tk()
        root.title("图片切割工具 - GUI模式")

        # ========== 界面元素 ==========
        tk.Label(root, text="切割模式:").grid(row=0, column=0, sticky="w")
        mode_var = tk.StringVar(value="grid")
        ttk.Combobox(root, textvariable=mode_var, values=["horizontal", "vertical", "grid"]).grid(row=0, column=1)

        tk.Label(root, text="份数 (水平/垂直):").grid(row=1, column=0, sticky="w")
        parts_var = tk.IntVar(value=2)
        tk.Entry(root, textvariable=parts_var).grid(row=1, column=1)

        tk.Label(root, text="网格 (行x列):").grid(row=2, column=0, sticky="w")
        grid_var = tk.StringVar(value="2x2")
        tk.Entry(root, textvariable=grid_var).grid(row=2, column=1)

        tk.Label(root, text="方向:").grid(row=3, column=0, sticky="w")
        dir_var = tk.StringVar(value="lr-tb")
        ttk.Combobox(root, textvariable=dir_var,
                     values=["tb", "bt", "lr", "rl", "lr-tb", "rl-tb", "lr-bt", "rl-bt", "snake", "reverse"]).grid(
            row=3, column=1)

        center_out_var = tk.BooleanVar(value=False)
        tk.Checkbutton(root, text="中心向外排序", variable=center_out_var).grid(row=4, columnspan=2)

        def run_gui():
            img_path = filedialog.askopenfilename(title="选择要切割的图片")
            if not img_path:
                messagebox.showerror("错误", "未选择图片")
                return
            grid_tuple = None
            if mode_var.get() == "grid":
                try:
                    grid_tuple = parse_grid(grid_var.get())
                except:
                    messagebox.showerror("错误", "网格格式错误,应为 3x5")
                    return

            outdir = run(
                image_path=img_path,
                outdir=None,
                mode=mode_var.get(),
                parts=parts_var.get() if mode_var.get() != "grid" else None,
                grid=grid_tuple,
                direction=dir_var.get(),
                center_out=center_out_var.get()
            )
            messagebox.showinfo("完成", f"切割完成,输出目录:{outdir}")

        tk.Button(root, text="选择图片并执行切割", command=run_gui).grid(row=5, columnspan=2, pady=10)

        root.mainloop()
        return

    # 命令行模式
    out_dir = run(
        image_path=args.image,
        outdir=args.outdir,
        mode=args.mode,
        parts=args.parts,
        grid=args.grid,
        direction=args.direction.lower() if args.direction else "lr-tb",
        center_out=args.center_out,
    )
    print("切割完成,输出目录:", out_dir)


if __name__ == "__main__":
    main()


posted @ 2025-09-30 05:41  _迷途  阅读(27)  评论(0)    收藏  举报