Browse Source

重构绘图模块,整合CSV转GeoTIFF、栅格地图绘制和直方图生成功能;更新相关文档以反映新功能和使用指南。

drggboy 1 week ago
parent
commit
25e6e589ee

+ 31 - 60
Cd_Prediction_Integrated_System/analysis/mapping.py

@@ -2,7 +2,8 @@
 栅格映射模块
 Raster Mapping Module
 
-基于原始02_Transfer_csv_to_geotif.py改进,用于将CSV数据转换为GeoTIFF格式
+基于通用绘图模块 app.utils.mapping_utils 的封装
+提供与原有接口兼容的栅格转换功能,包含空间插值处理
 """
 
 import os
@@ -17,7 +18,10 @@ from rasterio.transform import from_origin
 import numpy as np
 
 # 添加项目根目录到路径
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+# 导入通用绘图模块
+from app.utils.mapping_utils import MappingUtils
 
 import config
 
@@ -32,6 +36,8 @@ class RasterMapper:
         初始化栅格映射器
         """
         self.logger = logging.getLogger(__name__)
+        # 初始化通用绘图模块
+        self.mapping_utils = MappingUtils()
         
     def csv_to_shapefile(self, csv_file, shapefile_output):
         """
@@ -99,75 +105,39 @@ class RasterMapper:
             self.logger.error(f"CSV转Shapefile失败: {str(e)}")
             raise
     
-    def vector_to_raster(self, input_shapefile, template_tif, output_tif, field='Prediction'):
+    def vector_to_raster(self, input_shapefile, template_tif, output_tif, field='Prediction', resolution_factor=16.0):
         """
-        将点矢量数据转换为栅格数据
+        将点矢量数据转换为栅格数据(使用通用绘图模块)
         
         @param input_shapefile: 输入点矢量数据的Shapefile文件路径
         @param template_tif: 用作模板的GeoTIFF文件路径
         @param output_tif: 输出栅格化后的GeoTIFF文件路径
         @param field: 用于栅格化的属性字段名
+        @param resolution_factor: 分辨率倍数因子,16.0表示分辨率提高16倍(像素单元变为1/16)
         """
         try:
-            # 读取矢量数据
-            gdf = gpd.read_file(input_shapefile)
-            self.logger.info(f"矢量数据加载成功: {input_shapefile}")
-            
-            # 检查模板文件是否存在
-            if not os.path.exists(template_tif):
-                self.logger.warning(f"模板文件不存在: {template_tif},将创建默认栅格")
-                self._create_default_template(gdf, template_tif)
-            
-            # 打开模板栅格,获取元信息
-            with rasterio.open(template_tif) as src:
-                template_meta = src.meta.copy()
-                transform = src.transform
-                width = src.width
-                height = src.height
-                crs = src.crs
-                
-            self.logger.info(f"模板栅格信息 - 宽度: {width}, 高度: {height}, CRS: {crs}")
-            
-            # 将矢量数据投影到模板的坐标系
-            if gdf.crs != crs:
-                gdf = gdf.to_crs(crs)
-                self.logger.info(f"矢量数据投影到: {crs}")
-            
-            # 检查字段是否存在
-            if field not in gdf.columns:
-                # 尝试寻找替代字段
-                numeric_cols = [col for col in gdf.columns if pd.api.types.is_numeric_dtype(gdf[col])]
-                if numeric_cols:
-                    field = numeric_cols[0]
-                    self.logger.warning(f"字段'{field}'不存在,使用'{field}'替代")
-                else:
-                    raise ValueError(f"无法找到数值字段进行栅格化")
-            
-            # 栅格化
-            shapes = ((geom, value) for geom, value in zip(gdf.geometry, gdf[field]))
-            raster = rasterize(
-                shapes=shapes,
-                out_shape=(height, width),
-                transform=transform,
-                fill=np.nan,
-                dtype='float32'
+            self.logger.info(f"开始矢量转栅格: {input_shapefile}")
+            self.logger.info(f"分辨率因子: {resolution_factor}")
+            
+            # 获取边界文件
+            boundary_shp = config.ANALYSIS_CONFIG.get("boundary_shp")
+            
+            # 调用通用绘图模块的矢量转栅格方法(包含空间插值)
+            output_path, stats = self.mapping_utils.vector_to_raster(
+                input_shapefile=input_shapefile,
+                template_tif=template_tif,
+                output_tif=output_tif,
+                field=field,
+                resolution_factor=resolution_factor,
+                boundary_shp=boundary_shp,
+                interpolation_method='nearest'
             )
             
-            # 确保输出目录存在
-            os.makedirs(os.path.dirname(output_tif), exist_ok=True)
-            
-            # 写入GeoTIFF
-            template_meta.update({
-                "count": 1,
-                "dtype": 'float32',
-                "nodata": np.nan
-            })
+            self.logger.info(f"栅格文件创建成功: {output_path}")
+            if stats:
+                self.logger.info(f"统计信息: 有效像素 {stats.get('valid_pixels', 0)}/{stats.get('total_pixels', 0)}")
             
-            with rasterio.open(output_tif, 'w', **template_meta) as dst:
-                dst.write(raster, 1)
-                
-            self.logger.info(f"栅格文件创建成功: {output_tif}")
-            return output_tif
+            return output_path
             
         except Exception as e:
             self.logger.error(f"矢量转栅格失败: {str(e)}")
@@ -221,6 +191,7 @@ class RasterMapper:
         except Exception as e:
             self.logger.error(f"默认模板创建失败: {str(e)}")
             raise
+
     
     def csv_to_raster(self, csv_file, output_raster=None, output_shp=None):
         """

+ 37 - 223
Cd_Prediction_Integrated_System/analysis/visualization.py

@@ -2,24 +2,19 @@
 可视化模块
 Visualization Module
 
-基于原始01_Figure_raster_mapping.py改进,用于生成栅格地图和直方图
+基于通用绘图模块 app.utils.mapping_utils 的封装
+提供与原有接口兼容的可视化功能
 """
 
 import os
 import sys
 import logging
-import geopandas as gpd
-import rasterio
-from rasterio.mask import mask
-import matplotlib.pyplot as plt
-import numpy as np
-import json
-from matplotlib.colors import ListedColormap, BoundaryNorm
-from rasterio.plot import show
-import seaborn as sns
 
 # 添加项目根目录到路径
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+# 导入通用绘图模块
+from app.utils.mapping_utils import MappingUtils, COLORMAPS
 
 import config
 
@@ -27,6 +22,7 @@ class Visualizer:
     """
     可视化器
     负责创建栅格地图和直方图
+    基于通用绘图模块的封装,保持原有接口兼容性
     """
     
     def __init__(self):
@@ -34,88 +30,9 @@ class Visualizer:
         初始化可视化器
         """
         self.logger = logging.getLogger(__name__)
