refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用

- 将主包名从geo_tools改为app
- 更新所有模块中的引用路径
- 迁移并更新测试用例
- 添加项目规则文档
- 保持原有功能不变,仅进行结构调整
This commit is contained in:
2026-04-12 19:49:56 +08:00
parent fcb8e1f255
commit db51d41aef
41 changed files with 4132 additions and 808 deletions

10
.trae/rules/geo_rules.md Normal file
View File

@@ -0,0 +1,10 @@
# Geo-Tools 项目特定编码规范
## 地理数据处理黄金法则
1. **投影先行**:任何涉及距离、面积的计算前,必须先检查 CRS若是地理坐标系(如 EPSG:4326),必须提示用户或自动调用 `suggest_projected_crs` 转换。
2. **几何清洗**:任何接收用户输入的 GeoDataFrame 的公开方法,内部必须包含 `gdf = gdf[gdf.is_valid]``make_valid()` 的处理逻辑,防止脏数据导致底层 C 库崩溃。
3. **内存控制**:读取大文件函数需要提供 `mask` (裁剪范围) 或 `rows` (读取行数限制) 参数。
## 代码风格
- 空间操作函数返回新的 GeoDataFrame尽量不修改原始输入数据Immutable
- 使用 `pathlib.Path` 处理所有文件路径,而不是字符串拼接。

View File

@@ -0,0 +1,8 @@
# 给 AI 助手看的规则 - 小白专用版
## 关于内存(最重要)
1. 凡是读文件的函数,只要没加 `rows` 限制或 `chunk_size` 分块,都要在注释里用【警告】两个字提醒用户注意内存风险。
2. 不要用 `for` 循环一行行修改 GeoDataFrame要用 `apply` 或者向量化操作。
## 关于报错
3. 禁止直接把底层库fiona, shapely的英文报错抛给用户看。必须包一层 try-except翻译成中文提示例如“文件打不开检查一下路径是不是有中文”或者“数据里有烂掉的图形已自动修复/跳过”。

128
README.md
View File

@@ -1,128 +0,0 @@
# geo_tools
> 专业地理信息数据处理工具库 —— 基于 geopandas / shapely / fiona
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
---
## 功能特性
- **统一 IO 接口**:一行代码读写 Shapefile、GeoJSON、GeoPackage、**File Geodatabase (GDB)**、KML、CSV 等格式
- **核心几何运算**:基于 Shapely 2.x 的缓冲区、集合运算、有效性检查与自动修复
- **坐标系处理**重投影、CRS 信息查询、批量坐标转换,内置中国常用 CRS 常量
- **空间分析**:叠置分析、最近邻、按位置选择、面积加权均值、属性统计汇总
- **配置驱动**:通过 `.env` 或环境变量控制输出路径、日志级别、默认 CRS 等
- **栅格预留接口**:为 rasterio 集成预留扩展点
## 项目结构
```
geo_tools/
├── geo_tools/ # 主包
│ ├── config/ # Pydantic BaseSettings 全局配置
│ ├── core/ # 核心处理vector / geometry / projection / raster
│ ├── io/ # 数据读写readers / writers含 GDB
│ ├── analysis/ # 空间分析spatial_ops / stats
│ └── utils/ # 通用工具logger / validators / config
├── scripts/ # 独立处理脚本
├── tests/ # pytest 测试套件
├── data/sample/ # 示例数据GeoJSON
├── output/ # 处理结果输出目录
├── logs/ # 日志文件目录
├── docs/ # 文档
└── pyproject.toml # 项目配置与依赖
```
## 快速开始
### 安装依赖
```bash
# 推荐使用 conda 安装地理库(避免 GDAL 编译问题)
conda install -c conda-forge geopandas shapely fiona pyproj
# 然后安装本项目(开发模式)
pip install -e ".[dev]"
```
### 基本使用
```python
import geo_tools
# 读取矢量数据(自动识别格式)
gdf = geo_tools.read_vector("data/sample/sample_points.geojson")
# 读写 File Geodatabase
layers = geo_tools.list_gdb_layers("path/to/data.gdb")
gdf = geo_tools.read_gdb("path/to/data.gdb", layer="my_layer")
geo_tools.write_gdb(gdf, "output/result.gdb", layer="result")
# 坐标系转换
gdf_proj = geo_tools.reproject(gdf, "EPSG:3857")
# 缓冲区分析
from geo_tools.core.geometry import buffer_geometry
buffered_geom = buffer_geometry(gdf.geometry[0], distance=1000)
# 空间叠置
from geo_tools.analysis.spatial_ops import overlay
result = geo_tools.overlay(layer_a, layer_b, how="intersection")
# 面积加权均值
from geo_tools.analysis.stats import area_weighted_mean
result = area_weighted_mean(polygon_gdf, value_col="soil_ph", group_col="region")
```
### 配置
复制 `.env.example``.env` 并按需修改:
```bash
GEO_TOOLS_OUTPUT_DIR=D:/output
GEO_TOOLS_DEFAULT_CRS=EPSG:4490
GEO_TOOLS_LOG_LEVEL=DEBUG
```
## 运行测试
```bash
# 运行全部测试
pytest tests/ -v
# 运行带覆盖率报告
pytest tests/ -v --cov=geo_tools --cov-report=html
```
## 运行示例脚本
```bash
python scripts/example_workflow.py
```
## GDB 支持说明
本项目通过 `fiona>=1.9``OpenFileGDB` 驱动读写 Esri File Geodatabase`.gdb`)。
| 操作 | 驱动 | 要求 |
|------|------|------|
| 读取 GDB | `OpenFileGDB` | fiona >= 1.9(内置) |
| 写出 GDB | `OpenFileGDB` | fiona >= 1.9(内置) |
| 编辑 GDB高级 | `FileGDB` | 需要 ESRI FileGDB API |
```python
# 列出所有图层
layers = geo_tools.list_gdb_layers("data.gdb")
# 读取指定图层
gdf = geo_tools.read_gdb("data.gdb", layer="土地利用", crs="EPSG:4490")
# 写出到 GDB新建或追加图层
geo_tools.write_gdb(result_gdf, "output.gdb", layer="分析结果", mode="w")
```
## 许可证
MIT License

468
TUTORIAL.md Normal file
View File

