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 工具栏(保存/缩放/平移) - 自动检测坐标列和波段列,完善异常处理
This commit is contained in:
@ -183,26 +183,35 @@ class WorkspaceManager:
|
|||||||
for file_path in subdir_path.rglob('*'):
|
for file_path in subdir_path.rglob('*'):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
file_name = file_path.name.lower()
|
file_name = file_path.name.lower()
|
||||||
|
file_stem = file_path.stem.lower()
|
||||||
|
|
||||||
for step_id in step_ids:
|
for step_id in step_ids:
|
||||||
if step_id not in discovered_outputs:
|
if step_id not in discovered_outputs:
|
||||||
discovered_outputs[step_id] = {}
|
discovered_outputs[step_id] = {}
|
||||||
|
|
||||||
if 'water_mask' in file_name and step_id == 'step1':
|
# 2026-06-30 加强匹配:用更精确的边界匹配替代简单的子串包含
|
||||||
if self._is_scientific_mask(file_path):
|
# 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)
|
discovered_outputs[step_id]['water_mask'] = str(file_path)
|
||||||
elif 'glint' in file_name and 'mask' in file_name and step_id == 'step2':
|
# glint_mask 匹配:必须同时含 glint 且文件名主体以 mask 或 area 结尾
|
||||||
if self._is_scientific_mask(file_path):
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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']:
|
elif file_name.endswith('.csv') and step_id in ['step9_ml_predict', 'step11_map', 'step12_viz']:
|
||||||
discovered_outputs[step_id]['predictions'] = str(file_path)
|
discovered_outputs[step_id]['predictions'] = str(file_path)
|
||||||
@ -255,13 +264,16 @@ class WorkspaceManager:
|
|||||||
def prune_config_for_prediction_mode(config: dict) -> dict:
|
def prune_config_for_prediction_mode(config: dict) -> dict:
|
||||||
"""Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。
|
"""Prediction-only 模式:禁用训练相关步骤,保留预测和成图步骤。
|
||||||
|
|
||||||
|
2026-06-30 修复:步骤 ID 从旧的 PIPELINE_STEPS 体系改为 PANEL_REGISTRY 体系,
|
||||||
|
确保与 get_current_config() 返回的 key 一致,避免 enabled: False 写入无效 key。
|
||||||
|
|
||||||
被禁用的 step dict 中统一写入 'enabled': False,
|
被禁用的 step dict 中统一写入 'enabled': False,
|
||||||
这些配置最终传给 PipelineRunner,Runner 会跳过它们。
|
这些配置最终传给 PipelineRunner,Runner 会跳过它们。
|
||||||
同时,被跳过的步骤的 required_input_files 在 build_missing_items
|
同时,被跳过的步骤的 required_input_files 在 build_missing_items
|
||||||
中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。
|
中不会被检查,从而自然规避了"CSV 缺失"等训练模式下的误报。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: 完整配置字典(来自 get_current_config)
|
config: 完整配置字典(来自 get_current_config,key 为 PANEL_REGISTRY step_id)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
裁剪后的 config(深拷贝,原 config 不被修改)
|
裁剪后的 config(深拷贝,原 config 不被修改)
|
||||||
@ -269,12 +281,11 @@ class WorkspaceManager:
|
|||||||
cfg = copy.deepcopy(config)
|
cfg = copy.deepcopy(config)
|
||||||
|
|
||||||
training_steps = [
|
training_steps = [
|
||||||
"step4",
|
"step4_sampling",
|
||||||
"step5",
|
"step5_clean",
|
||||||
"step7",
|
"step6_feature",
|
||||||
"step6",
|
"step7_index",
|
||||||
"step8_non_empirical_modeling",
|
"step8_ml_train",
|
||||||
"step9",
|
|
||||||
]
|
]
|
||||||
for step_id in training_steps:
|
for step_id in training_steps:
|
||||||
step_cfg = cfg.setdefault(step_id, {})
|
step_cfg = cfg.setdefault(step_id, {})
|
||||||
|
|||||||
@ -13,15 +13,24 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
|
|||||||
|
|
||||||
内含:
|
内含:
|
||||||
- 自动识别下游 widget(按 dict 键名查找)
|
- 自动识别下游 widget(按 dict 键名查找)
|
||||||
|
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
|
||||||
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
|
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
|
||||||
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
|
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
|
||||||
|
|
||||||
|
2026-06-30 修复:
|
||||||
|
- 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sip
|
||||||
|
|
||||||
from src.gui.core.event_bus import global_event_bus
|
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):
|
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:
|
if not dependencies:
|
||||||
return
|
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():
|
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):
|
def callback(data):
|
||||||
|
# ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过
|
||||||
|
try:
|
||||||
|
if sip.isdeleted(panel):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
if data.get('step_id') != dep_step:
|
if data.get('step_id') != dep_step:
|
||||||
return
|
return
|
||||||
if data.get('output_type') != output_type:
|
if data.get('output_type') != output_type:
|
||||||
@ -57,10 +93,14 @@ def _make_subscription(panel, dep_step, output_type, target_widget_name):
|
|||||||
return
|
return
|
||||||
|
|
||||||
current = ''
|
current = ''
|
||||||
if hasattr(widget, 'get_path'):
|
try:
|
||||||
current = widget.get_path().strip()
|
if hasattr(widget, 'get_path'):
|
||||||
elif hasattr(widget, 'text'):
|
current = widget.get_path().strip()
|
||||||
current = widget.text().strip()
|
elif hasattr(widget, 'text'):
|
||||||
|
current = widget.text().strip()
|
||||||
|
except RuntimeError:
|
||||||
|
# C++ 对象已被删除
|
||||||
|
return
|
||||||
|
|
||||||
if current:
|
if current:
|
||||||
return
|
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):
|
if 'dir' in target_widget_name.lower() and os.path.isfile(path):
|
||||||
path = os.path.dirname(path)
|
path = os.path.dirname(path)
|
||||||
|
|
||||||
if hasattr(widget, 'set_path'):
|
try:
|
||||||
widget.set_path(path)
|
if hasattr(widget, 'set_path'):
|
||||||
elif hasattr(widget, 'setText'):
|
widget.set_path(path)
|
||||||
widget.setText(path)
|
elif hasattr(widget, 'setText'):
|
||||||
|
widget.setText(path)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # C++ 对象已被删除
|
||||||
|
|
||||||
global_event_bus.subscribe('OutputUpdated', callback)
|
return callback
|
||||||
|
|||||||
@ -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 collections import defaultdict
|
||||||
from typing import Any, Callable, Dict, List
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
class EventBus:
|
||||||
@ -16,18 +21,48 @@ class EventBus:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._subscribers: Dict[str, List[Callable]] = defaultdict(list)
|
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]):
|
def subscribe(self, event_name: str, callback: Callable[[dict], None]):
|
||||||
"""订阅事件。callback 接收一个 dict 作为事件数据。"""
|
"""订阅事件。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]):
|
def publish(self, event_name: str, data: Dict[str, Any]):
|
||||||
"""发布事件,通知所有订阅者。"""
|
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
|
||||||
for callback in self._subscribers.get(event_name, []):
|
|
||||||
|
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
|
||||||
|
"""
|
||||||
|
for callback in list(self._subscribers.get(event_name, [])):
|
||||||
try:
|
try:
|
||||||
callback(data)
|
callback(data)
|
||||||
except Exception:
|
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)
|
||||||
|
|
||||||
|
|
||||||
# 全局单例
|
# 全局单例
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
- 占位页:未加载的 tab 显示空白 QWidget,加载后原地替换为 QScrollArea(panel)
|
- 占位页:未加载的 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 PyQt5.QtCore import Qt
|
||||||
|
|
||||||
from src.gui.core.panel_registry import PANEL_REGISTRY
|
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
|
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:
|
class PanelFactory:
|
||||||
"""面板注册与装载工厂。
|
"""面板注册与装载工厂。
|
||||||
|
|
||||||
@ -64,12 +92,11 @@ class PanelFactory:
|
|||||||
同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。
|
同时连接 currentChanged 信号驱动懒加载 + 邻接预加载。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
icons_dir: 图标目录名(相对于项目根),用于 get_resource_path
|
icons_dir: 图标目录名(相对于项目根),用于 _get_resource_path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QTabWidget: 已添加所有占位 tab 的标签页控件
|
QTabWidget: 已添加所有占位 tab 的标签页控件
|
||||||
"""
|
"""
|
||||||
from src.gui.water_quality_gui import get_resource_path
|
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QIcon
|
||||||
|
|
||||||
self._tab_widget = QTabWidget()
|
self._tab_widget = QTabWidget()
|
||||||
@ -85,7 +112,7 @@ class PanelFactory:
|
|||||||
placeholder = QWidget()
|
placeholder = QWidget()
|
||||||
self._placeholders[idx] = placeholder
|
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)
|
self._tab_widget.addTab(placeholder, QIcon(icon_path), title)
|
||||||
|
|
||||||
# 连接切换信号 → 懒加载
|
# 连接切换信号 → 懒加载
|
||||||
@ -182,6 +209,9 @@ class PanelFactory:
|
|||||||
self._panels[step_id] = panel
|
self._panels[step_id] = panel
|
||||||
self._loaded.add(tab_index)
|
self._loaded.add(tab_index)
|
||||||
|
|
||||||
|
# ★ 禁用新面板中所有 SpinBox/ComboBox 的滚轮事件(防误触改值)
|
||||||
|
_disable_wheel_for_widget(panel)
|
||||||
|
|
||||||
# 事件总线自动接线
|
# 事件总线自动接线
|
||||||
if deps:
|
if deps:
|
||||||
subscribe_panel_to_dependencies(panel, step_id, deps)
|
subscribe_panel_to_dependencies(panel, step_id, deps)
|
||||||
@ -207,8 +237,12 @@ class PanelFactory:
|
|||||||
try:
|
try:
|
||||||
work_dir = self._get_current_work_dir()
|
work_dir = self._get_current_work_dir()
|
||||||
panel.update_from_config(work_dir=work_dir, pipeline=None)
|
panel.update_from_config(work_dir=work_dir, pipeline=None)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
# 2026-06-30:不再静默吞异常
|
||||||
|
global_event_bus.publish('LogMessage', {
|
||||||
|
'message': f'[警告] 面板 update_from_config 失败: {e}',
|
||||||
|
'level': 'warning',
|
||||||
|
})
|
||||||
|
|
||||||
# 2. 回放 WorkspaceManager 中已累积的 step_outputs
|
# 2. 回放 WorkspaceManager 中已累积的 step_outputs
|
||||||
ws_manager = self._get_workspace_manager()
|
ws_manager = self._get_workspace_manager()
|
||||||
@ -231,8 +265,10 @@ class PanelFactory:
|
|||||||
|
|
||||||
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
|
架构解耦(2026-06-22):第三个元素 source_attr 现在明确代表上游控件的真实名字,
|
||||||
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
|
不再混用语义。回放端仅依赖 source_attr 在 SOURCE 面板上能命中 widget。
|
||||||
|
|
||||||
|
2026-06-30 修复:仅当路径对应的文件/目录确实存在时才广播 OutputUpdated,
|
||||||
|
防止面板中幽灵占位路径级联扩散到下游。
|
||||||
"""
|
"""
|
||||||
from src.gui.core.event_bus import global_event_bus
|
|
||||||
for entry in self._registry:
|
for entry in self._registry:
|
||||||
deps = entry.get('dependencies')
|
deps = entry.get('dependencies')
|
||||||
if not deps: continue
|
if not deps: continue
|
||||||
@ -244,19 +280,22 @@ class PanelFactory:
|
|||||||
|
|
||||||
widget = getattr(src_panel, source_attr, None)
|
widget = getattr(src_panel, source_attr, None)
|
||||||
if widget is None: continue
|
if widget is None: continue
|
||||||
|
|
||||||
path = ""
|
path = ""
|
||||||
if hasattr(widget, 'get_path'):
|
if hasattr(widget, 'get_path'):
|
||||||
path = widget.get_path().strip()
|
path = widget.get_path().strip()
|
||||||
elif hasattr(widget, 'text'):
|
elif hasattr(widget, 'text'):
|
||||||
path = widget.text().strip()
|
path = widget.text().strip()
|
||||||
|
|
||||||
if not path: continue
|
if not path: continue
|
||||||
|
|
||||||
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
|
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
|
||||||
import os
|
|
||||||
absolute_path = os.path.abspath(path).replace('\\', '/')
|
absolute_path = os.path.abspath(path).replace('\\', '/')
|
||||||
|
|
||||||
|
# 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联
|
||||||
|
if not os.path.exists(absolute_path):
|
||||||
|
continue
|
||||||
|
|
||||||
global_event_bus.publish('OutputUpdated', {
|
global_event_bus.publish('OutputUpdated', {
|
||||||
'step_id': dep_step,
|
'step_id': dep_step,
|
||||||
'output_type': output_type,
|
'output_type': output_type,
|
||||||
|
|||||||
@ -187,11 +187,10 @@ PANEL_REGISTRY = [
|
|||||||
'display_name': '10. 水色指数反演',
|
'display_name': '10. 水色指数反演',
|
||||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||||
# 第三元素 source_attr = 上游源控件真实属性名
|
# 第三元素 source_attr = 上游源控件真实属性名
|
||||||
# step10 panel 仅有 bsq_file / hdr_file / output_dir,没有 water_mask widget,
|
# step10 panel 使用 CSV 散点模式,输入为 Step4 的采样光谱 CSV
|
||||||
# 故删除 water_mask 依赖,避免悬挂回调
|
|
||||||
'dependencies': {
|
'dependencies': {
|
||||||
# 目标框: self.bsq_file ← 上游 step3.output_file
|
# 目标框: self.sampling_csv_file ← 上游 step4_sampling.output_file
|
||||||
'bsq_file': ('step3', 'deglint_image', 'output_file'),
|
'sampling_csv_file': ('step4_sampling', 'sampling_points', 'output_file'),
|
||||||
},
|
},
|
||||||
'constructor_kwargs': None,
|
'constructor_kwargs': None,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -53,19 +53,21 @@ class PreflightDialog(QDialog):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# step_id → (step_name, panel_tab_index)
|
# step_id → (step_name, panel_tab_index)
|
||||||
|
# 2026-06-30 修正:step_id 改为 PANEL_REGISTRY 格式,与 build_missing_items 输出一致
|
||||||
STEP_TAB_MAP = {
|
STEP_TAB_MAP = {
|
||||||
"step1": ("水域掩膜", 0),
|
"step1": ("水域掩膜", 0),
|
||||||
"step2": ("耀斑检测", 1),
|
"step2": ("耀斑检测", 1),
|
||||||
"step3": ("耀斑去除", 2),
|
"step3": ("耀斑去除", 2),
|
||||||
"step4": ("数据清洗", 3),
|
"step4_sampling": ("采样点布设", 3),
|
||||||
"step5": ("特征构建", 4),
|
"step5_clean": ("数据清洗", 4),
|
||||||
"step7": ("水质指数", 5),
|
"step6_feature": ("光谱特征提取", 5),
|
||||||
"step8_non_empirical_modeling": ("回归建模", 7),
|
"step7_index": ("水质指数计算", 6),
|
||||||
"step9": ("水色指数反演", 8),
|
"step8_ml_train": ("机器学习建模", 7),
|
||||||
"step10": ("采样点布设", 10),
|
"step9_ml_predict": ("机器学习预测", 8),
|
||||||
"step11_ml": ("监督预测", 11),
|
"step10_watercolor": ("水色指数反演", 9),
|
||||||
"step11": ("回归预测", 12),
|
"step11_map": ("专题图生成", 10),
|
||||||
"step14": ("专题图生成", 13),
|
"step12_viz": ("可视化展示", 11),
|
||||||
|
"step13_report": ("报告生成", 12),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, missing_items: List[MissingItem], parent=None):
|
def __init__(self, missing_items: List[MissingItem], parent=None):
|
||||||
@ -302,6 +304,12 @@ class PreflightDialog(QDialog):
|
|||||||
def build_missing_items(config: dict) -> List[MissingItem]:
|
def build_missing_items(config: dict) -> List[MissingItem]:
|
||||||
"""DAG-aware 预检:从 config 构建缺失项列表。
|
"""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 列表,
|
1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表,
|
||||||
构建「动态产物集合」dynamically_produced_keys。
|
构建「动态产物集合」dynamically_produced_keys。
|
||||||
@ -320,7 +328,29 @@ class PreflightDialog(QDialog):
|
|||||||
"""
|
"""
|
||||||
items: List[MissingItem] = []
|
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)
|
step1_enabled = step1_cfg.get('enabled', False)
|
||||||
|
|
||||||
# ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
|
# ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ──
|
||||||
@ -328,7 +358,7 @@ class PreflightDialog(QDialog):
|
|||||||
enabled_step_ids: Set[str] = set()
|
enabled_step_ids: Set[str] = set()
|
||||||
|
|
||||||
for step_spec in PIPELINE_STEPS:
|
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):
|
if not step_cfg.get('enabled', True):
|
||||||
continue
|
continue
|
||||||
enabled_step_ids.add(step_spec.step_id)
|
enabled_step_ids.add(step_spec.step_id)
|
||||||
@ -349,20 +379,20 @@ class PreflightDialog(QDialog):
|
|||||||
panel_tab_index=0, is_critical=True
|
panel_tab_index=0, is_critical=True
|
||||||
))
|
))
|
||||||
|
|
||||||
# ── step4 csv_path(纯外部输入,必须手动提供)───────────────
|
# ── step5_clean csv_path(纯外部输入,必须手动提供)───────────
|
||||||
step4_cfg = config.get('step4', {})
|
step4_cfg = _cfg('step4') # 映射到 step5_clean
|
||||||
step4_enabled = step4_cfg.get('enabled', True)
|
step4_enabled = step4_cfg.get('enabled', True)
|
||||||
if step4_enabled:
|
if step4_enabled:
|
||||||
csv_path = step4_cfg.get('csv_path')
|
csv_path = step4_cfg.get('csv_path')
|
||||||
if not csv_path:
|
if not csv_path:
|
||||||
items.append(MissingItem(
|
items.append(MissingItem(
|
||||||
step_id="step4", step_name="数据清洗",
|
step_id="step5_clean", step_name="数据清洗",
|
||||||
reason="请在「数据清洗」中填写「实测水质数据 CSV」",
|
reason="请在「数据清洗」中填写「实测水质数据 CSV」",
|
||||||
panel_tab_index=3
|
panel_tab_index=3
|
||||||
))
|
))
|
||||||
elif not os.path.isfile(csv_path):
|
elif not os.path.isfile(csv_path):
|
||||||
items.append(MissingItem(
|
items.append(MissingItem(
|
||||||
step_id="step4", step_name="数据清洗",
|
step_id="step5_clean", step_name="数据清洗",
|
||||||
reason=f"实测水质数据文件不存在:{csv_path}",
|
reason=f"实测水质数据文件不存在:{csv_path}",
|
||||||
panel_tab_index=3
|
panel_tab_index=3
|
||||||
))
|
))
|
||||||
@ -374,42 +404,44 @@ class PreflightDialog(QDialog):
|
|||||||
# ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
|
# ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ──
|
||||||
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
|
PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'}
|
||||||
_TAB_INDEX_MAP: Dict[str, int] = {
|
_TAB_INDEX_MAP: Dict[str, int] = {
|
||||||
"step1": 0, "step2": 1, "step3": 2, "step4": 3,
|
"step1": 0, "step2": 1, "step3": 2,
|
||||||
"step5": 4, "step8": 5, "step7": 6,
|
"step4_sampling": 3, "step5_clean": 4, "step6_feature": 5,
|
||||||
"step8_non_empirical_modeling": 7, "step9": 8,
|
"step7_index": 6, "step8_ml_train": 7,
|
||||||
"step10": 9, "step11_ml": 10, "step11": 11,
|
"step9_ml_predict": 8, "step10_watercolor": 9,
|
||||||
"step12": 12, "step14": 13,
|
"step11_map": 10, "step12_viz": 11, "step13_report": 12,
|
||||||
}
|
}
|
||||||
_STEP_NAME_MAP: Dict[str, str] = {
|
_STEP_NAME_MAP: Dict[str, str] = {
|
||||||
"step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除",
|
"step1": "水域掩膜", "step2": "耀斑检测", "step3": "耀斑去除",
|
||||||
"step4": "数据清洗", "step5": "特征构建", "step8": "水质指数",
|
"step4_sampling": "采样点布设", "step5_clean": "数据清洗",
|
||||||
"step7": "监督建模", "step8_non_empirical_modeling": "回归建模",
|
"step6_feature": "光谱特征提取", "step7_index": "水质指数计算",
|
||||||
"step9": "自定义回归建模", "step10": "采样点布设",
|
"step8_ml_train": "机器学习建模", "step9_ml_predict": "机器学习预测",
|
||||||
"step11_ml": "监督预测", "step11": "回归预测",
|
"step10_watercolor": "水色指数反演", "step11_map": "专题图生成",
|
||||||
"step12": "自定义回归预测", "step14": "专题图生成",
|
"step12_viz": "可视化展示", "step13_report": "报告生成",
|
||||||
}
|
}
|
||||||
|
|
||||||
for step_spec in PIPELINE_STEPS:
|
for step_spec in PIPELINE_STEPS:
|
||||||
if step_spec.step_id not in enabled_step_ids:
|
if step_spec.step_id not in enabled_step_ids:
|
||||||
continue
|
continue
|
||||||
step_cfg = config.get(step_spec.step_id, {})
|
step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id
|
||||||
tab_idx = _TAB_INDEX_MAP.get(step_spec.step_id, 0)
|
# ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY step_id
|
||||||
step_name = _STEP_NAME_MAP.get(step_spec.step_id, step_spec.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:
|
for req_key in step_spec.required_input_files:
|
||||||
# ★★★ 高优先级硬编码白名单 ★★★
|
# ★★★ 高优先级硬编码白名单 ★★★
|
||||||
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
|
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
|
||||||
if req_key in ('boundary_path', 'boundary_shp_path'):
|
if req_key in ('boundary_path', 'boundary_shp_path'):
|
||||||
step1_cfg = config.get('step1', {})
|
_step1_cfg = _cfg('step1')
|
||||||
if step1_cfg.get('img_path') or step1_cfg.get('enabled', True):
|
if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True):
|
||||||
continue # 直接跳过,不判定为缺失
|
continue # 直接跳过,不判定为缺失
|
||||||
if req_key in PURE_EXTERNAL_INPUT_KEYS:
|
if req_key in PURE_EXTERNAL_INPUT_KEYS:
|
||||||
continue
|
continue
|
||||||
if req_key == 'formula_csv_path':
|
if req_key == 'formula_csv_path':
|
||||||
continue # ★ 底层完全可选,赦免
|
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/底层自动推导,赦免
|
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 自动回填,赦免
|
continue # ★ step1 执行则 panel 自动回填,赦免
|
||||||
cfg_val = step_cfg.get(req_key)
|
cfg_val = step_cfg.get(req_key)
|
||||||
if cfg_val and os.path.isfile(cfg_val):
|
if cfg_val and os.path.isfile(cfg_val):
|
||||||
@ -419,11 +451,11 @@ class PreflightDialog(QDialog):
|
|||||||
if req_key in dynamically_produced_keys:
|
if req_key in dynamically_produced_keys:
|
||||||
continue # ★ 前置步骤会生成,拓扑预判通过
|
continue # ★ 前置步骤会生成,拓扑预判通过
|
||||||
items.append(MissingItem(
|
items.append(MissingItem(
|
||||||
step_id=step_spec.step_id,
|
step_id=panel_step_id,
|
||||||
step_name=step_name,
|
step_name=step_name,
|
||||||
reason=f"缺少必需文件/目录 [{req_key}]",
|
reason=f"缺少必需文件/目录 [{req_key}]",
|
||||||
panel_tab_index=tab_idx,
|
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
|
return items
|
||||||
@ -188,12 +188,20 @@ except Exception as e:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class WorkerThread(QThread):
|
class WorkerThread(QThread):
|
||||||
"""后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。"""
|
"""后台工作线程,用于执行耗时任务(在工作线程内创建 Pipeline,避免阻塞 UI)。
|
||||||
|
|
||||||
|
2026-06-30 修复:
|
||||||
|
- stop() 不再直接调用 terminate(),改为先请求取消 + 等待线程自然结束 + 超时兜底
|
||||||
|
- total_steps 从 config 动态计算,不再硬编码为 9
|
||||||
|
"""
|
||||||
progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
|
progress_update = pyqtSignal(int, str) # 进度更新信号 (percentage, message)
|
||||||
log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
|
log_message = pyqtSignal(str, str) # 日志消息信号 (message, level: 'info'/'warning'/'error')
|
||||||
step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
|
step_completed = pyqtSignal(str, bool, str) # 步骤完成信号 (step_name, success, message)
|
||||||
finished = pyqtSignal(bool, str) # 完成信号 (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):
|
def __init__(self, work_dir: str, config, mode='full', step_name=None, skip_list=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.work_dir = str(work_dir)
|
self.work_dir = str(work_dir)
|
||||||
@ -205,7 +213,10 @@ class WorkerThread(QThread):
|
|||||||
self.is_running = True
|
self.is_running = True
|
||||||
self.current_step = None
|
self.current_step = None
|
||||||
self.step_count = 0
|
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=""):
|
def pipeline_callback(self, step_name, status, message=""):
|
||||||
"""Pipeline回调函数,用于接收步骤状态"""
|
"""Pipeline回调函数,用于接收步骤状态"""
|
||||||
@ -506,6 +517,35 @@ class WorkerThread(QThread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def stop(self):
|
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.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 生效
|
||||||
|
|||||||
@ -567,17 +567,22 @@ class Step10WatercolorPanel(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2. 回退:直接读 step4_sampling panel 的 output_file widget
|
# 2. 回退:直接读 step4_sampling panel 的 output_file widget
|
||||||
|
# 2026-06-30:panel widget 可能含幽灵占位路径,仅当文件确实存在时才采纳
|
||||||
if not sampling_path and main_window:
|
if not sampling_path and main_window:
|
||||||
step4_widget = getattr(main_window, 'step4_sampling', None)
|
step4_widget = getattr(main_window, 'step4_sampling', None)
|
||||||
if step4_widget and hasattr(step4_widget, 'output_file'):
|
if step4_widget and hasattr(step4_widget, 'output_file'):
|
||||||
sampling_path = step4_widget.output_file.get_path()
|
candidate = step4_widget.output_file.get_path()
|
||||||
else:
|
if candidate and os.path.isfile(candidate):
|
||||||
|
sampling_path = candidate
|
||||||
|
if not sampling_path:
|
||||||
# 通过 _panel_factory 懒加载查找
|
# 通过 _panel_factory 懒加载查找
|
||||||
factory = getattr(main_window, '_panel_factory', None)
|
factory = getattr(main_window, '_panel_factory', None)
|
||||||
if factory:
|
if factory:
|
||||||
step4_panel = factory.get_panel('step4_sampling')
|
step4_panel = factory.get_panel('step4_sampling')
|
||||||
if step4_panel and hasattr(step4_panel, 'output_file'):
|
if step4_panel and hasattr(step4_panel, 'output_file'):
|
||||||
sampling_path = step4_panel.output_file.get_path()
|
candidate = step4_panel.output_file.get_path()
|
||||||
|
if candidate and os.path.isfile(candidate):
|
||||||
|
sampling_path = candidate
|
||||||
|
|
||||||
# 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv
|
# 3. 终极回退:扫描 work_dir/4_sampling/sampling_spectra.csv
|
||||||
if not sampling_path and self.work_dir:
|
if not sampling_path and self.work_dir:
|
||||||
@ -593,14 +598,12 @@ class Step10WatercolorPanel(QWidget):
|
|||||||
).replace('\\', '/')
|
).replace('\\', '/')
|
||||||
self.sampling_csv_file.set_path(sampling_path)
|
self.sampling_csv_file.set_path(sampling_path)
|
||||||
|
|
||||||
# 自动填入输出目录(默认 work_dir/10_WaterIndex_CSV/)
|
# 自动填入输出目录(仅在为空时填入默认路径,不创建目录)
|
||||||
if self.work_dir:
|
if self.work_dir and not self.output_dir.get_path():
|
||||||
out_dir = os.path.join(
|
out_dir = os.path.join(
|
||||||
self.work_dir, '10_WaterIndex_CSV'
|
self.work_dir, '10_WaterIndex_CSV'
|
||||||
).replace('\\', '/')
|
).replace('\\', '/')
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
self.output_dir.set_path(out_dir)
|
||||||
if not self.output_dir.get_path():
|
|
||||||
self.output_dir.set_path(out_dir)
|
|
||||||
|
|
||||||
def _on_run_single_clicked(self):
|
def _on_run_single_clicked(self):
|
||||||
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""
|
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""
|
||||||
|
|||||||
@ -482,35 +482,32 @@ class Step11MapPanel(QWidget):
|
|||||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||||
if not factory: return
|
if not factory: return
|
||||||
|
|
||||||
# 1. 安全抓取 Step 9 的预测 CSV 目录
|
# 1. 安全抓取 Step 9 的预测 CSV 目录(仅当目录确实存在)
|
||||||
step9_panel = factory.get_panel('step9_ml_predict')
|
step9_panel = factory.get_panel('step9_ml_predict')
|
||||||
if step9_panel and hasattr(step9_panel, 'output_file'):
|
if step9_panel and hasattr(step9_panel, 'output_file'):
|
||||||
path = step9_panel.output_file.get_path()
|
path = step9_panel.output_file.get_path()
|
||||||
if path:
|
if path and os.path.isdir(path):
|
||||||
self.prediction_csv_dir_edit.setText(path)
|
self.prediction_csv_dir_edit.setText(path)
|
||||||
self.batch_mode_combo.setCurrentIndex(1)
|
self.batch_mode_combo.setCurrentIndex(1)
|
||||||
|
|
||||||
# 2. 安全抓取 Step 1 的真实掩膜文件(彻底拒绝瞎猜 roi.shp)
|
# 2. 安全抓取 Step 1 的真实掩膜文件(仅当文件确实存在)
|
||||||
step1_panel = factory.get_panel('step1')
|
step1_panel = factory.get_panel('step1')
|
||||||
if step1_panel:
|
if step1_panel:
|
||||||
use_ndwi = step1_panel.use_ndwi_radio.isChecked()
|
use_ndwi = step1_panel.use_ndwi_radio.isChecked()
|
||||||
# 根据用户在第1步的选择,拿真实的输出掩膜或导入的掩膜
|
|
||||||
if use_ndwi and hasattr(step1_panel, 'output_file'):
|
if use_ndwi and hasattr(step1_panel, 'output_file'):
|
||||||
path = step1_panel.output_file.get_path()
|
path = step1_panel.output_file.get_path()
|
||||||
elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
|
elif not use_ndwi and hasattr(step1_panel, 'mask_file'):
|
||||||
path = step1_panel.mask_file.get_path()
|
path = step1_panel.mask_file.get_path()
|
||||||
else:
|
else:
|
||||||
path = ""
|
path = ""
|
||||||
|
|
||||||
existing = self.boundary_file.get_path()
|
existing = self.boundary_file.get_path()
|
||||||
if path and not existing:
|
if path and not existing and os.path.exists(path):
|
||||||
self.boundary_file.set_path(path)
|
self.boundary_file.set_path(path)
|
||||||
|
|
||||||
# 3. 生成第 11 步的绝对输出目录 (杜绝保存到相对路径)
|
# 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)
|
||||||
if hasattr(self, 'work_dir') and self.work_dir:
|
if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path():
|
||||||
import os
|
|
||||||
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
|
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)
|
self.output_dir.set_path(out_dir)
|
||||||
|
|
||||||
def browse_output_dir(self):
|
def browse_output_dir(self):
|
||||||
|
|||||||
@ -1656,21 +1656,22 @@ class Step12VizPanel(QWidget):
|
|||||||
self.image_viewer.load_image(str(image_files[0]))
|
self.image_viewer.load_image(str(image_files[0]))
|
||||||
|
|
||||||
def _setup_prediction_output_dirs(self, work_path: Path):
|
def _setup_prediction_output_dirs(self, work_path: Path):
|
||||||
"""设置三个预测步骤的默认输出目录"""
|
"""收集预测输出目录路径信息(不创建目录,仅用于日志/调试)。
|
||||||
|
|
||||||
|
2026-06-30 修复:移除 mkdir 调用,不再在未运行 pipeline 时创建空目录。
|
||||||
|
目录创建统一留给各 pipeline 步骤在实际执行时处理。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
|
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
|
||||||
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
|
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
|
||||||
reg_dir = base_prediction_dir / "Regression_Model_Prediction"
|
reg_dir = base_prediction_dir / "Regression_Model_Prediction"
|
||||||
custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_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)
|
existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()]
|
||||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
if existing:
|
||||||
# 旧的 self.step11_ml_panel/step11_panel/step12_panel 在 Step12VizPanel 上不存在,是死代码。
|
print(f"预测输出目录已存在: {existing}")
|
||||||
# 三个目录的真实默认值在用户首次浏览 / 自动填充时由各 panel 自己的 _get_default_work_dir 路径产出。
|
|
||||||
# 这里仅做目录创建 + 提示输出,便于用户在工作目录树中能看到预测输出位置。
|
|
||||||
print(f"预测输出目录已创建:\n ML: {ml_dir}\n Reg: {reg_dir}\n Custom: {custom_dir}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"设置预测输出目录失败: {e}")
|
print(f"读取预测输出目录信息失败: {e}")
|
||||||
|
|
||||||
def on_tree_item_clicked(self, item, column):
|
def on_tree_item_clicked(self, item, column):
|
||||||
"""目录树项点击事件"""
|
"""目录树项点击事件"""
|
||||||
|
|||||||
@ -114,6 +114,9 @@ class Step1Panel(QWidget):
|
|||||||
config_group.setLayout(config_layout)
|
config_group.setLayout(config_layout)
|
||||||
main_layout.addWidget(config_group)
|
main_layout.addWidget(config_group)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 卡片 3:输出与执行(与 Step8 对齐的现代化卡片风格)
|
||||||
|
# ==========================================
|
||||||
output_group = QGroupBox("🚀 输出与执行")
|
output_group = QGroupBox("🚀 输出与执行")
|
||||||
output_layout = QVBoxLayout()
|
output_layout = QVBoxLayout()
|
||||||
output_layout.setSpacing(16)
|
output_layout.setSpacing(16)
|
||||||
@ -124,11 +127,10 @@ class Step1Panel(QWidget):
|
|||||||
"Mask Files (*.dat *.tif);;All Files (*.*)",
|
"Mask Files (*.dat *.tif);;All Files (*.*)",
|
||||||
mode="save"
|
mode="save"
|
||||||
)
|
)
|
||||||
self.output_file.label.setMinimumWidth(100)
|
|
||||||
self.output_file.line_edit.setPlaceholderText("water_mask.dat")
|
self.output_file.line_edit.setPlaceholderText("water_mask.dat")
|
||||||
output_layout.addWidget(self.output_file)
|
output_layout.addWidget(self.output_file)
|
||||||
|
|
||||||
# 完美对齐的底部按钮栏(已移除多余的启用步骤选项)
|
# 完美对齐的底部按钮栏
|
||||||
action_layout = QHBoxLayout()
|
action_layout = QHBoxLayout()
|
||||||
action_layout.addStretch()
|
action_layout.addStretch()
|
||||||
|
|
||||||
@ -140,6 +142,8 @@ class Step1Panel(QWidget):
|
|||||||
|
|
||||||
output_layout.addLayout(action_layout)
|
output_layout.addLayout(action_layout)
|
||||||
output_group.setLayout(output_layout)
|
output_group.setLayout(output_layout)
|
||||||
|
|
||||||
|
# 将打包好的输出卡片添加到主 layout 中
|
||||||
main_layout.addWidget(output_group)
|
main_layout.addWidget(output_group)
|
||||||
|
|
||||||
self.use_existing_radio.toggled.connect(self.update_ui_state)
|
self.use_existing_radio.toggled.connect(self.update_ui_state)
|
||||||
@ -164,10 +168,12 @@ class Step1Panel(QWidget):
|
|||||||
self._auto_fill_output_path()
|
self._auto_fill_output_path()
|
||||||
|
|
||||||
def _auto_fill_output_path(self):
|
def _auto_fill_output_path(self):
|
||||||
|
"""仅在输出框为空时填入默认路径;不创建目录(留给 pipeline 执行时创建)。"""
|
||||||
if not hasattr(self, 'work_dir') or not self.work_dir:
|
if not hasattr(self, 'work_dir') or not self.work_dir:
|
||||||
return
|
return
|
||||||
|
if self.output_file.get_path():
|
||||||
|
return # 用户已手动指定,不覆盖
|
||||||
output_dir = resolve_subdir(self.work_dir, 'water_mask')
|
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('\\', '/')
|
default_output_path = os.path.join(output_dir, "water_mask_out.dat").replace('\\', '/')
|
||||||
self.output_file.set_path(default_output_path)
|
self.output_file.set_path(default_output_path)
|
||||||
|
|
||||||
|
|||||||
@ -192,13 +192,15 @@ class Step2Panel(QWidget):
|
|||||||
if mask_path:
|
if mask_path:
|
||||||
if not os.path.isabs(mask_path):
|
if not os.path.isabs(mask_path):
|
||||||
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
||||||
self.water_mask_file.set_path(mask_path)
|
# 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散)
|
||||||
|
if os.path.exists(mask_path):
|
||||||
|
self.water_mask_file.set_path(mask_path)
|
||||||
|
|
||||||
if self.work_dir:
|
if self.work_dir:
|
||||||
output_dir = resolve_subdir(self.work_dir, 'glint_detection')
|
if not self.output_file.get_path():
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
output_dir = resolve_subdir(self.work_dir, 'glint_detection')
|
||||||
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
|
default_output_path = os.path.join(output_dir, "severe_glint_area.dat").replace('\\', '/')
|
||||||
self.output_file.set_path(default_output_path)
|
self.output_file.set_path(default_output_path)
|
||||||
else:
|
else:
|
||||||
self.output_file.set_path("")
|
self.output_file.set_path("")
|
||||||
|
|
||||||
|
|||||||
@ -317,13 +317,15 @@ class Step3Panel(QWidget):
|
|||||||
if mask_path:
|
if mask_path:
|
||||||
if not os.path.isabs(mask_path):
|
if not os.path.isabs(mask_path):
|
||||||
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
||||||
self.water_mask_file.set_path(mask_path)
|
# 仅当上游文件确实存在时才自动填入(防止幽灵路径级联扩散)
|
||||||
|
if os.path.exists(mask_path):
|
||||||
|
self.water_mask_file.set_path(mask_path)
|
||||||
|
|
||||||
if self.work_dir:
|
if self.work_dir:
|
||||||
output_dir = resolve_subdir(self.work_dir, 'deglint')
|
if not self.output_file.get_path():
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
output_dir = resolve_subdir(self.work_dir, 'deglint')
|
||||||
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
|
default_output_path = os.path.join(output_dir, "deglint_image.bsq").replace('\\', '/')
|
||||||
self.output_file.set_path(default_output_path)
|
self.output_file.set_path(default_output_path)
|
||||||
else:
|
else:
|
||||||
self.output_file.set_path("")
|
self.output_file.set_path("")
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Step4 面板 - 采样点布设 (已移除“启用此步骤”)
|
Step4 面板 - 采样点布设(内嵌交互式光谱探针视图)
|
||||||
|
|
||||||
|
2026-06-30 重构:
|
||||||
|
- 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图
|
||||||
|
- 1×2 子图:ax1 散点图 + ax2 光谱曲线
|
||||||
|
- Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线
|
||||||
|
- NavigationToolbar2QT 工具栏自带保存/缩放/平移
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
if _HERE not in sys.path:
|
if _HERE not in sys.path:
|
||||||
sys.path.insert(0, _HERE)
|
sys.path.insert(0, _HERE)
|
||||||
@ -16,9 +25,16 @@ from _step_path_resolver import resolve_subdir
|
|||||||
from PyQt5.QtCore import QTimer, Qt
|
from PyQt5.QtCore import QTimer, Qt
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
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.components.custom_widgets import FileSelectWidget
|
||||||
from src.gui.dialogs import SamplingViewerDialog
|
from src.gui.dialogs import SamplingViewerDialog
|
||||||
from src.gui.styles import ModernStylesheet
|
from src.gui.styles import ModernStylesheet
|
||||||
@ -27,15 +43,39 @@ from src.gui.styles import ModernStylesheet
|
|||||||
class Step4SamplingPanel(QWidget):
|
class Step4SamplingPanel(QWidget):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
# 交互状态
|
||||||
|
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()
|
self.init_ui()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# UI 构建
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
||||||
|
|
||||||
main_layout = QVBoxLayout()
|
# ── 顶层:水平分栏 (QSplitter) ──
|
||||||
main_layout.setContentsMargins(24, 24, 24, 24)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
main_layout.setSpacing(20)
|
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_group = QGroupBox("📁 输入数据")
|
||||||
input_layout = QVBoxLayout()
|
input_layout = QVBoxLayout()
|
||||||
input_layout.setSpacing(16)
|
input_layout.setSpacing(16)
|
||||||
@ -52,8 +92,9 @@ class Step4SamplingPanel(QWidget):
|
|||||||
input_layout.addWidget(self.deglint_img_file)
|
input_layout.addWidget(self.deglint_img_file)
|
||||||
input_layout.addWidget(self.water_mask_file)
|
input_layout.addWidget(self.water_mask_file)
|
||||||
input_group.setLayout(input_layout)
|
input_group.setLayout(input_layout)
|
||||||
main_layout.addWidget(input_group)
|
left_layout.addWidget(input_group)
|
||||||
|
|
||||||
|
# --- 卡片 2:采样参数 ---
|
||||||
params_group = QGroupBox("⚙️ 采样参数")
|
params_group = QGroupBox("⚙️ 采样参数")
|
||||||
params_layout = QFormLayout()
|
params_layout = QFormLayout()
|
||||||
params_layout.setSpacing(16)
|
params_layout.setSpacing(16)
|
||||||
@ -80,14 +121,14 @@ class Step4SamplingPanel(QWidget):
|
|||||||
self.chunk_size.setMinimumWidth(120)
|
self.chunk_size.setMinimumWidth(120)
|
||||||
params_layout.addRow("内存处理块大小:", self.chunk_size)
|
params_layout.addRow("内存处理块大小:", self.chunk_size)
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QCheckBox
|
|
||||||
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
|
self.use_adaptive_sampling = QCheckBox("启用自适应边缘采样")
|
||||||
self.use_adaptive_sampling.setChecked(True)
|
self.use_adaptive_sampling.setChecked(True)
|
||||||
params_layout.addRow("智能模式:", self.use_adaptive_sampling)
|
params_layout.addRow("智能模式:", self.use_adaptive_sampling)
|
||||||
|
|
||||||
params_group.setLayout(params_layout)
|
params_group.setLayout(params_layout)
|
||||||
main_layout.addWidget(params_group)
|
left_layout.addWidget(params_group)
|
||||||
|
|
||||||
|
# --- 卡片 3:输出与执行 ---
|
||||||
output_group = QGroupBox("🚀 输出与执行")
|
output_group = QGroupBox("🚀 输出与执行")
|
||||||
output_layout = QVBoxLayout()
|
output_layout = QVBoxLayout()
|
||||||
output_layout.setSpacing(16)
|
output_layout.setSpacing(16)
|
||||||
@ -104,33 +145,104 @@ class Step4SamplingPanel(QWidget):
|
|||||||
action_layout = QHBoxLayout()
|
action_layout = QHBoxLayout()
|
||||||
action_layout.addStretch()
|
action_layout.addStretch()
|
||||||
|
|
||||||
self.preview_btn = QPushButton("交互式预览采样点")
|
self.refresh_btn = QPushButton("🔄 刷新视图")
|
||||||
self.preview_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
|
self.refresh_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('normal'))
|
||||||
self.preview_btn.setEnabled(False)
|
self.refresh_btn.setEnabled(False)
|
||||||
self.preview_btn.setMinimumWidth(160)
|
self.refresh_btn.setMinimumWidth(140)
|
||||||
self.preview_btn.clicked.connect(self._open_sampling_viewer)
|
self.refresh_btn.setToolTip("重新加载 CSV 并渲染采样点散点图")
|
||||||
|
self.refresh_btn.clicked.connect(self._on_refresh_clicked)
|
||||||
|
|
||||||
self.run_btn = QPushButton("独立运行步骤")
|
self.run_btn = QPushButton("独立运行步骤")
|
||||||
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
|
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('primary'))
|
||||||
self.run_btn.setMinimumWidth(140)
|
self.run_btn.setMinimumWidth(140)
|
||||||
self.run_btn.clicked.connect(self._on_run_single_clicked)
|
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)
|
action_layout.addWidget(self.run_btn)
|
||||||
|
|
||||||
output_layout.addLayout(action_layout)
|
output_layout.addLayout(action_layout)
|
||||||
output_group.setLayout(output_layout)
|
output_group.setLayout(output_layout)
|
||||||
main_layout.addWidget(output_group)
|
left_layout.addWidget(output_group)
|
||||||
|
|
||||||
main_layout.addStretch()
|
left_layout.addStretch()
|
||||||
self.setLayout(main_layout)
|
left_widget.setLayout(left_layout)
|
||||||
|
left_widget.setMinimumWidth(360)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# 右侧:可视化区(嵌入式 Matplotlib 视图)
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
right_widget = QWidget()
|
||||||
|
right_layout = QVBoxLayout()
|
||||||
|
right_layout.setContentsMargins(12, 24, 24, 24)
|
||||||
|
right_layout.setSpacing(0)
|
||||||
|
|
||||||
|
viz_group = QGroupBox("📊 采样点交互式探索")
|
||||||
|
viz_layout = QVBoxLayout()
|
||||||
|
viz_layout.setContentsMargins(8, 20, 8, 8)
|
||||||
|
viz_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Matplotlib 画布(1×2 子图:散点图 + 光谱曲线)
|
||||||
|
self._fig = Figure(figsize=(9, 5))
|
||||||
|
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._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_scatter.set_xlabel("X 坐标")
|
||||||
|
self._ax_scatter.set_ylabel("Y 坐标")
|
||||||
|
self._ax_scatter.text(0.5, 0.5, "等待采样数据生成...\n\n请先配置参数并运行步骤\n或选择已有的 CSV 后刷新",
|
||||||
|
ha='center', va='center', transform=self._ax_scatter.transAxes,
|
||||||
|
fontsize=12, color='#888888')
|
||||||
|
self._ax_scatter.grid(False)
|
||||||
|
|
||||||
|
self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_spectrum.set_xlabel("波长 (nm)")
|
||||||
|
self._ax_spectrum.set_ylabel("反射率")
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
|
||||||
|
ha='center', va='center', transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#888888')
|
||||||
|
self._ax_spectrum.grid(False)
|
||||||
|
|
||||||
|
self._fig.tight_layout(pad=2.0)
|
||||||
|
|
||||||
|
# 工具栏(自带保存/缩放/平移)
|
||||||
|
self._toolbar = NavigationToolbar(self._canvas, self)
|
||||||
|
viz_layout.addWidget(self._toolbar)
|
||||||
|
viz_layout.addWidget(self._canvas)
|
||||||
|
|
||||||
|
viz_group.setLayout(viz_layout)
|
||||||
|
right_layout.addWidget(viz_group)
|
||||||
|
right_widget.setLayout(right_layout)
|
||||||
|
|
||||||
|
# ── 组装分栏 ──
|
||||||
|
splitter.addWidget(left_widget)
|
||||||
|
splitter.addWidget(right_widget)
|
||||||
|
splitter.setSizes([420, 680]) # 初始比例 ≈ 38:62
|
||||||
|
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 = QTimer(self)
|
||||||
self._status_timer.timeout.connect(self._check_csv_exists)
|
self._status_timer.timeout.connect(self._check_csv_and_auto_render)
|
||||||
self._status_timer.start(2000)
|
self._status_timer.start(5000)
|
||||||
|
|
||||||
self.output_file.line_edit.textChanged.connect(self._on_output_changed)
|
self.output_file.line_edit.textChanged.connect(self._on_output_changed)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 配置读写(保持不变)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def get_config(self):
|
def get_config(self):
|
||||||
config = {
|
config = {
|
||||||
'interval': self.interval.value(),
|
'interval': self.interval.value(),
|
||||||
@ -182,7 +294,8 @@ class Step4SamplingPanel(QWidget):
|
|||||||
if deglint_path:
|
if deglint_path:
|
||||||
if not os.path.isabs(deglint_path):
|
if not os.path.isabs(deglint_path):
|
||||||
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
|
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
|
||||||
self.deglint_img_file.set_path(deglint_path)
|
if os.path.exists(deglint_path):
|
||||||
|
self.deglint_img_file.set_path(deglint_path)
|
||||||
|
|
||||||
water_mask_path = None
|
water_mask_path = None
|
||||||
if pipeline and hasattr(pipeline, 'step_outputs'):
|
if pipeline and hasattr(pipeline, 'step_outputs'):
|
||||||
@ -215,14 +328,22 @@ class Step4SamplingPanel(QWidget):
|
|||||||
if water_mask_path:
|
if water_mask_path:
|
||||||
if not os.path.isabs(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('\\', '/')
|
water_mask_path = os.path.join(self.work_dir or '', water_mask_path).replace('\\', '/')
|
||||||
self.water_mask_file.set_path(water_mask_path)
|
if os.path.exists(water_mask_path):
|
||||||
|
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')
|
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.output_file.set_path(output_path.replace('\\', '/'))
|
||||||
|
|
||||||
self._check_csv_exists()
|
self._check_csv_exists()
|
||||||
|
# 若 CSV 已存在,尝试自动渲染
|
||||||
|
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):
|
def _on_run_single_clicked(self):
|
||||||
from src.gui.core.event_bus import global_event_bus
|
from src.gui.core.event_bus import global_event_bus
|
||||||
@ -236,20 +357,354 @@ class Step4SamplingPanel(QWidget):
|
|||||||
'config': config,
|
'config': config,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# CSV 状态检测
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _check_csv_exists(self):
|
def _check_csv_exists(self):
|
||||||
csv_path = self.output_file.get_path()
|
csv_path = self.output_file.get_path()
|
||||||
enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_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
|
return enabled
|
||||||
|
|
||||||
def _on_output_changed(self, _text=None):
|
def _on_output_changed(self, _text=None):
|
||||||
self._check_csv_exists()
|
self._check_csv_exists()
|
||||||
|
|
||||||
|
def _check_csv_and_auto_render(self):
|
||||||
|
"""定时器回调:检测到新 CSV 出现时自动渲染一次。"""
|
||||||
|
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):
|
||||||
|
"""检测坐标列,返回 (x_col, y_col) 或 (None, None)。
|
||||||
|
|
||||||
|
优先级:pixel_x/pixel_y → longitude/latitude → lon/lat →
|
||||||
|
X/Y → UTM_X/UTM_Y → 任何含 'x'/'y' 关键字的列
|
||||||
|
"""
|
||||||
|
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 / y 的数值列
|
||||||
|
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):
|
||||||
|
"""检测光谱波段列(纯数字列名,值域在 200–3000 nm 之间)。
|
||||||
|
|
||||||
|
优先使用列名可解析为 float 且在波长范围内的列;
|
||||||
|
否则回退到位置索引(跳过坐标列和已知元数据列)。
|
||||||
|
"""
|
||||||
|
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 → 检测坐标/波段列 → 绘制 1×2 子图。"""
|
||||||
|
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)
|
||||||
|
if not band_cols:
|
||||||
|
# 仅显示散点图,光谱子图留空
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 缓存
|
||||||
|
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
|
||||||
|
|
||||||
|
# 清除 Annotation
|
||||||
|
if self._annot is not None:
|
||||||
|
try:
|
||||||
|
self._annot.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._annot = None
|
||||||
|
|
||||||
|
# ── 绘制 ax1:散点图 ──
|
||||||
|
self._ax_scatter.clear()
|
||||||
|
x = df[x_col].values
|
||||||
|
y = df[y_col].values
|
||||||
|
|
||||||
|
self._scatter = self._ax_scatter.scatter(
|
||||||
|
x, y,
|
||||||
|
c='#0078D7', alpha=0.7, edgecolors='white',
|
||||||
|
linewidth=0.5, s=40, picker=True, zorder=3
|
||||||
|
)
|
||||||
|
self._ax_scatter.set_xlabel(str(x_col), fontsize=10)
|
||||||
|
self._ax_scatter.set_ylabel(str(y_col), fontsize=10)
|
||||||
|
self._ax_scatter.set_title(f"采样点分布 (共 {len(df)} 个点)", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_scatter.grid(True, alpha=0.3, linestyle='--')
|
||||||
|
self._ax_scatter.set_facecolor('#F8F9FA')
|
||||||
|
|
||||||
|
# ── 绘制 ax2:光谱曲线(初始状态)──
|
||||||
|
self._ax_spectrum.clear()
|
||||||
|
if band_cols:
|
||||||
|
self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10)
|
||||||
|
self._ax_spectrum.set_ylabel("反射率", fontsize=10)
|
||||||
|
self._ax_spectrum.grid(True, alpha=0.3, linestyle='--')
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线",
|
||||||
|
ha='center', va='center', transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#999999')
|
||||||
|
else:
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "缺少光谱数据列",
|
||||||
|
ha='center', va='center', transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#999999')
|
||||||
|
|
||||||
|
self._fig.tight_layout(pad=2.0)
|
||||||
|
self._canvas.draw()
|
||||||
|
|
||||||
|
def _show_empty_state(self, message: str):
|
||||||
|
"""在右侧画布显示提示信息。"""
|
||||||
|
self._ax_scatter.clear()
|
||||||
|
self._ax_scatter.set_title("采样点分布", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_scatter.text(0.5, 0.5, message, ha='center', va='center',
|
||||||
|
transform=self._ax_scatter.transAxes,
|
||||||
|
fontsize=12, color='#888888')
|
||||||
|
self._ax_scatter.grid(False)
|
||||||
|
|
||||||
|
self._ax_spectrum.clear()
|
||||||
|
self._ax_spectrum.set_title("光谱曲线", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "等待数据...",
|
||||||
|
ha='center', va='center', transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#888888')
|
||||||
|
self._ax_spectrum.grid(False)
|
||||||
|
|
||||||
|
self._fig.tight_layout(pad=2.0)
|
||||||
|
self._canvas.draw()
|
||||||
|
self._df = None
|
||||||
|
self._last_render_path = None
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 交互事件 (Hover & Click)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _on_hover(self, event):
|
||||||
|
"""悬停:在散点旁显示坐标 + ID 提示框。"""
|
||||||
|
if event.inaxes != self._ax_scatter:
|
||||||
|
# 鼠标离开 ax1 → 隐藏 annotation
|
||||||
|
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
|
||||||
|
|
||||||
|
# 检测是否悬停在散点上
|
||||||
|
contains, info = self._scatter.contains(event)
|
||||||
|
if not contains or info is None or 'ind' not in info or len(info['ind']) == 0:
|
||||||
|
if self._annot is not None:
|
||||||
|
try:
|
||||||
|
self._annot.set_visible(False)
|
||||||
|
self._canvas.draw_idle()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = info['ind'][0]
|
||||||
|
row = self._df.iloc[idx]
|
||||||
|
x_val = row[self._x_col]
|
||||||
|
y_val = row[self._y_col]
|
||||||
|
|
||||||
|
# 创建或更新 Annotation
|
||||||
|
text = f"#{idx}\n({x_val:.4f}, {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
|
||||||
|
|
||||||
|
# 检测是否点击在散点上
|
||||||
|
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]
|
||||||
|
row = self._df.iloc[idx]
|
||||||
|
self._highlight_idx = idx
|
||||||
|
|
||||||
|
# ── 高亮选中点 ──
|
||||||
|
x = self._df[self._x_col].values
|
||||||
|
y = self._df[self._y_col].values
|
||||||
|
|
||||||
|
colors = ['#0078D7'] * len(self._df)
|
||||||
|
sizes = [40] * len(self._df)
|
||||||
|
colors[idx] = '#E74C3C'
|
||||||
|
sizes[idx] = 80
|
||||||
|
|
||||||
|
self._ax_scatter.clear()
|
||||||
|
self._scatter = self._ax_scatter.scatter(
|
||||||
|
x, y, c=colors, s=sizes,
|
||||||
|
alpha=0.7, edgecolors='white', linewidth=0.5,
|
||||||
|
picker=True, zorder=3
|
||||||
|
)
|
||||||
|
# 将选中点提升到顶层
|
||||||
|
self._ax_scatter.scatter(
|
||||||
|
[x[idx]], [y[idx]],
|
||||||
|
c='#E74C3C', s=100,
|
||||||
|
alpha=0.9, edgecolors='white', linewidth=1.5,
|
||||||
|
zorder=5
|
||||||
|
)
|
||||||
|
self._ax_scatter.set_xlabel(str(self._x_col), fontsize=10)
|
||||||
|
self._ax_scatter.set_ylabel(str(self._y_col), fontsize=10)
|
||||||
|
self._ax_scatter.set_title(f"采样点分布 (共 {len(self._df)} 个点)", fontsize=11, fontweight='bold')
|
||||||
|
self._ax_scatter.grid(True, alpha=0.3, linestyle='--')
|
||||||
|
self._ax_scatter.set_facecolor('#F8F9FA')
|
||||||
|
|
||||||
|
# ── 绘制光谱曲线 ──
|
||||||
|
self._ax_spectrum.clear()
|
||||||
|
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.8
|
||||||
|
)
|
||||||
|
self._ax_spectrum.fill_between(wavelengths, reflectance, alpha=0.1, color='#0078D7')
|
||||||
|
self._ax_spectrum.set_xlabel("波长 (nm)", fontsize=10)
|
||||||
|
self._ax_spectrum.set_ylabel("反射率", fontsize=10)
|
||||||
|
self._ax_spectrum.set_title(f"样本 #{idx} 的光谱曲线 ({len(wavelengths)} 个波段)",
|
||||||
|
fontsize=11, fontweight='bold')
|
||||||
|
self._ax_spectrum.grid(True, alpha=0.3, linestyle='--')
|
||||||
|
else:
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "该样本无有效光谱数据",
|
||||||
|
ha='center', va='center',
|
||||||
|
transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#999999')
|
||||||
|
else:
|
||||||
|
self._ax_spectrum.text(0.5, 0.5, "缺少光谱波段列\n无法绘制光谱",
|
||||||
|
ha='center', va='center',
|
||||||
|
transform=self._ax_spectrum.transAxes,
|
||||||
|
fontsize=12, color='#999999')
|
||||||
|
|
||||||
|
self._fig.tight_layout(pad=2.0)
|
||||||
|
self._canvas.draw()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 旧版弹窗查看器(保留,供外部调用)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _open_sampling_viewer(self):
|
def _open_sampling_viewer(self):
|
||||||
|
"""打开独立的 SamplingViewerDialog 弹窗(保留兼容)。"""
|
||||||
csv_path = self.output_file.get_path()
|
csv_path = self.output_file.get_path()
|
||||||
if not csv_path or not os.path.exists(csv_path):
|
if not csv_path or not os.path.exists(csv_path):
|
||||||
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
|
QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。")
|
||||||
return
|
return
|
||||||
dialog = SamplingViewerDialog(csv_path, self)
|
dialog = SamplingViewerDialog(csv_path, self)
|
||||||
dialog.exec_()
|
dialog.exec_()
|
||||||
self._check_csv_exists()
|
self._check_csv_exists()
|
||||||
|
|||||||
@ -190,12 +190,11 @@ class Step5CleanPanel(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.work_dir = None
|
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')
|
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('\\', '/')
|
default_output_path = os.path.join(output_dir, "processed_data.csv").replace('\\', '/')
|
||||||
self.output_file.set_path(default_output_path)
|
self.output_file.set_path(default_output_path)
|
||||||
else:
|
elif not self.work_dir:
|
||||||
self.output_file.set_path("")
|
self.output_file.set_path("")
|
||||||
|
|
||||||
def _on_run_single_clicked(self):
|
def _on_run_single_clicked(self):
|
||||||
|
|||||||
@ -234,14 +234,16 @@ class Step6FeaturePanel(QWidget):
|
|||||||
if mask_path:
|
if mask_path:
|
||||||
if not os.path.isabs(mask_path):
|
if not os.path.isabs(mask_path):
|
||||||
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
mask_path = os.path.join(self.work_dir or '', mask_path).replace('\\', '/')
|
||||||
self.water_mask_file.set_path(mask_path)
|
if os.path.exists(mask_path):
|
||||||
|
self.water_mask_file.set_path(mask_path)
|
||||||
|
|
||||||
if hasattr(main_window, 'step2_panel'):
|
if hasattr(main_window, 'step2_panel'):
|
||||||
glint_path = main_window.step2_panel.output_file.get_path()
|
glint_path = main_window.step2_panel.output_file.get_path()
|
||||||
if glint_path:
|
if glint_path:
|
||||||
if not os.path.isabs(glint_path):
|
if not os.path.isabs(glint_path):
|
||||||
glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/')
|
glint_path = os.path.join(self.work_dir or '', glint_path).replace('\\', '/')
|
||||||
self.glint_mask_file.set_path(glint_path)
|
if os.path.exists(glint_path):
|
||||||
|
self.glint_mask_file.set_path(glint_path)
|
||||||
|
|
||||||
deglint_path = None
|
deglint_path = None
|
||||||
if pipeline and hasattr(pipeline, 'step_outputs'):
|
if pipeline and hasattr(pipeline, 'step_outputs'):
|
||||||
@ -273,15 +275,14 @@ class Step6FeaturePanel(QWidget):
|
|||||||
if not os.path.isabs(deglint_path):
|
if not os.path.isabs(deglint_path):
|
||||||
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
|
deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/')
|
||||||
existing_deglint = self.deglint_img_file.get_path()
|
existing_deglint = self.deglint_img_file.get_path()
|
||||||
if not existing_deglint or not existing_deglint.strip():
|
if (not existing_deglint or not existing_deglint.strip()) and os.path.exists(deglint_path):
|
||||||
self.deglint_img_file.set_path(deglint_path)
|
self.deglint_img_file.set_path(deglint_path)
|
||||||
|
|
||||||
if self.work_dir:
|
if self.work_dir and not self.output_file.get_path():
|
||||||
output_dir = resolve_subdir(self.work_dir, 'spectral_feature')
|
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('\\', '/')
|
default_output_path = os.path.join(output_dir, "training_spectra.csv").replace('\\', '/')
|
||||||
self.output_file.set_path(default_output_path)
|
self.output_file.set_path(default_output_path)
|
||||||
else:
|
elif not self.work_dir:
|
||||||
self.output_file.set_path("")
|
self.output_file.set_path("")
|
||||||
|
|
||||||
if main_window and hasattr(main_window, 'step5_clean_panel'):
|
if main_window and hasattr(main_window, 'step5_clean_panel'):
|
||||||
@ -292,7 +293,7 @@ class Step6FeaturePanel(QWidget):
|
|||||||
self.work_dir or '', step5_clean_output_path
|
self.work_dir or '', step5_clean_output_path
|
||||||
).replace('\\', '/')
|
).replace('\\', '/')
|
||||||
existing_csv = self.csv_file.get_path()
|
existing_csv = self.csv_file.get_path()
|
||||||
if not existing_csv or not existing_csv.strip():
|
if (not existing_csv or not existing_csv.strip()) and os.path.exists(step5_clean_output_path):
|
||||||
self.csv_file.set_path(step5_clean_output_path)
|
self.csv_file.set_path(step5_clean_output_path)
|
||||||
|
|
||||||
def _on_run_single_clicked(self):
|
def _on_run_single_clicked(self):
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import sys
|
|||||||
import csv
|
import csv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
||||||
QLabel, QPushButton, QMessageBox, QListWidget,
|
QLabel, QPushButton, QMessageBox, QListWidget,
|
||||||
@ -263,7 +265,15 @@ class Step7InversionPanel(QWidget):
|
|||||||
output_layout.setSpacing(16)
|
output_layout.setSpacing(16)
|
||||||
output_layout.setContentsMargins(20, 24, 20, 20)
|
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 = QHBoxLayout()
|
||||||
action_layout.addStretch()
|
action_layout.addStretch()
|
||||||
|
|
||||||
@ -328,6 +338,9 @@ class Step7InversionPanel(QWidget):
|
|||||||
'formula_names': selected_names,
|
'formula_names': selected_names,
|
||||||
'enabled': True # 默认启用
|
'enabled': True # 默认启用
|
||||||
}
|
}
|
||||||
|
output_path = self.output_file.get_path()
|
||||||
|
if output_path:
|
||||||
|
config['output_path'] = output_path
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def set_config(self, config: dict):
|
def set_config(self, config: dict):
|
||||||
@ -347,6 +360,9 @@ class Step7InversionPanel(QWidget):
|
|||||||
item.setCheckState(state)
|
item.setCheckState(state)
|
||||||
self.formula_list.blockSignals(False)
|
self.formula_list.blockSignals(False)
|
||||||
|
|
||||||
|
if 'output_path' in config:
|
||||||
|
self.output_file.set_path(config['output_path'])
|
||||||
|
|
||||||
def _load_formulas_from_csv(self):
|
def _load_formulas_from_csv(self):
|
||||||
"""解析公式 CSV 文件并填充列表框"""
|
"""解析公式 CSV 文件并填充列表框"""
|
||||||
csv_path = self.formula_file.get_path()
|
csv_path = self.formula_file.get_path()
|
||||||
@ -439,7 +455,7 @@ class Step7InversionPanel(QWidget):
|
|||||||
"""从全局配置/Pipeline 同步工作目录。
|
"""从全局配置/Pipeline 同步工作目录。
|
||||||
|
|
||||||
step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config
|
step6 的训练数据已由 PANEL_REGISTRY 的 dependencies 自动通过 set_config
|
||||||
注入到 self.training_data_widget;此处仅缓存 work_dir,
|
注入到 self.training_data_widget;此处仅缓存 work_dir 并填入默认输出路径,
|
||||||
不重复拉取,避免与 panel_factory 注入路径冲突。
|
不重复拉取,避免与 panel_factory 注入路径冲突。
|
||||||
"""
|
"""
|
||||||
if work_dir:
|
if work_dir:
|
||||||
@ -448,3 +464,11 @@ class Step7InversionPanel(QWidget):
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
self.work_dir = None
|
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("")
|
||||||
|
|||||||
@ -85,10 +85,11 @@ class Step8MlTrainPanel(QWidget):
|
|||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
|
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(24, 24, 24, 24)
|
||||||
# 标题
|
layout.setSpacing(20)
|
||||||
|
|
||||||
|
|
||||||
# 训练数据文件(用于独立运行)
|
# 训练数据文件(用于独立运行)
|
||||||
self.training_csv_file = FileSelectWidget(
|
self.training_csv_file = FileSelectWidget(
|
||||||
@ -547,13 +548,11 @@ class Step8MlTrainPanel(QWidget):
|
|||||||
if candidate:
|
if candidate:
|
||||||
self.training_csv_file.set_path(candidate)
|
self.training_csv_file.set_path(candidate)
|
||||||
|
|
||||||
# 2. 自动填充输出目录为 8_Machine_Learning_Models
|
# 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录)
|
||||||
if self.work_dir:
|
if self.work_dir and not self.output_path.get_path():
|
||||||
import os
|
|
||||||
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
|
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)
|
self.output_path.set_path(models_dir)
|
||||||
else:
|
elif not self.work_dir:
|
||||||
self.output_path.set_path("")
|
self.output_path.set_path("")
|
||||||
|
|
||||||
def _on_run_single_clicked(self):
|
def _on_run_single_clicked(self):
|
||||||
|
|||||||
@ -183,11 +183,13 @@ class Step9MlPredictPanel(QWidget):
|
|||||||
output_layout.setSpacing(16)
|
output_layout.setSpacing(16)
|
||||||
output_layout.setContentsMargins(20, 24, 20, 20)
|
output_layout.setContentsMargins(20, 24, 20, 20)
|
||||||
|
|
||||||
# 输出文件路径
|
# 输出目录路径(目录模式:模型预测结果为多个 CSV 文件,存放到目录中)
|
||||||
self.output_file = FileSelectWidget(
|
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)
|
output_layout.addWidget(self.output_file)
|
||||||
|
|
||||||
# 完美对齐的底部按钮栏(已彻底移除多余的启用复选框)
|
# 完美对齐的底部按钮栏(已彻底移除多余的启用复选框)
|
||||||
@ -400,31 +402,28 @@ class Step9MlPredictPanel(QWidget):
|
|||||||
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
factory = getattr(main_window, '_panel_factory', None) if main_window else None
|
||||||
|
|
||||||
# 1. 智能挑选采样 CSV:优先"含 WQI 指数的测试集"(防止特征维度与训练时不匹配)
|
# 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()
|
wqi_test_csv = self._resolve_latest_wqi_test_csv()
|
||||||
if wqi_test_csv:
|
if wqi_test_csv:
|
||||||
self.sampling_csv_file.set_path(wqi_test_csv)
|
self.sampling_csv_file.set_path(wqi_test_csv)
|
||||||
elif factory:
|
elif factory:
|
||||||
# 兜底:拿第 4 步的纯原始采样光谱(旧行为保留)
|
# 兜底:拿第 4 步的纯原始采样光谱(仅当文件确实存在)
|
||||||
step4_panel = factory.get_panel('step4_sampling')
|
step4_panel = factory.get_panel('step4_sampling')
|
||||||
if step4_panel and hasattr(step4_panel, 'output_file'):
|
if step4_panel and hasattr(step4_panel, 'output_file'):
|
||||||
path = step4_panel.output_file.get_path()
|
path = step4_panel.output_file.get_path()
|
||||||
if path: self.sampling_csv_file.set_path(path)
|
if path and os.path.exists(path):
|
||||||
|
self.sampling_csv_file.set_path(path)
|
||||||
|
|
||||||
# 2. 拿第 8 步的模型目录
|
# 2. 拿第 8 步的模型目录(仅当目录确实存在)
|
||||||
if factory:
|
if factory:
|
||||||
step8_panel = factory.get_panel('step8_ml_train')
|
step8_panel = factory.get_panel('step8_ml_train')
|
||||||
if step8_panel and hasattr(step8_panel, 'output_path'):
|
if step8_panel and hasattr(step8_panel, 'output_path'):
|
||||||
path = step8_panel.output_path.get_path()
|
path = step8_panel.output_path.get_path()
|
||||||
if path: self.models_dir_file.set_path(path)
|
if path and os.path.isdir(path):
|
||||||
|
self.models_dir_file.set_path(path)
|
||||||
|
|
||||||
# 3. 生成第 9 步的输出目录
|
# 3. 生成第 9 步的输出目录(仅在为空时填入默认路径,不创建目录)
|
||||||
if hasattr(self, 'work_dir') and self.work_dir:
|
if hasattr(self, 'work_dir') and self.work_dir and not self.output_file.get_path():
|
||||||
import os
|
|
||||||
out_dir = os.path.join(self.work_dir, "9_ML_Prediction").replace('\\', '/')
|
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)
|
self.output_file.set_path(out_dir)
|
||||||
|
|
||||||
def _get_default_work_dir(self):
|
def _get_default_work_dir(self):
|
||||||
@ -445,6 +444,15 @@ class Step9MlPredictPanel(QWidget):
|
|||||||
if dir_path:
|
if dir_path:
|
||||||
self.models_dir_file.set_path(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):
|
def get_config(self):
|
||||||
"""获取配置"""
|
"""获取配置"""
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
@ -31,8 +31,9 @@ from PyQt5.QtWidgets import (
|
|||||||
QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy,
|
QPushButton, QLabel, QTabWidget, QToolBar, QSizePolicy,
|
||||||
QListWidget, QListWidgetItem, QGroupBox,
|
QListWidget, QListWidgetItem, QGroupBox,
|
||||||
QTextEdit, QProgressBar, QMessageBox, QFileDialog,
|
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
|
from PyQt5.QtGui import QIcon, QFont, QPixmap, QColor, QTextCursor
|
||||||
|
|
||||||
if multiprocessing.current_process().name == 'MainProcess':
|
if multiprocessing.current_process().name == 'MainProcess':
|
||||||
@ -104,8 +105,11 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
self._apply_stylesheet()
|
self._apply_stylesheet()
|
||||||
self._disable_wheel_for_all_spinboxes()
|
self._disable_wheel_for_all_spinboxes()
|
||||||
|
|
||||||
# 第五步:延迟启动工作目录选择
|
# 第五步:默认选中第一个步骤(延迟执行,确保导航列表和 Tab 均已就位)
|
||||||
QTimer.singleShot(100, self._workspace_initializer.run)
|
QTimer.singleShot(120, self._select_first_nav_item)
|
||||||
|
|
||||||
|
# 第六步:延迟启动工作目录选择
|
||||||
|
QTimer.singleShot(200, self._workspace_initializer.run)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Manager 初始化
|
# Manager 初始化
|
||||||
@ -395,9 +399,8 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
|
|
||||||
# 分隔符 (不需要实体占用高度,缩小即可)
|
# 分隔符 (不需要实体占用高度,缩小即可)
|
||||||
if stage_idx < len(stage_names) - 1:
|
if stage_idx < len(stage_names) - 1:
|
||||||
from PyQt5.QtCore import QSize # 正确引入 QSize
|
|
||||||
sep = QListWidgetItem("")
|
sep = QListWidgetItem("")
|
||||||
sep.setSizeHint(QSize(0, 10)) # 直接使用 QSize
|
sep.setSizeHint(QSize(0, 10))
|
||||||
sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled)
|
sep.setFlags(sep.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEnabled)
|
||||||
self._step_list.addItem(sep)
|
self._step_list.addItem(sep)
|
||||||
|
|
||||||
@ -467,15 +470,26 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
panel.update_from_config(work_dir=work_dir, pipeline=None)
|
panel.update_from_config(work_dir=work_dir, pipeline=None)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
# 2026-06-30:不再静默吞异常,至少通过 EventBus 输出日志
|
||||||
|
self._event_bus.publish('LogMessage', {
|
||||||
|
'message': f'[警告] 面板 {step_id} 的 update_from_config 失败: {e}',
|
||||||
|
'level': 'warning',
|
||||||
|
})
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 导航 → Tab 单向路由(左侧 List 驱动右侧 Tab,Tab 头部已隐藏)
|
# 导航 → 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):
|
def _on_step_list_changed(self, index):
|
||||||
from PyQt5.QtCore import Qt
|
|
||||||
if index < 0: return
|
if index < 0: return
|
||||||
item = self._step_list.item(index)
|
item = self._step_list.item(index)
|
||||||
if not item: return
|
if not item: return
|
||||||
@ -609,7 +623,6 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
||||||
|
|
||||||
def _disable_wheel_for_all_spinboxes(self):
|
def _disable_wheel_for_all_spinboxes(self):
|
||||||
from PyQt5.QtWidgets import QSpinBox, QDoubleSpinBox, QComboBox
|
|
||||||
for sb in self.findChildren(QSpinBox):
|
for sb in self.findChildren(QSpinBox):
|
||||||
sb.setFocusPolicy(Qt.StrongFocus)
|
sb.setFocusPolicy(Qt.StrongFocus)
|
||||||
sb.wheelEvent = lambda e, s=sb: None
|
sb.wheelEvent = lambda e, s=sb: None
|
||||||
|
|||||||
Reference in New Issue
Block a user