-        self._setup_matplotlib()
-        
-    def _setup_matplotlib(self):
-        """
-        设置matplotlib的字体和样式
-        """
-        try:
-            # 设置字体,优先尝试常用的中文字体
-            import matplotlib.font_manager as fm
-            
-            # 清理matplotlib字体缓存(解决Windows系统字体问题)
-            try:
-                import matplotlib
-                fm._rebuild()
-                self.logger.info("matplotlib字体缓存已重建")
-            except Exception as cache_error:
-                self.logger.warning(f"字体缓存重建失败: {cache_error}")
-            
-            # 可用的中文字体列表(Windows系统优先)
-            chinese_fonts = [
-                'Microsoft YaHei',      # 微软雅黑 (Windows)
-                'Microsoft YaHei UI',   # 微软雅黑UI (Windows)
-                'SimHei',               # 黑体 (Windows)
-                'SimSun',               # 宋体 (Windows)
-                'KaiTi',                # 楷体 (Windows)
-                'FangSong',             # 仿宋 (Windows)
-                'Microsoft JhengHei',   # 微软正黑体 (Windows)
-                'PingFang SC',          # 苹方(macOS)
-                'Hiragino Sans GB',     # 冬青黑体(macOS)
-                'WenQuanYi Micro Hei',  # 文泉驿微米黑(Linux)
-                'Noto Sans CJK SC',     # 思源黑体(Linux)
-                'Arial Unicode MS',     # Unicode字体
-                'DejaVu Sans'           # 备用字体
-            ]
-            
-            # 查找可用的字体
-            available_fonts = [f.name for f in fm.fontManager.ttflist]
-            selected_font = None
-            
-            self.logger.info(f"系统中可用字体数量: {len(available_fonts)}")
-            
-            for font in chinese_fonts:
-                if font in available_fonts:
-                    selected_font = font
-                    self.logger.info(f"选择字体: {font}")
-                    break
-            
-            if selected_font:
-                plt.rcParams['font.sans-serif'] = [selected_font] + chinese_fonts
-                plt.rcParams['font.family'] = 'sans-serif'
-            else:
-                self.logger.warning("未找到合适的中文字体,将使用系统默认字体")
-                # 使用更安全的字体配置
-                plt.rcParams['font.sans-serif'] = ['DejaVu Sans', 'Arial', 'sans-serif']
-                plt.rcParams['font.family'] = 'sans-serif'
-            
-            # 解决负号显示问题
-            plt.rcParams['axes.unicode_minus'] = False
-            
-            # 设置图形样式
-            plt.rcParams['figure.figsize'] = (10, 8)
-            plt.rcParams['axes.labelsize'] = 12
-            plt.rcParams['axes.titlesize'] = 14
-            plt.rcParams['xtick.labelsize'] = 10
-            plt.rcParams['ytick.labelsize'] = 10
-            plt.rcParams['legend.fontsize'] = 10
-            
-            # 设置DPI以提高图像质量
-            plt.rcParams['figure.dpi'] = 100
-            plt.rcParams['savefig.dpi'] = 300
-            plt.rcParams['savefig.bbox'] = 'tight'
-            plt.rcParams['savefig.pad_inches'] = 0.1
-            
-            self.logger.info("matplotlib字体和样式设置完成")
-            
-        except Exception as e:
-            self.logger.warning(f"设置matplotlib字体失败: {str(e)},将使用默认配置")
-            # 最基本的安全配置
-            plt.rcParams['font.family'] = 'sans-serif'
-            plt.rcParams['font.sans-serif'] = ['DejaVu Sans', 'Arial']
-            plt.rcParams['axes.unicode_minus'] = False
+        self.mapping_utils = MappingUtils()
         
