KoImgProc.java Source code

Java tutorial

Introduction

Here is the source code for KoImgProc.java

Source

/*
 * Copyright Jordan Schalm 2015
 * This program is distributed under the terms of the GNU General Public License.
 * 
 * This file is part of Ko.
 *
 * Ko is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Ko is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Ko.  If not, see <http://www.gnu.org/licenses/>.
 */

import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.Point;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.imgproc.Imgproc;

import java.util.ArrayList;
import java.util.TreeMap;

/**
 * @author Jordan Schalm
 * 
 * KoImgProc is a utility class used for image processing related to board construction.
 * KoImgProc is strictly concerned with the process of detecting and filtering circles
 * and returns an array of KoStone objects which have passed the filtering process.
 *
 * Note on BGR colour:
 * (0,0,0) is black
 * (255,255,255) is white
 */
public class KoImgProc {
    private final static boolean VERBOSE = true;

    // Constants used as input to the HoughCircles function
    private final static int DP = 1;
    private final static int CANNY_DETECTOR_THRESHOLD_HIGH = 70;
    private final static int CANNY_DETECTOR_THRESHOLD_MED = 50;
    private final static int CANNY_DETECTOR_THRESHOLD_LOW = 30;

    private static final String WHITE_BGR_MIN_KEY = "whiteBGRMin";
    private static final String WHITE_BGR_MAX_KEY = "whiteBGRMax";
    private static final String WHITE_BGR_AVE_KEY = "whiteBGRAve";
    private static final String WHITE_BGR_AVE_DEV_KEY = "whiteBGRAveDev";
    private static final String BLACK_BGR_MIN_KEY = "blackBGRMin";
    private static final String BLACK_BGR_MAX_KEY = "blackBGRMax";
    private static final String BLACK_BGR_AVE_KEY = "blackBGRAve";
    private static final String BLACK_BGR_AVE_DEV_KEY = "blackBGRAveDev";
    private static final String GREYSCALE_AVE_KEY = "greyscaleAve";

    private static final int CIRCLE_OUTLINE_THICKNESS = 2;
    private static final int CIRCLE_FILL_THICKNESS = -1;
    private static final int CIRCLE_LINE_TYPE = 8;
    private static final int TRENDLINE_DELINEATOR_RADIUS = 8;
    private static final int TRENDLINE_THICKNESS = 5;

    private static final double BGRBufferFactor = 3.5;

    public static final Scalar BLUE = new Scalar(255, 0, 0);
    public static final Scalar GREEN = new Scalar(0, 255, 0);
    public static final Scalar RED = new Scalar(0, 0, 255);

    /**
     * Takes a Mat and performs a series of image analysis and filtering steps
     * to detect stones in the image and filter out false circles.
     * @param color 
     *             The input Mat to perform analysis on. Must be a 3-channel,
     *             8-bit BGR color image. 
     * @return An array of KoCircle objects that represent stones that were
     *          detected on the board.
     */
    public static ArrayList<KoCircle> detectStones(Mat color) {
        // Create 2 Mats we'll need for image processing
        Mat grey = new Mat();
        Mat blurred = new Mat();
        Imgproc.cvtColor(color, grey, Imgproc.COLOR_BGR2GRAY); // Convert to greyscale
        Imgproc.GaussianBlur(grey, grey, new Size(9, 9), 2, 2);
        Imgproc.GaussianBlur(color, blurred, new Size(9, 9), 2, 2);

        int widthInPixels = color.cols();
        //int heightInPixels = color.cols();

        // widthInPixels will be replaced by dimensions from a camera overlay that aligns with the board
        ArrayList<KoCircle> stones = detectStones(grey, widthInPixels / 50, widthInPixels / 30, widthInPixels / 30,
                CANNY_DETECTOR_THRESHOLD_HIGH);
        double aveRadius = getAverageRadius(stones);

        // Calculate more accurate inputs to HoughCircles given the average radius
        int minCircleRadius = (int) (0.9 * aveRadius);
        int maxCircleRadius = (int) (1.1 * aveRadius);
        int minDist = (int) (aveRadius * 1.75);

        stones = detectStones(grey, minCircleRadius, maxCircleRadius, minDist, CANNY_DETECTOR_THRESHOLD_MED);
        TreeMap<String, Double[]> colorData = getColorData(stones, blurred, grey);

        if (VERBOSE) {
            for (String key : colorData.keySet()) {
                Double[] current = colorData.get(key);
                System.out.println("Data for " + key + ":");
                for (int i = 0; i < current.length; i++) {
                    System.out.println(current[i]);
                }
            }
        }

        stones = detectStones(grey, minCircleRadius, maxCircleRadius, minDist, CANNY_DETECTOR_THRESHOLD_LOW);

        return filterStones(stones, colorData, aveRadius, grey, blurred);
    }

