refactor: 重构项目结构,将geo_tools重命名为app并更新相关引用
- 将主包名从geo_tools改为app - 更新所有模块中的引用路径 - 迁移并更新测试用例 - 添加项目规则文档 - 保持原有功能不变,仅进行结构调整
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user