Java tutorial
/* * 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); } } }