熱力圖生成演算法及其具體實現

charlee44發表於2022-05-21

1. 概述

以前一直覺得熱力圖非常高大上,現在終於有機會研究並總結這個問題了。其實從影像處理的角度上來說,熱力圖生成演算法並沒有什麼特別的,要得到非常漂亮的效果,資料以及配色方案的也很重要。這裡就用OpenCV簡單實現一下,用什麼工具不重要,重要的是其中的原理。

2. 詳論

2.1. 資料準備

我們沒有資料,但是可以通過隨機數演算法,生成一個熱力點的集合:

struct HPoint {
  int x;
  int y;
  int value;
};

int width = 512;   //熱力圖寬
int height = 512;  //熱力圖高
int reach = 25;    //影響範圍
int valueRange = 100;

vector<HPoint> heatPoints;  //熱力點
vector<HRect> heatRects;    //熱力範圍

void GetHeatPoint() {
  int num = 100;
  heatPoints.resize(num);
  heatRects.resize(num);

  for (int i = 0; i < num; i++) {
    heatPoints[i].x = rand() % width;
    heatPoints[i].y = rand() % height;
    heatPoints[i].value = rand() % valueRange;

    heatRects[i].left = (std::max)(heatPoints[i].x - reach, 0);
    heatRects[i].top = (std::max)(heatPoints[i].y - reach, 0);
    heatRects[i].right = (std::min)(heatPoints[i].x + reach, width - 1);
    heatRects[i].bottom = (std::min)(heatPoints[i].y + reach, height - 1);
  }
}

這段程式碼的意思是,我們根據給定的熱力圖寬高的範圍,生成熱力圖範圍內一定權值範圍的熱力點;並且,根據熱力點影響範圍求出其外包矩形。這裡的隨機數並沒有給時間種子,所以每次執行的結果都是固定的。

2.2. 準備繪製

我們繪製的目的是一個包含透明度的彩色圖片,所以需要建立4波段的圖片。通過直接操作圖片的記憶體buffer,首先我們將背景設定成黑色;然後遍歷熱力點,將熱力點的範圍塗成白色:

Mat img(height, width, CV_8UC4);
int nBand = 4;

uchar *data = img.data;
size_t dataLength = (size_t)width * height * nBand;
memset(data, 0, dataLength);

for (size_t i = 0; i < heatPoints.size(); i++) {
  //遍歷熱力點範圍
  for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
    for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
      size_t m = (size_t)width * nBand * hi + wi * nBand;
      data[m + 0] = data[m + 1] = data[m + 2] = data[m + 3] = 255;
    }
  }
}

imshow("熱力圖", img);

waitKey();

imglink1

2.3. 繪製熱力範圍

上面繪製的是熱力點的外接矩形範圍,現在我們繪製熱力圖真正影響範圍。原理其實很簡單,就是判斷點是否在圓內:

  for (size_t i = 0; i < heatPoints.size(); i++) {
    //遍歷熱力點範圍
    for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
      for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
        //判斷是否在熱力圈範圍
        float length =
            sqrt((float)(wi - heatPoints[i].x) * (wi - heatPoints[i].x) +
                 (hi - heatPoints[i].y) * (hi - heatPoints[i].y));
        if (length <= reach) {
          size_t m = (size_t)width * nBand * hi + wi * nBand;
          data[m + 0] = data[m + 1] = data[m + 2] = data[m + 3] = 255;
        }
      }
    }
  }

imglink2

2.4. 繪製熱力圖

接下來就讓熱力範圍根據與熱力點的距離漸變:距離越近,就越白,距離越遠,就越黑:

  for (size_t i = 0; i < heatPoints.size(); i++) {
    //遍歷熱力點範圍
    for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
      for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
        //判斷是否在熱力圈範圍
        float length =
            sqrt((float)(wi - heatPoints[i].x) * (wi - heatPoints[i].x) +
                 (hi - heatPoints[i].y) * (hi - heatPoints[i].y));
        if (length <= reach) {
          float alpha = ((reach - length) / reach);

          size_t m = (size_t)width * nBand * hi + wi * nBand;
          data[m + 0] = data[m + 1] = data[m + 2] = data[m + 3] = uchar(255 * alpha);
        }
      }
    }
  }

imglink3

立體感到是不錯,但是問題在於我們需要將熱力點的影響疊加起來,也就是每次遍歷熱力點之後,畫素值也要疊加起來:

  for (size_t i = 0; i < heatPoints.size(); i++) {
    //遍歷熱力點範圍
    for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
      for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
        //判斷是否在熱力圈範圍
        float length =
            sqrt((float)(wi - heatPoints[i].x) * (wi - heatPoints[i].x) +
                 (hi - heatPoints[i].y) * (hi - heatPoints[i].y));
        if (length <= reach) {
          float alpha = ((reach - length) / reach);

          size_t m = (size_t)width * nBand * hi + wi * nBand;
          float newAlpha = data[m + 3] / 255.0f + alpha;
          newAlpha = std::min(std::max(newAlpha * 255, 0.0f), 255.0f);
          data[m + 0] = data[m + 1] = data[m + 2] = data[m + 3] =
              uchar(newAlpha);
        }
      }
    }
  }

imglink4

