Skip to content

Commit 021e643

Browse files
committed
Split linestrings within bounds (behind "bounded" flag)
1 parent cdc2a79 commit 021e643

6 files changed

Lines changed: 160 additions & 29 deletions

File tree

extension/src/intersections.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ std::vector<py::object> convert_cpp2py(std::vector<linestr> splits) {
5353

5454
std::vector<py::object> splitLineString(py::object linestring_py, int nrows,
5555
int ncols,
56-
std::vector<double> transform) {
56+
std::vector<double> transform,
57+
bool bounded = false) {
5758
linestr linestring = convert_py2cpp(linestring_py);
5859
transform::Affine affine(transform[0], transform[1], transform[2],
5960
transform[3], transform[4], transform[5]);
6061
grid::Grid grid(ncols, nrows, affine);
6162
geometry::LineString line(linestring);
6263
std::vector<linestr> splits =
63-
operations::findIntersectionsLineString(line, grid);
64+
operations::findIntersectionsLineString(line, grid, bounded);
6465
return convert_cpp2py(splits);
6566
}
6667

@@ -129,6 +130,9 @@ PYBIND11_MODULE(intersections, m) {
129130
m.doc() = "Vector geometry to grid intersections";
130131

131132
m.def("split_linestring", &snail::splitLineString,
133+
pybind11::arg("linestring_py"), pybind11::arg("nrows"),
134+
pybind11::arg("ncols"), pybind11::arg("transform"),
135+
pybind11::arg("bounded") = false,
132136
"Split LineString along a grid");
133137
m.def("get_cell_indices", &snail::get_cell_indices,
134138
"Get LineString cell indices in a grid");

extension/src/operations.cpp

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@ namespace operations {
1010

1111
using linestr = std::vector<geometry::Coord>;
1212

13+
bool segmentIntersectsGridBounds(const geometry::Line &line,
14+
const grid::Grid &raster) {
15+
16+
auto xy0 = line.start;
17+
auto xy1 = line.end;
18+
const double x0 = xy0.x;
19+
const double y0 = xy0.y;
20+
const double x1 = xy1.x;
21+
const double y1 = xy1.y;
22+
23+
auto xymin = raster.grid_to_world * geometry::Coord(0.0, 0.0);
24+
auto xymax =
25+
raster.grid_to_world * geometry::Coord(raster.ncols, raster.nrows);
26+
const double xmin = xymin.x;
27+
const double ymin = xymin.y;
28+
const double xmax = xymax.x;
29+
const double ymax = xymax.y;
30+
31+
auto inside = [&](const double &x, const double &y) {
32+
return x >= xmin && x <= xmax && y >= ymin && y <= ymax;
33+
};
34+
35+
if (inside(x0, y0) || inside(x1, y1)) {
36+
return true;
37+
}
38+
39+
auto crosses_x = [&](const double &xtest) {
40+
if (x0 <= xtest && xtest <= x1) {
41+
const double ycross = y0 + (xtest - x0) * (y1 - y0) / (x1 - x0);
42+
return ymin <= ycross && ycross <= ymax;
43+
}
44+
return false;
45+
};
46+
47+
auto crosses_y = [&](const double &ytest) {
48+
if (y0 <= ytest && ytest <= y1) {
49+
const double xcross = x0 + (ytest - y0) * (x1 - x0) / (y1 - y0);
50+
return xmin <= xcross && xcross <= xmax;
51+
}
52+
return false;
53+
};
54+
55+
return crosses_x(xmin) || crosses_x(xmax) || crosses_y(ymin) ||
56+
crosses_y(ymax);
57+
}
58+
1359
/// Piecewise decomposition of a linestring according to intersection points
1460
std::vector<linestr> split_linestr(linestr linestring, linestr intersections) {
1561
// Add line start point
@@ -28,27 +74,49 @@ std::vector<linestr> split_linestr(linestr linestring, linestr intersections) {
2874

2975
/// Find intersection points of a linestring with a raster grid
3076
std::vector<linestr>
31-
findIntersectionsLineString(geometry::LineString linestring,
32-
grid::Grid raster) {
77+
findIntersectionsLineString(geometry::LineString linestring, grid::Grid raster,
78+
bool bounded) {
3379
linestr coords = linestring.coordinates;
3480

3581
std::vector<linestr> allsplits;
3682
linestr linestr_piece;
3783
for (std::size_t i = 0; i < coords.size() - 1; i++) {
3884
geometry::Line line(coords.at(i), coords.at(i + 1));
3985

40-
// If the line starts and ends in different cells, it needs to be cleaned.
41-
if (raster.cellIndex(line.start) != raster.cellIndex(line.end)) {
86+
bool single_cell =
87+
raster.cellIndices(line.start) == raster.cellIndices(line.end);
88+
89+
// If the line starts and ends in the same cell,
90+
// or (bounded and (segment does not intersect overall grid bounds))
91+
if (single_cell ||
92+
(bounded && !segmentIntersectsGridBounds(line, raster))) {
93+
// then don't split, just push back the coordinate
94+
linestr_piece.push_back(coords.at(i));
95+
} else {
96+
// otherwise do split this straight-line segment
4297
linestr intersections = raster.findIntersections(line);
98+
// if only splitting within grid bounds, filter the intersections
99+
if (bounded) {
100+
linestr filtered;
101+
filtered.reserve(intersections.size());
102+
for (std::size_t idx = 0; idx < intersections.size(); ++idx) {
103+
const auto &coordinate = intersections[idx];
104+
bool endpoint = (idx == 0) || (idx == intersections.size() - 1);
105+
// keep the endpoints as original coordinates in the linestring
106+
// and keep any split intersections from within the grid bounds
107+
if (endpoint || pointInBounds(coordinate, raster)) {
108+
filtered.push_back(coordinate);
109+
}
110+
}
111+
intersections = std::move(filtered);
112+
}
43113
std::vector<linestr> splits = split_linestr(linestr_piece, intersections);
44114
allsplits.insert(allsplits.end(), splits.begin(), splits.end());
45115
if (line.end == intersections.back()) {
46116
linestr_piece = {};
47117
} else {
48118
linestr_piece = {intersections.back()};
49119
}
50-
} else {
51-
linestr_piece.push_back(coords.at(i));
52120
}
53121
}
54122

@@ -57,9 +125,16 @@ findIntersectionsLineString(geometry::LineString linestring,
57125
allsplits.push_back(linestr_piece);
58126
}
59127

60-
return (allsplits);
128+
return allsplits;
61129
}
62130

131+
bool pointInBounds(const geometry::Coord &pt, const grid::Grid &raster) {
132+
auto ll = raster.grid_to_world * geometry::Coord(0.0, 0.0);
133+
auto ur = raster.grid_to_world * geometry::Coord(raster.ncols, raster.nrows);
134+
135+
return pt.x >= ll.x && pt.x <= ur.x && pt.y >= ll.y && pt.y <= ur.y;
136+
};
137+
63138
bool isOnGridLine(geometry::Coord point, Direction direction, double level,
64139
double cellSize) {
65140
switch (direction) {
@@ -81,7 +156,7 @@ bool isOnGridLine(geometry::Coord point, Direction direction, double level,
81156
// >>-----x----o-----o----- (don't include x)
82157
// /.\ |..../
83158
//
84-
// TODO figure out what to do when some portion of the boundary is already
159+
// figure out what to do when some portion of the boundary is already
85160
// along the grid line. This is a legitimate case for odd number of crossings:
86161
//
87162
// |......|

extension/src/operations.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ namespace operations {
1111
enum class Direction { horizontal, vertical };
1212

1313
std::vector<std::vector<geometry::Coord>>
14-
findIntersectionsLineString(geometry::LineString, grid::Grid);
14+
findIntersectionsLineString(geometry::LineString, grid::Grid,
15+
bool bounded = false);
1516
std::vector<std::vector<geometry::Coord>>
1617
splitAlongGridlines(std::vector<geometry::Coord>, int, int, Direction,
1718
grid::Grid);
1819

20+
bool pointInBounds(const geometry::Coord &pt, const grid::Grid &raster);
21+
1922
} // namespace operations
2023
} // namespace snail
2124

extension/tests/tests_intersections.cpp

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ TEST_CASE("LineString outside grid remains unchanged", "[decomposition]") {
258258
snail::geometry::LineString line(coordinates);
259259

260260
auto splits =
261-
snail::operations::findIntersectionsLineString(line, test_raster);
261+
snail::operations::findIntersectionsLineString(line, test_raster, true);
262262

263263
REQUIRE(splits.size() == 1);
264264
REQUIRE(splits[0].size() == coordinates.size());
@@ -281,7 +281,7 @@ TEST_CASE("LineString partially overlapping grid splits correctly",
281281
};
282282

283283
auto splits =
284-
snail::operations::findIntersectionsLineString(line, test_raster);
284+
snail::operations::findIntersectionsLineString(line, test_raster, true);
285285

286286
REQUIRE(splits.size() == expected.size());
287287
for (std::size_t i = 0; i < expected.size(); ++i) {
@@ -293,6 +293,45 @@ TEST_CASE("LineString partially overlapping grid splits correctly",
293293
}
294294
}
295295

296+
TEST_CASE("Bounded splits keep interior vertices within grid extents",
297+
"[decomposition]") {
298+
snail::grid::Grid test_raster(2, 2, snail::transform::Affine());
299+
linestr coordinates = {{-2.0, 0.5}, {1.5, 0.5}};
300+
snail::geometry::LineString line(coordinates);
301+
302+
auto splits =
303+
snail::operations::findIntersectionsLineString(line, test_raster, true);
304+
305+
REQUIRE(splits.size() == 3);
306+
for (std::size_t segment_idx = 0; segment_idx < splits.size();
307+
++segment_idx) {
308+
const auto &segment = splits[segment_idx];
309+
REQUIRE(segment.size() >= 2);
310+
for (std::size_t point_idx = 0; point_idx < segment.size(); ++point_idx) {
311+
bool is_start = (segment_idx == 0 && point_idx == 0);
312+
bool is_end =
313+
(segment_idx == splits.size() - 1 && point_idx == segment.size() - 1);
314+
if (is_start || is_end) {
315+
continue;
316+
}
317+
REQUIRE(
318+
snail::operations::pointInBounds(segment[point_idx], test_raster));
319+
}
320+
}
321+
}
322+
323+
TEST_CASE("pointInBounds treats boundary as inside", "[bounds]") {
324+
snail::grid::Grid test_raster(2, 2, snail::transform::Affine());
325+
snail::geometry::Coord on_edge_x(2.0, 1.0);
326+
REQUIRE(snail::operations::pointInBounds(on_edge_x, test_raster));
327+
snail::geometry::Coord on_edge_y(1.0, 2.0);
328+
REQUIRE(snail::operations::pointInBounds(on_edge_y, test_raster));
329+
snail::geometry::Coord inner(0.9, 1.1);
330+
REQUIRE(snail::operations::pointInBounds(inner, test_raster));
331+
snail::geometry::Coord outer(0.9, 11.1);
332+
REQUIRE(!snail::operations::pointInBounds(outer, test_raster));
333+
}
334+
296335
TEST_CASE("Split with different grid", "[decomposition]") {
297336
Config case1;
298337
case1.expected_splits = {

src/snail/intersection.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ def split_points(
204204

205205

206206
def split_linestrings(
207-
linestring_features: geopandas.GeoDataFrame, grid: GridDefinition
207+
linestring_features: geopandas.GeoDataFrame,
208+
grid: GridDefinition,
209+
bounded=False,
208210
) -> geopandas.GeoDataFrame:
209211
"""Split linestrings along a grid"""
210212
# TODO check for MultiLineString
@@ -217,21 +219,13 @@ def split_linestrings(
217219
grid.width,
218220
grid.height,
219221
grid.transform,
222+
bounded=bounded,
220223
)
221224
for j, s in enumerate(geom_splits):
222-
# splitting sometimes returns zero-length linestrings on edge of raster
223-
# see below for example linestring on eastern (lon=70W) extent of box
224-
# (Pdb) geometry.coords.xy
225-
# (array('d', [-70.0, -70.0]), array('d', [18.445832920952196, 18.445832920952196]))
226-
# this split geometry has: j = raster_width
227-
# however j should be in range: 0 <= j < raster_width
228-
# as a hacky workaround, drop any splits with length 0
229-
# do we need a nudge off a cell boundary somewhere when performing the splits?
230-
if s.length != 0:
231-
new_row = linestring_features.iloc[i].copy()
232-
new_row.geometry = s
233-
new_row["split"] = j
234-
pieces.append(new_row)
225+
new_row = linestring_features.iloc[i].copy()
226+
new_row.geometry = s
227+
new_row["split"] = j
228+
pieces.append(new_row)
235229
logger.info(f"Split {len(linestring_features)} edges into {len(pieces)} pieces")
236230
splits_df = geopandas.GeoDataFrame(pieces, crs=grid.crs, geometry="geometry")
237231
return splits_df

tests/test_intersection.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,19 +279,24 @@ def test_split_linestrings_outside_grid_returns_geometry(grid):
279279
outside = gpd.GeoDataFrame(
280280
{"id": [1]}, geometry=[LineString([(5.0, 0.5), (16.0, 1.5)])]
281281
)
282-
splits = split_linestrings(outside, grid)
282+
# must pass bounded=True for non-splitting behaviour
283+
splits = split_linestrings(outside, grid, bounded=True)
283284
assert len(splits) == 1
284285
assert splits.geometry.iloc[0].equals(outside.geometry.iloc[0])
285286
with_indices = apply_indices(splits, grid)
286287
assert (with_indices["index_i"] == -1).all()
287288
assert (with_indices["index_j"] == -1).all()
288289

290+
# default behaviour would split even outside grid bounds
291+
splits = split_linestrings(outside, grid, bounded=False)
292+
assert len(splits) > 1
293+
289294

290295
def test_split_linestrings_partial_overlap(grid):
291296
line = gpd.GeoDataFrame(
292297
{"id": [1]}, geometry=[LineString([(-2.0, 0.5), (1.5, 0.5)])]
293298
)
294-
splits = split_linestrings(line, grid)
299+
splits = split_linestrings(line, grid, bounded=True)
295300
coords = [list(geom.coords) for geom in splits.geometry]
296301
expected_coords = [
297302
[(-2.0, 0.5), (0.0, 0.5)],
@@ -307,6 +312,17 @@ def test_split_linestrings_partial_overlap(grid):
307312
)
308313
) == [(-1, -1), (0, 0), (1, 0)]
309314

315+
# default behaviour would split even outside grid bounds
316+
splits = split_linestrings(line, grid, bounded=False)
317+
coords = [list(geom.coords) for geom in splits.geometry]
318+
expected_coords = [
319+
[(-2.0, 0.5), (-1.0, 0.5)],
320+
[(-1.0, 0.5), (0.0, 0.5)],
321+
[(0.0, 0.5), (1.0, 0.5)],
322+
[(1.0, 0.5), (1.5, 0.5)],
323+
]
324+
assert coords == expected_coords
325+
310326

311327
def test_box_geom_bounds():
312328
"""Values take from tests/integration/range.tif"""

0 commit comments

Comments
 (0)