问题描述
我需要将彩色背景上带有彩色文本的图片转换为带有白色背景和黑色字体颜色的图像。原图示例:
我尝试了 Convert an image to grayscale 的灰度,但它不适合我,因为我只需要白色背面和黑色字体。灰度法结果:
当我尝试将原始图像划分为 Dictionary
for (int i = 0; i < originalImage.Width; i++)
{
for (int j = 0; j < originalImage.Height; j++)
{
if (originalImage.GetPixel(i,j).ToArgb == mostPopularColorOfOriginalImage)
{
newBitmap.SetPixel(i,j,Color.White);
}
else
{
newBitmap.SetPixel(i,Color.Black);
}
}
}
更好,但黑白图像中的文字不好。结果:
解决方法
解决这个问题的基本方法如下:
- 检查图像,并构建所有出现颜色的直方图。由于颜色可以表示为
Int32
,因此您可以使用Dictionary<Int32,Int32>
。 - 从该直方图中获取两种顶部颜色。将最常出现的颜色指定为“背景颜色”,将第二个指定为“内容颜色”。
- 利用背景颜色和内容颜色之间的 R、G 和 B 差异,并使用这些差异制作调色板,从一种颜色到另一种颜色具有平滑的 256 色渐变。
- 检查图像的所有像素,对于每个像素,使用 3D 色彩空间中的勾股距离来确定它最接近生成的调色板中的哪种颜色。
- 在生成的图像上,将颜色设置为从黑色到白色的平滑渐变。
现在,GetPixel
和 SetPixel
在整个图像上循环时,速度慢得离谱,因为它们必须为处理的每个像素对图像执行相当繁重的 LockBits
操作.因此,您可以对整个图像执行一次 LockBits
操作,将字节复制出来,对结果字节数组执行所有操作,然后再次使用 LockBits
将结果复制到一张新图片。
由于我们在这里处理灰度颜色,因此将最终结果写入 8 位图像可能更有效。这也使得在进行实际颜色匹配后操作调色板变得非常容易。
如果您更喜欢将它与两种颜色完全匹配,则方法完全相同,只是不是在两种颜色之间生成颜色渐变,要匹配的调色板将仅包含两种找到的颜色。由于唯一可能匹配的索引是 0
和 1
,因此最终图像的调色板同样只需要将索引 0 和索引 1 设置为黑白,而不是获取整个灰度褪色。
结果方法:
/// <summary>
/// Finds the two most prominent colours in an image,and uses them as
/// extremes for matching all pixels on the image to a grayscale palette.
/// </summary>
/// <param name="image">Image to reduce.</param>
/// <param name="bgWhite">True if the background (the most found colour) should become the white colour. If not,it will be the black one.</param>
/// <returns>
/// An 8-bit image with the image content of the input reduced to grayscale,/// with the found two most found colours as black and white.
/// </returns>
public static Bitmap ReduceToTwoColorFade(Bitmap image,Boolean bgWhite)
{
// Get data out of the image,using LockBits and Marshal.Copy
Int32 width = image.Width;
Int32 height = image.Height;
// LockBits can actually -convert- the image data to the requested colour depth.
// 32 bpp is the easiest to get the colour components out.
BitmapData sourceData = image.LockBits(new Rectangle(0,width,height),ImageLockMode.ReadOnly,PixelFormat.Format32bppArgb);
// Not really needed for 32bpp,but technically the stride does not always match the
// amount of used data on each line,since the stride gets rounded up to blocks of 4.
Int32 stride = sourceData.Stride;
Byte[] imgBytes = new Byte[stride * height];
Marshal.Copy(sourceData.Scan0,imgBytes,imgBytes.Length);
image.UnlockBits(sourceData);
// Make colour population histogram
Int32 lineOffset = 0;
Dictionary<Int32,Int32> histogram = new Dictionary<Int32,Int32>();
for (Int32 y = 0; y < height; y++)
{
Int32 offset = lineOffset;
for (Int32 x = 0; x < width; x++)
{
// Optional check: only handle if not mostly-transparent
if (imgBytes[offset + 3] > 0x7F)
{
// Get colour values from bytes,without alpha.
// Little-endian: UInt32 0xAARRGGBB = Byte[] { BB,GG,RR,AA }
Int32 val = (imgBytes[offset + 2] << 16) | (imgBytes[offset + 1] << 8) | imgBytes[offset + 0];
if (histogram.ContainsKey(val))
histogram[val] = histogram[val] + 1;
else
histogram[val] = 1;
}
offset += 4;
}
lineOffset += stride;
}
// Sort the histogram. This requires System.Linq
KeyValuePair<Int32,Int32>[] histoSorted = histogram.OrderByDescending(c => c.Value).ToArray();
// Technically these colours will be transparent when built like this,since their
// alpha is 0,but we won't use them directly as colours anyway.
// Since we filter on alpha,getting a result is not 100% guaranteed.
Color colBackgr = histoSorted.Length < 1 ? Color.Black : Color.FromArgb(histoSorted[0].Key);
// if less than 2 colors,just default it to the same.
Color colContent = histoSorted.Length < 2 ? colBackgr : Color.FromArgb(histoSorted[1].Key);
// Make a new 256-colour palette,making a fade between these two colours,for feeding into GetClosestPaletteIndexMatch later
Color[] matchPal = new Color[0x100];
Color toBlack = bgWhite ? colContent : colBackgr;
Color toWhite = bgWhite ? colBackgr : colContent;
Int32 rFirst = toBlack.R;
Int32 gFirst = toBlack.G;
Int32 bFirst = toBlack.B;
Double rDif = (toBlack.R - toWhite.R) / 255.0;
Double gDif = (toBlack.G - toWhite.G) / 255.0;
Double bDif = (toBlack.B - toWhite.B) / 255.0;
for (Int32 i = 0; i < 0x100; i++)
matchPal[i] = Color.FromArgb(
Math.Min(0xFF,Math.Max(0,rFirst - (Int32)Math.Round(rDif * i,MidpointRounding.AwayFromZero))),Math.Min(0xFF,gFirst - (Int32)Math.Round(gDif * i,bFirst - (Int32)Math.Round(bDif * i,MidpointRounding.AwayFromZero))));
// Ensure start and end point are correct,and not mangled by small rounding errors.
matchPal[0x00] = Color.FromArgb(toBlack.R,toBlack.G,toBlack.B);
matchPal[0xFF] = Color.FromArgb(toWhite.R,toWhite.G,toWhite.B);
// The 8-bit stride is simply the width in this case.
Int32 stride8Bit = width;
// Make 8-bit array to store the result
Byte[] imgBytes8Bit = new Byte[stride8Bit * height];
// Reset offset for a new loop through the image data
lineOffset = 0;
// Make new offset var for a loop through the 8-bit image data
Int32 lineOffset8Bit = 0;
for (Int32 y = 0; y < height; y++)
{
Int32 offset = lineOffset;
Int32 offset8Bit = lineOffset8Bit;
for (Int32 x = 0; x < width; x++)
{
Int32 toWrite;
// If transparent,revert to background colour.
if (imgBytes[offset + 3] <= 0x7F)
{
toWrite = bgWhite ? 0xFF : 0x00;
}
else
{
Color col = Color.FromArgb(imgBytes[offset + 2],imgBytes[offset + 1],imgBytes[offset + 0]);
toWrite = GetClosestPaletteIndexMatch(col,matchPal);
}
// Write the found colour index to the 8-bit byte array.
imgBytes8Bit[offset8Bit] = (Byte)toWrite;
offset += 4;
offset8Bit++;
}
lineOffset += stride;
lineOffset8Bit += stride8Bit;
}
// Make new 8-bit image and copy the data into it.
Bitmap newBm = new Bitmap(width,height,PixelFormat.Format8bppIndexed);
BitmapData targetData = newBm.LockBits(new Rectangle(0,ImageLockMode.WriteOnly,newBm.PixelFormat);
// get minimum data width for the pixel format.
Int32 newDataWidth = ((Image.GetPixelFormatSize(newBm.PixelFormat) * width) + 7) / 8;
// Note that this Stride will most likely NOT match the image width; it is rounded up to the
// next multiple of 4 bytes. For that reason,we copy the data per line,and not as one block.
Int32 targetStride = targetData.Stride;
Int64 scan0 = targetData.Scan0.ToInt64();
for (Int32 y = 0; y < height; ++y)
Marshal.Copy(imgBytes8Bit,y * stride8Bit,new IntPtr(scan0 + y * targetStride),newDataWidth);
newBm.UnlockBits(targetData);
// Set final image palette to grayscale fade.
// 'Image.Palette' makes a COPY of the palette when accessed.
// So copy it out,modify it,then copy it back in.
ColorPalette pal = newBm.Palette;
for (Int32 i = 0; i < 0x100; i++)
pal.Entries[i] = Color.FromArgb(i,i,i);
newBm.Palette = pal;
return newBm;
}
使用的GetClosestPaletteIndexMatch
函数:
/// <summary>
/// Uses Pythagorean distance in 3D colour space to find the closest match to a given colour on
/// a given colour palette,and returns the index on the palette at which that match was found.
/// </summary>
/// <param name="col">The colour to find the closest match to</param>
/// <param name="colorPalette">The palette of available colours to match</param>
/// <returns>The index on the palette of the colour that is the closest to the given colour.</returns>
public static Int32 GetClosestPaletteIndexMatch(Color col,Color[] colorPalette)
{
Int32 colorMatch = 0;
Int32 leastDistance = Int32.MaxValue;
Int32 red = col.R;
Int32 green = col.G;
Int32 blue = col.B;
for (Int32 i = 0; i < colorPalette.Length; ++i)
{
Color paletteColor = colorPalette[i];
Int32 redDistance = paletteColor.R - red;
Int32 greenDistance = paletteColor.G - green;
Int32 blueDistance = paletteColor.B - blue;
// Technically,Pythagorean distance needs to have a root taken of the result,but this is not needed for just comparing them.
Int32 distance = (redDistance * redDistance) + (greenDistance * greenDistance) + (blueDistance * blueDistance);
if (distance >= leastDistance)
continue;
colorMatch = i;
leastDistance = distance;
if (distance == 0)
return i;
}
return colorMatch;
}
结果: