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

format,png

 6b5c79e9f40a7586194755dc4430471c.gif 

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

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

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

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

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

啥是“牛听听”

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

6d79650af0852f7ab5c68a6bfe482af9.jpeg

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

b9854c83af4f094eadfcb0e64a3f35e6.jpeg

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

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

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

怎么它就坏了呢

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

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

5c8cc35d523a5b754129fdaced0cb767.png

再买一个?

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

d9ff8c0b9a36a1e15a2e2564ed71c78e.png

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

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

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

等一等!

它,复杂吗?

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

2baef0834ec73582192937ac87f2a9cd.png

我过去开发了搜狐新闻的手机端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程序单元测试一下效果:

56e32eb3349e57740c09d4bb8990aae3.jpege14952786d54b07606a1cc6f97f62f35.jpeg

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

夜思陲

李白

麻前明月光

疑似地上霜

举头望明月

低夫照おり

翻车了…

问题出在哪里?

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

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

b0ddc844183d9cbd92b00171d6de93a2.gif

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

  • 自动识别图片中四边形

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

  • 提取四边形内容,变形

  • 锐化清晰文本

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

6abcc779f0b7fe06fee004ef1c8a0776.png

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

2381b08d65187c3794419e775a25e8e3.png

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,对上面的照片使用此方法后,大概可以得到下图:

4332123e4ef400e445d719752e8d2d7e.jpeg

第二步: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,亦称为拉默-道格拉斯-普克算法、迭代适应点算法、分裂与合并算法)是将曲线近似表示为一系列点,并减少点的数量的一种算法。它的优点是具有平移和旋转不变性,给定曲线与阈值后,抽样结果一定。”

0c803868b9f867936d2703f8362e4756.png

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

9a81b3d229fc84429619f3363c37fe67.jpeg

第三步: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.

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

e9b910dc581f4f6463dd72d95262f3e4.jpeg

大功告成!

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

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;  
}

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

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

da489ef20c0ef531518a9994999cc34b.png

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

8af79a15eed4e89e3fe05854655e7f83.png

我们稍微加大一点亮度,中心从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%!再提高一点难度,识别手写体的图片怎么样呢?

acbf0cf85cd02e7dfcf9b0e50f9896b4.jpeg9ebcd16fbb30cdb71689aa1acf49cd63.jpeg

也几乎是全对!

再提高一点要求

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

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

直接看效果:

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

最后

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

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

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

没 有 手 机…

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值