Where’d The Water Go? Google Maps Water Pixel Detection With Canvas

An enhancement to a product we’ve been working on recently included a mockup of a business logo overlaid on a Google Map with people scattered randomly nearby intending to represent potential first time customers.

Bites Mockup

Everything seemed fairly straight forward: I’d use the Google Maps API to include the map centered at the coordinates of the business, overlay the transparent blue rings, randomly scatter the people icons across the map, and then stylize the business’s logo on top of everything. After getting a rough prototype of the page working, something awful happened: the people icons were being placed in the middle of water!

Bites People In Water

I looked at the screen for a few seconds, trying to figure out what to do. Should we scrap the people all together to not risk the product looking silly with Belly customers floating aimlessly in the water? How many of our businesses are near water where this would be a noticeable problem? Should we only include fixed people icons next to the business marker so it would be more likely that no one would be stranded (since it’s almost guaranteed that the business is on land)? Was there a way to figure out if a person would be placed in water ahead of time to avoid such a catastrophe?

I remembered reading about a cool project a while ago that used Neural Networks, OCR, and the Canvas element to solve captchas, so I wondered if it were possible to use the same approach to detect whether a given pixel (where the person would be placed) was water or land. After discovering that miscellaneous text of labels (“Lake Michigan”, “Atlantic Ocean”, “Mississippi River”, etc.) in the water would throw this method off in the same image, I finally settled on using two maps: one to display to the user, and another in a canvas element in memory with all non-water features removed to run through pixel detection.

1
2
Original Image
http://maps.googleapis.com/maps/api/staticmap?scale=2&center={LATLONG}&zoom=13&size=1024x160&sensor=false&visual_refresh=true

Bites Google Map

The Google Maps API lets you selectively style elements, including hiding geographic features all together. I finally settled on hiding everything but water (including labels), and styled the water in an easy to detect rgb(0, 255, 0) Lime Green.

1
2
3
4
5
6
7
8
9
Water Feature Image
http://maps.googleapis.com/maps/api/staticmap?scale=2&center={LATLONG}
&zoom=13&size=1024x160&sensor=false&visual_refresh=true
&style=feature:water|color:0x00FF00
&style=element:labels|visibility:off
&style=feature:transit|visibility:off
&style=feature:poi|visibility:off
&style=feature:road|visibility:off
&style=feature:administrative|visibility:off

Bites Google Water Feature

Initially, we load the unedited Google Maps Image centered at the coordinates of the business into a canvas in the screen. We wait for that image to load and then load the second Google Maps image with all water styled as Lime Green and every other feature hidden. We iterate through a loop which attempts to randomly add people to the map and skips a randomly generated pair if it is too close to an existing person (to avoid overlap) or if the pixel is detected to be water. To detect if a pixel is water, we use the Canvas getImageData method on the hidden water feature canvas, which returns a given pixels RGBA values and allows us to easily compare it to rgb(0, 255, 0). We stop iterating through the loop if we’ve placed the desired number of people on the map, or if we have failed too much due to people overlapping or a lot of water in the image.

Putting it all together, here’s a code sample of rendering random people on a Google Maps Image:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
var latlon = "40.7300694,-74.0024224"; // Sample Lat,Long (Manhattan NYC)
var distance_between_people = 40; // Keep 40 pixels between people on map
var max_map_people = 40; // Attempt to put 40 people on map

var $people_layer = $('.people');

// Create an in-memory canvas and store its 2d context
var water_context = document.createElement('canvas');
water_context.setAttribute('width', 1024);
water_context.setAttribute('height', 160);
water_context = water_context.getContext('2d');

// Assumes <canvas> element already in DOM with class "map"
var map_context = $('canvas.map')[0].getContext('2d');

var map = new Image();
map.crossOrigin = 'http://maps.googleapis.com/crossdomain.xml';
map.src = "http://maps.googleapis.com/maps/api/staticmap?scale=2&center=" + latlon + "&zoom=13&size=1024x160&sensor=false&visual_refresh=true";

map.onload = function(){
  // Put the map image inside the canvas once it loads
  map_context.drawImage(map, 0, 0, 1024, 256);

  water = new Image();
  water.crossOrigin = 'http://maps.googleapis.com/crossdomain.xml';
  water.src = "http://maps.googleapis.com/maps/api/staticmap?scale=2&center=" + latlon + "&zoom=13&size=1024x160&sensor=false&visual_refresh=true&style=element:labels|visibility:off&style=feature:water|color:0x00FF00&style=feature:transit|visibility:off&style=feature:poi|visibility:off&style=feature:road|visibility:off&style=feature:administrative|visibility:off";

  water.onload = function(){
    // Put the water image inside the water canvas
    water_context.drawImage(water, 0, 0, 1024, 256);
    render_random_people();
  }
}

function render_random_people(){
  $people_layer.empty();

  var tries = 0,
  drawn = [];

  // Give up after 2 * map_num_people tries in case it's not possible to place map_num_people icons on the map
  while(tries < max_map_people * 2 && drawn.length < max_map_people){
    tries++;

    // 5px padding around edges (1024 x 160 pixel Map)
    x = _.random(5, 1019);
    y = _.random(5, 155);

    // Skip this random (x,y) pair if it's nearby any currently drawn person
    if( any_people_nearby(drawn, x, y) ) continue;

    // Get the RGB colors at the given random pixel
    pixels = water_context.getImageData(x, y, 1, 1).data;

    // Skip if the pixel is water
    if( color_is_water(pixels) ) continue;

    // Append the icon to the layer (not all CSS included here)
    $people_layer.append('<div class="person" style="top:' + y + 'px;left:' + x + 'px"></div>');

    // Store record of where the person was drawn so nothing overlaps later
    drawn.push({x: x, y: y});
  }
}

function any_people_nearby(current_people, x, y){
  // Return true if any current_people have an (x,y) pair closer than distance_between_people
  return _.some(current_people, function(coords){
    var distance = Math.pow( Math.pow(x - coords.x, 2) + Math.pow(y - coords.y, 2), .5);
    return distance <= distance_between_people;
  });
}

function color_is_water(bytes){
  // Return true if the color is #00FF00 (Lime Green passed to Google Maps as water style property)
  var water_color_bytes = [0, 255, 0],
  our_color_bytes = [bytes[0], bytes[1], bytes[2]];
  return _.isEqual(water_color_bytes, our_color_bytes);
}

There are a few things which could be further optimized, like using Poisson-Disk Sampling to avoid placing people on top of each other instead of naively looping through every person drawn to avoid overlaps, but for our case the above is simple and works well. We toyed with the idea of using a different icon for people we knew were placed in the water (something silly like a mermaid), but decided it might cause confusion, as we do not yet expose the ability to target mermaids.

Bites Final

A working demo is on JSFiddle.

Ask a question or share this article, we’d love to hear from you!