在处理工业视觉检测、遥感图像或高倍显微镜成像时,我们经常面临将数十个图像切片拼接成一个完整大图的需求。市面上普通的拼接软件往往无法处理超大分辨率(几万像素级)图像,且无法手动灵活控制每个切片的位置。

做的是一款基于 Python 和 PySide6 开发的专业级图像拼接工具,专为“精确控制”和“无损导出”而生。

1. 自由定义画布网格

软件不再局限于固定的行列比例。用户可以根据实际拍摄情况,手动输入所需的行数列数。点击“重新生成画布”后,系统会自动构建出带坐标编号(如 R1-C1)的槽位,如同摆放拼图底板一样直观。

2. “点击式”交互排布

针对文件名排序混乱的问题,软件采用了“待选区 + 画布区”的分离设计:

  • 待选区:批量导入所有切片,点击图片即刻“激活”(红色边框高亮提示)。

  • 填充区:点击右侧任意槽位,选中的图片即刻飞入该位置。如果你发现放错了,只需选中新图再次点击该槽位即可覆盖,极大降低了纠错成本。

3. 支持超大分辨率无损导出

利用 Pillow 的高级处理能力,软件专门针对超限像素进行了优化:

  • 无损质量:默认导出为 TIFF (LZW无损压缩) 格式,绕过了标准 JPEG 65535 像素的尺寸上限。

  • 内存保护:自动解除 MAX_IMAGE_PIXELS 限制,支持处理单张数万像素的巨型图像拼接。

4. 实时导出进度反馈

大图拼接涉及大量像素运算和硬盘 IO,耗时较长。软件内置了实时进度条弹窗,用户可以清晰看到每一张切片的拼接进度,避免了程序“假死”带来的不确定感。

(注:大图超过2g卡死别动,处于导出状态,2-3分钟内导出是正常现象,越大导出时间越长)

import sys
import os
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                               QHBoxLayout, QPushButton, QFileDialog, QScrollArea,
                               QLabel, QLineEdit, QGridLayout, QFrame, QProgressDialog)
from PySide6.QtGui import QPixmap, QIntValidator
from PySide6.QtCore import Qt
from PIL import Image

Image.MAX_IMAGE_PIXELS = None


class DraggableImage(QFrame):
    """左侧待选区的图片小部件 - 增加选中状态"""

    def __init__(self, path, click_callback):
        super().__init__()
        self.path = path
        self.click_callback = click_callback
        self.setFrameShape(QFrame.Shape.StyledPanel)
        self.set_unselected_style()

        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)

        pix = QPixmap(path).scaled(110, 110, Qt.AspectRatioMode.KeepAspectRatio)
        img_label = QLabel()
        img_label.setPixmap(pix)
        img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        name_label = QLabel(os.path.basename(path))
        name_label.setStyleSheet("font-size: 9px; border: none; color: #bbb;")
        name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        layout.addWidget(img_label)
        layout.addWidget(name_label)

    def set_selected_style(self):
        self.setStyleSheet("DraggableImage { background-color: #555; border: 3px solid #ff4444; }")

    def set_unselected_style(self):
        self.setStyleSheet("DraggableImage { background-color: #3d3d3d; border: 1px solid #555; }")

    def mousePressEvent(self, event):
        self.click_callback(self)


class CanvasSlot(QFrame):
    """右侧画布槽位"""

    def __init__(self, row, col, click_callback):
        super().__init__()
        self.row = row
        self.col = col
        self.path = None
        self.setFixedSize(160, 160)
        self.setStyleSheet("QFrame { background-color: #222; border: 2px dashed #555; }")

        self.layout = QVBoxLayout(self)
        self.label = QLabel(f"R{row + 1}\nC{col + 1}")
        self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.label.setStyleSheet("color: #666; font-weight: bold; border: none;")
        self.layout.addWidget(self.label)
        self.mousePressEvent = lambda e: click_callback(self)

    def set_image(self, path):
        self.path = path
        while self.layout.count():
            self.layout.takeAt(0).widget().deleteLater()
        pix = QPixmap(path).scaled(140, 140, Qt.AspectRatioMode.KeepAspectRatio)
        img_lbl = QLabel()
        img_lbl.setPixmap(pix)
        img_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        img_lbl.setStyleSheet("border: none;")
        self.layout.addWidget(img_lbl)
        self.setStyleSheet("QFrame { background-color: #2b2b2b; border: 2px solid #00ff00; }")


