refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用
- 将主包名从geo_tools改为app - 更新所有模块中的引用路径 - 迁移并更新测试用例 - 添加项目规则文档 - 保持原有功能不变,仅进行结构调整
This commit is contained in:
63
app/__init__.py
Normal file
63
app/__init__.py
Normal 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
1
app/analysis/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""geo_tools.analysis 包 —— 空间分析层。"""
|
||||
149
app/analysis/spatial_ops.py
Normal file
149
app/analysis/spatial_ops.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
geo_tools.analysis.spatial_ops
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
空间叠加与邻域分析操作。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import geopandas as gpd
|
||||
import pandas as pd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def buffer_and_overlay(
|
||||
source: gpd.GeoDataFrame,
|
||||
distance: float,
|
||||
target: gpd.GeoDataFrame,
|
||||
how: str = "intersection",
|
||||
projected_crs: str | None = None,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""对 source 执行缓冲区后与 target 执行叠置分析。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source:
|
||||
源图层(生成缓冲区)。
|
||||
distance:
|
||||
缓冲距离(与 ``projected_crs`` 单位一致)。
|
||||
target:
|
||||
叠置目标图层。
|
||||
how:
|
||||
叠置类型:``"intersection"``、``"union"``、``"difference"``、``"symmetric_difference"``、``"identity"``。
|
||||
projected_crs:
|
||||
执行缓冲区前先投影到此 CRS(建议使用平面坐标系以保证距离精度);
|
||||
``None`` 则使用 source 的当前 CRS(地理 CRS 下 distance 单位为度)。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
"""
|
||||
original_crs = source.crs
|
||||
|
||||
if projected_crs:
|
||||
source = source.to_crs(projected_crs)
|
||||
target = target.to_crs(projected_crs)
|
||||
|
||||
buffered = source.copy()
|
||||
buffered["geometry"] = buffered.geometry.buffer(distance)
|
||||
logger.debug("缓冲区完成(distance=%.2f),执行叠置分析(how=%s)", distance, how)
|
||||
|
||||
result = gpd.overlay(buffered, target, how=how, keep_geom_type=False)
|
||||
|
||||
if projected_crs:
|
||||
result = result.to_crs(original_crs) # type: ignore
|
||||
|
||||
logger.info("叠置分析完成:%d 条结果", len(result))
|
||||
return result
|
||||
|
||||
|
||||
def overlay(
|
||||
df1: gpd.GeoDataFrame,
|
||||
df2: gpd.GeoDataFrame,
|
||||
how: str = "intersection",
|
||||
keep_geom_type: bool = True,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""封装 geopandas overlay,自动对齐 CRS。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
how:
|
||||
叠置类型:``"intersection"``、``"union"``、``"difference"``、
|
||||
``"symmetric_difference"``、``"identity"``。
|
||||
"""
|
||||
if df1.crs != df2.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
|
||||
|
||||
|
||||
def nearest_features(
|
||||
source: gpd.GeoDataFrame,
|
||||
target: gpd.GeoDataFrame,
|
||||
k: int = 1,
|
||||
max_distance: float | None = None,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""为 source 中每条要素找到 target 中最近的 k 个要素。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source:
|
||||
查询图层。
|
||||
target:
|
||||
被查询图层。
|
||||
k:
|
||||
最近邻数量。
|
||||
max_distance:
|
||||
最大搜索距离(与 CRS 单位一致),``None`` 表示无限制。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
连接了最近 target 属性的 source GDF(可能包含重复行,每行对应一个近邻)。
|
||||
"""
|
||||
if source.crs != target.crs:
|
||||
target = target.to_crs(source.crs) # type: ignore
|
||||
|
||||
result = gpd.sjoin_nearest(
|
||||
source,
|
||||
target,
|
||||
how="left",
|
||||
max_distance=max_distance,
|
||||
distance_col="nearest_distance",
|
||||
lsuffix="left",
|
||||
rsuffix="right",
|
||||
)
|
||||
logger.debug("最近邻分析完成(k=%d):%d 条结果", k, len(result))
|
||||
return result
|
||||
|
||||
|
||||
def select_by_location(
|
||||
source: gpd.GeoDataFrame,
|
||||
selector: gpd.GeoDataFrame,
|
||||
predicate: str = "intersects",
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""按位置关系从 source 中选取要素(等同于 ArcGIS「按位置选择」)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
predicate:
|
||||
空间谓词:``"intersects"``、``"within"``、``"contains"``、``"touches"``。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
满足条件的 source 子集。
|
||||
"""
|
||||
if source.crs != selector.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()
|
||||
logger.debug("按位置选择(%s):%d / %d 条", predicate, len(result), len(source))
|
||||
return result
|
||||
136
app/analysis/stats.py
Normal file
136
app/analysis/stats.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
geo_tools.analysis.stats
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
空间统计工具:属性汇总、面积加权均值、空间自相关指数等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import geopandas as gpd
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def area_weighted_mean(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
value_col: str,
|
||||
group_col: str | None = None,
|
||||
projected_crs: str = "EPSG:3857",
|
||||
) -> pd.Series | pd.DataFrame:
|
||||
"""计算面积加权均值。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame(面要素)。
|
||||
value_col:
|
||||
需要加权平均的属性列名。
|
||||
group_col:
|
||||
分组字段名;若为 ``None`` 则对整个 GDF 计算单一结果。
|
||||
projected_crs:
|
||||
用于计算面积的平面投影 CRS。
|
||||
|
||||
Returns
|
||||
-------
|
||||
pd.Series(无分组)或 pd.DataFrame(有分组)
|
||||
"""
|
||||
gdf = gdf.copy()
|
||||
|
||||
# 计算面积
|
||||
if not gdf.crs or not gdf.crs.is_projected:
|
||||
projected = gdf.to_crs(projected_crs)
|
||||
else:
|
||||
projected = gdf
|
||||
gdf["_area"] = projected.geometry.area
|
||||
|
||||
if group_col is None:
|
||||
total_area = gdf["_area"].sum()
|
||||
result = (gdf[value_col] * gdf["_area"]).sum() / total_area
|
||||
return pd.Series({"area_weighted_mean": result, "total_area": total_area})
|
||||
|
||||
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") # 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()
|
||||
|
||||
|
||||
def summarize_attributes(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
columns: list[str] | None = None,
|
||||
group_col: str | None = None,
|
||||
agg_funcs: list[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""对属性列进行统计汇总(最大、最小、均值、总和等)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame。
|
||||
columns:
|
||||
统计的列名列表;``None`` 则自动选取所有数值列。
|
||||
group_col:
|
||||
分组字段名;``None`` 则对全局统计。
|
||||
agg_funcs:
|
||||
聚合函数列表,默认 ``["count", "mean", "min", "max", "sum", "std"]``。
|
||||
|
||||
Returns
|
||||
-------
|
||||
pd.DataFrame
|
||||
"""
|
||||
if agg_funcs is None:
|
||||
agg_funcs = ["count", "mean", "min", "max", "sum", "std"]
|
||||
|
||||
df = gdf.drop(columns=["geometry"], errors="ignore")
|
||||
|
||||
if columns is None:
|
||||
columns = df.select_dtypes(include="number").columns.tolist()
|
||||
|
||||
if not columns:
|
||||
raise ValueError("未找到数值列,请显式指定 columns 参数。")
|
||||
|
||||
subset = df[columns]
|
||||
|
||||
if group_col is None:
|
||||
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()
|
||||
|
||||
|
||||
def count_by_polygon(
|
||||
points: gpd.GeoDataFrame,
|
||||
polygons: gpd.GeoDataFrame,
|
||||
count_col: str = "point_count",
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""统计每个面要素内的点要素数量(类似 ArcGIS「面要素统计点」)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
points:
|
||||
点图层。
|
||||
polygons:
|
||||
面图层。
|
||||
count_col:
|
||||
新增计数列名。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
含 ``count_col`` 列的 polygons 副本。
|
||||
"""
|
||||
if points.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)
|
||||
|
||||
result = polygons.copy()
|
||||
result = result.join(point_counts)
|
||||
result[count_col] = result[count_col].fillna(0).astype(int)
|
||||
return result
|
||||
5
app/config/__init__.py
Normal file
5
app/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""geo_tools.config 包 —— 全局配置层。"""
|
||||
|
||||
from app.config.settings import GeoToolsSettings, settings
|
||||
|
||||
__all__ = ["GeoToolsSettings", "settings"]
|
||||
43
app/config/project_enum.py
Normal file
43
app/config/project_enum.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
枚举类
|
||||
"""
|
||||
from enum import Enum, unique
|
||||
|
||||
# 坐标系枚举
|
||||
@unique
|
||||
class CRS(Enum):
|
||||
WGS84 = "EPSG:4326"
|
||||
CGCS2000 = "EPSG:4490"
|
||||
WEB_MERCATOR = "EPSG:3857"
|
||||
CGCS2000_3_DEGREE_ZONE_25 = "EPSG:4513"
|
||||
CGCS2000_3_DEGREE_ZONE_26 = "EPSG:4514"
|
||||
CGCS2000_3_DEGREE_ZONE_27 = "EPSG:4515"
|
||||
CGCS2000_3_DEGREE_ZONE_28 = "EPSG:4516"
|
||||
CGCS2000_3_DEGREE_ZONE_29 = "EPSG:4517"
|
||||
CGCS2000_3_DEGREE_ZONE_30 = "EPSG:4518"
|
||||
CGCS2000_3_DEGREE_ZONE_31 = "EPSG:4519"
|
||||
CGCS2000_3_DEGREE_ZONE_32 = "EPSG:4520"
|
||||
CGCS2000_3_DEGREE_ZONE_33 = "EPSG:4521"
|
||||
CGCS2000_3_DEGREE_ZONE_34 = "EPSG:4522"
|
||||
CGCS2000_3_DEGREE_ZONE_35 = "EPSG:4523"
|
||||
CGCS2000_3_DEGREE_ZONE_36 = "EPSG:4524"
|
||||
CGCS2000_3_DEGREE_ZONE_37 = "EPSG:4525"
|
||||
CGCS2000_3_DEGREE_ZONE_38 = "EPSG:4526"
|
||||
CGCS2000_3_DEGREE_ZONE_39 = "EPSG:4527"
|
||||
CGCS2000_3_DEGREE_ZONE_40 = "EPSG:4528"
|
||||
CGCS2000_3_DEGREE_ZONE_41 = "EPSG:4529"
|
||||
CGCS2000_3_DEGREE_ZONE_42 = "EPSG:4530"
|
||||
CGCS2000_3_DEGREE_ZONE_43 = "EPSG:4531"
|
||||
CGCS2000_3_DEGREE_ZONE_44 = "EPSG:4532"
|
||||
CGCS2000_3_DEGREE_ZONE_45 = "EPSG:4533"
|
||||
CGCS2000_6_DEGREE_ZONE_13 = "EPSG:4491"
|
||||
CGCS2000_6_DEGREE_ZONE_14 = "EPSG:4492"
|
||||
CGCS2000_6_DEGREE_ZONE_15 = "EPSG:4493"
|
||||
CGCS2000_6_DEGREE_ZONE_16 = "EPSG:4494"
|
||||
CGCS2000_6_DEGREE_ZONE_17 = "EPSG:4495"
|
||||
CGCS2000_6_DEGREE_ZONE_18 = "EPSG:4496"
|
||||
CGCS2000_6_DEGREE_ZONE_19 = "EPSG:4497"
|
||||
CGCS2000_6_DEGREE_ZONE_20 = "EPSG:4498"
|
||||
CGCS2000_6_DEGREE_ZONE_21 = "EPSG:4499"
|
||||
CGCS2000_6_DEGREE_ZONE_22 = "EPSG:4500"
|
||||
CGCS2000_6_DEGREE_ZONE_23 = "EPSG:4501"
|
||||
98
app/config/settings.py
Normal file
98
app/config/settings.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
geo_tools.config.settings
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
全局配置,通过 Pydantic BaseSettings 从环境变量 / .env 文件加载。
|
||||
|
||||
使用方式
|
||||
--------
|
||||
>>> from geo_tools.config.settings import settings
|
||||
>>> print(settings.default_crs)
|
||||
'EPSG:4326'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class GeoToolsSettings(BaseSettings):
|
||||
"""全局运行时配置。
|
||||
|
||||
所有字段均可通过前缀为 ``GEO_TOOLS_`` 的环境变量覆盖,
|
||||
或在项目根目录创建 ``.env`` 文件(参考 ``.env.example``)。
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="GEO_TOOLS_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── 目录配置 ──────────────────────────────────────────────
|
||||
output_dir: Path = Path("output")
|
||||
"""处理结果输出目录(相对路径相对于当前工作目录)。"""
|
||||
|
||||
log_dir: Path = Path("logs")
|
||||
"""日志文件目录。"""
|
||||
|
||||
# ── 坐标系配置 ────────────────────────────────────────────
|
||||
default_crs: str = "EPSG:4490"
|
||||
"""默认地理坐标系,使用 EPSG 代码字符串。
|
||||
常见值:
|
||||
- ``EPSG:4326`` — WGS84 经纬度
|
||||
- ``EPSG:4490`` — CGCS2000 经纬度(中国国家标准)
|
||||
- ``EPSG:3857`` — Web Mercator
|
||||
"""
|
||||
|
||||
# ── 日志配置 ──────────────────────────────────────────────
|
||||
log_level: str = "ERROR"
|
||||
"""日志等级:DEBUG / INFO / WARNING / ERROR / CRITICAL。"""
|
||||
|
||||
log_to_file: bool = True
|
||||
"""是否同时将日志写出到文件。"""
|
||||
|
||||
# ── 性能配置 ──────────────────────────────────────────────
|
||||
max_workers: int = 0
|
||||
"""并行处理最大 CPU 核数,0 表示自动检测(使用 CPU 核数 - 1)。"""
|
||||
|
||||
# ── 校验器 ────────────────────────────────────────────────
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
upper = v.upper()
|
||||
if upper not in allowed:
|
||||
raise ValueError(f"log_level 必须是 {allowed} 之一,收到:{v!r}")
|
||||
return upper
|
||||
|
||||
@field_validator("default_crs")
|
||||
@classmethod
|
||||
def validate_crs(cls, v: str) -> str:
|
||||
# 简单前缀校验,完整校验在 validators.py 中通过 pyproj 完成
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("default_crs 不能为空")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_max_workers(self) -> "GeoToolsSettings":
|
||||
if self.max_workers <= 0:
|
||||
cpu_count = multiprocessing.cpu_count()
|
||||
self.max_workers = max(1, cpu_count - 1)
|
||||
return self
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""创建输出和日志目录(幂等)。"""
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# 模块级单例,项目内统一引用
|
||||
settings = GeoToolsSettings()
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""geo_tools.core 包 —— 核心地理处理层。"""
|
||||
469
app/core/geometry.py
Normal file
469
app/core/geometry.py
Normal 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_valid(Shapely >= 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')
|
||||
218
app/core/projection.py
Normal file
218
app/core/projection.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
geo_tools.core.projection
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
坐标系与投影转换工具,基于 pyproj。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Sequence
|
||||
from pyproj import CRS, Transformer
|
||||
import geopandas as gpd
|
||||
|
||||
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``、``datum``。
|
||||
"""
|
||||
crs = CRS.from_user_input(crs_input)
|
||||
return {
|
||||
"name": crs.name,
|
||||
"epsg": crs.to_epsg(),
|
||||
"unit": str(crs.axis_info[0].unit_name) if crs.axis_info else None,
|
||||
"is_geographic": crs.is_geographic,
|
||||
"is_projected": crs.is_projected,
|
||||
"datum": crs.datum.name if crs.datum else None,
|
||||
}
|
||||
|
||||
|
||||
def crs_to_epsg(crs_input: str | int) -> int | None:
|
||||
"""尝试将 CRS 转为 EPSG 整数编号,无法识别时返回 None。"""
|
||||
try:
|
||||
return CRS.from_user_input(crs_input).to_epsg()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def transform_coordinates(
|
||||
xs: Sequence[float],
|
||||
ys: Sequence[float],
|
||||
source_crs: str | int,
|
||||
target_crs: str | int,
|
||||
*,
|
||||
always_xy: bool = True,
|
||||
) -> tuple[list[float], list[float]]:
|
||||
"""批量转换坐标点。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
xs:
|
||||
X 坐标序列(地理 CRS 时为经度)。
|
||||
ys:
|
||||
Y 坐标序列(地理 CRS 时为纬度)。
|
||||
source_crs:
|
||||
源 CRS。
|
||||
target_crs:
|
||||
目标 CRS。
|
||||
always_xy:
|
||||
强制以 (X, Y) 顺序输入输出(推荐保持 True)。
|
||||
|
||||
Returns
|
||||
-------
|
||||
(list[float], list[float])
|
||||
转换后的 (xs, ys)。
|
||||
"""
|
||||
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=always_xy)
|
||||
result_xs, result_ys = transformer.transform(list(xs), list(ys))
|
||||
return list(result_xs), list(result_ys)
|
||||
|
||||
|
||||
def transform_point(
|
||||
x: float,
|
||||
y: float,
|
||||
source_crs: str | int,
|
||||
target_crs: str | int,
|
||||
*,
|
||||
always_xy: bool = True,
|
||||
) -> tuple[float, float]:
|
||||
"""转换单个坐标点。"""
|
||||
xs, ys = transform_coordinates([x], [y], source_crs, target_crs, always_xy=always_xy)
|
||||
return xs[0], ys[0]
|
||||
|
||||
|
||||
def suggest_projected_crs(lon: float, lat: float, use_3degree: bool = True) -> str:
|
||||
"""根据经纬度范围自动推荐适合面积/距离计算的投影 CRS(CGCS2000 高斯-克吕格 带号)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lon:
|
||||
中心经度(CGCS2000)。
|
||||
lat:
|
||||
中心纬度(CGCS2000)。
|
||||
use_3degree:
|
||||
True 表示3度分带,False 表示6度分带。
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
EPSG 代码字符串,如 ``"EPSG:32650"``(CGCS2000 高斯-克吕格 带号)。
|
||||
"""
|
||||
if use_3degree:
|
||||
# 3度分带计算:中央经线 = 3° * n
|
||||
central_meridian = round(lon / 3) * 3
|
||||
zone_number = int(central_meridian / 3)
|
||||
|
||||
# CGCS2000 3度带投影定义
|
||||
# 从第25带到45带(75°E-135°E)
|
||||
if 75 <= central_meridian <= 135:
|
||||
epsg = 4513 + zone_number - 25
|
||||
else:
|
||||
# 默认使用36带(108°E)
|
||||
epsg = 4524
|
||||
logger.warning("经度范围超出3度带范围,默认使用36带(108°E)")
|
||||
else:
|
||||
# 6度分带计算:中央经线 = 6° * n - 3°
|
||||
central_meridian = round((lon + 3) / 6) * 6 - 3
|
||||
zone_number = int((central_meridian + 3) / 6)
|
||||
|
||||
# CGCS2000 6度带投影定义
|
||||
# 从第13带到23带(75°E-135°E)
|
||||
if 75 <= central_meridian <= 135:
|
||||
epsg = 4491 + zone_number - 13
|
||||
else:
|
||||
# 默认使用18带(105°E)
|
||||
epsg = 4496
|
||||
logger.warning("经度范围超出6度带范围,默认使用18带(105°E)")
|
||||
|
||||
logger.debug("建议投影 CRS:EPSG:%d(lon=%.2f, lat=%.2f)", epsg, lon, lat)
|
||||
return f"EPSG:{epsg}"
|
||||
|
||||
|
||||
def reproject_gdf(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
target_crs: str | int | None = None,
|
||||
*,
|
||||
auto_crs: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""将 GeoDataFrame(要素类)重投影到目标坐标系。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame,必须已定义 CRS。
|
||||
target_crs:
|
||||
目标 CRS,如 ``"EPSG:4326"``、``"EPSG:4490"`` 或整数 ``4523``。
|
||||
与 ``auto_crs=True`` 二选一。
|
||||
auto_crs:
|
||||
为 ``True`` 时忽略 ``target_crs``,根据数据中心点自动推荐
|
||||
CGCS2000 高斯-克吕格 带号(默认使用 3度分带)。
|
||||
verbose:
|
||||
为 ``True`` 时在日志中打印投影前后的 CRS 信息。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
重投影后的新 GeoDataFrame(原始对象不变)。
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
``gdf`` 未定义 CRS,或 ``target_crs`` 与 ``auto_crs`` 均未指定。
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> # 指定目标 CRS
|
||||
>>> gdf_proj = reproject_gdf(gdf, "EPSG:4490")
|
||||
|
||||
>>> # 自动推荐 CGCS2000 高斯-克吕格 带号 默认使用 3度分带
|
||||
>>> gdf_utm = reproject_gdf(gdf, auto_crs=True)
|
||||
|
||||
>>> # 配合 GDB 读取
|
||||
>>> gdf = geo_tools.readers.read_vector("data.gdb/图斑")
|
||||
>>> gdf_proj = reproject_gdf(gdf, "EPSG:4326")
|
||||
"""
|
||||
|
||||
|
||||
if gdf.crs is None:
|
||||
raise ValueError("GeoDataFrame 未定义 CRS,请先调用 set_crs() 设置坐标系。")
|
||||
|
||||
if auto_crs:
|
||||
# 先统一到地理坐标系,再取中心点推荐 CGCS2000 高斯-克吕格 带号
|
||||
if gdf.crs.is_projected:
|
||||
center = gdf.to_crs("EPSG:4490").geometry.union_all().centroid
|
||||
else:
|
||||
center = gdf.geometry.union_all().centroid
|
||||
target_crs = suggest_projected_crs(center.x, center.y)
|
||||
logger.info("auto_crs:自动推荐投影 CRS = %s", target_crs)
|
||||
|
||||
if target_crs is None:
|
||||
raise ValueError("请指定 target_crs,或设置 auto_crs=True 自动推荐投影。")
|
||||
|
||||
src_crs_str = gdf.crs.to_string()
|
||||
result = gdf.to_crs(target_crs)
|
||||
|
||||
if verbose:
|
||||
tgt_info = get_crs_info(target_crs)
|
||||
logger.info(
|
||||
"要素类重投影完成:%s → %s(%s,单位:%s,要素数:%d)",
|
||||
src_crs_str,
|
||||
tgt_info.get("epsg") or target_crs,
|
||||
tgt_info.get("name"),
|
||||
tgt_info.get("unit"),
|
||||
len(result),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
103
app/core/raster.py
Normal file
103
app/core/raster.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
geo_tools.core.raster
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
栅格数据处理预留接口。
|
||||
|
||||
当前提供基于 rasterio 的核心读写骨架;
|
||||
如需完整栅格分析功能,请安装可选依赖:
|
||||
``pip install geo-tools[raster]``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _require_rasterio() -> Any:
|
||||
"""检查 rasterio 是否可用,不可用时给出明确提示。"""
|
||||
try:
|
||||
import rasterio
|
||||
return rasterio
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"栅格处理功能需要 rasterio。\n"
|
||||
"请执行:pip install geo-tools[raster] 或 pip install rasterio"
|
||||
) from exc
|
||||
|
||||
|
||||
def read_raster(
|
||||
path: str | Path,
|
||||
band: int = 1,
|
||||
) -> tuple["np.ndarray", dict[str, Any]]:
|
||||
"""读取栅格文件(单波段)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
GeoTIFF 或其他 GDAL 支持格式的路径。
|
||||
band:
|
||||
波段号,1-indexed。
|
||||
|
||||
Returns
|
||||
-------
|
||||
(np.ndarray, dict)
|
||||
栅格数组 和 rasterio 元数据字典(``meta``)。
|
||||
"""
|
||||
rasterio = _require_rasterio()
|
||||
with rasterio.open(str(path)) as src:
|
||||
data = src.read(band)
|
||||
meta = src.meta.copy()
|
||||
return data, meta
|
||||
|
||||
|
||||
def write_raster(
|
||||
data: "np.ndarray",
|
||||
path: str | Path,
|
||||
meta: dict[str, Any],
|
||||
band: int = 1,
|
||||
) -> Path:
|
||||
"""将 numpy 数组写出为 GeoTIFF。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
2D numpy 数组(单波段)。
|
||||
path:
|
||||
输出路径(.tif)。
|
||||
meta:
|
||||
rasterio 元数据字典(从 ``read_raster`` 获取或自行构造)。
|
||||
band:
|
||||
写入的波段号,1-indexed。
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
"""
|
||||
rasterio = _require_rasterio()
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta.update({"count": 1, "dtype": str(data.dtype)})
|
||||
with rasterio.open(str(path), "w", **meta) as dst:
|
||||
dst.write(data, band)
|
||||
return path
|
||||
|
||||
|
||||
def get_raster_info(path: str | Path) -> dict[str, Any]:
|
||||
"""获取栅格文件的基本元信息(行列数、波段数、CRS、分辨率等)。"""
|
||||
rasterio = _require_rasterio()
|
||||
with rasterio.open(str(path)) as src:
|
||||
return {
|
||||
"width": src.width,
|
||||
"height": src.height,
|
||||
"count": src.count,
|
||||
"dtype": src.dtypes[0],
|
||||
"crs": str(src.crs),
|
||||
"transform": src.transform,
|
||||
"bounds": src.bounds,
|
||||
"nodata": src.nodata,
|
||||
"res": src.res,
|
||||
}
|
||||
215
app/core/vector.py
Normal file
215
app/core/vector.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
geo_tools.core.vector
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
基于 geopandas 的矢量要素处理函数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
import geopandas as gpd
|
||||
import pandas as pd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.validators import validate_geometry
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def reproject(gdf: gpd.GeoDataFrame, target_crs: str | int) -> gpd.GeoDataFrame:
|
||||
"""将 GeoDataFrame 重投影到目标坐标系。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame,必须已定义 CRS。
|
||||
target_crs:
|
||||
目标 CRS,如 ``"EPSG:3857"`` 或 ``4490``。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
重投影后的 GeoDataFrame(新对象,原始不变)。
|
||||
"""
|
||||
if gdf.crs is None:
|
||||
raise ValueError("GeoDataFrame 未定义 CRS,请先设置坐标系。")
|
||||
if gdf.crs.to_epsg() == (target_crs if isinstance(target_crs, int) else None):
|
||||
return gdf # 已经是目标 CRS,跳过
|
||||
logger.debug("重投影:%s → %s(共 %d 条)", gdf.crs, target_crs, len(gdf))
|
||||
return gdf.to_crs(target_crs)
|
||||
|
||||
|
||||
def set_crs(gdf: gpd.GeoDataFrame, crs: str | int, *, overwrite: bool = False) -> gpd.GeoDataFrame:
|
||||
"""为没有 CRS 的 GeoDataFrame 设置坐标系(不重投影)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入数据。
|
||||
crs:
|
||||
目标 CRS。
|
||||
overwrite:
|
||||
若为 ``True``,即使已有 CRS 也强制覆盖(危险操作,请确认坐标系正确)。
|
||||
"""
|
||||
if gdf.crs is not None and not overwrite:
|
||||
raise ValueError(
|
||||
f"GeoDataFrame 已有 CRS:{gdf.crs}。若要覆盖,请传入 overwrite=True。"
|
||||
)
|
||||
return gdf.set_crs(crs, allow_override=overwrite)
|
||||
|
||||
|
||||
def clip_to_extent(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
bbox: tuple[float, float, float, float] | gpd.GeoDataFrame,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""按矩形范围或另一个 GeoDataFrame 裁切要素。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
待裁切的 GeoDataFrame。
|
||||
bbox:
|
||||
矩形范围 ``(minx, miny, maxx, maxy)`` 或用于裁切的 GeoDataFrame / GeoSeries。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
"""
|
||||
if isinstance(bbox, tuple):
|
||||
from shapely.geometry import box as shapely_box
|
||||
mask = shapely_box(*bbox)
|
||||
result = gdf.clip(mask)
|
||||
else:
|
||||
if bbox.crs != gdf.crs:
|
||||
bbox = bbox.to_crs(gdf.crs) # type: ignore
|
||||
result = gdf.clip(bbox)
|
||||
|
||||
logger.debug("裁切完成:%d → %d 条", len(gdf), len(result))
|
||||
return result
|
||||
|
||||
|
||||
def dissolve_by(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
by: str | list[str],
|
||||
aggfunc: str | dict[str, Any] = "first",
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""按属性字段融合(Dissolve)几何要素。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame。
|
||||
by:
|
||||
融合字段名或字段列表。
|
||||
aggfunc:
|
||||
属性聚合函数,参考 ``pd.DataFrame.groupby``。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
融合后的 GeoDataFrame,索引为 ``by`` 字段。
|
||||
"""
|
||||
logger.debug("按字段 %r 融合要素(%d 条 → ?)", by, len(gdf))
|
||||
result = gdf.dissolve(by=by, aggfunc=aggfunc).reset_index()
|
||||
logger.debug("融合完成:%d 条", len(result))
|
||||
return result
|
||||
|
||||
|
||||
def explode_multipart(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
||||
"""将多部分几何(MultiPolygon 等)拆分为单部分要素。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame。
|
||||
|
||||
Returns
|
||||
-------
|
||||
gpd.GeoDataFrame
|
||||
拆分后索引已 reset。
|
||||
"""
|
||||
result = gdf.explode(index_parts=False).reset_index(drop=True)
|
||||
logger.debug("多部分拆分:%d → %d 条", len(gdf), len(result))
|
||||
return result
|
||||
|
||||
|
||||
def drop_invalid_geometries(gdf: gpd.GeoDataFrame, *, fix: bool = False) -> gpd.GeoDataFrame:
|
||||
"""删除或修复无效几何。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame。
|
||||
fix:
|
||||
若为 ``True``,尝试通过 ``buffer(0)`` 修复无效几何而非删除。
|
||||
"""
|
||||
stats = validate_geometry(gdf)
|
||||
if stats["invalid"] == 0 and stats["null"] == 0:
|
||||
return gdf
|
||||
|
||||
if fix:
|
||||
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) # type: ignore
|
||||
logger.info("已修复 %d 个无效几何", stats["invalid"])
|
||||
else:
|
||||
before = len(gdf)
|
||||
gdf = gdf[gdf.geometry.is_valid & gdf.geometry.notna()].copy()
|
||||
logger.info("已删除 %d 个无效/空几何", before - len(gdf))
|
||||
return gdf
|
||||
|
||||
|
||||
def spatial_join(
|
||||
left: gpd.GeoDataFrame,
|
||||
right: gpd.GeoDataFrame,
|
||||
how: Literal["left", "right", "inner"] = "left",
|
||||
predicate: str = "intersects",
|
||||
**kwargs: Any,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""空间连接(封装 geopandas.sjoin)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
left:
|
||||
左侧 GeoDataFrame。
|
||||
right:
|
||||
右侧 GeoDataFrame。
|
||||
how:
|
||||
连接方式:``"left"``、``"right"``、``"inner"``。
|
||||
predicate:
|
||||
空间谓词:``"intersects"``、``"contains"``、``"within"``、``"touches"``。
|
||||
"""
|
||||
if left.crs != right.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
|
||||
|
||||
|
||||
def add_area_column(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
col_name: str = "area_m2",
|
||||
projected_crs: str = "EPSG:3857",
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""添加面积列(单位:平方米)。
|
||||
|
||||
将数据临时投影到 ``projected_crs``(笛卡尔投影)计算面积后回填到原 GDF。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
输入 GeoDataFrame(面要素)。
|
||||
col_name:
|
||||
新列名。
|
||||
projected_crs:
|
||||
用于面积计算的投影 CRS(需为平面坐标系)。
|
||||
"""
|
||||
gdf = gdf.copy()
|
||||
if gdf.crs is None or not gdf.crs.is_projected:
|
||||
projected = gdf.to_crs(projected_crs)
|
||||
else:
|
||||
projected = gdf
|
||||
gdf[col_name] = projected.geometry.area
|
||||
return gdf
|
||||
1
app/io/__init__.py
Normal file
1
app/io/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""geo_tools.io 包 —— 数据读写层。"""
|
||||
374
app/io/readers.py
Normal file
374
app/io/readers.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
geo_tools.io.readers
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
统一的矢量数据读取接口,支持:
|
||||
- Shapefile (.shp)
|
||||
- GeoJSON (.geojson / .json)
|
||||
- GeoPackage (.gpkg)
|
||||
- File Geodatabase (.gdb) ← 通过 fiona OpenFileGDB / ESRI FileGDB 驱动
|
||||
- KML / KMZ
|
||||
- FlatGeobuf (.fgb)
|
||||
- CSV(含 WKT 或 经纬度列)
|
||||
|
||||
所有函数均返回 ``geopandas.GeoDataFrame``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator
|
||||
|
||||
import fiona
|
||||
import geopandas as gpd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.validators import validate_vector_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ── 主入口 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_vector(
|
||||
path: str | Path,
|
||||
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,
|
||||
):
|
||||
"""统一的矢量数据读取入口,自动识别文件格式。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
数据路径。支持文件或目录(FileGDB ``*.gdb``)。
|
||||
layer:
|
||||
图层名或索引(多图层格式如 GPKG、GDB 必填;单图层可省略)。
|
||||
crs:
|
||||
读取后强制重投影到目标 CRS(不传则保留原始 CRS)。
|
||||
encoding:
|
||||
属性表编码,Shapefile 中文路径常需指定 ``"gbk"``。
|
||||
chunk_size:
|
||||
分块大小,默认 None(一次性读取全部数据)。
|
||||
【警告】:若不设置 chunk_size,大文件可能会占用大量内存。
|
||||
rows:
|
||||
限制读取的行数,默认 None(读取全部数据)。
|
||||
用于快速预览数据,避免读取大文件的全部内容。
|
||||
**kwargs:
|
||||
透传给 ``geopandas.read_file`` 的额外参数。
|
||||
|
||||
Returns
|
||||
-------
|
||||
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 通用读取
|
||||
read_kwargs: dict[str, Any] = {"encoding": encoding, **kwargs}
|
||||
if layer is not None:
|
||||
read_kwargs["layer"] = layer
|
||||
|
||||
# 分块读取模式
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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 专用 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_gdb(
|
||||
gdb_path: str | Path,
|
||||
layer: str | int | None = None,
|
||||
crs: str | int | None = None,
|
||||
encoding: str = "utf-8",
|
||||
**kwargs: Any,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""读取 Esri File Geodatabase(.gdb)中的图层。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdb_path:
|
||||
``.gdb`` 目录路径。
|
||||
layer:
|
||||
图层名称或索引。若不指定且 GDB 仅有一个图层,则自动选取第一层;
|
||||
多图层时必须指定。
|
||||
crs:
|
||||
读取后目标 CRS,``None`` 则保留原始坐标系。
|
||||
encoding:
|
||||
属性表字段编码。
|
||||
"""
|
||||
gdb_path = Path(gdb_path)
|
||||
if not gdb_path.exists():
|
||||
raise FileNotFoundError(f"GDB 路径不存在:{gdb_path}")
|
||||
if gdb_path.suffix.lower() != ".gdb":
|
||||
raise ValueError(f"期望 .gdb 目录,收到:{gdb_path.suffix!r}")
|
||||
|
||||
available_layers = list_gdb_layers(gdb_path)
|
||||
logger.debug("GDB 可用图层:%s", available_layers)
|
||||
|
||||
if layer is None:
|
||||
if not available_layers:
|
||||
raise ValueError(f"GDB 中没有可用图层:{gdb_path}")
|
||||
layer = available_layers[0]
|
||||
if len(available_layers) > 1:
|
||||
logger.warning(
|
||||
"GDB 包含多个图层 %s,默认读取第一层 %r。请显式传入 layer=... 以指定图层。",
|
||||
available_layers,
|
||||
layer,
|
||||
)
|
||||
|
||||
logger.info("读取 GDB 图层:%s >> %s", gdb_path.name, layer)
|
||||
gdf = gpd.read_file(str(gdb_path), layer=layer, encoding=encoding, **kwargs)
|
||||
|
||||
if crs is not None:
|
||||
gdf = gdf.to_crs(crs) # type: ignore
|
||||
|
||||
logger.info("GDB 读取完成:%d 条要素,CRS=%s", len(gdf), gdf.crs)
|
||||
return gdf # type: ignore
|
||||
|
||||
|
||||
def list_gdb_layers(gdb_path: str | Path) -> list[str]:
|
||||
"""列出 FileGDB 中所有图层名称。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdb_path:
|
||||
``.gdb`` 目录路径。
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
图层名称列表。
|
||||
"""
|
||||
gdb_path = Path(gdb_path)
|
||||
try:
|
||||
return fiona.listlayers(str(gdb_path))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"无法列出 GDB 图层:{gdb_path}。\n"
|
||||
"请确认 fiona 已安装 OpenFileGDB 驱动(通常随 conda/wheels 自带)。\n"
|
||||
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 专用 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_gpkg(
|
||||
gpkg_path: str | Path,
|
||||
layer: str | int | None = None,
|
||||
crs: str | int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""读取 GeoPackage (.gpkg) 文件。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gpkg_path:
|
||||
``.gpkg`` 文件路径。
|
||||
layer:
|
||||
图层名或索引;多图层时必须指定。
|
||||
"""
|
||||
gpkg_path = Path(gpkg_path)
|
||||
if not gpkg_path.exists():
|
||||
raise FileNotFoundError(f"GPKG 文件不存在:{gpkg_path}")
|
||||
|
||||
available = fiona.listlayers(str(gpkg_path))
|
||||
if layer is None:
|
||||
if not available:
|
||||
raise ValueError(f"GPKG 中没有可用图层:{gpkg_path}")
|
||||
layer = available[0]
|
||||
if len(available) > 1:
|
||||
logger.warning(
|
||||
"GPKG 包含多个图层 %s,默认读取第一层 %r。", available, layer
|
||||
)
|
||||
|
||||
gdf = gpd.read_file(str(gpkg_path), layer=layer, **kwargs)
|
||||
if crs is not None:
|
||||
gdf = gdf.to_crs(crs) # type: ignore
|
||||
return gdf # type: ignore
|
||||
|
||||
|
||||
def list_gpkg_layers(gpkg_path: str | Path) -> list[str]:
|
||||
"""列出 GeoPackage 中所有图层名称。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gpkg_path:
|
||||
GeoPackage 文件路径,可以是字符串或 Path 对象。
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
图层名称列表。
|
||||
"""
|
||||
return fiona.listlayers(str(gpkg_path))
|
||||
|
||||
|
||||
# ── CSV 矢量读取 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _read_csv_vector(
|
||||
path: Path,
|
||||
lon_col: str = "longitude",
|
||||
lat_col: str = "latitude",
|
||||
wkt_col: str | None = None,
|
||||
crs: str | int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""从 CSV 读取空间数据,支持 WKT 列或经纬度列。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
CSV 文件路径。
|
||||
lon_col:
|
||||
经度列名(WKT 模式时忽略)。
|
||||
lat_col:
|
||||
纬度列名(WKT 模式时忽略)。
|
||||
wkt_col:
|
||||
WKT 几何列名;若指定则优先使用。
|
||||
"""
|
||||
import pandas as pd
|
||||
from shapely import wkt as shapely_wkt
|
||||
|
||||
df = pd.read_csv(path, **kwargs)
|
||||
|
||||
if wkt_col and wkt_col in df.columns:
|
||||
geometry = df[wkt_col].apply(shapely_wkt.loads)
|
||||
elif lon_col in df.columns and lat_col in df.columns:
|
||||
from shapely.geometry import Point
|
||||
geometry = [Point(lon, lat) for lon, lat in zip(df[lon_col], df[lat_col])]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"CSV 中未找到 WKT 列 {wkt_col!r} 或经纬度列 ({lon_col!r}, {lat_col!r})。"
|
||||
)
|
||||
|
||||
gdf = gpd.GeoDataFrame(df, geometry=geometry, crs=crs or "EPSG:4326")
|
||||
return gdf
|
||||
|
||||
|
||||
def read_csv_points(
|
||||
path: str | Path,
|
||||
lon_col: str = "longitude",
|
||||
lat_col: str = "latitude",
|
||||
crs: str | int = "EPSG:4326",
|
||||
**kwargs: Any,
|
||||
) -> gpd.GeoDataFrame:
|
||||
"""从含经纬度列的 CSV 文件创建点 GeoDataFrame(公开接口)。"""
|
||||
path = Path(path)
|
||||
return _read_csv_vector(path, lon_col=lon_col, lat_col=lat_col, crs=crs, **kwargs)
|
||||
207
app/io/writers.py
Normal file
207
app/io/writers.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
geo_tools.io.writers
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
统一的矢量数据写出接口,支持:
|
||||
- Shapefile (.shp)
|
||||
- GeoJSON (.geojson / .json)
|
||||
- GeoPackage (.gpkg) ← 支持追加图层
|
||||
- File Geodatabase (.gdb) ← 通过 fiona OpenFileGDB 驱动
|
||||
- FlatGeobuf (.fgb)
|
||||
- CSV(含 WKT 列)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import geopandas as gpd
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ── 主入口 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_vector(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
path: str | Path,
|
||||
layer: str | None = None,
|
||||
driver: str | None = None,
|
||||
encoding: str = "utf-8",
|
||||
mode: Literal["w", "a"] = "w",
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
"""统一的矢量数据写出入口,自动识别格式。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
待写出的 GeoDataFrame。
|
||||
path:
|
||||
目标路径(文件或 `.gdb` 目录)。
|
||||
layer:
|
||||
图层名(GPKG、GDB 多图层格式时使用)。
|
||||
driver:
|
||||
强制指定 fiona 驱动名(通常不需要,自动推断)。
|
||||
encoding:
|
||||
字段编码,Shapefile 导出中文时常需 ``"gbk"``。
|
||||
mode:
|
||||
``"w"`` 覆盖写出,``"a"`` 追加图层(GPKG / GDB 支持)。
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
实际写出的路径。
|
||||
"""
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == ".csv":
|
||||
return _write_csv_vector(gdf, path)
|
||||
|
||||
# 自动推断驱动
|
||||
if driver is None:
|
||||
driver = _infer_driver(path)
|
||||
|
||||
# 确保父目录存在(GDB 是目录,其父目录要存在)
|
||||
if suffix == ".gdb":
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
write_kwargs: dict[str, Any] = {
|
||||
"driver": driver,
|
||||
"encoding": encoding,
|
||||
"mode": mode,
|
||||
**kwargs,
|
||||
}
|
||||
if layer is not None:
|
||||
write_kwargs["layer"] = layer
|
||||
|
||||
logger.info(
|
||||
"写出矢量数据:%s(驱动:%s,图层:%s,模式:%s,要素数:%d)",
|
||||
path, driver, layer, mode, len(gdf),
|
||||
)
|
||||
gdf.to_file(str(path), **write_kwargs)
|
||||
logger.info("写出完成:%s", path)
|
||||
return path
|
||||
|
||||
|
||||
# ── GDB 专用 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_gdb(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
gdb_path: str | Path,
|
||||
layer: str,
|
||||
mode: Literal["w", "a"] = "w",
|
||||
encoding: str = "utf-8",
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
"""将 GeoDataFrame 写出到 Esri File Geodatabase(.gdb)中。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
待写出的 GeoDataFrame。
|
||||
gdb_path:
|
||||
目标 ``.gdb`` 目录路径(不存在时自动创建)。
|
||||
layer:
|
||||
图层名称(必填)。
|
||||
mode:
|
||||
``"w"`` 覆盖图层;``"a"`` 向已有 GDB 追加图层。
|
||||
|
||||
Notes
|
||||
-----
|
||||
写出 GDB 依赖 fiona 的 ``OpenFileGDB``(写)或 ``FileGDB``(需 ESRI 驱动)支持。
|
||||
当前 fiona >= 1.9 的 ``OpenFileGDB`` 驱动已支持创建和写出,无需额外安装。
|
||||
"""
|
||||
gdb_path = Path(gdb_path)
|
||||
if not layer:
|
||||
raise ValueError("写出 GDB 必须指定 layer 参数。")
|
||||
|
||||
gdb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("写出到 GDB:%s >> 图层 %r(模式:%s)", gdb_path.name, layer, mode)
|
||||
gdf.to_file(
|
||||
str(gdb_path),
|
||||
layer=layer,
|
||||
driver="OpenFileGDB",
|
||||
mode=mode,
|
||||
encoding=encoding,
|
||||
**kwargs,
|
||||
)
|
||||
logger.info("GDB 写出完成:%s >> %s", gdb_path, layer)
|
||||
return gdb_path
|
||||
|
||||
|
||||
# ── GPKG 专用 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_gpkg(
|
||||
gdf: gpd.GeoDataFrame,
|
||||
gpkg_path: str | Path,
|
||||
layer: str,
|
||||
mode: Literal["w", "a"] = "w",
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
"""将 GeoDataFrame 写出为 GeoPackage 中的一个图层。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gpkg_path:
|
||||
目标 ``.gpkg`` 文件路径(不存在时自动创建)。
|
||||
layer:
|
||||
图层名称(必填)。
|
||||
mode:
|
||||
``"w"`` 覆盖;``"a"`` 向已有 GPKG 追加图层。
|
||||
"""
|
||||
gpkg_path = Path(gpkg_path)
|
||||
gpkg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
gdf.to_file(str(gpkg_path), layer=layer, driver="GPKG", mode=mode, **kwargs)
|
||||
logger.info("GPKG 写出完成:%s >> %s", gpkg_path, layer)
|
||||
return gpkg_path
|
||||
|
||||
|
||||
# ── CSV 写出 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _write_csv_vector(gdf: gpd.GeoDataFrame, path: Path, **kwargs: Any) -> Path:
|
||||
"""将 GeoDataFrame 写出为含 WKT 几何列的 CSV。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = gdf.copy()
|
||||
df["geometry"] = df["geometry"].apply(lambda g: g.wkt if g is not None else None)
|
||||
df.to_csv(path, index=False, encoding="utf-8-sig", **kwargs) # utf-8-sig 兼容 Excel
|
||||
logger.info("CSV 写出完成:%s", path)
|
||||
return path
|
||||
|
||||
|
||||
def write_csv(gdf: gpd.GeoDataFrame, path: str | Path, **kwargs: Any) -> Path:
|
||||
"""将 GeoDataFrame 写出为含 WKT 几何列的 CSV(公开接口)。"""
|
||||
return _write_csv_vector(gdf, Path(path), **kwargs)
|
||||
|
||||
|
||||
# ── 工具函数 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _infer_driver(path: Path) -> str:
|
||||
"""根据文件扩展名推断 fiona 驱动。"""
|
||||
_EXT_TO_DRIVER: dict[str, str] = {
|
||||
".shp": "ESRI Shapefile",
|
||||
".geojson": "GeoJSON",
|
||||
".json": "GeoJSON",
|
||||
".gpkg": "GPKG",
|
||||
".gdb": "OpenFileGDB",
|
||||
".kml": "KML",
|
||||
".fgb": "FlatGeobuf",
|
||||
".gml": "GML",
|
||||
".dxf": "DXF",
|
||||
}
|
||||
suffix = path.suffix.lower()
|
||||
if path.is_dir() and suffix == ".gdb":
|
||||
return "OpenFileGDB"
|
||||
driver = _EXT_TO_DRIVER.get(suffix)
|
||||
if driver is None:
|
||||
raise ValueError(
|
||||
f"无法自动推断 fiona 驱动,未知扩展名:{suffix!r}。"
|
||||
f"请显式传入 driver=... 参数。"
|
||||
)
|
||||
return driver
|
||||
30
app/utils/__init__.py
Normal file
30
app/utils/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""geo_tools.utils 包 —— 通用工具函数。"""
|
||||
|
||||
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,
|
||||
validate_crs,
|
||||
validate_geometry,
|
||||
validate_vector_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# logger
|
||||
"get_logger",
|
||||
"set_global_level",
|
||||
# config loaders
|
||||
"load_config",
|
||||
"load_json_config",
|
||||
"load_toml_config",
|
||||
"load_yaml_config",
|
||||
# validators
|
||||
"is_valid_crs",
|
||||
"validate_crs",
|
||||
"validate_geometry",
|
||||
"is_supported_vector_format",
|
||||
"validate_vector_path",
|
||||
"SUPPORTED_VECTOR_EXTENSIONS",
|
||||
]
|
||||
85
app/utils/config.py
Normal file
85
app/utils/config.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
geo_tools.utils.config
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
配置加载辅助函数:读取 TOML / JSON / YAML 格式的任务配置文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_json_config(path: str | Path) -> dict[str, Any]:
|
||||
"""读取 JSON 配置文件。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
JSON 文件路径。
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在:{path}")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_toml_config(path: str | Path) -> dict[str, Any]:
|
||||
"""读取 TOML 配置文件(Python 3.11+ 内置 tomllib,低版本需 tomli)。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
TOML 文件路径。
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在:{path}")
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"读取 TOML 文件需要 Python 3.11+ 或安装 tomli:pip install tomli"
|
||||
) from exc
|
||||
with path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def load_yaml_config(path: str | Path) -> dict[str, Any]:
|
||||
"""读取 YAML 配置文件(需安装 PyYAML)。"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在:{path}")
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc:
|
||||
raise ImportError("读取 YAML 文件需要安装 pyyaml:pip install pyyaml") from exc
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> dict[str, Any]:
|
||||
"""根据文件扩展名自动选择解析器。
|
||||
|
||||
支持 ``.json``、``.toml``、``.yaml``、``.yml``。
|
||||
"""
|
||||
path = Path(path)
|
||||
ext = path.suffix.lower()
|
||||
loaders = {
|
||||
".json": load_json_config,
|
||||
".toml": load_toml_config,
|
||||
".yaml": load_yaml_config,
|
||||
".yml": load_yaml_config,
|
||||
}
|
||||
if ext not in loaders:
|
||||
raise ValueError(f"不支持的配置文件格式:{ext!r},支持:{list(loaders)}")
|
||||
return loaders[ext](path)
|
||||
107
app/utils/logger.py
Normal file
107
app/utils/logger.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
geo_tools.utils.logger
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
统一日志工厂,支持同时输出到控制台和文件。
|
||||
|
||||
使用方式
|
||||
--------
|
||||
>>> from geo_tools.utils.logger import get_logger
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> logger.info("处理开始")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 已初始化的 logger 集合,避免重复添加 handler
|
||||
_initialized: set[str] = set()
|
||||
|
||||
|
||||
def get_logger(
|
||||
name: str,
|
||||
level: str | None = None,
|
||||
log_file: Path | str | None = None,
|
||||
*,
|
||||
propagate: bool = False,
|
||||
) -> logging.Logger:
|
||||
"""获取(或创建)一个带格式化 handler 的 Logger。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Logger 名称,通常传入 ``__name__``。
|
||||
level:
|
||||
日志等级字符串;``None`` 时读取 ``settings.log_level``。
|
||||
log_file:
|
||||
日志文件路径;``None`` 时读取 ``settings``:
|
||||
若 ``settings.log_to_file`` 为 True,则写到 ``settings.log_dir/geo_tools.log``。
|
||||
propagate:
|
||||
是否向父 logger 传播,默认 False(避免重复输出)。
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
"""
|
||||
# 延迟导入,避免循环依赖
|
||||
from app.config.settings import settings as _settings
|
||||
|
||||
if level is None:
|
||||
level = _settings.log_level
|
||||
numeric_level = logging.getLevelName(level.upper())
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.propagate = propagate
|
||||
|
||||
# 已初始化则直接返回,level 可动态调整
|
||||
if name in _initialized:
|
||||
logger.setLevel(numeric_level)
|
||||
return logger
|
||||
|
||||
logger.setLevel(numeric_level)
|
||||
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
||||
|
||||
# ── 控制台 handler ────────────────────────────────────────
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(numeric_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# ── 文件 handler ──────────────────────────────────────────
|
||||
_resolve_log_file = log_file
|
||||
if _resolve_log_file is None and _settings.log_to_file:
|
||||
_settings.ensure_dirs()
|
||||
_resolve_log_file = _settings.log_dir / "geo_tools.log"
|
||||
|
||||
if _resolve_log_file is not None:
|
||||
file_path = Path(_resolve_log_file)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(file_path, encoding="utf-8")
|
||||
file_handler.setLevel(numeric_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
_initialized.add(name)
|
||||
return logger
|
||||
|
||||
|
||||
def set_global_level(level: str) -> None:
|
||||
"""动态调整所有 geo_tools 下 logger 的日志等级。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
level:
|
||||
目标日志等级,例如 ``"DEBUG"``。
|
||||
"""
|
||||
numeric = logging.getLevelName(level.upper())
|
||||
root = logging.getLogger("geo_tools")
|
||||
root.setLevel(numeric)
|
||||
for handler in root.handlers:
|
||||
handler.setLevel(numeric)
|
||||
183
app/utils/validators.py
Normal file
183
app/utils/validators.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
geo_tools.utils.validators
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
数据验证工具:CRS 合法性、几何有效性、文件格式等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import geopandas as gpd
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
|
||||
# ── CRS 校验 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_valid_crs(crs_input: str | int) -> bool:
|
||||
"""检查 CRS 是否可以被 pyproj 正常解析。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
crs_input:
|
||||
EPSG 代码(整数或 ``"EPSG:4326"`` 字符串)或 proj 字符串。
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
"""
|
||||
try:
|
||||
from pyproj import CRS
|
||||
CRS.from_user_input(crs_input)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def validate_crs(crs_input: str | int) -> str:
|
||||
"""校验并标准化 CRS,返回 EPSG 代码字符串。
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
如果 CRS 无法被 pyproj 解析。
|
||||
"""
|
||||
from pyproj import CRS
|
||||
try:
|
||||
crs_obj = CRS.from_user_input(crs_input)
|
||||
# 尝试返回简洁的 EPSG 字符串
|
||||
epsg = crs_obj.to_epsg()
|
||||
if epsg:
|
||||
return f"EPSG:{epsg}"
|
||||
return crs_obj.to_string()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"无效的 CRS:{crs_input!r}。原因:{exc}") from exc
|
||||
|
||||
|
||||
# ── 几何校验 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def validate_geometry(gdf: "gpd.GeoDataFrame", *, raise_on_invalid: bool = False) -> dict[str, int]:
|
||||
"""检查 GeoDataFrame 中几何对象的有效性。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gdf:
|
||||
待检查的 GeoDataFrame。
|
||||
raise_on_invalid:
|
||||
若为 True,当存在无效几何时抛出 ``ValueError``。
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
包含 ``total``、``valid``、``invalid``、``null`` 计数。
|
||||
"""
|
||||
null_count = gdf.geometry.isna().sum()
|
||||
non_null = gdf.geometry.dropna()
|
||||
invalid_mask = ~non_null.is_valid # type: ignore
|
||||
invalid_count = int(invalid_mask.sum())
|
||||
valid_count = len(non_null) - invalid_count
|
||||
|
||||
result = {
|
||||
"total": len(gdf),
|
||||
"valid": valid_count,
|
||||
"invalid": invalid_count,
|
||||
"null": int(null_count),
|
||||
}
|
||||
|
||||
if raise_on_invalid and (invalid_count > 0 or null_count > 0):
|
||||
raise ValueError(
|
||||
f"GeoDataFrame 存在 {invalid_count} 个无效几何、{null_count} 个空几何。"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── 文件格式校验 ───────────────────────────────────────────────────────────────
|
||||
|
||||
#: 支持读取的矢量文件扩展名(fiona 驱动映射)
|
||||
SUPPORTED_VECTOR_EXTENSIONS: dict[str, str] = {
|
||||
".shp": "ESRI Shapefile",
|
||||
".geojson": "GeoJSON",
|
||||
".json": "GeoJSON",
|
||||
".gpkg": "GPKG",
|
||||
".gdb": "OpenFileGDB",
|
||||
".kml": "KML",
|
||||
".kmz": "KML",
|
||||
".csv": "CSV",
|
||||
".gml": "GML",
|
||||
".dxf": "DXF",
|
||||
".fgb": "FlatGeobuf",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
if path.is_dir() and suffix == ".gdb":
|
||||
return True
|
||||
return suffix in SUPPORTED_VECTOR_EXTENSIONS
|
||||
|
||||
|
||||
def validate_vector_path(path: str | Path) -> Path:
|
||||
"""校验矢量数据路径,返回 Path 对象。
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
文件或目录不存在。
|
||||
ValueError
|
||||
文件格式不受支持。
|
||||
"""
|
||||
path = Path(path)
|
||||
# GDB 是目录
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"路径不存在:{path}")
|
||||
if not is_supported_vector_format(path):
|
||||
raise ValueError(
|
||||
f"不支持的矢量格式:{path.suffix!r}。"
|
||||
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
|
||||
Reference in New Issue
Block a user