Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to straighten unneeded turns in a A* graph search result?

I have been working on a JavaScript implementation of the early 90's adventure games and specifically plotting a path from the place the hero is standing to the location the player has clicked on. My approach is to first determine if a strait line (without obstructions) can be drawn, if not then to search for a path of clear way-points using Brian Grinstead's excellent javascript-astar. The problem I'm facing however is the path (while optimal will veer into spaces that would seem to the user an unintended. Here is a classic example of what I'm talking about (the green path is the generated path, the red dots are each turns where the direction of the path changes):

The green path is the hero, the red dots are the turns

Now I know that A* is only guaranteed to return a path that cannot be simpler (in terms of steps), but I'm struggling to implement a heuristic that weights turns. Here is a picture that show two other paths that would also qualify as just as simple (with an equal number of steps)

Alternate paths

The Blue path would present the same number of steps and turns while the red path has the same number of steps and fewer turns. In my code I have a simplifyPath() function that removes steps where the direction changes, so if I could get all possible paths from astar then I could select the one with the least turns, but that's not how A* fundamentally works, so I'm looking for a way to incorporate simplicity into the heuristic.

Here is my current code:

var img,
    field = document.getElementById('field'),
    EngineBuilder = function(field, size) {
        var context = field.getContext("2d"),
            graphSettings = { size: size, mid: Math.ceil(size/2)},
            engine = {
                getPosition: function(event) {
                    var bounds = field.getBoundingClientRect(),
                        x = Math.floor(((event.clientX - bounds.left)/field.clientWidth)*field.width),
                        y = Math.floor(((event.clientY - bounds.top)/field.clientHeight)*field.height),
                        node = graph.grid[Math.floor(y/graphSettings.size)][Math.floor(x/graphSettings.size)];

                    return {
                        x: x,
                        y: y,
                        node: node
                    }
                },
                drawObstructions: function() {
                    context.clearRect (0, 0, 320, 200);
                    if(img) {
                        context.drawImage(img, 0, 0);
                    } else {
                        context.fillStyle = 'rgb(0, 0, 0)';
                        context.fillRect(200, 100, 50, 50);
                        context.fillRect(0, 100, 50, 50);
                        context.fillRect(100, 100, 50, 50);
                        context.fillRect(0, 50, 150, 50);
                    }
                },
                simplifyPath: function(start, complexPath, end) {
                    var previous = complexPath[1], simplePath = [start, {x:(previous.y*graphSettings.size)+graphSettings.mid, y:(previous.x*graphSettings.size)+graphSettings.mid}], i, classification, previousClassification;
                    for(i = 1; i < (complexPath.length - 1); i++) {
                        classification = (complexPath[i].x-previous.x).toString()+':'+(complexPath[i].y-previous.y).toString();
                        
                        if(classification !== previousClassification) {
                            simplePath.push({x:(complexPath[i].y*graphSettings.size)+graphSettings.mid, y:(complexPath[i].x*graphSettings.size)+graphSettings.mid});
                        } else {
                            simplePath[simplePath.length-1]={x:(complexPath[i].y*graphSettings.size)+graphSettings.mid, y:(complexPath[i].x*graphSettings.size)+graphSettings.mid};
                        }
                        previous = complexPath[i];
                        previousClassification = classification;
                    }
                    simplePath.push(end);
                    return simplePath;
                },
                drawPath: function(start, end) {
                    var path, step, next;
                    if(this.isPathClear(start, end)) {
                       this.drawLine(start, end);
                    } else {
                        path = this.simplifyPath(start, astar.search(graph, start.node, end.node), end);
                        if(path.length > 1) {
                            step = path[0];
                            for(next = 1; next < path.length; next++) {
                                this.drawLine(step, path[next]);
                                step = path[next];
                            }
                        }
                    }
                },
                drawLine: function(start, end) {
                    var x = start.x,
                        y = start.y,
                        dx = Math.abs(end.x - start.x),
                        sx = start.x<end.x ? 1 : -1,
                        dy = -1 * Math.abs(end.y - start.y),
                        sy = start.y<end.y ? 1 : -1,
                        err = dx+dy,
                        e2, pixel;

                    for(;;) {
                        pixel = context.getImageData(x, y, 1, 1).data[3];
                        if(pixel === 255) {
                            context.fillStyle = 'rgb(255, 0, 0)';
                        } else {
                            context.fillStyle = 'rgb(0, 255, 0)';
                        }
                        context.fillRect(x, y, 1, 1);
                        
                        if(x === end.x && y === end.y) {
                            break;
                        } else {
                            e2 = 2 * err;
                            if(e2 >= dy) {
                                err += dy;
                                x += sx;
                            }
                            if(e2 <= dx) {
                                err += dx;
                                y += sy;
                            }
                        }
                    }
                },
                isPathClear: function(start, end) {
                    var x = start.x,
                        y = start.y,
                        dx = Math.abs(end.x - start.x),
                        sx = start.x<end.x ? 1 : -1,
                        dy = -1 * Math.abs(end.y - start.y),
                        sy = start.y<end.y ? 1 : -1,
                        err = dx+dy,
                        e2, pixel;
                    
                    for(;;) {
                        pixel = context.getImageData(x, y, 1, 1).data[3];
                        if(pixel === 255) {
                            return false;
                        }
                        
                        if(x === end.x && y === end.y) {
                            return true;
                        } else {
                            e2 = 2 * err;
                            if(e2 >= dy) {
                                err += dy;
                                x += sx;
                            }
                            if(e2 <= dx) {
                                err += dx;
                                y += sy;
                            }
                        }
                    }
                }
            }, graph;
            engine.drawObstructions();
            graph = (function() {
                var x, y, rows = [], cols, js = '[';
                for(y = 0; y < 200; y += graphSettings.size) {
                    cols = [];
                    
                    for(x = 0; x < 320; x += graphSettings.size) {
                        cols.push(context.getImageData(x+graphSettings.mid, y+graphSettings.mid, 1, 1).data[3] === 255 ? 0 : 1);
                    }
                    js += '['+cols+'],\n';
                    rows.push(cols);
                }
                js = js.substring(0, js.length - 2);
                js += ']';
                document.getElementById('Graph').value=js;
                return new Graph(rows, { diagonal: true });
            })();
            return engine;
        }, start, end, engine = EngineBuilder(field, 10);

