MATSIM
ConcaveHull.java
Go to the documentation of this file.
1 /* *********************************************************************** *
2  * project: org.matsim.*
3  * *
4  * *********************************************************************** *
5  * *
6  * copyright : (C) 2013 by the members listed in the COPYING, *
7  * LICENSE and WARRANTY file. *
8  * email : info at matsim dot org *
9  * *
10  * *********************************************************************** *
11  * *
12  * This program is free software; you can redistribute it and/or modify *
13  * it under the terms of the GNU General Public License as published by *
14  * the Free Software Foundation; either version 2 of the License, or *
15  * (at your option) any later version. *
16  * See also COPYING, LICENSE and WARRANTY file *
17  * *
18  * *********************************************************************** */
19 
20 package org.matsim.core.network.algorithms.intersectionSimplifier.containers;
21 
22 import java.io.*;
23 import java.util.*;
24 
25 import org.apache.commons.csv.CSVFormat;
26 import org.apache.commons.csv.CSVPrinter;
27 import org.apache.logging.log4j.LogManager;
28 import org.apache.logging.log4j.Logger;
29 import org.locationtech.jts.geom.*;
30 import org.locationtech.jts.operation.linemerge.LineMerger;
31 import org.locationtech.jts.triangulate.DelaunayTriangulationBuilder;
32 import org.locationtech.jts.triangulate.quadedge.QuadEdge;
33 import org.locationtech.jts.triangulate.quadedge.QuadEdgeSubdivision;
34 import org.locationtech.jts.triangulate.quadedge.QuadEdgeTriangle;
35 import org.locationtech.jts.triangulate.quadedge.Vertex;
36 import org.locationtech.jts.util.UniqueCoordinateArrayFilter;
37 import org.matsim.core.utils.io.IOUtils;
38 
57 public class ConcaveHull {
58  private final static Logger LOG = LogManager.getLogger(ConcaveHull.class);
59  private final GeometryFactory geomFactory;
60  private final GeometryCollection filteredPoints;
61  private final double threshold;
62 
63  private final HashMap<LineSegment, Integer> segments = new HashMap<>();
64  private final HashMap<Integer, HullEdge> edges = new HashMap<>();
65  private final HashMap<Integer, HullTriangle> triangles = new HashMap<>();
66 
67  private final TreeMap<Integer, HullEdge> consideredEdges = new TreeMap<>();
68  private final HashMap<Integer, HullEdge> ignoredEdges = new HashMap<>();
69 
70  private final Map<Coordinate, Integer> coordinates = new HashMap<>();
71  private final Map<Integer, HullNode> vertices = new HashMap<>();
72 
73  private final String[] hullHeader = new String[]{"iteration", "firstX", "firstY", "secondX", "secondY"};
74  private final boolean printOutput;
75  private final String fileTriangles;
76  private final String fileBorder;
77 
78 
79 
94  public ConcaveHull(GeometryCollection points, double threshold) {
95  this(points, threshold, null);
96  }
97 
113  public ConcaveHull(GeometryCollection points, double threshold, String folderOutput) {
114  this.geomFactory = points.getFactory();
115 
116  /* Ensure a unique set of input points. */
117  UniqueCoordinateArrayFilter filter = new UniqueCoordinateArrayFilter();
118  points.apply(filter);
119  Coordinate[] ca = filter.getCoordinates();
120 
121  /* Convert the filtered points into GeometryCollection. */
122  Geometry[] ga = new Geometry[ca.length];
123  for (int i = 0; i < ca.length; i++) {
124  ga[i] = geomFactory.createPoint(ca[i]);
125  }
126  this.filteredPoints = new GeometryCollection(ga, geomFactory);
127 
128  this.threshold = threshold;
129 
130  /* Set up the output (if required) */
131  if (folderOutput != null) {
132  folderOutput += folderOutput.endsWith("/") ? "" : "/";
133  File folder = new File(folderOutput);
134  if (!folder.exists()) {
135  boolean created = folder.mkdirs();
136  if (!created) {
137  LOG.error("Could not create the output folder '" + folderOutput + "'; this may crash down the line.");
138  }
139  }
140  this.fileTriangles = String.format("%sThreshold_%.0f_triangles.csv", folderOutput, this.threshold);
141  this.fileBorder = String.format("%sThreshold_%.0f_border.csv", folderOutput, this.threshold);
142  this.printOutput = true;
143  } else {
144  this.fileTriangles = null;
145  this.fileBorder = null;
146  this.printOutput = false;
147  }
148 
149  /* Print the header for the iterations' output. */
150  if (this.printOutput) {
151  printHeader(this.fileTriangles);
152  printHeader(this.fileBorder);
153  }
154  }
155 
156  private void printHeader(String filename) {
157  try (BufferedWriter writer = IOUtils.getBufferedWriter(filename);
158  CSVPrinter printer = new CSVPrinter(writer, CSVFormat.Builder.create().setHeader(hullHeader).build())) {
159  printer.println();
160  } catch (IOException e) {
161  throw new RuntimeException("Could not write header to " + filename);
162  }
163  }
164 
165 
182  public Geometry getConcaveHull() {
183  return getConcaveHull("");
184  }
185 
186 
205  public Geometry getConcaveHull(String facilityIdentifier) {
206  int numberOfGeometries = this.filteredPoints.getNumGeometries();
207  return switch (numberOfGeometries) {
208  case 0 -> this.geomFactory.createGeometryCollection(null);
209  case 1 -> this.filteredPoints.getGeometryN(0);
210  case 2 -> this.geomFactory.createLineString(this.filteredPoints.getCoordinates());
211  default -> concaveHull(facilityIdentifier);
212  };
213  }
214 
215 
216  private Geometry concaveHull(String facilityIdentifier) {
217  /* Construct the Delaunay Triangulation. */
218  DelaunayTriangulationBuilder dtb = new DelaunayTriangulationBuilder();
219  dtb.setSites(this.filteredPoints);
220 
221  QuadEdgeSubdivision qes = dtb.getSubdivision();
222 
223  /*TODO Sort out the following warnings...*/
224  Collection<QuadEdge> quadEdges = qes.getEdges();
225 
226  List<QuadEdgeTriangle> qeTriangles = QuadEdgeTriangle.createOn(qes);
227  Collection<Vertex> qeVertices = qes.getVertices(false);
228 
229  /*TODO Remove after debugging. It seems that some facilities, although
230  * they have three or more points, do not have a 'valid' Delaunay
231  * triangulation. */
232  if (qeTriangles.isEmpty() || qeVertices.isEmpty()) {
233  LOG.warn("No triangulation for " + this.filteredPoints.getNumPoints() + " points!!");
234  LOG.warn(" --> Unique id for the group of points: " + facilityIdentifier);
235  Coordinate[] ca = this.filteredPoints.getCoordinates();
236  if (ca.length == 3) {
237  LOG.warn(" --> Instead, a polygon (triangle) of the three points will be returned.");
238  Coordinate[] caClosed = new Coordinate[4];
239  caClosed[0] = ca[0];
240  caClosed[1] = ca[1];
241  caClosed[2] = ca[2];
242  caClosed[3] = ca[0];
243  return this.geomFactory.createPolygon(this.geomFactory.createLinearRing(caClosed), null);
244  } else {
245  LOG.warn(" --> Returning a single point geometry as the weighted coordinates of the filtered points.");
246  double xSum = 0;
247  double ySum = 0;
248  for (Coordinate c : this.filteredPoints.getCoordinates()) {
249  xSum += c.x;
250  ySum += c.y;
251  }
252  double newX = xSum / ((double) this.filteredPoints.getCoordinates().length);
253  double newY = ySum / ((double) this.filteredPoints.getCoordinates().length);
254 
255  return this.geomFactory.createPoint(new Coordinate(newX, newY));
256  }
257  }
258 
259  /* Create index maps for the nodes/vertices. */
260  int nodeId = 0;
261  for (Vertex v : qeVertices) {
262  this.coordinates.put(v.getCoordinate(), nodeId);
263  this.vertices.put(nodeId, new HullNode(nodeId, v.getCoordinate()));
264  nodeId++;
265  }
266 
267  /* Identify the list of boundary edges. To do that:
268  * - Find all edges that are `frame-border' QuadEdges;
269  * - Find all edges that are `frame' QuadEdges;
270  * - Those QuadEdges that are frame-border, but NOT frame; must
271  * therefore be `border' QuadEdges.
272  *
273  * (courtesy of Microsoft Copilot)
274  * Frame edges: Frame Edges: These are the edges that form the outer
275  * boundary of the convex hull of the set of points being
276  * triangulated. The convex hull is the smallest convex polygon
277  * that can enclose all the points. Frame edges are part of this
278  * polygon and are crucial in defining the overall shape of the
279  * triangulation.
280  * Border edges: These are the edges that lie on the boundary of the
281  * triangulation but are not necessarily part of the convex hull.
282  * They can be internal edges that connect to the outermost points
283  * but do not form the convex hull itself. Border edges help in
284  * defining the limits of the triangulated area within the convex
285  * hull.
286  */
287  List<QuadEdge> qeFrameBorder = new ArrayList<>();
288  List<QuadEdge> qeFrame = new ArrayList<>();
289  List<QuadEdge> qeBorder = new ArrayList<>();
290 
291  for (QuadEdge qe : quadEdges) {
292  if (qes.isFrameBorderEdge(qe)) {
293  qeFrameBorder.add(qe);
294  }
295  if (qes.isFrameEdge(qe)) {
296  qeFrame.add(qe);
297  }
298  }
299 
300  /* Now identify the border edges. */
301  for (QuadEdge q : qeFrameBorder) {
302  if (!qeFrame.contains(q)) {
303  qeBorder.add(q);
304  }
305  }
306 
307  /* Remove all the QuadEdges that were artificially added as a result
308  * of the Delaunay Triangulation process. */
309  //FIXME Is this the best? Why not create a new data structure with only those we want.
310  for (QuadEdge q : qeFrame) {
311  qes.delete(q);
312  quadEdges.remove(q);
313  }
314 
315  Comparator<QuadEdge> comparator = Comparator.comparingDouble(QuadEdge::getLength);
316  List<QuadEdge> sortedEdges = new ArrayList<>(quadEdges);
317  sortedEdges.sort(comparator);
318 
319  /* Initialise the vertex boundary function. Set all vertices'
320  * boundary attribute to `false'.
321  *
322  * This was done by default when `Node's were created. */
323 
324  /* Create the dual-directional map for all edges. For each boundary edge,
325  * set the associated nodes' boundary attribute to `true'. */
326  int edgeId = 0;
327  for (QuadEdge qe : sortedEdges) {
328  LineSegment ls = qe.toLineSegment();
329  ls.normalize();
330 
331  /* Get the nodes associated with the coordinates. */
332  Integer idOrigin = this.coordinates.get(ls.p0);
333  Integer idDestination = this.coordinates.get(ls.p1);
334  HullNode nodeOrigin = this.vertices.get(idOrigin);
335  HullNode nodeDestination = this.vertices.get(idDestination);
336 
337  HullEdge edge;
338  if (qeBorder.contains(qe)) {
339  nodeOrigin.setBorder(true);
340  nodeDestination.setBorder(true);
341  edge = new HullEdge(edgeId, ls, nodeOrigin, nodeDestination, true);
342  if (ls.getLength() < this.threshold) {
343  this.ignoredEdges.put(edgeId, edge);
344  } else {
345  this.consideredEdges.put(edgeId, edge);
346  }
347  } else {
348  edge = new HullEdge(edgeId, ls, nodeOrigin, nodeDestination, false);
349  }
350  this.edges.put(edgeId, edge);
351  this.segments.put(ls, edgeId);
352  edgeId++;
353  }
354 
355  /* Link the edges to their respective triangles. */
356  int triangleId = 0;
357  for (QuadEdgeTriangle qet : qeTriangles) {
358  LineSegment lsA = qet.getEdge(0).toLineSegment();
359  LineSegment lsB = qet.getEdge(1).toLineSegment();
360  LineSegment lsC = qet.getEdge(2).toLineSegment();
361  lsA.normalize();
362  lsB.normalize();
363  lsC.normalize();
364 
365  HullEdge edgeA = this.edges.get(this.segments.get(lsA));
366  HullEdge edgeB = this.edges.get(this.segments.get(lsB));
367  HullEdge edgeC = this.edges.get(this.segments.get(lsC));
368 
369  HullTriangle triangle = new HullTriangle(triangleId);
370  triangle.addEdge(edgeA);
371  triangle.addEdge(edgeB);
372  triangle.addEdge(edgeC);
373 
374  edgeA.addTriangle(triangle);
375  edgeB.addTriangle(triangle);
376  edgeC.addTriangle(triangle);
377 
378  this.triangles.put(triangleId, triangle);
379  triangleId++;
380  }
381 
382  /* For each edge, check if it belongs to more than one triangle. If so,
383  * link the triangles as neighbours. */
384  for (HullEdge edge : this.edges.values()) {
385  int numberOfTriangles = edge.getTriangles().size();
386  if (numberOfTriangles > 1) {
387  HullTriangle tA = edge.getTriangles().get(0);
388  HullTriangle tB = edge.getTriangles().get(1);
389  tA.addNeighbour(tB);
390  tB.addNeighbour(tA);
391  }
392 
393  /* TODO Remove after debugging. */
394  if (numberOfTriangles == 0) {
395  LOG.error("An edge not associated with a triangle!");
396  LOG.warn(" --> Unique id for the group of points: " + facilityIdentifier);
397  }
398  }
399 
400  /* Write the first iteration's output. */
401  int iteration = 0;
402  if (this.printOutput) {
403  writeOutput(iteration++);
404  }
405 
406  /* Iteratively remove boundary edges, add the new resulting boundary
407  * edges, and sort on edge length until no more boundary edges can be
408  * removed. */
409  while (!this.consideredEdges.isEmpty()) {
410 
411  /* Identify the longest edge. */
412  HullEdge e = this.consideredEdges.firstEntry().getValue();
413 
414  /* TODO Remove after debugging. This was necessary as some links
415  * are not associated with triangles... I don't know how that happens! */
416  if (e.getTriangles().isEmpty()) {
417  LOG.warn("Considered edge without a triangle association!!");
418  LOG.warn(" --> For now (20130703) we deal with this by simply making the link 'ignored'.");
419  LOG.warn(" --> Unique id for the group of points: " + facilityIdentifier);
420  this.consideredEdges.remove(e.getId());
421  this.ignoredEdges.put(e.getId(), e);
422  continue;
423  }
424 
425  HullTriangle triangle = e.getTriangles().getFirst();
426  List<HullTriangle> neighbours = triangle.getNeighbours();
427 
428  /* First test for irregular triangle. If a border edge belongs
429  * to a triangle that only has ONE neighbour, removing that
430  * border edge will result in an irregular triangle.
431  * TODO Check if this test is really necessary... it seems that
432  * the second test will cover both possibilities for irregular
433  * triangles. */
434  if (neighbours.size() == 1) {
435  /* It is irregular. Even though it's long enough to be
436  * considered for removal, it will be flagged and ignored. */
437  this.consideredEdges.remove(e.getId());
438  this.ignoredEdges.put(e.getId(), e);
439  } else {
440  HullEdge e0 = triangle.getEdges().get(0);
441  HullEdge e1 = triangle.getEdges().get(1);
442 
443  /* Second test for irregular triangle. If a triangle has
444  * ALL THREE nodes on the boundary, none of its edges can
445  * be removed while remaining regular. */
446  if (e0.getOriginNode().isBorder() &&
447  e0.getDestinationNode().isBorder() &&
448  e1.getOriginNode().isBorder() &&
449  e1.getDestinationNode().isBorder()) {
450 
451  /* It is irregular. Even though it's long enough to be
452  * considered for removal, it will be flagged and ignored */
453  this.consideredEdges.remove(e.getId());
454  this.ignoredEdges.put(e.getId(), e);
455  } else {
456  /* It seems it is fine to remove this edge. */
457 
458  /* Border triangles seems to always have only two
459  * neighbours. */
460  HullTriangle tA = neighbours.get(0);
461  HullTriangle tB = neighbours.get(1);
462  this.triangles.remove(triangle.getId());
463  tA.removeNeighbour(triangle);
464  tB.removeNeighbour(triangle);
465 
466  /* Get the other two edges associated with the triangle,
467  * as they will become the new border edges. */
468  List<HullEdge> triangleEdges = triangle.getEdges();
469  triangleEdges.remove(e);
470 
471  /* Remove the current edge. */
472  this.edges.remove(e.getId());
473  this.consideredEdges.remove(e.getId());
474 
475  /* Add the two new border edges, considering their
476  * length as well. */
477  HullEdge eA = triangleEdges.getFirst();
478  eA.setBorder(true);
479  if (eA.getGeometry().getLength() > this.threshold) {
480  this.consideredEdges.put(eA.getId(), eA);
481  } else {
482  this.ignoredEdges.put(eA.getId(), eA);
483  }
484  eA.removeTriangle(triangle);
485 
486  HullEdge eB = triangleEdges.get(1);
487  eB.setBorder(true);
488  if (eB.getGeometry().getLength() > this.threshold) {
489  this.consideredEdges.put(eB.getId(), eB);
490  } else {
491  this.ignoredEdges.put(eB.getId(), eB);
492  }
493  eB.removeTriangle(triangle);
494 
495  /* Write the iteration's output. */
496  if (this.printOutput) {
497  writeOutput(iteration++);
498  }
499  }
500  }
501  }
502 
503  /* Assemble the final concave Hull. Start by adding all the border
504  * edges. */
505  List<LineString> borderEdges = new ArrayList<>();
506  for (HullEdge e : this.ignoredEdges.values()) {
507  borderEdges.add(e.getGeometry().toGeometry(this.geomFactory));
508  }
509 
510  /* Merge the line strings. */
511  LineMerger lineMerger = new LineMerger();
512  lineMerger.add(borderEdges);
513  LineString merge = (LineString) lineMerger.getMergedLineStrings().iterator().next();
514 
515  LOG.info("Done calculating the concave hull.");
516  LOG.info(" Triangles: " + this.triangles.size());
517  LOG.info(" Segments: " + borderEdges.size());
518 
519  if (merge.isRing()) {
520  LinearRing lr = new LinearRing(merge.getCoordinateSequence(), this.geomFactory);
521  return new Polygon(lr, null, this.geomFactory);
522  } else {
523  LOG.warn("Could not create hull as the line segments do not form a closed ring.");
524  LOG.warn(" --> Unique id for the group of points: " + facilityIdentifier);
525  LOG.warn(" --> Unique points (" + this.filteredPoints.getNumGeometries() + "):");
526  Coordinate[] ca = this.filteredPoints.getCoordinates();
527  for (Coordinate c : ca) {
528  LOG.warn(" (" + c.x + ";" + c.y + ")");
529  }
530 
531  /* Create the convex hull. */
532  LOG.warn(" --> Returning the convex hull.");
533  return geomFactory.createMultiPointFromCoords(this.filteredPoints.getCoordinates()).convexHull();
534  }
535  }
536 
537 
542  public Object getInputPoints() {
543  return this.filteredPoints.getNumGeometries();
544  }
545 
546 
554  public static List<Coordinate> getClusterCoords(String fileName) {
555  LOG.info("Reading coordinate list from " + fileName);
556  List<Coordinate> coordinateList = new ArrayList<>();
557 
558  try {
559  BufferedReader br = IOUtils.getBufferedReader(fileName);
560  br.readLine();
561 
562  String lines;
563  while ((lines = br.readLine()) != null) {
564  String[] inputString = lines.split(",");
565  double x = Double.parseDouble(inputString[0]);
566  double y = Double.parseDouble(inputString[1]);
567  Coordinate coord = new Coordinate(x, y);
568  coordinateList.add(coord);
569  }
570  } catch (IOException e) {
571  LOG.error(e);
572  }
573  return coordinateList;
574  }
575 
576 
587  private void writeOutput(int iteration) {
588  /* Write the Delaunay triangles. */
589  writeHullEdges(this.edges, this.fileTriangles, iteration);
590 
591  /* Write the Border triangles. */
592  writeHullEdges(this.consideredEdges, this.fileBorder, iteration);
593  writeHullEdges(this.ignoredEdges, this.fileBorder, iteration);
594  }
595 
596  private void writeHullEdges(Map<Integer,HullEdge> hullEdges, String filename, int iteration){
597  try(BufferedWriter writer = IOUtils.getAppendingBufferedWriter(filename);
598  CSVPrinter printer = new CSVPrinter(writer, CSVFormat.Builder.create().setSkipHeaderRecord(true).build())){
599  for(HullEdge edge : hullEdges.values()){
600  printer.printRecord(
601  iteration,
602  edge.getOriginNode().getCoordinate().getX(),
603  edge.getOriginNode().getCoordinate().getY(),
604  edge.getDestinationNode().getCoordinate().getX(),
605  edge.getDestinationNode().getCoordinate().getY()
606  );
607  }
608  } catch(IOException e){
609  throw new RuntimeException("Could not write the output to " + filename);
610  }
611  }
612 
613 
630  public static void main(String[] args) {
631  if(args.length != 3){
632  throw new IllegalArgumentException("Insufficient arguments. Look at the test for an example.");
633  }
634 
635  /* Read in the coordinates from file. */
636  List<Coordinate> coordinates = ConcaveHull.getClusterCoords(args[0]);
637 
638  /* Convert the coordinates to a GeometryCollection. */
639  GeometryFactory gf = new GeometryFactory();
640  Geometry[] points = new Geometry[coordinates.size()];
641  for (int i = 0; i < coordinates.size(); i++) {
642  points[i] = gf.createPoint(coordinates.get(i));
643  }
644  GeometryCollection gc = new GeometryCollection(points, gf);
645 
646  /* Instantiate and run the concave hull algorithm. */
647  ConcaveHull ch = new ConcaveHull(gc, Double.parseDouble(args[1]), args[2]);
648  Geometry g = ch.getConcaveHull();
649  LOG.info("The hull contains " + (g.getCoordinates().length-1) + " coordinates");
650  }
651 
652 }
ConcaveHull(GeometryCollection points, double threshold, String folderOutput)
static BufferedReader getBufferedReader(URL url, Charset charset)
Definition: IOUtils.java:321
static BufferedWriter getBufferedWriter(URL url, Charset charset, boolean append)
Definition: IOUtils.java:390
static BufferedWriter getAppendingBufferedWriter(String filename)
Definition: IOUtils.java:553
void writeHullEdges(Map< Integer, HullEdge > hullEdges, String filename, int iteration)