格式统一
This commit is contained in:
@ -108,10 +108,10 @@ class ImageCategoryTree(QTreeWidget):
|
||||
if not work_path.exists():
|
||||
return
|
||||
|
||||
# 查找所有图像文件:14_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图)
|
||||
# 查找所有图像文件:12_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图)
|
||||
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp']
|
||||
scan_roots: List[Path] = []
|
||||
_viz = work_path / "14_visualization"
|
||||
_viz = work_path / "12_visualization"
|
||||
if _viz.is_dir():
|
||||
scan_roots.append(_viz)
|
||||
_wm = work_path / "1_water_mask"
|
||||
|
||||
@ -106,7 +106,7 @@ class ImageCategoryTree(QTreeWidget):
|
||||
|
||||
image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp']
|
||||
scan_roots: List[Path] = []
|
||||
_viz = work_path / "14_visualization"
|
||||
_viz = work_path / "12_visualization"
|
||||
if _viz.is_dir():
|
||||
scan_roots.append(_viz)
|
||||
_wm = work_path / "1_water_mask"
|
||||
|
||||
@ -92,6 +92,14 @@ class ConfigManager(QObject):
|
||||
)
|
||||
return
|
||||
|
||||
# ★ 验证 JSON 结构:必须是 dict 类型
|
||||
if not isinstance(config, dict):
|
||||
QMessageBox.critical(
|
||||
self.parent(), "配置格式错误",
|
||||
f"配置文件格式不正确(期望 JSON 对象,实际为 {type(config).__name__})。\n请确认选择了正确的配置文件。"
|
||||
)
|
||||
return
|
||||
|
||||
# 回填已加载面板
|
||||
loaded_count = 0
|
||||
for step_id, panel in self._panel_factory.get_loaded_panels().items():
|
||||
@ -99,8 +107,11 @@ class ConfigManager(QObject):
|
||||
try:
|
||||
panel.set_config(config[step_id])
|
||||
loaded_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': f'[警告] 面板 {step_id} 加载配置失败: {e}',
|
||||
'level': 'warning',
|
||||
})
|
||||
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': f'已加载配置: {file_path}(回填 {loaded_count} 个面板)',
|
||||
@ -156,6 +167,10 @@ class ConfigManager(QObject):
|
||||
if hasattr(panel, 'get_config'):
|
||||
try:
|
||||
config[step_id] = panel.get_config()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
config[step_id] = {}
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': f'[警告] 面板 {step_id} 获取配置失败: {e}',
|
||||
'level': 'warning',
|
||||
})
|
||||
return config
|
||||
|
||||
@ -16,12 +16,17 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
|
||||
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
|
||||
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
|
||||
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
|
||||
- ★ panel.destroyed 信号自动取消订阅(2026-06-30)
|
||||
- ★ weakref 防止闭包阻止 panel GC(2026-06-30)
|
||||
|
||||
2026-06-30 修复:
|
||||
- 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃
|
||||
- 连接 panel.destroyed 信号自动清理订阅,彻底杜绝回调泄漏
|
||||
- 使用 weakref.ref 包装回调中的 panel 引用,防止 GC 阻塞
|
||||
"""
|
||||
|
||||
import os
|
||||
import weakref
|
||||
import sip
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
@ -48,8 +53,18 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
|
||||
return
|
||||
|
||||
panel_id = id(panel)
|
||||
if panel_id not in _subscription_registry:
|
||||
_subscription_registry[panel_id] = []
|
||||
# ★ 若之前已订阅过,先清理旧订阅(防止重复订阅泄漏)
|
||||
if panel_id in _subscription_registry:
|
||||
unsubscribe_panel_from_dependencies(panel)
|
||||
|
||||
_subscription_registry[panel_id] = []
|
||||
|
||||
# ★ 面板销毁时自动清理订阅,杜绝闭包泄漏
|
||||
if hasattr(panel, 'destroyed'):
|
||||
try:
|
||||
panel.destroyed.connect(lambda obj=None: _on_panel_destroyed(panel_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items():
|
||||
callback = _make_callback(panel, dep_step, output_type, _input_field)
|
||||
@ -57,6 +72,13 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies):
|
||||
_subscription_registry[panel_id].append(('OutputUpdated', callback))
|
||||
|
||||
|
||||
def _on_panel_destroyed(panel_id):
|
||||
"""panel.destroyed 信号回调:清理该面板的所有订阅。"""
|
||||
subscriptions = _subscription_registry.pop(panel_id, [])
|
||||
for event_name, callback in subscriptions:
|
||||
global_event_bus.unsubscribe(event_name, callback)
|
||||
|
||||
|
||||
def unsubscribe_panel_from_dependencies(panel):
|
||||
"""取消面板的所有依赖订阅。应在面板销毁前调用,防止野指针回调。
|
||||
|
||||
@ -72,13 +94,20 @@ def unsubscribe_panel_from_dependencies(panel):
|
||||
def _make_callback(panel, dep_step, output_type, target_widget_name):
|
||||
"""为单个依赖项创建事件回调。返回闭包函数。
|
||||
|
||||
使用工厂函数确保每个回调的闭包变量独立绑定。
|
||||
使用 weakref 持有 panel 引用,防止闭包阻止 panel GC。
|
||||
同时检查 panel 和 widget 的 C++ 对象是否存活。
|
||||
"""
|
||||
|
||||
panel_ref = weakref.ref(panel)
|
||||
|
||||
def callback(data):
|
||||
p = panel_ref()
|
||||
if p is None:
|
||||
return
|
||||
|
||||
# ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过
|
||||
try:
|
||||
if sip.isdeleted(panel):
|
||||
if sip.isdeleted(p):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
@ -88,10 +117,17 @@ def _make_callback(panel, dep_step, output_type, target_widget_name):
|
||||
if data.get('output_type') != output_type:
|
||||
return
|
||||
|
||||
widget = getattr(panel, target_widget_name, None)
|
||||
widget = getattr(p, target_widget_name, None)
|
||||
if widget is None:
|
||||
return
|
||||
|
||||
# ★ 检查 widget 自身的 C++ 对象是否存活
|
||||
try:
|
||||
if sip.isdeleted(widget):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
current = ''
|
||||
try:
|
||||
if hasattr(widget, 'get_path'):
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
from PyQt5.QtCore import QObject
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
# ★ 集中版本号常量
|
||||
APP_VERSION = "V1.2"
|
||||
APP_NAME = "MegaCube-Water Quality"
|
||||
|
||||
|
||||
class DialogService(QObject):
|
||||
"""对话框服务。
|
||||
@ -53,7 +57,7 @@ class DialogService(QObject):
|
||||
"""显示"关于"对话框。"""
|
||||
QMessageBox.about(
|
||||
self.parent(), "关于",
|
||||
"MegaCube-Water Quality V1.2\n\n"
|
||||
f"{APP_NAME} {APP_VERSION}\n\n"
|
||||
"一个完整的水质参数反演工作流程工具\n\n"
|
||||
"公司:北京依锐思遥感技术有限公司\n"
|
||||
"地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n"
|
||||
|
||||
@ -11,16 +11,19 @@
|
||||
- 新增 unsubscribe() 方法,允许面板销毁时清理订阅,防止内存泄漏和野指针回调
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""发布-订阅事件总线"""
|
||||
"""发布-订阅事件总线(线程安全)。"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: Dict[str, List[Callable]] = defaultdict(list)
|
||||
self._lock = threading.Lock()
|
||||
# 异常日志回调(可选注入,供 LogManager 使用)
|
||||
self._error_logger: Optional[Callable[[str], None]] = None
|
||||
|
||||
@ -30,38 +33,42 @@ class EventBus:
|
||||
|
||||
def subscribe(self, event_name: str, callback: Callable[[dict], None]):
|
||||
"""订阅事件。callback 接收一个 dict 作为事件数据。"""
|
||||
if callback not in self._subscribers[event_name]:
|
||||
self._subscribers[event_name].append(callback)
|
||||
with self._lock:
|
||||
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]
|
||||
with self._lock:
|
||||
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]):
|
||||
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
|
||||
|
||||
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
|
||||
"""
|
||||
for callback in list(self._subscribers.get(event_name, [])):
|
||||
with self._lock:
|
||||
snapshot = list(self._subscribers.get(event_name, []))
|
||||
for callback in snapshot:
|
||||
try:
|
||||
callback(data)
|
||||
except Exception:
|
||||
cb_name = getattr(callback, '__name__', None) or repr(callback)
|
||||
err_msg = (
|
||||
f"[EventBus] 事件 '{event_name}' 的订阅者 {callback.__name__!r} 抛出异常:\n"
|
||||
f"[EventBus] 事件 '{event_name}' 的订阅者 {cb_name!r} 抛出异常:\n"
|
||||
+ traceback.format_exc()
|
||||
)
|
||||
if self._error_logger:
|
||||
try:
|
||||
self._error_logger(err_msg)
|
||||
except Exception:
|
||||
pass
|
||||
print(err_msg, file=sys.stderr, flush=True)
|
||||
else:
|
||||
import sys
|
||||
print(err_msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
ProgressUpdate → {percentage, message} 更新进度条
|
||||
"""
|
||||
|
||||
import html
|
||||
from datetime import datetime
|
||||
|
||||
from PyQt5.QtCore import QObject
|
||||
@ -27,6 +28,8 @@ from PyQt5.QtWidgets import (
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
|
||||
_MAX_LOG_LINES = 2000 # 日志行数上限,防止长时间运行内存耗尽
|
||||
|
||||
|
||||
class LogManager(QObject):
|
||||
"""日志与进度管理器。
|
||||
@ -48,6 +51,15 @@ class LogManager(QObject):
|
||||
global_event_bus.subscribe('LogMessage', self._on_log_message)
|
||||
global_event_bus.subscribe('ProgressUpdate', self._on_progress_update)
|
||||
|
||||
# ★ 父控件销毁时自动取消订阅,防止回调泄漏
|
||||
if parent is not None:
|
||||
parent.destroyed.connect(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
"""清理 EventBus 订阅。"""
|
||||
global_event_bus.unsubscribe('LogMessage', self._on_log_message)
|
||||
global_event_bus.unsubscribe('ProgressUpdate', self._on_progress_update)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 公开 API
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@ -168,13 +180,23 @@ class LogManager(QObject):
|
||||
"""LogMessage 事件回调:写入日志区。"""
|
||||
if self._log_text is None:
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
message = data.get('message', '')
|
||||
level = data.get('level', 'info')
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# ★ HTML 转义:防止日志消息中的 < > & 破坏 HTML 渲染
|
||||
safe_message = html.escape(str(message))
|
||||
color_map = {'error': 'red', 'warning': 'orange'}
|
||||
color = color_map.get(level, 'black')
|
||||
formatted = f'<span style="color: {color};">[{timestamp}] {message}</span>'
|
||||
formatted = f'<span style="color: {color};">[{timestamp}] {safe_message}</span>'
|
||||
self._log_text.append(formatted)
|
||||
# ★ 日志行数上限:防止长时间运行内存耗尽
|
||||
if self._log_text.document().blockCount() > _MAX_LOG_LINES + 100:
|
||||
self._log_text.clear()
|
||||
self._log_text.append(
|
||||
f'<span style="color: gray;">[日志已自动清空,达到 {_MAX_LOG_LINES} 行上限]</span>'
|
||||
)
|
||||
cursor = self._log_text.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
self._log_text.setTextCursor(cursor)
|
||||
|
||||
@ -202,6 +202,8 @@ class PanelFactory:
|
||||
self._tab_widget.removeTab(tab_index)
|
||||
self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title)
|
||||
self._tab_widget.setCurrentIndex(current_active) # 恢复原来的 Tab,严禁后台预加载引发跳页!
|
||||
# ★ 显式释放占位页,防止泄漏
|
||||
placeholder.deleteLater()
|
||||
finally:
|
||||
self._tab_widget.blockSignals(False)
|
||||
|
||||
@ -260,6 +262,9 @@ class PanelFactory:
|
||||
# 3. 实时扫描已加载面板中被依赖的属性(覆盖全局输入如 reference_img)
|
||||
self._replay_live_panel_inputs()
|
||||
|
||||
# ★ 重新禁用滚轮:update_from_config 可能动态创建了新的 SpinBox/ComboBox
|
||||
_disable_wheel_for_widget(panel)
|
||||
|
||||
def _replay_live_panel_inputs(self):
|
||||
"""遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。
|
||||
|
||||
@ -290,7 +295,7 @@ class PanelFactory:
|
||||
if not path: continue
|
||||
|
||||
# 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失
|
||||
absolute_path = os.path.abspath(path).replace('\\', '/')
|
||||
absolute_path = os.path.normpath(os.path.abspath(path))
|
||||
|
||||
# 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联
|
||||
if not os.path.exists(absolute_path):
|
||||
|
||||
@ -167,7 +167,7 @@ PANEL_REGISTRY = [
|
||||
'step_id': 'step9_ml_predict',
|
||||
'class_ref': Step9MlPredictPanel,
|
||||
'title': '机器学习预测',
|
||||
'icon': '10.png',
|
||||
'icon': '9.png',
|
||||
'stage': '模块三 模型训练与反演',
|
||||
'display_name': '9. 机器学习预测',
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名;
|
||||
@ -198,7 +198,7 @@ PANEL_REGISTRY = [
|
||||
'step_id': 'step11_map',
|
||||
'class_ref': Step11MapPanel,
|
||||
'title': '专题图生成',
|
||||
'icon': '10.png',
|
||||
'icon': '11.png',
|
||||
'stage': '模块四 制图与成果汇编',
|
||||
'display_name': '11. 分布图生成',
|
||||
# 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名
|
||||
@ -226,7 +226,7 @@ PANEL_REGISTRY = [
|
||||
'step_id': 'step13_report',
|
||||
'class_ref': Step13ReportPanel,
|
||||
'title': '报告生成',
|
||||
'icon': '10.png',
|
||||
'icon': '13.png',
|
||||
'stage': '模块四 制图与成果汇编',
|
||||
'display_name': '13. 分析报告生成',
|
||||
'dependencies': None,
|
||||
|
||||
@ -118,6 +118,18 @@ class PipelineExecutor(QObject):
|
||||
# ★ 终端即时反馈:确保即使 EventBus/日志区未就绪也能看到
|
||||
print("\n[PipelineExecutor] 收到「运行完整流程」指令,开始执行...")
|
||||
|
||||
# ★ 防重入:如果已有 Worker 在运行,拒绝启动
|
||||
if self.is_running:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新流程。',
|
||||
'level': 'warning',
|
||||
})
|
||||
QMessageBox.warning(
|
||||
self.parent(), "后台忙碌",
|
||||
"后台任务正在运行中,无法启动新流程。\n请等待当前任务完成或点击「停止」按钮后再试。"
|
||||
)
|
||||
return
|
||||
|
||||
if not PIPELINE_AVAILABLE:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': '无法导入 Pipeline 模块,请检查项目文件结构!',
|
||||
@ -340,6 +352,18 @@ class PipelineExecutor(QObject):
|
||||
)
|
||||
|
||||
def _run_single_step_impl(self, step_name: str, config: dict = None):
|
||||
# ★ 防重入:如果已有 Worker 在运行,拒绝启动
|
||||
if self.is_running:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新步骤。',
|
||||
'level': 'warning',
|
||||
})
|
||||
QMessageBox.warning(
|
||||
self.parent(), "后台忙碌",
|
||||
"后台任务正在运行中,无法启动新步骤。\n请等待当前任务完成或点击「停止」按钮后再试。"
|
||||
)
|
||||
return
|
||||
|
||||
if not PIPELINE_AVAILABLE:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': '无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!',
|
||||
|
||||
@ -45,6 +45,10 @@ class TrainingModeManager(QObject):
|
||||
Args:
|
||||
checked: True=有训练数据模式, False=无训练数据模式
|
||||
"""
|
||||
# ★ 防护:相同状态不重复发布事件
|
||||
if self._training_mode == checked:
|
||||
return
|
||||
|
||||
self._training_mode = checked
|
||||
|
||||
global_event_bus.publish('LogMessage', {
|
||||
|
||||
@ -73,7 +73,7 @@ class VisualizationWorkerThread(QThread):
|
||||
wp = Path(self.work_dir)
|
||||
if self.task == "mask_glint":
|
||||
from src.postprocessing.visualization_reports import WaterQualityVisualization
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
|
||||
preview_paths = viz.generate_glint_deglint_previews(
|
||||
work_dir=str(wp),
|
||||
output_subdir="glint_deglint_previews",
|
||||
@ -109,7 +109,7 @@ class VisualizationWorkerThread(QThread):
|
||||
csv_path = str(csv_files[0])
|
||||
from src.postprocessing.point_map import SamplingPointMap
|
||||
map_generator = SamplingPointMap(
|
||||
output_dir=str(wp / "14_visualization" / "sampling_maps"),
|
||||
output_dir=str(wp / "12_visualization" / "sampling_maps"),
|
||||
fast_mode=True,
|
||||
)
|
||||
map_path = map_generator.create_sampling_point_map(
|
||||
@ -134,7 +134,7 @@ class VisualizationWorkerThread(QThread):
|
||||
)
|
||||
elif self.task == "spectrum":
|
||||
from src.postprocessing.visualization_reports import WaterQualityVisualization
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
|
||||
csv_file = self.extra.get("csv_path")
|
||||
wl = self.extra.get("wavelength_start_column", "UTM_Y")
|
||||
n_groups = int(self.extra.get("n_groups", 5))
|
||||
@ -178,7 +178,7 @@ class VisualizationWorkerThread(QThread):
|
||||
)
|
||||
elif self.task == "statistics":
|
||||
from src.postprocessing.visualization_reports import WaterQualityVisualization
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
|
||||
csv_file = self.extra.get("csv_path")
|
||||
param_cols = self.extra.get("param_cols") or []
|
||||
output_paths = viz.plot_statistical_charts(
|
||||
@ -206,7 +206,7 @@ class VisualizationWorkerThread(QThread):
|
||||
self.finished_ok.emit({"task": "scatter", "scatter_paths": scatter_paths or {}})
|
||||
elif self.task == "generate_all_selected":
|
||||
from src.postprocessing.visualization_reports import WaterQualityVisualization
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization"))
|
||||
viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization"))
|
||||
parts = []
|
||||
|
||||
training_csv_path = (self.extra.get("training_csv_path") or "").strip()
|
||||
@ -333,7 +333,7 @@ class VisualizationWorkerThread(QThread):
|
||||
csv_path = str(csv_files[0])
|
||||
from src.postprocessing.point_map import SamplingPointMap
|
||||
map_generator = SamplingPointMap(
|
||||
output_dir=str(wp / "14_visualization" / "sampling_maps"),
|
||||
output_dir=str(wp / "12_visualization" / "sampling_maps"),
|
||||
fast_mode=True,
|
||||
)
|
||||
map_path = map_generator.create_sampling_point_map(
|
||||
|
||||
@ -28,8 +28,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QObject
|
||||
from PyQt5.QtWidgets import QMessageBox, QFileDialog
|
||||
from PyQt5.QtCore import QObject, Qt, QThread, QTimer
|
||||
from PyQt5.QtWidgets import QMessageBox, QFileDialog, QApplication
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
from src.core.workspace_manager import WorkspaceManager
|
||||
@ -157,10 +157,21 @@ class WorkspaceInitializer(QObject):
|
||||
panel.set_work_dir(dir_path)
|
||||
|
||||
def open_work_directory(self):
|
||||
"""在资源管理器中打开工作目录。"""
|
||||
"""在资源管理器中打开工作目录(跨平台)。"""
|
||||
import subprocess
|
||||
import platform
|
||||
work_dir = self._work_dir or './work_dir'
|
||||
if os.path.exists(work_dir):
|
||||
os.startfile(work_dir)
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == 'Windows':
|
||||
os.startfile(work_dir)
|
||||
elif system == 'Darwin':
|
||||
subprocess.Popen(['open', work_dir])
|
||||
else:
|
||||
subprocess.Popen(['xdg-open', work_dir])
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self.parent(), "警告", f"无法打开工作目录: {e}")
|
||||
else:
|
||||
QMessageBox.warning(self.parent(), "警告", "工作目录不存在!")
|
||||
|
||||
@ -205,8 +216,11 @@ class WorkspaceInitializer(QObject):
|
||||
continue
|
||||
try:
|
||||
panel.update_from_config(work_dir=work_dir, pipeline=None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': f'[警告] 面板 {step_id} 自动填充失败: {e}',
|
||||
'level': 'warning',
|
||||
})
|
||||
|
||||
global_event_bus.publish('LogMessage', {
|
||||
'message': '✓ 工作目录扫描完成,事件总线已通知所有面板自动填充',
|
||||
@ -223,7 +237,15 @@ class WorkspaceInitializer(QObject):
|
||||
当 PipelineExecutor 发布 StepCompleted 事件时,
|
||||
此方法调用 WorkspaceManager.update_step_outputs() 扫描磁盘,
|
||||
WorkspaceManager 内部自动发布 OutputUpdated 事件驱动面板填充。
|
||||
|
||||
★ 线程安全:若回调发生在非主线程(如 worker 线程),
|
||||
通过 QTimer.singleShot(0) 编组到主线程再执行。
|
||||
"""
|
||||
# 线程安全检查:非主线程时编组到主线程
|
||||
if QApplication.instance() and QApplication.instance().thread() != QThread.currentThread():
|
||||
QTimer.singleShot(0, lambda: self._on_step_completed(data))
|
||||
return
|
||||
|
||||
if not data.get('success'):
|
||||
return
|
||||
|
||||
|
||||
@ -26,22 +26,19 @@ from typing import Optional, Union
|
||||
# 这是"张冠李戴"修复的核心——main_window 上已不再直接挂载 panel 属性,
|
||||
# 所有面板都通过 _panel_factory.get_panel(step_id) 懒加载访问。
|
||||
STEP_DATA_SOURCE = {
|
||||
# 数据流 step 编号(用户口语) → PANEL_REGISTRY 中的 step_id
|
||||
'step5_clean_output': 'step5_clean',
|
||||
'step7_index_output': 'step7_index',
|
||||
'step8_ml_train_output': 'step8_ml_train',
|
||||
'step8_5_non_empirical': 'step8_ml_train',
|
||||
'step9_ml_predict_output': 'step9_ml_predict',
|
||||
'step10_watercolor_output': 'step10_watercolor',
|
||||
'step11_ml_prediction': 'step9_ml_predict', # 主流程 step11 = ML 预测
|
||||
'step12_regression_prediction': 'step8_ml_train', # 主流程 step12 = 非经验预测
|
||||
'step13_custom_regression': 'step13_report', # 自定义回归借用 step13 报告面板
|
||||
'sampling_csv': 'step4_sampling',
|
||||
'training_spectra_csv': 'step5_clean',
|
||||
'indices_csv': 'step7_index',
|
||||
'models_dir': 'step8_ml_train',
|
||||
'watercolor_dir': 'step10_watercolor',
|
||||
'prediction_csv_dir': 'step9_ml_predict', # 默认从 ML 预测读
|
||||
# 数据流别名 → PANEL_REGISTRY 中的 step_id
|
||||
'step5_clean_output': 'step5_clean',
|
||||
'step7_index_output': 'step7_index',
|
||||
'step8_ml_train_output': 'step8_ml_train',
|
||||
'step9_ml_predict_output': 'step9_ml_predict',
|
||||
'step10_watercolor_output': 'step10_watercolor',
|
||||
'step11_map_output': 'step11_map',
|
||||
'sampling_csv': 'step4_sampling',
|
||||
'training_spectra_csv': 'step5_clean',
|
||||
'indices_csv': 'step7_index',
|
||||
'models_dir': 'step8_ml_train',
|
||||
'watercolor_dir': 'step10_watercolor',
|
||||
'prediction_csv_dir': 'step9_ml_predict',
|
||||
}
|
||||
|
||||
|
||||
@ -92,7 +89,7 @@ def resolve_step_widget(main_window, step_key: str, widget_attr: str = 'output_f
|
||||
|
||||
|
||||
_FALLBACK_DIR_TABLE = {
|
||||
# pipeline key(与 _ensure_step_dir_map 对齐)→ 子目录名
|
||||
# pipeline key(与 base.py _ensure_step_dir_map 对齐)→ 子目录名
|
||||
'step1': '1_water_mask',
|
||||
'step2': '2_Glint_Detection',
|
||||
'step3': '3_deglint',
|
||||
@ -101,23 +98,26 @@ _FALLBACK_DIR_TABLE = {
|
||||
'step6_feature': '6_Spectral_Feature_Extraction',
|
||||
'step7_index': '7_Water_Quality_Indices',
|
||||
'step8_ml_train': '8_Supervised_Model_Training',
|
||||
'step9_ml_predict': '9_ML_Prediction',
|
||||
'step10_watercolor': '10_WaterIndex_CSV',
|
||||
'step11_map': '11_Thematic_Map',
|
||||
'step12_viz': '12_visualization',
|
||||
'step13_report': '12_visualization',
|
||||
# 短别名
|
||||
'step4': '4_sampling',
|
||||
'step5': '5_Data_Cleaning',
|
||||
'step6': '6_Spectral_Feature_Extraction',
|
||||
'step7': '7_Water_Quality_Indices',
|
||||
'step8': '8_Supervised_Model_Training',
|
||||
'step9_ml_predict': '8_Non_Empirical_Regression',
|
||||
'step9': '8_Non_Empirical_Regression',
|
||||
'step10_watercolor': '10_WaterIndex_Images',
|
||||
'step10': '10_WaterIndex_Images',
|
||||
'step11_map': '14_visualization',
|
||||
'step11': '11_12_13_predictions',
|
||||
'step11_predictions': '11_12_13_predictions',
|
||||
'step12': '13_Custom_Regression',
|
||||
'step12_predictions': '11_12_13_predictions',
|
||||
'step13': 'reports',
|
||||
'step13_predictions': '11_12_13_predictions',
|
||||
'step14': '14_visualization',
|
||||
'prediction_dir': '11_12_13_predictions',
|
||||
'visualization': '14_visualization',
|
||||
'step9': '9_ML_Prediction',
|
||||
'step10': '10_WaterIndex_CSV',
|
||||
'step11': '11_Thematic_Map',
|
||||
'step12': '12_visualization',
|
||||
'step13': '12_visualization',
|
||||
# 语义别名
|
||||
'prediction_dir': '9_ML_Prediction',
|
||||
'visualization': '12_visualization',
|
||||
'reports': 'reports',
|
||||
'custom_regression': '13_Custom_Regression',
|
||||
# 扩展:覆盖 panel 内部使用的子目录别名
|
||||
'water_mask': '1_water_mask',
|
||||
'glint_detection': '2_Glint_Detection',
|
||||
@ -127,10 +127,9 @@ _FALLBACK_DIR_TABLE = {
|
||||
'spectral_feature': '6_Spectral_Feature_Extraction',
|
||||
'indices': '7_Water_Quality_Indices',
|
||||
'supervised_models': '8_Supervised_Model_Training',
|
||||
'non_empirical': '8_Non_Empirical_Regression',
|
||||
'qaa_inversion': '8_QAA_Inversion',
|
||||
'regression_modeling': '8_Regression_Modeling',
|
||||
'watercolor': '10_WaterIndex_Images',
|
||||
'watercolor': '10_WaterIndex_CSV',
|
||||
'ml_prediction': '9_ML_Prediction',
|
||||
'sampling_csv_path': '4_sampling/sampling_spectra.csv',
|
||||
}
|
||||
@ -156,7 +155,12 @@ def get_step_output_path(
|
||||
widget = resolve_step_widget(main_window, step_key, widget_attr)
|
||||
p = _read_widget_path(widget)
|
||||
if p:
|
||||
if not Path(p).is_absolute() and wd:
|
||||
# ★ Windows 驱动器相对路径 (如 C:foo) 也视为绝对,不做拼接
|
||||
is_win_drive_relative = (
|
||||
len(p) >= 2 and p[1] == ':' and not p[2:].startswith(('\\', '/'))
|
||||
if len(p) > 2 else False
|
||||
)
|
||||
if not is_win_drive_relative and not Path(p).is_absolute() and wd:
|
||||
p = str(Path(wd) / p).replace('\\', '/')
|
||||
return p
|
||||
|
||||
@ -172,7 +176,7 @@ def resolve_subdir(work_dir, subdir_key: str) -> str:
|
||||
"""纯子目录拼装:把 pipeline key 解析为 work_dir 下的子目录路径。
|
||||
|
||||
用法:resolve_subdir(self.work_dir, 'visualization')
|
||||
→ '<work_dir>/14_visualization'
|
||||
→ '<work_dir>/12_visualization'
|
||||
|
||||
与 pipeline.get_step_output_dir 同源(都查同一份 _FALLBACK_DIR_TABLE 子集)。
|
||||
"""
|
||||
@ -219,49 +223,63 @@ def scan_work_dir_for_input(work_dir: str, output_type: str):
|
||||
if not work_dir:
|
||||
return None
|
||||
|
||||
wd = Path(work_dir)
|
||||
try:
|
||||
wd = Path(work_dir)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
entry = _SCAN_TABLE.get(output_type)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
subdir_key, extensions, matcher = entry
|
||||
target_dir = wd / _FALLBACK_DIR_TABLE.get(subdir_key, subdir_key)
|
||||
if not target_dir.is_dir():
|
||||
try:
|
||||
if not target_dir.is_dir():
|
||||
return None
|
||||
except (OSError, PermissionError):
|
||||
return None
|
||||
|
||||
# 目录类型(extensions 为 None)→ 只要目录存在就返回
|
||||
if extensions is None:
|
||||
return str(target_dir).replace('\\', '/')
|
||||
|
||||
# 扫描目录中的文件
|
||||
# 扫描目录中的文件(带异常保护)
|
||||
candidates = []
|
||||
for ext in extensions:
|
||||
for f in target_dir.glob(f'*{ext}'):
|
||||
if not f.is_file():
|
||||
for ext_variant in (ext, ext.upper()):
|
||||
try:
|
||||
for f in target_dir.glob(f'*{ext_variant}'):
|
||||
try:
|
||||
if f.is_file():
|
||||
candidates.append(f)
|
||||
except OSError:
|
||||
continue
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
candidates.append(f)
|
||||
# 也搜 ext 的大写变体
|
||||
for f in target_dir.glob(f'*{ext.upper()}'):
|
||||
if not f.is_file():
|
||||
continue
|
||||
candidates.append(f)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 按 matcher 优先级 + mtime 排序
|
||||
if matcher:
|
||||
matched = [f for f in candidates if matcher.lower() in f.stem.lower()]
|
||||
if matched:
|
||||
matched.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return str(matched[0]).replace('\\', '/')
|
||||
# matcher 未命中时,仍返回最新文件(宽松匹配)
|
||||
try:
|
||||
if matcher:
|
||||
matched = [f for f in candidates if matcher.lower() in f.stem.lower()]
|
||||
if matched:
|
||||
matched.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return str(matched[0]).replace('\\', '/')
|
||||
# matcher 未命中时,仍返回最新文件(宽松匹配)
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return str(candidates[0]).replace('\\', '/')
|
||||
|
||||
# 无 matcher → 返回最新的
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return str(candidates[0]).replace('\\', '/')
|
||||
|
||||
# 无 matcher → 返回最新的
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return str(candidates[0]).replace('\\', '/')
|
||||
except (OSError, FileNotFoundError):
|
||||
# TOCTOU:文件在 glob 和 stat 之间被删除
|
||||
if candidates:
|
||||
return str(candidates[0]).replace('\\', '/')
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Step10 面板 - 水色指数反演(散点 CSV 模式)(极致强迫症绝对对齐版)
|
||||
Step10 面板 - 水色指数反演(散点 CSV 模式)(极致强迫症对齐功能完整版)
|
||||
|
||||
与 Step 9 (ML 预测) 完全对称的【散点处理模式】:
|
||||
|
||||
* 输入:Step 4 输出的 ``sampling_spectra.csv``(散点+全波段光谱)
|
||||
* 处理:解析 ``waterindex.csv`` 中的公式,**逐行**对每个采样点计算水色指数
|
||||
* 输出:每个公式一个 CSV,列严格为 ``longitude, latitude, <formula_name>``,
|
||||
可直接喂给 Step 11 ContentMapper
|
||||
* 输出:每个公式一个 CSV,列严格为 ``longitude, latitude, <formula_name>``
|
||||
* 输出目录:默认 ``{work_dir}/10_WaterIndex_CSV/``
|
||||
"""
|
||||
|
||||
@ -20,14 +18,11 @@ from typing import Dict, List, Optional
|
||||
from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for_input
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout,
|
||||
QGroupBox, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton,
|
||||
QFileDialog, QMessageBox, QListWidget, QListWidgetItem,
|
||||
QAbstractItemView, QProgressBar, QTextEdit, QFrame,
|
||||
QScrollArea, QSizePolicy, QDoubleSpinBox,
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel,
|
||||
QComboBox, QPushButton, QMessageBox, QListWidget, QListWidgetItem,
|
||||
QSizePolicy, QDoubleSpinBox
|
||||
)
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from src.gui.components.custom_widgets import FileSelectWidget
|
||||
from src.gui.styles import ModernStylesheet
|
||||
@ -50,60 +45,6 @@ CATEGORY_CHINESE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
class WaterIndexWorker(QThread):
|
||||
"""后台线程:散点 CSV → 逐行公式计算 → 多 CSV 输出"""
|
||||
progress = pyqtSignal(str, float) # message, percent
|
||||
finished_ok = pyqtSignal(dict) # {公式名: 输出 CSV 路径}
|
||||
error = pyqtSignal(str) # error message
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sampling_csv_path: str,
|
||||
output_dir: str,
|
||||
selected_formulas: List[str],
|
||||
waterindex_csv: str,
|
||||
work_dir: Optional[str] = None,
|
||||
wavelength_offset: float = 0.0,
|
||||
):
|
||||
super().__init__()
|
||||
self.sampling_csv_path = sampling_csv_path
|
||||
self.output_dir = output_dir
|
||||
self.selected_formulas = selected_formulas
|
||||
self.waterindex_csv = waterindex_csv
|
||||
self.work_dir = work_dir
|
||||
self.wavelength_offset = float(wavelength_offset)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from src.core.algorithms.waterindex_inversion import (
|
||||
WaterIndexCsvProcessor,
|
||||
)
|
||||
|
||||
self.progress.emit("正在初始化散点水色指数处理器…", 2)
|
||||
|
||||
processor = WaterIndexCsvProcessor(self.waterindex_csv)
|
||||
|
||||
out_files = processor.compute_indices_from_csv(
|
||||
sampling_csv_path=self.sampling_csv_path,
|
||||
output_dir=self.output_dir,
|
||||
selected_formulas=self.selected_formulas or None,
|
||||
progress_callback=lambda m, p: self.progress.emit(m, p),
|
||||
wavelength_offset=self.wavelength_offset,
|
||||
)
|
||||
|
||||
self.progress.emit(
|
||||
f"完成!共生成 {len(out_files)} 个指数 CSV", 100
|
||||
)
|
||||
self.finished_ok.emit(out_files)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self.error.emit(f"文件不存在: {e}")
|
||||
except ValueError as e:
|
||||
self.error.emit(f"参数错误: {e}")
|
||||
except Exception as e:
|
||||
self.error.emit(f"{e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
class NoScrollPassListWidget(QListWidget):
|
||||
"""一个绝对不会把滚轮事件传给外层父组件的列表控件"""
|
||||
|
||||
@ -117,7 +58,7 @@ class Step10WatercolorPanel(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker: Optional[WaterIndexWorker] = None
|
||||
self.work_dir = None
|
||||
self._waterindex_csv = self._find_waterindex_csv()
|
||||
self._categories: List[str] = []
|
||||
self._all_formulas: List[Dict] = []
|
||||
@ -127,7 +68,11 @@ class Step10WatercolorPanel(QWidget):
|
||||
self._load_formulas()
|
||||
|
||||
def init_ui(self):
|
||||
self.setStyleSheet(ModernStylesheet.get_main_stylesheet())
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(20)
|
||||
|
||||
# ==========================================
|
||||
# 卡片 1:输入数据配置
|
||||
@ -142,10 +87,9 @@ class Step10WatercolorPanel(QWidget):
|
||||
"CSV Files (*.csv);;All Files (*.*)"
|
||||
)
|
||||
self.formula_file.line_edit.setReadOnly(True)
|
||||
self.formula_file.label.setMinimumWidth(120)
|
||||
builtin_csv = self._find_waterindex_csv()
|
||||
if builtin_csv:
|
||||
self.formula_file.set_path(builtin_csv)
|
||||
self.formula_file.label.setMinimumWidth(120) # 严格 120px 左对齐线
|
||||
if self._waterindex_csv:
|
||||
self.formula_file.set_path(self._waterindex_csv)
|
||||
input_layout.addWidget(self.formula_file)
|
||||
|
||||
self.sampling_csv_file = FileSelectWidget(
|
||||
@ -167,7 +111,6 @@ class Step10WatercolorPanel(QWidget):
|
||||
"负数=公式波长减偏移。默认0表示不做修正。"
|
||||
)
|
||||
|
||||
# 使用强化版的对齐方法直接插入到 input_layout 中
|
||||
self._add_aligned_row(input_layout, "波长偏移:", self.wavelength_offset_spin, "nm")
|
||||
|
||||
input_group.setLayout(input_layout)
|
||||
@ -257,9 +200,9 @@ class Step10WatercolorPanel(QWidget):
|
||||
layout.addWidget(formula_group)
|
||||
|
||||
# ==========================================
|
||||
# 卡片 3:输出设置
|
||||
# 卡片 3:输出与执行
|
||||
# ==========================================
|
||||
output_group = QGroupBox("输出设置")
|
||||
output_group = QGroupBox("🚀 输出与执行")
|
||||
output_layout = QVBoxLayout()
|
||||
output_layout.setSpacing(16)
|
||||
output_layout.setContentsMargins(20, 24, 20, 20)
|
||||
@ -274,32 +217,6 @@ class Step10WatercolorPanel(QWidget):
|
||||
)
|
||||
output_layout.addWidget(self.output_dir)
|
||||
|
||||
output_group.setLayout(output_layout)
|
||||
layout.addWidget(output_group)
|
||||
|
||||
# ---- 进度显示 ----
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setMinimum(0)
|
||||
self.progress_bar.setMaximum(100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setTextVisible(True)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
self.progress_label = QLabel("")
|
||||
self.progress_label.setStyleSheet("font-size: 11px; color: #666;")
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# ==========================================
|
||||
# 卡片 4:输出与执行
|
||||
# ==========================================
|
||||
execute_group = QGroupBox("🚀 输出与执行")
|
||||
execute_layout = QVBoxLayout()
|
||||
execute_layout.setSpacing(16)
|
||||
execute_layout.setContentsMargins(20, 24, 20, 20)
|
||||
|
||||
execute_layout.addWidget(self.progress_bar)
|
||||
execute_layout.addWidget(self.progress_label)
|
||||
|
||||
action_layout = QHBoxLayout()
|
||||
action_layout.addStretch()
|
||||
|
||||
@ -309,10 +226,9 @@ class Step10WatercolorPanel(QWidget):
|
||||
self.run_btn.clicked.connect(self._on_run_single_clicked)
|
||||
action_layout.addWidget(self.run_btn)
|
||||
|
||||
execute_layout.addLayout(action_layout)
|
||||
execute_group.setLayout(execute_layout)
|
||||
|
||||
layout.addWidget(execute_group)
|
||||
output_layout.addLayout(action_layout)
|
||||
output_group.setLayout(output_layout)
|
||||
layout.addWidget(output_group)
|
||||
|
||||
layout.addStretch()
|
||||
self.setLayout(layout)
|
||||
@ -321,22 +237,17 @@ class Step10WatercolorPanel(QWidget):
|
||||
"""强化版对齐函数:让单位的占位和浏览按钮一样宽,完美锁死输入框长度"""
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(6) # 严格匹配 FileSelectWidget 内部的控件间距
|
||||
row_layout.setSpacing(6)
|
||||
|
||||
# 1. 统一左侧标签宽度为 120px
|
||||
lbl = QLabel(label_text)
|
||||
lbl.setMinimumWidth(120)
|
||||
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
row_layout.addWidget(lbl)
|
||||
|
||||
# 2. 允许控件水平拉伸
|
||||
widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
widget.setStyleSheet("min-height: 28px;")
|
||||
row_layout.addWidget(widget)
|
||||
|
||||
# 3. 核心秘密:"浏览..."按钮的默认宽度大约是 75px。
|
||||
# 我们把单位标签的宽度锁死在 75px,并用 padding 制造出漂亮的空格!
|
||||
# 这样它就会完美模拟上方 "浏览" 按钮的存在感,让所有输入框的长短一刀切般整齐!
|
||||
suffix_lbl = QLabel(suffix_text)
|
||||
suffix_lbl.setFixedWidth(75)
|
||||
suffix_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
@ -470,37 +381,52 @@ class Step10WatercolorPanel(QWidget):
|
||||
self._current_type_filter = "all"
|
||||
self._refresh_visibility()
|
||||
|
||||
self.formula_list.blockSignals(True)
|
||||
for i in range(self.formula_list.count()):
|
||||
item = self.formula_list.item(i)
|
||||
# 眼见为实:可见的打勾,隐藏的全部强制取消打勾
|
||||
if not item.isHidden():
|
||||
item.setCheckState(Qt.Checked)
|
||||
else:
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.formula_list.blockSignals(False)
|
||||
self._update_formula_count()
|
||||
|
||||
def _deselect_all(self):
|
||||
self.formula_list.blockSignals(True)
|
||||
for i in range(self.formula_list.count()):
|
||||
item = self.formula_list.item(i)
|
||||
if not item.isHidden():
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
# 简单粗暴:无论可见与否,全部取消勾选
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.formula_list.blockSignals(False)
|
||||
self._update_formula_count()
|
||||
|
||||
def _select_ratio(self):
|
||||
self._current_type_filter = "ratio"
|
||||
self._refresh_visibility()
|
||||
|
||||
self.formula_list.blockSignals(True)
|
||||
for i in range(self.formula_list.count()):
|
||||
item = self.formula_list.item(i)
|
||||
if not item.isHidden():
|
||||
item.setCheckState(Qt.Checked)
|
||||
else:
|
||||
item.setCheckState(Qt.Unchecked) # 强制干掉隐藏的浓度型幽灵
|
||||
self.formula_list.blockSignals(False)
|
||||
self._update_formula_count()
|
||||
|
||||
def _select_conc(self):
|
||||
self._current_type_filter = "concentration"
|
||||
self._refresh_visibility()
|
||||
|
||||
self.formula_list.blockSignals(True)
|
||||
for i in range(self.formula_list.count()):
|
||||
item = self.formula_list.item(i)
|
||||
if not item.isHidden():
|
||||
item.setCheckState(Qt.Checked)
|
||||
else:
|
||||
item.setCheckState(Qt.Unchecked) # 强制干掉隐藏的比值型幽灵
|
||||
self.formula_list.blockSignals(False)
|
||||
self._update_formula_count()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
@ -532,9 +458,11 @@ class Step10WatercolorPanel(QWidget):
|
||||
|
||||
def get_config(self) -> dict:
|
||||
sampling = self.sampling_csv_file.get_path().strip()
|
||||
config: Dict[str, object] = {
|
||||
config = {
|
||||
'sampling_csv_path': sampling,
|
||||
'selected_formulas': self._get_selected_formula_names(),
|
||||
'wavelength_offset': self.wavelength_offset_spin.value(),
|
||||
'waterindex_csv': self.formula_file.get_path().strip()
|
||||
}
|
||||
out_dir = self.output_dir.get_path().strip()
|
||||
if out_dir:
|
||||
@ -546,12 +474,24 @@ class Step10WatercolorPanel(QWidget):
|
||||
self.sampling_csv_file.set_path(config['sampling_csv_path'])
|
||||
if config.get('output_dir'):
|
||||
self.output_dir.set_path(config['output_dir'])
|
||||
if config.get('waterindex_csv'):
|
||||
self.formula_file.set_path(config['waterindex_csv'])
|
||||
|
||||
if 'selected_formulas' in config:
|
||||
names = set(config['selected_formulas'])
|
||||
self.formula_list.blockSignals(True)
|
||||
for i in range(self.formula_list.count()):
|
||||
item = self.formula_list.item(i)
|
||||
name = item.data(Qt.UserRole)
|
||||
item.setCheckState(Qt.Checked if name in names else Qt.Unchecked)
|
||||
self.formula_list.blockSignals(False)
|
||||
self._update_formula_count()
|
||||
|
||||
if 'wavelength_offset' in config:
|
||||
try:
|
||||
self.wavelength_offset_spin.setValue(float(config['wavelength_offset']))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
def update_from_config(self, work_dir=None, pipeline=None):
|
||||
if work_dir:
|
||||
@ -561,6 +501,7 @@ class Step10WatercolorPanel(QWidget):
|
||||
else:
|
||||
self.work_dir = None
|
||||
|
||||
# 数据链条自动流转:优先从上游布设步骤获取散点数据
|
||||
sampling_path = None
|
||||
if pipeline and hasattr(pipeline, 'step_outputs'):
|
||||
step4_out = pipeline.step_outputs.get('step4_sampling', {})
|
||||
@ -618,85 +559,6 @@ class Step10WatercolorPanel(QWidget):
|
||||
'config': config,
|
||||
})
|
||||
|
||||
def run_step(self):
|
||||
sampling_csv_path = self.sampling_csv_file.get_path().strip()
|
||||
if not sampling_csv_path:
|
||||
QMessageBox.warning(self, "输入错误", "请选择采样点 CSV!")
|
||||
return
|
||||
if not Path(sampling_csv_path).exists():
|
||||
QMessageBox.warning(
|
||||
self, "输入错误", f"采样点 CSV 不存在:\n{sampling_csv_path}"
|
||||
)
|
||||
return
|
||||
|
||||
output_dir = self.output_dir.get_path().strip()
|
||||
if not output_dir:
|
||||
work_dir = self._get_default_work_dir()
|
||||
output_dir = os.path.join(work_dir, '10_WaterIndex_CSV')
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
self.output_dir.set_path(output_dir)
|
||||
|
||||
selected = self._get_selected_formula_names()
|
||||
if not selected:
|
||||
QMessageBox.warning(self, "输入错误", "请至少选择一个公式!")
|
||||
return
|
||||
|
||||
if self._waterindex_csv and not Path(self._waterindex_csv).exists():
|
||||
QMessageBox.warning(
|
||||
self, "配置错误",
|
||||
f"waterindex.csv 不存在:\n{self._waterindex_csv}",
|
||||
)
|
||||
return
|
||||
|
||||
work_dir = self.work_dir or str(Path(sampling_csv_path).parent)
|
||||
|
||||
self.run_btn.setEnabled(False)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_label.setText("")
|
||||
|
||||
self._worker = WaterIndexWorker(
|
||||
sampling_csv_path=sampling_csv_path,
|
||||
output_dir=output_dir,
|
||||
selected_formulas=selected,
|
||||
waterindex_csv=self._waterindex_csv,
|
||||
work_dir=work_dir,
|
||||
wavelength_offset=self.wavelength_offset_spin.value(),
|
||||
)
|
||||
self._worker.progress.connect(self._on_progress)
|
||||
self._worker.finished_ok.connect(self._on_finished)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_progress(self, msg: str, pct: float):
|
||||
self.progress_bar.setValue(int(pct))
|
||||
self.progress_label.setText(msg)
|
||||
|
||||
def _on_finished(self, results: Dict[str, str]):
|
||||
self.run_btn.setEnabled(True)
|
||||
n = len(results)
|
||||
names = list(results.keys())[:3]
|
||||
tail = " …" if n > 3 else ""
|
||||
QMessageBox.information(
|
||||
self, "执行成功",
|
||||
f"水色指数反演完成!\n"
|
||||
f"共生成 {n} 个指数 CSV(含 longitude / latitude / 公式值三列)。\n"
|
||||
f"前几个: {', '.join(names)}{tail}\n\n"
|
||||
f"输出目录: {self.output_dir.get_path()}"
|
||||
)
|
||||
main_window = self.window()
|
||||
if main_window and hasattr(main_window, 'log_message'):
|
||||
main_window.log_message(
|
||||
f"步骤10:水色指数反演完成,生成 {n} 个指数 CSV", "info"
|
||||
)
|
||||
|
||||
def _on_error(self, err: str):
|
||||
self.run_btn.setEnabled(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_label.setText("执行失败")
|
||||
QMessageBox.critical(
|
||||
self, "执行错误", f"水色指数反演失败:\n\n{err[:500]}"
|
||||
)
|
||||
|
||||
def get_output_dir(self) -> str:
|
||||
return self.output_dir.get_path().strip() or ""
|
||||
|
||||
|
||||
@ -296,7 +296,7 @@ class Step11MapPanel(QWidget):
|
||||
execute_layout.setContentsMargins(20, 24, 20, 20)
|
||||
|
||||
self.output_dir = FileSelectWidget("输出分布图目录:", "Directories;;All Files (*.*)")
|
||||
apply_fs_style(self.output_dir, "留空→工作目录/14_visualization")
|
||||
apply_fs_style(self.output_dir, "留空→工作目录/11_Thematic_Map")
|
||||
self.output_dir.browse_btn.clicked.disconnect()
|
||||
self.output_dir.browse_btn.clicked.connect(self.browse_output_dir)
|
||||
execute_layout.addWidget(self.output_dir)
|
||||
@ -323,7 +323,8 @@ class Step11MapPanel(QWidget):
|
||||
layout.addStretch()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.batch_mode_combo.setCurrentIndex(0)
|
||||
# ★ 默认使用"文件夹批量"模式(最常见的工作流场景)
|
||||
self.batch_mode_combo.setCurrentIndex(1)
|
||||
self._toggle_input_mode()
|
||||
|
||||
def _toggle_input_mode(self):
|
||||
@ -473,23 +474,49 @@ class Step11MapPanel(QWidget):
|
||||
if work_dir:
|
||||
self.work_dir = work_dir
|
||||
|
||||
# 1. 预测 CSV 目录:文件系统扫描
|
||||
# ── 智能自动路由:预测 CSV 目录 ──
|
||||
if self.work_dir:
|
||||
pred_dir = scan_work_dir_for_input(self.work_dir, 'ml_predictions_dir')
|
||||
if pred_dir and os.path.isdir(str(pred_dir)):
|
||||
self.prediction_csv_dir_edit.setText(str(pred_dir))
|
||||
self.batch_mode_combo.setCurrentIndex(1)
|
||||
wd = self.work_dir
|
||||
done = False
|
||||
|
||||
# 2. 边界文件(水体掩膜):文件系统扫描
|
||||
# Priority 1: Step 9 ML Prediction 输出目录
|
||||
for cand_dir_key in ('ml_prediction', 'prediction_dir'):
|
||||
pred_dir = resolve_subdir(wd, cand_dir_key)
|
||||
if pred_dir and os.path.isdir(pred_dir):
|
||||
csvs = list(Path(pred_dir).glob("*.csv"))
|
||||
if csvs:
|
||||
self.prediction_csv_dir_edit.setText(pred_dir)
|
||||
self.batch_mode_combo.setCurrentIndex(1)
|
||||
done = True
|
||||
break
|
||||
|
||||
# Priority 2 (Fallback): Step 10 水色指数输出目录
|
||||
if not done:
|
||||
for cand_dir_key in ('watercolor', 'step10_watercolor'):
|
||||
wc_dir = resolve_subdir(wd, cand_dir_key)
|
||||
if wc_dir and os.path.isdir(wc_dir):
|
||||
csvs = list(Path(wc_dir).glob("*.csv"))
|
||||
if csvs:
|
||||
self.prediction_csv_dir_edit.setText(wc_dir)
|
||||
self.batch_mode_combo.setCurrentIndex(1)
|
||||
done = True
|
||||
break
|
||||
|
||||
# GeoTIFF 目录:指向 step10 水色指数输出
|
||||
geotiff_dir = resolve_subdir(wd, 'watercolor')
|
||||
if geotiff_dir and os.path.isdir(geotiff_dir) and not self.geotiff_dir_edit.text().strip():
|
||||
self.geotiff_dir_edit.setText(geotiff_dir)
|
||||
|
||||
# ── 边界文件(水体掩膜):文件系统扫描 ──
|
||||
if self.work_dir:
|
||||
boundary_path = scan_work_dir_for_input(self.work_dir, 'water_mask')
|
||||
existing = self.boundary_file.get_path()
|
||||
if boundary_path and not existing and os.path.exists(str(boundary_path)):
|
||||
self.boundary_file.set_path(str(boundary_path))
|
||||
|
||||
# 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)
|
||||
# ── 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)──
|
||||
if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path():
|
||||
out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/')
|
||||
out_dir = os.path.join(self.work_dir, "11_Thematic_Map").replace('\\', '/')
|
||||
self.output_dir.set_path(out_dir)
|
||||
|
||||
def browse_output_dir(self):
|
||||
|
||||
@ -694,19 +694,98 @@ class ImageCategoryTree(QTreeWidget):
|
||||
elif "GLINT" in name_upper or "MASK" in name_upper or "PREVIEW" in name_upper:
|
||||
chart_type = "掩膜与预览"
|
||||
|
||||
# 2. 提取参数名
|
||||
# 2. 提取参数名(扩展版:覆盖 12 项常见水质参数前缀)
|
||||
param_name = "综合/未分类"
|
||||
# 常见水质参数字典映射
|
||||
# ★ 匹配规则:短关键字(≤3 字符)必须在词边界(开头或 _ - . 之后)出现,
|
||||
# 杜绝 "TT" 命中 "scaTTer"、"CL" 命中 "ChL" 等误匹配。
|
||||
# 长关键字(≥4 字符)保持简单子串匹配,兼容历史行为。
|
||||
import re as _re
|
||||
|
||||
def _match_key(name: str, key: str) -> bool:
|
||||
clean = key.replace('-', '')
|
||||
# 长关键字(≥4):简单子串匹配,历史行为一致
|
||||
if len(key) >= 4 or len(clean) >= 4:
|
||||
if key in name:
|
||||
return True
|
||||
if clean != key and clean in name:
|
||||
return True
|
||||
return False
|
||||
# 短关键字(≤3):必须出现在词边界(行首或 _ - . 之后)。
|
||||
# 若 key 本身以 _ - . 结尾,则不要求后缀边界(_ 已是分隔符)。
|
||||
suffix = r'' if key[-1] in '_.-' else r'(?![a-zA-Z0-9])'
|
||||
pattern = _re.compile(
|
||||
r'(?:^|[_.\-])' + _re.escape(key) + suffix
|
||||
)
|
||||
if pattern.search(name):
|
||||
return True
|
||||
if clean != key:
|
||||
clean_suffix = r'' if clean[-1] in '_.-' else r'(?![a-zA-Z0-9])'
|
||||
pattern2 = _re.compile(
|
||||
r'(?:^|[_.\-])' + _re.escape(clean) + clean_suffix
|
||||
)
|
||||
if pattern2.search(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
params_map = {
|
||||
'CHLOROPHYLL': 'Chlorophyll (叶绿素)', 'CHL_A': 'Chlorophyll (叶绿素)', 'CHLA': 'Chlorophyll (叶绿素)',
|
||||
'COD': 'COD (化学需氧量)', 'DO': 'DO (溶解氧)', 'PH': 'pH',
|
||||
'TEMPERATURE': 'Temperature (温度)', 'SPCOND': 'spCond (电导率)',
|
||||
'TURBIDITY': 'Turbidity (浊度)', 'TDS': 'TDS (总溶解固体)',
|
||||
'CL-': 'Cl- (氯离子)', 'NO3-N': 'NO3-N (硝态氮)', 'NH3-N': 'NH3-N (氨氮)',
|
||||
'BGA': 'BGA (蓝绿藻)', 'TT': 'TT (透明度)'
|
||||
# ── 叶绿素 a ──
|
||||
'CHLOROPHYLL': '叶绿素a (Chl-a)',
|
||||
'CHL-A': '叶绿素a (Chl-a)',
|
||||
'CHL_A': '叶绿素a (Chl-a)',
|
||||
'CHLA': '叶绿素a (Chl-a)',
|
||||
'CHL_CONC': '叶绿素a (Chl-a)',
|
||||
'CHL_': '叶绿素a (Chl-a)',
|
||||
# ── 总悬浮物 ──
|
||||
'SUSPENDED': '总悬浮物 (TSM)',
|
||||
'TSM_CONC': '总悬浮物 (TSM)',
|
||||
'TSM_': '总悬浮物 (TSM)',
|
||||
'TSM': '总悬浮物 (TSM)',
|
||||
# ── 藻蓝蛋白 ──
|
||||
'PHYCOCYANIN': '藻蓝蛋白 (PC)',
|
||||
'PHYCO': '藻蓝蛋白 (PC)',
|
||||
'BGA': '藻蓝蛋白 (PC)',
|
||||
'PC_CONC': '藻蓝蛋白 (PC)',
|
||||
'PC_': '藻蓝蛋白 (PC)',
|
||||
# ── 浊度 ──
|
||||
'TURBIDITY': '浊度 (Turbidity)',
|
||||
'TURB_CONC': '浊度 (Turbidity)',
|
||||
'TURB_': '浊度 (Turbidity)',
|
||||
# ── 有色可溶性有机物 ──
|
||||
'CDOM': '有色可溶性有机物 (CDOM)',
|
||||
# ── 透明度 ──
|
||||
'SECCHI': '透明度 (SDD)',
|
||||
'SDD_CONC': '透明度 (SDD)',
|
||||
'SDD_': '透明度 (SDD)',
|
||||
'SDD': '透明度 (SDD)',
|
||||
# ── 氮磷类 ──
|
||||
'NITROGEN': '总氮 (TN)',
|
||||
'TN_CONC': '总氮 (TN)',
|
||||
'TN_': '总氮 (TN)',
|
||||
'PHOSPHORUS': '总磷 (TP)',
|
||||
'TP_CONC': '总磷 (TP)',
|
||||
'TP_': '总磷 (TP)',
|
||||
'NH3-N': '氨氮 (NH3-N)',
|
||||
'NH3_CONC': '氨氮 (NH3-N)',
|
||||
'NH3_': '氨氮 (NH3-N)',
|
||||
'NH3N': '氨氮 (NH3-N)',
|
||||
'NO3-N': '硝态氮 (NO3-N)',
|
||||
'NO3N': '硝态氮 (NO3-N)',
|
||||
# ── 其他需氧量与离子 ──
|
||||
'COD': '化学需氧量 (COD)',
|
||||
'DISSOLVED_OXYGEN': '溶解氧 (DO)',
|
||||
'DO_CONC': '溶解氧 (DO)',
|
||||
'DO_': '溶解氧 (DO)',
|
||||
'CL-': '氯离子 (Cl-)',
|
||||
'CL_': '氯离子 (Cl-)',
|
||||
# ── 基础物理与特征指标 ──
|
||||
'PH': '酸碱度 (pH)',
|
||||
'TEMPERATURE': '温度 (Temperature)',
|
||||
'SPCOND': '电导率 (spCond)',
|
||||
'TDS': '总溶解固体 (TDS)',
|
||||
'TT': '特征指标 (TT)',
|
||||
}
|
||||
for key, display_name in params_map.items():
|
||||
if key in name_upper or key.replace('-', '') in name_upper:
|
||||
if _match_key(name_upper, key):
|
||||
param_name = display_name
|
||||
break
|
||||
|
||||
@ -1239,7 +1318,7 @@ class Step12VizPanel(QWidget):
|
||||
self,
|
||||
"成功",
|
||||
f"掩膜和耀斑缩略图生成完成,共 {cnt} 个预览图。\n"
|
||||
f"保存位置: 14_visualization/glint_deglint_previews/",
|
||||
f"保存位置: 12_visualization/glint_deglint_previews/",
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
@ -1254,7 +1333,7 @@ class Step12VizPanel(QWidget):
|
||||
"成功",
|
||||
"采样点地图生成完成。\n"
|
||||
f"输出: {Path(map_path).name if map_path else ''}\n"
|
||||
"路径: 14_visualization/sampling_maps/",
|
||||
"路径: 12_visualization/sampling_maps/",
|
||||
)
|
||||
if map_path:
|
||||
self.show_chart_viewer(map_path, "采样点分布图")
|
||||
@ -1265,7 +1344,7 @@ class Step12VizPanel(QWidget):
|
||||
errs = payload.get("errors") or []
|
||||
msg = (
|
||||
f"已为 {len(ok_paths)} 个水质参数生成光谱对比图。\n"
|
||||
f"保存目录: 工作目录/14_visualization/"
|
||||
f"保存目录: 工作目录/12_visualization/"
|
||||
)
|
||||
if errs:
|
||||
msg += f"\n\n以下列未生成或出错 ({len(errs)} 项,详见日志):\n"
|
||||
@ -1296,7 +1375,7 @@ class Step12VizPanel(QWidget):
|
||||
self,
|
||||
"成功",
|
||||
f"已生成 {len(ok_paths)} 个模型评估散点图。\n"
|
||||
f"保存位置: 14_visualization/scatter_plots/",
|
||||
f"保存位置: 12_visualization/scatter_plots/",
|
||||
)
|
||||
self.show_chart_viewer(ok_paths[0], "模型评估散点图")
|
||||
else:
|
||||
@ -1313,7 +1392,8 @@ class Step12VizPanel(QWidget):
|
||||
"完成",
|
||||
"批量可视化已执行:\n" + "\n".join(parts) if parts else "(无选中项或已跳过)",
|
||||
)
|
||||
self.scan_work_directory()
|
||||
# ★ 延迟 400ms 再扫描目录,确保后台渲染线程已将图像文件 flush 到磁盘
|
||||
QTimer.singleShot(400, self.scan_work_directory)
|
||||
|
||||
def _on_visualization_worker_fail(self, err: str):
|
||||
QMessageBox.critical(self, "错误", f"可视化任务失败:\n{err[:1200]}")
|
||||
@ -1556,9 +1636,9 @@ class Step12VizPanel(QWidget):
|
||||
|
||||
推断优先级:
|
||||
1. {work_dir}/9_ML_Prediction(机器学习预测)
|
||||
2. {work_dir}/11_12_13_predictions/Non_Empirical_Prediction(普通回归预测)
|
||||
3. {work_dir}/13_Custom_Regression/Custom_Regression_Prediction(自定义回归预测)
|
||||
4. {work_dir}/14_visualization(可视化目录)
|
||||
2. {work_dir}/10_WaterIndex_CSV(水色指数反演)
|
||||
3. {work_dir}/11_Thematic_Map(专题分布图)
|
||||
4. {work_dir}/12_visualization(可视化目录)
|
||||
5. {work_dir}(工作目录根)
|
||||
"""
|
||||
try:
|
||||
@ -1569,13 +1649,12 @@ class Step12VizPanel(QWidget):
|
||||
return
|
||||
|
||||
work_path = Path(self.work_dir)
|
||||
pred_dir = Path(resolve_subdir(self.work_dir, 'prediction_dir'))
|
||||
|
||||
# 按优先级寻找存在的目录
|
||||
candidates = [
|
||||
Path(resolve_subdir(self.work_dir, 'ml_prediction')),
|
||||
pred_dir / "Non_Empirical_Prediction",
|
||||
Path(resolve_subdir(self.work_dir, 'custom_regression')) / "Custom_Regression_Prediction",
|
||||
Path(resolve_subdir(self.work_dir, 'watercolor')),
|
||||
Path(resolve_subdir(self.work_dir, 'step11_map')),
|
||||
Path(resolve_subdir(self.work_dir, 'visualization')),
|
||||
work_path,
|
||||
]
|
||||
@ -1657,12 +1736,11 @@ class Step12VizPanel(QWidget):
|
||||
目录创建统一留给各 pipeline 步骤在实际执行时处理。
|
||||
"""
|
||||
try:
|
||||
base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir'))
|
||||
ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction'))
|
||||
reg_dir = base_prediction_dir / "Regression_Model_Prediction"
|
||||
custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_Prediction"
|
||||
watercolor_dir = Path(resolve_subdir(str(work_path), 'watercolor'))
|
||||
thematic_dir = Path(resolve_subdir(str(work_path), 'step11_map'))
|
||||
# 仅输出信息,不创建目录
|
||||
existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()]
|
||||
existing = [str(d) for d in (ml_dir, watercolor_dir, thematic_dir) if d.is_dir()]
|
||||
if existing:
|
||||
print(f"预测输出目录已存在: {existing}")
|
||||
except Exception as e:
|
||||
|
||||
@ -123,7 +123,7 @@ class Step13ReportPanel(QWidget):
|
||||
return row
|
||||
|
||||
intro = QLabel(
|
||||
"💡 提示:根据工作目录下的可视化结果(14_visualization 等)自动生成 Word 分析报告。\n"
|
||||
"💡 提示:根据工作目录下的可视化结果(12_visualization 等)自动生成 Word 分析报告。\n"
|
||||
"需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。"
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
@ -149,14 +149,14 @@ class Step13ReportPanel(QWidget):
|
||||
|
||||
# 工作目录 (只读)
|
||||
self.work_dir_edit = QLineEdit()
|
||||
self.work_dir_edit.setPlaceholderText("流程工作目录(含 14_visualization)…")
|
||||
self.work_dir_edit.setPlaceholderText("流程工作目录(含 12_visualization)…")
|
||||
self.work_dir_edit.setReadOnly(True)
|
||||
self.work_dir_edit.setStyleSheet(common_lineedit_css)
|
||||
path_layout.addLayout(create_standard_row("工作目录:", self.work_dir_edit))
|
||||
|
||||
# 报告输出目录
|
||||
self.output_dir_edit = QLineEdit()
|
||||
self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization")
|
||||
self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/12_visualization")
|
||||
self.output_dir_edit.setStyleSheet(common_lineedit_css)
|
||||
|
||||
out_browse = QPushButton("浏览...")
|
||||
@ -373,7 +373,7 @@ class Step13ReportPanel(QWidget):
|
||||
if not wd or not os.path.isdir(wd):
|
||||
QMessageBox.warning(self, "提示", "请选择有效的工作目录。")
|
||||
return
|
||||
viz = Path(wd) / "14_visualization"
|
||||
viz = Path(wd) / "12_visualization"
|
||||
if not viz.is_dir():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
|
||||
@ -60,6 +60,7 @@ class Step4SamplingPanel(QWidget):
|
||||
self._cid_hover = None
|
||||
self._cid_click = None
|
||||
self._last_render_path = None
|
||||
self._last_render_mtime = None
|
||||
|
||||
self.init_ui()
|
||||
|
||||
@ -447,7 +448,10 @@ class Step4SamplingPanel(QWidget):
|
||||
|
||||
def _check_csv_exists(self):
|
||||
csv_path = self.output_file.get_path()
|
||||
enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_path))
|
||||
# ★ 修复:不再强制要求 os.path.isabs —— resolve_subdir 产出的路径
|
||||
# 在 Windows 上用正斜杠时仍能通过 os.path.isfile 正确判定。
|
||||
# 只要文件真实存在就启用刷新按钮。
|
||||
enabled = bool(csv_path and os.path.isfile(csv_path))
|
||||
self.refresh_btn.setEnabled(enabled)
|
||||
return enabled
|
||||
|
||||
@ -457,8 +461,18 @@ class Step4SamplingPanel(QWidget):
|
||||
def _check_csv_and_auto_render(self):
|
||||
csv_path = self.output_file.get_path()
|
||||
if csv_path and os.path.isfile(csv_path):
|
||||
if csv_path != self._last_render_path:
|
||||
# ★ 修复:不仅检测路径变化,也检测文件修改时间变化,
|
||||
# 确保步骤执行中 CSV 增量写入后自动触发重渲染。
|
||||
try:
|
||||
mtime = os.path.getmtime(csv_path)
|
||||
except OSError:
|
||||
mtime = None
|
||||
path_changed = (csv_path != self._last_render_path)
|
||||
mtime_changed = (mtime is not None and mtime != self._last_render_mtime)
|
||||
|
||||
if path_changed or mtime_changed:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
self._last_render_mtime = mtime
|
||||
self._render_inline_plot()
|
||||
|
||||
def _on_refresh_clicked(self):
|
||||
@ -548,6 +562,10 @@ class Step4SamplingPanel(QWidget):
|
||||
self._band_cols = band_cols
|
||||
self._highlight_idx = None
|
||||
self._last_render_path = csv_path
|
||||
try:
|
||||
self._last_render_mtime = os.path.getmtime(csv_path)
|
||||
except OSError:
|
||||
self._last_render_mtime = None
|
||||
|
||||
if self._annot is not None:
|
||||
try:
|
||||
@ -611,6 +629,7 @@ class Step4SamplingPanel(QWidget):
|
||||
self._canvas.draw_idle()
|
||||
self._df = None
|
||||
self._last_render_path = None
|
||||
self._last_render_mtime = None
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 交互事件 (Hover & Click)
|
||||
|
||||
@ -19,8 +19,6 @@ from PyQt5.QtWidgets import (
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
|
||||
from src.gui.components.custom_widgets import FileSelectWidget
|
||||
from src.gui.styles import ModernStylesheet
|
||||
|
||||
@ -585,6 +583,8 @@ class Step7InversionPanel(QWidget):
|
||||
QMessageBox.warning(self, "输入错误", "请至少勾选一种待计算的水质指数!")
|
||||
return
|
||||
|
||||
from src.gui.core.event_bus import global_event_bus
|
||||
|
||||
config = self.get_config()
|
||||
payload = {
|
||||
'step_name': 'step7_index',
|
||||
|
||||
@ -494,7 +494,7 @@ class Step8MlTrainPanel(QWidget):
|
||||
|
||||
# 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录)
|
||||
if self.work_dir and not self.output_path.get_path():
|
||||
models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/')
|
||||
models_dir = os.path.join(self.work_dir, "8_Supervised_Model_Training").replace('\\', '/')
|
||||
self.output_path.set_path(models_dir)
|
||||
elif not self.work_dir:
|
||||
self.output_path.set_path("")
|
||||
|
||||
@ -509,6 +509,11 @@ class WaterQualityGUI(QMainWindow):
|
||||
if not self._tab_widget.isTabEnabled(tab_index):
|
||||
self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。")
|
||||
self._tab_widget.setTabEnabled(tab_index, True)
|
||||
QMessageBox.warning(
|
||||
self, "页面已解锁",
|
||||
f"步骤「{item_data}」之前被异常锁定,已自动强制解锁。\n"
|
||||
"如果当前有流程正在运行,请勿修改此页面的参数。"
|
||||
)
|
||||
|
||||
# 【新增修复】:在跳之前,再核实一遍要去的 tab_index 和 item_data 的 step_id 是否吻合!
|
||||
# 如果因为删除了死文件导致索引错位,这里强行用真实 step_id 再找一遍!
|
||||
|
||||
Reference in New Issue
Block a user