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

375 lines
13 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.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)