/* * Copyright (c) 2011-2016, Peter Abeles. All Rights Reserved. * * This file is part of BoofCV (http://boofcv.org). * * 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 boofcv.alg.fiducial.calib.circle; import boofcv.abst.filter.binary.InputToBinary; import boofcv.alg.fiducial.calib.circle.EllipseClustersIntoAsymmetricGrid.Grid; import boofcv.alg.shapes.ellipse.BinaryEllipseDetector; import boofcv.struct.image.GrayU8; import boofcv.struct.image.ImageGray; import georegression.struct.point.Point2D_F64; import georegression.struct.shapes.EllipseRotated_F64; import org.ddogleg.struct.FastQueue; import java.util.ArrayList; import java.util.List; /** * <p>Detects asymmetric grids of circles. The grid is composed of two regular grids which are offset by half a period. * See image below for an example. Rows and columns are counted by counting every row even if they are offset * from each other. The returned grid will be put into canonical orientation, see below.</p> * * <p> * For each circle there is one control point. The control point is first found by detecting all the ellipses, which * is what a circle appears to be under perspective distortion. The center the ellipse might not match the physical * center of the circle. The intersection of lines does not change under perspective distortion. The outer common * tangent lines between neighboring ellipses are found. Then the intersection of two such lines is found. This * intersection will be the physical center of the circle. * </p> * * <center> * <img src="doc-files/asymcirclegrid.jpg"/> * </center> * Example of a 8 by 5 grid; row, column. * * <p>Canonical orientation is defined as having the rows/columns matched, element (0,0) being occupied. * If there are multiple solution a solution will be selected which is in counter-clockwise order (image coordinates) * and if there is still ambiguity the ellipse closest to the image origin will be selected as (0,0).</p> * * @author Peter Abeles */ public class DetectAsymmetricCircleGrid<T extends ImageGray> { private BinaryEllipseDetector<T> ellipseDetector; private InputToBinary<T> inputToBinary; private GrayU8 binary = new GrayU8(1,1); // description of the calibration target private int numRows, numCols; // converts ellipses into clusters private EllipsesIntoClusters clustering; private EllipseClustersIntoAsymmetricGrid grider; // List of all the found valid grids in the image private List<Grid> validGrids = new ArrayList<>(); // local work space for manipulating the order of points inside a grid private List<EllipseRotated_F64> work = new ArrayList<>(); // storage for found clusters private List<List<EllipsesIntoClusters.Node>> clusters = new ArrayList<>(); private List<List<EllipsesIntoClusters.Node>> clustersPruned = new ArrayList<>(); // verbose printing to standard out private boolean verbose = false; /** * Creates and configures the detector * * @param numRows number of rows in grid * @param numCols number of columns in grid * @param inputToBinary Converts the input image into a binary image * @param ellipseDetector Detects ellipses inside the image * @param clustering Finds clusters of ellipses */ public DetectAsymmetricCircleGrid(int numRows, int numCols, InputToBinary<T> inputToBinary, BinaryEllipseDetector<T> ellipseDetector, EllipsesIntoClusters clustering) { this.ellipseDetector = ellipseDetector; this.inputToBinary = inputToBinary; this.numRows = numRows; this.numCols = numCols; this.clustering = clustering; this.grider = new EllipseClustersIntoAsymmetricGrid(); } /** * Processes the image and finds grids. To retrieve the found grids call {@link #getGrids()} * @param gray Input image */ public void process(T gray) { if( verbose) System.out.println("ENTER DetectAsymmetricCircleGrid.process()"); this.binary.reshape(gray.width,gray.height); inputToBinary.process(gray, binary); ellipseDetector.process(gray, binary); List<EllipseRotated_F64> found = ellipseDetector.getFoundEllipses().toList(); if( verbose) System.out.println(" Found "+found.size()+" ellpises"); clusters.clear(); clustering.process(found, clusters); clustersPruned.clear(); clustersPruned.addAll(clusters); if( verbose) System.out.println(" Found "+clusters.size()+" clusters"); pruneIncorrectSize(clustersPruned, totalEllipses(numRows,numCols) ); if( verbose) System.out.println(" Remaining clusters after pruning "+clustersPruned.size()); grider.process(found, clustersPruned); FastQueue<Grid> grids = grider.getGrids(); if( verbose) System.out.println(" Found "+grids.size()+" grids"); pruneIncorrectShape(grids,numRows,numCols); if( verbose) System.out.println(" Remaining grids after pruning "+grids.size()); validGrids.clear(); for (int i = 0; i < grids.size(); i++) { Grid g = grids.get(i); putGridIntoCanonical(g); validGrids.add( g ); } if( verbose) System.out.println("EXIT DetectAsymmetricCircleGrid.process()"); } /** * Computes the number of ellipses on the grid */ public static int totalEllipses( int numRows , int numCols ) { return (numRows/2)*(numCols/2) + ((numRows+1)/2)*((numCols+1)/2); } /** * Puts the grid into a canonical orientation */ void putGridIntoCanonical(Grid g ) { // first put it into a plausible solution if( g.columns != numCols ) { rotateGridCCW(g); } if( g.get(0,0) == null ) { reverse(g); } // select the best corner for canonical if( g.columns%2 == 1 && g.rows%2 == 1) { // first make sure orientation constraint is maintained if( isClockWise(g)) { flipHorizontal(g); } int numRotationsCCW = closestCorner4(g); if( g.columns == g.rows ) { for (int i = 0; i < numRotationsCCW; i++) { rotateGridCCW(g); } } else if( numRotationsCCW == 2 ){ // only two valid solutions. rotate only if the other valid solution is better rotateGridCCW(g); rotateGridCCW(g); } } else if( g.columns%2 == 1 ) { // only two solutions. Go with the one which maintains orientation constraint if( isClockWise(g)) { flipHorizontal(g); } } else if( g.rows%2 == 1 ) { // only two solutions. Go with the one which maintains orientation constraint if( isClockWise(g)) { flipVertical(g); } } } /** * Uses the cross product to determine if the grid is in clockwise order */ private static boolean isClockWise( Grid g ) { EllipseRotated_F64 v00 = g.get(0,0); EllipseRotated_F64 v02 = g.columns<3?g.get(1,1):g.get(0,2); EllipseRotated_F64 v20 = g.rows<3?g.get(1,1):g.get(2,0); double a_x = v02.center.x - v00.center.x; double a_y = v02.center.y - v00.center.y; double b_x = v20.center.x - v00.center.x; double b_y = v20.center.y - v00.center.y; return a_x * b_y - a_y * b_x < 0; } /** * Number of CCW rotations to put selected corner into the canonical location. Only works * when there are 4 possible solutions * @param g The grid * @return number of rotations */ static int closestCorner4(Grid g ) { double bestDistance = g.get(0,0).center.normSq(); int bestIdx = 0; double d = g.get(0,g.columns-1).center.normSq(); if( d < bestDistance ) { bestDistance = d; bestIdx = 3; } d = g.get(g.rows-1,g.columns-1).center.normSq(); if( d < bestDistance ) { bestDistance = d; bestIdx = 2; } d = g.get(g.rows-1,0).center.normSq(); if( d < bestDistance ) { bestIdx = 1; } return bestIdx; } /** * performs a counter-clockwise rotation */ void rotateGridCCW( Grid g ) { work.clear(); for (int i = 0; i < g.rows * g.columns; i++) { work.add(null); } for (int row = 0; row < g.rows; row++) { for (int col = 0; col < g.columns; col++) { work.set(col*g.rows + row, g.get(g.rows - row - 1,col)); } } g.ellipses.clear(); g.ellipses.addAll(work); int tmp = g.columns; g.columns = g.rows; g.rows = tmp; } /** * Reverse the order of elements inside the grid */ void reverse( Grid g ) { work.clear(); int N = g.rows*g.columns; for (int i = 0; i < N; i++) { work.add( g.ellipses.get(N-i-1)); } g.ellipses.clear(); g.ellipses.addAll(work); } void flipHorizontal( Grid g ) { work.clear(); for (int row = 0; row < g.rows; row++) { for (int col = 0; col < g.columns; col++) { work.add(g.get(row, g.columns - col - 1)); } } g.ellipses.clear(); g.ellipses.addAll(work); } void flipVertical( Grid g ) { work.clear(); for (int row = 0; row < g.rows; row++) { for (int col = 0; col < g.columns; col++) { work.add(g.get(g.rows-row-1, col)); } } g.ellipses.clear(); g.ellipses.addAll(work); } /** * Remove grids which cannot possible match the expected shape */ static void pruneIncorrectShape(FastQueue<Grid> grids , int numRows, int numCols ) { // prune clusters which can't be a member calibration target for (int i = grids.size()-1; i >= 0; i--) { Grid g = grids.get(i); if ((g.rows != numRows || g.columns != numCols) && (g.rows != numCols || g.columns != numRows)) { grids.remove(i); } } } /** * Prune clusters which do not have the expected number of elements */ static void pruneIncorrectSize(List<List<EllipsesIntoClusters.Node>> clusters, int N) { // prune clusters which can't be a member calibration target for (int i = clusters.size()-1; i >= 0; i--) { if( clusters.get(i).size() != N ) { clusters.remove(i); } } } public BinaryEllipseDetector<T> getEllipseDetector() { return ellipseDetector; } public EllipseClustersIntoAsymmetricGrid getGrider() { return grider; } public EllipsesIntoClusters getClustering() { return clustering; } public List<List<EllipsesIntoClusters.Node>> getClusters() { return clusters; } public List<List<EllipsesIntoClusters.Node>> getClustersPruned() { return clustersPruned; } /** * List of grids found inside the image */ public List<Grid> getGrids() { return validGrids; } public List<Point2D_F64> getCalibrationPoints() { return null; } public GrayU8 getBinary() { return binary; } public int getColumns() { return numCols; } public int getRows() { return numRows; } public boolean isVerbose() { return verbose; } public void setVerbose(boolean verbose) { this.ellipseDetector.setVerbose(verbose); this.grider.setVerbose(verbose); this.verbose = verbose; } }