此程序要实现的功能有:在0-8的可选数字中,可选择指定数字,在书写面板上用鼠标书写该数字
并录入素材库。
在书写面板上书写任意数字,点击识别按钮后程序能识别出对应数字。
要实现这样一个程序,我们需要:一个UI类用于展示UI面板、一个算法类用于实现KNN算法识别、一个基础数据接口用于保存程序的基本数据。
1.数据接口类BasicData
//基本数据
public interface BasicData {
//窗体
int framewidth = 450;
int frameheight = 550;
//控制面板
int panelwidth = 450;
int panelheight = 50;
//行距
int linesize = 20;
//行数
int line = 20;
//起始坐标
int x0=25,y0=25;
}
2.UI类
要展示的UI分为三个部分:
(1)窗体
(2)控制面板:含可选择数字序列、开始按钮、录入按钮、识别按钮
(3)书写面板:由一个19x19的网格构成,书写面板的网格是每次启动程序都要画好的,因此我们用一个子类继承JPanel,重写paint()方法写入网格的绘制。
书写面板类
//书写面板类
public class DrawPanel extends JPanel implements BasicData{
//重写paint方法
public void paint(Graphics g) {
super.paint(g);//执行父类方法
g.setColor(Color.BLACK);//设置画笔颜色
//绘制网格
for(int i=0;i<line;i++) {
g.drawLine(x0, y0 + i * linesize, x0 + (line - 1) * linesize, y0 + i * linesize);
g.drawLine(x0 + i * linesize, y0, x0 + i * linesize, y0 + (line - 1) * linesize);
}
}
}
UI类
//UI面板
public class UI implements BasicData{
public void showUI() {
//窗体
JFrame jf = new JFrame("knn手写识别");
jf.setSize(framewidth,frameheight);//设置窗体大小
jf.setDefaultCloseOperation(3);//后台关闭
jf.setLocationRelativeTo(null);//居中显示
jf.setLayout(new BorderLayout());//边框式布局
//控制面板
JPanel cpanel = new JPanel();
cpanel.setPreferredSize(new Dimension(panelwidth,panelheight));//设置面板大小
jf.add(cpanel,BorderLayout.NORTH);//将面板添加到窗体上方位置
//书写面板
DrawPanel dpanel = new DrawPanel();
jf.add(dpanel,BorderLayout.CENTER);//将面板添加到窗体中间位置
//控制面板中的按钮组件
//下拉选择组件
Integer num[] = {0,1,2,3,4,5,6,7,8};
JComboBox<Integer> jcb = new JComboBox<>(num);
cpanel.add(jcb);
//按钮
JButton jbu1 = new JButton("开始");
JButton jbu2 = new JButton("录入");
JButton jbu3 = new JButton("识别");
cpanel.add(jbu1);
cpanel.add(jbu2);
cpanel.add(jbu3);
//监听器
Listener ls = new Listener();
jbu1.addActionListener(ls);
jbu2.addActionListener(ls);
jbu3.addActionListener(ls);
dpanel.addMouseListener(ls);
dpanel.addMouseMotionListener(ls);
jf.setVisible(true);//设置窗体可见
/*要在监听器类中完成书写、识别和录入功能,
* 因此要把面板的画笔对象传入监听器类以获取书写素材
* 也要把选择数字序列组件也传入监听器类以获取选择录入的数字,
* 还要把书写面板对象传入监听器类以实现面板的刷新
*/
Graphics g = dpanel.getGraphics();//获取书写面板的画笔对象
ls.setGraphics(g);
ls.setJComboBox(jcb);
ls.setDrawPanel(dpanel);
}
public static void main(String[] args) {
new UI().showUI();
}
}
监听器类
监听器类要实现的功能:
(1)图像的绘制:通过鼠标拖动方法实现
(2)素材录入:用二维数组实现
(3)图像识别 :用KNN算法实现
(1)图像绘制
先定义变量
private int x1,y1,x2,y2;//画笔的起点坐标和终点坐标
private int[][] site = new int[line-1][line-1];//标记数组,每一个网格为一个标记点
private String info;//所选择按钮上的文字
private boolean start = false;//是否开始程序
选择不同按钮执行不同方法,此处先写点击“开始”按钮
public void actionPerformed(ActionEvent e) {
info = e.getActionCommand();//获取所点击按钮的文字
switch(info) {
case "开始":
start = true;//更改开始标记
//把site数组所有值重置为0
for(int i=0;i<line-1;i++) {
Arrays.fill(site[i],0);
}
dpanel.repaint();//刷新面板
break;
case "录入":
break;
case "识别":
break;
}
}
图像的绘制
//鼠标按下时获取起始坐标
public void mousePressed(MouseEvent e) {
if(start == true) {
//获取起点坐标
x1 = e.getX();
y1 = e.getY();
}
}
//鼠标拖动画线可以看成一段段直线的微元,不断更新起点坐标为终点坐标实现画线
public void mouseDragged(MouseEvent e) {
if(start = true) {
//获取终点坐标
x2 = e.getX();
y2 = e.getY();
g.drawLine(x1, y1, x2, y2);//画线
//19x19的网格对应19x19的二维数组site,线条经过的网格位置对应的数组位置标记为1
if(x1>=x0&&x1<=x0+(line-1)*linesize && y1>=y0&&y1<=y0+(line-1)*linesize) {//在网格线内书写
if((x1-x0)/linesize<line-1 && (y1-y0)/linesize<line-1){//数组越界判断,线是(line)20,格是(line-1)19,则数组最大下标是(line-1-1)18
site[(y1-y0)/linesize][(x1-x0)/linesize] = 1;//标记访问
}
}
//将起点坐标更新为终点坐标,实现微元画线
x1 = x2;
y1 = y2;
}
}
(2)素材录入
素材录入的思路:每一次画线会获得一个标记完的二维数组,将该二维数组以字节流的方式存入素材文件,通过将素材所要标记的数字加入到对素材的命名中,以此来判断此段素材用于识别哪个数字,对用于标记相同数字的素材,用UI类的hashcode()方法来作为素材名的后缀,可以避免素材名重复。如对于数字2的两段素材名可以为:2-178887853,2-178887854。
录入
//录入
public void Record(int[][] site,String selectedNum) throws IOException {
//文件路径,每一个素材对应一个txt文件存在素材文件夹中,文件名是素材对应数字 + 调用UID类获取哈希码(可避免文件名重复)
String filename = "D:\\java\\KNN手写识别\\素材\\"+selectedNum+"-"+new UID().hashCode()+".txt";
File file = new File(filename);
//fos要写入的文件file,若不存在则先新建再写入,若存在则直接写入覆盖之前的内容
FileOutputStream fos = new FileOutputStream(file);
//用字节流将二维数组写入文件
for(int i=0;i<line-1;i++){
for(int j=0;j<line-1;j++){
fos.write(site[i][j]);
}
}
fos.flush();
fos.close();
System.out.println("素材"+selectedNum+"录入成功");
}
更新actionPerformed()方法
case "录入":
start = false;//停止书写
String selectedNum = jcb.getSelectedItem().toString();//获取选择录入的数字
try {
Record(site,selectedNum);//调用录入方法
} catch (IOException ex) {
throw new RuntimeException(ex);
}
break;
(3)识别
识别方法要用到KNN算法,思路会相对复杂一点:
a.当获取到要识别图像的二维数组target后,对于素材库中每一个素材文件,用字节流读取获取其二维数组site,将target数组与site数组进行矩阵相减 ,再对相减后的矩阵的每一个值,取其绝对值并加和,得到的最终值result即为两个数组的契合度,result越小,target与site的契合度越高。
b.如果只是得到result是不够的,我们还需知道target是与哪个数字的素材进行计算得到的result,否则即使得到的result再小,由于不知道是与哪个数字的契合度高,仍无法识别目标。因此我们用一个二元结构体ResultData来保存 每一个site的标记数字sampleNum 和 target与该site计算得到后的result值。
把目标文件与所有素材文件计算后得到的ResultData都存入一个动态数组中。
c.接下来部分是KNN算法的体现:对该动态数组进行排序,取result最小的前5个ResultData,如果这5个的sampleNum都不重复,则取result最小的对应的sampleNum为结果数字。如果5个sampleNum中出现重复数字,则取重复数最多的sampleNum作为结果数字。这个对重复数字的判断可以用一个哈希表来实现。
d.最后可以写一个展示窗体,用于显示最终结果。
接下来是算法实现,首先我们需要一个结构体ResultData
class ResultData{
public int sampleNum;//素材标记数字
public int distance;//素材数组与目标数组计算出的契合度
public ResultData(int sampleNum,int distance){
this.sampleNum = sampleNum;
this.distance = distance;
}
}
其次如a中所说我们需要一个计算素材数组与目标数组契合度(在KNN中叫距离)的方法。
public int getDistance(int[][] site,int[][] sampleSite){
int result = 0;//契合度
for(int i=0;i<line-1;i++){
for(int j=0;j<line-1;j++){
int a = site[i][j] - sampleSite[i][j];
//取绝对值加和
if(a>=0){
result += a;
}else{
result -= a;
}
}
}
return result;
}
接下来是一个对结构体数组进行排序的快排算法
//快速排序-从小到大
public void fastSort(List<ResultData> nums, int low, int high) {
if (low >= high) return;
int i = low, j = high;
ResultData t;
ResultData tmp = nums.get(low); // 使用 get 方法获取列表中的元素
while (i < j) {
while (i < j && nums.get(j).distance >= tmp.distance) j--;
while (i < j && nums.get(i).distance <= tmp.distance) i++;
if (i < j) {
t = nums.get(i);
nums.set(i, nums.get(j)); // 使用 set 方法设置列表中的元素
nums.set(j, t);
}
}
nums.set(low, nums.get(i));
nums.set(i, tmp);
fastSort(nums, low, i - 1);
fastSort(nums, i + 1, high);
}
然后是展示结果窗体的方法
//显示结果窗体
public void showResult(int data){
JFrame jf = new JFrame("识别结果");
jf.setSize(300,250);
jf.setDefaultCloseOperation(3);
jf.setLocationRelativeTo(null);
JLabel jl = new JLabel("识别成功!结果为"+data);//标签
jl.setFont(new Font("宋体", Font.BOLD, 30));//设置字体样式
jl.setHorizontalAlignment(JLabel.CENTER);//设置居中对齐
jf.add(jl);
jf.setVisible(true);
}
最后是识别的代码实现
//识别
public void Recognize(int[][] site) throws IOException {
//存放所有素材的文件
File file = new File("D:\\java\\KNN手写识别\\素材\\");
//list()方法返回一个字符串数组,为该文件目录下所有子文件的名字
String[] sampleName = file.list();
//用一个结构体动态数组存储结果值
resultList = new ArrayList<ResultData>();
//逐个取出每个素材中存储的二维数组,与要识别的数组进行契合度计算,将结果记录到动态数组中
for(int i=0;i<sampleName.length;i++){
//素材文件名首字符为素材标记数字,以此用substring切分出字符串首字符获得当前素材对应的数字,parseInt方法把数字名由字符串转成整型
int sampleNum = Integer.parseInt(sampleName[i].substring(0,1));
//得到当前素材的文件路径
File sample = new File("D:\\java\\KNN手写识别\\素材\\"+sampleName[i]);
FileInputStream fis = new FileInputStream(sample);
//用字节流读取出当前素材的二维数组
int[][] sampleSite = new int[line-1][line-1];//素材二维数组
int m=0,n=0;
int b = fis.read();
while(b!=-1){//读取为空时返回-1
sampleSite[m][n] = b;
n++;
if(n==line-1){//换行
m++;
n=0;
}
b = fis.read();
}
fis.close();
//进行契合度计算
int distance = getDistance(site,sampleSite);
//将结果记入动态数组
resultList.add(new ResultData(sampleNum,distance));
}
//对动态数组进行快排
fastSort(resultList,0,resultList.size()-1);
//对排序后的结果数组,取前5个值,用一张哈希表记录5个值中不重复数字的出现个数,取出现个数最多的那一个。
HashMap<Integer,Integer> hm = new HashMap<>();//<素材标记数字,出现次数>
for(int i=0;i<5;i++){
int key = resultList.get(i).sampleNum;
if(!hm.containsKey(key)){
hm.put(key,1);
}else{
hm.put(key,hm.get(key)+1);
}
}
//遍历哈希表找出现次数最多的,finNum默认值为resultList.get(0).sampleNum是因为若最小5个值都不相同,则取最小的
int finNum = resultList.get(0).sampleNum,finCount=hm.get(finNum);
for(int key:hm.keySet()){//遍历哈希表key值
if(hm.get(key)>finCount){
finCount = hm.get(key);
finNum = key;
}
}
System.out.println(finNum);
showResult(finNum);//展示结果窗体
}
别忘了更新actionPerformed()方法
case "识别":
start = false;
dpanel.repaint();
try {
Recognize(site);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
break;
效果展示: