import sys from PyQt5 import QtCore from PyQt5.QtG...

Créé le : 11 décembre 2025

Répondu en utilisant GPT-5.1 par Chat01

Question

import sys
from PyQt5 import QtCore
from PyQt5.QtGui import QIcon, QPixmap, QFont
from PyQt5.QtWidgets import (
QWidget, QMenuBar, QMenu, QAction, QToolBar, QHBoxLayout, QVBoxLayout,
QLabel, QComboBox, QSlider, QPushButton, QFrame, QSplitter, QSizePolicy
)
from PyQt5.QtCore import Qt
import os

class ImageOperationPage(QWidget): # 改为继承QWidget(适配堆叠窗口)
def init(self, main_window):
super().init()
self.main_window = main_window # 保存主窗口引用
self.current_image_path = "" # 存储当前图片路径
self.canvas_label = QLabel() # 用于展示图片的Label(替代原canvas的QFrame)
self.initUI()

text
def initUI(self): # ========== 整体布局(QWidget的主布局) ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setSpacing(0) # ========== 菜单栏(适配QWidget:添加到布局顶部) ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setStyleSheet(""" QToolBar { spacing: 15px; background-color:#333333; } QToolButton{ color: #ffffff !important; font-size: 24px !important; padding: 5px !important; } QLabel { color: #ffffff; } """) # 工具栏按钮(注意:替换图标路径为你的实际路径,或注释图标避免报错) try: # 主页按钮(绑定返回首页逻辑) home_action = QAction( QIcon(QPixmap("./sotf_image/home.png")), "主页", self ) home_action.triggered.connect(self.main_window.back_to_home) # 点击返回首页 toolbar.addAction(home_action) # 保存按钮 save_action = QAction( QIcon(QPixmap("./sotf_image/save.png")), "保存", self ) toolbar.addAction(save_action) # 新建按钮 new_action = QAction( QIcon(QPixmap("./sotf_image/another.png")), "另存为", self ) toolbar.addAction(new_action) # 撤销按钮 undo_action = QAction( QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self ) toolbar.addAction(undo_action) # 重做按钮 redo_action = QAction( QIcon(QPixmap("./sotf_image/reset.png")), "重做", self ) toolbar.addAction(redo_action) # 放大按钮(绑定放大图片逻辑) zoom_in_action = QAction( QIcon(QPixmap("./sotf_image/big.png")), "放大", self ) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) # 缩小按钮(绑定缩小图片逻辑) zoom_out_action = QAction( QIcon(QPixmap("./sotf_image/small.png")), "缩小", self ) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) # 抓手按钮 hand_action = QAction( QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self ) toolbar.addAction(hand_action) except Exception as e: # 图标加载失败时,用文字按钮替代 self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 分隔线 # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) toolbar.addWidget(filter_combo) toolbar.addSeparator() # 梯度下拉框 toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) toolbar.addWidget(grad_combo) toolbar.addSeparator() # 自动调节开关(暂用标签替代) toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区与右侧面板(用分割器拆分) ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区(图片编辑区) ---- main_workspace = QWidget() main_layout = QVBoxLayout(main_workspace) # 黑色画布区域(改为QLabel用于展示图片) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(600, 500) # 默认提示文字 self.canvas_label.setText("请从首页选择图片") main_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(10, 10, 10, 10) right_layout.setSpacing(15) # 图像预览区1 self.preview1 = QLabel() self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setFixedHeight(250) self.preview1.setMinimumWidth(250) self.preview1.setText("原图预览") right_layout.addWidget(self.preview1) # 图像预览区2 self.preview2 = QLabel() self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setFixedHeight(250) self.preview2.setMinimumWidth(250) self.preview2.setText("处理后预览") right_layout.addWidget(self.preview2) # 滑块控制区 slider_group = QWidget() slider_layout = QVBoxLayout(slider_group) slider_layout.setSpacing(10) # 核大小滑块 kernel_slider = QWidget() kernel_layout = QHBoxLayout(kernel_slider) kernel_layout.addWidget(QLabel("核大小")) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) kernel_layout.addWidget(self.kernel_slider) slider_layout.addWidget(kernel_slider) # 高阈值滑块 high_thresh_slider = QWidget() high_layout = QHBoxLayout(high_thresh_slider) high_layout.addWidget(QLabel("高阈值")) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) high_layout.addWidget(self.high_thresh_slider) slider_layout.addWidget(high_thresh_slider) # 低阈值滑块 low_thresh_slider = QWidget() low_layout = QHBoxLayout(low_thresh_slider) low_layout.addWidget(QLabel("低阈值")) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) low_layout.addWidget(self.low_thresh_slider) slider_layout.addWidget(low_thresh_slider) # 额外滑块(示例) extra_slider = QWidget() extra_layout = QHBoxLayout(extra_slider) extra_layout.addWidget(QLabel("额外参数")) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) extra_layout.addWidget(self.extra_slider) slider_layout.addWidget(extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) splitter.addWidget(right_panel) splitter.setSizes([600, 300]) # 分割器初始比例 self.main_layout.addWidget(splitter) # 图片缩放比例(初始1.0) self.zoom_scale = 1.0 self.original_pixmap = None # 保存原始图片pixmap def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" # 校验文件 if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: # 加载原始图片 self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 # 重置缩放比例 # 展示到主画布(适配画布大小) canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) # 展示到预览区1(原图缩小预览) preview1_pixmap = self.original_pixmap.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(preview1_pixmap) # 预览区2默认显示原图(后续可改为处理后图片) self.preview2.setPixmap(preview1_pixmap) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") def zoom_in_image(self): """放大图片(每次+0.1,最大3.0)""" if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可放大") return self.zoom_scale = min(self.zoom_scale + 0.1, 3.0) self._update_zoom_image() def zoom_out_image(self): """缩小图片(每次-0.1,最小0.1)""" if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可缩小") return self.zoom_scale = max(self.zoom_scale - 0.1, 0.1) self._update_zoom_image() def _update_zoom_image(self): """更新缩放后的图片展示""" scaled_pixmap = self.original_pixmap.scaled( self.original_pixmap.size() * self.zoom_scale, Qt.KeepAspectRatio, Qt.SmoothTransformation ) # 适配画布居中显示 self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter) def reset_parameters(self): """重置所有滑块参数""" self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(200) self.low_thresh_slider.setValue(100) self.extra_slider.setValue(50) # 重置图片缩放 self.zoom_scale = 1.0 if self.original_pixmap: self._update_zoom_image() # 窗口大小变化时,图片自动适配 def resizeEvent(self, event): if self.original_pixmap: canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) super().resizeEvent(event)这是软件页面的设计图,请对页面不规整的地方进行优化

Réflexion

Looking into UI layout improvements for Chinese user

The user wants optimizations for irregularities in a PyQt5 UI, likely related to layout. I’ll focus on typical layout issues like margins, spacings, alignment, and sizing. I'll check things like the toolbar styling, group controls, slider alignment, and canvas scaling, while avoiding changing functionality. I'll focus on aesthetics and consistency.

Describing layout issues and providing optimizations

The current UI likely has issues with large empty margins, misaligned components, cramped labels, varying font sizes, and the right panel being stuck at the top. I'll provide a set of improvements, changing margins, adjusting panel behavior, fixing label alignment, and unifying fonts. I'll also adjust zoom logic slightly, keeping everything clear without being overly detailed. The code comments will be in Chinese too.

Thought for 17s

Réponse

我直接先说结论:你这个页面整体结构是对的(菜单栏 + 工具栏 + 左画布 + 右侧参数区),主要“不规整”的点来自于边距/间距不统一、右侧控件挤在一起、各个滑块行高不一致、预览区大小死固定等问题。

下面我分两步来帮你优化:


一、具体有哪些“不规整”的问题

1. 整体边距和间距不统一

  • self.main_layout.setContentsMargins(0, 0, 0, 0) 导致内容顶在窗口边缘,看起来比较“生硬”。
  • 右侧 right_layout.setContentsMargins(10, 10, 10, 10),左侧主工作区基本无边距,两边看起来不平衡。

👉 建议:

  • 给主布局一个小的统一边距,比如 (6, 6, 6, 6)
  • 左右区域都用相似的边距,让两边看起来“对称”。

2. 右侧面板控件堆叠、对齐不统一

现在右侧是:

  • 上:两个固定高度 QLabel 预览框(250px)
  • 中:一堆用 QHBoxLayout 包起来的滑块行
  • 下:按钮行

问题:

  1. 每个滑块自己包一层 QWidget + QHBoxLayout,行之间的高度不统一,看起来像“拼接”出来的。
  2. 滑块标签(“核大小”“高阈值”…)长度不一,左边对齐不齐。
  3. 右侧面板整体没有固定宽度,窗口拉大拉小的时候比例不好看。

👉 建议:

  • 使用 QFormLayout 来排列滑块:左列是标签,右列是滑块,天然左对齐整齐。
  • 给右侧面板一个固定宽度(比如 320–360),用 splitter.setStretchFactor 控制左大右小。
  • 在右侧 QVBoxLayout 的底部加一个 addStretch(),防止按钮死死贴底/贴上。

3. 预览区固定高度,随窗口变化不自然

  • self.preview1.setFixedHeight(250)self.preview2.setFixedHeight(250):当窗口拉高时,上下是大白条,预览区不跟着长,看起来空空的。
  • 而左边主画布是 QSizePolicy.Expanding,左右两边视觉权重不协调。

👉 建议:

  • 不要用 setFixedHeight,改用 setMinimumHeight + QSizePolicy.Expanding,让两个预览区在右侧垂直方向平分空间
  • 或者加一个 QSplitter(Qt.Vertical) 把 preview1 / preview2 拆开,让用户可调整两者高度。

4. 画布缩放逻辑和显示尺寸有点别扭

  • _update_zoom_image 使用:
    python
    self.original_pixmap.scaled( self.original_pixmap.size() * self.zoom_scale, Qt.KeepAspectRatio, Qt.SmoothTransformation )
    这样缩放是基于原图尺寸而不是画布尺寸,放大时可能比画布大很多,导致看上去“挤出边界”但 label 又居中,看起来怪异。
  • load_image 时又用 self.canvas_label.size() 去适配画布,两种缩放策略不统一。

👉 建议:

  • 缩放时统一用画布可见区域大小 × zoom_scale 来算,保证不会太离谱。
  • 或者简单一点:以 canvas_label.size() 作为基准,再乘以比例。

二、给你一份“更规整”的布局代码示例

我在你的基础上做了以下主要改动:

  1. 统一外边距和间距。
  2. 给右侧面板固定宽度,使用 setStretchFactor 控制左右比例。
  3. 右侧预览区使用 QVBoxLayout + QSizePolicy.Expanding,不再固定高度。
  4. 滑块使用 QFormLayout,标签对齐更规整。
  5. 稍微调整缩放逻辑,让画布显示更自然。

下面是改动后的关键代码(你可以直接替换原来的 initUI_update_zoom_image,其他逻辑基本不动):

python
def initUI(self): # ========== 整体布局 ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) # 统一一点的边距 self.main_layout.setSpacing(4) # ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) # 按钮(略,直接沿用你原来的,只是删掉注释) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域(不再固定高度,改为可伸缩) self.preview1 = QLabel("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2 = QLabel("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区:使用 QFormLayout 让标签对齐更规整 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) # 让上面内容顶上,下面留空更舒服 right_layout.addStretch() # 右侧固定宽度,使整体更规整 right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) # 调整左右比例 splitter.setStretchFactor(0, 3) # 左边画布更大 splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # 图片缩放参数 self.zoom_scale = 1.0 self.original_pixmap = None

缩放更新逻辑也建议稍微改一下(避免图片比画布大太多):

python
def _update_zoom_image(self): """更新缩放后的图片展示(基于画布大小和缩放比例)""" if not self.original_pixmap: return # 以画布当前大小为基准 canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return target_width = int(canvas_size.width() * self.zoom_scale) target_height = int(canvas_size.height() * self.zoom_scale) scaled_pixmap = self.original_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter)

如果你愿意的话,下一步我还可以帮你把右侧面板用 QGroupBox 做成**“预览区 / 参数设置 / 操作按钮”三大块**,视觉层次会更清晰一点。

Question

import sys
import logging
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QMainWindow, QStackedWidget, QVBoxLayout, QWidget

导入页面

from pages.First import HomePage
from pages.Operate import ImageOperationPage # 导入改造后的图片页

配置日志

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("ArtPartitioner.Main")

class ArtPartitionerWindow(QMainWindow):
"""主程序窗口:整合首页+图片操作页"""
PAGE_HOME = 0
PAGE_IMAGE = 1

text
def __init__(self): super().__init__() self.logger = logger self.stacked_widget = QStackedWidget() self.init_ui() def init_ui(self): # 窗口基础设置 self.setWindowTitle("ArtPartitioner") self.setGeometry(100, 100, 2000, 1200) self.setMinimumSize(1200, 800) # 中心部件+布局 central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 初始化页面 self._init_all_pages() # 加入堆叠窗口 main_layout.addWidget(self.stacked_widget) def _init_all_pages(self): """初始化首页+图片页""" self.logger.info("初始化页面...") # 首页 self.home_page = HomePage(self) self.stacked_widget.addWidget(self.home_page) # 图片操作页 self.image_page = ImageOperationPage(self) self.stacked_widget.addWidget(self.image_page) # 默认显示首页 self.stacked_widget.setCurrentWidget(self.home_page) def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE and file_path: self.image_page.load_image(file_path) # 加载图片 self.stacked_widget.setCurrentWidget(self.image_page) # 跳转到图片页 def back_to_home(self): """返回首页""" self.jump_to_page(self.PAGE_HOME) self.logger.info("返回首页") def _show_error提示(self, title, message): """全局错误提示""" from PyQt5.QtWidgets import QMessageBox msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.exec_()

if name == "main":
try:
app = QApplication(sys.argv)
app.setAttribute(Qt.AA_EnableHighDpiScaling)
app.setAttribute(Qt.AA_UseHighDpiPixmaps)
window = ArtPartitionerWindow()
window.show()
sys.exit(app.exec_())
except Exception as e:
logger.exception(f"程序启动失败:{e}")
sys.exit(1)这是首页选取图片的代码,通过给出的上述代码,无法跳转到
操作图片界面显示图片

Réflexion

Investigating navigation issue and possible bugs

The user mentions failing navigation when trying to jump between pages. I think the issue may be related to how jump_to_page handles the file_path; if it’s valid but contains spaces or non-Latin characters, it might still evaluate as truthy. I’ll also check the error handling in load_image to see if any errors show up when the file path is incorrect but still proceeds.

Diagnosing image display issue

It seems like the user might be using an older version of the ImageOperationPage class or importing it incorrectly. They mentioned using the "updated image page" but there could still be a mismatch between how the classes are used or initialized. I think the key issue might be not calling jump_to_page properly or passing incorrect parameters. I’ll suggest calling jump_to_page from main_window with the correct arguments from HomePage.

Fixing navigation and image displaying

It seems like HomePage might not be using the correct instance of ImageOperationPage tied to main_window. I should explain how to properly call jump_to_page from HomePage by using self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path) when a file is selected. Additionally, I’ll suggest a code fix to handle cases where file_path is empty, ensuring the page always changes.

Fixing image page jumping issue

The user might not be passing the file_path to jump_to_page,

Thought for 14s

Réponse

问题的关键其实就在这里这句判断上:

python
elif page_index == self.PAGE_IMAGE and file_path: self.image_page.load_image(file_path) # 加载图片 self.stacked_widget.setCurrentWidget(self.image_page) # 跳转到图片页

你现在只有在同时满足两个条件时才会切换页面

  1. page_index == self.PAGE_IMAGE
  2. file_path 是一个「真值」(不为 None / ""

如果你在首页调用的是类似:

python
self.main_window.jump_to_page(self.main_window.PAGE_IMAGE) # 或者 file_path 传的是 None / ""

那么这个 elif 条件就永远不成立:

  • 页面不会切到 image_page
  • 图片当然也就不会显示

一步到位的修改方案

1. 修改 jump_to_page,无论是否传图片路径都能跳到图片页

把你的 jump_to_page 改成这样更稳妥:

python
def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE: # 如果传了图片路径,先尝试加载 if file_path: self.image_page.load_image(file_path) else: self.logger.warning("跳转到图片页但未传入 file_path,将空白打开图片页。") # 无论是否加载成功,都切到图片页 self.stacked_widget.setCurrentWidget(self.image_page)

这样可以保证:

  • 你只想 “先打开操作页面再选图” 也没问题;
  • 你从首页选图并传入路径时,也能正常加载 + 跳转。

2. 确保首页调用方式正确

在首页 HomePage 里,选完图片后,应该像这样调用(伪代码):

python
# HomePage.__init__(self, main_window): self.main_window = main_window # 当用户选择完图片后,比如在某个按钮的槽函数里: from PyQt5.QtWidgets import QFileDialog def on_select_image_clicked(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" ) if not file_path: return # 用户取消 # 关键:要把 file_path 传给 jump_to_page self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)

两个重点:

  1. HomePage 的构造函数里要接收 main_window 并保存:

    python
    class HomePage(QWidget): def __init__(self, main_window): super().__init__() self.main_window = main_window ...
  2. 调用时一定要传第二个参数 file_path,否则刚才那个条件就用不上加载图片了。


3. 快速自查清单(不用改代码也能排查)

你可以按这个顺序自己检查一下当前工程:

  1. HomePage 构造函数
    • 是否是 HomePage(self),并在内部保存了 self.main_window = main_window
  2. 选图回调函数
    • 是否有 QFileDialog.getOpenFileName 返回的 file_path
    • 是否用 self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path) 调用?
  3. jump_to_page 逻辑
    • 有没像最开始那样把 file_path 写在 elif 条件里导致不跳转?

如果你愿意,把 HomePage 里“选取图片”的那段代码贴出来,我可以直接帮你改到能跑、能跳、能显示图的程度 👀

Question

我选取图片通过打开文件进行手动选取,这是全部代码import sys
import logging
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QMainWindow, QStackedWidget, QVBoxLayout, QWidget

导入页面

from pages.First import HomePage
from pages.Operate import ImageOperationPage # 导入改造后的图片页

配置日志

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("ArtPartitioner.Main")

class ArtPartitionerWindow(QMainWindow):
"""主程序窗口:整合首页+图片操作页"""
PAGE_HOME = 0
PAGE_IMAGE = 1

text
def __init__(self): super().__init__() self.logger = logger self.stacked_widget = QStackedWidget() self.init_ui() def init_ui(self): # 窗口基础设置 self.setWindowTitle("ArtPartitioner") self.setGeometry(100, 100, 2000, 1200) self.setMinimumSize(1200, 800) # 中心部件+布局 central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 初始化页面 self._init_all_pages() # 加入堆叠窗口 main_layout.addWidget(self.stacked_widget) def _init_all_pages(self): """初始化首页+图片页""" self.logger.info("初始化页面...") # 首页 self.home_page = HomePage(self) self.stacked_widget.addWidget(self.home_page) # 图片操作页 self.image_page = ImageOperationPage(self) self.stacked_widget.addWidget(self.image_page) # 默认显示首页 self.stacked_widget.setCurrentWidget(self.home_page) def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE and file_path: self.image_page.load_image(file_path) # 加载图片 self.stacked_widget.setCurrentWidget(self.image_page) # 跳转到图片页 def back_to_home(self): """返回首页""" self.jump_to_page(self.PAGE_HOME) self.logger.info("返回首页") def _show_error提示(self, title, message): """全局错误提示""" from PyQt5.QtWidgets import QMessageBox msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.exec_()

if name == "main":
try:
app = QApplication(sys.argv)
app.setAttribute(Qt.AA_EnableHighDpiScaling)
app.setAttribute(Qt.AA_UseHighDpiPixmaps)
window = ArtPartitionerWindow()
window.show()
sys.exit(app.exec_())
except Exception as e:
logger.exception(f"程序启动失败:{e}")
sys.exit(1)

Réflexion

Identifying issue with file selection and page switch

It seems like the user selects the image manually through an open file dialog — maybe in either the HomePage or the OperationPage. The issue is likely that the "jump_to_page" function requires a non-empty file path, which isn’t provided when opening the operation page without a selected file. This prevents the page from switching properly.

Thought for 5s

Réponse

好,我先直接指出现在为什么“选完图片也跳不到操作页”:

你这个判断:

python
elif page_index == self.PAGE_IMAGE and file_path: self.image_page.load_image(file_path) # 加载图片 self.stacked_widget.setCurrentWidget(self.image_page) # 跳转到图片页

要求 既是 PAGE_IMAGE,又必须有非空 file_path 才会跳转。
一旦你的调用里 file_pathNone / "" / 没传,整个 elif 不执行,所以根本不会切到图片页,于是你就感觉“没跳转”、“图片没显示”。


1️⃣ 先改 jump_to_page:无论如何先把页面切过去

把你类里的 jump_to_page 改成下面这样(完全可以原地替换):

python
def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE: # 如果传了有效路径,先尝试加载 if file_path: self.image_page.load_image(file_path) else: self.logger.warning("跳转到图片页但未传入 file_path,将空白打开图片页。") # 不管有没有 file_path,都切换到图片操作页 self.stacked_widget.setCurrentWidget(self.image_page)

这样有三个好处:

  1. 只想先打开操作页、之后再在操作页里打开文件,也可以;
  2. 从首页选完图片并传 path,能直接加载 + 显示;
  3. 就算文件路径无效,也至少会切到图片页,然后由 load_image 自己弹错误提示。

2️⃣ 确保你“打开文件”的地方调用方式正确

你说你是“通过打开文件进行手动选取”,那一般会是类似这样一段代码(通常写在 HomePage 里某个按钮/菜单的槽函数中):

python
from PyQt5.QtWidgets import QFileDialog def on_open_image_clicked(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" ) if not file_path: # 用户点了取消 return # 关键:选完图片后要把路径传给主窗口的 jump_to_page self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)

配套要保证两点:

  1. HomePage 的构造函数是接收主窗口的:

    python
    class HomePage(QWidget): def __init__(self, main_window): super().__init__() self.main_window = main_window # 其它初始化逻辑...
  2. 你在 ArtPartitionerWindow._init_all_pages 里本来就是这样创建的:

    python
    self.home_page = HomePage(self)

    这就没问题。

✅ 总结一下调用路径应该是:

  • HomePage 里按钮/菜单 → QFileDialog 选图片 → 得到 file_path
  • 然后:self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)
  • 主窗口:jump_to_page 调用 self.image_page.load_image(file_path) + 切换到 image_page
  • ImageOperationPage.load_image 把图片画到 canvas_label 和预览区

只要上面这三环都打通,你的图片就能正常在操作页显示了。


如果你方便的话,可以把 HomePage 里“打开文件”的那几行也贴出来,我可以直接帮你改成一整套连通的版本(带按钮/带菜单都行)。

Question

from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import (
QWidget, QLabel, QLineEdit, QPushButton, QToolButton,
QHBoxLayout, QVBoxLayout, QGridLayout, QFrame, QScrollArea,
QStyle, QComboBox
)

class HomePage(QWidget):
"""首页Page:封装所有首页UI和逻辑"""

text
def __init__(self, parent=None): super().__init__(parent) self.selected_image_path = "" self.selected_image_pixmap = None self.initUI() def initUI(self): # 首页主布局(替代原QMainWindow的centralWidget) main_layout = QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 1. 添加顶部标题栏(原menuWidget改为普通布局项) top_bar = self.create_top_bar() main_layout.addWidget(top_bar) # 2. 创建核心内容区域(左右面板) content_widget = QWidget() content_layout = QHBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(0) # 左侧面板 + 右侧面板 left_panel = self.create_left_panel() right_panel = self.create_right_panel() content_layout.addWidget(left_panel, 2) content_layout.addWidget(right_panel, 1) main_layout.addWidget(content_widget, 1) # 占满剩余空间 # 应用样式 self.apply_styles() def create_top_bar(self): """创建顶部标题栏(改为普通QWidget)""" top_bar = QWidget() top_bar.setFixedHeight(60) top_layout = QHBoxLayout(top_bar) top_layout.setContentsMargins(10, 0, 10, 0) top_bar.setStyleSheet("background-color: #333333;") # 左侧logo1 logo_pixmap = QPixmap("./sotf_image/logo1.png") scaled_pixmap = logo_pixmap.scaled( top_bar.height() + 120, top_bar.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) logo_label = QLabel() logo_label.setPixmap(scaled_pixmap) top_layout.addWidget(logo_label) # logo_zm logo_pixmap2 = QPixmap("./sotf_image/logo_zm.png") scaled_pixmap2 = logo_pixmap2.scaled( top_bar.height() + 200, top_bar.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) logo_label2 = QLabel() logo_label2.setPixmap(scaled_pixmap2) top_layout.addWidget(logo_label2) # 右侧帮助按钮 help_btn = QToolButton() help_btn.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxInformation)) help_btn.setFixedSize(24, 24) top_layout.addStretch() top_layout.addWidget(help_btn) return top_bar def create_left_panel(self): """创建左侧最近文件区域(逻辑完全复用,仅调整父容器)""" left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(25, 50, 25, 50) left_layout.setSpacing(10) # 顶部"最近"标签和搜索框 header_widget = QWidget() header_widget.setFixedHeight(60) header_layout = QHBoxLayout(header_widget) # 最近图标 recent_pixmap = QPixmap("./sotf_image/recent.png") scaled_pixmap_re = recent_pixmap.scaled( header_widget.height() + 30, header_widget.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) recent_label = QLabel() recent_label.setPixmap(scaled_pixmap_re) header_layout.addWidget(recent_label) header_layout.addStretch() # 搜索框 search_frame = QFrame() search_frame.setFixedHeight(30) search_layout = QHBoxLayout(search_frame) search_layout.setContentsMargins(5, 0, 5, 0) search_edit = QLineEdit() search_edit.setPlaceholderText("搜索最近文件") search_btn = QToolButton() recent_pixmap = QPixmap("./sotf_image/serach.png") scaled_pixmap_se = recent_pixmap.scaled( search_frame.height() + 30, search_frame.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) search_btn.setIcon(QIcon(scaled_pixmap_se)) search_btn.setFixedSize(20, 20) search_layout.addWidget(search_edit) search_layout.addWidget(search_btn) header_layout.addWidget(search_frame) left_layout.addWidget(header_widget) # 水平分隔线 h_line = QFrame() h_line.setFrameShape(QFrame.HLine) h_line.setStyleSheet("background-color: #999999;") h_line.setFrameShadow(QFrame.Sunken) left_layout.addWidget(h_line) # 视图切换按钮 view_layout = QHBoxLayout() view_layout.setContentsMargins(0, 0, 0, 10) grid_view_btn = QToolButton() list_pixmap = QPixmap("./sotf_image/list.png") scaled_pixmap_li = list_pixmap.scaled( search_frame.height(), search_frame.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) grid_view_btn.setIcon(QIcon(scaled_pixmap_li)) list_view_btn = QToolButton() sort_pixmap = QPixmap("./sotf_image/sort.png") scaled_pixmap_so = sort_pixmap.scaled( search_frame.height(), search_frame.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) list_view_btn.setIcon(QIcon(scaled_pixmap_so)) view_layout.addStretch() view_layout.addWidget(grid_view_btn) view_layout.addWidget(list_view_btn) left_layout.addLayout(view_layout) # 文件缩略图区域(带滚动条) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) files_container = QWidget() files_container.setStyleSheet("background-color: #000000;") files_layout = QGridLayout(files_container) files_layout.setContentsMargins(0, 0, 0, 0) files_layout.setSpacing(20) file_pixmap = QPixmap("./sotf_image/file.png") scaled_pixmap_fi = file_pixmap.scaled( 50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation ) # 添加6个文件缩略图 for row in range(2): for col in range(3): file_card = QToolButton() file_card.setStyleSheet("background-color: #FFFFFF;") file_card.setFixedSize(200, 200) file_card.setIcon(QIcon(scaled_pixmap_fi)) files_layout.addWidget(file_card, row, col) scroll_area.setWidget(files_container) left_layout.addWidget(scroll_area) return left_panel def create_right_panel(self): """创建右侧功能区域(逻辑完全复用)""" right_panel = QWidget() right_panel.setStyleSheet("background-color: #666666;") right_panel.setObjectName("rightPanel") right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(20, 40, 20, 20) right_layout.setSpacing(30) # 标题 title_label = QLabel("ArtPartitioner®") title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("font-size: 32px; font-weight: bold;") right_layout.addWidget(title_label) right_layout.addStretch() # 打开按钮 openfile = QWidget() openfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") open_layout = QHBoxLayout(openfile) open_btn = QPushButton(" 打开文件...") open_pixmap = QPixmap("./sotf_image/open.png") open_btn.setIcon(QIcon(open_pixmap)) open_btn.setIconSize(QSize(18, 18)) open_btn.setFixedHeight(30) open_btn.setMinimumWidth(250) open_layout.addWidget(open_btn) right_layout.addWidget(openfile, 0, Qt.AlignCenter) # 导入按钮 importfile = QWidget() importfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") import_layout = QHBoxLayout(importfile) import_btn = QPushButton(" 导入文件...") import_pixmap = QPixmap("./sotf_image/import.png") import_btn.setIcon(QIcon(import_pixmap)) import_btn.setIconSize(QSize(18, 18)) import_btn.setFixedHeight(30) import_btn.setMinimumWidth(250) import_layout.addWidget(import_btn) right_layout.addWidget(importfile, 0, Qt.AlignCenter) # 数据库选择下拉框 db_choose = QWidget() db_choose.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") db_choose_layout = QHBoxLayout(db_choose) db_choose_btn = QPushButton(" 选择数据库...") db_pixmap = QPixmap("./sotf_image/database.png") db_choose_btn.setIcon(QIcon(db_pixmap)) db_choose_btn.setIconSize(QSize(18, 18)) db_choose_btn.setFixedHeight(30) db_choose_btn.setMinimumWidth(250) db_choose_layout.addWidget(db_choose_btn) right_layout.addWidget(db_choose, 0, Qt.AlignCenter) right_layout.addStretch(3) return right_panel def apply_styles(self): """应用界面样式(完全复用)""" self.setStyleSheet(""" /* 全局样式 */ QWidget { background-color: #000000; color: #ffffff; } QLabel { color: #ffffff; font-size: 14px; } /* 左侧面板 */ QLineEdit { background-color: #444444; color: #ffffff; border: none; padding: 5px; border-radius: 3px; } QLineEdit::placeholder { color: #aaaaaa; } QFrame { background-color: #444444; border-radius: 3px; } QToolButton { background-color: transparent; border: none; color: #ffffff; } QToolButton:hover { background-color: #555555; border-radius: 3px; } /* 右侧面板 */ QWidget#rightPanel { background-color: #444444; } QPushButton { background-color: #ffffff; color: #000000; border: none; border-radius: 3px; padding: 5px 10px; font-size: 14px; text-align: left; } QPushButton:hover { background-color: #eeeeee; } QComboBox { background-color: #ffffff; color: #000000; border: none; border-radius: 3px; padding: 5px; min-width: 150px; } QScrollArea { background-color: transparent; border: none; } """)

Réflexion

Reworking HomePage class logic

I noticed that in the HomePage class, the __init__ method uses parent=None, but in the main window, self (ArtPartitionerWindow) is passed as the parent, which means HomePage currently doesn't have a reference to the main_window. I need to modify the method signature to accept and store the main_window reference, and adjust related code in ArtPartitionerWindow to match. This will allow the "打开文件..." button to work as intended.

Thought for 5s

Réponse

好,问题终于完整了,现在能一眼看出来为啥“选了图片也进不到操作页”。

核心问题有三个:

  1. HomePage 里根本没保存主窗口引用(self.main_window),所以没法调用 jump_to_page
  2. “打开文件…” 按钮只创建了,没有 clicked 信号连接,也就是点了啥都没发生。
  3. jump_to_page 里还多了个 and file_path 限制,导致不传路径就不会跳页面。

下面我给你一套直接可用的改法,你可以基本上原地替换 / 少量改动就能跑。


一、修改 HomePage:保存 main_window 引用 + 打开文件并跳转

1.1 修改构造函数签名

原来:

python
class HomePage(QWidget): """首页Page:封装所有首页UI和逻辑""" def __init__(self, parent=None): super().__init__(parent) self.selected_image_path = "" self.selected_image_pixmap = None self.initUI()

改成(关键:接收 main_window,并保存到 self.main_window):

python
class HomePage(QWidget): """首页Page:封装所有首页UI和逻辑""" def __init__(self, main_window): super().__init__(main_window) # 把主窗口作为父对象 self.main_window = main_window self.selected_image_path = "" self.selected_image_pixmap = None self.initUI()

你的 ArtPartitionerWindow._init_all_pages 里是这么写的:

python
self.home_page = HomePage(self)

刚好可以直接对上,没问题。


1.2 给“打开文件…”按钮加点击事件

create_right_panel 里,你现在是这样:

python
# 打开按钮 openfile = QWidget() openfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") open_layout = QHBoxLayout(openfile) open_btn = QPushButton(" 打开文件...") open_pixmap = QPixmap("./sotf_image/open.png") open_btn.setIcon(QIcon(open_pixmap)) open_btn.setIconSize(QSize(18, 18)) open_btn.setFixedHeight(30) open_btn.setMinimumWidth(250) open_layout.addWidget(open_btn) right_layout.addWidget(openfile, 0, Qt.AlignCenter)

只差一句把点击事件连起来

python
# 打开按钮 openfile = QWidget() openfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") open_layout = QHBoxLayout(openfile) open_btn = QPushButton(" 打开文件...") open_pixmap = QPixmap("./sotf_image/open.png") open_btn.setIcon(QIcon(open_pixmap)) open_btn.setIconSize(QSize(18, 18)) open_btn.setFixedHeight(30) open_btn.setMinimumWidth(250) # 关键:点击 → 打开文件对话框 → 跳转到图片操作页 open_btn.clicked.connect(self.open_image_file) open_layout.addWidget(open_btn) right_layout.addWidget(openfile, 0, Qt.AlignCenter)

1.3 实现 open_image_file 方法

在 HomePage 类里加一个方法(放在 class 里面任意位置即可):