field.addEventListener('click', function(event) {
    var position = engine.getPosition(event);
    if(!start) {
        start = position;
    } else {
        end = position;
    }
    if(start && end) {
        engine.drawObstructions();
        engine.drawPath(start, end);
        start = end;
    }
}, false);
#field {
border: thin black solid;
    width: 98%;
    background: #FFFFC7;
}
#Graph {
    width: 98%;
    height: 300px;
    overflow-y: scroll;
}
<script src="http://jason.sperske.com/adventure/astar.js"></script>
<code>Click on any 2 points on white spaces and a path will be drawn</code>
<canvas id='field' height
    
='200' width='320'></canvas>
<textarea id='Graph' wrap='off'></textarea>

After digging into Michail Michailidis' excellent answer I've added the following code to my simplifyPath() function) (demo):

simplifyPath: function(start, complexPath, end) {
    var previous = complexPath[1],
        simplePath = [start, {x:(previous.y*graphSettings.size)+graphSettings.mid, y:(previous.x*graphSettings.size)+graphSettings.mid}],
        i,
        finalPath = [simplePath[0]],
        classification,
        previousClassification;

    for(i = 1; i < (complexPath.length - 1); i++) {
        classification = (complexPath[i].x-previous.x).toString()+':'+(complexPath[i].y-previous.y).toString();

        if(classification !== previousClassification) {
            simplePath.push({x:(complexPath[i].y*graphSettings.size)+graphSettings.mid, y:(complexPath[i].x*graphSettings.size)+graphSettings.mid});
        } else {
            simplePath[simplePath.length-1]={x:(complexPath[i].y*graphSettings.size)+graphSettings.mid, y:(complexPath[i].x*graphSettings.size)+graphSettings.mid};
        }
        previous = complexPath[i];
        previousClassification = classification;
    }

    simplePath.push(end);
    previous = simplePath[0];
    for(i = 2; i < simplePath.length; i++) {
        if(!this.isPathClear(previous, simplePath[i])) {
            finalPath.push(simplePath[i-1]);
            previous = simplePath[i-1];
        }
    }
    finalPath.push(end);

    return finalPath;
}

Basically after it reduces redundant steps in the same direction, it tries to smooth out the path by looking ahead to see if it can eliminate any steps.

like image 700
Jason Sperske Avatar asked Oct 28 '14 18:10

Jason Sperske


People also ask

Will A * always find the lowest cost path?

A* is optimal. It will always return the least-cost path.

What is the heuristic function of A * search?

The A* algorithm uses a heuristic function to help decide which path to follow next. The heuristic function provides an estimate of the minimum cost between a given node and the target node.

Is A * optimal?

