2

The Google Maps iOS SDK's heat map (more specifically the Google-Maps-iOS-Utils framework) decides the color to render an area in essentially by calculating the density of the points in that area.

However, I would like to instead select the color based on the average weight or intensity of the points in that area.

From what I understand, this behavior is not built in (but who knows––the documentation sort of sucks). The file where the color-picking is decided is I think in /src/Heatmap/GMUHeatmapTileLayer.mThis is a relatively short file, but I am not very well versed in Objective-C, so I am having some difficulty figuring out what does what. I think -tileForX:y:zoom: in GMUHeatmapTileLayer.m is the important function, but I'm not sure and even if it is, I don't quite know how to modify it. Towards the end of this method, the data is 'convolved' first horizontally and then vertically. I think this is where the intensities are actually calculated. Unfortunately, I do not know exactly what it's doing, and I am afraid of changing things because I suck at obj-c. This is what the convolve parts of this method look like:

- (UIImage *)tileForX:(NSUInteger)x y:(NSUInteger)y zoom:(NSUInteger)zoom {

  // ...

  // Convolve data.
  int lowerLimit = (int)data->_radius;
  int upperLimit = paddedTileSize - (int)data->_radius - 1;
  // Convolve horizontally first.
  float *intermediate = calloc(paddedTileSize * paddedTileSize, sizeof(float));
  for (int y = 0; y < paddedTileSize; y++) {
    for (int x = 0; x < paddedTileSize; x++) {
      float value = intensity[y * paddedTileSize + x];
      if (value != 0) {
        // convolve to x +/- radius bounded by the limit we care about.
        int start = MAX(lowerLimit, x - (int)data->_radius);
        int end = MIN(upperLimit, x + (int)data->_radius);
        for (int x2 = start; x2 <= end; x2++) {
          float scaledKernel = value * [data->_kernel[x2 - x + data->_radius] floatValue];
          // I THINK THIS IS WHERE I NEED TO MAKE THE CHANGE
          intermediate[y * paddedTileSize + x2] += scaledKernel;
          // ^
        }
      }
    }
  }
  free(intensity);
  // Convole vertically to get final intensity.
  float *finalIntensity = calloc(kGMUTileSize * kGMUTileSize, sizeof(float));
  for (int x = lowerLimit; x <= upperLimit; x++) {
    for (int y = 0; y < paddedTileSize; y++) {
      float value = intermediate[y * paddedTileSize + x];
      if (value != 0) {
        int start = MAX(lowerLimit, y - (int)data->_radius);
        int end = MIN(upperLimit, y + (int)data->_radius);
        for (int y2 = start; y2 <= end; y2++) {
          float scaledKernel = value * [data->_kernel[y2 - y + data->_radius] floatValue];
          // I THINK THIS IS WHERE I NEED TO MAKE THE CHANGE
          finalIntensity[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit] += scaledKernel;
          // ^
        }
      }
    }
  }
  free(intermediate);

  // ...

}

This is the method where the intensities are calculated for each iteration, right? If so, how can I change this to achieve my desired effect (average, not summative colors, which I think are proportional to intensity).

So: How can I have averaged instead of summed intensities by modifying the framework?

IHaveAQuestion
  • 789
  • 8
  • 26

2 Answers2

1

I think you are on the right track. To calculate average you divide the point sum by the point count. Since you already have the sums calculated, I think an easy solution would be to also save the count for each point. If I understand it correctly, this it what you have to do.

When allocating memory for the sums also allocate memory for the counts

// At this place
float *intermediate = calloc(paddedTileSize * paddedTileSize, sizeof(float));
// Add this line, calloc will initialize them to zero
int *counts = calloc(paddedTileSize * paddedTileSize, sizeof(int));

Then increase the count in each loop.

// Below this line (first loop)
intermediate[y * paddedTileSize + x2] += scaledKernel;
// Add this
counts[y * paddedTileSize + x2]++;

// And below this line (second loop)
finalIntensity[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit] += scaledKernel;
// Add this
counts[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit]++;

After the two loops you should have two arrays, one with your sums finalIntensity and one with your counts counts. Now go through the values and calculate the averages.

for (int y = 0; y < paddedTileSize; y++) {
    for (int x = 0; x < paddedTileSize; x++) {
        int n = y * paddedTileSize + x;
        if (counts[n] != 0)
            finalIntensity[n] = finalIntensity[n] / counts[n];
    }
}
free(counts);

The finalIntensity should now contain your averages.

If you prefer, and the rest of the code makes it possible, you can skip the last loop and instead do the division when using the final intensity values. Just change any subsequent finalIntensity[n] to counts[n] == 0 ? finalIntensity[n] : finalIntensity[n] / counts[n].