python
from PyQt5.QtWidgets import QFileDialog # 顶部别忘了导入 class HomePage(QWidget): ... def open_image_file(self): """打开文件对话框,选图后跳转到图片操作页""" file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" ) if not file_path: # 用户取消 return # 记一下当前选中的路径(你之前有这两个属性) self.selected_image_path = file_path self.selected_image_pixmap = QPixmap(file_path) # 调用主窗口的方法跳转到图片操作页,并把路径传过去 self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)

这样,从首页 → 打开文件 → 选中图片 → 自动跳转到图片操作页这条链就打通了。


二、修改主窗口 jump_to_page:不再卡 file_path 条件

把你现在的 jump_to_page

python
def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE and file_path: self.image_page.load_image(file_path) # 加载图片 self.stacked_widget.setCurrentWidget(self.image_page) # 跳转到图片页

改成这样(完全可以直接替换):

python
def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE: # 如果传了有效路径,先加载图片 if file_path: self.image_page.load_image(file_path) else: self.logger.warning("跳转到图片操作页,但未传入 file_path,将空白打开图片页。") # 无论是否有 file_path 都切换到图片页 self.stacked_widget.setCurrentWidget(self.image_page)

这样以后你要是想从别的地方跳到图片页(不一定是首页选图),也很方便。


三、整体流程梳理一下(帮你脑内 debug 一遍)

  1. 启动程序 → ArtPartitionerWindow 创建 → _init_all_pages
    • HomePage(self)self.home_page
    • ImageOperationPage(self)self.image_page
  2. HomePage:
    • open_btnopen_image_file
    • open_image_file 里:
      • QFileDialog.getOpenFileName 选出图片路径 file_path
      • 调用 self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)
  3. 主窗口的 jump_to_page
    • page_index == PAGE_IMAGEself.image_page.load_image(file_path) → 切到图片页
  4. ImageOperationPage.load_image
    • 做文件存在性检查
    • QPixmap(file_path) 加载图片
    • 显示到 canvas_label / preview1 / preview2

这条链只要按上面的代码写,基本不会断。


如果你改完运行发现还有具体报错(比如某个属性名、路径错误之类),把报错栈贴出来,我可以帮你把最后的细节也抹平。

Question

D:\Computer_items\miniConda\soft\envs\py310\python.exe D:\Computer_items\Pycharm\python_code\ArtPartioner\Soft_Main.py
2025-12-11 10:28:56,068 - ArtPartitioner.Main - INFO - 初始化页面...

Process finished with exit code -1073740791 (0xC0000409)不能实现跳转

Réflexion

Suspecting misuse of QPixmap initialization

It looks like the issue might stem from using QPixmap before QApplication is fully initialized. The crash suggests an earlier use of QPixmap than expected. It seems like the right construction process was followed, but it’s possible it happened at the module level, which isn’t safe before QApplication.

Rethinking potential recursion and reparenting conflict

In my earlier response, I suggested importing QFileDialog, which is safe. The error code 0xC0000409 might be caused by an incompatible Qt build, or more likely, infinite recursion. The reparenting issue with QStackedWidget seems acceptable, but to avoid any odd behavior, super().__init__(parent) should be retained to ensure proper parent-child relationships.

Investigating possible causes of crash

To keep things simple, I’m thinking of setting up HomePage with super().__init__(main_window), which should be fine even if we leave __init__(parent=None). The crash might be due to a stack buffer overrun, but that's unlikely from our changes. The issue could arise from ImageOperationPage using main_window.logger before it's properly initialized.

Analyzing ImageOperationPage issue and potential crash sources

In ImageOperationPage.initUI, loading icons with QPixmap could fail with invalid paths, logged at the Python level but non-fatal. But the crash seems to happen at startup, not after a button click. I suspect it could be from changes in HomePage.__init__, potentially missing self.main_window, causing an early crash before any interaction.

Thought for 21s

Réponse

好的,我们一步把“选图片 → 跳到操作页面并显示图片”这条链修好。你现在遇到的 0xC0000409 基本是 Qt 这边直接崩掉了,多半是代码不一致导致的,我直接给你一套 完整能跑的版本,你按这个对照修改就行。


一、主窗口 ArtPartitionerWindow:统一跳转逻辑

先把你的 ArtPartitionerWindow 改成下面这样(主要是 jump_to_page,其他基本不动):

python
import sys import logging from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QStackedWidget, QVBoxLayout, QWidget # 导入页面 from pages.First import HomePage from pages.Operate import ImageOperationPage # 导入改造后的图片页 # 配置日志 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.StreamHandler(sys.stdout)] ) logger = logging.getLogger("ArtPartitioner.Main") class ArtPartitionerWindow(QMainWindow): """主程序窗口:整合首页+图片操作页""" PAGE_HOME = 0 PAGE_IMAGE = 1 def __init__(self): super().__init__() self.logger = logger self.stacked_widget = QStackedWidget() self.init_ui() def init_ui(self): # 窗口基础设置 self.setWindowTitle("ArtPartitioner") self.setGeometry(100, 100, 2000, 1200) self.setMinimumSize(1200, 800) # 中心部件+布局 central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 初始化页面 self._init_all_pages() # 加入堆叠窗口 main_layout.addWidget(self.stacked_widget) def _init_all_pages(self): """初始化首页+图片页""" self.logger.info("初始化页面...") # 首页 —— 把“self”(主窗口)传给 HomePage self.home_page = HomePage(self) self.stacked_widget.addWidget(self.home_page) # 图片操作页 —— 同样传主窗口,方便内部调用 self.image_page = ImageOperationPage(self) self.stacked_widget.addWidget(self.image_page) # 默认显示首页 self.stacked_widget.setCurrentWidget(self.home_page) def jump_to_page(self, page_index, file_path=None): """页面跳转:支持跳转到图片页并传递图片路径""" if page_index not in [self.PAGE_HOME, self.PAGE_IMAGE]: self.logger.error(f"无效索引:{page_index},跳回首页") self.stacked_widget.setCurrentWidget(self.home_page) return if page_index == self.PAGE_HOME: self.logger.info("跳转到首页") self.stacked_widget.setCurrentWidget(self.home_page) elif page_index == self.PAGE_IMAGE: # 有图片路径就先加载 if file_path: self.logger.info(f"跳转到图片页并加载图片:{file_path}") self.image_page.load_image(file_path) else: self.logger.warning("跳转到图片页但未传入 file_path,将空白打开图片页。") self.stacked_widget.setCurrentWidget(self.image_page) def back_to_home(self): """返回首页""" self.jump_to_page(self.PAGE_HOME) self.logger.info("返回首页") def _show_error提示(self, title, message): """全局错误提示""" from PyQt5.QtWidgets import QMessageBox msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.exec_() if __name__ == "__main__": try: app = QApplication(sys.argv) app.setAttribute(Qt.AA_EnableHighDpiScaling) app.setAttribute(Qt.AA_UseHighDpiPixmaps) window = ArtPartitionerWindow() window.show() sys.exit(app.exec_()) except Exception as e: logger.exception(f"程序启动失败:{e}") sys.exit(1)

关键改动点:

  • jump_to_page 不再用 elif page_index == self.PAGE_IMAGE and file_path 这种写法,而是 无论有没有 file_path 都会切到图片操作页,只是在有路径时才加载图片。
  • _init_all_pages 中明确将 self 作为参数传给 HomePage(self),稍后在 HomePage 里用它来调用 jump_to_page

二、HomePage:拿到 main_window + 打开文件 + 跳转

你当前的 HomePage 构造函数是:

python
class HomePage(QWidget): """首页Page:封装所有首页UI和逻辑""" def __init__(self, parent=None): super().__init__(parent) self.selected_image_path = "" self.selected_image_pixmap = None self.initUI()

这里我们要做两件事:

  1. 把“主窗口”保存下来,后续用来调用 jump_to_page
  2. 给“打开文件…”按钮绑定槽函数,使用 QFileDialog 选择图片,并把路径传给主窗口。

2.1 修改构造函数,保存 main_window 引用

改成下面这样(注意参数名变成 main_window,里面存到 self.main_window):

python
from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtWidgets import ( QWidget, QLabel, QLineEdit, QPushButton, QToolButton, QHBoxLayout, QVBoxLayout, QGridLayout, QFrame, QScrollArea, QStyle, QComboBox, QFileDialog # 这里顺便把 QFileDialog 导入 ) class HomePage(QWidget): """首页Page:封装所有首页UI和逻辑""" def __init__(self, main_window): # 不强制指定父对象也可以,但用 main_window 当 parent 也没问题 super().__init__(main_window) self.main_window = main_window # ★ 保存主窗口引用 self.selected_image_path = "" self.selected_image_pixmap = None self.initUI()

2.2 给“打开文件...”按钮加槽函数

create_right_panel 里找到“打开按钮”那块,改成这样:

python
def create_right_panel(self): """创建右侧功能区域(逻辑完全复用)""" right_panel = QWidget() right_panel.setStyleSheet("background-color: #666666;") right_panel.setObjectName("rightPanel") right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(20, 40, 20, 20) right_layout.setSpacing(30) # 标题 title_label = QLabel("ArtPartitioner®") title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("font-size: 32px; font-weight: bold;") right_layout.addWidget(title_label) right_layout.addStretch() # 打开按钮 openfile = QWidget() openfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") open_layout = QHBoxLayout(openfile) open_btn = QPushButton(" 打开文件...") open_pixmap = QPixmap("./sotf_image/open.png") open_btn.setIcon(QIcon(open_pixmap)) open_btn.setIconSize(QSize(18, 18)) open_btn.setFixedHeight(30) open_btn.setMinimumWidth(250) # ★ 关键:点击按钮 → 选择图片 → 跳转到图片操作页 open_btn.clicked.connect(self.open_image_file) open_layout.addWidget(open_btn) right_layout.addWidget(openfile, 0, Qt.AlignCenter) # 导入按钮(你原来的逻辑可以先不管) importfile = QWidget() importfile.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") import_layout = QHBoxLayout(importfile) import_btn = QPushButton(" 导入文件...") import_pixmap = QPixmap("./sotf_image/import.png") import_btn.setIcon(QIcon(import_pixmap)) import_btn.setIconSize(QSize(18, 18)) import_btn.setFixedHeight(30) import_btn.setMinimumWidth(250) import_layout.addWidget(import_btn) right_layout.addWidget(importfile, 0, Qt.AlignCenter) # 数据库选择下拉框(原样保留) db_choose = QWidget() db_choose.setStyleSheet("background-color:#FFFFFF;border-radius: 8px;") db_choose_layout = QHBoxLayout(db_choose) db_choose_btn = QPushButton(" 选择数据库...") db_pixmap = QPixmap("./sotf_image/database.png") db_choose_btn.setIcon(QIcon(db_pixmap)) db_choose_btn.setIconSize(QSize(18, 18)) db_choose_btn.setFixedHeight(30) db_choose_btn.setMinimumWidth(250) db_choose_layout.addWidget(db_choose_btn) right_layout.addWidget(db_choose, 0, Qt.AlignCenter) right_layout.addStretch(3) return right_panel

2.3 实现 open_image_file 方法

在 HomePage 类末尾加上这个方法(在 apply_styles 上面或下面都行,只要在 class 里):

python
def open_image_file(self): """打开文件对话框,选择图片后跳转到图片操作页""" file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" ) if not file_path: # 用户取消 return # 记录一下当前选中的图片 self.selected_image_path = file_path self.selected_image_pixmap = QPixmap(file_path) # 调用主窗口的跳转方法,带上图片路径 self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)

三、简单验证流程(你可以自己跟着点一遍)

  1. 运行程序
    控制台应该打印类似:

    text
    2025-12-11 10:28:56,068 - ArtPartitioner.Main - INFO - 初始化页面...
  2. 程序窗口出来后,在右侧点击「打开文件...」。

  3. 选择一张 .png / .jpg / .bmp 等图片,点击确定。

  4. 控制台会再打印:

    text
    ... INFO - 跳转到图片页并加载图片:xxx/xxx/xxx.png
  5. 界面切换到图片操作页,图片出现在中间黑色画布和预览框中。

如果还出现直接崩溃(依然是 0xC0000409),那就说明有更底层的 Qt 问题(比如 PyQt 版本不兼容、某些 dll 冲突等),但只按你目前贴的这几段代码来看,跳转逻辑本身已经是通了的


如果你愿意,下一步你可以把现在修改后的完整 HomePageSoft_Main.py 再贴出来,我可以帮你再扫一遍有没有残留的小坑(比如重复的 parent、路径问题之类)。

Question

D:\Computer_items\miniConda\soft\envs\py310\python.exe D:\Computer_items\Pycharm\python_code\ArtPartioner\Soft_Main.py
2025-12-11 10:35:54,560 - ArtPartitioner.Main - INFO - 初始化页面...
2025-12-11 10:35:59,450 - ArtPartitioner.Main - INFO - 跳转到图片页并加载图片:D:/Computer_items/Pycharm/python_code/ArtPartioner/sotf_image/logo.png

Process finished with exit code -1073740791 (0xC0000409)还是存在问题
import sys
from PyQt5 import QtCore
from PyQt5.QtGui import QIcon, QPixmap, QFont
from PyQt5.QtWidgets import (
QWidget, QMenuBar, QMenu, QAction, QToolBar, QHBoxLayout, QVBoxLayout,
QLabel, QComboBox, QSlider, QPushButton, QFrame, QSplitter, QSizePolicy, QFormLayout
)
from PyQt5.QtCore import Qt
import os

class ImageOperationPage(QWidget): # 改为继承QWidget(适配堆叠窗口)
def initUI(self):
# ========== 整体布局 ==========
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(6, 6, 6, 6) # 统一一点的边距
self.main_layout.setSpacing(4)

text
# ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) # 按钮(略,直接沿用你原来的,只是删掉注释) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域(不再固定高度,改为可伸缩) self.preview1 = QLabel("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2 = QLabel("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区:使用 QFormLayout 让标签对齐更规整 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) # 让上面内容顶上,下面留空更舒服 right_layout.addStretch() # 右侧固定宽度,使整体更规整 right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) # 调整左右比例 splitter.setStretchFactor(0, 3) # 左边画布更大 splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # 图片缩放参数 self.zoom_scale = 1.0 self.original_pixmap = None def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" # 校验文件 if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: # 加载原始图片 self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 # 重置缩放比例 # 展示到主画布(适配画布大小) canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) # 展示到预览区1(原图缩小预览) preview1_pixmap = self.original_pixmap.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(preview1_pixmap) # 预览区2默认显示原图(后续可改为处理后图片) self.preview2.setPixmap(preview1_pixmap) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") def zoom_in_image(self): """放大图片(每次+0.1,最大3.0)""" if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可放大") return self.zoom_scale = min(self.zoom_scale + 0.1, 3.0) self._update_zoom_image() def zoom_out_image(self): """缩小图片(每次-0.1,最小0.1)""" if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可缩小") return self.zoom_scale = max(self.zoom_scale - 0.1, 0.1) self._update_zoom_image() def _update_zoom_image(self): """更新缩放后的图片展示(基于画布大小和缩放比例)""" if not self.original_pixmap: return # 以画布当前大小为基准 canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return target_width = int(canvas_size.width() * self.zoom_scale) target_height = int(canvas_size.height() * self.zoom_scale) scaled_pixmap = self.original_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter) def reset_parameters(self): """重置所有滑块参数""" self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(200) self.low_thresh_slider.setValue(100) self.extra_slider.setValue(50) # 重置图片缩放 self.zoom_scale = 1.0 if self.original_pixmap: self._update_zoom_image() # 窗口大小变化时,图片自动适配 def resizeEvent(self, event): if self.original_pixmap: canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) super().resizeEvent(event)这是我的操作图片界面

Réflexion

Investigating PyQt crash due to uninitialized attributes

Okay, we’re dealing with a crash related to uninitialized attributes in PyQt. The crash happens because the ImageOperationPage class lacks an __init__ method, which means some critical attributes like self.canvas_label aren’t being set up. This leads to issues when load_image tries to use these attributes, potentially causing an unhandled exception.

Identifying missing __init__ and correct initialization

It looks like the code snippet is missing the __init__ method, which could be why some attributes aren’t being set, leading to an error when the image is loaded. The __init__ method should handle initializing key attributes like self.canvas_label. I’ll suggest adding the full ImageOperationPage class with proper initialization and necessary imports.

Proposing corrected class structure for initialization

The code uses QToolButton in styles, but the class doesn’t import it, which is fine. We need to ensure the main_window is passed properly. My proposal for fixing the issue:

We should initialize key attributes like self.canvas_label in __init__ before initUI is called. This avoids the AttributeError caused by using self.canvas_label before assignment.

Finalizing bug fix explanation and corrective steps

The crash happens because initUI is called without properly initializing self.canvas_label or self.main_window. In the previous version, these were assigned in __init__. We need to restore the __init__ method to fix this issue. I'll show the corrected ImageOperationPage class and mention the update to import the new version from pages.Operate.

Thought for 16s

Réponse

啊,这次日志里终于看到关键了:

text
INFO - 跳转到图片页并加载图片:D:/.../logo.png Process finished with exit code -1073740791 (0xC0000409)

说明:跳转方法已经正确调用了,崩溃发生在图片页 ImageOperationPage 里。

你贴出的 ImageOperationPage 有两个致命问题:

  1. 类里没有 __init__,也没有调用 initUI
    → 这个页面其实从来没初始化过布局和控件。
  2. initUI 里面直接用 self.canvas_label / self.preview1 等属性,
    但这些属性根本没定义,所以一旦调用 load_image 等方法,就会炸。

正常 Python 会抛 AttributeError,但 PyQt 里有信号/事件循环,有时会直接触发 Windows 异常退出(你看到的 0xC0000409)。


一步修好:给 ImageOperationPage 加上正确的 __init__

✅ 完整、修好的 ImageOperationPage(可以整段替换你现在的类)

python
import os from PyQt5 import QtCore from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout ) class ImageOperationPage(QWidget): # 适配堆叠窗口 def __init__(self, main_window): super().__init__(main_window) # 把主窗口作为 parent 也可以 self.main_window = main_window # 这些属性要先定义好,后面的方法里会用到 self.current_image_path = "" self.canvas_label = QLabel() self.preview1 = QLabel() self.preview2 = QLabel() self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self.zoom_scale = 1.0 self.original_pixmap = None self.initUI() def initUI(self): # ========== 整体布局 ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) # 统一一点的边距 self.main_layout.setSpacing(4) # ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域 self.preview1.setText("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2.setText("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区:使用 QFormLayout 让标签对齐更规整 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # -------- 图片加载与缩放 -------- def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) preview1_pixmap = self.original_pixmap.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(preview1_pixmap) self.preview2.setPixmap(preview1_pixmap) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") def zoom_in_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可放大") return self.zoom_scale = min(self.zoom_scale + 0.1, 3.0) self._update_zoom_image() def zoom_out_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可缩小") return self.zoom_scale = max(self.zoom_scale - 0.1, 0.1) self._update_zoom_image() def _update_zoom_image(self): if not self.original_pixmap: return canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return target_width = int(canvas_size.width() * self.zoom_scale) target_height = int(canvas_size.height() * self.zoom_scale) scaled_pixmap = self.original_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter) def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(200) self.low_thresh_slider.setValue(100) self.extra_slider.setValue(50) self.zoom_scale = 1.0 if self.original_pixmap: self._update_zoom_image() def resizeEvent(self, event): if self.original_pixmap: canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) super().resizeEvent(event)

再帮你检查一下整个流程是否打通

  1. 主窗口初始化页面

    python
    self.home_page = HomePage(self) self.image_page = ImageOperationPage(self)
  2. HomePage 构造函数必须是:

    python
    class HomePage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window ...
  3. HomePage 右侧「打开文件...」按钮的槽函数:

    python
    open_btn.clicked.connect(self.open_image_file) def open_image_file(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择图片", "", "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" ) if not file_path: return self.main_window.jump_to_page(self.main_window.PAGE_IMAGE, file_path)
  4. 主窗口的 jump_to_page 现在已经在日志里打印了:

    text
    INFO - 跳转到图片页并加载图片:xxx/logo.png

只要 ImageOperationPage 初始化正常,这一步就不会再崩溃,而是会成功显示图片操作界面


如果你按这个改完之后还是直接退出,那就再把 最新的 ImageOperationPage + HomePage + 主窗口 一起贴出来,我可以当成一个完整小工程帮你从头到尾再梳一遍。

Question

ok.可以运行了,现在针对图片处理的页面进行以下逻辑处理,要求暂时不改变页面布局,侧边的预览框显示分割的部分图和分割后的整体掩码图
import math
from collections import OrderedDict
import ezdxf
import cv2
import os
import sys
from tkinter import Tk, filedialog
from skimage import morphology
from scipy.spatial import KDTree
import numpy as np
from tqdm import tqdm

TOLERANCE = 20
midcount = 50
lab_bins = None
file_bins = None
image = None
clone_image = None
seed_list = []
seed_points = []
total_Mask = None
display_image = None
skeletonize_mask = None
gray_image = None
proceed_mask = None
proceed_image = None
gray_image_new = None
dbscan_points = []
kdtree = None
COUNT = 0
closest = None
grid_dict = {}
grid_size = 3
tree = None

def grayscale_custom(image):
# 加权平均:Y = 0.299R + 0.587G + 0.114B
b, g, r = cv2.split(image)
gray = 0.114 * b + 0.587 * g + 0.299 * r
return gray.astype(np.uint8)

def fullfill_mask(image, point, tolerance, fill_flag, value):
if fill_flag == 0:
flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY
elif fill_flag == 1:
flag = cv2.FLOODFILL_FIXED_RANGE
mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag)
return mask

def mouse_callback(event, x, y, flags, param):
global clone_image, proceed_mask, gray_image, proceed_image, gray_image_new, seed_list, COUNT
if event == cv2.EVENT_LBUTTONDOWN or event == cv2.EVENT_RBUTTONDOWN:
proceed_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
proceed_image = clone_image.copy()
if event == cv2.EVENT_LBUTTONDOWN:
seed_points.append([x, y])
print(f"添加种子点: ({x}, {y})")
elif event == cv2.EVENT_RBUTTONDOWN:
# clone_image = image.copy()
if seed_points:
seed_points.pop()
print("回退一步种子点")
else:
print("无法回退")
if seed_points:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# color_x = seed_points[0][0]
# color_y = seed_points[0][1]
value = ((COUNT + 1) * 5) % 255
for point in seed_points:
mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
process_clone_image(point, mask)
proceed_mask = cv2.bitwise_or(proceed_mask, mask)
proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
gray_image_new = cv2.bitwise_or(gray_image_new, proceed_mask * value)

text
cv2.imshow("Image", proceed_image) else: cv2.imshow("Image", proceed_image)

def mask_process():

def process_clone_image(point, mask):
global proceed_image
mask = mask[1:-1, 1:-1]
# contour = cv2.Canny(mask,0,0)
contour, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(proceed_image, contour, -1, 255, thickness=2)
# clone_image = cv2.add(clone_image,contour)
cv2.circle(proceed_image, point, 1, (0, 255, 0), 3)

def choose_right_image():
global image, COUNT
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择石材图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
image_stone = cv2.imread(file_path)
image_size = (image.shape[1], image.shape[0])
image_stone = cv2.resize(image_stone, image_size)
return image_stone
# global COUNT
# folder_path = r"python_image/stone"
# # 获取文件夹中所有符合要求的图片文件
# valid_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"]
# image_files = [f for f in os.listdir(folder_path) if any(f.lower().endswith(ext) for ext in valid_extensions)]
#
# # 确保文件夹中有图片
# if len(image_files) == 0:
# print("没有找到有效的图片文件。")
# return None
#
# # 根据 COUNT 顺序选择图片
# image_file = image_files[COUNT % len(image_files)] # 保证不越界
# image_path = os.path.join(folder_path, image_file)
#
# # 读取图片
# image_stone = cv2.imread(image_path)
#
# # 调整大小与目标图像一致
# image_size = (image.shape[1], image.shape[0]) # 使用目标图像的大小
# image_stone = cv2.resize(image_stone, image_size)
#
#
# return image_stone

def grid_simplification_corner_point(points): # 为角点构建聚类矩阵
"""
使用每个网格单元的第一个点坐标作为代表点进行简化。
"""
global grid_dict, grid_size
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
# 创建一个字典来存储每个网格单元的第一个点 for i in range(len(points)): x, y = points[i] min_x, max_x = x - grid_size, x + grid_size min_y, max_y = y - grid_size, y + grid_size key = (min_x, max_x, min_y, max_y) if key not in grid_dict: grid_dict[key] = points[i] # 只记录第一个遇到的点 # 取出每个网格的代表点 # replaced_points = np.array([grid_dict[(grid_x[i], grid_y[i])] for i in range(len(points))]) # df = pd.DataFrame(replaced_points, columns=['x', 'y']) # df_unique = df.drop_duplicates(keep='first')

def replace_cornerpoints(points):
global grid_dict, grid_size
new_points = []
replacement_flags = []
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break # 计算当前点所属的网格的 key # 如果该网格内没有点,则将当前点存入字典 if not replace: new_points.append(pt) # 保留当前点 replacement_flags.append(0) # 将新的点集转换为 numpy 数组并返回 return np.array(new_points), np.array(replacement_flags)

def operation_of_blank():
global seed_points, total_Mask, proceed_mask, clone_image, image, COUNT, display_image, skeletonize_mask
if seed_points:
kernel = np.ones((3, 3), np.uint8)
# proceed_mask = cv2.erode(proceed_mask, kernel, iterations=1)
seed_list.append(seed_points[0])
seed_points = []
# image_stone = choose_right_image()
temp_mask = proceed_mask[1:-1, 1:-1] * 255
total_Mask = cv2.bitwise_or(total_Mask, proceed_mask)
# proceed_mask = cv2.erode(proceed_mask,kernel,iterations=1)
# skeletonize_mask = cv2.bitwise_or(skeletonize_mask, proceed_mask)
canny = cv2.Canny(proceed_mask * 255, 50, 100)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask, canny)
temp_mask = cv2.cvtColor(temp_mask, cv2.COLOR_GRAY2BGR)
# temp_mask = np.where(temp_mask == 255, image_stone, temp_mask)
# display_image = cv2.bitwise_or(display_image, temp_mask)

text
total_Mask_3 = (total_Mask[1:-1, 1:-1] * 255) total_Mask_3 = cv2.cvtColor(total_Mask_3, cv2.COLOR_GRAY2BGR) # # total_stone_image = np.where(total_Mask_3 == 255, image_stone, total_Mask_3) # clone_image = image.copy() clone_image = cv2.add(clone_image, total_Mask_3) COUNT += 1 # total_stone_image = total_stone_image.astype(np.uint8) cv2.imshow("stone_image", skeletonize_mask) # cv2.imshow("image2", gray_image_new) # cv2.imshow("Image1", clone_image)

def operation_of_save():
global total_Mask, gray_image_new, seed_list, skeletonize_mask, image, gray_image
kernel = np.ones((3, 3), np.uint8) # 视腐蚀的缝隙而定
if len(seed_list) > 1:
gray_image_new = cv2.equalizeHist(gray_image_new)
gray_image_new = gray_image_new[1:-1, 1:-1]
total_Mask = total_Mask[1:-1, 1:-1]
total_Mask_not = cv2.bitwise_not(total_Mask * 255)
# total_Mask_not_3 = cv2.cvtColor(total_Mask_not, cv2.COLOR_GRAY2BGR)
gray_image_new = cv2.add(gray_image_new, total_Mask_not)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask[1:-1, 1:-1], total_Mask_not)
skeletonize_mask = cv2.morphologyEx(skeletonize_mask, cv2.MORPH_CLOSE, kernel)
# skeletonize_mask_not = cv2.bitwise_not(skeletonize_mask*255)
# skeletonize_image = cv2.morphologyEx(skeletonize_mask_not, cv2.MORPH_CLOSE, kernel)
skeletonize_image = skeletonize_skimage(skeletonize_mask)
corners = cv2.goodFeaturesToTrack(skeletonize_image, maxCorners=200, qualityLevel=0.1, minDistance=10)
corners = corners.reshape(-1, 2)
# print(corners.shape)
if corners is not None:
for i in corners:
x, y = i.ravel()
cv2.circle(gray_image, (int(x), int(y)), 2, (0, 0, 0), -1)
# erode = cv2.erode(gray_image_new, kernel, iterations=1)
# gray_image_new = np.where(gray_image_new == 255, erode, gray_image_new)
# canny = cv2.Canny(gray_image_new, 0, 0)
# seed_list = list(set(seed_list))
# skeletonize_image = cv2.morphologyEx(skeletonize_image,cv2.MORPH_CLOSE,kernel)
grid_simplification_corner_point(corners)
cv2.imshow("12345", gray_image)
process_mask2(skeletonize_image)
# mask_porcess()
# skeletonize_skimage(total_Mask_not)

def distance(p1, p2):
return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def skeletonize_skimage(image):
# 执行骨架化
skeleton = morphology.skeletonize(image)
skeleton_uint8 = skeleton.astype(np.uint8) * 255
return skeleton_uint8

def cluster_points_with_core_points(input_points, eps):
global dbscan_points
clustered_points = []
for point in input_points:
neighbor = get_closest_neighbor(point, eps)
if neighbor is not None:
clustered_points.append(neighbor)
else:
clustered_points.append(tuple(point))
dbscan_points.extend(clustered_points)
clustered_points = list(OrderedDict.fromkeys(clustered_points))
# first_two_points = clustered_points[:2]
# clustered_points.extend(first_two_points)
return clustered_points

def get_closest_neighbor(point, eps):
global dbscan_points, closest
min_dist = float('inf')
if dbscan_points:
closest = None
for core_point in dbscan_points:
dist = distance(point, core_point)
if dist < min_dist and dist < eps:
min_dist = dist
closest = core_point

return closest

def mask_porcess():
global gray_image_new, seed_list
doc = ezdxf.new()
msp = doc.modelspace()
# dwg = svgwrite.Drawing("output.svg", size=("100%", "100%"))
for point in seed_list:
mask = fullfill_mask(gray_image_new, point, 0, 0, None)
contours, _ = cv2.findContours(mask[1:-1, 1:-1], cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
print(len(contours))
for contour in contours:
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# d = generate_svg_from_points(part_contourPoints)
# dwg.add(dwg.path(d=d, stroke="black", fill="none", stroke_width=1))
# points_py = [tuple(map(float, pt)) for pt in part_contourPoints]
# dwg.add(dwg.polyline(points=points_py, stroke="black", fill="none", stroke_width=1))
# dwg.save()
# part_contourPoints = rdp(part_contourPoints, epsilon=1)
part_contourPoints = cluster_points_with_core_points(part_contourPoints, 30)
# part_contourPoints = rdp(part_contourPoints, epsilon=1.0)
# part_contourPoints.append(part_contourPoints[0])
# msp.add_lwpolyline(part_contourPoints)
# for point in part_contourPoints:
# msp.add_point(point)
msp.add_open_spline(part_contourPoints)
# cv2.drawContours(proceed_mask, contours, 1, 255, thickness=2)

text
# print("添加成功") doc.saveas("nanna13.dxf")

def process_mask2(image):
global seed_list, midcount
kernel = np.ones((3, 3), np.uint8)
doc = ezdxf.new()
msp = doc.modelspace()
for point in tqdm(seed_list, desc="abstract"):
p_image = image.copy()
fullfill_mask(p_image, point, 0, 1, 255)
mask = fullfill_mask(p_image, point, 0, 0, None)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# corners = cv2.goodFeaturesToTrack(mask, maxCorners=200, qualityLevel=0.03, minDistance=10)
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
for contour in contours:
# for contour in tqdm(contours, desc="abstract"):
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# part_contourPoints = grid_simplification_first_point(part_contourPoints,10)
# part_contourPoints = visvalingam_whyatt_simplify(part_contourPoints,2)
# part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
part_contourPoints, replace_flags_1 = replace_cornerpoints(part_contourPoints)
for i in range(midcount):
part_contourPoints, replace_flags = get_midpoints(part_contourPoints, replace_flags_1)
replace_flags_1 = replace_flags
# for point in part_contourPoints:
# msp.add_point(point)
# part_contourPoints = rdp(part_contourPoints, epsilon=10)
part_contourPoints[:, 1] = -part_contourPoints[:, 1]
part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
msp.add_lwpolyline(part_contourPoints)
doc.saveas("dddd.dxf")

def euclidean_distance(p1, p2):
return np.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def get_midpoints(contour_points, replacement_flags_1):
midpoints = []
replacement_flags = []
num_points = len(contour_points)

text
for i in range(num_points): # 当前点和下一个点的索引,最后一个点与第一个点连接 p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue else: if replacement_flags_1[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2 midpoints.append(midpoint) replacement_flags.append(0) # dist = euclidean_distance(p1, p2) # if dist > 10: # num_points = int(np.floor(dist / 10)) # 向下取整,得到点的数量 # # 生成插入的点 # points = [p1 + i * (p2 - p1) / (num_points + 1) for i in range(1, num_points + 1)] # points = np.vstack([p1, points]) # points = np.vstack([points, p2]) # midpoints.extend(points) # # 计算两点的中点 # else: return np.array(midpoints), replacement_flags

def main():
global image, clone_image, total_Mask, display_image, skeletonize_mask, lab_bins, file_bins
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
if not file_path:
print("无法加载图像,请检查文件格式或路径。")
return

text
lab_bins = np.load("./Stone_Data/stone_store_color.npy").astype(np.float64) file_bins = np.load("./Stone_Data/stone_store_file.npy") tree = KDTree(lab_bins) # 此处对图片进行预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() total_Mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) skeletonize_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) display_image = np.zeros((image.shape[0], image.shape[1], 3), dtype=np.uint8) while True: cv2.imshow("Image", clone_image) cv2.imshow("total_mask", total_Mask * 255) cv2.setMouseCallback("Image", mouse_callback) key = cv2.waitKey(0) if key == ord(' '): operation_of_blank() elif key == ord('s'): operation_of_save() elif key == 27: break cv2.destroyAllWindows()

if name == "main":
main()

Réflexion

Thought for 56s

Réponse

太好了,先确认一下:现在已经能跳到图片操作页面了,崩溃问题解决 ✅。接下来就是把你这套 OpenCV 分割逻辑整合到 PyQt 的图片页里,让:

  • 左边大画布:照旧显示原图(以后可以再叠加效果)。
  • 右侧:
    • 上面的预览框:显示当前这次点击得到的分割区域(局部图像)。
    • 下面的预览框:显示目前为止所有点击叠加之后的整体掩码图

并且不改页面布局,只加逻辑


整体思路(先讲人话版)

你原来的分割流程大概是:

  1. cv2.setMouseCallback("Image", mouse_callback) 在 OpenCV 窗口里点种子点。
  2. cv2.floodFill 根据灰度图 + 容差 TOLERANCE 做区域生长,拿到 mask
  3. 不断把每次的 mask 累加到 total_Mask,最后生成整体掩码,显示在 cv2.imshow 里。

现在我们要改成 PyQt 风格:

  • 不用 Tk,不用 cv2.imshow,不用 cv2.waitKey
  • ImageOperationPage 里监听用户点击 canvas_label
    • 左键点击 → 调用 floodFill 做分割。
    • 把本次分割区域显示到 preview1
    • 把所有分割累积的总掩码显示到 preview2

关键点是两件事:

  1. 让 QLabel 能响应鼠标点击(默认不会告诉你点击坐标)。
  2. 把 QLabel 的点击坐标转换成原始图像的像素坐标(因为图片是缩放后居中显示的)。

一步一步改:在 ImageOperationPage 里接入分割逻辑

下面给你的是一个“带分割逻辑”的 ImageOperationPage 版本,你可以在你现在可运行的基础上对照修改(主要是新增的部分),或者直接整体替换(保持你的布局不变)。

1. 头部 import 需要加上 OpenCV 和 numpy,以及 QImage / pyqtSignal

python
import os import cv2 import numpy as np from PyQt5 import QtCore from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap, QImage from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout )

2. 做一个可点击的画布 QLabel

ImageOperationPage 前面(或类里面上方)加一个小类:

python
class ClickableLabel(QLabel): clicked = pyqtSignal(int, int, int) # x, y, button def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event)

3. 修改 ImageOperationPage:初始化 OpenCV 相关变量 + 使用 ClickableLabel

下面是核心结构(我只标出和你之前相比新增/重要的地方):

python
class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # ---- OpenCV / 分割相关状态 ---- self.cv_image = None # OpenCV 原图(BGR) self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次分割的区域 mask(H×W,0/255) self.total_mask = None # 所有分割累积的 mask(H×W,0/255) # 画布和预览框 self.canvas_label = ClickableLabel() # ★ 改成可点击的 Label self.preview1 = QLabel() self.preview2 = QLabel() # 滑块等 self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None # 缩放 self.zoom_scale = 1.0 self.original_pixmap = None self.initUI() # 连接点击信号 self.canvas_label.clicked.connect(self.on_canvas_clicked)

注意:你之前在 initUI 里有 self.canvas_label = QLabel(),现在直接改成上面这个 ClickableLabel 即可,布局部分不变。

4. load_image:保存成 OpenCV 格式,并初始化 mask

在你的 load_image 里补上对 self.cv_image / self.gray_image / self.total_mask 的初始化:

python
def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: # ---- OpenCV 读取原图(BGR) ---- self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return # 灰度图(用你自定义或直接 cvtColor 均可) self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape # 初始化 mask self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) # ---- 显示用的 QPixmap ---- self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 # 展示到主画布 canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) # 预览框先清空文字 self.preview1.setText("原图预览") self.preview1.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self.preview2.setPixmap(QPixmap()) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}")

注意:这里我们把 OpenCV 的图像与用于显示的 QPixmap 都保存了,各司其职。


5. 点击画布:做一次 floodFill 分割

新增一个槽函数 on_canvas_clicked,放在类里:

python
def on_canvas_clicked(self, x, y, button): """画布点击事件:左键做一次区域分割,更新两个预览框""" if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return # 只处理左键 if button != int(Qt.LeftButton): return # 将 label 坐标转换为图像坐标 img_pt = self._map_label_pos_to_image(x, y) if img_pt is None: # 点击在黑边区域,忽略 return ix, iy = img_pt # 防止越界 h, w = self.gray_image.shape if not (0 <= ix < w and 0 <= iy < h): return # 用高阈值滑块当做“容差”也可以,不想用就固定数字 tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20 # 做一次 floodfill 得到当前区域 mask(0/255) region_mask = self._segment_region((ix, iy), tolerance) if region_mask is None: return self.last_region_mask = region_mask # 累加到总 mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) # 更新右侧两个预览框 self._update_previews()

6. 坐标映射:把 QLabel 的点击点转换成原图坐标

非常关键的一步(不然 floodFill 会乱跑):

python
def _map_label_pos_to_image(self, x, y): """ 将在 canvas_label 上点击的 (x, y) 转换成原始图像坐标 (ix, iy) 考虑了缩放和居中黑边。 """ if self.cv_image is None: return None pixmap = self.canvas_label.pixmap() if pixmap is None: return None img_h, img_w = self.cv_image.shape[:2] pix_w = pixmap.width() pix_h = pixmap.height() lab_w = self.canvas_label.width() lab_h = self.canvas_label.height() if img_w == 0 or img_h == 0: return None # 图片在 Label 中居中后的偏移 offset_x = (lab_w - pix_w) // 2 offset_y = (lab_h - pix_h) // 2 # 如果点在黑边上,忽略 if not (offset_x <= x < offset_x + pix_w and offset_y <= y < offset_y + pix_h): return None # 转成图片内的坐标(相对到缩放后的像素) x_in_pix = x - offset_x y_in_pix = y - offset_y # 缩放比例(Qt 是按宽高等比缩放) scale_x = pix_w / img_w scale_y = pix_h / img_h # 理论上 scale_x == scale_y ix = int(x_in_pix / scale_x) iy = int(y_in_pix / scale_y) return ix, iy

7. floodFill 分割函数

从你原来的 fullfill_mask / mouse_callback 里“抽骨头”出来一个干净的版本:

python
def _segment_region(self, seed_point, tolerance): """ 根据灰度图 + 种子点做一次 floodFill,返回二值 mask(0/255) seed_point: (x, y),图像坐标 """ if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) # 使用 MASK_ONLY 模式,只填充 mask,不改原图 flags = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY # 注意:floodFill 的种子点是 (x, y) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), # 不改原图 mask, seedPoint=seed_point, newVal=0, # 不重要,MASK_ONLY 模式下不会用 loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] # 去掉边框 region = np.where(region > 0, 255, 0).astype(np.uint8) return region

8. 更新右侧预览框:局部图 + 掩码图

python
def _update_previews(self): """根据 last_region_mask 和 total_mask 更新 preview1 / preview2""" if self.cv_image is None: return # ---- preview1:当前分割区域的局部图像 ---- if self.last_region_mask is not None and np.any(self.last_region_mask): # 只保留区域内的像素 part = cv2.bitwise_and(self.cv_image, self.cv_image, mask=self.last_region_mask) pix1 = self._cvimg_to_qpixmap(part) if not pix1.isNull(): pix1 = pix1.scaled(self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview1.setPixmap(pix1) self.preview1.setText("") # 清掉文字 else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # ---- preview2:整体掩码图 ---- if self.total_mask is not None and np.any(self.total_mask): mask_vis = cv2.cvtColor(self.total_mask, cv2.COLOR_GRAY2BGR) pix2 = self._cvimg_to_qpixmap(mask_vis) if not pix2.isNull(): pix2 = pix2.scaled(self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览")

再加一个 OpenCV 图像 → QPixmap 的小工具函数:

python
def _cvimg_to_qpixmap(self, img): """将 OpenCV BGR/灰度图转换为 QPixmap""" if img is None: return QPixmap() if len(img.shape) == 2: # 灰度 → 伪 BGR img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg)

9. 其它:缩放逻辑 / resizeEvent

你的缩放和 resizeEvent 逻辑原来就有,可以保留。唯一要注意的是:

  • 每次你重新 setPixmapcanvas_label 的时候,点击坐标映射函数 _map_label_pos_to_image 会自动根据当前 pixmap 的尺寸重新算 offset 和 scale,所以缩放不会打乱坐标映射(我们就是这么设计的)。

使用效果(交互方式)

整合好之后,交互应该是:

  1. 在首页“打开文件…” → 选一张图片 → 自动跳转到图片操作页,主画布显示原图。
  2. 在主画布上用左键点一个位置
    • 上面的预览框:显示这一次分出来的那一块区域(彩色的小块)。
    • 下面的预览框:显示目前为止所有点击区域的总体掩码(黑白)。
  3. 继续点其它地方:
    • 上面的预览框更新为最后一次的那块;
    • 下面的预览框累积显示越来越多的白色区域。

如果你愿意,下一步我们可以再把你原来那堆 operation_of_blank / operation_of_save / skeletonize_* / dxf 也一点点搬进来(比如点击“生成”按钮时导出 DXF),但目前你提的需求——侧边两个预览框分别显示局部分割图 + 整体掩码图——按照上面这套就已经能实现了。

Question

image.png

没有显示分割点,也没有分割效果
import os
import cv2
import numpy as np

from PyQt5 import QtCore
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QIcon, QPixmap, QImage
from PyQt5.QtWidgets import (
QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout,
QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout
)

class ClickableLabel(QLabel):
clicked = pyqtSignal(int, int, int) # x, y, button

text
def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event)

class ImageOperationPage(QWidget):
def init(self, main_window):
super().init(main_window)
self.main_window = main_window

text
# ---- OpenCV / 分割相关状态 ---- self.cv_image = None # OpenCV 原图(BGR) self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次分割的区域 mask(H×W,0/255) self.total_mask = None # 所有分割累积的 mask(H×W,0/255) # 画布和预览框 self.canvas_label = ClickableLabel() # ★ 改成可点击的 Label self.preview1 = QLabel() self.preview2 = QLabel() # 滑块等 self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None # 缩放 self.zoom_scale = 1.0 self.original_pixmap = None self.initUI() # 连接点击信号 self.canvas_label.clicked.connect(self.on_canvas_clicked) def initUI(self): # ========== 整体布局 ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) # 统一一点的边距 self.main_layout.setSpacing(4) # ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域 self.preview1.setText("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2.setText("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区:使用 QFormLayout 让标签对齐更规整 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # -------- 图片加载与缩放 -------- def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: # ---- OpenCV 读取原图(BGR) ---- self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return # 灰度图(用你自定义或直接 cvtColor 均可) self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape # 初始化 mask self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) # ---- 显示用的 QPixmap ---- self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 # 展示到主画布 canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) # 预览框先清空文字 self.preview1.setText("原图预览") self.preview1.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self.preview2.setPixmap(QPixmap()) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") def _map_label_pos_to_image(self, x, y): """ 将在 canvas_label 上点击的 (x, y) 转换成原始图像坐标 (ix, iy) 考虑了缩放和居中黑边。 """ if self.cv_image is None: return None pixmap = self.canvas_label.pixmap() if pixmap is None: return None img_h, img_w = self.cv_image.shape[:2] pix_w = pixmap.width() pix_h = pixmap.height() lab_w = self.canvas_label.width() lab_h = self.canvas_label.height() if img_w == 0 or img_h == 0: return None # 图片在 Label 中居中后的偏移 offset_x = (lab_w - pix_w) // 2 offset_y = (lab_h - pix_h) // 2 # 如果点在黑边上,忽略 if not (offset_x <= x < offset_x + pix_w and offset_y <= y < offset_y + pix_h): return None # 转成图片内的坐标(相对到缩放后的像素) x_in_pix = x - offset_x y_in_pix = y - offset_y # 缩放比例(Qt 是按宽高等比缩放) scale_x = pix_w / img_w scale_y = pix_h / img_h # 理论上 scale_x == scale_y ix = int(x_in_pix / scale_x) iy = int(y_in_pix / scale_y) return ix, iy def _update_previews(self): """根据 last_region_mask 和 total_mask 更新 preview1 / preview2""" if self.cv_image is None: return # ---- preview1:当前分割区域的局部图像 ---- if self.last_region_mask is not None and np.any(self.last_region_mask): # 只保留区域内的像素 part = cv2.bitwise_and(self.cv_image, self.cv_image, mask=self.last_region_mask) pix1 = self._cvimg_to_qpixmap(part) if not pix1.isNull(): pix1 = pix1.scaled(self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview1.setPixmap(pix1) self.preview1.setText("") # 清掉文字 else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # ---- preview2:整体掩码图 ---- if self.total_mask is not None and np.any(self.total_mask): mask_vis = cv2.cvtColor(self.total_mask, cv2.COLOR_GRAY2BGR) pix2 = self._cvimg_to_qpixmap(mask_vis) if not pix2.isNull(): pix2 = pix2.scaled(self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") def _cvimg_to_qpixmap(self, img): """将 OpenCV BGR/灰度图转换为 QPixmap""" if img is None: return QPixmap() if len(img.shape) == 2: # 灰度 → 伪 BGR img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _segment_region(self, seed_point, tolerance): """ 根据灰度图 + 种子点做一次 floodFill,返回二值 mask(0/255) seed_point: (x, y),图像坐标 """ if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) # 使用 MASK_ONLY 模式,只填充 mask,不改原图 flags = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY # 注意:floodFill 的种子点是 (x, y) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), # 不改原图 mask, seedPoint=seed_point, newVal=0, # 不重要,MASK_ONLY 模式下不会用 loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] # 去掉边框 region = np.where(region > 0, 255, 0).astype(np.uint8) return region def on_canvas_clicked(self, x, y, button): """画布点击事件:左键做一次区域分割,更新两个预览框""" if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return # 只处理左键 if button != int(Qt.LeftButton): return # 将 label 坐标转换为图像坐标 img_pt = self._map_label_pos_to_image(x, y) if img_pt is None: # 点击在黑边区域,忽略 return ix, iy = img_pt # 防止越界 h, w = self.gray_image.shape if not (0 <= ix < w and 0 <= iy < h): return # 用高阈值滑块当做“容差”也可以,不想用就固定数字 tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20 # 做一次 floodfill 得到当前区域 mask(0/255) region_mask = self._segment_region((ix, iy), tolerance) if region_mask is None: return self.last_region_mask = region_mask # 累加到总 mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) # 更新右侧两个预览框 self._update_previews() def zoom_in_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可放大") return self.zoom_scale = min(self.zoom_scale + 0.1, 3.0) self._update_zoom_image() def zoom_out_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可缩小") return self.zoom_scale = max(self.zoom_scale - 0.1, 0.1) self._update_zoom_image() def _update_zoom_image(self): if not self.original_pixmap: return canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return target_width = int(canvas_size.width() * self.zoom_scale) target_height = int(canvas_size.height() * self.zoom_scale) scaled_pixmap = self.original_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter) def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(200) self.low_thresh_slider.setValue(100) self.extra_slider.setValue(50) self.zoom_scale = 1.0 if self.original_pixmap: self._update_zoom_image() def resizeEvent(self, event): if self.original_pixmap: canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) super().resizeEvent(event)

Réflexion

Revisiting floodFill Arguments and Mask Sizes

When working with cv2.floodFill, we’re using a tolerance as both loDiff and upDiff. These values determine the intensity difference, and I know that for a gray image, 20 is acceptable, but 200 might be too much. There’s a potential bug with the mask size: floodFill outputs a (h+2, w+2) mask, so I need to slice it correctly. The preview step looks okay since the mask matches the image size.

Identifying potential issues with preview display

The issue with no visible difference in preview1 might be because the region selected is mostly black, with the click on white, and the tolerance being too small. It could also be a problem with the seed mapping, where the (ix, iy) could be reversed. But, the bounds are correct. The resize event seems fine too, as it occurs after setting the pixmap.

Investigating preview mismatches and seed point display

It looks like preview1 is showing the full original logo right from the start, which makes me think the _update_previews function may be getting triggered without a click. The issue about the seed points not showing up makes sense too, since we never alter the canvas pixmap—only the previews. Could be a version mismatch or an issue with the scaling.

Addressing potential issues with canvas click and preview updates

To identify and fix the bug, I’ll first add logging to on_canvas_clicked to track whether it’s triggered and get the clicked coordinates. Next, I’ll simplify by temporarily painting a red circle into total_mask instead of using floodFill for a quick visual check. If preview2 still doesn’t show, the issue likely lies with the mapping or preview update. Additionally, I’ll ensure mouse tracking is enabled and avoid any misunderstandings with button comparison by correctly casting the signal.

Dissecting logical bug with preview1 and floodFill issue

The main issue seems to be the initial state of preview1, which in some previous version shows the logo. However, resizing the window causes issues with the canvas pixmap size, and the mapping state isn’t updated. floodFill might be using too high a tolerance, affecting the region. Additionally, preview2 remains blank, which suggests problems related to mask resetting.

Identifying floodFill issue and mask misassignment

The problem comes from a mis-assigned return value in the floodFill function. We’re mistakenly treating the third return as the rectangle (_rect) instead of mask. While we're modifying the mask array in place, issues might also arise from pixel data type or grayscale differences, causing unexpected results.

Debugging segmentation and preview issues

We’re facing a few bugs, including not seeing segmentation effects on the main canvas. We haven't drawn the seed point, and previews may have changed unnoticed. I'll add logging to track clicks, simplify the segmentation, and adjust tolerance values. The issue with preview2 showing blank white could stem from improper masking, where the entire image’s mask might be filled. I'll dive deeper to make sure all connections are working properly.

Thought for 2m 19s

Réponse

好消息:你的代码现在逻辑上已经能跑通 floodFill 了,问题主要有两点:

  1. 画布上完全看不到“分割点 / 分割区域”——因为我们根本没在大画布上画任何东西,只更新了右侧预览;
  2. 右侧预览有没有变化现在也比较难看出来(尤其是整个区域被填满时,看起来就像一张纯白图)。

下面我直接给你最小改动版,做到:

  • 在大画布上显示分割效果(用红色覆盖被分割区域);
  • 在右侧预览框里:
    • 上面的预览框显示当前这次点击得到的区域;
    • 下面的预览框显示累计的整体掩码。

你只需要在现有 ImageOperationPage加几段函数 + 改几行即可,布局完全不动。


1. 在画布上叠加分割效果

新增一个函数:根据 total_mask 重新绘制主画布

放到 ImageOperationPage 类里面(比如 _update_previews 下面):

python
def _update_canvas_with_overlay(self): """在主画布上叠加 total_mask 显示分割效果""" if self.cv_image is None: return # 底图:原图 base = self.cv_image.copy() # 有整体掩码时,用红色半透明覆盖 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) # 红色区域表示已分割区域 color_mask[self.total_mask > 0] = (0, 0, 255) # BGR (蓝,绿,红) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) # 转成 QPixmap 并缩放到画布大小 pix = self._cvimg_to_qpixmap(base) pix = pix.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(pix)

2. 在合适的时机调用它

2.1 在 load_image 末尾,用它替代原来直接 setPixmap 的代码

原来的:

python
# 展示到主画布 canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap)

改成:

python
# 初始化时也走统一的画布更新逻辑 self._update_canvas_with_overlay()

2.2 在 _update_previews 最后,也顺手更新一下主画布

_update_previews 函数最后加一行:

python
# ...原来 preview1 / preview2 的更新逻辑结束后... # 更新主画布叠加效果 self._update_canvas_with_overlay()

3. 点击之后,确保一定会更新画布 & 预览

你的 on_canvas_clicked 已经写得没问题,最后一行现在是:

python
# 更新右侧两个预览框 self._update_previews()

这个可以保持不变——因为我们刚才在 _update_previews 里又调用了 _update_canvas_with_overlay()
这样每次点击:

  • self.last_region_mask 更新为本次区域;
  • self.total_mask 累加;
  • _update_previews() 更新两个预览框;
  • _update_canvas_with_overlay() 重新画主画布(叠加红色区域)。

4. 再确认几点容易踩坑的小细节

  1. 点击一定要点在图案上,不要点黑边

    • 黑边区域 _map_label_pos_to_image 会直接返回 None,点击被忽略;
    • 你可以试着点在红字上 / 圆环边缘附近的区域。
  2. 把“高阈值”滑块拉小一点试试
    我现在用的是:

    python
    tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20

    初始值是 200,有可能一点击就把整张图都吃掉;
    可以手动把滑块滑到 10–40 附近再点几下,效果会更清晰。

    如果你想先固定一个值测试,也可以直接写死:

    python
    tolerance = 20
  3. 现在不会显示“种子点的小圆点”
    我们现在是直接给整个区域上色。如果你还想在主画布上显示一个小点,
    可以在 _update_canvas_with_overlay 里记录最近点击的 (ix, iy),再画个小圆圈:

    python
    # 在 ImageOperationPage.__init__ 里 self.last_click_pt = None

    on_canvas_clicked 里:

    python
    self.last_click_pt = (ix, iy)

    然后在 _update_canvas_with_overlay 里:

    python
    if self.last_click_pt is not None: cx, cy = self.last_click_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) # 绿色小圆点

5. 你可以快速验证一下

  1. 回到首页,重新“打开文件”,跳到图片页面;

  2. 把“高阈值”滑块拉到偏小的位置(比如靠左 10–30);

  3. 在主画布上用鼠标左键点几下红色图案区域:

    • 主画布:对应区域会有红色半透明覆盖;
    • 上方预览:显示刚刚点击出来的那块;
    • 下方预览:显示累积的整体掩码(白色区域)。

如果还有“点击没反应”的情况,可以在 on_canvas_clicked 第一行加一句:

python
print("clicked:", x, y, button)

看看命令行里有没有打印;
如果有打印,说明信号没问题,问题就只剩下 floodFill 或显示逻辑;
如果没有打印,就是鼠标事件没到 label,我们再顺着那条线查。

你先按上面几步把 _update_canvas_with_overlay 接上试一次,有新现象(比如某些区域被全部染红 / 还是没反应),把现象描述一下,我再帮你把分割细节调精一点。

Question

image.png

标记的点不对,而且菜单栏的布局出现问题,标记后整个图变为红色,没有安给出的逻辑进行整合

Réflexion

Debugging overlay and click mapping issues

I need to address the green dot appearing off-center. It might be related to the difference in how the cv_image and the original image are scaled. Adjusting the seed mapping and flood fill tolerance should fix the area flooding. I’ll also make sure the overlay colors match the expected zones, ensuring correct region detection!

Fixing zoom and image scaling issues

It seems the mismatch is happening because I’m using different image formats for the overlay and zoomed version. I need to ensure the cv_image and original pixmap use the same scaling logic, as the zoom might be misaligned. Reducing tolerance for floodFill should help avoid overfilling the image. I’ll set a test tolerance to 20 for now.

Addressing image scaling and click mismatches

The issue seems to be the green dot being misaligned due to mismatched scaling between the image and label. I should disable zoom effects for now by setting a fixed zoom scale and modifying the overlay to match the original image's scaling. I’ll also add debug prints to track the click coordinates and mapped coordinates for clarity.

Fixing mapping and click mark issues

The dot appearing above the shoulder in the screenshot could mean the user clicked lower — hard to tell for sure. I’ll simplify things by storing the scale and offsets when drawing the pixmap, instead of recalculating them each time. This should help ensure the mapping is consistent, and the green dot appears at the correct location.

Addressing dot placement and layout issues

The green dot mismatch may be caused by layout scaling, especially with the canvas and overlay not syncing up after resizing. To fix this, I’ll temporarily disable scaling in the _update_canvas_with_overlay function for testing. I’ll adjust the scaling to maintain consistent offsets, using the label’s dimensions and ensuring proper centering for accurate mapping.

Investigating black margins and dot placement

The black margins occur due to a label background and pixmap scaling with the aspect ratio. The issue might stem from treating coordinates as (x, y) instead of (col, row) in the circle placement. It appears the tolerance was too high, turning the entire image red. I'll fix that with a lower tolerance value. I'll also suggest using the grayscale image for flood filling; this should help with precise segmentation. For the preview, I'll scale and assign the pixmap as requested.

Summary of changes and segmentation fix

To address the segmentation issue:

  1. Adjust the tolerance line in on_canvas_clicked.
  2. Modify _segment_region to show region threshold relative to the seed value, not fixed.
  3. Update _update_canvas_with_overlay to keep the red color only for the region_mask, and avoid repainting the entire image.

For the flood fill, ensure the use of correct flags and a smaller tolerance for grayscale. Finally, use the scale and offsets consistently for correct dot placement.

Thought for 47s

Réponse

好,照片一看就知道现在发生了两件事:

  1. 点是画出来了(绿色小点),但你感觉位置不太对;
  2. 一点击,整张图都变成了红色,右侧掩码框也是“满白”的——说明 floodFill 把整张图都吃掉了。

菜单栏只是被你放大/缩小时一起挤了点位置,本质没坏,我们先把分割逻辑调对。


一、为什么整张图都变红了?

现在这两行是关键:

python
tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20 region_mask = self._segment_region((ix, iy), tolerance)

high_thresh_slider 的范围是 0–255,初始值 200。
也就是说你现在是以 200 灰度容差 在做 floodFill:

“灰度相差在 ±200 以内的像素都算同一块”。

对于你的那张淡粉底图,整张图灰度其实都差不多,
所以 floodFill 直接“从一点蔓延到整个画面”,region_mask ≈ 全 255,
于是:

  • total_mask 也变成全 255;
  • _update_canvas_with_overlay 覆盖整张图一层红色;
  • 右下预览掩码也变成全白,看起来就“没分割效果”。

先把容差调小,整个逻辑就正常多了。


二、先修正 floodFill 的参数和容差

1. 把容差从 slider 映射到一个小一点的值

on_canvas_clicked 里,改这一段:

python
# 原来 tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20

改成类似这样(把 0–255 压缩到 1–32 左右):

python
raw = self.high_thresh_slider.value() if self.high_thresh_slider else 20 tolerance = max(1, raw // 8) # 0–255 -> 1–31

或者为了测试简单,直接先写死一个小值:

python
tolerance = 10

2. floodFill flags 带上填充值位(更稳)

_segment_region 里,把 flags 改一改:

python
def _segment_region(self, seed_point, tolerance): if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) # ★ 带上 255<<8,表示在 mask 内填充值为 255 flags = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY | (255 << 8) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), mask, seedPoint=seed_point, newVal=0, # MASK_ONLY 模式下不会改原图 loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] # 去掉边框 region = np.where(region > 0, 255, 0).astype(np.uint8) return region

这样 region 里就是 0 / 255,很鲜明。


三、让主画布只高亮分割区域(不是整张底色)

你现在 _update_canvas_with_overlay 是按 total_mask > 0 把整张图叠红的,这个逻辑没错,但在容差太大时就会“全红”。

当容差调小后,它就只会在你点击的那块区域上红一片——符合你“局部高亮”的预期。

你如果还没加 _update_canvas_with_overlay,可以用这个版本(直接放进类里):

python
def _update_canvas_with_overlay(self): """在主画布上叠加 total_mask 显示分割效果""" if self.cv_image is None: return base = self.cv_image.copy() # 整体掩码区域用红色半透明高亮 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) # 红色(BGR) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) pix = self._cvimg_to_qpixmap(base) pix = pix.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(pix)

然后:

  • load_image 里,用它替换掉原来那段 canvas_pixmap = ...

    python
    # 原来的 setPixmap 注释掉,改成: self._update_canvas_with_overlay()
  • _update_previews 末尾加一行:

    python
    self._update_canvas_with_overlay()

这样每次点击、每次掩码更新,画布都会重新叠加一次红色区域。


四、关于“标记点不对”和菜单栏的问题

1. 标记点偏移

你截图里的绿色小点,其实说明:

  • click 信号确实发出来了;
  • _map_label_pos_to_image(ix, iy) 没报错;
  • 我们在 OpenCV 图上画了点,又按当前 canvas 大小缩放显示。

如果你觉得点和鼠标有轻微偏差,最常见的是画布被你放大缩小过(zoom_in / zoom_out),而 _map_label_pos_to_image 还按“未缩放时的比例”在算。

最简单的方案:在还没搞定坐标映射前,先暂时关闭缩放按钮的功能

python
def zoom_in_image(self): # 暂时先不缩放,避免映射乱掉 self.main_window._show_error提示("提示", "先把分割功能调好,再启用缩放") return

等分割效果完全符合预期,再来精细调整“缩放 + 坐标”的联动。

如果你确定自己没点过放大缩小,只是偏一点点,那很可能只是主观感觉,
数学上我们现在的映射逻辑是对的:pixmap how-big、label how-big 全都考虑进去了。


2. 菜单栏布局“怪怪的”

菜单栏 / 工具栏的布局,我们没动,只是你在放大缩小窗口时:

  • 上方整条菜单 + 工具栏是水平撑满的;
  • 下面主区域左右分栏,中间图片变大后,看起来菜单栏“被压扁”了一点。

这个属于正常自适应行为
如果后面你对 UI 有更精细的需求,可以再做专门的布局调整,但和现在的分割逻辑是独立的。


五、你可以这样一步测试

  1. 把上面两处修改(容差 + floodFill flags)先改上;

  2. 把缩放功能临时禁用,避免坐标映射干扰;

  3. 重启程序,选一张图 → 跳到操作页;

  4. 把“高阈值”滑块拉到左边(小一点);

  5. 在人物的衣服、脸、文字上各点几下:

    • 主画布上,这些地方会出现局部红色高亮区域
    • 上方预览:显示你最后一次点击对应的那一块;
    • 下方预览:显示目前所有区域的整体掩码(白色区域)。

等你反馈一下现在的效果是:

  • 仍然“全红”;还是
  • 已经能看到局部区域了,但范围太小/太碎

我再帮你把分割参数(容差 + 预处理)往你想要的方向再细调一轮 👌

Question

import os
import cv2
import numpy as np

from PyQt5 import QtCore
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QIcon, QPixmap, QImage
from PyQt5.QtWidgets import (
QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout,
QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout
)

class ClickableLabel(QLabel):
clicked = pyqtSignal(int, int, int) # x, y, button

text
def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event)

class ImageOperationPage(QWidget):
def init(self, main_window):
super().init(main_window)
self.main_window = main_window

text
# ---- OpenCV / 分割相关状态 ---- self.cv_image = None # OpenCV 原图(BGR) self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次分割的区域 mask(H×W,0/255) self.total_mask = None # 所有分割累积的 mask(H×W,0/255) # 画布和预览框 self.canvas_label = ClickableLabel() # ★ 改成可点击的 Label self.preview1 = QLabel() self.preview2 = QLabel() # 滑块等 self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self.last_click_pt = None # 缩放 self.zoom_scale = 1.0 self.original_pixmap = None self.initUI() # 连接点击信号 self.canvas_label.clicked.connect(self.on_canvas_clicked) def initUI(self): # ========== 整体布局 ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) # 统一一点的边距 self.main_layout.setSpacing(4) # ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet("background-color:#333333;color: #ffffff; font-size: 15px") # 文件菜单 file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") # 编辑菜单 edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") # 图像菜单 image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") # 图层菜单 layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") # 文字菜单 text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") # 选择菜单 select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") # 视图菜单 view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") # 选项菜单 options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") # 帮助菜单 help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域 self.preview1.setText("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2.setText("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区:使用 QFormLayout 让标签对齐更规整 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(200) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(100) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # -------- 图片加载与缩放 -------- def load_image(self, file_path): """核心方法:加载首页选择的图片,展示在画布和预览区""" if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: # ---- OpenCV 读取原图(BGR) ---- self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return # 灰度图(用你自定义或直接 cvtColor 均可) self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape # 初始化 mask self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) # ---- 显示用的 QPixmap ---- self.original_pixmap = QPixmap(file_path) self.current_image_path = file_path self.zoom_scale = 1.0 # 展示到主画布 self._update_canvas_with_overlay() # 预览框先清空文字 self.preview1.setText("原图预览") self.preview1.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self.preview2.setPixmap(QPixmap()) self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") def _map_label_pos_to_image(self, x, y): """ 将在 canvas_label 上点击的 (x, y) 转换成原始图像坐标 (ix, iy) 考虑了缩放和居中黑边。 """ if self.cv_image is None: return None pixmap = self.canvas_label.pixmap() if pixmap is None: return None img_h, img_w = self.cv_image.shape[:2] pix_w = pixmap.width() pix_h = pixmap.height() lab_w = self.canvas_label.width() lab_h = self.canvas_label.height() if img_w == 0 or img_h == 0: return None # 图片在 Label 中居中后的偏移 offset_x = (lab_w - pix_w) // 2 offset_y = (lab_h - pix_h) // 2 # 如果点在黑边上,忽略 if not (offset_x <= x < offset_x + pix_w and offset_y <= y < offset_y + pix_h): return None # 转成图片内的坐标(相对到缩放后的像素) x_in_pix = x - offset_x y_in_pix = y - offset_y # 缩放比例(Qt 是按宽高等比缩放) scale_x = pix_w / img_w scale_y = pix_h / img_h # 理论上 scale_x == scale_y ix = int(x_in_pix / scale_x) iy = int(y_in_pix / scale_y) return ix, iy def _update_previews(self): """根据 last_region_mask 和 total_mask 更新 preview1 / preview2""" if self.cv_image is None: return # ---- preview1:当前分割区域的局部图像 ---- if self.last_region_mask is not None and np.any(self.last_region_mask): # 只保留区域内的像素 part = cv2.bitwise_and(self.cv_image, self.cv_image, mask=self.last_region_mask) pix1 = self._cvimg_to_qpixmap(part) if not pix1.isNull(): pix1 = pix1.scaled(self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview1.setPixmap(pix1) self.preview1.setText("") # 清掉文字 else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # ---- preview2:整体掩码图 ---- if self.total_mask is not None and np.any(self.total_mask): mask_vis = cv2.cvtColor(self.total_mask, cv2.COLOR_GRAY2BGR) pix2 = self._cvimg_to_qpixmap(mask_vis) if not pix2.isNull(): pix2 = pix2.scaled(self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._update_canvas_with_overlay() def _cvimg_to_qpixmap(self, img): """将 OpenCV BGR/灰度图转换为 QPixmap""" if img is None: return QPixmap() if len(img.shape) == 2: # 灰度 → 伪 BGR img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _segment_region(self, seed_point, tolerance): """ 根据灰度图 + 种子点做一次 floodFill,返回二值 mask(0/255) seed_point: (x, y),图像坐标 """ if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) # 使用 MASK_ONLY 模式,只填充 mask,不改原图 flags = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY # 注意:floodFill 的种子点是 (x, y) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), # 不改原图 mask, seedPoint=seed_point, newVal=0, # 不重要,MASK_ONLY 模式下不会用 loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] # 去掉边框 region = np.where(region > 0, 255, 0).astype(np.uint8) return region def on_canvas_clicked(self, x, y, button): """画布点击事件:左键做一次区域分割,更新两个预览框""" self.last_click_pt = (x, y) if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return # 只处理左键 if button != int(Qt.LeftButton): return # 将 label 坐标转换为图像坐标 img_pt = self._map_label_pos_to_image(x, y) if img_pt is None: # 点击在黑边区域,忽略 return ix, iy = img_pt # 防止越界 h, w = self.gray_image.shape if not (0 <= ix < w and 0 <= iy < h): return # 用高阈值滑块当做“容差”也可以,不想用就固定数字 # tolerance = self.high_thresh_slider.value() if self.high_thresh_slider else 20 tolerance = 10 # 做一次 floodfill 得到当前区域 mask(0/255) region_mask = self._segment_region((ix, iy), tolerance) if region_mask is None: return self.last_region_mask = region_mask # 累加到总 mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) # 更新右侧两个预览框 self._update_previews() def _update_canvas_with_overlay(self): """在主画布上叠加 total_mask 显示分割效果""" if self.cv_image is None: return # 底图:原图 base = self.cv_image.copy() if self.last_click_pt is not None: cx, cy = self.last_click_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) # 绿色小圆点 # 有整体掩码时,用红色半透明覆盖 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) # 红色区域表示已分割区域 color_mask[self.total_mask > 0] = (0, 0, 255) # BGR (蓝,绿,红) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) # 转成 QPixmap 并缩放到画布大小 pix = self._cvimg_to_qpixmap(base) pix = pix.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(pix) def zoom_in_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可放大") return self.zoom_scale = min(self.zoom_scale + 0.1, 3.0) self._update_zoom_image() def zoom_out_image(self): if not self.original_pixmap: self.main_window._show_error提示("提示", "暂无图片可缩小") return self.zoom_scale = max(self.zoom_scale - 0.1, 0.1) self._update_zoom_image() def _update_zoom_image(self): if not self.original_pixmap: return canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return target_width = int(canvas_size.width() * self.zoom_scale) target_height = int(canvas_size.height() * self.zoom_scale) scaled_pixmap = self.original_pixmap.scaled( target_width, target_height, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(scaled_pixmap) self.canvas_label.setAlignment(Qt.AlignCenter) def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(200) self.low_thresh_slider.setValue(100) self.extra_slider.setValue(50) self.zoom_scale = 1.0 if self.original_pixmap: self._update_zoom_image() def resizeEvent(self, event): if self.original_pixmap: canvas_pixmap = self.original_pixmap.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(canvas_pixmap) super().resizeEvent(event)给出一份修正后完整的代码

