搜狐技术产品

我搞坏了我儿子的“牛听听”

Image

 Image 
一个程序员搞坏了孩子玩具的故事

我搞坏了我儿子的“牛听听”,搞坏东西就得赔。

我是一个的中年程序员,我有一个六岁的儿子。我每天的主要生活,除了写代码,还需要经常和我的儿子斗智斗勇。记忆中,我小时候,都是作为儿子的我在闯祸,而我爸在收拾我。可是,终于熬到了我成为了爸爸,时代却改变了,新时代的亲子关系,是平等的,甚至很多时候,需要大人降低姿态来包容孩子。于是,在我和我儿子的对线中,悲剧重演了。再次变成了,我,在不停闯祸,而我的儿子,在替我爸收拾我。

于是,这一天,我又闯祸了。

——我搞坏了我儿子的“牛听听”

啥是“牛听听”

中年油腻的程序员,购物在猫狗之间,自然是选择狗东的。不过这不重要。我熟练的打开了京东,搜索“牛听听”,进入商品页面。商品首页上醒目的位置有这么两张图片。

Image

第一张图上的小女孩,旁边就摆着这个叫做“牛听听”的道具,以至于她甚至可以一边学习,一边快乐的欢笑,仿佛马上就要说出那句著名的广告语“哪里不会点哪里”。

Image

而第二张图片,简单直接的描绘了“牛听听”的用法,它可以扫描摆在它面前的书,并且把书里的内容“读”出来。

是的,这又是一个“科技解放父母的双手”的神器,作为被解放的父母,有了它,就可以减少很多帮助不识字的孩子读绘本的时间支出。有了它,想必,多出来的时间,一定自己又可以多玩不少手机吧,不过这也不重要。

重要的是,我儿子的“牛听听”,它被我搞坏了。

怎么它就坏了呢

它被我的手机充电器充过一次电以后,就再也不能开机了。我想当然的认为,充电口都是Type-C,既然插得进去,电自然就充得了。可是,它就是这么坏了,坏得透透的。

心怀一丝希望的我,接通了“牛听听”的真人客服,客服告诉我,虽然都是Type-C,但是手机快充头的输出电流太高了,把设备烧坏了。并且,这个属于人为损坏,而且本身我的这个牛听听就已经过了保修期了,她没有办法可以免费帮到我。

Image

再买一个?

看来,只能认栽了。于是,我再次打开了购物网站的商品页,准备以身作则的履行“弄坏东西就得赔”的教诲。但是,我又一次被“牛听听”惊到了。

Image

这玩意儿,原来,这么贵啊。

广告里都说什么爸爸少抽一包烟,妈妈少买一件化妆品。这,可不止一包烟呢…

就当我准备忍痛下单的时候,我那该死的工程师之魂,终于醒过来了。

等一等!

它,复杂吗?

它,不就是能识别书里面的文字,并且用声音把文字念出来吗?这功能,怎么听着挺耳熟的呢?

Image

我过去开发了搜狐新闻的手机端AI框架,里面OCR文字识别模块是现成的轮子,可以直接拿来用。

而文字转语音的部分,那也是我的老本行啊,搜狐新闻的听新闻功能也是我们开发的。

于是,万事俱备,我准备自己动手写一个,“手机版”的“牛听听”。

干货

先回顾一下搜狐双擎AI框架的实现:

搜狐双擎AI框架的文章:https://blog.csdn.net/SOHU_TECH/article/details/112975827

集成这个sdk之后,只用简单几行代码,就可以实现从Bitmap到String的转化过程:

AIHelperFactory.getInstance(context).init(AI_TOOL_OCR);

...

AIHelperFactory.getInstance(context).getOcr().recognize(bitmap);

…

AIHelperFactory.getInstance(context).release();

中间模型加载选型已经对开发者完全透明了,非常好用,不亏是我自己写的轮子。

5分钟集成完毕,我打印了李白的《静夜思》,随手拍了张照片,丢给demo程序单元测试一下效果:

ImageImage

嗯,仔细一看,不对劲?识别成了:

夜思陲

李白

麻前明月光

疑似地上霜

举头望明月

低夫照おり

翻车了…

问题出在哪里?

对比了成熟商用的扫描文档软件,看了看他门的广告图片,我发现了一个问题:

