[MATH-749] Improve robustness for convex hull algorithms in case of identical / collinear points, added flag whether to include only extreme points or all points on the hull, improve unit tests.

git-svn-id: https://svn.apache.org/repos/asf/commons/proper/math/trunk@1564934 13f79535-47bb-0310-9956-ffa450edef68
This commit is contained in:
Thomas Neidhart 2014-02-05 21:21:31 +00:00
parent e5002ce3f6
commit e5dc3ad337
7 changed files with 408 additions and 107 deletions

View File

@ -0,0 +1,129 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.commons.math3.geometry.euclidean.twod.hull;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import org.apache.commons.math3.exception.NullArgumentException;
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
import org.apache.commons.math3.util.MathUtils;
/**
* Abstract base class for convex hull generators in the two-dimensional euclidean space.
*
* @since 3.3
* @version $Id$
*/
public abstract class AbstractConvexHullGenerator2D implements ConvexHullGenerator2D {
/** Default value for tolerance. */
private static final double DEFAULT_TOLERANCE = 1e-10;
/** Tolerance below which points are considered identical. */
private final double tolerance;
/**
* Indicates if collinear points on the hull shall be present in the output.
* If {@code false}, only the extreme points are added to the hull.
*/
private final boolean includeCollinearPoints;
/**
* Simple constructor.
* <p>
* Collinear points on the hull will not be added to the hull vertices and
* {@code 1e-10} will be used as tolerance criteria for identical points.
*/
protected AbstractConvexHullGenerator2D() {
this(false, DEFAULT_TOLERANCE);
}
/**
* Simple constructor.
* <p>
* The default tolerance (1e-10) will be used to determine identical points.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
*/
protected AbstractConvexHullGenerator2D(final boolean includeCollinearPoints) {
this(includeCollinearPoints, DEFAULT_TOLERANCE);
}
/**
* Simple constructor.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
* @param tolerance tolerance below which points are considered identical
*/
protected AbstractConvexHullGenerator2D(final boolean includeCollinearPoints, final double tolerance) {
this.includeCollinearPoints = includeCollinearPoints;
this.tolerance = tolerance;
}
/**
* Get the tolerance below which points are considered identical.
* @return the tolerance below which points are considered identical
*/
public double getTolerance() {
return tolerance;
}
/**
* Returns if collinear points on the hull will be added as hull vertices.
* @return {@code true} if collinear points are added as hull vertices, or {@code false}
* if only extreme points are present.
*/
public boolean isIncludeCollinearPoints() {
return includeCollinearPoints;
}
/** {@inheritDoc} */
public ConvexHull2D generate(final Collection<Vector2D> points) throws NullArgumentException {
// check for null points
MathUtils.checkNotNull(points);
final int size = points.size();
if (size == 2) {
// special case: check that the two points are not identical
final Iterator<Vector2D> it = points.iterator();
final Vector2D firstPoint = it.next();
final Vector2D secondPoint = it.next();
if (firstPoint.distance(secondPoint) > tolerance) {
return new ConvexHull2D(points, tolerance);
} else {
return new ConvexHull2D(Arrays.asList(firstPoint), tolerance);
}
} else if (size < 2) {
return new ConvexHull2D(points, tolerance);
}
final Collection<Vector2D> hullVertices = generateHull(points);
return new ConvexHull2D(hullVertices, tolerance);
}
/**
* Compute the convex hull vertices from the set of input points.
* @param points the set of input points
* @return the convex hull vertices in CCW winding
*/
protected abstract Collection<Vector2D> generateHull(Collection<Vector2D> points);
}

View File