class ImageStitcherV4(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("专业图像拼接平台 V4 - 交互增强版")
        self.resize(1350, 900)
        self.current_selected_widget = None  # 当前选中的图片组件对象
        self.slot_data = {}
        self.pool_widgets = []
        self.init_ui()

    def init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_h_layout = QHBoxLayout(central)

        # --- 左侧:图片池 ---
        left_panel = QWidget()
        left_panel.setFixedWidth(240)
        left_layout = QVBoxLayout(left_panel)
        btn_import = QPushButton("📁 批量导入待选图")
        btn_import.clicked.connect(self.import_to_pool)
        left_layout.addWidget(btn_import)

        self.pool_scroll = QScrollArea()
        self.pool_container = QWidget()
        self.pool_layout = QVBoxLayout(self.pool_container)
        self.pool_scroll.setWidget(self.pool_container)
        self.pool_scroll.setWidgetResizable(True)
        left_layout.addWidget(QLabel("待选区 (红色代表当前选中):"))
        left_layout.addWidget(self.pool_scroll)
        main_h_layout.addWidget(left_panel)

        # --- 右侧:画布 ---
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)

        settings_bar = QHBoxLayout()
        settings_bar.addWidget(QLabel("列数:"))
        self.edit_cols = QLineEdit("2")
        self.edit_cols.setValidator(QIntValidator(1, 100))
        settings_bar.addWidget(self.edit_cols)
        settings_bar.addWidget(QLabel("行数:"))
        self.edit_rows = QLineEdit("10")
        self.edit_rows.setValidator(QIntValidator(1, 100))
        settings_bar.addWidget(self.edit_rows)

        btn_gen = QPushButton("⚙️ 重新生成画布")
        btn_gen.clicked.connect(self.generate_empty_grid)
        settings_bar.addWidget(btn_gen)
        settings_bar.addStretch()

        btn_export = QPushButton("🚀 导出无损拼接大图")
        btn_export.setMinimumHeight(35)
        btn_export.setStyleSheet("background-color: #2d5a27; color: white; font-weight: bold;")
        btn_export.clicked.connect(self.export_stitched_image)
        settings_bar.addWidget(btn_export)
        right_layout.addLayout(settings_bar)

        self.canvas_scroll = QScrollArea()
        self.canvas_widget = QWidget()
        self.canvas_grid = QGridLayout(self.canvas_widget)
        self.canvas_scroll.setWidget(self.canvas_widget)
        self.canvas_scroll.setWidgetResizable(True)
        right_layout.addWidget(self.canvas_scroll)
        main_h_layout.addWidget(right_panel)

    def import_to_pool(self):
        files, _ = QFileDialog.getOpenFileNames(self, "导入待选图", "", "Images (*.jpg *.png *.bmp)")
        for f in files:
            tile = DraggableImage(f, self.on_pool_item_clicked)
            self.pool_layout.addWidget(tile)
            self.pool_widgets.append(tile)

    def on_pool_item_clicked(self, clicked_widget):
        # 取消之前的选中样式
        if self.current_selected_widget:
            self.current_selected_widget.set_unselected_style()

        # 设置当前的选中样式
        self.current_selected_widget = clicked_widget
        self.current_selected_widget.set_selected_style()
        self.statusBar().showMessage(f"已选中: {os.path.basename(clicked_widget.path)}")

    def generate_empty_grid(self):
        while self.canvas_grid.count():
            self.canvas_grid.takeAt(0).widget().deleteLater()
        self.slot_data = {}
        cols = int(self.edit_cols.text() or 1)
        rows = int(self.edit_rows.text() or 1)
        for r in range(rows):
            for c in range(cols):
                slot = CanvasSlot(r, c, self.on_slot_clicked)
                self.canvas_grid.addWidget(slot, r, c)

    def on_slot_clicked(self, slot_widget):
        if self.current_selected_widget:
            slot_widget.set_image(self.current_selected_widget.path)
            self.slot_data[(slot_widget.row, slot_widget.col)] = self.current_selected_widget.path
        else:
            self.statusBar().showMessage("错误:请先点击左侧待选区的图片(红框),再点此处填充")

    def export_stitched_image(self):
        if not self.slot_data:
            self.statusBar().showMessage("导出失败:画布上还没有放任何图片")
            return

        save_path, _ = QFileDialog.getSaveFileName(self, "导出大图", "stitched_result.tif", "TIFF (*.tif)")
        if not save_path: return

        cols = int(self.edit_cols.text())
        rows = int(self.edit_rows.text())

        # 初始化进度条弹窗
        total_steps = len(self.slot_data)
        progress = QProgressDialog("正在拼接图像并保存...", "取消", 0, total_steps, self)
        progress.setWindowTitle("任务进行中")
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.show()

        try:
            # 基准大小
            first_path = list(self.slot_data.values())[0]
            with Image.open(first_path) as ref:
                w, h = ref.size

            final_canvas = Image.new('RGB', (w * cols, h * rows), (0, 0, 0))

            for i, ((r, c), path) in enumerate(self.slot_data.items()):
                if progress.wasCanceled():
                    break

                with Image.open(path) as img:
                    final_canvas.paste(img, (c * w, r * h))

                # 更新进度条
                progress.setValue(i + 1)
                QApplication.processEvents()  # 刷新界面防止假死

            if not progress.wasCanceled():
                progress.setLabelText("正在写入硬盘 (这可能需要一些时间)...")
                QApplication.processEvents()
                final_canvas.save(save_path, compression="tiff_lzw")
                self.statusBar().showMessage(f"✅ 导出成功: {save_path}")

        except Exception as e:
            self.statusBar().showMessage(f"❌ 导出出错: {str(e)}")
        finally:
            progress.close()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = ImageStitcherV4()
    window.show()
    sys.exit(app.exec())

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