Réflexion

Fixing code with segmentation integration

The user needs a corrected version of the code where segmentation is integrated properly. I’ll simplify things by removing the zoom scale and sticking to drawing overlays directly on the canvas. The zoom functions can remain disabled for now, as segmentation is the priority. I'll ensure the update focuses on this.

Finalizing class code with segmentation and overlays

I’ll provide the full class code to implement the requirements: preview1 for the current region, preview2 for the full mask, and main canvas showing the original image with overlays. I’ll adjust the resize event and manage click coordinates correctly, ensuring the overlays and zoom work properly with segmentation.

Simplifying zoom and segmentation for consistency

To keep things consistent, I'll stick to using a zoom scale factor applied during overlay creation, instead of relying on the original_pixmap. I’ll stub the zoom in/out functionality for now, with a simple message. The main focus will be to craft a single code snippet for ImageOperationPage and ClickableLabel, along with a brief explanation. I'll also update the segment region flags.

Finalizing code with adjustments for tolerance and zoom

I'll update the code based on the described changes. Specifically, I'll remove the original_pixmap and zoom functions or simplify them by tying the zoom_scale to a scale factor. I'll also ensure the overlay uses the correct cv_image and mask, and handle edge cases like

Thought for 9s

Réponse

下面是一份整理好、可以直接替换的完整代码(含 ClickableLabel + ImageOperationPage),已经把:

  • floodFill 容差缩小(不会一下子把整张图吃掉)
  • 使用 total_mask 叠加红色半透明区域到主画布
  • preview1 显示当前分割块,preview2 显示累计掩码
  • 点击位置用绿色点标在画布上(按图像坐标,不会因为窗口缩放而跑偏)
  • 缩放按钮先保留接口,但暂时不真正缩放,避免和分割坐标打架

你可以直接把你现在的 ImageOperationPage 整个文件内容替换成下面这份(保持其他文件不变):

python
import os import cv2 import numpy as np from PyQt5 import QtCore from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap, QImage from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout ) class ClickableLabel(QLabel): """可以发出点击信号的 QLabel""" clicked = pyqtSignal(int, int, int) # x, y, button def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event) class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # ---- OpenCV / 分割相关状态 ---- self.cv_image = None # OpenCV 原图(BGR) self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次分割区域 mask(H×W,0/255) self.total_mask = None # 所有分割累积 mask(H×W,0/255) self.last_click_img_pt = None # 最近一次点击的图像坐标 (ix, iy) # 画布和预览框 self.canvas_label = ClickableLabel() self.preview1 = QLabel() self.preview2 = QLabel() # 滑块等 self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self.initUI() # 连接点击信号 self.canvas_label.clicked.connect(self.on_canvas_clicked) # ====================== UI 布局 ====================== def initUI(self): # ========== 整体布局 ========== self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) self.main_layout.setSpacing(4) # ========== 菜单栏 ========== menubar = QMenuBar() menubar.setStyleSheet( "background-color:#333333;color: #ffffff; font-size: 15px" ) file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ========== 工具栏 ========== toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color: #ffffff !important; font-size: 14px !important; padding: 4px 6px !important; } QLabel { color: #ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self.zoom_in_image) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self.zoom_out_image) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() # 滤镜下拉框 toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color: #ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ========== 主工作区 + 右侧面板 ========== splitter = QSplitter(Qt.Horizontal) # ---- 左侧主工作区 ---- main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # ---- 右侧控制面板 ---- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览区域 self.preview1.setText("原图预览") self.preview1.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2.setText("处理后预览") self.preview2.setStyleSheet("background-color: #f0f0f0; border: 1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块区 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(40) # 初始值小一点 form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(10) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮区 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color: #2196F3; color: white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # ====================== 图片加载 ====================== def load_image(self, file_path): """从首页传入路径,加载图片并初始化 mask""" if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return valid_ext = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"] if os.path.splitext(file_path)[1].lower() not in valid_ext: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None # 预览框重置 self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._update_canvas_with_overlay() self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") # ====================== 坐标映射 ====================== def _map_label_pos_to_image(self, x, y): """将 canvas_label 上的 (x, y) 转为原图坐标 (ix, iy)""" if self.cv_image is None: return None pixmap = self.canvas_label.pixmap() if pixmap is None: return None img_h, img_w = self.cv_image.shape[:2] pix_w = pixmap.width() pix_h = pixmap.height() lab_w = self.canvas_label.width() lab_h = self.canvas_label.height() if img_w == 0 or img_h == 0: return None # 图片在 Label 中居中后的偏移 offset_x = (lab_w - pix_w) // 2 offset_y = (lab_h - pix_h) // 2 # 黑边区域直接忽略 if not (offset_x <= x < offset_x + pix_w and offset_y <= y < offset_y + pix_h): return None x_in_pix = x - offset_x y_in_pix = y - offset_y scale_x = pix_w / img_w scale_y = pix_h / img_h # 理论上相等 ix = int(x_in_pix / scale_x) iy = int(y_in_pix / scale_y) return ix, iy # ====================== 分割 & 预览 ====================== def _segment_region(self, seed_point, tolerance): """根据灰度图 + 种子点做一次 floodFill,返回 0/255 mask""" if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) # MASK_ONLY + 在 mask 中用 255 填充 flags = (cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY | (255 << 8)) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), mask, seedPoint=seed_point, newVal=0, loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] region = np.where(region > 0, 255, 0).astype(np.uint8) return region def _cvimg_to_qpixmap(self, img): """OpenCV BGR/灰度 -> QPixmap""" if img is None: return QPixmap() if len(img.shape) == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _update_canvas_with_overlay(self): """主画布:原图 + 红色分割区域 + 绿色点击点""" if self.cv_image is None: return base = self.cv_image.copy() # 绿色点击点(用图像坐标) if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) # 红色叠加 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) pix = self._cvimg_to_qpixmap(base) pix = pix.scaled( self.canvas_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.canvas_label.setPixmap(pix) def _update_previews(self): """更新右侧两个预览框""" if self.cv_image is None: return # 当前区域 if self.last_region_mask is not None and np.any(self.last_region_mask): part = cv2.bitwise_and(self.cv_image, self.cv_image, mask=self.last_region_mask) pix1 = self._cvimg_to_qpixmap(part) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # 整体掩码 if self.total_mask is not None and np.any(self.total_mask): mask_vis = np.zeros_like(self.cv_image) mask_vis[self.total_mask > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") # 主画布一起更新 self._update_canvas_with_overlay() def on_canvas_clicked(self, x, y, button): """画布点击:左键做一次区域分割""" if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return if button != int(Qt.LeftButton): return img_pt = self._map_label_pos_to_image(x, y) if img_pt is None: return ix, iy = img_pt self.last_click_img_pt = (ix, iy) h, w = self.gray_image.shape if not (0 <= ix < w and 0 <= iy < h): return # 把 0–255 映射到一个比较小的容差(1–40 左右) raw = self.high_thresh_slider.value() if self.high_thresh_slider else 40 tolerance = max(1, raw // 6) region_mask = self._segment_region((ix, iy), tolerance) if region_mask is None: return self.last_region_mask = region_mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) self._update_previews() # ====================== 其它操作 ====================== def zoom_in_image(self): # 为避免和分割坐标打架,这里先不实现缩放,只给个提示 self.main_window._show_error提示("提示", "当前版本暂未实现与分割联动的缩放功能") def zoom_out_image(self): self.main_window._show_error提示("提示", "当前版本暂未实现与分割联动的缩放功能") def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(40) self.low_thresh_slider.setValue(10) self.extra_slider.setValue(50) # 清空分割结果 if self.gray_image is not None: h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None self._update_previews() def resizeEvent(self, event): # 窗口大小变化时,重新按当前大小绘制画布(带 overlay) self._update_canvas_with_overlay() super().resizeEvent(event)

使用建议:

  1. 运行后,先把“高阈值”滑块调小一点(比如 20–60);
  2. 在人物、文字等区域点击左键:
    • 画布上对应区域会叠加红色半透明,点击点标绿色小圆点;
    • 右上预览显示这次点击的局部图;
    • 右下预览显示所有点击累积后的整体掩码。

如果你想后面再把 skeleton / dxf 导出逻辑接进来,我们可以再在「生成」按钮里挂处理函数,一步步搬你原来的代码。

Question

存在每次点击的窗口都会抖动的问题

Réflexion

Fixing window shake from resizing

The window shakes due to resizing in resizeEvent, which triggers _update_canvas_with_overlay. The issue arises because the pixmap size slightly changes the canvas_label, causing reflows. To fix this, I should set setScaledContents(False) while keeping size policies, and draw the scaled image directly onto a QPixmap with the label size, preventing resizing.

Preventing layout jitter on canvas update

The issue is that scaling the pixmap causes a mismatch between its size and the label, leading to layout shifts. To fix this, I’ll create a base canvas pixmap that matches the label's size, then draw the scaled image centered on it. This way, the label and pixmap sizes stay consistent, preventing layout jitter. The alternative would be to override sizeHint, but this approach should work fine. I'll update _update_canvas_with_overlay accordingly.

Fixing vertical jitter and resizing behavior

The jitter is likely vertical, so I'll focus on updating _update_canvas_with_overlay while ensuring the label's size remains fixed. I'll remove unnecessary setMinimumSize but ensure the label uses QSizePolicy.Expanding. Additionally, setting setScaledContents(False) in initUI should prevent automatic rescaling. I’ll explain the cause and provide a patch for these changes.

Thought for 18s

Réponse

这个“窗口抖动”其实是 QLabel 在每次 setPixmap 的时候自己在缩放尺寸

  • 我们给 canvas_label 设置的 pixmap 有时候比 label 小(KeepAspectRatio),
  • QLabel 的 sizeHint 会跟着 pixmap 变,layout 重新计算 → 中间画布那一块高度/宽度微调 → 整体界面看起来在抖

解决思路:
让 label 的尺寸固定,只在 label 的“内部画布”上画图——也就是:

  1. 始终创建一个 和 label 一样大的 QPixmap 当背景;
  2. 把缩放后的图像画到这个背景中间;
  3. 再把这个“整块画布”设置给 label。

这样 label 的 sizeHint 永远等于自身大小,不会跟着图片大小跳来跳去,抖动就没了。

下面给你最小改动版补丁,只改三处:


1. 多 import 一个 QPainter

在文件顶部的 import 改成这样:

python
from PyQt5.QtGui import QIcon, QPixmap, QImage, QPainter

2. 禁用 QLabel 自己缩放内容

initUI 里设置 canvas_label 的属性(就在你设置 styleSheet 那里加一行):

python
self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) # ★ 关键:不要让 QLabel 按 pixmap 自动等比拉伸/影响大小 self.canvas_label.setScaledContents(False) self.canvas_label.setText("请从首页选择图片")

3. 重写 _update_canvas_with_overlay ——在固定画布上居中绘制

把你原来的 _update_canvas_with_overlay 整个替换成下面这个版本:

python
def _update_canvas_with_overlay(self): """在固定大小的画布上叠加 total_mask,避免窗口抖动""" if self.cv_image is None: return # 原图 + 绿色点 + 红色区域 base = self.cv_image.copy() # 绿色点击点(图像坐标) if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) # 红色叠加区域 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) # 转成 QPixmap img_pix = self._cvimg_to_qpixmap(base) # 当前 label 的大小(整个画布大小) canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return # 创建与 label 一样大的“底板” canvas_pix = QPixmap(canvas_size) canvas_pix.fill(Qt.black) # 把图像按比例缩放后,居中绘制到“底板”上 scaled_pix = img_pix.scaled( canvas_size, Qt.KeepAspectRatio, Qt.SmoothTransformation ) painter = QPainter(canvas_pix) x = (canvas_size.width() - scaled_pix.width()) // 2 y = (canvas_size.height() - scaled_pix.height()) // 2 painter.drawPixmap(x, y, scaled_pix) painter.end() # 最后一次性把整块画布设置给 label self.canvas_label.setPixmap(canvas_pix)

其余代码(load_image / _update_previews / 点击分割逻辑)保持不变即可。


这样改完之后,每次点击只是:

  • 在同一块固定大小的画布上重新绘制内容,
  • 不会改变 QLabel 的 sizeHint,layout 不会重新“挤一挤”,
  • 窗口就不会再抖了。

你可以直接把这三处改上跑一下,有问题(比如某些尺寸下第一次加载没居中)再告诉我,我们再一起把细节抛光。

Question

import math
from collections import OrderedDict
import ezdxf
import cv2
import os
import sys
from tkinter import Tk, filedialog
from skimage import morphology
from scipy.spatial import KDTree
import numpy as np
from tqdm import tqdm

TOLERANCE = 20
midcount = 50
lab_bins = None
file_bins = None
image = None
clone_image = None
seed_list = []
seed_points = []
total_Mask = None
display_image = None
skeletonize_mask = None
gray_image = None
proceed_mask = None
proceed_image = None
gray_image_new = None
dbscan_points = []
kdtree = None
COUNT = 0
closest = None
grid_dict = {}
grid_size = 3
tree = None

def grayscale_custom(image):
# 加权平均:Y = 0.299R + 0.587G + 0.114B
b, g, r = cv2.split(image)
gray = 0.114 * b + 0.587 * g + 0.299 * r
return gray.astype(np.uint8)

def fullfill_mask(image, point, tolerance, fill_flag, value):
if fill_flag == 0:
flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY
elif fill_flag == 1:
flag = cv2.FLOODFILL_FIXED_RANGE
mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag)
return mask

def mouse_callback(event, x, y, flags, param):
global clone_image, proceed_mask, gray_image, proceed_image, gray_image_new, seed_list, COUNT
if event == cv2.EVENT_LBUTTONDOWN or event == cv2.EVENT_RBUTTONDOWN:
proceed_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
proceed_image = clone_image.copy()
if event == cv2.EVENT_LBUTTONDOWN:
seed_points.append([x, y])
print(f"添加种子点: ({x}, {y})")
elif event == cv2.EVENT_RBUTTONDOWN:
# clone_image = image.copy()
if seed_points:
seed_points.pop()
print("回退一步种子点")
else:
print("无法回退")
if seed_points:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# color_x = seed_points[0][0]
# color_y = seed_points[0][1]
value = ((COUNT + 1) * 5) % 255
for point in seed_points:
mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
process_clone_image(point, mask)
proceed_mask = cv2.bitwise_or(proceed_mask, mask)
proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
gray_image_new = cv2.bitwise_or(gray_image_new, proceed_mask * value)

text
cv2.imshow("Image", proceed_image) else: cv2.imshow("Image", proceed_image)

def mask_process():

def process_clone_image(point, mask):
global proceed_image
mask = mask[1:-1, 1:-1]
# contour = cv2.Canny(mask,0,0)
contour, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(proceed_image, contour, -1, 255, thickness=2)
# clone_image = cv2.add(clone_image,contour)
cv2.circle(proceed_image, point, 1, (0, 255, 0), 3)

def choose_right_image():
global image, COUNT
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择石材图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
image_stone = cv2.imread(file_path)
image_size = (image.shape[1], image.shape[0])
image_stone = cv2.resize(image_stone, image_size)
return image_stone
# global COUNT
# folder_path = r"python_image/stone"
# # 获取文件夹中所有符合要求的图片文件
# valid_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"]
# image_files = [f for f in os.listdir(folder_path) if any(f.lower().endswith(ext) for ext in valid_extensions)]
#
# # 确保文件夹中有图片
# if len(image_files) == 0:
# print("没有找到有效的图片文件。")
# return None
#
# # 根据 COUNT 顺序选择图片
# image_file = image_files[COUNT % len(image_files)] # 保证不越界
# image_path = os.path.join(folder_path, image_file)
#
# # 读取图片
# image_stone = cv2.imread(image_path)
#
# # 调整大小与目标图像一致
# image_size = (image.shape[1], image.shape[0]) # 使用目标图像的大小
# image_stone = cv2.resize(image_stone, image_size)
#
#
# return image_stone

def grid_simplification_corner_point(points): # 为角点构建聚类矩阵
"""
使用每个网格单元的第一个点坐标作为代表点进行简化。
"""
global grid_dict, grid_size
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
# 创建一个字典来存储每个网格单元的第一个点 for i in range(len(points)): x, y = points[i] min_x, max_x = x - grid_size, x + grid_size min_y, max_y = y - grid_size, y + grid_size key = (min_x, max_x, min_y, max_y) if key not in grid_dict: grid_dict[key] = points[i] # 只记录第一个遇到的点 # 取出每个网格的代表点 # replaced_points = np.array([grid_dict[(grid_x[i], grid_y[i])] for i in range(len(points))]) # df = pd.DataFrame(replaced_points, columns=['x', 'y']) # df_unique = df.drop_duplicates(keep='first')

def replace_cornerpoints(points):
global grid_dict, grid_size
new_points = []
replacement_flags = []
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break # 计算当前点所属的网格的 key # 如果该网格内没有点,则将当前点存入字典 if not replace: new_points.append(pt) # 保留当前点 replacement_flags.append(0) # 将新的点集转换为 numpy 数组并返回 return np.array(new_points), np.array(replacement_flags)

def operation_of_blank():
global seed_points, total_Mask, proceed_mask, clone_image, image, COUNT, display_image, skeletonize_mask
if seed_points:
kernel = np.ones((3, 3), np.uint8)
# proceed_mask = cv2.erode(proceed_mask, kernel, iterations=1)
seed_list.append(seed_points[0])
seed_points = []
# image_stone = choose_right_image()
temp_mask = proceed_mask[1:-1, 1:-1] * 255
total_Mask = cv2.bitwise_or(total_Mask, proceed_mask)
# proceed_mask = cv2.erode(proceed_mask,kernel,iterations=1)
# skeletonize_mask = cv2.bitwise_or(skeletonize_mask, proceed_mask)
canny = cv2.Canny(proceed_mask * 255, 50, 100)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask, canny)
temp_mask = cv2.cvtColor(temp_mask, cv2.COLOR_GRAY2BGR)
# temp_mask = np.where(temp_mask == 255, image_stone, temp_mask)
# display_image = cv2.bitwise_or(display_image, temp_mask)

text
total_Mask_3 = (total_Mask[1:-1, 1:-1] * 255) total_Mask_3 = cv2.cvtColor(total_Mask_3, cv2.COLOR_GRAY2BGR) # # total_stone_image = np.where(total_Mask_3 == 255, image_stone, total_Mask_3) # clone_image = image.copy() clone_image = cv2.add(clone_image, total_Mask_3) COUNT += 1 # total_stone_image = total_stone_image.astype(np.uint8) cv2.imshow("stone_image", skeletonize_mask) # cv2.imshow("image2", gray_image_new) # cv2.imshow("Image1", clone_image)

def operation_of_save():
global total_Mask, gray_image_new, seed_list, skeletonize_mask, image, gray_image
kernel = np.ones((3, 3), np.uint8) # 视腐蚀的缝隙而定
if len(seed_list) > 1:
gray_image_new = cv2.equalizeHist(gray_image_new)
gray_image_new = gray_image_new[1:-1, 1:-1]
total_Mask = total_Mask[1:-1, 1:-1]
total_Mask_not = cv2.bitwise_not(total_Mask * 255)
# total_Mask_not_3 = cv2.cvtColor(total_Mask_not, cv2.COLOR_GRAY2BGR)
gray_image_new = cv2.add(gray_image_new, total_Mask_not)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask[1:-1, 1:-1], total_Mask_not)
skeletonize_mask = cv2.morphologyEx(skeletonize_mask, cv2.MORPH_CLOSE, kernel)
# skeletonize_mask_not = cv2.bitwise_not(skeletonize_mask*255)
# skeletonize_image = cv2.morphologyEx(skeletonize_mask_not, cv2.MORPH_CLOSE, kernel)
skeletonize_image = skeletonize_skimage(skeletonize_mask)
corners = cv2.goodFeaturesToTrack(skeletonize_image, maxCorners=200, qualityLevel=0.1, minDistance=10)
corners = corners.reshape(-1, 2)
# print(corners.shape)
if corners is not None:
for i in corners:
x, y = i.ravel()
cv2.circle(gray_image, (int(x), int(y)), 2, (0, 0, 0), -1)
# erode = cv2.erode(gray_image_new, kernel, iterations=1)
# gray_image_new = np.where(gray_image_new == 255, erode, gray_image_new)
# canny = cv2.Canny(gray_image_new, 0, 0)
# seed_list = list(set(seed_list))
# skeletonize_image = cv2.morphologyEx(skeletonize_image,cv2.MORPH_CLOSE,kernel)
grid_simplification_corner_point(corners)
cv2.imshow("12345", gray_image)
process_mask2(skeletonize_image)
# mask_porcess()
# skeletonize_skimage(total_Mask_not)

def distance(p1, p2):
return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def skeletonize_skimage(image):
# 执行骨架化
skeleton = morphology.skeletonize(image)
skeleton_uint8 = skeleton.astype(np.uint8) * 255
return skeleton_uint8

def cluster_points_with_core_points(input_points, eps):
global dbscan_points
clustered_points = []
for point in input_points:
neighbor = get_closest_neighbor(point, eps)
if neighbor is not None:
clustered_points.append(neighbor)
else:
clustered_points.append(tuple(point))
dbscan_points.extend(clustered_points)
clustered_points = list(OrderedDict.fromkeys(clustered_points))
# first_two_points = clustered_points[:2]
# clustered_points.extend(first_two_points)
return clustered_points

def get_closest_neighbor(point, eps):
global dbscan_points, closest
min_dist = float('inf')
if dbscan_points:
closest = None
for core_point in dbscan_points:
dist = distance(point, core_point)
if dist < min_dist and dist < eps:
min_dist = dist
closest = core_point

return closest

