DATACOUCH-165 - Add utilities to remove geo false positives

PointInShapeEvaluator can be used to check answers and remove false positives from answers that have been approximated (circle and polygon).

One implementations are provided, based on awt.

Hinted at the existence of the PointInShapeEvaluator in the message that warns about bounding box approximations.

Added mention of the AwtPointInShapeEvaluator in the asciidoc with code sample.
This commit is contained in:
Simon Baslé
2015-10-09 18:42:51 +02:00
parent a2f26f4310
commit c6aa84ced4
6 changed files with 592 additions and 2 deletions

View File

@@ -341,10 +341,70 @@ The following query derivation keywords and parameters relative to geographical
|`LessThan,LessThanEqual,Before`|`findByLocationWithinAndOpeningHoursBefore`|useful for dimensions beyond 2, adds a numerical value to the endRange
|===============
IMPORTANT: For "within" types of queries, the expected parameters map to geographical 2D data. Classes from the `org.springframework.data.geo` package are usually expected: `Circle` and `Polygon` will be approximated to their surrounding bounding `Box`. Polygon and Boxes can also be expressed as arrays of `Point`s.
IMPORTANT: For "within" types of queries, the expected parameters map to geographical 2D data. Classes from the `org.springframework.data.geo` package are usually expected, but Polygon and Boxes can also be expressed as arrays of `Point`s.
TIP: Further dimensions are supported through keywords other than Within and Near and require numerical input.
=== Removing False Positives
`Circle` and `Polygon` will be approximated to their surrounding bounding `Box`, but we provide `PointInShapeEvaluator` (`AwtPointInShapeEvaluator`) to be used on the results if you want to remove false positives.
Here is an example of a repository using a spatial view with 3 dimensions (x, y and opening hours).
Second example demonstrates querying it within a `Polygon` then removing false positives in a second pass:
.Example of dimensional repository
====
[source,java]
----
public interface StoreRepository extends PagingAndSortingRepository<Store, String> {
@Dimensional(designDocument = "store", spatialViewName = "byLocationOpening", dimensions = 3)
public List<Store> findByLocationWithinAndOpenBefore(Polygon zone, Integer minOpeningHourMinute);
}
----
====
.Query a spatial view and eliminate false positives on the geo aspect
====
[source,java]
----
// Load the bean, or @Autowire it
StoreRepository repo = ctx.getBean(StoreRepository.class);
// Prepare an arbitrary polygon (a triangle)
Polygon zone = new Polygon(
new Point(2.0, 0.0),
new Point(4.0, 0.0),
new Point(3.0, -5.5),
new Point(2.0, 0.0)); //we explicitly closed the polygon
// Find all stores located inside the triangle.
// Use 3rd dimension to also query on opening hours, making sure one can go there at 8:00am
List<Store> stores = repo.findByLocationWithinAndOpenBefore(zone, 800);
// The view will return all matching stores that are actually within the enclosing rectangle around the polygon
// So we remove false positives
PointInShapeEvaluator evaluator = new AwtPointInShapeEvaluator();
List<Store> truePositives = new ArrayList<Store>(stores.size());
for (Store store : stores) {
if (evaluator.pointInPolygon(store.getLocation(), zone)) {
truePositives.add(store);
} //else this is a false positive
}
// Alternatively, use "removeFalsePositives" to get the list right away.
truePositives = evaluator.removeFalsePositives(stores,
//this method needs a Converter to know where to find the location
new Converter<Store, Point>() {
@Override
public Point convert(Store source) {
return source.getLocation();
}
},
zone);
----
====
[[couchbase.repository.consistency]]
== Querying with consistency
One aspect that is often needed and doesn't have a direct equivalent in the Spring Data query derivation mechanism is

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2012-2015 the original author or authors
*
* Licensed 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.springframework.data.couchbase.repository.query.support;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.util.List;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
/**
* A default {@link PointInShapeEvaluator} implementation based on the JDK's java.awt.geom classes.
*
* @author Simon Baslé
*/
public class AwtPointInShapeEvaluator extends PointInShapeEvaluator {
@Override
public boolean pointInPolygon(Point p, Polygon polygon) {
final List<Point> points = polygon.getPoints();
Path2D awtPolygon = new Path2D.Double(Path2D.WIND_EVEN_ODD, points.size());
boolean first = true;
for (Point point : points) {
if (first) {
first = false;
awtPolygon.moveTo(point.getX(), point.getY());
} else {
awtPolygon.lineTo(point.getX(), point.getY());
}
}
awtPolygon.closePath();
return pointInPolygon(p, awtPolygon);
}
@Override
public boolean pointInPolygon(Point p, Point... points) {
if (points == null) throw new NullPointerException("Polygon must at least contain 3 points");
if (points.length < 3) throw new IllegalArgumentException("Polygon must at least contain 3 points");
Path2D awtPolygon = new Path2D.Double(Path2D.WIND_EVEN_ODD, points.length);
boolean first = true;
for (Point point : points) {
if (first) {
first = false;
awtPolygon.moveTo(point.getX(), point.getY());
} else {
awtPolygon.lineTo(point.getX(), point.getY());
}
}
awtPolygon.closePath();
return pointInPolygon(p, awtPolygon);
}
@Override
public boolean pointInCircle(Point p, Circle c) {
Point2D center = new Point2D.Double(c.getCenter().getX(), c.getCenter().getY());
return pointNear(p, center, c.getRadius().getNormalizedValue());
}
@Override
public boolean pointInCircle(Point p, Point center, Distance radiusDistance) {
double radius = radiusDistance.getNormalizedValue();
return pointNear(p, new Point2D.Double(center.getX(), center.getY()), radius);
}
private boolean pointInPolygon(Point p, Path2D awtPolygon) {
return awtPolygon.contains(p.getX(), p.getY());
}
private boolean pointNear(Point p, Point2D center, double distance) {
return center.distance(p.getX(), p.getY()) <= distance;
}
}

