Spark-MLlib学习日记4:将自己手写的图片处理成MNIST格式

前言

今天要讲的呢,是自己制作手写图片,并处理成MNIST的标准格式,输入到我们训练好的模型来做识别,看看效果怎样,一直用别人提供的东西,调调api什么的总感觉参与感少了点哈哈。

本来这一部分我是打算给加到MNIST数据集介绍和朴素贝叶斯那两期后面作为扩展阅读的,因为觉得应该是蛮简单的一件事,没想到实际做下来又花了我2天的时间。遇到了不少难点和细节处理,所以最后决定独立一篇文章算了。

今天这篇呢,会涉及到图像处理的一些相关概念,比如RGB转灰度,还有图像在计算机里面的存储方式;也会涉及到一些计算机二进制的处理,蛮多知识盲点的,网上资料也不齐全,所以接下来会整理一下,结合自己的理解把这个学习过程记录下来。特别注意的是,网上相关代码基本都是python的,都是调用封装好的工具类,我这会用scala来全部手动实现一遍,希望下次跟我一样的小菜鸟遇到这些问题的时候不会像我那样束手无策了哈哈哈哈哈哈哈哈

制作手写图片

这里为了省事,我就直接用ps新建一张28 x 28像素的图片来写数字了,不过一开始做出来的图片我拿去识别效果并不好,所以拿了训练数据输出灰度矩阵,再对比自己手写的数字输出的灰度矩阵,发现识别不准的原因主要出现在以下两个地方:

  1. 手写数字要尽可能居中,且不要占满整个图片,边缘要留出部分空白
  2. 画笔像素过大,边缘像素有所偏差。

对于上述问题,经过我多次尝试后,画笔参数调成柔边2像素效果较好,创建过程如图:

画笔的选择:

图片的灰度处理

手写出来的图片看着是黑白的,其实是RGB真彩图片,要变成我们MNIST的标准格式,还要先把24位深的图片处理成8位深的灰度图。

几种灰度化方法

  • 分量法:使用RGB三个分量中的一个作为灰度图的灰度值。
  • 最值法:使用RGB三个分量中最大值或最小值作为灰度图的灰度值。
  • 均值法:使用RGB三个分量的平均值作为灰度图的灰度值。
  • 加权法:由于人眼颜色敏感度不同,按下一定的权值对RGB三分量进行加权平均能得到较合理的灰度图像。一般情况按照:Y = 0.30R + 0.59G + 0.11B

从网上看到的资料来说哈,据说是加权法出来的图片效果最好。
实际上java有自己的灰度化办法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void grayImage() throws IOException{
File file = new File(System.getProperty("user.dir")+"/test.jpg");
BufferedImage image = ImageIO.read(file);

int width = image.getWidth();
int height = image.getHeight();

BufferedImage grayImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
for(int i= 0 ; i < width ; i++){
for(int j = 0 ; j < height; j++){
int rgb = image.getRGB(i, j);
grayImage.setRGB(i, j, rgb);
}
}

File newFile = new File(System.getProperty("user.dir")+"/method1.jpg");
ImageIO.write(grayImage, "jpg", newFile);
}

看代码就是创建一个灰度图对象,然后把原图的RGB像素点强塞进去,输出成灰度图。出来的效果相当简陋,对于人来说看起来颜色比加权法出来的要糟糕不少,下面给出对比图片:

原图:

Java自带的处理出来的灰度图:

自己实现的加权法出来的灰度图:

很明显的,加权法出来的图片肉眼看起来更清晰,因此最后我选择用scala来实现图片的灰度化

scala中实现图片灰度化

说是scala实现吧,其实还是调了java的包哈哈哈,完整代码我放到了github上,顺手做了个批量读取文件夹里图片拼装成MNIST数据格式,所以分了3个class,于下列代码有点不一样,所以想要看如何实现还是去看完整代码吧:完整代码
这里一步步来,首先是读取图片文件:

1
2
val file = new File(path)
val img = mageIO.read(file)

把图片灰度化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*  把图片转成灰度值图片
* 原理其实还蛮简单的,就是按比例来取RGB三色,例如按比例3:6:1,就是R*0.3 + G*0.59 + B*0.11得出灰度值
*/
def getGrayImg(img:BufferedImage): BufferedImage = {
//宽度(行)
val width = img.getWidth
//高度(列)
val height = img.getHeight

//创建一个灰度图对象
val grayImg = new BufferedImage(width,height,img.getType)
//按比例计算灰度值
for(i <- 0 until width ; j <- 0 until height){
val color = img.getRGB(i,j)
val r = (color >> 16) & 0xff
val g = (color >> 8) & 0xff
val b = color & 0xff
val gray =255 - (0.3 * r + 0.59 * g + 0.11 * b).toInt
val newPixel = colorToRGB(256, gray, gray, gray)
grayImg.setRGB(i,j,newPixel)
}
grayImg
}

