Skip to content

Commit 018c8ce

Browse files
dbastonjdalrym2
andcommitted
Add Python bindings
Co-authored-by: Jon Dalrymple <[email protected]>
1 parent f108722 commit 018c8ce

31 files changed

+1432
-2
lines changed

.gitlab-ci.yml

+18-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ variables:
1717
- geos-config --version
1818
- mkdir build-coverage
1919
- cd build-coverage
20-
- cmake -DCMAKE_BUILD_TYPE=Coverage -DBUILD_CLI=NO ..
20+
- cmake -DCMAKE_BUILD_TYPE=Coverage -DBUILD_CLI=NO -DBUILD_PYTHON=NO ..
2121
- make -j2 catch_tests
2222
- valgrind --leak-check=full --error-exitcode=1 ./catch_tests
2323
after_script:
@@ -72,14 +72,30 @@ test:cli:
7272
- geos-config --version
7373
- mkdir build-coverage
7474
- cd build-coverage
75-
- cmake -DCMAKE_BUILD_TYPE=Coverage -DBUILD_CLI=YES ..
75+
- cmake -DCMAKE_BUILD_TYPE=Coverage -DBUILD_CLI=YES -DBUILD_PYTHON=NO ..
7676
- make -j2 exactextract_bin subdivide
7777
- pytest ../test
7878
after_script:
7979
- cd build-coverage
8080
- lcov --capture --directory CMakeFiles --output-file coverage.info
8181
- bash <(curl -s https://codecov.io/bash)
8282

83+
test:python:
84+
stage: test
85+
image: isciences/exactextract-test-env:geos312
86+
script:
87+
- apt-get install -y python3-dev pybind11-dev
88+
- geos-config --version
89+
- mkdir build-coverage
90+
- cd build-coverage
91+
- cmake -DCMAKE_BUILD_TYPE=Coverage -DBUILD_CLI=NO -DBUILD_PYTHON=YES ..
92+
- cmake --build . -j2
93+
- ctest -R pybindings --output-on-failure
94+
after_script:
95+
- cd build-coverage
96+
- lcov --capture --directory CMakeFiles --output-file coverage.info
97+
- bash <(curl -s https://codecov.io/bash)
98+
8399
build:
84100
stage: build
85101
script:

CMakeLists.txt

+8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ set(CMAKE_CXX_STANDARD 17)
99
set(CMAKE_CXX_STANDARD_REQUIRED ON)
1010
set(CMAKE_CXX_EXTENSIONS OFF)
1111

12+
13+
include(CTest)
1214
include(GNUInstallDirs)
1315

1416
set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
@@ -19,6 +21,7 @@ find_package(GEOS REQUIRED)
1921
#Configure some options the various components this module can build
2022
option(BUILD_CLI "Build the exactextract cli binary" ON) #requires gdal, cli11
2123
option(BUILD_TEST "Build the exactextract tests" ON) #requires catch
24+
option(BUILD_PYTHON "Build the exactextract Python bindings" ON) # requires pybind11
2225
option(BUILD_DOC "Build documentation" ON) #requires doxygen
2326

2427
if(BUILD_CLI)
@@ -246,6 +249,7 @@ set(PROJECT_SOURCES
246249

247250
add_library(${LIB_NAME} ${PROJECT_SOURCES})
248251

252+
249253
# Check matrix bounds for debug builds
250254
set_target_properties(${LIB_NAME}
251255
PROPERTIES COMPILE_DEFINITIONS $<$<CONFIG:Debug>:MATRIX_CHECK_BOUNDS>)
@@ -277,6 +281,10 @@ target_link_libraries(
277281

278282
set_target_properties(${LIB_NAME} PROPERTIES OUTPUT_NAME ${LIB_NAME})
279283

284+
if (BUILD_PYTHON)
285+
add_subdirectory(python)
286+
endif() # BUILD_PYTHON
287+
280288
if(BUILD_DOC)
281289
# Doxygen configuration from https://vicrucann.github.io/tutorials/quick-cmake-doxygen/
282290
# check if Doxygen is installed

python/CMakeLists.txt

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Build the Python bindings, which require pybind11
2+
find_package(pybind11 REQUIRED)
3+
if (pybind11_FOUND)
4+
# TODO: which pybind11 version
5+
endif() #pybind11_FOUND
6+
if (NOT pybind11_FOUND)
7+
message(FATAL_ERROR
8+
"pybind11 was not found. It is still possible to build and test libexactextract, but the "
9+
"exactextract Python bindings cannot be built or installed.")
10+
endif() #NOT pybind11_FOUND
11+
12+
13+
set(PYBIND_SOURCES
14+
src/pybindings/bindings.cpp
15+
src/pybindings/feature_bindings.cpp
16+
src/pybindings/feature_bindings.h
17+
src/pybindings/feature_source_bindings.cpp
18+
src/pybindings/feature_source_bindings.h
19+
src/pybindings/operation_bindings.cpp
20+
src/pybindings/operation_bindings.h
21+
src/pybindings/processor_bindings.cpp
22+
src/pybindings/processor_bindings.h
23+
src/pybindings/raster_source_bindings.cpp
24+
src/pybindings/raster_source_bindings.h
25+
src/pybindings/writer_bindings.cpp
26+
src/pybindings/writer_bindings.h)
27+
28+
pybind11_add_module(_exactextract MODULE ${PYBIND_SOURCES})
29+
target_include_directories(_exactextract PRIVATE
30+
${CMAKE_SOURCE_DIR}/src
31+
${GEOS_INCLUDE_DIR})
32+
set_property(TARGET ${LIB_NAME} PROPERTY POSITION_INDEPENDENT_CODE ON)
33+
target_link_libraries(_exactextract PRIVATE ${LIB_NAME} ${GEOS_LIBRARY})
34+
35+
add_test(NAME "pybindings"
36+
COMMAND ${CMAKE_COMMAND} -E env
37+
PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}:${CMAKE_CURRENT_LIST_DIR}/src
38+
python3 -m pytest ${CMAKE_CURRENT_LIST_DIR}/tests)

python/src/exactextract/__init__.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
""" Python bindings for exactextract """
4+
5+
from .feature import Feature, GDALFeature, JSONFeature
6+
from .feature_source import FeatureSource, GDALFeatureSource, JSONFeatureSource
7+
from .operation import Operation
8+
from .processor import FeatureSequentialProcessor, RasterSequentialProcessor
9+
from .raster_source import RasterSource, GDALRasterSource, NumPyRasterSource
10+
from .writer import Writer, JSONWriter, GDALWriter

python/src/exactextract/feature.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from _exactextract import Feature
2+
3+
4+
class GDALFeature(Feature):
5+
def __init__(self, f):
6+
Feature.__init__(self)
7+
self.feature = f
8+
9+
def set(self, name, value):
10+
self.feature.SetField(name, value)
11+
12+
def get(self, name):
13+
return self.feature.GetField(name)
14+
15+
def geometry(self):
16+
return bytes(self.feature.GetGeometryRef().ExportToWkb())
17+
18+
def fields(self):
19+
defn = self.feature.GetDefnRef()
20+
return [defn.GetFieldDefn(i).GetName() for i in range(defn.GetFieldCount())]
21+
22+
23+
class JSONFeature(Feature):
24+
def __init__(self, f=None):
25+
Feature.__init__(self)
26+
self.feature = f or {}
27+
28+
def set(self, name, value):
29+
if name == "id":
30+
self.feature["id"] = value
31+
else:
32+
if "properties" not in self.feature:
33+
self.feature["properties"] = {}
34+
self.feature["properties"][name] = value
35+
36+
def get(self, name):
37+
if name == "id":
38+
return self.feature["id"]
39+
else:
40+
return self.feature["properties"][name]
41+
42+
def geometry(self):
43+
import json
44+
45+
return json.dumps(self.feature["geometry"])
46+
47+
def fields(self):
48+
fields = []
49+
if "id" in self.feature:
50+
fields.append("id")
51+
if "properties" in self.feature:
52+
fields += self.feature["properties"].keys()
53+
return fields
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from _exactextract import FeatureSource
2+
3+
from .feature import GDALFeature, JSONFeature
4+
5+
6+
class GDALFeatureSource(FeatureSource):
7+
def __init__(self, src, id_field):
8+
super().__init__(id_field)
9+
self.src = src
10+
11+
def __iter__(self):
12+
for f in self.src:
13+
yield GDALFeature(f)
14+
15+
16+
class JSONFeatureSource(FeatureSource):
17+
def __init__(self, src, id_field):
18+
super().__init__(id_field)
19+
if type(src) is dict:
20+
self.src = [src]
21+
else:
22+
self.src = src
23+
24+
def __iter__(self):
25+
for f in self.src:
26+
yield JSONFeature(f)

python/src/exactextract/operation.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
from _exactextract import Operation as _Operation
8+
9+
from .raster_source import RasterSource
10+
11+
12+
class Operation(_Operation):
13+
"""Binding class around exactextract Operation"""
14+
15+
def __init__(
16+
self,
17+
stat_name: str,
18+
field_name: str,
19+
raster: RasterSource,
20+
weights: Optional[RasterSource] = None,
21+
):
22+
"""
23+
Create Operation object from stat name, field name, raster, and weighted raster
24+
25+
Args:
26+
stat_name (str): Name of the stat. Refer to docs for options.
27+
field_name (str): Field name to use. Output of operation will have title \'{field_name}_{stat_name}\'
28+
raster (RasterSource): Raster to compute over.
29+
weights (Optional[RasterSource], optional): Weight raster to use. Defaults to None.
30+
"""
31+
super().__init__(stat_name, field_name, raster, weights)

python/src/exactextract/processor.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
from typing import List, Union
5+
6+
from _exactextract import (
7+
FeatureSequentialProcessor as _FeatureSequentialProcessor,
8+
RasterSequentialProcessor as _RasterSequentialProcessor,
9+
)
10+
11+
from .feature_source import FeatureSource
12+
from .operation import Operation
13+
from .writer import Writer
14+
15+
16+
class FeatureSequentialProcessor(_FeatureSequentialProcessor):
17+
"""Binding class around exactextract FeatureSequentialProcessor"""
18+
19+
def __init__(self, ds: FeatureSource, writer: Writer, op_list: List[Operation]):
20+
"""
21+
Create FeatureSequentialProcessor object
22+
23+
Args:
24+
ds (FeatureSource): Dataset to use
25+
writer (Writer): Writer to use
26+
op_list (List[Operation]): List of operations
27+
"""
28+
super().__init__(ds, writer)
29+
for op in op_list:
30+
self.add_operation(op)
31+
32+
33+
class RasterSequentialProcessor(_RasterSequentialProcessor):
34+
"""Binding class around exactextract RasterSequentialProcessor"""
35+
36+
def __init__(self, ds: FeatureSource, writer: Writer, op_list: List[Operation]):
37+
"""
38+
Create RasterSequentialProcessor object
39+
40+
Args:
41+
ds (FeatureSource): Dataset to use
42+
writer (Writer): Writer to use
43+
op_list (List[Operation]): List of operations
44+
"""
45+
super().__init__(ds, writer)
46+
for op in op_list:
47+
self.add_operation(op)
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
from __future__ import annotations
5+
6+
import pathlib
7+
from typing import Optional, Tuple, Union
8+
from osgeo import gdal
9+
10+
from _exactextract import RasterSource
11+
12+
13+
class GDALRasterSource(RasterSource):
14+
def __init__(self, ds, band_idx: int = 1):
15+
super().__init__()
16+
self.ds = ds
17+
18+
# Sanity check inputs
19+
if band_idx is not None and band_idx <= 0:
20+
raise ValueError("Raster band index starts from 1!")
21+
22+
self.band = self.ds.GetRasterBand(band_idx)
23+
24+
def res(self):
25+
gt = self.ds.GetGeoTransform()
26+
return gt[1], abs(gt[5])
27+
28+
def extent(self):
29+
gt = self.ds.GetGeoTransform()
30+
31+
dx, dy = self.res()
32+
33+
left = gt[0]
34+
right = left + dx * self.ds.RasterXSize
35+
top = gt[3]
36+
bottom = gt[3] - dy * self.ds.RasterYSize
37+
38+
return (left, bottom, right, top)
39+
40+
def read_window(self, x0, y0, nx, ny):
41+
return self.band.ReadAsArray(xoff=x0, yoff=y0, win_xsize=nx, win_ysize=ny)
42+
43+
44+
class NumPyRasterSource(RasterSource):
45+
def __init__(self, mat, xmin, ymin, xmax, ymax):
46+
super().__init__()
47+
self.mat = mat
48+
self.ext = (xmin, ymin, xmax, ymax)
49+
50+
def res(self):
51+
ny, nx = self.mat.shape
52+
dy = (self.ext[3] - self.ext[1]) / ny
53+
dx = (self.ext[2] - self.ext[0]) / nx
54+
55+
return (dx, dy)
56+
57+
def extent(self):
58+
return self.ext
59+
60+
def read_window(self, x0, y0, nx, ny):
61+
return self.mat[y0 : y0 + ny, x0 : x0 + ny]

0 commit comments

Comments
 (0)