feat: 水质分析系统用户体验核心升级

This commit is contained in:
DXC
2026-05-06 11:33:35 +08:00
parent 69ce95cda4
commit 71e3aaa8cd
2 changed files with 223 additions and 78 deletions

View File

@ -242,20 +242,21 @@ class WaterQualityInversionPipeline:
ndwi_threshold: float = 0.4, ndwi_threshold: float = 0.4,
use_ndwi: bool = False, use_ndwi: bool = False,
skip_dependency_check: bool = False, skip_dependency_check: bool = False,
generate_png: bool = True) -> str: generate_png: bool = True,
output_path: Optional[str] = None) -> str:
""" """
步骤1: 生成或设置水域mask 步骤1: 生成或设置水域mask
支持三种方式生成水域掩膜: 支持三种方式生成水域掩膜:
1. 基于shp文件栅格化 1. 基于shp文件栅格化
2. 使用现有的栅格格式掩膜文件 2. 使用现有的栅格格式掩膜文件
3. 基于NDWI从影像自动生成水体掩膜 3. 基于NDWI从影像自动生成水体掩膜
当提供img_path时会自动生成PNG预览图基于波长选择RGB波段 当提供img_path时会自动生成PNG预览图基于波长选择RGB波段
- 红波段: ~650nm - 红波段: ~650nm
- 绿波段: ~550nm - 绿波段: ~550nm
- 蓝波段: ~460nm - 蓝波段: ~460nm
Args: Args:
mask_path: 水体掩膜文件路径,支持: mask_path: 水体掩膜文件路径,支持:
- shp格式文件.shp需要提供img_path用于栅格化 - shp格式文件.shp需要提供img_path用于栅格化
@ -265,7 +266,9 @@ class WaterQualityInversionPipeline:
ndwi_threshold: NDWI阈值当use_ndwi=True时使用 ndwi_threshold: NDWI阈值当use_ndwi=True时使用
use_ndwi: 是否使用NDWI方法从影像生成水体掩膜 use_ndwi: 是否使用NDWI方法从影像生成水体掩膜
generate_png: 是否生成输入影像的PNG预览图默认True generate_png: 是否生成输入影像的PNG预览图默认True
output_path: 指定输出掩膜文件的保存路径(可选)。如果提供,掩膜将保存到此路径;
如果为None则使用默认路径self.water_mask_dir
Returns: Returns:
dat格式的水域掩膜文件路径 dat格式的水域掩膜文件路径
""" """
@ -285,37 +288,44 @@ class WaterQualityInversionPipeline:
raise ValueError("当use_ndwi=True时必须提供img_path参数用于生成NDWI掩膜") raise ValueError("当use_ndwi=True时必须提供img_path参数用于生成NDWI掩膜")
if not Path(img_path).exists(): if not Path(img_path).exists():
raise ValueError(f"影像文件不存在: {img_path}") raise ValueError(f"影像文件不存在: {img_path}")
print(f"使用NDWI方法从影像生成水体掩膜阈值={ndwi_threshold}...") print(f"使用NDWI方法从影像生成水体掩膜阈值={ndwi_threshold}...")
output_path = str(self.water_mask_dir / "water_mask_from_ndwi.dat")
# 使用用户指定的输出路径,或使用默认路径
if output_path:
ndwi_output_path = output_path
# 确保输出目录存在
os.makedirs(Path(output_path).parent, exist_ok=True)
else:
ndwi_output_path = str(self.water_mask_dir / "water_mask_from_ndwi.dat")
# 检查文件是否已存在,避免重复生成 # 检查文件是否已存在,避免重复生成
if Path(output_path).exists(): if Path(ndwi_output_path).exists():
print(f"检测到已存在的NDWI掩膜文件直接使用: {output_path}") print(f"检测到已存在的NDWI掩膜文件直接使用: {ndwi_output_path}")
self.water_mask_path = output_path self.water_mask_path = ndwi_output_path
step_end_time = time.time() step_end_time = time.time()
self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time, status="skipped") self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time, status="skipped")
print(f"水域掩膜已设置: {self.water_mask_path}") print(f"水域掩膜已设置: {self.water_mask_path}")
# 生成水域掩膜叠加图(如果不存在) # 生成水域掩膜叠加图(如果不存在)
overlay_path = self.water_mask_dir / "water_mask_overlay.png" overlay_path = self.water_mask_dir / "water_mask_overlay.png"
if generate_png and img_path is not None and Path(img_path).exists() and not overlay_path.exists(): if generate_png and img_path is not None and Path(img_path).exists() and not overlay_path.exists():
self._generate_water_mask_overlay(img_path, self.water_mask_path) self._generate_water_mask_overlay(img_path, self.water_mask_path)
return self.water_mask_path return self.water_mask_path
# 执行NDWI水体提取 # 执行NDWI水体提取
from src.utils.extract_water_area import ndwi from src.utils.extract_water_area import ndwi
ndwi(img_path, ndwi_threshold, output_path) ndwi(img_path, ndwi_threshold, ndwi_output_path)
self.water_mask_path = output_path self.water_mask_path = ndwi_output_path
step_end_time = time.time() step_end_time = time.time()
self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time) self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time)
print(f"已生成NDWI水体掩膜: {self.water_mask_path}") print(f"已生成NDWI水体掩膜: {self.water_mask_path}")
# 生成水域掩膜叠加图 # 生成水域掩膜叠加图
if generate_png: if generate_png:
self._generate_water_mask_overlay(img_path, self.water_mask_path) self._generate_water_mask_overlay(img_path, self.water_mask_path)
return self.water_mask_path return self.water_mask_path
elif mask_path is None: elif mask_path is None:
@ -331,37 +341,44 @@ class WaterQualityInversionPipeline:
# 如果是shp文件需要栅格化为dat # 如果是shp文件需要栅格化为dat
if img_path is None: if img_path is None:
raise ValueError("当mask_path为shp格式时必须提供img_path参数用于栅格化") raise ValueError("当mask_path为shp格式时必须提供img_path参数用于栅格化")
print(f"检测到shp格式的水体掩膜正在转换为dat格式...") print(f"检测到shp格式的水体掩膜正在转换为dat格式...")
output_path = str(self.water_mask_dir / "water_mask_from_shp.dat")
# 使用用户指定的输出路径,或使用默认路径
if output_path:
shp_output_path = output_path
# 确保输出目录存在
os.makedirs(Path(output_path).parent, exist_ok=True)
else:
shp_output_path = str(self.water_mask_dir / "water_mask_from_shp.dat")
# 检查文件是否已存在,避免重复栅格化 # 检查文件是否已存在,避免重复栅格化
if Path(output_path).exists(): if Path(shp_output_path).exists():
print(f"检测到已存在的栅格化掩膜文件,直接使用: {output_path}") print(f"检测到已存在的栅格化掩膜文件,直接使用: {shp_output_path}")
self.water_mask_path = output_path self.water_mask_path = shp_output_path
step_end_time = time.time() step_end_time = time.time()
self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time, status="skipped") self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time, status="skipped")
print(f"水域掩膜已设置: {self.water_mask_path}") print(f"水域掩膜已设置: {self.water_mask_path}")
# 生成水域掩膜叠加图(如果不存在) # 生成水域掩膜叠加图(如果不存在)
overlay_path = self.water_mask_dir / "water_mask_overlay.png" overlay_path = self.water_mask_dir / "water_mask_overlay.png"
if generate_png and img_path is not None and Path(img_path).exists() and not overlay_path.exists(): if generate_png and img_path is not None and Path(img_path).exists() and not overlay_path.exists():
self._generate_water_mask_overlay(img_path, self.water_mask_path) self._generate_water_mask_overlay(img_path, self.water_mask_path)
return self.water_mask_path return self.water_mask_path
# 执行栅格化 # 执行栅格化
from src.utils.extract_water_area import rasterize_shp from src.utils.extract_water_area import rasterize_shp
rasterize_shp(mask_path, output_path, img_path) rasterize_shp(mask_path, shp_output_path, img_path)
self.water_mask_path = output_path self.water_mask_path = shp_output_path
step_end_time = time.time() step_end_time = time.time()
self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time) self._record_step_time("步骤1: 生成水域mask", step_start_time, step_end_time)
print(f"已生成dat格式的水域掩膜: {self.water_mask_path}") print(f"已生成dat格式的水域掩膜: {self.water_mask_path}")
# 生成水域掩膜叠加图 # 生成水域掩膜叠加图
if generate_png: if generate_png:
self._generate_water_mask_overlay(img_path, self.water_mask_path) self._generate_water_mask_overlay(img_path, self.water_mask_path)
return self.water_mask_path return self.water_mask_path
else: else:
@ -483,13 +500,13 @@ class WaterQualityInversionPipeline:
fontsize=12, fontweight='bold') fontsize=12, fontweight='bold')
ax.axis('off') ax.axis('off')
# 添加比例尺信息 # 添加比例尺信息(白色文字,黑色背景下清晰可见)
geo_transform = dataset.GetGeoTransform() geo_transform = dataset.GetGeoTransform()
if geo_transform: if geo_transform:
pixel_size_x = abs(geo_transform[1]) pixel_size_x = abs(geo_transform[1])
pixel_size_y = abs(geo_transform[5]) pixel_size_y = abs(geo_transform[5])
scale_text = f"分辨率: {pixel_size_x:.2f}m x {pixel_size_y:.2f}m | 尺寸: {width} x {height}" scale_text = f"分辨率: {pixel_size_x:.2f}m x {pixel_size_y:.2f}m | 尺寸: {width} x {height}"
fig.text(0.5, 0.02, scale_text, ha='center', fontsize=10, style='italic') fig.text(0.5, 0.02, scale_text, ha='center', fontsize=10, style='italic', color='white')
plt.tight_layout() plt.tight_layout()
plt.savefig(png_path, dpi=150, bbox_inches='tight', pad_inches=0.1) plt.savefig(png_path, dpi=150, bbox_inches='tight', pad_inches=0.1)