def mask_porcess():
global gray_image_new, seed_list
doc = ezdxf.new()
msp = doc.modelspace()
# dwg = svgwrite.Drawing("output.svg", size=("100%", "100%"))
for point in seed_list:
mask = fullfill_mask(gray_image_new, point, 0, 0, None)
contours, _ = cv2.findContours(mask[1:-1, 1:-1], cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
print(len(contours))
for contour in contours:
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# d = generate_svg_from_points(part_contourPoints)
# dwg.add(dwg.path(d=d, stroke="black", fill="none", stroke_width=1))
# points_py = [tuple(map(float, pt)) for pt in part_contourPoints]
# dwg.add(dwg.polyline(points=points_py, stroke="black", fill="none", stroke_width=1))
# dwg.save()
# part_contourPoints = rdp(part_contourPoints, epsilon=1)
part_contourPoints = cluster_points_with_core_points(part_contourPoints, 30)
# part_contourPoints = rdp(part_contourPoints, epsilon=1.0)
# part_contourPoints.append(part_contourPoints[0])
# msp.add_lwpolyline(part_contourPoints)
# for point in part_contourPoints:
# msp.add_point(point)
msp.add_open_spline(part_contourPoints)
# cv2.drawContours(proceed_mask, contours, 1, 255, thickness=2)

text
# print("添加成功") doc.saveas("nanna13.dxf")

def process_mask2(image):
global seed_list, midcount
kernel = np.ones((3, 3), np.uint8)
doc = ezdxf.new()
msp = doc.modelspace()
for point in tqdm(seed_list, desc="abstract"):
p_image = image.copy()
fullfill_mask(p_image, point, 0, 1, 255)
mask = fullfill_mask(p_image, point, 0, 0, None)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# corners = cv2.goodFeaturesToTrack(mask, maxCorners=200, qualityLevel=0.03, minDistance=10)
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
for contour in contours:
# for contour in tqdm(contours, desc="abstract"):
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# part_contourPoints = grid_simplification_first_point(part_contourPoints,10)
# part_contourPoints = visvalingam_whyatt_simplify(part_contourPoints,2)
# part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
part_contourPoints, replace_flags_1 = replace_cornerpoints(part_contourPoints)
for i in range(midcount):
part_contourPoints, replace_flags = get_midpoints(part_contourPoints, replace_flags_1)
replace_flags_1 = replace_flags
# for point in part_contourPoints:
# msp.add_point(point)
# part_contourPoints = rdp(part_contourPoints, epsilon=10)
part_contourPoints[:, 1] = -part_contourPoints[:, 1]
part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
msp.add_lwpolyline(part_contourPoints)
doc.saveas("dddd.dxf")

def euclidean_distance(p1, p2):
return np.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def get_midpoints(contour_points, replacement_flags_1):
midpoints = []
replacement_flags = []
num_points = len(contour_points)

text
for i in range(num_points): # 当前点和下一个点的索引,最后一个点与第一个点连接 p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue else: if replacement_flags_1[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2 midpoints.append(midpoint) replacement_flags.append(0) # dist = euclidean_distance(p1, p2) # if dist > 10: # num_points = int(np.floor(dist / 10)) # 向下取整,得到点的数量 # # 生成插入的点 # points = [p1 + i * (p2 - p1) / (num_points + 1) for i in range(1, num_points + 1)] # points = np.vstack([p1, points]) # points = np.vstack([points, p2]) # midpoints.extend(points) # # 计算两点的中点 # else: return np.array(midpoints), replacement_flags

def main():
global image, clone_image, total_Mask, display_image, skeletonize_mask, lab_bins, file_bins
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
if not file_path:
print("无法加载图像,请检查文件格式或路径。")
return

text
lab_bins = np.load("./Stone_Data/stone_store_color.npy").astype(np.float64) file_bins = np.load("./Stone_Data/stone_store_file.npy") tree = KDTree(lab_bins) # 此处对图片进行预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() total_Mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) skeletonize_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) display_image = np.zeros((image.shape[0], image.shape[1], 3), dtype=np.uint8) while True: cv2.imshow("Image", clone_image) cv2.imshow("total_mask", total_Mask * 255) cv2.setMouseCallback("Image", mouse_callback) key = cv2.waitKey(0) if key == ord(' '): operation_of_blank() elif key == ord('s'): operation_of_save() elif key == 27: break cv2.destroyAllWindows()

if name == "main":
main()
代码报错添加种子点: (347, 202)
Traceback (most recent call last):
File "D:\Computer_items\Pycharm\python_code\Project_Main.py", line 76, in mouse_callback
mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
File "D:\Computer_items\Pycharm\python_code\Project_Main.py", line 50, in fullfill_mask
mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
AttributeError: 'NoneType' object has no attribute 'shape'

Réflexion

Explaining gray_image initialization and usage

I’ll need to clarify that gray_image is None when it’s not initialized. We can compute it either with grayscale_custom(image) or cvtColor(). Then, gray_image_new should be initialized as zeros, ensuring it has the proper size and shape. For mouse callback functions, the gray image and mask need to be handled carefully while those values are updated.

Fixing global declarations and gray_image initialization

I need to ensure the global variables gray_image, gray_image_new, proceed_mask, and proceed_image are declared in the main function to avoid assigning values to locals. After doing some processing, I'll initialize gray_image with grayscale conversion and set up gray_image_new and proceed_mask with zeros, ensuring proper handling later.

Thought for 18s

Réponse

问题的根源很简单:
gray_image 一直是 None,所以在 fullfill_mask(gray_image, ...) 里一用到 image.shape 就直接炸了。

你在顶部把 gray_image = None 定义成了全局变量,但是在 main()从来没有给它赋值,而且 gray_image_new / proceed_mask / proceed_image 也是同样问题。


一、最关键的修改点

1. 补全 global 声明

main() 里,原来是:

python
def main(): global image, clone_image, total_Mask, display_image, skeletonize_mask, lab_bins, file_bins

改成(把用到的全局变量都列上):

python
def main(): global image, clone_image, total_Mask, display_image, skeletonize_mask global lab_bins, file_bins global gray_image, gray_image_new, proceed_mask, proceed_image

否则你在 main() 里给 gray_image 赋值,其实是给局部变量赋值,外面那一堆回调看到的 gray_image 还是 None


2. 初始化灰度图和中间变量

main() 里读取完图片、模糊之后,立刻初始化灰度图和各种 mask

python
# 此处对图片进行预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() # ★ 初始化灰度图(任选一种方式) # gray_image = grayscale_custom(image) gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # ★ 初始化 gray_image_new,尺寸要和 floodfill 的 mask 一致(+2) h, w = gray_image.shape gray_image_new = np.zeros((h + 2, w + 2), np.uint8) # ★ 初始化各种 mask / 中间图像 total_Mask = np.zeros((h + 2, w + 2), np.uint8) skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_image = clone_image.copy() display_image = np.zeros((h, w, 3), dtype=np.uint8)

这样:

  • mouse_callback 里的 fullfill_mask(gray_image, ...) 就能正常拿到 gray_image.shape
  • gray_image_new = cv2.bitwise_or(gray_image_new, proceed_mask * value) 也不会再对一个 None 做运算;
  • 后面 operation_of_save() 中对 gray_image_new 的一系列处理也都有合法的初始值。

二、补丁版 main()(可直接替换)

把你的 main() 用下面这段替换掉即可:

python
def main(): global image, clone_image, total_Mask, display_image, skeletonize_mask global lab_bins, file_bins global gray_image, gray_image_new, proceed_mask, proceed_image root = Tk() root.withdraw() file_path = filedialog.askopenfilename( title="选择图片文件", filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")] ) if not file_path: print("无法加载图像,请检查文件格式或路径。") return lab_bins = np.load("./Stone_Data/stone_store_color.npy").astype(np.float64) file_bins = np.load("./Stone_Data/stone_store_file.npy") tree = KDTree(lab_bins) # 读图 + 预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() # ---- 灰度图 & 初始 mask ---- # gray_image = grayscale_custom(image) # 如果你想用自己写的 gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) h, w = gray_image.shape gray_image_new = np.zeros((h + 2, w + 2), np.uint8) total_Mask = np.zeros((h + 2, w + 2), np.uint8) skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_image = clone_image.copy() display_image = np.zeros((h, w, 3), dtype=np.uint8) # ---- 主循环 ---- while True: cv2.imshow("Image", clone_image) cv2.imshow("total_mask", total_Mask * 255) cv2.setMouseCallback("Image", mouse_callback) key = cv2.waitKey(0) if key == ord(' '): operation_of_blank() elif key == ord('s'): operation_of_save() elif key == 27: # ESC break cv2.destroyAllWindows()

改完这两块之后,再点一下图像,fullfill_mask 里面的 image.shape 就不会再报 NoneType 了,你那句“添加种子点: (347, 202)”后面应该就能顺利继续跑下去。

如果下一步你想把这套逻辑继续无缝搬到 PyQt 项目里,我们可以再把这里的核心步骤对应到你现在的 ImageOperationPage

Question

目前图片的操作页面还存在选取一下,图片就扩大一点的问题,其次,我需要预览的图片上面是被选取的分割图,在没进行空格合并时需要全部显示出来

Réflexion

Addressing image operation and preview issues

It looks like the user is facing two main issues. The first is that the image enlarges slightly each time they click, causing jitter or zooming. The second issue is related to the preview: they want to see all selected regions (like seed regions) before merging, not just the last clicked one. Two codebases are involved — legacy OpenCV+Tk and the new PyQt UI.

Investigating image resizing issue

It seems that the image might be enlarging slightly due to how certain elements are handled. I think the issue could stem from using the original pixmap in _update_zoom_image rather than the overlaid pixmap, or possibly due to how the resizeEvent scales things. PyQt might be applying new scaling each time an image is loaded. Let me revisit the code to clarify this.

Investigating preview label resizing issue

It seems like the user is still experiencing minor resizing or jitter. Looking at it, the issue could be related to how the preview labels (preview1/preview2) update and affect the layout. Since the labels don’t have a fixed height, their changing size could cause slight adjustments in the main canvas. I’ll need to make sure the height is fixed to avoid this.

Addressing size change and preview updates

It seems the user is seeing the image enlarge slightly with each click, which could be related to how the layout adjusts between text and pixmap. I think fixing the heights of the canvases and previews using setMinimumHeight and setMaximumHeight will prevent layout changes. Also, the second issue about preview1 displaying the image union before merging requires addressing. I'll work on that since the zoom logic seems unconnected to the issue.

Implementing region mask and preview updates

I think the user needs PyQt to replicate the old script behavior, where each click stores a new region and ORs into a combined mask. Preview1 should show all previously selected regions, not just the last. I'll adapt the logic by maintaining current_mask as the OR of all clicks and use a "生成" or "合并" button for the merge. They likely want to avoid using space.

Implementing preview updates and fixing geometry issues

I’ll adjust the previews based on the user's changed requirements: preview1 will show the union of all selected regions (total_mask), and preview2 will show the final processed result after things like skeletonization, depending on the user’s planned changes. I’ll also keep an eye on geometry issues caused by the QSplitter layout to fix any resizing issues.

Investigating the resize issue during canvas updates

I’m considering the question of why the user still notices enlargement after each click! The canvas size should be stable, as we're setting it directly to the pixmap size. It seems like we're calling _update_canvas_with_overlay properly, but maybe the layout is causing slight changes when it recalculates the size of the preview labels. I’ll track down the exact cause.

Addressing potential jitter and resizing issues

The user might be seeing more of the image due to the red overlay masking, misinterpreting it as a scale change. I’ll start by fixing the label size manually and consider subclassing the label to avoid resizing when the window is adjusted. I’ll also adjust the layout and handling in resizeEvent to stop jitter. Lastly, I'll implement preview1 to use the total_mask for consistency.

Thought for 2m 25s

Réponse

好,我来一次把这两个问题都解决掉:

  1. 每点一下图片就“放大一点”
  2. 上面的预览框要显示“所有已选中的分割区域”(在没合并之前就全部看见)

下面都是在你现有 PyQt 图片操作页上「小改动」,不用重写整份代码。


一、彻底干掉“越点越大”的问题

思路:

  • 让画布 canvas_label尺寸固定(由布局决定,不跟着 pixmap 变);
  • 每次只是往一个“跟 label 一样大”的画布上画图,不改变 sizeHint;
  • 同时让 ClickableLabelsizeHint 固定,不随 pixmap 抖动。

1. 更新 ClickableLabel:锁死 sizeHint

把原来的 ClickableLabel 换成下面这个版本(多了 sizeHint / minimumSizeHint):

python
class ClickableLabel(QLabel): clicked = pyqtSignal(int, int, int) # x, y, button def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # 记录一个基础尺寸,layout 会在此基础上拉伸 self._base_size = QtCore.QSize(400, 300) def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event) def sizeHint(self): # 始终返回固定 sizeHint,避免跟着 pixmap 抖动 return self._base_size def minimumSizeHint(self): return self._base_size

2. initUI 里多加一行:不要让 QLabel 自己拉伸内容

在你设置 canvas_label 的那几行里补一行(如果已经加过就保持):

python
self.canvas_label.setStyleSheet("background-color: black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) # ★ 关键:不让 QLabel 根据 pixmap 自己缩放影响 sizeHint self.canvas_label.setScaledContents(False) self.canvas_label.setText("请从首页选择图片")

3. 把 _update_canvas_with_overlay 改成“固定画布绘制”版本

用这一版整体替换你现在的 _update_canvas_with_overlay

python
from PyQt5.QtGui import QPainter # 顶部 import 记得加上 def _update_canvas_with_overlay(self): """在固定大小的画布上叠加 total_mask,避免每次点击看起来在缩放""" if self.cv_image is None: return # 原图 + 绿色点 + 红色区域 base = self.cv_image.copy() # 绿色点击点(图像坐标) if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) # 红色叠加区域(所有已经选中的区域) if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) img_pix = self._cvimg_to_qpixmap(base) # 用「和 label 一样大的底板」来画,不改变 label 尺寸 canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return canvas_pix = QPixmap(canvas_size) canvas_pix.fill(Qt.black) scaled_pix = img_pix.scaled( canvas_size, Qt.KeepAspectRatio, Qt.SmoothTransformation ) painter = QPainter(canvas_pix) x = (canvas_size.width() - scaled_pix.width()) // 2 y = (canvas_size.height() - scaled_pix.height()) // 2 painter.drawPixmap(x, y, scaled_pix) painter.end() self.canvas_label.setPixmap(canvas_pix)

这样每次点击只是重新在同一块大画布上重画内容,
label 的大小不变,布局不变,视觉上就不会再“放大一点点”。


二、预览框显示“所有已选分割区域”

你的需求:

预览的图片上面是被选取的分割图,在没进行空格合并时需要全部显示出来

对应到我们现在的变量:

  • 所有已经点过的区域(无论合并没合并)其实就是 total_mask(我们每次点击都 bitwise_or 进去了)。
  • 之前我把:
    • preview1 = last_region_mask(当前那一块)
    • preview2 = total_mask(全部)

你现在想要的是:预览框上面显示“全部区域”,那就把 preview1 改成显示 total_mask 即可。

_update_previews 函数改成下面这样(整体替换):

python
def _update_previews(self): """更新右侧两个预览框""" if self.cv_image is None: return # ===== 上面预览框:显示「所有已经选中的分割区域」 ===== if self.total_mask is not None and np.any(self.total_mask): # 用 total_mask 保留原图中的对应区域 total_region = cv2.bitwise_and( self.cv_image, self.cv_image, mask=self.total_mask ) pix1 = self._cvimg_to_qpixmap(total_region) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # ===== 下面预览框:你可以保留「纯掩码」的效果,也方便调试 ===== if self.total_mask is not None and np.any(self.total_mask): mask_vis = np.zeros_like(self.cv_image) mask_vis[self.total_mask > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") # 主画布一起更新(带红色叠加) self._update_canvas_with_overlay()

现在的效果:

  • 不管你点了 1 个还是 10 个点,只要没重置,上面的预览框会把这些区域全部显示出来
  • 这就相当于“没按空格合并之前,所有选区都能在预览里看到”。

三、简单验收步骤

  1. 启动程序,打开一张图片;
  2. 在不同位置连续点击几次:
    • 画布不再每点一次就变大,只是红色区域越来越多;
    • 上面的预览框显示所有已点区域(在原图上裁出来的那块集合);
    • 下面的预览框显示黑底+白色整体掩码(方便你看形状)。

如果之后你还想把“空格合并”的逻辑一比一搬到 PyQt(比如在“生成”按钮里才把 total_mask 写入骨架 / DXF),我们可以再按你那份 Project_Main.pyoperation_of_blank / operation_of_save 映射过来。

Question

image.png

还是一样,每次点击图片都很会变大一些

Réflexion

Debugging zoom/resize error

It seems like the issue is related to how the image size changes on click. While sizeHint and the overlay updates are adjusted, the resizeEvent might still be working with the original pixmap, not the overlay. This might cause the label size to expand gradually. I will need to revisit how the label size is being set and whether its policy is influencing the resize behavior.

Investigating QSplitter behavior

I think the resizing issue might be due to QSplitter reassigning space when the preview images change, making the left side shrink and giving the impression that the image grows. The map's growth could actually come from the canvas label increasing in size as the layout is recalculated. This might be triggered when preview widgets change.

Clarifying size change causes

The issue might be that the preview pixmaps change, affecting their sizeHint and the resulting width of the panels. This could make it seem like the central image shrinks, although actually, the label size is increasing in response to the layout changes. Clicking shouldn't directly cause the label size to grow, though.

Investigating label growth

The screenshot suggests that the label grows as the black vertical bars shrink, indicating an increase in label size. I don’t expect the label size to change upon clicking. It's possible the splitter left widget is resizing due to the right panel's layout, though that shouldn't happen unless the window is resized or the right panel's minimum width changes. Maybe there's still a zoom effect triggering during the clicks.

Investigating label size change trigger

The issue might be related to how the label grows, especially since the right panel's minimum width is static and shouldn't change. The label could be resizing due to the splitter left widget dimensions. Since the zoom function isn't involved, I suspect the resizeEvent override is affecting the pixmap size and label.

Investigating label growth issue

The issue might be happening because the layout isn't changing but the image scaling seems to be compounding. I think the resizeEvent bug might be related to old code that scales the pixmap before assigning it to the label. The label itself shouldn't be resizing unless there's progressive scaling compounding with each click. So, I suspect replacing the resizeEvent properly should solve it.

Proposing a fix for the resizing issue

The issue seems to be related to the layout, specifically the right preview label’s sizeHint changing with the pixmap. A potential fix could be using a fixed QPixmap resolution for scaling, ensuring everything scales consistently. To simplify further, I suggest removing resizeEvent override, disabling zoom, and ensuring no conflicting size policies based on the pixmap. This should address the user's concerns with minimal changes.

Thought for 29s

Réponse

好,我直接给你一版**“不可能越点越大”**的完整代码,并满足:

  1. 每次点击绝对不会放大(不再和任何缩放/原始 QPixmap 扯上关系);
  2. 上面预览框始终显示“当前所有已选中的分割区域”(在你没点重置/没做合并前,全部都看得见);
  3. 下面预览框显示黑底+白色整体掩码,方便看形状。

核心思路:

  • 完全不用 original_pixmapzoom_scale,所有绘制都基于 cv_image + total_mask 做;
  • canvas_label 的大小只由布局和窗口大小决定,我们只在一块**与 label 一样大的“画布”**上画东西,不改 label 大小;
  • 点击只会更新画布内容,不会改任何 widget 的 sizePolicy / minSize。

你可以把当前 ImageOperationPage 整个类替换成下面这份(ClickableLabel 一起换掉),其它文件不用动。


完整代码:ClickableLabel + ImageOperationPage

python
import os import cv2 import numpy as np from PyQt5 import QtCore from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap, QImage, QPainter from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout ) class ClickableLabel(QLabel): """可以发出点击信号且 sizeHint 固定的 QLabel,避免跟随 pixmap 抖动""" clicked = pyqtSignal(int, int, int) # x, y, button def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._base_size = QtCore.QSize(400, 300) def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event) def sizeHint(self): return self._base_size def minimumSizeHint(self): return self._base_size class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # ---- OpenCV / 分割相关状态 ---- self.cv_image = None # 原图 BGR self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次区域 self.total_mask = None # 累积掩码(全部已选区域) self.last_click_img_pt = None # 最近一次点击的图像坐标 # UI 控件 self.canvas_label = ClickableLabel() self.preview1 = QLabel() self.preview2 = QLabel() self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self.initUI() self.canvas_label.clicked.connect(self.on_canvas_clicked) # ================= UI 布局 ================= def initUI(self): self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(6, 6, 6, 6) self.main_layout.setSpacing(4) # ---- 菜单栏 ---- menubar = QMenuBar() menubar.setStyleSheet( "background-color:#333333;color:#ffffff;font-size:15px" ) file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") self.main_layout.addWidget(menubar) # ---- 工具栏 ---- toolbar = QToolBar() toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setMovable(False) toolbar.setStyleSheet(""" QToolBar { spacing: 12px; background-color:#333333; padding: 4px; } QToolButton{ color:#ffffff !important; font-size:14px !important; padding:4px 6px !important; } QLabel { color:#ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) # 放大/缩小按钮先占位,不做真正缩放,以免影响分割坐标 zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color:#ffffff;") toolbar.addWidget(auto_toggle) self.main_layout.addWidget(toolbar) # ---- 主工作区 + 右侧 ---- splitter = QSplitter(Qt.Horizontal) # 左画布 main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.canvas_label.setStyleSheet("background-color:black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.canvas_label.setMinimumSize(400, 300) self.canvas_label.setScaledContents(False) # 不让 QLabel 自己按 pixmap 缩放 self.canvas_label.setText("请从首页选择图片") main_area_layout.addWidget(self.canvas_label) splitter.addWidget(main_workspace) # 右侧面板 right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) self.preview1.setText("原图预览") self.preview1.setStyleSheet("background-color:#f0f0f0;border:1px solid #ccc;") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview1.setMinimumHeight(120) self.preview2.setText("处理后预览") self.preview2.setStyleSheet("background-color:#f0f0f0;border:1px solid #ccc;") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.preview2.setMinimumHeight(120) right_layout.addWidget(self.preview1) right_layout.addWidget(self.preview2) # 滑块 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(40) # 默认给个中等偏小的 form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(10) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color:#2196F3;color:white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) self.main_layout.addWidget(splitter) # =============== 图片加载 =============== def load_image(self, file_path): if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return ext = os.path.splitext(file_path)[1].lower() if ext not in [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._update_canvas_with_overlay() self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") # =============== 坐标映射 =============== def _map_label_pos_to_image(self, x, y): """将 canvas_label 坐标映射到原图坐标""" if self.cv_image is None: return None pixmap = self.canvas_label.pixmap() if pixmap is None: return None img_h, img_w = self.cv_image.shape[:2] pix_w = pixmap.width() pix_h = pixmap.height() lab_w = self.canvas_label.width() lab_h = self.canvas_label.height() if img_w == 0 or img_h == 0: return None offset_x = (lab_w - pix_w) // 2 offset_y = (lab_h - pix_h) // 2 if not (offset_x <= x < offset_x + pix_w and offset_y <= y < offset_y + pix_h): return None x_in_pix = x - offset_x y_in_pix = y - offset_y scale_x = pix_w / img_w scale_y = pix_h / img_h ix = int(x_in_pix / scale_x) iy = int(y_in_pix / scale_y) return ix, iy # =============== 分割相关 =============== def _segment_region(self, seed_point, tolerance): """灰度图 + 区域生长""" if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) flags = (cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY | (255 << 8)) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), mask, seedPoint=seed_point, newVal=0, loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] return np.where(region > 0, 255, 0).astype(np.uint8) def _cvimg_to_qpixmap(self, img): if img is None: return QPixmap() if len(img.shape) == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _update_canvas_with_overlay(self): """主画布:原图 + 红色已选区域 + 绿色点,画在固定大小的画布上(不改变控件尺寸)""" if self.cv_image is None: return base = self.cv_image.copy() if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 5, (0, 255, 0), -1) if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) img_pix = self._cvimg_to_qpixmap(base) canvas_size = self.canvas_label.size() if canvas_size.width() <= 0 or canvas_size.height() <= 0: return canvas_pix = QPixmap(canvas_size) canvas_pix.fill(Qt.black) scaled_pix = img_pix.scaled( canvas_size, Qt.KeepAspectRatio, Qt.SmoothTransformation ) painter = QPainter(canvas_pix) x = (canvas_size.width() - scaled_pix.width()) // 2 y = (canvas_size.height() - scaled_pix.height()) // 2 painter.drawPixmap(x, y, scaled_pix) painter.end() self.canvas_label.setPixmap(canvas_pix) def _update_previews(self): """右侧两个预览:上=所有已选区域颜色图,下=总掩码""" if self.cv_image is None: return # 上面预览:total_mask 作用在原图上,显示所有已选区域 if self.total_mask is not None and np.any(self.total_mask): region_img = cv2.bitwise_and( self.cv_image, self.cv_image, mask=self.total_mask ) pix1 = self._cvimg_to_qpixmap(region_img) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # 下面预览:total_mask 画成黑底白块 if self.total_mask is not None and np.any(self.total_mask): mask_vis = np.zeros_like(self.cv_image) mask_vis[self.total_mask > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._update_canvas_with_overlay() def on_canvas_clicked(self, x, y, button): if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return if button != int(Qt.LeftButton): return img_pt = self._map_label_pos_to_image(x, y) if img_pt is None: return ix, iy = img_pt self.last_click_img_pt = (ix, iy) h, w = self.gray_image.shape if not (0 <= ix < w and 0 <= iy < h): return raw_tol = self.high_thresh_slider.value() if self.high_thresh_slider else 40 tolerance = max(1, raw_tol // 6) # 把 0–255 映射到较小范围 region_mask = self._segment_region((ix, iy), tolerance) if region_mask is None: return self.last_region_mask = region_mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) self._update_previews() # =============== 其它 =============== def _zoom_not_supported(self): self.main_window._show_error提示("提示", "当前版本未开启缩放功能,先把分割调好再说~") def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(40) self.low_thresh_slider.setValue(10) self.extra_slider.setValue(50) if self.gray_image is not None: h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None self._update_previews() def resizeEvent(self, event): """窗口改变大小时,只重画画布内容,不做任何缩放逻辑""" self._update_canvas_with_overlay() super().resizeEvent(event)

这样之后:

  • 你点击多少次,canvas_label 的尺寸都只由窗口/分割器决定,不会跟着 pixmap 变化,所以不会再出现“每点一下就变大一点”的情况
  • 上面预览框:始终显示“到目前为止所有点击得到的区域合集”;
  • 下面预览框:黑底白色整体掩码;
  • 主画布:原图 + 红色叠加 + 最后一次点击的绿色点。

如果这版你再点几下还是觉得有“放大感”,那就基本只能是窗口本身被你拖大了(或者录屏压缩错觉);你可以对比一下:连续点同一个位置,看左侧坐标格子/右侧工具栏大小是不是完全没变——没变的话,图像就是没在变大。

Question

很显然,右边的预览图一直在放大,而且一开始的整个页面的菜单栏的布局也有问题,给我一个完整的代码解决这两个问题

Question

image.png

上面的菜单栏存在变形,而且图片一直在放大至图片原来大小,对图片太小的情况进行居中,图片太大时考虑可以滑动,预览的框同样,不需要一直改变大小

Réflexion

Designing layout for image operation page

The user wants a full code for an image operation page with fixed behavior:

  • The menu bar should maintain its layout without deforming.
  • Images should stay centered if smaller than the canvas, but not zoom beyond 100%; if larger, scale to fit with scrollbars to view the full image.
  • Preview frames should not vary in size; they need to remain fixed while scaling the image inside.
  • The main layout should use a QVBox with the menu bar fixed at the top.

Fixing menu bar layout issue

It seems like the deforming is likely because the QMenuBar doesn't have a fixedHeight set. Also, the dark background of the toolbar could be part of it. From the screenshot, it looks like there's an extra QWidget with an unintended dark background, causing a large grey block between the menu and the toolbar. Adjusting the widget or style may resolve this.

Fixing menu and toolbar layout

It looks like the issue may be due to the QMenuBar having an expanding vertical size policy. To fix this, I’ll set its size policy to fixed vertically and use a fixed height (around 24px). I'll also apply the same thing to the toolbar to control its height. For the canvas, I'll use a QScrollArea with a QLabel for the image and ensure the label is aligned and scrollable as needed. Lastly, for segmentation clicking, I'll map click coordinates to the image.

Adjusting image scaling and segmentation handling

To avoid scaling small images, I’ll display them at their natural resolution, centering them in the scroll area. For large images, scrollbars will handle panning. When segmenting and overlaying, I'll maintain the image's resolution, leaving it unchanged during processing. For preview frames, I'll set a fixed or minimum size for the QLabel.

Handling fixed preview frames and segmentation

To keep the preview frames constant in size, I'll use setMinimumSize(220,160) and setMaximumSize(220,160) along with setAlignment(Qt.AlignCenter). When updating the image, it will scale to fit the frame size while preserving the aspect ratio. For segmentation, we’ll keep the image size fixed and adapt the overlays as before. When detecting clicks in the scroll area, the coordinates will be in image space due to the label's size. I’ll need to consider how the scroll offset affects mouse events.

Refining layout and segmentation handling

The event coordinates are already relative to the label's top-left corner, so there’s no need to map them. We'll simply check if clicks are inside bounds with tolerance for segmentation and mask updates. The ClickableLabel doesn't need custom sizeHint anymore, as it’s inside a QScrollArea, which handles resizing without layout jitter. For preview frames, we'll avoid resizing the preview label and focus on scaling the pixmap instead. The menu bar layout needs a fix as well.

Thought for 1m 44s

Réponse

下面这份是完整、可直接替换的图片操作页代码,专门解决你说的两个问题:

  1. 菜单栏变形

    • 使用固定高度的 QMenuBarQToolBar,不会占用大块高度。
  2. 图片 / 预览图越点越大

    • 左侧主图:放在一个 QScrollArea 里,按原始尺寸显示
      • 图片小于窗口:不会被放大,只是居中显示;
      • 图片大于窗口:出现滚动条,可以拖动看全图;
    • 右边两个预览框:使用固定大小,框本身不会改变,只是框里的内容按比例缩放。

另外保留了你需要的分割逻辑:

  • 左键点击主图进行 floodFill 分割;
  • 所有选中的区域累积到 total_mask
  • 上面的预览框:显示“所有已选区域作用在原图上的结果”;
  • 下面的预览框:显示黑底 + 白色整体掩码。

⚠️ 你只需要把你项目里原来的 ClickableLabelImageOperationPage 整段换成下面这份即可,其他文件不用改。


python
import os import cv2 import numpy as np from PyQt5 import QtCore from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap, QImage, QPainter, QPalette from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout, QScrollArea ) # =================== 可点击的 Label =================== class ClickableLabel(QLabel): clicked = pyqtSignal(int, int, int) # x, y, button def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event) # =================== 主页面 =================== class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # OpenCV / 分割相关 self.cv_image = None # BGR 原图 self.gray_image = None # 灰度图 self.last_region_mask = None # 最近一次区域 mask self.total_mask = None # 累积区域 mask self.last_click_img_pt = None # 最近点击的图像坐标 # UI 控件 self.canvas_label = ClickableLabel() self.scroll_area = None # 左侧滚动区域 self.preview1 = QLabel() self.preview2 = QLabel() self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self.initUI() self.canvas_label.clicked.connect(self.on_canvas_clicked) # =================== UI 布局 =================== def initUI(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(6, 6, 6, 6) main_layout.setSpacing(4) # ---------- 菜单栏 ---------- menubar = QMenuBar() menubar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) menubar.setFixedHeight(24) menubar.setStyleSheet( "QMenuBar{background-color:#333333;color:#ffffff;font-size:14px}" "QMenuBar::item:selected{background-color:#555555}" ) file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") main_layout.addWidget(menubar) # ---------- 工具栏 ---------- toolbar = QToolBar() toolbar.setMovable(False) toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) toolbar.setFixedHeight(32) toolbar.setStyleSheet(""" QToolBar { background-color:#333333; spacing: 12px; padding: 4px; } QToolButton { color:#ffffff !important; font-size:13px !important; padding:3px 6px !important; } QLabel { color:#ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color:#ffffff;") toolbar.addWidget(auto_toggle) main_layout.addWidget(toolbar) # ---------- 中间区域:左图 + 右面板 ---------- splitter = QSplitter(Qt.Horizontal) # 左侧:滚动区域 + 可点击的画布 main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(False) # 以图像大小为准 self.scroll_area.setAlignment(Qt.AlignCenter) self.scroll_area.setBackgroundRole(QPalette.Dark) self.canvas_label.setBackgroundRole(QPalette.Base) self.canvas_label.setStyleSheet("background-color:black;") self.canvas_label.setAlignment(Qt.AlignCenter) self.scroll_area.setWidget(self.canvas_label) main_area_layout.addWidget(self.scroll_area) splitter.addWidget(main_workspace) # 右侧控制面板 right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览框:使用固定尺寸,不随窗口变化 self.preview1.setText("原图预览") self.preview1.setStyleSheet( "background-color:#f0f0f0;border:1px solid #cccccc;" ) self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setFixedSize(230, 150) self.preview2.setText("处理后预览") self.preview2.setStyleSheet( "background-color:#f0f0f0;border:1px solid #cccccc;" ) self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setFixedSize(230, 150) right_layout.addWidget(self.preview1, alignment=Qt.AlignCenter) right_layout.addWidget(self.preview2, alignment=Qt.AlignCenter) # 滑块区域 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(40) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(10) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color:#2196F3;color:white;") btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) main_layout.addWidget(splitter) self.main_layout = main_layout # =================== 图片加载 =================== def load_image(self, file_path): if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return ext = os.path.splitext(file_path)[1].lower() if ext not in [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None # 清空预览 self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") # 设置主图:按原始大小显示,不缩放 self._redraw_main_image() self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") # =================== 工具函数 =================== def _zoom_not_supported(self): self.main_window._show_error提示("提示", "当前版本未实现缩放功能,先把分割逻辑调好~") def _cvimg_to_qpixmap(self, img): if img is None: return QPixmap() if len(img.shape) == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _segment_region(self, seed_point, tolerance): """灰度 + floodFill -> 二值 mask""" if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) flags = (cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY | (255 << 8)) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), mask, seedPoint=seed_point, newVal=0, loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] return np.where(region > 0, 255, 0).astype(np.uint8) # =================== 绘制主图 & 预览 =================== def _redraw_main_image(self): """根据 total_mask / 点击点,在原图上叠加显示,然后按原尺寸放到 label""" if self.cv_image is None: self.canvas_label.clear() self.canvas_label.setText("请从首页选择图片") return base = self.cv_image.copy() # 叠加已选区域 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) # 画点击点(图像坐标) if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 4, (0, 255, 0), -1) pix = self._cvimg_to_qpixmap(base) # 关键:label 的大小 = 图像大小,scrollArea 决定是否出现滚动条 self.canvas_label.setPixmap(pix) self.canvas_label.resize(pix.size()) def _update_previews(self): if self.cv_image is None: return # 上预览:total_mask 作用在原图上的效果(所有已选区域) if self.total_mask is not None and np.any(self.total_mask): region_img = cv2.bitwise_and( self.cv_image, self.cv_image, mask=self.total_mask ) pix1 = self._cvimg_to_qpixmap(region_img) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # 下预览:黑底 + 白色掩码 if self.total_mask is not None and np.any(self.total_mask): mask_vis = np.zeros_like(self.cv_image) mask_vis[self.total_mask > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._redraw_main_image() # =================== 交互:点击分割 =================== def on_canvas_clicked(self, x, y, button): if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return if button != int(Qt.LeftButton): return # 因为 label 的像素大小 = 图片大小,所以直接使用 (x,y) 即可 h, w = self.gray_image.shape if not (0 <= x < w and 0 <= y < h): return self.last_click_img_pt = (x, y) raw_tol = self.high_thresh_slider.value() if self.high_thresh_slider else 40 tolerance = max(1, raw_tol // 6) region_mask = self._segment_region((x, y), tolerance) if region_mask is None: return self.last_region_mask = region_mask if self.total_mask is None or self.total_mask.shape != region_mask.shape: self.total_mask = region_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, region_mask) self._update_previews() # =================== 其它操作 =================== def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(40) self.low_thresh_slider.setValue(10) self.extra_slider.setValue(50) if self.gray_image is not None: h, w = self.gray_image.shape self.last_region_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_click_img_pt = None self._update_previews()

你可以先用这一版替换原来的类,运行后注意看:

  • 顶部菜单栏高度应该正常,不再占半屏;
  • 小图保持原尺寸居中;大图出现滚动条;
  • 左图 / 右侧预览框在点击时只更新内容,框的大小不再改变。

如果还有哪个细节不符合你预期(比如想要“适应窗口”模式、再加缩放按钮),我们可以在这份稳定版本基础上再往上加功能。

Question

image.png

两个问题:首先菜单栏不见了,其次对于更深层次的逻辑可以添加了,对于空格进行切割,如同之前给定的代码逻辑那样,对所有的选取点和选取的区域进行保存,另外,生成按钮为生成DXF文件,给出显示生成进度条

Réflexion

Analyzing why the menu bar isn't showing up

I think the disappearing menu bar issue could be because a top-level window is created elsewhere, and it may already have its own menu bar. Alternatively, maybe it's a problem with how I added the menu bar in the previous code. I'll explore adjusting the menu bar and toolbar integration, especially regarding the color settings I used.

Investigating menu bar disappearance

It seems like the QMainWindow might be using its own native menu bar, and the widget inside doesn’t have one. The screenshot shows a dark top bar that’s likely from the main window, but it's not filled with the menu. A possible solution is to adapt our approach and suggest the user place menus in the main window.

Reevaluating the menu bar issue

It looks like I might have forgotten to call self.setLayout(main_layout) when setting up the layout. The menu bar should render, but the screenshot shows the top bar blank, though it still matches the dark color of the toolbar. One solution might be to revert to the previous menu layout with a fixed height to avoid the strange resizing.

Menu bar and functionality adjustments

It seems my menu bar might be hidden by the toolbar due to its fixed height being too small. The dark area in the screenshot indicates the toolbar, and the menu bar could have been obscured above it. To fix this, I’ll remove the custom menu bar and revert to the user’s earlier code. They also want functionality for DXF export and space key segmentation, which will involve integrating logic from the OpenCV script for selecting seeds, committing regions, and generating the DXF. They’re looking for the logic wiring, not perfect image processing.

Menu bar and key handling adjustments

We’ll keep the pending_mask for current strokes and all_regions_mask as the earlier total_mask. For the DXF export, we'll track seeds, add logic for merging regions with the space key, and use a progress bar for export. To fix the menu bar, I’ll revert to the original and adjust height and size policy to avoid deformation.

Handling image scaling and segmentation updates

The image scaling issue is clear now — we show the image at original size with scrollbars, so when it's large, scrolls, and when small, it's centered. For the code, I'll add a basic menu bar and implement the segmentation logic. The DXF export will use contours from total_mask, and I'll show a progress bar for each contour saved.

Thought for 33s

Réponse

好,我给你一份完整可替换的代码,解决你提到的三个点:

  1. 菜单栏重新出现,而且高度固定、不再怪异变形
  2. 图片和右侧预览框不会因为点击而变化大小
    • 小图:居中显示
    • 大图:出现滚动条,可拖动查看
  3. 加入“空格提交选区”的逻辑 + 生成 DXF 按钮(带进度条)
    • 左键点击:只把区域加入“本轮临时选区”
    • 按空格:把“本轮临时选区”合并到总区域,并记录这些种子点
    • 点“生成”:根据总区域的轮廓导出 DXF 文件,并显示进度条

⚠️ 用法提醒:

  • 请把你项目里原来的 ClickableLabelImageOperationPage 整个文件内容换成下面这份
  • 需要安装 ezdxfpip install ezdxf

python
import os import cv2 import numpy as np import ezdxf from PyQt5 import QtCore from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QIcon, QPixmap, QImage from PyQt5.QtWidgets import ( QWidget, QMenuBar, QAction, QToolBar, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QSlider, QPushButton, QSplitter, QSizePolicy, QFormLayout, QScrollArea, QFileDialog, QProgressDialog ) # =================== 可点击的 Label =================== class ClickableLabel(QLabel): clicked = pyqtSignal(int, int, int) # x, y, button def mousePressEvent(self, event): if event.button() in (Qt.LeftButton, Qt.RightButton): self.clicked.emit(event.x(), event.y(), int(event.button())) super().mousePressEvent(event) # =================== 主页面 =================== class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # OpenCV / 分割相关状态 self.cv_image = None # 原图 BGR self.gray_image = None # 灰度图 # mask: self.pending_mask = None # 本轮(还没按空格)选中的区域 self.total_mask = None # 所有按空格确认过的区域 self.last_region_mask = None # 最近一次 floodFill 区域(方便调试) # 种子点 self.current_seeds = [] # 本轮点下的种子点(还没按空格) self.all_region_seeds = [] # 每轮确认时,保存一个代表种子(用于后续深层逻辑) self.last_click_img_pt = None # 最近点击的图像坐标 # UI 控件 self.canvas_label = ClickableLabel() self.scroll_area = None self.preview1 = QLabel() self.preview2 = QLabel() self.kernel_slider = None self.high_thresh_slider = None self.low_thresh_slider = None self.extra_slider = None self._init_ui() # 交互 self.canvas_label.clicked.connect(self.on_canvas_clicked) # 接收键盘事件(空格) self.setFocusPolicy(Qt.StrongFocus) # =================== UI 布局 =================== def _init_ui(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(6, 6, 6, 6) main_layout.setSpacing(4) # ---------- 菜单栏(简单样式,避免“消失”) ---------- menubar = QMenuBar(self) menubar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) menubar.setMinimumHeight(24) file_menu = menubar.addMenu("文件(F)") file_menu.addAction("新建") file_menu.addAction("打开") file_menu.addAction("保存") file_menu.addAction("另存为") edit_menu = menubar.addMenu("编辑(E)") edit_menu.addAction("撤销") edit_menu.addAction("重做") image_menu = menubar.addMenu("图像(I)") image_menu.addAction("调整大小") image_menu.addAction("裁剪") layer_menu = menubar.addMenu("图层(L)") layer_menu.addAction("新建图层") layer_menu.addAction("删除图层") text_menu = menubar.addMenu("文字(T)") text_menu.addAction("添加文字") select_menu = menubar.addMenu("选择(S)") select_menu.addAction("全选") select_menu.addAction("取消选择") view_menu = menubar.addMenu("视图(V)") view_menu.addAction("缩放") view_menu.addAction("布局") options_menu = menubar.addMenu("选项(O)") options_menu.addAction("偏好设置") help_menu = menubar.addMenu("帮助(H)") help_menu.addAction("关于") main_layout.addWidget(menubar) # ---------- 工具栏 ---------- toolbar = QToolBar() toolbar.setMovable(False) toolbar.setIconSize(QtCore.QSize(15, 15)) toolbar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) toolbar.setMinimumHeight(32) toolbar.setStyleSheet(""" QToolBar { background-color:#333333; spacing: 12px; padding: 4px; } QToolButton { color:#ffffff !important; font-size:13px !important; padding:3px 6px !important; } QLabel { color:#ffffff; } """) try: home_action = QAction(QIcon(QPixmap("./sotf_image/home.png")), "主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) save_action = QAction(QIcon(QPixmap("./sotf_image/save.png")), "保存", self) toolbar.addAction(save_action) new_action = QAction(QIcon(QPixmap("./sotf_image/another.png")), "另存为", self) toolbar.addAction(new_action) undo_action = QAction(QIcon(QPixmap("./sotf_image/undo.png")), "撤销", self) toolbar.addAction(undo_action) redo_action = QAction(QIcon(QPixmap("./sotf_image/reset.png")), "重做", self) toolbar.addAction(redo_action) zoom_in_action = QAction(QIcon(QPixmap("./sotf_image/big.png")), "放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction(QIcon(QPixmap("./sotf_image/small.png")), "缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) hand_action = QAction(QIcon(QPixmap("./sotf_image/catch.png")), "抓手", self) toolbar.addAction(hand_action) except Exception as e: self.main_window.logger.warning(f"工具栏图标加载失败:{e}") home_action = QAction("主页", self) home_action.triggered.connect(self.main_window.back_to_home) toolbar.addAction(home_action) toolbar.addAction(QAction("保存", self)) toolbar.addAction(QAction("另存为", self)) toolbar.addAction(QAction("撤销", self)) toolbar.addAction(QAction("重做", self)) zoom_in_action = QAction("放大", self) zoom_in_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_in_action) zoom_out_action = QAction("缩小", self) zoom_out_action.triggered.connect(self._zoom_not_supported) toolbar.addAction(zoom_out_action) toolbar.addAction(QAction("抓手", self)) toolbar.addSeparator() toolbar.addWidget(QLabel("滤镜:")) filter_combo = QComboBox() filter_combo.addItems(["高斯滤波", "均值滤波", "中值滤波"]) filter_combo.setFixedWidth(110) toolbar.addWidget(filter_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("梯度:")) grad_combo = QComboBox() grad_combo.addItems(["sobel算子", "prewitt算子", "roberts算子"]) grad_combo.setFixedWidth(110) toolbar.addWidget(grad_combo) toolbar.addSeparator() toolbar.addWidget(QLabel("自动调节:")) auto_toggle = QLabel("开启") auto_toggle.setStyleSheet("color:#ffffff;") toolbar.addWidget(auto_toggle) main_layout.addWidget(toolbar) # ---------- 中间区域:左图 + 右面板 ---------- splitter = QSplitter(Qt.Horizontal) # 左侧:滚动区域 main_workspace = QWidget() main_area_layout = QVBoxLayout(main_workspace) main_area_layout.setContentsMargins(4, 4, 4, 4) main_area_layout.setSpacing(0) self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(False) # 遵循图像尺寸 self.scroll_area.setAlignment(Qt.AlignCenter) self.canvas_label.setAlignment(Qt.AlignCenter) self.canvas_label.setStyleSheet("background-color:black;") self.canvas_label.setText("请从首页选择图片") self.scroll_area.setWidget(self.canvas_label) main_area_layout.addWidget(self.scroll_area) splitter.addWidget(main_workspace) # 右侧:预览 + 参数 + 按钮 right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(8, 8, 8, 8) right_layout.setSpacing(10) # 预览框固定大小 self.preview1.setText("原图预览") self.preview1.setAlignment(Qt.AlignCenter) self.preview1.setStyleSheet("background-color:#f0f0f0;border:1px solid #ccc;") self.preview1.setFixedSize(230, 150) self.preview2.setText("处理后预览") self.preview2.setAlignment(Qt.AlignCenter) self.preview2.setStyleSheet("background-color:#f0f0f0;border:1px solid #ccc;") self.preview2.setFixedSize(230, 150) right_layout.addWidget(self.preview1, alignment=Qt.AlignCenter) right_layout.addWidget(self.preview2, alignment=Qt.AlignCenter) # 滑块 slider_group = QWidget() form_layout = QFormLayout(slider_group) form_layout.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignVCenter) form_layout.setHorizontalSpacing(10) form_layout.setVerticalSpacing(8) self.kernel_slider = QSlider(Qt.Horizontal) self.kernel_slider.setRange(1, 31) self.kernel_slider.setValue(3) form_layout.addRow("核大小", self.kernel_slider) self.high_thresh_slider = QSlider(Qt.Horizontal) self.high_thresh_slider.setRange(0, 255) self.high_thresh_slider.setValue(40) form_layout.addRow("高阈值", self.high_thresh_slider) self.low_thresh_slider = QSlider(Qt.Horizontal) self.low_thresh_slider.setRange(0, 255) self.low_thresh_slider.setValue(10) form_layout.addRow("低阈值", self.low_thresh_slider) self.extra_slider = QSlider(Qt.Horizontal) self.extra_slider.setRange(0, 100) self.extra_slider.setValue(50) form_layout.addRow("额外参数", self.extra_slider) right_layout.addWidget(slider_group) # 按钮 btn_group = QWidget() btn_layout = QHBoxLayout(btn_group) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(10) btn_layout.addStretch() reset_btn = QPushButton("重置") reset_btn.clicked.connect(self.reset_parameters) generate_btn = QPushButton("生成") generate_btn.setStyleSheet("background-color:#2196F3;color:white;") generate_btn.clicked.connect(self.export_dxf) btn_layout.addWidget(reset_btn) btn_layout.addWidget(generate_btn) right_layout.addWidget(btn_group) right_layout.addStretch() right_panel.setMinimumWidth(260) right_panel.setMaximumWidth(360) splitter.addWidget(right_panel) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 1) main_layout.addWidget(splitter) self.main_layout = main_layout # =================== 加载图片 =================== def load_image(self, file_path): if not os.path.exists(file_path): self.main_window._show_error提示("文件错误", f"图片不存在:{file_path}") return ext = os.path.splitext(file_path)[1].lower() if ext not in [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]: self.main_window._show_error提示("格式错误", "请选择有效图片文件") return try: self.cv_image = cv2.imread(file_path) if self.cv_image is None: self.main_window._show_error提示("加载失败", "OpenCV 无法读取图像") return self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) h, w = self.gray_image.shape self.pending_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_region_mask = np.zeros((h, w), np.uint8) self.current_seeds = [] self.all_region_seeds = [] self.last_click_img_pt = None # 清空预览 self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") # 主图按原尺寸显示 self._redraw_main_image() self.main_window.logger.info(f"图片加载成功:{file_path}") except Exception as e: self.main_window.logger.error(f"加载图片失败:{e}") self.main_window._show_error提示("加载失败", f"解析图片出错:{e}") # =================== 工具函数 =================== def _zoom_not_supported(self): self.main_window._show_error提示("提示", "当前版本暂未实现缩放功能(只支持滚动查看大图)") def _cvimg_to_qpixmap(self, img): if img is None: return QPixmap() if len(img.shape) == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w, ch = img_rgb.shape bytes_per_line = ch * w qimg = QImage(img_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def _segment_region(self, seed_point, tolerance): """灰度 + floodFill -> 二值 mask""" if self.gray_image is None: return None h, w = self.gray_image.shape mask = np.zeros((h + 2, w + 2), np.uint8) flags = (cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY | (255 << 8)) try: _retval, _img, _rect, _mask = cv2.floodFill( self.gray_image.copy(), mask, seedPoint=seed_point, newVal=0, loDiff=tolerance, upDiff=tolerance, flags=flags ) except Exception as e: self.main_window.logger.error(f"floodFill 出错:{e}") return None region = mask[1:-1, 1:-1] return np.where(region > 0, 255, 0).astype(np.uint8) # =================== 绘制主图 & 预览 =================== def _redraw_main_image(self): """根据 total_mask / pending_mask / 点击点,在原图上叠加显示""" if self.cv_image is None: self.canvas_label.clear() self.canvas_label.setText("请从首页选择图片") return base = self.cv_image.copy() # 累积区域:红色 if self.total_mask is not None and np.any(self.total_mask): color_mask = np.zeros_like(base) color_mask[self.total_mask > 0] = (0, 0, 255) base = cv2.addWeighted(base, 0.7, color_mask, 0.3, 0) # 当前临时区域:青色 if self.pending_mask is not None and np.any(self.pending_mask): color_mask2 = np.zeros_like(base) color_mask2[self.pending_mask > 0] = (255, 255, 0) base = cv2.addWeighted(base, 0.7, color_mask2, 0.3, 0) # 点击点 if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 4, (0, 255, 0), -1) pix = self._cvimg_to_qpixmap(base) self.canvas_label.setPixmap(pix) # label 大小 = 图像大小,scrollArea 决定是否滚动 self.canvas_label.resize(pix.size()) def _update_previews(self): if self.cv_image is None: return # 上预览:当前“所有已确认区域 + 当前临时区域” 在原图上的效果 mask_union = np.zeros_like(self.total_mask) if self.total_mask is not None: mask_union = np.bitwise_or(mask_union, self.total_mask) if self.pending_mask is not None: mask_union = np.bitwise_or(mask_union, self.pending_mask) if mask_union is not None and np.any(mask_union): region_img = cv2.bitwise_and( self.cv_image, self.cv_image, mask=mask_union ) pix1 = self._cvimg_to_qpixmap(region_img) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("原图预览") # 下预览:黑底 + 白色 mask_union if mask_union is not None and np.any(mask_union): mask_vis = np.zeros_like(self.cv_image) mask_vis[mask_union > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("处理后预览") self._redraw_main_image() # =================== 交互:点击 / 空格 =================== def on_canvas_clicked(self, x, y, button): if self.cv_image is None or self.gray_image is None: self.main_window._show_error提示("提示", "请先从首页选择图片") return if button != int(Qt.LeftButton): return # label 的尺寸 = 图像尺寸,直接用 (x,y) h, w = self.gray_image.shape if not (0 <= x < w and 0 <= y < h): return self.last_click_img_pt = (x, y) self.current_seeds.append((x, y)) raw_tol = self.high_thresh_slider.value() if self.high_thresh_slider else 40 tolerance = max(1, raw_tol // 6) region_mask = self._segment_region((x, y), tolerance) if region_mask is None: return self.last_region_mask = region_mask if self.pending_mask is None or self.pending_mask.shape != region_mask.shape: self.pending_mask = region_mask.copy() else: self.pending_mask = cv2.bitwise_or(self.pending_mask, region_mask) self.setFocus() # 让空格事件可以被本页面捕获 self._update_previews() def keyPressEvent(self, event): """按空格:把本轮 pending_mask 合并到 total_mask,并记录种子点""" if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): # 合并区域 if self.total_mask is None or self.total_mask.shape != self.pending_mask.shape: self.total_mask = self.pending_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, self.pending_mask) # 保存一个代表种子点(这里用本轮第一个) if self.current_seeds: self.all_region_seeds.append(list(self.current_seeds)) # 清空本轮 self.pending_mask[:] = 0 self.current_seeds.clear() self.last_click_img_pt = None self._update_previews() else: super().keyPressEvent(event) # =================== 导出 DXF =================== def export_dxf(self): """根据 total_mask 的轮廓导出 DXF 文件,显示进度条""" if self.total_mask is None or not np.any(self.total_mask): self.main_window._show_error提示("提示", "还没有任何确认的分割区域(请点击并按空格)") return # 选择保存路径 path, _ = QFileDialog.getSaveFileName( self, "保存 DXF 文件", "output.dxf", "DXF 文件 (*.dxf)" ) if not path: return # 查找轮廓 mask_u8 = (self.total_mask > 0).astype(np.uint8) * 255 contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: self.main_window._show_error提示("提示", "没有找到可导出的轮廓") return # 进度条 progress = QProgressDialog("正在生成 DXF ...", "取消", 0, len(contours), self) progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) # 创建 DXF doc = ezdxf.new() msp = doc.modelspace() h, w = mask_u8.shape for i, contour in enumerate(contours): if progress.wasCanceled(): break pts = contour[:, 0, :].astype(float) # DXF 坐标 Y 轴反向 pts_dxf = [(float(x), float(-y)) for (x, y) in pts] # 闭合折线 if len(pts_dxf) > 0: pts_dxf.append(pts_dxf[0]) msp.add_lwpolyline(pts_dxf) progress.setValue(i + 1) if not progress.wasCanceled(): try: doc.saveas(path) self.main_window._show_info提示("完成", f"DXF 已保存到:\n{path}") except Exception as e: self.main_window._show_error提示("保存失败", f"写入 DXF 时出错:{e}") progress.close() # =================== 其它操作 =================== def reset_parameters(self): self.kernel_slider.setValue(3) self.high_thresh_slider.setValue(40) self.low_thresh_slider.setValue(10) self.extra_slider.setValue(50) if self.gray_image is not None: h, w = self.gray_image.shape self.pending_mask = np.zeros((h, w), np.uint8) self.total_mask = np.zeros((h, w), np.uint8) self.last_region_mask = np.zeros((h, w), np.uint8) self.current_seeds.clear() self.all_region_seeds.clear() self.last_click_img_pt = None self._update_previews()

小结一下现在的行为

  • 菜单栏:就在最顶部,文件 / 编辑 / 图像 / ... 都能看到,而且高度固定;
  • 主图:按原图尺寸显示在左侧,
    • 图比窗口小 → 居中显示;
    • 图比窗口大 → 出现滚动条;
      点击不会再导致“越点越大”;
  • 预览框(右上 & 右中):固定大小,
    • 上面 = 已确认区域 + 当前临时区域 的彩色预览;
    • 下面 = 同一张的黑底白掩码。
  • 空格:提交本轮选区(类似你原来的“按空格合并”逻辑),并记住种子点;
  • 生成按钮:扫描 total_mask 轮廓 → 写 DXF → 用进度条显示进度。

如果你想把之前 Stone 贴图、骨架化、角点聚类那些高级逻辑也搬进来,可以在 export_dxf 里把现在的“简单轮廓导出”替换成你那套 process_mask2 流程,我们可以再一起对接;先把这一版 UI + 分割/导出基础逻辑稳定下来比较靠谱。

Question

右边的预览图需要实时显示我在本次操作后的分割结果图,上面的为本次分割的结果,下面的为分割效果的叠加掩膜图

Réflexion

Updating previews for segmentation operation

The user wants to adjust the preview behavior. Preview1 should show the pending segmentation result for this operation, while Preview2 should show the overlay of all operations. We'll also update the method to use pending_mask for Preview1 and total_mask for Preview2, incorporating a color overlay for clarity.

Updating real-time previews and patch details

The user wants real-time updates, so I’ll call _update_previews in both the "on_canvas_clicked"

Thought for 11s

Réponse

好,这个需求很清晰,我只改右边两个预览框的逻辑,其它保持不动:

  • 上面的预览:只显示「本次操作的分割结果」——也就是当前这一轮的 pending_mask(你还没按空格之前,当前所有点出来的区域)。
  • 下面的预览:显示「所有已确认分割的叠加掩膜图」——也就是 total_mask 叠加到原图上的效果。
  • 都是实时刷新:每次点击、按空格之后都会更新。

你只需要把我上一个完整代码里 _update_previews 那个函数整体替换成下面这段就行:

python
def _update_previews(self): """右侧预览: 上:本次分割结果(pending_mask) 下:所有历史确认结果的叠加掩膜(total_mask) """ if self.cv_image is None: return # ========= 上预览:本次分割结果 ========= if self.pending_mask is not None and np.any(self.pending_mask): # 只显示当前这一轮选中的区域(作用在原图上) pending_region = cv2.bitwise_and( self.cv_image, self.cv_image, mask=self.pending_mask ) pix1 = self._cvimg_to_qpixmap(pending_region) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: # 当前这一轮还没选东西 self.preview1.setPixmap(QPixmap()) self.preview1.setText("本次分割结果") # ========= 下预览:所有历史分割的叠加掩膜 ========= if self.total_mask is not None and np.any(self.total_mask): # 在原图上叠加红色掩膜,方便看整体效果 overlay = self.cv_image.copy() color_mask = np.zeros_like(overlay) color_mask[self.total_mask > 0] = (0, 0, 255) # 红色区域 overlay = cv2.addWeighted(overlay, 0.7, color_mask, 0.3, 0) pix2 = self._cvimg_to_qpixmap(overlay) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("分割叠加掩膜") # 主画布也更新(主画布还是:total_mask + pending_mask 的综合效果) self._redraw_main_image()

现在的行为就是:

  • 左键在图上点几下:

    • 左侧主图:红色 = 之前按空格确认过的区域;黄色(或青色)= 本轮临时区域;
    • 右上预览:只显示这次操作出来的区域(还没按空格之前就是当前 pending 的所有块);
    • 右下预览:显示“红色掩膜叠加到原图上的整体效果”。
  • 按空格:

    • 当前 pending_mask 合并进 total_mask,右上变成空(本轮结束),右下更新为新的整体掩膜效果。

如果你后面想让右下预览也改成纯黑底白掩膜(而不是叠加到原图上),只需要把下半段的 overlay 部分改成:

python
mask_vis = np.zeros_like(self.cv_image) mask_vis[self.total_mask > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) ...

就可以了。

Question

import math
from collections import OrderedDict
import ezdxf
import cv2
import os
import sys
from tkinter import Tk, filedialog
from skimage import morphology
from scipy.spatial import KDTree
import numpy as np
from tqdm import tqdm

TOLERANCE = 20
midcount = 50
lab_bins = None
file_bins = None
image = None
clone_image = None
seed_list = []
seed_points = []
total_Mask = None
display_image = None
skeletonize_mask = None
gray_image = None
proceed_mask = None
proceed_image = None
gray_image_new = None
dbscan_points = []
kdtree = None
COUNT = 0
closest = None
grid_dict = {}
grid_size = 3
tree = None

def grayscale_custom(image):
# 加权平均:Y = 0.299R + 0.587G + 0.114B
b, g, r = cv2.split(image)
gray = 0.114 * b + 0.587 * g + 0.299 * r
return gray.astype(np.uint8)

def fullfill_mask(image, point, tolerance, fill_flag, value):
if fill_flag == 0:
flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY
elif fill_flag == 1:
flag = cv2.FLOODFILL_FIXED_RANGE
mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag)
return mask

