001/*
002 * Copyright (C) Photon Vision.
003 *
004 * This program is free software: you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License as published by
006 * the Free Software Foundation, either version 3 of the License, or
007 * (at your option) any later version.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
016 */
017
018package org.photonvision.vision.pipe.impl;
019
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.List;
023import org.apache.commons.lang3.tuple.Pair;
024import org.opencv.calib3d.Calib3d;
025import org.opencv.core.*;
026import org.opencv.imgproc.Imgproc;
027import org.opencv.objdetect.CharucoBoard;
028import org.opencv.objdetect.CharucoDetector;
029import org.opencv.objdetect.Objdetect;
030import org.photonvision.common.logging.LogGroup;
031import org.photonvision.common.logging.Logger;
032import org.photonvision.vision.frame.FrameDivisor;
033import org.photonvision.vision.opencv.Releasable;
034import org.photonvision.vision.pipe.CVPipe;
035import org.photonvision.vision.pipeline.UICalibrationData;
036
037public class FindBoardCornersPipe
038        extends CVPipe<
039                Pair<Mat, Mat>,
040                FindBoardCornersPipe.FindBoardCornersPipeResult,
041                FindBoardCornersPipe.FindCornersPipeParams> {
042    private static final Logger logger =
043            new Logger(FindBoardCornersPipe.class, LogGroup.VisionModule);
044
045    MatOfPoint3f objectPoints = new MatOfPoint3f();
046
047    Size imageSize;
048    Size patternSize;
049
050    CharucoBoard board;
051    CharucoDetector detector;
052
053    // Configure the optimizations used while using OpenCV's find corners algorithm
054    // Since we return results in real-time, we want to ensure it goes as fast as
055    // possible
056    // and fails as fast as possible.
057    final int findChessboardFlags =
058            Calib3d.CALIB_CB_NORMALIZE_IMAGE
059                    | Calib3d.CALIB_CB_ADAPTIVE_THRESH
060                    | Calib3d.CALIB_CB_FILTER_QUADS
061                    | Calib3d.CALIB_CB_FAST_CHECK;
062
063    private final MatOfPoint2f boardCorners = new MatOfPoint2f();
064
065    // Intermediate result mat's
066    Mat smallerInFrame = new Mat();
067    MatOfPoint2f smallerBoardCorners = new MatOfPoint2f();
068
069    // SubCornerPix params
070    private final Size zeroZone = new Size(-1, -1);
071    private final TermCriteria criteria = new TermCriteria(3, 30, 0.001);
072
073    private FindCornersPipeParams lastParams = null;
074
075    public void createObjectPoints() {
076        if (this.lastParams != null && this.lastParams.equals(this.params)) return;
077        this.lastParams = this.params;
078
079        this.objectPoints.release();
080        this.objectPoints = null;
081        this.objectPoints = new MatOfPoint3f();
082
083        /*
084         * If using a chessboard, then the pattern size if the inner corners of the
085         * board. For example, the pattern size of a 9x9 chessboard would be 8x8
086         * If using a dot board, then the pattern size width is the sum of the bottom 2
087         * rows and the height is the left or right most column
088         * For example, a 5x4 dot board would have a pattern size of 11x4
089         * We subtract 1 for chessboard because the UI prompts users for the number of
090         * squares, not the
091         * number of corners.
092         */
093        this.patternSize =
094                params.type == UICalibrationData.BoardType.CHESSBOARD
095                        ? new Size(params.boardWidth - 1, params.boardHeight - 1)
096                        : new Size(params.boardWidth, params.boardHeight);
097
098        // Chessboard and dot board have different 3D points to project as a dot board
099        // has alternating
100        // dots per column
101        if (params.type == UICalibrationData.BoardType.CHESSBOARD) {
102            // Here we can create an NxN grid since a chessboard is rectangular
103            for (int heightIdx = 0; heightIdx < patternSize.height; heightIdx++) {
104                for (int widthIdx = 0; widthIdx < patternSize.width; widthIdx++) {
105                    double boardYCoord = heightIdx * params.gridSize;
106                    double boardXCoord = widthIdx * params.gridSize;
107                    objectPoints.push_back(new MatOfPoint3f(new Point3(boardXCoord, boardYCoord, 0.0)));
108                }
109            }
110        } else if (params.type == UICalibrationData.BoardType.CHARUCOBOARD) {
111            board =
112                    new CharucoBoard(
113                            new Size(params.boardWidth, params.boardHeight),
114                            (float) params.gridSize,
115                            (float) params.markerSize,
116                            Objdetect.getPredefinedDictionary(params.tagFamily.getValue()));
117            board.setLegacyPattern(params.useOldPattern);
118            detector = new CharucoDetector(board);
119            detector.getDetectorParameters().set_adaptiveThreshConstant(10);
120            detector.getDetectorParameters().set_adaptiveThreshWinSizeMin(11);
121            detector.getDetectorParameters().set_adaptiveThreshWinSizeStep(40);
122            detector.getDetectorParameters().set_adaptiveThreshWinSizeMax(91);
123
124        } else {
125            logger.error("Can't create pattern for unknown board type " + params.type);
126        }
127    }
128
129    /**
130     * Finds the corners in a given image and returns them
131     *
132     * @param in Input for pipe processing. Pair of input and output mat
133     * @return All valid Mats for camera calibration
134     */
135    @Override
136    protected FindBoardCornersPipeResult process(Pair<Mat, Mat> in) {
137        return findBoardCorners(in);
138    }
139
140    /**
141     * Figures out how much a frame or point cloud must be scaled down by to match the desired size at
142     * which to run FindCorners. Should usually be > 1.
143     *
144     * @param inFrame
145     * @return
146     */
147    private double getFindCornersScaleFactor(Mat inFrame) {
148        return 1.0 / params.divisor.value;
149    }
150
151    /**
152     * Finds the minimum spacing between a set of x/y points Currently only considers points whose
153     * index is next to each other Which, currently, means it traverses one dimension. This is a rough
154     * heuristic approach which could be refined in the future.
155     *
156     * <p>Note that the current implementation can be fooled under the following conditions: (1) The
157     * width of the image is an odd number, and the smallest distance was actually on the between the
158     * last two points in a given row and (2) The smallest distance was actually in the direction
159     * orthogonal to that which was getting traversed by iterating through the MatOfPoint2f in order.
160     *
161     * <p>I've chosen not to handle these for speed's sake, and because, really, you don't need the
162     * exact answer for "min distance". you just need something fairly reasonable.
163     *
164     * @param inPoints point set to analyze. Must be a "tall" matrix.
165     * @return min spacing between neighbors
166     */
167    private double getApproxMinSpacing(MatOfPoint2f inPoints) {
168        double minSpacing = Double.MAX_VALUE;
169        for (int pointIdx = 0; pointIdx < inPoints.height() - 1; pointIdx += 2) {
170            // +1 idx Neighbor distance
171            double[] startPoint = inPoints.get(pointIdx, 0);
172            double[] endPoint = inPoints.get(pointIdx + 1, 0);
173            double deltaX = startPoint[0] - endPoint[0];
174            double deltaY = startPoint[1] - endPoint[1];
175            double distToNext = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
176
177            minSpacing = Math.min(distToNext, minSpacing);
178        }
179        return minSpacing;
180    }
181
182    /**
183     * @param inFrame Full-size mat that is going to get scaled down before passing to
184     *     findBoardCorners
185     * @return the size to scale the input mat to
186     */
187    private Size getFindCornersImgSize(Mat in) {
188        int width = in.cols() / params.divisor.value;
189        int height = in.rows() / params.divisor.value;
190        return new Size(width, height);
191    }
192
193    /**
194     * Given an input frame and a set of points from the "smaller" findChessboardCorner analysis,
195     * re-scale the points back to where they would have been in the input frame
196     *
197     * @param inPoints set of points derived from a call to findChessboardCorner on a shrunken mat.
198     *     Must be a "tall" matrix.
199     * @param origFrame Original frame we're rescaling points back to
200     * @param outPoints mat into which the output rescaled points get placed
201     */
202    private void rescalePointsToOrigFrame(
203            MatOfPoint2f inPoints, Mat origFrame, MatOfPoint2f outPoints) {
204        // Rescale boardCorners back up to the inproc image size
205        Point[] outPointsArr = new Point[inPoints.height()];
206        double sf = getFindCornersScaleFactor(origFrame);
207        for (int pointIdx = 0; pointIdx < inPoints.height(); pointIdx++) {
208            double[] pointCoords = inPoints.get(pointIdx, 0);
209            double outXCoord = pointCoords[0] / sf;
210            double outYCoord = pointCoords[1] / sf;
211            outPointsArr[pointIdx] = new Point(outXCoord, outYCoord);
212        }
213        outPoints.fromArray(outPointsArr);
214    }
215
216    /**
217     * Picks a window size for doing subpixel optimization based on the board type and spacing
218     * observed between the corners or points in the image
219     *
220     * @param inPoints
221     * @return
222     */
223    private Size getWindowSize(MatOfPoint2f inPoints) {
224        double windowHalfWidth = 11; // Dot board uses fixed-size window half-width
225        if (params.type == UICalibrationData.BoardType.CHESSBOARD) {
226            // Chessboard uses a dynamic sized window based on how far apart the corners are
227            windowHalfWidth = Math.floor(getApproxMinSpacing(inPoints) * 0.50);
228            windowHalfWidth = Math.max(1, windowHalfWidth);
229        }
230        return new Size(windowHalfWidth, windowHalfWidth);
231    }
232
233    /**
234     * Find chessboard corners given an input mat and output mat to draw on
235     *
236     * @return Frame resolution, object points, board corners
237     */
238    private FindBoardCornersPipeResult findBoardCorners(Pair<Mat, Mat> in) {
239        createObjectPoints();
240
241        float[] levels = null;
242        var outLevels = new MatOfFloat();
243
244        var objPts = new MatOfPoint3f();
245        var outBoardCorners = new MatOfPoint2f();
246
247        var inFrame = in.getLeft();
248        var outFrame = in.getRight();
249
250        // Convert the inFrame too grayscale to increase contrast
251        Imgproc.cvtColor(inFrame, inFrame, Imgproc.COLOR_BGR2GRAY);
252        boolean boardFound = false;
253
254        // Get the size of the inFrame
255        this.imageSize = new Size(inFrame.width(), inFrame.height());
256
257        if (params.type == UICalibrationData.BoardType.CHARUCOBOARD) {
258            Mat objPoints =
259                    new Mat(); // 3 dimensional currentObjectPoints, the physical target ChArUco Board
260            Mat imgPoints =
261                    new Mat(); // 2 dimensional currentImagePoints, the likely distorted board on the flat
262            // camera sensor frame posed relative to the target
263            Mat detectedCorners = new Mat(); // currentCharucoCorners
264            Mat detectedIds = new Mat(); // currentCharucoIds
265            detector.detectBoard(inFrame, detectedCorners, detectedIds);
266
267            // reformat the Mat to a List<Mat> for matchImagePoints
268            final List<Mat> detectedCornersList = new ArrayList<>();
269            for (int i = 0; i < detectedCorners.total(); i++) {
270                detectedCornersList.add(detectedCorners.row(i));
271            }
272
273            if (detectedCornersList.size()
274                    >= 10) { // We need at least 4 corners to be used for calibration but we force 10 just to
275                // ensure the user cant get away with a garbage calibration.
276                boardFound = true;
277            }
278
279            if (!boardFound) {
280                // If we can't find a board, give up
281                return null;
282            }
283            board.matchImagePoints(detectedCornersList, detectedIds, objPoints, imgPoints);
284
285            // draw the charuco board
286            Objdetect.drawDetectedCornersCharuco(
287                    outFrame, detectedCorners, detectedIds, new Scalar(0, 0, 255)); // Red Text
288
289            imgPoints.copyTo(outBoardCorners);
290            objPoints.copyTo(objPts);
291
292            // Since charuco can still detect without the whole board we need to send "fake" (all
293            // values less than zero) points and then tell it to ignore that corner by setting the
294            // corresponding level to -1. Calibrate3dPipe deals with piping this into the correct format
295            // for each backend
296            {
297                Point[] boardCorners =
298                        new Point[(this.params.boardHeight - 1) * (this.params.boardWidth - 1)];
299                Point3[] objectPoints =
300                        new Point3[(this.params.boardHeight - 1) * (this.params.boardWidth - 1)];
301                levels = new float[(this.params.boardHeight - 1) * (this.params.boardWidth - 1)];
302
303                for (int i = 0; i < detectedIds.total(); i++) {
304                    int id = (int) detectedIds.get(i, 0)[0];
305                    boardCorners[id] = outBoardCorners.toList().get(i);
306                    objectPoints[id] = objPts.toList().get(i);
307                    levels[id] = 1.0f;
308                }
309                for (int i = 0; i < boardCorners.length; i++) {
310                    if (boardCorners[i] == null) {
311                        boardCorners[i] = new Point(-1, -1);
312                        objectPoints[i] = new Point3(-1, -1, -1);
313                        levels[i] = -1.0f;
314                    }
315                }
316
317                outBoardCorners.fromArray(boardCorners);
318                objPts.fromArray(objectPoints);
319                outLevels.fromArray(levels);
320            }
321            imgPoints.release();
322            objPoints.release();
323            detectedCorners.release();
324            detectedIds.release();
325
326        } else { // If not Charuco then do chessboard
327            // Reduce the image size to be much more manageable
328            // Note that opencv will copy the frame if no resize is requested; we can skip
329            // this since we
330            // don't need that copy. See:
331            // https://github.com/opencv/opencv/blob/a8ec6586118c3f8e8f48549a85f2da7a5b78bcc9/modules/imgproc/src/resize.cpp#L4185
332            if (params.divisor != FrameDivisor.NONE) {
333                Imgproc.resize(inFrame, smallerInFrame, getFindCornersImgSize(inFrame));
334            } else {
335                smallerInFrame = inFrame;
336            }
337
338            // Run the chessboard corner finder on the smaller image
339            boardFound =
340                    Calib3d.findChessboardCorners(
341                            smallerInFrame, patternSize, smallerBoardCorners, findChessboardFlags);
342
343            if (!boardFound) {
344                return null;
345            }
346
347            rescalePointsToOrigFrame(smallerBoardCorners, inFrame, boardCorners);
348
349            boardCorners.copyTo(outBoardCorners);
350
351            objectPoints.copyTo(objPts);
352
353            // Do sub corner pix for drawing chessboard when using OpenCV
354            Imgproc.cornerSubPix(
355                    inFrame, outBoardCorners, getWindowSize(outBoardCorners), zeroZone, criteria);
356
357            // draw the chessboard, doesn't have to be different for a dot board since it
358            // just re projects
359            // the corners we found
360            Calib3d.drawChessboardCorners(outFrame, patternSize, outBoardCorners, true);
361
362            levels = new float[(int) objPts.total()];
363            Arrays.fill(levels, 1.0f);
364            outLevels.fromArray(levels);
365        }
366        if (!boardFound) {
367            // If we can't find a chessboard/dot board, give up
368            return null;
369        }
370
371        return new FindBoardCornersPipeResult(inFrame.size(), objPts, outBoardCorners, outLevels);
372    }
373
374    public static class FindCornersPipeParams {
375        final int boardHeight;
376        final int boardWidth;
377        final UICalibrationData.BoardType type;
378        final double gridSize;
379        final double markerSize;
380        final FrameDivisor divisor;
381        final UICalibrationData.TagFamily tagFamily;
382        final boolean useOldPattern;
383
384        public FindCornersPipeParams(
385                int boardHeight,
386                int boardWidth,
387                UICalibrationData.BoardType type,
388                UICalibrationData.TagFamily tagFamily,
389                double gridSize,
390                double markerSize,
391                FrameDivisor divisor,
392                boolean useOldPattern) {
393            this.boardHeight = boardHeight;
394            this.boardWidth = boardWidth;
395            this.tagFamily = tagFamily;
396            this.type = type;
397            this.gridSize = gridSize; // meter
398            this.markerSize = markerSize; // meter
399            this.divisor = divisor;
400            this.useOldPattern = useOldPattern;
401        }
402
403        @Override
404        public int hashCode() {
405            final int prime = 31;
406            int result = 1;
407            result = prime * result + boardHeight;
408            result = prime * result + boardWidth;
409            result = prime * result + ((type == null) ? 0 : type.hashCode());
410            long temp;
411            temp = Double.doubleToLongBits(gridSize);
412            result = prime * result + (int) (temp ^ (temp >>> 32));
413            result = prime * result + ((divisor == null) ? 0 : divisor.hashCode());
414            return result;
415        }
416
417        @Override
418        public boolean equals(Object obj) {
419            if (this == obj) return true;
420            if (obj == null) return false;
421            if (getClass() != obj.getClass()) return false;
422            FindCornersPipeParams other = (FindCornersPipeParams) obj;
423            if (boardHeight != other.boardHeight) return false;
424            if (boardWidth != other.boardWidth) return false;
425            if (tagFamily != other.tagFamily) return false;
426            if (useOldPattern != other.useOldPattern) return false;
427            if (type != other.type) return false;
428            if (Double.doubleToLongBits(gridSize) != Double.doubleToLongBits(other.gridSize))
429                return false;
430            return divisor == other.divisor;
431        }
432    }
433
434    public static class FindBoardCornersPipeResult implements Releasable {
435        public Size size;
436        public MatOfPoint3f objectPoints;
437        public MatOfPoint2f imagePoints;
438        public MatOfFloat levels;
439
440        // Set later only if we need it
441        public Mat inputImage = null;
442
443        public FindBoardCornersPipeResult(
444                Size size, MatOfPoint3f objectPoints, MatOfPoint2f imagePoints, MatOfFloat levels) {
445            this.size = size;
446            this.objectPoints = objectPoints;
447            this.imagePoints = imagePoints;
448            this.levels = levels;
449        }
450
451        @Override
452        public void release() {
453            objectPoints.release();
454            imagePoints.release();
455            levels.release();
456            if (inputImage != null) inputImage.release();
457        }
458    }
459}