Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eolearn/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions eolearn/io/geometry_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from __future__ import annotations

import logging
import os
import shutil
import tempfile
from contextlib import nullcontext
from typing import Any

Expand Down Expand Up @@ -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")
100 changes: 98 additions & 2 deletions tests/io/test_geometry_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"