def mouse_callback(event, x, y, flags, param):
global clone_image, proceed_mask, gray_image, proceed_image, gray_image_new, seed_list, COUNT
if event == cv2.EVENT_LBUTTONDOWN or event == cv2.EVENT_RBUTTONDOWN:
proceed_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
proceed_image = clone_image.copy()
if event == cv2.EVENT_LBUTTONDOWN:
seed_points.append([x, y])
print(f"添加种子点: ({x}, {y})")
elif event == cv2.EVENT_RBUTTONDOWN:
# clone_image = image.copy()
if seed_points:
seed_points.pop()
print("回退一步种子点")
else:
print("无法回退")
if seed_points:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# color_x = seed_points[0][0]
# color_y = seed_points[0][1]
value = ((COUNT + 1) * 5) % 255
for point in seed_points:
mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
process_clone_image(point, mask)
proceed_mask = cv2.bitwise_or(proceed_mask, mask)
proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
gray_image_new = cv2.bitwise_or(gray_image_new, proceed_mask * value)

text
cv2.imshow("Image", proceed_image) else: cv2.imshow("Image", proceed_image)

def mask_process():

def process_clone_image(point, mask):
global proceed_image
mask = mask[1:-1, 1:-1]
# contour = cv2.Canny(mask,0,0)
contour, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(proceed_image, contour, -1, 255, thickness=2)
# clone_image = cv2.add(clone_image,contour)
cv2.circle(proceed_image, point, 1, (0, 255, 0), 3)

def choose_right_image():
global image, COUNT
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择石材图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
image_stone = cv2.imread(file_path)
image_size = (image.shape[1], image.shape[0])
image_stone = cv2.resize(image_stone, image_size)
return image_stone
# global COUNT
# folder_path = r"python_image/stone"
# # 获取文件夹中所有符合要求的图片文件
# valid_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"]
# image_files = [f for f in os.listdir(folder_path) if any(f.lower().endswith(ext) for ext in valid_extensions)]
#
# # 确保文件夹中有图片
# if len(image_files) == 0:
# print("没有找到有效的图片文件。")
# return None
#
# # 根据 COUNT 顺序选择图片
# image_file = image_files[COUNT % len(image_files)] # 保证不越界
# image_path = os.path.join(folder_path, image_file)
#
# # 读取图片
# image_stone = cv2.imread(image_path)
#
# # 调整大小与目标图像一致
# image_size = (image.shape[1], image.shape[0]) # 使用目标图像的大小
# image_stone = cv2.resize(image_stone, image_size)
#
#
# return image_stone

def grid_simplification_corner_point(points): # 为角点构建聚类矩阵
"""
使用每个网格单元的第一个点坐标作为代表点进行简化。
"""
global grid_dict, grid_size
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
# 创建一个字典来存储每个网格单元的第一个点 for i in range(len(points)): x, y = points[i] min_x, max_x = x - grid_size, x + grid_size min_y, max_y = y - grid_size, y + grid_size key = (min_x, max_x, min_y, max_y) if key not in grid_dict: grid_dict[key] = points[i] # 只记录第一个遇到的点 # 取出每个网格的代表点 # replaced_points = np.array([grid_dict[(grid_x[i], grid_y[i])] for i in range(len(points))]) # df = pd.DataFrame(replaced_points, columns=['x', 'y']) # df_unique = df.drop_duplicates(keep='first')

def replace_cornerpoints(points):
global grid_dict, grid_size
new_points = []
replacement_flags = []
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break # 计算当前点所属的网格的 key # 如果该网格内没有点,则将当前点存入字典 if not replace: new_points.append(pt) # 保留当前点 replacement_flags.append(0) # 将新的点集转换为 numpy 数组并返回 return np.array(new_points), np.array(replacement_flags)

def operation_of_blank():
global seed_points, total_Mask, proceed_mask, clone_image, image, COUNT, display_image, skeletonize_mask
if seed_points:
kernel = np.ones((3, 3), np.uint8)
# proceed_mask = cv2.erode(proceed_mask, kernel, iterations=1)
seed_list.append(seed_points[0])
seed_points = []
# image_stone = choose_right_image()
temp_mask = proceed_mask[1:-1, 1:-1] * 255
total_Mask = cv2.bitwise_or(total_Mask, proceed_mask)
# proceed_mask = cv2.erode(proceed_mask,kernel,iterations=1)
# skeletonize_mask = cv2.bitwise_or(skeletonize_mask, proceed_mask)
canny = cv2.Canny(proceed_mask * 255, 50, 100)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask, canny)
temp_mask = cv2.cvtColor(temp_mask, cv2.COLOR_GRAY2BGR)
# temp_mask = np.where(temp_mask == 255, image_stone, temp_mask)
# display_image = cv2.bitwise_or(display_image, temp_mask)

text
total_Mask_3 = (total_Mask[1:-1, 1:-1] * 255) total_Mask_3 = cv2.cvtColor(total_Mask_3, cv2.COLOR_GRAY2BGR) # # total_stone_image = np.where(total_Mask_3 == 255, image_stone, total_Mask_3) # clone_image = image.copy() clone_image = cv2.add(clone_image, total_Mask_3) COUNT += 1 # total_stone_image = total_stone_image.astype(np.uint8) cv2.imshow("stone_image", skeletonize_mask) # cv2.imshow("image2", gray_image_new) # cv2.imshow("Image1", clone_image)

def operation_of_save():
global total_Mask, gray_image_new, seed_list, skeletonize_mask, image, gray_image
kernel = np.ones((3, 3), np.uint8) # 视腐蚀的缝隙而定
if len(seed_list) > 1:
gray_image_new = cv2.equalizeHist(gray_image_new)
gray_image_new = gray_image_new[1:-1, 1:-1]
total_Mask = total_Mask[1:-1, 1:-1]
total_Mask_not = cv2.bitwise_not(total_Mask * 255)
# total_Mask_not_3 = cv2.cvtColor(total_Mask_not, cv2.COLOR_GRAY2BGR)
gray_image_new = cv2.add(gray_image_new, total_Mask_not)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask[1:-1, 1:-1], total_Mask_not)
skeletonize_mask = cv2.morphologyEx(skeletonize_mask, cv2.MORPH_CLOSE, kernel)
# skeletonize_mask_not = cv2.bitwise_not(skeletonize_mask*255)
# skeletonize_image = cv2.morphologyEx(skeletonize_mask_not, cv2.MORPH_CLOSE, kernel)
skeletonize_image = skeletonize_skimage(skeletonize_mask)
corners = cv2.goodFeaturesToTrack(skeletonize_image, maxCorners=200, qualityLevel=0.1, minDistance=10)
corners = corners.reshape(-1, 2)
# print(corners.shape)
if corners is not None:
for i in corners:
x, y = i.ravel()
cv2.circle(gray_image, (int(x), int(y)), 2, (0, 0, 0), -1)
# erode = cv2.erode(gray_image_new, kernel, iterations=1)
# gray_image_new = np.where(gray_image_new == 255, erode, gray_image_new)
# canny = cv2.Canny(gray_image_new, 0, 0)
# seed_list = list(set(seed_list))
# skeletonize_image = cv2.morphologyEx(skeletonize_image,cv2.MORPH_CLOSE,kernel)
grid_simplification_corner_point(corners)
cv2.imshow("12345", gray_image)
process_mask2(skeletonize_image)
# mask_porcess()
# skeletonize_skimage(total_Mask_not)

def distance(p1, p2):
return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def skeletonize_skimage(image):
# 执行骨架化
skeleton = morphology.skeletonize(image)
skeleton_uint8 = skeleton.astype(np.uint8) * 255
return skeleton_uint8

def cluster_points_with_core_points(input_points, eps):
global dbscan_points
clustered_points = []
for point in input_points:
neighbor = get_closest_neighbor(point, eps)
if neighbor is not None:
clustered_points.append(neighbor)
else:
clustered_points.append(tuple(point))
dbscan_points.extend(clustered_points)
clustered_points = list(OrderedDict.fromkeys(clustered_points))
# first_two_points = clustered_points[:2]
# clustered_points.extend(first_two_points)
return clustered_points

def get_closest_neighbor(point, eps):
global dbscan_points, closest
min_dist = float('inf')
if dbscan_points:
closest = None
for core_point in dbscan_points:
dist = distance(point, core_point)
if dist < min_dist and dist < eps:
min_dist = dist
closest = core_point

return closest