LGP
  • 4,135
  • 1
  • 22
  • 34
  • It is usually good to pair `calloc()` and `free()`. Since `counts` is just used temporarily it can be freed up after it has served its purpose. Not doing so will result in memory leaks every time the code fragment is run. However, if you are certain that the `paddedTileSize` doesn't change during the life of the app, you can certainly do the `calloc()` once, when the app starts, and never free it up. That will maybe give you a small speed increase. – LGP Feb 01 '18 at 22:04
  • Do you know how or even *if* this could be taken advantage of to display something like "yes" and "no" votes in two different colors? I was thinking of using the intensities as-is, but I realize now that every point contains every color, but with different radii. – IHaveAQuestion Feb 02 '18 at 02:44
  • Well, I haven't done must work of this kind, but one idea I would try is to use a color scale that range from (for example) blue to red color, giving the strongest blue color a value of zero, and the strongest red color the maximum value. Then for values in between assign colors gradually faded from one to the other. Depending on how it is presented a middle color of black or white could be used. Look at the accepted answer here and at the color range at the bottom of the page to see what I mean. https://stackoverflow.com/questions/15032562/ios-find-color-at-point-between-two-colors – LGP Feb 02 '18 at 07:28
  • Here is another link on doing gradients with two colors. https://stackoverflow.com/questions/22218140/calculate-the-color-at-a-given-point-on-a-gradient-between-two-colors – LGP Feb 02 '18 at 07:31
  • Looking at the heat map source, it does support working with an arbitrary number of colors and also does gradients for you. Not sure if the built-in gradient function will do the job for you, but why not try it? I suggest start with two base colors. Use the `GMUGradient` class and its `initWithColors:startPoints:colorMapSize:` method to create the object. Then set it on your tile with `setGradient:`. You set `colorMapSize` to the number of interpolated colors you want between your base colors. – LGP Feb 02 '18 at 07:49
  • I have been doing what you described for a couple days now, but when using your code, I noticed that all the points look identical even though some have intensities of 0.1 and others of 0.9. Are intensities supposed to be outside of this range? – IHaveAQuestion Feb 02 '18 at 12:40
  • You mean with the colors? – LGP Feb 02 '18 at 12:47
  • Ah actually what exactly is `colorMapSize`? For two colors, should it be 2 (or 3 if I add some color in between)? Right now I think I have it at 256 like in Google’s example. – IHaveAQuestion Feb 02 '18 at 12:50
  • It seems to be the number of shades/interpolated colors between the end point colors. The default values are set to `initWithColors:gradientColors startPoints:@[ @0.2f, @1.0f ] colorMapSize:1000`, so the gradient should then be 1000 shades between 0.2 and 1.0, as I understand it. – LGP Feb 02 '18 at 12:57
  • The number of shades will affect the smoothness of the colors as the data values change. Higher `colorMapSize` means smoother color changes on the map. – LGP Feb 02 '18 at 13:00
  • And does the intensity signify where the gradient begins? Does each point show all the colors on the gradient? – IHaveAQuestion Feb 02 '18 at 13:10
  • Each data point will have a color depending on its value. If you have set the colors to red 0.0 and blue 1.0, and your data point is 0.1, then it will be (roughly) 90% red and 10% blue, assuming you have a reasonable high number of shades (e.g. `colorMapSize = 1000`). – LGP Feb 02 '18 at 13:16
  • Will the red (at 0) always be closer to the center of the point than the blue (at 1)? Is there a way to change his for each point? – IHaveAQuestion Feb 02 '18 at 14:34
  • If you by _center of the point_ mean a point on the map with a maximum, then you can reverse the colors by simply changing the order in the `gradiendColors` array that holds the colors for `initWithColors:`. This will change the max/min colors for all of the map. I don't think you can have different colors for the same value in different areas. – LGP Feb 02 '18 at 14:42
  • I mean that the colors seem to diffuse from the center of each data point, so every point will show multiple colors. Does it work like this: there are two colors, A(0) and B(1). When you have an intensity of 0.3, 0.3 times the radius of the circle will be colored A, and the remaining 0.7 will be B. – IHaveAQuestion Feb 02 '18 at 15:10
  • As I understand it, yes. And if you have more than 2 set for `colorMapSize` you should get a smooth transition between the colors. – LGP Feb 02 '18 at 16:01
0

I may have just solved the same issue for the java version.

My problem was having a custom gradient with 12 different values. But my actual weighted data does not necessarily contain all intensity values from 1 to 12.

The problem is, the highest intensity value gets mapped to the highest color. Also 10 datapoints with intensity 1 that are close by will get the same color as a single point with intensity 12.

So the function where the tile gets created is a good starting point:

Java:

