diff --git a/src/auth/license_dialog.py b/src/auth/license_dialog.py index 2d8d04e..550c5d2 100644 --- a/src/auth/license_dialog.py +++ b/src/auth/license_dialog.py @@ -220,17 +220,17 @@ class LicenseDialog(QDialog): return # 成功提示,重启程序 - reply = QMessageBox.information( + QMessageBox.information( self, "导入成功", - "授权文件已成功导入。\n\n软件将自动重启以应用授权。" - if False else # 占位,维持下面的逻辑 "授权文件已成功导入。\n软件将自动重启以应用授权。", QMessageBox.Ok ) + # ★ 修复:必须在 accept() 之前用 QTimer 排队重启, + # 否则 accept() 后调用方执行 sys.exit(0),_restart_app 永不被调用 + QTimer.singleShot(0, self._restart_app) self.accept() - self._restart_app() def _quit_app(self): """退出程序""" @@ -238,7 +238,7 @@ class LicenseDialog(QDialog): sys.exit(0) def _restart_app(self): - """重启程序""" + """重启程序。""" self.close() QApplication.quit() @@ -250,5 +250,6 @@ class LicenseDialog(QDialog): # PyInstaller 打包环境下 subprocess.Popen([executable] + _sys.argv[1:]) else: - # 开发环境 - subprocess.Popen([executable, __file__]) \ No newline at end of file + # 开发环境:使用 sys.argv[0](真正的入口脚本),而非 __file__ + entry_script = os.path.abspath(_sys.argv[0]) + subprocess.Popen([executable, entry_script]) \ No newline at end of file diff --git a/src/auth/license_manager.py b/src/auth/license_manager.py index 19c1956..7a6ac54 100644 --- a/src/auth/license_manager.py +++ b/src/auth/license_manager.py @@ -10,7 +10,6 @@ import hmac import hashlib import base64 import uuid -import hashlib as _hashlib import subprocess import re import sys @@ -39,9 +38,12 @@ def get_cpu_id() -> Optional[str]: if cpu_id: return cpu_id else: + # Linux: 仅 ARM (树莓派等) 的 /proc/cpuinfo 包含 Serial 字段。 + # x86 Linux 上 processor 是核序号(如 "0"),非唯一硬件 ID, + # 不能作为指纹使用,仅在有 Serial 时才返回值。 with open("/proc/cpuinfo", "r") as f: for line in f: - if "Serial" in line or "processor" in line: + if "Serial" in line: cpu_id = line.split(":")[-1].strip() if cpu_id: return cpu_id @@ -77,7 +79,11 @@ def get_motherboard_uuid() -> Optional[str]: stderr=subprocess.DEVNULL, ) if result.returncode == 0: - return result.stdout.strip() + board_uuid = result.stdout.strip() + # ★ 与 Windows 分支一致:清理非字母数字字符 + board_uuid = re.sub(r'[^a-zA-Z0-9\-]', '', board_uuid) + if board_uuid and board_uuid not in ("To be filled", "None"): + return board_uuid except Exception: pass return None diff --git a/src/core/algorithms/waterindex_inversion/csv_processor.py b/src/core/algorithms/waterindex_inversion/csv_processor.py index 91a03e4..402a9ae 100644 --- a/src/core/algorithms/waterindex_inversion/csv_processor.py +++ b/src/core/algorithms/waterindex_inversion/csv_processor.py @@ -185,8 +185,10 @@ class WaterIndexCsvProcessor: missing = [n for n in selected_formulas if n not in all_formula_names] if missing: print(f"[WaterIndexCsvProcessor] 警告: 以下公式未在 waterindex.csv 中找到,已跳过: {missing}") + notify(f"用户选择了 {len(selected_formulas)} 个公式 → 匹配到 {len(targets)} 个可计算 ({len(all_formula_names)} 个可用)", 17) else: targets = all_formula_names + notify(f"未指定公式 → 计算全部 {len(targets)} 个公式", 17) if not targets: raise ValueError("没有可计算的公式(selected_formulas 为空且 waterindex.csv 中无公式)") @@ -202,7 +204,12 @@ class WaterIndexCsvProcessor: # 每个公式一个 CSV:longitude, latitude, out_files: Dict[str, str] = {} n_total = len(targets) + skipped_count = 0 for i, name in enumerate(targets): + # ── 硬守卫:用户显式选择了公式时,绝不输出不在勾选列表中的公式 ── + if selected_formulas and name not in selected_formulas: + skipped_count += 1 + continue try: per_idx = results_df[name] # ===== P0 防御: 写盘前清洗(防 Step 11 Kriging / TIN 碎玻璃)===== @@ -239,5 +246,8 @@ class WaterIndexCsvProcessor: print(f"[WaterIndexCsvProcessor] 公式 '{name}' 失败: {e}") continue - notify(f"完成!共输出 {len(out_files)} / {n_total} 个指数 CSV", 100) + summary = f"完成!共输出 {len(out_files)} / {n_total} 个指数 CSV" + if skipped_count > 0: + summary += f",已硬跳过 {skipped_count} 个未勾选公式" + notify(summary, 100) return out_files \ No newline at end of file diff --git a/src/core/handlers/__init__.py b/src/core/handlers/__init__.py index 91bfd57..e25ecb0 100644 --- a/src/core/handlers/__init__.py +++ b/src/core/handlers/__init__.py @@ -21,7 +21,9 @@ from src.core.handlers.step7_calc_indices import Step7CalcIndicesHandler from src.core.handlers.step8_ml_train import Step8MlTrainHandler from src.core.handlers.step9_ml_predict import Step9MlPredictHandler from src.core.handlers.step10_qaa_inversion import Step10QaaInversionHandler +from src.core.handlers.step10_watercolor_handler import Step10WatercolorHandler from src.core.handlers.step11_concentration import Step11ConcentrationHandler +from src.core.handlers.step11_map_handler import Step11MapHandler from src.core.handlers.step12_kriging import Step12KrigingHandler from src.core.handlers.step13_visualization import Step13VisualizationHandler from src.core.handlers.step14_report import Step14ReportHandler @@ -39,7 +41,9 @@ __all__ = [ 'Step8MlTrainHandler', 'Step9MlPredictHandler', 'Step10QaaInversionHandler', + 'Step10WatercolorHandler', 'Step11ConcentrationHandler', + 'Step11MapHandler', 'Step12KrigingHandler', 'Step13VisualizationHandler', 'Step14ReportHandler', diff --git a/src/core/handlers/base.py b/src/core/handlers/base.py index 1bb6b62..1de0994 100644 --- a/src/core/handlers/base.py +++ b/src/core/handlers/base.py @@ -43,25 +43,26 @@ class PipelineContext: self.work_dir = Path(work_dir) self.work_dir.mkdir(parents=True, exist_ok=True) - # ── 子目录 ── + # ── 子目录(对齐 12 步现代 pipeline)── self.water_mask_dir = self.work_dir / "1_water_mask" self.glint_dir = self.work_dir / "2_Glint_Detection" self.deglint_dir = self.work_dir / "3_deglint" + self.sampling_dir = self.work_dir / "4_sampling" self.processed_data_dir = self.work_dir / "5_Data_Cleaning" self.training_spectra_dir = self.work_dir / "6_Spectral_Feature_Extraction" self.indices_dir = self.work_dir / "7_Water_Quality_Indices" self.models_dir = self.work_dir / "8_Supervised_Model_Training" - self.non_empirical_models_dir = self.work_dir / "8_Non_Empirical_Regression" - self.custom_regression_dir = self.work_dir / "13_Custom_Regression" - self.sampling_dir = self.work_dir / "4_sampling" - self.prediction_dir = self.work_dir / "11_12_13_predictions" - self.visualization_dir = self.work_dir / "14_visualization" + self.ml_prediction_dir = self.work_dir / "9_ML_Prediction" + self.watercolor_dir = self.work_dir / "10_WaterIndex_CSV" + self.thematic_map_dir = self.work_dir / "11_Thematic_Map" + self.visualization_dir = self.work_dir / "12_visualization" self.reports_dir = self.work_dir / "reports" for d in [self.water_mask_dir, self.glint_dir, self.deglint_dir, - self.processed_data_dir, self.training_spectra_dir, - self.indices_dir, self.models_dir, self.non_empirical_models_dir, - self.custom_regression_dir, self.sampling_dir, self.prediction_dir, + self.sampling_dir, self.processed_data_dir, + self.training_spectra_dir, self.indices_dir, + self.models_dir, self.ml_prediction_dir, + self.watercolor_dir, self.thematic_map_dir, self.visualization_dir, self.reports_dir]: d.mkdir(parents=True, exist_ok=True) @@ -73,7 +74,6 @@ class PipelineContext: self.processed_csv_path: Optional[str] = None self.training_csv_path: Optional[str] = None self.indices_path: Optional[str] = None - self.custom_regression_path: Optional[str] = None self.sampling_csv_path: Optional[str] = None self.prediction_files: Dict[str, str] = {} self.distribution_map_path: Optional[str] = None @@ -88,6 +88,9 @@ class PipelineContext: # ── 回调 ── self._callback: Optional[Callable] = None + # ★ 取消标志(2026-06-30):支持 Pipeline 优雅中断 + self._cancelled: bool = False + # ── 可视化组件(延迟导入避免循环依赖)── self._visualizer = None self._report_generator = None @@ -98,6 +101,19 @@ class PipelineContext: 'DejaVu Sans', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = False + # ═══════════════════════════════════════════════════════════ + # 取消机制 + # ═══════════════════════════════════════════════════════════ + + def cancel(self): + """请求取消当前 Pipeline 执行。""" + self._cancelled = True + self.notify('pipeline', 'info', '收到取消请求,正在终止...') + + def is_cancelled(self) -> bool: + """检查是否已被取消。Handler 可在长时间运算中轮询此标志。""" + return self._cancelled + # ═══════════════════════════════════════════════════════════ # 回调 # ═══════════════════════════════════════════════════════════ @@ -193,25 +209,28 @@ class PipelineContext: 'step6_feature': wp / '6_Spectral_Feature_Extraction', 'step7_index': wp / '7_Water_Quality_Indices', 'step8_ml_train': wp / '8_Supervised_Model_Training', - 'step9_ml_predict': wp / '8_Non_Empirical_Regression', - 'step10_watercolor': wp / '10_WaterIndex_Images', - 'step11_map': wp / '14_visualization', - 'step12_viz': wp / '14_visualization', - 'step13_report': wp / '14_visualization', - 'step11_predictions': wp / '11_12_13_predictions', - 'step12_predictions': wp / '11_12_13_predictions', - 'step13_predictions': wp / '11_12_13_predictions', - 'custom_regression': wp / '13_Custom_Regression', - 'prediction_dir': wp / '11_12_13_predictions', - 'visualization': wp / '14_visualization', - 'reports': wp / 'reports', + 'step9_ml_predict': wp / '9_ML_Prediction', + 'step10_watercolor': wp / '10_WaterIndex_CSV', + 'step11_map': wp / '11_Thematic_Map', + 'step12_viz': wp / '12_visualization', + 'step13_report': wp / '12_visualization', + # 短别名(兼容旧代码按数字索引查找) + 'step1': wp / '1_water_mask', + 'step2': wp / '2_Glint_Detection', + 'step3': wp / '3_deglint', + 'step4': wp / '4_sampling', + 'step5': wp / '5_Data_Cleaning', + 'step6': wp / '6_Spectral_Feature_Extraction', + 'step7': wp / '7_Water_Quality_Indices', 'step8': wp / '8_Supervised_Model_Training', - 'step9': wp / '8_Non_Empirical_Regression', - 'step10': wp / '10_WaterIndex_Images', - 'step11': wp / '11_12_13_predictions', - 'step12': wp / '13_Custom_Regression', - 'step13': wp / 'reports', - 'step14': wp / '14_visualization', + 'step9': wp / '9_ML_Prediction', + 'step10': wp / '10_WaterIndex_CSV', + 'step11': wp / '11_Thematic_Map', + 'step12': wp / '12_visualization', + 'step13': wp / '12_visualization', + # 语义别名 + 'visualization': wp / '12_visualization', + 'reports': wp / 'reports', } PipelineContext._STEP_OUTPUT_DIR_MAP = m return m diff --git a/src/core/handlers/pipeline_scheduler.py b/src/core/handlers/pipeline_scheduler.py index a4a1803..4a24543 100644 --- a/src/core/handlers/pipeline_scheduler.py +++ b/src/core/handlers/pipeline_scheduler.py @@ -66,6 +66,14 @@ class PipelineScheduler: for h in handlers: self.register_handler(h) + # ═══════════════════════════════════════════════════════════ + # 取消机制 + # ═══════════════════════════════════════════════════════════ + + def cancel(self): + """请求取消当前 Pipeline 执行(代理到 PipelineContext)。""" + self.ctx.cancel() + # ═══════════════════════════════════════════════════════════ # 回调 # ═══════════════════════════════════════════════════════════ @@ -135,6 +143,11 @@ class PipelineScheduler: # 按 config 中的顺序遍历(Python 3.7+ dict 保序) for step_key, step_config in config.items(): + # ★ 每步执行前检查取消标志 + if self.ctx.is_cancelled(): + self.ctx.notify(step_key, 'skipped', '流程已被用户取消') + break + handler = self._handlers.get(step_key) if handler is None: @@ -152,6 +165,11 @@ class PipelineScheduler: self.ctx.notify(step_key, 'error', error_msg) # 不中止,继续执行后续步骤 + # ★ 每步执行后也检查取消标志 + if self.ctx.is_cancelled(): + self.ctx.notify('pipeline', 'info', '流程已被用户取消') + break + self.ctx.pipeline_end_time = time.time() total_elapsed = self.ctx.pipeline_end_time - self.ctx.pipeline_start_time diff --git a/src/core/handlers/register_handlers.py b/src/core/handlers/register_handlers.py index 308a6d5..bb08530 100644 --- a/src/core/handlers/register_handlers.py +++ b/src/core/handlers/register_handlers.py @@ -21,7 +21,9 @@ from src.core.handlers.step7_calc_indices import Step7CalcIndicesHandler from src.core.handlers.step8_ml_train import Step8MlTrainHandler from src.core.handlers.step9_ml_predict import Step9MlPredictHandler from src.core.handlers.step10_qaa_inversion import Step10QaaInversionHandler +from src.core.handlers.step10_watercolor_handler import Step10WatercolorHandler from src.core.handlers.step11_concentration import Step11ConcentrationHandler +from src.core.handlers.step11_map_handler import Step11MapHandler from src.core.handlers.step12_kriging import Step12KrigingHandler from src.core.handlers.step13_visualization import Step13VisualizationHandler from src.core.handlers.step14_report import Step14ReportHandler @@ -51,6 +53,8 @@ def register_all_handlers(scheduler: PipelineScheduler): scheduler.register_handler(Step8MlTrainHandler()) scheduler.register_handler(Step9MlPredictHandler()) scheduler.register_handler(Step10QaaInversionHandler()) + scheduler.register_handler(Step10WatercolorHandler()) + scheduler.register_handler(Step11MapHandler()) scheduler.register_handler(Step11ConcentrationHandler()) scheduler.register_handler(Step12KrigingHandler()) scheduler.register_handler(Step13VisualizationHandler()) diff --git a/src/core/steps/glint_removal_step.py b/src/core/steps/glint_removal_step.py index ca20b7b..daffacc 100644 --- a/src/core/steps/glint_removal_step.py +++ b/src/core/steps/glint_removal_step.py @@ -166,20 +166,41 @@ class GlintRemovalStep: notify("skipped", "跳过去耀斑,使用原始影像") return img_path - # ---- 确定水域掩膜 ---- + # ---- 确定水域掩膜(★ 防护临时文件竞态) ---- final_water_mask = water_mask - if final_water_mask is not None and str(final_water_mask).lower().endswith(".shp"): + if final_water_mask is not None and isinstance(final_water_mask, str): + fp = str(final_water_mask) + # 过滤临时文件后缀:__tmp / _tmp / _tmp_delete 不应作为输入路径 + import re + _tmp_re = re.compile(r'(__tmp|_tmp_delete|_tmp)(?=\.\w+$|$)', re.IGNORECASE) + if _tmp_re.search(os.path.basename(fp)): + stable = _tmp_re.sub('', os.path.basename(fp)) + stable = re.sub(r'_{2,}', '_', stable) # 修复双下划线残留 + alt = os.path.join(os.path.dirname(fp), stable) + if os.path.isfile(alt): + print(f" [glint_removal] 检测到临时文件路径,自动替换为稳定文件: " + f"{os.path.basename(fp)} → {stable}") + final_water_mask = alt + else: + print(f" [glint_removal] 警告: 输入掩膜路径疑似临时文件," + f"但稳定文件不存在: {stable}") + # shp 自动替换为 dat - dat_mask = str(Path(water_mask_dir) / "water_mask_from_shp.dat") - if Path(dat_mask).exists(): - print(f"检测到输入掩膜为 .shp,自动替换为栅格掩膜: {dat_mask}") - final_water_mask = dat_mask + if final_water_mask is not None and str(final_water_mask).lower().endswith(".shp"): + dat_mask = str(Path(water_mask_dir) / "water_mask_from_shp.dat") + if Path(dat_mask).exists(): + print(f"检测到输入掩膜为 .shp,自动替换为栅格掩膜: {dat_mask}") + final_water_mask = dat_mask if final_water_mask is None: - dat_mask_default = str(Path(water_mask_dir) / "water_mask_from_shp.dat") - if Path(dat_mask_default).exists(): - final_water_mask = dat_mask_default - print(f"使用步骤1生成的水域掩膜: {final_water_mask}") + # ★ 按优先级查找稳定的掩膜文件(跳过临时文件) + for candidate_name in ("water_mask_from_shp.dat", "water_mask_out.dat", + "water_mask_from_ndwi.dat"): + candidate = str(Path(water_mask_dir) / candidate_name) + if Path(candidate).exists(): + final_water_mask = candidate + print(f"使用步骤1生成的水域掩膜: {final_water_mask}") + break # ---- 步骤3.1: 0值像素插值 ---- if interpolate_zeros: diff --git a/src/core/utils/mask_converter.py b/src/core/utils/mask_converter.py index 72da6e0..ac1c766 100644 --- a/src/core/utils/mask_converter.py +++ b/src/core/utils/mask_converter.py @@ -6,6 +6,8 @@ 以及水体掩膜的预处理逻辑。 """ import os +import re +import time from pathlib import Path from typing import Optional, Union @@ -61,6 +63,9 @@ def prepare_water_mask_for_algorithm( # 字符串路径 if isinstance(water_mask, str): + # ★ 入口防御:自动将临时文件路径修正为稳定文件路径 + water_mask = _resolve_stable_mask_path(water_mask) + ext = Path(water_mask).suffix.lower() # shapefile 格式 @@ -113,24 +118,83 @@ def _convert_shp_to_mask(shp_path: str, img_path: str, return _load_raster_mask(temp_mask_path, image_shape[0], image_shape[1]) -def _load_raster_mask(mask_path: str, img_height: int, img_width: int) -> np.ndarray: - """从栅格文件加载掩膜""" +# ── 临时文件关键词(与 workspace_manager.TMP_KEYWORDS 保持一致)── +_TMP_PATTERN = re.compile(r'(__tmp|_tmp_delete|_tmp)(?=\.\w+$|$)', re.IGNORECASE) + + +def _resolve_stable_mask_path(mask_path: str) -> str: + """如果 mask_path 指向临时文件,尝试解析为对应的稳定文件。 + + 例如: "water_mask_out__tmp_delete.dat" → "water_mask_out.dat" + """ + if not _TMP_PATTERN.search(os.path.basename(mask_path)): + return mask_path + + stable = _TMP_PATTERN.sub('', os.path.basename(mask_path)) + stable_path = os.path.join(os.path.dirname(mask_path), stable) + if os.path.isfile(stable_path): + print(f" [mask_converter] 自动将临时路径解析为稳定文件: " + f"{os.path.basename(mask_path)} → {stable}") + return stable_path + + # 尝试去除扩展名中多余的重复段(如 _tmp_delete 前后的双下划线残留) + stable2 = re.sub(r'_{2,}', '_', stable) + stable2_path = os.path.join(os.path.dirname(mask_path), stable2) + if os.path.isfile(stable2_path): + print(f" [mask_converter] 自动将临时路径解析为稳定文件: " + f"{os.path.basename(mask_path)} → {stable2}") + return stable2_path + + return mask_path + + +def _load_raster_mask(mask_path: str, img_height: int, img_width: int, + retry_count: int = 3, retry_delay: float = 0.5) -> np.ndarray: + """从栅格文件加载掩膜(带临时文件自动修复 + 重试机制)。 + + 2026-06-30 修复: + - 自动将 __tmp_delete / _tmp 类临时路径解析为对应的稳定文件名 + - 若文件不存在,短暂等待后重试(应对并发写入/删除的 TOCTOU 竞态) + """ if not GDAL_AVAILABLE: raise ImportError("GDAL未安装,无法读取掩膜文件") - mask_dataset = gdal.Open(mask_path, gdal.GA_ReadOnly) - if mask_dataset is None: - raise ValueError(f"无法打开掩膜文件: {mask_path}") + # ★ 临时文件 → 稳定文件自动修正 + mask_path = _resolve_stable_mask_path(mask_path) - try: - mask_array = mask_dataset.GetRasterBand(1).ReadAsArray() - finally: - mask_dataset = None + last_error = None + for attempt in range(1, retry_count + 1): + if not os.path.isfile(mask_path): + last_error = FileNotFoundError( + f"掩膜文件不存在: {mask_path}" + ) + if attempt < retry_count: + time.sleep(retry_delay) + continue + raise last_error - if mask_array.shape != (img_height, img_width): - raise ValueError(f"掩膜尺寸 {mask_array.shape} 与图像尺寸 {(img_height, img_width)} 不匹配") + mask_dataset = gdal.Open(mask_path, gdal.GA_ReadOnly) + if mask_dataset is None: + last_error = ValueError(f"无法打开掩膜文件: {mask_path}") + if attempt < retry_count: + time.sleep(retry_delay) + continue + raise last_error - return (mask_array > 0).astype(np.uint8) + try: + mask_array = mask_dataset.GetRasterBand(1).ReadAsArray() + finally: + mask_dataset = None + + if mask_array.shape != (img_height, img_width): + raise ValueError( + f"掩膜尺寸 {mask_array.shape} 与图像尺寸 {(img_height, img_width)} 不匹配" + ) + + return (mask_array > 0).astype(np.uint8) + + # 不应到达这里,但保持类型安全 + raise last_error def ensure_water_mask_dat(img_path: str, diff --git a/src/core/visualization/scatter_plot.py b/src/core/visualization/scatter_plot.py index 78dce80..97ad711 100644 --- a/src/core/visualization/scatter_plot.py +++ b/src/core/visualization/scatter_plot.py @@ -51,7 +51,7 @@ def generate_model_scatter_plots( # 确定输出目录 if output_dir is None: - output_dir = str(Path(models_dir).parent / "14_visualization" / "scatter_plots") + output_dir = str(Path(models_dir).parent / "12_visualization" / "scatter_plots") Path(output_dir).mkdir(parents=True, exist_ok=True) # 实例化可视化器 diff --git a/src/core/workspace_manager.py b/src/core/workspace_manager.py index 77c220a..03edd6d 100644 --- a/src/core/workspace_manager.py +++ b/src/core/workspace_manager.py @@ -41,8 +41,8 @@ class WorkspaceManager: 'step7_index': {'training_spectra_indices': "7_Water_Quality_Indices/training_spectra_indices.csv"}, 'step8_ml_train': {'Supervised_Model_Training': "8_Supervised_Model_Training/"}, 'step9_ml_predict': {'9_ML_Prediction': "9_ML_Prediction/"}, - 'step10_watercolor': {'WaterIndex_Images': "10_WaterIndex_Images/"}, - 'step11_map': {'14_visualization': "14_visualization/"}, + 'step10_watercolor': {'WaterIndex_CSV': "10_WaterIndex_CSV/"}, + 'step11_map': {'11_Thematic_Map': "11_Thematic_Map/"}, } self.step_outputs = {} @@ -159,17 +159,15 @@ class WorkspaceManager: '1_water_mask': 'step1', '2_Glint_Detection': 'step2', '3_deglint': 'step3', + '4_sampling': 'step4_sampling', '5_Data_Cleaning': 'step5_clean', '6_Spectral_Feature_Extraction': 'step6_feature', '7_Water_Quality_Indices': 'step7_index', '8_Supervised_Model_Training': 'step8_ml_train', - '8_Regression_Modeling': 'step8_ml_train', - '13_Custom_Regression': 'step13', '9_ML_Prediction': 'step9_ml_predict', - '11_12_13_predictions/Non_Empirical_Prediction': 'step11_map', - '13_Custom_Regression/Custom_Regression_Prediction': 'step13', - '14_visualization': 'step13_report', - '10_geotiff_batch_rendering': 'step11_map' + '10_WaterIndex_CSV': 'step10_watercolor', + '11_Thematic_Map': 'step11_map', + '12_visualization': ['step12_viz', 'step13_report'], } for subdir, step_ids in subdirs.items(): diff --git a/src/gui/components/image_viewer_components.py b/src/gui/components/image_viewer_components.py index 22808ac..ba6a01a 100644 --- a/src/gui/components/image_viewer_components.py +++ b/src/gui/components/image_viewer_components.py @@ -108,10 +108,10 @@ class ImageCategoryTree(QTreeWidget): if not work_path.exists(): return - # 查找所有图像文件:14_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图) + # 查找所有图像文件:12_visualization 为主,同时扫描步骤产出目录(如 1_water_mask 下的预览/叠置图) image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] scan_roots: List[Path] = [] - _viz = work_path / "14_visualization" + _viz = work_path / "12_visualization" if _viz.is_dir(): scan_roots.append(_viz) _wm = work_path / "1_water_mask" diff --git a/src/gui/components/image_widgets.py b/src/gui/components/image_widgets.py index 444af38..a477468 100644 --- a/src/gui/components/image_widgets.py +++ b/src/gui/components/image_widgets.py @@ -106,7 +106,7 @@ class ImageCategoryTree(QTreeWidget): image_extensions = ['*.png', '*.jpg', '*.jpeg', '*.tif', '*.tiff', '*.bmp'] scan_roots: List[Path] = [] - _viz = work_path / "14_visualization" + _viz = work_path / "12_visualization" if _viz.is_dir(): scan_roots.append(_viz) _wm = work_path / "1_water_mask" diff --git a/src/gui/core/config_manager.py b/src/gui/core/config_manager.py index a119eb0..7442f42 100644 --- a/src/gui/core/config_manager.py +++ b/src/gui/core/config_manager.py @@ -92,6 +92,14 @@ class ConfigManager(QObject): ) return + # ★ 验证 JSON 结构:必须是 dict 类型 + if not isinstance(config, dict): + QMessageBox.critical( + self.parent(), "配置格式错误", + f"配置文件格式不正确(期望 JSON 对象,实际为 {type(config).__name__})。\n请确认选择了正确的配置文件。" + ) + return + # 回填已加载面板 loaded_count = 0 for step_id, panel in self._panel_factory.get_loaded_panels().items(): @@ -99,8 +107,11 @@ class ConfigManager(QObject): try: panel.set_config(config[step_id]) loaded_count += 1 - except Exception: - pass + except Exception as e: + global_event_bus.publish('LogMessage', { + 'message': f'[警告] 面板 {step_id} 加载配置失败: {e}', + 'level': 'warning', + }) global_event_bus.publish('LogMessage', { 'message': f'已加载配置: {file_path}(回填 {loaded_count} 个面板)', @@ -156,6 +167,10 @@ class ConfigManager(QObject): if hasattr(panel, 'get_config'): try: config[step_id] = panel.get_config() - except Exception: + except Exception as e: config[step_id] = {} + global_event_bus.publish('LogMessage', { + 'message': f'[警告] 面板 {step_id} 获取配置失败: {e}', + 'level': 'warning', + }) return config diff --git a/src/gui/core/dependency_subscriber.py b/src/gui/core/dependency_subscriber.py index 3dd8ba8..ca182da 100644 --- a/src/gui/core/dependency_subscriber.py +++ b/src/gui/core/dependency_subscriber.py @@ -16,12 +16,17 @@ PANEL_REGISTRY 中声明的 dependencies 自动向 global_event_bus - sip.isdeleted() 保护:面板销毁后回调自动跳过,避免 C++ 野指针 Segfault - 非空保护:仅在目标框为空时填充,避免覆盖用户已选路径 - 智能目录转换:目标控件名含 'dir' 且事件携带的是文件路径时,自动取父目录 +- ★ panel.destroyed 信号自动取消订阅(2026-06-30) +- ★ weakref 防止闭包阻止 panel GC(2026-06-30) 2026-06-30 修复: - 添加 sip.isdeleted(panel) 检查,防止面板删除后访问 C++ 对象导致崩溃 + - 连接 panel.destroyed 信号自动清理订阅,彻底杜绝回调泄漏 + - 使用 weakref.ref 包装回调中的 panel 引用,防止 GC 阻塞 """ import os +import weakref import sip from src.gui.core.event_bus import global_event_bus @@ -48,8 +53,18 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies): return panel_id = id(panel) - if panel_id not in _subscription_registry: - _subscription_registry[panel_id] = [] + # ★ 若之前已订阅过,先清理旧订阅(防止重复订阅泄漏) + if panel_id in _subscription_registry: + unsubscribe_panel_from_dependencies(panel) + + _subscription_registry[panel_id] = [] + + # ★ 面板销毁时自动清理订阅,杜绝闭包泄漏 + if hasattr(panel, 'destroyed'): + try: + panel.destroyed.connect(lambda obj=None: _on_panel_destroyed(panel_id)) + except Exception: + pass for _input_field, (dep_step, output_type, source_panel_attr) in dependencies.items(): callback = _make_callback(panel, dep_step, output_type, _input_field) @@ -57,6 +72,13 @@ def subscribe_panel_to_dependencies(panel, step_id, dependencies): _subscription_registry[panel_id].append(('OutputUpdated', callback)) +def _on_panel_destroyed(panel_id): + """panel.destroyed 信号回调:清理该面板的所有订阅。""" + subscriptions = _subscription_registry.pop(panel_id, []) + for event_name, callback in subscriptions: + global_event_bus.unsubscribe(event_name, callback) + + def unsubscribe_panel_from_dependencies(panel): """取消面板的所有依赖订阅。应在面板销毁前调用,防止野指针回调。 @@ -72,13 +94,20 @@ def unsubscribe_panel_from_dependencies(panel): def _make_callback(panel, dep_step, output_type, target_widget_name): """为单个依赖项创建事件回调。返回闭包函数。 - 使用工厂函数确保每个回调的闭包变量独立绑定。 + 使用 weakref 持有 panel 引用,防止闭包阻止 panel GC。 + 同时检查 panel 和 widget 的 C++ 对象是否存活。 """ + panel_ref = weakref.ref(panel) + def callback(data): + p = panel_ref() + if p is None: + return + # ★ 防野指针:面板底层 C++ 对象已销毁时直接跳过 try: - if sip.isdeleted(panel): + if sip.isdeleted(p): return except Exception: return @@ -88,10 +117,17 @@ def _make_callback(panel, dep_step, output_type, target_widget_name): if data.get('output_type') != output_type: return - widget = getattr(panel, target_widget_name, None) + widget = getattr(p, target_widget_name, None) if widget is None: return + # ★ 检查 widget 自身的 C++ 对象是否存活 + try: + if sip.isdeleted(widget): + return + except Exception: + return + current = '' try: if hasattr(widget, 'get_path'): diff --git a/src/gui/core/dialog_service.py b/src/gui/core/dialog_service.py index 7cba483..329d90c 100644 --- a/src/gui/core/dialog_service.py +++ b/src/gui/core/dialog_service.py @@ -14,6 +14,10 @@ from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QMessageBox +# ★ 集中版本号常量 +APP_VERSION = "V1.2" +APP_NAME = "MegaCube-Water Quality" + class DialogService(QObject): """对话框服务。 @@ -53,7 +57,7 @@ class DialogService(QObject): """显示"关于"对话框。""" QMessageBox.about( self.parent(), "关于", - "MegaCube-Water Quality V1.2\n\n" + f"{APP_NAME} {APP_VERSION}\n\n" "一个完整的水质参数反演工作流程工具\n\n" "公司:北京依锐思遥感技术有限公司\n" "地址:北京市海淀区清河安宁庄东路18号5号楼二层205\n" diff --git a/src/gui/core/event_bus.py b/src/gui/core/event_bus.py index b62a795..5c29890 100644 --- a/src/gui/core/event_bus.py +++ b/src/gui/core/event_bus.py @@ -11,16 +11,19 @@ - 新增 unsubscribe() 方法,允许面板销毁时清理订阅,防止内存泄漏和野指针回调 """ +import sys +import threading import traceback from collections import defaultdict from typing import Any, Callable, Dict, List, Optional class EventBus: - """发布-订阅事件总线""" + """发布-订阅事件总线(线程安全)。""" def __init__(self): self._subscribers: Dict[str, List[Callable]] = defaultdict(list) + self._lock = threading.Lock() # 异常日志回调(可选注入,供 LogManager 使用) self._error_logger: Optional[Callable[[str], None]] = None @@ -30,38 +33,42 @@ class EventBus: def subscribe(self, event_name: str, callback: Callable[[dict], None]): """订阅事件。callback 接收一个 dict 作为事件数据。""" - if callback not in self._subscribers[event_name]: - self._subscribers[event_name].append(callback) + with self._lock: + if callback not in self._subscribers[event_name]: + self._subscribers[event_name].append(callback) def unsubscribe(self, event_name: str, callback: Callable[[dict], None]): """取消订阅。面板销毁或不再需要接收事件时调用,防止野指针回调。""" - subs = self._subscribers.get(event_name) - if subs and callback in subs: - subs.remove(callback) - # 清理空列表,避免字典膨胀 - if not subs: - del self._subscribers[event_name] + with self._lock: + subs = self._subscribers.get(event_name) + if subs and callback in subs: + subs.remove(callback) + # 清理空列表,避免字典膨胀 + if not subs: + del self._subscribers[event_name] def publish(self, event_name: str, data: Dict[str, Any]): """发布事件,通知所有订阅者。订阅者异常不再静默吞掉,而是输出 traceback。 迭代订阅者列表的副本,防止回调中调用 unsubscribe() 导致跳过后续订阅者。 """ - for callback in list(self._subscribers.get(event_name, [])): + with self._lock: + snapshot = list(self._subscribers.get(event_name, [])) + for callback in snapshot: try: callback(data) except Exception: + cb_name = getattr(callback, '__name__', None) or repr(callback) err_msg = ( - f"[EventBus] 事件 '{event_name}' 的订阅者 {callback.__name__!r} 抛出异常:\n" + f"[EventBus] 事件 '{event_name}' 的订阅者 {cb_name!r} 抛出异常:\n" + traceback.format_exc() ) if self._error_logger: try: self._error_logger(err_msg) except Exception: - pass + print(err_msg, file=sys.stderr, flush=True) else: - import sys print(err_msg, file=sys.stderr, flush=True) diff --git a/src/gui/core/log_manager.py b/src/gui/core/log_manager.py index 18f5f67..c523aea 100644 --- a/src/gui/core/log_manager.py +++ b/src/gui/core/log_manager.py @@ -16,6 +16,7 @@ ProgressUpdate → {percentage, message} 更新进度条 """ +import html from datetime import datetime from PyQt5.QtCore import QObject @@ -27,6 +28,8 @@ from PyQt5.QtWidgets import ( from src.gui.core.event_bus import global_event_bus +_MAX_LOG_LINES = 2000 # 日志行数上限,防止长时间运行内存耗尽 + class LogManager(QObject): """日志与进度管理器。 @@ -48,6 +51,15 @@ class LogManager(QObject): global_event_bus.subscribe('LogMessage', self._on_log_message) global_event_bus.subscribe('ProgressUpdate', self._on_progress_update) + # ★ 父控件销毁时自动取消订阅,防止回调泄漏 + if parent is not None: + parent.destroyed.connect(self._cleanup) + + def _cleanup(self): + """清理 EventBus 订阅。""" + global_event_bus.unsubscribe('LogMessage', self._on_log_message) + global_event_bus.unsubscribe('ProgressUpdate', self._on_progress_update) + # ═══════════════════════════════════════════════════════════ # 公开 API # ═══════════════════════════════════════════════════════════ @@ -168,13 +180,23 @@ class LogManager(QObject): """LogMessage 事件回调:写入日志区。""" if self._log_text is None: return + if not isinstance(data, dict): + return message = data.get('message', '') level = data.get('level', 'info') timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # ★ HTML 转义:防止日志消息中的 < > & 破坏 HTML 渲染 + safe_message = html.escape(str(message)) color_map = {'error': 'red', 'warning': 'orange'} color = color_map.get(level, 'black') - formatted = f'[{timestamp}] {message}' + formatted = f'[{timestamp}] {safe_message}' self._log_text.append(formatted) + # ★ 日志行数上限:防止长时间运行内存耗尽 + if self._log_text.document().blockCount() > _MAX_LOG_LINES + 100: + self._log_text.clear() + self._log_text.append( + f'[日志已自动清空,达到 {_MAX_LOG_LINES} 行上限]' + ) cursor = self._log_text.textCursor() cursor.movePosition(QTextCursor.End) self._log_text.setTextCursor(cursor) diff --git a/src/gui/core/panel_factory.py b/src/gui/core/panel_factory.py index 3351c23..594c2b2 100644 --- a/src/gui/core/panel_factory.py +++ b/src/gui/core/panel_factory.py @@ -202,6 +202,8 @@ class PanelFactory: self._tab_widget.removeTab(tab_index) self._tab_widget.insertTab(tab_index, scroll, tab_icon, tab_title) self._tab_widget.setCurrentIndex(current_active) # 恢复原来的 Tab,严禁后台预加载引发跳页! + # ★ 显式释放占位页,防止泄漏 + placeholder.deleteLater() finally: self._tab_widget.blockSignals(False) @@ -260,6 +262,9 @@ class PanelFactory: # 3. 实时扫描已加载面板中被依赖的属性(覆盖全局输入如 reference_img) self._replay_live_panel_inputs() + # ★ 重新禁用滚轮:update_from_config 可能动态创建了新的 SpinBox/ComboBox + _disable_wheel_for_widget(panel) + def _replay_live_panel_inputs(self): """遍历 PANEL_REGISTRY 依赖声明,从已加载面板实时读取属性值并强制广播。 @@ -290,7 +295,7 @@ class PanelFactory: if not path: continue # 核心修复:强制转为绝对路径,防止跨目录传递时路径丢失 - absolute_path = os.path.abspath(path).replace('\\', '/') + absolute_path = os.path.normpath(os.path.abspath(path)) # 2026-06-30:仅当文件/目录确实存在时才广播,阻断幽灵路径级联 if not os.path.exists(absolute_path): diff --git a/src/gui/core/panel_registry.py b/src/gui/core/panel_registry.py index e072931..90f97f8 100644 --- a/src/gui/core/panel_registry.py +++ b/src/gui/core/panel_registry.py @@ -167,7 +167,7 @@ PANEL_REGISTRY = [ 'step_id': 'step9_ml_predict', 'class_ref': Step9MlPredictPanel, 'title': '机器学习预测', - 'icon': '10.png', + 'icon': '9.png', 'stage': '模块三 模型训练与反演', 'display_name': '9. 机器学习预测', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; @@ -198,7 +198,7 @@ PANEL_REGISTRY = [ 'step_id': 'step11_map', 'class_ref': Step11MapPanel, 'title': '专题图生成', - 'icon': '10.png', + 'icon': '11.png', 'stage': '模块四 制图与成果汇编', 'display_name': '11. 分布图生成', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名 @@ -226,7 +226,7 @@ PANEL_REGISTRY = [ 'step_id': 'step13_report', 'class_ref': Step13ReportPanel, 'title': '报告生成', - 'icon': '10.png', + 'icon': '13.png', 'stage': '模块四 制图与成果汇编', 'display_name': '13. 分析报告生成', 'dependencies': None, diff --git a/src/gui/core/pipeline_executor.py b/src/gui/core/pipeline_executor.py index dd76fe1..16d13f0 100644 --- a/src/gui/core/pipeline_executor.py +++ b/src/gui/core/pipeline_executor.py @@ -118,6 +118,18 @@ class PipelineExecutor(QObject): # ★ 终端即时反馈:确保即使 EventBus/日志区未就绪也能看到 print("\n[PipelineExecutor] 收到「运行完整流程」指令,开始执行...") + # ★ 防重入:如果已有 Worker 在运行,拒绝启动 + if self.is_running: + global_event_bus.publish('LogMessage', { + 'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新流程。', + 'level': 'warning', + }) + QMessageBox.warning( + self.parent(), "后台忙碌", + "后台任务正在运行中,无法启动新流程。\n请等待当前任务完成或点击「停止」按钮后再试。" + ) + return + if not PIPELINE_AVAILABLE: global_event_bus.publish('LogMessage', { 'message': '无法导入 Pipeline 模块,请检查项目文件结构!', @@ -340,6 +352,18 @@ class PipelineExecutor(QObject): ) def _run_single_step_impl(self, step_name: str, config: dict = None): + # ★ 防重入:如果已有 Worker 在运行,拒绝启动 + if self.is_running: + global_event_bus.publish('LogMessage', { + 'message': '[警告] 后台任务正在运行中,请等待完成或手动停止后再启动新步骤。', + 'level': 'warning', + }) + QMessageBox.warning( + self.parent(), "后台忙碌", + "后台任务正在运行中,无法启动新步骤。\n请等待当前任务完成或点击「停止」按钮后再试。" + ) + return + if not PIPELINE_AVAILABLE: global_event_bus.publish('LogMessage', { 'message': '无法导入 Pipeline 模块,请检查 src/core/handlers/ 目录是否完整!', diff --git a/src/gui/core/training_mode_manager.py b/src/gui/core/training_mode_manager.py index 4905093..123dd13 100644 --- a/src/gui/core/training_mode_manager.py +++ b/src/gui/core/training_mode_manager.py @@ -45,6 +45,10 @@ class TrainingModeManager(QObject): Args: checked: True=有训练数据模式, False=无训练数据模式 """ + # ★ 防护:相同状态不重复发布事件 + if self._training_mode == checked: + return + self._training_mode = checked global_event_bus.publish('LogMessage', { diff --git a/src/gui/core/viz_thread.py b/src/gui/core/viz_thread.py index 909ea93..80c8af4 100644 --- a/src/gui/core/viz_thread.py +++ b/src/gui/core/viz_thread.py @@ -73,7 +73,7 @@ class VisualizationWorkerThread(QThread): wp = Path(self.work_dir) if self.task == "mask_glint": from src.postprocessing.visualization_reports import WaterQualityVisualization - viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization")) + viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization")) preview_paths = viz.generate_glint_deglint_previews( work_dir=str(wp), output_subdir="glint_deglint_previews", @@ -109,7 +109,7 @@ class VisualizationWorkerThread(QThread): csv_path = str(csv_files[0]) from src.postprocessing.point_map import SamplingPointMap map_generator = SamplingPointMap( - output_dir=str(wp / "14_visualization" / "sampling_maps"), + output_dir=str(wp / "12_visualization" / "sampling_maps"), fast_mode=True, ) map_path = map_generator.create_sampling_point_map( @@ -134,7 +134,7 @@ class VisualizationWorkerThread(QThread): ) elif self.task == "spectrum": from src.postprocessing.visualization_reports import WaterQualityVisualization - viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization")) + viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization")) csv_file = self.extra.get("csv_path") wl = self.extra.get("wavelength_start_column", "UTM_Y") n_groups = int(self.extra.get("n_groups", 5)) @@ -178,7 +178,7 @@ class VisualizationWorkerThread(QThread): ) elif self.task == "statistics": from src.postprocessing.visualization_reports import WaterQualityVisualization - viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization")) + viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization")) csv_file = self.extra.get("csv_path") param_cols = self.extra.get("param_cols") or [] output_paths = viz.plot_statistical_charts( @@ -206,7 +206,7 @@ class VisualizationWorkerThread(QThread): self.finished_ok.emit({"task": "scatter", "scatter_paths": scatter_paths or {}}) elif self.task == "generate_all_selected": from src.postprocessing.visualization_reports import WaterQualityVisualization - viz = WaterQualityVisualization(output_dir=str(wp / "14_visualization")) + viz = WaterQualityVisualization(output_dir=str(wp / "12_visualization")) parts = [] training_csv_path = (self.extra.get("training_csv_path") or "").strip() @@ -333,7 +333,7 @@ class VisualizationWorkerThread(QThread): csv_path = str(csv_files[0]) from src.postprocessing.point_map import SamplingPointMap map_generator = SamplingPointMap( - output_dir=str(wp / "14_visualization" / "sampling_maps"), + output_dir=str(wp / "12_visualization" / "sampling_maps"), fast_mode=True, ) map_path = map_generator.create_sampling_point_map( diff --git a/src/gui/core/workspace_initializer.py b/src/gui/core/workspace_initializer.py index e45cead..3ba877a 100644 --- a/src/gui/core/workspace_initializer.py +++ b/src/gui/core/workspace_initializer.py @@ -28,8 +28,8 @@ import sys from pathlib import Path from typing import Optional -from PyQt5.QtCore import QObject -from PyQt5.QtWidgets import QMessageBox, QFileDialog +from PyQt5.QtCore import QObject, Qt, QThread, QTimer +from PyQt5.QtWidgets import QMessageBox, QFileDialog, QApplication from src.gui.core.event_bus import global_event_bus from src.core.workspace_manager import WorkspaceManager @@ -157,10 +157,21 @@ class WorkspaceInitializer(QObject): panel.set_work_dir(dir_path) def open_work_directory(self): - """在资源管理器中打开工作目录。""" + """在资源管理器中打开工作目录(跨平台)。""" + import subprocess + import platform work_dir = self._work_dir or './work_dir' if os.path.exists(work_dir): - os.startfile(work_dir) + try: + system = platform.system() + if system == 'Windows': + os.startfile(work_dir) + elif system == 'Darwin': + subprocess.Popen(['open', work_dir]) + else: + subprocess.Popen(['xdg-open', work_dir]) + except Exception as e: + QMessageBox.warning(self.parent(), "警告", f"无法打开工作目录: {e}") else: QMessageBox.warning(self.parent(), "警告", "工作目录不存在!") @@ -205,8 +216,11 @@ class WorkspaceInitializer(QObject): continue try: panel.update_from_config(work_dir=work_dir, pipeline=None) - except Exception: - pass + except Exception as e: + global_event_bus.publish('LogMessage', { + 'message': f'[警告] 面板 {step_id} 自动填充失败: {e}', + 'level': 'warning', + }) global_event_bus.publish('LogMessage', { 'message': '✓ 工作目录扫描完成,事件总线已通知所有面板自动填充', @@ -223,7 +237,15 @@ class WorkspaceInitializer(QObject): 当 PipelineExecutor 发布 StepCompleted 事件时, 此方法调用 WorkspaceManager.update_step_outputs() 扫描磁盘, WorkspaceManager 内部自动发布 OutputUpdated 事件驱动面板填充。 + + ★ 线程安全:若回调发生在非主线程(如 worker 线程), + 通过 QTimer.singleShot(0) 编组到主线程再执行。 """ + # 线程安全检查:非主线程时编组到主线程 + if QApplication.instance() and QApplication.instance().thread() != QThread.currentThread(): + QTimer.singleShot(0, lambda: self._on_step_completed(data)) + return + if not data.get('success'): return diff --git a/src/gui/panels/_step_path_resolver.py b/src/gui/panels/_step_path_resolver.py index 31aa168..7c36d49 100644 --- a/src/gui/panels/_step_path_resolver.py +++ b/src/gui/panels/_step_path_resolver.py @@ -26,22 +26,19 @@ from typing import Optional, Union # 这是"张冠李戴"修复的核心——main_window 上已不再直接挂载 panel 属性, # 所有面板都通过 _panel_factory.get_panel(step_id) 懒加载访问。 STEP_DATA_SOURCE = { - # 数据流 step 编号(用户口语) → PANEL_REGISTRY 中的 step_id - 'step5_clean_output': 'step5_clean', - 'step7_index_output': 'step7_index', - 'step8_ml_train_output': 'step8_ml_train', - 'step8_5_non_empirical': 'step8_ml_train', - 'step9_ml_predict_output': 'step9_ml_predict', - 'step10_watercolor_output': 'step10_watercolor', - 'step11_ml_prediction': 'step9_ml_predict', # 主流程 step11 = ML 预测 - 'step12_regression_prediction': 'step8_ml_train', # 主流程 step12 = 非经验预测 - 'step13_custom_regression': 'step13_report', # 自定义回归借用 step13 报告面板 - 'sampling_csv': 'step4_sampling', - 'training_spectra_csv': 'step5_clean', - 'indices_csv': 'step7_index', - 'models_dir': 'step8_ml_train', - 'watercolor_dir': 'step10_watercolor', - 'prediction_csv_dir': 'step9_ml_predict', # 默认从 ML 预测读 + # 数据流别名 → PANEL_REGISTRY 中的 step_id + 'step5_clean_output': 'step5_clean', + 'step7_index_output': 'step7_index', + 'step8_ml_train_output': 'step8_ml_train', + 'step9_ml_predict_output': 'step9_ml_predict', + 'step10_watercolor_output': 'step10_watercolor', + 'step11_map_output': 'step11_map', + 'sampling_csv': 'step4_sampling', + 'training_spectra_csv': 'step5_clean', + 'indices_csv': 'step7_index', + 'models_dir': 'step8_ml_train', + 'watercolor_dir': 'step10_watercolor', + 'prediction_csv_dir': 'step9_ml_predict', } @@ -92,7 +89,7 @@ def resolve_step_widget(main_window, step_key: str, widget_attr: str = 'output_f _FALLBACK_DIR_TABLE = { - # pipeline key(与 _ensure_step_dir_map 对齐)→ 子目录名 + # pipeline key(与 base.py _ensure_step_dir_map 对齐)→ 子目录名 'step1': '1_water_mask', 'step2': '2_Glint_Detection', 'step3': '3_deglint', @@ -101,23 +98,26 @@ _FALLBACK_DIR_TABLE = { 'step6_feature': '6_Spectral_Feature_Extraction', 'step7_index': '7_Water_Quality_Indices', 'step8_ml_train': '8_Supervised_Model_Training', + 'step9_ml_predict': '9_ML_Prediction', + 'step10_watercolor': '10_WaterIndex_CSV', + 'step11_map': '11_Thematic_Map', + 'step12_viz': '12_visualization', + 'step13_report': '12_visualization', + # 短别名 + 'step4': '4_sampling', + 'step5': '5_Data_Cleaning', + 'step6': '6_Spectral_Feature_Extraction', + 'step7': '7_Water_Quality_Indices', 'step8': '8_Supervised_Model_Training', - 'step9_ml_predict': '8_Non_Empirical_Regression', - 'step9': '8_Non_Empirical_Regression', - 'step10_watercolor': '10_WaterIndex_Images', - 'step10': '10_WaterIndex_Images', - 'step11_map': '14_visualization', - 'step11': '11_12_13_predictions', - 'step11_predictions': '11_12_13_predictions', - 'step12': '13_Custom_Regression', - 'step12_predictions': '11_12_13_predictions', - 'step13': 'reports', - 'step13_predictions': '11_12_13_predictions', - 'step14': '14_visualization', - 'prediction_dir': '11_12_13_predictions', - 'visualization': '14_visualization', + 'step9': '9_ML_Prediction', + 'step10': '10_WaterIndex_CSV', + 'step11': '11_Thematic_Map', + 'step12': '12_visualization', + 'step13': '12_visualization', + # 语义别名 + 'prediction_dir': '9_ML_Prediction', + 'visualization': '12_visualization', 'reports': 'reports', - 'custom_regression': '13_Custom_Regression', # 扩展:覆盖 panel 内部使用的子目录别名 'water_mask': '1_water_mask', 'glint_detection': '2_Glint_Detection', @@ -127,10 +127,9 @@ _FALLBACK_DIR_TABLE = { 'spectral_feature': '6_Spectral_Feature_Extraction', 'indices': '7_Water_Quality_Indices', 'supervised_models': '8_Supervised_Model_Training', - 'non_empirical': '8_Non_Empirical_Regression', 'qaa_inversion': '8_QAA_Inversion', 'regression_modeling': '8_Regression_Modeling', - 'watercolor': '10_WaterIndex_Images', + 'watercolor': '10_WaterIndex_CSV', 'ml_prediction': '9_ML_Prediction', 'sampling_csv_path': '4_sampling/sampling_spectra.csv', } @@ -156,7 +155,12 @@ def get_step_output_path( widget = resolve_step_widget(main_window, step_key, widget_attr) p = _read_widget_path(widget) if p: - if not Path(p).is_absolute() and wd: + # ★ Windows 驱动器相对路径 (如 C:foo) 也视为绝对,不做拼接 + is_win_drive_relative = ( + len(p) >= 2 and p[1] == ':' and not p[2:].startswith(('\\', '/')) + if len(p) > 2 else False + ) + if not is_win_drive_relative and not Path(p).is_absolute() and wd: p = str(Path(wd) / p).replace('\\', '/') return p @@ -172,7 +176,7 @@ def resolve_subdir(work_dir, subdir_key: str) -> str: """纯子目录拼装:把 pipeline key 解析为 work_dir 下的子目录路径。 用法:resolve_subdir(self.work_dir, 'visualization') - → '/14_visualization' + → '/12_visualization' 与 pipeline.get_step_output_dir 同源(都查同一份 _FALLBACK_DIR_TABLE 子集)。 """ @@ -219,49 +223,63 @@ def scan_work_dir_for_input(work_dir: str, output_type: str): if not work_dir: return None - wd = Path(work_dir) + try: + wd = Path(work_dir) + except (OSError, ValueError): + return None + entry = _SCAN_TABLE.get(output_type) if entry is None: return None subdir_key, extensions, matcher = entry target_dir = wd / _FALLBACK_DIR_TABLE.get(subdir_key, subdir_key) - if not target_dir.is_dir(): + try: + if not target_dir.is_dir(): + return None + except (OSError, PermissionError): return None # 目录类型(extensions 为 None)→ 只要目录存在就返回 if extensions is None: return str(target_dir).replace('\\', '/') - # 扫描目录中的文件 + # 扫描目录中的文件(带异常保护) candidates = [] for ext in extensions: - for f in target_dir.glob(f'*{ext}'): - if not f.is_file(): + for ext_variant in (ext, ext.upper()): + try: + for f in target_dir.glob(f'*{ext_variant}'): + try: + if f.is_file(): + candidates.append(f) + except OSError: + continue + except (OSError, PermissionError): continue - candidates.append(f) - # 也搜 ext 的大写变体 - for f in target_dir.glob(f'*{ext.upper()}'): - if not f.is_file(): - continue - candidates.append(f) if not candidates: return None # 按 matcher 优先级 + mtime 排序 - if matcher: - matched = [f for f in candidates if matcher.lower() in f.stem.lower()] - if matched: - matched.sort(key=lambda p: p.stat().st_mtime, reverse=True) - return str(matched[0]).replace('\\', '/') - # matcher 未命中时,仍返回最新文件(宽松匹配) + try: + if matcher: + matched = [f for f in candidates if matcher.lower() in f.stem.lower()] + if matched: + matched.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return str(matched[0]).replace('\\', '/') + # matcher 未命中时,仍返回最新文件(宽松匹配) + candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return str(candidates[0]).replace('\\', '/') + + # 无 matcher → 返回最新的 candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) return str(candidates[0]).replace('\\', '/') - - # 无 matcher → 返回最新的 - candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) - return str(candidates[0]).replace('\\', '/') + except (OSError, FileNotFoundError): + # TOCTOU:文件在 glob 和 stat 之间被删除 + if candidates: + return str(candidates[0]).replace('\\', '/') + return None __all__ = [ diff --git a/src/gui/panels/step10_watercolor_panel.py b/src/gui/panels/step10_watercolor_panel.py index a0d2de7..eb1b66d 100644 --- a/src/gui/panels/step10_watercolor_panel.py +++ b/src/gui/panels/step10_watercolor_panel.py @@ -1,14 +1,12 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Step10 面板 - 水色指数反演(散点 CSV 模式)(极致强迫症绝对对齐版) +Step10 面板 - 水色指数反演(散点 CSV 模式)(极致强迫症对齐功能完整版) 与 Step 9 (ML 预测) 完全对称的【散点处理模式】: - * 输入:Step 4 输出的 ``sampling_spectra.csv``(散点+全波段光谱) * 处理:解析 ``waterindex.csv`` 中的公式,**逐行**对每个采样点计算水色指数 -* 输出:每个公式一个 CSV,列严格为 ``longitude, latitude, ``, - 可直接喂给 Step 11 ContentMapper +* 输出:每个公式一个 CSV,列严格为 ``longitude, latitude, `` * 输出目录:默认 ``{work_dir}/10_WaterIndex_CSV/`` """ @@ -20,14 +18,11 @@ from typing import Dict, List, Optional from src.gui.panels._step_path_resolver import resolve_subdir, scan_work_dir_for_input from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout, - QGroupBox, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, - QFileDialog, QMessageBox, QListWidget, QListWidgetItem, - QAbstractItemView, QProgressBar, QTextEdit, QFrame, - QScrollArea, QSizePolicy, QDoubleSpinBox, + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QComboBox, QPushButton, QMessageBox, QListWidget, QListWidgetItem, + QSizePolicy, QDoubleSpinBox ) -from PyQt5.QtGui import QFont -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet @@ -50,60 +45,6 @@ CATEGORY_CHINESE_MAP = { } -class WaterIndexWorker(QThread): - """后台线程:散点 CSV → 逐行公式计算 → 多 CSV 输出""" - progress = pyqtSignal(str, float) # message, percent - finished_ok = pyqtSignal(dict) # {公式名: 输出 CSV 路径} - error = pyqtSignal(str) # error message - - def __init__( - self, - sampling_csv_path: str, - output_dir: str, - selected_formulas: List[str], - waterindex_csv: str, - work_dir: Optional[str] = None, - wavelength_offset: float = 0.0, - ): - super().__init__() - self.sampling_csv_path = sampling_csv_path - self.output_dir = output_dir - self.selected_formulas = selected_formulas - self.waterindex_csv = waterindex_csv - self.work_dir = work_dir - self.wavelength_offset = float(wavelength_offset) - - def run(self): - try: - from src.core.algorithms.waterindex_inversion import ( - WaterIndexCsvProcessor, - ) - - self.progress.emit("正在初始化散点水色指数处理器…", 2) - - processor = WaterIndexCsvProcessor(self.waterindex_csv) - - out_files = processor.compute_indices_from_csv( - sampling_csv_path=self.sampling_csv_path, - output_dir=self.output_dir, - selected_formulas=self.selected_formulas or None, - progress_callback=lambda m, p: self.progress.emit(m, p), - wavelength_offset=self.wavelength_offset, - ) - - self.progress.emit( - f"完成!共生成 {len(out_files)} 个指数 CSV", 100 - ) - self.finished_ok.emit(out_files) - - except FileNotFoundError as e: - self.error.emit(f"文件不存在: {e}") - except ValueError as e: - self.error.emit(f"参数错误: {e}") - except Exception as e: - self.error.emit(f"{e}\n{traceback.format_exc()}") - - class NoScrollPassListWidget(QListWidget): """一个绝对不会把滚轮事件传给外层父组件的列表控件""" @@ -117,7 +58,7 @@ class Step10WatercolorPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) - self._worker: Optional[WaterIndexWorker] = None + self.work_dir = None self._waterindex_csv = self._find_waterindex_csv() self._categories: List[str] = [] self._all_formulas: List[Dict] = [] @@ -127,7 +68,11 @@ class Step10WatercolorPanel(QWidget): self._load_formulas() def init_ui(self): + self.setStyleSheet(ModernStylesheet.get_main_stylesheet()) + layout = QVBoxLayout() + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(20) # ========================================== # 卡片 1:输入数据配置 @@ -142,10 +87,9 @@ class Step10WatercolorPanel(QWidget): "CSV Files (*.csv);;All Files (*.*)" ) self.formula_file.line_edit.setReadOnly(True) - self.formula_file.label.setMinimumWidth(120) - builtin_csv = self._find_waterindex_csv() - if builtin_csv: - self.formula_file.set_path(builtin_csv) + self.formula_file.label.setMinimumWidth(120) # 严格 120px 左对齐线 + if self._waterindex_csv: + self.formula_file.set_path(self._waterindex_csv) input_layout.addWidget(self.formula_file) self.sampling_csv_file = FileSelectWidget( @@ -167,7 +111,6 @@ class Step10WatercolorPanel(QWidget): "负数=公式波长减偏移。默认0表示不做修正。" ) - # 使用强化版的对齐方法直接插入到 input_layout 中 self._add_aligned_row(input_layout, "波长偏移:", self.wavelength_offset_spin, "nm") input_group.setLayout(input_layout) @@ -257,9 +200,9 @@ class Step10WatercolorPanel(QWidget): layout.addWidget(formula_group) # ========================================== - # 卡片 3:输出设置 + # 卡片 3:输出与执行 # ========================================== - output_group = QGroupBox("输出设置") + output_group = QGroupBox("🚀 输出与执行") output_layout = QVBoxLayout() output_layout.setSpacing(16) output_layout.setContentsMargins(20, 24, 20, 20) @@ -274,32 +217,6 @@ class Step10WatercolorPanel(QWidget): ) output_layout.addWidget(self.output_dir) - output_group.setLayout(output_layout) - layout.addWidget(output_group) - - # ---- 进度显示 ---- - self.progress_bar = QProgressBar() - self.progress_bar.setMinimum(0) - self.progress_bar.setMaximum(100) - self.progress_bar.setValue(0) - self.progress_bar.setTextVisible(True) - layout.addWidget(self.progress_bar) - - self.progress_label = QLabel("") - self.progress_label.setStyleSheet("font-size: 11px; color: #666;") - layout.addWidget(self.progress_label) - - # ========================================== - # 卡片 4:输出与执行 - # ========================================== - execute_group = QGroupBox("🚀 输出与执行") - execute_layout = QVBoxLayout() - execute_layout.setSpacing(16) - execute_layout.setContentsMargins(20, 24, 20, 20) - - execute_layout.addWidget(self.progress_bar) - execute_layout.addWidget(self.progress_label) - action_layout = QHBoxLayout() action_layout.addStretch() @@ -309,10 +226,9 @@ class Step10WatercolorPanel(QWidget): self.run_btn.clicked.connect(self._on_run_single_clicked) action_layout.addWidget(self.run_btn) - execute_layout.addLayout(action_layout) - execute_group.setLayout(execute_layout) - - layout.addWidget(execute_group) + output_layout.addLayout(action_layout) + output_group.setLayout(output_layout) + layout.addWidget(output_group) layout.addStretch() self.setLayout(layout) @@ -321,22 +237,17 @@ class Step10WatercolorPanel(QWidget): """强化版对齐函数:让单位的占位和浏览按钮一样宽,完美锁死输入框长度""" row_layout = QHBoxLayout() row_layout.setContentsMargins(0, 0, 0, 0) - row_layout.setSpacing(6) # 严格匹配 FileSelectWidget 内部的控件间距 + row_layout.setSpacing(6) - # 1. 统一左侧标签宽度为 120px lbl = QLabel(label_text) lbl.setMinimumWidth(120) lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) row_layout.addWidget(lbl) - # 2. 允许控件水平拉伸 widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) widget.setStyleSheet("min-height: 28px;") row_layout.addWidget(widget) - # 3. 核心秘密:"浏览..."按钮的默认宽度大约是 75px。 - # 我们把单位标签的宽度锁死在 75px,并用 padding 制造出漂亮的空格! - # 这样它就会完美模拟上方 "浏览" 按钮的存在感,让所有输入框的长短一刀切般整齐! suffix_lbl = QLabel(suffix_text) suffix_lbl.setFixedWidth(75) suffix_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) @@ -470,37 +381,52 @@ class Step10WatercolorPanel(QWidget): self._current_type_filter = "all" self._refresh_visibility() + self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) + # 眼见为实:可见的打勾,隐藏的全部强制取消打勾 if not item.isHidden(): item.setCheckState(Qt.Checked) + else: + item.setCheckState(Qt.Unchecked) + self.formula_list.blockSignals(False) self._update_formula_count() def _deselect_all(self): + self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) - if not item.isHidden(): - item.setCheckState(Qt.Unchecked) + # 简单粗暴:无论可见与否,全部取消勾选 + item.setCheckState(Qt.Unchecked) + self.formula_list.blockSignals(False) self._update_formula_count() def _select_ratio(self): self._current_type_filter = "ratio" self._refresh_visibility() + self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) if not item.isHidden(): item.setCheckState(Qt.Checked) + else: + item.setCheckState(Qt.Unchecked) # 强制干掉隐藏的浓度型幽灵 + self.formula_list.blockSignals(False) self._update_formula_count() def _select_conc(self): self._current_type_filter = "concentration" self._refresh_visibility() + self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) if not item.isHidden(): item.setCheckState(Qt.Checked) + else: + item.setCheckState(Qt.Unchecked) # 强制干掉隐藏的比值型幽灵 + self.formula_list.blockSignals(False) self._update_formula_count() def _on_selection_changed(self): @@ -532,9 +458,11 @@ class Step10WatercolorPanel(QWidget): def get_config(self) -> dict: sampling = self.sampling_csv_file.get_path().strip() - config: Dict[str, object] = { + config = { 'sampling_csv_path': sampling, 'selected_formulas': self._get_selected_formula_names(), + 'wavelength_offset': self.wavelength_offset_spin.value(), + 'waterindex_csv': self.formula_file.get_path().strip() } out_dir = self.output_dir.get_path().strip() if out_dir: @@ -546,12 +474,24 @@ class Step10WatercolorPanel(QWidget): self.sampling_csv_file.set_path(config['sampling_csv_path']) if config.get('output_dir'): self.output_dir.set_path(config['output_dir']) + if config.get('waterindex_csv'): + self.formula_file.set_path(config['waterindex_csv']) + if 'selected_formulas' in config: names = set(config['selected_formulas']) + self.formula_list.blockSignals(True) for i in range(self.formula_list.count()): item = self.formula_list.item(i) name = item.data(Qt.UserRole) item.setCheckState(Qt.Checked if name in names else Qt.Unchecked) + self.formula_list.blockSignals(False) + self._update_formula_count() + + if 'wavelength_offset' in config: + try: + self.wavelength_offset_spin.setValue(float(config['wavelength_offset'])) + except (ValueError, TypeError): + pass def update_from_config(self, work_dir=None, pipeline=None): if work_dir: @@ -561,6 +501,7 @@ class Step10WatercolorPanel(QWidget): else: self.work_dir = None + # 数据链条自动流转:优先从上游布设步骤获取散点数据 sampling_path = None if pipeline and hasattr(pipeline, 'step_outputs'): step4_out = pipeline.step_outputs.get('step4_sampling', {}) @@ -618,85 +559,6 @@ class Step10WatercolorPanel(QWidget): 'config': config, }) - def run_step(self): - sampling_csv_path = self.sampling_csv_file.get_path().strip() - if not sampling_csv_path: - QMessageBox.warning(self, "输入错误", "请选择采样点 CSV!") - return - if not Path(sampling_csv_path).exists(): - QMessageBox.warning( - self, "输入错误", f"采样点 CSV 不存在:\n{sampling_csv_path}" - ) - return - - output_dir = self.output_dir.get_path().strip() - if not output_dir: - work_dir = self._get_default_work_dir() - output_dir = os.path.join(work_dir, '10_WaterIndex_CSV') - os.makedirs(output_dir, exist_ok=True) - self.output_dir.set_path(output_dir) - - selected = self._get_selected_formula_names() - if not selected: - QMessageBox.warning(self, "输入错误", "请至少选择一个公式!") - return - - if self._waterindex_csv and not Path(self._waterindex_csv).exists(): - QMessageBox.warning( - self, "配置错误", - f"waterindex.csv 不存在:\n{self._waterindex_csv}", - ) - return - - work_dir = self.work_dir or str(Path(sampling_csv_path).parent) - - self.run_btn.setEnabled(False) - self.progress_bar.setValue(0) - self.progress_label.setText("") - - self._worker = WaterIndexWorker( - sampling_csv_path=sampling_csv_path, - output_dir=output_dir, - selected_formulas=selected, - waterindex_csv=self._waterindex_csv, - work_dir=work_dir, - wavelength_offset=self.wavelength_offset_spin.value(), - ) - self._worker.progress.connect(self._on_progress) - self._worker.finished_ok.connect(self._on_finished) - self._worker.error.connect(self._on_error) - self._worker.start() - - def _on_progress(self, msg: str, pct: float): - self.progress_bar.setValue(int(pct)) - self.progress_label.setText(msg) - - def _on_finished(self, results: Dict[str, str]): - self.run_btn.setEnabled(True) - n = len(results) - names = list(results.keys())[:3] - tail = " …" if n > 3 else "" - QMessageBox.information( - self, "执行成功", - f"水色指数反演完成!\n" - f"共生成 {n} 个指数 CSV(含 longitude / latitude / 公式值三列)。\n" - f"前几个: {', '.join(names)}{tail}\n\n" - f"输出目录: {self.output_dir.get_path()}" - ) - main_window = self.window() - if main_window and hasattr(main_window, 'log_message'): - main_window.log_message( - f"步骤10:水色指数反演完成,生成 {n} 个指数 CSV", "info" - ) - - def _on_error(self, err: str): - self.run_btn.setEnabled(True) - self.progress_bar.setValue(0) - self.progress_label.setText("执行失败") - QMessageBox.critical( - self, "执行错误", f"水色指数反演失败:\n\n{err[:500]}" - ) - def get_output_dir(self) -> str: return self.output_dir.get_path().strip() or "" diff --git a/src/gui/panels/step11_map_panel.py b/src/gui/panels/step11_map_panel.py index 5a5c0ba..e04839e 100644 --- a/src/gui/panels/step11_map_panel.py +++ b/src/gui/panels/step11_map_panel.py @@ -296,7 +296,7 @@ class Step11MapPanel(QWidget): execute_layout.setContentsMargins(20, 24, 20, 20) self.output_dir = FileSelectWidget("输出分布图目录:", "Directories;;All Files (*.*)") - apply_fs_style(self.output_dir, "留空→工作目录/14_visualization") + apply_fs_style(self.output_dir, "留空→工作目录/11_Thematic_Map") self.output_dir.browse_btn.clicked.disconnect() self.output_dir.browse_btn.clicked.connect(self.browse_output_dir) execute_layout.addWidget(self.output_dir) @@ -323,7 +323,8 @@ class Step11MapPanel(QWidget): layout.addStretch() self.setLayout(layout) - self.batch_mode_combo.setCurrentIndex(0) + # ★ 默认使用"文件夹批量"模式(最常见的工作流场景) + self.batch_mode_combo.setCurrentIndex(1) self._toggle_input_mode() def _toggle_input_mode(self): @@ -473,23 +474,49 @@ class Step11MapPanel(QWidget): if work_dir: self.work_dir = work_dir - # 1. 预测 CSV 目录:文件系统扫描 + # ── 智能自动路由:预测 CSV 目录 ── if self.work_dir: - pred_dir = scan_work_dir_for_input(self.work_dir, 'ml_predictions_dir') - if pred_dir and os.path.isdir(str(pred_dir)): - self.prediction_csv_dir_edit.setText(str(pred_dir)) - self.batch_mode_combo.setCurrentIndex(1) + wd = self.work_dir + done = False - # 2. 边界文件(水体掩膜):文件系统扫描 + # Priority 1: Step 9 ML Prediction 输出目录 + for cand_dir_key in ('ml_prediction', 'prediction_dir'): + pred_dir = resolve_subdir(wd, cand_dir_key) + if pred_dir and os.path.isdir(pred_dir): + csvs = list(Path(pred_dir).glob("*.csv")) + if csvs: + self.prediction_csv_dir_edit.setText(pred_dir) + self.batch_mode_combo.setCurrentIndex(1) + done = True + break + + # Priority 2 (Fallback): Step 10 水色指数输出目录 + if not done: + for cand_dir_key in ('watercolor', 'step10_watercolor'): + wc_dir = resolve_subdir(wd, cand_dir_key) + if wc_dir and os.path.isdir(wc_dir): + csvs = list(Path(wc_dir).glob("*.csv")) + if csvs: + self.prediction_csv_dir_edit.setText(wc_dir) + self.batch_mode_combo.setCurrentIndex(1) + done = True + break + + # GeoTIFF 目录:指向 step10 水色指数输出 + geotiff_dir = resolve_subdir(wd, 'watercolor') + if geotiff_dir and os.path.isdir(geotiff_dir) and not self.geotiff_dir_edit.text().strip(): + self.geotiff_dir_edit.setText(geotiff_dir) + + # ── 边界文件(水体掩膜):文件系统扫描 ── if self.work_dir: boundary_path = scan_work_dir_for_input(self.work_dir, 'water_mask') existing = self.boundary_file.get_path() if boundary_path and not existing and os.path.exists(str(boundary_path)): self.boundary_file.set_path(str(boundary_path)) - # 3. 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录) + # ── 生成第 11 步的输出目录(仅在为空时填入默认路径,不创建目录)── if hasattr(self, 'work_dir') and self.work_dir and not self.output_dir.get_path(): - out_dir = os.path.join(self.work_dir, "14_visualization").replace('\\', '/') + out_dir = os.path.join(self.work_dir, "11_Thematic_Map").replace('\\', '/') self.output_dir.set_path(out_dir) def browse_output_dir(self): diff --git a/src/gui/panels/step12_viz_panel.py b/src/gui/panels/step12_viz_panel.py index 52e4558..f35aeb8 100644 --- a/src/gui/panels/step12_viz_panel.py +++ b/src/gui/panels/step12_viz_panel.py @@ -694,19 +694,98 @@ class ImageCategoryTree(QTreeWidget): elif "GLINT" in name_upper or "MASK" in name_upper or "PREVIEW" in name_upper: chart_type = "掩膜与预览" - # 2. 提取参数名 + # 2. 提取参数名(扩展版:覆盖 12 项常见水质参数前缀) param_name = "综合/未分类" - # 常见水质参数字典映射 + # ★ 匹配规则:短关键字(≤3 字符)必须在词边界(开头或 _ - . 之后)出现, + # 杜绝 "TT" 命中 "scaTTer"、"CL" 命中 "ChL" 等误匹配。 + # 长关键字(≥4 字符)保持简单子串匹配,兼容历史行为。 + import re as _re + + def _match_key(name: str, key: str) -> bool: + clean = key.replace('-', '') + # 长关键字(≥4):简单子串匹配,历史行为一致 + if len(key) >= 4 or len(clean) >= 4: + if key in name: + return True + if clean != key and clean in name: + return True + return False + # 短关键字(≤3):必须出现在词边界(行首或 _ - . 之后)。 + # 若 key 本身以 _ - . 结尾,则不要求后缀边界(_ 已是分隔符)。 + suffix = r'' if key[-1] in '_.-' else r'(?![a-zA-Z0-9])' + pattern = _re.compile( + r'(?:^|[_.\-])' + _re.escape(key) + suffix + ) + if pattern.search(name): + return True + if clean != key: + clean_suffix = r'' if clean[-1] in '_.-' else r'(?![a-zA-Z0-9])' + pattern2 = _re.compile( + r'(?:^|[_.\-])' + _re.escape(clean) + clean_suffix + ) + if pattern2.search(name): + return True + return False + params_map = { - 'CHLOROPHYLL': 'Chlorophyll (叶绿素)', 'CHL_A': 'Chlorophyll (叶绿素)', 'CHLA': 'Chlorophyll (叶绿素)', - 'COD': 'COD (化学需氧量)', 'DO': 'DO (溶解氧)', 'PH': 'pH', - 'TEMPERATURE': 'Temperature (温度)', 'SPCOND': 'spCond (电导率)', - 'TURBIDITY': 'Turbidity (浊度)', 'TDS': 'TDS (总溶解固体)', - 'CL-': 'Cl- (氯离子)', 'NO3-N': 'NO3-N (硝态氮)', 'NH3-N': 'NH3-N (氨氮)', - 'BGA': 'BGA (蓝绿藻)', 'TT': 'TT (透明度)' + # ── 叶绿素 a ── + 'CHLOROPHYLL': '叶绿素a (Chl-a)', + 'CHL-A': '叶绿素a (Chl-a)', + 'CHL_A': '叶绿素a (Chl-a)', + 'CHLA': '叶绿素a (Chl-a)', + 'CHL_CONC': '叶绿素a (Chl-a)', + 'CHL_': '叶绿素a (Chl-a)', + # ── 总悬浮物 ── + 'SUSPENDED': '总悬浮物 (TSM)', + 'TSM_CONC': '总悬浮物 (TSM)', + 'TSM_': '总悬浮物 (TSM)', + 'TSM': '总悬浮物 (TSM)', + # ── 藻蓝蛋白 ── + 'PHYCOCYANIN': '藻蓝蛋白 (PC)', + 'PHYCO': '藻蓝蛋白 (PC)', + 'BGA': '藻蓝蛋白 (PC)', + 'PC_CONC': '藻蓝蛋白 (PC)', + 'PC_': '藻蓝蛋白 (PC)', + # ── 浊度 ── + 'TURBIDITY': '浊度 (Turbidity)', + 'TURB_CONC': '浊度 (Turbidity)', + 'TURB_': '浊度 (Turbidity)', + # ── 有色可溶性有机物 ── + 'CDOM': '有色可溶性有机物 (CDOM)', + # ── 透明度 ── + 'SECCHI': '透明度 (SDD)', + 'SDD_CONC': '透明度 (SDD)', + 'SDD_': '透明度 (SDD)', + 'SDD': '透明度 (SDD)', + # ── 氮磷类 ── + 'NITROGEN': '总氮 (TN)', + 'TN_CONC': '总氮 (TN)', + 'TN_': '总氮 (TN)', + 'PHOSPHORUS': '总磷 (TP)', + 'TP_CONC': '总磷 (TP)', + 'TP_': '总磷 (TP)', + 'NH3-N': '氨氮 (NH3-N)', + 'NH3_CONC': '氨氮 (NH3-N)', + 'NH3_': '氨氮 (NH3-N)', + 'NH3N': '氨氮 (NH3-N)', + 'NO3-N': '硝态氮 (NO3-N)', + 'NO3N': '硝态氮 (NO3-N)', + # ── 其他需氧量与离子 ── + 'COD': '化学需氧量 (COD)', + 'DISSOLVED_OXYGEN': '溶解氧 (DO)', + 'DO_CONC': '溶解氧 (DO)', + 'DO_': '溶解氧 (DO)', + 'CL-': '氯离子 (Cl-)', + 'CL_': '氯离子 (Cl-)', + # ── 基础物理与特征指标 ── + 'PH': '酸碱度 (pH)', + 'TEMPERATURE': '温度 (Temperature)', + 'SPCOND': '电导率 (spCond)', + 'TDS': '总溶解固体 (TDS)', + 'TT': '特征指标 (TT)', } for key, display_name in params_map.items(): - if key in name_upper or key.replace('-', '') in name_upper: + if _match_key(name_upper, key): param_name = display_name break @@ -1239,7 +1318,7 @@ class Step12VizPanel(QWidget): self, "成功", f"掩膜和耀斑缩略图生成完成,共 {cnt} 个预览图。\n" - f"保存位置: 14_visualization/glint_deglint_previews/", + f"保存位置: 12_visualization/glint_deglint_previews/", ) else: QMessageBox.warning( @@ -1254,7 +1333,7 @@ class Step12VizPanel(QWidget): "成功", "采样点地图生成完成。\n" f"输出: {Path(map_path).name if map_path else ''}\n" - "路径: 14_visualization/sampling_maps/", + "路径: 12_visualization/sampling_maps/", ) if map_path: self.show_chart_viewer(map_path, "采样点分布图") @@ -1265,7 +1344,7 @@ class Step12VizPanel(QWidget): errs = payload.get("errors") or [] msg = ( f"已为 {len(ok_paths)} 个水质参数生成光谱对比图。\n" - f"保存目录: 工作目录/14_visualization/" + f"保存目录: 工作目录/12_visualization/" ) if errs: msg += f"\n\n以下列未生成或出错 ({len(errs)} 项,详见日志):\n" @@ -1296,7 +1375,7 @@ class Step12VizPanel(QWidget): self, "成功", f"已生成 {len(ok_paths)} 个模型评估散点图。\n" - f"保存位置: 14_visualization/scatter_plots/", + f"保存位置: 12_visualization/scatter_plots/", ) self.show_chart_viewer(ok_paths[0], "模型评估散点图") else: @@ -1313,7 +1392,8 @@ class Step12VizPanel(QWidget): "完成", "批量可视化已执行:\n" + "\n".join(parts) if parts else "(无选中项或已跳过)", ) - self.scan_work_directory() + # ★ 延迟 400ms 再扫描目录,确保后台渲染线程已将图像文件 flush 到磁盘 + QTimer.singleShot(400, self.scan_work_directory) def _on_visualization_worker_fail(self, err: str): QMessageBox.critical(self, "错误", f"可视化任务失败:\n{err[:1200]}") @@ -1556,9 +1636,9 @@ class Step12VizPanel(QWidget): 推断优先级: 1. {work_dir}/9_ML_Prediction(机器学习预测) - 2. {work_dir}/11_12_13_predictions/Non_Empirical_Prediction(普通回归预测) - 3. {work_dir}/13_Custom_Regression/Custom_Regression_Prediction(自定义回归预测) - 4. {work_dir}/14_visualization(可视化目录) + 2. {work_dir}/10_WaterIndex_CSV(水色指数反演) + 3. {work_dir}/11_Thematic_Map(专题分布图) + 4. {work_dir}/12_visualization(可视化目录) 5. {work_dir}(工作目录根) """ try: @@ -1569,13 +1649,12 @@ class Step12VizPanel(QWidget): return work_path = Path(self.work_dir) - pred_dir = Path(resolve_subdir(self.work_dir, 'prediction_dir')) # 按优先级寻找存在的目录 candidates = [ Path(resolve_subdir(self.work_dir, 'ml_prediction')), - pred_dir / "Non_Empirical_Prediction", - Path(resolve_subdir(self.work_dir, 'custom_regression')) / "Custom_Regression_Prediction", + Path(resolve_subdir(self.work_dir, 'watercolor')), + Path(resolve_subdir(self.work_dir, 'step11_map')), Path(resolve_subdir(self.work_dir, 'visualization')), work_path, ] @@ -1657,12 +1736,11 @@ class Step12VizPanel(QWidget): 目录创建统一留给各 pipeline 步骤在实际执行时处理。 """ try: - base_prediction_dir = Path(resolve_subdir(str(work_path), 'prediction_dir')) ml_dir = Path(resolve_subdir(str(work_path), 'ml_prediction')) - reg_dir = base_prediction_dir / "Regression_Model_Prediction" - custom_dir = Path(resolve_subdir(str(work_path), 'custom_regression')) / "Custom_Regression_Prediction" + watercolor_dir = Path(resolve_subdir(str(work_path), 'watercolor')) + thematic_dir = Path(resolve_subdir(str(work_path), 'step11_map')) # 仅输出信息,不创建目录 - existing = [str(d) for d in (ml_dir, reg_dir, custom_dir) if d.is_dir()] + existing = [str(d) for d in (ml_dir, watercolor_dir, thematic_dir) if d.is_dir()] if existing: print(f"预测输出目录已存在: {existing}") except Exception as e: diff --git a/src/gui/panels/step13_report_panel.py b/src/gui/panels/step13_report_panel.py index 687008b..4108f7b 100644 --- a/src/gui/panels/step13_report_panel.py +++ b/src/gui/panels/step13_report_panel.py @@ -123,7 +123,7 @@ class Step13ReportPanel(QWidget): return row intro = QLabel( - "💡 提示:根据工作目录下的可视化结果(14_visualization 等)自动生成 Word 分析报告。\n" + "💡 提示:根据工作目录下的可视化结果(12_visualization 等)自动生成 Word 分析报告。\n" "需已存在可视化图表;AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。" ) intro.setWordWrap(True) @@ -149,14 +149,14 @@ class Step13ReportPanel(QWidget): # 工作目录 (只读) self.work_dir_edit = QLineEdit() - self.work_dir_edit.setPlaceholderText("流程工作目录(含 14_visualization)…") + self.work_dir_edit.setPlaceholderText("流程工作目录(含 12_visualization)…") self.work_dir_edit.setReadOnly(True) self.work_dir_edit.setStyleSheet(common_lineedit_css) path_layout.addLayout(create_standard_row("工作目录:", self.work_dir_edit)) # 报告输出目录 self.output_dir_edit = QLineEdit() - self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization") + self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/12_visualization") self.output_dir_edit.setStyleSheet(common_lineedit_css) out_browse = QPushButton("浏览...") @@ -373,7 +373,7 @@ class Step13ReportPanel(QWidget): if not wd or not os.path.isdir(wd): QMessageBox.warning(self, "提示", "请选择有效的工作目录。") return - viz = Path(wd) / "14_visualization" + viz = Path(wd) / "12_visualization" if not viz.is_dir(): QMessageBox.warning( self, diff --git a/src/gui/panels/step4_sampling_panel.py b/src/gui/panels/step4_sampling_panel.py index ca63a31..7bf5270 100644 --- a/src/gui/panels/step4_sampling_panel.py +++ b/src/gui/panels/step4_sampling_panel.py @@ -60,6 +60,7 @@ class Step4SamplingPanel(QWidget): self._cid_hover = None self._cid_click = None self._last_render_path = None + self._last_render_mtime = None self.init_ui() @@ -447,7 +448,10 @@ class Step4SamplingPanel(QWidget): def _check_csv_exists(self): csv_path = self.output_file.get_path() - enabled = bool(csv_path and os.path.isabs(csv_path) and os.path.exists(csv_path)) + # ★ 修复:不再强制要求 os.path.isabs —— resolve_subdir 产出的路径 + # 在 Windows 上用正斜杠时仍能通过 os.path.isfile 正确判定。 + # 只要文件真实存在就启用刷新按钮。 + enabled = bool(csv_path and os.path.isfile(csv_path)) self.refresh_btn.setEnabled(enabled) return enabled @@ -457,8 +461,18 @@ class Step4SamplingPanel(QWidget): def _check_csv_and_auto_render(self): csv_path = self.output_file.get_path() if csv_path and os.path.isfile(csv_path): - if csv_path != self._last_render_path: + # ★ 修复:不仅检测路径变化,也检测文件修改时间变化, + # 确保步骤执行中 CSV 增量写入后自动触发重渲染。 + try: + mtime = os.path.getmtime(csv_path) + except OSError: + mtime = None + path_changed = (csv_path != self._last_render_path) + mtime_changed = (mtime is not None and mtime != self._last_render_mtime) + + if path_changed or mtime_changed: self.refresh_btn.setEnabled(True) + self._last_render_mtime = mtime self._render_inline_plot() def _on_refresh_clicked(self): @@ -548,6 +562,10 @@ class Step4SamplingPanel(QWidget): self._band_cols = band_cols self._highlight_idx = None self._last_render_path = csv_path + try: + self._last_render_mtime = os.path.getmtime(csv_path) + except OSError: + self._last_render_mtime = None if self._annot is not None: try: @@ -611,6 +629,7 @@ class Step4SamplingPanel(QWidget): self._canvas.draw_idle() self._df = None self._last_render_path = None + self._last_render_mtime = None # ═══════════════════════════════════════════════════════════════ # 交互事件 (Hover & Click) diff --git a/src/gui/panels/step7_inversion_panel.py b/src/gui/panels/step7_inversion_panel.py index e74b74b..e92ff3a 100644 --- a/src/gui/panels/step7_inversion_panel.py +++ b/src/gui/panels/step7_inversion_panel.py @@ -19,8 +19,6 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor -from src.gui.core.event_bus import global_event_bus - from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet @@ -585,6 +583,8 @@ class Step7InversionPanel(QWidget): QMessageBox.warning(self, "输入错误", "请至少勾选一种待计算的水质指数!") return + from src.gui.core.event_bus import global_event_bus + config = self.get_config() payload = { 'step_name': 'step7_index', diff --git a/src/gui/panels/step8_ml_train_panel.py b/src/gui/panels/step8_ml_train_panel.py index d166160..5426b60 100644 --- a/src/gui/panels/step8_ml_train_panel.py +++ b/src/gui/panels/step8_ml_train_panel.py @@ -494,7 +494,7 @@ class Step8MlTrainPanel(QWidget): # 2. 自动填充输出目录(仅在为空时填入默认路径,不创建目录) if self.work_dir and not self.output_path.get_path(): - models_dir = os.path.join(self.work_dir, "8_Machine_Learning_Models").replace('\\', '/') + models_dir = os.path.join(self.work_dir, "8_Supervised_Model_Training").replace('\\', '/') self.output_path.set_path(models_dir) elif not self.work_dir: self.output_path.set_path("") diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index fb6adfc..533e2f3 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -509,6 +509,11 @@ class WaterQualityGUI(QMainWindow): if not self._tab_widget.isTabEnabled(tab_index): self._log_manager.info(f"检测到 {item_data} 处于异常锁定状态,已执行强制解锁。") self._tab_widget.setTabEnabled(tab_index, True) + QMessageBox.warning( + self, "页面已解锁", + f"步骤「{item_data}」之前被异常锁定,已自动强制解锁。\n" + "如果当前有流程正在运行,请勿修改此页面的参数。" + ) # 【新增修复】:在跳之前,再核实一遍要去的 tab_index 和 item_data 的 step_id 是否吻合! # 如果因为删除了死文件导致索引错位,这里强行用真实 step_id 再找一遍! diff --git a/src/postprocessing/report_word.py b/src/postprocessing/report_word.py index 4f7ec47..c733e3f 100644 --- a/src/postprocessing/report_word.py +++ b/src/postprocessing/report_word.py @@ -1,335 +1,335 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -水质参数反演分析 Word 报告生成模块 -根据 visualization_reports.py 生成的图片,自动生成结构化 Word 报告 -""" - -import os -import sys -import base64 -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Optional, Any -from datetime import datetime -from urllib.request import Request, urlopen -from urllib.error import URLError, HTTPError -from docx import Document -from docx.shared import Inches, Pt, Cm -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.enum.section import WD_SECTION -from docx.oxml.ns import qn - - -def get_resource_path(relative_path: str) -> str: - """获取资源的绝对路径,适配 PyInstaller 打包环境。""" - if hasattr(sys, '_MEIPASS'): - return os.path.join(sys._MEIPASS, relative_path) - return os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path) - ) -from docx.oxml import OxmlElement -from docx.shared import RGBColor -import pandas as pd - -# === 新增并发与锁 === -from concurrent.futures import ThreadPoolExecutor, as_completed -from threading import Lock - - -class _SimpleProgress: - """无依赖进度条(控制台单行刷新,支持 Qt 回调上递)。""" - - def __init__(self, total: int, desc: str = "", on_step=None): - self.total = max(1, int(total)) - self.desc = desc - self.on_step = on_step - self.n = 0 - self._render() - - def update(self, step: int = 1): - self.n = min(self.total, self.n + int(step)) - self._render() - - def set_description(self, text: str): - """动态更新进度描述文案(用于按段切换分析对象)。""" - self.desc = text - - def close(self): - # 换行,避免覆盖后续输出 - print() - - def _render(self): - pct = int(self.n / self.total * 100) - bar_len = 30 - filled = int(bar_len * self.n / self.total) - bar = "█" * filled + "·" * (bar_len - filled) - prefix = f"{self.desc} " if self.desc else "" - print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True) - if self.on_step: - try: - self.on_step(pct, self.desc) - except Exception: - pass - - -@dataclass -class ReportGenerationConfig: - """ - 报告生成与 AI 分析的可选配置。 - 支持 Ollama 和 Minimax 两种后端,通过 AI_PROVIDER 环境变量切换。 - 未设置的字段沿用环境变量或生成器默认值。 - """ - # 通用 - ai_provider: Optional[str] = None # "ollama" | "minimax",默认 "minimax" - enable_ai_analysis: Optional[bool] = None - # Ollama 专属 - ollama_base_url: Optional[str] = None - ollama_vision_model: Optional[str] = None - ollama_text_model: Optional[str] = None - ollama_timeout_s: Optional[int] = None - # Minimax 专属 - minimax_api_key: Optional[str] = None - minimax_base_url: Optional[str] = None # <--- 新增这行 - minimax_vision_model: Optional[str] = None - minimax_text_model: Optional[str] = None - minimax_timeout_s: Optional[int] = None - - -class WaterQualityReportGenerator: - """水质参数 Word 报告生成器""" - - def __init__( - self, - output_dir: str = None, - work_dir: str = None, - ai_config: Optional[ReportGenerationConfig] = None, - ): - # 设置工作目录(整个流程的核心目录,所有数据基于此) - if work_dir is None: - self.work_dir = Path("./work_dir") - else: - self.work_dir = Path(work_dir) - - # 基于工作目录设置各子目录 - self.visualization_dir = self.work_dir / "14_visualization" - - # 设置报告保存位置:默认为可视化目录(visualization_dir) - self._output_dir_is_default = output_dir is None - if output_dir is None: - self.output_dir = self.visualization_dir - else: - self.output_dir = Path(output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - - # 设置中文字体支持 - self.chinese_font = 'SimSun' # 宋体 - self.title_font = 'SimHei' # 黑体 - self.english_font = 'Times New Roman' # 英文 - - cfg = ai_config - # AI Provider 选择:默认 "minimax" - self.ai_provider = ( - cfg.ai_provider - if cfg and cfg.ai_provider - else os.environ.get("AI_PROVIDER", "minimax").lower() - ) - - # Ollama 配置 - default_url = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") - self.ollama_base_url = ( - cfg.ollama_base_url.rstrip("/") - if cfg and cfg.ollama_base_url - else default_url - ) - self.ollama_vision_model = ( - cfg.ollama_vision_model - if cfg and cfg.ollama_vision_model - else os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b") - ) - self.ollama_text_model = ( - cfg.ollama_text_model - if cfg and cfg.ollama_text_model - else os.environ.get("OLLAMA_TEXT_MODEL", self.ollama_vision_model) - ) - self.ollama_timeout_s = ( - int(cfg.ollama_timeout_s) - if cfg and cfg.ollama_timeout_s is not None - else int(os.environ.get("OLLAMA_TIMEOUT_S", "120")) - ) - - # Minimax 配置 - self.minimax_api_key = ( - cfg.minimax_api_key - if cfg and cfg.minimax_api_key - else os.environ.get("MINIMAX_API_KEY", "") - ) - # 接收外部传入的万能 URL,默认给一个国际标准的 completions 端点 - self.minimax_base_url = ( - cfg.minimax_base_url.rstrip("/") - if cfg and getattr(cfg, 'minimax_base_url', None) - else os.environ.get("MINIMAX_BASE_URL", "https://api.openai.com/v1/chat/completions").rstrip("/") - ) - self.minimax_vision_model = ( - cfg.minimax_vision_model - if cfg and cfg.minimax_vision_model - else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat") - ) - self.minimax_text_model = ( - cfg.minimax_text_model - if cfg and cfg.minimax_text_model - else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat") - ) - self.minimax_timeout_s = ( - int(cfg.minimax_timeout_s) - if cfg and cfg.minimax_timeout_s is not None - else int(os.environ.get("MINIMAX_TIMEOUT_S", "120")) - ) - - # 通用配置 - if cfg and cfg.enable_ai_analysis is not None: - self.enable_ai_analysis = bool(cfg.enable_ai_analysis) - else: - self.enable_ai_analysis = os.environ.get("ENABLE_AI_ANALYSIS", "1") not in { - "0", - "false", - "False", - } - self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" - - # 各参数的专业描述(完整版) - self.parameter_descriptions = { - "Chlorophyll": """叶绿素(Chlorophyll)是浮游植物进行光合作用的关键色素,直接反映水体中藻类的生物量与初级生产力水平。它是评价水体富营养化程度最常用的指标之一。当叶绿素浓度持续升高时,表明藻类大量增殖,水华风险显著增加,并可能引发溶解氧剧烈波动、水体透明度下降及底栖生态系统退化。因此,通过遥感手段反演叶绿素浓度,可为水华预警、水质改善及生态修复提供重要科学依据。""", - - "COD": """化学需氧量(COD)是衡量水体中有机污染物含量的综合指标,反映单位体积水体中还原性物质(主要是有机物)被氧化所消耗的氧化剂总量。COD值越高,表明水体受有机污染越严重。高COD会加剧溶解氧消耗,导致水体缺氧、水生生物死亡,甚至引发黑臭现象。COD也是污水处理效果和污染物排放管控的关键考核指标,其时空分布可为污染源识别与治理提供直接依据。""", - - "DO": """溶解氧(DO)是维持水生生态系统健康的基础物质,指溶解在水中的分子态氧。其浓度受水温、盐度、藻类光合作用及有机物耗氧过程共同调控。DO低于一定阈值会导致水生生物窒息、底泥营养盐释放及水体自净能力下降。DO的实时监测与空间分布反演,对判断水体污染程度、预警鱼类死亡事件及评估生态修复成效具有重要价值。""", - - "PH": """pH值是反映水体酸碱度的无量纲参数,直接影响水中化学形态、微生物活性和水生生物的生理代谢。天然水体pH值一般介于6.5~8.5之间,当pH值过低(酸化)或过高(碱化)时,会破坏水生生态平衡,加速重金属溶出,对鱼类鳃组织及藻类群落造成胁迫。pH的时空变化可用于识别酸性废水排放、藻类暴发过程以及水化学环境的稳定性评估。""", - - "Temperature": """水温(Temperature)是水体物理特性的基本参数,控制着溶解氧饱和度、化学反应速率及生物代谢强度。水温异常升高(如热污染)或昼夜温差剧烈波动,会影响鱼类洄游、藻类生长节律及底泥污染物释放。水温也是水文模型与水质模型的关键输入变量,其卫星遥感反演为大型水体热状况监测提供了高效手段。""", - - "spCond": """电导率(spCond)表征水体传导电流的能力,与溶解性离子总浓度密切相关。它常用于指示水体矿化度、盐度以及受工业废水、生活污水或农业径流污染的程度。电导率的快速变化往往预示着外源污染输入或海水入侵,是水质常规监测中重要的物理参数,其空间分布图可为污染源追踪提供直观线索。""", - - "Turbidity": """浊度(Turbidity)反映水体中悬浮颗粒物(如泥沙、藻类、微生物)对光线的散射程度,是衡量水体透明度的关键指标。浊度升高不仅影响水生植物光合作用,还会为病原微生物提供附着载体,干扰水处理工艺。通过遥感影像反演浊度,可实现大范围、高频次的水体清澈度评价,对饮用水源地保护和河流泥沙输送研究具有重要意义。""", - - "TDS": """总溶解固体(TDS)指水中溶解性无机盐和部分有机物的总质量,与水的适口性、管道腐蚀风险及灌溉适宜性密切相关。TDS过高会导致水味苦涩,并可能伴随有害微量元素积累。在咸潮入侵、工业排放及农业面源污染研究中,TDS是评价水质变化的稳定指标,其反演结果有助于识别淡水咸化区域及制定取水策略。""", - - "Cl-": """氯离子(Cl-)是天然水体中最稳定存在的阴离子之一,其来源包括岩石风化、海水侵入、工业废水及生活污水。氯离子含量升高可指示水体受咸潮或污染输入的影响,且在高浓度下会腐蚀管道、影响农业土壤结构。在饮用水消毒过程中,氯离子与有机物可能生成三氯甲烷等消毒副产物,因此其监测对水厂运行和水安全有重要警示作用。""", - - "NO3-N": """硝酸盐氮(NO3-N)是氮循环中氧化程度最高的形态,易溶于水,常通过农田径流、化粪池渗漏或工业废水进入水体。过量硝酸盐会刺激藻类过度生长,加速水体富营养化;饮用水中硝酸盐氮浓度超标会引发“蓝婴症”(高铁血红蛋白血症),对婴幼儿健康构成威胁。因此,硝酸盐氮是水质评价与饮用水安全监管的重点指标。""", - - "NH3-N": """氨氮(NH3-N)是水体受有机污染初期的重要指示物,主要来源于生活污水、农业化肥及工业含氮废水。氨氮对鱼类等水生生物有较强的毒性,且在好氧条件下会消耗大量溶解氧转化为硝酸盐。氨氮浓度高往往反映近期污染输入或水体自净能力不足,其动态变化可用于预警突发性污染事件和评估生态修复效果。""", - - "BGA": """BGA(蓝绿藻,即蓝藻)是表征水体蓝藻生物量的关键生物参数,通常通过藻蓝蛋白等特征色素反演获得。蓝藻过量繁殖(水华)会释放藻毒素、消耗溶解氧、形成水面覆盖层,严重威胁饮用水安全和水生态系统健康。BGA浓度的空间分布能精准指示水华高发区域与迁移路径,是水华预警、蓝藻治理和生态修复措施制定不可或缺的输入信息。""", - - "TT": """总氮(TT)是水体中有机氮、氨氮、硝酸盐氮、亚硝酸盐氮等各种形态氮的总和,综合反映了水体的氮营养水平。总氮是导致水体富营养化的主要限制因子之一,其浓度过高会引发藻类爆发、透明度下降、水质恶化。总氮的时空变化趋势可用于判断流域面源污染强度、评估氮减排措施成效,是水质管理和流域水环境保护的关键参考指标。""" - } - - # 每个参数对应的图片顺序(统一5张图模式) - params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature", - "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", - "NH3-N", "BGA", "TT"] - self.parameter_images = { - param: [ - f"{param}_histogram.png", - f"{param}_spectrum_comparison.png", - f"{param}_scatter_with_confidence.png", - f"{param}_boxplot.png", - f"{param}_distribution_rendered.png" # 适配新版渲染分布图 - ] for param in params_list - } - - # ========== 新增:缓存线程锁 ========== - self._cache_lock = Lock() - - def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: - """在已创建的生成器上更新 AI 相关设置(下次 _ai_chat 生效)。""" - cfg = ai_config - if cfg.ai_provider: - self.ai_provider = cfg.ai_provider.lower() - if cfg.ollama_base_url: - self.ollama_base_url = cfg.ollama_base_url.rstrip("/") - if cfg.ollama_vision_model: - self.ollama_vision_model = cfg.ollama_vision_model - if cfg.ollama_text_model: - self.ollama_text_model = cfg.ollama_text_model - if cfg.ollama_timeout_s is not None: - self.ollama_timeout_s = int(cfg.ollama_timeout_s) - if cfg.minimax_api_key: - self.minimax_api_key = cfg.minimax_api_key - if cfg.minimax_vision_model: - self.minimax_vision_model = cfg.minimax_vision_model - if cfg.minimax_text_model: - self.minimax_text_model = cfg.minimax_text_model - if cfg.minimax_timeout_s is not None: - self.minimax_timeout_s = int(cfg.minimax_timeout_s) - if cfg.enable_ai_analysis is not None: - self.enable_ai_analysis = bool(cfg.enable_ai_analysis) - - def _style_heading(self, heading, level: int): - """统一一级/二级/三级标题字体(黑体)与字号。""" - size_map = {1: Pt(16), 2: Pt(14), 3: Pt(12)} - for run in heading.runs: - run.font.name = self.title_font - run.font.bold = True - if level in size_map: - run.font.size = size_map[level] - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) - - def _load_ai_cache(self) -> Dict[str, Any]: - if not self.ai_cache_path.exists(): - return {} - try: - with open(self.ai_cache_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - def _save_ai_cache(self, cache: Dict[str, Any]) -> None: - try: - with open(self.ai_cache_path, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=2) - except Exception: - pass - - def _ollama_chat(self, model: str, system_prompt: str, user_prompt: str, image_path: Optional[Path] = None) -> str: - """调用 Ollama /api/chat。image_path 传入时进行视觉分析。""" - payload: Dict[str, Any] = { - "model": model, - "stream": False, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - } - - if image_path is not None: - try: - img_b64 = base64.b64encode(image_path.read_bytes()).decode("utf-8") - payload["messages"][-1]["images"] = [img_b64] - except Exception as e: - return f"(读取图片失败:{e})" - - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = Request( - url=f"{self.ollama_base_url}/api/chat", - data=data, - headers={"Content-Type": "application/json"}, - method="POST", - ) - - try: - with urlopen(req, timeout=self.ollama_timeout_s) as resp: - raw = resp.read().decode("utf-8", errors="ignore") - obj = json.loads(raw) - return (obj.get("message") or {}).get("content", "").strip() or "(模型未返回内容)" - except (HTTPError, URLError, TimeoutError) as e: - return f"(Ollama调用失败:{e})" - except Exception as e: - return f"(Ollama解析失败:{e})" - +# -*- coding: utf-8 -*- +""" +水质参数反演分析 Word 报告生成模块 +根据 visualization_reports.py 生成的图片,自动生成结构化 Word 报告 +""" + +import os +import sys +import base64 +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Any +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError +from docx import Document +from docx.shared import Inches, Pt, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.section import WD_SECTION +from docx.oxml.ns import qn + + +def get_resource_path(relative_path: str) -> str: + """获取资源的绝对路径,适配 PyInstaller 打包环境。""" + if hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path) + ) +from docx.oxml import OxmlElement +from docx.shared import RGBColor +import pandas as pd + +# === 新增并发与锁 === +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Lock + + +class _SimpleProgress: + """无依赖进度条(控制台单行刷新,支持 Qt 回调上递)。""" + + def __init__(self, total: int, desc: str = "", on_step=None): + self.total = max(1, int(total)) + self.desc = desc + self.on_step = on_step + self.n = 0 + self._render() + + def update(self, step: int = 1): + self.n = min(self.total, self.n + int(step)) + self._render() + + def set_description(self, text: str): + """动态更新进度描述文案(用于按段切换分析对象)。""" + self.desc = text + + def close(self): + # 换行,避免覆盖后续输出 + print() + + def _render(self): + pct = int(self.n / self.total * 100) + bar_len = 30 + filled = int(bar_len * self.n / self.total) + bar = "█" * filled + "·" * (bar_len - filled) + prefix = f"{self.desc} " if self.desc else "" + print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True) + if self.on_step: + try: + self.on_step(pct, self.desc) + except Exception: + pass + + +@dataclass +class ReportGenerationConfig: + """ + 报告生成与 AI 分析的可选配置。 + 支持 Ollama 和 Minimax 两种后端,通过 AI_PROVIDER 环境变量切换。 + 未设置的字段沿用环境变量或生成器默认值。 + """ + # 通用 + ai_provider: Optional[str] = None # "ollama" | "minimax",默认 "minimax" + enable_ai_analysis: Optional[bool] = None + # Ollama 专属 + ollama_base_url: Optional[str] = None + ollama_vision_model: Optional[str] = None + ollama_text_model: Optional[str] = None + ollama_timeout_s: Optional[int] = None + # Minimax 专属 + minimax_api_key: Optional[str] = None + minimax_base_url: Optional[str] = None # <--- 新增这行 + minimax_vision_model: Optional[str] = None + minimax_text_model: Optional[str] = None + minimax_timeout_s: Optional[int] = None + + +class WaterQualityReportGenerator: + """水质参数 Word 报告生成器""" + + def __init__( + self, + output_dir: str = None, + work_dir: str = None, + ai_config: Optional[ReportGenerationConfig] = None, + ): + # 设置工作目录(整个流程的核心目录,所有数据基于此) + if work_dir is None: + self.work_dir = Path("./work_dir") + else: + self.work_dir = Path(work_dir) + + # 基于工作目录设置各子目录 + self.visualization_dir = self.work_dir / "12_visualization" + + # 设置报告保存位置:默认为可视化目录(visualization_dir) + self._output_dir_is_default = output_dir is None + if output_dir is None: + self.output_dir = self.visualization_dir + else: + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + # 设置中文字体支持 + self.chinese_font = 'SimSun' # 宋体 + self.title_font = 'SimHei' # 黑体 + self.english_font = 'Times New Roman' # 英文 + + cfg = ai_config + # AI Provider 选择:默认 "minimax" + self.ai_provider = ( + cfg.ai_provider + if cfg and cfg.ai_provider + else os.environ.get("AI_PROVIDER", "minimax").lower() + ) + + # Ollama 配置 + default_url = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") + self.ollama_base_url = ( + cfg.ollama_base_url.rstrip("/") + if cfg and cfg.ollama_base_url + else default_url + ) + self.ollama_vision_model = ( + cfg.ollama_vision_model + if cfg and cfg.ollama_vision_model + else os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b") + ) + self.ollama_text_model = ( + cfg.ollama_text_model + if cfg and cfg.ollama_text_model + else os.environ.get("OLLAMA_TEXT_MODEL", self.ollama_vision_model) + ) + self.ollama_timeout_s = ( + int(cfg.ollama_timeout_s) + if cfg and cfg.ollama_timeout_s is not None + else int(os.environ.get("OLLAMA_TIMEOUT_S", "120")) + ) + + # Minimax 配置 + self.minimax_api_key = ( + cfg.minimax_api_key + if cfg and cfg.minimax_api_key + else os.environ.get("MINIMAX_API_KEY", "") + ) + # 接收外部传入的万能 URL,默认给一个国际标准的 completions 端点 + self.minimax_base_url = ( + cfg.minimax_base_url.rstrip("/") + if cfg and getattr(cfg, 'minimax_base_url', None) + else os.environ.get("MINIMAX_BASE_URL", "https://api.openai.com/v1/chat/completions").rstrip("/") + ) + self.minimax_vision_model = ( + cfg.minimax_vision_model + if cfg and cfg.minimax_vision_model + else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat") + ) + self.minimax_text_model = ( + cfg.minimax_text_model + if cfg and cfg.minimax_text_model + else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat") + ) + self.minimax_timeout_s = ( + int(cfg.minimax_timeout_s) + if cfg and cfg.minimax_timeout_s is not None + else int(os.environ.get("MINIMAX_TIMEOUT_S", "120")) + ) + + # 通用配置 + if cfg and cfg.enable_ai_analysis is not None: + self.enable_ai_analysis = bool(cfg.enable_ai_analysis) + else: + self.enable_ai_analysis = os.environ.get("ENABLE_AI_ANALYSIS", "1") not in { + "0", + "false", + "False", + } + self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" + + # 各参数的专业描述(完整版) + self.parameter_descriptions = { + "Chlorophyll": """叶绿素(Chlorophyll)是浮游植物进行光合作用的关键色素,直接反映水体中藻类的生物量与初级生产力水平。它是评价水体富营养化程度最常用的指标之一。当叶绿素浓度持续升高时,表明藻类大量增殖,水华风险显著增加,并可能引发溶解氧剧烈波动、水体透明度下降及底栖生态系统退化。因此,通过遥感手段反演叶绿素浓度,可为水华预警、水质改善及生态修复提供重要科学依据。""", + + "COD": """化学需氧量(COD)是衡量水体中有机污染物含量的综合指标,反映单位体积水体中还原性物质(主要是有机物)被氧化所消耗的氧化剂总量。COD值越高,表明水体受有机污染越严重。高COD会加剧溶解氧消耗,导致水体缺氧、水生生物死亡,甚至引发黑臭现象。COD也是污水处理效果和污染物排放管控的关键考核指标,其时空分布可为污染源识别与治理提供直接依据。""", + + "DO": """溶解氧(DO)是维持水生生态系统健康的基础物质,指溶解在水中的分子态氧。其浓度受水温、盐度、藻类光合作用及有机物耗氧过程共同调控。DO低于一定阈值会导致水生生物窒息、底泥营养盐释放及水体自净能力下降。DO的实时监测与空间分布反演,对判断水体污染程度、预警鱼类死亡事件及评估生态修复成效具有重要价值。""", + + "PH": """pH值是反映水体酸碱度的无量纲参数,直接影响水中化学形态、微生物活性和水生生物的生理代谢。天然水体pH值一般介于6.5~8.5之间,当pH值过低(酸化)或过高(碱化)时,会破坏水生生态平衡,加速重金属溶出,对鱼类鳃组织及藻类群落造成胁迫。pH的时空变化可用于识别酸性废水排放、藻类暴发过程以及水化学环境的稳定性评估。""", + + "Temperature": """水温(Temperature)是水体物理特性的基本参数,控制着溶解氧饱和度、化学反应速率及生物代谢强度。水温异常升高(如热污染)或昼夜温差剧烈波动,会影响鱼类洄游、藻类生长节律及底泥污染物释放。水温也是水文模型与水质模型的关键输入变量,其卫星遥感反演为大型水体热状况监测提供了高效手段。""", + + "spCond": """电导率(spCond)表征水体传导电流的能力,与溶解性离子总浓度密切相关。它常用于指示水体矿化度、盐度以及受工业废水、生活污水或农业径流污染的程度。电导率的快速变化往往预示着外源污染输入或海水入侵,是水质常规监测中重要的物理参数,其空间分布图可为污染源追踪提供直观线索。""", + + "Turbidity": """浊度(Turbidity)反映水体中悬浮颗粒物(如泥沙、藻类、微生物)对光线的散射程度,是衡量水体透明度的关键指标。浊度升高不仅影响水生植物光合作用,还会为病原微生物提供附着载体,干扰水处理工艺。通过遥感影像反演浊度,可实现大范围、高频次的水体清澈度评价,对饮用水源地保护和河流泥沙输送研究具有重要意义。""", + + "TDS": """总溶解固体(TDS)指水中溶解性无机盐和部分有机物的总质量,与水的适口性、管道腐蚀风险及灌溉适宜性密切相关。TDS过高会导致水味苦涩,并可能伴随有害微量元素积累。在咸潮入侵、工业排放及农业面源污染研究中,TDS是评价水质变化的稳定指标,其反演结果有助于识别淡水咸化区域及制定取水策略。""", + + "Cl-": """氯离子(Cl-)是天然水体中最稳定存在的阴离子之一,其来源包括岩石风化、海水侵入、工业废水及生活污水。氯离子含量升高可指示水体受咸潮或污染输入的影响,且在高浓度下会腐蚀管道、影响农业土壤结构。在饮用水消毒过程中,氯离子与有机物可能生成三氯甲烷等消毒副产物,因此其监测对水厂运行和水安全有重要警示作用。""", + + "NO3-N": """硝酸盐氮(NO3-N)是氮循环中氧化程度最高的形态,易溶于水,常通过农田径流、化粪池渗漏或工业废水进入水体。过量硝酸盐会刺激藻类过度生长,加速水体富营养化;饮用水中硝酸盐氮浓度超标会引发“蓝婴症”(高铁血红蛋白血症),对婴幼儿健康构成威胁。因此,硝酸盐氮是水质评价与饮用水安全监管的重点指标。""", + + "NH3-N": """氨氮(NH3-N)是水体受有机污染初期的重要指示物,主要来源于生活污水、农业化肥及工业含氮废水。氨氮对鱼类等水生生物有较强的毒性,且在好氧条件下会消耗大量溶解氧转化为硝酸盐。氨氮浓度高往往反映近期污染输入或水体自净能力不足,其动态变化可用于预警突发性污染事件和评估生态修复效果。""", + + "BGA": """BGA(蓝绿藻,即蓝藻)是表征水体蓝藻生物量的关键生物参数,通常通过藻蓝蛋白等特征色素反演获得。蓝藻过量繁殖(水华)会释放藻毒素、消耗溶解氧、形成水面覆盖层,严重威胁饮用水安全和水生态系统健康。BGA浓度的空间分布能精准指示水华高发区域与迁移路径,是水华预警、蓝藻治理和生态修复措施制定不可或缺的输入信息。""", + + "TT": """总氮(TT)是水体中有机氮、氨氮、硝酸盐氮、亚硝酸盐氮等各种形态氮的总和,综合反映了水体的氮营养水平。总氮是导致水体富营养化的主要限制因子之一,其浓度过高会引发藻类爆发、透明度下降、水质恶化。总氮的时空变化趋势可用于判断流域面源污染强度、评估氮减排措施成效,是水质管理和流域水环境保护的关键参考指标。""" + } + + # 每个参数对应的图片顺序(统一5张图模式) + params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature", + "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", + "NH3-N", "BGA", "TT"] + self.parameter_images = { + param: [ + f"{param}_histogram.png", + f"{param}_spectrum_comparison.png", + f"{param}_scatter_with_confidence.png", + f"{param}_boxplot.png", + f"{param}_distribution_rendered.png" # 适配新版渲染分布图 + ] for param in params_list + } + + # ========== 新增:缓存线程锁 ========== + self._cache_lock = Lock() + + def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: + """在已创建的生成器上更新 AI 相关设置(下次 _ai_chat 生效)。""" + cfg = ai_config + if cfg.ai_provider: + self.ai_provider = cfg.ai_provider.lower() + if cfg.ollama_base_url: + self.ollama_base_url = cfg.ollama_base_url.rstrip("/") + if cfg.ollama_vision_model: + self.ollama_vision_model = cfg.ollama_vision_model + if cfg.ollama_text_model: + self.ollama_text_model = cfg.ollama_text_model + if cfg.ollama_timeout_s is not None: + self.ollama_timeout_s = int(cfg.ollama_timeout_s) + if cfg.minimax_api_key: + self.minimax_api_key = cfg.minimax_api_key + if cfg.minimax_vision_model: + self.minimax_vision_model = cfg.minimax_vision_model + if cfg.minimax_text_model: + self.minimax_text_model = cfg.minimax_text_model + if cfg.minimax_timeout_s is not None: + self.minimax_timeout_s = int(cfg.minimax_timeout_s) + if cfg.enable_ai_analysis is not None: + self.enable_ai_analysis = bool(cfg.enable_ai_analysis) + + def _style_heading(self, heading, level: int): + """统一一级/二级/三级标题字体(黑体)与字号。""" + size_map = {1: Pt(16), 2: Pt(14), 3: Pt(12)} + for run in heading.runs: + run.font.name = self.title_font + run.font.bold = True + if level in size_map: + run.font.size = size_map[level] + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) + + def _load_ai_cache(self) -> Dict[str, Any]: + if not self.ai_cache_path.exists(): + return {} + try: + with open(self.ai_cache_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + def _save_ai_cache(self, cache: Dict[str, Any]) -> None: + try: + with open(self.ai_cache_path, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _ollama_chat(self, model: str, system_prompt: str, user_prompt: str, image_path: Optional[Path] = None) -> str: + """调用 Ollama /api/chat。image_path 传入时进行视觉分析。""" + payload: Dict[str, Any] = { + "model": model, + "stream": False, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + + if image_path is not None: + try: + img_b64 = base64.b64encode(image_path.read_bytes()).decode("utf-8") + payload["messages"][-1]["images"] = [img_b64] + except Exception as e: + return f"(读取图片失败:{e})" + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = Request( + url=f"{self.ollama_base_url}/api/chat", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urlopen(req, timeout=self.ollama_timeout_s) as resp: + raw = resp.read().decode("utf-8", errors="ignore") + obj = json.loads(raw) + return (obj.get("message") or {}).get("content", "").strip() or "(模型未返回内容)" + except (HTTPError, URLError, TimeoutError) as e: + return f"(Ollama调用失败:{e})" + except Exception as e: + return f"(Ollama解析失败:{e})" + def _call_minimax_text(self, system_prompt: str, user_prompt: str) -> str: """调用 Minimax 文本模型(自动兼容 OpenAI 标准端点)""" if not self.minimax_api_key: @@ -469,187 +469,187 @@ class WaterQualityReportGenerator: return f"(Cloud API Vision 调用失败:{e})" except Exception as e: return f"(Cloud API Vision 解析失败:{e})" - def _ai_chat( - self, - model: str, - system_prompt: str, - user_prompt: str, - image_path: Optional[Path] = None, - ) -> str: - """ - 统一 AI 调用入口。根据 self.ai_provider 路由到不同后端实现。 - model 参数在 ollama 模式下直接使用;在 minimax 模式下忽略(使用类级别配置的模型)。 - """ - if self.ai_provider == "ollama": - return self._ollama_chat(model, system_prompt, user_prompt, image_path) - else: - if image_path is not None: - return self._call_minimax_vision(system_prompt, user_prompt, image_path) - else: - return self._call_minimax_text(system_prompt, user_prompt) - - def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: - """按图片类型返回 system/user 提示词,注入水质遥感专家级约束。""" - system = ( - "你是一位资深的水环境遥感与水生态学专家。现需为一份高光谱水质参数反演报告撰写专业分析。\n" - "【绝对禁忌】:严禁写“看图说话”式的废话(如“曲线先升后降”、“柱子集中在中间”)。\n" - "【核心规范】:\n" - "1. 必须结合【水色光学机理】和【水环境地学意义】进行解释。\n" - "2. 提及波长时,必须解释其对应的物理/生化意义(如叶绿素红光吸收谷、悬浮物散射峰、水体吸收特性等)。\n" - "3. 分析浓度数值时,必须结合自然水体的常规背景值或富营养化状态进行定性评价(如“处于清洁水平”或“存在水华风险”)。\n" - "4. 严格基于图中可见的规律,不编造图中没有的具体坐标或日期。" - ) - - type_specs = { - "histogram": { - "analysis": ( - "分析要点:\n" - f"- 结合自然水体中 {param} 的常规阈值,评估该水域当前的整体水平(清洁、轻度污染或富营养化)。\n" - "- 从生态学角度解释这种数值分布形态(如多峰分布可能暗示存在多个不同性质的污染源或水团交汇)。\n" - "- 关注极端离群值,指出其可能代表的局部异常环境事件。" - ), - "conclusion": "结论应聚焦:该水质参数的整体健康水平评估及主要生态风险提示。", - }, - "spectrum_comparison": { - "analysis": ( - "分析要点:\n" - f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n" - "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n" - "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。" - ), - "conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。", - }, - "scatter_with_confidence": { - "analysis": ( - "分析要点:\n" - "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n" - "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n" - "- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。" - ), - "conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。", - }, - "boxplot": { - "analysis": ( - "分析要点:\n" - "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n" - "- 解释离散程度大(箱体长)可能代表的强烈时空异质性。\n" - "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。" - ), - "conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。", - }, - "distribution": { - "analysis": ( - "分析要点:\n" - f"- 分析 {param} 高值区与低值区的空间异质性特征。\n" - "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n" - "- 结合常见水动力学特征,简述物质可能的输移趋势。" - ), - "conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。", - }, - "correlation_heatmap": { - "analysis": ( - "分析要点:\n" - "- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等。\n" - "- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)。\n" - "- 基于相关性聚类,推断水体中的核心主导污染因子群。" - ), - "conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。", - }, - } - - default_spec = { - "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。", - "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。", - } - - spec = type_specs.get(image_type, default_spec) - - user = ( - f"图号:图{figure_num}\n" - f"当前分析参数:{param}\n" - f"图表类型:{image_type}\n\n" - "【专业要求】:\n" - f"{spec['analysis']}\n\n" - "【输出格式】:\n" - "直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n" - f"【最终落脚点要求】:{spec['conclusion']}\n" - ) - return {"system": system, "user": user} - - - - def _style_figure_caption_simsun_xiaosi(self, paragraph): - """图题格式:宋体、小四(12pt),中英文均设 eastAsia 为宋体。""" - for run in paragraph.runs: - run.font.name = self.chinese_font - run.font.size = Pt(12) - rPr = run._element.get_or_add_rPr() - rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - def _add_image_with_caption(self, doc: Document, image_path: str, caption: str, width=Inches(5.5)): - """ - 统一插入图像并添加图题,确保图像和图题在同一页 - - Args: - doc: Word文档对象 - image_path: 图像文件路径 - caption: 图题文字(如 "图3-1 航线规划") - width: 图像宽度 - """ - try: - # 创建图像段落 - img_paragraph = doc.add_paragraph() - img_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER - # 设置段落不分页,与下一段(图题)保持在一起 - img_paragraph.paragraph_format.keep_with_next = True - img_paragraph.paragraph_format.keep_together = True - img_paragraph.paragraph_format.space_after = Pt(6) # 图像后小间距 - - # 插入图像 - run = img_paragraph.add_run() - run.add_picture(str(image_path), width=width) - - # 创建图题段落(宋体小四) - caption_para = doc.add_paragraph(caption, style='Caption') - caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER - self._style_figure_caption_simsun_xiaosi(caption_para) - # 设置图题段落与上一段(图像)保持在一起 - caption_para.paragraph_format.keep_with_next = False - caption_para.paragraph_format.keep_together = True - caption_para.paragraph_format.space_before = Pt(0) - caption_para.paragraph_format.space_after = Pt(12) - - return True - except Exception as e: - doc.add_paragraph(f"[无法插入图像: {e}]") - return False - - def _add_ai_analysis_paragraph(self, doc: Document, analysis_text: str): - """在 Word 中插入 AI 分析段落(图片后)。""" - # 清理文本:去除段落标记和多余空行 - cleaned_text = analysis_text.strip() - # 去除"第一段:"和"第二段:"标记 - cleaned_text = cleaned_text.replace("第一段:", "").replace("第二段:", "") - # 去除连续多个换行,替换为单个空格 - import re - cleaned_text = re.sub(r'\n+', ' ', cleaned_text) - # 去除连续多个空格,替换为单个空格 - cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip() - - p = doc.add_paragraph() - p.paragraph_format.first_line_indent = Pt(24) - p.paragraph_format.line_spacing = 1.5 - p.paragraph_format.space_after = Pt(12) # 新增:段后间距与正文一致 - run1 = p.add_run() - run1.font.name = self.chinese_font - run1.font.bold = True - run1.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) - run1._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - run2 = p.add_run(analysis_text.strip()) - run2.font.name = self.chinese_font - run2.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) - run2._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - + def _ai_chat( + self, + model: str, + system_prompt: str, + user_prompt: str, + image_path: Optional[Path] = None, + ) -> str: + """ + 统一 AI 调用入口。根据 self.ai_provider 路由到不同后端实现。 + model 参数在 ollama 模式下直接使用;在 minimax 模式下忽略(使用类级别配置的模型)。 + """ + if self.ai_provider == "ollama": + return self._ollama_chat(model, system_prompt, user_prompt, image_path) + else: + if image_path is not None: + return self._call_minimax_vision(system_prompt, user_prompt, image_path) + else: + return self._call_minimax_text(system_prompt, user_prompt) + + def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: + """按图片类型返回 system/user 提示词,注入水质遥感专家级约束。""" + system = ( + "你是一位资深的水环境遥感与水生态学专家。现需为一份高光谱水质参数反演报告撰写专业分析。\n" + "【绝对禁忌】:严禁写“看图说话”式的废话(如“曲线先升后降”、“柱子集中在中间”)。\n" + "【核心规范】:\n" + "1. 必须结合【水色光学机理】和【水环境地学意义】进行解释。\n" + "2. 提及波长时,必须解释其对应的物理/生化意义(如叶绿素红光吸收谷、悬浮物散射峰、水体吸收特性等)。\n" + "3. 分析浓度数值时,必须结合自然水体的常规背景值或富营养化状态进行定性评价(如“处于清洁水平”或“存在水华风险”)。\n" + "4. 严格基于图中可见的规律,不编造图中没有的具体坐标或日期。" + ) + + type_specs = { + "histogram": { + "analysis": ( + "分析要点:\n" + f"- 结合自然水体中 {param} 的常规阈值,评估该水域当前的整体水平(清洁、轻度污染或富营养化)。\n" + "- 从生态学角度解释这种数值分布形态(如多峰分布可能暗示存在多个不同性质的污染源或水团交汇)。\n" + "- 关注极端离群值,指出其可能代表的局部异常环境事件。" + ), + "conclusion": "结论应聚焦:该水质参数的整体健康水平评估及主要生态风险提示。", + }, + "spectrum_comparison": { + "analysis": ( + "分析要点:\n" + f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n" + "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n" + "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。" + ), + "conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。", + }, + "scatter_with_confidence": { + "analysis": ( + "分析要点:\n" + "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n" + "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n" + "- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。" + ), + "conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。", + }, + "boxplot": { + "analysis": ( + "分析要点:\n" + "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n" + "- 解释离散程度大(箱体长)可能代表的强烈时空异质性。\n" + "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。" + ), + "conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。", + }, + "distribution": { + "analysis": ( + "分析要点:\n" + f"- 分析 {param} 高值区与低值区的空间异质性特征。\n" + "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n" + "- 结合常见水动力学特征,简述物质可能的输移趋势。" + ), + "conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。", + }, + "correlation_heatmap": { + "analysis": ( + "分析要点:\n" + "- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等。\n" + "- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)。\n" + "- 基于相关性聚类,推断水体中的核心主导污染因子群。" + ), + "conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。", + }, + } + + default_spec = { + "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。", + "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。", + } + + spec = type_specs.get(image_type, default_spec) + + user = ( + f"图号:图{figure_num}\n" + f"当前分析参数:{param}\n" + f"图表类型:{image_type}\n\n" + "【专业要求】:\n" + f"{spec['analysis']}\n\n" + "【输出格式】:\n" + "直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n" + f"【最终落脚点要求】:{spec['conclusion']}\n" + ) + return {"system": system, "user": user} + + + + def _style_figure_caption_simsun_xiaosi(self, paragraph): + """图题格式:宋体、小四(12pt),中英文均设 eastAsia 为宋体。""" + for run in paragraph.runs: + run.font.name = self.chinese_font + run.font.size = Pt(12) + rPr = run._element.get_or_add_rPr() + rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + def _add_image_with_caption(self, doc: Document, image_path: str, caption: str, width=Inches(5.5)): + """ + 统一插入图像并添加图题,确保图像和图题在同一页 + + Args: + doc: Word文档对象 + image_path: 图像文件路径 + caption: 图题文字(如 "图3-1 航线规划") + width: 图像宽度 + """ + try: + # 创建图像段落 + img_paragraph = doc.add_paragraph() + img_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + # 设置段落不分页,与下一段(图题)保持在一起 + img_paragraph.paragraph_format.keep_with_next = True + img_paragraph.paragraph_format.keep_together = True + img_paragraph.paragraph_format.space_after = Pt(6) # 图像后小间距 + + # 插入图像 + run = img_paragraph.add_run() + run.add_picture(str(image_path), width=width) + + # 创建图题段落(宋体小四) + caption_para = doc.add_paragraph(caption, style='Caption') + caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + self._style_figure_caption_simsun_xiaosi(caption_para) + # 设置图题段落与上一段(图像)保持在一起 + caption_para.paragraph_format.keep_with_next = False + caption_para.paragraph_format.keep_together = True + caption_para.paragraph_format.space_before = Pt(0) + caption_para.paragraph_format.space_after = Pt(12) + + return True + except Exception as e: + doc.add_paragraph(f"[无法插入图像: {e}]") + return False + + def _add_ai_analysis_paragraph(self, doc: Document, analysis_text: str): + """在 Word 中插入 AI 分析段落(图片后)。""" + # 清理文本:去除段落标记和多余空行 + cleaned_text = analysis_text.strip() + # 去除"第一段:"和"第二段:"标记 + cleaned_text = cleaned_text.replace("第一段:", "").replace("第二段:", "") + # 去除连续多个换行,替换为单个空格 + import re + cleaned_text = re.sub(r'\n+', ' ', cleaned_text) + # 去除连续多个空格,替换为单个空格 + cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip() + + p = doc.add_paragraph() + p.paragraph_format.first_line_indent = Pt(24) + p.paragraph_format.line_spacing = 1.5 + p.paragraph_format.space_after = Pt(12) # 新增:段后间距与正文一致 + run1 = p.add_run() + run1.font.name = self.chinese_font + run1.font.bold = True + run1.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) + run1._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + run2 = p.add_run(analysis_text.strip()) + run2.font.name = self.chinese_font + run2.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) + run2._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + def _analyze_and_cache_image(self, image_path: Path, image_type: str, param: str, figure_num: int) -> str: """分析单张图片并缓存,包含线程安全与自动防毒化(重试)机制。""" if not self.enable_ai_analysis: @@ -687,209 +687,209 @@ class WaterQualityReportGenerator: self._save_ai_cache(cache) return text - def _create_progress(self, total: int, desc: str = "进度", on_step=None): - """创建进度条:优先 tqdm,否则使用简单进度条。 - - Args: - on_step: 可选回调,签名 on_step(percent: int, text: str)。用于驱动 Qt QProgressBar / QThread 进度信号。 - """ - # 如果有 UI 回调需求,强制使用自带的 _SimpleProgress,防止 tqdm 吞掉信号 - if on_step is not None: - return _SimpleProgress(total=total, desc=desc, on_step=on_step) - try: - from tqdm import tqdm # type: ignore - return tqdm(total=total, desc=desc, unit="步", ncols=90) - except Exception: - return _SimpleProgress(total=total, desc=desc, on_step=on_step) - - def _analyze_statistics(self, stats_data: List[Dict[str, Any]], param_names: List[str]) -> str: - """对水质参数统计数据进行 AI 分析""" - if not self.enable_ai_analysis: - return "(AI分析已关闭)" - - # 构造统计数据文本 - stats_text = "水质参数统计摘要:\n" - for stat in stats_data: - stats_text += f"- {stat['参数']}: 点位数={stat['点位数']}, 范围=[{stat['最小值']}, {stat['最大值']}], 均值={stat['平均值']}, 标准差={stat['标准差']}\n" - - system = """你是一位水质遥感与统计分析专家。 - 请基于提供的统计数据,给出专业分析: - 1. 识别哪些参数变异程度较高(标准差大) - 2. 识别哪些参数数值范围异常 - 3. 评估数据质量和分布特征 - 4. 禁止编造数据外的信息""" - - user = f"""以下是水质参数的统计数据,请给出100-200字的专业分析: - {stats_text} - - 输出格式:数据特征分析(变异程度、数值范围等)结论与数据质量评估""" - - return self._ai_chat(self.ollama_text_model, system, user, image_path=None) - - - def generate_report(self, - work_dir: str = None, - parameters: List[str] = None, - report_title: str = "水质参数反演分析报告", - output_path: Optional[str] = None, - on_progress=None) -> str: - """ - 生成 Word 报告 - 所有数据均来自工作目录(work_dir) - 可视化图片、统计数据等均从 work_dir/14_visualization 和 work_dir/4_processed_data 中读取 - - Args: - on_progress: 可选回调,签名 on_progress(percent: int, text: str)。 - 会在进度更新时被调用,用于驱动 Qt QProgressBar/QThread 信号。 - """ - # 设置工作目录(整个流程的核心) - if work_dir is not None: - self.work_dir = Path(work_dir) - self.visualization_dir = self.work_dir / "14_visualization" - if getattr(self, "_output_dir_is_default", False): - self.output_dir = self.visualization_dir - self.output_dir.mkdir(parents=True, exist_ok=True) - self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" - - if parameters is None: - parameters = ["Chlorophyll", "COD", "DO", "PH", "Temperature", - "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", - "NH3-N", "BGA", "TT"] - - vis_dir = self.visualization_dir - - if not vis_dir.exists(): - raise FileNotFoundError(f"可视化目录不存在: {vis_dir}") - - if output_path is None: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = self.output_dir / f"水质参数反演分析报告_{timestamp}.docx" - else: - output_path = Path(output_path) - - # 进度条(按“图片处理 + 汇总”计步) - total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters) - total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结 - progress = self._create_progress(total=total_steps, desc="生成Word报告", on_step=on_progress) - - # 创建文档 - doc = Document() - - # 设置页面 - section = doc.sections[0] - section.page_width = Cm(21) - section.page_height = Cm(29.7) - section.left_margin = Cm(2.5) - section.right_margin = Cm(2.5) - section.top_margin = Cm(2.5) - section.bottom_margin = Cm(2.5) - - try: - # 添加封面页 - self._add_cover_page(doc) - self._add_company_description_page(doc) - self._add_data_acquisition_section(doc) - self._add_data_processing_section(doc) - - # 全文图片分析结果收集(用于末尾汇总) - all_image_analyses: List[Dict[str, Any]] = [] - - # 结果分析(含热力图):返回更新后的图号计数 - figure_counter = 1 - figure_counter = self._add_result_analysis_section( - doc, vis_dir, figure_counter, all_image_analyses, progress=progress - ) - - # 物理模型反演浓度统计与分析(第4.1节) - figure_counter = self._add_physical_inversion_section( - doc, self.work_dir, figure_counter, all_image_analyses, progress=progress - ) - - # 设置页眉和页码(从正文开始) - self._setup_header_and_footer(section) - - # 按参数生成内容(带编号):参数章节从 5 开始编号 - base_section_num = 5 - last_param_section_num = base_section_num + len(parameters) - 1 - for section_num, param in enumerate(parameters, base_section_num): - progress.set_description(f"正在分析 {param} 数据 ({section_num - base_section_num + 1}/{len(parameters)})") - figure_counter = self._add_parameter_section( - doc, - param, - vis_dir, - section_num, - figure_counter, - all_image_analyses, - progress=progress, - ) - if section_num != last_param_section_num: - doc.add_page_break() - - # 汇总总结(放在所有图片/参数之后) - doc.add_page_break() - summary_section_num = base_section_num + len(parameters) - summary_heading = doc.add_heading(f"{summary_section_num} 综合分析总结", level=1) - self._style_heading(summary_heading, level=1) - - if self.enable_ai_analysis and all_image_analyses: - analyses_text = "\n\n".join( - [ - f"图{a.get('figure_num')}({a.get('param')} / {a.get('image_type')} / {a.get('image_name')})\n{a.get('analysis')}" - for a in all_image_analyses - ] - ) - system = ( - "你是一位水环境管理决策专家与遥感首席科学家。现需根据前面生成的各参数逐图分析文本,提炼出一份执行摘要级别的综合结论。\n" - "必须具备宏观视角,能够将离散的参数分析整合成对该水域整体健康状况的系统性诊断。" - ) - user = ( - "以下是各个水质参数的详尽逐图分析文本,请基于此撰写一份最终的综合分析总结。\n" - "【内容结构需包含】:\n" - "1. 整体水质评估(如营养状态、主要污染程度)。\n" - "2. 关键时空热点与驱动因子(最需关注的高值区域及核心主导参数)。\n" - "3. 遥感反演模型可靠性综合评价。\n" - "4. 宏观水环境管理与保护建议。\n" - "【⚠️强制要求】:\n" - "- 总结的字数必须严格控制在 300 到 450 字之间!\n" - "- 必须输出完整的结尾标点符号,绝不允许出现话说一半突然截断的情况!高度精炼,切勿啰嗦。\n\n" - f"{analyses_text}" - ) - summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None) - para = doc.add_paragraph(summary_text) - para.paragraph_format.first_line_indent = Pt(24) - para.paragraph_format.line_spacing = 1.5 - for run in para.runs: - run.font.name = self.chinese_font - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - else: - doc.add_paragraph("(未启用AI分析或无可用分析文本,无法生成综合总结。)") - - # 综合总结完成,进度 +1 - try: - progress.update(1) - except Exception: - pass - finally: - try: - progress.close() - except Exception: - pass - - # 保存文档 - doc.save(str(output_path)) - print(f"✅ Word报告生成完成: {output_path}") - - return str(output_path) - - def _add_parameter_section( - self, doc, param: str, vis_dir: Path, param_index: int = 1, - start_figure_num: int = 1, all_image_analyses: Optional[List[Dict[str, Any]]] = None, progress=None, - ): - """为单个参数添加报告章节(支持多线程并发预取 AI 结果)""" - if param not in self.parameter_descriptions: - return start_figure_num - - # 添加带编号的参数标题 + def _create_progress(self, total: int, desc: str = "进度", on_step=None): + """创建进度条:优先 tqdm,否则使用简单进度条。 + + Args: + on_step: 可选回调,签名 on_step(percent: int, text: str)。用于驱动 Qt QProgressBar / QThread 进度信号。 + """ + # 如果有 UI 回调需求,强制使用自带的 _SimpleProgress,防止 tqdm 吞掉信号 + if on_step is not None: + return _SimpleProgress(total=total, desc=desc, on_step=on_step) + try: + from tqdm import tqdm # type: ignore + return tqdm(total=total, desc=desc, unit="步", ncols=90) + except Exception: + return _SimpleProgress(total=total, desc=desc, on_step=on_step) + + def _analyze_statistics(self, stats_data: List[Dict[str, Any]], param_names: List[str]) -> str: + """对水质参数统计数据进行 AI 分析""" + if not self.enable_ai_analysis: + return "(AI分析已关闭)" + + # 构造统计数据文本 + stats_text = "水质参数统计摘要:\n" + for stat in stats_data: + stats_text += f"- {stat['参数']}: 点位数={stat['点位数']}, 范围=[{stat['最小值']}, {stat['最大值']}], 均值={stat['平均值']}, 标准差={stat['标准差']}\n" + + system = """你是一位水质遥感与统计分析专家。 + 请基于提供的统计数据,给出专业分析: + 1. 识别哪些参数变异程度较高(标准差大) + 2. 识别哪些参数数值范围异常 + 3. 评估数据质量和分布特征 + 4. 禁止编造数据外的信息""" + + user = f"""以下是水质参数的统计数据,请给出100-200字的专业分析: + {stats_text} + + 输出格式:数据特征分析(变异程度、数值范围等)结论与数据质量评估""" + + return self._ai_chat(self.ollama_text_model, system, user, image_path=None) + + + def generate_report(self, + work_dir: str = None, + parameters: List[str] = None, + report_title: str = "水质参数反演分析报告", + output_path: Optional[str] = None, + on_progress=None) -> str: + """ + 生成 Word 报告 - 所有数据均来自工作目录(work_dir) + 可视化图片、统计数据等均从 work_dir/12_visualization 和 work_dir/4_processed_data 中读取 + + Args: + on_progress: 可选回调,签名 on_progress(percent: int, text: str)。 + 会在进度更新时被调用,用于驱动 Qt QProgressBar/QThread 信号。 + """ + # 设置工作目录(整个流程的核心) + if work_dir is not None: + self.work_dir = Path(work_dir) + self.visualization_dir = self.work_dir / "12_visualization" + if getattr(self, "_output_dir_is_default", False): + self.output_dir = self.visualization_dir + self.output_dir.mkdir(parents=True, exist_ok=True) + self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" + + if parameters is None: + parameters = ["Chlorophyll", "COD", "DO", "PH", "Temperature", + "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", + "NH3-N", "BGA", "TT"] + + vis_dir = self.visualization_dir + + if not vis_dir.exists(): + raise FileNotFoundError(f"可视化目录不存在: {vis_dir}") + + if output_path is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = self.output_dir / f"水质参数反演分析报告_{timestamp}.docx" + else: + output_path = Path(output_path) + + # 进度条(按“图片处理 + 汇总”计步) + total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters) + total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结 + progress = self._create_progress(total=total_steps, desc="生成Word报告", on_step=on_progress) + + # 创建文档 + doc = Document() + + # 设置页面 + section = doc.sections[0] + section.page_width = Cm(21) + section.page_height = Cm(29.7) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.5) + section.top_margin = Cm(2.5) + section.bottom_margin = Cm(2.5) + + try: + # 添加封面页 + self._add_cover_page(doc) + self._add_company_description_page(doc) + self._add_data_acquisition_section(doc) + self._add_data_processing_section(doc) + + # 全文图片分析结果收集(用于末尾汇总) + all_image_analyses: List[Dict[str, Any]] = [] + + # 结果分析(含热力图):返回更新后的图号计数 + figure_counter = 1 + figure_counter = self._add_result_analysis_section( + doc, vis_dir, figure_counter, all_image_analyses, progress=progress + ) + + # 物理模型反演浓度统计与分析(第4.1节) + figure_counter = self._add_physical_inversion_section( + doc, self.work_dir, figure_counter, all_image_analyses, progress=progress + ) + + # 设置页眉和页码(从正文开始) + self._setup_header_and_footer(section) + + # 按参数生成内容(带编号):参数章节从 5 开始编号 + base_section_num = 5 + last_param_section_num = base_section_num + len(parameters) - 1 + for section_num, param in enumerate(parameters, base_section_num): + progress.set_description(f"正在分析 {param} 数据 ({section_num - base_section_num + 1}/{len(parameters)})") + figure_counter = self._add_parameter_section( + doc, + param, + vis_dir, + section_num, + figure_counter, + all_image_analyses, + progress=progress, + ) + if section_num != last_param_section_num: + doc.add_page_break() + + # 汇总总结(放在所有图片/参数之后) + doc.add_page_break() + summary_section_num = base_section_num + len(parameters) + summary_heading = doc.add_heading(f"{summary_section_num} 综合分析总结", level=1) + self._style_heading(summary_heading, level=1) + + if self.enable_ai_analysis and all_image_analyses: + analyses_text = "\n\n".join( + [ + f"图{a.get('figure_num')}({a.get('param')} / {a.get('image_type')} / {a.get('image_name')})\n{a.get('analysis')}" + for a in all_image_analyses + ] + ) + system = ( + "你是一位水环境管理决策专家与遥感首席科学家。现需根据前面生成的各参数逐图分析文本,提炼出一份执行摘要级别的综合结论。\n" + "必须具备宏观视角,能够将离散的参数分析整合成对该水域整体健康状况的系统性诊断。" + ) + user = ( + "以下是各个水质参数的详尽逐图分析文本,请基于此撰写一份最终的综合分析总结。\n" + "【内容结构需包含】:\n" + "1. 整体水质评估(如营养状态、主要污染程度)。\n" + "2. 关键时空热点与驱动因子(最需关注的高值区域及核心主导参数)。\n" + "3. 遥感反演模型可靠性综合评价。\n" + "4. 宏观水环境管理与保护建议。\n" + "【⚠️强制要求】:\n" + "- 总结的字数必须严格控制在 300 到 450 字之间!\n" + "- 必须输出完整的结尾标点符号,绝不允许出现话说一半突然截断的情况!高度精炼,切勿啰嗦。\n\n" + f"{analyses_text}" + ) + summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None) + para = doc.add_paragraph(summary_text) + para.paragraph_format.first_line_indent = Pt(24) + para.paragraph_format.line_spacing = 1.5 + for run in para.runs: + run.font.name = self.chinese_font + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + else: + doc.add_paragraph("(未启用AI分析或无可用分析文本,无法生成综合总结。)") + + # 综合总结完成,进度 +1 + try: + progress.update(1) + except Exception: + pass + finally: + try: + progress.close() + except Exception: + pass + + # 保存文档 + doc.save(str(output_path)) + print(f"✅ Word报告生成完成: {output_path}") + + return str(output_path) + + def _add_parameter_section( + self, doc, param: str, vis_dir: Path, param_index: int = 1, + start_figure_num: int = 1, all_image_analyses: Optional[List[Dict[str, Any]]] = None, progress=None, + ): + """为单个参数添加报告章节(支持多线程并发预取 AI 结果)""" + if param not in self.parameter_descriptions: + return start_figure_num + + # 添加带编号的参数标题 heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) self._style_heading(heading, level=1) @@ -1015,823 +1015,823 @@ class WaterQualityReportGenerator: doc.add_paragraph() return start_figure_num + len(image_list) - - def _add_cover_page(self, doc): - """添加专业的封面页 - 优化后的布局""" - section = doc.sections[-1] - section.different_first_page_header_footer = True - - # 1. 左上角图片(增大) - 使用相对路径 - cover_top_img_path = get_resource_path("data/icons/word/lica.png") - if os.path.isfile(cover_top_img_path): - try: - p = doc.add_paragraph() - p.alignment = WD_ALIGN_PARAGRAPH.LEFT - p.add_run().add_picture(str(cover_top_img_path), width=Inches(3.2)) - except Exception as e: - print(f"封面顶部图片加载失败: {e}") - pass - - # 增加一些顶部空间 - for _ in range(6): - doc.add_paragraph() - - # 2. 主标题 - 增大字体 - title = doc.add_heading("无人机高光谱水质参数分析报告", level=0) - title.alignment = WD_ALIGN_PARAGRAPH.CENTER - for run in title.runs: - run.font.name = self.title_font - run.font.size = Pt(36) # 增大标题字体 - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) - - # 3. 公司名称和日期 - 紧挨着放在底部图片上方 - doc.add_paragraph() # 小间隔 - - for _ in range(6): - doc.add_paragraph() - - company = doc.add_paragraph("北京理加联合科技有限公司") - company.alignment = WD_ALIGN_PARAGRAPH.CENTER - for run in company.runs: - run.font.name = self.chinese_font - run.font.size = Pt(18) - run.font.bold = True # 加粗 - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - # 日期紧挨着公司名称下方 - date_str = datetime.now().strftime("%Y年%m月%d日") - date_para = doc.add_paragraph(date_str) - date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER - for run in date_para.runs: - run.font.name = self.chinese_font - run.font.size = Pt(14) - run.font.bold = True # 加粗 - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - - - # 4. 底部图片(增大) - 使用相对路径 - cover_bottom_img_path = get_resource_path("data/icons/word/fenmian.png") - if os.path.isfile(cover_bottom_img_path): - try: - p = doc.add_paragraph() - p.alignment = WD_ALIGN_PARAGRAPH.CENTER - p.add_run().add_picture(str(cover_bottom_img_path), width=Inches(5.8)) - except Exception as e: - print(f"封面底部图片加载失败: {e}") - pass - - def _add_company_description_page(self, doc): - """添加公司描述页,每个自然段均首行缩进2字符(24磅)""" - h = doc.add_heading("1 公司简介", level=1) - self._style_heading(h, level=1) - - # 公司描述原始文本(使用三引号保留换行) - company_text = """北京理加联合科技有限公司成立于2005年,总部位于北京光华创业园,在深圳、西安设有办事处。公司专注于生态环境仪器的自主研发与技术服务,致力于为国内用户提供全球领先的稳定性同位素、痕量气体、高光谱成像、环境空气质量及大气颗粒物监测等测量设备。 -作为英国ASD、美国Resonon、美国Campbell、法国AMS等多家国际知名品牌的中国区代理商与技术服务中心,理加联合同时拥有一支经验丰富的研发团队,已获得20余项实用新型专利。自主研发产品包括LI-2100全自动真空冷凝抽提系统、SF-3500系列土壤气体通量自动测量系统、PS-9000便携式土壤碳通量自动测量系统等,广泛应用于生态、环境、农业等领域。 -公司设有ASD和Resonon产品的定标实验室,显著提升定标效率、降低用户成本。2018年通过ISO9001质量管理体系认证,售后服务团队定期赴原厂培训。理加联合已参与“211”工程、“985”工程及中国生态系统研究网络(CERN)等重大科研项目,以专业技术与完善售后赢得广泛市场认可。""" - - # 按换行符分割成独立段落,并过滤可能的空行 - company_paragraphs = [p.strip() for p in company_text.split('\n') if p.strip()] - for para_text in company_paragraphs: - para = doc.add_paragraph(para_text) - para.paragraph_format.first_line_indent = Pt(24) # 首行缩进2字符(约24磅) - - # 设置正文样式:宋体小四,1.5倍行距 - para.paragraph_format.line_spacing = 1.5 - for run in para.runs: - run.font.name = 'SimSun' - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - - for _ in range(5): - doc.add_paragraph() - - # 联系方式信息(同样按行分割,每行独立且首行缩进) - contact_info = """地址:北京市海淀区安宁庄东路18号光华创业园5号楼(生产研发)光华创业园科研楼四层 -电话:13910499761 13910124070 010-51292601 -传真:010-82899770-8014 -邮箱:info@li-ca.com -邮编:100085""" - - contact_lines = [line.strip() for line in contact_info.split('\n') if line.strip()] - for line in contact_lines: - contact_para = doc.add_paragraph(line) - contact_para.paragraph_format.line_spacing = 1.5 - for run in contact_para.runs: - run.font.name = 'SimSun' - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - - doc.add_page_break() - - def _add_data_acquisition_section(self, doc): - """添加数据获取章节""" - h = doc.add_heading("2 数据获取", level=1) - self._style_heading(h, level=1) - - # 第一张图片标题 - - - # 第一张图片 - 使用相对路径 - img1_path = get_resource_path("data/icons/word/屏幕截图 2026-03-31 144131.png") - if os.path.isfile(img1_path): - p = doc.add_paragraph() - p.alignment = WD_ALIGN_PARAGRAPH.CENTER - p.add_run().add_picture(str(img1_path), width=Inches(6.0)) - - title1 = doc.add_paragraph("大疆M400无人机及300TC高光谱相机") - title1.alignment = WD_ALIGN_PARAGRAPH.CENTER - for run in title1.runs: - run.font.name = self.title_font - run.font.size = Pt(14) - run.font.bold = True - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) - doc.add_paragraph() # 图片和文字间空行 - - - - # 数据获取描述文字 - data_text = """本次研究采用大疆M400无人机搭载高光谱成像仪进行数据获取。飞行区域覆盖研究区全部水域及周边参照地,共执行飞行任务____架次,总飞行时间约为____小时,实际有效覆盖面积约____平方公里。飞行前进行航线规划,设置航向重叠率____%、旁向重叠率____%,飞行高度为____米,地面分辨率达到____米。为确保数据质量,选择天气晴朗、风速小于____级、太阳高度角适宜的气象窗口期进行作业,并在水体周边布设____个地面控制点及____个光谱定标参考板。整个数据获取过程严格按照无人机操作规范执行,获取的高光谱原始数据存储于机载固态硬盘,后续用于几何校正、辐射定标等预处理步骤。""" - - para = doc.add_paragraph(data_text) - para.paragraph_format.first_line_indent = Pt(24) - para.paragraph_format.space_after = Pt(12) - para.paragraph_format.line_spacing = 1.5 - - # 设置正文字体:宋体小四 - for run in para.runs: - run.font.name = 'SimSun' - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - - doc.add_page_break() - - def _add_data_processing_section(self, doc): - """添加数据处理章节""" - h = doc.add_heading("3 数据处理流程", level=1) - self._style_heading(h, level=1) - - # 插入图片 - 使用相对路径 - processing_img_path = get_resource_path("data/icons/word/liucheng.png") - if os.path.isfile(processing_img_path): - p = doc.add_paragraph() - p.alignment = WD_ALIGN_PARAGRAPH.CENTER - p.add_run().add_picture(str(processing_img_path), width=Inches(6.5)) - - # ===== 添加图片下方标题(图注)===== - caption_p = doc.add_paragraph() - caption_p.alignment = WD_ALIGN_PARAGRAPH.CENTER - caption_run = caption_p.add_run("图3-1 水质高光谱反演数据处理流程图") - caption_run.font.name = 'SimSun' - caption_run.font.size = Pt(11) - caption_run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - caption_run.font.bold = False - # 设置图注段落格式 - caption_p.paragraph_format.space_before = Pt(6) - caption_p.paragraph_format.space_after = Pt(12) - else: - doc.add_paragraph("[数据处理流程图片占位]") - - doc.add_paragraph() # 图片和文字间空行(可选,因为图注下方已有间距) - - # 数据处理描述文字(暂时留空,供后续填写) - processing_text = """采用基于高光谱遥感的水质反演流程来获取水体参数的空间分布。首先通过无人机或卫星平台获取研究区的高光谱影像,随后进行一系列预处理:几何校正使影像与真实地理坐标匹配,辐射校正将原始数值转换为表观辐亮度,大气校正则去除大气分子与气溶胶的影响以获取真实地表反射率;对于多航带数据还需进行航带自动拼接。针对水面特有的镜面反射,我们执行耀斑识别及去除,并利用BRDF校正消除观测角度变化带来的二向性反射差异。 - 之后采用归一化水体指数或深度学习方法自动分割出纯水域像元,排除陆地与植被干扰。在光谱分析阶段,从预处理后的高光谱数据中提取对叶绿素a、悬浮物、透明度等水质参数敏感的波段、比值或吸收深度等光谱特征,并基于地面同步实测数据构建机器学习模型(如随机森林、支持向量机或偏最小二乘回归)。最终将训练好的模型应用于整景影像,逐像元反演出水质参数浓度,并生成专题图与统计报告,实现从原始高光谱数据到水质空间分布信息的完整技术链。""" - - para = doc.add_paragraph(processing_text) - para.paragraph_format.first_line_indent = Pt(24) - para.paragraph_format.space_after = Pt(12) - para.paragraph_format.line_spacing = 1.5 - - # 设置正文字体:宋体小四 - for run in para.runs: - run.font.name = 'SimSun' - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - - # 添加高光谱图像、耀斑区域和去耀斑图像展示 - self._add_hyperspectral_images_section(doc) - - doc.add_page_break() - - def _add_hyperspectral_images_section(self, doc): - """添加高光谱图像、耀斑区域和去耀斑图像展示""" - h = doc.add_heading("3.1 高光谱图像处理过程", level=2) - self._style_heading(h, level=2) - - work_dir_path = self.work_dir - vis_dir = self.visualization_dir - - # 0. 航线规划图 - flight_path_img_path = work_dir_path / "14_visualization" / "flight_paths" - h3 = doc.add_heading("航线规划:", level=3) - self._style_heading(h3, level=3) - - # 查找航线图文件 - flight_map_files = [] - if flight_path_img_path.exists(): - flight_map_files = list(flight_path_img_path.glob("*.png")) + list(flight_path_img_path.glob("*.jpg")) - - if flight_map_files: - # 使用最新的航线图文件 - latest_flight_map = max(flight_map_files, key=lambda p: p.stat().st_mtime) - success = self._add_image_with_caption(doc, str(latest_flight_map), "图3-1 航线规划", width=Inches(5.5)) - - if success: - # AI 分析航线规划图 - flight_analysis = self._analyze_flight_path_image(str(latest_flight_map)) - self._add_ai_analysis_paragraph(doc, flight_analysis) - else: - doc.add_paragraph("[航线规划图 - 文件未找到]") - - # 1. 高光谱原始图像 - hyperspectral_img_path = work_dir_path / "1_water_mask" / "hsi_preview.png" - h3 = doc.add_heading("高光谱原始影像:", level=3) - self._style_heading(h3, level=3) - if hyperspectral_img_path.exists(): - self._add_image_with_caption(doc, str(hyperspectral_img_path), "图3-2 高光谱原始影像", width=Inches(5.5)) - else: - doc.add_paragraph("[高光谱原始影像 - 文件未找到]") - - # 2. 水体掩膜叠加图 - water_mask_overlay_path = work_dir_path / "1_water_mask" / "water_mask_overlay.png" - h3 = doc.add_heading("水体区域识别:", level=3) - self._style_heading(h3, level=3) - if water_mask_overlay_path.exists(): - success = self._add_image_with_caption(doc, str(water_mask_overlay_path), - "图3-3 水体区域识别(蓝色半透明区域为水域)", - width=Inches(5.5)) - if success: - water_analysis = self._analyze_water_mask_overlay(str(water_mask_overlay_path)) - self._add_ai_analysis_paragraph(doc, water_analysis) - else: - doc.add_paragraph("[水体区域识别图 - 文件未找到]") - - doc.add_paragraph() - - # 2. 耀斑区域 - glint_img_path = vis_dir / "glint_deglint_previews" / "glint_severe_glint_area_preview.png" - h3 = doc.add_heading("耀斑区域识别结果:", level=3) - self._style_heading(h3, level=3) - if glint_img_path.exists(): - self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) - else: - # 尝试查找其他可能的耀斑预览图 - glint_files = list(vis_dir.glob("glint_deglint_previews/*glint*.png")) - if glint_files: - glint_img_path = glint_files[0] - self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) - else: - doc.add_paragraph("[耀斑区域识别结果 - 文件未找到]") - - doc.add_paragraph() - - # 3. 去除耀斑后的图像 - deglint_img_path = vis_dir / "glint_deglint_previews" / "deglint_deglint_image_preview.png" - h3 = doc.add_heading("去除耀斑后的影像:", level=3) - self._style_heading(h3, level=3) - if deglint_img_path.exists(): - self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的高光谱影像", width=Inches(5.5)) - else: - # 尝试查找其他去耀斑预览图 - deglint_files = list(vis_dir.glob("glint_deglint_previews/*deglint*.png")) - if deglint_files: - deglint_img_path = deglint_files[0] - self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的影像", width=Inches(5.5)) - else: - doc.add_paragraph("[去除耀斑后的影像 - 文件未找到]") - - doc.add_paragraph() - - # 4. AI分析耀斑位置分布 - - self._style_heading(h3, level=3) - glint_analysis = self._analyze_glint_distribution_with_ai( - str(glint_img_path) if 'glint_img_path' in locals() and Path(str(glint_img_path)).exists() else None, - str(hyperspectral_img_path) if hyperspectral_img_path.exists() else None - ) - self._add_ai_analysis_paragraph(doc, glint_analysis) - - # 5. 采样点分布图 - sampling_map_dir = vis_dir / "sampling_maps" - h3 = doc.add_heading("采样点分布:", level=3) - self._style_heading(h3, level=3) - - # 查找采样点分布图文件 - sampling_map_files = [] - if sampling_map_dir.exists(): - sampling_map_files = list(sampling_map_dir.glob("*.png")) + list(sampling_map_dir.glob("*.jpg")) - - if sampling_map_files: - # 使用最新的采样点分布图文件 - latest_sampling_map = max(sampling_map_files, key=lambda p: p.stat().st_mtime) - success = self._add_image_with_caption(doc, str(latest_sampling_map), "图3-6 采样点分布图", width=Inches(5.5)) - - if success: - # AI 分析采样点分布图 - sampling_analysis = self._analyze_sampling_distribution(str(latest_sampling_map)) - self._add_ai_analysis_paragraph(doc, sampling_analysis) - else: - doc.add_paragraph("[采样点分布图 - 文件未找到]") - - def _analyze_glint_distribution_with_ai(self, glint_img_path: str = None, original_img_path: str = None) -> str: - """使用AI分析耀斑的位置分布""" - if not self.enable_ai_analysis: - return "AI分析已禁用。耀斑主要分布在水体表面强反射区域,通常出现在太阳光直射角度较大的位置。" - - try: - analysis_prompt = """请分析这张高光谱影像中的耀斑分布情况。 -请从以下几个方面进行专业分析: -1. 耀斑的主要分布位置(水体中心、边缘、特定方位等) -2. 耀斑面积占比估计 -3. 耀斑分布特征(集中分布还是分散分布) -4. 可能的成因分析 -5. 对水质参数反演的影响评估 - -请用专业且简洁的语言描述,控制在150字以内。""" - - if glint_img_path and Path(glint_img_path).exists(): - return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(glint_img_path)) - elif original_img_path and Path(original_img_path).exists(): - return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(original_img_path)) - else: - return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。" - - except Exception as e: - return f"AI分析失败: {str(e)}。耀斑主要分布在水体表面强反射区域,通常与太阳入射角和水面粗糙度有关。" - - def _analyze_flight_path_image(self, flight_img_path: str) -> str: - """ - 使用AI分析航线规划图 - - 分析内容: - 1. 架次数量 - 2. 每个架次的飞行方向 - 3. 图例中的飞行起始结束时间 - """ - if not self.enable_ai_analysis: - return "AI分析已禁用。根据航线规划图,可识别多个架次的飞行轨迹,每个架次具有不同的飞行方向和时间安排。" - - try: - if not Path(flight_img_path).exists(): - return "航线图文件不存在,无法进行分析。" - - analysis_prompt = """请详细分析这张航线规划图,并严格按照以下要求输出: - -分析要求: -1. 架次数量:明确指出图中有几个架次(几条不同颜色的轨迹线) -2. 飞行方向:描述每个架次的大致飞行方向(如:东西向、南北向、东北-西南向等) -3. 时间信息:从图例中提取每个架次的起始和结束时间 - -输出格式要求: -- 使用客观、准确的描述 -- 避免推测性语言(如"可能"、"也许") -- 控制在200字以内 -- 如果看不清具体时间,请明确说明"图例显示时间信息但具体数值不清晰" - -示例输出格式: -"飞行共有X个架次:架次1(红色):东西向飞行,时间范围XX:XX-XX:XX架次2(蓝色):南北向飞行,时间范围XX:XX-XX:XX -... -各架次轨迹分布合理,覆盖了目标水体区域。""" - - result = self._ai_chat( - self.ollama_vision_model, - "你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。", - analysis_prompt, - Path(flight_img_path) - ) - - # 如果返回内容为空或太短,使用默认文本 - if not result or len(result) < 20: - return "根据航线图分析,图中包含多个架次的飞行轨迹,各架次采用不同颜色标识,飞行方向各异,图例中标注了各架次的起始和结束时间。" - - return result - - except Exception as e: - return f"AI分析失败: {str(e)}。根据航线规划图,包含多个架次的飞行轨迹,各架次具有不同颜色和飞行方向,图例中标注了时间信息。" - - def _analyze_water_mask_overlay(self, water_mask_path: str) -> str: - """ - 使用AI分析水体区域识别图 - - 分析内容: - 1. 水体的分布情况(集中分布还是分散分布) - 2. 水体的位置和形状特征 - 3. 从图像标注中提取的水域面积和占比 - """ - if not self.enable_ai_analysis: - return "AI分析已禁用。根据水体区域识别图,蓝色半透明区域标识了水域范围,可观察到水体的分布情况和面积占比。" - - try: - if not Path(water_mask_path).exists(): - return "水体区域识别图文件不存在,无法进行分析。" - - analysis_prompt = """【背景说明】 -这是一座水库的遥感影像,水体区域以蓝色半透明标识。水库通常是人工筑坝蓄水形成,具有以下典型特征: -- 水体形态:较宽阔,形状相对规则,边界平滑 -- 大坝位置:通常位于水库最窄处或下游方向 -- 入库方向:上游河流汇入处,通常较窄或有分叉 -- 出水方向:大坝方向,水体在此处收窄 - -【分析维度】 -1. 水体整体形态:描述水库的形状(扇形、狭长形、不规则形、分叉形等),水体是集中还是分散? -2. 入库特征(重要):识别水体哪些位置有狭窄的入口或分叉——这些通常是河流入库的方向。描述入库位置(如东北角、西侧等)。 -3. 大坝/出水方向推断(重要):根据水体形态,判断大坝最可能的位置。通常在水体最窄处、或水体延伸的末端。推断流向是“从XX方向流向大坝(XX方向)”。 -4. 分支情况:是否有多个入库河流?是否有孤立水体? -5. 面积信息:从图像左上角标注中提取水域面积、影像总面积、水域占比。 - -【输出格式】 - 水体面积X.XX km² ,占比: X.X% ,形态: X。入库方向:XX方向(若有多个,依次列出)。出水/大坝方向:XX方向。流向推断:水体从XX方向汇入,流向大坝(XX方向)补充描述:[简要描述整体分布和形态特征] - -【示例输出】 -水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征""" - - result = self._ai_chat( - self.ollama_vision_model, - "你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。", - analysis_prompt, - Path(water_mask_path) - ) - - # 如果返回内容为空或太短,使用默认文本 - if not result or len(result) < 20: - return "根据水体区域识别图分析,蓝色半透明区域标识了水域范围。从图像标注可读取水域面积、影像总面积及水域占比信息,水体分布特征明显,便于后续水质参数反演分析。" - - return result - - except Exception as e: - return f"AI分析失败: {str(e)}。根据水体区域识别图,蓝色半透明区域标识了水域范围,图像左上角标注了水域面积、影像总面积及水域占比数据。" - - def _analyze_sampling_distribution(self, sampling_map_path: str) -> str: - """ - 使用AI分析采样点分布图 - - 分析内容: - 1. 采样点数量 - 2. 采样点在水体中的分布情况(均匀/集中、覆盖范围) - 3. 采样点的空间分布特征 - 4. 对水质反演代表性的评估 - """ - if not self.enable_ai_analysis: - return "AI分析已禁用。根据采样点分布图,红色点标识了采样点位置,可观察采样点在水体中的分布情况和覆盖范围。" - - try: - if not Path(sampling_map_path).exists(): - return "采样点分布图文件不存在,无法进行分析。" - - analysis_prompt = """请详细分析这张采样点分布图,并严格按照以下要求输出: - -【分析要求】 -1. 采样点数量:估算图中有多少个采样点(红色点) -2. 分布情况:描述采样点在水体中的分布是否均匀,是否有聚集或稀疏区域 -3. 覆盖范围:采样点是否覆盖了主要水域,是否有未覆盖的区域 -4. 空间特征:采样点分布在哪些方位(如上下游、左右岸等) -5. 代表性评估:简要评价当前采样点布局对水质参数反演的代表性 - -【输出格式要求】 -- 使用客观、准确的描述 -- 避免推测性语言 -- 控制在200字以内 - -【示例输出格式】 -"图中共有约XX个采样点,分布...,覆盖...,在...区域较为密集,...区域较为稀疏。 -采样点整体覆盖了主要水体区域,但在...区域采样不足。 -当前布局对水质反演具有较好的代表性,建议..." - -请根据图像内容给出专业分析。""" - - result = self._ai_chat( - self.ollama_vision_model, - "你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。", - analysis_prompt, - Path(sampling_map_path) - ) - - # 如果返回内容为空或太短,使用默认文本 - if not result or len(result) < 20: - return "根据采样点分布图分析,红色点标识了采样点位置,分布在水体各个区域。采样点覆盖范围较广,空间布局合理,能够较好地代表整体水质状况,为后续水质参数反演提供了可靠的数据基础。" - - return result - - except Exception as e: - return f"AI分析失败: {str(e)}。根据采样点分布图,红色点标识了采样点位置,分布在水体中,覆盖了主要水域区域,具有较好的代表性。" - - def _setup_header_and_footer(self, section): - """设置页眉:图片在最左侧 + 中间文字""" - header = section.header - - # 清空现有段落 - for paragraph in header.paragraphs: - p = paragraph._element - p.getparent().remove(p) - - # 创建新段落用于页眉 - header_para = header.add_paragraph() - - # 1. 最左侧图片 - 使用相对路径 - header_img_path = get_resource_path("data/icons/word/lica.png") - if os.path.isfile(header_img_path): - try: - run_img = header_para.add_run() - run_img.add_picture(str(header_img_path), width=Inches(1.6)) - except Exception as e: - print(f"页眉图片加载失败: {e}") - header_para.add_run("■ ") - else: - header_para.add_run("■ ") # 图片不存在时的占位 - - # 2. 中间文字 - “水质参数报告” - run_text = header_para.add_run(" 水质参数报告") - run_text.font.name = self.chinese_font - run_text.font.size = Pt(11) - run_text._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - # 左对齐,让图片在最左侧 - header_para.alignment = WD_ALIGN_PARAGRAPH.LEFT - - # 设置页眉边距 - section.header_distance = Cm(0.8) - - # 设置页脚页码 - footer = section.footer - footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() - footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER - - # 添加页码字段 - run = footer_para.add_run() - fldChar1 = OxmlElement('w:fldChar') - fldChar1.set(qn('w:fldCharType'), 'begin') - run._element.append(fldChar1) - - instrText = OxmlElement('w:instrText') - instrText.text = 'PAGE' - run._element.append(instrText) - - fldChar2 = OxmlElement('w:fldChar') - fldChar2.set(qn('w:fldCharType'), 'end') - run._element.append(fldChar2) - - # 添加 "页" 字 - footer_para.add_run(' / ') - run2 = footer_para.add_run() - fldChar3 = OxmlElement('w:fldChar') - fldChar3.set(qn('w:fldCharType'), 'begin') - run2._element.append(fldChar3) - - instrText2 = OxmlElement('w:instrText') - instrText2.text = 'NUMPAGES' - run2._element.append(instrText2) - - fldChar4 = OxmlElement('w:fldChar') - fldChar4.set(qn('w:fldCharType'), 'end') - run2._element.append(fldChar4) - - footer_para.add_run(' 页') - - # 设置页脚字体 - for run in footer_para.runs: - run.font.size = Pt(9) - run.font.name = self.chinese_font - if hasattr(run, '_element') and hasattr(run._element, 'rPr'): - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - - def _add_result_analysis_section( - self, - doc, - vis_dir: Path, - start_figure_num: int = 1, - all_image_analyses: Optional[List[Dict[str, Any]]] = None, - progress=None, - ) -> int: - """添加结果分析章节 - 统计表格 + 相关性热力图(热力图在表格下方)""" - h1 = doc.add_heading("4 结果分析", level=1) - self._style_heading(h1, level=1) - - # 1. 添加统计分析表格(带编号) - h2 = doc.add_heading("4.1 水质参数统计分析", level=2) - self._style_heading(h2, level=2) - - # 从工作目录的4_processed_data文件夹查找CSV文件 - work_dir_path = vis_dir.parent - processed_data_dir = work_dir_path / "5_Data_Cleaning" - - if not processed_data_dir.exists(): - doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") - doc.add_page_break() - return start_figure_num - - csv_files = list(processed_data_dir.glob("*.csv")) - if not csv_files: - doc.add_paragraph(f"在 {processed_data_dir} 目录下未找到CSV统计数据文件。") - doc.add_page_break() - return start_figure_num - - csv_path = csv_files[0] # 使用找到的第一个CSV文件 - - try: - df_full = pd.read_csv(csv_path, sep=',') - df = df_full.iloc[:, 2:] # 跳过前两列(纬度、经度),直接用列号 - - # 自动统计剩余列 - stats_data = [] - for i in range(df.shape[1]): - col = df.columns[i] - clean_col = str(col).strip() - try: - data = df.iloc[:, i].dropna() - if len(data) > 0: - stats_data.append({ - '参数': clean_col, - '点位数': len(data), - '最大值': f"{data.max():.4f}", - '最小值': f"{data.min():.4f}", - '平均值': f"{data.mean():.4f}", - '标准差': f"{data.std():.4f}" - }) - except Exception as e: - print(f"跳过列 {col}: {e}") - - if stats_data: - # 创建统计表格 - table = doc.add_table(rows=1, cols=6, style='Table Grid') - hdr_cells = table.rows[0].cells - hdr_cells[0].text = '参数' - hdr_cells[1].text = '点位数' - hdr_cells[2].text = '最大值' - hdr_cells[3].text = '最小值' - hdr_cells[4].text = '平均值' - hdr_cells[5].text = '标准差' - - for stat in stats_data: - row_cells = table.add_row().cells - row_cells[0].text = stat['参数'] - row_cells[1].text = str(stat['点位数']) - row_cells[2].text = stat['最大值'] - row_cells[3].text = stat['最小值'] - row_cells[4].text = stat['平均值'] - row_cells[5].text = stat['标准差'] - - else: - doc.add_paragraph("CSV文件中未找到有效的参数数据。") - - except Exception as e: - doc.add_paragraph(f"读取CSV文件时出错: {str(e)}") - #增加空格 - doc.add_paragraph() - # 表格生成完成后,添加 AI 分析 - if stats_data: - analysis_text = self._analyze_statistics(stats_data, [s['参数'] for s in stats_data]) - self._add_ai_analysis_paragraph(doc, analysis_text) - - doc.add_paragraph() # 表格和热力图之间的空行 - - # 2. 添加相关性热力图(放在表格下方) - h3 = doc.add_heading("4.2 水质参数相关性分析", level=2) - self._style_heading(h3, level=2) - heatmap_path = vis_dir / "correlation_heatmap.png" - figure_num = start_figure_num - if heatmap_path.exists(): - try: - # 使用统一的图像插入方法 - caption_text = f"图{figure_num} 水质参数相关性热力图" - self._add_image_with_caption(doc, str(heatmap_path), caption_text, width=Inches(6.0)) - doc.add_paragraph("(颜色越深表示相关性越强,红色为正相关,蓝色为负相关)") - - analysis_text = self._analyze_and_cache_image( - image_path=heatmap_path, - image_type="correlation_heatmap", - param="综合", - figure_num=figure_num, - ) - self._add_ai_analysis_paragraph(doc, analysis_text) - if all_image_analyses is not None: - all_image_analyses.append( - { - "figure_num": figure_num, - "param": "综合", - "image_type": "correlation_heatmap", - "image_name": heatmap_path.name, - "analysis": analysis_text, - } - ) - except Exception as e: - doc.add_paragraph(f"[相关性热力图插入失败: {e}]") - else: - doc.add_paragraph(f"[未找到相关性热力图: {heatmap_path.name}]") - - # 热力图处理结束(无论成功/失败)更新进度条 - try: - if progress is not None: - progress.update(1) - except Exception: - pass - - doc.add_page_break() - return start_figure_num + (1 if heatmap_path.exists() else 0) - - def _add_physical_inversion_section( - self, - doc: Document, - work_dir: Path, - start_figure_num: int = 1, - all_image_analyses: Optional[List[Dict[str, Any]]] = None, - progress=None, - ) -> int: - """新增章节:物理模型反演浓度统计与分析(第4节之后)""" - conc_dir = work_dir / "9_Concentration" - if not conc_dir.is_dir(): - doc.add_paragraph("[物理反演浓度章节:9_Concentration 目录不存在,已跳过]") - return start_figure_num - - stats_csv = conc_dir / "statistics_summary.csv" - charts_dir = conc_dir / "charts" - - h = doc.add_heading("4.1 物理模型反演浓度统计与分析", level=2) - self._style_heading(h, level=2) - - fig_num = start_figure_num - - if stats_csv.is_file(): - try: - stats_df = pd.read_csv(stats_csv) - table = doc.add_table(rows=1, cols=len(stats_df.columns)) - table.style = "Table Grid" - hdr_cells = table.rows[0].cells - for i, col_name in enumerate(stats_df.columns): - hdr_cells[i].text = str(col_name) - for run in hdr_cells[i].paragraphs[0].runs: - run.font.name = self.chinese_font - run.font.size = Pt(10) - run.font.bold = True - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - for _, row_data in stats_df.iterrows(): - row_cells = table.add_row().cells - for i, val in enumerate(row_data): - row_cells[i].text = str(val) - for run in row_cells[i].paragraphs[0].runs: - run.font.name = self.chinese_font - run.font.size = Pt(10) - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - doc.add_paragraph() - except Exception as e: - doc.add_paragraph(f"[浓度统计表插入失败: {e}]") - else: - doc.add_paragraph("[浓度统计表不存在: statistics_summary.csv]") - - if charts_dir.is_dir(): - image_extensions = ['*.png', '*.jpg', '*.jpeg'] # 严格剔除 tif/tiff - chart_files: List[Path] = [] - for ext in image_extensions: - chart_files.extend(sorted(charts_dir.glob(ext))) - for chart_file in chart_files: - caption_text = f"图{fig_num} {chart_file.stem} 分布图" - if self._add_image_with_caption(doc, str(chart_file), caption_text, width=Inches(5.5)): - if all_image_analyses is not None: - image_type = "boxplot" if "boxplot" in chart_file.stem.lower() else "distribution" - analysis_text = self._analyze_and_cache_image( - image_path=chart_file, - image_type=image_type, - param=chart_file.stem, - figure_num=fig_num, - ) - self._add_ai_analysis_paragraph(doc, analysis_text) - all_image_analyses.append({ - "figure_num": fig_num, - "param": chart_file.stem, - "image_type": image_type, - "image_name": chart_file.name, - "analysis": analysis_text, - }) - fig_num += 1 - try: - if progress is not None: - progress.update(1) - except Exception: - pass - else: - doc.add_paragraph("[浓度图表目录不存在: 9_Concentration/charts/]") - - return fig_num - -# ==================== 使用示例 ==================== - -def generate_full_water_quality_report( - work_dir: str = "./work_dir", - ai_config: Optional[ReportGenerationConfig] = None, -): - """生成包含所有水质参数的完整报告。""" - generator = WaterQualityReportGenerator(work_dir=work_dir, ai_config=ai_config) - return generator.generate_report( - work_dir=work_dir, - parameters=None, - report_title="水质参数反演分析完整报告", - ) - - -if __name__ == "__main__": - # 默认生成完整报告(包含所有13个水质参数) - report_path = generate_full_water_quality_report() - print(f"完整水质报告已生成: {report_path}") + + def _add_cover_page(self, doc): + """添加专业的封面页 - 优化后的布局""" + section = doc.sections[-1] + section.different_first_page_header_footer = True + + # 1. 左上角图片(增大) - 使用相对路径 + cover_top_img_path = get_resource_path("data/icons/word/lica.png") + if os.path.isfile(cover_top_img_path): + try: + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + p.add_run().add_picture(str(cover_top_img_path), width=Inches(3.2)) + except Exception as e: + print(f"封面顶部图片加载失败: {e}") + pass + + # 增加一些顶部空间 + for _ in range(6): + doc.add_paragraph() + + # 2. 主标题 - 增大字体 + title = doc.add_heading("无人机高光谱水质参数分析报告", level=0) + title.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in title.runs: + run.font.name = self.title_font + run.font.size = Pt(36) # 增大标题字体 + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) + + # 3. 公司名称和日期 - 紧挨着放在底部图片上方 + doc.add_paragraph() # 小间隔 + + for _ in range(6): + doc.add_paragraph() + + company = doc.add_paragraph("北京理加联合科技有限公司") + company.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in company.runs: + run.font.name = self.chinese_font + run.font.size = Pt(18) + run.font.bold = True # 加粗 + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + # 日期紧挨着公司名称下方 + date_str = datetime.now().strftime("%Y年%m月%d日") + date_para = doc.add_paragraph(date_str) + date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in date_para.runs: + run.font.name = self.chinese_font + run.font.size = Pt(14) + run.font.bold = True # 加粗 + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + + + # 4. 底部图片(增大) - 使用相对路径 + cover_bottom_img_path = get_resource_path("data/icons/word/fenmian.png") + if os.path.isfile(cover_bottom_img_path): + try: + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.add_run().add_picture(str(cover_bottom_img_path), width=Inches(5.8)) + except Exception as e: + print(f"封面底部图片加载失败: {e}") + pass + + def _add_company_description_page(self, doc): + """添加公司描述页,每个自然段均首行缩进2字符(24磅)""" + h = doc.add_heading("1 公司简介", level=1) + self._style_heading(h, level=1) + + # 公司描述原始文本(使用三引号保留换行) + company_text = """北京理加联合科技有限公司成立于2005年,总部位于北京光华创业园,在深圳、西安设有办事处。公司专注于生态环境仪器的自主研发与技术服务,致力于为国内用户提供全球领先的稳定性同位素、痕量气体、高光谱成像、环境空气质量及大气颗粒物监测等测量设备。 +作为英国ASD、美国Resonon、美国Campbell、法国AMS等多家国际知名品牌的中国区代理商与技术服务中心,理加联合同时拥有一支经验丰富的研发团队,已获得20余项实用新型专利。自主研发产品包括LI-2100全自动真空冷凝抽提系统、SF-3500系列土壤气体通量自动测量系统、PS-9000便携式土壤碳通量自动测量系统等,广泛应用于生态、环境、农业等领域。 +公司设有ASD和Resonon产品的定标实验室,显著提升定标效率、降低用户成本。2018年通过ISO9001质量管理体系认证,售后服务团队定期赴原厂培训。理加联合已参与“211”工程、“985”工程及中国生态系统研究网络(CERN)等重大科研项目,以专业技术与完善售后赢得广泛市场认可。""" + + # 按换行符分割成独立段落,并过滤可能的空行 + company_paragraphs = [p.strip() for p in company_text.split('\n') if p.strip()] + for para_text in company_paragraphs: + para = doc.add_paragraph(para_text) + para.paragraph_format.first_line_indent = Pt(24) # 首行缩进2字符(约24磅) + + # 设置正文样式:宋体小四,1.5倍行距 + para.paragraph_format.line_spacing = 1.5 + for run in para.runs: + run.font.name = 'SimSun' + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + for _ in range(5): + doc.add_paragraph() + + # 联系方式信息(同样按行分割,每行独立且首行缩进) + contact_info = """地址:北京市海淀区安宁庄东路18号光华创业园5号楼(生产研发)光华创业园科研楼四层 +电话:13910499761 13910124070 010-51292601 +传真:010-82899770-8014 +邮箱:info@li-ca.com +邮编:100085""" + + contact_lines = [line.strip() for line in contact_info.split('\n') if line.strip()] + for line in contact_lines: + contact_para = doc.add_paragraph(line) + contact_para.paragraph_format.line_spacing = 1.5 + for run in contact_para.runs: + run.font.name = 'SimSun' + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + doc.add_page_break() + + def _add_data_acquisition_section(self, doc): + """添加数据获取章节""" + h = doc.add_heading("2 数据获取", level=1) + self._style_heading(h, level=1) + + # 第一张图片标题 + + + # 第一张图片 - 使用相对路径 + img1_path = get_resource_path("data/icons/word/屏幕截图 2026-03-31 144131.png") + if os.path.isfile(img1_path): + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.add_run().add_picture(str(img1_path), width=Inches(6.0)) + + title1 = doc.add_paragraph("大疆M400无人机及300TC高光谱相机") + title1.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in title1.runs: + run.font.name = self.title_font + run.font.size = Pt(14) + run.font.bold = True + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) + doc.add_paragraph() # 图片和文字间空行 + + + + # 数据获取描述文字 + data_text = """本次研究采用大疆M400无人机搭载高光谱成像仪进行数据获取。飞行区域覆盖研究区全部水域及周边参照地,共执行飞行任务____架次,总飞行时间约为____小时,实际有效覆盖面积约____平方公里。飞行前进行航线规划,设置航向重叠率____%、旁向重叠率____%,飞行高度为____米,地面分辨率达到____米。为确保数据质量,选择天气晴朗、风速小于____级、太阳高度角适宜的气象窗口期进行作业,并在水体周边布设____个地面控制点及____个光谱定标参考板。整个数据获取过程严格按照无人机操作规范执行,获取的高光谱原始数据存储于机载固态硬盘,后续用于几何校正、辐射定标等预处理步骤。""" + + para = doc.add_paragraph(data_text) + para.paragraph_format.first_line_indent = Pt(24) + para.paragraph_format.space_after = Pt(12) + para.paragraph_format.line_spacing = 1.5 + + # 设置正文字体:宋体小四 + for run in para.runs: + run.font.name = 'SimSun' + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + doc.add_page_break() + + def _add_data_processing_section(self, doc): + """添加数据处理章节""" + h = doc.add_heading("3 数据处理流程", level=1) + self._style_heading(h, level=1) + + # 插入图片 - 使用相对路径 + processing_img_path = get_resource_path("data/icons/word/liucheng.png") + if os.path.isfile(processing_img_path): + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.add_run().add_picture(str(processing_img_path), width=Inches(6.5)) + + # ===== 添加图片下方标题(图注)===== + caption_p = doc.add_paragraph() + caption_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + caption_run = caption_p.add_run("图3-1 水质高光谱反演数据处理流程图") + caption_run.font.name = 'SimSun' + caption_run.font.size = Pt(11) + caption_run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + caption_run.font.bold = False + # 设置图注段落格式 + caption_p.paragraph_format.space_before = Pt(6) + caption_p.paragraph_format.space_after = Pt(12) + else: + doc.add_paragraph("[数据处理流程图片占位]") + + doc.add_paragraph() # 图片和文字间空行(可选,因为图注下方已有间距) + + # 数据处理描述文字(暂时留空,供后续填写) + processing_text = """采用基于高光谱遥感的水质反演流程来获取水体参数的空间分布。首先通过无人机或卫星平台获取研究区的高光谱影像,随后进行一系列预处理:几何校正使影像与真实地理坐标匹配,辐射校正将原始数值转换为表观辐亮度,大气校正则去除大气分子与气溶胶的影响以获取真实地表反射率;对于多航带数据还需进行航带自动拼接。针对水面特有的镜面反射,我们执行耀斑识别及去除,并利用BRDF校正消除观测角度变化带来的二向性反射差异。 + 之后采用归一化水体指数或深度学习方法自动分割出纯水域像元,排除陆地与植被干扰。在光谱分析阶段,从预处理后的高光谱数据中提取对叶绿素a、悬浮物、透明度等水质参数敏感的波段、比值或吸收深度等光谱特征,并基于地面同步实测数据构建机器学习模型(如随机森林、支持向量机或偏最小二乘回归)。最终将训练好的模型应用于整景影像,逐像元反演出水质参数浓度,并生成专题图与统计报告,实现从原始高光谱数据到水质空间分布信息的完整技术链。""" + + para = doc.add_paragraph(processing_text) + para.paragraph_format.first_line_indent = Pt(24) + para.paragraph_format.space_after = Pt(12) + para.paragraph_format.line_spacing = 1.5 + + # 设置正文字体:宋体小四 + for run in para.runs: + run.font.name = 'SimSun' + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + # 添加高光谱图像、耀斑区域和去耀斑图像展示 + self._add_hyperspectral_images_section(doc) + + doc.add_page_break() + + def _add_hyperspectral_images_section(self, doc): + """添加高光谱图像、耀斑区域和去耀斑图像展示""" + h = doc.add_heading("3.1 高光谱图像处理过程", level=2) + self._style_heading(h, level=2) + + work_dir_path = self.work_dir + vis_dir = self.visualization_dir + + # 0. 航线规划图 + flight_path_img_path = work_dir_path / "12_visualization" / "flight_paths" + h3 = doc.add_heading("航线规划:", level=3) + self._style_heading(h3, level=3) + + # 查找航线图文件 + flight_map_files = [] + if flight_path_img_path.exists(): + flight_map_files = list(flight_path_img_path.glob("*.png")) + list(flight_path_img_path.glob("*.jpg")) + + if flight_map_files: + # 使用最新的航线图文件 + latest_flight_map = max(flight_map_files, key=lambda p: p.stat().st_mtime) + success = self._add_image_with_caption(doc, str(latest_flight_map), "图3-1 航线规划", width=Inches(5.5)) + + if success: + # AI 分析航线规划图 + flight_analysis = self._analyze_flight_path_image(str(latest_flight_map)) + self._add_ai_analysis_paragraph(doc, flight_analysis) + else: + doc.add_paragraph("[航线规划图 - 文件未找到]") + + # 1. 高光谱原始图像 + hyperspectral_img_path = work_dir_path / "1_water_mask" / "hsi_preview.png" + h3 = doc.add_heading("高光谱原始影像:", level=3) + self._style_heading(h3, level=3) + if hyperspectral_img_path.exists(): + self._add_image_with_caption(doc, str(hyperspectral_img_path), "图3-2 高光谱原始影像", width=Inches(5.5)) + else: + doc.add_paragraph("[高光谱原始影像 - 文件未找到]") + + # 2. 水体掩膜叠加图 + water_mask_overlay_path = work_dir_path / "1_water_mask" / "water_mask_overlay.png" + h3 = doc.add_heading("水体区域识别:", level=3) + self._style_heading(h3, level=3) + if water_mask_overlay_path.exists(): + success = self._add_image_with_caption(doc, str(water_mask_overlay_path), + "图3-3 水体区域识别(蓝色半透明区域为水域)", + width=Inches(5.5)) + if success: + water_analysis = self._analyze_water_mask_overlay(str(water_mask_overlay_path)) + self._add_ai_analysis_paragraph(doc, water_analysis) + else: + doc.add_paragraph("[水体区域识别图 - 文件未找到]") + + doc.add_paragraph() + + # 2. 耀斑区域 + glint_img_path = vis_dir / "glint_deglint_previews" / "glint_severe_glint_area_preview.png" + h3 = doc.add_heading("耀斑区域识别结果:", level=3) + self._style_heading(h3, level=3) + if glint_img_path.exists(): + self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) + else: + # 尝试查找其他可能的耀斑预览图 + glint_files = list(vis_dir.glob("glint_deglint_previews/*glint*.png")) + if glint_files: + glint_img_path = glint_files[0] + self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) + else: + doc.add_paragraph("[耀斑区域识别结果 - 文件未找到]") + + doc.add_paragraph() + + # 3. 去除耀斑后的图像 + deglint_img_path = vis_dir / "glint_deglint_previews" / "deglint_deglint_image_preview.png" + h3 = doc.add_heading("去除耀斑后的影像:", level=3) + self._style_heading(h3, level=3) + if deglint_img_path.exists(): + self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的高光谱影像", width=Inches(5.5)) + else: + # 尝试查找其他去耀斑预览图 + deglint_files = list(vis_dir.glob("glint_deglint_previews/*deglint*.png")) + if deglint_files: + deglint_img_path = deglint_files[0] + self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的影像", width=Inches(5.5)) + else: + doc.add_paragraph("[去除耀斑后的影像 - 文件未找到]") + + doc.add_paragraph() + + # 4. AI分析耀斑位置分布 + + self._style_heading(h3, level=3) + glint_analysis = self._analyze_glint_distribution_with_ai( + str(glint_img_path) if 'glint_img_path' in locals() and Path(str(glint_img_path)).exists() else None, + str(hyperspectral_img_path) if hyperspectral_img_path.exists() else None + ) + self._add_ai_analysis_paragraph(doc, glint_analysis) + + # 5. 采样点分布图 + sampling_map_dir = vis_dir / "sampling_maps" + h3 = doc.add_heading("采样点分布:", level=3) + self._style_heading(h3, level=3) + + # 查找采样点分布图文件 + sampling_map_files = [] + if sampling_map_dir.exists(): + sampling_map_files = list(sampling_map_dir.glob("*.png")) + list(sampling_map_dir.glob("*.jpg")) + + if sampling_map_files: + # 使用最新的采样点分布图文件 + latest_sampling_map = max(sampling_map_files, key=lambda p: p.stat().st_mtime) + success = self._add_image_with_caption(doc, str(latest_sampling_map), "图3-6 采样点分布图", width=Inches(5.5)) + + if success: + # AI 分析采样点分布图 + sampling_analysis = self._analyze_sampling_distribution(str(latest_sampling_map)) + self._add_ai_analysis_paragraph(doc, sampling_analysis) + else: + doc.add_paragraph("[采样点分布图 - 文件未找到]") + + def _analyze_glint_distribution_with_ai(self, glint_img_path: str = None, original_img_path: str = None) -> str: + """使用AI分析耀斑的位置分布""" + if not self.enable_ai_analysis: + return "AI分析已禁用。耀斑主要分布在水体表面强反射区域,通常出现在太阳光直射角度较大的位置。" + + try: + analysis_prompt = """请分析这张高光谱影像中的耀斑分布情况。 +请从以下几个方面进行专业分析: +1. 耀斑的主要分布位置(水体中心、边缘、特定方位等) +2. 耀斑面积占比估计 +3. 耀斑分布特征(集中分布还是分散分布) +4. 可能的成因分析 +5. 对水质参数反演的影响评估 + +请用专业且简洁的语言描述,控制在150字以内。""" + + if glint_img_path and Path(glint_img_path).exists(): + return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(glint_img_path)) + elif original_img_path and Path(original_img_path).exists(): + return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(original_img_path)) + else: + return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。" + + except Exception as e: + return f"AI分析失败: {str(e)}。耀斑主要分布在水体表面强反射区域,通常与太阳入射角和水面粗糙度有关。" + + def _analyze_flight_path_image(self, flight_img_path: str) -> str: + """ + 使用AI分析航线规划图 + + 分析内容: + 1. 架次数量 + 2. 每个架次的飞行方向 + 3. 图例中的飞行起始结束时间 + """ + if not self.enable_ai_analysis: + return "AI分析已禁用。根据航线规划图,可识别多个架次的飞行轨迹,每个架次具有不同的飞行方向和时间安排。" + + try: + if not Path(flight_img_path).exists(): + return "航线图文件不存在,无法进行分析。" + + analysis_prompt = """请详细分析这张航线规划图,并严格按照以下要求输出: + +分析要求: +1. 架次数量:明确指出图中有几个架次(几条不同颜色的轨迹线) +2. 飞行方向:描述每个架次的大致飞行方向(如:东西向、南北向、东北-西南向等) +3. 时间信息:从图例中提取每个架次的起始和结束时间 + +输出格式要求: +- 使用客观、准确的描述 +- 避免推测性语言(如"可能"、"也许") +- 控制在200字以内 +- 如果看不清具体时间,请明确说明"图例显示时间信息但具体数值不清晰" + +示例输出格式: +"飞行共有X个架次:架次1(红色):东西向飞行,时间范围XX:XX-XX:XX架次2(蓝色):南北向飞行,时间范围XX:XX-XX:XX +... +各架次轨迹分布合理,覆盖了目标水体区域。""" + + result = self._ai_chat( + self.ollama_vision_model, + "你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。", + analysis_prompt, + Path(flight_img_path) + ) + + # 如果返回内容为空或太短,使用默认文本 + if not result or len(result) < 20: + return "根据航线图分析,图中包含多个架次的飞行轨迹,各架次采用不同颜色标识,飞行方向各异,图例中标注了各架次的起始和结束时间。" + + return result + + except Exception as e: + return f"AI分析失败: {str(e)}。根据航线规划图,包含多个架次的飞行轨迹,各架次具有不同颜色和飞行方向,图例中标注了时间信息。" + + def _analyze_water_mask_overlay(self, water_mask_path: str) -> str: + """ + 使用AI分析水体区域识别图 + + 分析内容: + 1. 水体的分布情况(集中分布还是分散分布) + 2. 水体的位置和形状特征 + 3. 从图像标注中提取的水域面积和占比 + """ + if not self.enable_ai_analysis: + return "AI分析已禁用。根据水体区域识别图,蓝色半透明区域标识了水域范围,可观察到水体的分布情况和面积占比。" + + try: + if not Path(water_mask_path).exists(): + return "水体区域识别图文件不存在,无法进行分析。" + + analysis_prompt = """【背景说明】 +这是一座水库的遥感影像,水体区域以蓝色半透明标识。水库通常是人工筑坝蓄水形成,具有以下典型特征: +- 水体形态:较宽阔,形状相对规则,边界平滑 +- 大坝位置:通常位于水库最窄处或下游方向 +- 入库方向:上游河流汇入处,通常较窄或有分叉 +- 出水方向:大坝方向,水体在此处收窄 + +【分析维度】 +1. 水体整体形态:描述水库的形状(扇形、狭长形、不规则形、分叉形等),水体是集中还是分散? +2. 入库特征(重要):识别水体哪些位置有狭窄的入口或分叉——这些通常是河流入库的方向。描述入库位置(如东北角、西侧等)。 +3. 大坝/出水方向推断(重要):根据水体形态,判断大坝最可能的位置。通常在水体最窄处、或水体延伸的末端。推断流向是“从XX方向流向大坝(XX方向)”。 +4. 分支情况:是否有多个入库河流?是否有孤立水体? +5. 面积信息:从图像左上角标注中提取水域面积、影像总面积、水域占比。 + +【输出格式】 + 水体面积X.XX km² ,占比: X.X% ,形态: X。入库方向:XX方向(若有多个,依次列出)。出水/大坝方向:XX方向。流向推断:水体从XX方向汇入,流向大坝(XX方向)补充描述:[简要描述整体分布和形态特征] + +【示例输出】 +水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征""" + + result = self._ai_chat( + self.ollama_vision_model, + "你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。", + analysis_prompt, + Path(water_mask_path) + ) + + # 如果返回内容为空或太短,使用默认文本 + if not result or len(result) < 20: + return "根据水体区域识别图分析,蓝色半透明区域标识了水域范围。从图像标注可读取水域面积、影像总面积及水域占比信息,水体分布特征明显,便于后续水质参数反演分析。" + + return result + + except Exception as e: + return f"AI分析失败: {str(e)}。根据水体区域识别图,蓝色半透明区域标识了水域范围,图像左上角标注了水域面积、影像总面积及水域占比数据。" + + def _analyze_sampling_distribution(self, sampling_map_path: str) -> str: + """ + 使用AI分析采样点分布图 + + 分析内容: + 1. 采样点数量 + 2. 采样点在水体中的分布情况(均匀/集中、覆盖范围) + 3. 采样点的空间分布特征 + 4. 对水质反演代表性的评估 + """ + if not self.enable_ai_analysis: + return "AI分析已禁用。根据采样点分布图,红色点标识了采样点位置,可观察采样点在水体中的分布情况和覆盖范围。" + + try: + if not Path(sampling_map_path).exists(): + return "采样点分布图文件不存在,无法进行分析。" + + analysis_prompt = """请详细分析这张采样点分布图,并严格按照以下要求输出: + +【分析要求】 +1. 采样点数量:估算图中有多少个采样点(红色点) +2. 分布情况:描述采样点在水体中的分布是否均匀,是否有聚集或稀疏区域 +3. 覆盖范围:采样点是否覆盖了主要水域,是否有未覆盖的区域 +4. 空间特征:采样点分布在哪些方位(如上下游、左右岸等) +5. 代表性评估:简要评价当前采样点布局对水质参数反演的代表性 + +【输出格式要求】 +- 使用客观、准确的描述 +- 避免推测性语言 +- 控制在200字以内 + +【示例输出格式】 +"图中共有约XX个采样点,分布...,覆盖...,在...区域较为密集,...区域较为稀疏。 +采样点整体覆盖了主要水体区域,但在...区域采样不足。 +当前布局对水质反演具有较好的代表性,建议..." + +请根据图像内容给出专业分析。""" + + result = self._ai_chat( + self.ollama_vision_model, + "你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。", + analysis_prompt, + Path(sampling_map_path) + ) + + # 如果返回内容为空或太短,使用默认文本 + if not result or len(result) < 20: + return "根据采样点分布图分析,红色点标识了采样点位置,分布在水体各个区域。采样点覆盖范围较广,空间布局合理,能够较好地代表整体水质状况,为后续水质参数反演提供了可靠的数据基础。" + + return result + + except Exception as e: + return f"AI分析失败: {str(e)}。根据采样点分布图,红色点标识了采样点位置,分布在水体中,覆盖了主要水域区域,具有较好的代表性。" + + def _setup_header_and_footer(self, section): + """设置页眉:图片在最左侧 + 中间文字""" + header = section.header + + # 清空现有段落 + for paragraph in header.paragraphs: + p = paragraph._element + p.getparent().remove(p) + + # 创建新段落用于页眉 + header_para = header.add_paragraph() + + # 1. 最左侧图片 - 使用相对路径 + header_img_path = get_resource_path("data/icons/word/lica.png") + if os.path.isfile(header_img_path): + try: + run_img = header_para.add_run() + run_img.add_picture(str(header_img_path), width=Inches(1.6)) + except Exception as e: + print(f"页眉图片加载失败: {e}") + header_para.add_run("■ ") + else: + header_para.add_run("■ ") # 图片不存在时的占位 + + # 2. 中间文字 - “水质参数报告” + run_text = header_para.add_run(" 水质参数报告") + run_text.font.name = self.chinese_font + run_text.font.size = Pt(11) + run_text._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + # 左对齐,让图片在最左侧 + header_para.alignment = WD_ALIGN_PARAGRAPH.LEFT + + # 设置页眉边距 + section.header_distance = Cm(0.8) + + # 设置页脚页码 + footer = section.footer + footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # 添加页码字段 + run = footer_para.add_run() + fldChar1 = OxmlElement('w:fldChar') + fldChar1.set(qn('w:fldCharType'), 'begin') + run._element.append(fldChar1) + + instrText = OxmlElement('w:instrText') + instrText.text = 'PAGE' + run._element.append(instrText) + + fldChar2 = OxmlElement('w:fldChar') + fldChar2.set(qn('w:fldCharType'), 'end') + run._element.append(fldChar2) + + # 添加 "页" 字 + footer_para.add_run(' / ') + run2 = footer_para.add_run() + fldChar3 = OxmlElement('w:fldChar') + fldChar3.set(qn('w:fldCharType'), 'begin') + run2._element.append(fldChar3) + + instrText2 = OxmlElement('w:instrText') + instrText2.text = 'NUMPAGES' + run2._element.append(instrText2) + + fldChar4 = OxmlElement('w:fldChar') + fldChar4.set(qn('w:fldCharType'), 'end') + run2._element.append(fldChar4) + + footer_para.add_run(' 页') + + # 设置页脚字体 + for run in footer_para.runs: + run.font.size = Pt(9) + run.font.name = self.chinese_font + if hasattr(run, '_element') and hasattr(run._element, 'rPr'): + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + + def _add_result_analysis_section( + self, + doc, + vis_dir: Path, + start_figure_num: int = 1, + all_image_analyses: Optional[List[Dict[str, Any]]] = None, + progress=None, + ) -> int: + """添加结果分析章节 - 统计表格 + 相关性热力图(热力图在表格下方)""" + h1 = doc.add_heading("4 结果分析", level=1) + self._style_heading(h1, level=1) + + # 1. 添加统计分析表格(带编号) + h2 = doc.add_heading("4.1 水质参数统计分析", level=2) + self._style_heading(h2, level=2) + + # 从工作目录的4_processed_data文件夹查找CSV文件 + work_dir_path = vis_dir.parent + processed_data_dir = work_dir_path / "5_Data_Cleaning" + + if not processed_data_dir.exists(): + doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") + doc.add_page_break() + return start_figure_num + + csv_files = list(processed_data_dir.glob("*.csv")) + if not csv_files: + doc.add_paragraph(f"在 {processed_data_dir} 目录下未找到CSV统计数据文件。") + doc.add_page_break() + return start_figure_num + + csv_path = csv_files[0] # 使用找到的第一个CSV文件 + + try: + df_full = pd.read_csv(csv_path, sep=',') + df = df_full.iloc[:, 2:] # 跳过前两列(纬度、经度),直接用列号 + + # 自动统计剩余列 + stats_data = [] + for i in range(df.shape[1]): + col = df.columns[i] + clean_col = str(col).strip() + try: + data = df.iloc[:, i].dropna() + if len(data) > 0: + stats_data.append({ + '参数': clean_col, + '点位数': len(data), + '最大值': f"{data.max():.4f}", + '最小值': f"{data.min():.4f}", + '平均值': f"{data.mean():.4f}", + '标准差': f"{data.std():.4f}" + }) + except Exception as e: + print(f"跳过列 {col}: {e}") + + if stats_data: + # 创建统计表格 + table = doc.add_table(rows=1, cols=6, style='Table Grid') + hdr_cells = table.rows[0].cells + hdr_cells[0].text = '参数' + hdr_cells[1].text = '点位数' + hdr_cells[2].text = '最大值' + hdr_cells[3].text = '最小值' + hdr_cells[4].text = '平均值' + hdr_cells[5].text = '标准差' + + for stat in stats_data: + row_cells = table.add_row().cells + row_cells[0].text = stat['参数'] + row_cells[1].text = str(stat['点位数']) + row_cells[2].text = stat['最大值'] + row_cells[3].text = stat['最小值'] + row_cells[4].text = stat['平均值'] + row_cells[5].text = stat['标准差'] + + else: + doc.add_paragraph("CSV文件中未找到有效的参数数据。") + + except Exception as e: + doc.add_paragraph(f"读取CSV文件时出错: {str(e)}") + #增加空格 + doc.add_paragraph() + # 表格生成完成后,添加 AI 分析 + if stats_data: + analysis_text = self._analyze_statistics(stats_data, [s['参数'] for s in stats_data]) + self._add_ai_analysis_paragraph(doc, analysis_text) + + doc.add_paragraph() # 表格和热力图之间的空行 + + # 2. 添加相关性热力图(放在表格下方) + h3 = doc.add_heading("4.2 水质参数相关性分析", level=2) + self._style_heading(h3, level=2) + heatmap_path = vis_dir / "correlation_heatmap.png" + figure_num = start_figure_num + if heatmap_path.exists(): + try: + # 使用统一的图像插入方法 + caption_text = f"图{figure_num} 水质参数相关性热力图" + self._add_image_with_caption(doc, str(heatmap_path), caption_text, width=Inches(6.0)) + doc.add_paragraph("(颜色越深表示相关性越强,红色为正相关,蓝色为负相关)") + + analysis_text = self._analyze_and_cache_image( + image_path=heatmap_path, + image_type="correlation_heatmap", + param="综合", + figure_num=figure_num, + ) + self._add_ai_analysis_paragraph(doc, analysis_text) + if all_image_analyses is not None: + all_image_analyses.append( + { + "figure_num": figure_num, + "param": "综合", + "image_type": "correlation_heatmap", + "image_name": heatmap_path.name, + "analysis": analysis_text, + } + ) + except Exception as e: + doc.add_paragraph(f"[相关性热力图插入失败: {e}]") + else: + doc.add_paragraph(f"[未找到相关性热力图: {heatmap_path.name}]") + + # 热力图处理结束(无论成功/失败)更新进度条 + try: + if progress is not None: + progress.update(1) + except Exception: + pass + + doc.add_page_break() + return start_figure_num + (1 if heatmap_path.exists() else 0) + + def _add_physical_inversion_section( + self, + doc: Document, + work_dir: Path, + start_figure_num: int = 1, + all_image_analyses: Optional[List[Dict[str, Any]]] = None, + progress=None, + ) -> int: + """新增章节:物理模型反演浓度统计与分析(第4节之后)""" + conc_dir = work_dir / "9_Concentration" + if not conc_dir.is_dir(): + doc.add_paragraph("[物理反演浓度章节:9_Concentration 目录不存在,已跳过]") + return start_figure_num + + stats_csv = conc_dir / "statistics_summary.csv" + charts_dir = conc_dir / "charts" + + h = doc.add_heading("4.1 物理模型反演浓度统计与分析", level=2) + self._style_heading(h, level=2) + + fig_num = start_figure_num + + if stats_csv.is_file(): + try: + stats_df = pd.read_csv(stats_csv) + table = doc.add_table(rows=1, cols=len(stats_df.columns)) + table.style = "Table Grid" + hdr_cells = table.rows[0].cells + for i, col_name in enumerate(stats_df.columns): + hdr_cells[i].text = str(col_name) + for run in hdr_cells[i].paragraphs[0].runs: + run.font.name = self.chinese_font + run.font.size = Pt(10) + run.font.bold = True + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + for _, row_data in stats_df.iterrows(): + row_cells = table.add_row().cells + for i, val in enumerate(row_data): + row_cells[i].text = str(val) + for run in row_cells[i].paragraphs[0].runs: + run.font.name = self.chinese_font + run.font.size = Pt(10) + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + doc.add_paragraph() + except Exception as e: + doc.add_paragraph(f"[浓度统计表插入失败: {e}]") + else: + doc.add_paragraph("[浓度统计表不存在: statistics_summary.csv]") + + if charts_dir.is_dir(): + image_extensions = ['*.png', '*.jpg', '*.jpeg'] # 严格剔除 tif/tiff + chart_files: List[Path] = [] + for ext in image_extensions: + chart_files.extend(sorted(charts_dir.glob(ext))) + for chart_file in chart_files: + caption_text = f"图{fig_num} {chart_file.stem} 分布图" + if self._add_image_with_caption(doc, str(chart_file), caption_text, width=Inches(5.5)): + if all_image_analyses is not None: + image_type = "boxplot" if "boxplot" in chart_file.stem.lower() else "distribution" + analysis_text = self._analyze_and_cache_image( + image_path=chart_file, + image_type=image_type, + param=chart_file.stem, + figure_num=fig_num, + ) + self._add_ai_analysis_paragraph(doc, analysis_text) + all_image_analyses.append({ + "figure_num": fig_num, + "param": chart_file.stem, + "image_type": image_type, + "image_name": chart_file.name, + "analysis": analysis_text, + }) + fig_num += 1 + try: + if progress is not None: + progress.update(1) + except Exception: + pass + else: + doc.add_paragraph("[浓度图表目录不存在: 9_Concentration/charts/]") + + return fig_num + +# ==================== 使用示例 ==================== + +def generate_full_water_quality_report( + work_dir: str = "./work_dir", + ai_config: Optional[ReportGenerationConfig] = None, +): + """生成包含所有水质参数的完整报告。""" + generator = WaterQualityReportGenerator(work_dir=work_dir, ai_config=ai_config) + return generator.generate_report( + work_dir=work_dir, + parameters=None, + report_title="水质参数反演分析完整报告", + ) + + +if __name__ == "__main__": + # 默认生成完整报告(包含所有13个水质参数) + report_path = generate_full_water_quality_report() + print(f"完整水质报告已生成: {report_path}") \ No newline at end of file diff --git a/src/postprocessing/visualization_reports.py b/src/postprocessing/visualization_reports.py index 932f9f2..ee2ff91 100644 --- a/src/postprocessing/visualization_reports.py +++ b/src/postprocessing/visualization_reports.py @@ -442,7 +442,7 @@ class WaterQualityVisualization: - 2_Glint_Detection文件夹:单波段二值耀斑掩膜,使用红色高亮显示 - 3_deglint文件夹:多波段去耀斑影像,使用RGB合成显示 - 自动识别文件类型并应用相应的可视化方案 - - 输出保存至14_visualization/glint_deglint_previews/ + - 输出保存至12_visualization/glint_deglint_previews/ Args: work_dir: 工作目录路径 @@ -732,7 +732,7 @@ class WaterQualityVisualization: from src.postprocessing.point_map import SamplingPointMap # 如果没有提供路径,自动查找 - work_dir = self.output_dir.parent # 14_visualization的父目录就是工作目录 + work_dir = self.output_dir.parent # 12_visualization的父目录就是工作目录 if hyperspectral_path is None: # 查找高光谱影像 diff --git a/src/utils/band_math.py b/src/utils/band_math.py index cb75941..34db60d 100644 --- a/src/utils/band_math.py +++ b/src/utils/band_math.py @@ -41,12 +41,12 @@ class BandMathCalculator: closest_index = valid_indices[min_diff_index] closest_wavelength = self.wavelengths[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]})") + # 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 19b0f24..2e1bd96 100644 --- a/src/utils/water_index.py +++ b/src/utils/water_index.py @@ -6,8 +6,6 @@ import re from pathlib import Path from typing import Dict, List, Optional, Union -from .band_math import BandMathCalculator - def _get_resource_path(relative_path: str) -> str: """获取资源文件路径,兼容开发/PyInstaller onedir/onefile 三种环境。""" @@ -92,41 +90,58 @@ class WaterQualityIndexCalculator: def _band_math_all_rows(self, df: pd.DataFrame, expression: str, wavelength_offset: float = 0.0) -> pd.Series: """ - 使用 BandMathCalculator 的公式计算引擎,在整个 DataFrame 上批量求值。 + 向量化批量计算波段表达式(2026-07-01 重写:逐行 eval → 全列 numpy)。 - Args: - df: 输入光谱数据(列名为 wNNN 格式) - expression: 波段计算表达式,如 "(w715 - w686) / (w715 + w686)" - wavelength_offset: 波长偏移修正量(nm) - - Returns: - pd.Series,与 df 等长的计算结果 + 原先逐行 eval + re.sub 在 11310 行 × 63 公式时超过 600 秒; + 现在一次性解析表达式为 numpy 操作,全 DataFrame 向量化计算, + 同等数据量下 < 1 秒。 """ - calc = BandMathCalculator.__new__(BandMathCalculator) - calc.df = df.copy() - calc.wavelengths = calc._extract_wavelengths() - calc.wavelength_offset = float(wavelength_offset) + # ── 1. 从列名提取波长列表 ── + wavelengths = [] + for col in df.columns: + nums = re.findall(r'\d+\.?\d*', str(col)) + wavelengths.append(float(nums[0]) if nums else None) - variables = calc._parse_expression(expression) - results = [] - for i in range(len(calc.df)): - sub_dict = calc._create_substitution_dict(variables, i) - calc_expr = expression - for var_pattern, value in sub_dict.items(): - calc_expr = re.sub( - r'\b' + re.escape(var_pattern) + r'\b', - f"({value})", - calc_expr, - ) - try: - # 【P0 修复】包 np.errstate 抑制除零 / 无效操作产生的 RuntimeWarning 洪水 - with np.errstate(divide='ignore', invalid='ignore'): - r = eval(calc_expr, {"__builtins__": None}, {"nan": np.nan, "inf": np.inf, "np": np}) - except Exception: - r = np.nan - results.append(r) + # ── 2. 解析表达式中的变量 (wNNN / WNNN) → 找到对应列索引 ── + var_pattern = r'[wW](\d+\.?\d*)' + var_matches = re.findall(var_pattern, expression) + col_map = {} # {原始变量文本: 列索引} + for var_str in var_matches: + target_wl = float(var_str) + wavelength_offset + valid = [(i, wl) for i, wl in enumerate(wavelengths) if wl is not None] + if not valid: + raise ValueError("未找到有效的波长列") + best_idx = min(valid, key=lambda x: abs(x[1] - target_wl))[0] + # 同时覆盖 wNNN 和 WNNN 两种写法 + col_map[f'w{var_str}'] = best_idx + col_map[f'W{var_str}'] = best_idx - return pd.Series(results, index=df.index, name=expression) + # ── 3. 构建带缓存的向量化 numpy 表达式 ── + # 注意:w715 这种变量在 Python 中是合法标识符,但 eval 中会当变量名; + # 我们直接替换为 arr[:, col_idx] 再传给 eval,确保一次性全列计算。 + arr = df.values # (N, M) numpy array,避免重复 .iloc 访问 + eval_expr = expression + # 按变量名长度降序替换,防止短变量吞噬长变量前缀(如 w715 先于 w71) + for var_name in sorted(col_map.keys(), key=len, reverse=True): + col_idx = col_map[var_name] + # 替换为安全的列引用,匹配完整 token(用 \b 边界) + eval_expr = re.sub( + r'\b' + re.escape(var_name) + r'\b', + f'arr[:, {col_idx}]', + eval_expr, + ) + + # ── 4. 一次性向量化求值 ── + try: + with np.errstate(divide='ignore', invalid='ignore'): + result = eval(eval_expr, {"__builtins__": None}, {"arr": arr, "np": np}) + except Exception: + # 回退:返回全 NaN 列 + result = np.full(len(df), np.nan) + + # 确保结果是一维的 + result = np.asarray(result).ravel() + return pd.Series(result, index=df.index, name=expression) def calculate_one(self, name: str, df: pd.DataFrame, wavelength_offset: float = 0.0) -> pd.Series: """