    public static double getAverageRadius(ArrayList<KoCircle> stones) {
        double radiusSum = 0;
        int numDetectedStones = 0;
        for (KoCircle current : stones) {
            radiusSum += current.getRadius();
            numDetectedStones++;
        }

        if (numDetectedStones == 0) {
            return 0;
        }
        return radiusSum / numDetectedStones;
    }

    /**
     * Detects stones in the given image and returns an array list of KoCircle objects.
     * @param grey greyscale Mat of the board.
     * @param threshold threshold value for HoughCircles
     * @return an ArrayList of KoCircle objects.
     */
    private static ArrayList<KoCircle> detectStones(Mat grey, int minRadius, int maxRadius, int minDist,
            int threshold) {
        Mat stones = new Mat();
        ArrayList<KoCircle> highThresholdStones = new ArrayList<KoCircle>();

        Imgproc.HoughCircles(grey, stones, Imgproc.CV_HOUGH_GRADIENT, DP, minDist, threshold, threshold / 2,
                minRadius, maxRadius);
        for (int i = 0; i < stones.cols(); i++) {
            highThresholdStones.add(new KoCircle(stones.get(0, i)));
        }
        System.out.println(highThresholdStones.size() + " stones detected. minRadius: " + minRadius
                + "\tmaxRadius: " + maxRadius + "\tminDist: " + minDist + "\tthreshold: " + threshold);

        return highThresholdStones;
    }

    private static ArrayList<KoCircle> filterStones(ArrayList<KoCircle> stones, TreeMap<String, Double[]> colorData,
            double aveRadius, Mat grey, Mat color) {
        ArrayList<KoCircle> filteredStones = new ArrayList<KoCircle>();
        ArrayList<KoCircle> stonesOutsideColorRange = new ArrayList<KoCircle>();
        double greyAve = colorData.get(GREYSCALE_AVE_KEY)[0];
        Double[] whiteBGRBuffer = { 0.0, 0.0, 0.0 };
        Double[] blackBGRBuffer = { 0.0, 0.0, 0.0 };

        for (int i = 0; i < whiteBGRBuffer.length; i++) {
            whiteBGRBuffer[i] = BGRBufferFactor * colorData.get(WHITE_BGR_AVE_DEV_KEY)[i];
            blackBGRBuffer[i] = BGRBufferFactor * colorData.get(BLACK_BGR_AVE_DEV_KEY)[i];
        }

        for (KoCircle current : stones) {
            Double[] BGRAve;
            Double[] currentBGRBuffer;
            if (getAverageGreyscaleColor(current.getImgX(), current.getImgY(), (int) current.getRadius(),
                    grey) > greyAve) {
                BGRAve = colorData.get(WHITE_BGR_AVE_KEY);
                current.defineColor(KoCircle.WHITE);
                currentBGRBuffer = whiteBGRBuffer;
            } else {
                BGRAve = colorData.get(BLACK_BGR_AVE_KEY);
                current.defineColor(KoCircle.BLACK);
                currentBGRBuffer = blackBGRBuffer;
            }
            double[] stoneBGR = getAverageBGRColor(current.getImgX(), current.getImgY(), (int) current.getRadius(),
                    color);
            if (isInBGRRange(stoneBGR, BGRAve, currentBGRBuffer)) {
                filteredStones.add(current);
            } else {
                stonesOutsideColorRange.add(current);
            }
        }

        /*for(KoCircle current : stonesOutsideColorRange) {
           if(isWellAligned(current, filteredStones, aveRadius)) {
          filteredStones.add(current);
           }
        }*/

        return filteredStones;
    }

    /*private static double calculateBoardSlope(ArrayList<KoCircle> stones) {
       double slopeSum = 0;
       ArrayList<KoTrendline> trendlines = KoBoard.defineTrendlines(stones);
       for(KoTrendline current : trendlines) {
      if(current.getOrientation() == KoTrendline.X_ORIENTATION) {
         slopeSum += current.calculateSlope();
      }
      else {
         // If the trendline is along the y-axis then its slope is -1/m 
         // where m is the slope along the x-axis
         slopeSum -= 1.0 / current.calculateSlope();
      }
       }
       return slopeSum / trendlines.size();
    }*/

    /**
     * Tests a stone to see whether it aligns with other detected stones. Use of this method
     * during the filtering process requires that images be properly aligned with the board
     * and that stones be well placed (near the actual intersection).
     * @param origin 
     *             the stone to check alignment of
     * @param stones 
     *             a set of stones which have been filtered in some way and have a very 
     *             small likelihood of being false circles
     * @param aveRadius 
     *             average stone radius in pixels, determined with a high threshold
     * @return true if the stone is well aligned, false otherwise
     */
    /*private static boolean isWellAligned(KoCircle origin, ArrayList<KoCircle> stones, double aveRadius) {
       int nearbyXSum = 0, nearbyYSum = 0, nearbyXCount = 0, nearbyYCount = 0;
       int xOrigin = origin.getImgX();
       int yOrigin = origin.getImgY();
           
       double slope = calculateBoardSlope(stones);
           
       for(KoCircle current : stones) {
      if(Math.abs(current.getImgX() - xOrigin + (current.getImgY() - yOrigin) * (-1.0 / slope)) < 1.75 * aveRadius ) {
         nearbyXSum += Math.abs(current.getImgX() - xOrigin + (current.getImgY() - yOrigin) * slope);
         nearbyXCount++;
      }
      if(Math.abs(current.getImgY() - (yOrigin + (current.getImgX() - xOrigin) * slope)) < 1.75 * aveRadius) {
         nearbyYSum += Math.abs(current.getImgY() - (yOrigin + (current.getImgX() - xOrigin) * slope));
         nearbyYCount++;
      }
       }
       double nearbyXAveDev = (nearbyXCount != 0) ? (nearbyXSum / nearbyXCount) : 0;
       double nearbyYAveDev = (nearbyYCount != 0) ? (nearbyYSum / nearbyYCount) : 0;
           
       if(nearbyXAveDev > (0.4 * aveRadius) || nearbyYAveDev > (0.4 * aveRadius)) {
      return false;
       }
       return true;
    }*/

    /**
     * Checks whether a stone is within the predetermined BGR range acceptable for each
     * color of stone. Checks each color channel and returns true if the stone's color
     * is within in range for all channels.
     * @param stoneBGR the BGR values of the stone to check.
     * @param BGRAve the average BGR values for this board.
     * @param BGRBuffer the predetermined maximum deviation from the mean.
     * @return true if the stone is within maximum deviation for all color channels,
     *         false otherwise.
     */
    private static boolean isInBGRRange(double[] stoneBGR, Double[] BGRAve, Double[] BGRBuffer) {
        int colorRanges = 0;
        for (int i = 0; i < stoneBGR.length; i++) {
            if (Math.abs(stoneBGR[i] - BGRAve[i]) < BGRBuffer[i]) {
                // One of the color channels is inside the range
                colorRanges++;
            }
        }
        return colorRanges > 2;
    }

    public static double getAverageGreyscaleColor(int x, int y, int radius, Mat grey) {
        // TODO what if x/y +/- offset is outside the range of the Mat? Throws NullPointerException
        double sum = 0;
        int offset = radius / 5;
        sum += grey.get(y, x)[0];
        sum += grey.get(y, x + offset)[0];
        sum += grey.get(y, x - offset)[0];
        sum += grey.get(y + offset, x)[0];
        sum += grey.get(y - offset, x)[0];
        return sum / 5;
    }

    public static double[] getAverageBGRColor(int x, int y, int radius, Mat color) {
        int sum = 0;
        int offset = radius / 5;
        double averageBGRColourValue[] = new double[3];
        for (int i = 0; i < 3; i++) {
            sum += color.get(y, x)[i];
            sum += color.get(y, x + offset)[i];
            sum += color.get(y, x - offset)[i];
            sum += color.get(y + offset, x)[i];
            sum += color.get(y - offset, x)[i];
            sum += color.get(y + offset, x + offset)[i];
            sum += color.get(y - offset, x - offset)[i];
            sum += color.get(y + offset, x - offset)[i];
            sum += color.get(y - offset, x + offset)[i];
            averageBGRColourValue[i] = sum / 9;
            sum = 0;
        }
        return averageBGRColourValue;
    }

