From 1333f1e8bc65d65af4f30eb39a462426deb62124 Mon Sep 17 00:00:00 2001 From: Pranava Kumar Date: Tue, 14 Jul 2026 00:22:24 +0530 Subject: [PATCH] feat(io): add VectorExportTask for exporting vector features from EOPatches Add VectorExportTask that exports vector features (VECTOR_TIMELESS, VECTOR) from EOPatches to common vector formats (GPKG, GeoJSON, etc.) using geopandas. Includes a _write_geodataframe helper that handles pyogrio compatibility for filesystem-based writes and a _driver_to_extension utility. Closes an API asymmetry: VectorImportTask existed but there was no equivalent export task. --- eolearn/io/__init__.py | 2 +- eolearn/io/geometry_io.py | 91 +++++++++++++++++++++++++++++++ tests/io/test_geometry_io.py | 100 ++++++++++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/eolearn/io/__init__.py b/eolearn/io/__init__.py index 93f8df2a..75c25ad3 100644 --- a/eolearn/io/__init__.py +++ b/eolearn/io/__init__.py @@ -2,7 +2,7 @@ A collection of input and output EOTasks """ -from .geometry_io import VectorImportTask +from .geometry_io import VectorExportTask, VectorImportTask from .raster_io import ExportToTiffTask, ImportFromTiffTask from .sentinelhub_process import ( SentinelHubDemTask, diff --git a/eolearn/io/geometry_io.py b/eolearn/io/geometry_io.py index 16ab1a8a..622bdaa1 100644 --- a/eolearn/io/geometry_io.py +++ b/eolearn/io/geometry_io.py @@ -10,6 +10,9 @@ from __future__ import annotations import logging +import os +import shutil +import tempfile from contextlib import nullcontext from typing import Any @@ -160,3 +163,91 @@ def execute(self, eopatch: EOPatch | None = None, *, bbox: BBox | None = None) - eopatch[self.feature] = self._reproject_and_clip(vectors, bbox) return eopatch + + +class VectorExportTask(EOTask): + """Task exports a vector feature from an EOPatch to a file. + + The task extracts a vector feature (e.g. :attr:`~eolearn.core.FeatureType.VECTOR_TIMELESS`) from an EOPatch + and exports it to a file in a supported vector format (GPKG, GeoJSON, Shapefile, etc.). + + :param feature: A vector feature to be exported. Must be a vector feature type + (:attr:`~eolearn.core.FeatureType.VECTOR` or :attr:`~eolearn.core.FeatureType.VECTOR_TIMELESS`). + :param path: Path to the output file, including the extension (e.g. ``/path/to/output.gpkg``). + :param filesystem: An optional filesystem object for writing to non-local paths. If provided, the + file will be written to a temporary local path and then copied to the filesystem. + :param driver: The vector driver to use. Defaults to ``"GPKG"``. Other common options include + ``"GeoJSON"``, ``"ESRI Shapefile"``, ``"FlatGeobuf"``. + :param kwargs: Additional keyword arguments passed to :meth:`geopandas.GeoDataFrame.to_file`. + """ + + def __init__( + self, + feature: Feature, + path: str, + *, + filesystem: FS | None = None, + driver: str = "GPKG", + **kwargs: Any, + ): + self.feature = feature + self.path = path + self.filesystem = filesystem + self.driver = driver + self.kwargs = kwargs + + def execute(self, eopatch: EOPatch) -> EOPatch: + """Exports the vector feature from the EOPatch to the specified file. + + :param eopatch: Input EOPatch containing the vector feature to export. + :returns: The input EOPatch unchanged. + :raises ValueError: If the feature is not found or contains no data. + """ + data = eopatch[self.feature] + if data is None: + raise ValueError(f"Feature {self.feature} has no data in the EOPatch") + + _write_geodataframe(data, self.path, self.driver, self.filesystem, **self.kwargs) + + return eopatch + + +def _write_geodataframe( + data: gpd.GeoDataFrame, + path: str, + driver: str = "GPKG", + filesystem: FS | None = None, + **kwargs: Any, +) -> None: + """Helper to write a GeoDataFrame to a path, optionally via a PyFilesystem abstraction. + + Pyogrio (used by geopandas >= 1) requires a real file path for GPKG format, so when a + filesystem is provided the data is first written to a temporary local file and then copied. + """ + if filesystem is None: + data.to_file(path, driver=driver, **kwargs) + return + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_file = os.path.join(tmp_dir, f"export.{_driver_to_extension(driver)}") + data.to_file(tmp_file, driver=driver, **kwargs) + with open(tmp_file, "rb") as tmp_handle: + filesystem.writebytes(path, tmp_handle.read()) + + +def _driver_to_extension(driver: str) -> str: + """Maps a GDAL/OGR driver name to a common file extension.""" + extension_map = { + "GPKG": "gpkg", + "GeoJSON": "geojson", + "GeoJSONSeq": "geojson", + "ESRI Shapefile": "shp", + "FlatGeobuf": "fgb", + "CSV": "csv", + "KML": "kml", + "GML": "gml", + "GPX": "gpx", + "DXF": "dxf", + "TopoJSON": "topojson", + } + return extension_map.get(driver, "gpkg") diff --git a/tests/io/test_geometry_io.py b/tests/io/test_geometry_io.py index fcead1ac..6868c970 100644 --- a/tests/io/test_geometry_io.py +++ b/tests/io/test_geometry_io.py @@ -7,12 +7,17 @@ from __future__ import annotations +import os +import tempfile + +import geopandas as gpd import pytest +from shapely import Point from sentinelhub import CRS, BBox -from eolearn.core import FeatureType -from eolearn.io import VectorImportTask +from eolearn.core import EOPatch, FeatureType +from eolearn.io import VectorExportTask, VectorImportTask @pytest.mark.parametrize( @@ -50,3 +55,94 @@ def test_clipping_wrong_crs(gpkg_file): import_task = VectorImportTask(feature=feature, path=gpkg_file, reproject=False, clip=True) with pytest.raises(ValueError): import_task.execute(bbox=BBox([657690, 5071637, 660493, 5074440], CRS.UTM_31N)) + + +def _create_test_geodataframe() -> gpd.GeoDataFrame: + """Create a simple GeoDataFrame with a few points for testing.""" + return gpd.GeoDataFrame( + {"id": [1, 2, 3], "label": ["a", "b", "c"]}, + geometry=[Point(0, 0), Point(1, 1), Point(2, 2)], + crs="EPSG:4326", + ) + + +class TestVectorExportTask: + """Tests for the VectorExportTask.""" + + def test_export_gpkg(self): + """Test exporting a vector feature to GPKG format.""" + gdf = _create_test_geodataframe() + feature = FeatureType.VECTOR_TIMELESS, "test_geom" + eopatch = EOPatch(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + eopatch[feature] = gdf + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "test.gpkg") + task = VectorExportTask(feature=feature, path=output_path, driver="GPKG") + task.execute(eopatch) + + assert os.path.isfile(output_path), "GPKG file was not created" + result = gpd.read_file(output_path) + assert len(result) == 3, "Should have 3 features" + assert list(result.columns) == ["id", "label", "geometry"], "Unexpected columns" + assert result.crs == gdf.crs, "CRS should be preserved" + + def test_export_geojson(self): + """Test exporting a vector feature to GeoJSON format.""" + gdf = _create_test_geodataframe() + feature = FeatureType.VECTOR_TIMELESS, "test_geom" + eopatch = EOPatch(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + eopatch[feature] = gdf + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "test.geojson") + task = VectorExportTask(feature=feature, path=output_path, driver="GeoJSON") + task.execute(eopatch) + + assert os.path.isfile(output_path), "GeoJSON file was not created" + result = gpd.read_file(output_path) + assert len(result) == 3, "Should have 3 features" + + def test_export_empty_feature(self): + """Test that exporting a non-existent feature raises an error.""" + feature = FeatureType.VECTOR_TIMELESS, "nonexistent" + eopatch = EOPatch(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "test.gpkg") + task = VectorExportTask(feature=feature, path=output_path) + with pytest.raises((KeyError, ValueError), match="nonexistent|no data"): + task.execute(eopatch) + + def test_export_roundtrip_gpkg(self): + """Test export then import round-trip with GPKG format.""" + gdf = _create_test_geodataframe() + feature = FeatureType.VECTOR_TIMELESS, "test_geom" + eopatch = EOPatch(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + eopatch[feature] = gdf + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "roundtrip.gpkg") + export_task = VectorExportTask(feature=feature, path=output_path) + export_task.execute(eopatch) + + # Import back and verify + import_task = VectorImportTask(feature=feature, path=output_path) + imported = import_task.execute(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + + assert len(imported[feature]) == 3, "Round-trip should preserve feature count" + assert imported[feature].crs.to_epsg() == gdf.crs.to_epsg(), "CRS should be preserved in round-trip" + + def test_export_creates_new_eopatch_object(self): + """Test that export returns the same EOPatch object.""" + gdf = _create_test_geodataframe() + feature = FeatureType.VECTOR_TIMELESS, "test_geom" + eopatch = EOPatch(bbox=BBox([0, 0, 3, 3], CRS.WGS84)) + eopatch[feature] = gdf + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "test.gpkg") + task = VectorExportTask(feature=feature, path=output_path) + result = task.execute(eopatch) + + assert result is eopatch, "Should return the same EOPatch instance"