diff --git a/src/gui/panels/step1_panel.py b/src/gui/panels/step1_panel.py index 1c616bf..a342a6c 100644 --- a/src/gui/panels/step1_panel.py +++ b/src/gui/panels/step1_panel.py @@ -1,14 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step1 面板 - 水域掩膜生成 +Step1 面板 - 水域掩膜生成 (完美对齐无跳动重构) """ import os import sys from pathlib import Path -# 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里) +# 路径归一化 helper _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) @@ -16,231 +16,202 @@ from _step_path_resolver import resolve_subdir from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, - QDoubleSpinBox, QCheckBox, QPushButton, QFormLayout, QRadioButton, - QMessageBox, + QCheckBox, QPushButton, QRadioButton, QLineEdit, + QMessageBox, QStackedWidget, QSizePolicy ) from PyQt5.QtCore import Qt +from PyQt5.QtGui import QDoubleValidator -# 从公共组件库导入 from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet class Step1Panel(QWidget): """1. 水域掩膜生成""" + def __init__(self, parent=None): super().__init__(parent) self.init_ui() def init_ui(self): - layout = QVBoxLayout() + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) - # 标题 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(24, 24, 24, 24) + main_layout.setSpacing(20) + # ========================================== + # 卡片 1:掩膜生成策略 + # ========================================== + method_group = QGroupBox("📁 掩膜生成策略") + method_layout = QHBoxLayout() + method_layout.setSpacing(24) + method_layout.setContentsMargins(20, 24, 20, 20) - # 掩膜生成方式选择 - method_group = QGroupBox("掩膜生成方式") - method_layout = QVBoxLayout() - - # 使用现有掩膜文件 self.use_existing_radio = QRadioButton("使用现有掩膜文件") self.use_existing_radio.setChecked(True) + # 【核心修复】:保留原生圆圈,但直接强制给予足够大(200x35)的安全盒模型,杜绝高DPI下任何形式的截断! + self.use_existing_radio.setMinimumSize(200, 35) + + self.use_ndwi_radio = QRadioButton("使用 NDWI 自动生成") + self.use_ndwi_radio.setMinimumSize(200, 35) + method_layout.addWidget(self.use_existing_radio) - - # 使用NDWI自动生成 - self.use_ndwi_radio = QRadioButton("使用NDWI自动生成") method_layout.addWidget(self.use_ndwi_radio) - - # 应用QRadioButton样式(实心选中点) - radio_style = """ - QRadioButton::indicator { - width: 16px; - height: 16px; - border-radius: 8px; - border: 2px solid #999; - } - QRadioButton::indicator:checked { - background-color: #0078D7; - border: 2px solid #0078D7; - } - QRadioButton::indicator:unchecked { - background-color: white; - border: 2px solid #999; - } - QRadioButton::indicator:hover { - border: 2px solid #0078D7; - } - """ - self.use_existing_radio.setStyleSheet(radio_style) - self.use_ndwi_radio.setStyleSheet(radio_style) - + method_layout.addStretch() method_group.setLayout(method_layout) - layout.addWidget(method_group) + main_layout.addWidget(method_group) - # 掩膜文件选择 + # ========================================== + # 卡片 2:文件与参数配置 (绝对像素级对齐) + # ========================================== + config_group = QGroupBox("⚙️ 文件与参数配置") + config_layout = QVBoxLayout() + config_layout.setSpacing(16) + config_layout.setContentsMargins(20, 24, 20, 20) + + hint = QLabel( + "💡 提示: 若掩膜文件是 Shapefile(.shp),需提供参考影像用于栅格化;若使用 NDWI 自动生成,只需提供参考影像即可。") + hint.setWordWrap(True) + hint.setStyleSheet(f""" + QLabel {{ + color: {ModernStylesheet.COLORS['primary']}; + background-color: {ModernStylesheet.COLORS['selected']}; + border: 1px solid {ModernStylesheet.COLORS['border_light']}; + border-radius: 6px; + padding: 10px 14px; + margin-bottom: 8px; + }} + """) + config_layout.addWidget(hint) + + self.stack = QStackedWidget() + + # 第 0 页:现有掩膜文件 self.mask_file = FileSelectWidget( - "掩膜文件:", + "现有掩膜文件:", "Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)" ) - layout.addWidget(self.mask_file) + self.mask_file.label.setMinimumWidth(100) + self.stack.addWidget(self.mask_file) - # 影像文件选择(用于shp栅格化或NDWI生成) + # 第 1 页:NDWI 阈值参数 + self.ndwi_widget = QWidget() + ndwi_layout = QHBoxLayout(self.ndwi_widget) + ndwi_layout.setContentsMargins(0, 0, 0, 0) + ndwi_layout.setSpacing(8) + + ndwi_label = QLabel("NDWI 阈值:") + ndwi_label.setMinimumWidth(100) + ndwi_layout.addWidget(ndwi_label) + + self.ndwi_threshold = QLineEdit() + validator = QDoubleValidator(0.0, 1.0, 2, self) + validator.setNotation(QDoubleValidator.StandardNotation) + self.ndwi_threshold.setValidator(validator) + self.ndwi_threshold.setText("0.40") + self.ndwi_threshold.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + ndwi_layout.addWidget(self.ndwi_threshold) + + self.stack.addWidget(self.ndwi_widget) + config_layout.addWidget(self.stack) + + # 参考影像 self.img_file = FileSelectWidget( - "参考影像:", + "输入参考影像:", "Image Files (*.bsq *.dat *.tif);;All Files (*.*)" ) - layout.addWidget(self.img_file) + self.img_file.label.setMinimumWidth(100) + config_layout.addWidget(self.img_file) - # NDWI参数设置 - self.ndwi_group = QGroupBox("NDWI参数设置") - ndwi_layout = QVBoxLayout() + config_group.setLayout(config_layout) + main_layout.addWidget(config_group) - # NDWI阈值 - threshold_layout = QHBoxLayout() - threshold_layout.addWidget(QLabel("NDWI阈值:")) - self.ndwi_threshold = QDoubleSpinBox() - self.ndwi_threshold.setRange(0.0, 1.0) - self.ndwi_threshold.setSingleStep(0.05) - self.ndwi_threshold.setValue(0.4) - self.ndwi_threshold.setDecimals(2) - threshold_layout.addWidget(self.ndwi_threshold) - threshold_layout.addStretch() - ndwi_layout.addLayout(threshold_layout) + # ========================================== + # 卡片 3:输出与执行 + # ========================================== + output_group = QGroupBox("🚀 输出与执行") + output_layout = QVBoxLayout() + output_layout.setSpacing(16) + output_layout.setContentsMargins(20, 24, 20, 20) - self.ndwi_group.setLayout(ndwi_layout) - layout.addWidget(self.ndwi_group) - - # 输出文件路径(使用save模式) self.output_file = FileSelectWidget( - "输出掩膜:", + "结果保存至:", "Mask Files (*.dat *.tif);;All Files (*.*)", mode="save" ) + self.output_file.label.setMinimumWidth(100) self.output_file.line_edit.setPlaceholderText("water_mask.dat") - layout.addWidget(self.output_file) + output_layout.addWidget(self.output_file) - # 提示信息 - 专业的 Info Alert 样式 - hint = QLabel("💡 提示: 如果掩膜文件是Shapefile(.shp),需要提供参考影像用于栅格化;如果使用NDWI自动生成,只需要提供参考影像") - hint.setWordWrap(True) # 允许自动换行 - hint.setStyleSheet(""" - QLabel { - color: #0055D4; - font-size: 13px; - font-weight: bold; - background-color: #E8F4FF; - border: 2px solid #0055D4; - border-radius: 8px; - padding: 12px 16px; - margin: 8px 0px; - } - """) - layout.addWidget(hint) - - # 启用步骤 + action_layout = QHBoxLayout() self.enable_checkbox = QCheckBox("启用此步骤") self.enable_checkbox.setChecked(True) - layout.addWidget(self.enable_checkbox) + self.enable_checkbox.setMinimumHeight(24) + action_layout.addWidget(self.enable_checkbox) + action_layout.addStretch() - # 独立运行按钮 - self.run_btn = QPushButton("独立运行此步骤") - self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) + self.run_btn = QPushButton("独立运行步骤") + self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary')) + self.run_btn.setMinimumWidth(140) self.run_btn.clicked.connect(self._on_run_single_clicked) - layout.addWidget(self.run_btn) + action_layout.addWidget(self.run_btn) + + output_layout.addLayout(action_layout) + output_group.setLayout(output_layout) + main_layout.addWidget(output_group) - # 连接信号 self.use_existing_radio.toggled.connect(self.update_ui_state) - self.use_ndwi_radio.toggled.connect(self.update_ui_state) + main_layout.addStretch() + self.setLayout(main_layout) - layout.addStretch() - self.setLayout(layout) - - # 初始UI状态 self.update_ui_state() def update_ui_state(self): - """根据选择的掩膜生成方式更新UI状态(使用显示/隐藏控制)""" - use_ndwi = self.use_ndwi_radio.isChecked() - - # 动态显示/隐藏组件 - if use_ndwi: - # 使用NDWI模式:隐藏掩膜文件,显示NDWI参数和输出掩膜 - self.mask_file.setVisible(False) - self.ndwi_group.setVisible(True) - self.output_file.setVisible(True) # 显示输出掩膜路径 - - # 当切换到NDWI模式时,如果工作目录已设置,自动填充输出路径 - if hasattr(self, 'work_dir') and self.work_dir: - self._auto_fill_output_path() - else: - # 使用现有掩膜模式:显示掩膜文件,隐藏NDWI参数和输出掩膜 - self.mask_file.setVisible(True) - self.ndwi_group.setVisible(False) - self.output_file.setVisible(False) # 隐藏输出掩膜路径 - - # 参考影像在两种模式下都显示 - self.img_file.setVisible(True) - - def update_work_directory(self, work_dir): - """ - 保存工作目录引用,用于后续自动填充路径 - - Args: - work_dir: 工作目录路径 - """ - if not work_dir: - return - - # 保存工作目录引用 - self.work_dir = work_dir - - # 如果当前选中的是NDWI模式,立即填充输出路径 if self.use_ndwi_radio.isChecked(): + self.stack.setCurrentWidget(self.ndwi_widget) + else: + self.stack.setCurrentWidget(self.mask_file) + + if hasattr(self, 'work_dir') and self.work_dir: self._auto_fill_output_path() + def update_work_directory(self, work_dir): + if not work_dir: + return + self.work_dir = work_dir + self._auto_fill_output_path() + def _auto_fill_output_path(self): - """ - 自动填充输出掩膜路径(仅在NDWI模式下) - 确保路径使用正斜杠,避免斜杠混用 - """ if not hasattr(self, 'work_dir') or not self.work_dir: return - - # 生成输出掩膜的完整路径(用 resolve_subdir 归一化,消除硬编码) output_dir = resolve_subdir(self.work_dir, 'water_mask') - os.makedirs(output_dir, exist_ok=True) # 确保目录存在 - - # 统一使用正斜杠,避免 \ 和 / 混用 + os.makedirs(output_dir, exist_ok=True) default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/') self.output_file.set_path(default_output_path) def get_config(self): - """获取配置""" use_ndwi = self.use_ndwi_radio.isChecked() + try: + ndwi_val = float(self.ndwi_threshold.text().strip()) + except ValueError: + ndwi_val = 0.4 config = { 'mask_path': None if use_ndwi else self.mask_file.get_path(), 'use_ndwi': use_ndwi, - 'ndwi_threshold': self.ndwi_threshold.value() + 'ndwi_threshold': ndwi_val } - - # 参考影像路径(两种模式都可能需要) img_path = self.img_file.get_path() if img_path: config['img_path'] = img_path - # 输出路径:仅在NDWI模式下有效 - if use_ndwi: - output_path = self.output_file.get_path() - if output_path: - config['output_path'] = output_path - else: - # 使用现有掩膜时,不传递output_path,避免底层错误尝试保存文件 - config['output_path'] = None - + output_path = self.output_file.get_path() + if output_path: + config['output_path'] = output_path return config def set_config(self, config): - """设置配置""" if 'mask_path' in config: self.mask_file.set_path(config['mask_path']) if 'img_path' in config: @@ -253,20 +224,13 @@ class Step1Panel(QWidget): else: self.use_existing_radio.setChecked(True) if 'ndwi_threshold' in config: - self.ndwi_threshold.setValue(config['ndwi_threshold']) + self.ndwi_threshold.setText(f"{config['ndwi_threshold']:.2f}") self.update_ui_state() def _on_run_single_clicked(self): - """通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。 - - 替代旧有的 parent 链上溯查找 run_single_step 的紧耦合方式。 - PipelineExecutor 在 __init__ 中订阅 RequestRunSingleStep 事件, - 收到后调用 run_single_step(step_name, config) 统一处理预检/工作目录/执行。 - """ from src.gui.core.event_bus import global_event_bus - # 验证输入(与旧 run_step 逻辑一致) if self.use_ndwi_radio.isChecked(): img_path = self.img_file.get_path() if not img_path: @@ -287,36 +251,4 @@ class Step1Panel(QWidget): global_event_bus.publish('RequestRunSingleStep', { 'step_name': 'step1', 'config': config, - }) - - def run_step(self): - """独立运行步骤1(旧版 parent 链上溯方式,保留兼容)。""" - # 验证输入 - if self.use_ndwi_radio.isChecked(): - # NDWI模式:需要影像文件 - img_path = self.img_file.get_path() - if not img_path: - QMessageBox.warning(self, "输入错误", "请选择参考影像文件!") - return - else: - # 现有掩膜模式:需要掩膜文件 - mask_path = self.mask_file.get_path() - if not mask_path: - QMessageBox.warning(self, "输入错误", "请选择掩膜文件!") - return - - # 如果是shp文件,还需要影像文件 - if mask_path.lower().endswith('.shp'): - img_path = self.img_file.get_path() - if not img_path: - QMessageBox.warning(self, "输入错误", "当使用shp文件时,需要提供参考影像用于栅格化!") - return - - # 获取父窗口并运行步骤 - parent = self.parent() - while parent and not hasattr(parent, 'run_single_step'): - parent = parent.parent() - - if parent and hasattr(parent, 'run_single_step'): - config = {'step1': self.get_config()} - parent.run_single_step("step1", config) \ No newline at end of file + }) \ No newline at end of file diff --git a/src/gui/styles.py b/src/gui/styles.py index 9381907..b99f64b 100644 --- a/src/gui/styles.py +++ b/src/gui/styles.py @@ -3,13 +3,14 @@ 全局样式与设计系统 (Design System) - 向后兼容增强版 """ + class ModernStylesheet: """现代化样式生成器""" # 1. 增强版色板 (Design Tokens) COLORS = { 'primary': '#0078D4', - 'accent': '#0078D4', # ⚠️ 历史兼容:保留全局 30+ 处对 accent 的引用 + 'accent': '#0078D4', # ⚠️ 历史兼容:保留全局 30+ 处对 accent 的引用 'primary_hover': '#106EBE', 'primary_pressed': '#005A9E', 'primary_light': '#E8F4FD', @@ -52,13 +53,20 @@ class ModernStylesheet: def get_main_stylesheet(cls) -> str: """全局基础样式表""" return f""" + /* 全局默认透明背景,防止嵌套组件出现“灰色残影” */ QWidget {{ - background-color: {cls.COLORS['main_bg']}; color: {cls.COLORS['text_primary']}; font-family: {cls.FONTS['family']}; font-size: 13px; + background-color: transparent; }} + /* 仅对最底层的窗口设置实体浅灰底色 */ + QMainWindow, QDialog {{ + background-color: {cls.COLORS['main_bg']}; + }} + + /* 阻断继承:让卡片内的元素保持纯白 */ QGroupBox, QFrame, QScrollArea, QListWidget, QTreeWidget, QTableWidget {{ background-color: {cls.COLORS['panel_bg']}; }} @@ -78,10 +86,13 @@ class ModernStylesheet: color: {cls.COLORS['primary']}; font-weight: bold; font-size: 13px; - background-color: {cls.COLORS['main_bg']}; + background-color: {cls.COLORS['main_bg']}; padding: 0 4px; }} + /* 【核心修复】彻底删除了 QRadioButton 的全部 CSS 代码,让其 100% 保持 Windows 原生样式 */ + + /* --- 纯净版:输入框 & 数字框 --- */ QLineEdit, QSpinBox, QDoubleSpinBox {{ background-color: {cls.COLORS['panel_bg']}; border: 1px solid {cls.COLORS['border']}; @@ -103,6 +114,19 @@ class ModernStylesheet: border: 1px solid {cls.COLORS['border_light']}; }} + QSpinBox > QLineEdit, QDoubleSpinBox > QLineEdit {{ + background: transparent; + border: none; + padding: 0px; + min-height: 0px; + }} + + QSpinBox::up-button, QSpinBox::down-button, QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {{ + width: 0px; + border: none; + background: transparent; + }} + QComboBox {{ border: 1px solid {cls.COLORS['border']}; border-radius: {cls.VARS['radius_md']}; @@ -118,7 +142,7 @@ class ModernStylesheet: border-left: none; }} QComboBox::down-arrow {{ - image: none; + image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid {cls.COLORS['text_secondary']}; @@ -137,7 +161,7 @@ class ModernStylesheet: QCheckBox::indicator:checked {{ background-color: {cls.COLORS['primary']}; border: 1px solid {cls.COLORS['primary']}; - image: none; + image: none; }} QScrollBar:vertical {{