View File

@@ -166,7 +166,8 @@ public class GeoUtils {
private static void warnBoundingBoxApproximation(String kind) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Spatial View queries " + kind + " are made using a bounding box approximation");
LOGGER.debug("Spatial View queries " + kind + " are made using a bounding box approximation," +
"you probably want to remove false positives by applying a PointInShapeEvaluator");
}
}
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright 2012-2015 the original author or authors
*
* Licensed 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.springframework.data.couchbase.repository.query.support;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.geo.Box;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
/**
* PointInShapeEvaluator can be used to tell if a particular {@link Point} is contained by a
* {@link Polygon} or {@link Circle}. It is most useful to eliminate false positive when a
* geo query has been made using a {@link Box bounding box} approximation.
*
* For the purpose of this class, a point on the edge of a polygon isn't considered within it.
* On the contrary a point on the edge of a circle is considered in it (distance from center <= radius).
*
* To that end, {@link #removeFalsePositives(Collection, Converter, Circle) additional methods}
* that return a {@link List} of objects with false positives removed are also provided. However,
* these need a {@link Converter Converter&lt;T, Point&gt;} to extract the location attribute that
* should be tested against the polygon/circle.
*
* @author Simon Baslé
* @see AwtPointInShapeEvaluator for a simple implementation based on AWT standard library.
*/
public abstract class PointInShapeEvaluator {
/**
* Determine if a {@link Point} is contained by a {@link Polygon}.
*
* @param p the point to test.
* @param polygon the polygon we want the point to be in.
* @return true if the polygon contains the point, false otherwise.
*/
public abstract boolean pointInPolygon(Point p, Polygon polygon);
/**
* Determine if a {@link Point} is contained by a polygon represented
* as an array of {@link Point points}.
*
* The points are not required to form a closed shape, but can (by having the
* first and last points be the same).
*
* @param p the point to test.
* @param points the Point[] representation of the polygon we want the point to be in.
* @return true if the polygon contains the point, false otherwise.
*/
public abstract boolean pointInPolygon(Point p, Point... points);
/**
* Determine if a {@link Point} is contained by a {@link Circle}.
*
* @param p the point to test.
* @param c the Circle we want the point to be in.
* @return true if the circle contains the point, false otherwise.
*/
public abstract boolean pointInCircle(Point p, Circle c);
/**
* Determine if a {@link Point} is contained by a {@link Circle} represented
* by its {@link Point center Point} and {@link Distance Distance radius}.
*
* @param p the point to test.
* @param center the center Point of the Circle we want the point to be in.
* @param radius the Distance radius of the Circle we want the point to be in.
* @return true if the circle contains the point, false otherwise.
*/
public abstract boolean pointInCircle(Point p, Point center, Distance radius);
/**
* Utility method to remove false positives from a collection of objects that have a
* notion of location, where we want to only include values that are located within a polygon.
*
* The collection should usually be already contained in the bounding box approximation of
* the polygon for maximum efficiency.
*
* @param boundingBoxResults the collections of located objects approximately inside the target polygon.
* @param locationExtractor a {@link Converter} to extract the location of the value objects.
* @param polygon the target polygon.
* @param <T> the type of located value objects in the collection.
* @return a {@link List} of the value objects which location has been verified to actually be contained within the polygon.
*/
public <T> List<T> removeFalsePositives(Collection<? extends T> boundingBoxResults,
Converter<T, Point> locationExtractor, Polygon polygon) {
ArrayList<T> result = new ArrayList<T>(boundingBoxResults.size());
for (T boxResult : boundingBoxResults) {
Point p = locationExtractor.convert(boxResult);
if (pointInPolygon(p, polygon)) {
result.add(boxResult);
}
}
result.trimToSize();
return result;
}
/**
* Utility method to remove false positives from a collection of objects that have a
* notion of location, where we want to only include values that are located within a circle.
*
* The collection should usually be already contained in the bounding box approximation of
* the circle for maximum efficiency.
*
* @param boundingBoxResults the collections of located objects approximately inside the target circle.
* @param locationExtractor a {@link Converter} to extract the location of the value objects.
* @param circle the target circle.
* @param <T> the type of located value objects in the collection.
* @return a {@link List} of the value objects which location has been verified to actually be contained within the circle.
*/
public <T> List<T> removeFalsePositives(Collection<? extends T> boundingBoxResults,
Converter<T, Point> locationExtractor, Circle circle) {
ArrayList<T> result = new ArrayList<T>(boundingBoxResults.size());
for (T boxResult : boundingBoxResults) {
Point p = locationExtractor.convert(boxResult);
if (pointInCircle(p, circle)) {
result.add(boxResult);
}
}
result.trimToSize();
return result;
}
/**
* Utility method to remove false positives from a collection of objects that have a
* notion of location, where we want to only include values that are located within a polygon.
*
* The collection should usually be already contained in the bounding box approximation of
* the polygon for maximum efficiency.
*
* @param boundingBoxResults the collections of located objects approximately inside the target polygon.
* @param locationExtractor a {@link Converter} to extract the location of the value objects.
* @param polygon the target polygon, as an array of {@link Point} (not necessarily closed).
* @param <T> the type of located value objects in the collection.
* @return a {@link List} of the value objects which location has been verified to actually be contained within the polygon.
*/
public <T> List<T> removeFalsePositives(Collection<? extends T> boundingBoxResults,
Converter<T, Point> locationExtractor, Point... polygon) {
ArrayList<T> result = new ArrayList<T>(boundingBoxResults.size());
for (T boxResult : boundingBoxResults) {
Point p = locationExtractor.convert(boxResult);
if (pointInPolygon(p, polygon)) {
result.add(boxResult);
}
}
result.trimToSize();
return result;
}
/**
* Utility method to remove false positives from a collection of objects that have a
* notion of location, where we want to only include values that are located within a circle.
*
* The collection should usually be already contained in the bounding box approximation of
* the circle for maximum efficiency.
*
* @param boundingBoxResults the collections of located objects approximately inside the target circle.
* @param locationExtractor a {@link Converter} to extract the location of the value objects.
* @param center the center of the target circle.
* @param radius the radius of the target circle.
* @param <T> the type of located value objects in the collection.
* @return a {@link List} of the value objects which location has been verified to actually be contained within the circle.
*/
public <T> List<T> removeFalsePositives(Collection<? extends T> boundingBoxResults,
Converter<T, Point> locationExtractor, Point center, Distance radius) {
ArrayList<T> result = new ArrayList<T>(boundingBoxResults.size());
for (T boxResult : boundingBoxResults) {
Point p = locationExtractor.convert(boxResult);
if (pointInCircle(p, center, radius)) {
result.add(boxResult);
}
}
result.trimToSize();
return result;
}
}

