From ad4f462fd62fabfe5268ea58f7d5bd69c5d94875 Mon Sep 17 00:00:00 2001 From: amquake Date: Sun, 15 Oct 2023 10:46:55 -0700 Subject: [PATCH] [photon-core] [2024] Cleanup and document coordinate system conversion (#894) * cleanup and document coordinate conversion * spotless * bump wpilib version * coordinate conversion tests * fix/document SolvePNPPipe models and corners * format * run lint --------- Co-authored-by: Matthew Morley --- .../common/util/math/MathUtils.java | 93 +++++++------------ .../vision/pipe/impl/CornerDetectionPipe.java | 61 ++++++------ .../vision/pipe/impl/Draw3dTargetsPipe.java | 31 +++---- .../vision/pipe/impl/SolvePNPPipe.java | 29 +----- .../vision/pipeline/AprilTagPipeline.java | 6 +- .../vision/target/TargetModel.java | 93 ++++++++++--------- .../common/util/CoordinateConversionTest.java | 79 ++++++++++++++++ .../vision/pipeline/AprilTagTest.java | 13 ++- .../vision/pipeline/SolvePNPTest.java | 70 ++++++++------ 9 files changed, 258 insertions(+), 217 deletions(-) create mode 100644 photon-core/src/test/java/org/photonvision/common/util/CoordinateConversionTest.java diff --git a/photon-core/src/main/java/org/photonvision/common/util/math/MathUtils.java b/photon-core/src/main/java/org/photonvision/common/util/math/MathUtils.java index 526311afc5..2b6bb31ae3 100644 --- a/photon-core/src/main/java/org/photonvision/common/util/math/MathUtils.java +++ b/photon-core/src/main/java/org/photonvision/common/util/math/MathUtils.java @@ -17,10 +17,12 @@ package org.photonvision.common.util.math; -import edu.wpi.first.math.MatBuilder; -import edu.wpi.first.math.Nat; +import edu.wpi.first.apriltag.AprilTagPoseEstimate; import edu.wpi.first.math.VecBuilder; -import edu.wpi.first.math.geometry.*; +import edu.wpi.first.math.geometry.CoordinateSystem; +import edu.wpi.first.math.geometry.Pose3d; +import edu.wpi.first.math.geometry.Rotation3d; +import edu.wpi.first.math.geometry.Transform3d; import edu.wpi.first.math.util.Units; import edu.wpi.first.util.WPIUtilJNI; import java.util.Arrays; @@ -137,73 +139,46 @@ public static double lerp(double startValue, double endValue, double t) { return startValue + (endValue - startValue) * t; } - public static Pose3d EDNtoNWU(final Pose3d pose) { - // Change of basis matrix from EDN to NWU. Each column vector is one of the - // old basis vectors mapped to its representation in the new basis. - // - // E (+X) -> N (-Y), D (+Y) -> W (-Z), N (+Z) -> U (+X) - var R = new MatBuilder<>(Nat.N3(), Nat.N3()).fill(0, 0, 1, -1, 0, 0, 0, -1, 0); - - // https://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/ - double w = Math.sqrt(1.0 + R.get(0, 0) + R.get(1, 1) + R.get(2, 2)) / 2.0; - double x = (R.get(2, 1) - R.get(1, 2)) / (4.0 * w); - double y = (R.get(0, 2) - R.get(2, 0)) / (4.0 * w); - double z = (R.get(1, 0) - R.get(0, 1)) / (4.0 * w); - var rotationQuat = new Rotation3d(new Quaternion(w, x, y, z)); - - return new Pose3d( - pose.getTranslation().rotateBy(rotationQuat), pose.getRotation().rotateBy(rotationQuat)); - } - /** - * All our solvepnp code returns a tag with X left, Y up, and Z out of the tag To better match - * wpilib, we want to apply another rotation so that we get Z up, X out of the tag, and Y to the - * right. We apply the following change of basis: X -> Y Y -> Z Z -> X + * OpenCV uses the EDN coordinate system, but WPILib uses NWU. Converts a camera-to-target + * transformation from EDN to NWU. + * + *

Note: The detected target's rvec and tvec perform a rotation-translation transformation + * which converts points in the target's coordinate system to the camera's. This means applying + * the transformation to the target point (0,0,0) for example would give the target's center + * relative to the camera. Conveniently, if we make a translation-rotation transformation out of + * these components instead, we get the transformation from the camera to the target. + * + * @param cameraToTarget3d A camera-to-target Transform3d in EDN. + * @return A camera-to-target Transform3d in NWU. */ - private static final Rotation3d WPILIB_BASE_ROTATION = - new Rotation3d(new MatBuilder<>(Nat.N3(), Nat.N3()).fill(0, 1, 0, 0, 0, 1, 1, 0, 0)); - public static Transform3d convertOpenCVtoPhotonTransform(Transform3d cameraToTarget3d) { // TODO: Refactor into new pipe? - // CameraToTarget _should_ be in opencv-land EDN - var nwu = - CoordinateSystem.convert( - new Pose3d().transformBy(cameraToTarget3d), - CoordinateSystem.EDN(), - CoordinateSystem.NWU()); - return new Transform3d(nwu.getTranslation(), WPILIB_BASE_ROTATION.rotateBy(nwu.getRotation())); - } - - public static Pose3d convertOpenCVtoPhotonPose(Transform3d cameraToTarget3d) { - // TODO: Refactor into new pipe? - // CameraToTarget _should_ be in opencv-land EDN - var nwu = - CoordinateSystem.convert( - new Pose3d().transformBy(cameraToTarget3d), - CoordinateSystem.EDN(), - CoordinateSystem.NWU()); - return new Pose3d(nwu.getTranslation(), WPILIB_BASE_ROTATION.rotateBy(nwu.getRotation())); + return CoordinateSystem.convert( + cameraToTarget3d, CoordinateSystem.EDN(), CoordinateSystem.NWU()); } /* - * The AprilTag pose rotation outputs are X left, Y down, Z away from the tag - * with the tag facing - * the camera upright and the camera facing the target parallel to the floor. - * But our OpenCV - * solvePNP code would have X left, Y up, Z towards the camera with the target - * facing the camera - * and both parallel to the floor. So we apply a base rotation to the rotation - * component of the - * apriltag pose to make it consistent with the EDN system that OpenCV uses, - * internally a 180 - * rotation about the X axis + * From the AprilTag repo: + * "The coordinate system has the origin at the camera center. The z-axis points from the camera + * center out the camera lens. The x-axis is to the right in the image taken by the camera, and + * y is down. The tag's coordinate frame is centered at the center of the tag, with x-axis to the + * right, y-axis down, and z-axis into the tag." + * + * This means our detected transformation will be in EDN. Our subsequent uses of the transformation, + * however, assume the tag's z-axis point away from the tag instead of into it. This means we + * have to correct the transformation's rotation. */ private static final Rotation3d APRILTAG_BASE_ROTATION = - new Rotation3d(VecBuilder.fill(1, 0, 0), Units.degreesToRadians(180)); + new Rotation3d(VecBuilder.fill(0, 1, 0), Units.degreesToRadians(180)); /** - * Apply a 180-degree rotation about X to the rotation component of a given Apriltag pose. This - * aligns it with the OpenCV poses we use in other places. + * AprilTag returns a camera-to-tag transform in EDN, but the tag's z-axis points into the tag + * instead of away from it and towards the camera. This means we have to correct the + * transformation's rotation. + * + * @param pose The Transform3d with translation and rotation directly from the {@link + * AprilTagPoseEstimate}. */ public static Transform3d convertApriltagtoOpenCV(Transform3d pose) { var ocvRotation = APRILTAG_BASE_ROTATION.rotateBy(pose.getRotation()); diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/CornerDetectionPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/CornerDetectionPipe.java index ff7f6fd3ea..bed3c6b5ef 100644 --- a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/CornerDetectionPipe.java +++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/CornerDetectionPipe.java @@ -17,7 +17,6 @@ package org.photonvision.vision.pipe.impl; -import edu.wpi.first.math.geometry.Translation2d; import java.util.*; import org.opencv.core.MatOfPoint2f; import org.opencv.core.Point; @@ -25,6 +24,10 @@ import org.photonvision.vision.pipe.CVPipe; import org.photonvision.vision.target.TrackedTarget; +/** + * Determines the target corners of the {@link TrackedTarget}. The {@link + * CornerDetectionPipeParameters} affect how these corners are calculated. + */ public class CornerDetectionPipe extends CVPipe< List, @@ -49,15 +52,15 @@ protected List process(List targetList) { } /** - * @param target the target to find the corners of. - * @return the corners. left top, left bottom, right bottom, right top + * @param target The target to find the corners of. + * @return Corners: (bottom-left, bottom-right, top-right, top-left) */ private List findBoundingBoxCorners(TrackedTarget target) { // extract the corners var points = new Point[4]; target.m_mainContour.getMinAreaRect().points(points); - // find the tl/tr/bl/br corners + // find the bl/br/tr/tl corners // first, min by left/right var list_ = Arrays.asList(points); list_.sort(leftRightComparator); @@ -68,13 +71,12 @@ private List findBoundingBoxCorners(TrackedTarget target) { var right = new ArrayList<>(List.of(list_.get(2), list_.get(3))); right.sort(verticalComparator); - // tl tr bl br var tl = left.get(0); var bl = left.get(1); var tr = right.get(0); var br = right.get(1); - return List.of(tl, bl, br, tr); + return List.of(bl, br, tr, tl); } /** @@ -83,31 +85,22 @@ private List findBoundingBoxCorners(TrackedTarget target) { * @return The straight line distance between them. */ private static double distanceBetween(Point a, Point b) { - return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); - } - - /** - * @param a First point. - * @param b Second point. - * @return The straight line distance between them. - */ - private static double distanceBetween(Translation2d a, Translation2d b) { - return Math.sqrt(Math.pow(a.getX() - b.getX(), 2) + Math.pow(a.getY() - b.getY(), 2)); + double xDelta = a.x - b.x; + double yDelta = a.y - b.y; + return Math.sqrt(xDelta * xDelta + yDelta * yDelta); } /** - * Find the 4 most extreme corners, + * Find the 4 most extreme corners of the target's contour. * - * @param target the target to track. - * @param convexHull weather to use the convex hull of the target. - * @return the 4 extreme corners of the contour. + * @param target The target to track. + * @param convexHull Whether to use the convex hull of the contour instead. + * @return The 4 extreme corners of the contour: (bottom-left, bottom-right, top-right, top-left) */ private List detectExtremeCornersByApproxPolyDp(TrackedTarget target, boolean convexHull) { var centroid = target.getMinAreaRect().center; - Comparator distanceProvider = - Comparator.comparingDouble( - (Point point) -> - Math.sqrt(Math.pow(centroid.x - point.x, 2) + Math.pow(centroid.y - point.y, 2))); + Comparator compareCenterDist = + Comparator.comparingDouble((Point point) -> distanceBetween(centroid, point)); MatOfPoint2f targetContour; if (convexHull) { @@ -141,17 +134,17 @@ private List detectExtremeCornersByApproxPolyDp(TrackedTarget target, boo // left top, left bottom, right bottom, right top var boundingBoxCorners = findBoundingBoxCorners(target); - var distanceToTlComparator = - Comparator.comparingDouble((Point p) -> distanceBetween(p, boundingBoxCorners.get(0))); - - var distanceToTrComparator = + var compareDistToTl = Comparator.comparingDouble((Point p) -> distanceBetween(p, boundingBoxCorners.get(3))); - // top left and top right are the poly corners closest to the bounding box tl and tr - pointList.sort(distanceToTlComparator); + var compareDistToTr = + Comparator.comparingDouble((Point p) -> distanceBetween(p, boundingBoxCorners.get(2))); + + // top left and top right are the poly corners closest to the bouding box tl and tr + pointList.sort(compareDistToTl); var tl = pointList.get(0); pointList.remove(tl); - pointList.sort(distanceToTrComparator); + pointList.sort(compareDistToTr); var tr = pointList.get(0); pointList.remove(tr); @@ -177,11 +170,11 @@ private List detectExtremeCornersByApproxPolyDp(TrackedTarget target, boo } } if (leftList.isEmpty() || rightList.isEmpty()) return null; - leftList.sort(distanceProvider); - rightList.sort(distanceProvider); + leftList.sort(compareCenterDist); + rightList.sort(compareCenterDist); var bl = leftList.get(leftList.size() - 1); var br = rightList.get(rightList.size() - 1); - return List.of(tl, bl, br, tr); + return List.of(bl, br, tr, tl); } public static class CornerDetectionPipeParameters { diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Draw3dTargetsPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Draw3dTargetsPipe.java index b88bd0ba17..99927ea0f1 100644 --- a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Draw3dTargetsPipe.java +++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Draw3dTargetsPipe.java @@ -127,7 +127,7 @@ protected Void process(Pair> in) { // Draw X, Y and Z axis MatOfPoint3f pointMat = new MatOfPoint3f(); - // Those points are in opencv-land, but we are in NWU + // OpenCV expects coordinates in EDN, but we want to visualize in NWU // NWU | EDN // X: Z // Y: -X @@ -136,11 +136,15 @@ protected Void process(Pair> in) { var list = List.of( new Point3(0, 0, 0), - new Point3(0, 0, AXIS_LEN), - new Point3(AXIS_LEN, 0, 0), - new Point3(0, AXIS_LEN, 0)); + new Point3(0, 0, AXIS_LEN), // x-axis + new Point3(-AXIS_LEN, 0, 0), // y-axis + new Point3(0, -AXIS_LEN, 0)); // z-axis pointMat.fromList(list); + // The detected target's rvec and tvec perform a rotation-translation transformation which + // converts points in the target's coordinate system to the camera's. This means applying + // the transformation to the target point (0,0,0) for example would give the target's center + // relative to the camera. Calib3d.projectPoints( pointMat, target.getCameraRelativeRvec(), @@ -152,19 +156,22 @@ protected Void process(Pair> in) { var axisPoints = tempMat.toList(); dividePointList(axisPoints); - // Red = x, green y, blue z + // XYZ is RGB + // y-axis = green Imgproc.line( in.getLeft(), axisPoints.get(0), axisPoints.get(2), ColorHelper.colorToScalar(Color.GREEN), 3); + // z-axis = blue Imgproc.line( in.getLeft(), axisPoints.get(0), axisPoints.get(3), ColorHelper.colorToScalar(Color.BLUE), 3); + // x-axis = red Imgproc.line( in.getLeft(), axisPoints.get(0), @@ -172,6 +179,7 @@ protected Void process(Pair> in) { ColorHelper.colorToScalar(Color.RED), 3); + // box edges perpendicular to tag for (int i = 0; i < bottomPoints.size(); i++) { Imgproc.line( in.getLeft(), @@ -180,6 +188,7 @@ protected Void process(Pair> in) { ColorHelper.colorToScalar(Color.blue), 3); } + // box edges parallel to tag for (int i = 0; i < topPoints.size(); i++) { Imgproc.line( in.getLeft(), @@ -265,18 +274,6 @@ private void divideMat2f(MatOfPoint2f src, MatOfPoint dst) { dst.fromArray(pointArray); } - private void divideMat2f(MatOfPoint2f src, MatOfPoint2f dst) { - var hull = src.toArray(); - var pointArray = new Point[hull.length]; - for (int i = 0; i < hull.length; i++) { - var hullAtI = hull[i]; - pointArray[i] = - new Point( - hullAtI.x / (double) params.divisor.value, hullAtI.y / (double) params.divisor.value); - } - dst.fromArray(pointArray); - } - /** Scale a given point list by the current frame divisor. the point list is mutated! */ private void dividePointList(List points) { for (var p : points) { diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/SolvePNPPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/SolvePNPPipe.java index db773f6375..6a887d3a18 100644 --- a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/SolvePNPPipe.java +++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/SolvePNPPipe.java @@ -18,7 +18,6 @@ package org.photonvision.vision.pipe.impl; import edu.wpi.first.math.VecBuilder; -import edu.wpi.first.math.geometry.Pose3d; import edu.wpi.first.math.geometry.Rotation3d; import edu.wpi.first.math.geometry.Transform3d; import edu.wpi.first.math.geometry.Translation3d; @@ -27,7 +26,6 @@ import org.opencv.core.Core; import org.opencv.core.Mat; import org.opencv.core.MatOfPoint2f; -import org.opencv.core.Scalar; import org.photonvision.common.logging.LogGroup; import org.photonvision.common.logging.Logger; import org.photonvision.common.util.math.MathUtils; @@ -99,33 +97,12 @@ private void calculateTargetPose(TrackedTarget target) { VecBuilder.fill(rVec.get(0, 0)[0], rVec.get(1, 0)[0], rVec.get(2, 0)[0]), Core.norm(rVec)); - Pose3d targetPose = MathUtils.convertOpenCVtoPhotonPose(new Transform3d(translation, rotation)); - target.setBestCameraToTarget3d( - new Transform3d(targetPose.getTranslation(), targetPose.getRotation())); + Transform3d camToTarget = + MathUtils.convertOpenCVtoPhotonTransform(new Transform3d(translation, rotation)); + target.setBestCameraToTarget3d(camToTarget); target.setAltCameraToTarget3d(new Transform3d()); } - Mat rotationMatrix = new Mat(); - Mat inverseRotationMatrix = new Mat(); - Mat pzeroWorld = new Mat(); - Mat kMat = new Mat(); - Mat scaledTvec; - - /** - * Element-wise scale a matrix by a given factor - * - * @param src the source matrix - * @param factor by how much to scale each element - * @return the scaled matrix - */ - @SuppressWarnings("SameParameterValue") - private static Mat matScale(Mat src, double factor) { - Mat dst = new Mat(src.rows(), src.cols(), src.type()); - Scalar s = new Scalar(factor); - Core.multiply(src, s, dst); - return dst; - } - public static class SolvePNPPipeParams { private final CameraCalibrationCoefficients cameraCoefficients; private final TargetModel targetModel; diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/AprilTagPipeline.java b/photon-core/src/main/java/org/photonvision/vision/pipeline/AprilTagPipeline.java index af1c5145d4..c7ffe1a83c 100644 --- a/photon-core/src/main/java/org/photonvision/vision/pipeline/AprilTagPipeline.java +++ b/photon-core/src/main/java/org/photonvision/vision/pipeline/AprilTagPipeline.java @@ -146,8 +146,10 @@ protected CVPipelineResult process(Frame frame, AprilTagPipelineSettings setting new TargetCalculationParameters( false, null, null, null, null, frameStaticProperties)); - var correctedBestPose = MathUtils.convertOpenCVtoPhotonPose(target.getBestCameraToTarget3d()); - var correctedAltPose = MathUtils.convertOpenCVtoPhotonPose(target.getAltCameraToTarget3d()); + var correctedBestPose = + MathUtils.convertOpenCVtoPhotonTransform(target.getBestCameraToTarget3d()); + var correctedAltPose = + MathUtils.convertOpenCVtoPhotonTransform(target.getAltCameraToTarget3d()); target.setBestCameraToTarget3d( new Transform3d(correctedBestPose.getTranslation(), correctedBestPose.getRotation())); diff --git a/photon-core/src/main/java/org/photonvision/vision/target/TargetModel.java b/photon-core/src/main/java/org/photonvision/vision/target/TargetModel.java index ea06638af8..198cbd5139 100644 --- a/photon-core/src/main/java/org/photonvision/vision/target/TargetModel.java +++ b/photon-core/src/main/java/org/photonvision/vision/target/TargetModel.java @@ -25,37 +25,50 @@ import org.opencv.core.MatOfPoint3f; import org.opencv.core.Point3; import org.photonvision.vision.opencv.Releasable; - +import org.photonvision.vision.pipe.impl.CornerDetectionPipe; +import org.photonvision.vision.pipe.impl.SolvePNPPipe; + +/** + * A model representing the vertices of targets with known shapes. The vertices are in the EDN + * coordinate system. When creating a TargetModel, the vertices must be supplied in a certain order + * to ensure correct correspondence with corners detected in 2D for use with SolvePNP. For planar + * targets, we expect the target's Z-axis to point towards the camera. + * + *

{@link SolvePNPPipe} expects 3d object points to correspond to the {@link CornerDetectionPipe} + * implementation. The 2d corner detection finds the 4 extreme corners (bottom-left, bottom-right, + * top-right, top-left). To match our expectations, this means the model vertices would look like: + * + *

+ * + *

AprilTag models are currently only used for drawing on the output stream. + */ public enum TargetModel implements Releasable { - k2020HighGoalOuter( - List.of( - new Point3(Units.inchesToMeters(-19.625), 0, 0), - new Point3(Units.inchesToMeters(-9.819867), Units.inchesToMeters(-17), 0), - new Point3(Units.inchesToMeters(9.819867), Units.inchesToMeters(-17), 0), - new Point3(Units.inchesToMeters(19.625), 0, 0)), - Units.inchesToMeters(12)), - k2020HighGoalInner( + k2016HighGoal( List.of( - new Point3(Units.inchesToMeters(-19.625), 0, Units.inchesToMeters(2d * 12d + 5.25)), - new Point3( - Units.inchesToMeters(-9.819867), - Units.inchesToMeters(-17), - Units.inchesToMeters(2d * 12d + 5.25)), - new Point3( - Units.inchesToMeters(9.819867), - Units.inchesToMeters(-17), - Units.inchesToMeters(2d * 12d + 5.25)), - new Point3(Units.inchesToMeters(19.625), 0, Units.inchesToMeters(2d * 12d + 5.25))), - Units.inchesToMeters(12)), - + new Point3(Units.inchesToMeters(10), Units.inchesToMeters(6), 0), + new Point3(Units.inchesToMeters(-10), Units.inchesToMeters(6), 0), + new Point3(Units.inchesToMeters(-10), Units.inchesToMeters(-6), 0), + new Point3(Units.inchesToMeters(10), Units.inchesToMeters(-6), 0)), + Units.inchesToMeters(6)), k2019DualTarget( List.of( - new Point3(Units.inchesToMeters(-5.936), Units.inchesToMeters(2.662), 0), - new Point3(Units.inchesToMeters(-7.313), Units.inchesToMeters(-2.662), 0), - new Point3(Units.inchesToMeters(7.313), Units.inchesToMeters(-2.662), 0), - new Point3(Units.inchesToMeters(5.936), Units.inchesToMeters(2.662), 0)), + new Point3(Units.inchesToMeters(7.313), Units.inchesToMeters(2.662), 0), + new Point3(Units.inchesToMeters(-7.313), Units.inchesToMeters(2.662), 0), + new Point3(Units.inchesToMeters(-5.936), Units.inchesToMeters(-2.662), 0), + new Point3(Units.inchesToMeters(5.936), Units.inchesToMeters(-2.662), 0)), 0.1), - + k2020HighGoalOuter( + List.of( + new Point3(Units.inchesToMeters(9.819867), Units.inchesToMeters(8.5), 0), + new Point3(Units.inchesToMeters(-9.819867), Units.inchesToMeters(8.5), 0), + new Point3(Units.inchesToMeters(-19.625), Units.inchesToMeters(-8.5), 0), + new Point3(Units.inchesToMeters(19.625), Units.inchesToMeters(-8.5), 0)), + Units.inchesToMeters(12)), kCircularPowerCell7in( List.of( new Point3( @@ -94,36 +107,26 @@ public enum TargetModel implements Releasable { -Units.inchesToMeters(9.5) / 2, -Units.inchesToMeters(9.5) / 2)), 0), - k2016HighGoal( - List.of( - new Point3(Units.inchesToMeters(-10), Units.inchesToMeters(12), 0), - new Point3(Units.inchesToMeters(-10), Units.inchesToMeters(0), 0), - new Point3(Units.inchesToMeters(10), Units.inchesToMeters(0), 0), - new Point3(Units.inchesToMeters(10), Units.inchesToMeters(12), 0)), - Units.inchesToMeters(6)), - k200mmAprilTag( // Nominal edge length of 200 mm includes the white border, but solvePNP corners - // do not + k200mmAprilTag( // Corners of the tag's inner black square (excluding white border) List.of( - new Point3(-Units.inchesToMeters(3.25), Units.inchesToMeters(3.25), 0), new Point3(Units.inchesToMeters(3.25), Units.inchesToMeters(3.25), 0), - new Point3(Units.inchesToMeters(3.25), -Units.inchesToMeters(3.25), 0), - new Point3(-Units.inchesToMeters(3.25), -Units.inchesToMeters(3.25), 0)), + new Point3(-Units.inchesToMeters(3.25), Units.inchesToMeters(3.25), 0), + new Point3(-Units.inchesToMeters(3.25), -Units.inchesToMeters(3.25), 0), + new Point3(Units.inchesToMeters(3.25), -Units.inchesToMeters(3.25), 0)), Units.inchesToMeters(3.25 * 2)), - kAruco6in_16h5( // Nominal edge length of 200 mm includes the white border, but solvePNP corners - // do not + kAruco6in_16h5( // Corners of the tag's inner black square (excluding white border) List.of( new Point3(Units.inchesToMeters(3), Units.inchesToMeters(3), 0), new Point3(Units.inchesToMeters(3), -Units.inchesToMeters(3), 0), new Point3(-Units.inchesToMeters(3), -Units.inchesToMeters(3), 0), new Point3(Units.inchesToMeters(3), -Units.inchesToMeters(3), 0)), Units.inchesToMeters(3 * 2)), - k6in_16h5( // Nominal edge length of 200 mm includes the white border, but solvePNP corners - // do not + k6in_16h5( // Corners of the tag's inner black square (excluding white border) List.of( - new Point3(-Units.inchesToMeters(3), Units.inchesToMeters(3), 0), new Point3(Units.inchesToMeters(3), Units.inchesToMeters(3), 0), - new Point3(Units.inchesToMeters(3), -Units.inchesToMeters(3), 0), - new Point3(-Units.inchesToMeters(3), -Units.inchesToMeters(3), 0)), + new Point3(-Units.inchesToMeters(3), Units.inchesToMeters(3), 0), + new Point3(-Units.inchesToMeters(3), -Units.inchesToMeters(3), 0), + new Point3(Units.inchesToMeters(3), -Units.inchesToMeters(3), 0)), Units.inchesToMeters(3 * 2)); @JsonIgnore private MatOfPoint3f realWorldTargetCoordinates; diff --git a/photon-core/src/test/java/org/photonvision/common/util/CoordinateConversionTest.java b/photon-core/src/test/java/org/photonvision/common/util/CoordinateConversionTest.java new file mode 100644 index 0000000000..a452f517f5 --- /dev/null +++ b/photon-core/src/test/java/org/photonvision/common/util/CoordinateConversionTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) Photon Vision. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.photonvision.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import edu.wpi.first.math.VecBuilder; +import edu.wpi.first.math.geometry.Rotation3d; +import edu.wpi.first.math.geometry.Transform3d; +import edu.wpi.first.math.geometry.Translation3d; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.photonvision.common.util.math.MathUtils; + +public class CoordinateConversionTest { + @BeforeAll + public static void Init() { + TestUtils.loadLibraries(); + } + + @Test + public void testAprilTagToOpenCV() { + // AprilTag and OpenCV both use the EDN coordinate system. AprilTag, however, assumes the tag's + // z-axis points away from the camera while we expect it to point towards the camera. + var apriltag = + new Transform3d( + new Translation3d(1, 2, 3), + new Rotation3d(Math.toRadians(5), Math.toRadians(10), Math.toRadians(15))); + var opencv = MathUtils.convertApriltagtoOpenCV(apriltag); + final var expectedTrl = new Translation3d(1, 2, 3); + assertEquals( + expectedTrl, opencv.getTranslation(), "AprilTag to OpenCV translation conversion failed"); + var apriltagXaxis = new Translation3d(1, 0, 0).rotateBy(apriltag.getRotation()); + var apriltagYaxis = new Translation3d(0, 1, 0).rotateBy(apriltag.getRotation()); + var apriltagZaxis = new Translation3d(0, 0, 1).rotateBy(apriltag.getRotation()); + var opencvXaxis = new Translation3d(1, 0, 0).rotateBy(opencv.getRotation()); + var opencvYaxis = new Translation3d(0, 1, 0).rotateBy(opencv.getRotation()); + var opencvZaxis = new Translation3d(0, 0, 1).rotateBy(opencv.getRotation()); + assertEquals( + apriltagXaxis.unaryMinus(), + opencvXaxis, + "AprilTag to OpenCV rotation conversion failed(X-axis)"); + assertEquals( + apriltagYaxis, opencvYaxis, "AprilTag to OpenCV rotation conversion failed(Y-axis)"); + assertEquals( + apriltagZaxis.unaryMinus(), + opencvZaxis, + "AprilTag to OpenCV rotation conversion failed(Z-axis)"); + } + + @Test + public void testOpenCVToPhoton() { + // OpenCV uses the EDN coordinate system while wpilib is in NWU. + var opencv = + new Transform3d( + new Translation3d(1, 2, 3), new Rotation3d(VecBuilder.fill(1, 2, 3), Math.PI / 8)); + var wpilib = MathUtils.convertOpenCVtoPhotonTransform(opencv); + final var expectedTrl = new Translation3d(3, -1, -2); + assertEquals( + expectedTrl, wpilib.getTranslation(), "OpenCV to WPILib translation conversion failed"); + var expectedRot = new Rotation3d(VecBuilder.fill(3, -1, -2), Math.PI / 8); + assertEquals(expectedRot, wpilib.getRotation(), "OpenCV to WPILib rotation conversion failed"); + } +} diff --git a/photon-core/src/test/java/org/photonvision/vision/pipeline/AprilTagTest.java b/photon-core/src/test/java/org/photonvision/vision/pipeline/AprilTagTest.java index 1ece69fc55..fe1b7b9d9c 100644 --- a/photon-core/src/test/java/org/photonvision/vision/pipeline/AprilTagTest.java +++ b/photon-core/src/test/java/org/photonvision/vision/pipeline/AprilTagTest.java @@ -75,20 +75,19 @@ public void testApriltagFacingCamera() { // these numbers are not *accurate*, but they are known and expected var pose = pipelineResult.targets.get(0).getBestCameraToTarget3d(); Assertions.assertEquals(2, pose.getTranslation().getX(), 0.2); - Assertions.assertEquals(0.0, pose.getTranslation().getY(), 0.2); + Assertions.assertEquals(0.1, pose.getTranslation().getY(), 0.2); Assertions.assertEquals(0.0, pose.getTranslation().getZ(), 0.2); - var objX = new Translation3d(1, 0, 0).rotateBy(pose.getRotation()).getY(); - var objY = new Translation3d(0, 1, 0).rotateBy(pose.getRotation()).getZ(); - var objZ = new Translation3d(0, 0, 1).rotateBy(pose.getRotation()).getX(); + // We expect the object axes to be in NWU, with the x-axis coming out of the tag + // This visible tag is facing the camera almost parallel, so in world space: - // We expect the object X to be forward, or -X in world space + // The object's X axis should be (-1, 0, 0) Assertions.assertEquals( -1, new Translation3d(1, 0, 0).rotateBy(pose.getRotation()).getX(), 0.1); - // We expect the object Y axis to be right, or negative-Y in world space + // The object's Y axis should be (0, -1, 0) Assertions.assertEquals( -1, new Translation3d(0, 1, 0).rotateBy(pose.getRotation()).getY(), 0.1); - // We expect the object Z axis to be up, or +Z in world space + // The object's Z axis should be (0, 0, 1) Assertions.assertEquals(1, new Translation3d(0, 0, 1).rotateBy(pose.getRotation()).getZ(), 0.1); } diff --git a/photon-core/src/test/java/org/photonvision/vision/pipeline/SolvePNPTest.java b/photon-core/src/test/java/org/photonvision/vision/pipeline/SolvePNPTest.java index 02852308b9..03bcc2535d 100644 --- a/photon-core/src/test/java/org/photonvision/vision/pipeline/SolvePNPTest.java +++ b/photon-core/src/test/java/org/photonvision/vision/pipeline/SolvePNPTest.java @@ -19,11 +19,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import edu.wpi.first.math.geometry.Rotation3d; import edu.wpi.first.math.geometry.Translation3d; import edu.wpi.first.math.util.Units; import java.util.stream.Collectors; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.photonvision.common.util.TestUtils; @@ -115,28 +116,31 @@ public void test2019() { pipeline.getSettings().hueInverted); frameProvider.requestHsvSettings(hsvParams); - CVPipelineResult pipelineResult; - - pipelineResult = pipeline.run(frameProvider.get(), QuirkyCamera.DefaultCamera); + CVPipelineResult pipelineResult = pipeline.run(frameProvider.get(), QuirkyCamera.DefaultCamera); printTestResults(pipelineResult); - // these numbers are not *accurate*, but they are known and expected - var pose = pipelineResult.targets.get(0).getBestCameraToTarget3d(); - Assertions.assertEquals(1.1, pose.getTranslation().getX(), 0.05); - Assertions.assertEquals(0.0, pose.getTranslation().getY(), 0.05); - - // We expect the object X to be forward, or -X in world space - Assertions.assertEquals( - -1, new Translation3d(1, 0, 0).rotateBy(pose.getRotation()).getX(), 0.05); - // We expect the object Y axis to be right, or negative-Y in world space - Assertions.assertEquals( - -1, new Translation3d(0, 1, 0).rotateBy(pose.getRotation()).getY(), 0.05); - // We expect the object Z axis to be up, or +Z in world space - Assertions.assertEquals( - 1, new Translation3d(0, 0, 1).rotateBy(pose.getRotation()).getZ(), 0.05); + // Draw on input + var outputPipe = new OutputStreamPipeline(); + outputPipe.process( + pipelineResult.inputAndOutputFrame, pipeline.getSettings(), pipelineResult.targets); TestUtils.showImage( - pipelineResult.inputAndOutputFrame.colorImage.getMat(), "Pipeline output", 999999); + pipelineResult.inputAndOutputFrame.processedImage.getMat(), "Pipeline output", 999999); + + var pose = pipelineResult.targets.get(0).getBestCameraToTarget3d(); + // these numbers are not *accurate*, but they are known and expected + var expectedTrl = new Translation3d(1.1, -0.05, -0.05); + assertTrue( + expectedTrl.getDistance(pose.getTranslation()) < 0.05, + "SolvePNP translation estimation failed"); + // We expect the object axes to be in NWU, with the x-axis coming out of the tag + // This target is facing the camera almost parallel, so in world space: + // The object's X axis should be (-1, 0, 0) + assertEquals(-1, new Translation3d(1, 0, 0).rotateBy(pose.getRotation()).getX(), 0.05); + // The object's Y axis should be (0, -1, 0) + assertEquals(-1, new Translation3d(0, 1, 0).rotateBy(pose.getRotation()).getY(), 0.05); + // The object's Z axis should be (0, 0, 1) + assertEquals(1, new Translation3d(0, 0, 1).rotateBy(pose.getRotation()).getZ(), 0.05); } @Test @@ -175,15 +179,27 @@ public void test2020() { outputPipe.process( pipelineResult.inputAndOutputFrame, pipeline.getSettings(), pipelineResult.targets); - // these numbers are not *accurate*, but they are known and expected - var pose = pipelineResult.targets.get(0).getBestCameraToTarget3d(); - Assertions.assertEquals(Units.inchesToMeters(240.26), pose.getTranslation().getX(), 0.05); - Assertions.assertEquals(Units.inchesToMeters(35), pose.getTranslation().getY(), 0.05); - // Z rotation should be mostly facing us - Assertions.assertEquals(Units.degreesToRadians(-140), pose.getRotation().getZ(), 1); - TestUtils.showImage( - pipelineResult.inputAndOutputFrame.colorImage.getMat(), "Pipeline output", 999999); + pipelineResult.inputAndOutputFrame.processedImage.getMat(), "Pipeline output", 999999); + + var pose = pipelineResult.targets.get(0).getBestCameraToTarget3d(); + // these numbers are not *accurate*, but they are known and expected + var expectedTrl = + new Translation3d( + Units.inchesToMeters(236), Units.inchesToMeters(36), Units.inchesToMeters(-53)); + assertTrue( + expectedTrl.getDistance(pose.getTranslation()) < 0.05, + "SolvePNP translation estimation failed"); + // We expect the object axes to be in NWU, with the x-axis coming out of the tag + // Rotation around Z axis (yaw) should be mostly facing us + var xAxis = new Translation3d(1, 0, 0); + var yAxis = new Translation3d(0, 1, 0); + var zAxis = new Translation3d(0, 0, 1); + var expectedRot = + new Rotation3d(Math.toRadians(-20), Math.toRadians(-20), Math.toRadians(-120)); + assertTrue(xAxis.rotateBy(expectedRot).getDistance(xAxis.rotateBy(pose.getRotation())) < 0.1); + assertTrue(yAxis.rotateBy(expectedRot).getDistance(yAxis.rotateBy(pose.getRotation())) < 0.1); + assertTrue(zAxis.rotateBy(expectedRot).getDistance(zAxis.rotateBy(pose.getRotation())) < 0.1); } private static void continuouslyRunPipeline(Frame frame, ReflectivePipelineSettings settings) {