From 9e433395f411fb5a9677870a2c53bd8c8df0f9d5 Mon Sep 17 00:00:00 2001 From: duxin Date: Tue, 30 Jun 2026 13:13:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A2=E9=95=BF=E5=81=8F=E7=A7=BB?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20+=20=E9=A1=B9=E7=9B=AE=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BandMathCalculator 支持 wavelength_offset 参数,公式波长统一加减偏移后匹配传感器波段 - WaterQualityIndexCalculator 全链传递偏移量 (band_math → calculate_one → calculate_many) - WaterIndexCsvProcessor / Step7Handler / DataPreparationStep 传播偏移参数 - Step7/Step10 面板新增 QDoubleSpinBox 波长偏移控件 (±200nm, 默认0) - 偏移控件去除单位后缀,避免编辑时需手动移动光标 - 新增 ARCHITECTURE.md 完整项目架构文档 --- ARCHITECTURE.md | 452 ++++++++++++++++++ .../waterindex_inversion/csv_processor.py | 5 +- src/core/handlers/step7_calc_indices.py | 1 + src/core/steps/data_preparation_step.py | 3 +- src/gui/panels/step10_watercolor_panel.py | 24 +- src/gui/panels/step7_inversion_panel.py | 34 +- src/utils/band_math.py | 19 +- src/utils/water_index.py | 14 +- 8 files changed, 537 insertions(+), 15 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..38e71e2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,452 @@ +# WQ_GUI 水质遥感分析系统 — 架构文档 + +> 生成日期:2026-06-30 | 分支:Mega2.2 | 步骤数:13 + +--- + +## 一、项目总览 + +``` +src/ +├── gui/ +│ ├── water_quality_gui.py # V1 入口 +│ ├── water_quality_gui_v2.py # V2 入口(当前主力) +│ ├── styles.py # 全局样式 ModernStylesheet +│ ├── core/ +│ │ ├── panel_registry.py # 步骤注册表 +│ │ ├── panel_factory.py # 惰性加载面板工厂 +│ │ ├── pipeline_executor.py # 流水线调度 +│ │ ├── event_bus.py # 全局事件总线 +│ │ ├── worker_thread.py # 工作线程 +│ │ ├── dependency_subscriber.py # 步骤间依赖自动注入 +│ │ └── workspace_initializer.py # 工作区初始化 +│ ├── panels/ +│ │ ├── _step_path_resolver.py # 路径解析(文件系统扫描) +│ │ ├── step1_panel.py ~ step13_report_panel.py # 13个步骤面板 +│ │ └── ... +│ └── components/ +│ ├── custom_widgets.py # FileSelectWidget / DirSelectWidget +│ ├── data_models.py # PandasTableModel +│ └── chart_dialogs.py # InteractiveViewerDialog 等 +├── core/ # 后端处理引擎 +│ ├── handlers/ # 步骤处理器(14个) +│ ├── steps/ # 步骤算法实现 +│ ├── algorithms/ # 核心算法 +│ └── pipeline/ # 流水线调度器 +├── postprocessing/ # 后处理(可视化、地图、报告) +└── new/ # 新架构(部分迁移中) +``` + +--- + +## 二、核心架构机制 + +### 2.1 事件总线 (EventBus) + +全局单例 `global_event_bus`,解耦面板之间及面板与调度器的通信: + +| 事件名 | 发布者 | 订阅者 | 用途 | +|---|---|---|---| +| `RequestRunSingleStep` | 各面板 `run_btn` | PipelineExecutor | 单步执行 | +| `OutputUpdated` | 面板输出变化时 | 依赖订阅者 | 自动传播输出路径 | +| `PipelineStarted/Finished/Stopped` | PipelineExecutor | 全局 | 流水线生命周期 | +| `StepCompleted` | WorkerThread | 日志/UI | 单步完成通知 | +| `LogMessage` | 各处 | LogManager | 日志写入 | +| `ProgressUpdate` | WorkerThread | 进度条 | 进度更新 | +| `NavigateToTab` | 各处 | QTabWidget | 标签切换 | +| `WorkspaceChanged` | WorkspaceManager | 全局面板 | 工作目录变更 | + +### 2.2 面板工厂 (PanelFactory) + +惰性加载机制:启动时仅创建占位 Tab,首次切换到某 Tab 时才实例化对应 Panel。 + +核心方法: +- `get_panel(step_id)` → 返回面板实例(已缓存则直接返回) +- `_ensure_loaded(tab_index)` → 实例化、包装 QScrollArea、替换占位 Tab +- `_replay_state_to_panel(panel)` → 新加载的面板回放上游已产生的输出状态 + +### 2.3 依赖注入系统 + +`PANEL_REGISTRY` 中每个步骤定义 `dependencies`: + +```python +dependencies = { + 'target_attr': ('upstream_step_id', 'output_type', 'source_attr') +} +``` + +`DependencySubscriber` 监听 `OutputUpdated` 事件,自动将上游输出路径填入下游面板的对应输入控件。 + +`_step_path_resolver.py` 则通过文件系统扫描 + 目录映射表实现回退路径发现。 + +### 2.4 执行流程 + +``` +用户点击 "独立运行步骤" + → Panel._on_run_single_clicked() + → EventBus.publish('RequestRunSingleStep', {step_name, config}) + → PipelineExecutor._on_request_run_single_step() + → WorkerThread(QThread) 启动 + → PipelineScheduler.run_step(step_key, config) + → BaseStepHandler.execute(ctx, config) + → 实际算法逻辑 +``` + +--- + +## 三、13 个步骤详解 + +### 模块一:影像预处理(Steps 1-3) + +--- + +#### Step 1 — 水域掩膜生成 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step1_panel.py` | +| **面板类** | `Step1Panel(QWidget)` | +| **后端处理器** | `src/core/handlers/step1_water_mask.py` — `Step1WaterMaskHandler` | +| **后端算法** | `src/core/steps/water_mask_step.py` — `WaterMaskStep` | +| **UI 输入** | `mask_file`(FileSelectWidget) / NDWI 阈值模式:`ndwi_threshold`(QLineEdit, 0.0-1.0), `img_file`(FileSelectWidget) | +| **UI 输出** | `output_file`(FileSelectWidget, mode=save), `run_btn` | +| **上游依赖** | 无 | +| **产出类型** | `reference_img`, `water_mask` | +| **关键方法** | `init_ui()` → `update_ui_state()` 切换掩膜/NDWI 策略;`get_config()` 返回 `{mask_path, use_ndwi, ndwi_threshold, img_path, output_path}` | +| **目录映射** | `→ 1_water_mask` | + +#### Step 2 — 耀斑检测 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step2_panel.py` | +| **面板类** | `Step2Panel(QWidget)` | +| **后端处理器** | `src/core/handlers/step2_glint_detection.py` — `Step2GlintDetectionHandler` | +| **后端算法** | `src/core/steps/glint_detection_step.py` — `GlintDetectionStep` | +| **UI 输入** | `img_file`(FileSelectWidget), `water_mask_file`(FileSelectWidget) | +| **UI 参数** | `glint_wave`(QDoubleSpinBox, 300-1000nm), `method`(QComboBox: Otsu/Z-Score/百分位数/IQR/自适应/多波段综合), `max_area`(QSpinBox), `buffer_size`(QSpinBox) | +| **UI 输出** | `output_file`(FileSelectWidget, mode=save), `run_btn` | +| **上游依赖** | `img_file` ← step1.reference_img; `water_mask_file` ← step1.water_mask | +| **产出类型** | `glint_mask` | +| **目录映射** | `→ 2_Glint_Detection` | + +#### Step 3 — 去耀斑 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step3_panel.py` | +| **面板类** | `Step3Panel(QWidget)` | +| **后端处理器** | `src/core/handlers/step3_glint_removal.py` — `Step3GlintRemovalHandler` | +| **后端算法** | `src/core/steps/glint_removal_step.py` — `GlintRemovalStep` | +| **UI 输入** | `img_file`(FileSelectWidget), `water_mask_file`(FileSelectWidget) | +| **UI 参数** | `method`(QComboBox: Goodman/Kutser/Hedley/SUGAR), 4 个 StackedWidget 参数页 + `interpolate_zeros`(QCheckBox), `interp_method`(QComboBox) | +| **UI 输出** | `output_file`(FileSelectWidget), `run_btn` | +| **上游依赖** | `img_file` ← step1.reference_img; `water_mask_file` ← step1.water_mask | +| **产出类型** | `deglint_image` | +| **特殊机制** | `img_file.textChanged` → 自动调用 `_update_band_ranges()` 检测波段范围;`method.currentIndexChanged` → 切换参数 StackedWidget 页面 | +| **目录映射** | `→ 3_deglint` | + +--- + +### 模块二:特征工程与数据(Steps 4-7) + +--- + +#### Step 4 — 采样点布局与交互式探索 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step4_sampling_panel.py` | +| **面板类** | `Step4SamplingPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step4_sampling.py` — `Step4SamplingHandler` | +| **UI 输入** | `deglint_img_file`(FileSelectWidget), `water_mask_file`(FileSelectWidget) | +| **UI 参数** | `interval`(QSpinBox, 10-500px), `sample_radius`(QSpinBox, 1-50px), `chunk_size`(QSpinBox, 100-10000px), `use_adaptive_sampling`(QCheckBox) | +| **UI 输出** | `output_file`(FileSelectWidget), `refresh_btn`, `run_btn` | +| **可视化** | Matplotlib 嵌入:`FigureCanvasQTAgg` + 隐藏 `NavigationToolbar2QT`,左右分栏(散点图 + 光谱曲线),自定义工具栏按钮 | +| **交互** | `_on_hover()` 鼠标变小手 + 悬停标注;`_on_click()` 点击高亮 + 光谱绘制 + 再次点击取消选中;三按钮互斥:👆 点选探针 / ✋ 拖拽漫游 / 🔍 框选放大 | +| **上游依赖** | `deglint_img_file` ← step3.deglint_image; `water_mask_file` ← step1.water_mask | +| **产出类型** | `sampling_points` | +| **特殊机制** | `_status_timer` (5000ms) 自动检测 CSV 并重渲染;`draw_idle()` 防 UI 卡死;rcParams 中文字体双重保障 | +| **目录映射** | `→ 4_sampling` | + +#### Step 5 — 数据清洗 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step5_clean_panel.py` | +| **面板类** | `Step5CleanPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step5_process_csv.py` — `Step5ProcessCsvHandler` | +| **UI 输入** | `csv_file`(FileSelectWidget) | +| **UI 参数** | `preview_rows_spin`(QSpinBox), `preview_table`(QTableView + PandasTableModel), `preview_status_label`(QLabel) | +| **UI 输出** | `output_file`(FileSelectWidget, mode=save), `run_btn` | +| **上游依赖** | 无(独立输入源) | +| **产出类型** | `processed_data` | +| **目录映射** | `→ 5_Data_Cleaning` | + +#### Step 6 — 光谱特征提取 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step6_feature_panel.py` | +| **面板类** | `Step6FeaturePanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step6_extract_spectra.py` — `Step6ExtractSpectraHandler` | +| **UI 输入** | `deglint_img_file`(FileSelectWidget), `csv_file`(FileSelectWidget), `water_mask_file`(FileSelectWidget), `glint_mask_file`(FileSelectWidget) | +| **UI 参数** | `radius`(QSpinBox, 1-50px), `source_epsg`(QSpinBox, default 4326) | +| **UI 输出** | `output_file`(FileSelectWidget), `run_btn` | +| **上游依赖** | `deglint_img_file` ← step3; `csv_file` ← step5; `water_mask_file` ← step1; `glint_mask_file` ← step2 | +| **产出类型** | `output_file` | +| **目录映射** | `→ 6_Spectral_Feature_Extraction` | + +#### Step 7 — 水质指数计算 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step7_inversion_panel.py` | +| **面板类** | `Step7InversionPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step7_calc_indices.py` — `Step7CalcIndicesHandler` | +| **UI 输入** | `formula_file`(FileSelectWidget, 只读), `training_data_widget`(FileSelectWidget) | +| **UI 参数** | `category_combo`(QComboBox 按类别筛选), `formula_list`(NoScrollPassListWidget, checkable), 选择按钮:全选/清空/比值/浓度/重载 | +| **UI 输出** | `output_file`(FileSelectWidget), `run_btn` | +| **上游依赖** | `training_data_widget` ← step6_feature.output_file | +| **产出类型** | `training_spectra_indices` | +| **特殊机制** | `_load_formulas_from_csv()` 从内置公式库加载;`_update_formula_count()` 统计选中数 | +| **目录映射** | `→ 7_Water_Quality_Indices` | + +--- + +### 模块三:模型训练与反演(Steps 8-10) + +--- + +#### Step 8 — 机器学习建模 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step8_ml_train_panel.py` | +| **面板类** | `Step8MlTrainPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step8_ml_train.py` — `Step8MlTrainHandler` | +| **后端算法** | `src/core/steps/modeling_step.py` — `ModelingStep` | +| **UI 输入** | `training_csv_file`(FileSelectWidget) | +| **UI 参数** | `feature_start`(QComboBox), `cv_folds`(QSpinBox 2-10), 预处理 11 项 CheckBox(None/MMS/SS/SNV/MA/SG/MSC/D1/D2/DT/CT), 模型 15 项 CheckBox(LR/Ridge/Lasso/ElasticNet/PLS/DT/RF/ExtraTrees/XGBoost/LightGBM/CatBoost/GBDT/AdaBoost/SVR/KNN/MLP), 数据划分 3 项 CheckBox(SPXY/KS/Random) | +| **UI 输出** | `output_path`(FileSelectWidget, 目录模式), `run_btn` | +| **上游依赖** | `training_csv_file` ← step7_index.training_spectra_indices | +| **产出类型** | `output_path` | +| **目录映射** | `→ 8_Supervised_Model_Training` | + +#### Step 9 — 机器学习预测 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step9_ml_predict_panel.py` | +| **面板类** | `Step9MlPredictPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step9_ml_predict.py` — `Step9MlPredictHandler` | +| **后端算法** | `src/core/steps/prediction_step.py` — `PredictionStep` | +| **UI 输入** | `sampling_csv_file`(FileSelectWidget), `models_dir_file`(FileSelectWidget, 目录) | +| **UI 参数** | 模型来源:`use_trained_model`/`use_external_model`(QRadioButton), 外部模型 `model_list`(QListWidget, checkable), `metric`(QComboBox: R²/RMSE/MAE), `prediction_column`(QLineEdit) | +| **UI 输出** | `output_file`(FileSelectWidget, 目录), `run_btn` | +| **上游依赖** | `models_dir_file` ← step8_ml_train.output_path | +| **产出类型** | `output_file` | +| **目录映射** | `→ 9_ML_Prediction` | + +#### Step 10 — 水色指数反演 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step10_watercolor_panel.py` | +| **面板类** | `Step10WatercolorPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step10_qaa_inversion.py` — `Step10QaaInversionHandler` | +| **后端算法** | `src/core/algorithms/waterindex_inversion.py` — `WaterIndexCsvProcessor` | +| **UI 输入** | `formula_file`(FileSelectWidget, 只读), `sampling_csv_file`(FileSelectWidget) | +| **UI 参数** | `category_combo`(QComboBox 按水质类别筛选), `formula_list`(NoScrollPassListWidget, checkable), 全选/清空/比值/浓度按钮 | +| **UI 输出** | `output_dir`(FileSelectWidget, 目录), `progress_bar`(QProgressBar), `progress_label`(QLabel), `run_btn` | +| **上游依赖** | `sampling_csv_file` ← step4_sampling.sampling_points | +| **产出类型** | `output_dir` | +| **特殊机制** | 内嵌 `WaterIndexWorker(QThread)` 后台处理 CSV | +| **目录映射** | `→ 10_WaterIndex_CSV` | + +--- + +### 模块四:制图与成果汇编(Steps 11-13) + +--- + +#### Step 11 — 专题图生成 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step11_map_panel.py` | +| **面板类** | `Step11MapPanel(QWidget)` | +| **后端处理器** | `src/core/handlers/step11_concentration.py` — `Step11ConcentrationHandler` | +| **后端算法** | CSV 模式: `src/core/steps/mapping_step.py` — `MappingStep.generate_distribution_map()`;GeoTIFF 模式: `src/postprocessing/map.py` — `ContentMapper.visualize_raster()` | +| **UI 输入** | `render_mode_combo`(QComboBox: CSV插值/GeoTIFF栅格), `batch_mode_combo`(QComboBox: 单个文件/文件夹批量), `prediction_csv_file`(FileSelectWidget), `geotiff_file`(FileSelectWidget), `boundary_file`(FileSelectWidget: *.shp) | +| **UI 参数** | `resolution`(QDoubleSpinBox, 1-1000), `input_crs`/`output_crs`(QLineEdit, EPSG:32651), `show_points`(QCheckBox), `use_diffusion`(QCheckBox) | +| **UI 输出** | `output_dir`(FileSelectWidget), `progress_bar`(QProgressBar), `run_button` | +| **上游依赖** | `prediction_csv_dir_edit` ← step9; `geotiff_dir_edit` ← step10; `boundary_file` ← step1.water_mask | +| **特殊机制** | 两个后台线程:`Step11MapBatchThread`(CSV) / `Step11GeoTIFFBatchThread`(GeoTIFF) | +| **目录映射** | `→ 14_visualization` | + +#### Step 12 — 可视化 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step12_viz_panel.py` | +| **面板类** | `Step12VizPanel(QWidget)` | +| **后端算法** | `src/postprocessing/visualization_reports.py` — `WaterQualityVisualization`; `src/core/visualization/scatter_plot.py`; `src/postprocessing/point_map.py` — `SamplingPointMap` | +| **UI 输入** | `work_dir_edit`(QLineEdit), `img_dir_edit`(QLineEdit) | +| **UI 参数** | 6 个 QCheckBox: `gen_scatter`(模型评估散点图), `gen_spectrum`(光谱曲线), `gen_boxplots`(统计图), `gen_mask_glint`(掩膜缩略图), `gen_sampling_map`(采样点地图), `gen_distribution_map`(空间分布图) | +| **UI 输出** | `gen_all_btn`(QPushButton), `scan_btn`(QPushButton), `ImageCategoryTree`(QTreeWidget), `ImageViewerWidget`(带缩放/平移/保存), 筛选: `view_mode_cb` + `chart_filter_cb` | +| **上游依赖** | 无(文件系统自动扫描) | +| **产出类型** | 多种图表 PNG | +| **特殊机制** | `VisualizationWorkerThread(QThread)` 后台批量生成;`ImageCategoryTree` 三视图模式(按水质参数/图表类型/物理文件夹) | + +#### Step 13 — 报告生成 + +| 项目 | 详情 | +|---|---| +| **面板文件** | `src/gui/panels/step13_report_panel.py` | +| **面板类** | `Step13ReportPanel(QWidget)` | +| **后端算法** | `src/postprocessing/report_word.py` — `WaterQualityReportGenerator` + `ReportGenerationConfig` | +| **UI 输入** | `work_dir_edit`(QLineEdit, 只读), `output_dir_edit`(QLineEdit + 浏览) | +| **UI 参数** | `report_title_edit`(QLineEdit), `enable_ai_cb`(QCheckBox), AI 设置按钮 → `AISettingsDialog` | +| **UI 输出** | `progress_label`(QLabel), `progress_bar`(QProgressBar), `generate_btn`(QPushButton) | +| **上游依赖** | 无(但需要 `main_window` 在构造时注入) | +| **产出类型** | Word 报告 (.docx) | +| **特殊机制** | `ReportWorkerThread(QThread)` 后台生成;AI 分析可选(需配置 API Key) | +| **目录映射** | `→ reports` | + +--- + +## 四、步骤间数据流转图 + +``` +Step1 水域掩膜 ─── img_file ───────────────┬──► Step2 耀斑检测 ── glint_mask ──► Step6 + │ │ + ├── water_mask ──────────────────────┼──► Step3 去耀斑 ─── deglint ────► Step4 + │ │ │ │ + │ │ └───────────────────────────┤ + │ │ │ + │ ├──► Step4 采样 ←────────────────────┘ + │ ├──► Step6 特征提取 + │ ├──► Step11 专题图 ───► Step12 + │ └──► Step12 可视化 Step13 + │ + └──► Step11 专题图(边界) + +Step4 采样 ── sampling_csv ──► Step10 水色指数 ── output_dir ──► Step11(GeoTIFF) + +Step5 清洗 ── csv ──► Step6 特征提取 ── output ──► Step7 指数计算 + │ + ▼ + Step8 机器学习建模 + │ + ▼ + Step9 机器学习预测 + │ + ▼ + Step11 专题图(CSV) +``` + +--- + +## 五、自定义组件 + +### 5.1 文件选择控件 + +**`FileSelectWidget`** (`src/gui/components/custom_widgets.py`) + +| 模式 | 行为 | +|---|---| +| `mode="open"` | `QFileDialog.getOpenFileName()` — 选择已存在文件 | +| `mode="save"` | `QFileDialog.getSaveFileName()` — 选择保存路径 | +| `mode="dir"` | `QFileDialog.getExistingDirectory()` — 选择目录 | + +关键方法:`get_path()`, `set_path(path)`, `set_read_only(bool)`, `line_edit.textChanged` 信号 + +### 5.2 路径解析器 + +**`_step_path_resolver.py`** — 所有 13 个 Panel 统一通过 `from src.gui.panels._step_path_resolver import ...` 导入 + +| 函数 | 用途 | +|---|---| +| `resolve_subdir(work_dir, subdir_key)` | 步骤名 → 实际子目录路径 | +| `scan_work_dir_for_input(work_dir, output_type)` | 文件系统扫描发现上游输出 | +| `resolve_step_widget(main_window, step_key, widget_attr)` | 通过 panel_factory 定位上游控件 | +| `get_step_output_path(main_window, step_key, work_dir, widget_attr, fallback_key)` | 获取上游输出路径(含回退) | + +### 5.3 全局样式 + +**`ModernStylesheet`** (`src/gui/styles.py`) — 提供 `get_button_stylesheet('normal'|'primary')` + +--- + +## 六、关键设计模式 + +| 模式 | 位置 | 说明 | +|---|---|---| +| **惰性加载** | PanelFactory | Tab 切换时才实例化 Panel,降低启动时间 | +| **事件总线** | EventBus | Panel ↔ PipelineExecutor 解耦通信 | +| **依赖注入** | DependencySubscriber + PANEL_REGISTRY | 上游输出自动填入下游输入 | +| **文件系统回退** | _step_path_resolver | 当依赖注入失败时,扫描实际文件系统 | +| **独立运行** | 每个 Panel 的 `run_btn` | 不依赖全流水线,可单独执行 | +| **后台线程** | WorkerThread / 各 Panel 内嵌 QThread | 长时间计算不阻塞 GUI | +| **交互模式** | Step4 三按钮互斥 | 点选探针 / 拖拽漫游 / 框选放大 | + +--- + +## 七、文件清单 + +### 前端面板(13 个) + +| 文件 | 类 | Step ID | +|---|---|---| +| `step1_panel.py` | `Step1Panel` | `step1` | +| `step2_panel.py` | `Step2Panel` | `step2` | +| `step3_panel.py` | `Step3Panel` | `step3` | +| `step4_sampling_panel.py` | `Step4SamplingPanel` | `step4_sampling` | +| `step5_clean_panel.py` | `Step5CleanPanel` | `step5_clean` | +| `step6_feature_panel.py` | `Step6FeaturePanel` | `step6_feature` | +| `step7_inversion_panel.py` | `Step7InversionPanel` | `step7_index` | +| `step8_ml_train_panel.py` | `Step8MlTrainPanel` | `step8_ml_train` | +| `step9_ml_predict_panel.py` | `Step9MlPredictPanel` | `step9_ml_predict` | +| `step10_watercolor_panel.py` | `Step10WatercolorPanel` | `step10_watercolor` | +| `step11_map_panel.py` | `Step11MapPanel` | `step11_map` | +| `step12_viz_panel.py` | `Step12VizPanel` | `step12_viz` | +| `step13_report_panel.py` | `Step13ReportPanel` | `step13_report` | + +### 后端核心(14 个 Handler) + +| 文件 | 类 | Step Key | +|---|---|---| +| `handlers/step1_water_mask.py` | `Step1WaterMaskHandler` | `step1` | +| `handlers/step2_glint_detection.py` | `Step2GlintDetectionHandler` | `step2` | +| `handlers/step3_glint_removal.py` | `Step3GlintRemovalHandler` | `step3` | +| `handlers/step4_sampling.py` | `Step4SamplingHandler` | `step4_sampling` | +| `handlers/step5_process_csv.py` | `Step5ProcessCsvHandler` | `step5_clean` | +| `handlers/step6_extract_spectra.py` | `Step6ExtractSpectraHandler` | `step6_feature` | +| `handlers/step7_calc_indices.py` | `Step7CalcIndicesHandler` | `step7_index` | +| `handlers/step8_ml_train.py` | `Step8MlTrainHandler` | `step8_ml_train` | +| `handlers/step9_ml_predict.py` | `Step9MlPredictHandler` | `step9_ml_predict` | +| `handlers/step10_qaa_inversion.py` | `Step10QaaInversionHandler` | `step10_qaa_inversion` | +| `handlers/step11_concentration.py` | `Step11ConcentrationHandler` | `step11_concentration` | +| `handlers/step12_kriging.py` | `Step12KrigingHandler` | `step12_kriging` | +| `handlers/step13_visualization.py` | `Step13VisualizationHandler` | `step13_visualization` | +| `handlers/step14_report.py` | `Step14ReportHandler` | `step14_report` | + +> 注:Handler 注册有 14 个(step1-14),对应 Panel 有 13 个(step1-13)。Step12 的 Handler 处理克里金插值,Step13 的 Handler 处理可视化,Step14 的 Handler 处理报告。 + +--- + +## 八、审计修复记录(2026-06-30) + +本轮已修复的全部问题: + +| # | 严重度 | 文件 | 问题 | 修复 | +|---|---|---|---|---| +| 1 | 🔴 | `step11_map_panel.py:652` | `mapper` 在 import 之前引用 | 移动 import 至引用上方 | +| 2 | 🟡 | `step12_viz_panel.py:1719` | `get_panel('step1_mask')` key 不存在 | 改为 `'step1'` | +| 3 | 🟡 | `step3_panel.py:384` | `currentData()` 返回 None | 改为 `currentText()` | +| 4 | 🟡 | `step5_clean_panel.py:228/253` | PandasTableModel 从 v1 入口导入 | 改为 `src.gui.components.data_models` | +| 5 | 🟡 | `step3_panel.py:271` | InteractiveViewerDialog 同上 | 改为 `src.gui.components.chart_dialogs` | +| 6 | 🟢 | `step7_inversion_panel.py:9` | `import csv` 未使用 | 移除 | +| 7 | 🟢 | `step5_clean_panel.py:167` | 死方法 `_add_row_with_fixed_label` | 标注废弃 | +| 8 | 🟢 | 全部 12 个 Panel | `_step_path_resolver` 导入风格不一致 | 统一为 `from src.gui.panels._step_path_resolver import ...`,移除 `_HERE`/`sys.path` hack | diff --git a/src/core/algorithms/waterindex_inversion/csv_processor.py b/src/core/algorithms/waterindex_inversion/csv_processor.py index 3ea3976..91a03e4 100644 --- a/src/core/algorithms/waterindex_inversion/csv_processor.py +++ b/src/core/algorithms/waterindex_inversion/csv_processor.py @@ -114,6 +114,7 @@ class WaterIndexCsvProcessor: output_dir: str, selected_formulas: Optional[List[str]] = None, progress_callback: Optional[Callable[[str, float], None]] = None, + wavelength_offset: float = 0.0, ) -> Dict[str, str]: """ 散点 CSV → 按指数拆分的多个 CSV。 @@ -128,6 +129,8 @@ class WaterIndexCsvProcessor: 要计算的公式名列表;None 或空列表 = 全部公式 progress_callback : callable, optional 进度回调 ``(msg: str, pct: float)`` + wavelength_offset : float + 波长偏移修正量(nm),公式波长统一加上此值后再匹配波段 Returns ------- @@ -192,7 +195,7 @@ class WaterIndexCsvProcessor: notify(f"开始逐行计算 {len(targets)} 个公式…", 25) spectra_df = df[wl_cols] try: - results_df = calc.calculate_many(targets, spectra_df) + results_df = calc.calculate_many(targets, spectra_df, wavelength_offset=wavelength_offset) except Exception as e: raise RuntimeError(f"公式计算失败: {e}") diff --git a/src/core/handlers/step7_calc_indices.py b/src/core/handlers/step7_calc_indices.py index cf1ddd7..c231928 100644 --- a/src/core/handlers/step7_calc_indices.py +++ b/src/core/handlers/step7_calc_indices.py @@ -38,6 +38,7 @@ class Step7CalcIndicesHandler(BaseStepHandler): output_file=config.get('output_file'), enabled=config.get('enabled', True), output_dir=str(context.indices_dir), + wavelength_offset=float(config.get('wavelength_offset', 0)), ) context.indices_path = result diff --git a/src/core/steps/data_preparation_step.py b/src/core/steps/data_preparation_step.py index 446645e..d671c26 100644 --- a/src/core/steps/data_preparation_step.py +++ b/src/core/steps/data_preparation_step.py @@ -133,6 +133,7 @@ class DataPreparationStep: enabled: bool = True, output_dir: Union[str, Path] = "./7_Water_Quality_Indices", callback: Optional[Callable] = None, + wavelength_offset: float = 0.0, ) -> Optional[str]: """根据训练光谱计算水质光谱指数(使用 band_math 方法)""" output_dir = Path(output_dir) @@ -170,7 +171,7 @@ class DataPreparationStep: from src.utils.band_math import BandMathCalculator - calculator = BandMathCalculator(training_csv_path) + calculator = BandMathCalculator(training_csv_path, wavelength_offset=wavelength_offset) result_df = calculator.process_formulas_from_csv( formula_csv_file=formula_csv_file, formula_names=formula_names, diff --git a/src/gui/panels/step10_watercolor_panel.py b/src/gui/panels/step10_watercolor_panel.py index e2dd5da..631bc93 100644 --- a/src/gui/panels/step10_watercolor_panel.py +++ b/src/gui/panels/step10_watercolor_panel.py @@ -26,7 +26,7 @@ from PyQt5.QtWidgets import ( QGroupBox, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QFileDialog, QMessageBox, QListWidget, QListWidgetItem, QAbstractItemView, QProgressBar, QTextEdit, QFrame, - QScrollArea, QSizePolicy, + QScrollArea, QSizePolicy, QDoubleSpinBox, ) from PyQt5.QtGui import QFont from PyQt5.QtCore import Qt, QThread, pyqtSignal @@ -72,6 +72,7 @@ class WaterIndexWorker(QThread): selected_formulas: List[str], waterindex_csv: str, work_dir: Optional[str] = None, + wavelength_offset: float = 0.0, ): super().__init__() self.sampling_csv_path = sampling_csv_path @@ -79,6 +80,7 @@ class WaterIndexWorker(QThread): self.selected_formulas = selected_formulas self.waterindex_csv = waterindex_csv self.work_dir = work_dir + self.wavelength_offset = float(wavelength_offset) def run(self): try: @@ -96,6 +98,7 @@ class WaterIndexWorker(QThread): output_dir=self.output_dir, selected_formulas=self.selected_formulas or None, progress_callback=lambda m, p: self.progress.emit(m, p), + wavelength_offset=self.wavelength_offset, ) self.progress.emit( @@ -166,7 +169,23 @@ class Step10WatercolorPanel(QWidget): self.sampling_csv_file.label.setMinimumWidth(100) input_layout.addWidget(self.sampling_csv_file) - # 注意:彻底去掉了 self.meta_label 及其相关的布局代码 + # ── 波长偏移修正 ── + offset_layout = QHBoxLayout() + offset_label = QLabel("波长偏移 (nm):") + offset_label.setMinimumWidth(120) + self.wavelength_offset_spin = QDoubleSpinBox() + self.wavelength_offset_spin.setRange(-200.0, 200.0) + self.wavelength_offset_spin.setValue(0.0) + self.wavelength_offset_spin.setDecimals(1) + self.wavelength_offset_spin.setSuffix("") + self.wavelength_offset_spin.setToolTip( + "传感器波长系统偏移修正量。正数=公式波长加偏移(如+100则w450→找≈w550的波段);" + "负数=公式波长减偏移。默认0表示不做修正。" + ) + offset_layout.addWidget(offset_label) + offset_layout.addWidget(self.wavelength_offset_spin) + offset_layout.addStretch() + input_layout.addLayout(offset_layout) input_group.setLayout(input_layout) layout.addWidget(input_group) @@ -653,6 +672,7 @@ class Step10WatercolorPanel(QWidget): selected_formulas=selected, waterindex_csv=self._waterindex_csv, work_dir=work_dir, + wavelength_offset=self.wavelength_offset_spin.value(), ) self._worker.progress.connect(self._on_progress) self._worker.finished_ok.connect(self._on_finished) diff --git a/src/gui/panels/step7_inversion_panel.py b/src/gui/panels/step7_inversion_panel.py index 01f0382..f9ea809 100644 --- a/src/gui/panels/step7_inversion_panel.py +++ b/src/gui/panels/step7_inversion_panel.py @@ -14,6 +14,7 @@ from PyQt5.QtWidgets import ( QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QPushButton, QMessageBox, QListWidget, QListWidgetItem, QSizePolicy, QWidget, QComboBox, + QDoubleSpinBox, ) from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor @@ -238,6 +239,30 @@ class Step7InversionPanel(QWidget): params_group.setLayout(params_layout) main_layout.addWidget(params_group) + # ========================================== + # 波长偏移修正 + # ========================================== + offset_group = QGroupBox("🔧 波长偏移修正") + offset_layout = QHBoxLayout() + offset_layout.setContentsMargins(20, 16, 20, 16) + offset_label = QLabel("统一偏移量 (nm):") + offset_label.setMinimumWidth(120) + self.wavelength_offset_spin = QDoubleSpinBox() + self.wavelength_offset_spin.setRange(-200.0, 200.0) + self.wavelength_offset_spin.setValue(0.0) + self.wavelength_offset_spin.setDecimals(1) + self.wavelength_offset_spin.setSuffix("") + self.wavelength_offset_spin.setToolTip( + "传感器波长系统偏移修正。公式中的目标波长统一加上此值后再匹配传感器波段。\n" + "例如:偏移 +100 意味着公式中的 w450 实际去找传感器 ~550nm 的波段。\n" + "默认 0 不做修正。适用于传感器标定漂移或不同传感器间的波段对齐。" + ) + offset_layout.addWidget(offset_label) + offset_layout.addWidget(self.wavelength_offset_spin) + offset_layout.addStretch() + offset_group.setLayout(offset_layout) + main_layout.addWidget(offset_group) + # ========================================== # 卡片 3:输出与执行 # ========================================== @@ -309,7 +334,8 @@ class Step7InversionPanel(QWidget): 'training_csv_path': self.training_data_widget.get_path(), 'formula_csv_file': self.formula_file.get_path(), 'formula_names': selected_names, - 'enabled': True + 'enabled': True, + 'wavelength_offset': self.wavelength_offset_spin.value(), } output_path = self.output_file.get_path() if output_path: @@ -337,6 +363,12 @@ class Step7InversionPanel(QWidget): if 'output_path' in config: self.output_file.set_path(config['output_path']) + if 'wavelength_offset' in config: + try: + self.wavelength_offset_spin.setValue(float(config['wavelength_offset'])) + except (ValueError, TypeError): + pass + def _load_formulas_from_csv(self): """解析公式 CSV 文件并填充列表框""" csv_path = self.formula_file.get_path() diff --git a/src/utils/band_math.py b/src/utils/band_math.py index 331bffc..cb75941 100644 --- a/src/utils/band_math.py +++ b/src/utils/band_math.py @@ -4,13 +4,16 @@ import re class BandMathCalculator: - def __init__(self, csv_file): + def __init__(self, csv_file, wavelength_offset=0.0): """ 初始化计算器 csv_file: 包含光谱反射率的CSV文件路径 + wavelength_offset: 波长偏移修正量(nm),公式中的目标波长会统一加上此偏移后再匹配最近的传感器波段。 + 例如 offset=100 意味着公式中的 w450 实际会去找传感器波段 ~550nm。 """ self.df = pd.read_csv(csv_file) self.wavelengths = self._extract_wavelengths() + self.wavelength_offset = float(wavelength_offset) def _extract_wavelengths(self): """从列名中提取波长信息""" @@ -25,19 +28,25 @@ class BandMathCalculator: return wavelengths def _find_closest_wavelength(self, target_wavelength): - """找到最接近目标波长的列索引""" + """找到最接近目标波长的列索引(自动应用波长偏移修正)""" + # 应用波长偏移修正 + adjusted_target = target_wavelength + self.wavelength_offset valid_indices = [i for i, wl in enumerate(self.wavelengths) if wl is not None] if not valid_indices: raise ValueError("未找到有效的波长列") # 计算与目标波长的差值 - differences = [abs(self.wavelengths[i] - target_wavelength) for i in valid_indices] + differences = [abs(self.wavelengths[i] - adjusted_target) for i in valid_indices] min_diff_index = np.argmin(differences) closest_index = valid_indices[min_diff_index] closest_wavelength = self.wavelengths[closest_index] - print( - f"目标波长 {target_wavelength}nm -> 最接近波长 {closest_wavelength}nm (列: {self.df.columns[closest_index]})") + if abs(self.wavelength_offset) > 0.01: + print( + f"公式波长 {target_wavelength}nm + 偏移 {self.wavelength_offset}nm → 目标 {adjusted_target}nm → 最接近波段 {closest_wavelength}nm (列: {self.df.columns[closest_index]})") + else: + print( + f"目标波长 {target_wavelength}nm -> 最接近波长 {closest_wavelength}nm (列: {self.df.columns[closest_index]})") return closest_index def _parse_expression(self, expression): diff --git a/src/utils/water_index.py b/src/utils/water_index.py index 8660e28..19b0f24 100644 --- a/src/utils/water_index.py +++ b/src/utils/water_index.py @@ -90,13 +90,14 @@ class WaterQualityIndexCalculator: parts = [float(x.strip()) for x in s.split(",")] return np.array(parts) - def _band_math_all_rows(self, df: pd.DataFrame, expression: str) -> pd.Series: + def _band_math_all_rows(self, df: pd.DataFrame, expression: str, wavelength_offset: float = 0.0) -> pd.Series: """ 使用 BandMathCalculator 的公式计算引擎,在整个 DataFrame 上批量求值。 Args: df: 输入光谱数据(列名为 wNNN 格式) expression: 波段计算表达式,如 "(w715 - w686) / (w715 + w686)" + wavelength_offset: 波长偏移修正量(nm) Returns: pd.Series,与 df 等长的计算结果 @@ -104,6 +105,7 @@ class WaterQualityIndexCalculator: calc = BandMathCalculator.__new__(BandMathCalculator) calc.df = df.copy() calc.wavelengths = calc._extract_wavelengths() + calc.wavelength_offset = float(wavelength_offset) variables = calc._parse_expression(expression) results = [] @@ -126,13 +128,14 @@ class WaterQualityIndexCalculator: return pd.Series(results, index=df.index, name=expression) - def calculate_one(self, name: str, df: pd.DataFrame) -> pd.Series: + def calculate_one(self, name: str, df: pd.DataFrame, wavelength_offset: float = 0.0) -> pd.Series: """ 计算单个水质指数。 Args: name: 公式名称(对应 Formula_Name) df: 光谱反射率 DataFrame + wavelength_offset: 波长偏移修正量(nm) Returns: pd.Series,计算结果 @@ -145,7 +148,7 @@ class WaterQualityIndexCalculator: ftype = cfg["type"] coeff_str = cfg["coeff"] - raw = self._band_math_all_rows(df, expr) + raw = self._band_math_all_rows(df, expr, wavelength_offset=wavelength_offset) if ftype == "concentration": coeff = self._parse_coeff(coeff_str) @@ -155,13 +158,14 @@ class WaterQualityIndexCalculator: raw.name = name return raw - def calculate_many(self, names: List[str], df: pd.DataFrame) -> pd.DataFrame: + def calculate_many(self, names: List[str], df: pd.DataFrame, wavelength_offset: float = 0.0) -> pd.DataFrame: """ 批量计算多个水质指数。 Args: names: 公式名称列表 df: 光谱反射率 DataFrame + wavelength_offset: 波长偏移修正量(nm) Returns: pd.DataFrame,每列对应一个公式的计算结果 @@ -169,7 +173,7 @@ class WaterQualityIndexCalculator: results = {} for name in names: try: - results[name] = self.calculate_one(name, df) + results[name] = self.calculate_one(name, df, wavelength_offset=wavelength_offset) except Exception as e: print(f"⚠️ 计算 {name} 失败: {e}") results[name] = pd.Series(np.nan, index=df.index, name=name)