A* is complete and optimal on graphs that are locally finite where the heuristics are admissible and monotonic.

HOW DOES A * pathfinding work?

At its core, a pathfinding method searches a graph by starting at one vertex and exploring adjacent nodes until the destination node is reached, generally with the intent of finding the cheapest route.


4 Answers

Very very intresting problem! Thanks for this question! So...some observations first:

Not allowing diagonal movement fixes this problem but since you are interested in diagonal movement I had to search more.

I had a look at path simplifying algorithms like: Ramer Douglas Peuker (http://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm) and an implementation: https://gist.github.com/rhyolight/2846020. I added an implementation to your code without success. This algorithm doesn't take into account obstacles so it was difficult to adapt it.

I wonder what would the behavior be (for diagonal movements) if you had used Dijkstra instead of A* or if you used an 'all shortest paths between a pair of nodes' algorithm and then you sorted them by increasing changes in direction.

After reading a bit about A* here http://buildnewgames.com/astar/ I thought that the implementation of A-star you are using is the problem or the heuristics. I tried all the heuristics on the a-star of your code including euclidean that I coded myself and tried also all the heuristics in the http://buildnewgames.com/astar code Unfortunately all of the diagonal allowing heuristics were having the same issue you are describing.

I started working with their code because it is a one-to-one grid and yours was giving me issues drawing. Your simplifyPath that I tried to remove was also causing additional problems. You have to keep in mind that since you are doing a remapping this could be an issue based on that

  • On a square grid that allows 4 directions of movement, use Manhattan distance (L1).
  • On a square grid that allows 8 directions of movement, use Diagonal distance (L∞).
  • On a square grid that allows any direction of movement, you might or might not want Euclidean distance (L2). If A* is finding paths on the grid but you are allowing movement not on the grid, you may want to consider other representations of the map. (from http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html)

What is my pseudocode algorithm:

var path = A-star();
for each node in path {
    check all following nodes till some lookahead limit
    if you find two nodes in the same row but not column or in the same column but not row { 
       var nodesToBeStraightened = push all nodes to be "straightened" 
       break the loop;
    }
    skip loop index to the next node after zig-zag
}

if nodesToBeStraightened length is at least 3 AND
   nodesToBeStraightened nodes don't form a line AND
   the resulting Straight line after simplification doesn't hit an obstruction

       var straightenedPath =  straighten by getting the first and last elements of nodesToBeStraightened  and using their coordinates accordingly

return straightenedPath;

Here is the visual explanation of what is being compared in the algorithm:

Visual Explanation: Visual Representation of the algorithm

How this code will be used with yours (I did most of the changes - I tried my best but there are so many problems like with how you do drawing and because of the rounding of the grid etc - you have to use a grid and keep the scale of the paths accurate - please see also assumptions below):

    var img,
    field = document.getElementById('field'),
    EngineBuilder = function(field, size) {
        var context = field.getContext("2d"),
        graphSettings = { size: size, mid: Math.ceil(size/2)},
        engine = {
            //[...] missing code
            removeZigZag: function(currentPath,lookahead){
                //for each of the squares on the path - see lookahead more squares and check if it is in the path
                for (var i=0; i<currentPath.length; i++){

                    var toBeStraightened = [];
                    for (var j=i; j<lookahead+i+1 && j<currentPath.length; j++){         
                        var startIndexToStraighten = i;
                        var endIndexToStraighten = i+1;

                        //check if the one from lookahead has the same x xor the same y with one later node in the path
                        //and they are not on the same line
                        if( 
                           (currentPath[i].x == currentPath[j].x && currentPath[i].y != currentPath[j].y) ||
                           (currentPath[i].x == currentPath[j].y && currentPath[i].x != currentPath[j].x)   
                        ) { 
                            endIndexToStraighten = j;

                            //now that we found something between i and j push it to be straightened
                            for (var k = startIndexToStraighten; k<=endIndexToStraighten; k++){
                                toBeStraightened.push(currentPath[k]);
                            }
                            //skip the loop forward
                            i = endIndexToStraighten-1;
                            break;
                        }
                    }

                    if (toBeStraightened.length>=3                                   
                        && !this.formsALine(toBeStraightened) 
                        && !this.lineWillGoThroughObstructions(currentPath[startIndexToStraighten], currentPath[endIndexToStraighten],this.graph?????)
                        ){
                        //straightening:
                        this.straightenLine(currentPath, startIndexToStraighten, endIndexToStraighten);
                    }
                }
                return currentPath;
            },

            straightenLine: function(currentPath,fromIndex,toIndex){
                for (var l=fromIndex; l<=toIndex; l++){

                    if (currentPath[fromIndex].x == currentPath[toIndex].x){
                         currentPath[l].x = currentPath[fromIndex].x;
                    }
                    else if (currentPath[fromIndex].y == currentPath[toIndex].y){
                         currentPath[l].y = currentPath[fromIndex].y;       
                    }
                }
            },

            lineWillGoThroughObstructions: function(point1, point2, graph){
                var minX = Math.min(point1.x,point2.x);
                var maxX = Math.max(point1.x,point2.x);
                var minY = Math.min(point1.y,point2.y);
                var maxY = Math.max(point1.y,point2.y);

                //same row
                if (point1.y == point2.y){
                    for (var i=minX; i<=maxX && i<graph.length; i++){
                        if (graph[i][point1.y] == 1){ //obstacle
                            return true;
                        } 
                    }
                }
                //same column
                if (point1.x == point2.x){
                    for (var i=minY; i<=maxY && i<graph[0].length; i++){
                        if (graph[point1.x][i] == 1){ //obstacle
                            return true;
                        } 
                    }
                }
                return false;
            },

            formsALine: function(pointsArray){
                //only horizontal or vertical
                if (!pointsArray || (pointsArray && pointsArray.length<1)){
                    return false;
                }

                var firstY = pointsArray[0].y;
                var lastY = pointsArray[pointsArray.length-1].y;

                var firstX = pointsArray[0].x;
                var lastX = pointsArray[pointsArray.length-1].x;

                //vertical line
                if (firstY == lastY){
                    for (var i=0; i<pointsArray.length; i++){
                        if (pointsArray[i].y!=firstY){
                            return false;
                        }
                    }
                    return true;
                }

                //horizontal line
                else if (firstX == lastX){
                    for (var i=0; i<pointsArray.length; i++){
                        if (pointsArray[i].x!=firstX){
                            return false;
                        }
                    }
                    return true;
                }       
                return false;
            }
            //[...] missing code
        }
        //[...] missing code
    }

Assumptions and Incompatibilities of the above code:

  • obstacle is 1 and not 0
  • the orginal code I have in the demo is using array instead of {x: number, y:number}
  • in case you use the other a-star implementation, the point1 location is on the column 1 and row 2.
  • converted to your {x: number, y:number} but haven't checked all the parts:
  • I couldn't access the graph object to get the obstacles - see ?????
  • You have to call my removeZigZag with a lookahead e.g 7 (steps away) in the place where you were doing your path simplification
  • admittedly their code is not that good compared to the a-star implementation from Stanford but for our purposes it should be irelevant
  • possibly the code has bugs that I don't know of and could be improved. Also I did my checks only in this specific world configuration
  • I believe the code has complexity O(N x lookahead)~O(N) where N is the number of steps in the input A* shortest path.

Here is the code in my github repository (you can run the demo) https://github.com/zifnab87/AstarWithDiagonalsFixedZigZag

  • based on this A* Javascript implementation downloaded from here: http://buildnewgames.com/astar/

Their clickHandling and world boundary code is broken as when you click on the right side of the map the path calculation is not working sometimes. I didn't have time to find their bug. As a result my code has the same issue Probably it is because the map I put from your question is not square - but anyway my algorithm should be unaffected. You will see this weird behavior is happening if non of my remove ZigZag code runs. (Edit: It was actually because the map was not square - I updated the map to be square for now)

Feel free to play around by uncommenting this line to see the before-after:

result = removeZigZag(result,7);

I have attached 3 before after image sets so the results can be visualized: (Keep in mind to match start and goal if you try them - direction DOES matter ;) )

