Add loop line merger (#1083)

pull/1112/head
Oliver Wipfli 2024-11-25 02:59:37 +01:00 zatwierdzone przez GitHub
rodzic 8a1b54f91f
commit c470dc30d3
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
11 zmienionych plików z 1338 dodań i 22 usunięć

Wyświetl plik

@ -0,0 +1,114 @@
package com.onthegomap.planetiler.benchmarks;
import com.onthegomap.planetiler.geo.GeoUtils;
import com.onthegomap.planetiler.util.Format;
import com.onthegomap.planetiler.util.FunctionThatThrows;
import com.onthegomap.planetiler.util.Gzip;
import com.onthegomap.planetiler.util.LoopLineMerger;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.MathContext;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateXY;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.operation.linemerge.LineMerger;
public class BenchmarkLineMerge {
private static int numLines;
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10; i++) {
time(" JTS", geom -> {
var lm = new LineMerger();
lm.add(geom);
return lm.getMergedLineStrings();
});
time(" loop(0)", geom -> loopMerger(0).add(geom).getMergedLineStrings());
time(" loop(0.1)", geom -> loopMerger(0.1).add(geom).getMergedLineStrings());
time("loop(20.0)", geom -> loopMerger(20).add(geom).getMergedLineStrings());
}
System.err.println(numLines);
}
private static LoopLineMerger loopMerger(double minLength) {
var lm = new LoopLineMerger();
lm.setMinLength(minLength);
lm.setStubMinLength(minLength);
lm.setLoopMinLength(minLength);
lm.setTolerance(1);
lm.setMergeStrokes(true);
return lm;
}
private static void time(String name, FunctionThatThrows<Geometry, Collection<LineString>> fn) throws Exception {
System.err.println(String.join("\t",
name,
timeMillis(read("mergelines_200433_lines.wkb.gz"), fn),
timeMillis(read("mergelines_239823_lines.wkb.gz"), fn),
"(/s):",
timePerSec(read("mergelines_1759_point_line.wkb.gz"), fn),
timePerSec(makeLines(50, 2), fn),
timePerSec(makeLines(10, 10), fn),
timePerSec(makeLines(2, 50), fn)
));
}
private static String timePerSec(Geometry geometry, FunctionThatThrows<Geometry, Collection<LineString>> fn)
throws Exception {
long start = System.nanoTime();
long end = start + Duration.ofSeconds(1).toNanos();
int num = 0;
for (; System.nanoTime() < end;) {
numLines += fn.apply(geometry).size();
num++;
}
return Format.defaultInstance()
.numeric(Math.round(num * 1d / ((System.nanoTime() - start) * 1d / Duration.ofSeconds(1).toNanos())), true);
}
private static String timeMillis(Geometry geometry, FunctionThatThrows<Geometry, Collection<LineString>> fn)
throws Exception {
long start = System.nanoTime();
long end = start + Duration.ofSeconds(1).toNanos();
int num = 0;
for (; System.nanoTime() < end;) {
numLines += fn.apply(geometry).size();
num++;
}
// equivalent of toPrecision(3)
long nanosPer = (System.nanoTime() - start) / num;
var bd = new BigDecimal(nanosPer, new MathContext(3));
return Format.padRight(Duration.ofNanos(bd.longValue()).toString().replace("PT", ""), 6);
}
private static Geometry read(String fileName) throws IOException, ParseException {
var path = Path.of("planetiler-core", "src", "test", "resources", "mergelines", fileName);
byte[] bytes = Gzip.gunzip(Files.readAllBytes(path));
return new WKBReader().read(bytes);
}
private static Geometry makeLines(int lines, int parts) {
List<LineString> result = new ArrayList<>();
double idx = 0;
for (int i = 0; i < lines; i++) {
Coordinate[] coords = new Coordinate[parts];
for (int j = 0; j < parts; j++) {
coords[j] = new CoordinateXY(idx, idx);
idx += 0.5;
}
result.add(GeoUtils.JTS_FACTORY.createLineString(coords));
}
return new GeometryFactory().createMultiLineString(result.toArray(LineString[]::new));
}
}

Wyświetl plik