看起來略具意思了,但是有個問題是沒有體現每個點的權值的影響,因此我們加上權值的影響,讓熱力的效果更真實一點:

  for (size_t i = 0; i < heatPoints.size(); i++) {
    //權值因子
    float ratio = (float)heatPoints[i].value / valueRange;

    //遍歷熱力點範圍
    for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
      for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
        //判斷是否在熱力圈範圍
        float length =
            sqrt((float)(wi - heatPoints[i].x) * (wi - heatPoints[i].x) +
                 (hi - heatPoints[i].y) * (hi - heatPoints[i].y));
        if (length <= reach) {
          float alpha = ((reach - length) / reach) * ratio;

          size_t m = (size_t)width * nBand * hi + wi * nBand;
          float newAlpha = data[m + 3] / 255.0f + alpha;
          newAlpha = std::min(std::max(newAlpha * 255, 0.0f), 255.0f);
          data[m + 0] = data[m + 1] = data[m + 2] = data[m + 3] =
              uchar(newAlpha);
        }
      }
    }
  }

imglink5

2.5. 配色方案

最後就是給這個黑白熱力圖上色了。配色是非常重要的,需要一點美術功底才行,我們直接採用參考2中的顏色值進行配色。首先建立一個顏色對映表,將之前的黑白色對映到一個BGR漸變色集合:

array<array<uchar, 3>, 256> bGRTable;  //顏色對映表

//生成漸變色
void Gradient(array<uchar, 3> &start, array<uchar, 3> &end,
              vector<array<uchar, 3>> &RGBList) {
  array<float, 3> dBgr;
  for (int i = 0; i < 3; i++) {
    dBgr[i] = (float)(end[i] - start[i]) / (RGBList.size() - 1);
  }

  for (size_t i = 0; i < RGBList.size(); i++) {
    for (int j = 0; j < 3; j++) {
      RGBList[i][j] = (uchar)(start[j] + dBgr[j] * i);
    }
  }
}

void InitAlpha2BGRTable() {
  array<double, 7> boundaryValue = {0.2, 0.3, 0.4, 0.6, 0.8, 0.9, 1.0};
  array<array<uchar, 3>, 7> boundaryBGR;
  boundaryBGR[0] = {255, 0, 0};
  boundaryBGR[1] = {231, 111, 43};
  boundaryBGR[2] = {241, 192, 2};
  boundaryBGR[3] = {148, 222, 44};
  boundaryBGR[4] = {83, 237, 254};
  boundaryBGR[5] = {50, 118, 253};
  boundaryBGR[6] = {28, 64, 255};

  double lastValue = 0;
  array<uchar, 3> lastRGB = {0, 0, 0};
  vector<array<uchar, 3>> RGBList;
  int sumNum = 0;
  for (size_t i = 0; i < boundaryValue.size(); i++) {
    int num = 0;
    if (i == boundaryValue.size() - 1) {
      num = 256 - sumNum;
    } else {
      num = (int)((boundaryValue[i] - lastValue) * 256 + 0.5);
    }

    RGBList.resize(num);
    Gradient(lastRGB, boundaryBGR[i], RGBList);

    for (int i = 0; i < num; i++) {
      bGRTable[i + sumNum] = RGBList[i];
    }
    sumNum = sumNum + num;

    lastValue = boundaryValue[i];
    lastRGB = boundaryBGR[i];
  }
}

通過這個顏色對映表,在填充畫素的時候,將計算的Alpha對映成一個BGR值,填充到前三個波段中:

  for (size_t i = 0; i < heatPoints.size(); i++) {
    //權值因子
    float ratio = (float)heatPoints[i].value / valueRange;

    //遍歷熱力點範圍
    for (int hi = heatRects[i].top; hi <= heatRects[i].bottom; hi++) {
      for (int wi = heatRects[i].left; wi <= heatRects[i].right; wi++) {
        //判斷是否在熱力圈範圍
        float length =
            sqrt((float)(wi - heatPoints[i].x) * (wi - heatPoints[i].x) +
                 (hi - heatPoints[i].y) * (hi - heatPoints[i].y));
        if (length <= reach) {
          float alpha = ((reach - length) / reach) * ratio;

          //計算Alpha
          size_t m = (size_t)width * nBand * hi + wi * nBand;
          float newAlpha = data[m + 3] / 255.0f + alpha;
          newAlpha = std::min(std::max(newAlpha * 255, 0.0f), 255.0f);
          data[m + 3] = (uchar)(newAlpha);

          //顏色對映
          for (int bi = 0; bi < 3; bi++) {
            data[m + bi] = bGRTable[data[m + 3]][bi];
          }
        }
      }
    }
  }

最終的成果如下:
imglink6

3. 問題

  1. OpenCV顯示的背景是黑色的,這是因為其預設是按照RGB三波段來顯示的,其實最後的結果是個包含透明通道的影像,可以將其疊加到任何圖層上:
    imglink7
  2. 熱力點可以有權值,也可以沒有。沒有權值可以認為所有點的權值是一樣的,可以適當調整熱力影響的範圍讓不同的熱力點連線,否則就是一個個獨立的圈。
  3. 如果出現紅色的區域(熱力值高)過多,那麼原因可能是熱力點太密了。同一個區域內收到的熱力影響太多,計算的alpha超過1,對映到影像畫素值導致被截斷,無法區分熱力值高的區域。那麼一個合理的改進方案就是將計算的alpha快取住,在計算所有的alpha的最大最小,將alpha再度對映到0到1之間,進而對映到畫素值的0~255之間——就不會高位截斷的問題了。如果有機會,再實現一下這個問題的改進。

4. 參考

  1. 你不知道的前端演算法之熱力圖的實現
  2. 資料視覺化:淺談熱力圖如何在前端實現

具體原始碼實現

相關文章