#!/usr/bin/env python # -*- coding: utf-8 -*- """ 水质参数反演分析 Word 报告生成模块 根据 visualization_reports.py 生成的图片,自动生成结构化 Word 报告 """ import os import sys import base64 import json from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Any from datetime import datetime from urllib.request import Request, urlopen from urllib.error import URLError, HTTPError from docx import Document from docx.shared import Inches, Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.section import WD_SECTION from docx.oxml.ns import qn def get_resource_path(relative_path: str) -> str: """获取资源的绝对路径,适配 PyInstaller 打包环境。""" if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) return os.path.abspath( os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), relative_path) ) from docx.oxml import OxmlElement from docx.shared import RGBColor import pandas as pd # === 新增并发与锁 === from concurrent.futures import ThreadPoolExecutor, as_completed from threading import Lock class _SimpleProgress: """无依赖进度条(控制台单行刷新,支持 Qt 回调上递)。""" def __init__(self, total: int, desc: str = "", on_step=None): self.total = max(1, int(total)) self.desc = desc self.on_step = on_step self.n = 0 self._render() def update(self, step: int = 1): self.n = min(self.total, self.n + int(step)) self._render() def set_description(self, text: str): """动态更新进度描述文案(用于按段切换分析对象)。""" self.desc = text def close(self): # 换行,避免覆盖后续输出 print() def _render(self): pct = int(self.n / self.total * 100) bar_len = 30 filled = int(bar_len * self.n / self.total) bar = "█" * filled + "·" * (bar_len - filled) prefix = f"{self.desc} " if self.desc else "" print(f"\r{prefix}[{bar}] {self.n}/{self.total} ({pct}%)", end="", flush=True) if self.on_step: try: self.on_step(pct, self.desc) except Exception: pass @dataclass class ReportGenerationConfig: """ 报告生成与 AI 分析的可选配置。 支持 Ollama 和 Minimax 两种后端,通过 AI_PROVIDER 环境变量切换。 未设置的字段沿用环境变量或生成器默认值。 """ # 通用 ai_provider: Optional[str] = None # "ollama" | "minimax",默认 "minimax" enable_ai_analysis: Optional[bool] = None # Ollama 专属 ollama_base_url: Optional[str] = None ollama_vision_model: Optional[str] = None ollama_text_model: Optional[str] = None ollama_timeout_s: Optional[int] = None # Minimax 专属 minimax_api_key: Optional[str] = None minimax_base_url: Optional[str] = None # <--- 新增这行 minimax_vision_model: Optional[str] = None minimax_text_model: Optional[str] = None minimax_timeout_s: Optional[int] = None class WaterQualityReportGenerator: """水质参数 Word 报告生成器""" def __init__( self, output_dir: str = None, work_dir: str = None, ai_config: Optional[ReportGenerationConfig] = None, ): # 设置工作目录(整个流程的核心目录,所有数据基于此) if work_dir is None: self.work_dir = Path("./work_dir") else: self.work_dir = Path(work_dir) # 基于工作目录设置各子目录 self.visualization_dir = self.work_dir / "12_visualization" # 设置报告保存位置:默认为可视化目录(visualization_dir) self._output_dir_is_default = output_dir is None if output_dir is None: self.output_dir = self.visualization_dir else: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) # 设置中文字体支持 self.chinese_font = 'SimSun' # 宋体 self.title_font = 'SimHei' # 黑体 self.english_font = 'Times New Roman' # 英文 cfg = ai_config # AI Provider 选择:默认 "minimax" self.ai_provider = ( cfg.ai_provider if cfg and cfg.ai_provider else os.environ.get("AI_PROVIDER", "minimax").lower() ) # Ollama 配置 default_url = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/") self.ollama_base_url = ( cfg.ollama_base_url.rstrip("/") if cfg and cfg.ollama_base_url else default_url ) self.ollama_vision_model = ( cfg.ollama_vision_model if cfg and cfg.ollama_vision_model else os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b") ) self.ollama_text_model = ( cfg.ollama_text_model if cfg and cfg.ollama_text_model else os.environ.get("OLLAMA_TEXT_MODEL", self.ollama_vision_model) ) self.ollama_timeout_s = ( int(cfg.ollama_timeout_s) if cfg and cfg.ollama_timeout_s is not None else int(os.environ.get("OLLAMA_TIMEOUT_S", "120")) ) # Minimax 配置 self.minimax_api_key = ( cfg.minimax_api_key if cfg and cfg.minimax_api_key else os.environ.get("MINIMAX_API_KEY", "") ) # 接收外部传入的万能 URL,默认给一个国际标准的 completions 端点 self.minimax_base_url = ( cfg.minimax_base_url.rstrip("/") if cfg and getattr(cfg, 'minimax_base_url', None) else os.environ.get("MINIMAX_BASE_URL", "https://api.openai.com/v1/chat/completions").rstrip("/") ) self.minimax_vision_model = ( cfg.minimax_vision_model if cfg and cfg.minimax_vision_model else os.environ.get("MINIMAX_VISION_MODEL", "abab6.5s-chat") ) self.minimax_text_model = ( cfg.minimax_text_model if cfg and cfg.minimax_text_model else os.environ.get("MINIMAX_TEXT_MODEL", "abab6.5s-chat") ) self.minimax_timeout_s = ( int(cfg.minimax_timeout_s) if cfg and cfg.minimax_timeout_s is not None else int(os.environ.get("MINIMAX_TIMEOUT_S", "120")) ) # 通用配置 if cfg and cfg.enable_ai_analysis is not None: self.enable_ai_analysis = bool(cfg.enable_ai_analysis) else: self.enable_ai_analysis = os.environ.get("ENABLE_AI_ANALYSIS", "1") not in { "0", "false", "False", } self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" # 各参数的专业描述(完整版) self.parameter_descriptions = { "Chlorophyll": """叶绿素(Chlorophyll)是浮游植物进行光合作用的关键色素,直接反映水体中藻类的生物量与初级生产力水平。它是评价水体富营养化程度最常用的指标之一。当叶绿素浓度持续升高时,表明藻类大量增殖,水华风险显著增加,并可能引发溶解氧剧烈波动、水体透明度下降及底栖生态系统退化。因此,通过遥感手段反演叶绿素浓度,可为水华预警、水质改善及生态修复提供重要科学依据。""", "COD": """化学需氧量(COD)是衡量水体中有机污染物含量的综合指标,反映单位体积水体中还原性物质(主要是有机物)被氧化所消耗的氧化剂总量。COD值越高,表明水体受有机污染越严重。高COD会加剧溶解氧消耗,导致水体缺氧、水生生物死亡,甚至引发黑臭现象。COD也是污水处理效果和污染物排放管控的关键考核指标,其时空分布可为污染源识别与治理提供直接依据。""", "DO": """溶解氧(DO)是维持水生生态系统健康的基础物质,指溶解在水中的分子态氧。其浓度受水温、盐度、藻类光合作用及有机物耗氧过程共同调控。DO低于一定阈值会导致水生生物窒息、底泥营养盐释放及水体自净能力下降。DO的实时监测与空间分布反演,对判断水体污染程度、预警鱼类死亡事件及评估生态修复成效具有重要价值。""", "PH": """pH值是反映水体酸碱度的无量纲参数,直接影响水中化学形态、微生物活性和水生生物的生理代谢。天然水体pH值一般介于6.5~8.5之间,当pH值过低(酸化)或过高(碱化)时,会破坏水生生态平衡,加速重金属溶出,对鱼类鳃组织及藻类群落造成胁迫。pH的时空变化可用于识别酸性废水排放、藻类暴发过程以及水化学环境的稳定性评估。""", "Temperature": """水温(Temperature)是水体物理特性的基本参数,控制着溶解氧饱和度、化学反应速率及生物代谢强度。水温异常升高(如热污染)或昼夜温差剧烈波动,会影响鱼类洄游、藻类生长节律及底泥污染物释放。水温也是水文模型与水质模型的关键输入变量,其卫星遥感反演为大型水体热状况监测提供了高效手段。""", "spCond": """电导率(spCond)表征水体传导电流的能力,与溶解性离子总浓度密切相关。它常用于指示水体矿化度、盐度以及受工业废水、生活污水或农业径流污染的程度。电导率的快速变化往往预示着外源污染输入或海水入侵,是水质常规监测中重要的物理参数,其空间分布图可为污染源追踪提供直观线索。""", "Turbidity": """浊度(Turbidity)反映水体中悬浮颗粒物(如泥沙、藻类、微生物)对光线的散射程度,是衡量水体透明度的关键指标。浊度升高不仅影响水生植物光合作用,还会为病原微生物提供附着载体,干扰水处理工艺。通过遥感影像反演浊度,可实现大范围、高频次的水体清澈度评价,对饮用水源地保护和河流泥沙输送研究具有重要意义。""", "TDS": """总溶解固体(TDS)指水中溶解性无机盐和部分有机物的总质量,与水的适口性、管道腐蚀风险及灌溉适宜性密切相关。TDS过高会导致水味苦涩,并可能伴随有害微量元素积累。在咸潮入侵、工业排放及农业面源污染研究中,TDS是评价水质变化的稳定指标,其反演结果有助于识别淡水咸化区域及制定取水策略。""", "Cl-": """氯离子(Cl-)是天然水体中最稳定存在的阴离子之一,其来源包括岩石风化、海水侵入、工业废水及生活污水。氯离子含量升高可指示水体受咸潮或污染输入的影响,且在高浓度下会腐蚀管道、影响农业土壤结构。在饮用水消毒过程中,氯离子与有机物可能生成三氯甲烷等消毒副产物,因此其监测对水厂运行和水安全有重要警示作用。""", "NO3-N": """硝酸盐氮(NO3-N)是氮循环中氧化程度最高的形态,易溶于水,常通过农田径流、化粪池渗漏或工业废水进入水体。过量硝酸盐会刺激藻类过度生长,加速水体富营养化;饮用水中硝酸盐氮浓度超标会引发“蓝婴症”(高铁血红蛋白血症),对婴幼儿健康构成威胁。因此,硝酸盐氮是水质评价与饮用水安全监管的重点指标。""", "NH3-N": """氨氮(NH3-N)是水体受有机污染初期的重要指示物,主要来源于生活污水、农业化肥及工业含氮废水。氨氮对鱼类等水生生物有较强的毒性,且在好氧条件下会消耗大量溶解氧转化为硝酸盐。氨氮浓度高往往反映近期污染输入或水体自净能力不足,其动态变化可用于预警突发性污染事件和评估生态修复效果。""", "BGA": """BGA(蓝绿藻,即蓝藻)是表征水体蓝藻生物量的关键生物参数,通常通过藻蓝蛋白等特征色素反演获得。蓝藻过量繁殖(水华)会释放藻毒素、消耗溶解氧、形成水面覆盖层,严重威胁饮用水安全和水生态系统健康。BGA浓度的空间分布能精准指示水华高发区域与迁移路径,是水华预警、蓝藻治理和生态修复措施制定不可或缺的输入信息。""", "TT": """总氮(TT)是水体中有机氮、氨氮、硝酸盐氮、亚硝酸盐氮等各种形态氮的总和,综合反映了水体的氮营养水平。总氮是导致水体富营养化的主要限制因子之一,其浓度过高会引发藻类爆发、透明度下降、水质恶化。总氮的时空变化趋势可用于判断流域面源污染强度、评估氮减排措施成效,是水质管理和流域水环境保护的关键参考指标。""" } # 每个参数对应的图片顺序(统一5张图模式) params_list = ["Chlorophyll", "COD", "DO", "PH", "Temperature", "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", "NH3-N", "BGA", "TT"] self.parameter_images = { param: [ f"{param}_histogram.png", f"{param}_spectrum_comparison.png", f"{param}_scatter_with_confidence.png", f"{param}_boxplot.png", f"{param}_distribution_rendered.png" # 适配新版渲染分布图 ] for param in params_list } # ========== 新增:缓存线程锁 ========== self._cache_lock = Lock() def apply_ai_config(self, ai_config: ReportGenerationConfig) -> None: """在已创建的生成器上更新 AI 相关设置(下次 _ai_chat 生效)。""" cfg = ai_config if cfg.ai_provider: self.ai_provider = cfg.ai_provider.lower() if cfg.ollama_base_url: self.ollama_base_url = cfg.ollama_base_url.rstrip("/") if cfg.ollama_vision_model: self.ollama_vision_model = cfg.ollama_vision_model if cfg.ollama_text_model: self.ollama_text_model = cfg.ollama_text_model if cfg.ollama_timeout_s is not None: self.ollama_timeout_s = int(cfg.ollama_timeout_s) if cfg.minimax_api_key: self.minimax_api_key = cfg.minimax_api_key if cfg.minimax_vision_model: self.minimax_vision_model = cfg.minimax_vision_model if cfg.minimax_text_model: self.minimax_text_model = cfg.minimax_text_model if cfg.minimax_timeout_s is not None: self.minimax_timeout_s = int(cfg.minimax_timeout_s) if cfg.enable_ai_analysis is not None: self.enable_ai_analysis = bool(cfg.enable_ai_analysis) def _style_heading(self, heading, level: int): """统一一级/二级/三级标题字体(黑体)与字号。""" size_map = {1: Pt(16), 2: Pt(14), 3: Pt(12)} for run in heading.runs: run.font.name = self.title_font run.font.bold = True if level in size_map: run.font.size = size_map[level] run._element.rPr.rFonts.set(qn('w:eastAsia'), self.title_font) def _load_ai_cache(self) -> Dict[str, Any]: if not self.ai_cache_path.exists(): return {} try: with open(self.ai_cache_path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_ai_cache(self, cache: Dict[str, Any]) -> None: try: with open(self.ai_cache_path, "w", encoding="utf-8") as f: json.dump(cache, f, ensure_ascii=False, indent=2) except Exception: pass def _ollama_chat(self, model: str, system_prompt: str, user_prompt: str, image_path: Optional[Path] = None) -> str: """调用 Ollama /api/chat。image_path 传入时进行视觉分析。""" payload: Dict[str, Any] = { "model": model, "stream": False, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], } if image_path is not None: try: img_b64 = base64.b64encode(image_path.read_bytes()).decode("utf-8") payload["messages"][-1]["images"] = [img_b64] except Exception as e: return f"(读取图片失败:{e})" data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = Request( url=f"{self.ollama_base_url}/api/chat", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with urlopen(req, timeout=self.ollama_timeout_s) as resp: raw = resp.read().decode("utf-8", errors="ignore") obj = json.loads(raw) return (obj.get("message") or {}).get("content", "").strip() or "(模型未返回内容)" except (HTTPError, URLError, TimeoutError) as e: return f"(Ollama调用失败:{e})" except Exception as e: return f"(Ollama解析失败:{e})" def _call_minimax_text(self, system_prompt: str, user_prompt: str) -> str: """调用 Minimax 文本模型(自动兼容 OpenAI 标准端点)""" if not self.minimax_api_key: return "(Minimax API Key 未配置,请设置 MINIMAX_API_KEY 环境变量)" url = self.minimax_base_url 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"(Cloud API 调用失败 HTTP {e.code}:{e.reason})" except (URLError, TimeoutError) as e: return f"(Cloud API 调用失败:{e})" except Exception as e: return f"(Cloud API 解析失败:{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 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"(Cloud API Vision 调用失败 HTTP {e.code}:{e.reason})" except (URLError, TimeoutError) as e: return f"(Cloud API Vision 调用失败:{e})" except Exception as e: return f"(Cloud API Vision 解析失败:{e})" def _ai_chat( self, model: str, system_prompt: str, user_prompt: str, image_path: Optional[Path] = None, ) -> str: """ 统一 AI 调用入口。根据 self.ai_provider 路由到不同后端实现。 model 参数在 ollama 模式下直接使用;在 minimax 模式下忽略(使用类级别配置的模型)。 """ if self.ai_provider == "ollama": return self._ollama_chat(model, system_prompt, user_prompt, image_path) else: if image_path is not None: return self._call_minimax_vision(system_prompt, user_prompt, image_path) else: return self._call_minimax_text(system_prompt, user_prompt) def _get_prompt_for_image(self, image_type: str, param: str, figure_num: int) -> Dict[str, str]: """按图片类型返回 system/user 提示词,注入水质遥感专家级约束。""" system = ( "你是一位资深的水环境遥感与水生态学专家。现需为一份高光谱水质参数反演报告撰写专业分析。\n" "【绝对禁忌】:严禁写“看图说话”式的废话(如“曲线先升后降”、“柱子集中在中间”)。\n" "【核心规范】:\n" "1. 必须结合【水色光学机理】和【水环境地学意义】进行解释。\n" "2. 提及波长时,必须解释其对应的物理/生化意义(如叶绿素红光吸收谷、悬浮物散射峰、水体吸收特性等)。\n" "3. 分析浓度数值时,必须结合自然水体的常规背景值或富营养化状态进行定性评价(如“处于清洁水平”或“存在水华风险”)。\n" "4. 严格基于图中可见的规律,不编造图中没有的具体坐标或日期。" ) type_specs = { "histogram": { "analysis": ( "分析要点:\n" f"- 结合自然水体中 {param} 的常规阈值,评估该水域当前的整体水平(清洁、轻度污染或富营养化)。\n" "- 从生态学角度解释这种数值分布形态(如多峰分布可能暗示存在多个不同性质的污染源或水团交汇)。\n" "- 关注极端离群值,指出其可能代表的局部异常环境事件。" ), "conclusion": "结论应聚焦:该水质参数的整体健康水平评估及主要生态风险提示。", }, "spectrum_comparison": { "analysis": ( "分析要点:\n" f"- 结合 {param} 的固有光学特性,重点分析400-900nm区间内的特征波段响应(如吸收谷、反射峰、双峰效应等)。\n" "- 对比不同浓度组别的光谱差异,说明浓度变化是如何改变水体对光吸收和后向散射规律的。\n" "- 指出对该参数反演最具区分度的关键波段区间,验证模型的物理可解释性。" ), "conclusion": "结论应聚焦:浓度梯度引起的光谱响应规律及其对应的光学机制验证。", }, "scatter_with_confidence": { "analysis": ( "分析要点:\n" "- 评估机器学习反演模型在该参数上的鲁棒性。点云对1:1线的贴合度反映了反演精度。\n" "- 重点分析在极低值区或极高值区是否存在系统性高估/低估(这是水色遥感的常见难点,如高浓度下的光谱饱和效应)。\n" "- 结合置信带宽度,说明模型在不同浓度区间的预测不确定性。" ), "conclusion": "结论应聚焦:反演模型的整体精度表现、局限性及可靠的浓度预测区间。", }, "boxplot": { "analysis": ( "分析要点:\n" "- 结合中位数和四分位距,分析不同类别(或区域)间水质差异的显著性。\n" "- 解释离散程度大(箱体长)可能代表的强烈时空异质性。\n" "- 指出箱线图上下的离群点,探讨其作为局部水质突变信号的价值。" ), "conclusion": "结论应聚焦:核心对比趋势及数据整体的时空变异特征。", }, "distribution": { "analysis": ( "分析要点:\n" f"- 分析 {param} 高值区与低值区的空间异质性特征。\n" "- 推断污染/物质来源类型:高值区呈斑块状/点状(通常提示点源排放或局部水华),还是呈沿岸带状/梯度扩散(通常提示面源径流或水动力扩散)。\n" "- 结合常见水动力学特征,简述物质可能的输移趋势。" ), "conclusion": "结论应聚焦:水质参数的空间格局特征及其指示的宏观环境动力学过程。", }, "correlation_heatmap": { "analysis": ( "分析要点:\n" "- 挖掘关键水质参数间的生物地球化学联系。如叶绿素与总氮/总磷的正相关提示营养盐驱动,与浊度的正相关提示藻类为主导的悬浮物等。\n" "- 识别拮抗作用(强负相关),并解释其潜在的生化机制(如高浊度遮蔽光照导致叶绿素降低)。\n" "- 基于相关性聚类,推断水体中的核心主导污染因子群。" ), "conclusion": "结论应聚焦:水质指标间的核心协同/拮抗机制及水环境的主要驱动力。", }, } default_spec = { "analysis": "结合水环境遥感原理,深入解读图中展现的数据分布或空间格局特征。", "conclusion": "结论应聚焦:该图表传递的核心水质遥感科学结论。", } spec = type_specs.get(image_type, default_spec) user = ( f"图号:图{figure_num}\n" f"当前分析参数:{param}\n" f"图表类型:{image_type}\n\n" "【专业要求】:\n" f"{spec['analysis']}\n\n" "【输出格式】:\n" "直接输出一段(不要分段)150~300字的专业分析。前半部分描述关键数据现象并深挖其光学或生态机制,最后用一句“总之,…”作为全文的科学性总结。\n" f"【最终落脚点要求】:{spec['conclusion']}\n" ) return {"system": system, "user": user} def _style_figure_caption_simsun_xiaosi(self, paragraph): """图题格式:宋体、小四(12pt),中英文均设 eastAsia 为宋体。""" for run in paragraph.runs: run.font.name = self.chinese_font run.font.size = Pt(12) rPr = run._element.get_or_add_rPr() rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) def _add_image_with_caption(self, doc: Document, image_path: str, caption: str, width=Inches(5.5)): """ 统一插入图像并添加图题,确保图像和图题在同一页 Args: doc: Word文档对象 image_path: 图像文件路径 caption: 图题文字(如 "图3-1 航线规划") width: 图像宽度 """ try: # 创建图像段落 img_paragraph = doc.add_paragraph() img_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER # 设置段落不分页,与下一段(图题)保持在一起 img_paragraph.paragraph_format.keep_with_next = True img_paragraph.paragraph_format.keep_together = True img_paragraph.paragraph_format.space_after = Pt(6) # 图像后小间距 # 插入图像 run = img_paragraph.add_run() run.add_picture(str(image_path), width=width) # 创建图题段落(宋体小四) caption_para = doc.add_paragraph(caption, style='Caption') caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER self._style_figure_caption_simsun_xiaosi(caption_para) # 设置图题段落与上一段(图像)保持在一起 caption_para.paragraph_format.keep_with_next = False caption_para.paragraph_format.keep_together = True caption_para.paragraph_format.space_before = Pt(0) caption_para.paragraph_format.space_after = Pt(12) return True except Exception as e: doc.add_paragraph(f"[无法插入图像: {e}]") return False def _add_ai_analysis_paragraph(self, doc: Document, analysis_text: str): """在 Word 中插入 AI 分析段落(图片后)。""" # 清理文本:去除段落标记和多余空行 cleaned_text = analysis_text.strip() # 去除"第一段:"和"第二段:"标记 cleaned_text = cleaned_text.replace("第一段:", "").replace("第二段:", "") # 去除连续多个换行,替换为单个空格 import re cleaned_text = re.sub(r'\n+', ' ', cleaned_text) # 去除连续多个空格,替换为单个空格 cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip() p = doc.add_paragraph() p.paragraph_format.first_line_indent = Pt(24) p.paragraph_format.line_spacing = 1.5 p.paragraph_format.space_after = Pt(12) # 新增:段后间距与正文一致 run1 = p.add_run() run1.font.name = self.chinese_font run1.font.bold = True run1.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) run1._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) run2 = p.add_run(analysis_text.strip()) run2.font.name = self.chinese_font run2.font.size = Pt(12) # 修改:从 Pt(11) 改为 Pt(12) run2._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) def _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/12_visualization 和 work_dir/4_processed_data 中读取 Args: on_progress: 可选回调,签名 on_progress(percent: int, text: str)。 会在进度更新时被调用,用于驱动 Qt QProgressBar/QThread 信号。 """ # 设置工作目录(整个流程的核心) if work_dir is not None: self.work_dir = Path(work_dir) self.visualization_dir = self.work_dir / "12_visualization" if getattr(self, "_output_dir_is_default", False): self.output_dir = self.visualization_dir self.output_dir.mkdir(parents=True, exist_ok=True) self.ai_cache_path = self.output_dir / "ollama_image_analyses_cache.json" if parameters is None: parameters = ["Chlorophyll", "COD", "DO", "PH", "Temperature", "spCond", "Turbidity", "TDS", "Cl-", "NO3-N", "NH3-N", "BGA", "TT"] vis_dir = self.visualization_dir if not vis_dir.exists(): raise FileNotFoundError(f"可视化目录不存在: {vis_dir}") if output_path is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_path = self.output_dir / f"水质参数反演分析报告_{timestamp}.docx" else: output_path = Path(output_path) # 进度条(按“图片处理 + 汇总”计步) total_images = sum(len(self.parameter_images.get(p, [])) for p in parameters) total_steps = total_images + 1 + 1 # +1 相关性热力图(尝试一次),+1 综合总结 progress = self._create_progress(total=total_steps, desc="生成Word报告", on_step=on_progress) # 创建文档 doc = Document() # 设置页面 section = doc.sections[0] section.page_width = Cm(21) section.page_height = Cm(29.7) section.left_margin = Cm(2.5) section.right_margin = Cm(2.5) section.top_margin = Cm(2.5) section.bottom_margin = Cm(2.5) try: # 添加封面页 self._add_cover_page(doc) self._add_company_description_page(doc) self._add_data_acquisition_section(doc) self._add_data_processing_section(doc) # 全文图片分析结果收集(用于末尾汇总) all_image_analyses: List[Dict[str, Any]] = [] # 结果分析(含热力图):返回更新后的图号计数 figure_counter = 1 figure_counter = self._add_result_analysis_section( doc, vis_dir, figure_counter, all_image_analyses, progress=progress ) # 物理模型反演浓度统计与分析(第4.1节) figure_counter = self._add_physical_inversion_section( doc, self.work_dir, figure_counter, all_image_analyses, progress=progress ) # 设置页眉和页码(从正文开始) self._setup_header_and_footer(section) # 按参数生成内容(带编号):参数章节从 5 开始编号 base_section_num = 5 last_param_section_num = base_section_num + len(parameters) - 1 for section_num, param in enumerate(parameters, base_section_num): progress.set_description(f"正在分析 {param} 数据 ({section_num - base_section_num + 1}/{len(parameters)})") figure_counter = self._add_parameter_section( doc, param, vis_dir, section_num, figure_counter, all_image_analyses, progress=progress, ) if section_num != last_param_section_num: doc.add_page_break() # 汇总总结(放在所有图片/参数之后) doc.add_page_break() summary_section_num = base_section_num + len(parameters) summary_heading = doc.add_heading(f"{summary_section_num} 综合分析总结", level=1) self._style_heading(summary_heading, level=1) if self.enable_ai_analysis and all_image_analyses: analyses_text = "\n\n".join( [ f"图{a.get('figure_num')}({a.get('param')} / {a.get('image_type')} / {a.get('image_name')})\n{a.get('analysis')}" for a in all_image_analyses ] ) system = ( "你是一位水环境管理决策专家与遥感首席科学家。现需根据前面生成的各参数逐图分析文本,提炼出一份执行摘要级别的综合结论。\n" "必须具备宏观视角,能够将离散的参数分析整合成对该水域整体健康状况的系统性诊断。" ) user = ( "以下是各个水质参数的详尽逐图分析文本,请基于此撰写一份最终的综合分析总结。\n" "【内容结构需包含】:\n" "1. 整体水质评估(如营养状态、主要污染程度)。\n" "2. 关键时空热点与驱动因子(最需关注的高值区域及核心主导参数)。\n" "3. 遥感反演模型可靠性综合评价。\n" "4. 宏观水环境管理与保护建议。\n" "【⚠️强制要求】:\n" "- 总结的字数必须严格控制在 300 到 450 字之间!\n" "- 必须输出完整的结尾标点符号,绝不允许出现话说一半突然截断的情况!高度精炼,切勿啰嗦。\n\n" f"{analyses_text}" ) summary_text = self._ai_chat(self.ollama_text_model, system, user, image_path=None) para = doc.add_paragraph(summary_text) para.paragraph_format.first_line_indent = Pt(24) para.paragraph_format.line_spacing = 1.5 for run in para.runs: run.font.name = self.chinese_font run.font.size = Pt(12) run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) else: doc.add_paragraph("(未启用AI分析或无可用分析文本,无法生成综合总结。)") # 综合总结完成,进度 +1 try: progress.update(1) except Exception: pass finally: try: progress.close() except Exception: pass # 保存文档 doc.save(str(output_path)) print(f"✅ Word报告生成完成: {output_path}") return str(output_path) def _add_parameter_section( self, doc, param: str, vis_dir: Path, param_index: int = 1, start_figure_num: int = 1, all_image_analyses: Optional[List[Dict[str, Any]]] = None, progress=None, ): """为单个参数添加报告章节(支持多线程并发预取 AI 结果)""" if param not in self.parameter_descriptions: return start_figure_num # 添加带编号的参数标题 heading = doc.add_heading(f"{param_index}. {param} 参数分析", level=1) self._style_heading(heading, level=1) 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 / "12_visualization" / "flight_paths" h3 = doc.add_heading("航线规划:", level=3) self._style_heading(h3, level=3) # 查找航线图文件 flight_map_files = [] if flight_path_img_path.exists(): flight_map_files = list(flight_path_img_path.glob("*.png")) + list(flight_path_img_path.glob("*.jpg")) if flight_map_files: # 使用最新的航线图文件 latest_flight_map = max(flight_map_files, key=lambda p: p.stat().st_mtime) success = self._add_image_with_caption(doc, str(latest_flight_map), "图3-1 航线规划", width=Inches(5.5)) if success: # AI 分析航线规划图 flight_analysis = self._analyze_flight_path_image(str(latest_flight_map)) self._add_ai_analysis_paragraph(doc, flight_analysis) else: doc.add_paragraph("[航线规划图 - 文件未找到]") # 1. 高光谱原始图像 hyperspectral_img_path = work_dir_path / "1_water_mask" / "hsi_preview.png" h3 = doc.add_heading("高光谱原始影像:", level=3) self._style_heading(h3, level=3) if hyperspectral_img_path.exists(): self._add_image_with_caption(doc, str(hyperspectral_img_path), "图3-2 高光谱原始影像", width=Inches(5.5)) else: doc.add_paragraph("[高光谱原始影像 - 文件未找到]") # 2. 水体掩膜叠加图 water_mask_overlay_path = work_dir_path / "1_water_mask" / "water_mask_overlay.png" h3 = doc.add_heading("水体区域识别:", level=3) self._style_heading(h3, level=3) if water_mask_overlay_path.exists(): success = self._add_image_with_caption(doc, str(water_mask_overlay_path), "图3-3 水体区域识别(蓝色半透明区域为水域)", width=Inches(5.5)) if success: water_analysis = self._analyze_water_mask_overlay(str(water_mask_overlay_path)) self._add_ai_analysis_paragraph(doc, water_analysis) else: doc.add_paragraph("[水体区域识别图 - 文件未找到]") doc.add_paragraph() # 2. 耀斑区域 glint_img_path = vis_dir / "glint_deglint_previews" / "glint_severe_glint_area_preview.png" h3 = doc.add_heading("耀斑区域识别结果:", level=3) self._style_heading(h3, level=3) if glint_img_path.exists(): self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) else: # 尝试查找其他可能的耀斑预览图 glint_files = list(vis_dir.glob("glint_deglint_previews/*glint*.png")) if glint_files: glint_img_path = glint_files[0] self._add_image_with_caption(doc, str(glint_img_path), "图3-4 耀斑区域识别结果", width=Inches(5.5)) else: doc.add_paragraph("[耀斑区域识别结果 - 文件未找到]") doc.add_paragraph() # 3. 去除耀斑后的图像 deglint_img_path = vis_dir / "glint_deglint_previews" / "deglint_deglint_image_preview.png" h3 = doc.add_heading("去除耀斑后的影像:", level=3) self._style_heading(h3, level=3) if deglint_img_path.exists(): self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的高光谱影像", width=Inches(5.5)) else: # 尝试查找其他去耀斑预览图 deglint_files = list(vis_dir.glob("glint_deglint_previews/*deglint*.png")) if deglint_files: deglint_img_path = deglint_files[0] self._add_image_with_caption(doc, str(deglint_img_path), "图3-5 去除耀斑后的影像", width=Inches(5.5)) else: doc.add_paragraph("[去除耀斑后的影像 - 文件未找到]") doc.add_paragraph() # 4. AI分析耀斑位置分布 self._style_heading(h3, level=3) glint_analysis = self._analyze_glint_distribution_with_ai( str(glint_img_path) if 'glint_img_path' in locals() and Path(str(glint_img_path)).exists() else None, str(hyperspectral_img_path) if hyperspectral_img_path.exists() else None ) self._add_ai_analysis_paragraph(doc, glint_analysis) # 5. 采样点分布图 sampling_map_dir = vis_dir / "sampling_maps" h3 = doc.add_heading("采样点分布:", level=3) self._style_heading(h3, level=3) # 查找采样点分布图文件 sampling_map_files = [] if sampling_map_dir.exists(): sampling_map_files = list(sampling_map_dir.glob("*.png")) + list(sampling_map_dir.glob("*.jpg")) if sampling_map_files: # 使用最新的采样点分布图文件 latest_sampling_map = max(sampling_map_files, key=lambda p: p.stat().st_mtime) success = self._add_image_with_caption(doc, str(latest_sampling_map), "图3-6 采样点分布图", width=Inches(5.5)) if success: # AI 分析采样点分布图 sampling_analysis = self._analyze_sampling_distribution(str(latest_sampling_map)) self._add_ai_analysis_paragraph(doc, sampling_analysis) else: doc.add_paragraph("[采样点分布图 - 文件未找到]") def _analyze_glint_distribution_with_ai(self, glint_img_path: str = None, original_img_path: str = None) -> str: """使用AI分析耀斑的位置分布""" if not self.enable_ai_analysis: return "AI分析已禁用。耀斑主要分布在水体表面强反射区域,通常出现在太阳光直射角度较大的位置。" try: analysis_prompt = """请分析这张高光谱影像中的耀斑分布情况。 请从以下几个方面进行专业分析: 1. 耀斑的主要分布位置(水体中心、边缘、特定方位等) 2. 耀斑面积占比估计 3. 耀斑分布特征(集中分布还是分散分布) 4. 可能的成因分析 5. 对水质参数反演的影响评估 请用专业且简洁的语言描述,控制在150字以内。""" if glint_img_path and Path(glint_img_path).exists(): return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(glint_img_path)) elif original_img_path and Path(original_img_path).exists(): return self._ai_chat(self.ollama_vision_model, "你是一个专业的水质遥感分析专家。", analysis_prompt, Path(original_img_path)) else: return "基于影像分析,耀斑主要分布在水体表面强反射区域,对水质参数反演有一定影响,建议在数据处理时重点关注这些区域。" except Exception as e: return f"AI分析失败: {str(e)}。耀斑主要分布在水体表面强反射区域,通常与太阳入射角和水面粗糙度有关。" def _analyze_flight_path_image(self, flight_img_path: str) -> str: """ 使用AI分析航线规划图 分析内容: 1. 架次数量 2. 每个架次的飞行方向 3. 图例中的飞行起始结束时间 """ if not self.enable_ai_analysis: return "AI分析已禁用。根据航线规划图,可识别多个架次的飞行轨迹,每个架次具有不同的飞行方向和时间安排。" try: if not Path(flight_img_path).exists(): return "航线图文件不存在,无法进行分析。" analysis_prompt = """请详细分析这张航线规划图,并严格按照以下要求输出: 分析要求: 1. 架次数量:明确指出图中有几个架次(几条不同颜色的轨迹线) 2. 飞行方向:描述每个架次的大致飞行方向(如:东西向、南北向、东北-西南向等) 3. 时间信息:从图例中提取每个架次的起始和结束时间 输出格式要求: - 使用客观、准确的描述 - 避免推测性语言(如"可能"、"也许") - 控制在200字以内 - 如果看不清具体时间,请明确说明"图例显示时间信息但具体数值不清晰" 示例输出格式: "飞行共有X个架次:架次1(红色):东西向飞行,时间范围XX:XX-XX:XX架次2(蓝色):南北向飞行,时间范围XX:XX-XX:XX ... 各架次轨迹分布合理,覆盖了目标水体区域。""" result = self._ai_chat( self.ollama_vision_model, "你是一位专业的航空摄影测量和遥感专家,擅长分析航线规划图。", analysis_prompt, Path(flight_img_path) ) # 如果返回内容为空或太短,使用默认文本 if not result or len(result) < 20: return "根据航线图分析,图中包含多个架次的飞行轨迹,各架次采用不同颜色标识,飞行方向各异,图例中标注了各架次的起始和结束时间。" return result except Exception as e: return f"AI分析失败: {str(e)}。根据航线规划图,包含多个架次的飞行轨迹,各架次具有不同颜色和飞行方向,图例中标注了时间信息。" def _analyze_water_mask_overlay(self, water_mask_path: str) -> str: """ 使用AI分析水体区域识别图 分析内容: 1. 水体的分布情况(集中分布还是分散分布) 2. 水体的位置和形状特征 3. 从图像标注中提取的水域面积和占比 """ if not self.enable_ai_analysis: return "AI分析已禁用。根据水体区域识别图,蓝色半透明区域标识了水域范围,可观察到水体的分布情况和面积占比。" try: if not Path(water_mask_path).exists(): return "水体区域识别图文件不存在,无法进行分析。" analysis_prompt = """【背景说明】 这是一座水库的遥感影像,水体区域以蓝色半透明标识。水库通常是人工筑坝蓄水形成,具有以下典型特征: - 水体形态:较宽阔,形状相对规则,边界平滑 - 大坝位置:通常位于水库最窄处或下游方向 - 入库方向:上游河流汇入处,通常较窄或有分叉 - 出水方向:大坝方向,水体在此处收窄 【分析维度】 1. 水体整体形态:描述水库的形状(扇形、狭长形、不规则形、分叉形等),水体是集中还是分散? 2. 入库特征(重要):识别水体哪些位置有狭窄的入口或分叉——这些通常是河流入库的方向。描述入库位置(如东北角、西侧等)。 3. 大坝/出水方向推断(重要):根据水体形态,判断大坝最可能的位置。通常在水体最窄处、或水体延伸的末端。推断流向是“从XX方向流向大坝(XX方向)”。 4. 分支情况:是否有多个入库河流?是否有孤立水体? 5. 面积信息:从图像左上角标注中提取水域面积、影像总面积、水域占比。 【输出格式】 水体面积X.XX km² ,占比: X.X% ,形态: X。入库方向:XX方向(若有多个,依次列出)。出水/大坝方向:XX方向。流向推断:水体从XX方向汇入,流向大坝(XX方向)补充描述:[简要描述整体分布和形态特征] 【示例输出】 水体面积25.60 km² ,占比: 42.3% ,形态: 扇形分叉。入库方向:西北角和东北角各有狭窄水道汇入,为主要入库河流。出水/大坝方向:南侧水体最窄处。流向推断:水体从西北和东北两个方向汇入,向南侧大坝方向流动。补充描述:水库整体呈扇形,库区宽阔,有两个明显入库分支,符合山区水库典型特征""" result = self._ai_chat( self.ollama_vision_model, "你是一位专业的水体遥感分析专家,擅长解读水体掩膜图和水域分布特征。", analysis_prompt, Path(water_mask_path) ) # 如果返回内容为空或太短,使用默认文本 if not result or len(result) < 20: return "根据水体区域识别图分析,蓝色半透明区域标识了水域范围。从图像标注可读取水域面积、影像总面积及水域占比信息,水体分布特征明显,便于后续水质参数反演分析。" return result except Exception as e: return f"AI分析失败: {str(e)}。根据水体区域识别图,蓝色半透明区域标识了水域范围,图像左上角标注了水域面积、影像总面积及水域占比数据。" def _analyze_sampling_distribution(self, sampling_map_path: str) -> str: """ 使用AI分析采样点分布图 分析内容: 1. 采样点数量 2. 采样点在水体中的分布情况(均匀/集中、覆盖范围) 3. 采样点的空间分布特征 4. 对水质反演代表性的评估 """ if not self.enable_ai_analysis: return "AI分析已禁用。根据采样点分布图,红色点标识了采样点位置,可观察采样点在水体中的分布情况和覆盖范围。" try: if not Path(sampling_map_path).exists(): return "采样点分布图文件不存在,无法进行分析。" analysis_prompt = """请详细分析这张采样点分布图,并严格按照以下要求输出: 【分析要求】 1. 采样点数量:估算图中有多少个采样点(红色点) 2. 分布情况:描述采样点在水体中的分布是否均匀,是否有聚集或稀疏区域 3. 覆盖范围:采样点是否覆盖了主要水域,是否有未覆盖的区域 4. 空间特征:采样点分布在哪些方位(如上下游、左右岸等) 5. 代表性评估:简要评价当前采样点布局对水质参数反演的代表性 【输出格式要求】 - 使用客观、准确的描述 - 避免推测性语言 - 控制在200字以内 【示例输出格式】 "图中共有约XX个采样点,分布...,覆盖...,在...区域较为密集,...区域较为稀疏。 采样点整体覆盖了主要水体区域,但在...区域采样不足。 当前布局对水质反演具有较好的代表性,建议..." 请根据图像内容给出专业分析。""" result = self._ai_chat( self.ollama_vision_model, "你是一位专业的水质采样设计专家,擅长评估采样点布局的合理性和代表性。", analysis_prompt, Path(sampling_map_path) ) # 如果返回内容为空或太短,使用默认文本 if not result or len(result) < 20: return "根据采样点分布图分析,红色点标识了采样点位置,分布在水体各个区域。采样点覆盖范围较广,空间布局合理,能够较好地代表整体水质状况,为后续水质参数反演提供了可靠的数据基础。" return result except Exception as e: return f"AI分析失败: {str(e)}。根据采样点分布图,红色点标识了采样点位置,分布在水体中,覆盖了主要水域区域,具有较好的代表性。" def _setup_header_and_footer(self, section): """设置页眉:图片在最左侧 + 中间文字""" header = section.header # 清空现有段落 for paragraph in header.paragraphs: p = paragraph._element p.getparent().remove(p) # 创建新段落用于页眉 header_para = header.add_paragraph() # 1. 最左侧图片 - 使用相对路径 header_img_path = get_resource_path("data/icons/word/lica.png") if os.path.isfile(header_img_path): try: run_img = header_para.add_run() run_img.add_picture(str(header_img_path), width=Inches(1.6)) except Exception as e: print(f"页眉图片加载失败: {e}") header_para.add_run("■ ") else: header_para.add_run("■ ") # 图片不存在时的占位 # 2. 中间文字 - “水质参数报告” run_text = header_para.add_run(" 水质参数报告") run_text.font.name = self.chinese_font run_text.font.size = Pt(11) run_text._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) # 左对齐,让图片在最左侧 header_para.alignment = WD_ALIGN_PARAGRAPH.LEFT # 设置页眉边距 section.header_distance = Cm(0.8) # 设置页脚页码 footer = section.footer footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER # 添加页码字段 run = footer_para.add_run() fldChar1 = OxmlElement('w:fldChar') fldChar1.set(qn('w:fldCharType'), 'begin') run._element.append(fldChar1) instrText = OxmlElement('w:instrText') instrText.text = 'PAGE' run._element.append(instrText) fldChar2 = OxmlElement('w:fldChar') fldChar2.set(qn('w:fldCharType'), 'end') run._element.append(fldChar2) # 添加 "页" 字 footer_para.add_run(' / ') run2 = footer_para.add_run() fldChar3 = OxmlElement('w:fldChar') fldChar3.set(qn('w:fldCharType'), 'begin') run2._element.append(fldChar3) instrText2 = OxmlElement('w:instrText') instrText2.text = 'NUMPAGES' run2._element.append(instrText2) fldChar4 = OxmlElement('w:fldChar') fldChar4.set(qn('w:fldCharType'), 'end') run2._element.append(fldChar4) footer_para.add_run(' 页') # 设置页脚字体 for run in footer_para.runs: run.font.size = Pt(9) run.font.name = self.chinese_font if hasattr(run, '_element') and hasattr(run._element, 'rPr'): run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) def _add_result_analysis_section( self, doc, vis_dir: Path, start_figure_num: int = 1, all_image_analyses: Optional[List[Dict[str, Any]]] = None, progress=None, ) -> int: """添加结果分析章节 - 统计表格 + 相关性热力图(热力图在表格下方)""" h1 = doc.add_heading("4 结果分析", level=1) self._style_heading(h1, level=1) # 1. 添加统计分析表格(带编号) h2 = doc.add_heading("4.1 水质参数统计分析", level=2) self._style_heading(h2, level=2) # 从工作目录的4_processed_data文件夹查找CSV文件 work_dir_path = vis_dir.parent processed_data_dir = work_dir_path / "5_Data_Cleaning" if not processed_data_dir.exists(): doc.add_paragraph(f"未找到数据处理目录: {processed_data_dir}") doc.add_page_break() return start_figure_num csv_files = list(processed_data_dir.glob("*.csv")) if not csv_files: doc.add_paragraph(f"在 {processed_data_dir} 目录下未找到CSV统计数据文件。") doc.add_page_break() return start_figure_num csv_path = csv_files[0] # 使用找到的第一个CSV文件 try: df_full = pd.read_csv(csv_path, sep=',') df = df_full.iloc[:, 2:] # 跳过前两列(纬度、经度),直接用列号 # 自动统计剩余列 stats_data = [] for i in range(df.shape[1]): col = df.columns[i] clean_col = str(col).strip() try: data = df.iloc[:, i].dropna() if len(data) > 0: stats_data.append({ '参数': clean_col, '点位数': len(data), '最大值': f"{data.max():.4f}", '最小值': f"{data.min():.4f}", '平均值': f"{data.mean():.4f}", '标准差': f"{data.std():.4f}" }) except Exception as e: print(f"跳过列 {col}: {e}") if stats_data: # 创建统计表格 table = doc.add_table(rows=1, cols=6, style='Table Grid') hdr_cells = table.rows[0].cells hdr_cells[0].text = '参数' hdr_cells[1].text = '点位数' hdr_cells[2].text = '最大值' hdr_cells[3].text = '最小值' hdr_cells[4].text = '平均值' hdr_cells[5].text = '标准差' for stat in stats_data: row_cells = table.add_row().cells row_cells[0].text = stat['参数'] row_cells[1].text = str(stat['点位数']) row_cells[2].text = stat['最大值'] row_cells[3].text = stat['最小值'] row_cells[4].text = stat['平均值'] row_cells[5].text = stat['标准差'] else: doc.add_paragraph("CSV文件中未找到有效的参数数据。") except Exception as e: doc.add_paragraph(f"读取CSV文件时出错: {str(e)}") #增加空格 doc.add_paragraph() # 表格生成完成后,添加 AI 分析 if stats_data: analysis_text = self._analyze_statistics(stats_data, [s['参数'] for s in stats_data]) self._add_ai_analysis_paragraph(doc, analysis_text) doc.add_paragraph() # 表格和热力图之间的空行 # 2. 添加相关性热力图(放在表格下方) h3 = doc.add_heading("4.2 水质参数相关性分析", level=2) self._style_heading(h3, level=2) heatmap_path = vis_dir / "correlation_heatmap.png" figure_num = start_figure_num if heatmap_path.exists(): try: # 使用统一的图像插入方法 caption_text = f"图{figure_num} 水质参数相关性热力图" self._add_image_with_caption(doc, str(heatmap_path), caption_text, width=Inches(6.0)) doc.add_paragraph("(颜色越深表示相关性越强,红色为正相关,蓝色为负相关)") analysis_text = self._analyze_and_cache_image( image_path=heatmap_path, image_type="correlation_heatmap", param="综合", figure_num=figure_num, ) self._add_ai_analysis_paragraph(doc, analysis_text) if all_image_analyses is not None: all_image_analyses.append( { "figure_num": figure_num, "param": "综合", "image_type": "correlation_heatmap", "image_name": heatmap_path.name, "analysis": analysis_text, } ) except Exception as e: doc.add_paragraph(f"[相关性热力图插入失败: {e}]") else: doc.add_paragraph(f"[未找到相关性热力图: {heatmap_path.name}]") # 热力图处理结束(无论成功/失败)更新进度条 try: if progress is not None: progress.update(1) except Exception: pass doc.add_page_break() return start_figure_num + (1 if heatmap_path.exists() else 0) def _add_physical_inversion_section( self, doc: Document, work_dir: Path, start_figure_num: int = 1, all_image_analyses: Optional[List[Dict[str, Any]]] = None, progress=None, ) -> int: """新增章节:物理模型反演浓度统计与分析(第4节之后)""" conc_dir = work_dir / "9_Concentration" if not conc_dir.is_dir(): doc.add_paragraph("[物理反演浓度章节:9_Concentration 目录不存在,已跳过]") return start_figure_num stats_csv = conc_dir / "statistics_summary.csv" charts_dir = conc_dir / "charts" h = doc.add_heading("4.1 物理模型反演浓度统计与分析", level=2) self._style_heading(h, level=2) fig_num = start_figure_num if stats_csv.is_file(): try: stats_df = pd.read_csv(stats_csv) table = doc.add_table(rows=1, cols=len(stats_df.columns)) table.style = "Table Grid" hdr_cells = table.rows[0].cells for i, col_name in enumerate(stats_df.columns): hdr_cells[i].text = str(col_name) for run in hdr_cells[i].paragraphs[0].runs: run.font.name = self.chinese_font run.font.size = Pt(10) run.font.bold = True run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) for _, row_data in stats_df.iterrows(): row_cells = table.add_row().cells for i, val in enumerate(row_data): row_cells[i].text = str(val) for run in row_cells[i].paragraphs[0].runs: run.font.name = self.chinese_font run.font.size = Pt(10) run._element.rPr.rFonts.set(qn('w:eastAsia'), self.chinese_font) doc.add_paragraph() except Exception as e: doc.add_paragraph(f"[浓度统计表插入失败: {e}]") else: doc.add_paragraph("[浓度统计表不存在: statistics_summary.csv]") if charts_dir.is_dir(): image_extensions = ['*.png', '*.jpg', '*.jpeg'] # 严格剔除 tif/tiff chart_files: List[Path] = [] for ext in image_extensions: chart_files.extend(sorted(charts_dir.glob(ext))) for chart_file in chart_files: caption_text = f"图{fig_num} {chart_file.stem} 分布图" if self._add_image_with_caption(doc, str(chart_file), caption_text, width=Inches(5.5)): if all_image_analyses is not None: image_type = "boxplot" if "boxplot" in chart_file.stem.lower() else "distribution" analysis_text = self._analyze_and_cache_image( image_path=chart_file, image_type=image_type, param=chart_file.stem, figure_num=fig_num, ) self._add_ai_analysis_paragraph(doc, analysis_text) all_image_analyses.append({ "figure_num": fig_num, "param": chart_file.stem, "image_type": image_type, "image_name": chart_file.name, "analysis": analysis_text, }) fig_num += 1 try: if progress is not None: progress.update(1) except Exception: pass else: doc.add_paragraph("[浓度图表目录不存在: 9_Concentration/charts/]") return fig_num # ==================== 使用示例 ==================== def generate_full_water_quality_report( work_dir: str = "./work_dir", ai_config: Optional[ReportGenerationConfig] = None, ): """生成包含所有水质参数的完整报告。""" generator = WaterQualityReportGenerator(work_dir=work_dir, ai_config=ai_config) return generator.generate_report( work_dir=work_dir, parameters=None, report_title="水质参数反演分析完整报告", ) if __name__ == "__main__": # 默认生成完整报告(包含所有13个水质参数) report_path = generate_full_water_quality_report() print(f"完整水质报告已生成: {report_path}")