@ -4,13 +4,13 @@ import com.carrotsearch.hppc.IntArrayList;
import com.carrotsearch.hppc.IntObjectMap;
import com.carrotsearch.hppc.IntStack;
import com.onthegomap.planetiler.collection.Hppc;
import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier;
import com.onthegomap.planetiler.geo.GeoUtils;
import com.onthegomap.planetiler.geo.GeometryException;
import com.onthegomap.planetiler.geo.GeometryType;
import com.onthegomap.planetiler.geo.MutableCoordinateSequence;
import com.onthegomap.planetiler.stats.DefaultStats;
import com.onthegomap.planetiler.stats.Stats;
import com.onthegomap.planetiler.util.LoopLineMerger;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collection;
@ -171,7 +171,12 @@ public class FeatureMerge {
if (groupedFeatures.size() == 1 && buffer == 0d && lengthLimit == 0 && (!resimplify || tolerance == 0)) {
result.add(feature1);
} else {
LineMerger merger = new LineMerger();
LoopLineMerger merger = new LoopLineMerger()
.setTolerance(tolerance)
.setMergeStrokes(true)
.setMinLength(lengthLimit)
.setLoopMinLength(lengthLimit)
.setStubMinLength(0.5);
for (VectorTile.Feature feature : groupedFeatures) {
try {
merger.add(feature.geometry().decode());
@ -180,24 +185,14 @@ public class FeatureMerge {
}
}
List<LineString> outputSegments = new ArrayList<>();
for (Object merged : merger.getMergedLineStrings()) {
if (merged instanceof LineString line && line.getLength() >= lengthLimit) {
// re-simplify since some endpoints of merged segments may be unnecessary
if (line.getNumPoints() > 2 && tolerance >= 0) {
Geometry simplified = DouglasPeuckerSimplifier.simplify(line, tolerance);
if (simplified instanceof LineString simpleLineString) {
line = simpleLineString;
} else {
LOGGER.warn("line string merge simplify emitted {}", simplified.getGeometryType());
}
}
if (buffer >= 0) {
removeDetailOutsideTile(line, buffer, outputSegments);
} else {
outputSegments.add(line);
}
for (var line : merger.getMergedLineStrings()) {
if (buffer >= 0) {
removeDetailOutsideTile(line, buffer, outputSegments);
} else {
outputSegments.add(line);
}
}
if (!outputSegments.isEmpty()) {
outputSegments = sortByHilbertIndex(outputSegments);
Geometry newGeometry = GeoUtils.combineLineStrings(outputSegments);

Wyświetl plik

@ -11,6 +11,8 @@
*/
package com.onthegomap.planetiler.geo;
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
@ -45,6 +47,22 @@ public class DouglasPeuckerSimplifier {
return (new DPTransformer(distanceTolerance)).transform(geom);
}
/**
* Returns a copy of {@code coords}, simplified using Douglas Peucker Algorithm.
*
* @param coords the coordinate list to simplify
* @param distanceTolerance the threshold below which we discard points
* @param area true if this is a polygon to retain at least 4 points to avoid collapse
* @return the simplified coordinate list
*/
public static List<Coordinate> simplify(List<Coordinate> coords, double distanceTolerance, boolean area) {
if (coords.isEmpty()) {
return List.of();
}
return (new DPTransformer(distanceTolerance)).transformCoordinateList(coords, area);
}
private static class DPTransformer extends GeometryTransformer {
private final double sqTolerance;
@ -84,6 +102,42 @@ public class DouglasPeuckerSimplifier {
return dx * dx + dy * dy;
}
private void subsimplify(List<Coordinate> in, List<Coordinate> out, int first, int last, int numForcedPoints) {
// numForcePoints lets us keep some points even if they are below simplification threshold
boolean force = numForcedPoints > 0;
double maxSqDist = force ? -1 : sqTolerance;
int index = -1;
Coordinate p1 = in.get(first);
Coordinate p2 = in.get(last);
double p1x = p1.x;
double p1y = p1.y;
double p2x = p2.x;
double p2y = p2.y;
int i = first + 1;
Coordinate furthest = null;
for (Coordinate coord : in.subList(first + 1, last)) {
double sqDist = getSqSegDist(coord.x, coord.y, p1x, p1y, p2x, p2y);
if (sqDist > maxSqDist) {
index = i;
furthest = coord;
maxSqDist = sqDist;
}
i++;
}
if (force || maxSqDist > sqTolerance) {
if (index - first > 1) {
subsimplify(in, out, first, index, numForcedPoints - 1);
}
out.add(furthest);
if (last - index > 1) {
subsimplify(in, out, index, last, numForcedPoints - 2);
}
}
}
private void subsimplify(CoordinateSequence in, MutableCoordinateSequence out, int first, int last,
int numForcedPoints) {
// numForcePoints lets us keep some points even if they are below simplification threshold
@ -117,6 +171,20 @@ public class DouglasPeuckerSimplifier {
}
}
protected List<Coordinate> transformCoordinateList(List<Coordinate> coords, boolean area) {
if (coords.isEmpty()) {
return coords;
}
// make sure we include the first and last points even if they are closer than the simplification threshold
List<Coordinate> result = new ArrayList<>();
result.add(coords.getFirst());
// for polygons, additionally keep at least 2 intermediate points even if they are below simplification threshold
// to avoid collapse.
subsimplify(coords, result, 0, coords.size() - 1, area ? 2 : 0);
result.add(coords.getLast());
return result;
}
@Override
protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) {
boolean area = parent instanceof LinearRing;

Wyświetl plik

@ -0,0 +1,609 @@
package com.onthegomap.planetiler.util;
import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier;
import com.onthegomap.planetiler.geo.GeoUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import org.locationtech.jts.algorithm.Angle;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateXY;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryComponentFilter;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.operation.linemerge.LineMerger;
/**
* A utility class for merging, simplifying, and connecting linestrings and removing small loops.
* <p>
* Compared to JTS {@link LineMerger} which only connects when 2 lines meet at a single point, this utility:
* <ul>
* <li>snap-rounds points to a grid
* <li>splits lines that intersect at a midpoint
* <li>breaks small loops less than {@code loopMinLength} so only the shortest path connects both endpoints of the loop
* <li>removes short "hair" edges less than {@code stubMinLength} coming off the side of longer segments
* <li>simplifies linestrings, without touching the points shared between multiple lines to avoid breaking connections
* <li>removes any duplicate edges
* <li>at any remaining 3+ way intersections, connect pairs of edges that form the straightest path through the node
* <li>remove any remaining edges shorter than {@code minLength}
* </ul>
*
* @see <a href= "https://oliverwipfli.ch/improving-linestring-merging-in-planetiler-2024-10-30/">Improving Linestring
* Merging in Planetiler</a>
*/
public class LoopLineMerger {
private final List<LineString> input = new ArrayList<>();
private final List<Node> output = new ArrayList<>();
private int numNodes = 0;
private int numEdges = 0;
private PrecisionModel precisionModel = new PrecisionModel(GeoUtils.TILE_PRECISION);
private GeometryFactory factory = new GeometryFactory(precisionModel);
private double minLength = 0.0;
private double loopMinLength = 0.0;
private double stubMinLength = 0.0;
private double tolerance = -1.0;
private boolean mergeStrokes = false;
/**
* Sets the precision model used to snap points to a grid.
* <p>
* Use {@link PrecisionModel#FLOATING} to not snap points at all, or {@code new PrecisionModel(4)} to snap to a 0.25px
* grid.
*/
public LoopLineMerger setPrecisionModel(PrecisionModel precisionModel) {
this.precisionModel = precisionModel;
factory = new GeometryFactory(precisionModel);
return this;
}
/**
* Sets the minimum length for retaining linestrings in the resulting geometry.
* <p>
* Linestrings shorter than this value will be removed. {@code minLength <= 0} disables min length removal.
*/
public LoopLineMerger setMinLength(double minLength) {
this.minLength = minLength;
return this;
}
/**
* Sets the minimum loop length for breaking loops in the merged geometry.
* <p>
* Loops that are shorter than loopMinLength are broken up so that only the shortest path between loop endpoints
* remains. This should be {@code >= minLength}. {@code loopMinLength <= 0} disables loop removal.
*/
public LoopLineMerger setLoopMinLength(double loopMinLength) {
this.loopMinLength = loopMinLength;
return this;
}
/**
* Sets the minimum length of stubs to be removed during processing.
* <p>
* Stubs are short "hair" line segments that hang off of a longer linestring without connecting to anything else.
* {@code stubMinLength <= 0} disables stub removal.
*/
public LoopLineMerger setStubMinLength(double stubMinLength) {
this.stubMinLength = stubMinLength;
return this;
}
/**
* Sets the tolerance for simplifying linestrings during processing. Lines are simplified between endpoints to avoid
* breaking intersections.
* <p>
* {@code tolerance = 0} still removes collinear points, so you need to set {@code tolerance <= 0} to disable
* simplification.
*/
public LoopLineMerger setTolerance(double tolerance) {
this.tolerance = tolerance;
return this;
}
/**
* Enables or disables stroke merging. Stroke merging connects the straightest pairs of linestrings at junctions with
* 3 or more attached linestrings based on the angle between them.
*/
public LoopLineMerger setMergeStrokes(boolean mergeStrokes) {
this.mergeStrokes = mergeStrokes;
return this;
}
/**
* Adds a geometry to the merger. Only linestrings from the input geometry are considered.
*/
public LoopLineMerger add(Geometry geometry) {
geometry.apply((GeometryComponentFilter) component -> {
if (component instanceof LineString lineString) {
input.add(lineString);
}
});
return this;
}
private void degreeTwoMerge() {
for (var node : output) {
degreeTwoMerge(node);
}
output.removeIf(node -> node.getEdges().isEmpty());
assert valid();
}
private boolean valid() {
// when run from a unit test, ensure some basic conditions always hold...
for (var node : output) {
for (var edge : node.getEdges()) {
assert edge.isLoop() || edge.to.getEdges().contains(edge.reversed) : edge.to + " does not contain " +
edge.reversed;
for (var other : node.getEdges()) {
if (edge != other) {
assert edge != other.reversed : "node contained edge and its reverse " + node;
assert !edge.coordinates.equals(other.coordinates) : "duplicate edges " + edge + " and " + other;
}
}
}
assert node.getEdges().size() != 2 || node.getEdges().stream().anyMatch(Edge::isLoop) : "degree 2 node found " +
node;
}
return true;
}
private Edge degreeTwoMerge(Node node) {
if (node.getEdges().size() == 2) {
Edge a = node.getEdges().getFirst();
Edge b = node.getEdges().get(1);
// if one side is a loop, degree is actually > 2
if (!a.isLoop() && !b.isLoop()) {
return mergeTwoEdges(node, a, b);
}
}
return null;
}
private Edge mergeTwoEdges(Node node, Edge edge1, Edge edge2) {
// attempt to preserve segment directions from the original line
// when: A << N -- B then output C reversed from B to A
// when: A >> N -- B then output C from A to B
Edge a = edge1.main ? edge2 : edge1;
Edge b = edge1.main ? edge1 : edge2;
node.getEdges().remove(a);
node.getEdges().remove(b);
List<Coordinate> coordinates = new ArrayList<>();
coordinates.addAll(a.coordinates.reversed());
coordinates.addAll(b.coordinates.subList(1, b.coordinates.size()));
Edge c = new Edge(a.to, b.to, coordinates, a.length + b.length);
a.to.removeEdge(a.reversed);
b.to.removeEdge(b.reversed);
a.to.addEdge(c);
if (a.to != b.to) {
b.to.addEdge(c.reversed);
}
return c;
}
private void strokeMerge() {
for (var node : output) {
List<Edge> edges = List.copyOf(node.getEdges());
if (edges.size() >= 2) {
record AngledPair(Edge a, Edge b, double angle) {}
List<AngledPair> angledPairs = new ArrayList<>();
for (var i = 0; i < edges.size(); ++i) {
var edgei = edges.get(i);
for (var j = i + 1; j < edges.size(); ++j) {
var edgej = edges.get(j);
if (edgei != edgej.reversed) {
double angle = edgei.angleTo(edgej);
angledPairs.add(new AngledPair(edgei, edgej, angle));
}
}
}
angledPairs.sort(Comparator.comparingDouble(angledPair -> angledPair.angle));
List<Edge> merged = new ArrayList<>();
for (var angledPair : angledPairs.reversed()) {
if (merged.contains(angledPair.a) || merged.contains(angledPair.b)) {
continue;
}
mergeTwoEdges(angledPair.a.from, angledPair.a, angledPair.b);
merged.add(angledPair.a);
merged.add(angledPair.b);
}
}
}
}
private void breakLoops() {
for (var node : output) {
if (node.getEdges().size() <= 1) {
continue;
}
for (var current : List.copyOf(node.getEdges())) {
record HasLoop(Edge edge, double distance) {}
List<HasLoop> loops = new ArrayList<>();
if (!node.getEdges().contains(current)) {
continue;
}
for (var other : node.getEdges()) {
double distance = other.length +
shortestDistanceAStar(other.to, current.to, current.from, loopMinLength - other.length);
if (distance <= loopMinLength) {
loops.add(new HasLoop(other, distance));
}
}
if (loops.size() > 1) {
HasLoop min = loops.stream().min(Comparator.comparingDouble(HasLoop::distance)).get();
for (var loop : loops) {
if (loop != min) {
loop.edge.remove();
}
}
}
}
}
}
private double shortestDistanceAStar(Node start, Node end, Node exclude, double maxLength) {
Map<Integer, Double> bestDistance = new HashMap<>();
record Candidate(Node node, double length, double minTotalLength) {}
PriorityQueue<Candidate> frontier = new PriorityQueue<>(Comparator.comparingDouble(Candidate::minTotalLength));
if (exclude != start) {
frontier.offer(new Candidate(start, 0, start.distance(end)));
}
while (!frontier.isEmpty()) {
Candidate candidate = frontier.poll();
Node current = candidate.node;
if (current == end) {
return candidate.length;
}
for (var edge : current.getEdges()) {
var neighbor = edge.to;
if (neighbor != exclude) {
double newDist = candidate.length + edge.length;
double prev = bestDistance.getOrDefault(neighbor.id, Double.POSITIVE_INFINITY);
if (newDist < prev) {
bestDistance.put(neighbor.id, newDist);
double minTotalLength = newDist + neighbor.distance(end);
if (minTotalLength <= maxLength) {
frontier.offer(new Candidate(neighbor, newDist, minTotalLength));
}
}
}
}
}
return Double.POSITIVE_INFINITY;
}
private void removeShortStubEdges() {
PriorityQueue<Edge> toCheck = new PriorityQueue<>(Comparator.comparingDouble(Edge::length));
for (var node : output) {
for (var edge : node.getEdges()) {
if (isShortStubEdge(edge)) {
toCheck.offer(edge);
}
}
}
while (!toCheck.isEmpty()) {
var edge = toCheck.poll();
if (edge.removed) {
continue;
}
if (isShortStubEdge(edge)) {
edge.remove();
}
if (degreeTwoMerge(edge.from) instanceof Edge merged) {
toCheck.offer(merged);
}
if (edge.from.getEdges().size() == 1) {
var other = edge.from.getEdges().getFirst();
if (isShortStubEdge(other)) {
toCheck.offer(other);
}
}
if (edge.from != edge.to) {
if (degreeTwoMerge(edge.to) instanceof Edge merged) {
toCheck.offer(merged);
}
if (edge.to.getEdges().size() == 1) {
var other = edge.to.getEdges().getFirst();
if (isShortStubEdge(other)) {
toCheck.offer(other);
}
}
}
}
}
private boolean isShortStubEdge(Edge edge) {
return edge != null && !edge.removed && edge.length < stubMinLength &&
(edge.from.getEdges().size() == 1 || edge.to.getEdges().size() == 1 || edge.isLoop());
}
private void removeShortEdges() {
for (var node : output) {
for (var edge : List.copyOf(node.getEdges())) {
if (edge.length < minLength) {
edge.remove();
}
}
}
}
private void simplify() {
List<Edge> toRemove = new ArrayList<>();
for (var node : output) {
for (var edge : node.getEdges()) {
if (edge.main) {
edge.simplify();
if (edge.isCollapsed()) {
toRemove.add(edge);
}
}
}
}
toRemove.forEach(Edge::remove);
}
private void removeDuplicatedEdges() {
for (var node : output) {
List<Edge> toRemove = new ArrayList<>();
for (var i = 0; i < node.getEdges().size(); ++i) {
Edge a = node.getEdges().get(i);
for (var j = i + 1; j < node.getEdges().size(); ++j) {
Edge b = node.getEdges().get(j);
if (b.to == a.to && a.coordinates.equals(b.coordinates)) {
toRemove.add(b);
}
}
}
for (var edge : toRemove) {
edge.remove();
}
}
}
/**
* Processes the added geometries and returns the merged linestrings.
* <p>
* Can be called more than once.
*/
public List<LineString> getMergedLineStrings() {
output.clear();
List<List<Coordinate>> edges = nodeLines(input);
buildNodes(edges);
degreeTwoMerge();
if (loopMinLength > 0.0) {
breakLoops();
degreeTwoMerge();
}
if (stubMinLength > 0.0) {
removeShortStubEdges();
// removeShortStubEdges does degreeTwoMerge internally
}
if (tolerance >= 0.0) {
simplify();
removeDuplicatedEdges();
degreeTwoMerge();
}
if (mergeStrokes) {
strokeMerge();
degreeTwoMerge();
}
if (minLength > 0) {
removeShortEdges();
}
List<LineString> result = new ArrayList<>();
for (var node : output) {
for (var edge : node.getEdges()) {
if (edge.main) {
result.add(factory.createLineString(edge.coordinates.toArray(Coordinate[]::new)));
}
}
}
return result;
}
private static double length(List<Coordinate> edge) {
Coordinate last = null;
double length = 0;
for (Coordinate coord : edge) {
if (last != null) {
length += last.distance(coord);
}
last = coord;
}
return length;
}
private void buildNodes(List<List<Coordinate>> edges) {
Map<Coordinate, Node> nodes = new HashMap<>();
for (var coordinateSequence : edges) {
Coordinate first = coordinateSequence.getFirst();
Node firstNode = nodes.get(first);
if (firstNode == null) {
firstNode = new Node(first);
nodes.put(first, firstNode);
output.add(firstNode);
}
Coordinate last = coordinateSequence.getLast();
Node lastNode = nodes.get(last);
if (lastNode == null) {
lastNode = new Node(last);
nodes.put(last, lastNode);
output.add(lastNode);
}
double length = length(coordinateSequence);
Edge edge = new Edge(firstNode, lastNode, coordinateSequence, length);
firstNode.addEdge(edge);
if (firstNode != lastNode) {
lastNode.addEdge(edge.reversed);
}
}
}
private List<List<Coordinate>> nodeLines(List<LineString> input) {
Map<Coordinate, Integer> nodeCounts = new HashMap<>();
List<List<Coordinate>> coords = new ArrayList<>(input.size());
for (var line : input) {
var coordinateSequence = line.getCoordinateSequence();
List<Coordinate> snapped = new ArrayList<>();
Coordinate last = null;
for (int i = 0; i < coordinateSequence.size(); i++) {
Coordinate current = new CoordinateXY(coordinateSequence.getX(i), coordinateSequence.getY(i));
precisionModel.makePrecise(current);
if (last == null || !last.equals(current)) {
snapped.add(current);
nodeCounts.merge(current, 1, Integer::sum);
}
last = current;
}
if (snapped.size() >= 2) {
coords.add(snapped);
}
}
List<List<Coordinate>> result = new ArrayList<>(input.size());
for (var coordinateSequence : coords) {
int start = 0;
for (int i = 0; i < coordinateSequence.size(); i++) {
Coordinate coordinate = coordinateSequence.get(i);
if (i > 0 && i < coordinateSequence.size() - 1 && nodeCounts.get(coordinate) > 1) {
result.add(coordinateSequence.subList(start, i + 1));
start = i;
}
}
if (start < coordinateSequence.size()) {
var sublist = start == 0 ? coordinateSequence : coordinateSequence.subList(start, coordinateSequence.size());
result.add(sublist);
}
}
return result;
}
private class Node {
final int id = numNodes++;
final List<Edge> edge = new ArrayList<>();
Coordinate coordinate;
Node(Coordinate coordinate) {
this.coordinate = coordinate;
}
void addEdge(Edge edge) {
for (Edge other : this.edge) {
if (other.coordinates.equals(edge.coordinates)) {
return;
}
}
this.edge.add(edge);
}
List<Edge> getEdges() {
return edge;
}
void removeEdge(Edge edge) {
this.edge.remove(edge);
}
@Override
public String toString() {
return "Node{" + id + ": " + edge + '}';
}
double distance(Node end) {
return coordinate.distance(end.coordinate);
}
}
private class Edge {
final int id;
final Node from;
final Node to;
final double length;
final boolean main;
boolean removed;
Edge reversed;
List<Coordinate> coordinates;
private Edge(Node from, Node to, List<Coordinate> coordinateSequence, double length) {
this(numEdges, from, to, length, coordinateSequence, true, null);
reversed = new Edge(numEdges, to, from, length, coordinateSequence.reversed(), false, this);
numEdges++;
}
private Edge(int id, Node from, Node to, double length, List<Coordinate> coordinates, boolean main, Edge reversed) {
this.id = id;
this.from = from;
this.to = to;
this.length = length;
this.coordinates = coordinates;
this.main = main;
this.reversed = reversed;
}
void remove() {
if (!removed) {
from.removeEdge(this);
to.removeEdge(reversed);
removed = true;
}
}
double angleTo(Edge other) {
assert from.equals(other.from);
assert coordinates.size() >= 2;
double angle = Angle.angle(coordinates.get(0), coordinates.get(1));
double angleOther = Angle.angle(other.coordinates.get(0), other.coordinates.get(1));
return Math.abs(Angle.normalize(angle - angleOther));
}
double length() {
return length;
}
void simplify() {
coordinates = DouglasPeuckerSimplifier.simplify(coordinates, tolerance, false);
if (reversed != null) {
reversed.coordinates = coordinates.reversed();
}
}
boolean isCollapsed() {
return coordinates.size() < 2 ||
(coordinates.size() == 2 && coordinates.getFirst().equals(coordinates.getLast()));
}
boolean isLoop() {
return from == to;
}
@Override
public String toString() {
return "Edge{" + from.id + "->" + to.id + (main ? "" : "(R)") + ": [" + coordinates.getFirst() + ".." +
coordinates.getLast() + "], length=" + length + '}';
}
}
}

Wyświetl plik

@ -4,9 +4,13 @@ import static com.onthegomap.planetiler.TestUtils.assertSameNormalizedFeature;
import static com.onthegomap.planetiler.TestUtils.newLineString;
import static com.onthegomap.planetiler.TestUtils.newPolygon;
import static com.onthegomap.planetiler.TestUtils.rectangle;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Polygonal;
import org.locationtech.jts.geom.util.AffineTransformation;
class DouglasPeuckerSimplifierTest {
@ -16,10 +20,18 @@ class DouglasPeuckerSimplifierTest {
private void testSimplify(Geometry in, Geometry expected, double amount) {
for (int rotation : rotations) {
var rotate = AffineTransformation.rotationInstance(Math.PI * rotation / 180);
var expRot = rotate.transform(expected);
var inRot = rotate.transform(in);
assertSameNormalizedFeature(
rotate.transform(expected),
DouglasPeuckerSimplifier.simplify(rotate.transform(in), amount)
expRot,
DouglasPeuckerSimplifier.simplify(inRot, amount)
);
// ensure the List<Coordinate> version also works...
List<Coordinate> inList = List.of(inRot.getCoordinates());
List<Coordinate> expList = List.of(expRot.getCoordinates());
List<Coordinate> actual = DouglasPeuckerSimplifier.simplify(inList, amount, in instanceof Polygonal);
assertEquals(expList, actual);
}
}
@ -65,8 +77,8 @@ class DouglasPeuckerSimplifierTest {
rectangle(0, 10),
newPolygon(
0, 0,
10, 10,
10, 0,
10, 10,
0, 0
),
20

Wyświetl plik

@ -0,0 +1,518 @@
package com.onthegomap.planetiler.util;
import static com.onthegomap.planetiler.TestUtils.newLineString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.onthegomap.planetiler.TestUtils;
import com.onthegomap.planetiler.geo.GeoUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.operation.linemerge.LineMerger;
class LoopLineMergerTest {
@Test
void testMergeTouchingLinestrings() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setStubMinLength(-1)
.setTolerance(-1)
.setLoopMinLength(-1);
merger.add(newLineString(10, 10, 20, 20));
merger.add(newLineString(20, 20, 30, 30));
assertEquals(
List.of(newLineString(10, 10, 20, 20, 30, 30)),
merger.getMergedLineStrings()
);
}
@Test
void testKeepTwoSeparateLinestring() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setLoopMinLength(-1);
merger.add(newLineString(10, 10, 20, 20));
merger.add(newLineString(30, 30, 40, 40));
assertEquals(
List.of(
newLineString(10, 10, 20, 20),
newLineString(30, 30, 40, 40)
),
merger.getMergedLineStrings()
);
}
@Test
void testDoesNotOvercountAlreadyAddedLines() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setTolerance(-1)
.setStubMinLength(-1)
.setLoopMinLength(-1);
merger.add(newLineString(10, 10, 20, 20));
merger.add(newLineString(20, 20, 30, 30));
merger.add(newLineString(20, 20, 30, 30));
assertEquals(
List.of(newLineString(10, 10, 20, 20, 30, 30)),
merger.getMergedLineStrings()
);
}
@Test
void testSplitLinestringsBeforeMerging() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setLoopMinLength(-1)
.setStubMinLength(-1)
.setTolerance(-1);
merger.add(newLineString(10, 10, 20, 20, 30, 30));
merger.add(newLineString(20, 20, 30, 30, 40, 40));
assertEquals(
List.of(newLineString(10, 10, 20, 20, 30, 30, 40, 40)),
merger.getMergedLineStrings()
);
}
@Test
void testProgressiveStubRemoval() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setStubMinLength(4)
.setLoopMinLength(-1)
.setTolerance(-1);
merger.add(newLineString(0, 0, 5, 0)); // stub length 5
merger.add(newLineString(5, 0, 6, 0)); // mid piece
merger.add(newLineString(6, 0, 8, 0)); // stub length 2
merger.add(newLineString(5, 0, 5, 1)); // stub length 1
merger.add(newLineString(6, 0, 6, 1)); // stub length 1
assertEquals(
List.of(newLineString(0, 0, 5, 0, 6, 0, 8, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testRoundCoordinatesBeforeMerging() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setLoopMinLength(-1)
.setStubMinLength(-1)
.setTolerance(-1);
merger.add(newLineString(10.00043983098, 10, 20, 20));
merger.add(newLineString(20, 20, 30, 30));
assertEquals(
List.of(newLineString(10, 10, 20, 20, 30, 30)),
merger.getMergedLineStrings()
);
}
@Test
void testRemoveSmallLoops() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setStubMinLength(-1)
.setTolerance(-1)
.setLoopMinLength(100);
merger.add(newLineString(
10, 10,
20, 10,
30, 10,
30, 20,
40, 20
));
merger.add(newLineString(
20, 10,
30, 20
));
assertEquals(
List.of(
newLineString(
10, 10,
20, 10,
30, 20,
40, 20
)
),
merger.getMergedLineStrings()
);
}
@Test
void testRemoveSelfClosingLoops() {
// Note that self-closing loops are considered stubs.
// They are removed by stubMinLength, not loopMinLength...
var merger = new LoopLineMerger()
.setMinLength(-1)
.setTolerance(-1)
.setStubMinLength(5)
.setLoopMinLength(-1);
merger.add(newLineString(
1, -10,
1, 1,
1, 2,
0, 2,
0, 1,
1, 1,
10, 1));
assertEquals(
List.of(newLineString(1, -10, 1, 1, 10, 1)),
merger.getMergedLineStrings()
);
}
@Test
void testDoNotRemoveLargeLoops() {
var merger = new LoopLineMerger()
.setMinLength(-1)
.setLoopMinLength(0.001);
merger.add(newLineString(
10, 10,
20, 10,
30, 10,
30, 20,
40, 20
));
merger.add(newLineString(
20, 10,
30, 20
));
assertEquals(
List.of(
newLineString(
10, 10,
20, 10
),
newLineString(
20, 10,
30, 10,
30, 20
),
newLineString(
20, 10,
30, 20
),
newLineString(
30, 20,
40, 20
)
),
merger.getMergedLineStrings()
);
}
@Test
void testRemoveShortLine() {
var merger = new LoopLineMerger()
.setMinLength(10)
.setStubMinLength(-1)
.setTolerance(-1)
.setLoopMinLength(-1);
merger.add(newLineString(10, 10, 11, 11));
merger.add(newLineString(20, 20, 30, 30));
merger.add(newLineString(30, 30, 40, 40));
assertEquals(
List.of(newLineString(20, 20, 30, 30, 40, 40)),
merger.getMergedLineStrings()
);
}
@Test
void testRemovesShortStubsTheNonStubsThatAreTooShort() {
var merger = new LoopLineMerger()
.setMinLength(0)
.setLoopMinLength(-1)
.setStubMinLength(15)
.setTolerance(-1);
merger.add(newLineString(0, 0, 20, 0));
merger.add(newLineString(20, 0, 30, 0));
merger.add(newLineString(30, 0, 50, 0));
merger.add(newLineString(20, 0, 20, 10));
merger.add(newLineString(30, 0, 30, 10));
assertEquals(
List.of(newLineString(0, 0, 20, 0, 30, 0, 50, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testMergeCarriagewaysWithOneSplitShorterThanLoopMinLength() {
var merger = new LoopLineMerger()
.setMinLength(20)
.setMergeStrokes(true)
.setLoopMinLength(20);
merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0));
merger.add(newLineString(30, 0, 20, 0, 15, 1, 10, 0, 0, 0));
assertEquals(
List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testMergeCarriagewaysWithOneSplitLongerThanLoopMinLength() {
var merger = new LoopLineMerger()
.setMinLength(5)
.setMergeStrokes(true)
.setLoopMinLength(5);
merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0));
merger.add(newLineString(30, 0, 20, 0, 15, 1, 10, 0, 0, 0));
assertEquals(
// ideally loop merging should connect long line strings and represent loops as separate segments off of the edges
List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0), newLineString(20, 0, 15, 1, 10, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testMergeCarriagewaysWithTwoSplits() {
var merger = new LoopLineMerger()
.setMinLength(20)
.setMergeStrokes(true)
.setLoopMinLength(20);
merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0, 40, 0));
merger.add(newLineString(40, 0, 30, 0, 25, 5, 20, 0, 15, 5, 10, 0, 0, 0));
assertEquals(
List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0, 40, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testMergeLoopAttachedToStub() {
var merger = new LoopLineMerger()
.setMinLength(10)
.setLoopMinLength(10)
.setStubMinLength(10)
.setTolerance(-1);
merger.add(newLineString(-20, 0, 0, 0, 20, 0));
merger.add(newLineString(0, 0, 0, 1));
merger.add(newLineString(0, 1, 1, 2, 1, 1, 0, 1));
assertEquals(
List.of(newLineString(-20, 0, 0, 0, 20, 0)),
merger.getMergedLineStrings()
);
}
@Test
void testRealWorldHarkingen() {
var merger = new LoopLineMerger()
.setMinLength(4 * 0.0625)
.setLoopMinLength(8 * 0.0625);
merger.add(
newLineString(99.185791015625, 109.83056640625, 99.202392578125, 109.8193359375, 99.21337890625, 109.810302734375,
99.222412109375, 109.8017578125, 99.229736328125, 109.793701171875, 99.241943359375, 109.779541015625));
merger.add(newLineString(98.9931640625, 109.863525390625, 99.005126953125, 109.862060546875, 99.01708984375,
109.86083984375, 99.028564453125, 109.85986328125, 99.040283203125, 109.859375, 99.0712890625, 109.85791015625,
99.08203125, 109.857421875, 99.093017578125, 109.856689453125, 99.104248046875, 109.855712890625, 99.115478515625,
109.8544921875, 99.12646484375, 109.852783203125, 99.1376953125, 109.850341796875, 99.1474609375, 109.84765625,
99.15673828125, 109.844482421875, 99.166748046875, 109.84033203125, 99.175537109375, 109.836181640625,
99.185791015625, 109.83056640625));
merger.add(newLineString(99.162841796875, 109.812744140625, 99.0966796875, 109.824462890625, 99.055419921875,
109.832275390625, 99.008544921875, 109.842041015625, 98.967529296875, 109.8525390625, 98.8818359375,
109.875244140625));
merger.add(newLineString(98.879150390625, 109.885498046875, 98.94091796875, 109.86572265625, 98.968017578125,
109.859130859375, 99.017578125, 109.847412109375, 99.056396484375, 109.83984375, 99.09814453125, 109.831298828125,
99.163330078125, 109.81982421875));
var merged = merger.getMergedLineStrings();
assertEquals(
1,
merged.size()
);
}
@ParameterizedTest
@CsvSource({
"mergelines_1759_point_line.wkb.gz,0,false,3",
"mergelines_1759_point_line.wkb.gz,1,false,2",
"mergelines_1759_point_line.wkb.gz,1,true,2",
"mergelines_200433_lines.wkb.gz,0,false,9103",
"mergelines_200433_lines.wkb.gz,0.1,false,8834",
"mergelines_200433_lines.wkb.gz,1,false,861",
"mergelines_200433_lines.wkb.gz,1,true,508",
"mergelines_239823_lines.wkb.gz,0,false,6188",
"mergelines_239823_lines.wkb.gz,0.1,false,5941",
"mergelines_239823_lines.wkb.gz,1,false,826",
"mergelines_239823_lines.wkb.gz,1,true,681",
"i90.wkb.gz,0,false,17",
"i90.wkb.gz,1,false,18",
"i90.wkb.gz,20,false,4",
"i90.wkb.gz,30,false,1",
})
void testOnRealWorldData(String file, double minLengths, boolean simplify, int expected)
throws IOException, ParseException {
Geometry geom = new WKBReader(GeoUtils.JTS_FACTORY).read(
Gzip.gunzip(Files.readAllBytes(TestUtils.pathToResource("mergelines").resolve(file))));
var merger = new LoopLineMerger();
merger.setMinLength(minLengths);
merger.setLoopMinLength(minLengths);
merger.setStubMinLength(minLengths);
merger.setMergeStrokes(true);
merger.setTolerance(simplify ? 1 : -1);
merger.add(geom);
var merged = merger.getMergedLineStrings();
Set<List<Coordinate>> lines = new HashSet<>();
var merger2 = new LineMerger();
for (var line : merged) {
merger2.add(line);
assertTrue(lines.add(Arrays.asList(line.getCoordinates())), "contained duplicate: " + line);
if (minLengths > 0 && !simplify) { // simplification can make an edge < min length
assertTrue(line.getLength() >= minLengths, "line < " + minLengths + ": " + line);
}
}
// ensure there are no more opportunities for simplification found by JTS:
List<LineString> loop = List.copyOf(merged);
List<LineString> jts = merger2.getMergedLineStrings().stream().map(LineString.class::cast).toList();
List<LineString> missing = jts.stream().filter(l -> !loop.contains(l)).toList();
List<LineString> extra = loop.stream().filter(l -> !jts.contains(l)).toList();
assertEquals(List.of(), missing, "missing edges");
assertEquals(List.of(), extra, "extra edges");
assertEquals(merged.size(), merger2.getMergedLineStrings().size());
assertEquals(expected, merged.size());
}
@Test
void testMergeStrokesAt3WayIntersectionWithLoop() {
var merger = new LoopLineMerger()
.setMinLength(1)
.setLoopMinLength(1)
.setStubMinLength(1)
.setMergeStrokes(true);
merger.add(newLineString(-5, 0, 0, 0));
merger.add(newLineString(0, 0, 5, 0, 5, 5, 0, 5, 0, 0));
assertEquals(
List.of(
newLineString(-5, 0, 0, 0, 5, 0, 5, 5, 0, 5, 0, 0)
),
merger.getMergedLineStrings()
);
}
@Test
void testMergeStrokesAt3WayIntersectionWithLoop2() {
var merger = new LoopLineMerger()
.setMinLength(1)
.setLoopMinLength(1)
.setStubMinLength(1)
.setMergeStrokes(true);
merger.add(newLineString(-5, 0, 0, 0));
merger.add(newLineString(0, 0, 0, -1, 5, 0, 5, 5, 0, 5, 0, 0));
assertEquals(
List.of(
newLineString(
-5, 0, 0, 0, 0, -1, 5, 0, 5, 5, 0, 5, 0, 0
)
),
merger.getMergedLineStrings()
);
}
@Test
void testMergeStrokesAt3WayIntersection() {
var merger = new LoopLineMerger()
.setMinLength(1)
.setLoopMinLength(1)
.setStubMinLength(1)
.setMergeStrokes(true);
merger.add(newLineString(-5, 0, 0, 0));
merger.add(newLineString(0, 0, 5, 0));
merger.add(newLineString(0, 0, 0, 5));
assertEquals(
List.of(
newLineString(-5, 0, 0, 0, 5, 0),
newLineString(0, 0, 0, 5)
),
merger.getMergedLineStrings()
);
}
@Test
void testMergeStrokesAt4WayIntersection() {
var merger = new LoopLineMerger()
.setMinLength(1)
.setLoopMinLength(1)
.setStubMinLength(1)
.setMergeStrokes(true);
merger.add(newLineString(-5, 0, 0, 0));
merger.add(newLineString(0, 0, 5, 0));
merger.add(newLineString(0, 0, 0, 5));
merger.add(newLineString(0, 0, 0, -5));
assertEquals(
List.of(
newLineString(-5, 0, 0, 0, 5, 0),
newLineString(0, -5, 0, 0, 0, 5)
),
merger.getMergedLineStrings()
);
}
@Test
void testMergeStrokesAt5WayIntersection() {
var merger = new LoopLineMerger()
.setMinLength(1)
.setLoopMinLength(1)
.setStubMinLength(1)
.setMergeStrokes(true);
merger.add(newLineString(-5, 0, 0, 0));
merger.add(newLineString(0, 0, 5, 0));
merger.add(newLineString(0, 0, 0, 5));
merger.add(newLineString(0, 0, 0, -5));
merger.add(newLineString(0, 0, 5, 5));
assertEquals(
List.of(
newLineString(-5, 0, 0, 0, 5, 0),
newLineString(0, 0, 5, 5),
newLineString(0, -5, 0, 0, 0, 5)
),
merger.getMergedLineStrings()
);
}
}

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -109,7 +109,7 @@ class BikeRouteOverlayTest {
.assertNumFeatures(mbtiles, "bicycle-route-international", 14, Map.of(
"name", "EuroVelo 8 - Mediterranean Route - part Monaco",
"ref", "EV8"
), GeoUtils.WORLD_LAT_LON_BOUNDS, 25, LineString.class);
), GeoUtils.WORLD_LAT_LON_BOUNDS, 13, LineString.class);
TestUtils.assertTileDuplicates(mbtiles, 0);
}