ac.robinson.ticqr.TickBoxImageParserTask.java Source code

Java tutorial

Introduction

Here is the source code for ac.robinson.ticqr.TickBoxImageParserTask.java

Source

/*
 * Copyright (c) 2014 Simon Robinson
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package ac.robinson.ticqr;

import android.graphics.Bitmap;
import android.graphics.PointF;
import android.os.AsyncTask;
import android.util.Log;

import org.opencv.android.Utils;
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.MatOfPoint;
import org.opencv.core.MatOfPoint2f;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.imgproc.Imgproc;

import java.util.ArrayList;
import java.util.List;

class TickBoxImageParserTask extends AsyncTask<Void, Bitmap, ArrayList<PointF>> {

    private static final String TAG = TickBoxImageParserTask.class.getSimpleName();
    private static final boolean DEBUG = false;

    private final Bitmap mBitmap;
    private final float mBoxSize;

    private final TickBoxImageParserCallback mCallback;

    public interface TickBoxImageParserCallback {
        public void boxDetectionFailed();

        public void boxDetectionSucceeded(ArrayList<PointF> result);
    }

    public TickBoxImageParserTask(Bitmap bitmap, float boxSize, TickBoxImageParserCallback callback) {
        mBitmap = bitmap;
        mBoxSize = boxSize;
        mCallback = callback;
    }

    @Override
    protected ArrayList<PointF> doInBackground(Void... unused) {
        Log.d(TAG, "Searching for tick boxes of " + mBoxSize + " size");

        // we look for *un-ticked* boxes, rather than ticked, as they are uniform in appearance (and hence easier to
        // detect) - they show up as a box within a box
        ArrayList<PointF> centrePoints = new ArrayList<>();
        int minimumOuterBoxArea = (int) Math.round(Math.pow(mBoxSize, 2));
        int maximumOuterBoxArea = (int) Math.round(Math.pow(mBoxSize * 1.35f, 2));
        int minimumInnerBoxArea = (int) Math.round(Math.pow(mBoxSize * 0.5f, 2));

        // image adjustment - blurSize, blurSTDev and adaptiveThresholdSize must not be even numbers
        int blurSize = 9;
        int blurSTDev = 3;
        int adaptiveThresholdSize = Math.round(mBoxSize * 3); // (oddness ensured below)
        int adaptiveThresholdC = 4; // value to add to the mean (can be negative or zero)
        adaptiveThresholdSize = adaptiveThresholdSize % 2 == 0 ? adaptiveThresholdSize + 1 : adaptiveThresholdSize;

        // how similar the recognised polygon must be to its actual contour - lower is more similar
        float outerPolygonSimilarity = 0.045f;
        float innerPolygonSimilarity = 0.075f; // don't require as much accuracy for the inner part of the tick box

        // how large the maximum internal angle can be (e.g., for checking square shape)
        float maxOuterAngleCos = 0.3f;
        float maxInnerAngleCos = 0.4f;

        // use OpenCV to recognise boxes that have a box inside them - i.e. an un-ticked tick box
        // see: http://stackoverflow.com/a/11427501
        // Bitmap newBitmap = mBitmap.copy(Bitmap.Config.RGB_565, true); // not needed
        Mat bitMat = new Mat();
        Utils.bitmapToMat(mBitmap, bitMat);

        // blur and convert to grey
        // alternative (less flexible): Imgproc.medianBlur(bitMat, bitMat, blurSize);
        Imgproc.GaussianBlur(bitMat, bitMat, new Size(blurSize, blurSize), blurSTDev, blurSTDev);
        Imgproc.cvtColor(bitMat, bitMat, Imgproc.COLOR_RGB2GRAY); // need 8uC1 (1 channel, unsigned char) image type

        // perform adaptive thresholding to detect edges
        // alternative (slower): Imgproc.Canny(bitMat, bitMat, 10, 20, 3, false);
        Imgproc.adaptiveThreshold(bitMat, bitMat, 255, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY,
                adaptiveThresholdSize, adaptiveThresholdC);

        // get the contours in the image, and their hierarchy
        Mat hierarchyMat = new Mat();
        List<MatOfPoint> contours = new ArrayList<>();
        Imgproc.findContours(bitMat, contours, hierarchyMat, Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);
        if (DEBUG) {
            Imgproc.drawContours(bitMat, contours, -1, new Scalar(30, 255, 255), 1);
        }

        // parse the contours and look for a box containing another box, with similar enough sizes
        int numContours = contours.size();
        ArrayList<Integer> searchedContours = new ArrayList<>();
        Log.d(TAG, "Found " + numContours + " possible tick box areas");
        if (numContours > 0 && !hierarchyMat.empty()) {
            for (int i = 0; i < numContours; i++) {

                // the original detected contour
                MatOfPoint boxPoints = contours.get(i);

                // hierarchy key: 0 = next sibling num, 1 = previous sibling num, 2 = first child num, 3 = parent num
                int childBox = (int) hierarchyMat.get(0, i)[2]; // usually the largest child (as we're doing RETR_TREE)
                if (childBox == -1) { // we only want elements that have children
                    continue;
                } else {
                    if (searchedContours.contains(childBox)) {
                        if (DEBUG) {
                            Log.d(TAG, "Ignoring duplicate box at first stage: " + childBox);
                        }
                        continue;
                    } else {
                        searchedContours.add(childBox);
                    }
                }

                // discard smaller (i.e. noise) outer box areas as soon as possible for speed
                // used to do Imgproc.isContourConvex(outerPoints) later, but the angle check covers this, so no need
                double originalArea = Math.abs(Imgproc.contourArea(boxPoints));
                if (originalArea < minimumOuterBoxArea) {
                    // if (DEBUG) {
                    // drawPoints(bitMat, boxPoints, new Scalar(255, 255, 255), 1);
                    // Log.d(TAG, "Outer box too small");
                    // }
                    continue;
                }
                if (originalArea > maximumOuterBoxArea) {
                    // if (DEBUG) {
                    // drawPoints(bitMat, boxPoints, new Scalar(255, 255, 255), 1);
                    // Log.d(TAG, "Outer box too big");
                    // }
                    continue;
                }

                // simplify the contours of the outer box - we want to detect four-sided shapes only
                MatOfPoint2f boxPoints2f = new MatOfPoint2f(boxPoints.toArray()); // Point2f for approxPolyDP
                Imgproc.approxPolyDP(boxPoints2f, boxPoints2f,
                        outerPolygonSimilarity * Imgproc.arcLength(boxPoints2f, true), true); // simplify the contour
                if (boxPoints2f.height() != 4) { // height is number of points
                    if (DEBUG) {
                        // drawPoints(bitMat, new MatOfPoint(boxPoints2f.toArray()), new Scalar(255, 255, 255), 1);
                        Log.d(TAG, "Outer box not 4 points");
                    }
                    continue;
                }

                // check that the simplified outer box is approximately a square, angle-wise
                org.opencv.core.Point[] boxPointsArray = boxPoints2f.toArray();
                double maxCosine = 0;
                for (int j = 0; j < 4; j++) {
                    org.opencv.core.Point pL = boxPointsArray[j];
                    org.opencv.core.Point pIntersect = boxPointsArray[(j + 1) % 4];
                    org.opencv.core.Point pR = boxPointsArray[(j + 2) % 4];
                    getLineAngle(pL, pIntersect, pR);
                    maxCosine = Math.max(maxCosine, getLineAngle(pL, pIntersect, pR));
                }
                if (maxCosine > maxOuterAngleCos) {
                    if (DEBUG) {
                        // drawPoints(bitMat, new MatOfPoint(boxPoints2f.toArray()), new Scalar(255, 255, 255), 1);
                        Log.d(TAG, "Outer angles not square enough");
                    }
                    continue;
                }

                // check that the simplified outer box is approximately a square, line length-wise
                double minLine = Double.MAX_VALUE;
                double maxLine = 0;
                for (int p = 1; p < 4; p++) {
                    org.opencv.core.Point p1 = boxPointsArray[p - 1];
                    org.opencv.core.Point p2 = boxPointsArray[p];
                    double xd = p1.x - p2.x;
                    double yd = p1.y - p2.y;
                    double lineLength = Math.sqrt((xd * xd) + (yd * yd));
                    minLine = Math.min(minLine, lineLength);
                    maxLine = Math.max(maxLine, lineLength);
                }
                if (maxLine - minLine > minLine) {
                    if (DEBUG) {
                        // drawPoints(bitMat, new MatOfPoint(boxPoints2f.toArray()), new Scalar(255, 255, 255), 1);
                        Log.d(TAG, "Outer lines not square enough");
                    }
                    continue;
                }

                // draw the outer box if debugging
                if (DEBUG) {
                    MatOfPoint debugBoxPoints = new MatOfPoint(boxPointsArray);
                    Log.d(TAG,
                            "Potential tick box: " + boxPoints2f.size() + ", " + "area: "
                                    + Math.abs(Imgproc.contourArea(debugBoxPoints)) + " (min:" + minimumOuterBoxArea
                                    + ", max:" + maximumOuterBoxArea + ")");
                    drawPoints(bitMat, debugBoxPoints, new Scalar(50, 255, 255), 2);
                }

                // loop through the children - they should be in descending size order, but sometimes this is wrong
                boolean wrongBox = false;
                while (true) {
                    if (DEBUG) {
                        Log.d(TAG, "Looping with box: " + childBox);
                    }

                    // we've previously tried a child - try the next one
                    // key: 0 = next sibling num, 1 = previous sibling num, 2 = first child num, 3 = parent num
                    if (wrongBox) {
                        childBox = (int) hierarchyMat.get(0, childBox)[0];
                        if (childBox == -1) {
                            break;
                        }
                        if (searchedContours.contains(childBox)) {
                            if (DEBUG) {
                                Log.d(TAG, "Ignoring duplicate box at loop stage: " + childBox);
                            }
                            break;
                        } else {
                            searchedContours.add(childBox);
                        }
                        //noinspection UnusedAssignment
                        wrongBox = false;
                    }

                    // perhaps this is the outer box - check its child has no children itself
                    // (removed so tiny children (i.e. noise) don't mean we mis-detect an un-ticked box as ticked)
                    // if (hierarchyMat.get(0, childBox)[2] != -1) {
                    // continue;
                    // }

                    // check the size of the child box is large enough
                    boxPoints = contours.get(childBox);
                    originalArea = Math.abs(Imgproc.contourArea(boxPoints));
                    if (originalArea < minimumInnerBoxArea) {
                        if (DEBUG) {
                            // drawPoints(bitMat, boxPoints, new Scalar(255, 255, 255), 1);
                            Log.d(TAG, "Inner box too small");
                        }
                        wrongBox = true;
                        continue;
                    }

                    // simplify the contours of the inner box - again, we want four-sided shapes only
                    boxPoints2f = new MatOfPoint2f(boxPoints.toArray());
                    Imgproc.approxPolyDP(boxPoints2f, boxPoints2f,
                            innerPolygonSimilarity * Imgproc.arcLength(boxPoints2f, true), true);
                    if (boxPoints2f.height() != 4) { // height is number of points
                        // if (DEBUG) {
                        // drawPoints(bitMat, boxPoints, new Scalar(255, 255, 255), 1);
                        // }
                        Log.d(TAG, "Inner box fewer than 4 points"); // TODO: allow > 4 for low quality images?
                        wrongBox = true;
                        continue;
                    }

                    // check that the simplified inner box is approximately a square, angle-wise
                    // higher tolerance because noise means if we get several inners, the box may not be quite square
                    boxPointsArray = boxPoints2f.toArray();
                    maxCosine = 0;
                    for (int j = 0; j < 4; j++) {
                        org.opencv.core.Point pL = boxPointsArray[j];
                        org.opencv.core.Point pIntersect = boxPointsArray[(j + 1) % 4];
                        org.opencv.core.Point pR = boxPointsArray[(j + 2) % 4];
                        getLineAngle(pL, pIntersect, pR);
                        maxCosine = Math.max(maxCosine, getLineAngle(pL, pIntersect, pR));
                    }
                    if (maxCosine > maxInnerAngleCos) {
                        Log.d(TAG, "Inner angles not square enough");
                        wrongBox = true;
                        continue;
                    }

                    // this is probably an inner box - log if debugging
                    if (DEBUG) {
                        Log.d(TAG,
                                "Un-ticked inner box: " + boxPoints2f.size() + ", " + "area: "
                                        + Math.abs(Imgproc.contourArea(new MatOfPoint2f(boxPointsArray)))
                                        + " (min: " + minimumInnerBoxArea + ")");
                    }

                    // find the inner box centre
                    double centreX = (boxPointsArray[0].x + boxPointsArray[1].x + boxPointsArray[2].x
                            + boxPointsArray[3].x) / 4f;
                    double centreY = (boxPointsArray[0].y + boxPointsArray[1].y + boxPointsArray[2].y
                            + boxPointsArray[3].y) / 4f;

                    // draw the inner box if debugging
                    if (DEBUG) {
                        drawPoints(bitMat, new MatOfPoint(boxPointsArray), new Scalar(255, 255, 255), 1);
                        Core.circle(bitMat, new org.opencv.core.Point(centreX, centreY), 3,
                                new Scalar(255, 255, 255));
                    }

                    // add to the list of boxes to check
                    centrePoints.add(new PointF((float) centreX, (float) centreY));
                    break;
                }
            }
        }

        Log.d(TAG, "Found " + centrePoints.size() + " un-ticked boxes");
        return centrePoints;
    }

    private void drawPoints(Mat bitMat, MatOfPoint boxPoints, Scalar colour, int width) {
        List<MatOfPoint> simplifiedOuterBox = new ArrayList<>();
        simplifiedOuterBox.add(boxPoints);
        Imgproc.drawContours(bitMat, simplifiedOuterBox, -1, colour, width);
    }

    private double getLineAngle(org.opencv.core.Point pL, org.opencv.core.Point pIntersect,
            org.opencv.core.Point pR) {
        double dx21 = pL.x - pIntersect.x;
        double dx31 = pR.x - pIntersect.x;
        double dy21 = pL.y - pIntersect.y;
        double dy31 = pR.y - pIntersect.y;
        double m12 = Math.sqrt(dx21 * dx21 + dy21 * dy21);
        double m13 = Math.sqrt(dx31 * dx31 + dy31 * dy31);
        return Math.abs((dx21 * dx31 + dy21 * dy31) / (m12 * m13));
    }

    @Override
    protected void onPostExecute(ArrayList<PointF> result) {
        if (result == null) {
            mCallback.boxDetectionFailed();
        } else {
            mCallback.boxDetectionSucceeded(result);
        }
    }
}