View File

@ -838,34 +838,50 @@ class VisualizationWorkerThread(QThread):
class FileSelectWidget(QWidget): class FileSelectWidget(QWidget):
"""文件选择组件""" """文件选择组件"""
def __init__(self, label_text, file_filter="All Files (*.*)", parent=None): def __init__(self, label_text, file_filter="All Files (*.*)", mode="open", parent=None):
"""
初始化文件选择组件
Args:
label_text: 标签文本
file_filter: 文件过滤器
mode: 选择模式 - "open"(打开文件) 或 "save"(保存文件)
parent: 父控件
"""
super().__init__(parent) super().__init__(parent)
self.file_filter = file_filter self.file_filter = file_filter
self.mode = mode # "open" 或 "save"
self.init_ui(label_text) self.init_ui(label_text)
def init_ui(self, label_text): def init_ui(self, label_text):
layout = QHBoxLayout() layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
self.label = QLabel(label_text) self.label = QLabel(label_text)
self.label.setMinimumWidth(120) self.label.setMinimumWidth(120)
self.line_edit = QLineEdit() self.line_edit = QLineEdit()
self.line_edit.setPlaceholderText("请选择文件...") placeholder = "请选择保存路径..." if self.mode == "save" else "请选择文件..."
self.line_edit.setPlaceholderText(placeholder)
self.browse_btn = QPushButton("浏览...") self.browse_btn = QPushButton("浏览...")
self.browse_btn.setMaximumWidth(80) self.browse_btn.setMaximumWidth(80)
self.browse_btn.clicked.connect(self.browse_file) self.browse_btn.clicked.connect(self.browse_file)
layout.addWidget(self.label) layout.addWidget(self.label)
layout.addWidget(self.line_edit, 1) layout.addWidget(self.line_edit, 1)
layout.addWidget(self.browse_btn) layout.addWidget(self.browse_btn)
self.setLayout(layout) self.setLayout(layout)
def browse_file(self): def browse_file(self):
"""浏览文件""" """浏览文件"""
file_path, _ = QFileDialog.getOpenFileName( if self.mode == "save":
self, "选择文件", "", self.file_filter file_path, _ = QFileDialog.getSaveFileName(
) self, "保存文件", "", self.file_filter
)
else:
file_path, _ = QFileDialog.getOpenFileName(
self, "选择文件", "", self.file_filter
)
if file_path: if file_path:
self.line_edit.setText(file_path) self.line_edit.setText(file_path)
@ -938,32 +954,55 @@ class Step1Panel(QWidget):
self.use_existing_radio = QRadioButton("使用现有掩膜文件") self.use_existing_radio = QRadioButton("使用现有掩膜文件")
self.use_existing_radio.setChecked(True) self.use_existing_radio.setChecked(True)
method_layout.addWidget(self.use_existing_radio) method_layout.addWidget(self.use_existing_radio)
# 使用NDWI自动生成 # 使用NDWI自动生成
self.use_ndwi_radio = QRadioButton("使用NDWI自动生成") self.use_ndwi_radio = QRadioButton("使用NDWI自动生成")
method_layout.addWidget(self.use_ndwi_radio) 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_group.setLayout(method_layout) method_group.setLayout(method_layout)
layout.addWidget(method_group) layout.addWidget(method_group)
# 掩膜文件选择 # 掩膜文件选择
self.mask_file = FileSelectWidget( self.mask_file = FileSelectWidget(
"掩膜文件:", "掩膜文件:",
"Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)" "Shapefiles (*.shp);;Raster Files (*.dat *.tif);;All Files (*.*)"
) )
layout.addWidget(self.mask_file) layout.addWidget(self.mask_file)
# 影像文件选择用于shp栅格化或NDWI生成 # 影像文件选择用于shp栅格化或NDWI生成
self.img_file = FileSelectWidget( self.img_file = FileSelectWidget(
"参考影像:", "参考影像:",
"Image Files (*.bsq *.dat *.tif);;All Files (*.*)" "Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
) )
layout.addWidget(self.img_file) layout.addWidget(self.img_file)
# NDWI参数设置 # NDWI参数设置
ndwi_group = QGroupBox("NDWI参数设置") self.ndwi_group = QGroupBox("NDWI参数设置")
ndwi_layout = QVBoxLayout() ndwi_layout = QVBoxLayout()
# NDWI阈值 # NDWI阈值
threshold_layout = QHBoxLayout() threshold_layout = QHBoxLayout()
threshold_layout.addWidget(QLabel("NDWI阈值:")) threshold_layout.addWidget(QLabel("NDWI阈值:"))
@ -975,60 +1014,91 @@ class Step1Panel(QWidget):
threshold_layout.addWidget(self.ndwi_threshold) threshold_layout.addWidget(self.ndwi_threshold)
threshold_layout.addStretch() threshold_layout.addStretch()
ndwi_layout.addLayout(threshold_layout) ndwi_layout.addLayout(threshold_layout)
ndwi_group.setLayout(ndwi_layout) self.ndwi_group.setLayout(ndwi_layout)
layout.addWidget(ndwi_group) layout.addWidget(self.ndwi_group)
# 输出文件路径 # 输出文件路径使用save模式
self.output_file = FileSelectWidget( self.output_file = FileSelectWidget(
"输出掩膜:", "输出掩膜:",
"Mask Files (*.dat *.tif);;All Files (*.*)" "Mask Files (*.dat *.tif);;All Files (*.*)",
mode="save"
) )
self.output_file.line_edit.setPlaceholderText("water_mask.dat") self.output_file.line_edit.setPlaceholderText("water_mask.dat")
layout.addWidget(self.output_file) layout.addWidget(self.output_file)
# 提示信息 # 提示信息 - 专业的 Info Alert 样式
hint = QLabel("提示: 如果掩膜文件是Shapefile(.shp)需要提供参考影像用于栅格化如果使用NDWI自动生成只需要提供参考影像") hint = QLabel("💡 提示: 如果掩膜文件是Shapefile(.shp)需要提供参考影像用于栅格化如果使用NDWI自动生成只需要提供参考影像")
hint.setStyleSheet("color: #666; font-size: 10px;") 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) layout.addWidget(hint)
# 启用步骤 # 启用步骤
self.enable_checkbox = QCheckBox("启用此步骤") self.enable_checkbox = QCheckBox("启用此步骤")
self.enable_checkbox.setChecked(True) self.enable_checkbox.setChecked(True)
layout.addWidget(self.enable_checkbox) layout.addWidget(self.enable_checkbox)
# 独立运行按钮 # 独立运行按钮
self.run_btn = QPushButton("独立运行此步骤") self.run_btn = QPushButton("独立运行此步骤")
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success')) self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
self.run_btn.clicked.connect(self.run_step) self.run_btn.clicked.connect(self.run_step)
layout.addWidget(self.run_btn) layout.addWidget(self.run_btn)
# 连接信号 # 连接信号
self.use_existing_radio.toggled.connect(self.update_ui_state) self.use_existing_radio.toggled.connect(self.update_ui_state)
self.use_ndwi_radio.toggled.connect(self.update_ui_state) self.use_ndwi_radio.toggled.connect(self.update_ui_state)
layout.addStretch() layout.addStretch()
self.setLayout(layout) self.setLayout(layout)
# 初始UI状态 # 初始UI状态
self.update_ui_state() self.update_ui_state()
def update_ui_state(self): def update_ui_state(self):
"""根据选择的掩膜生成方式更新UI状态""" """根据选择的掩膜生成方式更新UI状态(使用显示/隐藏控制)"""
use_ndwi = self.use_ndwi_radio.isChecked() use_ndwi = self.use_ndwi_radio.isChecked()
# 动态显示/隐藏组件
if use_ndwi:
# 使用NDWI模式隐藏掩膜文件显示NDWI参数
self.mask_file.setVisible(False)
self.ndwi_group.setVisible(True)
else:
# 使用现有掩膜模式显示掩膜文件隐藏NDWI参数
self.mask_file.setVisible(True)
self.ndwi_group.setVisible(False)
# 参考影像和输出掩膜在两种模式下都显示
self.img_file.setVisible(True)
self.output_file.setVisible(True)
def update_work_directory(self, work_dir):
"""
接收主窗口传来的工作目录,自动填充输出路径
# 掩膜文件在NDWI模式下禁用 Args:
self.mask_file.setEnabled(not use_ndwi) work_dir: 工作目录路径
"""
if not work_dir:
return
# 影像文件在两种模式下都需要 # 自动生成输出掩膜的完整路径
self.img_file.setEnabled(True) output_dir = os.path.join(work_dir, "1_water_mask")
os.makedirs(output_dir, exist_ok=True) # 确保目录存在
# NDWI参数在NDWI模式下启用 default_output_path = os.path.join(output_dir, "water_mask_out.dat")
for i in range(self.layout().count()): self.output_file.set_path(default_output_path)
widget = self.layout().itemAt(i).widget()
if widget and isinstance(widget, QGroupBox) and widget.title() == "NDWI参数设置":
widget.setEnabled(use_ndwi)
break
def get_config(self): def get_config(self):
"""获取配置""" """获取配置"""
@ -5393,20 +5463,25 @@ class WaterQualityGUI(QMainWindow):
self.pipeline = None self.pipeline = None
self.worker = None self.worker = None
self.config_file = None self.config_file = None
self.work_dir = None # 工作目录
# 训练数据模式状态 # 训练数据模式状态
self.has_training_data = True # 默认有训练数据 self.has_training_data = True # 默认有训练数据
# 步骤输出路径记录 # 步骤输出路径记录
self.step_outputs = {} # 记录每个步骤的输出路径 self.step_outputs = {} # 记录每个步骤的输出路径
# 定义步骤依赖关系和标准输出路径 # 定义步骤依赖关系和标准输出路径
self._init_step_dependencies() self._init_step_dependencies()
self.init_ui() self.init_ui()
self.apply_stylesheet() self.apply_stylesheet()
self._disable_wheel_for_all_spinboxes() self._disable_wheel_for_all_spinboxes()
# 延迟调用工作目录选择对话框,确保主界面已完全渲染
# 100ms 延迟足以让 GUI 事件循环启动并显示主窗口
QTimer.singleShot(100, self.init_workspace)
def _init_step_dependencies(self): def _init_step_dependencies(self):
"""初始化步骤依赖关系和标准输出路径""" """初始化步骤依赖关系和标准输出路径"""
# 定义每个步骤的标准输出路径模式(相对于工作目录) # 定义每个步骤的标准输出路径模式(相对于工作目录)
@ -5549,6 +5624,59 @@ class WaterQualityGUI(QMainWindow):
for combobox in self.findChildren(QComboBox): for combobox in self.findChildren(QComboBox):
combobox.setFocusPolicy(Qt.StrongFocus) combobox.setFocusPolicy(Qt.StrongFocus)
combobox.wheelEvent = lambda event, cb=combobox: None combobox.wheelEvent = lambda event, cb=combobox: None
def init_workspace(self):
"""
初始化工作空间:弹出对话框选择工作目录
此方法通过 QTimer 延迟调用,确保主界面已完全渲染后再弹出对话框
如果用户取消或关闭,则退出程序
"""
from PyQt5.QtWidgets import QMessageBox
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle("选择工作目录")
msg_box.setText("欢迎使用水质参数反演分析系统!\n\n请选择工作目录来保存所有分析结果。")
msg_box.setInformativeText("工作目录将用于存储:\n• 水域掩膜文件\n• 耀斑检测结果\n• 模型训练数据\n• 预测结果与分布图\n\n点击'确定'选择目录")
msg_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
msg_box.setDefaultButton(QMessageBox.Ok)
result = msg_box.exec_()
if result == QMessageBox.Cancel:
QMessageBox.warning(None, "取消操作", "未选择工作目录,程序将退出。")
sys.exit(0)
# 弹出目录选择对话框
work_dir = QFileDialog.getExistingDirectory(
self, # 使用 self 作为父窗口,而不是 None
"选择工作目录",
"",
QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks
)
if not work_dir:
QMessageBox.critical(self, "错误", "必须选择工作目录才能使用系统!\n程序即将退出。")
sys.exit(0)
self.work_dir = work_dir
print(f"✓ 已选择工作目录: {self.work_dir}")
# 选择完成后,自动填充输出路径
self._auto_fill_output_paths()
def _auto_fill_output_paths(self):
"""
根据工作目录自动填充各步骤的输出路径
"""
if not self.work_dir:
return
# Step1: 输出掩膜路径
if hasattr(self, 'step1_panel') and hasattr(self.step1_panel, 'output_file'):
default_mask_path = os.path.join(self.work_dir, "1_water_mask", "water_mask_out.dat")
self.step1_panel.output_file.set_path(default_mask_path)
self.step1_panel.update_work_directory(self.work_dir)
def init_ui(self): def init_ui(self):
"""初始化UI""" """初始化UI"""