    /**
     * Determines minimums, maximums and averages for each colour space (BGR) for each piece
     * color (black/white)
     * @param stones List of KoCircle objects that have been detected with MED threshold.
     * @param color original color Mat of the board.
     * @param grey greyscale Mat of the board.
     * @return a Map<String, Double[]> so that each string is the name of a variable defined
     *         at the beginning of the function: whiteBGRMin, whiteBGRMax, etc. Constants to
     *         that effect are declared at the beginning of this class.
     */
    private static TreeMap<String, Double[]> getColorData(ArrayList<KoCircle> stones, Mat color, Mat grey) {
        int greySum = 0;
        int greyCount = 0;
        for (KoCircle current : stones) {
            greySum += getAverageGreyscaleColor(current.getImgX(), current.getImgY(), (int) current.getRadius(),
                    grey);
            greyCount++;
        }
        // TODO edge case where no stones are detected
        double greyAve = greySum / greyCount;

        // Names or key strings in output map are the same as these variables
        Double[] whiteBGRSum = { 0.0, 0.0, 0.0 };
        Double[] whiteBGRMin = { Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE };
        Double[] whiteBGRMax = { 0.0, 0.0, 0.0 };
        Double[] whiteBGRAve = { 0.0, 0.0, 0.0 };
        Double[] whiteBGRAveDev = { 0.0, 0.0, 0.0 };

        Double[] blackBGRSum = { 0.0, 0.0, 0.0 };
        Double[] blackBGRMin = { Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE };
        Double[] blackBGRMax = { 0.0, 0.0, 0.0 };
        Double[] blackBGRAve = { 0.0, 0.0, 0.0 };
        Double[] blackBGRAveDev = { 0.0, 0.0, 0.0 };

        Double[] greyscaleAve = { greyAve };

        ArrayList<KoCircle> whiteStones = new ArrayList<KoCircle>();
        ArrayList<KoCircle> blackStones = new ArrayList<KoCircle>();

        for (KoCircle current : stones) {
            double currentGreyscale = getAverageGreyscaleColor(current.getImgX(), current.getImgY(),
                    (int) current.getRadius(), grey);
            double[] currentBGR = getAverageBGRColor(current.getImgX(), current.getImgY(),
                    (int) current.getRadius(), color);

            if (currentGreyscale > greyAve) { // Stone is white
                whiteStones.add(current);
                for (int i = 0; i < whiteBGRSum.length; i++) {
                    whiteBGRSum[i] += currentBGR[i];
                    if (currentBGR[i] < whiteBGRMin[i]) {
                        whiteBGRMin[i] = currentBGR[i];
                    }
                    if (currentBGR[i] > whiteBGRMax[i]) {
                        whiteBGRMax[i] = currentBGR[i];
                    }
                }
            } else { // Stone is black
                blackStones.add(current);
                for (int i = 0; i < blackBGRSum.length; i++) {
                    blackBGRSum[i] += currentBGR[i];
                    if (currentBGR[i] < blackBGRMin[i]) {
                        blackBGRMin[i] = currentBGR[i];
                    }
                    if (currentBGR[i] > blackBGRMax[i]) {
                        blackBGRMax[i] = currentBGR[i];
                    }
                }
            }
        }
        for (int i = 0; i < whiteBGRAve.length; i++) {
            whiteBGRAve[i] = whiteBGRSum[i] / whiteStones.size();
            blackBGRAve[i] = blackBGRSum[i] / blackStones.size();
            // Reset the sums to zero so we can reuse these variables to calculate average deviation.
            whiteBGRSum[i] = 0.0;
            blackBGRSum[i] = 0.0;
        }

        for (KoCircle current : whiteStones) {
            double[] currentBGR = getAverageBGRColor(current.getImgX(), current.getImgY(),
                    (int) current.getRadius(), color);
            for (int i = 0; i < whiteBGRSum.length; i++) {
                whiteBGRSum[i] += Math.abs(currentBGR[i] - whiteBGRAve[i]);
            }
        }
        for (KoCircle current : blackStones) {
            double[] currentBGR = getAverageBGRColor(current.getImgX(), current.getImgY(),
                    (int) current.getRadius(), color);
            for (int i = 0; i < blackBGRSum.length; i++) {
                blackBGRSum[i] += Math.abs(currentBGR[i] - blackBGRAve[i]);
            }
        }
        for (int i = 0; i < whiteBGRAve.length; i++) {
            whiteBGRAveDev[i] = whiteBGRSum[i] / whiteStones.size();
            blackBGRAveDev[i] = blackBGRSum[i] / blackStones.size();
        }

        TreeMap<String, Double[]> colorData = new TreeMap<String, Double[]>();

        colorData.put(WHITE_BGR_MIN_KEY, whiteBGRMin);
        colorData.put(WHITE_BGR_MAX_KEY, whiteBGRMax);
        colorData.put(WHITE_BGR_AVE_KEY, whiteBGRAve);
        colorData.put(WHITE_BGR_AVE_DEV_KEY, whiteBGRAveDev);
        colorData.put(BLACK_BGR_MIN_KEY, blackBGRMin);
        colorData.put(BLACK_BGR_MAX_KEY, blackBGRMax);
        colorData.put(BLACK_BGR_AVE_KEY, blackBGRAve);
        colorData.put(BLACK_BGR_AVE_DEV_KEY, blackBGRAveDev);
        colorData.put(GREYSCALE_AVE_KEY, greyscaleAve);

        return colorData;
    }

