Compare commits

...

4 Commits

Author SHA1 Message Date
f05916bc3a 步骤四的采样点和对应的光谱图像展示修正 2026-06-30 10:45:20 +08:00
73cb019a4a refactor: 重构所有面板 update_from_config 为文件系统扫描模式
=== 核心变更 ===
- _step_path_resolver 新增 scan_work_dir_for_input() 统一文件扫描工具
- 基于 _SCAN_TABLE 映射表,按 output_type 自动扫描 work_dir 子目录
- 支持扩展名匹配(.dat/.tif/.bsq/.csv 等)和文件名关键词匹配
- 按 mtime 排序,返回最新匹配文件

=== 各面板重构 ===
- step2/3/4/6: 废弃 main_window.stepX_panel 跨面板读取
  统一改为 scan_work_dir_for_input(work_dir, 'water_mask/glint_mask/deglint_image')
- step6: 移除对 step1/2/3/5 panel 的4处跨面板依赖
- step8: 替换 _resolve_training_csv_from_workdir 为 scan_work_dir_for_input
  优先级:training_spectra_indices -> training_spectra
- step9: 替换 _resolve_latest_wqi_test_csv 为 scan_work_dir_for_input
  废弃 factory.get_panel('step4_sampling') 和 get_panel('step8_ml_train')
- step10: 替换3层回退链为 scan_work_dir_for_input('sampling_points')
- step11: 替换 factory.get_panel('step9')/('step1') 为文件扫描

=== 删除的冗余方法 ===
- step8._resolve_training_csv_from_workdir (~55行)
- step9._resolve_latest_wqi_test_csv (~56行)

=== 数据流原则 ===
1. pipeline context 为第一顺位(执行时内存状态)
2. 文件系统扫描为回退(仅信任硬盘上真实存在的文件)
3. 彻底禁止面板间 UI 控件互相读取
2026-06-30 09:53:23 +08:00
1bdd623fe7 style: Step4 图表中文排版升级,对齐Step12风格
- 添加 matplotlib rcParams 中文字体配置(Microsoft YaHei / SimHei)
- 散点图标题→'采样点空间分布',坐标轴→'经度/X'、'纬度/Y'
- 光谱图标题→'采样点 ID: X 光谱曲线',坐标轴→'波长/特征'、'反射率'
- 悬停提示框中文化:ID + 经度 + 纬度
- 标题字号统一为 fontsize=14 fontweight='bold' pad=12
- 轴标签字号统一为 fontsize=11
2026-06-30 09:45:50 +08:00
48d17ef0ca fix: 全局UX修复与Step4交互可视化重构
=== 自动填入过于激进(幽灵路径级联)===
- 所有面板 update_from_config 移除 os.makedirs(),目录创建留给 pipeline 执行
- 输出路径仅 widget 为空时填入默认值,不覆盖用户已选
- 输入路径从上游读取后添加 os.path.exists() 检查,阻断幽灵路径级联
- panel_factory._replay_live_panel_inputs 广播前校验文件确实存在
- step10 update_from_config 添加 os.path.isfile() 存在性检查
- 清理 step8/9/11 中冗余局部 import os(修复 UnboundLocalError)

=== 输出目录缺失 ===
- step7 新增 output_file FileSelectWidget,默认路径 7_Water_Quality_Indices/
- step9 output_file 从文件模式改为目录模式 (Directories)

=== 空目录自动创建 ===
- step12 _setup_prediction_output_dirs 移除 mkdir() 调用,改为只读日志

=== 过期依赖与缺失 import ===
- panel_registry Step10 依赖 bsq_file→sampling_csv_file(匹配 CSV 模式重构)
- step7 添加缺失的 import pandas as pd(修复 NameError)

=== 导航与 UI 一致性 ===
- water_quality_gui_v2 新增 _select_first_nav_item(),启动时默认选中第一项
- step1 输出卡片对齐 step8 风格
- step8 补充缺失的样式表和统一边距

=== Step4 交互式光谱探针重构 ===
- 左右分栏 QSplitter 布局:左侧控制区 + 右侧 Matplotlib 视图
- 1x2 子图:ax1 散点图 + ax2 光谱曲线
- Hover 悬停 Annotation 显示坐标,Click 点击高亮+绘制光谱
- NavigationToolbar2QT 工具栏(保存/缩放/平移)
- 自动检测坐标列和波段列,完善异常处理
2026-06-30 09:38:27 +08:00
21 changed files with 1202 additions and 472 deletions

View File

@ -183,26 +183,35 @@ class WorkspaceManager:
for file_path in subdir_path.rglob('*'):
if file_path.is_file():
file_name = file_path.name.lower()
file_stem = file_path.stem.lower()
for step_id in step_ids:
if step_id not in discovered_outputs:
discovered_outputs[step_id] = {}
if 'water_mask' in file_name and step_id == 'step1':
if self._is_scientific_mask(file_path):
# 2026-06-30 加强匹配:用更精确的边界匹配替代简单的子串包含
# water_mask 匹配:必须是独立文件名的一部分(如 water_mask_out.dat, water_mask_from_ndwi.dat)
if step_id == 'step1' and self._is_scientific_mask(file_path):
if ('water_mask' in file_stem
and 'glint' not in file_stem):
discovered_outputs[step_id]['water_mask'] = str(file_path)
elif 'glint' in file_name and 'mask' in file_name and step_id == 'step2':
if self._is_scientific_mask(file_path):
# glint_mask 匹配:必须同时含 glint 且文件名主体以 mask 或 area 结尾
elif step_id == 'step2' and self._is_scientific_mask(file_path):
if ('glint' in file_stem and
(file_stem.endswith('mask') or file_stem.endswith('area')
or 'severe_glint' in file_stem)):
discovered_outputs[step_id]['glint_mask'] = str(file_path)
elif 'deglint' in file_name and step_id == 'step3':
elif 'deglint' in file_stem and step_id == 'step3':
discovered_outputs[step_id]['deglint_image'] = str(file_path)
elif 'processed_data' in file_name and step_id == 'step4_sampling':
elif file_name == 'processed_data.csv' and step_id == 'step5_clean':
discovered_outputs[step_id]['processed_data'] = str(file_path)
elif 'training_spectra' in file_name and step_id == 'step5_clean':
elif ('training_spectra' in file_stem and file_path.suffix == '.csv'
and step_id == 'step6_feature'):
discovered_outputs[step_id]['training_spectra'] = str(file_path)
elif 'water_quality_indices' in file_name and step_id == 'step6_feature':
elif ('water_quality_indices' in file_stem and file_path.suffix == '.csv'
and step_id == 'step7_index'):
discovered_outputs[step_id]['water_indices'] = str(file_path)
elif 'sampling_spectra' in file_name and step_id == 'step4_sampling':
elif file_name == 'sampling_spectra.csv' and step_id == 'step4_sampling':
discovered_outputs[step_id]['sampling_points'] = str(file_path)
elif file_name.endswith('.csv') and step_id in ['step9_ml_predict', 'step11_map', 'step12_viz']:
discovered_outputs[step_id]['predictions'] = str(file_path)
@ -255,13 +264,16 @@ class WorkspaceManager:
def prune_config_for_prediction_mode(config: dict) -> dict:
"""Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。
2026-06-30 修复:步骤 ID 从旧的 PIPELINE_STEPS 体系改为 PANEL_REGISTRY 体系,
确保与 get_current_config() 返回的 key 一致,避免 enabled: False 写入无效 key。
被禁用的 step dict 中统一写入 'enabled': False,
这些配置最终传给 PipelineRunner,Runner 会跳过它们。
同时,被跳过的步骤的 required_input_files 在 build_missing_items
中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。
Args:
config: 完整配置字典(来自 get_current_config)
config: 完整配置字典(来自 get_current_config,key 为 PANEL_REGISTRY step_id)
Returns:
裁剪后的 config(深拷贝,原 config 不被修改)
@ -269,12 +281,11 @@ class WorkspaceManager:
cfg = copy.deepcopy(config)
training_steps = [
"step4",
"step5",
"step7",
"step6",
"step8_non_empirical_modeling",
"step9",
"step4_sampling",
"step5_clean",
"step6_feature",
"step7_index",
"step8_ml_train",
]
for step_id in training_steps:
step_cfg = cfg.setdefault(step_id, {})

View File

@ -13,15 +13,24 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
内含:
- 自动识别下游 widget(按 dict 键名查找)
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
2026-06-30 修复:
- 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃
"""
import os
import sip
from src.gui.core.event_bus import global_event_bus
# 存储订阅回调引用,供 unsubscribe_panel_from_dependencies 使用
_subscription_registry = {} # {id(panel): [(event_name, callback), ...]}
def subscribe_panel_to_dependencies(panel, step_id, dependencies):
"""为面板订阅其依赖的上游步骤产出事件。
@ -38,15 +47,42 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
if not dependencies:
return
# 注意:我们使用字典的键名 (_input_field) 作为唯一的下游目标框名查找依据
panel_id = id(panel)
if panel_id not in _subscription_registry:
_subscription_registry[panel_id] = []
for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items():
_make_subscription(panel, dep_step, output_type, _input_field)
callback = _make_callback(panel, dep_step, output_type, _input_field)
global_event_bus.subscribe('OutputUpdated', callback)
_subscription_registry[panel_id].append(('OutputUpdated', callback))
def _make_subscription(panel, dep_step, output_type, target_widget_name):
"""为单个依赖项创建事件订阅。使用工厂函数避免闭包变量延迟绑定。"""
def unsubscribe_panel_from_dependencies(panel):
"""取消面板的所有依赖订阅。应在面板销毁前调用,防止野指针回调。
Args:
panel: 要取消订阅的步骤面板实例
"""
panel_id = id(panel)
subscriptions = _subscription_registry.pop(panel_id, [])
for event_name, callback in subscriptions:
global_event_bus.unsubscribe(event_name, callback)
def _make_callback(panel, dep_step, output_type, target_widget_name):
"""为单个依赖项创建事件回调。返回闭包函数。
使用工厂函数确保每个回调的闭包变量独立绑定。
"""
def callback(data):
# ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过
try:
if sip.isdeleted(panel):
return
except Exception:
return
if data.get('step_id') != dep_step:
return
if data.get('output_type') != output_type:
@ -57,10 +93,14 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name):
return
current = ''
if hasattr(widget, 'get_path'):
current = widget.get_path().strip()
elif hasattr(widget, 'text'):
current = widget.text().strip()
try:
if hasattr(widget, 'get_path'):
current = widget.get_path().strip()
elif hasattr(widget, 'text'):
current = widget.text().strip()
except RuntimeError:
# C++ 对象已被删除
return
if current:
return
@ -73,9 +113,12 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name):
if 'dir' in target_widget_name.lower() and os.path.isfile(path):
path = os.path.dirname(path)
if hasattr(widget, 'set_path'):
widget.set_path(path)
elif hasattr(widget, 'setText'):
widget.setText(path)
try:
if hasattr(widget, 'set_path'):
widget.set_path(path)
elif hasattr(widget, 'setText'):
widget.setText(path)
except RuntimeError:
pass # C++ 对象已被删除
global_event_bus.subscribe('OutputUpdated', callback)
return callback

View File

@ -3,12 +3,17 @@
"""
轻量级事件总线
支持 subscribe(event_name, callback) 和 publish(event_name, data),
用于步骤面板间的去中心化参数传导。
支持 subscribe(event_name, callback)、unsubscribe(event_name, callback)
和 publish(event_name, data),用于步骤面板间的去中心化参数传导。
2026-06-30 修复:
- publish() 中的 except Exception: pass 改为 traceback 日志输出(静默吞异常会导致 bug 无法发现)
- 新增 unsubscribe() 方法,允许面板销毁时清理订阅,防止内存泄漏和野指针回调
"""
import traceback
from collections import defaultdict
from typing import Any, Callable, Dict, List
from typing import Any, Callable, Dict, List, Optional
class EventBus:
@ -16,18 +21,48 @@ class EventBus:
def __init__(self):
self._subscribers: Dict[str, List[Callable]] = defaultdict(list)
# 异常日志回调(可选注入,供 LogManager 使用)
self._error_logger: Optional[Callable[[str], None]] = None
def set_error_logger(self, logger: Callable[[str], None]):
"""注入异常日志回调。若不设置,异常信息输出到 stderr。"""
self._error_logger = logger
def subscribe(self, event_name: str, callback: Callable[[dict], None]):
"""订阅事件。callback 接收一个 dict 作为事件数据。"""
self._subscribers[event_name].append(callback)
if callback not in self._subscribers[event_name]:
self._subscribers[event_name].append(callback)
def unsubscribe(self, event_name: str, callback: Callable[[dict], None]):
"""取消订阅。面板销毁或不再需要接收事件时调用,防止野指针回调。"""
subs = self._subscribers.get(event_name)
if subs and callback in subs:
subs.remove(callback)
# 清理空列表,避免字典膨胀
if not subs:
del self._subscribers[event_name]
def publish(self, event_name: str, data: Dict[str, Any]):
"""发布事件,通知所有订阅者。"""
for callback in self._subscribers.get(event_name, []):
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
"""
for callback in list(self._subscribers.get(event_name, [])):
try:
callback(data)
except Exception:
pass
err_msg = (
f"[EventBus] 事件 '{event_name}' 的订阅者 {callback.__name__!r} 抛出异常:\n"
+ traceback.format_exc()
)
if self._error_logger:
try:
self._error_logger(err_msg)
except Exception:
pass
else:
import sys
print(err_msg, file=sys.stderr, flush=True)
# 全局单例