到这一步就已经得到了一个灰度化图片了,接下来我们只需要按照MNIST格式

把灰度图拼装成MNIST格式

按照MNIST官网的格式,把一些数据信息转成byte数组拼接起来,再把灰度图的像素值以8位每个点(1byte)拼接起来就可以了,这里我直接给出代码,为了方便我还写了个批量读取文件夹内的图片,把这些图片全部转成byte数组再一一进行拼接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
package SparkMLlib.Base

import java.io.{File, FileInputStream, FileOutputStream}
import java.nio.ByteBuffer

import SparkMLlib.Base.FileUtil.using
import SparkMLlib.Base.ImageUtil.{getGrayArray, getGrayImg}
import javax.imageio.ImageIO

import scala.collection.mutable.ArrayBuffer

/**
* @author voidChen
* @date 2019/2/27 14:47
*/
object MNIST_Util {

/**
* 生成灰度图和MNIST格式文件
* @param args
*/
def main(args: Array[String]): Unit = {

val path = "C:\\Users\\42532\\Desktop\\own\\"
val files = FileUtil.getFileList(path,".jpg","灰")
//处理成(标签,灰度数组)
val datas:Array[(Int, Array[Byte])] = files.map(f => (f.getName.substring(0,1).toInt,getGrayArray(ImageIO.read(f))))
//生成MNIST文件
imgToMNIST(datas)

//输出灰度图像
files.foreach(f => {
val grayImg = getGrayImg(ImageIO.read(f))
val newFile = new File(path+"灰"+f.getName);
ImageIO.write(grayImg, "jpg", newFile);
})
println("图像输出完成")

}

/**
* 读取标签
* @param labelPath
* @return
*/
def loadLabel(labelPath: String): Array[Byte] ={
val file = new File(labelPath)
val in = new FileInputStream(file)
var labelDS = new Array[Byte](file.length.toInt)
using(new FileInputStream(file)) { source =>
{
in.read(labelDS)
}
}

//32 bit integer 0x00000801(2049) magic number (MSB first--high endian)
val magicLabelNum = ByteBuffer.wrap(labelDS.take(4)).getInt
println(s"magicLabelNum=$magicLabelNum")
//32 bit integer 60000 number of items
val numOfLabelItems = ByteBuffer.wrap(labelDS.slice(4, 8)).getInt
println(s"numOfLabelItems=$numOfLabelItems")
//删掉前面的文件描述
labelDS = labelDS.drop(8)
//打印测试数据
for ((e, index) <- labelDS.take(3).zipWithIndex) {
println(s"image$index is $e")
}

labelDS
}

/**
* 读取图像文件
* @param imagesPath
* @return
*/
def loadImages(imagesPath: String): Array[Array[Byte]] ={

val file = new File(imagesPath)
val in = new FileInputStream(file)
var trainingDS = new Array[Byte](file.length.toInt)
using(new FileInputStream(file)) { source =>
{
in.read(trainingDS)
}
}

//32 bit integer 0x00000803(2051) magic number
val magicNum = ByteBuffer.wrap(trainingDS.take(4)).getInt
println(s"magicNum=$magicNum")
//32 bit integer 60000 number of items
val numOfItems = ByteBuffer.wrap(trainingDS.slice(4, 8)).getInt
println(s"numOfItems=$numOfItems")
//32 bit integer 28 number of rows
val numOfRows = ByteBuffer.wrap(trainingDS.slice(8, 12)).getInt
println(s"numOfRows=$numOfRows")
//32 bit integer 28 number of columns
val numOfCols = ByteBuffer.wrap(trainingDS.slice(12, 16)).getInt
println(s"numOfCols=$numOfCols")

trainingDS = trainingDS.drop(16)


val itemsBuffer = new ArrayBuffer[Array[Byte]]
for(i <- 0 until numOfItems){
itemsBuffer += trainingDS.slice( i * numOfCols * numOfRows , (i+1) * numOfCols * numOfRows)
}

itemsBuffer.toArray
}


/**
* 转换成MNIST数据
* @param grayArray
* @return
*/
def imgToMNIST(data: Array[(Int, Array[Byte])]){

//组装MNIST格式的图像文件头
var images = ArrayBuffer[Byte]()
images ++= intToByteArray(2051) //magicNum
images ++= intToByteArray(data.length) //number of items
images ++= intToByteArray(28) //number of rows
images ++= intToByteArray(28) //number of columns

//组装MNIST格式的标签文件头
var labels = ArrayBuffer[Byte]()
labels ++= intToByteArray(2049) //magicNum
labels ++= intToByteArray(data.length) //number of items

//组装数据
data.foreach(d =>{
val label = (d._1 & 0xFF).toByte
val image = d._2
labels += label
images ++= image
})

// var i = 0
// images.drop(16).toArray.foreach(
// b =>{
// print(b+" ")
// i = i+1
// if(i == 28){
// i = 0
// println()
// }}
// )


//输出二进制文件
val imagesFile = new File("C:\\Users\\42532\\Desktop\\test-images.idx3-ubyte");
val labelsFile = new File("C:\\Users\\42532\\Desktop\\test-labels.idx1-ubyte");

val imagesWriter = new FileOutputStream(imagesFile)
imagesWriter.write(images.toArray)
imagesWriter.close()

val labelsWriter = new FileOutputStream(labelsFile)
labelsWriter.write(labels.toArray)
labelsWriter.close()

println("文件写入完成")
}

/**
* int到byte[]
*
* @param i
* @return
*/
def intToByteArray(i: Int): Array[Byte] = {
val result = new Array[Byte](4)
//由高位到低位
result(0) = ((i >> 24) & 0xFF).toByte
result(1) = ((i >> 16) & 0xFF).toByte
result(2) = ((i >> 8) & 0xFF).toByte
result(3) = (i & 0xFF).toByte
result
}
}

