diff --git a/src/postprocessing/report_word.py b/src/postprocessing/report_word.py index 77662d3..ba9563c 100644 --- a/src/postprocessing/report_word.py +++ b/src/postprocessing/report_word.py @@ -1,1789 +1,1837 @@ -#!/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 - - -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_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", "") - ) - self.minimax_base_url = ( - os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1/text/chatcompletion_v2").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_enhanced.png" - ] for param in params_list - } - - 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 文本模型 /v1/text/chatcompletion_v2。""" - if not self.minimax_api_key: - return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)" - - payload: Dict[str, Any] = { - "model": self.minimax_text_model, - "max_tokens": 4096, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - } - - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = Request( - url=self.minimax_base_url, - data=data, - headers={ - "Authorization": f"Bearer {self.minimax_api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - - try: - with urlopen(req, timeout=self.minimax_timeout_s) as resp: - raw = resp.read().decode("utf-8", errors="ignore") - obj = json.loads(raw) - return ( - obj.get("choices", [{}])[0] - .get("message", {}) - .get("content", "") - .strip() - or "(模型未返回内容)" - ) - except HTTPError as e: - body = e.read().decode("utf-8", errors="ignore") - print(f"[Minimax HTTP {e.code}] {body}") - return f"(Minimax调用失败 HTTP {e.code}:{e.reason})" - except (URLError, TimeoutError) as e: - return f"(Minimax调用失败:{e})" - except Exception as e: - return f"(Minimax解析失败:{e})" - - def _call_minimax_vision(self, system_prompt: str, user_prompt: str, image_path: Path) -> str: - """调用 Minimax 视觉模型(多模态),图片转为 base64 后通过 image_url 传入。""" - if not self.minimax_api_key: - return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)" - - try: - img_bytes = image_path.read_bytes() - img_b64 = base64.b64encode(img_bytes).decode("utf-8") - except Exception as e: - return f"(读取图片失败:{e})" - - payload: Dict[str, Any] = { - "model": self.minimax_vision_model, - "max_tokens": 4096, - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": user_prompt}, - { - "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}, - }, - ], - } - ], - } - - if system_prompt: - payload["messages"].insert( - 0, - {"role": "system", "content": system_prompt}, - ) - - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = Request( - url=self.minimax_base_url, - data=data, - headers={ - "Authorization": f"Bearer {self.minimax_api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - - try: - with urlopen(req, timeout=self.minimax_timeout_s) as resp: - raw = resp.read().decode("utf-8", errors="ignore") - obj = json.loads(raw) - return ( - obj.get("choices", [{}])[0] - .get("message", {}) - .get("content", "") - .strip() - or "(模型未返回内容)" - ) - except HTTPError as e: - body = e.read().decode("utf-8", errors="ignore") - print(f"[Minimax Vision HTTP {e.code}] {body}") - return f"(Minimax Vision调用失败 HTTP {e.code}:{e.reason})" - except (URLError, TimeoutError) as e: - return f"(Minimax Vision调用失败:{e})" - except Exception as e: - return f"(Minimax 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 == "minimax": - 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) - else: - return self._ollama_chat(model, system_prompt, user_prompt, image_path) - - 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: - return "(AI分析已关闭)" - if not image_path.exists(): - return "(图片不存在,无法分析)" - - cache = self._load_ai_cache() - cache_key = f"{image_path.name}::{image_path.stat().st_mtime_ns}::{self.ollama_vision_model}::{image_type}" - if cache_key in cache: - return str(cache[cache_key]) - - prompts = self._get_prompt_for_image(image_type=image_type, param=param, figure_num=figure_num) - text = self._ai_chat( - model=self.ollama_vision_model, - system_prompt=prompts["system"], - user_prompt=prompts["user"], - image_path=image_path, - ) - cache[cache_key] = text - 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, - ): - """为单个参数添加报告章节(带编号和规范中英文图题)""" - if param not in self.parameter_descriptions: - print(f"警告: 参数 {param} 没有预定义的描述") - return start_figure_num - - # 添加带编号的参数标题 - heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) - self._style_heading(heading, level=1) - - # 添加参数描述 - desc_para = doc.add_paragraph(self.parameter_descriptions[param]) - desc_para.paragraph_format.space_after = Pt(12) - - # 设置首行缩进两个字符(中文排版规范) - desc_para.paragraph_format.first_line_indent = Pt(24) - - # 设置正文样式:宋体小四,1.5倍行距 - desc_para.paragraph_format.line_spacing = 1.5 - for run in desc_para.runs: - run.font.name = 'SimSun' - run.font.size = Pt(12) - run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') - - # 添加图片 - 支持子文件夹结构 + 中英文图题 - if param in self.parameter_images: - image_list = self.parameter_images[param] - title_map = { - "histogram": "直方图", - "spectrum_comparison": "光谱对比图", - "scatter_with_confidence": "模型散点图", - "boxplot": "箱型图", - "distribution": "分布图" - } - - for i, img_name in enumerate(image_list): - figure_num = start_figure_num + i - - - # 选择子文件夹与动态寻址 - if "boxplot" in img_name.lower(): - title_key = "boxplot" - img_path = vis_dir / img_name - elif "scatter" in img_name.lower() or "pred" in img_name.lower() or "confidence" in img_name.lower(): - title_key = "scatter_with_confidence" - img_path = vis_dir / "scatter_plots" / img_name - if not img_path.exists(): - img_path = vis_dir / img_name - elif "histogram" in img_name.lower(): - title_key = "histogram" - img_path = vis_dir / img_name - elif "spectrum" in img_name.lower(): - title_key = "spectrum_comparison" - img_path = vis_dir / img_name - elif "distribution" in img_name.lower(): - title_key = "distribution" - # 颜色地图由预测步骤生成,开启全盘指索 - search_dirs = [ - vis_dir.parent / "9_water_quality_prediction", - vis_dir.parent / "11_12_13_predictions", - vis_dir.parent / "9_Concentration" / "charts", - vis_dir.parent / "9_Concentration", - vis_dir - ] - found_map = False - for s_dir in search_dirs: - if s_dir.exists(): - candidates = list(s_dir.glob(f"*{param}*.png")) + list(s_dir.glob(f"*{param}*.jpg")) - # 剔除掉属于其他类型的图 - candidates = [c for c in candidates if not any(x in c.name.lower() for x in ("scatter", "histogram", "spectrum", "boxplot", "preview"))] - if candidates: - img_path = candidates[0] - found_map = True - break - if not found_map: - img_path = vis_dir / img_name - else: - title_key = "histogram" - img_path = vis_dir / img_name - if img_path.exists(): - param_cn = param.replace("Chlorophyll", "叶绿素").replace("NO3-N", "硝酸盐氮").replace("NH3-N", "氨氮") - cn_title = title_map.get(title_key, "分析图") - - # 使用统一的图像插入方法(中文图题) - caption_text = f"图{figure_num} {param_cn}{cn_title}" - self._add_image_with_caption(doc, str(img_path), caption_text, width=Inches(6.0)) - - # 添加英文图题:宋体小四(与中文图题一致) - en_title = title_key.replace('_', ' ').title() - caption_en = doc.add_paragraph(f"Figure {figure_num} {param} {en_title}") - caption_en.style = 'Caption' - caption_en.alignment = WD_ALIGN_PARAGRAPH.CENTER - caption_en.paragraph_format.space_after = Pt(8) - self._style_figure_caption_simsun_xiaosi(caption_en) - for run in caption_en.runs: - run.font.color.rgb = RGBColor(0, 0, 0) - - # AI 分析:插入在图题之后 - analysis_text = self._analyze_and_cache_image( - image_path=img_path, - image_type=title_key, - param=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": param, - "image_type": title_key, - "image_name": img_name, - "analysis": analysis_text, - } - ) - else: - error_para = doc.add_paragraph(f"[图片未找到: {img_name} (已搜索根目录和对应子文件夹)]") - error_para.paragraph_format.first_line_indent = Pt(0) - for run in error_para.runs: - run.font.name = self.chinese_font - if hasattr(run._element, 'rPr'): - run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) - - # 每处理完一张图(无论成功/失败)更新进度条 - try: - if progress is not None: - progress.update(1) - except Exception: - pass - - 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_goodman_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}") +#!/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_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", "") + ) + self.minimax_base_url = ( + os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1/text/chatcompletion_v2").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: + return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)" + + url = self.minimax_base_url + if "chatcompletion_v2" in url: + url = "https://api.minimax.chat/v1/chat/completions" + + payload: Dict[str, Any] = { + "model": self.minimax_text_model, + "max_tokens": 4096, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = Request( + url=url, + data=data, + headers={ + "Authorization": f"Bearer {self.minimax_api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urlopen(req, timeout=self.minimax_timeout_s) as resp: + raw = resp.read().decode("utf-8", errors="ignore") + obj = json.loads(raw) + + if "base_resp" in obj and obj["base_resp"].get("status_code", 0) != 0: + return f"(Minimax API 拒绝请求:{obj['base_resp'].get('status_msg')})" + if "error" in obj: + err = obj["error"] + msg = err.get("message", str(err)) if isinstance(err, dict) else str(err) + return f"(API 报错:{msg})" + + choices = obj.get("choices") + if not choices: + return obj.get("reply", "").strip() or f"(API 返回异常:{raw[:100]})" + + choice = choices[0] + if "message" in choice: + content = choice["message"].get("content", "") + return content.strip() if isinstance(content, str) else str(content).strip() or "(模型未返回有效内容)" + if "messages" in choice: + msgs = choice["messages"] + if msgs: + return msgs[0].get("text", "").strip() or "(模型未返回有效内容)" + return "(模型未返回有效内容)" + except HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + print(f"[Minimax HTTP {e.code}] {body}") + return f"(Minimax调用失败 HTTP {e.code}:{e.reason})" + except (URLError, TimeoutError) as e: + return f"(Minimax调用失败:{e})" + except Exception as e: + return f"(Minimax解析失败:{e})" + def _call_minimax_vision(self, system_prompt: str, user_prompt: str, image_path: Path) -> str: + """调用 Minimax 视觉模型(多模态),图片转为 base64 后传入(自动兼容 OpenAI 标准端点)""" + if not self.minimax_api_key: + return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)" + + try: + img_bytes = image_path.read_bytes() + img_b64 = base64.b64encode(img_bytes).decode("utf-8") + except Exception as e: + return f"(读取图片失败:{e})" + + url = self.minimax_base_url + if "chatcompletion_v2" in url: + url = "https://api.minimax.chat/v1/chat/completions" + + payload: Dict[str, Any] = { + "model": self.minimax_vision_model, + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}, + }, + ], + } + ], + } + + if system_prompt: + payload["messages"].insert( + 0, + {"role": "system", "content": system_prompt}, + ) + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = Request( + url=url, + data=data, + headers={ + "Authorization": f"Bearer {self.minimax_api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urlopen(req, timeout=self.minimax_timeout_s) as resp: + raw = resp.read().decode("utf-8", errors="ignore") + obj = json.loads(raw) + + if "base_resp" in obj and obj["base_resp"].get("status_code", 0) != 0: + return f"(Minimax API 拒绝请求:{obj['base_resp'].get('status_msg')})" + if "error" in obj: + err = obj["error"] + msg = err.get("message", str(err)) if isinstance(err, dict) else str(err) + return f"(API 报错:{msg})" + + choices = obj.get("choices") + if not choices: + return obj.get("reply", "").strip() or f"(API 返回异常:{raw[:100]})" + + choice = choices[0] + if "message" in choice: + content = choice["message"].get("content", "") + return content.strip() if isinstance(content, str) else str(content).strip() or "(模型未返回有效内容)" + if "messages" in choice: + msgs = choice["messages"] + if msgs: + return msgs[0].get("text", "").strip() or "(模型未返回有效内容)" + return "(模型未返回有效内容)" + except HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + print(f"[Minimax Vision HTTP {e.code}] {body}") + return f"(Minimax Vision调用失败 HTTP {e.code}:{e.reason})" + except (URLError, TimeoutError) as e: + return f"(Minimax Vision调用失败:{e})" + except Exception as e: + return f"(Minimax 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 == "minimax": + 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) + else: + return self._ollama_chat(model, system_prompt, user_prompt, image_path) + + 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: + return "(AI分析已关闭)" + if not image_path.exists(): + return "(图片不存在,无法分析)" + + model_name = self.minimax_vision_model if self.ai_provider == "minimax" else self.ollama_vision_model + cache_key = f"{image_path.name}::{image_path.stat().st_mtime_ns}::{model_name}::{image_type}" + + # 1. 线程安全地读取缓存 + with self._cache_lock: + cache = self._load_ai_cache() + if cache_key in cache: + cached_text = str(cache[cache_key]) + # 【核心修复3】缓存里如果是之前存入的报错(以全角或半角左括号开头),视为失效,强制重新请求 + if not (cached_text.startswith("(") or cached_text.startswith("(")): + return cached_text + + # 2. 耗时的网络/本地 AI 请求(不加锁,让它们并发运行) + prompts = self._get_prompt_for_image(image_type=image_type, param=param, figure_num=figure_num) + text = self._ai_chat( + model=model_name, + system_prompt=prompts["system"], + user_prompt=prompts["user"], + image_path=image_path, + ) + + # 3. 线程安全地写入缓存 + # 只有在 API 成功返回(没有报错括号开头)时,才存入缓存,彻底杜绝缓存毒化 + if not (text.startswith("(") or text.startswith("(")): + with self._cache_lock: + cache = self._load_ai_cache() + cache[cache_key] = text + 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 + + # 添加带编号的参数标题 + heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) + self._style_heading(heading, level=1) + + desc_para = doc.add_paragraph(self.parameter_descriptions[param]) + desc_para.paragraph_format.space_after = Pt(12) + desc_para.paragraph_format.first_line_indent = Pt(24) + desc_para.paragraph_format.line_spacing = 1.5 + for run in desc_para.runs: + run.font.name = 'SimSun' + run.font.size = Pt(12) + run._element.rPr.rFonts.set(qn('w:eastAsia'), 'SimSun') + + if param in self.parameter_images: + image_list = self.parameter_images[param] + title_map = { + "histogram": "直方图", "spectrum_comparison": "光谱对比图", + "scatter_with_confidence": "模型散点图", "boxplot": "箱型图", "distribution": "分布图" + } + + # ======================================================== + # 阶段 1:扫描所有可用图片,并发丢给 AI 去看图(预热缓存) + # ======================================================== + tasks_for_ai = [] + valid_images_info = [] + + for i, img_name in enumerate(image_list): + figure_num = start_figure_num + i + + # 动态寻址 + if "boxplot" in img_name.lower(): + title_key = "boxplot" + img_path = vis_dir / "boxplots" / img_name + if not img_path.exists(): img_path = vis_dir / img_name + elif "scatter" in img_name.lower() or "pred" in img_name.lower() or "confidence" in img_name.lower(): + title_key = "scatter_with_confidence" + img_path = vis_dir / "scatter_plots" / img_name + if not img_path.exists(): img_path = vis_dir / img_name + elif "histogram" in img_name.lower(): + title_key = "histogram" + img_path = vis_dir / "boxplots" / img_name + if not img_path.exists(): img_path = vis_dir / img_name + elif "spectrum" in img_name.lower(): + title_key = "spectrum_comparison" + img_path = vis_dir / img_name + elif "distribution" in img_name.lower(): + title_key = "distribution" + search_dirs = [vis_dir / "distribution_maps", vis_dir.parent / "11_Thematic_Map", vis_dir] + found_map = False + for s_dir in search_dirs: + if s_dir.exists(): + candidates = list(s_dir.glob(f"*{param}*rendered*.png")) + list(s_dir.glob(f"*{param}*distribution*.png")) + candidates = [c for c in candidates if not any(x in c.name.lower() for x in ("scatter", "histogram", "spectrum", "boxplot", "preview", "heatmap"))] + if candidates: + img_path = candidates[0] + found_map = True + break + if not found_map: + img_path = vis_dir / "distribution_maps" / img_name + else: + title_key = "histogram" + img_path = vis_dir / img_name + + if img_path.exists(): + tasks_for_ai.append((img_path, title_key, param, figure_num)) + valid_images_info.append((img_path, title_key, img_name, figure_num)) + else: + valid_images_info.append((None, None, img_name, figure_num)) + + # 执行并发请求(开启 5 个并发线程) + if tasks_for_ai and self.enable_ai_analysis: + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(self._analyze_and_cache_image, p, k, pa, f) + for p, k, pa, f in tasks_for_ai] + # 阻塞:等这 5 张图全部分析完,结果已写入 JSON 缓存 + for _ in as_completed(futures): + pass + + # ======================================================== + # 阶段 2:瞬间排版!由于缓存已满,这里几乎是秒完成的 + # ======================================================== + for info in valid_images_info: + img_path, title_key, img_name, figure_num = info + if img_path is not None: + param_cn = param.replace("Chlorophyll", "叶绿素").replace("NO3-N", "硝酸盐氮").replace("NH3-N", "氨氮") + cn_title = title_map.get(title_key, "分析图") + + caption_text = f"图{figure_num} {param_cn}{cn_title}" + self._add_image_with_caption(doc, str(img_path), caption_text, width=Inches(6.0)) + + en_title = title_key.replace('_', ' ').title() + caption_en = doc.add_paragraph(f"Figure {figure_num} {param} {en_title}") + caption_en.style = 'Caption' + caption_en.alignment = WD_ALIGN_PARAGRAPH.CENTER + caption_en.paragraph_format.space_after = Pt(8) + self._style_figure_caption_simsun_xiaosi(caption_en) + for run in caption_en.runs: + run.font.color.rgb = RGBColor(0, 0, 0) + + # 此时调用瞬间完成,100% 命中刚才多线程拉回来的缓存 + analysis_text = self._analyze_and_cache_image( + image_path=img_path, image_type=title_key, param=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": param, + "image_type": title_key, "image_name": img_name, + "analysis": analysis_text, + }) + else: + error_para = doc.add_paragraph(f"[图片未找到: {img_name} (已搜索根目录和对应子文件夹)]") + error_para.paragraph_format.first_line_indent = Pt(0) + for run in error_para.runs: + run.font.name = self.chinese_font + if hasattr(run._element, 'rPr'): + run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) + + try: + if progress is not None: + progress.update(1) + except Exception: + pass + + 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}") + \ No newline at end of file