格式统一

This commit is contained in:
duxin
2026-07-01 13:19:46 +08:00
parent 8de73db80e
commit 1611001a72
13 changed files with 348 additions and 116 deletions

View File

@ -91,6 +91,9 @@ class PipelineContext:
# ★ 取消标志(2026-06-30):支持 Pipeline 优雅中断 # ★ 取消标志(2026-06-30):支持 Pipeline 优雅中断
self._cancelled: bool = False self._cancelled: bool = False
# ★ 步骤输出目录映射(实例级别,2026-07-01 修复:从类变量改为实例变量,防止跨 work_dir 路径错乱)
self._step_output_dir_map: Optional[Dict[str, Path]] = None
# ── 可视化组件(延迟导入避免循环依赖)── # ── 可视化组件(延迟导入避免循环依赖)──
self._visualizer = None self._visualizer = None
self._report_generator = 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]: def _ensure_step_dir_map(self) -> Dict[str, Path]:
if PipelineContext._STEP_OUTPUT_DIR_MAP is not None: """构建实例级别的步骤输出目录映射(每个 work_dir 独立缓存)。"""
return PipelineContext._STEP_OUTPUT_DIR_MAP if self._step_output_dir_map is not None:
return self._step_output_dir_map
wp = self.work_dir wp = self.work_dir
m = { m = {
'step1': wp / '1_water_mask', 'step1': wp / '1_water_mask',
@ -232,7 +234,7 @@ class PipelineContext:
'visualization': wp / '12_visualization', 'visualization': wp / '12_visualization',
'reports': wp / 'reports', 'reports': wp / 'reports',
} }
PipelineContext._STEP_OUTPUT_DIR_MAP = m self._step_output_dir_map = m
return m return m
def get_step_output_dir(self, step_name: str) -> Path: def get_step_output_dir(self, step_name: str) -> Path:

View File

@ -42,6 +42,34 @@ def register_all_handlers(scheduler: PipelineScheduler):
result = scheduler.run_full_pipeline(config) result = scheduler.run_full_pipeline(config)
新增步骤时,在此函数中追加一行 register_handler() 即可。 新增步骤时,在此函数中追加一行 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(Step1WaterMaskHandler())
scheduler.register_handler(Step2GlintDetectionHandler()) scheduler.register_handler(Step2GlintDetectionHandler())

View File