Case 1: Before Case 1: BeforeCase 1: After Case 1: AfterCase 2: Before Case 2 BeforeCase 2: After Case 2: AfterCase 3: Before Case 3: BeforeCase 3: After Case 3: AfterCase 4: Before Case 4: BeforeCase 4: After Case 4: AfterResources:

  • My code (A* diagonal movement zig zag fix demo): https://github.com/zifnab87/AstarWithDiagonalsFixedZigZag
  • Original Javascript A* implementation of my demo can be found above (first commit) or here: - http://buildnewgames.com/astar/
  • A* explanation: http://buildnewgames.com/astar/
  • A* explanation from Stanford: http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
  • JavaScript A* implementation used by OP's question (Github):
  • Ramer Douglas Peuker Algorithm (Wikipedia): http://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
  • Javascript implementation of Douglas Peuker Algorithm: https://gist.github.com/rhyolight/2846020
  • A* Algorithm (Wikipedia): http://en.wikipedia.org/wiki/A*_search_algorithm
like image 58
Michail Michailidis Avatar answered Oct 24 '22 01:10

Michail Michailidis


You can use a modified A* algorithm to account for changes in direction. While simplifying the result of the standard A* algorithm may yield good results, it may not be optimal. This modified A* algorithm will return a path of minimal length with the least number of turns.

