愤怒的猪蹄小组 Beta展示——算式识别与计算
1.背景介绍
如何在计算机上辅助计算公式?目前的方案主要包括mathOCR、爱作业、作业帮等代表,如下:
它们的缺点主要包括:
不支持手写字符输入、
不支持复杂题型、 不支持题库外的式子等等, 对于复杂的公式难以识别,对于简单的公式则应用场景不多。因此我们希望解决上述痛点,增强产品功能。
此类产品主要针对有辅助计算需求的学者和论文写作者,但还有一个基本的要求是近年来不断升级的验证码识别,以算术形式出现的验证码往往由被修改过的字符以异常的形式排列,因此不要求识别工具对于复杂式子的识别,但有对识别准确率的要求。算式识别工具可以作为API接口提供服务,便于爬虫等需要自动访问网页的工具调用,提高对算术验证码的识别、通过能力。
2. 架构设计
**识别目标:对于四则运算+-*/ 和分式具有识别和计算能力**
- 架构设计:
- pipeline处理,流程如下
- gui读取用户输入图片
- 做图像预处理,包括二值处理、滤波、切割
- 送进CNN识别
- 结合每个字符的识别结果和位置关系捆绑空间上不连续的字符形成符号(切割时按边界切割)
- 按定义好的支持文法实现递归下降语法制导翻译,对输入记号流递归得到求解结果
- 展示在gui上
3. 系统实现
- 图片预处理
- 图片预处理以OpenCV作为主要工具。预处理的主要目的是把图片中的字符切割出来,同时避免无关变量对字符识别的影响。
- 主要步骤包括:灰度化、二值化、高斯滤波、字符切割
工具介绍:
- 卷积神经网络模型(CNN)
- 国际数学公式识别比赛数据集(CROHME) 海量字符集图片 与实际输入相似
- 例
原图
binary image
extracted components
项目重要文件介绍:
- 项目配置文件:
- 图像处理:opencv
- 分类器:tensorflow
- gui: kivy
操作说明:
- 运行程序:
- test.py提供无gui调用方式展示
- gui直接运行可带着gui运行
- gui
- 在界面左边输入手写字符
- 右侧显示识别结果,界面上端显示计算结果
- 点击Solve按钮:展示结果
- 点击Clear按钮:清楚输入状态
- 在右侧栏中间显示识别出的表达式,方便差错
4. 实验结果
测试样例:
5. 总结
优点
- 实现了基本的完整算式识别流程
- 实现了全字符的识别和捆绑
- 在当前框架上拓展文法较方便,可以支持更多运算
缺点
- 全字符识别导致分类器对于相似字符的辨别效果较差(如稍有点弯曲的1和括号)
- 当前的处理流程输入图片先送入CNN完成识别和捆绑后将捆绑后的符号送进Parser进行语法分析,这样的方式导致书写潦草时效果较差,出现非同类符号识别时将导致整个表达式理解错误,简单地概括就是词法分析限制了语法分析,可以考虑在语法分析中加入冗余容错,例如不该读到括号时尝试重新解析当前的字符为数字。
Postmoterm报告
总述
- 成员
- 每个成员在beta阶段更加积极配合完成任务,由于课业影响,整个项目执行期较短,但成员基本都能加急完成分配的任务,并致力于找bug和debug。
- 吸收教训
- 在alpha和beta阶段的时间安排都不算很合理,不过beta阶段的预备时间比alpha阶段多了50%以上,算是做了一定的准备工作。其次由于目标更为清晰,但实现难度较大,实现过程不那么顺利。
- 开发评价
- 实现过程遇到的主要苦难时在图像处理阶段而不是模型训练阶段
- 如何将字符切割出来并将不连续字符根据空间关系进行捆绑是很琐碎的工作,很容易出现逻辑错误
- 语法制导翻译的过程中由于运算符关系优先级相当复杂,手工实现的工作量太大,我们最终没实现较多复杂运算符的复合运算支持,只实现了+-*/分式的表达式识别。
设想和目标
- 设想
- 我们的软件要解决什么问题?是否定义得很清楚?是否对典型用户和典型场景有清晰的描述?
- 我们希望设计一个支持算式识别解析的工具,实现的初衷是提供一个包输入一张表达式图解析出计算结果,用于验证码识别,在更复杂的运算符集上拓展后可用于智能的算式解析。
- 出于展示目的我们在上一阶段的GUI基础上做了修改也支持了gui展示功能。
- 我们的软件要解决什么问题?是否定义得很清楚?是否对典型用户和典型场景有清晰的描述?
- 目标
- 我们达到目标了么(原计划的功能做到了几个? 按照原计划交付时间交付了么? 原计划达到的用户数量达到了么?)
- 实现了。Alpha版本实现了对单个数字的识别,Beta版本实现了手写算式识别。按照预定时间交付。目前暂未推向市场,未获得用户。
- 软件质量
- 和上一个阶段相比,团队软件工程的质量提高了么? 在什么地方有提高,具体提高了多少,如何衡量的?
- 我们在代码质量上有所提高,具体是计算核心算法被更新,UI被重写。
- 经验教训:有什么经验教训? 如果历史重来一遍, 我们会做什么改进?
- 项目难度较大,需要关于图像处理的一些知识,还需要编译的知识。
- 组员的水平差异较大,半数以上不是计算机学院的,没有相关的知识积累,所以部分核心功能的实现团队成员参与度不高。
计划
是否有充足的时间来做计划?
- 是的。
团队在计划阶段是如何解决同事们对于计划的不同意见的?
- 微信在线讨论。
你原计划的工作是否最后都做完了? 如果有没做完的,为什么?
- 我们原计划的工作基本完成,但未实现在验证码工具上的完整应用程序,暂未将识别范围拓展至更多类型的算式。
是否项目的整个过程都按照计划进行,项目出了什么意外?有什么风险是当时没有估计到的,为什么没有估计到?
- 图像切割后捆绑算符、词法分析、语法分析都有很多细枝末节的问题,实现起来很头疼。算是预估到的难题,但是依然解决起来比较艰难。
我们学到了什么? 如果历史重来一遍, 我们会做什么改进?
- 提前开始基础知识的学习,提高成员参与度。
资源
我们有足够的资源来完成各项任务么?
- 充足,模型训练不算是项目的核心问题,其他核心功能都有成员之前有过接触。
测试的时间,人力和软件/硬件资源是否足够? 对于那些不需要编程的资源 (美工设计/文案)是否低估难度?
- 充足,UI不是我们设计初衷的重点,只是为了展示。
变更管理
每个相关的成员都及时知道了变更的消息?
- 是的。
我们采用了什么办法决定“推迟”和“必须实现”的功能?
- 取决于当时所有人员的空闲情况,以及交付的紧急程度。
成员是否能够有效地处理意料之外的工作请求?
- 目前都已处理。
设计/实现
设计工作在什么时候,由谁来完成的?是合适的时间,合适的人么?
- 是在项目的启动阶段,由团队讨论完成。
设计工作有没有碰到模棱两可的情况,团队是如何解决的?
- 比如对于产品功能的定位,最初的设计意见不一,最终讨论决定先做稳一点的验证码识别工具。
什么功能产生的Bug最多,为什么?在发布之后发现了什么重要的bug? 为什么我们在设计/开发的时候没有想到这些情况?
- 字符识别功能Bug最多,原因是初始时野心较大,希望支持较多的运算符,从而分类类别比我们实际使用的多,识别效果有所下降。
- 词法分析、语法分析等递归调用的函数中都容易有未触及的分支,由于时间限制和成员技能差异,我们没有进行高覆盖率的单元测试,测试都是在尝试使用中进行的,效率较低,可靠性较低。
完整处理流程代码展示
- gui
import solverfrom PIL import Imagefrom kivy.app import Appfrom kivy.uix.boxlayout import BoxLayoutfrom kivy.uix.button import Buttonfrom kivy.uix.label import Labelfrom kivy.uix.widget import Widgetfrom kivy.graphics import Color, Lineclass PaintWidget(Widget): color = (254, 0, 0, 1) # Pen color 画笔颜色 thick = 8 # Pen thickness 画笔粗度 def __init__(self, root, **kwargs): super().__init__(**kwargs) self.parent_widget = root # Touch down motion: # If the touch position is located in the painting board, draw lines. # 按下动作: # 如果触摸位置在画板内,则在画板上划线 def on_touch_down(self, touch): with self.canvas: Color(*self.color, mode='rgba') if touch.x > self.width or touch.y < self.parent_widget.height - self.height: return touch.ud['line'] = Line(points=(touch.x, touch.y), width=self.thick) # Touch move motion: # Draw line with mouse/hand moving # 移动动作: # 随着鼠标/手指的移动画线 def on_touch_move(self, touch): with self.canvas: if touch.x > self.width or touch.y < self.parent_widget.height - self.height: return touch.ud['line'].points += [touch.x, touch.y] # Touch up motion: # When ending drawing line, save the picture, and call the prediction component to do prediction # 抬起动作: # 结束画线,保存图片成文件,并调用预测相关的组件做预测 def on_touch_up(self, touch): if touch.x > self.width or touch.y < self.parent_widget.height - self.height: return #self.parent.parent.do_predictions() def export_image(self): input_img_name = './input_expression.png' self.export_to_png(input_img_name) im = Image.open(input_img_name) x, y = im.size p = Image.new('RGBA', im.size, (255, 255, 255)) p.paste(im, (0, 0, x, y), im) p.save('white_bg.png') return 'white_bg.png' class Recognizer(BoxLayout): def __init__(self, **kwargs): super().__init__(**kwargs) self.number = -1 # Variable to store the predicted number 保存识别的数字的变量 self.orientation = 'horizontal' # UI related UI相关 self.draw_window() # function to declare the components of the application, and add them to the window # 声明程序UI组件的函数,并且将它们添加到窗口上 def draw_window(self): # Clear button 清除按钮 self.clear_button = Button(text='CLEAR', font_name=HandwrittenMathCalculator.font_name, size_hint=(1, 4 / 45), background_color=(255, 165 / 255, 0, 1)) self.solve_button = Button(text='SOLVE', font_name=HandwrittenMathCalculator.font_name, size_hint=(1, 4 / 45), background_color=(255, 165 / 255, 0, 1)) # Painting board 画板 self.painter = PaintWidget(self, size_hint=(1, 8 / 9)) # Label for hint text 提示文字标签 self.hint_label = Label(font_name=HandwrittenMathCalculator.font_name, size_hint=(1, 1 / 45)) # Label for predicted number 识别数字展示标签 self.result_label = Label(font_size=120, size_hint=(1, 1 / 3)) # Label for some info 展示一些信息的标签 self.info_board = Label(font_size=24, size_hint=(1, 22 / 45)) # BoxLayout 盒子布局 first_column = BoxLayout(orientation='vertical', size_hint=(2 / 3, 1)) second_column = BoxLayout(orientation='vertical', size_hint=(1 / 3, 1)) # Add widgets to the window 将各个组件加到应用窗口上 first_column.add_widget(self.painter) first_column.add_widget(self.hint_label) second_column.add_widget(self.result_label) second_column.add_widget(self.info_board) second_column.add_widget(self.solve_button) second_column.add_widget(self.clear_button) self.add_widget(first_column) self.add_widget(second_column) # motion binding 动作绑定 # Bind the click of the clear button to the clear_paint function # 将清除按钮的点击事件绑定到clear_paint函数上 self.clear_button.bind(on_release=self.clear_paint) self.solve_button.bind(on_release=self.solve_expression) self.clear_paint() # Initialize the state of the app 初始化应用状态 # Clear the painting board and initialize the state of the app. def clear_paint(self): self.painter.export_image() #call solver to solve # Clear the painting board and initialize the state of the app. def clear_paint(self, obj=None): self.painter.canvas.clear() self.number = -1 self.result_label.text = '?' self.hint_label.text = 'Write math expression above' self.info_board.text = 'Detected expression:\n' # Extract info from the predictions, and display them on the window # 从预测结果中提取信息,并展示在窗口上 def show_info(self, result, detected_expression='8+7'): if result == None: self.number = 'Error' else: self.number = result self.result_label.text = str(self.number) self.hint_label.text = 'Detected expression and result is shown.Press clear to Retry!' self.info_board.text += detected_expression def solve_expression(self, obj=None): img = self.painter.export_image() self.info_board.text = 'Detected expression:\n' (result,detected_expression) = solver.solve(img) self.show_info(result,detected_expression)# Main app class# 主程序类class HandwrittenMathCalculator(App): font_name = r'Arial.ttf' def build(self): return Recognizer()
- solver
- 各模块功能汇总脚本
- 依次进行图片二值处理
- 进行图像分割
- 进行非连续符号合并捆绑
- 调用CNN分类器进行分类
- 根据位置进行从左到右从上到下排序,符合算式符号的结合逻辑
- 至此相当于实现了lexer
- 调用parser进行语法制导计算,返回结果
def solve(filename,mode = 'product'): original_img, binary_img = read_img_and_convert_to_binary(filename) symbols = binary_img_segment(binary_img, original_img) sort_symbols = sort_characters(symbols) process.detect_uncontinous_symbols(sort_symbols, binary_img) length = len(symbols) symbols_to_be_predicted = normalize_matrix_value([x['src_img'] for x in symbols]) predict_input_fn = tf.estimator.inputs.numpy_input_fn( x={"x": np.array(symbols_to_be_predicted)}, shuffle=False) predictions = cnn_symbol_classifier.predict(input_fn=predict_input_fn) characters = [] for i,p in enumerate(predictions): # print(p['classes'],FILELIST[p['classes']]) candidates = get_candidates(p['probabilities']) characters.append({'location':symbols[i]['location'],'candidates':candidates}) #print([x['location'] for x in characters]) modify_characters(characters) # print('排序后的字符序列') # print([[x['location'], x['candidates']] for x in characters]) tokens = process.group_into_tokens(characters) # print('识别出的token') print(tokens) node_list = characters_to_nodes(characters) print(node_list) exp_parser = Exp_parser() result=exp_parser.expression(node_list) str = '' for token in tokens: str += token['token_string'] print(result) if result is None: return None, str else: return (round(result,2),str)