View File

@ -15,7 +15,10 @@
- 占位页:未加载的 tab 显示空白 QWidget,加载后原地替换为 QScrollArea(panel)
"""
from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea
import os
import sys
from PyQt5.QtWidgets import QWidget, QTabWidget, QScrollArea, QSpinBox, QDoubleSpinBox, QComboBox
from PyQt5.QtCore import Qt
from src.gui.core.panel_registry import PANEL_REGISTRY
@ -23,6 +26,31 @@ from src.gui.core.dependency_subscriber import subscribe_panel_to_dependencies
from src.gui.core.event_bus import global_event_bus
def _get_resource_path(relative_path: str) -> str:
"""资源路径解析(内联版,避免从已废弃的 water_quality_gui.py 导入)。
2026-06-30:从 water_quality_gui.py 迁移到此处,打破对旧文件的依赖。
注意:panel_factory.py 在 src/gui/core/ 下,比 water_quality_gui.py 深一层,
因此需要 4 次 dirname 才能到达项目根目录。
"""
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.abspath(
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), relative_path)
)
def _disable_wheel_for_widget(widget):
"""禁用 widget 及其子控件的滚轮事件(防止误触改值)。
递归处理 QSpinBox / QDoubleSpinBox / QComboBox。
2026-06-30:提取为独立函数,方便懒加载面板创建后调用。
"""
for child in widget.findChildren((QSpinBox, QDoubleSpinBox, QComboBox)):
child.setFocusPolicy(Qt.StrongFocus)
child.wheelEvent = lambda e, w=child: None
class PanelFactory:
"""面板注册与装载工厂。
@ -64,12 +92,11 @@ class PanelFactory:
同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。
Args:
icons_dir: 图标目录名(相对于项目根),用于 get_resource_path
icons_dir: 图标目录名(相对于项目根),用于 _get_resource_path
Returns:
QTabWidget: 已添加所有占位 tab 的标签页控件
"""
from src.gui.water_quality_gui import get_resource_path
from PyQt5.QtGui import QIcon
self._tab_widget = QTabWidget()
@ -85,7 +112,7 @@ class PanelFactory:
placeholder = QWidget()
self._placeholders[idx] = placeholder
icon_path = get_resource_path(f"{icons_dir}/{icon_name}")
icon_path = _get_resource_path(f"{icons_dir}/{icon_name}")
self._tab_widget.addTab(placeholder, QIcon(icon_path), title)
# 连接切换信号 → 懒加载
@ -182,6 +209,9 @@ class PanelFactory:
self._panels[step_id] = panel
self._loaded.add(tab_index)
# ★ 禁用新面板中所有 SpinBox/ComboBox 的滚轮事件(防误触改值)
_disable_wheel_for_widget(panel)
# 事件总线自动接线
if deps:
subscribe_panel_to_dependencies(panel, step_id, deps)
@ -207,8 +237,12 @@ class PanelFactory:
try:
work_dir = self._get_current_work_dir()
panel.update_from_config(work_dir=work_dir, pipeline=None)
except Exception:
pass
except Exception as e:
# 2026-06-30:不再静默吞异常
global_event_bus.publish('LogMessage', {
'message': f'[警告] 面板 update_from_config 失败: {e}',
'level': 'warning',
})
# 2. 回放 WorkspaceManager 中已累积的 step_outputs
ws_manager = self._get_workspace_manager()
@ -231,8 +265,10 @@ class PanelFactory:
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
2026-06-30 修复:仅当路径对应的文件/目录确实存在时才广播 OutputUpdated,
防止面板中幽灵占位路径级联扩散到下游。
"""
from src.gui.core.event_bus import global_event_bus
for entry in self._registry:
deps = entry.get('dependencies')
if not deps: continue
@ -244,19 +280,22 @@ class PanelFactory:
widget = getattr(src_panel, source_attr, None)
if widget is None: continue
path = ""
if hasattr(widget, 'get_path'):
path = widget.get_path().strip()
elif hasattr(widget, 'text'):
path = widget.text().strip()
if not path: continue
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
import os
absolute_path = os.path.abspath(path).replace('\\', '/')
# 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联
if not os.path.exists(absolute_path):
continue
global_event_bus.publish('OutputUpdated', {
'step_id': dep_step,
'output_type': output_type,

View File

@ -187,11 +187,10 @@ PANEL_REGISTRY = [
'display_name': '10. 水色指数反演',
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
# 第三元素 source_attr = 上游源控件真实属性名
# step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget,
# 故删除 water_mask 依赖,避免悬挂回调
# step10 panel 使用 CSV 散点模式,输入为 Step4 的采样光谱 CSV
'dependencies': {
# 目标框: self.bsq_file ← 上游 step3.output_file
'bsq_file': ('step3', 'deglint_image', 'output_file'),
# 目标框: self.sampling_csv_file ← 上游 step4_sampling.output_file
'sampling_csv_file': ('step4_sampling', 'sampling_points', 'output_file'),
},
'constructor_kwargs': None,
},

View File

@ -53,19 +53,21 @@ class PreflightDialog(QDialog):
"""
# step_id → (step_name, panel_tab_index)
# 2026-06-30 修正:step_id 改为 PANEL_REGISTRY 格式,与 build_missing_items 输出一致
STEP_TAB_MAP = {
"step1": ("水域掩膜", 0),
"step2": ("耀斑检测", 1),
"step3": ("耀斑去除", 2),
"step4": ("数据清洗", 3),
"step5": ("特征构建", 4),
"step7": ("水质指数", 5),
"step8_non_empirical_modeling": ("回归建模", 7),
"step9": ("水色指数反演", 8),
"step10": ("采样点布设", 10),
"step11_ml": ("监督预测", 11),
"step11": ("回归预测", 12),
"step14": ("专题图生成", 13),
"step1": ("水域掩膜", 0),
"step2": ("耀斑检测", 1),
"step3": ("耀斑去除", 2),
"step4_sampling": ("采样点布设", 3),
"step5_clean": ("数据清洗", 4),
"step6_feature": ("光谱特征提取", 5),
"step7_index": ("水质指数计算", 6),
"step8_ml_train": ("机器学习建模", 7),
"step9_ml_predict": ("机器学习预测", 8),
"step10_watercolor": ("水色指数反演", 9),
"step11_map": ("专题图生成", 10),
"step12_viz": ("可视化展示", 11),
"step13_report": ("报告生成", 12),
}
def __init__(self, missing_items: List[MissingItem], parent=None):
@ -302,6 +304,12 @@ class PreflightDialog(QDialog):
def build_missing_items(config: dict) -> List[MissingItem]:
"""DAG-aware 预检:从 config 构建缺失项列表。
2026-06-30 修复(步骤 ID 错配):
config 的 key 是 PANEL_REGISTRY 的 step_id(如 step4_sampling),
而 PIPELINE_STEPS 使用另一套 step_id(如 step4)。
现通过 _PIPELINE_TO_CONFIG_KEY 映射表统一翻译,
避免 preflight 永远误报缺失项。
拓扑预判逻辑:
1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表,
构建「动态产物集合」dynamically_produced_keys。
@ -320,7 +328,29 @@ class PreflightDialog(QDialog):
"""
items: List[MissingItem] = []
step1_cfg = config.get('step1', {})
# ── ★ PIPELINE_STEPS step_id → PANEL_REGISTRY step_id(config 的 key)──
_PIPELINE_TO_CONFIG_KEY: Dict[str, str] = {
'step1': 'step1',
'step2': 'step2',
'step3': 'step3',
'step4': 'step5_clean',
'step5': 'step6_feature',
'step7': 'step7_index',
'step8': 'step8_ml_train',
'step8_non_empirical_modeling': 'step8_ml_train',
'step9': 'step10_watercolor',
'step10': 'step4_sampling',
'step11_ml': 'step9_ml_predict',
'step11': 'step9_ml_predict',
'step14': 'step11_map',
}
def _cfg(step_id: str) -> dict:
"""用映射表解析 config key,兼容新旧两种 step_id 体系。"""
config_key = _PIPELINE_TO_CONFIG_KEY.get(step_id, step_id)
return config.get(config_key, {})
step1_cfg = _cfg('step1')
step1_enabled = step1_cfg.get('enabled', False)
# ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
@ -328,7 +358,7 @@ class PreflightDialog(QDialog):
enabled_step_ids: Set[str] = set()
for step_spec in PIPELINE_STEPS:
step_cfg = config.get(step_spec.step_id, {})
step_cfg = _cfg(step_spec.step_id)
if not step_cfg.get('enabled', True):
continue
enabled_step_ids.add(step_spec.step_id)
@ -349,20 +379,20 @@ class PreflightDialog(QDialog):
panel_tab_index=0, is_critical=True
))
# ── step4 csv_path(纯外部输入,必须手动提供)───────────────
step4_cfg = config.get('step4', {})
# ── step5_clean csv_path(纯外部输入,必须手动提供)───────────
step4_cfg = _cfg('step4') # 映射到 step5_clean
step4_enabled = step4_cfg.get('enabled', True)
if step4_enabled:
csv_path = step4_cfg.get('csv_path')
if not csv_path:
items.append(MissingItem(
step_id="step4", step_name="数据清洗",
step_id="step5_clean", step_name="数据清洗",
reason="请在「数据清洗」中填写「实测水质数据 CSV」",
panel_tab_index=3
))
elif not os.path.isfile(csv_path):
items.append(MissingItem(
step_id="step4", step_name="数据清洗",
step_id="step5_clean", step_name="数据清洗",
reason=f"实测水质数据文件不存在:{csv_path}",
panel_tab_index=3
))
@ -374,42 +404,44 @@ class PreflightDialog(QDialog):
# ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
_TAB_INDEX_MAP: Dict[str, int] = {
"step1": 0, "step2": 1, "step3": 2, "step4": 3,
"step5": 4, "step8": 5, "step7": 6,
"step8_non_empirical_modeling": 7, "step9": 8,
"step10": 9, "step11_ml": 10, "step11": 11,
"step12": 12, "step14": 13,
"step1": 0, "step2": 1, "step3": 2,
"step4_sampling": 3, "step5_clean": 4, "step6_feature": 5,
"step7_index": 6, "step8_ml_train": 7,
"step9_ml_predict": 8, "step10_watercolor": 9,
"step11_map": 10, "step12_viz": 11, "step13_report": 12,
}
_STEP_NAME_MAP: Dict[str, str] = {
"step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除",
"step4": "数据清洗", "step5": "特征构建", "step8": "水质指数",
"step7": "监督建模", "step8_non_empirical_modeling": "回归建模",
"step9": "自定义回归建模", "step10": "采样点布设",
"step11_ml": "监督预测", "step11": "回归预测",
"step12": "自定义回归预测", "step14": "专题图生成",
"step4_sampling": "采样点布设", "step5_clean": "数据清洗",
"step6_feature": "光谱特征提取", "step7_index": "水质指数计算",
"step8_ml_train": "机器学习建模", "step9_ml_predict": "机器学习预测",
"step10_watercolor": "水色指数反演", "step11_map": "专题图生成",
"step12_viz": "可视化展示", "step13_report": "报告生成",
}
for step_spec in PIPELINE_STEPS:
if step_spec.step_id not in enabled_step_ids:
continue
step_cfg = config.get(step_spec.step_id, {})
tab_idx = _TAB_INDEX_MAP.get(step_spec.step_id, 0)
step_name = _STEP_NAME_MAP.get(step_spec.step_id, step_spec.step_id)
step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id
# ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY step_id
panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(step_spec.step_id, step_spec.step_id)
tab_idx = _TAB_INDEX_MAP.get(panel_step_id, 0)
step_name = _STEP_NAME_MAP.get(panel_step_id, panel_step_id)
for req_key in step_spec.required_input_files:
# ★★★ 高优先级硬编码白名单 ★★★
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
if req_key in ('boundary_path', 'boundary_shp_path'):
step1_cfg = config.get('step1', {})
if step1_cfg.get('img_path') or step1_cfg.get('enabled', True):
_step1_cfg = _cfg('step1')
if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True):
continue # 直接跳过,不判定为缺失
if req_key in PURE_EXTERNAL_INPUT_KEYS:
continue
if req_key == 'formula_csv_path':
continue # ★ 底层完全可选,赦免
if req_key == 'boundary_path' and step_spec.step_id == 'step5':
if req_key == 'boundary_path' and panel_step_id == 'step6_feature':
continue # ★ step1 执行则 panel/底层自动推导,赦免
if req_key == 'boundary_shp_path' and step_spec.step_id == 'step14':
if req_key == 'boundary_shp_path' and panel_step_id == 'step11_map':
continue # ★ step1 执行则 panel 自动回填,赦免
cfg_val = step_cfg.get(req_key)
if cfg_val and os.path.isfile(cfg_val):
@ -419,11 +451,11 @@ class PreflightDialog(QDialog):
if req_key in dynamically_produced_keys:
continue # ★ 前置步骤会生成,拓扑预判通过
items.append(MissingItem(
step_id=step_spec.step_id,
step_id=panel_step_id,
step_name=step_name,
reason=f"缺少必需文件/目录 [{req_key}]",
panel_tab_index=tab_idx,
is_critical=(step_spec.step_id == "step1" and req_key == "img_path"),
is_critical=(panel_step_id == "step1" and req_key == "img_path"),
))
return items

View File

@ -188,12 +188,20 @@ except Exception as e:
# =============================================================================
class WorkerThread(QThread):
"""后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。"""
"""后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。
2026-06-30 修复:
- stop() 不再直接调用 terminate(),改为先请求取消 + 等待线程自然结束 + 超时兜底
- total_steps 从 config 动态计算,不再硬编码为 9
"""
progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
finished = pyqtSignal(bool, str) # 完成信号 (success, message)
# 等待线程自然退出的最大秒数(仅在 stop() 中用到)
_GRACEFUL_STOP_TIMEOUT = 5.0
def __init__(self, work_dir: str, config, mode='full', step_name=None, skip_list=None):
super().__init__()
self.work_dir = str(work_dir)
@ -205,7 +213,10 @@ class WorkerThread(QThread):
self.is_running = True
self.current_step = None
self.step_count = 0
self.total_steps = 9
# 动态计算总步骤数(过滤 skip_list)
self.total_steps = len([
k for k in self.config.keys() if k not in self.skip_list
]) if self.mode == 'full' else 1
def pipeline_callback(self, step_name, status, message=""):
"""Pipeline回调函数,用于接收步骤状态"""
@ -506,6 +517,35 @@ class WorkerThread(QThread):
)
def stop(self):
"""停止执行"""
"""安全停止执行。
优先通过 PipelineScheduler 的取消机制自然终止:
1. 设置 is_running = False(run() 内各步骤间会检查)
2. 调用 scheduler.cancel() 请求中断当前步骤
3. 等待最多 5 秒让线程自然退出
4. 超时后 fallback 到 terminate()(最后手段)
2026-06-30 修复:不再直接调用 terminate(),
避免互斥锁死锁、文件损坏和资源泄漏。
"""
self.is_running = False
self.terminate()
# 尝试通过 scheduler 的取消机制中断正在执行的步骤
if self.pipeline is not None:
try:
cancel_fn = getattr(self.pipeline, 'cancel', None)
if callable(cancel_fn):
cancel_fn()
elif hasattr(self.pipeline, 'context'):
ctx = self.pipeline.context
if hasattr(ctx, 'cancel'):
ctx.cancel()
except Exception:
pass
# 等待线程自然退出
if self.isRunning():
if not self.wait(int(self._GRACEFUL_STOP_TIMEOUT * 1000)):
# 超时:线程卡死在不可中断的 I/O 或 C 扩展调用中,此时才 fallback
self.terminate()
self.wait(2000) # 等待 terminate 生效

View File

@ -183,9 +183,91 @@ def resolve_subdir(work_dir, subdir_key: str) -> str:
return wd
# ═══════════════════════════════════════════════════════════════
# 文件系统扫描表 —— 定义每个上游产出类型对应的搜索策略
# ═══════════════════════════════════════════════════════════════
_SCAN_TABLE = {
# output_type → (subdir_key, extensions, file_name_matcher)
'water_mask': ('water_mask', ['.dat', '.tif', '.tiff'], None),
'glint_mask': ('glint_detection', ['.dat'], 'severe_glint'),
'deglint_image': ('deglint', ['.bsq', '.dat', '.tif', '.tiff'], None),
'sampling_points': ('sampling', ['.csv'], 'sampling_spectra'),
'processed_data': ('data_cleaning', ['.csv'], 'processed_data'),
'training_spectra': ('spectral_feature',['.csv'], 'training_spectra'),
'training_spectra_indices': ('indices', ['.csv'], 'training_spectra_indices'),
'ml_models_dir': ('supervised_models', None, None), # 目录
'ml_predictions_dir': ('ml_prediction', None, None), # 目录
'water_index_csv_dir': ('watercolor', None, None), # 目录
'visualization_dir': ('visualization', None, None), # 目录
'reference_img': ('water_mask', ['.bsq', '.dat', '.tif', '.tiff'], None),
}
def scan_work_dir_for_input(work_dir: str, output_type: str):
"""基于文件系统扫描查找上游步骤的产出文件。
这是 update_from_config 重构的核心工具函数。
仅信任硬盘上真实存在的文件,彻底消除面板间 UI 控件互相读取。
Args:
work_dir: 工作目录路径
output_type: 产出类型,如 'water_mask', 'deglint_image', 'sampling_points'
Returns:
找到的文件/目录绝对路径字符串,未找到返回 None
"""
if not work_dir:
return None
wd = Path(work_dir)
entry = _SCAN_TABLE.get(output_type)
if entry is None:
return None
subdir_key, extensions, matcher = entry
target_dir = wd / _FALLBACK_DIR_TABLE.get(subdir_key, subdir_key)
if not target_dir.is_dir():
return None
# 目录类型(extensions 为 None)→ 只要目录存在就返回
if extensions is None:
return str(target_dir).replace('\\', '/')
# 扫描目录中的文件
candidates = []
for ext in extensions:
for f in target_dir.glob(f'*{ext}'):
if not f.is_file():
continue
candidates.append(f)
# 也搜 ext 的大写变体
for f in target_dir.glob(f'*{ext.upper()}'):
if not f.is_file():
continue
candidates.append(f)
if not candidates:
return None
# 按 matcher 优先级 + mtime 排序
if matcher:
matched = [f for f in candidates if matcher.lower() in f.stem.lower()]
if matched:
matched.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return str(matched[0]).replace('\\', '/')
# matcher 未命中时,仍返回最新文件(宽松匹配)
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return str(candidates[0]).replace('\\', '/')
# 无 matcher → 返回最新的
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return str(candidates[0]).replace('\\', '/')
__all__ = [
'STEP_DATA_SOURCE',
'resolve_step_widget',
'get_step_output_path',
'resolve_subdir',
'scan_work_dir_for_input',
]

View File

@ -24,7 +24,7 @@ from typing import Dict, List, Optional
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from _step_path_resolver import resolve_subdir, scan_work_dir_for_input
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout,
@ -554,9 +554,7 @@ class Step10WatercolorPanel(QWidget):
else:
self.work_dir = None
main_window = self.window()
# 1. 优先从 pipeline.step_outputs 取 Step 4 的采样点 CSV 路径
# 1. 采样点 CSV:优先 pipeline.step_outputs,回退文件系统扫描
sampling_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step4_out = pipeline.step_outputs.get('step4_sampling', {})
@ -565,42 +563,17 @@ class Step10WatercolorPanel(QWidget):
or step4_out.get('output_path')
or step4_out.get('output_file')
)
# 2. 回退:直接读 step4_sampling panel 的 output_file widget
if not sampling_path and main_window:
step4_widget = getattr(main_window, 'step4_sampling', None)
if step4_widget and hasattr(step4_widget, 'output_file'):
sampling_path = step4_widget.output_file.get_path()
else:
# 通过 _panel_factory 懒加载查找
factory = getattr(main_window, '_panel_factory', None)
if factory:
step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'):
sampling_path = step4_panel.output_file.get_path()
# 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv
if not sampling_path and self.work_dir:
candidate = resolve_subdir(self.work_dir, 'sampling_csv_path')
if os.path.isfile(candidate):
sampling_path = candidate
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))
# 填入 UI
if sampling_path:
if not os.path.isabs(sampling_path):
sampling_path = os.path.join(
self.work_dir or '', sampling_path
).replace('\\', '/')
self.sampling_csv_file.set_path(sampling_path)
# 自动填入输出目录(默认 work_dir/10_WaterIndex_CSV/)
if self.work_dir:
# 2. 自动填入输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir and not self.output_dir.get_path():
out_dir = os.path.join(
self.work_dir, '10_WaterIndex_CSV'
).replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
if not self.output_dir.get_path():
self.output_dir.set_path(out_dir)
self.output_dir.set_path(out_dir)
def _on_run_single_clicked(self):
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""

View File

@ -14,7 +14,7 @@ from typing import List, Optional
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from src.gui.panels._step_path_resolver import resolve_subdir, get_step_output_path
from src.gui.panels._step_path_resolver import resolve_subdir, get_step_output_path, scan_work_dir_for_input
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import (
@ -477,40 +477,24 @@ class Step11MapPanel(QWidget):
def update_from_config(self, work_dir=None, pipeline=None):
if work_dir:
self.work_dir = work_dir
main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None
if not factory: return
# 1. 安全抓取 Step 9 的预测 CSV 目录
step9_panel = factory.get_panel('step9_ml_predict')
if step9_panel and hasattr(step9_panel, 'output_file'):
path = step9_panel.output_file.get_path()
if path:
self.prediction_csv_dir_edit.setText(path)
# 1. 预测 CSV 目录:文件系统扫描
if self.work_dir:
pred_dir = scan_work_dir_for_input(self.work_dir, 'ml_predictions_dir')
if pred_dir and os.path.isdir(str(pred_dir)):
self.prediction_csv_dir_edit.setText(str(pred_dir))
self.batch_mode_combo.setCurrentIndex(1)
# 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp)
step1_panel = factory.get_panel('step1')
if step1_panel:
use_ndwi = step1_panel.use_ndwi_radio.isChecked()
# 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜
if use_ndwi and hasattr(step1_panel, 'output_file'):
path = step1_panel.output_file.get_path()
elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
path = step1_panel.mask_file.get_path()
else:
path = ""
# 2. 边界文件(水体掩膜):文件系统扫描
if self.work_dir:
boundary_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
existing = self.boundary_file.get_path()
if path and not existing:
self.boundary_file.set_path(path)
if boundary_path and not existing and os.path.exists(str(boundary_path)):
self.boundary_file.set_path(str(boundary_path))
# 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径)
if hasattr(self, 'work_dir') and self.work_dir:
import os
# 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path():
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
self.output_dir.set_path(out_dir)
def browse_output_dir(self):

View File

@ -1656,21 +1656,22 @@ class Step12VizPanel(QWidget):
self.image_viewer.load_image(str(image_files[0]))
def _setup_prediction_output_dirs(self, work_path: Path):
"""设置三个预测步骤的默认输出目录"""
"""收集预测输出目录路径信息(不创建目录,仅用于日志/调试)。
2026-06-30 修复:移除 mkdir 调用,不再在未运行 pipeline 时创建空目录。
目录创建统一留给各 pipeline 步骤在实际执行时处理。
"""
try:
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
reg_dir = base_prediction_dir / "Regression_Model_Prediction"
custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_Prediction"
ml_dir.mkdir(parents=True, exist_ok=True)
reg_dir.mkdir(parents=True, exist_ok=True)
custom_dir.mkdir(parents=True, exist_ok=True)
# 旧的 self.step11_ml_panel/step11_panel/step12_panel 在 Step12VizPanel 上不存在,是死代码。
# 三个目录的真实默认值在用户首次浏览 / 自动填充时由各 panel 自己的 _get_default_work_dir 路径产出。
# 这里仅做目录创建 + 提示输出,便于用户在工作目录树中能看到预测输出位置。
print(f"预测输出目录已创建:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
# 仅输出信息,不创建目录
existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()]
if existing:
print(f"预测输出目录已存在: {existing}")
except Exception as e:
print(f"设置预测输出目录失败: {e}")
print(f"读取预测输出目录信息失败: {e}")
def on_tree_item_clicked(self, item, column):
"""目录树项点击事件"""

View File

@ -114,6 +114,9 @@ class Step1Panel(QWidget):
config_group.setLayout(config_layout)
main_layout.addWidget(config_group)
# ==========================================
# 卡片 3:输出与执行(与 Step8 对齐的现代化卡片风格)
# ==========================================
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -124,11 +127,10 @@ 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)
# 完美对齐的底部按钮栏(已移除多余的启用步骤选项)
# 完美对齐的底部按钮栏
action_layout = QHBoxLayout()
action_layout.addStretch()
@ -140,6 +142,8 @@ class Step1Panel(QWidget):
output_layout.addLayout(action_layout)
output_group.setLayout(output_layout)
# 将打包好的输出卡片添加到主 layout 中
main_layout.addWidget(output_group)
self.use_existing_radio.toggled.connect(self.update_ui_state)
@ -164,10 +168,12 @@ class Step1Panel(QWidget):
self._auto_fill_output_path()
def _auto_fill_output_path(self):
"""仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。"""
if not hasattr(self, 'work_dir') or not self.work_dir:
return
if self.output_file.get_path():
return # 用户已手动指定,不覆盖
output_dir = resolve_subdir(self.work_dir, 'water_mask')
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)

View File

@ -11,7 +11,7 @@ from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from _step_path_resolver import resolve_subdir, scan_work_dir_for_input
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
@ -178,27 +178,31 @@ class Step2Panel(QWidget):
else:
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
elif pipeline and hasattr(pipeline, 'step_outputs'):
step1_out = pipeline.step_outputs.get('step1', {})
mask_path = step1_out.get('water_mask') or step1_out.get('output_path')
main_window = self.window()
if not mask_path and hasattr(main_window, 'step1_panel'):
if main_window.step1_panel.use_ndwi_radio.isChecked():
mask_path = main_window.step1_panel.output_file.get_path()
else:
mask_path = main_window.step1_panel.mask_file.get_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')
if mask_path:
# 填入 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('\\', '/')
self.water_mask_file.set_path(mask_path)
# ── 输出路径 ──
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'glint_detection')
os.makedirs(output_dir, exist_ok=True)
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
self.output_file.set_path(default_output_path)
if not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'glint_detection')
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
self.output_file.set_path("")

View File

@ -11,7 +11,7 @@ from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from _step_path_resolver import resolve_subdir, scan_work_dir_for_input
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
@ -307,23 +307,28 @@ class Step3Panel(QWidget):
else:
self.work_dir = None
main_window = self.window()
if hasattr(main_window, 'step1_panel'):
if main_window.step1_panel.use_ndwi_radio.isChecked():
mask_path = main_window.step1_panel.output_file.get_path()
else:
mask_path = main_window.step1_panel.mask_file.get_path()
# ── 水域掩膜输入 ──
# 优先:pipeline context
mask_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step1_out = pipeline.step_outputs.get('step1', {})
mask_path = step1_out.get('water_mask') or step1_out.get('output_path')
if mask_path:
if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
self.water_mask_file.set_path(mask_path)
# 回退:文件系统扫描 1_water_mask/
if not mask_path or not os.path.exists(mask_path):
mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
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('\\', '/')
self.water_mask_file.set_path(mask_path)
# ── 输出路径 ──
if self.work_dir:
output_dir = resolve_subdir(self.work_dir, 'deglint')
os.makedirs(output_dir, exist_ok=True)
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
self.output_file.set_path(default_output_path)
if not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'deglint')
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
self.output_file.set_path("")

View File

@ -1,24 +1,46 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Step4 面板 - 采样点布设 (已移除“启用此步骤”)
Step4 面板 - 采样点布设(内嵌交互式光谱探针视图)
2026-06-30 重构:
- 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图
- 1×2 子图:ax1 散点图 + ax2 光谱曲线
- Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线
- 完美复刻 Step12 风格的自定义独立顶部工具栏,隐藏原生丑陋图标
- 修复 Matplotlib 3.3+ 版本兼容性导致的 _active 属性报错
"""
import os
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# ── 全局中文字体配置(与 Step12 统一)──
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'sans-serif']
plt.rcParams['axes.unicode_minus'] = False
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import resolve_subdir
from _step_path_resolver import resolve_subdir, scan_work_dir_for_input
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QPushButton, QSpinBox, QMessageBox, QLabel, QFrame
QPushButton, QSpinBox, QCheckBox, QMessageBox, QLabel, QFrame,
QSplitter, QSizePolicy,
)
from matplotlib.backends.backend_qt5agg import (
FigureCanvasQTAgg as FigureCanvas,
NavigationToolbar2QT as NavigationToolbar,
)
from matplotlib.figure import Figure
from src.gui.components.custom_widgets import FileSelectWidget
from src.gui.dialogs import SamplingViewerDialog
from src.gui.styles import ModernStylesheet
@ -27,15 +49,44 @@ from src.gui.styles import ModernStylesheet
class Step4SamplingPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# ── 全局中文字体强制设置(必须在创建 Figure 之前,与模块级 rcParams 形成双重保障)──
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'sans-serif']
plt.rcParams['axes.unicode_minus'] = False
# 交互状态
self._df = None
self._x_col = None
self._y_col = None
self._band_cols = []
self._scatter = None
self._highlight_idx = None
self._annot = None
self._cid_hover = None
self._cid_click = None
self._last_render_path = None
self.init_ui()
# ═══════════════════════════════════════════════════════════════
# UI 构建
# ═══════════════════════════════════════════════════════════════
def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
main_layout = QVBoxLayout()
main_layout.setContentsMargins(24, 24, 24, 24)
main_layout.setSpacing(20)
# ── 顶层:水平分栏 (QSplitter) ──
splitter = QSplitter(Qt.Horizontal)
splitter.setChildrenCollapsible(False)
# ═══════════════════════════════════════════════
# 左侧:控制区(原封保留原有三张卡片)
# ═══════════════════════════════════════════════
left_widget = QWidget()
left_layout = QVBoxLayout()
left_layout.setContentsMargins(24, 24, 12, 24)
left_layout.setSpacing(20)
# --- 卡片 1:输入数据 ---
input_group = QGroupBox("📁 输入数据")
input_layout = QVBoxLayout()
input_layout.setSpacing(16)
@ -52,8 +103,9 @@ class Step4SamplingPanel(QWidget):
input_layout.addWidget(self.deglint_img_file)
input_layout.addWidget(self.water_mask_file)
input_group.setLayout(input_layout)
main_layout.addWidget(input_group)
left_layout.addWidget(input_group)
# --- 卡片 2:采样参数 ---
params_group = QGroupBox("⚙️ 采样参数")
params_layout = QFormLayout()
params_layout.setSpacing(16)
@ -80,14 +132,14 @@ class Step4SamplingPanel(QWidget):
self.chunk_size.setMinimumWidth(120)
params_layout.addRow("内存处理块大小:", self.chunk_size)
from PyQt5.QtWidgets import QCheckBox
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
self.use_adaptive_sampling.setChecked(True)
params_layout.addRow("智能模式:", self.use_adaptive_sampling)
params_group.setLayout(params_layout)
main_layout.addWidget(params_group)
left_layout.addWidget(params_group)
# --- 卡片 3:输出与执行 ---
output_group = QGroupBox("🚀 输出与执行")
output_layout = QVBoxLayout()
output_layout.setSpacing(16)
@ -104,33 +156,201 @@ class Step4SamplingPanel(QWidget):
action_layout = QHBoxLayout()
action_layout.addStretch()
self.preview_btn = QPushButton("交互式预览采样点")
self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self.preview_btn.setEnabled(False)
self.preview_btn.setMinimumWidth(160)
self.preview_btn.clicked.connect(self._open_sampling_viewer)
self.refresh_btn = QPushButton("🔄 刷新视图")
self.refresh_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
self.refresh_btn.setEnabled(False)
self.refresh_btn.setMinimumWidth(140)
self.refresh_btn.setToolTip("重新加载 CSV 并渲染采样点散点图")
self.refresh_btn.clicked.connect(self._on_refresh_clicked)
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)
action_layout.addWidget(self.preview_btn)
action_layout.addWidget(self.refresh_btn)
action_layout.addWidget(self.run_btn)
output_layout.addLayout(action_layout)
output_group.setLayout(output_layout)
main_layout.addWidget(output_group)
left_layout.addWidget(output_group)
main_layout.addStretch()
self.setLayout(main_layout)
left_layout.addStretch()
left_widget.setLayout(left_layout)
left_widget.setMinimumWidth(360)
# ═══════════════════════════════════════════════
# 右侧:可视化区(深度复刻 Step12 样式)
# ═══════════════════════════════════════════════
right_widget = QWidget()
right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(0)
viz_group = QGroupBox("📊 采样点交互式探索")
viz_layout = QVBoxLayout()
viz_layout.setContentsMargins(8, 20, 8, 8)
viz_layout.setSpacing(8)
# ── 学习 Step 12:重构标准的 PyQt 工具栏 ──
custom_toolbar = QHBoxLayout()
self.fit_btn = QPushButton("⬜ 适应窗口")
self.fit_btn.setToolTip("恢复默认全景视图")
custom_toolbar.addWidget(self.fit_btn)
separator = QFrame()
separator.setFrameShape(QFrame.VLine)
separator.setFrameShadow(QFrame.Sunken)
custom_toolbar.addWidget(separator)
# 专门为交互工具按钮定制的悬停与选中(蓝色)高亮样式
tool_btn_style = """
QPushButton { padding: 5px 10px; border-radius: 4px; border: 1px solid transparent; background: transparent; color: #475569; font-weight: bold; }
QPushButton:hover { background-color: #F1F5F9; border: 1px solid #CBD5E1; color: #0F172A; }
QPushButton:checked { background-color: #E0F2FE; color: #0369A1; border: 1px solid #BAE6FD; }
"""
self.probe_btn = QPushButton("👆 点选探针")
self.probe_btn.setToolTip("点击散点查看光谱曲线(默认模式)")
self.probe_btn.setCheckable(True)
self.probe_btn.setChecked(True) # 默认激活
self.probe_btn.setStyleSheet(tool_btn_style)
custom_toolbar.addWidget(self.probe_btn)
self.pan_btn = QPushButton("✋ 拖拽漫游")
self.pan_btn.setToolTip("按住左键拖拽平移图表")
self.pan_btn.setCheckable(True)
self.pan_btn.setStyleSheet(tool_btn_style)
custom_toolbar.addWidget(self.pan_btn)
self.zoom_rect_btn = QPushButton("🔍 框选放大")
self.zoom_rect_btn.setToolTip("框选局部区域进行放大")
self.zoom_rect_btn.setCheckable(True)
self.zoom_rect_btn.setStyleSheet(tool_btn_style)
custom_toolbar.addWidget(self.zoom_rect_btn)
custom_toolbar.addStretch()
self.save_btn = QPushButton("💾 保存图像")
self.save_btn.setToolTip("保存当前图表到本地")
custom_toolbar.addWidget(self.save_btn)
viz_layout.addLayout(custom_toolbar)
# ── 画布渲染区 ──
self._fig = Figure(figsize=(10, 7), facecolor='white')
self._ax_scatter = self._fig.add_subplot(121)
self._ax_spectrum = self._fig.add_subplot(122)
self._canvas = FigureCanvas(self._fig)
self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._canvas.setStyleSheet("background-color: white;")
# ── 彻底隐藏原生引擎并绑定到前台按钮 ──
self._hidden_toolbar = NavigationToolbar(self._canvas, self)
self._hidden_toolbar.hide()
self.fit_btn.clicked.connect(self._hidden_toolbar.home)
self.probe_btn.clicked.connect(self._toggle_probe)
self.pan_btn.clicked.connect(self._toggle_pan)
self.zoom_rect_btn.clicked.connect(self._toggle_zoom)
self.save_btn.clicked.connect(self._hidden_toolbar.save_figure)
# 设置子图初始状态
for ax, title, xlabel, ylabel in [
(self._ax_scatter, "采样点空间分布", "经度 / X", "纬度 / Y"),
(self._ax_spectrum, "光谱曲线", "波长 / 特征", "反射率"),
]:
ax.set_title(title, fontsize=14, fontweight='bold', pad=12)
ax.set_xlabel(xlabel, fontsize=11)
ax.set_ylabel(ylabel, fontsize=11)
ax.set_facecolor('white')
ax.tick_params(labelsize=9)
self._ax_scatter.text(0.5, 0.5, "等待采样数据生成...\n\n请先配置参数并运行步骤\n或选择已有的 CSV 后刷新",
ha='center', va='center', transform=self._ax_scatter.transAxes,
fontsize=13, color='#AAAAAA')
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
self._fig.tight_layout(pad=2.0)
viz_layout.addWidget(self._canvas)
viz_group.setLayout(viz_layout)
right_layout.addWidget(viz_group, 1)
right_widget.setLayout(right_layout)
# ── 组装分栏 ──
splitter.addWidget(left_widget)
splitter.addWidget(right_widget)
splitter.setSizes([400, 700])
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 2)
top_layout = QHBoxLayout()
top_layout.setContentsMargins(0, 0, 0, 0)
top_layout.addWidget(splitter)
self.setLayout(top_layout)
# ── 事件绑定 ──
self._cid_hover = self._canvas.mpl_connect('motion_notify_event', self._on_hover)
self._cid_click = self._canvas.mpl_connect('button_press_event', self._on_click)
# ── 定时器:降低频率,仅用于自动发现新生成的 CSV ──
self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._check_csv_exists)
self._status_timer.start(2000)
self._status_timer.timeout.connect(self._check_csv_and_auto_render)
self._status_timer.start(5000)
self.output_file.line_edit.textChanged.connect(self._on_output_changed)
# ═══════════════════════════════════════════════════════════════
# 原生交互工具代理 (兼容新旧版 Matplotlib)
# ═══════════════════════════════════════════════════════════════
def _toggle_probe(self):
"""探针模式:解除原生引擎工具,恢复点击选点交互"""
# 取消 Matplotlib 原生工具的激活状态(回到无工具模式)
if hasattr(self._hidden_toolbar, 'mode'):
active = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper()
else:
active = str(getattr(self._hidden_toolbar, '_active', '')).upper()
# 如果当前有激活的工具,再点一次让它取消
if 'PAN' in active:
self._hidden_toolbar.pan()
elif 'ZOOM' in active:
self._hidden_toolbar.zoom()
self._sync_button_states()
def _toggle_pan(self):
"""联动原生引擎的拖拽功能,并控制按钮激活状态"""
self._hidden_toolbar.pan()
self._sync_button_states()
def _toggle_zoom(self):
"""联动原生引擎的框选放大功能,并控制按钮激活状态"""
self._hidden_toolbar.zoom()
self._sync_button_states()
def _sync_button_states(self):
"""兼容新老版 Matplotlib 获取当前激活模式,实现三按钮互斥"""
if hasattr(self._hidden_toolbar, 'mode'):
mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper()
else:
mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper()
is_pan = 'PAN' in mode_str
is_zoom = 'ZOOM' in mode_str
is_probe = not is_pan and not is_zoom # 探针 = 两者都不激活
self.probe_btn.setChecked(is_probe)
self.pan_btn.setChecked(is_pan)
self.zoom_rect_btn.setChecked(is_zoom)
# ═══════════════════════════════════════════════════════════════
# 配置读写
# ═══════════════════════════════════════════════════════════════
def get_config(self):
config = {
'interval': self.interval.value(),
@ -168,61 +388,50 @@ class Step4SamplingPanel(QWidget):
else:
self.work_dir = None
main_window = self.window()
deglint_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step3_outputs = getattr(pipeline, 'step_outputs', {}).get('step3', {})
step3_out = pipeline.step_outputs.get('step3', {})
deglint_path = (
step3_outputs.get('deglint_image') or step3_outputs.get('output_path') or
step3_outputs.get('output_file') or step3_outputs.get('deglint_img_path')
step3_out.get('deglint_image') or step3_out.get('output_path') or
step3_out.get('output_file') or step3_out.get('deglint_img_path')
)
if not deglint_path and hasattr(main_window, 'step3_panel'):
deglint_path = main_window.step3_panel.output_file.get_path()
if deglint_path:
if not deglint_path or not os.path.exists(deglint_path):
deglint_path = scan_work_dir_for_input(self.work_dir, 'deglint_image')
if deglint_path and os.path.exists(deglint_path):
if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
self.deglint_img_file.set_path(deglint_path)
water_mask_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step1_outputs = getattr(pipeline, 'step_outputs', {}).get('step1', {})
step1_out = pipeline.step_outputs.get('step1', {})
water_mask_path = (
step1_outputs.get('water_mask') or step1_outputs.get('output_path') or step1_outputs.get(
'output_file')
step1_out.get('water_mask') or step1_out.get('output_path') or
step1_out.get('output_file')
)
if not water_mask_path and hasattr(main_window, 'step1_panel'):
water_mask_path = main_window.step1_panel.output_file.get_path()
if not water_mask_path and self.work_dir:
mask_dir = resolve_subdir(self.work_dir, 'water_mask')
if os.path.isdir(mask_dir):
dat_files = [f for f in os.listdir(mask_dir) if f.lower().endswith('.dat')]
if dat_files:
water_mask_path = os.path.join(mask_dir, dat_files[0]).replace('\\', '/')
if not water_mask_path or not os.path.exists(water_mask_path):
water_mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
if not water_mask_path and self.work_dir:
input_test_dir = os.path.join(self.work_dir, "input-test")
if os.path.isdir(input_test_dir):
dat_files = [f for f in os.listdir(input_test_dir) if f.lower().endswith('.dat')]
for f in dat_files:
if 'water_mask_from_shp' in f.lower():
water_mask_path = os.path.join(input_test_dir, f).replace('\\', '/')
break
if not water_mask_path and dat_files:
water_mask_path = os.path.join(input_test_dir, dat_files[0]).replace('\\', '/')
if water_mask_path:
if water_mask_path and os.path.exists(water_mask_path):
if not os.path.isabs(water_mask_path):
water_mask_path = os.path.join(self.work_dir or '', water_mask_path).replace('\\', '/')
self.water_mask_file.set_path(water_mask_path)
if self.work_dir:
if self.work_dir and not self.output_file.get_path():
output_path = resolve_subdir(self.work_dir, 'sampling_csv_path')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
self.output_file.set_path(output_path.replace('\\', '/'))
self._check_csv_exists()
csv_path = self.output_file.get_path()
if csv_path and os.path.isfile(csv_path):
self._render_inline_plot()
# ═══════════════════════════════════════════════════════════════
# 执行
# ═══════════════════════════════════════════════════════════════
def _on_run_single_clicked(self):
from src.gui.core.event_bus import global_event_bus
@ -236,15 +445,388 @@ class Step4SamplingPanel(QWidget):
'config': config,
})
# ═══════════════════════════════════════════════════════════════
# CSV 状态检测
# ═══════════════════════════════════════════════════════════════
def _check_csv_exists(self):
csv_path = self.output_file.get_path()
enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_path))
self.preview_btn.setEnabled(enabled)
self.refresh_btn.setEnabled(enabled)
return enabled
def _on_output_changed(self, _text=None):
self._check_csv_exists()
def _check_csv_and_auto_render(self):
csv_path = self.output_file.get_path()
if csv_path and os.path.isfile(csv_path):
if csv_path != self._last_render_path:
self.refresh_btn.setEnabled(True)
self._render_inline_plot()
def _on_refresh_clicked(self):
csv_path = self.output_file.get_path()
if not csv_path or not os.path.exists(csv_path):
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
return
self._render_inline_plot()
# ═══════════════════════════════════════════════════════════════
# 核心渲染
# ═══════════════════════════════════════════════════════════════
def _detect_coordinate_columns(self, df: pd.DataFrame):
cols_lower = {c.lower(): c for c in df.columns}
priority_pairs = [
('pixel_x', 'pixel_y'),
('longitude', 'latitude'),
('lon', 'lat'),
('x', 'y'),
('utm_x', 'utm_y'),
]
for x_key, y_key in priority_pairs:
if x_key in cols_lower and y_key in cols_lower:
return cols_lower[x_key], cols_lower[y_key]
x_candidates = [c for c in df.columns if 'x' in c.lower() and pd.api.types.is_numeric_dtype(df[c])]
y_candidates = [c for c in df.columns if 'y' in c.lower() and pd.api.types.is_numeric_dtype(df[c])]
if x_candidates and y_candidates:
return x_candidates[0], y_candidates[0]
return None, None
def _detect_band_columns(self, df: pd.DataFrame):
band_cols = []
for col in df.columns:
try:
val = float(str(col).strip())
if 200.0 <= val <= 3000.0:
band_cols.append(col)
except (ValueError, TypeError):
continue
if band_cols:
band_cols.sort(key=lambda c: float(str(c).strip()))
return band_cols
skip_keywords = ('x', 'y', 'lon', 'lat', 'utm', 'id', 'sample', 'index', 'pixel')
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
if self._x_col:
numeric_cols = [c for c in numeric_cols if c != self._x_col]
if self._y_col:
numeric_cols = [c for c in numeric_cols if c != self._y_col]
return [c for c in numeric_cols if not any(k in c.lower() for k in skip_keywords)]
def _render_inline_plot(self):
csv_path = self.output_file.get_path()
if not csv_path or not os.path.isfile(csv_path):
self._show_empty_state("等待采样数据生成...\n\n请先配置参数并运行步骤")
return
try:
df = pd.read_csv(csv_path)
except Exception as e:
self._show_empty_state(f"读取 CSV 失败:\n{str(e)[:200]}")
return
if df.empty:
self._show_empty_state("CSV 文件为空")
return
x_col, y_col = self._detect_coordinate_columns(df)
if x_col is None or y_col is None:
self._show_empty_state(
"缺少坐标列\n\n"
f"可用列: {', '.join(str(c) for c in df.columns[:15])}\n"
"期望: pixel_x/pixel_y, longitude/latitude, X/Y 等"
)
return
band_cols = self._detect_band_columns(df)
self._df = df
self._x_col = x_col
self._y_col = y_col
self._band_cols = band_cols
self._highlight_idx = None
self._last_render_path = csv_path
if self._annot is not None:
try:
self._annot.remove()
except Exception:
pass
self._annot = None
self._ax_scatter.clear()
self._ax_scatter.set_facecolor('white')
x = df[x_col].values
y = df[y_col].values
self._scatter = self._ax_scatter.scatter(
x, y,
c='#0078D7', alpha=0.75, edgecolors='white',
linewidth=0.6, s=45, picker=True, zorder=3
)
self._ax_scatter.set_xlabel("经度 / X", fontsize=11)
self._ax_scatter.set_ylabel("纬度 / Y", fontsize=11)
self._ax_scatter.set_title(f"采样点空间分布 (共 {len(df)} 个点)", fontsize=14, fontweight='bold', pad=12)
self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_scatter.tick_params(labelsize=9)
self._ax_spectrum.clear()
self._ax_spectrum.set_facecolor('white')
if band_cols:
self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=14, fontweight='bold', pad=12)
self._ax_spectrum.set_xlabel("波长 / 特征", fontsize=11)
self._ax_spectrum.set_ylabel("反射率", fontsize=11)
self._ax_spectrum.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_spectrum.tick_params(labelsize=9)
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
else:
self._ax_spectrum.text(0.5, 0.5, "缺少光谱数据列",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
self._fig.tight_layout(pad=2.0)
self._canvas.draw_idle()
def _show_empty_state(self, message: str):
for ax in (self._ax_scatter, self._ax_spectrum):
ax.clear()
ax.set_facecolor('white')
ax.tick_params(labelsize=0)
self._ax_scatter.set_title("采样点空间分布", fontsize=14, fontweight='bold', pad=12)
self._ax_scatter.text(0.5, 0.5, message, ha='center', va='center',
transform=self._ax_scatter.transAxes,
fontsize=13, color='#AAAAAA')
self._ax_spectrum.set_title("光谱曲线", fontsize=14, fontweight='bold', pad=12)
self._ax_spectrum.text(0.5, 0.5, "等待数据...",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
self._fig.tight_layout(pad=2.0)
self._canvas.draw_idle()
self._df = None
self._last_render_path = None
# ═══════════════════════════════════════════════════════════════
# 交互事件 (Hover & Click)
# ═══════════════════════════════════════════════════════════════
def _on_hover(self, event):
if event.inaxes != self._ax_scatter:
self._canvas.unsetCursor() # 离开绘图区,恢复默认鼠标
if self._annot is not None:
try:
self._annot.set_visible(False)
self._canvas.draw_idle()
except Exception:
pass
return
if self._df is None or self._scatter is None:
return
# ── 拦截:如果当前拿着拖拽或缩放工具,交出鼠标控制权给 Matplotlib ──
if hasattr(self._hidden_toolbar, 'mode'):
mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper()
else:
mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper()
if 'PAN' in mode_str or 'ZOOM' in mode_str:
self._canvas.unsetCursor() # 解除我们的控制,让工具管鼠标
if self._annot is not None and self._annot.get_visible():
self._annot.set_visible(False)
self._canvas.draw_idle()
return
contains, info = self._scatter.contains(event)
if not contains or info is None or 'ind' not in info or len(info['ind']) == 0:
self._canvas.setCursor(Qt.ArrowCursor) # 在空白处,变成普通箭头
if self._annot is not None:
try:
self._annot.set_visible(False)
self._canvas.draw_idle()
except Exception:
pass
return
# ── 【鼠标变小手】:悬停在散点上,变成可点击的"小手" ──
self._canvas.setCursor(Qt.PointingHandCursor)
idx = info['ind'][0]
row = self._df.iloc[idx]
x_val = row[self._x_col]
y_val = row[self._y_col]
text = f"ID: {idx}\n经度: {x_val:.4f}\n纬度: {y_val:.4f}"
if self._annot is None:
self._annot = self._ax_scatter.annotate(
text,
xy=(x_val, y_val),
xytext=(12, 12),
textcoords='offset points',
bbox=dict(boxstyle='round,pad=0.4', facecolor='#FFFFFF',
edgecolor='#0078D7', alpha=0.9),
fontsize=9,
zorder=10,
)
else:
self._annot.xy = (x_val, y_val)
self._annot.set_text(text)
self._annot.set_visible(True)
self._canvas.draw_idle()
def _on_click(self, event):
if event.inaxes != self._ax_scatter:
return
if self._df is None or self._scatter is None:
return
# ── 拦截:如果当前拿着拖拽或缩放工具,直接忽略选点 ──
if hasattr(self._hidden_toolbar, 'mode'):
mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper()
else:
mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper()
if 'PAN' in mode_str or 'ZOOM' in mode_str:
return
contains, info = self._scatter.contains(event)
if not contains or info is None or 'ind' not in info or len(info['ind']) == 0:
return
idx = info['ind'][0]
# ── 【取消选中逻辑】:如果点击的是已经高亮的点,则取消高亮并重置 ──
if getattr(self, '_highlight_idx', None) == idx:
self._highlight_idx = None
current_xlim = self._ax_scatter.get_xlim()
current_ylim = self._ax_scatter.get_ylim()
# 恢复左侧为全是蓝色散点的状态
self._ax_scatter.clear()
self._ax_scatter.set_facecolor('white')
x = self._df[self._x_col].values
y = self._df[self._y_col].values
self._scatter = self._ax_scatter.scatter(
x, y, c='#0078D7', alpha=0.75, edgecolors='white', linewidth=0.6, s=45, picker=True, zorder=3
)
self._ax_scatter.set_xlabel("经度 / X", fontsize=11)
self._ax_scatter.set_ylabel("纬度 / Y", fontsize=11)
self._ax_scatter.set_title(f"采样点空间分布 (共 {len(self._df)} 个点)", fontsize=14, fontweight='bold', pad=12)
self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_scatter.tick_params(labelsize=9)
self._ax_scatter.set_xlim(current_xlim)
self._ax_scatter.set_ylim(current_ylim)
# 清空右侧光谱图
self._ax_spectrum.clear()
self._ax_spectrum.set_facecolor('white')
self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=14, fontweight='bold', pad=12)
self._ax_spectrum.set_xlabel("波长 / 特征", fontsize=11)
self._ax_spectrum.set_ylabel("反射率", fontsize=11)
self._ax_spectrum.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_spectrum.tick_params(labelsize=9)
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
ha='center', va='center', transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
self._fig.tight_layout(pad=2.0)
self._canvas.draw_idle()
return
# ── 正常选中并高亮逻辑 ──
self._highlight_idx = idx
row = self._df.iloc[idx]
x = self._df[self._x_col].values
y = self._df[self._y_col].values
colors = ['#0078D7'] * len(self._df)
sizes = [45] * len(self._df)
colors[idx] = '#E74C3C'
sizes[idx] = 80
current_xlim = self._ax_scatter.get_xlim()
current_ylim = self._ax_scatter.get_ylim()
self._ax_scatter.clear()
self._ax_scatter.set_facecolor('white')
self._scatter = self._ax_scatter.scatter(
x, y, c=colors, s=sizes,
alpha=0.75, edgecolors='white', linewidth=0.6,
picker=True, zorder=3
)
self._ax_scatter.scatter(
[x[idx]], [y[idx]],
c='#E74C3C', s=110,
alpha=0.9, edgecolors='white', linewidth=1.5,
zorder=5
)
self._ax_scatter.set_xlabel("经度 / X", fontsize=11)
self._ax_scatter.set_ylabel("纬度 / Y", fontsize=11)
self._ax_scatter.set_title(f"采样点空间分布 (共 {len(self._df)} 个点)", fontsize=14, fontweight='bold', pad=12)
self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_scatter.tick_params(labelsize=9)
self._ax_scatter.set_xlim(current_xlim)
self._ax_scatter.set_ylim(current_ylim)
self._ax_spectrum.clear()
self._ax_spectrum.set_facecolor('white')
if self._band_cols:
wavelengths = []
reflectance = []
for col in self._band_cols:
try:
wl = float(str(col).strip())
val = row[col]
if pd.notna(val):
wavelengths.append(wl)
reflectance.append(float(val))
except (ValueError, TypeError):
continue
if wavelengths:
pairs = sorted(zip(wavelengths, reflectance), key=lambda p: p[0])
wavelengths, reflectance = zip(*pairs) if pairs else ([], [])
self._ax_spectrum.plot(
wavelengths, reflectance,
color='#0078D7', lw=1.5, marker='.', markersize=3, alpha=0.85
)
self._ax_spectrum.fill_between(wavelengths, reflectance, alpha=0.08, color='#0078D7')
self._ax_spectrum.set_xlabel("波长 / 特征", fontsize=11)
self._ax_spectrum.set_ylabel("反射率", fontsize=11)
self._ax_spectrum.set_title(f"采样点 ID: {idx} 光谱曲线 ({len(wavelengths)} 个波段)",
fontsize=14, fontweight='bold', pad=12)
self._ax_spectrum.grid(True, alpha=0.2, linestyle='-', linewidth=0.5)
self._ax_spectrum.tick_params(labelsize=9)
else:
self._ax_spectrum.text(0.5, 0.5, "该样本无有效光谱数据",
ha='center', va='center',
transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
else:
self._ax_spectrum.text(0.5, 0.5, "缺少光谱波段列\n无法绘制光谱",
ha='center', va='center',
transform=self._ax_spectrum.transAxes,
fontsize=13, color='#AAAAAA')
self._fig.tight_layout(pad=2.0)
self._canvas.draw_idle()
# ═══════════════════════════════════════════════════════════════
# 旧版弹窗查看器(保留,供外部调用)
# ═══════════════════════════════════════════════════════════════
def _open_sampling_viewer(self):
csv_path = self.output_file.get_path()
if not csv_path or not os.path.exists(csv_path):

View File

@ -190,12 +190,11 @@ class Step5CleanPanel(QWidget):
else:
self.work_dir = None
if self.work_dir:
if self.work_dir and not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'data_cleaning')
os.makedirs(output_dir, exist_ok=True)
default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
elif not self.work_dir:
self.output_file.set_path("")
def _on_run_single_clicked(self):

View File

@ -12,7 +12,7 @@ from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from src.gui.panels._step_path_resolver import resolve_subdir
from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for_input
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
@ -218,31 +218,22 @@ class Step6FeaturePanel(QWidget):
else:
self.work_dir = None
# 1. 水体掩膜:优先 pipeline,回退文件系统扫描
mask_path = None
if pipeline and hasattr(pipeline, 'water_mask_path') and pipeline.water_mask_path:
mask_path = pipeline.water_mask_path
if not mask_path and self.work_dir:
mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
if mask_path and os.path.exists(str(mask_path)):
self.water_mask_file.set_path(str(mask_path))
main_window = self.window()
if not mask_path and hasattr(main_window, 'step1_panel'):
if main_window.step1_panel.use_ndwi_radio.isChecked():
mask_path = main_window.step1_panel.output_file.get_path()
else:
mask_path = main_window.step1_panel.mask_file.get_path()
if mask_path and not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
if mask_path:
if not os.path.isabs(mask_path):
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
self.water_mask_file.set_path(mask_path)
if hasattr(main_window, 'step2_panel'):
glint_path = main_window.step2_panel.output_file.get_path()
if glint_path:
if not os.path.isabs(glint_path):
glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/')
self.glint_mask_file.set_path(glint_path)
# 2. 耀斑掩膜:文件系统扫描
if self.work_dir:
glint_path = scan_work_dir_for_input(self.work_dir, 'glint_mask')
if glint_path and os.path.exists(str(glint_path)):
self.glint_mask_file.set_path(str(glint_path))
# 3. 去耀斑影像:优先 pipeline.step_outputs,回退文件系统扫描
deglint_path = None
if pipeline and hasattr(pipeline, 'step_outputs'):
step3_outputs = getattr(pipeline, 'step_outputs', {}).get('step3', {})
@ -252,49 +243,29 @@ class Step6FeaturePanel(QWidget):
or step3_outputs.get('output_file')
or step3_outputs.get('deglint_img_path')
)
if not deglint_path and hasattr(main_window, 'step3_panel'):
step3_widget = getattr(main_window.step3_panel, 'output_file', None)
if step3_widget is not None and hasattr(step3_widget, 'get_path'):
deglint_path = step3_widget.get_path() or ""
if not deglint_path and self.work_dir:
deglint_dir = resolve_subdir(self.work_dir, 'deglint')
if os.path.isdir(deglint_dir):
bsq_files = [
f for f in os.listdir(deglint_dir)
if f.lower().endswith('.bsq')
]
bsq_files.sort(key=lambda n: (0 if 'goodman' in n.lower() else 1, n))
if bsq_files:
deglint_path = os.path.join(deglint_dir, bsq_files[0]).replace('\\', '/')
if deglint_path:
if not os.path.isabs(deglint_path):
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
deglint_path = scan_work_dir_for_input(self.work_dir, 'deglint_image')
if deglint_path and os.path.exists(str(deglint_path)):
existing_deglint = self.deglint_img_file.get_path()
if not existing_deglint or not existing_deglint.strip():
self.deglint_img_file.set_path(deglint_path)
if (not existing_deglint or not existing_deglint.strip()):
self.deglint_img_file.set_path(str(deglint_path))
# 4. 处理后 CSV:文件系统扫描
if self.work_dir:
csv_path = scan_work_dir_for_input(self.work_dir, 'processed_data')
if csv_path and os.path.exists(str(csv_path)):
existing_csv = self.csv_file.get_path()
if (not existing_csv or not existing_csv.strip()):
self.csv_file.set_path(str(csv_path))
# 5. 输出路径
if self.work_dir and not self.output_file.get_path():
output_dir = resolve_subdir(self.work_dir, 'spectral_feature')
os.makedirs(output_dir, exist_ok=True)
default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/')
self.output_file.set_path(default_output_path)
else:
elif not self.work_dir:
self.output_file.set_path("")
if main_window and hasattr(main_window, 'step5_clean_panel'):
step5_clean_output_path = main_window.step5_clean_panel.output_file.get_path()
if step5_clean_output_path:
if not os.path.isabs(step5_clean_output_path):
step5_clean_output_path = os.path.join(
self.work_dir or '', step5_clean_output_path
).replace('\\', '/')
existing_csv = self.csv_file.get_path()
if not existing_csv or not existing_csv.strip():
self.csv_file.set_path(step5_clean_output_path)
def _on_run_single_clicked(self):
from src.gui.core.event_bus import global_event_bus