In the modified A* algorithm, each position corresponds to eight different nodes, each with their own heading. For example, the position (1, 1) corresponds to the eight nodes

(1,1)-up, (1,1)-down, (1,1)-right, (1,1)-left,

(1,1)-up-left, (1,1)-up-right, (1,1)-down-left, and (1,1)-down-right

The heuristic distance from a node to the goal is the the heuristic distance from the corresponding point to the goal. In this case, you probably want to use the following function:

H(point) = max(abs(goal.xcor-point.xcor), abs(goal.ycor-point.ycor))

The nodes that correspond to a particular position are connected to the nodes of the neighboring positions with the proper heading. For example, the nodes corresponding to the position (1,1) are all connected to the following eight nodes

(1,2)-up, (1,0)-down, (2,1)-right, (0,1)-left,

(0,2)-up-left, (2,2)-up-right, (0,0)-down-left, and (2,0)-down-right

The distance between any two connected nodes depends on their heading. If they have the same head, then the distance is 1, otherwise, we have made a turn, so the distance is 1+epsilon. epsilon represents an arbitrarily small value/number.

We know need to have a special case for the both the start and goal. The start and goal are both represented as a single node. At the start, we have no heading, so the distance between the start node and any connected node is 1.

We can now run the standard A* algorithm on the modified graph. We can map the returned path to a path in the original grid, by ignoring the headings. The total length of the returned path will be of the form n+m*epsilon. n is the total length of the corresponding path in the original grid, and m is the number of turns. Because A* returns a path of minimal length, the path in the original grid is a path of minimal length that makes the least turns.

like image 2
Joshua Avatar answered Oct 24 '22 02:10

Joshua


I have come up with somewhat of a fix that is a simple addition to your original code, but it doesn't work in all situations (see image below) because we are limited to what the A* returns us. You can see my jsfiddle here

I added the following code to your simplifyPath function right before the return. What it does is strips out extra steps by seeing if there is a clear path between non-adjacent steps (looking at larger gaps first). It could be optimized, but you should get the gist from what I've got.

do{
    shortened = false;
    loop:
    for(i = 0; i < simplePath.length; i++) {
        for(j = (simplePath.length - 1); j > (i + 1); j--) {
            if(this.isPathClear(simplePath[i],simplePath[j])) {
                simplePath.splice((i + 1),(j - i - 1));
                shortened = true;
                break loop;
            }
        }
    }
} while(shortened == true);

You can see below that this removes the path that goes in on the left (as in the question) but also that not all the odd turns are removed. This solution only uses the points provided from the A*, not points in between on the path - for example, because the 2nd point does not have a straight unobstructed line to the 4th or 5th points, it cannot optimize point 3 out. It happens a lot less than the original code, but it still does give weird results sometimes. incorrect path

like image 1
Jo. Avatar answered Oct 24 '22 00:10

Jo.


In edition to nodes having references to their parent nodes. Also store which direction that node came from inside a variable. In my case there was only two possibilities horizontally or vertically. So I created two public static constants for each possibility. And a helper function named "toDirection" which takes two nodes and return which direction should be taken in order to go from one to another:

public class Node {
    final static int HORIZONTALLY = 0;
    final static int VERTICALLY = 1;

    int col, row;
    boolean isTravelable;
    int fromDirection;
    double hCost;
    double gCost;
    double fCost;

    Node parent;

    public Node(int col, int row, boolean isTravelable) {
        this.col = col;
        this.row = row;
        this.isTravelable = isTravelable;
    }

    public static int toDirection(Node from, Node to) {
        return (from.col != to.col) ? Node.HORIZONTALLY : Node.VERTICALLY;
    }
}

Then you can change your weight calculation function to take turns into account. You can now give a small punishment for turns like:

public double calcGCost(Node current, Node neighbor) {
    if(current.fromDirection == Node.toDirection(current, neighbor)) {
        return 1;
    } else{
        return 1.2;
    }
}

Full code: https://github.com/tezsezen/AStarAlgorithm

like image 1
user11629899 Avatar answered Oct 24 '22 02:10

user11629899