def mask_porcess():
global gray_image_new, seed_list
doc = ezdxf.new()
msp = doc.modelspace()
# dwg = svgwrite.Drawing("output.svg", size=("100%", "100%"))
for point in seed_list:
mask = fullfill_mask(gray_image_new, point, 0, 0, None)
contours, _ = cv2.findContours(mask[1:-1, 1:-1], cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
print(len(contours))
for contour in contours:
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# d = generate_svg_from_points(part_contourPoints)
# dwg.add(dwg.path(d=d, stroke="black", fill="none", stroke_width=1))
# points_py = [tuple(map(float, pt)) for pt in part_contourPoints]
# dwg.add(dwg.polyline(points=points_py, stroke="black", fill="none", stroke_width=1))
# dwg.save()
# part_contourPoints = rdp(part_contourPoints, epsilon=1)
part_contourPoints = cluster_points_with_core_points(part_contourPoints, 30)
# part_contourPoints = rdp(part_contourPoints, epsilon=1.0)
# part_contourPoints.append(part_contourPoints[0])
# msp.add_lwpolyline(part_contourPoints)
# for point in part_contourPoints:
# msp.add_point(point)
msp.add_open_spline(part_contourPoints)
# cv2.drawContours(proceed_mask, contours, 1, 255, thickness=2)

text
# print("添加成功") doc.saveas("nanna13.dxf")

def process_mask2(image):
global seed_list, midcount
kernel = np.ones((3, 3), np.uint8)
doc = ezdxf.new()
msp = doc.modelspace()
for point in tqdm(seed_list, desc="abstract"):
p_image = image.copy()
fullfill_mask(p_image, point, 0, 1, 255)
mask = fullfill_mask(p_image, point, 0, 0, None)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# corners = cv2.goodFeaturesToTrack(mask, maxCorners=200, qualityLevel=0.03, minDistance=10)
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
for contour in contours:
# for contour in tqdm(contours, desc="abstract"):
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# part_contourPoints = grid_simplification_first_point(part_contourPoints,10)
# part_contourPoints = visvalingam_whyatt_simplify(part_contourPoints,2)
# part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
part_contourPoints, replace_flags_1 = replace_cornerpoints(part_contourPoints)
for i in range(midcount):
part_contourPoints, replace_flags = get_midpoints(part_contourPoints, replace_flags_1)
replace_flags_1 = replace_flags
# for point in part_contourPoints:
# msp.add_point(point)
# part_contourPoints = rdp(part_contourPoints, epsilon=10)
part_contourPoints[:, 1] = -part_contourPoints[:, 1]
part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
msp.add_lwpolyline(part_contourPoints)
doc.saveas("dddd.dxf")

def euclidean_distance(p1, p2):
return np.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def get_midpoints(contour_points, replacement_flags_1):
midpoints = []
replacement_flags = []
num_points = len(contour_points)

text
for i in range(num_points): # 当前点和下一个点的索引,最后一个点与第一个点连接 p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue else: if replacement_flags_1[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2 midpoints.append(midpoint) replacement_flags.append(0) # dist = euclidean_distance(p1, p2) # if dist > 10: # num_points = int(np.floor(dist / 10)) # 向下取整,得到点的数量 # # 生成插入的点 # points = [p1 + i * (p2 - p1) / (num_points + 1) for i in range(1, num_points + 1)] # points = np.vstack([p1, points]) # points = np.vstack([points, p2]) # midpoints.extend(points) # # 计算两点的中点 # else: return np.array(midpoints), replacement_flags

def main():
global image, clone_image, total_Mask, display_image, skeletonize_mask
global lab_bins, file_bins
global gray_image, gray_image_new, proceed_mask, proceed_image

text
root = Tk() root.withdraw() file_path = filedialog.askopenfilename( title="选择图片文件", filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")] ) if not file_path: print("无法加载图像,请检查文件格式或路径。") return lab_bins = np.load("./Stone_Data/stone_store_color.npy").astype(np.float64) file_bins = np.load("./Stone_Data/stone_store_file.npy") tree = KDTree(lab_bins) # 读图 + 预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() # ---- 灰度图 & 初始 mask ---- # gray_image = grayscale_custom(image) # 如果你想用自己写的 gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) h, w = gray_image.shape gray_image_new = np.zeros((h + 2, w + 2), np.uint8) total_Mask = np.zeros((h + 2, w + 2), np.uint8) skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_image = clone_image.copy() display_image = np.zeros((h, w, 3), dtype=np.uint8) # ---- 主循环 ---- while True: cv2.imshow("Image", clone_image) cv2.imshow("total_mask", total_Mask * 255) cv2.setMouseCallback("Image", mouse_callback) key = cv2.waitKey(0) if key == ord(' '): operation_of_blank() elif key == ord('s'): operation_of_save() elif key == 27: # ESC break cv2.destroyAllWindows()

if name == "main":
main()
总结精简一下有多少个重要的参数可以放在页面上进行调节

Réflexion

Summarizing key parameters for UI sliders

I'll summarize the important parameters to expose on the UI:

  1. Segmentation tolerance (TOLERANCE)
  2. Morphological kernel sizes for clean-up
  3. Canny edge detection thresholds
  4. Corner detection parameters: qualityLevel and minDistance
  5. Midpoint iterations (midcount)
  6. Grid size
  7. Cluster parameters: eps I’ll omit unused functions like KDTree and stone color options, as they aren't relevant here.

Slimming down essential parameters

Here are the key parameters for UI sliders:

  1. FloodFill tolerance (TOLERANCE = 20)
  2. FloodFill mode (mask-only vs painting, maybe not as a slider)
  3. Morphological kernel for close (size for proceed_mask)
  4. Corner detection: qualityLevel and minDistance
  5. Midpoint iterations (midcount = 50)
  6. Grid size (grid_size = 3)
  7. Process mask: eps and kernel (3,3) I’ll skip constants and focus on making the UI more concise.

Picking essential parameters for UI sliders

So, the most important parameters to expose through UI sliders could be:

  1. FloodFill tolerance size
  2. GaussianBlur kernel size/sigma
  3. Morphological kernel for edge clean-up
  4. Canny thresholds for edge detection
  5. Corner detection: qualityLevel & minDistance
  6. Midcount for midpoint subdivision details
  7. Grid size for corner simplification

Some, like corner detection, might be considered advanced. I’ll focus on the ones that'll have the clearest visual impact.

Proposing basic and advanced UI sliders

Let's break it down into two levels:
Basic:

  1. Gaussian blur strength (kernel size/sigma)
  2. FloodFill tolerance
  3. Morphological kernel for region cleaning
  4. Skeletonization kernel (erosion/close)
  5. Midcount for contour smoothing
  6. Grid size for corner simplification

Advanced:
7. Canny thresholds (2 sliders) for edge detection
8

Thought for 1m 13s

Réponse

我直接帮你从这份脚本里“挑参数”+“去重”,看哪些真的值得放到界面上调节。


一、脚本里出现的可调常量

代码里现在这些数值是写死的:

  1. Gaussian 模糊

    python
    image = cv2.GaussianBlur(image, (5, 5), 1)
  2. 区域生长容差

    python
    TOLERANCE = 20 mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
  3. 区域闭运算核大小(5×5)

    python
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
  4. 其它 3×3 形态学核大小

    python
    kernel = np.ones((3, 3), np.uint8) skeletonize_mask = cv2.morphologyEx(skeletonize_mask, cv2.MORPH_CLOSE, kernel) # 以及 process_mask2 里的 open/close
  5. Canny 边缘阈值

    python
    canny = cv2.Canny(proceed_mask * 255, 50, 100)
  6. 骨架化本身:morphology.skeletonize(没有参数)

  7. 角点检测参数

    python
    corners = cv2.goodFeaturesToTrack( skeletonize_image, maxCorners=200, qualityLevel=0.1, minDistance=10 )
  8. 角点网格简化半径

    python
    grid_size = 3
  9. 轮廓细分次数(插中点循环次数)

    python
    midcount = 50 for i in range(midcount): part_contourPoints, replace_flags = get_midpoints(...)

二、真正“重要且有手感”的参数(建议放到页面上)

我帮你压缩成 6 个核心参数,既能控制主要效果,又不会把面板搞得太复杂:

1. 分割容差(TOLERANCE)——控制“淹没范围”

  • 对应:TOLERANCE = 20
  • 作用:控制 floodFill 的“吃色”范围,数值越大,区域越宽松、越容易把相近颜色连成一片。
  • 建议 UI:滑块:0–50
    • 默认:20
    • 标签:分割容差

2. 预处理模糊强度(Gaussian Blur)

  • 对应:cv2.GaussianBlur(image, (5, 5), 1)
  • 作用:平滑噪声,边界更连贯;太大则细节被抹掉。
  • 建议 UI:一个滑块控制核大小(奇数)
    • 范围:1, 3, 5, 7, 9(内部用滑块值映射到奇数)
    • 标签:模糊半径
  • 可选:把 sigma 写死为 0 让 OpenCV 自算,减少一个参数。

3. 区域清理核大小(proceed_mask 的闭运算)

  • 对应:
    python
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
  • 作用:填小洞、连接细碎边缘;越大越“圆润”,也越粗糙。
  • 建议 UI:滑块控制核尺寸(奇数)
    • 范围:1~11 → 映射到 (1,1), (3,3), ..., (11,11)
    • 标签:区域平滑

4. 骨架 & 边缘强度(Canny)

  • 对应:cv2.Canny(..., 50, 100)
  • 作用:影响 skeletonize_mask 的细节多少;阈值低→骨架复杂、支线多;阈值高→更干净。
  • 为了不弄两个滑块,可以做一个“强度”滑块:
    • 例如:
      text
      slider S: 0~100 low = S high = 2 * S
  • 建议 UI:
    • 单滑块 骨架边缘强度,默认 S=50(变成 (50,100))。

5. DXF 曲线精细度(midcount)

  • 对应:midcount = 50,控制插中点的迭代次数。
  • 作用:
    • midcount 大:线段被分得很细,DXF 曲线非常平滑,但点数很多、文件大。
    • midcount 小:点少,线条更“折线感”,但速度快、文件小。
  • 建议 UI:滑块:1~80
    • 默认:50
    • 标签:曲线精细度

6. 角点网格简化半径(grid_size)

  • 对应:grid_size = 3
  • 作用:在角点上做简单聚类,同一小格子里只保留一个点,越大越“去噪平滑”,越小越保留细节。
  • 建议 UI:滑块:1~10
    • 默认:3
    • 标签:角点合并半径

三、可以隐藏到“高级设置”的参数(非必需)

这些不是没用,而是用得少、用户难理解,可以先藏起来:

  1. 角点检测参数(goodFeaturesToTrack)

    • maxCorners=200:大多数图 200 个角点够用了;
    • qualityLevel=0.1minDistance=10:控制角点“可信度”和间距,普通用户不好调。
  2. 骨架阶段的 3×3 kernel

    • 你已经有一个“区域平滑核大小”参数,骨架那边就用一个固定 3×3 或跟随前面的设置即可。
  3. Canny 两个阈值细节

    • 用上面说的单一“强度”滑块就够;真正高阶用户可以去配置文件里直接改。

四、参数布局建议(给你现在的右侧面板)

右侧面板你已经有 4 个滑块位置了,现在可以这样重新命名/映射:

  1. 核大小模糊半径(控制 Gaussian blur 核大小)
  2. 高阈值分割容差(控制 TOLERANCE)
  3. 低阈值区域平滑(控制闭运算 kernel 大小)
  4. 额外参数骨架边缘强度(映射到 Canny low/high)

然后在“生成”旁边再加一个可折叠的“高级设置”按钮,里面放:

  • 曲线精细度(midcount)
  • 角点合并半径(grid_size)

如果你愿意,下一步我可以直接按你现在的 PyQt 右侧 UI,帮你把这 6 个参数写成真正的 slider→参数映射代码(包括默认值、range 和如何更新到这份分割脚本里)。

Question

image.png

D:\Computer_items\miniConda\soft\envs\py310\python.exe D:\Computer_items\Pycharm\python_code\ArtPartioner\Soft_Main.py
2025-12-11 16:30:28,859 - ArtPartitioner.Main - INFO - 初始化页面...
2025-12-11 16:30:38,018 - ArtPartitioner.Main - INFO - 跳转到图片页并加载图片:D:/Computer_items/Pycharm/python_code/python_image/mapofnanan.png
2025-12-11 16:30:38,038 - ArtPartitioner.Main - INFO - 图片加载成功:D:/Computer_items/Pycharm/python_code/python_image/mapofnanan.png
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_COLOR dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_GROUP dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_LAYOUT dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MATERIAL dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MLEADERSTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MLINESTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_PLOTSETTINGS dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_PLOTSTYLENAME dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_SCALELIST dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_TABLESTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_VISUALSTYLE dictionary
2025-12-11 16:31:10,666 - ezdxf - INFO - did not write header var INTERFEREOBJVS,valueisNone.2025121116:31:10,666ezdxfINFOdidnotwriteheadervarINTERFEREOBJVS, value is None. 2025-12-11 16:31:10,666 - ezdxf - INFO - did not write header var INTERFEREVPVS, value is None.
俩个问题:首先对于选取后的原图部分的填充不要改变原图,侧边的下面预览图对于改为掩膜叠加二值,上面的菜单栏颜色需要和之前一样,最后dxf文件的生成出现问题

Question

D:\Computer_items\miniConda\soft\envs\py310\python.exe D:\Computer_items\Pycharm\python_code\ArtPartioner\Soft_Main.py
2025-12-11 16:30:28,859 - ArtPartitioner.Main - INFO - 初始化页面...
2025-12-11 16:30:38,018 - ArtPartitioner.Main - INFO - 跳转到图片页并加载图片:D:/Computer_items/Pycharm/python_code/python_image/mapofnanan.png
2025-12-11 16:30:38,038 - ArtPartitioner.Main - INFO - 图片加载成功:D:/Computer_items/Pycharm/python_code/python_image/mapofnanan.png
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_COLOR dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_GROUP dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_LAYOUT dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MATERIAL dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MLEADERSTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_MLINESTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_PLOTSETTINGS dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_PLOTSTYLENAME dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_SCALELIST dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_TABLESTYLE dictionary
2025-12-11 16:31:10,638 - ezdxf - INFO - creating ACAD_VISUALSTYLE dictionary
2025-12-11 16:31:10,666 - ezdxf - INFO - did not write header var INTERFEREOBJVS,valueisNone.2025121116:31:10,666ezdxfINFOdidnotwriteheadervarINTERFEREOBJVS, value is None. 2025-12-11 16:31:10,666 - ezdxf - INFO - did not write header var INTERFEREVPVS, value is None.
俩个问题:首先对于选取后的原图部分的填充不要改变原图,侧边的下面预览图对于改为掩膜叠加二值,上面的菜单栏颜色需要和之前一样,最后dxf文件的生成出现问题

Réflexion

Fixing PyQt logic for message handling

It looks like the user encountered an error related to missing _show_info提示. We can’t change the main_window, so we'll adjust the existing method calls to _show_error提示 instead. For success messages, we might either use the error method or skip messaging altogether. Also, avoid the overlay on the original image.

Adjusting image handling per user feedback

The user wants the original image unchanged, so let's ensure the redrawn main image only displays the original without any overlays. The previews should show overlays: preview1 and preview2 with black backgrounds, white shapes, and binary mask values (0/255). Also, we need to revert the top menu bar color to dark gray (#333).

Adjusting UI style and error handling for code changes

The user wants the main window styled like before, with a dark grey background and white text for the menu and toolbar. For error handling, we'll use _show_error提示 for both error and info messages if _show_info提示 doesn’t exist. This way, they get consistent behavior without needing to inspect runtime attributes.

Handling DXF generation error and preview changes

The DXF generation issue isn't related to writing the file but to the missing _show_info提示 method, which caused an error in displaying the message. I’ll fix that. For the previews, the bottom one should show a black/white union of confirmed and pending areas. Additionally, the left canvas should only show the original image, not any segmentation overlays.

Thought for 27s

Réponse

我先说一下这次出了什么问题:

  • DXF 实际上已经写成功了,报错只是因为我在成功分支里调用了你主窗口没有的函数 _show_info提示
  • 左边主图现在会被“染红”,确实改变了原图显示。
  • 右下预览还是彩色叠加,不是你要的“二值掩膜叠加效果”。
  • 菜单栏样式没恢复成你之前那种深灰+白字。

下面我用最少改动把这几件事一次修完,你只要在当前 ImageOperationPage 里替换对应函数/片段就行。


1. 主图:不再填充选区,只显示原图(最多加个绿点)

把你类里的 _redraw_main_image 整个替换成下面这个版本:

python
def _redraw_main_image(self): """主画布:只显示原图本身,不做任何填充叠加;最多画一下点击点""" if self.cv_image is None: self.canvas_label.clear() self.canvas_label.setText("请从首页选择图片") return base = self.cv_image.copy() # 只画点击点,便于用户知道点到哪儿了,不改原图内容 if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 4, (0, 255, 0), -1) pix = self._cvimg_to_qpixmap(base) self.canvas_label.setPixmap(pix) # label 大小 = 图像大小,scrollArea 决定是否滚动 self.canvas_label.resize(pix.size())

这样左侧主图永远是“原图 + 绿色种子点”,不会再被红色/黄色区域覆盖。


2. 右边预览:上 = 本次分割;下 = 二值掩膜叠加

你现在的 _update_previews 改成下面这个版本(整体替换):

python
def _update_previews(self): """ 右侧预览: 上:本次操作的分割结果(pending_mask 作用在原图上的彩色图) 下:所有历史确认 + 当前本轮 的二值掩膜(黑底白块) """ if self.cv_image is None: return # ===== 上预览:本次分割结果(pending_mask) ===== if self.pending_mask is not None and np.any(self.pending_mask): pending_region = cv2.bitwise_and( self.cv_image, self.cv_image, mask=self.pending_mask ) pix1 = self._cvimg_to_qpixmap(pending_region) pix1 = pix1.scaled( self.preview1.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview1.setPixmap(pix1) self.preview1.setText("") else: self.preview1.setPixmap(QPixmap()) self.preview1.setText("本次分割结果") # ===== 下预览:所有分割的叠加二值掩膜 ===== # union = 历史确认(total_mask) + 当前 pending if self.total_mask is not None: mask_union = self.total_mask.copy() else: mask_union = None if self.pending_mask is not None and np.any(self.pending_mask): if mask_union is None: mask_union = self.pending_mask.copy() else: mask_union = cv2.bitwise_or(mask_union, self.pending_mask) if mask_union is not None and np.any(mask_union): mask_vis = np.zeros_like(self.cv_image) # 掩膜位置为白色(255),其它为黑色 mask_vis[mask_union > 0] = (255, 255, 255) pix2 = self._cvimg_to_qpixmap(mask_vis) pix2 = pix2.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix2) self.preview2.setText("") else: self.preview2.setPixmap(QPixmap()) self.preview2.setText("分割叠加掩膜") # 主画布也顺便刷新(但仍然只是原图 + 点) self._redraw_main_image()

这样:

  • 右上框:始终实时显示你这“一轮点击”产生的区域;
  • 右下框:黑底白块,看到全部分割效果(历史 + 这轮);
  • 左边主图不再被改色。

3. 菜单栏颜色恢复成之前那种深灰 + 白字

_init_ui 里创建 menubar = QMenuBar(self) 之后,增加这一段样式设置(如果原来已经有,就用这个覆盖掉):

python
menubar.setStyleSheet(""" QMenuBar { background-color:#333333; color:#ffffff; font-size:14px; } QMenuBar::item:selected { background-color:#555555; } QMenu { background-color:#333333; color:#ffffff; } QMenu::item:selected { background-color:#555555; } """)

这会让顶部看起来和你一开始截图那种风格一致。


4. DXF 生成报错:_show_info提示 不存在

日志里 ezdxf 一切正常,最后抛错是因为我写了:

python
self.main_window._show_info提示("完成", f"DXF 已保存到:\n{path}")

但你的主窗口没有这个方法,只有 _show_error提示

你只需要把 export_dxf 里面“保存成功”的部分换成下面这样更稳妥的写法:

python
from PyQt5.QtWidgets import QMessageBox # ... if not progress.wasCanceled(): try: doc.saveas(path) # 尝试调用主窗口的提示方法,如果没有就退回到 QMessageBox if hasattr(self.main_window, "_show_info提示"): self.main_window._show_info提示("完成", f"DXF 已保存到:\n{path}") elif hasattr(self.main_window, "_show_error提示"): # 退而求其次,用错误提示弹个信息 self.main_window._show_error提示("完成", f"DXF 已保存到:\n{path}") else: QMessageBox.information(self, "完成", f"DXF 已保存到:\n{path}") except Exception as e: if hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("保存失败", f"写入 DXF 时出错:{e}") else: QMessageBox.critical(self, "保存失败", f"写入 DXF 时出错:{e}") progress.close()

这三点改完之后:

  • DXF 文件会正常生成并提示,不再抛 '_show_info提示' 的异常;
  • 左侧主图不会被填充改色,只是原图 + 绿色点;
  • 右上预览 = 当前这轮的分割结果;右下预览 = 所有分割结果的二值掩膜;
  • 顶部菜单栏的颜色/风格和你之前的一致。

如果你愿意,下一步我们可以再把 PyQt 里那 4 个滑块正式映射到你 floodFill / 形态学 / midcount 等参数上,让调节更“有手感”。

Question

image.png

两个问题:生成的DXF不对,需要检查一下逻辑,另外,对于选取后的原图区域置为空白

Réflexion

Debugging DXF export issue

It looks like the DXF export isn't working as desired, producing a single rectangle instead of multiple regions. The issue might come from how we're merging masks and using the contour extraction. I think it's related to how the mask is created, especially with the uint8 format and white/black areas. I'll focus on fixing the mask generation and contour extraction.

Clarifying segmentation and DXF export logic

In the PyQt version, I reimplemented the segmentation with a simpler approach using floodFill, where I generate a region mask and update the total_mask. When exporting the DXF, I convert this mask into a uint8 format. At the export step, findContours extracts each region's outer boundary using RETR_EXTERNAL. So, the mask union should correctly represent selected regions.

Investigating floodFill mask issue

The square screenshot suggests that the mask might be too large, potentially affecting the whole rectangular area. I suspect the issue is in how the mask regions are defined, especially when passing the wrong mask or image dimensions in floodFill. I set the mask’s fill value to 255, but the region should only show the selected area. Still unsure why it's painting the entire rectangle.

Identifying possible bug with bounding box

It seems like the issue might be related to the DXF export and how the bounding rectangle is created—this could happen if the mask is entirely 255. If the mask isn't initialized correctly, it might cause the entire image to be treated as a region. I'll also make sure to update the selected region in the image to be blank afterwards.

Adapting to the new selection based on user’s request

The user wants selected regions to appear blanked, and to keep the original image intact. I can keep the original for reference and apply changes to a working copy. For segmentation, I’ll use the original gray image and make sure the floodFill and contour extraction processes reflect separate regions, avoiding unwanted union of selections. This should help with the DXF export.

Adjusting DXF generation logic for per-seed segmentation

It seems like the issue with the DXF export stems from the bounding rectangle around selected regions. To correct this, I plan to implement per-seed segmentation using the existing seed list (self.all_region_seeds). I’ll call _segment_region for each

Thought for 40s

Réponse

先帮你捋一下两个问题的本质:

  1. DXF 只有一个大矩形
    现在的导出逻辑是对 total_mask 这个“总掩膜”直接 findContours,所有区域被合成了一张大饼,自然只剩最外面一圈轮廓(你截图里的大矩形就是这个效果),而不是“每个选区一个轮廓”。

  2. 选取后的原图区域要变成空白
    目前我把主画布改成“只显示原图”,没有对被确认的区域做“挖空”。

下面我分两块给你补丁:DXF 导出逻辑 + 挖空显示,都是在你现有 ImageOperationPage 里改几段函数,不用重写整份代码。


一、DXF 导出逻辑:改成“按种子点逐块导出”

1. 思路说明

  • 点击 → pending_mask 只是本轮的区域;

  • 按空格 → pending_mask 合并到 total_mask,同时我已经在 keyPressEvent 里维护了:

    python
    self.current_seeds # 本轮点击过的种子点 self.all_region_seeds # 每轮确认的种子点列表(list[list[(x,y)...]])

所以在导出 DXF 时,不要再对 total_mask 整体轮廓 findContours
而是应该:
self.all_region_seeds 里的每一组种子点,重新 floodFill 出各自的区域,然后分别写入 DXF。

这样你得到的是“每块省份 / 区域一个 polyline”,而不是外围大框。

2. 替换 export_dxf 函数

请把你当前类中的 export_dxf 函数整个替换为下面这一版(保留你原来的 import):

python
from PyQt5.QtWidgets import QFileDialog, QProgressDialog, QMessageBox def export_dxf(self): """按每个确认的种子点导出各自轮廓为 DXF,多块区域而不是一个大矩形""" if self.gray_image is None or self.cv_image is None: if hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("提示", "请先加载图片并完成分割") else: QMessageBox.warning(self, "提示", "请先加载图片并完成分割") return # 没有任何确认的种子点 if not self.all_region_seeds and (self.total_mask is None or not np.any(self.total_mask)): if hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("提示", "还没有任何确认的分割区域(点击后按空格确认)") else: QMessageBox.warning(self, "提示", "还没有任何确认的分割区域(点击后按空格确认)") return # 选择保存路径 path, _ = QFileDialog.getSaveFileName( self, "保存 DXF 文件", "output.dxf", "DXF 文件 (*.dxf)" ) if not path: return # 进度条最大数量 = 种子组数(每组对应一个区域) num_regions = max(1, len(self.all_region_seeds)) progress = QProgressDialog("正在生成 DXF ...", "取消", 0, num_regions, self) progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) doc = ezdxf.new() msp = doc.modelspace() # 导出容差,复用“高阈值”滑块 raw_tol = self.high_thresh_slider.value() if self.high_thresh_slider else 40 tolerance = max(1, raw_tol // 6) # 如果 all_region_seeds 为空(比如老工程里没维护),退化到 total_mask 模式 if not self.all_region_seeds: mask_u8 = (self.total_mask > 0).astype(np.uint8) * 255 contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: pts = contour[:, 0, :].astype(float) pts[:, 1] = -pts[:, 1] # Y 轴翻转 pts = np.vstack([pts, pts[0]]) msp.add_lwpolyline(pts) num_regions = 1 progress.setValue(num_regions) else: # 正常:按每个区域的“代表种子点”逐块提轮廓 for idx, seed_group in enumerate(self.all_region_seeds): if progress.wasCanceled(): break if not seed_group: progress.setValue(idx + 1) continue seed = seed_group[0] # 一组里拿第一个种子 # 基于原始灰度重新做一次区域生长,得到该块区域的 mask region_mask = self._segment_region(seed, tolerance) if region_mask is None or not np.any(region_mask): progress.setValue(idx + 1) continue mask_u8 = (region_mask > 0).astype(np.uint8) * 255 contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: pts = contour[:, 0, :].astype(float) pts[:, 1] = -pts[:, 1] # DXF Y 轴反向 pts = np.vstack([pts, pts[0]]) # 闭合 msp.add_lwpolyline(pts) progress.setValue(idx + 1) # 保存 DXF if not progress.wasCanceled(): try: doc.saveas(path) if hasattr(self.main_window, "_show_info提示"): self.main_window._show_info提示("完成", f"DXF 已保存到:\n{path}") elif hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("完成", f"DXF 已保存到:\n{path}") else: QMessageBox.information(self, "完成", f"DXF 已保存到:\n{path}") except Exception as e: if hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("保存失败", f"写入 DXF 时出错:{e}") else: QMessageBox.critical(self, "保存失败", f"写入 DXF 时出错:{e}") progress.close()

小测试方法:
只确认 1 个小区域 → 生成 DXF → 看看是不是只出现那一块轮廓,而不是大矩形。
再确认多个区域 → 生成 DXF → 轮廓应当是一块块独立的 polyline。


二、选取后的原图区域置为空白

你现在的主图仅仅画了绿色点,没有做挖空。
需求是:按空格确认之后,把那一块在画布上“挖掉变成空白”,但灰度图 gray_image 仍然保持原样用于分割。

1. 加一个“工作图像”副本

load_image 里你应该已经有类似:

python
self.cv_image = cv2.imread(file_path) self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY)

在这后面多加一行:

python
self.cv_image_work = self.cv_image.copy() # 用来挖空显示的版本

并在 __init__ 里声明一下:

python
self.cv_image = None self.cv_image_work = None

2. 修改主画布绘制函数:用 cv_image_work

_redraw_main_image 再换成这一版(在上一条的基础上,用工作图像):

python
def _redraw_main_image(self): """主画布:显示被挖空后的工作图像 + 绿色种子点""" if self.cv_image_work is None: self.canvas_label.clear() self.canvas_label.setText("请从首页选择图片") return base = self.cv_image_work.copy() # 只画当前种子点提示一下位置 if self.last_click_img_pt is not None: cx, cy = self.last_click_img_pt cv2.circle(base, (cx, cy), 4, (0, 255, 0), -1) pix = self._cvimg_to_qpixmap(base) self.canvas_label.setPixmap(pix) self.canvas_label.resize(pix.size())

3. 在“空格确认”时挖空当前 pending 区域

在你的 keyPressEvent 里,空格分支目前类似这样:

python
if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): if self.total_mask is None or self.total_mask.shape != self.pending_mask.shape: self.total_mask = self.pending_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, self.pending_mask) if self.current_seeds: self.all_region_seeds.append(list(self.current_seeds)) self.pending_mask[:] = 0 self.current_seeds.clear() self.last_click_img_pt = None self._update_previews()

在“合并 total_mask”之后、清空之前,加一段“挖空”的逻辑:

python
if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): # 1. 累积掩膜 if self.total_mask is None or self.total_mask.shape != self.pending_mask.shape: self.total_mask = self.pending_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, self.pending_mask) # 2. 记录本轮种子点 if self.current_seeds: self.all_region_seeds.append(list(self.current_seeds)) # 3. 在工作图像上把这块区域挖空(置为白色) if self.cv_image_work is not None: cut = (self.pending_mask > 0) self.cv_image_work[cut] = (255, 255, 255) # 你也可以换成背景色 # 4. 清空本轮状态 self.pending_mask[:] = 0 self.current_seeds.clear() self.last_click_img_pt = None self._update_previews()

这样:

  • 你点几下 → pending_mask 里有这几块;
  • 按空格 →
    • 这一轮区域加入总掩膜;
    • 区域在 cv_image_work 上被置为白色;
    • 右上预览清空,右下预览更新;
    • 左侧主图看到那几块已经被“挖掉”。

三、右下预览已经是“二值掩膜叠加”了吗?

上一次我给你的 _update_previews 已经按你要求改成:

  • 上:pending_mask → 当前分割结果;
  • 下:total_mask ∪ pending_mask → 黑底白块。

如果你还看到彩色,那说明你用的是更早那版。
按本次答案里提供的 _update_previews 全量替换后,下预览一定是二值掩膜。


四、小结

现在这几处逻辑是对齐你描述的目标的:

  1. DXF

    • 以“每次按空格确认的一组种子点”为单位,重新 floodFill 各自区域 → 导出为多条 polyline,避免大矩形。
  2. 选取后原图挖空

    • 使用 cv_image_work 作工作图像;
    • 空格时用 pending_mask 把对应区域置为白色;
    • 主画布只基于 cv_image_work 绘制。
  3. 预览

    • 右上:本次(未确认)分割结果;
    • 右下:所有确认结果 + 本次的二值叠加掩膜。

如果你方便的话,可以把现在的 DXF 再截一张效果图给我看看,我可以再帮你针对 Autocad 里的表现调一下坐标缩放 / 方向(比如是否需要整体平移到原点、单位缩放到毫米等)。

Question

import math
from collections import OrderedDict
import ezdxf
import cv2
import os
import sys
from tkinter import Tk, filedialog
from skimage import morphology
from scipy.spatial import KDTree
import numpy as np
from tqdm import tqdm

TOLERANCE = 20
midcount = 50
lab_bins = None
file_bins = None
image = None
clone_image = None
seed_list = []
seed_points = []
total_Mask = None
display_image = None
skeletonize_mask = None
gray_image = None
proceed_mask = None
proceed_image = None
gray_image_new = None
dbscan_points = []
kdtree = None
COUNT = 0
closest = None
grid_dict = {}
grid_size = 3
tree = None

def grayscale_custom(image):
# 加权平均:Y = 0.299R + 0.587G + 0.114B
b, g, r = cv2.split(image)
gray = 0.114 * b + 0.587 * g + 0.299 * r
return gray.astype(np.uint8)

def fullfill_mask(image, point, tolerance, fill_flag, value):
if fill_flag == 0:
flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY
elif fill_flag == 1:
flag = cv2.FLOODFILL_FIXED_RANGE
mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag)
return mask

def mouse_callback(event, x, y, flags, param):
global clone_image, proceed_mask, gray_image, proceed_image, gray_image_new, seed_list, COUNT
if event == cv2.EVENT_LBUTTONDOWN or event == cv2.EVENT_RBUTTONDOWN:
proceed_mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8)
proceed_image = clone_image.copy()
if event == cv2.EVENT_LBUTTONDOWN:
seed_points.append([x, y])
print(f"添加种子点: ({x}, {y})")
elif event == cv2.EVENT_RBUTTONDOWN:
# clone_image = image.copy()
if seed_points:
seed_points.pop()
print("回退一步种子点")
else:
print("无法回退")
if seed_points:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# color_x = seed_points[0][0]
# color_y = seed_points[0][1]
value = ((COUNT + 1) * 5) % 255
for point in seed_points:
mask = fullfill_mask(gray_image, point, TOLERANCE, 0, None)
process_clone_image(point, mask)
proceed_mask = cv2.bitwise_or(proceed_mask, mask)
proceed_mask = cv2.morphologyEx(proceed_mask, cv2.MORPH_CLOSE, kernel)
gray_image_new = cv2.bitwise_or(gray_image_new, proceed_mask * value)

text
cv2.imshow("Image", proceed_image) else: cv2.imshow("Image", proceed_image)

def mask_process():

def process_clone_image(point, mask):
global proceed_image
mask = mask[1:-1, 1:-1]
# contour = cv2.Canny(mask,0,0)
contour, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(proceed_image, contour, -1, 255, thickness=2)
# clone_image = cv2.add(clone_image,contour)
cv2.circle(proceed_image, point, 1, (0, 255, 0), 3)

def choose_right_image():
global image, COUNT
root = Tk()
root.withdraw()
file_path = filedialog.askopenfilename(
title="选择石材图片文件",
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
image_stone = cv2.imread(file_path)
image_size = (image.shape[1], image.shape[0])
image_stone = cv2.resize(image_stone, image_size)
return image_stone
# global COUNT
# folder_path = r"python_image/stone"
# # 获取文件夹中所有符合要求的图片文件
# valid_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif"]
# image_files = [f for f in os.listdir(folder_path) if any(f.lower().endswith(ext) for ext in valid_extensions)]
#
# # 确保文件夹中有图片
# if len(image_files) == 0:
# print("没有找到有效的图片文件。")
# return None
#
# # 根据 COUNT 顺序选择图片
# image_file = image_files[COUNT % len(image_files)] # 保证不越界
# image_path = os.path.join(folder_path, image_file)
#
# # 读取图片
# image_stone = cv2.imread(image_path)
#
# # 调整大小与目标图像一致
# image_size = (image.shape[1], image.shape[0]) # 使用目标图像的大小
# image_stone = cv2.resize(image_stone, image_size)
#
#
# return image_stone

def grid_simplification_corner_point(points): # 为角点构建聚类矩阵
"""
使用每个网格单元的第一个点坐标作为代表点进行简化。
"""
global grid_dict, grid_size
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
# 创建一个字典来存储每个网格单元的第一个点 for i in range(len(points)): x, y = points[i] min_x, max_x = x - grid_size, x + grid_size min_y, max_y = y - grid_size, y + grid_size key = (min_x, max_x, min_y, max_y) if key not in grid_dict: grid_dict[key] = points[i] # 只记录第一个遇到的点 # 取出每个网格的代表点 # replaced_points = np.array([grid_dict[(grid_x[i], grid_y[i])] for i in range(len(points))]) # df = pd.DataFrame(replaced_points, columns=['x', 'y']) # df_unique = df.drop_duplicates(keep='first')

def replace_cornerpoints(points):
global grid_dict, grid_size
new_points = []
replacement_flags = []
# 将点映射到网格中,网格大小为 grid_size
# grid_x = np.floor(points[:, 0] / grid_size).astype(int)
# grid_y = np.floor(points[:, 1] / grid_size).astype(int)

text
for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break # 计算当前点所属的网格的 key # 如果该网格内没有点,则将当前点存入字典 if not replace: new_points.append(pt) # 保留当前点 replacement_flags.append(0) # 将新的点集转换为 numpy 数组并返回 return np.array(new_points), np.array(replacement_flags)

def operation_of_blank():
global seed_points, total_Mask, proceed_mask, clone_image, image, COUNT, display_image, skeletonize_mask
if seed_points:
kernel = np.ones((3, 3), np.uint8)
# proceed_mask = cv2.erode(proceed_mask, kernel, iterations=1)
seed_list.append(seed_points[0])
seed_points = []
# image_stone = choose_right_image()
temp_mask = proceed_mask[1:-1, 1:-1] * 255
total_Mask = cv2.bitwise_or(total_Mask, proceed_mask)
# proceed_mask = cv2.erode(proceed_mask,kernel,iterations=1)
# skeletonize_mask = cv2.bitwise_or(skeletonize_mask, proceed_mask)
canny = cv2.Canny(proceed_mask * 255, 50, 100)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask, canny)
temp_mask = cv2.cvtColor(temp_mask, cv2.COLOR_GRAY2BGR)
# temp_mask = np.where(temp_mask == 255, image_stone, temp_mask)
# display_image = cv2.bitwise_or(display_image, temp_mask)

text
total_Mask_3 = (total_Mask[1:-1, 1:-1] * 255) total_Mask_3 = cv2.cvtColor(total_Mask_3, cv2.COLOR_GRAY2BGR) # # total_stone_image = np.where(total_Mask_3 == 255, image_stone, total_Mask_3) # clone_image = image.copy() clone_image = cv2.add(clone_image, total_Mask_3) COUNT += 1 # total_stone_image = total_stone_image.astype(np.uint8) cv2.imshow("stone_image", skeletonize_mask) # cv2.imshow("image2", gray_image_new) # cv2.imshow("Image1", clone_image)

def operation_of_save():
global total_Mask, gray_image_new, seed_list, skeletonize_mask, image, gray_image
kernel = np.ones((3, 3), np.uint8) # 视腐蚀的缝隙而定
if len(seed_list) > 1:
gray_image_new = cv2.equalizeHist(gray_image_new)
gray_image_new = gray_image_new[1:-1, 1:-1]
total_Mask = total_Mask[1:-1, 1:-1]
total_Mask_not = cv2.bitwise_not(total_Mask * 255)
# total_Mask_not_3 = cv2.cvtColor(total_Mask_not, cv2.COLOR_GRAY2BGR)
gray_image_new = cv2.add(gray_image_new, total_Mask_not)
skeletonize_mask = cv2.bitwise_or(skeletonize_mask[1:-1, 1:-1], total_Mask_not)
skeletonize_mask = cv2.morphologyEx(skeletonize_mask, cv2.MORPH_CLOSE, kernel)
# skeletonize_mask_not = cv2.bitwise_not(skeletonize_mask*255)
# skeletonize_image = cv2.morphologyEx(skeletonize_mask_not, cv2.MORPH_CLOSE, kernel)
skeletonize_image = skeletonize_skimage(skeletonize_mask)
corners = cv2.goodFeaturesToTrack(skeletonize_image, maxCorners=200, qualityLevel=0.1, minDistance=10)
corners = corners.reshape(-1, 2)
# print(corners.shape)
if corners is not None:
for i in corners:
x, y = i.ravel()
cv2.circle(gray_image, (int(x), int(y)), 2, (0, 0, 0), -1)
# erode = cv2.erode(gray_image_new, kernel, iterations=1)
# gray_image_new = np.where(gray_image_new == 255, erode, gray_image_new)
# canny = cv2.Canny(gray_image_new, 0, 0)
# seed_list = list(set(seed_list))
# skeletonize_image = cv2.morphologyEx(skeletonize_image,cv2.MORPH_CLOSE,kernel)
grid_simplification_corner_point(corners)
cv2.imshow("12345", gray_image)
process_mask2(skeletonize_image)
# mask_porcess()
# skeletonize_skimage(total_Mask_not)

def distance(p1, p2):
return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def skeletonize_skimage(image):
# 执行骨架化
skeleton = morphology.skeletonize(image)
skeleton_uint8 = skeleton.astype(np.uint8) * 255
return skeleton_uint8

def cluster_points_with_core_points(input_points, eps):
global dbscan_points
clustered_points = []
for point in input_points:
neighbor = get_closest_neighbor(point, eps)
if neighbor is not None:
clustered_points.append(neighbor)
else:
clustered_points.append(tuple(point))
dbscan_points.extend(clustered_points)
clustered_points = list(OrderedDict.fromkeys(clustered_points))
# first_two_points = clustered_points[:2]
# clustered_points.extend(first_two_points)
return clustered_points

def get_closest_neighbor(point, eps):
global dbscan_points, closest
min_dist = float('inf')
if dbscan_points:
closest = None
for core_point in dbscan_points:
dist = distance(point, core_point)
if dist < min_dist and dist < eps:
min_dist = dist
closest = core_point

return closest

