Search code examples
javaalgorithm2dcollision-detection

Algorithm to detect and combine overlapping / colliding circles


I'm trying to write a time efficient algorithm that can detect a group of overlapping circles and make a single circle in the "middle" of the group that will represent that group. The practical application of this is representing GPS locations over a map, put the conversion in to Cartesian co-ordinates is already handled so that's not relevant, the desired effect is that at different zoom levels clusters of close together points just appear as a single circle (that will have the number of points printed in the centre in the final version)

In this example the circles just have a radius of 15 so the distance calculation (Pythagoras) is not being square rooted and compared to 225 for the collision detection. I was trying anything to shave off time, but the problem is this really needs to happen very quickly becasue it's a user facing bit of code that needs to be snappy and good looking.

I've given this a go and I it works with small data sets pretty well. 2 big problems, it takes too long and it can run out of memory if all the points are on top of one another.

The route I've taken is to calculate distance between each point in a first pass, and then take the shortest distance first and start to combine from there, anything that's been combined becomes ineligible for combination on that pass, and the whole list is passed back around to the distance calculations again until nothing changes.

To be honest I think it needs a radical shift in approach and I think it's a little beyond me. I've re factored my code in to one class for ease of posting and generated random points to give an example.

package mergepoints;

import java.awt.Point;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Merger {

    public static void main(String[] args) {

        Merger m = new Merger();

        m.subProcess(m.createRandomList());

    }

    private List<Plottable> createRandomList() {
        List<Plottable> points = new ArrayList<>();
        for (int i = 0; i < 50000; i++) {
            Plottable p = new Plottable();
            p.location = new Point((int) Math.floor(Math.random() * 1000),
                    (int) Math.floor(Math.random() * 1000));
            points.add(p);
        }
        return points;

    }

    private List<Plottable> subProcess(List<Plottable> visible) {

        List<PlottableTuple> tuples = new ArrayList<PlottableTuple>();

        // create a tuple to store distance and matching objects together,
        for (Plottable p : visible) {
            PlottableTuple tuple = new PlottableTuple();
            tuple.a = p;
            tuples.add(tuple);
        }

        // work out each Plottable relative distance from
        // one another and order them by shortest first.
        // We may need to do this multiple times for one set so going in own
        // method.

        // this is the bit that takes ages

        setDistances(tuples);

        // Sort so that smallest distances are at the top.
        // parse the set and combine any pair less than the smallest distance in
        // to a combined pin.
        // any plottable thats been combine is no longer eligable for combining
        // so ignore on this parse.

        List<PlottableTuple> sorted = new ArrayList<>(tuples);
        Collections.sort(sorted);

        Set<Plottable> done = new HashSet<>();
        Set<Plottable> mergedSet = new HashSet<>();
        for (PlottableTuple pt : sorted) {
            if (!done.contains(pt.a) && pt.distance <= 225) {
                Plottable merged = combine(pt, done);
                done.add(pt.a);
                for (PlottableTuple tup : pt.others) {
                    done.add(tup.a);
                }

                mergedSet.add(merged);
            }
        }

        // if we haven't processed anything we are done just return visible
        // list.
        if (done.size() == 0) {
            return visible;
        } else {
            // change the list to represent the new combined plottables and
            // repeat the process.
            visible.removeAll(done);
            visible.addAll(mergedSet);
            return subProcess(visible);
        }
    }

    private Plottable combine(PlottableTuple pt, Set<Plottable> done) {
        List<Plottable> plottables = new ArrayList<>();
        plottables.addAll(pt.a.containingPlottables);
        for (PlottableTuple otherTuple : pt.others) {
            if (!done.contains(otherTuple.a)) {
                plottables.addAll(otherTuple.a.containingPlottables);
            }
        }

        int x = 0;
        int y = 0;
        for (Plottable p : plottables) {
            Point position = p.location;
            x += position.x;
            y += position.y;
        }
        x = x / plottables.size();
        y = y / plottables.size();

        Plottable merged = new Plottable();
        merged.containingPlottables.addAll(plottables);
        merged.location = new Point(x, y);

        return merged;
    }

    private void setDistances(List<PlottableTuple> tuples) {

        System.out.println("pins: " + tuples.size());
        int loops = 0;
        // Start from the first item and loop through, then repeat but starting
        // with the next item.
        for (int startIndex = 0; startIndex < tuples.size() - 1; startIndex++) {
            // Get the data for the start Plottable
            PlottableTuple startTuple = tuples.get(startIndex);
            Point startLocation = startTuple.a.location;

            for (int i = startIndex + 1; i < tuples.size(); i++) {
                loops++;

                PlottableTuple compareTuple = tuples.get(i);

                double distance = distance(startLocation, compareTuple.a.location);

                setDistance(startTuple, compareTuple, distance);
                setDistance(compareTuple, startTuple, distance);

            }
        }
        System.out.println("loops " + loops);
    }

    private void setDistance(PlottableTuple from, PlottableTuple to,
            double distance) {
        if (distance < from.distance || from.others == null) {
            from.distance = distance;
            from.others = new HashSet<>();
            from.others.add(to);
        } else if (distance == from.distance) {
            from.others.add(to);
        }
    }

    private double distance(Point a, Point b) {
        if (a.equals(b)) {
            return 0.0;
        }
        double result = (((double) a.x - (double) b.x) * ((double) a.x - (double) b.x))
                + (((double) a.y - (double) b.y) * ((double) a.y - (double) b.y));
        return result;
    }

    class PlottableTuple implements Comparable<PlottableTuple> {
        public Plottable a;
        public Set<PlottableTuple> others;

        public double distance;

        @Override
        public int compareTo(PlottableTuple other) {
            return (new Double(distance)).compareTo(other.distance);
        }
    }

    class Plottable {
        public Point location;
        private Set<Plottable> containingPlottables;

        public Plottable(Set<Plottable> plots) {
            this.containingPlottables = plots;
        }

        public Plottable() {
            this.containingPlottables = new HashSet<>();
            this.containingPlottables.add(this);
        }

        public Set<Plottable> getContainingPlottables() {
            return containingPlottables;
        }

    }

}

Solution

  • Map all your circles on a 2D grid first. You then only need to compare the circles in a cell with the other circles in that cell and in it's 9 neighbors (you can reduce that to five by using a brick pattern instead of a regular grid).

    If you only need to be really approximate, then you can just group all the circles that fall into a cell together. You will probably also want to merge cells that only have a small number of circles together with there neighbors, but this will be fast.