public Tile getTile(int x, int y, int zoom) {
// ...
// Quantize points
    int dim = TILE_DIM + mRadius * 2;
    double[][] intensity = new double[dim][dim];
    int[][] count = new int[dim][dim];
    for (WeightedLatLng w : points) {
        Point p = w.getPoint();
        int bucketX = (int) ((p.x - minX) / bucketWidth);
        int bucketY = (int) ((p.y - minY) / bucketWidth);
        intensity[bucketX][bucketY] += w.getIntensity();
        count[bucketX][bucketY]++;
    }
    // Quantize wraparound points (taking xOffset into account)
    for (WeightedLatLng w : wrappedPoints) {
        Point p = w.getPoint();
        int bucketX = (int) ((p.x + xOffset - minX) / bucketWidth);
        int bucketY = (int) ((p.y - minY) / bucketWidth);
        intensity[bucketX][bucketY] += w.getIntensity();
        count[bucketX][bucketY]++;
    }
    for(int bx = 0; bx < dim; bx++)
        for (int by = 0; by < dim; by++)
            if (count[bx][by] != 0)
                intensity[bx][by] /= count[bx][by];
//...

I added a counter and count every addition to the intensities, after that I go through every intensity and calculate the average.

For C:

- (UIImage *)tileForX:(NSUInteger)x y:(NSUInteger)y zoom:(NSUInteger)zoom {
//...
// Quantize points.
    int paddedTileSize = kGMUTileSize + 2 * (int)data->_radius;
    float *intensity = calloc(paddedTileSize * paddedTileSize, sizeof(float));
    int *count = calloc(paddedTileSize * paddedTileSize, sizeof(int));
    for (GMUWeightedLatLng *item in points) {
        GQTPoint p = [item point];
        int x = (int)((p.x - minX) / bucketWidth);
        // Flip y axis as world space goes south to north, but tile content goes north to south.
        int y = (int)((maxY - p.y) / bucketWidth);
        // If the point is just on the edge of the query area, the bucketing could put it outside
        // bounds.
        if (x >= paddedTileSize) x = paddedTileSize - 1;
        if (y >= paddedTileSize) y = paddedTileSize - 1;
        intensity[y * paddedTileSize + x] += item.intensity;
        count[y * paddedTileSize + x] ++;
    }
    for (GMUWeightedLatLng *item in wrappedPoints) {
        GQTPoint p = [item point];
        int x = (int)((p.x + wrappedPointsOffset - minX) / bucketWidth);
        // Flip y axis as world space goes south to north, but tile content goes north to south.
        int y = (int)((maxY - p.y) / bucketWidth);
        // If the point is just on the edge of the query area, the bucketing could put it outside
        // bounds.
        if (x >= paddedTileSize) x = paddedTileSize - 1;
        if (y >= paddedTileSize) y = paddedTileSize - 1;
        // For wrapped points, additional shifting risks bucketing slipping just outside due to
        // numerical instability.
        if (x < 0) x = 0;
        intensity[y * paddedTileSize + x] += item.intensity;
        count[y * paddedTileSize + x] ++;
    }
    for(int i=0; i < paddedTileSize * paddedTileSize; i++)
        if (count[i] != 0)
            intensity[i] /= count[i];

Next is the convolving. What I did there, is to make sure that the calculated value does not go over the maximum in my data.

Java:

// Convolve it ("smoothen" it out)
double[][] convolved = convolve(intensity, mKernel, mMaxAverage);

// the mMaxAverage gets set here:
public void setWeightedData(Collection<WeightedLatLng> data) {
// ...
    // Add points to quad tree
    for (WeightedLatLng l : mData) {
        mTree.add(l);
        mMaxAverage = Math.max(l.getIntensity(), mMaxAverage);
    }
// ...
// And finally the convolve method:
static double[][] convolve(double[][] grid, double[] kernel, double max) {
// ...
    intermediate[x2][y] += val * kernel[x2 - (x - radius)];
    if (intermediate[x2][y] > max) intermediate[x2][y] = max;
// ...
    outputGrid[x - radius][y2 - radius] += val * kernel[y2 - (y - radius)];
    if (outputGrid[x - radius][y2 - radius] > max ) outputGrid[x - radius][y2 - radius] = max;

For C:

// To get the maximum average you could do that here:
- (void)setWeightedData:(NSArray<GMUWeightedLatLng *> *)weightedData {
    _weightedData = [weightedData copy];
    for (GMUWeightedLatLng *dataPoint in _weightedData)
        _maxAverage = Math.max(dataPoint.intensity, _maxAverage)
// ...
// And then simply in the convolve section
    intermediate[y * paddedTileSize + x2] += scaledKernel;
    if (intermediate[y * paddedTileSize + x2] > _maxAverage) 
        intermediate[y * paddedTileSize + x2] = _maxAverage;
// ...
   finalIntensity[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit] += scaledKernel;
   if (finalIntensity[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit] > _maxAverage)
       finalIntensity[(y2 - lowerLimit) * kGMUTileSize + x - lowerLimit] = _maxAverage;

And finally the coloring

Java:

// The maximum intensity is simply the size of my gradient colors array (or the starting points) 
Bitmap bitmap = colorize(convolved, mColorMap, mGradient.mStartPoints.length);

For C:

// Generate coloring
// ...
float max = [data->_maxIntensities[zoom] floatValue];
max = _gradient.startPoints.count;

I did this in Java and it worked for me, not sure about the C-code though.

You have to play around with the radius and you could even edit the kernel. Because I found that when I have a lot of homogeneous data (i.e. little variation in the intensities, or a lot of data in general) the heat map will degenerate to a one-colored overlay, because the gradient on the edges will get smaller and smaller.

But hope this helps anyway.

// Erik