在 C# 中解码 Portable Bitmap (PBM) 文件并在 WPF 中显示它们
本教程面向中级程序员,介绍如何使用 C# 解码 PBM (Portable Bitmap) 文件。
引言和背景
自从几年前开始编程以来,我一直以创建图像查看器为目标。虽然我最初使用的是 C 和 C++,但我已经转向使用 C# 编程,并且对在这门语言中读取图像格式的信息匮乏感到沮丧。在学习了更多编程知识并完成了一个读取 vCard 的独立项目后,我回到了创建图像查看器的项目,这次比我早期的尝试取得了更大的成功。
在这里,我将向您展示我用纯 C#(不使用第三方库)编写的 Portable Bitmap 查看器的实现,并希望以一种简单、易于理解的方式展示。
Using the Code
首先,有必要解释一下,便携式位图有两种类型:ASCII 和二进制。ASCII 文件比二进制文件大得多,而且是较旧的格式,但据说更不容易损坏。维基百科和在线文件格式百科全书都有关于该格式的优秀文章。
这两种类型都有一个魔术数字;文件开头是一个两个字节的序列。我们需要先检查这个来确定文件类型。
public int readMagicNumber(string fname)
{
FileStream pbm_name = new FileStream(
fname,
FileMode.Open,
FileAccess.Read,
FileShare.None);
using (BinaryReader r = new BinaryReader(pbm_name))
{
Int16 test = r.ReadInt16();
if (test == 0x3150)//P1
{
return 1;
}
else if (test == 0x3450)//P4
{
return 0;
}
else //Get in a tantrum
{
return -1;
}
}
}
下一步是读取文件。我们先来看 ASCII 类型。
与 JPEG 和 BMP 不同,PBM 文件并不直接。没有偏移量告诉我们数据从哪里开始,也没有固定的字节顺序。数据中甚至可以随意穿插文本注释。这种看似宽松的方法是故意的,旨在保护数据并在网络上传输(该格式属于一个名为 Network Portable Bitmaps 或 NetPBMs 的“家族”)位图。但这并没有让我们的工作变得简单。注意:readData
的参数是一个将存储像素数据的 char 数组,其余部分应从代码中自行解释。
public void readData(
string fname,
out int Height,
out int Width,
out char[] byteAndCharArray)
{
string input = File.ReadAllText(fname);
input = input.Replace("\r", "");
string[] StringArray = input.Split(
new string[] { "\n" },
StringSplitOptions.RemoveEmptyEntries);
//Array of all values in the file
int mNumberCounter = 0, dimCounter = 0, result;
//counter for the magic number, dimensions and an empty integer to
//hold the result of a search that we’ll do later on.
Width = 0;
Height = 0;
bool result1;
byteAndCharArray = null;
for (int i = 0; i < StringArray.Length; i++)
{
//Check to see whether or not the line starts with "# "
if (StringArray[i].StartsWith("# "))
{
continue; //It’s a comment – we need to ignore it.
}
//This line is not a comment.
else if (mNumberCounter == 0)
//If we’ve not encountered the magic number,
//then this should really be an error,
//but I’m trying to be flexible.
{
string[] BrokenUp = StringArray[i].Split(
new string[] { " " },
StringSplitOptions.RemoveEmptyEntries);
//Break up the string by whitespace characters.
switch (BrokenUp.Length)
{
case 1:
//If we’ve found something, but only one thing,
//then this must be the magic number, but there
//are no dimensions
//we have only got the magic number
mNumberCounter = 1;
break;
case 2:
//we have only got the Width (the width is given
//BEFORE the height
mNumberCounter = 1;
//We must have the magic number
result1 = int.TryParse(
BrokenUp[1],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
//Try to parse the width as an integer
if (result1)
//If it works
{
Width = result;
//set the width
dimCounter++;
}
else
{
continue;
//Well, it wasn’t the width after all
}
break;
case 3:
//we have everything, the width the height and the
//magic no.
mNumberCounter = 1;
result1 = int.TryParse(
BrokenUp[1],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Width = result;
dimCounter++;
}
else
{
continue;
}
result1 = int.TryParse(
BrokenUp[2],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Height = result;
dimCounter++;
}
else
{
continue;
//okay, so maybe we don’t, we’ll keep looking
}
break;
}
}
好的,现在我们已经确认找到的唯一数据是魔术数字,是时候寻找其余部分了。首先,我们需要检查 dimCounter
。请记住,这是一个计数器,告诉我们找到了多少个维度;PBM 格式规定宽度在前,所以如果 dimCounter
只有 1,那么我们只找到了宽度。
else if (dimCounter == 0)
//If we’ve only found the magic no., but not any dimensions
{
string[] BrokenUp = StringArray[i].Split(
new string[] { " " },
StringSplitOptions.RemoveEmptyEntries);
switch (BrokenUp.Length)
{
case 1:
//we have only got the Width
result1 = int.TryParse(
BrokenUp[0],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Width = result;
dimCounter++;
}
else
{
continue;
}
break;
case 2:
//we have only got the Height
mNumberCounter = 1;
result1 = int.TryParse(
BrokenUp[0],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Width = result;
dimCounter++;
}
else
{
continue;
}
result1 = int.TryParse(
BrokenUp[1],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Height = result;
dimCounter++;
}
else
{
continue;
}
break;
}
}
else if (dimCounter == 1)
//Height will be found hopefully
{
string[] BrokenUp = StringArray[i].Split(
new string[] { " " },
StringSplitOptions.RemoveEmptyEntries);
result1 = int.TryParse(
BrokenUp[0],
NumberStyles.Integer,
CultureInfo.CurrentCulture,
out result);
if (result1)
{
Height = result;
dimCounter++;
}
else
{
continue;
}
}
好的,通过排除法,我们已经找到了所有维度和魔术数字。之后就是数据本身——位图。我们甚至检查了行是否是注释,所以如果不是注释并且所有东西都已找到,那么这一定是数据。
我们逐个字符地读取剩余数据,并将 1 和 0 添加到 char 数组中。
诀窍在于,这些文件可以在其中包含注释,但它们是行内注释,一旦写入,注释将持续到新行开始。注释可以包含 1 和 0,但不应被程序读取为像素数据。为了让程序知道何时处于注释的中间,我们在遇到注释的开头('#')时将一个布尔标志设置为 true。当此标志被设置并且我们读取的字符不是换行符时,我们将继续而不将任何内容添加到我们的像素数组中。如果它是换行符,那么它标志着该注释的结束,我们将标志设置为 false。
else
//We’ve got the data and there isn’t a comment - take it that
//this is the start of the pixel data.
{
string main = ConvertStringArrayToString(StringArray, i);
bool booleanFlag = false;
//This will be used to tell the program whether we’re
//in a comment.
byteAndCharArray = new char[Width*Height];
int j = 0;
foreach (char c in main)
{
if (booleanFlag && c != '\n')
//If we're in a comment, just carry on
continue;
else if (booleanFlag && c == '\n')
//A newline character can mark the end of a comment.
//If we've met one, then set the flag to false,
//so that at the next loop, we read the data
booleanFlag = false;
else if (c == '0' || c == '1')
//If we've met a 1 or 0 and we're not in a comment,
//then add it to the array.
{
byteAndCharArray[j] = c;
j++;
}
else if (c == '#')
//This marks the start of a comment.
booleanFlag = true;
//Well, we’ve found a comment, so we need to make
//sure the program knows in future.
else
//If it's not met any of the above conditions,
//it's just junk and we can get rid of (i.e. ignore) it.
continue;
}
break;
}
好的,我们已经获取了像素。它们已经被检查过,我们现在只能处理我们获得的数据。下一步是将这些数据转换为有用的东西。在我的例子中,我将这些数据转换为 System.Drawing.Bitmap
,然后保存为 BitmapImage
。这可用于在 WPF 的 Image
元素中显示数据。对于 Windows Forms,我认为您可以省略 BitmapImage
部分,只返回 Bitmap
,将其强制转换为 Image
,然后在 PictureBox
中显示,但我不用 WinForms,所以如果我说错了请原谅。
我的代码很简单,但速度很慢。使用“不安全”代码(即指针)会比这快得多,但我不是这方面的专家,而且我花了一些时间才弄清楚二进制(见下文)。基本上,我将上面的源字符传递给程序,然后程序将像素设置为白色(如果字符是 '1')和黑色(如果字符是 '0')(好吧,任何不是 '1' 的,应该是 '0')。
public System.Windows.Media.Imaging.BitmapImage bmpFromPBM(
char[] pixels,
int width,
int height)
{
//Remember that pixels is simply a string of "0"s and
//"1"s. Width and Height are integers.
int Width = width;
int Height = height;
//Create our bitmap
using (Bitmap B = new Bitmap(Width, Height))
{
int X = 0;
int Y = 0;
//Current X,Y co-ordinates of the Bitmap object
//Loop through all of the bits
for (int i = 0; i < pixels.Length; i++)
{
//Below, we're comparing the value with "0".
//If it is a zero, then we change the pixel white,
//else make it black.
B.SetPixel(X, Y, pixels[i] == '0' ?
System.Drawing.Color.White :
System.Drawing.Color.Black );
//Increment our X position
X += 1;//Move along the right
//If we're passed the right boundry,
//reset the X and move the Y to the next line
if (X >= Width)
{
X = 0;//reset
Y += 1;//Add another row
}
}
MemoryStream ms = new MemoryStream();
B.Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
ms.Position = 0;
System.Windows.Media.Imaging.BitmapImage bi =
new System.Windows.Media.Imaging.BitmapImage();
bi.BeginInit();
bi.StreamSource = ms;
bi.EndInit();
return bi;
}
}
需要注意的是,有时在 GDI+ 中保存位图时会发生“通用错误”(我知道这很有帮助)。一个简单的解决方法是将其再次复制到一个新的位图中(Bitmap bitmap1 = new Bitmap(B);
),然后保存 bitmap1
而不是原始位图。我不知道为什么会发生这种情况,MSDN 和 Stack Overflow 的人也不知道,但它就是会发生。
总之,ASCII PBM 就说到这里。二进制文件以类似的方式解析,但我会复制第二个代码示例,其中包含 switch
语句,然后将以下代码插入 else
条件句中,而不是用于解析 ASCII 文件的代码。在该函数中,一个名为 stringBytes
的字节数组被作为 out
参数给出,而不是示例 2 中的 char 数组。
int nCount = i;
//This is the number of newlines that we've encountered.
BinarySearcher b = new BinarySearcher();
FileStream fs = new FileStream(
fname,
FileMode.Open,
FileAccess.Read);
int[] offsets = b.SearchToArray(0x0a, fname, nCount);
//This gives us the offset location where all of
//the newlines in the file are (see source).
int stride = ((Width * ((1 + 7) / 8)) + 4 -
((Width * ((1 + 7) / 8)) % 4));
//calculates the stride
using (BinaryReader e = new BinaryReader(fs))
{
e.BaseStream.Position = offsets[nCount-1] + 1;
//This gives the location of our current newline
//in the actual filestream
long bufferSize = e.BaseStream.Length;
//Gives the total size of the buffer
stringBytes = e.ReadBytes((int)bufferSize -
offsets[nCount-1] +
((stride - Width) * Height));
//read the bytes from our location to the end of the stream.
stringBytes = ConvertBytes(stringBytes);
//Next I turn the bits into bytes for ease later – you
//don’t need to do this, but it’s much easier to deal
//with mentally when working with the code. (see the
//source code for this)
}
break;
最后,我们可以将其转换为图像。在这里,我使用指针来加快速度。这有点复杂,但确实有效。
public System.Windows.Media.Imaging.BitmapImage bmpFromBinaryPBM(
byte[] pixels,
int width,
int height)
{
int stride = ((width * ((1 + 7) / 8)) + 4 -
((width * ((1 + 7) / 8)) % 4));
Bitmap B = new Bitmap(
width,
height,
System.Drawing.Imaging.PixelFormat.Format8bppIndexed);
unsafe
{
System.Drawing.Imaging.BitmapData bmd = B.LockBits(
new Rectangle(0, 0, B.Width, B.Height),
System.Drawing.Imaging.ImageLockMode.ReadWrite,
B.PixelFormat);
//unlock the bytes of the bitmap in memory
int j = 0;
int y = 0;
IntPtr ptr = bmd.Scan0;
//find the beginning of the data
int bytes = Math.Abs(pixels.Length);
//This gives us the bitmap data size
byte[] rgbValues = new byte[bytes];
System.Runtime.InteropServices.Marshal.Copy(
ptr,
rgbValues,
0,
bytes);
//copy all of the bytes in the bitmap’s memory to the
//rgbValues array.
for (int counter = 0;
counter < rgbValues.Length
&& j < pixels.Length; )
{
int k = 1;
//bring k back to 1
for (int i = 0; i < 8; i++, counter++, j++)
//Let’s go forward in 8 bytes.
//It makes it more simple when we have to deal with padding.
{
if (bmd.Stride - width == 0 & y == B.Width)
{
y = 0;
//reset y and then break. There is no padding,
//so we don’t need to skip anything.
break;
}
else if (!(bmd.Stride - width == 0) & y == B.Width)
{
y = 0;
counter += bmd.Stride - width;
//When we reach the width, we skip the padding
j += (bmd.Stride - width) +
(pixels.Length - rgbValues.Length) /
height;
//reset y and move j along, but remember to skip the
//padding or everything will look skewed.
//PBMs don’t do padding, so we have to leave it.
//Some other formats like BMPs and PCXs do and you
//need to consider that if you’re planning on
//implementing this elsewhere.
break;
}
byte colour = pixels[j];
//set colour (sorry Americans) to equal the pixel value
//from our file that we parsed earlier.
rgbValues[counter] = colour;
//set the pixel array from our BitmapImage to contain
//this value.
y++;
//Move y along (that is, move forward by a pixel)
}
//We don't move along until all the values are sorted.
}
System.Runtime.InteropServices.Marshal.Copy(
rgbValues,
0,
ptr,
bytes);
//put rgbValues back into the BitmapData memory area
B.UnlockBits(bmd);
//Unlock the bytes and end the unsafe stuff.
}
MemoryStream ms = new MemoryStream();
B.Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
ms.Position = 0;
System.Windows.Media.Imaging.BitmapImage bi = new
System.Windows.Media.Imaging.BitmapImage();
bi.BeginInit();
bi.StreamSource = ms;
bi.EndInit();
return bi;
}
好的,最后是实现。要使用此代码,我编写了一系列从类外部调用的语句。我的所有函数都是公共的,因此可以在程序的任何地方调用它们。您可能希望在类中添加一个“超级函数”来调用所有其他函数——不过我没有这样做。下面的示例显示了我如何调用我的代码,并从名为 openDialog
的 OpenFileDialog
获取文件名。
pbm pbm = new pbm();
int pbm_result =
pbm.readMagicNumber(openDialog.FileName);
int height, width;
if (pbm_result == 1)
{
char[] pixels;
pbm.readData(openDialog.FileName, out height, out width, out pixels);
if (Height > 0)
{
if (Width > 0)
{
try
{
image1.Source =
pbm.bmpFromPBM(
pixels,
pbmWidth,
pbmHeight);
label6.Content = Width;
label7.Content = Height;
label8.Content = "1";
}
catch (Exception eee)
{
MessageBox.Show(
"There was an error
processing this file. It could
not be read properly" +
eee);
}
}
else
MessageBox.Show("There was an error
reading the Width of this file. There
could be a comment obstructing
it.");
}
else
MessageBox.Show("There was an error
reading the Height of this file. There could
be a comment obstructing it.");
}
else if (pbm_result == 0)
{
byte[] pixels;
pbm.readBinaryData(
openDialog.FileName,
out height,
out width,
out pixels);
label6.Content = width;
label7.Content = height;
label8.Content = "1";
image1.Source = pbm.bmpFromBinaryPBM(
pixels,
width,
height);
}
else if (pbm_result == -1)
{
MessageBox.Show("This file is not a valid
PBM File");
}
#endregion
关注点
正如我在代码中所说,使用指针操作 Bitmap 比使用 SetPixel
更高效,也肯定更快,但我在此包含 SetPixel
方法是为了提供两种选择,并且因为我发现使用不安全代码来编写算法有时需要一些时间。
我提到了几个未解释的函数。一个是 ConvertStringArrayToString()
。它基本上使用 StringBuilder
将字符串数组中的字符串连接起来,并在某个索引之后返回该连接的字符串。下一个是 ConvertBytes()
。这会将一个字节转换为 8 个字节,这些字节是 0x00 或 0xFF,具体取决于给定字节的各个位。例如,如果字节 0xC0 作为参数中的字节数组的一部分传递,则该字节的结果将是一个字节 0xFF、0xFF、0x00、0x00、0x00、0x00、0x00、0x00。
历史
- 首次上传:2011 年 12 月 17 日星期六(格林威治标准时间 17:40)。