@ -30,6 +30,27 @@ class Step12KrigingHandler(BaseStepHandler):
prediction_csv_path = config.get('prediction_csv_path') prediction_csv_path = config.get('prediction_csv_path')
boundary_shp_path = config.get('boundary_shp_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 # 强制输出到 visualization_dir
csv_name = Path(prediction_csv_path).stem if prediction_csv_path else "distribution" 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") forced_image_path = str(context.visualization_dir / f"{csv_name}_distribution.png")

View File

@ -33,10 +33,15 @@ class Step13VisualizationHandler(BaseStepHandler):
step_start_time = time.time() step_start_time = time.time()
output_files: Dict[str, Any] = {} 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: try:
# ── 散点图 ── # ── 散点图 ──
if config.get('generate_scatter', True): 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: try:
scatter_config = config.get('scatter_config', {}) scatter_config = config.get('scatter_config', {})
scatter_paths = self._generate_scatter_plots(context, 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 config.get('generate_boxplots', True):
if context.processed_csv_path: if _processed_csv:
try: try:
boxplot_config = config.get('boxplot_config', {}) boxplot_config = config.get('boxplot_config', {})
boxplot_paths = self._generate_boxplots(context, boxplot_config) boxplot_paths = self._generate_boxplots(context, boxplot_config)
@ -58,7 +63,7 @@ class Step13VisualizationHandler(BaseStepHandler):
# ── 光谱曲线 ── # ── 光谱曲线 ──
if config.get('generate_spectrum', True): if config.get('generate_spectrum', True):
if context.training_csv_path: if _training_csv:
try: try:
spectrum_paths = self._generate_spectrum_plots(context, config) spectrum_paths = self._generate_spectrum_plots(context, config)
output_files['spectrum_plots'] = spectrum_paths output_files['spectrum_plots'] = spectrum_paths
@ -68,7 +73,7 @@ class Step13VisualizationHandler(BaseStepHandler):
# ── 统计图表 ── # ── 统计图表 ──
if config.get('generate_statistics', True): if config.get('generate_statistics', True):
if context.processed_csv_path: if _processed_csv:
try: try:
stat_charts = self._generate_statistics(context) stat_charts = self._generate_statistics(context)
output_files['statistical_charts'] = stat_charts output_files['statistical_charts'] = stat_charts

View File

@ -38,7 +38,7 @@ class Step9MlPredictHandler(BaseStepHandler):
output_dir = ( output_dir = (
config.get('output_path') config.get('output_path')
or config.get('output_dir') or config.get('output_dir')
or str(context.prediction_dir / "9_ML_Prediction") or str(context.ml_prediction_dir)
) )
try: try:

View File

@ -51,12 +51,28 @@ class EventBus:
"""发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。 """发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。
迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。 迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。
2026-07-01 修复:捕获 RuntimeError(sip 野指针异常)防止已删除的 C++ 对象回调崩溃。
""" """
with self._lock: with self._lock:
snapshot = list(self._subscribers.get(event_name, [])) snapshot = list(self._subscribers.get(event_name, []))
for callback in snapshot: for callback in snapshot:
try: try:
callback(data) 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: except Exception:
cb_name = getattr(callback, '__name__', None) or repr(callback) cb_name = getattr(callback, '__name__', None) or repr(callback)
err_msg = ( err_msg = (

View File

@ -537,16 +537,27 @@ class PipelineExecutor(QObject):
def _check_worker_health(self): def _check_worker_health(self):
"""看门狗巡检:每 5 秒触发一次。 """看门狗巡检:每 5 秒触发一次。
2026-07-01 修复:优先尝试优雅停止再 fallback terminate()
判定逻辑: 判定逻辑:
- Worker 已不在运行(自然结束/被强杀) → 停掉看门狗 - Worker 已不在运行(自然结束/被强杀) → 停掉看门狗
- Worker 仍在运行 + 上次进度距今 > 600 秒 → 判定为假死/死锁, - Worker 仍在运行 + 上次进度距今 > 600 秒 → 判定为假死/死锁,
强制 terminate() 并通过 _on_finished(False, ...) 汇流解 UI, 先尝试 WorkerThread.stop() 优雅停止(5 秒超时),
让被卡死的「独立运行此步骤」按钮恢复可用。 若优雅停止也超时则 fallback 到 terminate()
""" """
import time import time
if self._worker and self._worker.isRunning(): if self._worker and self._worker.isRunning():
if time.time() - self._last_progress_time > 600: if time.time() - self._last_progress_time > 600:
self._log_message("[错误] 后台任务响应超时(超过10分钟无响应),强制终止...", "error") 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._worker.terminate()
self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。") self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。")
else: else:

View File

@ -304,11 +304,11 @@ class PreflightDialog(QDialog):
def build_missing_items(config: dict) -> List[MissingItem]: def build_missing_items(config: dict) -> List[MissingItem]:
"""DAG-aware 预检:从 config 构建缺失项列表。 """DAG-aware 预检:从 config 构建缺失项列表。
2026-06-30 修复(步骤 ID 错配): 2026-07-01 修复(变量命名混乱 + 缺少 step4_sampling CSV 预检):
config 的 key 是 PANEL_REGISTRY 的 step_id(如 step4_sampling), - 将变量名从旧 PIPELINE_STEPS 编号改为 PANEL_REGISTRY step_id
而 PIPELINE_STEPS 使用另一套 step_id(如 step4)。 - 新增 step4_sampling 采样点 CSV 文件的显式预检
现通过 _PIPELINE_TO_CONFIG_KEY 映射表统一翻译, - 将 _TAB_INDEX_MAP 提升到顶级并与 STEP_TAB_MAP 合并使用
避免 preflight 永远误报缺失项。 - _cfg() 映射表保留用于翻译旧 PIPELINE_STEPS step_id → 新 PANEL_REGISTRY step_id
拓扑预判逻辑: 拓扑预判逻辑:
1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表, 1. 按 pipeline 顺序遍历所有 enabled=True 的步骤,收集其 produces 列表,
@ -318,17 +318,12 @@ class PreflightDialog(QDialog):
- 若 key 在 dynamically_produced_keys 中 → OK(前置步骤会生成) - 若 key 在 dynamically_produced_keys 中 → OK(前置步骤会生成)
- 否则 → MissingItem(真正缺失) - 否则 → MissingItem(真正缺失)
3. 智能免检规则: 3. 智能免检规则:
- formula_csv_path:底层的完全可选参数,任何情况下都免检。 - formula_csv_path:底层完全可选参数,任何情况下都免检。
- step5 boundary_path:若 step1 enabled 或 config 中有 water_mask_path, - boundary_path / boundary_shp_path:若 step1 enabled,信任 panel/底层自动推导。
则信任 panel/底层的自动推导机制,不拦截。
- step14 boundary_shp_path:若 step1 enabled,信任 panel 的自动回填,
不拦截。
关键阻断项(is_critical=True):step1 img_path 缺失。
""" """
items: List[MissingItem] = [] 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] = { _PIPELINE_TO_CONFIG_KEY: Dict[str, str] = {
'step1': 'step1', 'step1': 'step1',
'step2': 'step2', 'step2': 'step2',
@ -345,64 +340,12 @@ class PreflightDialog(QDialog):
'step14': 'step11_map', 'step14': 'step11_map',
} }
def _cfg(step_id: str) -> dict: def _cfg(old_step_id: str) -> dict:
"""用映射表解析 config key,兼容新旧两种 step_id 体系。""" """将旧 PIPELINE_STEPS step_id 翻译为新 PANEL_REGISTRY step_id 后取 config。"""
config_key = _PIPELINE_TO_CONFIG_KEY.get(step_id, step_id) panel_step_id = _PIPELINE_TO_CONFIG_KEY.get(old_step_id, old_step_id)
return config.get(config_key, {}) return config.get(panel_step_id, {})
step1_cfg = _cfg('step1') # ── TAB / STEP NAME 映射(基于 PANEL_REGISTRY step_id)──
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_INDEX_MAP: Dict[str, int] = { _TAB_INDEX_MAP: Dict[str, int] = {
"step1": 0, "step2": 1, "step3": 2, "step1": 0, "step2": 1, "step3": 2,
"step4_sampling": 3, "step5_clean": 4, "step6_feature": 5, "step4_sampling": 3, "step5_clean": 4, "step6_feature": 5,
@ -419,37 +362,99 @@ class PreflightDialog(QDialog):
"step12_viz": "可视化展示", "step13_report": "报告生成", "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: for step_spec in PIPELINE_STEPS:
if step_spec.step_id not in enabled_step_ids: if step_spec.step_id not in enabled_step_ids:
continue continue
step_cfg = _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) 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) tab_idx = _TAB_INDEX_MAP.get(panel_step_id, 0)
step_name = _STEP_NAME_MAP.get(panel_step_id, panel_step_id) 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: for req_key in step_spec.required_input_files:
# ★★★ 高优先级硬编码白名单 ★★★ # 边界文件:只要 step1 有影像输入就信任 panel/底层自动推导
# 当检测到需求为边界文件时,只要 step1 有填影像(代表有基础,底层能自动推导),直接放行
if req_key in ('boundary_path', 'boundary_shp_path'): 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):
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 in PURE_EXTERNAL_INPUT_KEYS:
continue # 已在上面显式检查
if req_key == 'formula_csv_path': if req_key == 'formula_csv_path':
continue # ★ 底层完全可选,赦免 continue # 底层完全可选
if req_key == 'boundary_path' and panel_step_id == 'step6_feature': 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': if req_key == 'boundary_shp_path' and panel_step_id == 'step11_map':
continue # ★ step1 执行则 panel 自动回填,赦免 continue # step1 执行则 panel 自动回填
cfg_val = step_cfg.get(req_key) cfg_val = step_cfg.get(req_key)
if cfg_val and os.path.isfile(cfg_val): if cfg_val and os.path.isfile(cfg_val):
continue continue
if cfg_val and os.path.isdir(cfg_val): if cfg_val and os.path.isdir(cfg_val):
continue continue
if req_key in dynamically_produced_keys: if req_key in dynamically_produced_keys:
continue # ★ 前置步骤会生成,拓扑预判通过 continue # 前置步骤会生成
items.append(MissingItem( items.append(MissingItem(
step_id=panel_step_id, step_id=panel_step_id,
step_name=step_name, step_name=step_name,

View File

@ -94,7 +94,7 @@ class VisualizationWorkerThread(QThread):
return return
hyperspectral_path = str(hyperspectral_files[0]) hyperspectral_path = str(hyperspectral_files[0])
csv_files = [] csv_files = []
processed_dir = wp / "4_processed_data" processed_dir = wp / "5_Data_Cleaning"
if processed_dir.exists(): if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv")) csv_files = list(processed_dir.glob("*.csv"))
if not csv_files: if not csv_files:
@ -320,7 +320,7 @@ class VisualizationWorkerThread(QThread):
if hyperspectral_files: if hyperspectral_files:
hyperspectral_path = str(hyperspectral_files[0]) hyperspectral_path = str(hyperspectral_files[0])
csv_files = [] csv_files = []
processed_dir = wp / "4_processed_data" processed_dir = wp / "5_Data_Cleaning"
if processed_dir.exists(): if processed_dir.exists():
csv_files = list(processed_dir.glob("*.csv")) csv_files = list(processed_dir.glob("*.csv"))
if not csv_files: if not csv_files:

View File

@ -257,11 +257,25 @@ class WorkerThread(QThread):
- 整个 run() 方法体包裹在单一 try/except 中 - 整个 run() 方法体包裹在单一 try/except 中
- 任何未预期的异常都会被捕获并通过 finished 信号回报主线程 - 任何未预期的异常都会被捕获并通过 finished 信号回报主线程
- 确保前端永远不会面对"静默死亡"的后台线程 - 确保前端永远不会面对"静默死亡"的后台线程
2026-07-01 GDAL 线程安全加固:
- GDAL_NUM_THREADS=1:禁用 GDAL 内部多线程,防止与 QThread 冲突导致 0xC0000005
- gdal.AllRegister():在子线程中重新注册 GDAL 驱动
""" """
import os import os
os.environ['GDAL_FILENAME_IS_UTF8'] = 'YES' os.environ['GDAL_FILENAME_IS_UTF8'] = 'YES'
os.environ['SHAPE_ENCODING'] = 'UTF-8' 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 mpl_prev = None
try: try:
# ★ 终端即时反馈 # ★ 终端即时反馈
@ -466,11 +480,11 @@ class WorkerThread(QThread):
) )
# ── 步骤8_ml_train(ML 建模) ── # ── 步骤8_ml_train(ML 建模) ──
step7_cfg = config.get('step8_ml_train', {}) step8_cfg = config.get('step8_ml_train', {})
step7_csv = step7_cfg.get('training_csv_path') step8_csv = step8_cfg.get('training_csv_path')
if step7_csv and not os.path.isfile(step7_csv): if step8_csv and not os.path.isfile(step8_csv):
errors.append( errors.append(
f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step7_csv}\n" f"步骤 8_ml_train(ML 建模):训练光谱文件不存在:\n {step8_csv}\n"
" → 请确认步骤 5_clean 已成功运行并生成了训练光谱。" " → 请确认步骤 5_clean 已成功运行并生成了训练光谱。"
) )

View File

@ -46,6 +46,7 @@ class WorkspaceInitializer(QObject):
super().__init__(parent) super().__init__(parent)
self._panel_factory = panel_factory self._panel_factory = panel_factory
self._work_dir: Optional[str] = None self._work_dir: Optional[str] = None
self._previous_work_dir: Optional[str] = None # ★ 2026-07-01:追踪目录变更
# 工作空间管理器(文件扫描、路径发现) # 工作空间管理器(文件扫描、路径发现)
self._workspace_manager = WorkspaceManager() self._workspace_manager = WorkspaceManager()
@ -68,6 +69,7 @@ class WorkspaceInitializer(QObject):
@work_dir.setter @work_dir.setter
def work_dir(self, value: str): def work_dir(self, value: str):
if value and value != self._work_dir: if value and value != self._work_dir:
self._previous_work_dir = self._work_dir # ★ 保存旧目录用于清理残留路径
self._work_dir = value self._work_dir = value
global_event_bus.publish('WorkspaceChanged', {'work_dir': value}) global_event_bus.publish('WorkspaceChanged', {'work_dir': value})
@ -137,14 +139,18 @@ class WorkspaceInitializer(QObject):
'level': 'info', 'level': 'info',
}) })
# 自动填充 step1 输出路径 # 自动填充 step1 输出路径(已禁用:no-op,保留调用点以备日后启用)
self._auto_fill_output_paths() # self._auto_fill_output_paths()
def set_work_directory(self): def set_work_directory(self):
"""手动设置工作目录(菜单触发)。""" """手动设置工作目录(菜单触发)。
2026-07-01 修复:切换目录后自动触发完整清理+扫描+回填,
确保所有面板的残留旧路径被清除并替换为新目录的路径。
"""
dir_path = QFileDialog.getExistingDirectory(self.parent(), "选择工作目录") dir_path = QFileDialog.getExistingDirectory(self.parent(), "选择工作目录")
if dir_path: if dir_path:
self.work_dir = dir_path self.work_dir = dir_path # ★ setter 中保存 _previous_work_dir + 发布 WorkspaceChanged
global_event_bus.publish('LogMessage', { global_event_bus.publish('LogMessage', {
'message': f'工作目录已设置: {dir_path}', 'message': f'工作目录已设置: {dir_path}',
'level': 'info', 'level': 'info',
@ -156,6 +162,9 @@ class WorkspaceInitializer(QObject):
if panel and hasattr(panel, 'set_work_dir'): if panel and hasattr(panel, 'set_work_dir'):
panel.set_work_dir(dir_path) panel.set_work_dir(dir_path)
# ★ 关键修复:切换目录后强制清理旧路径 + 重新扫描回填
self.auto_populate_all()
def open_work_directory(self): def open_work_directory(self):
"""在资源管理器中打开工作目录(跨平台)。""" """在资源管理器中打开工作目录(跨平台)。"""
import subprocess import subprocess
@ -179,6 +188,7 @@ class WorkspaceInitializer(QObject):
"""扫描工作目录并触发事件总线自动填充所有步骤的输入路径。 """扫描工作目录并触发事件总线自动填充所有步骤的输入路径。
流程: 流程:
0. 若工作目录已变更,先清理指向旧目录的残留输出路径
1. WorkspaceManager.scan_work_directory_for_files() 扫描磁盘 1. WorkspaceManager.scan_work_directory_for_files() 扫描磁盘
2. WorkspaceManager 内部自动发布 OutputUpdated 事件 2. WorkspaceManager 内部自动发布 OutputUpdated 事件
3. 各面板通过 DependencySubscriber 自动接收并填充 3. 各面板通过 DependencySubscriber 自动接收并填充
@ -194,6 +204,10 @@ class WorkspaceInitializer(QObject):
QMessageBox.warning(self.parent(), "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。") QMessageBox.warning(self.parent(), "警告", f"工作目录不存在: {work_dir}\n请先设置正确的工作目录。")
return 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 事件 # WorkspaceManager 扫描 → 内部自动发布 OutputUpdated 事件
self._workspace_manager.scan_work_directory_for_files(work_path) 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) 自动回填输出路径。 当用户切换工作目录时,面板中自动回填的输出路径(如 water_mask_out.dat)
现已清空为 no-op,原因:刚选定目录时任何"自动推断"的子目录都是幽灵路径, 仍指向旧目录。由于各 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,让用户误以为已经填好了实际却指向不存在的目录。 会污染后续面板的 input field,让用户误以为已经填好了实际却指向不存在的目录。
真实填充时机交由面板自身的 update_from_config + 用户手动指定。 真实填充时机交由面板自身的 update_from_config + 用户手动指定。
调用点已在 run() 中注释掉,此方法仅保留定义供外部存档引用。
""" """
pass pass

View File

@ -26,7 +26,10 @@ PIPELINE_AVAILABLE = True
class Step11MapBatchThread(QThread): class Step11MapBatchThread(QThread):
"""专题图:按文件夹内多个预测 CSV 批量生成分布图。""" """专题图:按文件夹内多个预测 CSV 批量生成分布图。
2026-07-01 修复:新增 is_running 标志 + stop() 方法,支持外部安全取消。
"""
finished_ok = pyqtSignal(int) finished_ok = pyqtSignal(int)
failed = pyqtSignal(str) failed = pyqtSignal(str)
@ -39,6 +42,19 @@ class Step11MapBatchThread(QThread):
self.csv_paths = csv_paths self.csv_paths = csv_paths
self.step10_kwargs = step10_kwargs self.step10_kwargs = step10_kwargs
self.output_dir_optional = (output_dir_optional or "").strip() or None 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): def run(self):
mpl_prev = None mpl_prev = None
@ -56,6 +72,9 @@ class Step11MapBatchThread(QThread):
from src.core.steps.mapping_step import MappingStep from src.core.steps.mapping_step import MappingStep
n = len(self.csv_paths) n = len(self.csv_paths)
for i, csv_p in enumerate(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.progress.emit(i + 1, n)
self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info") self.log_message.emit(f"专题图 [{i + 1}/{n}] {csv_p}", "info")
kw = {**self.step10_kwargs, "prediction_csv_path": csv_p} kw = {**self.step10_kwargs, "prediction_csv_path": csv_p}
@ -79,7 +98,10 @@ class Step11MapBatchThread(QThread):
class Step11GeoTIFFBatchThread(QThread): class Step11GeoTIFFBatchThread(QThread):
"""GeoTIFF 批量渲染:遍历文件夹下所有 .tif/.bsq 逐一渲染成分布图 PNG。""" """GeoTIFF 批量渲染:遍历文件夹下所有 .tif/.bsq 逐一渲染成分布图 PNG。
2026-07-01 修复:新增 is_running 标志 + stop() 方法,支持外部安全取消。
"""
finished_ok = pyqtSignal(int) finished_ok = pyqtSignal(int)
failed = pyqtSignal(str) failed = pyqtSignal(str)
@ -100,6 +122,19 @@ class Step11GeoTIFFBatchThread(QThread):
self.boundary_shp_path = boundary_shp_path self.boundary_shp_path = boundary_shp_path
self.input_crs = input_crs self.input_crs = input_crs
self.output_crs = output_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): def run(self):
mpl_prev = None mpl_prev = None
@ -118,6 +153,9 @@ class Step11GeoTIFFBatchThread(QThread):
mapper = ContentMapper() mapper = ContentMapper()
n = len(self.tif_paths) n = len(self.tif_paths)
for i, tif_path in enumerate(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) self.progress.emit(i + 1, n)
tif_stem = Path(tif_path).stem tif_stem = Path(tif_path).stem
chinese_name = mapper._get_chinese_title(tif_stem) chinese_name = mapper._get_chinese_title(tif_stem)
@ -148,11 +186,28 @@ class Step11GeoTIFFBatchThread(QThread):
class Step11MapPanel(QWidget): class Step11MapPanel(QWidget):
"""步骤11:专题图生成""" """步骤11:专题图生成。
2026-07-01 修复:订阅 PipelineStopped 事件,支持主窗口停止按钮取消批量任务。
"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._batch_thread = None self._batch_thread = None
self.init_ui() 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): def init_ui(self):
layout = QVBoxLayout() layout = QVBoxLayout()
@ -410,6 +465,9 @@ class Step11MapPanel(QWidget):
pred_dir = (self.prediction_csv_dir_edit.text() or "").strip() pred_dir = (self.prediction_csv_dir_edit.text() or "").strip()
geotiff_path = (self.geotiff_file.get_path() or "").strip() geotiff_path = (self.geotiff_file.get_path() or "").strip()
config = { 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', 'step10_batch_mode': 'folder' if folder_mode else 'single',
'render_mode': self.render_mode_combo.currentText(), 'render_mode': self.render_mode_combo.currentText(),
'prediction_csv_dir': pred_dir if pred_dir else None, 'prediction_csv_dir': pred_dir if pred_dir else None,

View File

@ -528,11 +528,16 @@ class Step8MlTrainPanel(QWidget):
def get_training_params(self): 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 { return {
'pipeline_type': 'machine_learning', 'pipeline_type': 'machine_learning',
# QComboBox 适配:currentText() 取列名;下拉项里就是"374.285004" 等纯数字波段名 'feature_start': feature_start,
# (与原 QLineEdit 中 "374.285004" 字符串保持完全一致,后端 float() 解析不变)
'feature_start': float(self.feature_start.currentText()),
'cv_folds': self.cv_folds.value(), 'cv_folds': self.cv_folds.value(),
'preprocess_methods': [method for method, cb in self.preproc_checkboxes.items() if cb.isChecked()], '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()], 'model_types': [model for model, cb in self.model_checkboxes.items() if cb.isChecked()],