View File

@ -9,6 +9,8 @@ import sys
import csv
from pathlib import Path
import pandas as pd
from PyQt5.QtWidgets import (
QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QPushButton, QMessageBox, QListWidget,
@ -263,7 +265,15 @@ class Step7InversionPanel(QWidget):
output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20)
# 这里不需要输出文件选择框,因为服务层会直接覆盖原 CSV,但保留执行按钮
self.output_file = FileSelectWidget(
"结果保存至:",
"CSV Files (*.csv);;All Files (*.*)",
mode="save"
)
self.output_file.label.setMinimumWidth(120)
self.output_file.line_edit.setPlaceholderText("training_spectra_indices.csv")
output_layout.addWidget(self.output_file)
action_layout = QHBoxLayout()
action_layout.addStretch()
@ -328,6 +338,9 @@ class Step7InversionPanel(QWidget):
'formula_names': selected_names,
'enabled': True # 默认启用
}
output_path = self.output_file.get_path()
if output_path:
config['output_path'] = output_path
return config
def set_config(self, config: dict):
@ -347,6 +360,9 @@ class Step7InversionPanel(QWidget):
item.setCheckState(state)
self.formula_list.blockSignals(False)
if 'output_path' in config:
self.output_file.set_path(config['output_path'])
def _load_formulas_from_csv(self):
"""解析公式 CSV 文件并填充列表框"""
csv_path = self.formula_file.get_path()
@ -439,7 +455,7 @@ class Step7InversionPanel(QWidget):
"""从全局配置/Pipeline 同步工作目录。
step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config
注入到 self.training_data_widget;此处仅缓存 work_dir,
注入到 self.training_data_widget;此处仅缓存 work_dir 并填入默认输出路径,
不重复拉取,避免与 panel_factory 注入路径冲突。
"""
if work_dir:
@ -448,3 +464,11 @@ class Step7InversionPanel(QWidget):
pass
else:
self.work_dir = None
# 仅在输出框为空时填入默认路径(不创建目录,留给 pipeline 执行时创建)
if self.work_dir and not self.output_file.get_path():
output_dir = os.path.join(self.work_dir, "7_Water_Quality_Indices")
default_path = os.path.join(output_dir, "training_spectra_indices.csv").replace('\\', '/')
self.output_file.set_path(default_path)
elif not self.work_dir:
self.output_file.set_path("")