@@ -0,0 +1,468 @@
# Geo-Tools 使用教程
## 1. 安装与环境准备
### 1.1 前提条件
在安装 Geo-Tools 之前,您需要确保已经安装了以下依赖:
- Python 3.8 或更高版本
- pipPython 包管理工具)
### 1.2 安装方法
1. **克隆项目仓库**
```bash
git clone <项目地址>
cd geo_tools
```
2. **安装依赖**
```bash
pip install -r requirements.txt
```
3. **安装 Geo-Tools**
```bash
pip install -e .
```
### 1.3 验证安装
运行以下命令验证 Geo-Tools 是否安装成功:
```python
from app.io.readers import read_vector
print("Geo-Tools 安装成功!")
```
## 2. 5分钟体验
### 2.1 读取 Shapefile
```python
from app.io.readers import read_vector
from pathlib import Path
# 读取 Shapefile 文件
# 这里使用示例数据,您可以替换为自己的文件路径
data_path = Path("data/sample/sample_points.geojson")
gdf = read_vector(data_path)
# 查看数据基本信息
print(f"数据形状:{gdf.shape}")
print(f"数据列名:{list(gdf.columns)}")
print(f"坐标系:{gdf.crs}")
```
### 2.2 预览数据
```python
# 预览前5行数据
print("\n数据预览")
print(gdf.head())
# 或者使用 rows 参数直接只读取前5行
print("\n使用 rows 参数预览:")
gdf_preview = read_vector(data_path, rows=5)
print(gdf_preview)
```
### 2.3 制作缓冲区
```python
from app.core.geometry import buffer_geometry
# 为每个点创建缓冲区
gdf["buffer"] = gdf.geometry.apply(lambda geom: buffer_geometry(geom, distance=0.1))
# 查看缓冲区结果
print("\n缓冲区创建完成")
print(f"原始几何类型:{gdf.geometry.geom_type.unique()}")
print(f"缓冲区几何类型:{gdf['buffer'].geom_type.unique()}")
```
### 2.4 保存结果
```python
from app.io.writers import write_vector
# 保存为 GeoJSON 文件
output_path = Path("output/buffered_points.geojson")
write_vector(gdf, output_path)
print(f"\n结果已保存至{output_path}")
```
## 3. 进阶功能详解
### 3.1 大文件处理
#### 3.1.1 使用 rows 参数预览数据
当处理大文件时,您可以使用 `rows` 参数只读取前几行数据,快速了解数据结构,而不需要加载整个文件:
```python
from app.io.readers import read_vector
from pathlib import Path
# 只读取前10行数据进行预览
large_file_path = Path("path/to/large_file.shp")
gdf_preview = read_vector(large_file_path, rows=10)
print(f"预览数据包含 {len(gdf_preview)} 条记录")
print(gdf_preview.head())
print(gdf_preview.columns)
```
#### 3.1.2 使用 chunk_size 分块读取
对于非常大的文件,您可以使用 `chunk_size` 参数进行分块读取,逐块处理数据,避免内存溢出:
```python
from app.io.readers import read_vector
from pathlib import Path
# 分块读取每块10000条数据
large_file_path = Path("path/to/large_file.shp")
# 使用 for 循环处理每个数据块
for i, chunk in enumerate(read_vector(large_file_path, chunk_size=10000)):
print(f"处理第 {i+1} 块数据,包含 {len(chunk)} 条记录")
# 在这里进行您的处理操作
# 例如:计算缓冲区
chunk["buffer"] = chunk.geometry.apply(lambda geom: buffer_geometry(geom, distance=0.1))
# 保存当前块的结果
output_path = Path(f"output/chunk_{i+1}.geojson")
write_vector(chunk, output_path)
print(f"第 {i+1} 块结果已保存")
```
### 3.2 坐标系转换
#### 3.2.1 什么时候需要转换坐标系?
- 当您需要进行距离、面积计算时,地理坐标系(如 EPSG:4326的单位是度不适合直接计算
- 当您需要将数据与其他不同坐标系的数据集叠加时
- 当您需要使用特定坐标系的工具或服务时
#### 3.2.2 如何转换坐标系
```python
from app.io.readers import read_vector
from app.core.projection import reproject
from pathlib import Path
# 读取数据
data_path = Path("data/sample/sample_points.geojson")
gdf = read_vector(data_path)
print(f"原始坐标系:{gdf.crs}")
# 转换到 Web Mercator 坐标系EPSG:3857
gdf_3857 = reproject(gdf, "EPSG:3857")
print(f"转换后坐标系:{gdf_3857.crs}")
# 或者在读取时直接指定目标坐标系
gdf_direct = read_vector(data_path, crs="EPSG:3857")
print(f"直接指定坐标系:{gdf_direct.crs}")
```
### 3.3 空间分析
#### 3.3.1 缓冲区分析
```python
from app.io.readers import read_vector
from app.core.geometry import buffer_geometry
from pathlib import Path
# 读取数据
data_path = Path("data/sample/sample_points.geojson")
gdf = read_vector(data_path)
# 创建不同距离的缓冲区
gdf["buffer_05"] = gdf.geometry.apply(lambda geom: buffer_geometry(geom, distance=0.05))
gdf["buffer_10"] = gdf.geometry.apply(lambda geom: buffer_geometry(geom, distance=0.1))
# 保存结果
output_path = Path("output/buffers.geojson")
write_vector(gdf, output_path)
print(f"缓冲区分析结果已保存至:{output_path}")
```
#### 3.3.2 空间叠加分析
```python
from app.io.readers import read_vector
from app.analysis.spatial_ops import overlay
from pathlib import Path
# 读取两个数据集
points_path = Path("data/sample/sample_points.geojson")
regions_path = Path("data/sample/sample_regions.geojson")
points = read_vector(points_path)
regions = read_vector(regions_path)
# 执行空间叠加(交集)
overlay_result = overlay(points, regions, how="intersection")
print(f"叠加结果包含 {len(overlay_result)} 条记录")
# 保存结果
output_path = Path("output/overlay_result.geojson")
write_vector(overlay_result, output_path)
print(f"叠加分析结果已保存至:{output_path}")
```
#### 3.3.3 最近邻查找
```python
from app.core.geometry import distance_between
from app.io.readers import read_vector
from pathlib import Path
# 读取点数据集
points_path = Path("data/sample/sample_points.geojson")
gdf = read_vector(points_path)
# 定义目标点
target_point = gdf.geometry.iloc[0]
print(f"目标点:{target_point}")
# 计算每个点到目标点的距离
gdf["distance"] = gdf.geometry.apply(lambda geom: distance_between(geom, target_point))
# 找到最近的点
nearest_point = gdf.loc[gdf["distance"].idxmin()]
print(f"最近的点:{nearest_point.geometry}")
print(f"距离:{nearest_point['distance']}")
```
## 4. 常见问题排查
### 4.1 报错“文件打不开”怎么办?
**可能原因:**
- 文件路径不存在
- 文件路径包含中文或特殊字符
- 文件格式不支持
- 文件损坏
**解决方案:**
1. 检查文件路径是否正确,使用绝对路径
2. 确保文件路径不包含中文或特殊字符
3. 确认文件格式是否被支持GeoJSON、Shapefile、GPKG、GDB等
4. 尝试使用其他软件打开文件,确认文件是否损坏
**示例代码:**
```python
from app.io.readers import read_vector
from pathlib import Path
# 使用绝对路径
try:
file_path = Path("c:/data/my_shapefile.shp")
gdf = read_vector(file_path)
print("文件读取成功!")
except Exception as e:
print(f"文件读取失败:{e}")
# 检查路径是否存在
if not file_path.exists():
print("错误:文件路径不存在")
# 检查文件扩展名
if file_path.suffix not in [".shp", ".geojson", ".gpkg"]:
print("错误:文件格式可能不支持")
```
### 4.2 报错“几何无效”怎么办?
**可能原因:**
- 几何数据损坏
- 几何自相交
- 几何为空
**解决方案:**
1. 使用 `fix_geometry` 函数尝试修复无效几何
2. 使用 `is_valid_geometry` 函数检查几何有效性
3. 过滤掉无效几何
**示例代码:**
```python
from app.core.geometry import fix_geometry, is_valid_geometry
from app.io.readers import read_vector
from pathlib import Path
# 读取数据
data_path = Path("data/sample/sample_points.geojson")
gdf = read_vector(data_path)
# 检查并修复几何
print("检查几何有效性...")
valid_count = 0
fixed_count = 0
for i, geom in enumerate(gdf.geometry):
if is_valid_geometry(geom):
valid_count += 1
else:
# 尝试修复
fixed_geom = fix_geometry(geom)
if fixed_geom is not None:
gdf.geometry.iloc[i] = fixed_geom
fixed_count += 1
print(f"有效几何:{valid_count}")
print(f"修复几何:{fixed_count}")
print(f"无效几何:{len(gdf) - valid_count - fixed_count}")
# 过滤掉仍然无效的几何
gdf_valid = gdf[gdf.geometry.apply(is_valid_geometry)]
print(f"过滤后剩余几何:{len(gdf_valid)}")
```
### 4.3 内存爆了怎么办?
**可能原因:**
- 文件太大,一次性加载到内存
- 处理过程中创建了过多临时对象
**解决方案:**
1. 使用 `rows` 参数预览数据
2. 使用 `chunk_size` 参数分块读取
3. 处理完数据后及时释放内存
4. 考虑使用更高效的数据结构和算法
**示例代码:**
```python
from app.io.readers import read_vector
from app.io.writers import write_vector
from pathlib import Path
# 分块处理大文件
large_file = Path("path/to/large_file.shp")
output_file = Path("output/processed_file.geojson")
# 分块读取并处理
chunks = []
for i, chunk in enumerate(read_vector(large_file, chunk_size=10000)):
print(f"处理第 {i+1} 块...")
# 在这里进行处理
# 例如:添加面积列
chunk["area"] = chunk.geometry.area
chunks.append(chunk)
# 合并所有块
import geopandas as gpd
result = gpd.GeoDataFrame(pd.concat(chunks, ignore_index=True))
# 保存结果
write_vector(result, output_file)
print(f"处理完成,结果已保存至:{output_file}")
# 释放内存
import gc
gc.collect()
```
## 5. 完整案例
### 5.1 土地利用数据分析
**场景:** 读取土地利用数据,筛选出耕地,计算耕地总面积,然后导出为 GeoJSON 文件。
**步骤:**
1. **读取数据**
```python
from app.io.readers import read_vector
from pathlib import Path
# 读取土地利用数据
landuse_path = Path("data/landuse.shp")
# 先预览数据结构
landuse_preview = read_vector(landuse_path, rows=10)
print("数据列名:", list(landuse_preview.columns))
print("土地利用类型:", landuse_preview["type"].unique())
# 读取完整数据
landuse = read_vector(landuse_path)
print(f"总数据量:{len(landuse)}")
```
2. **筛选耕地**
```python
# 假设耕地的类型代码是 "1" 或 "耕地"
# 根据实际数据结构调整条件
farmland = landuse[landuse["type"] == "耕地"]
print(f"耕地数量:{len(farmland)}")
```
3. **计算耕地总面积**
```python
from app.core.projection import reproject
# 检查坐标系
print(f"原始坐标系:{farmland.crs}")
# 如果是地理坐标系,转换到投影坐标系以获得准确的面积
if farmland.crs and farmland.crs.to_epsg() == 4326:
# 转换到 UTM 坐标系(根据数据所在区域选择合适的 EPSG 代码)
farmland_proj = reproject(farmland, "EPSG:32649") # 示例UTM 49N
print(f"转换后坐标系:{farmland_proj.crs}")
else:
farmland_proj = farmland
# 计算面积(单位:平方米)
farmland_proj["area"] = farmland_proj.geometry.area
total_area = farmland_proj["area"].sum()
print(f"耕地总面积:{total_area:.2f} 平方米")
print(f"耕地总面积:{total_area/10000:.2f} 公顷")
```
4. **导出结果**
```python
from app.io.writers import write_vector
# 导出为 GeoJSON
output_path = Path("output/farmland.geojson")
write_vector(farmland_proj, output_path)
print(f"耕地数据已导出至:{output_path}")
# 导出面积统计
import pandas as pd
stats = pd.DataFrame({
"总耕地数量": [len(farmland)],
"总面积(平方米)": [total_area],
"总面积(公顷)": [total_area/10000]
})
stats_path = Path("output/farmland_stats.csv")
stats.to_csv(stats_path, index=False, encoding="utf-8-sig")
print(f"统计数据已导出至:{stats_path}")
```
## 6. 总结
Geo-Tools 是一个功能强大的地理数据处理库,提供了丰富的空间分析工具和便捷的文件 I/O 功能。通过本教程,您应该已经掌握了:
- 基本的文件读取和写入操作
- 大文件的分块处理技巧
- 坐标系转换的方法
- 常见的空间分析操作
- 常见问题的排查方法
如果您在使用过程中遇到任何问题,可以参考本教程的常见问题排查部分,或者查看项目的详细文档。
祝您使用愉快!

63
app/__init__.py Normal file
View File

@@ -0,0 +1,63 @@
"""
geo_tools
~~~~~~~~~
专业地理信息数据处理工具库。
核心依赖geopandas、shapely、fiona、pyproj。
快速开始
--------
>>> from geo_tools.io import readers
>>> from geo_tools.core import vector
>>> gdf = readers.read_vector("data/sample/sample_points.geojson")
>>> gdf_proj = vector.reproject(gdf, "EPSG:3857")
>>> print(gdf_proj.crs)
GDB 读写
--------
>>> from geo_tools.io import readers, writers
>>> layers = readers.list_gdb_layers("path/to/data.gdb")
>>> gdf = readers.read_gdb("path/to/data.gdb", layer="my_layer")
>>> writers.write_gdb(gdf, "output/result.gdb", layer="result_layer")
要素类投影
----------
>>> from geo_tools.core import projection
>>> gdf_proj = projection.reproject_gdf(gdf, "EPSG:4490")
>>> gdf_utm = projection.reproject_gdf(gdf, auto_utm=True)
"""
from importlib.metadata import PackageNotFoundError, version
# ── 版本 ──────────────────────────────────────────────────────────────────────
try:
__version__ = version("geo-tools")
except PackageNotFoundError:
__version__ = "0.1.0-dev"
# ── 配置 & 日志 ───────────────────────────────────────────────────────────────
from .io import readers, writers
from .config.settings import settings
from .utils.logger import get_logger, set_global_level
from .utils.validators import (
SUPPORTED_VECTOR_EXTENSIONS,
is_supported_vector_format,
is_valid_crs,
validate_crs,
validate_geometry,
validate_vector_path,
)
__all__ = [
"__version__",
"settings",
# utils
"get_logger",
"set_global_level",
"is_valid_crs",
"validate_crs",
"validate_geometry",
"is_supported_vector_format",
"validate_vector_path",
"SUPPORTED_VECTOR_EXTENSIONS",
]

1
app/analysis/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""geo_tools.analysis 包 —— 空间分析层。"""

View File

@@ -11,7 +11,7 @@ from typing import Any
import geopandas as gpd
import pandas as pd
from geo_tools.utils.logger import get_logger
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -56,7 +56,7 @@ def buffer_and_overlay(
result = gpd.overlay(buffered, target, how=how, keep_geom_type=False)
if projected_crs:
result = result.to_crs(original_crs)
result = result.to_crs(original_crs) # type: ignore
logger.info("叠置分析完成:%d 条结果", len(result))
return result
@@ -77,7 +77,7 @@ def overlay(
``"symmetric_difference"````"identity"``
"""
if df1.crs != df2.crs:
df2 = df2.to_crs(df1.crs)
df2 = df2.to_crs(df1.crs) # type: ignore
result = gpd.overlay(df1, df2, how=how, keep_geom_type=keep_geom_type)
logger.debug("overlay(%s)%d 条结果", how, len(result))
return result
@@ -108,7 +108,7 @@ def nearest_features(
连接了最近 target 属性的 source GDF可能包含重复行每行对应一个近邻
"""
if source.crs != target.crs:
target = target.to_crs(source.crs)
target = target.to_crs(source.crs) # type: ignore
result = gpd.sjoin_nearest(
source,
@@ -141,7 +141,7 @@ def select_by_location(
满足条件的 source 子集
"""
if source.crs != selector.crs:
selector = selector.to_crs(source.crs)
selector = selector.to_crs(source.crs) # type: ignore
joined = gpd.sjoin(source, selector, how="inner", predicate=predicate)
result = source.loc[source.index.isin(joined.index)].copy()

View File

@@ -10,7 +10,7 @@ import geopandas as gpd
import numpy as np
import pandas as pd
from geo_tools.utils.logger import get_logger
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -55,7 +55,7 @@ def area_weighted_mean(
def _weighted(group: pd.DataFrame) -> float:
return float((group[value_col] * group["_area"]).sum() / group["_area"].sum())
result = gdf.groupby(group_col).apply(_weighted, include_groups=False).rename("area_weighted_mean")
result = gdf.groupby(group_col).apply(_weighted, include_groups=False).rename("area_weighted_mean") # type: ignore[no-untyped-call]
area_sum = gdf.groupby(group_col)["_area"].sum().rename("total_area")
return pd.concat([result, area_sum], axis=1).reset_index()
@@ -97,7 +97,7 @@ def summarize_attributes(
subset = df[columns]
if group_col is None:
return subset.agg(agg_funcs).T.rename_axis("column").reset_index()
return subset.agg(agg_funcs).T.rename_axis("column").reset_index() # type: ignore[no-untyped-call]
df_with_group = df[[group_col] + columns]
return df_with_group.groupby(group_col)[columns].agg(agg_funcs).reset_index()
@@ -125,7 +125,7 @@ def count_by_polygon(
``count_col`` 列的 polygons 副本
"""
if points.crs != polygons.crs:
points = points.to_crs(polygons.crs)
points = points.to_crs(polygons.crs) # type: ignore
joined = gpd.sjoin(points, polygons, how="inner", predicate="within")
point_counts = joined.groupby("index_right").size().rename(count_col)

View File

@@ -1,5 +1,5 @@
"""geo_tools.config 包 —— 全局配置层。"""
from geo_tools.config.settings import GeoToolsSettings, settings
from app.config.settings import GeoToolsSettings, settings
__all__ = ["GeoToolsSettings", "settings"]

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""geo_tools.core 包 —— 核心地理处理层。"""

469
app/core/geometry.py Normal file
View File

@@ -0,0 +1,469 @@
"""
geo_tools.core.geometry
~~~~~~~~~~~~~~~~~~~~~~~
基于 Shapely 2.x 的几何运算工具函数。
"""
from __future__ import annotations
from typing import Literal, Sequence
import shapely
from shapely.geometry import (
LinearRing,
LineString,
MultiLineString,
MultiPoint,
MultiPolygon,
Point,
Polygon,
)
from shapely.geometry.base import BaseGeometry
from app.utils.logger import get_logger
from app.utils.validators import ensure_valid_geometry
logger = get_logger(__name__)
# ── 几何有效性 ────────────────────────────────────────────────────────────────
def is_valid_geometry(geom: BaseGeometry | None) -> bool:
"""判断几何对象是否有效(非空且通过 Shapely 合法性检查)。
Parameters
----------
geom:
输入几何对象,可为 None。
Returns
-------
bool
如果几何对象有效且非空,返回 True否则返回 False。
"""
if geom is None:
return False
return bool(geom.is_valid and not geom.is_empty)
def fix_geometry(geom: BaseGeometry | None) -> BaseGeometry | None:
"""尝试修复无效几何。
依次尝试:
1. ``buffer(0)`` — 适合大多数自相交多边形
2. ``make_valid``Shapely 2.x— 覆盖更多情形
Parameters
----------
geom:
输入几何对象,可为 None。
Returns
-------
BaseGeometry | None
修复后的几何;无法修复时返回 ``None``。
Notes
-----
对于复杂的无效几何,可能无法完全修复,此时会返回 None。
"""
if geom is None:
return None
if geom.is_valid:
return geom
# 方法一buffer(0)
try:
fixed = geom.buffer(0)
if fixed.is_valid and not fixed.is_empty:
return fixed
except Exception:
pass
# 方法二shapely.make_validShapely >= 1.8
try:
fixed = shapely.make_valid(geom)
if fixed.is_valid and not fixed.is_empty:
return fixed
except Exception:
pass
logger.warning("无法修复几何:%r", geom.geom_type)
return None
def explain_validity(geom: BaseGeometry) -> str:
"""返回 Shapely 对该几何的有效性说明(英文)。
Parameters
----------
geom:
输入几何对象。
Returns
-------
str
Shapely 生成的有效性说明字符串。
"""
from shapely.validation import explain_validity as _explain
return _explain(geom)
# ── 基础几何运算 ───────────────────────────────────────────────────────────────
def buffer_geometry(
geom: BaseGeometry,
distance: float,
cap_style: Literal["round", "square", "flat"] = "round",
join_style: Literal["round", "mitre", "bevel"] = "round",
resolution: int = 16,
verbose: bool = False,
) -> BaseGeometry | None:
"""对几何对象执行缓冲区运算。
Parameters
----------
geom:
输入几何。
distance:
缓冲距离(单位与 CRS 一致;地理坐标系单位为度)。
cap_style:
端头样式:"round"(圆角)、"square"(方角)、"flat"(平角)(仅线要素有效)。
join_style:
转角样式:"round"(圆角)、"mitre"(斜角)、"bevel"(尖角)。
resolution:
圆弧逼近精度(段数),默认 16。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
缓冲区运算后的几何对象;失败时返回 None。
"""
try:
geom = ensure_valid_geometry(geom, verbose)
return geom.buffer(distance, cap_style=cap_style, join_style=join_style, resolution=resolution)
except Exception as e:
logger.warning(f"缓冲区计算失败,已跳过,原因:{str(e)}")
return None
def centroid(geom: BaseGeometry, verbose: bool = False) -> Point | None:
"""返回几何的质心点。
Parameters
----------
geom:
输入几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
Point | None
几何的质心点;失败时返回 None。
"""
try:
geom = ensure_valid_geometry(geom, verbose)
return geom.centroid
except Exception as e:
logger.warning(f"质心计算失败,已跳过,原因:{str(e)}")
return None
def bounding_box(geom: BaseGeometry, verbose: bool = False) -> Polygon | None:
"""返回几何的最小外接矩形BBOX为多边形。
Parameters
----------
geom:
输入几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
Polygon | None
最小外接矩形多边形;失败时返回 None。
"""
try:
geom = ensure_valid_geometry(geom, verbose)
from shapely.geometry import box
return box(*geom.bounds)
except Exception as e:
logger.warning(f"最小外接矩形计算失败,已跳过,原因:{str(e)}")
return None
def convex_hull(geom: BaseGeometry, verbose: bool = False) -> BaseGeometry | None:
"""返回几何的凸包。
Parameters
----------
geom:
输入几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
几何的凸包;失败时返回 None。
"""
try:
geom = ensure_valid_geometry(geom, verbose)
return geom.convex_hull
except Exception as e:
logger.warning(f"凸包计算失败,已跳过,原因:{str(e)}")
return None
# ── 集合运算 ──────────────────────────────────────────────────────────────────
def intersect(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> BaseGeometry | None:
"""返回两几何的交集。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
两几何的交集;失败时返回 None。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return geom_a.intersection(geom_b)
except Exception as e:
logger.warning(f"交集计算失败,已跳过,原因:{str(e)}")
return None
def union(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> BaseGeometry | None:
"""返回两几何的并集。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
两几何的并集;失败时返回 None。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return geom_a.union(geom_b)
except Exception as e:
logger.warning(f"并集计算失败,已跳过,原因:{str(e)}")
return None
def difference(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> BaseGeometry | None:
"""返回 ``geom_a`` 减去 ``geom_b`` 的差集。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
geom_a 减去 geom_b 的差集;失败时返回 None。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return geom_a.difference(geom_b)
except Exception as e:
logger.warning(f"差集计算失败,已跳过,原因:{str(e)}")
return None
def symmetric_difference(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> BaseGeometry | None:
"""返回两几何的对称差集(异或)。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
两几何的对称差集;失败时返回 None。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return geom_a.symmetric_difference(geom_b)
except Exception as e:
logger.warning(f"对称差集计算失败,已跳过,原因:{str(e)}")
return None
def unary_union(geoms: Sequence[BaseGeometry], verbose: bool = False) -> BaseGeometry | None:
"""将多个几何合并为一个(等同于逐一 union
Parameters
----------
geoms:
几何对象序列。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
BaseGeometry | None
合并后的几何对象;失败时返回 None。
"""
try:
# 检查几何有效性并尝试修复
fixed_geoms = []
for i, geom in enumerate(geoms):
try:
fixed = ensure_valid_geometry(geom, verbose)
if fixed is not None:
fixed_geoms.append(fixed)
else:
# 无法修复的几何跳过
if verbose:
logger.warning(f"几何对象 {i} 无效且无法修复,已跳过")
except Exception as e:
logger.warning(f"几何对象 {i} 处理失败,已跳过,原因:{str(e)}")
if not fixed_geoms:
logger.warning("没有有效的几何对象可合并")
return None
return shapely.unary_union(fixed_geoms)
except Exception as e:
logger.warning(f"几何合并失败,已跳过,原因:{str(e)}")
return None
# ── 空间关系判断 ───────────────────────────────────────────────────────────────
def contains(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> bool:
"""判断 ``geom_a`` 是否完全包含 ``geom_b``。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
bool
如果 geom_a 完全包含 geom_b返回 True否则返回 False失败时返回 False。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return bool(geom_a.contains(geom_b))
except Exception as e:
logger.warning(f"包含关系判断失败,已返回 False原因{str(e)}")
return False
def within(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> bool:
"""判断 ``geom_a`` 是否完全在 ``geom_b`` 内。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
bool
如果 geom_a 完全在 geom_b 内,返回 True否则返回 False失败时返回 False。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return bool(geom_a.within(geom_b))
except Exception as e:
logger.warning(f"包含于关系判断失败,已返回 False原因{str(e)}")
return False
def intersects(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> bool:
"""判断两几何是否相交(含边界接触)。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
bool
如果两几何相交,返回 True否则返回 False失败时返回 False。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return bool(geom_a.intersects(geom_b))
except Exception as e:
logger.warning(f"相交关系判断失败,已返回 False原因{str(e)}")
return False
def distance_between(geom_a: BaseGeometry, geom_b: BaseGeometry, verbose: bool = False) -> float:
"""计算两几何间的最小距离(单位与 CRS 一致)。
Parameters
----------
geom_a:
第一个几何。
geom_b:
第二个几何。
verbose:
是否输出修复几何的警告信息,默认 False。
Returns
-------
float
两几何间的最小距离;失败时返回无穷大。
"""
try:
geom_a = ensure_valid_geometry(geom_a, verbose)
geom_b = ensure_valid_geometry(geom_b, verbose)
return geom_a.distance(geom_b)
except Exception as e:
logger.warning(f"距离计算失败,已返回无穷大,原因:{str(e)}")
return float('inf')

View File

@@ -9,17 +9,22 @@ from typing import Sequence
from pyproj import CRS, Transformer
import geopandas as gpd
from geo_tools.utils.logger import get_logger
from app.utils.logger import get_logger
logger = get_logger(__name__)
def get_crs_info(crs_input: str | int) -> dict[str, str | int | None]:
"""获取 CRS 的基本信息。
Parameters
----------
crs_input:
EPSG 代码整数或 ``"EPSG:4326"`` 字符串 proj 字符串
Returns
-------
dict
包含 ``name````epsg````unit````is_geographic````is_projected``
包含 ``name````epsg````unit````is_geographic````is_projected````datum``
"""
crs = CRS.from_user_input(crs_input)
return {
@@ -152,7 +157,7 @@ def reproject_gdf(
``auto_crs=True`` 二选一
auto_crs:
``True`` 时忽略 ``target_crs``根据数据中心点自动推荐
CGCS2000 高斯-克吕格 带号适合面积/距离计算场景
CGCS2000 高斯-克吕格 带号默认使用 3度分带
verbose:
``True`` 时在日志中打印投影前后的 CRS 信息
@@ -171,11 +176,11 @@ def reproject_gdf(
>>> # 指定目标 CRS
>>> gdf_proj = reproject_gdf(gdf, "EPSG:4490")
>>> # 自动推荐 CGCS2000 高斯-克吕格 带号(用于面积计算)
>>> # 自动推荐 CGCS2000 高斯-克吕格 带号 默认使用 3度分带
>>> gdf_utm = reproject_gdf(gdf, auto_crs=True)
>>> # 配合 GDB 读取
>>> gdf = read_gdb("data.gdb", layer="图斑")
>>> gdf = geo_tools.readers.read_vector("data.gdb/图斑")
>>> gdf_proj = reproject_gdf(gdf, "EPSG:4326")
"""
@@ -186,9 +191,9 @@ def reproject_gdf(
if auto_crs:
# 先统一到地理坐标系,再取中心点推荐 CGCS2000 高斯-克吕格 带号
if gdf.crs.is_projected:
center = gdf.to_crs("EPSG:4490").geometry.unary_union.centroid
center = gdf.to_crs("EPSG:4490").geometry.union_all().centroid
else:
center = gdf.geometry.unary_union.centroid
center = gdf.geometry.union_all().centroid
target_crs = suggest_projected_crs(center.x, center.y)
logger.info("auto_crs自动推荐投影 CRS = %s", target_crs)

View File

@@ -6,13 +6,13 @@ geo_tools.core.vector
from __future__ import annotations
from typing import Any
from typing import Any, Literal
import geopandas as gpd
import pandas as pd
from geo_tools.utils.logger import get_logger
from geo_tools.utils.validators import validate_geometry
from app.utils.logger import get_logger
from app.utils.validators import validate_geometry
logger = get_logger(__name__)
@@ -82,7 +82,7 @@ def clip_to_extent(
result = gdf.clip(mask)
else:
if bbox.crs != gdf.crs:
bbox = bbox.to_crs(gdf.crs)
bbox = bbox.to_crs(gdf.crs) # type: ignore
result = gdf.clip(bbox)
logger.debug("裁切完成:%d%d", len(gdf), len(result))
@@ -119,6 +119,11 @@ def dissolve_by(
def explode_multipart(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""将多部分几何MultiPolygon 等)拆分为单部分要素。
Parameters
----------
gdf:
输入 GeoDataFrame
Returns
-------
gpd.GeoDataFrame
@@ -144,10 +149,10 @@ def drop_invalid_geometries(gdf: gpd.GeoDataFrame, *, fix: bool = False) -> gpd.
return gdf
if fix:
from geo_tools.core.geometry import fix_geometry
from app.core.geometry import fix_geometry
gdf = gdf.copy()
mask = ~gdf.geometry.is_valid | gdf.geometry.isna()
gdf.loc[mask, "geometry"] = gdf.loc[mask, "geometry"].apply(fix_geometry)
gdf.loc[mask, "geometry"] = gdf.loc[mask, "geometry"].apply(fix_geometry) # type: ignore
logger.info("已修复 %d 个无效几何", stats["invalid"])
else:
before = len(gdf)
@@ -159,7 +164,7 @@ def drop_invalid_geometries(gdf: gpd.GeoDataFrame, *, fix: bool = False) -> gpd.
def spatial_join(
left: gpd.GeoDataFrame,
right: gpd.GeoDataFrame,
how: str = "left",
how: Literal["left", "right", "inner"] = "left",
predicate: str = "intersects",
**kwargs: Any,
) -> gpd.GeoDataFrame:
@@ -177,7 +182,7 @@ def spatial_join(
空间谓词``"intersects"````"contains"````"within"````"touches"``
"""
if left.crs != right.crs:
right = right.to_crs(left.crs)
right = right.to_crs(left.crs) # type: ignore
result = gpd.sjoin(left, right, how=how, predicate=predicate, **kwargs)
logger.debug("空间连接完成:%d 条结果", len(result))
return result

1
app/io/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""geo_tools.io 包 —— 数据读写层。"""

View File

@@ -15,14 +15,15 @@ geo_tools.io.readers
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from typing import Any, Generator
import fiona
import geopandas as gpd
from geo_tools.utils.logger import get_logger
from geo_tools.utils.validators import validate_vector_path
from app.utils.logger import get_logger
from app.utils.validators import validate_vector_path
logger = get_logger(__name__)
@@ -34,8 +35,10 @@ def read_vector(
layer: str | int | None = None,
crs: str | int | None = None,
encoding: str = "utf-8",
chunk_size: int | None = None,
rows: int | None = None,
**kwargs: Any,
) -> gpd.GeoDataFrame:
):
"""统一的矢量数据读取入口,自动识别文件格式。
Parameters
@@ -48,19 +51,46 @@ def read_vector(
读取后强制重投影到目标 CRS不传则保留原始 CRS
encoding:
属性表编码Shapefile 中文路径常需指定 ``"gbk"``
chunk_size:
分块大小默认 None一次性读取全部数据
警告若不设置 chunk_size大文件可能会占用大量内存
rows:
限制读取的行数默认 None读取全部数据
用于快速预览数据避免读取大文件的全部内容
**kwargs:
透传给 ``geopandas.read_file`` 的额外参数
Returns
-------
gpd.GeoDataFrame
gpd.GeoDataFrame 或生成器
如果 chunk_size None返回完整的 GeoDataFrame
如果设置了 chunk_size返回一个生成器每次 yield 一个 GeoDataFrame
示例
-----
# 全量读取(老方法)
gdf = read_vector("data.shp")
# 分块读取(新方法)
for chunk in read_vector("large_data.shp", chunk_size=10000):
# 处理每个数据块
print(f"处理了 {len(chunk)} 条数据")
# 在这里做你的操作,比如计算、过滤等
# 只读取前 5 行数据(预览模式)
gdf_preview = read_vector("large_data.shp", rows=5)
print(gdf_preview.head())
"""
path, layer = _split_gdb_layer(path)
path = validate_vector_path(path)
suffix = path.suffix.lower()
logger.info("读取矢量数据:%s(格式:%s,图层:%s", path, suffix or "目录", layer)
if suffix == ".csv":
# CSV 文件暂时不支持分块读取
if chunk_size is not None:
logger.warning("CSV 文件暂不支持分块读取,将一次性读取全部数据")
return _read_csv_vector(path, crs=crs, **kwargs)
# fiona / geopandas 通用读取
@@ -68,14 +98,60 @@ def read_vector(
if layer is not None:
read_kwargs["layer"] = layer
gdf = gpd.read_file(str(path), **read_kwargs)
# 分块读取模式
if chunk_size is not None:
def _chunk_generator():
logger.info("启用分块读取模式,每块 %d 条数据", chunk_size)
try:
# 使用 fiona 打开文件
with fiona.open(str(path), **read_kwargs) as src:
# 获取坐标系信息
crs_info = src.crs
# 分块读取
features = []
for i, feature in enumerate(src):
# 检查是否达到行数限制
if rows is not None and i >= rows:
break
if crs is not None:
logger.debug("重投影到 %s", crs)
gdf = gdf.to_crs(crs)
features.append(feature)
if (i + 1) % chunk_size == 0:
# 创建 GeoDataFrame 并设置 CRS
gdf = gpd.GeoDataFrame.from_features(features, crs=crs_info)
# 重投影
if crs is not None:
gdf = gdf.to_crs(crs) # type: ignore
logger.debug("读取并处理第 %d 块数据,共 %d", (i + 1) // chunk_size, len(gdf))
yield gdf
features = []
# 处理最后一块
if features:
gdf = gpd.GeoDataFrame.from_features(features, crs=crs_info)
if crs is not None:
gdf = gdf.to_crs(crs) # type: ignore
logger.debug("读取并处理最后一块数据,共 %d", len(gdf))
yield gdf
except Exception as exc:
raise RuntimeError(f"无法分块读取矢量数据:{exc}") from None
return _chunk_generator()
else:
# 一次性读取模式
try:
# 添加 rows 参数到读取参数中
if rows is not None:
read_kwargs["rows"] = rows
logger.info("限制读取行数:%d", rows)
logger.info("读取完成:共 %d 条要素CRS=%s", len(gdf), gdf.crs)
return gdf
gdf = gpd.read_file(str(path), **read_kwargs)
except Exception as exc:
raise RuntimeError(f"无法读取矢量数据:{exc}") from None
if crs is not None:
logger.debug("重投影到 %s", crs)
gdf = gdf.to_crs(crs) # type: ignore
logger.info("读取完成:共 %d 条要素CRS=%s", len(gdf), gdf.crs)
return gdf # type: ignore
# ── GDB 专用 ───────────────────────────────────────────────────────────────────
@@ -125,10 +201,10 @@ def read_gdb(
gdf = gpd.read_file(str(gdb_path), layer=layer, encoding=encoding, **kwargs)
if crs is not None:
gdf = gdf.to_crs(crs)
gdf = gdf.to_crs(crs) # type: ignore
logger.info("GDB 读取完成:%d 条要素CRS=%s", len(gdf), gdf.crs)
return gdf
return gdf # type: ignore
def list_gdb_layers(gdb_path: str | Path) -> list[str]:
@@ -154,6 +230,42 @@ def list_gdb_layers(gdb_path: str | Path) -> list[str]:
f"原始错误:{exc}"
) from exc
def _split_gdb_layer(path: str | Path) -> tuple[Path, str | None]:
"""从完整路径中分离 GDB 数据库路径和图层名。
Parameters
----------
path:
完整路径可以是字符串或 Path 对象
Returns
-------
tuple[Path, str | None]
(gdb_path, layer_name)其中 gdb_path GDB 目录路径layer_name 是图层名若没有图层名则为 None
"""
path_obj = Path(path)
str_path = str(path_obj)
# 查找 .gdb 的位置
gdb_pos = str_path.find('.gdb')
if gdb_pos == -1:
# 如果没有 .gdb整个路径作为 GDB 路径,没有图层
return path_obj, None
# 提取 GDB 路径(包含 .gdb
gdb_path = str_path[:gdb_pos + 4]
# 提取图层名(.gdb 之后的部分)
layer_part = str_path[gdb_pos + 4:]
# 去除开头的路径分隔符
layer_name = layer_part.lstrip(os.sep).lstrip('/').lstrip('\\')
# 如果没有图层名,返回 None
if not layer_name:
layer_name = None
return Path(gdb_path), layer_name
# ── GPKG 专用 ──────────────────────────────────────────────────────────────────
@@ -188,12 +300,23 @@ def read_gpkg(
gdf = gpd.read_file(str(gpkg_path), layer=layer, **kwargs)
if crs is not None:
gdf = gdf.to_crs(crs)
return gdf
gdf = gdf.to_crs(crs) # type: ignore
return gdf # type: ignore
def list_gpkg_layers(gpkg_path: str | Path) -> list[str]:
"""列出 GeoPackage 中所有图层名称。"""
"""列出 GeoPackage 中所有图层名称。
Parameters
----------
gpkg_path:
GeoPackage 文件路径可以是字符串或 Path 对象
Returns
-------
list[str]
图层名称列表
"""
return fiona.listlayers(str(gpkg_path))

View File

@@ -17,7 +17,7 @@ from typing import Any, Literal
import geopandas as gpd
from geo_tools.utils.logger import get_logger
from app.utils.logger import get_logger
logger = get_logger(__name__)

View File

@@ -1,8 +1,8 @@
"""geo_tools.utils 包 —— 通用工具函数。"""
from geo_tools.utils.config import load_config, load_json_config, load_toml_config, load_yaml_config
from geo_tools.utils.logger import get_logger, set_global_level
from geo_tools.utils.validators import (
from app.utils.config import load_config, load_json_config, load_toml_config, load_yaml_config
from app.utils.logger import get_logger, set_global_level
from app.utils.validators import (
SUPPORTED_VECTOR_EXTENSIONS,
is_supported_vector_format,
is_valid_crs,

View File

@@ -50,7 +50,7 @@ def get_logger(
logging.Logger
"""
# 延迟导入,避免循环依赖
from geo_tools.config.settings import settings as _settings
from app.config.settings import settings as _settings
if level is None:
level = _settings.log_level

View File

@@ -73,11 +73,9 @@ def validate_geometry(gdf: "gpd.GeoDataFrame", *, raise_on_invalid: bool = False
dict
包含 ``total````valid````invalid````null`` 计数
"""
import geopandas as gpd # noqa: F811
null_count = gdf.geometry.isna().sum()
non_null = gdf.geometry.dropna()
invalid_mask = ~non_null.is_valid
invalid_mask = ~non_null.is_valid # type: ignore
invalid_count = int(invalid_mask.sum())
valid_count = len(non_null) - invalid_count
@@ -114,7 +112,18 @@ SUPPORTED_VECTOR_EXTENSIONS: dict[str, str] = {
def is_supported_vector_format(path: str | Path) -> bool:
"""判断路径是否为已知的矢量格式。"""
"""判断路径是否为已知的矢量格式。
Parameters
----------
path:
输入路径可以是字符串或 Path 对象
Returns
-------
bool
如果路径是支持的矢量格式返回 True否则返回 False
"""
path = Path(path)
suffix = path.suffix.lower()
# .gdb 可能是目录FileGDB
@@ -143,3 +152,32 @@ def validate_vector_path(path: str | Path) -> Path:
f"支持的格式:{list(SUPPORTED_VECTOR_EXTENSIONS.keys())}"
)
return path
def ensure_valid_geometry(geom: 'BaseGeometry', verbose: bool = False) -> 'BaseGeometry':
"""确保几何对象有效,无效时尝试修复。
Parameters
----------
geom:
输入几何
verbose:
是否输出修复几何的警告信息
Returns
-------
BaseGeometry
有效的几何对象可能是修复后的
"""
from app.core.geometry import fix_geometry
from app.utils.logger import get_logger
logger = get_logger(__name__)
if not geom.is_valid:
fixed_geom = fix_geometry(geom)
if fixed_geom is not None:
if verbose:
logger.warning("几何对象无效,已自动修复")
return fixed_geom
return geom

View File

@@ -1,248 +0,0 @@
# geo_tools.core.projection 使用说明
> 坐标系查询、坐标转换、投影推荐工具,基于 [pyproj](https://pyproj4.github.io/pyproj/)。
---
## 导入方式
```python
# 推荐:从顶层包导入
from geo_tools.core.projection import (
get_crs_info,
crs_to_epsg,
transform_coordinates,
transform_point,
suggest_projected_crs,
WGS84, CGCS2000, WEB_MERCATOR, CGCS2000_UTM_50N,
)
# 或直接通过 geo_tools 导入
import geo_tools
```
---
## CRS 常量
模块内置了中国地理信息处理中最常用的 CRS 快捷常量,可直接作为参数传入所有函数:
| 常量名 | EPSG | 说明 |
|--------|------|------|
| `WGS84` | `EPSG:4326` | WGS84 地理坐标系(经纬度,最通用) |
| `CGCS2000` | `EPSG:4490` | 中国国家大地坐标系 2000经纬度 |
| `WEB_MERCATOR` | `EPSG:3857` | Web Mercator 投影(网络地图常用,单位:米) |
| `CGCS2000_UTM_50N` | `EPSG:4508` | CGCS2000 / 3° 高斯-克吕格 50 带(单位:米) |
```python
from geo_tools.core.projection import WGS84, CGCS2000
# 直接用常量替代字符串
gdf = gdf.to_crs(CGCS2000)
```
---
## 函数说明
### `get_crs_info(crs_input)` — 查询 CRS 信息
返回坐标系的详细描述字典,方便快速了解一个未知 EPSG 的含义。
**参数**
- `crs_input`EPSG 代码字符串(如 `"EPSG:4523"`)、整数(如 `4523`)或 proj 字符串。
**返回值**`dict`
| 键 | 含义 |
|----|------|
| `name` | 坐标系名称 |
| `epsg` | EPSG 整数编号(无法识别时为 `None` |
| `unit` | 坐标单位(`degree` / `metre` |
| `is_geographic` | 是否为地理坐标系(经纬度) |
| `is_projected` | 是否为投影坐标系(平面直角) |
| `datum` | 基准面名称 |
```python
from geo_tools.core.projection import get_crs_info
# 查询读取到的 GDB 数据的 CRS 含义
info = get_crs_info("EPSG:4523")
print(info)
# {
# 'name': 'CGCS2000 / 3-degree Gauss-Kruger zone 45',
# 'epsg': 4523,
# 'unit': 'metre',
# 'is_geographic': False,
# 'is_projected': True,
# 'datum': 'China Geodetic Coordinate System 2000'
# }
# 直接传整数
info = get_crs_info(32650)
print(info["name"]) # WGS 84 / UTM zone 50N
```
---
### `crs_to_epsg(crs_input)` — 获取 EPSG 编号
将任意 CRS 描述转为整数 EPSG 编号,无法识别时返回 `None`(不抛异常)。
```python
from geo_tools.core.projection import crs_to_epsg
epsg = crs_to_epsg("EPSG:4490")
print(epsg) # 4490
epsg = crs_to_epsg("WGS 84")
print(epsg) # 4326
epsg = crs_to_epsg("invalid_crs")
print(epsg) # None
```
---
### `transform_coordinates(xs, ys, source_crs, target_crs)` — 批量坐标转换
将一组坐标点从源坐标系批量转换到目标坐标系,返回转换后的 `(xs, ys)` 列表。
**参数**
- `xs`X 坐标序列(地理 CRS 时为**经度**
- `ys`Y 坐标序列(地理 CRS 时为**纬度**
- `source_crs`:源坐标系
- `target_crs`:目标坐标系
- `always_xy`(关键字参数):强制按 (经度/X, 纬度/Y) 顺序处理,默认 `True`**建议不修改**
```python
from geo_tools.core.projection import transform_coordinates, WGS84, WEB_MERCATOR
# 将北京、上海、广州的 WGS84 经纬度转为 Web Mercator 米制坐标
lons = [116.4074, 121.4737, 113.2644]
lats = [39.9042, 31.2304, 23.1291]
xs, ys = transform_coordinates(lons, lats, WGS84, WEB_MERCATOR)
print(xs) # [12959618.8, 13521606.3, 12608870.0](单位:米)
print(ys) # [4859767.2, 3649094.2, 2641877.0]
# 国家坐标系转换CGCS2000 经纬度 → CGCS2000 3° 高斯带50带
from geo_tools.core.projection import CGCS2000, CGCS2000_UTM_50N
xs_proj, ys_proj = transform_coordinates(lons, lats, CGCS2000, CGCS2000_UTM_50N)
```
---
### `transform_point(x, y, source_crs, target_crs)` — 单点坐标转换
`transform_coordinates` 的单点版本,直接返回 `(x, y)` 元组。
```python
from geo_tools.core.projection import transform_point, WGS84, CGCS2000
# 单点WGS84 → CGCS2000两者数值非常接近差异在毫米级
x, y = transform_point(116.4074, 39.9042, WGS84, CGCS2000)
print(f"CGCS2000 坐标:经度={x:.6f}, 纬度={y:.6f}")
# 单点:经纬度 → 投影坐标(米)
from geo_tools.core.projection import WEB_MERCATOR
mx, my = transform_point(116.4074, 39.9042, WGS84, WEB_MERCATOR)
print(f"墨卡托坐标X={mx:.2f}m, Y={my:.2f}m")
```
---
### `suggest_projected_crs(lon, lat)` — 自动推荐投影 CRS
根据数据中心坐标WGS84 经纬度)自动推荐适合**面积/距离计算**的 UTM 投影带,避免在地理坐标系下计算面积出错。
**参数**
- `lon`中心经度WGS84
- `lat`中心纬度WGS84北半球为正
**返回值**EPSG 代码字符串,如 `"EPSG:32650"`
```python
from geo_tools.core.projection import suggest_projected_crs
# 云南马关县(约 104.4°E, 23.0°N
proj_crs = suggest_projected_crs(lon=104.4, lat=23.0)
print(proj_crs) # EPSG:32648 (WGS84 UTM zone 48N)
# 北京116.4°E, 39.9°N
proj_crs = suggest_projected_crs(lon=116.4, lat=39.9)
print(proj_crs) # EPSG:32650 (WGS84 UTM zone 50N)
# 实际场景:读取 GDB 后用推荐的投影计算面积
import geo_tools
gdf = geo_tools.read_gdb("data.gdb", layer="图斑")
cx, cy = gdf.geometry.unary_union.centroid.x, gdf.geometry.unary_union.centroid.y
# 如果数据是投影坐标系(单位:米),先转到地理坐标系再推荐
if gdf.crs.is_projected:
cx, cy = geo_tools.transform_point(cx, cy, gdf.crs, "EPSG:4326")
proj_crs = suggest_projected_crs(cx, cy)
gdf_proj = geo_tools.reproject(gdf, proj_crs) # 重投影
gdf_proj = geo_tools.add_area_column(gdf_proj) # 计算面积单位
```
---
## 常见场景示例
### 场景一:不认识数据的 CRS先查一下
```python
import geo_tools
gdf = geo_tools.read_gdb("临时数据库.gdb", layer="马关综合后图斑")
# 读取完成CRS=EPSG:4523
info = geo_tools.get_crs_info(gdf.crs)
print(info["name"]) # CGCS2000 / 3-degree Gauss-Kruger zone 45
print(info["unit"]) # metre投影坐标系单位是米
print(info["is_projected"]) # True
```
### 场景二:统一坐标系后叠置分析
```python
import geo_tools
from geo_tools.core.projection import CGCS2000
layer_a = geo_tools.read_gdb("a.gdb", layer="林地") # EPSG:4523
layer_b = geo_tools.read_vector("b.geojson") # EPSG:4326
# 统一到 CGCS2000 地理坐标系后再做叠置
layer_a = geo_tools.reproject(layer_a, CGCS2000)
layer_b = geo_tools.reproject(layer_b, CGCS2000)
result = geo_tools.overlay(layer_a, layer_b, how="intersection")
```
### 场景三:在地理坐标系数据上正确计算面积
```python
import geo_tools
from geo_tools.core.projection import suggest_projected_crs
gdf = geo_tools.read_vector("data.geojson") # EPSG:4326单位是度
# 自动推荐合适的投影
proj = suggest_projected_crs(lon=105.0, lat=25.0) # 云贵地区
gdf = geo_tools.add_area_column(gdf, projected_crs=proj)
print(gdf["area_m2"].describe())
```
---
## 注意事项
> [!WARNING]
> 在**地理坐标系**EPSG:4326 / 4490下直接调用 `geometry.area` 得到的是"平方度"**不是平方米**,面积计算会严重失真。始终用 `add_area_column()` 或先 `reproject()` 到投影坐标系后再计算。
> [!NOTE]
> `WGS84`EPSG:4326与 `CGCS2000`EPSG:4490的坐标数值差异极小通常 < 1 米),在普通精度的分析中可视为等价,但正式国家项目中必须使用 CGCS2000。

View File

@@ -1,40 +0,0 @@
"""
geo_tools
~~~~~~~~~
专业地理信息数据处理工具库。
核心依赖geopandas、shapely、fiona、pyproj。
快速开始
--------
>>> import geo_tools
>>> gdf = geo_tools.read_vector("data/sample/sample_points.geojson")
>>> gdf_proj = geo_tools.reproject(gdf, "EPSG:3857")
>>> print(gdf_proj.crs)
GDB 读写
--------
>>> layers = geo_tools.list_gdb_layers("path/to/data.gdb")
>>> gdf = geo_tools.read_gdb("path/to/data.gdb", layer="my_layer")
>>> geo_tools.write_gdb(gdf, "output/result.gdb", layer="result_layer")
"""
from importlib.metadata import PackageNotFoundError, version
# ── 版本 ──────────────────────────────────────────────────────────────────────
try:
__version__ = version("geo-tools")
except PackageNotFoundError:
__version__ = "0.1.0-dev"
# ── 配置 & 日志 ───────────────────────────────────────────────────────────────
from geo_tools.config.settings import settings
from geo_tools.utils.logger import get_logger, set_global_level
__all__ = [
"__version__",
"settings",
# utils
"get_logger",
"set_global_level"
]

View File

@@ -1,25 +0,0 @@
"""geo_tools.analysis 包 —— 空间分析层。"""
from geo_tools.analysis.spatial_ops import (
buffer_and_overlay,
nearest_features,
overlay,
select_by_location,
)
from geo_tools.analysis.stats import (
area_weighted_mean,
count_by_polygon,
summarize_attributes,
)
__all__ = [
# spatial_ops
"buffer_and_overlay",
"overlay",
"nearest_features",
"select_by_location",
# stats
"area_weighted_mean",
"summarize_attributes",
"count_by_polygon",
]

View File

@@ -1,80 +0,0 @@
"""geo_tools.core 包 —— 核心地理处理层。"""
from geo_tools.core.geometry import (
buffer_geometry,
bounding_box,
centroid,
contains,
convex_hull,
difference,
distance_between,
explain_validity,
fix_geometry,
intersect,
intersects,
is_valid_geometry,
symmetric_difference,
unary_union,
union,
within,
)
from geo_tools.core.projection import (
CGCS2000,
CGCS2000_UTM_50N,
WEB_MERCATOR,
WGS84,
crs_to_epsg,
get_crs_info,
suggest_projected_crs,
transform_coordinates,
transform_point,
)
from geo_tools.core.vector import (
add_area_column,
clip_to_extent,
dissolve_by,
drop_invalid_geometries,
explode_multipart,
reproject,
set_crs,
spatial_join,
)
__all__ = [
# geometry
"is_valid_geometry",
"fix_geometry",
"explain_validity",
"buffer_geometry",
"centroid",
"bounding_box",
"convex_hull",
"intersect",
"union",
"difference",
"symmetric_difference",
"unary_union",
"contains",
"within",
"intersects",
"distance_between",
# projection
"WGS84",
"CGCS2000",
"WEB_MERCATOR",
"CGCS2000_UTM_50N",
"get_crs_info",
"crs_to_epsg",
"transform_coordinates",
"transform_point",
"suggest_projected_crs",
# vector
"reproject",
"set_crs",
"clip_to_extent",
"dissolve_by",
"explode_multipart",
"drop_invalid_geometries",
"spatial_join",
"add_area_column",
]

View File

@@ -1,169 +0,0 @@
"""
geo_tools.core.geometry
~~~~~~~~~~~~~~~~~~~~~~~
基于 Shapely 2.x 的几何运算工具函数。
"""
from __future__ import annotations
from typing import Sequence
import shapely
from shapely.geometry import (
LinearRing,
LineString,
MultiLineString,
MultiPoint,
MultiPolygon,
Point,
Polygon,
)
from shapely.geometry.base import BaseGeometry
from geo_tools.utils.logger import get_logger
logger = get_logger(__name__)
# ── 几何有效性 ────────────────────────────────────────────────────────────────
def is_valid_geometry(geom: BaseGeometry | None) -> bool:
"""判断几何对象是否有效(非空且通过 Shapely 合法性检查)。"""
if geom is None:
return False
return bool(geom.is_valid and not geom.is_empty)
def fix_geometry(geom: BaseGeometry | None) -> BaseGeometry | None:
"""尝试修复无效几何。
依次尝试:
1. ``buffer(0)`` — 适合大多数自相交多边形
2. ``make_valid``Shapely 2.x— 覆盖更多情形
Returns
-------
BaseGeometry | None
修复后的几何;无法修复时返回 ``None``。
"""
if geom is None:
return None
if geom.is_valid:
return geom
# 方法一buffer(0)
try:
fixed = geom.buffer(0)
if fixed.is_valid and not fixed.is_empty:
return fixed
except Exception:
pass
# 方法二shapely.make_validShapely >= 1.8
try:
fixed = shapely.make_valid(geom)
if fixed.is_valid and not fixed.is_empty:
return fixed
except Exception:
pass
logger.warning("无法修复几何:%r", geom.geom_type)
return None
def explain_validity(geom: BaseGeometry) -> str:
"""返回 Shapely 对该几何的有效性说明(英文)。"""
from shapely.validation import explain_validity as _explain
return _explain(geom)
# ── 基础几何运算 ───────────────────────────────────────────────────────────────
def buffer_geometry(
geom: BaseGeometry,
distance: float,
cap_style: int = 1,
join_style: int = 1,
resolution: int = 16,
) -> BaseGeometry:
"""对几何对象执行缓冲区运算。
Parameters
----------
geom:
输入几何。
distance:
缓冲距离(单位与 CRS 一致;地理坐标系单位为度)。
cap_style:
端头样式1=圆形2=平头3=方头(仅线要素有效)。
join_style:
转角样式1=圆角2=斜角3=尖角。
resolution:
圆弧逼近精度(段数),默认 16。
"""
return geom.buffer(distance, cap_style=cap_style, join_style=join_style, resolution=resolution)
def centroid(geom: BaseGeometry) -> Point:
"""返回几何的质心点。"""
return geom.centroid
def bounding_box(geom: BaseGeometry) -> Polygon:
"""返回几何的最小外接矩形BBOX为多边形。"""
from shapely.geometry import box
return box(*geom.bounds)
def convex_hull(geom: BaseGeometry) -> BaseGeometry:
"""返回几何的凸包。"""
return geom.convex_hull
# ── 集合运算 ──────────────────────────────────────────────────────────────────
def intersect(geom_a: BaseGeometry, geom_b: BaseGeometry) -> BaseGeometry:
"""返回两几何的交集。"""
return geom_a.intersection(geom_b)
def union(geom_a: BaseGeometry, geom_b: BaseGeometry) -> BaseGeometry:
"""返回两几何的并集。"""
return geom_a.union(geom_b)
def difference(geom_a: BaseGeometry, geom_b: BaseGeometry) -> BaseGeometry:
"""返回 ``geom_a`` 减去 ``geom_b`` 的差集。"""
return geom_a.difference(geom_b)
def symmetric_difference(geom_a: BaseGeometry, geom_b: BaseGeometry) -> BaseGeometry:
"""返回两几何的对称差集(异或)。"""
return geom_a.symmetric_difference(geom_b)
def unary_union(geoms: Sequence[BaseGeometry]) -> BaseGeometry:
"""将多个几何合并为一个(等同于逐一 union"""
return shapely.unary_union(list(geoms))
# ── 空间关系判断 ───────────────────────────────────────────────────────────────
def contains(geom_a: BaseGeometry, geom_b: BaseGeometry) -> bool:
"""判断 ``geom_a`` 是否完全包含 ``geom_b``。"""
return bool(geom_a.contains(geom_b))
def within(geom_a: BaseGeometry, geom_b: BaseGeometry) -> bool:
"""判断 ``geom_a`` 是否完全在 ``geom_b`` 内。"""
return bool(geom_a.within(geom_b))
def intersects(geom_a: BaseGeometry, geom_b: BaseGeometry) -> bool:
"""判断两几何是否相交(含边界接触)。"""
return bool(geom_a.intersects(geom_b))
def distance_between(geom_a: BaseGeometry, geom_b: BaseGeometry) -> float:
"""计算两几何间的最小距离(单位与 CRS 一致)。"""
return float(geom_a.distance(geom_b))

View File

@@ -1,31 +0,0 @@
"""geo_tools.io 包 —— 数据读写层。"""
from geo_tools.io.readers import (
list_gdb_layers,
list_gpkg_layers,
read_csv_points,
read_gdb,
read_gpkg,
read_vector,
)
from geo_tools.io.writers import (
write_csv,
write_gdb,
write_gpkg,
write_vector,
)
__all__ = [
# readers
"read_vector",
"read_gdb",
"list_gdb_layers",
"read_gpkg",
"list_gpkg_layers",
"read_csv_points",
# writers
"write_vector",
"write_gdb",
"write_gpkg",
"write_csv",
]

View File

@@ -17,8 +17,8 @@ from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
import geo_tools
from geo_tools.utils.logger import get_logger
import app
from app.utils.logger import get_logger
logger = get_logger("example_workflow")
@@ -29,37 +29,37 @@ OUTPUT_DIR.mkdir(exist_ok=True)
def main() -> None:
logger.info("=" * 60)
logger.info("geo_tools 端到端工作流示例 v%s", geo_tools.__version__)
logger.info("geo_tools 端到端工作流示例 v%s", app.__version__)
logger.info("=" * 60)
# ── 1. 读取示例点数据 ──────────────────────────────────────────
logger.info("\n[步骤 1] 读取示例点数据GeoJSON")
points = geo_tools.read_vector(DATA_DIR / "sample_points.geojson")
points = app.read_vector(DATA_DIR / "sample_points.geojson")
logger.info(" 读取完成:%d 条要素CRS=%s", len(points), points.crs)
logger.info(" 字段:%s", list(points.columns))
# ── 2. 读取示例面数据 ──────────────────────────────────────────
logger.info("\n[步骤 2] 读取示例区域多边形GeoJSON")
regions = geo_tools.read_vector(DATA_DIR / "sample_regions.geojson")
regions = app.read_vector(DATA_DIR / "sample_regions.geojson")
logger.info(" 区域列表:%s", regions["name"].tolist())
# ── 3. 数据校验 ───────────────────────────────────────────────
logger.info("\n[步骤 3] 几何有效性校验")
stats = geo_tools.validate_geometry(points)
stats = app.validate_geometry(points)
logger.info(" 点数据校验结果:%s", stats)
stats = geo_tools.validate_geometry(regions)
stats = app.validate_geometry(regions)
logger.info(" 面数据校验结果:%s", stats)
# ── 4. 坐标系信息 ─────────────────────────────────────────────
logger.info("\n[步骤 4] 查询 CRS 信息")
crs_info = geo_tools.get_crs_info("EPSG:4326")
crs_info = app.get_crs_info("EPSG:4326")
logger.info(" WGS84 信息:%s", crs_info)
proj_crs = geo_tools.suggest_projected_crs(116.4, 39.9)
proj_crs = app.suggest_projected_crs(116.4, 39.9)
logger.info(" 北京适合的投影 CRS%s", proj_crs)
# ── 5. 重投影 ─────────────────────────────────────────────────
logger.info("\n[步骤 5] 重投影到 Web Mercator用于可视化")
points_3857 = geo_tools.reproject(points, "EPSG:3857")
points_3857 = app.reproject(points, "EPSG:3857")
logger.info(" 重投影完成CRS=%s", points_3857.crs)
# ── 6. 面积加权均值 ───────────────────────────────────────────
@@ -68,31 +68,31 @@ def main() -> None:
points_buffered = points.to_crs("EPSG:3857").copy()
points_buffered["geometry"] = points_buffered.geometry.buffer(100_000) # 100km缓冲
points_buffered = points_buffered.to_crs("EPSG:4326")
from geo_tools.analysis.stats import area_weighted_mean
from app.analysis.stats import area_weighted_mean
aw_result = area_weighted_mean(points_buffered, value_col="value")
logger.info(" 全局面积加权均值:%.4f", aw_result["area_weighted_mean"])
# ── 7. 按位置选择 ─────────────────────────────────────────────
logger.info("\n[步骤 7] 按位置选择:筛选华南区域内的城市")
hua_nan = regions[regions["name"] == "华南"]
points_in_huanan = geo_tools.select_by_location(points, hua_nan, predicate="intersects")
points_in_huanan = app.select_by_location(points, hua_nan, predicate="intersects")
logger.info(" 华南区域内的城市:%s", points_in_huanan["name"].tolist())
# ── 8. 统计汇总 ───────────────────────────────────────────────
logger.info("\n[步骤 8] 属性统计汇总")
from geo_tools.analysis.stats import summarize_attributes
from app.analysis.stats import summarize_attributes
summary = summarize_attributes(points, columns=["value"], group_col="category")
logger.info(" 按分类汇总:\n%s", summary.to_string(index=False))
# ── 9. 写出结果 ───────────────────────────────────────────────
logger.info("\n[步骤 9] 写出处理结果")
out_geojson = OUTPUT_DIR / "result_points_3857.geojson"
geo_tools.write_vector(points_3857, out_geojson)
app.write_vector(points_3857, out_geojson)
logger.info(" GeoJSON 写出:%s", out_geojson)
out_gpkg = OUTPUT_DIR / "results.gpkg"
geo_tools.write_gpkg(points, out_gpkg, layer="original_points")
geo_tools.write_gpkg(regions, out_gpkg, layer="regions", mode="a")
app.write_gpkg(points, out_gpkg, layer="original_points")
app.write_gpkg(regions, out_gpkg, layer="regions", mode="a")
logger.info(" GPKG 写出2 图层):%s", out_gpkg)
logger.info("\n" + "=" * 60)

View File

@@ -0,0 +1,188 @@
import os
from pathlib import Path
import time
import geopandas as gpd
from geopandas.io import file
import pandas as pd
import numpy as np
def assign_gzchd_flexible_v2(soil_prop, point_path, polygon_path, output_path):
print("正在读取数据...")
points = gpd.read_file(point_path)
polygons = gpd.read_file(polygon_path)
# 1. 坐标系转换
if points.crs != polygons.crs:
print(f"坐标系不一致,正在转换点数据...")
points = points.to_crs(polygons.crs)
# 2. 预处理, 判断样点是否存在TZ字段如果不存在则用TDLYLX字段代替,并将其转为字符串类型,如果两个字段都不存在,则报错
if 'TZ' not in points.columns:
if 'TDLYLX' in points.columns:
points['TZ'] = points['TDLYLX'].astype(str).str.strip()
else:
raise ValueError("点要素类中不存在TZ或TDLYLX字段无法进行匹配")
else:
points['TZ'] = points['TZ'].astype(str).str.strip()
polygons['TZ'] = polygons['TZ'].astype(str).str.strip()
# 确保 GZCHD 是数值类型,避免合并时类型冲突
points[soil_prop] = pd.to_numeric(points[soil_prop], errors='coerce').fillna(0)
if soil_prop in polygons.columns:
polygons = polygons.drop(columns=[soil_prop])
# 辅助函数:按指定字段分组进行最近点匹配
def match_by_attribute(poly_gdf, pt_gdf, attr_name, suffix):
if attr_name not in poly_gdf.columns or attr_name not in pt_gdf.columns:
return None, []
poly_sub = poly_gdf[poly_gdf[attr_name].notna()].copy()
point_sub = pt_gdf[pt_gdf[attr_name].notna()].copy()
if poly_sub.empty or point_sub.empty:
return None, []
poly_sub[attr_name] = poly_sub[attr_name].astype(str).str.strip()
point_sub[attr_name] = point_sub[attr_name].astype(str).str.strip()
common_values = set(poly_sub[attr_name].unique()) & set(point_sub[attr_name].unique())
if not common_values:
return None, []
matched_parts = []
matched_ids = []
for value in common_values:
poly_part = poly_sub[poly_sub[attr_name] == value].copy()
point_part = point_sub[point_sub[attr_name] == value][[soil_prop, 'geometry']].copy()
if poly_part.empty or point_part.empty:
continue
matched_part = gpd.sjoin_nearest(poly_part, point_part, how='left', rsuffix=suffix)
matched_part = matched_part[~matched_part.index.duplicated(keep='first')]
if not matched_part.empty:
matched_parts.append(matched_part)
matched_ids.extend(matched_part.index.tolist())
if matched_parts:
return pd.concat(matched_parts), matched_ids
return None, []
matched_results = []
matched_indices = []
# --- 第一步:按相同 TZ 匹配 ---
print("步骤 1: 正在匹配相同 TZ 的最近点...")
first_matched, first_ids = match_by_attribute(polygons, points, 'TZ', '_p1')
if first_matched is not None and not first_matched.empty:
matched_results.append(first_matched)
matched_indices.extend(first_ids)
# --- 第二步:按 TS 匹配未匹配面 ---
unmatched_mask = ~polygons.index.isin(matched_indices)
remaining_polygons = polygons[unmatched_mask].copy()
print(f"步骤 2: 正在为 {len(remaining_polygons)} 个要素匹配 TS 最近点...")
if not remaining_polygons.empty:
ts_matched, ts_ids = match_by_attribute(remaining_polygons, points, 'TS', '_p_ts')
if ts_matched is not None and not ts_matched.empty:
matched_results.append(ts_matched)
matched_indices.extend(ts_ids)
remaining_polygons = polygons[~polygons.index.isin(matched_indices)].copy()
print(f"已匹配 TS: {len(ts_ids)} 个要素,剩余 {len(remaining_polygons)} 个。")
else:
print("未匹配到 TS 类型要素,继续下一步。")
# --- 第三步:按 YL 匹配未匹配面 ---
if not remaining_polygons.empty:
print(f"步骤 3: 正在为 {len(remaining_polygons)} 个要素匹配 YL 最近点...")
yl_matched, yl_ids = match_by_attribute(remaining_polygons, points, 'YL', '_p_yl')
if yl_matched is not None and not yl_matched.empty:
matched_results.append(yl_matched)
matched_indices.extend(yl_ids)
remaining_polygons = polygons[~polygons.index.isin(matched_indices)].copy()
print(f"已匹配 YL: {len(yl_ids)} 个要素,剩余 {len(remaining_polygons)} 个。")
else:
print("未匹配到 YL 类型要素,继续下一步。")
# --- 第四步:按 TL 匹配未匹配面 ---
if not remaining_polygons.empty:
print(f"步骤 4: 正在为 {len(remaining_polygons)} 个要素匹配 TL 最近点...")
tl_matched, tl_ids = match_by_attribute(remaining_polygons, points, 'TL', '_p_tl')
if tl_matched is not None and not tl_matched.empty:
matched_results.append(tl_matched)
matched_indices.extend(tl_ids)
remaining_polygons = polygons[~polygons.index.isin(matched_indices)].copy()
print(f"已匹配 TL: {len(tl_ids)} 个要素,剩余 {len(remaining_polygons)} 个。")
else:
print("未匹配到 TL 类型要素,继续全局最近点。")
else:
print("没有未匹配的面要素,跳过 TS/YL/TL 匹配。")
# --- 最后:全局最近点匹配剩余面 ---
unmatched_mask = ~polygons.index.isin(matched_indices)
remaining_polygons = polygons[unmatched_mask].copy()
print(f"最后一步: 正在为 {len(remaining_polygons)} 个要素匹配全局最近点...")
if not remaining_polygons.empty:
point_pool = points[[soil_prop, 'geometry']]
step_final = gpd.sjoin_nearest(remaining_polygons, point_pool, how='left', rsuffix='_p2')
step_final = step_final[~step_final.index.duplicated(keep='first')]
matched_results.append(step_final)
# --- 第三步:稳健合并 ---
print("正在合并数据...")
# 过滤掉列表中可能的 None 或空 DataFrame防止 FutureWarning
to_concat = [res for res in matched_results if res is not None and not res.empty]
if to_concat:
final_gdf = pd.concat(to_concat)
else:
# 如果没有任何匹配结果,返回带空 GZCHD 的原面要素
final_gdf = polygons.copy()
final_gdf[soil_prop] = 0
# --- 4. 清理与保存 ---
# 删除临时列
cols_to_drop = [
c for c in final_gdf.columns
if 'index_' in c
or '_p1' in c
or '_p2' in c
or '_p_ts' in c
or '_p_yl' in c
or '_p_tl' in c
]
final_gdf = final_gdf.drop(columns=cols_to_drop)
# 强制去重复列名
final_gdf = final_gdf.loc[:, ~final_gdf.columns.duplicated()]
# 填充空值并确保类型一致
if soil_prop in final_gdf.columns:
final_gdf[soil_prop] = final_gdf[soil_prop].fillna(0)
else:
final_gdf[soil_prop] = 0
print(f"正在保存结果至: {output_path}")
final_gdf.to_file(output_path, encoding='utf-8')
print("处理完成!")
return final_gdf
if __name__ == "__main__":
# 遍历文件夹中所有样点shp文件并进行处理
shp_file = r"E:\@三普属性图出图\测试\YXTCHD.shp" # 样点数据文件夹
dltb_file = r"E:\@三普属性图出图\广西天峨县\@基础数据\土壤类型图\土壤类型图.shp" # 耕地图斑
output_folder = r"E:\@三普属性图出图\广西天峨县" # 输出文件夹
assign_gzchd_flexible_v2(
soil_prop= "YXTCHD", # 耕地层厚度字段名
point_path=shp_file, # 样点数据
polygon_path= dltb_file,
output_path=fr'{output_folder}\YXTCHD.shp' # 输出文件
)
time.sleep(2) # 防止文件读写冲突

View File

@@ -0,0 +1,404 @@
import pandas as pd
import numpy as np
import os
import geopandas as gpd
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, Border, Side
from openpyxl.utils import get_column_letter
# 定义指标代码与单位的对应关系
INDICATOR_UNITS = {
# 基本指标
'PH': ('pH', '-'),
'ECA': ('交换性钙', 'cmol(½Ca²⁺)/kg'),
'EMG': ('交换性镁', 'cmol(½Mg²⁺)/kg'),
'TN': ('全氮', 'g/kg'),
'TP': ('全磷', 'g/kg'),
'TK': ('全钾', 'g/kg'),
'AS1': ('有效硫', 'mg/kg'),
'AB': ('有效硼', 'mg/kg'),
'AP': ('有效磷', 'mg/kg'),
'AFE': ('有效铁', 'mg/kg'),
'ACU': ('有效铜', 'mg/kg'),
'AZN': ('有效锌', 'mg/kg'),
'AMN': ('有效锰', 'mg/kg'),
'OM': ('有机质', 'g/kg'),
'GZCHD': ('耕层厚度', 'cm'),
'AK': ('速效钾', 'mg/kg'),
'CEC': ('阳离子交换量', 'cmol/kg'),
# 特殊指标 - 根据文件名对应字段
'FL': ('粉粒', '%'),
'NL': ('黏粒', '%'),
'SL': ('砂粒', '%'),
'TRRZPJZ': ('土壤容重', 'g/cm³'),
'TRZD': ('土壤质地', '分类'),
# 其他可能指标
'AMO': ('有效钼', 'mg/kg'),
'TSE': ('全硒', 'mg/kg'),
'YXTCHD': ('有效土层厚度', 'cm')
}
# 文件名到字段的映射
FILENAME_TO_FIELD = {
'粉粒': 'FL',
'黏粒': 'NL',
'砂粒': 'SL',
'表层容重': 'TRRZPJZ',
'土壤质地十二级分类': 'TRZD',
'双江县YXTCHD': 'YXTCHD'
}
# 扩展字段别名映射支持更多pH字段名
FIELD_ALIASES = {
'PH': ['pH', 'PH', 'ph'], # 支持pH的各种大小写形式
'ECA': ['交换性钙', 'ECA'],
'EMG': ['交换性镁', 'EMG'],
'TN': ['全氮', 'TN'],
'TP': ['全磷', 'TP'],
'TK': ['全钾', 'TK'],
'AS1': ['有效硫', 'AS1'],
'AB': ['有效硼', 'AB'],
'AP': ['有效磷', 'AP'],
'AFE': ['有效铁', 'AFE'],
'ACU': ['有效铜', 'ACU'],
'AZN': ['有效锌', 'AZN'],
'AMN': ['有效锰', 'AMN'],
'OM': ['有机质', 'OM'],
'GZCHD': ['耕层厚度', 'GZCHD'],
'AK': ['速效钾', 'AK'],
'CEC': ['阳离子交换量', 'CEC'],
'FL': ['粉粒', 'FL'],
'NL': ['黏粒', 'NL'],
'SL': ['砂粒', 'SL'],
'TRRZPJZ': ['土壤容重', 'TRRZPJZ'],
'TRZD': ['土壤质地', 'TRZD'],
'AMO': ['有效钼', 'AMO'],
'TSE': ['全硒', 'TSE'],
'YXTCHD': ['有效土层厚度', 'YXTCHD']
}
def find_shapefiles(folder_path):
"""在文件夹中递归查找所有的Shapefile文件"""
shapefiles = []
for root, dirs, files in os.walk(folder_path):
for file in files:
if file.lower().endswith('.shp'):
shapefiles.append(os.path.join(root, file))
return shapefiles
def read_shapefile_data(shapefile_path):
"""读取Shapefile数据并返回属性表"""
try:
print(f" 读取Shapefile: {os.path.basename(shapefile_path)}")
gdf = gpd.read_file(shapefile_path, encoding='utf-8')
print(f" 要素数量: {len(gdf)}")
print(f" 属性字段: {list(gdf.columns)}")
return gdf
except Exception as e:
print(f" 读取Shapefile失败: {e}")
try:
gdf = gpd.read_file(shapefile_path, encoding='gbk')
print(f" 使用GBK编码成功读取")
return gdf
except:
return None
def get_indicator_data(gdf, filename):
"""从GeoDataFrame中获取指标数据使用统一字段匹配逻辑"""
indicator_data = {}
basename = os.path.basename(filename).replace('.shp', '')
# 1. 首先尝试文件名映射
target_field = None
if basename in FILENAME_TO_FIELD:
target_field = FILENAME_TO_FIELD[basename]
if target_field in gdf.columns:
indicator_data[target_field] = gdf[target_field]
print(f" 通过文件名映射找到字段: {target_field}")
else:
# 尝试通过别名查找
for indicator_code in INDICATOR_UNITS.keys():
if target_field == indicator_code:
for alias in FIELD_ALIASES.get(indicator_code, []):
if alias in gdf.columns:
indicator_data[indicator_code] = gdf[alias]
print(f" 通过文件名映射+别名找到字段: {alias} -> {indicator_code}")
break
# 2. 如果没有通过文件名找到,尝试直接匹配所有指标和别名
if not indicator_data:
for indicator_code in INDICATOR_UNITS.keys():
# 先尝试直接匹配指标代码
if indicator_code in gdf.columns:
indicator_data[indicator_code] = gdf[indicator_code]
print(f" 直接匹配字段: {indicator_code}")
continue
# 再尝试匹配别名
aliases = FIELD_ALIASES.get(indicator_code, [])
for alias in aliases:
if alias in gdf.columns:
indicator_data[indicator_code] = gdf[alias]
print(f" 通过别名匹配: {alias} -> {indicator_code}")
break
# 3. 额外检查:如果文件名包含特定关键词,尝试匹配
if not indicator_data:
filename_lower = basename.lower()
for indicator_code, (chinese_name, unit) in INDICATOR_UNITS.items():
if indicator_code.lower() in filename_lower or chinese_name in filename_lower:
# 尝试匹配指标代码或中文名
if indicator_code in gdf.columns:
indicator_data[indicator_code] = gdf[indicator_code]
print(f" 通过文件名关键词匹配: {indicator_code}")
break
elif chinese_name in gdf.columns:
indicator_data[indicator_code] = gdf[chinese_name]
print(f" 通过文件名关键词匹配中文名: {chinese_name} -> {indicator_code}")
break
return indicator_data
def get_combined_stats_from_folder(folder_path, folder_name="数据"):
"""从文件夹中所有shapefile合并统计指定指标"""
shapefiles = find_shapefiles(folder_path)
if not shapefiles:
print(f" 未找到Shapefile文件")
return pd.DataFrame()
print(f" 找到 {len(shapefiles)} 个Shapefile文件")
all_data = {code: [] for code in INDICATOR_UNITS.keys()}
for i, shp_file in enumerate(shapefiles, 1):
print(f"\n [{i}] 处理文件: {os.path.basename(shp_file)}")
gdf = read_shapefile_data(shp_file)
if gdf is not None:
indicator_data = get_indicator_data(gdf, shp_file)
for indicator_code, data_series in indicator_data.items():
if indicator_code in all_data:
# 转换为数值类型,处理可能的非数值数据
try:
data_series = pd.to_numeric(data_series, errors='coerce')
valid_data = data_series.dropna()
if len(valid_data) > 0:
all_data[indicator_code].extend(valid_data.tolist())
print(f" 提取 {indicator_code}: {len(valid_data)} 个值")
except Exception as e:
print(f" 处理 {indicator_code} 数据时出错: {e}")
# 计算每个指标的合并统计
stats_list = []
for indicator_code, (chinese_name, unit) in INDICATOR_UNITS.items():
data_list = all_data.get(indicator_code, [])
if not data_list:
continue
data_series = pd.Series(data_list)
# 过滤极端值(可选,根据实际需求调整)
data_series = data_series[(data_series >= 0) | pd.isna(data_series)]
if len(data_series) == 0:
continue
# 关键修复1计算总体标准差ddof=0而不是默认的样本标准差ddof=1
std_dev = data_series.std(ddof=0)
mean_val = data_series.mean()
# 关键修复2优化变异系数计算
if abs(mean_val) < 1e-8: # 均值接近0时
cv_value = 0.0
else:
# CV = (标准差 / 均值) * 100保留2位小数
cv_value = round((std_dev / mean_val) * 100, 2)
stats = {
'指标代码': indicator_code,
'指标': chinese_name,
'单位': unit,
'样点数': int(len(data_series)),
'Min': round(float(data_series.min()), 2),
'Max': round(float(data_series.max()), 2),
'Mean': round(float(mean_val), 2),
'Std': round(float(std_dev), 2), # 使用总体标准差
'CV': cv_value
}
stats_list.append(stats)
print(f" 统计 {chinese_name}({indicator_code}): {len(data_series)} 个样点")
if stats_list:
stats_df = pd.DataFrame(stats_list)
stats_df = stats_df.sort_values('指标')
print(f"\n 总共统计到 {len(stats_df)} 个指标")
return stats_df
print(" 未找到任何指标数据")
return pd.DataFrame()
def create_statistics_excel(before_folder, after_folder, output_path):
"""创建融合的统计表格,在剔除后表格前加一列剔除前样点数和剔除样点数"""
workbook = Workbook()
# 移除默认sheet
if 'Sheet' in workbook.sheetnames:
default_sheet = workbook['Sheet']
workbook.remove(default_sheet)
# 定义样式
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
print("=" * 60)
print("开始分析样点数据")
print("=" * 60)
# 分析剔除前数据
before_stats = None
if os.path.exists(before_folder):
print(f"\n[1] 分析剔除前数据:")
print(f"文件夹路径: {before_folder}")
before_stats = get_combined_stats_from_folder(before_folder, "剔除前")
if not before_stats.empty:
print(f"✓ 剔除前统计完成: {len(before_stats)} 个指标")
else:
print("✗ 剔除前未找到指定指标数据")
before_stats = None
else:
print(f"✗ 剔除前文件夹不存在: {before_folder}")
before_stats = None
# 分析剔除后数据
if os.path.exists(after_folder):
print(f"\n[2] 分析剔除后数据:")
print(f"文件夹路径: {after_folder}")
after_stats = get_combined_stats_from_folder(after_folder, "剔除后")
if not after_stats.empty:
# 创建融合的统计工作表
sheet_combined = workbook.create_sheet(title="样点统计")
# 新的表头:指标, 单位, 剔除前样点数, 剔除样点数, 剔除后样点数, Min, Max, Mean, Std, CV
combined_headers = ['指标', '单位', '剔除前样点数', '剔除样点数', '剔除后样点数', 'Min', 'Max', 'Mean',
'Std', 'CV']
for col, header in enumerate(combined_headers, 1):
cell = sheet_combined.cell(row=1, column=col, value=header)
cell.alignment = Alignment(horizontal='center', vertical='center')
cell.font = Font(bold=True)
cell.border = thin_border
# 写入数据
for row_idx, (index, after_row) in enumerate(after_stats.iterrows(), start=2):
# 查找对应的剔除前数据
before_sample_count = 0
before_row = None
if before_stats is not None:
# 首先尝试通过指标代码匹配
matching_rows = before_stats[before_stats['指标代码'] == after_row['指标代码']]
if not matching_rows.empty:
before_row = matching_rows.iloc[0]
else:
# 如果指标代码匹配失败,尝试通过指标名称匹配
matching_rows = before_stats[before_stats['指标'] == after_row['指标']]
if not matching_rows.empty:
before_row = matching_rows.iloc[0]
if before_row is not None:
before_sample_count = int(before_row['样点数'])
# 计算剔除样点数
after_sample_count = int(after_row['样点数'])
abnormal_count = max(0, before_sample_count - after_sample_count)
# 写入数据
sheet_combined.cell(row=row_idx, column=1, value=after_row['指标']) # 指标
sheet_combined.cell(row=row_idx, column=2, value=after_row['单位']) # 单位
sheet_combined.cell(row=row_idx, column=3, value=before_sample_count) # 剔除前样点数
sheet_combined.cell(row=row_idx, column=4, value=abnormal_count) # 剔除样点数
sheet_combined.cell(row=row_idx, column=5, value=after_sample_count) # 剔除后样点数
sheet_combined.cell(row=row_idx, column=6, value=after_row['Min']) # Min
sheet_combined.cell(row=row_idx, column=7, value=after_row['Max']) # Max
sheet_combined.cell(row=row_idx, column=8, value=after_row['Mean']) # Mean
sheet_combined.cell(row=row_idx, column=9, value=after_row['Std']) # Std
sheet_combined.cell(row=row_idx, column=10, value=after_row['CV']) # CV
# 设置所有单元格的样式
for col_idx in range(1, 11):
cell = sheet_combined.cell(row=row_idx, column=col_idx)
cell.alignment = Alignment(horizontal='center', vertical='center')
cell.border = thin_border
# 如果剔除了样点,高亮显示剔除样点数列
if abnormal_count > 0:
cell = sheet_combined.cell(row=row_idx, column=4) # 剔除样点数列
cell.font = Font(bold=True, color="FF0000") # 红色加粗
# 调整列宽
combined_column_widths = {
'指标': 15,
'单位': 12,
'剔除前样点数': 12,
'剔除样点数': 12,
'剔除后样点数': 12,
'Min': 10,
'Max': 10,
'Mean': 10,
'Std': 10,
'CV': 10
}
for col_idx, col_name in enumerate(combined_headers, 1):
column_letter = get_column_letter(col_idx)
if col_name in combined_column_widths:
sheet_combined.column_dimensions[column_letter].width = combined_column_widths[col_name]
print(f"\n✓ 融合统计完成: {len(after_stats)} 个指标")
# 输出匹配信息
if before_stats is not None:
print(f" 剔除前找到 {len(before_stats)} 个指标")
print(f" 剔除后找到 {len(after_stats)} 个指标")
print(
f" 成功匹配 {len([i for i in range(2, len(after_stats) + 2) if sheet_combined.cell(row=i, column=3).value > 0])} 个指标的剔除前数据")
else:
print("✗ 剔除后未找到指定指标数据")
sheet_combined = workbook.create_sheet(title="样点统计")
sheet_combined.cell(row=1, column=1, value="未找到指定指标数据")
else:
print(f"✗ 剔除后文件夹不存在: {after_folder}")
sheet_combined = workbook.create_sheet(title="样点统计")
sheet_combined.cell(row=1, column=1, value="剔除后文件夹不存在")
# 保存文件
workbook.save(output_path)
print(f"\n" + "=" * 60)
print(f"文件保存成功: {output_path}")
print("=" * 60)
# ================ 使用示例 ================
if __name__ == "__main__":
# 方式1: 处理单个样点数据对
before_folder = r"D:\a陆平\1.5实验数据\12月新版实验室数据\云南省实验室数据成果1218\永仁县20260127"
after_folder = r"D:\a陆平\1.5实验数据\12月新版实验室数据\云南省实验室数据成果1218\永仁县20260127剔除后"
output_path = r"D:\a陆平\1.5实验数据\12月新版实验室数据\云南省实验室数据成果1218\永仁县样点统计结果.xlsx"
# 执行
create_statistics_excel(before_folder, after_folder, output_path)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,932 @@
# 生成完整的exactextract面积加权计算Python脚本
# -*- coding: utf-8 -*-
"""
土壤属性栅格数据面积加权统计脚本
基于exactextract库实现多属性栅格的面积加权平均值计算
最终输出格式与土壤属性图斑数据表一致
"""
import exactextract as ee
import geopandas as gpd
import rasterio
import pandas as pd
import numpy as np
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
def init_logger():
"""初始化日志输出,便于跟踪处理过程"""
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
return logging.getLogger(__name__)
def validate_data(vector_path, raster_files):
"""
验证输入数据的有效性
:param vector_path: 矢量图斑文件路径
:param raster_files: 栅格文件字典
:return: 验证结果(布尔值)
"""
logger = init_logger()
# 验证矢量文件是否存在
if not Path(vector_path).exists():
logger.error(f"矢量文件不存在:{vector_path}")
return False
# 验证栅格文件是否存在
for attr_name, raster_path in raster_files.items():
if not Path(raster_path).exists():
logger.error(f"栅格文件不存在:{attr_name} -> {raster_path}")
return False
# 验证矢量文件格式
try:
gdf = gpd.read_file(vector_path)
if gdf.geometry.type.unique()[0] != 'Polygon':
logger.error("矢量文件必须是Polygon类型面要素")
return False
except Exception as e:
logger.error(f"矢量文件读取失败:{str(e)}")
return False
# 验证栅格文件格式
try:
test_raster = next(iter(raster_files.values()))
with rasterio.open(test_raster) as src:
if src.count != 1:
logger.error("每个栅格文件必须是单波段(每个属性单独一个栅格)")
return False
except Exception as e:
logger.error(f"栅格文件读取失败:{str(e)}")
return False
logger.info("所有输入数据验证通过")
return True
def standardize_crs(gdf, raster_path):
"""
标准化矢量与栅格的坐标参考系统CRS
:param gdf: 矢量GeoDataFrame
:param raster_path: 任意一个栅格文件路径用于获取目标CRS
:return: 标准化后的GeoDataFrame
"""
logger = init_logger()
# 获取栅格CRS
with rasterio.open(raster_path) as src:
raster_crs = src.crs
raster_crs_str = src.crs.to_string()
# 获取矢量CRS
vector_crs = gdf.crs
vector_crs_str = gdf.crs.to_string()
logger.info(f"当前矢量CRS{vector_crs_str}")
logger.info(f"目标栅格CRS{raster_crs_str}")
# 若CRS不一致进行转换
if vector_crs != raster_crs:
logger.warning("矢量与栅格CRS不一致正在进行转换...")
gdf = gdf.to_crs(raster_crs)
logger.info(f"CRS转换完成新矢量CRS{gdf.crs.to_string()}")
return gdf
def calculate_area_weighted_stats(vector_path, raster_files, output_path):
"""
核心函数:计算面积加权统计值
:param vector_path: 矢量图斑文件路径
:param raster_files: 栅格文件字典(键:属性名,值:栅格路径)
:param output_path: 结果输出路径Excel文件
:return: 统计结果DataFrame
"""
logger = init_logger()
logger.info("开始面积加权统计计算")
# 1. 加载矢量数据
logger.info(f"加载矢量数据:{vector_path}")
gdf = gpd.read_file(vector_path)
# 2. 标准化CRS
test_raster = next(iter(raster_files.values()))
gdf = standardize_crs(gdf, test_raster)
# 3. 初始化结果DataFrame保留矢量中的关键属性
logger.info("初始化结果数据结构")
# 基础字段列表(与土壤属性图斑表格式对齐)
# TODO
base_fields = ["FID","DM","XZM","QSDWDM","QSDWMC","TL", "YL", "TS", "TZ", "DLBM", "DLMC"]
# 检查矢量中是否包含必要字段,若不包含则创建空字段
result_df = pd.DataFrame()
for field in base_fields:
if field in gdf.columns:
result_df[field] = gdf[field]
else:
result_df[field] = np.nan
logger.warning(f"矢量中缺少'{field}'字段,将生成空值")
# 4. 计算图斑面积(转换为亩)
logger.info("计算图斑面积(单位:亩)")
# 计算平方米面积根据CRS单位自动适应
gdf["area_sqm"] = gdf.geometry.area
# 转换为亩1亩 ≈ 666.6667平方米)
result_df["面积亩"] = gdf["area_sqm"] * 0.0015
# 保留6位小数与示例数据格式一致
result_df["面积亩"] = result_df["面积亩"].round(6)
# 5. 对每个土壤属性进行面积加权平均计算
logger.info("开始处理土壤属性栅格(面积加权平均)")
for attr_idx, (attr_name, raster_path) in enumerate(raster_files.items(), 1):
total_attrs = len(raster_files)
logger.info(f"处理进度:{attr_idx}/{total_attrs} - 属性:{attr_name}")
try:
# 使用exactextract计算面积加权平均
# weights="area":按矢量与栅格的交集面积进行加权
stats = ee.exact_extract(
raster_path,
gdf,
["mean"], # 计算平均值
output="pandas" # 输出为DataFrame格式
)
# 将统计结果添加到结果DataFrame
if stats is None:
logger.warning(f"{attr_name}计算结果为空,可能无交集区域")
result_df[attr_name] = np.nan
continue
else:
# 确保 stats 为 pandas.DataFrame以便使用字符串索引
if not isinstance(stats, pd.DataFrame):
stats = pd.DataFrame(stats)
# 保留4位小数确保数据精度
result_df[attr_name] = stats["mean"].round(4)
# 处理可能的空值(无交集区域)
if result_df[attr_name].isnull().sum() > 0:
null_count = result_df[attr_name].isnull().sum()
logger.warning(f"{attr_name}存在{null_count}个空值(图斑与栅格无交集)")
# 用0填充空值可根据业务需求调整
result_df[attr_name] = result_df[attr_name].fillna(0)
except Exception as e:
logger.error(f"{attr_name}处理失败:{str(e)}")
# 失败时填充空值,避免整个程序崩溃
result_df[attr_name] = np.nan
# 6. 添加属性分级字段(根据业务规则实现)
logger.info("添加土壤属性分级字段")
result_df = add_attribute_classification(result_df)
# 7. 整理最终字段顺序(与示例表格完全对齐)
logger.info("整理输出字段顺序")
# TODO
final_columns = [
"FID","DM", "XZM", "QSDWDM", "QSDWMC",
"TL", "YL", "TS", "TZ", "DLBM", "DLMC",
"耕层厚度", "土壤容重", "砂粒", "粉粒", "黏粒", "酸碱度", "阳离子",
"有机质", "全氮", "全磷", "全钾", "有效磷", "速效钾", "有效铁", "有效锰",
"有效铜", "有效锌", "有效硼", "有效钼", "有效硫", "交换性钙", "交换性镁", "全硒",
"有效土层厚度", "土壤质地",
"耕层厚度分级", "土壤容重分级", "砂粒分级", "粉粒分级", "黏粒分级", "酸碱度分级", "阳离子分级",
"有机质分级", "全氮分级", "全磷分级", "全钾分级", "有效铁分级", "速效钾分级", "有效铁分级", "有效锰分级",
"有效铜分级", "有效锌分级", "有效硼分级", "有效钼分级", "有效硫分级", "交换性钙分级", "交换性镁分级", "全硒分级",
"有效土层厚度分级", "土壤质地分级",
"面积亩"
]
# 补充缺失的字段(如乡代码等)
for col in final_columns:
if col not in result_df.columns:
result_df[col] = np.nan
# 按最终顺序排列字段
result_df = result_df[final_columns]
# 8. 导出结果到Excel
logger.info(f"导出结果到:{output_path}")
# 使用openpyxl引擎支持.xlsx格式
result_df.to_excel(output_path, index=False, engine="openpyxl")
logger.info(f"结果导出完成,共生成{len(result_df)}条记录")
return result_df
def add_attribute_classification(df):
"""
添加土壤属性分级字段(根据常见土壤分类标准实现)
可根据实际业务需求调整分级阈值
:param df: 包含原始属性的DataFrame
:return: 包含分级字段的DataFrame
"""
# todo
# 1. 酸碱度分级pH值 - 按PH标准
def classify_ph(ph):
"""
土壤pH值分级第三次全国土壤普查标准
标准等级:
等级一: 6.07.0
等级二: 7.07.5, 5.56.0
等级三: 7.58.0, 5.05.5
等级四: 8.08.5, 4.55.0
等级五: 8.5, ≤4.5
"""
if ph > 8.5 or ph <= 4.5:
return 5 # 等级五: 8.5, ≤4.5
elif (8.0 < ph <= 8.5) or (4.5 < ph <= 5.0):
return 4 # 等级四: 8.08.5, 4.55.0
elif (7.5 < ph <= 8.0) or (5.0 < ph <= 5.5):
return 3 # 等级三: 7.58.0, 5.05.5
elif (7.0 < ph <= 7.5) or (5.5 < ph <= 6.0):
return 2 # 等级二: 7.07.5, 5.56.0
elif 6.0 < ph <= 7.0:
return 1 # 等级一: 6.07.0
else:
return None # 异常值
# 2. 有机质分级单位g/kg - 按OM标准
def classify_organic(organic):
"""
土壤有机质分级(第三次全国土壤普查标准)
标准等级:
等级一: 35.0
等级二: 25.035.0
等级三: 15.025.0
等级四: 10.015.0
等级五: ≤10.0
"""
if organic > 35.0:
return 1 # 等级一: 35.0
elif 25.0 < organic <= 35.0:
return 2 # 等级二: 25.035.0
elif 15.0 < organic <= 25.0:
return 3 # 等级三: 15.025.0
elif 10.0 < organic <= 15.0:
return 4 # 等级四: 10.015.0
elif organic <= 10.0:
return 5 # 等级五: ≤10.0
else:
return None # 异常值
# 3. 阳离子交换量分级单位cmol/kg - 按CEC标准
def classify_cation(cation):
"""
土壤阳离子交换量分级(第三次全国土壤普查标准)
标准等级:
等级一: 30.0
等级二: 20.030.0
等级三: 15.020.0
等级四: 10.015.0
等级五: ≤10.0
"""
if cation > 30.0:
return 1 # 等级一: 30.0
elif 20.0 < cation <= 30.0:
return 2 # 等级二: 20.030.0
elif 15.0 < cation <= 20.0:
return 3 # 等级三: 15.020.0
elif 10.0 < cation <= 15.0:
return 4 # 等级四: 10.015.0
elif cation <= 10.0:
return 5 # 等级五: ≤10.0
else:
return None # 异常值
# 4. 有效磷分级单位mg/kg - 按AP标准
def classify_available_p(p):
"""
土壤有效磷分级(第三次全国土壤普查标准)
标准等级:
等级一: 40.0
等级二: 25.040.0
等级三: 15.025.0
等级四: 5.015.0
等级五: ≤5.0
"""
if p > 40.0:
return 1 # 等级一: 40.0
elif 25.0 < p <= 40.0:
return 2 # 等级二: 25.040.0
elif 15.0 < p <= 25.0:
return 3 # 等级三: 15.025.0
elif 5.0 < p <= 15.0:
return 4 # 等级四: 5.015.0
elif p <= 5.0:
return 5 # 等级五: ≤5.0
else:
return None # 异常值
# 5. 速效钾分级单位mg/kg - 按AK标准
def classify_available_k(k):
"""
土壤速效钾分级(第三次全国土壤普查标准)
标准等级:
等级一: 150
等级二: 100150
等级三: 75100
等级四: 5075
等级五: ≤50
"""
if k > 150:
return 1 # 等级一: 150
elif 100 < k <= 150:
return 2 # 等级二: 100150
elif 75 < k <= 100:
return 3 # 等级三: 75100
elif 50 < k <= 75:
return 4 # 等级四: 5075
elif k <= 50:
return 5 # 等级五: ≤50
else:
return None # 异常值
# 6. 耕层厚度分级单位cm - 按GZCHD标准
def classify_soil_depth(depth):
"""
土壤耕作层厚度分级(第三次全国土壤普查标准)
标准等级:
等级一: 25.0
等级二: 20.025.0
等级三: 15.020.0
等级四: 10.015.0
等级五: ≤10.0
"""
if depth > 25.0:
return 1 # 等级一: 25.0
elif 20.0 < depth <= 25.0:
return 2 # 等级二: 20.025.0
elif 15.0 < depth <= 20.0:
return 3 # 等级三: 15.020.0
elif 10.0 < depth <= 15.0:
return 4 # 等级四: 10.015.0
elif depth <= 10.0:
return 5 # 等级五: ≤10.0
else:
return None # 异常值
# 7. 土壤容重分级单位g/cm³ - 按TRRZ标准
def classify_bulk_density(density):
"""
土壤容重分级(第三次全国土壤普查标准)
标准等级:
等级一: 1.101.25
等级二: 1.251.35, 1.001.10
等级三: 1.351.45
等级四: 1.451.55, 0.901.00
等级五: 1.55, ≤0.90
"""
if 1.10 < density <= 1.25:
return 1 # 等级一: 1.101.25
elif (1.25 < density <= 1.35) or (1.00 < density <= 1.10):
return 2 # 等级二: 1.251.35, 1.001.10
elif 1.35 < density <= 1.45:
return 3 # 等级三: 1.351.45
elif (1.45 < density <= 1.55) or (0.90 < density <= 1.00):
return 4 # 等级四: 1.451.55, 0.901.00
elif density > 1.55 or density <= 0.90:
return 5 # 等级五: 1.55, ≤0.90
else:
return None # 异常值
# 8. 全氮分级单位g/kg - 按TN标准
def classify_total_n(n):
"""
土壤全氮分级(第三次全国土壤普查标准)
标准等级:
等级一: 2.00
等级二: 1.502.00
等级三: 1.001.50
等级四: 0.501.00
等级五: ≤0.50
"""
if n > 2.00:
return 1 # 等级一: 2.00
elif 1.50 < n <= 2.00:
return 2 # 等级二: 1.502.00
elif 1.00 < n <= 1.50:
return 3 # 等级三: 1.001.50
elif 0.50 < n <= 1.00:
return 4 # 等级四: 0.501.00
elif n <= 0.50:
return 5 # 等级五: ≤0.50
else:
return None # 异常值
# 9. 全磷分级单位g/kg - 按TP标准
def classify_total_p(p):
"""
土壤全磷分级(第三次全国土壤普查标准)
标准等级:
等级一: 1.00
等级二: 0.801.00
等级三: 0.600.80
等级四: 0.400.60
等级五: ≤0.40
"""
if p > 1.00:
return 1 # 等级一: 1.00
elif 0.80 < p <= 1.00:
return 2 # 等级二: 0.801.00
elif 0.60 < p <= 0.80:
return 3 # 等级三: 0.600.80
elif 0.40 < p <= 0.60:
return 4 # 等级四: 0.400.60
elif p <= 0.40:
return 5 # 等级五: ≤0.40
else:
return None # 异常值
# 10. 全钾分级单位g/kg - 按TK标准
def classify_total_k(k):
"""
土壤全钾分级(第三次全国土壤普查标准)
标准等级:
等级一: 20.0
等级二: 15.020.0
等级三: 10.015.0
等级四: 5.010.0
等级五: ≤5.0
"""
if k > 20.0:
return 1 # 等级一: 20.0
elif 15.0 < k <= 20.0:
return 2 # 等级二: 15.020.0
elif 10.0 < k <= 15.0:
return 3 # 等级三: 10.015.0
elif 5.0 < k <= 10.0:
return 4 # 等级四: 5.010.0
elif k <= 5.0:
return 5 # 等级五: ≤5.0
else:
return None # 异常值
# 11. 有效铁分级单位mg/kg
def classify_available_fe(fe):
"""
土壤有效铁分级(第三次全国土壤普查标准)
参数:
fe: 有效铁含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if fe <= 3.0:
return 5 # 等级五: ≤3.0
elif 3.0 < fe <= 5.0:
return 4 # 等级四: 3.05.0
elif 5.0 < fe <= 10.0:
return 3 # 等级三: 5.010.0
elif 10.0 < fe <= 20.0:
return 2 # 等级二: 10.020.0
else: # fe > 20.0
return 1 # 等级一: 20.0
# 12. 有效锌分级单位mg/kg
def classify_available_zn(zn):
"""
土壤有效锌分级(第三次全国土壤普查标准)
参数:
zn: 有效锌含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if zn <= 0.20:
return 5 # 等级五: ≤0.20
elif 0.20 < zn <= 0.50:
return 4 # 等级四: 0.200.50
elif 0.50 < zn <= 1.00:
return 3 # 等级三: 0.501.00
elif 1.00 < zn <= 3.00:
return 2 # 等级二: 1.003.00
else: # zn > 3.00
return 1 # 等级一: 3.00
# 13. 有效锰分级单位mg/kg
def classify_available_mn(mn):
"""
土壤有效锰分级(第三次全国土壤普查标准)
参数:
mn: 有效锰含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if mn <= 1.0:
return 5 # 等级五: ≤1.0
elif 1.0 < mn <= 5.0:
return 4 # 等级四: 1.05.0
elif 5.0 < mn <= 15.0:
return 3 # 等级三: 5.015.0
elif 15.0 < mn <= 30.0:
return 2 # 等级二: 15.030.0
else: # mn > 30.0
return 1 # 等级一: 30.0
# 14. 有效铜分级单位mg/kg
def classify_available_cu(cu):
"""
土壤有效铜分级(第三次全国土壤普查标准)
参数:
cu: 有效铜含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if cu <= 0.20:
return 5 # 等级五: ≤0.20
elif 0.20 < cu <= 0.50:
return 4 # 等级四: 0.200.50
elif 0.50 < cu <= 1.00:
return 3 # 等级三: 0.501.00
elif 1.00 < cu <= 2.00:
return 2 # 等级二: 1.002.00
else: # cu > 2.00
return 1 # 等级一: 2.00
# 15. 有效硼分级单位mg/kg
def classify_available_b(b):
"""
土壤有效硼分级(第三次全国土壤普查标准)
参数:
b: 有效硼含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if b <= 0.20:
return 5 # 等级五: ≤0.20
elif 0.20 < b <= 0.50:
return 4 # 等级四: 0.200.50
elif 0.50 < b <= 0.80:
return 3 # 等级三: 0.500.80
elif 0.80 < b <= 1.00:
return 2 # 等级二: 0.801.00
else: # b > 1.00
return 1 # 等级一: 1.00
# 16. 有效钼分级单位mg/kg
def classify_available_mo(mo):
"""
土壤有效钼分级(第三次全国土壤普查标准)
参数:
mo: 有效钼含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if mo <= 0.05:
return 5 # 等级五: ≤0.05
elif 0.05 < mo <= 0.10:
return 4 # 等级四: 0.050.10
elif 0.10 < mo <= 0.15:
return 3 # 等级三: 0.100.15
elif 0.15 < mo <= 0.20:
return 2 # 等级二: 0.150.20
else: # mo > 0.20
return 1 # 等级一: 0.20
# 17. 有效硫分级单位mg/kg
def classify_available_s(s):
"""
土壤有效硫分级(第三次全国土壤普查标准)
参数:
s: 有效硫含量 (mg/kg)
返回:
分级等级 (1-5)
"""
if s <= 10.0:
return 5 # 等级五: ≤10.0
elif 10.0 < s <= 20.0:
return 4 # 等级四: 10.020.0
elif 20.0 < s <= 30.0:
return 3 # 等级三: 20.030.0
elif 30.0 < s <= 40.0:
return 2 # 等级二: 30.040.0
else: # s > 40.0
return 1 # 等级一: 40.0
# 18. 交换性钙分级单位cmol(½Ca²⁺)/kg
def classify_exchangeable_ca(ca):
"""
土壤交换性钙分级(第三次全国土壤普查标准)
参数:
ca: 交换性钙含量 (cmol(½Ca²⁺)/kg)
返回:
分级等级 (1-5)
"""
if ca <= 1.00:
return 5 # 等级五: ≤1.00
elif 1.00 < ca <= 2.50:
return 4 # 等级四: 1.002.50
elif 2.50 < ca <= 4.99:
return 3 # 等级三: 2.504.99
elif 4.99 < ca <= 7.49:
return 2 # 等级二: 4.997.49
else: # ca > 7.49
return 1 # 等级一: 7.49
# 19. 交换性镁分级单位cmol(½Mg²⁺)/kg
def classify_exchangeable_mg(mg):
"""
土壤交换性镁分级(第三次全国土壤普查标准)
参数:
mg: 交换性镁含量 (cmol(½Mg²⁺)/kg)
返回:
分级等级 (1-5)
"""
if mg <= 0.41:
return 5 # 等级五: ≤0.41
elif 0.41 < mg <= 0.82:
return 4 # 等级四: 0.410.82
elif 0.82 < mg <= 1.23:
return 3 # 等级三: 0.821.23
elif 1.23 < mg <= 1.64:
return 2 # 等级二: 1.231.64
else: # mg > 1.64
return 1 # 等级一: 1.64
# 20. 全硒分级单位mg/kg
def classify_total_se(se):
"""
土壤全硒分级(第三次全国土壤普查标准)
参数:
se: 全硒含量 (mg/kg)
返回:
分级等级 (1-4)
"""
if se <= 0.17:
return 4 # 等级四: ≤0.17
elif 0.17 < se <= 0.40:
return 3 # 等级三: 0.170.40
elif 0.40 < se <= 3.00:
return 2 # 等级二: 0.403.00
else: # se > 3.00
return 1 # 等级一: 3.00
# 21. 粉粒含量分级(单位:%
def classify_silt(silt):
"""
土壤粉粒含量分级(第三次全国土壤普查标准)
参数:
silt: 粉粒含量 (%)
返回:
分级等级 (1-5)
"""
if silt > 75:
return 5 # 等级五: 75
elif 45 < silt <= 75:
return 4 # 等级四: 4575
elif 30 < silt <= 45:
return 3 # 等级三: 3045
elif 15 < silt <= 30:
return 2 # 等级二: 1530
else: # silt <= 15
return 1 # 等级一: ≤15
# 22. 黏粒含量分级(单位:%
def classify_clay(clay):
"""
土壤黏粒含量分级(第三次全国土壤普查标准)
参数:
clay: 黏粒含量 (%)
返回:
分级等级 (1-5)
"""
if clay > 65:
return 5 # 等级五: 65
elif 45 < clay <= 65:
return 4 # 等级四: 4565
elif 25 < clay <= 45:
return 3 # 等级三: 2545
elif 15 < clay <= 25:
return 2 # 等级二: 1525
else: # clay <= 15
return 1 # 等级一: ≤15
# 23. 砂粒含量分级(单位:%
def classify_sand(sand):
"""
土壤砂粒含量分级(第三次全国土壤普查标准)
参数:
sand: 砂粒含量 (%)
返回:
分级等级 (1-5)
"""
if sand > 85:
return 5 # 等级五: 85
elif 55 < sand <= 85:
return 4 # 等级四: 5585
elif 40 < sand <= 55:
return 3 # 等级三: 4055
elif 30 < sand <= 40:
return 2 # 等级二: 3040
else: # sand <= 30
return 1 # 等级一: ≤30
# 24. 有效土层厚度分级单位cm
def classify_yxtchd(depth):
"""
土壤有效土层厚度分级(第三次全国土壤普查标准)
参数:
depth: 有效土层厚度 (cm)
返回:
分级等级 (1-5)
"""
if depth <= 40:
return 5 # 等级五: ≤40
elif 40 < depth <= 60:
return 4 # 等级四: 4060
elif 60 < depth <= 80:
return 3 # 等级三: 6080
elif 80 < depth <= 100:
return 2 # 等级二: 80100
else: # depth > 100
return 1 # 等级一: 100
# 25. 土壤质地
def classify_trzd(trzd):
"""
土壤质地
参数:
trzd: 土壤质地分类1-5
返回:
分级等级 (1-5)
"""
trzd = round(trzd, 0)
if trzd == 5:
return 5 # 等级五: ≤40
elif trzd == 4:
return 4 # 等级四: 4060
elif trzd == 3:
return 3 # 等级三: 6080
elif trzd == 2:
return 2 # 等级二: 80100
else: # depth > 100
return 1 # 等级一: 100
# 应用分级函数(只处理非空值)
if "酸碱度" in df.columns:
df["酸碱度分级"] = df["酸碱度"].apply(lambda x: classify_ph(x) if pd.notna(x) else np.nan)
if "有机质" in df.columns:
df["有机质分级"] = df["有机质"].apply(lambda x: classify_organic(x) if pd.notna(x) else np.nan)
if "阳离子" in df.columns:
df["阳离子分级"] = df["阳离子"].apply(lambda x: classify_cation(x) if pd.notna(x) else np.nan)
if "有效磷" in df.columns:
df["有效磷分级"] = df["有效磷"].apply(lambda x: classify_available_p(x) if pd.notna(x) else np.nan)
if "速效钾" in df.columns:
df["速效钾分级"] = df["速效钾"].apply(lambda x: classify_available_k(x) if pd.notna(x) else np.nan)
if "耕层厚度" in df.columns:
df["耕层厚度分级"] = df["耕层厚度"].apply(lambda x: classify_soil_depth(x) if pd.notna(x) else np.nan)
if "土壤容重" in df.columns:
df["土壤容重分级"] = df["土壤容重"].apply(lambda x: classify_bulk_density(x) if pd.notna(x) else np.nan)
if "全氮" in df.columns:
df["全氮分级"] = df["全氮"].apply(lambda x: classify_total_n(x) if pd.notna(x) else np.nan)
if "全磷" in df.columns:
df["全磷分级"] = df["全磷"].apply(lambda x: classify_total_p(x) if pd.notna(x) else np.nan)
if "全钾" in df.columns:
df["全钾分级"] = df["全钾"].apply(lambda x: classify_total_k(x) if pd.notna(x) else np.nan)
if "有效铁" in df.columns:
df["有效铁分级"] = df["有效铁"].apply(lambda x: classify_available_fe(x) if pd.notna(x) else np.nan)
if "有效锌" in df.columns:
df["有效锌分级"] = df["有效锌"].apply(lambda x: classify_available_zn(x) if pd.notna(x) else np.nan)
if "有效锰" in df.columns:
df["有效锰分级"] = df["有效锰"].apply(lambda x: classify_available_mn(x) if pd.notna(x) else np.nan)
if "有效铜" in df.columns:
df["有效铜分级"] = df["有效铜"].apply(lambda x: classify_available_cu(x) if pd.notna(x) else np.nan)
if "有效硼" in df.columns:
df["有效硼分级"] = df["有效硼"].apply(lambda x: classify_available_b(x) if pd.notna(x) else np.nan)
if "有效钼" in df.columns:
df["有效钼分级"] = df["有效钼"].apply(lambda x: classify_available_mo(x) if pd.notna(x) else np.nan)
if "有效硫" in df.columns:
df["有效硫分级"] = df["有效硫"].apply(lambda x: classify_available_s(x) if pd.notna(x) else np.nan)
if "交换性钙" in df.columns:
df["交换性钙分级"] = df["交换性钙"].apply(lambda x: classify_exchangeable_ca(x) if pd.notna(x) else np.nan)
if "交换性镁" in df.columns:
df["交换性镁分级"] = df["交换性镁"].apply(lambda x: classify_exchangeable_mg(x) if pd.notna(x) else np.nan)
if "全硒" in df.columns:
df["全硒分级"] = df["全硒"].apply(lambda x: classify_total_se(x) if pd.notna(x) else np.nan)
if "粉粒" in df.columns:
df["粉粒分级"] = df["粉粒"].apply(lambda x: classify_silt(x) if pd.notna(x) else np.nan)
if "黏粒" in df.columns:
df["黏粒分级"] = df["黏粒"].apply(lambda x: classify_clay(x) if pd.notna(x) else np.nan)
if "砂粒" in df.columns:
df["砂粒分级"] = df["砂粒"].apply(lambda x: classify_sand(x) if pd.notna(x) else np.nan)
if "有效土层厚度" in df.columns:
df["有效土层厚度分级"] = df["有效土层厚度"].apply(lambda x: classify_yxtchd(x) if pd.notna(x) else np.nan)
if "土壤质地" in df.columns:
df["土壤质地分级"] = df["土壤质地"].apply(lambda x: classify_trzd(x) if pd.notna(x) else np.nan)
return df
def main():
"""
主函数:程序入口
用户需根据实际情况修改以下参数
"""
logger = init_logger()
logger.info("="*50)
logger.info("土壤属性栅格面积加权统计程序启动")
logger.info("="*50)
# --------------------------
# 用户配置区域(必须修改!)
# --------------------------
# 1. 矢量图斑文件路径支持Shapefile、GeoPackage等格式
# TODO
VECTOR_PATH = r"D:\工作\三普成果编制\出图数据\北海\三普栅格\DL_ALL.shp" # 示例:"D:/data/土壤图斑.shp"
# 2. 土壤属性栅格文件配置(键:属性名称,值:栅格文件路径)
# 注意:属性名称必须与最终表格列名一致
# TODO
RASTER_FILES = {
"耕层厚度": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\GZCHD.tif", # 示例:"D:/data/耕层厚度.tif"
"土壤容重": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TRRZ.tif", # 示例:"D:/data/土壤容重.tif"
"砂粒": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\SL.tif", # 示例:"D:/data/砂粒含量.tif"
"粉粒": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\FL.tif", # 示例:"D:/data/粉粒含量.tif"
"黏粒": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\NL.tif", # 示例:"D:/data/黏粒含量.tif"
"酸碱度": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\PH.tif", # 示例:"D:/data/pH值.tif"
"阳离子": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\CEC.tif", # 示例:"D:/data/阳离子交换量.tif"
"有机质": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\OM.tif", # 示例:"D:/data/有机质含量.tif"
"全氮": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TN.tif", # 示例:"D:/data/全氮含量.tif"
"全磷": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TP.tif", # 示例:"D:/data/全磷含量.tif"
"全钾": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TK.tif", # 示例:"D:/data/全钾含量.tif"
"有效磷": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AP.tif", # 示例:"D:/data/有效磷含量.tif"
"速效钾": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AK.tif", # 示例:"D:/data/速效钾含量.tif"
# "有效铁": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AFE.tif", # 示例:"D:/data/有效铁含量.tif"
"有效锌": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AZN.tif", # 示例:"D:/data/有效锌含量.tif"
"有效锰": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AMN.tif", # 示例:"D:/data/有效锰含量.tif"
"有效铜": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\ACU.tif", # 示例:"D:/data/有效铜含量.tif"
"有效硼": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AB.tif", # 示例:"D:/data/有效硼含量.tif"
"有效钼": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AMO.tif", # 示例:"D:/data/有效钼含量.tif"
"有效硫": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\AS1.tif", # 示例:"D:/data/有效硫含量.tif"
"交换性钙": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\ECA.tif", # 示例:"D:/data/交换性钙含量.tif"
"交换性镁": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\EMG.tif", # 示例:"D:/data/交换性镁含量.tif"
"全硒": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TSE.tif", # 示例:"D:/data/全硒含量.tif"
"有效土层厚度": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\YXTCHD.tif", # 示例:"D:/data/有效土层厚度.tif"
"土壤质地": r"D:\工作\三普成果编制\出图数据\北海\三普栅格\TRZD.tif", # 示例:"D:/data/土壤质地.tif"
}
# 3. 结果输出路径Excel文件
OUTPUT_PATH = "土壤属性图斑数据_面积加权结果.xlsx" # 示例:"D:/result/结果.xlsx"
# --------------------------
# 程序执行流程(无需修改)
# --------------------------
try:
# 1. 数据验证
if not validate_data(VECTOR_PATH, RASTER_FILES):
logger.error("数据验证失败,程序终止")
return
# 2. 执行面积加权统计
result_df = calculate_area_weighted_stats(VECTOR_PATH, RASTER_FILES, OUTPUT_PATH)
# 3. 显示结果预览
logger.info("\\n结果预览前3行")
print(result_df.head(3).to_string(index=False))
logger.info("\\n" + "="*50)
logger.info("程序执行完成!")
logger.info(f"结果文件:{OUTPUT_PATH}")
logger.info("="*50)
except Exception as e:
logger.error(f"程序执行出错:{str(e)}", exc_info=True)
logger.error("程序异常终止")
if __name__ == "__main__":
# 启动主程序
main()

View File

@@ -7,14 +7,21 @@ from pathlib import Path
# 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
import geo_tools
import app
gdb_path = r"E:\@三普\@临时文件夹\临时数据库.gdb"
gdb_path = r"E:\@三普\@临时文件夹\临时数据库.gdb\马关综合后图斑"
shp_path = r"E:\@三普\@临时文件夹\新建文件夹\靖西二普样点\AK.shp"
shp = r"E:\@三普\@临时文件夹\新建文件夹\靖西二普样点"
gdb = r"E:\@三普\@临时文件夹\新建文件地理数据库.gdb"
# 列出图层
# layers = geo_tools.list_gdb_layers(gdb_path)
# layers = geo_tools.readers.list_gdb_layers(gdb_path)
# print(layers)
# 读取图层
gdf = geo_tools.read_gdb(gdb_path, layer="马关综合后图斑")
gdf = app.readers.read_vector(gdb_path)
print(gdf.crs)
# 获取几何类型
print(gdf.head())

View File

@@ -4,8 +4,8 @@ import pytest
import geopandas as gpd
from shapely.geometry import Point, Polygon
from geo_tools.analysis.spatial_ops import overlay, select_by_location
from geo_tools.analysis.stats import area_weighted_mean, count_by_polygon, summarize_attributes
from app.analysis.spatial_ops import overlay, select_by_location
from app.analysis.stats import area_weighted_mean, count_by_polygon, summarize_attributes
class TestOverlay:

View File

@@ -3,8 +3,8 @@
import pytest
from shapely.geometry import LineString, Point, Polygon
import geo_tools
from geo_tools.core.geometry import (
import app
from app.core.geometry import (
buffer_geometry,
bounding_box,
centroid,

View File

@@ -4,8 +4,8 @@ import pytest
import geopandas as gpd
from pathlib import Path
from geo_tools.io.readers import read_vector, read_gpkg, list_gpkg_layers, read_csv_points
from geo_tools.io.writers import write_vector, write_gpkg, write_csv
from app.io.readers import read_vector, read_gpkg, list_gpkg_layers, read_csv_points
from app.io.writers import write_vector, write_gpkg, write_csv
class TestReadVector:
@@ -61,7 +61,7 @@ class TestWriteReadRoundtrip:
assert "geometry" in df.columns # 存在 WKT 几何列
assert len(df) == len(sample_points_gdf) # 行数一致
# 再用 read_csv_points 以 WKT 模式读回
from geo_tools.io.readers import _read_csv_vector
from app.io.readers import _read_csv_vector
from pathlib import Path
gdf_back = _read_csv_vector(Path(out), wkt_col="geometry")
assert len(gdf_back) == len(sample_points_gdf)

View File

@@ -3,19 +3,31 @@ import os
os.environ["OGR_ORGANIZE_POLYGONS"] = "SKIP"
from pathlib import Path
import geopandas as gpd
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
import geo_tools
from geo_tools.core import projection
from geo_tools.config.project_enum import CRS
import app
from app.core import projection
from app.config.project_enum import CRS
info = projection.get_crs_info(CRS.CGCS2000_6_DEGREE_ZONE_18.value)
print(info)
print(type(CRS.CGCS2000_3_DEGREE_ZONE_27))
input_folder = r"E:\@三普\@二普和测土配方样点处理\云南省"
output_folder = r"E:\@三普\@二普和测土配方样点处理\云南省_投影转换"
# aa = geo_tools.read_vector(r"E:\@三普\@临时文件夹\样点异常值剔除\容县\异常样点数据\AB_outliers.shp")
# projection.reproject_gdf(aa,CRS.CGCS2000_3_DEGREE_ZONE_37).to_file(r"E:\@三普\@临时文件夹\样点异常值剔除\容县\AB_ou.shp")
for root, dirs, files in os.walk(input_folder):
for file in files:
if file.endswith(".shp"):
input_path = os.path.join(root, file)
output_path = os.path.join(output_folder, os.path.relpath(input_path, input_folder))
os.makedirs(os.path.dirname(output_path), exist_ok=True)
gdf = gpd.read_file(input_path)
if gdf.crs is not None:
reproject_gdf = projection.reproject_gdf(gdf, auto_crs=True)
reproject_gdf.to_file(output_path)
print("Reprojected CRS:", reproject_gdf.crs)
else:
print("No CRS information found.")

View File

@@ -4,7 +4,7 @@ import pytest
import geopandas as gpd
from shapely.geometry import Point
from geo_tools.core.vector import (
from app.core.vector import (
add_area_column,
clip_to_extent,
dissolve_by,

260
项目分析报告.md Normal file
View File

@@ -0,0 +1,260 @@
# Geo-Tools 项目分析报告
## 1. 项目概览
Geo-Tools 是一个专业的地理信息数据处理工具库,基于 geopandas、shapely、fiona 等开源地理空间库,提供了统一、简洁的 API 接口,简化地理空间数据处理流程。
- **核心价值**:为地理空间数据处理提供标准化、高效的工具集
- **应用场景**科学研究、GIS 分析、土地利用规划、农业分析等
- **技术特点**:统一数据读写接口、丰富的空间分析功能、坐标系转换支持
## 2. 目录结构
项目采用模块化设计清晰分离核心功能、IO 操作、分析工具和辅助功能。主要目录结构如下:
```
geo_tools/
├── app/ # 主应用目录
│ ├── analysis/ # 空间分析模块
│ │ ├── spatial_ops.py # 空间叠加与邻域分析
│ │ └── stats.py # 空间统计工具
│ ├── config/ # 配置模块
│ ├── core/ # 核心地理处理层
│ │ ├── geometry.py # 几何运算工具
│ │ ├── projection.py # 坐标系转换
│ │ ├── raster.py # 栅格数据处理
│ │ └── vector.py # 矢量数据处理
│ ├── io/ # 数据读写模块
│ │ ├── readers.py # 数据读取
│ │ └── writers.py # 数据写入
│ └── utils/ # 工具模块
│ ├── config.py # 配置工具
│ ├── logger.py # 日志工具
│ └── validators.py # 数据验证
├── data/ # 数据目录
│ └── sample/ # 示例数据
├── scripts/ # 脚本目录
│ └── 其他工具/ # 特定领域工具脚本
├── tests/ # 测试目录
└── pyproject.toml # 项目配置文件
```
## 3. 核心功能模块
### 3.1 数据读写IO
**功能描述**:提供统一的矢量数据读取接口,支持多种格式的数据读写。
**支持格式**
- Shapefile (.shp)
- GeoJSON (.geojson / .json)
- GeoPackage (.gpkg)
- File Geodatabase (.gdb)
- KML / KMZ
- FlatGeobuf (.fgb)
- CSV含 WKT 或 经纬度列)
**核心方法**
- `read_vector()`:统一的矢量数据读取入口
- `read_gdb()`:读取 Esri File Geodatabase
- `read_gpkg()`:读取 GeoPackage 文件
- `read_csv_points()`:从 CSV 读取点数据
- `list_gdb_layers()`:列出 GDB 中所有图层
### 3.2 几何处理Core
**功能描述**:基于 Shapely 提供几何对象的创建、操作和分析功能。
**核心功能**
- **几何有效性**:验证和修复几何对象
- **基础几何运算**:缓冲区、质心、边界框、凸包
- **集合运算**:交集、并集、差集、对称差集
- **空间关系判断**:包含、在内部、相交、距离计算
**核心方法**
- `is_valid_geometry()`:判断几何对象是否有效
- `fix_geometry()`:尝试修复无效几何
- `buffer_geometry()`:对几何对象执行缓冲区运算
- `intersect()/union()/difference()`:几何集合运算
- `contains()/within()/intersects()`:空间关系判断
### 3.3 坐标系转换Core
**功能描述**:基于 pyproj 提供坐标系信息查询和投影转换功能。
**核心功能**
- CRS 信息查询
- 坐标点批量转换
- 矢量数据重投影
- 自动推荐适合的投影 CRS
**核心方法**
- `get_crs_info()`:获取 CRS 的基本信息
- `transform_coordinates()`:批量转换坐标点
- `reproject_gdf()`:将 GeoDataFrame 重投影到目标坐标系
- `suggest_projected_crs()`:根据经纬度自动推荐投影 CRS
### 3.4 空间分析Analysis
**功能描述**:提供空间叠加、邻域分析和空间统计功能。
**核心功能**
- **空间叠加**:缓冲区叠加、图层叠加
- **邻域分析**:最近邻分析
- **位置选择**:按空间关系选择要素
- **空间统计**:面积加权均值、属性汇总、点计数
**核心方法**
- `buffer_and_overlay()`:缓冲区后执行叠置分析
- `nearest_features()`:找到最近的要素
- `select_by_location()`:按位置关系选择要素
- `area_weighted_mean()`:计算面积加权均值
- `summarize_attributes()`:属性统计汇总
- `count_by_polygon()`:统计面要素内的点数量
## 4. 技术架构与依赖
### 4.1 核心依赖
| 依赖库 | 版本要求 | 用途 |
|-------|---------|------|
| geopandas | >=0.14 | 地理空间数据处理核心库 |
| shapely | >=2.0 | 几何对象操作 |
| fiona | >=1.9 | 矢量数据读写 |
| pyproj | >=3.6 | 坐标系转换 |
| pandas | >=2.0 | 数据处理 |
| numpy | >=1.24 | 数值计算 |
| pydantic | >=2.0 | 数据验证 |
| pydantic-settings | >=2.0 | 配置管理 |
| python-dotenv | >=1.0 | 环境变量管理 |
### 4.2 可选依赖
| 依赖组 | 包含库 | 用途 |
|-------|-------|------|
| dev | pytest, black, isort, flake8, mypy | 测试和代码质量工具 |
| notebook | jupyter, matplotlib, contextily, folium | 交互式分析和可视化 |
| raster | rasterio, xarray, rio-cogeo | 栅格数据处理 |
### 4.3 技术架构
项目采用分层架构设计:
1. **底层依赖层**:基于 geopandas、shapely、fiona 等核心库
2. **核心处理层**:提供几何运算、坐标系转换等基础功能
3. **IO 层**:统一数据读写接口,支持多种格式
4. **分析层**:提供空间分析和统计功能
5. **工具层**:提供配置、日志、验证等辅助功能
6. **应用层**:通过脚本和 API 接口供用户使用
## 5. 业务定位与应用场景
### 5.1 业务定位
Geo-Tools 定位为专业的地理信息数据处理工具库,主要服务于以下场景:
- **科学研究**:为地理空间相关研究提供数据处理和分析工具
- **GIS 专业人员**:简化日常工作流程,提高处理效率
- **土地利用规划**:支持空间分析和统计功能
- **农业分析**:如耕作层厚度栅格制作、面积加权均值计算等
### 5.2 应用场景
从项目中的脚本和功能来看,主要应用场景包括:
1. **数据格式转换**:在不同地理数据格式之间转换
2. **空间分析**:缓冲区分析、叠加分析、邻域分析
3. **空间统计**:面积加权均值、属性汇总、点计数
4. **坐标系管理**:不同坐标系之间的转换和管理
5. **农业分析**:如耕作层厚度分析、区域面积统计
### 5.3 典型工作流
1. **数据读取**:从各种格式读取地理空间数据
2. **数据校验**:验证几何有效性和坐标系信息
3. **数据处理**:几何运算、坐标系转换
4. **空间分析**:执行叠加分析、邻域分析等
5. **统计计算**:计算面积加权均值、属性汇总等
6. **结果输出**:将处理结果写入各种格式
## 6. 核心 API/类/函数
### 6.1 数据读写 API
| 函数名 | 功能描述 | 参数说明 | 返回值 |
|-------|---------|---------|-------|
| `read_vector()` | 统一的矢量数据读取入口 | path: 数据路径<br>layer: 图层名<br>crs: 目标坐标系 | GeoDataFrame |
| `read_gdb()` | 读取 Esri File Geodatabase | gdb_path: GDB 路径<br>layer: 图层名 | GeoDataFrame |
| `list_gdb_layers()` | 列出 GDB 中所有图层 | gdb_path: GDB 路径 | list[str] |
| `write_vector()` | 统一的矢量数据写入 | gdf: GeoDataFrame<br>path: 输出路径 | None |
### 6.2 几何处理 API
| 函数名 | 功能描述 | 参数说明 | 返回值 |
|-------|---------|---------|-------|
| `is_valid_geometry()` | 判断几何对象是否有效 | geom: 几何对象 | bool |
| `fix_geometry()` | 尝试修复无效几何 | geom: 几何对象 | BaseGeometry |
| `buffer_geometry()` | 对几何对象执行缓冲区运算 | geom: 几何对象<br>distance: 缓冲距离 | BaseGeometry |
| `intersect()/union()/difference()` | 几何集合运算 | geom_a, geom_b: 几何对象 | BaseGeometry |
### 6.3 坐标系转换 API
| 函数名 | 功能描述 | 参数说明 | 返回值 |
|-------|---------|---------|-------|
| `get_crs_info()` | 获取 CRS 的基本信息 | crs_input: CRS 输入 | dict |
| `transform_coordinates()` | 批量转换坐标点 | xs, ys: 坐标序列<br>source_crs: 源 CRS<br>target_crs: 目标 CRS | (list[float], list[float]) |
| `reproject_gdf()` | 将 GeoDataFrame 重投影 | gdf: GeoDataFrame<br>target_crs: 目标 CRS | GeoDataFrame |
| `suggest_projected_crs()` | 自动推荐投影 CRS | lon, lat: 经纬度 | str |
### 6.4 空间分析 API
| 函数名 | 功能描述 | 参数说明 | 返回值 |
|-------|---------|---------|-------|
| `buffer_and_overlay()` | 缓冲区后执行叠置分析 | source: 源图层<br>distance: 缓冲距离<br>target: 目标图层 | GeoDataFrame |
| `nearest_features()` | 找到最近的要素 | source: 查询图层<br>target: 被查询图层<br>k: 最近邻数量 | GeoDataFrame |
| `select_by_location()` | 按位置关系选择要素 | source: 源图层<br>selector: 选择图层<br>predicate: 空间谓词 | GeoDataFrame |
| `area_weighted_mean()` | 计算面积加权均值 | gdf: GeoDataFrame<br>value_col: 值列名<br>group_col: 分组列名 | pd.Series 或 pd.DataFrame |
## 7. 技术亮点与优势
1. **统一的数据读写接口**:支持多种格式,简化数据输入输出流程
2. **丰富的几何运算功能**:基于 Shapely 提供全面的几何操作
3. **智能的坐标系管理**:自动推荐适合的投影 CRS简化坐标系转换
4. **强大的空间分析能力**:支持缓冲区分析、叠加分析、邻域分析等
5. **灵活的空间统计功能**:面积加权均值、属性汇总、点计数等
6. **良好的错误处理**:提供友好的错误信息,便于调试
7. **模块化设计**:清晰的代码结构,便于维护和扩展
8. **完善的文档**:详细的函数文档和示例代码
## 8. 潜在应用领域
1. **土地利用规划**:分析土地利用类型、面积统计
2. **农业分析**:耕作层厚度分析、农田面积计算
3. **环境监测**:生态区域分析、污染源影响范围评估
4. **城市规划**:基础设施布局、服务范围分析
5. **交通规划**:路线分析、站点服务范围
6. **灾害评估**:灾害影响范围分析、风险评估
7. **市场分析**:商业网点覆盖范围、人口分布分析
## 9. 项目发展建议
1. **增强栅格数据处理能力**:目前栅格处理是可选依赖,可考虑增强其功能
2. **添加更多空间分析算法**:如空间插值、网络分析等
3. **开发可视化工具**:集成更多可视化功能,便于结果展示
4. **提供更多领域特定工具**:针对不同领域开发专用工具
5. **完善测试覆盖**:增加更多测试用例,提高代码质量
6. **优化性能**:对大型数据集的处理性能进行优化
7. **提供更多示例**:增加不同领域的应用示例
## 10. 总结
Geo-Tools 是一个功能强大、设计合理的地理信息数据处理工具库,基于成熟的开源地理空间库,提供了统一、简洁的 API 接口,简化了地理空间数据的处理流程。
该项目的核心价值在于:
- 提供了标准化的地理空间数据处理工具
- 支持多种数据格式的读写
- 提供丰富的空间分析和统计功能
- 简化了坐标系管理和转换
- 为不同领域的地理空间分析提供了基础工具
Geo-Tools 不仅是一个实用的工具库,也是学习地理空间数据处理的良好资源,通过模块化的设计和清晰的文档,为用户提供了一个易于使用和扩展的地理信息处理框架。