格式统一
This commit is contained in:
@ -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:
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -537,17 +537,28 @@ 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")
|
||||||
self._worker.terminate()
|
# 先尝试通过 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, "后台任务响应超时或发生底层段错误,已强制终止。")
|
self._on_finished(False, "后台任务响应超时或发生底层段错误,已强制终止。")
|
||||||
else:
|
else:
|
||||||
# Worker 已退出(自然结束 / 已 terminate),关闭看门狗
|
# Worker 已退出(自然结束 / 已 terminate),关闭看门狗
|
||||||
|
|||||||
@ -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
|
||||||
continue # 直接跳过,不判定为缺失
|
|
||||||
if req_key in PURE_EXTERNAL_INPUT_KEYS:
|
if req_key in PURE_EXTERNAL_INPUT_KEYS:
|
||||||
continue
|
continue # 已在上面显式检查
|
||||||
if req_key == 'formula_csv_path':
|
if req_key == 'formula_csv_path':
|
||||||
continue # ★ 底层完全可选,赦免
|
continue # 底层完全可选
|
||||||
if req_key == 'boundary_path' and 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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 已成功运行并生成了训练光谱。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()],
|
||||||
|
|||||||
Reference in New Issue
Block a user