All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.openstreetmap.atlas.checks.validation.areas.SpikyBuildingCheck Maven / Gradle / Ivy

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;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy