Files
geo_tools/app/core/vector.py
missum db51d41aef refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用
- 将主包名从geo_tools改为app
- 更新所有模块中的引用路径
- 迁移并更新测试用例
- 添加项目规则文档
- 保持原有功能不变,仅进行结构调整
2026-04-12 19:49:56 +08:00

216 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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