他门图片都是正面直出。随手拍摄的图片,也会有自动识别并且拉伸变形:

Image

看来,需要在OCR识别之前,加入这么几个预处理:

  • 自动识别图片中四边形

  • 辨认四边形是否具备纸张的特性

  • 提取四边形内容,变形

  • 锐化清晰文本

手机版牛听听的技术选型,从OCR+TTS变成了“纸张识别预处理”+OCR+TTS:

Image

OCR+TTS使用现成的轮子完成。问题简化成了下面这张示意图,从左图自动提取右图的过程。

Image

OpenCV实现提取照片中的纸张

图像到图像的处理,当仁不让的,这次,我选择了OpenCV。

OpenCV的强大工具类Imgproc提供了一系列的图形处理方法,仔细看每个方法的说明,总能找到实现自己想要功能的思路。

这次,我使用了里面4个方法,轻而易举的就完成了提取纸张的功能:

第一步:findContours

提取图像中的轮廓

Finds contours in a binary image.

* The function retrieves contours from the binary image using the algorithm CITE: Suzuki85 . The contours are a useful tool for shape analysis and object detection and recognition.

如方法说明,这个方法可以输出图像中的轮廓Mat,对上面的照片使用此方法后,大概可以得到下图:

Image

第二步:approxPolyDP

提取轮廓中的多边形,使用“道格拉斯-普克算法”提取多边形

Approximates a polygonal curve(s) with the specified precision. * The function cv::approxPolyDP approximates a curve or a polygon with another curve/polygon with less vertices so that the distance between them is less or equal to the specified precision.

这个算法非常巧妙的把我们前一步提取到的轮廓,通过抽样概括成了多边形。

“道格拉斯-普克算法(Douglas–Peucker algorithm,亦称为拉默-道格拉斯-普克算法、迭代适应点算法、分裂与合并算法)是将曲线近似表示为一系列点,并减少点的数量的一种算法。它的优点是具有平移和旋转不变性,给定曲线与阈值后,抽样结果一定。”

Image

我选择的采样阈值是多边形周长的2%,这样得到的近似多边形中,只留下四边形之后是这样,只留下了红绿两个四边形:

Image

第三步:contourArea

保证四边形为主四边形

Calculates a contour area.

The function computes a contour area. Similarly to moments , the area is computed using the Green formula. Thus, the returned area and the number of non-zero pixels.

通过计算四边形的面积,当面积达到一定量,才可以足够呈现里面的文本内容。

这一步,在这个测试case中,因为两个四边形都足够大,所以没有过滤出结果。

第四步:isContourConvex

保证四边形形状像一个纸张(外凸四边形)

Tests a contour convexity.

The function tests whether the input contour is convex or not. The contour must be simple, that is,  without self-intersections. Otherwise, the function output is undefined.

过滤掉红色的内凹四边形之后,剩下唯一的结果:

Image

大功告成!

来回归一下核心几步的代码实现:

getPaperBitmapWithDefaultRect方法获取图片中的纸张图片