View File

@ -12,7 +12,7 @@ from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from src.gui.panels._step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir
from src.gui.panels._step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir, scan_work_dir_for_input
import pandas as pd
@ -85,10 +85,11 @@ class Step8MlTrainPanel(QWidget):
self.init_ui()
def init_ui(self):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
layout = QVBoxLayout()
# 标题
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(20)
# 训练数据文件(用于独立运行)
self.training_csv_file = FileSelectWidget(
@ -403,56 +404,6 @@ class Step8MlTrainPanel(QWidget):
continue
return None
def _resolve_training_csv_from_workdir(self):
"""根据工作目录智能挑选训练 CSV 路径。
优先级(从高到低):
1) 7_Water_Quality_Indices/training_spectra_indices.csv(Step 7 WQI 增强版)
2) 10_WaterIndex_CSV/*training* / *training*indices*.csv(用户自定义带指数汇总)
3) 6_Spectral_Feature_Extraction/training_spectra.csv(Step 6 原始特征)
4) 7_Water_Quality_Indices/ 下任意 *training*.csv
"""
work_dir = self._get_default_work_dir()
if not work_dir:
return ""
from pathlib import Path
wd = Path(work_dir)
# 1) Step 7 输出的训练 WQI 增强版
step7_csv = wd / "7_Water_Quality_Indices" / "training_spectra_indices.csv"
if step7_csv.is_file():
return str(step7_csv).replace('\\', '/')
# 2) 10_WaterIndex_CSV 下任何带 "training" 关键词的 csv(用户在 Step 10 跑过训练集)
idx_dir = wd / "10_WaterIndex_CSV"
if idx_dir.is_dir():
candidates = sorted(
idx_dir.glob("*training*.csv"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
return str(candidates[0]).replace('\\', '/')
# 3) Step 6 原始光谱特征
step6_csv = wd / "6_Spectral_Feature_Extraction" / "training_spectra.csv"
if step6_csv.is_file():
return str(step6_csv).replace('\\', '/')
# 4) Step 7 目录下任何 training*.csv(兜底)
step7_dir = wd / "7_Water_Quality_Indices"
if step7_dir.is_dir():
candidates = sorted(
step7_dir.glob("*training*.csv"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
return str(candidates[0]).replace('\\', '/')
return ""
def browse_output_path(self):
"""浏览输出模型目录"""
work_dir = getattr(self, 'work_dir', "")
@ -537,23 +488,20 @@ class Step8MlTrainPanel(QWidget):
else:
self.work_dir = None
# 1. 智能挑选训练 CSV(不再"强制"读 Step 6,而是优先 WQI 增强版)
# 优先级:Step 7 WQI > 10_WaterIndex_CSV/*training* > Step 6 原始光谱 > Step 7 兜底
# 修复目标:用户跑过 Step 7 后再回到 Step 8,UI 默认应指向带指数的训练集,
# 否则训练好的模型有 95 维(50 波段 + 45 WQI),下次回放变成只 50 维训练,特征维数错位。
# 1. 智能挑选训练 CSV:优先 WQI 增强版,回退原始光谱特征
existing_training_csv = self.training_csv_file.get_path()
if not existing_training_csv or not existing_training_csv.strip():
candidate = self._resolve_training_csv_from_workdir()
if candidate:
self.training_csv_file.set_path(candidate)
candidate = scan_work_dir_for_input(self.work_dir, 'training_spectra_indices')
if not candidate:
candidate = scan_work_dir_for_input(self.work_dir, 'training_spectra')
if candidate and os.path.exists(str(candidate)):
self.training_csv_file.set_path(str(candidate))
# 2. 自动填充输出目录为 8_Machine_Learning_Models
if self.work_dir:
import os
# 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录)
if self.work_dir and not self.output_path.get_path():
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
os.makedirs(models_dir, exist_ok=True)
self.output_path.set_path(models_dir)
else:
elif not self.work_dir:
self.output_path.set_path("")
def _on_run_single_clicked(self):

View File

@ -14,7 +14,7 @@ import pandas as pd
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir
from _step_path_resolver import get_step_output_path, resolve_step_widget, resolve_subdir, scan_work_dir_for_input
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QGroupBox, QFormLayout,
@ -183,11 +183,13 @@ class Step9MlPredictPanel(QWidget):
output_layout.setSpacing(16)
output_layout.setContentsMargins(20, 24, 20, 20)
# 输出文件路径
# 输出目录路径(目录模式:模型预测结果为多个 CSV 文件,存放到目录中)
self.output_file = FileSelectWidget(
"输出路径:",
"CSV Files (*.csv);;All Files (*.*)"
"输出目录:",
"Directories"
)
self.output_file.browse_btn.clicked.disconnect()
self.output_file.browse_btn.clicked.connect(self._browse_output_dir)
output_layout.addWidget(self.output_file)
# 完美对齐的底部按钮栏(已彻底移除多余的启用复选框)
@ -335,96 +337,24 @@ class Step9MlPredictPanel(QWidget):
result[name] = self.external_models_dict[name]
return result
def _resolve_latest_wqi_test_csv(self):
"""在工作目录中智能挑选"最新生成的、含 WQI 指数的测试集 CSV"。
返回:找到则返回文件路径字符串;找不到返回 ""。
搜索策略(按优先级递减,命中即返回):
1) 10_WaterIndex_CSV/*.csv — Step 10 输出目录(用户在 Step 10 跑过的产品)
2) 7_Water_Quality_Indices/*sampling*.csv / *test*.csv — 用户手动对采样点算过 WQI
3) work_dir 下任何 *indices*.csv / *wqi*.csv(不区分大小写)
4) work_dir 下任何 > 60 列的 csv(启发式:50 波段 + > 10 WQI 指数列)
5) 兜底空串,调用方回退到 Step 4 sampling_spectra.csv
多个候选时按 mtime 倒序选"最新生成的"。
"""
work_dir = self._get_default_work_dir()
if not work_dir:
return ""
wd = Path(work_dir)
found = []
# 1) 10_WaterIndex_CSV 下所有 csv(Step 10 输出)
idx_dir = wd / "10_WaterIndex_CSV"
if idx_dir.is_dir():
found.extend(idx_dir.glob("*.csv"))
# 2) 7_Water_Quality_Indices 下与采样/测试相关的 csv
qa_dir = wd / "7_Water_Quality_Indices"
if qa_dir.is_dir():
for pattern in ("*sampling*.csv", "*test*.csv", "*predict*.csv"):
found.extend(qa_dir.glob(pattern))
# 3) work_dir 直接子树下含 indices/wqi 关键词的 csv
for keyword in ("*indices*.csv", "*wqi*.csv", "*WQI*.csv"):
found.extend(wd.rglob(keyword))
# 4) 启发式:> 60 列的 csv(50 波段 + 至少 10 个指数)
try:
for csv in wd.rglob("*.csv"):
if csv in found:
continue
try:
head = pd.read_csv(csv, nrows=0)
if head.shape[1] > 60:
found.append(csv)
except Exception:
pass # 读取失败就跳过,不影响其它候选
except Exception:
pass
if not found:
return ""
# 去重 + 按 mtime 倒序排
uniq = {p.resolve(): p for p in found}.values()
sorted_paths = sorted(uniq, key=lambda p: p.stat().st_mtime, reverse=True)
return str(sorted_paths[0]).replace('\\', '/')
def update_from_config(self, work_dir=None, pipeline=None):
if work_dir: self.work_dir = work_dir
main_window = self.window()
factory = getattr(main_window, '_panel_factory', None) if main_window else None
# 1. 采样 CSV:文件系统扫描(sampling_points 即 sampling_spectra.csv)
if 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))
# 1. 智能挑选采样 CSV:优先"含 WQI 指数的测试集"(防止特征维度与训练时不匹配)
# 修复目标:用户在 Step 8 用 95 维 (50+45 WQI) 训练 → Step 9 默认读 50 维 raw sampling
# 时 inference_batch.preprocess_spectra 会触发"自动特征补全"逻辑;但若用户已经在
# Step 10/手工把指数算到 CSV 里了,应该直接用那个文件(少走内存补全、避免 band 列顺序漂移)
wqi_test_csv = self._resolve_latest_wqi_test_csv()
if wqi_test_csv:
self.sampling_csv_file.set_path(wqi_test_csv)
elif factory:
# 兜底:拿第 4 步的纯原始采样光谱(旧行为保留)
step4_panel = factory.get_panel('step4_sampling')
if step4_panel and hasattr(step4_panel, 'output_file'):
path = step4_panel.output_file.get_path()
if path: self.sampling_csv_file.set_path(path)
# 2. 模型目录:文件系统扫描
if self.work_dir:
models_dir = scan_work_dir_for_input(self.work_dir, 'ml_models_dir')
if models_dir and os.path.isdir(str(models_dir)):
self.models_dir_file.set_path(str(models_dir))
# 2. 拿第 8 步的模型目录
if factory:
step8_panel = factory.get_panel('step8_ml_train')
if step8_panel and hasattr(step8_panel, 'output_path'):
path = step8_panel.output_path.get_path()
if path: self.models_dir_file.set_path(path)
# 3. 生成第 9 步的输出目录
if hasattr(self, 'work_dir') and self.work_dir:
import os
# 3. 生成第 9 步的输出目录(仅在为空时填入默认路径,不创建目录)
if hasattr(self, 'work_dir') and self.work_dir and not self.output_file.get_path():
out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/')
os.makedirs(out_dir, exist_ok=True)
self.output_file.set_path(out_dir)
def _get_default_work_dir(self):
@ -445,6 +375,15 @@ class Step9MlPredictPanel(QWidget):
if dir_path:
self.models_dir_file.set_path(dir_path)
def _browse_output_dir(self):
"""浏览预测输出目录"""
default = self._get_default_work_dir()
if default:
default = os.path.join(default, '9_ML_Prediction')
dir_path = QFileDialog.getExistingDirectory(self, "选择预测输出目录", default)
if dir_path:
self.output_file.set_path(dir_path)
def get_config(self):
"""获取配置"""
config = {

View File

@ -31,8 +31,9 @@ from PyQt5.QtWidgets import (
QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy,
QListWidget, QListWidgetItem, QGroupBox,
QTextEdit, QProgressBar, QMessageBox, QFileDialog,
QSpinBox, QDoubleSpinBox, QComboBox,
)
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtCore import Qt, QTimer, QSize
from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor
if multiprocessing.current_process().name == 'MainProcess':
@ -104,8 +105,11 @@ class WaterQualityGUI(QMainWindow):
self._apply_stylesheet()
self._disable_wheel_for_all_spinboxes()
# 第五步:延迟启动工作目录选择
QTimer.singleShot(100, self._workspace_initializer.run)
# 第五步:默认选中第一个步骤(延迟执行,确保导航列表和 Tab 均已就位)
QTimer.singleShot(120, self._select_first_nav_item)
# 第六步:延迟启动工作目录选择
QTimer.singleShot(200, self._workspace_initializer.run)
# ================================================================
# Manager 初始化
@ -395,9 +399,8 @@ class WaterQualityGUI(QMainWindow):
# 分隔符 (不需要实体占用高度,缩小即可)
if stage_idx < len(stage_names) - 1:
from PyQt5.QtCore import QSize # 正确引入 QSize
sep = QListWidgetItem("")
sep.setSizeHint(QSize(0, 10)) # 直接使用 QSize
sep.setSizeHint(QSize(0, 10))
sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled)
self._step_list.addItem(sep)
@ -467,15 +470,26 @@ class WaterQualityGUI(QMainWindow):
continue
try:
panel.update_from_config(work_dir=work_dir, pipeline=None)
except Exception:
pass
except Exception as e:
# 2026-06-30:不再静默吞异常,至少通过 EventBus 输出日志
self._event_bus.publish('LogMessage', {
'message': f'[警告] 面板 {step_id} 的 update_from_config 失败: {e}',
'level': 'warning',
})
# ================================================================
# 导航 → Tab 单向路由(左侧 List 驱动右侧 Tab,Tab 头部已隐藏)
# ================================================================
def _select_first_nav_item(self):
"""默认选中左侧导航栏的第一个可导航步骤项(跳过阶段标题/分隔符)。"""
for i in range(self._step_list.count()):
item = self._step_list.item(i)
if item and item.data(Qt.UserRole) not in (None, "stage_header"):
self._step_list.setCurrentRow(i)
return
def _on_step_list_changed(self, index):
from PyQt5.QtCore import Qt
if index < 0: return
item = self._step_list.item(index)
if not item: return
@ -609,7 +623,6 @@ class WaterQualityGUI(QMainWindow):
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
def _disable_wheel_for_all_spinboxes(self):
from PyQt5.QtWidgets import QSpinBox, QDoubleSpinBox, QComboBox
for sb in self.findChildren(QSpinBox):
sb.setFocusPolicy(Qt.StrongFocus)
sb.wheelEvent = lambda e, s=sb: None