View File

@@ -0,0 +1,232 @@
package org.springframework.data.couchbase.repository.query.support;
import static org.junit.Assert.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
/**
* An abstract base for testing {@link PointInShapeEvaluator} implementations.
* Each implementation can be commonly tested by extending this base and instantiating the evaluator in createEvaluator.
*
* @author Simon Baslé
*/
public abstract class AbstractPointInShapeEvaluatorTest {
public abstract PointInShapeEvaluator createEvaluator();
private PointInShapeEvaluator evaluator;
protected static final class LocatedValue {
public final Point location;
public final String name;
public LocatedValue(Point location, String name) {
this.location = location;
this.name = name;
}
@Override
public String toString() {
return name;
}
}
protected static final Converter<LocatedValue, Point> LOCATED_VALUE_POINT_CONVERTER = new Converter<LocatedValue, Point>() {
@Override
public Point convert(LocatedValue source) {
return source.location;
}
};
@Before
public void init() {
evaluator = createEvaluator();
}
@Test
public void testPointInPolygon() throws Exception {
Polygon openTriangle = new Polygon(
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0)
);
Polygon closedTriangle = new Polygon(
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0),
new Point(1.0, 2.0) //explicitly closing the shape
);
Point inside = new Point(1.6, 1.8);
Point outside = new Point(1.1, 0.3);
Point edge = new Point(1.0, 2.0);
assertTrue("point inside open polygon failed", evaluator.pointInPolygon(inside, openTriangle));
assertFalse("point outside open polygon failed", evaluator.pointInPolygon(outside, openTriangle));
assertFalse("point on edge of open polygon should not be considered within the polygon",
evaluator.pointInPolygon(edge, openTriangle));
assertTrue("point inside closed polygon failed", evaluator.pointInPolygon(inside, closedTriangle));
assertFalse("point outside closed polygon failed", evaluator.pointInPolygon(outside, closedTriangle));
assertFalse("point on edge of closed polygon should not be considered within the polygon",
evaluator.pointInPolygon(edge, closedTriangle));
}
@Test
public void testPointInPolygonArray() throws Exception {
Point[] openTriangle = new Point[] {
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0)
};
Point[] closedTriangle = new Point[] {
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0),
new Point(1.0, 2.0) //explicitly closing the shape
};
Point inside = new Point(1.6, 1.8);
Point outside = new Point(1.1, 0.3);
Point edge = new Point(1.0, 2.0);
assertTrue("point inside open polygon failed", evaluator.pointInPolygon(inside, openTriangle));
assertFalse("point outside open polygon failed", evaluator.pointInPolygon(outside, openTriangle));
assertFalse("point on edge of open polygon should not be considered within the polygon",
evaluator.pointInPolygon(edge, openTriangle));
assertTrue("point inside closed polygon failed", evaluator.pointInPolygon(inside, closedTriangle));
assertFalse("point outside closed polygon failed", evaluator.pointInPolygon(outside, closedTriangle));
assertFalse("point on edge of closed polygon should not be considered within the polygon",
evaluator.pointInPolygon(edge, closedTriangle));
}
@Test
public void testPointInCircle() throws Exception {
Circle circle = new Circle(new Point(0, 0), new Distance(2));
Point inside = new Point(-0.3, 1.4);
Point outside = new Point(1.3, 2d);
Point onEdge = new Point(-2d, 0d);
assertTrue("point inside failed", evaluator.pointInCircle(inside, circle));
assertFalse("point outside failed", evaluator.pointInCircle(outside, circle));
assertTrue("point on edge of circle should be considered within / near", evaluator.pointInCircle(onEdge, circle));
}
@Test
public void testPointInCircleCenterRadius() throws Exception {
Point center = new Point(0, 0);
Distance radius = new Distance(2);
Point inside = new Point(-0.3, 1.8);
Point outside = new Point(1.3, 2d);
Point onEdge = new Point(-2d, 0d);
assertTrue("point inside failed", evaluator.pointInCircle(inside, center, radius));
assertFalse("point outside failed", evaluator.pointInCircle(outside, center, radius));
assertTrue("point on edge of circle should be considered within / near", evaluator.pointInCircle(onEdge, center, radius));
}
@Test
public void testRemoveFalsePositivesPolygon() throws Exception {
Polygon openTriangle = new Polygon(
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0)
);
Polygon closedTriangle = new Polygon(
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0),
new Point(1.0, 2.0) //explicitly closing the shape
);
LocatedValue inside = new LocatedValue(new Point(1.6, 1.8), "inside");
LocatedValue outside = new LocatedValue(new Point(1.1, 0.3), "outside");
LocatedValue edge = new LocatedValue(new Point(1.5, 0d), "edge");
List<LocatedValue> expected = Collections.singletonList(inside);
List<LocatedValue> tested = Arrays.asList(inside, outside, edge);
List<LocatedValue> filteredOpen = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, openTriangle);
List<LocatedValue> filteredClosed = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, closedTriangle);
assertEquals(expected, filteredOpen);
assertEquals(expected, filteredClosed);
}
@Test
public void testRemoveFalsePositivesPolygonArray() throws Exception {
Point[] openTriangle = new Point[] {
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0)
};
Point[] closedTriangle = new Point[] {
new Point(1.0, 2.0),
new Point(2.0, 2.0),
new Point(1.5, 0.0),
new Point(1.0, 2.0) //explicitly closing the shape
};
LocatedValue inside = new LocatedValue(new Point(1.6, 1.8), "inside");
LocatedValue outside = new LocatedValue(new Point(1.1, 0.3), "outside");
LocatedValue edge = new LocatedValue(new Point(1.5, 0d), "edge");
List<LocatedValue> expected = Collections.singletonList(inside);
List<LocatedValue> tested = Arrays.asList(inside, outside, edge);
List<LocatedValue> filteredOpen = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, openTriangle);
List<LocatedValue> filteredClosed = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, closedTriangle);
assertEquals(expected, filteredOpen);
assertEquals(expected, filteredClosed);
}
@Test
public void testRemoveFalsePositivesCircle() throws Exception {
Circle circle = new Circle(new Point(0, 0), new Distance(2));
LocatedValue inside = new LocatedValue(new Point(-0.3, 1.4), "inside");
LocatedValue outside = new LocatedValue(new Point(1.3, 2d), "outside");
LocatedValue edge = new LocatedValue(new Point(-2d, 0d), "edge");
List<LocatedValue> expected = Arrays.asList(inside, edge);
List<LocatedValue> tested = Arrays.asList(inside, outside, edge);
List<LocatedValue> filtered = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, circle);
assertEquals(expected, filtered);
}
@Test
public void testRemoveFalsePositivesCircleCenterRadius() throws Exception {
Point center = new Point(0, 0);
Distance radius = new Distance(2);
LocatedValue inside = new LocatedValue(new Point(-0.3, 1.4), "inside");
LocatedValue outside = new LocatedValue(new Point(1.3, 2d), "outside");
LocatedValue edge = new LocatedValue(new Point(-2d, 0d), "edge");
List<LocatedValue> expected = Arrays.asList(inside, edge);
List<LocatedValue> tested = Arrays.asList(inside, outside, edge);
List<LocatedValue> filtered = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, center, radius);
assertEquals(expected, filtered);
}
}

View File

@@ -0,0 +1,14 @@
package org.springframework.data.couchbase.repository.query.support;
/**
* Test case for the {@link AwtPointInShapeEvaluator}.
*
* @author Simon Baslé
*/
public class AwtPointInShapeEvaluatorTest extends AbstractPointInShapeEvaluatorTest {
@Override
public PointInShapeEvaluator createEvaluator() {
return new AwtPointInShapeEvaluator();
}
}