@ -23,11 +23,8 @@ import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import org.apache.commons.math3.exception.NullArgumentException;
import org.apache.commons.math3.geometry.euclidean.twod.Line;
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
import org.apache.commons.math3.util.FastMath;
import org.apache.commons.math3.util.MathUtils;
/**
* Implements Graham's scan method to generate the convex hull of a finite set of
@ -40,47 +37,50 @@ import org.apache.commons.math3.util.MathUtils;
* @since 3.3
* @version $Id$
*/
public class GrahamScan implements ConvexHullGenerator2D {
/** Default value for tolerance. */
private static final double DEFAULT_TOLERANCE = 1e-10;
public class GrahamScan extends AbstractConvexHullGenerator2D {
/** Vector representing the x-axis. */
private static final Vector2D X_AXIS = new Vector2D(1.0, 0.0);
/** Tolerance below which points are considered identical. */
private final double tolerance;
/**
* Creates a new instance.
* Create a new GrahamScan instance.
* <p>
* The default tolerance (1e-10) will be used to determine identical points.
* Collinear points on the hull will not be added to the hull vertices and
* {@code 1e-10} will be used as tolerance criteria for identical points.
*/
public GrahamScan() {
this(DEFAULT_TOLERANCE);
super();
}
/**
* Creates a new instance with the given tolerance for determining identical points.
* @param tolerance tolerance below which points are considered identical
* Create a new GrahamScan instance.
* <p>
* The default tolerance (1e-10) will be used to determine identical points.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
*/
public GrahamScan(final double tolerance) {
this.tolerance = tolerance;
public GrahamScan(final boolean includeCollinearPoints) {
super(includeCollinearPoints);
}
/** {@inheritDoc} */
public ConvexHull2D generate(final Collection<Vector2D> points) throws NullArgumentException {
/**
* Create a new GrahamScan instance.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
* @param tolerance tolerance below which points are considered identical
*/
public GrahamScan(final boolean includeCollinearPoints, final double tolerance) {
super(includeCollinearPoints, tolerance);
}
// check for null points
MathUtils.checkNotNull(points);
if (points.size() < 3) {
return new ConvexHull2D(points, tolerance);
}
@Override
protected Collection<Vector2D> generateHull(final Collection<Vector2D> points) {
final Vector2D referencePoint = getReferencePoint(points);
final List<Vertex> pointsSortedByAngle = new ArrayList<Vertex>();
final List<Vertex> pointsSortedByAngle = new ArrayList<Vertex>(points.size());
for (final Vector2D p : points) {
pointsSortedByAngle.add(new Vertex(p, getAngleBetweenPoints(p, referencePoint)));
}
@ -92,13 +92,22 @@ public class GrahamScan implements ConvexHullGenerator2D {
}
});
// list containing the vertices of the hull in ccw direction
final List<Vector2D> hullVertices = new ArrayList<Vector2D>(points.size());
// list containing the vertices of the hull in CCW direction
final List<Vector2D> hullVertices = new ArrayList<Vector2D>();
// push the first two points on the stack
final Iterator<Vertex> it = pointsSortedByAngle.iterator();
hullVertices.add(it.next().point);
hullVertices.add(it.next().point);
final Vector2D firstPoint = it.next().point;
hullVertices.add(firstPoint);
final double tolerance = getTolerance();
// ensure that we do not add an identical point
while(hullVertices.size() < 2 && it.hasNext()) {
final Vector2D p = it.next().point;
if (firstPoint.distance(p) >= tolerance) {
hullVertices.add(p);
}
}
Vector2D currentPoint = null;
while (it.hasNext() || currentPoint != null) {
@ -113,27 +122,47 @@ public class GrahamScan implements ConvexHullGenerator2D {
// get the last line segment of the current convex hull
final Vector2D p1 = hullVertices.get(size - 2);
final Vector2D p2 = hullVertices.get(size - 1);
final Line line = new Line(p1, p2, tolerance);
if (currentPoint == null) {
currentPoint = it.next().point;
}
// test if the current point is to the left of the line
final double offset = line.getOffset(currentPoint);
final double offset = currentPoint.crossProduct(p1, p2);
if (offset < 0.0) {
// the current point forms a convex section
if (FastMath.abs(offset) < tolerance) {
// the point is collinear to the line (p1, p2)
final double distanceToCurrent = p1.distance(currentPoint);
if (distanceToCurrent < tolerance || p2.distance(currentPoint) < tolerance) {
// the point is assumed to be identical to either p1 or p2
currentPoint = null;
continue;
}
final double distanceToLast = p1.distance(p2);
if (isIncludeCollinearPoints()) {
final int index = distanceToCurrent < distanceToLast ? size - 1 : size;
hullVertices.add(index, currentPoint);
currentPoint = null;
} else {
if (distanceToCurrent > distanceToLast) {
hullVertices.remove(size - 1);
} else {
currentPoint = null;
}
}
} else if (offset > 0.0) {
// the current point forms a convex polygon
hullVertices.add(currentPoint);
currentPoint = null;
} else {
// otherwise, the point is either collinear or will create
// a concave section, thus we need to remove the last point.
// the current point creates a concave polygon, remove the last point
hullVertices.remove(size - 1);
}
}
return new ConvexHull2D(hullVertices, tolerance);
return hullVertices;
}
/**
@ -145,7 +174,7 @@ public class GrahamScan implements ConvexHullGenerator2D {
* @param points the point set
* @return the point with the lowest y-coordinate
*/
private Vector2D getReferencePoint(final Collection<Vector2D> points) {
private Vector2D getReferencePoint(final Iterable<Vector2D> points) {
Vector2D minY = null;
for (final Vector2D p : points) {
if (minY == null) {

View File

@ -22,10 +22,8 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.apache.commons.math3.exception.NullArgumentException;
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
import org.apache.commons.math3.util.FastMath;
import org.apache.commons.math3.util.MathUtils;
/**
* Implements Andrew's monotone chain method to generate the convex hull of a finite set of
@ -40,40 +38,43 @@ import org.apache.commons.math3.util.MathUtils;
* @since 3.3
* @version $Id$
*/
public class MonotoneChain implements ConvexHullGenerator2D {
/** Default value for tolerance. */
private static final double DEFAULT_TOLERANCE = 1e-10;
/** Tolerance below which points are considered identical. */
private final double tolerance;
public class MonotoneChain extends AbstractConvexHullGenerator2D {
/**
* Creates a new instance.
* Create a new MonotoneChain instance.
* <p>
* The default tolerance (1e-10) will be used to determine identical points.
* Collinear points on the hull will not be added to the hull vertices and
* {@code 1e-10} will be used as tolerance criteria for identical points.
*/
public MonotoneChain() {
this(DEFAULT_TOLERANCE);
super();
}
/**
* Creates a new instance with the given tolerance for determining identical points.
* @param tolerance tolerance below which points are considered identical
* Create a new MonotoneChain instance.
* <p>
* The default tolerance (1e-10) will be used to determine identical points.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
*/
public MonotoneChain(final double tolerance) {
this.tolerance = tolerance;
public MonotoneChain(final boolean includeCollinearPoints) {
super(includeCollinearPoints);
}
/** {@inheritDoc} */
public ConvexHull2D generate(final Collection<Vector2D> points) throws NullArgumentException {
/**
* Create a new MonotoneChain instance.
*
* @param includeCollinearPoints indicates if collinear points on the hull shall be
* added as hull vertices
* @param tolerance tolerance below which points are considered identical
*/
public MonotoneChain(final boolean includeCollinearPoints, final double tolerance) {
super(includeCollinearPoints, tolerance);
}
// check for null points
MathUtils.checkNotNull(points);
if (points.size() < 3) {
return new ConvexHull2D(points, tolerance);
}
@Override
public Collection<Vector2D> generateHull(final Collection<Vector2D> points) {
final List<Vector2D> pointsSortedByXAxis = new ArrayList<Vector2D>(points);
@ -92,41 +93,19 @@ public class MonotoneChain implements ConvexHullGenerator2D {
// build lower hull
final List<Vector2D> lowerHull = new ArrayList<Vector2D>();
for (Vector2D p : pointsSortedByXAxis) {
while (lowerHull.size() >= 2) {
final int size = lowerHull.size();
final Vector2D p1 = lowerHull.get(size - 2);
final Vector2D p2 = lowerHull.get(size - 1);
if (p.crossProduct(p1, p2) <= 0) {
lowerHull.remove(size - 1);
} else {
break;
}
}
lowerHull.add(p);
updateHull(p, lowerHull);
}
// build upper hull
final List<Vector2D> upperHull = new ArrayList<Vector2D>();
for (int idx = pointsSortedByXAxis.size() - 1; idx >= 0; idx--) {
final Vector2D p = pointsSortedByXAxis.get(idx);
while (upperHull.size() >= 2) {
final int size = upperHull.size();
final Vector2D p1 = upperHull.get(size - 2);
final Vector2D p2 = upperHull.get(size - 1);
if (p.crossProduct(p1, p2) <= 0) {
upperHull.remove(size - 1);
} else {
break;
}
}
upperHull.add(p);
updateHull(p, upperHull);
}
// concatenate the lower and upper hulls
// the last point of each list is omitted as it is repeated at the beginning of the other list
List<Vector2D> hullVertices = new ArrayList<Vector2D>(lowerHull.size() + upperHull.size() - 2);
final List<Vector2D> hullVertices = new ArrayList<Vector2D>(lowerHull.size() + upperHull.size() - 2);
for (int idx = 0; idx < lowerHull.size() - 1; idx++) {
hullVertices.add(lowerHull.get(idx));
}
@ -134,7 +113,60 @@ public class MonotoneChain implements ConvexHullGenerator2D {
hullVertices.add(upperHull.get(idx));
}
return new ConvexHull2D(hullVertices, tolerance);
return hullVertices;
}
/**
* Update the partial hull with the current point.
*
* @param point the current point
* @param hull the partial hull
*/
private void updateHull(final Vector2D point, final List<Vector2D> hull) {
final double tolerance = getTolerance();
if (hull.size() == 1) {
// ensure that we do not add an identical point
final Vector2D p1 = hull.get(0);
if (p1.distance(point) < tolerance) {
return;
}
}
while (hull.size() >= 2) {
final int size = hull.size();
final Vector2D p1 = hull.get(size - 2);
final Vector2D p2 = hull.get(size - 1);
final double offset = point.crossProduct(p1, p2);
if (FastMath.abs(offset) < tolerance) {
// the point is collinear to the line (p1, p2)
final double distanceToCurrent = p1.distance(point);
if (distanceToCurrent < tolerance || p2.distance(point) < tolerance) {
// the point is assumed to be identical to either p1 or p2
return;
}
final double distanceToLast = p1.distance(p2);
if (isIncludeCollinearPoints()) {
final int index = distanceToCurrent < distanceToLast ? size - 1 : size;
hull.add(index, point);
} else {
if (distanceToCurrent > distanceToLast) {
hull.remove(size - 1);
}
hull.add(point);
}
return;
} else if (offset < 0) {
hull.remove(size - 1);
} else {
break;
}
}
hull.add(point);
}
}

View File

@ -27,8 +27,8 @@ import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
public class AklToussaintHeuristicTest extends ConvexHullGenerator2DAbstractTest {
@Override
protected ConvexHullGenerator2D createConvexHullGenerator() {
return new GrahamScan();
protected ConvexHullGenerator2D createConvexHullGenerator(boolean includeCollinearPoints) {
return new GrahamScan(includeCollinearPoints);
}
@Override

View File

@ -17,6 +17,7 @@
package org.apache.commons.math3.geometry.euclidean.twod.hull;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@ -43,7 +44,7 @@ public abstract class ConvexHullGenerator2DAbstractTest {
protected ConvexHullGenerator2D generator;
protected RandomGenerator random;
protected abstract ConvexHullGenerator2D createConvexHullGenerator();
protected abstract ConvexHullGenerator2D createConvexHullGenerator(boolean includeCollinearPoints);
protected Collection<Vector2D> reducePoints(Collection<Vector2D> points) {
// do nothing by default, may be overridden by other tests
@ -52,7 +53,7 @@ public abstract class ConvexHullGenerator2DAbstractTest {
@Before
public void setUp() {
generator = createConvexHullGenerator();
generator = createConvexHullGenerator(false);
random = new MersenneTwister(10);
}
@ -100,7 +101,7 @@ public abstract class ConvexHullGenerator2DAbstractTest {
}
@Test
public void testColinearPoints() {
public void testCollinearPoints() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
@ -112,6 +113,84 @@ public abstract class ConvexHullGenerator2DAbstractTest {
checkConvexHull(points, hull);
}
@Test
public void testCollinearPointsReverse() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(10, 1));
points.add(new Vector2D(4, 1));
final ConvexHull2D hull = generator.generate(points);
checkConvexHull(points, hull);
}
@Test
public void testCollinearPointsIncluded() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(4, 1));
points.add(new Vector2D(10, 1));
final ConvexHull2D hull = createConvexHullGenerator(true).generate(points);
checkConvexHull(points, hull, true);
}
@Test
public void testCollinearPointsIncludedReverse() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(10, 1));
points.add(new Vector2D(4, 1));
final ConvexHull2D hull = createConvexHullGenerator(true).generate(points);
checkConvexHull(points, hull, true);
}
@Test
public void testIdenticalPoints() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(4, 1));
points.add(new Vector2D(1, 1));
final ConvexHull2D hull = generator.generate(points);
checkConvexHull(points, hull);
}
@Test
public void testIdenticalPoints2() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(4, 1));
points.add(new Vector2D(1, 1));
final ConvexHull2D hull = createConvexHullGenerator(true).generate(points);
checkConvexHull(points, hull, true);
}
@Test
public void testClosePoints() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(4, 1));
points.add(new Vector2D(1.00001, 1));
final ConvexHull2D hull = generator.generate(points);
checkConvexHull(points, hull);
}
// ------------------------------------------------------------------------------
protected final List<Vector2D> createRandomPoints(int size) {
@ -123,15 +202,20 @@ public abstract class ConvexHullGenerator2DAbstractTest {
}
return points;
}
protected final void checkConvexHull(final Collection<Vector2D> points, final ConvexHull2D hull) {
checkConvexHull(points, hull, false);
}
protected final void checkConvexHull(final Collection<Vector2D> points, final ConvexHull2D hull,
final boolean includesCollinearPoints) {
Assert.assertNotNull(hull);
Assert.assertTrue(isConvex(hull));
checkPointsInsideHullRegion(points, hull);
Assert.assertTrue(isConvex(hull, includesCollinearPoints));
checkPointsInsideHullRegion(points, hull, includesCollinearPoints);
}
// verify that the constructed hull is really convex
protected final boolean isConvex(final ConvexHull2D hull) {
protected final boolean isConvex(final ConvexHull2D hull, final boolean includesCollinearPoints) {
double sign = 0.0;
final Vector2D[] points = hull.getVertices();
@ -142,11 +226,18 @@ public abstract class ConvexHullGenerator2DAbstractTest {
Vector2D d1 = p2.subtract(p1);
Vector2D d2 = p3.subtract(p2);
Assert.assertTrue(d1.getNorm() > 1e-10);
Assert.assertTrue(d2.getNorm() > 1e-10);
double cross = FastMath.signum(d1.getX() * d2.getY() - d1.getY() * d2.getX());
if (sign != 0.0 && cross != sign) {
return false;
if (includesCollinearPoints && cross == 0.0) {
// in case of collinear points the cross product will be zero
} else {
return false;
}
}
sign = cross;
@ -157,13 +248,19 @@ public abstract class ConvexHullGenerator2DAbstractTest {
// verify that all points are inside the convex hull region
protected final void checkPointsInsideHullRegion(final Collection<Vector2D> points,
final ConvexHull2D hull) {
final ConvexHull2D hull,
final boolean includesCollinearPoints) {
Region<Euclidean2D> region = hull.createRegion();
final Collection<Vector2D> hullVertices = Arrays.asList(hull.getVertices());
final Region<Euclidean2D> region = hull.createRegion();
for (final Vector2D p : points) {
Location location = region.checkPoint(p);
Assert.assertTrue(location != Location.OUTSIDE);
if (location == Location.BOUNDARY && includesCollinearPoints) {
Assert.assertTrue(hullVertices.contains(p));
}
}
}
}

View File

@ -17,6 +17,7 @@
package org.apache.commons.math3.geometry.euclidean.twod.hull;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
@ -30,14 +31,14 @@ import org.junit.Test;
public class GrahamScanTest extends ConvexHullGenerator2DAbstractTest {
@Override
protected ConvexHullGenerator2D createConvexHullGenerator() {
return new GrahamScan();
protected ConvexHullGenerator2D createConvexHullGenerator(boolean includeCollinearPoints) {
return new GrahamScan(includeCollinearPoints);
}
// ------------------------------------------------------------------------------
@Test
public void testIdenticalPoints() {
public void testIdenticalPointsRandom() {
final List<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(0.7886552422, 0.8629523066));
@ -62,5 +63,18 @@ public class GrahamScanTest extends ConvexHullGenerator2DAbstractTest {
final ConvexHull2D hull = generator.generate(points);
checkConvexHull(points, hull);
}
@Test
public void testIdenticalPointsAtStart() {
final Collection<Vector2D> points = new ArrayList<Vector2D>();
points.add(new Vector2D(1, 1));
points.add(new Vector2D(2, 2));
points.add(new Vector2D(2, 4));
points.add(new Vector2D(4, 1.5));
points.add(new Vector2D(1, 1));
final ConvexHull2D hull = createConvexHullGenerator(true).generate(points);
checkConvexHull(points, hull, true);
}
}

View File

@ -23,8 +23,8 @@ package org.apache.commons.math3.geometry.euclidean.twod.hull;
public class MonotoneChainTest extends ConvexHullGenerator2DAbstractTest {
@Override
protected ConvexHullGenerator2D createConvexHullGenerator() {
return new MonotoneChain();
protected ConvexHullGenerator2D createConvexHullGenerator(boolean includeCollinearPoints) {
return new MonotoneChain(includeCollinearPoints);
}
// ------------------------------------------------------------------------------