diff --git a/src/core/handlers/base.py b/src/core/handlers/base.py index 1de0994..b52da9e 100644 --- a/src/core/handlers/base.py +++ b/src/core/handlers/base.py @@ -91,6 +91,9 @@ class PipelineContext: # ★ 取消标志(2026-06-30):支持 Pipeline 优雅中断 self._cancelled: bool = False + # ★ 步骤输出目录映射(实例级别,2026-07-01 修复:从类变量改为实例变量,防止跨 work_dir 路径错乱) + self._step_output_dir_map: Optional[Dict[str, Path]] = None + # ── 可视化组件(延迟导入避免循环依赖)── self._visualizer = None self._report_generator = None @@ -194,11 +197,10 @@ class PipelineContext: # 步骤输出目录查找(兼容旧接口) # ═══════════════════════════════════════════════════════════ - _STEP_OUTPUT_DIR_MAP: Optional[Dict[str, Path]] = None - def _ensure_step_dir_map(self) -> Dict[str, Path]: - if PipelineContext._STEP_OUTPUT_DIR_MAP is not None: - return PipelineContext._STEP_OUTPUT_DIR_MAP + """构建实例级别的步骤输出目录映射(每个 work_dir 独立缓存)。""" + if self._step_output_dir_map is not None: + return self._step_output_dir_map wp = self.work_dir m = { 'step1': wp / '1_water_mask', @@ -232,7 +234,7 @@ class PipelineContext: 'visualization': wp / '12_visualization', 'reports': wp / 'reports', } - PipelineContext._STEP_OUTPUT_DIR_MAP = m + self._step_output_dir_map = m return m def get_step_output_dir(self, step_name: str) -> Path: diff --git a/src/core/handlers/register_handlers.py b/src/core/handlers/register_handlers.py index bb08530..f9e9090 100644 --- a/src/core/handlers/register_handlers.py +++ b/src/core/handlers/register_handlers.py @@ -42,6 +42,34 @@ def register_all_handlers(scheduler: PipelineScheduler): result = scheduler.run_full_pipeline(config) 新增步骤时,在此函数中追加一行 register_handler() 即可。 + + ── 2026-07-01 步骤体系说明 ── + 当前注册表包含 16 个 Handler,但 PANEL_REGISTRY 只有 13 个面板。 + 两个体系存在如下映射关系: + + Panel step_id → Handler step_key (via register_handlers) + ───────────────────────────────────────────────────────────────── + step1 → step1 + step2 → step2 + step3 → step3 + step4_sampling → 无独立 Handler(由 step4_sampling 面板驱动) + step5_clean → step5_clean + step6_feature → step6_feature + step7_index → step7_index + step8_ml_train → step8_ml_train + step9_ml_predict → step9_ml_predict + step10_watercolor → step10_watercolor (CSV 散点模式) + (无面板) → step10_qaa (QAA 物理反演,单独触发) + step11_map → step11_map (专题图 CSV 模式) + (无面板) → step11_concentration (浓度反演,单独触发) + (无面板) → step12_kriging (克里金插值,单独触发) + step12_viz → step13_visualization + step13_report → step14_report + + 注: + - step10_qaa / step11_concentration / step12_kriging / step14_report + 是没有对应 Panel 的 Handler,只能通过旧 config JSON 加载或外部脚本触发。 + - step10_watercolor 和 step10_qaa 共享 step10 编号空间,互斥使用。 """ scheduler.register_handler(Step1WaterMaskHandler()) scheduler.register_handler(Step2GlintDetectionHandler()) diff --git a/src/core/handlers/step12_kriging.py b/src/core/handlers/step12_kriging.py index 8328a10..f136080 100644 --- a/src/core/handlers/step12_kriging.py +++ b/src/core/handlers/step12_kriging.py @@ -30,6 +30,27 @@ class Step12KrigingHandler(BaseStepHandler): prediction_csv_path = config.get('prediction_csv_path') boundary_shp_path = config.get('boundary_shp_path') + # ★ 2026-07-01 空值保护:防止 prediction_csv_path 为 None 时 Path().stem 崩溃 + if not prediction_csv_path: + msg = '缺少 prediction_csv_path 参数,无法生成克里金插值图' + context.notify('step12_kriging', 'error', msg) + step_end_time = time.time() + context.record_step_time( + "步骤12: 克里金插值与分布图", step_start_time, step_end_time, + status="failed", error=msg + ) + return {'error': msg} + + if not os.path.isfile(prediction_csv_path): + msg = f'预测 CSV 文件不存在: {prediction_csv_path}' + context.notify('step12_kriging', 'error', msg) + step_end_time = time.time() + context.record_step_time( + "步骤12: 克里金插值与分布图", step_start_time, step_end_time, + status="failed", error=msg + ) + return {'error': msg} + # 强制输出到 visualization_dir csv_name = Path(prediction_csv_path).stem if prediction_csv_path else "distribution" forced_image_path = str(context.visualization_dir / f"{csv_name}_distribution.png") diff --git a/src/core/handlers/step13_visualization.py b/src/core/handlers/step13_visualization.py index 5728795..7d3340e 100644 --- a/src/core/handlers/step13_visualization.py +++ b/src/core/handlers/step13_visualization.py @@ -33,10 +33,15 @@ class Step13VisualizationHandler(BaseStepHandler): step_start_time = time.time() output_files: Dict[str, Any] = {} + # ★ 2026-07-01 防御性编程:提前解析路径并做 None/存在性检查 + _training_csv = context.training_csv_path + _models_dir = context.models_dir if context.models_dir else None + _processed_csv = context.processed_csv_path + try: # ── 散点图 ── if config.get('generate_scatter', True): - if context.training_csv_path and context.models_dir.exists(): + if _training_csv and _models_dir and _models_dir.exists(): try: scatter_config = config.get('scatter_config', {}) scatter_paths = self._generate_scatter_plots(context, scatter_config) @@ -47,7 +52,7 @@ class Step13VisualizationHandler(BaseStepHandler): # ── 箱型图 ── if config.get('generate_boxplots', True): - if context.processed_csv_path: + if _processed_csv: try: boxplot_config = config.get('boxplot_config', {}) boxplot_paths = self._generate_boxplots(context, boxplot_config) @@ -58,7 +63,7 @@ class Step13VisualizationHandler(BaseStepHandler): # ── 光谱曲线 ── if config.get('generate_spectrum', True): - if context.training_csv_path: + if _training_csv: try: spectrum_paths = self._generate_spectrum_plots(context, config) output_files['spectrum_plots'] = spectrum_paths @@ -68,7 +73,7 @@ class Step13VisualizationHandler(BaseStepHandler): # ── 统计图表 ── if config.get('generate_statistics', True): - if context.processed_csv_path: + if _processed_csv: try: stat_charts = self._generate_statistics(context) output_files['statistical_charts'] = stat_charts diff --git a/src/core/handlers/step9_ml_predict.py b/src/core/handlers/step9_ml_predict.py index 0ef3462..91ba9ee 100644 --- a/src/core/handlers/step9_ml_predict.py +++ b/src/core/handlers/step9_ml_predict.py @@ -38,7 +38,7 @@ class Step9MlPredictHandler(BaseStepHandler): output_dir = ( config.get('output_path') or config.get('output_dir') - or str(context.prediction_dir / "9_ML_Prediction") + or str(context.ml_prediction_dir) ) try: diff --git a/src/gui/core/event_bus.py b/src/gui/core/event_bus.py index 5c29890..fe2b1ad 100644 --- a/src/gui/core/event_bus.py +++ b/src/gui/core/event_bus.py @@ -51,12 +51,28 @@ class EventBus: """发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。 迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。 + + 2026-07-01 修复:捕获 RuntimeError(sip 野指针异常)防止已删除的 C++ 对象回调崩溃。 """ with self._lock: snapshot = list(self._subscribers.get(event_name, [])) for callback in snapshot: try: callback(data) + except RuntimeError: + # ★ 野指针保护:C++ 对象已删除时 sip 抛出 RuntimeError,静默跳过。 + cb_name = getattr(callback, '__name__', None) or repr(callback) + err_msg = ( + f"[EventBus] 事件 '{event_name}' 的订阅者 {cb_name!r} C++ 对象已销毁,跳过。\n" + + traceback.format_exc() + ) + if self._error_logger: + try: + self._error_logger(err_msg) + except Exception: + print(err_msg, file=sys.stderr, flush=True) + else: + print(err_msg, file=sys.stderr, flush=True) except Exception: cb_name = getattr(callback, '__name__', None) or repr(callback) err_msg = ( diff --git a/src/gui/core/pipeline_executor.py b/src/gui/core/pipeline_executor.py index 16d13f0..16895c2 100644 --- a/src/gui/core/pipeline_executor.py +++ b/src/gui/core/pipeline_executor.py @@ -537,17 +537,28 @@ class PipelineExecutor(QObject): def _check_worker_health(self): """看门狗巡检:每 5 秒触发一次。 + 2026-07-01 修复:优先尝试优雅停止再 fallback terminate() + 判定逻辑: - Worker 已不在运行(自然结束/被强杀) → 停掉看门狗 - Worker 仍在运行 + 上次进度距今 > 600 秒 → 判定为假死/死锁, - 强制 terminate() 并通过 _on_finished(False, ...) 汇流解 UI, - 让被卡死的「独立运行此步骤」按钮恢复可用。 + 先尝试 WorkerThread.stop() 优雅停止(5 秒超时), + 若优雅停止也超时则 fallback 到 terminate() """ import time if self._worker and self._worker.isRunning(): if time.time() - self._last_progress_time > 600: - self._log_message("[错误] 后台任务响应超时(超过10分钟无响应),强制终止...", "error") - self._worker.terminate() + self._log_message("[错误] 后台任务响应超时(超过10分钟无响应),尝试优雅停止...", "error") + # 先尝试通过 cancel + wait 优雅停止 + if hasattr(self._worker, 'stop'): + try: + self._worker.stop() + except Exception: + pass + # 如果优雅停止后线程仍在运行,再 fallback 到 terminate() + if self._worker.isRunning(): + self._log_message("[错误] 优雅停止无效,执行强制终止(terminate)...", "error") + self._worker.terminate() self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。") else: # Worker 已退出(自然结束 / 已 terminate),关闭看门狗 diff --git a/src/gui/core/preflight_dialog.py b/src/gui/core/preflight_dialog.py index 05be6ed..275ea52 100644 --- a/src/gui/core/preflight_dialog.py +++ b/src/gui/core/preflight_dialog.py @@ -304,11 +304,11 @@ class PreflightDialog(QDialog): def build_missing_items(config: dict) -> List[MissingItem]: """DAG-aware 预检:从 config 构建缺失项列表。 - 2026-06-30 修复(步骤 ID 错配): - config 的 key 是 PANEL_REGISTRY 的 step_id(如 step4_sampling), - 而 PIPELINE_STEPS 使用另一套 step_id(如 step4)。 - 现通过 _PIPELINE_TO_CONFIG_KEY 映射表统一翻译, - 避免 preflight 永远误报缺失项。 + 2026-07-01 修复(变量命名混乱 + 缺少 step4_sampling CSV 预检): + - 将变量名从旧 PIPELINE_STEPS 编号改为 PANEL_REGISTRY step_id + - 新增 step4_sampling 采样点 CSV 文件的显式预检 + - 将 _TAB_INDEX_MAP 提升到顶级并与 STEP_TAB_MAP 合并使用 + - _cfg() 映射表保留用于翻译旧 PIPELINE_STEPS step_id → 新 PANEL_REGISTRY step_id 拓扑预判逻辑: 1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表, @@ -318,17 +318,12 @@ class PreflightDialog(QDialog): - 若 key 在 dynamically_produced_keys 中 → OK(前置步骤会生成) - 否则 → MissingItem(真正缺失) 3. 智能免检规则: - - formula_csv_path:底层的完全可选参数,任何情况下都免检。 - - step5 boundary_path:若 step1 enabled 或 config 中有 water_mask_path, - 则信任 panel/底层的自动推导机制,不拦截。 - - step14 boundary_shp_path:若 step1 enabled,信任 panel 的自动回填, - 不拦截。 - - 关键阻断项(is_critical=True):step1 img_path 缺失。 + - formula_csv_path:底层完全可选参数,任何情况下都免检。 + - boundary_path / boundary_shp_path:若 step1 enabled,信任 panel/底层自动推导。 """ items: List[MissingItem] = [] - # ── ★ PIPELINE_STEPS step_id → PANEL_REGISTRY step_id(config 的 key)── + # ── PIPELINE_STEPS step_id → PANEL_REGISTRY step_id ── _PIPELINE_TO_CONFIG_KEY: Dict[str, str] = { 'step1': 'step1', 'step2': 'step2', @@ -345,64 +340,12 @@ class PreflightDialog(QDialog): 'step14': 'step11_map', } - def _cfg(step_id: str) -> dict: - """用映射表解析 config key,兼容新旧两种 step_id 体系。""" - config_key = _PIPELINE_TO_CONFIG_KEY.get(step_id, step_id) - return config.get(config_key, {}) + def _cfg(old_step_id: str) -> dict: + """将旧 PIPELINE_STEPS step_id 翻译为新 PANEL_REGISTRY step_id 后取 config。""" + panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(old_step_id, old_step_id) + return config.get(panel_step_id, {}) - step1_cfg = _cfg('step1') - step1_enabled = step1_cfg.get('enabled', False) - - # ── ★ 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ── - dynamically_produced_keys: Set[str] = set() - enabled_step_ids: Set[str] = set() - - for step_spec in PIPELINE_STEPS: - step_cfg = _cfg(step_spec.step_id) - if not step_cfg.get('enabled', True): - continue - enabled_step_ids.add(step_spec.step_id) - dynamically_produced_keys.update(step_spec.produces) - - # ── step1 img_path(阻断性)─────────────────────────────── - img_path = step1_cfg.get('img_path') - if not img_path: - items.append(MissingItem( - step_id="step1", step_name="水域掩膜", - reason="缺少参考影像路径 → 请在「阶段一」中填写「参考影像」", - panel_tab_index=0, is_critical=True - )) - elif not os.path.isfile(img_path): - items.append(MissingItem( - step_id="step1", step_name="水域掩膜", - reason=f"参考影像文件不存在:{img_path}", - panel_tab_index=0, is_critical=True - )) - - # ── step5_clean csv_path(纯外部输入,必须手动提供)─────────── - step4_cfg = _cfg('step4') # 映射到 step5_clean - step4_enabled = step4_cfg.get('enabled', True) - if step4_enabled: - csv_path = step4_cfg.get('csv_path') - if not csv_path: - items.append(MissingItem( - step_id="step5_clean", step_name="数据清洗", - reason="请在「数据清洗」中填写「实测水质数据 CSV」", - panel_tab_index=3 - )) - elif not os.path.isfile(csv_path): - items.append(MissingItem( - step_id="step5_clean", step_name="数据清洗", - reason=f"实测水质数据文件不存在:{csv_path}", - panel_tab_index=3 - )) - - # ── step12 formula_csv_path(绝对免检:底层完全可选)──────── - # formula_csv_path 在底层 CustomRegressionPredictor 中不传即可运行, - # 只影响日志输出,不阻断任何功能。此处不做任何检查。 - - # ── ★ DAG-aware 检查:遍历 enabled 步骤的 required_input_files ── - PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'} + # ── TAB / STEP NAME 映射(基于 PANEL_REGISTRY step_id)── _TAB_INDEX_MAP: Dict[str, int] = { "step1": 0, "step2": 1, "step3": 2, "step4_sampling": 3, "step5_clean": 4, "step6_feature": 5, @@ -419,37 +362,99 @@ class PreflightDialog(QDialog): "step12_viz": "可视化展示", "step13_report": "报告生成", } + # ── step1 img_path(阻断性)── + step1_cfg = _cfg('step1') + img_path = step1_cfg.get('img_path') + if not img_path: + items.append(MissingItem( + step_id="step1", step_name="水域掩膜", + reason="缺少参考影像路径 → 请在「水域掩膜」中填写「参考影像」", + panel_tab_index=0, is_critical=True + )) + elif not os.path.isfile(img_path): + items.append(MissingItem( + step_id="step1", step_name="水域掩膜", + reason=f"参考影像文件不存在:{img_path}", + panel_tab_index=0, is_critical=True + )) + + # ── step5_clean csv_path(纯外部输入,必须手动提供)── + step5_clean_cfg = _cfg('step4') # 旧 step4 → 新 step5_clean + if step5_clean_cfg.get('enabled', True): + csv_path = step5_clean_cfg.get('csv_path') + if not csv_path: + items.append(MissingItem( + step_id="step5_clean", step_name="数据清洗", + reason="请在「数据清洗」中填写「实测水质数据 CSV」", + panel_tab_index=_TAB_INDEX_MAP.get('step5_clean', 4) + )) + elif not os.path.isfile(csv_path): + items.append(MissingItem( + step_id="step5_clean", step_name="数据清洗", + reason=f"实测水质数据文件不存在:{csv_path}", + panel_tab_index=_TAB_INDEX_MAP.get('step5_clean', 4) + )) + + # ── step4_sampling csv_path(采样点 CSV,2026-07-01 新增缺失检查)── + step4_sampling_cfg = _cfg('step10') # 旧 step10 → 新 step4_sampling + if step4_sampling_cfg.get('enabled', True): + sampling_csv = step4_sampling_cfg.get('csv_path') + if not sampling_csv: + items.append(MissingItem( + step_id="step4_sampling", step_name="采样点布设", + reason="请在「采样点布设」中填写「实测水质数据 CSV」", + panel_tab_index=_TAB_INDEX_MAP.get('step4_sampling', 3) + )) + elif not os.path.isfile(sampling_csv): + items.append(MissingItem( + step_id="step4_sampling", step_name="采样点布设", + reason=f"实测水质数据文件不存在:{sampling_csv}", + panel_tab_index=_TAB_INDEX_MAP.get('step4_sampling', 3) + )) + + # ── 构建「动态产物集合」:按 pipeline 顺序收集所有 enabled 步骤的 produces ── + dynamically_produced_keys: Set[str] = set() + enabled_step_ids: Set[str] = set() + + for step_spec in PIPELINE_STEPS: + step_cfg = _cfg(step_spec.step_id) + if not step_cfg.get('enabled', True): + continue + enabled_step_ids.add(step_spec.step_id) + dynamically_produced_keys.update(step_spec.produces) + + # ── DAG-aware 检查:遍历 enabled 步骤的 required_input_files ── + PURE_EXTERNAL_INPUT_KEYS: Set[str] = {'img_path', 'csv_path'} + for step_spec in PIPELINE_STEPS: if step_spec.step_id not in enabled_step_ids: continue - step_cfg = _cfg(step_spec.step_id) # ★ 通过映射表翻译 step_id - # ★ 将 PIPELINE_STEPS step_id 翻译为 PANEL_REGISTRY step_id + # 将旧 PIPELINE_STEPS step_id 翻译为新 PANEL_REGISTRY step_id panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(step_spec.step_id, step_spec.step_id) tab_idx = _TAB_INDEX_MAP.get(panel_step_id, 0) step_name = _STEP_NAME_MAP.get(panel_step_id, panel_step_id) + step_cfg = _cfg(step_spec.step_id) for req_key in step_spec.required_input_files: - # ★★★ 高优先级硬编码白名单 ★★★ - # 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行 + # 边界文件:只要 step1 有影像输入就信任 panel/底层自动推导 if req_key in ('boundary_path', 'boundary_shp_path'): - _step1_cfg = _cfg('step1') - if _step1_cfg.get('img_path') or _step1_cfg.get('enabled', True): - continue # 直接跳过,不判定为缺失 + if step1_cfg.get('img_path') or step1_cfg.get('enabled', True): + continue if req_key in PURE_EXTERNAL_INPUT_KEYS: - continue + continue # 已在上面显式检查 if req_key == 'formula_csv_path': - continue # ★ 底层完全可选,赦免 + continue # 底层完全可选 if req_key == 'boundary_path' and panel_step_id == 'step6_feature': - continue # ★ step1 执行则 panel/底层自动推导,赦免 + continue # step1 执行则底层自动推导 if req_key == 'boundary_shp_path' and panel_step_id == 'step11_map': - continue # ★ step1 执行则 panel 自动回填,赦免 + continue # step1 执行则 panel 自动回填 cfg_val = step_cfg.get(req_key) if cfg_val and os.path.isfile(cfg_val): continue if cfg_val and os.path.isdir(cfg_val): continue if req_key in dynamically_produced_keys: - continue # ★ 前置步骤会生成,拓扑预判通过 + continue # 前置步骤会生成 items.append(MissingItem( step_id=panel_step_id, step_name=step_name, diff --git a/src/gui/core/viz_thread.py b/src/gui/core/viz_thread.py index 80c8af4..e31c6da 100644 --- a/src/gui/core/viz_thread.py +++ b/src/gui/core/viz_thread.py @@ -94,7 +94,7 @@ class VisualizationWorkerThread(QThread): return hyperspectral_path = str(hyperspectral_files[0]) csv_files = [] - processed_dir = wp / "4_processed_data" + processed_dir = wp / "5_Data_Cleaning" if processed_dir.exists(): csv_files = list(processed_dir.glob("*.csv")) if not csv_files: @@ -320,7 +320,7 @@ class VisualizationWorkerThread(QThread): if hyperspectral_files: hyperspectral_path = str(hyperspectral_files[0]) csv_files = [] - processed_dir = wp / "4_processed_data" + processed_dir = wp / "5_Data_Cleaning" if processed_dir.exists(): csv_files = list(processed_dir.glob("*.csv")) if not csv_files: diff --git a/src/gui/core/worker_thread.py b/src/gui/core/worker_thread.py index 865157d..182d9df 100644 --- a/src/gui/core/worker_thread.py +++ b/src/gui/core/worker_thread.py @@ -257,11 +257,25 @@ class WorkerThread(QThread): - 整个 run() 方法体包裹在单一 try/except 中 - 任何未预期的异常都会被捕获并通过 finished 信号回报主线程 - 确保前端永远不会面对"静默死亡"的后台线程 + + 2026-07-01 GDAL 线程安全加固: + - GDAL_NUM_THREADS=1:禁用 GDAL 内部多线程,防止与 QThread 冲突导致 0xC0000005 + - gdal.AllRegister():在子线程中重新注册 GDAL 驱动 """ import os os.environ['GDAL_FILENAME_IS_UTF8'] = 'YES' os.environ['SHAPE_ENCODING'] = 'UTF-8' + # ★ GDAL 线程安全配置(2026-07-01): + # 在子线程中禁用 GDAL 内部多线程,防止多个 QThread 并发调用 GDAL C API 时 + # 触发 0xC0000005 段错误(GDAL 部分驱动非线程安全)。 + try: + from osgeo import gdal + gdal.SetConfigOption('GDAL_NUM_THREADS', '1') + gdal.AllRegister() + except Exception: + pass + mpl_prev = None try: # ★ 终端即时反馈 @@ -466,11 +480,11 @@ class WorkerThread(QThread): ) # ── 步骤8_ml_train(ML 建模) ── - step7_cfg = config.get('step8_ml_train', {}) - step7_csv = step7_cfg.get('training_csv_path') - if step7_csv and not os.path.isfile(step7_csv): + step8_cfg = config.get('step8_ml_train', {}) + step8_csv = step8_cfg.get('training_csv_path') + if step8_csv and not os.path.isfile(step8_csv): errors.append( - f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step7_csv}\n" + f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step8_csv}\n" " → 请确认步骤 5_clean 已成功运行并生成了训练光谱。" ) diff --git a/src/gui/core/workspace_initializer.py b/src/gui/core/workspace_initializer.py index 3ba877a..a5781d4 100644 --- a/src/gui/core/workspace_initializer.py +++ b/src/gui/core/workspace_initializer.py @@ -46,6 +46,7 @@ class WorkspaceInitializer(QObject): super().__init__(parent) self._panel_factory = panel_factory self._work_dir: Optional[str] = None + self._previous_work_dir: Optional[str] = None # ★ 2026-07-01:追踪目录变更 # 工作空间管理器(文件扫描、路径发现) self._workspace_manager = WorkspaceManager() @@ -68,6 +69,7 @@ class WorkspaceInitializer(QObject): @work_dir.setter def work_dir(self, value: str): if value and value != self._work_dir: + self._previous_work_dir = self._work_dir # ★ 保存旧目录用于清理残留路径 self._work_dir = value global_event_bus.publish('WorkspaceChanged', {'work_dir': value}) @@ -137,14 +139,18 @@ class WorkspaceInitializer(QObject): 'level': 'info', }) - # 自动填充 step1 输出路径 - self._auto_fill_output_paths() + # 自动填充 step1 输出路径(已禁用:no-op,保留调用点以备日后启用) + # self._auto_fill_output_paths() def set_work_directory(self): - """手动设置工作目录(菜单触发)。""" + """手动设置工作目录(菜单触发)。 + + 2026-07-01 修复:切换目录后自动触发完整清理+扫描+回填, + 确保所有面板的残留旧路径被清除并替换为新目录的路径。 + """ dir_path = QFileDialog.getExistingDirectory(self.parent(), "选择工作目录") if dir_path: - self.work_dir = dir_path + self.work_dir = dir_path # ★ setter 中保存 _previous_work_dir + 发布 WorkspaceChanged global_event_bus.publish('LogMessage', { 'message': f'工作目录已设置: {dir_path}', 'level': 'info', @@ -156,6 +162,9 @@ class WorkspaceInitializer(QObject): if panel and hasattr(panel, 'set_work_dir'): panel.set_work_dir(dir_path) + # ★ 关键修复:切换目录后强制清理旧路径 + 重新扫描回填 + self.auto_populate_all() + def open_work_directory(self): """在资源管理器中打开工作目录(跨平台)。""" import subprocess @@ -179,6 +188,7 @@ class WorkspaceInitializer(QObject): """扫描工作目录并触发事件总线自动填充所有步骤的输入路径。 流程: + 0. 若工作目录已变更,先清理指向旧目录的残留输出路径 1. WorkspaceManager.scan_work_directory_for_files() 扫描磁盘 2. WorkspaceManager 内部自动发布 OutputUpdated 事件 3. 各面板通过 DependencySubscriber 自动接收并填充 @@ -194,6 +204,10 @@ class WorkspaceInitializer(QObject): QMessageBox.warning(self.parent(), "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。") return + # ★ 2026-07-01:若目录已变更,先清除指向旧目录的残留路径 + if self._previous_work_dir and self._previous_work_dir != work_dir: + self._clear_stale_output_paths(self._previous_work_dir) + # WorkspaceManager 扫描 → 内部自动发布 OutputUpdated 事件 self._workspace_manager.scan_work_directory_for_files(work_path) @@ -262,12 +276,65 @@ class WorkspaceInitializer(QObject): # 内部辅助 # ═══════════════════════════════════════════════════════════ - def _auto_fill_output_paths(self): - """【已禁用】禁止在工作目录刚选定后瞎拼凑假路径。 + def _clear_stale_output_paths(self, old_work_dir: str): + """清除所有面板中指向旧工作目录的输出路径(2026-07-01)。 - 历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir) 自动回填输出路径。 - 现已清空为 no-op,原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径, + 当用户切换工作目录时,面板中自动回填的输出路径(如 water_mask_out.dat) + 仍指向旧目录。由于各 panel 的 update_from_config() 有"非空不覆盖"保护, + 这些残留路径不会被自动更新。此方法遍历所有已加载面板, + 清除以旧 work_dir 开头的输出路径,确保后续 auto_populate_all() 能重新填充。 + + 安全设计: + - 仅清除以旧 work_dir 开头的路径(保留用户手动指定的外部路径) + - 使用 os.path.normpath 归一化比较,兼容正反斜杠混用 + """ + norm_old = os.path.normpath(old_work_dir).replace('\\', '/') + if not norm_old: + return + + cleared_count = 0 + for step_id, panel in self._panel_factory.get_loaded_panels().items(): + # 遍历面板中常见的输出/路径控件 + for attr_name in ('output_file', 'output_path', 'output_dir', + 'models_dir_file', 'sampling_csv_file', + 'training_csv_file', 'prediction_csv_dir_edit', + 'boundary_file', 'geotiff_dir_edit'): + widget = getattr(panel, attr_name, None) + if widget is None: + continue + # 获取当前路径(兼容 FileSelectWidget 和 QLineEdit) + current = '' + if hasattr(widget, 'get_path'): + current = (widget.get_path() or '').strip() + elif hasattr(widget, 'text'): + current = (widget.text() or '').strip() + if not current: + continue + # 归一化判断:路径是否指向旧 work_dir + norm_current = os.path.normpath(current).replace('\\', '/') + if norm_current.startswith(norm_old + '/'): + # 清除旧目录残留 + if hasattr(widget, 'set_path'): + widget.set_path('') + elif hasattr(widget, 'setText'): + widget.setText('') + cleared_count += 1 + + if cleared_count > 0: + global_event_bus.publish('LogMessage', { + 'message': f'✓ 已清理 {cleared_count} 个指向旧工作目录的残留路径', + 'level': 'info', + }) + + def _auto_fill_output_paths(self): + """【已禁用 — 2026-06-30 起永久禁用】 + + 历史行为:曾经在此处调用 step1_panel.update_work_directory(self._work_dir) + 自动回填输出路径。现已清空为 no-op。 + 原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径, 会污染后续面板的 input field,让用户误以为已经填好了实际却指向不存在的目录。 真实填充时机交由面板自身的 update_from_config + 用户手动指定。 + + 调用点已在 run() 中注释掉,此方法仅保留定义供外部存档引用。 """ pass diff --git a/src/gui/panels/step11_map_panel.py b/src/gui/panels/step11_map_panel.py index e04839e..7639a46 100644 --- a/src/gui/panels/step11_map_panel.py +++ b/src/gui/panels/step11_map_panel.py @@ -26,7 +26,10 @@ PIPELINE_AVAILABLE = True class Step11MapBatchThread(QThread): - """专题图:按文件夹内多个预测 CSV 批量生成分布图。""" + """专题图:按文件夹内多个预测 CSV 批量生成分布图。 + + 2026-07-01 修复:新增 is_running 标志 + stop() 方法,支持外部安全取消。 + """ finished_ok = pyqtSignal(int) failed = pyqtSignal(str) @@ -39,6 +42,19 @@ class Step11MapBatchThread(QThread): self.csv_paths = csv_paths self.step10_kwargs = step10_kwargs self.output_dir_optional = (output_dir_optional or "").strip() or None + self._cancelled = False + + def cancel(self): + """请求取消(不立即 terminate,让循环自然退出)。""" + self._cancelled = True + + def stop(self): + """安全停止:先请求取消 + 等待 3s,超时后 terminate。""" + self.cancel() + if self.isRunning(): + if not self.wait(3000): + self.terminate() + self.wait(2000) def run(self): mpl_prev = None @@ -56,6 +72,9 @@ class Step11MapBatchThread(QThread): from src.core.steps.mapping_step import MappingStep n = len(self.csv_paths) for i, csv_p in enumerate(self.csv_paths): + if self._cancelled: + self.log_message.emit("专题图批量任务已被用户取消", "warning") + break self.progress.emit(i + 1, n) self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info") kw = {**self.step10_kwargs, "prediction_csv_path": csv_p} @@ -79,7 +98,10 @@ class Step11MapBatchThread(QThread): class Step11GeoTIFFBatchThread(QThread): - """GeoTIFF 批量渲染:遍历文件夹下所有 .tif/.bsq 逐一渲染成分布图 PNG。""" + """GeoTIFF 批量渲染:遍历文件夹下所有 .tif/.bsq 逐一渲染成分布图 PNG。 + + 2026-07-01 修复:新增 is_running 标志 + stop() 方法,支持外部安全取消。 + """ finished_ok = pyqtSignal(int) failed = pyqtSignal(str) @@ -100,6 +122,19 @@ class Step11GeoTIFFBatchThread(QThread): self.boundary_shp_path = boundary_shp_path self.input_crs = input_crs self.output_crs = output_crs + self._cancelled = False + + def cancel(self): + """请求取消(不立即 terminate,让循环自然退出)。""" + self._cancelled = True + + def stop(self): + """安全停止:先请求取消 + 等待 3s,超时后 terminate。""" + self.cancel() + if self.isRunning(): + if not self.wait(3000): + self.terminate() + self.wait(2000) def run(self): mpl_prev = None @@ -118,6 +153,9 @@ class Step11GeoTIFFBatchThread(QThread): mapper = ContentMapper() n = len(self.tif_paths) for i, tif_path in enumerate(self.tif_paths): + if self._cancelled: + self.log_message.emit("GeoTIFF 批量渲染已被用户取消", "warning") + break self.progress.emit(i + 1, n) tif_stem = Path(tif_path).stem chinese_name = mapper._get_chinese_title(tif_stem) @@ -148,11 +186,28 @@ class Step11GeoTIFFBatchThread(QThread): class Step11MapPanel(QWidget): - """步骤11:专题图生成""" + """步骤11:专题图生成。 + + 2026-07-01 修复:订阅 PipelineStopped 事件,支持主窗口停止按钮取消批量任务。 + """ def __init__(self, parent=None): super().__init__(parent) self._batch_thread = None self.init_ui() + # 订阅 PipelineStopped 事件,用户点"强制停止"时自动取消批量线程 + from src.gui.core.event_bus import global_event_bus + self._on_stop_sub = lambda data: self._cancel_batch_thread() + global_event_bus.subscribe('PipelineStopped', self._on_stop_sub) + # 面板销毁时清理订阅 + self.destroyed.connect(lambda: global_event_bus.unsubscribe('PipelineStopped', self._on_stop_sub)) + + def _cancel_batch_thread(self): + """安全取消正在运行的批量线程。""" + if self._batch_thread and self._batch_thread.isRunning(): + if hasattr(self._batch_thread, 'stop'): + self._batch_thread.stop() + elif hasattr(self._batch_thread, 'cancel'): + self._batch_thread.cancel() def init_ui(self): layout = QVBoxLayout() @@ -410,6 +465,9 @@ class Step11MapPanel(QWidget): pred_dir = (self.prediction_csv_dir_edit.text() or "").strip() geotiff_path = (self.geotiff_file.get_path() or "").strip() config = { + # ★ 2026-07-01 修复:同时提供 batch_mode (bool, Handler 使用) 和 + # step10_batch_mode (str, 旧代码兼容) + 'batch_mode': folder_mode, 'step10_batch_mode': 'folder' if folder_mode else 'single', 'render_mode': self.render_mode_combo.currentText(), 'prediction_csv_dir': pred_dir if pred_dir else None, diff --git a/src/gui/panels/step8_ml_train_panel.py b/src/gui/panels/step8_ml_train_panel.py index 5426b60..cba5a64 100644 --- a/src/gui/panels/step8_ml_train_panel.py +++ b/src/gui/panels/step8_ml_train_panel.py @@ -528,11 +528,16 @@ class Step8MlTrainPanel(QWidget): def get_training_params(self): """获取模型训练参数""" + # ★ 2026-07-01 安全保护:float() 前验证 currentText() 是否为有效数值 + feature_text = self.feature_start.currentText() + try: + feature_start = float(feature_text) + except (ValueError, TypeError): + feature_start = 374.285004 # 默认光谱起始列 + return { 'pipeline_type': 'machine_learning', - # QComboBox 适配:currentText() 取列名;下拉项里就是"374.285004" 等纯数字波段名 - # (与原 QLineEdit 中 "374.285004" 字符串保持完全一致,后端 float() 解析不变) - 'feature_start': float(self.feature_start.currentText()), + 'feature_start': feature_start, 'cv_folds': self.cv_folds.value(), 'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()], 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()],