生成的图片和文件如下:


标签起名直接做成图片文件名,如果有多个“1”的图片数据呢,把文件名弄成1-1.jpg,1-2.jpg,······这样就好了

关于图像处理的一些知识扩展

图像位深

一个像素用多少位表示,这里的,只是针对每一个像素点而言的。考虑到位深度平均分给R, G, B和Alpha,而只有RGB可以相互组合成颜色。所以4位颜色的图,它的位深度是4,只有2的4次幂种颜色,即16种颜色或16种灰度等级 ) 。8位颜色的图,位深度就是8,用2的8次幂表示,它含有256种颜色 ( 或256种灰度等级 )。24位颜色可称之为真彩色,位深度是24,它能组合成2的24次幂种颜色,即:16777216种颜色 ( 或称千万种颜色 ),超过了人眼能够分辨的颜色数量。当我们用24位来记录颜色时,实际上是以2^(8×3),即红、绿、蓝 ( RGB ) 三基色各以2的8次幂,256种颜色而存在的,三色组合就形成一千六百万种颜色。

图像在计算机的存储格式

一般来说,我们自己做的图片都是24位的RGB真彩图,在上述代码中,img.getRGB(i,j) 这里get出来的只有一个int值,其实他已经把RGB三原色的值全放在里面了,由高位到低位排列下来,分别是R 8位 + G 8位 + B 8位,所以我们通过右移操作符 >> 每次移动8位,在& 0xFF 一下,就能取出RGB分别的Int 颜色值来了。而最高的32位深的图片,只是在RGB的基础三多加了一个alpha透明度通道。对于32位的像素值,里面的排列顺序由高位到低位排列下来,分别是alpha 8位 + R 8位 + G 8位 + B 8位,同样可以按位把真实值取出来。

关于 & 0xFF 操作

回想一下大学学的计算机导论,有一条很重要但是不被当时我所重视的知识:计算机中整数以补码的形式存储。正数的补码与原码一致,这没什么问题,当时负数的补码却是原码的反码+1,这也导致了为什么我们没做& 0xFF 操作的时候直接byte转int会由出现负数的原因之一(另一个原因是java里面int都是带了符号位,表示的数字范围在 -128 ~ 127,之间,而实际像素值是 0 ~ 255的正整数)。

为什么byte类型的数字要 &0xff 再赋值给int类型呢,其本质原因就是想保持二进制补码的一致性。例如二进制的的12910000001 ,8位byte就能存下这个数字,当时若是直接转int,出来的结果却是-127,其原因就在于,因为当系统检测到byte可能会转化成int或者说byte与int类型进行运算的时候,就会将byte的内存空间高位补1(也就是按符号位补位)扩充到32位,变成1111111111111111111111111 10000001,再参与运算,这样其二进制补码其实已经不一致了。而进行与操作 &0xff 则是这样的:
1111111111111111111111111 10000001 & 11111111 = 000000000000000000000000 10000001​
显然,这就是为什么我们在byte转成int的时候,要做一下 &0xff 操作了

参考链接

0%