unity自带得Texture2d.loadimage可以直接读取,如果你的图片小就直接用,如果图片尺寸过大,那么就可以研究下我的代码
关于通过文件流获取图片宽高参考我之前得文章:获取PNG/JPG/GIF/BMP的宽高
-
简介
PNG是一种使用无损压缩的图片格式,当原图片数据被编码成PNG格式后,是可以完全还原成原本的图片数据的,PNG保留原始所有的颜色信息,并且支持透明/alpha通道,然后采用无损压缩进行编码。
以下,我们来尝试获取PNG编码的图片数据:
结构
图片是属于二进制文件,因此在拿到PNG图片并想对其进行解析的话,就得以二进制的方式进行读取操作。PNG图片包含两部分:文件头和数据块。
文件头
PNG的文件头就是二进制流的前8个字节,其值为[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
//设置头
Array.Copy(fileData, 0, png.Herder, 0, png.Herder.Length);
数据块
去掉了PNG图片等前8个字节,剩下的就是存放PNG数据的数据块,我们通常称之为Chunk
。
数据块就是一段数据,去掉了头的PNG图片数据,不过我们要先知道有哪些类型的数据块才好判断。
数据块格式如下:
描述 | 长度 |
---|---|
数据块内容长度 | 4字节 |
数据块类型 | 4字节 |
数据块内容 | 不定字节 |
crc冗余校验码 | 4字节 |
Chunk代码结构如下:
public class Chunk
{
public int ChunkLen { get; set; }
public int ChunkFlag { get; set; }
public byte[] ChunkData { get; set; }
public byte[] CRC { get; set; } = new byte[4];
}
数据块类型
数据块类型有很多种,但是其中大部分我们都不需要用到,因为里面没有存储我们需要用到的数据。我们需要关注的数据块只有以下四种:
- IHDR:存放图片信息。
- PLTE:存放索引颜色。
- IDAT:存放图片数据。
- IEND:图片数据结束标志。
只要解析这四种数据块就可以获取图片本身的所有数据,因此我们也称这四种数据块为“关键数据块”。
IHDR
类型为IHDR的数据块用来存放图片信息,其长度为固定的13个字节:
描述 | 长度 |
---|---|
图片宽度 | 4字节 |
图片高度 | 4字节 |
图像深度 | 1字节 |
颜色类型 | 1字节 |
压缩方法 | 1字节 |
过滤方式 | 1字节 |
扫描方式 | 1字节 |
图片深度是指每个像素点中的每个通道占用的位数;
颜色类型用来判断每个像素点中有多少个通道;
压缩方法目前只支持一种(deflate/inflate 压缩算法),其值为0;
过滤方式也只有一种(包含标准的5种过滤类型),其值为0;
扫描方式有两种,一种是逐行扫描,值为0,还有一种是Adam7隔行扫描,其值为1,此次只针对普通的逐行扫描方式进行解析,因此暂时不考虑Adam7隔行扫描。
IHDR代码结构如下:
public class IHDR : Chunk
{
//宽
public int Width { get; set; }
//高
public int Height { get; set; }
//位深 真彩色图像:8或16
public byte BitDepth { get; set; }
//颜色类型 6:带α通道数据的真彩色图像,8或16
public byte ColorType { get; set; }
//压缩方法(LZ77派生算法) 固定0
public byte CompressionMethod { get; set; }
//滤波器方法 固定0
public byte FilterMethod { get; set; }
//隔行扫描方法:0:非隔行扫描 1: Adam7(由Adam M.Costello开发的7遍隔行扫描方法)
public byte InterlaceMethod { get; set; }
//通道占用的位数
public int Channel
{
get
{
switch (ColorType)
{
case 0://灰度图像,只有1个灰色通道
return 1;
case 2://rgb真彩色图像,有RGB3色通道
return 3;
case 3://索引颜色图像,只有索引值一个通道
return 1;
case 4://灰度图像 + alpha通道
return 2;
default:
return 3;
}
}
}
}
PLTE
类型为PLTE的数据块用来存放索引颜色,我们又称之为“调色板”。通常使用索引颜色的情况下,图像深度的值即为8,因而调色板里存放的颜色就只有256种颜色,长度为256 * 3
个字节。再加上1位布尔值表示透明像素,这就是我们常说的png8图片了。
PLTE代码结构如下:
public class PLTE : Chunk
{
}
IDAT
类型为IDAT的数据块用来存放图像数据,跟其他关键数据块不同的是,其数量可以是连续的复数个;这里的数据得按顺序把所有连续的IDAT数据块全部解析并将数据联合起来才能进行最终处理。
其他关键数据块在PNG文件里有且只有1个。
IDAT代码结构如下:
public class IDAT : Chunk
{
}
IEND
当解析到类型为IEND的数据块时,就表明所有的IDAT数据块已经解析完毕,我们就可以停止解析了。
IEND整个数据块的值时固定的:[0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]
,因为IEND数据块没有数据块内容,所以其数据块内容长度字段(数据块前4个字节)的值也是0。
-
解析
获取所有IDAT,如有多个需要按顺序连起来
[-4][-3][-2][-1]IDAT长度 [0][1][2][3]IDAT的标志位 [4~len]实际数据 [len~len+4]CRC数据
if (ChunkFlagBytes[0] == 0x49 && ChunkFlagBytes[1] == 0x44 && ChunkFlagBytes[2] == 0x41 && ChunkFlagBytes[3] == 0x54)
{
//Log.Info("----------------------IDAT----------------------");
//chunk length
Array.Copy(fileData, postion - chunkLengthBytes.Length, chunkLengthBytes, 0, chunkLengthBytes.Length);
Array.Reverse(chunkLengthBytes, 0, 4);
length = BitConverter.ToInt32(chunkLengthBytes, 0); ;
png.IDAT.ChunkLen = length;
//Log.Info("ChunkLen:{0}", png.IDAT.ChunkLen);
//chunk flag
png.IDAT.ChunkFlag = BitConverter.ToInt32(ChunkFlagBytes, 0);
postion += 4;
//Log.Info("ChunkFlag:{0}", png.IDAT.ChunkFlag);
//chunk data
png.IDAT.ChunkData = new byte[length];
Array.Copy(fileData, postion, png.IDAT.ChunkData, 0, png.IDAT.ChunkData.Length);
//添加数据
png.CombineBytesData.AddRange(png.IDAT.ChunkData);
postion += length;
//CRC
Array.Copy(fileData, postion, png.IDAT.CRC, 0, png.IDAT.CRC.Length);
postion += 4;
//Log.Info("CRC:{0}", ByteToHexString(png.IDAT.CRC));
//找下一个
postion++;
Array.Copy(fileData, postion, ChunkFlagBytes, 0, ChunkFlagBytes.Length);
}
解压缩
当我们收集完IDAT的所有数据块内容时,我们要先对其进行解压缩:
这里使用得时C#得zlib库;
private byte[] Buffer = new byte[1024 * 1024];
private MemoryStream OutStream = new MemoryStream();
public byte[] Analysis(byte[] ChunkData)
{
if (ChunkData == null)
return null;
MemoryStream inputStream = new MemoryStream(ChunkData);
Stream outputStream = deCompressStream(inputStream);
byte[] outputBytes = new byte[outputStream.Length];
outputStream.Position = 0;
outputStream.Read(outputBytes, 0, outputBytes.Length);
outputStream.Close();
inputStream.Close();
return outputBytes;
}
/// <summary>
/// 解压缩流
/// </summary>
/// <param name="sourceStream">需要被解压缩的流</param>
/// <returns>解压后的流</returns>
private Stream deCompressStream(Stream sourceStream)
{
ZOutputStream outZStream = new ZOutputStream(OutStream);
CopyStream(sourceStream, outZStream);
outZStream.finish();
return OutStream;
}
public void CopyStream(Stream input, Stream output)
{
int len;
while ((len = input.Read(Buffer, 0, Buffer.Length)) > 0)
{
output.Write(Buffer, 0, len);
}
output.Flush();
}
过滤
上文我们说过过滤方法只有1种,其中包含5种过滤类型,图像每一行数据里的第一个字节就表示当前行数什么过滤类型。
大多数情况下,图像的相邻像素点的色值时很相近的,而且很容易呈现线性变化(相邻数据的值是相似或有某种规律变化的),因此借由这个特性对图像的数据进行一定程度的压缩。针对这种情况我们常常使用一种叫差分编码的编码方式,即是记录当前数据和某个标准值的差距来存储当前数据。
比如说有这么一个数组[99, 100, 100, 102, 103]
,我们可以将其转存为[99, 1, 0, 2, 1]
。转存的规则就是以数组第1位为标准值,标准值存储原始数据,后续均存储以前1位数据的差值。
当我们使用了差分编码后,再进行deflate压缩的话,效果会更好(deflate压缩是LZ77延伸出来的一种算法)。
使用过滤代码:
// 每像素字节数
int bytesPerPixel = Math.Max(1, IHDR.Channel * IHDR.BitDepth / 8);
// 每行字节数
int bytesPerRow = bytesPerPixel * Width;
// 存储过滤后的像素数据
PixelsBuffer = new byte[bytesPerPixel * Width * Height];
// 当前行的偏移位置
int offset = 0;
// 当前行
byte[] scanline = new byte[bytesPerRow];
for (int i = 0; i < ParsedBytesData.Length; i += bytesPerRow + 1)
{
Array.Copy(ParsedBytesData, i + 1, scanline, 0, bytesPerRow);
// 第一个字节代表过滤类型
switch (ParsedBytesData[i])
{
case 0:
filterNone(scanline, bytesPerPixel, bytesPerRow, offset);
break;
case 1:
filterSub(scanline, bytesPerPixel, bytesPerRow, offset);
break;
case 2:
filterUp(scanline, bytesPerPixel, bytesPerRow, offset);
break;
case 3:
filterAverage(scanline, bytesPerPixel, bytesPerRow, offset);
break;
case 4:
filterPaeth(scanline, bytesPerPixel, bytesPerRow, offset);
break;
default:
break;
}
offset += bytesPerRow;
}
过滤类型0:None
这个没啥好解释的,就是完全不做任何过滤。
private void filterNone(byte[] scanline, int bytesPerPixel, int bytesPerRow, int offset)
{
for (int i = 0; i < bytesPerRow; i++)
{
PixelsBuffer[offset + i] = scanline[i];
}
}
过滤类型1:Sub
当前像素和左边像素的差值。左边起第一个像素是标准值,不做任何过滤。
private void filterSub(byte[] scanline, int bytesPerPixel, int bytesPerRow, int offset)
{
for (int i = 0; i < bytesPerRow; i++)
{
if (i < bytesPerPixel)
{
// 第一个像素,不作解析
PixelsBuffer[offset + i] = scanline[i];
}
else
{
// 其他像素
byte a = PixelsBuffer[offset + i - bytesPerPixel];
// ??????检查
int value = scanline[i] + a;
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
过滤类型2:Up
当前像素和上边像素点差值。如果当前行是第1行,则当前行数标准值,不做任何过滤。
private void filterUp(byte[] scanline, int bytesPerPixel, int bytesPerRow, int offset)
{
if (offset < bytesPerRow)
{
// 第一行,不作解析
for (int i = 0; i < bytesPerRow; i++)
{
PixelsBuffer[offset + i] = scanline[i];
}
}
else
{
for (int i = 0; i < bytesPerRow; i++)
{
byte b = PixelsBuffer[offset + i - bytesPerRow];
int value = scanline[i] + b;
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
过滤类型3:Average
当前像素与左边像素和上边像素的平均值的差值。
- 如果当前行数第一行:做特殊的Sub过滤,左边起第一个像素是标准值,不做任何过滤。其他像素记录该像素与左边像素的二分之一的值的差值。
如果当前行数不是第一行:左边起第一个像素记录该像素与上边像素的二分之一的值的差值,其他像素做正常的Average过滤。
private void filterAverage(byte[] scanline, int bytesPerPixel, int bytesPerRow, int offset)
{
if (offset < bytesPerRow)
{
// 第一行,只做Sub
for (int i = 0; i < bytesPerRow; i++)
{
if (i < bytesPerPixel)
{
// 第一个像素,不作解析
PixelsBuffer[offset + i] = scanline[i];
}
else
{
// 其他像素
byte a = PixelsBuffer[offset + i - bytesPerPixel];
int value = scanline[i] + (a >> 1); // 需要除以2
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
else
{
for (int i = 0; i < bytesPerRow; i++)
{
if (i < bytesPerPixel)
{
// 第一个像素,只做Up
byte b = PixelsBuffer[offset + i - bytesPerRow];
int value = scanline[i] + (b >> 1); // 需要除以2
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
else
{
// 其他像素
byte a = PixelsBuffer[offset + i - bytesPerPixel];
byte b = PixelsBuffer[offset + i - bytesPerRow];
int value = scanline[i] + ((a + b) >> 1);
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
}
过滤类型4:Paeth
这种过滤方式比较复杂,Pr的计算方式(伪代码)如下:
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
if pa <= pb and pa <= pc then Pr = a
else if pb <= pc then Pr = b
else Pr = c
return Pr
- 如果当前行数第一行:做Sub过滤。
- 如果当前行数不是第一行:左边起第一个像素记录该像素与上边像素的差值,其他像素做正常的Peath过滤。
private void filterPaeth(byte[] scanline, int bytesPerPixel, int bytesPerRow, int offset)
{
if (offset < bytesPerRow)
{
// 第一行,只做Sub
for (int i = 0; i < bytesPerRow; i++)
{
if (i < bytesPerPixel)
{
// 第一个像素,不作解析
PixelsBuffer[offset + i] = scanline[i];
}
else
{
// 其他像素
byte a = PixelsBuffer[offset + i - bytesPerPixel];
int value = scanline[i] + a;
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
else
{
for (int i = 0; i < bytesPerRow; i++)
{
if (i < bytesPerPixel)
{
// 第一个像素,只做Up
byte b = PixelsBuffer[offset + i - bytesPerRow];
int value = scanline[i] + b;
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
else
{
// 其他像素
byte a = PixelsBuffer[offset + i - bytesPerPixel];
byte b = PixelsBuffer[offset + i - bytesPerRow];
byte c = PixelsBuffer[offset + i - bytesPerRow - bytesPerPixel];
int p = a + b - c;
int pa = Math.Abs(p - a);
int pb = Math.Abs(p - b);
int pc = Math.Abs(p - c);
byte pr;
if (pa <= pb && pa <= pc) pr = a;
else if (pb <= pc) pr = b;
else pr = c;
int value = scanline[i] + pr;
PixelsBuffer[offset + i] = Convert.ToByte(value & 0xFF);
}
}
}
}
获取像素
到这里,解析的工作就做完了,上面代码里的PixelsBuffer
数组里存的就是像素的数据了,不过我们要如何获取具体某个像素的数据呢?方式可参考下面代码:
private Color PixelColor(int x, int y)
{
if (x < 0 || x >= Width || y < 0 || y >= Height)
{
Debug.Log("x或y的值超出了图像边界!");
return Color.white;
}
// 每像素字节数
int bytesPerPixel = Math.Max(1, IHDR.Channel * IHDR.BitDepth / 8);
int index = bytesPerPixel * (y * Width + x);
switch (IHDR.ColorType)
{
case 0:
// 灰度图像
return new Color(PixelsBuffer[index] / 255f, PixelsBuffer[index] / 255f, PixelsBuffer[index] / 255f, 1);
case 2:
// rgb真彩色图像
return new Color(PixelsBuffer[index] / 255f, PixelsBuffer[index + 1] / 255f, PixelsBuffer[index + 2] / 255f, 1);
case 3:
// 索引颜色图像
int paletteIndex = PixelsBuffer[index];
return new Color(PLTE.ChunkData[paletteIndex * 3 + 0] / 255f, PLTE.ChunkData[paletteIndex * 3 + 1] / 255f, PLTE.ChunkData[paletteIndex * 3 + 2] / 255f, 1);
case 4:
// 灰度图像 + alpha通道
return new Color(PixelsBuffer[index] / 255f, PixelsBuffer[index] / 255f, PixelsBuffer[index] / 255f, PixelsBuffer[index + 1] / 255f);
case 6:
// rgb真彩色图像 + alpha通道
return new Color(PixelsBuffer[index] / 255f, PixelsBuffer[index + 1] / 255f, PixelsBuffer[index + 2] / 255f, PixelsBuffer[index + 3] / 255f);
}
return Color.white;
}
-
小结
如果对完整代码有兴趣的同学可以私信我,也可以下载
在Unity中配合Loom使用:
Loom.RunAsync(() =>
{
m_LoadImageThread = new Thread(new ThreadStart(LoadAssert));
m_LoadImageThread.IsBackground = true;
m_LoadImageThread.Start();
});
private void LoadAssert()
{
FileInfo info = (FileInfo)item;
if (File.Exists(info.FullName) && !ContainsTexture2d(info.FullName))
{
byte[] fileData = null;
Vector2 Size = Vector2.zero;
FileUtility.ImageType type = FileUtility.FileInfo(info.FullName, out fileData, out Size);
PNG png = PNG.CreatePNG(fileData);
Color[] colors = png.GetColor();
Loom.QueueOnMainThread(() =>
{
string fullPath = info.FullName;
int x = (int)Size.x;
int y = (int)Size.y;
Color[] autoColors = (Color[])colors.Clone();
Texture2D tex = new Texture2D(x, y, TextureFormat.RGBA32, false);
tex.SetPixels(autoColors);
tex.Apply();
});
}
}
也可以把color[]保存下来用协程按行apply显示
注意: Texture2D 读取像素点是从左下角开始一行一行往上读,而获取的像素从左上角一行一行往下读的,所以在对Texture2d赋值颜色的时候要自己做个转化