voidcolor2gray(BITMAP *bmImg, BITMAP *bmGray) { BITMAPINFOHEADER *bmiHeader = &bmImg->bmInfo->bmiHeader; gen_gray(bmImg, bmGray); uint8_t minY = 255, maxY = 0; for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmImg->bmBytesPerRow + w * bmImg->bmBytesPerPel; uint8_t *B = &bmImg->bmData[pos]; uint8_t *G = &bmImg->bmData[pos + 1]; uint8_t *R = &bmImg->bmData[pos + 2]; uint8_t Y = adjust(0.299 * *R + 0.587 * *G + 0.114 * *B); if (Y < minY) { minY = Y; } if (Y > maxY) { maxY = Y; } } } // rearrange gray indensity for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmImg->bmBytesPerRow + w * bmImg->bmBytesPerPel; uint8_t *B = &bmImg->bmData[pos]; uint8_t *G = &bmImg->bmData[pos + 1]; uint8_t *R = &bmImg->bmData[pos + 2]; uint8_t Y = adjust(0.299 * *R + 0.587 * *G + 0.114 * *B); uint32_t _pos = h * bmGray->bmBytesPerRow + w; bmGray->bmData[_pos] = adjust(255. * (Y - minY) / (maxY - minY)); } } }
Step Two: Change Gray to Binary
In order to binarize the image, we need determine a threshold, and change pixels whose grayscale is below the threshold to black, and the others to white. But how to find the optimal threshold? There’s an excellent algorithm called Otsu’s method, which, in brief, maximize inter-class variance.
voidotsu_gray2binary(BITMAP *bmGray, BITMAP *bmBinary) { BITMAPINFOHEADER *bmiHeader = &bmGray->bmInfo->bmiHeader; gen_gray(bmGray, bmBinary); uint8_t minG = 255, maxG = 0; double *p = (double *) malloc(1 << 11); memset(p, 0, 1 << 11); for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmGray->bmBytesPerRow + w; ++p[bmGray->bmData[pos]]; if (bmGray->bmData[pos] < minG) { minG = bmGray->bmData[pos]; } if (bmGray->bmData[pos] > maxG) { maxG = bmGray->bmData[pos]; } } } double muT = 0; for (int16_t k = minG; k <= maxG; ++k) { p[k] /= bmiHeader->biHeight * bmiHeader->biWidth; muT += k * p[k]; } uint8_t threshold = 0; double omegaK = 0, muK = 0, maxB = 0; for (int16_t k = minG; k < maxG; ++k) { omegaK += p[k]; muK += k * p[k]; double sigmaK = (muT * omegaK - muK) * (muT * omegaK - muK) / omegaK / (1 - omegaK); if (maxB < sigmaK) { maxB = sigmaK; threshold = k; } } free(p); for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmGray->bmBytesPerRow + w; bmBinary->bmData[pos] = bmGray->bmData[pos] < threshold ? 0 : 255; } } }
Otsu’s method sets the global threshold, but for some special image with different luminance, adaptive thresholding may produce better result. There are at least two ways to apply adaptive thresholding.
Let’s say that white is the foreground and black is the background. Assume you have a solid circle, and you can only place it on white pixels, which means the circle shouldn’t cover the black pixels. We make the available area where the center can be put white and the others black, and get a new image—the result of erosion.
voiderode(BITMAP *bmBinary, BITMAP *bmErosion, uint32_t border) { BITMAPINFOHEADER *bmiHeader = &bmBinary->bmInfo->bmiHeader; gen_gray(bmBinary, bmErosion); border >>= 1; for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmBinary->bmBytesPerRow + w; // range checking uint32_t x1 = h > border ? h - border : 0; uint32_t x2 = h + border + 1 < bmiHeader->biHeight ? h + border + 1 : bmiHeader->biHeight; uint32_t y1 = w > border ? w - border : 0; uint32_t y2 = w + border + 1 < bmiHeader->biWidth ? w + border + 1 : bmiHeader->biWidth; uint8_t flag = 255; for (uint32_t _h = x1; _h < x2; ++_h) { for (uint32_t _w = y1; _w < y2; ++_w) { uint32_t _pos = _h * bmBinary->bmBytesPerRow + _w; flag &= bmBinary->bmData[_pos]; } } bmErosion->bmData[pos] = flag; } } }
This time, you want to put the center of the circle on white pixels regardless of whether other points lie on black pixels. We make the area that can be covered by the circle white and the others black. This is what dilation do. Surprisingly, the code is very very similar with erosion.
voiddilate(BITMAP *bmBinary, BITMAP *bmDilation, uint32_t border) { BITMAPINFOHEADER *bmiHeader = &bmBinary->bmInfo->bmiHeader; gen_gray(bmBinary, bmDilation); border >>= 1; for (uint32_t h = 0; h < bmiHeader->biHeight; ++h) { for (uint32_t w = 0; w < bmiHeader->biWidth; ++w) { uint32_t pos = h * bmBinary->bmBytesPerRow + w; // range checking uint32_t x1 = h > border ? h - border : 0; uint32_t x2 = h + border + 1 < bmiHeader->biHeight ? h + border + 1 : bmiHeader->biHeight; uint32_t y1 = w > border ? w - border : 0; uint32_t y2 = w + border + 1 < bmiHeader->biWidth ? w + border + 1 : bmiHeader->biWidth; uint8_t flag = 0; for (uint32_t _h = x1; _h < x2; ++_h) { for (uint32_t _w = y1; _w < y2; ++_w) { uint32_t _pos = _h * bmBinary->bmBytesPerRow + _w; flag |= bmBinary->bmData[_pos]; } } bmDilation->bmData[pos] = flag; } } }
Step Four: Opening and Closing
In fact, opening operation and closing operation are just the combination of the two operations we mentioned above, the only difference is the order. Opening operation is an erosion followed by a dilation, while closing operation is the reverse.
Visually speaking, opening operation makes the gaps between black pixels disappeared, and closing operation makes the gaps between white pixels disappeared.
Note that these two operations are both idempotent, which means performing one of them multiple times is equivalent to performing it one time.