From 944445a6f8ee06fc6666c891f4156eb4ec540174 Mon Sep 17 00:00:00 2001 From: Micah Nacht Date: Wed, 19 Dec 2018 14:56:32 -0800 Subject: [PATCH] Spiky Buildings Check (#102) * First approximation of a spikybuildings check. * Remove unneeded relation check, and don't flag buildings with 3 vertices. * Mark the spiky angle node, and flag relations better. * Refactor to use headings. Clean up a bit. * Remove roof check, clean up code. * First pass at angle output. * Make minimum valid angle and minimum number of sides to examine configurable. * Update string output. * Add filter for circular features of buildings. * Update default to not break unit test. * Configurables for curvy geometry detection, and an update to a unit test. * Find circular points instead of segments * Add unit test for consecutive, but distinct, curved segments. * Remove three-vertex constraint. * Add comments, clean up code and a medium refactor. * Formatting cleanup and extra usage of segmentPairsFrom(). * Rename configurables and remove code smell. * Add documentation to the docs folder. * Small cleanup. * Skinny->spiky, rephrase flag-building logic * Add map roulette challenge configuration * Add curve info to documentation. * Address comments * Update instructions, add tests and remove a pair of parentheses * Updated configurable names and types * spotless --- config/configuration.json | 17 + docs/checks/spikyBuildingCheck.md | 61 +++ .../validation/areas/SpikyBuildingCheck.java | 421 ++++++++++++++++++ .../areas/SpikyBuildingCheckTest.java | 121 +++++ .../areas/SpikyBuildingCheckTestRule.java | 139 ++++++ 5 files changed, 759 insertions(+) create mode 100644 docs/checks/spikyBuildingCheck.md create mode 100644 src/main/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheck.java create mode 100644 src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTest.java create mode 100644 src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTestRule.java diff --git a/config/configuration.json b/config/configuration.json index 216fdf437..946a85062 100644 --- a/config/configuration.json +++ b/config/configuration.json @@ -12,6 +12,23 @@ "minimum": 50.0 } }, + "SpikyBuildingCheck": { + "spiky.angle.maximum": 15.0, + "curve": { + "degrees": { + "maximum.single_heading_change": 25.0, + "minimum.total_heading_change": 10.0 + }, + "points.minimum": 4 + }, + "challenge": { + "description": "Find poorly digitized buildings with sharp angles in their geometry", + "blurb": "Fix buildings with spiky geometry", + "instruction": "Open your favorite editor and validate that the buildings are mapped correctly", + "difficulty": "NORMAL", + "tags":"building" + } + }, "OrphanNodeCheck": { }, diff --git a/docs/checks/spikyBuildingCheck.md b/docs/checks/spikyBuildingCheck.md new file mode 100644 index 000000000..bb58b1c48 --- /dev/null +++ b/docs/checks/spikyBuildingCheck.md @@ -0,0 +1,61 @@ +# SpikyBuildingCheck + +The purpose of this check is to identify buildings with extremely sharp angles in their geometry. +These angles, or “spikes” are formed by two or more poly-lines and a peak node. These spikes are often +hard to visually inspect as the poly-lines that create them can be very small. Spikes in building +geometry are almost always errors and should be corrected. They can be inadvertently created in the +data creation process by incorrectly closing a poly-line to create a polygon. + +The original issue was raised in an [OpenStreetMap Help forum](https://help.openstreetmap.org/questions/66104/is-there-a-way-to-detect-very-sharp-angles-of-buildings). +OSM user “sanser” included [a paper](https://drive.google.com/file/d/1MaLdnSnc454xKjn3eL95vDQKeoIW8zGU/view) +with their answer which described the methodology for identifying and correcting “spiky buildings” in the UK. + +This check flags all Atlas objects for which the following criteria are true: + * The object is a building or a building part + * The angle created by the intersection of any two line segments within the building's geometry + should be less than 15˚ (configurable value). + * An angle less than 15˚ should not be flagged if it occurs at the beginning or end of a curve + within the building's geometry. There should be three configurable values that control this behavior: + minimum number of points needed to comprise a curve, angle threshold between a curve and a non-curve, + and minimum total change in heading for the curve. + +#### Live Examples + +The way [34550963](https://www.openstreetmap.org/way/34550963) is tagged as a building, and has a +spiky protrusion less than 15 degrees that was likely accidental. + +The way [60485431](https://www.openstreetmap.org/way/60485431) is tagged as a building, but looks like it was +poorly digitized when compared to satellite imagery. + +The way [503863867](https://www.openstreetmap.org/way/503863867) is tagged as a building and has an +angle less than 15 degrees, but it is part of an intentionally mapped circle. This is a correct +digitization and should not be flagged. + +#### Code Review + +###### Curves +Perfectly identifying curved sections of a building's geometry is not possible, especially given the +wide variety of mapping techniques across the world. However, it is possible to use heuristics to +get pretty close. This section will briefly walk through the algorithm used by `SpikyBuildingCheck` +to detect curves. + +The first thing to note is that throughout the code, points are stored as the two surrounding segments. +So, for a triangle with points ABC and lines (AB, BC, and CA), we store the point B as . This +way, we have a little extra context needed later on down the line. + +The entry point into this algorithm is `getCurvedLocations`, but we'll start in `getPotentiallyCircularPoints`, +which grabs all points in a polygon where the change in heading between the previous and following +segments is less than some threshold. Once we have that list, we want to combine consecutive points +into a potentially circular segment. We do this in `summarizedCurvedSections`, which takes in an ordered +list of points in a polygon, and finds the start and end of each segment of consecutive points +(points that share a segment between them). It returns the segment before the start point, the segment +after the end point, and the total number of points present in the section. From there, `getCurvedLocations` +filters out all the summarized curved sections that are either too short, or the difference in headings +between the first and last segment in the section is too small. Finally, `sectionsToLocations` takes +all of the curvedLocations and converts them from the metadata tuple with length, start and end, back +to a list of locations. By taking all the sections at once, it is able to complete the entire conversion +in a single pass of the list of all segments, since both are in the same order. + +###### More Information +For more information, see the source code in +[SpikyBuildingCheck](../../src/main/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheck.java). diff --git a/src/main/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheck.java b/src/main/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheck.java new file mode 100644 index 000000000..5e37a3108 --- /dev/null +++ b/src/main/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheck.java @@ -0,0 +1,421 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.apache.commons.lang3.tuple.Triple; +import org.openstreetmap.atlas.checks.base.BaseCheck; +import org.openstreetmap.atlas.checks.flag.CheckFlag; +import org.openstreetmap.atlas.geography.Location; +import org.openstreetmap.atlas.geography.Polygon; +import org.openstreetmap.atlas.geography.Segment; +import org.openstreetmap.atlas.geography.atlas.items.Area; +import org.openstreetmap.atlas.geography.atlas.items.AtlasObject; +import org.openstreetmap.atlas.geography.atlas.items.ItemType; +import org.openstreetmap.atlas.geography.atlas.items.Relation; +import org.openstreetmap.atlas.geography.atlas.items.RelationMember; +import org.openstreetmap.atlas.tags.BuildingPartTag; +import org.openstreetmap.atlas.tags.BuildingTag; +import org.openstreetmap.atlas.tags.annotations.validation.Validators; +import org.openstreetmap.atlas.utilities.configuration.Configuration; +import org.openstreetmap.atlas.utilities.scalars.Angle; +import org.openstreetmap.atlas.utilities.tuples.Tuple; + +/** + * This check flags all buildings with angles less than some threshold value as part of their + * geometry. The purpose is to catch buildings that were automatically closed incorrectly, or + * buildings that are likely to have been poorly digitized. In order to avoid flagging most + * buildings with curved geometry, this check uses a configurable heuristic to detect curves and + * does not flag potential spikes at the ends of a curve. + * + * @author nachtm + */ +public class SpikyBuildingCheck extends BaseCheck +{ + private static final double DEFAULT_MIN_HEADING_THRESHOLD = 15; + private static final double DEFAULT_CIRCULAR_ANGLE_THRESHOLD = 25; + private static final double DEFAULT_MINIMUM_TOTAL_CIRCULAR_ANGLE_THRESHOLD = 10; + private static final long DEFAULT_MINIMUM_CIRCULAR_POINTS = 4; + private static final List FALLBACK_INSTRUCTIONS = Collections.singletonList( + "There are sharp angles ({0} degrees, which is less than the threshold of {1} degrees) in this building's geometry. This may be a result of poor digitization."); + private Angle headingThreshold; + private Angle circularAngleThreshold; + private Angle minimumTotalCircularAngleThreshold; + private long minimumCircularPointsInCurve; + + /** + * Default constructor + * + * @param configuration + * {@link Configuration} required to construct any Check + */ + public SpikyBuildingCheck(final Configuration configuration) + { + super(configuration); + this.headingThreshold = this.configurationValue(configuration, "spiky.angle.maximum", + DEFAULT_MIN_HEADING_THRESHOLD, Angle::degrees); + this.circularAngleThreshold = this.configurationValue(configuration, + "curve.degrees.maximum.single_heading_change", DEFAULT_CIRCULAR_ANGLE_THRESHOLD, + Angle::degrees); + this.minimumTotalCircularAngleThreshold = this.configurationValue(configuration, + "curve.degrees.minimum.total_heading_change", + DEFAULT_MINIMUM_TOTAL_CIRCULAR_ANGLE_THRESHOLD, Angle::degrees); + this.minimumCircularPointsInCurve = this.configurationValue(configuration, + "curve.points.minimum", DEFAULT_MINIMUM_CIRCULAR_POINTS); + } + + @Override + public boolean validCheckForObject(final AtlasObject object) + { + return (object instanceof Area + || (object instanceof Relation && ((Relation) object).isMultiPolygon())) + && this.isBuildingOrPart(object); + } + + /** + * Given an object, returns true if that object has a building tag or building part tag + * indicating that it is either a building or a building part. + * + * @param object + * any AtlasObject + * @return true if object is a building or a building part, false otherwise + */ + private boolean isBuildingOrPart(final AtlasObject object) + { + return BuildingTag.isBuilding(object) + || Validators.isNotOfType(object, BuildingPartTag.class, BuildingPartTag.NO); + } + + /** + * Converts a RelationMember to a polygon if that member is an area. + * + * @param member + * any RelationMember object + * @return an polygon containing the geometry of member if it is an area, otherwise an empty + * optional. + */ + private Optional toPolygon(final RelationMember member) + { + if (member.getEntity().getType().equals(ItemType.AREA)) + { + return Optional.of(((Area) member.getEntity()).asPolygon()); + } + return Optional.empty(); + } + + /** + * Gets all of the polygons contained in this object, if this object has any. + * + * @param object + * any atlas object + * @return A singleton stream if object is an Area, a stream if object is a Multipolygon, or an + * empty stream if object is neither + */ + private Stream getPolygons(final AtlasObject object) + { + if (object instanceof Area) + { + return Stream.of(((Area) object).asPolygon()); + } + else if (((Relation) object).isMultiPolygon()) + { + return ((Relation) object).members().stream().map(this::toPolygon) + .flatMap(optPoly -> optPoly.map(Stream::of).orElse(Stream.empty())); + } + return Stream.empty(); + } + + /** + * Returns a set of locations which correspond to interior angles of curved portions of a + * polygons' geometry, using some heuristics to define curved. + * + * @param segments + * the cached results of a call to Polyline.segments() + * @return A set of all locations for which the heuristics hold true. + */ + private Set getCurvedLocations(final List segments) + { + final List> curvedSections = this + .summarizeCurvedSections(this.getPotentiallyCircularPoints(segments)).stream() + // Has at least minimumCircularPointsInCurve + .filter(segment -> segment.getLeft() >= minimumCircularPointsInCurve) + // Changes heading by at least minimumTotalCircularAngleThreshold + .filter(segment -> this + .getDifferenceInHeadings(segment.getMiddle(), segment.getRight(), + Angle.MINIMUM) + .isGreaterThanOrEqualTo(minimumTotalCircularAngleThreshold)) + .collect(Collectors.toList()); + return this.sectionsToLocations(curvedSections, segments); + } + + /** + * Given a polygon, return a list of all points which have a change in heading less than + * circularAngleThreshold. + * + * @param segments + * the cached results of a call to Polyline.segments() + * @return A List of Tuples containing two consecutive segments. We use this to refer to the + * point between them, since other methods further down the pipeline need the + * information about the segment. + */ + private List> getPotentiallyCircularPoints(final List segments) + { + return this.segmentPairsFrom(segments) + .filter(segmentTuple -> this.getDifferenceInHeadings(segmentTuple.getFirst(), + segmentTuple.getSecond(), Angle.MAXIMUM).isLessThan(circularAngleThreshold)) + .collect(Collectors.toList()); + } + + /** + * Given a list of potentially circular points, summarize each section into a triple containing + * the segment before the first point, the segment after the last point, and the number of + * points contained inside. + * + * @param curvedLocations + * a list of points defined by the two segments connected to those points, as + * generated by getPotentiallyCircularPoints. + * @return a list of summary stats for each curved segment, containing the number of points, the + * segment before the first point, and the segment after the last point, in that order. + */ + private List> summarizeCurvedSections( + final List> curvedLocations) + { + if (curvedLocations.isEmpty()) + { + return Collections.emptyList(); + } + final List> summaryStats = new ArrayList<>(); + Tuple start = curvedLocations.get(0); + Tuple previous = curvedLocations.get(0); + int numPoints = 1; + for (final Tuple location : curvedLocations.subList(1, + curvedLocations.size())) + { + // If this location doesn't share a segment with the previous location, we just finished + // a segment + if (!previous.getSecond().equals(location.getFirst())) + { + summaryStats.add(Triple.of(numPoints, start.getFirst(), previous.getSecond())); + numPoints = 1; + start = location; + } + // Otherwise, we're still part of the same curved section, so just increment numPoints + else + { + numPoints++; + } + // Always update previous + previous = location; + } + // Add the last triple + summaryStats.add(Triple.of(numPoints, start.getFirst(), previous.getSecond())); + // We might need to clean up a circular segment that wraps around 0. + if (summaryStats.get(0).getMiddle() + .equals(summaryStats.get(summaryStats.size() - 1).getRight())) + { + final Triple first = summaryStats.get(0); + final Triple last = summaryStats.get(0); + + summaryStats.set(0, Triple.of(first.getLeft() + last.getLeft(), last.getMiddle(), + first.getRight())); + } + return summaryStats; + } + + /** + * Given an order list of summary stats for curved sections, and a list of all the segments in a + * polygon, traverse the polygon and return a set of all curved locations. Effectively a + * reversal of summarizeCurvedSections. Note that every segment listed in curvedSections should + * exist somewhere in allSegments! + * + * @param curvedSections + * a list of summary stats for a polygon generated by summarizeCurvedSections + * @param allSegments + * the cached results of a call to Polyline.segments() + * @return a set of all locations represented by the curvedSections data structure + */ + private Set sectionsToLocations( + final List> curvedSections, + final List allSegments) + { + if (curvedSections.isEmpty()) + { + return Collections.emptySet(); + } + final Set locations = new HashSet<>(); + boolean inMiddleOfSegment = false; + int curvedSectionIndex = 0; + Segment curvedSectionStart = curvedSections.get(curvedSectionIndex).getMiddle(); + Segment curvedSectionEnd = curvedSections.get(curvedSectionIndex).getRight(); + for (final Tuple beforeAndAfter : this.segmentPairsFrom(allSegments) + .collect(Collectors.toList())) + { + if (inMiddleOfSegment) + { + // Is this the end of the curved segment? + if (curvedSectionEnd.equals(beforeAndAfter.getSecond())) + { + inMiddleOfSegment = false; + locations.add(curvedSectionEnd.start()); + curvedSectionIndex++; + if (curvedSectionIndex >= curvedSections.size()) + { + break; + } + curvedSectionStart = curvedSections.get(curvedSectionIndex).getMiddle(); + curvedSectionEnd = curvedSections.get(curvedSectionIndex).getRight(); + } + else + { + locations.add(beforeAndAfter.getFirst().end()); + } + } + else + { + // Did we come across a new curved segment? + if (curvedSectionStart.equals(beforeAndAfter.getFirst())) + { + inMiddleOfSegment = true; + locations.add(curvedSectionStart.end()); + } + // If not, do nothing + } + } + return locations; + } + + /** + * Given a polygon, return a stream consisting of all consecutive pairs of segments from this + * polygon. For example, given a polygon ABCD, returns a stream with: (AB), (BC), (CD), (DA) + * + * @param segments + * The cached results of a call to Polyline.segments() for a polygon to decompose + * @return A stream containing all of the segment pairs in this polygon + */ + private Stream> segmentPairsFrom(final List segments) + { + return Stream.concat( + // Take the first segments + IntStream.range(1, segments.size()) + .mapToObj(secondIndex -> Tuple.createTuple(segments.get(secondIndex - 1), + segments.get(secondIndex))), + // Don't forget about the closing segment! + Stream.of(Tuple.createTuple(segments.get(segments.size() - 1), segments.get(0)))); + } + + /** + * Finds curved sections of a polygon, then gets the location of all spiky angles inside of the + * polygon and composes them into a list. + * + * @param polygon + * any Polygon to analyze + * @return a list of tuples representing spiky angles. The first value is the calculated angle + * of a particular point, and the second is its location in the world. + */ + private List> getSpikyAngleLocations(final Polygon polygon) + { + final List segments = polygon.segments(); + final Set curvedLocations = this.getCurvedLocations(segments); + return this.segmentPairsFrom(segments) + .map(segmentPair -> this.getSpikyAngleLocation(segmentPair.getFirst(), + segmentPair.getSecond(), curvedLocations)) + .filter(Optional::isPresent).map(Optional::get).collect(Collectors.toList()); + } + + /** + * For an point defined by the two surrounding segments, return the angle and location of that + * point if that point is not part of a curve, and the angle between the two segments is less + * than headingThreshold. + * + * @param beforeAngle + * the segment directly before the point in question + * @param afterAngle + * the segment directly after the point in question + * @param curvedLocations + * the locations of all curved segments in the polygon + * @return an empty optional if the point is part of a curve, or if the angle is greater than or + * equal to headingThreshold. Otherwise, a tuple containing the location of the point + * and the angle between beforeAnge and afterAngle + */ + private Optional> getSpikyAngleLocation(final Segment beforeAngle, + final Segment afterAngle, final Set curvedLocations) + { + if (!curvedLocations.contains(afterAngle.end()) + && !curvedLocations.contains(beforeAngle.start())) + { + final Angle difference = this.getDifferenceInHeadings(beforeAngle, + afterAngle.reversed(), Angle.MAXIMUM); + if (difference.isLessThan(headingThreshold)) + { + return Optional.of(Tuple.createTuple(difference, afterAngle.start())); + } + } + return Optional.empty(); + } + + /** + * Gets the difference in headings between firstSegment and secondSegment, returning + * defaultAngle if either segments are a point. + * + * @param firstSegment + * the first segment to compare + * @param secondSegment + * the second segment to compare + * @param defaultAngle + * the default value to return + * @return the difference between firstSegment.heading() and secondSegment.heading() if neither + * segment is a single point (same start and end nodes), or defaultAngle if either + * segment is a single point + */ + private Angle getDifferenceInHeadings(final Segment firstSegment, final Segment secondSegment, + final Angle defaultAngle) + { + return firstSegment.heading() + .flatMap(first -> secondSegment.heading().map(first::difference)) + .orElse(defaultAngle); + } + + @Override + protected Optional flag(final AtlasObject object) + { + final List> allSpikyAngles = this.getPolygons(object) + .map(this::getSpikyAngleLocations) + .filter(angleLocations -> !angleLocations.isEmpty()).flatMap(Collection::stream) + .collect(Collectors.toList()); + if (!allSpikyAngles.isEmpty()) + { + final String instruction = this + .getLocalizedInstruction(0, + allSpikyAngles.stream().map(Tuple::getFirst).map(Angle::toString) + .collect(Collectors.joining(", ")), + headingThreshold.toString()); + final List markers = allSpikyAngles.stream().map(Tuple::getSecond) + .collect(Collectors.toList()); + final CheckFlag flag; + if (object instanceof Area) + { + flag = this.createFlag(object, instruction, markers); + } + else + { + flag = this.createFlag(((Relation) object).flatten(), instruction, markers); + } + return Optional.of(flag); + } + return Optional.empty(); + } + + @Override + protected List getFallbackInstructions() + { + return FALLBACK_INSTRUCTIONS; + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTest.java b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTest.java new file mode 100644 index 000000000..159c21d29 --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTest.java @@ -0,0 +1,121 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.openstreetmap.atlas.checks.configuration.ConfigurationResolver; +import org.openstreetmap.atlas.checks.validation.verifier.ConsumerBasedExpectedCheckVerifier; + +/** + * Tests for SpikyBuildingCheck + * + * @author nachtm + */ +public class SpikyBuildingCheckTest +{ + @Rule + public SpikyBuildingCheckTestRule setup = new SpikyBuildingCheckTestRule(); + + @Rule + public ConsumerBasedExpectedCheckVerifier verifier = new ConsumerBasedExpectedCheckVerifier(); + + @Test + public void findsSpikyBuildings() + { + verifier.actual(setup.getSpikyBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyExpectedSize(1); + } + + @Test + public void findsSpikyBuildingsRoundNumbers() + { + verifier.actual(setup.getRoundNumbersSpiky(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyExpectedSize(1); + } + + @Test + public void doesNotFindNormalBuilding() + { + verifier.actual(setup.getNormalBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyEmpty(); + } + + @Test + public void doesNotFindNormalBuildingRound() + { + verifier.actual(setup.getNormalRound(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyEmpty(); + } + + @Test + public void badCase() + { + verifier.actual(setup.badCase(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyEmpty(); + } + + @Test + public void badCase2() + { + verifier.actual(setup.badCase2(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyEmpty(); + } + + @Test + public void circleBuilding() + { + verifier.actual(setup.circleBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyEmpty(); + } + + @Test + public void circleBuildingFlaggedWithMorePoints() + { + verifier.actual(setup.circleBuilding(), new SpikyBuildingCheck(ConfigurationResolver + .inlineConfiguration("{\"SpikyBuildingCheck\":{\"curve.points.minimum\":10}}"))); + verifier.verifyExpectedSize(3); + + } + + @Test + public void circleBuildingFlaggedWithStricterAngleThreshold() + { + verifier.actual(setup.circleBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.inlineConfiguration( + "{\"SpikyBuildingCheck\":{\"curve.degrees.maximum.single_heading_change\":3.0}}"))); + verifier.verifyExpectedSize(3); + } + + @Test + public void circleBuildingFlaggedWithLargerTotalAngleRequirement() + { + verifier.actual(setup.circleBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.inlineConfiguration( + "{\"SpikyBuildingCheck\":{\"curve.degrees.minimum.total_heading_change\":179.0}}"))); + verifier.verifyExpectedSize(3); + } + + @Test + public void spikyBuildingNotFlaggedSmallThreshold() + { + verifier.actual(setup.getSpikyBuilding(), new SpikyBuildingCheck(ConfigurationResolver + .inlineConfiguration("{\"SpikyBuildingCheck\":{\"spiky.angle.maximum\":0.1}}"))); + verifier.verifyEmpty(); + } + + @Test + public void smallConsecutiveCurves() + { + verifier.actual(setup.twoShortConsecutiveCurvesBuilding(), + new SpikyBuildingCheck(ConfigurationResolver.emptyConfiguration())); + verifier.verifyExpectedSize(1); + verifier.verify(flag -> Assert.assertEquals(2, flag.getPoints().size())); + } +} diff --git a/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTestRule.java b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTestRule.java new file mode 100644 index 000000000..436dc5ca3 --- /dev/null +++ b/src/test/java/org/openstreetmap/atlas/checks/validation/areas/SpikyBuildingCheckTestRule.java @@ -0,0 +1,139 @@ +package org.openstreetmap.atlas.checks.validation.areas; + +import org.openstreetmap.atlas.geography.atlas.Atlas; +import org.openstreetmap.atlas.utilities.testing.CoreTestRule; +import org.openstreetmap.atlas.utilities.testing.TestAtlas; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Area; +import org.openstreetmap.atlas.utilities.testing.TestAtlas.Loc; + +/** + * SpikyBuildingCheck test atlases + * + * @author nachtm + */ +public class SpikyBuildingCheckTestRule extends CoreTestRule +{ + private static final String ONE = "47.616988, -122.356726"; + private static final String TWO = "47.617031, -122.353797"; + private static final String TWO_A = "47.617031, -122.350797"; + private static final String THREE = "47.615671, -122.356"; + private static final String FOUR = "47.615729, -122.353898"; + + private static final String ROUND_ONE = "0.0000005,0.0000005"; + private static final String ROUND_TWO = "0.0000035,0.0000005"; + private static final String ROUND_THREE = "0.0000025,0.0000006"; + private static final String ROUND_FOUR = "0.0000025,0.0000020"; + private static final String ROUND_FIVE = "0.0000005,0.0000020"; + + private static final String A = "22.5136768, 114.0802380"; + private static final String B = "22.5136402, 114.0802804"; + private static final String C = "22.5136130, 114.0802528"; + private static final String D = "22.5135873, 114.0802267"; + private static final String E = "22.5135594, 114.0801985"; + private static final String F = "22.5135961, 114.0801561"; + + private static final String G = "50.4542345, -4.8259661"; + private static final String H = "50.4542210, -4.8257859"; + private static final String I = "50.4542209, -4.8257867"; + private static final String J = "50.4541504, -4.8257998"; + private static final String K = "50.4541645, -4.8259790"; + + private static final String L = "1.2796474, 103.8383986"; + private static final String M = "1.2796485, 103.8382257"; + private static final String N = "1.2796800, 103.8382153"; + private static final String O = "1.2797157, 103.8382624"; + private static final String P = "1.2797573, 103.8382886"; + private static final String Q = "1.2798157, 103.8383006"; + private static final String R = "1.2798743, 103.8382891"; + private static final String S = "1.2799293, 103.8382500"; + private static final String T = "1.2799582, 103.8382024"; + private static final String U = "1.2799679, 103.8381499"; + private static final String U_PRIME = "1.27996945, 103.838543"; + private static final String V = "1.2799710, 103.8389356"; + private static final String V_PRIME = "1.27995015, 103.8385928"; + + @TestAtlas(areas = { @Area(coordinates = { @Loc(value = ONE), @Loc(value = TWO_A), + @Loc(value = TWO), @Loc(value = THREE) }, tags = "building=yes") }) + private Atlas spikyBuilding; + + @TestAtlas(areas = { @Area(coordinates = { @Loc(value = ONE), @Loc(value = TWO), + @Loc(value = FOUR), @Loc(value = THREE) }, tags = "building=yes") }) + private Atlas normalBuilding; + + @TestAtlas(areas = { @Area(coordinates = { @Loc(value = ROUND_ONE), @Loc(value = ROUND_THREE), + @Loc(value = ROUND_FOUR), @Loc(value = ROUND_FIVE) }, tags = "building=yes") }) + private Atlas normalRound; + + @TestAtlas(areas = { @Area(coordinates = { @Loc(value = ROUND_ONE), @Loc(value = ROUND_TWO), + @Loc(value = ROUND_THREE), @Loc(value = ROUND_FOUR), + @Loc(value = ROUND_FIVE) }, tags = "building=yes") }) + private Atlas roundNumbersSpiky; + + @TestAtlas(areas = { + @Area(id = "1", coordinates = { @Loc(value = A), @Loc(value = B), @Loc(value = C), + @Loc(value = D), @Loc(value = E), @Loc(value = F) }, tags = "building=yes"), }) + private Atlas badCase; + + @TestAtlas(areas = { @Area(coordinates = { @Loc(value = G), @Loc(value = H), @Loc(value = I), + @Loc(value = J), @Loc(value = K) }, tags = "building=yes") }) + private Atlas badCase2; + + @TestAtlas(areas = { + @Area(id = "1", coordinates = { @Loc(value = L), @Loc(value = M), @Loc(value = N), + @Loc(value = O), @Loc(value = P), @Loc(value = Q), @Loc(value = R), + @Loc(value = S), @Loc(value = T), @Loc(value = U), + @Loc(value = V) }, tags = "building=yes"), + @Area(id = "2", coordinates = { @Loc(value = P), @Loc(value = Q), @Loc(value = R), + @Loc(value = S), @Loc(value = T), @Loc(value = U), @Loc(value = V), + @Loc(value = L), @Loc(value = M), @Loc(value = N), + @Loc(value = O) }, tags = "building=yes"), + @Area(id = "3", coordinates = { @Loc(value = Q), @Loc(value = R), @Loc(value = S), + @Loc(value = T), @Loc(value = U), @Loc(value = V), + @Loc(value = L) }, tags = "building=yes") }) + private Atlas circleBuilding; + + @TestAtlas(areas = { @Area(id = "1", coordinates = { @Loc(value = S), @Loc(value = T), + @Loc(value = U), @Loc(value = U_PRIME), @Loc(value = V), @Loc(value = V_PRIME), + @Loc(value = L), @Loc(value = N) }, tags = "building=yes") }) + private Atlas twoShortConsecutiveCurvesBuilding; + + public Atlas getSpikyBuilding() + { + return spikyBuilding; + } + + public Atlas getNormalBuilding() + { + return normalBuilding; + } + + public Atlas getRoundNumbersSpiky() + { + return roundNumbersSpiky; + } + + public Atlas getNormalRound() + { + return normalRound; + } + + public Atlas badCase() + { + return badCase; + } + + public Atlas badCase2() + { + return badCase2; + } + + public Atlas circleBuilding() + { + return circleBuilding; + } + + public Atlas twoShortConsecutiveCurvesBuilding() + { + return twoShortConsecutiveCurvesBuilding; + } +}