    /**
     * Draws a straight line from the top (or rightmost) coordinate to the bottom (or leftmost)
     * coordinate along the line that corresponds to the average origin of the the trendline.
     * Also draws a filled circle at the top and bottom of the trendline to indicate its length.
     * For example, for a trendline oriented in the y direction, the x-coordinate of the line
     * corresponds to the average x-coordinate of the constituents of the trendline.
     * @param trendline
     *             Trendline to draw.
     * @param image
     *             Mat image to draw the trendline onto.
     * @param circleConstituents
     *             If true, also draw a circle around each of the constituents of the trendline.
     */
    public static void drawTrendline(KoTrendline trendline, Mat image, boolean circleConstituents) {
        Point top = trendline.getTopPoint();
        Point bottom = trendline.getBottomPoint();
        Scalar blue = new Scalar(255, 0, 0);
        // Draw a line between the highest (right-most) stone and the lowest (left-most) stone
        Core.line(image, bottom, top, blue, TRENDLINE_THICKNESS);
        // Draw a filled circle at the top and bottom of the trendline.
        Core.circle(image, bottom, TRENDLINE_DELINEATOR_RADIUS, blue, CIRCLE_FILL_THICKNESS, CIRCLE_LINE_TYPE, 0);
        Core.circle(image, top, TRENDLINE_DELINEATOR_RADIUS, blue, CIRCLE_FILL_THICKNESS, CIRCLE_LINE_TYPE, 0);
        if (circleConstituents) {
            for (KoCircle stone : trendline.getConstituents()) {
                outlineCircle(image, stone, blue, 20, false);
            }
        }
    }

    /**
     * Outlines a set of circles with the given color
     */
    public static void outlineCircles(Mat image, ArrayList<KoCircle> stones, Scalar color, boolean drawCentres) {
        for (KoCircle current : stones) {
            outlineCircle(image, current, color, 0, drawCentres);
        }
    }

    /**
     * Outlines a single circle.
     * @param image
     *          Mat image to outline the circle on.
     * @param circle
     *          DetectedCircle to circle.
     * @param colour
     *          The colour to use for the outline.
     * @param radiusOffset
     *          optional radius offset value (default is 0). This value will 
     *          be added to the stone's radius when determining the radius
     *          of the circle outline.
     * @param drawCentre
     *          If true, will also draw a dot representing the centre point
     *          of the stone. If false, will only draw the circle outline.
     */
    public static void outlineCircle(Mat image, KoCircle circle, Scalar colour, int radiusOffset,
            boolean drawCentre) {
        Point center = new Point(circle.getImgX(), circle.getImgY());
        Core.circle(image, center, (int) circle.getRadius() + radiusOffset, colour, CIRCLE_OUTLINE_THICKNESS,
                CIRCLE_LINE_TYPE, 0);
        // Also draw the centre of the circle, if desired.
        if (drawCentre) {
            Core.circle(image, center, 1, colour, CIRCLE_OUTLINE_THICKNESS, CIRCLE_LINE_TYPE, 0);
        }
    }
}