+
     def create_raster_map(self, 
                          shp_path=None, 
                          tif_path=None, 
@@ -148,89 +65,25 @@ class Visualizer:
                 output_path = os.path.join(config.OUTPUT_PATHS["figures_dir"], "Prediction_results")
             if output_size is None:
                 output_size = config.VISUALIZATION_CONFIG["figure_size"]
-                
-            self.logger.info(f"开始创建栅格地图: {tif_path}")
-            
-            # 检查文件是否存在
-            if not os.path.exists(tif_path):
-                raise FileNotFoundError(f"栅格文件不存在: {tif_path}")
             
-            # 如果边界文件不存在,创建一个简单的边界
-            if not os.path.exists(shp_path):
+            # 检查边界文件是否存在
+            if shp_path and not os.path.exists(shp_path):
                 self.logger.warning(f"边界文件不存在: {shp_path},将跳过边界绘制")
-                gdf = None
-            else:
-                gdf = gpd.read_file(shp_path)
-            
-            # 读取并处理栅格数据
-            with rasterio.open(tif_path) as src:
-                if gdf is not None:
-                    # 使用边界裁剪栅格数据
-                    geoms = [json.loads(gdf.to_json())["features"][0]["geometry"]]
-                    out_image, out_transform = mask(src, geoms, crop=True)
-                    out_meta = src.meta.copy()
-                else:
-                    # 直接读取整个栅格
-                    out_image = src.read()
-                    out_transform = src.transform
-                    out_meta = src.meta.copy()
-            
-            # 提取数据并处理无效值
-            raster = out_image[0].astype('float32')
-            nodata = out_meta.get("nodata", None)
-            if nodata is not None:
-                raster[raster == nodata] = np.nan
-            
-            # 根据分位数分为6个等级
-            valid_data = raster[~np.isnan(raster)]
-            if len(valid_data) == 0:
-                raise ValueError("栅格数据中没有有效值")
+                shp_path = None
                 
-            bounds = np.nanpercentile(raster, [0, 20, 40, 60, 80, 90, 100])
-            norm = BoundaryNorm(bounds, ncolors=len(bounds) - 1)
-            cmap = ListedColormap(color_map_name)
-            
-            # 绘图
-            fig, ax = plt.subplots(figsize=(output_size, output_size))
-            show(raster, transform=out_transform, ax=ax, cmap=cmap, norm=norm)
-            
-            # 添加矢量边界
-            if gdf is not None:
-                gdf.boundary.plot(ax=ax, color='black', linewidth=1)
-            
-            # 设置标题和标签
-            ax.set_title(title_name, fontsize=20)
-            ax.set_xlabel("Longitude", fontsize=18)
-            ax.set_ylabel("Latitude", fontsize=18)
-            ax.grid(True, linestyle='--', color='gray', alpha=0.5)
-            ax.tick_params(axis='y', labelrotation=90)
-            
-            # 添加色带
-            tick_labels = [f"{bounds[i]:.1f}" for i in range(len(bounds) - 1)]
-            cbar = plt.colorbar(
-                plt.cm.ScalarMappable(norm=norm, cmap=cmap),
-                ax=ax,
-                ticks=[(bounds[i] + bounds[i+1]) / 2 for i in range(len(bounds) - 1)],
-                shrink=0.6,     # 缩小色带高度
-                aspect=15       # 细长效果
+            # 设置DPI
+            dpi = 600 if high_res else config.VISUALIZATION_CONFIG["dpi"]
+            
+            # 调用通用绘图模块
+            return self.mapping_utils.create_raster_map(
+                shp_path=shp_path,
+                tif_path=tif_path,
+                output_path=output_path,
+                colormap=color_map_name,
+                title=title_name,
+                output_size=output_size,
+                dpi=dpi
             )
-            cbar.ax.set_yticklabels(tick_labels)
-            cbar.set_label("Values")
-            
-            plt.tight_layout()
-            
-            # 确保输出目录存在
-            os.makedirs(os.path.dirname(output_path), exist_ok=True)
-            
-            # 保存图片
-            output_file = f"{output_path}.jpg"
-            # 根据high_res参数决定使用的DPI
-            output_dpi = 600 if high_res else config.VISUALIZATION_CONFIG["dpi"]
-            plt.savefig(output_file, dpi=output_dpi, format='jpg', bbox_inches='tight')
-            plt.close()
-            
-            self.logger.info(f"栅格地图创建成功: {output_file}")
-            return output_file
             
         except Exception as e:
             self.logger.error(f"栅格地图创建失败: {str(e)}")
@@ -264,59 +117,20 @@ class Visualizer:
                 figsize = (6, 6)
             if save_path is None:
                 save_path = os.path.join(config.OUTPUT_PATHS["figures_dir"], "Prediction_frequency.jpg")
-                
-            self.logger.info(f"开始创建直方图: {file_path}")
-            
-            # 检查文件是否存在
-            if not os.path.exists(file_path):
-                raise FileNotFoundError(f"栅格文件不存在: {file_path}")
             
-            # 设置seaborn样式
-            sns.set(style='ticks')
-            
-            # 读取栅格数据
-            with rasterio.open(file_path) as src:
-                band = src.read(1)
-                nodata = src.nodata
-            
-            # 处理无效值
-            if nodata is not None:
-                band = np.where(band == nodata, np.nan, band)
-            
-            # 展平数据并移除NaN值
-            band_flat = band.flatten()
-            band_flat = band_flat[~np.isnan(band_flat)]
-            
-            if len(band_flat) == 0:
-                raise ValueError("栅格数据中没有有效值")
-            
-            # 创建图形
-            plt.figure(figsize=figsize)
-            
-            # 绘制直方图和密度曲线
-            sns.histplot(band_flat, bins=100, color='steelblue', alpha=0.7, 
-                        edgecolor='black', stat='density')
-            sns.kdeplot(band_flat, color='red', linewidth=2)
-            
-            # 设置标签和标题
-            plt.xlabel(xlabel, fontsize=14)
-            plt.ylabel(ylabel, fontsize=14)
-            plt.title(title, fontsize=16)
-            plt.grid(True, linestyle='--', alpha=0.5)
-            plt.tight_layout()
-            
-            # 确保输出目录存在
-            os.makedirs(os.path.dirname(save_path), exist_ok=True)
-            
-            # 保存图片
-            # 根据high_res参数决定使用的DPI
-            output_dpi = 600 if high_res else config.VISUALIZATION_CONFIG["dpi"]
-            plt.savefig(save_path, dpi=output_dpi, 
-                       format='jpg', bbox_inches='tight')
-            plt.close()
-            
-            self.logger.info(f"直方图创建成功: {save_path}")
-            return save_path
+            # 设置DPI
+            dpi = 600 if high_res else config.VISUALIZATION_CONFIG["dpi"]
+            
+            # 调用通用绘图模块
+            return self.mapping_utils.create_histogram(
+                file_path=file_path,
+                save_path=save_path,
+                figsize=figsize,
+                xlabel=xlabel,
+                ylabel=ylabel,
+                title=title,
+                dpi=dpi
+            )
             
         except Exception as e:
             self.logger.error(f"直方图创建失败: {str(e)}")

+ 311 - 27
app/services/cd_prediction_service.py

@@ -9,7 +9,7 @@ import os
 import logging
 import asyncio
 from datetime import datetime
-from typing import Dict, Any, Optional, List
+from typing import Dict, Any, Optional, List, Tuple
 import glob
 import shutil
 import zipfile
@@ -245,7 +245,16 @@ class CdPredictionService:
         # 保存数据
         df.to_csv(file_path, index=False, encoding='utf-8-sig')
         
-        self.logger.info(f"临时数据文件已保存: {file_path}")
+        # 记录详细的文件信息
+        file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
+        self.logger.info(f"📁 临时数据文件已保存:")
+        self.logger.info(f"   路径: {file_path}")
+        self.logger.info(f"   大小: {file_size:,} bytes ({file_size/1024:.1f} KB)")
+        self.logger.info(f"   数据形状: {df.shape[0]} 行 × {df.shape[1]} 列")
+        
+        # 清理旧的临时文件,只保留最新的5个
+        self._cleanup_temp_files(county_name)
+        
         return file_path
     
     async def add_county_support(self, county_name: str, boundary_file, 
@@ -403,26 +412,43 @@ class CdPredictionService:
             coord_file_path = os.path.join(cd_system_path, "data", "coordinates", "坐标.csv")
             os.makedirs(os.path.dirname(coord_file_path), exist_ok=True)
             coordinates_df.to_csv(coord_file_path, index=False, encoding='utf-8-sig')
-            self.logger.info(f"坐标文件已保存: {coord_file_path}")
+            
+            # 记录坐标文件详细信息
+            coord_file_size = os.path.getsize(coord_file_path) if os.path.exists(coord_file_path) else 0
+            self.logger.info(f"🗺️  坐标文件已保存:")
+            self.logger.info(f"   路径: {coord_file_path}")
+            self.logger.info(f"   大小: {coord_file_size:,} bytes ({coord_file_size/1024:.1f} KB)")
+            self.logger.info(f"   坐标点数: {coordinates_df.shape[0]} 个")
             
             # 2. 准备作物Cd模型的训练数据
             crop_cd_data_dir = os.path.join(cd_system_path, "models", "crop_cd_model", "data")
             crop_target_file = os.path.join(crop_cd_data_dir, "areatest.csv")
             
-            # 备份原始文件
+            # 不再创建备份文件,因为此文件每次都会被用户数据完全覆盖
             if os.path.exists(crop_target_file):
-                backup_file = f"{crop_target_file}.backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
-                shutil.copy2(crop_target_file, backup_file)
-                self.logger.info(f"作物Cd模型原始数据已备份: {backup_file}")
+                original_size = os.path.getsize(crop_target_file)
+                self.logger.info(f"🔄 准备覆盖作物Cd模型数据文件:")
+                self.logger.info(f"   文件路径: {crop_target_file}")
+                self.logger.info(f"   原始文件大小: {original_size:,} bytes ({original_size/1024:.1f} KB)")
+                
+                # 清理现有的备份文件(如果存在)
+                self._cleanup_backup_files(crop_target_file, max_backups=0)
             
             # 提取环境因子数据(去掉前两列的经纬度)
             environmental_data = df.iloc[:, 2:].copy()  # 从第3列开始的所有列
             
             # 保存环境因子数据到作物Cd模型目录(不包含坐标)
             environmental_data.to_csv(crop_target_file, index=False, encoding='utf-8-sig')
-            self.logger.info(f"作物Cd模型数据文件已保存: {crop_target_file}, 数据形状: {environmental_data.shape}")
             
-            self.logger.info(f"作物Cd模型自定义数据文件已准备完成,县市: {county_name}")
+            # 记录作物Cd模型数据文件详细信息
+            model_file_size = os.path.getsize(crop_target_file) if os.path.exists(crop_target_file) else 0
+            self.logger.info(f"🌾 作物Cd模型数据文件已保存:")
+            self.logger.info(f"   路径: {crop_target_file}")
+            self.logger.info(f"   大小: {model_file_size:,} bytes ({model_file_size/1024:.1f} KB)")
+            self.logger.info(f"   数据形状: {environmental_data.shape[0]} 行 × {environmental_data.shape[1]} 列")
+            self.logger.info(f"   环境因子列数: {environmental_data.shape[1]}")
+            
+            self.logger.info(f"✅ 作物Cd模型自定义数据文件已准备完成,县市: {county_name}")
             
         except Exception as e:
             self.logger.error(f"准备作物Cd模型自定义数据文件失败: {str(e)}")
@@ -460,11 +486,15 @@ class CdPredictionService:
             effective_cd_data_dir = os.path.join(cd_system_path, "models", "effective_cd_model", "data")
             effective_target_file = os.path.join(effective_cd_data_dir, "areatest.csv")
             
-            # 备份原始文件
+            # 不再创建备份文件,因为此文件每次都会被用户数据完全覆盖
             if os.path.exists(effective_target_file):
-                backup_file = f"{effective_target_file}.backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
-                shutil.copy2(effective_target_file, backup_file)
-                self.logger.info(f"有效态Cd模型原始数据已备份: {backup_file}")
+                original_size = os.path.getsize(effective_target_file)
+                self.logger.info(f"🔄 准备覆盖有效态Cd模型数据文件:")
+                self.logger.info(f"   文件路径: {effective_target_file}")
+                self.logger.info(f"   原始文件大小: {original_size:,} bytes ({original_size/1024:.1f} KB)")
+                
+                # 清理现有的备份文件(如果存在)
+                self._cleanup_backup_files(effective_target_file, max_backups=0)
             
             # 检查用户数据是否包含足够的环境因子列
             user_env_columns = df.shape[1] - 2  # 减去经纬度列
@@ -711,6 +741,9 @@ class CdPredictionService:
         copied_files = {}
         
         try:
+            self.logger.info(f"🔄 开始复制输出文件 (模型类型: {model_type})")
+            total_size = 0
+            
             # 复制地图文件
             if latest_outputs.get('latest_map'):
                 src_map = latest_outputs['latest_map']
@@ -720,7 +753,22 @@ class CdPredictionService:
                 )
                 shutil.copy2(src_map, dst_map)
                 copied_files['map_path'] = dst_map
-                self.logger.info(f"地图文件已复制到: {dst_map}")
+                
+                # 记录详细信息
+                src_size = os.path.getsize(src_map) if os.path.exists(src_map) else 0
+                dst_size = os.path.getsize(dst_map) if os.path.exists(dst_map) else 0
+                total_size += dst_size
+                self.logger.info(f"🗺️  地图文件已复制:")
+                self.logger.info(f"   源文件: {src_map}")
+                self.logger.info(f"   目标文件: {dst_map}")
+                self.logger.info(f"   文件大小: {dst_size:,} bytes ({dst_size/1024:.1f} KB)")
+                
+                # 激进清理:立即删除源文件
+                try:
+                    os.remove(src_map)
+                    self.logger.info(f"🗑️  已删除源地图文件: {os.path.basename(src_map)}")
+                except Exception as e:
+                    self.logger.warning(f"删除源地图文件失败: {str(e)}")
             
             # 复制直方图文件
             if latest_outputs.get('latest_histogram'):
@@ -731,18 +779,53 @@ class CdPredictionService:
                 )
                 shutil.copy2(src_histogram, dst_histogram)
                 copied_files['histogram_path'] = dst_histogram
-                self.logger.info(f"直方图文件已复制到: {dst_histogram}")
-            
-            # 复制栅格文件
+                
+                # 记录详细信息
+                src_size = os.path.getsize(src_histogram) if os.path.exists(src_histogram) else 0
+                dst_size = os.path.getsize(dst_histogram) if os.path.exists(dst_histogram) else 0
+                total_size += dst_size
+                self.logger.info(f"📊 直方图文件已复制:")
+                self.logger.info(f"   源文件: {src_histogram}")
+                self.logger.info(f"   目标文件: {dst_histogram}")
+                self.logger.info(f"   文件大小: {dst_size:,} bytes ({dst_size/1024:.1f} KB)")
+                
+                # 激进清理:立即删除源文件
+                try:
+                    os.remove(src_histogram)
+                    self.logger.info(f"🗑️  已删除源直方图文件: {os.path.basename(src_histogram)}")
+                except Exception as e:
+                    self.logger.warning(f"删除源直方图文件失败: {str(e)}")
+            
+            # 处理栅格文件(不复制,直接删除源文件以节省空间)
             if latest_outputs.get('latest_raster'):
                 src_raster = latest_outputs['latest_raster']
-                dst_raster = os.path.join(
-                    self.output_raster_dir, 
-                    f"{model_type}_prediction_raster_{timestamp}.tif"
-                )
-                shutil.copy2(src_raster, dst_raster)
-                copied_files['raster_path'] = dst_raster
-                self.logger.info(f"栅格文件已复制到: {dst_raster}")
+                
+                # 记录栅格文件信息但不复制
+                src_size = os.path.getsize(src_raster) if os.path.exists(src_raster) else 0
+                self.logger.info(f"🌐 栅格文件处理:")
+                self.logger.info(f"   源文件: {src_raster}")
+                self.logger.info(f"   文件大小: {src_size:,} bytes ({src_size/1024:.1f} KB)")
+                self.logger.info(f"   处理方式: 跳过复制,直接删除(栅格文件为中间文件)")
+                
+                # 激进清理:直接删除源文件,不进行复制
+                try:
+                    os.remove(src_raster)
+                    self.logger.info(f"🗑️  已删除中间栅格文件: {os.path.basename(src_raster)}")
+                    self.logger.info(f"   节省空间: {src_size:,} bytes ({src_size/1024:.1f} KB)")
+                except Exception as e:
+                    self.logger.warning(f"删除源栅格文件失败: {str(e)}")
+                
+                # 不设置raster_path,因为不需要保留栅格文件
+                copied_files['raster_path'] = None
+            
+            # 记录总体信息
+            self.logger.info(f"✅ 文件复制完成,总大小: {total_size:,} bytes ({total_size/1024/1024:.1f} MB)")
+            
+            # 清理Cd预测系统原始输出文件
+            self._cleanup_cd_system_outputs()
+            
+            # 清理中间数据文件
+            self._cleanup_intermediate_data_files()
             
         except Exception as e:
             self.logger.error(f"复制输出文件失败: {str(e)}")
@@ -766,9 +849,10 @@ class CdPredictionService:
             histogram_pattern = os.path.join(self.output_figures_dir, f"{model_type}_prediction_histogram_*.jpg")
             self._cleanup_files_by_pattern(histogram_pattern, max_files)
             
-            # 清理栅格文件
-            raster_pattern = os.path.join(self.output_raster_dir, f"{model_type}_prediction_raster_*.tif")
-            self._cleanup_files_by_pattern(raster_pattern, max_files)
+            # 不再需要清理API栅格文件,因为我们不再保留栅格文件
+            # raster_pattern = os.path.join(self.output_raster_dir, f"{model_type}_prediction_raster_*.tif")
+            # self._cleanup_files_by_pattern(raster_pattern, max_files)
+            self.logger.info(f"跳过栅格文件清理(栅格文件已不再保留)")
             
         except Exception as e:
             self.logger.warning(f"清理旧文件失败: {str(e)}")
@@ -791,6 +875,206 @@ class CdPredictionService:
         except Exception as e:
             self.logger.warning(f"清理文件失败 {pattern}: {str(e)}")
     
+    def _cleanup_temp_files(self, county_name: str, max_files: int = 5):
+        """
+        清理临时数据文件,只保留指定县市的最新文件
+        
+        @param {str} county_name - 县市名称
+        @param {int} max_files - 最大保留文件数,默认5个
+        """
+        try:
+            temp_dir = os.path.join(self.output_data_dir, "temp")
+            temp_pattern = os.path.join(temp_dir, f"{county_name}_temp_data_*.csv")
+            
+            # 获取该县市的所有临时文件
+            temp_files = glob.glob(temp_pattern)
+            
+            if len(temp_files) > max_files:
+                # 按修改时间排序,删除最旧的文件
+                temp_files.sort(key=os.path.getmtime)
+                files_to_delete = temp_files[:-max_files]
+                
+                for file_to_delete in files_to_delete:
+                    os.remove(file_to_delete)
+                    self.logger.info(f"已删除旧临时文件: {os.path.basename(file_to_delete)}")
+                
+                self.logger.info(f"已清理{county_name}的临时文件,保留最新{max_files}个文件")
+                
+        except Exception as e:
+            self.logger.warning(f"清理{county_name}临时文件失败: {str(e)}")
+    
+    def _cleanup_backup_files(self, target_file: str, max_backups: int = 3):
+        """
+        清理备份文件,只保留最新的备份
+        
+        @param {str} target_file - 目标文件路径
+        @param {int} max_backups - 最大保留备份数,默认3个
+        """
+        try:
+            # 构建备份文件匹配模式
+            backup_pattern = f"{target_file}.backup_*"
+            backup_files = glob.glob(backup_pattern)
+            
+            if len(backup_files) > max_backups:
+                # 按修改时间排序,删除最旧的备份文件
+                backup_files.sort(key=os.path.getmtime)
+                files_to_delete = backup_files[:-max_backups]
+                
+                for file_to_delete in files_to_delete:
+                    os.remove(file_to_delete)
+                    self.logger.info(f"已删除旧备份文件: {os.path.basename(file_to_delete)}")
+                
+                self.logger.info(f"已清理备份文件,保留最新{max_backups}个备份")
+                
+        except Exception as e:
+            self.logger.warning(f"清理备份文件失败: {str(e)}")
+    
+    def _delete_all_files_by_pattern(self, pattern: str) -> Tuple[int, int]:
+        """
+        删除所有匹配模式的文件(激进清理模式)
+        
+        @param {str} pattern - 文件匹配模式
+        @returns {Tuple[int, int]} 返回删除的文件数量和释放的字节数
+        """
+        deleted_count = 0
+        total_size_freed = 0
+        
+        try:
+            files = glob.glob(pattern)
+            
+            for file_path in files:
+                try:
+                    # 获取文件大小
+                    file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
+                    
+                    # 删除文件
+                    os.remove(file_path)
+                    
+                    deleted_count += 1
+                    total_size_freed += file_size
+                    
+                    self.logger.info(f"🗑️  已删除中间文件: {os.path.basename(file_path)} ({file_size:,} bytes)")
+                    
+                except Exception as e:
+                    self.logger.warning(f"删除文件失败 {file_path}: {str(e)}")
+            
+            if deleted_count > 0:
+                self.logger.info(f"🧹 模式 '{os.path.basename(pattern)}' 清理完成: {deleted_count} 个文件, {total_size_freed:,} bytes")
+                
+        except Exception as e:
+            self.logger.warning(f"按模式删除文件失败 {pattern}: {str(e)}")
+        
+        return deleted_count, total_size_freed
+    
+    def _cleanup_cd_system_outputs(self, aggressive_mode: bool = True):
+        """
+        清理Cd预测系统原始输出文件(激进模式:删除所有中间文件)
+        
+        @param {bool} aggressive_mode - 激进模式,默认True(删除所有中间文件)
+        """
+        try:
+            cd_system_path = self.config.get_cd_system_path()
+            output_figures_dir = os.path.join(cd_system_path, "output", "figures")
+            output_raster_dir = os.path.join(cd_system_path, "output", "raster")
+            
+            total_deleted = 0
+            total_size_freed = 0
+            
+            # 清理figures目录下的所有预测输出文件
+            if os.path.exists(output_figures_dir):
+                # 立即删除所有地图文件
+                map_patterns = [
+                    "Prediction_results_*.jpg",
+                    "Prediction_results_*.png"
+                ]
+                for pattern in map_patterns:
+                    full_pattern = os.path.join(output_figures_dir, pattern)
+                    deleted, size_freed = self._delete_all_files_by_pattern(full_pattern)
+                    total_deleted += deleted
+                    total_size_freed += size_freed
+                
+                # 立即删除所有直方图文件
+                histogram_patterns = [
+                    "Prediction_frequency_*.jpg", 
+                    "Prediction_frequency_*.png"
+                ]
+                for pattern in histogram_patterns:
+                    full_pattern = os.path.join(output_figures_dir, pattern)
+                    deleted, size_freed = self._delete_all_files_by_pattern(full_pattern)
+                    total_deleted += deleted
+                    total_size_freed += size_freed
+            
+            # 清理raster目录下的所有文件
+            if os.path.exists(output_raster_dir):
+                # 立即删除所有栅格相关文件
+                raster_patterns = [
+                    "output_*.tif",
+                    "points_*.shp",
+                    "points_*.dbf", 
+                    "points_*.prj",
+                    "points_*.shx",
+                    "points_*.cpg"
+                ]
+                for pattern in raster_patterns:
+                    full_pattern = os.path.join(output_raster_dir, pattern)
+                    deleted, size_freed = self._delete_all_files_by_pattern(full_pattern)
+                    total_deleted += deleted
+                    total_size_freed += size_freed
+            
+            self.logger.info(f"🧹 激进清理完成:")
+            self.logger.info(f"   删除文件数: {total_deleted} 个")
+            self.logger.info(f"   释放空间: {total_size_freed:,} bytes ({total_size_freed/1024/1024:.2f} MB)")
+            
+        except Exception as e:
+            self.logger.warning(f"激进清理Cd预测系统输出文件失败: {str(e)}")
+    
+    def _cleanup_intermediate_data_files(self):
+        """
+        清理中间数据文件
+        包括:坐标.csv 和 combined_pH.csv, pHcombined.csv
+        """
+        try:
+            cd_system_path = self.config.get_cd_system_path()
+            deleted_count = 0
+            total_size_freed = 0
+            
+            # 要清理的中间数据文件
+            intermediate_files = [
+                os.path.join(cd_system_path, "data", "coordinates", "坐标.csv"),
+                os.path.join(cd_system_path, "data", "predictions", "combined_pH.csv"),
+                os.path.join(cd_system_path, "data", "predictions", "pHcombined.csv")
+            ]
+            
+            self.logger.info("🧹 开始清理中间数据文件...")
+            
+            for file_path in intermediate_files:
+                if os.path.exists(file_path):
+                    try:
+                        file_size = os.path.getsize(file_path)
+                        os.remove(file_path)
+                        
+                        deleted_count += 1
+                        total_size_freed += file_size
+                        
+                        self.logger.info(f"🗑️  已删除中间数据文件: {os.path.basename(file_path)}")
+                        self.logger.info(f"   文件路径: {file_path}")
+                        self.logger.info(f"   文件大小: {file_size:,} bytes ({file_size/1024:.1f} KB)")
+                        
+                    except Exception as e:
+                        self.logger.warning(f"删除中间数据文件失败 {file_path}: {str(e)}")
+                else:
+                    self.logger.debug(f"中间数据文件不存在,跳过: {os.path.basename(file_path)}")
+            
+            if deleted_count > 0:
+                self.logger.info(f"✅ 中间数据文件清理完成:")
+                self.logger.info(f"   删除文件数: {deleted_count} 个")
+                self.logger.info(f"   释放空间: {total_size_freed:,} bytes ({total_size_freed/1024:.1f} KB)")
+            else:
+                self.logger.info("ℹ️  没有找到需要清理的中间数据文件")
+                
+        except Exception as e:
+            self.logger.warning(f"清理中间数据文件失败: {str(e)}")
+    
     def _get_file_stats(self, file_path: Optional[str]) -> Dict[str, Any]:
         """
         获取文件统计信息

+ 13 - 0
app/utils/__init__.py

@@ -0,0 +1,13 @@
+"""
+工具模块包
+Utils Package
+"""
+
+from .mapping_utils import MappingUtils, COLORMAPS, get_available_colormaps, csv_to_raster_workflow
+
+__all__ = [
+    'MappingUtils',
+    'COLORMAPS', 
+    'get_available_colormaps',
+    'csv_to_raster_workflow'
+]

+ 553 - 0
app/utils/mapping_utils.py

@@ -0,0 +1,553 @@
+"""
+通用绘图工具模块
+Universal Mapping and Visualization Utils
+
+整合了CSV转GeoTIFF、栅格地图绘制和直方图生成功能
+基于01_Transfer_csv_to_geotif.py和02_Figure_raster_mapping.py的更新代码
+
+Author: Integrated from Wanxue Zhu's code
+"""
+
+import pandas as pd
+import geopandas as gpd
+from shapely.geometry import Point
+import rasterio
+from rasterio.features import rasterize
+from rasterio.transform import from_origin
+from rasterio.mask import mask
+from rasterio.plot import show
+import numpy as np
+import os
+import json
+import logging
+from scipy.interpolate import griddata
+from scipy.ndimage import distance_transform_edt
+import matplotlib.pyplot as plt
+from matplotlib.colors import ListedColormap, BoundaryNorm
+import seaborn as sns
+import warnings
+
+warnings.filterwarnings('ignore')
+
+# 配置日志
+logger = logging.getLogger(__name__)
+
+# 设置matplotlib的中文字体和样式
+plt.rcParams['font.family'] = 'Arial'
+plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题
+plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']  # 添加多个中文字体
+
+# 预定义的色彩方案
+COLORMAPS = {
+    'yellow_orange_brown': ['#FFFECE', '#FFF085', '#FEBA17', '#BE3D2A', '#74512D', '#4E1F00'],  # 黄-橙-棕
+    'blue_series': ['#F6F8D5', '#98D2C0', '#4F959D', '#205781', '#143D60', '#2A3335'],  # 蓝色系
+    'yellow_green': ['#FFEFC8', '#F8ED8C', '#D3E671', '#89AC46', '#5F8B4C', '#355F2E'],  # 淡黄-草绿
+    'green_brown': ['#F0F1C5', '#BBD8A3', '#6F826A', '#BF9264', '#735557', '#604652'],  # 绿色-棕色
+    'yellow_pink_purple': ['#FCFAEE', '#FBF3B9', '#FFDCCC', '#FDB7EA', '#B7B1F2', '#8D77AB'],  # 黄-粉-紫
+    'green_yellow_red_purple': ['#15B392', '#73EC8B', '#FFEB55', '#EE66A6', '#D91656', '#640D5F'],  # 绿-黄-红-紫
+}
+
+
+class MappingUtils:
+    """
+    通用绘图工具类
+    提供CSV转换、栅格处理、地图绘制和直方图生成功能
+    """
+    
+    def __init__(self, log_level=logging.INFO):
+        """
+        初始化绘图工具
+        
+        @param log_level: 日志级别
+        """
+        self.logger = logging.getLogger(self.__class__.__name__)
+        self.logger.setLevel(log_level)
+        
+        if not self.logger.handlers:
+            handler = logging.StreamHandler()
+            formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+            handler.setFormatter(formatter)
+            self.logger.addHandler(handler)
+    
+    def csv_to_shapefile(self, csv_file, shapefile_output, lon_col=0, lat_col=1, value_col=2):
+        """
+        将CSV文件转换为Shapefile文件
+        
+        @param csv_file: CSV文件路径
+        @param shapefile_output: 输出Shapefile文件路径
+        @param lon_col: 经度列索引或列名,默认第0列
+        @param lat_col: 纬度列索引或列名,默认第1列  
+        @param value_col: 数值列索引或列名,默认第2列
+        @return: 输出的shapefile路径
+        """
+        try:
+            self.logger.info(f"开始转换CSV到Shapefile: {csv_file}")
+            
+            # 读取CSV数据
+            df = pd.read_csv(csv_file)
+            
+            # 支持列索引或列名
+            if isinstance(lon_col, int):
+                lon = df.iloc[:, lon_col]
+            else:
+                lon = df[lon_col]
+                
+            if isinstance(lat_col, int):
+                lat = df.iloc[:, lat_col]
+            else:
+                lat = df[lat_col]
+                
+            if isinstance(value_col, int):
+                val = df.iloc[:, value_col]
+            else:
+                val = df[value_col]
+            
+            # 创建几何对象
+            geometry = [Point(xy) for xy in zip(lon, lat)]
+            gdf = gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")
+            
+            # 确保输出目录存在
+            os.makedirs(os.path.dirname(shapefile_output), exist_ok=True)
+            
+            # 保存Shapefile
+            gdf.to_file(shapefile_output, driver="ESRI Shapefile")
+            self.logger.info(f"✓ 成功转换CSV到Shapefile: {shapefile_output}")
+            
+            return shapefile_output
+            
+        except Exception as e:
+            self.logger.error(f"CSV转Shapefile失败: {str(e)}")
+            raise
+    
+    def create_boundary_mask(self, raster, transform, gdf):
+        """
+        创建边界掩膜,只保留边界内的区域
+        
+        @param raster: 栅格数据
+        @param transform: 栅格变换参数
+        @param gdf: 矢量边界数据
+        @return: 边界掩膜
+        """
+        try:
+            mask = rasterize(
+                gdf.geometry,
+                out_shape=raster.shape,
+                transform=transform,
+                fill=0,
+                default_value=1,
+                dtype=np.uint8
+            )
+            return mask.astype(bool)
+        except Exception as e:
+            self.logger.error(f"创建边界掩膜失败: {str(e)}")
+            raise
+    
+    def interpolate_nan_values(self, raster, method='nearest'):
+        """
+        使用插值方法填充NaN值
+        
+        @param raster: 包含NaN值的栅格数据
+        @param method: 插值方法 ('nearest', 'linear', 'cubic')
+        @return: 插值后的栅格数据
+        """
+        try:
+            if not np.isnan(raster).any():
+                return raster
+            
+            # 获取有效值的坐标
+            valid_mask = ~np.isnan(raster)
+            valid_coords = np.where(valid_mask)
+            valid_values = raster[valid_mask]
+            
+            if len(valid_values) == 0:
+                self.logger.warning("没有有效值用于插值")
+                return raster
+            
+            # 创建网格坐标
+            rows, cols = raster.shape
+            grid_x, grid_y = np.mgrid[0:rows, 0:cols]
+            
+            # 准备插值坐标
+            points = np.column_stack((valid_coords[0], valid_coords[1]))
+            
+            # 执行插值
+            interpolated = griddata(points, valid_values, (grid_x, grid_y), 
+                                   method=method, fill_value=np.nan)
+            
+            # 如果插值后仍有NaN值,使用最近邻方法填充
+            if np.isnan(interpolated).any():
+                self.logger.info(f"使用 {method} 插值后仍有NaN值,使用最近邻方法填充剩余值")
+                remaining_nan = np.isnan(interpolated)
+                remaining_coords = np.where(remaining_nan)
+                
+                if len(remaining_coords[0]) > 0:
+                    # 使用距离变换找到最近的已知值
+                    dist, indices = distance_transform_edt(remaining_nan, 
+                                                         return_distances=True, 
+                                                         return_indices=True)
+                    
+                    # 填充剩余的NaN值
+                    for i, j in zip(remaining_coords[0], remaining_coords[1]):
+                        if indices[0, i, j] < rows and indices[1, i, j] < cols:
+                            interpolated[i, j] = raster[indices[0, i, j], indices[1, i, j]]
+            
+            return interpolated
+            
+        except Exception as e:
+            self.logger.error(f"插值失败: {str(e)}")
+            return raster
+    
+    def vector_to_raster(self, input_shapefile, template_tif, output_tif, field, 
+                        resolution_factor=16.0, boundary_shp=None, interpolation_method='nearest'):
+        """
+        将点矢量数据转换为栅格数据
+        
+        @param input_shapefile: 输入点矢量数据的Shapefile文件路径
+        @param template_tif: 用作模板的GeoTIFF文件路径
+        @param output_tif: 输出栅格化后的GeoTIFF文件路径
+        @param field: 用于栅格化的属性字段名
+        @param resolution_factor: 分辨率倍数因子
+        @param boundary_shp: 边界Shapefile文件路径,用于创建掩膜
+        @param interpolation_method: 插值方法 ('nearest', 'linear', 'cubic')
+        @return: 输出的GeoTIFF文件路径和统计信息
+        """
+        try:
+            self.logger.info(f"开始处理: {input_shapefile}")
+            self.logger.info(f"分辨率因子: {resolution_factor}, 插值方法: {interpolation_method}")
+            
+            # 读取矢量数据
+            gdf = gpd.read_file(input_shapefile)
+            
+            # 读取模板栅格
+            with rasterio.open(template_tif) as src:
+                template_meta = src.meta.copy()
+                
+                # 根据分辨率因子计算新的尺寸和变换参数
+                if resolution_factor != 1.0:
+                    width = int(src.width * resolution_factor)
+                    height = int(src.height * resolution_factor)
+                    
+                    transform = rasterio.Affine(
+                        src.transform.a / resolution_factor,
+                        src.transform.b,
+                        src.transform.c,
+                        src.transform.d,
+                        src.transform.e / resolution_factor,
+                        src.transform.f
+                    )
+                    self.logger.info(f"分辨率调整: {src.width}x{src.height} -> {width}x{height}")
+                else:
+                    width = src.width
+                    height = src.height
+                    transform = src.transform
+                    self.logger.info(f"保持原始分辨率: {width}x{height}")
+                
+                crs = src.crs
+            
+            # 投影矢量数据
+            if gdf.crs != crs:
+                gdf = gdf.to_crs(crs)
+            
+            # 栅格化
+            shapes = ((geom, value) for geom, value in zip(gdf.geometry, gdf[field]))
+            raster = rasterize(
+                shapes=shapes,
+                out_shape=(height, width),
+                transform=transform,
+                fill=np.nan,
+                dtype='float32'
+            )
+            
+            # 应用边界掩膜(如果提供)
+            if boundary_shp and os.path.exists(boundary_shp):
+                self.logger.info(f"应用边界掩膜: {boundary_shp}")
+                boundary_gdf = gpd.read_file(boundary_shp)
+                if boundary_gdf.crs != crs:
+                    boundary_gdf = boundary_gdf.to_crs(crs)
+                boundary_mask = self.create_boundary_mask(raster, transform, boundary_gdf)
+                raster[~boundary_mask] = np.nan
+            
+            # 使用插值方法填充NaN值
+            if np.isnan(raster).any():
+                self.logger.info(f"使用 {interpolation_method} 方法进行插值...")
+                raster = self.interpolate_nan_values(raster, method=interpolation_method)
+            
+            # 创建输出目录
+            os.makedirs(os.path.dirname(output_tif), exist_ok=True)
+            
+            # 更新元数据
+            template_meta.update({
+                "count": 1,
+                "dtype": 'float32',
+                "nodata": np.nan,
+                "width": width,
+                "height": height,
+                "transform": transform
+            })
+            
+            # 保存栅格文件
+            with rasterio.open(output_tif, 'w', **template_meta) as dst:
+                dst.write(raster, 1)
+            
+            # 计算统计信息
+            valid_data = raster[~np.isnan(raster)]
+            stats = None
+            if len(valid_data) > 0:
+                stats = {
+                    'min': float(np.min(valid_data)),
+                    'max': float(np.max(valid_data)),
+                    'mean': float(np.mean(valid_data)),
+                    'std': float(np.std(valid_data)),
+                    'valid_pixels': int(len(valid_data)),
+                    'total_pixels': int(raster.size)
+                }
+                self.logger.info(f"统计信息: 有效像素 {stats['valid_pixels']}/{stats['total_pixels']}")
+                self.logger.info(f"数值范围: {stats['min']:.4f} - {stats['max']:.4f}")
+            else:
+                self.logger.warning("没有有效数据")
+            
+            self.logger.info(f"✓ 成功保存: {output_tif}")
+            return output_tif, stats
+            
+        except Exception as e:
+            self.logger.error(f"矢量转栅格失败: {str(e)}")
+            raise
+    
+    def create_raster_map(self, shp_path, tif_path, output_path, 
+                         colormap='green_yellow_red_purple', title="Prediction Map",
+                         output_size=12, figsize=None, dpi=300):
+        """
+        创建栅格地图
+        
+        @param shp_path: 输入的矢量数据路径
+        @param tif_path: 输入的栅格数据路径
+        @param output_path: 输出图片路径(不包含扩展名)
+        @param colormap: 色彩方案名称或颜色列表
+        @param title: 图片标题
+        @param output_size: 图片尺寸(正方形),如果指定了figsize则忽略此参数
+        @param figsize: 图片尺寸元组 (width, height),优先级高于output_size
+        @param dpi: 图片分辨率
+        @return: 输出图片文件路径
+        """
+        try:
+            self.logger.info(f"开始创建栅格地图: {tif_path}")
+            
+            # 读取矢量边界
+            gdf = gpd.read_file(shp_path) if shp_path else None
+            
+            # 读取并裁剪栅格数据
+            with rasterio.open(tif_path) as src:
+                if gdf is not None:
+                    # 确保坐标系一致
+                    if gdf.crs != src.crs:
+                        gdf = gdf.to_crs(src.crs)
+                    
+                    # 裁剪栅格
+                    geoms = [json.loads(gdf.to_json())["features"][0]["geometry"]]
+                    out_image, out_transform = mask(src, geoms, crop=True)
+                    out_meta = src.meta.copy()
+                else:
+                    # 如果没有边界文件,使用整个栅格
+                    out_image = src.read()
+                    out_transform = src.transform
+                    out_meta = src.meta.copy()
+            
+            # 提取数据并处理无效值
+            raster = out_image[0].astype('float32')
+            nodata = out_meta.get("nodata", None)
+            if nodata is not None:
+                raster[raster == nodata] = np.nan
+            
+            # 检查是否有有效数据
+            if np.all(np.isnan(raster)):
+                raise ValueError("栅格数据中没有有效值")
+            
+            # 根据分位数分为6个等级
+            bounds = np.nanpercentile(raster, [0, 20, 40, 60, 80, 90, 100])
+            norm = BoundaryNorm(bounds, ncolors=len(bounds) - 1)
+            
+            # 获取色彩方案
+            if isinstance(colormap, str):
+                if colormap in COLORMAPS:
+                    color_list = COLORMAPS[colormap]
+                else:
+                    self.logger.warning(f"未知色彩方案: {colormap},使用默认方案")
+                    color_list = COLORMAPS['green_yellow_red_purple']
+            else:
+                color_list = colormap
+            
+            cmap = ListedColormap(color_list)
+            
+            # 设置图片尺寸
+            if figsize is not None:
+                fig_size = figsize
+            else:
+                fig_size = (output_size, output_size)
+            
+            # 绘图
+            fig, ax = plt.subplots(figsize=fig_size)
+            show(raster, transform=out_transform, ax=ax, cmap=cmap, norm=norm)
+            
+            # 添加矢量边界
+            if gdf is not None:
+                gdf.boundary.plot(ax=ax, color='black', linewidth=1)
+            
+            # 设置标题和标签
+            ax.set_title(title, fontsize=20)
+            ax.set_xlabel("Longitude", fontsize=18)
+            ax.set_ylabel("Latitude", fontsize=18)
+            ax.grid(True, linestyle='--', color='gray', alpha=0.5)
+            ax.tick_params(axis='y', labelrotation=90)
+            
+            # 添加色带
+            tick_labels = [f"{bounds[i]:.1f}" for i in range(len(bounds) - 1)]
+            cbar = plt.colorbar(
+                plt.cm.ScalarMappable(norm=norm, cmap=cmap),
+                ax=ax,
+                ticks=[(bounds[i] + bounds[i+1]) / 2 for i in range(len(bounds) - 1)],
+                shrink=0.6,
+                aspect=15
+            )
+            cbar.ax.set_yticklabels(tick_labels)
+            cbar.set_label("Values")
+            
+            plt.tight_layout()
+            
+            # 确保输出目录存在
+            os.makedirs(os.path.dirname(output_path), exist_ok=True)
+            
+            # 保存图片
+            output_file = f"{output_path}.jpg"
+            plt.savefig(output_file, dpi=dpi, format='jpg', bbox_inches='tight')
+            plt.close()
+            
+            self.logger.info(f"✓ 栅格地图创建成功: {output_file}")
+            return output_file
+            
+        except Exception as e:
+            self.logger.error(f"栅格地图创建失败: {str(e)}")
+            raise
+    
+    def create_histogram(self, file_path, save_path=None, figsize=(10, 6),
+                        xlabel='像元值', ylabel='频率密度', title='数值分布图',
+                        bins=100, dpi=300):
+        """
+        绘制GeoTIFF文件的直方图
+        
+        @param file_path: GeoTIFF文件路径
+        @param save_path: 保存路径,如果为None则自动生成
+        @param figsize: 图像尺寸
+        @param xlabel: 横坐标标签
+        @param ylabel: 纵坐标标签
+        @param title: 图标题
+        @param bins: 直方图箱数
+        @param dpi: 图片分辨率
+        @return: 输出图片文件路径
+        """
+        try:
+            self.logger.info(f"开始创建直方图: {file_path}")
+            
+            # 设置seaborn样式
+            sns.set(style='ticks')
+            
+            # 读取栅格数据
+            with rasterio.open(file_path) as src:
+                band = src.read(1)
+                nodata = src.nodata
+            
+            # 处理无效值
+            if nodata is not None:
+                band = np.where(band == nodata, np.nan, band)
+            
+            # 展平数据并移除NaN值
+            band_flat = band.flatten()
+            band_flat = band_flat[~np.isnan(band_flat)]
+            
+            if len(band_flat) == 0:
+                raise ValueError("栅格数据中没有有效值")
+            
+            # 创建图形
+            plt.figure(figsize=figsize)
+            
+            # 绘制直方图和密度曲线
+            sns.histplot(band_flat, bins=bins, color='steelblue', alpha=0.7, 
+                        edgecolor='black', stat='density')
+            sns.kdeplot(band_flat, color='red', linewidth=2)
+            
+            # 设置标签和标题
+            plt.xlabel(xlabel, fontsize=14)
+            plt.ylabel(ylabel, fontsize=14)
+            plt.title(title, fontsize=16)
+            plt.grid(True, linestyle='--', alpha=0.5)
+            plt.tight_layout()
+            
+            # 保存图片
+            if save_path is None:
+                save_path = file_path.replace('.tif', '_histogram.jpg')
+            
+            # 确保输出目录存在
+            os.makedirs(os.path.dirname(save_path), exist_ok=True)
+            
+            plt.savefig(save_path, dpi=dpi, format='jpg', bbox_inches='tight')
+            plt.close()
+            
+            self.logger.info(f"✓ 直方图创建成功: {save_path}")
+            return save_path
+            
+        except Exception as e:
+            self.logger.error(f"直方图创建失败: {str(e)}")
+            raise
+
+
+def get_available_colormaps():
+    """
+    获取可用的色彩方案列表
+    
+    @return: 色彩方案字典
+    """
+    return COLORMAPS.copy()
+
+
+def csv_to_raster_workflow(csv_file, template_tif, output_dir, 
+                          boundary_shp=None, resolution_factor=16.0,
+                          interpolation_method='nearest', field_name='Prediction',
+                          lon_col=0, lat_col=1, value_col=2):
+    """
+    完整的CSV到栅格转换工作流
+    
+    @param csv_file: CSV文件路径
+    @param template_tif: 模板GeoTIFF文件路径
+    @param output_dir: 输出目录
+    @param boundary_shp: 边界Shapefile文件路径(可选)
+    @param resolution_factor: 分辨率因子
+    @param interpolation_method: 插值方法
+    @param field_name: 字段名称
+    @param lon_col: 经度列
+    @param lat_col: 纬度列
+    @param value_col: 数值列
+    @return: 输出文件路径字典
+    """
+    mapper = MappingUtils()
+    
+    # 确保输出目录存在
+    os.makedirs(output_dir, exist_ok=True)
+    
+    # 生成文件名
+    base_name = os.path.splitext(os.path.basename(csv_file))[0]
+    shapefile_path = os.path.join(output_dir, f"{base_name}_points.shp")
+    raster_path = os.path.join(output_dir, f"{base_name}_raster.tif")
+    
+    # 1. CSV转Shapefile
+    mapper.csv_to_shapefile(csv_file, shapefile_path, lon_col, lat_col, value_col)
+    
+    # 2. Shapefile转栅格
+    raster_path, stats = mapper.vector_to_raster(
+        shapefile_path, template_tif, raster_path, field_name,
+        resolution_factor, boundary_shp, interpolation_method
+    )
+    
+    return {
+        'shapefile': shapefile_path,
+        'raster': raster_path,
+        'statistics': stats
+    }

+ 185 - 0
docs/mapping_utils_guide.md

@@ -0,0 +1,185 @@
+# 通用绘图模块使用指南
+
+## 概述
+
+`app.utils.mapping_utils` 是一个统一的绘图工具模块,整合了CSV转GeoTIFF、栅格地图绘制和直方图生成等功能。该模块提供了标准化的接口,供项目中的所有组件调用。
+
+## 主要功能
+
+### 1. CSV到Shapefile转换
+```python
+from app.utils.mapping_utils import MappingUtils
+
+mapper = MappingUtils()
+shapefile_path = mapper.csv_to_shapefile(
+    csv_file="data.csv",
+    shapefile_output="output.shp",
+    lon_col=0,  # 经度列索引或列名
+    lat_col=1,  # 纬度列索引或列名
+    value_col=2  # 数值列索引或列名
+)
+```
+
+### 2. 矢量到栅格转换
+```python
+raster_path, stats = mapper.vector_to_raster(
+    input_shapefile="points.shp",
+    template_tif="template.tif",
+    output_tif="output.tif",
+    field="value_field",
+    resolution_factor=1.0,  # 分辨率倍数
+    boundary_shp="boundary.shp",  # 可选边界文件
+    interpolation_method='nearest'  # 插值方法
+)
+```
+
+### 3. 栅格地图绘制
+```python
+map_path = mapper.create_raster_map(
+    shp_path="boundary.shp",
+    tif_path="data.tif",
+    output_path="map",  # 不包含扩展名
+    colormap='green_yellow_red_purple',  # 色彩方案
+    title="预测结果地图",
+    output_size=12,  # 图片尺寸
+    dpi=300
+)
+```
+
+### 4. 直方图绘制
+```python
+hist_path = mapper.create_histogram(
+    file_path="data.tif",
+    save_path="histogram.jpg",
+    figsize=(10, 6),
+    xlabel='数值',
+    ylabel='频率',
+    title='数值分布',
+    dpi=300
+)
+```
+
+### 5. 完整工作流
+```python
+from app.utils.mapping_utils import csv_to_raster_workflow
+
+results = csv_to_raster_workflow(
+    csv_file="data.csv",
+    template_tif="template.tif",
+    output_dir="output/",
+    boundary_shp="boundary.shp",
+    resolution_factor=1.0
+)
+# 返回:{'shapefile': '...', 'raster': '...', 'statistics': {...}}
+```
+
+## 预定义色彩方案
+
+模块提供了6种预定义的色彩方案:
+
+```python
+from app.utils.mapping_utils import COLORMAPS, get_available_colormaps
+
+# 获取所有可用的色彩方案
+colormaps = get_available_colormaps()
+
+# 可用方案:
+# - 'yellow_orange_brown': 黄-橙-棕
+# - 'blue_series': 蓝色系  
+# - 'yellow_green': 淡黄-草绿
+# - 'green_brown': 绿色-棕色
+# - 'yellow_pink_purple': 黄-粉-紫
+# - 'green_yellow_red_purple': 绿-黄-红-紫
+```
+
+## 与现有代码的兼容性
+
+### Cd_Prediction_Integrated_System
+
+现有的 `Visualizer` 类已经更新为使用通用绘图模块,保持了原有的接口不变:
+
+```python
+from Cd_Prediction_Integrated_System.analysis.visualization import Visualizer
+
+visualizer = Visualizer()
+# 原有接口依然可用
+map_path = visualizer.create_raster_map(...)
+hist_path = visualizer.create_histogram(...)
+```
+
+### Water目录
+
+Water目录中的绘图函数已经更新为调用通用模块:
+
+```python
+from Water.Python_codes.Figure_raster_mapping import mapping_raster, plot_tif_histogram
+
+# 原有接口依然可用
+map_path = mapping_raster(shp_path, tif_path, colormap, title, output_path, size)
+hist_path = plot_tif_histogram(file_path, figsize, xlabel, ylabel, title, save_path)
+```
+
+## 在其他模块中使用
+
+### 在API中使用
+```python
+from app.utils.mapping_utils import MappingUtils
+
+def create_prediction_map(data_file, output_dir):
+    mapper = MappingUtils()
+    
+    # 创建地图
+    map_path = mapper.create_raster_map(
+        tif_path=data_file,
+        output_path=f"{output_dir}/prediction_map",
+        title="预测结果"
+    )
+    
+    # 创建直方图
+    hist_path = mapper.create_histogram(
+        file_path=data_file,
+        save_path=f"{output_dir}/prediction_histogram.jpg",
+        title="预测值分布"
+    )
+    
+    return {"map": map_path, "histogram": hist_path}
+```
+
+### 在服务中使用
+```python
+from app.utils.mapping_utils import MappingUtils
+
+class VisualizationService:
+    def __init__(self):
+        self.mapper = MappingUtils()
+    
+    def generate_maps(self, raster_file, boundary_file=None):
+        return self.mapper.create_raster_map(
+            shp_path=boundary_file,
+            tif_path=raster_file,
+            output_path="output/map"
+        )
+```
+
+## 注意事项
+
+1. **文件路径**: 确保输入文件存在且可读
+2. **坐标系**: 矢量和栅格数据的坐标系会自动处理
+3. **输出目录**: 输出目录会自动创建
+4. **内存使用**: 大型栅格文件可能占用较多内存
+5. **依赖库**: 需要安装 geopandas, rasterio, matplotlib, seaborn 等依赖
+
+## 错误处理
+
+所有函数都包含了完整的错误处理和日志记录:
+
+```python
+try:
+    result = mapper.create_raster_map(...)
+except FileNotFoundError as e:
+    print(f"文件不存在: {e}")
+except ValueError as e:
+    print(f"数据错误: {e}")
+except Exception as e:
+    print(f"处理失败: {e}")
+```