def mask_porcess():
global gray_image_new, seed_list
doc = ezdxf.new()
msp = doc.modelspace()
# dwg = svgwrite.Drawing("output.svg", size=("100%", "100%"))
for point in seed_list:
mask = fullfill_mask(gray_image_new, point, 0, 0, None)
contours, _ = cv2.findContours(mask[1:-1, 1:-1], cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
print(len(contours))
for contour in contours:
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# d = generate_svg_from_points(part_contourPoints)
# dwg.add(dwg.path(d=d, stroke="black", fill="none", stroke_width=1))
# points_py = [tuple(map(float, pt)) for pt in part_contourPoints]
# dwg.add(dwg.polyline(points=points_py, stroke="black", fill="none", stroke_width=1))
# dwg.save()
# part_contourPoints = rdp(part_contourPoints, epsilon=1)
part_contourPoints = cluster_points_with_core_points(part_contourPoints, 30)
# part_contourPoints = rdp(part_contourPoints, epsilon=1.0)
# part_contourPoints.append(part_contourPoints[0])
# msp.add_lwpolyline(part_contourPoints)
# for point in part_contourPoints:
# msp.add_point(point)
msp.add_open_spline(part_contourPoints)
# cv2.drawContours(proceed_mask, contours, 1, 255, thickness=2)

text
# print("添加成功") doc.saveas("nanna13.dxf")

def process_mask2(image):
global seed_list, midcount
kernel = np.ones((3, 3), np.uint8)
doc = ezdxf.new()
msp = doc.modelspace()
for point in tqdm(seed_list, desc="abstract"):
p_image = image.copy()
fullfill_mask(p_image, point, 0, 1, 255)
mask = fullfill_mask(p_image, point, 0, 0, None)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
# corners = cv2.goodFeaturesToTrack(mask, maxCorners=200, qualityLevel=0.03, minDistance=10)
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
for contour in contours:
# for contour in tqdm(contours, desc="abstract"):
part_contourPoints = np.array(contour[:, 0, :], dtype=np.float32)
# part_contourPoints = grid_simplification_first_point(part_contourPoints,10)
# part_contourPoints = visvalingam_whyatt_simplify(part_contourPoints,2)
# part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
part_contourPoints, replace_flags_1 = replace_cornerpoints(part_contourPoints)
for i in range(midcount):
part_contourPoints, replace_flags = get_midpoints(part_contourPoints, replace_flags_1)
replace_flags_1 = replace_flags
# for point in part_contourPoints:
# msp.add_point(point)
# part_contourPoints = rdp(part_contourPoints, epsilon=10)
part_contourPoints[:, 1] = -part_contourPoints[:, 1]
part_contourPoints = np.vstack([part_contourPoints, part_contourPoints[0]])
msp.add_lwpolyline(part_contourPoints)
doc.saveas("dddd.dxf")

def euclidean_distance(p1, p2):
return np.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)

def get_midpoints(contour_points, replacement_flags_1):
midpoints = []
replacement_flags = []
num_points = len(contour_points)

text
for i in range(num_points): # 当前点和下一个点的索引,最后一个点与第一个点连接 p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue else: if replacement_flags_1[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2 midpoints.append(midpoint) replacement_flags.append(0) # dist = euclidean_distance(p1, p2) # if dist > 10: # num_points = int(np.floor(dist / 10)) # 向下取整,得到点的数量 # # 生成插入的点 # points = [p1 + i * (p2 - p1) / (num_points + 1) for i in range(1, num_points + 1)] # points = np.vstack([p1, points]) # points = np.vstack([points, p2]) # midpoints.extend(points) # # 计算两点的中点 # else: return np.array(midpoints), replacement_flags

def main():
global image, clone_image, total_Mask, display_image, skeletonize_mask
global lab_bins, file_bins
global gray_image, gray_image_new, proceed_mask, proceed_image

text
root = Tk() root.withdraw() file_path = filedialog.askopenfilename( title="选择图片文件", filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")] ) if not file_path: print("无法加载图像,请检查文件格式或路径。") return lab_bins = np.load("./Stone_Data/stone_store_color.npy").astype(np.float64) file_bins = np.load("./Stone_Data/stone_store_file.npy") tree = KDTree(lab_bins) # 读图 + 预处理 image = cv2.imread(file_path) image = cv2.GaussianBlur(image, (5, 5), 1) if image is None: print("无法加载图像,图像为空") return clone_image = image.copy() # ---- 灰度图 & 初始 mask ---- # gray_image = grayscale_custom(image) # 如果你想用自己写的 gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) h, w = gray_image.shape gray_image_new = np.zeros((h + 2, w + 2), np.uint8) total_Mask = np.zeros((h + 2, w + 2), np.uint8) skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_image = clone_image.copy() display_image = np.zeros((h, w, 3), dtype=np.uint8) # ---- 主循环 ---- while True: cv2.imshow("Image", clone_image) cv2.imshow("total_mask", total_Mask * 255) cv2.setMouseCallback("Image", mouse_callback) key = cv2.waitKey(0) if key == ord(' '): operation_of_blank() elif key == ord('s'): operation_of_save() elif key == 27: # ESC break cv2.destroyAllWindows()

if name == "main":
main()
你需要整合上述的对于掩膜操作的那段全部逻辑,生成线条平滑的dxf文档,最后点击生成时,下面的预览图为骨架化的图

Réflexion

Integrating mask operations into PyQt GUI

The user wants me to integrate the mask operations, skeletonization, and DXF generation into the PyQt interface we've been working on. They need a smooth DXF output using existing functions like process_mask2, and the bottom-right preview should show a skeleton image when the "生成" button is clicked. I’ll have to port over several functions, keeping everything PyQt-based.

Integrating mask operations and DXF generation pipeline

The user only wants the mask logic and skeleton/DXF integration, not the stone textures. The core algorithm includes accumulating seed points, building a cumulative skeleton mask, and handling the save operation where it modifies the gray image, adjusts masks, performs skeletonization, and generates the DXF using process_mask2.

Adapting process_mask2 for GUI integration

In process_mask2, the function processes each point in the seed list using flood fill, morphological operations, contour detection, and refinement. For integration into the GUI, I can adapt this process by using the GUI’s existing structures like gray_image, total_mask, and seed_list. I'll unify these attributes—like pending_mask and all_region_seeds—for simplicity and better organization.

Emulating space press behavior in GUI

When emulating the space press, we want self.seed_list to store a representative seed for each "region confirm." We'll increase self.total_mask using pending_mask. The approach for flood filling is adjusted for GUI, so small changes are fine. On clicking "生成", a function will process the skeleton and DXF using self.gray_image and self.total_mask.

Integrating segmentation logic and skeleton generation

The user wants the segmentation logic and skeletonization integrated within the GUI, with a smooth DXF output. For this, I’ll replicate the behavior of the original operation_of_save and process_mask2 functions using self.total_mask and self.skeletonize_mask. When a region is confirmed (via space), I’ll update the skeleton and accumulate masks. I'll simplify things by directly porting relevant logic and adapting it to the GUI, ensuring the final preview shows the skeleton when "生成" is clicked.

Building the GUI design for segmentation and skeleton preview

I need to design the page with the left being the main image, the right showing a preview of segmentation at the top, and a preview of the skeleton after generation at the bottom. When a user clicks or presses 'space', the system will process seed points, compute masks, and update previews, showing the union mask while editing. After clicking "生成", the bottom preview will switch to the skeleton. I'll organize attributes and interactions as planned to ensure a smooth workflow.

Updating segmentation to generate DXF and skeleton preview

For generating the DXF after segmentation, I'll update the bottom preview to show the skeleton after pressing "生成." Using functions like operation_of_save and process_mask2, I'll create the skeleton from the total mask, then implement this in the ImageOperationPage with PyQt file dialogs. We'll bridge inputs properly and integrate seed data to produce the correct DXF output with the skeleton preview.

Building skeleton and exporting DXF in a clean structure

To implement the skeleton-building and DXF export, I’ll clean up the user’s approach with some object-oriented structure, encapsulating the process. Methods like _build_skeleton_and_export_dxf, add_seed(), confirm_region(), etc., will go into a class like MaskDxfExporter. The method will handle skeleton creation, grid simplification, corners detection, and DXF export efficiently, ensuring easy integration.

Integrating segmentation logic and DXF export

The user likely wants the updated ImageOperationPage with integrated logic. I'll deliver the full class, including methods for skeleton generation and DXF export. I'll ensure the bottom preview updates to the skeleton image after hitting "generate", as requested, and provide the relevant code snippets with clear instructions for them to update their page. While it’s a bit long, it keeps everything in one place for simplicity.

Designing the MaskProcessor for integration

I think the best approach is to provide a self-contained MaskDxfEngine class. This class would replicate the original script's logic but without Tk integration. The class will handle grayscale image processing, region addition, and DXF export. The user can call add_region and finalize_and_export_dxf from the GUI. I'll make sure it's easy for them to adapt to, keeping the new structure simple but effective for integration.

Thought for 1m 39s

Réponse

好,我帮你把这段“掩膜 + 骨架 + 平滑 DXF”的脚本抽成一个可复用的类,然后告诉你在 PyQt 页里怎么接进去,实现:

  1. 保留你现在的交互(点击选区、空格确认、右侧实时预览)。
  2. 生成 DXF 时使用你这套完整的 mask / skeleton / midpoints 逻辑,线条平滑。
  3. 点击“生成”后,下方预览图切成骨架化图像。

一、核心:MaskDxfEngine 类(集成你脚本里的逻辑)

把下面这段代码放到一个单独的文件,比如 mask_dxf_engine.py,或者直接加到 ImageOperationPage 所在文件的前面。

python
import math from collections import OrderedDict import cv2 import ezdxf import numpy as np from skimage import morphology class MaskDxfEngine: """ 把你原来脚本里和掩膜 / 骨架 / DXF 相关的逻辑做成一个独立引擎, GUI 只需要在“确认区域”和“生成 DXF”时调用它即可。 """ def __init__(self, gray_image: np.ndarray, midcount: int = 50, grid_size: int = 3): """ :param gray_image: 原图的灰度图 (H, W),不带 +2 边框 :param midcount: 细分次数(越大线条越平滑,点数越多) :param grid_size: 角点合并网格半径 """ self.gray_image = gray_image self.midcount = midcount self.grid_size = grid_size h, w = gray_image.shape # 注意:下面几个矩阵跟脚本一样带 +2 边框 self.gray_image_new = np.zeros((h + 2, w + 2), np.uint8) self.total_Mask = np.zeros((h + 2, w + 2), np.uint8) self.skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) # 每次按空格确认时,记录一个代表种子点 self.seed_list: list[tuple[int, int]] = [] # 用于角点网格聚类 self.grid_dict: dict[tuple[int, int, int, int], np.ndarray] = {} # ------------------ 基础工具 ------------------ # @staticmethod def _fullfill_mask(image: np.ndarray, point: tuple[int, int], tolerance: int, fill_flag: int, value=None) -> np.ndarray: """ 你原来的 fullfill_mask,直接搬过来。 image: 这里传入的是带 +2 边框的图 / 骨架 返回:同样带 +2 边框的 mask(0/255) """ if fill_flag == 0: flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY elif fill_flag == 1: flag = cv2.FLOODFILL_FIXED_RANGE else: flag = cv2.FLOODFILL_FIXED_RANGE mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag) return mask @staticmethod def _skeletonize_skimage(image: np.ndarray) -> np.ndarray: """骨架化:跟你原来一样""" skeleton = morphology.skeletonize(image.astype(bool)) skeleton_uint8 = skeleton.astype(np.uint8) * 255 return skeleton_uint8 def _grid_simplification_corner_point(self, points: np.ndarray): """为角点构建一个网格字典(和你原来一样)""" self.grid_dict.clear() for x, y in points: min_x, max_x = x - self.grid_size, x + self.grid_size min_y, max_y = y - self.grid_size, y + self.grid_size key = (int(min_x), int(max_x), int(min_y), int(max_y)) if key not in self.grid_dict: self.grid_dict[key] = np.array([x, y], dtype=np.float32) def _replace_cornerpoints(self, points: np.ndarray): """把落在同一网格里的点替换成代表点""" new_points = [] replacement_flags = [] for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in self.grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break if not replace: new_points.append(pt) replacement_flags.append(0) return np.array(new_points, dtype=np.float32), np.array(replacement_flags, dtype=np.int32) @staticmethod def _get_midpoints(contour_points: np.ndarray, replacement_flags_prev: np.ndarray): """和你脚本里的 get_midpoints 一样""" midpoints = [] replacement_flags = [] num_points = len(contour_points) for i in range(num_points): p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue if replacement_flags_prev[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2.0 midpoints.append(midpoint) replacement_flags.append(0) return np.array(midpoints, dtype=np.float32), np.array(replacement_flags, dtype=np.int32) # ------------------ 引擎对外接口 ------------------ # def add_region(self, seed_point: tuple[int, int], region_mask_no_border: np.ndarray): """ 对应你脚本里的 operation_of_blank: 在每次【空格确认】一块区域时调用。 :param seed_point: (x, y) 像素坐标(和 GUI 里的一致) :param region_mask_no_border: (H, W) 这一轮的区域掩膜(0/1 或 0/255 均可) """ h, w = self.gray_image.shape if region_mask_no_border.shape != (h, w): raise ValueError("region_mask_no_border 尺寸必须和 gray_image 一致") # 1. 保存当前这块区域的一个代表种子(你原来 seed_list.append(seed_points[0])) self.seed_list.append(seed_point) # 2. 扩展成带 +2 边框的掩膜 region_mask_border = np.zeros((h + 2, w + 2), np.uint8) region_mask_border[1:-1, 1:-1] = (region_mask_no_border > 0).astype(np.uint8) # 3. 累积 total_Mask self.total_Mask = cv2.bitwise_or(self.total_Mask, region_mask_border) # 4. 用 Canny 把这块区域的边缘加入 skeletonize_mask canny = cv2.Canny(region_mask_border * 255, 50, 100) self.skeletonize_mask = cv2.bitwise_or(self.skeletonize_mask, canny) # 5. 灰度叠加(和 gray_image_new 的逻辑保持一致,方便后续 equalizeHist) value = ((len(self.seed_list)) * 5) % 255 self.gray_image_new = cv2.bitwise_or(self.gray_image_new, region_mask_border * value) def finalize_and_export(self, dxf_path: str) -> np.ndarray: """ 对应你脚本里的 operation_of_save + process_mask2: 生成骨架图 & 平滑 polyline,并写出 DXF。 :param dxf_path: 输出 dxf 文件路径 :return: skeleton_image,用于在 GUI 里做骨架预览 """ if not self.seed_list: raise RuntimeError("还没有任何 seed,被确认的区域列表为空。") h, w = self.gray_image.shape kernel = np.ones((3, 3), np.uint8) # ---------- 基于 gray_image_new / total_Mask 构造骨架输入 ---------- gray_img_new = self.gray_image_new.copy() total_mask = self.total_Mask.copy() if len(self.seed_list) > 1: gray_img_new = cv2.equalizeHist(gray_img_new) gray_img_new = gray_img_new[1:-1, 1:-1] total_mask = total_mask[1:-1, 1:-1] # 现在都是 (H, W) total_mask_not = cv2.bitwise_not(total_mask * 255) gray_img_new = cv2.add(gray_img_new, total_mask_not) skel_mask = cv2.bitwise_or(self.skeletonize_mask[1:-1, 1:-1], total_mask_not) skel_mask = cv2.morphologyEx(skel_mask, cv2.MORPH_CLOSE, kernel) skeleton_image = self._skeletonize_skimage(skel_mask) # ---------- 角点检测 + 网格聚类 ---------- corners = cv2.goodFeaturesToTrack( skeleton_image, maxCorners=200, qualityLevel=0.1, minDistance=10 ) if corners is not None: corners = corners.reshape(-1, 2) self._grid_simplification_corner_point(corners) # ---------- process_mask2:按 seed_list 把骨架分块并细化轮廓 ---------- doc = ezdxf.new() msp = doc.modelspace() for seed in self.seed_list: p_image = skeleton_image.copy() # 注意:fullfill_mask 里会再加一次 +2 边框,所以这里传入的是原始尺寸 ff_image = p_image.copy() self._fullfill_mask(ff_image, seed, 0, 1, 255) mask = self._fullfill_mask(ff_image, seed, 0, 0, None) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours( mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE ) for contour in contours: pts = np.array(contour[:, 0, :], dtype=np.float32) pts, rep_flags = self._replace_cornerpoints(pts) for _ in range(self.midcount): pts, rep_flags = self._get_midpoints(pts, rep_flags) # 翻转 y 轴 + 闭合 pts[:, 1] = -pts[:, 1] pts = np.vstack([pts, pts[0]]) msp.add_lwpolyline(pts) doc.saveas(dxf_path) return skeleton_image

二、在 PyQt 页里怎么用这个引擎

下面假设你用的是我们之前一起改过的 ImageOperationPage,核心思路是:

  • 加载图片时:初始化 MaskDxfEngine
  • 按空格确认当前 pending_mask 时:调用 engine.add_region(seed, pending_mask)
  • 点击“生成”按钮时
    • QFileDialog 选路径;
    • 调用 engine.finalize_and_export(path) 得到 skeleton_image
    • skeleton_image 画到右下预览框

1. 在 ImageOperationPage.init / 属性里增加:

python
from mask_dxf_engine import MaskDxfEngine # 或者用相对导入 class ImageOperationPage(QWidget): def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window # ... 你原来的属性 ... # 掩膜 / DXF 引擎 self.mask_engine: MaskDxfEngine | None = None

2. 在 load_image 里创建引擎

在你读完图、生成 self.gray_image 后,加一句:

python
self.gray_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2GRAY) # 创建/重置掩膜引擎 self.mask_engine = MaskDxfEngine( self.gray_image, midcount=50, # 可以挂到滑块 grid_size=3 )

3. 空格确认一块区域时,喂给引擎

keyPressEvent 处理空格的分支中(你之前是把 pending_mask 合并到 total_mask 的地方),补上:

python
if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): # 1) 原有逻辑:合并到 total_mask、挖空显示等…… # (这里保留你之前写好的代码) # 2) 通知掩膜引擎:确认了一块区域 if self.mask_engine is not None: # 取本轮的一个代表种子点(你可以用 self.current_seeds[0]) if self.current_seeds: seed = self.current_seeds[0] else: seed = self.last_click_img_pt # 保底 # pending_mask 是 (H, W) 的二值图,正好就是 region_mask_no_border self.mask_engine.add_region(seed, self.pending_mask.astype(np.uint8)) # 3) 清空 pending、刷新预览 # (还是保留你原来的清空逻辑) ...

重点:引擎内部自己维护 total_Maskskeletonize_maskseed_list 等,不会影响你 GUI 里用的 total_mask

4. 生成按钮:调用 finalize_and_export,并把骨架画到下预览

把你现在的 export_dxf 换成下面这个版本(如果你已经改过 DXF 导出逻辑,可以直接覆盖):

python
from PyQt5.QtWidgets import QFileDialog, QProgressDialog, QMessageBox def export_dxf(self): """点击右下【生成】按钮:用引擎生成平滑 DXF,并在下预览显示骨架化结果""" if self.mask_engine is None or self.gray_image is None: QMessageBox.warning(self, "提示", "请先加载图片并完成分割") return if not self.mask_engine.seed_list: QMessageBox.warning(self, "提示", "还没有任何确认的分割区域(点击后按空格确认)") return # 选保存路径 path, _ = QFileDialog.getSaveFileName( self, "保存 DXF 文件", "output.dxf", "DXF 文件 (*.dxf)" ) if not path: return # 进度框(引擎内部并没有分步回调,这里就一个“正在生成”的提示) progress = QProgressDialog("正在生成 DXF ...", "取消", 0, 0, self) progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) progress.show() try: skeleton_image = self.mask_engine.finalize_and_export(path) except Exception as e: progress.close() if hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("保存失败", f"写入 DXF 时出错:{e}") else: QMessageBox.critical(self, "保存失败", f"写入 DXF 时出错:{e}") return progress.close() # 提示成功 if hasattr(self.main_window, "_show_info提示"): self.main_window._show_info提示("完成", f"DXF 已保存到:\n{path}") elif hasattr(self.main_window, "_show_error提示"): self.main_window._show_error提示("完成", f"DXF 已保存到:\n{path}") else: QMessageBox.information(self, "完成", f"DXF 已保存到:\n{path}") # ======= 关键:右下预览改为骨架化图像 ======= if skeleton_image is not None: skel_bgr = cv2.cvtColor(skeleton_image, cv2.COLOR_GRAY2BGR) pix = self._cvimg_to_qpixmap(skel_bgr) pix = pix.scaled( self.preview2.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.preview2.setPixmap(pix) self.preview2.setText("")

这样一来:

  • 平时你在选区时,右上预览显示“本轮 pending_mask 的分割结果”,右下预览显示你之前设定的“所有掩膜叠加二值图”;
  • 每次按空格确认一块区域时,MaskDxfEngine.add_region() 会把对应掩膜和种子点记进去;
  • 当你点击“生成”时:
    • finalize_and_export() 会跑完整一套 operation_of_save + process_mask2 的逻辑,输出线条平滑的 DXF;
    • 同时把骨架化后的 skeleton_image 返回,右下预览改画这张骨架图,满足“生成时下面预览为骨架图”的要求。

如果你愿意,下一步我们可以把 midcountgrid_size 也挂到右侧滑块上(比如用“曲线精细度”“角点合并半径”),这样在 GUI 里直接调线条平滑程度,而不需要改代码。

Question

if event.key() == Qt.Key_Space:
if self.pending_mask is not None and np.any(self.pending_mask):
# 1) 原有逻辑:合并到 total_mask、挖空显示等……
# (这里保留你之前写好的代码)

text
# 2) 通知掩膜引擎:确认了一块区域 if self.mask_engine is not None: # 取本轮的一个代表种子点(你可以用 self.current_seeds[0]) if self.current_seeds: seed = self.current_seeds[0] else: seed = self.last_click_img_pt # 保底 # pending_mask 是 (H, W) 的二值图,正好就是 region_mask_no_border self.mask_engine.add_region(seed, self.pending_mask.astype(np.uint8)) # 3) 清空 pending、刷新预览 # (还是保留你原来的清空逻辑) ...把这个类完整的给我

Question

if event.key() == Qt.Key_Space:
if self.pending_mask is not None and np.any(self.pending_mask):
# 1) 原有逻辑:合并到 total_mask、挖空显示等……
# (这里保留你之前写好的代码)

text
# 2) 通知掩膜引擎:确认了一块区域 if self.mask_engine is not None: # 取本轮的一个代表种子点(你可以用 self.current_seeds[0]) if self.current_seeds: seed = self.current_seeds[0] else: seed = self.last_click_img_pt # 保底 # pending_mask 是 (H, W) 的二值图,正好就是 region_mask_no_border self.mask_engine.add_region(seed, self.pending_mask.astype(np.uint8)) # 3) 清空 pending、刷新预览 # (还是保留你原来的清空逻辑) ...把这个类完整的给我

Réflexion

Providing full class definition and integration example

The user wants the full class definition for MaskDxfEngine, which I’ve already provided, but they requested it again. I need to give the complete class with add_region and finalize_and_export methods, along with a cleaned-up version, using only numpy, cv2, ezdxf, and skimage, while retaining their logic. I'll also include an example integration.

Adapting mask dimensions for consistency

The user’s process involves modifying masks with extra borders, but they forget to strip the border later. Their code originally handles masks with these borders, but the dimensions mismatch causes issues, especially with functions that expect (H, W) input. I'll adapt this by following their approach to maintain consistency in the class skeleton.

Reconstructing MaskDxfEngine class

I’ll produce a class MaskDxfEngine based primarily on my previous answer, including the necessary imports and class structure. In __init__, I'll store the gray image, create additional arrays, and set up relevant attributes, like self.seed_list and self.grid_dict. Methods like fullfill_mask will mirror its original functionality, handling image modifications in place while also returning masks.

Integrating mask processing and export flow

I'll construct proceed_mask, a mask with dimensions (H+2,W+2), and set part of it from region_mask. The mask operations will happen within methods like _skeletonize_skimage and _grid_simplification_corner_point. In finalize_and_export, I'll follow the steps of operation_of_save, but instead of showing windows, everything will run within the same class.

Thought for 38s

Réponse

下面是你要的 完整类(带全部方法,可以直接拷到文件里用),已经把你脚本里的
operation_of_blank + operation_of_save + process_mask2 的逻辑都整合进来了:

python
import cv2 import ezdxf import numpy as np from skimage import morphology class MaskDxfEngine: """ 掩膜 + 骨架 + 平滑 DXF 导出引擎 用法概念: 1. 初始化:传入灰度图 gray_image(不带 +2 边框) 2. 每次按空格确认一个区域时:调用 add_region(seed_point, region_mask) - seed_point: (x, y) 像素坐标 - region_mask: 与 gray_image 同尺寸的二值掩膜(0/1 或 0/255 都行) 3. 点击“生成 DXF”时:调用 finalize_and_export(dxf_path) - 会返回 skeleton_image(骨架图,用于预览) """ def __init__(self, gray_image: np.ndarray, midcount: int = 50, grid_size: int = 3): """ :param gray_image: 原始灰度图 (H, W) :param midcount: 轮廓细分次数(越大越平滑,点越多) :param grid_size: 角点合并网格半径 """ self.gray_image = gray_image self.midcount = midcount self.grid_size = grid_size h, w = gray_image.shape # 与你原脚本保持一致:这些矩阵带 +2 边框 self.gray_image_new = np.zeros((h + 2, w + 2), np.uint8) self.total_Mask = np.zeros((h + 2, w + 2), np.uint8) self.skeletonize_mask = np.zeros((h + 2, w + 2), np.uint8) # 每块区域的代表种子点(按空格时记录) self.seed_list: list[tuple[int, int]] = [] # 角点网格简化用 self.grid_dict: dict[tuple[int, int, int, int], np.ndarray] = {} # ============ 内部工具函数 ============ # @staticmethod def _fullfill_mask(image: np.ndarray, point: tuple[int, int], tolerance: int, fill_flag: int, value=None) -> np.ndarray: """ 等价于你原来的 fullfill_mask: image: (H, W) 灰度/二值图,函数内部生成 (H+2, W+2) 的 mask。 """ if fill_flag == 0: flag = cv2.FLOODFILL_FIXED_RANGE | cv2.FLOODFILL_MASK_ONLY elif fill_flag == 1: flag = cv2.FLOODFILL_FIXED_RANGE else: flag = cv2.FLOODFILL_FIXED_RANGE mask = np.zeros((image.shape[0] + 2, image.shape[1] + 2), np.uint8) cv2.floodFill(image, mask, point, newVal=value, loDiff=tolerance, upDiff=tolerance, flags=flag) return mask @staticmethod def _skeletonize_skimage(image: np.ndarray) -> np.ndarray: """骨架化:和你原来的 skeletonize_skimage 一样""" skeleton = morphology.skeletonize(image.astype(bool)) skeleton_uint8 = skeleton.astype(np.uint8) * 255 return skeleton_uint8 def _grid_simplification_corner_point(self, points: np.ndarray): """为角点构建网格字典(你原来的 grid_simplification_corner_point)""" self.grid_dict.clear() for p in points: x, y = p min_x, max_x = x - self.grid_size, x + self.grid_size min_y, max_y = y - self.grid_size, y + self.grid_size key = (int(min_x), int(max_x), int(min_y), int(max_y)) if key not in self.grid_dict: self.grid_dict[key] = np.array([x, y], dtype=np.float32) def _replace_cornerpoints(self, points: np.ndarray): """对应你原来的 replace_cornerpoints""" new_points = [] replacement_flags = [] for pt in points: replace = False for (min_x, max_x, min_y, max_y), center in self.grid_dict.items(): if min_x <= pt[0] <= max_x and min_y <= pt[1] <= max_y: new_points.append(center) replacement_flags.append(1) replace = True break if not replace: new_points.append(pt) replacement_flags.append(0) return (np.array(new_points, dtype=np.float32), np.array(replacement_flags, dtype=np.int32)) @staticmethod def _get_midpoints(contour_points: np.ndarray, replacement_flags_prev: np.ndarray): """对应你原来的 get_midpoints""" midpoints = [] replacement_flags = [] num_points = len(contour_points) for i in range(num_points): p1 = contour_points[i] p2 = contour_points[(i + 1) % num_points] if np.all(p1 == p2): continue if replacement_flags_prev[i] == 1: midpoints.append(p1) replacement_flags.append(1) midpoint = (p1 + p2) / 2.0 midpoints.append(midpoint) replacement_flags.append(0) return (np.array(midpoints, dtype=np.float32), np.array(replacement_flags, dtype=np.int32)) # ============ 对外接口:GUI 调用 ============ # def add_region(self, seed_point: tuple[int, int], region_mask_no_border: np.ndarray): """ 在 GUI 里每次按空格确认一个区域时调用。 :param seed_point: (x, y) 图像坐标(和你点击的坐标一致) :param region_mask_no_border: 与 gray_image 同大小的二值掩膜 (H, W) 只需要保证区域内部为 1/255 即可。 """ h, w = self.gray_image.shape if region_mask_no_border.shape != (h, w): raise ValueError("region_mask_no_border 尺寸必须与 gray_image 相同") # 1. 保存这块区域的代表种子点 self.seed_list.append(seed_point) # 2. 把 (H, W) 的掩膜扩展成 (H+2, W+2) proceed_mask = np.zeros((h + 2, w + 2), np.uint8) proceed_mask[1:-1, 1:-1] = (region_mask_no_border > 0).astype(np.uint8) # 3. 累积 total_Mask self.total_Mask = cv2.bitwise_or(self.total_Mask, proceed_mask) # 4. 对这块掩膜做 Canny,加到 skeletonize_mask 里 canny = cv2.Canny(proceed_mask * 255, 50, 100) self.skeletonize_mask = cv2.bitwise_or(self.skeletonize_mask, canny) # 5. gray_image_new 叠加(与原脚本保持一致) value = ((len(self.seed_list)) * 5) % 255 self.gray_image_new = cv2.bitwise_or( self.gray_image_new, proceed_mask * value ) def finalize_and_export(self, dxf_path: str) -> np.ndarray: """ 生成平滑 DXF,并返回骨架图 skeleton_image,用于预览。 :param dxf_path: 输出 DXF 文件路径 :return: skeleton_image (H, W) uint8, 0/255 """ if not self.seed_list: raise RuntimeError("seed_list 为空:还没有任何确认的区域。") h, w = self.gray_image.shape kernel = np.ones((3, 3), np.uint8) # ----------- 对应 operation_of_save ----------- # gray_img_new = self.gray_image_new.copy() total_mask_border = self.total_Mask.copy() if len(self.seed_list) > 1: gray_img_new = cv2.equalizeHist(gray_img_new) gray_img_new = gray_img_new[1:-1, 1:-1] # (H, W) total_mask = total_mask_border[1:-1, 1:-1] # (H, W) total_mask_not = cv2.bitwise_not(total_mask * 255) gray_img_new = cv2.add(gray_img_new, total_mask_not) skel_mask = cv2.bitwise_or(self.skeletonize_mask[1:-1, 1:-1], total_mask_not) skel_mask = cv2.morphologyEx(skel_mask, cv2.MORPH_CLOSE, kernel) skeleton_image = self._skeletonize_skimage(skel_mask) # ----------- 角点检测 + 网格聚类 ----------- # corners = cv2.goodFeaturesToTrack( skeleton_image, maxCorners=200, qualityLevel=0.1, minDistance=10 ) if corners is not None: corners = corners.reshape(-1, 2) self._grid_simplification_corner_point(corners) # ----------- 对应 process_mask2:按 seed_list 导出 DXF ----------- # doc = ezdxf.new() msp = doc.modelspace() for seed in self.seed_list: # 注意:skeleton_image 是 (H, W),这里完全等价于你原来的 image p_image = skeleton_image.copy() # 第一次 floodFill:填充并修改 p_image 本身 self._fullfill_mask(p_image, seed, 0, 1, 255) # 第二次 floodFill:只取 mask(带 +2 边框) mask = self._fullfill_mask(p_image, seed, 0, 0, None) # 形态学清理 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 去掉边框再找轮廓 mask_wo_border = mask[1:-1, 1:-1] contours, _ = cv2.findContours( mask_wo_border, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE ) for contour in contours: part_pts = np.array(contour[:, 0, :], dtype=np.float32) # 角点替换 part_pts, rep_flags = self._replace_cornerpoints(part_pts) # 多次插中点,平滑曲线 for _ in range(self.midcount): part_pts, rep_flags = self._get_midpoints(part_pts, rep_flags) # y 轴取反 + 闭合 polyline part_pts[:, 1] = -part_pts[:, 1] part_pts = np.vstack([part_pts, part_pts[0]]) msp.add_lwpolyline(part_pts) doc.saveas(dxf_path) return skeleton_image

keyPressEvent 里怎么用

你在 PyQt 页的空格处理里,把你那段伪代码换成下面形式即可(只示意关键部分):

python
def keyPressEvent(self, event): if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): # 1) 先做你原来的逻辑:合并 total_mask、挖空显示等等 # ...(保持你之前的代码)... # 2) 通知掩膜引擎 if self.mask_engine is not None: if self.current_seeds: seed = self.current_seeds[0] else: seed = self.last_click_img_pt # 保底 # pending_mask 是 (H, W) 二值图 self.mask_engine.add_region( seed, self.pending_mask.astype(np.uint8) ) # 3) 清空本轮 + 刷新预览 self.pending_mask[:] = 0 self.current_seeds.clear() self.last_click_img_pt = None self._update_previews() else: super().keyPressEvent(event)

然后“生成”按钮里调用:

python
skeleton_image = self.mask_engine.finalize_and_export(path) # 再把 skeleton_image 显示到右下预览即可

这样你就可以在 PyQt 里直接复用你原来脚本那套掩膜 / 骨架 / 平滑 DXF 的完整逻辑了。

Question

text
def keyPressEvent(self, event): """按空格:把本轮 pending_mask 合并到 total_mask,并记录种子点""" if event.key() == Qt.Key_Space: if self.pending_mask is not None and np.any(self.pending_mask): # 1. 累积掩膜 if self.total_mask is None or self.total_mask.shape != self.pending_mask.shape: self.total_mask = self.pending_mask.copy() else: self.total_mask = cv2.bitwise_or(self.total_mask, self.pending_mask) # 2. 记录本轮种子点 if self.current_seeds: self.all_region_seeds.append(list(self.current_seeds)) # 3. 在工作图像上把这块区域挖空(置为白色) if self.cv_image_work is not None: cut = (self.pending_mask > 0) self.cv_image_work[cut] = (255, 255, 255) # 你也可以换成背景色 # 4. 清空本轮状态 self.pending_mask[:] = 0 self.current_seeds.clear() self.last_click_img_pt = None self._update_previews() else: super().keyPressEvent(event)

不对,我是说把这个完整的类给我

Partager cette Q&R