public static Bitmap getPaperBitmapWithDefaultRect(Context context,          Uri srcUri, RectF defaultRect) {      // 1. Resize the srcBitmap to a smaller recognizeMat for performance      // optimization.      Bitmap recognizeBitmap = ImageUtils.getBitmapWithoutOrientation(              context, srcUri, PAPER_RECOGNIZE_WIDTH);      Mat recognizeMat = new Mat(recognizeBitmap.getHeight(),              recognizeBitmap.getWidth(), CvType.CV_8UC3);      try {          Utils.bitmapToMat(recognizeBitmap, recognizeMat);      } catch (org.opencv.core.CvException e) {          return null;      } catch (IllegalArgumentException e) {          return null;      }      if (recognizeMat.empty()) {          return null;      }      // 2. Find the paper edge in the recoginzeMat      MatOfPoint recognizeCorners = find_largest_square(find_squares(recognizeMat));      // 3. Get paper edge in the srcMat from paper edge in the recognizeMat      InputStream input = null;      try {          input = context.getContentResolver().openInputStream(srcUri);      } catch (FileNotFoundException e) {          e.printStackTrace();          return null;      }      BitmapFactory.Options opts = new BitmapFactory.Options();      opts.inJustDecodeBounds = true;      BitmapFactory.decodeStream(input, null, opts);      String filePath = ImageUtils.getImagePathFromUri(context, srcUri);      Mat srcMat = Imgcodecs.imread(filePath);      boolean needRotate = false;      if (srcMat.width() > srcMat.height()) {          needRotate = true;      }      Point[] recognizePoints;      if (recognizeCorners == null) {          recognizePoints = new Point[4];          int defaultWidth = needRotate ? srcMat.height()                  : srcMat.width();          int defaultHeight = needRotate ? srcMat.width()                  : srcMat.height();          if (needRotate) {              recognizePoints[0] = new Point(defaultHeight * defaultRect.top,                      defaultWidth * (1 - defaultRect.right));              recognizePoints[1] = new Point(defaultHeight                      * defaultRect.bottom, defaultWidth                      * (1 - defaultRect.right));              recognizePoints[2] = new Point(defaultHeight                      * defaultRect.bottom, defaultWidth                      * (1 - defaultRect.left));              recognizePoints[3] = new Point(defaultHeight * defaultRect.top,                      defaultWidth * (1 - defaultRect.left));          } else {              recognizePoints[0] = new Point(defaultWidth * defaultRect.left,                      defaultHeight * defaultRect.top);              recognizePoints[1] = new Point(                      defaultWidth * defaultRect.right, defaultHeight                              * defaultRect.top);              recognizePoints[2] = new Point(                      defaultWidth * defaultRect.right, defaultHeight                              * defaultRect.bottom);              recognizePoints[3] = new Point(defaultWidth * defaultRect.left,                      defaultHeight * defaultRect.bottom);          }      } else {          float scale = 1;          scale = (float) recognizeBitmap.getWidth() / opts.outWidth;          recognizePoints = recognizeCorners.toArray();          for (Point pt : recognizePoints) {              pt.x /= scale;              pt.y /= scale;          }      }      MatOfPoint srcCorners = new MatOfPoint(recognizePoints);      if (needRotate) {          srcCorners = sortRotateCorners(srcCorners);      } else {          srcCorners = sortCorners(srcCorners);      }      // 4. Get the transfer mat from the paper edge      MatOfPoint2f quad_pts = new MatOfPoint2f();      int padding = PAPER_PADDING;      Mat quad = Mat.zeros(PAPER_SIZE_HEIGHT, PAPER_SIZE_WIDTH,              CvType.CV_8UC3);      Size size = quad.size();      quad_pts.push_back(new MatOfPoint2f(new Point(-padding, -padding)));      quad_pts.push_back(new MatOfPoint2f(new Point(size.width + padding,              -padding)));      quad_pts.push_back(new MatOfPoint2f(new Point(size.width + padding,              size.height + padding)));      quad_pts.push_back(new MatOfPoint2f(new Point(-padding, size.height              + padding)));      srcCorners.convertTo(srcCorners, CvType.CV_32F);      Mat transmtx = Imgproc.getPerspectiveTransform(srcCorners, quad_pts);      // 5. Transfer the paper in srcMat      Imgproc.warpPerspective(srcMat, quad, transmtx, quad.size());      // 6. get paper bitmap      quad = getGrayContrastMat(quad);      Bitmap dstBitmap = Bitmap.createBitmap(quad.width(), quad.height(),              Bitmap.Config.ARGB_8888);      Utils.matToBitmap(quad, dstBitmap);      if (DEBUG) {          Debug.stopMethodTracing();      }      return dstBitmap;  }  

这方法里面核心得方法是find_squares

把图片的Bitmap,转换为OpenCV Mat模式。然后转为灰度图像,开始寻找四边形:

寻找四边形,使用我们前面提到的四个方法,得到纸张的四边形:

public static List<MatOfPoint> find_squares(Mat image) {      List<MatOfPoint> contours = new ArrayList<MatOfPoint>();      List<MatOfPoint> squares = new ArrayList<MatOfPoint>();      Mat blurred = new Mat(image.height(), image.width(), CvType.CV_8UC3);      Imgproc.GaussianBlur(image, blurred, new Size(11, 11), 0);      ArrayList<Mat> grayList = new ArrayList<Mat>();      Core.split(blurred, grayList);      Mat gray0 = new Mat(blurred.size(), CvType.CV_8U);      Mat gray = new Mat(image.height(), image.width(), CvType.CV_8U);      for (int a = 0; a < grayList.size(); a++) {          gray0 = grayList.get(a);          int threshold_level = 2;          for (int level = 0; level < threshold_level; level++) {              Imgproc.Canny(gray0, gray, 10 * (level + 1), 10 * (level + 1));              Imgproc.dilate(gray, gray, new Mat(), new Point(-1, -1), 1);              Mat hierarchy = new Mat();              hierarchy.convertTo(hierarchy, CvType.CV_32SC1);              Imgproc.findContours(gray, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);              MatOfPoint2f approx = new MatOfPoint2f();              for (MatOfPoint contoursPoint : contours) {                  // get each contours                  // convert contours to point2f                  // close a contours start point and end point                  MatOfPoint2f contourPoint2f = new MatOfPoint2f();                  contoursPoint.convertTo(contourPoint2f, CvType.CV_32F);                  Imgproc.approxPolyDP(contourPoint2f, approx,                          Imgproc.arcLength(contourPoint2f, true) * 0.02,                          true);                  if (approx.total() == 4) {                      // convert the closed path to a MatOfPoint                      Point[] approxArray = approx.toArray();                      MatOfPoint approxPoint = new MatOfPoint(approxArray);                      if (Math.abs(Imgproc.contourArea(approx)) > 1000                              && Imgproc.isContourConvex(approxPoint)) {                          double maxCosine = 0;                          for (int j = 2; j < 5; j++) {                              double cosine = Math.abs(angle(                                      approxArray[j % 4], approxArray[j - 2],                                      approxArray[j - 1]));                              maxCosine = Math.max(maxCosine, cosine);                          }                          if (maxCosine < 0.3) {                              squares.add(approxPoint);                          }                      }                  }              }          }      }      return squares;  }  

最后把得到的四边形区域,形变,锐化。

关于锐化图像,我们又要再次请出这位著名的“带帽子的菇凉”,相信每个程序员都在各个文档中见过她的身影:

Image

锐化矩阵,没有比这个更有名的了:

Image

我们稍微加大一点亮度,中心从5,改为5.5,实测这样提取文字的成功率更高:

private static Mat getGrayContrastMat(Mat srcMat) {      Mat highcontrastMat = Mat.zeros(srcMat.size(), CvType.CV_32FC4);      Imgproc.cvtColor(srcMat, highcontrastMat, Imgproc.COLOR_BGR2GRAY, 4);      Imgproc.GaussianBlur(highcontrastMat, highcontrastMat, new Size(3, 3),              0);      Mat kernel = new Mat(3, 3, CvType.CV_32F, new Scalar(0));      kernel.put(0, 1, -1.0);      kernel.put(1, 0, -1.0);      kernel.put(2, 1, -1.0);      kernel.put(1, 2, -1.0);      kernel.put(1, 1, 5.5);      Imgproc.filter2D(highcontrastMat, highcontrastMat,              highcontrastMat.depth(), kernel);      return highcontrastMat;  }  

效果

改完之后,来看看效果视频:

效果不错,正确率100%!再提高一点难度,识别手写体的图片怎么样呢?

Image Image

也几乎是全对!

再提高一点要求

如果我要动态识别摄像头回传的每一帧数据,这个方法的速度够快吗?

我添加了动态识别摄像头每一帧的数据,对识别到的四边形,加上候选框浮层,当候选框足够大的时候,自动拍照识别。并转到TTS播报。得到了最终的demo。

直接看效果:

真正的大功告成!零成本实现了软件版自动看书转读书。这牛听听,我应该不用赔了吧?我想孩子一定会非常满意,爸爸我可是很努力的写了代码呢。

最后

我是一名中年程序员,我不小心搞坏了儿子的牛听听,儿子让我赔,可是这个牛听听实在太贵了。于是,我自己动手写了一个手机软件版。

万万没想到,最后,我还是给孩子重新买了一个牛听听。

因为我忘了一个关键的问题,我儿子他

没 有 手 机…

虽然牛听听很贵,可是,现在给搜狐技术产品投稿,稿费也足够补贴我买牛听听了。于是,你看到了这篇文章。写代码的中年人,能有什么歪脑筋呢?

什么,你也想要挣稿费?那还等什么,赶紧添加搜狐技术产品的公众号,来投稿吧!

Image