格式统一

This commit is contained in:
duxin
2026-06-30 14:12:12 +08:00
parent 9e433395f4
commit c793ea2204
8 changed files with 353 additions and 275 deletions

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step10 面板 - 水色指数反演(散点 CSV 模式)
Step10 面板 - 水色指数反演(散点 CSV 模式)(极致强迫症绝对对齐版)
与 Step 9 (ML 预测) 完全对称的【散点处理模式】:
@ -10,8 +10,6 @@ Step10 面板 - 水色指数反演(散点 CSV 模式)
* 输出:每个公式一个 CSV列严格为 ``longitude, latitude, <formula_name>``
可直接喂给 Step 11 ContentMapper
* 输出目录:默认 ``{work_dir}/10_WaterIndex_CSV/``
注:原"读 BSQ 全图→GeoTIFF"模式已废弃(科学上误差大且与 GIS 栅格计算器重复)。
"""
import os
@ -35,7 +33,7 @@ from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.styles import ModernStylesheet
# ==========================================
# 必须放在所有 import 的下方class 的上方!
# 模块级字典 + 防穿透 ListWidget
# ==========================================
CATEGORY_CHINESE_MAP = {
'Total_Suspended_Matter': '总悬浮物 (TSM)',
@ -53,26 +51,19 @@ CATEGORY_CHINESE_MAP = {
class WaterIndexWorker(QThread):
"""后台线程:散点 CSV → 逐行公式计算 → 多 CSV 输出
应用 Step 13 QThread 协议:
- 三信号 ``progress / finished / error`` 命名严格遵循约定
(注意 ``finished`` 不能用——会覆盖 QThread 内建同名信号,
导致 _on_finished 不被回调时按钮不会恢复)
- 进度回调两参 (msg: str, pct: float)
"""
"""后台线程:散点 CSV → 逐行公式计算 → 多 CSV 输出"""
progress = pyqtSignal(str, float) # message, percent
finished_ok = pyqtSignal(dict) # {公式名: 输出 CSV 路径}
error = pyqtSignal(str) # error message
finished_ok = pyqtSignal(dict) # {公式名: 输出 CSV 路径}
error = pyqtSignal(str) # error message
def __init__(
self,
sampling_csv_path: str,
output_dir: str,
selected_formulas: List[str],
waterindex_csv: str,
work_dir: Optional[str] = None,
wavelength_offset: float = 0.0,
self,
sampling_csv_path: str,
output_dir: str,
selected_formulas: List[str],
waterindex_csv: str,
work_dir: Optional[str] = None,
wavelength_offset: float = 0.0,
):
super().__init__()
self.sampling_csv_path = sampling_csv_path
@ -92,7 +83,6 @@ class WaterIndexWorker(QThread):
processor = WaterIndexCsvProcessor(self.waterindex_csv)
# 散点 CSV → 逐公式一个 CSV
out_files = processor.compute_indices_from_csv(
sampling_csv_path=self.sampling_csv_path,
output_dir=self.output_dir,
@ -116,11 +106,9 @@ class WaterIndexWorker(QThread):
class NoScrollPassListWidget(QListWidget):
"""一个绝对不会把滚轮事件传给外层父组件的列表控件"""
def wheelEvent(self, event):
# 先执行原本正常的列表内部滚动逻辑
super().wheelEvent(event)
# 核心黑科技:强制接收该事件。
# 告诉 PyQt"这个滚轮操作到我这里就结束了,无论如何不要传给外层的 ScrollArea"
event.accept()
@ -140,13 +128,11 @@ class Step10WatercolorPanel(QWidget):
def init_ui(self):
layout = QVBoxLayout()
# 注意:这里彻底删除了顶部的 title 和 hint 变量,保持全局页面清爽
# ==========================================
# 卡片 1输入数据配置
# ==========================================
input_group = QGroupBox("📁 输入数据")
# 【关键修正1】必须用 QVBoxLayout如果用 QFormLayout 会导致标签文字重复!
input_layout = QVBoxLayout()
input_layout.setSpacing(16)
input_layout.setContentsMargins(20, 24, 20, 20)
@ -156,7 +142,7 @@ class Step10WatercolorPanel(QWidget):
"CSV Files (*.csv);;All Files (*.*)"
)
self.formula_file.line_edit.setReadOnly(True)
self.formula_file.label.setMinimumWidth(100)
self.formula_file.label.setMinimumWidth(120)
builtin_csv = self._find_waterindex_csv()
if builtin_csv:
self.formula_file.set_path(builtin_csv)
@ -166,31 +152,30 @@ class Step10WatercolorPanel(QWidget):
"采样点 CSV:",
"CSV Files (*.csv);;All Files (*.*)"
)
self.sampling_csv_file.label.setMinimumWidth(100)
self.sampling_csv_file.label.setMinimumWidth(120)
input_layout.addWidget(self.sampling_csv_file)
# ── 波长偏移修正 ──
offset_layout = QHBoxLayout()
offset_label = QLabel("波长偏移 (nm):")
offset_label.setMinimumWidth(120)
# ── 波长偏移修正 (强迫症像素级完美对齐重构) ──
self.wavelength_offset_spin = QDoubleSpinBox()
self.wavelength_offset_spin.setRange(-200.0, 200.0)
self.wavelength_offset_spin.setValue(0.0)
self.wavelength_offset_spin.setDecimals(1)
self.wavelength_offset_spin.setSuffix("")
self.wavelength_offset_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.wavelength_offset_spin.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.wavelength_offset_spin.setToolTip(
"传感器波长系统偏移修正量。正数=公式波长加偏移(如+100则w450→找≈w550的波段"
"负数=公式波长减偏移。默认0表示不做修正。"
)
offset_layout.addWidget(offset_label)
offset_layout.addWidget(self.wavelength_offset_spin)
offset_layout.addStretch()
input_layout.addLayout(offset_layout)
# 使用强化版的对齐方法直接插入到 input_layout 中
self._add_aligned_row(input_layout, "波长偏移:", self.wavelength_offset_spin, "nm")
input_group.setLayout(input_layout)
layout.addWidget(input_group)
# ---- 公式选择 ----
# ==========================================
# 卡片 2公式选择
# ==========================================
formula_group = QGroupBox("公式选择")
formula_layout = QVBoxLayout()
formula_layout.setSpacing(16)
@ -215,13 +200,11 @@ class Step10WatercolorPanel(QWidget):
self.select_ratio_btn = QPushButton("仅比值型")
self.select_conc_btn = QPushButton("仅浓度型")
# 强制指定标准的按钮样式,彻底解决没有边框的问题
btn_style = """
QPushButton { background-color: #FFFFFF; border: 1px solid #C0C0C0; border-radius: 4px; padding: 4px 16px; color: #333333; }
QPushButton:hover { background-color: #F0F0F0; border: 1px solid #0078D7; }
QPushButton:pressed { background-color: #E0E0E0; }
"""
# 把新按钮也加进循环里应用样式
for btn in [self.select_all_btn, self.deselect_all_btn, self.select_ratio_btn, self.select_conc_btn]:
btn.setStyleSheet(btn_style)
btn.setFixedHeight(28)
@ -229,7 +212,6 @@ class Step10WatercolorPanel(QWidget):
self.select_all_btn.clicked.connect(self._select_all)
self.deselect_all_btn.clicked.connect(self._deselect_all)
# 绑定新按钮的事件
self.select_ratio_btn.clicked.connect(self._select_ratio)
self.select_conc_btn.clicked.connect(self._select_conc)
@ -239,19 +221,18 @@ class Step10WatercolorPanel(QWidget):
toolbar_layout.addWidget(self.select_conc_btn)
toolbar_layout.addStretch()
# 新增统计标签,直观显示公式数量
self.formula_count_label = QLabel("共 0 个公式")
self.formula_count_label.setStyleSheet("color: #666666; font-size: 13px; font-weight: bold; padding-right: 10px;")
self.formula_count_label.setStyleSheet(
"color: #666666; font-size: 13px; font-weight: bold; padding-right: 10px;")
toolbar_layout.addWidget(self.formula_count_label)
formula_layout.addLayout(toolbar_layout)
# 列表框 (使用刚刚定义的防穿透自定义类)
# 列表框
self.formula_list = NoScrollPassListWidget()
self.formula_list.setMinimumHeight(240)
self.formula_list.setWordWrap(True) # 开启自动换行,防止公式过长被截断
self.formula_list.setWordWrap(True)
# 关键优化:取消原生选中的蓝色渐变框,放大复选框
self.formula_list.setStyleSheet("""
QListWidget {
border: 1px solid #D1D5DB; border-radius: 6px; outline: none; background-color: #FFFFFF;
@ -262,7 +243,6 @@ class Step10WatercolorPanel(QWidget):
QListWidget::item:hover {
background-color: #F9FAFB;
}
/* 重点:彻底屏蔽原生的深蓝色选中渐变 */
QListWidget::item:selected {
background-color: transparent; color: #333333;
}
@ -270,7 +250,6 @@ class Step10WatercolorPanel(QWidget):
width: 18px; height: 18px; margin-right: 8px;
}
""")
# 绑定新方法:实现整行点击打勾
self.formula_list.itemSelectionChanged.connect(self._on_selection_changed)
formula_layout.addWidget(self.formula_list)
@ -281,7 +260,6 @@ class Step10WatercolorPanel(QWidget):
# 卡片 3输出设置
# ==========================================
output_group = QGroupBox("输出设置")
# 【关键修正2】改成 QVBoxLayout消除重复的"输出目录:"
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20)
@ -290,6 +268,7 @@ class Step10WatercolorPanel(QWidget):
"输出目录:",
"Directories"
)
self.output_dir.label.setMinimumWidth(120)
self.output_dir.line_edit.setPlaceholderText(
"留空 → 工作目录/10_WaterIndex_CSV"
)
@ -311,25 +290,22 @@ class Step10WatercolorPanel(QWidget):
layout.addWidget(self.progress_label)
# ==========================================
# 卡片 4输出与执行 (与进度显示合并)
# 卡片 4输出与执行
# ==========================================
execute_group = QGroupBox("🚀 输出与执行")
execute_layout = QVBoxLayout()
execute_layout.setSpacing(16)
execute_layout.setContentsMargins(20, 24, 20, 20)
# 把原本悬空的进度条放进这个卡片里,看起来更整洁
execute_layout.addWidget(self.progress_bar)
execute_layout.addWidget(self.progress_label)
# 完美对齐的底部按钮栏(彻底移除了 enable_checkbox
action_layout = QHBoxLayout()
action_layout.addStretch()
self.run_btn = QPushButton("独立运行步骤")
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
self.run_btn.setMinimumWidth(140)
# 统一改成连接到标准的 EventBus 方法
self.run_btn.clicked.connect(self._on_run_single_clicked)
action_layout.addWidget(self.run_btn)
@ -341,8 +317,35 @@ class Step10WatercolorPanel(QWidget):
layout.addStretch()
self.setLayout(layout)
def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text):
"""强化版对齐函数:让单位的占位和浏览按钮一样宽,完美锁死输入框长度"""
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(6) # 严格匹配 FileSelectWidget 内部的控件间距
# 1. 统一左侧标签宽度为 120px
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
row_layout.addWidget(lbl)
# 2. 允许控件水平拉伸
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setStyleSheet("min-height: 28px;")
row_layout.addWidget(widget)
# 3. 核心秘密:"浏览..."按钮的默认宽度大约是 75px。
# 我们把单位标签的宽度锁死在 75px并用 padding 制造出漂亮的空格!
# 这样它就会完美模拟上方 "浏览" 按钮的存在感,让所有输入框的长短一刀切般整齐!
suffix_lbl = QLabel(suffix_text)
suffix_lbl.setFixedWidth(75)
suffix_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
suffix_lbl.setStyleSheet("padding-left: 8px; color: #555555;")
row_layout.addWidget(suffix_lbl)
parent_layout.addLayout(row_layout)
def _find_waterindex_csv(self) -> str:
"""查找 waterindex.csv 路径"""
candidates = [
Path(__file__).parent.parent.parent / "model" / "waterindex.csv",
Path(__file__).parent.parent.parent.parent / "src" / "gui" / "model" / "waterindex.csv",
@ -353,7 +356,6 @@ class Step10WatercolorPanel(QWidget):
return ""
def _load_formulas(self):
"""加载 waterindex.csv 中的公式"""
if not self._waterindex_csv or not Path(self._waterindex_csv).exists():
return
@ -363,14 +365,12 @@ class Step10WatercolorPanel(QWidget):
with open(self._waterindex_csv, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
# 关键修复1强制清理 CSV 键值中的所有前后空格,防止匹配失败导致丢失
cleaned_row = {str(k).strip(): str(v).strip() for k, v in row.items() if k}
self._all_formulas.append(cleaned_row)
except Exception as e:
QMessageBox.critical(self, "读取失败", f"加载公式失败: {e}")
return
# 提取所有类别
cats = set()
for f in self._all_formulas:
c = f.get('Category', '')
@ -386,17 +386,13 @@ class Step10WatercolorPanel(QWidget):
self.category_combo.addItem(display_name, cat)
self.category_combo.blockSignals(False)
# 关键修复2不再动态渲染而是一次性初始化所有公式
self._init_list_items()
def _init_list_items(self):
"""一次性加载所有公式项,按照类别进行排序"""
from PyQt5.QtGui import QColor
self.formula_list.clear()
self._formula_list_widgets.clear()
# 核心优化:对 self._all_formulas 进行自定义排序
# 排序规则:主要按 Category (类别/物质) 排,同类别下按 Formula_Name 排
sorted_formulas = sorted(self._all_formulas, key=lambda x: (x.get('Category', ''), x.get('Formula_Name', '')))
for f in sorted_formulas:
@ -409,10 +405,9 @@ class Step10WatercolorPanel(QWidget):
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
# 存入隐式数据供后续逻辑使用
item.setData(Qt.UserRole, name)
item.setData(Qt.UserRole + 1, cat)
item.setData(Qt.UserRole + 2, ftype) # 存入类型,供"仅比值型/仅浓度型"按钮区分
item.setData(Qt.UserRole + 2, ftype)
cat_cn = CATEGORY_CHINESE_MAP.get(cat, cat)
ftype_cn = "比值型" if ftype == 'ratio' else "浓度型"
@ -426,49 +421,52 @@ class Step10WatercolorPanel(QWidget):
self.formula_list.addItem(item)
self._formula_list_widgets[name] = item
self.formula_count_label.setText(f"共加载 {self.formula_list.count()} 个公式")
self._update_formula_count()
def _refresh_visibility(self):
"""统一管理公式列表的可见性(综合物质类别 + 公式类型)"""
# 获取当前选中的物质类别
current_category = self.category_combo.currentData()
if not current_category:
current_category = "全部"
visible_count = 0
for i in range(self.formula_list.count()):
item = self.formula_list.item(i)
item_cat = item.data(Qt.UserRole + 1)
item_type = item.data(Qt.UserRole + 2)
# 条件1物质类别匹配
cat_match = (current_category == "全部" or item_cat == current_category)
# 条件2公式类型匹配
type_match = (self._current_type_filter == "all" or item_type == self._current_type_filter)
if cat_match and type_match:
item.setHidden(False)
visible_count += 1
else:
item.setHidden(True)
# 更新统计标签(如果选了特定类型,标签上体现出来)
self._update_formula_count()
def _on_category_changed(self, index: int):
self._current_type_filter = "all"
self._refresh_visibility()
def _update_formula_count(self):
total = self.formula_list.count()
visible = 0
selected = 0
for i in range(total):
item = self.formula_list.item(i)
if not item.isHidden():
visible += 1
if item.checkState() == Qt.Checked:
selected += 1
type_str = ""
if self._current_type_filter == 'ratio':
type_str = " (仅比值型)"
elif self._current_type_filter == 'concentration':
type_str = " (仅浓度型)"
self.formula_count_label.setText(f"当前显示: {visible_count}{type_str}")
def _on_category_changed(self, index: int):
# 切换物质类别时,重置类型过滤器(可选:如果不重置,就能叠加筛选。这里我们选择重置,体验更好)
self._current_type_filter = "all"
self._refresh_visibility()
self.formula_count_label.setText(f"已选: {selected} | 显示: {visible}{type_str} / 共 {total}")
def _select_all(self):
"""恢复显示所有当前类别的公式,并全部勾选"""
self._current_type_filter = "all"
self._refresh_visibility()
@ -476,16 +474,16 @@ class Step10WatercolorPanel(QWidget):
item = self.formula_list.item(i)
if not item.isHidden():
item.setCheckState(Qt.Checked)
self._update_formula_count()
def _deselect_all(self):
"""保留当前显示状态,但全部取消勾选"""
for i in range(self.formula_list.count()):
item = self.formula_list.item(i)
if not item.isHidden():
item.setCheckState(Qt.Unchecked)
self._update_formula_count()
def _select_ratio(self):
"""过滤出比值型并全部勾选"""
self._current_type_filter = "ratio"
self._refresh_visibility()
@ -493,9 +491,9 @@ class Step10WatercolorPanel(QWidget):
item = self.formula_list.item(i)
if not item.isHidden():
item.setCheckState(Qt.Checked)
self._update_formula_count()
def _select_conc(self):
"""过滤出浓度型并全部勾选"""
self._current_type_filter = "concentration"
self._refresh_visibility()
@ -503,21 +501,16 @@ class Step10WatercolorPanel(QWidget):
item = self.formula_list.item(i)
if not item.isHidden():
item.setCheckState(Qt.Checked)
self._update_formula_count()
def _on_selection_changed(self):
"""实现点击整行任意位置即可切换勾选状态"""
# 暂时阻塞信号,防止死循环
self.formula_list.blockSignals(True)
for item in self.formula_list.selectedItems():
# 翻转勾选状态
new_state = Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked
item.setCheckState(new_state)
# 立即清除选中高亮状态,保持视觉干净
item.setSelected(False)
self.formula_list.blockSignals(False)
def _on_item_changed(self, item: QListWidgetItem):
pass # 可扩展:实时统计选中数量
self._update_formula_count()
def _get_selected_formula_names(self) -> List[str]:
names = []
@ -568,21 +561,19 @@ class Step10WatercolorPanel(QWidget):
else:
self.work_dir = None
# 1. 采样点 CSV优先 pipeline.step_outputs回退文件系统扫描
sampling_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step4_out = pipeline.step_outputs.get('step4_sampling', {})
sampling_path = (
step4_out.get('sampling_csv')
or step4_out.get('output_path')
or step4_out.get('output_file')
step4_out.get('sampling_csv')
or step4_out.get('output_path')
or step4_out.get('output_file')
)
if not sampling_path and self.work_dir:
sampling_path = scan_work_dir_for_input(self.work_dir, 'sampling_points')
if sampling_path and os.path.exists(str(sampling_path)):
self.sampling_csv_file.set_path(str(sampling_path))
# 2. 自动填入输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir and not self.output_dir.get_path():
out_dir = os.path.join(
self.work_dir, '10_WaterIndex_CSV'
@ -590,7 +581,6 @@ class Step10WatercolorPanel(QWidget):
self.output_dir.set_path(out_dir)
def _on_run_single_clicked(self):
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor"""
from src.gui.core.event_bus import global_event_bus
sampling_csv_path = self.sampling_csv_file.get_path().strip()
@ -629,7 +619,6 @@ class Step10WatercolorPanel(QWidget):
})
def run_step(self):
"""独立运行步骤10旧版 parent 链上溯方式,保留兼容)。"""
sampling_csv_path = self.sampling_csv_file.get_path().strip()
if not sampling_csv_path:
QMessageBox.warning(self, "输入错误", "请选择采样点 CSV")
@ -661,7 +650,6 @@ class Step10WatercolorPanel(QWidget):
work_dir = self.work_dir or str(Path(sampling_csv_path).parent)
# 开始后台处理
self.run_btn.setEnabled(False)
self.progress_bar.setValue(0)
self.progress_label.setText("")
@ -713,7 +701,6 @@ class Step10WatercolorPanel(QWidget):
return self.output_dir.get_path().strip() or ""
def get_output_csv_paths(self) -> List[str]:
"""获取输出目录下的所有指数 CSV 文件路径(供 Step 11 ContentMapper 探测)"""
out_dir = self.get_output_dir()
if not out_dir or not os.path.isdir(out_dir):
return []

View File

@ -123,6 +123,7 @@ class Step1Panel(QWidget):
"Mask Files (*.dat *.tif);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(100)
self.output_file.line_edit.setPlaceholderText("water_mask.dat")
output_layout.addWidget(self.output_file)
@ -163,6 +164,13 @@ class Step1Panel(QWidget):
self.work_dir = work_dir
self._auto_fill_output_path()
def update_from_config(self, work_dir=None, pipeline=None):
"""将工作目录回填到 Step1 面板(与所有其他步骤接口统一)"""
if work_dir:
self.work_dir = work_dir
if self.work_dir:
self._auto_fill_output_path()
def _auto_fill_output_path(self):
"""仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。"""
if not hasattr(self, 'work_dir') or not self.work_dir:

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step2 面板 - 耀斑区域识别 (已移除“启用此步骤”)
Step2 面板 - 耀斑区域识别 (极致强迫症对齐版)
"""
import os
@ -12,7 +12,7 @@ from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QDoubleSpinBox, QSpinBox, QComboBox, QPushButton,
QMessageBox, QLabel
QMessageBox, QLabel, QSizePolicy
)
from PyQt5.QtCore import Qt
@ -33,6 +33,9 @@ class Step2Panel(QWidget):
main_layout.setContentsMargins(24, 24, 24, 24)
main_layout.setSpacing(20)
# ==========================================
# 卡片 1输入数据
# ==========================================
input_group = QGroupBox("📁 输入数据")
input_layout = QVBoxLayout()
input_layout.setSpacing(16)
@ -42,31 +45,33 @@ class Step2Panel(QWidget):
"原始影像:",
"Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
)
self.img_file.label.setMinimumWidth(100)
self.img_file.label.setMinimumWidth(120)
self.water_mask_file = FileSelectWidget(
"水域掩膜:",
"Mask Files (*.dat *.tif);;All Files (*.*)"
)
self.water_mask_file.label.setMinimumWidth(100)
self.water_mask_file.label.setMinimumWidth(120)
input_layout.addWidget(self.img_file)
input_layout.addWidget(self.water_mask_file)
input_group.setLayout(input_layout)
main_layout.addWidget(input_group)
# ==========================================
# 卡片 2检测参数 (强迫症级像素对齐重构)
# ==========================================
params_group = QGroupBox("⚙️ 检测参数")
params_layout = QFormLayout()
params_layout = QVBoxLayout()
params_layout.setSpacing(16)
params_layout.setContentsMargins(20, 24, 20, 20)
self.glint_wave = QDoubleSpinBox()
self.glint_wave.setRange(300, 1000)
self.glint_wave.setValue(750.0)
self.glint_wave.setSuffix(" nm")
self.glint_wave.setMinimumWidth(120)
self.glint_wave.setButtonSymbols(QDoubleSpinBox.NoButtons)
params_layout.addRow("耀斑检测波长:", self.glint_wave)
# 移除居中对齐,恢复为默认的左对齐
self._add_aligned_row(params_layout, "耀斑检测波长:", self.glint_wave, "nm")
self.method = QComboBox()
self.method.addItem("Otsu 阈值法", "otsu")
@ -75,30 +80,30 @@ class Step2Panel(QWidget):
self.method.addItem("IQR 四分位距法", "iqr")
self.method.addItem("自适应阈值法", "adaptive")
self.method.addItem("多波段综合法", "multi_band")
self.method.setMinimumWidth(120)
params_layout.addRow("检测方法选择:", self.method)
self._add_aligned_row(params_layout, "检测方法选择:", self.method, "")
self.max_area = QSpinBox()
self.max_area.setRange(0, 100000)
self.max_area.setValue(50)
self.max_area.setSuffix(" px")
self.max_area.setSpecialValueText("不过滤面积")
self.max_area.setSpecialValueText("不过滤")
self.max_area.setButtonSymbols(QSpinBox.NoButtons)
self.max_area.setMinimumWidth(120)
params_layout.addRow("连通域最大面积:", self.max_area)
# 移除居中对齐,恢复为默认的左对齐
self._add_aligned_row(params_layout, "连通域最大面积:", self.max_area, "px")
self.buffer_size = QSpinBox()
self.buffer_size.setRange(0, 200)
self.buffer_size.setValue(10)
self.buffer_size.setSuffix(" 网格")
self.buffer_size.setSpecialValueText("不设置缓冲区")
self.buffer_size.setSpecialValueText("不设置")
self.buffer_size.setButtonSymbols(QSpinBox.NoButtons)
self.buffer_size.setMinimumWidth(120)
params_layout.addRow("岸边缓冲区大小:", self.buffer_size)
# 移除居中对齐,恢复为默认的左对齐
self._add_aligned_row(params_layout, "岸边缓冲区大小:", self.buffer_size, "网格")
params_group.setLayout(params_layout)
main_layout.addWidget(params_group)
# ==========================================
# 卡片 3输出与执行
# ==========================================
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -109,7 +114,7 @@ class Step2Panel(QWidget):
"Mask Files (*.dat *.tif);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(100)
self.output_file.label.setMinimumWidth(120)
output_layout.addWidget(self.output_file)
action_layout = QHBoxLayout()
@ -128,6 +133,31 @@ class Step2Panel(QWidget):
main_layout.addStretch()
self.setLayout(main_layout)
def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text):
"""核心对齐函数:利用隐形占位符实现多行输入框右边框绝对对齐"""
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(10)
# 1. 统一左侧标签宽度
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
row_layout.addWidget(lbl)
# 2. 允许控件水平拉伸,并统一高度
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setStyleSheet("min-height: 28px;")
row_layout.addWidget(widget)
# 3. 核心修复:强制设定右侧单位的物理宽度为 40px
# 哪怕后缀是空字符串(""),它也会强行占用 40px 的空间。
# 这样下拉框就不会拉伸过头,所有输入框的右边框就会绝对对齐!
suffix_lbl = QLabel(suffix_text)
suffix_lbl.setFixedWidth(40)
row_layout.addWidget(suffix_lbl)
parent_layout.addLayout(row_layout)
def get_config(self):
config = {
'img_path': self.img_file.get_path(),
@ -175,7 +205,6 @@ class Step2Panel(QWidget):
self.work_dir = None
# ── 水域掩膜输入 ──
# 优先pipeline context执行时内存中的确切状态
mask_path = None
if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path:
mask_path = pipeline.water_mask_path
@ -183,11 +212,9 @@ class Step2Panel(QWidget):
step1_out = pipeline.step_outputs.get('step1', {})
mask_path = step1_out.get('water_mask') or step1_out.get('output_path')
# 回退:基于 work_dir 的文件系统扫描(仅信任硬盘上真实存在的文件)
if not mask_path or not os.path.exists(mask_path):
mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
# 填入 UI
if mask_path and os.path.exists(mask_path):
if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step3 面板 - 耀斑去除 (已移除“启用此步骤”)
Step3 面板 - 耀斑去除 (极致强迫症对齐版)
"""
import os
@ -9,10 +9,11 @@ from pathlib import Path
from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for_input
# ── 【修复点】:在这里加上了 QFrame 的导入 ──
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QSpinBox, QComboBox, QCheckBox, QPushButton,
QLabel, QLineEdit, QMessageBox, QSizePolicy
QLabel, QLineEdit, QMessageBox, QSizePolicy, QFrame
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QDoubleValidator
@ -33,6 +34,9 @@ class Step3Panel(QWidget):
main_layout.setContentsMargins(24, 24, 24, 24)
main_layout.setSpacing(20)
# ==========================================
# 卡片 1输入数据
# ==========================================
input_group = QGroupBox("📁 输入数据")
input_layout = QVBoxLayout()
input_layout.setSpacing(16)
@ -71,137 +75,153 @@ class Step3Panel(QWidget):
input_group.setLayout(input_layout)
main_layout.addWidget(input_group)
# ==========================================
# 卡片 2去耀斑算法设置 (强迫症级对齐)
# ==========================================
params_group = QGroupBox("⚙️ 去耀斑算法设置")
params_layout = QVBoxLayout()
params_layout = QVBoxLayout() # 废除 FormLayout
params_layout.setSpacing(16)
params_layout.setContentsMargins(20, 24, 20, 20)
method_row = QHBoxLayout()
method_row.setContentsMargins(0, 0, 0, 0)
method_label = QLabel("去耀斑方法:")
method_label.setMinimumWidth(120)
# ── 顶层方法选择 ──
self.method = QComboBox()
for text, data in [('Goodman 方法', 'goodman'), ('Kutser 方法', 'kutser'),
('Hedley 方法', 'hedley'), ('SUGAR 算法', 'sugar')]:
self.method.addItem(text, data)
self.method.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.method.currentIndexChanged.connect(self._on_method_changed)
method_row.addWidget(method_label)
method_row.addWidget(self.method)
params_layout.addLayout(method_row)
self._add_aligned_row(params_layout, "去耀斑方法:", self.method, "")
# --- Goodman ---
# ── Goodman ──
self.goodman_widget = QWidget()
goodman_layout = QFormLayout(self.goodman_widget)
goodman_layout = QVBoxLayout(self.goodman_widget)
goodman_layout.setContentsMargins(0, 0, 0, 0)
goodman_layout.setSpacing(16)
self.nir_lower = QSpinBox()
self.nir_lower.setRange(0, 200)
self.nir_lower.setValue(65)
self.nir_lower.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(goodman_layout, "NIR下波段索引:", self.nir_lower)
self._add_aligned_row(goodman_layout, "NIR下波段索引:", self.nir_lower, "")
self.nir_upper = QSpinBox()
self.nir_upper.setRange(0, 200)
self.nir_upper.setValue(91)
self.nir_upper.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(goodman_layout, "NIR上波段索引:", self.nir_upper)
self._add_aligned_row(goodman_layout, "NIR上波段索引:", self.nir_upper, "")
self.goodman_a = QLineEdit("0.000019")
self.goodman_a.setValidator(QDoubleValidator(0.0, 1.0, 6, self))
self._add_row_with_fixed_label(goodman_layout, "参数 A:", self.goodman_a)
self._add_aligned_row(goodman_layout, "参数 A:", self.goodman_a, "")
self.goodman_b = QLineEdit("0.10")
self.goodman_b.setValidator(QDoubleValidator(0.0, 1.0, 2, self))
self._add_row_with_fixed_label(goodman_layout, "参数 B:", self.goodman_b)
self._add_aligned_row(goodman_layout, "参数 B:", self.goodman_b, "")
params_layout.addWidget(self.goodman_widget)
# --- Kutser ---
# ── Kutser ──
self.kutser_widget = QWidget()
kutser_layout = QFormLayout(self.kutser_widget)
kutser_layout = QVBoxLayout(self.kutser_widget)
kutser_layout.setContentsMargins(0, 0, 0, 0)
kutser_layout.setSpacing(16)
self.oxy_band = QSpinBox()
self.oxy_band.setRange(0, 200)
self.oxy_band.setValue(38)
self.oxy_band.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(kutser_layout, "氧吸收波段索引:", self.oxy_band)
self._add_aligned_row(kutser_layout, "氧吸收波段索引:", self.oxy_band, "")
self.lower_oxy = QSpinBox()
self.lower_oxy.setRange(0, 200)
self.lower_oxy.setValue(36)
self.lower_oxy.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(kutser_layout, "下氧吸收波段索引:", self.lower_oxy)
self._add_aligned_row(kutser_layout, "下氧吸收波段索引:", self.lower_oxy, "")
self.upper_oxy = QSpinBox()
self.upper_oxy.setRange(0, 200)
self.upper_oxy.setValue(49)
self.upper_oxy.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(kutser_layout, "上氧吸收波段索引:", self.upper_oxy)
self._add_aligned_row(kutser_layout, "上氧吸收波段索引:", self.upper_oxy, "")
self.nir_band = QSpinBox()
self.nir_band.setRange(0, 200)
self.nir_band.setValue(47)
self.nir_band.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(kutser_layout, "NIR波段索引:", self.nir_band)
self._add_aligned_row(kutser_layout, "NIR波段索引:", self.nir_band, "")
self.kutser_widget.setVisible(False)
params_layout.addWidget(self.kutser_widget)
# --- Hedley ---
# ── Hedley ──
self.hedley_widget = QWidget()
hedley_layout = QFormLayout(self.hedley_widget)
hedley_layout = QVBoxLayout(self.hedley_widget)
hedley_layout.setContentsMargins(0, 0, 0, 0)
hedley_layout.setSpacing(16)
self.hedley_nir_band = QSpinBox()
self.hedley_nir_band.setRange(0, 200)
self.hedley_nir_band.setValue(47)
self.hedley_nir_band.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(hedley_layout, "NIR波段索引:", self.hedley_nir_band)
self._add_aligned_row(hedley_layout, "NIR波段索引:", self.hedley_nir_band, "")
self.hedley_widget.setVisible(False)
params_layout.addWidget(self.hedley_widget)
# --- SUGAR ---
# ── SUGAR ──
self.sugar_widget = QWidget()
sugar_layout = QFormLayout(self.sugar_widget)
sugar_layout = QVBoxLayout(self.sugar_widget)
sugar_layout.setContentsMargins(0, 0, 0, 0)
sugar_layout.setSpacing(16)
self.sugar_iter = QSpinBox()
self.sugar_iter.setRange(1, 20)
self.sugar_iter.setValue(3)
self.sugar_iter.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(sugar_layout, "迭代次数:", self.sugar_iter)
self._add_aligned_row(sugar_layout, "迭代次数:", self.sugar_iter, "")
self.sugar_sigma = QLineEdit("1.00")
self.sugar_sigma.setValidator(QDoubleValidator(0.1, 10.0, 2, self))
self._add_row_with_fixed_label(sugar_layout, "LoG 平滑 σ:", self.sugar_sigma)
self._add_aligned_row(sugar_layout, "LoG 平滑 σ:", self.sugar_sigma, "")
self.sugar_estimate_background = QCheckBox()
self.sugar_estimate_background.setChecked(True)
self._add_row_with_fixed_label(sugar_layout, "估计背景光谱:", self.sugar_estimate_background)
# CheckBox 特殊处理:使用占位符填补控件区域
self._add_aligned_row(sugar_layout, "估计背景光谱:", self.sugar_estimate_background, "")
self.sugar_glint_mask_method = QComboBox()
self.sugar_glint_mask_method.addItems(['cdf', 'otsu'])
self.sugar_glint_mask_method.setCurrentText('cdf')
self.sugar_glint_mask_method.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._add_row_with_fixed_label(sugar_layout, "耀斑掩膜方法:", self.sugar_glint_mask_method)
self._add_aligned_row(sugar_layout, "耀斑掩膜方法:", self.sugar_glint_mask_method, "")
self.sugar_termination_thresh = QLineEdit("20.00")
self.sugar_termination_thresh.setValidator(QDoubleValidator(1.0, 100.0, 2, self))
self._add_row_with_fixed_label(sugar_layout, "终止阈值:", self.sugar_termination_thresh)
self._add_aligned_row(sugar_layout, "终止阈值:", self.sugar_termination_thresh, "")
self.sugar_bounds = QLineEdit("[(1, 2)]")
self._add_row_with_fixed_label(sugar_layout, "优化边界:", self.sugar_bounds)
self._add_aligned_row(sugar_layout, "优化边界:", self.sugar_bounds, "")
self.sugar_widget.setVisible(False)
params_layout.addWidget(self.sugar_widget)
# --- 通用参数 ---
# ── 通用插值参数 (放在底部) ──
# 添加一条分割线,区分算法特定参数和通用参数
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setStyleSheet("color: #E2E8F0;")
params_layout.addWidget(line)
interp_row = QHBoxLayout()
interp_row.setContentsMargins(0, 8, 0, 0)
interp_row.setContentsMargins(0, 0, 0, 0)
interp_row.setSpacing(10)
lbl_interp = QLabel("通用插值设置:")
lbl_interp.setMinimumWidth(120)
interp_row.addWidget(lbl_interp)
self.interpolate_zeros = QCheckBox("启用 0 值像素插值")
self.interpolate_zeros.setMinimumWidth(120)
self.interpolate_zeros.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
interp_row.addWidget(self.interpolate_zeros)
interp_row.addSpacing(20) # 间距
self.interp_method = QComboBox()
for text, data in [('最近邻插值', 'nearest'), ('双线性插值', 'bilinear'),
@ -209,14 +229,21 @@ class Step3Panel(QWidget):
self.interp_method.addItem(text, data)
self.interp_method.setCurrentIndex(1)
self.interp_method.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
interp_row.addWidget(self.interpolate_zeros)
self.interp_method.setStyleSheet("min-height: 28px;")
interp_row.addWidget(self.interp_method)
params_layout.addLayout(interp_row)
# 统一的 40px 占位符
suffix_lbl = QLabel("")
suffix_lbl.setFixedWidth(40)
interp_row.addWidget(suffix_lbl)
params_layout.addLayout(interp_row)
params_group.setLayout(params_layout)
main_layout.addWidget(params_group)
# ==========================================
# 卡片 3输出与执行
# ==========================================
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -249,19 +276,34 @@ class Step3Panel(QWidget):
self.img_file.line_edit.textChanged.connect(self._update_band_ranges)
def _add_row_with_fixed_label(self, form_layout, label_text, widget):
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text):
"""核心对齐函数:隐形占位符实现绝对边缘对齐"""
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(10)
# 1. 统一左侧标签宽度
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
row_layout.addWidget(lbl)
if hasattr(widget, 'setSizePolicy'):
# 2. 允许控件水平拉伸,填充满宽
if isinstance(widget, QCheckBox):
# CheckBox 不要拉伸太长,否则点击区域会变得很奇怪
widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
row_layout.addWidget(widget)
row_layout.addStretch() # 把剩下的空间推掉
else:
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setStyleSheet("min-height: 28px;")
row_layout.addWidget(widget)
row_layout.addWidget(widget)
form_layout.addRow(row_layout)
# 3. 隐形占位符:不论有没有单位,强行挤压 40px 的空间
suffix_lbl = QLabel(suffix_text)
suffix_lbl.setFixedWidth(40)
row_layout.addWidget(suffix_lbl)
parent_layout.addLayout(row_layout)
def open_interactive_viewer(self):
from src.gui.components.chart_dialogs import InteractiveViewerDialog

View File

@ -110,23 +110,23 @@ class Step4SamplingPanel(QWidget):
self.interval = QSpinBox()
self.interval.setRange(10, 500)
self.interval.setValue(50)
self.interval.setSuffix(" px")
self.interval.setMinimumWidth(120)
params_layout.addRow("采样点间隔:", self.interval)
w = QWidget(); l = QHBoxLayout(w); l.setContentsMargins(0,0,0,0); l.addWidget(self.interval); l.addWidget(QLabel(" px"))
w.setMinimumWidth(120)
params_layout.addRow("采样点间隔:", w)
self.sample_radius = QSpinBox()
self.sample_radius.setRange(1, 50)
self.sample_radius.setValue(5)
self.sample_radius.setSuffix(" px")
self.sample_radius.setMinimumWidth(120)
params_layout.addRow("中心采样半径:", self.sample_radius)
w2 = QWidget(); l2 = QHBoxLayout(w2); l2.setContentsMargins(0,0,0,0); l2.addWidget(self.sample_radius); l2.addWidget(QLabel(" px"))
w2.setMinimumWidth(120)
params_layout.addRow("中心采样半径:", w2)
self.chunk_size = QSpinBox()
self.chunk_size.setRange(100, 10000)
self.chunk_size.setValue(1000)
self.chunk_size.setSuffix(" px")
self.chunk_size.setMinimumWidth(120)
params_layout.addRow("内存处理块大小:", self.chunk_size)
w3 = QWidget(); l3 = QHBoxLayout(w3); l3.setContentsMargins(0,0,0,0); l3.addWidget(self.chunk_size); l3.addWidget(QLabel(" px"))
w3.setMinimumWidth(120)
params_layout.addRow("内存处理块大小:", w3)
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
self.use_adaptive_sampling.setChecked(True)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step6 面板 - 光谱特征提取 (完美对齐无跳动重构版)
Step6 面板 - 光谱特征提取 (极致强迫症绝对左对齐版)
"""
import os
@ -64,26 +64,27 @@ class Step6FeaturePanel(QWidget):
"去耀斑影像:",
"Image Files (*.bsq *.dat *.tif);;All Files (*.*)"
)
self.deglint_img_file.label.setMinimumWidth(100)
# FileSelectWidget 内部已经处理了左侧宽度我们这里强制设为120对齐
self.deglint_img_file.label.setMinimumWidth(120)
self.csv_file = FileSelectWidget(
"处理后CSV:",
"CSV Files (*.csv);;All Files (*.*)"
)
self.csv_file.label.setMinimumWidth(100)
self.csv_file.label.setMinimumWidth(120)
self.water_mask_file = FileSelectWidget(
"水体掩膜:",
"Mask Files (*.dat *.tif);;All Files (*.*)"
)
self.water_mask_file.label.setMinimumWidth(100)
self.water_mask_file.label.setMinimumWidth(120)
self.water_mask_file.line_edit.setPlaceholderText("可选,如不选择则自动生成")
self.glint_mask_file = FileSelectWidget(
"耀斑掩膜:",
"Mask Files (*.dat *.tif);;All Files (*.*)"
)
self.glint_mask_file.label.setMinimumWidth(100)
self.glint_mask_file.label.setMinimumWidth(120)
input_layout.addWidget(self.deglint_img_file)
input_layout.addWidget(self.csv_file)
@ -94,25 +95,26 @@ class Step6FeaturePanel(QWidget):
main_layout.addWidget(input_group)
# ==========================================
# 卡片 2提取参数
# 卡片 2提取参数 (强迫症像素级对齐)
# ==========================================
params_group = QGroupBox("⚙️ 提取参数")
params_layout = QFormLayout()
params_layout = QVBoxLayout() # 废除 QFormLayout使用可控的垂直布局
params_layout.setSpacing(16)
params_layout.setContentsMargins(20, 24, 20, 20)
self.radius = QSpinBox()
self.radius.setRange(1, 50)
self.radius.setValue(5)
self.radius.setSuffix(" px")
self.radius.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(params_layout, "采样半径:", self.radius)
self.radius.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制数字靠左
self._add_aligned_row(params_layout, "采样半径:", self.radius, "px")
self.source_epsg = QSpinBox()
self.source_epsg.setRange(1000, 99999)
self.source_epsg.setValue(4326)
self.source_epsg.setButtonSymbols(QSpinBox.NoButtons)
self._add_row_with_fixed_label(params_layout, "源坐标系 EPSG:", self.source_epsg)
self.source_epsg.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制数字靠左
self._add_aligned_row(params_layout, "源坐标系 EPSG:", self.source_epsg, "")
params_group.setLayout(params_layout)
main_layout.addWidget(params_group)
@ -130,7 +132,7 @@ class Step6FeaturePanel(QWidget):
"CSV Files (*.csv);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(100)
self.output_file.label.setMinimumWidth(120) # 统一 120px 对齐
self.output_file.line_edit.setPlaceholderText("training_spectra.csv")
output_layout.addWidget(self.output_file)
@ -150,20 +152,29 @@ class Step6FeaturePanel(QWidget):
main_layout.addStretch()
self.setLayout(main_layout)
def _add_row_with_fixed_label(self, form_layout, label_text, widget):
"""辅助方法:创建绝对锁死宽度的对齐行"""
lbl = QLabel(label_text)
lbl.setMinimumWidth(100)
def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text):
"""核心对齐函数:隐形占位符实现绝对边缘对齐,且标签绝对靠左"""
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(10)
# 1. 统一左侧标签宽度为 120px并强制内容靠左对齐
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制靠左!
row_layout.addWidget(lbl)
if hasattr(widget, 'setSizePolicy'):
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# 2. 允许控件水平拉伸,填充满宽
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setStyleSheet("min-height: 28px;")
row_layout.addWidget(widget)
form_layout.addRow(row_layout)
# 3. 隐形占位符:强制挤压 40px 的空间,让右侧边界绝对整齐
suffix_lbl = QLabel(suffix_text)
suffix_lbl.setFixedWidth(40)
row_layout.addWidget(suffix_lbl)
parent_layout.addLayout(row_layout)
def get_config(self):
"""获取配置"""

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step7 视图 - 水质光谱指数计算 (完美对齐卡片化重构版)
Step7 视图 - 水质光谱指数计算 (极致强迫症绝对对齐版)
"""
import os
@ -160,7 +160,6 @@ class Step7InversionPanel(QWidget):
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(10)
# 👉 【新增】: 按类别筛选下拉框 (对齐 Step 10)
btn_layout.addWidget(QLabel("按类别筛选:"))
self.category_combo = QComboBox()
self.category_combo.setStyleSheet("""
@ -186,7 +185,6 @@ class Step7InversionPanel(QWidget):
btn_layout.addStretch()
# 👉 【升级】: 统计标签 (显示选中、当前可见、总计)
self.formula_count_label = QLabel("已选: 0 | 显示: 0 / 共 0 个")
self.formula_count_label.setStyleSheet(
"color: #666666; font-size: 13px; font-weight: bold; padding-right: 10px;")
@ -194,12 +192,11 @@ class Step7InversionPanel(QWidget):
right_layout.addLayout(btn_layout)
# 列表框 (使用自定义防穿透类)
# 列表框
self.formula_list = NoScrollPassListWidget()
self.formula_list.setMinimumHeight(220) # 稍微加高以展示复杂内容
self.formula_list.setWordWrap(True) # 允许公式换行
self.formula_list.setMinimumHeight(220)
self.formula_list.setWordWrap(True)
# 禁用原生的 selected 高亮样式,并设置透明背景
self.formula_list.setStyleSheet(f"""
QListWidget {{
border: 1px solid {ModernStylesheet.COLORS['border']};
@ -208,7 +205,7 @@ class Step7InversionPanel(QWidget):
outline: none;
}}
QListWidget::item {{
padding: 8px 8px; /* 加大 padding 让多行显示更舒服 */
padding: 8px 8px;
border-bottom: 1px solid {ModernStylesheet.COLORS['border_light']};
}}
QListWidget::item:hover {{
@ -225,9 +222,7 @@ class Step7InversionPanel(QWidget):
}}
""")
# 绑定状态改变事件
self.formula_list.itemChanged.connect(self._update_formula_count)
# 绑定行选中事件,实现点击整行打勾
self.formula_list.itemSelectionChanged.connect(self._on_selection_changed)
right_layout.addWidget(self.formula_list)
@ -240,26 +235,28 @@ class Step7InversionPanel(QWidget):
main_layout.addWidget(params_group)
# ==========================================
# 波长偏移修正
# 波长偏移修正 (强迫症满宽左对齐重构版)
# ==========================================
offset_group = QGroupBox("🔧 波长偏移修正")
offset_layout = QHBoxLayout()
offset_layout.setContentsMargins(20, 16, 20, 16)
offset_label = QLabel("统一偏移量 (nm):")
offset_label.setMinimumWidth(120)
offset_layout = QVBoxLayout()
offset_layout.setSpacing(16)
offset_layout.setContentsMargins(20, 24, 20, 20)
self.wavelength_offset_spin = QDoubleSpinBox()
self.wavelength_offset_spin.setRange(-200.0, 200.0)
self.wavelength_offset_spin.setValue(0.0)
self.wavelength_offset_spin.setDecimals(1)
self.wavelength_offset_spin.setSuffix("")
self.wavelength_offset_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.wavelength_offset_spin.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 强制数字靠左
self.wavelength_offset_spin.setToolTip(
"传感器波长系统偏移修正。公式中的目标波长统一加上此值后再匹配传感器波段。\n"
"例如:偏移 +100 意味着公式中的 w450 实际去找传感器 ~550nm 的波段。\n"
"默认 0 不做修正。适用于传感器标定漂移或不同传感器间的波段对齐。"
)
offset_layout.addWidget(offset_label)
offset_layout.addWidget(self.wavelength_offset_spin)
offset_layout.addStretch()
# 提取公共方法 _add_aligned_row实现完美边缘对齐且带 nm 单位
self._add_aligned_row(offset_layout, "统一偏移量:", self.wavelength_offset_spin, "nm")
offset_group.setLayout(offset_layout)
main_layout.addWidget(offset_group)
@ -305,6 +302,30 @@ class Step7InversionPanel(QWidget):
# 初始加载
self._load_formulas_from_csv()
def _add_aligned_row(self, parent_layout, label_text, widget, suffix_text):
"""核心对齐函数:隐形占位符实现绝对边缘对齐,且标签绝对靠左"""
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(10)
# 1. 统一左侧标签宽度为 120px并强制内容靠左对齐
lbl = QLabel(label_text)
lbl.setMinimumWidth(120)
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
row_layout.addWidget(lbl)
# 2. 允许控件水平拉伸,填充满宽
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
widget.setStyleSheet("min-height: 28px;")
row_layout.addWidget(widget)
# 3. 隐形占位符:强制挤压 40px 的空间,让右侧边界绝对整齐
suffix_lbl = QLabel(suffix_text)
suffix_lbl.setFixedWidth(40)
row_layout.addWidget(suffix_lbl)
parent_layout.addLayout(row_layout)
def _resolve_builtin_csv(self):
"""探测内置 waterindex.csv 的实际位置。"""
panel_dir = os.path.dirname(os.path.abspath(__file__))
@ -325,7 +346,7 @@ class Step7InversionPanel(QWidget):
def get_config(self) -> dict:
"""收集当前界面的配置字典"""
selected_names = [
self.formula_list.item(i).data(Qt.UserRole) # 从隐藏属性中提取纯净的名称
self.formula_list.item(i).data(Qt.UserRole)
for i in range(self.formula_list.count())
if self.formula_list.item(i).checkState() == Qt.Checked
]
@ -394,7 +415,6 @@ class Step7InversionPanel(QWidget):
self.formula_list.blockSignals(False)
return
# 👉 【提取所有分类并更新下拉框】
cats = set()
for _, row in df.iterrows():
if 'Category' in df.columns and pd.notna(row['Category']):
@ -411,19 +431,16 @@ class Step7InversionPanel(QWidget):
self.category_combo.addItem(display_name, cat)
self.category_combo.blockSignals(False)
# 👉 【构建美观的列表项】
for _, row in df.iterrows():
idx_name = str(row[target_col]).strip()
item = QListWidgetItem()
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
# 解析公式和分类
formula_str = str(row['Formula']).strip() if 'Formula' in df.columns else ""
cat = str(row['Category']).strip() if 'Category' in df.columns and pd.notna(
row['Category']) else "综合/未分类"
# 解析精确的 Formula_Type
ftype = ''
if 'Formula_Type' in df.columns and pd.notna(row['Formula_Type']):
ftype = str(row['Formula_Type']).strip().lower()
@ -434,12 +451,10 @@ class Step7InversionPanel(QWidget):
else:
ftype = self._formula_type_map.get(idx_name, 'ratio')
# 保存隐式数据 (极其重要,用于过滤和配置提取)
item.setData(Qt.UserRole, idx_name)
item.setData(Qt.UserRole + 1, cat)
item.setData(Qt.UserRole + 2, ftype)
# 设置带分类和公式的美观文本 (对齐 Step 10)
cat_cn = CATEGORY_CHINESE_MAP.get(cat, cat)
ftype_cn = "比值型" if ftype == 'ratio' else "浓度型"
if formula_str:
@ -447,7 +462,6 @@ class Step7InversionPanel(QWidget):
else:
item.setText(f"{cat_cn}{idx_name} ({ftype_cn})")
# 设置底色
bg_color = QColor("#FFFFFF") if ftype == 'ratio' else QColor("#E8F4F8")
item.setBackground(bg_color)
@ -460,7 +474,6 @@ class Step7InversionPanel(QWidget):
QMessageBox.critical(self, "加载错误", f"加载公式文件时发生错误:\n{str(e)}")
def _refresh_visibility(self):
"""核心过滤引擎:根据下拉框和类型按钮动态隐藏/显示公式"""
current_category = self.category_combo.currentData()
if not current_category:
current_category = "全部"
@ -481,12 +494,10 @@ class Step7InversionPanel(QWidget):
self._update_formula_count()
def _on_category_changed(self, index: int):
"""下拉框改变分类时触发"""
self._current_type_filter = "all" # 切换分类时默认重置类型过滤
self._current_type_filter = "all"
self._refresh_visibility()
def _update_formula_count(self):
"""计算并更新多维度统计标签"""
total = self.formula_list.count()
visible = 0
selected = 0
@ -506,17 +517,15 @@ class Step7InversionPanel(QWidget):
self.formula_count_label.setText(f"已选: {selected} | 显示: {visible}{type_str} / 共 {total}")
def _on_selection_changed(self):
"""实现点击整行任意位置即可切换勾选状态,并立刻取消高亮"""
self.formula_list.blockSignals(True)
for item in self.formula_list.selectedItems():
new_state = Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked
item.setCheckState(new_state)
item.setSelected(False) # 立刻取消原生选中状态,避免变白
item.setSelected(False)
self.formula_list.blockSignals(False)
self._update_formula_count()
def _select_all_formulas(self):
"""全选并显示所有(当前分类下的)公式"""
self._current_type_filter = "all"
self._refresh_visibility()
@ -529,7 +538,6 @@ class Step7InversionPanel(QWidget):
self._update_formula_count()
def _clear_formula_selection(self):
"""只清空当前显示项的勾选"""
self.formula_list.blockSignals(True)
for i in range(self.formula_list.count()):
item = self.formula_list.item(i)
@ -539,7 +547,6 @@ class Step7InversionPanel(QWidget):
self._update_formula_count()
def _select_ratio_formulas(self):
"""仅显示比值型,并勾选"""
self._current_type_filter = "ratio"
self._refresh_visibility()
@ -552,7 +559,6 @@ class Step7InversionPanel(QWidget):
self._update_formula_count()
def _select_conc_formulas(self):
"""仅显示浓度型,并勾选"""
self._current_type_filter = "concentration"
self._refresh_visibility()
@ -565,7 +571,6 @@ class Step7InversionPanel(QWidget):
self._update_formula_count()
def _on_run_single_clicked(self):
"""点击独立运行按钮:包装参数并通过 EventBus 派发任务"""
training_path = self.training_data_widget.get_path()
if not training_path:
QMessageBox.warning(self, "输入错误", "请先选择输入的样本数据 (CSV)")

View File

@ -264,56 +264,54 @@ def multi_band_glint_detection(dataset, img_path, water_mask, glint_waves, weigh
@timeit
def adaptive_threshold(img, data_water_mask, window_size=15, percentile=90, foreground=1, background=0):
"""
自适应阈值方法
基于局部统计特性进行阈值分割,对光照变化更稳健
自适应阈值方法scipy.ndimage.percentile_filter 加速版)
对每个像素,在局部窗口内计算百分位数作为动态阈值:
像素值 > 局部{percentile}%分位数 → 耀斑
与原始逐像素双重循环语义完全一致,但使用 scipy 的 C 级向量化实现,
速度提升 1000+ 倍。
Args:
img: 输入图像数组
img: 输入图像数组(已做百分位数拉伸到 0-255 的整数类型)
data_water_mask: 水域掩膜
window_size: 局部窗口大小(奇数)
percentile: 局部百分位数阈值
window_size: 局部窗口大小(奇数,默认 15
percentile: 局部百分位数阈值(默认 90即高于局部90%像素值)
foreground: 前景值
background: 背景值
Returns:
二值化检测结果
"""
height, width = img.shape
from scipy.ndimage import percentile_filter
# 确保窗口大小为奇数
if window_size % 2 == 0:
window_size += 1
half_window = window_size // 2
# 创建输出图像
# 水域外区域设极小值(-1e9使其落在百分位分布的最底部不影响上分位数
masked = img.astype(np.float32)
masked[data_water_mask == 0] = -1e9
# 局部百分位数滤波 —— scipy C 级向量化,直接替代原来的双重 for 循环
# size 支持单一整数(正方形窗口)
local_thresh = percentile_filter(
masked,
percentile=percentile,
size=window_size,
mode='constant',
cval=-1e9,
)
# 二值化:原图像素 > 局部百分位阈值
det_img = np.zeros_like(img, dtype=np.int32)
# 对每个像素计算局部阈值
for i in range(half_window, height - half_window):
for j in range(half_window, width - half_window):
# 只在水域掩膜内处理
if data_water_mask[i, j] == 0:
continue
# 提取局部窗口
local_window = img[i - half_window:i + half_window + 1,
j - half_window:j + half_window + 1]
local_mask = data_water_mask[i - half_window:i + half_window + 1,
j - half_window:j + half_window + 1]
# 只考虑有效像素
valid_pixels = local_window[local_mask > 0]
if len(valid_pixels) > 0:
local_threshold = np.percentile(valid_pixels, percentile)
if img[i, j] > local_threshold:
det_img[i, j] = foreground
det_img[np.where(data_water_mask == 0)] = background
print(f"自适应阈值方法: 窗口大小={window_size}, 局部百分位数={percentile}%")
det_img[(img > local_thresh) & (data_water_mask > 0)] = foreground
det_img[data_water_mask == 0] = background
n_glint = int(np.sum(det_img == foreground))
print(f"自适应阈值方法 (scipy加速): 窗口大小={window_size}, "
f"局部百分位数={percentile}%, 检测到耀斑像素={n_glint}")
return det_img