格式统一

This commit is contained in:
duxin
2026-07-01 09:57:27 +08:00
parent c793ea2204
commit a3c20d3e49
37 changed files with 2286 additions and 1978 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -16,12 +16,17 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus
- sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault
- 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径
- 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录
- ★ panel.destroyed 信号自动取消订阅2026-06-30
- ★ weakref 防止闭包阻止 panel GC2026-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'):

View File

@ -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"

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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-22dict 键名 = 下游目标控件真实属性名;
@ -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-22dict 键名 = 下游目标控件真实属性名
@ -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,

View File

@ -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/ 目录是否完整!',

View File

@ -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', {

View File

@ -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(

View File

@ -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

View File

@ -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__ = [

View File

@ -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 ""

View File

@ -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):

View File

@ -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:

View File

@ -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,

View File

@ -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)

View File

@ -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',

View File

@ -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("")

View File

@ -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 再找一遍!